feat: plugin auto upgrade strategy (#19758)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Novice <novice12185727@gmail.com>
This commit is contained in:
Junyan Qin (Chin)
2025-07-23 15:33:39 +08:00
committed by GitHub
parent e6913744ae
commit eaae79a581
305 changed files with 2524 additions and 581 deletions

View File

@@ -10,7 +10,7 @@ import type { ExposeRefs } from './install-multi'
import InstallMulti from './install-multi'
import { useInstallOrUpdate } from '@/service/use-plugins'
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list'
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-permission'
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { useMittContextSelector } from '@/context/mitt-context'
import Checkbox from '@/app/components/base/checkbox'
const i18nPrefix = 'plugin.installModal'

View File

@@ -40,6 +40,11 @@ import { PluginAuth } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { useAllToolProviders } from '@/service/use-tools'
import DeprecationNotice from '../base/deprecation-notice'
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
import useReferenceSetting from '../plugin-page/use-reference-setting'
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
import { useAppContext } from '@/context/app-context'
const i18nPrefix = 'plugin.action'
@@ -55,6 +60,8 @@ const DetailHeader = ({
onUpdate,
}: Props) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme()
const locale = useGetLanguage()
const { locale: currentLocale } = useI18N()
@@ -112,8 +119,24 @@ const DetailHeader = ({
setFalse: hideUpdateModal,
}] = useBoolean(false)
const handleUpdate = async () => {
const { referenceSetting } = useReferenceSetting()
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
const isAutoUpgradeEnabled = useMemo(() => {
if (!autoUpgradeInfo || !isFromMarketplace)
return false
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
return true
return false
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
const [isDowngrade, setIsDowngrade] = useState(false)
const handleUpdate = async (isDowngrade?: boolean) => {
if (isFromMarketplace) {
setIsDowngrade(!!isDowngrade)
showUpdateModal()
return
}
@@ -180,9 +203,6 @@ const DetailHeader = ({
}
}, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
// #plugin TODO# used in apps
// const usedInApps = 3
return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
<div className="flex">
@@ -201,7 +221,7 @@ const DetailHeader = ({
currentVersion={version}
onSelect={(state) => {
setTargetVersion(state)
handleUpdate()
handleUpdate(state.isDowngrade)
}}
trigger={
<Badge
@@ -221,6 +241,18 @@ const DetailHeader = ({
/>
}
/>
{/* Auto update info */}
{isAutoUpgradeEnabled && (
<Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
{/* add a a div to fix tooltip hover not show problem */}
<div>
<Badge className='mr-1 cursor-pointer px-1'>
<AutoUpdateLine className='size-3' />
</Badge>
</div>
</Tooltip>
)}
{(hasNewVersion || isFromGitHub) && (
<Button variant='secondary-accent' size='small' className='!h-5' onClick={() => {
if (isFromMarketplace) {
@@ -324,6 +356,7 @@ const DetailHeader = ({
{
isShowUpdateModal && (
<UpdateFromMarketplace
pluginId={plugin_id}
payload={{
category: detail.declaration.category,
originalPackageInfo: {
@@ -337,6 +370,7 @@ const DetailHeader = ({
}}
onCancel={hideUpdateModal}
onSave={handleUpdatedFromMarketplace}
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
/>
)
}

View File

@@ -17,14 +17,14 @@ import {
} from './context'
import InstallPluginDropdown from './install-plugin-dropdown'
import { useUploader } from './use-uploader'
import usePermission from './use-permission'
import useReferenceSetting from './use-reference-setting'
import DebugInfo from './debug-info'
import PluginTasks from './plugin-tasks'
import Button from '@/app/components/base/button'
import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal/modal'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import {
useRouter,
@@ -121,16 +121,16 @@ const PluginPage = ({
}, [packageId, bundleInfo])
const {
referenceSetting,
canManagement,
canDebugger,
canSetPermissions,
permissions,
setPermissions,
} = usePermission()
setReferenceSettings,
} = useReferenceSetting()
const [showPluginSettingModal, {
setTrue: setShowPluginSettingModal,
setFalse: setHidePluginSettingModal,
}] = useBoolean()
}] = useBoolean(false)
const [currentFile, setCurrentFile] = useState<File | null>(null)
const containerRef = usePluginPageContext(v => v.containerRef)
const options = usePluginPageContext(v => v.options)
@@ -276,10 +276,10 @@ const PluginPage = ({
}
{showPluginSettingModal && (
<PermissionSetModal
payload={permissions!}
<ReferenceSettingModal
payload={referenceSetting!}
onHide={setHidePluginSettingModal}
onSave={setPermissions}
onSave={setReferenceSettings}
/>
)}

View File

@@ -2,7 +2,7 @@ import { PermissionType } from '../types'
import { useAppContext } from '@/context/app-context'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { useMemo } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
@@ -19,14 +19,16 @@ const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean)
return isAdmin
}
const usePermission = () => {
const useReferenceSetting = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
const { data: permissions } = usePermissions()
const invalidatePermissions = useInvalidatePermissions()
const { mutate: updatePermission, isPending: isUpdatePending } = useMutationPermissions({
const { data } = useReferenceSettings()
// console.log(data)
const { permission: permissions } = data || {}
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const { mutate: updateReferenceSetting, isPending: isUpdatePending } = useMutationReferenceSettings({
onSuccess: () => {
invalidatePermissions()
invalidateReferenceSettings()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
@@ -36,18 +38,18 @@ const usePermission = () => {
const isAdmin = isCurrentWorkspaceManager || isCurrentWorkspaceOwner
return {
referenceSetting: data,
setReferenceSettings: updateReferenceSetting,
canManagement: hasPermission(permissions?.install_permission, isAdmin),
canDebugger: hasPermission(permissions?.debug_permission, isAdmin),
canSetPermissions: isAdmin,
permissions,
setPermissions: updatePermission,
isUpdatePending,
}
}
export const useCanInstallPluginFromMarketplace = () => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { canManagement } = usePermission()
const { canManagement } = useReferenceSetting()
const canInstallPluginFromMarketplace = useMemo(() => {
return enable_marketplace && canManagement
@@ -58,4 +60,4 @@ export const useCanInstallPluginFromMarketplace = () => {
}
}
export default usePermission
export default useReferenceSetting

View File

@@ -0,0 +1,9 @@
import type { AutoUpdateConfig } from './types'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types'
export const defaultValue: AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}

View File

@@ -0,0 +1,185 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY, type AutoUpdateConfig } from './types'
import Label from '../label'
import StrategyPicker from './strategy-picker'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import PluginsPicker from './plugins-picker'
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from './utils'
import { useAppContext } from '@/context/app-context'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import { RiTimeLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import { useModalContextSelector } from '@/context/modal-context'
const i18nPrefix = 'plugin.autoUpdate'
type Props = {
payload: AutoUpdateConfig
onChange: (payload: AutoUpdateConfig) => void
}
const SettingTimeZone: FC<{
children?: React.ReactNode
}> = ({
children,
}) => {
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
return (
<span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'language' })} >{children}</span>
)
}
const AutoUpdateSetting: FC<Props> = ({
payload,
onChange,
}) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const {
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
} = payload
const minuteFilter = useCallback((minutes: string[]) => {
return minutes.filter((m) => {
const time = Number.parseInt(m, 10)
return time % 15 === 0
})
}, [])
const strategyDescription = useMemo(() => {
switch (strategy_setting) {
case AUTO_UPDATE_STRATEGY.fixOnly:
return t(`${i18nPrefix}.strategy.fixOnly.selectedDescription`)
case AUTO_UPDATE_STRATEGY.latest:
return t(`${i18nPrefix}.strategy.latest.selectedDescription`)
default:
return ''
}
}, [strategy_setting, t])
const plugins = useMemo(() => {
switch (upgrade_mode) {
case AUTO_UPDATE_MODE.partial:
return include_plugins
case AUTO_UPDATE_MODE.exclude:
return exclude_plugins
default:
return []
}
}, [upgrade_mode, exclude_plugins, include_plugins])
const handlePluginsChange = useCallback((newPlugins: string[]) => {
if (upgrade_mode === AUTO_UPDATE_MODE.partial) {
onChange({
...payload,
include_plugins: newPlugins,
})
}
else if (upgrade_mode === AUTO_UPDATE_MODE.exclude) {
onChange({
...payload,
exclude_plugins: newPlugins,
})
}
}, [payload, upgrade_mode, onChange])
const handleChange = useCallback((key: keyof AutoUpdateConfig) => {
return (value: AutoUpdateConfig[keyof AutoUpdateConfig]) => {
onChange({
...payload,
[key]: value,
})
}
}, [payload, onChange])
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
return (
<div
className='group float-right flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt'
onClick={onClick}
>
<div className='flex w-0 grow items-center gap-x-1'>
<RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-tertiary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
)} />
{inputElem}
</div>
<div className='system-sm-regular text-text-tertiary'>{convertTimezoneToOffsetStr(timezone)}</div>
</div>
)
}, [timezone])
return (
<div className='self-stretch px-6'>
<div className='my-3 flex items-center'>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.updateSettings`)}</div>
<div className='ml-2 h-px grow bg-divider-subtle'></div>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<Label label={t(`${i18nPrefix}.automaticUpdates`)} description={strategyDescription} />
<StrategyPicker value={strategy_setting} onChange={handleChange('strategy_setting')} />
</div>
{strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && (
<>
<div className='flex items-center justify-between'>
<Label label={t(`${i18nPrefix}.updateTime`)} />
<div className='flex flex-col justify-start'>
<TimePicker
value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))}
timezone={timezone}
onChange={v => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!))}
onClear={() => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(0, timezone!))}
popupClassName='z-[99]'
title={t(`${i18nPrefix}.updateTime`)}
minuteFilter={minuteFilter}
renderTrigger={renderTimePickerTrigger}
/>
<div className='body-xs-regular mt-1 text-right text-text-tertiary'>
<Trans
i18nKey={`${i18nPrefix}.changeTimezone`}
components={{
setTimezone: <SettingTimeZone />,
}}
/>
</div>
</div>
</div>
<div>
<Label label={t(`${i18nPrefix}.specifyPluginsToUpdate`)} />
<div className='mt-1 flex w-full items-start justify-between gap-2'>
{[AUTO_UPDATE_MODE.update_all, AUTO_UPDATE_MODE.exclude, AUTO_UPDATE_MODE.partial].map(option => (
<OptionCard
key={option}
title={t(`${i18nPrefix}.upgradeMode.${option}`)}
onSelect={() => handleChange('upgrade_mode')(option)}
selected={upgrade_mode === option}
className="flex-1"
/>
))}
</div>
{upgrade_mode !== AUTO_UPDATE_MODE.update_all && (
<PluginsPicker
value={plugins}
onChange={handlePluginsChange}
updateMode={upgrade_mode}
/>
)}
</div>
</>
)}
</div>
</div>
)
}
export default React.memo(AutoUpdateSetting)

View File

@@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { useTranslation } from 'react-i18next'
type Props = {
className: string
noPlugins?: boolean
}
const NoDataPlaceholder: FC<Props> = ({
className,
noPlugins,
}) => {
const { t } = useTranslation()
const icon = noPlugins ? (<Group className='size-6 text-text-quaternary' />) : (<SearchMenu className='size-8 text-text-tertiary' />)
const text = t(`plugin.autoUpdate.noPluginPlaceholder.${noPlugins ? 'noInstalled' : 'noFound'}`)
return (
<div className={cn('flex items-center justify-center', className)}>
<div className='flex flex-col items-center'>
{icon}
<div className='system-sm-regular mt-2 text-text-tertiary'>{text}</div>
</div>
</div>
)
}
export default React.memo(NoDataPlaceholder)

View File

@@ -0,0 +1,22 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { AUTO_UPDATE_MODE } from './types'
import { useTranslation } from 'react-i18next'
type Props = {
updateMode: AUTO_UPDATE_MODE
}
const NoPluginSelected: FC<Props> = ({
updateMode,
}) => {
const { t } = useTranslation()
const text = `${t(`plugin.autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`)}`
return (
<div className='system-xs-regular rounded-[10px] border border-[divider-subtle] bg-background-section p-3 text-center text-text-tertiary'>
{text}
</div>
)
}
export default React.memo(NoPluginSelected)

View File

@@ -0,0 +1,69 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import NoPluginSelected from './no-plugin-selected'
import { AUTO_UPDATE_MODE } from './types'
import PluginsSelected from './plugins-selected'
import Button from '@/app/components/base/button'
import { RiAddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import ToolPicker from './tool-picker'
const i18nPrefix = 'plugin.autoUpdate'
type Props = {
updateMode: AUTO_UPDATE_MODE
value: string[] // plugin ids
onChange: (value: string[]) => void
}
const PluginsPicker: FC<Props> = ({
updateMode,
value,
onChange,
}) => {
const { t } = useTranslation()
const hasSelected = value.length > 0
const isExcludeMode = updateMode === AUTO_UPDATE_MODE.exclude
const handleClear = () => {
onChange([])
}
const [isShowToolPicker, {
set: setToolPicker,
}] = useBoolean(false)
return (
<div className='mt-2 rounded-[10px] bg-background-section-burn p-2.5'>
{hasSelected ? (
<div className='flex justify-between text-text-tertiary'>
<div className='system-xs-medium'>{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { num: value.length })}</div>
<div className='system-xs-medium cursor-pointer' onClick={handleClear}>{t(`${i18nPrefix}.operation.clearAll`)}</div>
</div>
) : (
<NoPluginSelected updateMode={updateMode} />
)}
{hasSelected && (
<PluginsSelected
className='mt-2'
plugins={value}
/>
)}
<ToolPicker
trigger={
<Button className='mt-2 w-[412px]' size='small' variant='secondary-accent'>
<RiAddLine className='size-3.5' />
{t(`${i18nPrefix}.operation.select`)}
</Button>
}
value={value}
onChange={onChange}
isShow={isShowToolPicker}
onShowChange={setToolPicker}
/>
</div>
)
}
export default React.memo(PluginsPicker)

View File

@@ -0,0 +1,29 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Icon from '@/app/components/plugins/card/base/card-icon'
const MAX_DISPLAY_COUNT = 14
type Props = {
className?: string
plugins: string[]
}
const PluginsSelected: FC<Props> = ({
className,
plugins,
}) => {
const isShowAll = plugins.length < MAX_DISPLAY_COUNT
const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT)
return (
<div className={cn('flex items-center space-x-1', className)}>
{displayPlugins.map(plugin => (
<Icon key={plugin} size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin}/icon`} />
))}
{!isShowAll && <div className='system-xs-medium text-text-tertiary'>+{plugins.length - MAX_DISPLAY_COUNT}</div>}
</div>
)
}
export default React.memo(PluginsSelected)

View File

@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { AUTO_UPDATE_STRATEGY } from './types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
const i18nPrefix = 'plugin.autoUpdate.strategy'
type Props = {
value: AUTO_UPDATE_STRATEGY
onChange: (value: AUTO_UPDATE_STRATEGY) => void
}
const StrategyPicker = ({
value,
onChange,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: AUTO_UPDATE_STRATEGY.disabled,
label: t(`${i18nPrefix}.disabled.name`),
description: t(`${i18nPrefix}.disabled.description`),
},
{
value: AUTO_UPDATE_STRATEGY.fixOnly,
label: t(`${i18nPrefix}.fixOnly.name`),
description: t(`${i18nPrefix}.fixOnly.description`),
},
{
value: AUTO_UPDATE_STRATEGY.latest,
label: t(`${i18nPrefix}.latest.name`),
description: t(`${i18nPrefix}.latest.description`),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
size='small'
>
{selectedOption?.label}
<RiArrowDownSLine className='h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[99]'>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onChange(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default StrategyPicker

View File

@@ -0,0 +1,45 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { renderI18nObject } from '@/i18n'
import { useGetLanguage } from '@/context/i18n'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Checkbox from '@/app/components/base/checkbox'
type Props = {
payload: PluginDetail
isChecked?: boolean
onCheckChange: () => void
}
const ToolItem: FC<Props> = ({
payload,
isChecked,
onCheckChange,
}) => {
const language = useGetLanguage()
const { plugin_id, declaration } = payload
const { label, author: org } = declaration
return (
<div className='p-1'>
<div
className='flex w-full select-none items-center rounded-lg pr-2 hover:bg-state-base-hover'
>
<div className='flex h-8 grow items-center space-x-2 pl-3 pr-2'>
<Icon size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`} />
<div className='system-sm-medium max-w-[150px] shrink-0 truncate text-text-primary'>{renderI18nObject(label, language)}</div>
<div className='system-xs-regular max-w-[150px] shrink-0 truncate text-text-quaternary'>{org}</div>
</div>
<Checkbox
checked={isChecked}
onCheck={onCheckChange}
className='shrink-0'
/>
</div>
</div>
)
}
export default React.memo(ToolItem)

View File

@@ -0,0 +1,167 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useInstalledPluginList } from '@/service/use-plugins'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import ToolItem from './tool-item'
import Loading from '@/app/components/base/loading'
import NoDataPlaceholder from './no-data-placeholder'
import { PluginSource } from '../../types'
type Props = {
trigger: React.ReactNode
value: string[]
onChange: (value: string[]) => void
isShow: boolean
onShowChange: (isShow: boolean) => void
}
const ToolPicker: FC<Props> = ({
trigger,
value,
onChange,
isShow,
onShowChange,
}) => {
const { t } = useTranslation()
const toggleShowPopup = useCallback(() => {
onShowChange(!isShow)
}, [onShowChange, isShow])
const tabs = [
{
key: PLUGIN_TYPE_SEARCH_MAP.all,
name: t('plugin.category.all'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.model,
name: t('plugin.category.models'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.tool,
name: t('plugin.category.tools'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.agent,
name: t('plugin.category.agents'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.extension,
name: t('plugin.category.extensions'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
name: t('plugin.category.bundles'),
},
]
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const [query, setQuery] = useState('')
const [tags, setTags] = useState<string[]>([])
const { data, isLoading } = useInstalledPluginList()
const filteredList = useMemo(() => {
const list = data ? data.plugins : []
return list.filter((plugin) => {
const isFromMarketPlace = plugin.source === PluginSource.marketplace
return (
isFromMarketPlace && (pluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === pluginType)
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
&& (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase()))
)
})
}, [data, pluginType, query, tags])
const handleCheckChange = useCallback((pluginId: string) => {
return () => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
}, [onChange, value])
const listContent = (
<div className='max-h-[396px] overflow-y-auto'>
{filteredList.map(item => (
<ToolItem
key={item.plugin_id}
payload={item}
isChecked={value.includes(item.plugin_id)}
onCheckChange={handleCheckChange(item.plugin_id)}
/>
))}
</div>
)
const loadingContent = (
<div className='flex h-[396px] items-center justify-center'>
<Loading />
</div>
)
const noData = (
<NoDataPlaceholder className='h-[396px]' noPlugins={!query} />
)
return (
<PortalToFollowElem
placement='top'
offset={0}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
onClick={toggleShowPopup}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('relative min-h-20 w-[436px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-sm')}>
<div className='p-2 pb-1'>
<SearchBox
search={query}
onSearchChange={setQuery}
tags={tags}
onTagsChange={setTags}
size='small'
placeholder={t('plugin.searchTools')!}
inputClassName='w-full'
/>
</div>
<div className='flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs'>
<div className='flex h-8 items-center space-x-1'>
{
tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))
}
</div>
</div>
{!isLoading && filteredList.length > 0 && listContent}
{!isLoading && filteredList.length === 0 && noData}
{isLoading && loadingContent}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(ToolPicker)

View File

@@ -0,0 +1,19 @@
export enum AUTO_UPDATE_STRATEGY {
fixOnly = 'fix_only',
disabled = 'disabled',
latest = 'latest',
}
export enum AUTO_UPDATE_MODE {
partial = 'partial',
exclude = 'exclude',
update_all = 'all',
}
export type AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY
upgrade_time_of_day: number
upgrade_mode: AUTO_UPDATE_MODE
exclude_plugins: string[]
include_plugins: string[]
}

View File

@@ -0,0 +1,14 @@
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from './utils'
describe('convertLocalSecondsToUTCDaySeconds', () => {
it('should convert local seconds to UTC day seconds correctly', () => {
const localTimezone = 'Asia/Shanghai'
const utcSeconds = convertLocalSecondsToUTCDaySeconds(0, localTimezone)
expect(utcSeconds).toBe((24 - 8) * 3600)
})
it('should convert local seconds to UTC day seconds for a specific time', () => {
const localTimezone = 'Asia/Shanghai'
expect(convertUTCDaySecondsToLocalSeconds(convertLocalSecondsToUTCDaySeconds(0, localTimezone), localTimezone)).toBe(0)
})
})

View File

@@ -0,0 +1,37 @@
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export const timeOfDayToDayjs = (timeOfDay: number): Dayjs => {
const hours = Math.floor(timeOfDay / 3600)
const minutes = (timeOfDay - hours * 3600) / 60
const res = dayjs().startOf('day').hour(hours).minute(minutes)
return res
}
export const convertLocalSecondsToUTCDaySeconds = (secondsInDay: number, localTimezone: string): number => {
const localDayStart = dayjs().tz(localTimezone).startOf('day')
const localTargetTime = localDayStart.add(secondsInDay, 'second')
const utcTargetTime = localTargetTime.utc()
const utcDayStart = utcTargetTime.startOf('day')
const secondsFromUTCMidnight = utcTargetTime.diff(utcDayStart, 'second')
return secondsFromUTCMidnight
}
export const dayjsToTimeOfDay = (date?: Dayjs): number => {
if (!date) return 0
return date.hour() * 3600 + date.minute() * 60
}
export const convertUTCDaySecondsToLocalSeconds = (utcDaySeconds: number, localTimezone: string): number => {
const utcDayStart = dayjs().utc().startOf('day')
const utcTargetTime = utcDayStart.add(utcDaySeconds, 'second')
const localTargetTime = utcTargetTime.tz(localTimezone)
const localDayStart = localTargetTime.startOf('day')
const secondsInLocalDay = localTargetTime.diff(localDayStart, 'second')
return secondsInLocalDay
}

View File

@@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
label: string
description?: string
}
const Label: FC<Props> = ({
label,
description,
}) => {
return (
<div>
<div className={cn('flex h-6 items-center', description && 'h-4')}>
<span className='system-sm-semibold text-text-secondary'>{label}</span>
</div>
{description && (
<div className='body-xs-regular mt-1 text-text-tertiary'>
{description}
</div>
)}
</div>
)
}
export default React.memo(Label)

View File

@@ -5,14 +5,18 @@ import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import Button from '@/app/components/base/button'
import type { Permissions } from '@/app/components/plugins/types'
import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types'
import { PermissionType } from '@/app/components/plugins/types'
import type { AutoUpdateConfig } from './auto-update-setting/types'
import AutoUpdateSetting from './auto-update-setting'
import { defaultValue as autoUpdateDefaultValue } from './auto-update-setting/config'
import Label from './label'
const i18nPrefix = 'plugin.privilege'
type Props = {
payload: Permissions
payload: ReferenceSetting
onHide: () => void
onSave: (payload: Permissions) => void
onSave: (payload: ReferenceSetting) => void
}
const PluginSettingModal: FC<Props> = ({
@@ -21,7 +25,9 @@ const PluginSettingModal: FC<Props> = ({
onSave,
}) => {
const { t } = useTranslation()
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(payload)
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {}
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(privilege)
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(autoUpdateConfig || autoUpdateDefaultValue)
const handlePrivilegeChange = useCallback((key: string) => {
return (value: PermissionType) => {
setTempPrivilege({
@@ -32,18 +38,21 @@ const PluginSettingModal: FC<Props> = ({
}, [tempPrivilege])
const handleSave = useCallback(async () => {
await onSave(tempPrivilege)
await onSave({
permission: tempPrivilege,
auto_upgrade: tempAutoUpdateConfig,
})
onHide()
}, [onHide, onSave, tempPrivilege])
}, [onHide, onSave, tempAutoUpdateConfig, tempPrivilege])
return (
<Modal
isShow
onClose={onHide}
closable
className='w-[420px] !p-0'
className='w-[480px] !p-0'
>
<div className='shadows-shadow-xl flex w-[420px] flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
<div className='shadows-shadow-xl flex w-[480px] flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
<span className='title-2xl-semi-bold self-stretch text-text-primary'>{t(`${i18nPrefix}.title`)}</span>
</div>
@@ -53,9 +62,7 @@ const PluginSettingModal: FC<Props> = ({
{ title: t(`${i18nPrefix}.whoCanDebug`), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne },
].map(({ title, key, value }) => (
<div key={key} className='flex flex-col items-start gap-1 self-stretch'>
<div className='flex h-6 items-center gap-0.5'>
<span className='system-sm-semibold text-text-secondary'>{title}</span>
</div>
<Label label={title} />
<div className='flex w-full items-start justify-between gap-2'>
{[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => (
<OptionCard
@@ -70,6 +77,8 @@ const PluginSettingModal: FC<Props> = ({
</div>
))}
</div>
<AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} />
<div className='flex h-[76px] items-center justify-end gap-2 self-stretch p-6 pt-5'>
<Button
className='min-w-[72px]'

View File

@@ -2,6 +2,7 @@ import type { CredentialFormSchemaBase } from '../header/account-setting/model-p
import type { ToolCredential } from '@/app/components/tools/types'
import type { Locale } from '@/i18n'
import type { AgentFeature } from '@/app/components/workflow/nodes/agent/types'
import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types'
export enum PluginType {
tool = 'tool',
model = 'model',
@@ -170,6 +171,11 @@ export type Permissions = {
debug_permission: PermissionType
}
export type ReferenceSetting = {
permission: Permissions
auto_upgrade: AutoUpdateConfig
}
export type UpdateFromMarketPlacePayload = {
category: PluginType
originalPackageInfo: {

View File

@@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const i18nPrefix = 'plugin.autoUpdate.pluginDowngradeWarning'
type Props = {
onCancel: () => void
onJustDowngrade: () => void
onExcludeAndDowngrade: () => void
}
const DowngradeWarningModal = ({
onCancel,
onJustDowngrade,
onExcludeAndDowngrade,
}: Props) => {
const { t } = useTranslation()
return (
<>
<div className='flex flex-col items-start gap-2 self-stretch'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${i18nPrefix}.title`)}</div>
<div className='system-md-regular text-text-secondary'>
{t(`${i18nPrefix}.description`)}
</div>
</div>
<div className='mt-9 flex items-start justify-end space-x-2 self-stretch'>
<Button variant='secondary' onClick={() => onCancel()}>{t('app.newApp.Cancel')}</Button>
<Button variant='secondary' destructive onClick={onJustDowngrade}>{t(`${i18nPrefix}.downgrade`)}</Button>
<Button variant='primary' onClick={onExcludeAndDowngrade}>{t(`${i18nPrefix}.exclude`)}</Button>
</div>
</>
)
}
export default DowngradeWarningModal

View File

@@ -13,13 +13,18 @@ import { updateFromMarketPlace } from '@/service/plugins'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import { usePluginTaskList } from '@/service/use-plugins'
import Toast from '../../base/toast'
import DowngradeWarningModal from './downgrade-warning'
import { useInvalidateReferenceSettings, useRemoveAutoUpgrade } from '@/service/use-plugins'
import cn from '@/utils/classnames'
const i18nPrefix = 'plugin.upgrade'
type Props = {
payload: UpdateFromMarketPlacePayload
pluginId: string
onSave: () => void
onCancel: () => void
isShowDowngradeWarningModal?: boolean
}
enum UploadStep {
@@ -30,8 +35,10 @@ enum UploadStep {
const UpdatePluginModal: FC<Props> = ({
payload,
pluginId,
onSave,
onCancel,
isShowDowngradeWarningModal,
}) => {
const {
originalPackageInfo,
@@ -103,51 +110,74 @@ const UpdatePluginModal: FC<Props> = ({
onSave()
}, [onSave, uploadStep, check, originalPackageInfo.id, handleRefetch, targetPackageInfo.id])
const { mutateAsync } = useRemoveAutoUpgrade()
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const handleExcludeAndDownload = async () => {
await mutateAsync({
plugin_id: pluginId,
})
invalidateReferenceSettings()
handleConfirm()
}
const doShowDowngradeWarningModal = isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted
return (
<Modal
isShow={true}
onClose={onCancel}
className='min-w-[560px]'
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
closable
title={t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
title={!doShowDowngradeWarningModal && t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
>
<div className='system-md-regular mb-2 mt-3 text-text-secondary'>
{t(`${i18nPrefix}.description`)}
</div>
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
<Card
installed={uploadStep === UploadStep.installed}
payload={pluginManifestToCardPluginProps({
...originalPackageInfo.payload,
icon: icon!,
})}
className='w-full'
titleLeft={
<>
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
</Badge>
</>
}
{doShowDowngradeWarningModal && (
<DowngradeWarningModal
onCancel={onCancel}
onJustDowngrade={handleConfirm}
onExcludeAndDowngrade={handleExcludeAndDownload}
/>
</div>
<div className='flex items-center justify-end gap-2 self-stretch pt-5'>
{uploadStep === UploadStep.notStarted && (
)}
{!doShowDowngradeWarningModal && (
<>
<div className='system-md-regular mb-2 mt-3 text-text-secondary'>
{t(`${i18nPrefix}.description`)}
</div>
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
<Card
installed={uploadStep === UploadStep.installed}
payload={pluginManifestToCardPluginProps({
...originalPackageInfo.payload,
icon: icon!,
})}
className='w-full'
titleLeft={
<>
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
</Badge>
</>
}
/>
</div>
<div className='flex items-center justify-end gap-2 self-stretch pt-5'>
{uploadStep === UploadStep.notStarted && (
<Button
onClick={handleCancel}
>
{t('common.operation.cancel')}
</Button>
)}
<Button
onClick={handleCancel}
variant='primary'
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{t('common.operation.cancel')}
{configBtnText}
</Button>
)}
<Button
variant='primary'
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{configBtnText}
</Button>
</div>
</div>
</>
)}
</Modal>
)
}

View File

@@ -15,6 +15,7 @@ import type {
import { useVersionListOfPlugin } from '@/service/use-plugins'
import useTimestamp from '@/hooks/use-timestamp'
import cn from '@/utils/classnames'
import { lt } from 'semver'
type Props = {
disabled?: boolean
@@ -28,9 +29,11 @@ type Props = {
onSelect: ({
version,
unique_identifier,
isDowngrade,
}: {
version: string
unique_identifier: string
isDowngrade: boolean
}) => void
}
@@ -59,13 +62,14 @@ const PluginVersionPicker: FC<Props> = ({
const { data: res } = useVersionListOfPlugin(pluginID)
const handleSelect = useCallback(({ version, unique_identifier }: {
const handleSelect = useCallback(({ version, unique_identifier, isDowngrade }: {
version: string
unique_identifier: string
isDowngrade: boolean
}) => {
if (currentVersion === version)
return
onSelect({ version, unique_identifier })
onSelect({ version, unique_identifier, isDowngrade })
onShowChange(false)
}, [currentVersion, onSelect, onShowChange])
@@ -99,6 +103,7 @@ const PluginVersionPicker: FC<Props> = ({
onClick={() => handleSelect({
version: version.version,
unique_identifier: version.unique_identifier,
isDowngrade: lt(version.version, currentVersion),
})}
>
<div className='flex grow items-center'>