diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index dbc136653..1dd523f59 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -128,6 +128,7 @@ export const iconPaths = { 'common/voiceLight': () => import('./icons/common/voiceLight.svg'), 'common/wallet': () => import('./icons/common/wallet.svg'), 'common/warn': () => import('./icons/common/warn.svg'), + 'common/warningFill': () => import('./icons/common/warningFill.svg'), 'common/wechat': () => import('./icons/common/wechat.svg'), 'common/wechatFill': () => import('./icons/common/wechatFill.svg'), 'common/wecom': () => import('./icons/common/wecom.svg'), diff --git a/packages/web/components/common/Icon/icons/common/warningFill.svg b/packages/web/components/common/Icon/icons/common/warningFill.svg new file mode 100644 index 000000000..de9135ad9 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/warningFill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx index 40c8b4c5b..e71c16457 100644 --- a/packages/web/components/common/Textarea/PromptEditor/Editor.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/Editor.tsx @@ -43,6 +43,11 @@ import MarkdownPlugin from './plugins/MarkdownPlugin'; import MyIcon from '../../Icon'; import ListExitPlugin from './plugins/ListExitPlugin'; import KeyDownPlugin from './plugins/KeyDownPlugin'; +import SkillPickerPlugin from './plugins/SkillPickerPlugin'; +import SkillPlugin from './plugins/SkillPlugin'; +import { SkillNode } from './plugins/SkillPlugin/node'; +import type { SkillOptionType } from './plugins/SkillPickerPlugin'; +import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; const Placeholder = ({ children, padding }: { children: React.ReactNode; padding: string }) => ( Promise; + onRemoveToolFromEditor?: (toolId: string) => void; + onConfigureTool?: (toolId: string) => void; + selectedTools?: FlowNodeTemplateType[]; + skillOptionList?: SkillOptionType[]; + queryString?: string | null; + setQueryString?: (value: string | null) => void; + selectedSkillKey?: string; + setSelectedSkillKey?: (key: string) => void; value: string; + showOpenModal?: boolean; minH?: number; maxH?: number; @@ -97,6 +112,15 @@ export default function Editor({ onOpenModal, variables = [], variableLabels = [], + skillOptionList = [], + queryString, + setQueryString, + onAddToolFromEditor, + onRemoveToolFromEditor, + onConfigureTool, + selectedTools = [], + selectedSkillKey, + setSelectedSkillKey, onChange, onChangeText, onBlur, @@ -125,6 +149,7 @@ export default function Editor({ nodes: [ VariableNode, VariableLabelNode, + SkillNode, // Only register rich text nodes when in rich text mode ...(isRichText ? [HeadingNode, ListNode, ListItemNode, QuoteNode, CodeNode, CodeHighlightNode] @@ -139,7 +164,7 @@ export default function Editor({ useDeepCompareEffect(() => { if (focus) return; setKey(getNanoid(6)); - }, [value, variables, variableLabels]); + }, [value, variables, variableLabels, selectedTools]); const showFullScreenIcon = useMemo(() => { return showOpenModal && scrollHeight > maxH; @@ -226,6 +251,24 @@ export default function Editor({ {/* */} )} + {skillOptionList && skillOptionList.length > 0 && setSelectedSkillKey && ( + <> + + + + )} { diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx new file mode 100644 index 000000000..520b0432c --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -0,0 +1,544 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import { + $createTextNode, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_HIGH, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND +} from 'lexical'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { useCallback, useEffect, useRef, useMemo } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { useBasicTypeaheadTriggerMatch } from '../../utils'; +import Avatar from '../../../../Avatar'; +import MyIcon from '../../../../Icon'; +import { useRequest2 } from '../../../../../../hooks/useRequest'; +import MyBox from '../../../../MyBox'; + +export type SkillOptionType = { + key: string; + label: string; + icon?: string; + parentKey?: string; + canOpen?: boolean; + categoryType?: string; + categoryLabel?: string; +}; + +const getDisplayState = ({ + selectedKey, + skillOptionList, + skillOption +}: { + selectedKey: string; + skillOptionList: SkillOptionType[]; + skillOption: SkillOptionType; +}) => { + const isCurrentFocus = selectedKey === skillOption.key; + const hasSelectedChild = skillOptionList.some( + (item) => + item.parentKey === skillOption.key && + (selectedKey === item.key || + skillOptionList.some( + (subItem) => subItem.parentKey === item.key && selectedKey === subItem.key + )) + ); + + return { + isCurrentFocus, + hasSelectedChild + }; +}; + +export default function SkillPickerPlugin({ + skillOptionList, + isFocus, + onAddToolFromEditor, + selectedKey, + setSelectedKey, + queryString, + setQueryString +}: { + skillOptionList: SkillOptionType[]; + isFocus: boolean; + onAddToolFromEditor?: (toolKey: string) => Promise; + selectedKey: string; + setSelectedKey: (key: string) => void; + queryString?: string | null; + setQueryString?: (value: string | null) => void; +}) { + const [editor] = useLexicalComposerContext(); + + const highlightedRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + const nextIndexRef = useRef(null); + + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', { + minLength: 0 + }); + + useEffect(() => { + const timer = setTimeout(() => { + const currentRef = highlightedRefs.current[selectedKey]; + if (currentRef) { + currentRef.scrollIntoView({ + behavior: 'auto', + block: 'nearest' + }); + } + }, 0); + + return () => clearTimeout(timer); + }, [selectedKey]); + + const { runAsync: addTool, loading: isAddToolLoading } = useRequest2( + async (selectedOption: SkillOptionType) => { + if ((selectedOption.parentKey || queryString) && onAddToolFromEditor) { + return await onAddToolFromEditor(selectedOption.key); + } else { + return ''; + } + }, + { + manual: true + } + ); + + const currentOptions = useMemo(() => { + const currentOption = skillOptionList.find((option) => option.key === selectedKey); + if (!currentOption) { + return skillOptionList.filter((item) => !item.parentKey); + } + + const filteredOptions = skillOptionList.filter( + (item) => item.parentKey === currentOption.parentKey + ); + + return filteredOptions.map((option) => ({ + ...option, + setRefElement: () => {} + })); + }, [skillOptionList, selectedKey]); + + // overWrite arrow keys + useEffect(() => { + if (!isFocus || queryString === null) return; + const removeRightCommand = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (e: KeyboardEvent) => { + const currentOption = skillOptionList.find((option) => option.key === selectedKey); + if (!currentOption) return false; + + const firstChildOption = skillOptionList.find( + (item) => item.parentKey === currentOption.key + ); + if (firstChildOption) { + setSelectedKey(firstChildOption.key); + nextIndexRef.current = 0; + + e.preventDefault(); + e.stopPropagation(); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + const removeLeftCommand = editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (e: KeyboardEvent) => { + const currentOption = skillOptionList.find((option) => option.key === selectedKey); + if (!currentOption) return false; + + if (currentOption.parentKey) { + const parentOption = skillOptionList.find((item) => item.key === currentOption.parentKey); + if (parentOption) { + const parentSiblings = skillOptionList.filter( + (item) => item.parentKey === parentOption.parentKey + ); + const parentIndexInSiblings = parentSiblings.findIndex( + (item) => item.key === parentOption.key + ); + nextIndexRef.current = parentIndexInSiblings >= 0 ? parentIndexInSiblings : 0; + + setSelectedKey(parentOption.key); + e.preventDefault(); + e.stopPropagation(); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + return () => { + removeRightCommand(); + removeLeftCommand(); + }; + }, [editor, isFocus, queryString, selectedKey, skillOptionList, setSelectedKey]); + + const onSelectOption = useCallback( + async (selectedOption: SkillOptionType, closeMenu: () => void) => { + const skillId = await addTool(selectedOption); + if (!skillId) { + return; + } + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + + const nodes = selection.getNodes(); + nodes.forEach((node) => { + if ($isTextNode(node)) { + const text = node.getTextContent(); + const atIndex = text.lastIndexOf('@'); + if (atIndex !== -1) { + node.setTextContent(text.substring(0, atIndex)); + } + } + }); + + selection.insertNodes([$createTextNode(`{{@${skillId}@}}`)]); + closeMenu(); + }); + }, + [editor, addTool] + ); + + const menuOptions = useMemo(() => { + return currentOptions.map((option) => ({ + ...option, + setRefElement: () => {} + })); + }, [currentOptions]); + + const handleQueryChange = useCallback( + (() => { + let timeout: NodeJS.Timeout; + return (query: string | null) => { + if (setQueryString) { + clearTimeout(timeout); + if (!query?.trim()) { + setQueryString(query); + return; + } + timeout = setTimeout(() => setQueryString(query), 300); + } + }; + })(), + [setQueryString] + ); + + return ( + void }, + nodeToRemove, + closeMenu + ) => { + onSelectOption(selectedOption, closeMenu); + }} + triggerFn={checkForTriggerMatch} + options={menuOptions} + menuRenderFn={( + anchorElementRef, + { selectedIndex: currentSelectedIndex, selectOptionAndCleanUp, setHighlightedIndex } + ) => { + if (currentOptions.length === 0) { + return null; + } + if (anchorElementRef.current === null || !isFocus) { + return null; + } + + if (nextIndexRef.current !== null) { + setHighlightedIndex(nextIndexRef.current); + nextIndexRef.current = null; + } + + const currentOption = currentOptions[currentSelectedIndex || 0] || currentOptions[0]; + + if (currentOption && currentOption.key !== selectedKey) { + setSelectedKey(currentOption.key); + } + + // 判断层级:没有 parentKey 的是顶级,有 parentKey 的根据父级判断层级 + const getNodeDepth = (nodeKey: string): number => { + const node = skillOptionList.find((opt) => opt.key === nodeKey); + if (!node?.parentKey) return 0; + return 1 + getNodeDepth(node.parentKey); + }; + + const currentDepth = currentOption ? getNodeDepth(currentOption.key) : 0; + + const selectedSkillKey = (() => { + if (currentDepth === 0) { + return currentOption?.key; + } else if (currentOption?.parentKey) { + if (currentDepth === 1) { + return currentOption.parentKey; + } else if (currentDepth === 2) { + const parentOption = skillOptionList.find( + (item) => item.key === currentOption.parentKey + ); + return parentOption?.parentKey; + } + } + return null; + })(); + const selectedToolKey = (() => { + if (currentDepth === 1) { + return currentOption?.key; + } else if (currentDepth === 2) { + return currentOption?.parentKey; + } + return null; + })(); + + return ReactDOM.createPortal( + + {/* 一级菜单 */} + + {skillOptionList + .filter((option) => !option.parentKey) + .map((skillOption) => { + const { isCurrentFocus, hasSelectedChild } = getDisplayState({ + selectedKey, + skillOptionList, + skillOption + }); + return ( + { + highlightedRefs.current[skillOption.key] = el; + }} + {...(isCurrentFocus || hasSelectedChild + ? { + bg: '#1118240D' + } + : { + bg: 'white' + })} + _hover={{ + bg: '#1118240D' + }} + onMouseDown={(e) => { + const menuOption = menuOptions.find( + (option) => option.key === skillOption.key + ); + menuOption && selectOptionAndCleanUp(menuOption); + }} + > + + + {skillOption.label} + + + ); + })} + + + {/* 二级菜单 */} + {selectedSkillKey && !queryString && ( + + {(() => { + const secondaryOptions = skillOptionList.filter( + (item) => item.parentKey === selectedSkillKey + ); + + // Organize by category + const categories = new Map(); + secondaryOptions.forEach((item) => { + if (item.categoryType && item.categoryLabel) { + if (!categories.has(item.categoryType)) { + categories.set(item.categoryType, { + label: item.categoryLabel, + options: [] + }); + } + categories.get(item.categoryType).options.push(item); + } + }); + + return Array.from(categories.entries()).map(([categoryType, categoryData]) => ( + + + {categoryData.label} + + {categoryData.options.map((option: SkillOptionType) => { + const { isCurrentFocus, hasSelectedChild } = getDisplayState({ + selectedKey, + skillOptionList, + skillOption: option + }); + return ( + { + highlightedRefs.current[option.key] = el; + }} + onMouseDown={(e) => { + e.preventDefault(); + const menuOption = skillOptionList.find( + (item) => item.key === option.key + ); + menuOption && + selectOptionAndCleanUp({ ...menuOption, setRefElement: () => {} }); + }} + {...(isCurrentFocus || hasSelectedChild + ? { + bg: '#1118240D' + } + : { + bg: 'white' + })} + _hover={{ + bg: '#1118240D' + }} + > + + + {option.label} + + {option.canOpen && ( + + )} + + ); + })} + + )); + })()} + + )} + + {/* 三级菜单 */} + {selectedToolKey && + (() => { + const tertiaryOptions = skillOptionList.filter( + (option) => option.parentKey === selectedToolKey + ); + + if (tertiaryOptions.length > 0) { + return ( + + {tertiaryOptions.map((option: SkillOptionType) => ( + { + highlightedRefs.current[option.key] = el; + }} + onMouseDown={(e) => { + e.preventDefault(); + const menuOption = skillOptionList.find( + (item) => item.key === option.key + ); + menuOption && + selectOptionAndCleanUp({ ...menuOption, setRefElement: () => {} }); + }} + {...(selectedKey === option.key + ? { + bg: '#1118240D' + } + : { + bg: 'white' + })} + _hover={{ + bg: '#1118240D' + }} + > + + + {option.label} + + + + ))} + + ); + } + return null; + })()} + , + anchorElementRef.current + ); + }} + /> + ); +} diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/components/SkillLabel.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/components/SkillLabel.tsx new file mode 100644 index 000000000..3a2f3bef4 --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/components/SkillLabel.tsx @@ -0,0 +1,72 @@ +import { Box, Flex } from '@chakra-ui/react'; +import React from 'react'; +import Avatar from '../../../../../Avatar'; +import MyTooltip from '../../../../../MyTooltip'; +import MyIcon from '../../../../../Icon'; +import { useTranslation } from 'next-i18next'; +interface SkillLabelProps { + skillKey: string; + skillName?: string; + skillAvatar?: string; + isUnconfigured?: boolean; + isInvalid?: boolean; + onConfigureClick?: () => void; +} + +export default function SkillLabel({ + skillKey, + skillName, + skillAvatar, + isUnconfigured = false, + isInvalid = false, + onConfigureClick +}: SkillLabelProps) { + const { t } = useTranslation(); + return ( + + + + {t('common:Skill_Label_Unconfigured')} + + ) : undefined + } + > + + + {skillName || skillKey} + {isUnconfigured && } + {isInvalid && } + + + + ); +} diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/index.tsx new file mode 100644 index 000000000..466f2fd4d --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/index.tsx @@ -0,0 +1,150 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useCallback, useEffect } from 'react'; +import { $createSkillNode, SkillNode } from './node'; +import type { TextNode } from 'lexical'; +import { getSkillRegexString } from './utils'; +import { mergeRegister } from '@lexical/utils'; +import { registerLexicalTextEntity } from '../../utils'; + +const REGEX = new RegExp(getSkillRegexString(), 'i'); + +export default function SkillPlugin({ + selectedTools = [], + onConfigureTool, + onRemoveToolFromEditor +}: { + selectedTools?: any[]; + onConfigureTool?: (toolId: string) => void; + onRemoveToolFromEditor?: (toolId: string) => void; +}) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([SkillNode])) + throw new Error('SkillPlugin: SkillNode not registered on editor'); + }, [editor]); + + const createSkillPlugin = useCallback( + (textNode: TextNode): SkillNode => { + const textContent = textNode.getTextContent(); + const skillKey = textContent.slice(3, -3); + + const tool = selectedTools.find((t) => t.id === skillKey); + + if (tool) { + const extendedTool = tool; + const onConfigureClick = + extendedTool.isUnconfigured && onConfigureTool + ? () => onConfigureTool(skillKey) + : undefined; + return $createSkillNode( + skillKey, + tool.name, + tool.avatar, + extendedTool.isUnconfigured, + false, + onConfigureClick + ); + } + + return $createSkillNode(skillKey, undefined, undefined, false, true); + }, + [selectedTools, onConfigureTool] + ); + + const getSkillMatch = useCallback((text: string) => { + const matches = REGEX.exec(text); + if (!matches) return null; + + const skillLength = matches[4].length + 6; // {{@ + skillKey + @}} + const startOffset = matches.index; + const endOffset = startOffset + skillLength; + + return { + end: endOffset, + start: startOffset + }; + }, []); + + useEffect(() => { + const unregister = mergeRegister( + ...registerLexicalTextEntity(editor, getSkillMatch, SkillNode, createSkillPlugin) + ); + return unregister; + }, [createSkillPlugin, editor, getSkillMatch, selectedTools]); + + useEffect(() => { + if (selectedTools.length === 0) return; + + editor.update(() => { + const nodes = editor.getEditorState()._nodeMap; + + nodes.forEach((node) => { + if (node instanceof SkillNode) { + const skillKey = node.getSkillKey(); + const tool = selectedTools.find((t) => t.id === skillKey); + + if (tool) { + const extendedTool = tool; + if ( + !node.__skillName || + !node.__skillAvatar || + node.__isUnconfigured !== extendedTool.isUnconfigured || + node.__isInvalid !== false + ) { + const writableNode = node.getWritable(); + writableNode.__skillName = tool.name; + writableNode.__skillAvatar = tool.avatar; + writableNode.__isUnconfigured = extendedTool.isUnconfigured; + writableNode.__isInvalid = false; + writableNode.__onConfigureClick = + extendedTool.isUnconfigured && onConfigureTool + ? () => onConfigureTool(skillKey) + : undefined; + } + } else { + if (node.__isInvalid !== true) { + const writableNode = node.getWritable(); + writableNode.__isInvalid = true; + writableNode.__isUnconfigured = false; + writableNode.__onConfigureClick = undefined; + } + } + } + }); + }); + }, [selectedTools, editor]); + + useEffect(() => { + if (!onRemoveToolFromEditor) return; + + const checkRemovedTools = () => { + if (selectedTools.length === 0) return; + + const editorState = editor.getEditorState(); + const nodes = editorState._nodeMap; + const skillKeysInEditor = new Set(); + + nodes.forEach((node) => { + if (node instanceof SkillNode) { + skillKeysInEditor.add(node.getSkillKey()); + } + }); + + // Check for removed tools + selectedTools.forEach((tool) => { + if (!skillKeysInEditor.has(tool.id)) { + onRemoveToolFromEditor(tool.id); + } + }); + }; + + const unregister = editor.registerUpdateListener(({ editorState }) => { + setTimeout(checkRemovedTools, 50); + }); + + return unregister; + }, [selectedTools, editor, onRemoveToolFromEditor]); + + return null; +} diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/node.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/node.tsx new file mode 100644 index 000000000..530ab056d --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/node.tsx @@ -0,0 +1,171 @@ +import { + DecoratorNode, + type DOMConversionMap, + type DOMExportOutput, + type EditorConfig, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, + type TextFormatType +} from 'lexical'; +import SkillLabel from './components/SkillLabel'; + +export type SerializedSkillNode = Spread< + { + skillKey: string; + skillName?: string; + skillAvatar?: string; + isUnconfigured?: boolean; + isInvalid?: boolean; + format: number | TextFormatType; + }, + SerializedLexicalNode +>; + +export class SkillNode extends DecoratorNode { + __format: number | TextFormatType; + __skillKey: string; + __skillName?: string; + __skillAvatar?: string; + __isUnconfigured?: boolean; + __isInvalid?: boolean; + __onConfigureClick?: () => void; + + static getType(): string { + return 'skill'; + } + + static clone(node: SkillNode): SkillNode { + const newNode = new SkillNode( + node.__skillKey, + node.__skillName, + node.__skillAvatar, + node.__isUnconfigured, + node.__isInvalid, + node.__onConfigureClick, + node.__format, + node.__key + ); + return newNode; + } + + constructor( + skillKey: string, + skillName?: string, + skillAvatar?: string, + isUnconfigured?: boolean, + isInvalid?: boolean, + onConfigureClick?: () => void, + format?: number | TextFormatType, + key?: NodeKey + ) { + super(key); + this.__skillKey = skillKey; + this.__skillName = skillName; + this.__skillAvatar = skillAvatar; + this.__isUnconfigured = isUnconfigured; + this.__isInvalid = isInvalid; + this.__onConfigureClick = onConfigureClick; + this.__format = format || 0; + } + + static importJSON(serializedNode: SerializedSkillNode): SkillNode { + const node = $createSkillNode( + serializedNode.skillKey, + serializedNode.skillName, + serializedNode.skillAvatar, + serializedNode.isUnconfigured, + serializedNode.isInvalid + ); + node.setFormat(serializedNode.format); + return node; + } + + setFormat(format: number | TextFormatType): void { + const self = this.getWritable(); + self.__format = format; + } + + getFormat(): number | TextFormatType { + return this.__format; + } + + exportJSON(): SerializedSkillNode { + return { + format: this.__format || 0, + type: 'skill', + version: 1, + skillKey: this.getSkillKey(), + skillName: this.__skillName, + skillAvatar: this.__skillAvatar, + isUnconfigured: this.__isUnconfigured, + isInvalid: this.__isInvalid + }; + } + + createDOM(): HTMLElement { + const element = document.createElement('span'); + return element; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span'); + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return {}; + } + + updateDOM(): false { + return false; + } + + getSkillKey(): string { + return this.__skillKey; + } + + getTextContent( + _includeInert?: boolean | undefined, + _includeDirectionless?: false | undefined + ): string { + return `{{@${this.__skillKey}@}}`; + } + + decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element { + return ( + + ); + } +} + +export function $createSkillNode( + skillKey: string, + skillName?: string, + skillAvatar?: string, + isUnconfigured?: boolean, + isInvalid?: boolean, + onConfigureClick?: () => void +): SkillNode { + return new SkillNode( + skillKey, + skillName, + skillAvatar, + isUnconfigured, + isInvalid, + onConfigureClick + ); +} + +export function $isSkillNode(node: SkillNode | LexicalNode | null | undefined): node is SkillNode { + return node instanceof SkillNode; +} diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/utils.ts b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/utils.ts new file mode 100644 index 000000000..2f6af3a71 --- /dev/null +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPlugin/utils.ts @@ -0,0 +1,31 @@ +function getSkillRegexConfig(): Readonly<{ + leftChars: string; + rightChars: string; + middleChars: string; +}> { + const leftChars = '{'; + const rightChars = '}'; + const middleChars = '@'; + + return { + leftChars, + rightChars, + middleChars + }; +} + +export function getSkillRegexString(): string { + const { leftChars, rightChars, middleChars } = getSkillRegexConfig(); + + const hashLeftCharList = `[${leftChars}]`; + const hashRightCharList = `[${rightChars}]`; + const hashMiddleCharList = `[${middleChars}]`; + + const skillTag = + `(${hashLeftCharList})` + + `(${hashLeftCharList})` + + `(${hashMiddleCharList})(.*?)(${hashMiddleCharList})` + + `(${hashRightCharList})(${hashRightCharList})`; + + return skillTag; +} diff --git a/packages/web/components/common/Textarea/PromptEditor/type.d.ts b/packages/web/components/common/Textarea/PromptEditor/type.d.ts index dd5f88057..80168536f 100644 --- a/packages/web/components/common/Textarea/PromptEditor/type.d.ts +++ b/packages/web/components/common/Textarea/PromptEditor/type.d.ts @@ -46,7 +46,6 @@ export type TabEditorNode = BaseEditorNode & { type: 'tab'; }; -// Rich text export type ParagraphEditorNode = BaseEditorNode & { type: 'paragraph'; children: ChildEditorNode[]; @@ -55,17 +54,20 @@ export type ParagraphEditorNode = BaseEditorNode & { indent: number; }; -// ListItem 节点的 children 可以包含嵌套的 list 节点 -export type ListItemChildEditorNode = - | TextEditorNode - | LineBreakEditorNode - | TabEditorNode - | VariableLabelEditorNode - | VariableEditorNode; +export type ListEditorNode = BaseEditorNode & { + type: 'list'; + children: ListItemEditorNode[]; + direction: string | null; + format: string; + indent: number; + listType: 'bullet' | 'number'; + start: number; + tag: 'ul' | 'ol'; +}; export type ListItemEditorNode = BaseEditorNode & { type: 'listitem'; - children: (ListItemChildEditorNode | ListEditorNode)[]; + children: ChildEditorNode[]; direction: string | null; format: string; indent: number; @@ -82,15 +84,12 @@ export type VariableEditorNode = BaseEditorNode & { variableKey: string; }; -export type ListEditorNode = BaseEditorNode & { - type: 'list'; - children: ListItemEditorNode[]; - direction: string | null; - format: string; - indent: number; - listType: 'bullet' | 'number'; - start: number; - tag: 'ul' | 'ol'; +export type SkillEditorNode = BaseEditorNode & { + type: 'skill'; + skillKey: string; + skillName?: string; + skillAvatar?: string; + format: number; }; export type ChildEditorNode = @@ -101,7 +100,8 @@ export type ChildEditorNode = | ListEditorNode | ListItemEditorNode | VariableLabelEditorNode - | VariableEditorNode; + | VariableEditorNode + | SkillEditorNode; export type EditorState = { root: { diff --git a/packages/web/components/common/Textarea/PromptEditor/utils.ts b/packages/web/components/common/Textarea/PromptEditor/utils.ts index 244521089..a6cf5fb58 100644 --- a/packages/web/components/common/Textarea/PromptEditor/utils.ts +++ b/packages/web/components/common/Textarea/PromptEditor/utils.ts @@ -12,6 +12,7 @@ import { $createTextNode, $isTextNode, TextNode } from 'lexical'; import { useCallback } from 'react'; import type { VariableLabelNode } from './plugins/VariableLabelPlugin/node'; import type { VariableNode } from './plugins/VariablePlugin/node'; +import type { SkillNode } from './plugins/SkillPlugin/node'; import type { ListItemEditorNode, ListEditorNode, @@ -22,7 +23,9 @@ import type { } from './type'; import { TabStr } from './constants'; -export function registerLexicalTextEntity( +export function registerLexicalTextEntity< + T extends TextNode | VariableLabelNode | VariableNode | SkillNode +>( editor: LexicalEditor, getMatch: (text: string) => null | EntityMatch, targetNode: Klass, @@ -32,7 +35,9 @@ export function registerLexicalTextEntity { + const replaceWithSimpleText = ( + node: TextNode | VariableLabelNode | VariableNode | SkillNode + ): void => { const textNode = $createTextNode(node.getTextContent()); textNode.setFormat(node.getFormat()); node.replace(textNode); @@ -432,6 +437,8 @@ const processListItem = ({ itemText.push(TabStr); } else if (child.type === 'variableLabel' || child.type === 'Variable') { itemText.push(child.variableKey); + } else if (child.type === 'skill') { + itemText.push(`{{@${child.skillKey}@}}`); } else if (child.type === 'list') { nestedLists.push(child); } @@ -556,6 +563,17 @@ export const editorStateToText = (editor: LexicalEditor) => { children.forEach((child) => { const val = extractText(child); paragraphText.push(val); + if (child.type === 'linebreak') { + paragraphText.push('\n'); + } else if (child.type === 'text') { + paragraphText.push(child.text); + } else if (child.type === 'tab') { + paragraphText.push(' '); + } else if (child.type === 'variableLabel' || child.type === 'Variable') { + paragraphText.push(child.variableKey); + } else if (child.type === 'skill') { + paragraphText.push(`{{@${child.skillKey}@}}`); + } }); const finalText = paragraphText.join(''); diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 6061b08a3..9b66e8899 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -81,6 +81,7 @@ "Select_App": "Select an application", "Select_all": "Select all", "Setting": "Setting", + "Skill_Label_Unconfigured": "The parameters are not configured, click Configure", "Status": "Status", "Submit": "Submit", "Success": "Success", @@ -97,6 +98,7 @@ "add_new": "add_new", "add_new_param": "Add new param", "add_success": "Added Successfully", + "agent_prompt_tips": "It is recommended to fill in the following template for best results.\n\n\"Role Identity\"\n\n\"Task Objective\"\n\n\"Task Process and Skills\"\n\nEnter \"/\" to insert global variables; enter \"@\" to insert specific skills, including applications, tools, knowledge bases, and models.", "all_quotes": "All quotes", "all_result": "Full Results", "app_evaluation": "App Evaluation(Beta)", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index bfed3a4a3..acfc1820f 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -81,6 +81,7 @@ "Select_App": "选择应用", "Select_all": "全选", "Setting": "设置", + "Skill_Label_Unconfigured": "参数未配置,点击配置", "Status": "状态", "Submit": "提交", "Success": "成功", @@ -97,6 +98,7 @@ "add_new": "新增", "add_new_param": "新增参数", "add_success": "添加成功", + "agent_prompt_tips": "建议按照以下模板填写,以获得最佳效果。\n「角色身份」\n「任务目标」\n「任务流程与技能」\n输入“/”插入全局变量;输入“@”插入特定技能,包括应用、工具、知识库、模型。", "all_quotes": "全部引用", "all_result": "完整结果", "app_evaluation": "Agent 评测(Beta)", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 7b96ecea5..af4a675fe 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -81,6 +81,7 @@ "Select_App": "選擇應用", "Select_all": "全選", "Setting": "設定", + "Skill_Label_Unconfigured": "參數未配置,點擊配置", "Status": "狀態", "Submit": "送出", "Success": "成功", @@ -97,6 +98,7 @@ "add_new": "新增", "add_new_param": "新增參數", "add_success": "新增成功", + "agent_prompt_tips": "建議按照以下模板填寫,以獲得最佳效果。\n\n「角色身份」\n「任務目標」\n「任務流程與技能」\n輸入“/”插入全局變量;輸入“@”插入特定技能,包括應用、工具、知識庫、模型。", "all_quotes": "全部引用", "all_result": "完整結果", "app_evaluation": "應用評測(Beta)", diff --git a/projects/app/src/pageComponents/app/detail/Edit/Agent/EditForm.tsx b/projects/app/src/pageComponents/app/detail/Edit/Agent/EditForm.tsx index 5c11e233c..2c7c2cea3 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/Agent/EditForm.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/Agent/EditForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useTransition } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useTransition } from 'react'; import { Box, Flex, @@ -32,6 +32,8 @@ import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip'; import { getWebLLMModel } from '@/web/common/system/utils'; import ToolSelect from '../FormComponent/ToolSelector/ToolSelect'; import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover'; +import { useToolManager, type ExtendedToolType } from './hooks/useToolManager'; +import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node'; const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal')); const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal')); @@ -40,6 +42,7 @@ const QGConfig = dynamic(() => import('@/components/core/app/QGConfig')); const WhisperConfig = dynamic(() => import('@/components/core/app/WhisperConfig')); const InputGuideConfig = dynamic(() => import('@/components/core/app/InputGuideConfig')); const WelcomeTextConfig = dynamic(() => import('@/components/core/app/WelcomeTextConfig')); +const ConfigToolModal = dynamic(() => import('../component/ConfigToolModal')); const FileSelectConfig = dynamic(() => import('@/components/core/app/FileSelect')); const BoxStyles: BoxProps = { @@ -70,6 +73,36 @@ const EditForm = ({ const { appDetail } = useContextSelector(AppContext, (v) => v); const selectDatasets = useMemo(() => appForm?.dataset?.datasets, [appForm]); const [, startTst] = useTransition(); + const [selectedSkillKey, setSelectedSkillKey] = useState(''); + const [configTool, setConfigTool] = useState(); + const onAddTool = useCallback( + (tool: FlowNodeTemplateType) => { + setAppForm((state: any) => ({ + ...state, + selectedTools: state.selectedTools.map((t: ExtendedToolType) => + t.id === tool.id ? { ...tool, isUnconfigured: false } : t + ) + })); + setConfigTool(undefined); + }, + [setAppForm, setConfigTool] + ); + + const { + toolSkillOptions, + queryString, + setQueryString, + handleAddToolFromEditor, + handleConfigureTool, + handleRemoveToolFromEditor + } = useToolManager({ + appForm, + setAppForm, + setConfigTool, + selectedSkillKey + }); + + const onCloseConfigTool = useCallback(() => setConfigTool(undefined), []); const { isOpen: isOpenDatasetSelect, @@ -214,8 +247,17 @@ const EditForm = ({ })); }); }} + onAddToolFromEditor={handleAddToolFromEditor} + onRemoveToolFromEditor={handleRemoveToolFromEditor} + onConfigureTool={handleConfigureTool} + selectedTools={appForm.selectedTools} variableLabels={formatVariables} variables={formatVariables} + skillOptionList={[...toolSkillOptions]} + queryString={queryString} + setQueryString={setQueryString} + selectedSkillKey={selectedSkillKey} + setSelectedSkillKey={setSelectedSkillKey} placeholder={t('common:core.app.tip.systemPromptTip')} title={t('common:core.ai.Prompt')} ExtensionPopover={[OptimizerPopverComponent]} @@ -461,6 +503,13 @@ const EditForm = ({ }} /> )} + {!!configTool && ( + + )} ); }; diff --git a/projects/app/src/pageComponents/app/detail/Edit/Agent/hooks/useToolManager.ts b/projects/app/src/pageComponents/app/detail/Edit/Agent/hooks/useToolManager.ts new file mode 100644 index 000000000..20bbe6bb5 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/Edit/Agent/hooks/useToolManager.ts @@ -0,0 +1,359 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useTranslation } from 'next-i18next'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { + getSystemPlugTemplates, + getPluginGroups, + getPreviewPluginNode +} from '@/web/core/app/api/plugin'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { SkillOptionType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin'; +import type { + FlowNodeTemplateType, + NodeTemplateListItemType +} from '@fastgpt/global/core/workflow/type/node'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { workflowStartNodeId } from '@/web/core/app/constants'; +import type { AppFormEditFormType } from '@fastgpt/global/core/app/type'; +import type { SystemToolGroupSchemaType } from '@fastgpt/service/core/app/plugin/type'; + +export type ExtendedToolType = FlowNodeTemplateType & { + isUnconfigured?: boolean; +}; + +type UseToolManagerProps = { + appForm: AppFormEditFormType; + setAppForm: React.Dispatch>; + setConfigTool: (tool: ExtendedToolType | undefined) => void; + selectedSkillKey?: string; +}; + +type UseToolManagerReturn = { + toolSkillOptions: SkillOptionType[]; + queryString: string | null; + setQueryString: (value: string | null) => void; + + handleAddToolFromEditor: (toolKey: string) => Promise; + handleConfigureTool: (toolId: string) => void; + handleRemoveToolFromEditor: (toolId: string) => void; +}; + +export const useToolManager = ({ + appForm, + setAppForm, + setConfigTool, + selectedSkillKey +}: UseToolManagerProps): UseToolManagerReturn => { + const { t, i18n } = useTranslation(); + const { toast } = useToast(); + const lang = i18n?.language as localeType; + const [toolSkillOptions, setToolSkillOptions] = useState([]); + const [queryString, setQueryString] = useState(null); + + /* get tool skills */ + const { data: pluginGroups = [] } = useRequest2( + async () => { + try { + return await getPluginGroups(); + } catch (error) { + console.error('Failed to load plugin groups:', error); + return []; + } + }, + { + manual: false, + onSuccess(data) { + const primaryOptions: SkillOptionType[] = data.map((item) => ({ + key: item.groupId, + label: t(item.groupName), + icon: 'core/workflow/template/toolCall' + })); + setToolSkillOptions(primaryOptions); + } + } + ); + const requestParentId = useMemo(() => { + if (queryString?.trim()) { + return ''; + } + const selectedOption = toolSkillOptions.find((option) => option.key === selectedSkillKey); + if (!toolSkillOptions.some((option) => option.parentKey) && selectedOption) { + return ''; + } + if (selectedOption?.canOpen) { + const hasLoadingPlaceholder = toolSkillOptions.some( + (option) => option.parentKey === selectedSkillKey && option.key === 'loading' + ); + if (hasLoadingPlaceholder) { + return selectedSkillKey; + } + } + + return null; + }, [toolSkillOptions, selectedSkillKey, queryString]); + const buildToolSkillOptions = useCallback( + (systemPlugins: NodeTemplateListItemType[], pluginGroups: SystemToolGroupSchemaType[]) => { + const skillOptions: SkillOptionType[] = []; + + pluginGroups.forEach((group) => { + skillOptions.push({ + key: group.groupId, + label: t(group.groupName as any), + icon: 'core/workflow/template/toolCall' + }); + }); + + pluginGroups.forEach((group) => { + const categoryMap = group.groupTypes.reduce< + Record + >((acc, item) => { + acc[item.typeId] = { + label: t(parseI18nString(item.typeName, lang)), + type: item.typeId + }; + return acc; + }, {}); + + const pluginsByCategory = new Map(); + systemPlugins.forEach((plugin) => { + if (categoryMap[plugin.templateType]) { + if (!pluginsByCategory.has(plugin.templateType)) { + pluginsByCategory.set(plugin.templateType, []); + } + pluginsByCategory.get(plugin.templateType)!.push(plugin); + } + }); + + pluginsByCategory.forEach((plugins, categoryType) => { + plugins.forEach((plugin) => { + const canOpen = plugin.flowNodeType === 'toolSet' || plugin.isFolder; + const category = categoryMap[categoryType]; + + skillOptions.push({ + key: plugin.id, + label: t(parseI18nString(plugin.name, lang)), + icon: plugin.avatar || 'core/workflow/template/toolCall', + parentKey: group.groupId, + canOpen, + categoryType: category.type, + categoryLabel: category.label + }); + + if (canOpen) { + skillOptions.push({ + key: 'loading', + label: 'Loading...', + icon: plugin.avatar || 'core/workflow/template/toolCall', + parentKey: plugin.id + }); + } + }); + }); + }); + + return skillOptions; + }, + [t, lang] + ); + const buildSearchOptions = useCallback( + (searchResults: NodeTemplateListItemType[]) => { + return searchResults.map((plugin) => ({ + key: plugin.id, + label: t(parseI18nString(plugin.name, lang)), + icon: plugin.avatar || 'core/workflow/template/toolCall' + })); + }, + [t, lang] + ); + const updateTertiaryOptions = useCallback( + ( + currentOptions: SkillOptionType[], + parentKey: string | undefined, + subItems: NodeTemplateListItemType[] + ) => { + const filteredOptions = currentOptions.filter((option) => !(option.parentKey === parentKey)); + + const newTertiaryOptions = subItems.map((plugin) => ({ + key: plugin.id, + label: t(parseI18nString(plugin.name, lang)), + icon: 'core/workflow/template/toolCall', + parentKey + })); + + return [...filteredOptions, ...newTertiaryOptions]; + }, + [t, lang] + ); + useRequest2( + async () => { + try { + return await getSystemPlugTemplates({ + parentId: requestParentId || '', + searchKey: queryString?.trim() || '' + }); + } catch (error) { + console.error('Failed to load system plugin templates:', error); + return []; + } + }, + { + manual: requestParentId === null, + refreshDeps: [requestParentId, queryString], + onSuccess(data) { + if (queryString?.trim()) { + const searchOptions = buildSearchOptions(data); + setToolSkillOptions(searchOptions); + } else if (requestParentId === '') { + const fullOptions = buildToolSkillOptions(data, pluginGroups); + setToolSkillOptions(fullOptions); + } else if (requestParentId === selectedSkillKey) { + setToolSkillOptions((prevOptions) => + updateTertiaryOptions(prevOptions, requestParentId, data) + ); + } + } + } + ); + + const validateToolConfiguration = useCallback( + (toolTemplate: FlowNodeTemplateType): boolean => { + // 检查文件上传配置 + const oneFileInput = + toolTemplate.inputs.filter((input) => + input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect) + ).length === 1; + + const canUploadFile = + appForm.chatConfig?.fileSelectConfig?.canSelectFile || + appForm.chatConfig?.fileSelectConfig?.canSelectImg; + + const hasValidFileInput = oneFileInput && !!canUploadFile; + + // 检查是否有无效的输入配置 + const hasInvalidInput = toolTemplate.inputs.some( + (input) => + // 引用类型但没有工具描述 + (input.renderTypeList.length === 1 && + input.renderTypeList[0] === FlowNodeInputTypeEnum.reference && + !input.toolDescription) || + // 包含数据集选择 + input.renderTypeList.includes(FlowNodeInputTypeEnum.selectDataset) || + // 包含动态输入参数 + input.renderTypeList.includes(FlowNodeInputTypeEnum.addInputParam) || + // 文件选择但配置无效 + (input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect) && !hasValidFileInput) + ); + + if (hasInvalidInput) { + toast({ + title: t('app:simple_tool_tips'), + status: 'warning' + }); + return false; + } + + return true; + }, + [appForm.chatConfig, toast, t] + ); + const checkNeedsUserConfiguration = useCallback((toolTemplate: FlowNodeTemplateType): boolean => { + const formRenderTypes = [ + FlowNodeInputTypeEnum.input, + FlowNodeInputTypeEnum.textarea, + FlowNodeInputTypeEnum.numberInput, + FlowNodeInputTypeEnum.switch, + FlowNodeInputTypeEnum.select, + FlowNodeInputTypeEnum.JSONEditor + ]; + + return ( + toolTemplate.inputs.length > 0 && + toolTemplate.inputs.some((input) => { + // 有工具描述的不需要配置 + if (input.toolDescription) return false; + // 禁用流的不需要配置 + if (input.key === NodeInputKeyEnum.forbidStream) return false; + // 系统输入配置需要配置 + if (input.key === NodeInputKeyEnum.systemInputConfig) return true; + + // 检查是否包含表单类型的输入 + return formRenderTypes.some((type) => input.renderTypeList.includes(type)); + }) + ); + }, []); + const handleAddToolFromEditor = useCallback( + async (toolKey: string): Promise => { + try { + const toolTemplate = await getPreviewPluginNode({ appId: toolKey }); + if (!validateToolConfiguration(toolTemplate)) { + return ''; + } + + const needsConfiguration = checkNeedsUserConfiguration(toolTemplate); + const toolId = `tool_${getNanoid(6)}`; + const toolInstance: ExtendedToolType = { + ...toolTemplate, + id: toolId, + inputs: toolTemplate.inputs.map((input) => { + if (input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)) { + return { + ...input, + value: [[workflowStartNodeId, NodeOutputKeyEnum.userFiles]] + }; + } + return input; + }), + isUnconfigured: needsConfiguration + }; + + setAppForm((state: any) => ({ + ...state, + selectedTools: [...state.selectedTools, toolInstance] + })); + + return toolId; + } catch (error) { + console.error('Failed to add tool from editor:', error); + return ''; + } + }, + [validateToolConfiguration, checkNeedsUserConfiguration, setAppForm] + ); + + const handleRemoveToolFromEditor = useCallback( + (toolId: string) => { + setAppForm((state: any) => ({ + ...state, + selectedTools: state.selectedTools.filter((tool: ExtendedToolType) => tool.id !== toolId) + })); + }, + [setAppForm] + ); + + const handleConfigureTool = useCallback( + (toolId: string) => { + const tool = appForm.selectedTools.find( + (tool: ExtendedToolType) => tool.id === toolId + ) as ExtendedToolType; + + if (tool?.isUnconfigured) { + setConfigTool(tool); + } + }, + [appForm.selectedTools, setConfigTool] + ); + + return { + toolSkillOptions, + queryString, + setQueryString, + + handleAddToolFromEditor, + handleConfigureTool, + handleRemoveToolFromEditor + }; +};