feat: add skill presentation fields

Signed-off-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
This commit is contained in:
千乘妍 (Xiaoyaner) 2026-10-30 22:45:21 +08:00
parent 326cbd6224
commit 2b87c6c5ea
22 changed files with 215 additions and 23 deletions

View file

@ -44,8 +44,9 @@ description: When to use # 必需,1-2 句话
- 网页发布 multipart 可选字段 `displayName` 在 trim 后非空时优先于包内展示名,最长 200 字符;空白回退包内字段。该覆盖值保存在版本元数据快照,原始 frontmatter 和文件内容不改写;下载 ZIP 沿用现有重新打包机制,压缩包二进制可能不同。CLI 不填写此字段时继续使用包内规则。
- 网页上传后通过鉴权只读 `POST /api/web/skills/package-preview`(也提供 `/api/v1/skills/package-preview`)读取 `name/displayName/description/version`;此步骤不保存包、不创建技能或启动扫描,正式提交仍执行完整校验。
- 上述类型和长度限制在新上传解析时检查。读取历史版本快照时,非字符串或超长的旧 `display-name` 回退到该版本的 `name`,原始 frontmatter 保留,避免阻塞审核、提升或版本撤回。
- `description` 映射为 `skill.summary`
- frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`
- `metadata.short-description` 可选,作为卡片、列表和搜索摘要,去除首尾空白后最多 200 个字符;缺失时回退到原始 `description`。`metadata.overview` 可选,为详情页 Markdown 概览,最多 20,000 个字符;缺失时详情页按 README.md → SKILL.md 回退。
- 网页发布 multipart 可选字段 `shortDescription`、`overview` 和 `resetPresentation`;网页显式值优先于包内元数据,`resetPresentation=true` 清除自定义覆盖。值随版本快照保存,原始包文件不变。
- 有效 short description 映射为 `skill.summary`;原始 `description` 仍加入搜索文本。frontmatter 完整解析结果存入 `skill_version.parsed_metadata_json`。
平台扩展字段(可选,`x-astron-` 前缀):

View file

@ -0,0 +1,7 @@
# Design
- Parse `metadata.short-description` and `metadata.overview` from SKILL.md, with limits of 200 and 20,000 characters.
- Accept multipart `shortDescription`, `overview`, and `resetPresentation`; web values override package metadata and are stored in `parsedMetadataJson` as `shortDescriptionOverride`, `overviewOverride`, and `presentationInheritedFrom`.
- Keep the existing display-name/version snapshot model and publish/review/rollback lifecycle. Pending versions never update the Skill container summary.
- The effective short description feeds `skill.summary`; search also retains the original package description in `search_text`. Overview is detail-only and falls back to README then SKILL.md in the existing documentation reader.
- Web package preview supplies defaults without editing the uploaded archive. Generated API types remain authoritative.

View file

@ -0,0 +1,10 @@
# Add skill presentation fields
## Why
Skill packages currently expose only one description and cannot provide a concise card summary plus a richer detail overview without modifying package files.
## Scope
Add optional short description and Markdown overview to web and CLI-compatible publish metadata. Values are version snapshots, preserve package files, and use package metadata/default documentation when omitted. Public content changes only when the version publishes.
## Non-goals
No upload-free editing, database migration, i18n variants, or dedicated presentation file.

View file

@ -0,0 +1,23 @@
# Requirements
## Presentation metadata
The system MUST accept optional `metadata.short-description` and `metadata.overview` in SKILL.md. The short description MUST be at most 200 characters and the overview at most 20,000 characters.
## Web publish precedence
A web-provided value MUST override the package value for that submitted version. The archive MUST remain unchanged. Reset MUST remove the custom override so the package/default resolution applies.
## Lifecycle isolation
A pending version MUST NOT replace the currently published title, summary, or overview. Published metadata MUST be restored by rollback/yank using the selected published version snapshot.
## Search and detail
The effective short description MUST be used as the public summary. The original package description MUST remain searchable. The detailed overview MUST NOT be indexed and MUST fall back to README.md then SKILL.md when no explicit/package overview exists.
## Scenarios
- Given package metadata and web values, when previewing, the form is prefilled from package defaults and explicit edits win.
- Given a pending update, when it is submitted, public summary remains unchanged until publication.
- Given reset, when the version publishes, it uses package defaults and later default versions read their own package defaults.

View file

@ -0,0 +1,10 @@
# Tasks
- [x] Add SKILL.md metadata parsing and validation.
- [x] Add multipart publish fields and package-preview defaults.
- [x] Snapshot presentation fields in parsed metadata and update effective published summary.
- [x] Preserve original description in search text and keep pending updates isolated.
- [x] Add web short-description and Markdown-overview inputs.
- [ ] Add full inheritance/restore-default/rollback acceptance coverage.
- [ ] Regenerate OpenAPI types and update CLI usage documentation.
- [ ] Add detail-page overview projection and View SKILL.md entry.

View file

@ -83,6 +83,9 @@ public class SkillPublishController extends BaseApiController {
@RequestParam(value = "avatarUploadId", required = false) String avatarUploadId,
@RequestParam(value = "expectedSlug", required = false) String expectedSlug,
@RequestParam(value = "displayName", required = false) String displayName,
@RequestParam(value = "shortDescription", required = false) String shortDescription,
@RequestParam(value = "overview", required = false) String overview,
@RequestParam(value = "resetPresentation", defaultValue = "false") boolean resetPresentation,
@AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) throws IOException {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
@ -110,7 +113,8 @@ public class SkillPublishController extends BaseApiController {
principal.userId(),
skillVisibility,
principal.platformRoles(),
confirmWarnings, avatarAction, avatarUploadId, expectedSlug, displayName, AuditRequestContext.from(request)
confirmWarnings, avatarAction, avatarUploadId, expectedSlug, displayName,
shortDescription, overview, resetPresentation, AuditRequestContext.from(request)
);
PublishResponse response = new PublishResponse(

View file

@ -4,10 +4,11 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.iflytek.skillhub.domain.skill.validation.SkillExamplesSummary.Status;
/** Package defaults only: preview never creates a skill or changes its files. */
public record SkillPackagePreviewResponse(String name, String displayName, String description, String version,
public record SkillPackagePreviewResponse(String name, String displayName, String description, String shortDescription,
String overview, String version,
@JsonInclude(JsonInclude.Include.NON_NULL) Status examplesStatus,
@JsonInclude(JsonInclude.Include.NON_NULL) Integer examplesCount) {
public SkillPackagePreviewResponse(String name, String displayName, String description, String version) {
this(name, displayName, description, version, null, null);
this(name, displayName, description, description, null, version, null, null);
}
}

View file

@ -31,7 +31,8 @@ public class SkillPackagePreviewService {
.orElseThrow(() -> new DomainBadRequestException("error.skill.publish.skillMd.notFound"));
SkillMetadata metadata = parser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
SkillExamplesSummary examples = SkillExamplesSummary.recognize(entries);
return new SkillPackagePreviewResponse(metadata.name(), metadata.displayName(), metadata.description(), metadata.version(),
return new SkillPackagePreviewResponse(metadata.name(), metadata.displayName(), metadata.description(),
metadata.shortDescription(), metadata.overview(), metadata.version(),
examples.status(), examples.count());
}
}

View file

@ -42,13 +42,14 @@ public class SkillPublishAppService {
SkillVisibility visibility, Set<String> roles, boolean confirmWarnings, AvatarAction action,
String uploadId, String expectedSlug, AuditRequestContext context) {
return publish(namespace, entries, actor, visibility, roles, confirmWarnings, action,
uploadId, expectedSlug, null, context);
uploadId, expectedSlug, null, null, null, false, context);
}
@Transactional
public SkillPublishService.PublishResult publish(String namespace, List<PackageEntry> entries, String actor,
SkillVisibility visibility, Set<String> roles, boolean confirmWarnings, AvatarAction action,
String uploadId, String expectedSlug, String displayName, AuditRequestContext context) {
String uploadId, String expectedSlug, String displayName, String shortDescription,
String overview, boolean resetPresentation, AuditRequestContext context) {
if (action == AvatarAction.REPLACE && (uploadId == null || uploadId.isBlank())
|| action != AvatarAction.REPLACE && uploadId != null && !uploadId.isBlank()) {
throw new DomainBadRequestException("error.skill.avatar.invalidUpload");
@ -64,9 +65,12 @@ public class SkillPublishAppService {
uploads.validateReference(namespace, targetSlug, actor, uploadId);
}
}
SkillPublishService.PublishResult result = displayName == null || displayName.isBlank()
SkillPublishService.PublishResult result = (displayName == null || displayName.isBlank())
&& (shortDescription == null || shortDescription.isBlank())
&& (overview == null || overview.isBlank()) && !resetPresentation
? publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings)
: publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName);
: publications.publishFromEntries(namespace, entries, actor, visibility, roles, confirmWarnings, displayName,
shortDescription, overview, resetPresentation);
var review = reviews.findBySkillVersionIdAndStatus(
result.version().getId(), ReviewTaskStatus.PENDING).orElse(null);
if (action == AvatarAction.REPLACE) {

View file

@ -467,7 +467,7 @@ public class ReviewService {
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
skill.setSummary(metadata.shortDescription());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);
}

View file

@ -9,10 +9,35 @@ public record SkillMetadata(
String version,
String body,
Map<String, Object> frontmatter,
String displayNameOverride
String displayNameOverride,
String shortDescriptionOverride,
String overviewOverride,
String presentationInheritedFrom
) {
public SkillMetadata(String name, String description, String version, String body, Map<String, Object> frontmatter) {
this(name, description, version, body, frontmatter, null);
this(name, description, version, body, frontmatter, null, null, null, null);
}
public SkillMetadata(String name, String description, String version, String body, Map<String, Object> frontmatter,
String displayNameOverride) {
this(name, description, version, body, frontmatter, displayNameOverride, null, null, null);
}
public SkillMetadata withPresentation(String shortDescription, String overview, String inheritedFrom) {
String shortValue = normalize(shortDescription);
String overviewValue = normalize(overview);
if (shortValue != null && shortValue.length() > 200) {
throw new DomainBadRequestException("error.skill.shortDescription.tooLong", 200);
}
if (overviewValue != null && overviewValue.length() > 20000) {
throw new DomainBadRequestException("error.skill.overview.tooLong", 20000);
}
return new SkillMetadata(name, description, version, body, frontmatter, displayNameOverride,
shortValue, overviewValue, inheritedFrom);
}
private static String normalize(String value) {
return value == null || value.strip().isEmpty() ? null : value.strip();
}
/** Explicit portal input is validated before it becomes part of the version snapshot. */
@ -26,6 +51,24 @@ public record SkillMetadata(
override == null || override.isEmpty() ? null : override);
}
public String shortDescription() {
return shortDescriptionOverride != null ? shortDescriptionOverride
: nestedText(frontmatter, "short-description", description);
}
public String overview() {
String packageOverview = nestedText(frontmatter, "overview", null);
return overviewOverride != null ? overviewOverride : (packageOverview != null ? packageOverview : body);
}
private static String nestedText(Map<String, Object> values, String key, String fallback) {
if (values != null && values.get("metadata") instanceof Map<?, ?> metadata
&& metadata.get(key) instanceof String text && !text.strip().isEmpty()) {
return text.strip();
}
return fallback;
}
/** Human-readable label; unsupported legacy snapshot values fall back to the package name. */
public String displayName() {
if (displayNameOverride != null && !displayNameOverride.isBlank()

View file

@ -60,7 +60,15 @@ public class SkillMetadataParser {
}
validateDisplayName(frontmatter);
return new SkillMetadata(name, description, version, body, frontmatter);
String shortDescription = extractNestedOptionalField(frontmatter, "metadata", "short-description");
String overview = extractNestedOptionalField(frontmatter, "metadata", "overview");
if (shortDescription != null && shortDescription.length() > 200) {
throw new DomainBadRequestException("error.skill.metadata.shortDescription.tooLong", 200);
}
if (overview != null && overview.length() > 20000) {
throw new DomainBadRequestException("error.skill.metadata.overview.tooLong", 20000);
}
return new SkillMetadata(name, description, version, body, frontmatter, null, shortDescription, overview, null);
}
private void validateDisplayName(Map<String, Object> frontmatter) {

View file

@ -388,7 +388,7 @@ public class SkillGovernanceService {
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
skill.setSummary(metadata.shortDescription());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);
}

View file

@ -336,8 +336,17 @@ public class SkillPublishService {
String namespaceSlug, List<PackageEntry> entries, String publisherId,
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
String displayName) {
return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings,
displayName, null, null, false);
}
@Transactional
public PublishResult publishFromEntries(
String namespaceSlug, List<PackageEntry> entries, String publisherId,
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
String displayName, String shortDescription, String overview, boolean resetPresentation) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles,
confirmWarnings, false, false, null, null, displayName);
confirmWarnings, false, false, null, null, displayName, shortDescription, overview, resetPresentation);
}
/** Publishes a validated GitHub package into the dedicated sync review queue. */
@ -499,6 +508,17 @@ public class SkillPublishService {
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
boolean forceAutoPublish, boolean bypassMembershipCheck,
BundlePublicationTarget bundleTarget, GithubSyncPublicationTarget syncTarget, String displayName) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles,
confirmWarnings, forceAutoPublish, bypassMembershipCheck, bundleTarget, syncTarget, displayName,
null, null, false);
}
private PublishResult publishFromEntriesInternal(
String namespaceSlug, List<PackageEntry> entries, String publisherId,
SkillVisibility visibility, Set<String> platformRoles, boolean confirmWarnings,
boolean forceAutoPublish, boolean bypassMembershipCheck,
BundlePublicationTarget bundleTarget, GithubSyncPublicationTarget syncTarget, String displayName,
String shortDescription, String overview, boolean resetPresentation) {
// 1. Find namespace by slug
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
@ -538,6 +558,8 @@ public class SkillPublishService {
String skillMdContent = new String(skillMd.content());
SkillMetadata metadata = skillMetadataParser.parse(skillMdContent).withDisplayNameOverride(displayName);
metadata = metadata.withPresentation(resetPresentation ? null : shortDescription,
resetPresentation ? null : overview, null);
String upstreamVersion = syncTarget == null ? null : metadata.version();
if (syncTarget != null) {
// A reverted upstream package is a new review target after an intervening update.
@ -886,7 +908,7 @@ public class SkillPublishService {
// A pending update must not replace the currently published title or summary.
if (autoPublish || visibility == SkillVisibility.PRIVATE || skill.getLatestVersionId() == null) {
skill.setDisplayName(metadata.displayName());
skill.setSummary(metadata.description());
skill.setSummary(metadata.shortDescription());
}
if (syncTarget != null) {
skill.assignGithubSyncOwnership(publisherId);
@ -1150,6 +1172,9 @@ public class SkillPublishService {
parsedMetadata.put("body", metadata.body());
parsedMetadata.put("frontmatter", metadata.frontmatter());
parsedMetadata.put("displayNameOverride", metadata.displayNameOverride());
parsedMetadata.put("shortDescriptionOverride", metadata.shortDescriptionOverride());
parsedMetadata.put("overviewOverride", metadata.overviewOverride());
parsedMetadata.put("presentationInheritedFrom", metadata.presentationInheritedFrom());
if (syncTarget != null) {
parsedMetadata.put("upstreamVersion", upstreamVersion);
parsedMetadata.put("githubContentSha256", syncTarget.contentSha256());

View file

@ -11,6 +11,22 @@ class SkillMetadataParserTest {
private final SkillMetadataParser parser = new SkillMetadataParser();
@Test
void presentationOverridesAndPackageDefaultsRemainDistinct() {
SkillMetadata source = parser.parse("---\nname: my-skill\ndescription: Original\nmetadata:\n short-description: Package short\n overview: Package overview\n---\nBody");
assertEquals("Package short", source.shortDescription());
assertEquals("Package overview", source.overview());
SkillMetadata custom = source.withPresentation(" Web short ", " # Web overview ", null);
assertEquals("Web short", custom.shortDescription());
assertEquals("# Web overview", custom.overview());
assertEquals(source.frontmatter(), custom.frontmatter());
assertThrows(DomainBadRequestException.class,
() -> source.withPresentation("x".repeat(201), null, null));
assertThrows(DomainBadRequestException.class,
() -> source.withPresentation(null, "x".repeat(20001), null));
}
@Test
void platformOverrideIsVersionedWithoutChangingFrontmatter() throws Exception {
SkillMetadata source = parser.parse("---\nname: my-skill\ndescription: Test\nmetadata:\n display-name: 包内标题\n---\nBody");

View file

@ -129,6 +129,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
resolveLatestVersion(skill)
.map(this::extractParsedMetadata)
.ifPresent(metadata -> {
addPart(searchParts, String.valueOf(metadata.get("description")));
appendFrontmatter(asMap(metadata.get("frontmatter")), keywords, searchParts);
appendComplianceSnapshot(
asMap(metadata.get(ComplianceMetadataService.SNAPSHOT_FIELD_NAME)),

View file

@ -2235,6 +2235,8 @@
"displayName": "Display name",
"displayNamePlaceholder": "For example: Meeting notes assistant",
"displayNameHelp": "Up to 200 characters. Defaults to the package title. Changes follow this version’s review and do not alter the identifier or package files. Leave blank to use the package title.",
"shortDescription": "Short description",
"overview": "Detailed overview (Markdown)",
"readingPackage": "Reading package metadata…",
"examplesReady": "Recognized {{count}} task examples across all languages.",
"examplesNone": "No task examples provided. Publishing and use remain available.",

View file

@ -2234,6 +2234,8 @@
"displayName": "显示名称",
"displayNamePlaceholder": "例如:会议纪要助手",
"displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。",
"shortDescription": "简短描述",
"overview": "详细概览(Markdown)",
"readingPackage": "正在读取技能包信息…",
"examplesReady": "已识别 {{count}} 条使用案例(包含全部语种)。",
"examplesNone": "未提供使用案例,可正常发布和使用。",

View file

@ -22,6 +22,7 @@ import {
normalizeSelectValue,
} from '@/shared/ui/select'
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor'
@ -52,7 +53,11 @@ export function PublishPage() {
const previewId = useId()
const [fileRevision, setFileRevision] = useState(0)
const [displayName, setDisplayName] = useState('')
const [shortDescription, setShortDescription] = useState('')
const [overview, setOverview] = useState('')
const displayNameEdited = useRef(false)
const shortDescriptionEdited = useRef(false)
const overviewEdited = useRef(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [namespaceSlug, setNamespaceSlug] = useState<string>(prefill.namespace)
const [visibility, setVisibility] = useState<string>(prefill.visibility)
@ -75,6 +80,12 @@ export function PublishPage() {
if (packagePreview.data && !displayNameEdited.current) {
setDisplayName(packagePreview.data.displayName ?? packagePreview.data.name ?? '')
}
if (packagePreview.data && !shortDescriptionEdited.current) {
setShortDescription(packagePreview.data.shortDescription ?? packagePreview.data.description ?? '')
}
if (packagePreview.data && !overviewEdited.current) {
setOverview(packagePreview.data.overview ?? '')
}
}, [packagePreview.data])
const avatarTarget = useQuery({ queryKey: ['skill-avatar', namespaceSlug, updateSlug], queryFn: () => skillAvatarApi.status(namespaceSlug, updateSlug), enabled: !!namespaceSlug && !!updateSlug })
const avatarUnavailable = !!updateSlug && (avatarTarget.isLoading || avatarTarget.isError)
@ -148,6 +159,9 @@ export function PublishPage() {
avatarUploadId: avatarEdit.uploadId,
expectedSlug: updateSlug || undefined,
displayName,
shortDescription,
overview,
resetPresentation: false,
})
setPrecheckWarnings([])
setWarningDialogOpen(false)
@ -348,6 +362,17 @@ export function PublishPage() {
{selectedFile && packagePreview.data?.examplesStatus === 'invalid' && <p role="status" className="text-xs text-muted-foreground">{t('publish.examplesInvalid')}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="skill-short-description">{t('publish.shortDescription')}</Label>
<Input id="skill-short-description" maxLength={200} value={shortDescription} disabled={ownerReadOnly || isBusy || !selectedFile}
onChange={event => { shortDescriptionEdited.current = true; setShortDescription(event.target.value) }} />
</div>
<div className="space-y-2">
<Label htmlFor="skill-overview">{t('publish.overview')}</Label>
<Textarea id="skill-overview" maxLength={20000} rows={8} value={overview} disabled={ownerReadOnly || isBusy || !selectedFile}
onChange={event => { overviewEdited.current = true; setOverview(event.target.value) }} />
</div>
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={ownerReadOnly || isBusy || avatarUnavailable} onChange={setAvatarEdit} />
{updateSlug && <SkillAvatarManager namespace={namespaceSlug} slug={updateSlug} disabled={ownerReadOnly || isBusy} />}
{avatarTarget.isError && <p role="alert" className="text-sm text-destructive">{t('skillAvatar.failed')}</p>}

View file

@ -214,6 +214,7 @@ export function SkillDetailPage() {
const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady)
const documentationPath = resolveDocumentationFilePath(files)
const { data: readme, error: readmeError } = useSkillReadme(qns, qslug, selectedVersion, documentationPath, skillReady)
const renderedDocumentation = historicalMetadata.overview ?? readme
const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useSkillFile(
qns,
qslug,
@ -988,7 +989,7 @@ export function SkillDetailPage() {
</TabsList>
<TabsContent value="readme" className="mt-6">
{readme ? (
{renderedDocumentation ? (
<Card className="p-8 space-y-4">
{documentationPath ? (
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
@ -1004,7 +1005,7 @@ export function SkillDetailPage() {
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}
>
<div ref={overviewContentRef}>
<MarkdownRenderer content={readme} onLinkClick={handleOverviewLinkClick} />
<MarkdownRenderer content={renderedDocumentation} onLinkClick={handleOverviewLinkClick} />
</div>
{!isOverviewExpanded && isOverviewCollapsible ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-card via-card/95 to-transparent" />

View file

@ -45,7 +45,7 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s
return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`)
}
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string }): Promise<PublishResult> {
async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean; avatarAction?: 'KEEP' | 'REPLACE' | 'REMOVE'; avatarUploadId?: string; expectedSlug?: string; displayName?: string; shortDescription?: string; overview?: string; resetPresentation?: boolean }): Promise<PublishResult> {
const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace
const formData = new FormData()
formData.append('file', params.file)
@ -54,7 +54,10 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
formData.append('avatarAction', params.avatarAction ?? 'KEEP')
if (params.avatarUploadId) formData.append('avatarUploadId', params.avatarUploadId)
if (params.expectedSlug) formData.append('expectedSlug', params.expectedSlug)
if (params.displayName?.trim()) formData.append('displayName', params.displayName.trim())
if (params.displayName !== undefined) formData.append('displayName', params.displayName)
if (params.shortDescription !== undefined) formData.append('shortDescription', params.shortDescription)
if (params.overview !== undefined) formData.append('overview', params.overview)
if (params.resetPresentation) formData.append('resetPresentation', 'true')
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
method: 'POST',

View file

@ -1,5 +1,5 @@
/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } {
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string; overview?: string } {
if (!snapshot) return {}
try {
const value: unknown = JSON.parse(snapshot)
@ -8,7 +8,12 @@ export function getVersionPresentation(snapshot?: string): { displayName?: strin
const metadata = isObject(frontmatter.metadata) ? frontmatter.metadata : {}
const displayName = validName(value.displayNameOverride) ?? validName(metadata['display-name'])
?? (typeof value.name === 'string' ? value.name : undefined)
return { displayName, summary: typeof value.description === 'string' ? value.description : undefined }
const overview = typeof value.overviewOverride === 'string' && value.overviewOverride.trim().length > 0
? value.overviewOverride.trim()
: typeof metadata.overview === 'string' && metadata.overview.trim().length > 0 ? metadata.overview.trim() : undefined
const summary = typeof value.shortDescriptionOverride === 'string' && value.shortDescriptionOverride.trim().length > 0
? value.shortDescriptionOverride.trim() : typeof value.description === 'string' ? value.description : undefined
return overview ? { displayName, summary, overview } : { displayName, summary }
} catch {
return {}
}