mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
refactor(knowledge-fs): decompose sources page
This commit is contained in:
parent
d3f59d8458
commit
930de8196a
@ -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',
|
||||
|
||||
147
web/features/new-rag/sources/__tests__/state-boundary.spec.tsx
Normal file
147
web/features/new-rag/sources/__tests__/state-boundary.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
52
web/features/new-rag/sources/runtime-controller.tsx
Normal file
52
web/features/new-rag/sources/runtime-controller.tsx
Normal 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
|
||||
}
|
||||
@ -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)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
81
web/features/new-rag/sources/state-boundary.tsx
Normal file
81
web/features/new-rag/sources/state-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
276
web/features/new-rag/sources/state.ts
Normal file
276
web/features/new-rag/sources/state.ts
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user