From 3c13cc25656e47015c41b8d8a476bb18ae304eab Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:30:59 +0800 Subject: [PATCH] refactor(web): split new RAG page responsibilities --- .../overview/knowledge-overview-page.tsx | 1667 +---------------- .../overview/overview-activity-types.ts | 18 + .../new-rag/overview/overview-activity.tsx | 674 +++++++ .../new-rag/overview/overview-attention.tsx | 243 +++ .../new-rag/overview/overview-format.ts | 35 + .../new-rag/overview/overview-inventory.tsx | 193 ++ .../new-rag/overview/overview-metrics.tsx | 199 ++ .../new-rag/overview/overview-onboarding.tsx | 253 +++ .../new-rag/overview/overview-panel.tsx | 76 + .../new-rag/retrieval-test-history-utils.ts | 14 + .../new-rag/retrieval-test-history.tsx | 561 ++++++ web/features/new-rag/retrieval-test-page.tsx | 959 +--------- .../new-rag/retrieval-test-results.tsx | 403 ++++ web/features/new-rag/source-actions.tsx | 305 +++ web/features/new-rag/source-list-item.tsx | 425 +++++ web/features/new-rag/source-list-layout.ts | 2 + web/features/new-rag/source-list-model.ts | 122 ++ web/features/new-rag/sources-empty.tsx | 115 ++ web/features/new-rag/sources-page.tsx | 926 +-------- 19 files changed, 3673 insertions(+), 3517 deletions(-) create mode 100644 web/features/new-rag/overview/overview-activity-types.ts create mode 100644 web/features/new-rag/overview/overview-activity.tsx create mode 100644 web/features/new-rag/overview/overview-attention.tsx create mode 100644 web/features/new-rag/overview/overview-format.ts create mode 100644 web/features/new-rag/overview/overview-inventory.tsx create mode 100644 web/features/new-rag/overview/overview-metrics.tsx create mode 100644 web/features/new-rag/overview/overview-onboarding.tsx create mode 100644 web/features/new-rag/overview/overview-panel.tsx create mode 100644 web/features/new-rag/retrieval-test-history-utils.ts create mode 100644 web/features/new-rag/retrieval-test-history.tsx create mode 100644 web/features/new-rag/retrieval-test-results.tsx create mode 100644 web/features/new-rag/source-actions.tsx create mode 100644 web/features/new-rag/source-list-item.tsx create mode 100644 web/features/new-rag/source-list-layout.ts create mode 100644 web/features/new-rag/source-list-model.ts create mode 100644 web/features/new-rag/sources-empty.tsx diff --git a/web/features/new-rag/overview/knowledge-overview-page.tsx b/web/features/new-rag/overview/knowledge-overview-page.tsx index dd7e1234ded..0802d0beac2 100644 --- a/web/features/new-rag/overview/knowledge-overview-page.tsx +++ b/web/features/new-rag/overview/knowledge-overview-page.tsx @@ -1,95 +1,51 @@ 'use client' -import type { - KnowledgeFsBackgroundTaskResponse, - KnowledgeFsOverviewActivityResponse, - KnowledgeFsOverviewAttentionResponse, - KnowledgeFsOverviewQueryOutcomeBucketResponse, -} from '@dify/contracts/api/console/knowledge-fs/types.gen' -import type { Dayjs } from 'dayjs' -import type { CSSProperties } from 'react' -import type { DatePickerProps } from '@/app/components/base/date-and-time-picker/types' -import type { Member } from '@/models/common' -import { Avatar } from '@langgenius/dify-ui/avatar' +import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { ActivityDateRange, ActivityOperator, ActivityRange } from './overview-activity-types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - Drawer, - DrawerBackdrop, - DrawerCloseButton, - DrawerContent, - DrawerPopup, - DrawerPortal, - DrawerTitle, - DrawerViewport, -} from '@langgenius/dify-ui/drawer' import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' -import { - Select, - SelectContent, - SelectItem, - SelectItemIndicator, - SelectItemText, - SelectTrigger, -} from '@langgenius/dify-ui/select' -import { skipToken, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' -import dayjs from 'dayjs' -import ReactECharts from 'echarts-for-react' +import { skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { parseAsStringLiteral, useQueryState } from 'nuqs' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' -import { Infotip } from '@/app/components/base/infotip' 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 { useMembers } from '@/service/use-common' import { DatasetACLPermission, hasPermission } from '@/utils/permission' import { KnowledgeModelReadinessBanner } from '../components/knowledge-model-readiness-banner' +import { ActivityDrawer, RecentActivity } from './overview-activity' +import { activityDatesForRange } from './overview-activity-types' +import { AttentionPanel } from './overview-attention' import { - newKnowledgeAddSourcePath, - newKnowledgeDetailPath, - newKnowledgeDocumentsPath, - newKnowledgeRetrievalTestPath, - newKnowledgeSettingsPath, -} from '../routes' -import { buildQueryOutcomesChartOptions } from './query-outcomes-chart-options' + 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' -type ActivityRange = 'today' | '7d' | '30d' | '90d' | 'all' | 'custom' -type ActivityOperator = 'all' | 'system' | `member:${string}` -type ActivityDateRange = { end: Dayjs; start: Dayjs } const WINDOWS: OverviewWindow[] = ['24h', '7d', '30d'] -const ACTIVITY_RANGES: ActivityRange[] = ['today', '7d', '30d', '90d', 'all', 'custom'] const ACTIVE_TASK_STATES = new Set([ 'queued', 'running', ]) const TASK_PAGE_SIZE = 20 const ACTIVITY_PAGE_SIZE = 20 -const OVERVIEW_REFRESH_INTERVAL = 2000 const overviewWindowParser = parseAsStringLiteral(WINDOWS) .withDefault('24h') .withOptions({ history: 'push' }) -function activityDatesForRange(range: Exclude): ActivityDateRange { - const end = dayjs().endOf('day') - if (range === 'all') return { end, start: dayjs(0) } - if (range === 'today') return { end, start: dayjs().startOf('day') } - return { - end, - start: dayjs() - .subtract(Number.parseInt(range) - 1, 'day') - .startOf('day'), - } -} - function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) { return ( task.operation === 'document_processing' || @@ -98,1597 +54,6 @@ function isFirstSourceTask(task: KnowledgeFsBackgroundTaskResponse) { ) } -function compactNumber(value: number) { - return Intl.NumberFormat().format(value) -} - -function formatDuration(seconds: number | null | undefined) { - if (seconds === null || seconds === undefined) return '—' - if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s` - if (seconds < 3600) return `${Math.round(seconds / 60)}m` - if (seconds < 86400) return `${Math.round(seconds / 3600)}h` - return `${Math.round(seconds / 86400)}d` -} - -function changeLabel(value: number | null, suffix = '%') { - if (value === null || value === 0) return '—' - return `${value > 0 ? '+' : ''}${Math.round(value)}${suffix}` -} - -function overviewRefreshInterval({ - generatedAt, - hasActiveTasks, - latestTaskUpdatedAt, -}: { - generatedAt?: string - hasActiveTasks: boolean - latestTaskUpdatedAt?: number -}) { - if (hasActiveTasks) return OVERVIEW_REFRESH_INTERVAL - if (latestTaskUpdatedAt === undefined) return false - - const generatedAtTimestamp = generatedAt ? Date.parse(generatedAt) : Number.NaN - return Number.isFinite(generatedAtTimestamp) && generatedAtTimestamp >= latestTaskUpdatedAt - ? false - : OVERVIEW_REFRESH_INTERVAL -} - -function Skeleton({ className, style }: { className?: string; style?: CSSProperties }) { - return ( - - ) -} - -function Panel({ children, className }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -function MetricCard({ - change, - empty, - help, - loading, - title, - value, -}: { - change?: string - empty: boolean - help?: string - loading: boolean - title: string - value: string -}) { - return ( - -
-

{title}

- {help && ( - - {help} - - )} -
- {loading ? ( - - ) : ( -
- - {empty ? '—' : value} - - {!empty && change && ( - - {change !== '—' && ( - - )} - {change} - - )} -
- )} -
- ) -} - -function QueryOutcomesChart({ - buckets, - empty, - error, - loading, -}: { - buckets: KnowledgeFsOverviewQueryOutcomeBucketResponse[] - empty: boolean - error: boolean - loading: boolean -}) { - const { t, i18n } = useTranslation('dataset') - const chartOptions = useMemo( - () => - buildQueryOutcomesChartOptions({ - buckets, - labels: { - answered: t(($) => $['newKnowledge.overview.answered']), - lowConfidence: t(($) => $['newKnowledge.overview.lowConfidence']), - noEvidence: t(($) => $['newKnowledge.overview.noEvidence']), - }, - locale: i18n.language, - }), - [buckets, i18n.language, t], - ) - - if (error) - return ( -
-
-

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

-
- - - -
- ) - - if (empty) - return ( -
-
-

- {t(($) => $['newKnowledge.overview.queryOutcomes'])} - $['newKnowledge.overview.answerRateHelp'])} - className="ml-1 inline-flex size-4 align-middle" - popupClassName="max-w-[260px] border-0 bg-text-primary text-text-primary-on-surface" - > - {t(($) => $['newKnowledge.overview.answerRateHelp'])} - -

-
- - $['newKnowledge.overview.noQueryData'])} - description={t(($) => $['newKnowledge.overview.noQueryDataDescription'])} - /> - -
- ) - - return ( -
-
-

- {t(($) => $['newKnowledge.overview.queryOutcomes'])} - $['newKnowledge.overview.answerRateHelp'])} - className="ml-1 inline-flex size-4 align-middle" - popupClassName="max-w-[260px] border-0 bg-text-primary text-text-primary-on-surface" - > - {t(($) => $['newKnowledge.overview.answerRateHelp'])} - -

-
- - {loading ? ( -
- {[ - ['outcome-1', 100], - ['outcome-2', 100], - ['outcome-3', 100], - ['outcome-4', 100], - ['outcome-5', 55], - ].map(([key, width]) => ( - - ))} -
- ) : buckets.length ? ( - <> -

- {t(($) => $['newKnowledge.overview.queryOutcomes'])}: {buckets.length} -

- - - ) : ( - $['newKnowledge.overview.noActivity'])} - description={t(($) => $['newKnowledge.overview.noActivityDescription'])} - /> - )} -
-
- ) -} - -function EmptyInline({ - description, - icon, - positive = false, - title, -}: { - description: string - icon: string - positive?: boolean - title: string -}) { - return ( -
- - - -
-

{title}

-

{description}

-
-
- ) -} - -function OverviewErrorInline() { - const { t } = useTranslation('dataset') - - return ( - $['newKnowledge.detailErrorTitle'])} - description={t(($) => $['newKnowledge.detailErrorDescription'])} - /> - ) -} - -const ATTENTION_PAGE_SIZE = 4 - -function attentionPresentation( - issue: KnowledgeFsOverviewAttentionResponse, - t: ReturnType>['t'], -): { description?: string; title: string } { - const evidenceCodes = new Set(issue.evidence.map(({ code }) => code)) - if (issue.rule_id === 'stale-source') - return { - description: t(($) => $['newKnowledge.overview.attention.staleSource.description']), - title: t(($) => $['newKnowledge.overview.attention.staleSource.title']), - } - if (issue.rule_id === 'failed-document') - return { - description: t(($) => $['newKnowledge.overview.attention.failedDocument.description']), - title: t(($) => $['newKnowledge.overview.attention.failedDocument.title']), - } - if (issue.rule_id === 'low-quality-query') - return { - description: t(($) => $['newKnowledge.overview.attention.lowQualityQuery.description']), - title: t(($) => $['newKnowledge.overview.attention.lowQualityQuery.title']), - } - if (issue.rule_id === 'model-readiness') { - const reasons: string[] = [] - if ( - evidenceCodes.has('MODEL_EMBEDDING_PROFILE_MISSING') || - evidenceCodes.has('MODEL_RETRIEVAL_PROFILE_MISSING') - ) - reasons.push(t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing'])) - if (evidenceCodes.has('MODEL_PUBLICATION_BINDING_MISSING')) - reasons.push(t(($) => $['newKnowledge.overview.attention.modelReadiness.bindingMissing'])) - return { - description: - reasons.join(' ') || - t(($) => $['newKnowledge.overview.attention.modelReadiness.description']), - title: t(($) => $['newKnowledge.overview.attention.modelReadiness.title']), - } - } - - return { title: issue.title } -} - -function AttentionPanel({ - attention, - empty, - error, - knowledgeSpaceId, - loading, -}: { - attention: KnowledgeFsOverviewAttentionResponse[] - empty: boolean - error: boolean - knowledgeSpaceId: string - loading: boolean -}) { - const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - 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. - const actionableAttention = attention.filter((issue) => issue.rule_id !== 'permission-readiness') - const issuePageCount = Math.max(1, Math.ceil(actionableAttention.length / ATTENTION_PAGE_SIZE)) - const activeIssuePage = Math.min(issuePage, issuePageCount - 1) - const visibleIssues = actionableAttention.slice( - activeIssuePage * ATTENTION_PAGE_SIZE, - activeIssuePage * ATTENTION_PAGE_SIZE + ATTENTION_PAGE_SIZE, - ) - const issueAction = (issue: KnowledgeFsOverviewAttentionResponse) => { - if (issue.action.kind === 'review-models') - return { - href: newKnowledgeSettingsPath(knowledgeSpaceId), - label: t(($) => $['newKnowledge.overview.attention.action.configureModels']), - } - if (issue.action.resource_type === 'failed-query' || issue.rule_id === 'low-quality-query') - return { - href: newKnowledgeRetrievalTestPath(knowledgeSpaceId), - label: t(($) => $['newKnowledge.overview.reviewConflict']), - } - if (issue.action.resource_type === 'source') - return { - href: newKnowledgeDetailPath(knowledgeSpaceId), - label: t(($) => $['newKnowledge.overview.fixSource']), - } - return { - href: newKnowledgeDocumentsPath(knowledgeSpaceId), - label: t(($) => $['newKnowledge.overview.viewDocuments']), - } - } - - if (error) - return ( -
-

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

- - - -
- ) - - if (empty) - return ( -
-

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

- - $['newKnowledge.overview.noIssues'])} - description={t(($) => $['newKnowledge.overview.noIssuesDescription'])} - /> - -
- ) - - return ( -
-
-

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

-
- - {loading ? ( -
- {[ - ['attention-1', 100], - ['attention-2', 100], - ['attention-3', 100], - ['attention-4', 100], - ].map(([key, width]) => ( -
- -
- ))} -
- ) : actionableAttention.length ? ( - <> -
    - {visibleIssues.map((issue) => { - const presentation = attentionPresentation(issue, t) - const action = issueAction(issue) - return ( -
  • - - {issue.severity === 'critical' - ? t(($) => $['newKnowledge.overview.blocker']) - : issue.severity === 'warning' - ? t(($) => $['newKnowledge.overview.serious']) - : t(($) => $['newKnowledge.overview.review'])} - -
    -

    - {presentation.title} -

    - {presentation.description && ( -

    - {presentation.description} -

    - )} -
    - -
  • - ) - })} -
-
-
- - - {activeIssuePage + 1} / {issuePageCount} - - -
-
- - ) : ( - $['newKnowledge.overview.noIssues'])} - description={t(($) => $['newKnowledge.overview.noIssuesDescription'])} - /> - )} -
-
- ) -} - -function activityOperationLabel( - activity: KnowledgeFsOverviewActivityResponse, - t: ReturnType>['t'], -) { - if (activity.action.startsWith('source.')) - return t(($) => $['newKnowledge.overview.operation.source_sync']) - if (activity.action.startsWith('document.')) - return t(($) => $['newKnowledge.overview.operation.document_processing']) - if (activity.action.startsWith('query.')) - return t(($) => $['newKnowledge.overview.queryOutcomes']) - if (activity.action === 'permission.updated') return t(($) => $['newKnowledge.permission']) - if (activity.action === 'settings.updated') - return t(($) => $['newKnowledge.overview.updateEvidence']) - return t(($) => $['newKnowledge.backgroundTasks']) -} - -function activityLabel( - activity: KnowledgeFsOverviewActivityResponse, - t: ReturnType>['t'], -) { - if (activity.action === 'query.requested') { - const question = activity.details.question - const mode = activity.details.mode - const label = - typeof question === 'string' && question.trim() - ? `${t(($) => $['newKnowledge.qualityPage.question'])}: ${question}` - : activityOperationLabel(activity, t) - return typeof mode === 'string' && mode.trim() ? `${label} — ${mode}` : label - } - - const operation = activityOperationLabel(activity, t) - let label: string - if (activity.result === 'success') - label = t(($) => $['newKnowledge.overview.activityCompleted'], { operation }) - else if (activity.result === 'failure') - label = t(($) => $['newKnowledge.overview.activityFailed'], { operation }) - else if (activity.result === 'canceled') - label = t(($) => $['newKnowledge.overview.activityCanceled'], { operation }) - else label = t(($) => $['newKnowledge.overview.activityRunning'], { operation }) - - const detail = [ - activity.details.reasonCode, - activity.details.statusCode, - activity.details.documentType, - activity.details.providerId, - activity.details.mode, - ].find((value): value is string => typeof value === 'string' && Boolean(value.trim())) - if (!detail) return label - - const readableDetail = /^[A-Z0-9_]+$/.test(detail) - ? detail - .toLocaleLowerCase() - .replaceAll('_', ' ') - .replace(/^./, (character) => character.toLocaleUpperCase()) - : detail - return `${label} — ${readableDetail}` -} - -function compactIdentifier(value: string) { - const normalized = value.replace(/^dify-account:/, '') - return normalized.length > 16 ? `${normalized.slice(0, 8)}…${normalized.slice(-4)}` : normalized -} - -function activityActor( - activity: KnowledgeFsOverviewActivityResponse, - members: Member[], - systemLabel: string, -) { - if (activity.actor.type === 'system') return { avatar: null, name: systemLabel, system: true } - - const accountId = activity.actor.id?.replace(/^dify-account:/, '') - const member = members.find((candidate) => candidate.id === accountId) - return { - avatar: member?.avatar_url ?? null, - name: member?.name || compactIdentifier(activity.actor.id || systemLabel), - system: false, - } -} - -function ActivityActor({ - activity, - members, - showName = true, - size = 'xxs', -}: { - activity: KnowledgeFsOverviewActivityResponse - members: Member[] - showName?: boolean - size?: 'xxs' | 'xs' -}) { - const { t } = useTranslation('dataset') - const actor = activityActor( - activity, - members, - t(($) => $['newKnowledge.overview.system']), - ) - - return ( - <> - {actor.system ? ( - - S - - ) : ( - - )} - {showName && {actor.name}} - - ) -} - -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[] -}) { - const { t, i18n } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const formatWhen = (value: string) => { - const timestamp = new Date(value) - const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 60_000)) - const relativeTime = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }) - if (elapsedMinutes < 60) return relativeTime.format(-elapsedMinutes, 'minute') - const elapsedHours = Math.floor(elapsedMinutes / 60) - if (elapsedHours < 24) return relativeTime.format(-elapsedHours, 'hour') - const elapsedDays = Math.floor(elapsedHours / 24) - if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day') - return new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }).format( - timestamp, - ) - } - - if (error) - return ( -
-

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

- -
- -

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

- -
-
-
- ) - - if (empty) - return ( -
-

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

- - $['newKnowledge.overview.syncInProgress']) - : t(($) => $['newKnowledge.overview.noActivity']) - } - description={ - indexing - ? t(($) => $['newKnowledge.overview.syncInProgressDescription']) - : t(($) => $['newKnowledge.overview.noActivityDescription']) - } - /> - -
- ) - - return ( -
-
-

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

- -
- - {loading || activities.length ? ( -
$['newKnowledge.overview.recentActivity'])} - className="min-w-151" - > -
- - {t(($) => $['newKnowledge.overview.when'])} - - {t(($) => $['newKnowledge.overview.activity'])} - {t(($) => $['newKnowledge.overview.operator'])} -
-
- {loading - ? [ - ['activity-1', 55], - ['activity-2', 55], - ['activity-3', 55], - ['activity-4', 55], - ['activity-5', 41], - ].map(([key, width]) => ( -
- -
- )) - : activities.slice(0, 5).map((activity) => ( -
- - {formatWhen(activity.occurred_at)} - - - {activityLabel(activity, t)} - - - - -
- ))} -
- ) : ( - $['newKnowledge.overview.noActivity'])} - description={t(($) => $['newKnowledge.overview.noActivityDescription'])} - /> - )} - -
- ) -} - -function ActivityDateRangePicker({ - dates, - onChange, -}: { - dates: ActivityDateRange - onChange: (dates: ActivityDateRange) => void -}) { - const { t, i18n } = useTranslation('dataset') - const today = dayjs() - const formatter = useMemo( - () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), - [i18n.language], - ) - const renderTrigger = - (edge: 'start' | 'end'): NonNullable => - (props, state, { handleClickTrigger, value }) => ( -
$['newKnowledge.overview.timeRange'])} ${edge}`} - className={cn( - 'min-w-0 flex-1 truncate rounded px-1 py-0.5 text-left system-xs-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active', - props.className, - state.open && 'bg-state-base-hover', - )} - onClick={(event) => { - handleClickTrigger(event) - props.onClick?.(event) - }} - onKeyDown={(event) => { - props.onKeyDown?.(event) - if (event.defaultPrevented) return - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - event.currentTarget.click() - }} - > - {value ? formatter.format(value.toDate()) : '—'} -
- ) - - return ( -
$['newKnowledge.overview.timeRange'])} - className="flex h-6 w-35 shrink-0 items-center rounded-lg bg-background-section px-1" - > - start && onChange({ end: dates.end, start: start.startOf('day') })} - onClear={() => undefined} - renderTrigger={renderTrigger('start')} - getIsDateDisabled={(date) => date.isAfter(today, 'day') || date.isAfter(dates.end, 'day')} - /> - - – - - end && onChange({ end: end.endOf('day'), start: dates.start })} - onClear={() => undefined} - renderTrigger={renderTrigger('end')} - getIsDateDisabled={(date) => - date.isAfter(today, 'day') || date.isBefore(dates.start, 'day') - } - /> -
- ) -} - -function ActivityDrawer({ - activities, - dates, - hasNextPage, - isFetchingNextPage, - loading, - members, - onDatesChange, - onFetchNextPage, - onOpenChange, - onOperatorChange, - onRangeChange, - open, - operator, - range, -}: { - activities: KnowledgeFsOverviewActivityResponse[] - dates: ActivityDateRange - hasNextPage: boolean - isFetchingNextPage: boolean - loading: boolean - members: Member[] - onDatesChange: (dates: ActivityDateRange) => void - onFetchNextPage: () => void - onOpenChange: (open: boolean) => void - onOperatorChange: (operator: ActivityOperator) => void - onRangeChange: (range: ActivityRange) => void - open: boolean - operator: ActivityOperator - range: ActivityRange -}) { - const { t, i18n } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const { t: tActivityLog } = useTranslation('appLog') - const rangeTriggerRef = useRef(null) - const restoreFilterFocusRef = useRef(false) - const now = dayjs() - const dateFormatter = useMemo( - () => - new Intl.DateTimeFormat(i18n.language, { - day: 'numeric', - month: 'short', - weekday: 'short', - }), - [i18n.language], - ) - const timeFormatter = useMemo( - () => new Intl.DateTimeFormat(i18n.language, { hour: 'numeric', minute: '2-digit' }), - [i18n.language], - ) - const relativeTimeFormatter = useMemo( - () => new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto', style: 'narrow' }), - [i18n.language], - ) - const groups = activities.reduce>( - (result, task) => { - const key = dayjs(task.occurred_at).format('YYYY-MM-DD') - result[key] ??= [] - result[key].push(task) - return result - }, - {}, - ) - const groupLabel = (key: string) => { - const date = dayjs(key) - if (date.isSame(now, 'day')) return t(($) => $['newKnowledge.overview.today']) - if (date.isSame(now.subtract(1, 'day'), 'day')) - return t(($) => $['newKnowledge.overview.yesterday']) - return dateFormatter.format(date.toDate()) - } - const activityTime = (occurredAt: string) => { - const occurred = dayjs(occurredAt) - if (!occurred.isSame(now, 'day')) return timeFormatter.format(occurred.toDate()) - const elapsedMinutes = Math.max(0, now.diff(occurred, 'minute')) - if (elapsedMinutes < 60) return relativeTimeFormatter.format(-elapsedMinutes, 'minute') - return relativeTimeFormatter.format(-Math.floor(elapsedMinutes / 60), 'hour') - } - const rangeLabel: Record = { - '30d': t(($) => $['newKnowledge.overview.last30Days']), - '7d': t(($) => $['newKnowledge.overview.last7Days']), - '90d': t(($) => $['newKnowledge.overview.last90Days']), - all: t(($) => $['newKnowledge.overview.allTime']), - custom: tActivityLog(($) => $['filter.period.custom']), - today: t(($) => $['newKnowledge.overview.today']), - } - const rangeTriggerLabel: Record = { - ...rangeLabel, - '30d': t(($) => $['newKnowledge.overview.thirtyDays']), - '7d': t(($) => $['newKnowledge.overview.sevenDays']), - '90d': '90d', - } - const operatorLabel = - operator === 'all' - ? tActivityLog(($) => $['filter.annotation.all']) - : operator === 'system' - ? t(($) => $['newKnowledge.overview.system']) - : members.find((member) => `member:${member.id}` === operator)?.name || operator.slice(7) - const clearFilters = () => { - restoreFilterFocusRef.current = true - onRangeChange('today') - onOperatorChange('all') - } - - useEffect(() => { - if (!restoreFilterFocusRef.current) return - restoreFilterFocusRef.current = false - rangeTriggerRef.current?.focus({ preventScroll: true }) - }, [range]) - - return ( - - - - - - -
-
- - {t(($) => $['newKnowledge.overview.allActivity'])} - - -
-
-
- - {range === 'all' ? ( -
- {rangeLabel.all} -
- ) : ( - - )} - -
-
- {loading ? ( -
- -
- {[248, 300, 210, 280, 236, 264].map((width) => ( -
- -
- - -
- -
- ))} -
-
- ) : activities.length ? ( - <> - {Object.entries(groups).map(([key, group]) => ( -
-

- {groupLabel(key)} -

-
    - {group.map((activity) => ( -
  • - - - -
    -

    - {activityLabel(activity, t)} -

    -

    - { - activityActor( - activity, - members, - t(($) => $['newKnowledge.overview.system']), - ).name - } -

    -
    - -
  • - ))} -
-
- ))} -
- {hasNextPage && ( - - )} -
- - ) : ( -
- - - -

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

-

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

- -
- )} -
-
-
-
-
-
- ) -} - -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 -}) { - const { t } = useTranslation('dataset') - const categories = inventory - ? [ - { - color: 'bg-util-colors-blue-blue-500', - segment: 'border-util-colors-blue-blue-500 bg-util-colors-blue-blue-100', - label: t(($) => $['newKnowledge.overview.webCrawl']), - value: inventory.source_categories.crawl, - }, - { - color: 'bg-util-colors-green-green-500', - segment: 'border-util-colors-green-green-500 bg-util-colors-green-green-100', - label: t(($) => $['newKnowledge.overview.onlineDocuments']), - value: inventory.source_categories.online_documents, - }, - { - color: 'bg-util-colors-purple-purple-500', - segment: 'border-util-colors-purple-purple-500 bg-util-colors-purple-purple-100', - label: t(($) => $['newKnowledge.overview.onlineDrives']), - value: inventory.source_categories.online_drives, - }, - { - color: 'bg-util-colors-orange-orange-500', - segment: 'border-util-colors-orange-orange-500 bg-util-colors-orange-orange-50', - label: t(($) => $['newKnowledge.overview.uploads']), - value: inventory.source_categories.uploads, - }, - ] - : [] - const categoryTotal = categories.reduce((total, category) => total + category.value, 0) - const visibleCategories = categories.filter((category) => category.value > 0) - - if (error) - return ( -
-

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

- - - -
- ) - - if (empty) - return ( -
-

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

- - $['newKnowledge.overview.indexingInProgress']) - : t(($) => $['newKnowledge.documentsEmptyTitle']) - } - description={ - indexing - ? t(($) => $['newKnowledge.overview.indexingInProgressDescription']) - : t(($) => $['newKnowledge.documentsEmptyDescription']) - } - /> - -
- ) - - return ( -
-

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

- - {loading ? ( - <> - -
- -
-
- {[0, 1, 2].map((index) => ( -
- - -
- ))} -
- - ) : ( - <> -
$['newKnowledge.overview.sources'])} - > - {visibleCategories.map((category) => ( - - ))} -
-
    - {categories.map((category) => ( -
  • - - {category.label} - {category.value} -
  • - ))} -
-
- {[ - { - detail: `+${inventory?.graph_entities.added_last_7d ?? 0}`, - label: t(($) => $['newKnowledge.overview.graphEntities']), - value: compactNumber(inventory?.graph_entities.total ?? 0), - }, - { - detail: `+${inventory?.graph_relations.added_last_7d ?? 0}`, - label: t(($) => $['newKnowledge.overview.graphRelations']), - value: compactNumber(inventory?.graph_relations.total ?? 0), - }, - { - detail: t(($) => $['newKnowledge.overview.indexedSlices'], { - indexed: inventory?.index_coverage.indexed ?? 0, - total: inventory?.index_coverage.total ?? 0, - }), - label: t(($) => $['newKnowledge.overview.indexCoverage']), - value: `${Math.round(inventory?.index_coverage.percentage ?? 0)}%`, - }, - ].map((item) => ( -
-

{item.label}

-

- {item.value} -

-

{item.detail}

-
- ))} -
- - )} -
-
- ) -} - -function Onboarding({ - canConnectSource, - canUpload, - indexingTask, - indexingSourceName, - knowledgeSpaceId, -}: { - canConnectSource: boolean - canUpload: boolean - indexingTask?: KnowledgeFsBackgroundTaskResponse - indexingSourceName?: string - knowledgeSpaceId: string -}) { - const { t } = useTranslation('dataset') - const [pendingAction, setPendingAction] = useState<'source' | 'upload'>() - const actionCount = Number(canConnectSource) + Number(canUpload) - const description = canConnectSource - ? canUpload - ? t(($) => $['newKnowledge.overview.noSourcesDescription']) - : t(($) => $['newKnowledge.connectSourceDescription']) - : 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, - })} -

-
-
- ) - } - - return ( -
0 && 'md:h-54.75', - )} - > -
- 🔥 - - - - - -
-
-

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

-

{description}

-
- {actionCount > 0 && ( -
- {canConnectSource && ( - $['newKnowledge.overview.connectSource'])} - aria-busy={pendingAction === 'source' || undefined} - aria-disabled={pendingAction !== undefined} - className={cn( - 'flex h-26.25 flex-col items-center justify-center rounded-[10px] border border-divider-regular bg-components-panel-on-panel-item-bg text-center outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', - pendingAction !== undefined && 'pointer-events-none opacity-50', - )} - 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') - }} - > - - - {t(($) => $['newKnowledge.overview.connectSource'])} - - - {t(($) => $['newKnowledge.connectSourceDescription'])} - - - )} - {canUpload && ( - $['newKnowledge.overview.uploadFiles'])} - aria-busy={pendingAction === 'upload' || undefined} - aria-disabled={pendingAction !== undefined} - className={cn( - 'flex h-26.25 flex-col items-center justify-center rounded-[10px] border border-divider-regular bg-components-panel-on-panel-item-bg text-center outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', - pendingAction !== undefined && 'pointer-events-none opacity-50', - )} - href={`${newKnowledgeDocumentsPath(knowledgeSpaceId)}?upload=1`} - 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('upload') - }} - > - - - {t(($) => $['newKnowledge.overview.uploadFiles'])} - - - {t(($) => $['newKnowledge.uploadFilesDescription'])} - - - )} -
- )} -
- ) -} - -function FirstSourceTaskFailureBanner({ - failedTask, - knowledgeSpaceId, - onRetryTask, -}: { - failedTask: KnowledgeFsBackgroundTaskResponse - knowledgeSpaceId: string - onRetryTask: () => Promise -}) { - const { t } = useTranslation('dataset') - const retryTaskMutation = useMutation( - consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.byTaskKind.byTaskId.retry.post.mutationOptions(), - ) - const description = - failedTask.operation === 'document_upload' || failedTask.operation === 'document_processing' - ? t(($) => $['newKnowledge.documentUploadFailed']) - : t(($) => $['newKnowledge.addSourceFailed']) - const retryFailedTask = async () => { - if (!failedTask.can_retry || retryTaskMutation.isPending) return - - try { - await retryTaskMutation.mutateAsync({ - params: { - control_space_id: knowledgeSpaceId, - task_id: failedTask.id, - task_kind: failedTask.task_kind, - }, - }) - await onRetryTask() - } catch { - // Mutation state keeps the retry feedback visible. - } - } - - return ( -
- -

- {retryTaskMutation.isError - ? t(($) => $['newKnowledge.detailErrorDescription']) - : description} -

- {failedTask.can_retry && ( - - )} -
- ) -} - export function KnowledgeOverviewPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') diff --git a/web/features/new-rag/overview/overview-activity-types.ts b/web/features/new-rag/overview/overview-activity-types.ts new file mode 100644 index 00000000000..2a68df9a98d --- /dev/null +++ b/web/features/new-rag/overview/overview-activity-types.ts @@ -0,0 +1,18 @@ +import type { Dayjs } from 'dayjs' +import dayjs from 'dayjs' + +export type ActivityRange = 'today' | '7d' | '30d' | '90d' | 'all' | 'custom' +export type ActivityOperator = 'all' | 'system' | `member:${string}` +export type ActivityDateRange = { end: Dayjs; start: Dayjs } + +export function activityDatesForRange(range: Exclude): ActivityDateRange { + const end = dayjs().endOf('day') + if (range === 'all') return { end, start: dayjs(0) } + if (range === 'today') return { end, start: dayjs().startOf('day') } + return { + end, + start: dayjs() + .subtract(Number.parseInt(range) - 1, 'day') + .startOf('day'), + } +} diff --git a/web/features/new-rag/overview/overview-activity.tsx b/web/features/new-rag/overview/overview-activity.tsx new file mode 100644 index 00000000000..21536ef93eb --- /dev/null +++ b/web/features/new-rag/overview/overview-activity.tsx @@ -0,0 +1,674 @@ +'use client' + +import type { KnowledgeFsOverviewActivityResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { ActivityDateRange, ActivityOperator, ActivityRange } from './overview-activity-types' +import type { DatePickerProps } from '@/app/components/base/date-and-time-picker/types' +import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import dayjs from 'dayjs' +import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' +import { EmptyInline, Panel, Skeleton } from './overview-panel' + +const ACTIVITY_RANGES: ActivityRange[] = ['today', '7d', '30d', '90d', 'all', 'custom'] + +function activityOperationLabel( + activity: KnowledgeFsOverviewActivityResponse, + t: ReturnType>['t'], +) { + if (activity.action.startsWith('source.')) + return t(($) => $['newKnowledge.overview.operation.source_sync']) + if (activity.action.startsWith('document.')) + return t(($) => $['newKnowledge.overview.operation.document_processing']) + if (activity.action.startsWith('query.')) + return t(($) => $['newKnowledge.overview.queryOutcomes']) + if (activity.action === 'permission.updated') return t(($) => $['newKnowledge.permission']) + if (activity.action === 'settings.updated') + return t(($) => $['newKnowledge.overview.updateEvidence']) + return t(($) => $['newKnowledge.backgroundTasks']) +} + +function activityLabel( + activity: KnowledgeFsOverviewActivityResponse, + t: ReturnType>['t'], +) { + if (activity.action === 'query.requested') { + const question = activity.details.question + const mode = activity.details.mode + const label = + typeof question === 'string' && question.trim() + ? `${t(($) => $['newKnowledge.qualityPage.question'])}: ${question}` + : activityOperationLabel(activity, t) + return typeof mode === 'string' && mode.trim() ? `${label} — ${mode}` : label + } + + const operation = activityOperationLabel(activity, t) + let label: string + if (activity.result === 'success') + label = t(($) => $['newKnowledge.overview.activityCompleted'], { operation }) + else if (activity.result === 'failure') + label = t(($) => $['newKnowledge.overview.activityFailed'], { operation }) + else if (activity.result === 'canceled') + label = t(($) => $['newKnowledge.overview.activityCanceled'], { operation }) + else label = t(($) => $['newKnowledge.overview.activityRunning'], { operation }) + + const detail = [ + activity.details.reasonCode, + activity.details.statusCode, + activity.details.documentType, + activity.details.providerId, + activity.details.mode, + ].find((value): value is string => typeof value === 'string' && Boolean(value.trim())) + if (!detail) return label + + const readableDetail = /^[A-Z0-9_]+$/.test(detail) + ? detail + .toLocaleLowerCase() + .replaceAll('_', ' ') + .replace(/^./, (character) => character.toLocaleUpperCase()) + : detail + return `${label} — ${readableDetail}` +} + +function compactIdentifier(value: string) { + const normalized = value.replace(/^dify-account:/, '') + return normalized.length > 16 ? `${normalized.slice(0, 8)}…${normalized.slice(-4)}` : normalized +} + +function activityActor( + activity: KnowledgeFsOverviewActivityResponse, + members: Member[], + systemLabel: string, +) { + if (activity.actor.type === 'system') return { avatar: null, name: systemLabel, system: true } + + const accountId = activity.actor.id?.replace(/^dify-account:/, '') + const member = members.find((candidate) => candidate.id === accountId) + return { + avatar: member?.avatar_url ?? null, + name: member?.name || compactIdentifier(activity.actor.id || systemLabel), + system: false, + } +} + +function ActivityActor({ + activity, + members, + showName = true, + size = 'xxs', +}: { + activity: KnowledgeFsOverviewActivityResponse + members: Member[] + showName?: boolean + size?: 'xxs' | 'xs' +}) { + const { t } = useTranslation('dataset') + const actor = activityActor( + activity, + members, + t(($) => $['newKnowledge.overview.system']), + ) + + return ( + <> + {actor.system ? ( + + S + + ) : ( + + )} + {showName && {actor.name}} + + ) +} + +export 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[] +}) { + const { t, i18n } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const formatWhen = (value: string) => { + const timestamp = new Date(value) + const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 60_000)) + const relativeTime = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }) + if (elapsedMinutes < 60) return relativeTime.format(-elapsedMinutes, 'minute') + const elapsedHours = Math.floor(elapsedMinutes / 60) + if (elapsedHours < 24) return relativeTime.format(-elapsedHours, 'hour') + const elapsedDays = Math.floor(elapsedHours / 24) + if (elapsedDays < 7) return relativeTime.format(-elapsedDays, 'day') + return new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }).format( + timestamp, + ) + } + + if (error) + return ( +
+

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

+ +
+ +

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

+ +
+
+
+ ) + + if (empty) + return ( +
+

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

+ + $['newKnowledge.overview.syncInProgress']) + : t(($) => $['newKnowledge.overview.noActivity']) + } + description={ + indexing + ? t(($) => $['newKnowledge.overview.syncInProgressDescription']) + : t(($) => $['newKnowledge.overview.noActivityDescription']) + } + /> + +
+ ) + + return ( +
+
+

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

+ +
+ + {loading || activities.length ? ( +
$['newKnowledge.overview.recentActivity'])} + className="min-w-151" + > +
+ + {t(($) => $['newKnowledge.overview.when'])} + + {t(($) => $['newKnowledge.overview.activity'])} + {t(($) => $['newKnowledge.overview.operator'])} +
+
+ {loading + ? [ + ['activity-1', 55], + ['activity-2', 55], + ['activity-3', 55], + ['activity-4', 55], + ['activity-5', 41], + ].map(([key, width]) => ( +
+ +
+ )) + : activities.slice(0, 5).map((activity) => ( +
+ + {formatWhen(activity.occurred_at)} + + + {activityLabel(activity, t)} + + + + +
+ ))} +
+ ) : ( + $['newKnowledge.overview.noActivity'])} + description={t(($) => $['newKnowledge.overview.noActivityDescription'])} + /> + )} + +
+ ) +} + +function ActivityDateRangePicker({ + dates, + onChange, +}: { + dates: ActivityDateRange + onChange: (dates: ActivityDateRange) => void +}) { + const { t, i18n } = useTranslation('dataset') + const today = dayjs() + const formatter = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), + [i18n.language], + ) + const renderTrigger = + (edge: 'start' | 'end'): NonNullable => + (props, state, { handleClickTrigger, value }) => ( +
$['newKnowledge.overview.timeRange'])} ${edge}`} + className={cn( + 'min-w-0 flex-1 truncate rounded px-1 py-0.5 text-left system-xs-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active', + props.className, + state.open && 'bg-state-base-hover', + )} + onClick={(event) => { + handleClickTrigger(event) + props.onClick?.(event) + }} + onKeyDown={(event) => { + props.onKeyDown?.(event) + if (event.defaultPrevented) return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + event.currentTarget.click() + }} + > + {value ? formatter.format(value.toDate()) : '—'} +
+ ) + + return ( +
$['newKnowledge.overview.timeRange'])} + className="flex h-6 w-35 shrink-0 items-center rounded-lg bg-background-section px-1" + > + start && onChange({ end: dates.end, start: start.startOf('day') })} + onClear={() => undefined} + renderTrigger={renderTrigger('start')} + getIsDateDisabled={(date) => date.isAfter(today, 'day') || date.isAfter(dates.end, 'day')} + /> + + – + + end && onChange({ end: end.endOf('day'), start: dates.start })} + onClear={() => undefined} + renderTrigger={renderTrigger('end')} + getIsDateDisabled={(date) => + date.isAfter(today, 'day') || date.isBefore(dates.start, 'day') + } + /> +
+ ) +} + +export function ActivityDrawer({ + activities, + dates, + hasNextPage, + isFetchingNextPage, + loading, + members, + onDatesChange, + onFetchNextPage, + onOpenChange, + onOperatorChange, + onRangeChange, + open, + operator, + range, +}: { + activities: KnowledgeFsOverviewActivityResponse[] + dates: ActivityDateRange + hasNextPage: boolean + isFetchingNextPage: boolean + loading: boolean + members: Member[] + onDatesChange: (dates: ActivityDateRange) => void + onFetchNextPage: () => void + onOpenChange: (open: boolean) => void + onOperatorChange: (operator: ActivityOperator) => void + onRangeChange: (range: ActivityRange) => void + open: boolean + operator: ActivityOperator + range: ActivityRange +}) { + const { t, i18n } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const { t: tActivityLog } = useTranslation('appLog') + const rangeTriggerRef = useRef(null) + const restoreFilterFocusRef = useRef(false) + const now = dayjs() + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.language, { + day: 'numeric', + month: 'short', + weekday: 'short', + }), + [i18n.language], + ) + const timeFormatter = useMemo( + () => new Intl.DateTimeFormat(i18n.language, { hour: 'numeric', minute: '2-digit' }), + [i18n.language], + ) + const relativeTimeFormatter = useMemo( + () => new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto', style: 'narrow' }), + [i18n.language], + ) + const groups = activities.reduce>( + (result, task) => { + const key = dayjs(task.occurred_at).format('YYYY-MM-DD') + result[key] ??= [] + result[key].push(task) + return result + }, + {}, + ) + const groupLabel = (key: string) => { + const date = dayjs(key) + if (date.isSame(now, 'day')) return t(($) => $['newKnowledge.overview.today']) + if (date.isSame(now.subtract(1, 'day'), 'day')) + return t(($) => $['newKnowledge.overview.yesterday']) + return dateFormatter.format(date.toDate()) + } + const activityTime = (occurredAt: string) => { + const occurred = dayjs(occurredAt) + if (!occurred.isSame(now, 'day')) return timeFormatter.format(occurred.toDate()) + const elapsedMinutes = Math.max(0, now.diff(occurred, 'minute')) + if (elapsedMinutes < 60) return relativeTimeFormatter.format(-elapsedMinutes, 'minute') + return relativeTimeFormatter.format(-Math.floor(elapsedMinutes / 60), 'hour') + } + const rangeLabel: Record = { + '30d': t(($) => $['newKnowledge.overview.last30Days']), + '7d': t(($) => $['newKnowledge.overview.last7Days']), + '90d': t(($) => $['newKnowledge.overview.last90Days']), + all: t(($) => $['newKnowledge.overview.allTime']), + custom: tActivityLog(($) => $['filter.period.custom']), + today: t(($) => $['newKnowledge.overview.today']), + } + const rangeTriggerLabel: Record = { + ...rangeLabel, + '30d': t(($) => $['newKnowledge.overview.thirtyDays']), + '7d': t(($) => $['newKnowledge.overview.sevenDays']), + '90d': '90d', + } + const operatorLabel = + operator === 'all' + ? tActivityLog(($) => $['filter.annotation.all']) + : operator === 'system' + ? t(($) => $['newKnowledge.overview.system']) + : members.find((member) => `member:${member.id}` === operator)?.name || operator.slice(7) + const clearFilters = () => { + restoreFilterFocusRef.current = true + onRangeChange('today') + onOperatorChange('all') + } + + useEffect(() => { + if (!restoreFilterFocusRef.current) return + restoreFilterFocusRef.current = false + rangeTriggerRef.current?.focus({ preventScroll: true }) + }, [range]) + + return ( + + + + + + +
+
+ + {t(($) => $['newKnowledge.overview.allActivity'])} + + +
+
+
+ + {range === 'all' ? ( +
+ {rangeLabel.all} +
+ ) : ( + + )} + +
+
+ {loading ? ( +
+ +
+ {[248, 300, 210, 280, 236, 264].map((width) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ ) : activities.length ? ( + <> + {Object.entries(groups).map(([key, group]) => ( +
+

+ {groupLabel(key)} +

+
    + {group.map((activity) => ( +
  • + + + +
    +

    + {activityLabel(activity, t)} +

    +

    + { + activityActor( + activity, + members, + t(($) => $['newKnowledge.overview.system']), + ).name + } +

    +
    + +
  • + ))} +
+
+ ))} +
+ {hasNextPage && ( + + )} +
+ + ) : ( +
+ + + +

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

+

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

+ +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/web/features/new-rag/overview/overview-attention.tsx b/web/features/new-rag/overview/overview-attention.tsx new file mode 100644 index 00000000000..04d725612c6 --- /dev/null +++ b/web/features/new-rag/overview/overview-attention.tsx @@ -0,0 +1,243 @@ +'use client' + +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 { useState } from 'react' +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { + newKnowledgeDetailPath, + newKnowledgeDocumentsPath, + newKnowledgeRetrievalTestPath, + newKnowledgeSettingsPath, +} from '../routes' +import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' + +const ATTENTION_PAGE_SIZE = 4 + +function attentionPresentation( + issue: KnowledgeFsOverviewAttentionResponse, + t: ReturnType>['t'], +): { description?: string; title: string } { + const evidenceCodes = new Set(issue.evidence.map(({ code }) => code)) + if (issue.rule_id === 'stale-source') + return { + description: t(($) => $['newKnowledge.overview.attention.staleSource.description']), + title: t(($) => $['newKnowledge.overview.attention.staleSource.title']), + } + if (issue.rule_id === 'failed-document') + return { + description: t(($) => $['newKnowledge.overview.attention.failedDocument.description']), + title: t(($) => $['newKnowledge.overview.attention.failedDocument.title']), + } + if (issue.rule_id === 'low-quality-query') + return { + description: t(($) => $['newKnowledge.overview.attention.lowQualityQuery.description']), + title: t(($) => $['newKnowledge.overview.attention.lowQualityQuery.title']), + } + if (issue.rule_id === 'model-readiness') { + const reasons: string[] = [] + if ( + evidenceCodes.has('MODEL_EMBEDDING_PROFILE_MISSING') || + evidenceCodes.has('MODEL_RETRIEVAL_PROFILE_MISSING') + ) + reasons.push(t(($) => $['newKnowledge.overview.attention.modelReadiness.profilesMissing'])) + if (evidenceCodes.has('MODEL_PUBLICATION_BINDING_MISSING')) + reasons.push(t(($) => $['newKnowledge.overview.attention.modelReadiness.bindingMissing'])) + return { + description: + reasons.join(' ') || + t(($) => $['newKnowledge.overview.attention.modelReadiness.description']), + title: t(($) => $['newKnowledge.overview.attention.modelReadiness.title']), + } + } + + return { title: issue.title } +} + +export function AttentionPanel({ + attention, + empty, + error, + knowledgeSpaceId, + loading, +}: { + attention: KnowledgeFsOverviewAttentionResponse[] + empty: boolean + error: boolean + knowledgeSpaceId: string + loading: boolean +}) { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + 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. + const actionableAttention = attention.filter((issue) => issue.rule_id !== 'permission-readiness') + const issuePageCount = Math.max(1, Math.ceil(actionableAttention.length / ATTENTION_PAGE_SIZE)) + const activeIssuePage = Math.min(issuePage, issuePageCount - 1) + const visibleIssues = actionableAttention.slice( + activeIssuePage * ATTENTION_PAGE_SIZE, + activeIssuePage * ATTENTION_PAGE_SIZE + ATTENTION_PAGE_SIZE, + ) + const issueAction = (issue: KnowledgeFsOverviewAttentionResponse) => { + if (issue.action.kind === 'review-models') + return { + href: newKnowledgeSettingsPath(knowledgeSpaceId), + label: t(($) => $['newKnowledge.overview.attention.action.configureModels']), + } + if (issue.action.resource_type === 'failed-query' || issue.rule_id === 'low-quality-query') + return { + href: newKnowledgeRetrievalTestPath(knowledgeSpaceId), + label: t(($) => $['newKnowledge.overview.reviewConflict']), + } + if (issue.action.resource_type === 'source') + return { + href: newKnowledgeDetailPath(knowledgeSpaceId), + label: t(($) => $['newKnowledge.overview.fixSource']), + } + return { + href: newKnowledgeDocumentsPath(knowledgeSpaceId), + label: t(($) => $['newKnowledge.overview.viewDocuments']), + } + } + + if (error) + return ( +
+

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

+ + + +
+ ) + + if (empty) + return ( +
+

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

+ + $['newKnowledge.overview.noIssues'])} + description={t(($) => $['newKnowledge.overview.noIssuesDescription'])} + /> + +
+ ) + + return ( +
+
+

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

+
+ + {loading ? ( +
+ {[ + ['attention-1', 100], + ['attention-2', 100], + ['attention-3', 100], + ['attention-4', 100], + ].map(([key, width]) => ( +
+ +
+ ))} +
+ ) : actionableAttention.length ? ( + <> +
    + {visibleIssues.map((issue) => { + const presentation = attentionPresentation(issue, t) + const action = issueAction(issue) + return ( +
  • + + {issue.severity === 'critical' + ? t(($) => $['newKnowledge.overview.blocker']) + : issue.severity === 'warning' + ? t(($) => $['newKnowledge.overview.serious']) + : t(($) => $['newKnowledge.overview.review'])} + +
    +

    + {presentation.title} +

    + {presentation.description && ( +

    + {presentation.description} +

    + )} +
    + +
  • + ) + })} +
+
+
+ + + {activeIssuePage + 1} / {issuePageCount} + + +
+
+ + ) : ( + $['newKnowledge.overview.noIssues'])} + description={t(($) => $['newKnowledge.overview.noIssuesDescription'])} + /> + )} +
+
+ ) +} diff --git a/web/features/new-rag/overview/overview-format.ts b/web/features/new-rag/overview/overview-format.ts new file mode 100644 index 00000000000..76fe7cd0e36 --- /dev/null +++ b/web/features/new-rag/overview/overview-format.ts @@ -0,0 +1,35 @@ +export const OVERVIEW_REFRESH_INTERVAL = 2000 + +export function compactNumber(value: number) { + return Intl.NumberFormat().format(value) +} + +export function formatDuration(seconds: number | null | undefined) { + if (seconds === null || seconds === undefined) return '—' + if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + if (seconds < 86400) return `${Math.round(seconds / 3600)}h` + return `${Math.round(seconds / 86400)}d` +} + +export function changeLabel(value: number | null, suffix = '%') { + if (value === null || value === 0) return '—' + return `${value > 0 ? '+' : ''}${Math.round(value)}${suffix}` +} + +export function overviewRefreshInterval({ + generatedAt, + hasActiveTasks, + latestTaskUpdatedAt, +}: { + generatedAt?: string + hasActiveTasks: boolean + latestTaskUpdatedAt?: number +}) { + if (hasActiveTasks) return OVERVIEW_REFRESH_INTERVAL + if (latestTaskUpdatedAt === undefined) return false + const generatedAtTimestamp = generatedAt ? Date.parse(generatedAt) : Number.NaN + return Number.isFinite(generatedAtTimestamp) && generatedAtTimestamp >= latestTaskUpdatedAt + ? false + : OVERVIEW_REFRESH_INTERVAL +} diff --git a/web/features/new-rag/overview/overview-inventory.tsx b/web/features/new-rag/overview/overview-inventory.tsx new file mode 100644 index 00000000000..2c458a634fd --- /dev/null +++ b/web/features/new-rag/overview/overview-inventory.tsx @@ -0,0 +1,193 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { compactNumber } from './overview-format' +import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' + +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 +}) { + const { t } = useTranslation('dataset') + const categories = inventory + ? [ + { + color: 'bg-util-colors-blue-blue-500', + segment: 'border-util-colors-blue-blue-500 bg-util-colors-blue-blue-100', + label: t(($) => $['newKnowledge.overview.webCrawl']), + value: inventory.source_categories.crawl, + }, + { + color: 'bg-util-colors-green-green-500', + segment: 'border-util-colors-green-green-500 bg-util-colors-green-green-100', + label: t(($) => $['newKnowledge.overview.onlineDocuments']), + value: inventory.source_categories.online_documents, + }, + { + color: 'bg-util-colors-purple-purple-500', + segment: 'border-util-colors-purple-purple-500 bg-util-colors-purple-purple-100', + label: t(($) => $['newKnowledge.overview.onlineDrives']), + value: inventory.source_categories.online_drives, + }, + { + color: 'bg-util-colors-orange-orange-500', + segment: 'border-util-colors-orange-orange-500 bg-util-colors-orange-orange-50', + label: t(($) => $['newKnowledge.overview.uploads']), + value: inventory.source_categories.uploads, + }, + ] + : [] + const categoryTotal = categories.reduce((total, category) => total + category.value, 0) + const visibleCategories = categories.filter((category) => category.value > 0) + + if (error) + return ( +
+

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

+ + + +
+ ) + + if (empty) + return ( +
+

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

+ + $['newKnowledge.overview.indexingInProgress']) + : t(($) => $['newKnowledge.documentsEmptyTitle']) + } + description={ + indexing + ? t(($) => $['newKnowledge.overview.indexingInProgressDescription']) + : t(($) => $['newKnowledge.documentsEmptyDescription']) + } + /> + +
+ ) + + return ( +
+

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

+ + {loading ? ( + <> + +
+ +
+
+ {[0, 1, 2].map((index) => ( +
+ + +
+ ))} +
+ + ) : ( + <> +
$['newKnowledge.overview.sources'])} + > + {visibleCategories.map((category) => ( + + ))} +
+
    + {categories.map((category) => ( +
  • + + {category.label} + {category.value} +
  • + ))} +
+
+ {[ + { + detail: `+${inventory?.graph_entities.added_last_7d ?? 0}`, + label: t(($) => $['newKnowledge.overview.graphEntities']), + value: compactNumber(inventory?.graph_entities.total ?? 0), + }, + { + detail: `+${inventory?.graph_relations.added_last_7d ?? 0}`, + label: t(($) => $['newKnowledge.overview.graphRelations']), + value: compactNumber(inventory?.graph_relations.total ?? 0), + }, + { + detail: t(($) => $['newKnowledge.overview.indexedSlices'], { + indexed: inventory?.index_coverage.indexed ?? 0, + total: inventory?.index_coverage.total ?? 0, + }), + label: t(($) => $['newKnowledge.overview.indexCoverage']), + value: `${Math.round(inventory?.index_coverage.percentage ?? 0)}%`, + }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} +

+

{item.detail}

+
+ ))} +
+ + )} +
+
+ ) +} diff --git a/web/features/new-rag/overview/overview-metrics.tsx b/web/features/new-rag/overview/overview-metrics.tsx new file mode 100644 index 00000000000..9f1c6c0bce7 --- /dev/null +++ b/web/features/new-rag/overview/overview-metrics.tsx @@ -0,0 +1,199 @@ +'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 { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' +import { EmptyInline, OverviewErrorInline, Panel, Skeleton } from './overview-panel' +import { buildQueryOutcomesChartOptions } from './query-outcomes-chart-options' + +export function MetricCard({ + change, + empty, + help, + loading, + title, + value, +}: { + change?: string + empty: boolean + help?: string + loading: boolean + title: string + value: string +}) { + return ( + +
+

{title}

+ {help && ( + + {help} + + )} +
+ {loading ? ( + + ) : ( +
+ + {empty ? '—' : value} + + {!empty && change && ( + + {change !== '—' && ( + + )} + {change} + + )} +
+ )} +
+ ) +} + +export function QueryOutcomesChart({ + buckets, + empty, + error, + loading, +}: { + buckets: KnowledgeFsOverviewQueryOutcomeBucketResponse[] + empty: boolean + error: boolean + loading: boolean +}) { + const { t, i18n } = useTranslation('dataset') + const chartOptions = useMemo( + () => + buildQueryOutcomesChartOptions({ + buckets, + labels: { + answered: t(($) => $['newKnowledge.overview.answered']), + lowConfidence: t(($) => $['newKnowledge.overview.lowConfidence']), + noEvidence: t(($) => $['newKnowledge.overview.noEvidence']), + }, + locale: i18n.language, + }), + [buckets, i18n.language, t], + ) + + if (error) + return ( +
+
+

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

+
+ + + +
+ ) + + if (empty) + return ( +
+
+

+ {t(($) => $['newKnowledge.overview.queryOutcomes'])} + $['newKnowledge.overview.answerRateHelp'])} + className="ml-1 inline-flex size-4 align-middle" + popupClassName="max-w-[260px] border-0 bg-text-primary text-text-primary-on-surface" + > + {t(($) => $['newKnowledge.overview.answerRateHelp'])} + +

+
+ + $['newKnowledge.overview.noQueryData'])} + description={t(($) => $['newKnowledge.overview.noQueryDataDescription'])} + /> + +
+ ) + + return ( +
+
+

+ {t(($) => $['newKnowledge.overview.queryOutcomes'])} + $['newKnowledge.overview.answerRateHelp'])} + className="ml-1 inline-flex size-4 align-middle" + popupClassName="max-w-[260px] border-0 bg-text-primary text-text-primary-on-surface" + > + {t(($) => $['newKnowledge.overview.answerRateHelp'])} + +

+
+ + {loading ? ( +
+ {[ + ['outcome-1', 100], + ['outcome-2', 100], + ['outcome-3', 100], + ['outcome-4', 100], + ['outcome-5', 55], + ].map(([key, width]) => ( + + ))} +
+ ) : buckets.length ? ( + <> +

+ {t(($) => $['newKnowledge.overview.queryOutcomes'])}: {buckets.length} +

+ + + ) : ( + $['newKnowledge.overview.noActivity'])} + description={t(($) => $['newKnowledge.overview.noActivityDescription'])} + /> + )} +
+
+ ) +} diff --git a/web/features/new-rag/overview/overview-onboarding.tsx b/web/features/new-rag/overview/overview-onboarding.tsx new file mode 100644 index 00000000000..dc0d58ca9d8 --- /dev/null +++ b/web/features/new-rag/overview/overview-onboarding.tsx @@ -0,0 +1,253 @@ +'use client' + +import type { KnowledgeFsBackgroundTaskResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { consoleQuery } from '@/service/client' +import { newKnowledgeAddSourcePath, newKnowledgeDocumentsPath } from '../routes' + +export function Onboarding({ + canConnectSource, + canUpload, + indexingTask, + indexingSourceName, + knowledgeSpaceId, +}: { + canConnectSource: boolean + canUpload: boolean + indexingTask?: KnowledgeFsBackgroundTaskResponse + indexingSourceName?: string + knowledgeSpaceId: string +}) { + const { t } = useTranslation('dataset') + const [pendingAction, setPendingAction] = useState<'source' | 'upload'>() + const actionCount = Number(canConnectSource) + Number(canUpload) + const description = canConnectSource + ? canUpload + ? t(($) => $['newKnowledge.overview.noSourcesDescription']) + : t(($) => $['newKnowledge.connectSourceDescription']) + : 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, + })} +

+
+
+ ) + } + + return ( +
0 && 'md:h-54.75', + )} + > +
+ 🔥 + + + + + +
+
+

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

+

{description}

+
+ {actionCount > 0 && ( +
+ {canConnectSource && ( + $['newKnowledge.overview.connectSource'])} + aria-busy={pendingAction === 'source' || undefined} + aria-disabled={pendingAction !== undefined} + className={cn( + 'flex h-26.25 flex-col items-center justify-center rounded-[10px] border border-divider-regular bg-components-panel-on-panel-item-bg text-center outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', + pendingAction !== undefined && 'pointer-events-none opacity-50', + )} + 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') + }} + > + + + {t(($) => $['newKnowledge.overview.connectSource'])} + + + {t(($) => $['newKnowledge.connectSourceDescription'])} + + + )} + {canUpload && ( + $['newKnowledge.overview.uploadFiles'])} + aria-busy={pendingAction === 'upload' || undefined} + aria-disabled={pendingAction !== undefined} + className={cn( + 'flex h-26.25 flex-col items-center justify-center rounded-[10px] border border-divider-regular bg-components-panel-on-panel-item-bg text-center outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', + pendingAction !== undefined && 'pointer-events-none opacity-50', + )} + href={`${newKnowledgeDocumentsPath(knowledgeSpaceId)}?upload=1`} + 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('upload') + }} + > + + + {t(($) => $['newKnowledge.overview.uploadFiles'])} + + + {t(($) => $['newKnowledge.uploadFilesDescription'])} + + + )} +
+ )} +
+ ) +} + +export function FirstSourceTaskFailureBanner({ + failedTask, + knowledgeSpaceId, + onRetryTask, +}: { + failedTask: KnowledgeFsBackgroundTaskResponse + knowledgeSpaceId: string + onRetryTask: () => Promise +}) { + const { t } = useTranslation('dataset') + const retryTaskMutation = useMutation( + consoleQuery.knowledgeFs.spaces.byControlSpaceId.backgroundTasks.byTaskKind.byTaskId.retry.post.mutationOptions(), + ) + const description = + failedTask.operation === 'document_upload' || failedTask.operation === 'document_processing' + ? t(($) => $['newKnowledge.documentUploadFailed']) + : t(($) => $['newKnowledge.addSourceFailed']) + const retryFailedTask = async () => { + if (!failedTask.can_retry || retryTaskMutation.isPending) return + + try { + await retryTaskMutation.mutateAsync({ + params: { + control_space_id: knowledgeSpaceId, + task_id: failedTask.id, + task_kind: failedTask.task_kind, + }, + }) + await onRetryTask() + } catch { + // Mutation state keeps the retry feedback visible. + } + } + + return ( +
+ +

+ {retryTaskMutation.isError + ? t(($) => $['newKnowledge.detailErrorDescription']) + : description} +

+ {failedTask.can_retry && ( + + )} +
+ ) +} diff --git a/web/features/new-rag/overview/overview-panel.tsx b/web/features/new-rag/overview/overview-panel.tsx new file mode 100644 index 00000000000..1c8c805cd49 --- /dev/null +++ b/web/features/new-rag/overview/overview-panel.tsx @@ -0,0 +1,76 @@ +'use client' + +import type React from 'react' +import type { CSSProperties } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' + +export function Skeleton({ className, style }: { className?: string; style?: CSSProperties }) { + return ( + + ) +} + +export function Panel({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +export function EmptyInline({ + description, + icon, + positive = false, + title, +}: { + description: string + icon: string + positive?: boolean + title: string +}) { + return ( +
+ + + +
+

{title}

+

{description}

+
+
+ ) +} + +export function OverviewErrorInline() { + const { t } = useTranslation('dataset') + + return ( + $['newKnowledge.detailErrorTitle'])} + description={t(($) => $['newKnowledge.detailErrorDescription'])} + /> + ) +} diff --git a/web/features/new-rag/retrieval-test-history-utils.ts b/web/features/new-rag/retrieval-test-history-utils.ts new file mode 100644 index 00000000000..e814b7e607e --- /dev/null +++ b/web/features/new-rag/retrieval-test-history-utils.ts @@ -0,0 +1,14 @@ +import type { ResearchTaskProgressEvent } from './services/research-task-events' + +export function timeValue(value: number) { + return value < 10_000_000_000 ? value * 1000 : value +} + +export function mergeResearchProgressEvent( + events: ResearchTaskProgressEvent[], + event: ResearchTaskProgressEvent, +) { + const next = events.filter((candidate) => candidate.sequence !== event.sequence) + next.push(event) + return next.sort((left, right) => left.sequence - right.sequence) +} diff --git a/web/features/new-rag/retrieval-test-history.tsx b/web/features/new-rag/retrieval-test-history.tsx new file mode 100644 index 00000000000..24a3eb9b704 --- /dev/null +++ b/web/features/new-rag/retrieval-test-history.tsx @@ -0,0 +1,561 @@ +'use client' + +import type { + KnowledgeFsResearchTaskPlanResponse, + KnowledgeFsResearchTaskResponse, +} from '@dify/contracts/api/console/knowledge-fs/types.gen' +import type { RetrievalTestRecord } from './retrieval-test-model' +import type { ResearchTaskProgressEvent } from './services/research-task-events' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { timeValue } from './retrieval-test-history-utils' +import { + formatDuration, + formatRetrievalDuration, + formatStageDuration, + researchTaskIsActive, +} from './retrieval-test-model' + +const researchStageOrder = ['planning', 'retrieving', 'analyzing', 'generating'] as const +type ResearchStage = (typeof researchStageOrder)[number] + +function formatRecordTime(value: number) { + return new Intl.DateTimeFormat(undefined, { + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + month: 'short', + }).format(value) +} + +export function RecordTime({ value }: { value: number }) { + const { t } = useTranslation('dataset') + const [showJustNow, setShowJustNow] = useState(() => { + const age = Date.now() - value + return age >= 0 && age < 60_000 + }) + + useEffect(() => { + if (!showJustNow) return + const timeout = globalThis.setTimeout( + () => setShowJustNow(false), + Math.max(0, value + 60_000 - Date.now()), + ) + return () => globalThis.clearTimeout(timeout) + }, [showJustNow, value]) + + return showJustNow ? t(($) => $['newKnowledge.retrievalTest.justNow']) : formatRecordTime(value) +} + +type ResearchPayloadLabels = { + chunks: string + documents: string + retrievals: string + sources: string + topK: string +} + +const researchPayloadContainers = new Set([ + 'analysis', + 'analyzing', + 'candidates', + 'chunks', + 'coverage', + 'data', + 'details', + 'documents', + 'findings', + 'generating', + 'generation', + 'items', + 'plan', + 'planning', + 'questions', + 'results', + 'retrieval', + 'retrieving', + 'sources', + 'topics', + 'warnings', +]) +const researchPayloadLabels = new Set(['name', 'query', 'question', 'title', 'topic']) +const researchPayloadText = new Set([ + ...researchPayloadLabels, + 'coverage', + 'coveragegap', + 'coveragegapwarning', + 'coveragewarning', + 'finding', + 'findings', + 'mergedcandidatesummary', + 'mergedsummary', + 'message', + 'questions', + 'result', + 'results', + 'summary', + 'topics', + 'warning', + 'warnings', +]) + +function normalizedPayloadKey(key: string) { + return key.replaceAll(/[^a-z0-9]/gi, '').toLocaleLowerCase() +} + +function payloadCountLabel(key: string, labels: ResearchPayloadLabels) { + const normalizedKey = normalizedPayloadKey(key) + if (normalizedKey === 'chunkcount' || normalizedKey === 'chunks') return labels.chunks + if (normalizedKey === 'documentcount' || normalizedKey === 'documents') return labels.documents + if (normalizedKey === 'retrievalcount') return labels.retrievals + if (normalizedKey === 'sourcecount' || normalizedKey === 'sources') return labels.sources + if (normalizedKey === 'topk') return labels.topK +} + +function researchPayloadLines(payload: Record, labels: ResearchPayloadLabels) { + const lines: string[] = [] + const visit = (value: unknown, key = '', depth = 0) => { + if (depth > 3) return + const normalizedKey = normalizedPayloadKey(key) + if (typeof value === 'string') { + if (value.trim() && researchPayloadText.has(normalizedKey)) lines.push(value.trim()) + return + } + if (typeof value === 'number') { + const countLabel = payloadCountLabel(key, labels) + if (countLabel) lines.push(`${countLabel}: ${value}`) + return + } + if (Array.isArray(value)) { + if (!researchPayloadContainers.has(normalizedKey)) return + value.forEach((item) => visit(item, key, depth + 1)) + return + } + if (!value || typeof value !== 'object') return + if (key && !researchPayloadContainers.has(normalizedKey)) return + const record = value as Record + const entries = Object.entries(record) + const labelEntry = entries.find( + ([candidate, nested]) => + researchPayloadLabels.has(normalizedPayloadKey(candidate)) && typeof nested === 'string', + ) + const countEntry = entries.find( + ([candidate, nested]) => + ['chunkcount', 'chunks', 'count'].includes(normalizedPayloadKey(candidate)) && + typeof nested === 'number', + ) + if (labelEntry) { + const [, label] = labelEntry + lines.push( + `${String(label).trim()}${countEntry ? ` · ${countEntry[1]} ${labels.chunks}` : ''}`, + ) + } + entries.forEach(([nestedKey, nested]) => { + if (nestedKey !== labelEntry?.[0] && nestedKey !== countEntry?.[0]) + visit(nested, nestedKey, depth + 1) + }) + } + Object.entries(payload).forEach(([key, value]) => visit(value, key)) + return [...new Set(lines)].slice(0, 12) +} + +function researchStagePayloads(events: ResearchTaskProgressEvent[], stage: ResearchStage) { + const payloads: Record[] = [] + for (const event of [...events].reverse()) { + const details = event.payload.details + if ( + event.payload.previousStage === stage && + details && + typeof details === 'object' && + !Array.isArray(details) + ) { + payloads.push(details as Record) + continue + } + const nested = event.payload[stage] + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + payloads.push(nested as Record) + continue + } + if ( + event.stage === stage && + event.type !== 'research_task.answer_delta' && + event.payload.previousStage === undefined + ) + payloads.push(event.payload) + } + return payloads +} + +function fallbackResearchStagePayload({ + documentCount, + evidenceCount, + stage, + task, +}: { + documentCount: number + evidenceCount: number + stage: ResearchStage + task: KnowledgeFsResearchTaskResponse +}): Record { + if (stage === 'planning') { + return { + questions: [task.query], + ...(typeof task.top_k === 'number' ? { topK: task.top_k } : {}), + } + } + if (stage === 'retrieving') { + return { + documents: documentCount, + results: [{ chunkCount: evidenceCount, question: task.query }], + } + } + if (stage === 'analyzing') return { chunks: evidenceCount, documents: documentCount } + return { + chunks: evidenceCount, + documents: documentCount, + sources: documentCount || evidenceCount, + } +} + +function useClock(enabled: boolean) { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!enabled) return + const interval = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(interval) + }, [enabled]) + return now +} + +function researchStageIndex(stage: KnowledgeFsResearchTaskResponse['stage']) { + if (stage === 'queued' || stage === 'paused') return 0 + if (stage === 'completed') return researchStageOrder.length + return researchStageOrder.findIndex((item) => item === stage) +} + +function estimatedStageDuration( + plan: KnowledgeFsResearchTaskPlanResponse | undefined, + stage: ResearchStage, + locale: string, +) { + if (!plan) return undefined + const stepNames: Record> = { + analyzing: new Set(['analyze']), + generating: new Set(['generate']), + planning: new Set(['plan']), + retrieving: new Set(['inspect', 'retrieve']), + } + const milliseconds = plan.steps.reduce((total, step) => { + if (!stepNames[stage].has(typeof step.name === 'string' ? step.name : '')) return total + return total + (typeof step.estimatedLatencyMs === 'number' ? step.estimatedLatencyMs : 0) + }, 0) + return milliseconds > 0 ? formatStageDuration(milliseconds, locale) : undefined +} + +function researchProgressTime(event: ResearchTaskProgressEvent) { + return Date.parse(event.createdAt) +} + +function actualStageDuration( + events: ResearchTaskProgressEvent[], + stage: ResearchStage, + task: KnowledgeFsResearchTaskResponse, + now: number, + locale: string, +) { + const start = events.find((event) => event.stage === stage) + if (!start) return + const startedAt = researchProgressTime(start) + const next = events.find((event) => { + if (event.sequence <= start.sequence) return false + return ( + event.stage === 'canceled' || + event.stage === 'completed' || + event.stage === 'failed' || + (researchStageOrder.includes(event.stage as ResearchStage) && event.stage !== stage) + ) + }) + const endedAt = next + ? researchProgressTime(next) + : task.stage === stage + ? now + : task.completed_at + ? timeValue(task.completed_at) + : undefined + if (endedAt === undefined || endedAt < startedAt) return + return formatStageDuration(endedAt - startedAt, locale) +} + +export function ResearchProcess({ + documentCount, + evidenceCount, + events, + expanded, + onCancel, + onToggle, + plan, + task, +}: { + documentCount: number + evidenceCount: number + events: ResearchTaskProgressEvent[] + expanded: boolean + onCancel?: () => void + onToggle: () => void + plan?: KnowledgeFsResearchTaskPlanResponse + task: KnowledgeFsResearchTaskResponse +}) { + const { t, i18n } = useTranslation('dataset') + const active = researchTaskIsActive(task) + const now = useClock(active) + const firstProgressAt = events[0] ? researchProgressTime(events[0]) : undefined + const terminalProgress = [...events] + .reverse() + .find( + (event) => + event.stage === 'canceled' || event.stage === 'completed' || event.stage === 'failed', + ) + const startedAt = firstProgressAt ?? timeValue(task.created_at) + const endedAt = terminalProgress + ? researchProgressTime(terminalProgress) + : task.completed_at + ? timeValue(task.completed_at) + : now + const duration = formatDuration(endedAt - startedAt, i18n.language) + const currentIndex = researchStageIndex(task.stage) + const latestVisitedIndex = events.reduce((latest, event) => { + const index = researchStageOrder.indexOf(event.stage as ResearchStage) + return Math.max(latest, index) + }, -1) + const summary = + task.stage === 'completed' + ? t(($) => $['newKnowledge.retrievalTest.completedIn'], { duration }) + : task.stage === 'canceled' + ? t(($) => $['newKnowledge.retrievalTest.canceled']) + : task.stage === 'failed' + ? t(($) => $['newKnowledge.retrievalTest.failedTitle']) + : t(($) => $['newKnowledge.retrievalTest.running']) + const labels: Record<(typeof researchStageOrder)[number], string> = { + analyzing: t(($) => $['newKnowledge.retrievalTest.analyzing']), + generating: t(($) => $['newKnowledge.retrievalTest.generating']), + planning: t(($) => $['newKnowledge.retrievalTest.planning']), + retrieving: t(($) => $['newKnowledge.retrievalTest.retrieving']), + } + const activeLabels: Record<(typeof researchStageOrder)[number], string> = { + analyzing: t(($) => $['newKnowledge.retrievalTest.analyzingActive']), + generating: t(($) => $['newKnowledge.retrievalTest.generatingActive']), + planning: t(($) => $['newKnowledge.retrievalTest.planningActive']), + retrieving: t(($) => $['newKnowledge.retrievalTest.retrievingActive']), + } + const payloadLabels: ResearchPayloadLabels = { + chunks: t(($) => $['newKnowledge.chunkCount']), + documents: t(($) => $['newKnowledge.documents']), + retrievals: t(($) => $['newKnowledge.retrievalCount']), + sources: t(($) => $['newKnowledge.sources']), + topK: t(($) => $['newKnowledge.settings.topKLabel']), + } + + return ( +
+
+ + {active && onCancel && ( + + )} +
+ {expanded && ( +
+
    + {researchStageOrder.map((stage, index) => { + const completed = + task.stage === 'completed' || index < currentIndex || index < latestVisitedIndex + const current = index === currentIndex && active + const stageDuration = + actualStageDuration(events, stage, task, now, i18n.language) ?? + estimatedStageDuration(plan, stage, i18n.language) + const fallbackPayload = fallbackResearchStagePayload({ + documentCount, + evidenceCount, + stage, + task, + }) + const payloadLines = [...researchStagePayloads(events, stage), fallbackPayload] + .flatMap((payload) => researchPayloadLines(payload, payloadLabels)) + .filter((line, lineIndex, lines) => lines.indexOf(line) === lineIndex) + .slice(0, 12) + return ( +
  1. + + + + + {index < researchStageOrder.length - 1 && ( + + )} + + + + + {current ? activeLabels[stage] : labels[stage]} + + {stageDuration && (completed || current) && ( + + {stageDuration} + + )} + + {current && stage === 'retrieving' && evidenceCount > 0 && ( + + {t(($) => $['newKnowledge.retrievalTest.foundSoFar'], { + count: evidenceCount, + })} + + )} + {payloadLines.length > 0 && (completed || current) && ( +
      + {payloadLines.map((line) => ( +
    • + {line} +
    • + ))} +
    + )} +
    +
  2. + ) + })} +
+
+ )} +
+ ) +} + +export function RecordButton({ + active, + index, + onClick, + record, +}: { + active: boolean + index: number + onClick: () => void + record: RetrievalTestRecord +}) { + const { t, i18n } = useTranslation('dataset') + const failed = record.kind !== 'research' && record.status === 'failed' + const activeResearchStage = + record.kind === 'research' && record.status === 'running' + ? researchStageOrder[ + Math.min(Math.max(researchStageIndex(record.stage), 0), researchStageOrder.length - 1) + ] + : undefined + return ( + + ) +} diff --git a/web/features/new-rag/retrieval-test-page.tsx b/web/features/new-rag/retrieval-test-page.tsx index 1abf850f707..b455151c482 100644 --- a/web/features/new-rag/retrieval-test-page.tsx +++ b/web/features/new-rag/retrieval-test-page.tsx @@ -5,32 +5,23 @@ import type { KnowledgeFsResearchTaskResponse, } from '@dify/contracts/api/console/knowledge-fs/types.gen' import type { Hotkey } from '@tanstack/react-hotkeys' -import type { AnchorHTMLAttributes, PropsWithChildren } from 'react' import type { GoldenQuestionDraft, GoldenQuestionEvidenceOption } from './quality/types' import type { RetrievalEvidence, RetrievalTestMode, RetrievalTestRecord, } from './retrieval-test-model' +import type { BadCaseReason, QualityDecision } from './retrieval-test-results' import type { KnowledgeQueryEvent } from './services/knowledge-query-events' import type { ResearchTaskProgressEvent } from './services/research-task-events' -import type { MarkdownProps } from '@/app/components/base/markdown' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' import { matchesKeyboardEvent } from '@tanstack/react-hotkeys' import { skipToken, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { parseAsString, useQueryStates } from 'nuqs' import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Markdown } from '@/app/components/base/markdown' -import { Link as MarkdownLink } from '@/app/components/base/markdown-blocks' import Link from '@/next/link' import { consoleClient, consoleQuery } from '@/service/client' import { KnowledgeModelReadinessBanner } from './components/knowledge-model-readiness-banner' @@ -38,18 +29,25 @@ import { KnowledgeModelSetupDialog } from './components/knowledge-model-setup-di import { RetrievalModeSegmentedControl } from './components/retrieval-mode-segmented-control' import { useKnowledgeSpacePermission } from './knowledge-space-context' import { GoldenQuestionDialog } from './quality/golden-question-dialog' +import { RecordButton, RecordTime, ResearchProcess } from './retrieval-test-history' +import { mergeResearchProgressEvent, timeValue } from './retrieval-test-history-utils' import { extractRetrievalEvidence, extractStreamError, extractTraceId, - formatDuration, - formatRetrievalDuration, - formatStageDuration, researchTaskIsActive, retrievalTestRecords, shouldRefreshResearchPartials, } from './retrieval-test-model' -import { newKnowledgeDocumentDetailPath, newKnowledgeQualityPath } from './routes' +import { + EmptyState, + EvidenceCard, + FailedResult, + QualityActions, + ResearchAnswer, + ResultSkeleton, +} from './retrieval-test-results' +import { newKnowledgeQualityPath } from './routes' import { streamKnowledgeQuery } from './services/knowledge-query-events' import { researchTaskAnswerFromEvents, @@ -80,10 +78,6 @@ type ComposerDraft = { selectionKey?: string } -type QualityDecision = 'bad-case' | 'golden' - -type BadCaseReason = 'low-score' | 'retrieval-miss' - type ResearchExpansionState = Partial> type GoldenQuestionPromotion = { @@ -92,8 +86,6 @@ type GoldenQuestionPromotion = { value: GoldenQuestionDraft } -const researchStageOrder = ['planning', 'retrieving', 'analyzing', 'generating'] as const -type ResearchStage = (typeof researchStageOrder)[number] const runRetrievalHotkey = 'Mod+Enter' satisfies Hotkey function goldenQuestionEvidenceOptions( @@ -117,311 +109,11 @@ function goldenQuestionEvidenceOptions( return [...options.values()] } -function timeValue(value: number) { - return value < 10_000_000_000 ? value * 1000 : value -} - function normalizedRetrievalTestMode(mode?: string): RetrievalTestMode { if (mode === 'deep' || mode === 'research') return mode return 'fast' } -function formatRecordTime(value: number) { - return new Intl.DateTimeFormat(undefined, { - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - month: 'short', - }).format(value) -} - -function RecordTime({ value }: { value: number }) { - const { t } = useTranslation('dataset') - const [showJustNow, setShowJustNow] = useState(() => { - const age = Date.now() - value - return age >= 0 && age < 60_000 - }) - - useEffect(() => { - if (!showJustNow) return - const timeout = globalThis.setTimeout( - () => setShowJustNow(false), - Math.max(0, value + 60_000 - Date.now()), - ) - return () => globalThis.clearTimeout(timeout) - }, [showJustNow, value]) - - return showJustNow ? t(($) => $['newKnowledge.retrievalTest.justNow']) : formatRecordTime(value) -} - -type ResearchPayloadLabels = { - chunks: string - documents: string - retrievals: string - sources: string - topK: string -} - -const researchPayloadContainers = new Set([ - 'analysis', - 'analyzing', - 'candidates', - 'chunks', - 'coverage', - 'data', - 'details', - 'documents', - 'findings', - 'generating', - 'generation', - 'items', - 'plan', - 'planning', - 'questions', - 'results', - 'retrieval', - 'retrieving', - 'sources', - 'topics', - 'warnings', -]) -const researchPayloadLabels = new Set(['name', 'query', 'question', 'title', 'topic']) -const researchPayloadText = new Set([ - ...researchPayloadLabels, - 'coverage', - 'coveragegap', - 'coveragegapwarning', - 'coveragewarning', - 'finding', - 'findings', - 'mergedcandidatesummary', - 'mergedsummary', - 'message', - 'questions', - 'result', - 'results', - 'summary', - 'topics', - 'warning', - 'warnings', -]) - -function normalizedPayloadKey(key: string) { - return key.replaceAll(/[^a-z0-9]/gi, '').toLocaleLowerCase() -} - -function payloadCountLabel(key: string, labels: ResearchPayloadLabels) { - const normalizedKey = normalizedPayloadKey(key) - if (normalizedKey === 'chunkcount' || normalizedKey === 'chunks') return labels.chunks - if (normalizedKey === 'documentcount' || normalizedKey === 'documents') return labels.documents - if (normalizedKey === 'retrievalcount') return labels.retrievals - if (normalizedKey === 'sourcecount' || normalizedKey === 'sources') return labels.sources - if (normalizedKey === 'topk') return labels.topK -} - -function researchPayloadLines(payload: Record, labels: ResearchPayloadLabels) { - const lines: string[] = [] - const visit = (value: unknown, key = '', depth = 0) => { - if (depth > 3) return - const normalizedKey = normalizedPayloadKey(key) - if (typeof value === 'string') { - if (value.trim() && researchPayloadText.has(normalizedKey)) lines.push(value.trim()) - return - } - if (typeof value === 'number') { - const countLabel = payloadCountLabel(key, labels) - if (countLabel) lines.push(`${countLabel}: ${value}`) - return - } - if (Array.isArray(value)) { - if (!researchPayloadContainers.has(normalizedKey)) return - value.forEach((item) => visit(item, key, depth + 1)) - return - } - if (!value || typeof value !== 'object') return - if (key && !researchPayloadContainers.has(normalizedKey)) return - const record = value as Record - const entries = Object.entries(record) - const labelEntry = entries.find( - ([candidate, nested]) => - researchPayloadLabels.has(normalizedPayloadKey(candidate)) && typeof nested === 'string', - ) - const countEntry = entries.find( - ([candidate, nested]) => - ['chunkcount', 'chunks', 'count'].includes(normalizedPayloadKey(candidate)) && - typeof nested === 'number', - ) - if (labelEntry) { - const [, label] = labelEntry - lines.push( - `${String(label).trim()}${countEntry ? ` · ${countEntry[1]} ${labels.chunks}` : ''}`, - ) - } - entries.forEach(([nestedKey, nested]) => { - if (nestedKey !== labelEntry?.[0] && nestedKey !== countEntry?.[0]) - visit(nested, nestedKey, depth + 1) - }) - } - Object.entries(payload).forEach(([key, value]) => visit(value, key)) - return [...new Set(lines)].slice(0, 12) -} - -function researchStagePayloads(events: ResearchTaskProgressEvent[], stage: ResearchStage) { - const payloads: Record[] = [] - for (const event of [...events].reverse()) { - const details = event.payload.details - if ( - event.payload.previousStage === stage && - details && - typeof details === 'object' && - !Array.isArray(details) - ) { - payloads.push(details as Record) - continue - } - const nested = event.payload[stage] - if (nested && typeof nested === 'object' && !Array.isArray(nested)) { - payloads.push(nested as Record) - continue - } - if ( - event.stage === stage && - event.type !== 'research_task.answer_delta' && - event.payload.previousStage === undefined - ) - payloads.push(event.payload) - } - return payloads -} - -function fallbackResearchStagePayload({ - documentCount, - evidenceCount, - stage, - task, -}: { - documentCount: number - evidenceCount: number - stage: ResearchStage - task: KnowledgeFsResearchTaskResponse -}): Record { - if (stage === 'planning') { - return { - questions: [task.query], - ...(typeof task.top_k === 'number' ? { topK: task.top_k } : {}), - } - } - if (stage === 'retrieving') { - return { - documents: documentCount, - results: [{ chunkCount: evidenceCount, question: task.query }], - } - } - if (stage === 'analyzing') return { chunks: evidenceCount, documents: documentCount } - return { - chunks: evidenceCount, - documents: documentCount, - sources: documentCount || evidenceCount, - } -} - -function useClock(enabled: boolean) { - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - if (!enabled) return - const interval = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(interval) - }, [enabled]) - return now -} - -function researchStageIndex(stage: KnowledgeFsResearchTaskResponse['stage']) { - if (stage === 'queued' || stage === 'paused') return 0 - if (stage === 'completed') return researchStageOrder.length - return researchStageOrder.findIndex((item) => item === stage) -} - -function estimatedStageDuration( - plan: KnowledgeFsResearchTaskPlanResponse | undefined, - stage: ResearchStage, - locale: string, -) { - if (!plan) return undefined - const stepNames: Record> = { - analyzing: new Set(['analyze']), - generating: new Set(['generate']), - planning: new Set(['plan']), - retrieving: new Set(['inspect', 'retrieve']), - } - const milliseconds = plan.steps.reduce((total, step) => { - if (!stepNames[stage].has(typeof step.name === 'string' ? step.name : '')) return total - return total + (typeof step.estimatedLatencyMs === 'number' ? step.estimatedLatencyMs : 0) - }, 0) - return milliseconds > 0 ? formatStageDuration(milliseconds, locale) : undefined -} - -function researchProgressTime(event: ResearchTaskProgressEvent) { - return Date.parse(event.createdAt) -} - -function actualStageDuration( - events: ResearchTaskProgressEvent[], - stage: ResearchStage, - task: KnowledgeFsResearchTaskResponse, - now: number, - locale: string, -) { - const start = events.find((event) => event.stage === stage) - if (!start) return - const startedAt = researchProgressTime(start) - const next = events.find((event) => { - if (event.sequence <= start.sequence) return false - return ( - event.stage === 'canceled' || - event.stage === 'completed' || - event.stage === 'failed' || - (researchStageOrder.includes(event.stage as ResearchStage) && event.stage !== stage) - ) - }) - const endedAt = next - ? researchProgressTime(next) - : task.stage === stage - ? now - : task.completed_at - ? timeValue(task.completed_at) - : undefined - if (endedAt === undefined || endedAt < startedAt) return - return formatStageDuration(endedAt - startedAt, locale) -} - -function mergeResearchProgressEvent( - events: ResearchTaskProgressEvent[], - event: ResearchTaskProgressEvent, -) { - const next = events.filter((candidate) => candidate.sequence !== event.sequence) - next.push(event) - return next.sort((left, right) => left.sequence - right.sequence) -} - -function ScorePill({ score }: { score: number }) { - const { t } = useTranslation('dataset') - const normalized = Math.max(0, Math.min(1, score)) - const displayedScore = normalized > 0 && normalized < 0.01 ? '<0.01' : normalized.toFixed(2) - return ( - - - - {t(($) => $['newKnowledge.retrievalTest.score'])} - - {displayedScore} - - ) -} - function structuredErrorMessage(value: unknown): string | undefined { if (typeof value === 'string') return value.trim() || undefined if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined @@ -468,633 +160,6 @@ async function queryFailure(error: unknown) { } } -function EvidenceCard({ - citationTargetId, - citationTargeted, - evidence, - index, - knowledgeSpaceId, -}: { - citationTargetId?: string - citationTargeted?: boolean - evidence: RetrievalEvidence - index: number - knowledgeSpaceId: string -}) { - const { t } = useTranslation('dataset') - const openHref = - evidence.documentId && evidence.documentRevision - ? newKnowledgeDocumentDetailPath(knowledgeSpaceId, evidence.documentId, { - chunkId: evidence.chunkId, - revision: evidence.documentRevision, - }) - : undefined - - return ( -
-
-

- - {evidence.title || `Chunk ${index + 1}`} -

- {evidence.score !== undefined && } -
-

- {evidence.text} -

- {evidence.images.length > 0 && ( -
- {evidence.images.slice(0, 4).map((image) => ( - - - - ))} - {evidence.images.length > 4 && ( - - - +{evidence.images.length - 4} - - - )} -
- )} -
- - - {evidence.documentName ?? evidence.title} - - {evidence.revision && ( - - {t(($) => $['newKnowledge.retrievalTest.revision'], { - revision: evidence.revision, - })} - - )} - {evidence.page !== undefined && ( - - {t(($) => $['newKnowledge.retrievalTest.page'], { page: evidence.page })} - - )} - - {openHref && ( - - {t(($) => $['newKnowledge.retrievalTest.open'])} - - - )} -
-
- ) -} - -function ResultSkeleton() { - const { t } = useTranslation('common') - - return ( -
$.loading)} className="space-y-3"> - {[0, 1, 2].map((item) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- ) -} - -function EmptyState({ - description, - kind = 'initial', - title, -}: { - description: string - kind?: 'initial' | 'no-results' - title: string -}) { - return ( -
- -

{title}

-

{description}

-
- ) -} - -function FailedResult({ description, onRetry }: { description: string; onRetry: () => void }) { - const { t } = useTranslation('dataset') - return ( -
- - - {t(($) => $['newKnowledge.retrievalTest.failedTitle'])} - {' — '} - {description} - - -
- ) -} - -const researchCitationPattern = /(? { - if (index % 2 === 1) return segment - return segment.replace(researchCitationPattern, (citation, rawCitationNumber: string) => { - const citationNumber = Number(rawCitationNumber) - if (citationNumber < 1 || citationNumber > citationCount) return citation - return `[${citation}](#research-evidence-${citationNumber})` - }) - }) - .join('') -} - -type ResearchAnswerLinkProps = PropsWithChildren> & { - node?: unknown - onCitationClick: (citationIndex: number) => void -} - -function ResearchAnswerLink({ - children, - href, - node, - onCitationClick, - ...props -}: ResearchAnswerLinkProps) { - const citationMatch = href?.match(/^#research-evidence-(\d+)$/) - if (!citationMatch) - return ( - - {children} - - ) - - const citationIndex = Number(citationMatch[1]) - 1 - return ( - { - event.preventDefault() - onCitationClick(citationIndex) - }} - > - {children} - - ) -} - -function ResearchAnswer({ - answer, - citationCount, - onCitationClick, - streaming, -}: { - answer: string - citationCount: number - onCitationClick: (citationIndex: number) => void - streaming: boolean -}) { - const { t } = useTranslation('dataset') - const linkedAnswer = useMemo( - () => linkResearchCitations(answer, citationCount), - [answer, citationCount], - ) - const citationComponents = useMemo>( - () => ({ - a: (props) => , - }), - [onCitationClick], - ) - return ( -
-
- -

- {t(($) => - streaming - ? $['newKnowledge.retrievalTest.generatingActive'] - : $['newKnowledge.retrievalTest.generating'], - )} -

- {streaming && ( - - )} -
-
- -
-
- ) -} - -function QualityActions({ - badCaseAvailable, - decision, - noResults, - onBadCase, - onGolden, - pending, - qualityHref, -}: { - badCaseAvailable: boolean - decision?: QualityDecision - noResults?: boolean - onBadCase: (reason: BadCaseReason) => Promise - onGolden: () => void - pending?: boolean - qualityHref: string -}) { - const { t } = useTranslation('dataset') - if (decision) { - return ( -
- - - {t(($) => - decision === 'golden' - ? $['newKnowledge.retrievalTest.savedGoldenQuestion'] - : $['newKnowledge.retrievalTest.savedBadCase'], - )} - - - {t(($) => $['newKnowledge.retrievalTest.viewInQuality'])} - -
- ) - } - if (!badCaseAvailable && noResults) return null - - return ( -
- {badCaseAvailable && ( - - } - > - - {t(($) => $['newKnowledge.retrievalTest.makeBadCase'])} - - - void onBadCase('low-score')}> - {t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])} - - void onBadCase('retrieval-miss')}> - {t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])} - - - - )} - {!noResults && ( - - )} -
- ) -} - -function ResearchProcess({ - documentCount, - evidenceCount, - events, - expanded, - onCancel, - onToggle, - plan, - task, -}: { - documentCount: number - evidenceCount: number - events: ResearchTaskProgressEvent[] - expanded: boolean - onCancel?: () => void - onToggle: () => void - plan?: KnowledgeFsResearchTaskPlanResponse - task: KnowledgeFsResearchTaskResponse -}) { - const { t, i18n } = useTranslation('dataset') - const active = researchTaskIsActive(task) - const now = useClock(active) - const firstProgressAt = events[0] ? researchProgressTime(events[0]) : undefined - const terminalProgress = [...events] - .reverse() - .find( - (event) => - event.stage === 'canceled' || event.stage === 'completed' || event.stage === 'failed', - ) - const startedAt = firstProgressAt ?? timeValue(task.created_at) - const endedAt = terminalProgress - ? researchProgressTime(terminalProgress) - : task.completed_at - ? timeValue(task.completed_at) - : now - const duration = formatDuration(endedAt - startedAt, i18n.language) - const currentIndex = researchStageIndex(task.stage) - const latestVisitedIndex = events.reduce((latest, event) => { - const index = researchStageOrder.indexOf(event.stage as ResearchStage) - return Math.max(latest, index) - }, -1) - const summary = - task.stage === 'completed' - ? t(($) => $['newKnowledge.retrievalTest.completedIn'], { duration }) - : task.stage === 'canceled' - ? t(($) => $['newKnowledge.retrievalTest.canceled']) - : task.stage === 'failed' - ? t(($) => $['newKnowledge.retrievalTest.failedTitle']) - : t(($) => $['newKnowledge.retrievalTest.running']) - const labels: Record<(typeof researchStageOrder)[number], string> = { - analyzing: t(($) => $['newKnowledge.retrievalTest.analyzing']), - generating: t(($) => $['newKnowledge.retrievalTest.generating']), - planning: t(($) => $['newKnowledge.retrievalTest.planning']), - retrieving: t(($) => $['newKnowledge.retrievalTest.retrieving']), - } - const activeLabels: Record<(typeof researchStageOrder)[number], string> = { - analyzing: t(($) => $['newKnowledge.retrievalTest.analyzingActive']), - generating: t(($) => $['newKnowledge.retrievalTest.generatingActive']), - planning: t(($) => $['newKnowledge.retrievalTest.planningActive']), - retrieving: t(($) => $['newKnowledge.retrievalTest.retrievingActive']), - } - const payloadLabels: ResearchPayloadLabels = { - chunks: t(($) => $['newKnowledge.chunkCount']), - documents: t(($) => $['newKnowledge.documents']), - retrievals: t(($) => $['newKnowledge.retrievalCount']), - sources: t(($) => $['newKnowledge.sources']), - topK: t(($) => $['newKnowledge.settings.topKLabel']), - } - - return ( -
-
- - {active && onCancel && ( - - )} -
- {expanded && ( -
-
    - {researchStageOrder.map((stage, index) => { - const completed = - task.stage === 'completed' || index < currentIndex || index < latestVisitedIndex - const current = index === currentIndex && active - const stageDuration = - actualStageDuration(events, stage, task, now, i18n.language) ?? - estimatedStageDuration(plan, stage, i18n.language) - const fallbackPayload = fallbackResearchStagePayload({ - documentCount, - evidenceCount, - stage, - task, - }) - const payloadLines = [...researchStagePayloads(events, stage), fallbackPayload] - .flatMap((payload) => researchPayloadLines(payload, payloadLabels)) - .filter((line, lineIndex, lines) => lines.indexOf(line) === lineIndex) - .slice(0, 12) - return ( -
  1. - - - - - {index < researchStageOrder.length - 1 && ( - - )} - - - - - {current ? activeLabels[stage] : labels[stage]} - - {stageDuration && (completed || current) && ( - - {stageDuration} - - )} - - {current && stage === 'retrieving' && evidenceCount > 0 && ( - - {t(($) => $['newKnowledge.retrievalTest.foundSoFar'], { - count: evidenceCount, - })} - - )} - {payloadLines.length > 0 && (completed || current) && ( -
      - {payloadLines.map((line) => ( -
    • - {line} -
    • - ))} -
    - )} -
    -
  2. - ) - })} -
-
- )} -
- ) -} - -function RecordButton({ - active, - index, - onClick, - record, -}: { - active: boolean - index: number - onClick: () => void - record: RetrievalTestRecord -}) { - const { t, i18n } = useTranslation('dataset') - const failed = record.kind !== 'research' && record.status === 'failed' - const activeResearchStage = - record.kind === 'research' && record.status === 'running' - ? researchStageOrder[ - Math.min(Math.max(researchStageIndex(record.stage), 0), researchStageOrder.length - 1) - ] - : undefined - return ( - - ) -} - export function RetrievalTestPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') const queryClient = useQueryClient() diff --git a/web/features/new-rag/retrieval-test-results.tsx b/web/features/new-rag/retrieval-test-results.tsx new file mode 100644 index 00000000000..cc9554847b3 --- /dev/null +++ b/web/features/new-rag/retrieval-test-results.tsx @@ -0,0 +1,403 @@ +'use client' + +import type { AnchorHTMLAttributes, PropsWithChildren } from 'react' +import type { RetrievalEvidence } from './retrieval-test-model' +import type { MarkdownProps } from '@/app/components/base/markdown' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Markdown } from '@/app/components/base/markdown' +import { Link as MarkdownLink } from '@/app/components/base/markdown-blocks' +import Link from '@/next/link' +import { newKnowledgeDocumentDetailPath } from './routes' + +export type QualityDecision = 'bad-case' | 'golden' +export type BadCaseReason = 'low-score' | 'retrieval-miss' + +function ScorePill({ score }: { score: number }) { + const { t } = useTranslation('dataset') + const normalized = Math.max(0, Math.min(1, score)) + const displayedScore = normalized > 0 && normalized < 0.01 ? '<0.01' : normalized.toFixed(2) + return ( + + + + {t(($) => $['newKnowledge.retrievalTest.score'])} + + {displayedScore} + + ) +} + +export function EvidenceCard({ + citationTargetId, + citationTargeted, + evidence, + index, + knowledgeSpaceId, +}: { + citationTargetId?: string + citationTargeted?: boolean + evidence: RetrievalEvidence + index: number + knowledgeSpaceId: string +}) { + const { t } = useTranslation('dataset') + const openHref = + evidence.documentId && evidence.documentRevision + ? newKnowledgeDocumentDetailPath(knowledgeSpaceId, evidence.documentId, { + chunkId: evidence.chunkId, + revision: evidence.documentRevision, + }) + : undefined + + return ( +
+
+

+ + {evidence.title || `Chunk ${index + 1}`} +

+ {evidence.score !== undefined && } +
+

+ {evidence.text} +

+ {evidence.images.length > 0 && ( +
+ {evidence.images.slice(0, 4).map((image) => ( + + + + ))} + {evidence.images.length > 4 && ( + + + +{evidence.images.length - 4} + + + )} +
+ )} +
+ + + {evidence.documentName ?? evidence.title} + + {evidence.revision && ( + + {t(($) => $['newKnowledge.retrievalTest.revision'], { + revision: evidence.revision, + })} + + )} + {evidence.page !== undefined && ( + + {t(($) => $['newKnowledge.retrievalTest.page'], { page: evidence.page })} + + )} + + {openHref && ( + + {t(($) => $['newKnowledge.retrievalTest.open'])} + + + )} +
+
+ ) +} + +export function ResultSkeleton() { + const { t } = useTranslation('common') + + return ( +
$.loading)} className="space-y-3"> + {[0, 1, 2].map((item) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +export function EmptyState({ + description, + kind = 'initial', + title, +}: { + description: string + kind?: 'initial' | 'no-results' + title: string +}) { + return ( +
+ +

{title}

+

{description}

+
+ ) +} + +export function FailedResult({ + description, + onRetry, +}: { + description: string + onRetry: () => void +}) { + const { t } = useTranslation('dataset') + return ( +
+ + + {t(($) => $['newKnowledge.retrievalTest.failedTitle'])} + {' — '} + {description} + + +
+ ) +} + +const researchCitationPattern = /(? { + if (index % 2 === 1) return segment + return segment.replace(researchCitationPattern, (citation, rawCitationNumber: string) => { + const citationNumber = Number(rawCitationNumber) + if (citationNumber < 1 || citationNumber > citationCount) return citation + return `[${citation}](#research-evidence-${citationNumber})` + }) + }) + .join('') +} + +type ResearchAnswerLinkProps = PropsWithChildren> & { + node?: unknown + onCitationClick: (citationIndex: number) => void +} + +function ResearchAnswerLink({ + children, + href, + node, + onCitationClick, + ...props +}: ResearchAnswerLinkProps) { + const citationMatch = href?.match(/^#research-evidence-(\d+)$/) + if (!citationMatch) + return ( + + {children} + + ) + + const citationIndex = Number(citationMatch[1]) - 1 + return ( + { + event.preventDefault() + onCitationClick(citationIndex) + }} + > + {children} + + ) +} + +export function ResearchAnswer({ + answer, + citationCount, + onCitationClick, + streaming, +}: { + answer: string + citationCount: number + onCitationClick: (citationIndex: number) => void + streaming: boolean +}) { + const { t } = useTranslation('dataset') + const linkedAnswer = useMemo( + () => linkResearchCitations(answer, citationCount), + [answer, citationCount], + ) + const citationComponents = useMemo>( + () => ({ + a: (props) => , + }), + [onCitationClick], + ) + return ( +
+
+ +

+ {t(($) => + streaming + ? $['newKnowledge.retrievalTest.generatingActive'] + : $['newKnowledge.retrievalTest.generating'], + )} +

+ {streaming && ( + + )} +
+
+ +
+
+ ) +} + +export function QualityActions({ + badCaseAvailable, + decision, + noResults, + onBadCase, + onGolden, + pending, + qualityHref, +}: { + badCaseAvailable: boolean + decision?: QualityDecision + noResults?: boolean + onBadCase: (reason: BadCaseReason) => Promise + onGolden: () => void + pending?: boolean + qualityHref: string +}) { + const { t } = useTranslation('dataset') + if (decision) { + return ( +
+ + + {t(($) => + decision === 'golden' + ? $['newKnowledge.retrievalTest.savedGoldenQuestion'] + : $['newKnowledge.retrievalTest.savedBadCase'], + )} + + + {t(($) => $['newKnowledge.retrievalTest.viewInQuality'])} + +
+ ) + } + if (!badCaseAvailable && noResults) return null + + return ( +
+ {badCaseAvailable && ( + + } + > + + {t(($) => $['newKnowledge.retrievalTest.makeBadCase'])} + + + void onBadCase('low-score')}> + {t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])} + + void onBadCase('retrieval-miss')}> + {t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])} + + + + )} + {!noResults && ( + + )} +
+ ) +} diff --git a/web/features/new-rag/source-actions.tsx b/web/features/new-rag/source-actions.tsx new file mode 100644 index 00000000000..ae2567d8810 --- /dev/null +++ b/web/features/new-rag/source-actions.tsx @@ -0,0 +1,305 @@ +'use client' + +import type { SourceAction, SourceEditValues } from './source-list-model' +import type { Source, SourceSyncPolicy } from './source-models' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Input } from '@langgenius/dify-ui/input' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH } from './routes' +import { + getOpenableSourceUri, + sourceCustomIntervalHours, + sourceSyncMode, + sourceSyncPolicyChanged, +} from './source-list-model' +import { SyncPolicyField } from './sync-policy-field' + +const MIN_CUSTOM_INTERVAL_HOURS = 1 +const MAX_CUSTOM_INTERVAL_HOURS = 720 + +export function SourceActions({ + canEdit, + canRemove, + canSync, + canToggle, + onEdit, + onRemove, + onSync, + onToggle, + pendingAction, + source, + syncAction, +}: { + canEdit: boolean + canRemove: boolean + canSync: boolean + canToggle: boolean + onEdit: (values: SourceEditValues) => Promise + onRemove: () => Promise + onSync: () => Promise + onToggle: () => Promise + pendingAction?: SourceAction + source: Source + syncAction: 'retry' | 'sync' +}) { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const [menuOpen, setMenuOpen] = useState(false) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [nextName, setNextName] = useState(source.name) + const [nextSyncMode, setNextSyncMode] = useState(() => + sourceSyncMode(source), + ) + const [nextCustomIntervalHours, setNextCustomIntervalHours] = useState(() => + sourceCustomIntervalHours(source), + ) + const [removeDialogOpen, setRemoveDialogOpen] = useState(false) + const sourceUri = getOpenableSourceUri(source.uri) + const customIntervalValid = + typeof nextCustomIntervalHours === 'number' && + Number.isInteger(nextCustomIntervalHours) && + nextCustomIntervalHours >= MIN_CUSTOM_INTERVAL_HOURS && + nextCustomIntervalHours <= MAX_CUSTOM_INTERVAL_HOURS + const nameChanged = nextName.trim() !== source.name + const syncPolicyChanged = + customIntervalValid && + sourceSyncPolicyChanged(source, nextSyncMode, nextCustomIntervalHours as number) + const editChanged = nameChanged || syncPolicyChanged + + const openEditDialog = () => { + setNextName(source.name) + setNextSyncMode(sourceSyncMode(source)) + setNextCustomIntervalHours(sourceCustomIntervalHours(source)) + setMenuOpen(false) + setEditDialogOpen(true) + } + + const submitEdit = async () => { + const name = nextName.trim() + if (!name || !customIntervalValid || !editChanged || pendingAction) return + if ( + await onEdit({ + customIntervalHours: nextCustomIntervalHours as number, + name, + syncMode: nextSyncMode, + }) + ) + setEditDialogOpen(false) + } + + if (!canEdit && !canRemove && !canSync && !canToggle && !sourceUri) return null + + return ( + <> + + $['newKnowledge.sourceActions'], { name: source.name })} + disabled={Boolean(pendingAction)} + className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled" + > + + + + {canSync && ( + void onSync()} + className="mb-px h-7 gap-2 px-2 system-sm-medium" + > + + {syncAction === 'retry' + ? tCommon(($) => $['operation.retry']) + : t(($) => $['newKnowledge.syncNow'])} + + )} + {sourceUri && ( + $['newKnowledge.openSource'])} + href={sourceUri} + target="_blank" + rel="noopener noreferrer" + /> + } + className="mb-px h-7 gap-2 px-2 system-sm-medium" + > + + {t(($) => $['newKnowledge.openSource'])} + + )} + {canEdit && ( + + + {tCommon(($) => $['operation.edit'])} + + )} + {canToggle && ( + void onToggle()} + className="h-7 gap-2 px-2 system-sm-medium" + > + + {source.status === 'disabled' + ? t(($) => $.enable) + : t(($) => $['newKnowledge.disableSource'])} + + )} + {canRemove && ( + <> + {(canEdit || canSync || canToggle || sourceUri) && ( + + )} + { + setMenuOpen(false) + setRemoveDialogOpen(true) + }} + variant="destructive" + className="h-7 gap-2 px-2 system-sm-medium" + > + + {t(($) => $['newKnowledge.removeSource'])} + + + )} + + + + +
{ + event.preventDefault() + void submitEdit() + }} + > + + {tCommon(($) => $['operation.edit'])} {source.name} + + + setNextName(event.target.value)} + /> +
+ { + setNextSyncMode(value.mode) + if (value.customIntervalSeconds) + setNextCustomIntervalHours(value.customIntervalSeconds / 3600) + }} + /> +
+
+ + +
+
+
+
+ + +
+ + {tCommon(($) => $['operation.deleteConfirmTitle'])} + + + {tCommon(($) => $['operation.confirmAction'])} + +
+ + + {tCommon(($) => $['operation.cancel'])} + + + void onRemove().then((removed) => { + if (removed) setRemoveDialogOpen(false) + }) + } + > + {t(($) => $['newKnowledge.removeSource'])} + + +
+
+ + ) +} diff --git a/web/features/new-rag/source-list-item.tsx b/web/features/new-rag/source-list-item.tsx new file mode 100644 index 00000000000..efb075dc71d --- /dev/null +++ b/web/features/new-rag/source-list-item.tsx @@ -0,0 +1,425 @@ +'use client' + +import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' +import type { SourceAction, SourceEditValues } from './source-list-model' +import type { Source, SourceDisplayStatus } from './source-models' +import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { consoleClient, consoleQuery } from '@/service/client' +import { knowledgeFsTaskFailureMessageKey } from './knowledge-fs-task-error' +import { SourceActions } from './source-actions' +import { sourceTableGridClass } from './source-list-layout' +import { + createIdempotencyKey, + metadataString, + sourceLastSyncAt, + sourceProviderDetails, + sourceSyncPolicyChanged, + sourceSyncPolicyTranslationKey, + syncPolicyConfiguration, +} from './source-list-model' +import { + initialSourceWorkflowId, + sourceAsyncImportWorkflowId, + sourceDisplayStatus, + sourceFromApi, + sourceStatusWithSyncWorkflow, + sourceSyncPolicyFromApi, + sourceWorkflowFromApi, + sourceWorkflowIsActive, +} from './source-models' +import { SourceProviderIcon } from './source-setup-fields' + +const statusDotStatus: Record = { + active: 'success', + syncing: 'normal', + initializing: 'normal', + disabled: 'disabled', + error: 'error', +} + +function TruncatedSourceValue({ children, className }: { children: string; className?: string }) { + return ( + + {children}} + /> + {children} + + ) +} + +export function SourceRow({ + canEdit, + canSync, + checked, + ensureModelSetupReady, + knowledgeSpaceId, + onCheckedChange, + onRemoved, + onSourceChange, + source, +}: { + canEdit: boolean + canSync: boolean + checked: boolean + ensureModelSetupReady: () => Promise + knowledgeSpaceId: string + onCheckedChange: (checked: boolean) => void + onRemoved: () => void + onSourceChange: (source: Source) => void + source: Source +}) { + const { t } = useTranslation('dataset') + const { t: tCommon } = useTranslation('common') + const { formatTimeFromNow } = useFormatTimeFromNow() + const queryClient = useQueryClient() + const [pendingAction, setPendingAction] = useState() + const syncWorkflow = source.syncWorkflow + const displayStatus = sourceDisplayStatus(source) + const initializing = displayStatus === 'initializing' + const initialWorkflowId = initialSourceWorkflowId(source) + const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing' + + const provider = sourceProviderDetails(source) + const providerName = provider.name + const providerKind = metadataString(source.metadata, 'providerKind') + const sourceSyncPolicy = source.syncPolicy + const syncPolicy = sourceSyncPolicy + ? t(($) => $[sourceSyncPolicyTranslationKey(sourceSyncPolicy)]) + : metadataString(source.metadata, 'syncPolicy') + const lastSyncAt = sourceLastSyncAt(source) + const lastSyncTimestamp = lastSyncAt ? Date.parse(lastSyncAt) : Number.NaN + const lastSync = Number.isNaN(lastSyncTimestamp) + ? undefined + : formatTimeFromNow(lastSyncTimestamp) + const syncFailureMessageKey = knowledgeFsTaskFailureMessageKey( + undefined, + syncWorkflow?.lastErrorCode, + ) + const typeLabel = + source.type === 'connector' && + (providerKind === 'online-document' || + providerName === 'Notion' || + providerName === 'Google Docs' || + providerName === 'Confluence') + ? t(($) => $['newKnowledge.onlineDocuments']) + : source.type === 'connector' && + (providerKind === 'online-drive' || + providerName === 'Google Drive' || + providerName === 'OneDrive' || + providerName === 'Amazon S3') + ? t(($) => $['newKnowledge.onlineDrive']) + : t(($) => $[`newKnowledge.sourceType.${source.type}`]) + const sourceIcon = + provider.iconClass ?? (source.type === 'web' ? 'i-ri-global-line' : 'i-ri-links-line') + + const runAction = async ( + action: SourceAction, + mutation: () => Promise, + onAccepted?: (result: Result) => void, + beforeAction?: () => Promise, + ) => { + if (pendingAction) return false + setPendingAction(action) + try { + if (beforeAction && !(await beforeAction())) return false + let result: Result + try { + result = await mutation() + } catch { + toast.error(t(($) => $['newKnowledge.sourcesErrorDescription'])) + try { + await queryClient.invalidateQueries({ + queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(), + }) + } catch { + return false + } + return false + } + onAccepted?.(result) + + try { + await queryClient.invalidateQueries( + { + queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(), + }, + { + throwOnError: true, + }, + ) + } catch { + // The accepted mutation is already reflected by the list-owner state. + } + return true + } finally { + setPendingAction(undefined) + } + } + + const applyAcceptedWorkflow = (workflow: Parameters[0]) => { + const run = sourceWorkflowFromApi(workflow) + onSourceChange({ + ...source, + syncWorkflow: run, + status: sourceStatusWithSyncWorkflow(source.status, run), + }) + } + + const syncSource = () => + runAction( + 'sync', + () => + consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({ + headers: { 'Idempotency-Key': createIdempotencyKey() }, + params: { control_space_id: knowledgeSpaceId, source_id: source.id }, + }), + applyAcceptedWorkflow, + ensureModelSetupReady, + ) + + const retrySource = () => { + const retryWorkflowId = + initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id + if (!retryWorkflowId) return syncSource() + + return runAction( + 'sync', + () => + consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({ + params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId }, + }), + applyAcceptedWorkflow, + ensureModelSetupReady, + ) + } + + const toggleSource = () => + runAction( + 'toggle', + async () => + sourceFromApi( + await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({ + body: { + ...(source.version === undefined ? {} : { expectedVersion: source.version }), + status: source.status === 'disabled' ? 'active' : 'disabled', + }, + params: { control_space_id: knowledgeSpaceId, source_id: source.id }, + }), + ), + (updatedSource) => { + const syncWorkflow = + updatedSource.syncWorkflow ?? + (sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined) + onSourceChange({ + ...updatedSource, + lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt, + status: sourceStatusWithSyncWorkflow(updatedSource.status, syncWorkflow), + syncWorkflow, + syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy, + }) + }, + ) + + const editSource = ({ customIntervalHours, name, syncMode }: SourceEditValues) => + runAction( + 'edit', + async () => { + let updatedSource = source + if (name !== source.name) + updatedSource = sourceFromApi( + await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({ + body: { + ...(source.version === undefined ? {} : { expectedVersion: source.version }), + name, + }, + params: { control_space_id: knowledgeSpaceId, source_id: source.id }, + }), + ) + if (sourceSyncPolicyChanged(source, syncMode, customIntervalHours)) { + if (updatedSource.version === undefined) throw new Error('Source version is required') + const syncPolicy = sourceSyncPolicyFromApi( + await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.syncPolicy.put( + { + body: { + ...syncPolicyConfiguration(syncMode, customIntervalHours), + expectedRevision: source.syncPolicy?.revision ?? 0, + expectedSourceVersion: updatedSource.version, + }, + params: { control_space_id: knowledgeSpaceId, source_id: source.id }, + }, + ), + ) + updatedSource = { ...updatedSource, syncPolicy } + } + return updatedSource + }, + (updatedSource) => { + onSourceChange({ + ...updatedSource, + lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt, + syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy, + syncWorkflow: updatedSource.syncWorkflow ?? source.syncWorkflow, + }) + }, + ) + + const removeSource = () => + runAction( + 'remove', + async () => { + if (source.version === undefined) throw new Error('Source version is required') + return consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.delete({ + body: { expectedRevision: source.version }, + headers: { 'Idempotency-Key': createIdempotencyKey() }, + params: { control_space_id: knowledgeSpaceId, source_id: source.id }, + query: { documents: 'keep' }, + }) + }, + onRemoved, + ) + + return ( + td:not(:first-child)]:opacity-60', + )} + > + + + + +
+ + + {source.name} + +
+ + +

+ {t(($) => $['metadata.createMetadata.type'])} +

+
+ + {providerName ?? typeLabel} + + {providerName && ( + + {typeLabel} + + )} +
+ + +

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

+ + {initializing ? ( + + ) : ( + + )} + {source.name}: + {t(($) => $[`newKnowledge.sourceStatus.${displayStatus}`])} + {displayStatus === 'error' && syncFailureMessageKey && ( + $[syncFailureMessageKey])} + iconVariant="information" + popupClassName="max-w-80" + > + {t(($) => $[syncFailureMessageKey])} + + )} + + + +

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

+ + {syncPolicy ?? '—'} + + + +

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

+ {displayStatus === 'syncing' && syncWorkflow ? ( + + + {t(($) => $['newKnowledge.sourceSyncProgress'], { + completed: + syncWorkflow.progressCompleted + + syncWorkflow.progressFailed + + syncWorkflow.progressSkipped, + total: syncWorkflow.progressTotal ?? '—', + })} + + ) : ( + {lastSync ?? '—'} + )} + + +
+ {canSync && displayStatus === 'error' && ( + + )} + +
+ + + ) +} diff --git a/web/features/new-rag/source-list-layout.ts b/web/features/new-rag/source-list-layout.ts new file mode 100644 index 00000000000..c9d5e0fd498 --- /dev/null +++ b/web/features/new-rag/source-list-layout.ts @@ -0,0 +1,2 @@ +export const sourceTableGridClass = + 'grid grid-cols-2 gap-x-4 gap-y-3 @min-[768px]/knowledge-content:grid-cols-[16px_minmax(0,1fr)_120px_120px_40px] @min-[768px]/knowledge-content:gap-x-3 @min-[768px]/knowledge-content:gap-y-0 @min-[960px]/knowledge-content:grid-cols-[16px_minmax(200px,1fr)_160px_120px_120px_120px_40px] @min-[1280px]/knowledge-content:grid-cols-[16px_minmax(0,1fr)_180px_140px_120px_160px_80px]' diff --git a/web/features/new-rag/source-list-model.ts b/web/features/new-rag/source-list-model.ts new file mode 100644 index 00000000000..cf3e0df485a --- /dev/null +++ b/web/features/new-rag/source-list-model.ts @@ -0,0 +1,122 @@ +import type { Source, SourceSyncPolicy } from './source-models' +import { normalizeSourceProviderName, sourceProviderPresentation } from './source-provider-options' + +const MIN_CUSTOM_INTERVAL_HOURS = 1 + +export function metadataString(metadata: Source['metadata'], key: string) { + const value = metadata[key] + return typeof value === 'string' && value.trim() ? value : undefined +} + +function metadataRecord(metadata: Source['metadata'], key: string) { + const value = metadata[key] + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function sourceProviderType(source: Source, providerKind?: string) { + if (source.type === 'web' || providerKind === 'website') return 'websiteCrawl' as const + if (providerKind === 'online-document') return 'onlineDocuments' as const + if (providerKind === 'online-drive') return 'onlineDrive' as const + return undefined +} + +export function sourceProviderDetails(source: Source) { + const providerKind = metadataString(source.metadata, 'providerKind') + const providerType = sourceProviderType(source, providerKind) + const explicitName = metadataString(source.metadata, 'providerName') + if (explicitName) { + const presentation = sourceProviderPresentation(explicitName, providerType) + return { + iconClass: presentation?.fallbackIcon, + name: presentation?.label ?? explicitName, + } + } + + const providerId = metadataString(source.metadata, 'providerId') + if (!providerId) return {} + const presentation = sourceProviderPresentation(providerId, providerType) + if (presentation) return { iconClass: presentation.fallbackIcon, name: presentation.label } + if (normalizeSourceProviderName(providerId).includes('fakecrawler')) + return { name: 'FakeCrawler' } + return {} +} + +export function sourceLastSyncAt(source: Source) { + const syncMetadata = metadataRecord(source.metadata, 'sync') + return ( + source.lastSyncedAt ?? + metadataString(source.metadata, 'lastSyncedAt') ?? + (syncMetadata ? metadataString(syncMetadata, 'lastRunAt') : undefined) + ) +} + +export function sourceSyncPolicyTranslationKey(policy: SourceSyncPolicy) { + if (!policy.enabled || policy.mode === 'manual') return 'newKnowledge.syncPolicyManual' as const + if (policy.mode === 'provider') return 'newKnowledge.syncPolicyProvider' as const + if (policy.mode === 'interval') return 'newKnowledge.syncPolicyDaily' as const + return 'newKnowledge.syncPolicyCustom' as const +} + +export function sourceSyncMode(source: Source): SourceSyncPolicy['mode'] { + return source.syncPolicy?.mode ?? 'manual' +} + +export function sourceCustomIntervalHours(source: Source) { + return source.syncPolicy?.customIntervalSeconds + ? source.syncPolicy.customIntervalSeconds / 3600 + : MIN_CUSTOM_INTERVAL_HOURS +} + +export function syncPolicyConfiguration( + mode: SourceSyncPolicy['mode'], + customIntervalHours: number, +) { + if (mode === 'manual') return { enabled: false, mode } as const + if (mode === 'custom') + return { + customIntervalSeconds: customIntervalHours * 3600, + enabled: true, + mode, + } as const + return { enabled: true, mode } as const +} + +export function sourceSyncPolicyChanged( + source: Source, + mode: SourceSyncPolicy['mode'], + customIntervalHours: number, +) { + if (mode !== sourceSyncMode(source)) return true + return ( + mode === 'custom' && customIntervalHours * 3600 !== source.syncPolicy?.customIntervalSeconds + ) +} + +export type SourceEditValues = { + customIntervalHours: number + name: string + syncMode: SourceSyncPolicy['mode'] +} + +export function createIdempotencyKey() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` +} + +export function getOpenableSourceUri(uri: string) { + try { + const url = new URL(uri) + if (url.protocol === 's3:' && url.hostname) { + const prefix = decodeURIComponent(url.pathname.replace(/^\//, '')) + const consoleUrl = new URL(`https://s3.console.aws.amazon.com/s3/buckets/${url.hostname}`) + if (prefix) consoleUrl.searchParams.set('prefix', prefix) + return consoleUrl.toString() + } + return url.protocol === 'http:' || url.protocol === 'https:' ? uri : undefined + } catch { + return undefined + } +} + +export type SourceAction = 'edit' | 'remove' | 'sync' | 'toggle' diff --git a/web/features/new-rag/sources-empty.tsx b/web/features/new-rag/sources-empty.tsx new file mode 100644 index 00000000000..dbe214bee36 --- /dev/null +++ b/web/features/new-rag/sources-empty.tsx @@ -0,0 +1,115 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { newKnowledgeAddSourcePath } from './routes' + +const emptySourceShortcuts = [ + { + brand: 'firecrawl', + iconClass: 'i-custom-public-common-firecrawl', + provider: 'Firecrawl', + sourceType: 'websiteCrawl', + }, + { + brand: 'jina', + iconClass: 'i-custom-public-llm-jina', + provider: 'Jina Reader', + sourceType: 'websiteCrawl', + }, + { + brand: 'notion', + iconClass: 'i-custom-public-common-notion text-text-primary', + provider: 'Notion', + sourceType: 'onlineDocuments', + }, + { + brand: 'google-drive', + iconClass: 'i-custom-public-common-google-drive', + provider: 'Google Drive', + sourceType: 'onlineDrive', + }, + { + brand: 'confluence', + iconClass: 'i-custom-public-new-rag-confluence', + provider: 'Confluence', + sourceType: 'onlineDocuments', + }, +] as const + +export function SourcesEmpty({ + canAddSource, + knowledgeSpaceId, +}: { + canAddSource: boolean + knowledgeSpaceId: string +}) { + const { t } = useTranslation('dataset') + + return ( +
+
+ {emptySourceShortcuts.map((shortcut) => { + const icon = ( + + ) + if (!canAddSource) return icon + return ( + + {icon} + {shortcut.provider} + + ) + })} + {canAddSource ? ( + $['newKnowledge.moreProviders'])} + href={newKnowledgeAddSourcePath(knowledgeSpaceId)} + className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + + + ) : ( + + )} +
+
+

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

+

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

+
+ {canAddSource && ( + + + {t(($) => $['newKnowledge.addSource'])} + + )} +
+ ) +} diff --git a/web/features/new-rag/sources-page.tsx b/web/features/new-rag/sources-page.tsx index a6a249f0e02..b4c641f6356 100644 --- a/web/features/new-rag/sources-page.tsx +++ b/web/features/new-rag/sources-page.tsx @@ -1,30 +1,10 @@ 'use client' -import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' import type { SourceFilter } from './source-list-query-state' -import type { Source, SourceDisplayStatus, SourceSyncPolicy } from './source-models' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@langgenius/dify-ui/alert-dialog' +import type { Source } from './source-models' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLinkItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { Input } from '@langgenius/dify-ui/input' import { Select, SelectContent, @@ -34,43 +14,33 @@ import { SelectLabel, SelectTrigger, } from '@langgenius/dify-ui/select' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { toast } from '@langgenius/dify-ui/toast' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Infotip } from '@/app/components/base/infotip' import Loading from '@/app/components/base/loading' import { SearchInput } from '@/app/components/base/search-input' -import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' -import { consoleClient, consoleQuery } from '@/service/client' +import { consoleQuery } from '@/service/client' import { KnowledgeModelReadinessBanner } from './components/knowledge-model-readiness-banner' import { KnowledgeModelSetupDialog } from './components/knowledge-model-setup-dialog' -import { knowledgeFsTaskFailureMessageKey } from './knowledge-fs-task-error' import { useKnowledgeSpacePermission } from './knowledge-space-context' -import { NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH, newKnowledgeAddSourcePath } from './routes' +import { newKnowledgeAddSourcePath } from './routes' +import { SourceRow } from './source-list-item' +import { sourceTableGridClass } from './source-list-layout' import { sourceFilterParser, sourceSearchParser, sourceSortParser } from './source-list-query-state' import { initialSourcePollingPhase, - initialSourceWorkflowId, isInitialSourceForOperation, shouldHidePreviewSource, - sourceAsyncImportWorkflowId, sourceDisplayStatus, sourceFromApi, sourceNeedsPolling, sourceStatusWithSyncWorkflow, - sourceSyncPolicyFromApi, - sourceWorkflowFromApi, sourceWorkflowIsActive, } from './source-models' -import { normalizeSourceProviderName, sourceProviderPresentation } from './source-provider-options' -import { SourceProviderIcon } from './source-setup-fields' -import { SyncPolicyField } from './sync-policy-field' +import { SourcesEmpty } from './sources-empty' import { useKnowledgeModelSetupGuard } from './use-knowledge-model-setup-guard' const PAGE_SIZE = 200 @@ -78,165 +48,6 @@ const MAX_AUTO_CURSOR_PAGES = 5 const AWAIT_INITIAL_SOURCE_POLL_INTERVAL = 2000 const SOURCE_POLL_INTERVAL = 3000 const INITIAL_SOURCE_POLL_TIMEOUT = 10 * 60 * 1000 -const MIN_CUSTOM_INTERVAL_HOURS = 1 -const MAX_CUSTOM_INTERVAL_HOURS = 720 - -const sourceTableGridClass = - 'grid grid-cols-2 gap-x-4 gap-y-3 @min-[768px]/knowledge-content:grid-cols-[16px_minmax(0,1fr)_120px_120px_40px] @min-[768px]/knowledge-content:gap-x-3 @min-[768px]/knowledge-content:gap-y-0 @min-[960px]/knowledge-content:grid-cols-[16px_minmax(200px,1fr)_160px_120px_120px_120px_40px] @min-[1280px]/knowledge-content:grid-cols-[16px_minmax(0,1fr)_180px_140px_120px_160px_80px]' - -const statusDotStatus: Record = { - active: 'success', - syncing: 'normal', - initializing: 'normal', - disabled: 'disabled', - error: 'error', -} - -const emptySourceShortcuts = [ - { - brand: 'firecrawl', - iconClass: 'i-custom-public-common-firecrawl', - provider: 'Firecrawl', - sourceType: 'websiteCrawl', - }, - { - brand: 'jina', - iconClass: 'i-custom-public-llm-jina', - provider: 'Jina Reader', - sourceType: 'websiteCrawl', - }, - { - brand: 'notion', - iconClass: 'i-custom-public-common-notion text-text-primary', - provider: 'Notion', - sourceType: 'onlineDocuments', - }, - { - brand: 'google-drive', - iconClass: 'i-custom-public-common-google-drive', - provider: 'Google Drive', - sourceType: 'onlineDrive', - }, - { - brand: 'confluence', - iconClass: 'i-custom-public-new-rag-confluence', - provider: 'Confluence', - sourceType: 'onlineDocuments', - }, -] as const - -function metadataString(metadata: Source['metadata'], key: string) { - const value = metadata[key] - return typeof value === 'string' && value.trim() ? value : undefined -} - -function metadataRecord(metadata: Source['metadata'], key: string) { - const value = metadata[key] - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined -} - -function sourceProviderType(source: Source, providerKind?: string) { - if (source.type === 'web' || providerKind === 'website') return 'websiteCrawl' as const - if (providerKind === 'online-document') return 'onlineDocuments' as const - if (providerKind === 'online-drive') return 'onlineDrive' as const - return undefined -} - -function sourceProviderDetails(source: Source) { - const providerKind = metadataString(source.metadata, 'providerKind') - const providerType = sourceProviderType(source, providerKind) - const explicitName = metadataString(source.metadata, 'providerName') - if (explicitName) { - const presentation = sourceProviderPresentation(explicitName, providerType) - return { - iconClass: presentation?.fallbackIcon, - name: presentation?.label ?? explicitName, - } - } - - const providerId = metadataString(source.metadata, 'providerId') - if (!providerId) return {} - const presentation = sourceProviderPresentation(providerId, providerType) - if (presentation) return { iconClass: presentation.fallbackIcon, name: presentation.label } - if (normalizeSourceProviderName(providerId).includes('fakecrawler')) - return { name: 'FakeCrawler' } - return {} -} - -function sourceLastSyncAt(source: Source) { - const syncMetadata = metadataRecord(source.metadata, 'sync') - return ( - source.lastSyncedAt ?? - metadataString(source.metadata, 'lastSyncedAt') ?? - (syncMetadata ? metadataString(syncMetadata, 'lastRunAt') : undefined) - ) -} - -function sourceSyncPolicyTranslationKey(policy: SourceSyncPolicy) { - if (!policy.enabled || policy.mode === 'manual') return 'newKnowledge.syncPolicyManual' as const - if (policy.mode === 'provider') return 'newKnowledge.syncPolicyProvider' as const - if (policy.mode === 'interval') return 'newKnowledge.syncPolicyDaily' as const - return 'newKnowledge.syncPolicyCustom' as const -} - -function sourceSyncMode(source: Source): SourceSyncPolicy['mode'] { - return source.syncPolicy?.mode ?? 'manual' -} - -function sourceCustomIntervalHours(source: Source) { - return source.syncPolicy?.customIntervalSeconds - ? source.syncPolicy.customIntervalSeconds / 3600 - : MIN_CUSTOM_INTERVAL_HOURS -} - -function syncPolicyConfiguration(mode: SourceSyncPolicy['mode'], customIntervalHours: number) { - if (mode === 'manual') return { enabled: false, mode } as const - if (mode === 'custom') - return { - customIntervalSeconds: customIntervalHours * 3600, - enabled: true, - mode, - } as const - return { enabled: true, mode } as const -} - -function sourceSyncPolicyChanged( - source: Source, - mode: SourceSyncPolicy['mode'], - customIntervalHours: number, -) { - if (mode !== sourceSyncMode(source)) return true - return ( - mode === 'custom' && customIntervalHours * 3600 !== source.syncPolicy?.customIntervalSeconds - ) -} - -type SourceEditValues = { - customIntervalHours: number - name: string - syncMode: SourceSyncPolicy['mode'] -} - -function createIdempotencyKey() { - return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` -} - -function getOpenableSourceUri(uri: string) { - try { - const url = new URL(uri) - if (url.protocol === 's3:' && url.hostname) { - const prefix = decodeURIComponent(url.pathname.replace(/^\//, '')) - const consoleUrl = new URL(`https://s3.console.aws.amazon.com/s3/buckets/${url.hostname}`) - if (prefix) consoleUrl.searchParams.set('prefix', prefix) - return consoleUrl.toString() - } - return url.protocol === 'http:' || url.protocol === 'https:' ? uri : undefined - } catch { - return undefined - } -} function latestSourceWorkflow( sourceWorkflow?: Source['syncWorkflow'], @@ -303,729 +114,6 @@ function getCurrentSource(source: Source, sourceOverride?: Source) { } } -type SourceAction = 'edit' | 'remove' | 'sync' | 'toggle' - -function TruncatedSourceValue({ children, className }: { children: string; className?: string }) { - return ( - - {children}
} - /> - {children} - - ) -} - -function SourceActions({ - canEdit, - canRemove, - canSync, - canToggle, - onEdit, - onRemove, - onSync, - onToggle, - pendingAction, - source, - syncAction, -}: { - canEdit: boolean - canRemove: boolean - canSync: boolean - canToggle: boolean - onEdit: (values: SourceEditValues) => Promise - onRemove: () => Promise - onSync: () => Promise - onToggle: () => Promise - pendingAction?: SourceAction - source: Source - syncAction: 'retry' | 'sync' -}) { - const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const [menuOpen, setMenuOpen] = useState(false) - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [nextName, setNextName] = useState(source.name) - const [nextSyncMode, setNextSyncMode] = useState(() => - sourceSyncMode(source), - ) - const [nextCustomIntervalHours, setNextCustomIntervalHours] = useState(() => - sourceCustomIntervalHours(source), - ) - const [removeDialogOpen, setRemoveDialogOpen] = useState(false) - const sourceUri = getOpenableSourceUri(source.uri) - const customIntervalValid = - typeof nextCustomIntervalHours === 'number' && - Number.isInteger(nextCustomIntervalHours) && - nextCustomIntervalHours >= MIN_CUSTOM_INTERVAL_HOURS && - nextCustomIntervalHours <= MAX_CUSTOM_INTERVAL_HOURS - const nameChanged = nextName.trim() !== source.name - const syncPolicyChanged = - customIntervalValid && - sourceSyncPolicyChanged(source, nextSyncMode, nextCustomIntervalHours as number) - const editChanged = nameChanged || syncPolicyChanged - - const openEditDialog = () => { - setNextName(source.name) - setNextSyncMode(sourceSyncMode(source)) - setNextCustomIntervalHours(sourceCustomIntervalHours(source)) - setMenuOpen(false) - setEditDialogOpen(true) - } - - const submitEdit = async () => { - const name = nextName.trim() - if (!name || !customIntervalValid || !editChanged || pendingAction) return - if ( - await onEdit({ - customIntervalHours: nextCustomIntervalHours as number, - name, - syncMode: nextSyncMode, - }) - ) - setEditDialogOpen(false) - } - - if (!canEdit && !canRemove && !canSync && !canToggle && !sourceUri) return null - - return ( - <> - - $['newKnowledge.sourceActions'], { name: source.name })} - disabled={Boolean(pendingAction)} - className="flex size-7 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled" - > - - - - {canSync && ( - void onSync()} - className="mb-px h-7 gap-2 px-2 system-sm-medium" - > - - {syncAction === 'retry' - ? tCommon(($) => $['operation.retry']) - : t(($) => $['newKnowledge.syncNow'])} - - )} - {sourceUri && ( - $['newKnowledge.openSource'])} - href={sourceUri} - target="_blank" - rel="noopener noreferrer" - /> - } - className="mb-px h-7 gap-2 px-2 system-sm-medium" - > - - {t(($) => $['newKnowledge.openSource'])} - - )} - {canEdit && ( - - - {tCommon(($) => $['operation.edit'])} - - )} - {canToggle && ( - void onToggle()} - className="h-7 gap-2 px-2 system-sm-medium" - > - - {source.status === 'disabled' - ? t(($) => $.enable) - : t(($) => $['newKnowledge.disableSource'])} - - )} - {canRemove && ( - <> - {(canEdit || canSync || canToggle || sourceUri) && ( - - )} - { - setMenuOpen(false) - setRemoveDialogOpen(true) - }} - variant="destructive" - className="h-7 gap-2 px-2 system-sm-medium" - > - - {t(($) => $['newKnowledge.removeSource'])} - - - )} - - - - -
{ - event.preventDefault() - void submitEdit() - }} - > - - {tCommon(($) => $['operation.edit'])} {source.name} - - - setNextName(event.target.value)} - /> -
- { - setNextSyncMode(value.mode) - if (value.customIntervalSeconds) - setNextCustomIntervalHours(value.customIntervalSeconds / 3600) - }} - /> -
-
- - -
-
-
-
- - -
- - {tCommon(($) => $['operation.deleteConfirmTitle'])} - - - {tCommon(($) => $['operation.confirmAction'])} - -
- - - {tCommon(($) => $['operation.cancel'])} - - - void onRemove().then((removed) => { - if (removed) setRemoveDialogOpen(false) - }) - } - > - {t(($) => $['newKnowledge.removeSource'])} - - -
-
- - ) -} - -function SourceRow({ - canEdit, - canSync, - checked, - ensureModelSetupReady, - knowledgeSpaceId, - onCheckedChange, - onRemoved, - onSourceChange, - source, -}: { - canEdit: boolean - canSync: boolean - checked: boolean - ensureModelSetupReady: () => Promise - knowledgeSpaceId: string - onCheckedChange: (checked: boolean) => void - onRemoved: () => void - onSourceChange: (source: Source) => void - source: Source -}) { - const { t } = useTranslation('dataset') - const { t: tCommon } = useTranslation('common') - const { formatTimeFromNow } = useFormatTimeFromNow() - const queryClient = useQueryClient() - const [pendingAction, setPendingAction] = useState() - const syncWorkflow = source.syncWorkflow - const displayStatus = sourceDisplayStatus(source) - const initializing = displayStatus === 'initializing' - const initialWorkflowId = initialSourceWorkflowId(source) - const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing' - - const provider = sourceProviderDetails(source) - const providerName = provider.name - const providerKind = metadataString(source.metadata, 'providerKind') - const sourceSyncPolicy = source.syncPolicy - const syncPolicy = sourceSyncPolicy - ? t(($) => $[sourceSyncPolicyTranslationKey(sourceSyncPolicy)]) - : metadataString(source.metadata, 'syncPolicy') - const lastSyncAt = sourceLastSyncAt(source) - const lastSyncTimestamp = lastSyncAt ? Date.parse(lastSyncAt) : Number.NaN - const lastSync = Number.isNaN(lastSyncTimestamp) - ? undefined - : formatTimeFromNow(lastSyncTimestamp) - const syncFailureMessageKey = knowledgeFsTaskFailureMessageKey( - undefined, - syncWorkflow?.lastErrorCode, - ) - const typeLabel = - source.type === 'connector' && - (providerKind === 'online-document' || - providerName === 'Notion' || - providerName === 'Google Docs' || - providerName === 'Confluence') - ? t(($) => $['newKnowledge.onlineDocuments']) - : source.type === 'connector' && - (providerKind === 'online-drive' || - providerName === 'Google Drive' || - providerName === 'OneDrive' || - providerName === 'Amazon S3') - ? t(($) => $['newKnowledge.onlineDrive']) - : t(($) => $[`newKnowledge.sourceType.${source.type}`]) - const sourceIcon = - provider.iconClass ?? (source.type === 'web' ? 'i-ri-global-line' : 'i-ri-links-line') - - const runAction = async ( - action: SourceAction, - mutation: () => Promise, - onAccepted?: (result: Result) => void, - beforeAction?: () => Promise, - ) => { - if (pendingAction) return false - setPendingAction(action) - try { - if (beforeAction && !(await beforeAction())) return false - let result: Result - try { - result = await mutation() - } catch { - toast.error(t(($) => $['newKnowledge.sourcesErrorDescription'])) - try { - await queryClient.invalidateQueries({ - queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(), - }) - } catch { - return false - } - return false - } - onAccepted?.(result) - - try { - await queryClient.invalidateQueries( - { - queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(), - }, - { - throwOnError: true, - }, - ) - } catch { - // The accepted mutation is already reflected by the list-owner state. - } - return true - } finally { - setPendingAction(undefined) - } - } - - const applyAcceptedWorkflow = (workflow: Parameters[0]) => { - const run = sourceWorkflowFromApi(workflow) - onSourceChange({ - ...source, - syncWorkflow: run, - status: sourceStatusWithSyncWorkflow(source.status, run), - }) - } - - const syncSource = () => - runAction( - 'sync', - () => - consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({ - headers: { 'Idempotency-Key': createIdempotencyKey() }, - params: { control_space_id: knowledgeSpaceId, source_id: source.id }, - }), - applyAcceptedWorkflow, - ensureModelSetupReady, - ) - - const retrySource = () => { - const retryWorkflowId = - initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id - if (!retryWorkflowId) return syncSource() - - return runAction( - 'sync', - () => - consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({ - params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId }, - }), - applyAcceptedWorkflow, - ensureModelSetupReady, - ) - } - - const toggleSource = () => - runAction( - 'toggle', - async () => - sourceFromApi( - await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({ - body: { - ...(source.version === undefined ? {} : { expectedVersion: source.version }), - status: source.status === 'disabled' ? 'active' : 'disabled', - }, - params: { control_space_id: knowledgeSpaceId, source_id: source.id }, - }), - ), - (updatedSource) => { - const syncWorkflow = - updatedSource.syncWorkflow ?? - (sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined) - onSourceChange({ - ...updatedSource, - lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt, - status: sourceStatusWithSyncWorkflow(updatedSource.status, syncWorkflow), - syncWorkflow, - syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy, - }) - }, - ) - - const editSource = ({ customIntervalHours, name, syncMode }: SourceEditValues) => - runAction( - 'edit', - async () => { - let updatedSource = source - if (name !== source.name) - updatedSource = sourceFromApi( - await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({ - body: { - ...(source.version === undefined ? {} : { expectedVersion: source.version }), - name, - }, - params: { control_space_id: knowledgeSpaceId, source_id: source.id }, - }), - ) - if (sourceSyncPolicyChanged(source, syncMode, customIntervalHours)) { - if (updatedSource.version === undefined) throw new Error('Source version is required') - const syncPolicy = sourceSyncPolicyFromApi( - await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.syncPolicy.put( - { - body: { - ...syncPolicyConfiguration(syncMode, customIntervalHours), - expectedRevision: source.syncPolicy?.revision ?? 0, - expectedSourceVersion: updatedSource.version, - }, - params: { control_space_id: knowledgeSpaceId, source_id: source.id }, - }, - ), - ) - updatedSource = { ...updatedSource, syncPolicy } - } - return updatedSource - }, - (updatedSource) => { - onSourceChange({ - ...updatedSource, - lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt, - syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy, - syncWorkflow: updatedSource.syncWorkflow ?? source.syncWorkflow, - }) - }, - ) - - const removeSource = () => - runAction( - 'remove', - async () => { - if (source.version === undefined) throw new Error('Source version is required') - return consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.delete({ - body: { expectedRevision: source.version }, - headers: { 'Idempotency-Key': createIdempotencyKey() }, - params: { control_space_id: knowledgeSpaceId, source_id: source.id }, - query: { documents: 'keep' }, - }) - }, - onRemoved, - ) - - return ( - td:not(:first-child)]:opacity-60', - )} - > - - - - -
- - - {source.name} - -
- - -

- {t(($) => $['metadata.createMetadata.type'])} -

-
- - {providerName ?? typeLabel} - - {providerName && ( - - {typeLabel} - - )} -
- - -

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

- - {initializing ? ( - - ) : ( - - )} - {source.name}: - {t(($) => $[`newKnowledge.sourceStatus.${displayStatus}`])} - {displayStatus === 'error' && syncFailureMessageKey && ( - $[syncFailureMessageKey])} - iconVariant="information" - popupClassName="max-w-80" - > - {t(($) => $[syncFailureMessageKey])} - - )} - - - -

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

- - {syncPolicy ?? '—'} - - - -

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

- {displayStatus === 'syncing' && syncWorkflow ? ( - - - {t(($) => $['newKnowledge.sourceSyncProgress'], { - completed: - syncWorkflow.progressCompleted + - syncWorkflow.progressFailed + - syncWorkflow.progressSkipped, - total: syncWorkflow.progressTotal ?? '—', - })} - - ) : ( - {lastSync ?? '—'} - )} - - -
- {canSync && displayStatus === 'error' && ( - - )} - -
- - - ) -} - -function SourcesEmpty({ - canAddSource, - knowledgeSpaceId, -}: { - canAddSource: boolean - knowledgeSpaceId: string -}) { - const { t } = useTranslation('dataset') - - return ( -
-
- {emptySourceShortcuts.map((shortcut) => { - const icon = ( - - ) - if (!canAddSource) return icon - return ( - - {icon} - {shortcut.provider} - - ) - })} - {canAddSource ? ( - $['newKnowledge.moreProviders'])} - href={newKnowledgeAddSourcePath(knowledgeSpaceId)} - className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" - > - - - ) : ( - - )} -
-
-

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

-

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

-
- {canAddSource && ( - - - {t(($) => $['newKnowledge.addSource'])} - - )} -
- ) -} - export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common')