Commit 9c8450da by Finley Ge Committed by GitHub

feat(tool-marketplace): support partial plugin update retries (#7444)

* feat(plugin): support partial batch update retries

* fix: table header font size

* refactor(upload): centralize extension normalization helpers

* feat(marketplace): align batch update tool details

* fix(tool-marketplace): handle plugin update failures and refine status

* fix(i18n): rename ai managed label

* fix(i18n): rename generated input labels

* fix(marketplace): track installed plugin version in detail

* fix(marketplace): make version install action exclusive

* fix(marketplace): sync selected installed version

* fix(marketplace): persist installed version in tool state

* fix(marketplace): persist installed version status

* fix(marketplace): truncate long tool card titles

* fix(marketplace): preserve installed state after tool updates

* fix(marketplace): surface install errors and defer success toast
parent 5c1d1bd2
......@@ -4,7 +4,7 @@ import m from 'multer';
import type { NodeHttpRequest } from '../../types/http';
import path from 'path';
import fs from 'node:fs';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/uploadConstraints';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/extension';
type MulterFileFilterOptions = {
allowedExtensions?: string[];
......
......@@ -11,6 +11,16 @@ import {
resolveMimeType
} from '../utils/mime';
import type { UploadExtensionRule, UploadPolicy } from './type';
import {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
export {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectFile',
......@@ -70,25 +80,6 @@ export const officeZipFormats = [
* 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单
* 在预签、上传校验和 metadata 修正阶段出现不同表示。
*/
export const normalizeFileExtension = (extension?: string) => {
if (!extension) return '';
const trimmedExtension = extension.trim().toLowerCase();
if (!trimmedExtension) return '';
return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`;
};
export const normalizeAllowedExtensions = (extensions?: string[]) => {
if (!extensions?.length) return [];
return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))];
};
export const parseAllowedExtensions = (value: string) => {
return normalizeAllowedExtensions(value.split(','));
};
export const decodeFileName = (filename?: string) => {
if (!filename) return '';
try {
......
/**
* 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单
* 在预签、上传校验和 metadata 修正阶段出现不同表示。
*/
export const normalizeFileExtension = (extension?: string) => {
if (!extension) return '';
const trimmedExtension = extension.trim().toLowerCase();
if (!trimmedExtension) return '';
return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`;
};
export const normalizeAllowedExtensions = (extensions?: string[]) => {
if (!extensions?.length) return [];
return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))];
};
export const parseAllowedExtensions = (value: string) => {
return normalizeAllowedExtensions(value.split(','));
};
......@@ -4,13 +4,13 @@ import {
type FileExtensionKeyType
} from '@fastgpt/global/core/app/constants';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema';
import type { UploadConstraintsInput } from '../contracts/type';
import type { UploadConstraintsInput, UploadConstraints } from '../contracts/type';
import { createUploadExtensionRulesFromFileSelectConfig } from '../uploadPolicy/utils';
import {
createUploadExtensionRulesFromFileSelectConfig,
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../uploadPolicy/utils';
} from './extension';
import { createUploadPolicy } from '../uploadPolicy/service';
import type { UploadPolicy } from '../uploadPolicy/type';
......@@ -22,7 +22,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectCustomFileExtension'
];
export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions };
export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions } from './extension';
export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']);
export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType);
......
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import {
Box,
Button,
......@@ -9,50 +9,72 @@ import {
DrawerHeader,
DrawerOverlay,
Flex,
VStack,
Accordion
Grid,
Spinner,
VStack
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import Avatar from '../../../common/Avatar';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyIconButton from '../../../common/Icon/button';
import LightRowTabs from '../../../common/Tabs/LightRowTabs';
import { type ToolCardItemType } from './ToolCard';
import MyBox from '../../../common/MyBox';
import MyIcon from '../../../common/Icon';
import MyTooltip from '../../../common/MyTooltip';
import MyMenu from '../../../common/MyMenu';
import { useTableMultipleSelect } from '../../../../hooks/useTableMultipleSelect';
import { useRequest } from '../../../../hooks/useRequest';
import { useToast } from '../../../../hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import {
ParamSection,
ReadmeBox,
SubToolAccordionItem,
ToolDetailBody,
useToolDetail,
drawerScrollbarStyles,
type ToolDetailFetchResponse
type ToolDetailFetchResponse,
type ToolDetailVersionType
} from './ToolDetail';
type ViewMode = 'list' | 'detail';
interface BatchUpdateDrawerProps {
export type BatchUpdateFailure = {
toolId: string;
reason: string;
};
type BatchUpdateDrawerProps = {
isOpen: boolean;
onClose: () => void;
updatableTools: ToolCardItemType[];
onBatchUpdate: (toolIds: string[]) => Promise<void>;
onBatchUpdate: (toolIds: string[]) => Promise<BatchUpdateFailure[]>;
onUpdate: (tool: ToolCardItemType, version?: string) => Promise<void>;
onDelete: (tool: ToolCardItemType) => void | Promise<void>;
isBatchUpdating: boolean;
onFetchDetail?: (toolId: string) => Promise<ToolDetailFetchResponse>;
}
singleUpdatingToolIds?: ReadonlySet<string>;
deletingToolIds?: ReadonlySet<string>;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
};
const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
isOpen,
onClose,
updatableTools,
onBatchUpdate,
onUpdate,
onDelete,
isBatchUpdating,
onFetchDetail
singleUpdatingToolIds,
deletingToolIds,
onFetchDetail,
onFetchVersions
}) => {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedToolForDetail, setSelectedToolForDetail] = useState<ToolCardItemType | null>(null);
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const [selectedVersion, setSelectedVersion] = useState<string>();
const [isUpdatingSingle, setIsUpdatingSingle] = useState(false);
const [failureReasons, setFailureReasons] = useState<Record<string, string>>({});
const [updatingToolIds, setUpdatingToolIds] = useState<Set<string>>(new Set());
// Use table multiple select hook
const {
......@@ -68,10 +90,46 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
getItemId: (tool: ToolCardItemType) => tool.id
});
const {
data: toolVersionsResult,
loading: loadingVersions,
run: fetchToolVersions
} = useRequest(
async (toolId: string) => {
if (!onFetchVersions) return { toolId, versions: [] };
return { toolId, versions: await onFetchVersions(toolId) };
},
{
manual: true,
errorToast: ''
}
);
useEffect(() => {
if (!selectedToolForDetail?.id || !onFetchVersions) return;
setSelectedVersion(undefined);
fetchToolVersions(selectedToolForDetail.id);
}, [fetchToolVersions, onFetchVersions, selectedToolForDetail?.id]);
const toolVersions =
toolVersionsResult?.toolId === selectedToolForDetail?.id
? (toolVersionsResult?.versions ?? [])
: [];
const activeVersion =
selectedVersion ?? toolVersions[0]?.version ?? selectedToolForDetail?.version;
const isSelectedToolUpdating = selectedToolForDetail
? (singleUpdatingToolIds?.has(selectedToolForDetail.id) ?? false)
: false;
const isSelectedToolDeleting = selectedToolForDetail
? (deletingToolIds?.has(selectedToolForDetail.id) ?? false)
: false;
// Use tool detail hook
const { parentTool, isToolSet, subTools, readmeContent, loadingDetail } = useToolDetail({
toolId: selectedToolForDetail?.id,
tags: selectedToolForDetail?.tags || undefined,
version: activeVersion,
tags: selectedToolForDetail?.tags ?? undefined,
onFetchDetail,
autoFetch: viewMode === 'detail'
});
......@@ -79,12 +137,15 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
const handleClose = useCallback(() => {
setViewMode('list');
setSelectedToolForDetail(null);
setActiveTab('params');
setSelectedVersion(undefined);
setSelectedItems([]);
setFailureReasons({});
setUpdatingToolIds(new Set());
onClose();
}, [onClose, setSelectedItems]);
const handleViewDetail = useCallback((tool: ToolCardItemType) => {
setSelectedVersion(undefined);
setSelectedToolForDetail(tool);
setViewMode('detail');
}, []);
......@@ -92,21 +153,93 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
const handleBack = useCallback(() => {
setViewMode('list');
setSelectedToolForDetail(null);
setActiveTab('params');
setSelectedVersion(undefined);
}, []);
useEffect(() => {
if (
viewMode === 'detail' &&
selectedToolForDetail &&
!updatableTools.some((tool) => tool.id === selectedToolForDetail.id)
) {
handleBack();
}
}, [handleBack, selectedToolForDetail, updatableTools, viewMode]);
/** 执行选中插件更新,并把插件级失败原因留在当前列表中供用户重试。 */
const handleUpdateTools = useCallback(
async (toolIds: string[]) => {
if (toolIds.length === 0) return [];
setUpdatingToolIds((previous) => new Set([...previous, ...toolIds]));
setFailureReasons((previous) => {
const next = { ...previous };
toolIds.forEach((toolId) => delete next[toolId]);
return next;
});
try {
const failures = await onBatchUpdate(toolIds);
const failedToolIds = new Set(failures.map((failure) => failure.toolId));
setFailureReasons((previous) => ({
...previous,
...Object.fromEntries(failures.map((failure) => [failure.toolId, failure.reason]))
}));
setSelectedItems((previous) =>
previous.filter((tool) => !toolIds.includes(tool.id) || failedToolIds.has(tool.id))
);
return failures;
} catch (error) {
const reason = getErrText(error, t('app:toolkit_update_failed'));
const failures = toolIds.map((toolId) => ({ toolId, reason }));
setFailureReasons((previous) => ({
...previous,
...Object.fromEntries(failures.map((failure) => [failure.toolId, failure.reason]))
}));
return failures;
} finally {
setUpdatingToolIds((previous) => {
const next = new Set(previous);
toolIds.forEach((toolId) => next.delete(toolId));
return next;
});
}
},
[onBatchUpdate, setSelectedItems, t]
);
const handleUpdateSingle = useCallback(async () => {
if (!selectedToolForDetail) return;
setIsUpdatingSingle(true);
try {
await onBatchUpdate([selectedToolForDetail.id]);
// Go back to list view after successful update
handleBack();
await onUpdate(selectedToolForDetail, activeVersion);
setFailureReasons((previous) => {
const next = { ...previous };
delete next[selectedToolForDetail.id];
return next;
});
toast({
title: t('common:update_success'),
status: 'success'
});
} catch (error) {
const reason = getErrText(error, t('app:toolkit_update_failed'));
setFailureReasons((previous) => ({
...previous,
[selectedToolForDetail.id]: reason
}));
toast({
title: reason,
status: 'error'
});
} finally {
setIsUpdatingSingle(false);
}
}, [selectedToolForDetail, onBatchUpdate, handleBack]);
}, [activeVersion, onUpdate, selectedToolForDetail, t, toast]);
return (
<Drawer isOpen={isOpen} onClose={handleClose} placement="right">
......@@ -116,18 +249,55 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
{viewMode === 'list' ? (
<Flex gap={1.5} alignItems="center">
<Box fontSize={'16px'} fontWeight={500} color={'myGray.900'}>
{t('app:toolkit_updatable_plugins')}
{t('app:toolkit_plugin_update')}
</Box>
<Box flex={1} />
<MyIconButton icon={'common/closeLight'} onClick={handleClose} />
</Flex>
) : (
<Flex gap={1.5}>
<Avatar src={parentTool?.icon || ''} borderRadius={'md'} w={6} />
<Avatar src={parentTool?.icon ?? ''} borderRadius={'md'} w={6} />
<Box fontSize={'16px'} fontWeight={500} color={'myGray.900'}>
{parseI18nString(parentTool?.name || '', i18n.language)}
{parseI18nString(parentTool?.name ?? '', i18n.language)}
</Box>
<Box flex={1} />
{toolVersions.length > 0 && (
<MyMenu
trigger="hover"
placement="bottom-end"
Button={
<Flex
alignItems="center"
gap={1}
px={2}
h={7}
border="1px solid"
borderColor="myGray.200"
borderRadius="md"
cursor="pointer"
color="myGray.700"
>
<Box fontSize="12px">{activeVersion ?? t('common:Version')}</Box>
<MyIcon name="core/chat/chevronDown" w={4} />
</Flex>
}
menuList={[
{
children: toolVersions.map((item) => ({
label: item.version,
description: item.versionDescription,
isActive: item.version === activeVersion,
onClick: () => setSelectedVersion(item.version)
}))
}
]}
/>
)}
{loadingVersions && activeVersion && (
<Box fontSize="12px" color="myGray.500">
{activeVersion}
</Box>
)}
<MyIconButton icon={'common/backFill'} onClick={handleBack} />
</Flex>
)}
......@@ -136,60 +306,130 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
<DrawerBody position="relative" sx={drawerScrollbarStyles}>
{viewMode === 'list' ? (
<VStack align="stretch" spacing={0} pb={20}>
{updatableTools.map((tool) => (
<Flex
key={tool.id}
align="center"
p={3}
borderRadius="md"
borderBottom={'1px solid'}
borderColor={'myGray.200'}
_hover={{ bg: 'myGray.50' }}
cursor="pointer"
onClick={() => toggleSelect(tool)}
>
<Flex onClick={(e) => e.stopPropagation()} mr={3} align="center">
<Checkbox isChecked={isSelected(tool)} onChange={() => toggleSelect(tool)} />
</Flex>
<Avatar src={tool.icon} w={6} h={6} borderRadius="md" mr={3} flexShrink={0} />
<Box flex={1}>
<Box fontSize="sm" fontWeight="medium" color="myGray.900">
{parseI18nString(tool.name, i18n.language)}
</Box>
</Box>
<Box
as="button"
fontSize="sm"
color="primary.600"
_hover={{ textDecoration: 'underline' }}
onClick={(e: any) => {
e.stopPropagation();
handleViewDetail(tool);
}}
<Grid
gridTemplateColumns="48px minmax(0, 1fr) 108px"
h={10}
alignItems="center"
bg="myGray.100"
borderRadius="md"
color="myGray.600"
fontSize="12px"
>
<Box px={3}>{t('app:toolkit_select')}</Box>
<Box px={3}>{t('common:Name')}</Box>
<Box px={3}>{t('common:Status')}</Box>
</Grid>
{updatableTools.map((tool) => {
const failureReason = failureReasons[tool.id];
const isUpdating = updatingToolIds.has(tool.id);
return (
<Grid
key={tool.id}
gridTemplateColumns="48px minmax(0, 1fr) 108px"
minH={12}
alignItems="center"
borderBottom="1px solid"
borderColor="myGray.200"
>
{t('common:view_detail')}
</Box>
</Flex>
))}
<Flex px={3} align="center">
<Checkbox
size="sm"
isChecked={isSelected(tool)}
isDisabled={isUpdating}
onChange={() => toggleSelect(tool)}
/>
</Flex>
<Flex
as="button"
role="group"
minW={0}
px={3}
align="center"
gap={2}
textAlign="left"
cursor="pointer"
onClick={() => handleViewDetail(tool)}
>
<Avatar src={tool.icon} w={5} h={5} borderRadius="md" flexShrink={0} />
<Box
minW={0}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
fontSize="sm"
fontWeight="medium"
color="myGray.900"
_groupHover={{
color: 'primary.600',
textDecoration: 'underline'
}}
>
{parseI18nString(tool.name, i18n.language)}
</Box>
</Flex>
<Flex px={3} align="center" gap={1} minW={0}>
{failureReason ? (
<>
<Box color="red.600" fontSize="xs" lineHeight="16px" whiteSpace="nowrap">
{t('app:toolkit_update_failed')}
</Box>
<MyTooltip label={failureReason} maxW="240px" shouldWrapChildren={false}>
<Box w={3} h={3} display="flex" alignItems="center">
<MyIcon name="infoRounded" w={3} h={3} color="red.600" />
</Box>
</MyTooltip>
<MyIconButton
icon="common/refreshLight"
size="12px"
w="16px"
h="16px"
minW="16px"
p={0}
isLoading={isUpdating}
tip={t('app:toolkit_retry_update')}
aria-label={t('app:toolkit_retry_update')}
onClick={() => handleUpdateTools([tool.id])}
/>
</>
) : (
<Flex align="center" gap={1} color="myGray.600" fontSize="xs">
{isUpdating ? (
<Spinner size="xs" color="primary.500" />
) : (
<Box whiteSpace="nowrap">{t('app:toolkit_update_pending')}</Box>
)}
</Flex>
)}
</Flex>
</Grid>
);
})}
{/* Bottom action bar - Always visible */}
<Flex
position="fixed"
bottom={0}
left={0}
right={0}
w={'480px'}
px={8}
py={2}
w="min(480px, 100vw)"
px={6}
py={3}
alignItems="center"
justifyContent="space-between"
borderLeftRadius="md"
backgroundColor="white"
borderTop="1px solid"
borderColor="myGray.100"
>
<Flex alignItems="center" gap={2}>
<Checkbox size="sm" isChecked={isSelecteAll} onChange={selectAllTrigger} ml={1} />
<Box fontSize="sm" color="gray.600">
{t('common:select_count_num', { num: selectedItems.length })}
<Checkbox
size="sm"
isChecked={isSelecteAll}
isDisabled={isBatchUpdating}
onChange={selectAllTrigger}
/>
<Box fontSize="sm" color="myGray.700">
{t('common:Select_all')}
</Box>
</Flex>
<Button
......@@ -197,140 +437,44 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
isLoading={isBatchUpdating}
isDisabled={!hasSelections}
onClick={() =>
onBatchUpdate(selectedItems.map((tool: ToolCardItemType) => tool.id))
handleUpdateTools(selectedItems.map((tool: ToolCardItemType) => tool.id))
}
>
{t('app:toolkit_batch_update')}
{t('app:toolkit_update_selected', { count: selectedItems.length })}
</Button>
</Flex>
</VStack>
) : (
<MyBox>
<Flex gap={2} flexWrap="wrap">
{parentTool?.tags?.map((tag: string) => (
<Box
key={tag}
px={2}
py={1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
fontSize={'10px'}
fontWeight={'medium'}
color={'myGray.700'}
<ToolDetailBody
parentTool={parentTool}
isToolSet={isToolSet}
subTools={subTools}
readmeContent={readmeContent}
actions={
<Flex gap={2}>
<Button
flex="1 1 0"
minW={0}
variant="primary"
isLoading={isUpdatingSingle || isSelectedToolUpdating || loadingDetail}
isDisabled={isSelectedToolDeleting}
onClick={handleUpdateSingle}
>
{tag}
</Box>
))}
</Flex>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{parseI18nString(parentTool?.description || '', i18n.language)}
</Box>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${parentTool?.author || 'FastGPT'}`}
</Box>
<Flex mt={3} gap={2}>
<Button
flex={1}
variant="primary"
isLoading={isUpdatingSingle || loadingDetail}
onClick={handleUpdateSingle}
>
{t('app:custom_plugin_update')}
</Button>
</Flex>
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_activation_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{parentTool?.hasSystemSecret ||
(parentTool?.secretInputConfig && parentTool?.secretInputConfig.length > 0) ||
(parentTool?.inputList && parentTool?.inputList.length > 0)
? t('app:toolkit_activation_required')
: t('app:toolkit_activation_not_required')}
</Box>
</Flex>
<Box mt={4}>
<LightRowTabs
list={[
{
label: isToolSet
? t('app:toolkit_tool_list')
: t('app:toolkit_params_description'),
value: 'params'
},
...(parentTool?.courseUrl || parentTool?.readme || parentTool?.userGuide
? [{ label: t('app:toolkit_user_guide'), value: 'guide' }]
: [])
]}
value={activeTab}
onChange={(value) => {
if (
value === 'guide' &&
parentTool?.courseUrl &&
!parentTool?.readme &&
!parentTool?.userGuide
) {
window.open(parentTool?.courseUrl, '_blank');
} else {
setActiveTab(value as 'guide' | 'params');
}
}}
gap={4}
/>
<Box h={'1px'} w={'full'} bg={'myGray.200'} mt={'-5px'} mx={1} />
</Box>
<Box mt={4}>
{activeTab === 'guide' && (
<VStack align="stretch" spacing={4} flex="1" minH="0">
{(parentTool?.readme || readmeContent || parentTool?.userGuide) && (
<ReadmeBox
source={readmeContent || parentTool?.userGuide || ''}
courseUrl={parentTool?.courseUrl}
/>
)}
</VStack>
)}
{activeTab === 'params' && (
<VStack align="stretch" spacing={4}>
{isToolSet && subTools.length > 0 && (
<Accordion
allowMultiple
{...(subTools.length === 1 ? { defaultIndex: [0] } : {})}
>
{subTools.map((subTool) => (
<SubToolAccordionItem key={subTool.toolId} tool={subTool} />
))}
</Accordion>
)}
{!isToolSet && (
<>
{parentTool?.versionList?.[0]?.inputs &&
parentTool?.versionList?.[0]?.inputs.length > 0 && (
<ParamSection
title={t('app:toolkit_inputs')}
params={parentTool?.versionList?.[0]?.inputs}
/>
)}
{parentTool?.versionList?.[0]?.outputs &&
parentTool?.versionList?.[0]?.outputs.length > 0 && (
<ParamSection
title={t('app:toolkit_outputs')}
params={parentTool?.versionList?.[0]?.outputs}
/>
)}
</>
)}
</VStack>
)}
</Box>
</MyBox>
{t('app:custom_plugin_update')}
</Button>
<Button
flex="0 0 62px"
minW={0}
variant="dangerOutline"
isLoading={isSelectedToolDeleting}
isDisabled={isUpdatingSingle || isSelectedToolUpdating}
onClick={() => selectedToolForDetail && onDelete(selectedToolForDetail)}
>
{t('app:toolkit_uninstall')}
</Button>
</Flex>
}
/>
)}
</DrawerBody>
</DrawerContent>
......
......@@ -72,12 +72,7 @@ const ToolCard = ({
isMarketplaceVariant && mode === 'admin' && item.installed && !showActionButton;
useEffect(() => {
if (displayTags.length === 0) {
setVisibleTagsCount(0);
return;
}
setVisibleTagsCount(displayTags.length);
if (displayTags.length === 0) return;
const calculate = () => {
const container = tagsContainerRef.current;
......@@ -204,42 +199,6 @@ const ToolCard = ({
}
}}
>
{/* Update badge in top-right corner */}
{item.update && mode === 'admin' && !item.isDebug && (
<Flex
alignItems="center"
position={'absolute'}
top={isMarketplaceVariant ? '17px' : 4}
right={isMarketplaceVariant ? '17px' : 4}
px={2}
py={0.5}
bg={'rgb(255, 247, 237)'}
color={'rgba(234,88,12,1)'}
fontSize={'12px'}
fontWeight={'medium'}
borderRadius={'0.5rem'}
borderColor={'rgba(255,237,213,1)'}
borderWidth={'1px'}
zIndex={1}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path>
</svg>
{t('app:app.modules.has new version')}
</Flex>
)}
<HStack
minW={0}
spacing={isMarketplaceVariant ? 2 : undefined}
......@@ -250,6 +209,7 @@ const ToolCard = ({
borderRadius={'sm'}
w={isMarketplaceVariant ? '24px' : '1.5rem'}
h={isMarketplaceVariant ? '24px' : undefined}
flexShrink={0}
/>
<Box
color={isMarketplaceVariant ? '#111824' : 'myGray.900'}
......@@ -257,7 +217,7 @@ const ToolCard = ({
lineHeight={isMarketplaceVariant ? '24px' : undefined}
fontWeight={'medium'}
minW={0}
flexShrink={1}
flex={1}
className={'textEllipsis'}
>
{parseI18nString(item.name, i18n.language)}
......@@ -279,6 +239,37 @@ const ToolCard = ({
</Box>
)}
{item.isDebug && <DebugToolTag />}
{item.update && mode === 'admin' && !item.isDebug && (
<Flex
alignItems="center"
flexShrink={0}
px={2}
py={0.5}
bg={'rgb(255, 247, 237)'}
color={'rgba(234,88,12,1)'}
fontSize={'12px'}
fontWeight={'medium'}
borderRadius={'0.5rem'}
borderColor={'rgba(255,237,213,1)'}
borderWidth={'1px'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path>
</svg>
{t('app:app.modules.has new version')}
</Flex>
)}
{statusLabel && (
<Flex
flexShrink={0}
......
import React, { useState, type ReactNode } from 'react';
import { Accordion, Box, Flex, VStack } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyBox from '../../../../common/MyBox';
import LightRowTabs from '../../../../common/Tabs/LightRowTabs';
import { ParamSection, SubToolAccordionItem } from './components';
import ReadmeBox from './ReadmeBox';
import type { ToolDetailExtendedType } from './types';
type ToolDetailBodyProps = {
parentTool?: Partial<ToolDetailExtendedType>;
isToolSet: boolean;
subTools: ToolDetailExtendedType[];
readmeContent: string;
actions?: ReactNode;
showPoint?: boolean;
systemTitle?: string;
};
/**
* 渲染工具详情 Drawer 的共享正文,操作区由调用方注入以适配不同业务入口。
*/
const ToolDetailBody = ({
parentTool,
isToolSet,
subTools,
readmeContent,
actions,
showPoint = false,
systemTitle
}: ToolDetailBodyProps) => {
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const author = [parentTool?.author, systemTitle].find((value) => value?.trim()) ?? 'FastGPT';
const readmeSource = [readmeContent, parentTool?.userGuide].find((value) => value?.trim()) ?? '';
return (
<MyBox>
<Flex gap={2} flexWrap="wrap">
{parentTool?.tags?.map((tag) => (
<Box
key={tag}
px={2}
py={1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
fontSize={'10px'}
fontWeight={'medium'}
color={'myGray.700'}
>
{tag}
</Box>
))}
</Flex>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{parseI18nString(parentTool?.description ?? '', i18n.language)}
</Box>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${author}`}
</Box>
{actions && <Box mt={3}>{actions}</Box>}
{showPoint && (
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_call_points_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{!!parentTool?.currentCost ? parentTool.currentCost : t('app:toolkit_no_call_points')}
</Box>
</Flex>
)}
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_activation_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{parentTool?.hasSystemSecret ||
(parentTool?.secrets && parentTool.secrets.length > 0) ||
(parentTool?.secretInputConfig && parentTool.secretInputConfig.length > 0) ||
(parentTool?.inputList && parentTool.inputList.length > 0)
? t('app:toolkit_activation_required')
: t('app:toolkit_activation_not_required')}
</Box>
</Flex>
<Box mt={4}>
<LightRowTabs
list={[
{
label: isToolSet ? t('app:toolkit_tool_list') : t('app:toolkit_params_description'),
value: 'params'
},
...(parentTool?.courseUrl || parentTool?.readme || parentTool?.userGuide
? [{ label: t('app:toolkit_user_guide'), value: 'guide' }]
: [])
]}
value={activeTab}
onChange={(value) => {
if (
value === 'guide' &&
parentTool?.courseUrl &&
!parentTool?.readme &&
!parentTool?.userGuide
) {
window.open(parentTool.courseUrl, '_blank');
} else {
setActiveTab(value as 'guide' | 'params');
}
}}
gap={4}
/>
<Box h={'1px'} w={'full'} bg={'myGray.200'} mt={'-5px'} mx={1} />
</Box>
<Box mt={4}>
{activeTab === 'guide' && (
<VStack align="stretch" spacing={4} flex="1" minH="0">
{(parentTool?.readme || readmeContent || parentTool?.userGuide) && (
<ReadmeBox source={readmeSource} courseUrl={parentTool?.courseUrl} />
)}
</VStack>
)}
{activeTab === 'params' && (
<VStack align="stretch" spacing={4}>
{isToolSet && subTools.length > 0 && (
<Accordion allowMultiple {...(subTools.length === 1 ? { defaultIndex: [0] } : {})}>
{subTools.map((subTool) => (
<SubToolAccordionItem key={subTool.pluginId} tool={subTool} />
))}
</Accordion>
)}
{!isToolSet && (
<>
{parentTool?.versionList?.[0]?.inputs &&
parentTool.versionList[0].inputs.length > 0 && (
<ParamSection
title={t('app:toolkit_inputs')}
params={parentTool.versionList[0].inputs}
/>
)}
{parentTool?.versionList?.[0]?.outputs &&
parentTool.versionList[0].outputs.length > 0 && (
<ParamSection
title={t('app:toolkit_outputs')}
params={parentTool.versionList[0].outputs}
/>
)}
</>
)}
</VStack>
)}
</Box>
</MyBox>
);
};
export default ToolDetailBody;
......@@ -3,3 +3,4 @@ export * from './components';
export * from './hooks';
export * from './styles';
export { default as ReadmeBox } from './ReadmeBox';
export { default as ToolDetailBody } from './ToolDetailBody';
......@@ -7,9 +7,7 @@ import {
DrawerContent,
DrawerHeader,
DrawerOverlay,
Flex,
VStack,
Accordion
Flex
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import Avatar from '../../../common/Avatar';
......@@ -17,19 +15,16 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyIconButton from '../../../common/Icon/button';
import MyIcon from '../../../common/Icon';
import MyMenu from '../../../common/MyMenu';
import LightRowTabs from '../../../common/Tabs/LightRowTabs';
import { type ToolCardItemType } from './ToolCard';
import MyBox from '../../../common/MyBox';
import { useRequest } from '../../../../hooks/useRequest';
import {
ParamSection,
ReadmeBox,
SubToolAccordionItem,
ToolDetailBody,
useToolDetail,
drawerScrollbarStyles,
type ToolDetailFetchResponse,
type ToolDetailVersionType
} from './ToolDetail';
import { isToolVersionInstalled } from './utils';
const ToolDetailDrawer = ({
onClose,
......@@ -41,10 +36,12 @@ const ToolDetailDrawer = ({
systemTitle,
onFetchDetail,
onFetchVersions,
onFetchInstalledVersions,
onVersionChange,
isLoading,
showPoint,
mode,
installedVersion,
showActionButton = true
}: {
onClose: () => void;
......@@ -56,23 +53,21 @@ const ToolDetailDrawer = ({
systemTitle?: string;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
onFetchInstalledVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
onVersionChange?: (version: string) => void;
isLoading?: boolean;
showPoint: boolean;
mode: 'admin' | 'team' | 'marketplace';
installedVersion?: string;
showActionButton?: boolean;
}) => {
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const isInstalled = selectedTool.installed;
const [selectedVersion, setSelectedVersion] = useState<string | undefined>(selectedTool.version);
const isDownload = useMemo(() => {
return mode === 'marketplace';
}, [mode]);
const showUninstallButton = mode === 'admin' && isInstalled && !!onDelete;
const showInstallButton = showActionButton && !showUninstallButton;
const hasUpdateButton = !!selectedTool.update && !!onUpdate && mode !== 'marketplace';
const {
data: toolVersions = [],
......@@ -95,6 +90,27 @@ const ToolDetailDrawer = ({
}
}, [fetchToolVersions, onFetchVersions, selectedTool.id]);
const {
data: installedToolVersions,
loading: loadingInstalledVersions,
runAsync: fetchInstalledToolVersions
} = useRequest(
async (toolId: string) => {
if (!onFetchInstalledVersions) return [];
return onFetchInstalledVersions(toolId);
},
{
manual: true,
errorToast: ''
}
);
useEffect(() => {
if (selectedTool.id && onFetchInstalledVersions) {
fetchInstalledToolVersions(selectedTool.id);
}
}, [fetchInstalledToolVersions, onFetchInstalledVersions, selectedTool.id]);
const activeVersion = selectedVersion || toolVersions[0]?.version;
// Use tool detail hook
......@@ -106,6 +122,21 @@ const ToolDetailDrawer = ({
});
const currentVersion = activeVersion || parentTool?.version || selectedTool.version;
const isCurrentVersionInstalled = isToolVersionInstalled({
isInstalled: !!isInstalled,
currentVersion,
installedVersions: installedToolVersions?.map((item) => item.version),
installedVersion
});
const isLatestVersionSelected = currentVersion === selectedTool.version;
const hasUpdateButton =
!!isInstalled &&
!!onUpdate &&
mode !== 'marketplace' &&
isLatestVersionSelected &&
(!!selectedTool.update || (!!installedVersion && installedVersion !== currentVersion));
const showInstallButton = showActionButton && !isCurrentVersionInstalled && !hasUpdateButton;
const showUninstallButton = mode === 'admin' && !!isInstalled && !!onDelete && !showInstallButton;
return (
<Drawer isOpen={true} onClose={onClose} placement="right">
......@@ -163,182 +194,68 @@ const ToolDetailDrawer = ({
</DrawerHeader>
<DrawerBody position="relative" sx={drawerScrollbarStyles}>
<MyBox>
<Flex gap={2} flexWrap="wrap">
{parentTool?.tags?.map((tag: string) => (
<Box
key={tag}
px={2}
py={1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
fontSize={'10px'}
fontWeight={'medium'}
color={'myGray.700'}
>
{tag}
</Box>
))}
</Flex>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{parseI18nString(parentTool?.description || '', i18n.language)}
</Box>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${parentTool?.author || systemTitle || 'FastGPT'}`}
</Box>
{(showInstallButton || showUninstallButton || hasUpdateButton) && (
<Flex mt={3} gap={2}>
{showInstallButton && (
<Button
flex={'1 1 0'}
minW={0}
variant={isInstalled ? 'primaryOutline' : 'primary'}
isLoading={isLoading || loadingDetail}
isDisabled={isUpdating}
onClick={async () => {
await onToggleInstall?.(!isInstalled, currentVersion);
}}
>
{isDownload
? t('common:Download')
: isInstalled
? t('app:toolkit_uninstall')
: t('app:toolkit_install')}
</Button>
)}
{hasUpdateButton && (
<Button
variant="primary"
flex={'1 1 0'}
minW={0}
isLoading={isUpdating || loadingDetail}
onClick={async () => {
await onUpdate?.(currentVersion);
}}
>
{t('app:custom_plugin_update')}
</Button>
)}
{showUninstallButton && (
<Button
flex={hasUpdateButton ? '0 0 62px' : '1 1 0'}
minW={0}
variant="dangerOutline"
isLoading={isLoading || loadingDetail}
onClick={() => onDelete?.()}
>
{t('app:toolkit_uninstall')}
</Button>
)}
</Flex>
)}
{showPoint && (
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_call_points_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{!!parentTool?.currentCost
? parentTool?.currentCost
: t('app:toolkit_no_call_points')}
</Box>
</Flex>
)}
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_activation_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{parentTool?.hasSystemSecret ||
(parentTool?.secrets && parentTool?.secrets.length > 0) ||
(parentTool?.secretInputConfig && parentTool?.secretInputConfig.length > 0) ||
(parentTool?.inputList && parentTool?.inputList.length > 0)
? t('app:toolkit_activation_required')
: t('app:toolkit_activation_not_required')}
</Box>
</Flex>
<Box mt={4}>
<LightRowTabs
list={[
{
label: isToolSet
? t('app:toolkit_tool_list')
: t('app:toolkit_params_description'),
value: 'params'
},
...(parentTool?.courseUrl || parentTool?.readme || parentTool?.userGuide
? [{ label: t('app:toolkit_user_guide'), value: 'guide' }]
: [])
]}
value={activeTab}
onChange={(value) => {
if (
value === 'guide' &&
parentTool?.courseUrl &&
!parentTool?.readme &&
!parentTool?.userGuide
) {
window.open(parentTool?.courseUrl, '_blank');
} else {
setActiveTab(value as 'guide' | 'params');
}
}}
gap={4}
/>
<Box h={'1px'} w={'full'} bg={'myGray.200'} mt={'-5px'} mx={1} />
</Box>
<Box mt={4}>
{activeTab === 'guide' && (
<VStack align="stretch" spacing={4} flex="1" minH="0">
{(parentTool?.readme || readmeContent || parentTool?.userGuide) && (
<ReadmeBox
source={readmeContent || parentTool?.userGuide || ''}
courseUrl={parentTool?.courseUrl}
/>
<ToolDetailBody
parentTool={parentTool}
isToolSet={isToolSet}
subTools={subTools}
readmeContent={readmeContent}
showPoint={showPoint}
systemTitle={systemTitle}
actions={
(showInstallButton || showUninstallButton || hasUpdateButton) && (
<Flex gap={2}>
{showInstallButton && (
<Button
flex={'1 1 0'}
minW={0}
variant={isCurrentVersionInstalled ? 'primaryOutline' : 'primary'}
isLoading={isLoading || loadingDetail || loadingInstalledVersions}
isDisabled={isUpdating}
onClick={async () => {
await onToggleInstall?.(!isCurrentVersionInstalled, currentVersion);
if (onFetchInstalledVersions) {
await fetchInstalledToolVersions(selectedTool.id);
}
}}
>
{isDownload
? t('common:Download')
: isCurrentVersionInstalled
? t('app:toolkit_uninstall')
: t('app:toolkit_install')}
</Button>
)}
</VStack>
)}
{activeTab === 'params' && (
<VStack align="stretch" spacing={4}>
{isToolSet && subTools.length > 0 && (
<Accordion
allowMultiple
{...(subTools.length === 1 ? { defaultIndex: [0] } : {})}
{hasUpdateButton && (
<Button
variant="primary"
flex={'1 1 0'}
minW={0}
isLoading={isUpdating || loadingDetail}
onClick={async () => {
await onUpdate?.(currentVersion);
if (onFetchInstalledVersions) {
await fetchInstalledToolVersions(selectedTool.id);
}
}}
>
{subTools.map((subTool) => (
<SubToolAccordionItem key={subTool.pluginId} tool={subTool} />
))}
</Accordion>
{t('app:custom_plugin_update')}
</Button>
)}
{!isToolSet && (
<>
{parentTool?.versionList?.[0]?.inputs &&
parentTool?.versionList?.[0]?.inputs.length > 0 && (
<ParamSection
title={t('app:toolkit_inputs')}
params={parentTool?.versionList?.[0]?.inputs}
/>
)}
{parentTool?.versionList?.[0]?.outputs &&
parentTool?.versionList?.[0]?.outputs.length > 0 && (
<ParamSection
title={t('app:toolkit_outputs')}
params={parentTool?.versionList?.[0]?.outputs}
/>
)}
</>
{showUninstallButton && (
<Button
flex={hasUpdateButton ? '0 0 62px' : '1 1 0'}
minW={0}
variant="dangerOutline"
isLoading={isLoading || loadingDetail}
onClick={() => onDelete?.()}
>
{t('app:toolkit_uninstall')}
</Button>
)}
</VStack>
)}
</Box>
</MyBox>
</Flex>
)
}
/>
</DrawerBody>
</DrawerContent>
</Drawer>
......
......@@ -3,3 +3,22 @@
*/
export const normalizeToolCardTags = (tags?: string[] | null) =>
tags?.map((tag) => tag.trim()).filter(Boolean) ?? [];
/** 根据工具启用状态和 plugin-server 安装记录判断当前版本是否已安装。 */
export const isToolVersionInstalled = ({
isInstalled,
currentVersion,
installedVersions,
installedVersion
}: {
isInstalled: boolean;
currentVersion?: string;
installedVersions?: string[];
installedVersion?: string;
}) => {
if (!isInstalled) return false;
if (installedVersion === currentVersion) return true;
if (installedVersions) return installedVersions.includes(currentVersion ?? '');
if (installedVersion) return false;
return true;
};
......@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "Plugin Introduction",
"toolkit_plugin_name": "Plugin Name",
"toolkit_plugin_status": "Plugin Status",
"toolkit_plugin_update": "Plugin Update",
"toolkit_plugin_version": "Plugin Version",
"toolkit_promote_tags": "Promote Tags",
"toolkit_promote_tags_tip": "Users with the following tags will see the \"Promoted\" badge",
"toolkit_reinstall": "Reinstall",
"toolkit_retry_update": "Retry update",
"toolkit_search_placeholder": "Search tool names",
"toolkit_select": "Select",
"toolkit_select_app": "Select an existing app",
"toolkit_select_user_tags": "Select user tags",
"toolkit_status": "Status",
......@@ -475,6 +478,11 @@
"toolkit_uninstalled": "Not Installed",
"toolkit_updatable": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins",
"toolkit_update_failed": "Update failed",
"toolkit_update_pending": "Pending",
"toolkit_update_selected": "Update selected ({{count}})",
"toolkit_update_success_count": "Successfully updated {{count}} plugin(s)",
"toolkit_updating": "Updating",
"toolkit_user_guide": "User Guide",
"toolkit_version_info": "Version Information",
"tools": "tool",
......
......@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "Variable Reference",
"core.workflow.inputType.agentGenerated": "Agent generated",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "Parameter value is generated dynamically by AI",
"core.workflow.inputType.agentGeneratedManaged": "AI managed",
"core.workflow.inputType.agentGeneratedManaged": "AI Generate",
"core.workflow.inputType.agentGeneratedSwitchTip": "Controlled by AI",
"core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.",
"core.workflow.inputType.custom": "External Variable",
......
......@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "插件介绍",
"toolkit_plugin_name": "插件名称",
"toolkit_plugin_status": "插件状态",
"toolkit_plugin_update": "插件更新",
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推荐标签",
"toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识",
"toolkit_reinstall": "重新安装",
"toolkit_retry_update": "重试更新",
"toolkit_search_placeholder": "搜索工具名称",
"toolkit_select": "选择",
"toolkit_select_app": "选择现有应用",
"toolkit_select_user_tags": "选择用户标签",
"toolkit_status": "状态",
......@@ -475,6 +478,11 @@
"toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件",
"toolkit_update_failed": "更新失败",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新选中项 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 个插件",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用说明",
"toolkit_version_info": "版本信息",
"tools": "工具",
......
......@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "变量引用",
"core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "参数值由 AI 动态生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 托管",
"core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "开关由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "运行时由模型填写,会进入工具调用参数。",
"core.workflow.inputType.custom": "外部变量",
......
......@@ -430,11 +430,14 @@
"toolkit_plugin_intro": "插件介紹",
"toolkit_plugin_name": "插件名稱",
"toolkit_plugin_status": "插件狀態",
"toolkit_plugin_update": "外掛程式更新",
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推薦標籤",
"toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識",
"toolkit_reinstall": "重新安裝",
"toolkit_retry_update": "重試更新",
"toolkit_search_placeholder": "搜索工具名稱",
"toolkit_select": "選擇",
"toolkit_select_app": "選擇現有應用",
"toolkit_select_user_tags": "選擇使用者標籤",
"toolkit_status": "狀態",
......@@ -464,6 +467,11 @@
"toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式",
"toolkit_update_failed": "更新失敗",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新選中項 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 個外掛程式",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用說明",
"toolkit_version_info": "版本資訊",
"tools": "工具",
......
......@@ -666,7 +666,7 @@
"core.workflow.inputType.Reference": "變數引用",
"core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "參數值由 AI 動態生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 託管",
"core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "開關由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "執行時由模型填寫,會進入工具呼叫參數。",
"core.workflow.inputType.custom": "外部變數",
......
......@@ -28,12 +28,19 @@ import {
getMarketplaceToolVersions
} from '@/web/core/plugin/marketplace/api';
import {
getMarketplaceToolStateAfterInstall,
getBatchUpdateFailures,
shouldReinstallOfflineMarketplaceTool
} from '@/web/core/plugin/marketplace/utils';
import {
getAdminSystemToolDetail,
getAdminSystemToolVersions,
getAdminSystemTools,
putAdminUpdateSystemTool
} from '@/web/core/plugin/admin/tool/api';
import { usePagination } from '@fastgpt/web/hooks/usePagination';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { getDocPath } from '@/web/common/system/doc';
......@@ -46,6 +53,7 @@ import { useToast } from '@fastgpt/web/hooks/useToast';
type QueryValue = string | string[] | undefined;
type QueryRecord = Record<string, QueryValue>;
type MarketplaceToolCardItemType = ToolCardItemType & { installedVersion?: string };
const TOOL_GRID_TEMPLATE_COLUMNS =
'repeat(auto-fill, minmax(min(max(260px, calc((100% - 6.25rem) / 6)), 100%), 1fr))';
......@@ -158,7 +166,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
// Use custom hook for URL params management
const { searchText, tagIds, sourceFilter, updateParams } = useSearchParams();
const [selectedTool, setSelectedTool] = useState<ToolCardItemType | null>(null);
const [selectedTool, setSelectedTool] = useState<MarketplaceToolCardItemType | null>(null);
const [installingOrDeletingToolIds, installingOrDeletingToolIdsDispatch] = useSet<string>();
const [updatingToolIds, updatingToolIdsDispatch] = useSet<string>();
const operatingPromisesRef = useRef<Map<string, Promise<void>>>(new Map());
......@@ -281,7 +289,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
}
const offlineTool = systemInstalledPlugins?.allMap.get(tool.id);
const shouldReinstallOfflineTool = offlineTool?.status === PluginStatusEnum.Offline;
const targetVersion = version ?? tool.version;
const shouldReinstallOfflineTool = shouldReinstallOfflineMarketplaceTool({
isOffline: offlineTool?.status === PluginStatusEnum.Offline,
installedVersion: offlineTool?.version,
targetVersion
});
const reinstallSystemTool = async (systemToolId: string) => {
const detail = await getAdminSystemToolDetail({ toolId: systemToolId });
......@@ -301,25 +314,46 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
try {
if (shouldReinstallOfflineTool && offlineTool) {
await reinstallSystemTool(offlineTool.systemToolId);
toast({
title: t('app:custom_plugin_install_success'),
status: 'success'
});
} else {
const downloadUrl = await getMarketplaceDownloadURL(tool.id, version);
if (!downloadUrl) return;
if (!downloadUrl) throw new Error(t('common:request_error'));
await intallPluginWithUrl({
const installResult = await intallPluginWithUrl({
downloadUrls: [downloadUrl]
});
const failures = getBatchUpdateFailures({
toolIds: [tool.id],
downloadUrls: [downloadUrl],
installResult,
language: i18n.language
});
if (failures.length > 0) {
throw new Error(failures[0].reason);
}
if (offlineTool) {
await reinstallSystemTool(offlineTool.systemToolId);
}
}
const refreshedInstalledPlugins = await refreshInstalledPlugins();
const refreshedInstalledTool = refreshedInstalledPlugins?.map.get(tool.id);
if (selectedTool?.id === tool.id && shouldReinstallOfflineTool) {
setSelectedTool(null);
} else if (selectedTool?.id === tool.id) {
setSelectedTool((prev) => (prev ? { ...prev, installed: true, update: false } : null));
setSelectedTool((prev) =>
prev
? {
...prev,
...getMarketplaceToolStateAfterInstall({
targetVersion: version ?? tool.version,
refreshedInstalledVersion: refreshedInstalledTool?.version
})
}
: null
);
}
await refreshInstalledPlugins();
} finally {
installingOrDeletingToolIdsDispatch.remove(tool.id);
operatingPromisesRef.current.delete(tool.id);
......@@ -334,6 +368,21 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
}
);
const handleInstallToolWithErrorHandling = useCallback(
async (tool: ToolCardItemType, version?: string) => {
try {
await handleInstallTool(tool, version);
toast({
title: t('app:custom_plugin_install_success'),
status: 'success'
});
} catch {
// useRequest 已统一展示安装失败提示,这里只消费拒绝的 Promise。
}
},
[handleInstallTool, t, toast]
);
const handleUpdateTool = useCallback(
async (tool: ToolCardItemType, version?: string) => {
const existingPromise = operatingPromisesRef.current.get(tool.id);
......@@ -348,18 +397,39 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
try {
// Get download URL
const downloadUrl = await getMarketplaceDownloadURL(tool.id, version);
if (!downloadUrl) return;
if (!downloadUrl) throw new Error(t('common:request_error'));
// Call install interface for update
await intallPluginWithUrl({
// 即使接口返回 200,也要检查 plugin-server 返回的单项失败结果。
const installResult = await intallPluginWithUrl({
downloadUrls: [downloadUrl]
});
const failures = getBatchUpdateFailures({
toolIds: [tool.id],
downloadUrls: [downloadUrl],
installResult,
language: i18n.language
});
if (failures.length > 0) {
throw new Error(failures[0].reason);
}
const refreshedInstalledPlugins = await refreshInstalledPlugins();
const refreshedInstalledTool = refreshedInstalledPlugins?.map.get(tool.id);
// If the currently selected tool is the tool to be updated, update its status
if (selectedTool?.id === tool.id) {
setSelectedTool((prev) => (prev ? { ...prev, installed: true, update: false } : null));
setSelectedTool((prev) =>
prev
? {
...prev,
...getMarketplaceToolStateAfterInstall({
targetVersion: version ?? tool.version,
refreshedInstalledVersion: refreshedInstalledTool?.version
})
}
: null
);
}
await refreshInstalledPlugins();
} finally {
updatingToolIdsDispatch.remove(tool.id);
operatingPromisesRef.current.delete(tool.id);
......@@ -369,7 +439,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
operatingPromisesRef.current.set(tool.id, operationPromise);
await operationPromise;
},
[updatingToolIdsDispatch, selectedTool, refreshInstalledPlugins]
[i18n.language, refreshInstalledPlugins, selectedTool, t, updatingToolIdsDispatch]
);
const handleUpdateToolWithErrorHandling = useCallback(
async (tool: ToolCardItemType, version?: string) => {
try {
await handleUpdateTool(tool, version);
toast({
title: t('common:update_success'),
status: 'success'
});
} catch (error) {
toast({
title: getErrText(error, t('app:toolkit_update_failed')),
status: 'error'
});
}
},
[handleUpdateTool, t, toast]
);
const { runAsync: handleUninstallTool } = useRequest(
......@@ -400,7 +488,11 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
});
if (selectedTool?.id === tool.id) {
setSelectedTool((prev) => (prev ? { ...prev, installed: false, update: false } : null));
setSelectedTool((prev) =>
prev
? { ...prev, installed: false, installedVersion: undefined, update: false }
: null
);
}
await refreshInstalledPlugins();
} finally {
......@@ -436,26 +528,49 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
const { runAsync: handleBatchUpdate, loading: isBatchUpdating } = useRequest(
async (toolIds: string[]) => {
if (toolIds.length === 0) return;
if (toolIds.length === 0) return [];
// 1. Batch get download URLs
const downloadUrls = await getMarketplaceDownloadURLs(toolIds);
// 2. Batch install (update)
await intallPluginWithUrl({ downloadUrls });
const installResult = await intallPluginWithUrl({ downloadUrls });
const failures = getBatchUpdateFailures({
toolIds,
downloadUrls,
installResult,
language: i18n.language
});
// 3. Refresh installed plugins list
await refreshInstalledPlugins();
// 4. Close Drawer
setShowBatchUpdateDrawer(false);
const successCount = toolIds.length - failures.length;
if (successCount > 0) {
toast({
title: t('app:toolkit_update_success_count', { count: successCount }),
status: 'success'
});
}
// 4. 保留部分失败项,用户可直接在 Drawer 内重试。
if (failures.length === 0) {
setShowBatchUpdateDrawer(false);
}
return failures;
},
{
manual: true,
successToast: t('common:Success')
manual: true
}
);
const fetchInstalledToolVersions = useCallback(
(toolId: string) => getAdminSystemToolVersions({ toolId }),
[]
);
const heroSectionRef = useRef<HTMLDivElement>(null);
const [showCompactSearch, setShowCompactSearch] = useState(false);
useEffect(() => {
......@@ -479,7 +594,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
};
}, []);
const displayTools: ToolCardItemType[] = useMemo(() => {
const displayTools: MarketplaceToolCardItemType[] = useMemo(() => {
return (
tools
?.map((tool) => {
......@@ -506,6 +621,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
return parseI18nString(currentTag?.tagName || '', i18n.language) || '';
}),
installed: isInstalled,
installedVersion: installedTool?.version,
update,
version: tool.version,
etag: tool.etag,
......@@ -882,12 +998,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
variant="marketplace"
isInstallingOrDeleting={installingOrDeletingToolIds.has(tool.id)}
isUpdating={updatingToolIds.has(tool.id)}
onInstall={() => handleInstallTool(tool)}
onInstall={() => handleInstallToolWithErrorHandling(tool)}
onDelete={() => {
openMarketplaceUninstallConfirm(tool);
return Promise.resolve();
}}
onUpdate={() => handleUpdateTool(tool)}
onUpdate={() => handleUpdateToolWithErrorHandling(tool)}
onClickCard={() => setSelectedTool(tool)}
showActionButton={!tool.installed}
/>
......@@ -906,22 +1022,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
onClose={() => setSelectedTool(null)}
selectedTool={selectedTool}
showPoint={false}
onToggleInstall={(_, version) => handleInstallTool(selectedTool, version)}
onToggleInstall={(_, version) =>
handleInstallToolWithErrorHandling(selectedTool, version)
}
onDelete={() => {
openMarketplaceUninstallConfirm(selectedTool);
return Promise.resolve();
}}
onUpdate={(version) => handleUpdateTool(selectedTool, version)}
onUpdate={(version) => handleUpdateToolWithErrorHandling(selectedTool, version)}
isUpdating={updatingToolIds.has(selectedTool.id)}
isLoading={installingOrDeletingToolIds.has(selectedTool.id)}
installedVersion={selectedTool.installedVersion}
mode="admin"
showActionButton={!selectedTool.installed}
// TODO:这里复用 plugin 的类型,可以去掉 ts-ignore
//@ts-ignore
onFetchDetail={async (toolId: string, version?: string) =>
await getMarketplaceToolDetail({ toolId, version })
}
onFetchVersions={getMarketplaceToolVersions}
onFetchInstalledVersions={fetchInstalledToolVersions}
/>
)}
......@@ -931,11 +1050,16 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
onClose={() => setShowBatchUpdateDrawer(false)}
updatableTools={updatableTools}
onBatchUpdate={handleBatchUpdate}
onUpdate={handleUpdateTool}
onDelete={openMarketplaceUninstallConfirm}
isBatchUpdating={isBatchUpdating}
singleUpdatingToolIds={updatingToolIds}
deletingToolIds={installingOrDeletingToolIds}
//@ts-ignore
onFetchDetail={async (toolId: string, version?: string) =>
await getMarketplaceToolDetail({ toolId, version })
}
onFetchVersions={getMarketplaceToolVersions}
/>
)}
<UninstallConfirmModal />
......
......@@ -4,6 +4,7 @@ import type {
InstallPluginFromUrlBodyType,
UploadPkgPluginResponseType
} from '@fastgpt/global/openapi/core/plugin/admin/api';
import type { PluginInstallResultType } from '@fastgpt/global/sdk/fastgpt-plugin';
// Pkg plugin
export const uploadPkgPlugin = (formData: FormData) =>
......@@ -13,4 +14,4 @@ export const confirmPkgPluginUpload = (data: ConfirmUploadPkgPluginBodyType) =>
POST(`/core/plugin/admin/pkg/confirm`, data);
export const intallPluginWithUrl = (data: InstallPluginFromUrlBodyType) =>
POST('/core/plugin/admin/installWithUrl', data);
POST<PluginInstallResultType>('/core/plugin/admin/installWithUrl', data);
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type {
PluginInstallFailureType,
PluginInstallResultType
} from '@fastgpt/global/sdk/fastgpt-plugin';
export type BatchUpdateFailure = {
toolId: string;
reason: string;
};
/** 根据安装后的刷新结果生成详情页安装状态。 */
export const getMarketplaceToolStateAfterInstall = ({
targetVersion,
refreshedInstalledVersion
}: {
targetVersion?: string;
refreshedInstalledVersion?: string;
}) => ({
installed: true,
installedVersion: targetVersion ?? refreshedInstalledVersion,
update: false
});
/** 判断离线工具是否可以直接重新启用,版本不同时仍需下载安装所选版本。 */
export const shouldReinstallOfflineMarketplaceTool = ({
isOffline,
installedVersion,
targetVersion
}: {
isOffline: boolean;
installedVersion?: string;
targetVersion?: string;
}) => isOffline && installedVersion === targetVersion;
/**
* 将 plugin-server 按下载地址返回的失败项映射回 marketplace toolId。
* 下载地址与 toolId 按请求数组下标一一对应,未出现在 failed 中的项目视为更新成功。
*/
export const getBatchUpdateFailures = ({
toolIds,
downloadUrls,
installResult,
language
}: {
toolIds: string[];
downloadUrls: string[];
installResult: PluginInstallResultType;
language: string;
}): BatchUpdateFailure[] => {
const failureByUrl = new Map<string, PluginInstallFailureType>(
installResult.failed?.map((failure) => [failure.url, failure]) ?? []
);
return toolIds.flatMap((toolId, index) => {
const failure = failureByUrl.get(downloadUrls[index]);
if (!failure) return [];
return [
{
toolId,
reason: parseI18nString(failure.reason, language)
}
];
});
};
import { describe, expect, it } from 'vitest';
import {
getBatchUpdateFailures,
getMarketplaceToolStateAfterInstall,
shouldReinstallOfflineMarketplaceTool
} from '@/web/core/plugin/marketplace/utils';
import { isToolVersionInstalled } from '@fastgpt/web/components/core/plugin/tool/utils';
describe('shouldReinstallOfflineMarketplaceTool', () => {
it('re-enables an offline tool only when the selected version matches', () => {
expect(
shouldReinstallOfflineMarketplaceTool({
isOffline: true,
installedVersion: '3.0.0',
targetVersion: '3.0.0'
})
).toBe(true);
expect(
shouldReinstallOfflineMarketplaceTool({
isOffline: true,
installedVersion: '3.0.0',
targetVersion: '2.0.0'
})
).toBe(false);
});
});
describe('getMarketplaceToolStateAfterInstall', () => {
it('keeps the detail installed when refresh has not returned the newly installed tool yet', () => {
expect(
getMarketplaceToolStateAfterInstall({
targetVersion: '3.0.0',
refreshedInstalledVersion: undefined
})
).toEqual({
installed: true,
installedVersion: '3.0.0',
update: false
});
});
});
describe('isToolVersionInstalled', () => {
it('recognizes every installed version returned by plugin-server', () => {
expect(
isToolVersionInstalled({
isInstalled: true,
currentVersion: '2.0.0',
installedVersions: ['3.0.0', '2.0.0'],
installedVersion: '3.0.0'
})
).toBe(true);
});
it('uses the updated installed version while the installed version list is stale', () => {
expect(
isToolVersionInstalled({
isInstalled: true,
currentVersion: '3.0.0',
installedVersions: ['2.0.0'],
installedVersion: '3.0.0'
})
).toBe(true);
});
it('treats all versions as unavailable while the tool is offline', () => {
expect(
isToolVersionInstalled({
isInstalled: false,
currentVersion: '2.0.0',
installedVersions: ['3.0.0', '2.0.0']
})
).toBe(false);
});
});
describe('getBatchUpdateFailures', () => {
it('maps partial failures from download URLs back to tool IDs', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1', 'tool-2', 'tool-3'],
downloadUrls: ['url-1', 'url-2', 'url-3'],
installResult: {
failed: [
{
url: 'url-2',
reason: { en: 'Install failed', 'zh-CN': '安装失败' }
}
]
},
language: 'zh-CN'
})
).toEqual([{ toolId: 'tool-2', reason: '安装失败' }]);
});
it('returns an empty list when every plugin succeeds', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {},
language: 'en'
})
).toEqual([]);
});
it('falls back to the English reason for an unsupported locale', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {
failed: [{ url: 'url-1', reason: { en: 'Install failed' } }]
},
language: 'ja'
})
).toEqual([{ toolId: 'tool-1', reason: 'Install failed' }]);
});
it('ignores failure URLs that are not part of this request', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {
failed: [{ url: 'other-url', reason: { en: 'Install failed' } }]
},
language: 'en'
})
).toEqual([]);
});
});
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