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