You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useMemo, useState } from 'react'
  4. import { useTranslation } from 'react-i18next'
  5. import Link from 'next/link'
  6. import {
  7. RiArrowLeftLine,
  8. RiArrowRightUpLine,
  9. } from '@remixicon/react'
  10. import {
  11. PortalToFollowElem,
  12. PortalToFollowElemContent,
  13. PortalToFollowElemTrigger,
  14. } from '@/app/components/base/portal-to-follow-elem'
  15. import ToolTrigger from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-trigger'
  16. import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item'
  17. import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
  18. import Button from '@/app/components/base/button'
  19. import Indicator from '@/app/components/header/indicator'
  20. import ToolCredentialForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form'
  21. import Toast from '@/app/components/base/toast'
  22. import Textarea from '@/app/components/base/textarea'
  23. import Divider from '@/app/components/base/divider'
  24. import TabSlider from '@/app/components/base/tab-slider-plain'
  25. import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form'
  26. import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
  27. import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
  28. import { useAppContext } from '@/context/app-context'
  29. import {
  30. useAllBuiltInTools,
  31. useAllCustomTools,
  32. useAllWorkflowTools,
  33. useInvalidateAllBuiltInTools,
  34. useUpdateProviderCredentials,
  35. } from '@/service/use-tools'
  36. import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
  37. import { usePluginInstalledCheck } from '@/app/components/plugins/plugin-detail-panel/tool-selector/hooks'
  38. import { CollectionType } from '@/app/components/tools/types'
  39. import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
  40. import type {
  41. OffsetOptions,
  42. Placement,
  43. } from '@floating-ui/react'
  44. import { MARKETPLACE_API_PREFIX } from '@/config'
  45. import type { Node } from 'reactflow'
  46. import type { NodeOutPutVar } from '@/app/components/workflow/types'
  47. import cn from '@/utils/classnames'
  48. type Props = {
  49. disabled?: boolean
  50. placement?: Placement
  51. offset?: OffsetOptions
  52. scope?: string
  53. value?: ToolValue
  54. selectedTools?: ToolValue[]
  55. isEdit?: boolean
  56. onSelect: (tool: {
  57. provider_name: string
  58. tool_name: string
  59. tool_label: string
  60. settings?: Record<string, any>
  61. parameters?: Record<string, any>
  62. extra?: Record<string, any>
  63. }) => void
  64. onDelete?: () => void
  65. supportEnableSwitch?: boolean
  66. supportAddCustomTool?: boolean
  67. trigger?: React.ReactNode
  68. controlledState?: boolean
  69. onControlledStateChange?: (state: boolean) => void
  70. panelShowState?: boolean
  71. onPanelShowStateChange?: (state: boolean) => void
  72. nodeOutputVars: NodeOutPutVar[],
  73. availableNodes: Node[],
  74. nodeId?: string,
  75. }
  76. const ToolSelector: FC<Props> = ({
  77. value,
  78. selectedTools,
  79. isEdit,
  80. disabled,
  81. placement = 'left',
  82. offset = 4,
  83. onSelect,
  84. onDelete,
  85. scope,
  86. supportEnableSwitch,
  87. trigger,
  88. controlledState,
  89. onControlledStateChange,
  90. panelShowState,
  91. onPanelShowStateChange,
  92. nodeOutputVars,
  93. availableNodes,
  94. nodeId = '',
  95. }) => {
  96. const { t } = useTranslation()
  97. const [isShow, onShowChange] = useState(false)
  98. const handleTriggerClick = () => {
  99. if (disabled) return
  100. onShowChange(true)
  101. }
  102. const { data: buildInTools } = useAllBuiltInTools()
  103. const { data: customTools } = useAllCustomTools()
  104. const { data: workflowTools } = useAllWorkflowTools()
  105. const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
  106. const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
  107. // plugin info check
  108. const { inMarketPlace, manifest } = usePluginInstalledCheck(value?.provider_name)
  109. const currentProvider = useMemo(() => {
  110. const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || [])]
  111. return mergedTools.find((toolWithProvider) => {
  112. return toolWithProvider.id === value?.provider_name
  113. })
  114. }, [value, buildInTools, customTools, workflowTools])
  115. const [isShowChooseTool, setIsShowChooseTool] = useState(false)
  116. const handleSelectTool = (tool: ToolDefaultValue) => {
  117. const settingValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any))
  118. const paramValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any), true)
  119. const toolValue = {
  120. provider_name: tool.provider_id,
  121. type: tool.provider_type,
  122. tool_name: tool.tool_name,
  123. tool_label: tool.tool_label,
  124. tool_description: tool.tool_description,
  125. settings: settingValues,
  126. parameters: paramValues,
  127. enabled: tool.is_team_authorization,
  128. extra: {
  129. description: tool.tool_description,
  130. },
  131. schemas: tool.paramSchemas,
  132. }
  133. onSelect(toolValue)
  134. // setIsShowChooseTool(false)
  135. }
  136. const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  137. onSelect({
  138. ...value,
  139. extra: {
  140. ...value?.extra,
  141. description: e.target.value || '',
  142. },
  143. } as any)
  144. }
  145. // tool settings & params
  146. const currentToolSettings = useMemo(() => {
  147. if (!currentProvider) return []
  148. return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form !== 'llm') || []
  149. }, [currentProvider, value])
  150. const currentToolParams = useMemo(() => {
  151. if (!currentProvider) return []
  152. return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form === 'llm') || []
  153. }, [currentProvider, value])
  154. const [currType, setCurrType] = useState('settings')
  155. const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
  156. const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
  157. const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
  158. const settingsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolSettings), [currentToolSettings])
  159. const paramsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolParams), [currentToolParams])
  160. const handleSettingsFormChange = (v: Record<string, any>) => {
  161. const newValue = getStructureValue(v)
  162. const toolValue = {
  163. ...value,
  164. settings: newValue,
  165. }
  166. onSelect(toolValue as any)
  167. }
  168. const handleParamsFormChange = (v: Record<string, any>) => {
  169. const toolValue = {
  170. ...value,
  171. parameters: v,
  172. }
  173. onSelect(toolValue as any)
  174. }
  175. const handleEnabledChange = (state: boolean) => {
  176. onSelect({
  177. ...value,
  178. enabled: state,
  179. } as any)
  180. }
  181. // authorization
  182. const { isCurrentWorkspaceManager } = useAppContext()
  183. const [isShowSettingAuth, setShowSettingAuth] = useState(false)
  184. const handleCredentialSettingUpdate = () => {
  185. invalidateAllBuiltinTools()
  186. Toast.notify({
  187. type: 'success',
  188. message: t('common.api.actionSuccess'),
  189. })
  190. setShowSettingAuth(false)
  191. onShowChange(false)
  192. }
  193. const { mutate: updatePermission } = useUpdateProviderCredentials({
  194. onSuccess: handleCredentialSettingUpdate,
  195. })
  196. // install from marketplace
  197. const currentTool = useMemo(() => {
  198. return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
  199. }, [currentProvider?.tools, value?.tool_name])
  200. const manifestIcon = useMemo(() => {
  201. if (!manifest)
  202. return ''
  203. return `${MARKETPLACE_API_PREFIX}/plugins/${(manifest as any).plugin_id}/icon`
  204. }, [manifest])
  205. const handleInstall = async () => {
  206. invalidateAllBuiltinTools()
  207. invalidateInstalledPluginList()
  208. }
  209. return (
  210. <>
  211. <PortalToFollowElem
  212. placement={placement}
  213. offset={offset}
  214. open={trigger ? controlledState : isShow}
  215. onOpenChange={trigger ? onControlledStateChange : onShowChange}
  216. >
  217. <PortalToFollowElemTrigger
  218. className='w-full'
  219. onClick={() => {
  220. if (!currentProvider || !currentTool) return
  221. handleTriggerClick()
  222. }}
  223. >
  224. {trigger}
  225. {!trigger && !value?.provider_name && (
  226. <ToolTrigger
  227. isConfigure
  228. open={isShow}
  229. value={value}
  230. provider={currentProvider}
  231. />
  232. )}
  233. {!trigger && value?.provider_name && (
  234. <ToolItem
  235. open={isShow}
  236. icon={currentProvider?.icon || manifestIcon}
  237. providerName={value.provider_name}
  238. toolLabel={value.tool_label || value.tool_name}
  239. showSwitch={supportEnableSwitch}
  240. switchValue={value.enabled}
  241. onSwitchChange={handleEnabledChange}
  242. onDelete={onDelete}
  243. noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
  244. onAuth={() => setShowSettingAuth(true)}
  245. uninstalled={!currentProvider && inMarketPlace}
  246. versionMismatch={currentProvider && inMarketPlace && !currentTool}
  247. installInfo={manifest?.latest_package_identifier}
  248. onInstall={() => handleInstall()}
  249. isError={(!currentProvider || !currentTool) && !inMarketPlace}
  250. errorTip={
  251. <div className='max-w-[240px] space-y-1 text-xs'>
  252. <h3 className='font-semibold text-text-primary'>{currentTool ? t('plugin.detailPanel.toolSelector.uninstalledTitle') : t('plugin.detailPanel.toolSelector.unsupportedTitle')}</h3>
  253. <p className='tracking-tight text-text-secondary'>{currentTool ? t('plugin.detailPanel.toolSelector.uninstalledContent') : t('plugin.detailPanel.toolSelector.unsupportedContent')}</p>
  254. <p>
  255. <Link href={'/plugins'} className='tracking-tight text-text-accent'>{t('plugin.detailPanel.toolSelector.uninstalledLink')}</Link>
  256. </p>
  257. </div>
  258. }
  259. />
  260. )}
  261. </PortalToFollowElemTrigger>
  262. <PortalToFollowElemContent className='z-[1000]'>
  263. <div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', !isShowSettingAuth && 'overflow-y-auto pb-2')}>
  264. {!isShowSettingAuth && (
  265. <>
  266. <div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}</div>
  267. {/* base form */}
  268. <div className='flex flex-col gap-3 px-4 py-2'>
  269. <div className='flex flex-col gap-1'>
  270. <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div>
  271. <ToolPicker
  272. panelClassName='w-[328px]'
  273. placement='bottom'
  274. offset={offset}
  275. trigger={
  276. <ToolTrigger
  277. open={panelShowState || isShowChooseTool}
  278. value={value}
  279. provider={currentProvider}
  280. />
  281. }
  282. isShow={panelShowState || isShowChooseTool}
  283. onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool}
  284. disabled={false}
  285. supportAddCustomTool
  286. onSelect={handleSelectTool}
  287. scope={scope}
  288. selectedTools={selectedTools}
  289. />
  290. </div>
  291. <div className='flex flex-col gap-1'>
  292. <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.descriptionLabel')}</div>
  293. <Textarea
  294. className='resize-none'
  295. placeholder={t('plugin.detailPanel.toolSelector.descriptionPlaceholder')}
  296. value={value?.extra?.description || ''}
  297. onChange={handleDescriptionChange}
  298. disabled={!value?.provider_name}
  299. />
  300. </div>
  301. </div>
  302. {/* authorization */}
  303. {currentProvider && currentProvider.type === CollectionType.builtIn && currentProvider.allow_delete && (
  304. <>
  305. <Divider className='my-1 w-full' />
  306. <div className='px-4 py-2'>
  307. {!currentProvider.is_team_authorization && (
  308. <Button
  309. variant='primary'
  310. className={cn('w-full shrink-0')}
  311. onClick={() => setShowSettingAuth(true)}
  312. disabled={!isCurrentWorkspaceManager}
  313. >
  314. {t('tools.auth.unauthorized')}
  315. </Button>
  316. )}
  317. {currentProvider.is_team_authorization && (
  318. <Button
  319. variant='secondary'
  320. className={cn('w-full shrink-0')}
  321. onClick={() => setShowSettingAuth(true)}
  322. disabled={!isCurrentWorkspaceManager}
  323. >
  324. <Indicator className='mr-2' color={'green'} />
  325. {t('tools.auth.authorized')}
  326. </Button>
  327. )}
  328. </div>
  329. </>
  330. )}
  331. {/* tool settings */}
  332. {(currentToolSettings.length > 0 || currentToolParams.length > 0) && currentProvider?.is_team_authorization && (
  333. <>
  334. <Divider className='my-1 w-full' />
  335. {/* tabs */}
  336. {nodeId && showTabSlider && (
  337. <TabSlider
  338. className='mt-1 shrink-0 px-4'
  339. itemClassName='py-3'
  340. noBorderBottom
  341. smallItem
  342. value={currType}
  343. onChange={(value) => {
  344. setCurrType(value)
  345. }}
  346. options={[
  347. { value: 'settings', text: t('plugin.detailPanel.toolSelector.settings')! },
  348. { value: 'params', text: t('plugin.detailPanel.toolSelector.params')! },
  349. ]}
  350. />
  351. )}
  352. {nodeId && showTabSlider && currType === 'params' && (
  353. <div className='px-4 py-2'>
  354. <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div>
  355. <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div>
  356. </div>
  357. )}
  358. {/* user settings only */}
  359. {userSettingsOnly && (
  360. <div className='p-4 pb-1'>
  361. <div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.settings')}</div>
  362. </div>
  363. )}
  364. {/* reasoning config only */}
  365. {nodeId && reasoningConfigOnly && (
  366. <div className='mb-1 p-4 pb-1'>
  367. <div className='system-sm-semibold-uppercase text-text-primary'>{t('plugin.detailPanel.toolSelector.params')}</div>
  368. <div className='pb-1'>
  369. <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip1')}</div>
  370. <div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.toolSelector.paramsTip2')}</div>
  371. </div>
  372. </div>
  373. )}
  374. {/* user settings form */}
  375. {(currType === 'settings' || userSettingsOnly) && (
  376. <div className='px-4 py-2'>
  377. <Form
  378. value={getPlainValue(value?.settings || {})}
  379. onChange={handleSettingsFormChange}
  380. formSchemas={settingsFormSchemas as any}
  381. isEditMode={true}
  382. showOnVariableMap={{}}
  383. validating={false}
  384. inputClassName='bg-components-input-bg-normal hover:bg-components-input-bg-hover'
  385. fieldMoreInfo={item => item.url
  386. ? (<a
  387. href={item.url}
  388. target='_blank' rel='noopener noreferrer'
  389. className='inline-flex items-center text-xs text-text-accent'
  390. >
  391. {t('tools.howToGet')}
  392. <RiArrowRightUpLine className='ml-1 h-3 w-3' />
  393. </a>)
  394. : null}
  395. />
  396. </div>
  397. )}
  398. {/* reasoning config form */}
  399. {nodeId && (currType === 'params' || reasoningConfigOnly) && (
  400. <ReasoningConfigForm
  401. value={value?.parameters || {}}
  402. onChange={handleParamsFormChange}
  403. schemas={paramsFormSchemas as any}
  404. nodeOutputVars={nodeOutputVars}
  405. availableNodes={availableNodes}
  406. nodeId={nodeId}
  407. />
  408. )}
  409. </>
  410. )}
  411. </>
  412. )}
  413. {/* authorization panel */}
  414. {isShowSettingAuth && currentProvider && (
  415. <>
  416. <div className='relative flex flex-col gap-1 pt-3.5'>
  417. <div className='absolute -top-2 left-2 w-[345px] rounded-t-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pt-2 backdrop-blur-sm'></div>
  418. <div
  419. className='system-xs-semibold-uppercase flex h-6 cursor-pointer items-center gap-1 px-3 text-text-accent-secondary'
  420. onClick={() => setShowSettingAuth(false)}
  421. >
  422. <RiArrowLeftLine className='h-4 w-4' />
  423. BACK
  424. </div>
  425. <div className='system-xl-semibold px-4 text-text-primary'>{t('tools.auth.setupModalTitle')}</div>
  426. <div className='system-xs-regular px-4 text-text-tertiary'>{t('tools.auth.setupModalTitleDescription')}</div>
  427. </div>
  428. <ToolCredentialForm
  429. collection={currentProvider}
  430. onCancel={() => setShowSettingAuth(false)}
  431. onSaved={async value => updatePermission({
  432. providerName: currentProvider.name,
  433. credentials: value,
  434. })}
  435. />
  436. </>
  437. )}
  438. </div>
  439. </PortalToFollowElemContent>
  440. </PortalToFollowElem>
  441. </>
  442. )
  443. }
  444. export default React.memo(ToolSelector)