'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) => (
))}
{!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 (
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)}
/>
)
}