refactor(knowledge-fs): model document revision state

This commit is contained in:
Stephen Zhou 2026-09-01 13:09:24 +08:00
parent 0ece06dde5
commit e1aed49d40
No known key found for this signature in database
11 changed files with 497 additions and 370 deletions

View File

@ -344,6 +344,42 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
return {
...original,
atomWithInfiniteQuery: (
getOptions: (get: <Value>(target: import('jotai').Atom<Value>) => 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: <Value>(target: import('jotai').Atom<Value>) => 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'}`)
}),
}

View File

@ -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<HTMLDivElement>(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<LogicalDocumentRevision, null>
}) {
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],

View File

@ -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 <div ref={sentinelRef} aria-hidden className="h-px" />
}
export function DocumentChunkTreePanel({
chunkCount,
error,
fetchNextPage,
hasNextPage,
isFetchNextPageError,
isFetchingNextPage,
isPending,
onRetry,
onSelectChunk,
selectedChunkId,
tree,
}: {
chunkCount: number
error: boolean
fetchNextPage: () => Promise<unknown>
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<string>
expanded: Set<string>
@ -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<HTMLDivElement>) => {
@ -241,7 +241,7 @@ export function DocumentChunkTreePanel({
role="alert"
>
<span>{t(($) => $['newKnowledge.documentChunksLoadError'])}</span>
<Button onClick={onRetry}>{tCommon(($) => $['operation.retry'])}</Button>
<Button onClick={() => void retryChunks()}>{tCommon(($) => $['operation.retry'])}</Button>
</div>
)}
{isPending ? (
@ -254,7 +254,7 @@ export function DocumentChunkTreePanel({
<p className="system-xs-regular text-text-destructive">
{t(($) => $['newKnowledge.documentChunksLoadError'])}
</p>
<Button className="mt-3" onClick={onRetry}>
<Button className="mt-3" onClick={() => void retryChunks()}>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>

View File

@ -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,
})
}

View File

@ -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<number>({
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 <RevisionLoadingState />
if (selectedRevision !== null && !revision && revisionsQuery.error)
if (requestedRevision !== null && !revision && error)
return (
<RevisionErrorState
description={t(($) => $['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 (
<RevisionErrorState
description={t(($) => $['newKnowledge.documentNotFoundDescription'])}
@ -149,13 +108,13 @@ export function DocumentRevisionBrowser() {
/>
)
if (effectiveRevision === undefined && revisionsQuery.isPending) return <RevisionLoadingState />
if (effectiveRevision === undefined && isPending) return <RevisionLoadingState />
if (effectiveRevision === undefined && revisionsQuery.error)
if (effectiveRevision === undefined && error)
return (
<RevisionErrorState
description={t(($) => $['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 && (
<div
className="mt-4 flex flex-wrap items-center justify-between gap-2 rounded-lg bg-state-warning-hover px-3 py-2 system-xs-regular text-text-warning"
role="alert"
>
<span>{t(($) => $['newKnowledge.documentRevisionsLoadError'])}</span>
<Button onClick={() => void revisionsQuery.refetch()}>
<Button onClick={() => void retryRevisions()}>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
<DocumentRevisionData
document={document}
effectiveRevision={effectiveRevision}
knowledgeSpaceId={knowledgeSpaceId}
locale={locale}
onSelectChunk={(chunkId) => void setDocumentLocation({ chunk: chunkId })}
revision={revision}
selectedChunkId={selectedChunkId ?? undefined}
/>
<DocumentRevisionData />
</>
)
}

View File

@ -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<LogicalDocumentRevision, null>
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 (
<div className="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[14rem_minmax(0,1fr)_20rem] xl:gap-0">
<DocumentChunkTreePanel
key={`tree:${revisionSessionKey}`}
chunkCount={chunks.length}
error={Boolean(chunksQuery.error)}
fetchNextPage={chunksQuery.fetchNextPage}
hasNextPage={chunksQuery.hasNextPage}
isFetchNextPageError={chunksQuery.isFetchNextPageError}
isFetchingNextPage={chunksQuery.isFetchingNextPage}
isPending={chunksQuery.isPending}
onRetry={() => void chunksQuery.refetch()}
onSelectChunk={onSelectChunk}
selectedChunkId={selectedBlock?.chunk.id}
tree={detailModel.tree}
/>
<DocumentReadingPane
key={`content:${revisionSessionKey}`}
contentBlocks={detailModel.contentBlocks}
isLoadingMore={chunksQuery.isFetchingNextPage}
multimodalItems={multimodalItems}
selectedChunkId={selectedBlock?.chunk.id}
/>
<DocumentFactsSidebar
key={`facts:${document.id}`}
chunksComplete={
Boolean(chunksQuery.data) &&
!chunksQuery.error &&
!chunksQuery.hasNextPage &&
!chunksQuery.isFetchingNextPage &&
!chunksQuery.isFetchNextPageError
}
controlSpaceId={knowledgeSpaceId}
document={document}
indexChunks={detailModel.indexChunks}
locale={locale}
revision={revision}
/>
<RequestedChunkPageLoader />
<DocumentChunkTreePanel key={`tree:${revisionSessionKey}`} />
<DocumentReadingPane key={`content:${revisionSessionKey}`} />
<DocumentFactsSidebar />
</div>
)
}

View File

@ -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<number>({
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 },
)

View File

@ -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())

View File

@ -1,3 +1,4 @@
import { atom } from 'jotai'
import { atomWithLazy } from 'jotai/utils'
export const documentDetailKnowledgeSpaceIdAtom = atomWithLazy<string>(() => {
@ -7,3 +8,7 @@ export const documentDetailKnowledgeSpaceIdAtom = atomWithLazy<string>(() => {
export const documentDetailDocumentIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing document detail document id')
})
export const documentDetailRequestedRevisionAtom = atom<number | null>(null)
export const documentDetailRequestedChunkIdAtom = atom<string | null>(null)

View File

@ -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()
})

View File

@ -0,0 +1,22 @@
import { atom } from 'jotai'
type DocumentDetailLocation = {
chunk?: string | null
revision?: number | null
}
type DocumentDetailLocationRuntime = {
setDocumentLocation: (location: DocumentDetailLocation) => Promise<URLSearchParams>
}
const unavailableLocationRuntime = async () => {
throw new Error('Document detail location runtime is unavailable')
}
export const documentDetailLocationRuntimeAtom = atom<DocumentDetailLocationRuntime>({
setDocumentLocation: unavailableLocationRuntime,
})
export const selectDocumentChunkAtom = atom(null, (get, _set, chunkId: string) =>
get(documentDetailLocationRuntimeAtom).setDocumentLocation({ chunk: chunkId }),
)