V4.14.0 features (#5850)

* feat: migrate chat files to s3 (#5802)

* feat: migrate chat files to s3

* feat: add delete jobs for deleting s3 files

* chore: improvements

* fix: lockfile

* fix: imports

* feat: add ttl for those uploaded files but not send yet

* feat: init bullmq worker

* fix: s3 key

* perf: s3 internal url

* remove env

* fix: re-sign a new url

* fix: re-sign a new url

* perf: s3 code

---------

Co-authored-by: archer <545436317@qq.com>

* update pacakge

* feat: add more file type for uploading (#5807)

* fix: re-sign a new url

* wip: file selector

* feat: add more file type for uploading

* feat: migrate chat files to s3 (#5802)

* feat: migrate chat files to s3

* feat: add delete jobs for deleting s3 files

* chore: improvements

* fix: lockfile

* fix: imports

* feat: add ttl for those uploaded files but not send yet

* feat: init bullmq worker

* fix: s3 key

* perf: s3 internal url

* remove env

* fix: re-sign a new url

* fix: re-sign a new url

* perf: s3 code

---------

Co-authored-by: archer <545436317@qq.com>

* fix: limit minmax available file upload number

* perf: file select modal code

* fix: fileselect refresh

* fix: ts

---------

Co-authored-by: archer <545436317@qq.com>

* bugfix: chat page (#5809)

* fix: upload avatar

* fix: chat page username display issue and setting button visibility

* doc

* Markdown match base64 performance

* feat: improve global variables(time, file, dataset) (#5804)

* feat: improve global variables(time, file, dataset)

* feat: optimize code

* perf: time variables code

* fix: model, file

* fix: hide file upload

* fix: ts

* hide dataset select

---------

Co-authored-by: archer <545436317@qq.com>

* perf: insert training queue

* perf: s3 upload error i18n

* fix: share page s3

* fix: timeselector ui error

* var update node

* Timepicker ui

* feat: plugin support password

* fix: password disabled UX

* fix: button size

* fix: no model cache for chat page (#5820)

* rename function

* fix: workflow bug

* fix: interactive loop

* fix test

* perf: common textare no richtext

* move system plugin config (#5803) (#5813)

* move system plugin config (#5803)

* move system plugin config

* extract tag bar

* filter

* tool detail temp

* marketplace

* params

* fix

* type

* search

* tags render

* status

* ui

* code

* connect to backend (#5815)

* feat: marketplace apis & type definitions (#5817)

* chore: marketplace init

* chore: marketplace list api type

* chore: detail api

* marketplace & import

* feat: marketplace ui (#5826)

* temp

* marketplace

* import

* feat: detail return readme

* chore: cache data expire 10 mins

* chore: update docs

* feat: marketplace ui

---------

Co-authored-by: heheer <zhiyu44@qq.com>

* feat: marketplace (#5830)

* temp

* marketplace

* chore: tool list tag filter

* chore: adjust

---------

Co-authored-by: heheer <zhiyu44@qq.com>

* tool detail drawer

* remove tag filter

* fix

* fix

* fix build

* update pnpm-lock

* fix type

* perf code

* marketplace router

* fix build

* navbar icon

* fix ui

* fix init

* docs: marketplace/plugin (#5832)

* temp

* marketplace

* docs(plugin): system tool docs

---------

Co-authored-by: heheer <zhiyu44@qq.com>

* default url

* feat: i18n/ docker build (#5833)

* chore: docker build

* feat: i18n selector

* fix

* fix

* fix: i18n parse

* fix: i18n parse

---------

Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: Finley Ge <32237950+FinleyGe@users.noreply.github.com>
Co-authored-by: heheer <zhiyu44@qq.com>

* marketplace url

* update action

* market place code

* market place code

* title

* fix: nextconfig

* fix: copilot review

* Remove bypassable regex-based XSS sanitization from marketplace search (#5835)

* Initial plan

* Remove problematic regex-based XSS sanitization from search inputs

Co-authored-by: c121914yu <50446880+c121914yu@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: c121914yu <50446880+c121914yu@users.noreply.github.com>

* feat: tool tag openapi

* api check

* fix: tsc

* fix: ts

* fix: lock

* sdk version

* ts

* sdk version

* remove invalid tip

* perf: export data add timezone

* perf: admin plugin api move

* perf: tool code

* move tag code

* perf: marketplace and team plugin code

* remove workflow invalid request

* rename global tool code

* rename global tool code

* rename api

* fix some bugs (#5841)

* fix some bugs

* fix

* perf: Tag filter

* fix: ts

* fix: ts

---------

Co-authored-by: archer <545436317@qq.com>

* perf: Concat function

* fix: workflow snapshot push

* fix: ts type

* fix: login to config/*

* fix: ts

* fix: model avatar (#5848)

* fix: model avatar

* fix: ts

* fix: avatar migration to s3

* update lock

* fix: avatar redirect

---------

Co-authored-by: archer <545436317@qq.com>

* fix tool detail (#5847)

* fix tool detail

* init script

* fix build

* perf: plugin detail modal

* change tooltags to tags

* fix icon

---------

Co-authored-by: archer <545436317@qq.com>

* fix tag filter scroll (#5852)

* fix create app plugin & import info (#5853)

* tag size

* rename toolkit

* download url

* import plugin status (#5854)

* init doc

* fix: init shell

---------

Co-authored-by: 伍闲犬 <whoeverimf5@gmail.com>
Co-authored-by: Zeng Qingwen <143274079+fishwww-ww@users.noreply.github.com>
Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: Finley Ge <32237950+FinleyGe@users.noreply.github.com>
Co-authored-by: heheer <zhiyu44@qq.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Archer
2025-11-04 16:58:12 +08:00
committed by GitHub
parent fac170306e
commit a499d05a02
364 changed files with 15051 additions and 3514 deletions
@@ -0,0 +1,20 @@
import { exit } from 'process';
/*
Init system
*/
export async function register() {
try {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// 基础系统初始化
const [{ getToolList }] = await Promise.all([import('@/service/tool/data')]);
await getToolList();
console.log('Init system success');
}
} catch (error) {
console.log('Init system error', error);
exit(1);
}
}
+32
View File
@@ -0,0 +1,32 @@
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { appWithTranslation } from 'next-i18next';
import { ChakraProvider } from '@chakra-ui/react';
import { theme } from '@fastgpt/web/styles/theme';
import I18nInitializer from '@/web/common/i18n/I18nInitializer';
import Head from 'next/head';
// Simplified theme config without initialColorMode
const safeTheme = {
...theme,
config: {
...theme.config,
initialColorMode: undefined
}
};
function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>FastGPT Marketplace</title>
</Head>
<ChakraProvider theme={safeTheme}>
<I18nInitializer />
<Component {...pageProps} />
</ChakraProvider>
</>
);
}
export default appWithTranslation(App);
@@ -0,0 +1,16 @@
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { NextPageContext } from 'next';
import { useTranslation } from 'next-i18next';
import { Box, Button, Container, Heading, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
function ErrorPage({ statusCode }: { statusCode: number }) {
const { t } = useTranslation('common');
const router = useRouter();
return (
<Container maxW="container.md" py={20}>
<Box textAlign="center">
<Heading fontSize="6xl" mb={4}>
{statusCode}
</Heading>
<Text fontSize="xl" mb={8} color="gray.600">
{statusCode === 404 ? 'Page not found' : 'Something went wrong'}
</Text>
<Button colorScheme="blue" onClick={() => router.push('/')}>
Go back home
</Button>
</Box>
</Container>
);
}
ErrorPage.getInitialProps = async ({ res, err }: NextPageContext) => {
const statusCode = res?.statusCode || err?.statusCode || 404;
return { statusCode };
};
export default ErrorPage;
@@ -0,0 +1,46 @@
import { getToolList } from '@/service/tool/data';
import { ToolDetailSchema, type ToolDetailType } from '@fastgpt/global/sdk/fastgpt-plugin';
import { getPkgdownloadURL, getReadmeURL } from '@/service/s3';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
export type ToolDetailQuery = {
toolId: string;
};
export type ToolDetailBody = {};
export type ToolDetailResponse = {
tools: Array<ToolDetailType & { readme: string }>;
downloadUrl: string;
};
async function handler(
req: ApiRequestProps<ToolDetailBody, ToolDetailQuery>,
res: ApiResponseType<any>
): Promise<ToolDetailResponse> {
const { toolId } = req.query;
if (!toolId) {
throw new Error('toolId is required');
}
const toolList = await getToolList();
const tools = toolList.filter(
(item) => item.toolId.startsWith(toolId + '/') || item.toolId === toolId
);
if (tools.length < 1) {
res.status(404);
Promise.reject('tool not found');
}
return {
tools: tools.map((tool) => ({
...ToolDetailSchema.parse(tool),
readme: getReadmeURL(toolId)
})),
downloadUrl: getPkgdownloadURL(toolId)
};
}
export default NextAPI(handler);
@@ -0,0 +1,55 @@
import { getToolList } from '@/service/tool/data';
import type { PaginationProps, PaginationResponse } from '@fastgpt/web/common/fetch/type';
import { ToolSimpleSchema, type ToolSimpleType } from '@fastgpt/global/sdk/fastgpt-plugin';
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { getPkgdownloadURL } from '@/service/s3';
export type ToolListQuery = {};
export type ToolListBody = PaginationProps<{
searchKey?: string;
tags?: string[];
}>;
export type ToolListItem = ToolSimpleType & {
downloadUrl: string;
};
export type ToolListResponse = PaginationResponse<ToolListItem>;
async function handler(
req: ApiRequestProps<ToolListBody, ToolListQuery>,
res: ApiResponseType<any>
): Promise<ToolListResponse> {
const { pageSize, offset } = parsePaginationRequest(req);
const { searchKey, tags } = req.body;
const data = await getToolList();
const filteredData = data.filter((item) => {
if (item.parentId) {
return false;
}
if (
searchKey &&
!(
Object.values(item.name).join('') +
Object.values(item.description).join('') +
item.toolId
).includes(searchKey)
)
return false;
if (tags && !tags.some((tag) => (item.tags as string[]).includes(tag))) return false;
return true;
});
return {
list: filteredData.slice(offset, offset + pageSize).map((item) => ({
...ToolSimpleSchema.parse(item),
downloadUrl: getPkgdownloadURL(item.toolId)
})),
total: filteredData.length
};
}
export default NextAPI(handler);
@@ -0,0 +1,25 @@
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { ToolTagsNameMap } from '@fastgpt/global/sdk/fastgpt-plugin';
import type { SystemPluginToolTagType } from '@fastgpt/global/core/plugin/type';
export type TagListQuery = {};
export type TagListBody = {};
export type TagListResponse = SystemPluginToolTagType[];
async function handler(
req: ApiRequestProps<TagListBody, TagListQuery>,
res: ApiResponseType<any>
): Promise<TagListResponse> {
const arr: SystemPluginToolTagType[] = [];
for (const key of Object.keys(ToolTagsNameMap)) {
arr.push({
isSystem: true,
tagId: key,
tagName: ToolTagsNameMap[key as keyof typeof ToolTagsNameMap],
tagOrder: arr.length
});
}
return arr;
}
export default NextAPI(handler);
+489
View File
@@ -0,0 +1,489 @@
import { serviceSideProps } from '@/web/common/i18n/utils';
import { useTranslation } from 'next-i18next';
import { Box, Button, Flex, Grid, Input, InputGroup, VStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import ToolCard, { type ToolCardItemType } from '@fastgpt/web/components/core/plugin/tool/ToolCard';
import ToolTagFilterBox from '@fastgpt/web/components/core/plugin/tool/TagFilterBox';
import ToolDetailDrawer from '@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type { ToolListItem } from '@/pages/api/tool/list';
import { usePagination } from '@fastgpt/web/hooks/usePagination';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { getMarketplaceToolDetail, getMarketplaceTools, getToolTags } from '@/web/api';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import I18nLngSelector from '@/web/common/Select/I18nLngSelector';
import Head from 'next/head';
const ToolkitMarketplace = () => {
const { t, i18n } = useTranslation();
const router = useRouter();
const { search, tags } = router.query;
const [inputValue, setInputValue] = useState('');
const [searchText, setSearchText] = useState('');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [selectedTool, setSelectedTool] = useState<ToolCardItemType | null>(null);
const [operatingToolId] = useState<string | null>(null);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompactSearch, setShowCompactSearch] = useState(false);
const heroSectionRef = useRef<HTMLDivElement>(null);
// 从 URL 初始化状态
useEffect(() => {
try {
if (search && typeof search === 'string') {
setInputValue(search);
setSearchText(search);
setIsSearchExpanded(true);
}
if (tags) {
const tagArray =
typeof tags === 'string'
? tags
.split(',')
.filter(Boolean)
.map((tag) => tag.trim())
: [];
setSelectedTagIds(tagArray);
}
} catch (error) {
console.warn('Failed to initialize URL params:', error);
}
}, [search, tags]);
// 使用自定义 debounce 进行实时搜索
const [debouncedSearchText, setDebouncedSearchText] = useState(inputValue);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchText(inputValue);
}, 500);
return () => {
clearTimeout(handler);
};
}, [inputValue]);
// debounce 后更新 searchText 进行实时搜索
useEffect(() => {
setSearchText(debouncedSearchText);
}, [debouncedSearchText]);
// 更新 URL 的函数
const updateUrlParams = useCallback(
(newSearch: string, newTags: string[]) => {
try {
// 使用更安全的 URL 参数构建方式
const params: Record<string, string> = {};
if (newSearch) {
params.search = newSearch;
}
if (newTags.length > 0) {
params.tags = newTags.join(',');
}
// 手动构建查询字符串,避免 URLSearchParams 的安全问题
const queryString = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const newUrl = queryString ? `${router.pathname}?${queryString}` : router.pathname;
// 使用原生 History API 替代 Next.js router(更安全)
if (typeof window !== 'undefined' && window.history && window.history.replaceState) {
// 检查安全上下文
if (
window.isSecureContext ||
(window.location.protocol === 'http:' && window.location.hostname === 'localhost')
) {
try {
window.history.replaceState({}, '', newUrl);
} catch (historyError) {
console.warn('History replaceState failed:', historyError);
}
} else {
console.warn('Skipping URL update in insecure context');
}
}
} catch (error) {
console.warn('Failed to update URL params:', error);
// 如果 URL 操作失败,跳过更新
}
},
[router.pathname]
);
// 处理搜索框失焦,更新 URL
const handleSearchBlur = useCallback(() => {
if (router.isReady) {
updateUrlParams(searchText, selectedTagIds);
}
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
// 监听 selectedTagIds 变化,更新 URL
useEffect(() => {
if (router.isReady) {
updateUrlParams(searchText, selectedTagIds);
}
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
const {
data: tools,
isLoading: loadingTools,
ScrollData
} = usePagination(
({ pageNum = 1, pageSize = 20 }) =>
getMarketplaceTools({
pageNum,
pageSize,
searchKey: searchText || undefined,
tags: selectedTagIds.length > 0 ? selectedTagIds : undefined
}),
{
type: 'scroll',
throttleWait: 500,
refreshDeps: [searchText, selectedTagIds]
}
);
const { data: toolTags = [] } = useRequest2(getToolTags, {
manual: false
});
const displayTools: ToolCardItemType[] = useMemo(() => {
if (!tools || !Array.isArray(tools) || !toolTags) return [];
return tools.map((tool: ToolListItem) => {
return {
id: tool.toolId,
name: parseI18nString(tool.name || '', i18n.language) || '',
description: parseI18nString(tool.description || '', i18n.language) || '',
icon: tool.icon,
author: tool.author || '',
tags: tool.tags?.map((tag) => {
const currentTag = toolTags.find((item) => item.tagId === tag);
return parseI18nString(currentTag?.tagName || '', i18n.language) || '';
}),
downloadUrl: tool.downloadUrl
};
});
}, [tools, i18n.language, toolTags]);
// 使用 IntersectionObserver 监听英雄区域是否在视窗中
useEffect(() => {
const heroSection = heroSectionRef.current;
if (!heroSection) return;
const observer = new IntersectionObserver(
([entry]) => {
const shouldShowCompact = !entry.isIntersecting;
setShowCompactSearch(shouldShowCompact);
if (entry.isIntersecting && isSearchExpanded && !inputValue) {
setIsSearchExpanded(false);
}
},
{
threshold: 0
}
);
observer.observe(heroSection);
return () => {
observer.disconnect();
};
}, [isSearchExpanded, inputValue]);
return (
<>
<Head>
<title>{t('app:fastgpt_marketplace')}</title>
</Head>
<MyBox
bg={'white'}
h="100vh"
position={'relative'}
display={'flex'}
flexDirection={'column'}
isLoading={loadingTools && displayTools.length === 0}
>
<Box px={8} flexShrink={0} position={'relative'}>
<Flex gap={3} position={'absolute'} right={8} top={6} alignItems={'center'}>
<I18nLngSelector />
<Button
onClick={() => {
window.open(
'https://doc.fastgpt.io/docs/introduction/guide/plugins/dev_system_tool',
'_blank'
);
}}
>
{t('app:toolkit_contribute_resource')}
</Button>
<Button
variant={'whiteBase'}
onClick={() => {
window.open('https://github.com/labring/fastgpt-plugin/issues', '_blank');
}}
>
{t('app:toolkit_marketplace_submit_request')}
</Button>
</Flex>
<Box
h={showCompactSearch ? '90px' : '0'}
overflow={'hidden'}
position={'absolute'}
bg={'white'}
right={0}
left={0}
roundedTop={'md'}
px={8}
>
<Box
opacity={showCompactSearch ? 1 : 0}
transition={'opacity 0.1s ease-out'}
pointerEvents={showCompactSearch ? 'auto' : 'none'}
>
<Flex mt={2} pt={4} alignItems={'center'}>
<Flex
alignItems={'center'}
transition={'all 0.3s'}
w={isSearchExpanded ? '320px' : 'auto'}
mr={4}
>
{isSearchExpanded ? (
<InputGroup>
<MyIcon
position={'absolute'}
zIndex={10}
left={2.5}
name={'common/searchLight'}
w={5}
color={'primary.600'}
top={'50%'}
transform={'translateY(-50%)'}
/>
<Input
px={8}
h={10}
borderRadius={'md'}
placeholder={t('app:toolkit_marketplace_search_placeholder')}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
autoFocus
onBlur={() => {
handleSearchBlur();
if (!inputValue) {
setIsSearchExpanded(false);
}
}}
/>
{inputValue && (
<MyIcon
position={'absolute'}
zIndex={10}
right={2.5}
name={'common/closeLight'}
w={4}
top={'50%'}
transform={'translateY(-50%)'}
color={'myGray.500'}
cursor={'pointer'}
onClick={() => {
setInputValue('');
setSearchText('');
setIsSearchExpanded(false);
}}
/>
)}
</InputGroup>
) : (
<Flex
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
borderRadius={'10px'}
_hover={{ borderColor: 'primary.600' }}
onClick={() => setIsSearchExpanded(true)}
p={2}
border={'1px solid'}
borderColor={'myGray.200'}
>
<MyIcon name={'common/searchLight'} w={5} color={'primary.600'} mr={2} />
<Box
fontSize={'16px'}
fontWeight={'medium'}
color={'myGray.500'}
whiteSpace={'nowrap'}
>
{t('common:Search')}
</Box>
</Flex>
)}
</Flex>
<Box overflow={'auto'} mr={6}>
<ToolTagFilterBox
tags={toolTags}
selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds}
/>
</Box>
</Flex>
</Box>
</Box>
</Box>
<ScrollData flex={1} minHeight={0} height="auto" pb={10}>
<VStack ref={heroSectionRef} w={'full'} gap={8} px={8} pt={4} pb={8} mt={8}>
<Box
position={'relative'}
display={'inline-flex'}
px={4}
py={1}
borderRadius={'4px'}
fontSize={'11px'}
fontWeight={'medium'}
lineHeight={'16px'}
letterSpacing={'0.5px'}
bgGradient={'linear(180deg, #F9BDFD 0%, #80B8FF 100%)'}
_before={{
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '4px',
padding: '1px',
background: 'linear-gradient(180deg, #F9BDFD 0%, #80B8FF 100%)',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude'
}}
sx={{
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
Assets for FastGPT
</Box>
<Box fontSize={'45px'} fontWeight={'semibold'} color={'black'}>
{t('app:toolkit_marketplace_title')}
</Box>
<Box>
<InputGroup position={'relative'}>
<MyIcon
position={'absolute'}
zIndex={10}
left={2.5}
name={'common/searchLight'}
w={5}
top={'50%'}
transform={'translateY(-50%)'}
color={'myGray.600'}
/>
<Input
fontSize="sm"
bg={'white'}
pl={8}
w={'560px'}
h={12}
borderRadius={'10px'}
placeholder={t('app:toolkit_marketplace_search_placeholder')}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onBlur={handleSearchBlur}
/>
</InputGroup>
</Box>
</VStack>
<Box px={8} pb={6}>
<Flex
mt={2}
mb={4}
alignItems={'center'}
opacity={showCompactSearch ? 0 : 1}
pointerEvents={showCompactSearch ? 'none' : 'auto'}
>
<Box flex={'1'} overflow={'auto'}>
<ToolTagFilterBox
tags={toolTags}
selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds}
/>
</Box>
</Flex>
{displayTools.length > 0 ? (
<Grid
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']}
gridGap={5}
alignItems={'stretch'}
>
{displayTools.map((tool) => {
return (
<ToolCard
key={tool.id}
item={tool}
isLoading={operatingToolId === tool.id}
mode="marketplace"
onClickButton={() => {
if (tool.downloadUrl) {
const link = document.createElement('a');
link.href = tool.downloadUrl;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
onClickCard={() => setSelectedTool(tool)}
/>
);
})}
</Grid>
) : (
<EmptyTip />
)}
</Box>
</ScrollData>
</MyBox>
{!!selectedTool && (
<ToolDetailDrawer
onClose={() => setSelectedTool(null)}
showPoint={false}
mode="marketplace"
selectedTool={selectedTool}
// @ts-ignore
onFetchDetail={async (toolId: string) => await getMarketplaceToolDetail({ toolId })}
onToggleInstall={() => {
if (selectedTool.downloadUrl) {
const link = document.createElement('a');
link.href = selectedTool.downloadUrl;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
/>
)}
</>
);
};
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app']))
}
};
}
export default ToolkitMarketplace;
@@ -0,0 +1,52 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
export type NextApiHandler<T = any> = (
req: ApiRequestProps,
res: NextApiResponse<T>
) => unknown | Promise<unknown>;
/**
* Marketplace 专用的轻量级 API 包装器
* 避免引入主应用中的 MongoDB 和认证相关依赖
*/
export const NextAPI = (...handlers: NextApiHandler[]): NextApiHandler => {
return async function api(req: ApiRequestProps, res: NextApiResponse) {
try {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
let response = null;
for await (const handler of handlers) {
response = await handler(req, res);
if (res.writableFinished) {
break;
}
}
// 如果响应还未结束,返回 JSON 格式
if (!res.writableFinished) {
res.status(200).json({
code: 200,
data: response
});
}
} catch (error) {
console.error('Marketplace API Error:', error);
if (!res.writableFinished) {
res.status(500).json({
code: 500,
message: (error as Error)?.message || 'Internal Server Error'
});
}
}
};
};
@@ -0,0 +1,9 @@
const S3Prefix = process.env.S3_PREFIX;
export const getPkgdownloadURL = (toolId: string) => {
return S3Prefix + '/pkgs/' + toolId + '.pkg';
};
export const getReadmeURL = (toolId: string) => {
return `${S3Prefix}/system/plugin/tools/${toolId}/README.md`;
};
@@ -0,0 +1,26 @@
import type { ToolDetailType } from '@fastgpt/global/sdk/fastgpt-plugin';
import { readFile, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
declare global {
// eslint-disable-next-line no-var
var toolListData: Array<ToolDetailType>;
var expire: Date;
}
const dataFileURL = process.env.S3_PREFIX + '/data.json';
const localDataFilePath = join(tmpdir(), 'data.json');
export const getToolList = async () => {
if (!global.toolListData || global.toolListData.length === 0 || global.expire < new Date()) {
global.expire = new Date(Date.now() + 1000 * 10 * 60); // 10 minutes
// download the file to local
const res = await fetch(dataFileURL);
await writeFile(localDataFilePath, Buffer.from(await res.arrayBuffer()));
const data = await readFile(localDataFilePath, 'utf-8');
global.toolListData = JSON.parse(data);
}
return global.toolListData;
};
@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ToolListBody, ToolListResponse } from '@/pages/api/tool/list';
import type { ToolDetailResponse } from '@/pages/api/tool/detail';
import type { SystemPluginToolTagType } from '@fastgpt/global/core/plugin/type';
export const getMarketplaceTools = async (body: ToolListBody) => {
const res = await fetch('api/tool/list', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
}).then((res) => res.json());
return res.data as Promise<ToolListResponse>;
};
export const getMarketplaceToolDetail = async ({ toolId }: { toolId: string }) => {
const res = await fetch(`api/tool/detail?toolId=${toolId}`, { method: 'GET' }).then((res) =>
res.json()
);
return res.data as Promise<ToolDetailResponse>;
};
export const getToolTags = async () => {
const res = await fetch('api/tool/tags', { method: 'GET' }).then((res) => res.json());
return res.data as Promise<Array<SystemPluginToolTagType>>;
};
@@ -0,0 +1,37 @@
import { Box, Flex } from '@chakra-ui/react';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useI18nLng } from '@fastgpt/web/hooks/useI18n';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { langMap } from '@fastgpt/global/common/i18n/type';
const I18nLngSelector = () => {
const { i18n } = useTranslation();
const { onChangeLng } = useI18nLng();
const list = useMemo(() => {
return Object.entries(langMap).map(([key, lang]) => ({
label: (
<Flex alignItems={'center'}>
<MyIcon borderRadius={'0'} mr={2} name={lang.avatar as any} w={'1rem'} />
<Box>{lang.label}</Box>
</Flex>
),
value: key
}));
}, []);
return (
<MySelect
value={i18n.language}
list={list}
onChange={(val: any) => {
const lang = val;
onChangeLng(lang);
}}
/>
);
};
export default I18nLngSelector;
@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useI18nLng } from '@fastgpt/web/hooks/useI18n';
interface I18nInitializerProps {
onInitialized?: () => void;
}
/**
* 初始化 i18n 语言设置
* 根据用户浏览器语言自动设置默认语言
*/
const I18nInitializer = ({ onInitialized }: I18nInitializerProps) => {
const { setUserDefaultLng } = useI18nLng();
useEffect(() => {
// 只在客户端执行语言自动判断
if (typeof window !== 'undefined') {
try {
setUserDefaultLng();
} catch (error) {
console.warn('Failed to initialize i18n language:', error);
}
}
// 调用回调函数(如果提供)
onInitialized?.();
}, [setUserDefaultLng, onInitialized]);
// 这个组件不需要渲染任何内容
return null;
};
export default I18nInitializer;
@@ -0,0 +1,15 @@
import { type I18nNsType } from '@fastgpt/web/i18n/i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export const serviceSideProps = async (content: any, ns: I18nNsType = []) => {
const lang = content.req?.cookies?.NEXT_LOCALE || content.locale;
const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales;
// Device size
const deviceSize = content.req?.cookies?.NEXT_DEVICE_SIZE || null;
return {
...(await serverSideTranslations(lang, ['common', ...ns], undefined, extraLng)),
deviceSize
};
};