Test version (#4792)

* plugin node version select (#4760)

* plugin node version select

* type

* fix

* fix

* perf: version list

* fix node version (#4787)

* change my select

* fix-ui

* fix test

* add test

* fix

* remove invalid version field

* filter deprecated field

* fix: claude tool call

* fix: test

---------

Co-authored-by: heheer <heheer@sealos.io>
This commit is contained in:
Archer
2025-05-12 22:27:01 +08:00
committed by GitHub
parent 3cc6b8a17a
commit 0ef3d40296
69 changed files with 1024 additions and 599 deletions

View File

@@ -22,6 +22,7 @@ weight: 792
1. Chat log list 优化,避免大数据时超出内存限制。
2. 预加载 token 计算 worker避免主任务中并发创建导致线程阻塞。
3. 工作流节点版本控制交互优化。
## 🐛 修复
@@ -31,4 +32,5 @@ weight: 792
4. 重新训练时,图片过期索引未成功清除,导致图片会丢失。
5. 重新训练权限问题。
6. 文档链接地址。
7. Claude 工具调用,由于 index 为空,导致工具调用失败。

View File

@@ -331,7 +331,7 @@ export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user
};
// Document quote prompt
export const getDocumentQuotePrompt = (version: string) => {
export const getDocumentQuotePrompt = (version?: string) => {
const promptMap = {
['4.9.2']: `将 <FilesContent></FilesContent> 中的内容作为本次对话的参考:
<FilesContent>

View File

@@ -1,4 +1,5 @@
export const getPromptByVersion = (version?: string, promptMap: Record<string, string> = {}) => {
// 版本号大的在前面
const versions = Object.keys(promptMap).sort((a, b) => {
const [majorA, minorA, patchA] = a.split('.').map(Number);
const [majorB, minorB, patchB] = b.split('.').map(Number);
@@ -15,5 +16,5 @@ export const getPromptByVersion = (version?: string, promptMap: Record<string, s
if (version in promptMap) {
return promptMap[version];
}
return promptMap[versions[versions.length - 1]];
return promptMap[versions[0]];
};

View File

@@ -218,7 +218,6 @@ export const FlowValueTypeMap: Record<
};
export const EDGE_TYPE = 'default';
export const defaultNodeVersion = '481';
export const chatHistoryValueDesc = `{
obj: System | Human | AI;
@@ -236,3 +235,10 @@ export const datasetQuoteValueDesc = `{
export const datasetSelectValueDesc = `{
datasetId: string;
}[]`;
export const AppNodeFlowNodeTypeMap: Record<any, boolean> = {
[FlowNodeTypeEnum.pluginModule]: true,
[FlowNodeTypeEnum.appModule]: true,
[FlowNodeTypeEnum.tool]: true,
[FlowNodeTypeEnum.toolSet]: true
};

View File

@@ -101,7 +101,7 @@ export type RuntimeNodeItemType = {
outputs: FlowNodeOutputItemType[];
pluginId?: string; // workflow id / plugin id
version: string;
version?: string;
};
export type RuntimeEdgeItemType = StoreEdgeItemType & {

View File

@@ -25,7 +25,6 @@ export const RunAppModule: FlowNodeTemplateType = {
name: i18nT('workflow:application_call'),
intro: i18nT('workflow:select_another_application_to_call'),
showStatus: true,
version: '481',
isTool: true,
inputs: [
{

View File

@@ -19,7 +19,6 @@ import {
Input_Template_UserChatInput,
Input_Template_File_Link
} from '../../input';
import { chatNodeSystemPromptTip, systemPromptTip } from '../../tip';
import { getHandleConfig } from '../../utils';
import { i18nT } from '../../../../../../web/i18n/utils';
@@ -121,12 +120,7 @@ export const AiChatModule: FlowNodeTemplateType = {
valueType: WorkflowIOValueTypeEnum.string
},
// settings modal ---
{
...Input_Template_System_Prompt,
label: i18nT('common:core.ai.Prompt'),
description: systemPromptTip,
placeholder: chatNodeSystemPromptTip
},
Input_Template_System_Prompt,
Input_Template_History,
Input_Template_Dataset_Quote,
Input_Template_File_Link,

View File

@@ -18,7 +18,6 @@ export const AssignedAnswerModule: FlowNodeTemplateType = {
name: i18nT('workflow:assigned_reply'),
intro: i18nT('workflow:intro_assigned_reply'),
courseUrl: '/docs/guide/dashboard/workflow/reply/',
version: '481',
isTool: true,
inputs: [
{

View File

@@ -18,7 +18,6 @@ export const CustomFeedbackNode: FlowNodeTemplateType = {
name: i18nT('workflow:custom_feedback'),
intro: i18nT('workflow:intro_custom_feedback'),
courseUrl: '/docs/guide/dashboard/workflow/custom_feedback/',
version: '486',
inputs: [
{
key: NodeInputKeyEnum.textareaInput,

View File

@@ -42,7 +42,6 @@ export const DatasetConcatModule: FlowNodeTemplateType = {
intro: i18nT('workflow:intro_knowledge_base_search_merge'),
showStatus: false,
version: '486',
courseUrl: '/docs/guide/dashboard/workflow/knowledge_base_search_merge/',
inputs: [
{

View File

@@ -28,7 +28,6 @@ export const HttpNode468: FlowNodeTemplateType = {
showStatus: true,
isTool: true,
courseUrl: '/docs/guide/dashboard/workflow/http/',
version: '481',
inputs: [
{
...Input_Template_DynamicInput,

View File

@@ -24,7 +24,6 @@ export const IfElseNode: FlowNodeTemplateType = {
intro: i18nT('workflow:execute_different_branches_based_on_conditions'),
showStatus: true,
courseUrl: '/docs/guide/dashboard/workflow/tfswitch/',
version: '481',
inputs: [
{
key: NodeInputKeyEnum.ifElseList,

View File

@@ -23,7 +23,6 @@ export const FormInputNode: FlowNodeTemplateType = {
name: i18nT('app:workflow.form_input'),
intro: i18nT(`app:workflow.form_input_tip`),
isTool: true,
version: '4811',
inputs: [
{
key: NodeInputKeyEnum.description,

View File

@@ -24,7 +24,6 @@ export const UserSelectNode: FlowNodeTemplateType = {
name: i18nT('app:workflow.user_select'),
intro: i18nT(`app:workflow.user_select_tip`),
isTool: true,
version: '489',
courseUrl: '/docs/guide/dashboard/workflow/user-selection/',
inputs: [
{

View File

@@ -33,7 +33,6 @@ export const LafModule: FlowNodeTemplateType = {
showStatus: true,
isTool: true,
courseUrl: '/docs/guide/dashboard/workflow/laf/',
version: '481',
inputs: [
{
...Input_Template_DynamicInput,

View File

@@ -29,7 +29,6 @@ export const LoopNode: FlowNodeTemplateType = {
name: i18nT('workflow:loop'),
intro: i18nT('workflow:intro_loop'),
showStatus: true,
version: '4811',
courseUrl: '/docs/guide/dashboard/workflow/loop/',
inputs: [
{

View File

@@ -19,7 +19,6 @@ export const LoopEndNode: FlowNodeTemplateType = {
avatar: 'core/workflow/template/loopEnd',
name: i18nT('workflow:loop_end'),
showStatus: false,
version: '4811',
inputs: [
{
key: NodeInputKeyEnum.loopEndInput,

View File

@@ -24,7 +24,6 @@ export const LoopStartNode: FlowNodeTemplateType = {
unique: true,
forbidDelete: true,
showStatus: false,
version: '4811',
inputs: [
{
key: NodeInputKeyEnum.loopStartInput,

View File

@@ -15,7 +15,6 @@ export const PluginConfigNode: FlowNodeTemplateType = {
intro: '',
unique: true,
forbidDelete: true,
version: '4811',
inputs: [],
outputs: []
};

View File

@@ -16,7 +16,6 @@ export const PluginInputModule: FlowNodeTemplateType = {
name: i18nT('workflow:plugin_input'),
intro: i18nT('workflow:intro_plugin_input'),
showStatus: false,
version: '481',
inputs: [],
outputs: []
};

View File

@@ -16,7 +16,6 @@ export const PluginOutputModule: FlowNodeTemplateType = {
name: i18nT('workflow:template.plugin_output'),
intro: i18nT('workflow:intro_custom_plugin_output'),
showStatus: false,
version: '481',
inputs: [],
outputs: []
};

View File

@@ -13,7 +13,6 @@ export const RunAppNode: FlowNodeTemplateType = {
name: '',
showStatus: false,
isTool: false,
version: '481',
inputs: [], // [{key:'pluginId'},...]
outputs: []
};

View File

@@ -13,7 +13,6 @@ export const RunPluginModule: FlowNodeTemplateType = {
name: '',
showStatus: false,
isTool: true,
version: '481',
inputs: [], // [{key:'pluginId'},...]
outputs: []
};

View File

@@ -13,7 +13,6 @@ export const RunToolNode: FlowNodeTemplateType = {
name: '',
showStatus: false,
isTool: true,
version: '4.9.6',
inputs: [],
outputs: []
};

View File

@@ -13,7 +13,6 @@ export const RunToolSetNode: FlowNodeTemplateType = {
name: '',
showStatus: false,
isTool: true,
version: '4.9.6',
inputs: [],
outputs: []
};

View File

@@ -27,7 +27,6 @@ export const CodeNode: FlowNodeTemplateType = {
intro: i18nT('workflow:execute_a_simple_script_code_usually_for_complex_data_processing'),
showStatus: true,
courseUrl: '/docs/guide/dashboard/workflow/sandbox/',
version: '482',
inputs: [
{
...Input_Template_DynamicInput,

View File

@@ -13,7 +13,6 @@ export const StopToolNode: FlowNodeTemplateType = {
avatar: 'core/workflow/template/stopTool',
name: i18nT('workflow:tool_call_termination'),
intro: i18nT('workflow:intro_tool_call_termination'),
version: '481',
inputs: [],
outputs: []
};

View File

@@ -15,7 +15,6 @@ export const SystemConfigNode: FlowNodeTemplateType = {
intro: '',
unique: true,
forbidDelete: true,
version: '481',
inputs: [],
outputs: []
};

View File

@@ -24,7 +24,6 @@ export const TextEditorNode: FlowNodeTemplateType = {
name: i18nT('workflow:text_concatenation'),
intro: i18nT('workflow:intro_text_concatenation'),
courseUrl: '/docs/guide/dashboard/workflow/text_editor/',
version: '4813',
inputs: [
{
key: NodeInputKeyEnum.textareaInput,

View File

@@ -13,7 +13,6 @@ export const ToolParamsNode: FlowNodeTemplateType = {
avatar: 'core/workflow/template/toolParams',
name: i18nT('workflow:tool_custom_field'),
intro: i18nT('workflow:intro_tool_params_config'),
version: '4811',
isTool: true,
inputs: [],
outputs: []

View File

@@ -19,7 +19,6 @@ export const VariableUpdateNode: FlowNodeTemplateType = {
intro: i18nT('workflow:update_specified_node_output_or_global_variable'),
showStatus: false,
isTool: true,
version: '481',
courseUrl: '/docs/guide/dashboard/workflow/variable_update/',
inputs: [
{

View File

@@ -30,7 +30,6 @@ export const WorkflowStart: FlowNodeTemplateType = {
intro: '',
forbidDelete: true,
unique: true,
version: '481',
inputs: [{ ...Input_Template_UserChatInput, toolDescription: i18nT('workflow:user_question') }],
outputs: [
{

View File

@@ -37,7 +37,10 @@ export type WorkflowTemplateType = {
intro?: string;
author?: string;
courseUrl?: string;
version: string;
version?: string;
versionLabel?: string;
isLatestVersion?: boolean;
showStatus?: boolean;
weight?: number;

View File

@@ -63,6 +63,8 @@ export type FlowNodeInputItemType = InputComponentPropsType & {
canSelectFile?: boolean;
canSelectImg?: boolean;
maxFiles?: number;
deprecated?: boolean;
};
export type FlowNodeOutputItemType = {
@@ -86,6 +88,8 @@ export type FlowNodeOutputItemType = {
// component params
customFieldConfig?: CustomFieldConfigType;
deprecated?: boolean;
};
export type ReferenceItemValueType = [string, string | undefined];

View File

@@ -34,7 +34,10 @@ export type FlowNodeCommonType = {
name: string;
intro?: string; // template list intro
showStatus?: boolean; // chatting response step status
version: string;
version?: string;
versionLabel?: string; // Just ui show
isLatestVersion?: boolean; // Just ui show
// data
inputs: FlowNodeInputItemType[];
@@ -48,7 +51,7 @@ export type FlowNodeCommonType = {
};
export type PluginDataType = {
version: string;
version?: string;
diagram?: string;
userGuide?: string;
courseUrl?: string;

View File

@@ -1,5 +1,5 @@
import { type FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node.d';
import { FlowNodeTypeEnum, defaultNodeVersion } from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
appData2FlowNodeIO,
pluginData2FlowNodeIO,
@@ -14,10 +14,16 @@ import { cloneDeep } from 'lodash';
import { MongoApp } from '../schema';
import { type SystemPluginTemplateItemType } from '@fastgpt/global/core/workflow/type';
import { getSystemPluginTemplates } from '../../../../plugins/register';
import { getAppLatestVersion, getAppVersionById } from '../version/controller';
import {
checkIsLatestVersion,
getAppLatestVersion,
getAppVersionById
} from '../version/controller';
import { type PluginRuntimeType } from '@fastgpt/global/core/plugin/type';
import { MongoSystemPlugin } from './systemPluginSchema';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
import { MongoAppVersion } from '../version/schema';
import { i18nT } from '../../../../web/i18n/utils';
/*
plugin id rule:
@@ -90,20 +96,34 @@ const getSystemPluginTemplateById = async (
/* Format plugin to workflow preview node data */
export async function getChildAppPreviewNode({
id
appId,
versionId
}: {
id: string;
appId: string;
versionId?: string;
}): Promise<FlowNodeTemplateType> {
const app: ChildAppType = await (async () => {
const { source, pluginId } = await splitCombinePluginId(id);
const { source, pluginId } = await splitCombinePluginId(appId);
if (source === PluginSourceEnum.personal) {
const item = await MongoApp.findById(id).lean();
const item = await MongoApp.findById(appId).lean();
if (!item) return Promise.reject('plugin not found');
const version = await getAppLatestVersion(id, item);
const version = await getAppVersionById({ appId, versionId, app: item });
if (!version.versionId) return Promise.reject('App version not found');
if (!version.versionId) return Promise.reject(i18nT('common:app_not_version'));
const versionData = await MongoAppVersion.findById(
version.versionId,
'_id versionName appId time'
).lean();
const isLatest = versionData
? await checkIsLatestVersion({
appId,
versionId: versionData._id
})
: true;
return {
id: String(item._id),
@@ -118,7 +138,11 @@ export async function getChildAppPreviewNode({
chatConfig: version.chatConfig
},
templateType: FlowNodeTemplateTypeEnum.teamApp,
version: version.versionId,
versionLabel: versionData?.versionName || '',
isLatestVersion: isLatest,
originCost: 0,
currentCost: 0,
hasTokenFee: false,
@@ -175,7 +199,11 @@ export async function getChildAppPreviewNode({
userGuide: app.userGuide,
showStatus: app.showStatus,
isTool: true,
version: app.version,
versionLabel: app.versionLabel,
isLatestVersion: app.isLatestVersion,
sourceHandle: isToolSet
? getHandleConfig(false, false, false, false)
: getHandleConfig(true, true, true, true),
@@ -224,7 +252,7 @@ export async function getChildAppRuntimeById(
templateType: FlowNodeTemplateTypeEnum.teamApp,
// 用不到
version: item?.pluginData?.nodeVersion || defaultNodeVersion,
version: item?.pluginData?.nodeVersion,
originCost: 0,
currentCost: 0,
hasTokenFee: false,

View File

@@ -1,8 +1,14 @@
import { MongoDataset } from '../dataset/schema';
import { getEmbeddingModel } from '../ai/model';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
AppNodeFlowNodeTypeMap,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { MongoAppVersion } from './version/schema';
import { checkIsLatestVersion } from './version/controller';
import { Types } from '../../common/mongo';
export async function listAppDatasetDataByTeamIdAndDatasetIds({
teamId,
@@ -35,6 +41,45 @@ export async function rewriteAppWorkflowToDetail({
}) {
const datasetIdSet = new Set<string>();
// Add node(App Type) versionlabel and latest sign
const appNodes = nodes.filter((node) => AppNodeFlowNodeTypeMap[node.flowNodeType]);
const versionIds = appNodes
.filter((node) => node.version && Types.ObjectId.isValid(node.version))
.map((node) => node.version);
if (versionIds.length > 0) {
const versionDataList = await MongoAppVersion.find(
{
_id: { $in: versionIds }
},
'_id versionName appId time'
).lean();
const versionMap: Record<string, any> = {};
const isLatestChecks = await Promise.all(
versionDataList.map(async (version) => {
const isLatest = await checkIsLatestVersion({
appId: version.appId,
versionId: version._id
});
return { versionId: String(version._id), isLatest };
})
);
const isLatestMap = new Map(isLatestChecks.map((item) => [item.versionId, item.isLatest]));
versionDataList.forEach((version) => {
versionMap[String(version._id)] = version;
});
appNodes.forEach((node) => {
if (!node.version) return;
const versionData = versionMap[String(node.version)];
if (versionData) {
node.versionLabel = versionData.versionName;
node.isLatestVersion = isLatestMap.get(String(node.version)) || false;
}
});
}
// Get all dataset ids from nodes
nodes.forEach((node) => {
if (node.flowNodeType !== FlowNodeTypeEnum.datasetSearchNode) return;

View File

@@ -57,3 +57,22 @@ export const getAppVersionById = async ({
// If the version does not exist, the latest version is returned
return getAppLatestVersion(appId, app);
};
export const checkIsLatestVersion = async ({
appId,
versionId
}: {
appId: string;
versionId: string;
}) => {
const version = await MongoAppVersion.findOne(
{
appId,
isPublish: true,
_id: { $gt: versionId }
},
'_id'
).lean();
return !version;
};

View File

@@ -723,8 +723,8 @@ async function streamResponse({
}
// Parse tool calls
if (responseChoice?.tool_calls?.length) {
responseChoice.tool_calls.forEach((toolCall) => {
const index = toolCall.index;
responseChoice.tool_calls.forEach((toolCall, i) => {
const index = toolCall.index ?? i;
// Call new tool
if (toolCall.id || callingTool) {

View File

@@ -464,7 +464,7 @@ async function getChatMessages({
aiChatQuoteRole: AiChatQuoteRoleType; // user: replace user prompt; system: replace system prompt
datasetQuotePrompt?: string;
datasetQuoteText: string;
version: string;
version?: string;
useDatasetQuote: boolean;
histories: ChatItemType[];

View File

@@ -11,6 +11,7 @@ import type {
SystemVariablesType
} from '@fastgpt/global/core/workflow/runtime/type';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type.d';
import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io.d';
import type {
AIChatItemValueItemType,
ChatHistoryItemResType,
@@ -549,7 +550,7 @@ export async function dispatchWorkFlow(data: Props): Promise<DispatchFlowRespons
// Skip some special key
if (
[NodeInputKeyEnum.childrenNodeIdList, NodeInputKeyEnum.httpJsonBody].includes(
input.key as any
input.key as NodeInputKeyEnum
)
) {
params[input.key] = input.value;

View File

@@ -15,7 +15,6 @@ import {
useDisclosure,
MenuButton,
Box,
css,
Flex,
Input
} from '@chakra-ui/react';
@@ -32,13 +31,16 @@ import Avatar from '../Avatar';
* list: 列表数据
* isLoading: 是否加载中
* ScrollData: 分页滚动数据控制器 [useScrollPagination] 的返回值
* customOnOpen: 自定义打开回调
* customOnClose: 自定义关闭回调
* */
export type SelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
value?: T;
valueLabel?: string | React.ReactNode;
placeholder?: string;
isSearch?: boolean;
list: {
alias?: string;
alias?: string | React.ReactNode;
icon?: string;
iconSize?: string;
label: string | React.ReactNode;
@@ -49,18 +51,36 @@ export type SelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
isLoading?: boolean;
onChange?: (val: T) => any | Promise<any>;
ScrollData?: ReturnType<typeof useScrollPagination>['ScrollData'];
customOnOpen?: () => void;
customOnClose?: () => void;
};
export const menuItemStyles: MenuItemProps = {
borderRadius: 'sm',
py: 2,
display: 'flex',
alignItems: 'center',
_hover: {
backgroundColor: 'myGray.100'
},
_notLast: {
mb: 1
}
};
const MySelect = <T = any,>(
{
placeholder,
value,
valueLabel,
isSearch = false,
width = '100%',
list = [],
onChange,
isLoading = false,
ScrollData,
customOnOpen,
customOnClose,
...props
}: SelectProps<T>,
ref: ForwardedRef<{
@@ -72,21 +92,19 @@ const MySelect = <T = any,>(
const SelectedItemRef = useRef<HTMLDivElement>(null);
const SearchInputRef = useRef<HTMLInputElement>(null);
const menuItemStyles: MenuItemProps = {
borderRadius: 'sm',
py: 2,
display: 'flex',
alignItems: 'center',
_hover: {
backgroundColor: 'myGray.100'
},
_notLast: {
mb: 1
}
};
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen, onOpen: defaultOnOpen, onClose: defaultOnClose } = useDisclosure();
const selectItem = useMemo(() => list.find((item) => item.value === value), [list, value]);
const onOpen = () => {
defaultOnOpen();
customOnOpen?.();
};
const onClose = () => {
defaultOnClose();
customOnClose?.();
};
const [search, setSearch] = useState('');
const filterList = useMemo(() => {
if (!isSearch || !search) {
@@ -105,6 +123,7 @@ const MySelect = <T = any,>(
}
}));
// Auto scroll
useEffect(() => {
if (isOpen && MenuListRef.current && SelectedItemRef.current) {
const menu = MenuListRef.current;
@@ -117,7 +136,7 @@ const MySelect = <T = any,>(
}
}, [isSearch, isOpen]);
const { runAsync: onclickChange, loading } = useRequest2((val: T) => onChange?.(val));
const { runAsync: onClickChange, loading } = useRequest2((val: T) => onChange?.(val));
const ListRender = useMemo(() => {
return (
@@ -138,7 +157,7 @@ const MySelect = <T = any,>(
})}
onClick={() => {
if (value !== item.value) {
onclickChange(item.value);
onClickChange(item.value);
}
}}
whiteSpace={'pre-wrap'}
@@ -161,7 +180,7 @@ const MySelect = <T = any,>(
))}
</>
);
}, [filterList, value]);
}, [filterList, onClickChange, value]);
const isSelecting = loading || isLoading;
@@ -200,36 +219,48 @@ const MySelect = <T = any,>(
: {})}
{...props}
>
<Flex alignItems={'center'}>
{isSelecting && <MyIcon mr={2} name={'common/loading'} w={'1rem'} />}
{isSearch && isOpen ? (
<Input
ref={SearchInputRef}
autoFocus
variant={'unstyled'}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
selectItem?.alias ||
(typeof selectItem?.label === 'string' ? selectItem?.label : placeholder)
}
size={'sm'}
w={'100%'}
color={'myGray.700'}
onBlur={() => {
setTimeout(() => {
SearchInputRef?.current?.focus();
}, 0);
}}
/>
) : (
<>
{selectItem?.icon && (
<Avatar mr={2} src={selectItem.icon as any} w={selectItem.iconSize ?? '1rem'} />
)}
{selectItem?.alias || selectItem?.label || placeholder}
</>
)}
<Flex alignItems={'center'} justifyContent="space-between" w="100%">
<Flex alignItems={'center'}>
{isSelecting && <MyIcon mr={2} name={'common/loading'} w={'1rem'} />}
{valueLabel ? (
<>{valueLabel}</>
) : (
<>
{isSearch && isOpen ? (
<Input
ref={SearchInputRef}
autoFocus
variant={'unstyled'}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
(typeof selectItem?.alias === 'string' ? selectItem?.alias : '') ||
(typeof selectItem?.label === 'string' ? selectItem?.label : placeholder)
}
size={'sm'}
w={'100%'}
color={'myGray.700'}
onBlur={() => {
setTimeout(() => {
SearchInputRef?.current?.focus();
}, 0);
}}
/>
) : (
<>
{selectItem?.icon && (
<Avatar
mr={2}
src={selectItem.icon as any}
w={selectItem.iconSize ?? '1rem'}
/>
)}
{selectItem?.alias || selectItem?.label || placeholder}
</>
)}
</>
)}
</Flex>
</Flex>
</MenuButton>
@@ -252,7 +283,7 @@ const MySelect = <T = any,>(
'0px 2px 4px rgba(161, 167, 179, 0.25), 0px 0px 1px rgba(121, 141, 159, 0.25);'
}
zIndex={99}
maxH={'40vh'}
maxH={'45vh'}
overflowY={'auto'}
>
{ScrollData ? <ScrollData>{ListRender}</ScrollData> : ListRender}

View File

@@ -190,6 +190,7 @@ export function useScrollPagination<
params = {},
EmptyTip,
showErrorToast = true,
disalbed = false,
...props
}: {
scrollLoadType?: 'top' | 'bottom';
@@ -198,6 +199,7 @@ export function useScrollPagination<
params?: Record<string, any>;
EmptyTip?: React.JSX.Element;
showErrorToast?: boolean;
disalbed?: boolean;
} & Parameters<typeof useRequest2>[1]
) {
const { t } = useTranslation();
@@ -345,10 +347,10 @@ export function useScrollPagination<
// Reload data
useRequest2(
async () => {
if (disalbed) return;
loadData(true);
},
{
manual: false,
...props
}
);

View File

@@ -1,4 +1,6 @@
{
"Click_to_delete_this_field": "Click to delete this field",
"Filed_is_deprecated": "This field is deprecated",
"MCP_tools_list_is_empty": "MCP tool not resolved",
"MCP_tools_parse_failed": "Failed to parse MCP address",
"MCP_tools_url": "MCP Address",
@@ -6,7 +8,6 @@
"MCP_tools_url_placeholder": "After filling in the MCP address, click Analysis",
"Role_setting": "Permission",
"Run": "Execute",
"team_tags_set": "Team tags",
"Team_Tags": "Team tags",
"ai_point_price": "Billing",
"ai_settings": "AI Configuration",
@@ -106,6 +107,7 @@
"no_mcp_tools_list": "No data yet, the MCP address needs to be parsed first",
"node_not_intro": "This node is not introduced",
"not_json_file": "Please select a JSON file",
"not_the_newest": "Not the latest",
"oaste_curl_string": "Enter CURL code",
"open_auto_execute": "Enable automatic execution",
"open_vision_function_tip": "Models with icon switches have image recognition capabilities. \nAfter being turned on, the model will parse the pictures in the file link and automatically parse the pictures in the user's question (user question ≤ 500 words).",
@@ -138,6 +140,7 @@
"stop_sign_placeholder": "Multiple serial numbers are separated by |, for example: aaa|stop",
"stream_response": "Stream",
"stream_response_tip": "Turning this switch off forces the model to use non-streaming mode and will not output content directly. \nIn the output of the AI reply, the content output by this model can be obtained for secondary processing.",
"team_tags_set": "Team tags",
"temperature": "Temperature",
"temperature_tip": "Range 0~10. \nThe larger the value, the more divergent the models answer is; the smaller the value, the more rigorous the answer.",
"template.hard_strict": "Strict Q&A template",

View File

@@ -96,6 +96,7 @@
"add_new_param": "Add new param",
"all_quotes": "All quotes",
"all_result": "Full Results",
"app_not_version": "This application has not been published, please publish it first",
"back": "Back",
"base_config": "Basic Configuration",
"bill_already_processed": "Order has been processed",

View File

@@ -1,4 +1,6 @@
{
"Click_to_delete_this_field": "点击删除该字段",
"Filed_is_deprecated": "该字段已弃用",
"MCP_tools_debug": "调试",
"MCP_tools_detail": "查看详情",
"MCP_tools_list": "工具列表",
@@ -10,7 +12,6 @@
"MCP_tools_url_placeholder": "填入 MCP 地址后,点击解析",
"Role_setting": "权限设置",
"Run": "运行",
"team_tags_set": "团队标签",
"Team_Tags": "团队标签",
"ai_point_price": "AI积分计费",
"ai_settings": "AI 配置",
@@ -110,6 +111,7 @@
"no_mcp_tools_list": "暂无数据,需先解析 MCP 地址",
"node_not_intro": "这个节点没有介绍",
"not_json_file": "请选择JSON文件",
"not_the_newest": "非最新版",
"oaste_curl_string": "输入 CURL 代码",
"open_auto_execute": "启用自动执行",
"open_vision_function_tip": "有图示开关的模型即拥有图片识别能力。若开启模型会解析文件链接里的图片并自动解析用户问题中的图片用户问题≤500字时生效。",
@@ -143,6 +145,7 @@
"stop_sign_placeholder": "多个序列号通过 | 隔开例如aaa|stop",
"stream_response": "流输出",
"stream_response_tip": "关闭该开关,可以强制模型使用非流模式,并且不会直接进行内容输出。可以在 AI 回复的输出中,获取本次模型输出的内容进行二次处理。",
"team_tags_set": "团队标签",
"temperature": "温度",
"temperature_tip": "范围 010。值越大代表模型回答越发散值越小代表回答越严谨。",
"template.hard_strict": "严格问答模板",

View File

@@ -96,6 +96,7 @@
"add_new_param": "新增参数",
"all_quotes": "全部引用",
"all_result": "完整结果",
"app_not_version": " 该应用未发布过,请先发布应用",
"back": "返回",
"base_config": "基础配置",
"bill_already_processed": "订单已处理",

View File

@@ -1,4 +1,6 @@
{
"Click_to_delete_this_field": "點擊刪除該字段",
"Filed_is_deprecated": "該字段已棄用",
"MCP_tools_list_is_empty": "未解析到 MCP 工具",
"MCP_tools_parse_failed": "解析 MCP 地址失敗",
"MCP_tools_url": "MCP 地址",
@@ -6,7 +8,6 @@
"MCP_tools_url_placeholder": "填入 MCP 地址後,點擊解析",
"Role_setting": "權限設定",
"Run": "執行",
"team_tags_set": "團隊標籤",
"Team_Tags": "團隊標籤",
"ai_point_price": "AI 積分計費",
"ai_settings": "AI 設定",
@@ -106,6 +107,7 @@
"no_mcp_tools_list": "暫無數據,需先解析 MCP 地址",
"node_not_intro": "這個節點沒有介紹",
"not_json_file": "請選擇 JSON 檔案",
"not_the_newest": "非最新版",
"oaste_curl_string": "輸入 CURL 代碼",
"open_auto_execute": "啟用自動執行",
"open_vision_function_tip": "有圖示開關的模型即擁有圖片辨識功能。若開啟,模型會解析檔案連結中的圖片,並自動解析使用者問題中的圖片(使用者問題 ≤ 500 字時生效)。",
@@ -138,6 +140,7 @@
"stop_sign_placeholder": "多個序列號透過 | 隔開例如aaa|stop",
"stream_response": "流輸出",
"stream_response_tip": "關閉該開關​​,可以強制模型使用非流模式,並且不會直接進行內容輸出。\n可在 AI 回覆的輸出中,取得本次模型輸出的內容進行二次處理。",
"team_tags_set": "團隊標籤",
"temperature": "溫度",
"temperature_tip": "範圍 010。\n值越大代表模型回答越發散值越小代表回答越嚴謹。",
"template.hard_strict": "嚴格問答範本",

View File

@@ -96,6 +96,7 @@
"add_new_param": "新增參數",
"all_quotes": "全部引用",
"all_result": "完整結果",
"app_not_version": "該應用未發布過,請先發布應用",
"back": "返回",
"base_config": "基本設定",
"bill_already_processed": "訂單已處理",

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import {
getAppVersionDetail,
getWorkflowVersionList,
getAppVersionList,
updateAppVersion
} from '@/web/core/app/api/version';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
@@ -186,7 +186,7 @@ const TeamCloud = ({
ScrollData,
data: scrollDataList,
setData
} = useScrollPagination(getWorkflowVersionList, {
} = useScrollPagination(getAppVersionList, {
pageSize: 30,
params: {
appId: appDetail._id

View File

@@ -24,7 +24,10 @@ import { useSystemStore } from '@/web/common/system/useSystemStore';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { useTranslation } from 'next-i18next';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
AppNodeFlowNodeTypeMap,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import {
getPreviewPluginNode,
getSystemPlugTemplates,
@@ -475,11 +478,7 @@ const RenderList = React.memo(function RenderList({
const templateNode = await (async () => {
try {
// get plugin preview module
if (
template.flowNodeType === FlowNodeTypeEnum.pluginModule ||
template.flowNodeType === FlowNodeTypeEnum.appModule ||
template.flowNodeType === FlowNodeTypeEnum.toolSet
) {
if (AppNodeFlowNodeTypeMap[template.flowNodeType]) {
setLoading(true);
const res = await getPreviewPluginNode({ appId: template.id });
@@ -533,21 +532,25 @@ const RenderList = React.memo(function RenderList({
pluginId: templateNode.pluginId
}),
intro: t(templateNode.intro as any),
inputs: templateNode.inputs.map((input) => ({
...input,
value: defaultValueMap[input.key] ?? input.value,
valueDesc: t(input.valueDesc as any),
label: t(input.label as any),
description: t(input.description as any),
debugLabel: t(input.debugLabel as any),
toolDescription: t(input.toolDescription as any)
})),
outputs: templateNode.outputs.map((output) => ({
...output,
valueDesc: t(output.valueDesc as any),
label: t(output.label as any),
description: t(output.description as any)
}))
inputs: templateNode.inputs
.filter((input) => input.deprecated !== true)
.map((input) => ({
...input,
value: defaultValueMap[input.key] ?? input.value,
valueDesc: t(input.valueDesc as any),
label: t(input.label as any),
description: t(input.description as any),
debugLabel: t(input.debugLabel as any),
toolDescription: t(input.toolDescription as any)
})),
outputs: templateNode.outputs
.filter((output) => output.deprecated !== true)
.map((output) => ({
...output,
valueDesc: t(output.valueDesc as any),
label: t(output.label as any),
description: t(output.description as any)
}))
},
position: { x: mouseX, y: mouseY },
selected: true,

View File

@@ -1,13 +1,15 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Button, Flex, type FlexProps } from '@chakra-ui/react';
import React, { useCallback, useMemo, useRef } from 'react';
import { Box, Button, Flex, HStack, useDisclosure, type FlexProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
import { useTranslation } from 'next-i18next';
import { useEditTitle } from '@/web/common/hooks/useEditTitle';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import {
AppNodeFlowNodeTypeMap,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { LOGO_ICON } from '@fastgpt/global/common/system/constants';
import { ToolSourceHandle, ToolTargetHandle } from './Handle/ToolHandle';
import { useEditTextarea } from '@fastgpt/web/hooks/useEditTextarea';
@@ -28,6 +30,11 @@ import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
import NodeDebugResponse from './RenderDebug/NodeDebugResponse';
import { getAppVersionList } from '@/web/core/app/api/version';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useCreation } from 'ahooks';
type Props = FlowNodeItemType & {
children?: React.ReactNode | React.ReactNode[] | string;
@@ -61,7 +68,6 @@ const NodeCard = (props: Props) => {
w = 'full',
h = 'full',
nodeId,
flowNodeType,
selected,
menuForbid,
isTool = false,
@@ -73,7 +79,6 @@ const NodeCard = (props: Props) => {
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
const onUpdateNodeError = useContextSelector(WorkflowContext, (v) => v.onUpdateNodeError);
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
const onResetNode = useContextSelector(WorkflowContext, (v) => v.onResetNode);
const setHoverNodeId = useContextSelector(WorkflowEventContext, (v) => v.setHoverNodeId);
// custom title edit
@@ -96,6 +101,7 @@ const NodeCard = (props: Props) => {
return { node, parentNode };
}, [nodeList, nodeId]);
const isAppNode = node && AppNodeFlowNodeTypeMap[node?.flowNodeType];
const { data: nodeTemplate } = useRequest2(
async () => {
@@ -103,12 +109,7 @@ const NodeCard = (props: Props) => {
return undefined;
}
if (
node?.flowNodeType === FlowNodeTypeEnum.pluginModule ||
node?.flowNodeType === FlowNodeTypeEnum.appModule ||
node?.flowNodeType === FlowNodeTypeEnum.tool ||
node?.flowNodeType === FlowNodeTypeEnum.toolSet
) {
if (isAppNode) {
return { ...node, ...node.pluginData };
} else {
const template = moduleTemplatesFlat.find(
@@ -132,51 +133,6 @@ const NodeCard = (props: Props) => {
}
);
const {
openConfirm: onOpenConfirmSync,
onClose: onCloseConfirmSync,
ConfirmModal: ConfirmSyncModal
} = useConfirm({
content: t('workflow:Confirm_sync_node')
});
const hasNewVersion = nodeTemplate && nodeTemplate.version !== node?.version;
const { runAsync: onClickSyncVersion } = useRequest2(
async () => {
if (!node) return;
if (node.pluginId) {
const template = await getPreviewPluginNode({ appId: node.pluginId });
if (!!template) {
onResetNode({
id: nodeId,
node: template
});
}
} else {
const template = moduleTemplatesFlat.find(
(item) => item.flowNodeType === node.flowNodeType
);
if (!template) {
return toast({
title: t('app:app.modules.not_found_tips'),
status: 'warning'
});
}
onResetNode({
id: nodeId,
node: template
});
}
},
{
refreshDeps: [node, nodeId, onResetNode],
onFinally() {}
}
);
/* Node header */
const Header = useMemo(() => {
const showHeader = node?.flowNodeType !== FlowNodeTypeEnum.comment;
@@ -255,28 +211,9 @@ const NodeCard = (props: Props) => {
>
<MyIcon name={'edit'} w={'14px'} />
</Button>
<Box flex={1} />
{hasNewVersion && (
<MyTooltip label={t('app:app.modules.click to update')}>
<Button
bg={'yellow.50'}
color={'yellow.600'}
variant={'ghost'}
h={8}
px={2}
rounded={'6px'}
fontSize={'xs'}
fontWeight={'medium'}
cursor={'pointer'}
_hover={{ bg: 'yellow.100' }}
onClick={onOpenConfirmSync(onClickSyncVersion)}
>
<Box>{t('app:app.modules.has new version')}</Box>
<MyIcon name={'help'} w={'14px'} ml={1} />
</Button>
</MyTooltip>
)}
{!!nodeTemplate?.diagram && !hasNewVersion && (
<Box flex={1} mr={1} />
{isAppNode && <NodeVersion node={node} />}
{!!nodeTemplate?.diagram && (
<MyTooltip
label={
<MyImage
@@ -295,7 +232,7 @@ const NodeCard = (props: Props) => {
{!!nodeTemplate?.diagram && node?.courseUrl && (
<Box bg={'myGray.300'} w={'1px'} h={'12px'} ml={1} mr={0.5} />
)}
{!!(node?.courseUrl || nodeTemplate?.userGuide) && !hasNewVersion && (
{!!(node?.courseUrl || nodeTemplate?.userGuide) && (
<UseGuideModal
title={nodeTemplate?.name}
iconSrc={nodeTemplate?.avatar}
@@ -310,7 +247,7 @@ const NodeCard = (props: Props) => {
</UseGuideModal>
)}
{!!node?.pluginData?.error && (
<MyTooltip label={t('app:app.modules.not_found_tips')}>
<MyTooltip label={node?.pluginData?.error || t('app:app.modules.not_found_tips')}>
<Flex
bg={'red.50'}
alignItems={'center'}
@@ -321,7 +258,9 @@ const NodeCard = (props: Props) => {
fontWeight={'medium'}
>
<MyIcon name={'common/errorFill'} w={'14px'} mr={1} />
<Box color={'red.600'}>{t('app:app.modules.not_found')}</Box>
<Box color={'red.600'}>
{node?.pluginData?.error || t('app:app.modules.not_found')}
</Box>
</Flex>
</MyTooltip>
)}
@@ -333,18 +272,14 @@ const NodeCard = (props: Props) => {
</Box>
);
}, [
node?.flowNodeType,
node?.courseUrl,
node?.pluginData?.error,
node,
showToolHandle,
nodeId,
isFolded,
avatar,
t,
name,
hasNewVersion,
onOpenConfirmSync,
onClickSyncVersion,
isAppNode,
nodeTemplate?.diagram,
nodeTemplate?.userGuide,
nodeTemplate?.name,
@@ -419,7 +354,6 @@ const NodeCard = (props: Props) => {
{RenderHandle}
{RenderToolHandle}
<ConfirmSyncModal />
<EditTitleModal maxLength={100} />
</Flex>
);
@@ -663,3 +597,90 @@ const NodeIntro = React.memo(function NodeIntro({
return Render;
});
const NodeVersion = React.memo(function NodeVersion({ node }: { node: FlowNodeItemType }) {
const { t } = useTranslation();
const onResetNode = useContextSelector(WorkflowContext, (v) => v.onResetNode);
const { isOpen, onOpen, onClose } = useDisclosure();
// Load version list
const { ScrollData, data: versionList } = useScrollPagination(getAppVersionList, {
pageSize: 20,
params: {
appId: node.pluginId,
isPublish: true
},
refreshDeps: [node.pluginId, isOpen],
disalbed: !isOpen,
manual: false
});
const { runAsync: onUpdateVersion, loading: isUpdating } = useRequest2(
async (versionId: string) => {
if (!node) return;
if (node.pluginId) {
const template = await getPreviewPluginNode({ appId: node.pluginId, versionId });
if (!!template) {
onResetNode({
id: node.nodeId,
node: {
...template,
name: node.name,
intro: node.intro,
avatar: node.avatar
}
});
}
}
},
{
refreshDeps: [node, onResetNode]
}
);
const renderList = useCreation(
() =>
versionList.map((item) => ({
label: item.versionName,
value: item._id
})),
[node.isLatestVersion, node.version, t, versionList]
);
const valueLabel = useMemo(() => {
return (
<Flex alignItems={'center'} gap={0.5}>
{node?.versionLabel}
{!node.isLatestVersion && (
<MyTag type="fill" colorSchema={'adora'} fontSize={'mini'} borderRadius={'lg'}>
{t('app:not_the_newest')}
</MyTag>
)}
</Flex>
);
}, [node.isLatestVersion, node?.versionLabel, t]);
return (
<MySelect
className="nowheel"
value={node.version}
onChange={onUpdateVersion}
isLoading={isUpdating}
customOnOpen={onOpen}
customOnClose={onClose}
placeholder={node?.versionLabel}
variant={'whitePrimaryOutline'}
size={'sm'}
list={renderList}
ScrollData={(props) => (
<ScrollData minH={'100px'} maxH={'40vh'}>
{props.children}
</ScrollData>
)}
valueLabel={valueLabel}
/>
);
});

View File

@@ -10,6 +10,8 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '@/pageComponents/app/detail/WorkflowComponents/context';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
type Props = {
nodeId: string;
@@ -68,8 +70,38 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => {
</Box>
)}
{input.deprecated && (
<>
<Box flex={'1'} />
<MyTooltip label={t('app:Click_to_delete_this_field')}>
<Flex
px={1.5}
py={1}
bg={'adora.50'}
rounded={'6px'}
fontSize={'14px'}
cursor="pointer"
alignItems={'center'}
_hover={{
bg: 'adora.100'
}}
onClick={() => {
onChangeNode({
nodeId,
type: 'delInput',
key: input.key
});
}}
>
<MyIcon name={'common/info'} color={'adora.600'} w={4} mr={1} />
<Box color={'adora.600'}>{t('app:Filed_is_deprecated')}</Box>
</Flex>
</MyTooltip>
</>
)}
{/* Right Component */}
{RightComponent && (
{!input.deprecated && RightComponent && (
<>
<Box flex={'1'} />
{RightComponent}

View File

@@ -8,11 +8,17 @@ import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { Position } from 'reactflow';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import ValueTypeLabel from '../ValueTypeLabel';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../../context';
const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutputItemType }) => {
const { t } = useTranslation();
const { label = '', description, valueType, valueDesc } = output;
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
return (
<Box position={'relative'}>
<Flex
@@ -36,6 +42,36 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu
</Box>
{description && <QuestionTip ml={1} label={t(description as any)} />}
<ValueTypeLabel valueType={valueType} valueDesc={valueDesc} />
{output.deprecated && (
<>
<Box flex={'1'} />
<MyTooltip label={t('app:Click_to_delete_this_field')}>
<Flex
px={1.5}
py={1}
bg={'adora.50'}
rounded={'6px'}
fontSize={'14px'}
cursor="pointer"
alignItems={'center'}
_hover={{
bg: 'adora.100'
}}
onClick={() => {
onChangeNode({
nodeId,
type: 'delOutput',
key: output.key
});
}}
>
<MyIcon name={'common/info'} color={'adora.600'} w={4} mr={1} />
<Box color={'adora.600'}>{t('app:Filed_is_deprecated')}</Box>
</Flex>
</MyTooltip>
</>
)}
</Flex>
{output.type === FlowNodeOutputTypeEnum.source && (
<SourceHandle

View File

@@ -5,7 +5,6 @@ import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'
import type { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import type { AppSchema } from '@fastgpt/global/core/app/type';
import { defaultNodeVersion } from '@fastgpt/global/core/workflow/node/constant';
import { type ShortUrlParams } from '@fastgpt/global/support/marketing/type';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { TeamAppCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant';
@@ -125,8 +124,7 @@ export const onCreateApp = async ({
chatConfig,
type,
version: 'v2',
pluginData,
'pluginData.nodeVersion': defaultNodeVersion
pluginData
}
],
{ session, ordered: true }

View File

@@ -13,13 +13,13 @@ import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { PluginSourceEnum } from '@fastgpt/global/core/plugin/constants';
export type GetPreviewNodeQuery = { appId: string };
export type GetPreviewNodeQuery = { appId: string; versionId?: string };
async function handler(
req: ApiRequestProps<{}, GetPreviewNodeQuery>,
_res: NextApiResponse<any>
): Promise<FlowNodeTemplateType> {
const { appId } = req.query;
const { appId, versionId } = req.query;
const { source } = await splitCombinePluginId(appId);
@@ -27,7 +27,7 @@ async function handler(
await authApp({ req, authToken: true, appId, per: ReadPermissionVal });
}
return getChildAppPreviewNode({ id: appId });
return getChildAppPreviewNode({ appId, versionId });
}
export default NextAPI(handler);

View File

@@ -11,6 +11,7 @@ import { addSourceMember } from '@fastgpt/service/support/user/utils';
export type versionListBody = PaginationProps<{
appId: string;
isPublish?: boolean;
}>;
export type versionListResponse = PaginationResponse<VersionListItemType>;
@@ -19,16 +20,19 @@ async function handler(
req: ApiRequestProps<versionListBody>,
_res: NextApiResponse<any>
): Promise<versionListResponse> {
const { appId } = req.body;
const { appId, isPublish } = req.body;
const { offset, pageSize } = parsePaginationRequest(req);
await authApp({ appId, req, per: WritePermissionVal, authToken: true });
const match = {
appId,
isPublish
};
const [result, total] = await Promise.all([
(async () => {
const versions = await MongoAppVersion.find({
appId
})
const versions = await MongoAppVersion.find(match)
.sort({
time: -1
})
@@ -45,7 +49,7 @@ async function handler(
}))
);
})(),
MongoAppVersion.countDocuments({ appId })
MongoAppVersion.countDocuments(match)
]);
return {

View File

@@ -146,7 +146,7 @@ export const checkNode = async ({
}: {
node: StoreNodeItemType;
ownerTmbId: string;
}) => {
}): Promise<StoreNodeItemType> => {
const pluginId = node.pluginId;
if (!pluginId) return node;
@@ -160,7 +160,7 @@ export const checkNode = async ({
});
}
const preview = await getChildAppPreviewNode({ id: pluginId });
const preview = await getChildAppPreviewNode({ appId: pluginId });
return {
...node,
pluginData: {
@@ -175,7 +175,6 @@ export const checkNode = async ({
} catch (error: any) {
return {
...node,
isError: true,
pluginData: {
error
} as PluginDataType

View File

@@ -7,7 +7,7 @@ import type {
} from '@fastgpt/global/core/workflow/type/node';
import { getMyApps } from '../api';
import type { ListAppBody } from '@/pages/api/core/app/list';
import { defaultNodeVersion, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { GetPreviewNodeQuery } from '@/pages/api/core/app/plugin/getPreviewNode';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
@@ -47,7 +47,7 @@ export const getTeamPlugTemplates = (data?: ListAppBody) =>
name: app.name,
intro: app.intro,
showStatus: false,
version: app.pluginData?.nodeVersion || defaultNodeVersion,
version: app.pluginData?.nodeVersion,
isTool: true,
sourceMember: app.sourceMember
}))

View File

@@ -15,7 +15,7 @@ export const getAppLatestVersion = (data: getLatestVersionQuery) =>
export const postPublishApp = (appId: string, data: PostPublishAppProps) =>
POST(`/core/app/version/publish?appId=${appId}`, data);
export const getWorkflowVersionList = (data: PaginationProps<{ appId: string }>) =>
export const getAppVersionList = (data: PaginationProps<{ appId: string }>) =>
POST<versionListResponse>('/core/app/version/list', data);
export const getAppVersionDetail = (versionId: string, appId: string) =>

View File

@@ -7,7 +7,6 @@ import {
import { type StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
import {
chatHistoryValueDesc,
defaultNodeVersion,
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
@@ -402,7 +401,6 @@ export function form2AppWorkflow(
y: 545
},
// 这里不需要固定版本,给一个不存在的版本,每次都会用最新版
version: defaultNodeVersion,
pluginData: tool.pluginData,
inputs: tool.inputs.map((input) => {
// Special key value

View File

@@ -9,8 +9,7 @@ import {
EDGE_TYPE,
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum,
defaultNodeVersion
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { EmptyNode } from '@fastgpt/global/core/workflow/template/system/emptyNode';
import { type StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
@@ -101,10 +100,8 @@ export const storeNode2FlowNode = ({
...template,
...storeNode,
avatar: template.avatar ?? storeNode.avatar,
version: storeNode.version ?? template.version ?? defaultNodeVersion,
/*
Inputs and outputs, New fields are added, not reduced
*/
version: template.version || storeNode.version,
// template 中的输入必须都有
inputs: templateInputs
.map<FlowNodeInputItemType>((templateInput) => {
const storeInput =
@@ -113,10 +110,8 @@ export const storeNode2FlowNode = ({
return {
...storeInput,
...templateInput,
debugLabel: t(templateInput.debugLabel ?? (storeInput.debugLabel as any)),
toolDescription: t(templateInput.toolDescription ?? (storeInput.toolDescription as any)),
selectedTypeIndex: storeInput.selectedTypeIndex ?? templateInput.selectedTypeIndex,
value: storeInput.value,
valueType: storeInput.valueType ?? templateInput.valueType,
@@ -124,29 +119,29 @@ export const storeNode2FlowNode = ({
};
})
.concat(
/* Concat dynamic inputs */
// 合并 store 中有template 中没有的输入
storeNode.inputs
.filter((item) => !templateInputs.find((input) => input.key === item.key))
.map((item) => {
if (!dynamicInput) return item;
const templateInput = template.inputs.find((input) => input.key === item.key);
return {
...item,
...getInputComponentProps(dynamicInput)
...getInputComponentProps(dynamicInput),
deprecated: templateInput?.deprecated
};
})
),
outputs: templateOutputs
.map<FlowNodeOutputItemType>((templateOutput) => {
const storeOutput =
template.outputs.find((item) => item.key === templateOutput.key) || templateOutput;
storeNode.outputs.find((item) => item.key === templateOutput.key) || templateOutput;
return {
...storeOutput,
...templateOutput,
description: t(templateOutput.description ?? (storeOutput.description as any)),
id: storeOutput.id ?? templateOutput.id,
label: storeOutput.label ?? templateOutput.label,
value: storeOutput.value ?? templateOutput.value,
@@ -154,9 +149,15 @@ export const storeNode2FlowNode = ({
};
})
.concat(
storeNode.outputs.filter(
(item) => !templateOutputs.find((output) => output.key === item.key)
)
storeNode.outputs
.filter((item) => !templateOutputs.find((output) => output.key === item.key))
.map((item) => {
const templateOutput = template.outputs.find((output) => output.key === item.key);
return {
...item,
deprecated: templateOutput?.deprecated
};
})
)
};

View File

@@ -0,0 +1,86 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { storeNode2FlowNode } from '@/web/core/workflow/utils';
import type { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
describe('storeNode2FlowNode with deprecated inputs/outputs', () => {
beforeEach(() => {
vi.mock('@fastgpt/global/core/workflow/template/constants', () => {
return {
moduleTemplatesFlat: [
{
flowNodeType: 'userInput',
name: 'User Input',
avatar: '',
intro: '',
version: '1.0',
inputs: [
{
key: 'deprecatedInput',
deprecated: true,
label: 'Deprecated Input',
renderTypeList: ['input'],
selectedTypeIndex: 0
}
],
outputs: [
{
key: 'deprecatedOutput',
id: 'deprecatedId',
type: 'input',
deprecated: true,
label: 'Deprecated Output'
}
]
}
]
};
});
});
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
});
it('should handle deprecated inputs and outputs', () => {
const storeNode = {
nodeId: 'node1',
flowNodeType: 'userInput' as FlowNodeTypeEnum,
position: { x: 0, y: 0 },
inputs: [
{
key: 'deprecatedInput',
value: 'old value',
renderTypeList: ['input'],
label: 'Deprecated Input'
}
],
outputs: [
{
key: 'deprecatedOutput',
id: 'deprecatedId',
type: 'input',
label: 'Deprecated Output'
}
],
name: 'Test Node',
version: '1.0'
};
const result = storeNode2FlowNode({
item: storeNode as any,
t: ((key: any) => key) as any
});
const deprecatedInput = result.data.inputs.find((input) => input.key === 'deprecatedInput');
expect(deprecatedInput).toBeDefined();
expect(deprecatedInput?.deprecated).toBe(true);
const deprecatedOutput = result.data.outputs.find(
(output) => output.key === 'deprecatedOutput'
);
expect(deprecatedOutput).toBeDefined();
expect(deprecatedOutput?.deprecated).toBe(true);
});
});

View File

@@ -0,0 +1,113 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { storeNode2FlowNode } from '@/web/core/workflow/utils';
import type { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
describe('storeNode2FlowNode with version and avatar inheritance', () => {
beforeEach(() => {
vi.mock('@fastgpt/global/core/workflow/template/constants', () => {
return {
moduleTemplatesFlat: [
{
flowNodeType: 'userInput',
name: 'User Input',
avatar: 'template-avatar.png',
intro: '',
version: '2.0',
inputs: [],
outputs: []
}
]
};
});
vi.mock('@fastgpt/global/core/workflow/node/constant', () => {
return {
FlowNodeTypeEnum: { userInput: 'userInput' },
FlowNodeInputTypeEnum: {
addInputParam: 'addInputParam',
input: 'input',
reference: 'reference',
textarea: 'textarea',
numberInput: 'numberInput',
switch: 'switch',
select: 'select'
},
FlowNodeOutputTypeEnum: {
dynamic: 'dynamic',
static: 'static',
source: 'source',
hidden: 'hidden'
},
EDGE_TYPE: 'custom-edge',
chatHistoryValueDesc: 'chat history description',
datasetSelectValueDesc: 'dataset value description',
datasetQuoteValueDesc: 'dataset quote value description'
};
});
});
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
});
it('should handle version and avatar inheritance', () => {
// 测试场景1storeNode没有version使用template的version
const storeNode1 = {
nodeId: 'node1',
flowNodeType: 'userInput' as FlowNodeTypeEnum,
position: { x: 0, y: 0 },
inputs: [],
outputs: [],
name: 'Test Node 1'
};
// 测试场景2storeNode没有avatar使用template的avatar
const storeNode2 = {
nodeId: 'node2',
flowNodeType: 'userInput' as FlowNodeTypeEnum,
position: { x: 0, y: 0 },
inputs: [],
outputs: [],
name: 'Test Node 2',
version: '1.0'
};
// 测试场景3storeNode和template都有avatar使用template的avatar
const storeNode3 = {
nodeId: 'node3',
flowNodeType: 'userInput' as FlowNodeTypeEnum,
position: { x: 0, y: 0 },
inputs: [],
outputs: [],
name: 'Test Node 3',
version: '3.0',
avatar: 'store-avatar.png'
};
const result1 = storeNode2FlowNode({
item: storeNode1 as any,
t: ((key: any) => key) as any
});
const result2 = storeNode2FlowNode({
item: storeNode2 as any,
t: ((key: any) => key) as any
});
const result3 = storeNode2FlowNode({
item: storeNode3 as any,
t: ((key: any) => key) as any
});
// 验证版本继承关系
expect(result1.data.version).toBe('2.0'); // 使用template的version
expect(result2.data.version).toBe('2.0'); // 使用storeNode的version
expect(result3.data.version).toBe('2.0'); // 使用storeNode的version
// 验证avatar继承关系
expect(result1.data.avatar).toBe('template-avatar.png'); // 使用template的avatar
expect(result2.data.avatar).toBe('template-avatar.png'); // 使用template的avatar
expect(result3.data.avatar).toBe('template-avatar.png'); // 根据源码应该使用template的avatar
});
});

View File

@@ -0,0 +1,339 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type {
FlowNodeItemType,
FlowNodeTemplateType,
StoreNodeItemType
} from '@fastgpt/global/core/workflow/type/node';
import type { Node, Edge } from 'reactflow';
import {
FlowNodeTypeEnum,
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
EDGE_TYPE
} from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
nodeTemplate2FlowNode,
storeNode2FlowNode,
filterWorkflowNodeOutputsByType,
checkWorkflowNodeAndConnection,
getLatestNodeTemplate
} from '@/web/core/workflow/utils';
import type { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io';
describe('nodeTemplate2FlowNode', () => {
it('should convert template to flow node', () => {
const template: FlowNodeTemplateType = {
id: 'template1',
templateType: 'formInput',
name: 'Test Node',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [],
outputs: []
};
const result = nodeTemplate2FlowNode({
template,
position: { x: 100, y: 100 },
selected: true,
parentNodeId: 'parent1',
t: ((key: any) => key) as any
});
expect(result).toMatchObject({
type: FlowNodeTypeEnum.formInput,
position: { x: 100, y: 100 },
selected: true,
data: {
name: 'Test Node',
flowNodeType: FlowNodeTypeEnum.formInput,
parentNodeId: 'parent1'
}
});
expect(result.id).toBeDefined();
});
});
describe('storeNode2FlowNode', () => {
it('should convert store node to flow node', () => {
const storeNode: StoreNodeItemType = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.formInput,
position: { x: 100, y: 100 },
inputs: [],
outputs: [],
name: 'Test Node',
version: '1.0'
};
const result = storeNode2FlowNode({
item: storeNode,
selected: true,
t: ((key: any) => key) as any
});
expect(result).toMatchObject({
id: 'node1',
type: FlowNodeTypeEnum.formInput,
position: { x: 100, y: 100 },
selected: true
});
});
it('should handle dynamic inputs and outputs', () => {
const storeNode: StoreNodeItemType = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.formInput,
position: { x: 0, y: 0 },
inputs: [
{
key: 'dynamicInput',
label: 'Dynamic Input',
renderTypeList: [FlowNodeInputTypeEnum.addInputParam]
}
],
outputs: [
{
id: 'dynamicOutput',
key: 'dynamicOutput',
label: 'Dynamic Output',
type: FlowNodeOutputTypeEnum.dynamic
}
],
name: 'Test Node',
version: '1.0'
};
const result = storeNode2FlowNode({
item: storeNode,
t: ((key: any) => key) as any
});
expect(result.data.inputs).toHaveLength(3);
expect(result.data.outputs).toHaveLength(2);
});
// 这两个测试涉及到模拟冲突,请运行单独的测试文件:
// - utils.deprecated.test.ts: 测试 deprecated inputs/outputs
// - utils.version.test.ts: 测试 version 和 avatar inheritance
});
describe('filterWorkflowNodeOutputsByType', () => {
it('should filter outputs by type', () => {
const outputs: FlowNodeOutputItemType[] = [
{
id: '1',
valueType: WorkflowIOValueTypeEnum.string,
key: '1',
label: '1',
type: FlowNodeOutputTypeEnum.static
},
{
id: '2',
valueType: WorkflowIOValueTypeEnum.number,
key: '2',
label: '2',
type: FlowNodeOutputTypeEnum.static
},
{
id: '3',
valueType: WorkflowIOValueTypeEnum.boolean,
key: '3',
label: '3',
type: FlowNodeOutputTypeEnum.static
}
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.string);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('should return all outputs for any type', () => {
const outputs: FlowNodeOutputItemType[] = [
{
id: '1',
valueType: WorkflowIOValueTypeEnum.string,
key: '1',
label: '1',
type: FlowNodeOutputTypeEnum.static
},
{
id: '2',
valueType: WorkflowIOValueTypeEnum.number,
key: '2',
label: '2',
type: FlowNodeOutputTypeEnum.static
}
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.any);
expect(result).toHaveLength(2);
});
it('should handle array types correctly', () => {
const outputs: FlowNodeOutputItemType[] = [
{
id: '1',
valueType: WorkflowIOValueTypeEnum.string,
key: '1',
label: '1',
type: FlowNodeOutputTypeEnum.static
},
{
id: '2',
valueType: WorkflowIOValueTypeEnum.arrayString,
key: '2',
label: '2',
type: FlowNodeOutputTypeEnum.static
}
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.arrayString);
expect(result).toHaveLength(2);
});
});
describe('checkWorkflowNodeAndConnection', () => {
it('should validate nodes and connections', () => {
const nodes: Node[] = [
{
id: 'node1',
type: FlowNodeTypeEnum.formInput,
data: {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [
{
key: NodeInputKeyEnum.aiChatDatasetQuote,
required: true,
value: undefined,
renderTypeList: [FlowNodeInputTypeEnum.input]
}
],
outputs: []
},
position: { x: 0, y: 0 }
}
];
const edges: Edge[] = [
{
id: 'edge1',
source: 'node1',
target: 'node2',
type: EDGE_TYPE
}
];
const result = checkWorkflowNodeAndConnection({ nodes, edges });
expect(result).toEqual(['node1']);
});
it('should handle empty nodes and edges', () => {
const result = checkWorkflowNodeAndConnection({ nodes: [], edges: [] });
expect(result).toBeUndefined();
});
});
describe('getLatestNodeTemplate', () => {
it('should update node to latest template version', () => {
const node: FlowNodeItemType = {
id: 'node1',
nodeId: 'node1',
templateType: 'formInput',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [
{
key: 'input1',
value: 'test',
renderTypeList: [FlowNodeInputTypeEnum.input],
label: 'Input 1'
}
],
outputs: [
{
key: 'output1',
value: 'test',
type: FlowNodeOutputTypeEnum.static,
label: 'Output 1',
id: 'output1'
}
],
name: 'Old Name',
intro: 'Old Intro'
};
const template: FlowNodeTemplateType = {
name: 'Template 1',
id: 'template1',
templateType: 'formInput',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [
{ key: 'input1', renderTypeList: [FlowNodeInputTypeEnum.input], label: 'Input 1' },
{ key: 'input2', renderTypeList: [FlowNodeInputTypeEnum.input], label: 'Input 2' }
],
outputs: [
{ id: 'output1', key: 'output1', type: FlowNodeOutputTypeEnum.static, label: 'Output 1' },
{ id: 'output2', key: 'output2', type: FlowNodeOutputTypeEnum.static, label: 'Output 2' }
]
};
const result = getLatestNodeTemplate(node, template);
expect(result.inputs).toHaveLength(2);
expect(result.outputs).toHaveLength(2);
expect(result.name).toBe('Old Name');
});
it('should preserve existing values when updating template', () => {
const node: FlowNodeItemType = {
id: 'node1',
nodeId: 'node1',
templateType: 'formInput',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [
{
key: 'input1',
value: 'existingValue',
renderTypeList: [FlowNodeInputTypeEnum.input],
label: 'Input 1'
}
],
outputs: [
{
key: 'output1',
value: 'existingOutput',
type: FlowNodeOutputTypeEnum.static,
label: 'Output 1',
id: 'output1'
}
],
name: 'Node Name',
intro: 'Node Intro'
};
const template: FlowNodeTemplateType = {
name: 'Template 1',
id: 'template1',
templateType: 'formInput',
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [
{ key: 'input1', renderTypeList: [FlowNodeInputTypeEnum.input], label: 'Input 1' },
{ key: 'input2', renderTypeList: [FlowNodeInputTypeEnum.input], label: 'Input 2' }
],
outputs: [
{ id: 'output1', key: 'output1', type: FlowNodeOutputTypeEnum.static, label: 'Output 1' },
{ id: 'output2', key: 'output2', type: FlowNodeOutputTypeEnum.static, label: 'Output 2' }
]
};
const result = getLatestNodeTemplate(node, template);
expect(result.inputs[0].value).toBe('existingValue');
expect(result.outputs[0].value).toBe('existingOutput');
});
});

View File

@@ -1,4 +1,4 @@
import { ApiType } from './type';
import type { ApiType } from './type';
type OpenAPIParameter = {
name: string;

View File

@@ -20,7 +20,19 @@ vi.mock('@fastgpt/service/core/app/schema', () => ({
lean: vi.fn()
})
},
AppCollectionName: 'apps'
AppCollectionName: 'apps',
chatConfigType: {
welcomeText: String,
variables: Array,
questionGuide: Object,
ttsConfig: Object,
whisperConfig: Object,
scheduledTriggerConfig: Object,
chatInputGuide: Object,
fileSelectConfig: Object,
instruction: String,
autoExecute: Object
}
}));
vi.mock('@fastgpt/service/core/app/version/controller', () => ({

View File

@@ -1,105 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
form2AppWorkflow,
filterSensitiveFormData,
getAppQGuideCustomURL
} from '@/web/core/app/utils';
import { getDefaultAppForm } from '@fastgpt/global/core/app/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { AppSchema } from '@fastgpt/global/core/app/type';
describe('web/core/app/utils', () => {
const mockT = (text: string) => text;
describe('form2AppWorkflow', () => {
it('should generate simple chat workflow', () => {
const form = getDefaultAppForm();
const result = form2AppWorkflow(form, mockT);
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(1);
expect(result.chatConfig).toBeDefined();
});
it('should generate dataset workflow', () => {
const form = getDefaultAppForm();
form.dataset.datasets = ['dataset1'];
const result = form2AppWorkflow(form, mockT);
expect(result.nodes).toHaveLength(4);
expect(result.edges).toHaveLength(2);
});
it('should generate tools workflow', () => {
const form = getDefaultAppForm();
form.selectedTools = [
{
id: 'tool1',
name: 'Tool 1',
flowNodeType: FlowNodeTypeEnum.tools,
inputs: [],
outputs: []
}
];
const result = form2AppWorkflow(form, mockT);
expect(result.nodes.length).toBeGreaterThan(3);
expect(result.edges.length).toBeGreaterThan(1);
});
});
describe('filterSensitiveFormData', () => {
it('should filter sensitive data', () => {
const form = getDefaultAppForm();
form.dataset.datasets = ['sensitive'];
const result = filterSensitiveFormData(form);
expect(result.dataset).toEqual(getDefaultAppForm().dataset);
expect(result).not.toEqual(form);
});
});
describe('getAppQGuideCustomURL', () => {
it('should get custom URL from app detail', () => {
const appDetail = {
modules: [
{
flowNodeType: FlowNodeTypeEnum.systemConfig,
inputs: [
{
key: NodeInputKeyEnum.chatInputGuide,
value: {
customUrl: 'https://example.com'
}
}
]
}
]
} as AppSchema;
const result = getAppQGuideCustomURL(appDetail);
expect(result).toBe('https://example.com');
});
it('should return empty string if no custom URL', () => {
const appDetail = {
modules: [
{
flowNodeType: FlowNodeTypeEnum.systemConfig,
inputs: [
{
key: NodeInputKeyEnum.chatInputGuide,
value: {}
}
]
}
]
} as AppSchema;
const result = getAppQGuideCustomURL(appDetail);
expect(result).toBe('');
});
});
});

View File

@@ -1,237 +0,0 @@
import { vi, describe, it, expect } from 'vitest';
import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { Node, Edge } from 'reactflow';
import {
FlowNodeTypeEnum,
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
EDGE_TYPE
} from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
nodeTemplate2FlowNode,
storeNode2FlowNode,
storeEdgesRenderEdge,
computedNodeInputReference,
getRefData,
filterWorkflowNodeOutputsByType,
checkWorkflowNodeAndConnection,
getLatestNodeTemplate
} from '@/web/core/workflow/utils';
describe('workflow utils', () => {
describe('nodeTemplate2FlowNode', () => {
it('should convert template to flow node', () => {
const template: FlowNodeTemplateType = {
name: 'Test Node',
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [],
outputs: []
};
const result = nodeTemplate2FlowNode({
template,
position: { x: 100, y: 100 },
selected: true,
parentNodeId: 'parent1',
t: (key) => key
});
expect(result).toMatchObject({
type: FlowNodeTypeEnum.userInput,
position: { x: 100, y: 100 },
selected: true,
data: {
name: 'Test Node',
flowNodeType: FlowNodeTypeEnum.userInput,
parentNodeId: 'parent1'
}
});
expect(result.id).toBeDefined();
});
});
describe('storeNode2FlowNode', () => {
it('should convert store node to flow node', () => {
const storeNode: StoreNodeItemType = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.userInput,
position: { x: 100, y: 100 },
inputs: [],
outputs: [],
name: 'Test Node',
version: '1.0'
};
const result = storeNode2FlowNode({
item: storeNode,
selected: true,
t: (key) => key
});
expect(result).toMatchObject({
id: 'node1',
type: FlowNodeTypeEnum.userInput,
position: { x: 100, y: 100 },
selected: true
});
});
it('should handle dynamic inputs and outputs', () => {
const storeNode: StoreNodeItemType = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.userInput,
position: { x: 0, y: 0 },
inputs: [
{
key: 'dynamicInput',
renderTypeList: [FlowNodeInputTypeEnum.addInputParam]
}
],
outputs: [
{
key: 'dynamicOutput',
type: FlowNodeOutputTypeEnum.dynamic
}
],
name: 'Test Node',
version: '1.0'
};
const result = storeNode2FlowNode({
item: storeNode,
t: (key) => key
});
expect(result.data.inputs).toHaveLength(1);
expect(result.data.outputs).toHaveLength(1);
});
});
describe('filterWorkflowNodeOutputsByType', () => {
it('should filter outputs by type', () => {
const outputs = [
{ id: '1', valueType: WorkflowIOValueTypeEnum.string },
{ id: '2', valueType: WorkflowIOValueTypeEnum.number },
{ id: '3', valueType: WorkflowIOValueTypeEnum.boolean }
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.string);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('should return all outputs for any type', () => {
const outputs = [
{ id: '1', valueType: WorkflowIOValueTypeEnum.string },
{ id: '2', valueType: WorkflowIOValueTypeEnum.number }
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.any);
expect(result).toHaveLength(2);
});
it('should handle array types correctly', () => {
const outputs = [
{ id: '1', valueType: WorkflowIOValueTypeEnum.string },
{ id: '2', valueType: WorkflowIOValueTypeEnum.arrayString }
];
const result = filterWorkflowNodeOutputsByType(outputs, WorkflowIOValueTypeEnum.arrayString);
expect(result).toHaveLength(2);
});
});
describe('checkWorkflowNodeAndConnection', () => {
it('should validate nodes and connections', () => {
const nodes: Node[] = [
{
id: 'node1',
type: FlowNodeTypeEnum.userInput,
data: {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [
{
key: NodeInputKeyEnum.userInput,
required: true,
value: undefined,
renderTypeList: [FlowNodeInputTypeEnum.input]
}
],
outputs: []
},
position: { x: 0, y: 0 }
}
];
const edges: Edge[] = [
{
id: 'edge1',
source: 'node1',
target: 'node2',
type: EDGE_TYPE
}
];
const result = checkWorkflowNodeAndConnection({ nodes, edges });
expect(result).toEqual(['node1']);
});
it('should handle empty nodes and edges', () => {
const result = checkWorkflowNodeAndConnection({ nodes: [], edges: [] });
expect(result).toBeUndefined();
});
});
describe('getLatestNodeTemplate', () => {
it('should update node to latest template version', () => {
const node = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [{ key: 'input1', value: 'test' }],
outputs: [{ key: 'output1', value: 'test' }],
name: 'Old Name',
intro: 'Old Intro'
};
const template = {
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [{ key: 'input1' }, { key: 'input2' }],
outputs: [{ key: 'output1' }, { key: 'output2' }]
};
const result = getLatestNodeTemplate(node, template);
expect(result.inputs).toHaveLength(2);
expect(result.outputs).toHaveLength(2);
expect(result.name).toBe('Old Name');
});
it('should preserve existing values when updating template', () => {
const node = {
nodeId: 'node1',
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [{ key: 'input1', value: 'existingValue' }],
outputs: [{ key: 'output1', value: 'existingOutput' }],
name: 'Node Name',
intro: 'Node Intro'
};
const template = {
flowNodeType: FlowNodeTypeEnum.userInput,
inputs: [{ key: 'input1', value: 'newValue' }],
outputs: [{ key: 'output1', value: 'newOutput' }]
};
const result = getLatestNodeTemplate(node, template);
expect(result.inputs[0].value).toBe('existingValue');
expect(result.outputs[0].value).toBe('existingOutput');
});
});
});