refactor(web): split new RAG page responsibilities

This commit is contained in:
Stephen Zhou 2026-08-21 16:30:59 +08:00
parent 62ca5184a3
commit 3c13cc2565
No known key found for this signature in database
19 changed files with 3673 additions and 3517 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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<ActivityRange, 'custom'>): 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'),
}
}

View File

@ -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<typeof useTranslation<'dataset'>>['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<typeof useTranslation<'dataset'>>['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 ? (
<span className="system-2xs-semibold flex size-5 shrink-0 items-center justify-center rounded-full bg-util-colors-gray-gray-300 text-text-secondary">
S
</span>
) : (
<Avatar avatar={actor.avatar} name={actor.name} size={size} />
)}
{showName && <span className="truncate text-text-secondary">{actor.name}</span>}
</>
)
}
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 (
<section className="flex min-w-0 flex-col gap-2 pt-6">
<h2 className="system-md-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.recentActivity'])}
</h2>
<Panel className="flex h-50 border border-components-panel-border p-4 shadow-none">
<div
role="alert"
className="flex min-h-0 flex-1 flex-col items-center justify-center text-center"
>
<span aria-hidden className="i-ri-error-warning-line size-6 text-text-tertiary" />
<p className="mt-3 body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.tasksErrorDescription'])}
</p>
<Button
className="mt-4"
loading={retrying}
size="small"
variant="secondary"
onClick={onRetry}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
</Panel>
</section>
)
if (empty)
return (
<section className={cn('flex min-w-0 flex-col gap-2 pt-6', indexing ? 'h-67.75' : 'h-63')}>
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.recentActivity'])}
</h2>
<Panel
className={cn(
'flex border border-components-panel-border p-4 shadow-none',
indexing ? 'h-53.75' : 'h-50',
)}
>
<EmptyInline
icon="i-ri-time-line"
title={
indexing
? t(($) => $['newKnowledge.overview.syncInProgress'])
: t(($) => $['newKnowledge.overview.noActivity'])
}
description={
indexing
? t(($) => $['newKnowledge.overview.syncInProgressDescription'])
: t(($) => $['newKnowledge.overview.noActivityDescription'])
}
/>
</Panel>
</section>
)
return (
<section className="flex min-w-0 flex-col gap-2 pt-6">
<header className="flex h-6 items-center justify-between">
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.recentActivity'])}
</h2>
<Button size="small" variant="secondary" onClick={onOpenAll}>
{t(($) => $['newKnowledge.overview.allActivity'])}
</Button>
</header>
<Panel className="flex h-63.5 flex-col overflow-hidden border border-divider-subtle px-4 pt-4 pb-3 shadow-none">
{loading || activities.length ? (
<div
role="table"
aria-label={t(($) => $['newKnowledge.overview.recentActivity'])}
className="min-w-151"
>
<div
role="row"
className="grid grid-cols-[100px_minmax(280px,1fr)_200px] items-center gap-3 pb-2 system-2xs-medium-uppercase text-text-tertiary"
>
<span role="columnheader">
<span className="sr-only">{t(($) => $['newKnowledge.overview.when'])}</span>
</span>
<span role="columnheader">{t(($) => $['newKnowledge.overview.activity'])}</span>
<span role="columnheader">{t(($) => $['newKnowledge.overview.operator'])}</span>
</div>
<div className="h-px bg-divider-subtle" />
{loading
? [
['activity-1', 55],
['activity-2', 55],
['activity-3', 55],
['activity-4', 55],
['activity-5', 41],
].map(([key, width]) => (
<div key={key} role="row" className="flex h-9 items-center py-2">
<Skeleton className="h-3.5" style={{ width: `${width}%` }} />
</div>
))
: activities.slice(0, 5).map((activity) => (
<div
key={activity.id}
role="row"
className="-mx-3 grid h-9 grid-cols-[100px_minmax(280px,1fr)_200px] items-center gap-3 rounded-lg px-3 system-xs-regular transition-colors hover:bg-state-base-hover motion-reduce:transition-none"
>
<span role="cell" className="whitespace-nowrap text-text-tertiary">
{formatWhen(activity.occurred_at)}
</span>
<span role="cell" className="min-w-0 truncate text-text-secondary">
{activityLabel(activity, t)}
</span>
<span role="cell" className="flex min-w-0 items-center gap-2">
<ActivityActor activity={activity} members={members} />
</span>
</div>
))}
</div>
) : (
<EmptyInline
icon="i-ri-history-line"
title={t(($) => $['newKnowledge.overview.noActivity'])}
description={t(($) => $['newKnowledge.overview.noActivityDescription'])}
/>
)}
</Panel>
</section>
)
}
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<DatePickerProps['renderTrigger']> =>
(props, state, { handleClickTrigger, value }) => (
<div
{...props}
role="button"
tabIndex={0}
aria-label={`${t(($) => $['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()) : '—'}
</div>
)
return (
<div
role="group"
aria-label={t(($) => $['newKnowledge.overview.timeRange'])}
className="flex h-6 w-35 shrink-0 items-center rounded-lg bg-background-section px-1"
>
<DatePicker
noConfirm
needTimePicker={false}
value={dates.start}
onChange={(start) => 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')}
/>
<span aria-hidden className="text-text-quaternary">
</span>
<DatePicker
noConfirm
needTimePicker={false}
value={dates.end}
onChange={(end) => 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')
}
/>
</div>
)
}
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<HTMLButtonElement>(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<Record<string, KnowledgeFsOverviewActivityResponse[]>>(
(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<ActivityRange, string> = {
'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<ActivityRange, string> = {
...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 (
<Drawer open={open} swipeDirection="right" onOpenChange={onOpenChange}>
<DrawerPortal>
<DrawerBackdrop className="bg-transparent" />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]">
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
<header className="flex h-16 shrink-0 items-center px-5">
<div className="flex w-full items-center justify-between gap-3">
<DrawerTitle className="system-lg-semibold text-text-primary">
{t(($) => $['newKnowledge.overview.allActivity'])}
</DrawerTitle>
<DrawerCloseButton />
</div>
</header>
<div className="flex h-9 shrink-0 items-start gap-1 border-b border-divider-subtle px-5">
<Select
value={range}
onValueChange={(value) => onRangeChange(value as ActivityRange)}
>
<SelectTrigger
ref={rangeTriggerRef}
aria-label={t(($) => $['newKnowledge.overview.timeRange'])}
className="h-6 w-20 min-w-0 shrink-0 border-0 bg-background-section shadow-none"
>
<span className="truncate">{rangeTriggerLabel[range]}</span>
</SelectTrigger>
<SelectContent>
{ACTIVITY_RANGES.map((value) => (
<SelectItem key={value} value={value}>
<SelectItemText>{rangeLabel[value]}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
{range === 'all' ? (
<div className="flex h-6 w-35 shrink-0 items-center rounded-lg bg-background-section px-2 system-xs-regular text-text-tertiary">
{rangeLabel.all}
</div>
) : (
<ActivityDateRangePicker dates={dates} onChange={onDatesChange} />
)}
<Select
value={operator}
onValueChange={(value) => onOperatorChange(value as ActivityOperator)}
>
<SelectTrigger
aria-label={t(($) => $['newKnowledge.overview.operator'])}
className="h-6 w-50 min-w-0 shrink-0 border-0 bg-background-section shadow-none"
>
<span className="truncate">{operatorLabel}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<SelectItemText>
{tActivityLog(($) => $['filter.annotation.all'])}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="system">
<SelectItemText>{t(($) => $['newKnowledge.overview.system'])}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
{members.map((member) => (
<SelectItem key={member.id} value={`member:${member.id}`}>
<SelectItemText>{member.name || member.email}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{loading ? (
<div className="px-5 pt-3">
<Skeleton className="ml-5 h-3 w-14" />
<div className="mt-2 space-y-0">
{[248, 300, 210, 280, 236, 264].map((width) => (
<div key={width} className="flex h-13.5 items-center px-5">
<Skeleton className="size-6 shrink-0 rounded-full" />
<div className="ml-3 min-w-0 flex-1">
<Skeleton className="h-3" style={{ width }} />
<Skeleton className="mt-1.5 h-2.5 w-37" />
</div>
<Skeleton className="h-2.5 w-10" />
</div>
))}
</div>
</div>
) : activities.length ? (
<>
{Object.entries(groups).map(([key, group]) => (
<section key={key}>
<h3 className="sticky top-0 z-10 flex h-11 items-end bg-components-panel-bg px-5 pb-2 system-xs-regular text-text-tertiary">
{groupLabel(key)}
</h3>
<ul>
{group.map((activity) => (
<li
key={activity.id}
className="flex min-h-13.5 items-start gap-3 px-5 py-2.5"
>
<span className="flex size-6 shrink-0 items-center">
<ActivityActor
activity={activity}
members={members}
showName={false}
size="xs"
/>
</span>
<div className="min-w-0 flex-1 leading-4">
<p className="line-clamp-2 system-sm-regular text-text-secondary">
{activityLabel(activity, t)}
</p>
<p className="system-xs-regular text-text-tertiary">
{
activityActor(
activity,
members,
t(($) => $['newKnowledge.overview.system']),
).name
}
</p>
</div>
<time
className="shrink-0 system-xs-regular text-text-tertiary"
dateTime={activity.occurred_at}
>
{activityTime(activity.occurred_at)}
</time>
</li>
))}
</ul>
</section>
))}
<div className="flex h-11 items-start justify-center pt-4">
{hasNextPage && (
<Button
disabled={isFetchingNextPage}
loading={isFetchingNextPage}
size="small"
variant="secondary"
onClick={onFetchNextPage}
>
{t(($) => $['newKnowledge.overview.loadMore'])}
<span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4" />
</Button>
)}
</div>
</>
) : (
<div className="flex h-72.25 flex-col items-center justify-end pb-0 text-center">
<span className="flex size-11 items-center justify-center rounded-xl bg-background-section text-text-tertiary">
<span aria-hidden className="i-ri-search-line size-5" />
</span>
<p className="mt-4 system-md-medium text-text-primary">
{t(($) => $['newKnowledge.overview.noMatchingActivity'])}
</p>
<p className="mt-1 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.overview.noMatchingActivityDescription'])}
</p>
<button
type="button"
className="mt-3 system-xs-medium text-text-accent outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={clearFilters}
>
{tCommon(($) => $['operation.clear'])}
</button>
</div>
)}
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -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<typeof useTranslation<'dataset'>>['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 (
<section className="flex h-66.75 min-w-0 flex-col gap-2 pt-6">
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.needsAttention'])}
</h2>
<Panel className="flex h-52.75 border border-components-panel-border p-4 shadow-none">
<OverviewErrorInline />
</Panel>
</section>
)
if (empty)
return (
<section className="flex h-66.75 min-w-0 flex-col gap-2 pt-6">
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.needsAttention'])}
</h2>
<Panel className="flex h-52.75 border border-components-panel-border p-4 shadow-none">
<EmptyInline
positive
icon="i-ri-thumb-up-line"
title={t(($) => $['newKnowledge.overview.noIssues'])}
description={t(($) => $['newKnowledge.overview.noIssuesDescription'])}
/>
</Panel>
</section>
)
return (
<section className="flex h-93.25 min-w-0 flex-col gap-2 pt-6">
<div className="flex h-6 items-center">
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.needsAttention'])}
</h2>
</div>
<Panel className="flex h-79.25 flex-col overflow-hidden border border-divider-subtle px-4 pt-3 pb-1 shadow-none">
{loading ? (
<div>
{[
['attention-1', 100],
['attention-2', 100],
['attention-3', 100],
['attention-4', 100],
].map(([key, width]) => (
<div key={key} className="flex h-16 items-center">
<Skeleton className="h-3.5" style={{ width: `${width}%` }} />
</div>
))}
</div>
) : actionableAttention.length ? (
<>
<ul className="min-h-0 flex-1 overflow-hidden">
{visibleIssues.map((issue) => {
const presentation = attentionPresentation(issue, t)
const action = issueAction(issue)
return (
<li key={issue.issue_key} className="flex h-16 min-w-0 items-center gap-4">
<span
className={cn(
'shrink-0 rounded-md px-2 py-0.5 system-xs-medium',
issue.severity === 'critical'
? 'bg-state-destructive-hover text-text-destructive'
: issue.severity === 'warning'
? 'bg-state-warning-hover text-text-warning'
: 'bg-background-section text-text-tertiary',
)}
>
{issue.severity === 'critical'
? t(($) => $['newKnowledge.overview.blocker'])
: issue.severity === 'warning'
? t(($) => $['newKnowledge.overview.serious'])
: t(($) => $['newKnowledge.overview.review'])}
</span>
<div className="min-w-0 flex-1">
<p className="truncate system-sm-medium text-text-primary">
{presentation.title}
</p>
{presentation.description && (
<p className="mt-0.5 line-clamp-2 body-xs-regular text-text-tertiary">
{presentation.description}
</p>
)}
</div>
<Button
render={<Link href={action.href} />}
nativeButton={false}
size="small"
tone={issue.severity === 'critical' ? 'destructive' : 'default'}
variant={issue.severity === 'critical' ? 'primary' : 'secondary'}
className={cn(
issue.severity === 'critical' &&
'border-[#ff4d14] bg-[#ff4d14] hover:border-[#e64210] hover:bg-[#e64210]',
)}
>
{action.label}
</Button>
</li>
)
})}
</ul>
<div className="flex h-11 shrink-0 items-end justify-end border-t border-divider-subtle pb-1">
<div className="flex h-8 items-center rounded-lg border border-divider-subtle p-0.5">
<button
type="button"
aria-label={tCommon(($) => $['pagination.previous'])}
className="flex size-7 items-center justify-center rounded-md text-text-quaternary"
disabled={activeIssuePage === 0}
onClick={() => setIssuePage(Math.max(0, activeIssuePage - 1))}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<span className="px-2 system-xs-medium text-text-secondary">
{activeIssuePage + 1} / {issuePageCount}
</span>
<button
type="button"
aria-label={tCommon(($) => $['pagination.next'])}
className="flex size-7 items-center justify-center rounded-md text-text-quaternary"
disabled={activeIssuePage >= issuePageCount - 1}
onClick={() => setIssuePage(Math.min(issuePageCount - 1, activeIssuePage + 1))}
>
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
</button>
</div>
</div>
</>
) : (
<EmptyInline
icon="i-ri-checkbox-circle-line"
title={t(($) => $['newKnowledge.overview.noIssues'])}
description={t(($) => $['newKnowledge.overview.noIssuesDescription'])}
/>
)}
</Panel>
</section>
)
}

View File

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

View File

@ -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 (
<section className="flex h-68.75 min-w-0 flex-col gap-2 pt-6">
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.inventory'])}
</h2>
<Panel className="flex h-54.75 border border-components-panel-border p-4 shadow-none">
<OverviewErrorInline />
</Panel>
</section>
)
if (empty)
return (
<section className={cn('flex min-w-0 flex-col gap-2 pt-6', indexing ? 'h-65' : 'h-68.75')}>
<h2 className="text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.inventory'])}
</h2>
<Panel
className={cn(
'flex border border-components-panel-border p-4 shadow-none',
indexing ? 'h-51' : 'h-54.75',
)}
>
<EmptyInline
icon="i-ri-file-text-line"
title={
indexing
? t(($) => $['newKnowledge.overview.indexingInProgress'])
: t(($) => $['newKnowledge.documentsEmptyTitle'])
}
description={
indexing
? t(($) => $['newKnowledge.overview.indexingInProgressDescription'])
: t(($) => $['newKnowledge.documentsEmptyDescription'])
}
/>
</Panel>
</section>
)
return (
<section className="flex min-w-0 flex-col gap-2 pt-6">
<h2 className="flex h-6 items-center text-[15px] leading-6 font-medium text-text-secondary">
{t(($) => $['newKnowledge.overview.inventory'])}
</h2>
<Panel className="h-44.25 overflow-hidden border border-divider-subtle p-4 shadow-none">
{loading ? (
<>
<Skeleton className="h-6 w-full" />
<div className="mt-2.5 h-3.75">
<Skeleton className="h-3.5 w-80" />
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[0, 1, 2].map((index) => (
<div key={index} className="h-20 rounded-lg bg-background-section p-3">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="mt-2 h-5 w-24" />
</div>
))}
</div>
</>
) : (
<>
<div
className="flex h-6 gap-0.5 overflow-hidden"
aria-label={t(($) => $['newKnowledge.overview.sources'])}
>
{visibleCategories.map((category) => (
<span
key={category.label}
className={cn('border-l-4', category.segment)}
style={{
width: categoryTotal ? `${(category.value / categoryTotal) * 100}%` : '0%',
}}
/>
))}
</div>
<ul className="mt-2.5 flex min-h-3.75 flex-wrap gap-x-4 gap-y-1">
{categories.map((category) => (
<li
key={category.label}
className="flex items-center gap-1.5 text-[12px] leading-3.75 font-normal text-text-tertiary"
>
<span aria-hidden className={cn('size-2 rounded-full', category.color)} />
{category.label}
<span className="font-semibold text-text-secondary">{category.value}</span>
</li>
))}
</ul>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{
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) => (
<div
key={item.label}
className="flex h-20 flex-col gap-1 rounded-lg bg-background-section p-3"
>
<p className="system-2xs-medium text-text-tertiary">{item.label}</p>
<p className="text-[18px] leading-5 font-semibold text-text-primary">
{item.value}
</p>
<p className="system-2xs-regular text-text-tertiary">{item.detail}</p>
</div>
))}
</div>
</>
)}
</Panel>
</section>
)
}

View File

@ -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 (
<Panel
className={cn(
'flex h-23 flex-col justify-between border-0 bg-background-section p-4 shadow-none',
)}
>
<div className="flex items-center gap-1 text-text-tertiary">
<h2 className="system-xs-medium">{title}</h2>
{help && (
<Infotip
aria-label={help}
className="size-4"
popupClassName="max-w-[260px] border-0 bg-text-primary text-text-primary-on-surface"
>
{help}
</Infotip>
)}
</div>
{loading ? (
<Skeleton className="h-5.5 w-24" />
) : (
<div className="flex min-w-0 items-end gap-2">
<span
className={cn(
'truncate text-[28px] leading-8.5 font-semibold',
empty ? 'text-text-quaternary' : 'text-text-primary',
)}
>
{empty ? '—' : value}
</span>
{!empty && change && (
<span
className={cn(
'mb-0.5 flex shrink-0 items-center gap-0.5 system-xs-medium',
change.startsWith('+')
? 'text-text-success'
: change.startsWith('-')
? 'text-text-warning'
: 'text-text-quaternary',
)}
>
{change !== '—' && (
<span
aria-hidden
className={cn(
'size-3',
change.startsWith('+') ? 'i-ri-arrow-up-s-fill' : 'i-ri-arrow-down-s-fill',
)}
/>
)}
{change}
</span>
)}
</div>
)}
</Panel>
)
}
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 (
<section className="flex h-66.75 min-w-0 flex-col gap-2 pt-6">
<div className="flex h-6 items-center">
<h2 className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['newKnowledge.overview.queryOutcomes'])}
</h2>
</div>
<Panel className="flex h-52.75 border border-components-panel-border p-4 shadow-none">
<OverviewErrorInline />
</Panel>
</section>
)
if (empty)
return (
<section className="flex h-66.75 min-w-0 flex-col gap-2 pt-6">
<div className="flex h-6 items-center">
<h2 className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['newKnowledge.overview.queryOutcomes'])}
<Infotip
aria-label={t(($) => $['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'])}
</Infotip>
</h2>
</div>
<Panel className="flex h-52.75 border border-components-panel-border p-4 shadow-none">
<EmptyInline
icon="i-ri-time-line"
title={t(($) => $['newKnowledge.overview.noQueryData'])}
description={t(($) => $['newKnowledge.overview.noQueryDataDescription'])}
/>
</Panel>
</section>
)
return (
<section className="flex h-93.25 min-w-0 flex-col gap-2 pt-6">
<div className="flex h-6 items-center">
<h2 className="system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['newKnowledge.overview.queryOutcomes'])}
<Infotip
aria-label={t(($) => $['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'])}
</Infotip>
</h2>
</div>
<Panel className="flex h-79.25 flex-col overflow-hidden border border-divider-subtle p-4 shadow-none">
{loading ? (
<div className="space-y-6 pt-2">
{[
['outcome-1', 100],
['outcome-2', 100],
['outcome-3', 100],
['outcome-4', 100],
['outcome-5', 55],
].map(([key, width]) => (
<Skeleton key={key} className="h-3" style={{ width: `${width}%` }} />
))}
</div>
) : buckets.length ? (
<>
<p className="sr-only">
{t(($) => $['newKnowledge.overview.queryOutcomes'])}: {buckets.length}
</p>
<ReactECharts
option={chartOptions}
opts={{ renderer: 'svg' }}
style={{ height: 285, width: '100%' }}
/>
</>
) : (
<EmptyInline
icon="i-ri-line-chart-line"
title={t(($) => $['newKnowledge.overview.noActivity'])}
description={t(($) => $['newKnowledge.overview.noActivityDescription'])}
/>
)}
</Panel>
</section>
)
}

View File

@ -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 (
<section className="flex h-29.75 flex-col rounded-xl bg-background-section p-4">
<h2 className="text-[18px] leading-[1.2] font-semibold text-text-primary">
{indexingSourceName
? t(($) => $['newKnowledge.overview.indexingSource'], {
source: indexingSourceName,
})
: t(($) => $['newKnowledge.overview.indexing'])}
</h2>
<p className="mt-1 text-[13px] leading-4 font-normal text-text-primary">
{t(($) => $['newKnowledge.overview.indexingConnectedDescription'])}
</p>
<div className="mt-3">
<div
role="progressbar"
aria-label={t(($) => $['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"
>
<div
className="h-full rounded-full bg-components-progress-bar-progress-solid"
style={{ width: `${indexingTask.progress_percent}%` }}
/>
</div>
<p className="mt-2.5 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.overview.indexedDocuments'], {
indexed: indexingTask.progress_completed,
total: indexingTask.progress_total,
})}
</p>
</div>
</section>
)
}
return (
<section
className={cn(
'h-auto min-w-0 rounded-xl bg-background-section p-4',
actionCount > 0 && 'md:h-54.75',
)}
>
<div aria-hidden className="flex h-4 items-center gap-1.5 text-text-tertiary">
<span className="text-[13px] leading-4">🔥</span>
<span className="i-custom-public-llm-jina size-4" />
<span className="i-custom-public-common-notion size-4" />
<span className="i-custom-public-common-google-drive size-4" />
<span className="i-custom-public-new-rag-confluence size-4" />
<span className="i-ri-more-fill size-4" />
</div>
<div className="mt-3 h-10.5">
<h2 className="title-2xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.overview.noSources'])}
</h2>
<p className="mt-1 body-xs-regular text-text-tertiary">{description}</p>
</div>
{actionCount > 0 && (
<div
className={cn('mt-3 grid gap-3', actionCount === 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-1')}
>
{canConnectSource && (
<Link
aria-label={t(($) => $['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')
}}
>
<span
aria-hidden
className={cn(
'size-6 text-text-accent',
pendingAction === 'source'
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-node-tree',
)}
/>
<span className="mt-2 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.overview.connectSource'])}
</span>
<span className="mt-0.5 system-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.connectSourceDescription'])}
</span>
</Link>
)}
{canUpload && (
<Link
aria-label={t(($) => $['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')
}}
>
<span
aria-hidden
className={cn(
'size-6 text-text-accent',
pendingAction === 'upload'
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-file-text-line',
)}
/>
<span className="mt-2 system-md-semibold text-text-primary">
{t(($) => $['newKnowledge.overview.uploadFiles'])}
</span>
<span className="mt-0.5 system-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.uploadFilesDescription'])}
</span>
</Link>
)}
</div>
)}
</section>
)
}
export function FirstSourceTaskFailureBanner({
failedTask,
knowledgeSpaceId,
onRetryTask,
}: {
failedTask: KnowledgeFsBackgroundTaskResponse
knowledgeSpaceId: string
onRetryTask: () => Promise<unknown>
}) {
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 (
<div
className="mt-4 flex items-center gap-2.5 overflow-hidden rounded-lg bg-state-destructive-hover px-3.5 py-2.5"
role="alert"
>
<span aria-hidden className="i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" />
<p className="min-w-0 flex-1 system-sm-regular text-text-secondary">
{retryTaskMutation.isError
? t(($) => $['newKnowledge.detailErrorDescription'])
: description}
</p>
{failedTask.can_retry && (
<Button
size="small"
variant="secondary"
loading={retryTaskMutation.isPending}
onClick={() => void retryFailedTask()}
>
{t(($) => $['newKnowledge.retryTask'])}
</Button>
)}
</div>
)
}

View File

@ -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 (
<span
aria-hidden
className={cn(
'block animate-pulse rounded bg-util-colors-gray-gray-200 [animation-duration:1.2s] motion-reduce:animate-none',
className,
)}
style={style}
/>
)
}
export function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<section
className={cn(
'min-w-0 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
className,
)}
>
{children}
</section>
)
}
export function EmptyInline({
description,
icon,
positive = false,
title,
}: {
description: string
icon: string
positive?: boolean
title: string
}) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-10 text-center">
<span
aria-hidden
className={cn(
'flex size-11 shrink-0 items-center justify-center rounded-xl',
positive
? 'bg-state-success-hover text-text-success'
: 'bg-background-section text-text-tertiary',
)}
>
<span className={cn('size-5', icon)} />
</span>
<div className="flex flex-col items-center gap-1">
<p className="system-md-medium text-text-primary">{title}</p>
<p className="max-w-100 body-xs-regular text-text-tertiary">{description}</p>
</div>
</div>
)
}
export function OverviewErrorInline() {
const { t } = useTranslation('dataset')
return (
<EmptyInline
icon="i-ri-error-warning-line"
title={t(($) => $['newKnowledge.detailErrorTitle'])}
description={t(($) => $['newKnowledge.detailErrorDescription'])}
/>
)
}

View File

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

View File

@ -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<string, unknown>, 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<string, unknown>
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<string, unknown>[] = []
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<string, unknown>)
continue
}
const nested = event.payload[stage]
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
payloads.push(nested as Record<string, unknown>)
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<string, unknown> {
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<ResearchStage, Set<string>> = {
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 (
<section
className={cn(
'max-w-full overflow-hidden rounded-[10px] bg-components-panel-bg',
expanded ? 'w-full' : 'w-fit',
)}
>
<div className="flex min-h-10 max-w-full items-center">
<button
type="button"
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-1.5 self-stretch px-3 py-2 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
onClick={onToggle}
>
{task.stage === 'completed' ? (
<img src="/images/new-rag/vibe-coding-star.svg" alt="" className="size-3.5 shrink-0" />
) : (
<span
aria-hidden
className={cn(
'size-3.5 shrink-0 text-text-accent',
active && 'i-ri-loader-4-line animate-spin motion-reduce:animate-none',
task.stage === 'canceled' && 'i-ri-stop-circle-fill text-text-tertiary',
task.stage === 'failed' && 'i-ri-error-warning-fill text-text-destructive',
)}
/>
)}
<span className="truncate system-sm-regular whitespace-nowrap text-text-secondary">
{summary}
</span>
{active && <span className="system-xs-regular text-text-tertiary">{duration}</span>}
<span
aria-hidden
className={cn(
'i-ri-arrow-down-s-line size-4.5 shrink-0 text-text-tertiary transition-transform',
expanded && 'rotate-180',
)}
/>
</button>
{active && onCancel && (
<Button size="small" variant="secondary" className="mr-3 shrink-0" onClick={onCancel}>
{t(($) => $['newKnowledge.retrievalTest.cancel'])}
</Button>
)}
</div>
{expanded && (
<div className="border-t border-divider-subtle px-3 pt-2.5 pb-3.5">
<ol>
{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 (
<li key={stage} className="flex items-stretch gap-2.5 overflow-hidden">
<span
aria-hidden
className="flex w-3 shrink-0 flex-col items-center overflow-hidden"
>
<span className="flex h-5 w-3 shrink-0 items-center justify-center">
<span
className={cn(
'block size-1.75 shrink-0 rounded-full',
completed ? 'bg-gray-400' : 'bg-divider-deep',
current &&
'i-ri-loader-4-line size-2.5 animate-spin rounded-none text-text-accent motion-reduce:animate-none',
)}
/>
</span>
{index < researchStageOrder.length - 1 && (
<span className="min-h-0 w-px flex-1 bg-divider-regular" />
)}
</span>
<span
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden',
index < researchStageOrder.length - 1 ? 'pb-4' : 'pb-0.5',
)}
>
<span className="flex min-h-5 items-start justify-between gap-3">
<span
className={cn(
'text-[13px] leading-5 font-medium text-text-tertiary',
(completed || current) && 'text-text-primary',
)}
>
{current ? activeLabels[stage] : labels[stage]}
</span>
{stageDuration && (completed || current) && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{stageDuration}
</span>
)}
</span>
{current && stage === 'retrieving' && evidenceCount > 0 && (
<span className="mt-1.5 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.retrievalTest.foundSoFar'], {
count: evidenceCount,
})}
</span>
)}
{payloadLines.length > 0 && (completed || current) && (
<ul className="mt-1.5 space-y-1 system-xs-regular text-text-tertiary">
{payloadLines.map((line) => (
<li key={line} className="wrap-break-word">
{line}
</li>
))}
</ul>
)}
</span>
</li>
)
})}
</ol>
</div>
)}
</section>
)
}
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 (
<button
type="button"
aria-pressed={active}
className={cn(
'flex h-16 w-full items-center px-3 text-left outline-hidden transition-colors hover:bg-state-base-hover focus-visible:rounded-[10px] focus-visible:ring-1 focus-visible:ring-state-accent-solid/30 focus-visible:ring-inset',
index > 1 && 'border-t border-divider-subtle',
active &&
'rounded-[10px] bg-state-accent-solid/5 ring-1 ring-state-accent-solid/30 ring-inset hover:bg-state-accent-solid/5',
)}
onClick={onClick}
>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 system-sm-semibold text-text-secondary">{record.query}</span>
<span className="mt-1.5 flex items-center gap-1 system-xs-regular text-text-tertiary">
<span className={cn('min-w-0 flex-1 truncate', failed && 'text-text-destructive')}>
{activeResearchStage ? (
<>
{t(($) => $[`newKnowledge.retrievalTest.${activeResearchStage}Active`])}
{' · '}
{researchStageOrder.indexOf(activeResearchStage) + 1}/{researchStageOrder.length}
</>
) : failed ? (
record.durationMs !== undefined ? (
t(($) => $['newKnowledge.retrievalTest.failedAfter'], {
duration: formatDuration(record.durationMs, i18n.language),
})
) : (
t(($) => $['newKnowledge.retrievalTest.failedTitle'])
)
) : record.kind !== 'research' &&
record.resultCount !== undefined &&
record.durationMs !== undefined ? (
t(($) => $['newKnowledge.retrievalTest.recordSummary'], {
count: record.resultCount,
duration: formatRetrievalDuration(record.durationMs, i18n.language),
})
) : (
t(($) => $[`newKnowledge.settings.retrievalMode.${record.mode}`])
)}
</span>
<span className="shrink-0 text-[11px] leading-4 text-text-primary opacity-30">
<RecordTime value={record.createdAt} />
</span>
</span>
</span>
</button>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<span className="relative inline-flex h-5 min-w-5 shrink-0 items-center justify-center gap-0.75 overflow-hidden rounded-md border border-components-progress-bar-border bg-util-colors-blue-brand-blue-brand-50 px-1.25 text-util-colors-blue-brand-blue-brand-700">
<span
aria-hidden
className="absolute inset-y-0 left-0 border-r-[1.5px] border-components-progress-bar-progress-highlight bg-util-colors-blue-brand-blue-brand-100"
style={{ width: `${normalized * 100}%` }}
/>
<span className="relative system-2xs-medium">
{t(($) => $['newKnowledge.retrievalTest.score'])}
</span>
<span className="relative system-xs-semibold">{displayedScore}</span>
</span>
)
}
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 (
<article
id={citationTargetId}
tabIndex={citationTargetId ? -1 : undefined}
className={cn(
'overflow-hidden rounded-xl bg-components-panel-bg outline-hidden',
citationTargeted && 'ring-2 ring-state-accent-solid ring-inset',
)}
>
<div className="flex items-center gap-2 px-3 pt-3">
<h3 className="flex min-w-0 flex-1 items-center gap-0.5 truncate system-xs-medium text-text-tertiary">
<span aria-hidden className="i-custom-public-knowledge-selection-mod size-3 shrink-0" />
<span className="truncate">{evidence.title || `Chunk ${index + 1}`}</span>
</h3>
{evidence.score !== undefined && <ScorePill score={evidence.score} />}
</div>
<p className="px-3 pt-1 pb-2 body-md-regular tracking-[-0.07px] text-text-secondary">
<span className="line-clamp-2 whitespace-pre-wrap">{evidence.text}</span>
</p>
{evidence.images.length > 0 && (
<div className="flex gap-1 overflow-hidden px-3 py-1">
{evidence.images.slice(0, 4).map((image) => (
<span key={image} className="flex size-8 shrink-0 items-center justify-center p-0.5">
<img
src={image}
alt=""
className="size-7.5 border-2 border-effects-image-frame object-cover shadow-xs"
/>
</span>
))}
{evidence.images.length > 4 && (
<span className="flex h-8 shrink-0 items-center px-0.5 py-1">
<span className="flex size-7 items-center justify-center rounded-sm border-[1.5px] border-components-panel-bg bg-divider-regular system-xs-regular text-text-tertiary">
+{evidence.images.length - 4}
</span>
</span>
)}
</div>
)}
<footer className="flex h-10 items-center gap-1.5 border-t border-divider-subtle py-2 pr-2 pl-3">
<span
aria-hidden
className="i-ri-file-pdf-2-fill size-4 shrink-0 text-util-colors-red-red-500"
/>
<span className="min-w-0 truncate system-sm-regular text-text-secondary">
{evidence.documentName ?? evidence.title}
</span>
{evidence.revision && (
<span className="shrink-0 rounded-xs bg-divider-subtle px-1.25 py-px system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.retrievalTest.revision'], {
revision: evidence.revision,
})}
</span>
)}
{evidence.page !== undefined && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.retrievalTest.page'], { page: evidence.page })}
</span>
)}
<span className="min-w-0 flex-1" />
{openHref && (
<Link
href={openHref}
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t(($) => $['newKnowledge.retrievalTest.open'])}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Link>
)}
</footer>
</article>
)
}
export function ResultSkeleton() {
const { t } = useTranslation('common')
return (
<div role="status" aria-live="polite" aria-label={t(($) => $.loading)} className="space-y-3">
{[0, 1, 2].map((item) => (
<div
key={item}
className={cn(
'flex animate-pulse flex-col gap-2.5 overflow-hidden rounded-xl bg-components-panel-bg px-3 py-3.5 motion-reduce:animate-none',
item === 2 && 'opacity-60',
)}
>
<div className="flex items-center justify-between overflow-hidden">
<div className="h-3 w-30 shrink-0 rounded-xs bg-divider-regular" />
<div className="h-4 w-14 shrink-0 rounded-md bg-divider-subtle" />
</div>
<div className="h-3 w-full shrink-0 rounded-xs bg-divider-subtle" />
<div className="h-3 w-110 max-w-full shrink-0 rounded-xs bg-divider-subtle" />
<div className="h-px w-full shrink-0 bg-divider-subtle" />
<div className="flex items-start justify-between overflow-hidden">
<div className="h-3 w-50 shrink-0 rounded-xs bg-divider-subtle" />
<div className="h-3 w-10 shrink-0 rounded-xs bg-divider-subtle" />
</div>
</div>
))}
</div>
)
}
export function EmptyState({
description,
kind = 'initial',
title,
}: {
description: string
kind?: 'initial' | 'no-results'
title: string
}) {
return (
<div className="flex min-h-full flex-col items-center justify-center px-8 text-center">
<span
aria-hidden
className={cn(
kind === 'initial'
? 'i-custom-vender-main-nav-quick-search size-6 text-text-tertiary'
: 'i-ri-alert-fill size-5 text-text-warning',
)}
/>
<h2 className="mt-1.5 system-md-medium text-text-primary">{title}</h2>
<p className="mt-1.5 max-w-97.25 system-xs-regular text-text-tertiary">{description}</p>
</div>
)
}
export function FailedResult({
description,
onRetry,
}: {
description: string
onRetry: () => void
}) {
const { t } = useTranslation('dataset')
return (
<div
role="alert"
className="flex min-h-10 items-center gap-1.5 rounded-[10px] bg-util-colors-red-red-500/5 px-3 py-2"
>
<span aria-hidden className="i-ri-alert-fill size-3.5 text-text-destructive" />
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">
{t(($) => $['newKnowledge.retrievalTest.failedTitle'])}
{' — '}
<span>{description}</span>
</span>
<Button size="small" variant="secondary" onClick={onRetry}>
{t(($) => $['newKnowledge.retrievalTest.retry'])}
</Button>
</div>
)
}
const researchCitationPattern = /(?<!\\)\[(\d+)\](?!\s*(?:\(|:))/g
const researchCodePattern = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*(?:`|$))/g
function linkResearchCitations(answer: string, citationCount: number) {
return answer
.split(researchCodePattern)
.map((segment, index) => {
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<AnchorHTMLAttributes<HTMLAnchorElement>> & {
node?: unknown
onCitationClick: (citationIndex: number) => void
}
function ResearchAnswerLink({
children,
href,
node,
onCitationClick,
...props
}: ResearchAnswerLinkProps) {
const citationMatch = href?.match(/^#research-evidence-(\d+)$/)
if (!citationMatch)
return (
<MarkdownLink {...props} href={href} node={node}>
{children}
</MarkdownLink>
)
const citationIndex = Number(citationMatch[1]) - 1
return (
<a
{...props}
href={href}
className="rounded-sm px-0.5 font-medium text-text-accent outline-hidden hover:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={(event) => {
event.preventDefault()
onCitationClick(citationIndex)
}}
>
{children}
</a>
)
}
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<NonNullable<MarkdownProps['customComponents']>>(
() => ({
a: (props) => <ResearchAnswerLink {...props} onCitationClick={onCitationClick} />,
}),
[onCitationClick],
)
return (
<section className="mt-3 rounded-xl border border-components-panel-border bg-components-panel-bg px-4 py-3.5 shadow-xs">
<header className="mb-3 flex items-center gap-2">
<span aria-hidden className="i-ri-sparkling-2-fill size-4 text-text-accent" />
<h3 className="system-sm-semibold text-text-primary">
{t(($) =>
streaming
? $['newKnowledge.retrievalTest.generatingActive']
: $['newKnowledge.retrievalTest.generating'],
)}
</h3>
{streaming && (
<span
aria-hidden
className="size-1.5 animate-pulse rounded-full bg-text-accent motion-reduce:animate-none"
/>
)}
</header>
<div aria-live="polite" aria-atomic="false">
<Markdown
className="text-[13px]! leading-5.5! wrap-break-word text-text-secondary!"
content={linkedAnswer}
customComponents={citationComponents}
isAnimating={streaming}
mode={streaming ? 'streaming' : undefined}
/>
</div>
</section>
)
}
export function QualityActions({
badCaseAvailable,
decision,
noResults,
onBadCase,
onGolden,
pending,
qualityHref,
}: {
badCaseAvailable: boolean
decision?: QualityDecision
noResults?: boolean
onBadCase: (reason: BadCaseReason) => Promise<void>
onGolden: () => void
pending?: boolean
qualityHref: string
}) {
const { t } = useTranslation('dataset')
if (decision) {
return (
<div
aria-live="polite"
className="flex min-h-14 items-center justify-between gap-3 border-t border-divider-subtle px-5"
>
<span className="flex items-center gap-2 system-sm-medium text-text-success">
<span aria-hidden className="i-ri-checkbox-circle-fill size-4" />
{t(($) =>
decision === 'golden'
? $['newKnowledge.retrievalTest.savedGoldenQuestion']
: $['newKnowledge.retrievalTest.savedBadCase'],
)}
</span>
<Link
href={qualityHref}
className="rounded-md px-1 py-0.5 system-sm-semibold text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t(($) => $['newKnowledge.retrievalTest.viewInQuality'])}
</Link>
</div>
)
}
if (!badCaseAvailable && noResults) return null
return (
<div className="flex shrink-0 items-center justify-end gap-3 border-t border-divider-regular pt-4 pb-1">
{badCaseAvailable && (
<DropdownMenu>
<DropdownMenuTrigger
disabled={pending}
render={<Button loading={pending} variant={noResults ? 'secondary' : 'ghost'} />}
>
<span aria-hidden className="i-ri-thumb-down-line size-4" />
{t(($) => $['newKnowledge.retrievalTest.makeBadCase'])}
</DropdownMenuTrigger>
<DropdownMenuContent placement="top-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem onClick={() => void onBadCase('low-score')}>
{t(($) => $['newKnowledge.qualityPage.reasonValues.lowScore'])}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void onBadCase('retrieval-miss')}>
{t(($) => $['newKnowledge.qualityPage.reasonValues.retrievalMiss'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{!noResults && (
<Button
disabled={pending}
loading={pending}
variant="secondary"
onClick={() => void onGolden()}
>
<span aria-hidden className="i-ri-thumb-up-line size-4" />
{t(($) => $['newKnowledge.retrievalTest.keepGoldenQuestion'])}
</Button>
)}
</div>
)
}

View File

@ -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<boolean>
onRemove: () => Promise<boolean>
onSync: () => Promise<boolean>
onToggle: () => Promise<boolean>
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<SourceSyncPolicy['mode']>(() =>
sourceSyncMode(source),
)
const [nextCustomIntervalHours, setNextCustomIntervalHours] = useState<number | ''>(() =>
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 (
<>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t(($) => $['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"
>
<span
aria-hidden
className={cn(
'size-4.5',
pendingAction ? 'i-ri-loader-4-line animate-spin' : 'i-ri-more-fill',
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
{canSync && (
<DropdownMenuItem
onClick={() => void onSync()}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-refresh-line size-4" />
{syncAction === 'retry'
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.syncNow'])}
</DropdownMenuItem>
)}
{sourceUri && (
<DropdownMenuLinkItem
render={
<a
aria-label={t(($) => $['newKnowledge.openSource'])}
href={sourceUri}
target="_blank"
rel="noopener noreferrer"
/>
}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t(($) => $['newKnowledge.openSource'])}
</DropdownMenuLinkItem>
)}
{canEdit && (
<DropdownMenuItem
onClick={openEditDialog}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-edit-line size-4" />
{tCommon(($) => $['operation.edit'])}
</DropdownMenuItem>
)}
{canToggle && (
<DropdownMenuItem
onClick={() => void onToggle()}
className="h-7 gap-2 px-2 system-sm-medium"
>
<span
aria-hidden
className={cn(
'size-4',
source.status === 'disabled'
? 'i-ri-checkbox-circle-line'
: 'i-ri-indeterminate-circle-line',
)}
/>
{source.status === 'disabled'
? t(($) => $.enable)
: t(($) => $['newKnowledge.disableSource'])}
</DropdownMenuItem>
)}
{canRemove && (
<>
{(canEdit || canSync || canToggle || sourceUri) && (
<DropdownMenuSeparator className="my-px" />
)}
<DropdownMenuItem
onClick={() => {
setMenuOpen(false)
setRemoveDialogOpen(true)
}}
variant="destructive"
className="h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.removeSource'])}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault()
void submitEdit()
}}
>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.edit'])} {source.name}
</DialogTitle>
<label
className="mt-5 block system-sm-medium text-text-secondary"
htmlFor={`source-name-${source.id}`}
>
{t(($) => $['newKnowledge.sourceName'])}
</label>
<Input
id={`source-name-${source.id}`}
autoComplete="off"
className="mt-2 w-full"
disabled={pendingAction === 'edit'}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={nextName}
onChange={(event) => setNextName(event.target.value)}
/>
<div className="mt-4">
<SyncPolicyField
disabled={pendingAction === 'edit'}
label
triggerClassName="w-full"
value={{
customIntervalSeconds:
typeof nextCustomIntervalHours === 'number'
? nextCustomIntervalHours * 3600
: undefined,
mode: nextSyncMode,
}}
onChange={(value) => {
setNextSyncMode(value.mode)
if (value.customIntervalSeconds)
setNextCustomIntervalHours(value.customIntervalSeconds / 3600)
}}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
disabled={pendingAction === 'edit'}
onClick={() => setEditDialogOpen(false)}
type="button"
>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
disabled={
pendingAction === 'edit' ||
!nextName.trim() ||
!customIntervalValid ||
!editChanged
}
loading={pendingAction === 'edit'}
type="submit"
variant="primary"
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={removeDialogOpen} onOpenChange={setRemoveDialogOpen}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.deleteConfirmTitle'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{tCommon(($) => $['operation.confirmAction'])}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
loading={pendingAction === 'remove'}
disabled={pendingAction === 'remove'}
onClick={() =>
void onRemove().then((removed) => {
if (removed) setRemoveDialogOpen(false)
})
}
>
{t(($) => $['newKnowledge.removeSource'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -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<SourceDisplayStatus, StatusDotStatus> = {
active: 'success',
syncing: 'normal',
initializing: 'normal',
disabled: 'disabled',
error: 'error',
}
function TruncatedSourceValue({ children, className }: { children: string; className?: string }) {
return (
<Tooltip>
<TooltipTrigger
render={<span className={cn('block truncate', className)}>{children}</span>}
/>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
)
}
export function SourceRow({
canEdit,
canSync,
checked,
ensureModelSetupReady,
knowledgeSpaceId,
onCheckedChange,
onRemoved,
onSourceChange,
source,
}: {
canEdit: boolean
canSync: boolean
checked: boolean
ensureModelSetupReady: () => Promise<boolean>
knowledgeSpaceId: string
onCheckedChange: (checked: boolean) => void
onRemoved: () => void
onSourceChange: (source: Source) => void
source: Source
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const queryClient = useQueryClient()
const [pendingAction, setPendingAction] = useState<SourceAction>()
const syncWorkflow = source.syncWorkflow
const displayStatus = sourceDisplayStatus(source)
const initializing = displayStatus === 'initializing'
const initialWorkflowId = initialSourceWorkflowId(source)
const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing'
const provider = sourceProviderDetails(source)
const providerName = provider.name
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 <Result,>(
action: SourceAction,
mutation: () => Promise<Result>,
onAccepted?: (result: Result) => void,
beforeAction?: () => Promise<boolean>,
) => {
if (pendingAction) return false
setPendingAction(action)
try {
if (beforeAction && !(await beforeAction())) return false
let result: Result
try {
result = await mutation()
} catch {
toast.error(t(($) => $['newKnowledge.sourcesErrorDescription']))
try {
await queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
})
} catch {
return false
}
return false
}
onAccepted?.(result)
try {
await queryClient.invalidateQueries(
{
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
},
{
throwOnError: true,
},
)
} catch {
// The accepted mutation is already reflected by the list-owner state.
}
return true
} finally {
setPendingAction(undefined)
}
}
const applyAcceptedWorkflow = (workflow: Parameters<typeof sourceWorkflowFromApi>[0]) => {
const run = sourceWorkflowFromApi(workflow)
onSourceChange({
...source,
syncWorkflow: run,
status: sourceStatusWithSyncWorkflow(source.status, run),
})
}
const syncSource = () =>
runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
const retrySource = () => {
const retryWorkflowId =
initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id
if (!retryWorkflowId) return syncSource()
return runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({
params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
}
const toggleSource = () =>
runAction(
'toggle',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: {
...(source.version === undefined ? {} : { expectedVersion: source.version }),
status: source.status === 'disabled' ? 'active' : 'disabled',
},
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
),
(updatedSource) => {
const syncWorkflow =
updatedSource.syncWorkflow ??
(sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined)
onSourceChange({
...updatedSource,
lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(updatedSource.status, syncWorkflow),
syncWorkflow,
syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy,
})
},
)
const editSource = ({ 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 (
<tr
className={cn(
sourceTableGridClass,
'relative rounded-lg border border-divider-subtle p-4 text-left @min-[768px]/knowledge-content:min-h-12.5 @min-[768px]/knowledge-content:rounded-none @min-[768px]/knowledge-content:border-x-0 @min-[768px]/knowledge-content:border-b-0 @min-[768px]/knowledge-content:px-0 @min-[768px]/knowledge-content:py-2',
displayStatus === 'disabled' && '[&>td:not(:first-child)]:opacity-60',
)}
>
<td className="absolute top-4 left-4 @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:row-span-1">
<Checkbox aria-label={source.name} checked={checked} onCheckedChange={onCheckedChange} />
</td>
<td className="col-span-2 min-w-0 pr-8 pl-7 @min-[768px]/knowledge-content:col-span-1 @min-[768px]/knowledge-content:col-start-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[768px]/knowledge-content:p-0">
<div className="flex min-w-0 items-center gap-2.5">
<SourceProviderIcon className="size-4.5" fallbackIcon={sourceIcon} />
<TruncatedSourceValue className="text-[13px] leading-4.25 font-medium text-text-primary">
{source.name}
</TruncatedSourceValue>
</div>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:col-start-2 @min-[768px]/knowledge-content:row-start-2 @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-start-auto @min-[960px]/knowledge-content:flex @min-[960px]/knowledge-content:items-center">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['metadata.createMetadata.type'])}
</p>
<div className="min-w-0 text-xs leading-4 font-normal">
<TruncatedSourceValue className="text-text-primary">
{providerName ?? typeLabel}
</TruncatedSourceValue>
{providerName && (
<TruncatedSourceValue className="mt-0.5 text-text-tertiary">
{typeLabel}
</TruncatedSourceValue>
)}
</div>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:col-start-3 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.statusColumn'])}
</p>
<span
role="status"
className={cn(
'inline-flex min-w-0 items-center gap-1.5 text-xs leading-4 font-medium text-text-primary',
(displayStatus === 'syncing' || initializing) && 'text-text-accent',
)}
>
{initializing ? (
<span
aria-hidden
className="i-ri-loader-4-line size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
/>
) : (
<StatusDot
status={statusDotStatus[displayStatus]}
className={cn(
'shrink-0',
displayStatus === 'syncing' && 'animate-pulse motion-reduce:animate-none',
)}
/>
)}
<span className="sr-only">{source.name}: </span>
{t(($) => $[`newKnowledge.sourceStatus.${displayStatus}`])}
{displayStatus === 'error' && syncFailureMessageKey && (
<Infotip
aria-label={t(($) => $[syncFailureMessageKey])}
iconVariant="information"
popupClassName="max-w-80"
>
{t(($) => $[syncFailureMessageKey])}
</Infotip>
)}
</span>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:hidden @min-[960px]/knowledge-content:flex @min-[960px]/knowledge-content:items-center">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.syncPolicyColumn'])}
</p>
<TruncatedSourceValue className="text-xs leading-4 font-normal text-text-secondary">
{syncPolicy ?? '—'}
</TruncatedSourceValue>
</td>
<td className="min-w-0 text-xs leading-4 font-normal text-text-secondary @min-[768px]/knowledge-content:col-start-4 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.lastSyncColumn'])}
</p>
{displayStatus === 'syncing' && syncWorkflow ? (
<span className="inline-flex min-w-0 items-center gap-1.5 text-text-accent">
<span
aria-hidden
className="i-ri-loader-4-line size-3.5 animate-spin motion-reduce:animate-none"
/>
{t(($) => $['newKnowledge.sourceSyncProgress'], {
completed:
syncWorkflow.progressCompleted +
syncWorkflow.progressFailed +
syncWorkflow.progressSkipped,
total: syncWorkflow.progressTotal ?? '—',
})}
</span>
) : (
<TruncatedSourceValue>{lastSync ?? '—'}</TruncatedSourceValue>
)}
</td>
<td className="absolute top-2.5 right-2.5 text-right @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:col-start-5 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<div className="flex items-center justify-end gap-0.5">
{canSync && displayStatus === 'error' && (
<Button
className="@min-[768px]/knowledge-content:hidden @min-[1280px]/knowledge-content:inline-flex"
size="small"
variant="secondary"
loading={pendingAction === 'sync'}
disabled={Boolean(pendingAction)}
onClick={() => void retrySource()}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
<SourceActions
canEdit={canEdit && !initializing && !initialWorkflowId}
canRemove={canEdit && !initializing && !initialImportRetrying}
canSync={canSync && !initializing && displayStatus !== 'syncing'}
canToggle={canEdit && !initializing && !initialWorkflowId}
source={source}
pendingAction={pendingAction}
onEdit={editSource}
onSync={displayStatus === 'error' ? retrySource : syncSource}
onToggle={toggleSource}
onRemove={removeSource}
syncAction={displayStatus === 'error' ? 'retry' : 'sync'}
/>
</div>
</td>
</tr>
)
}

View File

@ -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]'

View File

@ -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<string, unknown>)
: 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'

View File

@ -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 (
<div className="mt-2.5 flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center">
<div className="flex items-center gap-3 opacity-85">
{emptySourceShortcuts.map((shortcut) => {
const icon = (
<span
key={shortcut.brand}
aria-hidden
data-brand={shortcut.brand}
className={`${shortcut.iconClass} size-8`}
/>
)
if (!canAddSource) return icon
return (
<Link
key={shortcut.brand}
href={newKnowledgeAddSourcePath(knowledgeSpaceId, {
provider: shortcut.provider,
sourceType: shortcut.sourceType,
})}
className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{icon}
<span className="sr-only">{shortcut.provider}</span>
</Link>
)
})}
{canAddSource ? (
<Link
aria-label={t(($) => $['newKnowledge.moreProviders'])}
href={newKnowledgeAddSourcePath(knowledgeSpaceId)}
className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span
aria-hidden
data-brand="more"
className="i-ri-more-fill size-8 text-text-quaternary"
/>
</Link>
) : (
<span
aria-hidden
data-brand="more"
className="i-ri-more-fill size-8 text-text-quaternary"
/>
)}
</div>
<div className="flex flex-col items-center gap-1.5 pt-1.5">
<h2 className="title-xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.sourcesEmptyTitle'])}
</h2>
<p className="w-full max-w-110 body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.sourcesEmptyDescription'])}
</p>
</div>
{canAddSource && (
<Link
href={newKnowledgeAddSourcePath(knowledgeSpaceId)}
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg bg-components-button-primary-bg px-3.5 system-sm-medium text-components-button-primary-text shadow-sm outline-hidden hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addSource'])}
</Link>
)}
</div>
)
}

View File

@ -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<SourceDisplayStatus, StatusDotStatus> = {
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<string, unknown>)
: 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 (
<Tooltip>
<TooltipTrigger
render={<span className={cn('block truncate', className)}>{children}</span>}
/>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
)
}
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<boolean>
onRemove: () => Promise<boolean>
onSync: () => Promise<boolean>
onToggle: () => Promise<boolean>
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<SourceSyncPolicy['mode']>(() =>
sourceSyncMode(source),
)
const [nextCustomIntervalHours, setNextCustomIntervalHours] = useState<number | ''>(() =>
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 (
<>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger
aria-label={t(($) => $['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"
>
<span
aria-hidden
className={cn(
'size-4.5',
pendingAction ? 'i-ri-loader-4-line animate-spin' : 'i-ri-more-fill',
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[200px]">
{canSync && (
<DropdownMenuItem
onClick={() => void onSync()}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-refresh-line size-4" />
{syncAction === 'retry'
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.syncNow'])}
</DropdownMenuItem>
)}
{sourceUri && (
<DropdownMenuLinkItem
render={
<a
aria-label={t(($) => $['newKnowledge.openSource'])}
href={sourceUri}
target="_blank"
rel="noopener noreferrer"
/>
}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t(($) => $['newKnowledge.openSource'])}
</DropdownMenuLinkItem>
)}
{canEdit && (
<DropdownMenuItem
onClick={openEditDialog}
className="mb-px h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-edit-line size-4" />
{tCommon(($) => $['operation.edit'])}
</DropdownMenuItem>
)}
{canToggle && (
<DropdownMenuItem
onClick={() => void onToggle()}
className="h-7 gap-2 px-2 system-sm-medium"
>
<span
aria-hidden
className={cn(
'size-4',
source.status === 'disabled'
? 'i-ri-checkbox-circle-line'
: 'i-ri-indeterminate-circle-line',
)}
/>
{source.status === 'disabled'
? t(($) => $.enable)
: t(($) => $['newKnowledge.disableSource'])}
</DropdownMenuItem>
)}
{canRemove && (
<>
{(canEdit || canSync || canToggle || sourceUri) && (
<DropdownMenuSeparator className="my-px" />
)}
<DropdownMenuItem
onClick={() => {
setMenuOpen(false)
setRemoveDialogOpen(true)
}}
variant="destructive"
className="h-7 gap-2 px-2 system-sm-medium"
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t(($) => $['newKnowledge.removeSource'])}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<form
onSubmit={(event) => {
event.preventDefault()
void submitEdit()
}}
>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.edit'])} {source.name}
</DialogTitle>
<label
className="mt-5 block system-sm-medium text-text-secondary"
htmlFor={`source-name-${source.id}`}
>
{t(($) => $['newKnowledge.sourceName'])}
</label>
<Input
id={`source-name-${source.id}`}
autoComplete="off"
className="mt-2 w-full"
disabled={pendingAction === 'edit'}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={nextName}
onChange={(event) => setNextName(event.target.value)}
/>
<div className="mt-4">
<SyncPolicyField
disabled={pendingAction === 'edit'}
label
triggerClassName="w-full"
value={{
customIntervalSeconds:
typeof nextCustomIntervalHours === 'number'
? nextCustomIntervalHours * 3600
: undefined,
mode: nextSyncMode,
}}
onChange={(value) => {
setNextSyncMode(value.mode)
if (value.customIntervalSeconds)
setNextCustomIntervalHours(value.customIntervalSeconds / 3600)
}}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
disabled={pendingAction === 'edit'}
onClick={() => setEditDialogOpen(false)}
type="button"
>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
disabled={
pendingAction === 'edit' ||
!nextName.trim() ||
!customIntervalValid ||
!editChanged
}
loading={pendingAction === 'edit'}
type="submit"
variant="primary"
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={removeDialogOpen} onOpenChange={setRemoveDialogOpen}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{tCommon(($) => $['operation.deleteConfirmTitle'])}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{tCommon(($) => $['operation.confirmAction'])}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
loading={pendingAction === 'remove'}
disabled={pendingAction === 'remove'}
onClick={() =>
void onRemove().then((removed) => {
if (removed) setRemoveDialogOpen(false)
})
}
>
{t(($) => $['newKnowledge.removeSource'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function SourceRow({
canEdit,
canSync,
checked,
ensureModelSetupReady,
knowledgeSpaceId,
onCheckedChange,
onRemoved,
onSourceChange,
source,
}: {
canEdit: boolean
canSync: boolean
checked: boolean
ensureModelSetupReady: () => Promise<boolean>
knowledgeSpaceId: string
onCheckedChange: (checked: boolean) => void
onRemoved: () => void
onSourceChange: (source: Source) => void
source: Source
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const { formatTimeFromNow } = useFormatTimeFromNow()
const queryClient = useQueryClient()
const [pendingAction, setPendingAction] = useState<SourceAction>()
const syncWorkflow = source.syncWorkflow
const displayStatus = sourceDisplayStatus(source)
const initializing = displayStatus === 'initializing'
const initialWorkflowId = initialSourceWorkflowId(source)
const initialImportRetrying = Boolean(initialWorkflowId) && displayStatus === 'syncing'
const provider = sourceProviderDetails(source)
const providerName = provider.name
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 <Result,>(
action: SourceAction,
mutation: () => Promise<Result>,
onAccepted?: (result: Result) => void,
beforeAction?: () => Promise<boolean>,
) => {
if (pendingAction) return false
setPendingAction(action)
try {
if (beforeAction && !(await beforeAction())) return false
let result: Result
try {
result = await mutation()
} catch {
toast.error(t(($) => $['newKnowledge.sourcesErrorDescription']))
try {
await queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
})
} catch {
return false
}
return false
}
onAccepted?.(result)
try {
await queryClient.invalidateQueries(
{
queryKey: consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.key(),
},
{
throwOnError: true,
},
)
} catch {
// The accepted mutation is already reflected by the list-owner state.
}
return true
} finally {
setPendingAction(undefined)
}
}
const applyAcceptedWorkflow = (workflow: Parameters<typeof sourceWorkflowFromApi>[0]) => {
const run = sourceWorkflowFromApi(workflow)
onSourceChange({
...source,
syncWorkflow: run,
status: sourceStatusWithSyncWorkflow(source.status, run),
})
}
const syncSource = () =>
runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.sync.post({
headers: { 'Idempotency-Key': createIdempotencyKey() },
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
const retrySource = () => {
const retryWorkflowId =
initialWorkflowId ?? sourceAsyncImportWorkflowId(source) ?? syncWorkflow?.id
if (!retryWorkflowId) return syncSource()
return runAction(
'sync',
() =>
consoleClient.knowledgeFs.spaces.byControlSpaceId.sourceWorkflows.byRunId.retry.post({
params: { control_space_id: knowledgeSpaceId, run_id: retryWorkflowId },
}),
applyAcceptedWorkflow,
ensureModelSetupReady,
)
}
const toggleSource = () =>
runAction(
'toggle',
async () =>
sourceFromApi(
await consoleClient.knowledgeFs.spaces.byControlSpaceId.sources.bySourceId.patch({
body: {
...(source.version === undefined ? {} : { expectedVersion: source.version }),
status: source.status === 'disabled' ? 'active' : 'disabled',
},
params: { control_space_id: knowledgeSpaceId, source_id: source.id },
}),
),
(updatedSource) => {
const syncWorkflow =
updatedSource.syncWorkflow ??
(sourceWorkflowIsActive(source.syncWorkflow) ? source.syncWorkflow : undefined)
onSourceChange({
...updatedSource,
lastSyncedAt: updatedSource.lastSyncedAt ?? source.lastSyncedAt,
status: sourceStatusWithSyncWorkflow(updatedSource.status, syncWorkflow),
syncWorkflow,
syncPolicy: updatedSource.syncPolicy ?? source.syncPolicy,
})
},
)
const editSource = ({ 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 (
<tr
className={cn(
sourceTableGridClass,
'relative rounded-lg border border-divider-subtle p-4 text-left @min-[768px]/knowledge-content:min-h-12.5 @min-[768px]/knowledge-content:rounded-none @min-[768px]/knowledge-content:border-x-0 @min-[768px]/knowledge-content:border-b-0 @min-[768px]/knowledge-content:px-0 @min-[768px]/knowledge-content:py-2',
displayStatus === 'disabled' && '[&>td:not(:first-child)]:opacity-60',
)}
>
<td className="absolute top-4 left-4 @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:row-span-1">
<Checkbox aria-label={source.name} checked={checked} onCheckedChange={onCheckedChange} />
</td>
<td className="col-span-2 min-w-0 pr-8 pl-7 @min-[768px]/knowledge-content:col-span-1 @min-[768px]/knowledge-content:col-start-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[768px]/knowledge-content:p-0">
<div className="flex min-w-0 items-center gap-2.5">
<SourceProviderIcon className="size-4.5" fallbackIcon={sourceIcon} />
<TruncatedSourceValue className="text-[13px] leading-4.25 font-medium text-text-primary">
{source.name}
</TruncatedSourceValue>
</div>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:col-start-2 @min-[768px]/knowledge-content:row-start-2 @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-start-auto @min-[960px]/knowledge-content:flex @min-[960px]/knowledge-content:items-center">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['metadata.createMetadata.type'])}
</p>
<div className="min-w-0 text-xs leading-4 font-normal">
<TruncatedSourceValue className="text-text-primary">
{providerName ?? typeLabel}
</TruncatedSourceValue>
{providerName && (
<TruncatedSourceValue className="mt-0.5 text-text-tertiary">
{typeLabel}
</TruncatedSourceValue>
)}
</div>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:col-start-3 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.statusColumn'])}
</p>
<span
role="status"
className={cn(
'inline-flex min-w-0 items-center gap-1.5 text-xs leading-4 font-medium text-text-primary',
(displayStatus === 'syncing' || initializing) && 'text-text-accent',
)}
>
{initializing ? (
<span
aria-hidden
className="i-ri-loader-4-line size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
/>
) : (
<StatusDot
status={statusDotStatus[displayStatus]}
className={cn(
'shrink-0',
displayStatus === 'syncing' && 'animate-pulse motion-reduce:animate-none',
)}
/>
)}
<span className="sr-only">{source.name}: </span>
{t(($) => $[`newKnowledge.sourceStatus.${displayStatus}`])}
{displayStatus === 'error' && syncFailureMessageKey && (
<Infotip
aria-label={t(($) => $[syncFailureMessageKey])}
iconVariant="information"
popupClassName="max-w-80"
>
{t(($) => $[syncFailureMessageKey])}
</Infotip>
)}
</span>
</td>
<td className="min-w-0 @min-[768px]/knowledge-content:hidden @min-[960px]/knowledge-content:flex @min-[960px]/knowledge-content:items-center">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.syncPolicyColumn'])}
</p>
<TruncatedSourceValue className="text-xs leading-4 font-normal text-text-secondary">
{syncPolicy ?? '—'}
</TruncatedSourceValue>
</td>
<td className="min-w-0 text-xs leading-4 font-normal text-text-secondary @min-[768px]/knowledge-content:col-start-4 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<p className="mb-1 text-[11px] leading-4 font-medium tracking-[0.3px] text-text-tertiary uppercase @min-[768px]/knowledge-content:hidden">
{t(($) => $['newKnowledge.lastSyncColumn'])}
</p>
{displayStatus === 'syncing' && syncWorkflow ? (
<span className="inline-flex min-w-0 items-center gap-1.5 text-text-accent">
<span
aria-hidden
className="i-ri-loader-4-line size-3.5 animate-spin motion-reduce:animate-none"
/>
{t(($) => $['newKnowledge.sourceSyncProgress'], {
completed:
syncWorkflow.progressCompleted +
syncWorkflow.progressFailed +
syncWorkflow.progressSkipped,
total: syncWorkflow.progressTotal ?? '—',
})}
</span>
) : (
<TruncatedSourceValue>{lastSync ?? '—'}</TruncatedSourceValue>
)}
</td>
<td className="absolute top-2.5 right-2.5 text-right @min-[768px]/knowledge-content:static @min-[768px]/knowledge-content:col-start-5 @min-[768px]/knowledge-content:row-span-2 @min-[768px]/knowledge-content:row-start-1 @min-[768px]/knowledge-content:flex @min-[768px]/knowledge-content:items-center @min-[960px]/knowledge-content:col-start-auto @min-[960px]/knowledge-content:row-span-1 @min-[960px]/knowledge-content:row-start-auto">
<div className="flex items-center justify-end gap-0.5">
{canSync && displayStatus === 'error' && (
<Button
className="@min-[768px]/knowledge-content:hidden @min-[1280px]/knowledge-content:inline-flex"
size="small"
variant="secondary"
loading={pendingAction === 'sync'}
disabled={Boolean(pendingAction)}
onClick={() => void retrySource()}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
<SourceActions
canEdit={canEdit && !initializing && !initialWorkflowId}
canRemove={canEdit && !initializing && !initialImportRetrying}
canSync={canSync && !initializing && displayStatus !== 'syncing'}
canToggle={canEdit && !initializing && !initialWorkflowId}
source={source}
pendingAction={pendingAction}
onEdit={editSource}
onSync={displayStatus === 'error' ? retrySource : syncSource}
onToggle={toggleSource}
onRemove={removeSource}
syncAction={displayStatus === 'error' ? 'retry' : 'sync'}
/>
</div>
</td>
</tr>
)
}
function SourcesEmpty({
canAddSource,
knowledgeSpaceId,
}: {
canAddSource: boolean
knowledgeSpaceId: string
}) {
const { t } = useTranslation('dataset')
return (
<div className="mt-2.5 flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center">
<div className="flex items-center gap-3 opacity-85">
{emptySourceShortcuts.map((shortcut) => {
const icon = (
<span
key={shortcut.brand}
aria-hidden
data-brand={shortcut.brand}
className={`${shortcut.iconClass} size-8`}
/>
)
if (!canAddSource) return icon
return (
<Link
key={shortcut.brand}
href={newKnowledgeAddSourcePath(knowledgeSpaceId, {
provider: shortcut.provider,
sourceType: shortcut.sourceType,
})}
className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{icon}
<span className="sr-only">{shortcut.provider}</span>
</Link>
)
})}
{canAddSource ? (
<Link
aria-label={t(($) => $['newKnowledge.moreProviders'])}
href={newKnowledgeAddSourcePath(knowledgeSpaceId)}
className="inline-flex size-8 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span
aria-hidden
data-brand="more"
className="i-ri-more-fill size-8 text-text-quaternary"
/>
</Link>
) : (
<span
aria-hidden
data-brand="more"
className="i-ri-more-fill size-8 text-text-quaternary"
/>
)}
</div>
<div className="flex flex-col items-center gap-1.5 pt-1.5">
<h2 className="title-xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.sourcesEmptyTitle'])}
</h2>
<p className="w-full max-w-110 body-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.sourcesEmptyDescription'])}
</p>
</div>
{canAddSource && (
<Link
href={newKnowledgeAddSourcePath(knowledgeSpaceId)}
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg bg-components-button-primary-bg px-3.5 system-sm-medium text-components-button-primary-text shadow-sm outline-hidden hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-add-line size-4" />
{t(($) => $['newKnowledge.addSource'])}
</Link>
)}
</div>
)
}
export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')