fix(web): discover source providers dynamically

This commit is contained in:
Stephen Zhou 2026-08-10 20:51:09 +08:00
parent b2956fd544
commit 62cebcf64d
No known key found for this signature in database
8 changed files with 545 additions and 303 deletions

View File

@ -495,6 +495,35 @@ const jinaDatasourceAuth: DatasourceProviderAuthListResponse['result'][number] =
provider: 'jinareader',
}
const customCrawlerDatasourcePlugin: DataSourceItem = {
...firecrawlDatasourcePlugin,
declaration: {
...firecrawlDatasourcePlugin.declaration,
datasources: [
{
description: { en_US: 'Acme crawler', zh_Hans: 'Acme crawler' },
identity: {
author: 'acme',
label: { en_US: 'Acme Crawler', zh_Hans: 'Acme Crawler' },
name: 'acme_crawler',
provider: 'acme',
},
parameters: [],
},
],
identity: {
...firecrawlDatasourcePlugin.declaration.identity,
author: 'acme',
description: { en_US: 'Acme crawler', zh_Hans: 'Acme crawler' },
label: { en_US: 'Acme Crawler', zh_Hans: 'Acme Crawler' },
name: 'acme_crawler',
},
},
plugin_id: 'acme/acme_crawler',
plugin_unique_identifier: 'acme/acme_crawler:1.0.0@local',
provider: 'acme',
}
const connection = (
status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked',
version = 2,
@ -698,10 +727,10 @@ describe('AddSourcePage', () => {
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeChecked()
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(screen.getByText('dataset.newKnowledge.providerUnavailable')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name: 'dataset.newKnowledge.configureProvider:{"provider":"Jina Reader"}',
name: 'plugin.installPlugin',
}),
)
expect(openMock).toHaveBeenCalledWith(
@ -742,7 +771,7 @@ describe('AddSourcePage', () => {
).not.toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'Jina Reader' }))
expect(screen.getByText('dataset.newKnowledge.providerUnavailable')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
expect(
screen.queryByRole('textbox', { name: 'dataset.newKnowledge.rootUrl' }),
).not.toBeInTheDocument()
@ -1133,14 +1162,16 @@ describe('AddSourcePage', () => {
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources')
})
it('renders every designed website provider and the provider-management action', async () => {
it('discovers installed website providers and keeps the provider-management action', async () => {
const user = userEvent.setup()
queryState.datasourcePlugins.data = [firecrawlDatasourcePlugin, customCrawlerDatasourcePlugin]
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('group', { name: 'dataset.newKnowledge.providerLabel' }),
).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'FakeCrawler' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'Acme Crawler' })).toBeEnabled()
expect(screen.queryByRole('radio', { name: 'FakeCrawler' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' }))
expect(openMock).toHaveBeenCalledWith(
'/integrations/data-source',

View File

@ -552,6 +552,25 @@ describe('ConnectedSourceSetup', () => {
)
})
it('distinguishes an uninstalled provider from an installed provider without credentials', async () => {
const user = userEvent.setup()
renderSetup({
...defaultDraft,
provider: 'Confluence',
})
expect(await screen.findByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.notionNotConnected')).not.toBeInTheDocument()
expect(clientMock.createConnection).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'plugin.installPlugin' }))
expect(openMock).toHaveBeenCalledWith(
'/integrations/data-source?package-ids=%5B%22langgenius%2Fconfluence_datasource%22%5D',
'_blank',
'noopener,noreferrer',
)
})
it('starts the selected Notion import and completes setup without waiting for indexing', async () => {
const user = userEvent.setup()
clientMock.listDatasourceAuth.mockResolvedValue({

View File

@ -35,8 +35,8 @@ describe('New RAG routes', () => {
).toBe('/datasets/new/space-1/sources/new?type=websiteCrawl&provider=Jina+Reader')
})
it('falls back when a provider does not belong to the selected source type', () => {
expect(createNewKnowledgeSourceDraft('onlineDrive', 'Confluence').provider).toBe('Google Drive')
it('keeps a dynamically discovered provider supplied by the add-source entry point', () => {
expect(createNewKnowledgeSourceDraft('onlineDrive', 'Acme Drive').provider).toBe('Acme Drive')
})
it('defaults website crawls to a daily sync policy', () => {

View File

@ -1,13 +1,9 @@
'use client'
import type { DatasourceProviderAuthListResponse } from '@dify/contracts/api/console/auth/types.gen'
import type {
NewKnowledgeSourceDraft,
NewKnowledgeSourceType,
NewKnowledgeWebsiteProvider,
} from './routes'
import type { NewKnowledgeSourceDraft, NewKnowledgeSourceType } from './routes'
import type { SourceConnection as Connection, SourceProvider as Provider } from './source-models'
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
import type { InstalledSourceProviderOption, SourceProviderOption } from './source-provider-options'
import { Button } from '@langgenius/dify-ui/button'
import { Field, FieldControl, FieldDescription, FieldLabel } from '@langgenius/dify-ui/field'
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
@ -43,7 +39,15 @@ import {
sourceConnectionListFromApi,
sourceProviderListFromApi,
} from './source-models'
import { SourceProviderRadioGroup, SourceTypeSelector } from './source-setup-fields'
import {
discoverSourceProviderOptions,
sourceProviderOptionForDraft,
} from './source-provider-options'
import {
SourceProviderNotInstalledCard,
SourceProviderRadioGroup,
SourceTypeSelector,
} from './source-setup-fields'
import { WebsiteCrawlPreview } from './website-crawl-preview'
type ProviderField = Provider['configuration'][number]
@ -52,37 +56,6 @@ type SourceType = NewKnowledgeSourceType
const CONNECTION_PAGE_SIZE = 200
const WEBSITE_SOURCE_PROVIDER_ID = 'plugin-daemon-website'
const WEBSITE_PROVIDER_OPTIONS: Array<{
aliases: string[]
icon: string
value: NewKnowledgeWebsiteProvider
}> = [
{
aliases: ['firecrawl'],
icon: 'i-custom-public-common-firecrawl',
value: 'Firecrawl',
},
{
aliases: ['jina', 'jina reader', 'jinareader'],
icon: 'i-custom-public-llm-jina',
value: 'Jina Reader',
},
{
aliases: ['watercrawl'],
icon: 'i-custom-public-knowledge-watercrawl',
value: 'WaterCrawl',
},
{
aliases: ['fakecrawler'],
icon: 'i-ri-global-line text-text-accent',
value: 'FakeCrawler',
},
]
const WEBSITE_PROVIDER_PACKAGE_IDS: Partial<Record<NewKnowledgeWebsiteProvider, string>> = {
Firecrawl: 'langgenius/firecrawl_datasource',
'Jina Reader': 'langgenius/jina_datasource',
WaterCrawl: 'watercrawl/watercrawl_datasource',
}
const MANAGED_PROVIDER_FIELD_NAMES = new Set([
'credentialId',
'datasource',
@ -110,20 +83,10 @@ function fieldValue(value: string, type: ProviderField['type']) {
return value.trim()
}
function normalizeProviderName(value: string) {
return value.toLocaleLowerCase().replace(/[^a-z0-9]+/g, '')
}
function websiteProviderOption(provider: NewKnowledgeWebsiteProvider) {
return WEBSITE_PROVIDER_OPTIONS.find((option) => option.value === provider)
}
function websiteProviderIntegrationPath(provider?: NewKnowledgeWebsiteProvider) {
function websiteProviderIntegrationPath(provider?: SourceProviderOption) {
const base = buildIntegrationPath('data-source')
if (!provider) return base
const packageId = WEBSITE_PROVIDER_PACKAGE_IDS[provider]
if (!packageId) return base
const query = new URLSearchParams({ 'package-ids': JSON.stringify([packageId]) })
const query = new URLSearchParams({ 'package-ids': JSON.stringify([provider.packageId]) })
return `${base}?${query.toString()}`
}
@ -131,38 +94,7 @@ function findWebsiteSourceProvider(providers: Provider[]) {
return providers.find((provider) => provider.id === WEBSITE_SOURCE_PROVIDER_ID)
}
function findWebsiteDatasourceProvider(
datasourcePlugins: DataSourceItem[],
providerName: NewKnowledgeWebsiteProvider,
) {
if (providerName === 'FakeCrawler') return undefined
const option = websiteProviderOption(providerName)
if (!option) return undefined
const aliases = [option.value, ...option.aliases].map(normalizeProviderName)
for (const plugin of datasourcePlugins) {
if (plugin.declaration.provider_type !== 'website_crawl') continue
const pluginIdentities = [
plugin.declaration.identity.label.en_US,
plugin.declaration.identity.name,
plugin.plugin_id,
plugin.provider,
].map(normalizeProviderName)
const datasource = plugin.declaration.datasources.find((candidate) => {
const datasourceIdentities = [
candidate.identity.label.en_US,
candidate.identity.name,
candidate.identity.provider,
].map(normalizeProviderName)
return [...pluginIdentities, ...datasourceIdentities].some((identity) =>
aliases.some((alias) => identity.includes(alias) || alias.includes(identity)),
)
})
if (datasource) return { datasource, plugin }
}
return undefined
}
type WebsiteDatasourceProvider = NonNullable<ReturnType<typeof findWebsiteDatasourceProvider>>
type WebsiteDatasourceProvider = InstalledSourceProviderOption
function findDatasourceAuth(
providers: DatasourceProviderAuthListResponse['result'],
@ -278,13 +210,15 @@ function getSupportedAuthKinds(provider: Provider, credentialId?: string) {
function ProviderSelector({
disabled = false,
onMoreProviders,
provider,
options,
providerKey,
onChange,
}: {
disabled?: boolean
onMoreProviders: () => void
provider: NewKnowledgeWebsiteProvider
onChange: (provider: NewKnowledgeWebsiteProvider) => void
options: SourceProviderOption[]
providerKey: string
onChange: (providerKey: string) => void
}) {
const { t } = useTranslation('dataset')
@ -307,19 +241,30 @@ function ProviderSelector({
</Button>
</div>
<SourceProviderRadioGroup
value={provider}
value={providerKey}
disabled={disabled}
layout="grid-four"
options={WEBSITE_PROVIDER_OPTIONS.map((option) => ({
icon: <span aria-hidden className={`${option.icon} size-4`} />,
value: option.value,
layout="wrap"
options={options.map((option) => ({
icon: <WebsiteProviderIcon option={option} />,
label: option.label,
value: option.key,
}))}
size="small"
onChange={onChange}
/>
</Fieldset>
)
}
function WebsiteProviderIcon({ option }: { option: SourceProviderOption }) {
const icon = option.installed
? (option.datasource.identity.icon ?? option.plugin.declaration.identity.icon)
: undefined
if (typeof icon === 'string' && icon)
return <img aria-hidden alt="" className="size-4 shrink-0 object-contain" src={icon} />
return <span aria-hidden className={`${option.fallbackIcon} size-4 shrink-0`} />
}
function ProviderFieldControl({
field,
setValues,
@ -421,7 +366,7 @@ function ConnectionForm({
onDraftChange: (dirty: boolean) => void
onReconcile: () => Promise<Connection | undefined>
provider: Provider
providerName: NewKnowledgeWebsiteProvider
providerName: string
credentialId?: string
}) {
const { t } = useTranslation('dataset')
@ -582,7 +527,7 @@ function ManagedProviderConnection({
onConnected: (connection: Connection) => void
onReconcile: () => Promise<Connection | undefined>
provider: Provider
providerName: NewKnowledgeWebsiteProvider
providerName: string
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
@ -687,6 +632,7 @@ function UnconfiguredProvider({
onDraftChange,
onReconcile,
provider,
providerOption,
providerName,
credentialId,
}: {
@ -697,7 +643,8 @@ function UnconfiguredProvider({
onDraftChange: (dirty: boolean) => void
onReconcile: () => Promise<Connection | undefined>
provider: Provider
providerName: NewKnowledgeWebsiteProvider
providerOption: InstalledSourceProviderOption
providerName: string
credentialId?: string
}) {
const { t } = useTranslation('dataset')
@ -733,10 +680,7 @@ function UnconfiguredProvider({
return (
<div className="flex flex-col items-start gap-2.5 rounded-xl bg-background-section p-4">
<span className="flex size-9 items-center justify-center rounded-lg border border-divider-subtle bg-background-default">
<span
aria-hidden
className={`${websiteProviderOption(providerName)?.icon ?? 'i-ri-global-line'} size-4.5`}
/>
<WebsiteProviderIcon option={providerOption} />
</span>
<h3 className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.providerNotConfigured'], {
@ -993,12 +937,13 @@ export function AddSourcePage({
}),
)
const provider = findWebsiteSourceProvider(providersQuery.data ?? [])
const websiteProviderName =
sourceDraft.sourceType === 'websiteCrawl' ? sourceDraft.provider : 'Firecrawl'
const datasourceProvider = useMemo(
() => findWebsiteDatasourceProvider(datasourcePluginsQuery.data ?? [], websiteProviderName),
[datasourcePluginsQuery.data, websiteProviderName],
const websiteProviderOptions = useMemo(
() => discoverSourceProviderOptions('websiteCrawl', datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data],
)
const websiteProviderOption = sourceProviderOptionForDraft(websiteProviderOptions, sourceDraft)
const websiteProviderName = websiteProviderOption?.label ?? sourceDraft.provider
const datasourceProvider = websiteProviderOption?.installed ? websiteProviderOption : undefined
const datasourceProviders = datasourceAuthQuery.data?.result ?? []
const datasourceCredential = findDatasourceCredential(datasourceProviders, datasourceProvider)
const difyManagedProvider = provider ? isDifyManagedProvider(provider) : false
@ -1368,12 +1313,21 @@ export function AddSourcePage({
<>
<ProviderSelector
disabled={websiteSetupLocked}
provider={sourceDraft.provider}
options={websiteProviderOptions}
providerKey={websiteProviderOption?.key ?? ''}
onMoreProviders={() =>
globalThis.open(websiteProviderIntegrationPath(), '_blank', 'noopener,noreferrer')
}
onChange={(provider) => {
updateSourceDraft({ ...sourceDraft, provider })
onChange={(providerKey) => {
const nextProvider = websiteProviderOptions.find(
(option) => option.key === providerKey,
)
if (!nextProvider) return
updateSourceDraft({
...sourceDraft,
provider: nextProvider.label,
providerKey: nextProvider.key,
})
}}
/>
{queryError ? (
@ -1395,34 +1349,22 @@ export function AddSourcePage({
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : !provider ? (
) : websiteProviderOption && !websiteProviderOption.installed ? (
<SourceProviderNotInstalledCard
icon={<WebsiteProviderIcon option={websiteProviderOption} />}
provider={websiteProviderOption.label}
onInstall={() =>
globalThis.open(
websiteProviderIntegrationPath(websiteProviderOption),
'_blank',
'noopener,noreferrer',
)
}
/>
) : !datasourceProvider || !provider ? (
<div className="rounded-xl bg-background-section p-4 system-sm-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</div>
) : !datasourceProvider ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{websiteProviderName}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</p>
{websiteProviderName !== 'FakeCrawler' && (
<Button
variant="primary"
className="mt-3"
onClick={() =>
globalThis.open(
websiteProviderIntegrationPath(websiteProviderName),
'_blank',
'noopener,noreferrer',
)
}
>
{t(($) => $['newKnowledge.configureProvider'], {
provider: websiteProviderName,
})}
</Button>
)}
</div>
) : !provider.available || !supportsDirectConnection ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{websiteProviderName}</p>
@ -1462,7 +1404,7 @@ export function AddSourcePage({
onConnected={rememberConnection}
onConfigureManagedProvider={() =>
globalThis.open(
websiteProviderIntegrationPath(websiteProviderName),
websiteProviderIntegrationPath(websiteProviderOption),
'_blank',
'noopener,noreferrer',
)
@ -1470,6 +1412,7 @@ export function AddSourcePage({
onDraftChange={setConnectionDraftDirty}
onReconcile={reconcileConnection}
provider={provider}
providerOption={datasourceProvider}
providerName={websiteProviderName}
/>
)}

View File

@ -5,13 +5,12 @@ import type {
KnowledgeFsSourcePageResponse,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type {
NewKnowledgeOnlineDocumentsProvider,
NewKnowledgeOnlineDocumentsSourceDraft,
NewKnowledgeOnlineDriveProvider,
NewKnowledgeOnlineDriveSourceDraft,
NewKnowledgeSourceDraft,
} from './routes'
import type { Source, SourceConnection, SourceProvider } from './source-models'
import type { InstalledSourceProviderOption, SourceProviderOption } from './source-provider-options'
import type {
DataSourceAuth,
DataSourceCredential,
@ -39,9 +38,15 @@ import {
sourceProviderListFromApi,
sourceSyncPolicyFromApi,
} from './source-models'
import {
discoverSourceProviderOptions,
normalizeSourceProviderName,
sourceProviderOptionForDraft,
} from './source-provider-options'
import {
SourceConnectionRequiredCard,
SourceNameField,
SourceProviderNotInstalledCard,
SourceProviderRadioGroup,
SourceSyncPolicyField,
} from './source-setup-fields'
@ -96,73 +101,25 @@ const CONNECTION_STATUS_PRIORITY: Record<SourceConnection['status'], number> = {
revoked: 4,
}
const providerOptions = {
onlineDocuments: [
{
aliases: ['notion'],
icon: 'i-custom-public-common-notion',
label: 'Notion',
},
{
aliases: ['google docs', 'googledocs', 'google drive', 'googledrive'],
icon: 'i-ri-file-text-fill text-[#4d8bf5]',
label: 'Google Docs',
},
{
aliases: ['confluence'],
icon: 'i-custom-public-common-confluence',
label: 'Confluence',
},
],
onlineDrive: [
{
aliases: ['google drive', 'googledrive'],
icon: 'i-custom-public-common-google-drive',
label: 'Google Drive',
},
{
aliases: ['onedrive', 'microsoft onedrive'],
icon: 'i-ri-cloud-line',
label: 'OneDrive',
},
{
aliases: ['amazon s3', 'amazons3', 's3'],
icon: 'i-ri-box-3-line',
label: 'Amazon S3',
},
],
} as const
const providerPackageIds: Record<ConnectedSourceDraft['provider'], string | undefined> = {
'Amazon S3': 'langgenius/aws_s3_storage',
Confluence: 'langgenius/confluence_datasource',
'Google Docs': 'langgenius/google_drive',
'Google Drive': 'langgenius/google_drive',
Notion: 'langgenius/notion_datasource',
OneDrive: 'langgenius/onedrive_datasource',
}
function normalizeProviderName(value: string) {
return value.toLocaleLowerCase().replace(/[^a-z0-9]+/g, '')
}
function usesDriveTransport(draft: ConnectedSourceDraft) {
return draft.sourceType === 'onlineDrive' || draft.provider === 'Google Docs'
}
function providerForDraft(providers: SourceProvider[], draft: ConnectedSourceDraft) {
function providerForDraft(
providers: SourceProvider[],
draft: ConnectedSourceDraft,
option?: SourceProviderOption,
) {
const capability = usesDriveTransport(draft) ? 'online-drive' : 'online-document'
const option = providerOptions[draft.sourceType].find((item) => item.label === draft.provider)
if (!option) return undefined
const aliases = new Set(
[option.label, ...option.aliases].map((candidate) => normalizeProviderName(candidate)),
[draft.provider, option?.label ?? ''].map(normalizeSourceProviderName).filter(Boolean),
)
const capableProviders = providers.filter((provider) =>
provider.capabilities.includes(capability),
)
return (
capableProviders.find((provider) => {
const names = [provider.id, provider.displayName].map(normalizeProviderName)
const names = [provider.id, provider.displayName].map(normalizeSourceProviderName)
return names.some(
(name) =>
aliases.has(name) ||
@ -176,45 +133,19 @@ function providerForDraft(providers: SourceProvider[], draft: ConnectedSourceDra
)
}
function datasourceProviderForDraft(
datasourcePlugins: DataSourceItem[],
draft: ConnectedSourceDraft,
) {
const option = providerOptions[draft.sourceType].find((item) => item.label === draft.provider)
if (!option) return undefined
const aliases = [option.label, ...option.aliases].map(normalizeProviderName)
const providerType = usesDriveTransport(draft) ? 'online_drive' : 'online_document'
for (const plugin of datasourcePlugins) {
if (plugin.declaration.provider_type !== providerType) continue
const identities = [
plugin.declaration.identity.label.en_US,
plugin.declaration.identity.name,
plugin.plugin_id,
plugin.provider,
].map(normalizeProviderName)
const datasource = plugin.declaration.datasources.find((action) => {
const actionIdentities = [
action.identity.label.en_US,
action.identity.name,
action.identity.provider,
].map(normalizeProviderName)
return [...identities, ...actionIdentities].some((identity) =>
aliases.some((alias) => identity.includes(alias) || alias.includes(identity)),
)
})
if (datasource)
return {
datasource,
plugin,
function datasourceProviderForOption(option?: SourceProviderOption) {
return option?.installed
? {
datasource: option.datasource,
plugin: option.plugin,
}
}
return undefined
: undefined
}
type ProviderBrandIconValue = DataSourceItem['declaration']['identity']['icon']
function datasourceProviderIcon(
datasourceProvider: ReturnType<typeof datasourceProviderForDraft>,
datasourceProvider: ReturnType<typeof datasourceProviderForOption>,
): ProviderBrandIconValue | undefined {
return datasourceProvider?.plugin.declaration.identity.icon
}
@ -222,7 +153,7 @@ function datasourceProviderIcon(
function credentialRegion(credential: DataSourceCredential | undefined) {
const region = Object.entries(credential?.credential ?? {}).find(
([key, value]) =>
['awsregion', 'region', 'regionname'].includes(normalizeProviderName(key)) &&
['awsregion', 'region', 'regionname'].includes(normalizeSourceProviderName(key)) &&
typeof value === 'string' &&
value.trim(),
)?.[1]
@ -231,7 +162,7 @@ function credentialRegion(credential: DataSourceCredential | undefined) {
function datasourceAuthForProvider(
providers: DataSourceAuth[],
datasourceProvider: ReturnType<typeof datasourceProviderForDraft>,
datasourceProvider: ReturnType<typeof datasourceProviderForOption>,
) {
if (!datasourceProvider) return undefined
return providers.find(
@ -250,7 +181,7 @@ function preferredCredential(provider?: DataSourceAuth) {
function connectionMatchesDatasource(
connection: SourceConnection,
datasourceProvider: ReturnType<typeof datasourceProviderForDraft>,
datasourceProvider: ReturnType<typeof datasourceProviderForOption>,
credential: DataSourceCredential | undefined,
) {
if (!datasourceProvider) return false
@ -266,7 +197,7 @@ function connectionMatchesDatasource(
function findProviderConnection(
connections: SourceConnection[],
providerId: string | undefined,
datasourceProvider: ReturnType<typeof datasourceProviderForDraft>,
datasourceProvider: ReturnType<typeof datasourceProviderForOption>,
credential: DataSourceCredential | undefined,
) {
if (!providerId || !datasourceProvider) return undefined
@ -285,16 +216,15 @@ function findProviderConnection(
)[0]
}
function providerIntegrationPath(draft: ConnectedSourceDraft) {
function providerIntegrationPath(option?: SourceProviderOption) {
const base = buildIntegrationPath('data-source')
const packageId = providerPackageIds[draft.provider]
if (!packageId) return base
const query = new URLSearchParams({ 'package-ids': JSON.stringify([packageId]) })
if (!option) return base
const query = new URLSearchParams({ 'package-ids': JSON.stringify([option.packageId]) })
return `${base}?${query.toString()}`
}
function providerScheme(providerName: string) {
const normalized = normalizeProviderName(providerName)
const normalized = normalizeSourceProviderName(providerName)
if (normalized.includes('notion')) return 'notion'
if (normalized.includes('googledocs')) return 'gdocs'
if (normalized.includes('googledrive')) return 'gdrive'
@ -312,7 +242,7 @@ function sourceUri(
const scheme = providerScheme(`${draft.provider} ${provider.id} ${provider.displayName}`)
if (scheme === 's3') {
const bucket = Object.entries(connection.configuration).find(([key]) =>
['bucket', 'bucketname'].includes(normalizeProviderName(key)),
['bucket', 'bucketname'].includes(normalizeSourceProviderName(key)),
)?.[1]
if (typeof bucket === 'string' && bucket.trim()) return `s3://${bucket.trim()}`
}
@ -386,18 +316,17 @@ function requestStatus(error: unknown) {
}
function ProviderSelector({
datasourcePlugins,
draft,
options,
providerKey,
onChange,
onMoreProviders,
}: {
datasourcePlugins: DataSourceItem[]
draft: ConnectedSourceDraft
onChange: (provider: string) => void
options: SourceProviderOption[]
providerKey: string
onChange: (providerKey: string) => void
onMoreProviders: () => void
}) {
const { t } = useTranslation('dataset')
const options = providerOptions[draft.sourceType]
return (
<Fieldset>
<div className="mb-2 flex items-center justify-between gap-3">
@ -416,27 +345,22 @@ function ProviderSelector({
</Button>
</div>
<SourceProviderRadioGroup
value={draft.provider}
value={providerKey}
layout="wrap"
options={options.map((option) => {
const optionProvider = datasourceProviderForDraft(datasourcePlugins, {
...draft,
provider: option.label,
} as ConnectedSourceDraft)
return {
icon: (
<ProviderBrandIcon
fallbackIcon={option.icon}
icon={
option.label === 'Google Docs'
? undefined
: datasourceProviderIcon(optionProvider)
}
/>
),
value: option.label,
}
})}
options={options.map((option) => ({
icon: (
<ProviderBrandIcon
fallbackIcon={option.fallbackIcon}
icon={
option.installed
? (option.datasource.identity.icon ?? option.plugin.declaration.identity.icon)
: undefined
}
/>
),
label: option.label,
value: option.key,
}))}
size="small"
onChange={onChange}
/>
@ -471,14 +395,15 @@ function ProviderBrandIcon({
function OAuthConnectionCard({
draft,
icon,
providerOption,
onConnect,
}: {
draft: ConnectedSourceDraft
icon?: ProviderBrandIconValue
providerOption: InstalledSourceProviderOption
onConnect: () => void
}) {
const { t } = useTranslation('dataset')
const option = providerOptions[draft.sourceType].find((item) => item.label === draft.provider)
return (
<SourceConnectionRequiredCard
actionLabel={t(($) => $['newKnowledge.connectProvider'], { provider: draft.provider })}
@ -489,7 +414,7 @@ function OAuthConnectionCard({
provider: draft.provider,
})
}
icon={<ProviderBrandIcon fallbackIcon={option?.icon ?? 'i-ri-links-line'} icon={icon} />}
icon={<ProviderBrandIcon fallbackIcon={providerOption.fallbackIcon} icon={icon} />}
title={
draft.provider === 'Notion'
? t(($) => $['newKnowledge.notionNotConnected'])
@ -1707,9 +1632,15 @@ export function ConnectedSourceSetup({
retry: false,
}),
)
const provider = providerForDraft(providersQuery.data ?? [], draft)
const providerOptions = useMemo(
() => discoverSourceProviderOptions(draft.sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, draft.sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const installedProviderOption = providerOption?.installed ? providerOption : undefined
const provider = providerForDraft(providersQuery.data ?? [], draft, providerOption)
const driveTransport = usesDriveTransport(draft)
const datasourceProvider = datasourceProviderForDraft(datasourcePluginsQuery.data ?? [], draft)
const datasourceProvider = datasourceProviderForOption(installedProviderOption)
const datasourceAuth = datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
datasourceProvider,
@ -1866,23 +1797,27 @@ export function ConnectedSourceSetup({
providersQuery.isPending,
provisionConnection,
])
const selectProvider = (nextProvider: string) => {
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
setConnectionOverride(undefined)
setProvisionError(false)
if (draft.sourceType === 'onlineDocuments') {
onDraftChange({
...draft,
provider: nextProvider as NewKnowledgeOnlineDocumentsProvider,
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
})
return
}
onDraftChange({
...draft,
provider: nextProvider as NewKnowledgeOnlineDriveProvider,
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
syncPolicy:
nextProvider === 'Amazon S3' && draft.syncPolicy === 'provider'
nextProvider.label === 'Amazon S3' && draft.syncPolicy === 'provider'
? 'daily'
: draft.syncPolicy,
})
@ -1890,8 +1825,8 @@ export function ConnectedSourceSetup({
return (
<div className="flex flex-col gap-4">
<ProviderSelector
datasourcePlugins={datasourcePluginsQuery.data ?? []}
draft={draft}
options={providerOptions}
providerKey={providerOption?.key ?? ''}
onChange={selectProvider}
onMoreProviders={() =>
globalThis.open(buildIntegrationPath('data-source'), '_blank', 'noopener,noreferrer')
@ -1923,7 +1858,19 @@ export function ConnectedSourceSetup({
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : !provider ? (
) : providerOption && !providerOption.installed ? (
<SourceProviderNotInstalledCard
icon={<ProviderBrandIcon fallbackIcon={providerOption.fallbackIcon} />}
provider={providerOption.label}
onInstall={() =>
globalThis.open(
providerIntegrationPath(providerOption),
'_blank',
'noopener,noreferrer',
)
}
/>
) : !installedProviderOption || !provider ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{draft.provider}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
@ -1977,7 +1924,11 @@ export function ConnectedSourceSetup({
<Button
variant="primary"
onClick={() =>
globalThis.open(providerIntegrationPath(draft), '_blank', 'noopener,noreferrer')
globalThis.open(
providerIntegrationPath(installedProviderOption),
'_blank',
'noopener,noreferrer',
)
}
>
{t(($) => $['newKnowledge.connectProvider'], { provider: draft.provider })}
@ -2018,12 +1969,18 @@ export function ConnectedSourceSetup({
<OAuthConnectionCard
draft={draft}
icon={datasourceProviderIcon(datasourceProvider)}
providerOption={installedProviderOption}
onConnect={() =>
globalThis.open(providerIntegrationPath(draft), '_blank', 'noopener,noreferrer')
globalThis.open(
providerIntegrationPath(installedProviderOption),
'_blank',
'noopener,noreferrer',
)
}
/>
)}
{!connection &&
Boolean(installedProviderOption) &&
provider?.available &&
!provisioningConnection &&
!queryError &&

View File

@ -1,9 +1,9 @@
export type NewKnowledgeStartMode = 'empty' | 'source' | 'upload'
export type NewKnowledgeSourceType = 'onlineDocuments' | 'onlineDrive' | 'websiteCrawl'
type NewKnowledgeSyncPolicy = 'daily' | 'manual' | 'provider'
export type NewKnowledgeWebsiteProvider = 'FakeCrawler' | 'Firecrawl' | 'Jina Reader' | 'WaterCrawl'
export type NewKnowledgeOnlineDocumentsProvider = 'Confluence' | 'Google Docs' | 'Notion'
export type NewKnowledgeOnlineDriveProvider = 'Amazon S3' | 'Google Drive' | 'OneDrive'
export type NewKnowledgeWebsiteProvider = string
export type NewKnowledgeOnlineDocumentsProvider = string
export type NewKnowledgeOnlineDriveProvider = string
export type NewKnowledgeSourceProvider =
| NewKnowledgeOnlineDocumentsProvider
| NewKnowledgeOnlineDriveProvider
@ -12,6 +12,7 @@ export type NewKnowledgeSourceProvider =
type NewKnowledgeSourceDraftBase = {
sourceName: string
syncPolicy: NewKnowledgeSyncPolicy
providerKey?: string
}
export type NewKnowledgeWebsiteSourceDraft = NewKnowledgeSourceDraftBase & {
@ -39,6 +40,8 @@ export type NewKnowledgeSourceDraft =
export const NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH = 200
export const NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH = 2048
const NEW_KNOWLEDGE_PROVIDER_NAME_MAX_LENGTH = 200
const NEW_KNOWLEDGE_PROVIDER_KEY_MAX_LENGTH = 1024
const NEW_KNOWLEDGE_SOURCE_DRAFT_STORAGE_PREFIX = 'new-knowledge-source-draft:'
export function createNewKnowledgeSourceDraft(
@ -47,18 +50,14 @@ export function createNewKnowledgeSourceDraft(
): NewKnowledgeSourceDraft {
if (sourceType === 'onlineDocuments')
return {
provider: ['Confluence', 'Google Docs', 'Notion'].includes(initialProvider ?? '')
? (initialProvider as NewKnowledgeOnlineDocumentsProvider)
: 'Notion',
provider: initialProvider?.trim() || 'Notion',
sourceName: '',
sourceType,
syncPolicy: 'provider',
}
if (sourceType === 'onlineDrive')
return {
provider: ['Amazon S3', 'Google Drive', 'OneDrive'].includes(initialProvider ?? '')
? (initialProvider as NewKnowledgeOnlineDriveProvider)
: 'Google Drive',
provider: initialProvider?.trim() || 'Google Drive',
sourceName: '',
sourceType,
syncPolicy: 'provider',
@ -66,11 +65,7 @@ export function createNewKnowledgeSourceDraft(
return {
includeSubpages: true,
maxPages: 100,
provider: ['FakeCrawler', 'Firecrawl', 'Jina Reader', 'WaterCrawl'].includes(
initialProvider ?? '',
)
? (initialProvider as NewKnowledgeWebsiteProvider)
: 'Firecrawl',
provider: initialProvider?.trim() || 'Firecrawl',
rootUrl: '',
sourceName: '',
sourceType,
@ -135,24 +130,29 @@ export function parseNewKnowledgeSourceDraft(value: string): NewKnowledgeSourceD
if (
typeof candidate.sourceName !== 'string' ||
candidate.sourceName.length > NEW_KNOWLEDGE_SOURCE_NAME_MAX_LENGTH ||
typeof candidate.provider !== 'string' ||
!candidate.provider.trim() ||
candidate.provider.length > NEW_KNOWLEDGE_PROVIDER_NAME_MAX_LENGTH ||
(candidate.providerKey !== undefined &&
(typeof candidate.providerKey !== 'string' ||
!candidate.providerKey ||
candidate.providerKey.length > NEW_KNOWLEDGE_PROVIDER_KEY_MAX_LENGTH)) ||
!syncPolicy
)
return undefined
if (candidate.sourceType === 'onlineDocuments') {
if (!['Confluence', 'Google Docs', 'Notion'].includes(String(candidate.provider)))
return undefined
return {
provider: candidate.provider as NewKnowledgeOnlineDocumentsProvider,
provider: candidate.provider,
...(candidate.providerKey ? { providerKey: candidate.providerKey } : {}),
sourceName: candidate.sourceName,
sourceType: candidate.sourceType,
syncPolicy,
}
}
if (candidate.sourceType === 'onlineDrive') {
if (!['Amazon S3', 'Google Drive', 'OneDrive'].includes(String(candidate.provider)))
return undefined
return {
provider: candidate.provider as NewKnowledgeOnlineDriveProvider,
provider: candidate.provider,
...(candidate.providerKey ? { providerKey: candidate.providerKey } : {}),
sourceName: candidate.sourceName,
sourceType: candidate.sourceType,
syncPolicy,
@ -160,9 +160,6 @@ export function parseNewKnowledgeSourceDraft(value: string): NewKnowledgeSourceD
}
if (
(candidate.sourceType !== undefined && candidate.sourceType !== 'websiteCrawl') ||
!['FakeCrawler', 'Firecrawl', 'Jina Reader', 'WaterCrawl'].includes(
String(candidate.provider),
) ||
typeof candidate.includeSubpages !== 'boolean' ||
typeof candidate.maxPages !== 'number' ||
!Number.isInteger(candidate.maxPages) ||
@ -175,7 +172,8 @@ export function parseNewKnowledgeSourceDraft(value: string): NewKnowledgeSourceD
return {
includeSubpages: candidate.includeSubpages,
maxPages: candidate.maxPages,
provider: candidate.provider as NewKnowledgeWebsiteProvider,
provider: candidate.provider,
...(candidate.providerKey ? { providerKey: candidate.providerKey } : {}),
rootUrl: candidate.rootUrl,
sourceName: candidate.sourceName,
sourceType: 'websiteCrawl',

View File

@ -0,0 +1,266 @@
import type { NewKnowledgeSourceDraft, NewKnowledgeSourceType } from './routes'
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
type Datasource = DataSourceItem['declaration']['datasources'][number]
type RecommendedProvider = {
aliases: string[]
fallbackIcon: string
label: string
packageId: string
providerType: DataSourceItem['declaration']['provider_type']
sourceType: NewKnowledgeSourceType
}
export type InstalledSourceProviderOption = {
datasource: Datasource
fallbackIcon: string
installed: true
key: string
label: string
packageId: string
plugin: DataSourceItem
providerType: DataSourceItem['declaration']['provider_type']
sourceType: NewKnowledgeSourceType
}
export type UninstalledSourceProviderOption = {
fallbackIcon: string
installed: false
key: string
label: string
packageId: string
providerType: DataSourceItem['declaration']['provider_type']
sourceType: NewKnowledgeSourceType
}
export type SourceProviderOption = InstalledSourceProviderOption | UninstalledSourceProviderOption
const recommendedProviders: RecommendedProvider[] = [
{
aliases: ['firecrawl'],
fallbackIcon: 'i-custom-public-common-firecrawl',
label: 'Firecrawl',
packageId: 'langgenius/firecrawl_datasource',
providerType: 'website_crawl',
sourceType: 'websiteCrawl',
},
{
aliases: ['jina', 'jina reader', 'jinareader'],
fallbackIcon: 'i-custom-public-llm-jina',
label: 'Jina Reader',
packageId: 'langgenius/jina_datasource',
providerType: 'website_crawl',
sourceType: 'websiteCrawl',
},
{
aliases: ['watercrawl'],
fallbackIcon: 'i-custom-public-knowledge-watercrawl',
label: 'WaterCrawl',
packageId: 'watercrawl/watercrawl_datasource',
providerType: 'website_crawl',
sourceType: 'websiteCrawl',
},
{
aliases: ['notion'],
fallbackIcon: 'i-custom-public-common-notion',
label: 'Notion',
packageId: 'langgenius/notion_datasource',
providerType: 'online_document',
sourceType: 'onlineDocuments',
},
{
aliases: ['google docs', 'googledocs', 'google drive', 'googledrive'],
fallbackIcon: 'i-ri-file-text-fill text-[#4d8bf5]',
label: 'Google Docs',
packageId: 'langgenius/google_drive',
providerType: 'online_drive',
sourceType: 'onlineDocuments',
},
{
aliases: ['confluence'],
fallbackIcon: 'i-custom-public-common-confluence',
label: 'Confluence',
packageId: 'langgenius/confluence_datasource',
providerType: 'online_document',
sourceType: 'onlineDocuments',
},
{
aliases: ['google drive', 'googledrive'],
fallbackIcon: 'i-custom-public-common-google-drive',
label: 'Google Drive',
packageId: 'langgenius/google_drive',
providerType: 'online_drive',
sourceType: 'onlineDrive',
},
{
aliases: ['onedrive', 'microsoft onedrive'],
fallbackIcon: 'i-ri-cloud-line',
label: 'OneDrive',
packageId: 'langgenius/onedrive_datasource',
providerType: 'online_drive',
sourceType: 'onlineDrive',
},
{
aliases: ['amazon s3', 'amazons3', 's3'],
fallbackIcon: 'i-ri-box-3-line',
label: 'Amazon S3',
packageId: 'langgenius/aws_s3_storage',
providerType: 'online_drive',
sourceType: 'onlineDrive',
},
]
export function normalizeSourceProviderName(value: string) {
return value.toLocaleLowerCase().replace(/[^a-z0-9]+/g, '')
}
function providerKey(
sourceType: NewKnowledgeSourceType,
plugin: DataSourceItem,
datasource: Datasource,
) {
return `${sourceType}:${plugin.plugin_id}:${plugin.provider}:${datasource.identity.name}`
}
function datasourceMatchesAliases(
plugin: DataSourceItem,
datasource: Datasource,
aliases: string[],
) {
const normalizedAliases = aliases.map(normalizeSourceProviderName)
return [
plugin.declaration.identity.label.en_US,
plugin.declaration.identity.name,
plugin.provider,
datasource.identity.label.en_US,
datasource.identity.name,
datasource.identity.provider,
]
.map(normalizeSourceProviderName)
.some((identity) =>
normalizedAliases.some((alias) => identity.includes(alias) || alias.includes(identity)),
)
}
function installedRecommendedProvider(
definition: RecommendedProvider,
datasourcePlugins: DataSourceItem[],
): InstalledSourceProviderOption | undefined {
const plugin = datasourcePlugins.find(
(candidate) =>
candidate.plugin_id === definition.packageId &&
candidate.declaration.provider_type === definition.providerType,
)
if (!plugin) return undefined
const datasource =
plugin.declaration.datasources.find((candidate) =>
datasourceMatchesAliases(plugin, candidate, definition.aliases),
) ?? plugin.declaration.datasources[0]
if (!datasource) return undefined
return {
datasource,
fallbackIcon: definition.fallbackIcon,
installed: true,
key: providerKey(definition.sourceType, plugin, datasource),
label: definition.label,
packageId: definition.packageId,
plugin,
providerType: definition.providerType,
sourceType: definition.sourceType,
}
}
function sourceTypeForProviderType(
providerType: DataSourceItem['declaration']['provider_type'],
): NewKnowledgeSourceType | undefined {
if (providerType === 'website_crawl') return 'websiteCrawl'
if (providerType === 'online_document') return 'onlineDocuments'
if (providerType === 'online_drive') return 'onlineDrive'
return undefined
}
function fallbackIcon(sourceType: NewKnowledgeSourceType) {
if (sourceType === 'websiteCrawl') return 'i-ri-global-line'
if (sourceType === 'onlineDocuments') return 'i-ri-file-text-line'
return 'i-ri-hard-drive-3-line'
}
function uniqueLabel(label: string, pluginLabel: string, labels: Set<string>) {
if (!labels.has(label)) return label
const qualified = `${label} (${pluginLabel})`
if (!labels.has(qualified)) return qualified
let suffix = 2
while (labels.has(`${qualified} ${suffix}`)) suffix += 1
return `${qualified} ${suffix}`
}
export function discoverSourceProviderOptions(
sourceType: NewKnowledgeSourceType,
datasourcePlugins: DataSourceItem[],
): SourceProviderOption[] {
const definitions = recommendedProviders.filter(
(definition) => definition.sourceType === sourceType,
)
const recommended: SourceProviderOption[] = definitions.map((definition) => {
const installed = installedRecommendedProvider(definition, datasourcePlugins)
return (
installed ?? {
fallbackIcon: definition.fallbackIcon,
installed: false as const,
key: `${sourceType}:marketplace:${definition.packageId}`,
label: definition.label,
packageId: definition.packageId,
providerType: definition.providerType,
sourceType,
}
)
})
const consumedKeys = new Set(
recommended.flatMap((option) => (option.installed ? [option.key] : [])),
)
const labels = new Set(recommended.map((option) => option.label))
const discovered: InstalledSourceProviderOption[] = []
for (const plugin of datasourcePlugins) {
const discoveredSourceType = sourceTypeForProviderType(plugin.declaration.provider_type)
if (discoveredSourceType !== sourceType) continue
for (const datasource of plugin.declaration.datasources) {
const key = providerKey(sourceType, plugin, datasource)
if (consumedKeys.has(key)) continue
const rawLabel =
datasource.identity.label.en_US ||
plugin.declaration.identity.label.en_US ||
datasource.identity.name
const label = uniqueLabel(rawLabel, plugin.declaration.identity.label.en_US, labels)
labels.add(label)
discovered.push({
datasource,
fallbackIcon: fallbackIcon(sourceType),
installed: true,
key,
label,
packageId: plugin.plugin_id,
plugin,
providerType: plugin.declaration.provider_type,
sourceType,
})
}
}
return [...recommended, ...discovered]
}
export function sourceProviderOptionForDraft(
options: SourceProviderOption[],
draft: Pick<NewKnowledgeSourceDraft, 'provider' | 'providerKey'>,
) {
return (
options.find((option) => option.key === draft.providerKey) ??
options.find(
(option) =>
normalizeSourceProviderName(option.label) === normalizeSourceProviderName(draft.provider),
) ??
options[0]
)
}

View File

@ -95,7 +95,7 @@ export function SourceProviderRadioGroup<T extends string>({
}: {
disabled?: boolean
layout: 'grid-four' | 'grid-three' | 'wrap'
options: Array<{ disabled?: boolean; icon: ReactNode; value: T }>
options: Array<{ disabled?: boolean; icon: ReactNode; label?: ReactNode; value: T }>
size?: 'medium' | 'small'
surface?: 'default' | 'transparent'
value: T
@ -127,7 +127,7 @@ export function SourceProviderRadioGroup<T extends string>({
)}
>
{option.icon}
<span className="truncate">{option.value}</span>
<span className="truncate">{option.label ?? option.value}</span>
</RadioItem>
))}
</RadioGroup>
@ -274,3 +274,31 @@ export function SourceConnectionRequiredCard({
</section>
)
}
export function SourceProviderNotInstalledCard({
icon,
provider,
onInstall,
}: {
icon: ReactNode
provider: string
onInstall: () => void
}) {
const { t: tPlugin } = useTranslation('plugin')
const { t: tWorkflow } = useTranslation('workflow')
return (
<section className="flex min-h-44 flex-col items-start gap-2.5 rounded-xl bg-background-section p-4">
<span className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-background-default">
{icon}
</span>
<h3 className="system-sm-semibold text-text-primary">{provider}</h3>
<p className="system-xs-regular text-text-tertiary">
{tWorkflow(($) => $['nodes.common.pluginNotInstalled'])}
</p>
<Button type="button" variant="primary" className="mt-auto" onClick={onInstall}>
{tPlugin(($) => $.installPlugin)}
</Button>
</section>
)
}