mirror of
https://github.com/langgenius/dify.git
synced 2026-03-22 14:57:58 +08:00
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:
@ -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)
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1074,12 +1074,14 @@
|
||||
"singleRun.testRunLoop": "Test Run Loop",
|
||||
"skill.startTab.createBlankSkill": "Create Blank Skill",
|
||||
"skill.startTab.createBlankSkillDesc": "Start with an empty folder structure",
|
||||
"skill.startTab.filesIncluded": "{{count}} files included",
|
||||
"skill.startTab.importSkill": "Import Skill",
|
||||
"skill.startTab.importSkillDesc": "Import skill from skill.zip file",
|
||||
"skill.startTab.searchPlaceholder": "Search…",
|
||||
"skill.startTab.templatesComingSoon": "Templates coming soon…",
|
||||
"skill.startTab.templatesDesc": "Choose a template to bootstrap your agent's capabilities",
|
||||
"skill.startTab.templatesTitle": "Skill Templates",
|
||||
"skill.startTab.useThisSkill": "Use this Skill",
|
||||
"skillEditor.authorizationBadge": "Auth",
|
||||
"skillEditor.authorizationRequired": "Authorization required before use.",
|
||||
"skillEditor.previewUnavailable": "Preview unavailable",
|
||||
|
||||
@ -1066,12 +1066,14 @@
|
||||
"singleRun.testRunLoop": "测试运行循环",
|
||||
"skill.startTab.createBlankSkill": "创建空白 Skill",
|
||||
"skill.startTab.createBlankSkillDesc": "从空文件夹结构开始",
|
||||
"skill.startTab.filesIncluded": "包含 {{count}} 个文件",
|
||||
"skill.startTab.importSkill": "导入 Skill",
|
||||
"skill.startTab.importSkillDesc": "从 skill.zip 文件导入",
|
||||
"skill.startTab.searchPlaceholder": "搜索…",
|
||||
"skill.startTab.templatesComingSoon": "模板即将推出…",
|
||||
"skill.startTab.templatesDesc": "选择模板来快速构建你的 Agent 能力",
|
||||
"skill.startTab.templatesTitle": "Skill 模板",
|
||||
"skill.startTab.useThisSkill": "使用此 Skill",
|
||||
"skillEditor.authorizationBadge": "Auth",
|
||||
"skillEditor.authorizationRequired": "使用前需要授权。",
|
||||
"skillEditor.previewUnavailable": "无法预览",
|
||||
|
||||
Reference in New Issue
Block a user