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

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