fix: in plugin config panel appselector can not list all apps() (#21457)

This commit is contained in:
thief
2025-06-25 11:14:46 +08:00
committed by GitHub
parent 8a20134a5b
commit 257809bb30
2 changed files with 171 additions and 31 deletions

View File

@@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useState } from 'react'
import React, { useCallback, useEffect, useRef } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -14,9 +13,9 @@ import type {
import Input from '@/app/components/base/input'
import AppIcon from '@/app/components/base/app-icon'
import type { App } from '@/types/app'
import { useTranslation } from 'react-i18next'
type Props = {
appList: App[]
scope: string
disabled: boolean
trigger: React.ReactNode
@@ -25,11 +24,16 @@ type Props = {
isShow: boolean
onShowChange: (isShow: boolean) => void
onSelect: (app: App) => void
apps: App[]
isLoading: boolean
hasMore: boolean
onLoadMore: () => void
searchText: string
onSearchChange: (text: string) => void
}
const AppPicker: FC<Props> = ({
scope,
appList,
disabled,
trigger,
placement = 'right-start',
@@ -37,19 +41,81 @@ const AppPicker: FC<Props> = ({
isShow,
onShowChange,
onSelect,
apps,
isLoading,
hasMore,
onLoadMore,
searchText,
onSearchChange,
}) => {
const [searchText, setSearchText] = useState('')
const filteredAppList = useMemo(() => {
return (appList || [])
.filter(app => app.name.toLowerCase().includes(searchText.toLowerCase()))
.filter(app => (app.mode !== 'advanced-chat' && app.mode !== 'workflow') || !!app.workflow)
.filter(app => scope === 'all'
|| (scope === 'completion' && app.mode === 'completion')
|| (scope === 'workflow' && app.mode === 'workflow')
|| (scope === 'chat' && app.mode === 'advanced-chat')
|| (scope === 'chat' && app.mode === 'agent-chat')
|| (scope === 'chat' && app.mode === 'chat'))
}, [appList, scope, searchText])
const { t } = useTranslation()
const observerTarget = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadingRef = useRef(false)
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
const target = entries[0]
if (!target.isIntersecting || loadingRef.current || !hasMore || isLoading) return
loadingRef.current = true
onLoadMore()
// Reset loading state
setTimeout(() => {
loadingRef.current = false
}, 500)
}, [hasMore, isLoading, onLoadMore])
useEffect(() => {
if (!isShow) {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
return
}
let mutationObserver: MutationObserver | null = null
const setupIntersectionObserver = () => {
if (!observerTarget.current) return
// Create new observer
observerRef.current = new IntersectionObserver(handleIntersection, {
root: null,
rootMargin: '100px',
threshold: 0.1,
})
observerRef.current.observe(observerTarget.current)
}
// Set up MutationObserver to watch DOM changes
mutationObserver = new MutationObserver((mutations) => {
if (observerTarget.current) {
setupIntersectionObserver()
mutationObserver?.disconnect()
}
})
// Watch body changes since Portal adds content to body
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
})
// If element exists, set up IntersectionObserver directly
if (observerTarget.current)
setupIntersectionObserver()
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
mutationObserver?.disconnect()
}
}, [isShow, handleIntersection])
const getAppType = (app: App) => {
switch (app.mode) {
case 'advanced-chat':
@@ -84,18 +150,18 @@ const AppPicker: FC<Props> = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className="relative min-h-20 w-[356px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className='p-2 pb-1'>
<Input
showLeftIcon
showClearIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
onChange={e => onSearchChange(e.target.value)}
onClear={() => onSearchChange('')}
/>
</div>
<div className='p-1'>
{filteredAppList.map(app => (
<div className='min-h-0 flex-1 overflow-y-auto p-1'>
{apps.map(app => (
<div
key={app.id}
className='flex cursor-pointer items-center gap-3 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
@@ -113,6 +179,13 @@ const AppPicker: FC<Props> = ({
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{getAppType(app)}</div>
</div>
))}
<div ref={observerTarget} className='h-4 w-full'>
{isLoading && (
<div className='flex justify-center py-2'>
<div className='text-sm text-gray-500'>{t('common.loading')}</div>
</div>
)}
</div>
</div>
</div>
</PortalToFollowElemContent>

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
@@ -10,12 +10,36 @@ import {
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
import { useAppFullList } from '@/service/use-apps'
import type { App } from '@/types/app'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import useSWRInfinite from 'swr/infinite'
import { fetchAppList } from '@/service/apps'
import type { AppListResponse } from '@/models/app'
const PAGE_SIZE = 20
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
searchText: string,
) => {
if (pageIndex === 0 || (previousPageData && previousPageData.has_more)) {
const params: any = {
url: 'apps',
params: {
page: pageIndex + 1,
limit: PAGE_SIZE,
name: searchText,
},
}
return params
}
return null
}
type Props = {
value?: {
@@ -34,6 +58,7 @@ type Props = {
}) => void
supportAddCustomTool?: boolean
}
const AppSelector: FC<Props> = ({
value,
scope,
@@ -44,18 +69,47 @@ const AppSelector: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [isShow, onShowChange] = useState(false)
const [searchText, setSearchText] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const { data, isLoading, setSize } = useSWRInfinite(
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, searchText),
fetchAppList,
{
revalidateFirstPage: true,
shouldRetryOnError: false,
dedupingInterval: 500,
errorRetryCount: 3,
},
)
const displayedApps = useMemo(() => {
if (!data) return []
return data.flatMap(({ data: apps }) => apps)
}, [data])
const hasMore = data?.at(-1)?.has_more ?? true
const handleLoadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return
setIsLoadingMore(true)
try {
await setSize((size: number) => size + 1)
}
finally {
// Add a small delay to ensure state updates are complete
setTimeout(() => {
setIsLoadingMore(false)
}, 300)
}
}, [isLoadingMore, hasMore, setSize])
const handleTriggerClick = () => {
if (disabled) return
onShowChange(true)
}
const { data: appList } = useAppFullList()
const currentAppInfo = useMemo(() => {
if (!appList?.data || !value)
return undefined
return appList.data.find(app => app.id === value.app_id)
}, [appList?.data, value])
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
const handleSelectApp = (app: App) => {
const clearValue = app.id !== value?.app_id
@@ -67,6 +121,7 @@ const AppSelector: FC<Props> = ({
onSelect(appValue)
setIsShowChooseApp(false)
}
const handleFormChange = (inputs: Record<string, any>) => {
const newFiles = inputs['#image#']
delete inputs['#image#']
@@ -88,6 +143,12 @@ const AppSelector: FC<Props> = ({
}
}, [value])
const currentAppInfo = useMemo(() => {
if (!displayedApps || !value)
return undefined
return displayedApps.find(app => app.id === value.app_id)
}, [displayedApps, value])
return (
<>
<PortalToFollowElem
@@ -121,9 +182,14 @@ const AppSelector: FC<Props> = ({
isShow={isShowChooseApp}
onShowChange={setIsShowChooseApp}
disabled={false}
appList={appList?.data || []}
onSelect={handleSelectApp}
scope={scope || 'all'}
apps={displayedApps}
isLoading={isLoading || isLoadingMore}
hasMore={hasMore}
onLoadMore={handleLoadMore}
searchText={searchText}
onSearchChange={setSearchText}
/>
</div>
{/* app inputs config panel */}
@@ -140,4 +206,5 @@ const AppSelector: FC<Props> = ({
</>
)
}
export default React.memo(AppSelector)