feat: enable tool selection as skills (#5638)

* temp: list value

* backspace

* optimize code

* temp-tool

* third menu

* render-temp

* editor use

* optimize skill picker

* update

* change index to key

* fix select index

* remove tool

* query string

* right command fix

* tool hooks

* highlight scroll

* loading status

* search

* debounce

* option list

* invalid

* click

* delete comment

* tooltip

* delete level
This commit is contained in:
heheer
2025-09-16 21:30:24 +08:00
committed by archer
parent ed7a9d1f79
commit 9c4d12bbc1
15 changed files with 1470 additions and 23 deletions

View File

@@ -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'),

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 9C1.5 4.85786 4.85786 1.5 9 1.5C13.1421 1.5 16.5 4.85786 16.5 9C16.5 13.1421 13.1421 16.5 9 16.5C4.85786 16.5 1.5 13.1421 1.5 9ZM8.25 11.25V12.75H9.75V11.25H8.25ZM9.75 10.5L9.75 5.25H8.25L8.25 10.5H9.75Z" fill="#FF7D00"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@@ -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 }) => (
<Box
@@ -72,7 +77,17 @@ export type EditorProps = {
isRichText?: boolean;
variables?: EditorVariablePickerType[];
variableLabels?: EditorVariableLabelPickerType[];
onAddToolFromEditor?: (toolKey: string) => Promise<string>;
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({
{/* <VariablePickerPlugin variables={variables} /> */}
</>
)}
{skillOptionList && skillOptionList.length > 0 && setSelectedSkillKey && (
<>
<SkillPlugin
selectedTools={selectedTools}
onConfigureTool={onConfigureTool}
onRemoveToolFromEditor={onRemoveToolFromEditor}
/>
<SkillPickerPlugin
skillOptionList={skillOptionList}
queryString={queryString}
setQueryString={setQueryString}
isFocus={focus}
onAddToolFromEditor={onAddToolFromEditor}
selectedKey={selectedSkillKey || ''}
setSelectedKey={setSelectedSkillKey}
/>
</>
)}
<OnBlurPlugin onBlur={onBlur} />
<OnChangePlugin
onChange={(editorState, editor) => {

View File

@@ -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<string>;
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<number | null>(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 (
<LexicalTypeaheadMenuPlugin
onQueryChange={handleQueryChange}
onSelectOption={(
selectedOption: SkillOptionType & { setRefElement: () => 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(
<Flex position="relative" align="flex-start" zIndex={99999}>
{/* 一级菜单 */}
<MyBox
p={1.5}
borderRadius={'sm'}
w={queryString ? '200px' : '160px'}
boxShadow={'0 4px 10px 0 rgba(19, 51, 107, 0.10), 0 0 1px 0 rgba(19, 51, 107, 0.10)'}
bg={'white'}
flexShrink={0}
isLoading={isAddToolLoading}
>
{skillOptionList
.filter((option) => !option.parentKey)
.map((skillOption) => {
const { isCurrentFocus, hasSelectedChild } = getDisplayState({
selectedKey,
skillOptionList,
skillOption
});
return (
<Flex
key={skillOption.key}
px={2}
py={1.5}
gap={2}
borderRadius={'4px'}
cursor={'pointer'}
ref={(el) => {
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);
}}
>
<Avatar src={skillOption.icon} w={'16px'} borderRadius={'3px'} />
<Box
color={isCurrentFocus ? 'primary.700' : 'myGray.600'}
fontSize={'12px'}
fontWeight={'medium'}
letterSpacing={'0.5px'}
flex={1}
>
{skillOption.label}
</Box>
</Flex>
);
})}
</MyBox>
{/* 二级菜单 */}
{selectedSkillKey && !queryString && (
<MyBox
ml={2}
p={1.5}
borderRadius={'sm'}
w={'200px'}
boxShadow={
'0 4px 10px 0 rgba(19, 51, 107, 0.10), 0 0 1px 0 rgba(19, 51, 107, 0.10)'
}
bg={'white'}
flexShrink={0}
maxH={'320px'}
overflow={'auto'}
isLoading={isAddToolLoading}
>
{(() => {
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]) => (
<Box key={categoryType} mb={3}>
<Box fontSize={'12px'} fontWeight={'600'} color={'myGray.900'} mb={1} px={2}>
{categoryData.label}
</Box>
{categoryData.options.map((option: SkillOptionType) => {
const { isCurrentFocus, hasSelectedChild } = getDisplayState({
selectedKey,
skillOptionList,
skillOption: option
});
return (
<Flex
key={option.key}
px={2}
py={1.5}
gap={2}
borderRadius={'4px'}
cursor={'pointer'}
ref={(el) => {
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'
}}
>
<Avatar src={option.icon} w={'16px'} borderRadius={'3px'} />
<Box
color={isCurrentFocus ? 'primary.700' : 'myGray.600'}
fontSize={'12px'}
fontWeight={'medium'}
letterSpacing={'0.5px'}
flex={1}
>
{option.label}
</Box>
{option.canOpen && (
<MyIcon
name={'core/chat/chevronRight'}
w={'12px'}
color={'myGray.400'}
/>
)}
</Flex>
);
})}
</Box>
));
})()}
</MyBox>
)}
{/* 三级菜单 */}
{selectedToolKey &&
(() => {
const tertiaryOptions = skillOptionList.filter(
(option) => option.parentKey === selectedToolKey
);
if (tertiaryOptions.length > 0) {
return (
<MyBox
ml={2}
p={1.5}
borderRadius={'sm'}
w={'200px'}
boxShadow={
'0 4px 10px 0 rgba(19, 51, 107, 0.10), 0 0 1px 0 rgba(19, 51, 107, 0.10)'
}
bg={'white'}
flexShrink={0}
maxH={'280px'}
overflow={'auto'}
isLoading={isAddToolLoading}
>
{tertiaryOptions.map((option: SkillOptionType) => (
<Flex
key={option.key}
px={2}
py={1.5}
gap={2}
borderRadius={'4px'}
cursor={'pointer'}
ref={(el) => {
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'
}}
>
<Box flex={1}>
<Box
color={selectedKey === option.key ? 'primary.700' : 'myGray.600'}
fontSize={'12px'}
fontWeight={'medium'}
letterSpacing={'0.5px'}
>
{option.label}
</Box>
</Box>
</Flex>
))}
</MyBox>
);
}
return null;
})()}
</Flex>,
anchorElementRef.current
);
}}
/>
);
}

View File

@@ -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 (
<Box
as="span"
display="inline-flex"
alignItems="center"
px={2}
mx={1}
bg={isInvalid ? 'red.50' : 'yellow.50'}
color={isInvalid ? 'red.600' : 'myGray.900'}
borderRadius="4px"
fontSize="sm"
cursor="pointer"
position="relative"
border={isInvalid ? '1px solid' : 'none'}
borderColor={isInvalid ? 'red.200' : 'transparent'}
_hover={{
bg: isInvalid ? 'red.100' : 'yellow.100',
borderColor: isInvalid ? 'red.300' : 'yellow.300'
}}
onClick={isUnconfigured ? onConfigureClick : undefined}
transform={'translateY(2px)'}
>
<MyTooltip
shouldWrapChildren={false}
label={
isUnconfigured ? (
<Flex py={2} gap={2} fontWeight={'normal'} fontSize={'14px'} color={'myGray.900'}>
<MyIcon name="common/warningFill" w={'18px'} />
{t('common:Skill_Label_Unconfigured')}
</Flex>
) : undefined
}
>
<Flex alignItems="center" gap={1}>
<Avatar
src={skillAvatar || 'core/workflow/template/toolCall'}
w={'14px'}
h={'14px'}
borderRadius={'2px'}
/>
<Box>{skillName || skillKey}</Box>
{isUnconfigured && <Box w="6px" h="6px" bg="primary.600" borderRadius="50%" ml={1} />}
{isInvalid && <Box w="6px" h="6px" bg="red.600" borderRadius="50%" ml={1} />}
</Flex>
</MyTooltip>
</Box>
);
}

View File

@@ -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<string>();
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;
}

View File

@@ -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<JSX.Element> {
__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 (
<SkillLabel
skillKey={this.__skillKey}
skillName={this.__skillName}
skillAvatar={this.__skillAvatar}
isUnconfigured={this.__isUnconfigured}
isInvalid={this.__isInvalid}
onConfigureClick={this.__onConfigureClick}
/>
);
}
}
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;
}

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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<T extends TextNode | VariableLabelNode | VariableNode>(
export function registerLexicalTextEntity<
T extends TextNode | VariableLabelNode | VariableNode | SkillNode
>(
editor: LexicalEditor,
getMatch: (text: string) => null | EntityMatch,
targetNode: Klass<T>,
@@ -32,7 +35,9 @@ export function registerLexicalTextEntity<T extends TextNode | VariableLabelNode
return node instanceof targetNode;
};
const replaceWithSimpleText = (node: TextNode | VariableLabelNode | VariableNode): void => {
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('');

View File

@@ -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)",

View File

@@ -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)",

View File

@@ -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)",

View File

@@ -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<string>('');
const [configTool, setConfigTool] = useState<ExtendedToolType>();
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 && (
<ConfigToolModal
configTool={configTool}
onCloseConfigTool={onCloseConfigTool}
onAddTool={onAddTool}
/>
)}
</>
);
};

View File

@@ -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<React.SetStateAction<AppFormEditFormType>>;
setConfigTool: (tool: ExtendedToolType | undefined) => void;
selectedSkillKey?: string;
};
type UseToolManagerReturn = {
toolSkillOptions: SkillOptionType[];
queryString: string | null;
setQueryString: (value: string | null) => void;
handleAddToolFromEditor: (toolKey: string) => Promise<string>;
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<SkillOptionType[]>([]);
const [queryString, setQueryString] = useState<string | null>(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<string, { label: string; type: string }>
>((acc, item) => {
acc[item.typeId] = {
label: t(parseI18nString(item.typeName, lang)),
type: item.typeId
};
return acc;
}, {});
const pluginsByCategory = new Map<string, NodeTemplateListItemType[]>();
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<string> => {
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
};
};