feat: support choose tools

This commit is contained in:
Joel
2026-01-21 15:02:53 +08:00
parent e85b0c49d8
commit 911c1852d5
7 changed files with 315 additions and 157 deletions

View File

@ -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<string, unknown>
onToolMetadataChange?: (metadata: Record<string, unknown>) => void
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
historyBlock?: HistoryBlockType
@ -124,6 +133,8 @@ const PromptEditor: FC<PromptEditorProps> = ({
onChange,
onBlur,
onFocus,
toolMetadata,
onToolMetadataChange,
contextBlock,
queryBlock,
historyBlock,
@ -156,7 +167,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
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<PromptEditorProps> = ({
} as any)
}, [eventEmitter, historyBlock?.history])
const toolBlockContextValue = React.useMemo(() => {
if (!onToolMetadataChange)
return null
return {
metadata: toolMetadata,
onMetadataChange: onToolMetadataChange,
useModal: true,
}
}, [onToolMetadataChange, toolMetadata])
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', wrapperClassName)}>
<RichTextPlugin
contentEditable={(
<ContentEditable
className={cn(
'text-text-secondary outline-none',
compact ? 'text-[13px] leading-5' : 'text-sm leading-6',
className,
)}
style={style || {}}
/>
)}
placeholder={(
<Placeholder
value={placeholder}
className={cn('truncate', placeholderClassName)}
compact={compact}
/>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<ComponentPickerBlock
triggerString="/"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{(!agentBlock || agentBlock.show) && (
<ToolBlockContextProvider value={toolBlockContextValue}>
<div
className={cn('relative', wrapperClassName)}
data-skill-editor-root={isSupportSandbox ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={(
<ContentEditable
className={cn(
'text-text-secondary outline-none',
compact ? 'text-[13px] leading-5' : 'text-sm leading-6',
className,
)}
style={style || {}}
/>
)}
placeholder={(
<Placeholder
value={placeholder}
className={cn('truncate', placeholderClassName)}
compact={compact}
/>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<ComponentPickerBlock
triggerString="@"
triggerString="/"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
@ -234,100 +244,123 @@ const PromptEditor: FC<PromptEditorProps> = ({
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
agentBlock={agentBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
)}
<ComponentPickerBlock
triggerString="{"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{
contextBlock?.show && (
{!isSupportSandbox && (!agentBlock || agentBlock.show) && (
<ComponentPickerBlock
triggerString="@"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
agentBlock={agentBlock}
isSupportFileVar={isSupportFileVar}
/>
)}
{isSupportSandbox && (
<>
<ContextBlock {...contextBlock} />
<ContextBlockReplacementBlock {...contextBlock} />
<ToolBlock />
<ToolBlockReplacementBlock />
{editable && <ToolPickerBlock />}
</>
)
}
{
queryBlock?.show && (
<>
<QueryBlock {...queryBlock} />
<QueryBlockReplacementBlock />
</>
)
}
{
historyBlock?.show && (
<>
<HistoryBlock {...historyBlock} />
<HistoryBlockReplacementBlock {...historyBlock} />
</>
)
}
{
(variableBlock?.show || externalToolBlock?.show) && (
<>
<VariableBlock />
)}
<ComponentPickerBlock
triggerString="{"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
/>
{
contextBlock?.show && (
<>
<ContextBlock {...contextBlock} />
<ContextBlockReplacementBlock {...contextBlock} />
</>
)
}
{
queryBlock?.show && (
<>
<QueryBlock {...queryBlock} />
<QueryBlockReplacementBlock />
</>
)
}
{
historyBlock?.show && (
<>
<HistoryBlock {...historyBlock} />
<HistoryBlockReplacementBlock {...historyBlock} />
</>
)
}
{
(variableBlock?.show || externalToolBlock?.show) && (
<>
<VariableBlock />
<VariableValueBlock />
</>
)
}
{
workflowVariableBlock?.show && (
<>
<WorkflowVariableBlock {...workflowVariableBlock} />
<WorkflowVariableBlockReplacementBlock {...workflowVariableBlock} />
</>
)
}
{isSupportSandbox && <FileReferenceReplacementBlock />}
{
currentBlock?.show && (
<>
<CurrentBlock {...currentBlock} />
<CurrentBlockReplacementBlock {...currentBlock} />
</>
)
}
{
errorMessageBlock?.show && (
<>
<ErrorMessageBlock {...errorMessageBlock} />
<ErrorMessageBlockReplacementBlock {...errorMessageBlock} />
</>
)
}
{
lastRunBlock?.show && (
<>
<LastRunBlock {...lastRunBlock} />
<LastRunReplacementBlock {...lastRunBlock} />
</>
)
}
{
isSupportFileVar && (
<VariableValueBlock />
</>
)
}
{
workflowVariableBlock?.show && (
<>
<WorkflowVariableBlock {...workflowVariableBlock} />
<WorkflowVariableBlockReplacementBlock {...workflowVariableBlock} />
</>
)
}
{isSupportSandbox && <FileReferenceReplacementBlock />}
{
currentBlock?.show && (
<>
<CurrentBlock {...currentBlock} />
<CurrentBlockReplacementBlock {...currentBlock} />
</>
)
}
{
errorMessageBlock?.show && (
<>
<ErrorMessageBlock {...errorMessageBlock} />
<ErrorMessageBlockReplacementBlock {...errorMessageBlock} />
</>
)
}
{
lastRunBlock?.show && (
<>
<LastRunBlock {...lastRunBlock} />
<LastRunReplacementBlock {...lastRunBlock} />
</>
)
}
{
isSupportFileVar && (
<VariableValueBlock />
)
}
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{/* <TreeView /> */}
</div>
)
}
<OnChangePlugin onChange={handleEditorChange} />
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{/* <TreeView /> */}
</div>
</ToolBlockContextProvider>
</LexicalComposer>
)
}

View File

@ -63,6 +63,8 @@ type Props = {
availableNodes?: Node[]
isSupportFileVar?: boolean
isSupportSandbox?: boolean
promptMetadata?: Record<string, unknown>
onPromptMetadataChange?: (metadata: Record<string, unknown>) => void
isSupportPromptGenerator?: boolean
onGenerated?: (prompt: string) => void
modelConfig?: ModelConfig
@ -104,6 +106,8 @@ const Editor: FC<Props> = ({
availableNodes = [],
isSupportFileVar,
isSupportSandbox,
promptMetadata,
onPromptMetadataChange,
isSupportPromptGenerator,
isSupportJinja,
editionType,
@ -298,6 +302,8 @@ const Editor: FC<Props> = ({
editable={!readOnly}
isSupportFileVar={isSupportFileVar}
isSupportSandbox={isSupportSandbox}
toolMetadata={promptMetadata}
onToolMetadataChange={onPromptMetadataChange}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className="absolute inset-0 z-10"></div>}

View File

@ -27,6 +27,7 @@ type Props = {
payload: PromptItem
handleChatModeMessageRoleChange: (role: PromptRole) => void
onPromptChange: (p: string) => void
onMetadataChange: (metadata: Record<string, unknown>) => void
onEditionTypeChange: (editionType: EditionType) => void
onRemove: () => void
isShowContext: boolean
@ -74,6 +75,7 @@ const ConfigPromptItem: FC<Props> = ({
isChatApp,
payload,
onPromptChange,
onMetadataChange,
onEditionTypeChange,
onRemove,
isShowContext,
@ -131,6 +133,8 @@ const ConfigPromptItem: FC<Props> = ({
)}
value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text}
onChange={onPromptChange}
promptMetadata={payload.metadata}
onPromptMetadataChange={onMetadataChange}
readOnly={readOnly}
showRemove={canRemove}
onRemove={onRemove}

View File

@ -146,6 +146,17 @@ const ConfigPrompt: FC<Props> = ({
}
}, [onChange, payload])
const handleChatModeMetadataChange = useCallback((index: number) => {
return (metadata: Record<string, unknown>) => {
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<Props> = ({
onChange(newPrompt)
}, [onChange, payload])
const handleCompletionMetadataChange = useCallback((metadata: Record<string, unknown>) => {
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<Props> = ({
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<Props> = ({
title={<span className="capitalize">{t(`${i18nPrefix}.prompt`, { ns: 'workflow' })}</span>}
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}

View File

@ -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<ToolBlockComponentProps> = ({
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<ToolValue | null>(null)
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null)
@ -154,11 +159,15 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
}, [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<ToolBlockComponentProps> = ({
}, [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<ToolBlockComponentProps> = ({
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<ToolBlockComponentProps> = ({
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<string, unknown> | 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<ToolBlockComponentProps> = ({
setToolValue(prev => (prev ? { ...prev, credential_id: id } : prev))
}
const toolSettingsContent = currentProvider && currentTool && toolValue && (
<>
<ToolHeader
icon={resolvedIcon}
providerLabel={currentProvider.label?.[language] || currentProvider.name || provider}
toolLabel={toolValue.tool_label || displayLabel}
description={toolDescriptionText}
onClose={() => setIsSettingOpen(false)}
/>
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={toolValue.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
<ToolSettingsSection
currentProvider={currentProvider}
currentTool={currentTool}
value={toolValue}
onChange={handleToolValueChange}
nodeId={undefined}
/>
</>
)
return (
<>
<span
@ -375,35 +442,25 @@ const ToolBlockComponent: FC<ToolBlockComponentProps> = ({
{displayLabel}
</span>
</span>
{portalContainer && isSettingOpen && createPortal(
{useModal && (
<Modal
isShow={isSettingOpen}
onClose={() => setIsSettingOpen(false)}
className="!max-w-[420px] !bg-transparent !p-0"
overflowVisible
>
<div className={cn('relative min-h-20 w-[361px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
{toolSettingsContent}
</div>
</Modal>
)}
{!useModal && portalContainer && isSettingOpen && createPortal(
<div
className="absolute bottom-4 right-4 top-4 z-[999]"
data-tool-setting-panel="true"
>
<div className={cn('relative h-full min-h-20 w-[361px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
{currentProvider && currentTool && toolValue && (
<>
<ToolHeader
icon={resolvedIcon}
providerLabel={currentProvider.label?.[language] || currentProvider.name || provider}
toolLabel={toolValue.tool_label || displayLabel}
description={toolDescriptionText}
onClose={() => setIsSettingOpen(false)}
/>
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={toolValue.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
<ToolSettingsSection
currentProvider={currentProvider}
currentTool={currentTool}
value={toolValue}
onChange={handleToolValueChange}
nodeId={undefined}
/>
</>
)}
{toolSettingsContent}
</div>
</div>,
portalContainer,

View File

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react'
type ToolBlockContextValue = {
metadata?: Record<string, unknown>
onMetadataChange?: (metadata: Record<string, unknown>) => void
useModal?: boolean
}
const ToolBlockContext = createContext<ToolBlockContextValue | null>(null)
export const ToolBlockContextProvider = ToolBlockContext.Provider
export const useToolBlockContext = () => useContext(ToolBlockContext)

View File

@ -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<ToolPickerBlockProps> = ({ 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<ToolPickerBlockProps> = ({ scope = 'all' }) => {
$insertNodes(nodes)
})
if (isUsingExternalMetadata) {
const metadata = (toolBlockContext?.metadata || {}) as Record<string, unknown>
const nextTools = { ...(metadata.tools || {}) } as Record<string, unknown>
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<ToolPickerBlockProps> = ({ scope = 'all' }) => {
tools: nextTools,
})
pinTab(activeTabId)
}, [checkForTriggerMatch, editor, storeApi])
}, [checkForTriggerMatch, editor, isUsingExternalMetadata, storeApi, toolBlockContext])
const renderMenu = useCallback((
anchorElementRef: React.RefObject<HTMLElement | null>,