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:

committed by
GitHub

parent
e6913744ae
commit
eaae79a581
@@ -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: [],
|
||||
}
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -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)
|
@@ -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[]
|
||||
}
|
@@ -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)
|
||||
})
|
||||
})
|
@@ -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
|
||||
}
|
Reference in New Issue
Block a user