feat(knowledge-fs): create spaces with initial sources

This commit is contained in:
Stephen Zhou 2026-08-10 22:08:01 +08:00
parent 62cebcf64d
commit e4e9271fbe
No known key found for this signature in database
17 changed files with 2467 additions and 555 deletions

View File

@ -48,6 +48,7 @@ from services.knowledge_fs.control_plane_service import (
from services.knowledge_fs.credential_service import (
KnowledgeFSCredentialPolicyError,
)
from services.knowledge_fs.initial_source_preview import KnowledgeFSInitialSourcePreviewService
from services.knowledge_fs.product_authorization import (
KnowledgeFSProductNotFoundError,
)
@ -100,6 +101,8 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSGoldenQuestionPayload,
KnowledgeFSGoldenQuestionResponse,
KnowledgeFSIdempotencyHeader,
KnowledgeFSInitialSourcePreviewPayload,
KnowledgeFSInitialSourcePreviewResponse,
KnowledgeFSJWKSResponse,
KnowledgeFSLogicalDocumentDeletePayload,
KnowledgeFSLogicalDocumentListResponse,
@ -246,6 +249,7 @@ register_schema_models(
KnowledgeFSSourceConnectionRefreshPayload,
KnowledgeFSCrawlImportPayload,
KnowledgeFSCrawlPreviewSelectionPayload,
KnowledgeFSInitialSourcePreviewPayload,
KnowledgeFSSourceDeletePayload,
KnowledgeFSSourceDeleteQuery,
KnowledgeFSSourceFilesQuery,
@ -340,6 +344,7 @@ register_response_schema_models(
KnowledgeFSOverviewQueryOutcomesResponse,
KnowledgeFSOverviewStatsResponse,
KnowledgeFSPresignedUploadResponse,
KnowledgeFSInitialSourcePreviewResponse,
KnowledgeFSUploadSessionCreateResponse,
KnowledgeFSUploadSessionMutationResponse,
)
@ -579,6 +584,28 @@ def _overview_stats_response(
)
@console_ns.route("/knowledge-fs/source-provider-preview")
class KnowledgeFSInitialSourcePreviewApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeFSInitialSourcePreviewPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
"Datasource resources available for an initial Source",
console_ns.models[KnowledgeFSInitialSourcePreviewResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self):
account, tenant_id = current_account_with_tenant()
result = KnowledgeFSInitialSourcePreviewService(session_factory.get_session_maker()).preview(
tenant_id=tenant_id,
account=account,
payload=_payload(KnowledgeFSInitialSourcePreviewPayload),
)
return dump_response(KnowledgeFSInitialSourcePreviewResponse, result)
@console_ns.route("/knowledge-fs/spaces")
class KnowledgeFSSpacesApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSSpaceListQuery))

View File

@ -0,0 +1,177 @@
"""Read-only datasource discovery used before a KnowledgeFS Space exists."""
from __future__ import annotations
import json
from typing import Any, cast
from sqlalchemy import select
from core.datasource.datasource_manager import DatasourceManager
from core.datasource.entities.datasource_entities import (
DatasourceProviderType,
OnlineDriveBrowseFilesRequest,
)
from core.datasource.online_document.online_document_plugin import OnlineDocumentDatasourcePlugin
from core.datasource.online_drive.online_drive_plugin import OnlineDriveDatasourcePlugin
from models.account import Account
from models.credential_permission import CredentialType
from models.oauth import DatasourceProvider
from services.credential_permission_service import CredentialPermissionService
from services.datasource_provider_service import DatasourceProviderService
from services.knowledge_fs.product_dto import (
KnowledgeFSInitialSourcePreviewDocumentResponse,
KnowledgeFSInitialSourcePreviewFileResponse,
KnowledgeFSInitialSourcePreviewPayload,
KnowledgeFSInitialSourcePreviewResponse,
)
_MAX_PREVIEW_ITEMS = 200
class KnowledgeFSInitialSourcePreviewService:
def __init__(self, session_maker) -> None:
self._session_maker = session_maker
def _require_visible_credential(
self,
*,
tenant_id: str,
account: Account,
payload: KnowledgeFSInitialSourcePreviewPayload,
) -> None:
query = select(DatasourceProvider).where(
DatasourceProvider.tenant_id == tenant_id,
DatasourceProvider.id == payload.credential_id,
DatasourceProvider.provider == payload.provider,
DatasourceProvider.plugin_id == payload.plugin_id,
)
query = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=DatasourceProvider.id,
model_user_id_column=DatasourceProvider.user_id,
model_visibility_column=DatasourceProvider.visibility,
credential_type=CredentialType.DATASOURCE_PROVIDER,
user=account,
)
with self._session_maker() as session:
if session.scalar(query.limit(1)) is None:
raise PermissionError("Datasource credential is unavailable")
def preview(
self,
*,
tenant_id: str,
account: Account,
payload: KnowledgeFSInitialSourcePreviewPayload,
) -> KnowledgeFSInitialSourcePreviewResponse:
self._require_visible_credential(tenant_id=tenant_id, account=account, payload=payload)
credentials = DatasourceProviderService().get_datasource_credentials(
tenant_id=tenant_id,
provider=payload.provider,
plugin_id=payload.plugin_id,
credential_id=payload.credential_id,
)
if not credentials:
raise PermissionError("Datasource credential is unavailable")
provider_type = DatasourceProviderType(payload.kind)
runtime = DatasourceManager.get_datasource_runtime(
provider_id=f"{payload.plugin_id}/{payload.provider}",
datasource_name=payload.datasource,
tenant_id=tenant_id,
datasource_type=provider_type,
)
runtime.runtime.credentials = credentials
parameters = dict(payload.parameters)
if payload.kind == "online_document":
document_runtime = cast(OnlineDocumentDatasourcePlugin, runtime)
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = []
for document_message in document_runtime.get_online_document_pages(
user_id=account.id,
datasource_parameters=parameters,
provider_type=document_runtime.datasource_provider_type(),
):
for workspace in document_message.result:
workspace_id = workspace.workspace_id or payload.provider
for page in workspace.pages:
documents.append(
KnowledgeFSInitialSourcePreviewDocumentResponse(
last_edited_time=page.last_edited_time,
name=page.page_name,
page_id=page.page_id,
provider_item_id=json.dumps([workspace_id, page.page_id], separators=(",", ":")),
type=page.type,
workspace_id=workspace_id,
workspace_name=workspace.workspace_name,
)
)
if len(documents) >= _MAX_PREVIEW_ITEMS:
return KnowledgeFSInitialSourcePreviewResponse(
documents=documents,
kind=payload.kind,
)
return KnowledgeFSInitialSourcePreviewResponse(documents=documents, kind=payload.kind)
drive_runtime = cast(OnlineDriveDatasourcePlugin, runtime)
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = []
next_page_parameters = None
max_keys = parameters.get("max_keys", _MAX_PREVIEW_ITEMS)
if not isinstance(max_keys, int) or isinstance(max_keys, bool):
max_keys = _MAX_PREVIEW_ITEMS
max_keys = min(max(max_keys, 1), _MAX_PREVIEW_ITEMS)
bucket = parameters.get("bucket")
prefix = parameters.get("prefix")
raw_next_page_parameters = parameters.get("next_page_parameters")
request = OnlineDriveBrowseFilesRequest(
bucket=bucket if isinstance(bucket, str) else None,
prefix=prefix if isinstance(prefix, str) else "",
max_keys=max_keys,
next_page_parameters=(
cast(dict[str, Any], raw_next_page_parameters) if isinstance(raw_next_page_parameters, dict) else None
),
)
for drive_message in drive_runtime.online_drive_browse_files(
user_id=account.id,
request=request,
provider_type=drive_runtime.datasource_provider_type(),
):
for group in drive_message.result:
if group.is_truncated and group.next_page_parameters:
next_page_parameters = group.next_page_parameters
if group.bucket and not group.files:
files.append(
KnowledgeFSInitialSourcePreviewFileResponse(
bucket=group.bucket,
id="",
name=group.bucket,
provider_item_id=json.dumps([group.bucket, ""], separators=(",", ":")),
size=0,
type="bucket",
)
)
for file in group.files:
files.append(
KnowledgeFSInitialSourcePreviewFileResponse(
bucket=group.bucket,
id=file.id,
mime_type=file.type if "/" in file.type else None,
name=file.name,
provider_item_id=json.dumps([group.bucket or "", file.id], separators=(",", ":")),
size=file.size,
type=file.type,
)
)
if len(files) >= _MAX_PREVIEW_ITEMS:
return KnowledgeFSInitialSourcePreviewResponse(
files=files,
kind=payload.kind,
next_page_parameters=next_page_parameters,
)
return KnowledgeFSInitialSourcePreviewResponse(
files=files,
kind=payload.kind,
next_page_parameters=next_page_parameters,
)
__all__ = ["KnowledgeFSInitialSourcePreviewService"]

View File

@ -111,14 +111,14 @@ class KnowledgeFSProductApplicationService:
visibility=payload.visibility,
)
if payload.initial_source is not None:
from tasks.knowledge_fs_initial_source_tasks import import_initial_website_source
from tasks.knowledge_fs_initial_source_tasks import import_initial_source
import_initial_website_source.delay(
import_initial_source.delay(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=result.control_space.id,
operation_id=operation_id,
payload=payload.initial_source.model_dump(mode="json"),
payload=payload.initial_source.model_dump(mode="json", exclude_none=True),
)
return KnowledgeFSSpaceCreateResponse(
control_space_id=result.control_space.id,

View File

@ -113,16 +113,51 @@ class KnowledgeFSInitialWebsiteSelectionPayload(BaseModel):
return source_url.strip()
class KnowledgeFSOnlineDocumentWorkflowImportItemPayload(BaseModel):
etag: str | None = Field(default=None, max_length=1_024)
last_edited_time: str | None = Field(default=None, max_length=128, alias="lastEditedTime")
name: str | None = Field(default=None, max_length=500)
page_id: str = Field(min_length=1, max_length=1_024, alias="pageId")
provider_item_id: str = Field(min_length=1, max_length=1_024, alias="providerItemId")
type: str = Field(min_length=1, max_length=128)
workspace_id: str = Field(min_length=1, max_length=1_024, alias="workspaceId")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSOnlineDriveWorkflowImportItemPayload(BaseModel):
bucket: str | None = Field(default=None, max_length=1_024)
etag: str | None = Field(default=None, max_length=1_024)
id: str = Field(min_length=1, max_length=1_024)
mime_type: str | None = Field(default=None, max_length=255, alias="mimeType")
name: str = Field(min_length=1, max_length=500)
provider_item_id: str = Field(min_length=1, max_length=1_024, alias="providerItemId")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSInitialDatasourceBindingPayload(BaseModel):
credential_id: str | None = Field(default=None, min_length=1, max_length=255, alias="credentialId")
datasource: str = Field(min_length=1, max_length=255)
plugin_id: str = Field(min_length=1, max_length=255, alias="pluginId")
provider: str = Field(min_length=1, max_length=255)
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSInitialWebsiteSourcePayload(BaseModel):
kind: Literal["website_crawl"]
name: str = Field(min_length=1, max_length=200)
provider: Literal["firecrawl"]
provider: str = Field(min_length=1, max_length=255)
plugin_id: str | None = Field(default=None, min_length=1, max_length=255, alias="pluginId")
datasource: str = Field(default="crawl", min_length=1, max_length=255)
credential_id: str | None = Field(default=None, min_length=1, max_length=255, alias="credentialId")
root_url: str = Field(min_length=1, max_length=4_096)
crawl_options: KnowledgeFSInitialWebsiteCrawlOptionsPayload
selection: list[KnowledgeFSInitialWebsiteSelectionPayload] = Field(min_length=1, max_length=200)
sync_policy: Literal["provider", "daily", "manual"] = "provider"
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
@model_validator(mode="after")
def validate_selection(self) -> KnowledgeFSInitialWebsiteSourcePayload:
@ -132,6 +167,68 @@ class KnowledgeFSInitialWebsiteSourcePayload(BaseModel):
return self
class KnowledgeFSInitialOnlineDocumentSourcePayload(KnowledgeFSInitialDatasourceBindingPayload):
credential_id: str = Field(min_length=1, max_length=255, alias="credentialId")
kind: Literal["online_document"]
name: str = Field(min_length=1, max_length=200)
selection: list[KnowledgeFSOnlineDocumentWorkflowImportItemPayload] = Field(min_length=1, max_length=200)
sync_policy: Literal["provider", "daily", "manual"] = "provider"
class KnowledgeFSInitialOnlineDriveSourcePayload(KnowledgeFSInitialDatasourceBindingPayload):
credential_id: str = Field(min_length=1, max_length=255, alias="credentialId")
kind: Literal["online_drive"]
name: str = Field(min_length=1, max_length=200)
selection: list[KnowledgeFSOnlineDriveWorkflowImportItemPayload] = Field(min_length=1, max_length=200)
sync_policy: Literal["provider", "daily", "manual"] = "provider"
class KnowledgeFSInitialSourcePreviewPayload(KnowledgeFSInitialDatasourceBindingPayload):
credential_id: str = Field(min_length=1, max_length=255, alias="credentialId")
kind: Literal["online_document", "online_drive"]
parameters: dict[str, JsonValue] = Field(default_factory=dict, max_length=50)
class KnowledgeFSInitialSourcePreviewDocumentResponse(ResponseModel):
last_edited_time: str | None = Field(
default=None, validation_alias=AliasChoices("last_edited_time", "lastEditedTime")
)
name: str
page_id: str = Field(validation_alias=AliasChoices("page_id", "pageId"))
provider_item_id: str = Field(validation_alias=AliasChoices("provider_item_id", "providerItemId"))
type: str
workspace_id: str = Field(validation_alias=AliasChoices("workspace_id", "workspaceId"))
workspace_name: str | None = Field(default=None, validation_alias=AliasChoices("workspace_name", "workspaceName"))
class KnowledgeFSInitialSourcePreviewFileResponse(ResponseModel):
bucket: str | None = None
id: str
mime_type: str | None = Field(default=None, validation_alias=AliasChoices("mime_type", "mimeType"))
name: str
provider_item_id: str = Field(validation_alias=AliasChoices("provider_item_id", "providerItemId"))
size: int = Field(ge=0)
type: str
class KnowledgeFSInitialSourcePreviewResponse(ResponseModel):
documents: list[KnowledgeFSInitialSourcePreviewDocumentResponse] = Field(default_factory=list)
files: list[KnowledgeFSInitialSourcePreviewFileResponse] = Field(default_factory=list)
kind: Literal["online_document", "online_drive"]
next_page_parameters: dict[str, JsonValue] | None = Field(
default=None,
validation_alias=AliasChoices("next_page_parameters", "nextPageParameters"),
)
KnowledgeFSInitialSourcePayload = Annotated[
KnowledgeFSInitialWebsiteSourcePayload
| KnowledgeFSInitialOnlineDocumentSourcePayload
| KnowledgeFSInitialOnlineDriveSourcePayload,
Field(discriminator="kind"),
]
class KnowledgeFSSpaceCreatePayload(BaseModel):
name: str = Field(min_length=1, max_length=40)
slug: str = Field(min_length=1, max_length=160, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
@ -141,7 +238,7 @@ class KnowledgeFSSpaceCreatePayload(BaseModel):
embedding: KnowledgeFSModelIntent | None = None
retrieval: KnowledgeFSRetrievalProfileIntent | None = None
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
initial_source: KnowledgeFSInitialWebsiteSourcePayload | None = None
initial_source: KnowledgeFSInitialSourcePayload | None = None
model_config = ConfigDict(extra="forbid")
@ -1573,29 +1670,6 @@ class KnowledgeFSSourceWorkflowResponse(ResponseModel):
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
class KnowledgeFSOnlineDocumentWorkflowImportItemPayload(BaseModel):
etag: str | None = Field(default=None, max_length=1_024)
last_edited_time: str | None = Field(default=None, max_length=128, alias="lastEditedTime")
name: str | None = Field(default=None, max_length=500)
page_id: str = Field(min_length=1, max_length=1_024, alias="pageId")
provider_item_id: str = Field(min_length=1, max_length=1_024, alias="providerItemId")
type: str = Field(min_length=1, max_length=128)
workspace_id: str = Field(min_length=1, max_length=1_024, alias="workspaceId")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSOnlineDriveWorkflowImportItemPayload(BaseModel):
bucket: str | None = Field(default=None, max_length=1_024)
etag: str | None = Field(default=None, max_length=1_024)
id: str = Field(min_length=1, max_length=1_024)
mime_type: str | None = Field(default=None, max_length=255, alias="mimeType")
name: str = Field(min_length=1, max_length=500)
provider_item_id: str = Field(min_length=1, max_length=1_024, alias="providerItemId")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSOnlineDocumentWorkflowImportPayload(BaseModel):
items: list[KnowledgeFSOnlineDocumentWorkflowImportItemPayload] = Field(min_length=1, max_length=200)
kind: Literal["online-document-import"]
@ -2798,6 +2872,14 @@ __all__ = [
"KnowledgeFSExternalAccessPayload",
"KnowledgeFSExternalAccessResponse",
"KnowledgeFSIdempotencyHeader",
"KnowledgeFSInitialDatasourceBindingPayload",
"KnowledgeFSInitialOnlineDocumentSourcePayload",
"KnowledgeFSInitialOnlineDriveSourcePayload",
"KnowledgeFSInitialSourcePayload",
"KnowledgeFSInitialSourcePreviewDocumentResponse",
"KnowledgeFSInitialSourcePreviewFileResponse",
"KnowledgeFSInitialSourcePreviewPayload",
"KnowledgeFSInitialSourcePreviewResponse",
"KnowledgeFSInitialWebsiteCrawlOptionsPayload",
"KnowledgeFSInitialWebsiteSelectionPayload",
"KnowledgeFSInitialWebsiteSourcePayload",
@ -2809,6 +2891,8 @@ __all__ = [
"KnowledgeFSMemberBindingPayload",
"KnowledgeFSMembersReplacePayload",
"KnowledgeFSModelIntent",
"KnowledgeFSOnlineDocumentWorkflowImportItemPayload",
"KnowledgeFSOnlineDriveWorkflowImportItemPayload",
"KnowledgeFSOverviewBaseStatsResponse",
"KnowledgeFSOverviewCountComparisonResponse",
"KnowledgeFSOverviewHealthComponentResponse",

View File

@ -1,44 +1,110 @@
"""Durable follow-up that starts the first website import after Space provisioning."""
"""Durable follow-up that imports the first Source after Space provisioning."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Literal
from celery import shared_task
from pydantic import TypeAdapter
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.account import Account
from models.credential_permission import CredentialType
from models.knowledge_fs import KnowledgeFSControlSpaceState
from models.oauth import DatasourceProvider
from repositories.sqlalchemy_knowledge_fs_control_space_repository import (
SQLAlchemyKnowledgeFSControlSpaceRepository,
)
from services.credential_permission_service import CredentialPermissionService
from services.knowledge_fs.product_dto import (
KnowledgeFSCrawlImportPayload,
KnowledgeFSInitialOnlineDocumentSourcePayload,
KnowledgeFSInitialSourcePayload,
KnowledgeFSInitialWebsiteSourcePayload,
KnowledgeFSOnlineDocumentWorkflowImportPayload,
KnowledgeFSOnlineDriveWorkflowImportPayload,
KnowledgeFSSourceConnectionCreatePayload,
KnowledgeFSSourceCreatePayload,
KnowledgeFSSourceSyncPolicyPayload,
KnowledgeFSSourceUpdatePayload,
KnowledgeFSSourceWorkflowImportPayload,
)
from services.knowledge_fs.product_remote import KnowledgeFSProductResourceNotFoundError
from services.knowledge_fs.runtime import get_knowledge_fs_runtime
_FIRECRAWL_PROVIDER_ID = "plugin-daemon-website"
_FIRECRAWL_PLUGIN_ID = "langgenius/firecrawl_datasource"
_LEGACY_WEBSITE_PLUGIN_IDS = {
"firecrawl": "langgenius/firecrawl_datasource",
"jinareader": "langgenius/jina_datasource",
"watercrawl": "watercrawl/watercrawl_datasource",
}
_PAGE_SIZE = 200
_INITIAL_SOURCE_ADAPTER: TypeAdapter[KnowledgeFSInitialSourcePayload] = TypeAdapter(KnowledgeFSInitialSourcePayload)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _DatasourceBinding:
credential_id: str | None
datasource: str
plugin_id: str
provider: str
provider_id: str
provider_kind: str
class KnowledgeFSInitialSourceNotReadyError(RuntimeError):
"""The Space or Source workflow is still progressing and should be retried."""
"""The Space, connection, or Source workflow is progressing and should be retried."""
def __init__(self, message: str, *, workflow_id: str | None = None) -> None:
super().__init__(message)
self.workflow_id = workflow_id
def _binding(payload: KnowledgeFSInitialSourcePayload) -> _DatasourceBinding:
if isinstance(payload, KnowledgeFSInitialWebsiteSourcePayload):
normalized_provider = "".join(character for character in payload.provider.lower() if character.isalnum())
plugin_id = payload.plugin_id or _LEGACY_WEBSITE_PLUGIN_IDS.get(normalized_provider)
if plugin_id is None:
raise RuntimeError("Website datasource plugin binding is required")
return _DatasourceBinding(
credential_id=payload.credential_id,
datasource=payload.datasource,
plugin_id=plugin_id,
provider=payload.provider,
provider_id="plugin-daemon-website",
provider_kind="website",
)
if isinstance(payload, KnowledgeFSInitialOnlineDocumentSourcePayload):
return _DatasourceBinding(
credential_id=payload.credential_id,
datasource=payload.datasource,
plugin_id=payload.plugin_id,
provider=payload.provider,
provider_id="plugin-daemon-online-document",
provider_kind="online-document",
)
return _DatasourceBinding(
credential_id=payload.credential_id,
datasource=payload.datasource,
plugin_id=payload.plugin_id,
provider=payload.provider,
provider_id="plugin-daemon-online-drive",
provider_kind="online-drive",
)
def _request_id(*, operation_id: str, payload: KnowledgeFSInitialSourcePayload) -> str:
# Preserve the original website request ID so retries from the previous rollout
# reconcile with the same provisional Source.
if isinstance(payload, KnowledgeFSInitialWebsiteSourcePayload):
return f"initial-website-source:{operation_id}"
return f"initial-source:{operation_id}"
def _find_initial_source(*, facade, tenant_id: str, account_id: str, control_space_id: str, request_id: str):
cursor: str | None = None
while True:
@ -57,29 +123,41 @@ def _find_initial_source(*, facade, tenant_id: str, account_id: str, control_spa
cursor = response.next_cursor
def _find_firecrawl_credential(*, session_maker, tenant_id: str) -> tuple[str, str]:
def _find_credential(*, session_maker, tenant_id: str, account_id: str, binding: _DatasourceBinding) -> tuple[str, str]:
query = select(DatasourceProvider).where(
DatasourceProvider.tenant_id == tenant_id,
DatasourceProvider.provider == binding.provider,
DatasourceProvider.plugin_id == binding.plugin_id,
)
with session_maker() as session:
account = session.get(Account, account_id)
if account is None:
raise RuntimeError("Initial Source account was not found")
query = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=DatasourceProvider.id,
model_user_id_column=DatasourceProvider.user_id,
model_visibility_column=DatasourceProvider.visibility,
credential_type=CredentialType.DATASOURCE_PROVIDER,
user=account,
)
if binding.credential_id is not None:
query = query.where(DatasourceProvider.id == binding.credential_id)
credential = session.scalar(
select(DatasourceProvider)
.where(
DatasourceProvider.tenant_id == tenant_id,
DatasourceProvider.provider == "firecrawl",
DatasourceProvider.plugin_id == _FIRECRAWL_PLUGIN_ID,
)
.order_by(DatasourceProvider.is_default.desc(), DatasourceProvider.created_at.asc())
.limit(1)
query.order_by(DatasourceProvider.is_default.desc(), DatasourceProvider.created_at.asc()).limit(1)
)
if credential is None:
raise RuntimeError("Firecrawl credential is unavailable")
return str(credential.id), credential.name or "Firecrawl"
raise RuntimeError("Datasource credential is unavailable")
return str(credential.id), credential.name or binding.provider
def _find_or_create_firecrawl_connection(
def _find_or_create_connection(
*,
facade,
tenant_id: str,
account_id: str,
control_space_id: str,
binding: _DatasourceBinding,
credential_id: str,
credential_name: str,
):
@ -88,9 +166,16 @@ def _find_or_create_firecrawl_connection(
account_id=account_id,
control_space_id=control_space_id,
)
if not any(provider.id == _FIRECRAWL_PROVIDER_ID and provider.available for provider in providers.data):
raise RuntimeError("Firecrawl provider is unavailable")
if not any(provider.id == binding.provider_id and provider.available for provider in providers.data):
raise RuntimeError(f"{binding.provider_kind} provider is unavailable")
expected_configuration: dict[str, bool | int | str] = {
"credentialId": credential_id,
"datasource": binding.datasource,
"pluginId": binding.plugin_id,
"provider": binding.provider,
"providerKind": binding.provider_kind,
}
cursor: str | None = None
while True:
response = facade.list_source_connections(
@ -101,16 +186,15 @@ def _find_or_create_firecrawl_connection(
limit=_PAGE_SIZE,
)
for connection in response.data:
if (
connection.provider_id != _FIRECRAWL_PROVIDER_ID
or connection.configuration.get("credentialId") != credential_id
):
if connection.provider_id != binding.provider_id:
continue
if any(connection.configuration.get(key) != value for key, value in expected_configuration.items()):
continue
if connection.status == "active":
return connection
if connection.status == "provisioning":
raise KnowledgeFSInitialSourceNotReadyError("Firecrawl connection is still provisioning")
raise RuntimeError(f"Firecrawl connection is unavailable in state {connection.status}")
raise KnowledgeFSInitialSourceNotReadyError("Datasource connection is still provisioning")
raise RuntimeError(f"Datasource connection is unavailable in state {connection.status}")
if not response.next_cursor:
break
cursor = response.next_cursor
@ -121,33 +205,131 @@ def _find_or_create_firecrawl_connection(
control_space_id=control_space_id,
payload=KnowledgeFSSourceConnectionCreatePayload(
authKind="endpoint",
configuration={
"credentialId": credential_id,
"datasource": "crawl",
"pluginId": _FIRECRAWL_PLUGIN_ID,
"provider": "firecrawl",
"providerKind": "website",
},
configuration=expected_configuration,
credentials={},
name=credential_name,
providerId=_FIRECRAWL_PROVIDER_ID,
providerId=binding.provider_id,
),
)
if connection.status != "active":
raise KnowledgeFSInitialSourceNotReadyError("Firecrawl connection is still provisioning")
raise KnowledgeFSInitialSourceNotReadyError("Datasource connection is still provisioning")
return connection
def start_initial_website_source_import(
def _source_payload(
*,
payload: KnowledgeFSInitialSourcePayload,
binding: _DatasourceBinding,
connection_id: str,
request_id: str,
) -> KnowledgeFSSourceCreatePayload:
metadata: dict[str, object] = {
"clientRequestId": request_id,
"preview": True,
"providerId": binding.provider_id,
"providerKind": binding.provider_kind,
"providerName": payload.provider,
}
if isinstance(payload, KnowledgeFSInitialWebsiteSourcePayload):
metadata["crawlOptions"] = {
"includeSubpages": payload.crawl_options.include_subpages,
"limit": payload.crawl_options.limit,
}
source_type: Literal["connector", "web"] = "web"
uri = payload.root_url
else:
source_type = "connector"
uri = f"connector://{connection_id}"
return KnowledgeFSSourceCreatePayload(
connectionId=connection_id,
metadata=metadata,
name=payload.name,
status="disabled",
type=source_type,
uri=uri,
)
def _start_workflow(
*,
facade,
tenant_id: str,
account_id: str,
control_space_id: str,
source_id: str,
request_id: str,
payload: KnowledgeFSInitialSourcePayload,
):
if isinstance(payload, KnowledgeFSInitialWebsiteSourcePayload):
return facade.import_selected_source_crawl(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
source_id=source_id,
payload=KnowledgeFSCrawlImportPayload(
sourceUrls=[selection.source_url for selection in payload.selection],
),
idempotency_key=f"{request_id}:crawl-import",
)
if isinstance(payload, KnowledgeFSInitialOnlineDocumentSourcePayload):
import_payload = KnowledgeFSSourceWorkflowImportPayload(
KnowledgeFSOnlineDocumentWorkflowImportPayload(
kind="online-document-import",
items=payload.selection,
)
)
else:
import_payload = KnowledgeFSSourceWorkflowImportPayload(
KnowledgeFSOnlineDriveWorkflowImportPayload(
kind="online-drive-import",
items=payload.selection,
)
)
return facade.import_source_workflow(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
source_id=source_id,
payload=import_payload,
idempotency_key=f"{request_id}:connector-import",
)
def _sync_policy_payload(
*, payload: KnowledgeFSInitialSourcePayload, expected_revision: int, source_version: int
) -> KnowledgeFSSourceSyncPolicyPayload:
if payload.sync_policy == "manual":
return KnowledgeFSSourceSyncPolicyPayload(
enabled=False,
mode="manual",
expectedRevision=expected_revision,
expectedSourceVersion=source_version,
)
if payload.sync_policy == "daily":
return KnowledgeFSSourceSyncPolicyPayload(
enabled=True,
mode="interval",
expectedRevision=expected_revision,
expectedSourceVersion=source_version,
)
return KnowledgeFSSourceSyncPolicyPayload(
enabled=True,
mode="provider",
expectedRevision=expected_revision,
expectedSourceVersion=source_version,
)
def start_initial_source_import(
*,
tenant_id: str,
account_id: str,
control_space_id: str,
operation_id: str,
payload: KnowledgeFSInitialWebsiteSourcePayload,
payload: KnowledgeFSInitialSourcePayload,
workflow_id: str | None = None,
) -> str:
"""Idempotently create the provisional Source and start its selected crawl import."""
"""Idempotently create a provisional Source, import its selection, and commit it."""
session_maker = session_factory.get_session_maker()
with session_maker() as session:
@ -173,10 +355,10 @@ def start_initial_website_source_import(
run_id=workflow_id,
)
if workflow.source_id is None:
raise RuntimeError("Initial website Source import workflow has no Source")
raise RuntimeError("Initial Source import workflow has no Source")
source_id = workflow.source_id
else:
request_id = f"initial-website-source:{operation_id}"
request_id = _request_id(operation_id=operation_id, payload=payload)
source = _find_initial_source(
facade=facade,
tenant_id=tenant_id,
@ -185,15 +367,19 @@ def start_initial_website_source_import(
request_id=request_id,
)
if source is None:
credential_id, credential_name = _find_firecrawl_credential(
binding = _binding(payload)
credential_id, credential_name = _find_credential(
session_maker=session_maker,
tenant_id=tenant_id,
account_id=account_id,
binding=binding,
)
connection = _find_or_create_firecrawl_connection(
connection = _find_or_create_connection(
facade=facade,
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
binding=binding,
credential_id=credential_id,
credential_name=credential_name,
)
@ -201,38 +387,27 @@ def start_initial_website_source_import(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
payload=KnowledgeFSSourceCreatePayload(
connectionId=connection.id,
metadata={
"clientRequestId": request_id,
"crawlOptions": {
"includeSubpages": payload.crawl_options.include_subpages,
"limit": payload.crawl_options.limit,
},
"preview": True,
"providerId": _FIRECRAWL_PROVIDER_ID,
},
name=payload.name,
status="disabled",
type="web",
uri=payload.root_url,
payload=_source_payload(
payload=payload,
binding=binding,
connection_id=connection.id,
request_id=request_id,
),
)
source_id = source.id
workflow = facade.import_selected_source_crawl(
workflow = _start_workflow(
facade=facade,
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
source_id=source_id,
payload=KnowledgeFSCrawlImportPayload(
sourceUrls=[selection.source_url for selection in payload.selection],
),
idempotency_key=f"{request_id}:crawl-import",
request_id=request_id,
payload=payload,
)
if workflow.state in {"queued", "running", "crawling", "importing", "syncing"}:
raise KnowledgeFSInitialSourceNotReadyError(
"Initial website Source import is still running",
"Initial Source import is still running",
workflow_id=workflow.id,
)
if workflow.state != "completed":
@ -269,7 +444,7 @@ def start_initial_website_source_import(
),
)
logger.error(
"Initial website Source import failed",
"Initial Source import failed",
extra={
"control_space_id": control_space_id,
"error_code": workflow.last_error_code,
@ -311,43 +486,99 @@ def start_initial_website_source_import(
expected_revision = current_policy.revision
except KnowledgeFSProductResourceNotFoundError:
expected_revision = 0
if payload.sync_policy == "manual":
sync_policy = KnowledgeFSSourceSyncPolicyPayload(
enabled=False,
mode="manual",
expectedRevision=expected_revision,
expectedSourceVersion=committed_source.version,
)
elif payload.sync_policy == "daily":
sync_policy = KnowledgeFSSourceSyncPolicyPayload(
enabled=True,
mode="interval",
expectedRevision=expected_revision,
expectedSourceVersion=committed_source.version,
)
else:
sync_policy = KnowledgeFSSourceSyncPolicyPayload(
enabled=True,
mode="provider",
expectedRevision=expected_revision,
expectedSourceVersion=committed_source.version,
)
facade.update_source_sync_policy(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
source_id=source_id,
payload=sync_policy,
payload=_sync_policy_payload(
payload=payload,
expected_revision=expected_revision,
source_version=committed_source.version,
),
)
return workflow.id
@shared_task(
bind=True,
queue="knowledge_fs_lifecycle",
max_retries=180,
default_retry_delay=2,
)
def start_initial_website_source_import(
*,
tenant_id: str,
account_id: str,
control_space_id: str,
operation_id: str,
payload: KnowledgeFSInitialWebsiteSourcePayload,
workflow_id: str | None = None,
) -> str:
"""Compatibility wrapper for callers using the original website-only protocol."""
return start_initial_source_import(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id=operation_id,
payload=payload,
workflow_id=workflow_id,
)
def _run_initial_source_task(
task,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
operation_id: str,
payload: dict[str, object],
workflow_id: str | None,
) -> str:
try:
return start_initial_source_import(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id=operation_id,
payload=_INITIAL_SOURCE_ADAPTER.validate_python(payload),
workflow_id=workflow_id,
)
except KnowledgeFSInitialSourceNotReadyError as exc:
if exc.workflow_id is not None:
raise task.retry(
exc=exc,
kwargs={
"tenant_id": tenant_id,
"account_id": account_id,
"control_space_id": control_space_id,
"operation_id": operation_id,
"payload": payload,
"workflow_id": exc.workflow_id,
},
)
raise task.retry(exc=exc)
@shared_task(bind=True, queue="knowledge_fs_lifecycle", max_retries=180, default_retry_delay=2)
def import_initial_source(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
operation_id: str,
payload: dict[str, object],
workflow_id: str | None = None,
) -> str:
return _run_initial_source_task(
self,
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id=operation_id,
payload=payload,
workflow_id=workflow_id,
)
@shared_task(bind=True, queue="knowledge_fs_lifecycle", max_retries=180, default_retry_delay=2)
def import_initial_website_source(
self,
*,
@ -358,6 +589,8 @@ def import_initial_website_source(
payload: dict[str, object],
workflow_id: str | None = None,
) -> str:
"""Compatibility task for already-enqueued website-only messages."""
try:
return start_initial_website_source_import(
tenant_id=tenant_id,
@ -383,4 +616,9 @@ def import_initial_website_source(
raise self.retry(exc=exc)
__all__ = ["import_initial_website_source", "start_initial_website_source_import"]
__all__ = [
"import_initial_source",
"import_initial_website_source",
"start_initial_source_import",
"start_initial_website_source_import",
]

View File

@ -0,0 +1,184 @@
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from core.datasource.entities.datasource_entities import (
OnlineDocumentInfo,
OnlineDocumentPage,
OnlineDocumentPagesMessage,
OnlineDriveBrowseFilesResponse,
OnlineDriveFile,
OnlineDriveFileBucket,
)
from models.account import Account
from services.knowledge_fs.initial_source_preview import KnowledgeFSInitialSourcePreviewService
from services.knowledge_fs.product_dto import KnowledgeFSInitialSourcePreviewPayload
_CREDENTIAL = object()
def _service(credential=_CREDENTIAL) -> tuple[KnowledgeFSInitialSourcePreviewService, MagicMock]:
session = MagicMock()
session.scalar.return_value = credential
context = MagicMock()
context.__enter__.return_value = session
return KnowledgeFSInitialSourcePreviewService(MagicMock(return_value=context)), session
def _payload(kind: str) -> KnowledgeFSInitialSourcePreviewPayload:
return KnowledgeFSInitialSourcePreviewPayload.model_validate(
{
"credentialId": "credential-1",
"datasource": "pages" if kind == "online_document" else "drive",
"kind": kind,
"parameters": {},
"pluginId": "langgenius/provider",
"provider": "provider",
}
)
def test_preview_lists_online_documents_with_stable_import_identity() -> None:
service, _session = _service()
runtime = MagicMock()
runtime.datasource_provider_type.return_value = "online_document"
runtime.get_online_document_pages.return_value = [
OnlineDocumentPagesMessage(
result=[
OnlineDocumentInfo(
pages=[
OnlineDocumentPage(
last_edited_time="2026-08-10T00:00:00Z",
page_id="page-1",
page_name="Roadmap",
parent_id=None,
type="page",
)
],
total=1,
workspace_id="workspace-1",
workspace_name="Product",
)
]
)
]
with (
patch(
"services.knowledge_fs.initial_source_preview.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "secret"},
),
patch(
"services.knowledge_fs.initial_source_preview.DatasourceManager.get_datasource_runtime",
return_value=runtime,
),
):
response = service.preview(
tenant_id="tenant-1",
account=cast(Account, SimpleNamespace(id="account-1")),
payload=_payload("online_document"),
)
assert response.kind == "online_document"
assert response.documents[0].provider_item_id == '["workspace-1","page-1"]'
assert response.documents[0].name == "Roadmap"
assert response.files == []
assert runtime.runtime.credentials == {"token": "secret"}
def test_preview_browses_online_drive_and_preserves_pagination() -> None:
service, _session = _service()
runtime = MagicMock()
runtime.datasource_provider_type.return_value = "online_drive"
runtime.online_drive_browse_files.return_value = [
OnlineDriveBrowseFilesResponse(
result=[
OnlineDriveFileBucket(
bucket="manuals",
files=[
OnlineDriveFile(
id="file-1",
name="Plan.pdf",
size=128,
type="application/pdf",
)
],
is_truncated=True,
next_page_parameters={"cursor": "next"},
)
]
)
]
payload = _payload("online_drive")
payload.parameters = {"prefix": "folder-1"}
with (
patch(
"services.knowledge_fs.initial_source_preview.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "secret"},
),
patch(
"services.knowledge_fs.initial_source_preview.DatasourceManager.get_datasource_runtime",
return_value=runtime,
),
):
response = service.preview(
tenant_id="tenant-1",
account=cast(Account, SimpleNamespace(id="account-1")),
payload=payload,
)
assert response.kind == "online_drive"
assert response.files[0].provider_item_id == '["manuals","file-1"]'
assert response.files[0].mime_type == "application/pdf"
assert response.next_page_parameters == {"cursor": "next"}
request = runtime.online_drive_browse_files.call_args.kwargs["request"]
assert request.prefix == "folder-1"
def test_preview_exposes_an_empty_drive_bucket_as_a_browsable_container() -> None:
service, _session = _service()
runtime = MagicMock()
runtime.datasource_provider_type.return_value = "online_drive"
runtime.online_drive_browse_files.return_value = [
OnlineDriveBrowseFilesResponse(
result=[OnlineDriveFileBucket(bucket="manuals", files=[], is_truncated=False)]
)
]
with (
patch(
"services.knowledge_fs.initial_source_preview.DatasourceProviderService.get_datasource_credentials",
return_value={"token": "secret"},
),
patch(
"services.knowledge_fs.initial_source_preview.DatasourceManager.get_datasource_runtime",
return_value=runtime,
),
):
response = service.preview(
tenant_id="tenant-1",
account=cast(Account, SimpleNamespace(id="account-1")),
payload=_payload("online_drive"),
)
assert response.files[0].bucket == "manuals"
assert response.files[0].id == ""
assert response.files[0].provider_item_id == '["manuals",""]'
assert response.files[0].type == "bucket"
def test_preview_rejects_a_credential_hidden_from_the_account() -> None:
service, _session = _service(credential=None)
with (
patch(
"services.knowledge_fs.initial_source_preview.DatasourceProviderService.get_datasource_credentials"
) as get_credentials,
pytest.raises(PermissionError, match="credential is unavailable"),
):
service.preview(
tenant_id="tenant-1",
account=cast(Account, SimpleNamespace(id="account-1")),
payload=_payload("online_document"),
)
get_credentials.assert_not_called()

View File

@ -245,7 +245,7 @@ def test_product_application_create_schedules_selected_website_import() -> None:
"services.knowledge_fs.product_application_service.uuid.uuid5",
return_value="operation-1",
),
patch("tasks.knowledge_fs_initial_source_tasks.import_initial_website_source.delay") as schedule_import,
patch("tasks.knowledge_fs_initial_source_tasks.import_initial_source.delay") as schedule_import,
):
application.create_space(tenant_id="tenant-1", account_id="account-1", payload=payload)
@ -258,6 +258,7 @@ def test_product_application_create_schedules_selected_website_import() -> None:
"kind": "website_crawl",
"name": "Dify docs",
"provider": "firecrawl",
"datasource": "crawl",
"root_url": "https://docs.dify.ai",
"crawl_options": {"include_subpages": True, "limit": 25},
"selection": [
@ -271,6 +272,58 @@ def test_product_application_create_schedules_selected_website_import() -> None:
)
def test_product_application_create_schedules_selected_connector_import() -> None:
application, _product, _control_plane, _commands, _facade, _rbac = _application()
payload = _create_payload(
initial_source={
"kind": "online_document",
"name": "Product wiki",
"pluginId": "langgenius/notion_datasource",
"provider": "notion",
"datasource": "pages",
"credentialId": "credential-1",
"selection": [
{
"name": "Roadmap",
"pageId": "page-1",
"providerItemId": "notion:page-1",
"type": "page",
"workspaceId": "workspace-1",
}
],
}
)
with (
patch(
"services.knowledge_fs.product_application_service.uuid.uuid5",
return_value="operation-1",
),
patch("tasks.knowledge_fs_initial_source_tasks.import_initial_source.delay") as schedule_import,
):
application.create_space(tenant_id="tenant-1", account_id="account-1", payload=payload)
scheduled_payload = schedule_import.call_args.kwargs["payload"]
assert scheduled_payload == {
"credential_id": "credential-1",
"datasource": "pages",
"kind": "online_document",
"name": "Product wiki",
"plugin_id": "langgenius/notion_datasource",
"provider": "notion",
"selection": [
{
"name": "Roadmap",
"page_id": "page-1",
"provider_item_id": "notion:page-1",
"type": "page",
"workspace_id": "workspace-1",
}
],
"sync_policy": "provider",
}
def test_product_application_create_generates_idempotency_and_skips_default_visibility_update() -> None:
application, _product, control_plane, commands, _facade, _rbac = _application()

View File

@ -45,6 +45,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSourceListQuery,
KnowledgeFSSourceUpdatePayload,
KnowledgeFSSourceWorkflowImportPayload,
KnowledgeFSSpaceCreatePayload,
KnowledgeFSSpaceListItemResponse,
KnowledgeFSStatResponse,
KnowledgeFSTraceResponse,
@ -55,6 +56,75 @@ from services.knowledge_fs.product_dto import (
)
def test_space_create_initial_source_is_a_backward_compatible_discriminated_union() -> None:
website = KnowledgeFSSpaceCreatePayload.model_validate(
{
"name": "Docs",
"slug": "docs",
"initial_source": {
"kind": "website_crawl",
"name": "Website",
"provider": "firecrawl",
"root_url": "https://docs.example.com",
"crawl_options": {},
"selection": [{"source_url": "https://docs.example.com/start"}],
},
}
)
assert website.initial_source is not None
assert website.initial_source.kind == "website_crawl"
document = KnowledgeFSSpaceCreatePayload.model_validate(
{
"name": "Docs",
"slug": "docs",
"initial_source": {
"kind": "online_document",
"name": "Wiki",
"pluginId": "langgenius/notion_datasource",
"provider": "notion",
"datasource": "pages",
"credentialId": "credential-1",
"selection": [
{
"pageId": "page-1",
"providerItemId": "notion:page-1",
"type": "page",
"workspaceId": "workspace-1",
}
],
},
}
)
assert document.initial_source is not None
assert document.initial_source.kind == "online_document"
assert document.initial_source.credential_id == "credential-1"
def test_connector_initial_source_requires_an_exact_credential_binding() -> None:
with pytest.raises(ValidationError):
KnowledgeFSSpaceCreatePayload.model_validate(
{
"name": "Docs",
"slug": "docs",
"initial_source": {
"kind": "online_drive",
"name": "Drive",
"pluginId": "langgenius/google_drive",
"provider": "google_drive",
"datasource": "google_drive",
"selection": [
{
"id": "file-1",
"name": "Plan.pdf",
"providerItemId": "google-drive:file-1",
}
],
},
}
)
def test_retrieval_test_payload_uses_bounded_kfs_filters_and_resolved_modes() -> None:
payload = KnowledgeFSRetrievalTestPayload.model_validate(
{

View File

@ -5,11 +5,17 @@ from unittest.mock import MagicMock, patch
import pytest
from models.knowledge_fs import KnowledgeFSControlSpaceState
from services.knowledge_fs.product_dto import KnowledgeFSInitialWebsiteSourcePayload
from services.knowledge_fs.product_dto import (
KnowledgeFSInitialOnlineDocumentSourcePayload,
KnowledgeFSInitialOnlineDriveSourcePayload,
KnowledgeFSInitialWebsiteSourcePayload,
)
from services.knowledge_fs.product_remote import KnowledgeFSProductResourceNotFoundError
from tasks.knowledge_fs_initial_source_tasks import (
KnowledgeFSInitialSourceNotReadyError,
import_initial_source,
import_initial_website_source,
start_initial_source_import,
start_initial_website_source_import,
)
@ -33,6 +39,51 @@ def _payload(sync_policy: str = "daily") -> KnowledgeFSInitialWebsiteSourcePaylo
)
def _document_payload() -> KnowledgeFSInitialOnlineDocumentSourcePayload:
return KnowledgeFSInitialOnlineDocumentSourcePayload.model_validate(
{
"kind": "online_document",
"name": "Product wiki",
"pluginId": "langgenius/notion_datasource",
"provider": "notion",
"datasource": "pages",
"credentialId": "notion-credential-1",
"selection": [
{
"lastEditedTime": "2026-08-10T00:00:00Z",
"name": "Roadmap",
"pageId": "page-1",
"providerItemId": "notion:page-1",
"type": "page",
"workspaceId": "workspace-1",
}
],
}
)
def _drive_payload() -> KnowledgeFSInitialOnlineDriveSourcePayload:
return KnowledgeFSInitialOnlineDriveSourcePayload.model_validate(
{
"kind": "online_drive",
"name": "Team drive",
"pluginId": "langgenius/google_drive",
"provider": "google_drive",
"datasource": "google_drive",
"credentialId": "drive-credential-1",
"selection": [
{
"id": "file-1",
"mimeType": "application/pdf",
"name": "Plan.pdf",
"providerItemId": "google-drive:file-1",
}
],
"sync_policy": "manual",
}
)
def _facade() -> MagicMock:
facade = MagicMock()
facade.list_sources.return_value = SimpleNamespace(data=[], next_cursor=None)
@ -42,7 +93,13 @@ def _facade() -> MagicMock:
facade.list_source_connections.return_value = SimpleNamespace(
data=[
SimpleNamespace(
configuration={"credentialId": "firecrawl-credential-1"},
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-1",
provider_id="plugin-daemon-website",
status="active",
@ -165,6 +222,85 @@ def test_initial_website_source_import_recrawls_exact_selection_and_configures_d
assert sync_payload.expected_source_version == 4
@pytest.mark.parametrize(
("payload", "provider_id", "credential_id", "workflow_kind", "expected_source_name"),
[
(
_document_payload(),
"plugin-daemon-online-document",
"notion-credential-1",
"online-document-import",
"Product wiki",
),
(
_drive_payload(),
"plugin-daemon-online-drive",
"drive-credential-1",
"online-drive-import",
"Team drive",
),
],
)
def test_initial_connector_source_import_uses_exact_binding_and_selection(
payload: KnowledgeFSInitialOnlineDocumentSourcePayload | KnowledgeFSInitialOnlineDriveSourcePayload,
provider_id: str,
credential_id: str,
workflow_kind: str,
expected_source_name: str,
) -> None:
facade = _facade()
facade.list_source_providers.return_value = SimpleNamespace(data=[SimpleNamespace(id=provider_id, available=True)])
facade.list_source_connections.return_value = SimpleNamespace(data=[], next_cursor=None)
facade.create_source_connection.return_value = SimpleNamespace(id="connector-1", status="active")
facade.import_source_workflow.return_value = SimpleNamespace(
id="connector-workflow-1",
source_id="source-1",
state="completed",
)
facade.get_source.return_value = SimpleNamespace(
metadata={"clientRequestId": "initial-source:operation-1", "preview": True},
status="disabled",
version=3,
)
facade.update_source.return_value = SimpleNamespace(
metadata={"clientRequestId": "initial-source:operation-1", "preview": False},
status="active",
version=4,
)
credential = SimpleNamespace(id=credential_id, name=expected_source_name)
with _runtime(facade, credential=credential):
result = start_initial_source_import(
tenant_id="tenant-1",
account_id="account-1",
control_space_id="control-1",
operation_id="operation-1",
payload=payload,
)
assert result == "connector-workflow-1"
connection_payload = facade.create_source_connection.call_args.kwargs["payload"]
assert connection_payload.provider_id == provider_id
assert connection_payload.configuration == {
"credentialId": credential_id,
"datasource": payload.datasource,
"pluginId": payload.plugin_id,
"provider": payload.provider,
"providerKind": "online-document" if workflow_kind == "online-document-import" else "online-drive",
}
source_payload = facade.create_source.call_args.kwargs["payload"]
assert source_payload.connection_id == "connector-1"
assert source_payload.name == expected_source_name
assert source_payload.type == "connector"
assert source_payload.uri == "connector://connector-1"
assert source_payload.metadata["providerId"] == provider_id
import_payload = facade.import_source_workflow.call_args.kwargs["payload"].root
assert import_payload.kind == workflow_kind
assert import_payload.items == payload.selection
sync_payload = facade.update_source_sync_policy.call_args.kwargs["payload"]
assert sync_payload.mode == ("manual" if workflow_kind == "online-drive-import" else "provider")
def test_initial_website_source_import_reuses_source_across_pages_and_preserves_failure() -> None:
facade = _facade()
existing_source = SimpleNamespace(
@ -216,7 +352,13 @@ def test_initial_website_source_import_configures_remaining_sync_modes(
SimpleNamespace(
data=[
SimpleNamespace(
configuration={"credentialId": "firecrawl-credential-1"},
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-2",
provider_id="plugin-daemon-website",
status="active",
@ -365,7 +507,13 @@ def test_initial_website_source_import_retries_provisioning_firecrawl_connection
facade.list_source_connections.return_value = SimpleNamespace(
data=[
SimpleNamespace(
configuration={"credentialId": "firecrawl-credential-1"},
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-1",
provider_id="plugin-daemon-website",
status="provisioning",
@ -523,3 +671,25 @@ def test_initial_website_source_task_retries_with_workflow_id() -> None:
"workflow_id": "workflow-1",
},
)
def test_initial_source_task_validates_discriminated_connector_payload() -> None:
serialized_payload = _document_payload().model_dump(mode="json", by_alias=True)
with patch(
"tasks.knowledge_fs_initial_source_tasks.start_initial_source_import",
return_value="workflow-1",
) as start_import:
assert (
import_initial_source.run(
tenant_id="tenant-1",
account_id="account-1",
control_space_id="control-1",
operation_id="operation-1",
payload=serialized_payload,
)
== "workflow-1"
)
parsed_payload = start_import.call_args.kwargs["payload"]
assert isinstance(parsed_payload, KnowledgeFSInitialOnlineDocumentSourcePayload)
assert parsed_payload.credential_id == "notion-credential-1"

View File

@ -179,6 +179,8 @@ import {
zPatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponse,
zPostKnowledgeFsQueryStreamBody,
zPostKnowledgeFsQueryStreamResponse,
zPostKnowledgeFsSourceProviderPreviewBody,
zPostKnowledgeFsSourceProviderPreviewResponse,
zPostKnowledgeFsSpacesBody,
zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelPath,
zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponse,
@ -363,6 +365,21 @@ export const researchTasks = {
byTaskId,
}
export const post2 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postKnowledgeFsSourceProviderPreview',
path: '/knowledge-fs/source-provider-preview',
tags: ['console'],
})
.input(z.object({ body: zPostKnowledgeFsSourceProviderPreviewBody }))
.output(zPostKnowledgeFsSourceProviderPreviewResponse)
export const sourceProviderPreview = {
post: post2,
}
export const delete_ = oc
.route({
inputStructure: 'detailed',
@ -420,7 +437,7 @@ export const appBindings = {
byCallerKind,
}
export const post2 = oc
export const post3 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -436,10 +453,10 @@ export const post2 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponse)
export const cancel = {
post: post2,
post: post3,
}
export const post3 = oc
export const post4 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -455,7 +472,7 @@ export const post3 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponse)
export const retry = {
post: post3,
post: post4,
}
export const byTaskId2 = {
@ -536,7 +553,7 @@ export const get6 = oc
.input(z.object({ params: zGetKnowledgeFsSpacesByControlSpaceIdCredentialsPath }))
.output(zGetKnowledgeFsSpacesByControlSpaceIdCredentialsResponse)
export const post4 = oc
export const post5 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -555,7 +572,7 @@ export const post4 = oc
export const credentials = {
get: get6,
post: post4,
post: post5,
byCredentialId,
}
@ -581,7 +598,7 @@ export const bulk = {
delete: delete3,
}
export const post5 = oc
export const post6 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -598,7 +615,7 @@ export const post5 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdDocumentsReindexResponse)
export const reindex = {
post: post5,
post: post6,
}
export const get7 = oc
@ -761,7 +778,7 @@ export const get12 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdDocumentsResponse)
export const post6 = oc
export const post7 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -780,7 +797,7 @@ export const post6 = oc
export const documents = {
get: get12,
post: post6,
post: post7,
bulk,
reindex,
byDocumentId,
@ -818,7 +835,7 @@ export const externalAccess = {
put: put2,
}
export const post7 = oc
export const post8 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -836,10 +853,10 @@ export const post7 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdGoldenQuestionsBulkImportResponse)
export const bulkImport = {
post: post7,
post: post8,
}
export const post8 = oc
export const post9 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -856,7 +873,7 @@ export const post8 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdGoldenQuestionsEvidenceMatchesResponse)
export const evidenceMatches = {
post: post8,
post: post9,
}
export const delete5 = oc
@ -910,7 +927,7 @@ export const get14 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdGoldenQuestionsResponse)
export const post9 = oc
export const post10 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -929,13 +946,13 @@ export const post9 = oc
export const goldenQuestions = {
get: get14,
post: post9,
post: post10,
bulkImport,
evidenceMatches,
byQuestionId,
}
export const post10 = oc
export const post11 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -947,7 +964,7 @@ export const post10 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponse)
export const retry2 = {
post: post10,
post: post11,
}
export const delete6 = oc
@ -1112,7 +1129,7 @@ export const get18 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdMetadataResponse)
export const post11 = oc
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1131,7 +1148,7 @@ export const post11 = oc
export const metadata = {
get: get18,
post: post11,
post: post12,
byFieldId,
}
@ -1337,7 +1354,7 @@ export const get28 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdQualityBadCasesResponse)
export const post12 = oc
export const post13 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1356,11 +1373,11 @@ export const post12 = oc
export const badCases = {
get: get28,
post: post12,
post: post13,
byBadCaseId,
}
export const post13 = oc
export const post14 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1379,7 +1396,7 @@ export const post13 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdQualityReplayRunsResponse)
export const replayRuns = {
post: post13,
post: post14,
}
export const quality = {
@ -1387,7 +1404,7 @@ export const quality = {
replayRuns,
}
export const post14 = oc
export const post15 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1404,13 +1421,13 @@ export const post14 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdQueriesAdmissionResponse)
export const admission = {
post: post14,
post: post15,
}
/**
* @deprecated
*/
export const post15 = oc
export const post16 = oc
.route({
deprecated: true,
inputStructure: 'detailed',
@ -1429,14 +1446,14 @@ export const post15 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdQueriesResponse)
export const queries = {
post: post15,
post: post16,
admission,
}
/**
* @deprecated
*/
export const post16 = oc
export const post17 = oc
.route({
deprecated: true,
inputStructure: 'detailed',
@ -1449,10 +1466,10 @@ export const post16 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdQueryStreamCapabilityResponse)
export const queryStreamCapability = {
post: post16,
post: post17,
}
export const post17 = oc
export const post18 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1469,7 +1486,7 @@ export const post17 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdResearchTasksPlanResponse)
export const plan = {
post: post17,
post: post18,
}
export const get29 = oc
@ -1536,7 +1553,7 @@ export const get31 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdResearchTasksResponse)
export const post18 = oc
export const post19 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1555,7 +1572,7 @@ export const post18 = oc
export const researchTasks2 = {
get: get31,
post: post18,
post: post19,
plan,
byTaskId: byTaskId3,
}
@ -1614,7 +1631,7 @@ export const settings = {
migrations,
}
export const post19 = oc
export const post20 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1631,7 +1648,7 @@ export const post19 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponse)
export const refresh = {
post: post19,
post: post20,
}
export const byConnectionId = {
@ -1654,7 +1671,7 @@ export const get34 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponse)
export const post20 = oc
export const post21 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1673,7 +1690,7 @@ export const post20 = oc
export const sourceConnections = {
get: get34,
post: post20,
post: post21,
byConnectionId,
}
@ -1692,7 +1709,7 @@ export const sourceProviders = {
get: get35,
}
export const post21 = oc
export const post22 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1709,7 +1726,7 @@ export const post21 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponse)
export const cancel2 = {
post: post21,
post: post22,
}
export const get36 = oc
@ -1732,7 +1749,7 @@ export const pages = {
get: get36,
}
export const post22 = oc
export const post23 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1746,10 +1763,10 @@ export const post22 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponse)
export const retry3 = {
post: post22,
post: post23,
}
export const post23 = oc
export const post24 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1768,7 +1785,7 @@ export const post23 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponse)
export const selection = {
post: post23,
post: post24,
}
export const get37 = oc
@ -1794,7 +1811,7 @@ export const sourceWorkflows = {
byRunId,
}
export const post24 = oc
export const post25 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1813,10 +1830,10 @@ export const post24 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlImportResponse)
export const crawlImport = {
post: post24,
post: post25,
}
export const post25 = oc
export const post26 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1834,7 +1851,7 @@ export const post25 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponse)
export const crawlPreview = {
post: post25,
post: post26,
}
export const get38 = oc
@ -1857,7 +1874,7 @@ export const files = {
get: get38,
}
export const post26 = oc
export const post27 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1874,10 +1891,10 @@ export const post26 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdImportResponse)
export const import_ = {
post: post26,
post: post27,
}
export const post27 = oc
export const post28 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1894,7 +1911,7 @@ export const post27 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdImportFilesResponse)
export const importFiles = {
post: post27,
post: post28,
}
export const get39 = oc
@ -1917,7 +1934,7 @@ export const pages2 = {
get: get39,
}
export const post28 = oc
export const post29 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1935,7 +1952,7 @@ export const post28 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponse)
export const sync = {
post: post28,
post: post29,
}
export const get40 = oc
@ -1970,7 +1987,7 @@ export const syncPolicy = {
put: put4,
}
export const post29 = oc
export const post30 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -1982,10 +1999,10 @@ export const post29 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdTestResponse)
export const test = {
post: post29,
post: post30,
}
export const post30 = oc
export const post31 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2004,7 +2021,7 @@ export const post30 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdWorkflowImportsResponse)
export const workflowImports = {
post: post30,
post: post31,
}
export const delete10 = oc
@ -2085,7 +2102,7 @@ export const get42 = oc
)
.output(zGetKnowledgeFsSpacesByControlSpaceIdSourcesResponse)
export const post31 = oc
export const post32 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2104,7 +2121,7 @@ export const post31 = oc
export const sources = {
get: get42,
post: post31,
post: post32,
bySourceId,
}
@ -2207,7 +2224,7 @@ export const traces = {
byTraceId,
}
export const post32 = oc
export const post33 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2224,10 +2241,10 @@ export const post32 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsByUploadSessionIdAbortResponse)
export const abort = {
post: post32,
post: post33,
}
export const post33 = oc
export const post34 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2244,10 +2261,10 @@ export const post33 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsByUploadSessionIdCompleteResponse)
export const complete = {
post: post33,
post: post34,
}
export const post34 = oc
export const post35 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2268,7 +2285,7 @@ export const post34 = oc
)
export const presign = {
post: post34,
post: post35,
}
export const byPartNumber = {
@ -2279,7 +2296,7 @@ export const parts = {
byPartNumber,
}
export const post35 = oc
export const post36 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2296,7 +2313,7 @@ export const post35 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsByUploadSessionIdSmallFileResponse)
export const smallFile = {
post: post35,
post: post36,
}
export const byUploadSessionId = {
@ -2306,7 +2323,7 @@ export const byUploadSessionId = {
smallFile,
}
export const post36 = oc
export const post37 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2325,7 +2342,7 @@ export const post36 = oc
.output(zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsResponse)
export const uploadSessions = {
post: post36,
post: post37,
byUploadSessionId,
}
@ -2409,7 +2426,7 @@ export const get49 = oc
.input(z.object({ query: zGetKnowledgeFsSpacesQuery.optional() }))
.output(zGetKnowledgeFsSpacesResponse)
export const post37 = oc
export const post38 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2423,11 +2440,11 @@ export const post37 = oc
export const spaces = {
get: get49,
post: post37,
post: post38,
byControlSpaceId,
}
export const post38 = oc
export const post39 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -2444,7 +2461,7 @@ export const post38 = oc
.output(zPostKnowledgeFsTasksByTaskIdStreamCapabilityResponse)
export const streamCapability = {
post: post38,
post: post39,
}
export const byTaskId4 = {
@ -2459,6 +2476,7 @@ export const knowledgeFs = {
wellKnown,
queryStream,
researchTasks,
sourceProviderPreview,
spaces,
tasks,
}

View File

@ -18,6 +18,26 @@ export type KnowledgeFsAdmittedQueryRequest = {
sessionId?: string | null
}
export type KnowledgeFsInitialSourcePreviewPayload = {
credentialId: string
datasource: string
kind: 'online_document' | 'online_drive'
parameters?: {
[key: string]: JsonValue
}
pluginId: string
provider: string
}
export type KnowledgeFsInitialSourcePreviewResponse = {
documents?: Array<KnowledgeFsInitialSourcePreviewDocumentResponse>
files?: Array<KnowledgeFsInitialSourcePreviewFileResponse>
kind: 'online_document' | 'online_drive'
next_page_parameters?: {
[key: string]: JsonValue
} | null
}
export type KnowledgeFsSpaceListResponse = {
data: Array<KnowledgeFsSpaceListItemResponse>
has_more: boolean
@ -30,7 +50,17 @@ export type KnowledgeFsSpaceCreatePayload = {
embedding?: KnowledgeFsModelIntent | null
icon?: string | null
idempotency_key?: string | null
initial_source?: KnowledgeFsInitialWebsiteSourcePayload | null
initial_source?:
| ({
kind: 'website_crawl'
} & KnowledgeFsInitialWebsiteSourcePayload)
| ({
kind: 'online_document'
} & KnowledgeFsInitialOnlineDocumentSourcePayload)
| ({
kind: 'online_drive'
} & KnowledgeFsInitialOnlineDriveSourcePayload)
| null
name: string
retrieval?: KnowledgeFsRetrievalProfileIntent | null
slug: string
@ -947,6 +977,28 @@ export type KnowledgeFsQueryImageReference = {
uploadFileId: string
}
export type JsonValue = unknown
export type KnowledgeFsInitialSourcePreviewDocumentResponse = {
last_edited_time?: string | null
name: string
page_id: string
provider_item_id: string
type: string
workspace_id: string
workspace_name?: string | null
}
export type KnowledgeFsInitialSourcePreviewFileResponse = {
bucket?: string | null
id: string
mime_type?: string | null
name: string
provider_item_id: string
size: number
type: string
}
export type KnowledgeFsSpaceListItemResponse = {
control_space_id: string
created_at: string
@ -970,14 +1022,39 @@ export type KnowledgeFsModelIntent = {
export type KnowledgeFsInitialWebsiteSourcePayload = {
crawl_options: KnowledgeFsInitialWebsiteCrawlOptionsPayload
credentialId?: string | null
datasource?: string
kind: 'website_crawl'
name: string
provider: 'firecrawl'
pluginId?: string | null
provider: string
root_url: string
selection: Array<KnowledgeFsInitialWebsiteSelectionPayload>
sync_policy?: 'daily' | 'manual' | 'provider'
}
export type KnowledgeFsInitialOnlineDocumentSourcePayload = {
credentialId: string
datasource: string
kind: 'online_document'
name: string
pluginId: string
provider: string
selection: Array<KnowledgeFsOnlineDocumentWorkflowImportItemPayload>
sync_policy?: 'daily' | 'manual' | 'provider'
}
export type KnowledgeFsInitialOnlineDriveSourcePayload = {
credentialId: string
datasource: string
kind: 'online_drive'
name: string
pluginId: string
provider: string
selection: Array<KnowledgeFsOnlineDriveWorkflowImportItemPayload>
sync_policy?: 'daily' | 'manual' | 'provider'
}
export type KnowledgeFsRetrievalProfileIntent = {
default_mode: 'deep' | 'fast' | 'research'
reasoning_model: KnowledgeFsModelIntent
@ -1478,6 +1555,25 @@ export type KnowledgeFsInitialWebsiteSelectionPayload = {
title?: string | null
}
export type KnowledgeFsOnlineDocumentWorkflowImportItemPayload = {
etag?: string | null
lastEditedTime?: string | null
name?: string | null
pageId: string
providerItemId: string
type: string
workspaceId: string
}
export type KnowledgeFsOnlineDriveWorkflowImportItemPayload = {
bucket?: string | null
etag?: string | null
id: string
mimeType?: string | null
name: string
providerItemId: string
}
export type KnowledgeFsRerankIntent = {
enabled: boolean
model?: KnowledgeFsModelIntent | null
@ -1578,25 +1674,6 @@ export type KnowledgeFsSourcePageResponse = {
type: string
}
export type KnowledgeFsOnlineDocumentWorkflowImportItemPayload = {
etag?: string | null
lastEditedTime?: string | null
name?: string | null
pageId: string
providerItemId: string
type: string
workspaceId: string
}
export type KnowledgeFsOnlineDriveWorkflowImportItemPayload = {
bucket?: string | null
etag?: string | null
id: string
mimeType?: string | null
name: string
providerItemId: string
}
export type KnowledgeFsTraceProfileResponse = {
embedding_model?: string | null
embedding_vector_space_id?: string | null
@ -1671,6 +1748,20 @@ export type GetKnowledgeFsResearchTasksByTaskIdEventsResponses = {
export type GetKnowledgeFsResearchTasksByTaskIdEventsResponse =
GetKnowledgeFsResearchTasksByTaskIdEventsResponses[keyof GetKnowledgeFsResearchTasksByTaskIdEventsResponses]
export type PostKnowledgeFsSourceProviderPreviewData = {
body: KnowledgeFsInitialSourcePreviewPayload
path?: never
query?: never
url: '/knowledge-fs/source-provider-preview'
}
export type PostKnowledgeFsSourceProviderPreviewResponses = {
200: KnowledgeFsInitialSourcePreviewResponse
}
export type PostKnowledgeFsSourceProviderPreviewResponse =
PostKnowledgeFsSourceProviderPreviewResponses[keyof PostKnowledgeFsSourceProviderPreviewResponses]
export type GetKnowledgeFsSpacesData = {
body?: never
path?: never

View File

@ -733,6 +733,56 @@ export const zKnowledgeFsResearchTaskPlanPayload = z.object({
topK: z.int().gte(1).lte(50).nullish(),
})
export const zJsonValue = z.unknown()
/**
* KnowledgeFSInitialSourcePreviewPayload
*/
export const zKnowledgeFsInitialSourcePreviewPayload = z.object({
credentialId: z.string().min(1).max(255),
datasource: z.string().min(1).max(255),
kind: z.enum(['online_document', 'online_drive']),
parameters: z.record(z.string(), zJsonValue).optional(),
pluginId: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
})
/**
* KnowledgeFSInitialSourcePreviewDocumentResponse
*/
export const zKnowledgeFsInitialSourcePreviewDocumentResponse = z.object({
last_edited_time: z.string().nullish(),
name: z.string(),
page_id: z.string(),
provider_item_id: z.string(),
type: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
})
/**
* KnowledgeFSInitialSourcePreviewFileResponse
*/
export const zKnowledgeFsInitialSourcePreviewFileResponse = z.object({
bucket: z.string().nullish(),
id: z.string(),
mime_type: z.string().nullish(),
name: z.string(),
provider_item_id: z.string(),
size: z.int().gte(0),
type: z.string(),
})
/**
* KnowledgeFSInitialSourcePreviewResponse
*/
export const zKnowledgeFsInitialSourcePreviewResponse = z.object({
documents: z.array(zKnowledgeFsInitialSourcePreviewDocumentResponse).optional(),
files: z.array(zKnowledgeFsInitialSourcePreviewFileResponse).optional(),
kind: z.enum(['online_document', 'online_drive']),
next_page_parameters: z.record(z.string(), zJsonValue).nullish(),
})
/**
* KnowledgeFSModelIntent
*/
@ -1599,14 +1649,96 @@ export const zKnowledgeFsInitialWebsiteSelectionPayload = z.object({
*/
export const zKnowledgeFsInitialWebsiteSourcePayload = z.object({
crawl_options: zKnowledgeFsInitialWebsiteCrawlOptionsPayload,
credentialId: z.string().min(1).max(255).nullish(),
datasource: z.string().min(1).max(255).optional().default('crawl'),
kind: z.literal('website_crawl'),
name: z.string().min(1).max(200),
provider: z.literal('firecrawl'),
pluginId: z.string().min(1).max(255).nullish(),
provider: z.string().min(1).max(255),
root_url: z.string().min(1).max(4096),
selection: z.array(zKnowledgeFsInitialWebsiteSelectionPayload).min(1).max(200),
sync_policy: z.enum(['daily', 'manual', 'provider']).optional().default('provider'),
})
/**
* KnowledgeFSOnlineDocumentWorkflowImportItemPayload
*/
export const zKnowledgeFsOnlineDocumentWorkflowImportItemPayload = z.object({
etag: z.string().max(1024).nullish(),
lastEditedTime: z.string().max(128).nullish(),
name: z.string().max(500).nullish(),
pageId: z.string().min(1).max(1024),
providerItemId: z.string().min(1).max(1024),
type: z.string().min(1).max(128),
workspaceId: z.string().min(1).max(1024),
})
/**
* KnowledgeFSInitialOnlineDocumentSourcePayload
*/
export const zKnowledgeFsInitialOnlineDocumentSourcePayload = z.object({
credentialId: z.string().min(1).max(255),
datasource: z.string().min(1).max(255),
kind: z.literal('online_document'),
name: z.string().min(1).max(200),
pluginId: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
selection: z.array(zKnowledgeFsOnlineDocumentWorkflowImportItemPayload).min(1).max(200),
sync_policy: z.enum(['daily', 'manual', 'provider']).optional().default('provider'),
})
/**
* KnowledgeFSOnlineDocumentWorkflowImportPayload
*/
export const zKnowledgeFsOnlineDocumentWorkflowImportPayload = z.object({
items: z.array(zKnowledgeFsOnlineDocumentWorkflowImportItemPayload).min(1).max(200),
kind: z.literal('online-document-import'),
})
/**
* KnowledgeFSOnlineDriveWorkflowImportItemPayload
*/
export const zKnowledgeFsOnlineDriveWorkflowImportItemPayload = z.object({
bucket: z.string().max(1024).nullish(),
etag: z.string().max(1024).nullish(),
id: z.string().min(1).max(1024),
mimeType: z.string().max(255).nullish(),
name: z.string().min(1).max(500),
providerItemId: z.string().min(1).max(1024),
})
/**
* KnowledgeFSInitialOnlineDriveSourcePayload
*/
export const zKnowledgeFsInitialOnlineDriveSourcePayload = z.object({
credentialId: z.string().min(1).max(255),
datasource: z.string().min(1).max(255),
kind: z.literal('online_drive'),
name: z.string().min(1).max(200),
pluginId: z.string().min(1).max(255),
provider: z.string().min(1).max(255),
selection: z.array(zKnowledgeFsOnlineDriveWorkflowImportItemPayload).min(1).max(200),
sync_policy: z.enum(['daily', 'manual', 'provider']).optional().default('provider'),
})
/**
* KnowledgeFSOnlineDriveWorkflowImportPayload
*/
export const zKnowledgeFsOnlineDriveWorkflowImportPayload = z.object({
items: z.array(zKnowledgeFsOnlineDriveWorkflowImportItemPayload).min(1).max(200),
kind: z.literal('online-drive-import'),
})
/**
* KnowledgeFSSourceWorkflowImportPayload
*/
export const zKnowledgeFsSourceWorkflowImportPayload = z.discriminatedUnion('kind', [
zKnowledgeFsOnlineDocumentWorkflowImportPayload.extend({
kind: z.literal('online-document-import'),
}),
zKnowledgeFsOnlineDriveWorkflowImportPayload.extend({ kind: z.literal('online-drive-import') }),
])
/**
* KnowledgeFSRerankIntent
*/
@ -1647,7 +1779,13 @@ export const zKnowledgeFsSpaceCreatePayload = z.object({
.regex(/^(?:builtin:)?[+a-z0-9_-]{1,64}$/)
.nullish(),
idempotency_key: z.string().min(1).max(255).nullish(),
initial_source: zKnowledgeFsInitialWebsiteSourcePayload.nullish(),
initial_source: z
.discriminatedUnion('kind', [
zKnowledgeFsInitialWebsiteSourcePayload.extend({ kind: z.literal('website_crawl') }),
zKnowledgeFsInitialOnlineDocumentSourcePayload.extend({ kind: z.literal('online_document') }),
zKnowledgeFsInitialOnlineDriveSourcePayload.extend({ kind: z.literal('online_drive') }),
])
.nullish(),
name: z.string().min(1).max(40),
retrieval: zKnowledgeFsRetrievalProfileIntent.nullish(),
slug: z
@ -2077,57 +2215,6 @@ export const zKnowledgeFsSourcePagesResponse = z.object({
workspaces: z.array(zKnowledgeFsSourceWorkspacePagesResponse),
})
/**
* KnowledgeFSOnlineDocumentWorkflowImportItemPayload
*/
export const zKnowledgeFsOnlineDocumentWorkflowImportItemPayload = z.object({
etag: z.string().max(1024).nullish(),
lastEditedTime: z.string().max(128).nullish(),
name: z.string().max(500).nullish(),
pageId: z.string().min(1).max(1024),
providerItemId: z.string().min(1).max(1024),
type: z.string().min(1).max(128),
workspaceId: z.string().min(1).max(1024),
})
/**
* KnowledgeFSOnlineDocumentWorkflowImportPayload
*/
export const zKnowledgeFsOnlineDocumentWorkflowImportPayload = z.object({
items: z.array(zKnowledgeFsOnlineDocumentWorkflowImportItemPayload).min(1).max(200),
kind: z.literal('online-document-import'),
})
/**
* KnowledgeFSOnlineDriveWorkflowImportItemPayload
*/
export const zKnowledgeFsOnlineDriveWorkflowImportItemPayload = z.object({
bucket: z.string().max(1024).nullish(),
etag: z.string().max(1024).nullish(),
id: z.string().min(1).max(1024),
mimeType: z.string().max(255).nullish(),
name: z.string().min(1).max(500),
providerItemId: z.string().min(1).max(1024),
})
/**
* KnowledgeFSOnlineDriveWorkflowImportPayload
*/
export const zKnowledgeFsOnlineDriveWorkflowImportPayload = z.object({
items: z.array(zKnowledgeFsOnlineDriveWorkflowImportItemPayload).min(1).max(200),
kind: z.literal('online-drive-import'),
})
/**
* KnowledgeFSSourceWorkflowImportPayload
*/
export const zKnowledgeFsSourceWorkflowImportPayload = z.discriminatedUnion('kind', [
zKnowledgeFsOnlineDocumentWorkflowImportPayload.extend({
kind: z.literal('online-document-import'),
}),
zKnowledgeFsOnlineDriveWorkflowImportPayload.extend({ kind: z.literal('online-drive-import') }),
])
/**
* KnowledgeFSTraceProfileResponse
*/
@ -2213,6 +2300,14 @@ export const zGetKnowledgeFsResearchTasksByTaskIdEventsQuery = z.object({
*/
export const zGetKnowledgeFsResearchTasksByTaskIdEventsResponse = z.record(z.string(), z.unknown())
export const zPostKnowledgeFsSourceProviderPreviewBody = zKnowledgeFsInitialSourcePreviewPayload
/**
* Datasource resources available for an initial Source
*/
export const zPostKnowledgeFsSourceProviderPreviewResponse =
zKnowledgeFsInitialSourcePreviewResponse
export const zGetKnowledgeFsSpacesQuery = z.object({
creator_ids: z.array(z.string().min(1).max(255)).max(100).optional(),
limit: z.int().gte(1).lte(100).optional().default(20),

View File

@ -10,6 +10,7 @@ const serviceMock = vi.hoisted(() => ({
createKfsSource: vi.fn(),
getKfsSource: vi.fn(),
getCrawlStatus: vi.fn(),
previewInitialSource: vi.fn(),
getDefaultModel: vi.fn(),
getSpace: vi.fn(),
getSyncPolicy: vi.fn(),
@ -27,6 +28,21 @@ const serviceMock = vi.hoisted(() => ({
documentsKey: vi.fn(() => ['console', 'knowledgeFs', 'documents']),
}))
const datasourceQueryMock = vi.hoisted(() => ({
auth: {
data: { result: [] as Array<Record<string, unknown>> },
error: null as unknown,
isPending: false,
refetch: vi.fn(),
},
plugins: {
data: [] as Array<Record<string, unknown>>,
error: null as unknown,
isPending: false,
refetch: vi.fn(),
},
}))
const routerMock = vi.hoisted(() => ({
back: vi.fn(),
replace: vi.fn(),
@ -82,6 +98,9 @@ vi.mock('jotai', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
sourceProviderPreview: {
post: serviceMock.previewInitialSource,
},
spaces: {
byControlSpaceId: {
get: serviceMock.getSpace,
@ -153,9 +172,161 @@ vi.mock('@/service/client', () => ({
vi.mock('@/service/datasets', () => ({
checkFirecrawlTaskStatus: serviceMock.getCrawlStatus,
checkJinaReaderTaskStatus: serviceMock.getCrawlStatus,
checkWatercrawlTaskStatus: serviceMock.getCrawlStatus,
createFirecrawlTask: serviceMock.createCrawl,
createJinaReaderTask: serviceMock.createCrawl,
createWatercrawlTask: serviceMock.createCrawl,
}))
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => datasourceQueryMock.plugins,
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: () => datasourceQueryMock.auth,
}))
const firecrawlDatasourcePlugin = {
declaration: {
credentials_schema: [],
datasources: [
{
description: { en_US: 'Firecrawl', zh_Hans: 'Firecrawl' },
identity: {
author: 'langgenius',
label: { en_US: 'Firecrawl', zh_Hans: 'Firecrawl' },
name: 'crawl',
provider: 'firecrawl',
},
parameters: [],
},
],
identity: {
author: 'langgenius',
description: { en_US: 'Firecrawl', zh_Hans: 'Firecrawl' },
icon: 'icon.svg',
label: { en_US: 'Firecrawl', zh_Hans: 'Firecrawl' },
name: 'firecrawl',
tags: [],
},
provider_type: 'website_crawl',
},
is_authorized: true,
plugin_id: 'langgenius/firecrawl_datasource',
plugin_unique_identifier: 'langgenius/firecrawl_datasource:1.0.0@local',
provider: 'firecrawl',
}
const firecrawlDatasourceAuth = {
author: 'langgenius',
credentials_list: [
{
avatar_url: '',
credential: {},
id: 'firecrawl-credential-1',
is_default: true,
name: 'Default Firecrawl',
type: 'api-key',
},
],
description: { en_US: 'Firecrawl' },
icon: 'icon.svg',
label: { en_US: 'Firecrawl' },
name: 'firecrawl',
plugin_id: 'langgenius/firecrawl_datasource',
plugin_unique_identifier: 'langgenius/firecrawl_datasource:1.0.0@local',
provider: 'firecrawl',
}
const notionDatasourcePlugin = {
...firecrawlDatasourcePlugin,
declaration: {
...firecrawlDatasourcePlugin.declaration,
datasources: [
{
description: { en_US: 'Notion', zh_Hans: 'Notion' },
identity: {
author: 'langgenius',
label: { en_US: 'Notion', zh_Hans: 'Notion' },
name: 'notion',
provider: 'notion',
},
parameters: [],
},
],
identity: {
...firecrawlDatasourcePlugin.declaration.identity,
label: { en_US: 'Notion', zh_Hans: 'Notion' },
name: 'notion',
},
provider_type: 'online_document',
},
plugin_id: 'langgenius/notion_datasource',
plugin_unique_identifier: 'langgenius/notion_datasource:1.0.0@local',
provider: 'notion',
}
const notionDatasourceAuth = {
...firecrawlDatasourceAuth,
credentials_list: [
{
...firecrawlDatasourceAuth.credentials_list[0],
id: 'notion-credential-1',
name: 'Default Notion',
},
],
label: { en_US: 'Notion' },
name: 'notion',
plugin_id: 'langgenius/notion_datasource',
plugin_unique_identifier: 'langgenius/notion_datasource:1.0.0@local',
provider: 'notion',
}
const googleDriveDatasourcePlugin = {
...firecrawlDatasourcePlugin,
declaration: {
...firecrawlDatasourcePlugin.declaration,
datasources: [
{
description: { en_US: 'Google Drive', zh_Hans: 'Google Drive' },
identity: {
author: 'langgenius',
label: { en_US: 'Google Drive', zh_Hans: 'Google Drive' },
name: 'google_drive',
provider: 'google_drive',
},
parameters: [],
},
],
identity: {
...firecrawlDatasourcePlugin.declaration.identity,
label: { en_US: 'Google Drive', zh_Hans: 'Google Drive' },
name: 'google_drive',
},
provider_type: 'online_drive',
},
plugin_id: 'langgenius/google_drive',
plugin_unique_identifier: 'langgenius/google_drive:1.0.0@local',
provider: 'google_drive',
}
const googleDriveDatasourceAuth = {
...firecrawlDatasourceAuth,
credentials_list: [
{
...firecrawlDatasourceAuth.credentials_list[0],
id: 'google-drive-credential-1',
name: 'Default Google Drive',
},
],
label: { en_US: 'Google Drive' },
name: 'google_drive',
plugin_id: 'langgenius/google_drive',
plugin_unique_identifier: 'langgenius/google_drive:1.0.0@local',
provider: 'google_drive',
}
const createdKnowledge = {
control_space_id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084',
model_setup_required: false,
@ -249,6 +420,12 @@ describe('CreateKnowledgePage', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.sessionStorage.clear()
datasourceQueryMock.plugins.data = [firecrawlDatasourcePlugin]
datasourceQueryMock.plugins.error = null
datasourceQueryMock.plugins.isPending = false
datasourceQueryMock.auth.data = { result: [firecrawlDatasourceAuth] }
datasourceQueryMock.auth.error = null
datasourceQueryMock.auth.isPending = false
serviceMock.create.mockResolvedValue(createdKnowledge)
serviceMock.createCrawl.mockResolvedValue({ job_id: 'crawl-job-1' })
serviceMock.createKfsSource.mockResolvedValue(kfsSourceResponse())
@ -735,7 +912,7 @@ describe('CreateKnowledgePage', () => {
)
})
it('enables only source options supported by atomic creation', async () => {
it('enables every atomic source type and distinguishes installed providers', async () => {
const user = userEvent.setup()
renderPage()
@ -756,24 +933,17 @@ describe('CreateKnowledgePage', () => {
const onlineDocuments = screen.getByRole('radio', {
name: 'dataset.newKnowledge.onlineDocuments',
})
expect(onlineDocuments).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(onlineDocuments).toBeEnabled()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
for (const unavailableProvider of ['Jina Reader', 'WaterCrawl', 'FakeCrawler']) {
expect(screen.getByRole('radio', { name: unavailableProvider })).toHaveAttribute(
'aria-disabled',
'true',
)
}
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'WaterCrawl' })).toBeEnabled()
await user.click(onlineDocuments)
expect(onlineDocuments).not.toBeChecked()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' })).toBeChecked()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' }),
).toBeDisabled()
expect(onlineDocuments).toBeChecked()
expect(screen.getByRole('radio', { name: 'Notion' })).toBeChecked()
expect(screen.getByText('workflow.nodes.common.pluginNotInstalled')).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' }))
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.moreProviders' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.crawlOptions')).toBeInTheDocument()
expect(
@ -998,8 +1168,11 @@ describe('CreateKnowledgePage', () => {
include_subpages: true,
limit: 100,
},
credentialId: 'firecrawl-credential-1',
datasource: 'crawl',
kind: 'website_crawl',
name: 'Dify docs',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
root_url: 'https://docs.dify.ai',
selection: [
@ -1017,6 +1190,154 @@ describe('CreateKnowledgePage', () => {
expect(serviceMock.selectWorkflowPages).not.toHaveBeenCalled()
})
it('distinguishes an installed document provider with no credential', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
datasourceQueryMock.plugins.data = [firecrawlDatasourcePlugin, notionDatasourcePlugin]
renderPage()
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
expect(
screen.getByText('dataset.newKnowledge.providerNotConfigured:{"provider":"Notion"}'),
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.connectProvider:{"provider":"Notion"}',
}),
).toBeEnabled()
expect(screen.queryByText('workflow.nodes.common.pluginNotInstalled')).not.toBeInTheDocument()
})
it('creates a knowledge space atomically with selected online documents', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
datasourceQueryMock.plugins.data = [firecrawlDatasourcePlugin, notionDatasourcePlugin]
datasourceQueryMock.auth.data = {
result: [firecrawlDatasourceAuth, notionDatasourceAuth],
}
serviceMock.previewInitialSource.mockResolvedValue({
documents: [
{
last_edited_time: '2026-08-10T08:00:00Z',
name: 'Product handbook',
page_id: 'page-1',
provider_item_id: '["workspace-1","page-1"]',
type: 'page',
workspace_id: 'workspace-1',
workspace_name: 'Dify',
},
],
kind: 'online_document',
next_page_parameters: null,
})
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Notion handbook',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.preview' }))
await user.click(await screen.findByRole('checkbox', { name: 'Product handbook' }))
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }),
).toBeEnabled(),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({
initial_source: {
credentialId: 'notion-credential-1',
datasource: 'notion',
kind: 'online_document',
name: 'Notion handbook',
pluginId: 'langgenius/notion_datasource',
provider: 'notion',
selection: [
{
lastEditedTime: '2026-08-10T08:00:00Z',
name: 'Product handbook',
pageId: 'page-1',
providerItemId: '["workspace-1","page-1"]',
type: 'page',
workspaceId: 'workspace-1',
},
],
sync_policy: 'provider',
},
}),
})
})
it('creates a knowledge space atomically with a selected drive file', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'
datasourceQueryMock.plugins.data = [firecrawlDatasourcePlugin, googleDriveDatasourcePlugin]
datasourceQueryMock.auth.data = {
result: [firecrawlDatasourceAuth, googleDriveDatasourceAuth],
}
serviceMock.previewInitialSource.mockResolvedValue({
files: [
{
bucket: null,
id: 'file-1',
mime_type: 'application/pdf',
name: 'Runbook.pdf',
provider_item_id: '["","file-1"]',
size: 2048,
type: 'application/pdf',
},
],
kind: 'online_drive',
next_page_parameters: null,
})
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' }))
await user.type(
screen.getByPlaceholderText('dataset.newKnowledge.sourceNamePlaceholder'),
'Drive runbook',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.preview' }))
await user.click(await screen.findByRole('checkbox', { name: 'Runbook.pdf' }))
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }),
).toBeEnabled(),
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({
initial_source: {
credentialId: 'google-drive-credential-1',
datasource: 'google_drive',
kind: 'online_drive',
name: 'Drive runbook',
pluginId: 'langgenius/google_drive',
provider: 'google_drive',
selection: [
{
bucket: undefined,
id: 'file-1',
mimeType: 'application/pdf',
name: 'Runbook.pdf',
providerItemId: '["","file-1"]',
},
],
sync_policy: 'provider',
},
}),
})
})
it('requires a selected website preview page before creating with an initial source', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'source'

View File

@ -0,0 +1,410 @@
'use client'
import type {
KnowledgeFsInitialSourcePreviewDocumentResponse,
KnowledgeFsInitialSourcePreviewFileResponse,
KnowledgeFsSpaceCreatePayload,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type {
NewKnowledgeOnlineDocumentsSourceDraft,
NewKnowledgeOnlineDriveSourceDraft,
NewKnowledgeSourceDraft,
} from './routes'
import type { InstalledSourceProviderOption } from './source-provider-options'
import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { consoleClient } from '@/service/client'
import { SourceNameField, SourceSyncPolicyField } from './source-setup-fields'
type ConnectedDraft = NewKnowledgeOnlineDocumentsSourceDraft | NewKnowledgeOnlineDriveSourceDraft
type InitialSource = NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
type PreviewDocument = KnowledgeFsInitialSourcePreviewDocumentResponse
type PreviewFile = KnowledgeFsInitialSourcePreviewFileResponse
type PreviewResource =
| {
depth: number
document: PreviewDocument
key: string
kind: 'document'
parentKey?: string
}
| { depth: number; file: PreviewFile; key: string; kind: 'file'; parentKey?: string }
type NextPageRequest = {
bucket?: string
depth: number
nextPage: Record<string, unknown>
parentKey?: string
prefix?: string
}
function isDriveContainer(file: PreviewFile) {
return /bucket|directory|folder|workspace/i.test(file.type)
}
function resourceName(resource: PreviewResource) {
return resource.kind === 'document' ? resource.document.name : resource.file.name
}
function resourceIcon(resource: PreviewResource) {
if (resource.kind === 'file' && isDriveContainer(resource.file))
return 'i-ri-folder-3-fill text-text-warning'
return resource.kind === 'document'
? 'i-ri-file-text-line text-text-tertiary'
: 'i-ri-file-3-line text-text-tertiary'
}
export function CreateConnectedSourceSetup({
credential,
disabled,
draft,
providerOption,
onDraftChange,
onInitialSourceChange,
}: {
credential: DataSourceCredential
disabled: boolean
draft: ConnectedDraft
providerOption: InstalledSourceProviderOption
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onInitialSourceChange: (source?: InitialSource) => void
}) {
const { t } = useTranslation('dataset')
const [resources, setResources] = useState<PreviewResource[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(false)
const [previewed, setPreviewed] = useState(false)
const [nextPageRequest, setNextPageRequest] = useState<NextPageRequest | null>()
const selectableResources = useMemo(
() =>
resources.filter(
(resource) => resource.kind === 'document' || !isDriveContainer(resource.file),
),
[resources],
)
const visibleResources = useMemo(() => {
const byKey = new Map(resources.map((resource) => [resource.key, resource]))
return resources.filter((resource) => {
let parentKey = resource.parentKey
while (parentKey) {
if (!expanded.has(parentKey)) return false
parentKey = byKey.get(parentKey)?.parentKey
}
return true
})
}, [expanded, resources])
useEffect(() => {
const selectedResources = selectableResources.filter((resource) => selected.has(resource.key))
const name = draft.sourceName.trim()
if (!name || !selectedResources.length) {
onInitialSourceChange(undefined)
return
}
const binding = {
credentialId: credential.id,
datasource: providerOption.datasource.identity.name,
pluginId: providerOption.plugin.plugin_id,
provider: providerOption.plugin.provider,
}
if (draft.sourceType === 'onlineDocuments') {
onInitialSourceChange({
...binding,
kind: 'online_document',
name,
selection: selectedResources.flatMap((resource) =>
resource.kind === 'document'
? [
{
lastEditedTime: resource.document.last_edited_time ?? undefined,
name: resource.document.name,
pageId: resource.document.page_id,
providerItemId: resource.document.provider_item_id,
type: resource.document.type,
workspaceId: resource.document.workspace_id,
},
]
: [],
),
sync_policy: draft.syncPolicy,
})
return
}
onInitialSourceChange({
...binding,
kind: 'online_drive',
name,
selection: selectedResources.flatMap((resource) =>
resource.kind === 'file'
? [
{
bucket: resource.file.bucket ?? undefined,
id: resource.file.id,
mimeType: resource.file.mime_type ?? undefined,
name: resource.file.name,
providerItemId: resource.file.provider_item_id,
},
]
: [],
),
sync_policy: draft.syncPolicy,
})
}, [
credential.id,
draft,
onInitialSourceChange,
providerOption.datasource.identity.name,
providerOption.plugin.plugin_id,
providerOption.plugin.provider,
selectableResources,
selected,
])
const requestPreview = useCallback(
async ({
append = false,
bucket,
depth = 0,
nextPage,
parentKey,
prefix,
}: {
append?: boolean
bucket?: string
depth?: number
nextPage?: Record<string, unknown>
parentKey?: string
prefix?: string
} = {}) => {
append ? setLoadingMore(true) : setLoading(true)
setError(false)
try {
const response = await consoleClient.knowledgeFs.sourceProviderPreview.post({
body: {
credentialId: credential.id,
datasource: providerOption.datasource.identity.name,
kind: draft.sourceType === 'onlineDocuments' ? 'online_document' : 'online_drive',
parameters: {
...(bucket ? { bucket } : {}),
...(prefix ? { prefix } : {}),
...(nextPage ? { next_page_parameters: nextPage } : {}),
},
pluginId: providerOption.plugin.plugin_id,
provider: providerOption.plugin.provider,
},
})
const nextResources: PreviewResource[] =
draft.sourceType === 'onlineDocuments'
? (response.documents ?? []).map((document) => ({
depth,
document,
key: `document:${document.provider_item_id}`,
kind: 'document' as const,
parentKey,
}))
: (response.files ?? []).map((file) => ({
depth,
file,
key: `file:${file.provider_item_id}`,
kind: 'file' as const,
parentKey,
}))
setResources((current) => {
const next = new Map((append ? current : []).map((resource) => [resource.key, resource]))
for (const resource of nextResources) next.set(resource.key, resource)
return [...next.values()]
})
if (parentKey) setExpanded((current) => new Set(current).add(parentKey))
setNextPageRequest(
response.next_page_parameters
? {
bucket,
depth,
nextPage: response.next_page_parameters,
parentKey,
prefix,
}
: null,
)
setPreviewed(true)
} catch {
setError(true)
} finally {
setLoading(false)
setLoadingMore(false)
}
},
[credential.id, draft.sourceType, providerOption],
)
const toggle = (key: string) => {
setSelected((current) => {
const next = new Set(current)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const toggleAll = () => {
setSelected((current) => {
const next = new Set(current)
const allSelected =
selectableResources.length > 0 &&
selectableResources.every((resource) => current.has(resource.key))
for (const resource of selectableResources) {
if (allSelected) next.delete(resource.key)
else next.add(resource.key)
}
return next
})
}
const expandContainer = (resource: Extract<PreviewResource, { kind: 'file' }>) => {
if (expanded.has(resource.key)) {
setExpanded((current) => {
const next = new Set(current)
next.delete(resource.key)
return next
})
return
}
void requestPreview({
append: true,
bucket: resource.file.bucket ?? undefined,
depth: resource.depth + 1,
parentKey: resource.key,
prefix: resource.file.id || undefined,
})
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SourceNameField
disabled={disabled}
draft={draft}
preventSubmitOnEnter
size="medium"
onDraftChange={onDraftChange}
/>
<SourceSyncPolicyField
availablePolicies={draft.provider === 'Amazon S3' ? ['daily', 'manual'] : undefined}
disabled={disabled}
draft={draft}
size="medium"
onDraftChange={onDraftChange}
/>
</div>
{!previewed && (
<Button
type="button"
variant="primary"
className="w-full"
loading={loading}
disabled={disabled || loading}
onClick={() => void requestPreview()}
>
{t(($) => $['newKnowledge.preview'])}
</Button>
)}
{loading && (
<div className="flex min-h-40 items-center justify-center rounded-lg border border-divider-subtle">
<Loading />
</div>
)}
{error && (
<div role="alert" className="rounded-lg bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.providerLoadFailed'])}
</p>
<Button className="mt-3" onClick={() => void requestPreview()}>
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
)}
{previewed && !loading && (
<section className="overflow-hidden rounded-lg border border-divider-subtle bg-background-default">
<div className="flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
<Checkbox
aria-label={t(($) => $['newKnowledge.selectAll'])}
checked={
selectableResources.length > 0 &&
selectableResources.every((resource) => selected.has(resource.key))
}
disabled={disabled || !selectableResources.length}
onCheckedChange={toggleAll}
/>
<span className="system-xs-medium text-text-secondary">
{draft.sourceType === 'onlineDocuments'
? t(($) => $['newKnowledge.selectPagesToSync'])
: t(($) => $['newKnowledge.selectFilesAndFolders'])}
</span>
<span className="ml-auto system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.pagesSelected'], { count: selected.size })}
</span>
</div>
<ul className="max-h-64 overflow-y-auto p-1.5">
{visibleResources.map((resource) => {
const container = resource.kind === 'file' && isDriveContainer(resource.file)
return (
<li
key={resource.key}
className="flex min-h-8 items-center gap-2 rounded-md px-2 hover:bg-state-base-hover"
style={{ paddingLeft: `${8 + resource.depth * 20}px` }}
>
{container ? (
<Button
type="button"
variant="ghost"
size="small"
className="size-5 px-0"
aria-label={resourceName(resource)}
disabled={disabled || loadingMore}
onClick={() => expandContainer(resource)}
>
<span
aria-hidden
className={`i-ri-arrow-right-s-line size-4 transition-transform ${
expanded.has(resource.key) ? 'rotate-90' : ''
}`}
/>
</Button>
) : (
<Checkbox
aria-label={resourceName(resource)}
checked={selected.has(resource.key)}
disabled={disabled}
onCheckedChange={() => toggle(resource.key)}
/>
)}
<span aria-hidden className={`${resourceIcon(resource)} size-4 shrink-0`} />
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-primary">
{resourceName(resource)}
</span>
</li>
)
})}
</ul>
{nextPageRequest && (
<div className="border-t border-divider-subtle px-3 py-2 text-center">
<Button
type="button"
size="small"
variant="ghost"
loading={loadingMore}
onClick={() => void requestPreview({ append: true, ...nextPageRequest })}
>
{t(($) => $['newKnowledge.loadMore'])}
</Button>
</div>
)}
</section>
)}
</div>
)
}

View File

@ -1,12 +1,11 @@
'use client'
import type {
KnowledgeFsInitialWebsiteSourcePayload,
KnowledgeFsSpaceCreatePayload,
KnowledgeFsSpaceCreateResponse,
} from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { CreateKnowledgeExitReason } from './components/create-knowledge-exit-dialog'
import type { KnowledgeVisibility } from './create-knowledge-workflow'
import type { WebsiteCrawlPreviewSelection } from './create-source-setup'
import type { QueuedUpload } from './create-upload-queue'
import type { KnowledgeFsUploadPhase, KnowledgeFsUploadProgress } from './knowledge-fs-upload'
import type { NewKnowledgeSourceDraft, NewKnowledgeStartMode } from './routes'
@ -64,58 +63,19 @@ import { uploadKnowledgeFsDocuments } from './knowledge-fs-upload'
import { createRequestId } from './request-id'
import {
createNewKnowledgeSourceDraft,
isValidWebsiteSourceDraft,
newKnowledgeDetailPath,
newKnowledgeDocumentsPath,
newKnowledgeListPath,
newKnowledgeSettingsPath,
} from './routes'
type InitialSource = NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
function normalizeStartMode(value: string | null): NewKnowledgeStartMode {
if (value === 'source' || value === 'upload') return value
return 'empty'
}
function initialWebsiteSourceFromSelection(
draft: NewKnowledgeSourceDraft,
selection?: WebsiteCrawlPreviewSelection,
): KnowledgeFsInitialWebsiteSourcePayload | undefined {
if (
draft.sourceType !== 'websiteCrawl' ||
draft.provider !== 'Firecrawl' ||
!selection ||
selection.draft.sourceType !== 'websiteCrawl' ||
selection.draft.provider !== draft.provider ||
selection.draft.rootUrl !== draft.rootUrl ||
selection.draft.sourceName !== draft.sourceName ||
selection.draft.includeSubpages !== draft.includeSubpages ||
selection.draft.maxPages !== draft.maxPages ||
selection.draft.syncPolicy !== draft.syncPolicy
)
return undefined
const selectedPages = selection.pages.filter((page) =>
selection.selectedPageIds.includes(page.pageId),
)
if (!selectedPages.length) return undefined
return {
crawl_options: {
include_subpages: draft.includeSubpages,
limit: draft.maxPages,
},
kind: 'website_crawl',
name: draft.sourceName.trim(),
provider: 'firecrawl',
root_url: draft.rootUrl,
selection: selectedPages.map((page) => ({
source_url: page.sourceUrl,
...(page.title ? { title: page.title } : {}),
})),
sync_policy: draft.syncPolicy,
}
}
export function CreateKnowledgePage() {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
@ -141,9 +101,8 @@ export function CreateKnowledgePage() {
const [sourceDraft, setSourceDraft] = useState<NewKnowledgeSourceDraft>(() =>
createNewKnowledgeSourceDraft('websiteCrawl'),
)
const [websitePreviewSelection, setWebsitePreviewSelection] =
useState<WebsiteCrawlPreviewSelection>()
const websitePreviewSelectionRef = useRef<WebsiteCrawlPreviewSelection | undefined>(undefined)
const [initialSource, setInitialSource] = useState<InitialSource>()
const initialSourceRef = useRef<InitialSource | undefined>(undefined)
const [uploads, setUploads] = useState<QueuedUpload[]>([])
const [createdKnowledge, setCreatedKnowledge] = useState<KnowledgeFsSpaceCreateResponse>()
const [modelSetupDialogOpen, setModelSetupDialogOpen] = useState(false)
@ -167,15 +126,7 @@ export function CreateKnowledgePage() {
const uploadSubmissionBlocked =
startMode === 'upload' &&
(!uploadAvailable || !uploads.length || uploads.some((upload) => upload.issue))
const initialWebsiteSource = initialWebsiteSourceFromSelection(
sourceDraft,
websitePreviewSelection,
)
const sourceSubmissionBlocked =
startMode === 'source' &&
(sourceDraft.sourceType === 'websiteCrawl'
? !isValidWebsiteSourceDraft(sourceDraft) || !initialWebsiteSource
: !sourceDraft.sourceName.trim())
const sourceSubmissionBlocked = startMode === 'source' && !initialSource
const sourceDraftChanged =
JSON.stringify(sourceDraft) !==
JSON.stringify(createNewKnowledgeSourceDraft(sourceDraft.sourceType))
@ -189,9 +140,9 @@ export function CreateKnowledgePage() {
createdKnowledge,
)
const updateWebsitePreviewSelection = useCallback((selection?: WebsiteCrawlPreviewSelection) => {
websitePreviewSelectionRef.current = selection
setWebsitePreviewSelection(selection)
const updateInitialSource = useCallback((source?: InitialSource) => {
initialSourceRef.current = source
setInitialSource(source)
}, [])
const armHistoryGuard = useCallback(() => {
@ -321,14 +272,8 @@ export function CreateKnowledgePage() {
const normalizedDescription = description.trim()
if (!normalizedName) return
const latestWebsitePreviewSelection =
websitePreviewSelectionRef.current ?? websitePreviewSelection
const initialSource = initialWebsiteSourceFromSelection(
sourceDraft,
latestWebsitePreviewSelection,
)
if (startMode === 'source' && sourceDraft.sourceType === 'websiteCrawl' && !initialSource)
return
const latestInitialSource = initialSourceRef.current ?? initialSource
if (startMode === 'source' && !latestInitialSource) return
idempotencyKeyRef.current ??= createRequestId()
setSubmissionLocked(true)
@ -337,7 +282,7 @@ export function CreateKnowledgePage() {
existingKnowledge: createdKnowledge,
description: normalizedDescription,
idempotencyKey: idempotencyKeyRef.current,
initialSource,
initialSource: startMode === 'source' ? latestInitialSource : undefined,
name: normalizedName,
onCreated: (knowledgeSpace) => {
setCreatedKnowledge(knowledgeSpace)
@ -573,14 +518,12 @@ export function CreateKnowledgePage() {
draft={sourceDraft}
onDraftChange={(value) => {
setSourceDraft(value)
updateWebsitePreviewSelection(undefined)
resetUnsubmittedError()
}}
onWebsitePreviewSelectionChange={updateWebsitePreviewSelection}
onInitialSourceChange={updateInitialSource}
onSourceTypeChange={(value) => {
if (value !== 'websiteCrawl') return
setSourceDraft(createNewKnowledgeSourceDraft(value))
updateWebsitePreviewSelection(undefined)
updateInitialSource(undefined)
resetUnsubmittedError()
}}
/>

View File

@ -1,6 +1,5 @@
import type {
KnowledgeFsControlSpaceVisibility,
KnowledgeFsInitialWebsiteSourcePayload,
KnowledgeFsModelIntent,
KnowledgeFsSpaceCreatePayload,
KnowledgeFsSpaceCreateResponse,
@ -21,7 +20,7 @@ type CreateKnowledgeValues = {
existingKnowledge?: KnowledgeFsSpaceCreateResponse
description: string
idempotencyKey: string
initialSource?: KnowledgeFsInitialWebsiteSourcePayload
initialSource?: NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
name: string
onCreated: (knowledgeSpace: KnowledgeFsSpaceCreateResponse) => void
visibility: KnowledgeVisibility

View File

@ -1,13 +1,12 @@
'use client'
import type {
NewKnowledgeOnlineDocumentsProvider,
NewKnowledgeOnlineDriveProvider,
NewKnowledgeSourceDraft,
NewKnowledgeWebsiteProvider,
NewKnowledgeWebsiteSourceDraft,
} from './routes'
import type { KnowledgeFsSpaceCreatePayload } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import type { NewKnowledgeSourceDraft } from './routes'
import type { CrawlPreviewPage } from './source-models'
import type {
DataSourceAuth,
DataSourceCredential,
} from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlResultItem } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
@ -25,13 +24,29 @@ import {
} from '@langgenius/dify-ui/number-field'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import {
checkFirecrawlTaskStatus,
checkJinaReaderTaskStatus,
checkWatercrawlTaskStatus,
createFirecrawlTask,
createJinaReaderTask,
createWatercrawlTask,
} from '@/service/datasets'
import { useGetDataSourceListAuth } from '@/service/use-datasource'
import { useDataSourceList } from '@/service/use-pipeline'
import { CrawlPreviewPageSelection } from './crawl-selection-form'
import { CreateConnectedSourceSetup } from './create-connected-source-setup'
import { isValidWebsiteSourceDraft, NEW_KNOWLEDGE_SOURCE_URL_MAX_LENGTH } from './routes'
import {
discoverSourceProviderOptions,
normalizeSourceProviderName,
sourceProviderOptionForDraft,
} from './source-provider-options'
import {
SourceConnectionRequiredCard,
SourceNameField,
SourceProviderNotInstalledCard,
SourceProviderRadioGroup,
SourceSyncPolicyField,
SourceTypeSelector,
@ -48,12 +63,7 @@ const CRAWL_PREVIEW_SKELETONS = [
] as const
type LocalCrawlState = 'error' | 'idle' | 'running' | 'stopped' | 'success'
export type WebsiteCrawlPreviewSelection = {
draft: NewKnowledgeWebsiteSourceDraft
pages: CrawlPreviewPage[]
selectedPageIds: string[]
}
type InitialSource = NonNullable<KnowledgeFsSpaceCreatePayload['initial_source']>
function crawlPages(response: Record<string, unknown>): CrawlResultItem[] {
if (!Array.isArray(response.data)) return []
@ -92,129 +102,56 @@ function crawlPreviewPages(pages: CrawlResultItem[]): CrawlPreviewPage[] {
}))
}
const providers = {
onlineDocuments: [
{ available: false, icon: 'i-custom-public-common-notion', label: 'Notion' },
{
available: false,
icon: 'i-ri-file-text-fill text-[#4d8bf5]',
label: 'Google Docs',
},
{ available: false, icon: 'i-custom-public-common-confluence', label: 'Confluence' },
],
onlineDrive: [
{ available: false, icon: 'i-custom-public-common-google-drive', label: 'Google Drive' },
{ available: false, icon: 'i-ri-cloud-line', label: 'OneDrive' },
{ available: false, icon: 'i-ri-box-3-line', label: 'Amazon S3' },
],
websiteCrawl: [
{ icon: 'i-custom-public-common-firecrawl', label: 'Firecrawl', available: true },
{ available: false, icon: 'i-custom-public-llm-jina', label: 'Jina Reader' },
{
available: false,
icon: 'i-custom-public-knowledge-watercrawl',
label: 'WaterCrawl',
},
{
available: false,
icon: 'i-ri-global-line text-text-accent',
label: 'FakeCrawler',
},
],
} as const
function ConnectedSourceConfiguration({
disabled,
draft,
onDraftChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
}) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SourceNameField
disabled={disabled}
draft={draft}
preventSubmitOnEnter
size="medium"
onDraftChange={onDraftChange}
/>
<SourceSyncPolicyField
disabled={disabled}
draft={draft}
size="medium"
onDraftChange={onDraftChange}
/>
</div>
function datasourceAuthForProvider(
authProviders: DataSourceAuth[],
pluginId: string,
provider: string,
) {
return authProviders.find(
(candidate) => candidate.plugin_id === pluginId && candidate.provider === provider,
)
}
function NotionSourceConfiguration({
disabled,
draft,
onConnect,
onDraftChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onConnect: () => void
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
}) {
const { t } = useTranslation('dataset')
const datasourceAuthQuery = useGetDataSourceListAuth()
const notionConnected = Boolean(
datasourceAuthQuery.data?.result.some(
(auth) =>
auth.credentials_list.length > 0 &&
[auth.name, auth.plugin_id, auth.provider].some((identity) =>
identity
.toLocaleLowerCase()
.replaceAll(/[^a-z0-9]/g, '')
.includes('notion'),
),
),
)
if (notionConnected) {
return (
<ConnectedSourceConfiguration
disabled={disabled}
draft={draft}
onDraftChange={onDraftChange}
/>
)
}
function preferredCredential(auth?: DataSourceAuth): DataSourceCredential | undefined {
return (
<SourceConnectionRequiredCard
actionLabel={t(($) => $['newKnowledge.connectNotion'])}
description={t(($) => $['newKnowledge.notionNotConnectedDescription'])}
disabled={disabled}
icon={<span aria-hidden className="i-custom-public-common-notion size-4.5" />}
title={t(($) => $['newKnowledge.notionNotConnected'])}
onConnect={onConnect}
/>
auth?.credentials_list.find((credential) => credential.is_default) ?? auth?.credentials_list[0]
)
}
function providerIntegrationPath(packageId?: string) {
const base = buildIntegrationPath('data-source')
if (!packageId) return base
const query = new URLSearchParams({ 'package-ids': JSON.stringify([packageId]) })
return `${base}?${query.toString()}`
}
function websiteProviderTransport(provider: string) {
const normalized = normalizeSourceProviderName(provider)
if (normalized.includes('firecrawl'))
return { check: checkFirecrawlTaskStatus, create: createFirecrawlTask }
if (normalized.includes('jinareader') || normalized === 'jina')
return { check: checkJinaReaderTaskStatus, create: createJinaReaderTask }
if (normalized.includes('watercrawl'))
return { check: checkWatercrawlTaskStatus, create: createWatercrawlTask }
}
export function CreateSourceSetup({
disabled,
draft,
onDraftChange,
onWebsitePreviewSelectionChange,
onInitialSourceChange,
onSourceTypeChange,
}: {
disabled: boolean
draft: NewKnowledgeSourceDraft
onDraftChange: (draft: NewKnowledgeSourceDraft) => void
onWebsitePreviewSelectionChange?: (selection?: WebsiteCrawlPreviewSelection) => void
onInitialSourceChange: (source?: InitialSource) => void
onSourceTypeChange: (sourceType: NewKnowledgeSourceDraft['sourceType']) => void
}) {
const { t } = useTranslation('dataset')
const datasourcePluginsQuery = useDataSourceList(true)
const datasourceAuthQuery = useGetDataSourceListAuth()
const [optionsExpanded, setOptionsExpanded] = useState(false)
const [backendBoundaryVisible, setBackendBoundaryVisible] = useState(false)
const [crawlState, setCrawlState] = useState<LocalCrawlState>('idle')
const [previewPages, setPreviewPages] = useState<CrawlResultItem[]>([])
const [selectedPageIds, setSelectedPageIds] = useState<Set<string>>(() => new Set())
@ -222,19 +159,35 @@ export function CreateSourceSetup({
const pollResolveRef = useRef<(() => void) | undefined>(undefined)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const sourceType = draft.sourceType
const availableProviders = providers[sourceType]
const activeProvider = availableProviders.some((provider) => provider.label === draft.provider)
? draft.provider
: availableProviders[0].label
const websiteProviderAvailable =
draft.sourceType === 'websiteCrawl' && draft.provider === 'Firecrawl'
const previewReady = websiteProviderAvailable && isValidWebsiteSourceDraft(draft)
const providerOptions = useMemo(
() => discoverSourceProviderOptions(sourceType, datasourcePluginsQuery.data ?? []),
[datasourcePluginsQuery.data, sourceType],
)
const providerOption = sourceProviderOptionForDraft(providerOptions, draft)
const installedProviderOption = providerOption?.installed ? providerOption : undefined
const datasourceAuth = installedProviderOption
? datasourceAuthForProvider(
datasourceAuthQuery.data?.result ?? [],
installedProviderOption.plugin.plugin_id,
installedProviderOption.plugin.provider,
)
: undefined
const credential = preferredCredential(datasourceAuth)
const websiteTransport =
draft.sourceType === 'websiteCrawl' && installedProviderOption
? websiteProviderTransport(installedProviderOption.plugin.provider)
: undefined
const previewReady = Boolean(
websiteTransport &&
credential &&
draft.sourceType === 'websiteCrawl' &&
isValidWebsiteSourceDraft(draft),
)
const previewRootUrl = draft.sourceType === 'websiteCrawl' ? draft.rootUrl : ''
const selectionPages = useMemo(() => crawlPreviewPages(previewPages), [previewPages])
const crawlOptionsAreDefault =
draft.sourceType !== 'websiteCrawl' ||
(draft.includeSubpages === DEFAULT_INCLUDE_SUBPAGES && draft.maxPages === DEFAULT_MAX_PAGES)
const showBackendBoundary = () => setBackendBoundaryVisible(true)
const stopPreview = (state: LocalCrawlState = 'stopped') => {
crawlAttemptRef.current += 1
globalThis.clearTimeout(pollTimerRef.current)
@ -247,23 +200,29 @@ export function CreateSourceSetup({
stopPreview('idle')
setPreviewPages([])
setSelectedPageIds(new Set())
onWebsitePreviewSelectionChange?.(undefined)
onInitialSourceChange(undefined)
}
const updateDraft = (nextDraft: NewKnowledgeSourceDraft) => {
onDraftChange(nextDraft)
setBackendBoundaryVisible(false)
resetPreview()
}
const updateDraftWithoutReset = (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 })
const selectProvider = (providerKey: string) => {
const nextProvider = providerOptions.find((option) => option.key === providerKey)
if (!nextProvider) return
updateDraft({
...draft,
provider: nextProvider.label,
providerKey: nextProvider.key,
sourceName: '',
...(draft.sourceType === 'onlineDrive' &&
nextProvider.label === 'Amazon S3' &&
draft.syncPolicy === 'provider'
? { syncPolicy: 'daily' as const }
: {}),
})
}
useEffect(
@ -276,7 +235,14 @@ export function CreateSourceSetup({
)
const startPreview = async () => {
if (draft.sourceType !== 'websiteCrawl' || !isValidWebsiteSourceDraft(draft)) return
if (
draft.sourceType !== 'websiteCrawl' ||
!isValidWebsiteSourceDraft(draft) ||
!websiteTransport ||
!credential ||
!installedProviderOption
)
return
const attempt = crawlAttemptRef.current + 1
crawlAttemptRef.current = attempt
globalThis.clearTimeout(pollTimerRef.current)
@ -284,7 +250,7 @@ export function CreateSourceSetup({
setSelectedPageIds(new Set())
setCrawlState('running')
try {
const created = (await createFirecrawlTask({
const created = (await websiteTransport.create({
options: {
crawl_sub_pages: draft.includeSubpages,
excludes: '',
@ -300,7 +266,7 @@ export function CreateSourceSetup({
if (!jobId) throw new Error('Website crawl did not return a job id')
while (crawlAttemptRef.current === attempt) {
const response = (await checkFirecrawlTaskStatus(jobId)) as Record<string, unknown>
const response = (await websiteTransport.check(jobId)) as Record<string, unknown>
if (crawlAttemptRef.current !== attempt) return
setPreviewPages(crawlPages(response))
if (response.status === 'completed') {
@ -326,38 +292,59 @@ export function CreateSourceSetup({
const updateSelectedPageIds = (pageIds: Set<string>) => {
setSelectedPageIds(pageIds)
if (draft.sourceType !== 'websiteCrawl' || crawlState !== 'success' || !selectionPages.length) {
onWebsitePreviewSelectionChange?.(undefined)
return
}
onWebsitePreviewSelectionChange?.({
draft,
pages: selectionPages,
selectedPageIds: [...pageIds],
})
}
useEffect(() => {
if (draft.sourceType !== 'websiteCrawl' || crawlState !== 'success' || !selectionPages.length) {
onWebsitePreviewSelectionChange?.(undefined)
if (
draft.sourceType !== 'websiteCrawl' ||
crawlState !== 'success' ||
!selectionPages.length ||
!installedProviderOption ||
!credential
) {
onInitialSourceChange(undefined)
return
}
onWebsitePreviewSelectionChange?.({
draft,
pages: selectionPages,
selectedPageIds: [...selectedPageIds],
const selectedPages = selectionPages.filter((page) => selectedPageIds.has(page.pageId))
if (!selectedPages.length) {
onInitialSourceChange(undefined)
return
}
onInitialSourceChange({
crawl_options: {
include_subpages: draft.includeSubpages,
limit: draft.maxPages,
},
credentialId: credential.id,
datasource: installedProviderOption.datasource.identity.name,
kind: 'website_crawl',
name: draft.sourceName.trim(),
pluginId: installedProviderOption.plugin.plugin_id,
provider: installedProviderOption.plugin.provider,
root_url: draft.rootUrl,
selection: selectedPages.map((page) => ({
source_url: page.sourceUrl,
...(page.title ? { title: page.title } : {}),
})),
sync_policy: draft.syncPolicy,
})
}, [crawlState, draft, onWebsitePreviewSelectionChange, selectedPageIds, selectionPages])
}, [
crawlState,
credential,
draft,
installedProviderOption,
onInitialSourceChange,
selectedPageIds,
selectionPages,
])
return (
<div className="mx-4 -mt-1 mb-3.75 flex flex-col gap-4">
<SourceTypeSelector
appearance="embedded"
disabled={disabled}
disabledValues={['onlineDocuments', 'onlineDrive']}
value={sourceType}
onChange={(value) => {
setBackendBoundaryVisible(false)
onSourceTypeChange(value)
}}
/>
@ -374,29 +361,92 @@ export function CreateSourceSetup({
type="button"
variant="ghost-accent"
size="small"
disabled
disabled={disabled}
className="gap-0.5 px-2.75"
onClick={showBackendBoundary}
onClick={() =>
globalThis.open(buildIntegrationPath('data-source'), '_blank', 'noopener,noreferrer')
}
>
{t(($) => $['newKnowledge.moreProviders'])}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
</div>
<SourceProviderRadioGroup
value={activeProvider}
value={providerOption?.key ?? ''}
disabled={disabled}
layout={sourceType === 'websiteCrawl' ? 'grid-four' : 'grid-three'}
options={providers[sourceType].map((provider) => ({
disabled: !provider.available,
icon: <span aria-hidden className={`${provider.icon} size-4 shrink-0`} />,
value: provider.label,
options={providerOptions.map((option) => ({
icon: <span aria-hidden className={`${option.fallbackIcon} size-4 shrink-0`} />,
label: option.label,
value: option.key,
}))}
surface="default"
onChange={selectProvider}
/>
</Fieldset>
{draft.sourceType === 'websiteCrawl' && (
{datasourcePluginsQuery.isPending || datasourceAuthQuery.isPending ? (
<div className="flex min-h-44 items-center justify-center">
<span aria-hidden className="i-ri-loader-4-line size-5 animate-spin text-text-tertiary" />
</div>
) : datasourcePluginsQuery.error || datasourceAuthQuery.error ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">
{t(($) => $['newKnowledge.providerLoadFailed'])}
</p>
<Button
className="mt-3"
onClick={() =>
void Promise.all([datasourcePluginsQuery.refetch(), datasourceAuthQuery.refetch()])
}
>
{t(($) => $['newKnowledge.retryProviderLoad'])}
</Button>
</div>
) : providerOption && !providerOption.installed ? (
<SourceProviderNotInstalledCard
icon={<span aria-hidden className={`${providerOption.fallbackIcon} size-4.5`} />}
provider={providerOption.label}
onInstall={() =>
globalThis.open(
providerIntegrationPath(providerOption.packageId),
'_blank',
'noopener,noreferrer',
)
}
/>
) : installedProviderOption && !credential ? (
<SourceConnectionRequiredCard
actionLabel={t(($) => $['newKnowledge.connectProvider'], {
provider: installedProviderOption.label,
})}
description={t(($) => $['newKnowledge.providerCredentialRequiredDescription'], {
provider: installedProviderOption.label,
})}
disabled={disabled}
icon={<span aria-hidden className={`${installedProviderOption.fallbackIcon} size-4.5`} />}
title={t(($) => $['newKnowledge.providerNotConfigured'], {
provider: installedProviderOption.label,
})}
onConnect={() =>
globalThis.open(
providerIntegrationPath(installedProviderOption.packageId),
'_blank',
'noopener,noreferrer',
)
}
/>
) : draft.sourceType === 'websiteCrawl' &&
installedProviderOption &&
credential &&
!websiteTransport ? (
<div className="rounded-xl bg-background-section p-4">
<p className="system-sm-semibold text-text-primary">{installedProviderOption.label}</p>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.providerUnavailable'])}
</p>
</div>
) : draft.sourceType === 'websiteCrawl' && installedProviderOption && credential ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field name="rootUrl" className="gap-1.5">
@ -618,35 +668,17 @@ export function CreateSourceSetup({
/>
)}
</div>
)}
{draft.sourceType !== 'websiteCrawl' && (
<>
{draft.sourceType === 'onlineDocuments' && activeProvider === 'Notion' ? (
<NotionSourceConfiguration
disabled={disabled || !websiteProviderAvailable}
draft={draft}
onConnect={showBackendBoundary}
onDraftChange={updateDraft}
/>
) : (
<ConnectedSourceConfiguration
disabled={disabled || !websiteProviderAvailable}
draft={draft}
onDraftChange={updateDraft}
/>
)}
</>
)}
{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>
)}
) : draft.sourceType !== 'websiteCrawl' && installedProviderOption && credential ? (
<CreateConnectedSourceSetup
key={`${draft.sourceType}:${installedProviderOption.key}:${credential.id}`}
credential={credential}
disabled={disabled}
draft={draft}
providerOption={installedProviderOption}
onDraftChange={updateDraftWithoutReset}
onInitialSourceChange={onInitialSourceChange}
/>
) : null}
</div>
)
}