mirror of https://github.com/langgenius/dify.git
Merge remote-tracking branch 'origin/main' into feat/e2e-testing
This commit is contained in:
commit
a8a0f2c900
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
import { useKeyPress } from 'ahooks'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
|
|
@ -50,6 +49,7 @@ import { AppModeEnum } from '@/types/app'
|
|||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { basePath } from '@/utils/var'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
|
|
@ -216,18 +216,23 @@ const AppPublisher = ({
|
|||
setPublished(false)
|
||||
}, [disabled, onToggle, open])
|
||||
|
||||
const handleOpenInExplore = useCallback(async () => {
|
||||
try {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenInExplore = useCallback(() => {
|
||||
if (!appDetail?.id) return
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}, [appDetail?.id, openAsync])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { canFindTool } from '@/utils'
|
|||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
const AgentTools: FC = () => {
|
||||
|
|
@ -93,13 +94,17 @@ const AgentTools: FC = () => {
|
|||
|
||||
const [isDeleting, setIsDeleting] = useState<number>(-1)
|
||||
const getToolValue = (tool: ToolDefaultValue) => {
|
||||
const currToolInCollections = collectionList.find(c => c.id === tool.provider_id)
|
||||
const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name)
|
||||
const formSchemas = currToolWithConfigs ? toolParametersToFormSchemas(currToolWithConfigs.parameters) : []
|
||||
const paramsWithDefaultValue = addDefaultValue(tool.params, formSchemas)
|
||||
return {
|
||||
provider_id: tool.provider_id,
|
||||
provider_type: tool.provider_type as CollectionType,
|
||||
provider_name: tool.provider_name,
|
||||
tool_name: tool.tool_name,
|
||||
tool_label: tool.tool_label,
|
||||
tool_parameters: tool.params,
|
||||
tool_parameters: paramsWithDefaultValue,
|
||||
notAuthor: !tool.is_team_authorization,
|
||||
enabled: true,
|
||||
}
|
||||
|
|
@ -119,7 +124,7 @@ const AgentTools: FC = () => {
|
|||
}
|
||||
const getProviderShowName = (item: AgentTool) => {
|
||||
const type = item.provider_type
|
||||
if(type === CollectionType.builtIn)
|
||||
if (type === CollectionType.builtIn)
|
||||
return item.provider_name.split('/').pop()
|
||||
return item.provider_name
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import Description from '@/app/components/plugins/card/base/description'
|
|||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import type { Collection, Tool } from '@/app/components/tools/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
|
||||
|
|
@ -92,15 +92,11 @@ const SettingBuiltInTool: FC<Props> = ({
|
|||
}())
|
||||
})
|
||||
setTools(list)
|
||||
const currTool = list.find(tool => tool.name === toolName)
|
||||
if (currTool) {
|
||||
const formSchemas = toolParametersToFormSchemas(currTool.parameters)
|
||||
setTempSetting(addDefaultValue(setting, formSchemas))
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
setIsLoading(false)
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collection?.name, collection?.id, collection?.type])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -249,7 +245,7 @@ const SettingBuiltInTool: FC<Props> = ({
|
|||
{!readonly && !isInfoActive && (
|
||||
<div className='flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2'>
|
||||
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium ' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium' variant='primary' disabled={!isValid} onClick={() => onSave?.(addDefaultValue(tempSetting, formSchemas))}>{t('common.operation.save')}</Button>
|
||||
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium' variant='primary' disabled={!isValid} onClick={() => onSave?.(tempSetting)}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
|
@ -31,6 +31,7 @@ import { AccessMode } from '@/models/access-control'
|
|||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
|
|
@ -242,20 +243,24 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||
e.preventDefault()
|
||||
setShowAccessControl(true)
|
||||
}
|
||||
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const onClickInstalledApp = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
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
|
||||
|
||||
openAsync(
|
||||
async () => {
|
||||
const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {}
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
},
|
||||
{
|
||||
errorMessage: 'Failed to open app in Explore',
|
||||
},
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -54,6 +55,8 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
})[plan]
|
||||
}, [isCurrent, plan, t])
|
||||
|
||||
const { openAsync } = useAsyncWindowOpen()
|
||||
|
||||
const handleGetPayUrl = async () => {
|
||||
if (loading)
|
||||
return
|
||||
|
|
@ -72,8 +75,13 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
|||
setLoading(true)
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
const res = await fetchBillingUrl()
|
||||
window.open(res.url, '_blank')
|
||||
await openAsync(
|
||||
() => fetchBillingUrl().then(res => res.url),
|
||||
{
|
||||
errorMessage: 'Failed to open billing page',
|
||||
windowFeatures: 'noopener,noreferrer',
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
export type AsyncWindowOpenOptions = {
|
||||
successMessage?: string
|
||||
errorMessage?: string
|
||||
windowFeatures?: string
|
||||
onError?: (error: any) => void
|
||||
onSuccess?: (url: string) => void
|
||||
}
|
||||
|
||||
export const useAsyncWindowOpen = () => {
|
||||
const openAsync = useCallback(async (
|
||||
fetchUrl: () => Promise<string>,
|
||||
options: AsyncWindowOpenOptions = {},
|
||||
) => {
|
||||
const {
|
||||
successMessage,
|
||||
errorMessage = 'Failed to open page',
|
||||
windowFeatures = 'noopener,noreferrer',
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options
|
||||
|
||||
const newWindow = window.open('', '_blank', windowFeatures)
|
||||
|
||||
if (!newWindow) {
|
||||
const error = new Error('Popup blocked by browser')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Popup blocked. Please allow popups for this site.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await fetchUrl()
|
||||
|
||||
if (url) {
|
||||
newWindow.location.href = url
|
||||
onSuccess?.(url)
|
||||
|
||||
if (successMessage) {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: successMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
newWindow.close()
|
||||
const error = new Error('Invalid URL received')
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
newWindow.close()
|
||||
onError?.(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { openAsync }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -228,6 +228,11 @@ const translation = {
|
|||
installedError: '{{errorLength}} les plugins n’ont 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',
|
||||
|
|
|
|||
|
|
@ -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: 'मार्केटप्लेस से',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}} インストール',
|
||||
|
|
|
|||
|
|
@ -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: '검색 도구...',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'Поиск категорий',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '发布插件',
|
||||
|
|
|
|||
|
|
@ -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: '發佈插件',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -174,6 +174,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",
|
||||
|
|
|
|||
|
|
@ -419,6 +419,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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue