refactor(knowledge-fs): decompose sources page

This commit is contained in:
Stephen Zhou 2026-09-02 00:31:11 +08:00
parent d3f59d8458
commit 930de8196a
No known key found for this signature in database
10 changed files with 1417 additions and 846 deletions

View File

@ -1,9 +1,10 @@
import type { Getter } from 'jotai'
import type { Source, SourceSyncPolicy, SourceWorkflowRun } from '../source-models'
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import datasetTranslations from '@/i18n/en-US/dataset.json'
import { renderWithNuqs as render } from '@/test/nuqs-testing'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { SourcesPage } from '../page'
vi.mock('../../components/knowledge-model-readiness-banner', () => ({
@ -115,6 +116,9 @@ const sourcesQuery = vi.hoisted(() => ({
isPending: false,
refetch: vi.fn(),
}))
const jotaiQueryMocks = vi.hoisted(() => ({
bump: undefined as undefined | (() => void),
}))
const connectionsQuery = vi.hoisted(() => ({
data: undefined as
| {
@ -205,6 +209,33 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}
})
vi.mock('jotai-tanstack-query', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai-tanstack-query')>()
const { atom, getDefaultStore } = await import('jotai/vanilla')
const revisionAtom = atom(0)
jotaiQueryMocks.bump = () =>
getDefaultStore().set(revisionAtom, (revision: number) => revision + 1)
return {
...original,
atomWithInfiniteQuery: (getOptions: (get: Getter) => unknown) =>
atom((get) => {
get(revisionAtom)
getOptions(get)
return {
...sourcesQuery,
data: sourcesQuery.data
? {
pages: sourcesQuery.data.pages.map((page) => ({
data: page.items.map(sourceApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
: undefined,
}
}),
}
})
vi.mock('@/next/navigation', () => ({
usePathname: () => '/datasets/new/space-1/sources',
useRouter: () => routerMock,
@ -280,6 +311,19 @@ vi.mock('@/service/client', () => ({
},
}))
function render(...args: Parameters<typeof renderWithNuqs>) {
jotaiQueryMocks.bump?.()
const rendered = renderWithNuqs(...args)
const rerender = rendered.rerender
return {
...rendered,
rerender: (ui: Parameters<typeof rerender>[0]) => {
jotaiQueryMocks.bump?.()
rerender(ui)
},
}
}
const source = (overrides: Partial<Source>): Source => ({
createdAt: '2026-07-20T10:00:00Z',
id: 'source-1',

View File

@ -0,0 +1,147 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { atom, createStore, Provider, useAtomValue, useSetAtom } from 'jotai'
import {
removedSourceIdsAtom,
removeSourceFromListAtom,
sourcesAwaitedOperationIdAtom,
sourcesFilterAtom,
sourcesKnowledgeSpaceIdAtom,
sourcesSearchAtom,
sourcesSortAtom,
} from '../state'
import { SourcesStateBoundary } from '../state-boundary'
const parentValueAtom = atom('missing')
function SourcesInputs({ label }: { label: string }) {
const knowledgeSpaceId = useAtomValue(sourcesKnowledgeSpaceIdAtom)
const filter = useAtomValue(sourcesFilterAtom)
const search = useAtomValue(sourcesSearchAtom)
const sort = useAtomValue(sourcesSortAtom)
const awaitedOperationId = useAtomValue(sourcesAwaitedOperationIdAtom)
const parentValue = useAtomValue(parentValueAtom)
return (
<p>{`${label}:${knowledgeSpaceId}:${filter}:${search}:${sort}:${awaitedOperationId}:${parentValue}`}</p>
)
}
function RemovedSourcesSession() {
const removedSourceIds = useAtomValue(removedSourceIdsAtom)
const removeSource = useSetAtom(removeSourceFromListAtom)
return (
<button onClick={() => removeSource('source-1')}>
{[...removedSourceIds].join(',') || 'none'}
</button>
)
}
describe('SourcesStateBoundary', () => {
it('isolates route inputs between sibling instances while observing the parent store', () => {
const store = createStore()
store.set(parentValueAtom, 'parent-visible')
render(
<Provider store={store}>
<SourcesStateBoundary
awaitedOperationId="operation-a"
filter="active"
knowledgeSpaceId="space-a"
search="alpha"
sort="name-asc"
>
<SourcesInputs label="first" />
</SourcesStateBoundary>
<SourcesStateBoundary
awaitedOperationId={null}
filter="error"
knowledgeSpaceId="space-b"
search="beta"
sort="name-desc"
>
<SourcesInputs label="second" />
</SourcesStateBoundary>
</Provider>,
)
expect(
screen.getByText('first:space-a:active:alpha:name-asc:operation-a:parent-visible'),
).toBeInTheDocument()
expect(
screen.getByText('second:space-b:error:beta:name-desc:null:parent-visible'),
).toBeInTheDocument()
})
it('follows authoritative URL inputs without resetting the space session', async () => {
const user = userEvent.setup()
const rendered = render(
<SourcesStateBoundary
awaitedOperationId={null}
filter="all"
knowledgeSpaceId="space-a"
search=""
sort={null}
>
<SourcesInputs label="sources" />
<RemovedSourcesSession />
</SourcesStateBoundary>,
)
await user.click(screen.getByRole('button', { name: 'none' }))
expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
act(() =>
rendered.rerender(
<SourcesStateBoundary
awaitedOperationId="operation-2"
filter="syncing"
knowledgeSpaceId="space-a"
search="docs"
sort="name-asc"
>
<SourcesInputs label="sources" />
<RemovedSourcesSession />
</SourcesStateBoundary>,
),
)
expect(
screen.getByText('sources:space-a:syncing:docs:name-asc:operation-2:missing'),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
})
it('resets workflow primitives when the knowledge space identity changes', async () => {
const user = userEvent.setup()
const rendered = render(
<SourcesStateBoundary
awaitedOperationId={null}
filter="all"
knowledgeSpaceId="space-a"
search=""
sort={null}
>
<RemovedSourcesSession />
</SourcesStateBoundary>,
)
await user.click(screen.getByRole('button', { name: 'none' }))
expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
act(() =>
rendered.rerender(
<SourcesStateBoundary
awaitedOperationId={null}
filter="all"
knowledgeSpaceId="space-b"
search=""
sort={null}
>
<RemovedSourcesSession />
</SourcesStateBoundary>,
),
)
expect(screen.getByRole('button', { name: 'none' })).toBeInTheDocument()
})
})

View File

@ -2,6 +2,7 @@
import type { SourceFilter } from './source-list-query-state'
import type { Source } from './source-models'
import type { SourceSort } from './state'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
@ -14,112 +15,80 @@ import {
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SearchInput } from '@/app/components/base/search-input'
import Link from '@/next/link'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useSearchParams } from '@/next/navigation'
import { KnowledgeModelReadinessBanner } from '../components/knowledge-model-readiness-banner'
import { KnowledgeModelSetupDialog } from '../components/knowledge-model-setup-dialog'
import { newKnowledgeAddSourcePath } from '../routes'
import { useKnowledgeSpacePermission } from '../space/context'
import { useKnowledgeModelSetupGuard } from '../use-knowledge-model-setup-guard'
import { SourcesEmpty } from './empty'
import { SourcesRuntimeController } from './runtime-controller'
import { SourceRow } from './source-list-item'
import { sourceTableGridClass } from './source-list-layout'
import { sourceFilterParser, sourceSearchParser, sourceSortParser } from './source-list-query-state'
import {
initialSourcePollingPhase,
isInitialSourceForOperation,
shouldHidePreviewSource,
sourceDisplayStatus,
sourceFromApi,
sourceNeedsPolling,
sourceStatusWithSyncWorkflow,
sourceWorkflowIsActive,
} from './source-models'
const PAGE_SIZE = 200
const MAX_AUTO_CURSOR_PAGES = 5
const AWAIT_INITIAL_SOURCE_POLL_INTERVAL = 2000
const SOURCE_POLL_INTERVAL = 3000
const INITIAL_SOURCE_POLL_TIMEOUT = 10 * 60 * 1000
function latestSourceWorkflow(
sourceWorkflow?: Source['syncWorkflow'],
sourceOverrideWorkflow?: Source['syncWorkflow'],
) {
if (!sourceWorkflow || !sourceOverrideWorkflow) return sourceWorkflow ?? sourceOverrideWorkflow
if (sourceWorkflow.id === sourceOverrideWorkflow.id) {
if (sourceWorkflow.executionAttempts !== sourceOverrideWorkflow.executionAttempts)
return sourceWorkflow.executionAttempts > sourceOverrideWorkflow.executionAttempts
? sourceWorkflow
: sourceOverrideWorkflow
return sourceWorkflow.updatedAt >= sourceOverrideWorkflow.updatedAt
? sourceWorkflow
: sourceOverrideWorkflow
}
const sourceWorkflowIsRunning = sourceWorkflowIsActive(sourceWorkflow)
const sourceOverrideWorkflowIsRunning = sourceWorkflowIsActive(sourceOverrideWorkflow)
// The server snapshot ranks active runs first, so an active server run remains authoritative
// even when it is an older run being retried. A local active override still has to be newer
// than a terminal server run, otherwise it could remain stuck after a later run completes.
if (sourceWorkflowIsRunning && !sourceOverrideWorkflowIsRunning) return sourceWorkflow
const createdAtComparison = sourceWorkflow.createdAt.localeCompare(
sourceOverrideWorkflow.createdAt,
)
if (createdAtComparison !== 0)
return createdAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
const updatedAtComparison = sourceWorkflow.updatedAt.localeCompare(
sourceOverrideWorkflow.updatedAt,
)
if (updatedAtComparison !== 0)
return updatedAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
return sourceWorkflow.id > sourceOverrideWorkflow.id ? sourceWorkflow : sourceOverrideWorkflow
}
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
const overrideHasNewerSource =
sourceVersion < overrideVersion || source.updatedAt < sourceOverride.updatedAt
const sourceHasNewerSource =
sourceVersion === overrideVersion && source.updatedAt > sourceOverride.updatedAt
if (sourceHasNewerSource) return source
const syncWorkflow = overrideHasNewerSource
? sourceOverride.syncWorkflow
: latestSourceWorkflow(source.syncWorkflow, sourceOverride.syncWorkflow)
if (
!overrideHasNewerSource &&
source.syncWorkflow &&
source.syncWorkflow.id !== sourceOverride.syncWorkflow?.id &&
syncWorkflow === source.syncWorkflow
)
return source
return {
...sourceOverride,
lastSyncedAt: source.lastSyncedAt ?? sourceOverride.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(sourceOverride.status, syncWorkflow),
syncWorkflow,
syncPolicy: overrideHasNewerSource
? (sourceOverride.syncPolicy ?? source.syncPolicy)
: (source.syncPolicy ?? sourceOverride.syncPolicy),
}
}
completingFilteredResultsAtom,
fetchNextSourcePageAtom,
filteredSourcesAtom,
refreshSourcesAtom,
sourcesFilterAtom,
sourcesKnowledgeSpaceIdAtom,
sourcesPollingPhaseAtom,
sourcesQueryErrorAtom,
sourcesQueryFetchingNextPageAtom,
sourcesQueryFetchNextPageErrorAtom,
sourcesQueryHasDataAtom,
sourcesQueryHasNextPageAtom,
sourcesQueryPendingAtom,
sourcesSearchAtom,
sourcesSortAtom,
visibleSourcesAtom,
} from './state'
import { SourcesStateBoundary } from './state-boundary'
export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
const searchParams = useSearchParams()
const [filter, setFilter] = useQueryState('status', sourceFilterParser)
const [search, setSearch] = useQueryState('query', sourceSearchParser)
const [sort, setSort] = useQueryState('sort', sourceSortParser)
const awaitedOperationId = searchParams.get('awaitInitialSource')?.trim() || null
return (
<SourcesStateBoundary
awaitedOperationId={awaitedOperationId}
filter={filter}
knowledgeSpaceId={knowledgeSpaceId}
search={search}
sort={sort}
>
<SourcesPageContent
onFilterChange={(value) => void setFilter(value)}
onSearchChange={(value) => void setSearch(value)}
onSortChange={(value) => void setSort(value)}
/>
</SourcesStateBoundary>
)
}
function SourcesPageContent({
onFilterChange,
onSearchChange,
onSortChange,
}: {
onFilterChange: (value: SourceFilter) => void
onSearchChange: (value: string) => void
onSortChange: (value: SourceSort) => void
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const knowledgeSpaceId = useAtomValue(sourcesKnowledgeSpaceIdAtom)
const {
configureModelSetup,
ensureModelReady,
@ -134,174 +103,25 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
[ensureModelReady],
)
const canManageSources = useKnowledgeSpacePermission('knowledge_space_document_write')
const [filter, setFilter] = useQueryState('status', sourceFilterParser)
const [search, setSearch] = useQueryState('query', sourceSearchParser)
const [sort, setSort] = useQueryState('sort', sourceSortParser)
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(() => new Set())
const [sourceOverrides, setSourceOverrides] = useState<Record<string, Source>>({})
const [removedSourceIds, setRemovedSourceIds] = useState<Set<string>>(() => new Set())
const [initialSourcePollingTimedOut, setInitialSourcePollingTimedOut] = useState(false)
const initialSourcePollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const normalizedAwaitedOperationId = searchParams.get('awaitInitialSource')?.trim() || null
const sourcesQuery = useInfiniteQuery(
consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.infiniteOptions({
input: (pageParam) => ({
params: { control_space_id: knowledgeSpaceId },
query: {
limit: PAGE_SIZE,
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
},
}),
getNextPageParam: (lastPage) => lastPage.next_cursor,
initialPageParam: null as string | null,
refetchInterval: (query) => {
const currentSources =
query.state.data?.pages.flatMap((page) =>
page.data
.filter((source) => !removedSourceIds.has(source.id))
.map((source) => getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
) ?? []
const phase = initialSourcePollingPhase(
currentSources,
normalizedAwaitedOperationId,
initialSourcePollingTimedOut,
)
if (phase === 'awaiting') return AWAIT_INITIAL_SOURCE_POLL_INTERVAL
return currentSources.some(
(source) =>
sourceNeedsPolling(source) &&
(!initialSourcePollingTimedOut || sourceDisplayStatus(source) !== 'initializing'),
)
? SOURCE_POLL_INTERVAL
: false
},
}),
)
const remoteSources = sourcesQuery.data?.pages.flatMap((page) =>
page.data.map((source) => sourceFromApi(source)),
)
const currentSources = useMemo(
() =>
(remoteSources ?? [])
.filter((source) => !removedSourceIds.has(source.id))
.map((source) => getCurrentSource(source, sourceOverrides[source.id])),
[remoteSources, removedSourceIds, sourceOverrides],
)
const pollingPhase = initialSourcePollingPhase(
currentSources,
normalizedAwaitedOperationId,
initialSourcePollingTimedOut,
)
const initialSourcePollingActive = pollingPhase === 'awaiting' || pollingPhase === 'initializing'
const filter = useAtomValue(sourcesFilterAtom)
const search = useAtomValue(sourcesSearchAtom)
const sort = useAtomValue(sourcesSortAtom)
const pollingPhase = useAtomValue(sourcesPollingPhaseAtom)
const waitingForInitialSource = pollingPhase === 'awaiting'
const sources = useMemo(
() =>
currentSources
.filter((source) => !shouldHidePreviewSource(source))
.sort(
(left, right) =>
right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id),
),
[currentSources],
)
const filteredSources = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase()
const nextSources = (sources ?? []).filter((source) => {
if (filter !== 'all' && sourceDisplayStatus(source) !== 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 loadedSourcePageCount = sourcesQuery.data?.pages.length ?? 0
const canAutoLoadNextPage = loadedSourcePageCount < MAX_AUTO_CURSOR_PAGES
const canAutoCompleteFilteredResults = localTransformActive && canAutoLoadNextPage
const latestSourcePage = sourcesQuery.data?.pages.at(-1)
const needsVisibleSource =
latestSourcePage !== undefined &&
latestSourcePage.data.some((source) =>
shouldHidePreviewSource(getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
) &&
!latestSourcePage.data.some((source) => {
if (removedSourceIds.has(source.id)) return false
return !shouldHidePreviewSource(
getCurrentSource(sourceFromApi(source), sourceOverrides[source.id]),
)
})
const completingFilteredResults =
(canAutoCompleteFilteredResults || (needsVisibleSource && canAutoLoadNextPage)) &&
!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 (
normalizedAwaitedOperationId &&
currentSources.some((source) =>
isInitialSourceForOperation(source, normalizedAwaitedOperationId),
)
) {
const nextSearchParams = new URLSearchParams(searchParams)
nextSearchParams.delete('awaitInitialSource')
const queryString = nextSearchParams.toString()
router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
}
}, [currentSources, normalizedAwaitedOperationId, pathname, router, searchParams])
useEffect(() => {
if (!initialSourcePollingActive) {
globalThis.clearTimeout(initialSourcePollingTimeoutRef.current)
initialSourcePollingTimeoutRef.current = undefined
return
}
if (initialSourcePollingTimeoutRef.current) return
initialSourcePollingTimeoutRef.current = globalThis.setTimeout(() => {
initialSourcePollingTimeoutRef.current = undefined
setInitialSourcePollingTimedOut(true)
}, INITIAL_SOURCE_POLL_TIMEOUT)
}, [initialSourcePollingActive])
useEffect(() => () => globalThis.clearTimeout(initialSourcePollingTimeoutRef.current), [])
useEffect(() => {
if (
(canAutoCompleteFilteredResults || (needsVisibleSource && canAutoLoadNextPage)) &&
hasNextSourcePage &&
!isFetchingNextSourcePage &&
!sourcesQuery.isFetchNextPageError
)
void fetchNextSourcePage()
}, [
canAutoCompleteFilteredResults,
canAutoLoadNextPage,
fetchNextSourcePage,
hasNextSourcePage,
isFetchingNextSourcePage,
needsVisibleSource,
sourcesQuery.isFetchNextPageError,
])
const sources = useAtomValue(visibleSourcesAtom)
const filteredSources = useAtomValue(filteredSourcesAtom)
const completingFilteredResults = useAtomValue(completingFilteredResultsAtom)
const sourcesQueryPending = useAtomValue(sourcesQueryPendingAtom)
const sourcesQueryError = useAtomValue(sourcesQueryErrorAtom)
const sourcesQueryHasData = useAtomValue(sourcesQueryHasDataAtom)
const sourcesQueryHasNextPage = useAtomValue(sourcesQueryHasNextPageAtom)
const sourcesQueryFetchNextPageError = useAtomValue(sourcesQueryFetchNextPageErrorAtom)
const sourcesQueryFetchingNextPage = useAtomValue(sourcesQueryFetchingNextPageAtom)
const refreshSources = useSetAtom(refreshSourcesAtom)
const fetchNextSourcePage = useSetAtom(fetchNextSourcePageAtom)
return (
<div className="flex min-h-full min-w-0 flex-1 flex-col px-6 pt-3 pb-6 sm:pb-8">
<SourcesRuntimeController />
<header className="flex items-start justify-between gap-4">
<div>
<h2 className="title-xl-semi-bold leading-6 text-text-primary">
@ -312,7 +132,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
</p>
</div>
{pollingPhase === 'timed-out' && (
<Button onClick={() => void sourcesQuery.refetch()}>
<Button onClick={() => void refreshSources()}>
<span aria-hidden className="i-ri-refresh-line size-4" />
{t(($) => $['newKnowledge.refreshSources'])}
</Button>
@ -335,11 +155,11 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
{t(($) => $['newKnowledge.awaitingInitialSource'])}
</div>
)}
{sourcesQuery.isPending ? (
{sourcesQueryPending ? (
<div className="flex min-h-64 flex-1 items-center justify-center">
<Loading />
</div>
) : sourcesQuery.error && !sourcesQuery.data ? (
) : sourcesQueryError && !sourcesQueryHasData ? (
<div className="flex min-h-64 flex-1 flex-col items-center justify-center px-6 text-center">
<span aria-hidden className="i-ri-error-warning-line size-7 text-text-tertiary" />
<h2 className="mt-3 title-xl-semi-bold text-text-primary">
@ -348,7 +168,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
<p className="mt-2 body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.sourcesErrorDescription'])}
</p>
<Button className="mt-4" onClick={() => void sourcesQuery.refetch()}>
<Button className="mt-4" onClick={() => void refreshSources()}>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
@ -359,7 +179,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
{t(($) => $['newKnowledge.awaitingInitialSource'])}
</p>
</div>
) : !sources?.length && !sourcesQuery.hasNextPage ? (
) : !sources?.length && !sourcesQueryHasNextPage ? (
<SourcesEmpty canAddSource={canManageSources} knowledgeSpaceId={knowledgeSpaceId} />
) : (
<>
@ -367,7 +187,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
<Select<SourceFilter>
value={filter}
onValueChange={(value) => {
if (value) void setFilter(value)
if (value) onFilterChange(value)
}}
>
<SelectLabel className="sr-only">
@ -399,7 +219,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
aria-label={t(($) => $['newKnowledge.searchSources'])}
className="@min-[768px]/knowledge-content:w-60"
value={search}
onValueChange={(value) => void setSearch(value)}
onValueChange={onSearchChange}
placeholder={t(($) => $['newKnowledge.searchSources'])}
/>
{canManageSources && (
@ -415,138 +235,29 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
</Link>
)}
</div>
<div className="mt-3 min-w-0">
<table className="relative block w-full text-left">
<thead className="absolute size-px overflow-hidden text-[11px] leading-4 font-medium tracking-[0.3px] whitespace-nowrap text-text-tertiary uppercase [clip:rect(0,0,0,0)] @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:block @min-[768px]/knowledge-content:size-auto @min-[768px]/knowledge-content:overflow-visible @min-[768px]/knowledge-content:whitespace-normal @min-[768px]/knowledge-content:[clip:auto]">
<tr className={cn(sourceTableGridClass, 'py-2.5')}>
<th className="flex items-center">
<Checkbox
aria-label={tCommon(($) => $['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
})
}}
/>
</th>
<th
aria-sort={
sort === 'name-asc'
? 'ascending'
: sort === 'name-desc'
? 'descending'
: 'none'
}
className="min-w-0 @min-[768px]/knowledge-content:col-start-2 @min-[960px]/knowledge-content:col-start-auto"
>
<Button
variant="ghost"
size="small"
onClick={() => void setSort(sort === 'name-asc' ? 'name-desc' : 'name-asc')}
className="h-auto gap-1 rounded px-0 text-[11px] leading-4 font-medium tracking-[0.3px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{t(($) => $['newKnowledge.sourceColumn'])}
{sort && (
<span
aria-hidden
className={cn(
'size-3.5',
sort === 'name-desc' ? 'i-ri-arrow-down-line' : 'i-ri-arrow-up-line',
)}
/>
)}
</Button>
</th>
<th className="hidden min-w-0 @min-[960px]/knowledge-content:block">
{t(($) => $['metadata.createMetadata.type'])}
</th>
<th className="min-w-0 @min-[768px]/knowledge-content:col-start-3 @min-[960px]/knowledge-content:col-start-auto">
{t(($) => $['newKnowledge.statusColumn'])}
</th>
<th className="hidden min-w-0 @min-[960px]/knowledge-content:block">
{t(($) => $['newKnowledge.syncPolicyColumn'])}
</th>
<th className="min-w-0 @min-[768px]/knowledge-content:col-start-4 @min-[960px]/knowledge-content:col-start-auto">
{t(($) => $['newKnowledge.lastSyncColumn'])}
</th>
<th
className="@min-[768px]/knowledge-content:col-start-5 @min-[960px]/knowledge-content:col-start-auto"
aria-label={t(($) => $['newKnowledge.actionsColumn'])}
/>
</tr>
</thead>
<tbody className="block space-y-2 @min-[768px]/knowledge-content:space-y-0">
{filteredSources.map((source) => (
<SourceRow
key={source.id}
canEdit={canManageSources}
canSync={canManageSources}
checked={selectedSourceIds.has(source.id)}
ensureModelSetupReady={ensureSourceSyncReady}
knowledgeSpaceId={knowledgeSpaceId}
source={source}
onRemoved={() => {
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
})
}}
/>
))}
</tbody>
</table>
{!filteredSources.length &&
!sourcesQuery.hasNextPage &&
!completingFilteredResults &&
!sourcesQuery.isFetchNextPageError && (
<p className="py-16 text-center body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.noMatchingSources'])}
</p>
)}
{!filteredSources.length && completingFilteredResults && (
<div className="flex min-h-40 items-center justify-center">
<Loading />
</div>
)}
</div>
{sourcesQuery.isFetchNextPageError ? (
<SourcesTable
completingFilteredResults={completingFilteredResults}
ensureModelSetupReady={ensureSourceSyncReady}
filteredSources={filteredSources}
hasNextPage={sourcesQueryHasNextPage}
isFetchNextPageError={sourcesQueryFetchNextPageError}
sort={sort}
onSortChange={onSortChange}
/>
{sourcesQueryFetchNextPageError ? (
<div className="mt-5 flex items-center justify-center gap-3" role="alert">
<span className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.sourcesErrorDescription'])}
</span>
<Button onClick={() => void sourcesQuery.fetchNextPage()}>
<Button onClick={() => void fetchNextSourcePage()}>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
) : sourcesQuery.hasNextPage && !completingFilteredResults ? (
) : sourcesQueryHasNextPage && !completingFilteredResults ? (
<div className="mt-5 flex justify-center">
<Button
loading={sourcesQuery.isFetchingNextPage}
onClick={() => void sourcesQuery.fetchNextPage()}
loading={sourcesQueryFetchingNextPage}
onClick={() => void fetchNextSourcePage()}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
@ -563,3 +274,130 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
</div>
)
}
function SourcesTable({
completingFilteredResults,
ensureModelSetupReady,
filteredSources,
hasNextPage,
isFetchNextPageError,
onSortChange,
sort,
}: {
completingFilteredResults: boolean
ensureModelSetupReady: () => Promise<boolean>
filteredSources: Source[]
hasNextPage: boolean
isFetchNextPageError: boolean
onSortChange: (value: SourceSort) => void
sort: SourceSort
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(() => new Set())
const allFilteredSourcesSelected =
filteredSources.length > 0 &&
filteredSources.every((source) => selectedSourceIds.has(source.id))
const someFilteredSourcesSelected = filteredSources.some((source) =>
selectedSourceIds.has(source.id),
)
return (
<div className="mt-3 min-w-0">
<table className="relative block w-full text-left">
<thead className="absolute size-px overflow-hidden text-[11px] leading-4 font-medium tracking-[0.3px] whitespace-nowrap text-text-tertiary uppercase [clip:rect(0,0,0,0)] @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:block @min-[768px]/knowledge-content:size-auto @min-[768px]/knowledge-content:overflow-visible @min-[768px]/knowledge-content:whitespace-normal @min-[768px]/knowledge-content:[clip:auto]">
<tr className={cn(sourceTableGridClass, 'py-2.5')}>
<th className="flex items-center">
<Checkbox
aria-label={tCommon(($) => $['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
})
}}
/>
</th>
<th
aria-sort={
sort === 'name-asc' ? 'ascending' : sort === 'name-desc' ? 'descending' : 'none'
}
className="min-w-0 @min-[768px]/knowledge-content:col-start-2 @min-[960px]/knowledge-content:col-start-auto"
>
<Button
variant="ghost"
size="small"
onClick={() => onSortChange(sort === 'name-asc' ? 'name-desc' : 'name-asc')}
className="h-auto gap-1 rounded px-0 text-[11px] leading-4 font-medium tracking-[0.3px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{t(($) => $['newKnowledge.sourceColumn'])}
{sort && (
<span
aria-hidden
className={cn(
'size-3.5',
sort === 'name-desc' ? 'i-ri-arrow-down-line' : 'i-ri-arrow-up-line',
)}
/>
)}
</Button>
</th>
<th className="hidden min-w-0 @min-[960px]/knowledge-content:block">
{t(($) => $['metadata.createMetadata.type'])}
</th>
<th className="min-w-0 @min-[768px]/knowledge-content:col-start-3 @min-[960px]/knowledge-content:col-start-auto">
{t(($) => $['newKnowledge.statusColumn'])}
</th>
<th className="hidden min-w-0 @min-[960px]/knowledge-content:block">
{t(($) => $['newKnowledge.syncPolicyColumn'])}
</th>
<th className="min-w-0 @min-[768px]/knowledge-content:col-start-4 @min-[960px]/knowledge-content:col-start-auto">
{t(($) => $['newKnowledge.lastSyncColumn'])}
</th>
<th
className="@min-[768px]/knowledge-content:col-start-5 @min-[960px]/knowledge-content:col-start-auto"
aria-label={t(($) => $['newKnowledge.actionsColumn'])}
/>
</tr>
</thead>
<tbody className="block space-y-2 @min-[768px]/knowledge-content:space-y-0">
{filteredSources.map((source) => (
<SourceRow
key={source.id}
checked={selectedSourceIds.has(source.id)}
ensureModelSetupReady={ensureModelSetupReady}
source={source}
onCheckedChange={(checked) => {
setSelectedSourceIds((current) => {
const next = new Set(current)
if (checked) next.add(source.id)
else next.delete(source.id)
return next
})
}}
/>
))}
</tbody>
</table>
{!filteredSources.length &&
!hasNextPage &&
!completingFilteredResults &&
!isFetchNextPageError && (
<p className="py-16 text-center body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.noMatchingSources'])}
</p>
)}
{!filteredSources.length && completingFilteredResults && (
<div className="flex min-h-40 items-center justify-center">
<Loading />
</div>
)}
</div>
)
}

View File

@ -0,0 +1,52 @@
'use client'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect } from 'react'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { isInitialSourceForOperation } from './source-models'
import {
currentSourcesAtom,
fetchNextSourcePageAtom,
markSourcePollingTimedOutAtom,
shouldAutoLoadNextSourcePageAtom,
sourcesAwaitedOperationIdAtom,
sourcesPollingPhaseAtom,
} from './state'
const INITIAL_SOURCE_POLL_TIMEOUT = 10 * 60 * 1000
export function SourcesRuntimeController() {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const awaitedOperationId = useAtomValue(sourcesAwaitedOperationIdAtom)
const currentSources = useAtomValue(currentSourcesAtom)
const pollingPhase = useAtomValue(sourcesPollingPhaseAtom)
const shouldAutoLoadNextSourcePage = useAtomValue(shouldAutoLoadNextSourcePageAtom)
const fetchNextSourcePage = useSetAtom(fetchNextSourcePageAtom)
const markPollingTimedOut = useSetAtom(markSourcePollingTimedOutAtom)
useEffect(() => {
if (
awaitedOperationId &&
currentSources.some((source) => isInitialSourceForOperation(source, awaitedOperationId))
) {
const nextSearchParams = new URLSearchParams(searchParams)
nextSearchParams.delete('awaitInitialSource')
const queryString = nextSearchParams.toString()
router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
}
}, [awaitedOperationId, currentSources, pathname, router, searchParams])
useEffect(() => {
if (pollingPhase !== 'awaiting' && pollingPhase !== 'initializing') return
const timeout = globalThis.setTimeout(markPollingTimedOut, INITIAL_SOURCE_POLL_TIMEOUT)
return () => globalThis.clearTimeout(timeout)
}, [awaitedOperationId, markPollingTimedOut, pollingPhase])
useEffect(() => {
if (shouldAutoLoadNextSourcePage) void fetchNextSourcePage()
}, [fetchNextSourcePage, shouldAutoLoadNextSourcePage])
return null
}

View File

@ -5,6 +5,7 @@ import type {
KnowledgeFsInitialSourcePreviewFileResponse,
KnowledgeFsSpaceCreatePayload,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { ReactNode } from 'react'
import type { InstalledSourceProviderOption } from './provider-options'
import type {
NewKnowledgeOnlineDocumentsSourceDraft,
@ -28,6 +29,7 @@ import { SourceNameField, SourceSyncPolicyField } from './fields'
type ConnectedDraft = NewKnowledgeOnlineDocumentsSourceDraft | NewKnowledgeOnlineDriveSourceDraft
type InitialSource = NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
type ConnectedInitialSource = Extract<InitialSource, { kind: 'online_document' | 'online_drive' }>
type PreviewDocument = KnowledgeFsInitialSourcePreviewDocumentResponse
type PreviewFile = KnowledgeFsInitialSourcePreviewFileResponse
type PreviewResource =
@ -75,11 +77,75 @@ function resourceIcon(resource: PreviewResource) {
: 'i-ri-file-3-line text-text-tertiary'
}
export function ConnectedSourceConfiguration({
type ConnectedSourceConfigurationProps = {
disabled: boolean
draft: ConnectedDraft
previewBinding: ConnectedSourceConfigurationBinding
providerOption: InstalledSourceProviderOption
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onInitialSourceChange: (source?: InitialSource) => void
}
export function ConnectedSourceConfiguration(props: ConnectedSourceConfigurationProps) {
return <ConnectedSourceConfigurationFields {...props} />
}
export function ConnectedSourceEditForm({
disabled,
initialDraft,
previewBinding,
providerOption,
onCancel,
onSubmit,
}: {
disabled: boolean
initialDraft: ConnectedDraft
previewBinding: ConnectedSourceConfigurationBinding
providerOption: InstalledSourceProviderOption
onCancel: () => void
onSubmit: (source: ConnectedInitialSource) => Promise<boolean>
}) {
const { t: tCommon } = useTranslation('common')
const [draft, setDraft] = useState(initialDraft)
return (
<ConnectedSourceConfigurationFields
disabled={disabled}
draft={draft}
previewBinding={previewBinding}
providerOption={providerOption}
renderActions={(initialSource) => (
<div className="mt-6 flex justify-end gap-2">
<Button disabled={disabled} onClick={onCancel} type="button">
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
disabled={!initialSource}
loading={disabled}
type="button"
variant="primary"
onClick={() => {
if (initialSource && !disabled) void onSubmit(initialSource)
}}
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
)}
onDraftChange={(nextDraft) => {
if (nextDraft.sourceType === 'onlineDocuments' || nextDraft.sourceType === 'onlineDrive')
setDraft(nextDraft)
}}
/>
)
}
function ConnectedSourceConfigurationFields({
disabled,
draft,
previewBinding,
providerOption,
renderActions,
onDraftChange,
onInitialSourceChange,
}: {
@ -87,8 +153,9 @@ export function ConnectedSourceConfiguration({
draft: ConnectedDraft
previewBinding: ConnectedSourceConfigurationBinding
providerOption: InstalledSourceProviderOption
renderActions?: (source?: ConnectedInitialSource) => ReactNode
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onInitialSourceChange: (source?: InitialSource) => void
onInitialSourceChange?: (source?: InitialSource) => void
}) {
const { t } = useTranslation('dataset')
const { credentialId, datasource, pluginId, provider, providerDisplayName } = previewBinding
@ -134,13 +201,10 @@ export function ConnectedSourceConfiguration({
})
}, [expanded, resources])
useEffect(() => {
const initialSource = useMemo<ConnectedInitialSource | undefined>(() => {
const selectedResources = selectableResources.filter((resource) => selected.has(resource.key))
const name = draft.sourceName.trim()
if (!name || !selectedResources.length) {
onInitialSourceChange(undefined)
return
}
if (!name || !selectedResources.length) return undefined
const binding = {
credentialId,
datasource,
@ -150,7 +214,7 @@ export function ConnectedSourceConfiguration({
providerDisplayName,
}
if (!driveTransport) {
onInitialSourceChange({
return {
...binding,
kind: 'online_document',
name,
@ -172,10 +236,9 @@ export function ConnectedSourceConfiguration({
? { custom_interval_seconds: draft.customIntervalSeconds }
: {}),
sync_policy: draft.syncPolicy,
})
return
}
}
onInitialSourceChange({
return {
...binding,
kind: 'online_drive',
name,
@ -196,11 +259,10 @@ export function ConnectedSourceConfiguration({
? { custom_interval_seconds: draft.customIntervalSeconds }
: {}),
sync_policy: draft.syncPolicy,
})
}
}, [
draft,
driveTransport,
onInitialSourceChange,
parameters,
credentialId,
datasource,
@ -211,6 +273,10 @@ export function ConnectedSourceConfiguration({
selected,
])
useEffect(() => {
onInitialSourceChange?.(initialSource)
}, [initialSource, onInitialSourceChange])
const requestPreview = useCallback(
async ({
append = false,
@ -338,168 +404,178 @@ export function ConnectedSourceConfiguration({
const visibleNextPageRequests = [...nextPageRequests.entries()].filter(
([scope]) => scope === ROOT_PAGE_SCOPE || expanded.has(scope),
)
const handleDraftChange = (nextDraft: NewKnowledgeSourceDraft) => {
if (nextDraft.sourceType !== draft.sourceType) return
if (nextDraft.sourceType === 'onlineDocuments' || nextDraft.sourceType === 'onlineDrive')
onDraftChange(nextDraft)
}
return (
<div className="space-y-4">
<SourceNameField
disabled={disabled}
draft={draft}
preventSubmitOnEnter
onDraftChange={onDraftChange}
/>
<DatasourceParameterForm
disabled={disabled || loading}
parameters={parameters}
schemas={parameterSchemas}
onChange={(nextParameters) => {
setResources([])
setSelected(new Set())
setExpanded(new Set())
setNextPageRequests(new Map())
setPreviewed(false)
onDraftChange({ ...draft, parameters: nextParameters })
}}
/>
{!previewed && (
<Button
type="button"
variant="primary"
className="w-full"
loading={loading}
disabled={disabled || loading || !parametersValid}
onClick={() => void requestPreview()}
>
{t(($) => $['newKnowledge.preview'])}
</Button>
)}
{loading && (
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
<Loading />
</div>
)}
{error && (
<div role="alert" className="rounded-lg bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.providerLoadFailed'])}
</p>
<Button className="mt-3" onClick={() => void requestPreview()}>
{t(($) => $['newKnowledge.retryProviderLoad'])}
<>
<div className="space-y-4">
<SourceNameField
disabled={disabled}
draft={draft}
preventSubmitOnEnter
onDraftChange={handleDraftChange}
/>
<DatasourceParameterForm
disabled={disabled || loading}
parameters={parameters}
schemas={parameterSchemas}
onChange={(nextParameters) => {
setResources([])
setSelected(new Set())
setExpanded(new Set())
setNextPageRequests(new Map())
setPreviewed(false)
onDraftChange({ ...draft, parameters: nextParameters })
}}
/>
{!previewed && (
<Button
type="button"
variant="primary"
className="w-full"
loading={loading}
disabled={disabled || loading || !parametersValid}
onClick={() => void requestPreview()}
>
{t(($) => $['newKnowledge.preview'])}
</Button>
</div>
)}
{previewed && !loading && (
<section className="overflow-hidden rounded-lg border border-divider-subtle bg-background-default">
<div className="flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
<Checkbox
aria-label={t(($) => $['newKnowledge.selectAll'])}
aria-describedby={selectionAtLimit ? SELECTION_LIMIT_ID : undefined}
checked={
selectableResources.length > 0 &&
selectableResources.every((resource) => selected.has(resource.key))
}
disabled={
disabled ||
!selectableResources.length ||
(selectionAtLimit &&
!selectableResources.every((resource) => selected.has(resource.key)))
}
indeterminate={
selected.size > 0 &&
!selectableResources.every((resource) => selected.has(resource.key))
}
onCheckedChange={toggleAll}
/>
<span className="system-xs-medium text-text-secondary">
{draft.sourceType === 'onlineDocuments'
? t(($) => $['newKnowledge.selectPagesToSync'])
: t(($) => $['newKnowledge.selectFilesAndFolders'])}
</span>
<span role="status" className="ml-auto system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.pagesSelected'], { count: selected.size })}
{selectionAtLimit && (
<span id={SELECTION_LIMIT_ID} className="ml-2 text-text-destructive">
{t(($) => $['newKnowledge.maxPages'])}: {MAX_SELECTION}
</span>
)}
</span>
)}
{loading && (
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
<Loading />
</div>
<ul className="max-h-64 overflow-y-auto p-1.5">
{visibleResources.map((resource) => {
const container = resource.kind === 'file' && isDriveContainer(resource.file)
return (
<li
key={resource.key}
className="flex min-h-8 items-center gap-2 rounded-md px-2 hover:bg-state-base-hover"
style={{ paddingLeft: `${8 + resource.depth * 20}px` }}
>
{container ? (
<Button
type="button"
variant="ghost"
size="small"
className="size-5 px-0"
aria-label={resourceName(resource)}
aria-expanded={expanded.has(resource.key)}
disabled={disabled || loadingMore}
onClick={() => expandContainer(resource)}
>
<span
aria-hidden
className={`i-ri-arrow-right-s-line size-4 transition-transform motion-reduce:transition-none ${
expanded.has(resource.key) ? 'rotate-90' : ''
}`}
/>
</Button>
) : (
<Checkbox
aria-label={resourceName(resource)}
aria-describedby={
selectionAtLimit && !selected.has(resource.key)
? SELECTION_LIMIT_ID
: undefined
}
checked={selected.has(resource.key)}
disabled={disabled || (selectionAtLimit && !selected.has(resource.key))}
onCheckedChange={() => toggle(resource.key)}
/>
)}
<span aria-hidden className={`${resourceIcon(resource)} size-4 shrink-0`} />
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-primary">
{resourceName(resource)}
)}
{error && (
<div role="alert" className="rounded-lg bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.providerLoadFailed'])}
</p>
<Button className="mt-3" onClick={() => void requestPreview()}>
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
)}
{previewed && !loading && (
<section className="overflow-hidden rounded-lg border border-divider-subtle bg-background-default">
<div className="flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
<Checkbox
aria-label={t(($) => $['newKnowledge.selectAll'])}
aria-describedby={selectionAtLimit ? SELECTION_LIMIT_ID : undefined}
checked={
selectableResources.length > 0 &&
selectableResources.every((resource) => selected.has(resource.key))
}
disabled={
disabled ||
!selectableResources.length ||
(selectionAtLimit &&
!selectableResources.every((resource) => selected.has(resource.key)))
}
indeterminate={
selected.size > 0 &&
!selectableResources.every((resource) => selected.has(resource.key))
}
onCheckedChange={toggleAll}
/>
<span className="system-xs-medium text-text-secondary">
{draft.sourceType === 'onlineDocuments'
? t(($) => $['newKnowledge.selectPagesToSync'])
: t(($) => $['newKnowledge.selectFilesAndFolders'])}
</span>
<span role="status" className="ml-auto system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.pagesSelected'], { count: selected.size })}
{selectionAtLimit && (
<span id={SELECTION_LIMIT_ID} className="ml-2 text-text-destructive">
{t(($) => $['newKnowledge.maxPages'])}: {MAX_SELECTION}
</span>
</li>
)
})}
</ul>
{visibleNextPageRequests.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 border-t border-divider-subtle px-3 py-2 text-center">
{visibleNextPageRequests.map(([scope, request]) => {
const parent =
scope === ROOT_PAGE_SCOPE ? undefined : resources.find(({ key }) => key === scope)
)}
</span>
</div>
<ul className="max-h-64 overflow-y-auto p-1.5">
{visibleResources.map((resource) => {
const container = resource.kind === 'file' && isDriveContainer(resource.file)
return (
<Button
key={scope}
type="button"
size="small"
variant="ghost"
loading={loadingMore}
onClick={() => void requestPreview({ append: true, ...request })}
<li
key={resource.key}
className="flex min-h-8 items-center gap-2 rounded-md px-2 hover:bg-state-base-hover"
style={{ paddingLeft: `${8 + resource.depth * 20}px` }}
>
{t(($) => $['newKnowledge.loadMore'])}
{parent ? ` · ${resourceName(parent)}` : ''}
</Button>
{container ? (
<Button
type="button"
variant="ghost"
size="small"
className="size-5 px-0"
aria-label={resourceName(resource)}
aria-expanded={expanded.has(resource.key)}
disabled={disabled || loadingMore}
onClick={() => expandContainer(resource)}
>
<span
aria-hidden
className={`i-ri-arrow-right-s-line size-4 transition-transform motion-reduce:transition-none ${
expanded.has(resource.key) ? 'rotate-90' : ''
}`}
/>
</Button>
) : (
<Checkbox
aria-label={resourceName(resource)}
aria-describedby={
selectionAtLimit && !selected.has(resource.key)
? SELECTION_LIMIT_ID
: undefined
}
checked={selected.has(resource.key)}
disabled={disabled || (selectionAtLimit && !selected.has(resource.key))}
onCheckedChange={() => toggle(resource.key)}
/>
)}
<span aria-hidden className={`${resourceIcon(resource)} size-4 shrink-0`} />
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-primary">
{resourceName(resource)}
</span>
</li>
)
})}
</div>
)}
</section>
)}
<SourceSyncPolicyField
disabled={disabled}
draft={draft}
size="medium"
onDraftChange={onDraftChange}
/>
</div>
</ul>
{visibleNextPageRequests.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 border-t border-divider-subtle px-3 py-2 text-center">
{visibleNextPageRequests.map(([scope, request]) => {
const parent =
scope === ROOT_PAGE_SCOPE
? undefined
: resources.find(({ key }) => key === scope)
return (
<Button
key={scope}
type="button"
size="small"
variant="ghost"
loading={loadingMore}
onClick={() => void requestPreview({ append: true, ...request })}
>
{t(($) => $['newKnowledge.loadMore'])}
{parent ? ` · ${resourceName(parent)}` : ''}
</Button>
)
})}
</div>
)}
</section>
)}
<SourceSyncPolicyField
disabled={disabled}
draft={draft}
size="medium"
onDraftChange={handleDraftChange}
/>
</div>
{renderActions?.(initialSource)}
</>
)
}

View File

@ -11,6 +11,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -20,43 +21,195 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleClient, consoleQuery } from '@/service/client'
import { useKnowledgeSpacePermission } from '../space/context'
import { SourceEditDialog } from './source-edit-dialog'
import { getOpenableSourceUri } from './source-list-model'
import { createIdempotencyKey, getOpenableSourceUri } from './source-list-model'
import {
initialSourceWorkflowId,
sourceAsyncImportWorkflowId,
sourceDisplayStatus,
sourceFromApi,
sourceStatusWithSyncWorkflow,
sourceWorkflowFromApi,
sourceWorkflowIsActive,
} from './source-models'
import {
acceptSourceSnapshotAtom,
removeSourceFromListAtom,
sourcesKnowledgeSpaceIdAtom,
} from './state'
export function SourceActions({
canEdit,
canRemove,
canSync,
canToggle,
onEdit,
onRemove,
onSync,
onToggle,
pendingAction,
ensureModelSetupReady,
source,
syncAction,
}: {
canEdit: boolean
canRemove: boolean
canSync: boolean
canToggle: boolean
onEdit: (values: SourceEditValues) => Promise<boolean>
onRemove: () => Promise<boolean>
onSync: () => Promise<boolean>
onToggle: () => Promise<boolean>
pendingAction?: SourceAction
ensureModelSetupReady: () => Promise<boolean>
source: Source
syncAction: 'retry' | 'sync'
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const knowledgeSpaceId = useAtomValue(sourcesKnowledgeSpaceIdAtom)
const acceptSourceSnapshot = useSetAtom(acceptSourceSnapshotAtom)
const removeSourceFromList = useSetAtom(removeSourceFromListAtom)
const canManageSources = useKnowledgeSpacePermission('knowledge_space_document_write')
const [pendingAction, setPendingAction] = useState<SourceAction>()
const [menuOpen, setMenuOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
const syncWorkflow = source.syncWorkflow
const displayStatus = sourceDisplayStatus(source)
const initializing = displayStatus === 'initializing'
const initialWorkflowId = initialSourceWorkflowId(source)
const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing'
const canEdit = canManageSources && !initializing && !initialWorkflowId
const canRemove = canManageSources && !initializing && !initialImportRetrying
const canSync = canManageSources && !initializing && displayStatus !== 'syncing'
const canToggle = canManageSources && !initializing && !initialWorkflowId
const syncAction = displayStatus === 'error' ? 'retry' : 'sync'
const sourceUri = getOpenableSourceUri(source.uri)
const runAction = async <Result,>(
action: SourceAction,
mutation: () => Promise<Result>,
onAccepted?: (result: Result) => void,
beforeAction?: () => Promise<boolean>,
) => {
if (pendingAction) return false
setPendingAction(action)
try {
if (beforeAction && !(await beforeAction())) return false
let result: Result
try {
result = await mutation()
} catch {
toast.error(t(($) => $['newKnowledge.sourcesErrorDescription']))
try {
await queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
})
} catch {
return false
}
return false
}
onAccepted?.(result)
try {
await queryClient.invalidateQueries(
{
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
},
{ throwOnError: true },
)
} catch {
// The accepted mutation is already reflected by the feature graph.
}
return true
} finally {
setPendingAction(undefined)
}
}
const applyAcceptedWorkflow = (workflow: Parameters<typeof sourceWorkflowFromApi>[0]) => {
const run = sourceWorkflowFromApi(workflow)
acceptSourceSnapshot({
...source,
syncWorkflow: run,
status: sourceStatusWithSyncWorkflow(source.status, run),
})
}
const syncSource = () =>
runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
const retrySource = () => {
const retryWorkflowId =
initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id
if (!retryWorkflowId) return syncSource()
return runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({
params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
}
const toggleSource = () =>
runAction(
'toggle',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: {
...(source.version === undefined ? {} : { expectedVersion: source.version }),
status: source.status === 'disabled' ? 'active' : 'disabled',
},
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
),
(updatedSource) => {
const nextSyncWorkflow =
updatedSource.syncWorkflow ??
(sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined)
acceptSourceSnapshot({
...updatedSource,
lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(updatedSource.status, nextSyncWorkflow),
syncWorkflow: nextSyncWorkflow,
syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy,
})
},
)
const editSource = (values: SourceEditValues) =>
runAction(
'edit',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: values,
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
{ useResponseStatus: true },
),
acceptSourceSnapshot,
)
const removeSource = () =>
runAction(
'remove',
async () => {
if (source.version === undefined) throw new Error('Source version is required')
return consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.delete({
body: { expectedRevision: source.version },
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
query: { documents: 'keep' },
})
},
() => removeSourceFromList(source.id),
)
const openEditDialog = () => {
setMenuOpen(false)
setEditDialogOpen(true)
@ -66,6 +219,18 @@ export function SourceActions({
return (
<>
{canSync && displayStatus === 'error' && (
<Button
className="@min-[768px]/knowledge-content:hidden @min-[1280px]/knowledge-content:inline-flex"
size="small"
variant="secondary"
loading={pendingAction === 'sync'}
disabled={Boolean(pendingAction)}
onClick={() => void retrySource()}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t(($) => $['newKnowledge.sourceActions'], { name: source.name })}
@ -83,7 +248,7 @@ export function SourceActions({
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-[200px]">
{canSync && (
<DropdownMenuItem
onClick={() => void onSync()}
onClick={() => void (syncAction === 'retry' ? retrySource() : syncSource())}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-refresh-line size-4" />
@ -119,7 +284,7 @@ export function SourceActions({
)}
{canToggle && (
<DropdownMenuItem
onClick={() => void onToggle()}
onClick={() => void toggleSource()}
className="h-7 gap-2 px-2 system-sm-medium"
>
<span
@ -157,7 +322,7 @@ export function SourceActions({
</DropdownMenuContent>
</DropdownMenu>
<SourceEditDialog
onEdit={onEdit}
onEdit={editSource}
onOpenChange={setEditDialogOpen}
open={editDialogOpen}
pending={pendingAction === 'edit'}
@ -181,7 +346,7 @@ export function SourceActions({
tone="destructive"
loading={pendingAction === 'remove'}
onClick={() =>
void onRemove().then((removed) => {
void removeSource().then((removed) => {
if (removed) setRemoveDialogOpen(false)
})
}

View File

@ -8,7 +8,6 @@ import type {
import type {
NewKnowledgeOnlineDocumentsSourceDraft,
NewKnowledgeOnlineDriveSourceDraft,
NewKnowledgeSourceDraft,
} from './setup/source-draft'
import type { SourceEditValues } from './source-list-model'
import type { CrawlPreviewPage, Source, SourceSyncPolicy } from './source-models'
@ -17,11 +16,11 @@ import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Input } from '@langgenius/dify-ui/input'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleClient, consoleQuery } from '@/service/client'
import { useDataSourceList } from '@/service/use-pipeline'
import { ConnectedSourceConfiguration } from './setup/connected-source-configuration'
import { ConnectedSourceEditForm } from './setup/connected-source-configuration'
import { CrawlPreviewPageSelection } from './setup/crawl-selection'
import { WebsiteDatasourceParameterForm } from './setup/datasource-parameter-form'
import {
@ -137,29 +136,39 @@ export function SourceEditDialog({
pending: boolean
source: Source
}) {
const connectedDraft = connectedDraftFromSource(source)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open && (
<DialogContent
className={
connectedDraft
? 'max-h-[calc(100vh-2rem)] w-180! max-w-[calc(100vw-2rem)]! overflow-y-auto'
: 'w-160! max-w-[calc(100vw-2rem)]!'
}
>
<SourceEditDialogContent
connectedDraft={connectedDraft}
onEdit={onEdit}
onOpenChange={onOpenChange}
pending={pending}
source={source}
/>
)}
</DialogContent>
</Dialog>
)
}
function SourceEditDialogContent(props: {
connectedDraft?: ConnectedSourceDraft
onEdit: (values: SourceEditValues) => Promise<boolean>
onOpenChange: (open: boolean) => void
pending: boolean
source: Source
}) {
const draft = connectedDraftFromSource(props.source)
if (draft) return <ConnectedSourceEditDialogContent {...props} initialDraft={draft} />
return <StandardSourceEditDialogContent {...props} />
if (props.connectedDraft)
return <ConnectedSourceEditDialogContent {...props} initialDraft={props.connectedDraft} />
if (props.source.type === 'web') return <WebsiteSourceEditDialogContent {...props} />
return <BasicSourceEditDialogContent {...props} />
}
function ConnectedSourceEditDialogContent({
@ -177,8 +186,6 @@ function ConnectedSourceEditDialogContent({
}) {
const { t: tCommon } = useTranslation('common')
const { t } = useTranslation('dataset')
const [draft, setDraft] = useState(initialDraft)
const [submission, setSubmission] = useState<ConnectedInitialSource>()
const datasourcePluginsQuery = useDataSourceList(true)
const {
data: connectionsData,
@ -204,10 +211,10 @@ function ConnectedSourceEditDialogContent({
}),
)
const providerOptions = useMemo(
() => discoverSourceProviderOptions(draft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, draft.sourceType],
() => discoverSourceProviderOptions(initialDraft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, initialDraft.sourceType],
)
const normalizedProviderName = normalizeSourceProviderName(draft.provider)
const normalizedProviderName = normalizeSourceProviderName(initialDraft.provider)
const providerOption = providerOptions.find(
(option) => normalizeSourceProviderName(option.label) === normalizedProviderName,
)
@ -238,32 +245,19 @@ function ConnectedSourceEditDialogContent({
: undefined,
[bindingReady, credentialId, datasource, installedProviderOption, pluginId, provider],
)
const handleDraftChange = useCallback(
(nextDraft: NewKnowledgeSourceDraft) => {
if (nextDraft.sourceType !== initialDraft.sourceType) return
setDraft(nextDraft)
setSubmission(undefined)
},
[initialDraft.sourceType],
)
const handleInitialSourceChange = useCallback((value?: InitialSource) => {
if (value?.kind === 'online_document' || value?.kind === 'online_drive') setSubmission(value)
else setSubmission(undefined)
}, [])
useEffect(() => {
if (source.connectionId && !connection && hasNextPage && !isFetchingNextPage)
void fetchNextPage()
}, [connection, fetchNextPage, hasNextPage, isFetchingNextPage, source.connectionId])
const submitEdit = async () => {
if (!submission || pending) return
const submitEdit = async (submission: ConnectedInitialSource) => {
if (pending) return false
const syncPolicy =
draft.syncPolicy === 'manual'
submission.sync_policy === 'manual'
? ({ enabled: false, mode: 'manual' } as const)
: draft.syncPolicy === 'custom'
: submission.sync_policy === 'custom'
? ({
customIntervalSeconds: draft.customIntervalSeconds,
customIntervalSeconds: submission.custom_interval_seconds,
enabled: true,
mode: 'custom',
} as const)
@ -279,6 +273,7 @@ function ConnectedSourceEditDialogContent({
syncPolicy,
})
if (accepted) onOpenChange(false)
return accepted
}
const loading = datasourcePluginsQuery.isPending || connectionsPending
@ -289,60 +284,148 @@ function ConnectedSourceEditDialogContent({
(!loading && !bindingReady)
return (
<DialogContent className="max-h-[calc(100vh-2rem)] w-180! max-w-[calc(100vw-2rem)]! overflow-y-auto">
<form
onSubmit={(event) => {
event.preventDefault()
void submitEdit()
}}
>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.edit'])} {source.name}
</DialogTitle>
<div className="mt-5">
{loading ? (
<div role="status" aria-label={tCommon(($) => $.loading)} className="py-16 text-center">
<span
aria-hidden
className="i-ri-loader-4-line inline-block size-5 animate-spin text-text-tertiary"
/>
</div>
) : unavailable || !installedProviderOption || !previewBinding ? (
<p role="alert" className="py-8 system-sm-regular text-text-destructive">
{datasourcePluginsQuery.isError || connectionsError
? t(($) => $['newKnowledge.providerLoadFailed'])
: t(($) => $['newKnowledge.providerUnavailable'])}
</p>
) : (
<ConnectedSourceConfiguration
disabled={pending}
draft={draft}
previewBinding={previewBinding}
providerOption={installedProviderOption}
onDraftChange={handleDraftChange}
onInitialSourceChange={handleInitialSourceChange}
<>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.edit'])} {source.name}
</DialogTitle>
<div className="mt-5">
{loading ? (
<div role="status" aria-label={tCommon(($) => $.loading)} className="py-16 text-center">
<span
aria-hidden
className="i-ri-loader-4-line inline-block size-5 animate-spin text-text-tertiary"
/>
)}
</div>
</div>
) : unavailable || !installedProviderOption || !previewBinding ? (
<p role="alert" className="py-8 system-sm-regular text-text-destructive">
{datasourcePluginsQuery.isError || connectionsError
? t(($) => $['newKnowledge.providerLoadFailed'])
: t(($) => $['newKnowledge.providerUnavailable'])}
</p>
) : (
<ConnectedSourceEditForm
disabled={pending}
initialDraft={initialDraft}
previewBinding={previewBinding}
providerOption={installedProviderOption}
onCancel={() => onOpenChange(false)}
onSubmit={submitEdit}
/>
)}
</div>
{(loading || unavailable) && (
<div className="mt-6 flex justify-end gap-2">
<Button disabled={pending} onClick={() => onOpenChange(false)} type="button">
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
disabled={!submission || unavailable}
loading={pending}
type="submit"
variant="primary"
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</form>
</DialogContent>
)}
</>
)
}
function StandardSourceEditDialogContent({
function BasicSourceEditDialogContent({
onEdit,
onOpenChange,
pending,
source,
}: {
onEdit: (values: SourceEditValues) => Promise<boolean>
onOpenChange: (open: boolean) => void
pending: boolean
source: Source
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const [initialSource] = useState(source)
const [nextName, setNextName] = useState(initialSource.name)
const [nextSyncMode, setNextSyncMode] = useState<SourceSyncPolicy['mode']>(() =>
sourceSyncMode(initialSource),
)
const [nextCustomIntervalHours, setNextCustomIntervalHours] = useState<number | ''>(() =>
sourceCustomIntervalHours(initialSource),
)
const customIntervalValid =
typeof nextCustomIntervalHours === 'number' &&
Number.isInteger(nextCustomIntervalHours) &&
nextCustomIntervalHours >= MIN_CUSTOM_INTERVAL_HOURS &&
nextCustomIntervalHours <= MAX_CUSTOM_INTERVAL_HOURS
const submitEdit = async () => {
const name = nextName.trim()
if (!name || !customIntervalValid || pending) return
if (
await onEdit({
expectedVersion: initialSource.version,
name,
syncPolicy: syncPolicyConfiguration(nextSyncMode, nextCustomIntervalHours as number),
})
)
onOpenChange(false)
}
return (
<form
onSubmit={(event) => {
event.preventDefault()
void submitEdit()
}}
>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.edit'])} {initialSource.name}
</DialogTitle>
<div className="mt-5">
<Field name="sourceName" className="gap-1.5">
<FieldLabel htmlFor={`source-name-${initialSource.id}`}>
{t(($) => $['newKnowledge.sourceName'])}
</FieldLabel>
<Input
id={`source-name-${initialSource.id}`}
autoComplete="off"
disabled={pending}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={nextName}
onChange={(event) => setNextName(event.target.value)}
/>
</Field>
</div>
<div className="mt-4">
<SyncPolicyField
disabled={pending}
label
triggerClassName="w-full"
value={{
customIntervalSeconds:
typeof nextCustomIntervalHours === 'number'
? nextCustomIntervalHours * 3600
: undefined,
mode: nextSyncMode,
}}
onChange={(value) => {
setNextSyncMode(value.mode)
if (value.customIntervalSeconds)
setNextCustomIntervalHours(value.customIntervalSeconds / 3600)
}}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button disabled={pending} onClick={() => onOpenChange(false)} type="button">
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
disabled={!nextName.trim() || !customIntervalValid}
loading={pending}
type="submit"
variant="primary"
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</form>
)
}
function WebsiteSourceEditDialogContent({
onEdit,
onOpenChange,
pending,
@ -587,7 +670,7 @@ function StandardSourceEditDialogContent({
)
return (
<DialogContent className="w-160! max-w-[calc(100vw-2rem)]!">
<>
<form
onSubmit={(event) => {
event.preventDefault()
@ -712,6 +795,6 @@ function StandardSourceEditDialogContent({
</Button>
</div>
</form>
</DialogContent>
</>
)
}

View File

@ -1,40 +1,25 @@
'use client'
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type { SourceAction, SourceEditValues } from './source-list-model'
import type { Source, SourceDisplayStatus } from './source-models'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { consoleClient, consoleQuery } from '@/service/client'
import { knowledgeFsTaskFailureMessageKey } from '../knowledge-fs-task-error'
import { SourceProviderIcon } from './setup/fields'
import { SourceActions } from './source-actions'
import { sourceTableGridClass } from './source-list-layout'
import {
createIdempotencyKey,
metadataString,
sourceLastSyncAt,
sourceProviderDetails,
sourceSyncPolicyTranslationKey,
} from './source-list-model'
import {
initialSourceWorkflowId,
sourceAsyncImportWorkflowId,
sourceDisplayStatus,
sourceFromApi,
sourceStatusWithSyncWorkflow,
sourceWorkflowFromApi,
sourceWorkflowIsActive,
} from './source-models'
import { sourceDisplayStatus } from './source-models'
const statusDotStatus: Record<SourceDisplayStatus, StatusDotStatus> = {
active: 'success',
@ -56,36 +41,21 @@ function TruncatedSourceValue({ children, className }: { children: string; class
}
export function SourceRow({
canEdit,
canSync,
checked,
ensureModelSetupReady,
knowledgeSpaceId,
onCheckedChange,
onRemoved,
onSourceChange,
source,
}: {
canEdit: boolean
canSync: boolean
checked: boolean
ensureModelSetupReady: () => Promise<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 { formatTimeFromNow } = useFormatTimeFromNow()
const queryClient = useQueryClient()
const [pendingAction, setPendingAction] = useState<SourceAction>()
const syncWorkflow = source.syncWorkflow
const displayStatus = sourceDisplayStatus(source)
const initializing = displayStatus === 'initializing'
const initialWorkflowId = initialSourceWorkflowId(source)
const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing'
const provider = sourceProviderDetails(source)
const providerName = provider.name
@ -120,143 +90,6 @@ export function SourceRow({
const sourceIcon =
provider.iconClass ?? (source.type === 'web' ? 'i-ri-global-line' : 'i-ri-links-line')
const runAction = async <Result,>(
action: SourceAction,
mutation: () => Promise<Result>,
onAccepted?: (result: Result) => void,
beforeAction?: () => Promise<boolean>,
) => {
if (pendingAction) return false
setPendingAction(action)
try {
if (beforeAction && !(await beforeAction())) return false
let result: Result
try {
result = await mutation()
} catch {
toast.error(t(($) => $['newKnowledge.sourcesErrorDescription']))
try {
await queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
})
} catch {
return false
}
return false
}
onAccepted?.(result)
try {
await queryClient.invalidateQueries(
{
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
},
{
throwOnError: true,
},
)
} catch {
// The accepted mutation is already reflected by the list-owner state.
}
return true
} finally {
setPendingAction(undefined)
}
}
const applyAcceptedWorkflow = (workflow: Parameters<typeof sourceWorkflowFromApi>[0]) => {
const run = sourceWorkflowFromApi(workflow)
onSourceChange({
...source,
syncWorkflow: run,
status: sourceStatusWithSyncWorkflow(source.status, run),
})
}
const syncSource = () =>
runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
const retrySource = () => {
const retryWorkflowId =
initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id
if (!retryWorkflowId) return syncSource()
return runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({
params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
}
const toggleSource = () =>
runAction(
'toggle',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: {
...(source.version === undefined ? {} : { expectedVersion: source.version }),
status: source.status === 'disabled' ? 'active' : 'disabled',
},
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
),
(updatedSource) => {
const syncWorkflow =
updatedSource.syncWorkflow ??
(sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined)
onSourceChange({
...updatedSource,
lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(updatedSource.status, syncWorkflow),
syncWorkflow,
syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy,
})
},
)
const editSource = (values: SourceEditValues) =>
runAction(
'edit',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: values,
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
{ useResponseStatus: true },
),
onSourceChange,
)
const removeSource = () =>
runAction(
'remove',
async () => {
if (source.version === undefined) throw new Error('Source version is required')
return consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.delete({
body: { expectedRevision: source.version },
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
query: { documents: 'keep' },
})
},
onRemoved,
)
return (
<tr
className={cn(
@ -361,31 +194,7 @@ export function SourceRow({
</td>
<td className="absolute top-2.5 right-2.5 text-right @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:col-start-5 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<div className="flex items-center justify-end gap-0.5">
{canSync && displayStatus === 'error' && (
<Button
className="@min-[768px]/knowledge-content:hidden @min-[1280px]/knowledge-content:inline-flex"
size="small"
variant="secondary"
loading={pendingAction === 'sync'}
disabled={Boolean(pendingAction)}
onClick={() => void retrySource()}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
<SourceActions
canEdit={canEdit && !initializing && !initialWorkflowId}
canRemove={canEdit && !initializing && !initialImportRetrying}
canSync={canSync && !initializing && displayStatus !== 'syncing'}
canToggle={canEdit && !initializing && !initialWorkflowId}
source={source}
pendingAction={pendingAction}
onEdit={editSource}
onSync={displayStatus === 'error' ? retrySource : syncSource}
onToggle={toggleSource}
onRemove={removeSource}
syncAction={displayStatus === 'error' ? 'retry' : 'sync'}
/>
<SourceActions source={source} ensureModelSetupReady={ensureModelSetupReady} />
</div>
</td>
</tr>

View File

@ -0,0 +1,81 @@
'use client'
import type { ReactNode } from 'react'
import type { SourceFilter } from './source-list-query-state'
import type { SourceSort } from './state'
import { ScopeProvider } from 'jotai-scope'
import { useHydrateAtoms } from 'jotai/utils'
import {
sourcesAwaitedOperationIdAtom,
sourcesFilterAtom,
sourcesKnowledgeSpaceIdAtom,
sourcesSearchAtom,
sourcesSessionAtoms,
sourcesSortAtom,
} from './state'
function SourcesExternalInputsBridge({
awaitedOperationId,
children,
filter,
search,
sort,
}: {
awaitedOperationId: string | null
children: ReactNode
filter: SourceFilter
search: string
sort: SourceSort
}) {
useHydrateAtoms(
[
[sourcesFilterAtom, filter],
[sourcesSearchAtom, search],
[sourcesSortAtom, sort],
[sourcesAwaitedOperationIdAtom, awaitedOperationId],
],
{ dangerouslyForceHydrate: true },
)
return children
}
export function SourcesStateBoundary({
awaitedOperationId,
children,
filter,
knowledgeSpaceId,
search,
sort,
}: {
awaitedOperationId: string | null
children: ReactNode
filter: SourceFilter
knowledgeSpaceId: string
search: string
sort: SourceSort
}) {
return (
<ScopeProvider
key={knowledgeSpaceId}
atoms={[
[sourcesKnowledgeSpaceIdAtom, knowledgeSpaceId],
[sourcesFilterAtom, filter],
[sourcesSearchAtom, search],
[sourcesSortAtom, sort],
[sourcesAwaitedOperationIdAtom, awaitedOperationId],
...sourcesSessionAtoms,
]}
name="SourcesPage"
>
<SourcesExternalInputsBridge
awaitedOperationId={awaitedOperationId}
filter={filter}
search={search}
sort={sort}
>
{children}
</SourcesExternalInputsBridge>
</ScopeProvider>
)
}

View File

@ -0,0 +1,276 @@
import type { SourceFilter } from './source-list-query-state'
import type { Source } from './source-models'
import { atom } from 'jotai'
import { atomWithInfiniteQuery } from 'jotai-tanstack-query'
import { atomWithLazy, selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import {
initialSourcePollingPhase,
shouldHidePreviewSource,
sourceDisplayStatus,
sourceFromApi,
sourceNeedsPolling,
sourceStatusWithSyncWorkflow,
sourceWorkflowIsActive,
} from './source-models'
export type SourceSort = 'name-asc' | 'name-desc' | null
const PAGE_SIZE = 200
export const MAX_AUTO_CURSOR_PAGES = 5
const AWAIT_INITIAL_SOURCE_POLL_INTERVAL = 2000
const SOURCE_POLL_INTERVAL = 3000
export const sourcesKnowledgeSpaceIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing Sources knowledge space id')
})
export const sourcesFilterAtom = atom<SourceFilter>('all')
export const sourcesSearchAtom = atom('')
export const sourcesSortAtom = atom<SourceSort>(null)
export const sourcesAwaitedOperationIdAtom = atom<string | null>(null)
export const sourceOverridesAtom = atom<Record<string, Source>>({})
export const removedSourceIdsAtom = atom<Set<string>>(new Set<string>())
export const sourcePollingTimeoutAtom = atom<{
awaitedOperationId: string | null
timedOut: boolean
}>({ awaitedOperationId: null, timedOut: false })
function latestSourceWorkflow(
sourceWorkflow?: Source['syncWorkflow'],
sourceOverrideWorkflow?: Source['syncWorkflow'],
) {
if (!sourceWorkflow || !sourceOverrideWorkflow) return sourceWorkflow ?? sourceOverrideWorkflow
if (sourceWorkflow.id === sourceOverrideWorkflow.id) {
if (sourceWorkflow.executionAttempts !== sourceOverrideWorkflow.executionAttempts)
return sourceWorkflow.executionAttempts > sourceOverrideWorkflow.executionAttempts
? sourceWorkflow
: sourceOverrideWorkflow
return sourceWorkflow.updatedAt >= sourceOverrideWorkflow.updatedAt
? sourceWorkflow
: sourceOverrideWorkflow
}
const sourceWorkflowIsRunning = sourceWorkflowIsActive(sourceWorkflow)
const sourceOverrideWorkflowIsRunning = sourceWorkflowIsActive(sourceOverrideWorkflow)
if (sourceWorkflowIsRunning && !sourceOverrideWorkflowIsRunning) return sourceWorkflow
const createdAtComparison = sourceWorkflow.createdAt.localeCompare(
sourceOverrideWorkflow.createdAt,
)
if (createdAtComparison !== 0)
return createdAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
const updatedAtComparison = sourceWorkflow.updatedAt.localeCompare(
sourceOverrideWorkflow.updatedAt,
)
if (updatedAtComparison !== 0)
return updatedAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
return sourceWorkflow.id > sourceOverrideWorkflow.id ? sourceWorkflow : sourceOverrideWorkflow
}
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
const overrideHasNewerSource =
sourceVersion < overrideVersion || source.updatedAt < sourceOverride.updatedAt
const sourceHasNewerSource =
sourceVersion === overrideVersion && source.updatedAt > sourceOverride.updatedAt
if (sourceHasNewerSource) return source
const syncWorkflow = overrideHasNewerSource
? sourceOverride.syncWorkflow
: latestSourceWorkflow(source.syncWorkflow, sourceOverride.syncWorkflow)
if (
!overrideHasNewerSource &&
source.syncWorkflow &&
source.syncWorkflow.id !== sourceOverride.syncWorkflow?.id &&
syncWorkflow === source.syncWorkflow
)
return source
return {
...sourceOverride,
lastSyncedAt: source.lastSyncedAt ?? sourceOverride.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(sourceOverride.status, syncWorkflow),
syncWorkflow,
syncPolicy: overrideHasNewerSource
? (sourceOverride.syncPolicy ?? source.syncPolicy)
: (source.syncPolicy ?? sourceOverride.syncPolicy),
}
}
const sourcePollingTimedOutAtom = atom((get) => {
const timeout = get(sourcePollingTimeoutAtom)
return timeout.timedOut && timeout.awaitedOperationId === get(sourcesAwaitedOperationIdAtom)
})
const sourcesQueryAtom = atomWithInfiniteQuery((get) => {
const knowledgeSpaceId = get(sourcesKnowledgeSpaceIdAtom)
const removedSourceIds = get(removedSourceIdsAtom)
const sourceOverrides = get(sourceOverridesAtom)
const awaitedOperationId = get(sourcesAwaitedOperationIdAtom)
const pollingTimedOut = get(sourcePollingTimedOutAtom)
return consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.infiniteOptions({
input: (pageParam) => ({
params: { control_space_id: knowledgeSpaceId },
query: {
limit: PAGE_SIZE,
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
},
}),
getNextPageParam: (lastPage) => lastPage.next_cursor,
initialPageParam: null as string | null,
refetchInterval: (query) => {
const currentSources =
query.state.data?.pages.flatMap((page) =>
page.data
.filter((source) => !removedSourceIds.has(source.id))
.map((source) => getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
) ?? []
const phase = initialSourcePollingPhase(currentSources, awaitedOperationId, pollingTimedOut)
if (phase === 'awaiting') return AWAIT_INITIAL_SOURCE_POLL_INTERVAL
return currentSources.some(
(source) =>
sourceNeedsPolling(source) &&
(!pollingTimedOut || sourceDisplayStatus(source) !== 'initializing'),
)
? SOURCE_POLL_INTERVAL
: false
},
})
})
const sourcesQueryDataAtom = selectAtom(sourcesQueryAtom, (query) => query.data)
export const sourcesQueryHasDataAtom = atom((get) => Boolean(get(sourcesQueryDataAtom)))
export const sourcesQueryErrorAtom = selectAtom(sourcesQueryAtom, (query) => query.error)
export const sourcesQueryPendingAtom = selectAtom(sourcesQueryAtom, (query) => query.isPending)
export const sourcesQueryHasNextPageAtom = selectAtom(
sourcesQueryAtom,
(query) => query.hasNextPage,
)
export const sourcesQueryFetchNextPageErrorAtom = selectAtom(
sourcesQueryAtom,
(query) => query.isFetchNextPageError,
)
export const sourcesQueryFetchingNextPageAtom = selectAtom(
sourcesQueryAtom,
(query) => query.isFetchingNextPage,
)
export const currentSourcesAtom = atom((get) => {
const removedSourceIds = get(removedSourceIdsAtom)
const sourceOverrides = get(sourceOverridesAtom)
return (
get(sourcesQueryDataAtom)
?.pages.flatMap((page) => page.data.map((source) => sourceFromApi(source)))
.filter((source) => !removedSourceIds.has(source.id))
.map((source) => getCurrentSource(source, sourceOverrides[source.id])) ?? []
)
})
export const sourcesPollingPhaseAtom = atom((get) =>
initialSourcePollingPhase(
get(currentSourcesAtom),
get(sourcesAwaitedOperationIdAtom),
get(sourcePollingTimedOutAtom),
),
)
export const visibleSourcesAtom = atom((get) =>
get(currentSourcesAtom)
.filter((source) => !shouldHidePreviewSource(source))
.sort(
(left, right) =>
right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id),
),
)
export const filteredSourcesAtom = atom((get) => {
const filter = get(sourcesFilterAtom)
const search = get(sourcesSearchAtom)
const sort = get(sourcesSortAtom)
const normalizedSearch = search.trim().toLocaleLowerCase()
const nextSources = get(visibleSourcesAtom).filter((source) => {
if (filter !== 'all' && sourceDisplayStatus(source) !== 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
})
})
const loadedSourcePageCountAtom = atom((get) => get(sourcesQueryDataAtom)?.pages.length ?? 0)
const localTransformActiveAtom = atom((get) =>
Boolean(
get(sourcesFilterAtom) !== 'all' || get(sourcesSearchAtom).trim() || get(sourcesSortAtom),
),
)
const canAutoLoadNextPageAtom = atom(
(get) => get(loadedSourcePageCountAtom) < MAX_AUTO_CURSOR_PAGES,
)
const needsVisibleSourceAtom = atom((get) => {
const latestSourcePage = get(sourcesQueryDataAtom)?.pages.at(-1)
if (!latestSourcePage) return false
const removedSourceIds = get(removedSourceIdsAtom)
const sourceOverrides = get(sourceOverridesAtom)
return (
latestSourcePage.data.some((source) =>
shouldHidePreviewSource(getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
) &&
!latestSourcePage.data.some((source) => {
if (removedSourceIds.has(source.id)) return false
return !shouldHidePreviewSource(
getCurrentSource(sourceFromApi(source), sourceOverrides[source.id]),
)
})
)
})
export const completingFilteredResultsAtom = atom((get) => {
const canAutoLoadNextPage = get(canAutoLoadNextPageAtom)
const shouldComplete =
(get(localTransformActiveAtom) && canAutoLoadNextPage) ||
(get(needsVisibleSourceAtom) && canAutoLoadNextPage)
return (
shouldComplete &&
!get(sourcesQueryFetchNextPageErrorAtom) &&
(get(sourcesQueryHasNextPageAtom) || get(sourcesQueryFetchingNextPageAtom))
)
})
export const shouldAutoLoadNextSourcePageAtom = atom(
(get) =>
((get(localTransformActiveAtom) && get(canAutoLoadNextPageAtom)) ||
(get(needsVisibleSourceAtom) && get(canAutoLoadNextPageAtom))) &&
get(sourcesQueryHasNextPageAtom) &&
!get(sourcesQueryFetchingNextPageAtom) &&
!get(sourcesQueryFetchNextPageErrorAtom),
)
export const acceptSourceSnapshotAtom = atom(null, (_get, set, source: Source) => {
set(sourceOverridesAtom, (current) => ({ ...current, [source.id]: source }))
})
export const removeSourceFromListAtom = atom(null, (_get, set, sourceId: string) => {
set(removedSourceIdsAtom, (current) => new Set(current).add(sourceId))
})
export const markSourcePollingTimedOutAtom = atom(null, (get, set) => {
set(sourcePollingTimeoutAtom, {
awaitedOperationId: get(sourcesAwaitedOperationIdAtom),
timedOut: true,
})
})
export const refreshSourcesAtom = atom(null, (get) => get(sourcesQueryAtom).refetch())
export const fetchNextSourcePageAtom = atom(null, (get) => get(sourcesQueryAtom).fetchNextPage())
export const sourcesSessionAtoms = [
sourceOverridesAtom,
removedSourceIdsAtom,
sourcePollingTimeoutAtom,
] as const