Commit 5c1d1bd2 by Archer Committed by GitHub

Update admin modal migration (#7466)

* update admin modal dependencies

* fix: workflow tool ui

* fix: response data
parent 5a048432
......@@ -57,8 +57,17 @@ export const dispatchPluginInput = async (
const val = params[key];
const maxFiles = fileInputMaxFiles.get(key);
if (maxFiles !== undefined && Array.isArray(val)) {
// 文件选择器未选文件时可能提交无 key/url 的占位项,应按空输入处理。
const fileItems = val
.filter((fileItem) => {
if (typeof fileItem === 'string') return fileItem.trim().length > 0;
return (
!!fileItem && typeof fileItem === 'object' && Boolean(fileItem.key || fileItem.url)
);
})
.slice(0, maxFiles);
const fileUrls = await Promise.all(
val.slice(0, maxFiles).map(async (fileItem) => {
fileItems.map(async (fileItem) => {
const storeValue =
typeof fileItem === 'string'
? normalizeChatFileStoreValue({ url: fileItem })
......
......@@ -204,6 +204,21 @@ describe('dispatchPluginInput', () => {
expect(result.data?.upload).toEqual(['https://external.example.com/1.pdf']);
});
it('treats empty file selector placeholders as no file input', async () => {
const result = await runWithMockFileContext(() =>
dispatchPluginInput({
params: {
upload: [{ type: ChatFileTypeEnum.file }]
},
query: [],
node: pluginInputNode
} as any)
);
expect(result.data?.upload).toEqual([]);
expect(mockRegisterInputFile).not.toHaveBeenCalled();
});
it('reuses a file already selected in the current child context', async () => {
mockResolveInputFile.mockReturnValueOnce({
modelUrl: 'https://parent.example.com/signed.pdf'
......
Subproject commit c4367e0522a30b03d2d38271e954cb1e6161470d
Subproject commit 18869dd92cd3f0559918e9234f39e415114ae402
......@@ -131,7 +131,7 @@ const RenderInput = () => {
const isDisabledInput = !!hasHistory;
return (
<Box>
<>
{/* instruction */}
{instruction && (
<Box
......@@ -273,7 +273,7 @@ const RenderInput = () => {
</Button>
</Flex>
)}
</Box>
</>
);
};
......
import { ResponseBox } from '../../../components/WholeResponseModal';
import React, { useMemo } from 'react';
import { WorkflowToolResponseBox } from '../../../components/WholeResponseModal/WorkflowToolResponseBox';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { PluginRunContext } from '../context';
import { Box } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
......@@ -14,22 +13,20 @@ const RenderResponseDetail = () => {
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const isChatting = useContextSelector(PluginRunContext, (v) => v.isChatting);
const aiRecord = useMemo(
() => [...chatRecords].reverse().find((item) => item.obj === ChatRoleEnum.AI),
[chatRecords]
);
const responseData = aiRecord?.responseData || [];
// 流式响应可能更新记录对象本身,直接读取最新的 AI 记录,避免按数组引用缓存旧结果。
const aiRecord = [...chatRecords].reverse().find((item) => item.obj === ChatRoleEnum.AI);
const responseData = aiRecord?.responseData ?? [];
return isChatting ? (
<>{t('chat:in_progress')}</>
) : (
<Box flex={'1 0 0'} h={'100%'} overflow={'auto'}>
<>
{responseData.length > 0 ? (
<ResponseBox useMobile={true} response={responseData} dataId={aiRecord?.dataId} />
<WorkflowToolResponseBox response={responseData} dataId={aiRecord?.dataId} />
) : (
<EmptyTip text={t('chat:response.no_workflow_response')} />
)}
</Box>
</>
);
};
......
import React, { type ReactNode, useCallback, useMemo, useRef } from 'react';
import React, { type ReactNode, useCallback, useRef } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import { type PluginRunBoxProps } from './type';
import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
......@@ -150,12 +150,8 @@ const PluginRunContextProvider = ({
[setChatRecords, resetVariables]
);
const isChatting = useMemo(
() =>
chatRecords[chatRecords.length - 1] &&
chatRecords[chatRecords.length - 1]?.status !== 'finish',
[chatRecords]
);
const isChatting =
chatRecords[chatRecords.length - 1] && chatRecords[chatRecords.length - 1]?.status !== 'finish';
const onSubmit = useCallback(
async ({ variables }: ChatBoxInputFormType) => {
......@@ -243,7 +239,8 @@ const PluginRunContextProvider = ({
})
);
} catch (err: any) {
toast({ title: err.message, status: 'error' });
const errorMsg = t(getErrText(err, t('common:core.chat.error.Chat error') as any));
toast({ title: errorMsg, status: 'error' });
setChatRecords((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
......
......@@ -7,7 +7,6 @@ import { useContextSelector } from 'use-context-selector';
import RenderOutput from './components/RenderOutput';
import RenderResponseDetail from './components/RenderResponseDetail';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { Box } from '@chakra-ui/react';
const PluginRunBox = (props: PluginRunBoxProps) => {
const tab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
......@@ -15,11 +14,11 @@ const PluginRunBox = (props: PluginRunBoxProps) => {
return (
<PluginRunContextProvider {...props}>
<Box h={'100%'} minH={0} display={'flex'} flexDirection={'column'}>
<>
{formatTab === PluginRunBoxTabEnum.input && <RenderInput />}
{formatTab === PluginRunBoxTabEnum.output && <RenderOutput />}
{formatTab === PluginRunBoxTabEnum.detail && <RenderResponseDetail />}
</Box>
</>
</PluginRunContextProvider>
);
};
......
......@@ -20,13 +20,11 @@ const sideTabDeepTreeMinDepth = 4;
export const ResponseBox = React.memo(function ResponseBox({
response,
dataId,
hideTabs = false,
useMobile = false
hideTabs = false
}: {
response: ChatHistoryItemResType[];
dataId?: string;
hideTabs?: boolean;
useMobile?: boolean;
}) {
const { t } = useSafeTranslation();
const { isPc } = useSystem();
......@@ -79,7 +77,7 @@ export const ResponseBox = React.memo(function ResponseBox({
return (
<>
{isPc && !useMobile ? (
{isPc ? (
<Flex
overflow={'hidden'}
height={'100%'}
......
......@@ -32,7 +32,9 @@ export const WholeResponseContent = ({
minH={0}
ref={contentRef}
py={3}
px={hideTabs ? 4 : 3}
// 详情页移动端需要让内容贴合滚动容器,水平留白由外层面板负责。
// 桌面端保留原有留白,避免改变完整结果的布局。
px={hideTabs ? [0, 4] : 3}
display={'flex'}
flexDirection={'column'}
gap={3}
......
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Flex, useDisclosure } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import { WholeResponseSideTab } from './SideTab';
import { WholeResponseContent } from './WholeResponseContent';
import { flattenResponse, getSideTabItems } from './responseData';
const RequestIdDetailModal = dynamic(() => import('@/components/core/ai/requestId'));
/**
* 工作流工具专用完整结果:列表沿用工具面板的父级滚动,详情覆盖整个结果区域。
*/
export const WorkflowToolResponseBox = React.memo(function WorkflowToolResponseBox({
response,
dataId
}: {
response: ChatHistoryItemResType[];
dataId?: string;
}) {
const { t } = useSafeTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const [selectedRequestId, setSelectedRequestId] = useState<string>();
// 流式运行期间 response 可能原地追加内容,不能按数组引用缓存计算结果。
const flattedResponse = flattenResponse(response);
const [currentNodeId, setCurrentNodeId] = useState(
flattedResponse[0]?.id ?? flattedResponse[0]?.nodeId ?? ''
);
const firstNodeId = flattedResponse[0]?.id ?? flattedResponse[0]?.nodeId ?? '';
const hasCurrentNode = flattedResponse.some((item) => item.id === currentNodeId);
useEffect(() => {
if (!firstNodeId) {
setCurrentNodeId('');
return;
}
if (hasCurrentNode) return;
setCurrentNodeId(firstNodeId);
}, [currentNodeId, firstNodeId, hasCurrentNode]);
const activeModule = (flattedResponse.find((item) => item.id === currentNodeId) ||
flattedResponse[0]) as ChatHistoryItemResType;
// 流式响应会暂时按 parentId 挂成树,工具面板需要和历史结果保持一致,逐节点展示完整执行链路。
const sliderResponseList = getSideTabItems(flattedResponse);
const { isOpen: isOpenDetail, onOpen: onOpenDetail, onClose: onCloseDetail } = useDisclosure();
const handleOpenRequestIdDetail = useCallback((requestId: string) => {
setSelectedRequestId(requestId);
}, []);
return (
<Box ref={rootRef} minH={'100%'} h={'100%'}>
{isOpenDetail ? (
<Flex bg={'white'} flexDirection={'column'} h={'100%'} minH={0}>
<Flex
align={'center'}
justifyContent={'center'}
px={2}
py={2}
borderBottom={'sm'}
position={'relative'}
height={'40px'}
flexShrink={0}
>
<MyIcon
width={4}
height={4}
name="common/backLight"
onClick={(e) => {
e.stopPropagation();
onCloseDetail();
}}
position={'absolute'}
left={2}
top={'50%'}
transform={'translateY(-50%)'}
cursor={'pointer'}
_hover={{ color: 'primary.500' }}
/>
<Avatar
src={
activeModule.moduleLogo ||
moduleTemplatesFlat.find(
(template) => activeModule.moduleType === template.flowNodeType
)?.avatar
}
w={'1.25rem'}
h={'1.25rem'}
borderRadius={'sm'}
/>
<Box ml={1.5} lineHeight={'1.25rem'} alignItems={'center'}>
{t(activeModule.moduleName as any, activeModule.moduleNameArgs)}
</Box>
</Flex>
<Box flex={'1 1 0'} minH={0} overflowY={'auto'}>
<WholeResponseContent
dataId={dataId}
activeModule={activeModule}
hideTabs={true}
onOpenRequestIdDetail={handleOpenRequestIdDetail}
/>
</Box>
</Flex>
) : (
<WholeResponseSideTab
response={sliderResponseList}
value={currentNodeId}
onChange={(item: string) => {
setCurrentNodeId(item);
rootRef.current?.parentElement?.scrollTo({ top: 0 });
onOpenDetail();
}}
isMobile={true}
/>
)}
{selectedRequestId && (
<RequestIdDetailModal
onClose={() => setSelectedRequestId(undefined)}
requestId={selectedRequestId}
/>
)}
</Box>
);
});
......@@ -193,7 +193,16 @@ const DetailLogsModal = ({
<Flex flex={'1 0 0'} h={0}>
<Box flex={'1 0 0'} h={'100%'} minH={0} overflow={isPlugin ? 'hidden' : 'auto'}>
{isPlugin ? (
<Box px={5} py={2} h={'100%'} minH={0} display={'flex'} flexDirection={'column'}>
<Box
px={5}
py={2}
h={'100%'}
minH={0}
minW={0}
overflowY={'auto'}
display={'flex'}
flexDirection={'column'}
>
<PluginRunBox appId={appId} chatId={chatId} />
</Box>
) : (
......
......@@ -10,7 +10,6 @@ import { type StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import dynamic from 'next/dynamic';
import { Box } from '@chakra-ui/react';
import { type AppChatConfigType } from '@fastgpt/global/core/app/type';
import ChatBox from '@/components/core/chat/ChatContainer/ChatBox';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
......@@ -21,6 +20,7 @@ import { useTranslation } from 'next-i18next';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getAppChatSourceKey } from '@/web/core/chat/utils';
import { Box } from '@chakra-ui/react';
const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
......@@ -172,7 +172,15 @@ export const useChatTest = ({
const CustomChatContainer = useMemoizedFn(() =>
appDetail.type === AppTypeEnum.workflowTool ? (
<Box p={5} h={'100%'} minH={0} display={'flex'} flexDirection={'column'}>
<Box
p={5}
h={'100%'}
minH={0}
minW={0}
overflowY={'auto'}
display={'flex'}
flexDirection={'column'}
>
<PluginRunBox
appId={appId}
chatId={chatId}
......
......@@ -174,7 +174,7 @@ const AppChatWindow = () => {
);
return (
<Flex h={'100%'} flexDirection={['column', 'row']}>
<Flex h={'100%'} minH={0} minW={0} flexDirection={['column', 'row']}>
{/* set window title and icon */}
<NextHead
title={isCurrentChatReady ? chatBoxData.app.name : undefined}
......@@ -202,64 +202,69 @@ const AppChatWindow = () => {
<Flex
position={'relative'}
h={[0, '100%']}
minH={0}
minW={0}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
>
{isPc ? (
<ChatWindowHeader
title={chatWindowTitle}
history={chatRecords}
chatType={ChatTypeEnum.chat}
rightActions={<SandboxEntryIcon onOpen={onOpenSandboxModal} />}
/>
) : (
<Flex
h="48px"
px={4}
bg="white"
alignItems="center"
justifyContent="space-between"
color="myGray.600"
>
<IconButton
aria-label="Open history"
icon={<MyIcon name="core/chat/sidebar/menu" w="20px" h="20px" color="currentColor" />}
variant="unstyled"
{...mobileChatHeaderIconButtonStyle}
onClick={onOpenSlider}
{!isPlugin &&
(isPc ? (
<ChatWindowHeader
title={chatWindowTitle}
history={chatRecords}
chatType={ChatTypeEnum.chat}
rightActions={<SandboxEntryIcon onOpen={onOpenSandboxModal} />}
/>
) : (
<Flex
h="48px"
px={4}
bg="white"
alignItems="center"
justifyContent="space-between"
color="myGray.600"
>
<IconButton
aria-label="Open history"
icon={
<MyIcon name="core/chat/sidebar/menu" w="20px" h="20px" color="currentColor" />
}
variant="unstyled"
{...mobileChatHeaderIconButtonStyle}
onClick={onOpenSlider}
/>
<Flex alignItems="center" minW={0} flex="1" justifyContent="center" px={3} gap={2}>
{isCurrentChatReady && (
<Avatar
src={chatBoxData.app.avatar}
w="24px"
h="24px"
borderRadius="6px"
flexShrink={0}
/>
)}
<Box
minW={0}
fontSize="16px"
fontWeight={500}
color="myGray.900"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="clip"
>
{mobileHeaderTitle}
<Flex alignItems="center" minW={0} flex="1" justifyContent="center" px={3} gap={2}>
{isCurrentChatReady && (
<Avatar
src={chatBoxData.app.avatar}
w="24px"
h="24px"
borderRadius="6px"
flexShrink={0}
/>
)}
<Box
minW={0}
fontSize="16px"
fontWeight={500}
color="myGray.900"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="clip"
>
{mobileHeaderTitle}
</Box>
</Flex>
<Box minW="36px">
<ToolMenu history={chatRecords} chatType={ChatTypeEnum.chat} />
</Box>
</Flex>
))}
<Box minW="36px">
<ToolMenu history={chatRecords} chatType={ChatTypeEnum.chat} />
</Box>
</Flex>
)}
<Box flex={'1 0 0'} bg={'white'}>
<Box flex={'1 0 0'} minH={0} minW={0} overflow={'hidden'} bg={'white'}>
{isPlugin ? (
<CustomPluginRunBox
appId={appId}
......
......@@ -23,14 +23,32 @@ const CustomPluginRunBox = (props: PluginRunBoxProps) => {
}, [isPc, setTab, tab]);
return isPc ? (
<Grid gridTemplateColumns={'450px 1fr'} h={'100%'}>
<Box px={3} py={4} borderRight={'base'} h={'100%'} overflowY={'auto'} w={'100%'}>
<Grid gridTemplateColumns={'450px 1fr'} h={'100%'} minH={0} minW={0}>
<Box
px={3}
py={4}
borderRight={'base'}
h={'100%'}
minH={0}
minW={0}
overflowY={'auto'}
w={'100%'}
>
<Box color={'myGray.900'} pb={5}>
{t('common:Input')}
</Box>
<PluginRunBox {...props} showTab={PluginRunBoxTabEnum.input} />
</Box>
<Stack px={3} py={4} h={'100%'} alignItems={'flex-start'} w={'100%'} overflow={'auto'}>
<Stack
px={3}
py={4}
h={'100%'}
minH={0}
minW={0}
alignItems={'flex-start'}
w={'100%'}
overflow={'hidden'}
>
<Box display={'inline-block'}>
<LightRowTabs<PluginRunBoxTabEnum>
list={[
......@@ -48,31 +66,35 @@ const CustomPluginRunBox = (props: PluginRunBoxProps) => {
fontSize={'sm'}
/>
</Box>
<Box flex={'1 0 0'} overflow={'auto'} w={'100%'}>
<Box flex={'1 0 0'} minH={0} minW={0} overflowY={'auto'} w={'100%'}>
<PluginRunBox {...props} />
</Box>
</Stack>
</Grid>
) : (
<Stack py={2} px={4} h={'100%'}>
<LightRowTabs<PluginRunBoxTabEnum>
list={[
{ label: t('common:Input'), value: PluginRunBoxTabEnum.input },
{ label: t('common:Output'), value: PluginRunBoxTabEnum.output },
{ label: t('common:all_result'), value: PluginRunBoxTabEnum.detail }
]}
value={tab}
onChange={setTab}
inlineStyles={{ px: 0.5, pt: 0 }}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
gap={5}
py={0}
fontSize={'sm'}
/>
<Box flex={'1 0 0'} w={'100%'}>
<PluginRunBox {...props} />
<Stack pt={2} h={'100%'} minH={0} minW={0} overflow={'hidden'}>
<Box px={4}>
<LightRowTabs<PluginRunBoxTabEnum>
list={[
{ label: t('common:Input'), value: PluginRunBoxTabEnum.input },
{ label: t('common:Output'), value: PluginRunBoxTabEnum.output },
{ label: t('common:all_result'), value: PluginRunBoxTabEnum.detail }
]}
value={tab}
onChange={setTab}
inlineStyles={{ px: 0.5, pt: 0 }}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
gap={5}
py={0}
fontSize={'sm'}
/>
</Box>
<Box flex={'1 0 0'} minH={0} minW={0} overflowY={'auto'} w={'100%'}>
<Box px={4} pb={2}>
<PluginRunBox {...props} />
</Box>
</Box>
</Stack>
);
......
......@@ -53,15 +53,17 @@ export async function handler(req: ApiRequestProps): Promise<GetPaginationRecord
chatId,
outLinkAuthData
});
// 后续查询统一使用鉴权解析后的来源,避免分享链接请求体缺少 sourceType 时降级为普通对话。
const resolvedSourceType = authRes.sourceType;
const resolvedSourceId = authRes.sourceId;
const [app] = await Promise.all([
sourceType === ChatSourceTypeEnum.app
resolvedSourceType === ChatSourceTypeEnum.app
? MongoApp.findById(resolvedSourceId, 'type').lean()
: null
]);
if (sourceType === ChatSourceTypeEnum.app && !app) {
if (resolvedSourceType === ChatSourceTypeEnum.app && !app) {
return Promise.reject(AppErrEnum.unExist);
}
const isPlugin = app?.type === AppTypeEnum.workflowTool;
......@@ -76,7 +78,7 @@ export async function handler(req: ApiRequestProps): Promise<GetPaginationRecord
};
const { total, histories: sourceHistories } = await getChatItems({
sourceType,
sourceType: resolvedSourceType,
sourceId: resolvedSourceId,
chatId,
field: fieldMap[type],
......
......@@ -55,14 +55,16 @@ async function handler(req: ApiRequestProps): Promise<GetRecordsV2ResponseType>
sourceId,
chatId
});
// 后续查询统一使用鉴权解析后的来源,避免分享链接请求体缺少 sourceType 时降级为普通对话。
const resolvedSourceType = authRes.sourceType;
const resolvedSourceId = authRes.sourceId;
const app =
sourceType === ChatSourceTypeEnum.app
resolvedSourceType === ChatSourceTypeEnum.app
? await MongoApp.findById(resolvedSourceId, 'type').lean()
: null;
if (sourceType === ChatSourceTypeEnum.app && !app) {
if (resolvedSourceType === ChatSourceTypeEnum.app && !app) {
return Promise.reject(AppErrEnum.unExist);
}
const isPlugin = app?.type === AppTypeEnum.workflowTool;
......@@ -78,7 +80,7 @@ async function handler(req: ApiRequestProps): Promise<GetRecordsV2ResponseType>
const result = await getChatItems({
includeDeleted,
sourceType,
sourceType: resolvedSourceType,
sourceId: resolvedSourceId,
chatId,
field: fieldMap[type],
......
......@@ -632,7 +632,8 @@ const authShareChat = async ({
app,
apikey: '',
authType,
responseAllData: false,
// 工作流工具的分享运行需要把完整节点链路返回给运行面板;普通对话仍按公开字段过滤。
responseAllData: app.type === AppTypeEnum.workflowTool,
showCite,
outLinkUserId: uid,
showRunningStatus,
......
......@@ -632,7 +632,8 @@ const authShareChat = async ({
app,
apikey: '',
authType,
responseAllData: false,
// 工作流工具的分享运行需要把完整节点链路返回给运行面板;普通对话仍按公开字段过滤。
responseAllData: app.type === AppTypeEnum.workflowTool,
showCite,
outLinkUserId: uid,
showRunningStatus,
......
......@@ -304,6 +304,8 @@ const OutLink = (props: Props) => {
/>
<Flex
h={'full'}
minH={0}
minW={0}
gap={datasetCiteData ? 0 : 4}
{...(isEmbed ? { p: '0 !important', borderRadius: '0', boxShadow: 'none' } : { p: [0, 5] })}
>
......@@ -311,6 +313,8 @@ const OutLink = (props: Props) => {
<PageContainer
flex={'1 0 0'}
w={0}
minH={0}
minW={0}
p={'0 !important'}
insertProps={
datasetCiteData
......@@ -320,19 +324,22 @@ const OutLink = (props: Props) => {
: undefined
}
>
<Flex h={'100%'} flexDirection={['column', 'row']}>
<Flex h={'100%'} minH={0} minW={0} flexDirection={['column', 'row']}>
{RenderHistoryList}
{/* chat container */}
<Flex
position={'relative'}
h={[0, '100%']}
minH={0}
minW={0}
w={['100%', 0]}
flex={'1 0 0'}
flexDirection={'column'}
>
{/* header */}
{showHead === '1' &&
!isPlugin &&
(isPc ? (
<ChatWindowHeader
title={chatWindowTitle}
......@@ -414,7 +421,7 @@ const OutLink = (props: Props) => {
</Flex>
))}
{/* chat box */}
<Box flex={1} bg={'white'}>
<Box flex={1} minH={0} minW={0} overflow={'hidden'} bg={'white'}>
{isPlugin ? (
<CustomPluginRunBox
appId={appId}
......
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