Commit 4fbabbd8 by light5980 Committed by GitHub

debug: import compatibility issue (#7099)

* debug: import compatibility issue

* fix: improve app import compatibility

---------

Co-authored-by: archer <545436317@qq.com>
parent e6de4950
---
name: pr-review
description: 当用户传入一个 review 的 pr 链接时候,触发该 skill,对 pr 进行代码审查
description: 仅当用户明确手动指定使用 pr-review skill 时触发;不要因为用户传入 PR 链接、要求 review 或要求代码审查而自动触发
---
# PR Review 代码审查技能
> 按阶段对 Pull Request 进行系统性审查,先验证需求理解与逻辑正确性,再并行进行多维度质量检测,最后提交审查报告。
**触发约束**:本 skill 必须由用户明确点名 `pr-review``$pr-review` 或“使用 pr-review skill”才可使用。普通 PR 链接、`review 下这个 PR``帮我代码审查` 等请求不应自动触发本 skill。
---
## 步骤 0:拉取代码
......
......@@ -209,9 +209,16 @@ const MyMenu = ({
const formatTrigger = !isPc ? 'click' : trigger;
const isIgnoreOutsideClickTarget = (event: Event) => {
return event.composedPath().some((target) => {
return target instanceof HTMLElement && target.dataset.myMenuIgnoreOutsideClick !== undefined;
});
};
useOutsideClick({
ref: ref,
handler: () => {
handler: (event) => {
if (isIgnoreOutsideClickTarget(event)) return;
setIsOpen(false);
}
});
......@@ -245,7 +252,7 @@ const MyMenu = ({
if (formatTrigger === 'hover') {
closeTimer.current = setTimeout(() => {
setIsOpen(false);
}, 100);
}, 250);
}
}}
>
......@@ -294,6 +301,7 @@ const MyMenu = ({
const menuItem = (
<MenuItem
key={index}
w={'100%'}
borderRadius={'sm'}
isDisabled={child.disabled}
onClick={(e) => {
......
......@@ -22,6 +22,8 @@ interface Props extends PopoverContentProps {
onCloseFunc?: () => void;
onOpenFunc?: () => void;
closeOnBlur?: boolean;
usePortal?: boolean;
flip?: boolean;
}
const MyPopover = ({
......@@ -34,6 +36,8 @@ const MyPopover = ({
onOpenFunc,
onCloseFunc,
closeOnBlur = false,
usePortal = true,
flip = true,
onBackdropClick,
...props
}: Props) => {
......@@ -41,6 +45,13 @@ const MyPopover = ({
const { onOpen, onClose, isOpen } = useDisclosure();
const popoverContent = (
<PopoverContent zIndex={1001} {...props}>
{hasArrow && <PopoverArrow />}
{children({ onClose })}
</PopoverContent>
);
return (
<Popover
isOpen={isOpen}
......@@ -55,6 +66,7 @@ const MyPopover = ({
}}
placement={placement}
offset={offset}
flip={flip}
closeOnBlur={closeOnBlur}
trigger={trigger}
openDelay={100}
......@@ -69,12 +81,7 @@ const MyPopover = ({
<Box position="fixed" zIndex={1000} inset={0} onClick={() => onBackdropClick()} />
</Portal>
)}
<Portal>
<PopoverContent zIndex={1001} {...props}>
{hasArrow && <PopoverArrow />}
{children({ onClose })}
</PopoverContent>
</Portal>
{usePortal ? <Portal>{popoverContent}</Portal> : popoverContent}
</Popover>
);
};
......
......@@ -131,20 +131,38 @@ const ExportConfigPopover = ({
return (
<MyPopover
placement={'right-start'}
offset={[0, 20]}
hasArrow
offset={[0, 0]}
hasArrow={false}
trigger={'hover'}
flip={false}
zIndex={2000}
data-my-menu-ignore-outside-click
w={'8.8rem'}
Trigger={
<MyBox display={'flex'} cursor={'pointer'} onClick={(e) => e.stopPropagation()}>
<MyIcon name={'export'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('app:export_configs')}</Box>
<MyBox
display={'flex'}
alignItems={'center'}
w={'100%'}
cursor={'pointer'}
onClick={(e) => e.stopPropagation()}
>
<MyIcon name={'export'} w={'16px'} mr={2} flexShrink={0} />
<Box flex={1} minW={0} fontSize={'sm'}>
{t('app:export_configs')}
</Box>
</MyBox>
}
>
{() => (
<Box p={1} onClick={(e) => e.stopPropagation()}>
<Box
p={1}
w={'100%'}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Flex
w={'100%'}
py={'0.38rem'}
px={1}
color={'myGray.600'}
......@@ -156,10 +174,13 @@ const ExportConfigPopover = ({
borderRadius={'xs'}
onClick={() => onExportWorkflow('copy')}
>
<MyIcon name={'copy'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:copy_to_clipboard')}</Box>
<MyIcon name={'copy'} w={'1rem'} mr={2} flexShrink={0} />
<Box flex={1} minW={0} fontSize={'mini'}>
{t('common:copy_to_clipboard')}
</Box>
</Flex>
<Flex
w={'100%'}
py={'0.38rem'}
px={1}
color={'myGray.600'}
......@@ -171,13 +192,16 @@ const ExportConfigPopover = ({
borderRadius={'xs'}
onClick={() => onExportWorkflow('json')}
>
<MyIcon name={'configmap'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:export_to_json')}</Box>
<MyIcon name={'configmap'} w={'1rem'} mr={2} flexShrink={0} />
<Box flex={1} minW={0} fontSize={'mini'}>
{t('common:export_to_json')}
</Box>
</Flex>
<Divider />
<Divider my={1} />
<Flex
w={'100%'}
py={'0.38rem'}
px={1}
alignItems={'center'}
......
import React, { useState } from 'react';
import { Button, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { Button } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
......@@ -10,6 +10,8 @@ import { removeUnauthModels } from '@fastgpt/global/core/workflow/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { WorkflowUtilsContext } from '../context/workflowUtilsContext';
import { parseWorkflowImportConfig } from '@/pageComponents/dashboard/agent/utils/appTemplateParse';
import { AppContext } from '../../context';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
const ImportAppConfigEditor = dynamic(() => import('@/pageComponents/app/ImportAppConfigEditor'), {
ssr: false
......@@ -23,6 +25,7 @@ const ImportSettings = ({ onClose }: Props) => {
const { toast } = useToast();
const initData = useContextSelector(WorkflowUtilsContext, (v) => v.initData);
const appType = useContextSelector(AppContext, (v) => v.appDetail.type);
const { t } = useTranslation();
const [value, setValue] = useState('');
const { getMyModelList } = useSystemStore();
......@@ -35,18 +38,13 @@ const ImportSettings = ({ onClose }: Props) => {
<MyModal
isOpen
onClose={onClose}
iconSrc="common/importLight"
iconColor="primary.600"
title={t('app:import_configs')}
size={'md'}
>
<ModalBody>
<ImportAppConfigEditor value={value} onChange={setValue} rows={16} />
</ModalBody>
<ModalFooter justifyItems={'flex-end'}>
footer={
<Button
px={5}
py={2}
isDisabled={!value}
onClick={async () => {
if (!value) {
return onClose();
......@@ -54,6 +52,10 @@ const ImportSettings = ({ onClose }: Props) => {
try {
const workflowConfig = parseWorkflowImportConfig({
config: JSON.parse(value),
appType:
appType === AppTypeEnum.workflowTool
? AppTypeEnum.workflowTool
: AppTypeEnum.workflow,
t
});
await removeUnauthModels({ modules: workflowConfig.nodes, allowedModels: myModels });
......@@ -65,7 +67,8 @@ const ImportSettings = ({ onClose }: Props) => {
onClose();
} catch {
toast({
title: t('app:import_configs_failed')
title: t('app:import_configs_failed'),
status: 'error'
});
}
}}
......@@ -73,7 +76,9 @@ const ImportSettings = ({ onClose }: Props) => {
>
{t('common:Save')}
</Button>
</ModalFooter>
}
>
<ImportAppConfigEditor value={value} onChange={setValue} rows={16} />
</MyModal>
);
};
......
......@@ -134,7 +134,7 @@ const JsonImportModal = ({ scene, onClose }: JsonImportModalProps) => {
}
}, [avatar, scene, workflowStr]);
const { runAsync: onSubmit, loading: isCreating } = useRequest(
const { run: onSubmit, loading: isCreating } = useRequest(
async ({ name, intro, workflowStr }: FormType) => {
if ((intro || '').length > 500) {
throw new Error(t('app:app_intro_too_long'));
......@@ -181,7 +181,7 @@ const JsonImportModal = ({ scene, onClose }: JsonImportModalProps) => {
<MyModal
isOpen
onClose={handleCloseJsonImportModal}
isLoading={isCreating || isFetching}
isLoading={isFetching}
title={t('app:type.Import from json')}
size={'md'}
isCentered
......@@ -191,7 +191,11 @@ const JsonImportModal = ({ scene, onClose }: JsonImportModalProps) => {
<Button size={'md'} variant={'whiteBase'} onClick={handleCloseJsonImportModal}>
{t('common:Cancel')}
</Button>
<Button size={'md'} onClick={handleSubmit(onSubmit)}>
<Button
size={'md'}
isLoading={isCreating}
onClick={handleSubmit((data) => onSubmit(data))}
>
{t('common:Confirm')}
</Button>
</>
......
......@@ -22,6 +22,14 @@ type ParsedImportConfig = {
type SupportedImportAppType = ParsedImportConfig['appType'];
type ParseAppImportConfigOptions = {
config: unknown;
t: any;
resolveScene?: JsonImportModalScene;
allowedAppTypes?: readonly SupportedImportAppType[];
expectedAppType?: SupportedImportAppType;
};
const supportedImportAppTypes = [
AppTypeEnum.simple,
AppTypeEnum.workflow,
......@@ -41,6 +49,16 @@ const isSupportedImportAppType = (
): type is (typeof supportedImportAppTypes)[number] =>
supportedImportAppTypes.includes(type as (typeof supportedImportAppTypes)[number]);
const importAppTypeAliasMap: Record<string, SupportedImportAppType> = {
workflowTool: AppTypeEnum.workflowTool
};
const normalizeImportAppType = (type: unknown): SupportedImportAppType | '' => {
if (isSupportedImportAppType(type)) return type;
if (typeof type === 'string') return importAppTypeAliasMap[type] || '';
return '';
};
export const isDashboardImportAppTypeAllowed = ({
appType,
scene
......@@ -81,21 +99,51 @@ export const normalizeSimpleImportForm = (config: Record<string, unknown>) => {
return AppFormEditFormV1TypeSchema.safeParse(form);
};
export const resolveImportAppType = (config: Record<string, unknown>) => {
const assertImportConfigObject = (config: unknown, t: any): Record<string, unknown> => {
if (!config || typeof config !== 'object') {
throw new Error(t('app:type_not_recognized'));
}
return config as Record<string, unknown>;
};
export const resolveImportAppType = (
config: Record<string, unknown>,
scene?: JsonImportModalScene
) => {
const metaType = config.type;
if (metaType !== undefined) {
if (!isSupportedImportAppType(metaType)) {
const appType = normalizeImportAppType(metaType);
if (!appType) {
return '';
}
return metaType;
return appType;
}
if ('nodes' in config && !Array.isArray(config.nodes)) {
return '';
}
if (Array.isArray(config.nodes)) {
const hasPluginInputNode = config.nodes.some(
(node) =>
!!node &&
typeof node === 'object' &&
(node as { flowNodeType?: unknown }).flowNodeType === 'pluginInput'
);
const hasWorkflowStartNode = config.nodes.some(
(node) =>
!!node &&
typeof node === 'object' &&
(node as { flowNodeType?: unknown }).flowNodeType === 'workflowStart'
);
if (scene === 'tool' && hasPluginInputNode) return AppTypeEnum.workflowTool;
if (scene === 'agent' && hasWorkflowStartNode) return AppTypeEnum.workflow;
}
try {
return getAppType(config as any);
} catch {
......@@ -103,62 +151,64 @@ export const resolveImportAppType = (config: Record<string, unknown>) => {
}
};
/**
* 解析工作台 JSON 导入配置。
*
* 顶层 `type` 存在时按导出元信息校验业务结构;无 `type` 时回退
* 现有 `getAppType` 结构识别逻辑,以兼容老版本导出 JSON。
*/
export const parseDashboardImportConfig = ({
config,
scene,
const assertImportAppTypeAllowed = ({
appType,
allowedAppTypes,
expectedAppType,
t
}: {
config: unknown;
scene: JsonImportModalScene;
appType: SupportedImportAppType;
allowedAppTypes?: readonly SupportedImportAppType[];
expectedAppType?: SupportedImportAppType;
t: any;
}): ParsedImportConfig => {
if (!config || typeof config !== 'object') {
}) => {
if (expectedAppType && appType !== expectedAppType) {
throw new Error(t('app:type_not_recognized'));
}
const workflowConfig = config as Record<string, unknown>;
const appType = resolveImportAppType(config as Record<string, unknown>);
if (!appType) {
if (allowedAppTypes && !allowedAppTypes.includes(appType)) {
throw new Error(t('app:type_not_recognized'));
}
};
if (!isDashboardImportAppTypeAllowed({ appType, scene })) {
const parseSimpleImportWorkflow = ({
config,
t
}: {
config: Record<string, unknown>;
t: any;
}): ImportWorkflowConfig => {
if (
!config.aiSettings ||
typeof config.aiSettings !== 'object' ||
Array.isArray(config.aiSettings)
) {
throw new Error(t('app:type_not_recognized'));
}
if (appType === AppTypeEnum.simple) {
if (
!workflowConfig.aiSettings ||
typeof workflowConfig.aiSettings !== 'object' ||
Array.isArray(workflowConfig.aiSettings)
) {
throw new Error(t('app:type_not_recognized'));
}
const parsedForm = normalizeSimpleImportForm(workflowConfig);
if (!parsedForm.success) {
throw new Error(t('app:type_not_recognized'));
}
return {
workflow: form2AppWorkflow(parsedForm.data as AppFormEditFormType, t),
appType
};
const parsedForm = normalizeSimpleImportForm(config);
if (!parsedForm.success) {
throw new Error(t('app:type_not_recognized'));
}
if (!Array.isArray(workflowConfig.nodes)) {
return form2AppWorkflow(parsedForm.data as AppFormEditFormType, t);
};
const parseWorkflowLikeImportConfig = ({
config,
appType,
t
}: {
config: Record<string, unknown>;
appType: Exclude<SupportedImportAppType, AppTypeEnum.simple>;
t: any;
}): ImportWorkflowConfig => {
if (!Array.isArray(config.nodes)) {
throw new Error(t('app:type_not_recognized'));
}
const matchedStartNodeType = appType === AppTypeEnum.workflow ? 'workflowStart' : 'pluginInput';
const hasMatchedStartNode = workflowConfig.nodes.some(
const hasMatchedStartNode = config.nodes.some(
(node) =>
!!node &&
typeof node === 'object' &&
......@@ -170,29 +220,91 @@ export const parseDashboardImportConfig = ({
}
return {
workflow: {
nodes: workflowConfig.nodes as StoreNodeItemType[],
edges: Array.isArray(workflowConfig.edges)
? (workflowConfig.edges as StoreEdgeItemType[])
: [],
chatConfig: (workflowConfig.chatConfig || {}) as AppChatConfigType
},
appType
nodes: config.nodes as StoreNodeItemType[],
edges: Array.isArray(config.edges) ? (config.edges as StoreEdgeItemType[]) : [],
chatConfig: (config.chatConfig || {}) as AppChatConfigType
};
};
/**
* 解析工作流详情内的 JSON 导入配置
* 统一解析导入 JSON
*
* 该入口只允许导入 workflow 配置。导出的 `name`、`intro` 等应用元信息
* 只用于工作台新建应用,工作流内部导入时会忽略。
* 这个入口只处理通用导入流程:识别导入类型、应用调用方约束、按类型解析为
* workflow 数据。工作台导入和详情页导入只负责传入不同约束,避免把场景规则
* 和结构解析散落在多个入口里。
*/
export const parseWorkflowImportConfig = ({ config, t }: { config: unknown; t: any }) => {
const { workflow, appType } = parseDashboardImportConfig({ config, scene: 'agent', t });
export const parseAppImportConfig = ({
config,
t,
resolveScene,
allowedAppTypes,
expectedAppType
}: ParseAppImportConfigOptions): ParsedImportConfig => {
const importConfig = assertImportConfigObject(config, t);
const appType = resolveImportAppType(importConfig, resolveScene);
if (appType !== AppTypeEnum.workflow) {
if (!appType) {
throw new Error(t('app:type_not_recognized'));
}
assertImportAppTypeAllowed({ appType, allowedAppTypes, expectedAppType, t });
return {
workflow:
appType === AppTypeEnum.simple
? parseSimpleImportWorkflow({ config: importConfig, t })
: parseWorkflowLikeImportConfig({ config: importConfig, appType, t }),
appType
};
};
/**
* 解析工作台 JSON 导入配置。
*
* 顶层 `type` 存在时按导出元信息校验业务结构;无 `type` 时回退
* 现有 `getAppType` 结构识别逻辑,以兼容老版本导出 JSON。
*/
export const parseDashboardImportConfig = ({
config,
scene,
t
}: {
config: unknown;
scene: JsonImportModalScene;
t: any;
}): ParsedImportConfig => {
return parseAppImportConfig({
config,
t,
resolveScene: scene,
allowedAppTypes: dashboardImportAppTypesByScene[scene]
});
};
/**
* 解析应用详情内的 JSON 导入配置。
*
* 详情页导入只允许覆盖当前应用同类型的编排配置,避免普通工作流与插件工作流
* 互相导入后缺少各自的入口节点。导出的 `name`、`intro` 等应用元信息只用于
* 工作台新建应用,详情内导入时会忽略。
*/
export const parseWorkflowImportConfig = ({
config,
appType: expectedAppType = AppTypeEnum.workflow,
t
}: {
config: unknown;
appType?: AppTypeEnum.workflow | AppTypeEnum.workflowTool;
t: any;
}) => {
const scene: JsonImportModalScene =
expectedAppType === AppTypeEnum.workflowTool ? 'tool' : 'agent';
const { workflow } = parseAppImportConfig({
config,
t,
resolveScene: scene,
expectedAppType
});
return workflow;
};
......@@ -17,6 +17,7 @@ vi.mock('@/pageComponents/app/detail/Edit/SimpleApp/utils', () => ({
const {
normalizeSimpleImportForm,
parseAppImportConfig,
parseDashboardImportConfig,
parseWorkflowImportConfig,
resolveImportAppType,
......@@ -87,6 +88,10 @@ describe('resolveImportAppType', () => {
expect(resolveImportAppType({ type: 'workflow' })).toBe('');
});
it('should support legacy workflowTool type alias', () => {
expect(resolveImportAppType({ type: 'workflowTool' })).toBe(AppTypeEnum.workflowTool);
});
it('should fall back to old JSON structure detection when type is missing', () => {
expect(resolveImportAppType(createSimpleConfig())).toBe(AppTypeEnum.simple);
expect(
......@@ -103,6 +108,16 @@ describe('resolveImportAppType', () => {
).toBe(AppTypeEnum.workflowTool);
});
it('should prefer plugin input in tool scene for old mixed workflow JSON', () => {
const config = {
nodes: [{ flowNodeType: 'workflowStart' }, { flowNodeType: 'pluginInput' }],
edges: []
};
expect(resolveImportAppType(config, 'tool')).toBe(AppTypeEnum.workflowTool);
expect(resolveImportAppType(config, 'agent')).toBe(AppTypeEnum.workflow);
});
it('should return empty type for malformed node list items', () => {
expect(
resolveImportAppType({
......@@ -169,6 +184,33 @@ describe('parseDashboardImportConfig', () => {
expect(result.appType).toBe(AppTypeEnum.workflowTool);
});
it('should parse legacy workflowTool alias JSON in tool dashboard', () => {
const result = parseDashboardImportConfig({
config: {
type: 'workflowTool',
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
scene: 'tool',
t
});
expect(result.appType).toBe(AppTypeEnum.workflowTool);
});
it('should parse old mixed workflow JSON as workflow tool in tool dashboard', () => {
const result = parseDashboardImportConfig({
config: {
nodes: [{ flowNodeType: 'workflowStart' }, { flowNodeType: 'pluginInput' }],
edges: []
},
scene: 'tool',
t
});
expect(result.appType).toBe(AppTypeEnum.workflowTool);
});
it('should reject workflow tool JSON in agent dashboard', () => {
expect(() =>
parseDashboardImportConfig({
......@@ -275,12 +317,51 @@ describe('isDashboardImportAppTypeAllowed', () => {
expect(
isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflowTool, scene: 'agent' })
).toBe(false);
expect(isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.simple, scene: 'tool' })).toBe(
false
);
expect(isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflow, scene: 'tool' })).toBe(
false
);
expect(
isDashboardImportAppTypeAllowed({ appType: AppTypeEnum.workflowTool, scene: 'tool' })
).toBe(true);
});
});
describe('parseAppImportConfig', () => {
it('should parse through the shared import entry with caller constraints', () => {
const result = parseAppImportConfig({
config: {
type: 'workflowTool',
nodes: [{ flowNodeType: 'pluginInput' }],
edges: []
},
resolveScene: 'tool',
expectedAppType: AppTypeEnum.workflowTool,
t
});
expect(result.appType).toBe(AppTypeEnum.workflowTool);
expect(result.workflow.nodes).toEqual([{ flowNodeType: 'pluginInput' }]);
});
it('should reject mismatched app type in the shared import entry', () => {
expect(() =>
parseAppImportConfig({
config: {
type: AppTypeEnum.workflow,
nodes: [{ flowNodeType: 'workflowStart' }],
edges: []
},
resolveScene: 'agent',
expectedAppType: AppTypeEnum.workflowTool,
t
})
).toThrow('app:type_not_recognized');
});
});
describe('parseWorkflowImportConfig', () => {
it('should parse workflow JSON and ignore app meta in workflow detail import', () => {
const result = parseWorkflowImportConfig({
......@@ -302,7 +383,28 @@ describe('parseWorkflowImportConfig', () => {
});
});
it('should reject non-workflow JSON in workflow detail import', () => {
it('should parse workflow tool JSON in workflow tool detail import', () => {
const result = parseWorkflowImportConfig({
config: {
type: AppTypeEnum.workflowTool,
name: 'Tool name',
intro: 'Tool intro',
nodes: [{ flowNodeType: 'pluginInput' }, { flowNodeType: 'pluginOutput' }],
edges: [],
chatConfig: { welcomeText: 'plugin hello' }
},
appType: AppTypeEnum.workflowTool,
t
});
expect(result).toEqual({
nodes: [{ flowNodeType: 'pluginInput' }, { flowNodeType: 'pluginOutput' }],
edges: [],
chatConfig: { welcomeText: 'plugin hello' }
});
});
it('should reject mismatched JSON in workflow detail import', () => {
expect(() =>
parseWorkflowImportConfig({
config: {
......@@ -316,6 +418,18 @@ describe('parseWorkflowImportConfig', () => {
expect(() =>
parseWorkflowImportConfig({
config: {
type: AppTypeEnum.workflow,
nodes: [{ flowNodeType: 'workflowStart' }],
edges: []
},
appType: AppTypeEnum.workflowTool,
t
})
).toThrow('app:type_not_recognized');
expect(() =>
parseWorkflowImportConfig({
config: createSimpleConfig({
type: AppTypeEnum.simple
}),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment