'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 { IconButton } from '@langgenius/dify-ui/icon-button' 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 { MAIN_NAV_APP_CARD_GRID_CLASS_NAME } from '@/app/components/main-nav/app-card-grid' 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 const SKILL_GRID_CLASS_NAME = cn('gap-2.5', MAIN_NAV_APP_CARD_GRID_CLASS_NAME) 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 SkillCardSkeletonCards() { return ( <> {skeletonRows.map((row) => (
))} ) } function SkillCardSkeleton() { const { t } = useTranslation('common') return ( <> {t(($) => $.loading)} ) } type SkillPlaceholderActions = { creating: boolean importing: boolean onCreate: () => void onImport: () => void } type SkillPlaceholderStateProps = { role?: 'alert' | 'status' title: string } & ( | { actions?: SkillPlaceholderActions; isRetrying?: never; onRetry?: never } | { actions?: never; isRetrying: boolean; onRetry: () => void } ) function SkillPlaceholderState({ actions, isRetrying, onRetry, role, title, }: SkillPlaceholderStateProps) { const { t } = useTranslation('skill') const { t: tCommon } = useTranslation('common') return (
{placeholderCardIds.map((id) => (
))}

{title}

{onRetry && ( )}
{actions && (
)}
) } 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 isDeleteUnavailable = (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 (deleteMutation.isPending || isDeleteUnavailable) 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 descriptionId = useId() const draftStatusId = 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 accessibleDescriptionIds = isDraft ? `${draftStatusId} ${descriptionId}` : descriptionId 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'])}
    {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'])}
    )} {(canEdit || canDelete || !!skill.latest_published_version_id) && (
    $['skillManagement.moreActions'], { name: skill.display_name, })} size="lg" className="data-popup-open:bg-state-base-hover" > } /> {canEdit && ( {tCommon(($) => $['operation.duplicate'])} )} {skill.latest_published_version_id && ( {tCommon(($) => $['operation.export'])} )} {canDelete && ( <> setIsDeleteOpen(true)} > {tCommon(($) => $['operation.delete'])} )}
    )}
    invalidateSkillListQueries(queryClient)} />
  • ) } 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) 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 && ( )}
    ) } type SkillGridRetryState = { status: 'error'; isRetrying: boolean; onRetry: () => void } type SkillGridState = | { status: 'pending' } | SkillGridRetryState | { status: 'ready' content: | { kind: 'empty' emptyState: 'skills' | 'filtered' actions?: SkillPlaceholderActions } | { kind: 'list' skills: SkillResponse[] pagination: { status: 'none' } | { status: 'loading' } | SkillGridRetryState refresh: { status: 'none' } | SkillGridRetryState cardActions: { canDelete: boolean canEdit: boolean onOpenTagManagement: () => void } } isFetching: boolean } type SkillGridProps = { state: SkillGridState } function SkillGridRetryStatus({ state }: { state: SkillGridRetryState }) { const { t } = useTranslation('skill') const { t: tCommon } = useTranslation('common') return (
    {t(($) => $['skillManagement.loadingError'])}
    ) } function SkillGridPagination({ state, }: { state: Extract< Extract['content'], { kind: 'list' } >['pagination'] }) { const { t } = useTranslation('common') if (state.status === 'none') return null if (state.status === 'error') return return (
    {t(($) => $.loading)}
    ) } function SkillGrid({ state }: SkillGridProps) { const { t } = useTranslation('skill') const isBusy = state.status === 'pending' || (state.status === 'error' && state.isRetrying) || (state.status === 'ready' && state.isFetching) const readyContent = state.status === 'ready' ? state.content : undefined return (
    $['skillManagement.listLabel'])} aria-busy={isBusy}> {state.status === 'pending' && (
    )} {state.status === 'error' && ( $['skillManagement.loadingError'])} /> )} {readyContent?.kind === 'empty' && ( $['skillManagement.emptySearch']) : t(($) => $['skillManagement.empty']) } /> )} {readyContent?.kind === 'list' && ( <> {readyContent.refresh.status === 'error' && ( )} {/* Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility */} {/* oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers. */}
      {readyContent.skills.map((skill) => ( ))}
    )}
    ) } 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 || importMutation.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 || createMutation.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 && !skillsQuery.isFetching && !skillsQuery.isFetchNextPageError ) void fetchNextPage() } useEffect(() => { const viewport = listViewportRef.current if ( !viewport || viewport.clientHeight === 0 || isPending || skillsQuery.isFetching || skillsQuery.isFetchNextPageError || !hasNextPage ) return if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80) void fetchNextPage() }, [ fetchNextPage, hasNextPage, isPending, skills.length, skillsQuery.isFetching, skillsQuery.isFetchNextPageError, ]) const isFiltered = !!debouncedKeyword || selectedTags.length > 0 const skillGridState: SkillGridState = isPending ? { status: 'pending' } : skillsQuery.isLoadingError || (skills.length === 0 && skillsQuery.isRefetchError) ? { status: 'error', isRetrying: skillsQuery.isFetching, onRetry: () => void skillsQuery.refetch(), } : { status: 'ready', content: skills.length === 0 ? { kind: 'empty', emptyState: isFiltered ? 'filtered' : 'skills', actions: canEdit && !isFiltered ? { creating: createMutation.isPending, importing: importMutation.isPending, onCreate: handleCreate, onImport: () => importInputRef.current?.click(), } : undefined, } : { kind: 'list', skills, pagination: skillsQuery.isFetchNextPageError ? { status: 'error', isRetrying: isFetchingNextPage, onRetry: () => void fetchNextPage(), } : isFetchingNextPage ? { status: 'loading' } : { status: 'none' }, refresh: skillsQuery.isRefetchError ? { status: 'error', isRetrying: skillsQuery.isFetching, onRetry: () => void skillsQuery.refetch(), } : { status: 'none' }, cardActions: { canDelete, canEdit, onOpenTagManagement: () => setShowTagManagementModal(true), }, }, isFetching: skillsQuery.isFetching, } return (

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

    handleFileChange(event.currentTarget.files?.[0])} /> importInputRef.current?.click()} onOpenTagManagement={() => setShowTagManagementModal(true)} />
    setShowTagManagementModal(false)} onTagsChange={() => invalidateSkillListQueries(queryClient)} />
    ) }