dify/web/features/new-rag/create-source-setup.tsx
2026-08-03 11:06:21 +08:00

457 lines
18 KiB
TypeScript

'use client'
import type {
NewKnowledgeOnlineDocumentsProvider,
NewKnowledgeOnlineDriveProvider,
NewKnowledgeSourceDraft,
NewKnowledgeWebsiteProvider,
} from './routes'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field'
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@langgenius/dify-ui/number-field'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
isValidWebsiteSourceDraft,
NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH,
NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH,
} from './routes'
const sourceTypes = [
{ icon: 'i-ri-global-line', value: 'websiteCrawl' },
{ icon: 'i-ri-file-text-line', value: 'onlineDocuments' },
{ icon: 'i-ri-hard-drive-3-line', value: 'onlineDrive' },
] as const
const DEFAULT_INCLUDE_SUBPAGES = true
const DEFAULT_MAX_PAGES = 100
const providers = {
onlineDocuments: [
{ icon: 'i-custom-public-common-notion', label: 'Notion' },
{ icon: 'i-ri-file-text-fill text-[#4d8bf5]', label: 'Google Docs' },
{ icon: 'i-custom-public-common-confluence', label: 'Confluence' },
],
onlineDrive: [
{ icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },
{ icon: 'i-ri-cloud-line', label: 'OneDrive' },
{ icon: 'i-ri-box-3-line', label: 'Amazon S3' },
],
websiteCrawl: [
{ icon: 'i-ri-fire-fill text-orange-500', label: 'Firecrawl', available: true },
{ icon: 'i-custom-public-llm-jina', label: 'Jina Reader' },
{ icon: 'i-ri-water-flash-line', label: 'WaterCrawl' },
{ icon: 'i-ri-bug-line', label: 'FakeCrawler' },
],
} as const
function ConnectedSourceConfiguration({
disabled,
draft,
onDraftChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
}) {
const { t } = useTranslation('dataset')
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field name="sourceName" className="gap-1.5">
<FieldLabel>
{t(($) => $['newKnowledge.sourceName'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
</FieldLabel>
<FieldControl
type="text"
autoComplete="off"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
size="large"
onValueChange={(value) => onDraftChange({ ...draft, sourceName: value })}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</Field>
<Select<NewKnowledgeSourceDraft['syncPolicy']>
name="syncPolicy"
disabled={disabled}
value={draft.syncPolicy}
onValueChange={(value) => {
if (value) onDraftChange({ ...draft, syncPolicy: value })
}}
>
<SelectLabel>{t(($) => $['newKnowledge.syncPolicy'])}</SelectLabel>
<SelectTrigger size="large">
{t(($) =>
draft.syncPolicy === 'provider'
? $['newKnowledge.syncPolicyProvider']
: draft.syncPolicy === 'daily'
? $['newKnowledge.syncPolicyDaily']
: $['newKnowledge.syncPolicyManual'],
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="provider">
<SelectItemText>{t(($) => $['newKnowledge.syncPolicyProvider'])}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="daily">
<SelectItemText>{t(($) => $['newKnowledge.syncPolicyDaily'])}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="manual">
<SelectItemText>{t(($) => $['newKnowledge.syncPolicyManual'])}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
)
}
export function CreateSourceSetup({
disabled,
draft,
onDraftChange,
onSourceTypeChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onSourceTypeChange: (sourceType: NewKnowledgeSourceDraft['sourceType']) => void
}) {
const { t } = useTranslation('dataset')
const [optionsExpanded, setOptionsExpanded] = useState(false)
const [backendBoundaryVisible, setBackendBoundaryVisible] = useState(false)
const sourceType = draft.sourceType
const availableProviders = providers[sourceType]
const activeProvider = availableProviders.some((provider) => provider.label === draft.provider)
? draft.provider
: availableProviders[0].label
const previewReady = draft.sourceType === 'websiteCrawl' && isValidWebsiteSourceDraft(draft)
const crawlOptionsAreDefault =
draft.sourceType !== 'websiteCrawl' ||
(draft.includeSubpages === DEFAULT_INCLUDE_SUBPAGES && draft.maxPages === DEFAULT_MAX_PAGES)
const showBackendBoundary = () => setBackendBoundaryVisible(true)
const updateDraft = (nextDraft: NewKnowledgeSourceDraft) => {
onDraftChange(nextDraft)
setBackendBoundaryVisible(false)
}
const selectProvider = (provider: string) => {
if (draft.sourceType === 'onlineDocuments')
updateDraft({ ...draft, provider: provider as NewKnowledgeOnlineDocumentsProvider })
else if (draft.sourceType === 'onlineDrive')
updateDraft({ ...draft, provider: provider as NewKnowledgeOnlineDriveProvider })
else updateDraft({ ...draft, provider: provider as NewKnowledgeWebsiteProvider })
}
return (
<div className="mx-4 mb-4 space-y-4 border-t border-divider-subtle pt-4">
<Fieldset disabled={disabled}>
<FieldsetLegend className="mb-1.5 py-0 system-xs-medium">
{t(($) => $['newKnowledge.sourceTypeLabel'])}
</FieldsetLegend>
<RadioGroup<NewKnowledgeSourceDraft['sourceType']>
value={sourceType}
disabled={disabled}
className="grid grid-cols-1 gap-0.5 rounded-lg bg-background-default p-0.5 sm:grid-cols-3"
onValueChange={(value) => {
setBackendBoundaryVisible(false)
onSourceTypeChange(value)
}}
>
{sourceTypes.map((option) => (
<RadioItem<NewKnowledgeSourceDraft['sourceType']>
key={option.value}
value={option.value}
className={cn(
'relative flex min-h-8 items-center justify-center gap-1.5 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden',
'hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:bg-components-option-card-option-selected-bg data-checked:text-text-primary data-checked:shadow-xs',
'data-disabled:cursor-not-allowed data-disabled:opacity-60',
)}
>
<span aria-hidden className={`${option.icon} size-4`} />
{t(($) => $[`newKnowledge.${option.value}`])}
</RadioItem>
))}
</RadioGroup>
</Fieldset>
<Fieldset disabled={disabled}>
<FieldsetLegend className="sr-only">
{t(($) => $['newKnowledge.providerLabel'])}
</FieldsetLegend>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.providerLabel'])}
</span>
<Button
variant="ghost-accent"
size="small"
disabled={disabled}
className="h-auto px-0"
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.moreProviders'])}
</Button>
</div>
<RadioGroup<string>
value={activeProvider}
disabled={disabled}
className={cn(
'grid grid-cols-2 gap-2',
sourceType === 'websiteCrawl' ? 'sm:grid-cols-4' : 'sm:grid-cols-3',
)}
onValueChange={selectProvider}
>
{providers[sourceType].map((provider) => {
return (
<RadioItem<string>
key={provider.label}
value={provider.label}
className={cn(
'flex min-h-10 items-center gap-2 rounded-lg border border-divider-subtle bg-background-default px-3 system-xs-medium text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-components-option-card-option-selected-border data-checked:text-text-primary',
'data-disabled:cursor-not-allowed data-disabled:opacity-60',
)}
>
<span aria-hidden className={`${provider.icon} size-4 shrink-0`} />
<span className="truncate">{provider.label}</span>
</RadioItem>
)
})}
</RadioGroup>
</Fieldset>
{draft.sourceType === 'websiteCrawl' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field name="rootUrl" className="gap-1.5">
<FieldLabel>
{t(($) => $['newKnowledge.rootUrl'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
</FieldLabel>
<FieldControl
type="url"
inputMode="url"
autoComplete="off"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH}
value={draft.rootUrl}
placeholder={t(($) => $['newKnowledge.rootUrlPlaceholder'])}
size="large"
onValueChange={(value) => {
updateDraft({ ...draft, rootUrl: value })
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</Field>
<Field name="sourceName" className="gap-1.5">
<FieldLabel>
{t(($) => $['newKnowledge.sourceName'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
</FieldLabel>
<FieldControl
type="text"
autoComplete="off"
disabled={disabled}
maxLength={NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH}
value={draft.sourceName}
placeholder={t(($) => $['newKnowledge.sourceNamePlaceholder'])}
size="large"
onValueChange={(value) => {
updateDraft({ ...draft, sourceName: value })
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
</Field>
</div>
<Collapsible
open={optionsExpanded}
onOpenChange={setOptionsExpanded}
className="overflow-hidden rounded-lg bg-background-section"
>
<CollapsibleTrigger
aria-label={t(($) => $['newKnowledge.crawlOptions'])}
disabled={disabled}
className="min-h-9 justify-start px-3 system-xs-medium"
>
<span
aria-hidden
className="i-ri-arrow-right-s-line size-4 transition-transform group-data-panel-open:rotate-90 motion-reduce:transition-none"
/>
{t(($) => $['newKnowledge.crawlOptions'])}
{!optionsExpanded && (
<span className="ml-auto system-xs-regular text-text-tertiary">
{crawlOptionsAreDefault
? t(($) => $['newKnowledge.usingDefaults'])
: `${t(($) => $['newKnowledge.includeSubpages'])}: ${t(($) =>
draft.includeSubpages
? $['newKnowledge.booleanTrue']
: $['newKnowledge.booleanFalse'],
)} · ${t(($) => $['newKnowledge.maxPages'])}: ${draft.maxPages}`}
</span>
)}
</CollapsibleTrigger>
<CollapsiblePanel>
<Fieldset
disabled={disabled}
className="grid grid-cols-1 gap-3 px-3 pb-3 sm:grid-cols-2"
>
<label className="flex items-center gap-2 system-xs-regular text-text-secondary">
<Checkbox
name="includeSubpages"
checked={draft.includeSubpages}
disabled={disabled}
onCheckedChange={(checked) =>
updateDraft({ ...draft, includeSubpages: checked })
}
/>
{t(($) => $['newKnowledge.includeSubpages'])}
</label>
<div>
<span className="system-xs-medium text-text-secondary">
{t(($) => $['newKnowledge.maxPages'])}
</span>
<NumberField
disabled={disabled}
name="maxPages"
min={1}
max={200}
value={draft.maxPages}
onValueChange={(value) => updateDraft({ ...draft, maxPages: value ?? 0 })}
>
<NumberFieldGroup className="mt-1.5">
<NumberFieldInput
aria-label={t(($) => $['newKnowledge.maxPages'])}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
<NumberFieldControls>
<NumberFieldIncrement />
<NumberFieldDecrement />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div>
</Fieldset>
</CollapsiblePanel>
</Collapsible>
<Button
type="button"
variant="primary"
className="w-full"
disabled={disabled || !previewReady}
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.crawlAndPreview'])}
</Button>
<section
aria-label={t(($) => $['newKnowledge.crawlPreview'])}
className="flex min-h-40 flex-col items-center justify-center rounded-lg border border-dashed border-divider-regular px-6 text-center"
>
<span className="flex size-10 items-center justify-center rounded-lg bg-background-section">
<span aria-hidden className="i-ri-global-line size-5 text-text-tertiary" />
</span>
<p className="mt-2 system-xs-semibold text-text-primary">
{t(($) => $['newKnowledge.pagesAppearTitle'])}
</p>
<p className="mt-2 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.pagesAppearDescription'])}
</p>
</section>
</div>
)}
{draft.sourceType !== 'websiteCrawl' && (
<div className="space-y-3">
{draft.sourceType === 'onlineDocuments' && activeProvider === 'Notion' && (
<section className="rounded-lg border border-divider-subtle bg-background-default p-4">
<div className="flex flex-col items-start gap-3">
<span
aria-hidden
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg bg-background-section text-xl',
availableProviders.find((provider) => provider.label === activeProvider)?.icon,
)}
/>
<div className="min-w-0 flex-1">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.notionNotConnected'])}
</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.notionNotConnectedDescription'])}
</p>
</div>
</div>
<Button
variant="primary"
disabled={disabled}
className="mt-4"
onClick={showBackendBoundary}
>
{t(($) => $['newKnowledge.connectNotion'])}
</Button>
</section>
)}
{!(draft.sourceType === 'onlineDocuments' && activeProvider === 'Notion') && (
<ConnectedSourceConfiguration
disabled={disabled}
draft={draft}
onDraftChange={updateDraft}
/>
)}
</div>
)}
{backendBoundaryVisible && (
<p
role="alert"
className="rounded-md bg-components-badge-status-light-warning-bg px-3 py-2 system-xs-regular text-text-warning"
>
{t(($) => $['newKnowledge.sourceSetupBackendDependency'])}
</p>
)}
</div>
)
}