diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index cdddae14ed..e104bed7d7 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -35,6 +35,8 @@ import { ToolBlock, ToolBlockNode, ToolBlockReplacementBlock, + ToolGroupBlockNode, + ToolGroupBlockReplacementBlock, } 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' @@ -167,7 +169,7 @@ const PromptEditor: FC = ({ CurrentBlockNode, ErrorMessageBlockNode, LastRunBlockNode, // LastRunBlockNode is used for error message block replacement - ...(isSupportSandbox ? [FileReferenceNode, ToolBlockNode] : []), + ...(isSupportSandbox ? [FileReferenceNode, ToolGroupBlockNode, ToolBlockNode] : []), ], editorState: textToEditorState(value || ''), onError: (error: Error) => { @@ -266,6 +268,7 @@ const PromptEditor: FC = ({ {isSupportSandbox && ( <> + {editable && } diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index c50cc20369..57de577619 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -54,6 +54,7 @@ type AllToolsProps = { showFeatured?: boolean onFeaturedInstallSuccess?: () => Promise | void hideFeaturedTool?: boolean + hideSelectedInfo?: boolean } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -78,6 +79,7 @@ const AllTools = ({ showFeatured = false, onFeaturedInstallSuccess, hideFeaturedTool = false, + hideSelectedInfo = false, }: AllToolsProps) => { const { t } = useTranslation() const language = useGetLanguage() @@ -277,7 +279,7 @@ const AllTools = ({ viewType={isSupportGroupView ? activeView : ViewType.flat} hasSearchText={hasSearchText} selectedTools={selectedTools} - hideSelectedInfo={hideFeaturedTool} + hideSelectedInfo={hideSelectedInfo} /> )} diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index 58d018a134..1d702e9931 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -53,6 +53,7 @@ type Props = { selectedTools?: ToolValue[] preventFocusLoss?: boolean hideFeaturedTool?: boolean + hideSelectedInfo?: boolean } const ToolPicker: FC = ({ @@ -71,6 +72,7 @@ const ToolPicker: FC = ({ panelClassName, preventFocusLoss = false, hideFeaturedTool = false, + hideSelectedInfo = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -220,6 +222,7 @@ const ToolPicker: FC = ({ featuredLoading={isFeaturedLoading} showFeatured={scope === 'all' && enable_marketplace} hideFeaturedTool={hideFeaturedTool} + hideSelectedInfo={hideSelectedInfo} onFeaturedInstallSuccess={async () => { invalidateBuiltInTools() invalidateCustomTools() diff --git a/web/app/components/workflow/skill/editor/skill-editor/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/index.tsx index 21232d94b1..d4107cc38e 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/index.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/index.tsx @@ -28,6 +28,8 @@ import { ToolBlock, ToolBlockNode, ToolBlockReplacementBlock, + ToolGroupBlockNode, + ToolGroupBlockReplacementBlock, } from './plugins/tool-block' import ToolPickerBlock from './plugins/tool-block/tool-picker-block' @@ -73,6 +75,7 @@ const SkillEditor: FC = ({ replace: TextNode, with: (node: TextNode) => new CustomTextNode(node.__text), }, + ToolGroupBlockNode, ToolBlockNode, FileReferenceNode, ], @@ -123,6 +126,7 @@ const SkillEditor: FC = ({ /> <> + {editable && } diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/index.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/index.tsx index 414afaab88..e3bebc9735 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/index.tsx +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/index.tsx @@ -44,3 +44,5 @@ ToolBlock.displayName = 'ToolBlock' export { ToolBlock } export { ToolBlockNode } from './node' export { default as ToolBlockReplacementBlock } from './tool-block-replacement-block' +export { ToolGroupBlockNode } from './tool-group-block-node' +export { default as ToolGroupBlockReplacementBlock } from './tool-group-block-replacement-block' 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 new file mode 100644 index 0000000000..3bffb95cdd --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-component.tsx @@ -0,0 +1,119 @@ +import type { FC } from 'react' +import type { ToolToken } from './utils' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import * as React from 'react' +import { useMemo } from 'react' +import AppIcon from '@/app/components/base/app-icon' +import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks' +import { useGetLanguage } from '@/context/i18n' +import useTheme from '@/hooks/use-theme' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' +import { Theme } from '@/types/app' +import { canFindTool } from '@/utils' +import { cn } from '@/utils/classnames' +import { basePath } from '@/utils/var' +import { DELETE_TOOL_BLOCK_COMMAND } from './index' + +type ToolGroupBlockComponentProps = { + nodeKey: string + tools: ToolToken[] +} + +const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => { + if (!icon) + return icon + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} + +const ToolGroupBlockComponent: FC = ({ + nodeKey, + tools, +}) => { + const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_TOOL_BLOCK_COMMAND) + const language = useGetLanguage() + const { theme } = useTheme() + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + + const mergedTools = useMemo(() => { + return [buildInTools, customTools, workflowTools, mcpTools].filter(Boolean) as ToolWithProvider[][] + }, [buildInTools, customTools, workflowTools, mcpTools]) + + const providerId = tools[0]?.provider || '' + const currentProvider = useMemo(() => { + if (!providerId) + return undefined + for (const collection of mergedTools) { + const providerItem = collection.find(item => item.name === providerId || item.id === providerId || canFindTool(item.id, providerId)) + if (providerItem) + return providerItem + } + return undefined + }, [mergedTools, providerId]) + + const providerLabel = currentProvider?.label?.[language] || currentProvider?.name || providerId + const resolvedIcon = (() => { + const fromMeta = theme === Theme.dark ? currentProvider?.icon_dark : currentProvider?.icon + return normalizeProviderIcon(fromMeta) + })() + + const renderIcon = () => { + if (!resolvedIcon) + return null + if (typeof resolvedIcon === 'string') { + if (resolvedIcon.startsWith('http') || resolvedIcon.startsWith('/')) { + return ( + + ) + } + return ( + + ) + } + return ( + + ) + } + + return ( + + {renderIcon()} + + {providerLabel} + + + {tools.length} + + + ) +} + +export default React.memo(ToolGroupBlockComponent) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-node.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-node.tsx new file mode 100644 index 0000000000..69e2824f27 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-node.tsx @@ -0,0 +1,82 @@ +import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical' +import type { ToolToken } from './utils' +import { DecoratorNode } from 'lexical' +import ToolGroupBlockComponent from './tool-group-block-component' +import { buildToolTokenList } from './utils' + +export type ToolGroupBlockPayload = { + tools: ToolToken[] +} + +export type SerializedToolGroupBlockNode = SerializedLexicalNode & ToolGroupBlockPayload + +export class ToolGroupBlockNode extends DecoratorNode { + __tools: ToolToken[] + + static getType(): string { + return 'tool-group-block' + } + + static clone(node: ToolGroupBlockNode): ToolGroupBlockNode { + return new ToolGroupBlockNode( + { + tools: node.__tools, + }, + node.__key, + ) + } + + isInline(): boolean { + return true + } + + constructor(payload: ToolGroupBlockPayload, key?: NodeKey) { + super(key) + this.__tools = payload.tools + } + + createDOM(): HTMLElement { + const span = document.createElement('span') + span.classList.add('inline-flex', 'items-center', 'align-middle') + return span + } + + updateDOM(): false { + return false + } + + decorate(): React.JSX.Element { + return ( + + ) + } + + exportJSON(): SerializedToolGroupBlockNode { + return { + type: 'tool-group-block', + version: 1, + tools: this.__tools, + } + } + + static importJSON(serializedNode: SerializedToolGroupBlockNode): ToolGroupBlockNode { + return $createToolGroupBlockNode(serializedNode) + } + + getTextContent(): string { + return buildToolTokenList(this.__tools) + } +} + +export function $createToolGroupBlockNode(payload: ToolGroupBlockPayload): ToolGroupBlockNode { + return new ToolGroupBlockNode(payload) +} + +export function $isToolGroupBlockNode( + node: ToolGroupBlockNode | LexicalNode | null | undefined, +): node is ToolGroupBlockNode { + return node instanceof ToolGroupBlockNode +} diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-replacement-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-replacement-block.tsx new file mode 100644 index 0000000000..a88300eb75 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-group-block-replacement-block.tsx @@ -0,0 +1,98 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' +import { $createTextNode, $isTextNode } from 'lexical' +import { useEffect, useMemo } from 'react' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import { $createToolGroupBlockNode, ToolGroupBlockNode } from './tool-group-block-node' +import { getToolTokenListRegexString, parseToolTokenList } from './utils' + +const decoratorTransformAllowAdjacent = ( + node: CustomTextNode, + getMatch: (text: string) => null | { start: number, end: number }, + createNode: (textNode: CustomTextNode) => ReturnType | ToolGroupBlockNode, +) => { + if (!node.isSimpleText()) + return + + const prevSibling = node.getPreviousSibling() + let text = node.getTextContent() + let currentNode = node + let match + + while (true) { + match = getMatch(text) + let nextText = match === null ? '' : text.slice(match.end) + text = nextText + + if (nextText === '') { + const nextSibling = currentNode.getNextSibling() + + if ($isTextNode(nextSibling)) { + nextText = currentNode.getTextContent() + nextSibling.getTextContent() + const nextMatch = getMatch(nextText) + + if (nextMatch === null) { + nextSibling.markDirty() + return + } + else if (nextMatch.start !== 0) { + return + } + } + } + + if (match === null) + return + + if (match.start === 0 && $isTextNode(prevSibling) && prevSibling.isTextEntity()) + continue + + let nodeToReplace + + if (match.start === 0) + [nodeToReplace, currentNode] = currentNode.splitText(match.end) + else + [, nodeToReplace, currentNode] = currentNode.splitText(match.start, match.end) + + const replacementNode = createNode(nodeToReplace as CustomTextNode) + nodeToReplace.replace(replacementNode) + + if (currentNode == null) + return + } +} + +const ToolGroupBlockReplacementBlock = () => { + const [editor] = useLexicalComposerContext() + const regex = useMemo(() => new RegExp(getToolTokenListRegexString(), 'i'), []) + + useEffect(() => { + if (!editor.hasNodes([ToolGroupBlockNode])) + throw new Error('ToolGroupBlockReplacementBlock: ToolGroupBlockNode not registered on editor') + + const getMatch = (text: string) => { + const matchArr = regex.exec(text) + if (!matchArr) + return null + return { + start: matchArr.index, + end: matchArr.index + matchArr[0].length, + } + } + + const createToolGroupBlockNode = (textNode: CustomTextNode) => { + const parsed = parseToolTokenList(textNode.getTextContent()) + if (!parsed) + return $createTextNode(textNode.getTextContent()) + return $createToolGroupBlockNode({ tools: parsed }) + } + + return mergeRegister( + editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransformAllowAdjacent(textNode, getMatch, createToolGroupBlockNode)), + ) + }, [editor, regex]) + + return null +} + +export default ToolGroupBlockReplacementBlock 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 2beb92ed9e..a3d31ffa5a 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 @@ -20,6 +20,7 @@ import { START_TAB_ID } from '@/app/components/workflow/skill/constants' import { useWorkflowStore } from '@/app/components/workflow/store' import { $createToolBlockNode } from './node' import { useToolBlockContext } from './tool-block-context' +import { $createToolGroupBlockNode } from './tool-group-block-node' class ToolPickerMenuOption extends MenuOption { constructor() { @@ -55,20 +56,31 @@ const ToolPickerBlock: FC = ({ scope = 'all' }) => { nodeToRemove.remove() const nodes: LexicalNode[] = [] - toolEntries.forEach(({ tool, configId }, index) => { - nodes.push( - $createToolBlockNode({ + if (toolEntries.length > 1) { + nodes.push($createToolGroupBlockNode({ + tools: toolEntries.map(({ tool, configId }) => ({ provider: tool.provider_id, tool: tool.tool_name, configId, - label: tool.tool_label, - icon: tool.provider_icon, - iconDark: tool.provider_icon_dark, - }), - ) - if (index !== tools.length - 1) - nodes.push($createTextNode(' ')) - }) + })), + })) + } + else { + toolEntries.forEach(({ tool, configId }, index) => { + nodes.push( + $createToolBlockNode({ + provider: tool.provider_id, + tool: tool.tool_name, + configId, + label: tool.tool_label, + icon: tool.provider_icon, + iconDark: tool.provider_icon_dark, + }), + ) + if (index !== tools.length - 1) + nodes.push($createTextNode(' ')) + }) + } if (nodes.length) $insertNodes(nodes) diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/utils.ts b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/utils.ts index 78af716add..b05e9d62aa 100644 --- a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/utils.ts +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/utils.ts @@ -1,8 +1,19 @@ +export type ToolToken = { + provider: string + tool: string + configId: string +} + export const getToolTokenRegexString = (): string => { return '§\\[tool\\]\\.\\[[a-zA-Z0-9_-]+(?:\\/[a-zA-Z0-9_-]+)*\\]\\.\\[[a-zA-Z0-9_-]+\\]\\.\\[[a-fA-F0-9-]{36}\\]§' } -export const parseToolToken = (text: string) => { +export const getToolTokenListRegexString = (): string => { + const token = getToolTokenRegexString() + return `\\[(?:${token})(?:\\s*,\\s*${token})*\\]` +} + +export const parseToolToken = (text: string): ToolToken | null => { const match = /^§\[tool\]\.\[([\w-]+(?:\/[\w-]+)*)\]\.\[([\w-]+)\]\.\[([a-fA-F0-9-]{36})\]§$/.exec(text) if (!match) return null @@ -13,6 +24,26 @@ export const parseToolToken = (text: string) => { } } -export const buildToolToken = (payload: { provider: string, tool: string, configId: string }) => { +export const parseToolTokenList = (text: string): ToolToken[] | null => { + const trimmed = text.trim() + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) + return null + const content = trimmed.slice(1, -1).trim() + if (!content) + return null + const tokens = content.split(',').map(token => token.trim()).filter(Boolean) + if (!tokens.length) + return null + const parsed = tokens.map(token => parseToolToken(token)) + if (parsed.some(item => !item)) + return null + return parsed as ToolToken[] +} + +export const buildToolToken = (payload: ToolToken) => { return `§[tool].[${payload.provider}].[${payload.tool}].[${payload.configId}]§` } + +export const buildToolTokenList = (tokens: ToolToken[]) => { + return `[${tokens.map(buildToolToken).join(',')}]` +}