Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

index.tsx 5.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useCallback, useMemo, useState } from 'react'
  4. import { useTranslation } from 'react-i18next'
  5. import {
  6. PortalToFollowElem,
  7. PortalToFollowElemContent,
  8. PortalToFollowElemTrigger,
  9. } from '@/app/components/base/portal-to-follow-elem'
  10. import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
  11. import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
  12. import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
  13. import type { App } from '@/types/app'
  14. import type {
  15. OffsetOptions,
  16. Placement,
  17. } from '@floating-ui/react'
  18. import useSWRInfinite from 'swr/infinite'
  19. import { fetchAppList } from '@/service/apps'
  20. import type { AppListResponse } from '@/models/app'
  21. const PAGE_SIZE = 20
  22. const getKey = (
  23. pageIndex: number,
  24. previousPageData: AppListResponse,
  25. searchText: string,
  26. ) => {
  27. if (pageIndex === 0 || (previousPageData && previousPageData.has_more)) {
  28. const params: any = {
  29. url: 'apps',
  30. params: {
  31. page: pageIndex + 1,
  32. limit: PAGE_SIZE,
  33. name: searchText,
  34. },
  35. }
  36. return params
  37. }
  38. return null
  39. }
  40. type Props = {
  41. value?: {
  42. app_id: string
  43. inputs: Record<string, any>
  44. files?: any[]
  45. }
  46. scope?: string
  47. disabled?: boolean
  48. placement?: Placement
  49. offset?: OffsetOptions
  50. onSelect: (app: {
  51. app_id: string
  52. inputs: Record<string, any>
  53. files?: any[]
  54. }) => void
  55. supportAddCustomTool?: boolean
  56. }
  57. const AppSelector: FC<Props> = ({
  58. value,
  59. scope,
  60. disabled,
  61. placement = 'bottom',
  62. offset = 4,
  63. onSelect,
  64. }) => {
  65. const { t } = useTranslation()
  66. const [isShow, onShowChange] = useState(false)
  67. const [searchText, setSearchText] = useState('')
  68. const [isLoadingMore, setIsLoadingMore] = useState(false)
  69. const { data, isLoading, setSize } = useSWRInfinite(
  70. (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, searchText),
  71. fetchAppList,
  72. {
  73. revalidateFirstPage: true,
  74. shouldRetryOnError: false,
  75. dedupingInterval: 500,
  76. errorRetryCount: 3,
  77. },
  78. )
  79. const displayedApps = useMemo(() => {
  80. if (!data) return []
  81. return data.flatMap(({ data: apps }) => apps)
  82. }, [data])
  83. const hasMore = data?.at(-1)?.has_more ?? true
  84. const handleLoadMore = useCallback(async () => {
  85. if (isLoadingMore || !hasMore) return
  86. setIsLoadingMore(true)
  87. try {
  88. await setSize((size: number) => size + 1)
  89. }
  90. finally {
  91. // Add a small delay to ensure state updates are complete
  92. setTimeout(() => {
  93. setIsLoadingMore(false)
  94. }, 300)
  95. }
  96. }, [isLoadingMore, hasMore, setSize])
  97. const handleTriggerClick = () => {
  98. if (disabled) return
  99. onShowChange(true)
  100. }
  101. const [isShowChooseApp, setIsShowChooseApp] = useState(false)
  102. const handleSelectApp = (app: App) => {
  103. const clearValue = app.id !== value?.app_id
  104. const appValue = {
  105. app_id: app.id,
  106. inputs: clearValue ? {} : value?.inputs || {},
  107. files: clearValue ? [] : value?.files || [],
  108. }
  109. onSelect(appValue)
  110. setIsShowChooseApp(false)
  111. }
  112. const handleFormChange = (inputs: Record<string, any>) => {
  113. const newFiles = inputs['#image#']
  114. delete inputs['#image#']
  115. const newValue = {
  116. app_id: value?.app_id || '',
  117. inputs,
  118. files: newFiles ? [newFiles] : value?.files || [],
  119. }
  120. onSelect(newValue)
  121. }
  122. const formattedValue = useMemo(() => {
  123. return {
  124. app_id: value?.app_id || '',
  125. inputs: {
  126. ...value?.inputs,
  127. ...(value?.files?.length ? { '#image#': value.files[0] } : {}),
  128. },
  129. }
  130. }, [value])
  131. const currentAppInfo = useMemo(() => {
  132. if (!displayedApps || !value)
  133. return undefined
  134. return displayedApps.find(app => app.id === value.app_id)
  135. }, [displayedApps, value])
  136. return (
  137. <>
  138. <PortalToFollowElem
  139. placement={placement}
  140. offset={offset}
  141. open={isShow}
  142. onOpenChange={onShowChange}
  143. >
  144. <PortalToFollowElemTrigger
  145. className='w-full'
  146. onClick={handleTriggerClick}
  147. >
  148. <AppTrigger
  149. open={isShow}
  150. appDetail={currentAppInfo}
  151. />
  152. </PortalToFollowElemTrigger>
  153. <PortalToFollowElemContent className='z-[1000]'>
  154. <div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
  155. <div className='flex flex-col gap-1 px-4 py-3'>
  156. <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('app.appSelector.label')}</div>
  157. <AppPicker
  158. placement='bottom'
  159. offset={offset}
  160. trigger={
  161. <AppTrigger
  162. open={isShowChooseApp}
  163. appDetail={currentAppInfo}
  164. />
  165. }
  166. isShow={isShowChooseApp}
  167. onShowChange={setIsShowChooseApp}
  168. disabled={false}
  169. onSelect={handleSelectApp}
  170. scope={scope || 'all'}
  171. apps={displayedApps}
  172. isLoading={isLoading || isLoadingMore}
  173. hasMore={hasMore}
  174. onLoadMore={handleLoadMore}
  175. searchText={searchText}
  176. onSearchChange={setSearchText}
  177. />
  178. </div>
  179. {/* app inputs config panel */}
  180. {currentAppInfo && (
  181. <AppInputsPanel
  182. value={formattedValue}
  183. appDetail={currentAppInfo}
  184. onFormChange={handleFormChange}
  185. />
  186. )}
  187. </div>
  188. </PortalToFollowElemContent>
  189. </PortalToFollowElem>
  190. </>
  191. )
  192. }
  193. export default React.memo(AppSelector)