tool oauth

pull/22338/head^2
zxhlyh 7 months ago
parent 90f800408d
commit cb0082c0b8

@ -18,7 +18,6 @@ import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator' import Indicator from '@/app/components/header/indicator'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import ConfigContext from '@/context/debug-configuration' import ConfigContext from '@/context/debug-configuration'
import type { AgentTool } from '@/types/app' import type { AgentTool } from '@/types/app'
import { type Collection, CollectionType } from '@/app/components/tools/types' import { type Collection, CollectionType } from '@/app/components/tools/types'
@ -26,8 +25,6 @@ import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other' import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { updateBuiltInToolCredential } from '@/service/tools'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
@ -57,13 +54,7 @@ const AgentTools: FC = () => {
const formattingChangedDispatcher = useFormattingChangedDispatcher() const formattingChangedDispatcher = useFormattingChangedDispatcher()
const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null) const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
const currentCollection = useMemo(() => {
if (!currentTool) return null
const collection = collectionList.find(collection => canFindTool(collection.id, currentTool?.provider_id) && collection.type === currentTool?.provider_type)
return collection
}, [currentTool, collectionList])
const [isShowSettingTool, setIsShowSettingTool] = useState(false) const [isShowSettingTool, setIsShowSettingTool] = useState(false)
const [isShowSettingAuth, setShowSettingAuth] = useState(false)
const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => { const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => {
const collection = collectionList.find( const collection = collectionList.find(
collection => collection =>
@ -100,17 +91,6 @@ const AgentTools: FC = () => {
formattingChangedDispatcher() formattingChangedDispatcher()
} }
const handleToolAuthSetting = (value: AgentToolWithMoreInfo) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === value?.collection?.id && item.tool_name === value?.tool_name)
if (tool)
(tool as AgentTool).notAuthor = false
})
setModelConfig(newModelConfig)
setIsShowSettingTool(false)
formattingChangedDispatcher()
}
const [isDeleting, setIsDeleting] = useState<number>(-1) const [isDeleting, setIsDeleting] = useState<number>(-1)
const getToolValue = (tool: ToolDefaultValue) => { const getToolValue = (tool: ToolDefaultValue) => {
return { return {
@ -144,6 +124,20 @@ const AgentTools: FC = () => {
return item.provider_name return item.provider_name
} }
const handleAuthorizationItemClick = useCallback((credentialId: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).credential_id = credentialId
})
setCurrentTool({
...currentTool,
credential_id: credentialId,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
return ( return (
<> <>
<Panel <Panel
@ -302,7 +296,7 @@ const AgentTools: FC = () => {
{item.notAuthor && ( {item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => { <Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item) setCurrentTool(item)
setShowSettingAuth(true) setIsShowSettingTool(true)
}}> }}>
{t('tools.notAuthorized')} {t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' /> <Indicator className='ml-2' color='orange' />
@ -322,21 +316,8 @@ const AgentTools: FC = () => {
isModel={currentTool?.collection?.type === CollectionType.model} isModel={currentTool?.collection?.type === CollectionType.model}
onSave={handleToolSettingChange} onSave={handleToolSettingChange}
onHide={() => setIsShowSettingTool(false)} onHide={() => setIsShowSettingTool(false)}
/> credentialId={currentTool?.credential_id}
)} onAuthorizationItemClick={handleAuthorizationItemClick}
{isShowSettingAuth && (
<ConfigCredential
collection={currentCollection as any}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential((currentCollection as any).name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
handleToolAuthSetting(currentTool)
setShowSettingAuth(false)
}}
/> />
)} )}
</> </>

@ -14,7 +14,6 @@ import Icon from '@/app/components/plugins/card/base/card-icon'
import OrgInfo from '@/app/components/plugins/card/base/org-info' import OrgInfo from '@/app/components/plugins/card/base/org-info'
import Description from '@/app/components/plugins/card/base/description' import Description from '@/app/components/plugins/card/base/description'
import TabSlider from '@/app/components/base/tab-slider-plain' import TabSlider from '@/app/components/base/tab-slider-plain'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@ -25,6 +24,10 @@ import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language' import { getLanguage } from '@/i18n/language'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { ToolWithProvider } from '@/app/components/workflow/types' import type { ToolWithProvider } from '@/app/components/workflow/types'
import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
type Props = { type Props = {
showBackButton?: boolean showBackButton?: boolean
@ -36,6 +39,8 @@ type Props = {
readonly?: boolean readonly?: boolean
onHide: () => void onHide: () => void
onSave?: (value: Record<string, any>) => void onSave?: (value: Record<string, any>) => void
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
} }
const SettingBuiltInTool: FC<Props> = ({ const SettingBuiltInTool: FC<Props> = ({
@ -48,6 +53,8 @@ const SettingBuiltInTool: FC<Props> = ({
readonly, readonly,
onHide, onHide,
onSave, onSave,
credentialId,
onAuthorizationItemClick,
}) => { }) => {
const { locale } = useContext(I18n) const { locale } = useContext(I18n)
const language = getLanguage(locale) const language = getLanguage(locale)
@ -197,8 +204,20 @@ const SettingBuiltInTool: FC<Props> = ({
</div> </div>
<div className='system-md-semibold mt-1 text-text-primary'>{currTool?.label[language]}</div> <div className='system-md-semibold mt-1 text-text-primary'>{currTool?.label[language]}</div>
{!!currTool?.description[language] && ( {!!currTool?.description[language] && (
<Description className='mt-3' text={currTool.description[language]} descriptionLineRows={2}></Description> <Description className='mb-2 mt-3 h-auto' text={currTool.description[language]} descriptionLineRows={2}></Description>
)} )}
{
collection.allow_delete && collection.type === CollectionType.builtIn && (
<PluginAuthInAgent
pluginPayload={{
provider: collection.name,
category: AuthCategory.tool,
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
/>
)
}
</div> </div>
{/* form */} {/* form */}
<div className='h-full'> <div className='h-full'>

@ -1,5 +1,6 @@
import { import {
memo, memo,
useCallback,
useState, useState,
} from 'react' } from 'react'
import { RiEqualizer2Line } from '@remixicon/react' import { RiEqualizer2Line } from '@remixicon/react'
@ -8,6 +9,10 @@ import type { ButtonProps } from '@/app/components/base/button'
import OAuthClientSettings from './oauth-client-settings' import OAuthClientSettings from './oauth-client-settings'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { PluginPayload } from '../types' import type { PluginPayload } from '../types'
import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useGetPluginOAuthUrlHook,
} from '../hooks/use-credential'
export type AddOAuthButtonProps = { export type AddOAuthButtonProps = {
pluginPayload: PluginPayload pluginPayload: PluginPayload
@ -20,6 +25,7 @@ export type AddOAuthButtonProps = {
disabled?: boolean disabled?: boolean
} }
const AddOAuthButton = ({ const AddOAuthButton = ({
pluginPayload,
buttonVariant = 'primary', buttonVariant = 'primary',
buttonText = 'use oauth', buttonText = 'use oauth',
className, className,
@ -29,6 +35,20 @@ const AddOAuthButton = ({
disabled, disabled,
}: AddOAuthButtonProps) => { }: AddOAuthButtonProps) => {
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false) const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
const handleOAuth = useCallback(async () => {
const { authorization_url } = await getPluginOAuthUrl()
if (authorization_url) {
openOAuthPopup(
authorization_url,
() => {
console.log('success')
},
)
}
}, [getPluginOAuthUrl])
return ( return (
<> <>
@ -39,6 +59,7 @@ const AddOAuthButton = ({
className, className,
)} )}
disabled={disabled} disabled={disabled}
onClick={handleOAuth}
> >
<div className={cn( <div className={cn(
'flex h-full grow items-center justify-center rounded-l-lg hover:bg-components-button-primary-bg-hover', 'flex h-full grow items-center justify-center rounded-l-lg hover:bg-components-button-primary-bg-hover',
@ -55,7 +76,10 @@ const AddOAuthButton = ({
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover', 'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
buttonRightClassName, buttonRightClassName,
)} )}
onClick={() => setIsOAuthSettingsOpen(true)} onClick={(e) => {
e.stopPropagation()
setIsOAuthSettingsOpen(true)
}}
> >
<RiEqualizer2Line className='h-4 w-4' /> <RiEqualizer2Line className='h-4 w-4' />
</div> </div>
@ -63,7 +87,9 @@ const AddOAuthButton = ({
{ {
isOAuthSettingsOpen && ( isOAuthSettingsOpen && (
<OAuthClientSettings <OAuthClientSettings
pluginPayload={pluginPayload}
onClose={() => setIsOAuthSettingsOpen(false)} onClose={() => setIsOAuthSettingsOpen(false)}
disabled={disabled}
/> />
) )
} }

@ -1,12 +1,80 @@
import { memo } from 'react' import {
memo,
useCallback,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal/modal' import Modal from '@/app/components/base/modal/modal'
import {
useGetPluginOAuthClientSchemaHook,
useInvalidPluginCredentialInfoHook,
useSetPluginOAuthCustomClientHook,
} from '../hooks/use-credential'
import type { PluginPayload } from '../types'
import Loading from '@/app/components/base/loading'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import type { FromRefObject } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { transformFormSchemasSecretInput } from '../utils'
import { useToastContext } from '@/app/components/base/toast'
type OAuthClientSettingsProps = { type OAuthClientSettingsProps = {
pluginPayload: PluginPayload
onClose?: () => void onClose?: () => void
editValues?: Record<string, any>
disabled?: boolean
} }
const OAuthClientSettings = ({ const OAuthClientSettings = ({
pluginPayload,
onClose, onClose,
editValues,
disabled,
}: OAuthClientSettingsProps) => { }: OAuthClientSettingsProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
data,
isLoading,
} = useGetPluginOAuthClientSchemaHook(pluginPayload)
const formSchemas = useMemo(() => {
return data?.schema || []
}, [data])
const defaultValues = formSchemas.reduce((acc, schema) => {
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
const invalidatePluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const formRef = useRef<FromRefObject>(null)
const handleConfirm = useCallback(async () => {
const form = formRef.current?.getForm()
const store = form?.store
const values = store?.state.values
const isPristineSecretInputNames: string[] = []
formSchemas.forEach((schema) => {
if (schema.type === FormTypeEnum.secretInput) {
const fieldMeta = form?.getFieldMeta(schema.name)
if (fieldMeta?.isPristine)
isPristineSecretInputNames.push(schema.name)
}
})
const transformedValues = transformFormSchemasSecretInput(isPristineSecretInputNames, values)
await setPluginOAuthCustomClient({
client_params: transformedValues,
enable_oauth_custom_client: true,
})
notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
onClose?.()
invalidatePluginCredentialInfo()
}, [onClose, invalidatePluginCredentialInfo, setPluginOAuthCustomClient, notify, t, formSchemas])
return ( return (
<Modal <Modal
title='Oauth client settings' title='Oauth client settings'
@ -17,8 +85,25 @@ const OAuthClientSettings = ({
extraButtonVariant='secondary' extraButtonVariant='secondary'
onExtraButtonClick={onClose} onExtraButtonClick={onClose}
onClose={onClose} onClose={onClose}
onConfirm={handleConfirm}
> >
<div>oauth</div> {
isLoading && (
<div className='flex h-40 items-center justify-center'>
<Loading />
</div>
)
}
{
!isLoading && !!data?.schema.length && (
<AuthForm
ref={formRef}
formSchemas={formSchemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
)
}
</Modal> </Modal>
) )
} }

@ -103,6 +103,7 @@ const AuthorizedInNode = ({
onItemClick={handleAuthorizationItemClick} onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems} extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon showItemSelectedIcon
selectedCredentialId={credentialId || '__workspace_default__'}
/> />
) )
} }

@ -51,6 +51,7 @@ type AuthorizedProps = {
onItemClick?: (id: string) => void onItemClick?: (id: string) => void
extraAuthorizationItems?: Credential[] extraAuthorizationItems?: Credential[]
showItemSelectedIcon?: boolean showItemSelectedIcon?: boolean
selectedCredentialId?: string
} }
const Authorized = ({ const Authorized = ({
pluginPayload, pluginPayload,
@ -69,6 +70,7 @@ const Authorized = ({
onItemClick, onItemClick,
extraAuthorizationItems, extraAuthorizationItems,
showItemSelectedIcon, showItemSelectedIcon,
selectedCredentialId,
}: AuthorizedProps) => { }: AuthorizedProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = useToastContext() const { notify } = useToastContext()
@ -195,6 +197,7 @@ const Authorized = ({
disableDelete disableDelete
disableSetDefault disableSetDefault
showSelectedIcon={showItemSelectedIcon} showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/> />
)) ))
} }
@ -223,6 +226,7 @@ const Authorized = ({
disableSetDefault={disableSetDefault} disableSetDefault={disableSetDefault}
onItemClick={onItemClick} onItemClick={onItemClick}
showSelectedIcon={showItemSelectedIcon} showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/> />
)) ))
} }
@ -252,6 +256,7 @@ const Authorized = ({
onItemClick={onItemClick} onItemClick={onItemClick}
onRename={handleRename} onRename={handleRename}
showSelectedIcon={showItemSelectedIcon} showSelectedIcon={showItemSelectedIcon}
selectedCredentialId={selectedCredentialId}
/> />
)) ))
} }

@ -36,6 +36,7 @@ type ItemProps = {
disableSetDefault?: boolean disableSetDefault?: boolean
onItemClick?: (id: string) => void onItemClick?: (id: string) => void
showSelectedIcon?: boolean showSelectedIcon?: boolean
selectedCredentialId?: string
} }
const Item = ({ const Item = ({
credential, credential,
@ -50,6 +51,7 @@ const Item = ({
disableSetDefault, disableSetDefault,
onItemClick, onItemClick,
showSelectedIcon, showSelectedIcon,
selectedCredentialId,
}: ItemProps) => { }: ItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [renaming, setRenaming] = useState(false) const [renaming, setRenaming] = useState(false)
@ -107,7 +109,7 @@ const Item = ({
showSelectedIcon && ( showSelectedIcon && (
<div className='h-4 w-4'> <div className='h-4 w-4'>
{ {
credential.is_default && ( selectedCredentialId === credential.id && (
<RiCheckLine className='h-4 w-4 text-text-accent' /> <RiCheckLine className='h-4 w-4 text-text-accent' />
) )
} }

@ -3,8 +3,12 @@ import {
useDeletePluginCredential, useDeletePluginCredential,
useGetPluginCredentialInfo, useGetPluginCredentialInfo,
useGetPluginCredentialSchema, useGetPluginCredentialSchema,
useGetPluginOAuthClientSchema,
useGetPluginOAuthCustomClientSchema,
useGetPluginOAuthUrl,
useInvalidPluginCredentialInfo, useInvalidPluginCredentialInfo,
useSetPluginDefaultCredential, useSetPluginDefaultCredential,
useSetPluginOAuthCustomClient,
useUpdatePluginCredential, useUpdatePluginCredential,
} from '@/service/use-plugins-auth' } from '@/service/use-plugins-auth'
import { useGetApi } from './use-get-api' import { useGetApi } from './use-get-api'
@ -51,3 +55,27 @@ export const useUpdatePluginCredentialHook = (pluginPayload: PluginPayload) => {
return useUpdatePluginCredential(apiMap.updateCredential) return useUpdatePluginCredential(apiMap.updateCredential)
} }
export const useGetPluginOAuthUrlHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthUrl(apiMap.getOauthUrl)
}
export const useGetPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthClientSchema(apiMap.getOauthClientSchema)
}
export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient)
}
export const useGetPluginOAuthCustomClientSchemaHook = (pluginPayload: PluginPayload) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginOAuthCustomClientSchema(apiMap.getCustomOAuthClient)
}

@ -109,6 +109,7 @@ const PluginAuthInAgent = ({
renderTrigger={renderTrigger} renderTrigger={renderTrigger}
isOpen={isOpen} isOpen={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
selectedCredentialId={credentialId || '__workspace_default__'}
/> />
) )
} }

@ -272,7 +272,7 @@ const DetailHeader = ({
</ActionButton> </ActionButton>
</div> </div>
</div> </div>
<Description className='mt-3' text={description[locale]} descriptionLineRows={2}></Description> <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>
{ {
category === PluginType.tool && ( category === PluginType.tool && (
<PluginAuth <PluginAuth

@ -311,13 +311,13 @@ const ToolSelector: FC<Props> = ({
<Divider className='my-1 w-full' /> <Divider className='my-1 w-full' />
<div className='px-4 py-2'> <div className='px-4 py-2'>
<PluginAuthInAgent <PluginAuthInAgent
pluginPayload={{ pluginPayload={{
provider: currentProvider.name, provider: currentProvider.name,
category: AuthCategory.tool, category: AuthCategory.tool,
}} }}
credentialId={value?.credential_id} credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick} onAuthorizationItemClick={handleAuthorizationItemClick}
/> />
</div> </div>
</> </>
)} )}

@ -102,9 +102,16 @@ export const useGetPluginCredentialSchema = (
export const useGetPluginOAuthUrl = ( export const useGetPluginOAuthUrl = (
url: string, url: string,
) => { ) => {
return useQuery({ return useMutation({
queryKey: [NAME_SPACE, 'oauth-url', url], mutationKey: [NAME_SPACE, 'oauth-url', url],
queryFn: () => get(url), mutationFn: () => {
return get<
{
authorization_url: string
state: string
context_id: string
}>(url)
},
}) })
} }
@ -113,7 +120,10 @@ export const useGetPluginOAuthClientSchema = (
) => { ) => {
return useQuery({ return useQuery({
queryKey: [NAME_SPACE, 'oauth-client-schema', url], queryKey: [NAME_SPACE, 'oauth-client-schema', url],
queryFn: () => get(url), queryFn: () => get<{
schema: FormSchema[]
is_oauth_custom_client_enabled: boolean
}>(url),
}) })
} }
@ -121,8 +131,11 @@ export const useSetPluginOAuthCustomClient = (
url: string, url: string,
) => { ) => {
return useMutation({ return useMutation({
mutationFn: (params) => { mutationFn: (params: {
return post(url, { body: params }) client_params: Record<string, any>
enable_oauth_custom_client: boolean
}) => {
return post<{ result: string }>(url, { body: params })
}, },
}) })
} }
@ -132,6 +145,9 @@ export const useGetPluginOAuthCustomClientSchema = (
) => { ) => {
return useQuery({ return useQuery({
queryKey: [NAME_SPACE, 'oauth-custom-client-schema', url], queryKey: [NAME_SPACE, 'oauth-custom-client-schema', url],
queryFn: () => get(url), queryFn: () => get<{
client_id: string
client_secret: string
}>(url),
}) })
} }

@ -130,6 +130,7 @@ export type AgentTool = {
enabled: boolean enabled: boolean
isDeleted?: boolean isDeleted?: boolean
notAuthor?: boolean notAuthor?: boolean
credential_id?: string
} }
export type ToolItem = { export type ToolItem = {

Loading…
Cancel
Save