mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-05 01:02:59 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user