feat: workflow new nodes (#4683)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Patryk Garstecki <patryk20120@yahoo.pl>
Co-authored-by: Sebastian.W <thiner@gmail.com>
Co-authored-by: 呆萌闷油瓶 <253605712@qq.com>
Co-authored-by: takatost <takatost@users.noreply.github.com>
Co-authored-by: rechardwang <wh_goodjob@163.com>
Co-authored-by: Nite Knite <nkCoding@gmail.com>
Co-authored-by: Chenhe Gu <guchenhe@gmail.com>
Co-authored-by: Joshua <138381132+joshua20231026@users.noreply.github.com>
Co-authored-by: Weaxs <459312872@qq.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: leejoo0 <81673835+leejoo0@users.noreply.github.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: sino <sino2322@gmail.com>
Co-authored-by: Vikey Chen <vikeytk@gmail.com>
Co-authored-by: wanghl <Wang-HL@users.noreply.github.com>
Co-authored-by: Haolin Wang-汪皓临 <haolin.wang@atlaslovestravel.com>
Co-authored-by: Zixuan Cheng <61724187+Theysua@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: Bowen Liang <bowenliang@apache.org>
Co-authored-by: Bowen Liang <liangbowen@gf.com.cn>
Co-authored-by: fanghongtai <42790567+fanghongtai@users.noreply.github.com>
Co-authored-by: wxfanghongtai <wxfanghongtai@gf.com.cn>
Co-authored-by: Matri <qjp@bithuman.io>
Co-authored-by: Benjamin <benjaminx@gmail.com>
This commit is contained in:
zxhlyh
2024-05-27 21:57:08 +08:00
committed by GitHub
parent 444fdb79dc
commit 45deaee762
210 changed files with 9951 additions and 2223 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,70 @@
'use client'
import { useRef } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useMount } from 'ahooks'
import { Apps02 } from '@/app/components/base/icons/src/vender/line/others'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
import { fetchLabelList } from '@/service/tools'
type Props = {
value: string
onSelect: (type: string) => void
}
const Icon = ({ svgString, active }: { svgString: string; active: boolean }) => {
const svgRef = useRef<SVGSVGElement | null>(null)
const SVGParsor = (svg: string) => {
if (!svg)
return null
const parser = new DOMParser()
const doc = parser.parseFromString(svg, 'image/svg+xml')
console.log(doc.documentElement)
return doc.documentElement
}
useMount(() => {
const svgElement = SVGParsor(svgString)
if (svgRef.current && svgElement)
svgRef.current.appendChild(svgElement)
})
return <svg className={cn('w-4 h-4 text-gray-700', active && '!text-primary-600')} ref={svgRef} />
}
const Category = ({
value,
onSelect,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<div className='mb-3'>
<div className='px-3 py-0.5 text-gray-500 text-xs leading-[18px] font-medium'>{t('tools.addToolModal.category').toLocaleUpperCase()}</div>
<div className={cn('mb-0.5 p-1 pl-3 flex items-center cursor-pointer text-gray-700 text-sm leading-5 rounded-lg hover:bg-white', value === '' && '!bg-white !text-primary-600 font-medium')} onClick={() => onSelect('')}>
<Apps02 className='shrink-0 w-4 h-4 mr-2' />
{t('tools.type.all')}
</div>
{labelList.map(label => (
<div key={label.name} title={label.label[language]} className={cn('mb-0.5 p-1 pl-3 flex items-center cursor-pointer text-gray-700 text-sm leading-5 rounded-lg hover:bg-white truncate overflow-hidden', value === label.name && '!bg-white !text-primary-600 font-medium')} onClick={() => onSelect(label.name)}>
<div className='shrink-0 w-4 h-4 mr-2'>
<Icon active={value === label.name} svgString={label.icon} />
</div>
{label.label[language]}
</div>
))}
</div>
)
}
export default Category

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
const Empty = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col items-center'>
<div className="shrink-0 w-[163px] h-[149px] bg-cover bg-no-repeat bg-[url('~@/app/components/tools/add-tool-modal/empty.png')]"></div>
<div className='mb-1 text-[13px] font-medium text-gray-700 leading-[18px]'>{t('tools.addToolModal.emptyTitle')}</div>
<div className='text-[13px] text-gray-500 leading-[18px]'>{t('tools.addToolModal.emptyTip')}</div>
</div>
)
}
export default Empty

View File

@@ -0,0 +1,235 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import produce from 'immer'
import cn from 'classnames'
import { useMount } from 'ahooks'
import type { Collection, CustomCollectionBackend, Tool } from '../types'
import Type from './type'
import Category from './category'
import Tools from './tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import Drawer from '@/app/components/base/drawer'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import SearchInput from '@/app/components/base/search-input'
import { Plus, XClose } from '@/app/components/base/icons/src/vender/line/general'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import {
createCustomCollection,
fetchAllBuiltInTools,
fetchAllCustomTools,
fetchAllWorkflowTools,
removeBuiltInToolCredential,
updateBuiltInToolCredential,
} from '@/service/tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import ConfigContext from '@/context/debug-configuration'
import type { ModelConfig } from '@/models/debug'
type Props = {
onHide: () => void
}
// Add and Edit
const AddToolModal: FC<Props> = ({
onHide,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [currentType, setCurrentType] = useState('builtin')
const [currentCategory, setCurrentCategory] = useState('')
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const [toolList, setToolList] = useState<ToolWithProvider[]>([])
const [listLoading, setListLoading] = useState(true)
const getAllTools = async () => {
setListLoading(true)
const buildInTools = await fetchAllBuiltInTools()
const customTools = await fetchAllCustomTools()
const workflowTools = await fetchAllWorkflowTools()
const mergedToolList = [
...buildInTools,
...customTools,
...workflowTools.filter((toolWithProvider) => {
return !toolWithProvider.tools.some((tool) => {
return !!tool.parameters.find(item => item.name === '__image')
})
}),
]
setToolList(mergedToolList)
setListLoading(false)
}
const filteredList = useMemo(() => {
return toolList.filter((toolWithProvider) => {
if (currentType === 'all')
return true
else
return toolWithProvider.type === currentType
}).filter((toolWithProvider) => {
if (!currentCategory)
return true
else
return toolWithProvider.labels.includes(currentCategory)
}).filter((toolWithProvider) => {
return toolWithProvider.tools.some((tool) => {
return tool.label[language].toLowerCase().includes(keywords.toLowerCase())
})
})
}, [currentType, currentCategory, toolList, keywords, language])
const {
modelConfig,
setModelConfig,
} = useContext(ConfigContext)
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
getAllTools()
}
const [showSettingAuth, setShowSettingAuth] = useState(false)
const [collection, setCollection] = useState<Collection>()
const toolSelectHandle = (collection: Collection, tool: Tool) => {
const parameters: Record<string, string> = {}
if (tool.parameters) {
tool.parameters.forEach((item) => {
parameters[item.name] = ''
})
}
const nexModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.agentConfig.tools.push({
provider_id: collection.id || collection.name,
provider_type: collection.type,
provider_name: collection.name,
tool_name: tool.name,
tool_label: tool.label[locale] || tool.label[locale.replaceAll('-', '_')],
tool_parameters: parameters,
enabled: true,
})
})
setModelConfig(nexModelConfig)
}
const authSelectHandle = (provider: Collection) => {
setCollection(provider)
setShowSettingAuth(true)
}
const updateBuiltinAuth = async (value: Record<string, any>) => {
if (!collection)
return
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await getAllTools()
setShowSettingAuth(false)
}
const removeBuiltinAuth = async () => {
if (!collection)
return
await removeBuiltInToolCredential(collection.name)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await getAllTools()
setShowSettingAuth(false)
}
useMount(() => {
getAllTools()
})
return (
<>
<Drawer
isOpen
mask
clickOutsideNotOpen
onClose={onHide}
footer={null}
panelClassname={cn('mt-16 mx-2 sm:mr-2 mb-3 !p-0 rounded-xl', 'mt-2 !w-[640px]', '!max-w-[640px]')}
>
<div
className='w-full flex bg-white border-[0.5px] border-gray-200 rounded-xl shadow-xl'
style={{
height: 'calc(100vh - 16px)',
}}
>
<div className='relative shrink-0 w-[200px] pb-3 bg-gray-100 rounded-l-xl border-r-[0.5px] border-black/2 overflow-y-auto'>
<div className='sticky top-0 left-0 right-0'>
<div className='sticky top-0 left-0 right-0 px-5 py-3 text-md font-semibold text-gray-900'>{t('tools.addTool')}</div>
<div className='px-3 pt-2 pb-4'>
<Button type='primary' className='w-[176px] text-[13px] leading-[18px] font-medium' onClick={() => setIsShowEditCustomCollectionModal(true)}>
<Plus className='w-4 h-4 mr-1'/>
{t('tools.createCustomTool')}
</Button>
</div>
</div>
<div className='px-2 py-1'>
<Type value={currentType} onSelect={setCurrentType}/>
<Category value={currentCategory} onSelect={setCurrentCategory}/>
</div>
</div>
<div className='relative grow bg-white rounded-r-xl overflow-y-auto'>
<div className='z-10 sticky top-0 left-0 right-0 p-2 flex items-center gap-1 bg-white'>
<div className='grow'>
<SearchInput className='w-full' value={keywords} onChange={handleKeywordsChange} />
</div>
<div className='ml-2 mr-1 w-[1px] h-4 bg-gray-200'></div>
<div className='p-2 cursor-pointer' onClick={onHide}>
<XClose className='w-4 h-4 text-gray-500' />
</div>
</div>
{listLoading && (
<div className='flex h-[200px] items-center justify-center bg-white'>
<Loading />
</div>
)}
{!listLoading && (
<Tools
showWorkflowEmpty={currentType === 'workflow'}
tools={filteredList}
addedTools={(modelConfig?.agentConfig?.tools as any) || []}
onSelect={toolSelectHandle}
onAuthSetup={authSelectHandle}
/>
)}
</div>
</div>
</Drawer>
{isShowEditCollectionToolModal && (
<EditCustomToolModal
positionLeft
payload={null}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onAdd={doCreateCustomToolCollection}
/>
)}
{showSettingAuth && collection && (
<ConfigCredential
collection={collection}
onCancel={() => setShowSettingAuth(false)}
onSaved={updateBuiltinAuth}
onRemove={removeBuiltinAuth}
/>
)}
</>
)
}
export default React.memo(AddToolModal)

View File

@@ -0,0 +1,146 @@
import {
memo,
useCallback,
} from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check, Plus } from '@/app/components/base/icons/src/vender/line/general'
import { Tag01 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import { useGetLanguage } from '@/context/i18n'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
import Empty from '@/app/components/tools/add-tool-modal/empty'
import type { Tool } from '@/app/components/tools/types'
import { CollectionType } from '@/app/components/tools/types'
import type { AgentTool } from '@/types/app'
import { MAX_TOOLS_NUM } from '@/config'
type ToolsProps = {
showWorkflowEmpty: boolean
tools: ToolWithProvider[]
addedTools: AgentTool[]
onSelect: (provider: ToolWithProvider, tool: Tool) => void
onAuthSetup: (provider: ToolWithProvider) => void
}
const Blocks = ({
showWorkflowEmpty,
tools,
addedTools,
onSelect,
onAuthSetup,
}: ToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const labelList = useLabelStore(s => s.labelList)
const addable = addedTools.length < MAX_TOOLS_NUM
const renderGroup = useCallback((toolWithProvider: ToolWithProvider) => {
const list = toolWithProvider.tools
const needAuth = toolWithProvider.allow_delete && !toolWithProvider.is_team_authorization && toolWithProvider.type === CollectionType.builtIn
return (
<div
key={toolWithProvider.id}
className='group mb-1 last-of-type:mb-0'
>
<div className='flex items-center justify-between w-full pl-3 pr-1 h-[22px] text-xs font-medium text-gray-500'>
{toolWithProvider.label[language]}
<a className='hidden cursor-pointer items-center group-hover:flex' href={`/tools?category=${toolWithProvider.type}`} target='_blank'>{t('tools.addToolModal.manageInTools')}<ArrowUpRight className='ml-0.5 w-3 h-3' /></a>
</div>
{list.map((tool) => {
const labelContent = (() => {
if (!tool.labels)
return ''
return tool.labels.map((name) => {
const label = labelList.find(item => item.name === name)
return label?.label[language]
}).filter(Boolean).join(', ')
})()
const added = !!addedTools?.find(v => v.provider_id === toolWithProvider.id && v.provider_type === toolWithProvider.type && v.tool_name === tool.name)
return (
<Tooltip
key={tool.name}
selector={`workflow-block-tool-${tool.name}`}
position='bottom'
className='!p-0 !px-3 !py-2.5 !w-[210px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg translate-x-[108px]'
htmlContent={(
<div>
<BlockIcon
size='md'
className='mb-2'
type={BlockEnum.Tool}
toolIcon={toolWithProvider.icon}
/>
<div className='mb-1 text-sm leading-5 text-gray-900'>{tool.label[language]}</div>
<div className='text-xs text-gray-700 leading-[18px]'>{tool.description[language]}</div>
{tool.labels?.length > 0 && (
<div className='flex items-center shrink-0 mt-1'>
<div className='relative w-full flex items-center gap-1 py-1 rounded-md text-gray-500' title={labelContent}>
<Tag01 className='shrink-0 w-3 h-3 text-gray-500' />
<div className='grow text-xs text-start leading-[18px] font-normal truncate'>{labelContent}</div>
</div>
</div>
)}
</div>
)}
noArrow
>
<div className='group/item flex items-center w-full pl-3 pr-1 h-8 rounded-lg hover:bg-gray-50 cursor-pointer'>
<BlockIcon
className={cn('mr-2 shrink-0', needAuth && 'opacity-30')}
type={BlockEnum.Tool}
toolIcon={toolWithProvider.icon}
/>
<div className={cn('grow text-sm text-gray-900 truncate', needAuth && 'opacity-30')}>{tool.label[language]}</div>
{!needAuth && added && (
<div className='flex items-center gap-1 rounded-[6px] border border-gray-100 px-2 py-[3px] bg-white text-gray-300 text-xs font-medium leading-[18px]'>
<Check className='w-3 h-3'/>
{t('tools.addToolModal.added').toLocaleUpperCase()}
</div>
)}
{!needAuth && !added && addable && (
<Button
type='default'
className={cn('hidden shrink-0 items-center !h-6 px-2 py-1 bg-white text-xs font-medium leading-[18px] text-primary-600 group-hover/item:flex')}
onClick={() => onSelect(toolWithProvider, tool)}
>
<Plus className='w-3 h-3'/>
{t('tools.addToolModal.add').toLocaleUpperCase()}
</Button>
)}
{needAuth && (
<Button
type='default'
className={cn('hidden shrink-0 items-center !h-6 px-2 py-1 bg-white text-xs font-medium leading-[18px] text-primary-600 group-hover/item:flex')}
onClick={() => onAuthSetup(toolWithProvider)}
>{t('tools.auth.setup')}</Button>
)}
</div>
</Tooltip>
)
})}
</div>
)
}, [addable, language, t, labelList, addedTools, onAuthSetup, onSelect])
return (
<div className='p-1 pb-6 max-w-[440px]'>
{!tools.length && !showWorkflowEmpty && (
<div className='flex items-center px-3 h-[22px] text-xs font-medium text-gray-500'>{t('workflow.tabs.noResult')}</div>
)}
{!tools.length && showWorkflowEmpty && (
<div className='pt-[280px]'>
<Empty/>
</div>
)}
{!!tools.length && tools.map(renderGroup)}
</div>
)
}
export default memo(Blocks)

View File

@@ -0,0 +1,34 @@
'use client'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { Exchange02, FileCode } from '@/app/components/base/icons/src/vender/line/others'
type Props = {
value: string
onSelect: (type: string) => void
}
const Types = ({
value,
onSelect,
}: Props) => {
const { t } = useTranslation()
return (
<div className='mb-3'>
<div className={cn('mb-0.5 p-1 pl-3 flex items-center cursor-pointer text-sm leading-5 rounded-lg hover:bg-white', value === 'builtin' && '!bg-white font-medium')} onClick={() => onSelect('builtin')}>
<div className="shrink-0 w-4 h-4 mr-2 bg-cover bg-no-repeat bg-[url('~@/app/components/tools/add-tool-modal/D.png')]" />
<span className={cn('text-gray-700', value === 'builtin' && '!text-primary-600')}>{t('tools.type.builtIn')}</span>
</div>
<div className={cn('mb-0.5 p-1 pl-3 flex items-center cursor-pointer text-gray-700 text-sm leading-5 rounded-lg hover:bg-white', value === 'api' && '!bg-white !text-primary-600 font-medium')} onClick={() => onSelect('api')}>
<FileCode className='shrink-0 w-4 h-4 mr-2' />
{t('tools.type.custom')}
</div>
<div className={cn('mb-0.5 p-1 pl-3 flex items-center cursor-pointer text-gray-700 text-sm leading-5 rounded-lg hover:bg-white', value === 'workflow' && '!bg-white !text-primary-600 font-medium')} onClick={() => onSelect('workflow')}>
<Exchange02 className='shrink-0 w-4 h-4 mr-2' />
{t('tools.type.workflow')}
</div>
</div>
)
}
export default Types

View File

@@ -1,31 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Heart02 } from '../base/icons/src/vender/solid/education'
import { BookOpen01 } from '../base/icons/src/vender/line/education'
const Contribute: FC = () => {
const { t } = useTranslation()
return (
<div className='shrink-0 p-2'>
<div className='inline-block p-2 bg-white shadow-lg rounded-lg'>
<Heart02 className='w-3 h-3 text-[#EE46BC]' />
</div>
<div className='mt-2'>
<div className='text-gradient'>
{t('tools.contribute.line1')}
</div>
<div className='text-gradient'>
{t('tools.contribute.line2')}
</div>
</div>
<a href='https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md' target='_blank' rel='noopener noreferrer' className='mt-1 flex items-center space-x-1 text-[#155EEF]'>
<BookOpen01 className='w-3 h-3' />
<div className='leading-[18px] text-xs font-normal'>{t('tools.contribute.viewGuide')}</div>
</a>
</div>
)
}
export default React.memo(Contribute)

View File

@@ -12,6 +12,7 @@ import Radio from '@/app/components/base/radio/ui'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
type Props = {
positionCenter?: boolean
credential: Credential
onChange: (credential: Credential) => void
onHide: () => void
@@ -38,6 +39,7 @@ const SelectItem: FC<ItemProps> = ({ text, value, isChecked, onClick }) => {
}
const ConfigCredential: FC<Props> = ({
positionCenter,
credential,
onChange,
onHide,
@@ -48,6 +50,7 @@ const ConfigCredential: FC<Props> = ({
return (
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={t('tools.createTool.authMethod.title')!}
panelClassName='mt-2 !w-[520px]'

View File

@@ -16,9 +16,11 @@ import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker'
import AppIcon from '@/app/components/base/app-icon'
import { parseParamsSchema } from '@/service/tools'
import LabelSelector from '@/app/components/tools/labels/selector'
const fieldNameClassNames = 'py-2 leading-5 text-sm font-medium text-gray-900'
type Props = {
positionLeft?: boolean
payload: any
onHide: () => void
onAdd?: (payload: CustomCollectionBackend) => void
@@ -27,6 +29,7 @@ type Props = {
}
// Add and Edit
const EditCustomCollectionModal: FC<Props> = ({
positionLeft,
payload,
onHide,
onAdd,
@@ -114,6 +117,11 @@ const EditCustomCollectionModal: FC<Props> = ({
const [currTool, setCurrTool] = useState<CustomParamSchema | null>(null)
const [isShowTestApi, setIsShowTestApi] = useState(false)
const [labels, setLabels] = useState<string[]>(payload?.labels || [])
const handleLabelSelect = (value: string[]) => {
setLabels(value)
}
const handleSave = () => {
// const postData = clone(customCollection)
const postData = produce(customCollection, (draft) => {
@@ -124,6 +132,8 @@ const EditCustomCollectionModal: FC<Props> = ({
delete draft.credentials.api_key_header_prefix
delete draft.credentials.api_key_value
}
draft.labels = labels
})
if (isAdd) {
@@ -154,10 +164,11 @@ const EditCustomCollectionModal: FC<Props> = ({
<>
<Drawer
isShow
positionCenter={isAdd && !positionLeft}
onHide={onHide}
title={t(`tools.createTool.${isAdd ? 'title' : 'editTitle'}`)!}
panelClassName='mt-2 !w-[640px]'
maxWidthClassName='!max-w-[640px]'
panelClassName='mt-2 !w-[630px]'
maxWidthClassName='!max-w-[630px]'
height='calc(100vh - 16px)'
headerClassName='!border-b-black/5'
body={
@@ -254,6 +265,13 @@ const EditCustomCollectionModal: FC<Props> = ({
</div>
</div>
{/* Labels */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.toolInput.label')}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className={fieldNameClassNames}>{t('tools.createTool.privacyPolicy')}</div>
<input
@@ -288,7 +306,7 @@ const EditCustomCollectionModal: FC<Props> = ({
)
}
<div className='flex space-x-2 '>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700 bg-white' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium' type='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
</div>
</div>
@@ -308,6 +326,7 @@ const EditCustomCollectionModal: FC<Props> = ({
/>}
{credentialsModalShow && (
<ConfigCredentials
positionCenter={isAdd}
credential={credential}
onChange={setCredential}
onHide={() => setCredentialsModalShow(false)}
@@ -315,6 +334,7 @@ const EditCustomCollectionModal: FC<Props> = ({
}
{isShowTestApi && (
<TestApi
positionCenter={isAdd}
tool={currTool as CustomParamSchema}
customCollection={customCollection}
onHide={() => setIsShowTestApi(false)}

View File

@@ -13,6 +13,7 @@ import { testAPIAvailable } from '@/service/tools'
import { getLanguage } from '@/i18n/language'
type Props = {
positionCenter?: boolean
customCollection: CustomCollectionBackend
tool: CustomParamSchema
onHide: () => void
@@ -21,6 +22,7 @@ type Props = {
const keyClassNames = 'py-2 leading-5 text-sm font-medium text-gray-900'
const TestApi: FC<Props> = ({
positionCenter,
customCollection,
tool,
onHide,
@@ -57,6 +59,7 @@ const TestApi: FC<Props> = ({
<>
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={`${t('tools.test.title')} ${toolName}`}
panelClassName='mt-2 !w-[600px]'
@@ -119,6 +122,7 @@ const TestApi: FC<Props> = ({
/>
{credentialsModalShow && (
<ConfigCredentials
positionCenter={positionCenter}
credential={tempCredential}
onChange={setTempCredential}
onHide={() => setCredentialsModalShow(false)}

View File

@@ -1,259 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import Button from '../base/button'
import { Plus } from '../base/icons/src/vender/line/general'
import Toast from '../base/toast'
import type { Collection, CustomCollectionBackend, Tool } from './types'
import { CollectionType, LOC } from './types'
import ToolNavList from './tool-nav-list'
import Search from './search'
import Contribute from './contribute'
import ToolList from './tool-list'
import EditCustomToolModal from './edit-custom-collection-modal'
import NoCustomTool from './info/no-custom-tool'
import NoSearchRes from './info/no-search-res'
import NoCustomToolPlaceholder from './no-custom-tool-placeholder'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import TabSlider from '@/app/components/base/tab-slider'
import { createCustomCollection, fetchCollectionList as doFetchCollectionList, fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList } from '@/service/tools'
import type { AgentTool } from '@/types/app'
type Props = {
loc: LOC
addedTools?: AgentTool[]
onAddTool?: (collection: Collection, payload: Tool) => void
selectedProviderId?: string
}
const Tools: FC<Props> = ({
loc,
addedTools,
onAddTool,
selectedProviderId,
}) => {
const { t } = useTranslation()
const isInToolsPage = loc === LOC.tools
const isInDebugPage = !isInToolsPage
const [collectionList, setCollectionList] = useState<Collection[]>([])
const [currCollectionIndex, setCurrCollectionIndex] = useState<number | null>(null)
const [isDetailLoading, setIsDetailLoading] = useState(false)
const fetchCollectionList = async () => {
const list = await doFetchCollectionList()
setCollectionList(list)
if (list.length > 0 && currCollectionIndex === null) {
let index = 0
if (selectedProviderId)
index = list.findIndex(item => item.id === selectedProviderId)
setCurrCollectionIndex(index || 0)
}
}
useEffect(() => {
fetchCollectionList()
}, [])
const collectionTypeOptions = (() => {
const res = [
{ value: CollectionType.builtIn, text: t('tools.type.builtIn') },
{ value: CollectionType.custom, text: t('tools.type.custom') },
]
if (!isInToolsPage)
res.unshift({ value: CollectionType.all, text: t('tools.type.all') })
return res
})()
const [query, setQuery] = useState('')
const [toolPageCollectionType, setToolPageCollectionType] = useTabSearchParams({
defaultTab: collectionTypeOptions[0].value,
})
const [appPageCollectionType, setAppPageCollectionType] = useState(collectionTypeOptions[0].value)
const { collectionType, setCollectionType } = (() => {
if (isInToolsPage) {
return {
collectionType: toolPageCollectionType,
setCollectionType: setToolPageCollectionType,
}
}
return {
collectionType: appPageCollectionType,
setCollectionType: setAppPageCollectionType,
}
})()
const showCollectionList = (() => {
let typeFilteredList: Collection[] = []
if (collectionType === CollectionType.all)
typeFilteredList = collectionList.filter(item => item.type !== CollectionType.model)
else if (collectionType === CollectionType.builtIn)
typeFilteredList = collectionList.filter(item => item.type === CollectionType.builtIn)
else if (collectionType === CollectionType.custom)
typeFilteredList = collectionList.filter(item => item.type === CollectionType.custom)
if (query)
return typeFilteredList.filter(item => item.name.includes(query))
return typeFilteredList
})()
const hasNoCustomCollection = !collectionList.find(item => item.type === CollectionType.custom)
useEffect(() => {
setCurrCollectionIndex(0)
}, [collectionType])
const currCollection = (() => {
if (currCollectionIndex === null)
return null
return showCollectionList[currCollectionIndex]
})()
const [currTools, setCurrentTools] = useState<Tool[]>([])
useEffect(() => {
if (!currCollection)
return
(async () => {
setIsDetailLoading(true)
try {
if (currCollection.type === CollectionType.builtIn) {
const list = await fetchBuiltInToolList(currCollection.name)
setCurrentTools(list)
}
else if (currCollection.type === CollectionType.model) {
const list = await fetchModelToolList(currCollection.name)
setCurrentTools(list)
}
else {
const list = await fetchCustomToolList(currCollection.name)
setCurrentTools(list)
}
}
catch (e) { }
setIsDetailLoading(false)
})()
}, [currCollection?.name, currCollection?.type])
const [isShowEditCollectionToolModal, setIsShowEditCollectionToolModal] = useState(false)
const handleCreateToolCollection = () => {
setIsShowEditCollectionToolModal(true)
}
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await fetchCollectionList()
setIsShowEditCollectionToolModal(false)
}
return (
<>
<div className='flex h-full'>
{/* sidebar */}
<div className={cn(isInToolsPage ? 'sm:w-[216px] px-4' : 'sm:w-[256px] px-3', 'flex flex-col w-16 shrink-0 pb-2')}>
{isInToolsPage && (
<Button className='mt-6 flex items-center !h-8 pl-4' type='primary' onClick={handleCreateToolCollection}>
<Plus className='w-4 h-4 mr-1' />
<div className='leading-[18px] text-[13px] font-medium truncate'>{t('tools.createCustomTool')}</div>
</Button>
)}
{isInDebugPage && (
<div className='mt-6 flex space-x-1 items-center'>
<Search
className='grow'
value={query}
onChange={setQuery}
/>
<Button className='flex items-center justify-center !w-8 !h-8 !p-0' type='primary'>
<Plus className='w-4 h-4' onClick={handleCreateToolCollection} />
</Button>
</div>
)}
<TabSlider
className='mt-3'
itemWidth={isInToolsPage ? 89 : 75}
value={collectionType}
onChange={v => setCollectionType(v as CollectionType)}
options={collectionTypeOptions}
/>
{isInToolsPage && (
<Search
className='mt-5'
value={query}
onChange={setQuery}
/>
)}
{(collectionType === CollectionType.custom && hasNoCustomCollection)
? (
<div className='grow h-0 p-2 pt-8'>
<NoCustomTool onCreateTool={handleCreateToolCollection} />
</div>
)
: (
(showCollectionList.length > 0 || !query)
? <ToolNavList
className='mt-2 grow height-0 overflow-y-auto'
currentIndex={currCollectionIndex || 0}
list={showCollectionList}
onChosen={setCurrCollectionIndex}
/>
: (
<div className='grow h-0 p-2 pt-8'>
<NoSearchRes
onReset={() => { setQuery('') }}
/>
</div>
)
)}
{loc === LOC.tools && (
<Contribute />
)}
</div>
{/* tools */}
<div className={cn('grow h-full overflow-hidden p-2')}>
<div className='h-full bg-white rounded-2xl'>
{!(collectionType === CollectionType.custom && hasNoCustomCollection) && showCollectionList.length > 0 && (
<ToolList
collection={currCollection}
list={currTools}
loc={loc}
addedTools={addedTools}
onAddTool={onAddTool}
onRefreshData={fetchCollectionList}
onCollectionRemoved={() => {
setCurrCollectionIndex(0)
fetchCollectionList()
}}
isLoading={isDetailLoading}
/>
)}
{collectionType === CollectionType.custom && hasNoCustomCollection && (
<NoCustomToolPlaceholder />
)}
</div>
</div>
</div>
{isShowEditCollectionToolModal && (
<EditCustomToolModal
payload={null}
onHide={() => setIsShowEditCollectionToolModal(false)}
onAdd={doCreateCustomToolCollection}
/>
)}
</>
)
}
export default React.memo(Tools)

View File

@@ -1,38 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Icon3Dots } from '../../base/icons/src/public/other'
import { Tools } from '@/app/components/base/icons/src/public/header-nav/tools'
type Props = {
onCreateTool: () => void
}
const NoCustomTool: FC<Props> = ({
onCreateTool,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='inline-flex p-3 rounded-lg bg-gray-50 border border-[#EAECF5]'>
<Tools className='w-5 h-5 text-gray-500' />
</div>
<div className='mt-2'>
<div className='leading-5 text-sm font-medium text-gray-500'>
{t('tools.noCustomTool.title')}<Icon3Dots className='inline relative -top-3 -left-1.5' />
</div>
<div className='mt-1 leading-[18px] text-xs font-normal text-gray-500'>
{t('tools.noCustomTool.content')}
</div>
<div
className='mt-2 leading-[18px] text-xs font-medium text-[#155EEF] uppercase cursor-pointer'
onClick={onCreateTool}
>
{t('tools.noCustomTool.createTool')}
</div>
</div>
</div>
)
}
export default React.memo(NoCustomTool)

View File

@@ -1,38 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { SearchMd } from '../../base/icons/src/vender/solid/general'
type Props = {
onReset: () => void
}
const NoSearchRes: FC<Props> = ({
onReset,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='inline-flex p-3 rounded-lg bg-gray-50 border border-[#EAECF5]'>
<SearchMd className='w-5 h-5 text-gray-500' />
</div>
<div className='mt-2'>
<div className='leading-5 text-sm font-medium text-gray-500'>
{t('tools.noSearchRes.title')}
</div>
<div className='mt-1 leading-[18px] text-xs font-normal text-gray-500'>
{t('tools.noSearchRes.content')}
</div>
<div
className='mt-2 leading-[18px] text-xs font-medium text-[#155EEF] uppercase cursor-pointer'
onClick={onReset}
>
{t('tools.noSearchRes.reset')}
</div>
</div>
</div>
)
}
export default React.memo(NoSearchRes)

View File

@@ -0,0 +1,6 @@
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
export type Label = {
name: string
icon: string
label: TypeWithI18N
}

View File

@@ -0,0 +1,144 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useDebounceFn, useMount } from 'ahooks'
import cn from 'classnames'
import { useStore as useLabelStore } from './store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import type { Label } from '@/app/components/tools/labels/constant'
import { fetchLabelList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
type LabelFilterProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelFilter: FC<LabelFilterProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [open, setOpen] = useState(false)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
const currentLabel = useMemo(() => {
return labelList.find(label => label.name === value[0])
}, [value, labelList])
const selectLabel = (label: Label) => {
if (value.includes(label.name))
onChange(value.filter(v => v !== label.name))
else
onChange([...value, label.name])
}
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<div className='relative'>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className='block'
>
<div className={cn(
'flex items-center gap-1 px-2 h-8 rounded-lg border-[0.5px] border-transparent bg-gray-200 cursor-pointer hover:bg-gray-300',
open && !value.length && '!bg-gray-300 hover:bg-gray-300',
!open && !!value.length && '!bg-white/80 shadow-xs !border-black/5 hover:!bg-gray-200',
open && !!value.length && '!bg-gray-200 !border-black/5 shadow-xs hover:!bg-gray-200',
)}>
<div className='p-[1px]'>
<Tag01 className='h-3.5 w-3.5 text-gray-700' />
</div>
<div className='text-[13px] leading-[18px] text-gray-700'>
{!value.length && t('common.tag.placeholder')}
{!!value.length && currentLabel?.label[language]}
</div>
{value.length > 1 && (
<div className='text-xs font-medium leading-[18px] text-gray-500'>{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className='p-[1px]'>
<ChevronDown className='h-3.5 w-3.5 text-gray-700'/>
</div>
)}
{!!value.length && (
<div className='p-[1px] cursor-pointer group/clear' onClick={(e) => {
e.stopPropagation()
onChange([])
}}>
<XCircle className='h-3.5 w-3.5 text-gray-400 group-hover/clear:text-gray-600'/>
</div>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1002]'>
<div className='relative w-[240px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
<div className='p-2 border-b-[0.5px] border-black/5'>
<SearchInput white value={keywords} onChange={handleKeywordsChange} />
</div>
<div className='p-1'>
{filteredLabelList.map(label => (
<div
key={label.name}
className='flex items-center gap-2 pl-3 py-[6px] pr-2 rounded-lg cursor-pointer hover:bg-gray-100'
onClick={() => selectLabel(label)}
>
<div title={label.label[language]} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label[language]}</div>
{value.includes(label.name) && <Check className='shrink-0 w-4 h-4 text-primary-600'/>}
</div>
))}
{!filteredLabelList.length && (
<div className='p-3 flex flex-col items-center gap-1'>
<Tag03 className='h-6 w-6 text-gray-300' />
<div className='text-gray-500 text-xs leading-[14px]'>{t('common.tag.noTag')}</div>
</div>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default LabelFilter

View File

@@ -0,0 +1,128 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useDebounceFn, useMount } from 'ahooks'
import cn from 'classnames'
import { useStore as useLabelStore } from './store'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Checkbox from '@/app/components/base/checkbox'
import type { Label } from '@/app/components/tools/labels/constant'
import { fetchLabelList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
type LabelSelectorProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelSelector: FC<LabelSelectorProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [open, setOpen] = useState(false)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
const selectedLabels = useMemo(() => {
return value.map(v => labelList.find(l => l.name === v)?.label[language]).join(', ')
}, [value, labelList, language])
const selectLabel = (label: Label) => {
if (value.includes(label.name))
onChange(value.filter(v => v !== label.name))
else
onChange([...value, label.name])
}
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<div className='relative'>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className='block'
>
<div className={cn(
'flex items-center gap-1 px-3 h-9 rounded-lg border-[0.5px] border-transparent bg-gray-100 cursor-pointer hover:bg-gray-200',
open && '!bg-gray-200 hover:bg-gray-200',
)}>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow text-[13px] leading-[18px] text-gray-700 truncate', !value.length && '!text-gray-400')}>
{!value.length && t('tools.createTool.toolInput.labelPlaceholder')}
{!!value.length && selectedLabels}
</div>
<div className='shrink-0 ml-1 text-gray-700 opacity-60'>
<ChevronDown className='h-4 w-4'/>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1040]'>
<div className='relative w-[591px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
<div className='p-2 border-b-[0.5px] border-black/5'>
<SearchInput white value={keywords} onChange={handleKeywordsChange} />
</div>
<div className='p-1 max-h-[264px] overflow-y-auto'>
{filteredLabelList.map(label => (
<div
key={label.name}
className='flex items-center gap-2 pl-3 py-[6px] pr-2 rounded-lg cursor-pointer hover:bg-gray-100'
onClick={() => selectLabel(label)}
>
<Checkbox
className='shrink-0'
checked={value.includes(label.name)}
onCheck={() => {}}
/>
<div title={label.label[language]} className='grow text-sm text-gray-700 leading-5 truncate'>{label.label[language]}</div>
</div>
))}
{!filteredLabelList.length && (
<div className='p-3 flex flex-col items-center gap-1'>
<Tag03 className='h-6 w-6 text-gray-300' />
<div className='text-gray-500 text-xs leading-[14px]'>{t('common.tag.noTag')}</div>
</div>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default LabelSelector

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand'
import type { Label } from './constant'
type State = {
labelList: Label[]
}
type Action = {
setLabelList: (labelList?: Label[]) => void
}
export const useStore = create<State & Action>(set => ({
labelList: [],
setLabelList: labelList => set(() => ({ labelList })),
}))

View File

@@ -1,26 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen01 } from '../base/icons/src/vender/line/education'
import { Icon3Dots } from '../base/icons/src/public/other'
const NoCustomToolPlaceHolder: FC = () => {
const { t } = useTranslation()
return (
<div className='h-full flex items-center justify-center'>
<div className='p-6 rounded-xl bg-gray-50'>
<div className='inline-flex p-2 border border-gray-200 rounded-md'>
<BookOpen01 className='w-4 h-4 text-primary-600' />
</div>
<div className='mt-3 leading-6 text-base font-medium text-gray-700'>
{t('tools.noCustomTool.title')}
<Icon3Dots className='inline relative -top-3 -left-1.5' />
</div>
<div className='mt-2 leading-5 text-sm font-normal text-gray-700'>{t('tools.noCustomTool.content')}</div>
</div>
</div>
)
}
export default React.memo(NoCustomToolPlaceHolder)

View File

@@ -0,0 +1,117 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import type { Collection } from './types'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import LabelFilter from '@/app/components/tools/labels/filter'
import SearchInput from '@/app/components/base/search-input'
import { DotsGrid, XClose } from '@/app/components/base/icons/src/vender/line/general'
import { Colors } from '@/app/components/base/icons/src/vender/line/others'
import { Route } from '@/app/components/base/icons/src/vender/line/mapsAndTravel'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ContributeCard from '@/app/components/tools/provider/contribute'
import ProviderCard from '@/app/components/tools/provider/card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import Empty from '@/app/components/tools/add-tool-modal/empty'
import { fetchCollectionList } from '@/service/tools'
const ProviderList = () => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: 'builtin',
})
const options = [
{ value: 'builtin', text: t('tools.type.builtIn'), icon: <DotsGrid className='w-[14px] h-[14px] mr-1'/> },
{ value: 'api', text: t('tools.type.custom'), icon: <Colors className='w-[14px] h-[14px] mr-1'/> },
{ value: 'workflow', text: t('tools.type.workflow'), icon: <Route className='w-[14px] h-[14px] mr-1'/> },
]
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
}
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const [collectionList, setCollectionList] = useState<Collection[]>([])
const filteredCollectionList = useMemo(() => {
return collectionList.filter((collection) => {
if (collection.type !== activeTab)
return false
if (tagFilterValue.length > 0 && (!collection.labels || collection.labels.every(label => !tagFilterValue.includes(label))))
return false
if (keywords)
return collection.name.toLowerCase().includes(keywords.toLowerCase())
return true
})
}, [activeTab, tagFilterValue, keywords, collectionList])
const getProviderList = async () => {
const list = await fetchCollectionList()
setCollectionList([...list])
}
useEffect(() => {
getProviderList()
}, [])
const [currentProvider, setCurrentProvider] = useState<Collection | undefined>()
useEffect(() => {
if (currentProvider && collectionList.length > 0) {
const newCurrentProvider = collectionList.find(collection => collection.id === currentProvider.id)
setCurrentProvider(newCurrentProvider)
}
}, [collectionList, currentProvider])
return (
<div className='relative flex overflow-hidden bg-gray-100 shrink-0 h-0 grow'>
<div className='relative flex flex-col overflow-y-auto bg-gray-100 grow'>
<div className={cn(
'z-20 sticky top-0 flex justify-between items-center pt-4 px-12 pb-2 leading-[56px] bg-gray-100 flex-wrap gap-y-2',
currentProvider && 'pr-6',
)}>
<TabSliderNew
value={activeTab}
onChange={(state) => {
setActiveTab(state)
if (state !== activeTab)
setCurrentProvider(undefined)
}}
options={options}
/>
<div className='flex items-center gap-2'>
<LabelFilter value={tagFilterValue} onChange={handleTagsChange} />
<SearchInput className='w-[200px]' value={keywords} onChange={handleKeywordsChange} />
</div>
</div>
<div className={cn(
'relative grid content-start grid-cols-1 gap-4 px-12 pt-2 pb-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 grow shrink-0',
currentProvider && 'pr-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
)}>
{activeTab === 'builtin' && <ContributeCard />}
{activeTab === 'api' && <CustomCreateCard onRefreshData={getProviderList}/>}
{filteredCollectionList.map(collection => (
<ProviderCard
active={currentProvider?.id === collection.id}
onSelect={() => setCurrentProvider(collection)}
key={collection.id}
collection={collection}
/>
))}
{!filteredCollectionList.length && <div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'><Empty/></div>}
</div>
</div>
<div className={cn(
'shrink-0 w-0 border-l-[0.5px] border-black/8 overflow-y-auto transition-all duration-200 ease-in-out',
currentProvider && 'w-[420px]',
)}>
{currentProvider && <ProviderDetail collection={currentProvider} onRefreshData={getProviderList} />}
</div>
<div className='absolute top-5 right-5 p-1 cursor-pointer' onClick={() => setCurrentProvider(undefined)}><XClose className='w-4 h-4'/></div>
</div>
)
}
ProviderList.displayName = 'ToolProviderList'
export default ProviderList

View File

@@ -0,0 +1,83 @@
'use client'
import { useMemo } from 'react'
import cn from 'classnames'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import type { Collection } from '../types'
import AppIcon from '@/app/components/base/app-icon'
import { Tag01 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
type Props = {
active: boolean
collection: Collection
onSelect: () => void
}
const ProviderCard = ({
active,
collection,
onSelect,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const labelList = useLabelStore(s => s.labelList)
const labelContent = useMemo(() => {
if (!collection.labels)
return ''
return collection.labels.map((name) => {
const label = labelList.find(item => item.name === name)
return label?.label[language]
}).filter(Boolean).join(', ')
}, [collection.labels, labelList, language])
return (
<div className={cn('group flex col-span-1 bg-white border-2 border-solid border-transparent rounded-xl shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg', active && '!border-primary-400')} onClick={onSelect}>
<div className='flex pt-[14px] px-[14px] pb-3 h-[66px] items-center gap-3 grow-0 shrink-0'>
<div className='relative shrink-0'>
{typeof collection.icon === 'string' && (
<div className='w-10 h-10 bg-center bg-cover bg-no-repeat rounded-md' style={{ backgroundImage: `url(${collection.icon})` }}/>
)}
{typeof collection.icon !== 'string' && (
<AppIcon
size='large'
icon={collection.icon.content}
background={collection.icon.background}
/>
)}
</div>
<div className='grow w-0 py-[1px]'>
<div className='flex items-center text-sm leading-5 font-semibold text-gray-800'>
<div className='truncate' title={collection.label[language]}>{collection.label[language]}</div>
</div>
<div className='flex items-center text-[10px] leading-[18px] text-gray-500 font-medium'>
<div className='truncate'>{t('tools.author')}&nbsp;{collection.author}</div>
</div>
</div>
</div>
<div
className={cn(
'grow mb-2 px-[14px] max-h-[72px] text-xs leading-normal text-gray-500',
collection.labels?.length ? 'line-clamp-2' : 'line-clamp-4',
collection.labels?.length > 0 && 'group-hover:line-clamp-2 group-hover:max-h-[36px]',
)}
title={collection.description[language]}
>
{collection.description[language]}
</div>
{collection.labels?.length > 0 && (
<div className='flex items-center shrink-0 mt-1 pt-1 pl-[14px] pr-[6px] pb-[6px] h-[42px]'>
<div className='relative w-full flex items-center gap-1 py-[7px] rounded-md text-gray-500' title={labelContent}>
<Tag01 className='shrink-0 w-3 h-3' />
<div className='grow text-xs text-start leading-[18px] font-normal truncate'>{labelContent}</div>
</div>
</div>
)}
</div>
)
}
export default ProviderCard

View File

@@ -0,0 +1,38 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ToolsActive } from '@/app/components/base/icons/src/public/header-nav/tools'
import { Heart02 } from '@/app/components/base/icons/src/vender/solid/education'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
const Contribute: FC = () => {
const { t } = useTranslation()
return (
<a
href='https://github.com/langgenius/dify/blob/main/api/core/tools/README.md'
target='_blank'
rel='noopener noreferrer'
className="group flex col-span-1 bg-white bg-cover bg-no-repeat bg-[url('~@/app/components/tools/provider/grid_bg.svg')] border-2 border-solid border-transparent rounded-xl shadow-sm min-h-[160px] flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg"
>
<div className='flex pt-[14px] px-[14px] pb-3 h-[66px] items-center gap-3 grow-0 shrink-0'>
<div className='relative shrink-0 flex items-center'>
<div className='z-10 flex p-3 rounded-[10px] bg-white border-[0.5px] border-primary-100 shadow-md'><ToolsActive className='w-4 h-4 text-primary-600'/></div>
<div className='-translate-x-2 flex p-3 rounded-[10px] bg-[#FEF6FB] border-[0.5px] border-[#FCE7F6] shadow-md'><Heart02 className='w-4 h-4 text-[#EE46BC]'/></div>
</div>
</div>
<div className='mb-3 px-[14px] text-[15px] leading-5 font-semibold'>
<div className='text-gradient'>{t('tools.contribute.line1')}</div>
<div className='text-gradient'>{t('tools.contribute.line2')}</div>
</div>
<div className='px-4 py-3 border-t-[0.5px] border-black/5 flex items-center space-x-1 text-[#155EEF]'>
<BookOpen01 className='w-3 h-3' />
<div className='grow leading-[18px] text-xs font-normal'>{t('tools.contribute.viewGuide')}</div>
<ArrowUpRight className='w-3 h-3' />
</div>
</a>
)
}
export default React.memo(Contribute)

View File

@@ -0,0 +1,70 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import type { CustomCollectionBackend } from '../types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { createCustomCollection } from '@/service/tools'
import Toast from '@/app/components/base/toast'
type Props = {
onRefreshData: () => void
}
const Contribute = ({ onRefreshData }: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const linkUrl = useMemo(() => {
if (language.startsWith('zh_'))
return 'https://docs.dify.ai/v/zh-hans/guides/gong-ju/quick-tool-integration'
return 'https://docs.dify.ai/tutorials/quick-tool-integration'
}, [language])
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
onRefreshData()
}
return (
<>
<div className='flex flex-col col-span-1 bg-gray-200 border-[0.5px] border-black/5 rounded-xl min-h-[160px] transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 hover:shadow-lg'>
<div className='group grow rounded-t-xl hover:bg-white' onClick={() => setIsShowEditCustomCollectionModal(true)}>
<div className='shrink-0 flex items-center p-4 pb-3'>
<div className='w-10 h-10 flex items-center justify-center border border-gray-200 bg-gray-100 rounded-lg group-hover:border-primary-100 group-hover:bg-primary-50'>
<Plus className='w-4 h-4 text-gray-500 group-hover:text-primary-600'/>
</div>
<div className='ml-3 text-sm font-semibold leading-5 text-gray-800 group-hover:text-primary-600'>{t('tools.createCustomTool')}</div>
</div>
</div>
<div className='px-4 py-3 rounded-b-xl border-t-[0.5px] border-black/5 text-gray-500 hover:text-[#155EEF] hover:bg-white'>
<a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
<BookOpen01 className='shrink-0 w-3 h-3' />
<div className='grow leading-[18px] text-xs font-normal truncate' title={t('tools.customToolTip') || ''}>{t('tools.customToolTip')}</div>
<ArrowUpRight className='shrink-0 w-3 h-3' />
</a>
</div>
</div>
{isShowEditCollectionToolModal && (
<EditCustomToolModal
payload={null}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onAdd={doCreateCustomToolCollection}
/>
)}
</>
)
}
export default Contribute

View File

@@ -0,0 +1,343 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import cn from 'classnames'
import { AuthHeaderPrefix, AuthType, CollectionType } from '../types'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import ToolItem from './tool-item'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import Toast from '@/app/components/base/toast'
import {
deleteWorkflowTool,
fetchBuiltInToolList,
fetchCustomCollection,
fetchCustomToolList,
fetchModelToolList,
fetchWorkflowToolDetail,
removeBuiltInToolCredential,
removeCustomCollection,
saveWorkflowToolProvider,
updateBuiltInToolCredential,
updateCustomCollection,
} from '@/service/tools'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { ConfigurateMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Loading from '@/app/components/base/loading'
type Props = {
collection: Collection
onRefreshData: () => void
}
const ProviderDetail = ({
collection,
onRefreshData,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const needAuth = collection.allow_delete || collection.type === CollectionType.model
const isAuthed = collection.is_team_authorization
const isBuiltIn = collection.type === CollectionType.builtIn
const isModel = collection.type === CollectionType.model
const [isDetailLoading, setIsDetailLoading] = useState(false)
// built in provider
const [showSettingAuth, setShowSettingAuth] = useState(false)
const { setShowModelModal } = useModalContext()
const { modelProviders: providers } = useProviderContext()
const showSettingAuthModal = () => {
if (isModel) {
const provider = providers.find(item => item.provider === collection?.id)
if (provider) {
setShowModelModal({
payload: {
currentProvider: provider,
currentConfigurateMethod: ConfigurateMethodEnum.predefinedModel,
currentCustomConfigrationModelFixedFields: undefined,
},
onSaveCallback: () => {
onRefreshData()
},
})
}
}
else {
setShowSettingAuth(true)
}
}
// custom provider
const [customCollection, setCustomCollection] = useState<CustomCollectionBackend | WorkflowToolProviderResponse | null>(null)
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doUpdateCustomToolCollection = async (data: CustomCollectionBackend) => {
await updateCustomCollection(data)
onRefreshData()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
}
const doRemoveCustomToolCollection = async () => {
await removeCustomCollection(collection?.name as string)
onRefreshData()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
}
const getCustomProvider = useCallback(async () => {
setIsDetailLoading(true)
const res = await fetchCustomCollection(collection.name)
if (res.credentials.auth_type === AuthType.apiKey && !res.credentials.api_key_header_prefix) {
if (res.credentials.api_key_value)
res.credentials.api_key_header_prefix = AuthHeaderPrefix.custom
}
setCustomCollection({
...res,
labels: collection.labels,
provider: collection.name,
})
setIsDetailLoading(false)
}, [collection.name])
// workflow provider
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false)
const getWorkflowToolProvider = useCallback(async () => {
setIsDetailLoading(true)
const res = await fetchWorkflowToolDetail(collection.id)
const payload = {
...res,
parameters: res.tool?.parameters.map((item) => {
return {
name: item.name,
description: item.llm_description,
form: item.form,
required: item.required,
type: item.type,
}
}) || [],
labels: res.tool?.labels || [],
}
setCustomCollection(payload)
setIsDetailLoading(false)
}, [collection.id])
const removeWorkflowToolProvider = async () => {
await deleteWorkflowTool(collection.id)
onRefreshData()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditWorkflowToolModal(false)
}
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
await saveWorkflowToolProvider(data)
onRefreshData()
getWorkflowToolProvider()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditWorkflowToolModal(false)
}
// ToolList
const [toolList, setToolList] = useState<Tool[]>([])
const getProviderToolList = useCallback(async () => {
setIsDetailLoading(true)
try {
if (collection.type === CollectionType.builtIn) {
const list = await fetchBuiltInToolList(collection.name)
setToolList(list)
}
else if (collection.type === CollectionType.model) {
const list = await fetchModelToolList(collection.name)
setToolList(list)
}
else if (collection.type === CollectionType.workflow) {
setToolList([])
}
else {
const list = await fetchCustomToolList(collection.name)
setToolList(list)
}
}
catch (e) { }
setIsDetailLoading(false)
}, [collection.name, collection.type])
useEffect(() => {
if (collection.type === CollectionType.custom)
getCustomProvider()
if (collection.type === CollectionType.workflow)
getWorkflowToolProvider()
getProviderToolList()
}, [collection.name, collection.type, getCustomProvider, getProviderToolList, getWorkflowToolProvider])
return (
<div className='px-6 py-3'>
<div className='flex items-center py-1 gap-2'>
<div className='relative shrink-0'>
{typeof collection.icon === 'string' && (
<div className='w-8 h-8 bg-center bg-cover bg-no-repeat rounded-md' style={{ backgroundImage: `url(${collection.icon})` }}/>
)}
{typeof collection.icon !== 'string' && (
<AppIcon
size='small'
icon={collection.icon.content}
background={collection.icon.background}
/>
)}
</div>
<div className='grow w-0 py-[1px]'>
<div className='flex items-center text-md leading-6 font-semibold text-gray-900'>
<div className='truncate' title={collection.label[language]}>{collection.label[language]}</div>
</div>
</div>
</div>
<div className='mt-2 min-h-[36px] text-gray-500 text-sm leading-[18px]'>{collection.description[language]}</div>
<div className='flex gap-1 border-b-[0.5px] border-black/5'>
{(collection.type === CollectionType.builtIn) && needAuth && (
<Button
type={isAuthed ? 'default' : 'primary'}
className={cn('shrink-0 my-3 w-full flex items-center', isAuthed && 'bg-white')}
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
>
{isAuthed && <Indicator className='mr-2' color={'green'} />}
<div className={cn('text-white leading-[18px] text-[13px] font-medium', isAuthed && '!text-gray-700')}>
{isAuthed ? t('tools.auth.authorized') : t('tools.auth.unauthorized')}
</div>
</Button>
)}
{collection.type === CollectionType.custom && !isDetailLoading && (
<Button
className={cn('shrink-0 my-3 w-full flex items-center bg-white')}
onClick={() => setIsShowEditCustomCollectionModal(true)}
>
<Settings01 className='mr-1 w-4 h-4 text-gray-500' />
<div className='leading-5 text-sm font-medium text-gray-700'>{t('tools.createTool.editAction')}</div>
</Button>
)}
{collection.type === CollectionType.workflow && !isDetailLoading && customCollection && (
<>
<Button
type='primary'
className={cn('shrink-0 my-3 w-[183px] flex items-center')}
>
<a className='flex items-center text-white' href={`/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel='noreferrer' target='_blank'>
<div className='leading-5 text-sm font-medium'>{t('tools.openInStudio')}</div>
<LinkExternal02 className='ml-1 w-4 h-4' />
</a>
</Button>
<Button
className={cn('shrink-0 my-3 w-[183px] flex items-center bg-white')}
onClick={() => setIsShowEditWorkflowToolModal(true)}
>
<div className='leading-5 text-sm font-medium text-gray-700'>{t('tools.createTool.editAction')}</div>
</Button>
</>
)}
</div>
{/* Tools */}
<div className='pt-3'>
{isDetailLoading && <div className='flex h-[200px]'><Loading type='app'/></div>}
{!isDetailLoading && (
<div className='text-xs font-medium leading-6 text-gray-500'>
{collection.type === CollectionType.workflow && <span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>}
{collection.type !== CollectionType.workflow && <span className=''>{t('tools.includeToolNum', { num: toolList.length }).toLocaleUpperCase()}</span>}
{needAuth && (isBuiltIn || isModel) && !isAuthed && (
<>
<span className='px-1'>·</span>
<span className='text-[#DC6803]'>{t('tools.auth.setup').toLocaleUpperCase()}</span>
</>
)}
</div>
)}
{!isDetailLoading && (
<div className='mt-1'>
{collection.type !== CollectionType.workflow && toolList.map(tool => (
<ToolItem
key={tool.name}
disabled={needAuth && (isBuiltIn || isModel) && !isAuthed}
collection={collection}
tool={tool}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
))}
{collection.type === CollectionType.workflow && (customCollection as WorkflowToolProviderResponse)?.tool?.parameters.map(item => (
<div key={item.name} className='mb-2 px-4 py-3 rounded-xl bg-gray-25 border-[0.5px] border-gray-200'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-900'>{item.name}</span>
<span className='text-xs leading-[18px] text-gray-500'>{item.type}</span>
<span className='font-medium text-xs leading-[18px] text-[#ec4a0a]'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div>
<div className='h-[18px] leading-[18px] text-gray-500 text-xs'>{item.llm_description}</div>
</div>
))}
</div>
)}
</div>
{showSettingAuth && (
<ConfigCredential
collection={collection}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await onRefreshData()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(collection.name)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await onRefreshData()
setShowSettingAuth(false)
}}
/>
)}
{isShowEditCollectionToolModal && (
<EditCustomToolModal
payload={customCollection}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onEdit={doUpdateCustomToolCollection}
onRemove={doRemoveCustomToolCollection}
/>
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={removeWorkflowToolProvider}
onSave={updateWorkflowToolProvider}
/>
)}
</div>
)
}
export default ProviderDetail

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,53 @@
'use client'
import React, { useState } from 'react'
import cn from 'classnames'
import { useContext } from 'use-context-selector'
import type { Collection, Tool } from '../types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool'
type Props = {
disabled?: boolean
collection: Collection
tool: Tool
isBuiltIn: boolean
isModel: boolean
}
const ToolItem = ({
disabled,
collection,
tool,
isBuiltIn,
isModel,
}: Props) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [showDetail, setShowDetail] = useState(false)
return (
<>
<div
className={cn('mb-2 px-4 py-3 rounded-xl bg-gray-25 border-[0.5px] border-gary-200 shadow-xs cursor-pointer', disabled && 'opacity-50 !cursor-not-allowed')}
onClick={() => !disabled && setShowDetail(true)}
>
<div className='text-gray-800 font-semibold text-sm leading-5'>{tool.label[language]}</div>
<div className='mt-0.5 text-xs leading-[18px] text-gray-500 line-clamp-2' title={tool.description[language]}>{tool.description[language]}</div>
</div>
{showDetail && (
<SettingBuiltInTool
collection={collection}
toolName={tool.name}
readonly
onHide={() => {
setShowDetail(false)
}}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
)}
</>
)
}
export default ToolItem

View File

@@ -1,41 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import {
MagnifyingGlassIcon,
} from '@heroicons/react/24/solid'
import { useTranslation } from 'react-i18next'
type Props = {
className?: string
value: string
onChange: (v: string) => void
}
const Search: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className={cn(className, 'flex relative')}>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="query"
className="block w-0 grow bg-gray-200 shadow-sm rounded-md border-0 pl-10 text-gray-900 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none sm:text-sm sm:leading-8"
placeholder={t('common.operation.search')!}
value={value}
onChange={(e) => {
onChange(e.target.value)
}}
/>
</div>
)
}
export default React.memo(Search)

View File

@@ -48,8 +48,8 @@ const ConfigCredential: FC<Props> = ({
onHide={onCancel}
title={t('tools.auth.setupModalTitle') as string}
titleDescription={t('tools.auth.setupModalTitleDescription') as string}
panelClassName='mt-2 !w-[480px]'
maxWidthClassName='!max-w-[480px]'
panelClassName='mt-2 !w-[405px]'
maxWidthClassName='!max-w-[405px]'
height='calc(100vh - 16px)'
contentClassName='!bg-gray-100'
headerClassName='!border-b-black/5'
@@ -88,7 +88,7 @@ const ConfigCredential: FC<Props> = ({
)
}
< div className='flex space-x-2'>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700' onClick={onCancel}>{t('common.operation.cancel')}</Button>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700 bg-white' onClick={onCancel}>{t('common.operation.cancel')}</Button>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium' type='primary' onClick={() => onSaved(tempCredential)}>{t('common.operation.save')}</Button>
</div>
</div>

View File

@@ -1,77 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useContext } from 'use-context-selector'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import type { Collection } from '../types'
import { CollectionType, LOC } from '../types'
import { Settings01 } from '../../base/icons/src/vender/line/general'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
type Props = {
icon: JSX.Element
collection: Collection
loc: LOC
onShowAuth: () => void
onShowEditCustomCollection: () => void
}
const Header: FC<Props> = ({
icon,
collection,
loc,
onShowAuth,
onShowEditCustomCollection,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { t } = useTranslation()
const isInToolsPage = loc === LOC.tools
const isInDebugPage = !isInToolsPage
const needAuth = collection?.allow_delete || collection?.type === CollectionType.model
const isAuthed = collection.is_team_authorization
return (
<div className={cn(isInToolsPage ? 'py-4 px-6' : 'py-[11px] pl-4 pr-3', 'flex justify-between items-start border-b border-gray-200')}>
<div className='flex items-start w-full'>
{icon}
<div className='ml-3 grow w-0'>
<div className='flex items-center h-6 space-x-1'>
<div className={cn(isInDebugPage && 'truncate', 'text-base font-semibold text-gray-900')}>{collection.label[language]}</div>
<div className='text-xs font-normal text-gray-500'>·</div>
<div className='text-xs font-normal text-gray-500'>{t('tools.author')}&nbsp;{collection.author}</div>
</div>
{collection.description && (
<div className={cn('leading-[18px] text-[13px] font-normal text-gray-500')}>
{collection.description[language]}
</div>
)}
</div>
</div>
{(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && (
<div
className={cn('cursor-pointer', 'ml-1 shrink-0 flex items-center h-8 border border-gray-200 rounded-lg px-3 space-x-2 shadow-xs')}
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
onShowAuth()
}}
>
<div className={cn(isAuthed ? 'border-[#12B76A] bg-[#32D583]' : 'border-gray-400 bg-gray-300', 'rounded h-2 w-2 border')}></div>
<div className='leading-5 text-sm font-medium text-gray-700'>{t(`tools.auth.${isAuthed ? 'authorized' : 'unauthorized'}`)}</div>
</div>
)}
{collection.type === CollectionType.custom && (
<div
className={cn('cursor-pointer', 'ml-1 shrink-0 flex items-center h-8 border border-gray-200 rounded-lg px-3 space-x-2 shadow-xs')}
onClick={() => onShowEditCustomCollection()}
>
<Settings01 className='w-4 h-4 text-gray-700' />
<div className='leading-5 text-sm font-medium text-gray-700'>{t('tools.createTool.editAction')}</div>
</div>
)}
</div >
)
}
export default React.memo(Header)

View File

@@ -1,220 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { AuthHeaderPrefix, AuthType, CollectionType, LOC } from '../types'
import type { Collection, CustomCollectionBackend, Tool } from '../types'
import Loading from '../../base/loading'
import { ArrowNarrowRight } from '../../base/icons/src/vender/line/arrows'
import Toast from '../../base/toast'
import { ConfigurateMethodEnum } from '../../header/account-setting/model-provider-page/declarations'
import Header from './header'
import Item from './item'
import AppIcon from '@/app/components/base/app-icon'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { fetchCustomCollection, removeBuiltInToolCredential, removeCustomCollection, updateBuiltInToolCredential, updateCustomCollection } from '@/service/tools'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import type { AgentTool } from '@/types/app'
import { MAX_TOOLS_NUM } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
type Props = {
collection: Collection | null
list: Tool[]
// onToolListChange: () => void // custom tools change
loc: LOC
addedTools?: AgentTool[]
onAddTool?: (collection: Collection, payload: Tool) => void
onRefreshData: () => void
onCollectionRemoved: () => void
isLoading: boolean
}
const ToolList: FC<Props> = ({
collection,
list,
loc,
addedTools,
onAddTool,
onRefreshData,
onCollectionRemoved,
isLoading,
}) => {
const { t } = useTranslation()
const isInToolsPage = loc === LOC.tools
const isBuiltIn = collection?.type === CollectionType.builtIn
const isModel = collection?.type === CollectionType.model
const needAuth = collection?.allow_delete
const { setShowModelModal } = useModalContext()
const [showSettingAuth, setShowSettingAuth] = useState(false)
const { modelProviders: providers } = useProviderContext()
const showSettingAuthModal = () => {
if (isModel) {
const provider = providers.find(item => item.provider === collection?.id)
if (provider) {
setShowModelModal({
payload: {
currentProvider: provider,
currentConfigurateMethod: ConfigurateMethodEnum.predefinedModel,
currentCustomConfigrationModelFixedFields: undefined,
},
onSaveCallback: () => {
onRefreshData()
},
})
}
}
else {
setShowSettingAuth(true)
}
}
const [customCollection, setCustomCollection] = useState<CustomCollectionBackend | null>(null)
useEffect(() => {
if (!collection)
return
(async () => {
if (collection.type === CollectionType.custom) {
const res = await fetchCustomCollection(collection.name)
if (res.credentials.auth_type === AuthType.apiKey && !res.credentials.api_key_header_prefix) {
if (res.credentials.api_key_value)
res.credentials.api_key_header_prefix = AuthHeaderPrefix.custom
}
setCustomCollection({
...res,
provider: collection.name,
})
}
})()
}, [collection])
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doUpdateCustomToolCollection = async (data: CustomCollectionBackend) => {
await updateCustomCollection(data)
onRefreshData()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
}
const doRemoveCustomToolCollection = async () => {
await removeCustomCollection(collection?.name as string)
onCollectionRemoved()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
}
if (!collection || isLoading)
return <Loading type='app' />
const icon = <>{typeof collection.icon === 'string'
? (
<div
className='p-2 bg-cover bg-center border border-gray-100 rounded-lg'
>
<div className='w-6 h-6 bg-center bg-contain rounded-md'
style={{
backgroundImage: `url(${collection.icon})`,
}}
></div>
</div>
)
: (
<AppIcon
size='large'
icon={collection.icon.content}
background={collection.icon.background}
/>
)}
</>
return (
<div className='flex flex-col h-full pb-4'>
<Header
icon={icon}
collection={collection}
loc={loc}
onShowAuth={() => showSettingAuthModal()}
onShowEditCustomCollection={() => setIsShowEditCustomCollectionModal(true)}
/>
<div className={cn(isInToolsPage ? 'px-6 pt-4' : 'px-4 pt-3')}>
<div className='flex items-center h-[4.5] space-x-2 text-xs font-medium text-gray-500'>
<div className=''>{t('tools.includeToolNum', {
num: list.length,
})}</div>
{needAuth && (isBuiltIn || isModel) && !collection.is_team_authorization && (
<>
<div>·</div>
<div
className='flex items-center text-[#155EEF] cursor-pointer'
onClick={() => showSettingAuthModal()}
>
<div>{t('tools.auth.setup')}</div>
<ArrowNarrowRight className='ml-0.5 w-3 h-3' />
</div>
</>
)}
</div>
</div>
<div className={cn(isInToolsPage ? 'px-6' : 'px-4', 'grow h-0 pt-2 overflow-y-auto')}>
{/* list */}
<div className={cn(isInToolsPage ? 'grid-cols-3 gap-4' : 'grid-cols-1 gap-2', 'grid')}>
{list.map(item => (
<Item
key={item.name}
icon={icon}
payload={item}
collection={collection}
isInToolsPage={isInToolsPage}
isToolNumMax={(addedTools?.length || 0) >= MAX_TOOLS_NUM}
added={!!addedTools?.find(v => v.provider_id === collection.id && v.provider_type === collection.type && v.tool_name === item.name)}
onAdd={!isInToolsPage ? tool => onAddTool?.(collection as Collection, tool) : undefined}
/>
))}
</div>
</div>
{showSettingAuth && (
<ConfigCredential
collection={collection}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await onRefreshData()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(collection.name)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await onRefreshData()
setShowSettingAuth(false)
}}
/>
)}
{isShowEditCollectionToolModal && (
<EditCustomToolModal
payload={customCollection}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onEdit={doUpdateCustomToolCollection}
onRemove={doRemoveCustomToolCollection}
/>
)}
</div>
)
}
export default React.memo(ToolList)

View File

@@ -1,84 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useContext } from 'use-context-selector'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import type { Collection, Tool } from '../types'
import Button from '../../base/button'
import { CollectionType } from '../types'
import TooltipPlus from '../../base/tooltip-plus'
import I18n from '@/context/i18n'
import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool'
import { getLanguage } from '@/i18n/language'
type Props = {
collection: Collection
icon: JSX.Element
payload: Tool
isInToolsPage: boolean
isToolNumMax: boolean
added?: boolean
onAdd?: (payload: Tool) => void
}
const Item: FC<Props> = ({
collection,
icon,
payload,
isInToolsPage,
isToolNumMax,
added,
onAdd,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const isBuiltIn = collection.type === CollectionType.builtIn
const isModel = collection.type === CollectionType.model
const canShowDetail = isInToolsPage
const [showDetail, setShowDetail] = useState(false)
const addBtn = <Button className='shrink-0 flex items-center h-7 !px-3 !text-xs !font-medium !text-gray-700' disabled={added || !collection.is_team_authorization} onClick={() => onAdd?.(payload)}>{t(`common.operation.${added ? 'added' : 'add'}`)}</Button>
return (
<>
<div
className={cn(canShowDetail && 'cursor-pointer', 'flex justify-between items-start p-4 rounded-xl border border-gray-200 bg-gray-50 shadow-xs')}
onClick={() => canShowDetail && setShowDetail(true)}
>
<div className='flex items-start w-full'>
{icon}
<div className='ml-3 w-0 grow'>
<div className={cn('text-base font-semibold text-gray-900 truncate')}>{payload.label[language]}</div>
<div className={cn('leading-[18px] text-[13px] font-normal text-gray-500')}>
{payload.description[language]}
</div>
</div>
</div>
<div className='shrink-0'>
{!isToolNumMax && onAdd && (
!collection.is_team_authorization
? <TooltipPlus popupContent={t('tools.auth.unauthorized')}>
{addBtn}
</TooltipPlus>
: addBtn
)}
</div>
</div>
{showDetail && (
<SettingBuiltInTool
collection={collection}
toolName={payload.name}
readonly
onHide={() => {
setShowDetail(false)
}}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
)}
</>
)
}
export default React.memo(Item)

View File

@@ -1,28 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import Item from './item'
import type { Collection } from '@/app/components/tools/types'
type Props = {
className?: string
currentIndex: number
list: Collection[]
onChosen: (index: number) => void
}
const ToolNavList: FC<Props> = ({
className,
currentIndex,
list,
onChosen,
}) => {
return (
<div className={cn(className)}>
{list.map((item, index) => (
<Item isCurrent={index === currentIndex} key={index} payload={item} onClick={() => onChosen(index)}></Item>
))}
</div>
)
}
export default React.memo(ToolNavList)

View File

@@ -1,50 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useContext } from 'use-context-selector'
import cn from 'classnames'
import AppIcon from '../../base/app-icon'
import type { Collection } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
type Props = {
isCurrent: boolean
payload: Collection
onClick: () => void
}
const Item: FC<Props> = ({
isCurrent,
payload,
onClick,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
return (
<div
className={cn(isCurrent && 'bg-white shadow-xs rounded-lg', 'mt-1 flex h-9 items-center px-2 space-x-2 cursor-pointer')}
onClick={() => !isCurrent && onClick()}
>
{typeof payload.icon === 'string'
? (
<div
className='w-6 h-6 bg-cover bg-center rounded-md'
style={{
backgroundImage: `url(${payload.icon})`,
}}
></div>
)
: (
<AppIcon
size='tiny'
icon={payload.icon.content}
background={payload.icon.background}
/>
)}
<div className={cn(isCurrent && 'text-primary-600 font-semibold', 'leading-5 text-sm font-normal truncate')}>{payload.label[language]}</div>
</div>
)
}
export default React.memo(Item)

View File

@@ -27,6 +27,7 @@ export enum CollectionType {
builtIn = 'builtin',
custom = 'api',
model = 'model',
workflow = 'workflow',
}
export type Emoji = {
@@ -45,6 +46,7 @@ export type Collection = {
team_credentials: Record<string, any>
is_team_authorization: boolean
allow_delete: boolean
labels: string[]
}
export type ToolParameter = {
@@ -52,19 +54,25 @@ export type ToolParameter = {
label: TypeWithI18N
human_description: TypeWithI18N
type: string
form: string
llm_description: string
required: boolean
default: string
options?: {
label: TypeWithI18N
value: string
}[]
min?: number
max?: number
}
export type Tool = {
name: string
author: string
label: TypeWithI18N
description: any
parameters: ToolParameter[]
labels: string[]
}
export type ToolCredential = {
@@ -91,13 +99,17 @@ export type CustomCollectionBackend = {
privacy_policy: string
custom_disclaimer: string
tools?: ParamItem[]
id: string
labels: string[]
}
export type ParamItem = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
llm_description: string
type: string
form: string
required: boolean
default: string
min?: number
@@ -115,3 +127,39 @@ export type CustomParamSchema = {
method: string
parameters: ParamItem[]
}
export type WorkflowToolProviderParameter = {
name: string
form: string
description: string
required?: boolean
type?: string
}
export type WorkflowToolProviderRequest = {
name: string
icon: Emoji
description: string
parameters: WorkflowToolProviderParameter[]
labels: string[]
privacy_policy: string
}
export type WorkflowToolProviderResponse = {
workflow_app_id: string
workflow_tool_id: string
label: string
name: string
icon: Emoji
description: string
synced: boolean
tool: {
author: string
name: string
label: TypeWithI18N
description: TypeWithI18N
labels: string[]
parameters: ParamItem[]
}
privacy_policy: string
}

View File

@@ -0,0 +1,225 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import Button from '@/app/components/base/button'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { Tools } from '@/app/components/base/icons/src/vender/line/others'
import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools'
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar } from '@/app/components/workflow/types'
type Props = {
disabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
handlePublish: () => void
onRefreshData?: () => void
}
const WorkflowToolConfigureButton = ({
disabled,
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
handlePublish,
onRefreshData,
}: Props) => {
const { t } = useTranslation()
const router = useRouter()
const [showModal, setShowModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
const outdated = useMemo(() => {
if (!detail)
return false
if (detail.tool.parameters.length !== inputs?.length) {
return true
}
else {
for (const item of inputs || []) {
const param = detail.tool.parameters.find(toolParam => toolParam.name === item.variable)
if (!param) {
return true
}
else if (param.required !== item.required) {
return true
}
else {
if (item.type === 'paragraph' && param.type !== 'string')
return true
if (param.type !== item.type && !(param.type === 'string' && item.type === 'paragraph'))
return true
}
}
}
return false
}, [detail, inputs])
const payload = useMemo(() => {
let parameters: WorkflowToolProviderParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}
})
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => {
return {
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(param => param.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(param => param.name === item.variable)?.form || 'llm',
}
})
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
name: detail?.name || '',
description: detail?.description || description,
parameters,
labels: detail?.tool?.labels || [],
privacy_policy: detail?.privacy_policy || '',
...(published
? {
workflow_tool_id: detail?.workflow_tool_id,
}
: {
workflow_app_id: workflowAppId,
}),
}
}, [detail, published, workflowAppId, icon, name, description, inputs])
const getDetail = useCallback(async (workflowAppId: string) => {
setIsLoading(true)
const res = await fetchWorkflowToolDetailByAppID(workflowAppId)
setDetail(res)
setIsLoading(false)
}, [])
useEffect(() => {
if (published)
getDetail(workflowAppId)
}, [getDetail, published, workflowAppId])
useEffect(() => {
if (detailNeedUpdate)
getDetail(workflowAppId)
}, [detailNeedUpdate, getDetail, workflowAppId])
const createHandle = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
await createWorkflowToolProvider(data)
onRefreshData?.()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => {
try {
await handlePublish()
await saveWorkflowToolProvider(data)
onRefreshData?.()
getDetail(workflowAppId)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setShowModal(false)
}
catch (e) {
Toast.notify({ type: 'error', message: (e as Error).message })
}
}
return (
<>
<div className='mt-2 pt-2 border-t-[0.5px] border-t-black/5'>
{(!published || !isLoading) && (
<div className={cn(
'group bg-gray-100 rounded-lg transition-colors',
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'cursor-pointer',
!published && 'hover:bg-primary-50',
)}>
<div
className='flex justify-start items-center gap-2 px-2.5 py-2'
onClick={() => !published && setShowModal(true)}
>
<Tools className={cn('relative w-4 h-4', !published && 'group-hover:text-primary-600')}/>
<div title={t('workflow.common.workflowAsTool') || ''} className={cn('grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate', !published && 'group-hover:text-primary-600')}>{t('workflow.common.workflowAsTool')}</div>
{!published && (
<span className='shrink-0 px-1 border border-black/8 rounded-[5px] bg-white text-[10px] font-medium leading-[18px] text-gray-500'>{t('workflow.common.configureRequired').toLocaleUpperCase()}</span>
)}
</div>
{published && (
<div className='px-2.5 py-2 border-t-[0.5px] border-black/5'>
<div className='flex justify-between'>
<Button
className='px-2 w-[140px] py-0 h-6 shadow-xs rounded-md text-xs font-medium text-gray-700 border-[0.5px] bg-white border-gray-200'
onClick={() => setShowModal(true)}
>
{t('workflow.common.configure')}
{outdated && <Indicator className='ml-1' color={'yellow'} />}
</Button>
<Button
className='px-2 w-[140px] py-0 h-6 shadow-xs rounded-md text-xs font-medium text-gray-700 border-[0.5px] bg-white border-gray-200'
onClick={() => router.push('/tools?category=workflow')}
>
{t('workflow.common.manageInTools')}
<ArrowUpRight className='ml-1' />
</Button>
</div>
{outdated && <div className='mt-1 text-xs leading-[18px] text-[#dc6803]'>{t('workflow.common.workflowAsToolTip')}</div>}
</div>
)}
</div>
)}
{published && isLoading && <div className='pt-2'><Loading type='app'/></div>}
</div>
{showModal && (
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={() => setShowModal(false)}
onCreate={createHandle}
onSave={updateWorkflowToolProvider}
/>
)}
</>
)
}
export default WorkflowToolConfigureButton

View File

@@ -0,0 +1,47 @@
'use client'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import s from './style.module.css'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
type ConfirmModalProps = {
show: boolean
onConfirm?: () => void
onClose: () => void
}
const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
const { t } = useTranslation()
return (
<Modal
wrapperClassName='!z-[1020]'
className={cn('p-8 max-w-[600px] w-[600px]', s.bg)}
isShow={show}
onClose={() => {}}
>
<div className='absolute right-4 top-4 p-2 cursor-pointer' onClick={onClose}>
<XClose className='w-4 h-4 text-gray-500' />
</div>
<div className='w-12 h-12 p-3 bg-white rounded-xl border-[0.5px] border-gray-100 shadow-xl'>
<AlertTriangle className='w-6 h-6 text-[rgb(247,144,9)]' />
</div>
<div className='relative mt-3 text-xl font-semibold leading-[30px] text-gray-900'>{t('tools.createTool.confirmTitle')}</div>
<div className='my-1 text-gray-500 text-sm leading-5'>
{t('tools.createTool.confirmTip')}
</div>
<div className='pt-6 flex justify-end items-center'>
<div className='flex items-center'>
<Button className='mr-2 text-gray-700 text-sm font-medium' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button className='text-sm font-medium border-red-700 border-[0.5px]' type="warning" onClick={onConfirm}>{t('common.operation.confirm')}</Button>
</div>
</div>
</Modal>
)
}
export default ConfirmModal

View File

@@ -0,0 +1,3 @@
.bg {
background: linear-gradient(180deg, rgba(247, 144, 9, 0.05) 0%, rgba(247, 144, 9, 0.00) 24.41%), #F9FAFB;
}

View File

@@ -0,0 +1,282 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import produce from 'immer'
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import Drawer from '@/app/components/base/drawer-plus'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import EmojiPicker from '@/app/components/base/emoji-picker'
import AppIcon from '@/app/components/base/app-icon'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
isAdd?: boolean
payload: any
onHide: () => void
onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
onSave?: (payload: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
workflow_tool_id: string
}>) => void
}
// Add and Edit
const WorkflowToolAsModal: FC<Props> = ({
isAdd,
payload,
onHide,
onRemove,
onSave,
onCreate,
}) => {
const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState<Boolean>(false)
const [emoji, setEmoji] = useState<Emoji>(payload.icon)
const [label, setLabel] = useState<string>(payload.label)
const [name, setName] = useState(payload.name)
const [description, setDescription] = useState(payload.description)
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
const handleParameterChange = (key: string, value: string, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
if (key === 'description')
draft[index].description = value
else
draft[index].form = value
})
setParameters(newData)
}
const [labels, setLabels] = useState<string[]>(payload.labels)
const handleLabelSelect = (value: string[]) => {
setLabels(value)
}
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
const [showModal, setShowModal] = useState(false)
const isNameValid = (name: string) => {
return /^[a-zA-Z0-9_]+$/.test(name)
}
const onConfirm = () => {
if (!label) {
return Toast.notify({
type: 'error',
message: 'Please enter the tool name',
})
}
if (!name) {
return Toast.notify({
type: 'error',
message: 'Please enter the name for tool call',
})
}
else if (!isNameValid(name)) {
return Toast.notify({
type: 'error',
message: 'Name for tool call can only contain numbers, letters, and underscores',
})
}
const requestParams = {
name,
description,
icon: emoji,
label,
parameters: parameters.map(item => ({
name: item.name,
description: item.description,
form: item.form,
})),
labels,
privacy_policy: privacyPolicy,
}
if (!isAdd) {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id,
})
}
else {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id,
})
}
}
return (
<>
<Drawer
isShow
onHide={onHide}
title={t('workflow.common.workflowAsTool')!}
panelClassName='mt-2 !w-[640px]'
maxWidthClassName='!max-w-[640px]'
height='calc(100vh - 16px)'
headerClassName='!border-b-black/5'
body={
<div className='flex flex-col h-full'>
<div className='grow h-0 overflow-y-auto px-6 py-3 space-y-4'>
{/* name & icon */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.name')}</div>
<div className='flex items-center justify-between gap-3'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.content} background={emoji.background} />
<input
type='text'
className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs'
placeholder={t('tools.createTool.toolNamePlaceHolder')!}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
</div>
{/* name for tool call */}
<div>
<div className='flex items-center py-2 leading-5 text-sm font-medium text-gray-900'>
{t('tools.createTool.nameForToolCall')}
<Tooltip
htmlContent={
<div className='w-[180px]'>
{t('tools.createTool.nameForToolCallPlaceHolder')}
</div>
}
selector='workflow-tool-modal-tooltip'
>
<HelpCircle className='ml-2 w-[14px] h-[14px] text-gray-400' />
</Tooltip>
</div>
<input
type='text'
className='w-full h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs'
placeholder={t('tools.createTool.nameForToolCallPlaceHolder')!}
value={name}
onChange={e => setName(e.target.value)}
/>
{!isNameValid(name) && (
<div className='text-xs leading-[18px] text-[#DC6803]'>{t('tools.createTool.nameForToolCallTip')}</div>
)}
</div>
{/* description */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.description')}</div>
<textarea
className='w-full h-10 px-3 py-2 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs h-[80px] resize-none'
placeholder={t('tools.createTool.descriptionPlaceholder') || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.toolInput.title')}</div>
<div className='rounded-lg border border-gray-200 w-full overflow-x-auto'>
<table className='w-full leading-[18px] text-xs text-gray-700 font-normal'>
<thead className='text-gray-500 uppercase'>
<tr className='border-b border-gray-200'>
<th className="p-2 pl-3 font-medium w-[156px]">{t('tools.createTool.toolInput.name')}</th>
<th className="p-2 pl-3 font-medium w-[102px]">{t('tools.createTool.toolInput.method')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.toolInput.description')}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className='border-b last:border-0 border-gray-200'>
<td className="p-2 pl-3 max-w-[156px]">
<div className='text-[13px] leading-[18px]'>
<div title={item.name} className='flex'>
<span className='font-medium text-gray-900 truncate'>{item.name}</span>
<span className='shrink-0 pl-1 text-[#ec4a0a] text-xs leading-[18px]'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div>
<div className='text-gray-500'>{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex items-center gap-1 min-h-[56px] px-3 py-2 h-9 bg-white cursor-default',
)}>
<div className={cn('grow text-[13px] leading-[18px] text-gray-700 truncate')}>
{t('tools.createTool.toolInput.methodParameter')}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)}/>
)}
</td>
<td className="p-2 pl-3 text-gray-500 w-[236px]">
<input
type='text'
className='grow text-gray-700 text-[13px] leading-[18px] font-normal bg-white outline-none appearance-none caret-primary-600 placeholder:text-gray-300'
placeholder={t('tools.createTool.toolInput.descriptionPlaceholder')!}
value={item.description}
onChange={e => handleParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tags */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.toolInput.label')}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className='py-2 leading-5 text-sm font-medium text-gray-900'>{t('tools.createTool.privacyPolicy')}</div>
<input
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
className='grow w-full h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs' placeholder={t('tools.createTool.privacyPolicyPlaceholder') || ''} />
</div>
</div>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 shrink-0 flex py-4 px-6 rounded-b-[10px] bg-gray-50 border-t border-black/5')} >
{!isAdd && onRemove && (
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700' onClick={onRemove}>{t('common.operation.remove')}</Button>
)}
<div className='flex space-x-2 '>
<Button className='flex items-center h-8 !px-3 !text-[13px] font-medium !text-gray-700' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button disabled={!label || !name || !isNameValid(name)} className='flex items-center h-8 !px-3 !text-[13px] font-medium' type='primary' onClick={() => {
if (isAdd)
onConfirm()
else
setShowModal(true)
}}>{t('common.operation.save')}</Button>
</div>
</div>
</div>
}
isShowMask={true}
clickOutsideNotOpen={true}
/>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>}
{showModal && (
<ConfirmModal
show={showModal}
onClose={() => setShowModal(false)}
onConfirm={onConfirm}
/>
)}
</>
)
}
export default React.memo(WorkflowToolAsModal)

View File

@@ -0,0 +1,77 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
type MethodSelectorProps = {
value?: string
onChange: (v: string) => void
}
const MethodSelector: FC<MethodSelectorProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<div className='relative'>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className='block'
>
<div className={cn(
'flex items-center gap-1 min-h-[56px] px-3 py-2 h-9 bg-white cursor-pointer hover:bg-gray-100',
open && '!bg-gray-100 hover:bg-gray-100',
)}>
<div className={cn('grow text-[13px] leading-[18px] text-gray-700 truncate')}>
{value === 'llm' ? t('tools.createTool.toolInput.methodParameter') : t('tools.createTool.toolInput.methodSetting')}
</div>
<div className='shrink-0 ml-1 text-gray-700 opacity-60'>
<ChevronDown className='h-4 w-4'/>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1040]'>
<div className='relative w-[320px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
<div className='p-1'>
<div className='pl-3 pr-2 py-2.5 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => onChange('llm')}>
<div className='flex item-center gap-1'>
<div className='shrink-0 w-4 h-4'>
{value === 'llm' && <Check className='shrink-0 w-4 h-4 text-primary-600'/>}
</div>
<div className='text-[13px] text-gray-700 font-medium leading-[18px]'>{t('tools.createTool.toolInput.methodParameter')}</div>
</div>
<div className='pl-5 text-gray-500 text-[13px] leading-[18px]'>{t('tools.createTool.toolInput.methodParameterTip')}</div>
</div>
<div className='pl-3 pr-2 py-2.5 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => onChange('form')}>
<div className='flex item-center gap-1'>
<div className='shrink-0 w-4 h-4'>
{value === 'form' && <Check className='shrink-0 w-4 h-4 text-primary-600'/>}
</div>
<div className='text-[13px] text-gray-700 font-medium leading-[18px]'>{t('tools.createTool.toolInput.methodSetting')}</div>
</div>
<div className='pl-5 text-gray-500 text-[13px] leading-[18px]'>{t('tools.createTool.toolInput.methodSettingTip')}</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default MethodSelector