'use client' import type { SkillResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { QueryClient } from '@tanstack/react-query' import type { UIEvent } from 'react' import { AlertDialog, AlertDialogActions, AlertDialogCancelButton, AlertDialogConfirmButton, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' import { ScrollArea, ScrollAreaContent, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' import { toast } from '@langgenius/dify-ui/toast' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useQueryState } from 'nuqs' import { useEffect, useId, useMemo, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { SkeletonRectangle } from '@/app/components/base/skeleton' import { SkillCardTags } from '@/features/tag-management/components/skill-card-tags' import { TagFilter } from '@/features/tag-management/components/tag-filter' import useDocumentTitle from '@/hooks/use-document-title' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { downloadBlob } from '@/utils/download' import { fetchSkillArchiveBlob } from './client' import { SkillReferencesList, SkillReferencesListSkeleton } from './detail/skill-metadata' import { getSkillErrorCode, getSkillErrorDetailString, normalizeSkillError } from './error' import { useSkillPermissions } from './permissions' import { skillKeywordQueryParser, skillQueryParamNames, skillTagQueryParser } from './query-params' import { SkillListTagManagementModal } from './skill-list-tag-management-modal' const placeholderCardIds = Array.from( { length: 16 }, (_, index) => `skill-placeholder-card-${index}`, ) const skeletonRows = ['primary', 'secondary', 'tertiary'] as const const SKILLS_PAGE_SIZE = 20 function skillsListQueryKey(type: 'infinite' | 'query') { return consoleQuery.workspaces.current.skills.get.key({ type }) } function invalidateSkillListQueries(queryClient: QueryClient) { void queryClient.invalidateQueries({ queryKey: skillsListQueryKey('query') }) void queryClient.invalidateQueries({ queryKey: skillsListQueryKey('infinite') }) void queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.skills.tags.get.key({ type: 'query' }), }) } function SkillIcon() { return (
) } function SkillCardSkeleton() { return ( <> {skeletonRows.map((row) => (
))} ) } function SkillPlaceholderState({ canEdit, creating, importing, isEmptySearch, onCreate, onImport, title, }: { canEdit?: boolean creating?: boolean importing?: boolean isEmptySearch?: boolean onCreate?: () => void onImport?: () => void title: string }) { const { t } = useTranslation('skill') return (
{placeholderCardIds.map((id) => (
))}

{title}

{!isEmptySearch && canEdit !== false && (
)}
) } function DeleteSkillDialog({ open, skill, onOpenChange, }: { open: boolean skill: SkillResponse onOpenChange: (open: boolean) => void }) { const { t } = useTranslation('skill') const { t: tCommon } = useTranslation('common') const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const queryClient = useQueryClient() const deleteMutation = useMutation( consoleQuery.workspaces.current.skills.bySkillId.delete.mutationOptions(), ) const referencesQuery = useQuery({ ...consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({ input: { params: { skill_id: skill.id, }, }, enabled: open, }), refetchOnMount: 'always', }) const references = referencesQuery.data?.data ?? [] const referenceCount = Math.max(skill.reference_count ?? 0, references.length) const isDeleteDisabled = deleteMutation.isPending || (open && (referencesQuery.isFetching || !referencesQuery.isSuccess)) || (referenceCount > 0 && confirmDeleteInput !== skill.display_name) const description = referenceCount > 0 ? t( ($) => referenceCount === 1 ? $['skillManagement.deleteDialog.referencedDescription_one'] : $['skillManagement.deleteDialog.referencedDescription_other'], { count: referenceCount }, ) : t(($) => $['skillManagement.deleteDialog.description']) const handleDelete = () => { if (isDeleteDisabled) return deleteMutation.mutate( { params: { skill_id: skill.id, }, body: { confirmation_name: referenceCount > 0 ? skill.display_name : skill.name, }, }, { onSuccess: () => { toast.success(t(($) => $['skillManagement.deleteSuccess'])) invalidateSkillListQueries(queryClient) onOpenChange(false) }, onError: () => { toast.error(t(($) => $['skillManagement.deleteFailed'])) }, }, ) } return ( { if (!nextOpen) setConfirmDeleteInput('') onOpenChange(nextOpen) }} >
{t(($) => $['skillManagement.deleteDialog.title'], { name: skill.display_name })} {description} {referenceCount > 0 && (
{referencesQuery.isPending ? ( ) : ( )}
)} {referenceCount > 0 && ( $['skillManagement.deleteDialog.confirmInputLabel']} ns="skill" values={{ skillName: skill.display_name }} components={{ skillName: ( ), }} /> $['skillManagement.deleteDialog.confirmInputPlaceholder'])} value={confirmDeleteInput} onValueChange={setConfirmDeleteInput} /> )}
{tCommon(($) => $['operation.cancel'])} {tCommon(($) => $['operation.delete'])}
) } function SkillCard({ canDelete, canEdit, skill, onOpenTagManagement, }: { canDelete: boolean canEdit: boolean skill: SkillResponse onOpenTagManagement: () => void }) { const { t } = useTranslation('skill') const { t: tCommon } = useTranslation('common') const { formatTimeFromNow } = useFormatTimeFromNow() const queryClient = useQueryClient() const nameId = useId() const [isDeleteOpen, setIsDeleteOpen] = useState(false) const duplicateMutation = useMutation( consoleQuery.workspaces.current.skills.bySkillId.duplicate.post.mutationOptions(), ) const exportMutation = useMutation({ mutationFn: () => fetchSkillArchiveBlob(skill.id), onSuccess: (blob) => { downloadBlob({ data: blob, fileName: `${skill.name}.zip` }) }, onError: () => { toast.error(tCommon(($) => $['operation.downloadFailed'])) }, }) const isDraft = !skill.latest_published_version_id const updatedAt = formatTimeFromNow(skill.updated_at * 1000) const publishedAt = skill.latest_published_at ? formatTimeFromNow(skill.latest_published_at * 1000) : undefined const handleDuplicate = () => { if (duplicateMutation.isPending) return duplicateMutation.mutate( { params: { skill_id: skill.id, }, }, { onSuccess: () => { toast.success(t(($) => $['skillManagement.duplicateSuccess'])) invalidateSkillListQueries(queryClient) }, onError: () => { toast.error(t(($) => $['skillManagement.duplicateFailed'])) }, }, ) } const handleExport = () => { if (exportMutation.isPending) return exportMutation.mutate() } return (

{skill.display_name}

{!skill.name.startsWith('untitled-skill-') && (

{skill.name}

)}
{skill.description.trim() ? skill.description : t(($) => $['skillManagement.noDescription'])}
{(canEdit || canDelete || !!skill.latest_published_version_id) && (
$['skillManagement.moreActions'], { name: skill.display_name, })} className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" onClick={(event) => event.stopPropagation()} > {t(($) => $['skillManagement.moreActions'], { name: skill.display_name })} {canEdit && ( {tCommon(($) => $['operation.duplicate'])} )} {skill.latest_published_version_id && ( {tCommon(($) => $['operation.export'])} )} {canDelete && ( <> setIsDeleteOpen(true)} > {tCommon(($) => $['operation.delete'])} )}
)}
invalidateSkillListQueries(queryClient)} />
{t( ($) => skill.reference_count === 1 ? $['skillManagement.referenceCount_one'] : $['skillManagement.referenceCount_other'], { count: skill.reference_count ?? 0 }, )} ยท {isDraft ? t(($) => $['skillManagement.editedAt'], { time: updatedAt }) : t(($) => $['skillManagement.publishedAt'], { time: publishedAt })}
{isDraft && (
{t(($) => $['skillManagement.draft'])}
)}
) } function SkillTagFilter({ onOpenTagManagement }: { onOpenTagManagement: () => void }) { const [selectedTags, setSelectedTags] = useQueryState( skillQueryParamNames.tag, skillTagQueryParser, ) const tagsQuery = useQuery( consoleQuery.tags.get.queryOptions({ input: { query: { type: 'skill', }, }, }), ) const skillTags = useMemo( () => (tagsQuery.data ?? []).filter((tag) => tag.type === 'skill'), [tagsQuery.data], ) const tagIdByName = useMemo( () => new Map(skillTags.map((tag) => [tag.name, tag.id])), [skillTags], ) const tagNameById = useMemo( () => new Map(skillTags.map((tag) => [tag.id, tag.name])), [skillTags], ) const validSelectedTags = useMemo( () => selectedTags.filter((tagName) => tagIdByName.has(tagName)), [selectedTags, tagIdByName], ) const selectedTagIds = selectedTags.flatMap((tagName) => { const tagId = tagIdByName.get(tagName) return tagId ? [tagId] : [] }) useEffect(() => { if (!tagsQuery.isSuccess || validSelectedTags.length === selectedTags.length) return // oxlint-disable-next-line eslint-react/set-state-in-effect -- The loaded tag list is authoritative, so remove stale names from the external URL query state. void setSelectedTags(validSelectedTags) }, [selectedTags, setSelectedTags, tagsQuery.isSuccess, validSelectedTags]) return ( { void setSelectedTags( tagIds.flatMap((tagId) => { const tagName = tagNameById.get(tagId) return tagName ? [tagName] : [] }), ) }} onOpenTagManagement={onOpenTagManagement} showLeadingIcon={false} triggerClassName="min-w-0" /> ) } function SkillsToolbar({ canEdit, creating, importing, onCreate, onImport, onOpenTagManagement, }: { canEdit: boolean creating: boolean importing: boolean onCreate: () => void onImport: () => void onOpenTagManagement: () => void }) { const { t } = useTranslation('skill') const [keyword, setKeyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser) const isMutating = creating || importing return (
$['skillManagement.searchLabel'])} className="h-8 w-50 min-w-0 shrink" placeholder={t(($) => $['skillManagement.searchPlaceholder'])} value={keyword} onValueChange={(value) => { void setKeyword(value) }} />
{canEdit && ( )} {canEdit && ( )}
) } function SkillGrid({ canDelete, canEdit, creating, importing, isEmptySearch, isError, isFetching, isFetchingNextPage, isPending, onCreate, onImport, onOpenTagManagement, skills, }: { canDelete: boolean canEdit: boolean creating: boolean importing: boolean isEmptySearch: boolean isError: boolean isFetching: boolean isFetchingNextPage: boolean isPending: boolean onCreate: () => void onImport: () => void onOpenTagManagement: () => void skills: SkillResponse[] }) { const { t } = useTranslation('skill') return (
$['skillManagement.listLabel'])} className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5" aria-busy={isFetching || undefined} > {isPending && } {!isPending && isError && ( $['skillManagement.loadingError'])} /> )} {!isPending && !isError && skills.length === 0 && ( $['skillManagement.emptySearch']) : t(($) => $['skillManagement.empty']) } /> )} {!isPending && !isError && skills.map((skill) => ( ))} {!isPending && !isError && isFetchingNextPage && }
) } export default function SkillsPage() { const { t } = useTranslation('skill') const router = useRouter() const queryClient = useQueryClient() const { canDelete, canEdit } = useSkillPermissions() const importInputRef = useRef(null) const listViewportRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser) const [selectedTags] = useQueryState(skillQueryParamNames.tag, skillTagQueryParser) const debouncedKeyword = useDebounce(keyword.trim(), { wait: 300 }) const createMutation = useMutation( consoleQuery.workspaces.current.skills.post.mutationOptions({ context: { silent: true }, }), ) const importMutation = useMutation( consoleQuery.workspaces.current.skills.import.post.mutationOptions({ context: { silent: true }, }), ) const skillsQuery = useInfiniteQuery({ ...consoleQuery.workspaces.current.skills.get.infiniteOptions({ input: (pageParam) => ({ query: { limit: SKILLS_PAGE_SIZE, page: Number(pageParam), ...(debouncedKeyword ? { keyword: debouncedKeyword } : {}), ...(selectedTags.length > 0 ? { tag: selectedTags } : {}), }, }), getNextPageParam: (lastPage) => (lastPage.has_more ? (lastPage.page ?? 1) + 1 : undefined), initialPageParam: 1, }), refetchOnMount: 'always', }) const skills = skillsQuery.data?.pages.flatMap((page) => page.data ?? []) ?? [] const { fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = skillsQuery useDocumentTitle(t(($) => $['skillManagement.title'])) const handleCreate = () => { if (createMutation.isPending) return createMutation.mutate( { body: {}, }, { onSuccess: (skill) => { toast.success(t(($) => $['skillManagement.createSuccess'])) invalidateSkillListQueries(queryClient) router.push(`/skills/${skill.id}`) }, onError: async (error) => { const normalizedError = await normalizeSkillError(error) toast.error( getSkillErrorCode(normalizedError) === 'skill_limit_exceeded' ? t(($) => $['skillManagement.errors.workspaceLimit']) : t(($) => $['skillManagement.createFailed']), ) }, }, ) } const handleFileChange = (file: File | undefined) => { if (!file || importMutation.isPending) return importMutation.mutate( { body: { file, }, }, { onSuccess: (skill) => { toast.success(t(($) => $['skillManagement.importSuccess'])) invalidateSkillListQueries(queryClient) router.push(`/skills/${skill.id}`) }, onError: async (error) => { const normalizedError = await normalizeSkillError(error) const errorCode = getSkillErrorCode(normalizedError) if (errorCode === 'skill_name_conflict') { toast.error( t(($) => $['skillManagement.errors.nameConflict'], { name: getSkillErrorDetailString(normalizedError, 'name') ?? '', }), ) return } if (errorCode === 'skill_limit_exceeded') { toast.error(t(($) => $['skillManagement.errors.workspaceLimit'])) return } if (errorCode === 'missing_skill_md') { toast.error(t(($) => $['skillManagement.errors.missingSkillMd'])) return } toast.error(t(($) => $['skillManagement.importFailed'])) }, onSettled: () => { if (importInputRef.current) importInputRef.current.value = '' }, }, ) } const handleListScroll = (event: UIEvent) => { const target = event.currentTarget const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight if (scrollBottom < 80 && hasNextPage && !isFetchingNextPage) void fetchNextPage() } useEffect(() => { const viewport = listViewportRef.current if (!viewport || viewport.clientHeight === 0 || isPending || isFetchingNextPage || !hasNextPage) return if (viewport.scrollHeight - viewport.clientHeight < 80) void fetchNextPage() }, [fetchNextPage, hasNextPage, isFetchingNextPage, isPending, skills.length]) return (

{t(($) => $['skillManagement.title'])}

handleFileChange(event.currentTarget.files?.[0])} /> importInputRef.current?.click()} onOpenTagManagement={() => setShowTagManagementModal(true)} />
0} isError={skillsQuery.isError} isFetching={skillsQuery.isFetching} isFetchingNextPage={skillsQuery.isFetchingNextPage} isPending={skillsQuery.isPending} onCreate={handleCreate} onImport={() => importInputRef.current?.click()} onOpenTagManagement={() => setShowTagManagementModal(true)} />
setShowTagManagementModal(false)} onTagsChange={() => invalidateSkillListQueries(queryClient)} />
) }