mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
refactor(web): split new RAG page responsibilities
This commit is contained in:
parent
62ca5184a3
commit
3c13cc2565
File diff suppressed because it is too large
Load Diff
18
web/features/new-rag/overview/overview-activity-types.ts
Normal file
18
web/features/new-rag/overview/overview-activity-types.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
674
web/features/new-rag/overview/overview-activity.tsx
Normal file
674
web/features/new-rag/overview/overview-activity.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
web/features/new-rag/overview/overview-attention.tsx
Normal file
243
web/features/new-rag/overview/overview-attention.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
web/features/new-rag/overview/overview-format.ts
Normal file
35
web/features/new-rag/overview/overview-format.ts
Normal 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
|
||||
}
|
||||
193
web/features/new-rag/overview/overview-inventory.tsx
Normal file
193
web/features/new-rag/overview/overview-inventory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
web/features/new-rag/overview/overview-metrics.tsx
Normal file
199
web/features/new-rag/overview/overview-metrics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
253
web/features/new-rag/overview/overview-onboarding.tsx
Normal file
253
web/features/new-rag/overview/overview-onboarding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
web/features/new-rag/overview/overview-panel.tsx
Normal file
76
web/features/new-rag/overview/overview-panel.tsx
Normal 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'])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
web/features/new-rag/retrieval-test-history-utils.ts
Normal file
14
web/features/new-rag/retrieval-test-history-utils.ts
Normal 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)
|
||||
}
|
||||
561
web/features/new-rag/retrieval-test-history.tsx
Normal file
561
web/features/new-rag/retrieval-test-history.tsx
Normal 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
403
web/features/new-rag/retrieval-test-results.tsx
Normal file
403
web/features/new-rag/retrieval-test-results.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
305
web/features/new-rag/source-actions.tsx
Normal file
305
web/features/new-rag/source-actions.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
425
web/features/new-rag/source-list-item.tsx
Normal file
425
web/features/new-rag/source-list-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
web/features/new-rag/source-list-layout.ts
Normal file
2
web/features/new-rag/source-list-layout.ts
Normal 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]'
|
||||
122
web/features/new-rag/source-list-model.ts
Normal file
122
web/features/new-rag/source-list-model.ts
Normal 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'
|
||||
115
web/features/new-rag/sources-empty.tsx
Normal file
115
web/features/new-rag/sources-empty.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user