From d3f59d84589003f06cbb12bbfd4ef3197a1d92f5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:21:34 +0800 Subject: [PATCH] refactor(knowledge-fs): decompose overview page --- .../new-rag/overview/__tests__/page.spec.tsx | 123 +++-- .../__tests__/state-boundary.spec.tsx | 63 +++ .../new-rag/overview/overview-activity.tsx | 94 ++-- .../new-rag/overview/overview-attention.tsx | 27 +- .../new-rag/overview/overview-inventory.tsx | 41 +- .../new-rag/overview/overview-metrics.tsx | 85 +++- .../new-rag/overview/overview-onboarding.tsx | 192 ++++---- web/features/new-rag/overview/page.tsx | 451 +++++------------- .../new-rag/overview/state-boundary.tsx | 48 ++ web/features/new-rag/overview/state.ts | 254 ++++++++++ 10 files changed, 817 insertions(+), 561 deletions(-) create mode 100644 web/features/new-rag/overview/__tests__/state-boundary.spec.tsx create mode 100644 web/features/new-rag/overview/state-boundary.tsx create mode 100644 web/features/new-rag/overview/state.ts diff --git a/web/features/new-rag/overview/__tests__/page.spec.tsx b/web/features/new-rag/overview/__tests__/page.spec.tsx index c4af6657f65..b633f673609 100644 --- a/web/features/new-rag/overview/__tests__/page.spec.tsx +++ b/web/features/new-rag/overview/__tests__/page.spec.tsx @@ -1,4 +1,5 @@ -import { screen, waitFor, within } from '@testing-library/react' +import type { Getter } from 'jotai' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' import { renderWithNuqs } from '@/test/nuqs-testing' @@ -172,6 +173,11 @@ const systemFeaturesState = vi.hoisted(() => ({ uploadAvailableAtom: Symbol('knowledgeFsUploadEnabledAtom'), })) +const jotaiQueryMocks = vi.hoisted(() => ({ + bump: undefined as undefined | (() => void), + refetchActivity: vi.fn(), +})) + vi.mock('echarts-for-react', () => ({ default: ({ option }: { option: unknown }) => { chartOptions.current = option @@ -202,6 +208,45 @@ vi.mock('jotai', async (importOriginal) => { } }) +vi.mock('jotai-tanstack-query', async () => { + const { atom, getDefaultStore } = await import('jotai/vanilla') + const revisionAtom = atom(0) + jotaiQueryMocks.bump = () => + getDefaultStore().set(revisionAtom, (revision: number) => revision + 1) + + return { + atomWithInfiniteQuery: (getOptions: (get: Getter) => { queryKey: string[] }) => + atom((get) => { + get(revisionAtom) + getOptions(get) + return { + data: { pages: [{ data: queryData.tasks, next_cursor: null }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: tasksQueryState.isError, + isFetchingNextPage: false, + isPending: tasksQueryState.isPending, + isRefetching: tasksQueryState.isRefetching, + refetch: tasksQueryState.refetch, + } + }), + atomWithQuery: (getOptions: (get: Getter) => { input?: unknown; queryKey: string[] }) => + atom((get) => { + get(revisionAtom) + const options = getOptions(get) + const name = options.queryKey[0] as keyof typeof overviewQueryState + const state = overviewQueryState[name] + const skipped = typeof options.input === 'symbol' + return { + data: state.isError || skipped ? undefined : queryData[name], + ...state, + isRefetching: state.isFetching, + refetch: name === 'activity' ? jotaiQueryMocks.refetchActivity : vi.fn(), + } + }), + } +}) + vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { @@ -307,6 +352,18 @@ vi.mock('@/service/client', () => { } }) +function renderOverviewWithNuqs(...args: Parameters) { + jotaiQueryMocks.bump?.() + const rendered = renderWithNuqs(...args) + return { + ...rendered, + rerender: (ui: Parameters[0]) => { + act(() => jotaiQueryMocks.bump?.()) + rendered.rerender(ui) + }, + } +} + describe('KnowledgeOverviewPage', () => { beforeEach(() => { vi.clearAllMocks() @@ -357,7 +414,9 @@ describe('KnowledgeOverviewPage', () => { it('refreshes the metrics query when a user switches the overview range', async () => { const user = userEvent.setup() - const { onUrlUpdate } = renderWithNuqs() + const { onUrlUpdate } = renderOverviewWithNuqs( + , + ) const sevenDayRadio = screen.getByRole('radio', { name: 'dataset.newKnowledge.overview.sevenDays', @@ -387,7 +446,7 @@ describe('KnowledgeOverviewPage', () => { }) it('restores the selected overview range from the URL', () => { - renderWithNuqs(, { + renderOverviewWithNuqs(, { searchParams: '?window=30d', }) @@ -411,7 +470,7 @@ describe('KnowledgeOverviewPage', () => { }) it('renders ratio fields from the API contract as percentages', () => { - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.getByText('84%')).toBeInTheDocument() expect(screen.getByText('+100%')).toBeInTheDocument() @@ -426,7 +485,7 @@ describe('KnowledgeOverviewPage', () => { uploads: 1, } - renderWithNuqs() + renderOverviewWithNuqs() const inventoryBar = screen.getByLabelText('dataset.newKnowledge.overview.sources') expect(inventoryBar.children).toHaveLength(1) @@ -445,7 +504,7 @@ describe('KnowledgeOverviewPage', () => { }, ] - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.getByText('84%')).toBeInTheDocument() expect(screen.getByLabelText('query outcomes chart')).toBeInTheDocument() @@ -461,7 +520,7 @@ describe('KnowledgeOverviewPage', () => { }, ] - renderWithNuqs() + renderOverviewWithNuqs() const options = chartOptions.current as { series: Array<{ smooth: boolean }> @@ -475,7 +534,7 @@ describe('KnowledgeOverviewPage', () => { }) it('shows the member who performed an activity instead of a system placeholder', () => { - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.getByText('Ada')).toBeInTheDocument() expect(screen.queryByText('dataset.newKnowledge.overview.system')).not.toBeInTheDocument() @@ -486,7 +545,9 @@ describe('KnowledgeOverviewPage', () => { queryData.stats.documents = 0 for (const state of Object.values(overviewQueryState)) state.isPending = true - const { rerender } = renderWithNuqs() + const { rerender } = renderOverviewWithNuqs( + , + ) expect( screen.getByRole('radiogroup', { name: 'dataset.newKnowledge.overview.timeRange' }), @@ -525,7 +586,7 @@ describe('KnowledgeOverviewPage', () => { const user = userEvent.setup() overviewQueryState.activity.isError = true - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.getByText('dataset.newKnowledge.tasksErrorDescription')).toBeInTheDocument() const activitySection = screen @@ -559,7 +620,7 @@ describe('KnowledgeOverviewPage', () => { title: `Source ${index} has not synced in 7 days`, updated_at: '2026-07-29T08:00:00Z', })) - const rendered = renderWithNuqs() + const rendered = renderOverviewWithNuqs() expect( screen.getAllByText('dataset.newKnowledge.overview.attention.staleSource.title'), @@ -614,7 +675,7 @@ describe('KnowledgeOverviewPage', () => { }, ] - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByText('dataset.newKnowledge.overview.attention.modelReadiness.title'), @@ -635,7 +696,7 @@ describe('KnowledgeOverviewPage', () => { it('opens the complete activity view from recent activity', async () => { const user = userEvent.setup() - renderWithNuqs() + renderOverviewWithNuqs() const whenHeader = screen.getByRole('columnheader', { name: 'dataset.newKnowledge.overview.when', }) @@ -664,7 +725,7 @@ describe('KnowledgeOverviewPage', () => { it('enables the complete activity query only while its drawer is open', async () => { const user = userEvent.setup() - renderWithNuqs() + renderOverviewWithNuqs() expect(infiniteOptionsMocks.activity.mock.lastCall?.[0]).toMatchObject({ enabled: false }) @@ -677,7 +738,7 @@ describe('KnowledgeOverviewPage', () => { it('restarts activity pagination when the date or operator filter changes', async () => { const user = userEvent.setup() - renderWithNuqs() + renderOverviewWithNuqs() await user.click( screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }), ) @@ -741,7 +802,7 @@ describe('KnowledgeOverviewPage', () => { queryData.activity.data[0]!.occurred_at = new Date(Date.now() - 2 * 60 * 60_000).toISOString() queryData.activity.data[0]!.result = 'failure' - renderWithNuqs() + renderOverviewWithNuqs() await user.click( screen.getByRole('button', { name: 'dataset.newKnowledge.overview.allActivity' }), ) @@ -763,7 +824,7 @@ describe('KnowledgeOverviewPage', () => { } queryData.activity.data[0]!.result = 'success' - renderWithNuqs() + renderOverviewWithNuqs() const label = 'dataset.newKnowledge.qualityPage.question: How do permissions work? — research' expect(within(screen.getByRole('table')).getByText(label)).toBeInTheDocument() @@ -780,7 +841,7 @@ describe('KnowledgeOverviewPage', () => { it('refreshes overview snapshots until they catch up with a completed background task', () => { queryData.tasks[0]!.state = 'running' queryData.tasks[0]!.updated_at = '2026-07-29T10:00:00Z' - const rendered = renderWithNuqs() + const rendered = renderOverviewWithNuqs() const statsRefreshInterval = () => { const options = queryOptionsMocks.stats.mock.lastCall?.[0] as { refetchInterval: (query: { state: { data: typeof queryData.stats } }) => false | number @@ -803,7 +864,7 @@ describe('KnowledgeOverviewPage', () => { queryData.stats.source_count = 0 queryData.stats.documents = 0 - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.noSources' }), @@ -835,7 +896,7 @@ describe('KnowledgeOverviewPage', () => { queryData.stats.source_count = 0 queryData.stats.documents = 0 - renderWithNuqs() + renderOverviewWithNuqs() const connectSource = screen.getByRole('link', { name: 'dataset.newKnowledge.overview.connectSource', @@ -858,7 +919,7 @@ describe('KnowledgeOverviewPage', () => { queryData.tasks[0]!.operation = 'document_upload' queryData.tasks[0]!.state = 'failed' - renderWithNuqs() + renderOverviewWithNuqs() const overviewTitle = screen.getByRole('heading', { name: 'dataset.newKnowledge.overviewTitle', @@ -915,7 +976,7 @@ describe('KnowledgeOverviewPage', () => { }, ] - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect( @@ -932,7 +993,7 @@ describe('KnowledgeOverviewPage', () => { permissionState.datasetKeys = ['dataset.acl.readonly'] permissionState.workspaceKeys = ['dataset.acl.readonly'] - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.queryByRole('link', { name: 'dataset.newKnowledge.overview.connectSource' }), @@ -950,7 +1011,7 @@ describe('KnowledgeOverviewPage', () => { queryData.stats.documents = 0 permissionState.datasetKeys = ['dataset.acl.readonly'] permissionState.workspaceKeys = ['dataset.external.connect'] - const rendered = renderWithNuqs() + const rendered = renderOverviewWithNuqs() expect( screen.getByRole('link', { name: 'dataset.newKnowledge.overview.connectSource' }), @@ -975,7 +1036,7 @@ describe('KnowledgeOverviewPage', () => { overviewQueryState.attention.isError = true overviewQueryState.inventory.isError = true - renderWithNuqs() + renderOverviewWithNuqs() const healthSection = screen .getByRole('heading', { name: 'dataset.newKnowledge.overview.needsAttention' }) @@ -1005,7 +1066,7 @@ describe('KnowledgeOverviewPage', () => { queryData.stats.source_count = 0 queryData.stats.documents = 1 - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.recentActivity' }), @@ -1016,7 +1077,7 @@ describe('KnowledgeOverviewPage', () => { }) it('labels index coverage as slices rather than documents', () => { - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByText('dataset.newKnowledge.overview.indexedSlices:{"indexed":4,"total":5}'), @@ -1037,7 +1098,7 @@ describe('KnowledgeOverviewPage', () => { queryData.tasks[0]!.source_id = 'source-1' queryData.tasks[0]!.state = 'running' - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByRole('heading', { @@ -1065,7 +1126,7 @@ describe('KnowledgeOverviewPage', () => { queryData.tasks[0]!.progress_total = 0 queryData.tasks[0]!.state = 'queued' - renderWithNuqs() + renderOverviewWithNuqs() const progressbar = screen.getByRole('progressbar', { name: 'dataset.newKnowledge.overview.indexing', @@ -1082,7 +1143,7 @@ describe('KnowledgeOverviewPage', () => { queryData.tasks[0]!.source_id = null queryData.tasks[0]!.state = 'running' - renderWithNuqs() + renderOverviewWithNuqs() expect( screen.getByRole('heading', { name: 'dataset.newKnowledge.overview.indexing' }), @@ -1094,7 +1155,7 @@ describe('KnowledgeOverviewPage', () => { vi.spyOn(Date, 'now').mockReturnValue(now) queryData.activity.data[0]!.occurred_at = '2026-07-29T08:05:00Z' - renderWithNuqs() + renderOverviewWithNuqs() expect(screen.getByText('59 minutes ago')).toBeInTheDocument() }) diff --git a/web/features/new-rag/overview/__tests__/state-boundary.spec.tsx b/web/features/new-rag/overview/__tests__/state-boundary.spec.tsx new file mode 100644 index 00000000000..77e06423bd4 --- /dev/null +++ b/web/features/new-rag/overview/__tests__/state-boundary.spec.tsx @@ -0,0 +1,63 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { atom, createStore, Provider, useAtomValue } from 'jotai' +import { useState } from 'react' +import { overviewKnowledgeSpaceIdAtom, overviewWindowAtom } from '../state' +import { OverviewStateBoundary } from '../state-boundary' + +const parentValueAtom = atom('missing') + +function OverviewInputs({ label }: { label: string }) { + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + const window = useAtomValue(overviewWindowAtom) + const parentValue = useAtomValue(parentValueAtom) + return

{`${label}:${knowledgeSpaceId}:${window}:${parentValue}`}

+} + +function LocalSession() { + const [count, setCount] = useState(0) + return +} + +describe('OverviewStateBoundary', () => { + it('isolates route inputs between sibling instances while observing the parent store', () => { + const store = createStore() + store.set(parentValueAtom, 'parent-visible') + + render( + + + + + + + + , + ) + + expect(screen.getByText('first:space-a:24h:parent-visible')).toBeInTheDocument() + expect(screen.getByText('second:space-b:7d:parent-visible')).toBeInTheDocument() + }) + + it('resets owner-local sessions when the knowledge space identity changes', async () => { + const user = userEvent.setup() + const rendered = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: '0' })) + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + + act(() => + rendered.rerender( + + + , + ), + ) + + expect(screen.getByRole('button', { name: '0' })).toBeInTheDocument() + }) +}) diff --git a/web/features/new-rag/overview/overview-activity.tsx b/web/features/new-rag/overview/overview-activity.tsx index 4bf55dca89c..38026b681f8 100644 --- a/web/features/new-rag/overview/overview-activity.tsx +++ b/web/features/new-rag/overview/overview-activity.tsx @@ -25,16 +25,26 @@ import { SelectItemText, SelectTrigger, } from '@langgenius/dify-ui/select' -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useInfiniteQuery } from '@tanstack/react-query' import dayjs from 'dayjs' +import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' import { consoleQuery } from '@/service/client' import { useMembers } from '@/service/use-common' import { activityDatesForRange } from './overview-activity-types' -import { OVERVIEW_REFRESH_INTERVAL } from './overview-format' import { EmptyInline, Panel, Skeleton } from './overview-panel' +import { + overviewActivityPreviewDataAtom, + overviewActivityPreviewErrorAtom, + overviewActivityPreviewPendingAtom, + overviewActivityPreviewRefetchingAtom, + overviewKnowledgeSpaceIdAtom, + overviewShowEmptyModulesAtom, + overviewShowIndexingAtom, + retryOverviewActivityPreviewAtom, +} from './state' const ACTIVITY_RANGES: ActivityRange[] = ['today', '7d', '30d', '90d', 'all', 'custom'] const ACTIVITY_PAGE_SIZE = 20 @@ -150,29 +160,17 @@ function ActivityActor({ ) } -function RecentActivity({ - activities, - empty, - error, - indexing = false, - loading, - members, - onOpenAll, - onRetry, - retrying, -}: { - activities: KnowledgeFsOverviewActivityResponse[] - empty: boolean - error: boolean - indexing?: boolean - loading: boolean - onOpenAll: () => void - onRetry: () => void - retrying: boolean - members: Member[] -}) { +function RecentActivity({ onOpenAll }: { onOpenAll: () => void }) { const { t, i18n } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') + const activities = useAtomValue(overviewActivityPreviewDataAtom) + const empty = useAtomValue(overviewShowEmptyModulesAtom) + const error = useAtomValue(overviewActivityPreviewErrorAtom) + const indexing = useAtomValue(overviewShowIndexingAtom) + const loading = useAtomValue(overviewActivityPreviewPendingAtom) + const retrying = useAtomValue(overviewActivityPreviewRefetchingAtom) + const retry = useSetAtom(retryOverviewActivityPreviewAtom) + const members = useMembers().data?.accounts ?? [] const formatWhen = (value: string) => { const timestamp = new Date(value) const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 60_000)) @@ -207,7 +205,7 @@ function RecentActivity({ loading={retrying} size="small" variant="secondary" - onClick={onRetry} + onClick={retry} > {tCommon(($) => $['operation.retry'])} @@ -315,51 +313,15 @@ function RecentActivity({ ) } -export function OverviewActivity({ - empty, - hasActiveTasks, - indexing, - knowledgeSpaceId, -}: { - empty: boolean - hasActiveTasks: boolean - indexing: boolean - knowledgeSpaceId: string -}) { +export function OverviewActivity() { const [drawerOpen, setDrawerOpen] = useState(false) - const membersQuery = useMembers() - const previewQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.activity.get.queryOptions({ - input: { - params: { control_space_id: knowledgeSpaceId }, - query: { limit: 5 }, - }, - refetchInterval: hasActiveTasks ? OVERVIEW_REFRESH_INTERVAL : false, - }), - ) - const members = membersQuery.data?.accounts ?? [] return ( <>
- setDrawerOpen(true)} - onRetry={() => void previewQuery.refetch()} - /> + setDrawerOpen(true)} />
- + ) } @@ -440,19 +402,17 @@ function ActivityDateRangePicker({ } function ActivityDrawer({ - knowledgeSpaceId, - members, onOpenChange, open, }: { - knowledgeSpaceId: string - members: Member[] onOpenChange: (open: boolean) => void open: boolean }) { const { t, i18n } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const { t: tActivityLog } = useTranslation('appLog') + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + const members = useMembers().data?.accounts ?? [] const [range, setRange] = useState('today') const [dates, setDates] = useState(() => activityDatesForRange('today')) const [operator, setOperator] = useState('all') diff --git a/web/features/new-rag/overview/overview-attention.tsx b/web/features/new-rag/overview/overview-attention.tsx index 04d725612c6..71213fdce6d 100644 --- a/web/features/new-rag/overview/overview-attention.tsx +++ b/web/features/new-rag/overview/overview-attention.tsx @@ -3,6 +3,7 @@ import type { KnowledgeFsOverviewAttentionResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Link from '@/next/link' @@ -13,6 +14,13 @@ import { newKnowledgeSettingsPath, } from '../routes' import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' +import { + overviewAttentionDataAtom, + overviewAttentionErrorAtom, + overviewAttentionPendingAtom, + overviewKnowledgeSpaceIdAtom, + overviewShowEmptyModulesAtom, +} from './state' const ATTENTION_PAGE_SIZE = 4 @@ -56,21 +64,14 @@ function attentionPresentation( return { title: issue.title } } -export function AttentionPanel({ - attention, - empty, - error, - knowledgeSpaceId, - loading, -}: { - attention: KnowledgeFsOverviewAttentionResponse[] - empty: boolean - error: boolean - knowledgeSpaceId: string - loading: boolean -}) { +export function AttentionPanel() { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') + const attention = useAtomValue(overviewAttentionDataAtom) + const empty = useAtomValue(overviewShowEmptyModulesAtom) + const error = useAtomValue(overviewAttentionErrorAtom) + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + const loading = useAtomValue(overviewAttentionPendingAtom) const [issuePage, setIssuePage] = useState(0) // Dify owns product authorization; ignore responses cached or served by an older backend that // still contain the retired KnowledgeFS-local permission readiness rule. diff --git a/web/features/new-rag/overview/overview-inventory.tsx b/web/features/new-rag/overview/overview-inventory.tsx index 2c458a634fd..7944d27817c 100644 --- a/web/features/new-rag/overview/overview-inventory.tsx +++ b/web/features/new-rag/overview/overview-inventory.tsx @@ -1,36 +1,27 @@ 'use client' +import type { KnowledgeFsOverviewInventoryResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { compactNumber } from './overview-format' import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' +import { + overviewInventoryDataAtom, + overviewInventoryErrorAtom, + overviewInventoryPendingAtom, + overviewShowEmptyModulesAtom, + overviewShowIndexingAtom, +} from './state' -export function InventoryPanel({ - empty, - error, - indexing = false, - inventory, - loading, -}: { - empty: boolean - error: boolean - indexing?: boolean - inventory: - | { - graph_entities: { added_last_7d: number; total: number } - graph_relations: { added_last_7d: number; total: number } - index_coverage: { indexed: number; percentage: number; total: number } - source_categories: { - crawl: number - online_documents: number - online_drives: number - uploads: number - } - } - | undefined - loading: boolean -}) { +export function InventoryPanel() { const { t } = useTranslation('dataset') + const empty = useAtomValue(overviewShowEmptyModulesAtom) + const error = useAtomValue(overviewInventoryErrorAtom) + const indexing = useAtomValue(overviewShowIndexingAtom) + const inventory: KnowledgeFsOverviewInventoryResponse | undefined = + useAtomValue(overviewInventoryDataAtom) + const loading = useAtomValue(overviewInventoryPendingAtom) const categories = inventory ? [ { diff --git a/web/features/new-rag/overview/overview-metrics.tsx b/web/features/new-rag/overview/overview-metrics.tsx index 9f1c6c0bce7..e16b2d5c999 100644 --- a/web/features/new-rag/overview/overview-metrics.tsx +++ b/web/features/new-rag/overview/overview-metrics.tsx @@ -1,13 +1,22 @@ 'use client' -import type { KnowledgeFsOverviewQueryOutcomeBucketResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' import { cn } from '@langgenius/dify-ui/cn' import ReactECharts from 'echarts-for-react' +import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' +import { changeLabel, compactNumber, formatDuration } from './overview-format' import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' import { buildQueryOutcomesChartOptions } from './query-outcomes-chart-options' +import { + overviewOutcomesDataAtom, + overviewOutcomesErrorAtom, + overviewOutcomesPendingAtom, + overviewShowEmptyModulesAtom, + overviewStatsDataAtom, + overviewStatsPendingAtom, +} from './state' export function MetricCard({ change, @@ -83,22 +92,70 @@ export function MetricCard({ ) } -export function QueryOutcomesChart({ - buckets, - empty, - error, - loading, -}: { - buckets: KnowledgeFsOverviewQueryOutcomeBucketResponse[] - empty: boolean - error: boolean - loading: boolean -}) { +export function OverviewMetrics() { + const empty = useAtomValue(overviewShowEmptyModulesAtom) + const loading = useAtomValue(overviewStatsPendingAtom) + const stats = useAtomValue(overviewStatsDataAtom) + const { t } = useTranslation('dataset') + + return ( +
+ $['newKnowledge.overview.queries'])} + help={t(($) => $['newKnowledge.overview.queriesHelp'])} + value={stats ? compactNumber(stats.queries.value) : '—'} + change={ + stats + ? changeLabel( + stats.queries.change_rate === null ? null : stats.queries.change_rate * 100, + ) + : undefined + } + /> + $['newKnowledge.overview.answerRate'])} + help={t(($) => $['newKnowledge.overview.answerRateHelp'])} + value={stats ? `${Math.round(stats.answer_rate.value * 100)}%` : '—'} + change={stats ? changeLabel(stats.answer_rate.change_percentage_points, 'pp') : undefined} + /> + $['newKnowledge.overview.documents'])} + value={stats ? compactNumber(stats.documents) : '—'} + /> + $['newKnowledge.overview.linkedApps'])} + value={stats ? compactNumber(stats.linked_apps) : '—'} + /> + $['newKnowledge.overview.freshness'])} + help={t(($) => $['newKnowledge.overview.freshnessHelp'])} + value={formatDuration(stats?.freshness_seconds)} + /> +
+ ) +} + +export function QueryOutcomesChart() { + const outcomes = useAtomValue(overviewOutcomesDataAtom) + const empty = useAtomValue(overviewShowEmptyModulesAtom) + const error = useAtomValue(overviewOutcomesErrorAtom) + const loading = useAtomValue(overviewOutcomesPendingAtom) + const buckets = outcomes?.buckets const { t, i18n } = useTranslation('dataset') const chartOptions = useMemo( () => buildQueryOutcomesChartOptions({ - buckets, + buckets: buckets ?? [], labels: { answered: t(($) => $['newKnowledge.overview.answered']), lowConfidence: t(($) => $['newKnowledge.overview.lowConfidence']), @@ -175,7 +232,7 @@ export function QueryOutcomesChart({ ))} - ) : buckets.length ? ( + ) : buckets?.length ? ( <>

{t(($) => $['newKnowledge.overview.queryOutcomes'])}: {buckets.length} diff --git a/web/features/new-rag/overview/overview-onboarding.tsx b/web/features/new-rag/overview/overview-onboarding.tsx index dc0d58ca9d8..7dfb0418a2f 100644 --- a/web/features/new-rag/overview/overview-onboarding.tsx +++ b/web/features/new-rag/overview/overview-onboarding.tsx @@ -1,30 +1,93 @@ 'use client' import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { MouseEvent, ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useMutation } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { + datasetDefaultPermissionKeysAtom, + workspacePermissionKeysAtom, +} from '@/context/permission-state' +import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state' import Link from '@/next/link' import { consoleQuery } from '@/service/client' +import { DatasetACLPermission, hasPermission } from '@/utils/permission' import { newKnowledgeAddSourcePath, newKnowledgeDocumentsPath } from '../routes' +import { + overviewEmptyAtom, + overviewFailedFirstSourceTaskAtom, + overviewIndexingSourceNameAtom, + overviewIndexingTaskAtom, + overviewKnowledgeSpaceIdAtom, + overviewShowIndexingAtom, + refreshOverviewBackgroundTasksAtom, +} from './state' -export function Onboarding({ - canConnectSource, - canUpload, - indexingTask, - indexingSourceName, - knowledgeSpaceId, -}: { - canConnectSource: boolean - canUpload: boolean - indexingTask?: KnowledgeFsBackgroundTaskResponse - indexingSourceName?: string - knowledgeSpaceId: string -}) { +export function OverviewOnboarding() { + const empty = useAtomValue(overviewEmptyAtom) + const showIndexing = useAtomValue(overviewShowIndexingAtom) + let content: ReactNode = null + if (showIndexing) content = + else if (empty) content = + return content ?

{content}
: null +} + +function IndexingProgress() { const { t } = useTranslation('dataset') + const indexingTask = useAtomValue(overviewIndexingTaskAtom) + const indexingSourceName = useAtomValue(overviewIndexingSourceNameAtom) + if (!indexingTask) return null + + const progressKnown = indexingTask.progress_total > 0 + return ( +
+

+ {indexingSourceName + ? t(($) => $['newKnowledge.overview.indexingSource'], { source: indexingSourceName }) + : t(($) => $['newKnowledge.overview.indexing'])} +

+

+ {t(($) => $['newKnowledge.overview.indexingConnectedDescription'])} +

+
+
$['newKnowledge.overview.indexing'])} + aria-valuemin={0} + aria-valuemax={progressKnown ? indexingTask.progress_total : undefined} + aria-valuenow={progressKnown ? indexingTask.progress_completed : undefined} + className="h-2 overflow-hidden rounded-full bg-util-colors-gray-gray-200" + > +
+
+

+ {t(($) => $['newKnowledge.overview.indexedDocuments'], { + indexed: indexingTask.progress_completed, + total: indexingTask.progress_total, + })} +

+
+
+ ) +} + +function EmptyKnowledgeOnboarding() { + const { t } = useTranslation('dataset') + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom) + const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom) const [pendingAction, setPendingAction] = useState<'source' | 'upload'>() + const canConnectSource = hasPermission(workspacePermissionKeys, 'dataset.external.connect') + const canUpload = + uploadAvailable && hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit) const actionCount = Number(canConnectSource) + Number(canUpload) const description = canConnectSource ? canUpload @@ -33,43 +96,22 @@ export function Onboarding({ : canUpload ? t(($) => $['newKnowledge.uploadFilesDescription']) : t(($) => $['newKnowledge.overview.readOnlyDescription']) - if (indexingTask) { - const progressKnown = indexingTask.progress_total > 0 - return ( -
-

- {indexingSourceName - ? t(($) => $['newKnowledge.overview.indexingSource'], { - source: indexingSourceName, - }) - : t(($) => $['newKnowledge.overview.indexing'])} -

-

- {t(($) => $['newKnowledge.overview.indexingConnectedDescription'])} -

-
-
$['newKnowledge.overview.indexing'])} - aria-valuemin={0} - aria-valuemax={progressKnown ? indexingTask.progress_total : undefined} - aria-valuenow={progressKnown ? indexingTask.progress_completed : undefined} - className="h-2 overflow-hidden rounded-full bg-util-colors-gray-gray-200" - > -
-
-

- {t(($) => $['newKnowledge.overview.indexedDocuments'], { - indexed: indexingTask.progress_completed, - total: indexingTask.progress_total, - })} -

-
-
+ + const beginNavigation = (action: 'source' | 'upload', event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey ) + return + if (pendingAction !== undefined) { + event.preventDefault() + return + } + setPendingAction(action) } return ( @@ -108,22 +150,7 @@ export function Onboarding({ )} href={newKnowledgeAddSourcePath(knowledgeSpaceId)} tabIndex={pendingAction === undefined ? undefined : -1} - onClick={(event) => { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return - if (pendingAction !== undefined) { - event.preventDefault() - return - } - setPendingAction('source') - }} + onClick={(event) => beginNavigation('source', event)} > { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return - if (pendingAction !== undefined) { - event.preventDefault() - return - } - setPendingAction('upload') - }} + onClick={(event) => beginNavigation('upload', event)} > +} + +function FirstSourceTaskFailureSession({ failedTask, - knowledgeSpaceId, - onRetryTask, }: { failedTask: KnowledgeFsBackgroundTaskResponse - knowledgeSpaceId: string - onRetryTask: () => Promise }) { const { t } = useTranslation('dataset') + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + const refreshBackgroundTasks = useSetAtom(refreshOverviewBackgroundTasksAtom) const retryTaskMutation = useMutation( consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.byTaskKind.byTaskId.retry.post.mutationOptions(), ) @@ -212,7 +229,6 @@ export function FirstSourceTaskFailureBanner({ : t(($) => $['newKnowledge.addSourceFailed']) const retryFailedTask = async () => { if (!failedTask.can_retry || retryTaskMutation.isPending) return - try { await retryTaskMutation.mutateAsync({ params: { @@ -221,7 +237,7 @@ export function FirstSourceTaskFailureBanner({ task_kind: failedTask.task_kind, }, }) - await onRetryTask() + await refreshBackgroundTasks() } catch { // Mutation state keeps the retry feedback visible. } diff --git a/web/features/new-rag/overview/page.tsx b/web/features/new-rag/overview/page.tsx index bcd9dbb644f..d996b1042e4 100644 --- a/web/features/new-rag/overview/page.tsx +++ b/web/features/new-rag/overview/page.tsx @@ -1,325 +1,65 @@ 'use client' -import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { OverviewWindow } from './state' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' -import { skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useTranslation } from 'react-i18next' -import { - datasetDefaultPermissionKeysAtom, - workspacePermissionKeysAtom, -} from '@/context/permission-state' -import { knowledgeFsUploadEnabledAtom } from '@/features/system-features/state' -import { consoleQuery } from '@/service/client' -import { DatasetACLPermission, hasPermission } from '@/utils/permission' import { KnowledgeModelReadinessBanner } from '../components/knowledge-model-readiness-banner' import { OverviewActivity } from './overview-activity' import { AttentionPanel } from './overview-attention' -import { - changeLabel, - compactNumber, - formatDuration, - OVERVIEW_REFRESH_INTERVAL, - overviewRefreshInterval, -} from './overview-format' import { InventoryPanel } from './overview-inventory' -import { MetricCard, QueryOutcomesChart } from './overview-metrics' -import { FirstSourceTaskFailureBanner, Onboarding } from './overview-onboarding' - -type OverviewWindow = '24h' | '7d' | '30d' +import { OverviewMetrics, QueryOutcomesChart } from './overview-metrics' +import { FirstSourceTaskFailureBanner, OverviewOnboarding } from './overview-onboarding' +import { + overviewEmptyAtom, + overviewFirstLoadFailedAtom, + overviewKnowledgeSpaceIdAtom, + overviewPageLoadingAtom, + overviewShowEmptyModulesAtom, + overviewShowIndexingAtom, + overviewWindowAtom, + retryOverviewSnapshotsAtom, +} from './state' +import { OverviewStateBoundary } from './state-boundary' const WINDOWS: OverviewWindow[] = ['24h', '7d', '30d'] -const ACTIVE_TASK_STATES = new Set([ - 'queued', - 'running', -]) -const TASK_PAGE_SIZE = 20 const overviewWindowParser = parseAsStringLiteral(WINDOWS) .withDefault('24h') .withOptions({ history: 'push' }) -function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) { +export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { + const [window, setWindow] = useQueryState('window', overviewWindowParser) + return ( - task.operation === 'document_processing' || - task.operation === 'document_upload' || - task.operation.includes('import') + + void setWindow(value)} /> + ) } -export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { - const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom) - const uploadAvailable = useAtomValue(knowledgeFsUploadEnabledAtom) - const canConnectSource = hasPermission(workspacePermissionKeys, 'dataset.external.connect') - const canUpload = - uploadAvailable && hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit) - const [window, setWindow] = useQueryState('window', overviewWindowParser) - const tasksQuery = useInfiniteQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.get.infiniteOptions({ - getNextPageParam: (lastPage) => lastPage.next_cursor, - initialPageParam: null as string | null, - input: (pageParam) => ({ - params: { control_space_id: knowledgeSpaceId }, - query: { - ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), - limit: TASK_PAGE_SIZE, - }, - }), - refetchInterval: (query) => - query.state.data?.pages.some((page) => - page.data.some((task) => ACTIVE_TASK_STATES.has(task.state)), - ) - ? OVERVIEW_REFRESH_INTERVAL - : false, - }), - ) - const tasks = tasksQuery.data?.pages.flatMap((page) => page.data) ?? [] - const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATES.has(task.state)) - const latestTaskUpdatedAt = tasks.reduce((latest, task) => { - const updatedAt = Date.parse(task.updated_at) - if (!Number.isFinite(updatedAt)) return latest - return latest === undefined || updatedAt > latest ? updatedAt : latest - }, undefined) - const statsQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.stats.get.queryOptions({ - input: { - params: { control_space_id: knowledgeSpaceId }, - query: { window }, - }, - refetchInterval: (query) => - overviewRefreshInterval({ - generatedAt: query.state.data?.generated_at, - hasActiveTasks, - latestTaskUpdatedAt, - }), - }), - ) - const outcomesQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.queryOutcomes.get.queryOptions({ - input: { - params: { control_space_id: knowledgeSpaceId }, - query: { window }, - }, - refetchInterval: (query) => - overviewRefreshInterval({ - generatedAt: query.state.data?.generated_at, - hasActiveTasks, - latestTaskUpdatedAt, - }), - }), - ) - const inventoryQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.inventory.get.queryOptions({ - input: { params: { control_space_id: knowledgeSpaceId } }, - refetchInterval: (query) => - overviewRefreshInterval({ - generatedAt: query.state.data?.generated_at, - hasActiveTasks, - latestTaskUpdatedAt, - }), - }), - ) - const attentionQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.attention.get.queryOptions({ - input: { - params: { control_space_id: knowledgeSpaceId }, - query: { include_dismissed: false, limit: 100 }, - }, - refetchInterval: hasActiveTasks ? OVERVIEW_REFRESH_INTERVAL : false, - }), - ) - const indexingTask = tasks.find( - (task) => ACTIVE_TASK_STATES.has(task.state) && isFirstSourceTask(task), - ) - const indexingSourceQuery = useQuery( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.get.queryOptions({ - input: indexingTask?.source_id - ? { - params: { - control_space_id: knowledgeSpaceId, - source_id: indexingTask.source_id, - }, - } - : skipToken, - }), - ) - const latestFirstSourceTask = tasks - .filter(isFirstSourceTask) - .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))[0] - const failedTask = latestFirstSourceTask?.state === 'failed' ? latestFirstSourceTask : undefined - const pageLoading = - tasksQuery.isPending || - statsQuery.isPending || - outcomesQuery.isPending || - inventoryQuery.isPending || - attentionQuery.isPending || - (indexingTask?.source_id != null && indexingSourceQuery.isPending) - const firstLoadFailed = - !pageLoading && - (statsQuery.isError || - outcomesQuery.isError || - inventoryQuery.isError || - attentionQuery.isError) - const hasContent = - (statsQuery.data?.source_count ?? 0) > 0 || (statsQuery.data?.documents ?? 0) > 0 - const showIndexing = - !pageLoading && - !inventoryQuery.isError && - (inventoryQuery.data?.index_coverage.indexed ?? 0) === 0 && - indexingTask !== undefined - const empty = !pageLoading && !statsQuery.isError && !hasContent && !showIndexing - const showEmptyModules = empty || showIndexing - const retry = () => - void Promise.all([ - statsQuery.refetch(), - outcomesQuery.refetch(), - inventoryQuery.refetch(), - attentionQuery.refetch(), - tasksQuery.refetch(), - ]) +function KnowledgeOverviewContent({ + onWindowChange, + window, +}: { + onWindowChange: (window: OverviewWindow) => void + window: OverviewWindow +}) { + const showEmptyModules = useAtomValue(overviewShowEmptyModulesAtom) + const showIndexing = useAtomValue(overviewShowIndexingAtom) return (
-
-

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

- {!empty && !showIndexing && ( - - aria-label={t(($) => $['newKnowledge.overview.timeRange'])} - value={window} - onValueChange={(value) => void setWindow(value)} - > - {WINDOWS.map((value) => ( - - key={value} - className="h-6.5 px-2.5" - value={value} - > - {value === '24h' - ? value - : value === '7d' - ? t(($) => $['newKnowledge.overview.sevenDays']) - : t(($) => $['newKnowledge.overview.thirtyDays'])} - - ))} - - )} -
- {empty && failedTask && ( - - )} - - - {pageLoading && ( -

- {tCommon(($) => $.loading)} -

- )} - - {firstLoadFailed && ( -
-

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

- -
- )} - - {showIndexing && ( -
- -
- )} - {empty && ( -
- -
- )} -
- $['newKnowledge.overview.queries'])} - help={t(($) => $['newKnowledge.overview.queriesHelp'])} - value={statsQuery.data ? compactNumber(statsQuery.data.queries.value) : '—'} - change={ - statsQuery.data - ? changeLabel( - statsQuery.data.queries.change_rate === null - ? null - : statsQuery.data.queries.change_rate * 100, - ) - : undefined - } - /> - $['newKnowledge.overview.answerRate'])} - help={t(($) => $['newKnowledge.overview.answerRateHelp'])} - value={ - statsQuery.data ? `${Math.round(statsQuery.data.answer_rate.value * 100)}%` : '—' - } - change={ - statsQuery.data - ? changeLabel(statsQuery.data.answer_rate.change_percentage_points, 'pp') - : undefined - } - /> - $['newKnowledge.overview.documents'])} - value={statsQuery.data ? compactNumber(statsQuery.data.documents) : '—'} - /> - $['newKnowledge.overview.linkedApps'])} - value={statsQuery.data ? compactNumber(statsQuery.data.linked_apps) : '—'} - /> - $['newKnowledge.overview.freshness'])} - help={t(($) => $['newKnowledge.overview.freshnessHelp'])} - value={formatDuration(statsQuery.data?.freshness_seconds)} - /> + + + + + +
+
- - + +
- +
- +
) } + +function OverviewHeader({ + onWindowChange, + window, +}: { + onWindowChange: (window: OverviewWindow) => void + window: OverviewWindow +}) { + const { t } = useTranslation('dataset') + const empty = useAtomValue(overviewEmptyAtom) + const showIndexing = useAtomValue(overviewShowIndexingAtom) + const setGraphWindow = useSetAtom(overviewWindowAtom) + + return ( +
+

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

+ {!empty && !showIndexing && ( + + aria-label={t(($) => $['newKnowledge.overview.timeRange'])} + value={window} + onValueChange={(value) => { + setGraphWindow(value) + onWindowChange(value) + }} + > + {WINDOWS.map((value) => ( + + key={value} + className="h-6.5 px-2.5" + value={value} + > + {value === '24h' + ? value + : value === '7d' + ? t(($) => $['newKnowledge.overview.sevenDays']) + : t(($) => $['newKnowledge.overview.thirtyDays'])} + + ))} + + )} +
+ ) +} + +function OverviewKnowledgeModelReadinessBanner() { + const knowledgeSpaceId = useAtomValue(overviewKnowledgeSpaceIdAtom) + return ( + + ) +} + +function OverviewRecoveryStatus() { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const pageLoading = useAtomValue(overviewPageLoadingAtom) + const firstLoadFailed = useAtomValue(overviewFirstLoadFailedAtom) + const retry = useSetAtom(retryOverviewSnapshotsAtom) + + return ( + <> + {pageLoading && ( +

+ {tCommon(($) => $.loading)} +

+ )} + {firstLoadFailed && ( +
+

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

+ +
+ )} + + ) +} diff --git a/web/features/new-rag/overview/state-boundary.tsx b/web/features/new-rag/overview/state-boundary.tsx new file mode 100644 index 00000000000..b7642338fd5 --- /dev/null +++ b/web/features/new-rag/overview/state-boundary.tsx @@ -0,0 +1,48 @@ +'use client' + +import type { ReactNode } from 'react' +import type { OverviewWindow } from './state' +import { useAtomValue, useSetAtom } from 'jotai' +import { ScopeProvider } from 'jotai-scope' +import { useEffect } from 'react' +import { overviewKnowledgeSpaceIdAtom, overviewWindowAtom } from './state' + +function OverviewWindowBridge({ + children, + window, +}: { + children: ReactNode + window: OverviewWindow +}) { + const currentWindow = useAtomValue(overviewWindowAtom) + const setWindow = useSetAtom(overviewWindowAtom) + + useEffect(() => { + if (currentWindow !== window) setWindow(window) + }, [currentWindow, setWindow, window]) + + return children +} + +export function OverviewStateBoundary({ + children, + knowledgeSpaceId, + window, +}: { + children: ReactNode + knowledgeSpaceId: string + window: OverviewWindow +}) { + return ( + + {children} + + ) +} diff --git a/web/features/new-rag/overview/state.ts b/web/features/new-rag/overview/state.ts new file mode 100644 index 00000000000..b3523afbb0a --- /dev/null +++ b/web/features/new-rag/overview/state.ts @@ -0,0 +1,254 @@ +import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import { skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { atomWithLazy, selectAtom } from 'jotai/utils' +import { consoleQuery } from '@/service/client' +import { OVERVIEW_REFRESH_INTERVAL, overviewRefreshInterval } from './overview-format' + +export type OverviewWindow = '24h' | '7d' | '30d' + +const ACTIVE_TASK_STATES = new Set([ + 'queued', + 'running', +]) +const TASK_PAGE_SIZE = 20 + +export const overviewKnowledgeSpaceIdAtom = atomWithLazy(() => { + throw new Error('Missing overview knowledge space id') +}) +export const overviewWindowAtom = atom('24h') + +function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) { + return ( + task.operation === 'document_processing' || + task.operation === 'document_upload' || + task.operation.includes('import') + ) +} + +const backgroundTasksQueryAtom = atomWithInfiniteQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.get.infiniteOptions({ + getNextPageParam: (lastPage) => lastPage.next_cursor, + initialPageParam: null as string | null, + input: (pageParam) => ({ + params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) }, + query: { + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + limit: TASK_PAGE_SIZE, + }, + }), + refetchInterval: (query) => + query.state.data?.pages.some((page) => + page.data.some((task) => ACTIVE_TASK_STATES.has(task.state)), + ) + ? OVERVIEW_REFRESH_INTERVAL + : false, + }), +) + +const backgroundTasksQueryDataAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.data) +const backgroundTasksRefetchAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.refetch) +const backgroundTasksPendingAtom = selectAtom(backgroundTasksQueryAtom, (query) => query.isPending) + +const backgroundTasksAtom = atom( + (get) => get(backgroundTasksQueryDataAtom)?.pages.flatMap((page) => page.data) ?? [], +) +const overviewHasActiveTasksAtom = atom((get) => + get(backgroundTasksAtom).some((task) => ACTIVE_TASK_STATES.has(task.state)), +) +const latestTaskUpdatedAtAtom = atom((get) => + get(backgroundTasksAtom).reduce((latest, task) => { + const updatedAt = Date.parse(task.updated_at) + if (!Number.isFinite(updatedAt)) return latest + return latest === undefined || updatedAt > latest ? updatedAt : latest + }, undefined), +) +export const overviewIndexingTaskAtom = atom((get) => + get(backgroundTasksAtom).find( + (task) => ACTIVE_TASK_STATES.has(task.state) && isFirstSourceTask(task), + ), +) +export const overviewFailedFirstSourceTaskAtom = atom((get) => { + const latestTask = get(backgroundTasksAtom) + .filter(isFirstSourceTask) + .sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at))[0] + return latestTask?.state === 'failed' ? latestTask : undefined +}) + +const statsQueryAtom = atomWithQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.stats.get.queryOptions({ + input: { + params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) }, + query: { window: get(overviewWindowAtom) }, + }, + refetchInterval: (query) => + overviewRefreshInterval({ + generatedAt: query.state.data?.generated_at, + hasActiveTasks: get(overviewHasActiveTasksAtom), + latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom), + }), + }), +) +export const overviewStatsDataAtom = selectAtom(statsQueryAtom, (query) => query.data) +export const overviewStatsPendingAtom = selectAtom(statsQueryAtom, (query) => query.isPending) +const statsErrorAtom = selectAtom(statsQueryAtom, (query) => query.isError) +const statsRefetchAtom = selectAtom(statsQueryAtom, (query) => query.refetch) + +const outcomesQueryAtom = atomWithQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.queryOutcomes.get.queryOptions({ + input: { + params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) }, + query: { window: get(overviewWindowAtom) }, + }, + refetchInterval: (query) => + overviewRefreshInterval({ + generatedAt: query.state.data?.generated_at, + hasActiveTasks: get(overviewHasActiveTasksAtom), + latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom), + }), + }), +) +export const overviewOutcomesDataAtom = selectAtom(outcomesQueryAtom, (query) => query.data) +export const overviewOutcomesPendingAtom = selectAtom(outcomesQueryAtom, (query) => query.isPending) +export const overviewOutcomesErrorAtom = selectAtom(outcomesQueryAtom, (query) => query.isError) +const outcomesRefetchAtom = selectAtom(outcomesQueryAtom, (query) => query.refetch) + +const inventoryQueryAtom = atomWithQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.inventory.get.queryOptions({ + input: { params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) } }, + refetchInterval: (query) => + overviewRefreshInterval({ + generatedAt: query.state.data?.generated_at, + hasActiveTasks: get(overviewHasActiveTasksAtom), + latestTaskUpdatedAt: get(latestTaskUpdatedAtAtom), + }), + }), +) +export const overviewInventoryDataAtom = selectAtom(inventoryQueryAtom, (query) => query.data) +export const overviewInventoryPendingAtom = selectAtom( + inventoryQueryAtom, + (query) => query.isPending, +) +export const overviewInventoryErrorAtom = selectAtom(inventoryQueryAtom, (query) => query.isError) +const inventoryRefetchAtom = selectAtom(inventoryQueryAtom, (query) => query.refetch) + +const attentionQueryAtom = atomWithQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.attention.get.queryOptions({ + input: { + params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) }, + query: { include_dismissed: false, limit: 100 }, + }, + refetchInterval: get(overviewHasActiveTasksAtom) ? OVERVIEW_REFRESH_INTERVAL : false, + }), +) +export const overviewAttentionDataAtom = selectAtom( + attentionQueryAtom, + (query) => query.data?.data ?? [], +) +export const overviewAttentionPendingAtom = selectAtom( + attentionQueryAtom, + (query) => query.isPending, +) +export const overviewAttentionErrorAtom = selectAtom(attentionQueryAtom, (query) => query.isError) +const attentionRefetchAtom = selectAtom(attentionQueryAtom, (query) => query.refetch) + +const indexingSourceQueryAtom = atomWithQuery((get) => { + const indexingTask = get(overviewIndexingTaskAtom) + return consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.get.queryOptions({ + input: indexingTask?.source_id + ? { + params: { + control_space_id: get(overviewKnowledgeSpaceIdAtom), + source_id: indexingTask.source_id, + }, + } + : skipToken, + }) +}) +export const overviewIndexingSourceNameAtom = atom((get) => get(indexingSourceQueryAtom).data?.name) +const indexingSourcePendingAtom = selectAtom(indexingSourceQueryAtom, (query) => query.isPending) + +const activityPreviewQueryAtom = atomWithQuery((get) => + consoleQuery.knowledgeFs.spaces.byControlSpaceId.overview.activity.get.queryOptions({ + input: { + params: { control_space_id: get(overviewKnowledgeSpaceIdAtom) }, + query: { limit: 5 }, + }, + refetchInterval: get(overviewHasActiveTasksAtom) ? OVERVIEW_REFRESH_INTERVAL : false, + }), +) +export const overviewActivityPreviewDataAtom = selectAtom( + activityPreviewQueryAtom, + (query) => query.data?.data ?? [], +) +export const overviewActivityPreviewPendingAtom = selectAtom( + activityPreviewQueryAtom, + (query) => query.isPending, +) +export const overviewActivityPreviewErrorAtom = selectAtom( + activityPreviewQueryAtom, + (query) => query.isError, +) +export const overviewActivityPreviewRefetchingAtom = selectAtom( + activityPreviewQueryAtom, + (query) => query.isRefetching, +) +const activityPreviewRefetchAtom = selectAtom(activityPreviewQueryAtom, (query) => query.refetch) + +export const overviewPageLoadingAtom = atom((get) => { + const indexingTask = get(overviewIndexingTaskAtom) + return ( + get(backgroundTasksPendingAtom) || + get(overviewStatsPendingAtom) || + get(overviewOutcomesPendingAtom) || + get(overviewInventoryPendingAtom) || + get(overviewAttentionPendingAtom) || + (indexingTask?.source_id != null && get(indexingSourcePendingAtom)) + ) +}) +export const overviewFirstLoadFailedAtom = atom( + (get) => + !get(overviewPageLoadingAtom) && + (get(statsErrorAtom) || + get(overviewOutcomesErrorAtom) || + get(overviewInventoryErrorAtom) || + get(overviewAttentionErrorAtom)), +) +const overviewHasContentAtom = atom((get) => { + const stats = get(overviewStatsDataAtom) + return (stats?.source_count ?? 0) > 0 || (stats?.documents ?? 0) > 0 +}) +export const overviewShowIndexingAtom = atom( + (get) => + !get(overviewPageLoadingAtom) && + !get(overviewInventoryErrorAtom) && + (get(overviewInventoryDataAtom)?.index_coverage.indexed ?? 0) === 0 && + get(overviewIndexingTaskAtom) !== undefined, +) +export const overviewEmptyAtom = atom( + (get) => + !get(overviewPageLoadingAtom) && + !get(statsErrorAtom) && + !get(overviewHasContentAtom) && + !get(overviewShowIndexingAtom), +) +export const overviewShowEmptyModulesAtom = atom( + (get) => get(overviewEmptyAtom) || get(overviewShowIndexingAtom), +) + +export const retryOverviewSnapshotsAtom = atom(null, (get) => { + void Promise.all([ + get(statsRefetchAtom)(), + get(outcomesRefetchAtom)(), + get(inventoryRefetchAtom)(), + get(attentionRefetchAtom)(), + get(backgroundTasksRefetchAtom)(), + ]) +}) +export const refreshOverviewBackgroundTasksAtom = atom(null, (get) => + get(backgroundTasksRefetchAtom)(), +) +export const retryOverviewActivityPreviewAtom = atom(null, (get) => { + void get(activityPreviewRefetchAtom)() +})