This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import type { Credential } from '@/app/components/tools/types'
import Input from '@/app/components/base/input'
import Drawer from '@/app/components/base/drawer-plus'
import Button from '@/app/components/base/button'
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
}
type ItemProps = {
text: string
value: AuthType | AuthHeaderPrefix
isChecked: boolean
onClick: (value: AuthType | AuthHeaderPrefix) => void
}
const SelectItem: FC<ItemProps> = ({ text, value, isChecked, onClick }) => {
return (
<div
className={cn(isChecked ? 'border-[2px] border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border', 'mb-2 flex h-9 w-[150px] cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 hover:bg-components-panel-on-panel-item-bg-hover')}
onClick={() => onClick(value)}
>
<Radio isChecked={isChecked} />
<div className='system-sm-regular text-text-primary'>{text}</div>
</div>
)
}
const ConfigCredential: FC<Props> = ({
positionCenter,
credential,
onChange,
onHide,
}) => {
const { t } = useTranslation()
const [tempCredential, setTempCredential] = React.useState<Credential>(credential)
return (
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={t('tools.createTool.authMethod.title')!}
dialogClassName='z-[60]'
dialogBackdropClassName='z-[70]'
panelClassName='mt-2 !w-[520px] h-fit z-[80]'
maxWidthClassName='!max-w-[520px]'
height={'fit-content'}
headerClassName='!border-b-divider-regular'
body={
<div className='px-6 pt-2'>
<div className='space-y-4'>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authMethod.type')}</div>
<div className='flex space-x-3'>
<SelectItem
text={t('tools.createTool.authMethod.types.none')}
value={AuthType.none}
isChecked={tempCredential.auth_type === AuthType.none}
onClick={value => setTempCredential({
auth_type: value as AuthType,
})}
/>
<SelectItem
text={t('tools.createTool.authMethod.types.api_key_header')}
value={AuthType.apiKeyHeader}
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_header: tempCredential.api_key_header || 'Authorization',
api_key_value: tempCredential.api_key_value || '',
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
})}
/>
<SelectItem
text={t('tools.createTool.authMethod.types.api_key_query')}
value={AuthType.apiKeyQuery}
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
onClick={value => setTempCredential({
auth_type: value as AuthType,
api_key_query_param: tempCredential.api_key_query_param || 'key',
api_key_value: tempCredential.api_key_value || '',
})}
/>
</div>
</div>
{tempCredential.auth_type === AuthType.apiKeyHeader && (
<>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authHeaderPrefix.title')}</div>
<div className='flex space-x-3'>
<SelectItem
text={t('tools.createTool.authHeaderPrefix.types.basic')}
value={AuthHeaderPrefix.basic}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('tools.createTool.authHeaderPrefix.types.bearer')}
value={AuthHeaderPrefix.bearer}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
<SelectItem
text={t('tools.createTool.authHeaderPrefix.types.custom')}
value={AuthHeaderPrefix.custom}
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
/>
</div>
</div>
<div>
<div className='system-sm-medium flex items-center py-2 text-text-primary'>
{t('tools.createTool.authMethod.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('tools.createTool.authMethod.keyTooltip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4'
/>
</div>
<Input
value={tempCredential.api_key_header}
onChange={e => setTempCredential({ ...tempCredential, api_key_header: e.target.value })}
placeholder={t('tools.createTool.authMethod.types.apiKeyPlaceholder')!}
/>
</div>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authMethod.value')}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('tools.createTool.authMethod.types.apiValuePlaceholder')!}
/>
</div>
</>)}
{tempCredential.auth_type === AuthType.apiKeyQuery && (
<>
<div>
<div className='system-sm-medium flex items-center py-2 text-text-primary'>
{t('tools.createTool.authMethod.queryParam')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('tools.createTool.authMethod.queryParamTooltip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4'
/>
</div>
<Input
value={tempCredential.api_key_query_param}
onChange={e => setTempCredential({ ...tempCredential, api_key_query_param: e.target.value })}
placeholder={t('tools.createTool.authMethod.types.queryParamPlaceholder')!}
/>
</div>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authMethod.value')}</div>
<Input
value={tempCredential.api_key_value}
onChange={e => setTempCredential({ ...tempCredential, api_key_value: e.target.value })}
placeholder={t('tools.createTool.authMethod.types.apiValuePlaceholder')!}
/>
</div>
</>)}
</div>
<div className='mt-4 flex shrink-0 justify-end space-x-2 py-4'>
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={() => {
onChange(tempCredential)
onHide()
}}>{t('common.operation.save')}</Button>
</div>
</div>
}
/>
)
}
export default React.memo(ConfigCredential)

View File

@@ -0,0 +1,181 @@
const examples = [
{
key: 'json',
content: `{
"openapi": "3.1.0",
"info": {
"title": "Get weather data",
"description": "Retrieves current weather data for a location.",
"version": "v1.0.0"
},
"servers": [
{
"url": "https://weather.example.com"
}
],
"paths": {
"/location": {
"get": {
"description": "Get temperature for a specific location",
"operationId": "GetCurrentWeather",
"parameters": [
{
"name": "location",
"in": "query",
"description": "The city and state to retrieve the weather for",
"required": true,
"schema": {
"type": "string"
}
}
],
"deprecated": false
}
}
},
"components": {
"schemas": {}
}
}`,
},
{
key: 'yaml',
content: `# Taken from https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/petstore.yaml
openapi: "3.0.0"
info:
version: 1.0.0
title: Swagger Petstore
license:
name: MIT
servers:
- url: https://petstore.swagger.io/v1
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
schema:
type: integer
maximum: 100
format: int32
responses:
'200':
description: A paged array of pets
headers:
x-next:
description: A link to the next page of responses
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Pets"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
post:
summary: Create a pet
operationId: createPets
tags:
- pets
responses:
'201':
description: Null response
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/pets/{petId}:
get:
summary: Info for a specific pet
operationId: showPetById
tags:
- pets
parameters:
- name: petId
in: path
required: true
description: The id of the pet to retrieve
schema:
type: string
responses:
'200':
description: Expected response to a valid request
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string
Pets:
type: array
maxItems: 100
items:
$ref: "#/components/schemas/Pet"
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string`,
},
{
key: 'blankTemplate',
content: `{
"openapi": "3.1.0",
"info": {
"title": "Untitled",
"description": "Your OpenAPI specification",
"version": "v1.0.0"
},
"servers": [
{
"url": ""
}
],
"paths": {},
"components": {
"schemas": {}
}
}`,
},
]
export default examples

View File

@@ -0,0 +1,123 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'ahooks'
import {
RiAddLine,
RiArrowDownSLine,
} from '@remixicon/react'
import Toast from '../../base/toast'
import examples from './examples'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { importSchemaFromURL } from '@/service/tools'
type Props = {
onChange: (value: string) => void
}
const GetSchema: FC<Props> = ({
onChange,
}) => {
const { t } = useTranslation()
const [showImportFromUrl, setShowImportFromUrl] = useState(false)
const [importUrl, setImportUrl] = useState('')
const [isParsing, setIsParsing] = useState(false)
const handleImportFromUrl = async () => {
if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) {
Toast.notify({
type: 'error',
message: t('tools.createTool.urlError'),
})
return
}
setIsParsing(true)
try {
const { schema } = await importSchemaFromURL(importUrl) as any
setImportUrl('')
onChange(schema)
}
finally {
setIsParsing(false)
setShowImportFromUrl(false)
}
}
const importURLRef = React.useRef(null)
useClickAway(() => {
setShowImportFromUrl(false)
}, importURLRef)
const [showExamples, setShowExamples] = useState(false)
const showExamplesRef = React.useRef(null)
useClickAway(() => {
setShowExamples(false)
}, showExamplesRef)
return (
<div className='relative flex w-[224px] justify-end space-x-1'>
<div ref={importURLRef}>
<Button
size='small'
className='space-x-1 '
onClick={() => { setShowImportFromUrl(!showImportFromUrl) }}
>
<RiAddLine className='h-3 w-3' />
<div className='system-xs-medium text-text-secondary'>{t('tools.createTool.importFromUrl')}</div>
</Button>
{showImportFromUrl && (
<div className=' absolute left-[-35px] top-[26px] rounded-lg border border-components-panel-border bg-components-panel-bg p-2 shadow-lg'>
<div className='relative'>
<Input
type='text'
className='w-[244px]'
placeholder={t('tools.createTool.importFromUrlPlaceHolder')!}
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
/>
<Button
className='absolute right-1 top-1'
size='small'
variant='primary'
disabled={!importUrl}
onClick={handleImportFromUrl}
loading={isParsing}
>
{isParsing ? '' : t('common.operation.ok')}
</Button>
</div>
</div>
)}
</div>
<div className='relative -mt-0.5' ref={showExamplesRef}>
<Button
size='small'
className='space-x-1'
onClick={() => { setShowExamples(!showExamples) }}
>
<div className='system-xs-medium text-text-secondary'>{t('tools.createTool.examples')}</div>
<RiArrowDownSLine className='h-3 w-3' />
</Button>
{showExamples && (
<div className='absolute right-0 top-7 rounded-lg bg-components-panel-bg p-1 shadow-sm'>
{examples.map(item => (
<div
key={item.key}
onClick={() => {
onChange(item.content)
setShowExamples(false)
}}
className='system-sm-regular cursor-pointer whitespace-nowrap rounded-lg px-3 py-1.5 leading-5 text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover'
>
{t(`tools.createTool.exampleOptions.${item.key}`)}
</div>
))}
</div>
)}
</div>
</div>
)
}
export default React.memo(GetSchema)

View File

@@ -0,0 +1,369 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounce, useGetState } from 'ahooks'
import { RiSettings2Line } from '@remixicon/react'
import { produce } from 'immer'
import { LinkExternal02 } from '../../base/icons/src/vender/line/general'
import type { Credential, CustomCollectionBackend, CustomParamSchema, Emoji } from '../types'
import { AuthHeaderPrefix, AuthType } from '../types'
import GetSchema from './get-schema'
import ConfigCredentials from './config-credentials'
import TestApi from './test-api'
import cn from '@/utils/classnames'
import Drawer from '@/app/components/base/drawer-plus'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
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'
import Toast from '@/app/components/base/toast'
type Props = {
positionLeft?: boolean
dialogClassName?: string
payload: any
onHide: () => void
onAdd?: (payload: CustomCollectionBackend) => void
onRemove?: () => void
onEdit?: (payload: CustomCollectionBackend) => void
}
// Add and Edit
const EditCustomCollectionModal: FC<Props> = ({
positionLeft,
dialogClassName = '',
payload,
onHide,
onAdd,
onEdit,
onRemove,
}) => {
const { t } = useTranslation()
const isAdd = !payload
const isEdit = !!payload
const [editFirst, setEditFirst] = useState(!isAdd)
const [paramsSchemas, setParamsSchemas] = useState<CustomParamSchema[]>(payload?.tools || [])
const [customCollection, setCustomCollection, getCustomCollection] = useGetState<CustomCollectionBackend>(isAdd
? {
provider: '',
credentials: {
auth_type: AuthType.none,
api_key_header: 'Authorization',
api_key_header_prefix: AuthHeaderPrefix.basic,
},
icon: {
content: '🕵️',
background: '#FEF7C3',
},
schema_type: '',
schema: '',
}
: payload)
const originalProvider = isEdit ? payload.provider : ''
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const emoji = customCollection.icon
const setEmoji = (emoji: Emoji) => {
const newCollection = produce(customCollection, (draft) => {
draft.icon = emoji
})
setCustomCollection(newCollection)
}
const schema = customCollection.schema
const debouncedSchema = useDebounce(schema, { wait: 500 })
const setSchema = (schema: any) => {
const newCollection = produce(customCollection, (draft) => {
draft.schema = schema
})
setCustomCollection(newCollection)
}
useEffect(() => {
if (!debouncedSchema)
return
if (isEdit && editFirst) {
setEditFirst(false)
return
}
(async () => {
try {
const { parameters_schema, schema_type } = await parseParamsSchema(debouncedSchema)
const customCollection = getCustomCollection()
const newCollection = produce(customCollection, (draft) => {
draft.schema_type = schema_type
})
setCustomCollection(newCollection)
setParamsSchemas(parameters_schema)
}
catch {
const customCollection = getCustomCollection()
const newCollection = produce(customCollection, (draft) => {
draft.schema_type = ''
})
setCustomCollection(newCollection)
setParamsSchemas([])
}
})()
}, [debouncedSchema])
const [credentialsModalShow, setCredentialsModalShow] = useState(false)
const credential = customCollection.credentials
const setCredential = (credential: Credential) => {
const newCollection = produce(customCollection, (draft) => {
draft.credentials = credential
})
setCustomCollection(newCollection)
}
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) => {
delete draft.tools
if (draft.credentials.auth_type === AuthType.none) {
delete draft.credentials.api_key_header
delete draft.credentials.api_key_header_prefix
delete draft.credentials.api_key_value
}
draft.labels = labels
})
let errorMessage = ''
if (!postData.provider)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.name') })
if (!postData.schema)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.schema') })
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
if (isAdd) {
onAdd?.(postData)
return
}
onEdit?.({
...postData,
original_provider: originalProvider,
})
}
const getPath = (url: string) => {
if (!url)
return ''
try {
const path = decodeURI(new URL(url).pathname)
return path || ''
}
catch {
return url
}
}
return (
<>
<Drawer
isShow
positionCenter={isAdd && !positionLeft}
onHide={onHide}
title={t(`tools.createTool.${isAdd ? 'title' : 'editTitle'}`)!}
dialogClassName={dialogClassName}
panelClassName='mt-2 !w-[640px]'
maxWidthClassName='!max-w-[640px]'
height='calc(100vh - 16px)'
headerClassName='!border-b-divider-regular'
body={
<div className='flex h-full flex-col'>
<div className='h-0 grow space-y-4 overflow-y-auto px-6 py-3'>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.name')} <span className='ml-1 text-red-500'>*</span></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
className='h-10 grow' placeholder={t('tools.createTool.toolNamePlaceHolder')!}
value={customCollection.provider}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.provider = e.target.value
})
setCustomCollection(newCollection)
}}
/>
</div>
</div>
{/* Schema */}
<div className='select-none'>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.schema')}<span className='ml-1 text-red-500'>*</span></div>
<div className='mx-2 h-3 w-px bg-divider-regular'></div>
<a
href="https://swagger.io/specification/"
target='_blank' rel='noopener noreferrer'
className='flex h-[18px] items-center space-x-1 text-text-accent'
>
<div className='text-xs font-normal'>{t('tools.createTool.viewSchemaSpec')}</div>
<LinkExternal02 className='h-3 w-3' />
</a>
</div>
<GetSchema onChange={setSchema} />
</div>
<Textarea
className='h-[240px] resize-none'
value={schema}
onChange={e => setSchema(e.target.value)}
placeholder={t('tools.createTool.schemaPlaceHolder')!}
/>
</div>
{/* Available Tools */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.availableTools.title')}</div>
<div className='w-full overflow-x-auto rounded-lg border border-divider-regular'>
<table className='system-xs-regular w-full text-text-secondary'>
<thead className='uppercase text-text-tertiary'>
<tr className={cn(paramsSchemas.length > 0 && 'border-b', 'border-divider-regular')}>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.name')}</th>
<th className="w-[236px] p-2 pl-3 font-medium">{t('tools.createTool.availableTools.description')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.method')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.createTool.availableTools.path')}</th>
<th className="w-[54px] p-2 pl-3 font-medium">{t('tools.createTool.availableTools.action')}</th>
</tr>
</thead>
<tbody>
{paramsSchemas.map((item, index) => (
<tr key={index} className='border-b border-divider-regular last:border-0'>
<td className="p-2 pl-3">{item.operation_id}</td>
<td className="w-[236px] p-2 pl-3">{item.summary}</td>
<td className="p-2 pl-3">{item.method}</td>
<td className="p-2 pl-3">{getPath(item.server_url)}</td>
<td className="w-[62px] p-2 pl-3">
<Button
size='small'
onClick={() => {
setCurrTool(item)
setIsShowTestApi(true)
}}
>
{t('tools.createTool.availableTools.test')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Authorization method */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authMethod.title')}</div>
<div className='flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5' onClick={() => setCredentialsModalShow(true)}>
<div className='system-xs-regular text-text-primary'>{t(`tools.createTool.authMethod.types.${credential.auth_type}`)}</div>
<RiSettings2Line className='h-4 w-4 text-text-secondary' />
</div>
</div>
{/* Labels */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.toolInput.label')}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.privacyPolicy')}</div>
<Input
value={customCollection.privacy_policy}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.privacy_policy = e.target.value
})
setCustomCollection(newCollection)
}}
className='h-10 grow' placeholder={t('tools.createTool.privacyPolicyPlaceholder') || ''} />
</div>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.customDisclaimer')}</div>
<Input
value={customCollection.custom_disclaimer}
onChange={(e) => {
const newCollection = produce(customCollection, (draft) => {
draft.custom_disclaimer = e.target.value
})
setCustomCollection(newCollection)
}}
className='h-10 grow' placeholder={t('tools.createTool.customDisclaimerPlaceholder') || ''} />
</div>
</div>
<div className={cn(isEdit ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')} >
{
isEdit && (
<Button variant='warning' onClick={onRemove}>{t('common.operation.delete')}</Button>
)
}
<div className='flex space-x-2 '>
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
</div>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
setShowEmojiPicker(false)
}}
/>}
{credentialsModalShow && (
<ConfigCredentials
positionCenter={isAdd}
credential={credential}
onChange={setCredential}
onHide={() => setCredentialsModalShow(false)}
/>)
}
{isShowTestApi && (
<TestApi
positionCenter={isAdd}
tool={currTool as CustomParamSchema}
customCollection={customCollection}
onHide={() => setIsShowTestApi(false)}
/>
)}
</div>
}
isShowMask={true}
clickOutsideNotOpen={true}
/>
</>
)
}
export default React.memo(EditCustomCollectionModal)

View File

@@ -0,0 +1,138 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiSettings2Line } from '@remixicon/react'
import ConfigCredentials from './config-credentials'
import { AuthType, type Credential, type CustomCollectionBackend, type CustomParamSchema } from '@/app/components/tools/types'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Drawer from '@/app/components/base/drawer-plus'
import I18n from '@/context/i18n'
import { testAPIAvailable } from '@/service/tools'
import { getLanguage } from '@/i18n-config/language'
type Props = {
positionCenter?: boolean
customCollection: CustomCollectionBackend
tool: CustomParamSchema
onHide: () => void
}
const TestApi: FC<Props> = ({
positionCenter,
customCollection,
tool,
onHide,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const [credentialsModalShow, setCredentialsModalShow] = useState(false)
const [tempCredential, setTempCredential] = React.useState<Credential>(customCollection.credentials)
const [testing, setTesting] = useState(false)
const [result, setResult] = useState<string>('')
const { operation_id: toolName, parameters } = tool
const [parametersValue, setParametersValue] = useState<Record<string, string>>({})
const handleTest = async () => {
if (testing) return
setTesting(true)
// clone test schema
const credentials = JSON.parse(JSON.stringify(tempCredential)) as Credential
if (credentials.auth_type === AuthType.none) {
delete credentials.api_key_header_prefix
delete credentials.api_key_header
delete credentials.api_key_value
}
const data = {
provider_name: customCollection.provider,
tool_name: toolName,
credentials,
schema_type: customCollection.schema_type,
schema: customCollection.schema,
parameters: parametersValue,
}
const res = await testAPIAvailable(data) as any
setResult(res.error || res.result)
setTesting(false)
}
return (
<>
<Drawer
isShow
positionCenter={positionCenter}
onHide={onHide}
title={`${t('tools.test.title')} ${toolName}`}
panelClassName='mt-2 !w-[600px]'
maxWidthClassName='!max-w-[600px]'
height='calc(100vh - 16px)'
headerClassName='!border-b-divider-regular'
body={
<div className='overflow-y-auto px-6 pt-2'>
<div className='space-y-4'>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.authMethod.title')}</div>
<div className='flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2.5' onClick={() => setCredentialsModalShow(true)}>
<div className='system-xs-regular text-text-primary'>{t(`tools.createTool.authMethod.types.${tempCredential.auth_type}`)}</div>
<RiSettings2Line className='h-4 w-4 text-text-secondary' />
</div>
</div>
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.test.parametersValue')}</div>
<div className='rounded-lg border border-divider-regular'>
<table className='system-xs-regular w-full font-normal text-text-secondary'>
<thead className='uppercase text-text-tertiary'>
<tr className='border-b border-divider-regular'>
<th className="p-2 pl-3 font-medium">{t('tools.test.parameters')}</th>
<th className="p-2 pl-3 font-medium">{t('tools.test.value')}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className='border-b border-divider-regular last:border-0'>
<td className="py-2 pl-3 pr-2.5">
{item.label[language]}
</td>
<td className="">
<Input
value={parametersValue[item.name] || ''}
onChange={e => setParametersValue({ ...parametersValue, [item.name]: e.target.value })}
type='text'
className='!hover:border-transparent !hover:bg-transparent !focus:border-transparent !focus:bg-transparent !border-transparent !bg-transparent' />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<Button variant='primary' className=' mt-4 h-10 w-full' loading={testing} disabled={testing} onClick={handleTest}>{t('tools.test.title')}</Button>
<div className='mt-6'>
<div className='flex items-center space-x-3'>
<div className='system-xs-semibold text-text-tertiary'>{t('tools.test.testResult')}</div>
<div className='bg-[rgb(243, 244, 246)] h-px w-0 grow'></div>
</div>
<div className='system-xs-regular mt-2 h-[200px] overflow-y-auto overflow-x-hidden rounded-lg bg-components-input-bg-normal px-3 py-2 text-text-secondary'>
{result || <span className='text-text-quaternary'>{t('tools.test.testResultPlaceholder')}</span>}
</div>
</div>
</div>
}
/>
{credentialsModalShow && (
<ConfigCredentials
positionCenter={positionCenter}
credential={tempCredential}
onChange={setTempCredential}
onHide={() => setCredentialsModalShow(false)}
/>)
}
</>
)
}
export default React.memo(TestApi)

View File

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

View File

@@ -0,0 +1,136 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Input from '@/app/components/base/input'
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 { useTags } from '@/app/components/plugins/hooks'
type LabelFilterProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelFilter: FC<LabelFilterProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { tags: labelList } = useTags()
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])
}
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 h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover',
!open && !!value.length && 'shadow-xs',
open && !!value.length && 'shadow-xs',
)}>
<div className='p-[1px]'>
<Tag01 className='h-3.5 w-3.5 text-text-tertiary' />
</div>
<div className='text-[13px] leading-[18px] text-text-tertiary'>
{!value.length && t('common.tag.placeholder')}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className='text-xs font-medium leading-[18px] text-text-tertiary'>{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className='p-[1px]'>
<RiArrowDownSLine className='h-3.5 w-3.5 text-text-tertiary' />
</div>
)}
{!!value.length && (
<div className='group/clear cursor-pointer p-[1px]' onClick={(e) => {
e.stopPropagation()
onChange([])
}}>
<XCircle className='h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary' />
</div>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1002]'>
<div className='relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
<div className='p-2'>
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className='p-1'>
{filteredLabelList.map(label => (
<div
key={label.name}
className='flex cursor-pointer select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover'
onClick={() => selectLabel(label)}
>
<div title={label.label} className='grow truncate text-sm leading-5 text-text-secondary'>{label.label}</div>
{value.includes(label.name) && <Check className='h-4 w-4 shrink-0 text-text-accent' />}
</div>
))}
{!filteredLabelList.length && (
<div className='flex flex-col items-center gap-1 p-3'>
<Tag03 className='h-6 w-6 text-text-quaternary' />
<div className='text-xs leading-[14px] text-text-tertiary'>{t('common.tag.noTag')}</div>
</div>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default LabelFilter

View File

@@ -0,0 +1,122 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Input from '@/app/components/base/input'
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 { useTags } from '@/app/components/plugins/hooks'
import { noop } from 'lodash-es'
type LabelSelectorProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelSelector: FC<LabelSelectorProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { tags: labelList } = useTags()
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).join(', ')
}, [value, labelList])
const selectLabel = (label: Label) => {
if (value.includes(label.name))
onChange(value.filter(v => v !== label.name))
else
onChange([...value, label.name])
}
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 h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && '!text-text-quaternary')}>
{!value.length && t('tools.createTool.toolInput.labelPlaceholder')}
{!!value.length && selectedLabels}
</div>
<div className='ml-1 shrink-0 text-text-secondary opacity-60'>
<RiArrowDownSLine className='h-4 w-4' />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1040]'>
<div className='relative w-[591px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
<div className='border-b-[0.5px] border-divider-regular p-2'>
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className='max-h-[264px] overflow-y-auto p-1'>
{filteredLabelList.map(label => (
<div
key={label.name}
className='flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-components-panel-on-panel-item-bg-hover'
onClick={() => selectLabel(label)}
>
<Checkbox
className='shrink-0'
checked={value.includes(label.name)}
onCheck={noop}
/>
<div title={label.label} className='grow truncate text-sm leading-5 text-text-secondary'>{label.label}</div>
</div>
))}
{!filteredLabelList.length && (
<div className='flex flex-col items-center gap-1 p-3'>
<Tag03 className='h-6 w-6 text-text-quaternary' />
<div className='text-xs leading-[14px] text-text-tertiary'>{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

@@ -0,0 +1,117 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
useMarketplaceCollectionsAndPlugins,
useMarketplacePlugins,
} from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { useAllToolProviders } from '@/service/use-tools'
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => {
const { data: toolProvidersData, isSuccess } = useAllToolProviders()
const exclude = useMemo(() => {
if (isSuccess)
return toolProvidersData?.filter(toolProvider => !!toolProvider.plugin_id).map(toolProvider => toolProvider.plugin_id!)
}, [isSuccess, toolProvidersData])
const {
isLoading,
marketplaceCollections,
marketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins,
} = useMarketplaceCollectionsAndPlugins()
const {
plugins,
resetPlugins,
queryPlugins,
queryPluginsWithDebounced,
isLoading: isPluginsLoading,
total: pluginsTotal,
} = useMarketplacePlugins()
const [page, setPage] = useState(1)
const pageRef = useRef(page)
const searchPluginTextRef = useRef(searchPluginText)
const filterPluginTagsRef = useRef(filterPluginTags)
useEffect(() => {
searchPluginTextRef.current = searchPluginText
filterPluginTagsRef.current = filterPluginTags
}, [searchPluginText, filterPluginTags])
useEffect(() => {
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
setPage(1)
pageRef.current = 1
if (searchPluginText) {
queryPluginsWithDebounced({
category: PluginCategoryEnum.tool,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
page: pageRef.current,
})
return
}
queryPlugins({
category: PluginCategoryEnum.tool,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
page: pageRef.current,
})
}
else {
if (isSuccess) {
queryMarketplaceCollectionsAndPlugins({
category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude,
type: 'plugin',
})
resetPlugins()
}
}
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const {
scrollTop,
scrollHeight,
clientHeight,
} = target
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0) {
const searchPluginText = searchPluginTextRef.current
const filterPluginTags = filterPluginTagsRef.current
if (pluginsTotal && plugins && pluginsTotal > plugins.length && (!!searchPluginText || !!filterPluginTags.length)) {
setPage(pageRef.current + 1)
pageRef.current++
queryPlugins({
category: PluginCategoryEnum.tool,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
page: pageRef.current,
})
}
}
}, [exclude, plugins, pluginsTotal, queryPlugins])
return {
isLoading: isLoading || isPluginsLoading,
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
handleScroll,
page,
}
}

View File

@@ -0,0 +1,116 @@
import { useTheme } from 'next-themes'
import {
RiArrowRightUpLine,
RiArrowUpDoubleLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { useMarketplace } from './hooks'
import List from '@/app/components/plugins/marketplace/list'
import Loading from '@/app/components/base/loading'
import { getLocaleOnClient } from '@/i18n-config'
import { getMarketplaceUrl } from '@/utils/var'
type MarketplaceProps = {
searchPluginText: string
filterPluginTags: string[]
isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void
marketplaceContext: ReturnType<typeof useMarketplace>
}
const Marketplace = ({
searchPluginText,
filterPluginTags,
isMarketplaceArrowVisible,
showMarketplacePanel,
marketplaceContext,
}: MarketplaceProps) => {
const locale = getLocaleOnClient()
const { t } = useTranslation()
const { theme } = useTheme()
const {
isLoading,
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
page,
} = marketplaceContext
return (
<>
<div className='sticky bottom-0 flex shrink-0 flex-col bg-background-default-subtle px-12 pb-[14px] pt-2'>
{isMarketplaceArrowVisible && (
<RiArrowUpDoubleLine
className='absolute left-1/2 top-2 z-10 h-4 w-4 -translate-x-1/2 cursor-pointer text-text-quaternary'
onClick={showMarketplacePanel}
/>
)}
<div className='pb-3 pt-4'>
<div className='title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent'>
{t('plugin.marketplace.moreFrom')}
</div>
<div className='body-md-regular flex items-center text-center text-text-tertiary'>
{t('plugin.marketplace.discover')}
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.models')}
</span>
,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.tools')}
</span>
,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.datasources')}
</span>
,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.triggers')}
</span>
,
<span className="body-md-medium relative ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.agents')}
</span>
,
<span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.extensions')}
</span>
{t('plugin.marketplace.and')}
<span className="body-md-medium relative ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('plugin.category.bundles')}
</span>
{t('common.operation.in')}
<a
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })}
className='system-sm-medium ml-1 flex items-center text-text-accent'
target='_blank'
>
{t('plugin.marketplace.difyMarketplace')}
<RiArrowRightUpLine className='h-4 w-4' />
</a>
</div>
</div>
</div>
<div className='mt-[-14px] shrink-0 grow bg-background-default-subtle px-12 pb-2'>
{
isLoading && page === 1 && (
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
<Loading />
</div>
)
}
{
(!isLoading || page > 1) && (
<List
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton
locale={locale}
/>
)
}
</div>
</>
)
}
export default Marketplace

View File

@@ -0,0 +1,75 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
RiAddCircleFill,
RiArrowRightUpLine,
RiBookOpenLine,
} from '@remixicon/react'
import MCPModal from './modal'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { useAppContext } from '@/context/app-context'
import { useCreateMCP } from '@/service/use-tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
type Props = {
handleCreate: (provider: ToolWithProvider) => void
}
const NewMCPCard = ({ handleCreate }: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext()
const { mutateAsync: createMCP } = useCreateMCP()
const create = async (info: any) => {
const provider = await createMCP(info)
handleCreate(provider)
}
const linkUrl = useMemo(() => {
if (language.startsWith('zh_'))
return 'https://docs.dify.ai/zh-hans/guides/tools/mcp'
if (language.startsWith('ja_jp'))
return 'https://docs.dify.ai/ja_jp/guides/tools/mcp'
return 'https://docs.dify.ai/en/guides/tools/mcp'
}, [language])
const [showModal, setShowModal] = useState(false)
return (
<>
{isCurrentWorkspaceManager && (
<div className='col-span-1 flex min-h-[108px] cursor-pointer flex-col rounded-xl bg-background-default-dimmed transition-all duration-200 ease-in-out'>
<div className='group grow rounded-t-xl' onClick={() => setShowModal(true)}>
<div className='flex shrink-0 items-center p-4 pb-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-lg border border-dashed border-divider-deep group-hover:border-solid group-hover:border-state-accent-hover-alt group-hover:bg-state-accent-hover'>
<RiAddCircleFill className='h-4 w-4 text-text-quaternary group-hover:text-text-accent'/>
</div>
<div className='system-md-semibold ml-3 text-text-secondary group-hover:text-text-accent'>{t('tools.mcp.create.cardTitle')}</div>
</div>
</div>
<div className='rounded-b-xl border-t-[0.5px] border-divider-subtle px-4 py-3 text-text-tertiary hover:text-text-accent'>
<a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
<RiBookOpenLine className='h-3 w-3 shrink-0' />
<div className='system-xs-regular grow truncate' title={t('tools.mcp.create.cardLink') || ''}>{t('tools.mcp.create.cardLink')}</div>
<RiArrowRightUpLine className='h-3 w-3 shrink-0' />
</a>
</div>
</div>
)}
{showModal && (
<MCPModal
show={showModal}
onConfirm={create}
onHide={() => setShowModal(false)}
/>
)}
</>
)
}
export default NewMCPCard

View File

@@ -0,0 +1,307 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import type { FC } from 'react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import {
RiCloseLine,
RiLoader2Line,
RiLoopLeftLine,
} from '@remixicon/react'
import type { ToolWithProvider } from '../../../workflow/types'
import Icon from '@/app/components/plugins/card/base/card-icon'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import Indicator from '@/app/components/header/indicator'
import Tooltip from '@/app/components/base/tooltip'
import MCPModal from '../modal'
import OperationDropdown from './operation-dropdown'
import ListLoading from './list-loading'
import ToolItem from './tool-item'
import {
useAuthorizeMCP,
useDeleteMCP,
useInvalidateMCPTools,
useMCPTools,
useUpdateMCP,
useUpdateMCPTools,
} from '@/service/use-tools'
import { openOAuthPopup } from '@/hooks/use-oauth'
import cn from '@/utils/classnames'
type Props = {
detail: ToolWithProvider
onUpdate: (isDelete?: boolean) => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}
const MCPDetailContent: FC<Props> = ({
detail,
onUpdate,
onHide,
isTriggerAuthorize,
onFirstCreate,
}) => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
const invalidateMCPTools = useInvalidateMCPTools()
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP()
const toolList = data?.tools || []
const [isShowUpdateConfirm, {
setTrue: showUpdateConfirm,
setFalse: hideUpdateConfirm,
}] = useBoolean(false)
const handleUpdateTools = useCallback(async () => {
hideUpdateConfirm()
if (!detail)
return
await updateTools(detail.id)
invalidateMCPTools(detail.id)
onUpdate()
}, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools])
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isShowUpdateModal, {
setTrue: showUpdateModal,
setFalse: hideUpdateModal,
}] = useBoolean(false)
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleOAuthCallback = useCallback(() => {
if (!isCurrentWorkspaceManager)
return
if (!detail.id)
return
handleUpdateTools()
}, [detail.id, handleUpdateTools, isCurrentWorkspaceManager])
const handleAuthorize = useCallback(async () => {
onFirstCreate()
if (!isCurrentWorkspaceManager)
return
if (!detail)
return
const res = await authorizeMcp({
provider_id: detail.id,
})
if (res.result === 'success')
handleUpdateTools()
else if (res.authorization_url)
openOAuthPopup(res.authorization_url, handleOAuthCallback)
}, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback])
const handleUpdate = useCallback(async (data: any) => {
if (!detail)
return
const res = await updateMCP({
...data,
provider_id: detail.id,
})
if ((res as any)?.result === 'success') {
hideUpdateModal()
onUpdate()
handleAuthorize()
}
}, [detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize])
const handleDelete = useCallback(async () => {
if (!detail)
return
showDeleting()
const res = await deleteMCP(detail.id)
hideDeleting()
if ((res as any)?.result === 'success') {
hideDeleteConfirm()
onUpdate(true)
}
}, [detail, showDeleting, deleteMCP, hideDeleting, hideDeleteConfirm, onUpdate])
useEffect(() => {
if (isTriggerAuthorize)
handleAuthorize()
}, [])
if (!detail)
return null
return (
<>
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
<div className='flex'>
<div className='shrink-0 overflow-hidden rounded-xl border border-components-panel-border-subtle'>
<Icon src={detail.icon} />
</div>
<div className='ml-3 w-0 grow'>
<div className='flex h-5 items-center'>
<div className='system-md-semibold truncate text-text-primary' title={detail.name}>{detail.name}</div>
</div>
<div className='mt-0.5 flex items-center gap-1'>
<Tooltip popupContent={t('tools.mcp.identifier')}>
<div className='system-xs-regular shrink-0 cursor-pointer text-text-secondary' onClick={() => copy(detail.server_identifier || '')}>{detail.server_identifier}</div>
</Tooltip>
<div className='system-xs-regular shrink-0 text-text-quaternary'>·</div>
<Tooltip popupContent={t('tools.mcp.modal.serverUrl')}>
<div className='system-xs-regular truncate text-text-secondary'>{detail.server_url}</div>
</Tooltip>
</div>
</div>
<div className='flex gap-1'>
<OperationDropdown
onEdit={showUpdateModal}
onRemove={showDeleteConfirm}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
<div className='mt-5'>
{!isAuthorizing && detail.is_team_authorization && (
<Button
variant='secondary'
className='w-full'
onClick={handleAuthorize}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
{!detail.is_team_authorization && !isAuthorizing && (
<Button
variant='primary'
className='w-full'
onClick={handleAuthorize}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.mcp.authorize')}
</Button>
)}
{isAuthorizing && (
<Button
variant='primary'
className='w-full'
disabled
>
<RiLoader2Line className={cn('mr-1 h-4 w-4 animate-spin')} />
{t('tools.mcp.authorizing')}
</Button>
)}
</div>
</div>
<div className='flex grow flex-col'>
{((detail.is_team_authorization && isGettingTools) || isUpdating) && (
<>
<div className='flex shrink-0 justify-between gap-2 px-4 pb-1 pt-2'>
<div className='flex h-6 items-center'>
{!isUpdating && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.gettingTools')}</div>}
{isUpdating && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.updateTools')}</div>}
</div>
<div></div>
</div>
<div className='flex h-full w-full grow flex-col overflow-hidden px-4 pb-4'>
<ListLoading />
</div>
</>
)}
{!isUpdating && detail.is_team_authorization && !isGettingTools && !toolList.length && (
<div className='flex h-full w-full flex-col items-center justify-center'>
<div className='system-sm-regular mb-3 text-text-tertiary'>{t('tools.mcp.toolsEmpty')}</div>
<Button
variant='primary'
onClick={handleUpdateTools}
>{t('tools.mcp.getTools')}</Button>
</div>
)}
{!isUpdating && !isGettingTools && toolList.length > 0 && (
<>
<div className='flex shrink-0 justify-between gap-2 px-4 pb-1 pt-2'>
<div className='flex h-6 items-center'>
{toolList.length > 1 && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.toolsNum', { count: toolList.length })}</div>}
{toolList.length === 1 && <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.mcp.onlyTool')}</div>}
</div>
<div>
<Button size='small' onClick={showUpdateConfirm}>
<RiLoopLeftLine className='mr-1 h-3.5 w-3.5' />
{t('tools.mcp.update')}
</Button>
</div>
</div>
<div className='flex h-0 w-full grow flex-col gap-2 overflow-y-auto px-4 pb-4'>
{toolList.map(tool => (
<ToolItem
key={`${detail.id}${tool.name}`}
tool={tool}
/>
))}
</div>
</>
)}
{!isUpdating && !detail.is_team_authorization && (
<div className='flex h-full w-full flex-col items-center justify-center'>
{!isAuthorizing && <div className='system-md-medium mb-1 text-text-secondary'>{t('tools.mcp.authorizingRequired')}</div>}
{isAuthorizing && <div className='system-md-medium mb-1 text-text-secondary'>{t('tools.mcp.authorizing')}</div>}
<div className='system-sm-regular text-text-tertiary'>{t('tools.mcp.authorizeTip')}</div>
</div>
)}
</div>
{isShowUpdateModal && (
<MCPModal
data={detail}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('tools.mcp.delete')}
content={
<div>
{t('tools.mcp.deleteConfirmTitle', { mcp: detail.name })}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
{isShowUpdateConfirm && (
<Confirm
isShow
title={t('tools.mcp.toolUpdateConfirmTitle')}
content={t('tools.mcp.toolUpdateConfirmContent')}
onCancel={hideUpdateConfirm}
onConfirm={handleUpdateTools}
/>
)}
</>
)
}
export default MCPDetailContent

View File

@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import cn from '@/utils/classnames'
const ListLoading = () => {
return (
<div className={cn('space-y-2')}>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[180px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[148px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[196px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[148px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
<div className='space-y-3 rounded-xl bg-components-panel-on-panel-item-bg-hover p-4'>
<div className='h-2 w-[180px] rounded-sm bg-text-quaternary opacity-20'></div>
<div className='h-2 rounded-sm bg-text-quaternary opacity-10'></div>
<div className='mr-10 h-2 rounded-sm bg-text-quaternary opacity-10'></div>
</div>
</div>
)
}
export default ListLoading

View File

@@ -0,0 +1,88 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
} from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
type Props = {
inCard?: boolean
onOpenChange?: (open: boolean) => void
onEdit: () => void
onRemove: () => void
}
const OperationDropdown: FC<Props> = ({
inCard,
onOpenChange,
onEdit,
onRemove,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
onOpenChange?.(v)
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: !inCard ? -12 : 0,
crossAxis: !inCard ? 36 : 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'>
<div
className='flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-base-hover'
onClick={() => {
onEdit()
handleTrigger()
}}
>
<RiEditLine className='h-4 w-4 text-text-tertiary' />
<div className='system-md-regular ml-2 text-text-secondary'>{t('tools.mcp.operation.edit')}</div>
</div>
<div
className='group flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-destructive-hover'
onClick={() => {
onRemove()
handleTrigger()
}}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary group-hover:text-text-destructive-secondary' />
<div className='system-md-regular ml-2 text-text-secondary group-hover:text-text-destructive'>{t('tools.mcp.operation.remove')}</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(OperationDropdown)

View File

@@ -0,0 +1,56 @@
'use client'
import React from 'react'
import type { FC } from 'react'
import Drawer from '@/app/components/base/drawer'
import MCPDetailContent from './content'
import type { ToolWithProvider } from '../../../workflow/types'
import cn from '@/utils/classnames'
type Props = {
detail?: ToolWithProvider
onUpdate: () => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}
const MCPDetailPanel: FC<Props> = ({
detail,
onUpdate,
onHide,
isTriggerAuthorize,
onFirstCreate,
}) => {
const handleUpdate = (isDelete = false) => {
if (isDelete)
onHide()
onUpdate()
}
if (!detail)
return null
return (
<Drawer
isOpen={!!detail}
clickOutsideNotOpen={false}
onClose={onHide}
footer={null}
mask={false}
positionCenter={false}
panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
>
{detail && (
<MCPDetailContent
detail={detail}
onHide={onHide}
onUpdate={handleUpdate}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={onFirstCreate}
/>
)}
</Drawer>
)
}
export default MCPDetailPanel

View File

@@ -0,0 +1,69 @@
'use client'
import React from 'react'
import { useContext } from 'use-context-selector'
import type { Tool } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
tool: Tool
}
const MCPToolItem = ({
tool,
}: Props) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { t } = useTranslation()
const renderParameters = () => {
const parameters = tool.parameters
if (parameters.length === 0)
return null
return (
<div className='mt-2'>
<div className='title-xs-semi-bold mb-1 text-text-primary'>{t('tools.mcp.toolItem.parameters')}:</div>
<ul className='space-y-1'>
{parameters.map((parameter) => {
const descriptionContent = parameter.human_description[language] || t('tools.mcp.toolItem.noDescription')
return (
<li key={parameter.name} className='pl-2'>
<span className='system-xs-regular font-bold text-text-secondary'>{parameter.name}</span>
<span className='system-xs-regular mr-1 text-text-tertiary'>({parameter.type}):</span>
<span className='system-xs-regular text-text-tertiary'>{descriptionContent}</span>
</li>
)
})}
</ul>
</div>
)
}
return (
<Tooltip
key={tool.name}
position='left'
popupClassName='!p-0 !px-4 !py-3.5 !w-[360px] !border-[0.5px] !border-components-panel-border !rounded-xl !shadow-lg'
popupContent={(
<div>
<div className='title-xs-semi-bold mb-1 text-text-primary'>{tool.label[language]}</div>
<div className='body-xs-regular text-text-secondary'>{tool.description[language]}</div>
{renderParameters()}
</div>
)}
>
<div
className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')}
>
<div className='system-md-semibold pb-0.5 text-text-secondary'>{tool.label[language]}</div>
<div className='system-xs-regular line-clamp-2 text-text-tertiary' title={tool.description[language]}>{tool.description[language]}</div>
</div>
</Tooltip>
)
}
export default MCPToolItem

View File

@@ -0,0 +1,133 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
export type HeaderItem = {
id: string
key: string
value: string
}
type Props = {
headersItems: HeaderItem[]
onChange: (headerItems: HeaderItem[]) => void
readonly?: boolean
isMasked?: boolean
}
const HeadersInput = ({
headersItems,
onChange,
readonly = false,
isMasked = false,
}: Props) => {
const { t } = useTranslation()
const handleItemChange = (index: number, field: 'key' | 'value', value: string) => {
const newItems = [...headersItems]
newItems[index] = { ...newItems[index], [field]: value }
onChange(newItems)
}
const handleRemoveItem = (index: number) => {
const newItems = headersItems.filter((_, i) => i !== index)
onChange(newItems)
}
const handleAddItem = () => {
const newItems = [...headersItems, { id: uuid(), key: '', value: '' }]
onChange(newItems)
}
if (headersItems.length === 0) {
return (
<div className='space-y-2'>
<div className='body-xs-regular text-text-tertiary'>
{t('tools.mcp.modal.noHeaders')}
</div>
{!readonly && (
<Button
variant='secondary'
size='small'
onClick={handleAddItem}
className='w-full'
>
<RiAddLine className='mr-1 h-4 w-4' />
{t('tools.mcp.modal.addHeader')}
</Button>
)}
</div>
)
}
return (
<div className='space-y-2'>
{isMasked && (
<div className='body-xs-regular text-text-tertiary'>
{t('tools.mcp.modal.maskedHeadersTip')}
</div>
)}
<div className='overflow-hidden rounded-lg border border-divider-regular'>
<div className='system-xs-medium-uppercase bg-background-secondary flex h-7 items-center leading-7 text-text-tertiary'>
<div className='h-full w-1/2 border-r border-divider-regular pl-3'>{t('tools.mcp.modal.headerKey')}</div>
<div className='h-full w-1/2 pl-3 pr-1'>{t('tools.mcp.modal.headerValue')}</div>
</div>
{headersItems.map((item, index) => (
<div key={item.id} className={cn(
'flex items-center border-divider-regular',
index < headersItems.length - 1 && 'border-b',
)}>
<div className='w-1/2 border-r border-divider-regular'>
<Input
value={item.key}
onChange={e => handleItemChange(index, 'key', e.target.value)}
placeholder={t('tools.mcp.modal.headerKeyPlaceholder')}
className='rounded-none border-0'
readOnly={readonly}
/>
</div>
<div className='flex w-1/2 items-center'>
<Input
value={item.value}
onChange={e => handleItemChange(index, 'value', e.target.value)}
placeholder={t('tools.mcp.modal.headerValuePlaceholder')}
className='flex-1 rounded-none border-0'
readOnly={readonly}
/>
{!readonly && !!headersItems.length && (
<ActionButton
onClick={() => handleRemoveItem(index)}
className='mr-2'
>
<RiDeleteBinLine className='h-4 w-4 text-text-destructive' />
</ActionButton>
)}
</div>
</div>
))}
</div>
{!readonly && (
<Button
variant='secondary'
size='small'
onClick={handleAddItem}
className='w-full'
>
<RiAddLine className='mr-1 h-4 w-4' />
{t('tools.mcp.modal.addHeader')}
</Button>
)}
</div>
)
}
export default React.memo(HeadersInput)

View File

@@ -0,0 +1,98 @@
'use client'
import { useMemo, useState } from 'react'
import NewMCPCard from './create-card'
import MCPCard from './provider-card'
import MCPDetailPanel from './detail/provider-detail'
import {
useAllToolProviders,
} from '@/service/use-tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
searchText: string
}
function renderDefaultCard() {
const defaultCards = Array.from({ length: 36 }, (_, index) => (
<div
key={index}
className={cn(
'inline-flex h-[111px] rounded-xl bg-background-default-lighter opacity-10',
index < 4 && 'opacity-60',
index >= 4 && index < 8 && 'opacity-50',
index >= 8 && index < 12 && 'opacity-40',
index >= 12 && index < 16 && 'opacity-30',
index >= 16 && index < 20 && 'opacity-25',
index >= 20 && index < 24 && 'opacity-20',
)}
></div>
))
return defaultCards
}
const MCPList = ({
searchText,
}: Props) => {
const { data: list = [] as ToolWithProvider[], refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
const filteredList = useMemo(() => {
return list.filter((collection) => {
if (searchText)
return Object.values(collection.name).some(value => (value as string).toLowerCase().includes(searchText.toLowerCase()))
return collection.type === 'mcp'
}) as ToolWithProvider[]
}, [list, searchText])
const [currentProviderID, setCurrentProviderID] = useState<string>()
const currentProvider = useMemo(() => {
return list.find(provider => provider.id === currentProviderID)
}, [list, currentProviderID])
const handleCreate = async (provider: ToolWithProvider) => {
await refetch() // update list
setCurrentProviderID(provider.id)
setIsTriggerAuthorize(true)
}
const handleUpdate = async (providerID: string) => {
await refetch() // update list
setCurrentProviderID(providerID)
setIsTriggerAuthorize(true)
}
return (
<>
<div
className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pb-4 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!list.length && 'h-[calc(100vh_-_136px)] overflow-hidden',
)}
>
<NewMCPCard handleCreate={handleCreate} />
{filteredList.map(provider => (
<MCPCard
key={provider.id}
data={provider}
currentProvider={currentProvider as ToolWithProvider}
handleSelect={setCurrentProviderID}
onUpdate={handleUpdate}
onDeleted={refetch}
/>
))}
{!list.length && renderDefaultCard()}
</div>
{currentProvider && (
<MCPDetailPanel
detail={currentProvider as ToolWithProvider}
onHide={() => setCurrentProviderID(undefined)}
onUpdate={refetch}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={() => setIsTriggerAuthorize(false)}
/>
)}
</>
)
}
export default MCPList

View File

@@ -0,0 +1,143 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Divider from '@/app/components/base/divider'
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
import type {
MCPServerDetail,
} from '@/app/components/tools/types'
import {
useCreateMCPServer,
useInvalidateMCPServerDetail,
useUpdateMCPServer,
} from '@/service/use-tools'
import cn from '@/utils/classnames'
export type ModalProps = {
appID: string
latestParams?: any[]
data?: MCPServerDetail
show: boolean
onHide: () => void
appInfo?: any
}
const MCPServerModal = ({
appID,
latestParams = [],
data,
show,
onHide,
appInfo,
}: ModalProps) => {
const { t } = useTranslation()
const { mutateAsync: createMCPServer, isPending: creating } = useCreateMCPServer()
const { mutateAsync: updateMCPServer, isPending: updating } = useUpdateMCPServer()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const defaultDescription = data?.description || appInfo?.description || ''
const [description, setDescription] = React.useState(defaultDescription)
const [params, setParams] = React.useState(data?.parameters || {})
const handleParamChange = (variable: string, value: string) => {
setParams(prev => ({
...prev,
[variable]: value,
}))
}
const getParamValue = () => {
const res = {} as any
latestParams.map((param) => {
res[param.variable] = params[param.variable]
return param
})
return res
}
const submit = async () => {
if (!data) {
const payload: any = {
appID,
parameters: getParamValue(),
}
if (description.trim())
payload.description = description
await createMCPServer(payload)
invalidateMCPServerDetail(appID)
onHide()
}
else {
const payload: any = {
appID,
id: data.id,
parameters: getParamValue(),
}
payload.description = description
await updateMCPServer(payload)
invalidateMCPServerDetail(appID)
onHide()
}
}
return (
<Modal
isShow={show}
onClose={onHide}
className={cn('relative !max-w-[520px] !p-0')}
>
<div className='absolute right-5 top-5 z-10 cursor-pointer p-1.5' onClick={onHide}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='title-2xl-semi-bold relative p-6 pb-3 text-xl text-text-primary'>
{!data ? t('tools.mcp.server.modal.addTitle') : t('tools.mcp.server.modal.editTitle')}
</div>
<div className='space-y-5 px-6 py-3'>
<div className='space-y-0.5'>
<div className='flex h-6 items-center gap-1'>
<div className='system-sm-medium text-text-secondary'>{t('tools.mcp.server.modal.description')}</div>
<div className='system-xs-regular text-text-destructive-secondary'>*</div>
</div>
<Textarea
className='h-[96px] resize-none'
value={description}
placeholder={t('tools.mcp.server.modal.descriptionPlaceholder')}
onChange={e => setDescription(e.target.value)}
></Textarea>
</div>
{latestParams.length > 0 && (
<div>
<div className='mb-1 flex items-center gap-2'>
<div className='system-xs-medium-uppercase shrink-0 text-text-primary'>{t('tools.mcp.server.modal.parameters')}</div>
<Divider type='horizontal' className='!m-0 !h-px grow bg-divider-subtle' />
</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('tools.mcp.server.modal.parametersTip')}</div>
<div className='space-y-3'>
{latestParams.map(paramItem => (
<MCPServerParamItem
key={paramItem.variable}
data={paramItem}
value={params[paramItem.variable] || ''}
onChange={value => handleParamChange(paramItem.variable, value)}
/>
))}
</div>
</div>
)}
</div>
<div className='flex flex-row-reverse p-6 pt-5'>
<Button disabled={!description || creating || updating} className='ml-2' variant='primary' onClick={submit}>{data ? t('tools.mcp.modal.save') : t('tools.mcp.server.modal.confirm')}</Button>
<Button onClick={onHide}>{t('tools.mcp.modal.cancel')}</Button>
</div>
</Modal>
)
}
export default MCPServerModal

View File

@@ -0,0 +1,37 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
type Props = {
data?: any
value: string
onChange: (value: string) => void
}
const MCPServerParamItem = ({
data,
value,
onChange,
}: Props) => {
const { t } = useTranslation()
return (
<div className='space-y-0.5'>
<div className='flex h-6 items-center gap-2'>
<div className='system-xs-medium text-text-secondary'>{data.label}</div>
<div className='system-xs-medium text-text-quaternary'>·</div>
<div className='system-xs-medium text-text-secondary'>{data.variable}</div>
<div className='system-xs-medium text-text-tertiary'>{data.type}</div>
</div>
<Textarea
className='h-8 resize-none'
value={value}
placeholder={t('tools.mcp.server.modal.parametersPlaceholder')}
onChange={e => onChange(e.target.value)}
></Textarea>
</div>
)
}
export default MCPServerParamItem

View File

@@ -0,0 +1,294 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import {
Mcp,
} from '@/app/components/base/icons/src/vender/other'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch'
import Divider from '@/app/components/base/divider'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Confirm from '@/app/components/base/confirm'
import type { AppDetailResponse } from '@/models/app'
import { useAppContext } from '@/context/app-context'
import { AppModeEnum, type AppSSO } from '@/types/app'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { useAppWorkflow } from '@/service/use-workflow'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { BlockEnum } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { fetchAppDetail } from '@/service/apps'
import { useDocLink } from '@/context/i18n'
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}: IAppCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appId = appInfo.id
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if(!isBasicApp || !basicAppConfig?.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
if(isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
if(isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}
const onChangeStatus = async (state: boolean) => {
setActivated(state)
if (state) {
if (!serverPublished) {
setShowMCPServerModal(true)
return
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'active',
})
invalidateMCPServerDetail(appId)
}
else {
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'inactive',
})
invalidateMCPServerDetail(appId)
}
}
const handleServerModalHide = () => {
setShowMCPServerModal(false)
if (!serverActivated)
setActivated(false)
}
useEffect(() => {
setActivated(serverActivated)
}, [serverActivated])
if (!currentWorkflow && isAdvancedApp)
return null
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
triggerModeMessage ? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
</Tooltip>
) : <div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className='flex w-full items-center gap-3 self-stretch'>
<div className='flex grow items-center'>
<div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
<Mcp className='h-4 w-4 text-text-primary-on-surface' />
</div>
<div className="group w-full">
<div className="system-md-semibold min-w-0 overflow-hidden text-ellipsis break-normal text-text-secondary group-hover:text-text-primary">
{t('tools.mcp.server.title')}
</div>
</div>
</div>
<div className='flex items-center gap-1'>
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{serverActivated
? t('appOverview.overview.status.running')
: t('appOverview.overview.status.disable')}
</div>
</div>
<Tooltip
popupContent={
toggleDisabled ? (
appUnpublished ? (
t('tools.mcp.server.publishTip')
) : missingStartNode ? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('appOverview.overview.appInfo.enableTooltip.description')}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
) : triggerModeMessage || ''
) : ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch defaultValue={activated} onChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
</div>
{!isMinimalState && (
<div className='flex flex-col items-start justify-center self-stretch'>
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('tools.mcp.server.url')}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback
content={serverURL}
className={'!size-6'}
/>
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('appOverview.overview.appInfo.regenerate') || ''}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/>
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)}
</div>
{!isMinimalState && (
<div className='flex items-center gap-1 self-stretch p-3'>
<Button
disabled={toggleDisabled}
size='small'
variant='ghost'
onClick={() => setShowMCPServerModal(true)}
>
<div className="flex items-center justify-center gap-[1px]">
<RiEditLine className="h-3.5 w-3.5" />
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div>
</div>
</Button>
</div>
)}
</div>
</div>
{showMCPServerModal && (
<MCPServerModal
show={showMCPServerModal}
appID={appId}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={handleServerModalHide}
appInfo={appInfo}
/>
)}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type='warning'
title={t('appOverview.overview.appInfo.regenerate')}
content={t('tools.mcp.server.reGen')}
isShow={showConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</>
)
}
export default MCPServiceCard

View File

@@ -0,0 +1,154 @@
const tools = [
{
author: 'Novice',
name: 'NOTION_ADD_PAGE_CONTENT',
label: {
en_US: 'NOTION_ADD_PAGE_CONTENT',
zh_Hans: 'NOTION_ADD_PAGE_CONTENT',
pt_BR: 'NOTION_ADD_PAGE_CONTENT',
ja_JP: 'NOTION_ADD_PAGE_CONTENT',
},
description: {
en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
},
parameters: [
{
name: 'after',
label: {
en_US: 'after',
zh_Hans: 'after',
pt_BR: 'after',
ja_JP: 'after',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
form: 'llm',
llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
{
name: 'content_block',
label: {
en_US: 'content_block',
zh_Hans: 'content_block',
pt_BR: 'content_block',
ja_JP: 'content_block',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'Child content to append to a page.',
zh_Hans: 'Child content to append to a page.',
pt_BR: 'Child content to append to a page.',
ja_JP: 'Child content to append to a page.',
},
form: 'llm',
llm_description: 'Child content to append to a page.',
},
{
name: 'parent_block_id',
label: {
en_US: 'parent_block_id',
zh_Hans: 'parent_block_id',
pt_BR: 'parent_block_id',
ja_JP: 'parent_block_id',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the page which the children will be added.',
zh_Hans: 'The ID of the page which the children will be added.',
pt_BR: 'The ID of the page which the children will be added.',
ja_JP: 'The ID of the page which the children will be added.',
},
form: 'llm',
llm_description: 'The ID of the page which the children will be added.',
},
],
labels: [],
output_schema: null,
},
]
export const listData = [
{
id: 'fdjklajfkljadslf111',
author: 'KVOJJJin',
name: 'GOGOGO',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO',
zh_Hans: 'GOGOGO',
},
},
{
id: 'fdjklajfkljadslf222',
author: 'KVOJJJin',
name: 'GOGOGO2',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: false,
tools: [],
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO2',
zh_Hans: 'GOGOGO2',
},
},
{
id: 'fdjklajfkljadslf333',
author: 'KVOJJJin',
name: 'GOGOGO3',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO3',
zh_Hans: 'GOGOGO3',
},
},
]

View File

@@ -0,0 +1,424 @@
'use client'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { getDomain } from 'tldts'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import AppIcon from '@/app/components/base/app-icon'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import HeadersInput from './headers-input'
import type { HeaderItem } from './headers-input'
import type { AppIconType } from '@/types/app'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { noop } from 'lodash-es'
import Toast from '@/app/components/base/toast'
import { uploadRemoteFileInfo } from '@/service/common'
import cn from '@/utils/classnames'
import { useHover } from 'ahooks'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
import TabSlider from '@/app/components/base/tab-slider'
import { MCPAuthMethod } from '@/app/components/tools/types'
import Switch from '@/app/components/base/switch'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import { API_PREFIX } from '@/config'
export type DuplicateAppModalProps = {
data?: ToolWithProvider
show: boolean
onConfirm: (info: {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}) => void
onHide: () => void
}
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider) => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
}
const MCPModal = ({
data,
show,
onConfirm,
onHide,
}: DuplicateAppModalProps) => {
const { t } = useTranslation()
const isCreate = !data
const authMethods = [
{
text: t('tools.mcp.modal.authentication'),
value: MCPAuthMethod.authentication,
},
{
text: t('tools.mcp.modal.headers'),
value: MCPAuthMethod.headers,
},
{
text: t('tools.mcp.modal.configurations'),
value: MCPAuthMethod.configurations,
},
]
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
const [url, setUrl] = React.useState(data?.server_url || '')
const [name, setName] = React.useState(data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
const [timeout, setMcpTimeout] = React.useState(data?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.sse_read_timeout || 300)
const [headers, setHeaders] = React.useState<HeaderItem[]>(
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
)
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
const isHovering = useHover(appIconRef)
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
// Update states when data changes (for edit mode)
React.useEffect(() => {
if (data) {
setUrl(data.server_url || '')
setName(data.name || '')
setServerIdentifier(data.server_identifier || '')
setMcpTimeout(data.timeout || 30)
setSseReadTimeout(data.sse_read_timeout || 300)
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
setAppIcon(getIcon(data))
setIsDynamicRegistration(data.is_dynamic_registration)
setClientID(data.authentication?.client_id || '')
setCredentials(data.authentication?.client_secret || '')
}
else {
// Reset for create mode
setUrl('')
setName('')
setServerIdentifier('')
setMcpTimeout(30)
setSseReadTimeout(300)
setHeaders([])
setAppIcon(DEFAULT_ICON as AppIconSelection)
setIsDynamicRegistration(true)
setClientID('')
setCredentials('')
}
}, [data])
const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
const handleBlur = async (url: string) => {
if (data)
return
if (!isValidUrl(url))
return
const domain = getDomain(url)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
const errorData = await (e as Response).json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}
const submit = async () => {
if (!isValidUrl(url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
return
}
if (!isValidServerID(serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
return
}
const formattedHeaders = headers.reduce((acc, item) => {
if (item.key.trim())
acc[item.key.trim()] = item.value
return acc
}, {} as Record<string, string>)
await onConfirm({
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
server_identifier: serverIdentifier.trim(),
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
is_dynamic_registration: isDynamicRegistration,
authentication: {
client_id: clientID,
client_secret: credentials,
},
configuration: {
timeout: timeout || 30,
sse_read_timeout: sseReadTimeout || 300,
},
})
if(isCreate)
onHide()
}
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
return (
<>
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<div className='absolute right-5 top-5 z-10 cursor-pointer p-1.5' onClick={onHide}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='title-2xl-semi-bold relative pb-3 text-xl text-text-primary'>{!isCreate ? t('tools.mcp.modal.editTitle') : t('tools.mcp.modal.title')}</div>
<div className='space-y-5 py-3'>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverUrl')}</span>
</div>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.serverUrlPlaceholder')}
/>
{originalServerUrl && originalServerUrl !== url && (
<div className='mt-1 flex h-5 items-center'>
<span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverUrlWarning')}</span>
</div>
)}
</div>
<div className='flex space-x-3'>
<div className='grow pb-1'>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.name')}</span>
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('tools.mcp.modal.namePlaceholder')}
/>
</div>
<div className='pt-2' ref={appIconRef}>
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className='h-8 w-8 text-text-primary-on-surface' /> : undefined}
size='xxl'
className='relative cursor-pointer rounded-2xl'
coverElement={
isHovering
? (<div className='absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt'>
<RiEditLine className='size-6 text-text-primary-on-surface' />
</div>) : null
}
onClick={() => { setShowAppIconPicker(true) }}
/>
</div>
</div>
<div>
<div className='flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverIdentifier')}</span>
</div>
<div className='body-xs-regular mb-1 text-text-tertiary'>{t('tools.mcp.modal.serverIdentifierTip')}</div>
<Input
value={serverIdentifier}
onChange={e => setServerIdentifier(e.target.value)}
placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')}
/>
{originalServerID && originalServerID !== serverIdentifier && (
<div className='mt-1 flex h-5 items-center'>
<span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverIdentifierWarning')}</span>
</div>
)}
</div>
<TabSlider
className='w-full'
itemClassName={(isActive) => {
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
}}
value={authMethod}
onChange={handleAuthMethodChange}
options={authMethods}
/>
{
authMethod === MCPAuthMethod.authentication && (
<>
<div>
<div className='mb-1 flex h-6 items-center'>
<Switch
className='mr-2'
defaultValue={isDynamicRegistration}
onChange={setIsDynamicRegistration}
/>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.useDynamicClientRegistration')}</span>
</div>
{!isDynamicRegistration && (
<div className='mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3'>
<AlertTriangle className='mt-0.5 h-4 w-4 shrink-0 text-text-warning' />
<div className='system-xs-regular text-text-secondary'>
<div className='mb-1'>{t('tools.mcp.modal.redirectUrlWarning')}</div>
<code className='system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary'>
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.clientID')}</span>
</div>
<Input
value={clientID}
onChange={e => setClientID(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.clientID')}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.clientSecret')}</span>
</div>
<Input
value={credentials}
onChange={e => setCredentials(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.clientSecretPlaceholder')}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
{
authMethod === MCPAuthMethod.headers && (
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.headers')}</span>
</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('tools.mcp.modal.headersTip')}</div>
<HeadersInput
headersItems={headers}
onChange={setHeaders}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
{
authMethod === MCPAuthMethod.configurations && (
<>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.timeout')}</span>
</div>
<Input
type='number'
value={timeout}
onChange={e => setMcpTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.timeoutPlaceholder')}
/>
</div>
<div>
<div className='mb-1 flex h-6 items-center'>
<span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.sseReadTimeout')}</span>
</div>
<Input
type='number'
value={sseReadTimeout}
onChange={e => setSseReadTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('tools.mcp.modal.timeoutPlaceholder')}
/>
</div>
</>
)
}
</div>
<div className='flex flex-row-reverse pt-5'>
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className='ml-2' variant='primary' onClick={submit}>{data ? t('tools.mcp.modal.save') : t('tools.mcp.modal.confirm')}</Button>
<Button onClick={onHide}>{t('tools.mcp.modal.cancel')}</Button>
</div>
</Modal>
{showAppIconPicker && <AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(getIcon(data))
setShowAppIconPicker(false)
}}
/>}
</>
)
}
export default MCPModal

View File

@@ -0,0 +1,152 @@
'use client'
import { useCallback, useState } from 'react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { RiHammerFill } from '@remixicon/react'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import type { ToolWithProvider } from '../../workflow/types'
import Confirm from '@/app/components/base/confirm'
import MCPModal from './modal'
import OperationDropdown from './detail/operation-dropdown'
import { useDeleteMCP, useUpdateMCP } from '@/service/use-tools'
import cn from '@/utils/classnames'
type Props = {
currentProvider?: ToolWithProvider
data: ToolWithProvider
handleSelect: (providerID: string) => void
onUpdate: (providerID: string) => void
onDeleted: () => void
}
const MCPCard = ({
currentProvider,
data,
onUpdate,
handleSelect,
onDeleted,
}: Props) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const { isCurrentWorkspaceManager } = useAppContext()
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isOperationShow, setIsOperationShow] = useState(false)
const [isShowUpdateModal, {
setTrue: showUpdateModal,
setFalse: hideUpdateModal,
}] = useBoolean(false)
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleUpdate = useCallback(async (form: any) => {
const res = await updateMCP({
...form,
provider_id: data.id,
})
if ((res as any)?.result === 'success') {
hideUpdateModal()
onUpdate(data.id)
}
}, [data, updateMCP, hideUpdateModal, onUpdate])
const handleDelete = useCallback(async () => {
showDeleting()
const res = await deleteMCP(data.id)
hideDeleting()
if ((res as any)?.result === 'success') {
hideDeleteConfirm()
onDeleted()
}
}, [showDeleting, deleteMCP, data.id, hideDeleting, hideDeleteConfirm, onDeleted])
return (
<div
onClick={() => handleSelect(data.id)}
className={cn(
'group relative flex cursor-pointer flex-col rounded-xl border-[1.5px] border-transparent bg-components-card-bg shadow-xs hover:bg-components-card-bg-alt hover:shadow-md',
currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-card-bg-alt',
)}
>
<div className='flex grow items-center gap-3 rounded-t-xl p-4'>
<div className='shrink-0 overflow-hidden rounded-xl border border-components-panel-border-subtle'>
<Icon src={data.icon} />
</div>
<div className='grow'>
<div className='system-md-semibold mb-1 truncate text-text-secondary' title={data.name}>{data.name}</div>
<div className='system-xs-regular text-text-tertiary'>{data.server_identifier}</div>
</div>
</div>
<div className='flex items-center gap-1 rounded-b-xl pb-2.5 pl-4 pr-2.5 pt-1.5'>
<div className='flex w-0 grow items-center gap-2'>
<div className='flex items-center gap-1'>
<RiHammerFill className='h-3 w-3 shrink-0 text-text-quaternary' />
{data.tools.length > 0 && (
<div className='system-xs-regular shrink-0 text-text-tertiary'>{t('tools.mcp.toolsCount', { count: data.tools.length })}</div>
)}
{!data.tools.length && (
<div className='system-xs-regular shrink-0 text-text-tertiary'>{t('tools.mcp.noTools')}</div>
)}
</div>
<div className={cn('system-xs-regular text-divider-deep', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')}>/</div>
<div className={cn('system-xs-regular truncate text-text-tertiary', (!data.is_team_authorization || !data.tools.length) && ' sm:hidden')} title={`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}>{`${t('tools.mcp.updateTime')} ${formatTimeFromNow(data.updated_at! * 1000)}`}</div>
</div>
{data.is_team_authorization && data.tools.length > 0 && <Indicator color='green' className='shrink-0' />}
{(!data.is_team_authorization || !data.tools.length) && (
<div className='system-xs-medium flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 text-util-colors-red-red-500'>
{t('tools.mcp.noConfigured')}
<Indicator color='red' />
</div>
)}
</div>
{isCurrentWorkspaceManager && (
<div className={cn('absolute right-2.5 top-2.5 hidden group-hover:block', isOperationShow && 'block')} onClick={e => e.stopPropagation()}>
<OperationDropdown
inCard
onOpenChange={setIsOperationShow}
onEdit={showUpdateModal}
onRemove={showDeleteConfirm}
/>
</div>
)}
{isShowUpdateModal && (
<MCPModal
data={data}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t('tools.mcp.delete')}
content={
<div>
{t('tools.mcp.deleteConfirmTitle', { mcp: data.name })}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
</div>
)
}
export default MCPCard

View File

@@ -0,0 +1,228 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Collection } from './types'
import Marketplace from './marketplace'
import cn from '@/utils/classnames'
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 Input from '@/app/components/base/input'
import ProviderDetail from '@/app/components/tools/provider/detail'
import Empty from '@/app/components/plugins/marketplace/empty'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import MCPList from './mcp'
import { useAllToolProviders } from '@/service/use-tools'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { ToolTypeEnum } from '../workflow/block-selector/types'
import { useMarketplace } from './marketplace/hooks'
import { useTags } from '@/app/components/plugins/hooks'
const getToolType = (type: string) => {
switch (type) {
case 'builtin':
return ToolTypeEnum.BuiltIn
case 'api':
return ToolTypeEnum.Custom
case 'workflow':
return ToolTypeEnum.Workflow
case 'mcp':
return ToolTypeEnum.MCP
default:
return ToolTypeEnum.BuiltIn
}
}
const ProviderList = () => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'
const { t } = useTranslation()
const { getTagLabel } = useTags()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const containerRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: 'builtin',
})
const options = [
{ value: 'builtin', text: t('tools.type.builtIn') },
{ value: 'api', text: t('tools.type.custom') },
{ value: 'workflow', text: t('tools.type.workflow') },
{ value: 'mcp', text: 'MCP' },
]
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
}
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const { data: collectionList = [], refetch } = useAllToolProviders()
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 Object.values(collection.label).some(value => value.toLowerCase().includes(keywords.toLowerCase()))
return true
})
}, [activeTab, tagFilterValue, keywords, collectionList])
const [currentProviderId, setCurrentProviderId] = useState<string | undefined>()
const currentProvider = useMemo<Collection | undefined>(() => {
return filteredCollectionList.find(collection => collection.id === currentProviderId)
}, [currentProviderId, filteredCollectionList])
const { data: checkedInstalledData } = useCheckInstalled({
pluginIds: currentProvider?.plugin_id ? [currentProvider.plugin_id] : [],
enabled: !!currentProvider?.plugin_id,
})
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const currentPluginDetail = useMemo(() => {
return checkedInstalledData?.plugins?.[0]
}, [checkedInstalledData])
const toolListTailRef = useRef<HTMLDivElement>(null)
const showMarketplacePanel = useCallback(() => {
containerRef.current?.scrollTo({
top: toolListTailRef.current
? toolListTailRef.current?.offsetTop - 80
: 0,
behavior: 'smooth',
})
}, [toolListTailRef])
const marketplaceContext = useMarketplace(keywords, tagFilterValue)
const {
handleScroll,
} = marketplaceContext
const [isMarketplaceArrowVisible, setIsMarketplaceArrowVisible] = useState(true)
const onContainerScroll = useMemo(() => {
return (e: Event) => {
handleScroll(e)
if (containerRef.current && toolListTailRef.current)
setIsMarketplaceArrowVisible(containerRef.current.scrollTop < (toolListTailRef.current?.offsetTop - 80))
}
}, [handleScroll, containerRef, toolListTailRef, setIsMarketplaceArrowVisible])
useEffect(() => {
const container = containerRef.current
if (container)
container.addEventListener('scroll', onContainerScroll)
return () => {
if (container)
container.removeEventListener('scroll', onContainerScroll)
}
}, [onContainerScroll])
return (
<>
<div className='relative flex h-0 shrink-0 grow overflow-hidden'>
<div
ref={containerRef}
className='relative flex grow flex-col overflow-y-auto bg-background-body'
>
<div className={cn(
'sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-2 pt-4 leading-[56px]',
currentProviderId && 'pr-6',
)}>
<TabSliderNew
value={activeTab}
onChange={(state) => {
setActiveTab(state)
if (state !== activeTab)
setCurrentProviderId(undefined)
}}
options={options}
/>
<div className='flex items-center gap-2'>
{activeTab !== 'mcp' && (
<LabelFilter value={tagFilterValue} onChange={handleTagsChange} />
)}
<Input
showLeftIcon
showClearIcon
wrapperClassName='w-[200px]'
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
{activeTab !== 'mcp' && (
<div className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pb-4 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
!filteredCollectionList.length && activeTab === 'workflow' && 'grow',
)}>
{activeTab === 'api' && <CustomCreateCard onRefreshData={refetch} />}
{filteredCollectionList.map(collection => (
<div
key={collection.id}
onClick={() => setCurrentProviderId(collection.id)}
>
<Card
className={cn(
'cursor-pointer border-[1.5px] border-transparent',
currentProviderId === collection.id && 'border-components-option-card-option-selected-border',
)}
hideCornerMark
payload={{
...collection,
brief: collection.description,
org: collection.plugin_id ? collection.plugin_id.split('/')[0] : '',
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
} as any}
footer={
<CardMoreInfo
tags={collection.labels?.map(label => getTagLabel(label)) || []}
/>
}
/>
</div>
))}
{!filteredCollectionList.length && activeTab === 'workflow' && <div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'><WorkflowToolEmpty type={getToolType(activeTab)} /></div>}
</div>
)}
{!filteredCollectionList.length && activeTab === 'builtin' && (
<Empty lightCard text={t('tools.noTools')} className='h-[224px] shrink-0 px-12' />
)}
<div ref={toolListTailRef} />
{enable_marketplace && activeTab === 'builtin' && (
<Marketplace
searchPluginText={keywords}
filterPluginTags={tagFilterValue}
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
/>
)}
{activeTab === 'mcp' && (
<MCPList searchText={keywords} />
)}
</div>
</div>
{currentProvider && !currentProvider.plugin_id && (
<ProviderDetail
collection={currentProvider}
onHide={() => setCurrentProviderId(undefined)}
onRefreshData={refetch}
/>
)}
<PluginDetailPanel
detail={currentPluginDetail}
onUpdate={() => invalidateInstalledPluginList()}
onHide={() => setCurrentProviderId(undefined)}
/>
</>
)
}
ProviderList.displayName = 'ToolProviderList'
export default ProviderList

View File

@@ -0,0 +1,78 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
RiAddCircleFill,
RiArrowRightUpLine,
RiBookOpenLine,
} from '@remixicon/react'
import type { CustomCollectionBackend } from '../types'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { createCustomCollection } from '@/service/tools'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
type Props = {
onRefreshData: () => void
}
const Contribute = ({ onRefreshData }: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext()
const docLink = useDocLink()
const linkUrl = useMemo(() => {
return docLink('/guides/tools#how-to-create-custom-tools', {
'zh-Hans': '/guides/tools#ru-he-chuang-jian-zi-ding-yi-gong-ju',
})
}, [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 (
<>
{isCurrentWorkspaceManager && (
<div className='col-span-1 flex min-h-[135px] cursor-pointer flex-col rounded-xl bg-background-default-dimmed transition-all duration-200 ease-in-out'>
<div className='group grow rounded-t-xl' onClick={() => setIsShowEditCustomCollectionModal(true)}>
<div className='flex shrink-0 items-center p-4 pb-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-lg border border-dashed border-divider-deep group-hover:border-solid group-hover:border-state-accent-hover-alt group-hover:bg-state-accent-hover'>
<RiAddCircleFill className='h-4 w-4 text-text-quaternary group-hover:text-text-accent'/>
</div>
<div className='system-md-semibold ml-3 text-text-secondary group-hover:text-text-accent'>{t('tools.createCustomTool')}</div>
</div>
</div>
<div className='rounded-b-xl border-t-[0.5px] border-divider-subtle px-4 py-3 text-text-tertiary hover:text-text-accent'>
<a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
<RiBookOpenLine className='h-3 w-3 shrink-0' />
<div className='system-xs-regular grow truncate' title={t('tools.customToolTip') || ''}>{t('tools.customToolTip')}</div>
<RiArrowRightUpLine className='h-3 w-3 shrink-0' />
</a>
</div>
</div>
)}
{isShowEditCollectionToolModal && (
<EditCustomToolModal
payload={null}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onAdd={doCreateCustomToolCollection}
/>
)}
</>
)
}
export default Contribute

View File

@@ -0,0 +1,429 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
RiCloseLine,
} from '@remixicon/react'
import { AuthHeaderPrefix, AuthType, CollectionType } from '../types'
import { basePath } from '@/utils/var'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import ToolItem from './tool-item'
import cn from '@/utils/classnames'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import Confirm from '@/app/components/base/confirm'
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 Icon from '@/app/components/plugins/card/base/card-icon'
import Title from '@/app/components/plugins/card/base/title'
import OrgInfo from '@/app/components/plugins/card/base/org-info'
import Description from '@/app/components/plugins/card/base/description'
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 Drawer from '@/app/components/base/drawer'
import ActionButton from '@/app/components/base/action-button'
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 { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
type Props = {
collection: Collection
onHide: () => void
onRefreshData: () => void
}
const ProviderDetail = ({
collection,
onHide,
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 { isCurrentWorkspaceManager } = useAppContext()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
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,
currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
currentCustomConfigurationModelFixedFields: undefined,
},
onSaveCallback: () => {
onRefreshData()
},
})
}
}
else {
setShowSettingAuth(true)
}
}
// custom provider
const [customCollection, setCustomCollection] = useState<CustomCollectionBackend | WorkflowToolProviderResponse | null>(null)
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [deleteAction, setDeleteAction] = useState('')
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.labels, 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)
invalidateAllWorkflowTools()
onRefreshData()
getWorkflowToolProvider()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditWorkflowToolModal(false)
}
const onClickCustomToolDelete = () => {
setDeleteAction('customTool')
setShowConfirmDelete(true)
}
const onClickWorkflowToolDelete = () => {
setDeleteAction('workflowTool')
setShowConfirmDelete(true)
}
const handleConfirmDelete = () => {
if (deleteAction === 'customTool')
doRemoveCustomToolCollection()
else if (deleteAction === 'workflowTool')
removeWorkflowToolProvider()
setShowConfirmDelete(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 { }
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 (
<Drawer
isOpen={!!collection}
clickOutsideNotOpen={false}
onClose={onHide}
footer={null}
mask={false}
positionCenter={false}
panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
>
<div className='flex h-full flex-col p-4'>
<div className="shrink-0">
<div className='mb-3 flex'>
<Icon src={collection.icon} />
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={collection.label[language]} />
</div>
<div className='mb-1 mt-0.5 flex h-4 items-center justify-between'>
<OrgInfo
packageNameClassName='w-auto'
orgName={collection.author}
packageName={collection.name}
/>
</div>
</div>
<div className='flex gap-1'>
<ActionButton onClick={onHide}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
</div>
{!!collection.description[language] && (
<Description text={collection.description[language]} descriptionLineRows={2}></Description>
)}
<div className='flex gap-1 border-b-[0.5px] border-divider-subtle'>
{collection.type === CollectionType.custom && !isDetailLoading && (
<Button
className={cn('my-3 w-full shrink-0')}
onClick={() => setIsShowEditCustomCollectionModal(true)}
>
<Settings01 className='mr-1 h-4 w-4 text-text-tertiary' />
<div className='system-sm-medium text-text-secondary'>{t('tools.createTool.editAction')}</div>
</Button>
)}
{collection.type === CollectionType.workflow && !isDetailLoading && customCollection && (
<>
<Button
variant='primary'
className={cn('my-3 w-[183px] shrink-0')}
>
<a className='flex items-center' href={`${basePath}/app/${(customCollection as WorkflowToolProviderResponse).workflow_app_id}/workflow`} rel='noreferrer' target='_blank'>
<div className='system-sm-medium'>{t('tools.openInStudio')}</div>
<LinkExternal02 className='ml-1 h-4 w-4' />
</a>
</Button>
<Button
className={cn('my-3 w-[183px] shrink-0')}
onClick={() => setIsShowEditWorkflowToolModal(true)}
disabled={!isCurrentWorkspaceManager}
>
<div className='system-sm-medium text-text-secondary'>{t('tools.createTool.editAction')}</div>
</Button>
</>
)}
</div>
<div className='flex min-h-0 flex-1 flex-col pt-3'>
{isDetailLoading && <div className='flex h-[200px]'><Loading type='app' /></div>}
{!isDetailLoading && (
<>
<div className="shrink-0">
{(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && isAuthed && (
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
{t('plugin.detailPanel.actionNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' })}
{needAuth && (
<Button
variant='secondary'
size='small'
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
{t('tools.auth.authorized')}
</Button>
)}
</div>
)}
{(collection.type === CollectionType.builtIn || collection.type === CollectionType.model) && needAuth && !isAuthed && (
<>
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
<span className='px-1'>·</span>
<span className='text-util-colors-orange-orange-600'>{t('tools.auth.setup').toLocaleUpperCase()}</span>
</div>
<Button
variant='primary'
className={cn('my-3 w-full shrink-0')}
onClick={() => {
if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
showSettingAuthModal()
}}
disabled={!isCurrentWorkspaceManager}
>
{t('tools.auth.unauthorized')}
</Button>
</>
)}
{(collection.type === CollectionType.custom) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.includeToolNum', { num: toolList.length, action: toolList.length > 1 ? 'actions' : 'action' }).toLocaleUpperCase()}</span>
</div>
)}
{(collection.type === CollectionType.workflow) && (
<div className='system-sm-semibold-uppercase text-text-secondary'>
<span className=''>{t('tools.createTool.toolInput.title').toLocaleUpperCase()}</span>
</div>
)}
</div>
<div className='mt-1 flex-1 overflow-y-auto py-2'>
{collection.type !== CollectionType.workflow && toolList.map(tool => (
<ToolItem
key={tool.name}
disabled={false}
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-1 py-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='code-sm-semibold text-text-secondary'>{item.name}</span>
<span className='system-xs-regular text-text-tertiary'>{item.type}</span>
<span className='system-xs-medium text-text-warning-secondary'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div>
<div className='system-xs-regular text-text-tertiary'>{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={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('tools.createTool.deleteToolConfirmTitle')}
content={t('tools.createTool.deleteToolConfirmContent')}
isShow={showConfirmDelete}
onConfirm={handleConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</div>
</Drawer>
)
}
export default ProviderDetail

View File

@@ -0,0 +1,52 @@
'use client'
import { useTranslation } from 'react-i18next'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import { RiArrowRightUpLine } from '@remixicon/react'
import Link from 'next/link'
import cn from '@/utils/classnames'
import { NoToolPlaceholder } from '../../base/icons/src/vender/other'
import useTheme from '@/hooks/use-theme'
type Props = {
type?: ToolTypeEnum
isAgent?: boolean
}
const getLink = (type?: ToolTypeEnum) => {
switch (type) {
case ToolTypeEnum.Custom:
return '/tools?category=api'
case ToolTypeEnum.MCP:
return '/tools?category=mcp'
default:
return '/tools?category=api'
}
}
const Empty = ({
type,
isAgent,
}: Props) => {
const { t } = useTranslation()
const { theme } = useTheme()
const hasLink = type && [ToolTypeEnum.Custom, ToolTypeEnum.MCP].includes(type)
const Comp = (hasLink ? Link : 'div') as any
const linkProps = hasLink ? { href: getLink(type), target: '_blank' } : {}
const renderType = isAgent ? 'agent' : type
const hasTitle = t(`tools.addToolModal.${renderType}.title`) !== `tools.addToolModal.${renderType}.title`
return (
<div className='flex flex-col items-center justify-center'>
<NoToolPlaceholder className={theme === 'dark' ? 'invert' : ''} />
<div className='mb-1 mt-2 text-[13px] font-medium leading-[18px] text-text-primary'>
{hasTitle ? t(`tools.addToolModal.${renderType}.title`) : 'No tools available'}
</div>
{(!isAgent && hasTitle) && (
<Comp className={cn('flex items-center text-[13px] leading-[18px] text-text-tertiary', hasLink && 'cursor-pointer hover:text-text-accent')} {...linkProps}>
{t(`tools.addToolModal.${renderType}.tip`)} {hasLink && <RiArrowRightUpLine className='ml-0.5 h-3 w-3' />}
</Comp>
)}
</div>
)
}
export default Empty

View File

@@ -0,0 +1,54 @@
'use client'
import React, { useState } from 'react'
import { useContext } from 'use-context-selector'
import type { Collection, Tool } from '../types'
import cn from '@/utils/classnames'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/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('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover', disabled && '!cursor-not-allowed opacity-50')}
onClick={() => !disabled && setShowDetail(true)}
>
<div className='system-md-semibold pb-0.5 text-text-secondary'>{tool.label[language]}</div>
<div className='system-xs-regular line-clamp-2 text-text-tertiary' title={tool.description[language]}>{tool.description[language]}</div>
</div>
{showDetail && (
<SettingBuiltInTool
showBackButton
collection={collection}
toolName={tool.name}
readonly
onHide={() => {
setShowDetail(false)
}}
isBuiltIn={isBuiltIn}
isModel={isModel}
/>
)}
</>
)
}
export default ToolItem

View File

@@ -0,0 +1,130 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addDefaultValue, toolCredentialToFormSchemas } from '../../utils/to-form-schema'
import type { Collection } from '../../types'
import cn from '@/utils/classnames'
import Drawer from '@/app/components/base/drawer-plus'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import { fetchBuiltInToolCredential, fetchBuiltInToolCredentialSchema } from '@/service/tools'
import Loading from '@/app/components/base/loading'
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { noop } from 'lodash-es'
type Props = {
collection: Collection
onCancel: () => void
onSaved: (value: Record<string, any>) => void
isHideRemoveBtn?: boolean
onRemove?: () => void
isSaving?: boolean
}
const ConfigCredential: FC<Props> = ({
collection,
onCancel,
onSaved,
isHideRemoveBtn,
onRemove = noop,
isSaving,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const [credentialSchema, setCredentialSchema] = useState<any>(null)
const { name: collectionName } = collection
const [tempCredential, setTempCredential] = React.useState<any>({})
const [isLoading, setIsLoading] = React.useState(false)
useEffect(() => {
fetchBuiltInToolCredentialSchema(collectionName).then(async (res) => {
const toolCredentialSchemas = toolCredentialToFormSchemas(res)
const credentialValue = await fetchBuiltInToolCredential(collectionName)
setTempCredential(credentialValue)
const defaultCredentials = addDefaultValue(credentialValue, toolCredentialSchemas)
setCredentialSchema(toolCredentialSchemas)
setTempCredential(defaultCredentials)
})
}, [])
const handleSave = async () => {
for (const field of credentialSchema) {
if (field.required && !tempCredential[field.name]) {
Toast.notify({ type: 'error', message: t('common.errorMsg.fieldRequired', { field: field.label[language] || field.label.en_US }) })
return
}
}
setIsLoading(true)
try {
await onSaved(tempCredential)
setIsLoading(false)
}
finally {
setIsLoading(false)
}
}
return (
<Drawer
isShow
onHide={onCancel}
title={t('tools.auth.setupModalTitle') as string}
titleDescription={t('tools.auth.setupModalTitleDescription') as string}
panelClassName='mt-[64px] mb-2 !w-[420px] border-components-panel-border'
maxWidthClassName='!max-w-[420px]'
height='calc(100vh - 64px)'
contentClassName='!bg-components-panel-bg'
headerClassName='!border-b-divider-subtle'
body={
<div className='h-full px-6 py-3'>
{!credentialSchema
? <Loading type='app' />
: (
<>
<Form
value={tempCredential}
onChange={(v) => {
setTempCredential(v)
}}
formSchemas={credentialSchema}
isEditMode={true}
showOnVariableMap={{}}
validating={false}
inputClassName='!bg-components-input-bg-normal'
fieldMoreInfo={item => item.url
? (<a
href={item.url}
target='_blank' rel='noopener noreferrer'
className='inline-flex items-center text-xs text-text-accent'
>
{t('tools.howToGet')}
<LinkExternal02 className='ml-1 h-3 w-3' />
</a>)
: null}
/>
<div className={cn((collection.is_team_authorization && !isHideRemoveBtn) ? 'justify-between' : 'justify-end', 'mt-2 flex ')} >
{
(collection.is_team_authorization && !isHideRemoveBtn) && (
<Button onClick={onRemove}>{t('common.operation.remove')}</Button>
)
}
<div className='flex space-x-2'>
<Button onClick={onCancel}>{t('common.operation.cancel')}</Button>
<Button loading={isLoading || isSaving} disabled={isLoading || isSaving} variant='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
</div>
</div>
</>
)
}
</div >
}
isShowMask={true}
clickOutsideNotOpen={false}
/>
)
}
export default React.memo(ConfigCredential)

View File

@@ -0,0 +1,238 @@
import type { TypeWithI18N } from '../header/account-setting/model-provider-page/declarations'
export enum LOC {
tools = 'tools',
app = 'app',
}
export enum AuthType {
none = 'none',
apiKey = 'api_key', // backward compatibility
apiKeyHeader = 'api_key_header',
apiKeyQuery = 'api_key_query',
}
export enum AuthHeaderPrefix {
basic = 'basic',
bearer = 'bearer',
custom = 'custom',
}
export type Credential = {
auth_type: AuthType
api_key_header?: string
api_key_value?: string
api_key_header_prefix?: AuthHeaderPrefix
api_key_query_param?: string
}
export enum CollectionType {
all = 'all',
builtIn = 'builtin',
custom = 'api',
model = 'model',
workflow = 'workflow',
mcp = 'mcp',
datasource = 'datasource',
trigger = 'trigger',
}
export type Emoji = {
background: string
content: string
}
export type Collection = {
id: string
name: string
author: string
description: TypeWithI18N
icon: string | Emoji
label: TypeWithI18N
type: CollectionType | string
team_credentials: Record<string, any>
is_team_authorization: boolean
allow_delete: boolean
labels: string[]
plugin_id?: string
letter?: string
// MCP Server
server_url?: string
updated_at?: number
server_identifier?: string
timeout?: number
sse_read_timeout?: number
headers?: Record<string, string>
masked_headers?: Record<string, string>
is_authorized?: boolean
provider?: string
credential_id?: string
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
}
configuration?: {
timeout?: number
sse_read_timeout?: number
}
}
export type ToolParameter = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
type: string
form: string
llm_description: string
required: boolean
multiple: boolean
default: string
options?: {
label: TypeWithI18N
value: string
}[]
min?: number
max?: number
}
export type TriggerParameter = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
type: string
form: string
llm_description: string
required: boolean
multiple: boolean
default: string
options?: {
label: TypeWithI18N
value: string
}[]
}
// Action
export type Event = {
name: string
author: string
label: TypeWithI18N
description: TypeWithI18N
parameters: TriggerParameter[]
labels: string[]
output_schema: Record<string, any>
}
export type Tool = {
name: string
author: string
label: TypeWithI18N
description: any
parameters: ToolParameter[]
labels: string[]
output_schema: Record<string, any>
}
export type ToolCredential = {
name: string
label: TypeWithI18N
help: TypeWithI18N | null
placeholder: TypeWithI18N
type: string
required: boolean
default: string
options?: {
label: TypeWithI18N
value: string
}[]
}
export type CustomCollectionBackend = {
provider: string
original_provider?: string
credentials: Credential
icon: Emoji
schema_type: string
schema: string
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
max?: number
options?: {
label: TypeWithI18N
value: string
}[]
}
export type CustomParamSchema = {
operation_id: string // name
summary: string
server_url: string
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
}
export type MCPServerDetail = {
id: string
server_code: string
description: string
status: string
parameters?: Record<string, string>
headers?: Record<string, string>
}
export enum MCPAuthMethod {
authentication = 'authentication',
headers = 'headers',
configurations = 'configurations',
}

View File

@@ -0,0 +1,27 @@
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { VisionFile } from '@/types/app'
export const sortAgentSorts = (list: ThoughtItem[]) => {
if (!list)
return list
if (list.some(item => item.position === undefined))
return list
const temp = [...list]
temp.sort((a, b) => a.position - b.position)
return temp
}
export const addFileInfos = (list: ThoughtItem[], messageFiles: (FileEntity | VisionFile)[]) => {
if (!list || !messageFiles)
return list
return list.map((item) => {
if (item.files && item.files?.length > 0) {
return {
...item,
message_files: item.files.map(fileId => messageFiles.find(file => file.id === fileId)) as FileEntity[],
}
}
return item
})
}

View File

@@ -0,0 +1,225 @@
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import type { TriggerEventParameter } from '../../plugins/types'
import type { ToolCredential, ToolParameter } from '../types'
export const toType = (type: string) => {
switch (type) {
case 'string':
return 'text-input'
case 'number':
return 'number-input'
case 'boolean':
return 'checkbox'
default:
return type
}
}
export const triggerEventParametersToFormSchemas = (parameters: TriggerEventParameter[]) => {
if (!parameters?.length)
return []
return parameters.map((parameter) => {
return {
...parameter,
type: toType(parameter.type),
_type: parameter.type,
tooltip: parameter.description,
}
})
}
export const toolParametersToFormSchemas = (parameters: ToolParameter[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
type: toType(parameter.type),
_type: parameter.type,
show_on: [],
options: parameter.options?.map((option) => {
return {
...option,
show_on: [],
}
}),
tooltip: parameter.human_description,
}
})
return formSchemas
}
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
type: toType(parameter.type),
label: parameter.label,
tooltip: parameter.help,
show_on: [],
options: parameter.options?.map((option) => {
return {
...option,
show_on: [],
}
}),
}
})
return formSchemas
}
export const addDefaultValue = (value: Record<string, any>, formSchemas: { variable: string; type: string; default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined))
newValues[formSchema.variable] = formSchema.default
// Fix: Convert boolean field values to proper boolean type
if (formSchema.type === 'boolean' && itemValue !== undefined && itemValue !== null && itemValue !== '') {
if (typeof itemValue === 'string')
newValues[formSchema.variable] = itemValue === 'true' || itemValue === '1' || itemValue === 'True'
else if (typeof itemValue === 'number')
newValues[formSchema.variable] = itemValue === 1
else if (typeof itemValue === 'boolean')
newValues[formSchema.variable] = itemValue
}
})
return newValues
}
const correctInitialData = (type: string, target: any, defaultValue: any) => {
if (type === 'text-input' || type === 'secret-input')
target.type = 'mixed'
if (type === 'boolean') {
if (typeof defaultValue === 'string')
target.value = defaultValue === 'true' || defaultValue === '1'
if (typeof defaultValue === 'boolean')
target.value = defaultValue
if (typeof defaultValue === 'number')
target.value = defaultValue === 1
}
if (type === 'number-input') {
if (typeof defaultValue === 'string' && defaultValue !== '')
target.value = Number.parseFloat(defaultValue)
}
if (type === 'app-selector' || type === 'model-selector')
target.value = defaultValue
return target
}
export const generateFormValue = (value: Record<string, any>, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => {
const newValues = {} as any
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const value = formSchema.default
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: formSchema.default,
},
...(isReasoning ? { auto: 1, value: null } : {}),
}
if (!isReasoning)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value)
}
})
return newValues
}
export const getPlainValue = (value: Record<string, any>) => {
const plainValue = { ...value }
Object.keys(plainValue).forEach((key) => {
plainValue[key] = {
...value[key].value,
}
})
return plainValue
}
export const getStructureValue = (value: Record<string, any>) => {
const newValue = { ...value } as any
Object.keys(newValue).forEach((key) => {
newValue[key] = {
value: value[key],
}
})
return newValue
}
export const getConfiguredValue = (value: Record<string, any>, formSchemas: { variable: string; type: string; default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const value = formSchema.default
newValues[formSchema.variable] = {
type: 'constant',
value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default,
}
newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value)
}
})
return newValues
}
const getVarKindType = (type: FormTypeEnum) => {
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return VarKindType.variable
if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber)
return VarKindType.constant
if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput)
return VarKindType.mixed
}
export const generateAgentToolValue = (value: Record<string, any>, formSchemas: { variable: string; default?: any; type: string }[], isReasoning = false) => {
const newValues = {} as any
if (!isReasoning) {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: itemValue.value,
},
}
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value)
})
}
else {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if (itemValue.auto === 1) {
newValues[formSchema.variable] = {
auto: 1,
value: null,
}
}
else {
newValues[formSchema.variable] = {
auto: 0,
value: itemValue.value || {
type: getVarKindType(formSchema.type as FormTypeEnum),
value: null,
},
}
}
})
}
return newValues
}

View File

@@ -0,0 +1,269 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import Divider from '../../base/divider'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
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'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useAppContext } from '@/context/app-context'
import { useInvalidateAllWorkflowTools } from '@/service/use-tools'
type Props = {
disabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
disabledReason?: string
}
const WorkflowToolConfigureButton = ({
disabled,
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
handlePublish,
onRefreshData,
disabledReason,
}: Props) => {
const { t } = useTranslation()
const router = useRouter()
const [showModal, setShowModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
const { isCurrentWorkspaceManager } = useAppContext()
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
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 (item.type === 'text-input' && param.type !== 'string')
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)
invalidateAllWorkflowTools()
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?.()
invalidateAllWorkflowTools()
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 (
<>
<Divider type='horizontal' className='h-px bg-divider-subtle' />
{(!published || !isLoading) && (
<div className={cn(
'group rounded-lg bg-background-section-burn transition-colors',
disabled || !isCurrentWorkspaceManager ? 'cursor-not-allowed opacity-60 shadow-xs' : 'cursor-pointer',
!disabled && !published && isCurrentWorkspaceManager && 'hover:bg-state-accent-hover',
)}>
{isCurrentWorkspaceManager
? (
<div
className='flex items-center justify-start gap-2 p-2 pl-2.5'
onClick={() => !disabled && !published && setShowModal(true)}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div
title={t('workflow.common.workflowAsTool') || ''}
className={cn('system-sm-medium shrink grow basis-0 truncate text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
>
{t('workflow.common.workflowAsTool')}
</div>
{!published && (
<span className='system-2xs-medium-uppercase shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 text-text-tertiary'>
{t('workflow.common.configureRequired')}
</span>
)}
</div>
)
: (
<div
className='flex items-center justify-start gap-2 p-2 pl-2.5'
>
<RiHammerLine className='h-4 w-4 text-text-tertiary' />
<div
title={t('workflow.common.workflowAsTool') || ''}
className='system-sm-medium shrink grow basis-0 truncate text-text-tertiary'
>
{t('workflow.common.workflowAsTool')}
</div>
</div>
)}
{disabledReason && (
<div className='mt-1 px-2.5 pb-2 text-xs leading-[18px] text-text-tertiary'>
{disabledReason}
</div>
)}
{published && (
<div className='border-t-[0.5px] border-divider-regular px-2.5 py-2'>
<div className='flex justify-between gap-x-2'>
<Button
size='small'
className='w-[140px]'
onClick={() => setShowModal(true)}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('workflow.common.configure')}
{outdated && <Indicator className='ml-1' color={'yellow'} />}
</Button>
<Button
size='small'
className='w-[140px]'
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{t('workflow.common.manageInTools')}
<RiArrowRightUpLine className='ml-1 h-4 w-4' />
</Button>
</div>
{outdated && (
<div className='mt-1 text-xs leading-[18px] text-text-warning'>
{t('workflow.common.workflowAsToolTip')}
</div>
)}
</div>
)}
</div>
)}
{published && isLoading && <div className='pt-2'><Loading type='app' /></div>}
{showModal && (
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={() => setShowModal(false)}
onCreate={createHandle}
onSave={updateWorkflowToolProvider}
/>
)}
</>
)
}
export default WorkflowToolConfigureButton

View File

@@ -0,0 +1,46 @@
'use client'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { noop } from 'lodash-es'
type ConfirmModalProps = {
show: boolean
onConfirm?: () => void
onClose: () => void
}
const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
const { t } = useTranslation()
return (
<Modal
className={cn('w-[600px] max-w-[600px] p-8')}
isShow={show}
onClose={noop}
>
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onClose}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl'>
<AlertTriangle className='h-6 w-6 text-[rgb(247,144,9)]' />
</div>
<div className='relative mt-3 text-xl font-semibold leading-[30px] text-text-primary'>{t('tools.createTool.confirmTitle')}</div>
<div className='my-1 text-sm leading-5 text-text-tertiary'>
{t('tools.createTool.confirmTip')}
</div>
<div className='flex items-center justify-end pt-6'>
<div className='flex items-center'>
<Button className='mr-2' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant="warning" onClick={onConfirm}>{t('common.operation.confirm')}</Button>
</div>
</div>
</Modal>
)
}
export default ConfirmModal

View File

@@ -0,0 +1,282 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import cn from '@/utils/classnames'
import Drawer from '@/app/components/base/drawer-plus'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
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 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) => {
// when the user has not input anything, no need for a warning
if (name === '')
return true
return /^\w+$/.test(name)
}
const onConfirm = () => {
let errorMessage = ''
if (!label)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.name') })
if (!name)
errorMessage = t('common.errorMsg.fieldRequired', { field: t('tools.createTool.nameForToolCall') })
if (!isNameValid(name))
errorMessage = t('tools.createTool.nameForToolCall') + t('tools.createTool.nameForToolCallTip')
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
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-divider'
body={
<div className='flex h-full flex-col'>
<div className='h-0 grow space-y-4 overflow-y-auto px-6 py-3'>
{/* name & icon */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.name')} <span className='ml-1 text-red-500'>*</span></div>
<div className='flex items-center justify-between gap-3'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' iconType='emoji' icon={emoji.content} background={emoji.background} />
<Input
className='h-10 grow'
placeholder={t('tools.createTool.toolNamePlaceHolder')!}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
</div>
{/* name for tool call */}
<div>
<div className='system-sm-medium flex items-center py-2 text-text-primary'>
{t('tools.createTool.nameForToolCall')} <span className='ml-1 text-red-500'>*</span>
<Tooltip
popupContent={
<div className='w-[180px]'>
{t('tools.createTool.nameForToolCallPlaceHolder')}
</div>
}
/>
</div>
<Input
className='h-10'
placeholder={t('tools.createTool.nameForToolCallPlaceHolder')!}
value={name}
onChange={e => setName(e.target.value)}
/>
{!isNameValid(name) && (
<div className='text-xs leading-[18px] text-red-500'>{t('tools.createTool.nameForToolCallTip')}</div>
)}
</div>
{/* description */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.description')}</div>
<Textarea
placeholder={t('tools.createTool.descriptionPlaceholder') || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.toolInput.title')}</div>
<div className='w-full overflow-x-auto rounded-lg border border-divider-regular'>
<table className='w-full text-xs font-normal leading-[18px] text-text-secondary'>
<thead className='uppercase text-text-tertiary'>
<tr className='border-b border-divider-regular'>
<th className="w-[156px] p-2 pl-3 font-medium">{t('tools.createTool.toolInput.name')}</th>
<th className="w-[102px] p-2 pl-3 font-medium">{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 border-divider-regular last:border-0'>
<td className="max-w-[156px] p-2 pl-3">
<div className='text-[13px] leading-[18px]'>
<div title={item.name} className='flex'>
<span className='truncate font-medium text-text-primary'>{item.name}</span>
<span className='shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]'>{item.required ? t('tools.createTool.toolInput.required') : ''}</span>
</div>
<div className='text-text-tertiary'>{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{t('tools.createTool.toolInput.methodParameter')}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type='text'
className='w-full appearance-none bg-transparent text-[13px] font-normal leading-[18px] text-text-secondary caret-primary-600 outline-none placeholder:text-text-quaternary'
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='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.toolInput.label')}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className='system-sm-medium py-2 text-text-primary'>{t('tools.createTool.privacyPolicy')}</div>
<Input
className='h-10'
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
placeholder={t('tools.createTool.privacyPolicyPlaceholder') || ''} />
</div>
</div>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')} >
{!isAdd && onRemove && (
<Button variant='warning' onClick={onRemove}>{t('common.operation.delete')}</Button>
)}
<div className='flex space-x-2 '>
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button variant='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 { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
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 h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
open && '!bg-background-section-burn hover:bg-background-section-burn',
)}>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{value === 'llm' ? t('tools.createTool.toolInput.methodParameter') : t('tools.createTool.toolInput.methodSetting')}
</div>
<div className='ml-1 shrink-0 text-text-secondary opacity-60'>
<RiArrowDownSLine className='h-4 w-4' />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1040]'>
<div className='relative w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-1'>
<div className='cursor-pointer rounded-lg py-2.5 pl-3 pr-2 hover:bg-components-panel-on-panel-item-bg-hover' onClick={() => onChange('llm')}>
<div className='item-center flex gap-1'>
<div className='h-4 w-4 shrink-0'>
{value === 'llm' && <Check className='h-4 w-4 shrink-0 text-text-accent' />}
</div>
<div className='text-[13px] font-medium leading-[18px] text-text-secondary'>{t('tools.createTool.toolInput.methodParameter')}</div>
</div>
<div className='pl-5 text-[13px] leading-[18px] text-text-tertiary'>{t('tools.createTool.toolInput.methodParameterTip')}</div>
</div>
<div className='cursor-pointer rounded-lg py-2.5 pl-3 pr-2 hover:bg-components-panel-on-panel-item-bg-hover' onClick={() => onChange('form')}>
<div className='item-center flex gap-1'>
<div className='h-4 w-4 shrink-0'>
{value === 'form' && <Check className='h-4 w-4 shrink-0 text-text-accent' />}
</div>
<div className='text-[13px] font-medium leading-[18px] text-text-secondary'>{t('tools.createTool.toolInput.methodSetting')}</div>
</div>
<div className='pl-5 text-[13px] leading-[18px] text-text-tertiary'>{t('tools.createTool.toolInput.methodSettingTip')}</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default MethodSelector