feat(skill): guard template cards against duplicate skill addition

Add useExistingSkillNames hook that derives root folder names from the
cached asset tree via TanStack Query select, then use it to show an
"Added" state on hover for already-present skills and block re-upload.
This commit is contained in:
yyh
2026-01-30 15:43:37 +08:00
parent 60b4b10622
commit e9608532bd
5 changed files with 57 additions and 14 deletions

View File

@ -35,3 +35,23 @@ export function useSkillAssetNodeMap() {
},
})
}
/**
* Hook to get the set of root-level folder names in the skill asset tree.
* Useful for checking whether a skill template has already been added.
*/
export function useExistingSkillNames() {
const appId = useSkillAppId()
return useGetAppAssetTree(appId, {
select: (data: AppAssetTreeResponse): Set<string> => {
if (!data?.children)
return new Set()
const names = new Set<string>()
for (const node of data.children) {
if (node.node_type === 'folder')
names.add(node.name)
}
return names
},
})
}

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useBatchUpload } from '@/service/use-app-asset'
import { useExistingSkillNames } from '../hooks/use-skill-asset-tree'
import { useSkillTreeUpdateEmitter } from '../hooks/use-skill-tree-collaboration'
import CategoryTabs from './category-tabs'
import SectionHeader from './section-header'
@ -30,9 +31,13 @@ const SkillTemplatesSection = () => {
const emitTreeUpdateRef = useRef(emitTreeUpdate)
emitTreeUpdateRef.current = emitTreeUpdate
const { data: existingNames } = useExistingSkillNames()
const existingNamesRef = useRef(existingNames)
existingNamesRef.current = existingNames
const handleUse = useCallback(async (summary: SkillTemplateSummary) => {
const entry = SKILL_TEMPLATES.find(e => e.id === summary.id)
if (!entry || !appId)
if (!entry || !appId || existingNamesRef.current?.has(summary.name))
return
setLoadingId(summary.id)
@ -94,6 +99,7 @@ const SkillTemplatesSection = () => {
<TemplateCard
key={entry.id}
template={entry}
added={existingNames?.has(entry.name) ?? false}
disabled={loadingId !== null}
loading={loadingId === entry.id}
onUse={handleUse}

View File

@ -1,7 +1,7 @@
'use client'
import type { SkillTemplateSummary } from './templates/types'
import { RiAddLine } from '@remixicon/react'
import { RiAddLine, RiCheckLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
@ -10,12 +10,13 @@ import Button from '@/app/components/base/button'
type TemplateCardProps = {
template: SkillTemplateSummary
added?: boolean
disabled?: boolean
loading?: boolean
onUse: (template: SkillTemplateSummary) => void
}
const TemplateCard = ({ template, disabled, loading, onUse }: TemplateCardProps) => {
const TemplateCard = ({ template, added, disabled, loading, onUse }: TemplateCardProps) => {
const { t } = useTranslation('workflow')
return (
@ -51,17 +52,31 @@ const TemplateCard = ({ template, disabled, loading, onUse }: TemplateCardProps)
)
: <div className="h-[18px]" />}
<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"
disabled={disabled}
loading={loading}
onClick={() => onUse(template)}
>
<RiAddLine className="mr-0.5 h-4 w-4" />
{t('skill.startTab.useThisSkill')}
</Button>
{added
? (
<Button
variant="secondary"
size="medium"
className="w-full"
disabled
>
<RiCheckLine className="mr-0.5 h-4 w-4" />
{t('skill.startTab.skillAdded')}
</Button>
)
: (
<Button
variant="primary"
size="medium"
className="w-full"
disabled={disabled}
loading={loading}
onClick={() => onUse(template)}
>
<RiAddLine className="mr-0.5 h-4 w-4" />
{t('skill.startTab.useThisSkill')}
</Button>
)}
</div>
</div>
</div>

View File

@ -1078,6 +1078,7 @@
"skill.startTab.importSkill": "Import Skill",
"skill.startTab.importSkillDesc": "Import skill from skill.zip file",
"skill.startTab.searchPlaceholder": "Search…",
"skill.startTab.skillAdded": "Added",
"skill.startTab.templatesComingSoon": "Templates coming soon…",
"skill.startTab.templatesDesc": "Choose a template to bootstrap your agent's capabilities",
"skill.startTab.templatesTitle": "Skill Templates",

View File

@ -1070,6 +1070,7 @@
"skill.startTab.importSkill": "导入 Skill",
"skill.startTab.importSkillDesc": "从 skill.zip 文件导入",
"skill.startTab.searchPlaceholder": "搜索…",
"skill.startTab.skillAdded": "已添加",
"skill.startTab.templatesComingSoon": "模板即将推出…",
"skill.startTab.templatesDesc": "选择模板来快速构建你的 Agent 能力",
"skill.startTab.templatesTitle": "Skill 模板",