4.8.8 test fix (#2143)

* perf: transcriptions api

* perf: variable picker tip

* perf: variable picker tip

* perf: chat select app

* feat: router to app detail

* perf: variable avoid space

* perf: variable picker

* perf: doc2x icon and params

* perf: sandbox support countToken

* feat: sandbox support delay and countToken
This commit is contained in:
Archer
2024-07-24 16:02:53 +08:00
committed by GitHub
parent a478621730
commit 45b8d7e8de
49 changed files with 521 additions and 527 deletions

View File

@@ -35,22 +35,22 @@
"formidable": "^2.1.1",
"framer-motion": "9.1.7",
"hyperdown": "^2.4.29",
"i18next": "23.11.5",
"immer": "^9.0.19",
"js-yaml": "^4.1.0",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mermaid": "^10.2.3",
"nanoid": "^4.0.1",
"next": "14.2.5",
"json5": "^2.2.3",
"next-i18next": "15.3.0",
"nextjs-node-loader": "^1.1.5",
"nprogress": "^0.2.0",
"react": "18.3.1",
"react-day-picker": "^8.7.1",
"react-dom": "18.3.1",
"react-hook-form": "7.43.1",
"i18next": "23.11.5",
"next-i18next": "15.3.0",
"react-i18next": "14.1.2",
"react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.5.0",

View File

@@ -0,0 +1,16 @@
import { Box, HStack, StackProps } from '@chakra-ui/react';
import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
const VariableTip = (props: StackProps) => {
const { t } = useTranslation();
return (
<HStack fontSize={'xs'} spacing={1} {...props}>
<MyIcon name={'common/info'} w={'0.9rem'} transform={'translateY(1px)'} />
<Box>{t('common:textarea_variable_picker_tip')}</Box>
</HStack>
);
};
export default VariableTip;

View File

@@ -127,6 +127,7 @@ const DatasetParamsModal = ({
>
<ModalBody flex={'auto'} overflow={'auto'}>
<LightRowTabs<SearchSettingTabEnum>
width={'100%'}
mb={3}
list={[
{

View File

@@ -299,6 +299,8 @@ const VariableEdit = ({
</Button>
<Button
onClick={handleSubmitEdit(({ variable }) => {
variable.key = variable.key.trim();
// check select
if (variable.type === VariableInputEnum.select) {
const enums = variable.enums.filter((item) => item.value);

View File

@@ -157,6 +157,7 @@ export const ResponseBox = React.memo(function ResponseBox({
{!hideTabs && (
<Box>
<LightRowTabs
w={'100%'}
list={list}
value={currentTab}
inlineStyles={{ pt: 0 }}

View File

@@ -3,15 +3,14 @@ import { jsonRes } from '@fastgpt/service/common/response';
import { getUploadModel } from '@fastgpt/service/common/file/multer';
import { removeFilesByPaths } from '@fastgpt/service/common/file/utils';
import fs from 'fs';
import { getAIApi } from '@fastgpt/service/core/ai/config';
import { pushWhisperUsage } from '@/service/support/wallet/usage/push';
import { authChatCert } from '@/service/support/permission/auth/chat';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { NextAPI } from '@/service/middleware/entry';
import { aiTranscriptions } from '@fastgpt/service/core/ai/audio/transcriptions';
const upload = getUploadModel({
maxSize: 2
maxSize: 20
});
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
@@ -45,6 +44,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
// auth role
const { teamId, tmbId } = await authChatCert({ req, authToken: true });
// auth app
// const app = await MongoApp.findById(appId, 'modules').lean();
// if (!app) {
@@ -54,11 +54,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
// throw new Error('Whisper is not open in the app');
// }
const ai = getAIApi();
const result = await ai.audio.transcriptions.create({
file: fs.createReadStream(file.path),
model: global.whisperModel.model
const result = await aiTranscriptions({
model: global.whisperModel.model,
fileStream: fs.createReadStream(file.path)
});
pushWhisperUsage({

View File

@@ -34,6 +34,7 @@ import { useContextSelector } from 'use-context-selector';
import { AppContext } from '@/pages/app/detail/components/context';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
@@ -57,7 +58,8 @@ const LabelStyles: BoxProps = {
w: ['60px', '100px'],
whiteSpace: 'nowrap',
flexShrink: 0,
fontSize: 'xs'
fontSize: 'sm',
color: 'myGray.900'
};
const EditForm = ({
@@ -162,10 +164,13 @@ const EditForm = ({
</Box>
</Flex>
<Box mt={3}>
<HStack {...LabelStyles}>
<Box mt={4}>
<HStack {...LabelStyles} w={'100%'}>
<Box>{t('common:core.ai.Prompt')}</Box>
<QuestionTip label={t('common:core.app.tip.chatNodeSystemPromptTip')} />
<Box flex={1} />
<VariableTip color={'myGray.500'} />
</HStack>
<Box mt={1}>
<PromptEditor

View File

@@ -309,6 +309,7 @@ export function RenderHttpProps({
></QuestionTip>
</Flex>
<LightRowTabs<TabEnum>
width={'100%'}
list={[
{ label: <RenderPropsItem text="Params" num={paramsLength} />, value: TabEnum.params },
...(!['GET', 'DELETE'].includes(requestMethods)

View File

@@ -1,7 +1,7 @@
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'next-i18next';
import { Box, Flex } from '@chakra-ui/react';
import { Box, Flex, HStack } from '@chakra-ui/react';
import NodeInputSelect from '@fastgpt/web/components/core/workflow/NodeInputSelect';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
@@ -10,6 +10,10 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '@/pages/app/detail/components/WorkflowComponents/context';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
type Props = {
nodeId: string;
@@ -68,6 +72,14 @@ const InputLabel = ({ nodeId, input }: Props) => {
/>
</Box>
)}
{/* Variable picker tip */}
{input.renderTypeList[input.selectedTypeIndex ?? 0] === FlowNodeInputTypeEnum.textarea && (
<>
<Box flex={1} />
<VariableTip transform={'translateY(2px)'} />
</>
)}
</Flex>
);
}, [

View File

@@ -27,50 +27,45 @@ const ChatHeader = ({
chatData,
history,
showHistory,
onRoute2AppDetail,
apps
apps,
onRouteToAppDetail
}: {
history: ChatItemType[];
showHistory?: boolean;
onRoute2AppDetail?: () => void;
apps?: AppListItemType[];
chatData: InitChatResponse;
apps?: AppListItemType[];
onRouteToAppDetail?: () => void;
}) => {
const isPlugin = chatData.app.type === AppTypeEnum.plugin;
const { isPc } = useSystem();
return (
<>
{isPc && isPlugin ? null : (
<Flex
alignItems={'center'}
px={[3, 5]}
minH={['46px', '60px']}
borderBottom={'sm'}
color={'myGray.900'}
fontSize={'sm'}
>
{isPc ? (
<PcHeader
title={chatData.title}
chatModels={chatData.app.chatModels}
history={history}
/>
) : (
<MobileHeader
apps={apps}
appId={chatData.appId}
go2AppDetail={onRoute2AppDetail}
name={chatData.app.name}
avatar={chatData.app.avatar}
showHistory={showHistory}
/>
)}
{/* control */}
{!isPlugin && <ToolMenu history={history} />}
</Flex>
return isPc && isPlugin ? null : (
<Flex
alignItems={'center'}
px={[3, 5]}
minH={['46px', '60px']}
borderBottom={'sm'}
color={'myGray.900'}
fontSize={'sm'}
>
{isPc ? (
<>
<PcHeader title={chatData.title} chatModels={chatData.app.chatModels} history={history} />
<Box flex={1} />
</>
) : (
<MobileHeader
apps={apps}
appId={chatData.appId}
name={chatData.app.name}
avatar={chatData.app.avatar}
showHistory={showHistory}
/>
)}
</>
{/* control */}
{!isPlugin && <ToolMenu history={history} onRouteToAppDetail={onRouteToAppDetail} />}
</Flex>
);
};
@@ -88,7 +83,6 @@ const MobileDrawer = ({
app = 'app'
}
const { t } = useTranslation();
const { isPc } = useSystem();
const router = useRouter();
const isTeamChat = router.pathname === '/chat/team';
const [currentTab, setCurrentTab] = useState<TabEnum>(TabEnum.recently);
@@ -103,129 +97,120 @@ const MobileDrawer = ({
);
}, []);
const { onChangeAppId } = useContextSelector(ChatContext, (v) => v);
const onclickApp = (id: string) => {
onChangeAppId(id);
onCloseDrawer();
};
return (
<>
<Box
position={'absolute'}
top={'45px'}
w={'100vw'}
h={'calc(100% - 45px)'}
background={'rgba(0, 0, 0, 0.2)'}
left={0}
zIndex={5}
onClick={() => {
onCloseDrawer();
}}
>
{/* menu */}
<Box
position={'absolute'}
top={'45px'}
w={'100vw'}
h={'calc(100% - 45px)'}
background={'rgba(0, 0, 0, 0.2)'}
left={0}
zIndex={5}
onClick={() => {
onCloseDrawer();
}}
px={[2, 5]}
padding={2}
onClick={(e) => e.stopPropagation()}
background={'white'}
position={'relative'}
>
{/* menu */}
<Box
w={'100vw'}
px={[2, 5]}
padding={2}
onClick={(e) => e.stopPropagation()}
background={'white'}
position={'relative'}
>
{!isPc && appId && (
<LightRowTabs<TabEnum>
flex={'1 0 0'}
width={isTeamChat ? '30%' : '60%'}
mr={10}
inlineStyles={{
px: 1
}}
list={[
...(isTeamChat
? [{ label: t('common:all_apps'), value: TabEnum.recently }]
: [
{ label: t('common:core.chat.Recent use'), value: TabEnum.recently },
{ label: t('common:all_apps'), value: TabEnum.app }
])
]}
value={currentTab}
onChange={setCurrentTab}
/>
)}
</Box>
<Box
width={'100vw'}
height={'auto'}
minH={'10vh'}
maxH={'60vh'}
overflow={'auto'}
background={'white'}
zIndex={3}
onClick={(e) => e.stopPropagation()}
borderRadius={'0 0 10px 10px'}
position={'relative'}
padding={3}
pt={0}
pb={4}
>
{/* history */}
{currentTab === TabEnum.recently && (
<>
{Array.isArray(apps) &&
apps.map((item) => (
<Flex justify={'center'} key={item._id}>
<Flex
py={2.5}
px={2}
width={'100%'}
borderRadius={'md'}
alignItems={'center'}
{...(item._id === appId
? {
backgroundColor: 'primary.50 !important',
color: 'primary.600'
}
: {
onClick: () => onChangeAppId(item._id)
})}
>
<Avatar src={item.avatar} w={'24px'} />
<Box ml={2} className={'textEllipsis'}>
{item.name}
</Box>
</Flex>
</Flex>
))}
</>
)}
{currentTab === TabEnum.app && !isPc && (
<>
<SelectOneResource
value={appId}
onSelect={(id) => {
if (!id) return;
onChangeAppId(id);
}}
server={getAppList}
/>
</>
)}
</Box>
<LightRowTabs<TabEnum>
gap={3}
inlineStyles={{
px: 2
}}
list={[
...(isTeamChat
? [{ label: t('common:all_apps'), value: TabEnum.recently }]
: [
{ label: t('common:core.chat.Recent use'), value: TabEnum.recently },
{ label: t('common:all_apps'), value: TabEnum.app }
])
]}
value={currentTab}
onChange={setCurrentTab}
/>
</Box>
</>
<Box
width={'100%'}
minH={'10vh'}
background={'white'}
onClick={(e) => e.stopPropagation()}
borderRadius={'0 0 10px 10px'}
position={'relative'}
py={3}
pt={0}
pb={4}
h={'65vh'}
>
{/* history */}
{currentTab === TabEnum.recently && (
<Box px={3} overflow={'auto'} h={'100%'}>
{Array.isArray(apps) &&
apps.map((item) => (
<Flex justify={'center'} key={item._id}>
<Flex
py={2.5}
px={2}
width={'100%'}
borderRadius={'md'}
alignItems={'center'}
{...(item._id === appId
? {
backgroundColor: 'primary.50 !important',
color: 'primary.600'
}
: {
onClick: () => onclickApp(item._id)
})}
>
<Avatar src={item.avatar} w={'24px'} />
<Box ml={2} className={'textEllipsis'}>
{item.name}
</Box>
</Flex>
</Flex>
))}
</Box>
)}
{currentTab === TabEnum.app && (
<SelectOneResource
value={appId}
onSelect={(id) => {
if (!id) return;
onclickApp(id);
}}
server={getAppList}
/>
)}
</Box>
</Box>
);
};
const MobileHeader = ({
showHistory,
go2AppDetail,
name,
avatar,
appId,
apps
}: {
showHistory?: boolean;
go2AppDetail?: () => void;
avatar: string;
name: string;
apps?: AppListItemType[];
appId: string;
}) => {
const { isPc } = useSystem();
const router = useRouter();
const onOpenSlider = useContextSelector(ChatContext, (v) => v.onOpenSlider);
const { isOpen: isOpenDrawer, onToggle: toggleDrawer, onClose: onCloseDrawer } = useDisclosure();
@@ -237,22 +222,21 @@ const MobileHeader = ({
<MyIcon name={'menu'} w={'20px'} h={'20px'} color={'myGray.900'} onClick={onOpenSlider} />
)}
<Flex px={3} alignItems={'center'} flex={'1 0 0'} w={0} justifyContent={'center'}>
<Avatar src={avatar} w={'16px'} />
<Box ml={1} className="textEllipsis" onClick={go2AppDetail}>
{name}
</Box>
{isShareChat ? null : (
<MyIcon
_active={{ transform: 'scale(0.9)' }}
name={'core/chat/chevronSelector'}
w={'20px'}
h={'20px'}
color={isOpenDrawer ? 'primary.600' : 'myGray.900'}
onClick={toggleDrawer}
/>
)}
<Flex alignItems={'center'} onClick={toggleDrawer}>
<Avatar src={avatar} w={'1rem'} />
<Box ml={1} className="textEllipsis">
{name}
</Box>
{isShareChat ? null : (
<MyIcon
name={'core/chat/chevronSelector'}
w={'1.25rem'}
color={isOpenDrawer ? 'primary.600' : 'myGray.900'}
/>
)}
</Flex>
</Flex>
{!isPc && isOpenDrawer && !isShareChat && (
{isOpenDrawer && !isShareChat && (
<MobileDrawer apps={apps} appId={appId} onCloseDrawer={onCloseDrawer} />
)}
</>
@@ -292,7 +276,6 @@ const PcHeader = ({
</MyTag>
</MyTooltip>
)}
<Box flex={1} />
</>
);
};

View File

@@ -27,7 +27,6 @@ const ChatHistorySlider = ({
appId,
appName,
appAvatar,
apps = [],
confirmClearText,
onDelHistory,
onClearHistory,
@@ -37,7 +36,6 @@ const ChatHistorySlider = ({
appId?: string;
appName: string;
appAvatar: string;
apps?: AppListItemType[];
confirmClearText: string;
onDelHistory: (e: { chatId: string }) => void;
onClearHistory: () => void;
@@ -46,10 +44,9 @@ const ChatHistorySlider = ({
}) => {
const theme = useTheme();
const router = useRouter();
const isTeamChat = router.pathname === '/chat/team';
const isUserChatPage = router.pathname === '/chat';
const { t } = useTranslation();
const { appT } = useI18n();
const { isPc } = useSystem();
const { userInfo } = useUserStore();
@@ -103,7 +100,7 @@ const ChatHistorySlider = ({
whiteSpace={'nowrap'}
>
{isPc && (
<MyTooltip label={canRouteToDetail ? appT('app_detail') : ''} offset={[0, 0]}>
<MyTooltip label={canRouteToDetail ? t('app:app_detail') : ''} offset={[0, 0]}>
<Flex
pt={5}
pb={2}
@@ -136,7 +133,7 @@ const ChatHistorySlider = ({
justify={['space-between', '']}
alignItems={'center'}
>
{!isPc && appId && (
{!isPc && (
<Flex height={'100%'} align={'center'} justify={'center'}>
<MyIcon ml={2} name="core/chat/sideLine" />
<Box ml={2} fontWeight={'bold'}>
@@ -147,8 +144,9 @@ const ChatHistorySlider = ({
<Button
variant={'whitePrimary'}
flex={[appId ? '0 0 auto' : 1, 1]}
flex={['0 0 auto', 1]}
h={'100%'}
px={6}
color={'primary.600'}
borderRadius={'xl'}
leftIcon={<MyIcon name={'core/chat/chatLight'} w={'16px'} />}
@@ -158,7 +156,7 @@ const ChatHistorySlider = ({
{t('common:core.chat.New Chat')}
</Button>
{/* Clear */}
{isPc && (
{isPc && histories.length > 0 && (
<IconButton
ml={3}
h={'100%'}
@@ -288,7 +286,7 @@ const ChatHistorySlider = ({
</Box>
{/* exec */}
{!isPc && appId && !isTeamChat && (
{!isPc && isUserChatPage && (
<Flex
mt={2}
borderTop={theme.borders.base}

View File

@@ -7,7 +7,13 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { useRouter } from 'next/router';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
const ToolMenu = ({ history }: { history: ChatItemType[] }) => {
const ToolMenu = ({
history,
onRouteToAppDetail
}: {
history: ChatItemType[];
onRouteToAppDetail?: () => void;
}) => {
const { t } = useTranslation();
const { onExportChat } = useChatBox();
const router = useRouter();
@@ -57,7 +63,20 @@ const ToolMenu = ({ history }: { history: ChatItemType[] }) => {
// onClick: () => onExportChat({ type: 'pdf', history })
// }
]
}
},
...(onRouteToAppDetail
? [
{
children: [
{
icon: 'core/app/aiLight',
label: t('app:app_detail'),
onClick: onRouteToAppDetail
}
]
}
]
: [])
]}
/>
) : (

View File

@@ -195,7 +195,6 @@ const Chat = ({
);
})(
<ChatHistorySlider
apps={myApps}
confirmClearText={t('common:core.chat.Confirm to clear history')}
appId={appId}
appName={chatData.app.name}
@@ -229,8 +228,8 @@ const Chat = ({
apps={myApps}
chatData={chatData}
history={chatRecords}
onRoute2AppDetail={() => router.push(`/app/detail?appId=${appId}`)}
showHistory
onRouteToAppDetail={() => router.push(`/app/detail?appId=${appId}`)}
/>
{/* chat box */}
@@ -341,7 +340,7 @@ export async function getServerSideProps(context: any) {
props: {
appId: context?.query?.appId || '',
chatId: context?.query?.chatId || '',
...(await serviceSideProps(context, ['file']))
...(await serviceSideProps(context, ['file', 'app']))
}
};
}

View File

@@ -292,7 +292,7 @@ const OutLink = ({ appName, appIntro, appAvatar }: Props) => {
{showHead === '1' ? (
<ChatHeader
chatData={chatData}
history={chatData.history}
history={chatRecords}
showHistory={showHistory === '1'}
/>
) : null}
@@ -396,7 +396,7 @@ export async function getServerSideProps(context: any) {
appIntro: app?.appId?.intro ?? 'intro',
shareId: shareId ?? '',
authToken: authToken ?? '',
...(await serviceSideProps(context, ['file']))
...(await serviceSideProps(context, ['file', 'app']))
}
};
}

View File

@@ -199,7 +199,6 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
})(
<ChatHistorySlider
appId={appId}
apps={myApps}
appName={chatData.app.name}
appAvatar={chatData.app.avatar}
confirmClearText={t('common:core.chat.Confirm to clear history')}
@@ -230,7 +229,7 @@ const Chat = ({ myApps }: { myApps: AppListItemType[] }) => {
flexDirection={'column'}
>
{/* header */}
<ChatHeader apps={myApps} chatData={chatData} history={chatData.history} showHistory />
<ChatHeader apps={myApps} chatData={chatData} history={chatRecords} showHistory />
{/* chat box */}
<Box flex={1}>
{chatData.app.type === AppTypeEnum.plugin ? (
@@ -340,7 +339,7 @@ export async function getServerSideProps(context: any) {
chatId: context?.query?.chatId || '',
teamId: context?.query?.teamId || '',
teamToken: context?.query?.teamToken || '',
...(await serviceSideProps(context, ['file']))
...(await serviceSideProps(context, ['file', 'app']))
}
};
}

View File

@@ -128,7 +128,7 @@ const Login = () => {
export async function getServerSideProps(context: any) {
return {
props: { ...(await serviceSideProps(context)) }
props: { ...(await serviceSideProps(context, ['app'])) }
};
}

View File

@@ -439,7 +439,7 @@ export const simpleBotTemplates: TemplateType = [
label: 'core.ai.Prompt',
description: 'core.app.tip.chatNodeSystemPromptTip',
placeholder: 'core.app.tip.chatNodeSystemPromptTip',
value: '请直接将我的问题翻译成{{language}},不需要回答问题。'
value: '请直接将我的问题翻译成{{$VARIABLE_NODE_ID.language$}},不需要回答问题。'
},
{
key: 'history',

View File

@@ -25,7 +25,9 @@
"@nestjs/platform-fastify": "^10.3.8",
"@nestjs/swagger": "^7.3.1",
"fastify": "^4.27.0",
"dayjs": "^1.11.7",
"isolated-vm": "^4.7.2",
"tiktoken": "^1.0.15",
"node-gyp": "^10.1.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"

View File

@@ -0,0 +1,10 @@
export const timeDelay = (time: number) => {
return new Promise((resolve, reject) => {
if (time > 10000) {
reject('Delay time must be less than 10');
}
setTimeout(() => {
resolve('');
}, time);
});
};

View File

@@ -0,0 +1,8 @@
import { Tiktoken } from 'tiktoken/lite';
const cl100k_base = require('tiktoken/encoders/cl100k_base');
export const countToken = (text: string = '') => {
const enc = new Tiktoken(cl100k_base.bpe_ranks, cl100k_base.special_tokens, cl100k_base.pat_str);
const encodeText = enc.encode(text);
return encodeText.length;
};

View File

@@ -1,15 +1,14 @@
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { SandboxService } from './sandbox.service';
import { RunCodeDto, RunCodeResponse } from './dto/create-sandbox.dto';
import { WorkerNameEnum, runWorker } from 'src/worker/utils';
import { RunCodeDto } from './dto/create-sandbox.dto';
import { runSandbox } from './utils';
@Controller('sandbox')
export class SandboxController {
constructor(private readonly sandboxService: SandboxService) {}
constructor() {}
@Post('/js')
@HttpCode(200)
runJs(@Body() codeProps: RunCodeDto) {
return runWorker<RunCodeResponse>(WorkerNameEnum.runJs, codeProps);
return runSandbox(codeProps);
}
}

View File

@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import { RunCodeDto } from './dto/create-sandbox.dto';
import { WorkerNameEnum, runWorker } from 'src/worker/utils';
@Injectable()
export class SandboxService {
runJs(params: RunCodeDto) {
return runWorker(WorkerNameEnum.runJs, params);
return {};
}
}

View File

@@ -0,0 +1,102 @@
import { RunCodeDto, RunCodeResponse } from 'src/sandbox/dto/create-sandbox.dto';
import IsolatedVM, { ExternalCopy, Isolate, Reference } from 'isolated-vm';
import { countToken } from './jsFn/tiktoken';
import { timeDelay } from './jsFn/delay';
const CustomLogStr = 'CUSTOM_LOG';
/*
Rewrite code to add custom functions: Promise function; Log.
*/
function getFnCode(code: string) {
const rewriteSystemFn = `
const thisDelay = (...args) => global_delay.applySyncPromise(undefined,args)
`;
// rewrite delay
code = code.replace(/delay\((.*)\)/g, `thisDelay($1)`);
// rewrite log
code = code.replace(/console\.log/g, `${CustomLogStr}`);
const runCode = `
(async() => {
try {
${rewriteSystemFn}
${code}
const res = await main(variables, {})
return JSON.stringify(res);
} catch(err) {
return JSON.stringify({ERROR: err?.message ?? err})
}
})
`;
return runCode;
}
function registerSystemFn(jail: IsolatedVM.Reference<Record<string | number | symbol, any>>) {
return Promise.all([
// delay
jail.set('global_delay', new Reference(timeDelay)),
jail.set('countToken', countToken)
]);
}
export const runSandbox = async ({
code,
variables = {}
}: RunCodeDto): Promise<RunCodeResponse> => {
const logData = [];
const isolate = new Isolate({ memoryLimit: 32 });
const context = await isolate.createContext();
const jail = context.global;
try {
// Add global variables
await Promise.all([
jail.set('variables', new ExternalCopy(variables).copyInto()),
jail.set(CustomLogStr, function (...args) {
logData.push(
args
.map((item) => (typeof item === 'object' ? JSON.stringify(item, null, 2) : item))
.join(', ')
);
}),
registerSystemFn(jail)
]);
// Run code
const fn = await context.eval(getFnCode(code), { reference: true, timeout: 10000 });
try {
// Get result and parse
const value = await fn.apply(undefined, [], { result: { promise: true } });
const result = JSON.parse(value.toLocaleString());
// release memory
context.release();
isolate.dispose();
if (result.ERROR) {
return Promise.reject(result.ERROR);
}
return {
codeReturn: result,
log: logData.join('\n')
};
} catch (error) {
context.release();
isolate.dispose();
return Promise.reject('Not an invalid response.You must return an object');
}
} catch (err) {
console.log(err);
context.release();
isolate.dispose();
return Promise.reject(err);
}
};

View File

@@ -1,53 +0,0 @@
import { RunCodeDto, RunCodeResponse } from 'src/sandbox/dto/create-sandbox.dto';
import { parentPort } from 'worker_threads';
import { workerResponse } from './utils';
// @ts-ignore
const ivm = require('isolated-vm');
parentPort?.on('message', ({ code, variables = {} }: RunCodeDto) => {
const resolve = (data: RunCodeResponse) => workerResponse({ parentPort, type: 'success', data });
const reject = (error: any) => workerResponse({ parentPort, type: 'error', data: error });
try {
const isolate = new ivm.Isolate({ memoryLimit: 32 });
const context = isolate.createContextSync();
const jail = context.global;
// custom function
const logData = [];
const CustomLogStr = 'CUSTOM_LOG';
code = code.replace(/console\.log/g, `${CustomLogStr}`);
jail.setSync(CustomLogStr, function (...args) {
logData.push(
args
.map((item) => (typeof item === 'object' ? JSON.stringify(item, null, 2) : item))
.join(', ')
);
});
jail.setSync('responseData', function (args: any): any {
if (typeof args === 'object') {
resolve({
codeReturn: args,
log: logData.join('\n')
});
} else {
reject('Not an invalid response, must return an object');
}
});
// Add global variables
jail.setSync('variables', new ivm.ExternalCopy(variables).copyInto());
const scriptCode = `
${code}
responseData(main(variables))`;
context.evalSync(scriptCode, { timeout: 6000 });
} catch (err) {
console.log(err);
reject(err);
}
process.exit();
});

View File

@@ -1,47 +0,0 @@
import { type MessagePort, Worker } from 'worker_threads';
import * as path from 'path';
export enum WorkerNameEnum {
runJs = 'runJs',
runPy = 'runPy'
}
type WorkerResponseType = { type: 'success' | 'error'; data: any };
export const getWorker = (name: WorkerNameEnum) => {
const baseUrl =
process.env.NODE_ENV === 'production' ? 'projects/sandbox/dist/worker' : 'dist/worker';
const workerPath = path.join(process.cwd(), baseUrl, `${name}.js`);
return new Worker(workerPath);
};
export const runWorker = <T = any>(name: WorkerNameEnum, params?: Record<string, any>) => {
return new Promise<T>((resolve, reject) => {
const worker = getWorker(name);
worker.postMessage(params);
worker.on('message', (msg: WorkerResponseType) => {
if (msg.type === 'error') return reject(msg.data);
resolve(msg.data);
worker.terminate();
});
worker.on('error', (err) => {
reject(err);
worker.terminate();
});
worker.on('messageerror', (err) => {
reject(err);
worker.terminate();
});
});
};
export const workerResponse = ({
parentPort,
...data
}: WorkerResponseType & { parentPort?: MessagePort }) => {
parentPort?.postMessage(data);
};