From 644f8fa7942470ed1aafa866f2c813c0b7aae4a8 Mon Sep 17 00:00:00 2001 From: liuxy0551 Date: Mon, 7 Sep 2026 10:40:32 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20agent=20=E5=B8=82=E5=9C=BA=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E4=B8=BA=20codex-plugin=20=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E6=B8=85=E7=90=86=E5=86=97=E4=BD=99=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=8E=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入从 .codex-plugin/plugin.json 读取元数据,去掉 agent.yaml - logo 从包内 assets/logo.png 读取落盘,前端回退首字母占位 - 删除 demo_images 列、entrypoint_host/type/name/ref 列、agent_skills 表 - 删除 demo gallery / 内置 Skills / 依赖 Skills 前端展示 - 移除 agent_skill 模型与 entrypoint 编排逻辑 Co-Authored-By: Claude Opus 4.8 --- app/controller/agents.js | 6 - app/model/agent.js | 20 - app/model/agent_skill.js | 64 -- app/router.js | 1 - app/service/agents.js | 724 +++++------------- app/web/api/url.ts | 4 - app/web/pages/agents/codex-button-utils.js | 2 +- .../agents/detail/AgentDetailContent.tsx | 335 ++------ app/web/pages/agents/detail/intro-utils.js | 5 +- app/web/pages/agents/detail/style.scss | 199 +---- app/web/pages/agents/index.tsx | 23 +- app/web/pages/agents/types.ts | 28 +- sql/doraemon.sql | 25 +- test/agent-codex-button-utils.test.js | 9 +- test/agent-detail-layout.test.js | 329 ++------ test/agent-detail-plugin-contract.test.js | 30 + test/agent-intro-utils.test.js | 20 +- test/agent-market-controller.test.js | 15 - test/agent-market-service.test.js | 499 +++--------- test/agent-plugin-contract.test.js | 157 ++++ 20 files changed, 613 insertions(+), 1882 deletions(-) delete mode 100644 app/model/agent_skill.js create mode 100644 test/agent-detail-plugin-contract.test.js create mode 100644 test/agent-plugin-contract.test.js diff --git a/app/controller/agents.js b/app/controller/agents.js index e945de1..bd14e72 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); 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 deleted file mode 100644 index 09b68e6..0000000 --- a/app/model/agent_skill.js +++ /dev/null @@ -1,64 +0,0 @@ -module.exports = (app) => { - const { INTEGER, STRING, DATE } = app.Sequelize; - - const AgentSkill = app.model.define( - 'agent_skill', - { - id: { - type: INTEGER, - primaryKey: true, - autoIncrement: true, - }, - agent_id: { - type: INTEGER, - allowNull: false, - comment: 'agents.id', - }, - 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: '展示顺序', - }, - 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: 'agent_skills', - timestamps: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - indexes: [ - { fields: ['agent_id'] }, - { fields: ['skill_slug'] }, - { fields: ['relation_type'] }, - ], - } - ); - - return AgentSkill; -}; diff --git a/app/router.js b/app/router.js index a30a813..19e93d8 100644 --- a/app/router.js +++ b/app/router.js @@ -167,7 +167,6 @@ module.exports = (app) => { */ app.get('/api/agents/list', app.controller.agents.getAgentList); app.get('/api/agents/detail', app.controller.agents.getAgentDetail); - app.get('/api/agents/related', app.controller.agents.getRelatedAgents); app.get('/api/agents/asset', app.controller.agents.getAgentAsset); app.get('/api/agents/download', app.controller.agents.downloadAgentArchive); app.post('/api/agents/import-file', app.controller.agents.importAgentFile); diff --git a/app/service/agents.js b/app/service/agents.js index 895ad45..f55bab0 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -3,14 +3,9 @@ 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 } = require('../utils/skill-utils'); const { isValidSkillCategory, SKILL_CATEGORY_OPTIONS, @@ -47,14 +42,13 @@ class AgentsService extends Service { } this.storageReadyPromise = (async () => { - const { Agent, AgentFile, AgentSkill } = this.app.model; - if (!Agent || !AgentFile || !AgentSkill) { + const { Agent, AgentFile } = this.app.model; + if (!Agent || !AgentFile) { this.ctx.throw(500, 'Agent 数据模型未加载'); } await Agent.sync(); await AgentFile.sync(); - await AgentSkill.sync(); this.storageReady = true; })(); @@ -73,19 +67,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 +150,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 +196,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 不能为空'); + this.ctx.throw(400, 'author.name 不能为空'); } - const profile = String(spec.profile || '').trim(); - if (!profile) { - this.ctx.throw(400, 'spec.profile 不能为空'); + 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 logoPath = this.normalizeAgentPath(metadata.logo, 'metadata.logo 路径非法'); - if (!fileMap.has(logoPath)) { - this.ctx.throw(400, `Logo 文件不存在: ${logoPath}`); - } - - 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 +258,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 +380,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 +401,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,48 +433,54 @@ 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/')) @@ -610,28 +505,18 @@ class AgentsService extends Service { 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, + logo, contentHash, fileCount: fileRecords.length, }, - logo, - demoImages, files, - skillRelations: this.buildSkillRelations(validated.name, manifest), - assetFiles: [logo, ...demoImages], + assetFiles, }; } @@ -664,19 +549,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 +565,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 +615,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 +649,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,22 +667,6 @@ 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 }); - } - return { id: agentId, name: parsed.agent.name, @@ -854,7 +697,6 @@ class AgentsService extends Service { } toAgentListItem(row) { - const dependencies = this.parseJsonArray(row.dependencies || '[]'); return { name: row.name, displayName: row.display_name, @@ -864,14 +706,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 +746,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, @@ -946,8 +759,7 @@ class AgentsService extends Service { 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,196 +770,24 @@ 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 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); - 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}` : '', - }; - }); - 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() : '', }; } - async getRelatedAgents(name, limit = 3) { - await this.ensureStorageReady(); - const { Agent, AgentSkill } = this.app.model; - const target = await Agent.findOne({ - where: { - name, - is_delete: 0, - }, - }); - if (!target) { - 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] = []; - } - 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, - }, - candidates, - limit - ); - } - async getAgentAssetStream(params = {}) { await this.ensureStorageReady(); const name = String(params.name || '').trim(); @@ -1163,14 +803,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) { @@ -1240,7 +876,7 @@ class AgentsService extends Service { this.ctx.throw(400, 'Agent 名称不能为空'); } - const { Agent, AgentFile, AgentSkill } = this.app.model; + const { Agent, AgentFile } = this.app.model; const row = await Agent.findOne({ where: { name, @@ -1263,10 +899,6 @@ class AgentsService extends Service { where: { agent_id: row.id }, transaction, }); - await AgentSkill.destroy({ - where: { agent_id: row.id }, - transaction, - }); }); try { diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 685ba0b..0e406d0 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -413,10 +413,6 @@ export default { method: 'get', url: '/api/agents/detail', }, - getRelatedAgents: { - method: 'get', - url: '/api/agents/related', - }, downloadAgentArchive: { method: 'get', url: '/api/agents/download', 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..2cff684 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -3,23 +3,18 @@ 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 { AgentCapability, AgentDetail } from '../types'; import './style.scss'; const { Paragraph, Text, Title } = Typography; -const { TabPane } = Tabs; const { normalizeAgentCapabilities } = require('./capability-utils'); const { buildAgentIntroBlocks } = require('./intro-utils'); @@ -28,94 +23,24 @@ interface AgentDetailContentProps { 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 }; -}> = ({ item, history }) => ( - history.push(`/page/agents/${item.name}`)} - > -
- {item.displayName} { - event.currentTarget.style.visibility = 'hidden'; - }} - /> -
- {item.displayName} - {item.description || '暂无描述'} -
-
-
-); - 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 }), - API.getRelatedAgents({ name, limit: 3 }), - ]); + const detailRes = await API.getAgentDetail({ name }); if (cancelled) return; setDetail(detailRes.success ? (detailRes.data as AgentDetail) : null); - setRelated(relatedRes.success ? relatedRes.data || [] : []); } catch (error) { console.error('获取 Agent 详情失败:', error); if (!cancelled) { setDetail(null); - setRelated([]); } } finally { if (!cancelled) { @@ -133,12 +58,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 +109,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}
@@ -212,113 +137,35 @@ const AgentDetailContent: React.FC = ({ name, history }
- - - - 概览 - - } - 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.description || '暂无描述'} +
+
- - Agent 演示 - {detail.demoImages.length > 0 ? ( -
-
- {detail.demoImages.map((item, index) => ( - - ))} -
-
- { -
-
- ) : ( - - )} -
-
- + + 能力范围 + {normalizedCapabilities.length > 0 ? ( +
+ {normalizedCapabilities.map( + (item: AgentCapability, index: number) => ( + + {item.name} + + ) + )} +
+ ) : ( + + )} +
- - - Agent 简介 - - } - key="profile" - >
@@ -338,25 +185,6 @@ const AgentDetailContent: React.FC = ({ name, history }
-
-
- 开场消息 -
- -
-
- -
- - {introBlocks.openingMessage || '暂无开场消息'} - -
-
-
-
开场问题 @@ -411,62 +239,8 @@ 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} -
-
- +
+
diff --git a/app/web/pages/agents/detail/intro-utils.js b/app/web/pages/agents/detail/intro-utils.js index f7bf66a..aab7299 100644 --- a/app/web/pages/agents/detail/intro-utils.js +++ b/app/web/pages/agents/detail/intro-utils.js @@ -9,9 +9,8 @@ function splitParagraphs(content) { function buildAgentIntroBlocks(detail = {}) { return { - introParagraphs: splitParagraphs(detail.profile), - openingMessage: String(detail.description || detail.summary || '').trim(), - openingQuestions: Array.isArray(detail.prompts) ? detail.prompts : [], + introParagraphs: splitParagraphs(detail.longDescription), + openingQuestions: Array.isArray(detail.defaultPrompt) ? detail.defaultPrompt : [], }; } diff --git a/app/web/pages/agents/detail/style.scss b/app/web/pages/agents/detail/style.scss index 3916ab4..0dd651a 100644 --- a/app/web/pages/agents/detail/style.scss +++ b/app/web/pages/agents/detail/style.scss @@ -19,14 +19,10 @@ } .agent-hero, .agent-section-card, - .agent-side-actions, - .agent-side-related { + .agent-side-actions { border-radius: 20px; border: 1px solid #EDF0F5; } - .agent-side-related { - margin-top: 20px; - } .agent-side-actions { .ant-card-head { min-height: 48px; @@ -135,6 +131,7 @@ .agent-hero-logo { width: 88px; height: 88px; + flex: 0 0 88px; border-radius: 24px; object-fit: cover; background: linear-gradient(135deg, #EFF4FF, #EEF2FF); @@ -161,102 +158,18 @@ flex-wrap: wrap; gap: 8px; } - .agent-detail-tabs { - .ant-tabs-nav { - margin-bottom: 16px; - } + .agent-detail-content { + min-width: 0; } .agent-section-stack { display: flex; flex-direction: column; gap: 12px; } - .agent-capability-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - } - .agent-capability-card { - position: relative; - min-width: 0; - padding: 10px 12px; - border: 1px solid #E9EEF5; - border-radius: 10px; - .ant-typography:first-child { - display: block; - margin-bottom: 2px; - color: #1F2937; - font-size: 15px; - line-height: 1.4; - } - .ant-typography:last-child { - margin-top: 4px; - margin-bottom: 0; - color: #667085; - line-height: 1.5; - font-size: 13px; - } - } - .agent-capability-card-index { - position: absolute; - top: 10px; - right: 12px; - color: #98A2B3; - font-size: 14px; - line-height: 1; - font-weight: 500; - font-variant-numeric: tabular-nums; - letter-spacing: 0.02em; - } .agent-prompts { display: grid; gap: 12px; } - .agent-demo-gallery { - display: flex; - flex-direction: column; - gap: 16px; - } - .agent-demo-thumbnails { - display: flex; - gap: 10px; - overflow-x: auto; - padding: 2px; - } - .agent-demo-thumbnail { - width: 132px; - height: 80px; - flex: 0 0 132px; - padding: 4px; - overflow: hidden; - cursor: pointer; - border: 2px solid transparent; - border-radius: 10px; - background: #F8FAFC; - transition: border-color 0.2s ease; - &.is-active { - border-color: #3F86F7; - } - img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; - } - } - .agent-demo-preview { - width: min(80%, 960px); - margin: 0 auto; - border-radius: 16px; - overflow: hidden; - background: #F8FAFC; - border: 1px solid #EDF0F5; - img { - display: block; - width: 100%; - height: auto; - } - } .agent-overview-description { color: #344054; } @@ -310,33 +223,11 @@ flex: 0 0 32px; border-radius: 10px; font-size: 16px; - &.is-message { - color: #F97316; - background: rgba(249, 115, 22, 0.12); - } &.is-question { color: #F59E0B; background: rgba(245, 158, 11, 0.12); } } - .agent-message-card { - .ant-card-body { - padding: 16px 18px; - } - .agent-message-card-body { - display: grid; - grid-template-columns: 32px minmax(0, 1fr); - align-items: center; - gap: 12px; - } - .ant-typography { - margin-bottom: 0; - flex: 1; - color: #344054; - line-height: 2; - font-size: 15px; - } - } .agent-prompts-compact { display: flex; flex-direction: column; @@ -419,100 +310,18 @@ color: #F59E0B; font-size: 14px; } - .agent-skill-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 12px; - } - .agent-skill-card { - padding: 14px 16px; - border-radius: 14px; - border: 1px solid #E9EEF5; - box-shadow: none; - background: linear-gradient(180deg, #FFF 0%, #FBFDFF 100%); - .ant-card-body { - padding: 0; - } - &.is-clickable { - cursor: pointer; - } - &.is-disabled { - cursor: default; - opacity: 0.78; - } - } - .agent-skill-card-title { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - margin-bottom: 6px; - span:first-child { - font-size: 15px; - line-height: 1.5; - font-weight: 600; - color: #1F2937; - } - .ant-tag { - margin-right: 0; - } - } - .agent-skill-card-description { - margin-bottom: 0; - color: #667085; - font-size: 14px; - line-height: 1.6; - } - .related-agent-list { - display: flex; - flex-direction: column; - gap: 12px; - } - .related-agent-card { - border-radius: 14px; - } - .related-agent-head { - display: flex; - gap: 12px; - align-items: flex-start; - } - .related-agent-logo { - width: 44px; - height: 44px; - border-radius: 12px; - object-fit: cover; - background: linear-gradient(135deg, #EFF4FF, #EEF2FF); - } - .related-agent-meta { - min-width: 0; - .ant-typography { - margin-bottom: 0; - } - } @media screen and (max-width: 992px) { padding: 16px; .agent-detail-shell { grid-template-columns: 1fr; } - .agent-capability-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } @media screen and (max-width: 768px) { .agent-hero { padding: 20px; } - .agent-skill-grid { - grid-template-columns: 1fr; - } - .agent-capability-grid { - grid-template-columns: 1fr; - } - .agent-demo-preview { - width: 100%; - } .agent-section-card { .ant-card-body { padding: 16px 18px; diff --git a/app/web/pages/agents/index.tsx b/app/web/pages/agents/index.tsx index b6a5a99..6c246ce 100644 --- a/app/web/pages/agents/index.tsx +++ b/app/web/pages/agents/index.tsx @@ -270,14 +270,16 @@ const AgentMarket: React.FC = ({ history }) => { >
- {agent.displayName} { - event.currentTarget.style.visibility = 'hidden'; - }} - /> + {agent.logoUrl ? ( + {agent.displayName} { + event.currentTarget.style.display = 'none'; + }} + /> + ) : null}
{agent.displayName}
@@ -316,9 +318,6 @@ const AgentMarket: React.FC = ({ history }) => {
版本 {agent.version || '-'} - - 内置 Skills {agent.dependencyCount} -
))} @@ -353,7 +352,7 @@ const AgentMarket: React.FC = ({ history }) => { > - 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `agent.yaml` 自动解析。 + 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `.codex-plugin/plugin.json` 自动解析。 { displayName: 'Bug 修复 Agent', name: 'bugfix-agent', - prompts: [{ title: '默认问题', prompt: '$bugfix-workflow 默认' }], + defaultPrompt: [{ title: '默认问题', prompt: '$bugfix-workflow 默认' }], }, 'http://10.10.10.168:7001/page/agents/bugfix-agent', { title: '自然语言', prompt: '帮我修 bug,禅道 Bug ID 是 156343' } diff --git a/test/agent-detail-layout.test.js b/test/agent-detail-layout.test.js index cbcfbd7..f81ea1b 100644 --- a/test/agent-detail-layout.test.js +++ b/test/agent-detail-layout.test.js @@ -3,295 +3,80 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -test('Agent 简介页不再重复渲染 category 标签,并包含消息/问题图标结构', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - - assert.equal( - content.includes('{detail.category}'), - false, - '不应重复渲染 category 标签' - ); - assert.equal( - content.includes('agent-intro-icon-wrap is-message'), - true, - '开场消息区域需要消息图标' - ); - assert.equal(content.includes('agent-intro-count'), true, '开场问题数量需要和标题同行展示'); - assert.equal( - content.includes('{introBlocks.openingQuestions.length} 个'), - true, - '开场问题数量需要显示为 N 个' - ); -}); - -test('概览页不再重复渲染示例问题区块', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - - assert.equal( - content.includes('Title level={4}>示例问题'), - false, - '概览页不应继续渲染示例问题区块' - ); -}); - -test('概览描述和 Agent 简介正文字号为 16px', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); - - assert.equal( - content.includes('.agent-overview-description') && content.includes('font-size: 16px;'), - true, - '概览描述字号需要是 16px' - ); - assert.equal( - content.includes('.agent-profile-copy') && content.includes('font-size: 16px;'), - true, - 'Agent 简介正文字号需要是 16px' - ); -}); - -test('概览描述复用 Agent 简介正文容器样式', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - - assert.equal( - content.includes( - '
' - ), - true, - '概览描述需要使用与 Agent 简介相同的正文容器' - ); -}); - -test('Agent 详情页自身负责滚动,避免高内容区被父层裁剪', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); - - assert.equal(content.includes('overflow: auto;'), true, 'Agent 详情页需要显式开启滚动'); - assert.equal( - content.includes('max-width: 1300px;') && content.includes('margin: 0 auto;'), - true, - 'Agent 详情内容需要限制为 1300px 并居中展示' - ); -}); - -test('Agent 演示使用缩略图切换当前图片并限制完整图宽度', () => { - const componentContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - const styleContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); - - assert.equal( - componentContent.includes('agent-demo-thumbnails') && - componentContent.includes('setSelectedDemoIndex(index)') && - componentContent.includes('agent-demo-preview'), - true, - 'Agent 演示需要提供缩略图切换和当前图片预览' - ); - assert.equal( - componentContent.includes('description="暂无演示图片"'), - true, - 'Agent 演示无图片时需要展示空态' - ); - assert.equal( - styleContent.includes('width: min(80%, 960px);'), - true, - '当前演示图片需要限制展示宽度' - ); -}); - -test('Agent 简介页三块内容间距为 16px,消息和问题卡片使用双列布局', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); - - assert.equal( - content.includes('.agent-intro-sections') && content.includes('gap: 16px;'), - true, - '三块内容区域之间需要是 16px 间距' - ); - assert.equal( - content.includes('grid-template-columns: 32px minmax(0, 1fr);'), - true, - '图标和文案需要用双列布局保持在一行' - ); +const COMPONENT_PATH = path.join( + __dirname, + '../app/web/pages/agents/detail/AgentDetailContent.tsx' +); +const STYLE_PATH = path.join(__dirname, '../app/web/pages/agents/detail/style.scss'); + +function readDetailFiles() { + return { + component: fs.readFileSync(COMPONENT_PATH, 'utf8'), + style: fs.readFileSync(STYLE_PATH, 'utf8'), + }; +} + +test('Agent 详情页使用 plugin 的长描述和默认 prompt', () => { + const { component } = readDetailFiles(); + + assert.match(component, /detail\?\.longDescription/); + assert.match(component, /detail\?\.defaultPrompt/); + assert.match(component, /openingQuestions/); + assert.doesNotMatch(component, /开场消息|openingMessage/); }); -test('概览页的 Agent 能力使用紧凑网格卡片,不再渲染纵向列表', () => { - const componentContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - const styleContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); +test('Agent 详情页移除非 plugin 的关系和演示字段', () => { + const { component, style } = readDetailFiles(); - assert.equal( - componentContent.includes('agent-capability-grid'), - true, - 'Agent 能力区块需要使用紧凑网格' - ); - assert.equal(componentContent.includes(' { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); +test('概览页保留短描述和能力标签', () => { + const { component } = readDetailFiles(); - assert.equal( - content.includes('.agent-skill-grid') && - content.includes('grid-template-columns: repeat(2, minmax(0, 1fr));'), - true, - '内置 Skills 需要使用双列紧凑网格' - ); - assert.equal( - content.includes('.agent-skill-card') && content.includes('padding: 14px 16px;'), - true, - '内置 Skills 卡片需要收紧内边距' - ); - assert.equal( - content.includes('.agent-skill-card-title') && - content.includes('span:first-child') && - content.includes('font-size: 15px;'), - true, - '内置 Skills 标题字号需要提升' - ); - assert.equal( - content.includes('.agent-skill-card-description') && content.includes('font-size: 15px;'), - true, - '内置 Skills 描述字号需要提升' - ); + assert.match(component, /detail\.description/); + assert.match(component, /agent-capability-tags/); + assert.doesNotMatch(component, /agent-capability-grid| { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); +test('详情页自身负责滚动并限制内容宽度', () => { + const { style } = readDetailFiles(); - assert.equal( - content.includes('related.length > 0') && content.includes('description="暂无相关 Agent"'), - true, - '相关 Agent 为空时需要展示明确的空态' - ); + assert.match(style, /overflow: auto;/); + assert.match(style, /max-width: 1300px;/); + assert.match(style, /margin: 0 auto;/); }); -test('Agent 详情右侧展示可复制的自动安装命令', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - const styleContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); +test('Agent 详情页右侧提供可复制的自动安装命令', () => { + const { component, style } = readDetailFiles(); - assert.equal( - content.includes('curl -fsSL ${currentOrigin}/agent-market/install.sh') && - content.includes('| bash -s -- ${detail.name}'), - true, - '安装命令需要根据当前站点地址和 Agent 名称动态拼接' - ); - assert.equal( - content.includes('agent-install-terminal') && - content.includes('copyToClipboard(') && - content.includes('installCommand,') && - content.includes("'Agent 安装命令已复制到剪贴板'"), - true, - '右侧安装面板需要展示终端命令并支持复制' - ); - assert.equal(content.includes("message.info('敬请期待')"), false, '不应继续展示安装占位按钮'); - assert.equal( - styleContent.includes('.ant-btn.agent-install-copy') && - styleContent.includes('border-color: transparent;') && - styleContent.includes('background: transparent;') && - styleContent.includes('box-shadow: none;'), - true, - '终端复制按钮需要清除 Ant Design 的默认白底、边框和阴影' - ); + assert.match(component, /curl -fsSL \$\{currentOrigin\}\/agent-market\/install\.sh/); + assert.match(component, /\| bash -s -- \$\{detail\.name\}/); + assert.match(component, /copyToClipboard\(/); + assert.match(component, /Agent 安装命令已复制到剪贴板/); + assert.doesNotMatch(component, /敬请期待/); + assert.match(style, /\.ant-btn\.agent-install-copy/); }); test('Agent 开场问题卡片提供调起 Codex 的快捷使用入口', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); - const styleContent = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), - 'utf8' - ); - - assert.equal( - content.includes('buildAgentDetailCodexPrompt') && - content.includes('buildCodexNewThreadUrl') && - content.includes('className="agent-question-quick-use"') && - content.includes('agent-question-quick-use-icon') && - content.includes('onClick') && - content.includes('openCodexInstall(item)') && - content.includes('快捷使用'), - true, - '开场问题卡片需要提供快捷使用按钮并调起 Codex' - ); - assert.equal( - content.includes('className="agent-quick-use"'), - false, - '右侧不应再展示独立快捷使用按钮' - ); - assert.equal( - styleContent.includes('.agent-question-quick-use') && - styleContent.includes('grid-template-columns: 32px minmax(0, 1fr);') && - styleContent.includes('align-items: center;') && - styleContent.includes('&:hover,') && - styleContent.includes('transform: translateY(-1px);') && - styleContent.includes('border-color: #D8DFEA;') && - styleContent.includes('.agent-question-quick-use-icon') && - !styleContent.includes('border: 1px solid #F59E0B;'), - true, - '开场问题快捷使用按钮需要左右布局、垂直居中并提供克制的 hover 样式' - ); + const { component, style } = readDetailFiles(); + + assert.match(component, /buildAgentDetailCodexPrompt/); + assert.match(component, /buildCodexNewThreadUrl/); + assert.match(component, /className="agent-question-quick-use"/); + assert.match(component, /openCodexInstall\(item\)/); + assert.match(component, /快捷使用/); + assert.match(style, /\.agent-question-quick-use/); }); -test('Agent 详情右侧提供当前原始 ZIP 下载入口', () => { - const content = fs.readFileSync( - path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), - 'utf8' - ); +test('Agent 详情页提供当前原始 ZIP 下载入口', () => { + const { component } = readDetailFiles(); - assert.equal( - content.includes('/api/agents/download?name=${encodeURIComponent(detail.name)}') && - content.includes('下载 Agent ZIP'), - true, - '详情页需要提供当前 Agent 原始 ZIP 下载按钮' + assert.match( + component, + /\/api\/agents\/download\?name=\$\{encodeURIComponent\(detail\.name\)\}/ ); + assert.match(component, /下载 Agent ZIP/); }); diff --git a/test/agent-detail-plugin-contract.test.js b/test/agent-detail-plugin-contract.test.js new file mode 100644 index 0000000..138e3c6 --- /dev/null +++ b/test/agent-detail-plugin-contract.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +test('Agent 详情页只展示 plugin manifest 字段', () => { + const component = fs.readFileSync( + path.join(ROOT, 'app/web/pages/agents/detail/AgentDetailContent.tsx'), + 'utf8' + ); + const types = fs.readFileSync(path.join(ROOT, 'app/web/pages/agents/types.ts'), 'utf8'); + const api = fs.readFileSync(path.join(ROOT, 'app/web/api/url.ts'), 'utf8'); + + assert.match(component, /detail\??\.longDescription/); + assert.match(component, /detail\??\.defaultPrompt/); + assert.match(component, /agent-detail-content/); + assert.doesNotMatch(component, / { +test('buildAgentIntroBlocks 将 longDescription、defaultPrompt 拆成两个展示块', () => { const result = buildAgentIntroBlocks({ - profile: '第一段\n\n第二段', - description: '欢迎告诉我你当前要处理的 Bug。', - prompts: [ + longDescription: '第一段\n\n第二段', + defaultPrompt: [ { title: '仅分析', prompt: '$bugfix-workflow 分析这个 Bug,但先不要修改代码' }, { title: '恢复任务', prompt: '$bugfix-workflow 继续处理上一次未完成的 Bug' }, ], }); assert.deepEqual(result.introParagraphs, ['第一段', '第二段']); - assert.equal(result.openingMessage, '欢迎告诉我你当前要处理的 Bug。'); assert.equal(result.openingQuestions.length, 2); + assert.equal('openingMessage' in result, false); }); -test('buildAgentIntroBlocks 缺少开场消息时回退到列表摘要', () => { +test('buildAgentIntroBlocks 缺少长描述和默认 prompt 时返回空展示块', () => { const result = buildAgentIntroBlocks({ - profile: '简介', - description: '', - summary: '这是摘要', - prompts: [], + longDescription: '', + defaultPrompt: [], }); - assert.equal(result.openingMessage, '这是摘要'); + assert.deepEqual(result.introParagraphs, []); + assert.deepEqual(result.openingQuestions, []); }); diff --git a/test/agent-market-controller.test.js b/test/agent-market-controller.test.js index dfb9aab..b0a695b 100644 --- a/test/agent-market-controller.test.js +++ b/test/agent-market-controller.test.js @@ -56,21 +56,6 @@ test('getAgentDetail 返回统一 response 包装', async () => { assert.deepEqual(controller.ctx.body.data, { name: 'bugfix-agent' }); }); -test('getRelatedAgents 透传 limit 参数', async () => { - const controller = buildController({ - getRelatedAgents: async (name, limit) => { - assert.equal(name, 'bugfix-agent'); - assert.equal(limit, '2'); - return [{ name: 'review-agent' }]; - }, - }); - controller.ctx.query = { name: 'bugfix-agent', limit: '2' }; - - await controller.getRelatedAgents(); - assert.equal(controller.ctx.body.success, true); - assert.equal(controller.ctx.body.data.length, 1); -}); - test('downloadAgentArchive 返回 ZIP 文件流和下载响应头', async () => { const headers = {}; const stream = { pipe() {} }; diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js index a89b359..ab083a1 100644 --- a/test/agent-market-service.test.js +++ b/test/agent-market-service.test.js @@ -36,126 +36,62 @@ function createService() { return service; } -function createAgentZip(manifestOverrides = {}, extraEntries = []) { +function createPluginZip({ + codexManifest: codexOverrides = {}, + claudeManifest: claudeOverrides = {}, + includeClaudeManifest = true, + logoPath = 'assets/logo.png', + extraEntries = [], +} = {}) { const zip = new AdmZip(); const root = 'bugfix-agent'; - const manifest = { - apiVersion: 'doraemon.dtstack.com/v1', - kind: 'Agent', - metadata: { - name: 'bugfix-agent', + const codexManifest = { + name: root, + version: '1.0.0', + description: 'Agent 简短描述', + author: { name: 'DTStack' }, + keywords: ['Bugfix', 'Review'], + skills: './skills/', + interface: { displayName: 'Bugfix Agent', - version: '1.0.0', - logo: './assets/logo.png', - description: 'Agent 简短描述', - author: { - name: 'DTStack', - }, - category: '工程效率', - tags: ['Bugfix', 'Review'], + longDescription: '负责 Bug 分析、修复和回归验证', + developerName: 'DTStack', + category: 'Coding', + capabilities: ['分析 Bug', '修复代码'], + defaultPrompt: ['$bugfix-workflow 156343 dataApi/release_6.0.x'], + logo: `./${logoPath}`, }, - spec: { - profile: '负责 Bug 分析、修复和回归验证', - capabilities: ['分析 Bug', '修复代码', '推动回归'], - prompts: [ - { - title: '修复 Bug 并部署 OMP online 环境', - prompt: '$bugfix-workflow 156343 dataApi 6.0.x,使用来源分支 dataApi/release_6.0.x,并部署到匹配的 OMP online 环境', - }, - { - title: '仅分析 Bug', - prompt: '分析 Bug 156372,应用 batch,版本 6.2.x,只做根因分析,先不要修改代码', - }, - { - title: '指定 hotfix 与负责人', - prompt: '$bugfix-workflow 156460 stream 6.2.x hotfix zhaoge', - }, - ], - demo: { - images: [ - { - path: './assets/demo1.png', - alt: 'Bugfix Agent Demo 1', - }, - { - path: './assets/demo2.png', - alt: 'Bugfix Agent Demo 2', - }, - ], - }, - entrypoint: { - host: 'codex', - type: 'skill', - name: 'bugfix-workflow', - ref: './skills/bugfix-workflow', - }, - dependencies: { - skills: ['systematic-debugging', 'gitlab-mr-code-review'], - }, - }, - ...manifestOverrides, + ...codexOverrides, + }; + const claudeManifest = { + name: root, + version: '1.0.0', + description: 'Agent 简短描述', + author: { name: 'DTStack' }, + agents: ['./agents/claude/bugfix-worker.md'], + ...claudeOverrides, }; - const yaml = [ - 'apiVersion: doraemon.dtstack.com/v1', - 'kind: Agent', - 'metadata:', - ` name: ${manifest.metadata.name}`, - ` displayName: ${manifest.metadata.displayName}`, - ` version: ${manifest.metadata.version}`, - ` logo: ${manifest.metadata.logo}`, - ` description: ${manifest.metadata.description}`, - ' author:', - ` name: ${manifest.metadata.author.name}`, - ` category: ${manifest.metadata.category}`, - ' tags:', - ...manifest.metadata.tags.map((tag) => ` - ${tag}`), - 'spec:', - ` profile: ${manifest.spec.profile}`, - ' capabilities:', - ...manifest.spec.capabilities.map((item) => ` - ${item}`), - ' prompts:', - ...manifest.spec.prompts.flatMap((item) => [ - ` - title: ${item.title}`, - ` prompt: ${item.prompt}`, - ]), - ' demo:', - ' images:', - ...manifest.spec.demo.images.flatMap((item) => [ - ` - src: ${item.path}`, - ` alt: ${item.alt}`, - ]), - ' entrypoint:', - ` host: ${manifest.spec.entrypoint.host}`, - ` type: ${manifest.spec.entrypoint.type}`, - ` name: ${manifest.spec.entrypoint.name}`, - ` ref: ${manifest.spec.entrypoint.ref}`, - ' dependencies:', - ' skills:', - ...manifest.spec.dependencies.skills.map((item) => ` - ${item}`), - '', - ].join('\n'); - - zip.addFile(`${root}/agent.yaml`, Buffer.from(yaml, 'utf8')); - zip.addFile(`${root}/README.md`, Buffer.from('# Bugfix Agent\n', 'utf8')); - zip.addFile(`${root}/setup.sh`, Buffer.from('#!/bin/sh\necho setup\n', 'utf8')); - zip.addFile(`${root}/MIGRATION.md`, Buffer.from('migration notes\n', 'utf8')); zip.addFile( - `${root}/skills/bugfix-workflow/SKILL.md`, - Buffer.from('# Bugfix Workflow\n', 'utf8') + `${root}/.codex-plugin/plugin.json`, + Buffer.from(JSON.stringify(codexManifest), 'utf8') ); + if (includeClaudeManifest) { + zip.addFile( + `${root}/.claude-plugin/plugin.json`, + Buffer.from(JSON.stringify(claudeManifest), 'utf8') + ); + } zip.addFile( - `${root}/subagents/bugfix-reviewer.toml`, - Buffer.from('name = "bugfix-reviewer"\n', 'utf8') + `${root}/skills/bugfix-workflow/SKILL.md`, + Buffer.from('# Bugfix Workflow\n', 'utf8') ); zip.addFile( - `${root}/subagents/bugfix-worker.toml`, - Buffer.from('name = "bugfix-worker"\n', 'utf8') + `${root}/agents/claude/bugfix-worker.md`, + Buffer.from('---\nname: bugfix-worker\ndescription: worker\n---\n', 'utf8') ); - zip.addFile(`${root}/assets/logo.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')); - zip.addFile(`${root}/assets/demo1.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')); - zip.addFile(`${root}/assets/demo2.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')); - + zip.addFile(`${root}/${logoPath}`, Buffer.from('logo', 'utf8')); + zip.addFile(`${root}/README.md`, Buffer.from('# Bugfix Agent\n', 'utf8')); extraEntries.forEach((entry) => { zip.addFile(entry.name, Buffer.from(entry.content || '', entry.encoding || 'utf8')); }); @@ -171,41 +107,36 @@ function createAgentZip(manifestOverrides = {}, extraEntries = []) { }; } -test('parseAgentZip 解析单 Agent ZIP 并拆出结构化字段与文件快照', async () => { - const service = createService(); - const fixture = createAgentZip(); +test('parseAgentZip 解析双宿主 plugin 并返回规范展示字段', async () => { + const fixture = createPluginZip(); try { - const parsed = await service.parseAgentZip(fixture.zipPath); + const parsed = await createService().parseAgentZip(fixture.zipPath); assert.equal(parsed.agent.name, 'bugfix-agent'); assert.equal(parsed.agent.displayName, 'Bugfix Agent'); assert.equal(parsed.agent.version, '1.0.0'); assert.equal(parsed.agent.category, '工程效率'); assert.equal(parsed.agent.authorName, 'DTStack'); - assert.equal(parsed.logo.path.startsWith('bugfix-agent/'), true); - assert.equal(parsed.demoImages.length, 2); - assert.deepEqual( - parsed.skillRelations.map((item) => ({ - slug: item.skillSlug, - relationType: item.relationType, - })), - [ - { slug: 'bugfix-workflow', relationType: 'entrypoint' }, - { slug: 'systematic-debugging', relationType: 'dependency' }, - { slug: 'gitlab-mr-code-review', relationType: 'dependency' }, - ] - ); + assert.equal(parsed.agent.longDescription, '负责 Bug 分析、修复和回归验证'); + assert.deepEqual(parsed.agent.defaultPrompt, [ + '$bugfix-workflow 156343 dataApi/release_6.0.x', + ]); + assert.deepEqual(parsed.agent.keywords, ['Bugfix', 'Review']); + assert.equal(parsed.agent.logo.path.startsWith('bugfix-agent/'), true); + assert.equal('profile' in parsed.agent, false); + assert.equal('prompts' in parsed.agent, false); + assert.equal('entrypointName' in parsed.agent, false); + assert.equal('skillRelations' in parsed, false); assert.equal( parsed.files.some((item) => item.filePath === 'assets/logo.png'), - false, - '资源文件不应该写入 agent_files' + false ); assert.equal( - parsed.files.some((item) => item.filePath === 'skills/bugfix-workflow/SKILL.md'), + parsed.files.some((item) => item.filePath === '.claude-plugin/plugin.json'), true ); assert.equal( - parsed.files.some((item) => item.filePath === 'agent.yaml'), + parsed.files.some((item) => item.filePath === 'skills/bugfix-workflow/SKILL.md'), true ); } finally { @@ -213,144 +144,73 @@ test('parseAgentZip 解析单 Agent ZIP 并拆出结构化字段与文件快照' } }); -test('parseAgentZip 拒绝非法分类', async () => { - const service = createService(); - const fixture = createAgentZip({ - metadata: { - name: 'bugfix-agent', - displayName: 'Bugfix Agent', - version: '1.0.0', - logo: './assets/logo.png', - description: 'Agent 简短描述', - author: { name: 'DTStack' }, - category: '未知分类', - tags: ['Bugfix'], - }, - }); +test('parseAgentZip 拒绝缺少 Claude Code manifest 的 plugin', async () => { + const fixture = createPluginZip({ includeClaudeManifest: false }); try { - await assert.rejects(() => service.parseAgentZip(fixture.zipPath), /category 无效/); + await assert.rejects( + () => createService().parseAgentZip(fixture.zipPath), + /\.claude-plugin\/plugin\.json/ + ); } finally { fixture.cleanup(); } }); -test('parseAgentZip 支持 demo.images 使用 src 字段', async () => { - const service = createService(); - const fixture = createAgentZip(); +test('parseAgentZip 拒绝双 manifest 的版本不一致', async () => { + const fixture = createPluginZip({ claudeManifest: { version: '2.0.0' } }); try { - const parsed = await service.parseAgentZip(fixture.zipPath); - assert.equal(parsed.demoImages.length, 2); - assert.equal(parsed.demoImages[0].originalPath, 'assets/demo1.png'); + await assert.rejects( + () => createService().parseAgentZip(fixture.zipPath), + /version 必须一致/ + ); } finally { fixture.cleanup(); } }); -test('parseAgentZip 拒绝 demo.images 使用 path 字段', async () => { - const service = createService(); - const fixture = createAgentZip(); - - const zip = new AdmZip(fixture.zipPath); - const agentYamlEntry = zip.getEntry('bugfix-agent/agent.yaml'); - const yamlContent = agentYamlEntry.getData().toString('utf8').replace(/src:/g, 'path:'); - zip.updateFile('bugfix-agent/agent.yaml', Buffer.from(yamlContent, 'utf8')); - zip.writeZip(fixture.zipPath); +test('parseAgentZip 拒绝超过 Codex 限制的默认 prompt', async () => { + const fixture = createPluginZip({ + codexManifest: { + interface: { + displayName: 'Bugfix Agent', + longDescription: '描述', + developerName: 'DTStack', + category: 'Coding', + defaultPrompt: ['1', '2', '3', '4'], + logo: './assets/logo.png', + }, + }, + }); try { await assert.rejects( - () => service.parseAgentZip(fixture.zipPath), - /spec\.demo\.images\[0\] 路径非法/ + () => createService().parseAgentZip(fixture.zipPath), + /defaultPrompt 最多支持 3 条/ ); } finally { fixture.cleanup(); } }); -test('parseAgentZip 支持 capabilities 使用对象数组并提取 name', async () => { - const service = createService(); - const fixture = createAgentZip(); - - const zip = new AdmZip(fixture.zipPath); - const yamlContent = [ - 'apiVersion: doraemon.dtstack.com/v1', - 'kind: Agent', - 'metadata:', - ' name: bugfix-agent', - ' displayName: Bugfix Agent', - ' version: 1.0.0', - ' logo: ./assets/logo.png', - ' description: Agent 简短描述', - ' author:', - ' name: DTStack', - ' category: 工程效率', - ' tags:', - ' - Bugfix', - 'spec:', - ' profile: 负责 Bug 分析、修复和回归验证', - ' capabilities:', - ' - id: bug-context', - ' name: Bug 信息分析', - ' description: 获取 Bug 上下文', - ' - id: code-fix', - ' name: 代码修复', - ' description: 完成修复', - ' prompts:', - ' - title: 修复 Bug 并部署 OMP online 环境', - ' prompt: $bugfix-workflow 156343 dataApi 6.0.x', - ' demo:', - ' images:', - ' - src: ./assets/demo1.png', - ' alt: Demo 1', - ' - src: ./assets/demo2.png', - ' alt: Demo 2', - ' entrypoint:', - ' host: codex', - ' type: skill', - ' name: bugfix-workflow', - ' ref: ./skills/bugfix-workflow', - ' dependencies:', - ' skills:', - ' - systematic-debugging', - '', - ].join('\n'); - zip.updateFile('bugfix-agent/agent.yaml', Buffer.from(yamlContent, 'utf8')); - zip.writeZip(fixture.zipPath); +test('parseAgentZip 支持 Codex 官方 .codex-plugin/assets Logo 路径', async () => { + const fixture = createPluginZip({ logoPath: '.codex-plugin/assets/logo.png' }); try { - const parsed = await service.parseAgentZip(fixture.zipPath); - assert.deepEqual(parsed.agent.capabilities, [ - { - id: 'bug-context', - name: 'Bug 信息分析', - description: '获取 Bug 上下文', - }, - { - id: 'code-fix', - name: '代码修复', - description: '完成修复', - }, - ]); + const parsed = await createService().parseAgentZip(fixture.zipPath); + assert.match(parsed.agent.logo.path, /\.codex-plugin\/assets\/logo\.png$/); } finally { fixture.cleanup(); } }); -test('normalizeCapabilities 兼容旧的字符串数组存量数据', () => { +test('normalizeCapabilities 兼容字符串和对象数组', () => { const service = createService(); - assert.deepEqual(service.normalizeCapabilities(['分析 Bug', '修复代码']), [ - { - id: '', - name: '分析 Bug', - description: '', - }, - { - id: '', - name: '修复代码', - description: '', - }, + assert.deepEqual(service.normalizeCapabilities(['分析 Bug', { id: 'fix', name: '修复代码' }]), [ + { id: '', name: '分析 Bug', description: '' }, + { id: 'fix', name: '修复代码', description: '' }, ]); }); @@ -362,66 +222,20 @@ test('compareAgentVersion 按 semver 比较版本号', () => { assert.equal(service.compareAgentVersion('1.2.0', '1.10.0'), -1); }); -test('buildRelatedAgents 仅按依赖 Skills 交集排序且忽略入口 Skill', () => { - const service = createService(); - const target = { - name: 'bugfix-agent', - dependencies: ['systematic-debugging', 'gitlab-mr-code-review'], - entrypointName: 'bugfix-workflow', - }; - const related = service.buildRelatedAgents( - target, - [ - { - name: 'release-conflict-agent', - displayName: 'Release Conflict Agent', - dependencies: ['systematic-debugging'], - entrypointName: 'bugfix-workflow', - updatedAt: '2026-08-10T12:00:00.000Z', - }, - { - name: 'review-agent', - displayName: 'Review Agent', - dependencies: ['systematic-debugging', 'gitlab-mr-code-review'], - entrypointName: 'review-workflow', - updatedAt: '2026-08-09T12:00:00.000Z', - }, - { - name: 'empty-agent', - displayName: 'Empty Agent', - dependencies: [], - entrypointName: 'bugfix-workflow', - updatedAt: '2026-08-11T12:00:00.000Z', - }, - ], - 3 - ); - - assert.deepEqual( - related.map((item) => item.name), - ['review-agent', 'release-conflict-agent'] - ); -}); - test('writeAgentArchive 将原始 ZIP 保存到当前内容 hash 目录', async () => { + const service = createService(); const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-storage-')); const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-source-')); - const sourcePath = path.join(sourceDir, 'uploaded.zip'); + const sourcePath = path.join(sourceDir, 'source.zip'); fs.writeFileSync(sourcePath, Buffer.from('original-agent-zip')); - const service = createService(); service.app.config.agentMarket.storageDir = storageDir; try { const archiveDir = await service.writeAgentArchive( - { - name: 'bugfix-agent', - contentHash: 'hash-v2', - }, + { name: 'bugfix-agent', contentHash: 'hash-v2' }, sourcePath ); - const archivePath = path.join(storageDir, 'bugfix-agent', 'hash-v2', 'bugfix-agent.zip'); - - assert.equal(archiveDir, path.dirname(archivePath)); + const archivePath = path.join(archiveDir, 'bugfix-agent.zip'); assert.equal(fs.readFileSync(archivePath, 'utf8'), 'original-agent-zip'); } finally { fs.rmSync(storageDir, { recursive: true, force: true }); @@ -430,12 +244,12 @@ test('writeAgentArchive 将原始 ZIP 保存到当前内容 hash 目录', async }); test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => { + const service = createService(); const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-download-')); const archiveDir = path.join(storageDir, 'bugfix-agent', 'hash-current'); const archivePath = path.join(archiveDir, 'bugfix-agent.zip'); fs.mkdirSync(archiveDir, { recursive: true }); fs.writeFileSync(archivePath, Buffer.from('download-agent-zip')); - const service = createService(); service.app.config.agentMarket.storageDir = storageDir; service.storageReady = true; service.app.model = { @@ -454,7 +268,6 @@ test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => const result = await service.getAgentArchiveStream('bugfix-agent'); const chunks = []; for await (const chunk of result.stream) chunks.push(chunk); - assert.equal(Buffer.concat(chunks).toString('utf8'), 'download-agent-zip'); assert.equal(result.fileName, 'bugfix-agent.zip'); assert.equal(result.mimeType, 'application/zip'); @@ -463,126 +276,42 @@ test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => } }); -function createDetailService() { +test('getAgentDetail 返回规范化的 plugin 展示字段', async () => { const service = createService(); service.storageReady = true; - service.app.Sequelize = { Op: require('sequelize').Op }; - const row = { - id: 1, name: 'bugfix-agent', display_name: 'Bugfix Agent', description: 'Agent 简短描述', - profile: '简介', + profile: '负责 Bug 分析、修复和回归验证', author_name: 'DTStack', category: '工程效率', - tags: '[]', - prompts: '[]', - capabilities: '[]', + tags: '["Bugfix"]', + prompts: '[{"title":"开场问题 1","prompt":"$bugfix-workflow 156343"}]', + capabilities: '["Interactive","Read"]', version: '1.0.0', logo_path: '', - demo_images: '[]', updated_at: new Date('2026-01-01T00:00:00Z'), - entrypoint_ref: 'skills/bugfix-workflow', toJSON() { return { ...this }; }, }; - - const skillMdContents = { - 'skills/bugfix-workflow/SKILL.md': - '---\nname: Bugfix Workflow\n---\n# Bugfix Workflow\n修复 Bug 的完整流程', - 'skills/builtin-review/SKILL.md': - '---\nname: 内置审查 Skill\n---\n# 内置审查\n用于代码审查', - 'skills/systematic-debugging/SKILL.md': - '---\nname: Systematic Debugging\n---\n# Systematic Debugging\n系统化调试方法论', - }; - service.app.model = { Agent: { async findOne() { return row; }, }, - AgentSkill: { - async findAll() { - return [ - { skill_slug: 'bugfix-workflow', relation_type: 'entrypoint', sort_order: 0 }, - { skill_slug: 'builtin-review', relation_type: 'private', sort_order: 0 }, - { - skill_slug: 'systematic-debugging', - relation_type: 'dependency', - sort_order: 0, - }, - ]; - }, - }, - SkillsItem: { - async findAll() { - return []; - }, - }, - AgentFile: { - async findAll({ where }) { - const { Op } = service.app.Sequelize; - const paths = where.file_path[Op.in] || []; - return paths - .filter((filePath) => skillMdContents[filePath] !== undefined) - .map((filePath) => ({ - file_path: filePath, - content: skillMdContents[filePath], - })); - }, - }, - }; - - return service; -} - -test('getAgentDetail 未收录入口/内置/依赖 Skill 从包内 SKILL.md 回填描述', async () => { - const detail = await createDetailService().getAgentDetail('bugfix-agent'); - - // 核心工作流:未收录时回填 SKILL.md 的 name/description - assert.equal(detail.entrypoint.name, 'Bugfix Workflow'); - assert.match(detail.entrypoint.description, /修复 Bug/); - assert.equal(detail.entrypoint.collected, false); - - // 内置 Skills:总是回填,name 取 frontmatter,标记 builtin - assert.equal(detail.privateSkills.length, 1); - assert.equal(detail.privateSkills[0].name, '内置审查 Skill'); - assert.match(detail.privateSkills[0].description, /代码审查/); - assert.equal(detail.privateSkills[0].builtin, true); - assert.equal(detail.privateSkills[0].path, ''); - - // 未收录的依赖 Skills:包内有 SKILL.md 时同样回填 - assert.equal(detail.dependencies.length, 1); - assert.equal(detail.dependencies[0].name, 'Systematic Debugging'); - assert.match(detail.dependencies[0].description, /系统化调试/); -}); - -test('getAgentDetail 已收录入口 Skill 用 SkillsItem 描述,且不查包内 SKILL.md', async () => { - const service = createDetailService(); - service.app.model.SkillsItem.findAll = async () => [ - { - slug: 'bugfix-workflow', - name: 'Bugfix Workflow(已收录)', - description: '来自 Skills Hub 的描述', - }, - ]; - // 包内不提供 SKILL.md,验证已收录场景不读取它 - service.app.model.AgentFile.findAll = async ({ where }) => { - const { Op } = service.app.Sequelize; - const paths = where.file_path[Op.in] || []; - assert.equal(paths.length, 2); // 仅内置 + 未收录依赖,不再包含入口 - return []; }; const detail = await service.getAgentDetail('bugfix-agent'); - - assert.equal(detail.entrypoint.name, 'Bugfix Workflow(已收录)'); - assert.equal(detail.entrypoint.description, '来自 Skills Hub 的描述'); - assert.equal(detail.entrypoint.collected, true); - // 内置 Skill 无 SKILL.md:name 回退 slug,description 为空(前端显示"暂无描述") - assert.equal(detail.privateSkills[0].name, 'builtin-review'); - assert.equal(detail.privateSkills[0].description, ''); + assert.equal(detail.longDescription, '负责 Bug 分析、修复和回归验证'); + assert.deepEqual(detail.defaultPrompt, [ + { title: '开场问题 1', prompt: '$bugfix-workflow 156343' }, + ]); + assert.equal('profile' in detail, false); + assert.equal('prompts' in detail, false); + assert.equal('entrypoint' in detail, false); + assert.equal('dependencies' in detail, false); + assert.equal('privateSkills' in detail, false); }); diff --git a/test/agent-plugin-contract.test.js b/test/agent-plugin-contract.test.js new file mode 100644 index 0000000..4c6c568 --- /dev/null +++ b/test/agent-plugin-contract.test.js @@ -0,0 +1,157 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const AdmZip = require('adm-zip'); + +const AgentsService = require('../app/service/agents'); + +function createService() { + const service = Object.create(AgentsService.prototype); + service.ctx = { + throw(status, message) { + const error = new Error(message); + error.status = status; + throw error; + }, + }; + service.app = { + config: { + agentMarket: { + storageDir: '/data/doraemon/agent-market', + maxExtractedSize: 200 * 1024 * 1024, + maxFileCount: 500, + maxSingleFileSize: 20 * 1024 * 1024, + }, + }, + }; + return service; +} + +function createPluginZip({ includeClaudeManifest = true, version = '1.0.0' } = {}) { + const zip = new AdmZip(); + const root = 'bugfix-agent'; + const codexManifest = { + name: root, + version, + description: 'Bugfix plugin', + author: { name: 'DTStack' }, + keywords: ['bugfix'], + skills: './skills/', + interface: { + displayName: 'Bugfix Agent', + longDescription: '负责 Bug 分析、修复和交付', + developerName: 'DTStack', + category: 'Coding', + capabilities: ['Interactive', 'Read', 'Write'], + defaultPrompt: ['$bugfix-workflow 156343'], + logo: './assets/logo.png', + }, + }; + const claudeManifest = { + name: root, + version, + description: 'Bugfix plugin', + author: { name: 'DTStack' }, + agents: ['./agents/claude/bugfix-worker.md'], + }; + + zip.addFile( + `${root}/.codex-plugin/plugin.json`, + Buffer.from(JSON.stringify(codexManifest), 'utf8') + ); + if (includeClaudeManifest) { + zip.addFile( + `${root}/.claude-plugin/plugin.json`, + Buffer.from(JSON.stringify(claudeManifest), 'utf8') + ); + } + zip.addFile(`${root}/skills/bugfix-workflow/SKILL.md`, Buffer.from('# Bugfix Workflow\n')); + zip.addFile( + `${root}/agents/claude/bugfix-worker.md`, + Buffer.from('---\nname: bugfix-worker\ndescription: worker\n---\n') + ); + zip.addFile(`${root}/assets/logo.png`, Buffer.from('logo')); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-plugin-contract-')); + const zipPath = path.join(tempDir, 'bugfix-agent.zip'); + zip.writeZip(zipPath); + return { + zipPath, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +test('parseAgentZip 只返回双 manifest 的规范展示字段', async () => { + const fixture = createPluginZip(); + + try { + const parsed = await createService().parseAgentZip(fixture.zipPath); + assert.equal(parsed.agent.longDescription, '负责 Bug 分析、修复和交付'); + assert.deepEqual(parsed.agent.defaultPrompt, ['$bugfix-workflow 156343']); + assert.equal('profile' in parsed.agent, false); + assert.equal('prompts' in parsed.agent, false); + assert.equal('entrypointName' in parsed.agent, false); + assert.equal('skillRelations' in parsed, false); + } finally { + fixture.cleanup(); + } +}); + +test('parseAgentZip 拒绝缺少 Claude Code manifest 的 plugin', async () => { + const fixture = createPluginZip({ includeClaudeManifest: false }); + + try { + await assert.rejects( + () => createService().parseAgentZip(fixture.zipPath), + /\.claude-plugin\/plugin\.json/ + ); + } finally { + fixture.cleanup(); + } +}); + +test('getAgentDetail 只返回 plugin 展示契约字段', async () => { + const service = createService(); + service.storageReady = true; + const row = { + id: 1, + name: 'bugfix-agent', + display_name: 'Bugfix Agent', + description: 'Bugfix plugin', + profile: '负责 Bug 分析、修复和交付', + author_name: 'DTStack', + category: '工程效率', + tags: '["bugfix"]', + prompts: '[{"title":"开场问题 1","prompt":"$bugfix-workflow 156343"}]', + capabilities: '["Interactive","Read","Write"]', + version: '1.0.0', + logo_path: '', + updated_at: new Date('2026-01-01T00:00:00Z'), + toJSON() { + return { ...this }; + }, + }; + service.app.model = { + Agent: { + async findOne() { + return row; + }, + }, + }; + + const detail = await service.getAgentDetail('bugfix-agent'); + + assert.equal(detail.longDescription, '负责 Bug 分析、修复和交付'); + assert.deepEqual(detail.defaultPrompt, [ + { title: '开场问题 1', prompt: '$bugfix-workflow 156343' }, + ]); + assert.equal('profile' in detail, false); + assert.equal('prompts' in detail, false); + assert.equal('entrypoint' in detail, false); + assert.equal('dependencies' in detail, false); + assert.equal('privateSkills' in detail, false); +}); From 92be095a3eb42b8175da6e8c2778dde9f086f42d Mon Sep 17 00:00:00 2001 From: liuxy0551 Date: Wed, 9 Sep 2026 09:34:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(agents):=20agent=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=20Skills=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=B8=8E=E7=9B=B8=E5=85=B3=20Agent=20=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent_skills 持久化关联表,导入时解析包内 SKILL.md 落库 - 详情接口返回 skills 并匹配 skill 市场判定收录状态 - 详情页 Skills 模块与 master「依赖 Skills」网格卡片样式对齐,点击新标签页跳转 - 恢复「相关 Agent」模块,基于共同技能重叠推荐 - 合并功能概览与关键词,关键词移至顶部 hero Co-Authored-By: Claude Opus 4.8 --- app/controller/agents.js | 6 + app/model/agent_skill.js | 44 +++++ app/router.js | 1 + app/service/agents.js | 167 +++++++++++++++++- app/web/api/url.ts | 4 + .../agents/detail/AgentDetailContent.tsx | 162 +++++++++++++---- app/web/pages/agents/detail/style.scss | 118 ++++++++++++- app/web/pages/agents/index.tsx | 3 +- app/web/pages/agents/types.ts | 11 ++ sql/doraemon.sql | 15 ++ test/agent-detail-layout.test.js | 17 +- test/agent-detail-plugin-contract.test.js | 6 +- test/agent-market-service.test.js | 6 + test/agent-plugin-contract.test.js | 6 + 14 files changed, 522 insertions(+), 44 deletions(-) create mode 100644 app/model/agent_skill.js diff --git a/app/controller/agents.js b/app/controller/agents.js index bd14e72..76ef005 100644 --- a/app/controller/agents.js +++ b/app/controller/agents.js @@ -29,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) diff --git a/app/model/agent_skill.js b/app/model/agent_skill.js new file mode 100644 index 0000000..2a2b5fc --- /dev/null +++ b/app/model/agent_skill.js @@ -0,0 +1,44 @@ +module.exports = (app) => { + const { INTEGER, STRING, DATE } = app.Sequelize; + + const AgentSkill = app.model.define( + 'agent_skill', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true, + }, + agent_id: { + type: INTEGER, + allowNull: false, + comment: 'agents.id', + }, + skill_slug: { + type: STRING(255), + allowNull: false, + comment: 'Skill 标识(包内 SKILL.md 解析出的 name 或目录名)', + }, + 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: 'agent_skills', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [{ fields: ['agent_id'] }, { fields: ['skill_slug'] }], + } + ); + + return AgentSkill; +}; diff --git a/app/router.js b/app/router.js index 19e93d8..a30a813 100644 --- a/app/router.js +++ b/app/router.js @@ -167,6 +167,7 @@ module.exports = (app) => { */ app.get('/api/agents/list', app.controller.agents.getAgentList); app.get('/api/agents/detail', app.controller.agents.getAgentDetail); + app.get('/api/agents/related', app.controller.agents.getRelatedAgents); app.get('/api/agents/asset', app.controller.agents.getAgentAsset); app.get('/api/agents/download', app.controller.agents.downloadAgentArchive); app.post('/api/agents/import-file', app.controller.agents.importAgentFile); diff --git a/app/service/agents.js b/app/service/agents.js index f55bab0..fac5e5b 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -5,7 +5,8 @@ const fs = require('fs'); const path = require('path'); const mime = require('mime-types'); -const { normalizeRelativePath } = require('../utils/skill-utils'); +const { normalizeRelativePath, extractSkillMdName } = require('../utils/skill-utils'); +const { resolveSkillIdentifier, sanitizeInstallKeySegment } = require('../utils/skill-install-key'); const { isValidSkillCategory, SKILL_CATEGORY_OPTIONS, @@ -42,13 +43,14 @@ class AgentsService extends Service { } this.storageReadyPromise = (async () => { - const { Agent, AgentFile } = this.app.model; - if (!Agent || !AgentFile) { + const { Agent, AgentFile, AgentSkill } = this.app.model; + if (!Agent || !AgentFile || !AgentSkill) { this.ctx.throw(500, 'Agent 数据模型未加载'); } await Agent.sync(); await AgentFile.sync(); + await AgentSkill.sync(); this.storageReady = true; })(); @@ -499,6 +501,25 @@ 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, @@ -511,6 +532,7 @@ class AgentsService extends Service { keywords: validated.keywords, defaultPrompt: validated.defaultPrompt, capabilities: validated.capabilities, + skills: agentSkills, logo, contentHash, fileCount: fileRecords.length, @@ -667,6 +689,23 @@ class AgentsService extends Service { await AgentFile.bulkCreate(fileRows, { 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 { id: agentId, name: parsed.agent.name, @@ -757,6 +796,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 byInstallKey = skillCache.byInstallKey; + if (byInstallKey instanceof Map) { + const sanitized = sanitizeInstallKeySegment(value); + if (sanitized && byInstallKey.has(sanitized)) { + return byInstallKey.get(sanitized); + } + } + return null; + } + async getAgentDetail(name) { await this.ensureStorageReady(); const { Agent } = this.app.model; @@ -772,6 +831,48 @@ class AgentsService extends Service { const detail = row.toJSON(); + // 读取 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: identifier, + installKey: identifier, + name: identifier, + description: '', + isPackage: 0, + parentSlug: null, + installed: false, + }; + }); + } + return { name: detail.name, displayName: detail.display_name, @@ -785,9 +886,69 @@ class AgentsService extends Service { version: detail.version || '', 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: nameValue, + is_delete: 0, + }, + }); + if (!target) { + this.ctx.throw(404, 'Agent 不存在'); + } + + const safeLimit = Math.max(Number(limit) || 3, 1); + const skillRows = await AgentSkill.findAll({ + attributes: ['agent_id', 'skill_slug'], + }); + const skillMap = new Map(); + skillRows.forEach((item) => { + if (!skillMap.has(item.agent_id)) { + skillMap.set(item.agent_id, new Set()); + } + skillMap.get(item.agent_id).add(item.skill_slug); + }); + + const targetSkills = skillMap.get(target.id) || new Set(); + const agentRows = await Agent.findAll({ + where: { is_delete: 0 }, + order: [['updated_at', 'DESC']], + }); + + return agentRows + .filter((item) => item.id !== target.id) + .map((item) => { + const itemSkills = skillMap.get(item.id) || new Set(); + let overlap = 0; + targetSkills.forEach((skill) => { + if (itemSkills.has(skill)) overlap += 1; + }); + return { + ...this.toAgentListItem(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, safeLimit); + } + async getAgentAssetStream(params = {}) { await this.ensureStorageReady(); const name = String(params.name || '').trim(); diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 0e406d0..685ba0b 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -413,6 +413,10 @@ export default { method: 'get', url: '/api/agents/detail', }, + getRelatedAgents: { + method: 'get', + url: '/api/agents/related', + }, downloadAgentArchive: { method: 'get', url: '/api/agents/download', diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx index 2cff684..7b98b9d 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -11,13 +11,42 @@ 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 } from '../types'; +import type { AgentCapability, AgentDetail, AgentItem, AgentSkill } from '../types'; import './style.scss'; const { Paragraph, Text, Title } = Typography; const { normalizeAgentCapabilities } = require('./capability-utils'); const { buildAgentIntroBlocks } = require('./intro-utils'); +const RelatedAgentCard: React.FC<{ + item: AgentItem; + history: { push: (path: string) => void }; +}> = ({ item, history }) => ( + history.push(`/page/agents/${item.name}`)} + > +
+ {item.logoUrl ? ( + {item.displayName} { + event.currentTarget.style.visibility = 'hidden'; + }} + /> + ) : null} +
+ {item.displayName} + {item.description || '暂无描述'} +
+
+
+); + interface AgentDetailContentProps { name: string; history: { push: (path: string) => void }; @@ -26,6 +55,7 @@ interface AgentDetailContentProps { const AgentDetailContent: React.FC = ({ name, history }) => { const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); + const [related, setRelated] = useState([]); useEffect(() => { let cancelled = false; @@ -33,14 +63,19 @@ const AgentDetailContent: React.FC = ({ name, history } const load = async () => { setLoading(true); try { - const detailRes = await API.getAgentDetail({ name }); + const [detailRes, relatedRes] = await Promise.all([ + API.getAgentDetail({ name }), + API.getRelatedAgents({ name, limit: 3 }), + ]); if (cancelled) return; setDetail(detailRes.success ? (detailRes.data as AgentDetail) : null); + setRelated(relatedRes.success ? relatedRes.data || [] : []); } catch (error) { console.error('获取 Agent 详情失败:', error); if (!cancelled) { setDetail(null); + setRelated([]); } } finally { if (!cancelled) { @@ -128,11 +163,26 @@ const AgentDetailContent: React.FC = ({ name, history } {detail.category}
-
- {detail.tags.map((tag) => ( - {tag} - ))} +
+ 功能: + {normalizedCapabilities.map( + (item: AgentCapability, index: number) => ( + + {item.name} + + ) + )}
+ {detail.tags && detail.tags.length > 0 ? ( +
+ + 关键词: + + {detail.tags.map((tag) => ( + {tag} + ))} +
+ ) : null}
@@ -140,32 +190,12 @@ const AgentDetailContent: React.FC = ({ name, history }
- 你可以使用该 Agent 做什么 + 功能概览
{detail.description || '暂无描述'}
- - 能力范围 - {normalizedCapabilities.length > 0 ? ( -
- {normalizedCapabilities.map( - (item: AgentCapability, index: number) => ( - - {item.name} - - ) - )} -
- ) : ( - - )} -
-
@@ -187,7 +217,7 @@ const AgentDetailContent: React.FC = ({ name, history }
- 开场问题 + 快速使用 {introBlocks.openingQuestions.length} 个 @@ -206,7 +236,6 @@ const AgentDetailContent: React.FC = ({ name, history }
- {item.title} {item.prompt} @@ -232,13 +261,67 @@ const AgentDetailContent: React.FC = ({ name, history } ) : ( )}
+ + +
+ 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 || '暂无描述'} + +
+ ))} +
+ ) : ( + + )} +
@@ -286,6 +369,25 @@ const AgentDetailContent: React.FC = ({ name, history } 下载 Agent ZIP + + +
+ {related.length > 0 ? ( + related.map((item) => ( + + )) + ) : ( + + )} +
+
diff --git a/app/web/pages/agents/detail/style.scss b/app/web/pages/agents/detail/style.scss index 0dd651a..3ef7d88 100644 --- a/app/web/pages/agents/detail/style.scss +++ b/app/web/pages/agents/detail/style.scss @@ -19,10 +19,52 @@ } .agent-hero, .agent-section-card, - .agent-side-actions { + .agent-side-actions, + .agent-side-related { border-radius: 20px; border: 1px solid #EDF0F5; } + .agent-side-related { + margin-top: 20px; + .ant-card-head { + min-height: 48px; + padding: 0 18px; + } + .ant-card-head-title { + padding: 14px 0; + font-size: 15px; + } + .ant-card-body { + padding: 16px; + } + } + .related-agent-list { + display: flex; + flex-direction: column; + gap: 12px; + } + .related-agent-card { + border-radius: 14px; + } + .related-agent-head { + display: flex; + gap: 12px; + align-items: flex-start; + } + .related-agent-logo { + width: 44px; + height: 44px; + flex: 0 0 44px; + border-radius: 12px; + object-fit: cover; + background: linear-gradient(135deg, #EFF4FF, #EEF2FF); + } + .related-agent-meta { + min-width: 0; + .ant-typography { + margin-bottom: 0; + } + } .agent-side-actions { .ant-card-head { min-height: 48px; @@ -153,10 +195,17 @@ color: #98A2B3; } } - .agent-hero-tags { + .agent-hero-capabilities { display: flex; flex-wrap: wrap; gap: 8px; + align-items: center; + .agent-hero-capabilities-label { + color: #667085; + } + } + .agent-hero-capabilities + .agent-hero-capabilities { + margin-top: 8px; } .agent-detail-content { min-width: 0; @@ -166,6 +215,62 @@ flex-direction: column; gap: 12px; } + .agent-capability-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + .agent-capability-label { + color: #667085; + } + &.agent-overview-tags { + margin-top: 12px; + } + } + .agent-skill-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + } + .agent-skill-card { + padding: 14px 16px; + border-radius: 14px; + border: 1px solid #E9EEF5; + box-shadow: none; + background: linear-gradient(180deg, #FFF 0%, #FBFDFF 100%); + .ant-card-body { + padding: 0; + } + &.is-clickable { + cursor: pointer; + } + &.is-disabled { + cursor: default; + opacity: 0.78; + } + } + .agent-skill-card-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 6px; + span:first-child { + font-size: 15px; + line-height: 1.5; + font-weight: 600; + color: #1F2937; + } + .ant-tag { + margin-right: 0; + } + } + .agent-skill-card-description { + margin-bottom: 0; + color: #667085; + font-size: 14px; + line-height: 1.6; + } .agent-prompts { display: grid; gap: 12px; @@ -214,6 +319,15 @@ margin-right: 0; align-self: center; } + .agent-skills-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 10px; + h4 { + margin-bottom: 0; + } + } .agent-intro-icon-wrap { display: inline-flex; align-items: center; diff --git a/app/web/pages/agents/index.tsx b/app/web/pages/agents/index.tsx index 6c246ce..5cfa7ae 100644 --- a/app/web/pages/agents/index.tsx +++ b/app/web/pages/agents/index.tsx @@ -352,7 +352,8 @@ const AgentMarket: React.FC = ({ history }) => { > - 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `.codex-plugin/plugin.json` 自动解析。 + 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `.codex-plugin/plugin.json` + 自动解析。 { assert.doesNotMatch(component, /开场消息|openingMessage/); }); -test('Agent 详情页移除非 plugin 的关系和演示字段', () => { +test('Agent 详情页已移除 demo 关系字段,并恢复相关 Agent 模块', () => { const { component, style } = readDetailFiles(); - assert.doesNotMatch(component, /Agent 能力|核心工作流|相关 Agent|getRelatedAgents/); - assert.doesNotMatch(component, /AgentSkillRelation|agent-demo|agent-skill|related-agent/); - assert.doesNotMatch(style, /agent-demo|agent-skill|related-agent|agent-message/); + assert.doesNotMatch(component, /Agent 能力|核心工作流|agent-demo|AgentSkillRelation/); + assert.doesNotMatch(component, /agent-demo|agent-message/); + assert.doesNotMatch(style, /agent-demo|agent-message/); + // 相关 Agent 模块已恢复(侧栏) + assert.match(component, /相关 Agent/); + assert.match(component, /getRelatedAgents/); + assert.match(component, /related-agent-card/); + assert.match(style, /\.agent-side-related/); }); -test('概览页保留短描述和能力标签', () => { +test('概览页保留短描述和顶部功能标签', () => { const { component } = readDetailFiles(); assert.match(component, /detail\.description/); - assert.match(component, /agent-capability-tags/); + assert.match(component, /agent-hero-capabilities/); assert.doesNotMatch(component, /agent-capability-grid| { assert.doesNotMatch(component, /开场消息/); assert.doesNotMatch(component, /核心工作流/); assert.doesNotMatch(component, /Agent 能力/); - assert.doesNotMatch(component, /getRelatedAgents/); assert.doesNotMatch(component, /AgentSkillRelation/); assert.doesNotMatch( types, /AgentSkillRelation|entrypoint|dependencies|privateSkills|demoImages/ ); - assert.doesNotMatch(api, /getRelatedAgents|\/api\/agents\/related/); + // 相关 Agent 模块已恢复(独立接口,不注入 plugin 数据模型) + assert.match(component, /getRelatedAgents/); + assert.match(api, /getRelatedAgents|\/api\/agents\/related/); + assert.doesNotMatch(api, /\/api\/agents\/entrypoint/); }); diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js index ab083a1..080029c 100644 --- a/test/agent-market-service.test.js +++ b/test/agent-market-service.test.js @@ -302,6 +302,11 @@ test('getAgentDetail 返回规范化的 plugin 展示字段', async () => { return row; }, }, + AgentSkill: { + async findAll() { + return []; + }, + }, }; const detail = await service.getAgentDetail('bugfix-agent'); @@ -309,6 +314,7 @@ test('getAgentDetail 返回规范化的 plugin 展示字段', async () => { assert.deepEqual(detail.defaultPrompt, [ { title: '开场问题 1', prompt: '$bugfix-workflow 156343' }, ]); + assert.deepEqual(detail.skills, []); assert.equal('profile' in detail, false); assert.equal('prompts' in detail, false); assert.equal('entrypoint' in detail, false); diff --git a/test/agent-plugin-contract.test.js b/test/agent-plugin-contract.test.js index 4c6c568..7c88417 100644 --- a/test/agent-plugin-contract.test.js +++ b/test/agent-plugin-contract.test.js @@ -141,6 +141,11 @@ test('getAgentDetail 只返回 plugin 展示契约字段', async () => { return row; }, }, + AgentSkill: { + async findAll() { + return []; + }, + }, }; const detail = await service.getAgentDetail('bugfix-agent'); @@ -149,6 +154,7 @@ test('getAgentDetail 只返回 plugin 展示契约字段', async () => { assert.deepEqual(detail.defaultPrompt, [ { title: '开场问题 1', prompt: '$bugfix-workflow 156343' }, ]); + assert.deepEqual(detail.skills, []); assert.equal('profile' in detail, false); assert.equal('prompts' in detail, false); assert.equal('entrypoint' in detail, false); From 4cf3125dc8de849c89cca4c311442c6e960a2463 Mon Sep 17 00:00:00 2001 From: liuxy0551 Date: Wed, 9 Sep 2026 10:18:53 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(agents):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=A0=87=E7=AD=BE=E6=94=B9=E4=B8=BA=E7=BA=AF?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E9=80=97=E5=8F=B7=E5=88=86=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../agents/detail/AgentDetailContent.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx index 7b98b9d..708ecf5 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -11,7 +11,7 @@ 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, AgentSkill } from '../types'; +import type { AgentDetail, AgentItem, AgentSkill } from '../types'; import './style.scss'; const { Paragraph, Text, Title } = Typography; @@ -163,16 +163,18 @@ const AgentDetailContent: React.FC = ({ name, history } {detail.category}
-
- 功能: - {normalizedCapabilities.map( - (item: AgentCapability, index: number) => ( - - {item.name} - - ) - )} -
+ {normalizedCapabilities.length > 0 ? ( +
+ + 功能: + + + {normalizedCapabilities + .map((item) => item.name) + .join(', ')} + +
+ ) : null} {detail.tags && detail.tags.length > 0 ? (
From a2fa8d21a83d379c505f5189a24fcd6f2c4dbc20 Mon Sep 17 00:00:00 2001 From: liuxy0551 Date: Wed, 9 Sep 2026 11:01:50 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(agents):=20=E4=BF=AE=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=A1=A8=E7=BB=93=E6=9E=84=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E3=80=81=E5=88=A0=E9=99=A4=E5=AD=A4=E5=84=BF=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=8E=E7=9B=B8=E5=85=B3=E6=8E=A8=E8=8D=90=E5=85=A8=E8=A1=A8?= =?UTF-8?q?=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动存储时兼容检查 agent_skills 表,处理历史 relation_type 非空约束 - deleteAgent 事务中补充清理 agent_skills 关联数据 - getRelatedAgents 改为基于共同技能过滤的定向查询,避免全表扫描 - parseAgentZip 过滤 .codex-plugin/assets 避免二进制图片存入快照文件 - 修复 controller 上传临时文件遍历清理 - 移除注释结尾中文句号 - 补齐相关功能单元测试 --- app/controller/agents.js | 13 ++- app/service/agents.js | 108 +++++++++++++------ test/agent-market-service.test.js | 170 ++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 39 deletions(-) diff --git a/app/controller/agents.js b/app/controller/agents.js index 76ef005..983178b 100644 --- a/app/controller/agents.js +++ b/app/controller/agents.js @@ -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/service/agents.js b/app/service/agents.js index fac5e5b..fbed190 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -51,6 +51,7 @@ class AgentsService extends Service { await Agent.sync(); await AgentFile.sync(); await AgentSkill.sync(); + await this.ensureAgentSkillsTableCompatible(); this.storageReady = true; })(); @@ -61,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) { @@ -435,7 +455,7 @@ class AgentsService extends Service { })) ); - // logo 从包内 assets/logo.png 读取(支持 png/jpeg/webp),随 resource 落盘并记录元数据。 + // logo 从包内 assets/logo.png 读取(支持 png/jpeg/webp),随 resource 落盘并记录元数据 const LOGO_ALLOWED = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp']; let logo = null; const assetFiles = []; @@ -485,7 +505,11 @@ class AgentsService extends Service { } 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 { @@ -796,8 +820,8 @@ class AgentsService extends Service { }; } - // 用 Agent 关联的 skill 标识匹配 skill 市场,返回命中的 skill 或 null。 - // 优先精确匹配 slug/installKey,再尝试 sanitize 后的小写连字符形式(覆盖 SKILL.md name 与市场 name 不一致的情形)。 + // 用 Agent 关联的 skill 标识匹配 skill 市场,返回命中的 skill 或 null + // 优先精确匹配 slug/installKey,再尝试 sanitize 后的小写连字符形式(覆盖 SKILL.md name 与市场 name 不一致的情形) matchMarketSkill(identifier, skillCache) { if (!skillCache) return null; const value = String(identifier || '').trim(); @@ -806,12 +830,9 @@ class AgentsService extends Service { const matched = resolveSkillIdentifier(value, skillCache); if (matched) return matched; - const byInstallKey = skillCache.byInstallKey; - if (byInstallKey instanceof Map) { - const sanitized = sanitizeInstallKeySegment(value); - if (sanitized && byInstallKey.has(sanitized)) { - return byInstallKey.get(sanitized); - } + const sanitized = sanitizeInstallKeySegment(value); + if (sanitized && sanitized !== value) { + return resolveSkillIdentifier(sanitized, skillCache) || null; } return null; } @@ -890,7 +911,7 @@ class AgentsService extends Service { }; } - // 根据 Agent 关联的 Skill 重叠度推荐相关 Agent(共同 skill 越多越相关)。 + // 根据 Agent 关联的 Skill 重叠度推荐相关 Agent(共同 skill 越多越相关) async getRelatedAgents(name, limit = 3) { await this.ensureStorageReady(); const nameValue = String(name || '').trim(); @@ -906,36 +927,51 @@ class AgentsService extends Service { } const safeLimit = Math.max(Number(limit) || 3, 1); - const skillRows = await AgentSkill.findAll({ + // 先查询当前 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'], }); - const skillMap = new Map(); - skillRows.forEach((item) => { - if (!skillMap.has(item.agent_id)) { - skillMap.set(item.agent_id, new Set()); - } - skillMap.get(item.agent_id).add(item.skill_slug); + if (relatedSkillRows.length === 0) { + return []; + } + + // 统计各候选 Agent 的技能重叠数 + const overlapCountMap = new Map(); + relatedSkillRows.forEach((item) => { + const current = overlapCountMap.get(item.agent_id) || 0; + overlapCountMap.set(item.agent_id, current + 1); }); - const targetSkills = skillMap.get(target.id) || new Set(); + const candidateIds = Array.from(overlapCountMap.keys()); const agentRows = await Agent.findAll({ - where: { is_delete: 0 }, - order: [['updated_at', 'DESC']], + where: { + id: candidateIds, + is_delete: 0, + }, }); return agentRows - .filter((item) => item.id !== target.id) - .map((item) => { - const itemSkills = skillMap.get(item.id) || new Set(); - let overlap = 0; - targetSkills.forEach((skill) => { - if (itemSkills.has(skill)) overlap += 1; - }); - return { - ...this.toAgentListItem(item), - overlapCount: overlap, - }; - }) + .map((item) => ({ + ...this.toAgentListItem(item), + overlapCount: overlapCountMap.get(item.id) || 0, + })) .filter((item) => item.overlapCount > 0) .sort((left, right) => { if (right.overlapCount !== left.overlapCount) { @@ -1037,7 +1073,7 @@ class AgentsService extends Service { this.ctx.throw(400, 'Agent 名称不能为空'); } - const { Agent, AgentFile } = this.app.model; + const { Agent, AgentFile, AgentSkill } = this.app.model; const row = await Agent.findOne({ where: { name, @@ -1060,6 +1096,10 @@ class AgentsService extends Service { where: { agent_id: row.id }, transaction, }); + await AgentSkill.destroy({ + where: { agent_id: row.id }, + transaction, + }); }); try { diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js index 080029c..4a95c94 100644 --- a/test/agent-market-service.test.js +++ b/test/agent-market-service.test.js @@ -321,3 +321,173 @@ test('getAgentDetail 返回规范化的 plugin 展示字段', async () => { assert.equal('dependencies' in detail, false); assert.equal('privateSkills' in detail, false); }); + +test('getRelatedAgents 在无关联技能时返回空数组,不进行无效查询', async () => { + const service = createService(); + service.storageReady = true; + service.app.model = { + Agent: { + async findOne() { + return { id: 1, name: 'agent-1' }; + }, + }, + AgentSkill: { + async findAll() { + return []; + }, + }, + }; + + const result = await service.getRelatedAgents('agent-1'); + assert.deepEqual(result, []); +}); + +test('getRelatedAgents 根据技能重叠数降序推荐相关 Agent 并排除自身', async () => { + const service = createService(); + service.storageReady = true; + service.app.Sequelize = { Op: { ne: Symbol('ne') } }; + + service.app.model = { + Agent: { + async findOne({ where }) { + if (where.name === 'target-agent') { + return { id: 1, name: 'target-agent' }; + } + return null; + }, + async findAll({ where }) { + const agents = [ + { + id: 2, + name: 'agent-high-overlap', + display_name: 'High Overlap Agent', + updated_at: new Date('2026-01-01T00:00:00Z'), + }, + { + id: 3, + name: 'agent-low-overlap', + display_name: 'Low Overlap Agent', + updated_at: new Date('2026-01-02T00:00:00Z'), + }, + ]; + return agents.filter((a) => where.id.includes(a.id)); + }, + }, + AgentSkill: { + async findAll({ where }) { + // target agent skills query + if (where.agent_id === 1) { + return [{ skill_slug: 'skill-a' }, { skill_slug: 'skill-b' }]; + } + // related skills query + return [ + { agent_id: 2, skill_slug: 'skill-a' }, + { agent_id: 2, skill_slug: 'skill-b' }, + { agent_id: 3, skill_slug: 'skill-a' }, + ]; + }, + }, + }; + + const result = await service.getRelatedAgents('target-agent', 10); + assert.equal(result.length, 2); + assert.equal(result[0].name, 'agent-high-overlap'); + assert.equal(result[0].overlapCount, 2); + assert.equal(result[1].name, 'agent-low-overlap'); + assert.equal(result[1].overlapCount, 1); +}); + +test('deleteAgent 软删除 Agent 并清理 AgentFile 与 AgentSkill 关联数据', async () => { + const service = createService(); + service.storageReady = true; + service.getAgentMarketConfig = () => ({ storageDir: '/tmp/test-storage' }); + service.removeDirectory = () => {}; + + let agentUpdated = false; + let filesDestroyed = false; + let skillsDestroyed = false; + + service.app.model = { + Agent: { + async findOne() { + return { id: 10, name: 'test-agent', content_hash: 'hash-1' }; + }, + async update(values, { where }) { + if (values.is_delete === 1 && where.id === 10) { + agentUpdated = true; + } + }, + }, + AgentFile: { + async destroy({ where }) { + if (where.agent_id === 10) { + filesDestroyed = true; + } + }, + }, + AgentSkill: { + async destroy({ where }) { + if (where.agent_id === 10) { + skillsDestroyed = true; + } + }, + }, + async transaction(callback) { + return await callback({}); + }, + }; + + const res = await service.deleteAgent({ name: 'test-agent' }); + assert.equal(res.deleted, true); + assert.equal(agentUpdated, true); + assert.equal(filesDestroyed, true); + assert.equal(skillsDestroyed, true); +}); + +test('parseAgentZip 过滤 .codex-plugin/assets 避免二进制图片存入快照文件列表', async () => { + const service = createService(); + const fixture = createPluginZip({ logoPath: '.codex-plugin/assets/logo.png' }); + + try { + const parsed = await service.parseAgentZip(fixture.zipPath); + assert.match(parsed.agent.logo.path, /\.codex-plugin\/assets\/logo\.png$/); + const hasAssetInFiles = parsed.files.some((f) => + f.filePath.startsWith('.codex-plugin/assets/') + ); + assert.equal(hasAssetInFiles, false); + } finally { + fixture.cleanup(); + } +}); + +test('ensureAgentSkillsTableCompatible 兼容处理历史 relation_type 非空约束', async () => { + const service = createService(); + let changed = false; + service.app.Sequelize = { STRING: (len) => `VARCHAR(${len})` }; + service.app.model = { + getQueryInterface() { + return { + async describeTable() { + return { + relation_type: { + type: 'VARCHAR(20)', + allowNull: false, + }, + }; + }, + async changeColumn(table, col, def) { + if ( + table === 'agent_skills' && + col === 'relation_type' && + def.allowNull === true + ) { + changed = true; + } + }, + }; + }, + }; + + await service.ensureAgentSkillsTableCompatible(); + assert.equal(changed, true); +}); From 0169a826d427199ffc2a77db1542123a37bc1d99 Mon Sep 17 00:00:00 2001 From: liuxy0551 Date: Wed, 9 Sep 2026 11:25:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?style(agents):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=9C=AA=E5=BC=95=E7=94=A8=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E5=9C=A8=E7=9B=B8=E5=85=B3=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E4=B8=AD=E8=A7=84=E8=8C=83=E5=8C=96=E6=A8=A1=E5=9E=8B=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/service/agents.js | 24 +++++++++++++++--------- app/web/pages/agents/detail/style.scss | 12 ------------ 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/app/service/agents.js b/app/service/agents.js index fbed190..8294927 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -952,14 +952,16 @@ class AgentsService extends Service { return []; } - // 统计各候选 Agent 的技能重叠数 - const overlapCountMap = new Map(); + // 统计各候选 Agent 的技能重叠数,使用 Set 防御重复关联 + const overlapSkillMap = new Map(); relatedSkillRows.forEach((item) => { - const current = overlapCountMap.get(item.agent_id) || 0; - overlapCountMap.set(item.agent_id, current + 1); + if (!overlapSkillMap.has(item.agent_id)) { + overlapSkillMap.set(item.agent_id, new Set()); + } + overlapSkillMap.get(item.agent_id).add(item.skill_slug); }); - const candidateIds = Array.from(overlapCountMap.keys()); + const candidateIds = Array.from(overlapSkillMap.keys()); const agentRows = await Agent.findAll({ where: { id: candidateIds, @@ -968,10 +970,14 @@ class AgentsService extends Service { }); return agentRows - .map((item) => ({ - ...this.toAgentListItem(item), - overlapCount: overlapCountMap.get(item.id) || 0, - })) + .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) { diff --git a/app/web/pages/agents/detail/style.scss b/app/web/pages/agents/detail/style.scss index 3ef7d88..8927745 100644 --- a/app/web/pages/agents/detail/style.scss +++ b/app/web/pages/agents/detail/style.scss @@ -215,18 +215,6 @@ flex-direction: column; gap: 12px; } - .agent-capability-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - .agent-capability-label { - color: #667085; - } - &.agent-overview-tags { - margin-top: 12px; - } - } .agent-skill-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));