diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index 4d55cea499..87092f8886 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -41,6 +41,7 @@ type Props = { panelClassName?: string disabled: boolean trigger: React.ReactNode + triggerAsChild?: boolean placement?: Placement offset?: OffsetOptions isShow: boolean @@ -55,6 +56,7 @@ type Props = { const ToolPicker: FC = ({ disabled, trigger, + triggerAsChild = false, placement = 'right-start', offset = 0, isShow, @@ -165,6 +167,7 @@ const ToolPicker: FC = ({ > {trigger} diff --git a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx index 4e65136140..9ab6369c16 100644 --- a/web/app/components/workflow/skill/editor/markdown-file-editor.tsx +++ b/web/app/components/workflow/skill/editor/markdown-file-editor.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import PromptEditor from '@/app/components/base/prompt-editor' +import SkillEditor from './skill-editor' type MarkdownFileEditorProps = { value: string @@ -13,7 +13,7 @@ const MarkdownFileEditor: FC = ({ value, onChange }) => return (
- void + onBlur?: () => void + onFocus?: () => void + toolPickerScope?: string +} + +const SkillEditor: FC = ({ + instanceId, + compact, + wrapperClassName, + className, + placeholder, + placeholderClassName, + showLineNumbers, + style, + value, + editable = true, + onChange, + onBlur, + onFocus, + toolPickerScope = 'all', +}) => { + const initialConfig = { + namespace: 'skill-editor', + nodes: [ + CodeNode, + CustomTextNode, + { + replace: TextNode, + with: (node: TextNode) => new CustomTextNode(node.__text), + }, + ToolBlockNode, + ], + editorState: textToEditorState(value || ''), + onError: (error: Error) => { + throw error + }, + } + + const handleEditorChange = (editorState: EditorState) => { + const text = editorState.read(() => { + return $getRoot().getChildren().map(p => p.getTextContent()).join('\n') + }) + if (onChange) + onChange(text) + } + + return ( + +
+ + )} + placeholder={( + + )} + ErrorBoundary={LexicalErrorBoundary} + /> + <> + + + {editable && } + + + + + +
+
+ ) +} + +export default SkillEditor 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 new file mode 100644 index 0000000000..eaba2de055 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/component.tsx @@ -0,0 +1,129 @@ +import type { FC } from 'react' +import type { Emoji } from '@/app/components/tools/types' +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 ToolBlockComponentProps = { + nodeKey: string + provider: string + tool: string + label?: string + icon?: string | Emoji + iconDark?: string | Emoji +} + +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 ToolBlockComponent: FC = ({ + nodeKey, + provider, + tool, + label, + icon, + iconDark, +}) => { + 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 toolMeta = useMemo(() => { + const collections = [buildInTools, customTools, workflowTools, mcpTools].filter(Boolean) as ToolWithProvider[][] + for (const collection of collections) { + const providerItem = collection.find(item => item.name === provider || item.id === provider || canFindTool(item.id, provider)) + if (!providerItem) + continue + const toolItem = providerItem.tools?.find(item => item.name === tool) + if (!toolItem) + continue + return { + label: toolItem.label?.[language] || tool, + icon: providerItem.icon, + iconDark: providerItem.icon_dark, + } + } + return null + }, [buildInTools, customTools, workflowTools, mcpTools, language, provider, tool]) + + const displayLabel = label || toolMeta?.label || tool + const resolvedIcon = (() => { + const fromNode = theme === Theme.dark ? iconDark : icon + if (fromNode) + return normalizeProviderIcon(fromNode) + const fromMeta = theme === Theme.dark ? toolMeta?.iconDark : toolMeta?.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()} + + {displayLabel} + + + ) +} + +export default React.memo(ToolBlockComponent) 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 new file mode 100644 index 0000000000..414afaab88 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/index.tsx @@ -0,0 +1,46 @@ +import type { ToolBlockPayload } from './node' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' +import { + $insertNodes, + COMMAND_PRIORITY_EDITOR, + createCommand, +} from 'lexical' +import { memo, useEffect } from 'react' +import { $createToolBlockNode, ToolBlockNode } from './node' + +export const INSERT_TOOL_BLOCK_COMMAND = createCommand('INSERT_TOOL_BLOCK_COMMAND') +export const DELETE_TOOL_BLOCK_COMMAND = createCommand('DELETE_TOOL_BLOCK_COMMAND') + +const ToolBlock = memo(() => { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([ToolBlockNode])) + throw new Error('ToolBlockPlugin: ToolBlockNode not registered on editor') + + return mergeRegister( + editor.registerCommand( + INSERT_TOOL_BLOCK_COMMAND, + (payload: ToolBlockPayload) => { + const toolBlockNode = $createToolBlockNode(payload) + $insertNodes([toolBlockNode]) + return true + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_TOOL_BLOCK_COMMAND, + () => true, + COMMAND_PRIORITY_EDITOR, + ), + ) + }, [editor]) + + return null +}) +ToolBlock.displayName = 'ToolBlock' + +export { ToolBlock } +export { ToolBlockNode } from './node' +export { default as ToolBlockReplacementBlock } from './tool-block-replacement-block' diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/node.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/node.tsx new file mode 100644 index 0000000000..21c07fea51 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/node.tsx @@ -0,0 +1,115 @@ +import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical' +import type { Emoji } from '@/app/components/tools/types' +import { DecoratorNode } from 'lexical' +import ToolBlockComponent from './component' +import { buildToolToken } from './utils' + +export type ToolBlockPayload = { + provider: string + tool: string + configId: string + label?: string + icon?: string | Emoji + iconDark?: string | Emoji +} + +export type SerializedToolBlockNode = SerializedLexicalNode & ToolBlockPayload + +export class ToolBlockNode extends DecoratorNode { + __provider: string + __tool: string + __configId: string + __label?: string + __icon?: string | Emoji + __iconDark?: string | Emoji + + static getType(): string { + return 'tool-block' + } + + static clone(node: ToolBlockNode): ToolBlockNode { + return new ToolBlockNode( + { + provider: node.__provider, + tool: node.__tool, + configId: node.__configId, + label: node.__label, + icon: node.__icon, + iconDark: node.__iconDark, + }, + node.__key, + ) + } + + isInline(): boolean { + return true + } + + constructor(payload: ToolBlockPayload, key?: NodeKey) { + super(key) + this.__provider = payload.provider + this.__tool = payload.tool + this.__configId = payload.configId + this.__label = payload.label + this.__icon = payload.icon + this.__iconDark = payload.iconDark + } + + 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(): SerializedToolBlockNode { + return { + type: 'tool-block', + version: 1, + provider: this.__provider, + tool: this.__tool, + configId: this.__configId, + label: this.__label, + icon: this.__icon, + iconDark: this.__iconDark, + } + } + + static importJSON(serializedNode: SerializedToolBlockNode): ToolBlockNode { + return $createToolBlockNode(serializedNode) + } + + getTextContent(): string { + return buildToolToken({ + provider: this.__provider, + tool: this.__tool, + configId: this.__configId, + }) + } +} + +export function $createToolBlockNode(payload: ToolBlockPayload): ToolBlockNode { + return new ToolBlockNode(payload) +} + +export function $isToolBlockNode( + node: ToolBlockNode | LexicalNode | null | undefined, +): node is ToolBlockNode { + return node instanceof ToolBlockNode +} diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-replacement-block.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-replacement-block.tsx new file mode 100644 index 0000000000..4309e90145 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-block-replacement-block.tsx @@ -0,0 +1,43 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' +import { $createTextNode } from 'lexical' +import { useEffect, useMemo } from 'react' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import { decoratorTransform } from '@/app/components/base/prompt-editor/utils' +import { $createToolBlockNode, ToolBlockNode } from './node' +import { getToolTokenRegexString, parseToolToken } from './utils' + +const ToolBlockReplacementBlock = () => { + const [editor] = useLexicalComposerContext() + const regex = useMemo(() => new RegExp(getToolTokenRegexString(), 'i'), []) + + useEffect(() => { + if (!editor.hasNodes([ToolBlockNode])) + throw new Error('ToolBlockReplacementBlock: ToolBlockNode 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 createToolBlockNode = (textNode: CustomTextNode) => { + const parsed = parseToolToken(textNode.getTextContent()) + if (!parsed) + return $createTextNode(textNode.getTextContent()) + return $createToolBlockNode(parsed) + } + + return mergeRegister( + editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createToolBlockNode)), + ) + }, [editor, regex]) + + return null +} + +export default ToolBlockReplacementBlock 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 new file mode 100644 index 0000000000..a37c5dcee9 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/tool-picker-block.tsx @@ -0,0 +1,115 @@ +import type { LexicalNode } from 'lexical' +import type { FC } from 'react' +import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' +import { + $createTextNode, + $insertNodes, +} from 'lexical' +import * as React from 'react' +import { useCallback, useMemo } from 'react' +import ReactDOM from 'react-dom' +import { v4 as uuid } from 'uuid' +import { useBasicTypeaheadTriggerMatch } from '@/app/components/base/prompt-editor/hooks' +import { $splitNodeContainingQuery } from '@/app/components/base/prompt-editor/utils' +import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' +import { $createToolBlockNode } from './node' + +class ToolPickerMenuOption extends MenuOption { + constructor() { + super('tool-picker') + } +} + +type ToolPickerBlockProps = { + scope?: string +} + +const ToolPickerBlock: FC = ({ scope = 'all' }) => { + const [editor] = useLexicalComposerContext() + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', { + minLength: 0, + maxLength: 0, + }) + + const options = useMemo(() => [new ToolPickerMenuOption()], []) + + const insertTools = useCallback((tools: ToolDefaultValue[]) => { + editor.update(() => { + const match = checkForTriggerMatch('@', editor) + const nodeToRemove = match ? $splitNodeContainingQuery(match) : null + if (nodeToRemove) + nodeToRemove.remove() + + const nodes: LexicalNode[] = [] + tools.forEach((tool, index) => { + nodes.push( + $createToolBlockNode({ + provider: tool.provider_name, + tool: tool.tool_name, + configId: uuid(), + 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) + }) + }, [checkForTriggerMatch, editor]) + + const renderMenu = useCallback(( + anchorElementRef: React.RefObject, + { selectOptionAndCleanUp }: { selectOptionAndCleanUp: (option: MenuOption) => void }, + ) => { + if (!anchorElementRef.current) + return null + + const closeMenu = () => selectOptionAndCleanUp(options[0]) + + return ReactDOM.createPortal( + + )} + triggerAsChild + placement="bottom-start" + offset={4} + isShow + onShowChange={(isShow) => { + if (!isShow) + closeMenu() + }} + onSelect={(tool) => { + insertTools([tool]) + closeMenu() + }} + onSelectMultiple={(tools) => { + insertTools(tools) + closeMenu() + }} + scope={scope} + />, + anchorElementRef.current, + ) + }, [insertTools, options, scope]) + + return ( + {}} + onQueryChange={() => {}} + menuRenderFn={renderMenu} + triggerFn={checkForTriggerMatch} + anchorClassName="z-[999999] translate-y-[calc(-100%-3px)]" + /> + ) +} + +export default React.memo(ToolPickerBlock) 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 new file mode 100644 index 0000000000..bca36d0a18 --- /dev/null +++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/tool-block/utils.ts @@ -0,0 +1,18 @@ +export const getToolTokenRegexString = (): string => { + return '§tool\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\.[a-fA-F0-9-]{36}§' +} + +export const parseToolToken = (text: string) => { + const match = /^§tool\.([\w-]+)\.([\w-]+)\.([a-fA-F0-9-]{36})§$/.exec(text) + if (!match) + return null + return { + provider: match[1], + tool: match[2], + configId: match[3], + } +} + +export const buildToolToken = (payload: { provider: string, tool: string, configId: string }) => { + return `§tool.${payload.provider}.${payload.tool}.${payload.configId}§` +}