From 911c1852d55f23193a29a2cc5128333fea807aab Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 21 Jan 2026 15:02:53 +0800 Subject: [PATCH] feat: support choose tools --- .../components/base/prompt-editor/index.tsx | 287 ++++++++++-------- .../nodes/_base/components/prompt/editor.tsx | 6 + .../llm/components/config-prompt-item.tsx | 4 + .../nodes/llm/components/config-prompt.tsx | 21 ++ .../plugins/tool-block/component.tsx | 115 +++++-- .../plugins/tool-block/tool-block-context.tsx | 13 + .../plugins/tool-block/tool-picker-block.tsx | 26 +- 7 files changed, 315 insertions(+), 157 deletions(-) create mode 100644 web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context.tsx diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 78159f8fc3..cdddae14ed 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -31,6 +31,13 @@ import * as React from 'react' import { useEffect } from 'react' import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node' import FileReferenceReplacementBlock from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/replacement-block' +import { + ToolBlock, + ToolBlockNode, + ToolBlockReplacementBlock, +} from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block' +import { ToolBlockContextProvider } from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context' +import ToolPickerBlock from '@/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block' import { useEventEmitterContextContext } from '@/context/event-emitter' import { cn } from '@/utils/classnames' import { @@ -97,6 +104,8 @@ export type PromptEditorProps = { onChange?: (text: string) => void onBlur?: () => void onFocus?: () => void + toolMetadata?: Record + onToolMetadataChange?: (metadata: Record) => void contextBlock?: ContextBlockType queryBlock?: QueryBlockType historyBlock?: HistoryBlockType @@ -124,6 +133,8 @@ const PromptEditor: FC = ({ onChange, onBlur, onFocus, + toolMetadata, + onToolMetadataChange, contextBlock, queryBlock, historyBlock, @@ -156,7 +167,7 @@ const PromptEditor: FC = ({ CurrentBlockNode, ErrorMessageBlockNode, LastRunBlockNode, // LastRunBlockNode is used for error message block replacement - ...(isSupportSandbox ? [FileReferenceNode] : []), + ...(isSupportSandbox ? [FileReferenceNode, ToolBlockNode] : []), ], editorState: textToEditorState(value || ''), onError: (error: Error) => { @@ -185,46 +196,45 @@ const PromptEditor: FC = ({ } as any) }, [eventEmitter, historyBlock?.history]) + const toolBlockContextValue = React.useMemo(() => { + if (!onToolMetadataChange) + return null + return { + metadata: toolMetadata, + onMetadataChange: onToolMetadataChange, + useModal: true, + } + }, [onToolMetadataChange, toolMetadata]) + return ( -
- - )} - placeholder={( - - )} - ErrorBoundary={LexicalErrorBoundary} - /> - - {(!agentBlock || agentBlock.show) && ( + +
+ + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> = ({ currentBlock={currentBlock} errorMessageBlock={errorMessageBlock} lastRunBlock={lastRunBlock} - agentBlock={agentBlock} isSupportFileVar={isSupportFileVar} + isSupportSandbox={isSupportSandbox} /> - )} - - { - contextBlock?.show && ( + {!isSupportSandbox && (!agentBlock || agentBlock.show) && ( + + )} + {isSupportSandbox && ( <> - - + + + {editable && } - ) - } - { - queryBlock?.show && ( - <> - - - - ) - } - { - historyBlock?.show && ( - <> - - - - ) - } - { - (variableBlock?.show || externalToolBlock?.show) && ( - <> - + )} + + { + contextBlock?.show && ( + <> + + + + ) + } + { + queryBlock?.show && ( + <> + + + + ) + } + { + historyBlock?.show && ( + <> + + + + ) + } + { + (variableBlock?.show || externalToolBlock?.show) && ( + <> + + + + ) + } + { + workflowVariableBlock?.show && ( + <> + + + + ) + } + {isSupportSandbox && } + { + currentBlock?.show && ( + <> + + + + ) + } + { + errorMessageBlock?.show && ( + <> + + + + ) + } + { + lastRunBlock?.show && ( + <> + + + + ) + } + { + isSupportFileVar && ( - - ) - } - { - workflowVariableBlock?.show && ( - <> - - - - ) - } - {isSupportSandbox && } - { - currentBlock?.show && ( - <> - - - - ) - } - { - errorMessageBlock?.show && ( - <> - - - - ) - } - { - lastRunBlock?.show && ( - <> - - - - ) - } - { - isSupportFileVar && ( - - ) - } - - - - - {/* */} -
+ ) + } + + + + + {/* */} +
+
) } diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx index 5c332226f2..33d78032bd 100644 --- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx @@ -63,6 +63,8 @@ type Props = { availableNodes?: Node[] isSupportFileVar?: boolean isSupportSandbox?: boolean + promptMetadata?: Record + onPromptMetadataChange?: (metadata: Record) => void isSupportPromptGenerator?: boolean onGenerated?: (prompt: string) => void modelConfig?: ModelConfig @@ -104,6 +106,8 @@ const Editor: FC = ({ availableNodes = [], isSupportFileVar, isSupportSandbox, + promptMetadata, + onPromptMetadataChange, isSupportPromptGenerator, isSupportJinja, editionType, @@ -298,6 +302,8 @@ const Editor: FC = ({ editable={!readOnly} isSupportFileVar={isSupportFileVar} isSupportSandbox={isSupportSandbox} + toolMetadata={promptMetadata} + onToolMetadataChange={onPromptMetadataChange} /> {/* to patch Editor not support dynamic change editable status */} {readOnly &&
} diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx index 224753f7a7..7b843ec816 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx @@ -27,6 +27,7 @@ type Props = { payload: PromptItem handleChatModeMessageRoleChange: (role: PromptRole) => void onPromptChange: (p: string) => void + onMetadataChange: (metadata: Record) => void onEditionTypeChange: (editionType: EditionType) => void onRemove: () => void isShowContext: boolean @@ -74,6 +75,7 @@ const ConfigPromptItem: FC = ({ isChatApp, payload, onPromptChange, + onMetadataChange, onEditionTypeChange, onRemove, isShowContext, @@ -131,6 +133,8 @@ const ConfigPromptItem: FC = ({ )} value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text} onChange={onPromptChange} + promptMetadata={payload.metadata} + onPromptMetadataChange={onMetadataChange} readOnly={readOnly} showRemove={canRemove} onRemove={onRemove} diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx index 3bfaae7cd4..7866d5ffae 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt.tsx @@ -146,6 +146,17 @@ const ConfigPrompt: FC = ({ } }, [onChange, payload]) + const handleChatModeMetadataChange = useCallback((index: number) => { + return (metadata: Record) => { + const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { + const item = draft[index] + if (!isPromptMessageContext(item)) + (item as PromptItem).metadata = metadata + }) + onChange(newPrompt) + } + }, [onChange, payload]) + const handleChatModeEditionTypeChange = useCallback((index: number) => { return (editionType: EditionType) => { const newPrompt = produce(payload as PromptTemplateItem[], (draft) => { @@ -246,6 +257,13 @@ const ConfigPrompt: FC = ({ onChange(newPrompt) }, [onChange, payload]) + const handleCompletionMetadataChange = useCallback((metadata: Record) => { + const newPrompt = produce(payload as PromptItem, (draft) => { + draft.metadata = metadata + }) + onChange(newPrompt) + }, [onChange, payload]) + const handleGenerated = useCallback((prompt: string) => { handleCompletionPromptChange(prompt) setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) @@ -331,6 +349,7 @@ const ConfigPrompt: FC = ({ isChatApp={isChatApp} payload={item} onPromptChange={handleChatModePromptChange(index)} + onMetadataChange={handleChatModeMetadataChange(index)} onEditionTypeChange={handleChatModeEditionTypeChange(index)} onRemove={handleRemove(index)} isShowContext={isShowContext} @@ -399,6 +418,8 @@ const ConfigPrompt: FC = ({ title={{t(`${i18nPrefix}.prompt`, { ns: 'workflow' })}} value={((payload as PromptItem).edition_type === EditionType.basic || !(payload as PromptItem).edition_type) ? (payload as PromptItem).text : ((payload as PromptItem).jinja2_text || '')} onChange={handleCompletionPromptChange} + promptMetadata={(payload as PromptItem).metadata} + onPromptMetadataChange={handleCompletionMetadataChange} readOnly={readOnly} isChatModel={isChatModel} isChatApp={isChatApp} diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx index 0752531c1d..68dd6dedf5 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx @@ -6,6 +6,7 @@ import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import AppIcon from '@/app/components/base/app-icon' +import Modal from '@/app/components/base/modal' import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks' 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' @@ -27,6 +28,7 @@ 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' import ToolHeader from './tool-header' type ToolBlockComponentProps = { @@ -102,6 +104,9 @@ const ToolBlockComponent: FC = ({ const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_TOOL_BLOCK_COMMAND) 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 [toolValue, setToolValue] = useState(null) const [portalContainer, setPortalContainer] = useState(null) @@ -154,11 +159,15 @@ const ToolBlockComponent: FC = ({ }, [currentTool?.description, language, toolValue?.tool_description]) const toolConfigFromMetadata = useMemo(() => { + if (isUsingExternalMetadata) { + const metadata = toolBlockContext?.metadata as SkillFileMetadata | undefined + return metadata?.tools?.[configId] + } if (!activeTabId) return undefined const metadata = fileMetadata.get(activeTabId) as SkillFileMetadata | undefined return metadata?.tools?.[configId] - }, [activeTabId, configId, fileMetadata]) + }, [activeTabId, configId, fileMetadata, isUsingExternalMetadata, toolBlockContext?.metadata]) const defaultToolValue = useMemo(() => { if (!currentProvider || !currentTool) @@ -244,16 +253,18 @@ const ToolBlockComponent: FC = ({ }, [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]) + }, [ref, useModal]) useEffect(() => { - if (!isSettingOpen) + if (!isSettingOpen || useModal) return const handleClickOutside = (event: MouseEvent) => { @@ -273,7 +284,7 @@ const ToolBlockComponent: FC = ({ document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isSettingOpen, portalContainer, ref]) + }, [isSettingOpen, portalContainer, ref, useModal]) const displayLabel = label || toolMeta?.label || tool const resolvedIcon = (() => { @@ -316,7 +327,39 @@ const ToolBlockComponent: FC = ({ const handleToolValueChange = (nextValue: ToolValue) => { setToolValue(nextValue) - if (!activeTabId || !currentProvider || !currentTool) + if (!currentProvider || !currentTool) + return + 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 || {}), + [configId]: { + type: toolType, + configuration: { fields }, + }, + }, + } + toolBlockContext?.onMetadataChange?.(nextMetadata) + return + } + if (!activeTabId) return const metadata = (fileMetadata.get(activeTabId) || {}) as SkillFileMetadata const toolType = currentProvider.type === CollectionType.mcp ? 'mcp' : 'builtin' @@ -352,6 +395,30 @@ const ToolBlockComponent: FC = ({ setToolValue(prev => (prev ? { ...prev, credential_id: id } : prev)) } + const toolSettingsContent = currentProvider && currentTool && toolValue && ( + <> + setIsSettingOpen(false)} + /> + + + + ) + return ( <> = ({ {displayLabel} - {portalContainer && isSettingOpen && createPortal( + {useModal && ( + setIsSettingOpen(false)} + className="!max-w-[420px] !bg-transparent !p-0" + overflowVisible + > +
+ {toolSettingsContent} +
+
+ )} + {!useModal && portalContainer && isSettingOpen && createPortal(
- {currentProvider && currentTool && toolValue && ( - <> - setIsSettingOpen(false)} - /> - - - - )} + {toolSettingsContent}
, portalContainer, diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context.tsx new file mode 100644 index 0000000000..487eab7f19 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-context.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react' + +type ToolBlockContextValue = { + metadata?: Record + onMetadataChange?: (metadata: Record) => void + useModal?: boolean +} + +const ToolBlockContext = createContext(null) + +export const ToolBlockContextProvider = ToolBlockContext.Provider + +export const useToolBlockContext = () => useContext(ToolBlockContext) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx index 87ab7bbf3a..8092fe9f0e 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx @@ -18,6 +18,7 @@ import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-for import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import { useWorkflowStore } from '@/app/components/workflow/store' import { $createToolBlockNode } from './node' +import { useToolBlockContext } from './tool-block-context' class ToolPickerMenuOption extends MenuOption { constructor() { @@ -36,6 +37,8 @@ const ToolPickerBlock: FC = ({ scope = 'all' }) => { maxLength: 0, }) const storeApi = useWorkflowStore() + const toolBlockContext = useToolBlockContext() + const isUsingExternalMetadata = Boolean(toolBlockContext?.onMetadataChange) const options = useMemo(() => [new ToolPickerMenuOption()], []) @@ -70,6 +73,27 @@ const ToolPickerBlock: FC = ({ scope = 'all' }) => { $insertNodes(nodes) }) + if (isUsingExternalMetadata) { + const metadata = (toolBlockContext?.metadata || {}) as Record + const nextTools = { ...(metadata.tools || {}) } as Record + toolEntries.forEach(({ configId, tool }) => { + const schemas = toolParametersToFormSchemas((tool.paramSchemas || []) as ToolParameter[]) + const fields = schemas.map(schema => ({ + id: schema.variable, + value: schema.default ?? null, + auto: schema.form === 'llm', + })) + nextTools[configId] = { + type: tool.provider_type, + configuration: { fields }, + } + }) + toolBlockContext?.onMetadataChange?.({ + ...metadata, + tools: nextTools, + }) + return + } const { activeTabId, fileMetadata, setDraftMetadata, pinTab } = storeApi.getState() if (!activeTabId) return @@ -92,7 +116,7 @@ const ToolPickerBlock: FC = ({ scope = 'all' }) => { tools: nextTools, }) pinTab(activeTabId) - }, [checkForTriggerMatch, editor, storeApi]) + }, [checkForTriggerMatch, editor, isUsingExternalMetadata, storeApi, toolBlockContext]) const renderMenu = useCallback(( anchorElementRef: React.RefObject,