Merge branch 'main' into feat/enchance-warn-user-time-when-need-upgrade-plan

This commit is contained in:
twwu 2025-12-10 17:19:23 +08:00
commit 5891731ab2
86 changed files with 2241 additions and 333 deletions

8
.github/CODEOWNERS vendored
View File

@ -9,6 +9,14 @@
# Backend (default owner, more specific rules below will override)
api/ @QuantumGhost
# Backend - MCP
api/core/mcp/ @Nov1c444
api/core/entities/mcp_provider.py @Nov1c444
api/services/tools/mcp_tools_manage_service.py @Nov1c444
api/controllers/mcp/ @Nov1c444
api/controllers/console/app/mcp_server.py @Nov1c444
api/tests/**/*mcp* @Nov1c444
# Backend - Workflow - Engine (Core graph execution engine)
api/core/workflow/graph_engine/ @laipz8200 @QuantumGhost
api/core/workflow/runtime/ @laipz8200 @QuantumGhost

View File

@ -1,8 +1,6 @@
name: "✨ Refactor"
description: Refactor existing code for improved readability and maintainability.
title: "[Chore/Refactor] "
labels:
- refactor
name: "✨ Refactor or Chore"
description: Refactor existing code or perform maintenance chores to improve readability and reliability.
title: "[Refactor/Chore] "
body:
- type: checkboxes
attributes:
@ -11,7 +9,7 @@ body:
options:
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
required: true
- label: This is only for refactoring, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- label: This is only for refactors or chores; if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
required: true
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
required: true
@ -25,14 +23,14 @@ body:
id: description
attributes:
label: Description
placeholder: "Describe the refactor you are proposing."
placeholder: "Describe the refactor or chore you are proposing."
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
placeholder: "Explain why this refactor is necessary."
placeholder: "Explain why this refactor or chore is necessary."
validations:
required: false
- type: textarea

View File

@ -1,13 +0,0 @@
name: "👾 Tracker"
description: For inner usages, please do not use this template.
title: "[Tracker] "
labels:
- tracker
body:
- type: textarea
id: content
attributes:
label: Blockers
placeholder: "- [ ] ..."
validations:
required: true

View File

@ -422,7 +422,6 @@ class DatasetApi(Resource):
raise NotFound("Dataset not found.")
payload = DatasetUpdatePayload.model_validate(console_ns.payload or {})
payload_data = payload.model_dump(exclude_unset=True)
current_user, current_tenant_id = current_account_with_tenant()
# check embedding model setting
if (
@ -434,6 +433,7 @@ class DatasetApi(Resource):
dataset.tenant_id, payload.embedding_model_provider, payload.embedding_model
)
payload.is_multimodal = is_multimodal
payload_data = payload.model_dump(exclude_unset=True)
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
DatasetPermissionService.check_permission(
current_user, dataset, payload.permission, payload.partial_member_list

View File

@ -554,11 +554,16 @@ class LLMGenerator:
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
)
generated_raw = cast(str, response.message.content)
generated_raw = response.message.get_text_content()
first_brace = generated_raw.find("{")
last_brace = generated_raw.rfind("}")
return {**json.loads(generated_raw[first_brace : last_brace + 1])}
if first_brace == -1 or last_brace == -1 or last_brace < first_brace:
raise ValueError(f"Could not find a valid JSON object in response: {generated_raw}")
json_str = generated_raw[first_brace : last_brace + 1]
data = json_repair.loads(json_str)
if not isinstance(data, dict):
raise TypeError(f"Expected a JSON object, but got {type(data).__name__}")
return data
except InvokeError as e:
error = str(e)
return {"error": f"Failed to generate code. Error: {error}"}

View File

@ -371,7 +371,7 @@ class RetrievalService:
include_segment_ids = set()
segment_child_map = {}
segment_file_map = {}
with Session(db.engine) as session:
with Session(bind=db.engine, expire_on_commit=False) as session:
# Process documents
for document in documents:
segment_id = None
@ -395,7 +395,7 @@ class RetrievalService:
session,
)
if attachment_info_dict:
attachment_info = attachment_info_dict["attchment_info"]
attachment_info = attachment_info_dict["attachment_info"]
segment_id = attachment_info_dict["segment_id"]
else:
child_index_node_id = document.metadata.get("doc_id")
@ -417,13 +417,6 @@ class RetrievalService:
DocumentSegment.status == "completed",
DocumentSegment.id == segment_id,
)
.options(
load_only(
DocumentSegment.id,
DocumentSegment.content,
DocumentSegment.answer,
)
)
.first()
)
@ -475,7 +468,7 @@ class RetrievalService:
session,
)
if attachment_info_dict:
attachment_info = attachment_info_dict["attchment_info"]
attachment_info = attachment_info_dict["attachment_info"]
segment_id = attachment_info_dict["segment_id"]
document_segment_stmt = select(DocumentSegment).where(
DocumentSegment.dataset_id == dataset_document.dataset_id,
@ -483,7 +476,7 @@ class RetrievalService:
DocumentSegment.status == "completed",
DocumentSegment.id == segment_id,
)
segment = db.session.scalar(document_segment_stmt)
segment = session.scalar(document_segment_stmt)
if segment:
segment_file_map[segment.id] = [attachment_info]
else:
@ -496,7 +489,7 @@ class RetrievalService:
DocumentSegment.status == "completed",
DocumentSegment.index_node_id == index_node_id,
)
segment = db.session.scalar(document_segment_stmt)
segment = session.scalar(document_segment_stmt)
if not segment:
continue
@ -684,7 +677,7 @@ class RetrievalService:
.first()
)
if attachment_binding:
attchment_info = {
attachment_info = {
"id": upload_file.id,
"name": upload_file.name,
"extension": "." + upload_file.extension,
@ -692,5 +685,5 @@ class RetrievalService:
"source_url": sign_upload_file(upload_file.id, upload_file.extension),
"size": upload_file.size,
}
return {"attchment_info": attchment_info, "segment_id": attachment_binding.segment_id}
return {"attachment_info": attachment_info, "segment_id": attachment_binding.segment_id}
return None

View File

@ -266,7 +266,7 @@ class DatasetRetrieval:
).all()
if attachments_with_bindings:
for _, upload_file in attachments_with_bindings:
attchment_info = File(
attachment_info = File(
id=upload_file.id,
filename=upload_file.name,
extension="." + upload_file.extension,
@ -280,7 +280,7 @@ class DatasetRetrieval:
storage_key=upload_file.key,
url=sign_upload_file(upload_file.id, upload_file.extension),
)
context_files.append(attchment_info)
context_files.append(attachment_info)
if show_retrieve_source:
for record in records:
segment = record.segment

View File

@ -334,6 +334,7 @@ class LLMNode(Node[LLMNodeData]):
inputs=node_inputs,
process_data=process_data,
error_type=type(e).__name__,
llm_usage=usage,
)
)
except Exception as e:
@ -344,6 +345,8 @@ class LLMNode(Node[LLMNodeData]):
error=str(e),
inputs=node_inputs,
process_data=process_data,
error_type=type(e).__name__,
llm_usage=usage,
)
)
@ -694,7 +697,7 @@ class LLMNode(Node[LLMNodeData]):
).all()
if attachments_with_bindings:
for _, upload_file in attachments_with_bindings:
attchment_info = File(
attachment_info = File(
id=upload_file.id,
filename=upload_file.name,
extension="." + upload_file.extension,
@ -708,7 +711,7 @@ class LLMNode(Node[LLMNodeData]):
storage_key=upload_file.key,
url=sign_upload_file(upload_file.id, upload_file.extension),
)
context_files.append(attchment_info)
context_files.append(attachment_info)
yield RunRetrieverResourceEvent(
retriever_resources=original_retriever_resource,
context=context_str.strip(),

View File

@ -221,6 +221,7 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]):
status=WorkflowNodeExecutionStatus.FAILED,
inputs=variables,
error=str(e),
error_type=type(e).__name__,
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,

View File

@ -78,7 +78,7 @@ class Dataset(Base):
pipeline_id = mapped_column(StringUUID, nullable=True)
chunk_structure = mapped_column(sa.String(255), nullable=True)
enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
is_multimodal = mapped_column(sa.Boolean, nullable=False, server_default=db.text("false"))
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=db.text("false"))
@property
def total_documents(self):

View File

@ -259,7 +259,7 @@ class App(Base):
provider_id = tool.get("provider_id", "")
if provider_type == ToolProviderType.API:
if uuid.UUID(provider_id) not in existing_api_providers:
if provider_id not in existing_api_providers:
deleted_tools.append(
{
"type": ToolProviderType.API,

View File

@ -907,19 +907,29 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
@property
def extras(self) -> dict[str, Any]:
from core.tools.tool_manager import ToolManager
from core.trigger.trigger_manager import TriggerManager
extras: dict[str, Any] = {}
if self.execution_metadata_dict:
if self.node_type == NodeType.TOOL and "tool_info" in self.execution_metadata_dict:
tool_info: dict[str, Any] = self.execution_metadata_dict["tool_info"]
execution_metadata = self.execution_metadata_dict
if execution_metadata:
if self.node_type == NodeType.TOOL and "tool_info" in execution_metadata:
tool_info: dict[str, Any] = execution_metadata["tool_info"]
extras["icon"] = ToolManager.get_tool_icon(
tenant_id=self.tenant_id,
provider_type=tool_info["provider_type"],
provider_id=tool_info["provider_id"],
)
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in self.execution_metadata_dict:
datasource_info = self.execution_metadata_dict["datasource_info"]
elif self.node_type == NodeType.DATASOURCE and "datasource_info" in execution_metadata:
datasource_info = execution_metadata["datasource_info"]
extras["icon"] = datasource_info.get("icon")
elif self.node_type == NodeType.TRIGGER_PLUGIN and "trigger_info" in execution_metadata:
trigger_info = execution_metadata["trigger_info"] or {}
provider_id = trigger_info.get("provider_id")
if provider_id:
extras["icon"] = TriggerManager.get_trigger_plugin_icon(
tenant_id=self.tenant_id,
provider_id=provider_id,
)
return extras
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]:

View File

@ -1129,6 +1129,9 @@ WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai
WEAVIATE_DISABLE_TELEMETRY=false
WEAVIATE_ENABLE_TOKENIZER_GSE=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false
# ------------------------------
# Environment Variables for Chroma

View File

@ -451,6 +451,9 @@ services:
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
# OceanBase vector database
oceanbase:

View File

@ -479,6 +479,9 @@ x-shared-env: &shared-api-worker-env
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
WEAVIATE_DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
WEAVIATE_ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456}
CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider}
CHROMA_IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE}
@ -1085,6 +1088,9 @@ services:
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
# OceanBase vector database
oceanbase:

View File

@ -42,6 +42,7 @@ import type { InputVar, Variable } from '@/app/components/workflow/types'
import { appDefaultIconBackground } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
@ -153,6 +154,7 @@ const AppPublisher = ({
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const openAsyncWindow = useAsyncWindowOpen()
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
@ -217,17 +219,19 @@ const AppPublisher = ({
}, [disabled, onToggle, open])
const handleOpenInExplore = useCallback(async () => {
try {
await openAsyncWindow(async () => {
if (!appDetail?.id)
throw new Error('App not found')
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
if (installed_apps?.length > 0)
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
else
throw new Error('No app found in Explore')
}
catch (e: any) {
Toast.notify({ type: 'error', message: `${e.message || e}` })
}
}, [appDetail?.id])
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: `${err.message || err}` })
},
})
}, [appDetail?.id, openAsyncWindow])
const handleAccessControlUpdate = useCallback(async () => {
if (!appDetail)

View File

@ -27,6 +27,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { fetchInstalledAppList } from '@/service/explore'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import Tooltip from '@/app/components/base/tooltip'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { formatTime } from '@/utils/time'
@ -64,6 +65,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
@ -247,11 +249,16 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
props.onClick?.()
e.preventDefault()
try {
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
if (installed_apps?.length > 0)
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
else
await openAsyncWindow(async () => {
const { installed_apps }: any = await fetchInstalledAppList(app.id) || {}
if (installed_apps?.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: `${err.message || err}` })
},
})
}
catch (e: any) {
Toast.notify({ type: 'error', message: `${e.message || e}` })

View File

@ -21,6 +21,7 @@ type NotionPageSelectorProps = {
datasetId?: string
credentialList: DataSourceCredential[]
onSelectCredential?: (credentialId: string) => void
supportBatchUpload?: boolean
}
const NotionPageSelector = ({
@ -32,6 +33,7 @@ const NotionPageSelector = ({
datasetId = '',
credentialList,
onSelectCredential,
supportBatchUpload = false,
}: NotionPageSelectorProps) => {
const [searchValue, setSearchValue] = useState('')
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
@ -110,7 +112,7 @@ const NotionPageSelector = ({
setCurrentCredential(credential)
onSelect([]) // Clear selected pages when changing credential
onSelectCredential?.(credential.credentialId)
}, [invalidPreImportNotionPages, onSelect, onSelectCredential])
}, [datasetId, invalidPreImportNotionPages, notionCredentials, onSelect, onSelectCredential])
const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => {
const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId])
@ -175,6 +177,7 @@ const NotionPageSelector = ({
canPreview={canPreview}
previewPageId={previewPageId}
onPreview={handlePreviewPage}
isMultipleChoice={supportBatchUpload}
/>
)}
</div>

View File

@ -1,9 +1,8 @@
'use client'
import { useTranslation } from 'react-i18next'
import React, { Fragment, useMemo } from 'react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowDownSLine } from '@remixicon/react'
import NotionIcon from '../../notion-icon'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
export type NotionCredential = {
credentialId: string
@ -23,14 +22,10 @@ const CredentialSelector = ({
items,
onSelect,
}: CredentialSelectorProps) => {
const { t } = useTranslation()
const currentCredential = items.find(item => item.credentialId === value)!
const getDisplayName = (item: NotionCredential) => {
return item.workspaceName || t('datasetPipeline.credentialSelector.name', {
credentialName: item.credentialName,
pluginName: 'Notion',
})
return item.workspaceName || item.credentialName
}
const currentDisplayName = useMemo(() => {
@ -43,10 +38,11 @@ const CredentialSelector = ({
({ open }) => (
<>
<MenuButton className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}>
<NotionIcon
<CredentialIcon
className='mr-2'
src={currentCredential?.workspaceIcon}
avatarUrl={currentCredential?.workspaceIcon}
name={currentDisplayName}
size={20}
/>
<div
className='mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary'
@ -80,10 +76,11 @@ const CredentialSelector = ({
className='flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
onClick={() => onSelect(item.credentialId)}
>
<NotionIcon
<CredentialIcon
className='mr-2 shrink-0'
src={item.workspaceIcon}
avatarUrl={item.workspaceIcon}
name={displayName}
size={20}
/>
<div
className='system-sm-medium mr-2 grow truncate text-text-secondary'

View File

@ -7,6 +7,7 @@ import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon'
import cn from '@/utils/classnames'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import Radio from '@/app/components/base/radio/ui'
type PageSelectorProps = {
value: Set<string>
@ -18,6 +19,7 @@ type PageSelectorProps = {
canPreview?: boolean
previewPageId?: string
onPreview?: (selectedPageId: string) => void
isMultipleChoice?: boolean
}
type NotionPageTreeItem = {
children: Set<string>
@ -80,6 +82,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean
}>) => {
const { t } = useTranslation()
const {
@ -94,6 +97,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
@ -134,16 +138,24 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
previewPageId === current.page_id && 'bg-state-base-hover')}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
<Checkbox
className='mr-2 shrink-0'
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
if (disabled)
return
handleCheck(index)
}}
/>
{isMultipleChoice ? (
<Checkbox
className='mr-2 shrink-0'
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>) : (
<Radio
className='mr-2 shrink-0'
isChecked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
)}
{!searchValue && renderArrow()}
<NotionIcon
className='mr-1 shrink-0'
@ -192,6 +204,7 @@ const PageSelector = ({
canPreview = true,
previewPageId,
onPreview,
isMultipleChoice = true,
}: PageSelectorProps) => {
const { t } = useTranslation()
const [dataList, setDataList] = useState<NotionPageItem[]>([])
@ -265,7 +278,7 @@ const PageSelector = ({
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
if (!searchValue) {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
@ -273,12 +286,18 @@ const PageSelector = ({
copyValue.delete(pageId)
}
else {
if (!searchValue) {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
copyValue.add(pageId)
// Single choice mode, clear previous selection
if (!isMultipleChoice && copyValue.size > 0) {
copyValue.clear()
copyValue.add(pageId)
}
else {
copyValue.add(pageId)
}
}
onSelect(new Set(copyValue))
@ -322,6 +341,7 @@ const PageSelector = ({
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
isMultipleChoice,
}}
>
{Item}

View File

@ -9,33 +9,28 @@ import PlanComp from '../plan'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useBillingUrl } from '@/service/use-billing'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
const Billing: FC = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const { enableBilling } = useProviderContext()
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
const openAsyncWindow = useAsyncWindowOpen()
const handleOpenBilling = async () => {
// Open synchronously to preserve user gesture for popup blockers
if (billingUrl) {
window.open(billingUrl, '_blank', 'noopener,noreferrer')
return
}
const newWindow = window.open('', '_blank', 'noopener,noreferrer')
try {
await openAsyncWindow(async () => {
const url = (await refetch()).data
if (url && newWindow) {
newWindow.location.href = url
return
}
}
catch (err) {
console.error('Failed to fetch billing url', err)
}
// Close the placeholder window if we failed to fetch the URL
newWindow?.close()
if (url)
return url
return null
}, {
immediateUrl: billingUrl,
features: 'noopener,noreferrer',
onError: (err) => {
console.error('Failed to fetch billing url', err)
},
})
}
return (

View File

@ -9,6 +9,7 @@ import Toast from '../../../../base/toast'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import { useAppContext } from '@/context/app-context'
import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import List from './list'
import Button from './button'
import { Professional, Sandbox, Team } from '../../assets'
@ -42,6 +43,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const { isCurrentWorkspaceManager } = useAppContext()
const openAsyncWindow = useAsyncWindowOpen()
const btnText = useMemo(() => {
if (isCurrent)
@ -72,8 +74,16 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
setLoading(true)
try {
if (isCurrentPaidPlan) {
const res = await fetchBillingUrl()
window.open(res.url, '_blank')
await openAsyncWindow(async () => {
const res = await fetchBillingUrl()
if (res.url)
return res.url
throw new Error('Failed to open billing page')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: err.message || String(err) })
},
})
return
}

View File

@ -2,7 +2,7 @@ import cn from '@/utils/classnames'
import React, { useCallback, useMemo, useState } from 'react'
type CredentialIconProps = {
avatar_url?: string
avatarUrl?: string
name: string
size?: number
className?: string
@ -16,12 +16,12 @@ const ICON_BG_COLORS = [
]
export const CredentialIcon: React.FC<CredentialIconProps> = ({
avatar_url,
avatarUrl,
name,
size = 20,
className = '',
}) => {
const [showAvatar, setShowAvatar] = useState(!!avatar_url && avatar_url !== 'default')
const [showAvatar, setShowAvatar] = useState(!!avatarUrl && avatarUrl !== 'default')
const firstLetter = useMemo(() => name.charAt(0).toUpperCase(), [name])
const bgColor = useMemo(() => ICON_BG_COLORS[firstLetter.charCodeAt(0) % ICON_BG_COLORS.length], [firstLetter])
@ -29,17 +29,20 @@ export const CredentialIcon: React.FC<CredentialIconProps> = ({
setShowAvatar(false)
}, [])
if (avatar_url && avatar_url !== 'default' && showAvatar) {
if (avatarUrl && avatarUrl !== 'default' && showAvatar) {
return (
<div
className='flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular'
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-divider-regular',
className,
)}
style={{ width: `${size}px`, height: `${size}px` }}
>
<img
src={avatar_url}
src={avatarUrl}
width={size}
height={size}
className={cn('shrink-0 object-contain', className)}
className='shrink-0 object-contain'
onError={onImgLoadError}
/>
</div>

View File

@ -25,7 +25,7 @@ type IFileUploaderProps = {
onFileUpdate: (fileItem: FileItem, progress: number, list: FileItem[]) => void
onFileListUpdate?: (files: FileItem[]) => void
onPreview: (file: File) => void
notSupportBatchUpload?: boolean
supportBatchUpload?: boolean
}
const FileUploader = ({
@ -35,7 +35,7 @@ const FileUploader = ({
onFileUpdate,
onFileListUpdate,
onPreview,
notSupportBatchUpload,
supportBatchUpload = false,
}: IFileUploaderProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
@ -44,7 +44,7 @@ const FileUploader = ({
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const hideUpload = notSupportBatchUpload && fileList.length > 0
const hideUpload = !supportBatchUpload && fileList.length > 0
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const { data: supportFileTypesResponse } = useFileSupportTypes()
@ -68,9 +68,9 @@ const FileUploader = ({
const ACCEPTS = supportTypes.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
}), [fileUploadConfigResponse])
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
}), [fileUploadConfigResponse, supportBatchUpload])
const fileListRef = useRef<FileItem[]>([])
@ -254,12 +254,12 @@ const FileUploader = ({
}),
)
let files = nested.flat()
if (notSupportBatchUpload) files = files.slice(0, 1)
if (!supportBatchUpload) files = files.slice(0, 1)
files = files.slice(0, fileUploadConfig.batch_count_limit)
const valid = files.filter(isValid)
initialUpload(valid)
},
[initialUpload, isValid, notSupportBatchUpload, traverseFileEntry, fileUploadConfig],
[initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig],
)
const selectHandle = () => {
if (fileUploader.current)
@ -303,7 +303,7 @@ const FileUploader = ({
id="fileUploader"
className="hidden"
type="file"
multiple={!notSupportBatchUpload}
multiple={supportBatchUpload}
accept={ACCEPTS.join(',')}
onChange={fileChangeHandle}
/>
@ -317,7 +317,7 @@ const FileUploader = ({
<RiUploadCloud2Line className='mr-2 size-5' />
<span>
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
{supportBatchUpload ? t('datasetCreation.stepOne.uploader.button') : t('datasetCreation.stepOne.uploader.buttonSingleFile')}
{supportTypes.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
)}
@ -326,7 +326,7 @@ const FileUploader = ({
<div>{t('datasetCreation.stepOne.uploader.tip', {
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}

View File

@ -259,7 +259,7 @@ const StepOne = ({
onFileListUpdate={updateFileList}
onFileUpdate={updateFile}
onPreview={updateCurrentFile}
notSupportBatchUpload={notSupportBatchUpload}
supportBatchUpload={supportBatchUpload}
/>
{isShowVectorSpaceFull && (
<div className='mb-4 max-w-[640px]'>
@ -297,6 +297,7 @@ const StepOne = ({
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
supportBatchUpload={supportBatchUpload}
/>
</div>
{isShowVectorSpaceFull && (

View File

@ -6,6 +6,7 @@ import cn from '@/utils/classnames'
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import Checkbox from '@/app/components/base/checkbox'
import Button from '@/app/components/base/button'
import Radio from '@/app/components/base/radio/ui'
type Props = {
payload: CrawlResultItemType
@ -13,6 +14,7 @@ type Props = {
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
isMultipleChoice: boolean
}
const CrawledResultItem: FC<Props> = ({
@ -21,6 +23,7 @@ const CrawledResultItem: FC<Props> = ({
isChecked,
onCheckChange,
onPreview,
isMultipleChoice,
}) => {
const { t } = useTranslation()
@ -31,7 +34,21 @@ const CrawledResultItem: FC<Props> = ({
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
<div className='relative flex'>
<div className='flex h-5 items-center'>
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
{
isMultipleChoice ? (
<Checkbox
className='mr-2 shrink-0'
checked={isChecked}
onCheck={handleCheckChange}
/>
) : (
<Radio
className='mr-2 shrink-0'
isChecked={isChecked}
onCheck={handleCheckChange}
/>
)
}
</div>
<div className='flex min-w-0 grow flex-col'>
<div

View File

@ -16,6 +16,7 @@ type Props = {
onSelectedChange: (selected: CrawlResultItem[]) => void
onPreview: (payload: CrawlResultItem) => void
usedTime: number
isMultipleChoice: boolean
}
const CrawledResult: FC<Props> = ({
@ -25,6 +26,7 @@ const CrawledResult: FC<Props> = ({
onSelectedChange,
onPreview,
usedTime,
isMultipleChoice,
}) => {
const { t } = useTranslation()
@ -40,13 +42,17 @@ const CrawledResult: FC<Props> = ({
const handleItemCheckChange = useCallback((item: CrawlResultItem) => {
return (checked: boolean) => {
if (checked)
onSelectedChange([...checkedList, item])
else
if (checked) {
if (isMultipleChoice)
onSelectedChange([...checkedList, item])
else
onSelectedChange([item])
}
else {
onSelectedChange(checkedList.filter(checkedItem => checkedItem.source_url !== item.source_url))
}
}
}, [checkedList, onSelectedChange])
}, [checkedList, isMultipleChoice, onSelectedChange])
const [previewIndex, setPreviewIndex] = React.useState<number>(-1)
const handlePreview = useCallback((index: number) => {
@ -59,11 +65,13 @@ const CrawledResult: FC<Props> = ({
return (
<div className={cn(className, 'border-t-[0.5px] border-divider-regular shadow-xs shadow-shadow-shadow-3')}>
<div className='flex h-[34px] items-center justify-between px-4'>
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
/>
{isMultipleChoice && (
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
/>
)}
<div className='text-xs text-text-tertiary'>
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
total: list.length,
@ -80,6 +88,7 @@ const CrawledResult: FC<Props> = ({
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
isMultipleChoice={isMultipleChoice}
/>
))}
</div>

View File

@ -26,6 +26,7 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -41,6 +42,7 @@ const FireCrawl: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -171,7 +173,7 @@ const FireCrawl: FC<Props> = ({
content: item.markdown,
}))
setCrawlResult(data)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -182,7 +184,7 @@ const FireCrawl: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])
}, [checkValid, crawlOptions, onJobIdChange, waitForCrawlFinished, t, onCheckedCrawlResultChange, supportBatchUpload])
return (
<div>
@ -221,6 +223,7 @@ const FireCrawl: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -24,6 +24,7 @@ type Props = {
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
authedDataSourceList: DataSourceAuth[]
supportBatchUpload?: boolean
}
const Website: FC<Props> = ({
@ -35,6 +36,7 @@ const Website: FC<Props> = ({
crawlOptions,
onCrawlOptionsChange,
authedDataSourceList,
supportBatchUpload = false,
}) => {
const { t } = useTranslation()
const { setShowAccountSettingModal } = useModalContext()
@ -116,6 +118,7 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{source && selectedProvider === DataSourceProvider.waterCrawl && (
@ -126,6 +129,7 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{source && selectedProvider === DataSourceProvider.jinaReader && (
@ -136,6 +140,7 @@ const Website: FC<Props> = ({
onJobIdChange={onJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{!source && (

View File

@ -26,6 +26,7 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -41,6 +42,7 @@ const JinaReader: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -157,7 +159,7 @@ const JinaReader: FC<Props> = ({
total: 1,
data: [{
title,
content,
markdown: content,
description,
source_url: url,
}],
@ -176,7 +178,7 @@ const JinaReader: FC<Props> = ({
}
else {
setCrawlResult(data)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -188,7 +190,7 @@ const JinaReader: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, t, waitForCrawlFinished])
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
return (
<div>
@ -227,6 +229,7 @@ const JinaReader: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -32,7 +32,7 @@ const WebsitePreview = ({
<div className='system-xs-medium truncate text-text-tertiary' title={payload.source_url}>{payload.source_url}</div>
</div>
<div className={cn(s.previewContent, 'body-md-regular')}>
<div className={cn(s.fileContent)}>{payload.content}</div>
<div className={cn(s.fileContent)}>{payload.markdown}</div>
</div>
</div>
)

View File

@ -26,6 +26,7 @@ type Props = {
onJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
supportBatchUpload: boolean
}
enum Step {
@ -41,6 +42,7 @@ const WaterCrawl: FC<Props> = ({
onJobIdChange,
crawlOptions,
onCrawlOptionsChange,
supportBatchUpload,
}) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
@ -132,7 +134,7 @@ const WaterCrawl: FC<Props> = ({
},
}
}
}, [crawlOptions.limit])
}, [crawlOptions.limit, onCheckedCrawlResultChange])
const handleRun = useCallback(async (url: string) => {
const { isValid, errorMsg } = checkValid(url)
@ -163,7 +165,7 @@ const WaterCrawl: FC<Props> = ({
}
else {
setCrawlResult(data)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
onCheckedCrawlResultChange(supportBatchUpload ? (data.data || []) : (data.data?.slice(0, 1) || [])) // default select the crawl result
setCrawlErrorMessage('')
}
}
@ -174,7 +176,7 @@ const WaterCrawl: FC<Props> = ({
finally {
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished])
}, [checkValid, crawlOptions, onCheckedCrawlResultChange, onJobIdChange, supportBatchUpload, t, waitForCrawlFinished])
return (
<div>
@ -213,6 +215,7 @@ const WaterCrawl: FC<Props> = ({
onSelectedChange={onCheckedCrawlResultChange}
onPreview={onPreview}
usedTime={Number.parseFloat(crawlResult?.time_consuming as string) || 0}
isMultipleChoice={supportBatchUpload}
/>
}
</div>

View File

@ -10,14 +10,12 @@ import Trigger from './trigger'
import List from './list'
export type CredentialSelectorProps = {
pluginName: string
currentCredentialId: string
onCredentialChange: (credentialId: string) => void
credentials: Array<DataSourceCredential>
}
const CredentialSelector = ({
pluginName,
currentCredentialId,
onCredentialChange,
credentials,
@ -50,7 +48,6 @@ const CredentialSelector = ({
<PortalToFollowElemTrigger onClick={toggle} className='grow overflow-hidden'>
<Trigger
currentCredential={currentCredential}
pluginName={pluginName}
isOpen={open}
/>
</PortalToFollowElemTrigger>
@ -58,7 +55,6 @@ const CredentialSelector = ({
<List
currentCredentialId={currentCredentialId}
credentials={credentials}
pluginName={pluginName}
onCredentialChange={handleCredentialChange}
/>
</PortalToFollowElemContent>

View File

@ -2,22 +2,18 @@ import { CredentialIcon } from '@/app/components/datasets/common/credential-icon
import type { DataSourceCredential } from '@/types/pipeline'
import { RiCheckLine } from '@remixicon/react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
type ItemProps = {
credential: DataSourceCredential
pluginName: string
isSelected: boolean
onCredentialChange: (credentialId: string) => void
}
const Item = ({
credential,
pluginName,
isSelected,
onCredentialChange,
}: ItemProps) => {
const { t } = useTranslation()
const { avatar_url, name } = credential
const handleCredentialChange = useCallback(() => {
@ -30,15 +26,12 @@ const Item = ({
onClick={handleCredentialChange}
>
<CredentialIcon
avatar_url={avatar_url}
avatarUrl={avatar_url}
name={name}
size={20}
/>
<span className='system-sm-medium grow truncate text-text-secondary'>
{t('datasetPipeline.credentialSelector.name', {
credentialName: name,
pluginName,
})}
{name}
</span>
{
isSelected && (

View File

@ -5,14 +5,12 @@ import Item from './item'
type ListProps = {
currentCredentialId: string
credentials: Array<DataSourceCredential>
pluginName: string
onCredentialChange: (credentialId: string) => void
}
const List = ({
currentCredentialId,
credentials,
pluginName,
onCredentialChange,
}: ListProps) => {
return (
@ -24,7 +22,6 @@ const List = ({
<Item
key={credential.id}
credential={credential}
pluginName={pluginName}
isSelected={isSelected}
onCredentialChange={onCredentialChange}
/>

View File

@ -1,23 +1,18 @@
import React from 'react'
import type { DataSourceCredential } from '@/types/pipeline'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
type TriggerProps = {
currentCredential: DataSourceCredential | undefined
pluginName: string
isOpen: boolean
}
const Trigger = ({
currentCredential,
pluginName,
isOpen,
}: TriggerProps) => {
const { t } = useTranslation()
const {
avatar_url,
name = '',
@ -31,16 +26,13 @@ const Trigger = ({
)}
>
<CredentialIcon
avatar_url={avatar_url}
avatarUrl={avatar_url}
name={name}
size={20}
/>
<div className='flex grow items-center gap-x-1 overflow-hidden'>
<span className='system-md-semibold grow truncate text-text-secondary'>
{t('datasetPipeline.credentialSelector.name', {
credentialName: name,
pluginName,
})}
{name}
</span>
<RiArrowDownSLine className='size-4 shrink-0 text-text-secondary' />
</div>

View File

@ -11,12 +11,14 @@ type HeaderProps = {
docTitle: string
docLink: string
onClickConfiguration?: () => void
pluginName: string
} & CredentialSelectorProps
const Header = ({
docTitle,
docLink,
onClickConfiguration,
pluginName,
...rest
}: HeaderProps) => {
const { t } = useTranslation()
@ -29,7 +31,7 @@ const Header = ({
/>
<Divider type='vertical' className='mx-1 h-3.5 shrink-0' />
<Tooltip
popupContent={t('datasetPipeline.configurationTip', { pluginName: rest.pluginName })}
popupContent={t('datasetPipeline.configurationTip', { pluginName })}
position='top'
>
<Button

View File

@ -23,12 +23,12 @@ const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-ch
export type LocalFileProps = {
allowedExtensions: string[]
notSupportBatchUpload?: boolean
supportBatchUpload?: boolean
}
const LocalFile = ({
allowedExtensions,
notSupportBatchUpload,
supportBatchUpload = false,
}: LocalFileProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
@ -42,7 +42,7 @@ const LocalFile = ({
const fileUploader = useRef<HTMLInputElement>(null)
const fileListRef = useRef<FileItem[]>([])
const hideUpload = notSupportBatchUpload && localFileList.length > 0
const hideUpload = !supportBatchUpload && localFileList.length > 0
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const supportTypesShowNames = useMemo(() => {
@ -64,9 +64,9 @@ const LocalFile = ({
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: fileUploadConfigResponse?.batch_count_limit ?? 5,
file_upload_limit: fileUploadConfigResponse?.file_upload_limit ?? 5,
}), [fileUploadConfigResponse])
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
}), [fileUploadConfigResponse, supportBatchUpload])
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
@ -119,7 +119,7 @@ const LocalFile = ({
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size', { size: fileUploadConfig.file_size_limit }) })
return isValidType && isValidSize
}, [fileUploadConfig, notify, t, ACCEPTS])
}, [notify, t, ACCEPTS, fileUploadConfig.file_size_limit])
type UploadResult = Awaited<ReturnType<typeof upload>>
@ -230,12 +230,12 @@ const LocalFile = ({
return
let files = [...e.dataTransfer.files] as File[]
if (notSupportBatchUpload)
if (!supportBatchUpload)
files = files.slice(0, 1)
const validFiles = files.filter(isValid)
initialUpload(validFiles)
}, [initialUpload, isValid, notSupportBatchUpload])
}, [initialUpload, isValid, supportBatchUpload])
const selectHandle = useCallback(() => {
if (fileUploader.current)
@ -280,7 +280,7 @@ const LocalFile = ({
id='fileUploader'
className='hidden'
type='file'
multiple={!notSupportBatchUpload}
multiple={supportBatchUpload}
accept={ACCEPTS.join(',')}
onChange={fileChangeHandle}
/>
@ -296,7 +296,7 @@ const LocalFile = ({
<RiUploadCloud2Line className='mr-2 size-5' />
<span>
{notSupportBatchUpload ? t('datasetCreation.stepOne.uploader.buttonSingleFile') : t('datasetCreation.stepOne.uploader.button')}
{supportBatchUpload ? t('datasetCreation.stepOne.uploader.button') : t('datasetCreation.stepOne.uploader.buttonSingleFile')}
{allowedExtensions.length > 0 && (
<label className='ml-1 cursor-pointer text-text-accent' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
)}
@ -305,7 +305,7 @@ const LocalFile = ({
<div>{t('datasetCreation.stepOne.uploader.tip', {
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}</div>
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}

View File

@ -19,16 +19,18 @@ import { useDocLink } from '@/context/i18n'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
type OnlineDocumentsProps = {
isInPipeline?: boolean
nodeId: string
nodeData: DataSourceNodeType
onCredentialChange: (credentialId: string) => void
isInPipeline?: boolean
supportBatchUpload?: boolean
}
const OnlineDocuments = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
onCredentialChange,
}: OnlineDocumentsProps) => {
const docLink = useDocLink()
@ -157,7 +159,7 @@ const OnlineDocuments = ({
onSelect={handleSelectPages}
canPreview={!isInPipeline}
onPreview={handlePreviewPage}
isMultipleChoice={!isInPipeline}
isMultipleChoice={supportBatchUpload}
currentCredentialId={currentCredentialId}
/>
) : (

View File

@ -17,6 +17,7 @@ type FileListProps = {
handleSelectFile: (file: OnlineDriveFile) => void
handleOpenFolder: (file: OnlineDriveFile) => void
isLoading: boolean
supportBatchUpload: boolean
}
const FileList = ({
@ -32,6 +33,7 @@ const FileList = ({
handleOpenFolder,
isInPipeline,
isLoading,
supportBatchUpload,
}: FileListProps) => {
const [inputValue, setInputValue] = useState(keywords)
@ -72,8 +74,8 @@ const FileList = ({
handleResetKeywords={handleResetKeywords}
handleOpenFolder={handleOpenFolder}
handleSelectFile={handleSelectFile}
isInPipeline={isInPipeline}
isLoading={isLoading}
supportBatchUpload={supportBatchUpload}
/>
</div>
)

View File

@ -11,8 +11,8 @@ type FileListProps = {
fileList: OnlineDriveFile[]
selectedFileIds: string[]
keywords: string
isInPipeline: boolean
isLoading: boolean
supportBatchUpload: boolean
handleResetKeywords: () => void
handleSelectFile: (file: OnlineDriveFile) => void
handleOpenFolder: (file: OnlineDriveFile) => void
@ -25,8 +25,8 @@ const List = ({
handleResetKeywords,
handleSelectFile,
handleOpenFolder,
isInPipeline,
isLoading,
supportBatchUpload,
}: FileListProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver>(null)
@ -80,7 +80,7 @@ const List = ({
isSelected={isSelected}
onSelect={handleSelectFile}
onOpen={handleOpenFolder}
isMultipleChoice={!isInPipeline}
isMultipleChoice={supportBatchUpload}
/>
)
})

View File

@ -20,14 +20,16 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
type OnlineDriveProps = {
nodeId: string
nodeData: DataSourceNodeType
isInPipeline?: boolean
onCredentialChange: (credentialId: string) => void
isInPipeline?: boolean
supportBatchUpload?: boolean
}
const OnlineDrive = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
onCredentialChange,
}: OnlineDriveProps) => {
const docLink = useDocLink()
@ -111,7 +113,7 @@ const OnlineDrive = ({
},
},
)
}, [datasourceNodeRunURL, dataSourceStore])
}, [dataSourceStore, datasourceNodeRunURL, breadcrumbs])
useEffect(() => {
if (!currentCredentialId) return
@ -152,12 +154,12 @@ const OnlineDrive = ({
draft.splice(index, 1)
}
else {
if (isInPipeline && draft.length >= 1) return
if (!supportBatchUpload && draft.length >= 1) return
draft.push(file.id)
}
})
setSelectedFileIds(newSelectedFileList)
}, [dataSourceStore, isInPipeline])
}, [dataSourceStore, supportBatchUpload])
const handleOpenFolder = useCallback((file: OnlineDriveFile) => {
const { breadcrumbs, prefix, setBreadcrumbs, setPrefix, setBucket, setOnlineDriveFileList, setSelectedFileIds } = dataSourceStore.getState()
@ -177,7 +179,7 @@ const OnlineDrive = ({
setBreadcrumbs(newBreadcrumbs)
setPrefix(newPrefix)
}
}, [dataSourceStore, getOnlineDriveFiles])
}, [dataSourceStore])
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
@ -209,6 +211,7 @@ const OnlineDrive = ({
handleOpenFolder={handleOpenFolder}
isInPipeline={isInPipeline}
isLoading={isLoading}
supportBatchUpload={supportBatchUpload}
/>
</div>
)

View File

@ -46,6 +46,7 @@ const CrawledResultItem = ({
/>
) : (
<Radio
className='shrink-0'
isChecked={isChecked}
onCheck={handleCheckChange}
/>

View File

@ -33,14 +33,16 @@ const I18N_PREFIX = 'datasetCreation.stepOne.website'
export type WebsiteCrawlProps = {
nodeId: string
nodeData: DataSourceNodeType
isInPipeline?: boolean
onCredentialChange: (credentialId: string) => void
isInPipeline?: boolean
supportBatchUpload?: boolean
}
const WebsiteCrawl = ({
nodeId,
nodeData,
isInPipeline = false,
supportBatchUpload = false,
onCredentialChange,
}: WebsiteCrawlProps) => {
const { t } = useTranslation()
@ -122,7 +124,7 @@ const WebsiteCrawl = ({
time_consuming: time_consuming ?? 0,
}
setCrawlResult(crawlResultData)
handleCheckedCrawlResultChange(isInPipeline ? [crawlData[0]] : crawlData) // default select the crawl result
handleCheckedCrawlResultChange(supportBatchUpload ? crawlData : crawlData.slice(0, 1)) // default select the crawl result
setCrawlErrorMessage('')
setStep(CrawlStep.finished)
},
@ -132,7 +134,7 @@ const WebsiteCrawl = ({
},
},
)
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, isInPipeline, t])
}, [dataSourceStore, datasourceNodeRunURL, handleCheckedCrawlResultChange, supportBatchUpload, t])
const handleSubmit = useCallback((value: Record<string, any>) => {
handleRun(value)
@ -149,7 +151,7 @@ const WebsiteCrawl = ({
setTotalNum(0)
setCrawlErrorMessage('')
onCredentialChange(credentialId)
}, [dataSourceStore, onCredentialChange])
}, [onCredentialChange])
return (
<div className='flex flex-col'>
@ -195,7 +197,7 @@ const WebsiteCrawl = ({
previewIndex={previewIndex}
onPreview={handlePreview}
showPreview={!isInPipeline}
isMultipleChoice={!isInPipeline} // only support single choice in test run
isMultipleChoice={supportBatchUpload} // only support single choice in test run
/>
)}
</div>

View File

@ -102,7 +102,7 @@ const CreateFormPipeline = () => {
return onlineDriveFileList.length > 0 && isVectorSpaceFull && enableBilling
return false
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
const notSupportBatchUpload = enableBilling && plan.type === 'sandbox'
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
const nextBtnDisabled = useMemo(() => {
if (!datasource) return true
@ -125,15 +125,16 @@ const CreateFormPipeline = () => {
const showSelect = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument) {
const pagesCount = currentWorkspace?.pages.length ?? 0
return pagesCount > 0
return supportBatchUpload && pagesCount > 0
}
if (datasourceType === DatasourceType.onlineDrive) {
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
return !isBucketList && onlineDriveFileList.filter((item) => {
return supportBatchUpload && !isBucketList && onlineDriveFileList.filter((item) => {
return item.type !== 'bucket'
}).length > 0
}
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
return false
}, [currentWorkspace?.pages.length, datasourceType, supportBatchUpload, onlineDriveFileList])
const totalOptions = useMemo(() => {
if (datasourceType === DatasourceType.onlineDocument)
@ -395,7 +396,7 @@ const CreateFormPipeline = () => {
clearWebsiteCrawlData()
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
clearOnlineDriveData()
}, [])
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
const {
@ -406,13 +407,13 @@ const CreateFormPipeline = () => {
setCurrentCredentialId('')
currentNodeIdRef.current = dataSource.nodeId
setDatasource(dataSource)
}, [dataSourceStore])
}, [clearDataSourceData, dataSourceStore])
const handleCredentialChange = useCallback((credentialId: string) => {
const { setCurrentCredentialId } = dataSourceStore.getState()
clearDataSourceData(datasource!)
setCurrentCredentialId(credentialId)
}, [dataSourceStore, datasource])
}, [clearDataSourceData, dataSourceStore, datasource])
if (isFetchingPipelineInfo) {
return (
@ -443,7 +444,7 @@ const CreateFormPipeline = () => {
{datasourceType === DatasourceType.localFile && (
<LocalFile
allowedExtensions={datasource!.nodeData.fileExtensions || []}
notSupportBatchUpload={notSupportBatchUpload}
supportBatchUpload={supportBatchUpload}
/>
)}
{datasourceType === DatasourceType.onlineDocument && (
@ -451,6 +452,7 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{datasourceType === DatasourceType.websiteCrawl && (
@ -458,6 +460,7 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{datasourceType === DatasourceType.onlineDrive && (
@ -465,6 +468,7 @@ const CreateFormPipeline = () => {
nodeId={datasource!.nodeId}
nodeData={datasource!.nodeData}
onCredentialChange={handleCredentialChange}
supportBatchUpload={supportBatchUpload}
/>
)}
{isShowVectorSpaceFull && (

View File

@ -27,7 +27,7 @@ const WebsitePreview = ({
<span className='uppercase' title={currentWebsite.source_url}>{currentWebsite.source_url}</span>
<span>·</span>
<span>·</span>
<span>{`${formatNumberAbbreviated(currentWebsite.content.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
<span>{`${formatNumberAbbreviated(currentWebsite.markdown.length)} ${t('datasetPipeline.addDocuments.characters')}`}</span>
</div>
</div>
<button
@ -39,7 +39,7 @@ const WebsitePreview = ({
</button>
</div>
<div className='body-md-regular grow overflow-hidden px-6 py-5 text-text-secondary'>
{currentWebsite.content}
{currentWebsite.markdown}
</div>
</div>
)

View File

@ -113,7 +113,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
return [{
title: websiteInfo.title,
source_url: websiteInfo.source_url,
content: websiteInfo.content,
markdown: websiteInfo.content,
description: websiteInfo.description,
}]
}, [websiteInfo])

View File

@ -55,7 +55,7 @@ const PipelineSettings = ({
if (lastRunData?.datasource_type === DatasourceType.websiteCrawl) {
const { content, description, source_url, title } = lastRunData.datasource_info
websitePages.push({
content,
markdown: content,
description,
source_url,
title,
@ -135,7 +135,7 @@ const PipelineSettings = ({
push(`/datasets/${datasetId}/documents`)
},
})
}, [datasetId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline])
}, [datasetId, documentId, invalidDocumentDetail, invalidDocumentList, lastRunData, pipelineId, push, runPublishedPipeline])
const onClickProcess = useCallback(() => {
isPreview.current = false

View File

@ -0,0 +1,367 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ExternalAPIItem } from '@/models/datasets'
import ExternalKnowledgeBaseConnector from './index'
import { createExternalKnowledgeBase } from '@/service/datasets'
// Mock next/navigation
const mockRouterBack = jest.fn()
const mockReplace = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
back: mockRouterBack,
replace: mockReplace,
push: jest.fn(),
refresh: jest.fn(),
}),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
}))
// Mock toast context
const mockNotify = jest.fn()
jest.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
// Mock modal context
jest.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: jest.fn(),
}),
}))
// Mock API service
jest.mock('@/service/datasets', () => ({
createExternalKnowledgeBase: jest.fn(),
}))
// Factory function to create mock ExternalAPIItem
const createMockExternalAPIItem = (overrides: Partial<ExternalAPIItem> = {}): ExternalAPIItem => ({
id: 'api-default',
tenant_id: 'tenant-1',
name: 'Default API',
description: 'Default API description',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-api-key',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2024-01-01T00:00:00Z',
...overrides,
})
// Default mock API list
const createDefaultMockApiList = (): ExternalAPIItem[] => [
createMockExternalAPIItem({
id: 'api-1',
name: 'Test API 1',
settings: { endpoint: 'https://api1.example.com', api_key: 'key-1' },
}),
createMockExternalAPIItem({
id: 'api-2',
name: 'Test API 2',
settings: { endpoint: 'https://api2.example.com', api_key: 'key-2' },
}),
]
let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList()
jest.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: jest.fn(),
isLoading: false,
}),
}))
// Suppress console.error helper
const suppressConsoleError = () => jest.spyOn(console, 'error').mockImplementation(jest.fn())
// Helper to create a pending promise with external resolver
function createPendingPromise<T>() {
let resolve: (value: T) => void = jest.fn()
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
// Helper to fill required form fields and submit
async function fillFormAndSubmit(user: ReturnType<typeof userEvent.setup>) {
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
fireEvent.change(nameInput, { target: { value: 'Test Knowledge Base' } })
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-123' } })
// Wait for button to be enabled
await waitFor(() => {
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).not.toBeDisabled()
})
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
await user.click(connectButton!)
}
describe('ExternalKnowledgeBaseConnector', () => {
beforeEach(() => {
jest.clearAllMocks()
mockExternalKnowledgeApiList = createDefaultMockApiList()
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({ id: 'new-kb-id' })
})
// Tests for rendering with real ExternalKnowledgeBaseCreate component
describe('Rendering', () => {
it('should render the create form with all required elements', () => {
render(<ExternalKnowledgeBaseConnector />)
// Verify main title and form elements
expect(screen.getByText('dataset.connectDataset')).toBeInTheDocument()
expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument()
expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument()
expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument()
// Verify buttons
expect(screen.getByText('dataset.externalKnowledgeForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalKnowledgeForm.connect')).toBeInTheDocument()
})
it('should render connect button disabled initially', () => {
render(<ExternalKnowledgeBaseConnector />)
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).toBeDisabled()
})
})
// Tests for API success flow
describe('API Success Flow', () => {
it('should call API and show success notification when form is submitted', async () => {
const user = userEvent.setup()
render(<ExternalKnowledgeBaseConnector />)
await fillFormAndSubmit(user)
// Verify API was called with form data
await waitFor(() => {
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
body: expect.objectContaining({
name: 'Test Knowledge Base',
external_knowledge_id: 'kb-123',
external_knowledge_api_id: 'api-1',
provider: 'external',
}),
})
})
// Verify success notification
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External Knowledge Base Connected Successfully',
})
// Verify navigation back
expect(mockRouterBack).toHaveBeenCalledTimes(1)
})
it('should include retrieval settings in API call', async () => {
const user = userEvent.setup()
render(<ExternalKnowledgeBaseConnector />)
await fillFormAndSubmit(user)
await waitFor(() => {
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
body: expect.objectContaining({
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
})
})
})
})
// Tests for API error flow
describe('API Error Flow', () => {
it('should show error notification when API fails', async () => {
const user = userEvent.setup()
const consoleErrorSpy = suppressConsoleError()
;(createExternalKnowledgeBase as jest.Mock).mockRejectedValue(new Error('Network Error'))
render(<ExternalKnowledgeBaseConnector />)
await fillFormAndSubmit(user)
// Verify error notification
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to connect External Knowledge Base',
})
})
// Verify no navigation
expect(mockRouterBack).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should show error notification when API returns invalid result', async () => {
const user = userEvent.setup()
const consoleErrorSpy = suppressConsoleError()
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({})
render(<ExternalKnowledgeBaseConnector />)
await fillFormAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to connect External Knowledge Base',
})
})
expect(mockRouterBack).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
// Tests for loading state
describe('Loading State', () => {
it('should show loading state during API call', async () => {
const user = userEvent.setup()
// Create a promise that won't resolve immediately
const { promise, resolve: resolvePromise } = createPendingPromise<{ id: string }>()
;(createExternalKnowledgeBase as jest.Mock).mockReturnValue(promise)
render(<ExternalKnowledgeBaseConnector />)
// Fill form
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
await waitFor(() => {
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).not.toBeDisabled()
})
// Click connect
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
await user.click(connectButton!)
// Button should show loading (the real Button component has loading prop)
await waitFor(() => {
expect(createExternalKnowledgeBase).toHaveBeenCalled()
})
// Resolve the promise
resolvePromise({ id: 'new-id' })
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External Knowledge Base Connected Successfully',
})
})
})
})
// Tests for form validation (integration with real create component)
describe('Form Validation', () => {
it('should keep button disabled when only name is filled', () => {
render(<ExternalKnowledgeBaseConnector />)
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'Test' } })
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).toBeDisabled()
})
it('should keep button disabled when only knowledge id is filled', () => {
render(<ExternalKnowledgeBaseConnector />)
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).toBeDisabled()
})
it('should enable button when all required fields are filled', async () => {
render(<ExternalKnowledgeBaseConnector />)
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
await waitFor(() => {
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
expect(connectButton).not.toBeDisabled()
})
})
})
// Tests for user interactions
describe('User Interactions', () => {
it('should allow typing in form fields', async () => {
const user = userEvent.setup()
render(<ExternalKnowledgeBaseConnector />)
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder')
await user.type(nameInput, 'My Knowledge Base')
await user.type(descriptionInput, 'My Description')
expect((nameInput as HTMLInputElement).value).toBe('My Knowledge Base')
expect((descriptionInput as HTMLTextAreaElement).value).toBe('My Description')
})
it('should handle cancel button click', async () => {
const user = userEvent.setup()
render(<ExternalKnowledgeBaseConnector />)
const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button')
await user.click(cancelButton!)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
it('should handle back button click', async () => {
const user = userEvent.setup()
render(<ExternalKnowledgeBaseConnector />)
const buttons = screen.getAllByRole('button')
const backButton = buttons.find(btn => btn.classList.contains('rounded-full'))
await user.click(backButton!)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,9 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { TaskStatus } from '@/app/components/plugins/types'
import type { PluginStatus } from '@/app/components/plugins/types'
import {
useMutationClearAllTaskPlugin,
useMutationClearTaskPlugin,
usePluginTaskList,
} from '@/service/use-plugins'
@ -18,7 +14,6 @@ export const usePluginTaskStatus = () => {
handleRefetch,
} = usePluginTaskList()
const { mutateAsync } = useMutationClearTaskPlugin()
const { mutateAsync: mutateAsyncClearAll } = useMutationClearAllTaskPlugin()
const allPlugins = pluginTasks.map(task => task.plugins.map((plugin) => {
return {
...plugin,
@ -45,10 +40,6 @@ export const usePluginTaskStatus = () => {
})
handleRefetch()
}, [mutateAsync, handleRefetch])
const handleClearAllErrorPlugin = useCallback(async () => {
await mutateAsyncClearAll()
handleRefetch()
}, [mutateAsyncClearAll, handleRefetch])
const totalPluginsLength = allPlugins.length
const runningPluginsLength = runningPlugins.length
const errorPluginsLength = errorPlugins.length
@ -60,26 +51,6 @@ export const usePluginTaskStatus = () => {
const isSuccess = successPluginsLength === totalPluginsLength && totalPluginsLength > 0
const isFailed = runningPluginsLength === 0 && (errorPluginsLength + successPluginsLength) === totalPluginsLength && totalPluginsLength > 0 && errorPluginsLength > 0
const [opacity, setOpacity] = useState(1)
const timerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isSuccess) {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
if (opacity > 0) {
timerRef.current = setTimeout(() => {
setOpacity(v => v - 0.1)
}, 200)
}
}
if (!isSuccess)
setOpacity(1)
}, [isSuccess, opacity])
return {
errorPlugins,
successPlugins,
@ -94,7 +65,5 @@ export const usePluginTaskStatus = () => {
isSuccess,
isFailed,
handleClearErrorPlugin,
handleClearAllErrorPlugin,
opacity,
}
}

View File

@ -1,4 +1,5 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
@ -6,6 +7,7 @@ import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInstallLine,
RiLoaderLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePluginTaskStatus } from './hooks'
@ -14,7 +16,6 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
@ -22,6 +23,7 @@ import cn from '@/utils/classnames'
import { useGetLanguage } from '@/context/i18n'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
import Tooltip from '@/app/components/base/tooltip'
const PluginTasks = () => {
const { t } = useTranslation()
@ -29,6 +31,8 @@ const PluginTasks = () => {
const [open, setOpen] = useState(false)
const {
errorPlugins,
successPlugins,
runningPlugins,
runningPluginsLength,
successPluginsLength,
errorPluginsLength,
@ -39,33 +43,69 @@ const PluginTasks = () => {
isSuccess,
isFailed,
handleClearErrorPlugin,
handleClearAllErrorPlugin,
opacity,
} = usePluginTaskStatus()
const { getIconUrl } = useGetIcon()
const handleClearAllWithModal = useCallback(async () => {
// Clear all completed plugins (success and error) but keep running ones
const completedPlugins = [...successPlugins, ...errorPlugins]
// Clear all completed plugins individually
for (const plugin of completedPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearErrorsWithModal = useCallback(async () => {
// Clear only error plugins, not all plugins
for (const plugin of errorPlugins)
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [errorPlugins, handleClearErrorPlugin, runningPluginsLength])
const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => {
await handleClearErrorPlugin(taskId, pluginId)
// Only close modal if no plugins are still installing
if (runningPluginsLength === 0)
setOpen(false)
}, [handleClearErrorPlugin, runningPluginsLength])
const tip = useMemo(() => {
if (isInstalling)
return t('plugin.task.installing', { installingLength: runningPluginsLength })
if (isInstallingWithSuccess)
return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength })
if (isInstallingWithError)
return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength })
if (isInstallingWithSuccess)
return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength })
if (isInstalling)
return t('plugin.task.installing')
if (isFailed)
return t('plugin.task.installError', { errorLength: errorPluginsLength })
}, [isInstalling, isInstallingWithSuccess, isInstallingWithError, isFailed, errorPluginsLength, runningPluginsLength, successPluginsLength, t])
return t('plugin.task.installedError', { errorLength: errorPluginsLength })
if (isSuccess)
return t('plugin.task.installSuccess', { successLength: successPluginsLength })
return t('plugin.task.installed')
}, [
errorPluginsLength,
isFailed,
isInstalling,
isInstallingWithError,
isInstallingWithSuccess,
isSuccess,
runningPluginsLength,
successPluginsLength,
t,
])
if (!totalPluginsLength)
// Show icon if there are any plugin tasks (completed, running, or failed)
// Only hide when there are absolutely no plugin tasks
if (totalPluginsLength === 0)
return null
return (
<div
className={cn('flex items-center', opacity < 0 && 'hidden')}
style={{ opacity }}
>
<div className='flex items-center'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -77,15 +117,20 @@ const PluginTasks = () => {
>
<PortalToFollowElemTrigger
onClick={() => {
if (isFailed)
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
setOpen(v => !v)
}}
>
<Tooltip popupContent={tip}>
<Tooltip
popupContent={tip}
asChild
offset={8}
>
<div
className={cn(
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
(isInstallingWithError || isFailed) && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
id="plugin-task-trigger"
>
@ -124,7 +169,7 @@ const PluginTasks = () => {
)
}
{
isSuccess && (
(isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && (
<RiCheckboxCircleFill className='h-3.5 w-3.5 text-text-success' />
)
}
@ -138,52 +183,129 @@ const PluginTasks = () => {
</Tooltip>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pb-2 shadow-lg'>
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
{t('plugin.task.installedError', { errorLength: errorPluginsLength })}
<Button
className='shrink-0'
size='small'
variant='ghost'
onClick={() => handleClearAllErrorPlugin()}
>
{t('plugin.task.clearAll')}
</Button>
</div>
<div className='max-h-[400px] overflow-y-auto'>
{
errorPlugins.map(errorPlugin => (
<div
key={errorPlugin.plugin_unique_identifier}
className='flex rounded-lg p-2 hover:bg-state-base-hover'
>
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
<RiErrorWarningFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive' />
<CardIcon
size='tiny'
src={getIconUrl(errorPlugin.icon)}
/>
</div>
<div className='grow'>
<div className='system-md-regular truncate text-text-secondary'>
{errorPlugin.labels[language]}
</div>
<div className='system-xs-regular break-all text-text-destructive'>
{errorPlugin.message}
</div>
</div>
<Button
className='shrink-0'
size='small'
variant='ghost'
onClick={() => handleClearErrorPlugin(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
<div className='w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{/* Running Plugins */}
{runningPlugins.length > 0 && (
<>
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
{t('plugin.task.installing')} ({runningPlugins.length})
</div>
<div className='max-h-[200px] overflow-y-auto'>
{runningPlugins.map(runningPlugin => (
<div
key={runningPlugin.plugin_unique_identifier}
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
>
{t('common.operation.clear')}
</Button>
</div>
))
}
</div>
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
<RiLoaderLine className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent' />
<CardIcon
size='tiny'
src={getIconUrl(runningPlugin.icon)}
/>
</div>
<div className='grow'>
<div className='system-md-regular truncate text-text-secondary'>
{runningPlugin.labels[language]}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.task.installing')}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Success Plugins */}
{successPlugins.length > 0 && (
<>
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
{t('plugin.task.installed')} ({successPlugins.length})
<Button
className='shrink-0'
size='small'
variant='ghost'
onClick={() => handleClearAllWithModal()}
>
{t('plugin.task.clearAll')}
</Button>
</div>
<div className='max-h-[200px] overflow-y-auto'>
{successPlugins.map(successPlugin => (
<div
key={successPlugin.plugin_unique_identifier}
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
>
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
<RiCheckboxCircleFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success' />
<CardIcon
size='tiny'
src={getIconUrl(successPlugin.icon)}
/>
</div>
<div className='grow'>
<div className='system-md-regular truncate text-text-secondary'>
{successPlugin.labels[language]}
</div>
<div className='system-xs-regular text-text-success'>
{successPlugin.message || t('plugin.task.installed')}
</div>
</div>
</div>
))}
</div>
</>
)}
{/* Error Plugins */}
{errorPlugins.length > 0 && (
<>
<div className='system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1'>
{t('plugin.task.installError', { errorLength: errorPlugins.length })}
<Button
className='shrink-0'
size='small'
variant='ghost'
onClick={() => handleClearErrorsWithModal()}
>
{t('plugin.task.clearAll')}
</Button>
</div>
<div className='max-h-[200px] overflow-y-auto'>
{errorPlugins.map(errorPlugin => (
<div
key={errorPlugin.plugin_unique_identifier}
className='flex items-center rounded-lg p-2 hover:bg-state-base-hover'
>
<div className='relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'>
<RiErrorWarningFill className='absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive' />
<CardIcon
size='tiny'
src={getIconUrl(errorPlugin.icon)}
/>
</div>
<div className='grow'>
<div className='system-md-regular truncate text-text-secondary'>
{errorPlugin.labels[language]}
</div>
<div className='system-xs-regular break-all text-text-destructive'>
{errorPlugin.message}
</div>
</div>
<Button
className='shrink-0'
size='small'
variant='ghost'
onClick={() => handleClearSingleWithModal(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
>
{t('common.operation.clear')}
</Button>
</div>
))}
</div>
</>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -131,7 +131,7 @@ const Preparation = () => {
clearWebsiteCrawlData()
else if (dataSource.nodeData.provider_type === DatasourceType.onlineDrive)
clearOnlineDriveData()
}, [])
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
const {
@ -142,13 +142,13 @@ const Preparation = () => {
setCurrentCredentialId('')
currentNodeIdRef.current = dataSource.nodeId
setDatasource(dataSource)
}, [dataSourceStore])
}, [clearDataSourceData, dataSourceStore])
const handleCredentialChange = useCallback((credentialId: string) => {
const { setCurrentCredentialId } = dataSourceStore.getState()
clearDataSourceData(datasource!)
setCurrentCredentialId(credentialId)
}, [dataSourceStore, datasource])
}, [clearDataSourceData, dataSourceStore, datasource])
return (
<>
<StepIndicator steps={steps} currentStep={currentStep} />
@ -164,7 +164,7 @@ const Preparation = () => {
{datasourceType === DatasourceType.localFile && (
<LocalFile
allowedExtensions={datasource!.nodeData.fileExtensions || []}
notSupportBatchUpload // only support single file upload in test run
supportBatchUpload={false} // only support single file upload in test run
/>
)}
{datasourceType === DatasourceType.onlineDocument && (
@ -173,6 +173,7 @@ const Preparation = () => {
nodeData={datasource!.nodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
{datasourceType === DatasourceType.websiteCrawl && (
@ -181,6 +182,7 @@ const Preparation = () => {
nodeData={datasource!.nodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
{datasourceType === DatasourceType.onlineDrive && (
@ -189,6 +191,7 @@ const Preparation = () => {
nodeData={datasource!.nodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
</div>

View File

@ -43,13 +43,13 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
clearWebsiteCrawlData()
else if (datasourceType === DatasourceType.onlineDrive)
clearOnlineDriveData()
}, [datasourceType])
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData, datasourceType])
const handleCredentialChange = useCallback((credentialId: string) => {
const { setCurrentCredentialId } = dataSourceStore.getState()
clearDataSourceData()
setCurrentCredentialId(credentialId)
}, [dataSourceStore])
}, [clearDataSourceData, dataSourceStore])
return (
<PanelWrap
@ -60,7 +60,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
{datasourceType === DatasourceType.localFile && (
<LocalFile
allowedExtensions={datasourceNodeData.fileExtensions || []}
notSupportBatchUpload
supportBatchUpload={false}
/>
)}
{datasourceType === DatasourceType.onlineDocument && (
@ -69,6 +69,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
nodeData={datasourceNodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
{datasourceType === DatasourceType.websiteCrawl && (
@ -77,6 +78,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
nodeData={datasourceNodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
{datasourceType === DatasourceType.onlineDrive && (
@ -85,6 +87,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
nodeData={datasourceNodeData}
isInPipeline
onCredentialChange={handleCredentialChange}
supportBatchUpload={false}
/>
)}
<div className='flex justify-end gap-x-2'>

View File

@ -0,0 +1,116 @@
import { act, renderHook } from '@testing-library/react'
import { useAsyncWindowOpen } from './use-async-window-open'
describe('useAsyncWindowOpen', () => {
const originalOpen = window.open
beforeEach(() => {
jest.clearAllMocks()
})
afterAll(() => {
window.open = originalOpen
})
it('opens immediate url synchronously without calling async getter', async () => {
const openSpy = jest.fn()
window.open = openSpy
const getUrl = jest.fn()
const { result } = renderHook(() => useAsyncWindowOpen())
await act(async () => {
await result.current(getUrl, {
immediateUrl: 'https://example.com',
target: '_blank',
features: 'noopener,noreferrer',
})
})
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
expect(getUrl).not.toHaveBeenCalled()
})
it('sets opener to null and redirects when async url resolves', async () => {
const close = jest.fn()
const mockWindow: any = {
location: { href: '' },
close,
opener: 'should-be-cleared',
}
const openSpy = jest.fn(() => mockWindow)
window.open = openSpy
const { result } = renderHook(() => useAsyncWindowOpen())
await act(async () => {
await result.current(async () => 'https://example.com/path')
})
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank', undefined)
expect(mockWindow.opener).toBeNull()
expect(mockWindow.location.href).toBe('https://example.com/path')
expect(close).not.toHaveBeenCalled()
})
it('closes placeholder and forwards error when async getter throws', async () => {
const close = jest.fn()
const mockWindow: any = {
location: { href: '' },
close,
opener: null,
}
const openSpy = jest.fn(() => mockWindow)
window.open = openSpy
const onError = jest.fn()
const { result } = renderHook(() => useAsyncWindowOpen())
const error = new Error('fetch failed')
await act(async () => {
await result.current(async () => {
throw error
}, { onError })
})
expect(close).toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(error)
expect(mockWindow.location.href).toBe('')
})
it('closes placeholder and reports when no url is returned', async () => {
const close = jest.fn()
const mockWindow: any = {
location: { href: '' },
close,
opener: null,
}
const openSpy = jest.fn(() => mockWindow)
window.open = openSpy
const onError = jest.fn()
const { result } = renderHook(() => useAsyncWindowOpen())
await act(async () => {
await result.current(async () => null, { onError })
})
expect(close).toHaveBeenCalled()
expect(onError).toHaveBeenCalled()
const errArg = onError.mock.calls[0][0] as Error
expect(errArg.message).toBe('No url resolved for new window')
})
it('reports failure when window.open returns null', async () => {
const openSpy = jest.fn(() => null)
window.open = openSpy
const getUrl = jest.fn()
const onError = jest.fn()
const { result } = renderHook(() => useAsyncWindowOpen())
await act(async () => {
await result.current(getUrl, { onError })
})
expect(onError).toHaveBeenCalled()
const errArg = onError.mock.calls[0][0] as Error
expect(errArg.message).toBe('Failed to open new window')
expect(getUrl).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,49 @@
import { useCallback } from 'react'
type GetUrl = () => Promise<string | null | undefined>
type AsyncWindowOpenOptions = {
immediateUrl?: string | null
target?: string
features?: string
onError?: (error: Error) => void
}
export const useAsyncWindowOpen = () => useCallback(async (getUrl: GetUrl, options?: AsyncWindowOpenOptions) => {
const {
immediateUrl,
target = '_blank',
features,
onError,
} = options ?? {}
if (immediateUrl) {
window.open(immediateUrl, target, features)
return
}
const newWindow = window.open('about:blank', target, features)
if (!newWindow) {
onError?.(new Error('Failed to open new window'))
return
}
try {
newWindow.opener = null
}
catch { /* noop */ }
try {
const url = await getUrl()
if (url) {
newWindow.location.href = url
return
}
newWindow.close()
onError?.(new Error('No url resolved for new window'))
}
catch (error) {
newWindow.close()
onError?.(error instanceof Error ? error : new Error(String(error)))
}
}, [])

View File

@ -230,6 +230,11 @@ const translation = {
installing: 'Installation von {{installingLength}} Plugins, 0 erledigt.',
installError:
'{{errorLength}} Plugins konnten nicht installiert werden, klicken Sie hier, um sie anzusehen',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
allCategories: 'Alle Kategorien',
install: '{{num}} Installationen',

View File

@ -145,9 +145,6 @@ const translation = {
emptySearchResult: 'No items were found',
resetKeywords: 'Reset keywords',
},
credentialSelector: {
name: '{{credentialName}}\'s {{pluginName}}',
},
configurationTip: 'Configure {{pluginName}}',
conversion: {
title: 'Convert to Knowledge Pipeline',

View File

@ -270,12 +270,17 @@ const translation = {
partnerTip: 'Verified by a Dify partner',
},
task: {
installing: 'Installing {{installingLength}} plugins, 0 done.',
installing: 'Installing plugins',
installingWithSuccess: 'Installing {{installingLength}} plugins, {{successLength}} success.',
installingWithError: 'Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed',
installError: '{{errorLength}} plugins failed to install, click to view',
installedError: '{{errorLength}} plugins failed to install',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
clearAll: 'Clear all',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
requestAPlugin: 'Request a plugin',
publishPlugins: 'Publish plugins',

View File

@ -230,6 +230,11 @@ const translation = {
'Los complementos {{errorLength}} no se pudieron instalar, haga clic para ver',
installingWithError:
'Instalando plugins {{installingLength}}, {{successLength}} éxito, {{errorLength}} fallido',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
fromMarketplace: 'De Marketplace',
endpointsEnabled: '{{num}} conjuntos de puntos finales habilitados',

View File

@ -223,6 +223,11 @@ const translation = {
'نصب پلاگین های {{installingLength}}، {{successLength}} موفقیت آمیز است.',
installingWithError:
'نصب پلاگین های {{installingLength}}، {{successLength}} با موفقیت مواجه شد، {{errorLength}} ناموفق بود',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
searchTools: 'ابزارهای جستجو...',
findMoreInMarketplace: 'اطلاعات بیشتر در Marketplace',

View File

@ -228,6 +228,11 @@ const translation = {
installedError: '{{errorLength}} les plugins nont pas pu être installés',
clearAll: 'Effacer tout',
installing: 'Installation des plugins {{installingLength}}, 0 fait.',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
search: 'Rechercher',
installAction: 'Installer',

View File

@ -227,6 +227,11 @@ const translation = {
'{{installingLength}} प्लगइन्स स्थापित कर रहे हैं, {{successLength}} सफल, {{errorLength}} विफल',
installingWithSuccess:
'{{installingLength}} प्लगइन्स स्थापित कर रहे हैं, {{successLength}} सफल।',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
installFrom: 'से इंस्टॉल करें',
fromMarketplace: 'मार्केटप्लेस से',

View File

@ -261,6 +261,11 @@ const translation = {
installingWithError: 'Memasang {{installingLength}} plugin, {{successLength}} berhasil, {{errorLength}} gagal',
installError: 'Gagal menginstal plugin {{errorLength}}, klik untuk melihat',
installedError: 'Gagal menginstal {{errorLength}} plugin',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
auth: {
customCredentialUnavailable: 'Kredensial kustom saat ini tidak tersedia',

View File

@ -208,6 +208,11 @@ const translation = {
installedError: 'Impossibile installare i plugin di {{errorLength}}',
installingWithError: 'Installazione dei plugin {{installingLength}}, {{successLength}} successo, {{errorLength}} fallito',
installingWithSuccess: 'Installazione dei plugin {{installingLength}}, {{successLength}} successo.',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
searchInMarketplace: 'Cerca nel Marketplace',
endpointsEnabled: '{{num}} set di endpoint abilitati',

View File

@ -137,9 +137,6 @@ const translation = {
emptySearchResult: 'アイテムは見つかりませんでした',
resetKeywords: 'キーワードをリセットする',
},
credentialSelector: {
name: '{{credentialName}}の{{pluginName}}',
},
configurationTip: '{{pluginName}}を設定',
conversion: {
confirm: {

View File

@ -208,6 +208,11 @@ const translation = {
installedError: '{{errorLength}} プラグインのインストールに失敗しました',
installingWithError: '{{installingLength}}個のプラグインをインストール中、{{successLength}}件成功、{{errorLength}}件失敗',
installing: '{{installingLength}}個のプラグインをインストール中、0 個完了。',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
from: 'インストール元',
install: '{{num}} インストール',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithError: '{{installingLength}} 플러그인 설치, {{successLength}} 성공, {{errorLength}} 실패',
installError: '{{errorLength}} 플러그인 설치 실패, 보려면 클릭하십시오.',
clearAll: '모두 지우기',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
installAction: '설치하다',
searchTools: '검색 도구...',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithSuccess: 'Instalacja wtyczek {{installingLength}}, {{successLength}} powodzenie.',
clearAll: 'Wyczyść wszystko',
installingWithError: 'Instalacja wtyczek {{installingLength}}, {{successLength}} powodzenie, {{errorLength}} niepowodzenie',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
search: 'Szukać',
installFrom: 'ZAINSTALUJ Z',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithError: 'Instalando plug-ins {{installingLength}}, {{successLength}} sucesso, {{errorLength}} falhou',
installing: 'Instalando plugins {{installingLength}}, 0 feito.',
clearAll: 'Apagar tudo',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
installAction: 'Instalar',
endpointsEnabled: '{{num}} conjuntos de endpoints habilitados',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithError: 'Instalarea pluginurilor {{installingLength}}, {{successLength}} succes, {{errorLength}} eșuat',
installingWithSuccess: 'Instalarea pluginurilor {{installingLength}}, {{successLength}} succes.',
installing: 'Instalarea pluginurilor {{installingLength}}, 0 terminat.',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
fromMarketplace: 'Din Marketplace',
from: 'Din',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithSuccess: 'Установка плагинов {{installingLength}}, {{successLength}} успех.',
installedError: 'плагины {{errorLength}} не удалось установить',
installError: 'Плагины {{errorLength}} не удалось установить, нажмите для просмотра',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
install: '{{num}} установок',
searchCategories: 'Поиск категорий',

View File

@ -211,6 +211,11 @@ const translation = {
installingWithSuccess: 'Namestitev {{installingLength}} dodatkov, {{successLength}} uspešnih.',
installedError: '{{errorLength}} vtičnikov ni uspelo namestiti',
installingWithError: 'Namestitev {{installingLength}} vtičnikov, {{successLength}} uspešnih, {{errorLength}} neuspešnih',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
endpointsEnabled: '{{num}} nizov končnih točk omogočenih',
search: 'Iskanje',

View File

@ -208,6 +208,11 @@ const translation = {
installedError: '{{errorLength}} ปลั๊กอินติดตั้งไม่สําเร็จ',
clearAll: 'ล้างทั้งหมด',
installError: '{{errorLength}} ปลั๊กอินติดตั้งไม่สําเร็จ คลิกเพื่อดู',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
searchCategories: 'หมวดหมู่การค้นหา',
searchInMarketplace: 'ค้นหาใน Marketplace',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithSuccess: '{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı.',
installError: '{{errorLength}} eklentileri yüklenemedi, görüntülemek için tıklayın',
installingWithError: '{{installingLength}} eklentileri yükleniyor, {{successLength}} başarılı, {{errorLength}} başarısız oldu',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
allCategories: 'Tüm Kategoriler',
installAction: 'Yüklemek',

View File

@ -208,6 +208,11 @@ const translation = {
installError: 'Плагіни {{errorLength}} не вдалося встановити, натисніть, щоб переглянути',
installing: 'Встановлення плагінів {{installingLength}}, 0 виконано.',
installingWithSuccess: 'Встановлення плагінів {{installingLength}}, успіх {{successLength}}.',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
from: 'Від',
searchInMarketplace: 'Пошук у Marketplace',

View File

@ -208,6 +208,11 @@ const translation = {
installError: '{{errorLength}} plugin không cài đặt được, nhấp để xem',
installedError: '{{errorLength}} plugin không cài đặt được',
clearAll: 'Xóa tất cả',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
from: 'Từ',
installAction: 'Cài đặt',

View File

@ -145,9 +145,6 @@ const translation = {
emptySearchResult: '未找到任何项目',
resetKeywords: '重置关键词',
},
credentialSelector: {
name: '{{credentialName}} 的 {{pluginName}}',
},
configurationTip: '配置 {{pluginName}}',
conversion: {
title: '转换为知识流水线',

View File

@ -270,12 +270,17 @@ const translation = {
partnerTip: '此插件由 Dify 合作伙伴认证',
},
task: {
installing: '{{installingLength}} 个插件安装中0 已完成',
installing: '正在安装插件',
installingWithSuccess: '{{installingLength}} 个插件安装中,{{successLength}} 安装成功',
installingWithError: '{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败',
installError: '{{errorLength}} 个插件安装失败,点击查看',
installedError: '{{errorLength}} 个插件安装失败',
installSuccess: '{{successLength}} 个插件安装成功',
installed: '已安装',
clearAll: '清除所有',
runningPlugins: '正在安装的插件',
successPlugins: '安装成功的插件',
errorPlugins: '安装失败的插件',
},
requestAPlugin: '申请插件',
publishPlugins: '发布插件',

View File

@ -208,6 +208,11 @@ const translation = {
installingWithSuccess: '安裝 {{installingLength}} 個插件,{{successLength}} 成功。',
clearAll: '全部清除',
installing: '安裝 {{installingLength}} 個插件0 個完成。',
installSuccess: '{{successLength}} plugins installed successfully',
installed: 'Installed',
runningPlugins: 'Installing Plugins',
successPlugins: 'Successfully Installed Plugins',
errorPlugins: 'Failed to Install Plugins',
},
requestAPlugin: '申请插件',
publishPlugins: '發佈插件',

View File

@ -1,6 +1,25 @@
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
// Fix for @headlessui/react compatibility with happy-dom
// headlessui tries to override focus properties which may be read-only in happy-dom
if (typeof window !== 'undefined') {
const ensureWritable = (target: object, prop: string) => {
const descriptor = Object.getOwnPropertyDescriptor(target, prop)
if (descriptor && !descriptor.writable) {
const original = descriptor.value ?? descriptor.get?.call(target)
Object.defineProperty(target, prop, {
value: typeof original === 'function' ? original : jest.fn(),
writable: true,
configurable: true,
})
}
}
ensureWritable(window, 'focus')
ensureWritable(HTMLElement.prototype, 'focus')
}
afterEach(() => {
cleanup()
})

View File

@ -156,7 +156,7 @@ export type CrawlOptions = {
export type CrawlResultItem = {
title: string
content: string
markdown: string
description: string
source_url: string
}

View File

@ -168,6 +168,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9",

3
web/pnpm-lock.yaml generated
View File

@ -416,6 +416,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/jest':
specifier: ^29.5.14
version: 29.5.14

View File

@ -634,7 +634,8 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
export const useMutationClearTaskPlugin = () => {
return useMutation({
mutationFn: ({ taskId, pluginId }: { taskId: string; pluginId: string }) => {
return post<{ success: boolean }>(`/workspaces/current/plugin/tasks/${taskId}/delete/${pluginId}`)
const encodedPluginId = encodeURIComponent(pluginId)
return post<{ success: boolean }>(`/workspaces/current/plugin/tasks/${taskId}/delete/${encodedPluginId}`)
},
})
}

View File

@ -145,8 +145,17 @@ Treat component state as part of the public behavior: confirm the initial render
- ✅ When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`).
- ✅ Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs.
- ✅ For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers.
- ✅ **Use factory functions for mock data**: Import actual types and create factory functions with complete defaults (see [Test Data Builders](#9-test-data-builders-anti-hardcoding) section).
- ✅ If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provider-context-mock.spec.tsx`).
- ✅ Use factory functions to create mock data with TypeScript types. This ensures type safety and makes tests more maintainable.
If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provier-context-mock.spec.tsx`).
**Rules**:
1. **Import actual types**: Always import types from the source (`@/models/`, `@/types/`, etc.) instead of defining inline types.
1. **Provide complete defaults**: Factory functions should return complete objects with all required fields filled with sensible defaults.
1. **Allow partial overrides**: Accept `Partial<T>` to enable flexible customization for specific test cases.
1. **Create list factories**: For array data, create a separate factory function that composes item factories.
1. **Reference**: See `__mocks__/provider-context.ts` for reusable context mock factories used across multiple test files.
### 4. Performance Optimization