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 16KB

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