diff --git a/.gitignore b/.gitignore index fc686bc6..07519af5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ cache .history public resources +agent-market app/view # Research docs (working notes, not for commit) diff --git a/AGENTS.md b/AGENTS.md index bb143826..ca7d80c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. -**项目**: dt-doraemon (哆啦A梦) — 开发者工具箱平台,包含代理服务、主机管理、配置中心、MCP 服务器注册中心、Skills 市场等模块。 +**项目**: dt-doraemon (哆啦A梦) — 开发者工具箱平台,包含代理服务、主机管理、配置中心、MCP 服务器注册中心、Skill 市场、Agent 市场等模块。 **技术栈**: Egg.js 2.x + React 16 SSR + MySQL (Sequelize) + Webpack 4 + Redux + Socket.IO。 diff --git a/app/controller/agents.js b/app/controller/agents.js new file mode 100644 index 00000000..e945de1f --- /dev/null +++ b/app/controller/agents.js @@ -0,0 +1,73 @@ +const Controller = require('egg').Controller; +const fs = require('fs'); + +class AgentsController extends Controller { + async getAgentList() { + const data = await this.ctx.service.agents.queryAgentList(this.ctx.query); + this.ctx.body = this.app.utils.response(true, data); + } + + async getAgentDetail() { + const data = await this.ctx.service.agents.getAgentDetail(this.ctx.query.name); + 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); + this.ctx.set('Content-Type', mimeType); + this.ctx.set('Cache-Control', cacheControl); + this.ctx.body = stream; + } + + async downloadAgentArchive() { + const { stream, fileName, mimeType } = await this.ctx.service.agents.getAgentArchiveStream( + this.ctx.query.name + ); + this.ctx.set('Content-Type', mimeType); + this.ctx.set('Content-Disposition', `attachment; filename="${fileName}"`); + this.ctx.body = stream; + } + + async importAgentFile() { + const files = this.ctx.request.files + ? Array.isArray(this.ctx.request.files) + ? this.ctx.request.files + : [this.ctx.request.files] + : []; + const file = files[0]; + + if (!file) { + this.ctx.throw(400, '缺少上传文件'); + } + + try { + const data = await this.ctx.service.agents.importAgentFile( + this.ctx.request.body || {}, + file + ); + 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}`); + } + } + } + } + + async deleteAgent() { + const data = await this.ctx.service.agents.deleteAgent(this.ctx.request.body || {}); + this.ctx.body = this.app.utils.response(true, data); + } +} + +module.exports = AgentsController; diff --git a/app/controller/common.js b/app/controller/common.js index 05711dbf..1f9f97d0 100644 --- a/app/controller/common.js +++ b/app/controller/common.js @@ -19,7 +19,8 @@ class CommonController extends Controller { } async getLocalIp() { const { app, ctx } = this; - const localIp = ctx.header['x-real-ip'] || ctx.ip; + const rawIp = ctx.header['x-real-ip'] || ctx.ip; + const localIp = rawIp === '::1' ? '127.0.0.1' : rawIp; ctx.body = app.utils.response(true, { localIp, host: ctx.host, diff --git a/app/model/agent.js b/app/model/agent.js new file mode 100644 index 00000000..8912de83 --- /dev/null +++ b/app/model/agent.js @@ -0,0 +1,144 @@ +module.exports = (app) => { + const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize; + + const Agent = app.model.define( + 'agent', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: STRING(100), + allowNull: false, + unique: true, + comment: 'Agent 唯一标识', + }, + display_name: { + type: STRING(255), + allowNull: false, + comment: 'Agent 展示名称', + }, + version: { + type: STRING(64), + allowNull: false, + defaultValue: '', + comment: 'Agent 版本号', + }, + description: { + type: TEXT, + comment: '列表摘要', + }, + profile: { + type: TEXT('long'), + comment: 'Agent 详细简介', + }, + author_name: { + type: STRING(255), + comment: '作者', + }, + category: { + type: STRING(64), + allowNull: false, + defaultValue: '通用', + comment: '分类', + }, + tags: { + type: TEXT('long'), + comment: 'JSON 字符串数组', + }, + prompts: { + type: TEXT('long'), + comment: 'JSON 字符串数组', + }, + capabilities: { + 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 相对路径', + }, + logo_mime_type: { + type: STRING(100), + comment: 'Logo MIME', + }, + logo_size: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: 'Logo 大小', + }, + logo_hash: { + type: STRING(128), + comment: 'Logo 哈希', + }, + content_hash: { + type: STRING(128), + allowNull: false, + comment: '内容哈希', + }, + source_file_name: { + type: STRING(255), + comment: '上传文件名', + }, + file_count: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: '文件数量', + }, + is_delete: { + type: TINYINT, + 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: 'agents', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { unique: true, fields: ['name'] }, + { fields: ['category'] }, + { fields: ['updated_at'] }, + ], + } + ); + + return Agent; +}; diff --git a/app/model/agent_file.js b/app/model/agent_file.js new file mode 100644 index 00000000..daf51c7d --- /dev/null +++ b/app/model/agent_file.js @@ -0,0 +1,84 @@ +module.exports = (app) => { + const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize; + + const AgentFile = app.model.define( + 'agent_file', + { + id: { + type: INTEGER, + primaryKey: true, + autoIncrement: true, + }, + agent_id: { + type: INTEGER, + allowNull: false, + comment: 'agents.id', + }, + file_path: { + type: STRING(512), + allowNull: false, + comment: 'Agent 内相对路径', + }, + mime_type: { + type: STRING(100), + comment: '文件 MIME', + }, + size: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: '文件大小', + }, + is_binary: { + type: TINYINT, + allowNull: false, + defaultValue: 0, + comment: '是否二进制', + }, + encoding: { + type: STRING(20), + allowNull: false, + defaultValue: 'utf8', + comment: '内容编码', + }, + mode: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: 'Unix 权限', + }, + content: { + type: TEXT('long'), + comment: '文件内容', + }, + is_delete: { + type: TINYINT, + allowNull: false, + defaultValue: 0, + }, + created_at: { + type: DATE, + allowNull: false, + defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: DATE, + allowNull: false, + defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }, + { + freezeTableName: true, + tableName: 'agent_files', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { unique: true, fields: ['agent_id', 'file_path'] }, + { fields: ['agent_id'] }, + ], + } + ); + + return AgentFile; +}; diff --git a/app/model/agent_skill.js b/app/model/agent_skill.js new file mode 100644 index 00000000..98b9c174 --- /dev/null +++ b/app/model/agent_skill.js @@ -0,0 +1,64 @@ +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', + }, + 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 c3f6c8fd..a30a8133 100644 --- a/app/router.js +++ b/app/router.js @@ -147,7 +147,7 @@ module.exports = (app) => { app.post('/api/mcp-servers/health/all', app.controller.mcp.checkAllMCPServersHealth); /** - * Skills 市场 + * Skill 市场 */ app.get('/api/skills/list', app.controller.skills.getSkillList); app.get('/api/skills/detail', app.controller.skills.getSkillDetail); @@ -162,6 +162,17 @@ module.exports = (app) => { app.post('/api/skills/unlike', app.controller.skillLike.unlike); app.get('/api/skills/like-status', app.controller.skillLike.getLikeStatus); + /** + * Agent 市场 + */ + 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); + app.post('/api/agents/delete', app.controller.agents.deleteAgent); + /** * Skills Registry API (v1) */ diff --git a/app/service/agents.js b/app/service/agents.js new file mode 100644 index 00000000..04be1924 --- /dev/null +++ b/app/service/agents.js @@ -0,0 +1,1184 @@ +const Service = require('egg').Service; +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 } = require('../utils/skill-utils'); +const { + isValidSkillCategory, + SKILL_CATEGORY_OPTIONS, +} = require('../../contracts/skill-categories'); + +const AGENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +class AgentsService extends Service { + constructor(ctx) { + super(ctx); + this.storageReady = false; + this.storageReadyPromise = null; + } + + getAgentMarketConfig() { + return { + storageDir: '/data/doraemon/agent-market', + maxZipSize: 50 * 1024 * 1024, + maxExtractedSize: 200 * 1024 * 1024, + maxFileCount: 500, + maxSingleFileSize: 20 * 1024 * 1024, + maxImageSize: 5 * 1024 * 1024, + ...this.app.config.agentMarket, + }; + } + + async ensureStorageReady() { + if (this.storageReady) return; + if (this.storageReadyPromise) { + await this.storageReadyPromise; + return; + } + + this.storageReadyPromise = (async () => { + 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; + })(); + + try { + await this.storageReadyPromise; + } finally { + this.storageReadyPromise = null; + } + } + + normalizeAgentPath(filePath, message = '非法文件路径') { + const normalized = normalizeRelativePath(String(filePath || '').replace(/^\.\//, '')); + if (!normalized) { + this.ctx.throw(400, message); + } + return normalized; + } + + parseJsonArray(value) { + if (!value) return []; + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } + } + + isLikelyBinary(buffer) { + if (!buffer || buffer.length === 0) return false; + const sample = buffer.subarray(0, Math.min(buffer.length, 4096)); + if (sample.includes(0)) return true; + try { + new TextDecoder('utf-8', { fatal: true }).decode(buffer); + return false; + } catch { + return true; + } + } + + getZipEntryMode(entry) { + const attr = Number(entry?.attr || entry?.header?.attr || 0); + const mode = (attr >>> 16) & 0xffff; + return mode || 0o644; + } + + isSymbolicLink(entry) { + const mode = this.getZipEntryMode(entry); + return (mode & 0o170000) === 0o120000; + } + + validateAgentName(name) { + const value = String(name || '').trim(); + if (!AGENT_NAME_PATTERN.test(value) || value.length > 100) { + this.ctx.throw(400, 'metadata.name 格式无效'); + } + return value; + } + + validateAgentVersion(version) { + const value = String(version || '').trim(); + if (!SEMVER_PATTERN.test(value)) { + this.ctx.throw(400, 'metadata.version 必须是有效的 SemVer 格式'); + } + return value; + } + + parseSemver(version) { + const match = String(version || '') + .trim() + .match(SEMVER_PATTERN); + if (!match) { + this.ctx.throw(400, 'metadata.version 必须是有效的 SemVer 格式'); + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || '', + }; + } + + compareAgentVersion(left, right) { + const a = this.parseSemver(left); + const b = this.parseSemver(right); + const keys = ['major', 'minor', 'patch']; + for (const key of keys) { + if (a[key] > b[key]) return 1; + if (a[key] < b[key]) return -1; + } + + if (!a.prerelease && !b.prerelease) return 0; + if (!a.prerelease) return 1; + if (!b.prerelease) return -1; + 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}`); + } + + 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, + }); + }); + + 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) { + try { + return yaml.load(rawContent); + } catch (error) { + this.ctx.throw(400, `agent.yaml 解析失败: ${error.message}`); + } + } + + getDemoImagePath(item, index) { + // demo.images 只接受 src,避免和其他文件路径字段语义混淆 + return this.normalizeAgentPath(item?.src || '', `spec.demo.images[${index}] 路径非法`); + } + + normalizeCapabilities(capabilities) { + if (!Array.isArray(capabilities)) return []; + + return capabilities + .map((item) => { + if (typeof item === 'string') { + return { + id: '', + name: item.trim(), + description: '', + }; + } + if (item && typeof item === 'object') { + return { + id: String(item.id || '').trim(), + name: String(item.name || item.description || '').trim(), + description: String(item.description || '').trim(), + }; + } + return null; + }) + .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'); + } + + const metadata = manifest.metadata || {}; + const spec = manifest.spec || {}; + const author = metadata.author || {}; + const entrypoint = spec.entrypoint || {}; + + const name = this.validateAgentName(metadata.name); + const version = this.validateAgentVersion(metadata.version); + const category = String(metadata.category || '').trim(); + + if (!isValidSkillCategory(category)) { + this.ctx.throw(400, `category 无效,可选: ${SKILL_CATEGORY_OPTIONS.join(', ')}`); + } + + const displayName = String(metadata.displayName || '').trim(); + if (!displayName) { + this.ctx.throw(400, 'metadata.displayName 不能为空'); + } + + const description = String(metadata.description || '').trim(); + if (!description) { + this.ctx.throw(400, 'metadata.description 不能为空'); + } + + const authorName = String(author.name || '').trim(); + if (!authorName) { + this.ctx.throw(400, 'metadata.author.name 不能为空'); + } + + const profile = String(spec.profile || '').trim(); + if (!profile) { + this.ctx.throw(400, 'spec.profile 不能为空'); + } + + 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}`); + } + + 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}`); + } + }); + + return { + name, + displayName, + version, + 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) + : [], + }; + } + + buildContentHash(records) { + const hash = crypto.createHash('sha256'); + records + .slice() + .sort((left, right) => left.filePath.localeCompare(right.filePath)) + .forEach((item) => { + hash.update(item.filePath); + hash.update('\0'); + hash.update(item.buffer); + hash.update('\0'); + }); + return hash.digest('hex'); + } + + async parseAgentZip(zipPath) { + const config = this.getAgentMarketConfig(); + let zip; + + try { + zip = new AdmZip(zipPath); + } catch (error) { + this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`); + } + + const entries = zip.getEntries().filter((entry) => { + const normalizedName = String(entry.entryName || '').replace(/\\/g, '/'); + if (!normalizedName) return false; + if (normalizedName.startsWith('__MACOSX/')) return false; + if (normalizedName.endsWith('.DS_Store')) return false; + return true; + }); + + const fileEntries = entries.filter((entry) => !entry.isDirectory); + if (fileEntries.length === 0) { + this.ctx.throw(400, '.zip 包内未发现有效文件'); + } + if (fileEntries.length > config.maxFileCount) { + this.ctx.throw(400, `文件数量超过限制: ${config.maxFileCount}`); + } + + const caseInsensitivePaths = new Set(); + const topLevelDirs = new Set(); + const fileRecords = []; + const fileMap = new Map(); + let extractedSize = 0; + + // 逐个 ZIP 条目校验路径、大小和特殊文件 + fileEntries.forEach((entry) => { + if (this.isSymbolicLink(entry)) { + this.ctx.throw(400, `不支持软链接: ${entry.entryName}`); + } + + const normalized = this.normalizeAgentPath(entry.entryName); + const lowerCasePath = normalized.toLowerCase(); + if (caseInsensitivePaths.has(lowerCasePath)) { + this.ctx.throw(400, `检测到重复路径: ${normalized}`); + } + caseInsensitivePaths.add(lowerCasePath); + + const buffer = entry.getData(); + if (buffer.length > config.maxSingleFileSize) { + this.ctx.throw(400, `文件超过大小限制: ${normalized}`); + } + + extractedSize += buffer.length; + if (extractedSize > config.maxExtractedSize) { + this.ctx.throw(400, `解压后总大小超过限制: ${config.maxExtractedSize}`); + } + + const [topLevel] = normalized.split('/'); + if (topLevel) { + topLevelDirs.add(topLevel); + } + + fileRecords.push({ + entry, + filePath: normalized, + buffer, + size: buffer.length, + }); + fileMap.set(normalized, { + entry, + buffer, + size: buffer.length, + }); + }); + + if (topLevelDirs.size !== 1) { + this.ctx.throw(400, 'ZIP 顶层必须且只能包含一个 Agent 目录'); + } + + const [rootDir] = [...topLevelDirs]; + const agentYamlPath = `${rootDir}/agent.yaml`; + const agentYamlEntry = fileMap.get(agentYamlPath); + if (!agentYamlEntry) { + this.ctx.throw(400, 'ZIP 中缺少根目录 agent.yaml'); + } + + const relativeFileMap = new Map(); + fileRecords.forEach((item) => { + const relativePath = item.filePath.slice(rootDir.length + 1); + if (!relativePath) return; + relativeFileMap.set(relativePath, { + ...item, + relativePath, + }); + }); + + const manifest = this.parseAgentYaml(agentYamlEntry.buffer.toString('utf8')); + const validated = this.validateManifest(manifest, relativeFileMap); + const contentHash = this.buildContentHash( + fileRecords.map((item) => ({ + filePath: item.filePath, + buffer: item.buffer, + })) + ); + + 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}`); + } + if (record.size > config.maxImageSize) { + this.ctx.throw(400, `Demo 图片超过大小限制: ${rawPath}`); + } + + const storedPath = this.buildAssetTargetPath(validated.name, contentHash, rawPath); + return { + path: storedPath, + originalPath: rawPath, + mimeType, + size: record.size, + hash: crypto.createHash('sha256').update(record.buffer).digest('hex'), + alt: String(item.alt || '').trim(), + sortOrder: index, + buffer: record.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, + }; + + const files = [...relativeFileMap.values()] + .filter((item) => !item.relativePath.startsWith('assets/')) + .map((item) => { + const isBinary = this.isLikelyBinary(item.buffer); + return { + filePath: item.relativePath, + mimeType: mime.lookup(item.relativePath) || 'application/octet-stream', + size: item.size, + isBinary, + encoding: isBinary ? 'base64' : 'utf8', + mode: this.getZipEntryMode(item.entry), + content: isBinary + ? item.buffer.toString('base64') + : item.buffer.toString('utf8'), + }; + }); + + return { + agent: { + name: validated.name, + displayName: validated.displayName, + version: validated.version, + description: validated.description, + profile: validated.profile, + authorName: validated.authorName, + category: validated.category, + tags: validated.tags, + prompts: validated.prompts, + 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, + contentHash, + fileCount: fileRecords.length, + }, + logo, + demoImages, + files, + skillRelations: this.buildSkillRelations(validated.name, manifest), + assetFiles: [logo, ...demoImages], + }; + } + + async writeAssetFiles(assetFiles = []) { + const storageDir = this.getAgentMarketConfig().storageDir; + const touchedDirs = new Set(); + + assetFiles.forEach((item) => { + const absolutePath = path.join(storageDir, item.path); + const parentDir = path.dirname(absolutePath); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync(absolutePath, item.buffer); + touchedDirs.add(path.join(storageDir, item.path.split('/').slice(0, 2).join('/'))); + }); + + return touchedDirs; + } + + async writeAgentArchive(agent, sourcePath) { + const storageDir = this.getAgentMarketConfig().storageDir; + const archiveDir = path.join(storageDir, agent.name, agent.contentHash); + const archivePath = path.join(archiveDir, `${agent.name}.zip`); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.copyFileSync(sourcePath, archivePath); + return archiveDir; + } + + removeDirectory(targetPath) { + if (!targetPath || !fs.existsSync(targetPath)) return; + 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, '上传文件无效'); + } + if (!String(file.filename).toLowerCase().endsWith('.zip')) { + this.ctx.throw(400, '仅支持上传 .zip 文件'); + } + + const config = this.getAgentMarketConfig(); + if (file.size && file.size > config.maxZipSize) { + this.ctx.throw(400, `ZIP 文件超过大小限制 ${config.maxZipSize / 1024 / 1024}MB`); + } + + await this.ensureStorageReady(); + + const parsed = await this.parseAgentZip(file.filepath); + const { Agent, AgentFile, AgentSkill } = this.app.model; + const existing = await Agent.findOne({ + where: { + name: parsed.agent.name, + }, + }); + + if (existing && Number(existing.is_delete) !== 1) { + const versionDiff = this.compareAgentVersion(parsed.agent.version, existing.version); + if (versionDiff < 0) { + this.ctx.throw( + 400, + `低版本禁止覆盖,当前版本 ${existing.version},导入版本 ${parsed.agent.version}` + ); + } + + if (existing.content_hash === parsed.agent.contentHash) { + await this.writeAgentArchive(parsed.agent, file.filepath); + return { + unchanged: true, + name: parsed.agent.name, + version: parsed.agent.version, + message: '内容未变化', + }; + } + + const confirmed = String(params.confirmOverwrite || '').trim() === 'true'; + if (!confirmed) { + return { + requiresConfirm: true, + name: parsed.agent.name, + currentVersion: existing.version, + incomingVersion: parsed.agent.version, + }; + } + } + + let touchedDirs = new Set(); + + try { + touchedDirs = await this.writeAssetFiles(parsed.assetFiles); + touchedDirs.add(await this.writeAgentArchive(parsed.agent, file.filepath)); + const result = await this.app.model.transaction(async (transaction) => { + let agentId = existing ? existing.id : null; + + const agentPayload = { + name: parsed.agent.name, + display_name: parsed.agent.displayName, + version: parsed.agent.version, + description: parsed.agent.description, + profile: parsed.agent.profile, + 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, + })) + ), + 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, + content_hash: parsed.agent.contentHash, + source_file_name: file.filename, + file_count: parsed.agent.fileCount, + is_delete: 0, + }; + + if (!existing) { + const created = await Agent.create(agentPayload, { transaction }); + agentId = created.id; + } else { + await Agent.update(agentPayload, { + where: { id: existing.id }, + transaction, + }); + agentId = existing.id; + await AgentFile.destroy({ + where: { agent_id: agentId }, + transaction, + }); + await AgentSkill.destroy({ + where: { agent_id: agentId }, + transaction, + }); + } + + const fileRows = parsed.files.map((item) => ({ + agent_id: agentId, + file_path: item.filePath, + mime_type: item.mimeType, + size: item.size, + is_binary: item.isBinary ? 1 : 0, + encoding: item.encoding, + mode: item.mode, + content: item.content, + is_delete: 0, + })); + + if (fileRows.length > 0) { + 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, + version: parsed.agent.version, + updated: existing && Number(existing.is_delete) !== 1, + contentHash: parsed.agent.contentHash, + }; + }); + + if ( + existing && + existing.content_hash && + existing.content_hash !== parsed.agent.contentHash + ) { + this.removeDirectory( + path.join( + this.getAgentMarketConfig().storageDir, + `${parsed.agent.name}/${existing.content_hash}` + ) + ); + } + + return result; + } catch (error) { + touchedDirs.forEach((dir) => this.removeDirectory(dir)); + throw error; + } + } + + toAgentListItem(row) { + const dependencies = this.parseJsonArray(row.dependencies || '[]'); + return { + name: row.name, + displayName: row.display_name, + description: row.description || '', + authorName: row.author_name || '', + category: row.category || '通用', + 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), + }; + } + + async queryAgentList(params = {}) { + await this.ensureStorageReady(); + const { Agent, AgentSkill } = this.app.model; + const keyword = String(params.keyword || '').trim(); + const category = String(params.category || '').trim(); + const pageNum = Math.max(Number(params.pageNum) || 1, 1); + const pageSize = Math.max(Number(params.pageSize) || 12, 1); + const { Op } = this.app.Sequelize; + const where = { + is_delete: 0, + }; + + if (category) { + where.category = category; + } + + if (keyword) { + where[Op.or] = [ + { name: { [Op.like]: `%${keyword}%` } }, + { display_name: { [Op.like]: `%${keyword}%` } }, + { description: { [Op.like]: `%${keyword}%` } }, + { author_name: { [Op.like]: `%${keyword}%` } }, + { tags: { [Op.like]: `%${keyword}%` } }, + ]; + } + + const { count, rows } = await Agent.findAndCountAll({ + where, + order: [ + ['updated_at', 'DESC'], + ['id', 'DESC'], + ], + offset: (pageNum - 1) * pageSize, + 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: 'dependency', + }, + }) + : []; + + 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] || []), + }) + ); + + return { + list, + total: count, + pageNum, + pageSize, + categories: [...SKILL_CATEGORY_OPTIONS], + }; + } + + async getAgentDetail(name) { + await this.ensureStorageReady(); + const { Agent, AgentSkill, SkillsItem } = this.app.model; + const row = await Agent.findOne({ + where: { + name, + is_delete: 0, + }, + }); + if (!row) { + 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 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 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 || '', + authorName: detail.author_name || '', + category: detail.category || '通用', + tags: this.parseJsonArray(detail.tags), + prompts: this.parseJsonArray(detail.prompts), + capabilities, + version: detail.version || '', + logoUrl: this.buildAssetUrl(detail.name, detail.logo_path), + logoPath: detail.logo_path, + demoImages, + entrypoint: entrypoint + ? { + slug: entrypoint.skill_slug, + name: skillMap.get(entrypoint.skill_slug) + ? skillMap.get(entrypoint.skill_slug).name + : entrypoint.skill_slug, + collected: Boolean(skillMap.get(entrypoint.skill_slug)), + path: `/page/skills/${entrypoint.skill_slug}`, + } + : null, + dependencies, + 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(); + const requestedPath = this.normalizeAgentPath(params.path); + const { Agent } = this.app.model; + const row = await Agent.findOne({ + where: { + name, + is_delete: 0, + }, + }); + if (!row) { + 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) { + this.ctx.throw(404, '资源不存在'); + } + + const storageDir = this.getAgentMarketConfig().storageDir; + const absolutePath = path.join(storageDir, requestedPath); + const resolvedStorageDir = path.resolve(storageDir); + const resolvedFilePath = path.resolve(absolutePath); + if ( + resolvedFilePath !== resolvedStorageDir && + !resolvedFilePath.startsWith(`${resolvedStorageDir}${path.sep}`) + ) { + this.ctx.throw(400, '资源路径非法'); + } + + if (!fs.existsSync(resolvedFilePath)) { + this.ctx.throw(404, '资源不存在'); + } + + return { + stream: fs.createReadStream(resolvedFilePath), + mimeType, + cacheControl: requestedPath.includes(`/${row.content_hash}/`) + ? 'public, max-age=31536000, immutable' + : 'public, max-age=300', + }; + } + + async getAgentArchiveStream(name) { + await this.ensureStorageReady(); + const agentName = this.validateAgentName(name); + const { Agent } = this.app.model; + const row = await Agent.findOne({ + where: { + name: agentName, + is_delete: 0, + }, + }); + if (!row) { + this.ctx.throw(404, 'Agent 不存在'); + } + + const fileName = `${row.name}.zip`; + const archivePath = path.join( + this.getAgentMarketConfig().storageDir, + row.name, + row.content_hash, + fileName + ); + if (!fs.existsSync(archivePath)) { + this.ctx.throw(404, 'Agent 原始 ZIP 不存在,请重新导入后再试'); + } + + return { + stream: fs.createReadStream(archivePath), + fileName, + mimeType: 'application/zip', + }; + } + + async deleteAgent(params = {}) { + await this.ensureStorageReady(); + const name = String(params.name || '').trim(); + if (!name) { + this.ctx.throw(400, 'Agent 名称不能为空'); + } + + const { Agent, AgentFile, AgentSkill } = this.app.model; + const row = await Agent.findOne({ + where: { + name, + is_delete: 0, + }, + }); + if (!row) { + this.ctx.throw(404, 'Agent 不存在'); + } + + await this.app.model.transaction(async (transaction) => { + await Agent.update( + { is_delete: 1 }, + { + where: { id: row.id }, + transaction, + } + ); + await AgentFile.destroy({ + where: { agent_id: row.id }, + transaction, + }); + await AgentSkill.destroy({ + where: { agent_id: row.id }, + transaction, + }); + }); + + try { + this.removeDirectory( + path.join(this.getAgentMarketConfig().storageDir, `${row.name}/${row.content_hash}`) + ); + } catch (error) { + this.ctx.logger.warn(`[agents] 清理资源目录失败: ${error.message}`); + } + + return { + name: row.name, + deleted: true, + }; + } +} + +module.exports = AgentsService; diff --git a/app/web/api/url.ts b/app/web/api/url.ts index 1c7986d5..685ba0b5 100644 --- a/app/web/api/url.ts +++ b/app/web/api/url.ts @@ -339,7 +339,7 @@ export default { }, /** - * Skills 市场 + * Skill 市场 */ // 获取 Skills 列表 getSkillList: { @@ -401,4 +401,32 @@ export default { method: 'get', url: '/api/skills/like-status', }, + + /** + * Agent 市场 + */ + getAgentList: { + method: 'get', + url: '/api/agents/list', + }, + getAgentDetail: { + method: 'get', + url: '/api/agents/detail', + }, + getRelatedAgents: { + method: 'get', + url: '/api/agents/related', + }, + downloadAgentArchive: { + method: 'get', + url: '/api/agents/download', + }, + importAgentFile: { + method: 'postForm', + url: '/api/agents/import-file', + }, + deleteAgent: { + method: 'post', + url: '/api/agents/delete', + }, }; diff --git a/app/web/layouts/basicLayout/index.tsx b/app/web/layouts/basicLayout/index.tsx index 1e79f7fb..425946c4 100644 --- a/app/web/layouts/basicLayout/index.tsx +++ b/app/web/layouts/basicLayout/index.tsx @@ -6,12 +6,13 @@ import classnames from 'classnames'; import Header from '../header/header'; import './style.scss'; +const { shouldUseSkillDetailLayout } = require('./layout-flags'); const { Content } = Layout; const BasicLayout = (props: any) => { const { className, route, location } = props; const { pathname } = location; - const isSkillDetailPage = /^\/page\/skills\/[^/]+$/.test(pathname); + const isSkillDetailPage = shouldUseSkillDetailLayout(pathname); // 如果弹出过哆啦A梦 Chrome 插件的弹框,则后续不再弹出 React.useEffect(() => { diff --git a/app/web/layouts/basicLayout/layout-flags.js b/app/web/layouts/basicLayout/layout-flags.js new file mode 100644 index 00000000..4053a76b --- /dev/null +++ b/app/web/layouts/basicLayout/layout-flags.js @@ -0,0 +1,10 @@ +'use strict'; + +function shouldUseSkillDetailLayout(pathname) { + const targetPath = String(pathname || ''); + return /^\/page\/skills\/[^/]+$/.test(targetPath); +} + +module.exports = { + shouldUseSkillDetailLayout, +}; diff --git a/app/web/layouts/header/header.tsx b/app/web/layouts/header/header.tsx index 41849e11..acff9919 100644 --- a/app/web/layouts/header/header.tsx +++ b/app/web/layouts/header/header.tsx @@ -7,7 +7,9 @@ import { CloudOutlined, CloudServerOutlined, DesktopOutlined, + MoreOutlined, QuestionCircleOutlined, + RobotOutlined, SettingOutlined, SyncOutlined, TagOutlined, @@ -19,82 +21,38 @@ import { bindActionCreators } from 'redux'; import logo from '@/asset/images/logo.png'; import * as actions from '@/store/actions'; import config from '../../../../env.json'; +import { getMenuState, NAV_MENU_LIST } from './nav-config'; import './style.scss'; const { SubMenu } = Menu; const { Header } = Layout; -const navMenuList: any = [ - { - name: '应用中心', - path: '/page/toolbox', - icon: , - routers: ['toolbox', 'switch-hosts-list', 'switch-hosts-edit', 'article-subscription-list'], - }, - { - name: '代理服务', - path: '/page/proxy-server', - icon: , - routers: ['proxy-server'], - }, - { - name: 'MCP', - path: '/page/mcp-server-market', - icon: , - routers: [ - 'mcp-server-market', - 'mcp-server-registry', - 'mcp-server-management', - 'mcp-server-detail', - 'mcp-server-inspector', - ], - }, - { - name: 'Skills', - path: '/page/skills', - icon: , - routers: ['skills'], - }, - { - name: '主机管理', - path: '/page/host-management', - icon: , - routers: ['host-management'], - }, - { - name: '环境管理', - path: '/page/env-management', - icon: , - routers: ['env-management'], - }, - { - name: '配置中心', - path: '/page/config-center', - icon: , - routers: ['config-center', 'config-detail'], - }, - { - name: '标签管理', - path: '/page/tags', - icon: , - routers: ['tags'], - }, -]; +const iconMap: Record = { + appstore: , + cloud: , + ungroup: , + book: , + robot: , + more: , + 'cloud-server': , + desktop: , + setting: , + tag: , +}; + const HeaderComponent = (props: any) => { const { location } = props; const { localIp = '127.0.0.1' } = useSelector((state: any) => state.global); const { pathname } = location; - const [selectedKeys, setSelectedKeys] = useState([pathname]); + const initialMenuState = getMenuState(pathname, NAV_MENU_LIST); + const [selectedKeys, setSelectedKeys] = useState(initialMenuState.selectedKeys); + const [openKeys, setOpenKeys] = useState(initialMenuState.openKeys); const { changeLocalIp } = bindActionCreators(actions, useDispatch()); - const handleSelectedKeys = (e: any) => { - setSelectedKeys(e.key); - }; useEffect(() => { - const current = navMenuList.filter((item) => - item.routers.some((ele) => pathname.indexOf(ele) > -1) - ); - current.length && setSelectedKeys([current[0].path]); + const nextMenuState = getMenuState(pathname, NAV_MENU_LIST); + setSelectedKeys(nextMenuState.selectedKeys); + setOpenKeys(nextMenuState.openKeys); }, [pathname]); return ( @@ -110,19 +68,21 @@ const HeaderComponent = (props: any) => { setOpenKeys(keys as string[])} > - {navMenuList.map((nav: any) => { - const { children, name, path, icon } = nav; + {NAV_MENU_LIST.map((nav: any) => { + const { children, name, path, iconKey } = nav; + const icon = iconMap[iconKey]; if (Array.isArray(children) && children.length > 0) { return ( {icon} - Navigation Two + {name} } > @@ -130,7 +90,7 @@ const HeaderComponent = (props: any) => { {/* @ts-ignore */} - {navChild.icon} + {iconMap[navChild.iconKey]} {navChild.name} diff --git a/app/web/layouts/header/nav-config.js b/app/web/layouts/header/nav-config.js new file mode 100644 index 00000000..ef002d26 --- /dev/null +++ b/app/web/layouts/header/nav-config.js @@ -0,0 +1,138 @@ +'use strict'; + +const MORE_MENU_PATH = '/page/more'; + +const NAV_MENU_LIST = [ + { + name: '应用中心', + path: '/page/toolbox', + iconKey: 'appstore', + routers: ['toolbox', 'switch-hosts-list', 'switch-hosts-edit', 'article-subscription-list'], + }, + { + name: '代理服务', + path: '/page/proxy-server', + iconKey: 'cloud', + routers: ['proxy-server'], + }, + { + name: 'MCP', + path: '/page/mcp-server-market', + iconKey: 'ungroup', + routers: [ + 'mcp-server-market', + 'mcp-server-registry', + 'mcp-server-management', + 'mcp-server-detail', + 'mcp-server-inspector', + ], + }, + { + name: 'Skills', + path: '/page/skills', + iconKey: 'book', + routers: ['skills'], + }, + { + name: 'Agents', + path: '/page/agents', + iconKey: 'robot', + routers: ['agents'], + }, + { + name: '环境管理', + path: '/page/env-management', + iconKey: 'desktop', + routers: ['env-management'], + }, + { + name: '更多', + path: MORE_MENU_PATH, + iconKey: 'more', + routers: ['host-management', 'config-center', 'config-detail', 'tags'], + children: [ + { + name: '主机管理', + path: '/page/host-management', + iconKey: 'cloud-server', + routers: ['host-management'], + }, + { + name: '配置中心', + path: '/page/config-center', + iconKey: 'setting', + routers: ['config-center', 'config-detail'], + }, + { + name: '标签管理', + path: '/page/tags', + iconKey: 'tag', + routers: ['tags'], + }, + ], + }, +]; + +function getActiveNavPath(pathname, navMenuList = NAV_MENU_LIST) { + const targetPathname = String(pathname || ''); + const current = navMenuList.find((item) => + (item.routers || []).some((router) => targetPathname.indexOf(router) > -1) + ); + + return current ? current.path : ''; +} + +function findMatchedMenu(pathname, navMenuList = NAV_MENU_LIST) { + const targetPathname = String(pathname || ''); + + for (const item of navMenuList) { + const directMatched = (item.routers || []).some( + (router) => targetPathname.indexOf(router) > -1 + ); + if (directMatched && !Array.isArray(item.children)) { + return { + selectedPath: item.path, + openPath: '', + }; + } + + if (Array.isArray(item.children)) { + const childMatched = item.children.find((child) => + (child.routers || []).some((router) => targetPathname.indexOf(router) > -1) + ); + if (childMatched) { + return { + selectedPath: childMatched.path, + openPath: item.path, + }; + } + } + } + + return { + selectedPath: '', + openPath: '', + }; +} + +function getMenuState(pathname, navMenuList = NAV_MENU_LIST) { + const matched = findMatchedMenu(pathname, navMenuList); + return { + selectedKeys: matched.selectedPath ? [matched.selectedPath] : [], + openKeys: matched.openPath ? [matched.openPath] : [], + }; +} + +function resolveMenuClickKey(event) { + const safeEvent = event || {}; + const keyPath = Array.isArray(safeEvent.keyPath) ? safeEvent.keyPath : []; + return keyPath.length > 1 ? keyPath[keyPath.length - 1] : String(safeEvent.key || ''); +} + +module.exports = { + MORE_MENU_PATH, + NAV_MENU_LIST, + getMenuState, + getActiveNavPath, + resolveMenuClickKey, +}; diff --git a/app/web/layouts/header/style.scss b/app/web/layouts/header/style.scss index 7539faae..66836205 100644 --- a/app/web/layouts/header/style.scss +++ b/app/web/layouts/header/style.scss @@ -10,6 +10,19 @@ font-size: 14px; } } + .ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu { + top: 0; + > .ant-menu-submenu-title:hover, + > .ant-menu-submenu-title:focus, + &.ant-menu-submenu-open > .ant-menu-submenu-title, + &.ant-menu-submenu-active > .ant-menu-submenu-title { + color: #3F87FF; + background-color: rgba(40, 123, 247, 0.157); + .anticon { + color: #3F87FF; + } + } + } .help-link { color: #FFF; font-size: 18px; diff --git a/app/web/pages/agents/codex-button-utils.js b/app/web/pages/agents/codex-button-utils.js new file mode 100644 index 00000000..a4e181ae --- /dev/null +++ b/app/web/pages/agents/codex-button-utils.js @@ -0,0 +1,25 @@ +'use strict'; + +const CODEX_NEW_THREAD_URL = 'codex://threads/new'; + +function buildCodexNewThreadUrl({ prompt, originUrl }) { + const params = new URLSearchParams(); + params.set('prompt', String(prompt || '')); + + if (originUrl) { + params.set('originUrl', String(originUrl)); + } + + return `${CODEX_NEW_THREAD_URL}?${params.toString()}`; +} + +function buildAgentDetailCodexPrompt(detail = {}, _originUrl, selectedPrompt) { + const firstPrompt = + selectedPrompt || (Array.isArray(detail.prompts) ? detail.prompts[0] : null); + return firstPrompt ? String(firstPrompt.prompt || '') : ''; +} + +module.exports = { + buildAgentDetailCodexPrompt, + buildCodexNewThreadUrl, +}; diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx new file mode 100644 index 00000000..35957eca --- /dev/null +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -0,0 +1,520 @@ +import React, { useEffect, useMemo, useState } from 'react'; +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 { 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 './style.scss'; + +const { Paragraph, Text, Title } = Typography; +const { TabPane } = Tabs; +const { normalizeAgentCapabilities } = require('./capability-utils'); +const { buildAgentIntroBlocks } = require('./intro-utils'); + +interface AgentDetailContentProps { + name: string; + history: { push: (path: string) => void }; +} + +const SkillRelationCard: React.FC<{ + item: AgentSkillRelation; + history: { push: (path: string) => void }; +}> = ({ item, history }) => { + const clickable = Boolean(item.collected && item.path); + + return ( + { + if (!clickable) return; + if (typeof window !== 'undefined') { + window.open(item.path as string, '_blank', 'noopener,noreferrer'); + return; + } + history.push(item.path as string); + }} + > +
+ {item.name} + {!item.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 }), + ]); + + 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) { + setLoading(false); + } + } + }; + + load(); + return () => { + cancelled = true; + }; + }, [name]); + + const introBlocks = useMemo( + () => + buildAgentIntroBlocks({ + profile: detail?.profile || '', + description: detail?.description || '', + summary: detail?.description || '', + prompts: detail?.prompts || [], + }), + [detail?.description, detail?.profile, detail?.prompts] + ); + const normalizedCapabilities = useMemo( + () => normalizeAgentCapabilities(detail?.capabilities || []), + [detail?.capabilities] + ); + const currentOrigin = useMemo(() => { + if (typeof window === 'undefined') return ''; + return window.location.origin; + }, []); + const installCommand = detail + ? `curl -fsSL ${currentOrigin}/agent-market/install.sh | bash -s -- ${detail.name}` + : ''; + + const openCodexInstall = (selectedPrompt?: { title?: string; prompt?: string }) => { + if (!detail) return; + + const originUrl = typeof window !== 'undefined' ? window.location.href : ''; + const prompt = buildAgentDetailCodexPrompt(detail, originUrl, selectedPrompt); + const codexUrl = buildCodexNewThreadUrl({ prompt, originUrl }); + + window.location.href = codexUrl; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+
+
+
+ {detail.displayName} { + event.currentTarget.style.visibility = 'hidden'; + }} + /> +
+ {detail.displayName} +
+ {detail.authorName || '未知作者'} + + 版本 {detail.version || '-'} + + {detail.category} +
+
+ {detail.tags.map((tag) => ( + {tag} + ))} +
+
+
+
+ + + + + 概览 + + } + key="overview" + > +
+ + 你可以使用该 Agent 做什么 +
+ {detail.description || '暂无描述'} +
+
+ + + 能力范围 + {normalizedCapabilities.length > 0 ? ( +
+ {normalizedCapabilities.map( + (item: AgentCapability, index: number) => ( +
+ {item.name} + {item.description ? ( + + {item.description} + + ) : null} +
+ ) + )} +
+ ) : ( + + )} +
+ + + Agent 演示 + {detail.demoImages.length > 0 ? ( +
+
+ {detail.demoImages.map((item, index) => ( + + ))} +
+
+ { +
+
+ ) : ( + + )} +
+
+
+ + + + Agent 简介 + + } + key="profile" + > + +
+
+
+ Agent 简介 +
+
+ {introBlocks.introParagraphs.map( + (item: string, index: number) => ( + + {item} + + ) + )} +
+
+ +
+
+ 开场消息 +
+ +
+
+ +
+ + {introBlocks.openingMessage || '暂无开场消息'} + +
+
+
+ +
+
+ 开场问题 + + {introBlocks.openingQuestions.length} 个 + +
+
+ {introBlocks.openingQuestions.length > 0 ? ( + introBlocks.openingQuestions.map( + (item: any, index: number) => ( + +
+
+ +
+
+ {item.title} + + {item.prompt} + +
+ +
+
+ ) + ) + ) : ( + + )} +
+
+
+
+
+ + + + Agent 能力 + + } + key="skills" + > +
+ + 核心工作流 + {detail.entrypoint ? ( + + ) : ( + + )} + + + + 内置 Skills + {detail.dependencies.length > 0 ? ( +
+ {detail.dependencies.map((item) => ( + + ))} +
+ ) : ( + + )} +
+
+
+
+
+ + +
+
+ ); +}; + +export default AgentDetailContent; diff --git a/app/web/pages/agents/detail/capability-utils.js b/app/web/pages/agents/detail/capability-utils.js new file mode 100644 index 00000000..52876db0 --- /dev/null +++ b/app/web/pages/agents/detail/capability-utils.js @@ -0,0 +1,30 @@ +'use strict'; + +function normalizeAgentCapabilities(capabilities) { + if (!Array.isArray(capabilities)) return []; + + return capabilities + .map((item) => { + if (typeof item === 'string') { + const name = item.trim(); + return name ? { id: '', name, description: '' } : null; + } + + if (item && typeof item === 'object') { + const name = String(item.name || item.description || '').trim(); + if (!name) return null; + return { + id: String(item.id || '').trim(), + name, + description: String(item.description || '').trim(), + }; + } + + return null; + }) + .filter(Boolean); +} + +module.exports = { + normalizeAgentCapabilities, +}; diff --git a/app/web/pages/agents/detail/index.tsx b/app/web/pages/agents/detail/index.tsx new file mode 100644 index 00000000..1b504141 --- /dev/null +++ b/app/web/pages/agents/detail/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +import AgentDetailContent from './AgentDetailContent'; + +const AgentDetailPage: React.FC = ({ history, match }) => { + const name = decodeURIComponent(match?.params?.name || ''); + return ; +}; + +export default AgentDetailPage; diff --git a/app/web/pages/agents/detail/intro-utils.js b/app/web/pages/agents/detail/intro-utils.js new file mode 100644 index 00000000..f7bf66a1 --- /dev/null +++ b/app/web/pages/agents/detail/intro-utils.js @@ -0,0 +1,21 @@ +'use strict'; + +function splitParagraphs(content) { + return String(content || '') + .split(/\n+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildAgentIntroBlocks(detail = {}) { + return { + introParagraphs: splitParagraphs(detail.profile), + openingMessage: String(detail.description || detail.summary || '').trim(), + openingQuestions: Array.isArray(detail.prompts) ? detail.prompts : [], + }; +} + +module.exports = { + buildAgentIntroBlocks, + splitParagraphs, +}; diff --git a/app/web/pages/agents/detail/style.scss b/app/web/pages/agents/detail/style.scss new file mode 100644 index 00000000..07268d55 --- /dev/null +++ b/app/web/pages/agents/detail/style.scss @@ -0,0 +1,521 @@ +.page-agent-detail { + height: 100%; + min-height: 100%; + overflow: auto; + padding: 24px 32px 32px; + background: #F5F7FB; + .agent-detail-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 24px; + align-items: start; + width: 100%; + max-width: 1300px; + min-height: 100%; + margin: 0 auto; + } + .agent-detail-main { + min-width: 0; + } + .agent-hero, + .agent-section-card, + .agent-side-actions, + .agent-side-related { + border-radius: 20px; + border: 1px solid #EDF0F5; + } + .agent-side-related { + margin-top: 20px; + } + .agent-side-actions { + .ant-card-head { + min-height: 48px; + padding: 0 18px; + } + .ant-card-head-title { + padding: 14px 0; + font-size: 15px; + } + .ant-card-body { + padding: 16px; + } + } + .agent-install-terminal { + overflow: hidden; + border-radius: 8px; + color: #F8FAFC; + background: #111827; + } + .agent-install-terminal-head { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.52); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + } + .agent-install-terminal-dots { + display: inline-flex; + gap: 6px; + i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #CBD5E1; + } + } + .agent-install-terminal-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + min-height: 88px; + padding: 14px 12px; + code { + color: #E2E8F0; + font-family: SFMono-Regular, Consolas, monospace; + font-size: 12px; + line-height: 1.6; + word-break: break-word; + } + } + .agent-install-prompt { + color: #34D399; + font-family: SFMono-Regular, Consolas, monospace; + font-size: 13px; + line-height: 1.6; + } + .ant-btn.agent-install-copy { + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + border-color: transparent; + color: #94A3B8; + background: transparent; + box-shadow: none; + &:hover, + &:focus, + &:active { + border-color: transparent; + color: #E2E8F0; + background: rgba(255, 255, 255, 0.08); + box-shadow: none; + } + } + .agent-archive-download { + height: 36px; + margin-top: 12px; + border-radius: 8px; + font-weight: 600; + } + .agent-section-card { + .ant-card-body { + padding: 18px 22px; + } + .ant-list-item { + padding: 12px 0; + } + } + .agent-hero { + padding: 28px; + margin-bottom: 20px; + background: linear-gradient(180deg, #FFF 0%, #F8FBFF 100%); + } + .agent-hero-brand { + display: flex; + gap: 18px; + align-items: center; + } + .agent-hero-logo { + width: 88px; + height: 88px; + border-radius: 24px; + object-fit: cover; + background: linear-gradient(135deg, #EFF4FF, #EEF2FF); + } + .agent-hero-meta { + min-width: 0; + h2 { + margin-bottom: 8px; + } + } + .agent-hero-subline { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 12px; + color: #667085; + .dot { + color: #98A2B3; + } + } + .agent-hero-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .agent-detail-tabs { + .ant-tabs-nav { + margin-bottom: 16px; + } + } + .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 { + min-width: 0; + padding: 10px 12px; + border: 1px solid #E9EEF5; + border-radius: 10px; + background: linear-gradient(180deg, #FFF 0%, #FBFDFF 100%); + .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-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; + } + .agent-profile-copy { + padding: 16px 18px; + text-indent: 2em; + p:last-child { + margin-bottom: 0; + } + .ant-typography { + font-size: 15px; + line-height: 2; + margin-bottom: 0; + } + } + .agent-intro-sections { + display: flex; + flex-direction: column; + gap: 16px; + } + .agent-intro-block { + display: flex; + flex-direction: column; + gap: 10px; + } + .agent-intro-block-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + h4 { + margin-bottom: 0; + } + } + .agent-intro-panel { + border: 1px solid #E9EEF5; + border-radius: 16px; + background: linear-gradient(180deg, #FFF 0%, #FBFDFF 100%); + box-shadow: none; + } + .agent-intro-count { + margin-right: 0; + align-self: center; + } + .agent-intro-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + 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; + gap: 10px; + .ant-card { + border-radius: 16px; + } + .ant-typography { + margin-bottom: 0; + } + } + .agent-question-card { + .ant-card-body { + padding: 16px 18px; + } + } + .agent-question-card-body { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + } + .agent-question-copy { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + .ant-typography:first-child { + display: block; + margin-bottom: 4px; + color: #1F2937; + font-size: 15px; + line-height: 1.5; + } + .ant-typography:last-child { + color: #667085; + line-height: 1.6; + font-size: 14px; + } + } + .agent-question-quick-use { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 16px 0 12px; + border-color: #D8DFEA; + border-radius: 10px; + color: #344054; + background: #FFF; + font-weight: 600; + white-space: nowrap; + box-shadow: none; + transition: + transform 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease; + &:hover, + &:focus { + border-color: #F6B85A; + color: #B66A00; + background: #FFF9EF; + box-shadow: 0 6px 14px rgba(245, 158, 11, 0.12); + transform: translateY(-1px); + } + &:active { + transform: translateY(0); + box-shadow: 0 3px 8px rgba(245, 158, 11, 0.1); + } + .anticon { + margin-right: 8px; + } + } + .agent-question-quick-use-icon { + display: inline-flex; + align-items: center; + justify-content: center; + 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; + } + } + .agent-hero-brand { + flex-direction: column; + align-items: flex-start; + } + .agent-question-card-body { + grid-template-columns: 32px minmax(0, 1fr); + } + .agent-question-quick-use { + grid-column: 2; + justify-self: flex-end; + } + } +} diff --git a/app/web/pages/agents/index.tsx b/app/web/pages/agents/index.tsx new file mode 100644 index 00000000..b6a5a993 --- /dev/null +++ b/app/web/pages/agents/index.tsx @@ -0,0 +1,372 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DeleteOutlined, ImportOutlined, SearchOutlined, UploadOutlined } from '@ant-design/icons'; +import { + Button, + Card, + Empty, + Input, + message, + Modal, + Pagination, + Select, + Space, + Spin, + Tag, + Typography, + Upload, +} from 'antd'; +import debounce from 'lodash/debounce'; + +import { API } from '@/api'; +import helpIcon from '@/asset/images/help-icon.png'; +import config from '../../../../env.json'; +import type { AgentItem, AgentListResponse } from './types'; +import './style.scss'; + +const { Search } = Input; +const { Option } = Select; +const { Paragraph, Text, Title } = Typography; + +const FALLBACK_CATEGORIES = [ + '通用', + '前端', + '后端', + '数据与AI', + '运维与系统', + '工程效率', + '安全', + '其他', +]; + +const INITIAL_QUERY = { + keyword: '', + category: '', + pageNum: 1, + pageSize: 12, +}; +const AGENT_DELETE_STORAGE_KEY = 'doraemon.agentMarket.deleteEnabled'; +const AGENT_DELETE_STORAGE_VALUE = 'true'; + +interface AgentMarketProps { + history: { push: (path: string) => void }; +} + +const AgentMarket: React.FC = ({ history }) => { + const [loading, setLoading] = useState(false); + const [agents, setAgents] = useState([]); + const [categories, setCategories] = useState(FALLBACK_CATEGORIES); + const [total, setTotal] = useState(0); + const [query, setQuery] = useState(INITIAL_QUERY); + const [keywordInput, setKeywordInput] = useState(''); + const [importVisible, setImportVisible] = useState(false); + const [importing, setImporting] = useState(false); + const [uploadFiles, setUploadFiles] = useState([]); + const [deleteEnabled, setDeleteEnabled] = useState(false); + const queryRef = useRef(query); + queryRef.current = query; + + const fetchAgents = useCallback(async (nextQuery) => { + setLoading(true); + try { + const response = await API.getAgentList(nextQuery); + if (!response.success) { + message.error(response.msg || '获取 Agent 列表失败'); + return; + } + + const data = response.data as AgentListResponse; + setAgents(data.list || []); + setCategories(data.categories?.length ? data.categories : FALLBACK_CATEGORIES); + setTotal(data.total || 0); + } catch (error) { + message.error('获取 Agent 列表失败'); + console.error('获取 Agent 列表失败:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAgents(INITIAL_QUERY); + }, [fetchAgents]); + + useEffect(() => { + const debouncedFetch = debouncedFetchRef.current; + return () => { + debouncedFetch.cancel(); + }; + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + setDeleteEnabled( + window.localStorage.getItem(AGENT_DELETE_STORAGE_KEY) === AGENT_DELETE_STORAGE_VALUE + ); + }, []); + + const updateQueryAndFetch = (patch: Partial) => { + const next = { ...queryRef.current, ...patch }; + setQuery(next); + fetchAgents(next); + }; + + const debouncedFetchRef = useRef( + debounce((keyword: string) => { + const next = { ...queryRef.current, keyword, pageNum: 1 }; + setQuery(next); + fetchAgents(next); + }, 300) + ); + + const handleDelete = (agent: AgentItem, event?: React.MouseEvent) => { + event?.stopPropagation(); + Modal.confirm({ + title: `删除 Agent「${agent.displayName}」`, + content: '删除后会移除当前 Agent 与资源文件,但不会删除已收录的 Skills', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + async onOk() { + const response = await API.deleteAgent({ name: agent.name }); + if (!response.success) { + message.error(response.msg || '删除失败'); + return; + } + message.success('删除成功'); + fetchAgents(queryRef.current); + }, + }); + }; + + const submitImport = async (confirmOverwrite = false) => { + const targetFile = uploadFiles[0]?.originFileObj; + if (!targetFile) { + message.error('请先选择 .zip 文件'); + return; + } + + setImporting(true); + try { + const response = await API.importAgentFile({ + file: targetFile, + confirmOverwrite: confirmOverwrite ? 'true' : 'false', + }); + if (!response.success) { + message.error(response.msg || '导入失败'); + return; + } + + if (response.data?.requiresConfirm) { + Modal.confirm({ + title: `检测到同名 Agent「${response.data.name}」`, + content: `当前版本 ${response.data.currentVersion},导入版本 ${response.data.incomingVersion},是否覆盖`, + okText: '覆盖导入', + cancelText: '取消', + onOk: () => submitImport(true), + }); + return; + } + + if (response.data?.unchanged) { + message.info('内容未变化'); + } else if (response.data?.updated) { + message.success('更新成功'); + } else { + message.success('导入成功'); + } + + setImportVisible(false); + setUploadFiles([]); + fetchAgents(queryRef.current); + } catch (error) { + message.error('导入失败,请检查 ZIP 文件'); + console.error('导入 Agent 失败:', error); + } finally { + setImporting(false); + } + }; + + const categoryOptions = useMemo( + () => (categories.length ? categories : FALLBACK_CATEGORIES), + [categories] + ); + + const handleHelpIcon = () => { + if (config.agentHelpDocUrl) { + window.open(config.agentHelpDocUrl, '_blank', 'noopener,noreferrer'); + } + }; + + return ( +
+
+
+

Agent 市场

+

发现并导入适用于不同研发场景的 Agent

+
+ +
+ + {config.agentHelpDocUrl ? ( + 帮助文档 + ) : null} + +
+ } + onChange={(event) => { + const nextValue = event.target.value; + setKeywordInput(nextValue); + debouncedFetchRef.current(nextValue); + }} + onSearch={(value) => { + setKeywordInput(value); + updateQueryAndFetch({ keyword: value, pageNum: 1 }); + }} + /> + +
+ + + {agents.length === 0 ? ( +
+ +
+ ) : ( +
+ {agents.map((agent) => ( + history.push(`/page/agents/${agent.name}`)} + > +
+
+ {agent.displayName} { + event.currentTarget.style.visibility = 'hidden'; + }} + /> +
+ {agent.displayName} +
+ + {agent.authorName || '未知作者'} + + + {agent.category} +
+
+
+ {deleteEnabled ? ( +
+ + + {agent.description || '暂无描述'} + + +
+ {agent.tags.slice(0, 4).map((tag) => ( + {tag} + ))} +
+ +
+ 版本 {agent.version || '-'} + + 内置 Skills {agent.dependencyCount} + +
+
+ ))} +
+ )} +
+ + {total > query.pageSize ? ( +
+ updateQueryAndFetch({ pageNum })} + /> +
+ ) : null} + + { + if (importing) return; + setImportVisible(false); + setUploadFiles([]); + }} + onOk={() => submitImport(false)} + > + + + 仅支持导入单个 Agent ZIP。Agent 信息会从包内 `agent.yaml` 自动解析。 + + false} + onChange={(info) => setUploadFiles(info.fileList.slice(-1))} + > + + + + +
+ ); +}; + +export default AgentMarket; diff --git a/app/web/pages/agents/style.scss b/app/web/pages/agents/style.scss new file mode 100644 index 00000000..c3f5f558 --- /dev/null +++ b/app/web/pages/agents/style.scss @@ -0,0 +1,184 @@ +.page-agents { + min-height: 100%; + padding: 24px 32px 32px; + background: #F5F7FB; + .agents-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + } + .title-group { + display: flex; + align-items: baseline; + gap: 16px; + .page-title { + margin: 0; + color: #2A3439; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.025em; + } + .page-subtitle { + margin: 0; + color: #64748B; + } + } + .search-filter-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 18px; + margin-bottom: 20px; + .keyword-search { + width: 620px; + max-width: 100%; + .ant-input-group > .ant-input-affix-wrapper:not(:last-child) { + padding-left: 14px; + } + .ant-input-group > .ant-input-affix-wrapper:not(:last-child) .ant-input { + padding-left: 0; + } + } + } + .category-filter { + width: 220px; + } + .agents-empty { + padding: 72px 0; + background: #FFF; + border: 1px solid #EDF0F5; + border-radius: 16px; + } + .agents-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + .help-icon { + width: 60px; + height: 60px; + padding: 12px; + border-radius: 50%; + background-color: #FFF; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.28); + cursor: pointer; + position: fixed; + bottom: 120px; + right: 60px; + z-index: 10; + } + .agent-card { + height: 100%; + border-radius: 16px; + border: 1px solid #EDF0F5; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); + .ant-card-body { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + padding: 6px 20px; + } + } + .agent-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + .agent-card-brand { + display: flex; + gap: 12px; + min-width: 0; + } + .agent-card-logo { + width: 52px; + height: 52px; + flex: 0 0 52px; + border-radius: 14px; + object-fit: cover; + background: linear-gradient(135deg, #EFF4FF, #EEF2FF); + } + .agent-card-meta { + min-width: 0; + h4 { + margin-bottom: 4px; + } + } + .agent-card-subline { + display: flex; + align-items: center; + gap: 10px; + color: #98A2B3; + .ant-typography { + font-size: 13px; + line-height: 1.5; + } + .dot { + color: #98A2B3; + } + } + .agent-card-description { + flex: 1; + font-size: 14px; + min-height: 66px; + margin-bottom: 0; + color: #344054; + } + .agent-card-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .agent-card-footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid #EDF0F5; + } + .agents-pagination { + display: flex; + justify-content: flex-end; + margin-top: 20px; + } + + @media screen and (max-width: 1200px) { + .agents-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media screen and (max-width: 768px) { + padding: 16px; + .agents-header { + flex-direction: column; + align-items: stretch; + } + .title-group { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .search-filter-row { + flex-direction: column; + align-items: stretch; + } + .category-filter { + width: 100%; + } + .agents-grid { + grid-template-columns: 1fr; + } + .agent-card-footer { + flex-direction: column; + } + .help-icon { + right: 24px; + bottom: 100px; + } + } +} diff --git a/app/web/pages/agents/types.ts b/app/web/pages/agents/types.ts new file mode 100644 index 00000000..a8fa282e --- /dev/null +++ b/app/web/pages/agents/types.ts @@ -0,0 +1,58 @@ +export interface AgentPrompt { + title: string; + prompt: string; +} + +export interface AgentCapability { + id: string; + name: string; + description: string; +} + +export interface AgentDemoImage { + path: string; + url: string; + alt: string; + mimeType: string; + size: number; + hash: string; + sortOrder: number; +} + +export interface AgentSkillRelation { + slug: string; + name: string; + description?: string; + collected: boolean; + path?: string; +} + +export interface AgentItem { + name: string; + displayName: string; + description: string; + authorName: string; + category: string; + tags: string[]; + version: string; + updatedAt: string; + dependencyCount: number; + logoUrl: string; +} + +export interface AgentListResponse { + list: AgentItem[]; + total: number; + pageNum: number; + pageSize: number; + categories: string[]; +} + +export interface AgentDetail extends AgentItem { + profile: string; + prompts: AgentPrompt[]; + capabilities: AgentCapability[]; + demoImages: AgentDemoImage[]; + entrypoint: AgentSkillRelation | null; + dependencies: AgentSkillRelation[]; +} diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 098c1540..c255ec81 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -321,7 +321,7 @@ const SkillsMarket: React.FC = ({ history }) => {
-

Skills 市场

+

Skill 市场

发现、筛选并导入本地可用的 Skills 能力

diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss index bb799178..d3b10afd 100644 --- a/app/web/pages/skills/style.scss +++ b/app/web/pages/skills/style.scss @@ -13,6 +13,9 @@ flex-shrink: 0; } .title-group { + display: flex; + align-items: baseline; + gap: 16px; .page-title { margin: 0; color: #2A3439; @@ -22,7 +25,7 @@ letter-spacing: -0.025em; } .page-subtitle { - margin: 8px 0 0; + margin: 0; color: #64748B; } } @@ -144,3 +147,13 @@ justify-content: space-between; width: 100%; } + +@media screen and (max-width: 768px) { + .page-skills { + .title-group { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + } +} diff --git a/app/web/router/index.ts b/app/web/router/index.ts index 9cfe8728..54d9ea72 100644 --- a/app/web/router/index.ts +++ b/app/web/router/index.ts @@ -1,6 +1,8 @@ import Loadable from 'react-loadable'; import BasicLayout from '@/layouts/basicLayout'; +import AgentMarket from '@/pages/agents'; +import AgentDetail from '@/pages/agents/detail'; // 文章订阅管理 import ArticleSubscriptionList from '@/pages/articleSubscription'; // 配置中心 @@ -122,6 +124,14 @@ const routes: any = [ path: `${urlPrefix}/mcp-server-management`, component: McpServerManagement, }, + { + path: `${urlPrefix}/agents/:name`, + component: AgentDetail, + }, + { + path: `${urlPrefix}/agents`, + component: AgentMarket, + }, { path: `${urlPrefix}/skills/:parentSlug/:slug`, component: SkillDetail, diff --git a/config/config.default.js b/config/config.default.js index fb4e759d..d1cf1d37 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -43,6 +43,14 @@ module.exports = (app) => { githubToken: process.env.GITHUB_TOKEN || '', gitlabHostWhitelist: ['gitlab.prod.dtstack.cn'], }; + exports.agentMarket = { + storageDir: process.env.AGENT_MARKET_STORAGE_DIR || '/data/doraemon/agent-market', + maxZipSize: 50 * 1024 * 1024, + maxExtractedSize: 200 * 1024 * 1024, + maxFileCount: 500, + maxSingleFileSize: 20 * 1024 * 1024, + maxImageSize: 5 * 1024 * 1024, + }; exports.middleware = ['access']; diff --git a/config/config.local.js b/config/config.local.js index 0ab31f75..e1600666 100644 --- a/config/config.local.js +++ b/config/config.local.js @@ -1,3 +1,4 @@ +const path = require('path'); const ip = require('ip'); const EasyWebpack = require('easywebpack-react'); @@ -45,6 +46,9 @@ module.exports = () => { }; exports.security = { domainWhiteList }; + exports.agentMarket = { + storageDir: path.join(process.cwd(), 'agent-market'), + }; // exports.ssh = { // host: '172.16.100.225', diff --git a/docs/docsify/_sidebar.md b/docs/docsify/_sidebar.md index 4d56c526..e78edf8b 100644 --- a/docs/docsify/_sidebar.md +++ b/docs/docsify/_sidebar.md @@ -16,5 +16,6 @@ + [文章订阅](zh-cn/guide/文章订阅) + [意见反馈](zh-cn/guide/意见反馈) + [Skills Hub](zh-cn/guide/dt-skill) + + [Agent 市场](zh-cn/guide/agent-market) + [贡献指南](zh-cn/other/贡献者文档) + [ChangeLog](zh-cn/other/CHANGELOG) diff --git a/docs/docsify/favicon.ico b/docs/docsify/favicon.ico new file mode 100644 index 00000000..ba3ae918 Binary files /dev/null and b/docs/docsify/favicon.ico differ diff --git a/docs/docsify/imgs/agent-detail-overview.png b/docs/docsify/imgs/agent-detail-overview.png new file mode 100644 index 00000000..955f7711 Binary files /dev/null and b/docs/docsify/imgs/agent-detail-overview.png differ diff --git a/docs/docsify/imgs/agent-detail-profile.png b/docs/docsify/imgs/agent-detail-profile.png new file mode 100644 index 00000000..36090f65 Binary files /dev/null and b/docs/docsify/imgs/agent-detail-profile.png differ diff --git a/docs/docsify/imgs/agent-detail-skills.png b/docs/docsify/imgs/agent-detail-skills.png new file mode 100644 index 00000000..4301fcc1 Binary files /dev/null and b/docs/docsify/imgs/agent-detail-skills.png differ diff --git a/docs/docsify/imgs/agent-list.png b/docs/docsify/imgs/agent-list.png new file mode 100644 index 00000000..65a9b83e Binary files /dev/null and b/docs/docsify/imgs/agent-list.png differ diff --git a/docs/docsify/imgs/article-2.png b/docs/docsify/imgs/article-2.png index 57feaa42..47ac1ef3 100644 Binary files a/docs/docsify/imgs/article-2.png and b/docs/docsify/imgs/article-2.png differ diff --git a/docs/docsify/imgs/article-3.png b/docs/docsify/imgs/article-3.png index 4b1d8fcb..c6ebd666 100644 Binary files a/docs/docsify/imgs/article-3.png and b/docs/docsify/imgs/article-3.png differ diff --git a/docs/docsify/imgs/config_detail.jpg b/docs/docsify/imgs/config_detail.jpg new file mode 100644 index 00000000..4ec0f87b Binary files /dev/null and b/docs/docsify/imgs/config_detail.jpg differ diff --git a/docs/docsify/imgs/create_hosts.jpg b/docs/docsify/imgs/create_hosts.jpg new file mode 100644 index 00000000..4c73844e Binary files /dev/null and b/docs/docsify/imgs/create_hosts.jpg differ diff --git a/docs/docsify/imgs/dt-skill-install.jpg b/docs/docsify/imgs/dt-skill-install.jpg index 2e088a41..ab0ca173 100644 Binary files a/docs/docsify/imgs/dt-skill-install.jpg and b/docs/docsify/imgs/dt-skill-install.jpg differ diff --git a/docs/docsify/imgs/dt-skill-upload.jpg b/docs/docsify/imgs/dt-skill-upload.jpg index 19c42ff5..d132649a 100644 Binary files a/docs/docsify/imgs/dt-skill-upload.jpg and b/docs/docsify/imgs/dt-skill-upload.jpg differ diff --git a/docs/docsify/imgs/env_management.png b/docs/docsify/imgs/env_management.png index 749dc70e..59964da0 100644 Binary files a/docs/docsify/imgs/env_management.png and b/docs/docsify/imgs/env_management.png differ diff --git a/docs/docsify/imgs/host_management.jpg b/docs/docsify/imgs/host_management.jpg index 9dbc0eab..fb25ef61 100644 Binary files a/docs/docsify/imgs/host_management.jpg and b/docs/docsify/imgs/host_management.jpg differ diff --git a/docs/docsify/imgs/host_management_detail.jpg b/docs/docsify/imgs/host_management_detail.jpg index aa4c0c8d..1de7d3f0 100644 Binary files a/docs/docsify/imgs/host_management_detail.jpg and b/docs/docsify/imgs/host_management_detail.jpg differ diff --git a/docs/docsify/imgs/hosts_info.jpg b/docs/docsify/imgs/hosts_info.jpg new file mode 100644 index 00000000..34d54ed7 Binary files /dev/null and b/docs/docsify/imgs/hosts_info.jpg differ diff --git a/docs/docsify/imgs/hosts_list.jpg b/docs/docsify/imgs/hosts_list.jpg new file mode 100644 index 00000000..06ec977e Binary files /dev/null and b/docs/docsify/imgs/hosts_list.jpg differ diff --git a/docs/docsify/imgs/new_config.jpg b/docs/docsify/imgs/new_config.jpg new file mode 100644 index 00000000..13128b7e Binary files /dev/null and b/docs/docsify/imgs/new_config.jpg differ diff --git a/docs/docsify/imgs/proxy_create.jpg b/docs/docsify/imgs/proxy_create.jpg new file mode 100644 index 00000000..5f0ca1b7 Binary files /dev/null and b/docs/docsify/imgs/proxy_create.jpg differ diff --git a/docs/docsify/imgs/proxy_rule_create.jpg b/docs/docsify/imgs/proxy_rule_create.jpg new file mode 100644 index 00000000..b8acbcb2 Binary files /dev/null and b/docs/docsify/imgs/proxy_rule_create.jpg differ diff --git a/docs/docsify/imgs/proxy_switch.jpg b/docs/docsify/imgs/proxy_switch.jpg new file mode 100644 index 00000000..b898a760 Binary files /dev/null and b/docs/docsify/imgs/proxy_switch.jpg differ diff --git a/docs/docsify/imgs/switchhosts.jpg b/docs/docsify/imgs/switchhosts.jpg new file mode 100644 index 00000000..05cb1cd8 Binary files /dev/null and b/docs/docsify/imgs/switchhosts.jpg differ diff --git a/docs/docsify/imgs/web_terminal.jpg b/docs/docsify/imgs/web_terminal.jpg index a525a9cd..f58741e7 100644 Binary files a/docs/docsify/imgs/web_terminal.jpg and b/docs/docsify/imgs/web_terminal.jpg differ diff --git a/docs/docsify/index.html b/docs/docsify/index.html index 0257900f..5071f24e 100644 --- a/docs/docsify/index.html +++ b/docs/docsify/index.html @@ -9,6 +9,7 @@ + diff --git a/docs/docsify/zh-cn/_sidebar.md b/docs/docsify/zh-cn/_sidebar.md index 4d56c526..e78edf8b 100644 --- a/docs/docsify/zh-cn/_sidebar.md +++ b/docs/docsify/zh-cn/_sidebar.md @@ -16,5 +16,6 @@ + [文章订阅](zh-cn/guide/文章订阅) + [意见反馈](zh-cn/guide/意见反馈) + [Skills Hub](zh-cn/guide/dt-skill) + + [Agent 市场](zh-cn/guide/agent-market) + [贡献指南](zh-cn/other/贡献者文档) + [ChangeLog](zh-cn/other/CHANGELOG) diff --git a/docs/docsify/zh-cn/configuration/envConfig.md b/docs/docsify/zh-cn/configuration/envConfig.md index 08e6dd36..c53119df 100644 --- a/docs/docsify/zh-cn/configuration/envConfig.md +++ b/docs/docsify/zh-cn/configuration/envConfig.md @@ -7,7 +7,8 @@ "webhookUrls": [], "articleResultWebhook": "", "msgSingleUrl": "https://dtstack.github.io/doraemon/docsify/#/", - "helpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/" + "helpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/", + "agentHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market" } ``` @@ -89,6 +90,32 @@ dingBot 的通知模板的跳转路径,默认跳转到帮助文档,可自行 } ``` +## skillsHelpDocUrl + +- 类型:String +- 默认值:'https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/dt-skill' + +Skills 首页右下角帮助文档入口跳转链接 + +```json +{ + "skillsHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/dt-skill" +} +``` + +## agentHelpDocUrl + +- 类型:String +- 默认值:'https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market' + +Agent 市场首页右下角帮助文档入口跳转链接 + +```json +{ + "agentHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market" +} +``` + ## mysql - 类型:Object diff --git a/docs/docsify/zh-cn/guide/agent-market.md b/docs/docsify/zh-cn/guide/agent-market.md new file mode 100644 index 00000000..23de325d --- /dev/null +++ b/docs/docsify/zh-cn/guide/agent-market.md @@ -0,0 +1,267 @@ +# Agent 市场 + +Agent 市场是 Doraemon 提供的 Agent Registry 与展示页能力,用于统一展示、导入、更新和分发 Agent 安装包。 + +当前版本里,Doraemon 主要负责两件事: + +- Web 展示与检索:在页面中浏览 Agent 列表、查看详情、查看关联 Skill 和相关 Agent +- 安装包存储与分发:保存导入的 Agent ZIP,并提供安装命令与 ZIP 下载 + +需要注意:Doraemon 不负责在线运行 Agent。Agent 的实际执行仍由 Codex 等宿主环境完成。不过在 Agent 详情页中,Doraemon 现在可以通过“快捷使用”入口直接打开 Codex,并把选中的问题预填到输入框中,作为运行入口之一。 + +## 入口 + +- Agent 列表页:`/page/agents` +- Agent 详情页:`/page/agents/` + +列表页支持: + +- 按关键字搜索 Agent +- 按分类筛选 +- 查看名称、简介、作者、版本、标签 + +详情页包含三部分信息: + +- 概览:Agent 用途说明、能力范围、演示图片 +- Agent 简介:详细简介、开场消息、开场问题 +- Agent 能力:核心工作流和内置 Skills + +## 快速使用 + +### 浏览和查看详情 + +进入 Agent 市场后,可以先在列表页按名称、描述、作者或标签搜索,再进入详情页查看完整说明。 + +![agent-list.png](../../imgs/agent-list.png) + +详情页右侧会展示: + +- 安装命令 +- 下载 Agent ZIP +- 相关 Agent + +### 复制安装命令 + +详情页右侧会根据当前站点地址自动拼接安装命令,格式如下: + +```bash +curl -fsSL http://127.0.0.1:7001/agent-market/install.sh | bash -s -- bugfix-agent +``` + +其中: + +- `http://127.0.0.1:7001` 会替换为当前访问 Doraemon 的站点地址 +- `bugfix-agent` 是当前 Agent 的唯一名称 + +这条命令适合直接给使用方安装指定 Agent。 + +### 下载 Agent ZIP + +详情页右侧支持直接下载当前 Agent 的原始 ZIP 包。 + +适合两个场景: + +- 本地离线查看 Agent 包结构 +- 参考 ZIP 目录格式,制作新的 Agent 包 + +如果页面提示原始 ZIP 不存在,说明这个 Agent 是历史导入数据,尚未补齐 ZIP 存档。此时重新导入一次同内容 ZIP 即可恢复下载能力。 + +### 直接在 Codex 中快捷使用 + +在 Agent 详情页的“Agent 简介 > 开场问题”区域中,每个问题右侧都提供“快捷使用”按钮。 + +![agent-detail-profile.png](../../imgs/agent-detail-profile.png) + +点击后会: + +- 打开 Codex 新任务 +- 自动带入当前 Agent 的上下文信息 +- 自动把选中的问题预填到输入框中 + +这个入口适合首次体验 Agent,或者直接从推荐问题开始对话。 + +## 详情页说明 + +![agent-detail-overview.png](../../imgs/agent-detail-overview.png) + +### 你可以使用该 Agent 做什么 + +该区域展示 Agent 的核心用途说明,和 Agent 简介中的正文描述保持一致,便于快速理解这个 Agent 适合解决什么问题。 + +### 能力范围 + +能力范围以卡片形式展示当前 Agent 的能力项,例如: + +- Bug 分析 +- 代码修复 +- 独立审校 +- MR 交付 + +如果未配置能力项,页面会显示“暂无能力描述”。 + +### Agent 演示 + +演示区支持多张图片: + +- 顶部显示缩略图列表 +- 下方显示当前选中的完整图片 +- 点击缩略图可切换展示内容 + +如果 Agent 未提供演示图,页面会显示“暂无演示图片”。 + +### Agent 简介 + +Agent 简介页会进一步展开 Agent 的介绍内容,包括: + +- 简介正文 +- 开场消息 +- 开场问题 + +适合用于理解这个 Agent 的对话风格、首轮引导方式和使用预期。 + +其中“开场问题”不仅用于展示推荐提问,还支持通过“快捷使用”直接跳转到 Codex,并把问题自动填入输入框,便于从推荐问题开始使用 Agent。 + +### 核心工作流与内置 Skills + +![agent-detail-skills.png](../../imgs/agent-detail-skills.png) + +详情页会解析 Agent 的入口 Skill 和依赖 Skills,并分别展示为: + +- 核心工作流 +- 内置 Skills + +如果某个 Skill 已被 Skills Hub 收录,可直接跳转到对应 Skill 详情页;如果尚未收录,会标记“暂未收录”。 + +### 相关 Agent + +相关 Agent 不是手工配置的,而是根据依赖 Skill 的重合度自动计算出的推荐结果。当前最多展示 3 个。 + +如果没有可推荐的结果,会显示“暂无相关 Agent”。 + +## 导入与更新规则 + +Agent 市场当前通过上传单个 Agent ZIP 包导入数据。 + +ZIP 导入时会校验以下内容: + +- 顶层必须且只能有一个 Agent 根目录 +- 根目录下必须包含 `agent.yaml` +- `metadata.name` 必须符合命名规则 +- `metadata.version` 必须是合法 SemVer +- `metadata.logo`、演示图、入口 Skill 引用必须真实存在 +- Logo 仅支持 `PNG`、`JPEG`、`WebP` + +### 更新规则 + +当上传的 Agent 名称已存在时,系统会按下面的规则处理: + +- 低版本禁止覆盖高版本 +- 同内容重复上传:不会重复写入数据库内容,但会补齐缺失的原始 ZIP 存档 +- 不同内容更新:需要确认覆盖 + +### ZIP 覆盖后的存储行为 + +上传新 ZIP 覆盖旧版本内容后,系统会: + +1. 写入新的资源文件和原始 ZIP +2. 更新数据库中的 Agent、文件快照和 Skill 关联 +3. 删除旧内容哈希目录下的历史资源和历史 ZIP + +也就是说,当前实现会保留当前最新的一份有效 ZIP 和资源文件,不会长期保留多份历史 ZIP。 + +如果更新过程失败,系统会清理这次新写入但未成功生效的目录,避免脏数据残留。 + +## Agent ZIP 目录约定 + +结合 `bugfix-agent` 的实际组织方式,一个典型的 Agent ZIP 通常会包含这些内容: + +```text +bugfix-agent +├── agent.yaml +├── README.md +├── MIGRATION.md +├── setup.sh +├── assets +│ ├── logo.png +│ ├── demo1.png +│ └── demo2.png +├── skills +│ └── bugfix-workflow +│ ├── SKILL.md +│ ├── agents +│ │ └── openai.yaml +│ ├── references +│ ├── scripts +│ └── tests +└── subagents + ├── bugfix-worker.toml + └── bugfix-reviewer.toml +``` + +其中: + +- `agent.yaml`:Agent 入口清单,声明页面展示字段和导入校验所需元数据 +- `README.md`:Agent 使用文档 +- `MIGRATION.md`:版本迁移说明 +- `setup.sh`:环境准备或安装脚本 +- `assets/logo.*`:Agent Logo,由 `metadata.logo` 引用 +- `assets/demo*`:详情页演示图片,由 `spec.demo.images` 引用 +- `skills/`:入口 Skill 及其依赖的参考文档、脚本、测试等内容 +- `subagents/`:可选的子 Agent 定义,例如 Worker、Reviewer 等角色 + +需要注意: + +- 不是所有目录都是必需的 +- 当前最小必需结构仍然是“根目录 + `agent.yaml` + 被 `agent.yaml` 引用到的文件” +- `subagents/` 可以作为运行时扩展内容打包进 ZIP,但 Agent 市场第一版不会在详情页单独展示它们 + +## agent.yaml 要求 + +`agent.yaml` 是 Agent ZIP 中最重要的入口文件。当前导入时会基于它做结构校验、资源校验和详情页字段提取。 + +### 当前必填和校验要求 + +- `apiVersion` 必须是 `doraemon.dtstack.com/v1` +- `kind` 必须是 `Agent` +- `metadata.name` 必须符合命名规则 +- `metadata.displayName` 不能为空 +- `metadata.version` 必须是合法 SemVer +- `metadata.description` 不能为空 +- `metadata.category` 必须是系统支持的分类值 +- `metadata.author.name` 不能为空 +- `metadata.logo` 必须存在,且文件类型仅支持 `PNG`、`JPEG`、`WebP` +- `spec.profile` 不能为空 +- `spec.entrypoint.ref` 必须存在,且要能定位到入口 Skill +- `spec.demo.images[].src` 如果填写,引用文件必须存在 + +### 当前会被页面使用的主要字段 + +- `metadata.logo`:列表页和详情页 Logo +- `metadata.description`:列表简介和概览说明 +- `metadata.tags`:列表和详情标签 +- `spec.profile`:Agent 简介正文 +- `spec.prompts`:开场问题和引导文案 +- `spec.capabilities`:概览中的能力范围 +- `spec.demo.images`:概览中的演示图片 +- `spec.entrypoint`:Agent 能力中的核心工作流 +- `spec.dependencies.skills`:内置 Skills 和相关 Agent 推荐依据 + +### 关于扩展字段 + +- 可以在 `agent.yaml` 中声明更多运行时结构 +- 例如 `spec.agents` 这类子 Agent 配置,可以和 `subagents/` 目录配合使用 +- 但当前 Agent 市场第一版不会把这些运行内部结构单独渲染到详情页 + +## 与 Skills Hub 的关系 + +Agent 市场和 Skills Hub 是两层不同的能力: + +- Skills Hub:管理 Skill 的收录、浏览、下载和安装 +- Agent 市场:管理 Agent 的展示、安装命令、原始 ZIP 和关联 Skill 说明 + +两者的关系可以理解为: + +- Skill 是能力模块 +- Agent 是把一个入口工作流和若干 Skill 组合后的可分发单元 + +因此在 Agent 详情页里,你会看到它依赖了哪些已收录或未收录的 Skill,但 Agent 市场本身不替代 Skills Hub。 diff --git a/docs/docsify/zh-cn/guide/dt-skill.md b/docs/docsify/zh-cn/guide/dt-skill.md index cc9a01f3..6549d53c 100644 --- a/docs/docsify/zh-cn/guide/dt-skill.md +++ b/docs/docsify/zh-cn/guide/dt-skill.md @@ -1,6 +1,6 @@ # Skills Hub(dt-skill) -Skills Hub 是 Doraemon 的 **Agent Skills 市场**能力:Web 端可浏览、下载;命令行 **`dt-skill`** 负责安装、更新、卸载本机 skill,以及把本地 skill **上传(upload)** 到 Registry。 +Skills Hub 是 Doraemon 的 **Agent Skill 市场**能力:Web 端可浏览、下载;命令行 **`dt-skill`** 负责安装、更新、卸载本机 skill,以及把本地 skill **上传(upload)** 到 Registry。 - **Node.js 18 及以上**(与 Doraemon / `dt-skill` package `engines` 对齐,推荐 18.x) - 默认 Registry:`http://172.16.100.225:7001` @@ -36,7 +36,7 @@ npx dt-skill --registry http://127.0.0.1:7001 list ## 安装 skill(install) -从 Skills 市场安装到本机,并链接到你使用的 Agent。 +从 Skill 市场安装到本机,并链接到你使用的 Agent。 ```bash npx dt-skill install zentao-api diff --git a/docs/superpowers/plans/2026-08-11-agent-market.md b/docs/superpowers/plans/2026-08-11-agent-market.md new file mode 100644 index 00000000..0a5924ef --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-agent-market.md @@ -0,0 +1,245 @@ +# Agent Market Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 Doraemon 落地可导入 ZIP、可从数据库读取列表与详情、可流式返回资源文件的 Agent 市场第一版 + +**Architecture:** 复用现有 Skill 市场的路由、控制器和页面骨架,新增独立的 `agents + agent_files + agent_skills` 数据模型与 `agents` 服务。导入时将 ZIP 先解压到临时目录,校验并解析 `agent.yaml` 后,把结构化字段写入数据库、把 `assets/` 写入 `/data/doraemon/agent-market` 配置目录,再通过数据库索引对外提供列表、详情、相关推荐和图片流接口。 + +**Tech Stack:** Egg.js 2.x、Sequelize、React 16、Ant Design 4、Node.js test runner、SCSS + +## Global Constraints + +- 必须保留现有 Skill 市场实现,不重构 `skills_*` 表和页面 +- 一个 ZIP 只允许一个 Agent,且必须包含唯一顶层目录和根部 `agent.yaml` +- 图片二进制不能写入数据库,只能保存到 `config.agentMarket.storageDir` +- 列表、详情、图片接口都必须以数据库为正式数据源 +- 图片 URL 不能暴露服务器绝对路径,也不能返回 Base64 +- 导入失败时不能留下半写入的数据库记录或本次新增资源目录 +- 详情页只展示 `概览 / Agent 简介 / Agent 能力` 三个页签 +- “安装”“使用”按钮只弹 `message.info('安装')` / `message.info('使用')` +- Demo 图片按内容宽度 100% 展示,高度自适应,图片间距 16px +- 分类复用 Skills 分类选项,相关推荐只按依赖 Skills 交集计算 + +--- + +### Task 1: 建立 Agent 数据模型与后端测试骨架 + +**Files:** +- Create: `app/model/agent.js` +- Create: `app/model/agent_file.js` +- Create: `app/model/agent_skill.js` +- Create: `test/agent-market-service.test.js` +- Modify: `sql/doraemon.sql` + +**Interfaces:** +- Consumes: `app.Sequelize`、现有 Skills 分类常量 +- Produces: `app.model.Agent`、`app.model.AgentFile`、`app.model.AgentSkill` + +- [ ] **Step 1: 写后端红灯测试** + +```js +test('Agent 模型字段包含资源索引和内容快照字段', async () => { + const agent = require('../app/model/agent'); + assert.equal(typeof agent, 'function'); +}); +``` + +- [ ] **Step 2: 运行单测确认失败** + +Run: `node --test test/agent-market-service.test.js` +Expected: FAIL,提示 `Cannot find module '../app/model/agent'` + +- [ ] **Step 3: 最小实现三个模型和 SQL 表结构** + +```js +module.exports = (app) => { + const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize; + return app.model.define('agent', { + name: { type: STRING(100), allowNull: false, unique: true }, + // 其余字段按设计文档补齐 + }, { + tableName: 'agents', + createdAt: 'created_at', + updatedAt: 'updated_at', + }); +}; +``` + +- [ ] **Step 4: 重跑单测确认通过** + +Run: `node --test test/agent-market-service.test.js` +Expected: PASS + +- [ ] **Step 5: 提交当前阶段** + +```bash +git add app/model/agent.js app/model/agent_file.js app/model/agent_skill.js sql/doraemon.sql test/agent-market-service.test.js +git commit -m "feat: add agent market models" +``` + +### Task 2: 按 TDD 落地 Agent 导入、更新、删除和资源读取服务 + +**Files:** +- Create: `app/service/agents.js` +- Create: `app/controller/agents.js` +- Modify: `app/router.js` +- Modify: `config/config.default.js` +- Modify: `test/agent-market-service.test.js` + +**Interfaces:** +- Consumes: `ctx.request.files`、`app.model.Agent`、`app.model.AgentFile`、`app.model.AgentSkill` +- Produces: + - `ctx.service.agents.queryAgentList(params)` + - `ctx.service.agents.getAgentDetail(name)` + - `ctx.service.agents.getRelatedAgents(name, limit)` + - `ctx.service.agents.importAgentFile(params, file)` + - `ctx.service.agents.deleteAgent(params)` + - `ctx.service.agents.getAgentAssetStream(params)` + +- [ ] **Step 1: 为导入规则和资源读取写失败测试** + +```js +test('导入单 Agent ZIP 时会拆出结构化字段并保存资源相对路径', async () => { + const service = createAgentsService(); + await assert.rejects(() => service.importAgentFile({}, mockZipFile)); +}); +``` + +- [ ] **Step 2: 运行单测确认失败** + +Run: `node --test test/agent-market-service.test.js` +Expected: FAIL,提示 `service.importAgentFile is not a function` + +- [ ] **Step 3: 最小实现导入与查询主链路** + +```js +async importAgentFile(params, file) { + await this.ensureStorageReady(); + const parsed = await this.parseAgentZip(file); + return this.app.model.transaction(async (transaction) => { + return this.saveAgentSnapshot(parsed, transaction); + }); +} +``` + +- [ ] **Step 4: 增加控制器与路由,补配置项** + +```js +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.post('/api/agents/import-file', app.controller.agents.importAgentFile); +app.post('/api/agents/delete', app.controller.agents.deleteAgent); +``` + +- [ ] **Step 5: 重跑服务测试确认通过** + +Run: `node --test test/agent-market-service.test.js` +Expected: PASS,覆盖导入成功、低版本拒绝、完整快照删除旧文件、资源路径校验、删除不影响 Skills + +### Task 3: 落地 Agent 市场列表页、详情页和前端交互 + +**Files:** +- Create: `app/web/pages/agents/index.tsx` +- Create: `app/web/pages/agents/types.ts` +- Create: `app/web/pages/agents/style.scss` +- Create: `app/web/pages/agents/detail/index.tsx` +- Create: `app/web/pages/agents/detail/AgentDetailContent.tsx` +- Create: `app/web/pages/agents/detail/style.scss` +- Modify: `app/web/router/index.ts` +- Modify: `app/web/layouts/header/header.tsx` +- Modify: `app/web/layouts/basicLayout/index.tsx` +- Modify: `app/web/api/url.ts` + +**Interfaces:** +- Consumes: `/api/agents/list`、`/api/agents/detail`、`/api/agents/related`、`/api/agents/import-file`、`/api/agents/delete` +- Produces: + - `/page/agents` + - `/page/agents/:name` + - `API.getAgentList / getAgentDetail / getRelatedAgents / importAgentFile / deleteAgent` + +- [ ] **Step 1: 先写前端契约测试或最小类型约束** + +```ts +export interface AgentItem { + name: string; + displayName: string; + logoUrl: string; +} +``` + +- [ ] **Step 2: 运行类型或构建校验,确认新页面尚未接入** + +Run: `npm run check-types` +Expected: FAIL,提示 `Cannot find module '@/pages/agents'` 或 API 类型缺失 + +- [ ] **Step 3: 最小实现列表页和详情页** + +```tsx + + + + + +``` + +- [ ] **Step 4: 接入导入弹窗、删除、相关 Agent 和按钮提示** + +```tsx + + +``` + +- [ ] **Step 5: 运行前端校验确认通过** + +Run: `npm run check-types` +Expected: PASS + +### Task 4: 补控制器集成测试与最终验证 + +**Files:** +- Create: `test/agent-market-controller.test.js` +- Modify: `test/agent-market-service.test.js` +- Modify: `docs/superpowers/specs/2026-08-11-agent-market-design.md` + +**Interfaces:** +- Consumes: `app/controller/agents.js`、`app/service/agents.js` +- Produces: 可回归的 Agent 市场后端测试集合 + +- [ ] **Step 1: 给控制器路由契约写失败测试** + +```js +test('Agent detail controller 返回统一 response 包装', async () => { + const controller = buildAgentsController({ getAgentDetail: async () => ({ name: 'bugfix-agent' }) }); + await controller.getAgentDetail(); + assert.equal(controller.ctx.body.success, true); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node --test test/agent-market-controller.test.js test/agent-market-service.test.js` +Expected: FAIL,提示缺少 `app/controller/agents` + +- [ ] **Step 3: 完成控制器测试支撑并收口文档** + +```js +ctx.body = app.utils.response(true, data); +``` + +- [ ] **Step 4: 运行完整验证** + +Run: `node --test test/agent-market-controller.test.js test/agent-market-service.test.js` +Expected: PASS + +Run: `npm run check-types` +Expected: PASS + +- [ ] **Step 5: 提交最终实现** + +```bash +git add app/controller/agents.js app/service/agents.js app/web/pages/agents app/web/router/index.ts app/web/layouts/header/header.tsx app/web/layouts/basicLayout/index.tsx app/web/api/url.ts test/agent-market-controller.test.js test/agent-market-service.test.js docs/superpowers/plans/2026-08-11-agent-market.md +git commit -m "feat: implement agent market" +``` diff --git a/docs/superpowers/specs/2026-08-11-agent-market-design.md b/docs/superpowers/specs/2026-08-11-agent-market-design.md new file mode 100644 index 00000000..a83ee2f6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-agent-market-design.md @@ -0,0 +1,407 @@ +# Agent 市场设计 + +## 1. 背景与目标 + +Doraemon 新增 Agent 市场,用于展示、搜索、导入和更新 Agent。Doraemon 负责 Agent 的市场展示和安装包存储,不负责在线运行;Agent 最终由 Codex 等宿主执行。 + +第一版目标: + +- 提供 Agent 列表页和详情页 +- 通过 ZIP 导入单个 Agent +- 从 `agent.yaml` 提取市场展示字段 +- 保存 Agent 完整文件快照,为后续安装能力保留基础 +- 展示核心工作流和依赖 Skills +- 根据公共依赖 Skills 推荐相关 Agent + +第一版不包含: + +- Git 仓库自动同步 +- Agent 在线运行 +- Agent 安装和使用的真实功能 +- Stars、下载量、收藏和版本历史 +- 多 Agent ZIP 导入 + +## 2. 数据来源 + +正式运行时,Agent 列表和详情从数据库读取。Logo、Demo 等资源的元数据和相对路径从数据库读取,图片二进制从服务器持久化目录读取,不存入数据库。`agent-market` 本地目录不作为生产数据源,也不依赖相邻目录挂载。 + +开发环境如需直接预览本地资源,只能在 `config.local.js` 配置静态目录,不能将本机路径写入 `config.default.js`。 + +第一版按单机部署设计,生产资源根目录默认为 `/data/doraemon/agent-market`,并允许通过配置项覆盖。该目录必须位于持久化磁盘,不随应用发布、重启或临时文件清理而删除。 + +## 3. Agent ZIP 契约 + +### 3.1 目录结构 + +一个 ZIP 只能包含一个 Agent,压缩包顶层为一个 Agent 目录: + +```text +bugfix-agent/ +├── assets/ +│ ├── demo1.png +│ ├── demo2.png +│ └── logo.png +├── skills/ +│ └── bugfix-workflow/ +│ ├── agents/ +│ ├── references/ +│ ├── scripts/ +│ ├── tests/ +│ └── SKILL.md +├── subagents/ +│ ├── bugfix-reviewer.toml +│ └── bugfix-worker.toml +├── agent.yaml +├── MIGRATION.md +├── README.md +└── setup.sh +``` + +导入器忽略 `.DS_Store` 和 `__MACOSX`。解压后必须且只能发现一个 `agent.yaml`,且该文件必须位于唯一顶层 Agent 目录的根部。 + +### 3.2 文件安全 + +导入时拒绝: + +- 绝对路径、`../` 路径和路径穿越 +- 软链接和其他特殊文件 +- 重复路径和大小写冲突路径 +- 超出限制的 ZIP、文件数量、解压体积或单文件 + +默认限制:ZIP 最大 50MB、解压后最大 200MB、最多 500 个文件、单文件最大 20MB、单张展示图片最大 5MB。 + +保存非 `assets/` 文件时记录相对路径、MIME、大小、编码、内容和 Unix mode。`setup.sh` 等可执行文件在重新构建安装包时必须恢复执行权限。 + +`assets/` 仅允许普通文件,Logo 和 Demo 第一版支持 PNG、JPEG 和 WebP。导入时根据文件签名校验实际类型,不能只信任扩展名或上传的 `Content-Type`。 + +## 4. Manifest 契约 + +### 4.1 必填字段 + +```yaml +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: Agent 详细简介 + entrypoint: + host: codex + type: skill + name: bugfix-workflow + ref: ./skills/bugfix-workflow +``` + +校验规则: + +- `apiVersion` 第一版只接受 `doraemon.dtstack.com/v1` +- `kind` 必须为 `Agent` +- `metadata.name` 是不可变唯一标识,只允许小写字母、数字和连字符,最大 100 字符 +- `metadata.version` 必须是 SemVer +- `metadata.category` 复用 Skills 分类:`通用`、`前端`、`后端`、`数据与AI`、`运维与系统`、`工程效率`、`安全`、`其他` +- 已声明的 Logo、Demo、入口 Skill 和 SubAgent 相对路径必须存在于 ZIP 中 +- 未识别的扩展字段不写入结构化列,但会随完整 `agent.yaml` 保存在 `agent_files` 中,不影响当前版本解析 + +### 4.2 页面字段映射 + +| Manifest 字段 | 用途 | +| --- | --- | +| `metadata.name` | 唯一标识、详情路由、更新匹配 | +| `metadata.displayName` | Agent 展示名称 | +| `metadata.version` | 当前版本 | +| `metadata.logo` | 列表和详情 Logo | +| `metadata.description` | 列表摘要和详情头部摘要 | +| `metadata.author.name` | 作者 | +| `metadata.category` | 单一分类 | +| `metadata.tags` | 多个检索和展示标签 | +| `spec.profile` | Agent 简介 | +| `spec.prompts` | 概览示例问题 | +| `spec.capabilities` | 概览中的“可以做什么” | +| `spec.entrypoint` | Agent 能力中的核心工作流 | +| `spec.dependencies.skills` | Agent 能力中的依赖 Skills、相关推荐依据 | +| `spec.demo.images` | 概览 Demo 图片和替代文本 | + +`spec.agents` 属于运行内部结构,第一版不在详情页单独展示。 + +### 4.3 Bugfix Agent 示例问题 + +```yaml +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" +``` + +## 5. 数据模型 + +采用独立的 Agent 数据模型,不重构现有 Skills。 + +### 5.1 `agents` + +保存可查询的结构化字段: + +- `id` +- `name`,唯一索引 +- `display_name` +- `version` +- `description` +- `profile`,LONGTEXT +- `author_name` +- `category` +- `tags`,JSON 字符串 +- `prompts`,JSON 字符串 +- `capabilities`,JSON 字符串 +- `demo_images`,JSON 字符串,保存资源相对路径、MIME、大小、hash、alt 和顺序 +- `entrypoint_host` +- `entrypoint_type` +- `entrypoint_name` +- `entrypoint_ref` +- `logo_path`,保存资源相对路径 +- `logo_mime_type` +- `logo_size` +- `logo_hash` +- `content_hash` +- `source_file_name` +- `file_count` +- `is_delete` +- `created_at`、`updated_at` + +### 5.2 `agent_files` + +保存 Agent 除 `assets/` 外的完整文件快照: + +- `id` +- `agent_id` +- `file_path` +- `mime_type` +- `size` +- `is_binary` +- `encoding`,文本使用 `utf8`,二进制使用 `base64` +- `mode`,保存 Unix 文件权限 +- `content`,LONGTEXT +- `is_delete` +- `created_at`、`updated_at` + +`agent_id + file_path` 建立唯一索引。`assets/` 下的图片二进制不写入 `agent_files`,对应索引由 `agents.logo_*` 和 `agents.demo_images` 保存。 + +### 5.3 `agent_skills` + +保存核心工作流和公共 Skill 关系: + +- `id` +- `agent_id` +- `skill_slug` +- `skill_id`,允许为空 +- `relation_type`,`entrypoint` 或 `dependency` +- `sort_order` +- `created_at`、`updated_at` + +公共 Skill 尚未收录时仍保存 `skill_slug`。详情查询时按 slug 动态解析,后续 Skill 导入后无需重新导入 Agent。 + +### 5.4 Agent 资源目录 + +资源根目录由 `config.agentMarket.storageDir` 控制,生产环境配置为: + +```text +/data/doraemon/agent-market/ +└── / + └── / + └── assets/ + ├── logo.png + ├── demo1.png + └── demo2.png +``` + +数据库只保存相对于资源根目录的路径,例如 `bugfix-agent//assets/logo.png`,不保存绝对路径。`content-hash` 进入路径,用于避免同版本覆盖后的浏览器缓存污染,并支持新旧资源目录原子切换。 + +资源不能写入 `cache/uploads`,该目录只用于上传和解压过程中的临时文件。资源也不能直接读取相邻的 `../agent-market` 源目录。 + +## 6. 导入、更新与删除 + +### 6.1 导入流程 + +1. 接收一个 ZIP,并写入上传临时目录 +2. 安全解压到临时目录 +3. 校验单 Agent 目录结构和 `agent.yaml` +4. 校验所有 Manifest 文件引用 +5. 读取全部文件和权限,计算 `content_hash` +6. 按 `metadata.name` 查询现有 Agent +7. 新 Agent 直接创建;已有 Agent 进入更新规则 +8. 将 `assets/` 写入资源根目录下的临时目录,校验完成后原子重命名为 `//assets` +9. 在一个数据库事务中写入 Agent、非资源文件和 Skill 关系,并将资源相对路径指向新目录 +10. 数据库事务失败时删除本次新增资源目录;事务成功后删除该 Agent 的旧 hash 资源目录 +11. 成功或失败后删除上传 ZIP 和解压目录 +12. 清理 Agent 列表缓存并返回导入结果 + +### 6.2 更新规则 + +- 相同版本允许覆盖 +- 高版本允许升级 +- 低版本禁止覆盖高版本 +- `content_hash` 相同则返回“内容未变化”,不重复写入 +- 新 ZIP 是完整快照,新包不存在的旧文件和旧关系必须删除 +- 更新失败时旧版本保持不变 +- 第一版只保存当前版本,不保留版本历史 + +更新前端采用重新上传 ZIP,不提供直接修改数据库字段的编辑表单。首次检测到同名 Agent 时返回更新摘要并请求用户确认,确认后再次提交覆盖请求。 + +### 6.3 删除规则 + +删除时将 `agents.is_delete` 设置为 `1`,并物理删除对应的 `agent_files`、`agent_skills` 和服务器资源目录。不得删除任何 Skill 市场记录。重新导入相同 `metadata.name` 时恢复 Agent,并使用新 ZIP 重建文件、资源和关系。 + +数据库删除成功但资源目录清理失败时记录错误并进入后续清理,不回滚数据库删除结果。资源目录只能根据数据库中已校验的 Agent 名称和 hash 计算,禁止接受客户端传入的任意物理路径。 + +## 7. API 设计 + +第一版提供: + +- `GET /api/agents/list`:关键词、分类、分页查询 +- `GET /api/agents/detail`:Agent 详情、核心工作流、依赖 Skills +- `GET /api/agents/related`:相关 Agent +- `GET /api/agents/asset`:返回 Logo 或 Demo 二进制资源 +- `POST /api/agents/import-file`:导入或确认覆盖 Agent ZIP +- `POST /api/agents/delete`:软删除 Agent + +资源接口根据数据库记录定位资源相对路径,校验解析后的绝对路径仍位于资源根目录内,再以文件流返回。接口根据已保存 MIME 返回正确 `Content-Type`,并对带 hash 的资源设置长期缓存头。列表和详情接口只返回资源 URL,不内嵌 Base64,也不返回服务器绝对路径。 + +## 8. 列表页 + +导航栏在 `Skills` 相邻位置增加 `Agents`,路由为 `/page/agents`。 + +列表页沿用 Skill 市场的视觉语言: + +- 标题“Agent 市场” +- 副标题“发现并导入适用于不同研发场景的 Agent” +- 搜索名称、描述、标签和作者 +- 分类筛选复用 Skills 分类 +- 三列响应式卡片,移动端单列 +- 卡片展示 Logo、名称、描述、分类、最多三个标签、版本、作者、更新时间和依赖 Skill 数量 +- 默认按更新时间倒序 +- API 保留分页,当前总数不超过一页时不展示分页器 +- 不展示 Stars、下载量、收藏、多选、复制命令和排序器 +- 提供“导入 Agent”按钮 +- 更新入口只允许重新上传 ZIP,删除行为与 Skill 市场一致 + +## 9. 详情页 + +详情页路由为 `/page/agents/:name`,沿用 LobeHub 的信息层级,但保持 Doraemon 的现有视觉语言。 + +### 9.1 顶部区域 + +展示 Logo、名称、作者、版本、分类和标签。右侧提供“安装”和“使用”按钮;第一版点击后分别执行 `message.info('安装')` 和 `message.info('使用')`。 + +### 9.2 页签 + +详情页包含三个页签: + +- 概览 +- Agent 简介 +- Agent 能力 + +默认打开概览,页签状态不写入 URL。 + +### 9.3 概览 + +概览展示: + +- `metadata.description` +- `spec.capabilities` 能力卡片 +- `spec.prompts` 三个示例问题 +- `spec.demo.images` Demo 图片 + +Demo 图片按内容区宽度 `width: 100%` 展示,高度自适应,图片间距 16px,不使用轮播、缩略图或灯箱。 + +### 9.4 Agent 简介 + +渲染数据库中的 `profile` 字段,不直接读取或渲染 README。第一版按纯文本段落和换行展示,不启用任意 HTML。 + +### 9.5 Agent 能力 + +分为: + +- 核心工作流:展示 `spec.entrypoint` 对应的一个入口 Skill +- 依赖 Skills:按 `spec.dependencies.skills` 顺序展示公共 Skill + +已收录 Skill 可跳转 Skills 详情页;未收录 Skill 显示“暂未收录”,不提供跳转。 + +### 9.6 相关 Agent + +右侧最多展示三个相关 Agent。仅使用公共依赖 Skills 计算,不使用入口 Skill: + +1. 排除当前 Agent +2. 计算公共依赖 Skill 交集数量 +3. 过滤交集为 0 的 Agent +4. 按交集数量倒序 +5. 同分按更新时间倒序 + +没有结果时隐藏整个模块。移动端将操作按钮和相关 Agent 移到正文下方。 + +## 10. 异常与空状态 + +- Agent 不存在或已删除:展示空状态并返回 Agent 列表 +- Logo 缺失或加载失败:使用统一默认 Agent 图标 +- 单张 Demo 加载失败:保留位置并显示“图片加载失败”,其他图片继续展示 +- 依赖 Skill 未收录:展示不可跳转的缺失状态,不阻止 Agent 导入 +- ZIP 或 Manifest 校验失败:一次返回明确错误,不写入任何 Agent 数据 +- 低版本覆盖:返回当前版本和导入版本 +- 相同内容:返回“内容未变化” + +## 11. 测试与验收 + +后端测试至少覆盖: + +- 正常单 Agent ZIP 导入 +- 多 Agent、无 Agent、路径穿越、软链接和超限 ZIP 拒绝 +- Manifest 必填字段、分类、SemVer 和文件引用校验 +- 新增、同版本覆盖、高版本升级、低版本拒绝 +- 完整快照删除旧文件 +- 导入失败事务回滚 +- 非资源文件内容和 mode 保存 +- Logo、Demo 写入持久化目录,数据库不保存图片二进制 +- 资源路径穿越、伪造图片类型和超限图片拒绝 +- 覆盖成功切换新 hash 目录并清理旧目录 +- 数据库事务失败时清理本次新增资源目录 +- 缺失公共 Skill 仍可导入 +- 相关 Agent 交集排序和无结果隐藏 +- 删除 Agent 不影响 Skills + +前端验收至少覆盖: + +- 列表搜索、分类、空状态和响应式布局 +- 导入新增、覆盖确认、错误提示和内容未变化 +- 三个详情页签内容映射正确 +- Demo 图片宽度、高度和 16px 间距正确 +- 已收录与未收录 Skill 状态正确 +- 相关 Agent 排序、跳转和移动端布局正确 +- “安装”“使用”按钮弹出对应名称 + +## 12. 已确认决策 + +- 使用独立 `agents + agent_files + agent_skills`,不重构 Skills +- 一个 ZIP 只允许一个 Agent +- ZIP 顶层为单一 Agent 目录,`agent.yaml` 位于该目录根部 +- 仅手动 ZIP 导入,不做目录导入和自动同步 +- 相同版本覆盖,低版本禁止覆盖 +- 更新按完整快照处理 +- 不保留版本历史 +- 删除 Agent 不影响 Skills +- 分类复用 Skills,Tags 独立保存 +- 数据库是列表、详情和资源索引的正式数据源 +- Logo、Demo 图片保存在单机持久化目录 `/data/doraemon/agent-market`,数据库不保存图片二进制 +- Demo 原图顺序纵向展示,宽度撑满,高度自适应,间距 16px +- 详情页只渲染数据库字段,不直接渲染源文件 +- 第一版按钮只弹出按钮名称 diff --git a/env.json b/env.json index a0a5e2b2..328489a3 100644 --- a/env.json +++ b/env.json @@ -6,6 +6,7 @@ "articleHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/%E6%96%87%E7%AB%A0%E8%AE%A2%E9%98%85", "proxyHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1", "skillsHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/dt-skill", + "agentHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market", "mysql": { "prod": {} }, diff --git a/sql/doraemon.sql b/sql/doraemon.sql index 30b4f373..19eb4c82 100644 --- a/sql/doraemon.sql +++ b/sql/doraemon.sql @@ -377,6 +377,84 @@ CREATE TABLE `skills_files` ( KEY `idx_skills_file_skill_id` (`skill_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能文件表'; +-- ---------------------------- +-- Table structure for agents +-- ---------------------------- +DROP TABLE IF EXISTS `agents`; +CREATE TABLE `agents` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL COMMENT 'Agent 唯一标识', + `display_name` varchar(255) NOT NULL COMMENT 'Agent 展示名称', + `version` varchar(64) NOT NULL DEFAULT '' COMMENT 'Agent 版本号', + `description` text COMMENT '列表摘要', + `profile` longtext COMMENT 'Agent 详细简介', + `author_name` varchar(255) DEFAULT NULL COMMENT '作者', + `category` varchar(64) NOT NULL DEFAULT '通用' COMMENT '分类', + `tags` longtext COMMENT 'JSON 字符串数组', + `prompts` longtext COMMENT 'JSON 字符串数组', + `capabilities` longtext COMMENT 'JSON 字符串数组', + `demo_images` longtext COMMENT 'JSON 字符串数组', + `entrypoint_host` varchar(64) DEFAULT NULL COMMENT '入口宿主', + `entrypoint_type` varchar(64) DEFAULT NULL COMMENT '入口类型', + `entrypoint_name` varchar(255) DEFAULT NULL COMMENT '入口名称', + `entrypoint_ref` varchar(1000) DEFAULT NULL COMMENT '入口路径', + `logo_path` varchar(1000) DEFAULT NULL COMMENT 'Logo 相对路径', + `logo_mime_type` varchar(100) DEFAULT NULL COMMENT 'Logo MIME', + `logo_size` int NOT NULL DEFAULT '0' COMMENT 'Logo 大小', + `logo_hash` varchar(128) DEFAULT NULL COMMENT 'Logo 哈希', + `content_hash` varchar(128) NOT NULL COMMENT '内容哈希', + `source_file_name` varchar(255) DEFAULT NULL COMMENT '上传文件名', + `file_count` int NOT NULL DEFAULT '0' COMMENT '文件数量', + `is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_agents_name` (`name`), + KEY `idx_agents_category` (`category`), + KEY `idx_agents_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 条目表'; + +-- ---------------------------- +-- Table structure for agent_files +-- ---------------------------- +DROP TABLE IF EXISTS `agent_files`; +CREATE TABLE `agent_files` ( + `id` int NOT NULL AUTO_INCREMENT, + `agent_id` int NOT NULL COMMENT 'agents.id', + `file_path` varchar(512) NOT NULL COMMENT 'Agent 内相对路径', + `mime_type` varchar(100) DEFAULT NULL COMMENT '文件 MIME', + `size` int NOT NULL DEFAULT '0' COMMENT '文件大小', + `is_binary` tinyint NOT NULL DEFAULT '0' COMMENT '是否二进制', + `encoding` varchar(20) NOT NULL DEFAULT 'utf8' COMMENT '内容编码', + `mode` int NOT NULL DEFAULT '0' COMMENT 'Unix 权限', + `content` longtext COMMENT '文件内容', + `is_delete` tinyint NOT NULL DEFAULT '0', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_agent_files_agent_path` (`agent_id`,`file_path`), + KEY `idx_agent_files_agent_id` (`agent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 文件快照表'; + +-- ---------------------------- +-- Table structure for agent_skills +-- ---------------------------- +DROP TABLE IF EXISTS `agent_skills`; +CREATE TABLE `agent_skills` ( + `id` int NOT NULL AUTO_INCREMENT, + `agent_id` int NOT NULL COMMENT 'agents.id', + `skill_slug` varchar(255) NOT NULL COMMENT 'Skill slug', + `skill_id` int DEFAULT NULL COMMENT 'skills_items.id', + `relation_type` varchar(20) NOT NULL COMMENT 'entrypoint 或 dependency', + `sort_order` int NOT NULL DEFAULT '0' COMMENT '展示顺序', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_agent_skills_agent_id` (`agent_id`), + KEY `idx_agent_skills_skill_slug` (`skill_slug`), + KEY `idx_agent_skills_relation_type` (`relation_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 关联 Skill 表'; + -- ---------------------------- -- Table structure for skill_likes -- ---------------------------- diff --git a/test/agent-capability-utils.test.js b/test/agent-capability-utils.test.js new file mode 100644 index 00000000..0bd1309d --- /dev/null +++ b/test/agent-capability-utils.test.js @@ -0,0 +1,23 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { normalizeAgentCapabilities } = require('../app/web/pages/agents/detail/capability-utils'); + +test('normalizeAgentCapabilities 兼容旧的字符串数组能力数据', () => { + const result = normalizeAgentCapabilities(['分析 Bug', '修复代码']); + + assert.deepEqual(result, [ + { id: '', name: '分析 Bug', description: '' }, + { id: '', name: '修复代码', description: '' }, + ]); +}); + +test('normalizeAgentCapabilities 保留对象型能力数据的 name 和 description', () => { + const result = normalizeAgentCapabilities([ + { id: 'bug-context', name: 'Bug 信息分析', description: '获取上下文' }, + ]); + + assert.deepEqual(result, [ + { id: 'bug-context', name: 'Bug 信息分析', description: '获取上下文' }, + ]); +}); diff --git a/test/agent-codex-button-utils.test.js b/test/agent-codex-button-utils.test.js new file mode 100644 index 00000000..ea21c56a --- /dev/null +++ b/test/agent-codex-button-utils.test.js @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAgentDetailCodexPrompt, + buildCodexNewThreadUrl, +} = require('../app/web/pages/agents/codex-button-utils'); + +test('buildCodexNewThreadUrl builds a codex new thread deep link with encoded prompt and origin', () => { + const url = buildCodexNewThreadUrl({ + prompt: '打开 Agent 市场\n使用示例', + originUrl: 'http://10.10.10.168:7001/page/agents?keyword=AI Agent', + }); + + assert.equal(url.startsWith('codex://threads/new?'), true); + assert.equal(url.includes('prompt='), true); + assert.equal(url.includes('originUrl='), true); + assert.equal(url.includes('AI+Agent'), true); + assert.equal(url.includes('\n'), false); +}); + +test('buildAgentDetailCodexPrompt returns only the opening question prompt', () => { + const prompt = buildAgentDetailCodexPrompt( + { + displayName: 'Bug 修复 Agent', + name: 'bugfix-agent', + description: '用于修复 Bug', + entrypoint: { slug: 'bugfix-workflow', name: 'Bug 修复工作流' }, + dependencies: [ + { slug: 'zentao-api', name: '禅道 API' }, + { slug: 'gitlab-mr-ci-watch', name: 'MR CI 观察' }, + ], + prompts: [{ title: '修复 Bug', prompt: '$bugfix-workflow 12345' }], + }, + 'http://10.10.10.168:7001/page/agents/bugfix-agent' + ); + + assert.equal(prompt, '$bugfix-workflow 12345'); +}); + +test('buildAgentDetailCodexPrompt can use the selected opening question', () => { + const prompt = buildAgentDetailCodexPrompt( + { + displayName: 'Bug 修复 Agent', + name: 'bugfix-agent', + prompts: [{ title: '默认问题', prompt: '$bugfix-workflow 默认' }], + }, + 'http://10.10.10.168:7001/page/agents/bugfix-agent', + { title: '自然语言', prompt: '帮我修 bug,禅道 Bug ID 是 156343' } + ); + + assert.equal(prompt, '帮我修 bug,禅道 Bug ID 是 156343'); +}); diff --git a/test/agent-detail-layout.test.js b/test/agent-detail-layout.test.js new file mode 100644 index 00000000..cbcfbd7a --- /dev/null +++ b/test/agent-detail-layout.test.js @@ -0,0 +1,297 @@ +const test = require('node:test'); +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, + '图标和文案需要用双列布局保持在一行' + ); +}); + +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-capability-grid'), + true, + 'Agent 能力区块需要使用紧凑网格' + ); + assert.equal(componentContent.includes(' { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/detail/style.scss'), + 'utf8' + ); + + 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 描述字号需要提升' + ); +}); + +test('相关 Agent 为空时展示暂无数据空态', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), + 'utf8' + ); + + assert.equal( + content.includes('related.length > 0') && content.includes('description="暂无相关 Agent"'), + true, + '相关 Agent 为空时需要展示明确的空态' + ); +}); + +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' + ); + + 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 的默认白底、边框和阴影' + ); +}); + +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 样式' + ); +}); + +test('Agent 详情右侧提供当前原始 ZIP 下载入口', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'), + 'utf8' + ); + + assert.equal( + content.includes('/api/agents/download?name=${encodeURIComponent(detail.name)}') && + content.includes('下载 Agent ZIP'), + true, + '详情页需要提供当前 Agent 原始 ZIP 下载按钮' + ); +}); diff --git a/test/agent-help-doc-config.test.js b/test/agent-help-doc-config.test.js new file mode 100644 index 00000000..3c3081c2 --- /dev/null +++ b/test/agent-help-doc-config.test.js @@ -0,0 +1,14 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const envConfig = require('../env.json'); + +test('env.json 提供 Agent 市场独立帮助文档地址', () => { + assert.equal(typeof envConfig.agentHelpDocUrl, 'string'); + assert.equal(envConfig.agentHelpDocUrl.length > 0, true, '应配置 agentHelpDocUrl'); + assert.equal( + envConfig.agentHelpDocUrl.includes('/zh-cn/guide/agent-market'), + true, + 'Agent 帮助文档应指向 agent-market 文档' + ); +}); diff --git a/test/agent-intro-utils.test.js b/test/agent-intro-utils.test.js new file mode 100644 index 00000000..b8468ff2 --- /dev/null +++ b/test/agent-intro-utils.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildAgentIntroBlocks } = require('../app/web/pages/agents/detail/intro-utils'); + +test('buildAgentIntroBlocks 将 profile、description、prompts 拆成三个展示块', () => { + const result = buildAgentIntroBlocks({ + profile: '第一段\n\n第二段', + description: '欢迎告诉我你当前要处理的 Bug。', + prompts: [ + { 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); +}); + +test('buildAgentIntroBlocks 缺少开场消息时回退到列表摘要', () => { + const result = buildAgentIntroBlocks({ + profile: '简介', + description: '', + summary: '这是摘要', + prompts: [], + }); + + assert.equal(result.openingMessage, '这是摘要'); +}); diff --git a/test/agent-list-layout.test.js b/test/agent-list-layout.test.js new file mode 100644 index 00000000..1f0507ce --- /dev/null +++ b/test/agent-list-layout.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +test('Agent 列表卡片不再把 category 渲染成 tag,并提供删除按钮本地开关注释', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/index.tsx'), + 'utf8' + ); + + assert.equal( + content.includes('{agent.category}'), + false, + '列表卡片不应再把 category 渲染成 tag' + ); + assert.equal( + content.includes('doraemon.agentMarket.deleteEnabled'), + true, + '应包含删除按钮 localStorage 开关键' + ); + assert.equal( + content.includes("localStorage.setItem('doraemon.agentMarket.deleteEnabled', 'true')"), + true, + '应在删除按钮旁保留启用写法注释' + ); + assert.equal( + content.includes('agent.tags.slice(0, 4)'), + true, + '列表页 tag 数量应与详情页保持一致' + ); +}); + +test('Agent 列表卡片描述字号为 13px', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/style.scss'), + 'utf8' + ); + + assert.equal( + content.includes('.agent-card-description') && content.includes('font-size: 13px;'), + true, + '列表页描述字号需要是 13px' + ); +}); + +test('Agent 列表页提供帮助文档入口并读取独立配置', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/pages/agents/index.tsx'), + 'utf8' + ); + + assert.equal(content.includes("import helpIcon from '@/asset/images/help-icon.png';"), true); + assert.equal(content.includes('config.agentHelpDocUrl'), true, '应读取 Agent 独立帮助文档配置'); + assert.equal(content.includes('title="Agent 市场帮助文档"'), true, '应提供帮助文档提示文案'); +}); diff --git a/test/agent-market-controller.test.js b/test/agent-market-controller.test.js new file mode 100644 index 00000000..dfb9aab2 --- /dev/null +++ b/test/agent-market-controller.test.js @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const AgentsController = require('../app/controller/agents'); + +function createMockCtx(query = {}, body = {}, files = []) { + return { + query, + request: { + body, + files, + }, + logger: { + warn() {}, + info() {}, + error() {}, + }, + throw(status, message) { + const error = new Error(message); + error.status = status; + throw error; + }, + set() {}, + body: null, + service: { + agents: {}, + }, + }; +} + +function buildController(serviceMethods) { + const controller = Object.create(AgentsController.prototype); + controller.ctx = createMockCtx(); + controller.app = { + utils: { + response(success, data, msg) { + return { success, data, msg }; + }, + }, + }; + controller.ctx.service.agents = serviceMethods; + return controller; +} + +test('getAgentDetail 返回统一 response 包装', async () => { + const controller = buildController({ + getAgentDetail: async (name) => { + assert.equal(name, 'bugfix-agent'); + return { name: 'bugfix-agent' }; + }, + }); + controller.ctx.query = { name: 'bugfix-agent' }; + + await controller.getAgentDetail(); + assert.equal(controller.ctx.body.success, true); + 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() {} }; + const controller = buildController({ + getAgentArchiveStream: async (name) => { + assert.equal(name, 'bugfix-agent'); + return { + stream, + fileName: 'bugfix-agent.zip', + mimeType: 'application/zip', + }; + }, + }); + controller.ctx.query = { name: 'bugfix-agent' }; + controller.ctx.set = (key, value) => { + headers[key] = value; + }; + + await controller.downloadAgentArchive(); + + assert.equal(headers['Content-Type'], 'application/zip'); + assert.equal(headers['Content-Disposition'], 'attachment; filename="bugfix-agent.zip"'); + assert.equal(controller.ctx.body, stream); +}); diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js new file mode 100644 index 00000000..0a78380a --- /dev/null +++ b/test/agent-market-service.test.js @@ -0,0 +1,464 @@ +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 = { + logger: { + info() {}, + warn() {}, + error() {}, + }, + throw(status, message) { + const error = new Error(message); + error.status = status; + throw error; + }, + }; + service.app = { + config: { + agentMarket: { + storageDir: '/data/doraemon/agent-market', + maxZipSize: 50 * 1024 * 1024, + maxExtractedSize: 200 * 1024 * 1024, + maxFileCount: 500, + maxSingleFileSize: 20 * 1024 * 1024, + maxImageSize: 5 * 1024 * 1024, + }, + }, + }; + return service; +} + +function createAgentZip(manifestOverrides = {}, extraEntries = []) { + const zip = new AdmZip(); + const root = 'bugfix-agent'; + const manifest = { + 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', 'Review'], + }, + 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, + }; + + 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') + ); + zip.addFile( + `${root}/subagents/bugfix-reviewer.toml`, + Buffer.from('name = "bugfix-reviewer"\n', 'utf8') + ); + zip.addFile( + `${root}/subagents/bugfix-worker.toml`, + Buffer.from('name = "bugfix-worker"\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')); + + extraEntries.forEach((entry) => { + zip.addFile(entry.name, Buffer.from(entry.content || '', entry.encoding || 'utf8')); + }); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-market-test-')); + const zipPath = path.join(tempDir, 'bugfix-agent.zip'); + zip.writeZip(zipPath); + return { + zipPath, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +test('parseAgentZip 解析单 Agent ZIP 并拆出结构化字段与文件快照', async () => { + const service = createService(); + const fixture = createAgentZip(); + + try { + const parsed = await service.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.files.some((item) => item.filePath === 'assets/logo.png'), + false, + '资源文件不应该写入 agent_files' + ); + assert.equal( + parsed.files.some((item) => item.filePath === 'skills/bugfix-workflow/SKILL.md'), + true + ); + assert.equal( + parsed.files.some((item) => item.filePath === 'agent.yaml'), + true + ); + } finally { + fixture.cleanup(); + } +}); + +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'], + }, + }); + + try { + await assert.rejects(() => service.parseAgentZip(fixture.zipPath), /category 无效/); + } finally { + fixture.cleanup(); + } +}); + +test('parseAgentZip 支持 demo.images 使用 src 字段', async () => { + const service = createService(); + const fixture = createAgentZip(); + + try { + const parsed = await service.parseAgentZip(fixture.zipPath); + assert.equal(parsed.demoImages.length, 2); + assert.equal(parsed.demoImages[0].originalPath, 'assets/demo1.png'); + } 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); + + try { + await assert.rejects( + () => service.parseAgentZip(fixture.zipPath), + /spec\.demo\.images\[0\] 路径非法/ + ); + } 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); + + 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: '完成修复', + }, + ]); + } finally { + fixture.cleanup(); + } +}); + +test('normalizeCapabilities 兼容旧的字符串数组存量数据', () => { + const service = createService(); + + assert.deepEqual(service.normalizeCapabilities(['分析 Bug', '修复代码']), [ + { + id: '', + name: '分析 Bug', + description: '', + }, + { + id: '', + name: '修复代码', + description: '', + }, + ]); +}); + +test('compareAgentVersion 按 semver 比较版本号', () => { + const service = createService(); + + assert.equal(service.compareAgentVersion('1.0.0', '1.0.0'), 0); + assert.equal(service.compareAgentVersion('1.0.1', '1.0.0'), 1); + 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 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'); + 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', + }, + sourcePath + ); + const archivePath = path.join(storageDir, 'bugfix-agent', 'hash-v2', 'bugfix-agent.zip'); + + assert.equal(archiveDir, path.dirname(archivePath)); + assert.equal(fs.readFileSync(archivePath, 'utf8'), 'original-agent-zip'); + } finally { + fs.rmSync(storageDir, { recursive: true, force: true }); + fs.rmSync(sourceDir, { recursive: true, force: true }); + } +}); + +test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => { + 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 = { + Agent: { + async findOne() { + return { + name: 'bugfix-agent', + content_hash: 'hash-current', + is_delete: 0, + }; + }, + }, + }; + + try { + 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'); + } finally { + fs.rmSync(storageDir, { recursive: true, force: true }); + } +}); diff --git a/test/basic-layout-flags.test.js b/test/basic-layout-flags.test.js new file mode 100644 index 00000000..df99a709 --- /dev/null +++ b/test/basic-layout-flags.test.js @@ -0,0 +1,12 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { shouldUseSkillDetailLayout } = require('../app/web/layouts/basicLayout/layout-flags'); + +test('skills 详情页命中专用布局', () => { + assert.equal(shouldUseSkillDetailLayout('/page/skills/bugfix-workflow'), true); +}); + +test('agents 详情页不命中 skills 专用布局,避免页面滚动被锁住', () => { + assert.equal(shouldUseSkillDetailLayout('/page/agents/bugfix-agent'), false); +}); diff --git a/test/config-local-agent-market.test.js b/test/config-local-agent-market.test.js new file mode 100644 index 00000000..cb5ee334 --- /dev/null +++ b/test/config-local-agent-market.test.js @@ -0,0 +1,12 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const createLocalConfig = require('../config/config.local'); + +test('config.local.js 为 Agent 市场提供仓库内本地存储目录', () => { + const config = createLocalConfig(); + const expectedStorageDir = path.join(process.cwd(), 'agent-market'); + + assert.equal(config.agentMarket.storageDir, expectedStorageDir); +}); diff --git a/test/header-nav-config.test.js b/test/header-nav-config.test.js new file mode 100644 index 00000000..2b70e012 --- /dev/null +++ b/test/header-nav-config.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { + NAV_MENU_LIST, + getActiveNavPath, + getMenuState, + resolveMenuClickKey, + MORE_MENU_PATH, +} = require('../app/web/layouts/header/nav-config'); + +test('主机管理等四个低频入口被收纳到更多菜单', () => { + const moreMenu = NAV_MENU_LIST.find((item) => item.path === MORE_MENU_PATH); + + assert.ok(moreMenu); + assert.equal(moreMenu.children.length, 3); + assert.deepEqual( + moreMenu.children.map((item) => item.path), + ['/page/host-management', '/page/config-center', '/page/tags'] + ); +}); + +test('命中更多菜单子路由时高亮更多', () => { + assert.equal(getActiveNavPath('/page/host-management/detail', NAV_MENU_LIST), MORE_MENU_PATH); + assert.equal(getActiveNavPath('/page/config-detail/12', NAV_MENU_LIST), MORE_MENU_PATH); +}); + +test('环境管理提升为一级导航后直接高亮自身', () => { + assert.equal(getActiveNavPath('/page/env-management', NAV_MENU_LIST), '/page/env-management'); + assert.deepEqual(getMenuState('/page/env-management', NAV_MENU_LIST), { + selectedKeys: ['/page/env-management'], + openKeys: [], + }); +}); + +test('命中更多菜单子路由时,selectedKeys 选中子项,openKeys 展开更多', () => { + assert.deepEqual(getMenuState('/page/host-management/detail', NAV_MENU_LIST), { + selectedKeys: ['/page/host-management'], + openKeys: [MORE_MENU_PATH], + }); + assert.deepEqual(getMenuState('/page/config-detail/12', NAV_MENU_LIST), { + selectedKeys: ['/page/config-center'], + openKeys: [MORE_MENU_PATH], + }); +}); + +test('子菜单点击时使用顶层更多菜单作为选中项', () => { + assert.equal( + resolveMenuClickKey({ key: '/page/tags', keyPath: ['/page/tags', MORE_MENU_PATH] }), + MORE_MENU_PATH + ); + assert.equal( + resolveMenuClickKey({ key: '/page/agents', keyPath: ['/page/agents'] }), + '/page/agents' + ); +}); + +test('导航配置不使用可选链语法,兼容当前前端构建链', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/layouts/header/nav-config.js'), + 'utf8' + ); + + assert.equal(content.includes('?.'), false, 'nav-config.js 不能使用 optional chaining 语法'); +}); + +test('顶栏样式覆盖更多标题的 hover 和展开颜色', () => { + const content = fs.readFileSync( + path.join(__dirname, '../app/web/layouts/header/style.scss'), + 'utf8' + ); + + assert.equal( + content.includes('.ant-menu-submenu-title:hover'), + true, + 'header style 需要显式覆盖更多标题 hover 样式' + ); + assert.equal( + content.includes('&.ant-menu-submenu-open > .ant-menu-submenu-title'), + true, + 'header style 需要显式覆盖更多标题展开样式' + ); + assert.equal( + content.includes('color: #3F87FF'), + true, + 'header style 需要把更多标题 hover 和展开文字设为蓝色' + ); +});