diff --git a/app/model/skills_item.js b/app/model/skills_item.js index 7bbc531e..b9b3827a 100644 --- a/app/model/skills_item.js +++ b/app/model/skills_item.js @@ -92,6 +92,11 @@ module.exports = (app) => { allowNull: true, comment: '所属技能包的 slug', }, + contributor: { + type: STRING(50), + allowNull: true, + comment: '贡献者', + }, created_at: { type: DATE, allowNull: false, diff --git a/app/service/skills.js b/app/service/skills.js index 09eb1a3f..814bad6d 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -21,6 +21,7 @@ const GIT_COMMAND_TIMEOUT_MS = 120 * 1000; const GITHUB_API_TIMEOUT_MS = 10 * 1000; const MAX_PLATFORM_TAGS = 5; const MAX_TAG_LENGTH = 20; +const MAX_CONTRIBUTOR_LENGTH = 50; const DISCOVER_DEPTH_LIMIT = 2; const SKILLS_ROOT_DISCOVER_DEPTH_LIMIT = 8; const DISCOVER_MAX_DIR_COUNT = 3000; @@ -125,6 +126,7 @@ class SkillsService extends Service { await SkillsFile.sync(); await this.ensureSkillsItemVersionColumn(); await this.ensureSkillsItemPackageColumns(); + await this.ensureSkillsItemContributorColumn(); this.storageReady = true; })(); @@ -168,6 +170,18 @@ class SkillsService extends Service { } } + async ensureSkillsItemContributorColumn() { + const queryInterface = this.app.model.getQueryInterface(); + const table = await queryInterface.describeTable('skills_items'); + if (table.contributor) return; + + await queryInterface.addColumn('skills_items', 'contributor', { + type: this.app.Sequelize.STRING(50), + allowNull: true, + comment: '贡献者', + }); + } + parseJsonArray(value) { if (!value) return []; if (Array.isArray(value)) return value; @@ -198,6 +212,7 @@ class SkillsService extends Service { installCommand: skill.installCommand, isPackage: skill.isPackage ? 1 : 0, parentSlug: skill.parentSlug || null, + contributor: skill.contributor || '', }; } @@ -227,6 +242,7 @@ class SkillsService extends Service { fileCount: Number(row.file_count) || 0, isPackage: Number(row.is_package) || 0, parentSlug: row.parent_slug || null, + contributor: this.parseContributor(row.contributor), }; } @@ -296,7 +312,8 @@ class SkillsService extends Service { item.name.toLowerCase().includes(value) || item.description.toLowerCase().includes(value) || item.sourceRepo.toLowerCase().includes(value) || - item.tags.some((tag) => tag.toLowerCase().includes(value)) + item.tags.some((tag) => tag.toLowerCase().includes(value)) || + item.contributor.toLowerCase().includes(value) ); } @@ -729,6 +746,38 @@ class SkillsService extends Service { .filter(Boolean); } + parseContributor(value) { + if (Array.isArray(value)) { + return String(value[0] || '').trim(); + } + + return String(value || '').trim(); + } + + validateContributor(value) { + const contributor = this.parseContributor(value); + if (contributor.length > MAX_CONTRIBUTOR_LENGTH) { + this.ctx.throw(400, `贡献者不能超过 ${MAX_CONTRIBUTOR_LENGTH} 个字符`); + } + return contributor; + } + + applyContributorToSkillRecords(records, contributor, shouldOverride = true) { + if (!shouldOverride) return records; + + return records.map((record) => ({ + ...record, + contributor, + })); + } + + validateSkillRecordContributors(records) { + return records.map((record) => ({ + ...record, + contributor: this.validateContributor(record.contributor), + })); + } + normalizePlatformTags(rawTags) { const values = this.parseArrayLike(rawTags) .map((item) => String(item || '').trim()) @@ -1406,6 +1455,12 @@ class SkillsService extends Service { const allowedTools = this.parseArrayLike( frontmatter['allowed-tools'] || frontmatter.allowedTools || frontmatter.allowed_tools ); + const contributor = this.parseContributor( + frontmatter.contributor || + frontmatter.contributors || + frontmatter.author || + frontmatter.authors + ); const sourcePath = path.relative(repoDir, skillDir).split(path.sep).join('/'); const installCommand = this.getInstallCommand({ @@ -1423,6 +1478,7 @@ class SkillsService extends Service { version, tags, allowedTools, + contributor, updatedAt: stat.mtime, sourceRepo: sourceMeta.sourceRepo, sourcePath, @@ -1633,6 +1689,8 @@ class SkillsService extends Service { const packageName = String(params.packageName || '').trim(); const category = this.normalizeCategory(params.category); const tags = this.normalizePlatformTags(params.tags); + const hasContributor = Object.prototype.hasOwnProperty.call(params, 'contributor'); + const contributor = hasContributor ? this.validateContributor(params.contributor) : ''; const fileName = String((file && file.filename) || '').trim(); const filePath = String((file && file.filepath) || '').trim(); @@ -1675,7 +1733,7 @@ class SkillsService extends Service { const identityKey = packageName || skillName; const parsedSource = this.buildUploadSourceMeta(fileName, identityKey); - const skillRecords = discoveredSkillDirs.map((skillDir) => { + let skillRecords = discoveredSkillDirs.map((skillDir) => { const record = this.prepareSkillRecord( skillDir, tempDir, @@ -1688,6 +1746,11 @@ class SkillsService extends Service { } return record; }); + skillRecords = this.applyContributorToSkillRecords( + skillRecords, + contributor, + hasContributor + ); const tempUsedSlugs = new Set(); const excludeSlugs = skillRecords.map((record) => @@ -1825,6 +1888,8 @@ class SkillsService extends Service { await this.ensureSkillCache(); const currentSkill = this.getSkillByIdentifier(slug); + const hasContributor = Object.prototype.hasOwnProperty.call(params, 'contributor'); + const contributor = hasContributor ? this.validateContributor(params.contributor) : ''; await this.ensureStorageReady(); if (!name) { @@ -1867,6 +1932,9 @@ class SkillsService extends Service { version, tags: JSON.stringify(tags || []), }; + if (hasContributor) { + payload.contributor = contributor || null; + } if (!hasZipUpload) { await itemRow.update(payload, { transaction }); @@ -2005,6 +2073,7 @@ class SkillsService extends Service { skillRecords = [], preferredPackageName = '' ) { + skillRecords = this.validateSkillRecordContributors(skillRecords); const { SkillsItem, SkillsFile } = this.app.model; const { Op } = this.app.Sequelize; const repoStars = await this.fetchStarsBySourceRepo(sourceMeta.sourceRepo); @@ -2062,6 +2131,7 @@ class SkillsService extends Service { allowed_tools: JSON.stringify( Array.from(new Set(skillRecords.flatMap((r) => r.allowedTools || []))) ), + contributor: skillRecords[0].contributor || null, stars: resolvedStars, updated_at_remote: new Date(), source_repo: sourceMeta.sourceRepo || '', @@ -2112,6 +2182,7 @@ class SkillsService extends Service { version: record.version || '', tags: JSON.stringify(record.tags || []), allowed_tools: JSON.stringify(record.allowedTools || []), + contributor: record.contributor || null, stars: resolvedStars, updated_at_remote: record.updatedAt, source_repo: record.sourceRepo, diff --git a/app/web/components/skills/SkillCard.tsx b/app/web/components/skills/SkillCard.tsx index ad629fd8..9614c2fb 100644 --- a/app/web/components/skills/SkillCard.tsx +++ b/app/web/components/skills/SkillCard.tsx @@ -116,12 +116,9 @@ export const SkillCard: React.FC = ({ {showMeta && (
- 来源 - - {skill.sourceRepo || skill.sourcePath || '-'} + 贡献者 + + {skill.contributor || '-'} · diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx index c5d45a4b..7e52f1ad 100644 --- a/app/web/pages/skills/detail/SkillDetailContent.tsx +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -313,6 +313,7 @@ const SkillDetailContent: React.FC = ({ downloadCommand={downloadCommand} agentTerminalCommand={agentTerminalCommand} manualDownloadUrl={manualDownloadUrl} + contributor={detail?.contributor || ''} /> ) : null}
diff --git a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx index 391b3119..6deb4ae3 100644 --- a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx +++ b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx @@ -186,7 +186,6 @@ const SkillSummaryModalContent: React.FC = ({ slu } const detailTags = (detail.tags || []).filter((tag) => tag && tag !== detail.category); - const detailSource = detail.sourceRepo || detail.sourcePath || '-'; const detailUpdatedAt = detail.updatedAt ? new Date(detail.updatedAt).toLocaleString('zh-CN') : '-'; @@ -207,8 +206,8 @@ const SkillSummaryModalContent: React.FC = ({ slu value: detailUpdatedAt, }, { - label: '来源', - value: detailSource, + label: '贡献者', + value: detail.contributor || '-', className: 'is-wide', }, ]; diff --git a/app/web/pages/skills/detail/components/SkillInstallPanel.tsx b/app/web/pages/skills/detail/components/SkillInstallPanel.tsx index 55cffe5f..5c2b1112 100644 --- a/app/web/pages/skills/detail/components/SkillInstallPanel.tsx +++ b/app/web/pages/skills/detail/components/SkillInstallPanel.tsx @@ -4,8 +4,6 @@ import { Button } from 'antd'; import agentIcon from '@/asset/images/skills-detail-figma/agent.svg'; import chevronDownIcon from '@/asset/images/skills-detail-figma/chevron-down.svg'; import chevronRightIcon from '@/asset/images/skills-detail-figma/chevron-right.svg'; -import contributorOne from '@/asset/images/skills-detail-figma/contributor-1.png'; -import contributorTwo from '@/asset/images/skills-detail-figma/contributor-2.png'; import copyDarkIcon from '@/asset/images/skills-detail-figma/copy-dark.svg'; import downloadIcon from '@/asset/images/skills-detail-figma/download.svg'; import emptyRelatedIcon from '@/asset/images/skills-detail-figma/empty-related.svg'; @@ -39,6 +37,7 @@ interface SkillInstallPanelProps { downloadCommand: string; agentTerminalCommand: string; manualDownloadUrl: string; + contributor?: string; } const renderInlineCommand = (command: string, copyMessage: string, compact = false) => ( @@ -94,6 +93,7 @@ export const SkillInstallPanel: React.FC = ({ downloadCommand, agentTerminalCommand, manualDownloadUrl, + contributor = '', }) => ( diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss index 869b7548..54ef4904 100644 --- a/app/web/pages/skills/detail/style.scss +++ b/app/web/pages/skills/detail/style.scss @@ -1059,29 +1059,12 @@ font-weight: 600; } } - .contributors-stack { - display: inline-flex; - align-items: center; - img, - span { - width: 16px; - height: 16px; - border-radius: 999px; - border: 1px solid #FFF; - margin-left: -4px; - } - img:first-child, - span:first-child { - margin-left: 0; - } - span { - background: #E2E8F0; - color: #566166; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 8px; - } + .contributor-name { + max-width: 120px; + overflow: hidden; + color: #475569; + text-overflow: ellipsis; + white-space: nowrap; } @media (max-width: 1200px) { diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx index 30dd00d7..200ec486 100644 --- a/app/web/pages/skills/index.tsx +++ b/app/web/pages/skills/index.tsx @@ -170,6 +170,7 @@ const SkillsMarket: React.FC = ({ history }) => { category: skill.category || '通用', tags: skill.tags || [], version: skill.version || '', + contributor: skill.contributor || '', }); }; @@ -191,11 +192,13 @@ const SkillsMarket: React.FC = ({ history }) => { return; } const values = importForm.getFieldsValue(); + const contributor = String(values.contributor || '').trim(); const response = await API.importSkillFile({ file: targetFile, skillName: values.skillName || '', category: values.category, tags: JSON.stringify(values.tags || []), + ...(contributor ? { contributor } : {}), }); if (!response.success) { @@ -236,6 +239,7 @@ const SkillsMarket: React.FC = ({ history }) => { name: values.name, category: values.category, tags: JSON.stringify(values.tags || []), + contributor: values.contributor || '', version: values.version || '', file: targetFile, }); @@ -293,7 +297,7 @@ const SkillsMarket: React.FC = ({ history }) => { allowClear value={query.keyword} className="keyword-search" - placeholder="搜索名称、描述、标签或来源..." + placeholder="搜索名称、描述、标签或贡献者..." enterButton={} onChange={(e) => setQuery({ ...query, keyword: e.target.value })} onSearch={(value) => updateQueryAndFetch({ keyword: value, pageNum: 1 })} @@ -458,6 +462,13 @@ const SkillsMarket: React.FC = ({ history }) => { maxTagCount={5} /> + + + 提示:.zip 包内部应包含 `技能目录/SKILL.md` @@ -542,6 +553,13 @@ const SkillsMarket: React.FC = ({ history }) => { + + + {}; + service.ensureStorageReady = async () => {}; + service.getSkillByIdentifier = () => ({ + id: 1, + slug: 'demo-skill', + contributor: '缓存中的旧贡献者', + tags: [], + }); + service.assertSkillNamesUnique = async () => {}; + service.invalidateCache = () => {}; + service.app = { + model: { + SkillsItem: { + findOne: async () => itemRow, + }, + SkillsFile: {}, + transaction: async (callback) => callback({}), + }, + }; + + return { + service, + getUpdatedPayload: () => updatedPayload, + }; +} + +async function importZipWithContributor(params) { + const service = createService(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-contributor-import-')); + const zipPath = path.join(tempDir, 'demo.zip'); + const zip = new AdmZip(); + zip.addFile( + 'demo/SKILL.md', + Buffer.from('---\nname: demo\ncontributor: Frontmatter 贡献者\n---\n\nDemo') + ); + zip.writeZip(zipPath); + let persistedRecords; + + service.ensureStorageReady = async () => {}; + service.assertSkillNamesUnique = async () => {}; + service.upsertSourceRecord = async () => ({ + id: 1, + update: async () => {}, + }); + service.persistSkillsForSource = async (_sourceId, _sourceMeta, records) => { + persistedRecords = records; + return records.map((record) => ({ ...record, slug: 'demo' })); + }; + service.invalidateCache = () => {}; + service.ensureSkillCache = async () => {}; + + try { + await service.importSkillFile( + { category: '通用', ...params }, + { filename: 'demo.zip', filepath: zipPath } + ); + return persistedRecords; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('parseContributor returns one trimmed contributor', () => { + const service = createService(); + + assert.equal(service.parseContributor(' 张三 '), '张三'); + assert.equal(service.parseContributor(['张三', '李四']), '张三'); + assert.equal(service.parseContributor(''), ''); +}); + +test('validateContributor accepts 50 characters and rejects 51 characters', () => { + const service = createService(); + + assert.equal(service.validateContributor('a'.repeat(50)), 'a'.repeat(50)); + assert.throws(() => service.validateContributor('a'.repeat(51)), /贡献者不能超过 50 个字符/); +}); + +test('applyContributorToSkillRecords applies one contributor to every package record', () => { + const service = createService(); + const records = [{ name: 'skill-a' }, { name: 'skill-b', contributor: '旧名称' }]; + + assert.deepEqual(service.applyContributorToSkillRecords(records, '张三'), [ + { name: 'skill-a', contributor: '张三' }, + { name: 'skill-b', contributor: '张三' }, + ]); +}); + +test('applyContributorToSkillRecords keeps frontmatter value when request omits contributor', () => { + const service = createService(); + const records = [{ name: 'skill-a', contributor: 'Frontmatter 作者' }]; + + assert.deepEqual(service.applyContributorToSkillRecords(records, '', false), records); +}); + +test('updateSkill does not overwrite contributor when request omits the field', async () => { + const { service, getUpdatedPayload } = createUpdateService(); + + await service.updateSkill({ + slug: 'demo-skill', + name: 'Demo Skill', + category: '通用', + version: '1.0.0', + tags: '[]', + }); + + assert.equal(Object.prototype.hasOwnProperty.call(getUpdatedPayload(), 'contributor'), false); +}); + +test('updateSkill writes null when request explicitly clears contributor', async () => { + const { service, getUpdatedPayload } = createUpdateService(); + + await service.updateSkill({ + slug: 'demo-skill', + name: 'Demo Skill', + category: '通用', + version: '1.0.0', + tags: '[]', + contributor: '', + }); + + assert.equal(getUpdatedPayload().contributor, null); +}); + +test('importSkillFile keeps frontmatter contributor when request omits the field', async () => { + const records = await importZipWithContributor({}); + + assert.equal(records[0].contributor, 'Frontmatter 贡献者'); +}); + +test('importSkillFile overrides frontmatter contributor when request provides the field', async () => { + const records = await importZipWithContributor({ contributor: '弹框贡献者' }); + + assert.equal(records[0].contributor, '弹框贡献者'); +}); + +test('ensureSkillsItemContributorColumn adds nullable VARCHAR(50) when missing', async () => { + const service = createService(); + const addedColumns = []; + service.app = { + model: { + getQueryInterface: () => ({ + describeTable: async () => ({ id: { type: 'INTEGER' } }), + addColumn: async (table, column, definition) => { + addedColumns.push({ table, column, definition }); + }, + }), + }, + Sequelize: { + STRING: (length) => `VARCHAR(${length})`, + }, + }; + + await service.ensureSkillsItemContributorColumn(); + + assert.deepEqual(addedColumns, [ + { + table: 'skills_items', + column: 'contributor', + definition: { + type: 'VARCHAR(50)', + allowNull: true, + comment: '贡献者', + }, + }, + ]); +}); + +test('ensureSkillsItemContributorColumn leaves an existing column unchanged', async () => { + const service = createService(); + let addColumnCalled = false; + service.app = { + model: { + getQueryInterface: () => ({ + describeTable: async () => ({ contributor: { type: 'VARCHAR(50)' } }), + addColumn: async () => { + addColumnCalled = true; + }, + }), + }, + Sequelize: { + STRING: (length) => `VARCHAR(${length})`, + }, + }; + + await service.ensureSkillsItemContributorColumn(); + + assert.equal(addColumnCalled, false); +}); diff --git a/test/skills-install-key.test.js b/test/skills-install-key.test.js index 600b1a24..83ab64aa 100644 --- a/test/skills-install-key.test.js +++ b/test/skills-install-key.test.js @@ -791,6 +791,7 @@ test('persistSkillsForSource - multi-skill source creates parent package with ch version: '1.0.0', tags: ['a'], allowedTools: [], + contributor: '张三', updatedAt: new Date(), sourceRepo: '', sourcePath: 'skills/alpha', @@ -805,6 +806,7 @@ test('persistSkillsForSource - multi-skill source creates parent package with ch version: '1.0.0', tags: ['b'], allowedTools: [], + contributor: '张三', updatedAt: new Date(), sourceRepo: '', sourcePath: 'skills/beta', @@ -819,6 +821,7 @@ test('persistSkillsForSource - multi-skill source creates parent package with ch version: '1.0.0', tags: ['g'], allowedTools: [], + contributor: '张三', updatedAt: new Date(), sourceRepo: '', sourcePath: 'skills/gamma', @@ -841,6 +844,7 @@ test('persistSkillsForSource - multi-skill source creates parent package with ch assert.equal(parent.parent_slug, null, 'Parent should have null parent_slug'); assert.equal(parent.name, 'mega-pack'); assert.equal(parent.source_path, '.'); + assert.equal(parent.contributor, '张三'); assert.ok( parent.description.includes('alpha-skill'), 'Parent description should list children' @@ -853,6 +857,7 @@ test('persistSkillsForSource - multi-skill source creates parent package with ch `Child ${child.slug} should reference parent slug` ); assert.equal(child.is_package, 0, `Child ${child.slug} should NOT be a package`); + assert.equal(child.contributor, '张三'); } });