feat(skill): add skill template types, card component and upload utility

Introduce type definitions separating raw skill data (SkillTemplate)
from UI metadata (SkillTemplateWithMetadata) to match the actual
skill format from upstream repos. Add template card component with
hover state and file count display, template-to-upload conversion
utility, and i18n keys for en-US/zh-Hans.
This commit is contained in:
yyh
2026-01-30 14:01:32 +08:00
parent f5b84384cf
commit 66b4fa102b
5 changed files with 162 additions and 0 deletions

View File

@ -0,0 +1,76 @@
'use client'
import type { SkillTemplateNode, SkillTemplateWithMetadata } from './templates/types'
import { RiAddLine } from '@remixicon/react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
function countFiles(nodes: SkillTemplateNode[]): number {
return nodes.reduce((count, node) => {
if (node.node_type === 'file')
return count + 1
return count + countFiles(node.children)
}, 0)
}
type TemplateCardProps = {
template: SkillTemplateWithMetadata
onUse: (template: SkillTemplateWithMetadata) => void
}
const TemplateCard = ({ template, onUse }: TemplateCardProps) => {
const { t } = useTranslation('workflow')
const fileCount = useMemo(() => countFiles(template.children), [template.children])
return (
<div className="group flex h-full flex-col overflow-hidden rounded-xl border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg transition-colors hover:bg-components-panel-on-panel-item-bg-hover">
<div className="flex items-center gap-3 px-4 pb-2 pt-4">
<AppIcon
size="large"
icon={template.icon || '📁'}
background="#f5f3ff"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<span className="system-md-semibold truncate text-text-secondary">
{template.name}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('skill.startTab.filesIncluded', { count: fileCount })}
</span>
</div>
</div>
<div className="flex flex-1 flex-col px-4 py-1">
<p className="system-xs-regular line-clamp-2 min-h-[32px] w-full text-text-tertiary">
{template.description}
</p>
</div>
<div className="relative px-4 pb-4">
{template.tags?.length
? (
<div className="flex flex-wrap gap-1 transition-opacity group-hover:opacity-0">
{template.tags.map(tag => (
<Badge key={tag} text={tag} />
))}
</div>
)
: <div className="h-5" />}
<div className="pointer-events-none absolute inset-0 flex items-end px-4 pb-4 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
<Button
variant="primary"
size="medium"
className="w-full"
onClick={() => onUse(template)}
>
<RiAddLine className="mr-0.5 h-4 w-4" />
{t('skill.startTab.useThisSkill')}
</Button>
</div>
</div>
</div>
)
}
export default memo(TemplateCard)

View File

@ -0,0 +1,43 @@
import type { SkillTemplate, SkillTemplateNode } from './types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { prepareSkillUploadFile } from '../../utils/skill-upload-utils'
type TemplateUploadData = {
tree: BatchUploadNodeInput[]
files: Map<string, File>
}
export async function buildUploadDataFromTemplate(
template: SkillTemplate,
): Promise<TemplateUploadData> {
const files = new Map<string, File>()
async function convertNode(
node: SkillTemplateNode,
pathPrefix: string,
): Promise<BatchUploadNodeInput> {
const currentPath = pathPrefix ? `${pathPrefix}/${node.name}` : node.name
if (node.node_type === 'folder') {
const children = await Promise.all(
node.children.map(child => convertNode(child, currentPath)),
)
return { name: node.name, node_type: 'folder', children }
}
const raw = new File([node.content], node.name, { type: 'text/plain' })
const prepared = await prepareSkillUploadFile(raw)
files.set(currentPath, prepared)
return { name: node.name, node_type: 'file', size: prepared.size }
}
const rootFolder: BatchUploadNodeInput = {
name: template.name,
node_type: 'folder',
children: await Promise.all(
template.children.map(child => convertNode(child, template.name)),
),
}
return { tree: [rootFolder], files }
}

View File

@ -0,0 +1,39 @@
import type { AssetNodeType } from '@/types/app-asset'
export type SkillTemplateFileNode = {
name: string
node_type: Extract<AssetNodeType, 'file'>
content: string
}
export type SkillTemplateFolderNode = {
name: string
node_type: Extract<AssetNodeType, 'folder'>
children: SkillTemplateNode[]
}
export type SkillTemplateNode = SkillTemplateFileNode | SkillTemplateFolderNode
export type SkillTemplateFrontmatter = {
name: string
description: string
}
export type SkillTemplate = {
id: string
name: string
description: string
children: SkillTemplateNode[]
}
export type SkillTemplateMetadata = {
tags?: string[]
icon?: string
}
export type SkillTemplateWithMetadata = SkillTemplate & SkillTemplateMetadata
export type SkillTemplateTag = {
id: string
label: string
}