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'; ...@@ -4,7 +4,7 @@ import m from 'multer';
import type { NodeHttpRequest } from '../../types/http'; import type { NodeHttpRequest } from '../../types/http';
import path from 'path'; import path from 'path';
import fs from 'node:fs'; import fs from 'node:fs';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/uploadConstraints'; import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/extension';
type MulterFileFilterOptions = { type MulterFileFilterOptions = {
allowedExtensions?: string[]; allowedExtensions?: string[];
......
...@@ -11,6 +11,16 @@ import { ...@@ -11,6 +11,16 @@ import {
resolveMimeType resolveMimeType
} from '../utils/mime'; } from '../utils/mime';
import type { UploadExtensionRule, UploadPolicy } from './type'; import type { UploadExtensionRule, UploadPolicy } from './type';
import {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
export {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
const uploadConfigKeys: FileExtensionKeyType[] = [ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectFile', 'canSelectFile',
...@@ -70,25 +80,6 @@ export const officeZipFormats = [ ...@@ -70,25 +80,6 @@ export const officeZipFormats = [
* 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单 * 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单
* 在预签、上传校验和 metadata 修正阶段出现不同表示。 * 在预签、上传校验和 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) => { export const decodeFileName = (filename?: string) => {
if (!filename) return ''; if (!filename) return '';
try { 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 { ...@@ -4,13 +4,13 @@ import {
type FileExtensionKeyType type FileExtensionKeyType
} from '@fastgpt/global/core/app/constants'; } from '@fastgpt/global/core/app/constants';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; 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 { import {
createUploadExtensionRulesFromFileSelectConfig,
normalizeAllowedExtensions, normalizeAllowedExtensions,
normalizeFileExtension, normalizeFileExtension,
parseAllowedExtensions parseAllowedExtensions
} from '../uploadPolicy/utils'; } from './extension';
import { createUploadPolicy } from '../uploadPolicy/service'; import { createUploadPolicy } from '../uploadPolicy/service';
import type { UploadPolicy } from '../uploadPolicy/type'; import type { UploadPolicy } from '../uploadPolicy/type';
...@@ -22,7 +22,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [ ...@@ -22,7 +22,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectCustomFileExtension' 'canSelectCustomFileExtension'
]; ];
export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions }; export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions } from './extension';
export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']); export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']);
export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType); export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType);
......
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { import {
Box, Box,
Button, Button,
...@@ -9,50 +9,72 @@ import { ...@@ -9,50 +9,72 @@ import {
DrawerHeader, DrawerHeader,
DrawerOverlay, DrawerOverlay,
Flex, Flex,
VStack, Grid,
Accordion Spinner,
VStack
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Avatar from '../../../common/Avatar'; import Avatar from '../../../common/Avatar';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyIconButton from '../../../common/Icon/button'; import MyIconButton from '../../../common/Icon/button';
import LightRowTabs from '../../../common/Tabs/LightRowTabs';
import { type ToolCardItemType } from './ToolCard'; 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 { useTableMultipleSelect } from '../../../../hooks/useTableMultipleSelect';
import { useRequest } from '../../../../hooks/useRequest';
import { useToast } from '../../../../hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { import {
ParamSection, ToolDetailBody,
ReadmeBox,
SubToolAccordionItem,
useToolDetail, useToolDetail,
drawerScrollbarStyles, drawerScrollbarStyles,
type ToolDetailFetchResponse type ToolDetailFetchResponse,
type ToolDetailVersionType
} from './ToolDetail'; } from './ToolDetail';
type ViewMode = 'list' | 'detail'; type ViewMode = 'list' | 'detail';
interface BatchUpdateDrawerProps { export type BatchUpdateFailure = {
toolId: string;
reason: string;
};
type BatchUpdateDrawerProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
updatableTools: ToolCardItemType[]; 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; 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> = ({ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
isOpen, isOpen,
onClose, onClose,
updatableTools, updatableTools,
onBatchUpdate, onBatchUpdate,
onUpdate,
onDelete,
isBatchUpdating, isBatchUpdating,
onFetchDetail singleUpdatingToolIds,
deletingToolIds,
onFetchDetail,
onFetchVersions
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { toast } = useToast();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedToolForDetail, setSelectedToolForDetail] = useState<ToolCardItemType | null>(null); 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 [isUpdatingSingle, setIsUpdatingSingle] = useState(false);
const [failureReasons, setFailureReasons] = useState<Record<string, string>>({});
const [updatingToolIds, setUpdatingToolIds] = useState<Set<string>>(new Set());
// Use table multiple select hook // Use table multiple select hook
const { const {
...@@ -68,10 +90,46 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -68,10 +90,46 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
getItemId: (tool: ToolCardItemType) => tool.id 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 // Use tool detail hook
const { parentTool, isToolSet, subTools, readmeContent, loadingDetail } = useToolDetail({ const { parentTool, isToolSet, subTools, readmeContent, loadingDetail } = useToolDetail({
toolId: selectedToolForDetail?.id, toolId: selectedToolForDetail?.id,
tags: selectedToolForDetail?.tags || undefined, version: activeVersion,
tags: selectedToolForDetail?.tags ?? undefined,
onFetchDetail, onFetchDetail,
autoFetch: viewMode === 'detail' autoFetch: viewMode === 'detail'
}); });
...@@ -79,12 +137,15 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -79,12 +137,15 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setViewMode('list'); setViewMode('list');
setSelectedToolForDetail(null); setSelectedToolForDetail(null);
setActiveTab('params'); setSelectedVersion(undefined);
setSelectedItems([]); setSelectedItems([]);
setFailureReasons({});
setUpdatingToolIds(new Set());
onClose(); onClose();
}, [onClose, setSelectedItems]); }, [onClose, setSelectedItems]);
const handleViewDetail = useCallback((tool: ToolCardItemType) => { const handleViewDetail = useCallback((tool: ToolCardItemType) => {
setSelectedVersion(undefined);
setSelectedToolForDetail(tool); setSelectedToolForDetail(tool);
setViewMode('detail'); setViewMode('detail');
}, []); }, []);
...@@ -92,21 +153,93 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -92,21 +153,93 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
setViewMode('list'); setViewMode('list');
setSelectedToolForDetail(null); 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 () => { const handleUpdateSingle = useCallback(async () => {
if (!selectedToolForDetail) return; if (!selectedToolForDetail) return;
setIsUpdatingSingle(true); setIsUpdatingSingle(true);
try { try {
await onBatchUpdate([selectedToolForDetail.id]); await onUpdate(selectedToolForDetail, activeVersion);
// Go back to list view after successful update setFailureReasons((previous) => {
handleBack(); 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 { } finally {
setIsUpdatingSingle(false); setIsUpdatingSingle(false);
} }
}, [selectedToolForDetail, onBatchUpdate, handleBack]); }, [activeVersion, onUpdate, selectedToolForDetail, t, toast]);
return ( return (
<Drawer isOpen={isOpen} onClose={handleClose} placement="right"> <Drawer isOpen={isOpen} onClose={handleClose} placement="right">
...@@ -116,18 +249,55 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -116,18 +249,55 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
{viewMode === 'list' ? ( {viewMode === 'list' ? (
<Flex gap={1.5} alignItems="center"> <Flex gap={1.5} alignItems="center">
<Box fontSize={'16px'} fontWeight={500} color={'myGray.900'}> <Box fontSize={'16px'} fontWeight={500} color={'myGray.900'}>
{t('app:toolkit_updatable_plugins')} {t('app:toolkit_plugin_update')}
</Box> </Box>
<Box flex={1} /> <Box flex={1} />
<MyIconButton icon={'common/closeLight'} onClick={handleClose} /> <MyIconButton icon={'common/closeLight'} onClick={handleClose} />
</Flex> </Flex>
) : ( ) : (
<Flex gap={1.5}> <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'}> <Box fontSize={'16px'} fontWeight={500} color={'myGray.900'}>
{parseI18nString(parentTool?.name || '', i18n.language)} {parseI18nString(parentTool?.name ?? '', i18n.language)}
</Box> </Box>
<Box flex={1} /> <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} /> <MyIconButton icon={'common/backFill'} onClick={handleBack} />
</Flex> </Flex>
)} )}
...@@ -136,60 +306,130 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -136,60 +306,130 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
<DrawerBody position="relative" sx={drawerScrollbarStyles}> <DrawerBody position="relative" sx={drawerScrollbarStyles}>
{viewMode === 'list' ? ( {viewMode === 'list' ? (
<VStack align="stretch" spacing={0} pb={20}> <VStack align="stretch" spacing={0} pb={20}>
{updatableTools.map((tool) => ( <Grid
<Flex gridTemplateColumns="48px minmax(0, 1fr) 108px"
key={tool.id} h={10}
align="center" alignItems="center"
p={3} bg="myGray.100"
borderRadius="md" borderRadius="md"
borderBottom={'1px solid'} color="myGray.600"
borderColor={'myGray.200'} fontSize="12px"
_hover={{ bg: 'myGray.50' }} >
cursor="pointer" <Box px={3}>{t('app:toolkit_select')}</Box>
onClick={() => toggleSelect(tool)} <Box px={3}>{t('common:Name')}</Box>
> <Box px={3}>{t('common:Status')}</Box>
<Flex onClick={(e) => e.stopPropagation()} mr={3} align="center"> </Grid>
<Checkbox isChecked={isSelected(tool)} onChange={() => toggleSelect(tool)} />
</Flex> {updatableTools.map((tool) => {
<Avatar src={tool.icon} w={6} h={6} borderRadius="md" mr={3} flexShrink={0} /> const failureReason = failureReasons[tool.id];
<Box flex={1}> const isUpdating = updatingToolIds.has(tool.id);
<Box fontSize="sm" fontWeight="medium" color="myGray.900">
{parseI18nString(tool.name, i18n.language)} return (
</Box> <Grid
</Box> key={tool.id}
<Box gridTemplateColumns="48px minmax(0, 1fr) 108px"
as="button" minH={12}
fontSize="sm" alignItems="center"
color="primary.600" borderBottom="1px solid"
_hover={{ textDecoration: 'underline' }} borderColor="myGray.200"
onClick={(e: any) => {
e.stopPropagation();
handleViewDetail(tool);
}}
> >
{t('common:view_detail')} <Flex px={3} align="center">
</Box> <Checkbox
</Flex> 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 <Flex
position="fixed" position="fixed"
bottom={0} bottom={0}
left={0}
right={0} right={0}
w={'480px'} w="min(480px, 100vw)"
px={8} px={6}
py={2} py={3}
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
borderLeftRadius="md" borderLeftRadius="md"
backgroundColor="white" backgroundColor="white"
borderTop="1px solid"
borderColor="myGray.100"
> >
<Flex alignItems="center" gap={2}> <Flex alignItems="center" gap={2}>
<Checkbox size="sm" isChecked={isSelecteAll} onChange={selectAllTrigger} ml={1} /> <Checkbox
<Box fontSize="sm" color="gray.600"> size="sm"
{t('common:select_count_num', { num: selectedItems.length })} isChecked={isSelecteAll}
isDisabled={isBatchUpdating}
onChange={selectAllTrigger}
/>
<Box fontSize="sm" color="myGray.700">
{t('common:Select_all')}
</Box> </Box>
</Flex> </Flex>
<Button <Button
...@@ -197,140 +437,44 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({ ...@@ -197,140 +437,44 @@ const BatchUpdateDrawer: React.FC<BatchUpdateDrawerProps> = ({
isLoading={isBatchUpdating} isLoading={isBatchUpdating}
isDisabled={!hasSelections} isDisabled={!hasSelections}
onClick={() => 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> </Button>
</Flex> </Flex>
</VStack> </VStack>
) : ( ) : (
<MyBox> <ToolDetailBody
<Flex gap={2} flexWrap="wrap"> parentTool={parentTool}
{parentTool?.tags?.map((tag: string) => ( isToolSet={isToolSet}
<Box subTools={subTools}
key={tag} readmeContent={readmeContent}
px={2} actions={
py={1} <Flex gap={2}>
border={'1px solid'} <Button
borderRadius={'6px'} flex="1 1 0"
borderColor={'myGray.200'} minW={0}
fontSize={'10px'} variant="primary"
fontWeight={'medium'} isLoading={isUpdatingSingle || isSelectedToolUpdating || loadingDetail}
color={'myGray.700'} isDisabled={isSelectedToolDeleting}
onClick={handleUpdateSingle}
> >
{tag} {t('app:custom_plugin_update')}
</Box> </Button>
))} <Button
</Flex> flex="0 0 62px"
<Box fontSize={'12px'} color="myGray.500" mt={3}> minW={0}
{parseI18nString(parentTool?.description || '', i18n.language)} variant="dangerOutline"
</Box> isLoading={isSelectedToolDeleting}
<Box fontSize={'12px'} color="myGray.500" mt={3}> isDisabled={isUpdatingSingle || isSelectedToolUpdating}
{`by ${parentTool?.author || 'FastGPT'}`} onClick={() => selectedToolForDetail && onDelete(selectedToolForDetail)}
</Box> >
<Flex mt={3} gap={2}> {t('app:toolkit_uninstall')}
<Button </Button>
flex={1} </Flex>
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>
)} )}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
......
...@@ -72,12 +72,7 @@ const ToolCard = ({ ...@@ -72,12 +72,7 @@ const ToolCard = ({
isMarketplaceVariant && mode === 'admin' && item.installed && !showActionButton; isMarketplaceVariant && mode === 'admin' && item.installed && !showActionButton;
useEffect(() => { useEffect(() => {
if (displayTags.length === 0) { if (displayTags.length === 0) return;
setVisibleTagsCount(0);
return;
}
setVisibleTagsCount(displayTags.length);
const calculate = () => { const calculate = () => {
const container = tagsContainerRef.current; const container = tagsContainerRef.current;
...@@ -204,42 +199,6 @@ const ToolCard = ({ ...@@ -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 <HStack
minW={0} minW={0}
spacing={isMarketplaceVariant ? 2 : undefined} spacing={isMarketplaceVariant ? 2 : undefined}
...@@ -250,6 +209,7 @@ const ToolCard = ({ ...@@ -250,6 +209,7 @@ const ToolCard = ({
borderRadius={'sm'} borderRadius={'sm'}
w={isMarketplaceVariant ? '24px' : '1.5rem'} w={isMarketplaceVariant ? '24px' : '1.5rem'}
h={isMarketplaceVariant ? '24px' : undefined} h={isMarketplaceVariant ? '24px' : undefined}
flexShrink={0}
/> />
<Box <Box
color={isMarketplaceVariant ? '#111824' : 'myGray.900'} color={isMarketplaceVariant ? '#111824' : 'myGray.900'}
...@@ -257,7 +217,7 @@ const ToolCard = ({ ...@@ -257,7 +217,7 @@ const ToolCard = ({
lineHeight={isMarketplaceVariant ? '24px' : undefined} lineHeight={isMarketplaceVariant ? '24px' : undefined}
fontWeight={'medium'} fontWeight={'medium'}
minW={0} minW={0}
flexShrink={1} flex={1}
className={'textEllipsis'} className={'textEllipsis'}
> >
{parseI18nString(item.name, i18n.language)} {parseI18nString(item.name, i18n.language)}
...@@ -279,6 +239,37 @@ const ToolCard = ({ ...@@ -279,6 +239,37 @@ const ToolCard = ({
</Box> </Box>
)} )}
{item.isDebug && <DebugToolTag />} {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 && ( {statusLabel && (
<Flex <Flex
flexShrink={0} 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'; ...@@ -3,3 +3,4 @@ export * from './components';
export * from './hooks'; export * from './hooks';
export * from './styles'; export * from './styles';
export { default as ReadmeBox } from './ReadmeBox'; export { default as ReadmeBox } from './ReadmeBox';
export { default as ToolDetailBody } from './ToolDetailBody';
...@@ -7,9 +7,7 @@ import { ...@@ -7,9 +7,7 @@ import {
DrawerContent, DrawerContent,
DrawerHeader, DrawerHeader,
DrawerOverlay, DrawerOverlay,
Flex, Flex
VStack,
Accordion
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Avatar from '../../../common/Avatar'; import Avatar from '../../../common/Avatar';
...@@ -17,19 +15,16 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; ...@@ -17,19 +15,16 @@ import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyIconButton from '../../../common/Icon/button'; import MyIconButton from '../../../common/Icon/button';
import MyIcon from '../../../common/Icon'; import MyIcon from '../../../common/Icon';
import MyMenu from '../../../common/MyMenu'; import MyMenu from '../../../common/MyMenu';
import LightRowTabs from '../../../common/Tabs/LightRowTabs';
import { type ToolCardItemType } from './ToolCard'; import { type ToolCardItemType } from './ToolCard';
import MyBox from '../../../common/MyBox';
import { useRequest } from '../../../../hooks/useRequest'; import { useRequest } from '../../../../hooks/useRequest';
import { import {
ParamSection, ToolDetailBody,
ReadmeBox,
SubToolAccordionItem,
useToolDetail, useToolDetail,
drawerScrollbarStyles, drawerScrollbarStyles,
type ToolDetailFetchResponse, type ToolDetailFetchResponse,
type ToolDetailVersionType type ToolDetailVersionType
} from './ToolDetail'; } from './ToolDetail';
import { isToolVersionInstalled } from './utils';
const ToolDetailDrawer = ({ const ToolDetailDrawer = ({
onClose, onClose,
...@@ -41,10 +36,12 @@ const ToolDetailDrawer = ({ ...@@ -41,10 +36,12 @@ const ToolDetailDrawer = ({
systemTitle, systemTitle,
onFetchDetail, onFetchDetail,
onFetchVersions, onFetchVersions,
onFetchInstalledVersions,
onVersionChange, onVersionChange,
isLoading, isLoading,
showPoint, showPoint,
mode, mode,
installedVersion,
showActionButton = true showActionButton = true
}: { }: {
onClose: () => void; onClose: () => void;
...@@ -56,23 +53,21 @@ const ToolDetailDrawer = ({ ...@@ -56,23 +53,21 @@ const ToolDetailDrawer = ({
systemTitle?: string; systemTitle?: string;
onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>; onFetchDetail?: (toolId: string, version?: string) => Promise<ToolDetailFetchResponse>;
onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>; onFetchVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
onFetchInstalledVersions?: (toolId: string) => Promise<ToolDetailVersionType[]>;
onVersionChange?: (version: string) => void; onVersionChange?: (version: string) => void;
isLoading?: boolean; isLoading?: boolean;
showPoint: boolean; showPoint: boolean;
mode: 'admin' | 'team' | 'marketplace'; mode: 'admin' | 'team' | 'marketplace';
installedVersion?: string;
showActionButton?: boolean; showActionButton?: boolean;
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const isInstalled = 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 = [],
...@@ -95,6 +90,27 @@ const ToolDetailDrawer = ({ ...@@ -95,6 +90,27 @@ const ToolDetailDrawer = ({
} }
}, [fetchToolVersions, onFetchVersions, selectedTool.id]); }, [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; const activeVersion = selectedVersion || toolVersions[0]?.version;
// Use tool detail hook // Use tool detail hook
...@@ -106,6 +122,21 @@ const ToolDetailDrawer = ({ ...@@ -106,6 +122,21 @@ const ToolDetailDrawer = ({
}); });
const currentVersion = activeVersion || parentTool?.version || selectedTool.version; 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 ( return (
<Drawer isOpen={true} onClose={onClose} placement="right"> <Drawer isOpen={true} onClose={onClose} placement="right">
...@@ -163,182 +194,68 @@ const ToolDetailDrawer = ({ ...@@ -163,182 +194,68 @@ const ToolDetailDrawer = ({
</DrawerHeader> </DrawerHeader>
<DrawerBody position="relative" sx={drawerScrollbarStyles}> <DrawerBody position="relative" sx={drawerScrollbarStyles}>
<MyBox> <ToolDetailBody
<Flex gap={2} flexWrap="wrap"> parentTool={parentTool}
{parentTool?.tags?.map((tag: string) => ( isToolSet={isToolSet}
<Box subTools={subTools}
key={tag} readmeContent={readmeContent}
px={2} showPoint={showPoint}
py={1} systemTitle={systemTitle}
border={'1px solid'} actions={
borderRadius={'6px'} (showInstallButton || showUninstallButton || hasUpdateButton) && (
borderColor={'myGray.200'} <Flex gap={2}>
fontSize={'10px'} {showInstallButton && (
fontWeight={'medium'} <Button
color={'myGray.700'} flex={'1 1 0'}
> minW={0}
{tag} variant={isCurrentVersionInstalled ? 'primaryOutline' : 'primary'}
</Box> isLoading={isLoading || loadingDetail || loadingInstalledVersions}
))} isDisabled={isUpdating}
</Flex> onClick={async () => {
<Box fontSize={'12px'} color="myGray.500" mt={3}> await onToggleInstall?.(!isCurrentVersionInstalled, currentVersion);
{parseI18nString(parentTool?.description || '', i18n.language)} if (onFetchInstalledVersions) {
</Box> await fetchInstalledToolVersions(selectedTool.id);
<Box fontSize={'12px'} color="myGray.500" mt={3}> }
{`by ${parentTool?.author || systemTitle || 'FastGPT'}`} }}
</Box> >
{(showInstallButton || showUninstallButton || hasUpdateButton) && ( {isDownload
<Flex mt={3} gap={2}> ? t('common:Download')
{showInstallButton && ( : isCurrentVersionInstalled
<Button ? t('app:toolkit_uninstall')
flex={'1 1 0'} : t('app:toolkit_install')}
minW={0} </Button>
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}
/>
)} )}
</VStack> {hasUpdateButton && (
)} <Button
variant="primary"
{activeTab === 'params' && ( flex={'1 1 0'}
<VStack align="stretch" spacing={4}> minW={0}
{isToolSet && subTools.length > 0 && ( isLoading={isUpdating || loadingDetail}
<Accordion onClick={async () => {
allowMultiple await onUpdate?.(currentVersion);
{...(subTools.length === 1 ? { defaultIndex: [0] } : {})} if (onFetchInstalledVersions) {
await fetchInstalledToolVersions(selectedTool.id);
}
}}
> >
{subTools.map((subTool) => ( {t('app:custom_plugin_update')}
<SubToolAccordionItem key={subTool.pluginId} tool={subTool} /> </Button>
))}
</Accordion>
)} )}
{showUninstallButton && (
{!isToolSet && ( <Button
<> flex={hasUpdateButton ? '0 0 62px' : '1 1 0'}
{parentTool?.versionList?.[0]?.inputs && minW={0}
parentTool?.versionList?.[0]?.inputs.length > 0 && ( variant="dangerOutline"
<ParamSection isLoading={isLoading || loadingDetail}
title={t('app:toolkit_inputs')} onClick={() => onDelete?.()}
params={parentTool?.versionList?.[0]?.inputs} >
/> {t('app:toolkit_uninstall')}
)} </Button>
{parentTool?.versionList?.[0]?.outputs &&
parentTool?.versionList?.[0]?.outputs.length > 0 && (
<ParamSection
title={t('app:toolkit_outputs')}
params={parentTool?.versionList?.[0]?.outputs}
/>
)}
</>
)} )}
</VStack> </Flex>
)} )
</Box> }
</MyBox> />
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
......
...@@ -3,3 +3,22 @@ ...@@ -3,3 +3,22 @@
*/ */
export const normalizeToolCardTags = (tags?: string[] | null) => export const normalizeToolCardTags = (tags?: string[] | null) =>
tags?.map((tag) => tag.trim()).filter(Boolean) ?? []; 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 @@ ...@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "Plugin Introduction", "toolkit_plugin_intro": "Plugin Introduction",
"toolkit_plugin_name": "Plugin Name", "toolkit_plugin_name": "Plugin Name",
"toolkit_plugin_status": "Plugin Status", "toolkit_plugin_status": "Plugin Status",
"toolkit_plugin_update": "Plugin Update",
"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_reinstall": "Reinstall",
"toolkit_retry_update": "Retry update",
"toolkit_search_placeholder": "Search tool names", "toolkit_search_placeholder": "Search tool names",
"toolkit_select": "Select",
"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",
...@@ -475,6 +478,11 @@ ...@@ -475,6 +478,11 @@
"toolkit_uninstalled": "Not Installed", "toolkit_uninstalled": "Not Installed",
"toolkit_updatable": "Updates Available", "toolkit_updatable": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins", "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_user_guide": "User Guide",
"toolkit_version_info": "Version Information", "toolkit_version_info": "Version Information",
"tools": "tool", "tools": "tool",
......
...@@ -671,7 +671,7 @@ ...@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "Variable Reference", "core.workflow.inputType.Reference": "Variable Reference",
"core.workflow.inputType.agentGenerated": "Agent generated", "core.workflow.inputType.agentGenerated": "Agent generated",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "Parameter value is generated dynamically by AI", "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.agentGeneratedSwitchTip": "Controlled by AI",
"core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.", "core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.",
"core.workflow.inputType.custom": "External Variable", "core.workflow.inputType.custom": "External Variable",
......
...@@ -440,11 +440,14 @@ ...@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "插件介绍", "toolkit_plugin_intro": "插件介绍",
"toolkit_plugin_name": "插件名称", "toolkit_plugin_name": "插件名称",
"toolkit_plugin_status": "插件状态", "toolkit_plugin_status": "插件状态",
"toolkit_plugin_update": "插件更新",
"toolkit_plugin_version": "插件版本", "toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推荐标签", "toolkit_promote_tags": "推荐标签",
"toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识", "toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识",
"toolkit_reinstall": "重新安装", "toolkit_reinstall": "重新安装",
"toolkit_retry_update": "重试更新",
"toolkit_search_placeholder": "搜索工具名称", "toolkit_search_placeholder": "搜索工具名称",
"toolkit_select": "选择",
"toolkit_select_app": "选择现有应用", "toolkit_select_app": "选择现有应用",
"toolkit_select_user_tags": "选择用户标签", "toolkit_select_user_tags": "选择用户标签",
"toolkit_status": "状态", "toolkit_status": "状态",
...@@ -475,6 +478,11 @@ ...@@ -475,6 +478,11 @@
"toolkit_uninstalled": "未安装", "toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新", "toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件", "toolkit_updatable_plugins": "可更新的插件",
"toolkit_update_failed": "更新失败",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新选中项 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 个插件",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用说明", "toolkit_user_guide": "使用说明",
"toolkit_version_info": "版本信息", "toolkit_version_info": "版本信息",
"tools": "工具", "tools": "工具",
......
...@@ -671,7 +671,7 @@ ...@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "变量引用", "core.workflow.inputType.Reference": "变量引用",
"core.workflow.inputType.agentGenerated": "Agent 生成", "core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "参数值由 AI 动态生成", "core.workflow.inputType.agentGeneratedInputPlaceholder": "参数值由 AI 动态生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 托管", "core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "开关由 AI 控制", "core.workflow.inputType.agentGeneratedSwitchTip": "开关由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "运行时由模型填写,会进入工具调用参数。", "core.workflow.inputType.agentGeneratedDesc": "运行时由模型填写,会进入工具调用参数。",
"core.workflow.inputType.custom": "外部变量", "core.workflow.inputType.custom": "外部变量",
......
...@@ -430,11 +430,14 @@ ...@@ -430,11 +430,14 @@
"toolkit_plugin_intro": "插件介紹", "toolkit_plugin_intro": "插件介紹",
"toolkit_plugin_name": "插件名稱", "toolkit_plugin_name": "插件名稱",
"toolkit_plugin_status": "插件狀態", "toolkit_plugin_status": "插件狀態",
"toolkit_plugin_update": "外掛程式更新",
"toolkit_plugin_version": "插件版本", "toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推薦標籤", "toolkit_promote_tags": "推薦標籤",
"toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識", "toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識",
"toolkit_reinstall": "重新安裝", "toolkit_reinstall": "重新安裝",
"toolkit_retry_update": "重試更新",
"toolkit_search_placeholder": "搜索工具名稱", "toolkit_search_placeholder": "搜索工具名稱",
"toolkit_select": "選擇",
"toolkit_select_app": "選擇現有應用", "toolkit_select_app": "選擇現有應用",
"toolkit_select_user_tags": "選擇使用者標籤", "toolkit_select_user_tags": "選擇使用者標籤",
"toolkit_status": "狀態", "toolkit_status": "狀態",
...@@ -464,6 +467,11 @@ ...@@ -464,6 +467,11 @@
"toolkit_uninstalled": "未安裝", "toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新", "toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式", "toolkit_updatable_plugins": "可更新的外掛程式",
"toolkit_update_failed": "更新失敗",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新選中項 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 個外掛程式",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用說明", "toolkit_user_guide": "使用說明",
"toolkit_version_info": "版本資訊", "toolkit_version_info": "版本資訊",
"tools": "工具", "tools": "工具",
......
...@@ -666,7 +666,7 @@ ...@@ -666,7 +666,7 @@
"core.workflow.inputType.Reference": "變數引用", "core.workflow.inputType.Reference": "變數引用",
"core.workflow.inputType.agentGenerated": "Agent 生成", "core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "參數值由 AI 動態生成", "core.workflow.inputType.agentGeneratedInputPlaceholder": "參數值由 AI 動態生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 託管", "core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "開關由 AI 控制", "core.workflow.inputType.agentGeneratedSwitchTip": "開關由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "執行時由模型填寫,會進入工具呼叫參數。", "core.workflow.inputType.agentGeneratedDesc": "執行時由模型填寫,會進入工具呼叫參數。",
"core.workflow.inputType.custom": "外部變數", "core.workflow.inputType.custom": "外部變數",
......
...@@ -28,12 +28,19 @@ import { ...@@ -28,12 +28,19 @@ import {
getMarketplaceToolVersions getMarketplaceToolVersions
} from '@/web/core/plugin/marketplace/api'; } from '@/web/core/plugin/marketplace/api';
import { import {
getMarketplaceToolStateAfterInstall,
getBatchUpdateFailures,
shouldReinstallOfflineMarketplaceTool
} from '@/web/core/plugin/marketplace/utils';
import {
getAdminSystemToolDetail, getAdminSystemToolDetail,
getAdminSystemToolVersions,
getAdminSystemTools, getAdminSystemTools,
putAdminUpdateSystemTool putAdminUpdateSystemTool
} from '@/web/core/plugin/admin/tool/api'; } from '@/web/core/plugin/admin/tool/api';
import { usePagination } from '@fastgpt/web/hooks/usePagination'; import { usePagination } from '@fastgpt/web/hooks/usePagination';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { getDocPath } from '@/web/common/system/doc'; import { getDocPath } from '@/web/common/system/doc';
...@@ -46,6 +53,7 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; ...@@ -46,6 +53,7 @@ import { useToast } from '@fastgpt/web/hooks/useToast';
type QueryValue = string | string[] | undefined; type QueryValue = string | string[] | undefined;
type QueryRecord = Record<string, QueryValue>; type QueryRecord = Record<string, QueryValue>;
type MarketplaceToolCardItemType = ToolCardItemType & { installedVersion?: string };
const TOOL_GRID_TEMPLATE_COLUMNS = const TOOL_GRID_TEMPLATE_COLUMNS =
'repeat(auto-fill, minmax(min(max(260px, calc((100% - 6.25rem) / 6)), 100%), 1fr))'; 'repeat(auto-fill, minmax(min(max(260px, calc((100% - 6.25rem) / 6)), 100%), 1fr))';
...@@ -158,7 +166,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -158,7 +166,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
// Use custom hook for URL params management // Use custom hook for URL params management
const { searchText, tagIds, sourceFilter, updateParams } = useSearchParams(); 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 [installingOrDeletingToolIds, installingOrDeletingToolIdsDispatch] = useSet<string>();
const [updatingToolIds, updatingToolIdsDispatch] = useSet<string>(); const [updatingToolIds, updatingToolIdsDispatch] = useSet<string>();
const operatingPromisesRef = useRef<Map<string, Promise<void>>>(new Map()); const operatingPromisesRef = useRef<Map<string, Promise<void>>>(new Map());
...@@ -281,7 +289,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -281,7 +289,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
} }
const offlineTool = systemInstalledPlugins?.allMap.get(tool.id); 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 reinstallSystemTool = async (systemToolId: string) => {
const detail = await getAdminSystemToolDetail({ toolId: systemToolId }); const detail = await getAdminSystemToolDetail({ toolId: systemToolId });
...@@ -301,25 +314,46 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -301,25 +314,46 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
try { try {
if (shouldReinstallOfflineTool && offlineTool) { if (shouldReinstallOfflineTool && offlineTool) {
await reinstallSystemTool(offlineTool.systemToolId); await reinstallSystemTool(offlineTool.systemToolId);
toast({
title: t('app:custom_plugin_install_success'),
status: 'success'
});
} else { } else {
const downloadUrl = await getMarketplaceDownloadURL(tool.id, version); 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] 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) { if (selectedTool?.id === tool.id && shouldReinstallOfflineTool) {
setSelectedTool(null); setSelectedTool(null);
} else if (selectedTool?.id === tool.id) { } 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 { } finally {
installingOrDeletingToolIdsDispatch.remove(tool.id); installingOrDeletingToolIdsDispatch.remove(tool.id);
operatingPromisesRef.current.delete(tool.id); operatingPromisesRef.current.delete(tool.id);
...@@ -334,6 +368,21 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -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( const handleUpdateTool = useCallback(
async (tool: ToolCardItemType, version?: string) => { async (tool: ToolCardItemType, version?: string) => {
const existingPromise = operatingPromisesRef.current.get(tool.id); const existingPromise = operatingPromisesRef.current.get(tool.id);
...@@ -348,18 +397,39 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -348,18 +397,39 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
try { try {
// Get download URL // Get download URL
const downloadUrl = await getMarketplaceDownloadURL(tool.id, version); const downloadUrl = await getMarketplaceDownloadURL(tool.id, version);
if (!downloadUrl) return; if (!downloadUrl) throw new Error(t('common:request_error'));
// Call install interface for update // 即使接口返回 200,也要检查 plugin-server 返回的单项失败结果。
await intallPluginWithUrl({ const installResult = await intallPluginWithUrl({
downloadUrls: [downloadUrl] 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 the currently selected tool is the tool to be updated, update its status
if (selectedTool?.id === tool.id) { 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 { } finally {
updatingToolIdsDispatch.remove(tool.id); updatingToolIdsDispatch.remove(tool.id);
operatingPromisesRef.current.delete(tool.id); operatingPromisesRef.current.delete(tool.id);
...@@ -369,7 +439,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -369,7 +439,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
operatingPromisesRef.current.set(tool.id, operationPromise); operatingPromisesRef.current.set(tool.id, operationPromise);
await 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( const { runAsync: handleUninstallTool } = useRequest(
...@@ -400,7 +488,11 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -400,7 +488,11 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
}); });
if (selectedTool?.id === tool.id) { 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(); await refreshInstalledPlugins();
} finally { } finally {
...@@ -436,26 +528,49 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -436,26 +528,49 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
const { runAsync: handleBatchUpdate, loading: isBatchUpdating } = useRequest( const { runAsync: handleBatchUpdate, loading: isBatchUpdating } = useRequest(
async (toolIds: string[]) => { async (toolIds: string[]) => {
if (toolIds.length === 0) return; if (toolIds.length === 0) return [];
// 1. Batch get download URLs // 1. Batch get download URLs
const downloadUrls = await getMarketplaceDownloadURLs(toolIds); const downloadUrls = await getMarketplaceDownloadURLs(toolIds);
// 2. Batch install (update) // 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 // 3. Refresh installed plugins list
await refreshInstalledPlugins(); await refreshInstalledPlugins();
// 4. Close Drawer const successCount = toolIds.length - failures.length;
setShowBatchUpdateDrawer(false); 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, manual: true
successToast: t('common:Success')
} }
); );
const fetchInstalledToolVersions = useCallback(
(toolId: string) => getAdminSystemToolVersions({ toolId }),
[]
);
const heroSectionRef = useRef<HTMLDivElement>(null); const heroSectionRef = useRef<HTMLDivElement>(null);
const [showCompactSearch, setShowCompactSearch] = useState(false); const [showCompactSearch, setShowCompactSearch] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -479,7 +594,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -479,7 +594,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
}; };
}, []); }, []);
const displayTools: ToolCardItemType[] = useMemo(() => { const displayTools: MarketplaceToolCardItemType[] = useMemo(() => {
return ( return (
tools tools
?.map((tool) => { ?.map((tool) => {
...@@ -506,6 +621,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -506,6 +621,7 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
return parseI18nString(currentTag?.tagName || '', i18n.language) || ''; return parseI18nString(currentTag?.tagName || '', i18n.language) || '';
}), }),
installed: isInstalled, installed: isInstalled,
installedVersion: installedTool?.version,
update, update,
version: tool.version, version: tool.version,
etag: tool.etag, etag: tool.etag,
...@@ -882,12 +998,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -882,12 +998,12 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
variant="marketplace" variant="marketplace"
isInstallingOrDeleting={installingOrDeletingToolIds.has(tool.id)} isInstallingOrDeleting={installingOrDeletingToolIds.has(tool.id)}
isUpdating={updatingToolIds.has(tool.id)} isUpdating={updatingToolIds.has(tool.id)}
onInstall={() => handleInstallTool(tool)} onInstall={() => handleInstallToolWithErrorHandling(tool)}
onDelete={() => { onDelete={() => {
openMarketplaceUninstallConfirm(tool); openMarketplaceUninstallConfirm(tool);
return Promise.resolve(); return Promise.resolve();
}} }}
onUpdate={() => handleUpdateTool(tool)} onUpdate={() => handleUpdateToolWithErrorHandling(tool)}
onClickCard={() => setSelectedTool(tool)} onClickCard={() => setSelectedTool(tool)}
showActionButton={!tool.installed} showActionButton={!tool.installed}
/> />
...@@ -906,22 +1022,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -906,22 +1022,25 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
onClose={() => setSelectedTool(null)} onClose={() => setSelectedTool(null)}
selectedTool={selectedTool} selectedTool={selectedTool}
showPoint={false} showPoint={false}
onToggleInstall={(_, version) => handleInstallTool(selectedTool, version)} onToggleInstall={(_, version) =>
handleInstallToolWithErrorHandling(selectedTool, version)
}
onDelete={() => { onDelete={() => {
openMarketplaceUninstallConfirm(selectedTool); openMarketplaceUninstallConfirm(selectedTool);
return Promise.resolve(); return Promise.resolve();
}} }}
onUpdate={(version) => handleUpdateTool(selectedTool, version)} onUpdate={(version) => handleUpdateToolWithErrorHandling(selectedTool, version)}
isUpdating={updatingToolIds.has(selectedTool.id)} isUpdating={updatingToolIds.has(selectedTool.id)}
isLoading={installingOrDeletingToolIds.has(selectedTool.id)} isLoading={installingOrDeletingToolIds.has(selectedTool.id)}
installedVersion={selectedTool.installedVersion}
mode="admin" mode="admin"
showActionButton={!selectedTool.installed}
// TODO:这里复用 plugin 的类型,可以去掉 ts-ignore // TODO:这里复用 plugin 的类型,可以去掉 ts-ignore
//@ts-ignore //@ts-ignore
onFetchDetail={async (toolId: string, version?: string) => onFetchDetail={async (toolId: string, version?: string) =>
await getMarketplaceToolDetail({ toolId, version }) await getMarketplaceToolDetail({ toolId, version })
} }
onFetchVersions={getMarketplaceToolVersions} onFetchVersions={getMarketplaceToolVersions}
onFetchInstalledVersions={fetchInstalledToolVersions}
/> />
)} )}
...@@ -931,11 +1050,16 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => { ...@@ -931,11 +1050,16 @@ const ToolkitMarketplace = ({ marketplaceUrl }: { marketplaceUrl: string }) => {
onClose={() => setShowBatchUpdateDrawer(false)} onClose={() => setShowBatchUpdateDrawer(false)}
updatableTools={updatableTools} updatableTools={updatableTools}
onBatchUpdate={handleBatchUpdate} onBatchUpdate={handleBatchUpdate}
onUpdate={handleUpdateTool}
onDelete={openMarketplaceUninstallConfirm}
isBatchUpdating={isBatchUpdating} isBatchUpdating={isBatchUpdating}
singleUpdatingToolIds={updatingToolIds}
deletingToolIds={installingOrDeletingToolIds}
//@ts-ignore //@ts-ignore
onFetchDetail={async (toolId: string, version?: string) => onFetchDetail={async (toolId: string, version?: string) =>
await getMarketplaceToolDetail({ toolId, version }) await getMarketplaceToolDetail({ toolId, version })
} }
onFetchVersions={getMarketplaceToolVersions}
/> />
)} )}
<UninstallConfirmModal /> <UninstallConfirmModal />
......
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
InstallPluginFromUrlBodyType, InstallPluginFromUrlBodyType,
UploadPkgPluginResponseType UploadPkgPluginResponseType
} from '@fastgpt/global/openapi/core/plugin/admin/api'; } from '@fastgpt/global/openapi/core/plugin/admin/api';
import type { PluginInstallResultType } from '@fastgpt/global/sdk/fastgpt-plugin';
// Pkg plugin // Pkg plugin
export const uploadPkgPlugin = (formData: FormData) => export const uploadPkgPlugin = (formData: FormData) =>
...@@ -13,4 +14,4 @@ export const confirmPkgPluginUpload = (data: ConfirmUploadPkgPluginBodyType) => ...@@ -13,4 +14,4 @@ export const confirmPkgPluginUpload = (data: ConfirmUploadPkgPluginBodyType) =>
POST(`/core/plugin/admin/pkg/confirm`, data); POST(`/core/plugin/admin/pkg/confirm`, data);
export const intallPluginWithUrl = (data: InstallPluginFromUrlBodyType) => 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