diff --git a/packages/global/openapi/core/plugin/marketplace/api.ts b/packages/global/openapi/core/plugin/marketplace/api.ts index 56e5f5dfe073..ebf5a1bccc86 100644 --- a/packages/global/openapi/core/plugin/marketplace/api.ts +++ b/packages/global/openapi/core/plugin/marketplace/api.ts @@ -7,19 +7,16 @@ const formatToolDetailSchema = z.object({}); const formatToolSimpleSchema = z.object({}); // Create intersection types for extended schemas -export const MarketplaceToolListItemSchema = formatToolSimpleSchema.extend({ - downloadUrl: z.string() -}); +export const MarketplaceToolListItemSchema = formatToolSimpleSchema; export type MarketplaceToolListItemType = ToolSimpleType & { - downloadUrl: string; + downloadCount: number; }; export const MarketplaceToolDetailItemSchema = formatToolDetailSchema.extend({ readme: z.string().optional() }); export const MarketplaceToolDetailSchema = z.object({ - tools: z.array(MarketplaceToolDetailItemSchema), - downloadUrl: z.string() + tools: z.array(MarketplaceToolDetailItemSchema) }); // List @@ -57,7 +54,12 @@ export type GetSystemInstalledPluginsQueryType = z.infer< typeof GetSystemInstalledPluginsQuerySchema >; export const GetSystemInstalledPluginsResponseSchema = z.object({ - ids: z.array(z.string()) + list: z.array( + z.object({ + id: z.string(), + version: z.string() + }) + ) }); export type GetSystemInstalledPluginsResponseType = z.infer< typeof GetSystemInstalledPluginsResponseSchema diff --git a/packages/global/package.json b/packages/global/package.json index e0e775a420cd..4ca63017718f 100644 --- a/packages/global/package.json +++ b/packages/global/package.json @@ -2,7 +2,7 @@ "name": "@fastgpt/global", "version": "1.0.0", "dependencies": { - "@fastgpt-sdk/plugin": "0.2.15", + "@fastgpt-sdk/plugin": "0.2.16", "@apidevtools/swagger-parser": "^10.1.0", "@bany/curl-to-json": "^1.2.8", "axios": "^1.12.1", diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index dbc136653c5a..cdc8095080c5 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -45,6 +45,7 @@ export const iconPaths = { 'common/disable': () => import('./icons/common/disable.svg'), 'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'), 'common/download': () => import('./icons/common/download.svg'), + 'common/downloadLine': () => import('./icons/common/downloadLine.svg'), 'common/edit': () => import('./icons/common/edit.svg'), 'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'), 'common/ellipsis': () => import('./icons/common/ellipsis.svg'), diff --git a/packages/web/components/common/Icon/icons/common/downloadLine.svg b/packages/web/components/common/Icon/icons/common/downloadLine.svg new file mode 100644 index 000000000000..f8400f11d9db --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/downloadLine.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/core/plugin/tool/ToolCard.tsx b/packages/web/components/core/plugin/tool/ToolCard.tsx index af5556fedcd4..753a927c2a16 100644 --- a/packages/web/components/core/plugin/tool/ToolCard.tsx +++ b/packages/web/components/core/plugin/tool/ToolCard.tsx @@ -7,12 +7,6 @@ import MyIcon from '../../../common/Icon'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { PluginStatusEnum } from '@fastgpt/global/core/plugin/type'; -/* - 3 种使用场景: - 1. admin 视角插件市场:显示是否安装,无状态,显示安装/卸载 - 2. team 视角资源库:显示是否安装,状态文本,以及安装/卸载 - 3. 开放的插件市场:不显示任何状态,只显示下载按钮 -*/ export type ToolCardItemType = { id: string; name: string; @@ -23,21 +17,35 @@ export type ToolCardItemType = { downloadUrl?: string; status?: number; installed?: boolean; + update?: boolean; + downloadCount?: number; }; +/** + 3 种使用场景: + 1. admin 视角插件市场:显示是否安装,是否更新,无状态,显示安装/卸载 + 2. team 视角资源库:显示是否安装,不显示更新,状态文本,以及安装/卸载 + 3. 开放的插件市场:不显示任何状态,只显示下载按钮 +*/ const ToolCard = ({ item, systemTitle, - isLoading, + isInstallingOrDeleting, + isUpdating, mode, - onClickButton, + onInstall, + onDelete, + onUpdate, onClickCard }: { item: ToolCardItemType; systemTitle?: string; - isLoading?: boolean; + isInstallingOrDeleting?: boolean; + isUpdating?: boolean; mode: 'admin' | 'team' | 'marketplace'; - onClickButton: (installed: boolean) => void; + onInstall: () => Promise; + onDelete?: () => Promise; + onUpdate?: () => Promise; onClickCard?: () => void; }) => { const { t, i18n } = useTranslation(); @@ -76,7 +84,7 @@ const ToolCard = ({ }; }, [item.tags]); - const statusMap = useMemo(() => { + const statusLabel = useMemo(() => { if (mode === 'marketplace') return null; const pluginStatusMap: Record = @@ -91,23 +99,27 @@ const ToolCard = ({ } }; - const installedStatusMap = item.installed - ? { - label: t('app:toolkit_installed'), - color: 'myGray.500', - icon: 'common/check' - } - : null; - if (mode === 'admin') { - return installedStatusMap; + return item.installed + ? { + label: t('app:toolkit_installed'), + color: 'myGray.500', + icon: 'common/check' + } + : null; } if (mode === 'team') { if (item.status && pluginStatusMap[item.status]) { return pluginStatusMap[item.status]; } - return installedStatusMap; + return item.installed + ? { + label: t('app:toolkit_installed'), + color: 'myGray.500', + icon: 'common/check' + } + : null; } }, [item.installed, item.status]); @@ -122,23 +134,77 @@ const ToolCard = ({ display={'flex'} flexDirection={'column'} cursor={onClickCard ? 'pointer' : 'default'} - onClick={onClickCard} + position={'relative'} + onClick={() => { + if (isInstallingOrDeleting || isUpdating) return; + onClickCard?.(); + }} _hover={{ boxShadow: '0 4px 4px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08);', '& .install-button': { display: 'flex' + }, + '& .update-button': { + display: 'flex' + }, + // Only hide author info when there are multiple buttons + ...(item.update && mode === 'admin' + ? { + '& .author-info': { + display: 'none' + } + } + : {}), + '& .download-count': { + display: 'none' } }} > + {/* Update badge in top-right corner */} + {item.update && mode === 'admin' && ( + + + {t('app:app.modules.has new version')} + + )} + {parseI18nString(item.name, i18n.language)} - {statusMap && ( - - {statusMap.icon && } - {statusMap.label} + {statusLabel && ( + + {statusLabel.icon && } + {statusLabel.label} )} @@ -194,36 +260,77 @@ const ToolCard = ({ - {`by ${item.author || systemTitle || 'FastGPT'}`} - {mode === 'marketplace' ? ( - - ) : ( - - )} + {`by ${item.author || systemTitle || 'FastGPT'}`} + {/*TODO: when statistics is ready*/} + {/* + + {!item.downloadCount + ? 0 + : item.downloadCount < 1000 + ? `${item.downloadCount}` + : `${(item.downloadCount / 1000).toFixed(1)}k`} + */} + + + {mode === 'marketplace' ? ( + + ) : ( + + )} + + {/* Update button for admin mode when update is available */} + {item.update && mode === 'admin' && onUpdate && ( + + )} + ); diff --git a/packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx b/packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx index fcb0dc6a453b..dbdd1797c773 100644 --- a/packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx +++ b/packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx @@ -163,6 +163,8 @@ const ToolDetailDrawer = ({ onClose, selectedTool, onToggleInstall, + onUpdate, + isUpdating, systemTitle, onFetchDetail, isLoading, @@ -172,6 +174,8 @@ const ToolDetailDrawer = ({ onClose: () => void; selectedTool: ToolCardItemType; onToggleInstall: (installed: boolean) => void; + onUpdate?: () => void; + isUpdating?: boolean; systemTitle?: string; onFetchDetail?: (toolId: string) => Promise; isLoading?: boolean; @@ -321,23 +325,39 @@ const ToolDetailDrawer = ({ {`by ${parentTool?.author || systemTitle || 'FastGPT'}`} - - + + {/* Determine if we have two buttons */} + {(() => { + const hasUpdateButton = selectedTool.update && onUpdate && mode !== 'marketplace'; + const buttonFlex = hasUpdateButton ? 1 : 1; // Both use flex=1, but when single button it fills the space + + return ( + <> + + {hasUpdateButton && ( + + )} + + ); + })()} {showPoint && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5df2b40e6c0..0a8c3753b950 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: specifier: ^1.2.8 version: 1.2.8 '@fastgpt-sdk/plugin': - specifier: 0.2.15 - version: 0.2.15(@types/node@20.14.0) + specifier: 0.2.16 + version: 0.2.16(@types/node@20.14.0) axios: specifier: ^1.12.1 version: 1.12.1 @@ -753,6 +753,9 @@ importers: i18next: specifier: 23.16.8 version: 23.16.8 + mongoose: + specifier: ^8.10.1 + version: 8.12.1(socks@2.8.4) next: specifier: 14.2.33 version: 14.2.33(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1) @@ -2332,8 +2335,8 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@fastgpt-sdk/plugin@0.2.15': - resolution: {integrity: sha512-z2JMoQ02gDHDM3feWemY10GyI9g0AH9g1IUAuqSfThkmPMhdLGsB4B6V6r2aSCUCb9FbdNqJblzriPYH9VtP9w==} + '@fastgpt-sdk/plugin@0.2.16': + resolution: {integrity: sha512-tEpwtSLqPJCvvEk+KpdlgCl2N7n3m5ew2C9/hFKt+ahBY+eaU4WVrblJ6idSIImZ9lqOjOW4Mq7U1lO1mq06mQ==} '@fastify/accept-negotiator@1.1.0': resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} @@ -13061,7 +13064,7 @@ snapshots: '@eslint/js@8.57.1': {} - '@fastgpt-sdk/plugin@0.2.15(@types/node@20.14.0)': + '@fastgpt-sdk/plugin@0.2.16(@types/node@20.14.0)': dependencies: '@fortaine/fetch-event-source': 3.0.6 '@ts-rest/core': 3.52.1(@types/node@20.14.0)(zod@3.25.76) @@ -17817,8 +17820,8 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.8.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.9.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -17837,6 +17840,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.0 + eslint: 8.57.1 + get-tsconfig: 4.10.0 + is-bun-module: 1.3.0 + oxc-resolver: 5.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.12 + optionalDependencies: + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0)(eslint@8.56.0): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17878,6 +17896,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.8.2) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0)(eslint@8.57.1): dependencies: debug: 3.2.7 @@ -17918,6 +17947,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.8.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.9.0)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 diff --git a/projects/app/src/pages/api/core/plugin/admin/marketplace/installed.ts b/projects/app/src/pages/api/core/plugin/admin/marketplace/installed.ts index 6006318a3f1f..a6faa1b6e331 100644 --- a/projects/app/src/pages/api/core/plugin/admin/marketplace/installed.ts +++ b/projects/app/src/pages/api/core/plugin/admin/marketplace/installed.ts @@ -26,7 +26,10 @@ async function handler( const tools = await APIGetSystemToolList(); return { - ids: tools.map((tool) => tool.id.replace(`${AppToolSourceEnum.systemTool}-`, '')) + list: tools.map((tool) => ({ + id: tool.id.replace(`${AppToolSourceEnum.systemTool}-`, ''), + version: tool.version + })) }; } diff --git a/projects/app/src/pages/config/tool/marketplace.tsx b/projects/app/src/pages/config/tool/marketplace.tsx index 3a3ca6fb02a1..7bbb9f5bb38c 100644 --- a/projects/app/src/pages/config/tool/marketplace.tsx +++ b/projects/app/src/pages/config/tool/marketplace.tsx @@ -9,7 +9,7 @@ import MyBox from '@fastgpt/web/components/common/MyBox'; import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import MyMenu from '@fastgpt/web/components/common/MyMenu'; import { useState, useMemo, useRef, useEffect, useCallback, useReducer } from 'react'; -import { useDebounce, useMount } from 'ahooks'; +import { useDebounce, useMount, useSet } from 'ahooks'; 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'; @@ -19,6 +19,7 @@ import { intallPluginWithUrl } from '@/web/core/plugin/admin/api'; import { deletePkgPlugin } from '@/web/core/plugin/admin/api'; import { getMarketPlaceToolTags, + getMarketplaceDownloadURL, getMarketplaceToolDetail, getMarketplaceTools, getSystemInstalledPlugins @@ -60,28 +61,6 @@ const useSearchParams = () => { return { searchText, tagIds, updateParams }; }; -type OperatingAction = { type: 'TRY_ADD'; toolId: string } | { type: 'REMOVE'; toolId: string }; - -const operatingReducer = (state: Set, action: OperatingAction): Set => { - if (action.type === 'TRY_ADD') { - if (state.has(action.toolId)) { - return state; - } - const newSet = new Set(state); - newSet.add(action.toolId); - return newSet; - } - if (action.type === 'REMOVE') { - if (!state.has(action.toolId)) { - return state; - } - const newSet = new Set(state); - newSet.delete(action.toolId); - return newSet; - } - return state; -}; - const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { const { t, i18n } = useTranslation(); const router = useRouter(); @@ -92,7 +71,8 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { const { searchText, tagIds, updateParams } = useSearchParams(); const [selectedTool, setSelectedTool] = useState(null); - const [operatingToolIds, dispatchOperating] = useReducer(operatingReducer, new Set()); + const [installingOrDeletingToolIds, installingOrDeletingToolIdsDispatch] = useSet(); + const [updatingToolIds, updatingToolIdsDispatch] = useSet(); const operatingPromisesRef = useRef>>(new Map()); // Type filter @@ -153,18 +133,13 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { } ); - const [installedPluginsMap, setInstalledPluginsMap] = useState>({}); - useRequest2( + const { data: systemInstalledPlugins, runAsync: refreshInstalledPlugins } = useRequest2( async () => { - const { ids } = await getSystemInstalledPlugins({ type: 'tool' }); - const data = ids.reduce( - (acc, id) => { - acc[id] = true; - return acc; - }, - {} as Record - ); - setInstalledPluginsMap(data); + const { list } = await getSystemInstalledPlugins({ type: 'tool' }); + return { + ids: new Set(list.map((item) => item.id)), + map: new Map(list.map((item) => [item.id, item])) + }; }, { manual: false @@ -178,7 +153,8 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { // Controler const { runAsync: handleInstallTool } = useRequest2( async (tool: ToolCardItemType) => { - if (!tool.downloadUrl) return; + const downloadUrl = await getMarketplaceDownloadURL(tool.id); + if (!downloadUrl) return; const existingPromise = operatingPromisesRef.current.get(tool.id); if (existingPromise) { @@ -187,20 +163,20 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { } const operationPromise = (async () => { - dispatchOperating({ type: 'TRY_ADD', toolId: tool.id }); + installingOrDeletingToolIdsDispatch.add(tool.id); try { await intallPluginWithUrl({ - downloadUrls: [tool.downloadUrl || ''] + downloadUrls: [downloadUrl] }); - setInstalledPluginsMap((prev) => ({ ...prev, [tool.id]: true })); if (selectedTool?.id === tool.id) { setSelectedTool((prev) => (prev ? { ...prev, status: 3 } : null)); } } finally { - dispatchOperating({ type: 'REMOVE', toolId: tool.id }); + installingOrDeletingToolIdsDispatch.remove(tool.id); operatingPromisesRef.current.delete(tool.id); + await refreshInstalledPlugins(); } })(); operatingPromisesRef.current.set(tool.id, operationPromise); @@ -211,6 +187,45 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { manual: true } ); + + const handleUpdateTool = useCallback( + async (tool: ToolCardItemType) => { + const existingPromise = operatingPromisesRef.current.get(tool.id); + if (existingPromise) { + await existingPromise; + return; + } + + const operationPromise = (async () => { + updatingToolIdsDispatch.add(tool.id); + + try { + // Get download URL + const downloadUrl = await getMarketplaceDownloadURL(tool.id); + if (!downloadUrl) return; + + // Call install interface for update + await intallPluginWithUrl({ + downloadUrls: [downloadUrl] + }); + + // If the currently selected tool is the tool to be updated, update its status + if (selectedTool?.id === tool.id) { + setSelectedTool((prev) => (prev ? { ...prev, status: 3 } : null)); + } + } finally { + updatingToolIdsDispatch.remove(tool.id); + operatingPromisesRef.current.delete(tool.id); + await refreshInstalledPlugins(); + } + })(); + + operatingPromisesRef.current.set(tool.id, operationPromise); + await operationPromise; + }, + [updatingToolIdsDispatch, selectedTool, refreshInstalledPlugins] + ); + const { runAsync: handleDeleteTool } = useRequest2( async (tool: ToolCardItemType) => { const existingPromise = operatingPromisesRef.current.get(tool.id); @@ -220,18 +235,18 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { } const operationPromise = (async () => { - dispatchOperating({ type: 'TRY_ADD', toolId: tool.id }); + installingOrDeletingToolIdsDispatch.add(tool.id); try { await deletePkgPlugin({ toolId: tool.id }); - setInstalledPluginsMap((prev) => ({ ...prev, [tool.id]: false })); if (selectedTool?.id === tool.id) { setSelectedTool((prev) => (prev ? { ...prev, status: 1 } : null)); } } finally { - dispatchOperating({ type: 'REMOVE', toolId: tool.id }); + installingOrDeletingToolIdsDispatch.remove(tool.id); operatingPromisesRef.current.delete(tool.id); + await refreshInstalledPlugins(); } })(); operatingPromisesRef.current.set(tool.id, operationPromise); @@ -270,7 +285,10 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { return ( tools ?.map((tool) => { - const isInstalled = !!installedPluginsMap[tool.toolId]; + const isInstalled = systemInstalledPlugins?.ids.has(tool.toolId); + const update = !isInstalled + ? false + : systemInstalledPlugins?.map.get(tool.toolId)?.version !== tool.version; return { id: tool.toolId, @@ -282,8 +300,9 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { const currentTag = allTags.find((t) => t.tagId === tag); return parseI18nString(currentTag?.tagName || '', i18n.language) || ''; }), - downloadUrl: tool.downloadUrl, - installed: isInstalled + installed: isInstalled, + update, + downloadCount: tool.downloadCount }; }) ?.filter((tool) => { @@ -291,7 +310,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { return !tool.installed; }) || [] ); - }, [tools, installedPluginsMap, i18n.language, allTags, installedFilter]); + }, [tools, i18n.language, allTags, installedFilter, systemInstalledPlugins]); if (toolsError && !loadingTools) { return ( @@ -598,15 +617,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { { - if (installed) { - handleInstallTool(tool); - } else { - handleDeleteTool(tool); - } - }} + isInstallingOrDeleting={installingOrDeletingToolIds.has(tool.id)} + isUpdating={updatingToolIds.has(tool.id)} + onInstall={() => handleInstallTool(tool)} + onDelete={() => handleDeleteTool(tool)} + onUpdate={() => handleUpdateTool(tool)} onClickCard={() => setSelectedTool(tool)} /> ); @@ -631,7 +647,9 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { handleInstallTool(selectedTool); } }} - isLoading={operatingToolIds.has(selectedTool.id)} + onUpdate={() => handleUpdateTool(selectedTool)} + isUpdating={updatingToolIds.has(selectedTool.id)} + isLoading={installingOrDeletingToolIds.has(selectedTool.id)} mode="admin" //@ts-ignore onFetchDetail={async (toolId: string) => await getMarketplaceToolDetail({ toolId })} diff --git a/projects/app/src/pages/dashboard/systemTool/index.tsx b/projects/app/src/pages/dashboard/systemTool/index.tsx index cb0af99595a5..69be7cb22d4a 100644 --- a/projects/app/src/pages/dashboard/systemTool/index.tsx +++ b/projects/app/src/pages/dashboard/systemTool/index.tsx @@ -342,9 +342,10 @@ const ToolKitProvider = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { item={tool} systemTitle={feConfigs?.systemTitle} mode="team" - onClickButton={(installed) => toggleInstall({ pluginId: tool.id, installed })} + onInstall={() => toggleInstall({ pluginId: tool.id, installed: true })} + onDelete={() => toggleInstall({ pluginId: tool.id, installed: false })} onClickCard={() => setSelectedTool(tool)} - isLoading={loadingPluginIds.has(tool.id)} + isInstallingOrDeleting={loadingPluginIds.has(tool.id)} /> ); })} diff --git a/projects/app/src/web/core/plugin/marketplace/api.ts b/projects/app/src/web/core/plugin/marketplace/api.ts index 88eb3d1aa538..14b2557a7f57 100644 --- a/projects/app/src/web/core/plugin/marketplace/api.ts +++ b/projects/app/src/web/core/plugin/marketplace/api.ts @@ -21,3 +21,6 @@ export const getMarketplaceToolDetail = (data: GetMarketplaceToolDetailQueryType export const getMarketPlaceToolTags = () => GET('/marketplace/api/tool/tags'); + +export const getMarketplaceDownloadURL = (toolId: string) => + GET('/marketplace/api/tool/getDownloadUrl', { toolId }); diff --git a/projects/marketplace/.env.template b/projects/marketplace/.env.template index bf02052a515a..4b2163f916bc 100644 --- a/projects/marketplace/.env.template +++ b/projects/marketplace/.env.template @@ -1,2 +1,3 @@ S3_PREFIX=http://localhost:9000/fastgpt-plugins -AUTH_TOKEN=xxxx \ No newline at end of file +AUTH_TOKEN=xxxx +MONGODB_URI="mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin&directConnection=true" diff --git a/projects/marketplace/package.json b/projects/marketplace/package.json index 589567f502e9..4d5b6b51a66b 100644 --- a/projects/marketplace/package.json +++ b/projects/marketplace/package.json @@ -15,26 +15,27 @@ "@chakra-ui/react": "2.10.7", "@chakra-ui/styled-system": "2.9.1", "@chakra-ui/system": "2.6.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "next": "14.2.33", + "@fastgpt/global": "workspace:*", "@fastgpt/service": "workspace:*", "@fastgpt/web": "workspace:*", - "@fastgpt/global": "workspace:*", + "i18next": "23.16.8", + "mongoose": "^8.10.1", + "next": "14.2.33", "next-i18next": "15.4.2", + "react": "18.3.1", + "react-dom": "18.3.1", "react-i18next": "14.1.2", - "i18next": "23.16.8", "zod": "^4.1.12" }, "devDependencies": { - "typescript": "^5", - "@types/node": "^20", "@svgr/webpack": "^6.5.1", + "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.33", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.33" + "typescript": "^5" } } diff --git a/projects/marketplace/src/instrumentation.ts b/projects/marketplace/src/instrumentation.ts index 6eed746f0b27..3a1679ad013f 100644 --- a/projects/marketplace/src/instrumentation.ts +++ b/projects/marketplace/src/instrumentation.ts @@ -7,8 +7,12 @@ export async function register() { try { if (process.env.NEXT_RUNTIME === 'nodejs') { // 基础系统初始化 - const [{ getToolList }] = await Promise.all([import('@/service/tool/data')]); + const [{ getToolList }, { connectMongo, connectionMongo, MONGO_URL }] = await Promise.all([ + import('@/service/tool/data'), + import('@/service/mongo') + ]); + await connectMongo(connectionMongo, MONGO_URL); await getToolList(); console.log('Init system success'); diff --git a/projects/marketplace/src/pages/api/tool/detail.ts b/projects/marketplace/src/pages/api/tool/detail.ts index cc13ed8e51a9..affab02e452d 100644 --- a/projects/marketplace/src/pages/api/tool/detail.ts +++ b/projects/marketplace/src/pages/api/tool/detail.ts @@ -1,6 +1,6 @@ import { getToolList } from '@/service/tool/data'; import { ToolDetailSchema, type ToolDetailType } from '@fastgpt/global/sdk/fastgpt-plugin'; -import { getPkgdownloadURL, getReadmeURL } from '@/service/s3'; +import { getReadmeURL } from '@/service/s3'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; @@ -11,7 +11,7 @@ export type ToolDetailBody = {}; export type ToolDetailResponse = { tools: Array; - downloadUrl: string; + downloadCount: number; }; async function handler( @@ -39,7 +39,7 @@ async function handler( ...ToolDetailSchema.parse(tool), readme: getReadmeURL(toolId) })), - downloadUrl: getPkgdownloadURL(toolId) + downloadCount: tools.find((tool) => !tool.parentId)?.downloadCount ?? 0 }; } diff --git a/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts b/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts new file mode 100644 index 000000000000..5cfd034be4a3 --- /dev/null +++ b/projects/marketplace/src/pages/api/tool/getDownloadUrl.ts @@ -0,0 +1,29 @@ +import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; +import { NextAPI } from '@/service/middleware/entry'; +import { getToolList } from '@/service/tool/data'; +import { getPkgdownloadURL } from '@/service/s3'; +import { increaseDownloadCount } from '@/service/downloadCount'; + +export type GetDownloadURLQuery = { + toolId: string; +}; +export type GetDownloadURLBody = {}; +export type GetDownloadURLResponse = string; + +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { + const { toolId } = req.query; + if (!toolId) { + return Promise.reject('toolId is required'); + } + const tools = await getToolList(); + const tool = tools.find((item) => item.toolId === toolId); + if (!tool) { + return Promise.reject(`tool: ${toolId} not found`); + } + await increaseDownloadCount(toolId, 'tool'); + return getPkgdownloadURL(toolId); +} +export default NextAPI(handler); diff --git a/projects/marketplace/src/pages/api/tool/list.ts b/projects/marketplace/src/pages/api/tool/list.ts index ee2200298927..75839849583a 100644 --- a/projects/marketplace/src/pages/api/tool/list.ts +++ b/projects/marketplace/src/pages/api/tool/list.ts @@ -4,7 +4,6 @@ import { ToolSimpleSchema, type ToolSimpleType } from '@fastgpt/global/sdk/fastg 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<{ @@ -13,7 +12,7 @@ export type ToolListBody = PaginationProps<{ }>; export type ToolListItem = ToolSimpleType & { - downloadUrl: string; + downloadCount: number; }; export type ToolListResponse = PaginationResponse; @@ -46,7 +45,7 @@ async function handler( return { list: filteredData.slice(offset, offset + pageSize).map((item) => ({ ...ToolSimpleSchema.parse(item), - downloadUrl: getPkgdownloadURL(item.toolId) + downloadCount: item.downloadCount })), total: filteredData.length }; diff --git a/projects/marketplace/src/pages/index.tsx b/projects/marketplace/src/pages/index.tsx index 91bb61f15273..f0b5184825d6 100644 --- a/projects/marketplace/src/pages/index.tsx +++ b/projects/marketplace/src/pages/index.tsx @@ -12,7 +12,12 @@ 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 { + getDownloadURL, + 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'; @@ -25,7 +30,6 @@ const ToolkitMarketplace = () => { const [searchText, setSearchText] = useState(''); const [selectedTagIds, setSelectedTagIds] = useState([]); const [selectedTool, setSelectedTool] = useState(null); - const [operatingToolId] = useState(null); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [showCompactSearch, setShowCompactSearch] = useState(false); const heroSectionRef = useRef(null); @@ -166,11 +170,30 @@ const ToolkitMarketplace = () => { const currentTag = toolTags.find((item) => item.tagId === tag); return parseI18nString(currentTag?.tagName || '', i18n.language) || ''; }), - downloadUrl: tool.downloadUrl + downloadCount: tool.downloadCount }; }); }, [tools, i18n.language, toolTags]); + const onDownload = useCallback(async (toolId: string) => { + try { + const url = await getDownloadURL(toolId); + if (url) { + // Create download link + const link = document.createElement('a'); + link.href = url; + link.download = ''; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (error) { + console.error('Download failed:', error); + // Can add error prompt here + } + }, []); + // 使用 IntersectionObserver 监听英雄区域是否在视窗中 useEffect(() => { const heroSection = heroSectionRef.current; @@ -201,6 +224,8 @@ const ToolkitMarketplace = () => { <> {t('app:fastgpt_marketplace')} + + { { - if (tool.downloadUrl) { - const link = document.createElement('a'); - link.href = tool.downloadUrl; - link.download = ''; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } + onInstall={() => { + onDownload(tool.id); }} onClickCard={() => setSelectedTool(tool)} /> @@ -463,14 +480,7 @@ const ToolkitMarketplace = () => { // @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); - } + onDownload(selectedTool.id); }} /> )} diff --git a/projects/marketplace/src/service/downloadCount/index.ts b/projects/marketplace/src/service/downloadCount/index.ts new file mode 100644 index 000000000000..82abec01cdb1 --- /dev/null +++ b/projects/marketplace/src/service/downloadCount/index.ts @@ -0,0 +1,135 @@ +import { MongoDownloadCount } from '../mongo/models/download'; +import type { pluginTypeEnum } from '../mongo/models/download'; +import type z from 'zod'; + +const BATCH_INTERVAL = 10000; // 10 seconds + +// Global cache storage for Next.js environment +declare global { + var __downloadCache: + | Array<{ + toolId: string; + type: z.infer; + hour: Date; + }> + | undefined; + var __batchTimer: NodeJS.Timeout | undefined; +} + +// Initialize global cache if not exists +if (!global.__downloadCache) { + global.__downloadCache = []; +} + +if (!global.__batchTimer) { + global.__batchTimer = undefined; +} + +// Get hour start time +const getHourStart = (date: Date = new Date()): Date => { + const hourStart = new Date(date); + hourStart.setMinutes(0, 0, 0); + hourStart.setMilliseconds(0); + return hourStart; +}; + +// Batch update to database +const batchUpdate = async () => { + if (global.__downloadCache!.length === 0) { + return; + } + + const bulkOps = global.__downloadCache!.map((record) => ({ + updateOne: { + filter: { + toolId: record.toolId, + type: record.type, + time: record.hour + }, + update: { + $inc: { + downloadCount: 1 + } + }, + upsert: true + } + })); + + try { + await MongoDownloadCount.bulkWrite(bulkOps); + console.log(`Batch update download counts: ${bulkOps.length} items`); + } catch (error) { + console.error('Batch update download counts failed:', error); + } + + // Clear cache + global.__downloadCache = []; +}; + +// Start batch timer +const startBatchTimer = () => { + if (global.__batchTimer) { + return; + } + + global.__batchTimer = setInterval(() => { + batchUpdate(); + }, BATCH_INTERVAL); +}; + +// Increase download count (with cache) +export const increaseDownloadCount = async ( + toolId: string, + type: z.infer +) => { + const hour = getHourStart(); + + // Add to cache array + global.__downloadCache!.push({ + toolId, + type, + hour + }); + + // Start timer (if not already started) + startBatchTimer(); +}; + +// Get download counts +export const getDownloadCounts = async () => { + // Get data from database first + const dbCounts = await MongoDownloadCount.find({}).lean(); + + // Create result map with database data + const resultMap = new Map(); + + // Add database data + dbCounts.forEach((item) => { + resultMap.set(item.toolId, { + type: item.type, + downloadCount: item.downloadCount + }); + }); + + // Add cache data + global.__downloadCache!.forEach((record) => { + const existing = resultMap.get(record.toolId); + if (existing) { + existing.downloadCount++; + } else { + resultMap.set(record.toolId, { + type: record.type, + downloadCount: 1 + }); + } + }); + + return resultMap; +}; + +// Get total download count for specific tool +export const getToolDownloadCount = async (toolId: string) => { + const counts = await getDownloadCounts(); + const toolCount = counts.get(toolId); + return toolCount?.downloadCount || 0; +}; diff --git a/projects/marketplace/src/service/mongo/index.ts b/projects/marketplace/src/service/mongo/index.ts new file mode 100644 index 000000000000..1cb3f6e3652d --- /dev/null +++ b/projects/marketplace/src/service/mongo/index.ts @@ -0,0 +1,109 @@ +import { addLog } from '@fastgpt/service/common/system/log'; +import type { Model, Schema } from 'mongoose'; +import { Mongoose } from 'mongoose'; + +export const MONGO_URL = process.env.MONGODB_URI ?? ''; + +declare global { + var mongodb: Mongoose | undefined; +} + +export const connectionMongo = (() => { + if (!global.mongodb) { + global.mongodb = new Mongoose(); + } + return global.mongodb; +})(); + +export const getMongoModel = (name: string, schema: T) => { + if (connectionMongo.models[name]) return connectionMongo.model(name); + addLog.info(`Load model: ${name}`); + + const model = connectionMongo.model(name, schema); + + syncMongoIndex(model); + + return model; +}; + +const syncMongoIndex = async (model: Model) => { + if (process.env.SYNC_INDEX !== '0' && process.env.NODE_ENV !== 'test') { + try { + model.syncIndexes({ background: true }); + } catch (error: any) { + addLog.error('Create index error', error); + } + } +}; + +export const ReadPreference = connectionMongo.mongo.ReadPreference; + +export async function connectMongo(db: Mongoose, url: string): Promise { + if (db.connection.readyState !== 0) { + return db; + } + + if (!url || typeof url !== 'string') { + throw new Error(`Invalid MongoDB connection URL: ${url}`); + } + + try { + db.connection.removeAllListeners('error'); + db.connection.removeAllListeners('disconnected'); + db.set('strictQuery', 'throw'); + + db.connection.on('error', async (error: any) => { + addLog.error('mongo error', error); + try { + if (db.connection.readyState !== 0) { + await db.disconnect(); + await delay(1000); + await connectMongo(db, url); + } + } catch (_error) { + addLog.error('Error during reconnection:', _error); + } + }); + + db.connection.on('disconnected', async () => { + addLog.warn('mongo disconnected'); + try { + if (db.connection.readyState !== 0) { + await db.disconnect(); + await delay(1000); + await connectMongo(db, url); + } + } catch (_error) { + addLog.error('Error during reconnection:', _error); + } + }); + + const options = { + bufferCommands: true, + maxPoolSize: Math.max(30, Number(process.env.MONGO_MAX_LINK || 20)), + minPoolSize: 20, + connectTimeoutMS: 60000, + waitQueueTimeoutMS: 60000, + socketTimeoutMS: 60000, + maxIdleTimeMS: 300000, + retryWrites: true, + retryReads: true, + serverSelectionTimeoutMS: 60000, + heartbeatFrequencyMS: 20000, + maxStalenessSeconds: 120 + }; + + await db.connect(url, options); + addLog.info('mongo connected'); + return db; + } catch (error) { + addLog.error('Mongo connect error', error); + await db.disconnect(); + await delay(1000); + return connectMongo(db, url); + } +} + +export async function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/projects/marketplace/src/service/mongo/models/download.ts b/projects/marketplace/src/service/mongo/models/download.ts new file mode 100644 index 000000000000..17372e70a055 --- /dev/null +++ b/projects/marketplace/src/service/mongo/models/download.ts @@ -0,0 +1,26 @@ +import { Schema } from 'mongoose'; +import z from 'zod'; +import { getMongoModel } from '..'; + +export const pluginTypeEnum = z.enum(['tool']); + +export const PluginZodSchema = z.object({ + type: z.literal('tool'), + toolId: z.string(), + downloadCount: z.number(), + time: z.date() +}); + +export type MongoPluginSchemaType = z.infer; + +const downloadCountSchema = new Schema({ + toolId: { type: String, required: true }, + type: { type: String, required: true, enum: Object.values(pluginTypeEnum.enum) }, + downloadCount: { type: Number, required: true, default: 0 }, + time: { type: Date, required: true } +}); + +// 复合索引:type + toolId + time +downloadCountSchema.index({ type: 1, toolId: 1, time: 1 }, { unique: true }); + +export const MongoDownloadCount = getMongoModel('download_count', downloadCountSchema); diff --git a/projects/marketplace/src/service/tool/data.ts b/projects/marketplace/src/service/tool/data.ts index 201c80d28351..fd4f7cfb4d7b 100644 --- a/projects/marketplace/src/service/tool/data.ts +++ b/projects/marketplace/src/service/tool/data.ts @@ -2,10 +2,11 @@ import type { ToolDetailType } from '@fastgpt/global/sdk/fastgpt-plugin'; import { readFile, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; +import { getDownloadCounts } from '../downloadCount'; declare global { // eslint-disable-next-line no-var - var toolListData: Array; + var toolListData: Array; var expire: Date; } @@ -20,7 +21,12 @@ export const getToolList = async () => { 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); + const downloadCount = await getDownloadCounts(); + + global.toolListData = JSON.parse(data).map((item: ToolDetailType) => ({ + ...item, + downloadCount: downloadCount.get(item.toolId)?.downloadCount ?? 0 + })); } return global.toolListData; }; diff --git a/projects/marketplace/src/web/api.ts b/projects/marketplace/src/web/api.ts index ffc7eea50a18..a613e1aa944f 100644 --- a/projects/marketplace/src/web/api.ts +++ b/projects/marketplace/src/web/api.ts @@ -8,17 +8,24 @@ export const getMarketplaceTools = async (body: ToolListBody) => { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }).then((res) => res.json()); - return res.data as Promise; + return res.data as 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; + return res.data as ToolDetailResponse; }; export const getToolTags = async () => { const res = await fetch('api/tool/tags', { method: 'GET' }).then((res) => res.json()); - return res.data as Promise>; + return res.data as Array; +}; + +export const getDownloadURL = async (toolId: string) => { + const res = await fetch(`api/tool/getDownloadUrl?toolId=${toolId}`, { method: 'GET' }).then( + (res) => res.json() + ); + return res.data as string; };