diff --git a/api/core/rag/datasource/vdb/tencent/tencent_vector.py b/api/core/rag/datasource/vdb/tencent/tencent_vector.py index d2bf3eb92a..75afe0cdb8 100644 --- a/api/core/rag/datasource/vdb/tencent/tencent_vector.py +++ b/api/core/rag/datasource/vdb/tencent/tencent_vector.py @@ -122,7 +122,6 @@ class TencentVector(BaseVector): metric_type, params, ) - index_text = vdb_index.FilterIndex(self.field_text, enum.FieldType.String, enum.IndexType.FILTER) index_metadate = vdb_index.FilterIndex(self.field_metadata, enum.FieldType.Json, enum.IndexType.FILTER) index_sparse_vector = vdb_index.SparseIndex( name="sparse_vector", @@ -130,7 +129,7 @@ class TencentVector(BaseVector): index_type=enum.IndexType.SPARSE_INVERTED, metric_type=enum.MetricType.IP, ) - indexes = [index_id, index_vector, index_text, index_metadate] + indexes = [index_id, index_vector, index_metadate] if self._enable_hybrid_search: indexes.append(index_sparse_vector) try: @@ -149,7 +148,7 @@ class TencentVector(BaseVector): index_metadate = vdb_index.FilterIndex( self.field_metadata, enum.FieldType.String, enum.IndexType.FILTER ) - indexes = [index_id, index_vector, index_text, index_metadate] + indexes = [index_id, index_vector, index_metadate] if self._enable_hybrid_search: indexes.append(index_sparse_vector) self._client.create_collection( diff --git a/api/core/repositories/sqlalchemy_workflow_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_execution_repository.py index cdec92aee7..0b3e5eb424 100644 --- a/api/core/repositories/sqlalchemy_workflow_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_execution_repository.py @@ -17,6 +17,7 @@ from core.workflow.entities.workflow_execution import ( ) from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter +from libs.helper import extract_tenant_id from models import ( Account, CreatorUserRole, @@ -67,7 +68,7 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository): ) # Extract tenant_id from user - tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id + tenant_id = extract_tenant_id(user) if not tenant_id: raise ValueError("User must have a tenant_id or current_tenant_id") self._tenant_id = tenant_id diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py index 797cce9354..a5feeb0d7c 100644 --- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py +++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py @@ -20,6 +20,7 @@ from core.workflow.entities.workflow_node_execution import ( from core.workflow.nodes.enums import NodeType from core.workflow.repositories.workflow_node_execution_repository import OrderConfig, WorkflowNodeExecutionRepository from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter +from libs.helper import extract_tenant_id from models import ( Account, CreatorUserRole, @@ -70,7 +71,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository) ) # Extract tenant_id from user - tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id + tenant_id = extract_tenant_id(user) if not tenant_id: raise ValueError("User must have a tenant_id or current_tenant_id") self._tenant_id = tenant_id diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index 23cf4c5cab..b62b0b60d6 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -12,6 +12,7 @@ from flask_login import user_loaded_from_request, user_logged_in # type: ignore from configs import dify_config from dify_app import DifyApp +from libs.helper import extract_tenant_id from models import Account, EndUser @@ -24,11 +25,8 @@ def on_user_loaded(_sender, user: Union["Account", "EndUser"]): if user: try: current_span = get_current_span() - if isinstance(user, Account) and user.current_tenant_id: - tenant_id = user.current_tenant_id - elif isinstance(user, EndUser): - tenant_id = user.tenant_id - else: + tenant_id = extract_tenant_id(user) + if not tenant_id: return if current_span: current_span.set_attribute("service.tenant.id", tenant_id) diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index 9f1bef3b36..f00ea71c54 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -17,6 +17,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": encrypter.obfuscated_token(value.value), "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, Variable): return { @@ -24,6 +25,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": value.value, "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, dict): value_type = value.get("value_type") diff --git a/api/libs/helper.py b/api/libs/helper.py index 3f2a630956..48126461a3 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -25,6 +25,31 @@ from extensions.ext_redis import redis_client if TYPE_CHECKING: from models.account import Account + from models.model import EndUser + + +def extract_tenant_id(user: Union["Account", "EndUser"]) -> str | None: + """ + Extract tenant_id from Account or EndUser object. + + Args: + user: Account or EndUser object + + Returns: + tenant_id string if available, None otherwise + + Raises: + ValueError: If user is neither Account nor EndUser + """ + from models.account import Account + from models.model import EndUser + + if isinstance(user, Account): + return user.current_tenant_id + elif isinstance(user, EndUser): + return user.tenant_id + else: + raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.") def run(script): diff --git a/api/models/workflow.py b/api/models/workflow.py index 7f01135af3..77d48bec4f 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -15,6 +15,7 @@ from core.variables import utils as variable_utils from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID from core.workflow.nodes.enums import NodeType from factories.variable_factory import TypeMismatchError, build_segment_with_type +from libs.helper import extract_tenant_id from ._workflow_exc import NodeNotFoundError, WorkflowDataError @@ -352,12 +353,7 @@ class Workflow(Base): self._environment_variables = "{}" # Get tenant_id from current_user (Account or EndUser) - if isinstance(current_user, Account): - # Account user - tenant_id = current_user.current_tenant_id - else: - # EndUser - tenant_id = current_user.tenant_id + tenant_id = extract_tenant_id(current_user) if not tenant_id: return [] @@ -384,12 +380,7 @@ class Workflow(Base): return # Get tenant_id from current_user (Account or EndUser) - if isinstance(current_user, Account): - # Account user - tenant_id = current_user.current_tenant_id - else: - # EndUser - tenant_id = current_user.tenant_id + tenant_id = extract_tenant_id(current_user) if not tenant_id: self._environment_variables = "{}" diff --git a/api/services/file_service.py b/api/services/file_service.py index 2d68f30c5a..286535bd18 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -18,6 +18,7 @@ from core.file import helpers as file_helpers from core.rag.extractor.extract_processor import ExtractProcessor from extensions.ext_database import db from extensions.ext_storage import storage +from libs.helper import extract_tenant_id from models.account import Account from models.enums import CreatorUserRole from models.model import EndUser, UploadFile @@ -61,11 +62,7 @@ class FileService: # generate file key file_uuid = str(uuid.uuid4()) - if isinstance(user, Account): - current_tenant_id = user.current_tenant_id - else: - # end_user - current_tenant_id = user.tenant_id + current_tenant_id = extract_tenant_id(user) file_key = "upload_files/" + (current_tenant_id or "") + "/" + file_uuid + "." + extension diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py new file mode 100644 index 0000000000..b7701055f5 --- /dev/null +++ b/api/tests/unit_tests/libs/test_helper.py @@ -0,0 +1,65 @@ +import pytest + +from libs.helper import extract_tenant_id +from models.account import Account +from models.model import EndUser + + +class TestExtractTenantId: + """Test cases for the extract_tenant_id utility function.""" + + def test_extract_tenant_id_from_account_with_tenant(self): + """Test extracting tenant_id from Account with current_tenant_id.""" + # Create a mock Account object + account = Account() + # Mock the current_tenant_id property + account._current_tenant = type("MockTenant", (), {"id": "account-tenant-123"})() + + tenant_id = extract_tenant_id(account) + assert tenant_id == "account-tenant-123" + + def test_extract_tenant_id_from_account_without_tenant(self): + """Test extracting tenant_id from Account without current_tenant_id.""" + # Create a mock Account object + account = Account() + account._current_tenant = None + + tenant_id = extract_tenant_id(account) + assert tenant_id is None + + def test_extract_tenant_id_from_enduser_with_tenant(self): + """Test extracting tenant_id from EndUser with tenant_id.""" + # Create a mock EndUser object + end_user = EndUser() + end_user.tenant_id = "enduser-tenant-456" + + tenant_id = extract_tenant_id(end_user) + assert tenant_id == "enduser-tenant-456" + + def test_extract_tenant_id_from_enduser_without_tenant(self): + """Test extracting tenant_id from EndUser without tenant_id.""" + # Create a mock EndUser object + end_user = EndUser() + end_user.tenant_id = None + + tenant_id = extract_tenant_id(end_user) + assert tenant_id is None + + def test_extract_tenant_id_with_invalid_user_type(self): + """Test extracting tenant_id with invalid user type raises ValueError.""" + invalid_user = "not_a_user_object" + + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(invalid_user) + + def test_extract_tenant_id_with_none_user(self): + """Test extracting tenant_id with None user raises ValueError.""" + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(None) + + def test_extract_tenant_id_with_dict_user(self): + """Test extracting tenant_id with dict user raises ValueError.""" + dict_user = {"id": "123", "tenant_id": "456"} + + with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"): + extract_tenant_id(dict_user) diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index 69163d48bd..5bc77ad0ef 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -9,6 +9,7 @@ from core.file.models import File from core.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable from core.variables.segments import IntegerSegment, Segment from factories.variable_factory import build_segment +from models.model import EndUser from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable @@ -43,7 +44,7 @@ def test_environment_variables(): ) # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( @@ -90,7 +91,7 @@ def test_update_environment_variables(): ) # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( @@ -136,7 +137,7 @@ def test_to_dict(): # Create some EnvironmentVariable instances # Mock current_user as an EndUser - mock_user = mock.Mock() + mock_user = mock.Mock(spec=EndUser) mock_user.tenant_id = "tenant_id" with ( diff --git a/docker/.env.example b/docker/.env.example index e7dbecb413..a403f25cb2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -826,6 +826,9 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Allow rendering unsafe URLs which have "data:" scheme. +ALLOW_UNSAFE_DATA_SCHEME=false + # ------------------------------ # Environment Variables for db Service # ------------------------------ diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index a34f96e945..fd7c78c7e7 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -67,6 +67,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e48b5afd8c..0a95251ff0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -364,6 +364,7 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}} POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}} @@ -582,6 +583,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/web/.env.example b/web/.env.example index c30064ffed..37bfc939eb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -32,6 +32,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Allow rendering unsafe URLs which have "data:" scheme. +NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false + # Github Access Token, used for invoking Github API NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= # The maximum number of top-k value for RAG. diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx new file mode 100644 index 0000000000..f997297691 --- /dev/null +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -0,0 +1,87 @@ +import { + isValidElement, + memo, + useMemo, +} from 'react' +import type { AnyFieldApi } from '@tanstack/react-form' +import { useStore } from '@tanstack/react-form' +import cn from '@/utils/classnames' +import Input from '@/app/components/base/input' +import type { FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { useRenderI18nObject } from '@/hooks/use-i18n' + +export type BaseFieldProps = { + fieldClassName?: string + labelClassName?: string + inputContainerClassName?: string + inputClassName?: string + formSchema: FormSchema + field: AnyFieldApi + disabled?: boolean +} +const BaseField = ({ + fieldClassName, + labelClassName, + inputContainerClassName, + inputClassName, + formSchema, + field, + disabled, +}: BaseFieldProps) => { + const renderI18nObject = useRenderI18nObject() + const { + label, + } = formSchema + + const memorizedLabel = useMemo(() => { + if (isValidElement(label)) + return label + + if (typeof label === 'string') + return label + + if (typeof label === 'object' && label !== null) + return renderI18nObject(label as Record) + }, [label, renderI18nObject]) + const value = useStore(field.form.store, s => s.values[field.name]) + + return ( +
+
+ {memorizedLabel} +
+
+ { + formSchema.type === FormTypeEnum.textInput && ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + disabled={disabled} + /> + ) + } + { + formSchema.type === FormTypeEnum.secretInput && ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + disabled={disabled} + /> + ) + } +
+
+ ) +} + +export default memo(BaseField) diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx new file mode 100644 index 0000000000..3fee80bac1 --- /dev/null +++ b/web/app/components/base/form/components/base/base-form.tsx @@ -0,0 +1,96 @@ +import { + memo, + useCallback, + useImperativeHandle, +} from 'react' +import type { + AnyFieldApi, +} from '@tanstack/react-form' +import { useForm } from '@tanstack/react-form' +import type { + FormRef, + FormSchema, +} from '@/app/components/base/form/types' +import { + BaseField, +} from '.' +import type { + BaseFieldProps, +} from '.' +import cn from '@/utils/classnames' + +export type BaseFormProps = { + formSchemas?: FormSchema[] + defaultValues?: Record + formClassName?: string + ref?: FormRef + disabled?: boolean +} & Pick + +const BaseForm = ({ + formSchemas, + defaultValues, + formClassName, + fieldClassName, + labelClassName, + inputContainerClassName, + inputClassName, + ref, + disabled, +}: BaseFormProps) => { + const form = useForm({ + defaultValues, + }) + + useImperativeHandle(ref, () => { + return { + getForm() { + return form + }, + } + }, [form]) + + const renderField = useCallback((field: AnyFieldApi) => { + const formSchema = formSchemas?.find(schema => schema.name === field.name) + + if (formSchema) { + return ( + + ) + } + + return null + }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled]) + + if (!formSchemas?.length) + return null + + return ( +
+ { + formSchemas.map((formSchema) => { + return ( + + {renderField} + + ) + }) + } +
+ ) +} + +export default memo(BaseForm) diff --git a/web/app/components/base/form/components/base/index.tsx b/web/app/components/base/form/components/base/index.tsx new file mode 100644 index 0000000000..0d6f0808ff --- /dev/null +++ b/web/app/components/base/form/components/base/index.tsx @@ -0,0 +1,2 @@ +export { default as BaseForm, type BaseFormProps } from './base-form' +export { default as BaseField, type BaseFieldProps } from './base-field' diff --git a/web/app/components/base/form/form-scenarios/auth/index.tsx b/web/app/components/base/form/form-scenarios/auth/index.tsx new file mode 100644 index 0000000000..5a88f94ac6 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/auth/index.tsx @@ -0,0 +1,21 @@ +import { memo } from 'react' +import { BaseForm } from '../../components/base' +import type { BaseFormProps } from '../../components/base' + +const AuthForm = ({ + formSchemas = [], + defaultValues, + ref, +}: BaseFormProps) => { + return ( + + ) +} + +export default memo(AuthForm) diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts new file mode 100644 index 0000000000..5156477a38 --- /dev/null +++ b/web/app/components/base/form/types.ts @@ -0,0 +1,51 @@ +import type { + ForwardedRef, + ReactNode, +} from 'react' +import type { AnyFormApi } from '@tanstack/react-form' + +export type TypeWithI18N = { + en_US: T + zh_Hans: T + [key: string]: T +} + +export type FormShowOnObject = { + variable: string + value: string +} + +export enum FormTypeEnum { + textInput = 'text-input', + textNumber = 'number-input', + secretInput = 'secret-input', + select = 'select', + radio = 'radio', + boolean = 'boolean', + files = 'files', + file = 'file', + modelSelector = 'model-selector', + toolSelector = 'tool-selector', + multiToolSelector = 'array[tools]', + appSelector = 'app-selector', + dynamicSelect = 'dynamic-select', +} + +export type FormSchema = { + type: FormTypeEnum + name: string + label: string | ReactNode | TypeWithI18N + required: boolean + default?: any + tooltip?: string | TypeWithI18N + show_on?: FormShowOnObject[] + url?: string + scope?: string +} + +export type FormValues = Record + +export type FromRefObject = { + getForm: () => AnyFormApi +} +export type FormRef = ForwardedRef diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index 4e9e98dbed..d8df76aefc 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -1,3 +1,7 @@ +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' + export const isValidUrl = (url: string): boolean => { - return ['http:', 'https:', '//', 'mailto:'].some(prefix => url.startsWith(prefix)) + const validPrefixes = ['http:', 'https:', '//', 'mailto:'] + if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') + return validPrefixes.some(prefix => url.startsWith(prefix)) } diff --git a/web/app/components/base/markdown/markdown-utils.ts b/web/app/components/base/markdown/markdown-utils.ts index 209fcd0b32..0089bef0ac 100644 --- a/web/app/components/base/markdown/markdown-utils.ts +++ b/web/app/components/base/markdown/markdown-utils.ts @@ -4,6 +4,7 @@ * Includes preprocessing for LaTeX and custom "think" tags. */ import { flow } from 'lodash-es' +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' export const preprocessLaTeX = (content: string) => { if (typeof content !== 'string') @@ -86,5 +87,8 @@ export const customUrlTransform = (uri: string): string | undefined => { if (PERMITTED_SCHEME_REGEX.test(scheme)) return uri + if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') + return uri + return undefined } diff --git a/web/app/components/base/modal/modal.tsx b/web/app/components/base/modal/modal.tsx new file mode 100644 index 0000000000..5738704722 --- /dev/null +++ b/web/app/components/base/modal/modal.tsx @@ -0,0 +1,123 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { RiCloseLine } from '@remixicon/react' +import { + PortalToFollowElem, + PortalToFollowElemContent, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import cn from '@/utils/classnames' + +type ModalProps = { + onClose?: () => void + size?: 'sm' | 'md' + title: string + subTitle?: string + children?: React.ReactNode + confirmButtonText?: string + onConfirm?: () => void + cancelButtonText?: string + onCancel?: () => void + showExtraButton?: boolean + extraButtonText?: string + extraButtonVariant?: ButtonProps['variant'] + onExtraButtonClick?: () => void + footerSlot?: React.ReactNode + bottomSlot?: React.ReactNode + disabled?: boolean +} +const Modal = ({ + onClose, + size = 'sm', + title, + subTitle, + children, + confirmButtonText, + onConfirm, + cancelButtonText, + onCancel, + showExtraButton, + extraButtonVariant = 'warning', + extraButtonText, + onExtraButtonClick, + footerSlot, + bottomSlot, + disabled, +}: ModalProps) => { + const { t } = useTranslation() + + return ( + + +
e.stopPropagation()} + > +
+ {title} + { + subTitle && ( +
+ {subTitle} +
+ ) + } +
+ +
+
+ { + children && ( +
{children}
+ ) + } +
+ {footerSlot} + { + showExtraButton && ( + <> + +
+ + ) + } + + +
+ {bottomSlot} +
+
+
+ ) +} + +export default memo(Modal) diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx index 53f36be5fb..697d6e3d96 100644 --- a/web/app/components/base/tooltip/index.tsx +++ b/web/app/components/base/tooltip/index.tsx @@ -101,7 +101,7 @@ const Tooltip: FC = ({ > {popupContent && (
triggerMethod === 'hover' && setHoverPopup()} diff --git a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx new file mode 100644 index 0000000000..e7312168b4 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx @@ -0,0 +1,46 @@ +import { + memo, + useState, +} from 'react' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import ApiKeyModal from './api-key-modal' + +export type AddApiKeyButtonProps = { + provider?: string + buttonVariant?: ButtonProps['variant'] + buttonText?: string + disabled?: boolean +} +const AddApiKeyButton = ({ + provider = '', + buttonVariant = 'secondary-accent', + buttonText = 'use api key', + disabled, +}: AddApiKeyButtonProps) => { + const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) + + return ( + <> + + { + isApiKeyModalOpen && ( + setIsApiKeyModalOpen(false)} + /> + ) + } + + + ) +} + +export default memo(AddApiKeyButton) diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx new file mode 100644 index 0000000000..6f2460b2de --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -0,0 +1,72 @@ +import { + memo, + useState, +} from 'react' +import { RiEqualizer2Line } from '@remixicon/react' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import OAuthClientSettings from './oauth-client-settings' +import cn from '@/utils/classnames' + +export type AddOAuthButtonProps = { + buttonVariant?: ButtonProps['variant'] + buttonText?: string + className?: string + buttonLeftClassName?: string + buttonRightClassName?: string + dividerClassName?: string + disabled?: boolean +} +const AddOAuthButton = ({ + buttonVariant = 'primary', + buttonText = 'use oauth', + className, + buttonLeftClassName, + buttonRightClassName, + dividerClassName, + disabled, +}: AddOAuthButtonProps) => { + const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false) + + return ( + <> + + { + isOAuthSettingsOpen && ( + setIsOAuthSettingsOpen(false)} + /> + ) + } + + ) +} + +export default memo(AddOAuthButton) diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx new file mode 100644 index 0000000000..b7aae3c9fd --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -0,0 +1,145 @@ +import { + memo, + useCallback, + useMemo, + useRef, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiExternalLinkLine } from '@remixicon/react' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Modal from '@/app/components/base/modal/modal' +import { + useAddPluginToolCredential, + useGetPluginToolCredentialSchema, + useInvalidPluginToolCredentialInfo, + useUpdatePluginToolCredential, +} from '@/service/use-plugins-auth' +import { CredentialTypeEnum } from '../types' +import { transformFormSchemasSecretInput } from '../utils' +import AuthForm from '@/app/components/base/form/form-scenarios/auth' +import type { FromRefObject } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { useToastContext } from '@/app/components/base/toast' + +export type ApiKeyModalProps = { + provider: string + onClose?: () => void + editValues?: Record + onRemove?: () => void + disabled?: boolean +} +const ApiKeyModal = ({ + provider, + onClose, + editValues, + onRemove, + disabled, +}: ApiKeyModalProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const { data = [] } = useGetPluginToolCredentialSchema(provider, CredentialTypeEnum.API_KEY) + const formSchemas = useMemo(() => { + return [ + { + type: FormTypeEnum.textInput, + name: '__name__', + label: 'Authorization name', + required: false, + }, + ...data, + ] + }, [data]) + const { mutateAsync: addPluginToolCredential } = useAddPluginToolCredential(provider) + const { mutateAsync: updatePluginToolCredential } = useUpdatePluginToolCredential(provider) + const invalidatePluginToolCredentialInfo = useInvalidPluginToolCredentialInfo(provider) + const formRef = useRef(null) + const handleConfirm = useCallback(async () => { + const form = formRef.current?.getForm() + const store = form?.store + const { + __name__, + __credential_id__, + ...values + } = store?.state.values + const isPristineSecretInputNames: string[] = [] + formSchemas.forEach((schema) => { + if (schema.type === FormTypeEnum.secretInput) { + const fieldMeta = form?.getFieldMeta(schema.name) + if (fieldMeta?.isPristine) + isPristineSecretInputNames.push(schema.name) + } + }) + + const transformedValues = transformFormSchemasSecretInput(isPristineSecretInputNames, values) + + if (editValues) { + await updatePluginToolCredential({ + credentials: transformedValues, + credential_id: __credential_id__, + type: CredentialTypeEnum.API_KEY, + name: __name__ || '', + }) + } + else { + await addPluginToolCredential({ + credentials: transformedValues, + type: CredentialTypeEnum.API_KEY, + name: __name__ || '', + }) + } + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + + onClose?.() + invalidatePluginToolCredentialInfo() + }, [addPluginToolCredential, onClose, invalidatePluginToolCredentialInfo, updatePluginToolCredential, notify, t, editValues, formSchemas]) + + return ( + + Get your API Key from OpenAI + + + } + bottomSlot={ +
+ + {t('common.modelProvider.encrypted.front')} + + PKCS1_OAEP + + {t('common.modelProvider.encrypted.back')} +
+ } + onConfirm={handleConfirm} + showExtraButton={!!editValues} + onExtraButtonClick={onRemove} + disabled={disabled} + > + +
+ ) +} + +export default memo(ApiKeyModal) diff --git a/web/app/components/plugins/plugin-auth/authorize/index.tsx b/web/app/components/plugins/plugin-auth/authorize/index.tsx new file mode 100644 index 0000000000..ceb5e79023 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/index.tsx @@ -0,0 +1,91 @@ +import { + memo, + useMemo, +} from 'react' +import AddOAuthButton from './add-oauth-button' +import type { AddOAuthButtonProps } from './add-oauth-button' +import AddApiKeyButton from './add-api-key-button' +import type { AddApiKeyButtonProps } from './add-api-key-button' + +type AuthorizeProps = { + provider?: string + theme?: 'primary' | 'secondary' + showDivider?: boolean + canOAuth?: boolean + canApiKey?: boolean + disabled?: boolean +} +const Authorize = ({ + provider = '', + theme = 'primary', + showDivider = true, + canOAuth, + canApiKey, + disabled, +}: AuthorizeProps) => { + const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { + if (theme === 'secondary') { + return { + buttonText: !canApiKey ? 'Add OAuth Authorization' : 'Add OAuth', + buttonVariant: 'secondary', + className: 'hover:bg-components-button-secondary-bg', + buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover', + buttonRightClassName: 'hover:bg-components-button-secondary-bg-hover', + dividerClassName: 'bg-divider-regular opacity-100', + } + } + + return { + buttonText: !canApiKey ? 'Use OAuth Authorization' : 'Use OAuth', + } + }, [canApiKey, theme]) + + const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => { + if (theme === 'secondary') { + return { + provider, + buttonVariant: 'secondary', + buttonText: !canOAuth ? 'API Key Authorization Configuration' : 'Add API Key', + } + } + return { + provider, + buttonText: !canOAuth ? 'API Key Authorization Configuration' : 'Use API Key', + buttonVariant: !canOAuth ? 'primary' : 'secondary-accent', + } + }, [canOAuth, theme, provider]) + + return ( + <> +
+ { + canOAuth && ( + + ) + } + { + showDivider && canOAuth && canApiKey && ( +
+
+ or +
+
+ ) + } + { + canApiKey && ( + + ) + } +
+ + ) +} + +export default memo(Authorize) diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx new file mode 100644 index 0000000000..724351bd57 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -0,0 +1,26 @@ +import { memo } from 'react' +import Modal from '@/app/components/base/modal/modal' + +type OAuthClientSettingsProps = { + onClose?: () => void +} +const OAuthClientSettings = ({ + onClose, +}: OAuthClientSettingsProps) => { + return ( + +
oauth
+
+ ) +} + +export default memo(OAuthClientSettings) diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx new file mode 100644 index 0000000000..1bf0425695 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -0,0 +1,94 @@ +import { + memo, + useCallback, + useMemo, + useState, +} from 'react' +import { RiArrowDownSLine } from '@remixicon/react' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import cn from '@/utils/classnames' +import type { Credential } from './types' +import { + Authorized, + usePluginAuth, +} from '.' + +type AuthorizedInNodeProps = { + provider: string + onAuthorizationItemClick: (id: string) => void + credentialId?: string +} +const AuthorizedInNode = ({ + provider = '', + onAuthorizationItemClick, + credentialId, +}: AuthorizedInNodeProps) => { + const [isOpen, setIsOpen] = useState(false) + const { + canApiKey, + canOAuth, + credentials, + disabled, + } = usePluginAuth(provider, isOpen) + const label = useMemo(() => { + if (!credentialId) + return 'Workspace default' + const credential = credentials.find(c => c.id === credentialId) + + if (!credential) + return 'Auth removed' + + return credential.name + }, [credentials, credentialId]) + const renderTrigger = useCallback((open?: boolean) => { + return ( + + ) + }, [label]) + const extraAuthorizationItems: Credential[] = [ + { + id: '__workspace_default__', + name: 'Workspace default', + provider: '', + is_default: false, + isWorkspaceDefault: true, + }, + ] + const handleAuthorizationItemClick = useCallback((id: string) => { + onAuthorizationItemClick(id) + setIsOpen(false) + }, [ + onAuthorizationItemClick, + setIsOpen, + ]) + + return ( + + ) +} + +export default memo(AuthorizedInNode) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx new file mode 100644 index 0000000000..f1188aaa70 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -0,0 +1,267 @@ +import { + memo, + useCallback, + useRef, + useState, +} from 'react' +import { + RiArrowDownSLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { + PortalToFollowElemOptions, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import cn from '@/utils/classnames' +import Confirm from '@/app/components/base/confirm' +import Authorize from '../authorize' +import type { Credential } from '../types' +import { CredentialTypeEnum } from '../types' +import ApiKeyModal from '../authorize/api-key-modal' +import Item from './item' +import { + useDeletePluginToolCredential, + useInvalidPluginToolCredentialInfo, + useSetPluginToolDefaultCredential, +} from '@/service/use-plugins-auth' +import { useToastContext } from '@/app/components/base/toast' + +type AuthorizedProps = { + provider: string + credentials: Credential[] + canOAuth?: boolean + canApiKey?: boolean + disabled?: boolean + renderTrigger?: (open?: boolean) => React.ReactNode + isOpen?: boolean + onOpenChange?: (open: boolean) => void + offset?: PortalToFollowElemOptions['offset'] + placement?: PortalToFollowElemOptions['placement'] + triggerPopupSameWidth?: boolean + popupClassName?: string + disableSetDefault?: boolean + onItemClick?: (id: string) => void + extraAuthorizationItems?: Credential[] +} +const Authorized = ({ + provider, + credentials, + canOAuth, + canApiKey, + disabled, + renderTrigger, + isOpen, + onOpenChange, + offset = 8, + placement = 'bottom-start', + triggerPopupSameWidth = true, + popupClassName, + disableSetDefault, + onItemClick, + extraAuthorizationItems, +}: AuthorizedProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const [isLocalOpen, setIsLocalOpen] = useState(false) + const mergedIsOpen = isOpen ?? isLocalOpen + const setMergedIsOpen = useCallback((open: boolean) => { + if (onOpenChange) + onOpenChange(open) + + setIsLocalOpen(open) + }, [onOpenChange]) + const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2) + const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY) + const pendingOperationCredentialId = useRef(null) + const [deleteCredentialId, setDeleteCredentialId] = useState(null) + const { mutateAsync: deletePluginToolCredential } = useDeletePluginToolCredential(provider) + const invalidatePluginToolCredentialInfo = useInvalidPluginToolCredentialInfo(provider) + const openConfirm = useCallback((credentialId?: string) => { + if (credentialId) + pendingOperationCredentialId.current = credentialId + + setDeleteCredentialId(pendingOperationCredentialId.current) + }, []) + const closeConfirm = useCallback(() => { + setDeleteCredentialId(null) + pendingOperationCredentialId.current = null + }, []) + const handleConfirm = useCallback(async () => { + if (!pendingOperationCredentialId.current) { + setDeleteCredentialId(null) + return + } + + await deletePluginToolCredential({ credential_id: pendingOperationCredentialId.current }) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + invalidatePluginToolCredentialInfo() + setDeleteCredentialId(null) + pendingOperationCredentialId.current = null + }, [deletePluginToolCredential, invalidatePluginToolCredentialInfo, notify, t]) + const [editValues, setEditValues] = useState | null>(null) + const handleEdit = useCallback((id: string, values: Record) => { + pendingOperationCredentialId.current = id + setEditValues(values) + }, []) + const handleRemove = useCallback(() => { + setDeleteCredentialId(pendingOperationCredentialId.current) + }, []) + const { mutateAsync: setPluginToolDefaultCredential } = useSetPluginToolDefaultCredential(provider) + const handleSetDefault = useCallback(async (id: string) => { + await setPluginToolDefaultCredential(id) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + invalidatePluginToolCredentialInfo() + }, [setPluginToolDefaultCredential, invalidatePluginToolCredentialInfo, notify, t]) + + return ( + <> + + setMergedIsOpen(!mergedIsOpen)} + asChild + > + { + renderTrigger + ? renderTrigger(mergedIsOpen) + : ( + + ) + } + + +
+ { + !!extraAuthorizationItems?.length && ( +
+ { + extraAuthorizationItems.map(credential => ( + + )) + } +
+ ) + } +
+ { + !!oAuthCredentials.length && ( +
+
+ OAuth +
+ { + oAuthCredentials.map(credential => ( + + )) + } +
+ ) + } + { + !!apiKeyCredentials.length && ( +
+
+ API Keys +
+ { + apiKeyCredentials.map(credential => ( + + )) + } +
+ ) + } +
+
+
+ +
+
+
+
+ { + deleteCredentialId && ( + + ) + } + { + !!editValues && ( + { + setEditValues(null) + pendingOperationCredentialId.current = null + }} + onRemove={handleRemove} + disabled={disabled} + /> + ) + } + + ) +} + +export default memo(Authorized) diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx new file mode 100644 index 0000000000..f54ef5ac9b --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -0,0 +1,139 @@ +import { + memo, + useMemo, +} from 'react' +import { + RiDeleteBinLine, + RiEditLine, + RiEqualizer2Line, +} from '@remixicon/react' +import Indicator from '@/app/components/header/indicator' +import Badge from '@/app/components/base/badge' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import Button from '@/app/components/base/button' +import type { Credential } from '../types' +import { CredentialTypeEnum } from '../types' + +type ItemProps = { + credential: Credential + disabled?: boolean + onDelete?: (id: string) => void + onEdit?: (id: string, values: Record) => void + onSetDefault?: (id: string) => void + disableRename?: boolean + disableEdit?: boolean + disableDelete?: boolean + disableSetDefault?: boolean + onItemClick?: (id: string) => void +} +const Item = ({ + credential, + disabled, + onDelete, + onEdit, + onSetDefault, + disableRename, + disableEdit, + disableDelete, + disableSetDefault, + onItemClick, +}: ItemProps) => { + const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2 + const showAction = useMemo(() => { + return !(disableRename && disableEdit && disableDelete && disableSetDefault) + }, [disableRename, disableEdit, disableDelete, disableSetDefault]) + + return ( +
onItemClick?.(credential.id)} + > +
+ +
+ {credential.name} +
+ { + credential.is_default && ( + + Default + + ) + } +
+ { + showAction && ( +
+ { + !credential.is_default && !disableSetDefault && ( + + ) + } + { + isOAuth && !disableRename && ( + + + + + + ) + } + { + !isOAuth && !disableEdit && ( + + { + e.stopPropagation() + onEdit?.( + credential.id, + { + ...credential.credentials, + __name__: credential.name, + __credential_id__: credential.id, + }, + ) + }} + > + + + + ) + } + { + !disableDelete && ( + + { + e.stopPropagation() + onDelete?.(credential.id) + }} + > + + + + ) + } +
+ ) + } +
+ ) +} + +export default memo(Item) diff --git a/web/app/components/plugins/plugin-auth/hooks.ts b/web/app/components/plugins/plugin-auth/hooks.ts new file mode 100644 index 0000000000..3c47997e4e --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks.ts @@ -0,0 +1,20 @@ +import { useAppContext } from '@/context/app-context' +import { useGetPluginToolCredentialInfo } from '@/service/use-plugins-auth' +import { CredentialTypeEnum } from './types' + +export const usePluginAuth = (provider: string, enable?: boolean) => { + const { data } = useGetPluginToolCredentialInfo(enable ? provider : '') + const { isCurrentWorkspaceManager } = useAppContext() + const isAuthorized = !!data?.credentials.length + const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2) + const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY) + + return { + isAuthorized, + canOAuth, + canApiKey, + credentials: data?.credentials || [], + provider, + disabled: !isCurrentWorkspaceManager, + } +} diff --git a/web/app/components/plugins/plugin-auth/index.tsx b/web/app/components/plugins/plugin-auth/index.tsx new file mode 100644 index 0000000000..a3cde28c72 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/index.tsx @@ -0,0 +1,4 @@ +export { default as PluginAuth } from './plugin-auth' +export { default as Authorized } from './authorized' +export { default as AuthorizedInNode } from './authorized-in-node' +export { usePluginAuth } from './hooks' diff --git a/web/app/components/plugins/plugin-auth/plugin-auth.tsx b/web/app/components/plugins/plugin-auth/plugin-auth.tsx new file mode 100644 index 0000000000..f02c66bc5f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/plugin-auth.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react' +import Authorize from './authorize' +import Authorized from './authorized' +import { useAppContext } from '@/context/app-context' +import { useGetPluginToolCredentialInfo } from '@/service/use-plugins-auth' +import { CredentialTypeEnum } from './types' + +type PluginAuthProps = { + provider?: string + children?: React.ReactNode +} +const PluginAuth = ({ + provider = '', + children, +}: PluginAuthProps) => { + const { data } = useGetPluginToolCredentialInfo(provider) + const { isCurrentWorkspaceManager } = useAppContext() + const isAuthorized = !!data?.credentials.length + const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2) + const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY) + + return ( + <> + { + !isAuthorized && ( + + ) + } + { + isAuthorized && !children && ( + + ) + } + { + isAuthorized && children + } + + ) +} + +export default memo(PluginAuth) diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts new file mode 100644 index 0000000000..f60324b7f7 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -0,0 +1,14 @@ +export enum CredentialTypeEnum { + OAUTH2 = 'oauth2', + API_KEY = 'api-key', +} + +export type Credential = { + id: string + name: string + provider: string + credential_type?: CredentialTypeEnum + is_default: boolean + credentials?: Record + isWorkspaceDefault?: boolean +} diff --git a/web/app/components/plugins/plugin-auth/utils.ts b/web/app/components/plugins/plugin-auth/utils.ts new file mode 100644 index 0000000000..d264cfb198 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/utils.ts @@ -0,0 +1,10 @@ +export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record) => { + const transformedValues: Record = { ...values } + + isPristineSecretInputNames.forEach((name) => { + if (transformedValues[name]) + transformedValues[name] = '[__HIDDEN__]' + }) + + return transformedValues +} diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.tsx index 2505b6d5aa..040c728630 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/action-list.tsx @@ -1,17 +1,9 @@ -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' -import Button from '@/app/components/base/button' -import Toast from '@/app/components/base/toast' -import Indicator from '@/app/components/header/indicator' import ToolItem from '@/app/components/tools/provider/tool-item' -import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import { useAllToolProviders, useBuiltinTools, - useInvalidateAllToolProviders, - useRemoveProviderCredentials, - useUpdateProviderCredentials, } from '@/service/use-tools' import type { PluginDetail } from '@/app/components/plugins/types' @@ -23,35 +15,14 @@ const ActionList = ({ detail, }: Props) => { const { t } = useTranslation() - const { isCurrentWorkspaceManager } = useAppContext() const providerBriefInfo = detail.declaration.tool.identity const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}` const { data: collectionList = [] } = useAllToolProviders() - const invalidateAllToolProviders = useInvalidateAllToolProviders() const provider = useMemo(() => { return collectionList.find(collection => collection.name === providerKey) }, [collectionList, providerKey]) const { data } = useBuiltinTools(providerKey) - const [showSettingAuth, setShowSettingAuth] = useState(false) - - const handleCredentialSettingUpdate = () => { - invalidateAllToolProviders() - Toast.notify({ - type: 'success', - message: t('common.api.actionSuccess'), - }) - setShowSettingAuth(false) - } - - const { mutate: updatePermission, isPending } = useUpdateProviderCredentials({ - onSuccess: handleCredentialSettingUpdate, - }) - - const { mutate: removePermission } = useRemoveProviderCredentials({ - onSuccess: handleCredentialSettingUpdate, - }) - if (!data || !provider) return null @@ -60,26 +31,7 @@ const ActionList = ({
{t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })} - {provider.is_team_authorization && provider.allow_delete && ( - - )}
- {!provider.is_team_authorization && provider.allow_delete && ( - - )}
{data.map(tool => ( @@ -93,18 +45,6 @@ const ActionList = ({ /> ))}
- {showSettingAuth && ( - setShowSettingAuth(false)} - onSaved={async value => updatePermission({ - providerName: provider.name, - credentials: value, - })} - onRemove={async () => removePermission(provider.name)} - isSaving={isPending} - /> - )}
) } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index a1017629aa..105679ae6b 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -41,6 +41,8 @@ import { getMarketplaceUrl } from '@/utils/var' import useReferenceSetting from '../plugin-page/use-reference-setting' import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types' import { useAppContext } from '@/context/app-context' +import { PluginAuth } from '@/app/components/plugins/plugin-auth' +import { useAllToolProviders } from '@/service/use-tools' const i18nPrefix = 'plugin.action' @@ -75,7 +77,14 @@ const DetailHeader = ({ meta, plugin_id, } = detail - const { author, category, name, label, description, icon, verified } = detail.declaration + const { author, category, name, label, description, icon, verified, tool } = detail.declaration + const isTool = category === PluginType.tool + const providerBriefInfo = tool?.identity + const providerKey = `${plugin_id}/${providerBriefInfo?.name}` + const { data: collectionList = [] } = useAllToolProviders(isTool) + const provider = useMemo(() => { + return collectionList.find(collection => collection.name === providerKey) + }, [collectionList, providerKey]) const isFromGitHub = source === PluginSource.github const isFromMarketplace = source === PluginSource.marketplace @@ -295,6 +304,13 @@ const DetailHeader = ({ + { + category === PluginType.tool && ( + + ) + } {isShowPluginInfo && ( [] output_schema: Record meta?: PluginMeta + credential_id?: string } export type ToolValue = { diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 1058f29119..ac95f54757 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -462,6 +462,7 @@ const formatItem = ( return { variable: `env.${env.name}`, type: env.value_type, + description: env.description, } }) as Var[] break @@ -472,7 +473,7 @@ const formatItem = ( return { variable: `conversation.${chatVar.name}`, type: chatVar.value_type, - des: chatVar.description, + description: chatVar.description, } }) as Var[] break diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 164369e64c..59c46b1e45 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -59,6 +59,11 @@ import { useLogs } from '@/app/components/workflow/run/hooks' import PanelWrap from '../before-run-form/panel-wrap' import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { + AuthorizedInNode, + PluginAuth, +} from '@/app/components/plugins/plugin-auth' +import { canFindTool } from '@/utils' type BasePanelProps = { children: ReactNode @@ -215,6 +220,22 @@ const BasePanel: FC = ({ return {} })() + const buildInTools = useStore(s => s.buildInTools) + const currCollection = useMemo(() => { + return buildInTools.find(item => canFindTool(item.id, data.provider_id)) + }, [buildInTools, data.provider_id]) + const showPluginAuth = useMemo(() => { + return data.type === BlockEnum.Tool && currCollection?.allow_delete && !currCollection.is_team_authorization + }, [currCollection, data.type]) + const handleAuthorizationItemClick = useCallback((id: string) => { + handleNodeDataUpdate({ + id, + data: { + credential_id: id === '__workspace_default__' ? undefined : id, + }, + }) + }, [handleNodeDataUpdate]) + if(logParams.showSpecialResultPanel) { return (
= ({ onChange={handleDescriptionChange} />
-
- -
+ { + showPluginAuth && ( + +
+ +
+
+ ) + } + { + !showPluginAuth && ( +
+ + { + currCollection?.allow_delete && ( + + ) + } +
+ ) + } diff --git a/web/app/components/workflow/nodes/http/default.ts b/web/app/components/workflow/nodes/http/default.ts index 1bd584eeb9..3f9df0178d 100644 --- a/web/app/components/workflow/nodes/http/default.ts +++ b/web/app/components/workflow/nodes/http/default.ts @@ -22,6 +22,7 @@ const nodeDefault: NodeDefault = { type: BodyType.none, data: [], }, + ssl_verify: true, timeout: { max_connect_timeout: 0, max_read_timeout: 0, diff --git a/web/app/components/workflow/nodes/http/panel.tsx b/web/app/components/workflow/nodes/http/panel.tsx index 9a07c0ad61..b994910ea0 100644 --- a/web/app/components/workflow/nodes/http/panel.tsx +++ b/web/app/components/workflow/nodes/http/panel.tsx @@ -10,6 +10,7 @@ import type { HttpNodeType } from './types' import Timeout from './components/timeout' import CurlPanel from './components/curl-panel' import cn from '@/utils/classnames' +import Switch from '@/app/components/base/switch' import Field from '@/app/components/workflow/nodes/_base/components/field' import Split from '@/app/components/workflow/nodes/_base/components/split' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' @@ -47,6 +48,7 @@ const Panel: FC> = ({ showCurlPanel, hideCurlPanel, handleCurlImport, + handleSSLVerifyChange, } = useConfig(id, data) // To prevent prompt editor in body not update data. if (!isDataReady) @@ -124,6 +126,18 @@ const Panel: FC> = ({ onChange={setBody} /> + + }> + { setInputs(newInputs) }, [inputs, setInputs]) + const handleSSLVerifyChange = useCallback((checked: boolean) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.ssl_verify = checked + }) + setInputs(newInputs) + }, [inputs, setInputs]) + return { readOnly, isDataReady, @@ -164,6 +171,8 @@ const useConfig = (id: string, payload: HttpNodeType) => { toggleIsParamKeyValueEdit, // body setBody, + // ssl verify + handleSSLVerifyChange, // authorization isShowAuthorization, showAuthorization, diff --git a/web/app/components/workflow/nodes/tool/panel.tsx b/web/app/components/workflow/nodes/tool/panel.tsx index 936f730a46..3c63a0f892 100644 --- a/web/app/components/workflow/nodes/tool/panel.tsx +++ b/web/app/components/workflow/nodes/tool/panel.tsx @@ -5,10 +5,8 @@ import Split from '../_base/components/split' import type { ToolNodeType } from './types' import useConfig from './use-config' import ToolForm from './components/tool-form' -import Button from '@/app/components/base/button' import Field from '@/app/components/workflow/nodes/_base/components/field' import type { NodePanelProps } from '@/app/components/workflow/types' -import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import Loading from '@/app/components/base/loading' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' @@ -32,10 +30,6 @@ const Panel: FC> = ({ setToolSettingValue, currCollection, isShowAuthBtn, - showSetAuth, - showSetAuthModal, - hideSetAuthModal, - handleSaveAuth, isLoading, outputSchema, hasObjectOutput, @@ -52,21 +46,8 @@ const Panel: FC> = ({ return (
- {!readOnly && isShowAuthBtn && ( - <> -
- -
- - )} - {!isShowAuthBtn && ( -
+ {!isShowAuthBtn && <> +
{toolInputVarSchema.length > 0 && ( > = ({ )}
- )} - - {showSetAuth && ( - - )} - + }
<> diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts index 7d2fedfc22..ea8d0e21ca 100644 --- a/web/app/components/workflow/nodes/tool/use-config.ts +++ b/web/app/components/workflow/nodes/tool/use-config.ts @@ -13,7 +13,7 @@ import { toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' import Toast from '@/app/components/base/toast' -import type { InputVar, Var } from '@/app/components/workflow/types' +import type { InputVar } from '@/app/components/workflow/types' import { useFetchToolsData, useNodesReadOnly, diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index 347c83c155..869317ca6a 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -80,7 +80,7 @@ const ChatVariableModal = ({ const [objectValue, setObjectValue] = React.useState([DEFAULT_OBJECT_VALUE]) const [editorContent, setEditorContent] = React.useState() const [editInJSON, setEditInJSON] = React.useState(false) - const [des, setDes] = React.useState('') + const [description, setDescription] = React.useState('') const editorMinHeight = useMemo(() => { if (type === ChatVarType.ArrayObject) @@ -237,7 +237,7 @@ const ChatVariableModal = ({ name, value_type: type, value: formatValue(value), - description: des, + description, }) onClose() } @@ -247,7 +247,7 @@ const ChatVariableModal = ({ setName(chatVar.name) setType(chatVar.value_type) setValue(chatVar.value) - setDes(chatVar.description) + setDescription(chatVar.description) setObjectValue(getObjectValue()) if (chatVar.value_type === ChatVarType.ArrayObject) { setEditorContent(JSON.stringify(chatVar.value)) @@ -385,9 +385,9 @@ const ChatVariableModal = ({