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>