From 9a68243fcc0bc83752ec59c8a62de27d06c9bdbf Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 26 Jan 2026 10:44:09 +0800 Subject: [PATCH] feat: show provider config --- .../tool-block/tool-group-block-component.tsx | 574 +++++++++++++++++- web/i18n/en-US/workflow.json | 2 + web/i18n/zh-Hans/workflow.json | 2 + web/i18n/zh-Hant/workflow.json | 2 + 4 files changed, 564 insertions(+), 16 deletions(-) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-component.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-component.tsx index 3bffb95cdd..8719e7a240 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-component.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-component.tsx @@ -1,10 +1,29 @@ import type { FC } from 'react' import type { ToolToken } from './utils' +import type { PluginDetail } from '@/app/components/plugins/types' +import type { ToolParameter } from '@/app/components/tools/types' +import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolWithProvider } from '@/app/components/workflow/types' +import { RiCloseLine } from '@remixicon/react' import * as React from 'react' -import { useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' +import { Settings01 } from '@/app/components/base/icons/src/vender/line/general' +import Modal from '@/app/components/base/modal' import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks' +import Switch from '@/app/components/base/switch' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import ToolAuthorizationSection from '@/app/components/plugins/plugin-detail-panel/tool-selector/sections/tool-authorization-section' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' +import { ReadmeShowType } from '@/app/components/plugins/readme-panel/store' +import { CollectionType } from '@/app/components/tools/types' +import { generateFormValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import { VarKindType } from '@/app/components/workflow/nodes/_base/types' +import { START_TAB_ID } from '@/app/components/workflow/skill/constants' +import ToolSettingsSection from '@/app/components/workflow/skill/editor/skill-editor/tool-setting/tool-settings-section' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useGetLanguage } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { @@ -18,12 +37,60 @@ import { canFindTool } from '@/utils' import { cn } from '@/utils/classnames' import { basePath } from '@/utils/var' import { DELETE_TOOL_BLOCK_COMMAND } from './index' +import { useToolBlockContext } from './tool-block-context' type ToolGroupBlockComponentProps = { nodeKey: string tools: ToolToken[] } +type ToolConfigField = { + id: string + value: unknown + auto: boolean +} + +type ToolConfigMetadata = { + type: 'mcp' | 'builtin' + configuration: { + fields: ToolConfigField[] + } +} + +type SkillFileMetadata = { + tools?: Record +} + +type ToolFormSchema = { + variable: string + type: string + default?: unknown +} + +type ToolConfigValueItem = { + auto?: 0 | 1 + value?: { + type: VarKindType + value?: unknown + } | null +} + +type ToolConfigValueMap = Record + +type ToolItem = { + configId: string + providerId: string + toolName: string + toolLabel: string + toolDescription: string + providerIcon?: ToolWithProvider['icon'] + providerIconDark?: ToolWithProvider['icon'] + providerType?: ToolWithProvider['type'] + providerName?: string + providerLabel?: string + toolParams?: ToolParameter[] +} + const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { if (!icon) return icon @@ -37,12 +104,24 @@ const ToolGroupBlockComponent: FC = ({ tools, }) => { const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_TOOL_BLOCK_COMMAND) + const { t } = useTranslation() const language = useGetLanguage() const { theme } = useTheme() + const toolBlockContext = useToolBlockContext() + const isUsingExternalMetadata = Boolean(toolBlockContext?.onMetadataChange) + const useModal = Boolean(toolBlockContext?.useModal) + const [isSettingOpen, setIsSettingOpen] = useState(false) + const [portalContainer, setPortalContainer] = useState(null) + const [expandedToolId, setExpandedToolId] = useState(null) + const [toolValue, setToolValue] = useState(null) + const [enabledByConfigId, setEnabledByConfigId] = useState>({}) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const activeTabId = useStore(s => s.activeTabId) + const fileMetadata = useStore(s => s.fileMetadata) + const storeApi = useWorkflowStore() const mergedTools = useMemo(() => { return [buildInTools, customTools, workflowTools, mcpTools].filter(Boolean) as ToolWithProvider[][] @@ -61,11 +140,294 @@ const ToolGroupBlockComponent: FC = ({ }, [mergedTools, providerId]) const providerLabel = currentProvider?.label?.[language] || currentProvider?.name || providerId + const providerAuthor = currentProvider?.author + const providerDescription = useMemo(() => { + if (!currentProvider?.description) + return '' + return currentProvider.description?.[language] || currentProvider.description?.['en-US'] || Object.values(currentProvider.description).find(Boolean) || '' + }, [currentProvider?.description, language]) const resolvedIcon = (() => { const fromMeta = theme === Theme.dark ? currentProvider?.icon_dark : currentProvider?.icon return normalizeProviderIcon(fromMeta) })() + const toolItems = useMemo(() => { + if (!currentProvider) + return [] + return tools.map((toolToken) => { + const tool = currentProvider.tools?.find(item => item.name === toolToken.tool) + const toolLabel = tool?.label?.[language] || toolToken.tool + const toolDescription = tool?.description?.[language] || '' + return { + configId: toolToken.configId, + providerId: toolToken.provider, + toolName: toolToken.tool, + toolLabel, + toolDescription, + providerIcon: currentProvider.icon, + providerIconDark: currentProvider.icon_dark, + providerType: currentProvider.type, + providerName: currentProvider.name, + providerLabel: currentProvider.label?.[language] || currentProvider.name, + toolParams: tool?.parameters, + } + }) + }, [currentProvider, language, tools]) + + const activeToolItem = useMemo(() => { + if (!expandedToolId) + return undefined + return toolItems.find(item => item.configId === expandedToolId) + }, [expandedToolId, toolItems]) + + const currentTool = useMemo(() => { + if (!activeToolItem || !currentProvider) + return undefined + return currentProvider.tools?.find(item => item.name === activeToolItem.toolName) + }, [activeToolItem, currentProvider]) + + const toolConfigFromMetadata = useMemo(() => { + if (!activeToolItem) + return undefined + if (isUsingExternalMetadata) { + const metadata = toolBlockContext?.metadata as SkillFileMetadata | undefined + return metadata?.tools?.[activeToolItem.configId] + } + if (!activeTabId) + return undefined + const metadata = fileMetadata.get(activeTabId) as SkillFileMetadata | undefined + return metadata?.tools?.[activeToolItem.configId] + }, [activeTabId, activeToolItem, fileMetadata, isUsingExternalMetadata, toolBlockContext?.metadata]) + + const getVarKindType = (type: FormTypeEnum | string) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) + return VarKindType.constant + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + return VarKindType.constant + } + + const defaultToolValue = useMemo(() => { + if (!currentProvider || !currentTool || !activeToolItem) + return null + const settingsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form !== 'llm') || []) as ToolFormSchema[] + const paramsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form === 'llm') || []) as ToolFormSchema[] + const toolLabel = currentTool.label?.[language] || activeToolItem.toolName + const toolDescription = typeof currentTool.description === 'object' + ? (currentTool.description?.[language] || '') + : (currentTool.description || '') + return { + provider_name: currentProvider.id, + provider_show_name: currentProvider.name, + tool_name: currentTool.name, + tool_label: toolLabel, + tool_description: toolDescription, + settings: generateFormValue({}, settingsSchemas), + parameters: generateFormValue({}, paramsSchemas, true), + enabled: true, + extra: { description: toolDescription }, + } as ToolValue + }, [activeToolItem, currentProvider, currentTool, language]) + + const configuredToolValue = useMemo(() => { + if (!defaultToolValue || !currentTool) + return defaultToolValue + const fields = toolConfigFromMetadata?.configuration?.fields ?? [] + if (!fields.length) + return defaultToolValue + const fieldsById = new Map(fields.map(field => [field.id, field])) + const settingsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form !== 'llm') || []) as ToolFormSchema[] + const paramsSchemas = toolParametersToFormSchemas(currentTool.parameters?.filter(param => param.form === 'llm') || []) as ToolFormSchema[] + const applyFields = (schemas: ToolFormSchema[]) => { + const nextValue: ToolConfigValueMap = {} + schemas.forEach((schema) => { + const field = fieldsById.get(schema.variable) + if (!field) + return + const isAuto = Boolean(field.auto) + if (isAuto) { + nextValue[schema.variable] = { auto: 1, value: null } + return + } + nextValue[schema.variable] = { + auto: 0, + value: { + type: getVarKindType(schema.type), + value: field.value ?? null, + }, + } + }) + return nextValue + } + + return { + ...defaultToolValue, + settings: { + ...(defaultToolValue.settings || {}), + ...applyFields(settingsSchemas), + }, + parameters: { + ...(defaultToolValue.parameters || {}), + ...applyFields(paramsSchemas), + }, + } + }, [currentTool, defaultToolValue, toolConfigFromMetadata]) + + const needAuthorization = useMemo(() => { + return !currentProvider?.is_team_authorization + }, [currentProvider]) + + const readmeEntrance = useMemo(() => { + if (!currentProvider) + return null + return + }, [currentProvider]) + + useEffect(() => { + if (!configuredToolValue) + return + if (!toolValue || toolValue.tool_name !== configuredToolValue.tool_name || toolValue.provider_name !== configuredToolValue.provider_name) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setToolValue(configuredToolValue) + }, [configuredToolValue, toolValue]) + + useEffect(() => { + if (expandedToolId) + return + if (toolValue) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setToolValue(null) + }, [expandedToolId, toolValue]) + + useEffect(() => { + if (!isSettingOpen || !configuredToolValue) + return + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setToolValue(configuredToolValue) + }, [configuredToolValue, isSettingOpen]) + + useEffect(() => { + if (useModal) + return + const containerFromRef = ref.current?.closest('[data-skill-editor-root="true"]') as HTMLElement | null + const fallbackContainer = document.querySelector('[data-skill-editor-root="true"]') as HTMLElement | null + const container = containerFromRef || fallbackContainer + if (container) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setPortalContainer(container) + }, [ref, useModal]) + + useEffect(() => { + if (!isSettingOpen || useModal) + return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node | null + const triggerEl = ref.current + const panelEl = portalContainer?.querySelector('[data-tool-group-setting-panel="true"]') + if (!target || !panelEl) + return + if (target instanceof Element && target.closest('[data-modal-root="true"]')) + return + if (panelEl.contains(target)) + return + if (triggerEl && triggerEl.contains(target)) + return + setIsSettingOpen(false) + setExpandedToolId(null) + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isSettingOpen, portalContainer, ref, useModal]) + + const resolvedEnabledByConfigId = useMemo(() => { + const next = { ...enabledByConfigId } + toolItems.forEach((item) => { + if (next[item.configId] === undefined) + next[item.configId] = true + }) + return next + }, [enabledByConfigId, toolItems]) + + const enabledCount = useMemo(() => { + if (!toolItems.length) + return 0 + return toolItems.reduce((count, item) => count + (resolvedEnabledByConfigId[item.configId] === false ? 0 : 1), 0) + }, [resolvedEnabledByConfigId, toolItems]) + + const handleToolValueChange = (nextValue: ToolValue) => { + if (!activeToolItem || !currentProvider || !currentTool) + return + setToolValue(nextValue) + if (isUsingExternalMetadata) { + const metadata = (toolBlockContext?.metadata || {}) as SkillFileMetadata + const toolType = currentProvider.type === CollectionType.mcp ? 'mcp' : 'builtin' + const buildFields = (value: Record | undefined) => { + if (!value) + return [] + return Object.entries(value).map(([id, field]) => { + const fieldValue = field as ToolConfigValueItem | undefined + const auto = Boolean(fieldValue?.auto) + const rawValue = auto ? null : fieldValue?.value?.value ?? null + return { id, value: rawValue, auto } + }) + } + const fields = [ + ...buildFields(nextValue.settings), + ...buildFields(nextValue.parameters), + ] + const nextMetadata: SkillFileMetadata = { + ...metadata, + tools: { + ...(metadata.tools || {}), + [activeToolItem.configId]: { + type: toolType, + configuration: { fields }, + }, + }, + } + toolBlockContext?.onMetadataChange?.(nextMetadata) + return + } + if (!activeTabId || activeTabId === START_TAB_ID) + return + const metadata = (fileMetadata.get(activeTabId) || {}) as SkillFileMetadata + const toolType = currentProvider.type === CollectionType.mcp ? 'mcp' : 'builtin' + const buildFields = (value: Record | undefined) => { + if (!value) + return [] + return Object.entries(value).map(([id, field]) => { + const fieldValue = field as ToolConfigValueItem | undefined + const auto = Boolean(fieldValue?.auto) + const rawValue = auto ? null : fieldValue?.value?.value ?? null + return { id, value: rawValue, auto } + }) + } + const fields = [ + ...buildFields(nextValue.settings), + ...buildFields(nextValue.parameters), + ] + const nextMetadata: SkillFileMetadata = { + ...metadata, + tools: { + ...(metadata.tools || {}), + [activeToolItem.configId]: { + type: toolType, + configuration: { fields }, + }, + }, + } + storeApi.getState().setDraftMetadata(activeTabId, nextMetadata) + storeApi.getState().pinTab(activeTabId) + } + + const handleToggleTool = useCallback((configId: string, nextValue: boolean) => { + setEnabledByConfigId(prev => ({ ...prev, [configId]: nextValue })) + }, []) + const renderIcon = () => { if (!resolvedIcon) return null @@ -96,23 +458,203 @@ const ToolGroupBlockComponent: FC = ({ ) } + const renderProviderHeaderIcon = () => { + if (!resolvedIcon) + return null + if (typeof resolvedIcon === 'string') { + if (resolvedIcon.startsWith('http') || resolvedIcon.startsWith('/')) { + return ( + + + + ) + } + return ( + + ) + } + return ( + + ) + } + + const toolSettingsContent = currentProvider && currentTool && toolValue && ( +
+ +
+ ) + + const groupPanelContent = ( +
+
+
+
+ {renderProviderHeaderIcon()} +
+ {providerLabel} + {providerAuthor && ( + {t('toolGroup.byAuthor', { ns: 'workflow', author: providerAuthor })} + )} +
+
+ +
+ {providerDescription && ( +
+ {providerDescription} +
+ )} +
+
+ { + setToolValue(prev => (prev ? { ...prev, credential_id: id } : prev)) + }} + /> + {needAuthorization && ( +
+
+ {t('skillEditor.authorizationRequired', { ns: 'workflow' })} +
+
+ )} +
+
+
+ {t('toolGroup.actionsEnabled', { ns: 'workflow', num: enabledCount })} +
+
+ {toolItems.map(item => ( +
+
+
+ {item.toolLabel} +
+ {item.toolParams?.length + ? ( + + ) + : null} +
+ { + handleToggleTool(item.configId, value) + }} + /> +
+
+ {item.toolDescription && ( +
+ {item.toolDescription} +
+ )} + {expandedToolId === item.configId && toolSettingsContent} +
+ ))} +
+
+ {readmeEntrance} +
+ ) + return ( - + { + if (!toolItems.length) + return + setIsSettingOpen(true) + }} + > + {renderIcon()} + + {providerLabel} + + + {tools.length} + + + {useModal && ( + { + setIsSettingOpen(false) + setExpandedToolId(null) + }} + className="!max-w-[420px] !bg-transparent !p-0" + overflowVisible + > +
+ {groupPanelContent} +
+
)} - title={providerLabel} - > - {renderIcon()} - - {providerLabel} - - - {tools.length} - -
+ {!useModal && portalContainer && isSettingOpen && createPortal( +
+
+ {groupPanelContent} +
+
, + portalContainer, + )} + ) } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index c361530786..20fda0b89c 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1185,6 +1185,8 @@ "tabs.usePlugin": "Select tool", "tabs.utilities": "Utilities", "tabs.workflowTool": "Workflow", + "toolGroup.actionsEnabled": "{{num}} ACTIONS ENABLED", + "toolGroup.byAuthor": "by {{author}}", "tracing.stopBy": "Stop by {{user}}", "triggerStatus.disabled": "TRIGGER • DISABLED", "triggerStatus.enabled": "TRIGGER", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index bbb0f3d565..0f1ca368c1 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1175,6 +1175,8 @@ "tabs.usePlugin": "选择工具", "tabs.utilities": "工具", "tabs.workflowTool": "工作流", + "toolGroup.actionsEnabled": "{{num}} 个操作已启用", + "toolGroup.byAuthor": "由 {{author}} 提供", "tracing.stopBy": "由{{user}}终止", "triggerStatus.disabled": "触发器 • 已禁用", "triggerStatus.enabled": "触发器", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 87060b9a37..c0640ff8a3 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -1084,6 +1084,8 @@ "tabs.usePlugin": "選取工具", "tabs.utilities": "工具", "tabs.workflowTool": "工作流", + "toolGroup.actionsEnabled": "{{num}} 個操作已啟用", + "toolGroup.byAuthor": "由 {{author}} 提供", "tracing.stopBy": "由{{user}}終止", "triggerStatus.disabled": "觸發器 • 已停用", "triggerStatus.enabled": "觸發",