mirror of
https://github.com/labring/FastGPT.git
synced 2025-11-29 01:05:13 +08:00
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:
@@ -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'),
|
||||
|
||||
@@ -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 |
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user