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

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