diff --git a/web/features/new-rag/documents/detail/__tests__/page.spec.tsx b/web/features/new-rag/documents/detail/__tests__/page.spec.tsx index 52eec5fd38b..db962a3d77c 100644 --- a/web/features/new-rag/documents/detail/__tests__/page.spec.tsx +++ b/web/features/new-rag/documents/detail/__tests__/page.spec.tsx @@ -344,6 +344,42 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { return { ...original, + atomWithInfiniteQuery: ( + getOptions: (get: (target: import('jotai').Atom) => Value) => { + queryKind?: string + }, + ) => + atom((get) => { + get(versionAtom) + const options = getOptions(get) + if (options.queryKind === 'revisions') + return { + ...revisionsQuery, + data: revisionsQuery.data + ? { + pages: revisionsQuery.data.pages.map((page) => ({ + data: page.items.flatMap((revision) => + revision ? [revisionApiResponse(revision)] : [], + ), + next_cursor: page.nextCursor ?? null, + })), + } + : undefined, + } + if (options.queryKind === 'chunks') + return { + ...chunksQuery, + data: chunksQuery.data + ? { + pages: chunksQuery.data.pages.map((page) => ({ + data: page.items.map(chunkApiResponse), + next_cursor: page.nextCursor ?? null, + })), + } + : undefined, + } + throw new Error(`Unexpected infinite query atom: ${options.queryKind ?? 'unknown'}`) + }), atomWithQuery: ( getOptions: (get: (target: import('jotai').Atom) => Value) => { queryKind?: string @@ -353,6 +389,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { get(versionAtom) const options = getOptions(get) if (options.queryKind === 'document') return { ...documentQuery } + if (options.queryKind === 'outline') return { ...outlineQuery } + if (options.queryKind === 'multimodal') return { ...multimodalQuery } throw new Error(`Unexpected query atom: ${options.queryKind ?? 'unknown'}`) }), } diff --git a/web/features/new-rag/documents/detail/chunk-detail.tsx b/web/features/new-rag/documents/detail/chunk-detail.tsx index fe0dc62c6a3..50c9a3f9a84 100644 --- a/web/features/new-rag/documents/detail/chunk-detail.tsx +++ b/web/features/new-rag/documents/detail/chunk-detail.tsx @@ -1,6 +1,3 @@ -import type { KnowledgeFsDocumentMultimodalItemResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' -import type { DocumentRevisionChunk, LogicalDocument, LogicalDocumentRevision } from '../models' -import type { DocumentContentBlock } from './model' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -12,12 +9,23 @@ import { } from '@langgenius/dify-ui/scroll-area' import { toast } from '@langgenius/dify-ui/toast' import copy from 'copy-to-clipboard' +import { useAtomValue } from 'jotai' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Markdown } from '@/app/components/base/markdown' import { DocumentMetadataCard } from '../metadata/card' import { chunkCharacterCount, placeDocumentMultimodalItems } from './model' import { DocumentMultimodalAsset } from './multimodal-asset' +import { + documentChunksQueryIsFetchingNextPageAtom, + documentDetailChunksCompleteAtom, + documentDetailModelAtom, + documentDetailMultimodalItemsAtom, + documentDetailSelectedChunkIdAtom, +} from './state/content' +import { documentDetailKnowledgeSpaceIdAtom } from './state/inputs' +import { documentDetailDocumentAtom } from './state/queries' +import { documentDetailRevisionAtom } from './state/revisions' import { useDocumentWriteAccess } from './workflow-context' const SELECTED_CHUNK_TOP_OFFSET = 8 @@ -115,19 +123,13 @@ function DocumentSectionSummary({ children }: { children: React.ReactNode }) { ) } -export function DocumentReadingPane({ - contentBlocks, - isLoadingMore, - multimodalItems, - selectedChunkId, -}: { - contentBlocks: DocumentContentBlock[] - isLoadingMore: boolean - multimodalItems: KnowledgeFsDocumentMultimodalItemResponse[] - selectedChunkId?: string -}) { +export function DocumentReadingPane() { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') + const contentBlocks = useAtomValue(documentDetailModelAtom).contentBlocks + const isLoadingMore = useAtomValue(documentChunksQueryIsFetchingNextPageAtom) + const multimodalItems = useAtomValue(documentDetailMultimodalItemsAtom) + const selectedChunkId = useAtomValue(documentDetailSelectedChunkIdAtom) const contentScrollRef = useRef(null) const contentChunks = useMemo(() => contentBlocks.map((block) => block.chunk), [contentBlocks]) const multimodalPlacement = useMemo( @@ -271,24 +273,16 @@ export function DocumentReadingPane({ ) } -export function DocumentFactsSidebar({ - chunksComplete, - controlSpaceId, - document, - indexChunks, - locale, - revision, -}: { - chunksComplete: boolean - controlSpaceId: string - document: LogicalDocument - indexChunks: DocumentRevisionChunk[] - locale: string - revision?: Exclude -}) { - const { t } = useTranslation('dataset') +export function DocumentFactsSidebar() { + const { i18n, t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const { canEdit } = useDocumentWriteAccess() + const chunksComplete = useAtomValue(documentDetailChunksCompleteAtom) + const controlSpaceId = useAtomValue(documentDetailKnowledgeSpaceIdAtom) + const document = useAtomValue(documentDetailDocumentAtom) + const indexChunks = useAtomValue(documentDetailModelAtom).indexChunks + const revision = useAtomValue(documentDetailRevisionAtom) + const locale = i18n.resolvedLanguage ?? i18n.language const characterCount = useMemo( () => indexChunks.reduce((total, chunk) => total + chunkCharacterCount(chunk.text), 0), [indexChunks], diff --git a/web/features/new-rag/documents/detail/chunk-tree.tsx b/web/features/new-rag/documents/detail/chunk-tree.tsx index a7d82e732b6..018e8c735d2 100644 --- a/web/features/new-rag/documents/detail/chunk-tree.tsx +++ b/web/features/new-rag/documents/detail/chunk-tree.tsx @@ -1,13 +1,26 @@ 'use client' -import type { DocumentChunkTree } from './model' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' +import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { chunkTreeLabel, visibleDocumentChunkNodes } from './model' +import { + documentChunksQueryErrorAtom, + documentChunksQueryHasNextPageAtom, + documentChunksQueryIsFetchingNextPageAtom, + documentChunksQueryIsFetchNextPageErrorAtom, + documentChunksQueryIsPendingAtom, + documentDetailChunksAtom, + documentDetailModelAtom, + documentDetailSelectedChunkIdAtom, + loadNextDocumentChunkPageAtom, + retryDocumentChunksAtom, +} from './state/content' +import { selectDocumentChunkAtom } from './state/runtime' const VIRTUALIZATION_THRESHOLD = 80 const TREE_ROW_SIZE = 30 @@ -51,33 +64,20 @@ function AutomaticChunkPageLoader({ return
} -export function DocumentChunkTreePanel({ - chunkCount, - error, - fetchNextPage, - hasNextPage, - isFetchNextPageError, - isFetchingNextPage, - isPending, - onRetry, - onSelectChunk, - selectedChunkId, - tree, -}: { - chunkCount: number - error: boolean - fetchNextPage: () => Promise - hasNextPage: boolean - isFetchNextPageError: boolean - isFetchingNextPage: boolean - isPending: boolean - onRetry: () => void - onSelectChunk: (chunkId: string) => void - selectedChunkId?: string - tree: DocumentChunkTree -}) { +export function DocumentChunkTreePanel() { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') + const chunkCount = useAtomValue(documentDetailChunksAtom).length + const error = Boolean(useAtomValue(documentChunksQueryErrorAtom)) + const hasNextPage = useAtomValue(documentChunksQueryHasNextPageAtom) + const isFetchNextPageError = useAtomValue(documentChunksQueryIsFetchNextPageErrorAtom) + const isFetchingNextPage = useAtomValue(documentChunksQueryIsFetchingNextPageAtom) + const isPending = useAtomValue(documentChunksQueryIsPendingAtom) + const selectedChunkId = useAtomValue(documentDetailSelectedChunkIdAtom) + const tree = useAtomValue(documentDetailModelAtom).tree + const fetchNextPage = useSetAtom(loadNextDocumentChunkPageAtom) + const retryChunks = useSetAtom(retryDocumentChunksAtom) + const selectChunk = useSetAtom(selectDocumentChunkAtom) const [expansionOverrides, setExpansionOverrides] = useState<{ collapsed: Set expanded: Set @@ -156,7 +156,7 @@ export function DocumentChunkTreePanel({ const selectNode = (node: (typeof visibleNodes)[number]['node']) => { setSelectedNodeId(node.id) - onSelectChunk(node.targetChunkId) + void selectChunk(node.targetChunkId) } const handleTreeKeyDown = (event: React.KeyboardEvent) => { @@ -241,7 +241,7 @@ export function DocumentChunkTreePanel({ role="alert" > {t(($) => $['newKnowledge.documentChunksLoadError'])} - +
)} {isPending ? ( @@ -254,7 +254,7 @@ export function DocumentChunkTreePanel({

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

- diff --git a/web/features/new-rag/documents/detail/queries.ts b/web/features/new-rag/documents/detail/queries.ts deleted file mode 100644 index 9e795de9153..00000000000 --- a/web/features/new-rag/documents/detail/queries.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { skipToken } from '@tanstack/react-query' -import { consoleQuery } from '@/service/client' - -export function documentChunksQueryOptions({ - documentId, - effectiveRevision, - knowledgeSpaceId, -}: { - documentId: string - effectiveRevision: number - knowledgeSpaceId: string -}) { - const chunksQuery = - consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.revisions.byRevision - .chunks - - return chunksQuery.get.infiniteOptions({ - input: (pageParam) => ({ - params: { - control_space_id: knowledgeSpaceId, - document_id: documentId, - revision: effectiveRevision, - }, - query: { - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), - }, - }), - getNextPageParam: (lastPage) => lastPage.next_cursor, - initialPageParam: null as string | null, - }) -} - -export function documentOutlineQueryOptions({ - documentAssetId, - knowledgeSpaceId, -}: { - documentAssetId?: string - knowledgeSpaceId: string -}) { - const outlineQuery = - consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.outline - - return outlineQuery.get.queryOptions({ - context: { silent: true }, - input: documentAssetId - ? { - params: { - control_space_id: knowledgeSpaceId, - document_id: documentAssetId, - }, - } - : skipToken, - retry: false, - }) -} - -export function documentMultimodalQueryOptions({ - documentAssetId, - knowledgeSpaceId, -}: { - documentAssetId?: string - knowledgeSpaceId: string -}) { - const multimodalQuery = - consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.multimodal - - return multimodalQuery.get.queryOptions({ - context: { silent: true }, - input: documentAssetId - ? { - params: { - control_space_id: knowledgeSpaceId, - document_id: documentAssetId, - }, - } - : skipToken, - retry: false, - }) -} diff --git a/web/features/new-rag/documents/detail/revision-browser.tsx b/web/features/new-rag/documents/detail/revision-browser.tsx index 2143e23923b..a845fc5a52b 100644 --- a/web/features/new-rag/documents/detail/revision-browser.tsx +++ b/web/features/new-rag/documents/detail/revision-browser.tsx @@ -1,27 +1,23 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { useInfiniteQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { createParser, parseAsString, useQueryStates } from 'nuqs' -import { useEffect, useMemo } from 'react' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { consoleQuery } from '@/service/client' -import { documentRevisionListFromApi } from '../models' -import { initialDocumentRevision } from './model' import { DocumentRevisionData } from './revision-content' -import { documentDetailKnowledgeSpaceIdAtom } from './state/inputs' -import { documentDetailDocumentAtom } from './state/queries' - -const documentRevisionParser = createParser({ - parse: (value) => { - const revision = Number(value) - return Number.isInteger(revision) && revision > 0 ? revision : null - }, - serialize: String, -}).withOptions({ history: 'push' }) -const documentChunkParser = parseAsString.withOptions({ history: 'replace' }) +import { documentDetailRequestedRevisionAtom } from './state/inputs' +import { + documentDetailEffectiveRevisionAtom, + documentDetailRevisionAtom, + documentRevisionsQueryErrorAtom, + documentRevisionsQueryHasNextPageAtom, + documentRevisionsQueryIsFetchingNextPageAtom, + documentRevisionsQueryIsFetchNextPageErrorAtom, + documentRevisionsQueryIsPendingAtom, + loadNextDocumentRevisionPageAtom, + retryDocumentRevisionsAtom, +} from './state/revisions' function RevisionLoadingState() { const { t: tCommon } = useTranslation('common') @@ -60,88 +56,51 @@ function RevisionErrorState({ } export function DocumentRevisionBrowser() { - const { i18n, t } = useTranslation('dataset') + const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') - const document = useAtomValue(documentDetailDocumentAtom) - const knowledgeSpaceId = useAtomValue(documentDetailKnowledgeSpaceIdAtom) - const locale = i18n.resolvedLanguage ?? i18n.language - const [documentLocation, setDocumentLocation] = useQueryStates({ - chunk: documentChunkParser, - revision: documentRevisionParser, - }) - const { chunk: selectedChunkId, revision: selectedRevision } = documentLocation - const revisionsQuery = useInfiniteQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.revisions.get.infiniteOptions( - { - input: (pageParam) => ({ - params: { - control_space_id: knowledgeSpaceId, - document_id: document.id, - }, - query: { - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), - }, - }), - getNextPageParam: (lastPage) => lastPage.next_cursor, - initialPageParam: null as string | null, - }, - ), - ) - const { fetchNextPage, hasNextPage, isFetchNextPageError, isFetchingNextPage } = revisionsQuery - const revisions = useMemo( - () => - revisionsQuery.data?.pages.flatMap((page) => documentRevisionListFromApi(page).items) ?? [], - [revisionsQuery.data], - ) - const availableRevisions = useMemo(() => { - const byRevision = new Map(revisions.map((revision) => [revision.revision, revision])) - if (document.active) byRevision.set(document.active.revision, document.active) - return [...byRevision.values()].sort((left, right) => right.revision - left.revision) - }, [document.active, revisions]) - const requestedRevision = - selectedRevision ?? initialDocumentRevision(document, availableRevisions) - const revision = availableRevisions.find((candidate) => candidate.revision === requestedRevision) - const effectiveRevision = revision?.revision + const requestedRevision = useAtomValue(documentDetailRequestedRevisionAtom) + const revision = useAtomValue(documentDetailRevisionAtom) + const effectiveRevision = useAtomValue(documentDetailEffectiveRevisionAtom) + const error = useAtomValue(documentRevisionsQueryErrorAtom) + const hasNextPage = useAtomValue(documentRevisionsQueryHasNextPageAtom) + const isFetchNextPageError = useAtomValue(documentRevisionsQueryIsFetchNextPageErrorAtom) + const isFetchingNextPage = useAtomValue(documentRevisionsQueryIsFetchingNextPageAtom) + const isPending = useAtomValue(documentRevisionsQueryIsPendingAtom) + const loadNextPage = useSetAtom(loadNextDocumentRevisionPageAtom) + const retryRevisions = useSetAtom(retryDocumentRevisionsAtom) useEffect(() => { if ( - selectedRevision === null || + requestedRevision === null || revision || !hasNextPage || isFetchingNextPage || isFetchNextPageError ) return - void fetchNextPage() + void loadNextPage() }, [ - fetchNextPage, hasNextPage, isFetchNextPageError, isFetchingNextPage, + loadNextPage, + requestedRevision, revision, - selectedRevision, ]) - if ( - selectedRevision !== null && - !revision && - (revisionsQuery.isPending || isFetchingNextPage || hasNextPage) - ) + if (requestedRevision !== null && !revision && (isPending || isFetchingNextPage || hasNextPage)) return - if (selectedRevision !== null && !revision && revisionsQuery.error) + if (requestedRevision !== null && !revision && error) return ( $['newKnowledge.documentRevisionsLoadError'])} - onRetry={() => { - if (isFetchNextPageError) void fetchNextPage() - else void revisionsQuery.refetch() - }} + onRetry={() => void retryRevisions()} title={t(($) => $['newKnowledge.documentLoadErrorTitle'])} /> ) - if (selectedRevision !== null && !revision) + if (requestedRevision !== null && !revision) return ( $['newKnowledge.documentNotFoundDescription'])} @@ -149,13 +108,13 @@ export function DocumentRevisionBrowser() { /> ) - if (effectiveRevision === undefined && revisionsQuery.isPending) return + if (effectiveRevision === undefined && isPending) return - if (effectiveRevision === undefined && revisionsQuery.error) + if (effectiveRevision === undefined && error) return ( $['newKnowledge.documentLoadErrorDescription'])} - onRetry={() => void revisionsQuery.refetch()} + onRetry={() => void retryRevisions()} title={t(($) => $['newKnowledge.documentLoadErrorTitle'])} /> ) @@ -175,26 +134,18 @@ export function DocumentRevisionBrowser() { return ( <> - {revisionsQuery.error && !revisionsQuery.isFetchNextPageError && ( + {error && !isFetchNextPageError && (
{t(($) => $['newKnowledge.documentRevisionsLoadError'])} -
)} - void setDocumentLocation({ chunk: chunkId })} - revision={revision} - selectedChunkId={selectedChunkId ?? undefined} - /> + ) } diff --git a/web/features/new-rag/documents/detail/revision-content.tsx b/web/features/new-rag/documents/detail/revision-content.tsx index 5331160afc6..183679c612a 100644 --- a/web/features/new-rag/documents/detail/revision-content.tsx +++ b/web/features/new-rag/documents/detail/revision-content.tsx @@ -1,166 +1,59 @@ 'use client' -import type { LogicalDocument, LogicalDocumentRevision } from '../models' -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { useEffect, useMemo } from 'react' -import { documentChunkListFromApi } from '../models' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' import { DocumentFactsSidebar, DocumentReadingPane } from './chunk-detail' import { DocumentChunkTreePanel } from './chunk-tree' -import { buildDocumentDetailModel } from './model' import { - documentChunksQueryOptions, - documentMultimodalQueryOptions, - documentOutlineQueryOptions, -} from './queries' + documentChunksQueryHasNextPageAtom, + documentChunksQueryIsFetchingNextPageAtom, + documentChunksQueryIsFetchNextPageErrorAtom, + documentDetailSelectedChunkKnownAtom, + loadNextDocumentChunkPageAtom, +} from './state/content' +import { documentDetailRequestedChunkIdAtom } from './state/inputs' +import { documentDetailRevisionSessionKeyAtom } from './state/revisions' -export function DocumentRevisionData({ - document, - effectiveRevision, - knowledgeSpaceId, - locale, - onSelectChunk, - revision, - selectedChunkId, -}: { - document: LogicalDocument - effectiveRevision: number - knowledgeSpaceId: string - locale: string - onSelectChunk: (chunkId: string) => void - revision?: Exclude - selectedChunkId?: string -}) { - const chunksQueryOptions = useMemo( - () => - documentChunksQueryOptions({ - documentId: document.id, - effectiveRevision, - knowledgeSpaceId, - }), - [document.id, effectiveRevision, knowledgeSpaceId], - ) - const chunksQuery = useInfiniteQuery(chunksQueryOptions) - const { - fetchNextPage: fetchNextChunkPage, - hasNextPage: hasNextChunkPage, - isFetchNextPageError: isFetchNextChunkPageError, - isFetchingNextPage: isFetchingNextChunkPage, - } = chunksQuery - const documentAsset = - revision ?? (document.active?.revision === effectiveRevision ? document.active : undefined) - const outlineQueryOptions = useMemo( - () => - documentOutlineQueryOptions({ - documentAssetId: documentAsset?.documentAssetId, - knowledgeSpaceId, - }), - [documentAsset?.documentAssetId, knowledgeSpaceId], - ) - const outlineQuery = useQuery(outlineQueryOptions) - const multimodalQueryOptions = useMemo( - () => - documentMultimodalQueryOptions({ - documentAssetId: documentAsset?.documentAssetId, - knowledgeSpaceId, - }), - [documentAsset?.documentAssetId, knowledgeSpaceId], - ) - const multimodalQuery = useQuery(multimodalQueryOptions) - const chunks = useMemo( - () => - [ - ...(chunksQuery.data?.pages.flatMap((page) => documentChunkListFromApi(page).items) ?? []), - ].sort((left, right) => left.ordinal - right.ordinal || left.id.localeCompare(right.id)), - [chunksQuery.data], - ) - const multimodalItems = useMemo(() => { - const manifest = multimodalQuery.data - if (!manifest || manifest.version !== documentAsset?.documentAssetVersion) return [] - return manifest.items ?? [] - }, [documentAsset?.documentAssetVersion, multimodalQuery.data]) - const detailModel = useMemo(() => { - const outline = outlineQuery.data - return buildDocumentDetailModel( - chunks, - outline && outline.version === documentAsset?.documentAssetVersion ? outline.nodes : [], - multimodalItems, - ) - }, [chunks, documentAsset?.documentAssetVersion, multimodalItems, outlineQuery.data]) - const targetedBlock = selectedChunkId - ? detailModel.contentBlocksByChunkId.get(selectedChunkId) - : undefined - const selectedChunkKnown = selectedChunkId - ? detailModel.sourceChunksById.has(selectedChunkId) - : false - const targetLookupComplete = - !selectedChunkId || - selectedChunkKnown || - (!chunksQuery.isPending && (!hasNextChunkPage || isFetchNextChunkPageError)) - const fallbackBlock = detailModel.tree.roots[0] - ? detailModel.contentBlocksByChunkId.get(detailModel.tree.roots[0].targetChunkId) - : undefined - const selectedBlock = targetedBlock ?? (targetLookupComplete ? fallbackBlock : undefined) - const revisionSessionKey = `${document.id}:${effectiveRevision}` +function RequestedChunkPageLoader() { + const selectedChunkId = useAtomValue(documentDetailRequestedChunkIdAtom) + const selectedChunkKnown = useAtomValue(documentDetailSelectedChunkKnownAtom) + const hasNextPage = useAtomValue(documentChunksQueryHasNextPageAtom) + const isFetchNextPageError = useAtomValue(documentChunksQueryIsFetchNextPageErrorAtom) + const isFetchingNextPage = useAtomValue(documentChunksQueryIsFetchingNextPageAtom) + const loadNextPage = useSetAtom(loadNextDocumentChunkPageAtom) useEffect(() => { if ( !selectedChunkId || selectedChunkKnown || - !hasNextChunkPage || - isFetchingNextChunkPage || - isFetchNextChunkPageError + !hasNextPage || + isFetchingNextPage || + isFetchNextPageError ) return - void fetchNextChunkPage() + void loadNextPage() }, [ - fetchNextChunkPage, - hasNextChunkPage, - isFetchNextChunkPageError, - isFetchingNextChunkPage, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + loadNextPage, selectedChunkId, selectedChunkKnown, ]) + return null +} + +export function DocumentRevisionData() { + const revisionSessionKey = useAtomValue(documentDetailRevisionSessionKeyAtom) + if (!revisionSessionKey) return null + return (
- void chunksQuery.refetch()} - onSelectChunk={onSelectChunk} - selectedChunkId={selectedBlock?.chunk.id} - tree={detailModel.tree} - /> - - - - + + + +
) } diff --git a/web/features/new-rag/documents/detail/state/boundary.tsx b/web/features/new-rag/documents/detail/state/boundary.tsx index fb3c789dc10..0061a4dae07 100644 --- a/web/features/new-rag/documents/detail/state/boundary.tsx +++ b/web/features/new-rag/documents/detail/state/boundary.tsx @@ -2,7 +2,23 @@ import type { ReactNode } from 'react' import { useHydrateAtoms } from 'jotai/utils' -import { documentDetailDocumentIdAtom, documentDetailKnowledgeSpaceIdAtom } from './inputs' +import { createParser, parseAsString, useQueryStates } from 'nuqs' +import { + documentDetailDocumentIdAtom, + documentDetailKnowledgeSpaceIdAtom, + documentDetailRequestedChunkIdAtom, + documentDetailRequestedRevisionAtom, +} from './inputs' +import { documentDetailLocationRuntimeAtom } from './runtime' + +const documentRevisionParser = createParser({ + parse: (value) => { + const revision = Number(value) + return Number.isInteger(revision) && revision > 0 ? revision : null + }, + serialize: String, +}).withOptions({ history: 'push' }) +const documentChunkParser = parseAsString.withOptions({ history: 'replace' }) export function DocumentDetailStateBoundary({ children, @@ -13,10 +29,18 @@ export function DocumentDetailStateBoundary({ documentId: string knowledgeSpaceId: string }) { + const [documentLocation, setDocumentLocation] = useQueryStates({ + chunk: documentChunkParser, + revision: documentRevisionParser, + }) + useHydrateAtoms( [ [documentDetailDocumentIdAtom, documentId], [documentDetailKnowledgeSpaceIdAtom, knowledgeSpaceId], + [documentDetailRequestedChunkIdAtom, documentLocation.chunk], + [documentDetailRequestedRevisionAtom, documentLocation.revision], + [documentDetailLocationRuntimeAtom, { setDocumentLocation }], ], { dangerouslyForceHydrate: true }, ) diff --git a/web/features/new-rag/documents/detail/state/content.ts b/web/features/new-rag/documents/detail/state/content.ts new file mode 100644 index 00000000000..6def3e5ac41 --- /dev/null +++ b/web/features/new-rag/documents/detail/state/content.ts @@ -0,0 +1,181 @@ +import { skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { selectAtom } from 'jotai/utils' +import { consoleQuery } from '@/service/client' +import { documentChunkListFromApi } from '../../models' +import { buildDocumentDetailModel } from '../model' +import { + documentDetailDocumentIdAtom, + documentDetailKnowledgeSpaceIdAtom, + documentDetailRequestedChunkIdAtom, +} from './inputs' +import { documentDetailDocumentAtom } from './queries' +import { documentDetailEffectiveRevisionAtom, documentDetailRevisionAtom } from './revisions' + +const documentChunksQueryAtom = atomWithInfiniteQuery((get) => { + const effectiveRevision = get(documentDetailEffectiveRevisionAtom) + if (effectiveRevision === undefined) + throw new Error('Document revision is unavailable for chunk loading') + + return consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.revisions.byRevision.chunks.get.infiniteOptions( + { + input: (pageParam) => ({ + params: { + control_space_id: get(documentDetailKnowledgeSpaceIdAtom), + document_id: get(documentDetailDocumentIdAtom), + revision: effectiveRevision, + }, + query: { + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + }), + getNextPageParam: (lastPage) => lastPage.next_cursor, + initialPageParam: null as string | null, + }, + ) +}) + +const documentChunksQueryDataAtom = selectAtom(documentChunksQueryAtom, (query) => query.data) + +export const documentChunksQueryErrorAtom = selectAtom( + documentChunksQueryAtom, + (query) => query.error, +) +export const documentChunksQueryHasDataAtom = atom((get) => + Boolean(get(documentChunksQueryDataAtom)), +) +export const documentChunksQueryHasNextPageAtom = selectAtom( + documentChunksQueryAtom, + (query) => query.hasNextPage, +) +export const documentChunksQueryIsFetchNextPageErrorAtom = selectAtom( + documentChunksQueryAtom, + (query) => query.isFetchNextPageError, +) +export const documentChunksQueryIsFetchingNextPageAtom = selectAtom( + documentChunksQueryAtom, + (query) => query.isFetchingNextPage, +) +export const documentChunksQueryIsPendingAtom = selectAtom( + documentChunksQueryAtom, + (query) => query.isPending, +) + +export const documentDetailChunksAtom = atom((get) => + [ + ...(get(documentChunksQueryDataAtom)?.pages.flatMap( + (page) => documentChunkListFromApi(page).items, + ) ?? []), + ].sort((left, right) => left.ordinal - right.ordinal || left.id.localeCompare(right.id)), +) + +const documentDetailAssetAtom = atom((get) => { + const document = get(documentDetailDocumentAtom) + const effectiveRevision = get(documentDetailEffectiveRevisionAtom) + return ( + get(documentDetailRevisionAtom) ?? + (document.active?.revision === effectiveRevision ? document.active : undefined) + ) +}) + +const documentOutlineQueryAtom = atomWithQuery((get) => { + const documentAssetId = get(documentDetailAssetAtom)?.documentAssetId + return consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.outline.get.queryOptions( + { + context: { silent: true }, + input: documentAssetId + ? { + params: { + control_space_id: get(documentDetailKnowledgeSpaceIdAtom), + document_id: documentAssetId, + }, + } + : skipToken, + retry: false, + }, + ) +}) + +const documentMultimodalQueryAtom = atomWithQuery((get) => { + const documentAssetId = get(documentDetailAssetAtom)?.documentAssetId + return consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.multimodal.get.queryOptions( + { + context: { silent: true }, + input: documentAssetId + ? { + params: { + control_space_id: get(documentDetailKnowledgeSpaceIdAtom), + document_id: documentAssetId, + }, + } + : skipToken, + retry: false, + }, + ) +}) + +const documentOutlineDataAtom = selectAtom(documentOutlineQueryAtom, (query) => query.data) +const documentMultimodalDataAtom = selectAtom(documentMultimodalQueryAtom, (query) => query.data) + +export const documentDetailMultimodalItemsAtom = atom((get) => { + const manifest = get(documentMultimodalDataAtom) + if (!manifest || manifest.version !== get(documentDetailAssetAtom)?.documentAssetVersion) + return [] + return manifest.items ?? [] +}) + +export const documentDetailModelAtom = atom((get) => { + const documentAssetVersion = get(documentDetailAssetAtom)?.documentAssetVersion + const outline = get(documentOutlineDataAtom) + return buildDocumentDetailModel( + get(documentDetailChunksAtom), + outline && outline.version === documentAssetVersion ? outline.nodes : [], + get(documentDetailMultimodalItemsAtom), + ) +}) + +export const documentDetailSelectedChunkKnownAtom = atom((get) => { + const selectedChunkId = get(documentDetailRequestedChunkIdAtom) + return selectedChunkId + ? get(documentDetailModelAtom).sourceChunksById.has(selectedChunkId) + : false +}) + +export const documentDetailSelectedBlockAtom = atom((get) => { + const selectedChunkId = get(documentDetailRequestedChunkIdAtom) + const detailModel = get(documentDetailModelAtom) + const targetedBlock = selectedChunkId + ? detailModel.contentBlocksByChunkId.get(selectedChunkId) + : undefined + const targetLookupComplete = + !selectedChunkId || + get(documentDetailSelectedChunkKnownAtom) || + (!get(documentChunksQueryIsPendingAtom) && + (!get(documentChunksQueryHasNextPageAtom) || + get(documentChunksQueryIsFetchNextPageErrorAtom))) + const firstRoot = detailModel.tree.roots[0] + const fallbackBlock = firstRoot + ? detailModel.contentBlocksByChunkId.get(firstRoot.targetChunkId) + : undefined + return targetedBlock ?? (targetLookupComplete ? fallbackBlock : undefined) +}) + +export const documentDetailSelectedChunkIdAtom = atom( + (get) => get(documentDetailSelectedBlockAtom)?.chunk.id, +) + +export const documentDetailChunksCompleteAtom = atom( + (get) => + get(documentChunksQueryHasDataAtom) && + !get(documentChunksQueryErrorAtom) && + !get(documentChunksQueryHasNextPageAtom) && + !get(documentChunksQueryIsFetchingNextPageAtom) && + !get(documentChunksQueryIsFetchNextPageErrorAtom), +) + +export const loadNextDocumentChunkPageAtom = atom(null, (get) => + get(documentChunksQueryAtom).fetchNextPage(), +) + +export const retryDocumentChunksAtom = atom(null, (get) => get(documentChunksQueryAtom).refetch()) diff --git a/web/features/new-rag/documents/detail/state/inputs.ts b/web/features/new-rag/documents/detail/state/inputs.ts index 3f708360ce5..4b1cac70575 100644 --- a/web/features/new-rag/documents/detail/state/inputs.ts +++ b/web/features/new-rag/documents/detail/state/inputs.ts @@ -1,3 +1,4 @@ +import { atom } from 'jotai' import { atomWithLazy } from 'jotai/utils' export const documentDetailKnowledgeSpaceIdAtom = atomWithLazy(() => { @@ -7,3 +8,7 @@ export const documentDetailKnowledgeSpaceIdAtom = atomWithLazy(() => { export const documentDetailDocumentIdAtom = atomWithLazy(() => { throw new Error('Missing document detail document id') }) + +export const documentDetailRequestedRevisionAtom = atom(null) + +export const documentDetailRequestedChunkIdAtom = atom(null) diff --git a/web/features/new-rag/documents/detail/state/revisions.ts b/web/features/new-rag/documents/detail/state/revisions.ts new file mode 100644 index 00000000000..e4db315bb74 --- /dev/null +++ b/web/features/new-rag/documents/detail/state/revisions.ts @@ -0,0 +1,98 @@ +import { atom } from 'jotai' +import { atomWithInfiniteQuery } from 'jotai-tanstack-query' +import { selectAtom } from 'jotai/utils' +import { consoleQuery } from '@/service/client' +import { documentRevisionListFromApi } from '../../models' +import { initialDocumentRevision } from '../model' +import { + documentDetailDocumentIdAtom, + documentDetailKnowledgeSpaceIdAtom, + documentDetailRequestedRevisionAtom, +} from './inputs' +import { documentDetailDocumentAtom } from './queries' + +const documentRevisionsQueryAtom = atomWithInfiniteQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.documents.byDocumentId.revisions.get.infiniteOptions( + { + input: (pageParam) => ({ + params: { + control_space_id: get(documentDetailKnowledgeSpaceIdAtom), + document_id: get(documentDetailDocumentIdAtom), + }, + query: { + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + }), + getNextPageParam: (lastPage) => lastPage.next_cursor, + initialPageParam: null as string | null, + }, + ), +) + +const documentRevisionsQueryDataAtom = selectAtom(documentRevisionsQueryAtom, (query) => query.data) + +const documentRevisionsAtom = atom( + (get) => + get(documentRevisionsQueryDataAtom)?.pages.flatMap( + (page) => documentRevisionListFromApi(page).items, + ) ?? [], +) + +export const documentDetailAvailableRevisionsAtom = atom((get) => { + const document = get(documentDetailDocumentAtom) + const byRevision = new Map( + get(documentRevisionsAtom).map((revision) => [revision.revision, revision]), + ) + if (document.active) byRevision.set(document.active.revision, document.active) + return [...byRevision.values()].sort((left, right) => right.revision - left.revision) +}) + +export const documentDetailRevisionAtom = atom((get) => { + const document = get(documentDetailDocumentAtom) + const availableRevisions = get(documentDetailAvailableRevisionsAtom) + const requestedRevision = + get(documentDetailRequestedRevisionAtom) ?? + initialDocumentRevision(document, availableRevisions) + return availableRevisions.find((candidate) => candidate.revision === requestedRevision) +}) + +export const documentDetailEffectiveRevisionAtom = atom( + (get) => get(documentDetailRevisionAtom)?.revision, +) + +export const documentDetailRevisionSessionKeyAtom = atom((get) => { + const effectiveRevision = get(documentDetailEffectiveRevisionAtom) + if (effectiveRevision === undefined) return undefined + return `${get(documentDetailDocumentIdAtom)}:${effectiveRevision}` +}) + +export const documentRevisionsQueryErrorAtom = selectAtom( + documentRevisionsQueryAtom, + (query) => query.error, +) +export const documentRevisionsQueryHasNextPageAtom = selectAtom( + documentRevisionsQueryAtom, + (query) => query.hasNextPage, +) +export const documentRevisionsQueryIsFetchNextPageErrorAtom = selectAtom( + documentRevisionsQueryAtom, + (query) => query.isFetchNextPageError, +) +export const documentRevisionsQueryIsFetchingNextPageAtom = selectAtom( + documentRevisionsQueryAtom, + (query) => query.isFetchingNextPage, +) +export const documentRevisionsQueryIsPendingAtom = selectAtom( + documentRevisionsQueryAtom, + (query) => query.isPending, +) + +export const loadNextDocumentRevisionPageAtom = atom(null, (get) => + get(documentRevisionsQueryAtom).fetchNextPage(), +) + +export const retryDocumentRevisionsAtom = atom(null, (get) => { + const query = get(documentRevisionsQueryAtom) + if (query.isFetchNextPageError) return query.fetchNextPage() + return query.refetch() +}) diff --git a/web/features/new-rag/documents/detail/state/runtime.ts b/web/features/new-rag/documents/detail/state/runtime.ts new file mode 100644 index 00000000000..cf2ce3dacd8 --- /dev/null +++ b/web/features/new-rag/documents/detail/state/runtime.ts @@ -0,0 +1,22 @@ +import { atom } from 'jotai' + +type DocumentDetailLocation = { + chunk?: string | null + revision?: number | null +} + +type DocumentDetailLocationRuntime = { + setDocumentLocation: (location: DocumentDetailLocation) => Promise +} + +const unavailableLocationRuntime = async () => { + throw new Error('Document detail location runtime is unavailable') +} + +export const documentDetailLocationRuntimeAtom = atom({ + setDocumentLocation: unavailableLocationRuntime, +}) + +export const selectDocumentChunkAtom = atom(null, (get, _set, chunkId: string) => + get(documentDetailLocationRuntimeAtom).setDocumentLocation({ chunk: chunkId }), +)