'use client' import type { Source } from '@dify/contracts/knowledge-fs/types.gen' import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' import { AlertDialog, AlertDialogActions, AlertDialogCancelButton, AlertDialogConfirmButton, AlertDialogContent, AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { toast } from '@langgenius/dify-ui/toast' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { workspacePermissionKeysAtom } from '@/context/permission-state' import Link from '@/next/link' import { consoleClient, consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' import { newKnowledgeAddSourcePath } from './routes' type SourceStatus = Source['status'] type SourceFilter = SourceStatus | 'all' type SourceSort = 'name-asc' | 'name-desc' const PAGE_SIZE = 50 const MAX_AUTO_FILTER_PAGES = 4 const SOURCE_POLL_INTERVAL = 3000 const statusDotStatus: Record = { active: 'success', syncing: 'normal', disabled: 'disabled', error: 'error', } function metadataString(metadata: Source['metadata'], key: string) { const value = metadata[key] return typeof value === 'string' && value.trim() ? value : undefined } function isPreviewDraft(source: Source) { return source.metadata.preview === true && source.status === 'disabled' } function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` } function getOpenableSourceUri(uri: string) { try { const url = new URL(uri) return url.protocol === 'http:' || url.protocol === 'https:' ? uri : undefined } catch { return undefined } } function getCurrentSource(source: Source, sourceOverride?: Source) { if (!sourceOverride || sourceOverride.id !== source.id) return source const sourceVersion = source.version ?? -1 const overrideVersion = sourceOverride.version ?? -1 if (sourceVersion > overrideVersion) return source if (sourceVersion < overrideVersion) return sourceOverride return source.updatedAt > sourceOverride.updatedAt ? source : sourceOverride } type SourceAction = 'remove' | 'sync' | 'toggle' function SourceActions({ canEdit, canSync, onRemove, onSync, onToggle, pendingAction, source, }: { canEdit: boolean canSync: boolean onRemove: () => Promise onSync: () => Promise onToggle: () => Promise pendingAction?: SourceAction source: Source }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const [removeDialogOpen, setRemoveDialogOpen] = useState(false) const sourceUri = getOpenableSourceUri(source.uri) if (!canEdit && !canSync && !sourceUri) return null return ( <> $['newKnowledge.sourceActions'], { name: source.name })} disabled={Boolean(pendingAction)} className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled" > {canSync && ( void onSync()} className="gap-2 px-3"> {t(($) => $['newKnowledge.syncNow'])} )} {sourceUri && ( $['operation.openInNewTab'])} href={sourceUri} target="_blank" rel="noopener noreferrer" /> } className="gap-2 px-3" > {tCommon(($) => $['operation.openInNewTab'])} )} {canEdit && ( <> void onToggle()} className="gap-2 px-3"> {source.status === 'disabled' ? t(($) => $.enable) : t(($) => $['newKnowledge.disableSource'])} setRemoveDialogOpen(true)} variant="destructive" className="gap-2 px-3" > {t(($) => $['newKnowledge.removeSource'])} )}
{tCommon(($) => $['operation.deleteConfirmTitle'])} {tCommon(($) => $['operation.confirmAction'])}
{tCommon(($) => $['operation.cancel'])} void onRemove().then((removed) => { if (removed) setRemoveDialogOpen(false) }) } > {t(($) => $['newKnowledge.removeSource'])}
) } function SourceRow({ canEdit, canSync, checked, knowledgeSpaceId, onCheckedChange, onRemoved, onSourceChange, source, }: { canEdit: boolean canSync: boolean checked: boolean knowledgeSpaceId: string onCheckedChange: (checked: boolean) => void onRemoved: () => void onSourceChange: (source: Source) => void source: Source }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const queryClient = useQueryClient() const [pendingAction, setPendingAction] = useState() const providerName = metadataString(source.metadata, 'providerName') const syncPolicy = metadataString(source.metadata, 'syncPolicy') const lastSync = metadataString(source.metadata, 'lastSyncedAt') const typeLabel = t(($) => $[`newKnowledge.sourceType.${source.type}`]) const runAction = async ( action: SourceAction, mutation: () => Promise, onAccepted?: (result: Result) => void, ) => { if (pendingAction) return false setPendingAction(action) try { let result: Result try { result = await mutation() } catch { toast.error(t(($) => $['newKnowledge.sourcesErrorDescription'])) try { await queryClient.invalidateQueries({ queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.key(), }) } catch { return false } return false } onAccepted?.(result) try { await queryClient.invalidateQueries({ queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.key(), }) } catch { // The accepted mutation is already reflected by the list-owner state. } return true } finally { setPendingAction(undefined) } } const syncSource = () => runAction( 'sync', () => consoleClient.knowledgeFs.postKnowledgeSpacesByIdSourcesBySourceIdSync({ headers: { 'Idempotency-Key': createIdempotencyKey() }, params: { id: knowledgeSpaceId, sourceId: source.id }, }), () => onSourceChange({ ...source, status: 'syncing' }), ) const toggleSource = () => runAction( 'toggle', () => consoleClient.knowledgeFs.patchKnowledgeSpacesByIdSourcesBySourceId({ body: { ...(source.version === undefined ? {} : { expectedVersion: source.version }), status: source.status === 'disabled' ? 'active' : 'disabled', }, params: { id: knowledgeSpaceId, sourceId: source.id }, }), onSourceChange, ) const removeSource = () => runAction( 'remove', async () => { if (source.version === undefined) throw new Error('Source version is required') return consoleClient.knowledgeFs.deleteKnowledgeSpacesByIdSourcesBySourceId({ body: { expectedRevision: source.version }, headers: { 'idempotency-key': createIdempotencyKey() }, params: { id: knowledgeSpaceId, sourceId: source.id }, query: { documents: 'keep' }, }) }, onRemoved, ) return (

{source.name}

{providerName ?? typeLabel}

{providerName &&

{typeLabel}

} {t(($) => $[`newKnowledge.sourceStatus.${source.status}`])} {syncPolicy ?? '—'} {source.status === 'error' ? ( {t(($) => $['newKnowledge.sourceSyncFailed'])} ) : ( (lastSync ?? '—') )}
{canSync && source.status === 'error' && ( )}
) } function SourcesEmpty({ canAddSource, knowledgeSpaceId, }: { canAddSource: boolean knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') return (

{t(($) => $['newKnowledge.sourcesEmptyTitle'])}

{t(($) => $['newKnowledge.sourcesEmptyDescription'])}

{canAddSource && ( {t(($) => $['newKnowledge.addSource'])} )}
) } export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageSources = hasPermission(workspacePermissionKeys, 'dataset.external.connect') const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [sort, setSort] = useState() const [selectedSourceIds, setSelectedSourceIds] = useState>(() => new Set()) const [sourceOverrides, setSourceOverrides] = useState>({}) const [removedSourceIds, setRemovedSourceIds] = useState>(() => new Set()) const sourcesQuery = useInfiniteQuery( consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.infiniteOptions({ input: (pageParam) => ({ params: { id: knowledgeSpaceId }, query: { limit: PAGE_SIZE, ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), }, }), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null as string | null, refetchInterval: (query) => query.state.data?.pages.some((page) => page.items.some( (source) => !removedSourceIds.has(source.id) && getCurrentSource(source, sourceOverrides[source.id]).status === 'syncing', ), ) ? SOURCE_POLL_INTERVAL : false, }), ) const remoteSources = sourcesQuery.data?.pages.flatMap((page) => page.items) const sources = useMemo( () => (remoteSources ?? []) .filter((source) => !removedSourceIds.has(source.id)) .map((source) => getCurrentSource(source, sourceOverrides[source.id])) .filter((source) => !isPreviewDraft(source)) .sort( (left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), ), [remoteSources, removedSourceIds, sourceOverrides], ) const loadedPageCount = sourcesQuery.data?.pages.length ?? 0 const filteredSources = useMemo(() => { const normalizedSearch = search.trim().toLocaleLowerCase() const nextSources = (sources ?? []).filter((source) => { if (filter !== 'all' && source.status !== filter) return false if (!normalizedSearch) return true return `${source.name} ${source.uri}`.toLocaleLowerCase().includes(normalizedSearch) }) if (!sort) return nextSources return [...nextSources].sort((left, right) => { const result = left.name.localeCompare(right.name) return sort === 'name-asc' ? result : -result }) }, [filter, search, sort, sources]) const localTransformActive = filter !== 'all' || Boolean(search.trim()) || Boolean(sort) const canAutoCompleteFilteredResults = localTransformActive && loadedPageCount < MAX_AUTO_FILTER_PAGES const latestSourcePage = sourcesQuery.data?.pages.at(-1) const needsVisibleSource = latestSourcePage !== undefined && latestSourcePage.items.some((source) => isPreviewDraft(getCurrentSource(source, sourceOverrides[source.id])), ) && !latestSourcePage.items.some((source) => { if (removedSourceIds.has(source.id)) return false return !isPreviewDraft(getCurrentSource(source, sourceOverrides[source.id])) }) const completingFilteredResults = (canAutoCompleteFilteredResults || needsVisibleSource) && !sourcesQuery.isFetchNextPageError && (sourcesQuery.hasNextPage || sourcesQuery.isFetchingNextPage) const allFilteredSourcesSelected = filteredSources.length > 0 && filteredSources.every((source) => selectedSourceIds.has(source.id)) const someFilteredSourcesSelected = filteredSources.some((source) => selectedSourceIds.has(source.id), ) const { fetchNextPage: fetchNextSourcePage, hasNextPage: hasNextSourcePage, isFetchingNextPage: isFetchingNextSourcePage, } = sourcesQuery useEffect(() => { if ( (canAutoCompleteFilteredResults || needsVisibleSource) && hasNextSourcePage && !isFetchingNextSourcePage && !sourcesQuery.isFetchNextPageError ) void fetchNextSourcePage() }, [ canAutoCompleteFilteredResults, fetchNextSourcePage, hasNextSourcePage, isFetchingNextSourcePage, needsVisibleSource, sourcesQuery.isFetchNextPageError, ]) return (

{t(($) => $['newKnowledge.sources'])}

{t(($) => $['newKnowledge.sourcesDescription'])}

{sourcesQuery.isPending ? (
) : sourcesQuery.error && !sourcesQuery.data ? (

{t(($) => $['newKnowledge.sourcesErrorTitle'])}

{t(($) => $['newKnowledge.sourcesErrorDescription'])}

) : !sources?.length && !sourcesQuery.hasNextPage ? ( ) : ( <>
{canManageSources && ( {t(($) => $['newKnowledge.addSource'])} )}
{filteredSources.map((source) => ( { setRemovedSourceIds((current) => new Set(current).add(source.id)) setSelectedSourceIds((current) => { if (!current.has(source.id)) return current const next = new Set(current) next.delete(source.id) return next }) }} onSourceChange={(updatedSource) => setSourceOverrides((current) => ({ ...current, [updatedSource.id]: updatedSource, })) } onCheckedChange={(checked) => { setSelectedSourceIds((current) => { const next = new Set(current) if (checked) next.add(source.id) else next.delete(source.id) return next }) }} /> ))}
$['operation.selectAll'])} checked={allFilteredSourcesSelected} indeterminate={someFilteredSourcesSelected && !allFilteredSourcesSelected} onCheckedChange={(checked) => { setSelectedSourceIds((current) => { const next = new Set(current) for (const source of filteredSources) { if (checked) next.add(source.id) else next.delete(source.id) } return next }) }} /> {t(($) => $['metadata.createMetadata.type'])} {t(($) => $['newKnowledge.statusColumn'])} {t(($) => $['newKnowledge.syncPolicyColumn'])} {t(($) => $['newKnowledge.lastSyncColumn'])} $['newKnowledge.actionsColumn'])} />
{!filteredSources.length && !completingFilteredResults && !sourcesQuery.isFetchNextPageError && (

{t(($) => $['newKnowledge.noMatchingSources'])}

)} {!filteredSources.length && completingFilteredResults && (
)}
{sourcesQuery.isFetchNextPageError ? (
{t(($) => $['newKnowledge.sourcesErrorDescription'])}
) : sourcesQuery.hasNextPage && !completingFilteredResults ? (
) : null} )}
) }