Commit 9363d7a5 by Finley Ge Committed by GitHub

feat(marketplace): support community plugins and uninstall flow (#7250)

* refactor: simplify workflow and plugin handling

* feat(config/tool): add tag and status filters

* test(marketplace): cover source filtering

* fix(marketplace): skip redundant route updates

* docs(upgrade): update 4-15 release notes

* fix(system-tool): reject offline tools before runtime

* fix(marketplace): address review feedback
parent 5df8fe28
......@@ -49,3 +49,4 @@ pro/admin/worker/
/pro/llm_benchmark/content_benchmark/.env.*.local
/pro/llm_benchmark/content_benchmark/eval-runs/
/pro/llm_benchmark/content_benchmark/.cache/
.gstack/
......@@ -46,10 +46,12 @@ The script only fills missing `appName` values. It does not overwrite existing v
1. Added global API Key tag management and an `appName` display snapshot for historical app-level API Keys, making older API keys compatible and easier to find when they were previously associated with apps.
2. Pre-extract the skill name and description when publishing a skill to help with generation.
3. Added the `WECOM_LOGIN_AUTO_REDIRECT` environment variable to control whether WeCom terminals automatically redirect to login. It is disabled by default.
4. The Plugin Marketplace now supports official/community source filters, and the system tool list supports status and tag filters.
## ⚙️ Improvements
1. Removed system field parameters when AgentV2 calls nested workflows.
2. System tools now support uninstall and reinstall. Uninstalling changes the tool status to Uninstalled and requires entering the tool name for confirmation. Uninstalled tools show only basic information and can be reinstalled.
## 🐛 Fixes
......@@ -58,6 +60,7 @@ The script only fills missing `appName` values. It does not overwrite existing v
3. Fixed an issue where workflow tools did not initialize variables from the tool app's global variable configuration when running a sub-workflow, causing runtime variables such as default variables and system variables to be read incorrectly.
4. Fixed an issue where updates to global variables or outputs from nodes outside the container through **Variable Update** inside loop nodes and parallel execution nodes were not synchronized back to the main workflow by round or task completion. Successful rounds or tasks now write back their changes, while failed rounds or tasks do not commit their changes.
5. The component did not refresh immediately when retrying all Knowledge Base collections.
6. Fixed repeated shallow route updates in the embedded FastGPT Marketplace when filters did not change, which could keep the top progress bar loading.
## 🛠️ Code Improvements
......
......@@ -48,10 +48,12 @@ curl -X POST "{{host}}/api/admin/initv4151" \
1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。
2. 发布技能时,预提取技能名称和描述,便于辅助生成。
3. 新增 `WECOM_LOGIN_AUTO_REDIRECT` 环境变量,可控制企微终端是否自动跳转登录,默认关闭。
4. 插件市场支持官方/社区来源筛选,系统工具列表的状态列和标签列支持筛选。
## ⚙️ 优化
1. AgentV2 调用嵌套工作流时候,去除系统字段参数。
2. 系统工具支持卸载和重新安装。卸载会将工具状态改为“已卸载”,需要输入工具名称确认;已卸载工具只展示基础信息,并可重新安装恢复。
## 🐛 修复
......@@ -60,6 +62,7 @@ curl -X POST "{{host}}/api/admin/initv4151" \
3. 修复工作流工具运行子工作流时未按工具应用的全局变量配置初始化变量,导致默认变量、系统变量等运行态变量读取异常的问题。
4. 修复循环节点和并行执行节点中通过【变量更新】修改全局变量或容器外节点输出时,主流程未按轮次/任务结束同步更新的问题。成功轮次或成功任务会回写本轮变更,失败轮次或失败任务不提交本轮变更。
5. 重试全部知识库集合时,未立即刷新组件。
6. 修复 FastGPT 内嵌插件市场在筛选条件没有变化时重复更新浅路由,导致顶部进度条持续 loading 的问题。
## 🛠️ 代码优化
......
......@@ -4,7 +4,13 @@ import { PluginToolTagSchema } from '../../../../core/plugin/type';
import type { ToolListItemType } from '../../../../sdk/fastgpt-plugin';
export const MarketplaceOfficialSource = 'official';
export const MarketplaceCommunitySource = 'community';
export const MarketplacePkgSourceSchema = z.string().trim().min(1);
export const MarketplaceSourceFilterSchema = z.enum([
MarketplaceOfficialSource,
MarketplaceCommunitySource
]);
export type MarketplaceSourceFilterType = z.infer<typeof MarketplaceSourceFilterSchema>;
const formatToolDetailSchema = z.object({});
const formatToolSimpleSchema = z.object({});
......@@ -15,6 +21,7 @@ export type MarketplaceToolListItemType = ToolListItemType & {
toolId: string;
downloadCount: number;
downloadUrl?: string;
source?: string;
};
export const MarketplaceToolDetailItemSchema = formatToolDetailSchema.extend({
......@@ -27,7 +34,8 @@ export const MarketplaceToolDetailSchema = z.object({
// List
export const GetMarketplaceToolsBodySchema = PaginationSchema.extend({
searchKey: z.string().optional(),
tags: z.array(z.string()).nullish()
tags: z.array(z.string()).nullish(),
source: MarketplaceSourceFilterSchema.optional()
});
export type GetMarketplaceToolsBodyType = z.infer<typeof GetMarketplaceToolsBodySchema>;
......@@ -98,9 +106,7 @@ export const DeleteMarketplacePkgResponseSchema = z.object({
description: '插件来源'
})
});
export type DeleteMarketplacePkgResponseType = z.infer<
typeof DeleteMarketplacePkgResponseSchema
>;
export type DeleteMarketplacePkgResponseType = z.infer<typeof DeleteMarketplacePkgResponseSchema>;
// Tags
export const GetMarketplaceToolTagsResponseSchema = z.array(PluginToolTagSchema);
......
......@@ -202,6 +202,34 @@ const getVisiblePluginStatus = ({
return status ?? PluginStatusEnum.Normal;
};
const assertSystemToolRunnable = ({
tool,
source
}: {
tool?: SystemPluginToolCollectionType | null;
source?: string;
}) => {
if (isDebugToolSource(source)) return;
if (tool?.status === PluginStatusEnum.Offline) {
return Promise.reject(PluginErrEnum.unExist);
}
};
const getParentSystemToolConfig = async ({
pluginId,
idSource,
parentPluginId
}: {
pluginId: string;
idSource?: string;
parentPluginId: string;
}) => {
if (!pluginId.includes('/')) return;
if (idSource === AppToolSourceEnum.systemTool || idSource === AppToolSourceEnum.commercial) {
return getSystemToolConfig(`${idSource}-${parentPluginId}`);
}
};
/**
* SystemTool Repo
* 系统工具仓储层
......@@ -737,6 +765,13 @@ export class SystemToolRepo {
const [parentPluginId] = rawPluginId.split('/');
const dbTool = await getSystemToolConfig(pluginId);
await assertSystemToolRunnable({ tool: dbTool, source: pluginSource });
const parentDbTool = await getParentSystemToolConfig({
pluginId,
idSource,
parentPluginId
});
await assertSystemToolRunnable({ tool: parentDbTool, source: pluginSource });
if (!dbTool?.customConfig?.associatedPluginId) {
const tool = await pluginClient.getTool({
......@@ -778,6 +813,7 @@ export class SystemToolRepo {
}
const tool = await this.getSystemToolRecord(pluginId);
await assertSystemToolRunnable({ tool });
if (!tool || !tool.customConfig?.associatedPluginId) {
return Promise.reject('Plugin is not associated with a app');
......
......@@ -6,6 +6,7 @@ import {
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
const mocks = vi.hoisted(() => ({
listTools: vi.fn(),
......@@ -457,6 +458,28 @@ describe('SystemToolRepo.getSystemToolDetail', () => {
});
describe('SystemToolRepo.getSystemToolWorkflowRuntime', () => {
it('rejects uninstalled workflow tools before loading app version', async () => {
mocks.findSystemTool.mockResolvedValue({
pluginId: 'commercial-workflow-tool',
status: PluginStatusEnum.Offline,
currentCost: 2,
customConfig: {
name: 'Workflow Tool',
avatar: 'workflow.svg',
associatedPluginId: 'app-id'
}
});
await expect(
SystemToolRepo.getInstance().getSystemToolWorkflowRuntime({
pluginId: 'commercial-workflow-tool',
version: 'version-id'
})
).rejects.toBe(PluginErrEnum.unExist);
expect(mocks.getAppVersionById).not.toHaveBeenCalled();
});
it('returns workflow app chatConfig for runtime variable initialization', async () => {
mocks.findSystemTool.mockResolvedValue({
pluginId: 'commercial-workflow-tool',
......@@ -909,6 +932,45 @@ describe('SystemToolRepo.getSystemToolDisplayInfo', () => {
});
describe('SystemToolRepo.getSystemToolRuntime', () => {
it('rejects uninstalled system tools before calling plugin runtime', async () => {
mocks.findSystemTool.mockResolvedValue({
pluginId: 'systemTool-weather',
status: PluginStatusEnum.Offline,
currentCost: 1,
systemKeyCost: 2,
customConfig: {}
});
await expect(
SystemToolRepo.getInstance().getSystemToolRuntime({
pluginId: 'systemTool-weather',
source: 'system'
})
).rejects.toBe(PluginErrEnum.unExist);
expect(mocks.getTool).not.toHaveBeenCalled();
});
it('rejects toolset children when parent system tool is uninstalled', async () => {
mocks.findSystemTool.mockResolvedValueOnce(null).mockResolvedValueOnce({
pluginId: 'systemTool-toolset',
status: PluginStatusEnum.Offline,
currentCost: 1,
systemKeyCost: 2,
customConfig: {}
});
mocks.findSystemTools.mockResolvedValueOnce([]);
await expect(
SystemToolRepo.getInstance().getSystemToolRuntime({
pluginId: 'systemTool-toolset/child',
source: 'system'
})
).rejects.toBe(PluginErrEnum.unExist);
expect(mocks.getTool).not.toHaveBeenCalled();
});
it('does not return configured system secrets for debug source', async () => {
mocks.findSystemTool.mockResolvedValue({
pluginId: 'systemTool-weather',
......
import { Box, Flex } from '@chakra-ui/react';
import { Box, Flex, Menu, MenuButton, MenuItem, MenuList, Portal } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { SystemPluginToolTagType } from '@fastgpt/global/core/plugin/type';
import React, { useMemo } from 'react';
import MyIcon from '../../../common/Icon';
export type MarketplaceSourceFilterValue = 'official' | 'community';
const ToolTagFilterBox = ({
tags,
selectedTagIds,
onTagSelect,
size = 'base'
selectedSource,
onSourceSelect,
size = 'base',
variant = 'default'
}: {
tags: SystemPluginToolTagType[];
selectedTagIds: string[];
onTagSelect: (tagIds: string[]) => void;
selectedSource?: MarketplaceSourceFilterValue;
onSourceSelect?: (source?: MarketplaceSourceFilterValue) => void;
size?: 'base' | 'sm';
variant?: 'default' | 'marketplace';
}) => {
const { t, i18n } = useTranslation();
const isMarketplaceVariant = variant === 'marketplace';
const sourceOptions = [
{ label: t('common:All'), value: undefined },
{ label: t('app:toolkit_official'), value: 'official' },
{ label: t('app:toolkit_community'), value: 'community' }
] as const;
const selectedSourceLabel =
sourceOptions.find((option) => option.value === selectedSource)?.label || t('common:All');
const toggleTag = (tagId: string) => {
if (selectedTagIds.includes(tagId)) {
......@@ -27,11 +44,18 @@ const ToolTagFilterBox = ({
const tagBaseStyles = useMemo(() => {
const sizeStyles = {
base: {
px: 3,
py: 1.5,
fontSize: 'sm'
},
base: isMarketplaceVariant
? {
px: '13px',
h: '35px',
fontSize: '14px',
lineHeight: '21px'
}
: {
px: 3,
py: 1.5,
fontSize: 'sm'
},
sm: {
px: 2,
py: 1,
......@@ -42,14 +66,20 @@ const ToolTagFilterBox = ({
return {
...sizeStyles[size],
fontWeight: 'medium',
color: 'myGray.700',
color: isMarketplaceVariant ? '#383F50' : 'myGray.700',
border: '1px solid',
borderColor: 'myGray.200',
borderColor: isMarketplaceVariant ? '#E8EBF0' : 'myGray.200',
whiteSpace: 'nowrap',
flexShrink: 0,
cursor: 'pointer'
cursor: 'pointer',
...(isMarketplaceVariant
? {
alignItems: 'center',
justifyContent: 'center'
}
: {})
};
}, [size]);
}, [isMarketplaceVariant, size]);
return (
<Flex
......@@ -83,15 +113,98 @@ const ToolTagFilterBox = ({
}
}}
>
{isMarketplaceVariant ? (
<Menu placement="bottom-start" autoSelect={false} isLazy>
<MenuButton
as={Box}
{...tagBaseStyles}
display={'inline-block'}
w={'65px'}
rounded={'6px'}
bg={'white'}
position={'relative'}
_hover={{ bg: 'myGray.50' }}
_expanded={{ bg: 'myGray.50' }}
>
<Box
position={'absolute'}
left={'13px'}
top={'7px'}
h={'21px'}
lineHeight={'21px'}
whiteSpace={'nowrap'}
>
{selectedSourceLabel}
</Box>
<Box
position={'absolute'}
left={'45px'}
top={'10.5px'}
w={'7px'}
h={'14px'}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
overflow={'visible'}
>
<MyIcon
name={'core/chat/chevronSelector'}
w={'14px'}
h={'14px'}
color={'#667085'}
verticalAlign={'middle'}
/>
</Box>
</MenuButton>
<Portal>
<MenuList
minW={'92px'}
p={'6px'}
border={'1px solid'}
borderColor={'#E8EBF0'}
boxShadow={'3'}
zIndex={2000}
>
{sourceOptions.map((option) => {
const isSelected = option.value === selectedSource;
return (
<MenuItem
key={option.value ?? 'all'}
h={'32px'}
borderRadius={'6px'}
fontSize={'14px'}
fontWeight={'medium'}
color={isSelected ? 'primary.600' : '#383F50'}
bg={isSelected ? 'myGray.50' : 'white'}
_hover={{ bg: 'myGray.50' }}
onClick={() => onSourceSelect?.(option.value)}
>
<Box flex={1}>{option.label}</Box>
{isSelected && <MyIcon name={'common/check'} w={4} color={'primary.600'} />}
</MenuItem>
);
})}
</MenuList>
</Portal>
</Menu>
) : (
<Box
{...tagBaseStyles}
rounded={'sm'}
bg={selectedTagIds.length === 0 ? 'myGray.150' : 'transparent'}
onClick={() => onTagSelect([])}
>
{t('common:All')}
</Box>
)}
<Box
{...tagBaseStyles}
rounded={'sm'}
bg={selectedTagIds.length === 0 ? 'myGray.150' : 'transparent'}
onClick={() => onTagSelect([])}
>
{t('common:All')}
</Box>
<Box mx={2} h={'20px'} w={'1px'} bg={'myGray.200'} flexShrink={0} />
mx={2}
h={'20px'}
w={'1px'}
bg={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
flexShrink={0}
/>
<Box flex={1}>
<Flex gap={2} flexWrap="nowrap">
{tags.map((tag) => {
......@@ -100,8 +213,15 @@ const ToolTagFilterBox = ({
<Box
key={tag.tagId}
{...tagBaseStyles}
display={isMarketplaceVariant ? 'inline-flex' : undefined}
rounded={'full'}
bg={isSelected ? 'myGray.150 !important' : 'transparent'}
bg={(() => {
if (isMarketplaceVariant) {
return isSelected ? 'myGray.50 !important' : 'white';
}
return isSelected ? 'myGray.150 !important' : 'transparent';
})()}
_hover={isMarketplaceVariant ? { bg: 'myGray.50' } : undefined}
onClick={() => toggleTag(tag.tagId)}
>
{t(parseI18nString(tag.tagName, i18n.language))}
......
......@@ -8,6 +8,8 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
import DebugToolTag from './DebugToolTag';
const marketplaceOfficialSource = 'official';
export type ToolCardItemType = {
id: string;
name: string;
......@@ -43,7 +45,8 @@ const ToolCard = ({
onDelete,
onUpdate,
onClickCard,
showActionButton = true
showActionButton = true,
variant = 'default'
}: {
item: ToolCardItemType;
systemTitle?: string;
......@@ -55,10 +58,16 @@ const ToolCard = ({
onUpdate?: () => Promise<void>;
onClickCard?: () => void;
showActionButton?: boolean;
variant?: 'default' | 'marketplace';
}) => {
const { t, i18n } = useTranslation();
const tagsContainerRef = useRef<HTMLDivElement>(null);
const [visibleTagsCount, setVisibleTagsCount] = useState(item.tags?.length || 0);
const isMarketplaceVariant = variant === 'marketplace';
const showOfficialBadge =
isMarketplaceVariant && (!item.source || item.source === marketplaceOfficialSource);
const showMarketplaceUninstallButton =
isMarketplaceVariant && mode === 'admin' && item.installed && !showActionButton;
useEffect(() => {
const calculate = () => {
......@@ -109,6 +118,8 @@ const ToolCard = ({
};
if (mode === 'admin') {
if (isMarketplaceVariant) return null;
return item.installed
? {
label: t('app:toolkit_installed'),
......@@ -130,14 +141,25 @@ const ToolCard = ({
}
: null;
}
}, [item.installed, item.status]);
}, [isMarketplaceVariant, item.installed, item.status, mode, t]);
return (
<MyBox
key={item.id}
p={4}
pb={3}
border={'base'}
{...(isMarketplaceVariant
? {
px: '17px',
pt: '17px',
pb: '13px',
minH: '178px',
border: '1px solid',
borderColor: '#DFE2EA'
}
: {
p: 4,
pb: 3,
border: 'base'
})}
bg={'white'}
borderRadius={'10px'}
display={'flex'}
......@@ -150,7 +172,7 @@ const ToolCard = ({
}}
_hover={{
boxShadow: '0 4px 4px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08);',
...(showActionButton
...(showActionButton || showMarketplaceUninstallButton
? {
'& .install-button': {
display: 'flex'
......@@ -178,8 +200,8 @@ const ToolCard = ({
<Flex
alignItems="center"
position={'absolute'}
top={4}
right={4}
top={isMarketplaceVariant ? '17px' : 4}
right={isMarketplaceVariant ? '17px' : 4}
px={2}
py={0.5}
bg={'rgb(255, 247, 237)'}
......@@ -209,11 +231,44 @@ const ToolCard = ({
</Flex>
)}
<HStack minW={0}>
<Avatar src={item.icon} borderRadius={'sm'} w={'1.5rem'} />
<Box color={'myGray.900'} fontWeight={'medium'} minW={0} className={'textEllipsis'}>
<HStack
minW={0}
spacing={isMarketplaceVariant ? 2 : undefined}
h={isMarketplaceVariant ? '24px' : undefined}
>
<Avatar
src={item.icon}
borderRadius={'sm'}
w={isMarketplaceVariant ? '24px' : '1.5rem'}
h={isMarketplaceVariant ? '24px' : undefined}
/>
<Box
color={isMarketplaceVariant ? '#111824' : 'myGray.900'}
fontSize={isMarketplaceVariant ? '16px' : undefined}
lineHeight={isMarketplaceVariant ? '24px' : undefined}
fontWeight={'medium'}
minW={0}
flexShrink={1}
className={'textEllipsis'}
>
{parseI18nString(item.name, i18n.language)}
</Box>
{showOfficialBadge && (
<Box
px={'8px'}
py={'4px'}
borderRadius={'6px'}
bg={'#F0F4FF'}
color={'#3370FF'}
fontSize={'10px'}
lineHeight={'14px'}
fontWeight={'medium'}
letterSpacing={'0.2px'}
flexShrink={0}
>
{t('app:toolkit_official')}
</Box>
)}
{item.isDebug && <DebugToolTag />}
{statusLabel && (
<Flex
......@@ -229,32 +284,40 @@ const ToolCard = ({
)}
</HStack>
<Box
flex={['1 0 48px', '1 0 56px']}
mt={3}
flex={isMarketplaceVariant ? '1 0 68px' : ['1 0 48px', '1 0 56px']}
mt={isMarketplaceVariant ? undefined : 3}
pt={isMarketplaceVariant ? '12px' : undefined}
pr={1}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
fontSize={isMarketplaceVariant ? '12.8px' : 'xs'}
lineHeight={isMarketplaceVariant ? '19.2px' : undefined}
color={isMarketplaceVariant ? '#667085' : 'myGray.500'}
>
<Box className={'textEllipsis2'}>
{parseI18nString(item.description || '', i18n.language) ||
t('app:templateMarket.no_intro')}
</Box>
</Box>
<Flex gap={1} overflow={'hidden'} ref={tagsContainerRef}>
<Flex
h={isMarketplaceVariant ? '26.5px' : undefined}
gap={1}
overflow={'hidden'}
ref={tagsContainerRef}
>
{item.tags?.slice(0, visibleTagsCount).map((tag) => {
return (
<Box
key={tag}
px={2}
py={1}
px={isMarketplaceVariant ? '9px' : 2}
py={isMarketplaceVariant ? '5px' : 1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
borderColor={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
fontSize={'11px'}
lineHeight={isMarketplaceVariant ? '16.5px' : undefined}
fontWeight={'medium'}
color={'myGray.700'}
color={isMarketplaceVariant ? '#383F50' : 'myGray.700'}
flexShrink={0}
data-tag-item
>
......@@ -264,14 +327,15 @@ const ToolCard = ({
})}
{item.tags && item.tags.length > visibleTagsCount && (
<Box
px={2}
py={1}
px={isMarketplaceVariant ? '9px' : 2}
py={isMarketplaceVariant ? '5px' : 1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
borderColor={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
fontSize={'11px'}
lineHeight={isMarketplaceVariant ? '16.5px' : undefined}
fontWeight={'medium'}
color={'myGray.700'}
color={isMarketplaceVariant ? '#383F50' : 'myGray.700'}
flexShrink={0}
>
+{item.tags.length - visibleTagsCount}
......@@ -279,11 +343,18 @@ const ToolCard = ({
)}
</Flex>
<Flex w={'full'} fontSize={'mini'} alignItems={'end'} justifyContent={'space-between'}>
<Flex
w={'full'}
h={isMarketplaceVariant ? '30px' : undefined}
fontSize={isMarketplaceVariant ? '12px' : 'mini'}
lineHeight={isMarketplaceVariant ? '18px' : undefined}
alignItems={'end'}
justifyContent={'space-between'}
>
<Box
className="author-info"
color={'myGray.500'}
mt={3}
color={isMarketplaceVariant ? '#667085' : 'myGray.500'}
mt={isMarketplaceVariant ? undefined : 3}
>{`by ${item.author || systemTitle || 'FastGPT'}`}</Box>
{/*TODO: when statistics is ready*/}
{/*<Flex flexDirection={'row'} gap={1} className="download-count" color={'myGray.500'} mt={3}>
......@@ -296,7 +367,21 @@ const ToolCard = ({
</Flex>*/}
<Flex gap={2} alignItems={'center'} ml={'auto'}>
{showActionButton && mode === 'marketplace' ? (
{showMarketplaceUninstallButton ? (
<Button
className="install-button"
size={'sm'}
variant={'dangerOutline'}
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
isLoading={isInstallingOrDeleting}
display={'none'}
>
{t('app:toolkit_uninstall')}
</Button>
) : showActionButton && mode === 'marketplace' ? (
<Button
className="install-button"
size={'sm'}
......
......@@ -35,6 +35,7 @@ const ToolDetailDrawer = ({
onClose,
selectedTool,
onToggleInstall,
onDelete,
onUpdate,
isUpdating,
systemTitle,
......@@ -49,7 +50,8 @@ const ToolDetailDrawer = ({
onClose: () => void;
selectedTool: ToolCardItemType;
onToggleInstall?: (installed: boolean, version?: string) => void | Promise<void>;
onUpdate?: (version?: string) => void;
onDelete?: () => void | Promise<void>;
onUpdate?: (version?: string) => void | Promise<void>;
isUpdating?: boolean;
systemTitle?: string;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
......@@ -62,12 +64,15 @@ const ToolDetailDrawer = ({
}) => {
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const [isInstalled, setIsInstalled] = useState(selectedTool.installed);
const isInstalled = selectedTool.installed;
const [selectedVersion, setSelectedVersion] = useState<string | undefined>(selectedTool.version);
const isDownload = useMemo(() => {
return mode === 'marketplace';
}, [mode]);
const showUninstallButton = mode === 'admin' && isInstalled && !!onDelete;
const showInstallButton = showActionButton && !showUninstallButton;
const hasUpdateButton = !!selectedTool.update && !!onUpdate && mode !== 'marketplace';
const {
data: toolVersions = [],
......@@ -88,7 +93,7 @@ const ToolDetailDrawer = ({
if (selectedTool.id && onFetchVersions) {
fetchToolVersions(selectedTool.id);
}
}, [selectedTool.id]);
}, [fetchToolVersions, onFetchVersions, selectedTool.id]);
const activeVersion = selectedVersion || toolVersions[0]?.version;
......@@ -182,46 +187,50 @@ const ToolDetailDrawer = ({
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${parentTool?.author || systemTitle || 'FastGPT'}`}
</Box>
{(showActionButton || (selectedTool.update && onUpdate && mode !== 'marketplace')) && (
{(showInstallButton || showUninstallButton || hasUpdateButton) && (
<Flex mt={3} gap={2}>
{/* Determine if we have two buttons */}
{(() => {
const hasUpdateButton = selectedTool.update && onUpdate && mode !== 'marketplace';
return (
<>
{showActionButton && (
<Button
flex={1}
variant={isInstalled ? 'primaryOutline' : 'primary'}
isLoading={isLoading || loadingDetail}
isDisabled={isUpdating}
onClick={async () => {
await onToggleInstall?.(!isInstalled, currentVersion);
if (mode === 'marketplace') return;
setIsInstalled(!isInstalled);
}}
>
{isDownload
? t('common:Download')
: isInstalled
? t('app:toolkit_uninstall')
: t('app:toolkit_install')}
</Button>
)}
{hasUpdateButton && (
<Button
variant="primary"
flex={1}
isLoading={isUpdating || loadingDetail}
onClick={() => onUpdate?.(currentVersion)}
>
{t('app:custom_plugin_update')}
</Button>
)}
</>
);
})()}
{showInstallButton && (
<Button
flex={'1 1 0'}
minW={0}
variant={isInstalled ? 'primaryOutline' : 'primary'}
isLoading={isLoading || loadingDetail}
isDisabled={isUpdating}
onClick={async () => {
await onToggleInstall?.(!isInstalled, currentVersion);
}}
>
{isDownload
? t('common:Download')
: isInstalled
? t('app:toolkit_uninstall')
: t('app:toolkit_install')}
</Button>
)}
{hasUpdateButton && (
<Button
variant="primary"
flex={'1 1 0'}
minW={0}
isLoading={isUpdating || loadingDetail}
onClick={async () => {
await onUpdate?.(currentVersion);
}}
>
{t('app:custom_plugin_update')}
</Button>
)}
{showUninstallButton && (
<Button
flex={hasUpdateButton ? '0 0 62px' : '1 1 0'}
minW={0}
variant="dangerOutline"
isLoading={isLoading || loadingDetail}
onClick={() => onDelete?.()}
>
{t('app:toolkit_uninstall')}
</Button>
)}
</Flex>
)}
......
......@@ -83,6 +83,7 @@
"confirm_delete_chats": "Are you sure you want to delete {{n}} chat records?\nThis cannot be undone.",
"confirm_delete_folder_tip": "When you delete this folder, all applications and corresponding chat records under it will be deleted.",
"confirm_delete_tool": "Confirm to delete this tool?",
"confirm_uninstall_tool": "After uninstalling this plugin, apps that depend on it may not run properly. Please proceed carefully.",
"copilot_config_message": "Current Node Configuration Information: \n Code Type: {{codeType}} \n Current Code: \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n Input Parameters: {{inputs}} \n Output Parameters: {{outputs}}",
"copilot_confirm_message": "The original configuration has been received to understand the current code structure and input and output parameters. \nPlease explain your optimization requirements.",
"copy_one_app": "Create Duplicate",
......@@ -115,6 +116,9 @@
"custom_plugin_config_title": "{{name}} Configuration",
"custom_plugin_create": "Create Plugin",
"custom_plugin_delete_success": "Delete successful",
"custom_plugin_install_success": "Install successful",
"custom_plugin_reinstall_success": "Reinstall successful",
"custom_plugin_uninstall_success": "Uninstall successful",
"custom_plugin_has_token_fee_label": "Charge Token Fee",
"custom_plugin_intro_label": "Introduction",
"custom_plugin_intro_placeholder": "Add an introduction for this application",
......@@ -383,7 +387,7 @@
"tool_input_param_tip": "This tool requires configuration of relevant information for normal operation.",
"tool_not_active": "This tool has not been activated yet",
"tool_not_desc": "The tool lacks a description ~",
"tool_offset_tips": "This tool is no longer available and will interrupt application operation. Please replace it immediately.",
"tool_offset_tips": "This tool has been uninstalled and will interrupt application operation. Please replace it immediately.",
"tool_param_config": "Parameter configuration",
"tool_params_description_tips": "The description of parameter functions, if used as tool invocation parameters, affects the model tool invocation effect.",
"tool_run_free": "This tool runs without points consumption",
......@@ -396,8 +400,10 @@
"toolkit_activation_required": "Activation Required",
"toolkit_add_resource": "Add resources",
"toolkit_basic_config": "Basic Configuration",
"toolkit_basic_info": "Basic Info",
"toolkit_batch_update": "Batch Update",
"toolkit_call_points_label": "Call Points",
"toolkit_community": "Community",
"toolkit_config_system_key": "Configure System Key",
"toolkit_contribute_resource": "Contribute Resource",
"toolkit_debug_connection_link": "Debug Link",
......@@ -425,6 +431,7 @@
"toolkit_name": "Name",
"toolkit_no_call_points": "This tool does not require call points",
"toolkit_no_plugins": "No plugins",
"toolkit_official": "Official",
"toolkit_open_marketplace": "Open Marketplace",
"toolkit_outputs": "Output Parameters",
"toolkit_params_description": "Parameters",
......@@ -435,12 +442,13 @@
"toolkit_plugin_version": "Plugin Version",
"toolkit_promote_tags": "Promote Tags",
"toolkit_promote_tags_tip": "Users with the following tags will see the \"Promoted\" badge",
"toolkit_reinstall": "Reinstall",
"toolkit_search_placeholder": "Search tool names",
"toolkit_select_app": "Select an existing app",
"toolkit_select_user_tags": "Select user tags",
"toolkit_status": "Status",
"toolkit_status_normal": "Normal",
"toolkit_status_offline": "Offline",
"toolkit_status_offline": "Uninstalled",
"toolkit_status_soon_offline": "Soon Offline",
"toolkit_system_key": "System Key",
"toolkit_system_key_configured": "Configured",
......@@ -462,7 +470,8 @@
"toolkit_tool_name": "Tool Name",
"toolkit_tutorial_link": "Open tutorial link",
"toolkit_uninstall": "Uninstall",
"toolkit_uninstalled": "Uninstalled",
"toolkit_uninstalled_reinstall_tip": "This plugin has been uninstalled. Please reinstall it.",
"toolkit_uninstalled": "Not Installed",
"toolkit_updatable": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins",
"toolkit_user_guide": "User Guide",
......
......@@ -83,6 +83,7 @@
"confirm_delete_chats": "确认删除 {{n}} 组对话记录?记录将会永久删除!",
"confirm_delete_folder_tip": "删除该文件夹时,将会删除它下面所有应用及对应的聊天记录。",
"confirm_delete_tool": "确认删除该工具?",
"confirm_uninstall_tool": "插件卸载后,依赖此插件的应用可能无法正常运行,请谨慎操作。",
"copilot_config_message": "`当前节点配置信息: \n代码类型:{{codeType}} \n当前代码: \\`\\`\\`{{codeType}} \n{{code}} \\`\\`\\` \n输入参数: {{inputs}} \n输出参数: {{outputs}}`",
"copilot_confirm_message": "已接收到原始配置,了解当前代码结构和输入输出参数。请说明您的优化需求。",
"copy_one_app": "创建副本",
......@@ -115,6 +116,9 @@
"custom_plugin_config_title": "{{name}}配置",
"custom_plugin_create": "新建插件",
"custom_plugin_delete_success": "删除成功",
"custom_plugin_install_success": "安装成功",
"custom_plugin_reinstall_success": "重新安装成功",
"custom_plugin_uninstall_success": "卸载成功",
"custom_plugin_has_token_fee_label": "是否收取 Token 费用",
"custom_plugin_intro_label": "介绍",
"custom_plugin_intro_placeholder": "为这个应用添加一个介绍",
......@@ -383,7 +387,7 @@
"tool_input_param_tip": "该工具正常运行需要配置相关信息",
"tool_not_active": "该工具尚未激活",
"tool_not_desc": "工具缺少描述~",
"tool_offset_tips": "该工具已无法使用,将中断应用运行,请立即替换",
"tool_offset_tips": "该工具已卸载,将中断应用运行,请立即替换",
"tool_param_config": "参数配置",
"tool_params_description_tips": "参数功能的描述,若作为工具调用参数,影响模型工具调用效果",
"tool_run_free": "该工具运行无积分消耗",
......@@ -396,8 +400,10 @@
"toolkit_activation_required": "需要激活",
"toolkit_add_resource": "添加插件",
"toolkit_basic_config": "基础配置",
"toolkit_basic_info": "基础信息",
"toolkit_batch_update": "批量更新",
"toolkit_call_points_label": "调用积分",
"toolkit_community": "社区",
"toolkit_config_system_key": "是否配置系统密钥",
"toolkit_contribute_resource": "贡献插件",
"toolkit_debug_connection_link": "调试链接",
......@@ -425,6 +431,7 @@
"toolkit_name": "名称",
"toolkit_no_call_points": "该工具无需调用积分",
"toolkit_no_plugins": "暂无插件",
"toolkit_official": "官方",
"toolkit_open_marketplace": "打开插件市场",
"toolkit_outputs": "输出参数",
"toolkit_params_description": "参数说明",
......@@ -435,12 +442,13 @@
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推荐标签",
"toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识",
"toolkit_reinstall": "重新安装",
"toolkit_search_placeholder": "搜索工具名称",
"toolkit_select_app": "选择现有应用",
"toolkit_select_user_tags": "选择用户标签",
"toolkit_status": "状态",
"toolkit_status_normal": "正常",
"toolkit_status_offline": "已下线",
"toolkit_status_offline": "已卸载",
"toolkit_status_soon_offline": "即将下线",
"toolkit_system_key": "系统密钥",
"toolkit_system_key_configured": "已配置",
......@@ -462,6 +470,7 @@
"toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教程链接",
"toolkit_uninstall": "卸载",
"toolkit_uninstalled_reinstall_tip": "该插件已卸载,请重新安装",
"toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件",
......
......@@ -81,6 +81,7 @@
"confirm_delete_chats": "確認刪除 {{n}} 組對話記錄?\n記錄將會永久刪除!",
"confirm_delete_folder_tip": "刪除該文件夾時,將會刪除它下面所有應用及對應的聊天記錄。",
"confirm_delete_tool": "確認刪除該工具?",
"confirm_uninstall_tool": "插件卸載後,依賴此插件的應用可能無法正常運行,請謹慎操作。",
"copilot_config_message": "當前節點配置信息: \n代碼類型:{{codeType}} \n當前代碼: \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n輸入參數: {{inputs}} \n輸出參數: {{outputs}}",
"copilot_confirm_message": "已接收到原始配置,了解當前代碼結構和輸入輸出參數。\n請說明您的優化需求。",
"copy_one_app": "建立副本",
......@@ -112,6 +113,9 @@
"custom_plugin_config_title": "{{name}}配置",
"custom_plugin_create": "新建外掛",
"custom_plugin_delete_success": "刪除成功",
"custom_plugin_install_success": "安裝成功",
"custom_plugin_reinstall_success": "重新安裝成功",
"custom_plugin_uninstall_success": "卸載成功",
"custom_plugin_has_token_fee_label": "是否收取 Token 費用",
"custom_plugin_intro_label": "介紹",
"custom_plugin_intro_placeholder": "為這個應用程式新增一個介紹",
......@@ -373,7 +377,7 @@
"tool_input_param_tip": "該工具正常運行需要配置相關信息",
"tool_not_active": "該工具尚未激活",
"tool_not_desc": "工具缺少描述~",
"tool_offset_tips": "該工具已無法使用,將中斷應用運行,請立即替換",
"tool_offset_tips": "該工具已卸載,將中斷應用運行,請立即替換",
"tool_param_config": "參數配置",
"tool_params_description_tips": "參數功能的描述,若作為工具調用參數,影響模型工具調用效果",
"tool_run_free": "該工具運行無積分消耗",
......@@ -386,8 +390,10 @@
"toolkit_activation_required": "需要激活",
"toolkit_add_resource": "添加資源",
"toolkit_basic_config": "基礎配置",
"toolkit_basic_info": "基礎資訊",
"toolkit_batch_update": "批次更新",
"toolkit_call_points_label": "調用積分",
"toolkit_community": "社區",
"toolkit_config_system_key": "是否配置系統密鑰",
"toolkit_contribute_resource": "貢獻資源",
"toolkit_debug_connection_link": "調試連結",
......@@ -415,6 +421,7 @@
"toolkit_name": "名稱",
"toolkit_no_call_points": "該工具無需調用積分",
"toolkit_no_plugins": "暫無插件",
"toolkit_official": "官方",
"toolkit_open_marketplace": "打開資源市場",
"toolkit_outputs": "輸出參數",
"toolkit_params_description": "參數說明",
......@@ -425,12 +432,13 @@
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推薦標籤",
"toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識",
"toolkit_reinstall": "重新安裝",
"toolkit_search_placeholder": "搜索工具名稱",
"toolkit_select_app": "選擇現有應用",
"toolkit_select_user_tags": "選擇使用者標籤",
"toolkit_status": "狀態",
"toolkit_status_normal": "正常",
"toolkit_status_offline": "下線",
"toolkit_status_offline": "已卸載",
"toolkit_status_soon_offline": "即將下線",
"toolkit_system_key": "系統密鑰",
"toolkit_system_key_configured": "已配置",
......@@ -451,6 +459,7 @@
"toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教學連結",
"toolkit_uninstall": "卸載",
"toolkit_uninstalled_reinstall_tip": "該插件已卸載,請重新安裝",
"toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式",
......
......@@ -247,6 +247,29 @@ const Button = defineStyleConfig({
color: 'red.600'
}
},
dangerOutline: {
color: 'red.600',
border: '1px solid',
borderColor: 'red.500',
bg: 'white',
transition: 'background 0.1s',
boxShadow: '0px 0px 1px 0px rgba(19, 51, 107, 0.08), 0px 1px 2px 0px rgba(19, 51, 107, 0.05)',
_hover: {
color: 'red.600',
borderColor: 'red.600',
bg: 'red.50'
},
_active: {
color: 'red.600',
borderColor: 'red.600',
bg: 'red.50'
},
_disabled: {
color: 'red.300 !important',
borderColor: 'red.200 !important',
bg: 'white !important'
}
},
grayBase: {
bg: 'myGray.150',
color: 'myGray.900',
......
......@@ -85,6 +85,23 @@ type Props = FlowNodeItemType & {
colorSchema?: keyof typeof NodeGradients;
};
const getCurrentSystemToolTemplate = async (node?: FlowNodeItemType) => {
if (!node?.pluginId || node.pluginData?.error || isDebugToolSource(node.source)) return;
try {
const { source } = splitCombineToolId(node.pluginId);
if (source !== AppToolSourceEnum.systemTool && source !== AppToolSourceEnum.commercial) return;
return getClientToolPreviewNode({
appId: node.pluginId,
versionId: node.version ?? '',
source: node.source
});
} catch {
return;
}
};
const NodeCard = (props: Props) => {
const { t } = useTranslation();
const {
......@@ -227,28 +244,6 @@ const NodeCard = (props: Props) => {
const isAppNode = node && AppNodeFlowNodeTypeMap[node?.flowNodeType];
const isLoopNode = isNestedParentNodeType(node?.flowNodeType ?? '');
const showVersion = useMemo(() => {
const source = node?.pluginId ? splitCombineToolId(node.pluginId).source : undefined;
if (isDebugToolSource(node?.source)) return false;
// 1. MCP/HTTP single tools use the latest toolset content and do not expose version selection.
if (source === AppToolSourceEnum.mcp || source === AppToolSourceEnum.http) return false;
// 2. MCP/HTTP tool sets do not have version
if (
isAppNode &&
(node.toolConfig?.mcpToolSet ||
node.toolConfig?.mcpTool ||
node?.toolConfig?.httpToolSet ||
node?.toolConfig?.httpTool)
)
return false;
// 3. Team app/System commercial plugin
if (isAppNode && node?.pluginId && !node?.pluginData?.error) return true;
// 4. System tool
if (isAppNode && node?.toolConfig?.systemTool) return true;
return false;
}, [isAppNode, node]);
const { data: nodeTemplate } = useRequest(
async () => {
......@@ -257,7 +252,21 @@ const NodeCard = (props: Props) => {
}
if (isAppNode) {
return { ...node, ...node.pluginData };
const currentSystemToolTemplate = await getCurrentSystemToolTemplate(node);
return {
...node,
...node.pluginData,
...(currentSystemToolTemplate
? {
status: currentSystemToolTemplate.status,
courseUrl: currentSystemToolTemplate.courseUrl,
readmeUrl: currentSystemToolTemplate.readmeUrl,
userGuide: currentSystemToolTemplate.userGuide,
diagram: currentSystemToolTemplate.diagram
}
: {})
};
} else {
const template = moduleTemplatesFlat.find(
(item) => item.flowNodeType === node?.flowNodeType
......@@ -290,10 +299,45 @@ const NodeCard = (props: Props) => {
}
]);
},
manual: false
manual: false,
errorToast: '',
refreshDeps: [
isAppNode,
node?.pluginData?.error,
node?.pluginData?.status,
node?.pluginId,
node?.source,
node?.version
]
}
);
const toolStatus = nodeTemplate?.status ?? node?.pluginData?.status;
const showVersion = useMemo(() => {
if (toolStatus === PluginStatusEnum.Offline) return false;
const source = node?.pluginId ? splitCombineToolId(node.pluginId).source : undefined;
if (isDebugToolSource(node?.source)) return false;
// 1. MCP/HTTP single tools use the latest toolset content and do not expose version selection.
if (source === AppToolSourceEnum.mcp || source === AppToolSourceEnum.http) return false;
// 2. MCP/HTTP tool sets do not have version
if (
isAppNode &&
(node.toolConfig?.mcpToolSet ||
node.toolConfig?.mcpTool ||
node?.toolConfig?.httpToolSet ||
node?.toolConfig?.httpTool)
)
return false;
// 3. Team app/System commercial plugin
if (isAppNode && node?.pluginId && !node?.pluginData?.error) return true;
// 4. System tool
if (isAppNode && node?.toolConfig?.systemTool) return true;
return false;
}, [isAppNode, node, toolStatus]);
/* Node header - 重构后的版本,依赖项大幅减少 */
const error = useMemo(() => formatToolError(node?.pluginData?.error), [node?.pluginData?.error]);
const showHeader = node?.flowNodeType !== FlowNodeTypeEnum.comment;
......
......@@ -7,6 +7,7 @@ import {
UpdateWorkflowToolBodySchema,
type UpdateWorkflowToolBodyType
} from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
export type updateWorkflowToolQuery = {};
......@@ -14,13 +15,23 @@ export type updateWorkflowToolBody = UpdateWorkflowToolBodyType;
export type updateWorkflowToolResponse = {};
const omitUndefinedFields = <T extends Record<string, unknown>>(fields: T) =>
Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined)
) as Partial<T>;
async function handler(
req: ApiRequestProps<updateWorkflowToolBody, updateWorkflowToolQuery>,
res: ApiResponseType<any>
): Promise<updateWorkflowToolResponse> {
await authSystemAdmin({ req });
const { id: pluginId, ...updateFields } = UpdateWorkflowToolBodySchema.parse(req.body);
const {
body: { id: pluginId, ...updateFields }
} = parseApiInput({
req,
bodySchema: UpdateWorkflowToolBodySchema
});
const plugin = await MongoSystemTool.findOne({ pluginId });
if (!plugin?.customConfig?.associatedPluginId) {
......@@ -40,16 +51,16 @@ async function handler(
plugin.customConfig.name !== nextCustomConfig.name ||
plugin.customConfig.avatar !== nextCustomConfig.avatar ||
plugin.customConfig.intro !== nextCustomConfig.intro;
const baseUpdateFields = {
const baseUpdateFields = omitUndefinedFields({
pluginId,
status: updateFields.status,
originCost: updateFields.originCost,
currentCost: updateFields.currentCost,
hasTokenFee: updateFields.hasTokenFee,
systemKeyCost: updateFields.systemKeyCost,
promoteTags: updateFields.promoteTags ?? null,
hideTags: updateFields.hideTags ?? null
};
promoteTags: 'promoteTags' in updateFields ? (updateFields.promoteTags ?? null) : undefined,
hideTags: 'hideTags' in updateFields ? (updateFields.hideTags ?? null) : undefined
});
if ('secretsVal' in updateFields) {
Object.assign(baseUpdateFields, {
secretsVal: updateFields.secretsVal ?? null
......
......@@ -7,6 +7,7 @@ import {
UpdateSystemToolBodySchema,
type UpdateSystemToolBodyType
} from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
export type updateToolQuery = {};
......@@ -14,12 +15,22 @@ export type updateToolBody = UpdateSystemToolBodyType;
export type updateToolResponse = {};
const omitUndefinedFields = <T extends Record<string, unknown>>(fields: T) =>
Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined)
) as Partial<T>;
async function handler(
req: ApiRequestProps<updateToolBody, updateToolQuery>,
res: ApiResponseType<any>
): Promise<updateToolResponse> {
await authSystemAdmin({ req });
const { id: pluginId, ...updateFields } = UpdateSystemToolBodySchema.parse(req.body);
const {
body: { id: pluginId, ...updateFields }
} = parseApiInput({
req,
bodySchema: UpdateSystemToolBodySchema
});
const plugin = await MongoSystemTool.findOne({ pluginId });
......@@ -28,16 +39,16 @@ async function handler(
}
// 基础更新字段
const baseUpdateFields = {
const baseUpdateFields = omitUndefinedFields({
pluginId,
status: updateFields.status,
originCost: updateFields.originCost,
currentCost: updateFields.currentCost,
hasTokenFee: updateFields.hasTokenFee,
systemKeyCost: updateFields.systemKeyCost,
promoteTags: updateFields.promoteTags ?? null,
hideTags: updateFields.hideTags ?? null
};
promoteTags: 'promoteTags' in updateFields ? (updateFields.promoteTags ?? null) : undefined,
hideTags: 'hideTags' in updateFields ? (updateFields.hideTags ?? null) : undefined
});
if ('secretsVal' in updateFields) {
Object.assign(baseUpdateFields, {
secretsVal: updateFields.secretsVal ?? null
......@@ -63,7 +74,7 @@ async function handler(
// 如果有子工具,更新子工具
for await (const tool of updateFields.children || []) {
const childPluginId = tool.id.includes('/') ? tool.id : `${pluginId}/${tool.id}`;
const childUpdateFields = {
const childUpdateFields = omitUndefinedFields({
pluginId: childPluginId,
systemKeyCost: tool.systemKeyCost,
currentCost: updateFields.currentCost,
......@@ -72,7 +83,7 @@ async function handler(
originCost: updateFields.originCost,
promoteTags: updateFields.promoteTags,
hideTags: updateFields.hideTags
};
});
if ('secretsVal' in updateFields) {
Object.assign(childUpdateFields, {
secretsVal: updateFields.secretsVal
......
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { Box, Button, Center, Flex, useDisclosure } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
......@@ -22,6 +22,7 @@ import { getAdminSystemTools, putAdminUpdateToolOrder } from '@/web/core/plugin/
import type { GetAdminSystemToolsResponseType } from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import type { AdminSystemToolListItemType } from '@fastgpt/global/core/app/tool/systemTool/type';
import { useDebounce } from 'ahooks';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
const SystemToolConfigModal = dynamic(
() => import('@/pageComponents/config/tool/SystemToolConfigModal')
......@@ -38,6 +39,8 @@ const ToolProvider = () => {
const [localTools, setLocalTools] = useState<GetAdminSystemToolsResponseType>([]);
const [editingToolId, setEditingToolId] = useState<string>();
const [searchKey, setSearchKey] = useState('');
const [statusFilter, setStatusFilter] = useState<PluginStatusType>();
const [tagFilter, setTagFilter] = useState<string>();
const debouncedSearchKey = useDebounce(searchKey, { wait: 300 });
const requestSearchKey = debouncedSearchKey.trim();
......@@ -64,6 +67,55 @@ const ToolProvider = () => {
manual: false
}
);
const statusFilterOptions = useMemo(
() => [
{
label: t('common:All'),
value: undefined
},
{
label: t('app:toolkit_status_normal'),
value: PluginStatusEnum.Normal
},
{
label: t('app:toolkit_status_soon_offline'),
value: PluginStatusEnum.SoonOffline
},
{
label: t('app:toolkit_status_offline'),
value: PluginStatusEnum.Offline
}
],
[t]
);
const tagFilterOptions = useMemo(
() => [
{
label: t('common:All'),
value: undefined
},
...Array.from(new Set(localTools.flatMap((tool) => tool.tags || [])))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.map((tag) => ({
label: tag,
value: tag
}))
],
[localTools, t]
);
const isStatusFilterActive = statusFilter !== undefined;
const isTagFilterActive = tagFilter !== undefined;
const isTableFilterActive = isStatusFilterActive || isTagFilterActive;
const statusFilterLabel = statusFilterOptions.find((item) => item.value === statusFilter)?.label;
const tagFilterLabel = tagFilterOptions.find((item) => item.value === tagFilter)?.label;
const displayTools = useMemo(() => {
return localTools.filter((tool) => {
if (statusFilter && tool.status !== statusFilter) return false;
if (tagFilter && !tool.tags?.includes(tagFilter)) return false;
return true;
});
}, [localTools, statusFilter, tagFilter]);
return (
<MyBox pt={4} pl={3} pr={8} isLoading={loadingTools}>
......@@ -132,10 +184,61 @@ const ToolProvider = () => {
<Box w={2.2 / 10} pl={8}>
{t('app:toolkit_name')}
</Box>
<Box w={1.5 / 10}>{t('app:toolkit_tags')}</Box>
<Box w={1.5 / 10}>
<MyMenu
trigger="hover"
placement="bottom-start"
Button={
<Flex
alignItems={'center'}
cursor={'pointer'}
w={'fit-content'}
maxW={'100%'}
color={isTagFilterActive ? 'primary.600' : 'inherit'}
>
<Box maxW={'110px'} className="textEllipsis">
{isTagFilterActive ? tagFilterLabel || tagFilter : t('app:toolkit_tags')}
</Box>
<MyIcon name="core/chat/chevronDown" w={4} ml={1} flexShrink={0} />
</Flex>
}
menuList={[
{
children: tagFilterOptions.map((item) => ({
label: item.label,
onClick: () => setTagFilter(item.value),
isActive: item.value === tagFilter
}))
}
]}
/>
</Box>
<Box w={4.1 / 10}>{t('common:Intro')}</Box>
<Box w={1.1 / 10} pl={6}>
{t('app:toolkit_status')}
<MyMenu
trigger="hover"
placement="bottom-start"
Button={
<Flex
alignItems={'center'}
cursor={'pointer'}
w={'fit-content'}
color={isStatusFilterActive ? 'primary.600' : 'inherit'}
>
<Box>{isStatusFilterActive ? statusFilterLabel : t('app:toolkit_status')}</Box>
<MyIcon name="core/chat/chevronDown" w={4} ml={1} />
</Flex>
}
menuList={[
{
children: statusFilterOptions.map((item) => ({
label: item.label,
onClick: () => setStatusFilter(item.value),
isActive: item.value === statusFilter
}))
}
]}
/>
</Box>
<Box w={1.1 / 10} display={'flex'} alignItems={'center'}>
{t('app:toolkit_system_key')}
......@@ -150,7 +253,7 @@ const ToolProvider = () => {
</Flex>
<Box overflow={'auto'} mt={2} h={'calc(100vh - 150px)'}>
{localTools.length > 0 ? (
{displayTools.length > 0 ? (
<DndDrag<AdminSystemToolListItemType>
onDragEndCb={async (list: Array<AdminSystemToolListItemType>) => {
const newOrder = list.map((item, index) => ({
......@@ -160,7 +263,7 @@ const ToolProvider = () => {
setLocalTools(list);
await putAdminUpdateToolOrder({ plugins: newOrder });
}}
dataList={localTools}
dataList={displayTools}
>
{({ provided }) => (
<Flex
......@@ -170,12 +273,12 @@ const ToolProvider = () => {
{...provided.droppableProps}
ref={provided.innerRef}
>
{localTools.map((item, index) => (
{displayTools.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
isDragDisabled={!!searchKey.trim()}
isDragDisabled={!!searchKey.trim() || isTableFilterActive}
>
{(provided, snapshot) => (
<ToolRow
......
......@@ -6,4 +6,5 @@ STORAGE_S3_FORCE_PATH_STYLE=true
STORAGE_ACCESS_KEY_ID=minioadmin
STORAGE_SECRET_ACCESS_KEY=minioadmin
AUTH_TOKEN=xxxx
COMMUNITY_AUTH_TOKEN=xxxx
MONGODB_URI="mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin&directConnection=true"
......@@ -12,6 +12,7 @@ export const marketplaceEnv = createEnv({
NEXT_RUNTIME: z.string().optional(),
AUTH_TOKEN: z.string().optional().default('marketplace-token'),
COMMUNITY_AUTH_TOKEN: z.string().optional(),
MONGODB_URI: z.string().optional().default(''),
DB_MAX_LINK: IntSchema.default(20),
SYNC_INDEX: BoolSchema.default(true),
......
import type { NextApiRequest, NextApiResponse } from 'next';
import { AUTH_TOKEN } from '@/service/auth';
import { isOfficialToken } from '@/service/auth';
import { deleteMarketplacePkg } from '@/service/tool/delete';
import {
DeleteMarketplacePkgBodySchema,
DeleteMarketplacePkgResponseSchema,
type DeleteMarketplacePkgResponseType
} from '@fastgpt/global/openapi/core/plugin/marketplace/api';
import {
getZodParseErrorInputSource,
parseApiInput
} from '@fastgpt/service/common/zod/requestParseError';
/* ============================================================================
* API: 删除 marketplace 插件 pkg
......@@ -46,15 +50,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return sendJson(res, 405, null, 'Method not allowed');
}
if (!!AUTH_TOKEN && req.headers['authorization'] !== `Bearer ${AUTH_TOKEN}`) {
if (!isOfficialToken(req.headers['authorization'])) {
return sendJson(res, 401, null, 'Unauthorized');
}
const data = DeleteMarketplacePkgBodySchema.parse(req.body);
const { body: data } = parseApiInput({
req,
bodySchema: DeleteMarketplacePkgBodySchema
});
const response = await deleteMarketplacePkg(data);
return sendJson(res, 200, DeleteMarketplacePkgResponseSchema.parse(response));
} catch (error) {
if (getZodParseErrorInputSource(error)) {
return sendJson(res, 400, null, 'Invalid request body');
}
return sendJson(
res,
500,
......
import type { NextApiRequest, NextApiResponse } from 'next';
import { multer } from '@fastgpt/service/common/file/multer';
import { AUTH_TOKEN } from '@/service/auth';
import { authenticateSubmitToken } from '@/service/auth';
import { uploadMarketplacePkg } from '@/service/tool/upload';
import {
UploadMarketplacePkgDataSchema,
......@@ -55,7 +55,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return sendJson(res, 405, null, 'Method not allowed');
}
if (!!AUTH_TOKEN && req.headers['authorization'] !== `Bearer ${AUTH_TOKEN}`) {
const tokenIdentity = authenticateSubmitToken(req.headers['authorization']);
if (!tokenIdentity) {
return sendJson(res, 401, null, 'Unauthorized');
}
......@@ -65,10 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
filepaths.push(result.fileMetadata.path);
const data = UploadMarketplacePkgDataSchema.parse(result.data);
UploadMarketplacePkgDataSchema.parse(result.data);
const response = await uploadMarketplacePkg({
buffer: result.getBuffer(),
source: data.source
source: tokenIdentity.source
});
return sendJson(res, 200, UploadMarketplacePkgResponseSchema.parse(response));
......
......@@ -5,17 +5,25 @@ 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';
import {
GetMarketplaceToolsBodySchema,
MarketplaceOfficialSource,
type MarketplaceSourceFilterType
} from '@fastgpt/global/openapi/core/plugin/marketplace/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
export type ToolListQuery = {};
export type ToolListBody = PaginationProps<{
searchKey?: string;
tags?: string[];
source?: MarketplaceSourceFilterType;
}>;
export type ToolListItem = ToolListItemType & {
downloadCount: number;
downloadUrl: string;
toolId: string;
source?: string;
};
export type ToolListResponse = PaginationResponse<ToolListItem>;
......@@ -26,12 +34,24 @@ const getToolTags = (item: { tags?: readonly string[] | null }): readonly string
const hasSecretSchemaProperties = (secretSchema?: { properties?: Record<string, unknown> }) =>
!!secretSchema?.properties && Object.keys(secretSchema.properties).length > 0;
const matchSource = (itemSource: string | undefined, source: MarketplaceSourceFilterType) => {
if (source === MarketplaceOfficialSource) {
return !itemSource || itemSource === MarketplaceOfficialSource;
}
return itemSource === source;
};
async function handler(
req: ApiRequestProps<ToolListBody, ToolListQuery>,
res: ApiResponseType<any>
): Promise<ToolListResponse> {
const { body } = parseApiInput({
req,
bodySchema: GetMarketplaceToolsBodySchema
});
const { pageSize, offset } = parsePaginationRequest(req);
const { searchKey, tags } = req.body;
const { searchKey, tags, source } = body;
const data = await getToolList();
const filteredData = data.filter((item) => {
......@@ -48,6 +68,7 @@ async function handler(
)
return false;
if (tags && !tags.some((tag) => getToolTags(item).includes(tag))) return false;
if (source && !matchSource(item.source, source)) return false;
return true;
});
......
......@@ -6,7 +6,9 @@ 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 ToolTagFilterBox, {
type MarketplaceSourceFilterValue
} 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 { usePagination } from '@fastgpt/web/hooks/usePagination';
......@@ -42,21 +44,41 @@ const createQuerySelectedTool = ({
version
}: Required<Pick<MarketplaceDetailQuery, 'pluginId'>> &
Pick<MarketplaceDetailQuery, 'version'>): ToolCardItemType => ({
id: pluginId,
name: pluginId,
description: '',
version
});
id: pluginId,
name: pluginId,
description: '',
version
});
const getSourceFilterValue = (value: string | string[] | undefined) => {
const source = getSingleQueryValue(value);
return source === 'official' || source === 'community'
? (source as MarketplaceSourceFilterValue)
: undefined;
};
const isSameBrowserUrl = (nextUrl: string) => {
if (typeof window === 'undefined') return false;
const targetUrl = new URL(nextUrl, window.location.origin);
return (
targetUrl.pathname === window.location.pathname && targetUrl.search === window.location.search
);
};
const ToolkitMarketplace = () => {
const { t, i18n } = useTranslation();
const router = useRouter();
const { search, tags, pluginId, version } = router.query;
const { search, tags, pluginId, version, source } = router.query;
const queryPluginId = getSingleQueryValue(pluginId);
const queryVersion = getSingleQueryValue(version);
const querySource = getSourceFilterValue(source);
const [inputValue, setInputValue] = useState('');
const [searchText, setSearchText] = useState('');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [selectedSource, setSelectedSource] = useState<MarketplaceSourceFilterValue | undefined>(
querySource
);
const [selectedTool, setSelectedTool] = useState<ToolCardItemType | null>(null);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompactSearch, setShowCompactSearch] = useState(false);
......@@ -88,10 +110,11 @@ const ToolkitMarketplace = () => {
: [];
setSelectedTagIds(tagArray);
}
setSelectedSource(querySource);
} catch (error) {
console.warn('Failed to initialize URL params:', error);
}
}, [search, tags]);
}, [querySource, search, tags]);
// 使用自定义 debounce 进行实时搜索
const [debouncedSearchText, setDebouncedSearchText] = useState(inputValue);
......@@ -113,12 +136,18 @@ const ToolkitMarketplace = () => {
// 更新 URL 的函数
const updateUrlParams = useCallback(
(newSearch: string, newTags: string[], detailQuery = detailQueryRef.current) => {
(
newSearch: string,
newTags: string[],
detailQuery = detailQueryRef.current,
newSource = selectedSource
) => {
try {
const newUrl = buildMarketplacePageUrl({
pathname: router.pathname,
search: newSearch,
tags: newTags,
source: newSource,
pluginId: detailQuery?.pluginId,
version: detailQuery?.version
});
......@@ -130,6 +159,7 @@ const ToolkitMarketplace = () => {
window.isSecureContext ||
(window.location.protocol === 'http:' && window.location.hostname === 'localhost')
) {
if (isSameBrowserUrl(newUrl)) return;
try {
window.history.replaceState({}, '', newUrl);
} catch (historyError) {
......@@ -144,7 +174,7 @@ const ToolkitMarketplace = () => {
// 如果 URL 操作失败,跳过更新
}
},
[router.pathname]
[router.pathname, selectedSource]
);
const getCurrentDetailQuery = useCallback(() => {
......@@ -167,6 +197,14 @@ const ToolkitMarketplace = () => {
}
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
const handleSourceSelect = useCallback(
(nextSource?: MarketplaceSourceFilterValue) => {
setSelectedSource(nextSource);
updateUrlParams(searchText, selectedTagIds, detailQueryRef.current, nextSource);
},
[searchText, selectedTagIds, updateUrlParams]
);
const {
data: tools,
isLoading: loadingTools,
......@@ -177,12 +215,13 @@ const ToolkitMarketplace = () => {
pageNum,
pageSize,
searchKey: searchText || undefined,
tags: selectedTagIds.length > 0 ? selectedTagIds : undefined
tags: selectedTagIds.length > 0 ? selectedTagIds : undefined,
source: selectedSource
}),
{
type: 'scroll',
throttleWait: 500,
refreshDeps: [searchText, selectedTagIds]
refreshDeps: [searchText, selectedTagIds, selectedSource]
}
);
......@@ -205,16 +244,18 @@ const ToolkitMarketplace = () => {
return parseI18nString(currentTag?.tagName || '', i18n.language) || '';
}),
version: tool.version,
source: tool.source,
downloadCount: tool.downloadCount
};
});
}, [tools, i18n.language, toolTags]);
const selectedTagKey = selectedTagIds.join(',');
const selectedSourceKey = selectedSource ?? 'all';
const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: displayTools,
listKey: `${searchText}-${selectedTagKey}-${i18n.language}`,
estimatedRowHeight: 160,
listKey: `${searchText}-${selectedTagKey}-${selectedSourceKey}-${i18n.language}`,
estimatedRowHeight: 178,
estimatedRowGap: 20
});
......@@ -258,7 +299,7 @@ const ToolkitMarketplace = () => {
if (router.isReady) {
updateUrlParams(searchText, selectedTagIds);
}
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
}, [router.isReady, searchText, selectedTagIds, selectedSource, updateUrlParams]);
const onDownload = useCallback(async (toolId: string, version?: string) => {
try {
......@@ -318,6 +359,7 @@ const ToolkitMarketplace = () => {
<ToolCard
item={tool}
mode="marketplace"
variant="marketplace"
onInstall={() => onDownload(tool.id)}
onClickCard={() => handleSelectTool(tool)}
/>
......@@ -372,10 +414,7 @@ const ToolkitMarketplace = () => {
<I18nLngSelector />
<Button
onClick={() => {
window.open(
'https://doc.fastgpt.cn/plugin/system-tool-development',
'_blank'
);
window.open('https://doc.fastgpt.cn/plugin/system-tool-development', '_blank');
}}
>
{t('app:toolkit_contribute_resource')}
......@@ -490,6 +529,9 @@ const ToolkitMarketplace = () => {
tags={toolTags}
selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds}
selectedSource={selectedSource}
onSourceSelect={handleSourceSelect}
variant="marketplace"
/>
</Box>
</Flex>
......@@ -547,7 +589,8 @@ const ToolkitMarketplace = () => {
fontSize="sm"
bg={'white'}
pl={8}
w={'560px'}
w={['calc(100vw - 64px)', '560px']}
maxW={'560px'}
h={12}
borderRadius={'10px'}
placeholder={t('app:toolkit_marketplace_search_placeholder')}
......@@ -574,13 +617,16 @@ const ToolkitMarketplace = () => {
tags={toolTags}
selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds}
selectedSource={selectedSource}
onSourceSelect={handleSourceSelect}
variant="marketplace"
/>
</Box>
</Flex>
{displayTools.length > 0 ? (
<Grid
ref={gridRef}
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']}
gridTemplateColumns={'repeat(auto-fill, minmax(min(260px, 100%), 1fr))'}
gridGap={5}
alignItems={'stretch'}
>
......
import { marketplaceEnv } from '@/env';
import {
MarketplaceCommunitySource,
MarketplaceOfficialSource
} from '@fastgpt/global/openapi/core/plugin/marketplace/api';
export const AUTH_TOKEN = marketplaceEnv.AUTH_TOKEN;
export const COMMUNITY_AUTH_TOKEN = marketplaceEnv.COMMUNITY_AUTH_TOKEN;
export type MarketplaceTokenIdentity = {
source: typeof MarketplaceOfficialSource | typeof MarketplaceCommunitySource;
};
const getAuthorizationToken = (authorization: string | string[] | undefined) => {
const rawAuthorization = Array.isArray(authorization) ? authorization[0] : authorization;
return rawAuthorization?.startsWith('Bearer ')
? rawAuthorization.slice('Bearer '.length)
: rawAuthorization;
};
/**
* 解析提交插件使用的 marketplace token。
* official token 只能产生 official 插件,community token 只能产生 community 插件。
*/
export const authenticateSubmitToken = (
authorization: string | string[] | undefined
): MarketplaceTokenIdentity | null => {
const token = getAuthorizationToken(authorization);
if (AUTH_TOKEN && token === AUTH_TOKEN) {
return {
source: MarketplaceOfficialSource
};
}
if (COMMUNITY_AUTH_TOKEN && token === COMMUNITY_AUTH_TOKEN) {
return {
source: MarketplaceCommunitySource
};
}
return null;
};
/**
* 校验 official token。revoke 等管理接口只允许 official token 调用。
*/
export const isOfficialToken = (authorization: string | string[] | undefined) => {
const token = getAuthorizationToken(authorization);
return Boolean(AUTH_TOKEN && token === AUTH_TOKEN);
};
......@@ -10,6 +10,7 @@ export type MarketplaceToolListDataItem = ToolDetailType & {
downloadUrl?: string;
readme?: string;
parentId?: string;
source?: string;
};
declare global {
......
......@@ -6,6 +6,7 @@ export type MarketplaceDetailQuery = {
export type MarketplacePageQuery = MarketplaceDetailQuery & {
search?: string;
tags?: string[];
source?: string;
};
export type NextQueryValue = string | string[] | undefined;
......@@ -20,6 +21,7 @@ export const getSingleQueryValue = (value: NextQueryValue) => {
export const buildMarketplaceQueryString = ({
search,
tags,
source,
pluginId,
version
}: MarketplacePageQuery) => {
......@@ -31,6 +33,9 @@ export const buildMarketplaceQueryString = ({
if (tags && tags.length > 0) {
params.push(['tags', tags.join(',')]);
}
if (source) {
params.push(['source', source]);
}
if (pluginId) {
params.push(['pluginId', pluginId]);
}
......
import { afterEach, describe, expect, it, vi } from 'vitest';
const originalSyncIndex = process.env.SYNC_INDEX;
const originalCommunityAuthToken = process.env.COMMUNITY_AUTH_TOKEN;
const importEnv = async () => {
vi.resetModules();
......@@ -10,6 +11,7 @@ const importEnv = async () => {
describe('marketplace env', () => {
afterEach(() => {
vi.stubEnv('SYNC_INDEX', originalSyncIndex);
vi.stubEnv('COMMUNITY_AUTH_TOKEN', originalCommunityAuthToken);
});
it('defaults SYNC_INDEX to true when it is not configured', async () => {
......@@ -35,4 +37,12 @@ describe('marketplace env', () => {
expect(marketplaceEnv.SYNC_INDEX).toBe(false);
});
it('parses optional community auth token', async () => {
vi.stubEnv('COMMUNITY_AUTH_TOKEN', 'community-token');
const { marketplaceEnv } = await importEnv();
expect(marketplaceEnv.COMMUNITY_AUTH_TOKEN).toBe('community-token');
});
});
......@@ -6,7 +6,10 @@ const deleteMocks = vi.hoisted(() => ({
vi.mock('../../../../../src/service/tool/delete', () => deleteMocks);
vi.mock('../../../../../src/service/auth', () => ({
AUTH_TOKEN: 'marketplace-token'
AUTH_TOKEN: 'marketplace-token',
isOfficialToken: vi.fn((authorization: string | undefined) => {
return authorization === 'Bearer marketplace-token';
})
}));
const createResponse = () => {
......@@ -127,6 +130,28 @@ describe('/api/admin/pkg/delete', () => {
});
});
it('returns 400 for invalid delete body', async () => {
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/delete');
const res = createResponse();
await handler(
{
method: 'POST',
headers: { authorization: 'Bearer marketplace-token' },
body: {
pluginId: 'tool-a'
}
} as any,
res as any
);
expect(deleteMocks.deleteMarketplacePkg).not.toHaveBeenCalled();
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({
code: 400,
message: 'Invalid request body'
});
});
it('rejects unsupported methods', async () => {
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/delete');
const res = createResponse();
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
const uploadMocks = vi.hoisted(() => ({
uploadMarketplacePkg: vi.fn()
}));
const multerMocks = vi.hoisted(() => ({
resolveFormData: vi.fn(),
clearDiskTempFiles: vi.fn()
}));
vi.mock('../../../../../src/service/tool/upload', () => uploadMocks);
vi.mock('@fastgpt/service/common/file/multer', () => ({
multer: multerMocks
}));
vi.mock('../../../../../src/service/auth', () => ({
AUTH_TOKEN: 'marketplace-token',
COMMUNITY_AUTH_TOKEN: 'community-token',
authenticateSubmitToken: vi.fn((authorization: string | undefined) => {
if (authorization === 'Bearer marketplace-token') return { source: 'official' };
if (authorization === 'Bearer community-token') return { source: 'community' };
return null;
})
}));
const createResponse = () => {
const res = {
statusCode: 200,
body: undefined as unknown,
writableFinished: false,
setHeader: vi.fn(),
status: vi.fn((code: number) => {
res.statusCode = code;
return res;
}),
json: vi.fn((payload: unknown) => {
res.body = payload;
res.writableFinished = true;
return res;
}),
end: vi.fn(() => {
res.writableFinished = true;
return res;
})
};
return res;
};
const mockUploadForm = (data: Record<string, unknown> = {}) => {
multerMocks.resolveFormData.mockResolvedValue({
fileMetadata: {
path: '/tmp/tool.pkg'
},
data,
getBuffer: () => Buffer.from('pkg')
});
};
describe('/api/admin/pkg/upload', () => {
beforeEach(() => {
uploadMocks.uploadMarketplacePkg.mockReset();
multerMocks.resolveFormData.mockReset();
multerMocks.clearDiskTempFiles.mockReset();
});
it('uploads official package when official token is used', async () => {
mockUploadForm();
uploadMocks.uploadMarketplacePkg.mockResolvedValue({
pluginId: 'tool-a',
version: '1.0.0',
etag: 'etag-1',
source: 'official',
downloadUrl: 'https://cdn.example.com/tool-a.pkg',
tool: {}
});
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/upload');
const res = createResponse();
await handler(
{
method: 'POST',
headers: { authorization: 'Bearer marketplace-token' }
} as any,
res as any
);
expect(uploadMocks.uploadMarketplacePkg).toHaveBeenCalledWith({
buffer: Buffer.from('pkg'),
source: 'official'
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
code: 200,
data: {
pluginId: 'tool-a',
version: '1.0.0',
etag: 'etag-1',
source: 'official',
downloadUrl: 'https://cdn.example.com/tool-a.pkg',
tool: {}
}
});
});
it('uploads community package when community token is used and ignores submitted source', async () => {
mockUploadForm({ source: 'official' });
uploadMocks.uploadMarketplacePkg.mockResolvedValue({
pluginId: 'tool-a',
version: '1.0.0',
etag: 'etag-1',
source: 'community',
downloadUrl: 'https://cdn.example.com/tool-a.pkg',
tool: {}
});
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/upload');
const res = createResponse();
await handler(
{
method: 'POST',
headers: { authorization: 'Bearer community-token' }
} as any,
res as any
);
expect(uploadMocks.uploadMarketplacePkg).toHaveBeenCalledWith({
buffer: Buffer.from('pkg'),
source: 'community'
});
expect(res.body).toEqual({
code: 200,
data: expect.objectContaining({
source: 'community'
})
});
});
it('rejects unauthorized upload before parsing form data', async () => {
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/upload');
const res = createResponse();
await handler(
{
method: 'POST',
headers: { authorization: 'Bearer bad-token' }
} as any,
res as any
);
expect(multerMocks.resolveFormData).not.toHaveBeenCalled();
expect(uploadMocks.uploadMarketplacePkg).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
expect(res.body).toEqual({
code: 401,
message: 'Unauthorized'
});
});
});
......@@ -136,4 +136,60 @@ describe('/api/tool/list', () => {
}
});
});
it('filters by source before pagination and keeps legacy tools as official', async () => {
toolDataMocks.getToolList.mockResolvedValue([
createTool({ toolId: 'legacy', id: 'legacy', pluginId: 'legacy', source: undefined }),
createTool({ toolId: 'official', id: 'official', pluginId: 'official', source: 'official' }),
createTool({ toolId: 'community', id: 'community', pluginId: 'community', source: 'community' })
]);
const { default: handler } = await import('../../../../../src/pages/api/tool/list');
const officialRes = createResponse();
await handler(
{
method: 'POST',
query: {},
body: {
pageNum: 1,
pageSize: 10,
source: 'official'
}
} as any,
officialRes as any
);
expect(officialRes.body).toMatchObject({
code: 200,
data: {
total: 2,
list: [
{ toolId: 'legacy', downloadUrl: 'https://cdn.example.com/legacy.pkg' },
{ toolId: 'official', downloadUrl: 'https://cdn.example.com/official.pkg' }
]
}
});
const communityRes = createResponse();
await handler(
{
method: 'POST',
query: {},
body: {
pageNum: 1,
pageSize: 10,
source: 'community'
}
} as any,
communityRes as any
);
expect(communityRes.body).toMatchObject({
code: 200,
data: {
total: 1,
list: [{ toolId: 'community', downloadUrl: 'https://cdn.example.com/community.pkg' }]
}
});
});
});
......@@ -30,6 +30,7 @@ export default defineConfig({
include: [
'src/env.ts',
'src/web/api.ts',
'src/pages/api/admin/pkg/upload.ts',
'src/pages/api/admin/pkg/delete.ts',
'src/pages/api/tool/getDownloadUrl.ts',
'src/service/plugin/repo.ts',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment