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/ ...@@ -49,3 +49,4 @@ pro/admin/worker/
/pro/llm_benchmark/content_benchmark/.env.*.local /pro/llm_benchmark/content_benchmark/.env.*.local
/pro/llm_benchmark/content_benchmark/eval-runs/ /pro/llm_benchmark/content_benchmark/eval-runs/
/pro/llm_benchmark/content_benchmark/.cache/ /pro/llm_benchmark/content_benchmark/.cache/
.gstack/
...@@ -46,10 +46,12 @@ The script only fills missing `appName` values. It does not overwrite existing v ...@@ -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. 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. 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. 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 ## ⚙️ Improvements
1. Removed system field parameters when AgentV2 calls nested workflows. 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 ## 🐛 Fixes
...@@ -58,6 +60,7 @@ The script only fills missing `appName` values. It does not overwrite existing v ...@@ -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. 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. 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. 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 ## 🛠️ Code Improvements
......
...@@ -48,10 +48,12 @@ curl -X POST "{{host}}/api/admin/initv4151" \ ...@@ -48,10 +48,12 @@ curl -X POST "{{host}}/api/admin/initv4151" \
1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。 1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。
2. 发布技能时,预提取技能名称和描述,便于辅助生成。 2. 发布技能时,预提取技能名称和描述,便于辅助生成。
3. 新增 `WECOM_LOGIN_AUTO_REDIRECT` 环境变量,可控制企微终端是否自动跳转登录,默认关闭。 3. 新增 `WECOM_LOGIN_AUTO_REDIRECT` 环境变量,可控制企微终端是否自动跳转登录,默认关闭。
4. 插件市场支持官方/社区来源筛选,系统工具列表的状态列和标签列支持筛选。
## ⚙️ 优化 ## ⚙️ 优化
1. AgentV2 调用嵌套工作流时候,去除系统字段参数。 1. AgentV2 调用嵌套工作流时候,去除系统字段参数。
2. 系统工具支持卸载和重新安装。卸载会将工具状态改为“已卸载”,需要输入工具名称确认;已卸载工具只展示基础信息,并可重新安装恢复。
## 🐛 修复 ## 🐛 修复
...@@ -60,6 +62,7 @@ curl -X POST "{{host}}/api/admin/initv4151" \ ...@@ -60,6 +62,7 @@ curl -X POST "{{host}}/api/admin/initv4151" \
3. 修复工作流工具运行子工作流时未按工具应用的全局变量配置初始化变量,导致默认变量、系统变量等运行态变量读取异常的问题。 3. 修复工作流工具运行子工作流时未按工具应用的全局变量配置初始化变量,导致默认变量、系统变量等运行态变量读取异常的问题。
4. 修复循环节点和并行执行节点中通过【变量更新】修改全局变量或容器外节点输出时,主流程未按轮次/任务结束同步更新的问题。成功轮次或成功任务会回写本轮变更,失败轮次或失败任务不提交本轮变更。 4. 修复循环节点和并行执行节点中通过【变量更新】修改全局变量或容器外节点输出时,主流程未按轮次/任务结束同步更新的问题。成功轮次或成功任务会回写本轮变更,失败轮次或失败任务不提交本轮变更。
5. 重试全部知识库集合时,未立即刷新组件。 5. 重试全部知识库集合时,未立即刷新组件。
6. 修复 FastGPT 内嵌插件市场在筛选条件没有变化时重复更新浅路由,导致顶部进度条持续 loading 的问题。
## 🛠️ 代码优化 ## 🛠️ 代码优化
......
...@@ -4,7 +4,13 @@ import { PluginToolTagSchema } from '../../../../core/plugin/type'; ...@@ -4,7 +4,13 @@ import { PluginToolTagSchema } from '../../../../core/plugin/type';
import type { ToolListItemType } from '../../../../sdk/fastgpt-plugin'; import type { ToolListItemType } from '../../../../sdk/fastgpt-plugin';
export const MarketplaceOfficialSource = 'official'; export const MarketplaceOfficialSource = 'official';
export const MarketplaceCommunitySource = 'community';
export const MarketplacePkgSourceSchema = z.string().trim().min(1); 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 formatToolDetailSchema = z.object({});
const formatToolSimpleSchema = z.object({}); const formatToolSimpleSchema = z.object({});
...@@ -15,6 +21,7 @@ export type MarketplaceToolListItemType = ToolListItemType & { ...@@ -15,6 +21,7 @@ export type MarketplaceToolListItemType = ToolListItemType & {
toolId: string; toolId: string;
downloadCount: number; downloadCount: number;
downloadUrl?: string; downloadUrl?: string;
source?: string;
}; };
export const MarketplaceToolDetailItemSchema = formatToolDetailSchema.extend({ export const MarketplaceToolDetailItemSchema = formatToolDetailSchema.extend({
...@@ -27,7 +34,8 @@ export const MarketplaceToolDetailSchema = z.object({ ...@@ -27,7 +34,8 @@ export const MarketplaceToolDetailSchema = z.object({
// List // List
export const GetMarketplaceToolsBodySchema = PaginationSchema.extend({ export const GetMarketplaceToolsBodySchema = PaginationSchema.extend({
searchKey: z.string().optional(), 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>; export type GetMarketplaceToolsBodyType = z.infer<typeof GetMarketplaceToolsBodySchema>;
...@@ -98,9 +106,7 @@ export const DeleteMarketplacePkgResponseSchema = z.object({ ...@@ -98,9 +106,7 @@ export const DeleteMarketplacePkgResponseSchema = z.object({
description: '插件来源' description: '插件来源'
}) })
}); });
export type DeleteMarketplacePkgResponseType = z.infer< export type DeleteMarketplacePkgResponseType = z.infer<typeof DeleteMarketplacePkgResponseSchema>;
typeof DeleteMarketplacePkgResponseSchema
>;
// Tags // Tags
export const GetMarketplaceToolTagsResponseSchema = z.array(PluginToolTagSchema); export const GetMarketplaceToolTagsResponseSchema = z.array(PluginToolTagSchema);
......
...@@ -202,6 +202,34 @@ const getVisiblePluginStatus = ({ ...@@ -202,6 +202,34 @@ const getVisiblePluginStatus = ({
return status ?? PluginStatusEnum.Normal; 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 * SystemTool Repo
* 系统工具仓储层 * 系统工具仓储层
...@@ -737,6 +765,13 @@ export class SystemToolRepo { ...@@ -737,6 +765,13 @@ export class SystemToolRepo {
const [parentPluginId] = rawPluginId.split('/'); const [parentPluginId] = rawPluginId.split('/');
const dbTool = await getSystemToolConfig(pluginId); 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) { if (!dbTool?.customConfig?.associatedPluginId) {
const tool = await pluginClient.getTool({ const tool = await pluginClient.getTool({
...@@ -778,6 +813,7 @@ export class SystemToolRepo { ...@@ -778,6 +813,7 @@ export class SystemToolRepo {
} }
const tool = await this.getSystemToolRecord(pluginId); const tool = await this.getSystemToolRecord(pluginId);
await assertSystemToolRunnable({ tool });
if (!tool || !tool.customConfig?.associatedPluginId) { if (!tool || !tool.customConfig?.associatedPluginId) {
return Promise.reject('Plugin is not associated with a app'); return Promise.reject('Plugin is not associated with a app');
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
FlowNodeTypeEnum FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant'; } from '@fastgpt/global/core/workflow/node/constant';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type'; import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
import { PluginErrEnum } from '@fastgpt/global/common/error/code/plugin';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
listTools: vi.fn(), listTools: vi.fn(),
...@@ -457,6 +458,28 @@ describe('SystemToolRepo.getSystemToolDetail', () => { ...@@ -457,6 +458,28 @@ describe('SystemToolRepo.getSystemToolDetail', () => {
}); });
describe('SystemToolRepo.getSystemToolWorkflowRuntime', () => { 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 () => { it('returns workflow app chatConfig for runtime variable initialization', async () => {
mocks.findSystemTool.mockResolvedValue({ mocks.findSystemTool.mockResolvedValue({
pluginId: 'commercial-workflow-tool', pluginId: 'commercial-workflow-tool',
...@@ -909,6 +932,45 @@ describe('SystemToolRepo.getSystemToolDisplayInfo', () => { ...@@ -909,6 +932,45 @@ describe('SystemToolRepo.getSystemToolDisplayInfo', () => {
}); });
describe('SystemToolRepo.getSystemToolRuntime', () => { 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 () => { it('does not return configured system secrets for debug source', async () => {
mocks.findSystemTool.mockResolvedValue({ mocks.findSystemTool.mockResolvedValue({
pluginId: 'systemTool-weather', 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 { useTranslation } from 'next-i18next';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { SystemPluginToolTagType } from '@fastgpt/global/core/plugin/type'; import type { SystemPluginToolTagType } from '@fastgpt/global/core/plugin/type';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import MyIcon from '../../../common/Icon';
export type MarketplaceSourceFilterValue = 'official' | 'community';
const ToolTagFilterBox = ({ const ToolTagFilterBox = ({
tags, tags,
selectedTagIds, selectedTagIds,
onTagSelect, onTagSelect,
size = 'base' selectedSource,
onSourceSelect,
size = 'base',
variant = 'default'
}: { }: {
tags: SystemPluginToolTagType[]; tags: SystemPluginToolTagType[];
selectedTagIds: string[]; selectedTagIds: string[];
onTagSelect: (tagIds: string[]) => void; onTagSelect: (tagIds: string[]) => void;
selectedSource?: MarketplaceSourceFilterValue;
onSourceSelect?: (source?: MarketplaceSourceFilterValue) => void;
size?: 'base' | 'sm'; size?: 'base' | 'sm';
variant?: 'default' | 'marketplace';
}) => { }) => {
const { t, i18n } = useTranslation(); 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) => { const toggleTag = (tagId: string) => {
if (selectedTagIds.includes(tagId)) { if (selectedTagIds.includes(tagId)) {
...@@ -27,7 +44,14 @@ const ToolTagFilterBox = ({ ...@@ -27,7 +44,14 @@ const ToolTagFilterBox = ({
const tagBaseStyles = useMemo(() => { const tagBaseStyles = useMemo(() => {
const sizeStyles = { const sizeStyles = {
base: { base: isMarketplaceVariant
? {
px: '13px',
h: '35px',
fontSize: '14px',
lineHeight: '21px'
}
: {
px: 3, px: 3,
py: 1.5, py: 1.5,
fontSize: 'sm' fontSize: 'sm'
...@@ -42,14 +66,20 @@ const ToolTagFilterBox = ({ ...@@ -42,14 +66,20 @@ const ToolTagFilterBox = ({
return { return {
...sizeStyles[size], ...sizeStyles[size],
fontWeight: 'medium', fontWeight: 'medium',
color: 'myGray.700', color: isMarketplaceVariant ? '#383F50' : 'myGray.700',
border: '1px solid', border: '1px solid',
borderColor: 'myGray.200', borderColor: isMarketplaceVariant ? '#E8EBF0' : 'myGray.200',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
flexShrink: 0, flexShrink: 0,
cursor: 'pointer' cursor: 'pointer',
...(isMarketplaceVariant
? {
alignItems: 'center',
justifyContent: 'center'
}
: {})
}; };
}, [size]); }, [isMarketplaceVariant, size]);
return ( return (
<Flex <Flex
...@@ -83,6 +113,82 @@ const ToolTagFilterBox = ({ ...@@ -83,6 +113,82 @@ 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 <Box
{...tagBaseStyles} {...tagBaseStyles}
rounded={'sm'} rounded={'sm'}
...@@ -91,7 +197,14 @@ const ToolTagFilterBox = ({ ...@@ -91,7 +197,14 @@ const ToolTagFilterBox = ({
> >
{t('common:All')} {t('common:All')}
</Box> </Box>
<Box mx={2} h={'20px'} w={'1px'} bg={'myGray.200'} flexShrink={0} /> )}
<Box
mx={2}
h={'20px'}
w={'1px'}
bg={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
flexShrink={0}
/>
<Box flex={1}> <Box flex={1}>
<Flex gap={2} flexWrap="nowrap"> <Flex gap={2} flexWrap="nowrap">
{tags.map((tag) => { {tags.map((tag) => {
...@@ -100,8 +213,15 @@ const ToolTagFilterBox = ({ ...@@ -100,8 +213,15 @@ const ToolTagFilterBox = ({
<Box <Box
key={tag.tagId} key={tag.tagId}
{...tagBaseStyles} {...tagBaseStyles}
display={isMarketplaceVariant ? 'inline-flex' : undefined}
rounded={'full'} 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)} onClick={() => toggleTag(tag.tagId)}
> >
{t(parseI18nString(tag.tagName, i18n.language))} {t(parseI18nString(tag.tagName, i18n.language))}
......
...@@ -8,6 +8,8 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; ...@@ -8,6 +8,8 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type'; import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
import DebugToolTag from './DebugToolTag'; import DebugToolTag from './DebugToolTag';
const marketplaceOfficialSource = 'official';
export type ToolCardItemType = { export type ToolCardItemType = {
id: string; id: string;
name: string; name: string;
...@@ -43,7 +45,8 @@ const ToolCard = ({ ...@@ -43,7 +45,8 @@ const ToolCard = ({
onDelete, onDelete,
onUpdate, onUpdate,
onClickCard, onClickCard,
showActionButton = true showActionButton = true,
variant = 'default'
}: { }: {
item: ToolCardItemType; item: ToolCardItemType;
systemTitle?: string; systemTitle?: string;
...@@ -55,10 +58,16 @@ const ToolCard = ({ ...@@ -55,10 +58,16 @@ const ToolCard = ({
onUpdate?: () => Promise<void>; onUpdate?: () => Promise<void>;
onClickCard?: () => void; onClickCard?: () => void;
showActionButton?: boolean; showActionButton?: boolean;
variant?: 'default' | 'marketplace';
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const tagsContainerRef = useRef<HTMLDivElement>(null); const tagsContainerRef = useRef<HTMLDivElement>(null);
const [visibleTagsCount, setVisibleTagsCount] = useState(item.tags?.length || 0); 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(() => { useEffect(() => {
const calculate = () => { const calculate = () => {
...@@ -109,6 +118,8 @@ const ToolCard = ({ ...@@ -109,6 +118,8 @@ const ToolCard = ({
}; };
if (mode === 'admin') { if (mode === 'admin') {
if (isMarketplaceVariant) return null;
return item.installed return item.installed
? { ? {
label: t('app:toolkit_installed'), label: t('app:toolkit_installed'),
...@@ -130,14 +141,25 @@ const ToolCard = ({ ...@@ -130,14 +141,25 @@ const ToolCard = ({
} }
: null; : null;
} }
}, [item.installed, item.status]); }, [isMarketplaceVariant, item.installed, item.status, mode, t]);
return ( return (
<MyBox <MyBox
key={item.id} key={item.id}
p={4} {...(isMarketplaceVariant
pb={3} ? {
border={'base'} px: '17px',
pt: '17px',
pb: '13px',
minH: '178px',
border: '1px solid',
borderColor: '#DFE2EA'
}
: {
p: 4,
pb: 3,
border: 'base'
})}
bg={'white'} bg={'white'}
borderRadius={'10px'} borderRadius={'10px'}
display={'flex'} display={'flex'}
...@@ -150,7 +172,7 @@ const ToolCard = ({ ...@@ -150,7 +172,7 @@ const ToolCard = ({
}} }}
_hover={{ _hover={{
boxShadow: '0 4px 4px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08);', 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': { '& .install-button': {
display: 'flex' display: 'flex'
...@@ -178,8 +200,8 @@ const ToolCard = ({ ...@@ -178,8 +200,8 @@ const ToolCard = ({
<Flex <Flex
alignItems="center" alignItems="center"
position={'absolute'} position={'absolute'}
top={4} top={isMarketplaceVariant ? '17px' : 4}
right={4} right={isMarketplaceVariant ? '17px' : 4}
px={2} px={2}
py={0.5} py={0.5}
bg={'rgb(255, 247, 237)'} bg={'rgb(255, 247, 237)'}
...@@ -209,11 +231,44 @@ const ToolCard = ({ ...@@ -209,11 +231,44 @@ const ToolCard = ({
</Flex> </Flex>
)} )}
<HStack minW={0}> <HStack
<Avatar src={item.icon} borderRadius={'sm'} w={'1.5rem'} /> minW={0}
<Box color={'myGray.900'} fontWeight={'medium'} minW={0} className={'textEllipsis'}> 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)} {parseI18nString(item.name, i18n.language)}
</Box> </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 />} {item.isDebug && <DebugToolTag />}
{statusLabel && ( {statusLabel && (
<Flex <Flex
...@@ -229,32 +284,40 @@ const ToolCard = ({ ...@@ -229,32 +284,40 @@ const ToolCard = ({
)} )}
</HStack> </HStack>
<Box <Box
flex={['1 0 48px', '1 0 56px']} flex={isMarketplaceVariant ? '1 0 68px' : ['1 0 48px', '1 0 56px']}
mt={3} mt={isMarketplaceVariant ? undefined : 3}
pt={isMarketplaceVariant ? '12px' : undefined}
pr={1} pr={1}
textAlign={'justify'} textAlign={'justify'}
wordBreak={'break-all'} wordBreak={'break-all'}
fontSize={'xs'} fontSize={isMarketplaceVariant ? '12.8px' : 'xs'}
color={'myGray.500'} lineHeight={isMarketplaceVariant ? '19.2px' : undefined}
color={isMarketplaceVariant ? '#667085' : 'myGray.500'}
> >
<Box className={'textEllipsis2'}> <Box className={'textEllipsis2'}>
{parseI18nString(item.description || '', i18n.language) || {parseI18nString(item.description || '', i18n.language) ||
t('app:templateMarket.no_intro')} t('app:templateMarket.no_intro')}
</Box> </Box>
</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) => { {item.tags?.slice(0, visibleTagsCount).map((tag) => {
return ( return (
<Box <Box
key={tag} key={tag}
px={2} px={isMarketplaceVariant ? '9px' : 2}
py={1} py={isMarketplaceVariant ? '5px' : 1}
border={'1px solid'} border={'1px solid'}
borderRadius={'6px'} borderRadius={'6px'}
borderColor={'myGray.200'} borderColor={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
fontSize={'11px'} fontSize={'11px'}
lineHeight={isMarketplaceVariant ? '16.5px' : undefined}
fontWeight={'medium'} fontWeight={'medium'}
color={'myGray.700'} color={isMarketplaceVariant ? '#383F50' : 'myGray.700'}
flexShrink={0} flexShrink={0}
data-tag-item data-tag-item
> >
...@@ -264,14 +327,15 @@ const ToolCard = ({ ...@@ -264,14 +327,15 @@ const ToolCard = ({
})} })}
{item.tags && item.tags.length > visibleTagsCount && ( {item.tags && item.tags.length > visibleTagsCount && (
<Box <Box
px={2} px={isMarketplaceVariant ? '9px' : 2}
py={1} py={isMarketplaceVariant ? '5px' : 1}
border={'1px solid'} border={'1px solid'}
borderRadius={'6px'} borderRadius={'6px'}
borderColor={'myGray.200'} borderColor={isMarketplaceVariant ? '#E8EBF0' : 'myGray.200'}
fontSize={'11px'} fontSize={'11px'}
lineHeight={isMarketplaceVariant ? '16.5px' : undefined}
fontWeight={'medium'} fontWeight={'medium'}
color={'myGray.700'} color={isMarketplaceVariant ? '#383F50' : 'myGray.700'}
flexShrink={0} flexShrink={0}
> >
+{item.tags.length - visibleTagsCount} +{item.tags.length - visibleTagsCount}
...@@ -279,11 +343,18 @@ const ToolCard = ({ ...@@ -279,11 +343,18 @@ const ToolCard = ({
)} )}
</Flex> </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 <Box
className="author-info" className="author-info"
color={'myGray.500'} color={isMarketplaceVariant ? '#667085' : 'myGray.500'}
mt={3} mt={isMarketplaceVariant ? undefined : 3}
>{`by ${item.author || systemTitle || 'FastGPT'}`}</Box> >{`by ${item.author || systemTitle || 'FastGPT'}`}</Box>
{/*TODO: when statistics is ready*/} {/*TODO: when statistics is ready*/}
{/*<Flex flexDirection={'row'} gap={1} className="download-count" color={'myGray.500'} mt={3}> {/*<Flex flexDirection={'row'} gap={1} className="download-count" color={'myGray.500'} mt={3}>
...@@ -296,7 +367,21 @@ const ToolCard = ({ ...@@ -296,7 +367,21 @@ const ToolCard = ({
</Flex>*/} </Flex>*/}
<Flex gap={2} alignItems={'center'} ml={'auto'}> <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 <Button
className="install-button" className="install-button"
size={'sm'} size={'sm'}
......
...@@ -35,6 +35,7 @@ const ToolDetailDrawer = ({ ...@@ -35,6 +35,7 @@ const ToolDetailDrawer = ({
onClose, onClose,
selectedTool, selectedTool,
onToggleInstall, onToggleInstall,
onDelete,
onUpdate, onUpdate,
isUpdating, isUpdating,
systemTitle, systemTitle,
...@@ -49,7 +50,8 @@ const ToolDetailDrawer = ({ ...@@ -49,7 +50,8 @@ const ToolDetailDrawer = ({
onClose: () => void; onClose: () => void;
selectedTool: ToolCardItemType; selectedTool: ToolCardItemType;
onToggleInstall?: (installed: boolean, version?: string) => void | Promise<void>; onToggleInstall?: (installed: boolean, version?: string) => void | Promise<void>;
onUpdate?: (version?: string) => void; onDelete?: () => void | Promise<void>;
onUpdate?: (version?: string) => void | Promise<void>;
isUpdating?: boolean; isUpdating?: boolean;
systemTitle?: string; systemTitle?: string;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>; onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
...@@ -62,12 +64,15 @@ const ToolDetailDrawer = ({ ...@@ -62,12 +64,15 @@ const ToolDetailDrawer = ({
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params'); 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 [selectedVersion, setSelectedVersion] = useState<string | undefined>(selectedTool.version);
const isDownload = useMemo(() => { const isDownload = useMemo(() => {
return mode === 'marketplace'; return mode === 'marketplace';
}, [mode]); }, [mode]);
const showUninstallButton = mode === 'admin' && isInstalled && !!onDelete;
const showInstallButton = showActionButton && !showUninstallButton;
const hasUpdateButton = !!selectedTool.update && !!onUpdate && mode !== 'marketplace';
const { const {
data: toolVersions = [], data: toolVersions = [],
...@@ -88,7 +93,7 @@ const ToolDetailDrawer = ({ ...@@ -88,7 +93,7 @@ const ToolDetailDrawer = ({
if (selectedTool.id && onFetchVersions) { if (selectedTool.id && onFetchVersions) {
fetchToolVersions(selectedTool.id); fetchToolVersions(selectedTool.id);
} }
}, [selectedTool.id]); }, [fetchToolVersions, onFetchVersions, selectedTool.id]);
const activeVersion = selectedVersion || toolVersions[0]?.version; const activeVersion = selectedVersion || toolVersions[0]?.version;
...@@ -182,24 +187,17 @@ const ToolDetailDrawer = ({ ...@@ -182,24 +187,17 @@ const ToolDetailDrawer = ({
<Box fontSize={'12px'} color="myGray.500" mt={3}> <Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${parentTool?.author || systemTitle || 'FastGPT'}`} {`by ${parentTool?.author || systemTitle || 'FastGPT'}`}
</Box> </Box>
{(showActionButton || (selectedTool.update && onUpdate && mode !== 'marketplace')) && ( {(showInstallButton || showUninstallButton || hasUpdateButton) && (
<Flex mt={3} gap={2}> <Flex mt={3} gap={2}>
{/* Determine if we have two buttons */} {showInstallButton && (
{(() => {
const hasUpdateButton = selectedTool.update && onUpdate && mode !== 'marketplace';
return (
<>
{showActionButton && (
<Button <Button
flex={1} flex={'1 1 0'}
minW={0}
variant={isInstalled ? 'primaryOutline' : 'primary'} variant={isInstalled ? 'primaryOutline' : 'primary'}
isLoading={isLoading || loadingDetail} isLoading={isLoading || loadingDetail}
isDisabled={isUpdating} isDisabled={isUpdating}
onClick={async () => { onClick={async () => {
await onToggleInstall?.(!isInstalled, currentVersion); await onToggleInstall?.(!isInstalled, currentVersion);
if (mode === 'marketplace') return;
setIsInstalled(!isInstalled);
}} }}
> >
{isDownload {isDownload
...@@ -212,16 +210,27 @@ const ToolDetailDrawer = ({ ...@@ -212,16 +210,27 @@ const ToolDetailDrawer = ({
{hasUpdateButton && ( {hasUpdateButton && (
<Button <Button
variant="primary" variant="primary"
flex={1} flex={'1 1 0'}
minW={0}
isLoading={isUpdating || loadingDetail} isLoading={isUpdating || loadingDetail}
onClick={() => onUpdate?.(currentVersion)} onClick={async () => {
await onUpdate?.(currentVersion);
}}
> >
{t('app:custom_plugin_update')} {t('app:custom_plugin_update')}
</Button> </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> </Flex>
)} )}
......
...@@ -83,6 +83,7 @@ ...@@ -83,6 +83,7 @@
"confirm_delete_chats": "Are you sure you want to delete {{n}} chat records?\nThis cannot be undone.", "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_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_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_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.", "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", "copy_one_app": "Create Duplicate",
...@@ -115,6 +116,9 @@ ...@@ -115,6 +116,9 @@
"custom_plugin_config_title": "{{name}} Configuration", "custom_plugin_config_title": "{{name}} Configuration",
"custom_plugin_create": "Create Plugin", "custom_plugin_create": "Create Plugin",
"custom_plugin_delete_success": "Delete successful", "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_has_token_fee_label": "Charge Token Fee",
"custom_plugin_intro_label": "Introduction", "custom_plugin_intro_label": "Introduction",
"custom_plugin_intro_placeholder": "Add an introduction for this application", "custom_plugin_intro_placeholder": "Add an introduction for this application",
...@@ -383,7 +387,7 @@ ...@@ -383,7 +387,7 @@
"tool_input_param_tip": "This tool requires configuration of relevant information for normal operation.", "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_active": "This tool has not been activated yet",
"tool_not_desc": "The tool lacks a description ~", "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_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_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", "tool_run_free": "This tool runs without points consumption",
...@@ -396,8 +400,10 @@ ...@@ -396,8 +400,10 @@
"toolkit_activation_required": "Activation Required", "toolkit_activation_required": "Activation Required",
"toolkit_add_resource": "Add resources", "toolkit_add_resource": "Add resources",
"toolkit_basic_config": "Basic Configuration", "toolkit_basic_config": "Basic Configuration",
"toolkit_basic_info": "Basic Info",
"toolkit_batch_update": "Batch Update", "toolkit_batch_update": "Batch Update",
"toolkit_call_points_label": "Call Points", "toolkit_call_points_label": "Call Points",
"toolkit_community": "Community",
"toolkit_config_system_key": "Configure System Key", "toolkit_config_system_key": "Configure System Key",
"toolkit_contribute_resource": "Contribute Resource", "toolkit_contribute_resource": "Contribute Resource",
"toolkit_debug_connection_link": "Debug Link", "toolkit_debug_connection_link": "Debug Link",
...@@ -425,6 +431,7 @@ ...@@ -425,6 +431,7 @@
"toolkit_name": "Name", "toolkit_name": "Name",
"toolkit_no_call_points": "This tool does not require call points", "toolkit_no_call_points": "This tool does not require call points",
"toolkit_no_plugins": "No plugins", "toolkit_no_plugins": "No plugins",
"toolkit_official": "Official",
"toolkit_open_marketplace": "Open Marketplace", "toolkit_open_marketplace": "Open Marketplace",
"toolkit_outputs": "Output Parameters", "toolkit_outputs": "Output Parameters",
"toolkit_params_description": "Parameters", "toolkit_params_description": "Parameters",
...@@ -435,12 +442,13 @@ ...@@ -435,12 +442,13 @@
"toolkit_plugin_version": "Plugin Version", "toolkit_plugin_version": "Plugin Version",
"toolkit_promote_tags": "Promote Tags", "toolkit_promote_tags": "Promote Tags",
"toolkit_promote_tags_tip": "Users with the following tags will see the \"Promoted\" badge", "toolkit_promote_tags_tip": "Users with the following tags will see the \"Promoted\" badge",
"toolkit_reinstall": "Reinstall",
"toolkit_search_placeholder": "Search tool names", "toolkit_search_placeholder": "Search tool names",
"toolkit_select_app": "Select an existing app", "toolkit_select_app": "Select an existing app",
"toolkit_select_user_tags": "Select user tags", "toolkit_select_user_tags": "Select user tags",
"toolkit_status": "Status", "toolkit_status": "Status",
"toolkit_status_normal": "Normal", "toolkit_status_normal": "Normal",
"toolkit_status_offline": "Offline", "toolkit_status_offline": "Uninstalled",
"toolkit_status_soon_offline": "Soon Offline", "toolkit_status_soon_offline": "Soon Offline",
"toolkit_system_key": "System Key", "toolkit_system_key": "System Key",
"toolkit_system_key_configured": "Configured", "toolkit_system_key_configured": "Configured",
...@@ -462,7 +470,8 @@ ...@@ -462,7 +470,8 @@
"toolkit_tool_name": "Tool Name", "toolkit_tool_name": "Tool Name",
"toolkit_tutorial_link": "Open tutorial link", "toolkit_tutorial_link": "Open tutorial link",
"toolkit_uninstall": "Uninstall", "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": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins", "toolkit_updatable_plugins": "Updatable Plugins",
"toolkit_user_guide": "User Guide", "toolkit_user_guide": "User Guide",
......
...@@ -83,6 +83,7 @@ ...@@ -83,6 +83,7 @@
"confirm_delete_chats": "确认删除 {{n}} 组对话记录?记录将会永久删除!", "confirm_delete_chats": "确认删除 {{n}} 组对话记录?记录将会永久删除!",
"confirm_delete_folder_tip": "删除该文件夹时,将会删除它下面所有应用及对应的聊天记录。", "confirm_delete_folder_tip": "删除该文件夹时,将会删除它下面所有应用及对应的聊天记录。",
"confirm_delete_tool": "确认删除该工具?", "confirm_delete_tool": "确认删除该工具?",
"confirm_uninstall_tool": "插件卸载后,依赖此插件的应用可能无法正常运行,请谨慎操作。",
"copilot_config_message": "`当前节点配置信息: \n代码类型:{{codeType}} \n当前代码: \\`\\`\\`{{codeType}} \n{{code}} \\`\\`\\` \n输入参数: {{inputs}} \n输出参数: {{outputs}}`", "copilot_config_message": "`当前节点配置信息: \n代码类型:{{codeType}} \n当前代码: \\`\\`\\`{{codeType}} \n{{code}} \\`\\`\\` \n输入参数: {{inputs}} \n输出参数: {{outputs}}`",
"copilot_confirm_message": "已接收到原始配置,了解当前代码结构和输入输出参数。请说明您的优化需求。", "copilot_confirm_message": "已接收到原始配置,了解当前代码结构和输入输出参数。请说明您的优化需求。",
"copy_one_app": "创建副本", "copy_one_app": "创建副本",
...@@ -115,6 +116,9 @@ ...@@ -115,6 +116,9 @@
"custom_plugin_config_title": "{{name}}配置", "custom_plugin_config_title": "{{name}}配置",
"custom_plugin_create": "新建插件", "custom_plugin_create": "新建插件",
"custom_plugin_delete_success": "删除成功", "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_has_token_fee_label": "是否收取 Token 费用",
"custom_plugin_intro_label": "介绍", "custom_plugin_intro_label": "介绍",
"custom_plugin_intro_placeholder": "为这个应用添加一个介绍", "custom_plugin_intro_placeholder": "为这个应用添加一个介绍",
...@@ -383,7 +387,7 @@ ...@@ -383,7 +387,7 @@
"tool_input_param_tip": "该工具正常运行需要配置相关信息", "tool_input_param_tip": "该工具正常运行需要配置相关信息",
"tool_not_active": "该工具尚未激活", "tool_not_active": "该工具尚未激活",
"tool_not_desc": "工具缺少描述~", "tool_not_desc": "工具缺少描述~",
"tool_offset_tips": "该工具已无法使用,将中断应用运行,请立即替换", "tool_offset_tips": "该工具已卸载,将中断应用运行,请立即替换",
"tool_param_config": "参数配置", "tool_param_config": "参数配置",
"tool_params_description_tips": "参数功能的描述,若作为工具调用参数,影响模型工具调用效果", "tool_params_description_tips": "参数功能的描述,若作为工具调用参数,影响模型工具调用效果",
"tool_run_free": "该工具运行无积分消耗", "tool_run_free": "该工具运行无积分消耗",
...@@ -396,8 +400,10 @@ ...@@ -396,8 +400,10 @@
"toolkit_activation_required": "需要激活", "toolkit_activation_required": "需要激活",
"toolkit_add_resource": "添加插件", "toolkit_add_resource": "添加插件",
"toolkit_basic_config": "基础配置", "toolkit_basic_config": "基础配置",
"toolkit_basic_info": "基础信息",
"toolkit_batch_update": "批量更新", "toolkit_batch_update": "批量更新",
"toolkit_call_points_label": "调用积分", "toolkit_call_points_label": "调用积分",
"toolkit_community": "社区",
"toolkit_config_system_key": "是否配置系统密钥", "toolkit_config_system_key": "是否配置系统密钥",
"toolkit_contribute_resource": "贡献插件", "toolkit_contribute_resource": "贡献插件",
"toolkit_debug_connection_link": "调试链接", "toolkit_debug_connection_link": "调试链接",
...@@ -425,6 +431,7 @@ ...@@ -425,6 +431,7 @@
"toolkit_name": "名称", "toolkit_name": "名称",
"toolkit_no_call_points": "该工具无需调用积分", "toolkit_no_call_points": "该工具无需调用积分",
"toolkit_no_plugins": "暂无插件", "toolkit_no_plugins": "暂无插件",
"toolkit_official": "官方",
"toolkit_open_marketplace": "打开插件市场", "toolkit_open_marketplace": "打开插件市场",
"toolkit_outputs": "输出参数", "toolkit_outputs": "输出参数",
"toolkit_params_description": "参数说明", "toolkit_params_description": "参数说明",
...@@ -435,12 +442,13 @@ ...@@ -435,12 +442,13 @@
"toolkit_plugin_version": "插件版本", "toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推荐标签", "toolkit_promote_tags": "推荐标签",
"toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识", "toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识",
"toolkit_reinstall": "重新安装",
"toolkit_search_placeholder": "搜索工具名称", "toolkit_search_placeholder": "搜索工具名称",
"toolkit_select_app": "选择现有应用", "toolkit_select_app": "选择现有应用",
"toolkit_select_user_tags": "选择用户标签", "toolkit_select_user_tags": "选择用户标签",
"toolkit_status": "状态", "toolkit_status": "状态",
"toolkit_status_normal": "正常", "toolkit_status_normal": "正常",
"toolkit_status_offline": "已下线", "toolkit_status_offline": "已卸载",
"toolkit_status_soon_offline": "即将下线", "toolkit_status_soon_offline": "即将下线",
"toolkit_system_key": "系统密钥", "toolkit_system_key": "系统密钥",
"toolkit_system_key_configured": "已配置", "toolkit_system_key_configured": "已配置",
...@@ -462,6 +470,7 @@ ...@@ -462,6 +470,7 @@
"toolkit_tool_name": "工具名", "toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教程链接", "toolkit_tutorial_link": "查看教程链接",
"toolkit_uninstall": "卸载", "toolkit_uninstall": "卸载",
"toolkit_uninstalled_reinstall_tip": "该插件已卸载,请重新安装",
"toolkit_uninstalled": "未安装", "toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新", "toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件", "toolkit_updatable_plugins": "可更新的插件",
......
...@@ -81,6 +81,7 @@ ...@@ -81,6 +81,7 @@
"confirm_delete_chats": "確認刪除 {{n}} 組對話記錄?\n記錄將會永久刪除!", "confirm_delete_chats": "確認刪除 {{n}} 組對話記錄?\n記錄將會永久刪除!",
"confirm_delete_folder_tip": "刪除該文件夾時,將會刪除它下面所有應用及對應的聊天記錄。", "confirm_delete_folder_tip": "刪除該文件夾時,將會刪除它下面所有應用及對應的聊天記錄。",
"confirm_delete_tool": "確認刪除該工具?", "confirm_delete_tool": "確認刪除該工具?",
"confirm_uninstall_tool": "插件卸載後,依賴此插件的應用可能無法正常運行,請謹慎操作。",
"copilot_config_message": "當前節點配置信息: \n代碼類型:{{codeType}} \n當前代碼: \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n輸入參數: {{inputs}} \n輸出參數: {{outputs}}", "copilot_config_message": "當前節點配置信息: \n代碼類型:{{codeType}} \n當前代碼: \\\\`\\\\`\\\\`{{codeType}} \n{{code}} \\\\`\\\\`\\\\` \n輸入參數: {{inputs}} \n輸出參數: {{outputs}}",
"copilot_confirm_message": "已接收到原始配置,了解當前代碼結構和輸入輸出參數。\n請說明您的優化需求。", "copilot_confirm_message": "已接收到原始配置,了解當前代碼結構和輸入輸出參數。\n請說明您的優化需求。",
"copy_one_app": "建立副本", "copy_one_app": "建立副本",
...@@ -112,6 +113,9 @@ ...@@ -112,6 +113,9 @@
"custom_plugin_config_title": "{{name}}配置", "custom_plugin_config_title": "{{name}}配置",
"custom_plugin_create": "新建外掛", "custom_plugin_create": "新建外掛",
"custom_plugin_delete_success": "刪除成功", "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_has_token_fee_label": "是否收取 Token 費用",
"custom_plugin_intro_label": "介紹", "custom_plugin_intro_label": "介紹",
"custom_plugin_intro_placeholder": "為這個應用程式新增一個介紹", "custom_plugin_intro_placeholder": "為這個應用程式新增一個介紹",
...@@ -373,7 +377,7 @@ ...@@ -373,7 +377,7 @@
"tool_input_param_tip": "該工具正常運行需要配置相關信息", "tool_input_param_tip": "該工具正常運行需要配置相關信息",
"tool_not_active": "該工具尚未激活", "tool_not_active": "該工具尚未激活",
"tool_not_desc": "工具缺少描述~", "tool_not_desc": "工具缺少描述~",
"tool_offset_tips": "該工具已無法使用,將中斷應用運行,請立即替換", "tool_offset_tips": "該工具已卸載,將中斷應用運行,請立即替換",
"tool_param_config": "參數配置", "tool_param_config": "參數配置",
"tool_params_description_tips": "參數功能的描述,若作為工具調用參數,影響模型工具調用效果", "tool_params_description_tips": "參數功能的描述,若作為工具調用參數,影響模型工具調用效果",
"tool_run_free": "該工具運行無積分消耗", "tool_run_free": "該工具運行無積分消耗",
...@@ -386,8 +390,10 @@ ...@@ -386,8 +390,10 @@
"toolkit_activation_required": "需要激活", "toolkit_activation_required": "需要激活",
"toolkit_add_resource": "添加資源", "toolkit_add_resource": "添加資源",
"toolkit_basic_config": "基礎配置", "toolkit_basic_config": "基礎配置",
"toolkit_basic_info": "基礎資訊",
"toolkit_batch_update": "批次更新", "toolkit_batch_update": "批次更新",
"toolkit_call_points_label": "調用積分", "toolkit_call_points_label": "調用積分",
"toolkit_community": "社區",
"toolkit_config_system_key": "是否配置系統密鑰", "toolkit_config_system_key": "是否配置系統密鑰",
"toolkit_contribute_resource": "貢獻資源", "toolkit_contribute_resource": "貢獻資源",
"toolkit_debug_connection_link": "調試連結", "toolkit_debug_connection_link": "調試連結",
...@@ -415,6 +421,7 @@ ...@@ -415,6 +421,7 @@
"toolkit_name": "名稱", "toolkit_name": "名稱",
"toolkit_no_call_points": "該工具無需調用積分", "toolkit_no_call_points": "該工具無需調用積分",
"toolkit_no_plugins": "暫無插件", "toolkit_no_plugins": "暫無插件",
"toolkit_official": "官方",
"toolkit_open_marketplace": "打開資源市場", "toolkit_open_marketplace": "打開資源市場",
"toolkit_outputs": "輸出參數", "toolkit_outputs": "輸出參數",
"toolkit_params_description": "參數說明", "toolkit_params_description": "參數說明",
...@@ -425,12 +432,13 @@ ...@@ -425,12 +432,13 @@
"toolkit_plugin_version": "插件版本", "toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推薦標籤", "toolkit_promote_tags": "推薦標籤",
"toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識", "toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識",
"toolkit_reinstall": "重新安裝",
"toolkit_search_placeholder": "搜索工具名稱", "toolkit_search_placeholder": "搜索工具名稱",
"toolkit_select_app": "選擇現有應用", "toolkit_select_app": "選擇現有應用",
"toolkit_select_user_tags": "選擇使用者標籤", "toolkit_select_user_tags": "選擇使用者標籤",
"toolkit_status": "狀態", "toolkit_status": "狀態",
"toolkit_status_normal": "正常", "toolkit_status_normal": "正常",
"toolkit_status_offline": "下線", "toolkit_status_offline": "已卸載",
"toolkit_status_soon_offline": "即將下線", "toolkit_status_soon_offline": "即將下線",
"toolkit_system_key": "系統密鑰", "toolkit_system_key": "系統密鑰",
"toolkit_system_key_configured": "已配置", "toolkit_system_key_configured": "已配置",
...@@ -451,6 +459,7 @@ ...@@ -451,6 +459,7 @@
"toolkit_tool_name": "工具名", "toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教學連結", "toolkit_tutorial_link": "查看教學連結",
"toolkit_uninstall": "卸載", "toolkit_uninstall": "卸載",
"toolkit_uninstalled_reinstall_tip": "該插件已卸載,請重新安裝",
"toolkit_uninstalled": "未安裝", "toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新", "toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式", "toolkit_updatable_plugins": "可更新的外掛程式",
......
...@@ -247,6 +247,29 @@ const Button = defineStyleConfig({ ...@@ -247,6 +247,29 @@ const Button = defineStyleConfig({
color: 'red.600' 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: { grayBase: {
bg: 'myGray.150', bg: 'myGray.150',
color: 'myGray.900', color: 'myGray.900',
......
...@@ -85,6 +85,23 @@ type Props = FlowNodeItemType & { ...@@ -85,6 +85,23 @@ type Props = FlowNodeItemType & {
colorSchema?: keyof typeof NodeGradients; 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 NodeCard = (props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
...@@ -227,28 +244,6 @@ const NodeCard = (props: Props) => { ...@@ -227,28 +244,6 @@ const NodeCard = (props: Props) => {
const isAppNode = node && AppNodeFlowNodeTypeMap[node?.flowNodeType]; const isAppNode = node && AppNodeFlowNodeTypeMap[node?.flowNodeType];
const isLoopNode = isNestedParentNodeType(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( const { data: nodeTemplate } = useRequest(
async () => { async () => {
...@@ -257,7 +252,21 @@ const NodeCard = (props: Props) => { ...@@ -257,7 +252,21 @@ const NodeCard = (props: Props) => {
} }
if (isAppNode) { 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 { } else {
const template = moduleTemplatesFlat.find( const template = moduleTemplatesFlat.find(
(item) => item.flowNodeType === node?.flowNodeType (item) => item.flowNodeType === node?.flowNodeType
...@@ -290,10 +299,45 @@ const NodeCard = (props: Props) => { ...@@ -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 - 重构后的版本,依赖项大幅减少 */ /* Node header - 重构后的版本,依赖项大幅减少 */
const error = useMemo(() => formatToolError(node?.pluginData?.error), [node?.pluginData?.error]); const error = useMemo(() => formatToolError(node?.pluginData?.error), [node?.pluginData?.error]);
const showHeader = node?.flowNodeType !== FlowNodeTypeEnum.comment; const showHeader = node?.flowNodeType !== FlowNodeTypeEnum.comment;
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
UpdateWorkflowToolBodySchema, UpdateWorkflowToolBodySchema,
type UpdateWorkflowToolBodyType type UpdateWorkflowToolBodyType
} from '@fastgpt/global/openapi/core/plugin/admin/tool/api'; } from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
export type updateWorkflowToolQuery = {}; export type updateWorkflowToolQuery = {};
...@@ -14,13 +15,23 @@ export type updateWorkflowToolBody = UpdateWorkflowToolBodyType; ...@@ -14,13 +15,23 @@ export type updateWorkflowToolBody = UpdateWorkflowToolBodyType;
export type updateWorkflowToolResponse = {}; 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( async function handler(
req: ApiRequestProps<updateWorkflowToolBody, updateWorkflowToolQuery>, req: ApiRequestProps<updateWorkflowToolBody, updateWorkflowToolQuery>,
res: ApiResponseType<any> res: ApiResponseType<any>
): Promise<updateWorkflowToolResponse> { ): Promise<updateWorkflowToolResponse> {
await authSystemAdmin({ req }); 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 }); const plugin = await MongoSystemTool.findOne({ pluginId });
if (!plugin?.customConfig?.associatedPluginId) { if (!plugin?.customConfig?.associatedPluginId) {
...@@ -40,16 +51,16 @@ async function handler( ...@@ -40,16 +51,16 @@ async function handler(
plugin.customConfig.name !== nextCustomConfig.name || plugin.customConfig.name !== nextCustomConfig.name ||
plugin.customConfig.avatar !== nextCustomConfig.avatar || plugin.customConfig.avatar !== nextCustomConfig.avatar ||
plugin.customConfig.intro !== nextCustomConfig.intro; plugin.customConfig.intro !== nextCustomConfig.intro;
const baseUpdateFields = { const baseUpdateFields = omitUndefinedFields({
pluginId, pluginId,
status: updateFields.status, status: updateFields.status,
originCost: updateFields.originCost, originCost: updateFields.originCost,
currentCost: updateFields.currentCost, currentCost: updateFields.currentCost,
hasTokenFee: updateFields.hasTokenFee, hasTokenFee: updateFields.hasTokenFee,
systemKeyCost: updateFields.systemKeyCost, systemKeyCost: updateFields.systemKeyCost,
promoteTags: updateFields.promoteTags ?? null, promoteTags: 'promoteTags' in updateFields ? (updateFields.promoteTags ?? null) : undefined,
hideTags: updateFields.hideTags ?? null hideTags: 'hideTags' in updateFields ? (updateFields.hideTags ?? null) : undefined
}; });
if ('secretsVal' in updateFields) { if ('secretsVal' in updateFields) {
Object.assign(baseUpdateFields, { Object.assign(baseUpdateFields, {
secretsVal: updateFields.secretsVal ?? null secretsVal: updateFields.secretsVal ?? null
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
UpdateSystemToolBodySchema, UpdateSystemToolBodySchema,
type UpdateSystemToolBodyType type UpdateSystemToolBodyType
} from '@fastgpt/global/openapi/core/plugin/admin/tool/api'; } from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
export type updateToolQuery = {}; export type updateToolQuery = {};
...@@ -14,12 +15,22 @@ export type updateToolBody = UpdateSystemToolBodyType; ...@@ -14,12 +15,22 @@ export type updateToolBody = UpdateSystemToolBodyType;
export type updateToolResponse = {}; 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( async function handler(
req: ApiRequestProps<updateToolBody, updateToolQuery>, req: ApiRequestProps<updateToolBody, updateToolQuery>,
res: ApiResponseType<any> res: ApiResponseType<any>
): Promise<updateToolResponse> { ): Promise<updateToolResponse> {
await authSystemAdmin({ req }); 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 }); const plugin = await MongoSystemTool.findOne({ pluginId });
...@@ -28,16 +39,16 @@ async function handler( ...@@ -28,16 +39,16 @@ async function handler(
} }
// 基础更新字段 // 基础更新字段
const baseUpdateFields = { const baseUpdateFields = omitUndefinedFields({
pluginId, pluginId,
status: updateFields.status, status: updateFields.status,
originCost: updateFields.originCost, originCost: updateFields.originCost,
currentCost: updateFields.currentCost, currentCost: updateFields.currentCost,
hasTokenFee: updateFields.hasTokenFee, hasTokenFee: updateFields.hasTokenFee,
systemKeyCost: updateFields.systemKeyCost, systemKeyCost: updateFields.systemKeyCost,
promoteTags: updateFields.promoteTags ?? null, promoteTags: 'promoteTags' in updateFields ? (updateFields.promoteTags ?? null) : undefined,
hideTags: updateFields.hideTags ?? null hideTags: 'hideTags' in updateFields ? (updateFields.hideTags ?? null) : undefined
}; });
if ('secretsVal' in updateFields) { if ('secretsVal' in updateFields) {
Object.assign(baseUpdateFields, { Object.assign(baseUpdateFields, {
secretsVal: updateFields.secretsVal ?? null secretsVal: updateFields.secretsVal ?? null
...@@ -63,7 +74,7 @@ async function handler( ...@@ -63,7 +74,7 @@ async function handler(
// 如果有子工具,更新子工具 // 如果有子工具,更新子工具
for await (const tool of updateFields.children || []) { for await (const tool of updateFields.children || []) {
const childPluginId = tool.id.includes('/') ? tool.id : `${pluginId}/${tool.id}`; const childPluginId = tool.id.includes('/') ? tool.id : `${pluginId}/${tool.id}`;
const childUpdateFields = { const childUpdateFields = omitUndefinedFields({
pluginId: childPluginId, pluginId: childPluginId,
systemKeyCost: tool.systemKeyCost, systemKeyCost: tool.systemKeyCost,
currentCost: updateFields.currentCost, currentCost: updateFields.currentCost,
...@@ -72,7 +83,7 @@ async function handler( ...@@ -72,7 +83,7 @@ async function handler(
originCost: updateFields.originCost, originCost: updateFields.originCost,
promoteTags: updateFields.promoteTags, promoteTags: updateFields.promoteTags,
hideTags: updateFields.hideTags hideTags: updateFields.hideTags
}; });
if ('secretsVal' in updateFields) { if ('secretsVal' in updateFields) {
Object.assign(childUpdateFields, { Object.assign(childUpdateFields, {
secretsVal: updateFields.secretsVal secretsVal: updateFields.secretsVal
......
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { serviceSideProps } from '@/web/common/i18n/utils'; import { serviceSideProps } from '@/web/common/i18n/utils';
import { Box, Button, Center, Flex, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Center, Flex, useDisclosure } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
...@@ -22,6 +22,7 @@ import { getAdminSystemTools, putAdminUpdateToolOrder } from '@/web/core/plugin/ ...@@ -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 { GetAdminSystemToolsResponseType } from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import type { AdminSystemToolListItemType } from '@fastgpt/global/core/app/tool/systemTool/type'; import type { AdminSystemToolListItemType } from '@fastgpt/global/core/app/tool/systemTool/type';
import { useDebounce } from 'ahooks'; import { useDebounce } from 'ahooks';
import { PluginStatusEnum, type PluginStatusType } from '@fastgpt/global/core/plugin/type';
const SystemToolConfigModal = dynamic( const SystemToolConfigModal = dynamic(
() => import('@/pageComponents/config/tool/SystemToolConfigModal') () => import('@/pageComponents/config/tool/SystemToolConfigModal')
...@@ -38,6 +39,8 @@ const ToolProvider = () => { ...@@ -38,6 +39,8 @@ const ToolProvider = () => {
const [localTools, setLocalTools] = useState<GetAdminSystemToolsResponseType>([]); const [localTools, setLocalTools] = useState<GetAdminSystemToolsResponseType>([]);
const [editingToolId, setEditingToolId] = useState<string>(); const [editingToolId, setEditingToolId] = useState<string>();
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
const [statusFilter, setStatusFilter] = useState<PluginStatusType>();
const [tagFilter, setTagFilter] = useState<string>();
const debouncedSearchKey = useDebounce(searchKey, { wait: 300 }); const debouncedSearchKey = useDebounce(searchKey, { wait: 300 });
const requestSearchKey = debouncedSearchKey.trim(); const requestSearchKey = debouncedSearchKey.trim();
...@@ -64,6 +67,55 @@ const ToolProvider = () => { ...@@ -64,6 +67,55 @@ const ToolProvider = () => {
manual: false 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 ( return (
<MyBox pt={4} pl={3} pr={8} isLoading={loadingTools}> <MyBox pt={4} pl={3} pr={8} isLoading={loadingTools}>
...@@ -132,10 +184,61 @@ const ToolProvider = () => { ...@@ -132,10 +184,61 @@ const ToolProvider = () => {
<Box w={2.2 / 10} pl={8}> <Box w={2.2 / 10} pl={8}>
{t('app:toolkit_name')} {t('app:toolkit_name')}
</Box> </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={4.1 / 10}>{t('common:Intro')}</Box>
<Box w={1.1 / 10} pl={6}> <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>
<Box w={1.1 / 10} display={'flex'} alignItems={'center'}> <Box w={1.1 / 10} display={'flex'} alignItems={'center'}>
{t('app:toolkit_system_key')} {t('app:toolkit_system_key')}
...@@ -150,7 +253,7 @@ const ToolProvider = () => { ...@@ -150,7 +253,7 @@ const ToolProvider = () => {
</Flex> </Flex>
<Box overflow={'auto'} mt={2} h={'calc(100vh - 150px)'}> <Box overflow={'auto'} mt={2} h={'calc(100vh - 150px)'}>
{localTools.length > 0 ? ( {displayTools.length > 0 ? (
<DndDrag<AdminSystemToolListItemType> <DndDrag<AdminSystemToolListItemType>
onDragEndCb={async (list: Array<AdminSystemToolListItemType>) => { onDragEndCb={async (list: Array<AdminSystemToolListItemType>) => {
const newOrder = list.map((item, index) => ({ const newOrder = list.map((item, index) => ({
...@@ -160,7 +263,7 @@ const ToolProvider = () => { ...@@ -160,7 +263,7 @@ const ToolProvider = () => {
setLocalTools(list); setLocalTools(list);
await putAdminUpdateToolOrder({ plugins: newOrder }); await putAdminUpdateToolOrder({ plugins: newOrder });
}} }}
dataList={localTools} dataList={displayTools}
> >
{({ provided }) => ( {({ provided }) => (
<Flex <Flex
...@@ -170,12 +273,12 @@ const ToolProvider = () => { ...@@ -170,12 +273,12 @@ const ToolProvider = () => {
{...provided.droppableProps} {...provided.droppableProps}
ref={provided.innerRef} ref={provided.innerRef}
> >
{localTools.map((item, index) => ( {displayTools.map((item, index) => (
<Draggable <Draggable
key={item.id} key={item.id}
draggableId={item.id} draggableId={item.id}
index={index} index={index}
isDragDisabled={!!searchKey.trim()} isDragDisabled={!!searchKey.trim() || isTableFilterActive}
> >
{(provided, snapshot) => ( {(provided, snapshot) => (
<ToolRow <ToolRow
......
...@@ -6,4 +6,5 @@ STORAGE_S3_FORCE_PATH_STYLE=true ...@@ -6,4 +6,5 @@ STORAGE_S3_FORCE_PATH_STYLE=true
STORAGE_ACCESS_KEY_ID=minioadmin STORAGE_ACCESS_KEY_ID=minioadmin
STORAGE_SECRET_ACCESS_KEY=minioadmin STORAGE_SECRET_ACCESS_KEY=minioadmin
AUTH_TOKEN=xxxx AUTH_TOKEN=xxxx
COMMUNITY_AUTH_TOKEN=xxxx
MONGODB_URI="mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin&directConnection=true" MONGODB_URI="mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin&directConnection=true"
...@@ -12,6 +12,7 @@ export const marketplaceEnv = createEnv({ ...@@ -12,6 +12,7 @@ export const marketplaceEnv = createEnv({
NEXT_RUNTIME: z.string().optional(), NEXT_RUNTIME: z.string().optional(),
AUTH_TOKEN: z.string().optional().default('marketplace-token'), AUTH_TOKEN: z.string().optional().default('marketplace-token'),
COMMUNITY_AUTH_TOKEN: z.string().optional(),
MONGODB_URI: z.string().optional().default(''), MONGODB_URI: z.string().optional().default(''),
DB_MAX_LINK: IntSchema.default(20), DB_MAX_LINK: IntSchema.default(20),
SYNC_INDEX: BoolSchema.default(true), SYNC_INDEX: BoolSchema.default(true),
......
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { AUTH_TOKEN } from '@/service/auth'; import { isOfficialToken } from '@/service/auth';
import { deleteMarketplacePkg } from '@/service/tool/delete'; import { deleteMarketplacePkg } from '@/service/tool/delete';
import { import {
DeleteMarketplacePkgBodySchema, DeleteMarketplacePkgBodySchema,
DeleteMarketplacePkgResponseSchema, DeleteMarketplacePkgResponseSchema,
type DeleteMarketplacePkgResponseType type DeleteMarketplacePkgResponseType
} from '@fastgpt/global/openapi/core/plugin/marketplace/api'; } from '@fastgpt/global/openapi/core/plugin/marketplace/api';
import {
getZodParseErrorInputSource,
parseApiInput
} from '@fastgpt/service/common/zod/requestParseError';
/* ============================================================================ /* ============================================================================
* API: 删除 marketplace 插件 pkg * API: 删除 marketplace 插件 pkg
...@@ -46,15 +50,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -46,15 +50,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return sendJson(res, 405, null, 'Method not allowed'); 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'); return sendJson(res, 401, null, 'Unauthorized');
} }
const data = DeleteMarketplacePkgBodySchema.parse(req.body); const { body: data } = parseApiInput({
req,
bodySchema: DeleteMarketplacePkgBodySchema
});
const response = await deleteMarketplacePkg(data); const response = await deleteMarketplacePkg(data);
return sendJson(res, 200, DeleteMarketplacePkgResponseSchema.parse(response)); return sendJson(res, 200, DeleteMarketplacePkgResponseSchema.parse(response));
} catch (error) { } catch (error) {
if (getZodParseErrorInputSource(error)) {
return sendJson(res, 400, null, 'Invalid request body');
}
return sendJson( return sendJson(
res, res,
500, 500,
......
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { multer } from '@fastgpt/service/common/file/multer'; 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 { uploadMarketplacePkg } from '@/service/tool/upload';
import { import {
UploadMarketplacePkgDataSchema, UploadMarketplacePkgDataSchema,
...@@ -55,7 +55,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -55,7 +55,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return sendJson(res, 405, null, 'Method not allowed'); 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'); return sendJson(res, 401, null, 'Unauthorized');
} }
...@@ -65,10 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -65,10 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
filepaths.push(result.fileMetadata.path); filepaths.push(result.fileMetadata.path);
const data = UploadMarketplacePkgDataSchema.parse(result.data); UploadMarketplacePkgDataSchema.parse(result.data);
const response = await uploadMarketplacePkg({ const response = await uploadMarketplacePkg({
buffer: result.getBuffer(), buffer: result.getBuffer(),
source: data.source source: tokenIdentity.source
}); });
return sendJson(res, 200, UploadMarketplacePkgResponseSchema.parse(response)); return sendJson(res, 200, UploadMarketplacePkgResponseSchema.parse(response));
......
...@@ -5,17 +5,25 @@ import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination'; ...@@ -5,17 +5,25 @@ import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { getPkgdownloadURL } from '@/service/s3'; 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 ToolListQuery = {};
export type ToolListBody = PaginationProps<{ export type ToolListBody = PaginationProps<{
searchKey?: string; searchKey?: string;
tags?: string[]; tags?: string[];
source?: MarketplaceSourceFilterType;
}>; }>;
export type ToolListItem = ToolListItemType & { export type ToolListItem = ToolListItemType & {
downloadCount: number; downloadCount: number;
downloadUrl: string; downloadUrl: string;
toolId: string; toolId: string;
source?: string;
}; };
export type ToolListResponse = PaginationResponse<ToolListItem>; export type ToolListResponse = PaginationResponse<ToolListItem>;
...@@ -26,12 +34,24 @@ const getToolTags = (item: { tags?: readonly string[] | null }): readonly string ...@@ -26,12 +34,24 @@ const getToolTags = (item: { tags?: readonly string[] | null }): readonly string
const hasSecretSchemaProperties = (secretSchema?: { properties?: Record<string, unknown> }) => const hasSecretSchemaProperties = (secretSchema?: { properties?: Record<string, unknown> }) =>
!!secretSchema?.properties && Object.keys(secretSchema.properties).length > 0; !!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( async function handler(
req: ApiRequestProps<ToolListBody, ToolListQuery>, req: ApiRequestProps<ToolListBody, ToolListQuery>,
res: ApiResponseType<any> res: ApiResponseType<any>
): Promise<ToolListResponse> { ): Promise<ToolListResponse> {
const { body } = parseApiInput({
req,
bodySchema: GetMarketplaceToolsBodySchema
});
const { pageSize, offset } = parsePaginationRequest(req); const { pageSize, offset } = parsePaginationRequest(req);
const { searchKey, tags } = req.body; const { searchKey, tags, source } = body;
const data = await getToolList(); const data = await getToolList();
const filteredData = data.filter((item) => { const filteredData = data.filter((item) => {
...@@ -48,6 +68,7 @@ async function handler( ...@@ -48,6 +68,7 @@ async function handler(
) )
return false; return false;
if (tags && !tags.some((tag) => getToolTags(item).includes(tag))) return false; if (tags && !tags.some((tag) => getToolTags(item).includes(tag))) return false;
if (source && !matchSource(item.source, source)) return false;
return true; return true;
}); });
......
...@@ -6,7 +6,9 @@ import MyIcon from '@fastgpt/web/components/common/Icon'; ...@@ -6,7 +6,9 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import ToolCard, { type ToolCardItemType } from '@fastgpt/web/components/core/plugin/tool/ToolCard'; 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 ToolDetailDrawer from '@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { usePagination } from '@fastgpt/web/hooks/usePagination'; import { usePagination } from '@fastgpt/web/hooks/usePagination';
...@@ -46,17 +48,37 @@ const createQuerySelectedTool = ({ ...@@ -46,17 +48,37 @@ const createQuerySelectedTool = ({
name: pluginId, name: pluginId,
description: '', description: '',
version 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 ToolkitMarketplace = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
const { search, tags, pluginId, version } = router.query; const { search, tags, pluginId, version, source } = router.query;
const queryPluginId = getSingleQueryValue(pluginId); const queryPluginId = getSingleQueryValue(pluginId);
const queryVersion = getSingleQueryValue(version); const queryVersion = getSingleQueryValue(version);
const querySource = getSourceFilterValue(source);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [selectedSource, setSelectedSource] = useState<MarketplaceSourceFilterValue | undefined>(
querySource
);
const [selectedTool, setSelectedTool] = useState<ToolCardItemType | null>(null); const [selectedTool, setSelectedTool] = useState<ToolCardItemType | null>(null);
const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompactSearch, setShowCompactSearch] = useState(false); const [showCompactSearch, setShowCompactSearch] = useState(false);
...@@ -88,10 +110,11 @@ const ToolkitMarketplace = () => { ...@@ -88,10 +110,11 @@ const ToolkitMarketplace = () => {
: []; : [];
setSelectedTagIds(tagArray); setSelectedTagIds(tagArray);
} }
setSelectedSource(querySource);
} catch (error) { } catch (error) {
console.warn('Failed to initialize URL params:', error); console.warn('Failed to initialize URL params:', error);
} }
}, [search, tags]); }, [querySource, search, tags]);
// 使用自定义 debounce 进行实时搜索 // 使用自定义 debounce 进行实时搜索
const [debouncedSearchText, setDebouncedSearchText] = useState(inputValue); const [debouncedSearchText, setDebouncedSearchText] = useState(inputValue);
...@@ -113,12 +136,18 @@ const ToolkitMarketplace = () => { ...@@ -113,12 +136,18 @@ const ToolkitMarketplace = () => {
// 更新 URL 的函数 // 更新 URL 的函数
const updateUrlParams = useCallback( const updateUrlParams = useCallback(
(newSearch: string, newTags: string[], detailQuery = detailQueryRef.current) => { (
newSearch: string,
newTags: string[],
detailQuery = detailQueryRef.current,
newSource = selectedSource
) => {
try { try {
const newUrl = buildMarketplacePageUrl({ const newUrl = buildMarketplacePageUrl({
pathname: router.pathname, pathname: router.pathname,
search: newSearch, search: newSearch,
tags: newTags, tags: newTags,
source: newSource,
pluginId: detailQuery?.pluginId, pluginId: detailQuery?.pluginId,
version: detailQuery?.version version: detailQuery?.version
}); });
...@@ -130,6 +159,7 @@ const ToolkitMarketplace = () => { ...@@ -130,6 +159,7 @@ const ToolkitMarketplace = () => {
window.isSecureContext || window.isSecureContext ||
(window.location.protocol === 'http:' && window.location.hostname === 'localhost') (window.location.protocol === 'http:' && window.location.hostname === 'localhost')
) { ) {
if (isSameBrowserUrl(newUrl)) return;
try { try {
window.history.replaceState({}, '', newUrl); window.history.replaceState({}, '', newUrl);
} catch (historyError) { } catch (historyError) {
...@@ -144,7 +174,7 @@ const ToolkitMarketplace = () => { ...@@ -144,7 +174,7 @@ const ToolkitMarketplace = () => {
// 如果 URL 操作失败,跳过更新 // 如果 URL 操作失败,跳过更新
} }
}, },
[router.pathname] [router.pathname, selectedSource]
); );
const getCurrentDetailQuery = useCallback(() => { const getCurrentDetailQuery = useCallback(() => {
...@@ -167,6 +197,14 @@ const ToolkitMarketplace = () => { ...@@ -167,6 +197,14 @@ const ToolkitMarketplace = () => {
} }
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]); }, [router.isReady, searchText, selectedTagIds, updateUrlParams]);
const handleSourceSelect = useCallback(
(nextSource?: MarketplaceSourceFilterValue) => {
setSelectedSource(nextSource);
updateUrlParams(searchText, selectedTagIds, detailQueryRef.current, nextSource);
},
[searchText, selectedTagIds, updateUrlParams]
);
const { const {
data: tools, data: tools,
isLoading: loadingTools, isLoading: loadingTools,
...@@ -177,12 +215,13 @@ const ToolkitMarketplace = () => { ...@@ -177,12 +215,13 @@ const ToolkitMarketplace = () => {
pageNum, pageNum,
pageSize, pageSize,
searchKey: searchText || undefined, searchKey: searchText || undefined,
tags: selectedTagIds.length > 0 ? selectedTagIds : undefined tags: selectedTagIds.length > 0 ? selectedTagIds : undefined,
source: selectedSource
}), }),
{ {
type: 'scroll', type: 'scroll',
throttleWait: 500, throttleWait: 500,
refreshDeps: [searchText, selectedTagIds] refreshDeps: [searchText, selectedTagIds, selectedSource]
} }
); );
...@@ -205,16 +244,18 @@ const ToolkitMarketplace = () => { ...@@ -205,16 +244,18 @@ const ToolkitMarketplace = () => {
return parseI18nString(currentTag?.tagName || '', i18n.language) || ''; return parseI18nString(currentTag?.tagName || '', i18n.language) || '';
}), }),
version: tool.version, version: tool.version,
source: tool.source,
downloadCount: tool.downloadCount downloadCount: tool.downloadCount
}; };
}); });
}, [tools, i18n.language, toolTags]); }, [tools, i18n.language, toolTags]);
const selectedTagKey = selectedTagIds.join(','); const selectedTagKey = selectedTagIds.join(',');
const selectedSourceKey = selectedSource ?? 'all';
const { gridRef, renderVirtualGridItems } = useVirtualGridList({ const { gridRef, renderVirtualGridItems } = useVirtualGridList({
list: displayTools, list: displayTools,
listKey: `${searchText}-${selectedTagKey}-${i18n.language}`, listKey: `${searchText}-${selectedTagKey}-${selectedSourceKey}-${i18n.language}`,
estimatedRowHeight: 160, estimatedRowHeight: 178,
estimatedRowGap: 20 estimatedRowGap: 20
}); });
...@@ -258,7 +299,7 @@ const ToolkitMarketplace = () => { ...@@ -258,7 +299,7 @@ const ToolkitMarketplace = () => {
if (router.isReady) { if (router.isReady) {
updateUrlParams(searchText, selectedTagIds); updateUrlParams(searchText, selectedTagIds);
} }
}, [router.isReady, searchText, selectedTagIds, updateUrlParams]); }, [router.isReady, searchText, selectedTagIds, selectedSource, updateUrlParams]);
const onDownload = useCallback(async (toolId: string, version?: string) => { const onDownload = useCallback(async (toolId: string, version?: string) => {
try { try {
...@@ -318,6 +359,7 @@ const ToolkitMarketplace = () => { ...@@ -318,6 +359,7 @@ const ToolkitMarketplace = () => {
<ToolCard <ToolCard
item={tool} item={tool}
mode="marketplace" mode="marketplace"
variant="marketplace"
onInstall={() => onDownload(tool.id)} onInstall={() => onDownload(tool.id)}
onClickCard={() => handleSelectTool(tool)} onClickCard={() => handleSelectTool(tool)}
/> />
...@@ -372,10 +414,7 @@ const ToolkitMarketplace = () => { ...@@ -372,10 +414,7 @@ const ToolkitMarketplace = () => {
<I18nLngSelector /> <I18nLngSelector />
<Button <Button
onClick={() => { onClick={() => {
window.open( window.open('https://doc.fastgpt.cn/plugin/system-tool-development', '_blank');
'https://doc.fastgpt.cn/plugin/system-tool-development',
'_blank'
);
}} }}
> >
{t('app:toolkit_contribute_resource')} {t('app:toolkit_contribute_resource')}
...@@ -490,6 +529,9 @@ const ToolkitMarketplace = () => { ...@@ -490,6 +529,9 @@ const ToolkitMarketplace = () => {
tags={toolTags} tags={toolTags}
selectedTagIds={selectedTagIds} selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds} onTagSelect={setSelectedTagIds}
selectedSource={selectedSource}
onSourceSelect={handleSourceSelect}
variant="marketplace"
/> />
</Box> </Box>
</Flex> </Flex>
...@@ -547,7 +589,8 @@ const ToolkitMarketplace = () => { ...@@ -547,7 +589,8 @@ const ToolkitMarketplace = () => {
fontSize="sm" fontSize="sm"
bg={'white'} bg={'white'}
pl={8} pl={8}
w={'560px'} w={['calc(100vw - 64px)', '560px']}
maxW={'560px'}
h={12} h={12}
borderRadius={'10px'} borderRadius={'10px'}
placeholder={t('app:toolkit_marketplace_search_placeholder')} placeholder={t('app:toolkit_marketplace_search_placeholder')}
...@@ -574,13 +617,16 @@ const ToolkitMarketplace = () => { ...@@ -574,13 +617,16 @@ const ToolkitMarketplace = () => {
tags={toolTags} tags={toolTags}
selectedTagIds={selectedTagIds} selectedTagIds={selectedTagIds}
onTagSelect={setSelectedTagIds} onTagSelect={setSelectedTagIds}
selectedSource={selectedSource}
onSourceSelect={handleSourceSelect}
variant="marketplace"
/> />
</Box> </Box>
</Flex> </Flex>
{displayTools.length > 0 ? ( {displayTools.length > 0 ? (
<Grid <Grid
ref={gridRef} ref={gridRef}
gridTemplateColumns={['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']} gridTemplateColumns={'repeat(auto-fill, minmax(min(260px, 100%), 1fr))'}
gridGap={5} gridGap={5}
alignItems={'stretch'} alignItems={'stretch'}
> >
......
import { marketplaceEnv } from '@/env'; import { marketplaceEnv } from '@/env';
import {
MarketplaceCommunitySource,
MarketplaceOfficialSource
} from '@fastgpt/global/openapi/core/plugin/marketplace/api';
export const AUTH_TOKEN = marketplaceEnv.AUTH_TOKEN; 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 & { ...@@ -10,6 +10,7 @@ export type MarketplaceToolListDataItem = ToolDetailType & {
downloadUrl?: string; downloadUrl?: string;
readme?: string; readme?: string;
parentId?: string; parentId?: string;
source?: string;
}; };
declare global { declare global {
......
...@@ -6,6 +6,7 @@ export type MarketplaceDetailQuery = { ...@@ -6,6 +6,7 @@ export type MarketplaceDetailQuery = {
export type MarketplacePageQuery = MarketplaceDetailQuery & { export type MarketplacePageQuery = MarketplaceDetailQuery & {
search?: string; search?: string;
tags?: string[]; tags?: string[];
source?: string;
}; };
export type NextQueryValue = string | string[] | undefined; export type NextQueryValue = string | string[] | undefined;
...@@ -20,6 +21,7 @@ export const getSingleQueryValue = (value: NextQueryValue) => { ...@@ -20,6 +21,7 @@ export const getSingleQueryValue = (value: NextQueryValue) => {
export const buildMarketplaceQueryString = ({ export const buildMarketplaceQueryString = ({
search, search,
tags, tags,
source,
pluginId, pluginId,
version version
}: MarketplacePageQuery) => { }: MarketplacePageQuery) => {
...@@ -31,6 +33,9 @@ export const buildMarketplaceQueryString = ({ ...@@ -31,6 +33,9 @@ export const buildMarketplaceQueryString = ({
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
params.push(['tags', tags.join(',')]); params.push(['tags', tags.join(',')]);
} }
if (source) {
params.push(['source', source]);
}
if (pluginId) { if (pluginId) {
params.push(['pluginId', pluginId]); params.push(['pluginId', pluginId]);
} }
......
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
const originalSyncIndex = process.env.SYNC_INDEX; const originalSyncIndex = process.env.SYNC_INDEX;
const originalCommunityAuthToken = process.env.COMMUNITY_AUTH_TOKEN;
const importEnv = async () => { const importEnv = async () => {
vi.resetModules(); vi.resetModules();
...@@ -10,6 +11,7 @@ const importEnv = async () => { ...@@ -10,6 +11,7 @@ const importEnv = async () => {
describe('marketplace env', () => { describe('marketplace env', () => {
afterEach(() => { afterEach(() => {
vi.stubEnv('SYNC_INDEX', originalSyncIndex); vi.stubEnv('SYNC_INDEX', originalSyncIndex);
vi.stubEnv('COMMUNITY_AUTH_TOKEN', originalCommunityAuthToken);
}); });
it('defaults SYNC_INDEX to true when it is not configured', async () => { it('defaults SYNC_INDEX to true when it is not configured', async () => {
...@@ -35,4 +37,12 @@ describe('marketplace env', () => { ...@@ -35,4 +37,12 @@ describe('marketplace env', () => {
expect(marketplaceEnv.SYNC_INDEX).toBe(false); 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(() => ({ ...@@ -6,7 +6,10 @@ const deleteMocks = vi.hoisted(() => ({
vi.mock('../../../../../src/service/tool/delete', () => deleteMocks); vi.mock('../../../../../src/service/tool/delete', () => deleteMocks);
vi.mock('../../../../../src/service/auth', () => ({ 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 = () => { const createResponse = () => {
...@@ -127,6 +130,28 @@ describe('/api/admin/pkg/delete', () => { ...@@ -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 () => { it('rejects unsupported methods', async () => {
const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/delete'); const { default: handler } = await import('../../../../../src/pages/api/admin/pkg/delete');
const res = createResponse(); 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', () => { ...@@ -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({ ...@@ -30,6 +30,7 @@ export default defineConfig({
include: [ include: [
'src/env.ts', 'src/env.ts',
'src/web/api.ts', 'src/web/api.ts',
'src/pages/api/admin/pkg/upload.ts',
'src/pages/api/admin/pkg/delete.ts', 'src/pages/api/admin/pkg/delete.ts',
'src/pages/api/tool/getDownloadUrl.ts', 'src/pages/api/tool/getDownloadUrl.ts',
'src/service/plugin/repo.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