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

modal.tsx 8.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. 'use client'
  2. import React, { useRef, useState } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import { getDomain } from 'tldts'
  5. import { RiCloseLine, RiEditLine } from '@remixicon/react'
  6. import AppIconPicker from '@/app/components/base/app-icon-picker'
  7. import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
  8. import AppIcon from '@/app/components/base/app-icon'
  9. import Modal from '@/app/components/base/modal'
  10. import Button from '@/app/components/base/button'
  11. import Input from '@/app/components/base/input'
  12. import type { AppIconType } from '@/types/app'
  13. import type { ToolWithProvider } from '@/app/components/workflow/types'
  14. import { noop } from 'lodash-es'
  15. import Toast from '@/app/components/base/toast'
  16. import { uploadRemoteFileInfo } from '@/service/common'
  17. import cn from '@/utils/classnames'
  18. import { useHover } from 'ahooks'
  19. export type DuplicateAppModalProps = {
  20. data?: ToolWithProvider
  21. show: boolean
  22. onConfirm: (info: {
  23. name: string
  24. server_url: string
  25. icon_type: AppIconType
  26. icon: string
  27. icon_background?: string | null
  28. server_identifier: string
  29. }) => void
  30. onHide: () => void
  31. }
  32. const DEFAULT_ICON = { type: 'emoji', icon: '🧿', background: '#EFF1F5' }
  33. const extractFileId = (url: string) => {
  34. const match = url.match(/files\/(.+?)\/file-preview/)
  35. return match ? match[1] : null
  36. }
  37. const getIcon = (data?: ToolWithProvider) => {
  38. if (!data)
  39. return DEFAULT_ICON as AppIconSelection
  40. if (typeof data.icon === 'string')
  41. return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
  42. return {
  43. ...data.icon,
  44. icon: data.icon.content,
  45. type: 'emoji',
  46. } as unknown as AppIconSelection
  47. }
  48. const MCPModal = ({
  49. data,
  50. show,
  51. onConfirm,
  52. onHide,
  53. }: DuplicateAppModalProps) => {
  54. const { t } = useTranslation()
  55. const isCreate = !data
  56. const originalServerUrl = data?.server_url
  57. const originalServerID = data?.server_identifier
  58. const [url, setUrl] = React.useState(data?.server_url || '')
  59. const [name, setName] = React.useState(data?.name || '')
  60. const [appIcon, setAppIcon] = useState<AppIconSelection>(getIcon(data))
  61. const [showAppIconPicker, setShowAppIconPicker] = useState(false)
  62. const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
  63. const [isFetchingIcon, setIsFetchingIcon] = useState(false)
  64. const appIconRef = useRef<HTMLDivElement>(null)
  65. const isHovering = useHover(appIconRef)
  66. const isValidUrl = (string: string) => {
  67. try {
  68. const urlPattern = /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?/i
  69. return urlPattern.test(string)
  70. }
  71. catch (e) {
  72. return false
  73. }
  74. }
  75. const isValidServerID = (str: string) => {
  76. return /^[a-z0-9_-]{1,24}$/.test(str)
  77. }
  78. const handleBlur = async (url: string) => {
  79. if (data)
  80. return
  81. if (!isValidUrl(url))
  82. return
  83. const domain = getDomain(url)
  84. const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
  85. setIsFetchingIcon(true)
  86. try {
  87. const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
  88. setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
  89. }
  90. catch (e) {
  91. console.error('Failed to fetch remote icon:', e)
  92. Toast.notify({ type: 'warning', message: 'Failed to fetch remote icon' })
  93. }
  94. finally {
  95. setIsFetchingIcon(false)
  96. }
  97. }
  98. const submit = async () => {
  99. if (!isValidUrl(url)) {
  100. Toast.notify({ type: 'error', message: 'invalid server url' })
  101. return
  102. }
  103. if (!isValidServerID(serverIdentifier.trim())) {
  104. Toast.notify({ type: 'error', message: 'invalid server identifier' })
  105. return
  106. }
  107. await onConfirm({
  108. server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
  109. name,
  110. icon_type: appIcon.type,
  111. icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
  112. icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
  113. server_identifier: serverIdentifier.trim(),
  114. })
  115. if(isCreate)
  116. onHide()
  117. }
  118. return (
  119. <>
  120. <Modal
  121. isShow={show}
  122. onClose={noop}
  123. className={cn('relative !max-w-[520px]', 'p-6')}
  124. >
  125. <div className='absolute right-5 top-5 z-10 cursor-pointer p-1.5' onClick={onHide}>
  126. <RiCloseLine className='h-5 w-5 text-text-tertiary' />
  127. </div>
  128. <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>
  129. <div className='space-y-5 py-3'>
  130. <div>
  131. <div className='mb-1 flex h-6 items-center'>
  132. <span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverUrl')}</span>
  133. </div>
  134. <Input
  135. value={url}
  136. onChange={e => setUrl(e.target.value)}
  137. onBlur={e => handleBlur(e.target.value.trim())}
  138. placeholder={t('tools.mcp.modal.serverUrlPlaceholder')}
  139. />
  140. {originalServerUrl && originalServerUrl !== url && (
  141. <div className='mt-1 flex h-5 items-center'>
  142. <span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverUrlWarning')}</span>
  143. </div>
  144. )}
  145. </div>
  146. <div className='flex space-x-3'>
  147. <div className='grow pb-1'>
  148. <div className='mb-1 flex h-6 items-center'>
  149. <span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.name')}</span>
  150. </div>
  151. <Input
  152. value={name}
  153. onChange={e => setName(e.target.value)}
  154. placeholder={t('tools.mcp.modal.namePlaceholder')}
  155. />
  156. </div>
  157. <div className='pt-2' ref={appIconRef}>
  158. <AppIcon
  159. iconType={appIcon.type}
  160. icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
  161. background={appIcon.type === 'emoji' ? appIcon.background : undefined}
  162. imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
  163. size='xxl'
  164. className='relative cursor-pointer rounded-2xl'
  165. coverElement={
  166. isHovering
  167. ? (<div className='absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt'>
  168. <RiEditLine className='size-6 text-text-primary-on-surface' />
  169. </div>) : null
  170. }
  171. onClick={() => { setShowAppIconPicker(true) }}
  172. />
  173. </div>
  174. </div>
  175. <div>
  176. <div className='flex h-6 items-center'>
  177. <span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.serverIdentifier')}</span>
  178. </div>
  179. <div className='body-xs-regular mb-1 text-text-tertiary'>{t('tools.mcp.modal.serverIdentifierTip')}</div>
  180. <Input
  181. value={serverIdentifier}
  182. onChange={e => setServerIdentifier(e.target.value)}
  183. placeholder={t('tools.mcp.modal.serverIdentifierPlaceholder')}
  184. />
  185. {originalServerID && originalServerID !== serverIdentifier && (
  186. <div className='mt-1 flex h-5 items-center'>
  187. <span className='body-xs-regular text-text-warning'>{t('tools.mcp.modal.serverIdentifierWarning')}</span>
  188. </div>
  189. )}
  190. </div>
  191. </div>
  192. <div className='flex flex-row-reverse pt-5'>
  193. <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>
  194. <Button onClick={onHide}>{t('tools.mcp.modal.cancel')}</Button>
  195. </div>
  196. </Modal>
  197. {showAppIconPicker && <AppIconPicker
  198. onSelect={(payload) => {
  199. setAppIcon(payload)
  200. setShowAppIconPicker(false)
  201. }}
  202. onClose={() => {
  203. setAppIcon(getIcon(data))
  204. setShowAppIconPicker(false)
  205. }}
  206. />}
  207. </>
  208. )
  209. }
  210. export default MCPModal