From 777bf9ea67c7bee18e2b183281d1253cde2120a7 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 20 Aug 2026 18:58:24 +0800 Subject: [PATCH 01/38] perf: paginate app dataset and skill lists --- package.json | 10 + .../global/openapi/core/app/common/api.ts | 8 +- packages/global/openapi/core/dataset/api.ts | 49 ++-- packages/service/core/ai/skill/manage/list.ts | 153 ++++++----- .../repository/resourcePermissionRepo.ts | 5 +- .../permission/resourcePermissionService.ts | 3 + .../permission/resourcePermissionRepo.test.ts | 32 ++- .../app/src/components/Select/AppSelect.tsx | 4 +- .../components/core/dataset/SelectModal.tsx | 4 +- .../Edit/ChatAgent/hooks/useSkillManager.tsx | 6 +- .../ToolSelector/hooks/useSkillSelectData.ts | 4 +- .../Flow/SelectAppModal.tsx | 4 +- .../src/pageComponents/chat/ChatHeader.tsx | 8 +- .../chat/ChatSetting/AppTree.tsx | 2 +- .../AddFavouriteAppModal.tsx | 4 +- .../HomepageSetting/AddQuickAppModal.tsx | 4 +- .../pageComponents/dashboard/agent/List.tsx | 165 ++++++------ .../dashboard/agent/context.tsx | 30 ++- .../dashboard/mcp/EditModal.tsx | 4 +- .../pageComponents/dashboard/skill/List.tsx | 177 ++++++------- .../dashboard/skill/context.tsx | 31 ++- .../src/pageComponents/dataset/list/List.tsx | 156 +++++------ .../pageComponents/dataset/list/context.tsx | 28 +- .../app/src/pages/api/core/ai/skill/list.ts | 4 +- projects/app/src/pages/api/core/app/list.ts | 246 +++++++++--------- .../app/src/pages/api/core/dataset/list.ts | 223 ++++++++-------- projects/app/src/web/core/app/api.ts | 13 + projects/app/src/web/core/app/api/tool.ts | 4 +- projects/app/src/web/core/dataset/api.ts | 16 +- .../app/src/web/core/dataset/store/dataset.ts | 4 +- projects/app/src/web/core/skill/api.ts | 15 +- .../app/test/api/core/ai/skill/list.test.ts | 26 ++ projects/app/test/api/core/app/list.test.ts | 43 ++- .../app/test/api/core/dataset/list.test.ts | 42 ++- scripts/test/light.mjs | 9 +- scripts/test/run.mjs | 5 +- 36 files changed, 873 insertions(+), 668 deletions(-) diff --git a/package.json b/package.json index 8b950733bb33..30d3bc1b00de 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,16 @@ "clean:unused:pro": "node --experimental-strip-types ./pro/scripts/cleanup-unused.ts", "clean:unused:pro:write": "node --experimental-strip-types ./pro/scripts/cleanup-unused.ts --write", "test": "node ./scripts/test/run.mjs", + "test:all": "pnpm test:workspace && pnpm test:vector", + "test:repo": "vitest run --config vitest.config.mts --coverage --passWithNoTests", + "test:workspace": "node ./scripts/test/withMongo.mjs pnpm exec turbo run test --filter=@fastgpt/app --filter=@fastgpt/admin --filter=@fastgpt/global --filter=@fastgpt/service --filter=@fastgpt/web", + "test:app": "turbo run test --filter=@fastgpt/app", + "test:admin": "turbo run test --filter=@fastgpt/admin", + "test:global": "turbo run test --filter=@fastgpt/global", + "test:service": "turbo run test --filter=@fastgpt/service", + "test:web": "turbo run test --filter=@fastgpt/web", + "test:service:integration": "turbo run test:integration --filter=@fastgpt/service", + "test:service:integration:sandbox": "pnpm --dir packages/service test:integration:sandbox", "generate:i18n-loaders": "node scripts/generate-i18n-resource-loaders.mjs", "check:i18n-loaders": "node scripts/check-i18n-resource-loaders.mjs", "build:sdks": "pnpm -r --filter @fastgpt-sdk/storage --filter @fastgpt-sdk/otel --filter @fastgpt-sdk/sandbox-adapter build" diff --git a/packages/global/openapi/core/app/common/api.ts b/packages/global/openapi/core/app/common/api.ts index b83b263bc08b..256ac5461067 100644 --- a/packages/global/openapi/core/app/common/api.ts +++ b/packages/global/openapi/core/app/common/api.ts @@ -20,7 +20,8 @@ import { OpenAPIStoreNodeItemTypeSchema } from '../../workflow/node'; import { StoreEdgeItemTypeSchema } from '../../../../core/workflow/type/edge'; -import { BoolSchema, NumSchema } from '../../../../common/zod'; +import { BoolSchema, IntSchema, NumSchema } from '../../../../common/zod'; +import { PaginationResponseSchema, PaginationSchema } from '../../../api'; import { migrateWorkflowToCurrent } from '../../../../core/workflow/migration'; import z from 'zod'; @@ -299,6 +300,7 @@ export const ListAppBodySchema = z description: '按创建者筛选。缺省不过滤;空数组表示无选中创建者,返回空列表' }) }) + .extend(PaginationSchema.shape) .meta({ example: { parentId: '68ad85a7463006c963799a05', @@ -332,8 +334,8 @@ export const AppListItemSchema = z description: '应用列表项' }) as z.ZodType; -export const ListAppResponseSchema = z.array(AppListItemSchema).meta({ - description: '应用列表' +export const ListAppResponseSchema = PaginationResponseSchema(AppListItemSchema).meta({ + description: '应用列表(分页)' }); export type ListAppResponseType = z.infer; diff --git a/packages/global/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts index 1a60ed4e954c..42090a48fca3 100644 --- a/packages/global/openapi/core/dataset/api.ts +++ b/packages/global/openapi/core/dataset/api.ts @@ -15,6 +15,7 @@ import { CollaboratorUpdateListSchema, ShowUsernameQuerySchema } from '../../../support/permission/collaborator.schema'; +import { PaginationResponseSchema, PaginationSchema } from '../../api'; /* ============================================================================ * API: 创建知识库 @@ -186,34 +187,36 @@ export type GetDatasetDetailResponse = z.infer; // 出参复用 DatasetListItemSchema -export const GetDatasetListResponseSchema = z.array(DatasetListItemSchema); +export const GetDatasetListResponseSchema = PaginationResponseSchema(DatasetListItemSchema); export type GetDatasetListResponse = z.infer; /* ============================================================================ diff --git a/packages/service/core/ai/skill/manage/list.ts b/packages/service/core/ai/skill/manage/list.ts index 2fa7cf4ee16f..cfc0e51687cf 100644 --- a/packages/service/core/ai/skill/manage/list.ts +++ b/packages/service/core/ai/skill/manage/list.ts @@ -3,8 +3,14 @@ import { MongoApp } from '../../../app/schema'; import { AppResourceRefsSkillIdsPath, buildAppSkillRefMongoQuery } from '../../../app/resourceRefs'; import { MongoAgentSkills } from '../model/schema'; import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller'; -import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; -import { getResourcePermissionsByTeam } from '../../../../support/permission/resourcePermissionService'; +import { + PerResourceTypeEnum, + ReadPermissionVal +} from '@fastgpt/global/support/permission/constant'; +import { + findResourceKeysByCollaboratorsPermission, + getResourcePermissionsByResourceIds +} from '../../../../support/permission/resourcePermissionService'; import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils'; import { replaceRegChars } from '@fastgpt/global/common/string/tools'; import { getGroupsByTmbId } from '../../../../support/permission/memberGroup/controllers'; @@ -72,61 +78,25 @@ export const listReadableAgentSkills = async ({ const selectedSkillIds = skillIds?.filter(Boolean) ?? []; const isSkillIdsQuery = selectedSkillIds.length > 0; - const [roleList, myGroupMap, myOrgSet] = await Promise.all([ - getResourcePermissionsByTeam({ - resourceType: PerResourceTypeEnum.agentSkill, - teamId - }), - getGroupsByTmbId({ - tmbId, - teamId - }).then((item) => { - const map = new Map(); - item.forEach((item) => { - map.set(String(item._id), 1); - }); - return map; - }), - getOrgIdSetWithParentByTmbId({ - teamId, - tmbId - }) + const [groups, orgSet] = await Promise.all([ + getGroupsByTmbId({ tmbId, teamId }), + getOrgIdSetWithParentByTmbId({ teamId, tmbId }) ]); - - const myRoles = roleList.filter( - (item) => - String(item.tmbId) === String(tmbId) || - myGroupMap.has(String(item.groupId)) || - myOrgSet.has(String(item.orgId)) - ); - const myRoleResourceIds = Array.from( - new Map(myRoles.map((item) => [String(item.resourceId), item.resourceId])).values() - ); - const roleCountByResourceId = new Map(); - roleList.forEach((item) => { - const resourceId = String(item.resourceId); - roleCountByResourceId.set(resourceId, (roleCountByResourceId.get(resourceId) ?? 0) + 1); - }); - - const myTmbRoleByResourceId = new Map(); - const myGroupOrgRoleListByResourceId = new Map>(); - myRoles.forEach((item) => { - const resourceId = String(item.resourceId); - if (item.tmbId) { - myTmbRoleByResourceId.set(resourceId, item.permission); - return; - } - - if (item.groupId || item.orgId) { - const permissionList = myGroupOrgRoleListByResourceId.get(resourceId) ?? []; - permissionList.push(item.permission); - myGroupOrgRoleListByResourceId.set(resourceId, permissionList); - } - }); - const myGroupOrgRoleByResourceId = new Map>(); - myGroupOrgRoleListByResourceId.forEach((permissionList, resourceId) => { - myGroupOrgRoleByResourceId.set(resourceId, sumPer(...permissionList)); - }); + const groupIds = groups.map((item) => String(item._id)); + const orgIds = Array.from(orgSet).map(String); + const readableResourceIds = + teamPer.isOwner || source === 'store' + ? [] + : await findResourceKeysByCollaboratorsPermission({ + resourceType: PerResourceTypeEnum.agentSkill, + teamId, + tmbId, + groupIds, + orgIds, + permission: ReadPermissionVal, + matchLogic: 'or', + personalPermissionPriority: true + }); const findSkillQuery = (() => { const sourceQuery = (() => { @@ -162,8 +132,8 @@ export const listReadableAgentSkills = async ({ : { $or: [{ teamId }, { source: AgentSkillSourceEnum.system }] }; - const idList = { _id: { $in: myRoleResourceIds } }; - const readPermissionQuery = teamPer.isOwner || source === 'store' ? {} : idList; + const readPermissionQuery = + teamPer.isOwner || source === 'store' ? {} : { _id: { $in: readableResourceIds } }; return mergeMongoAndQuery(baseQuery, scopeQuery, readPermissionQuery, { _id: { $in: selectedSkillIds }, @@ -172,8 +142,8 @@ export const listReadableAgentSkills = async ({ } // 普通列表查询同时按当前目录和资源自身 ACL 过滤。 - const idList = { _id: { $in: myRoleResourceIds } }; - const skillPerQuery = teamPer.isOwner || source === 'store' ? {} : idList; + const skillPerQuery = + teamPer.isOwner || source === 'store' ? {} : { _id: { $in: readableResourceIds } }; const teamIdQuery = source === 'store' ? {} : { teamId }; if (searchKey) { @@ -188,28 +158,59 @@ export const listReadableAgentSkills = async ({ ); })(); - const mySkills = await MongoAgentSkills.find(findSkillQuery) - .sort(appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc]) - .lean(); + const paged = page !== undefined && pageSize !== undefined; + const skip = paged ? (page! - 1) * pageSize! : 0; + const skillQuery = MongoAgentSkills.find(findSkillQuery) + .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) + .skip(skip); + if (paged) skillQuery.limit(pageSize!); + const [mySkills, dbTotal] = await Promise.all([ + skillQuery.lean(), + paged ? MongoAgentSkills.countDocuments(findSkillQuery) : Promise.resolve(undefined) + ]); + + const pageRoleList = await getResourcePermissionsByResourceIds({ + resourceType: PerResourceTypeEnum.agentSkill, + teamId, + resourceIds: mySkills.map((skill) => String(skill._id)) + }); + const pageRoleListMap = new Map(); + pageRoleList.forEach((item) => { + const resourceId = String(item.resourceId); + const list = pageRoleListMap.get(resourceId) ?? []; + list.push(item); + pageRoleListMap.set(resourceId, list); + }); const formatSkills = mySkills .map((skill) => { const { Per, privateSkill } = (() => { - const getPer = (skillId: string) => { - const tmbRole = myTmbRoleByResourceId.get(skillId); - const groupAndOrgRole = myGroupOrgRoleByResourceId.get(skillId); + const resourceClbs = pageRoleListMap.get(String(skill._id)) ?? []; + const getPer = () => { + if (skill.source === AgentSkillSourceEnum.system) { + return new SkillPermission({ role: 0b100 }); + } + const tmbRole = resourceClbs.find( + (item) => String(item.tmbId) === String(tmbId) + )?.permission; + const groupAndOrgRole = sumPer( + ...resourceClbs + .filter( + (item) => + (item.groupId && groupIds.includes(String(item.groupId))) || + (item.orgId && orgIds.includes(String(item.orgId))) + ) + .map((item) => item.permission) + ); return new SkillPermission({ role: tmbRole ?? groupAndOrgRole, isOwner: String(skill.tmbId) === String(tmbId) || teamPer.isOwner }); }; - const getClbCount = (skillId: string) => { - return roleCountByResourceId.get(skillId) ?? 0; - }; return { - Per: getPer(String(skill._id)), - privateSkill: getClbCount(String(skill._id)) <= 1 + Per: getPer(), + privateSkill: resourceClbs.length <= 1 }; })(); @@ -237,14 +238,8 @@ export const listReadableAgentSkills = async ({ }) .filter((skill) => skill.permission.hasReadPer); - const total = formatSkills.length; - const pagedSkills = (() => { - if (page && pageSize) { - const skip = (page - 1) * pageSize; - return formatSkills.slice(skip, skip + pageSize); - } - return formatSkills; - })(); + const total = dbTotal ?? formatSkills.length; + const pagedSkills = formatSkills; const nonFolderSkills = withAppCount !== false ? pagedSkills.filter((s) => s.type !== AgentSkillTypeEnum.folder) : []; diff --git a/packages/service/support/permission/repository/resourcePermissionRepo.ts b/packages/service/support/permission/repository/resourcePermissionRepo.ts index 5becae4d74ac..2c53f80df99c 100644 --- a/packages/service/support/permission/repository/resourcePermissionRepo.ts +++ b/packages/service/support/permission/repository/resourcePermissionRepo.ts @@ -12,6 +12,7 @@ import type { } from '@fastgpt/global/support/permission/collaborator'; import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; import type { ResourcePermissionType } from '@fastgpt/global/support/permission/type'; +import type { RolePerMapType } from '@fastgpt/global/support/permission/type'; import { MongoResourcePermission } from '../schema'; import { pickCollaboratorIdFields } from '../utils'; @@ -71,6 +72,7 @@ type FindResourceKeysByCollaboratorsPermissionProps = { permission: PermissionValueType; matchLogic: ResourcePermissionMatchLogic; personalPermissionPriority: boolean; + rolePerMap?: RolePerMapType; }; const withSession = (session?: ClientSession) => (session ? { session } : undefined); @@ -196,7 +198,8 @@ export const resourcePermissionRepo = { orgIds, permission, matchLogic, - personalPermissionPriority + personalPermissionPriority, + rolePerMap = CommonRolePerMap }: FindResourceKeysByCollaboratorsPermissionProps) => { if (permission === OwnerPermissionVal) { throw new Error('Owner permission must be checked through owner authorization'); diff --git a/packages/service/support/permission/resourcePermissionService.ts b/packages/service/support/permission/resourcePermissionService.ts index e5e13a58913a..9f5ae8143d00 100644 --- a/packages/service/support/permission/resourcePermissionService.ts +++ b/packages/service/support/permission/resourcePermissionService.ts @@ -28,6 +28,9 @@ type ResourceModel = Model; /** 读取团队内某类资源的完整 ACL,供列表和运行时工具复用。 */ export const getResourcePermissionsByTeam = resourcePermissionRepo.findByTeam; +/** 查询指定资源的 ACL,用于只格式化分页结果中的当前页资源。 */ +export const getResourcePermissionsByResourceIds = resourcePermissionRepo.findByResourceIds; + /** 查询成员拥有指定有效权限的资源标识,支持 resourceId 和 resourceName 两类资源。 */ export const findResourceKeysByCollaboratorsPermission = resourcePermissionRepo.findResourceKeysByCollaboratorsPermission; diff --git a/packages/service/test/support/permission/resourcePermissionRepo.test.ts b/packages/service/test/support/permission/resourcePermissionRepo.test.ts index 1efa267c8475..4e3859614653 100644 --- a/packages/service/test/support/permission/resourcePermissionRepo.test.ts +++ b/packages/service/test/support/permission/resourcePermissionRepo.test.ts @@ -5,7 +5,10 @@ import { ReadPermissionVal, WritePermissionVal } from '@fastgpt/global/support/permission/constant'; -import { AppReadChatLogRoleVal } from '@fastgpt/global/support/permission/app/constant'; +import { + AppRolePerMap, + AppReadChatLogRoleVal +} from '@fastgpt/global/support/permission/app/constant'; import { Types } from '@fastgpt/service/common/mongo'; import { resourcePermissionRepo } from '@fastgpt/service/support/permission/repository/resourcePermissionRepo'; import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; @@ -689,4 +692,31 @@ describe('resourcePermissionRepo mutation helpers', () => { expect(activeRow).toMatchObject({ permission: WritePermissionVal }); expect(String(activeRow?.resourceId)).toBe(String(firstResourceId)); }); + + it('supports resource-specific role maps when querying readable resources', async () => { + const resourceId = objectId(); + const tmbId = objectId(); + + await MongoResourcePermission.create({ + teamId, + tmbId, + resourceType: PerResourceTypeEnum.app, + resourceId, + permission: AppReadChatLogRoleVal + }); + + await expect( + resourcePermissionRepo.findResourceKeysByCollaboratorsPermission({ + teamId: String(teamId), + resourceType: PerResourceTypeEnum.app, + tmbId: String(tmbId), + groupIds: [], + orgIds: [], + permission: ReadPermissionVal, + matchLogic: 'or', + personalPermissionPriority: true, + rolePerMap: AppRolePerMap + }) + ).resolves.toEqual([String(resourceId)]); + }); }); diff --git a/projects/app/src/components/Select/AppSelect.tsx b/projects/app/src/components/Select/AppSelect.tsx index 1f075e7d369e..8c45e1227191 100644 --- a/projects/app/src/components/Select/AppSelect.tsx +++ b/projects/app/src/components/Select/AppSelect.tsx @@ -8,7 +8,7 @@ import type { GetResourceFolderListProps, GetResourceListItemResponse } from '@fastgpt/global/common/parentFolder/type'; -import { getMyApps } from '@/web/core/app/api'; +import { getAllApps } from '@/web/core/app/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import Avatar from '@fastgpt/web/components/common/Avatar'; @@ -17,7 +17,7 @@ const AppSelect = ({ value, onSelect }: { value: string; onSelect: (id: string) const { t } = useTranslation(); const getAppList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { - return getMyApps({ + return getAllApps({ parentId, type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] }).then((res) => diff --git a/projects/app/src/components/core/dataset/SelectModal.tsx b/projects/app/src/components/core/dataset/SelectModal.tsx index 10e75f514a71..aed4d00e018e 100644 --- a/projects/app/src/components/core/dataset/SelectModal.tsx +++ b/projects/app/src/components/core/dataset/SelectModal.tsx @@ -1,4 +1,4 @@ -import { getDatasets, getDatasetPaths } from '@/web/core/dataset/api'; +import { getAllDatasets, getDatasetPaths } from '@/web/core/dataset/api'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import React, { type Dispatch, useState } from 'react'; import { useTranslation } from 'next-i18next'; @@ -77,7 +77,7 @@ export function useDatasetSelect() { } = useRequest( async () => { const result = await Promise.all([ - getDatasets({ parentId, searchKey }), + getAllDatasets({ parentId, searchKey }), // Only get paths when not searching searchKey.trim() ? Promise.resolve([]) diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx index 9aaae0d17e5c..a827f4bbc7a8 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx @@ -35,7 +35,7 @@ import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/age import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools'; import type { SkillClickResult } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin'; -import { getSkillList } from '@/web/core/skill/api'; +import { getAllSkillList } from '@/web/core/skill/api'; import { AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import { inheritToolInputConfig } from '../../FormComponent/ToolSelector/utils'; @@ -260,7 +260,7 @@ export const useSkillManager = ({ async () => { if (!onAddAgentSkill) return []; - const { list } = await getSkillList({ + const list = await getAllSkillList({ source: 'mine', parentId: '', withAppCount: false @@ -274,7 +274,7 @@ export const useSkillManager = ({ const onFolderLoadAgentSkills = useCallback( async (folderId: string) => { - const { list } = await getSkillList({ + const list = await getAllSkillList({ source: 'mine', parentId: folderId, withAppCount: false diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts index 3d5200986302..5c93bff8a158 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { getSkillList } from '@/web/core/skill/api'; +import { getAllSkillList } from '@/web/core/skill/api'; import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; @@ -25,7 +25,7 @@ export const useSkillSelectData = () => { refreshAsync: refreshSkillList } = useRequest( async () => { - const { list } = await getSkillList({ + const list = await getAllSkillList({ source: 'mine', parentId, searchKey: searchKey || undefined, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx index 2d6aad89fad7..0eadcbd2d0d4 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx @@ -8,7 +8,7 @@ import { type GetResourceFolderListProps, type GetResourceListItemResponse } from '@fastgpt/global/common/parentFolder/type'; -import { getMyApps } from '@/web/core/app/api'; +import { getAllApps } from '@/web/core/app/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; const SelectAppModal = ({ @@ -27,7 +27,7 @@ const SelectAppModal = ({ const getAppList = useCallback( async ({ parentId }: GetResourceFolderListProps) => { - return getMyApps({ + return getAllApps({ parentId, type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] }).then((res) => diff --git a/projects/app/src/pageComponents/chat/ChatHeader.tsx b/projects/app/src/pageComponents/chat/ChatHeader.tsx index 90a2887f894a..ff7430ac5731 100644 --- a/projects/app/src/pageComponents/chat/ChatHeader.tsx +++ b/projects/app/src/pageComponents/chat/ChatHeader.tsx @@ -18,7 +18,7 @@ import { type GetResourceFolderListProps, type GetResourceListItemResponse } from '@fastgpt/global/common/parentFolder/type'; -import { getMyApps } from '@/web/core/app/api'; +import { getAllApps } from '@/web/core/app/api'; import SelectOneResource from '@/components/common/folder/SelectOneResource'; import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover'; @@ -127,7 +127,7 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app const [currentTab, setCurrentTab] = useState(TabEnum.recently); const getAppList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { - return getMyApps({ parentId }).then((res) => + return getAllApps({ parentId }).then((res) => res.map((item) => ({ id: item._id, name: item.name, @@ -221,9 +221,7 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app })} > - - {item.name} - + {item.name} ))} diff --git a/projects/app/src/pageComponents/chat/ChatSetting/AppTree.tsx b/projects/app/src/pageComponents/chat/ChatSetting/AppTree.tsx index cd295c8e1f62..70d508bc6c3b 100644 --- a/projects/app/src/pageComponents/chat/ChatSetting/AppTree.tsx +++ b/projects/app/src/pageComponents/chat/ChatSetting/AppTree.tsx @@ -5,7 +5,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { useMemo, useState, useCallback } from 'react'; -export type App = Awaited>[number]; +export type App = Awaited>['list'][number]; export const TreeItem = ({ app, diff --git a/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx b/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx index 9e67d4bed3cc..9527b0a86376 100644 --- a/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx +++ b/projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import MyIcon from '@fastgpt/web/components/common/Icon'; -import { getMyApps } from '@/web/core/app/api'; +import { getAllApps } from '@/web/core/app/api'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { getFavouriteApps, updateFavouriteApps } from '@/web/core/chat/api'; import type { App } from '@/pageComponents/chat/ChatSetting/AppTree'; @@ -36,7 +36,7 @@ const AddFavouriteAppModal = ({ onClose, onRefresh }: Props) => { const { data: appData = { apps: [], paths: [] }, loading: isFetching } = useRequest( async () => { const [apps, paths] = await Promise.all([ - getMyApps({ + getAllApps({ parentId, searchKey: searchAppNameValue, type: [ diff --git a/projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx b/projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx index f04496403f73..11ee29b4b2d7 100644 --- a/projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx +++ b/projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx @@ -1,4 +1,4 @@ -import { getMyApps, getAppBasicInfoByIds } from '@/web/core/app/api'; +import { getAllApps, getAppBasicInfoByIds } from '@/web/core/app/api'; import { Box, Button, Grid, GridItem, HStack, VStack, Flex, Checkbox } from '@chakra-ui/react'; import MyModal from '@fastgpt/web/components/common/MyModal'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; @@ -47,7 +47,7 @@ const AddQuickAppModal = ({ selectedIds, onClose, onConfirm }: Props) => { } = useRequest( async () => { const [apps, paths] = await Promise.all([ - getMyApps({ + getAllApps({ parentId, searchKey: searchAppName, type: [ diff --git a/projects/app/src/pageComponents/dashboard/agent/List.tsx b/projects/app/src/pageComponents/dashboard/agent/List.tsx index 97ceadb58aa2..af04dae7e8d0 100644 --- a/projects/app/src/pageComponents/dashboard/agent/List.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/List.tsx @@ -72,6 +72,7 @@ const List = () => { const { myApps, + ScrollData, appType, loadMyApps, isFetchingApps, @@ -436,88 +437,90 @@ const List = () => { if (myApps.length === 0 && isFetchingApps) return null; return ( - <> - {myApps.length === 0 && !folderDetail ? ( - hasActiveFilter ? ( - - ) : isPc && hasCreatePer ? ( - + + <> + {myApps.length === 0 && !folderDetail ? ( + hasActiveFilter ? ( + + ) : isPc && hasCreatePer ? ( + + ) : ( + + {hasCreatePer ? : } + + ) ) : ( - - {hasCreatePer ? : } - - ) - ) : ( - <> - - {hasCreatePer ? : } - {renderVirtualGridItems(renderAppCard)} - - - )} - - - {!!editedApp && ( - { - setEditedApp(undefined); - }} - onEdit={({ id, ...data }) => onUpdateApp(id, data)} - /> - )} - {!!editPerApp && ( - - changeOwner({ - appId: editPerApp._id, - ownerId: tmbId - }).then(() => loadMyApps()) - })} - refetchResource={loadMyApps} - hasParent={Boolean(parentId)} - resumeInheritPermission={onResumeInheritPermission} - isInheritPermission={editPerApp.inheritPermission} - avatar={editPerApp.avatar} - name={editPerApp.name} - managePer={{ - defaultRole: ReadRoleVal, - permission: editPerApp.permission, - onGetCollaboratorList: () => getCollaboratorList(editPerApp._id), - roleList: AppRoleList, - onUpdateCollaborators: (props) => - postUpdateAppCollaborators({ - ...props, - appId: editPerApp._id - }), - refreshDeps: [editPerApp.inheritPermission] - }} - onClose={() => setEditPerAppId(undefined)} - /> - )} - - + <> + + {hasCreatePer ? : } + {renderVirtualGridItems(renderAppCard)} + + + )} + + + {!!editedApp && ( + { + setEditedApp(undefined); + }} + onEdit={({ id, ...data }) => onUpdateApp(id, data)} + /> + )} + {!!editPerApp && ( + + changeOwner({ + appId: editPerApp._id, + ownerId: tmbId + }).then(() => loadMyApps()) + })} + refetchResource={loadMyApps} + hasParent={Boolean(parentId)} + resumeInheritPermission={onResumeInheritPermission} + isInheritPermission={editPerApp.inheritPermission} + avatar={editPerApp.avatar} + name={editPerApp.name} + managePer={{ + defaultRole: ReadRoleVal, + permission: editPerApp.permission, + onGetCollaboratorList: () => getCollaboratorList(editPerApp._id), + roleList: AppRoleList, + onUpdateCollaborators: (props) => + postUpdateAppCollaborators({ + ...props, + appId: editPerApp._id + }), + refreshDeps: [editPerApp.inheritPermission] + }} + onClose={() => setEditPerAppId(undefined)} + /> + )} + + + ); }; diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index bbbc6f052025..224b8e3ed37a 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -2,7 +2,8 @@ import React, { type ReactNode, useCallback, useEffect, useState } from 'react'; import { createContext } from 'use-context-selector'; import { useRouter } from 'next/router'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { getAppDetailById, getMyApps, putAppById } from '@/web/core/app/api'; +import { useScrollPagination, type ScrollListType } from '@fastgpt/web/hooks/useScrollPagination'; +import { getAllApps, getAppDetailById, getMyApps, putAppById } from '@/web/core/app/api'; import { type AppDetailType, type AppListItemType } from '@fastgpt/global/core/app/type'; import { getAppFolderPath } from '@/web/core/app/api/app'; import { @@ -36,8 +37,9 @@ type AppListContextType = { parentId?: string | null; appType: AppTypeEnum | 'all'; myApps: AppListItemType[]; - loadMyApps: () => Promise; + loadMyApps: () => Promise; isFetchingApps: boolean; + ScrollData: ScrollListType; folderDetail: AppDetailType | undefined | null; paths: ParentTreePathItemType[]; onUpdateApp: (id: string, data: UpdateAppBodyType) => Promise; @@ -54,10 +56,11 @@ type AppListContextType = { export const AppListContext = createContext({ parentId: undefined, myApps: [], - loadMyApps: async function (): Promise { + loadMyApps: async function (): Promise { throw new Error('Function not implemented.'); }, isFetchingApps: false, + ScrollData: () => <>, folderDetail: undefined, paths: [], onUpdateApp: function (id: string, data: UpdateAppBodyType): Promise { @@ -129,11 +132,12 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { applyToolbarFilters && feConfigs.isPlus ? toListTmbIds(listFilters.creator) : undefined; const { - data = [], - runAsync: loadMyApps, - loading: isFetchingApps - } = useRequest( - async () => { + data: myApps = [], + isLoading: isFetchingApps, + ScrollData, + fetchData + } = useScrollPagination( + async ({ offset = 0, pageSize = 50 }) => { const formatType = resolveDashboardAppListTypes({ pathname: router.pathname, type: appType @@ -143,6 +147,8 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { parentId, type: formatType, searchKey, + offset, + pageSize, ...(sort ? { sort } : {}), ...(tmbIds !== undefined ? { tmbIds } : {}) }); @@ -161,7 +167,6 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { return fetchApps(selectedMembers.list.map((item) => String(item.tmbId))); }, { - manual: false, refreshDeps: [ searchKey, parentId, @@ -172,10 +177,12 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { feConfigs.isPlus, isPc ], + pageSize: 50, throttleWait: 500, refreshOnWindowFocus: true } ); + const loadMyApps = useCallback(() => fetchData({ init: true }), [fetchData]); const { data: paths = [], runAsync: refetchPaths } = useRequest( () => getAppFolderPath({ sourceId: parentId, type: 'current' }), @@ -216,7 +223,7 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { ({ parentId }: GetResourceFolderListProps) => { const folderType = isAgentPage ? AppTypeEnum.folder : AppTypeEnum.toolFolder; - return getMyApps({ + return getAllApps({ parentId, type: folderType }).then((res) => @@ -238,8 +245,9 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { const contextValue: AppListContextType = { parentId, appType, - myApps: data, + myApps, loadMyApps, + ScrollData, refetchFolderDetail, isFetchingApps, folderDetail, diff --git a/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx b/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx index e2a510915c52..008e4248631f 100644 --- a/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx +++ b/projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx @@ -30,7 +30,7 @@ import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; import Path from '@/components/common/folder/Path'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { getAppBasicInfoByIds, getMyApps } from '@/web/core/app/api'; +import { getAllApps, getAppBasicInfoByIds } from '@/web/core/app/api'; import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import { getAppFolderPath } from '@/web/core/app/api/app'; import { AppFolderTypeList } from '@fastgpt/global/core/app/constants'; @@ -95,7 +95,7 @@ const SelectAppModal = ({ const { data: apps = [], loading: loadingApps } = useRequest( () => - getMyApps({ + getAllApps({ searchKey, parentId }), diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index d64ec86d2429..ed160fc9e1f0 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -195,14 +195,15 @@ const List = ({ const router = useRouter(); const { isPc } = useSystem(); - const { skills, refreshSkills, isFetchingSkills, searchKey, folderDetail, listFilters } = + const { skills, refreshSkills, isFetchingSkills, searchKey, folderDetail, listFilters, ScrollData } = useContextSelector(SkillListContext, (v) => ({ skills: v.skills, refreshSkills: v.refreshSkills, isFetchingSkills: v.isFetchingSkills, searchKey: v.searchKey, folderDetail: v.folderDetail, - listFilters: v.listFilters + listFilters: v.listFilters, + ScrollData: v.ScrollData })); const [editedSkill, setEditedSkill] = useState(); @@ -524,98 +525,100 @@ const List = ({ if (skills.length === 0 && isFetchingSkills) return null; return ( - <> - {skills.length === 0 && !folderDetail ? ( - searchKey ? ( - - ) : onClickCreate && onClickImport ? ( - + + <> + {skills.length === 0 && !folderDetail ? ( + searchKey ? ( + + ) : onClickCreate && onClickImport ? ( + + ) : ( + + + + ) ) : ( - + {onClickCreate ? : } + {renderVirtualGridItems(renderSkillCard)} - ) - ) : ( - - {onClickCreate ? : } - {renderVirtualGridItems(renderSkillCard)} - - )} - - - {!!editedSkill && ( - { - setEditedSkill(undefined); - }} - onEdit={({ id, ...data }) => onUpdateSkill(id, data)} - /> - )} - {!!moveSkillId && ( - setMoveSkillId(undefined)} - onConfirm={onMoveSkill} - moveHint={t('skill:move_skill_hint')} - /> - )} - {!!selectedSkill && ( - - postChangeSkillOwner({ - skillId: selectedSkill._id, - ownerId: tmbId - }).then(() => refreshSkills()) - } - hasParent={!!selectedSkill.parentId} - refetchResource={refreshSkills} - isInheritPermission={selectedSkill.inheritPermission} - resumeInheritPermission={() => - resumeInheritPer(selectedSkill._id).then(() => refreshSkills()) - } - avatar={selectedSkill.avatar} - name={selectedSkill.name} - managePer={{ - defaultRole: ReadRoleVal, - permission: selectedSkill.permission, - onGetCollaboratorList: () => getSkillCollaboratorList(selectedSkill._id), - roleList: SkillRoleList, - onUpdateCollaborators: (props) => - postUpdateSkillCollaborators({ - ...props, - skillId: selectedSkill._id - }), - refreshDeps: [selectedSkill._id, selectedSkill.inheritPermission] - }} - onClose={() => setEditPerSkillId(undefined)} - /> - )} - + )} + + + {!!editedSkill && ( + { + setEditedSkill(undefined); + }} + onEdit={({ id, ...data }) => onUpdateSkill(id, data)} + /> + )} + {!!moveSkillId && ( + setMoveSkillId(undefined)} + onConfirm={onMoveSkill} + moveHint={t('skill:move_skill_hint')} + /> + )} + {!!selectedSkill && ( + + postChangeSkillOwner({ + skillId: selectedSkill._id, + ownerId: tmbId + }).then(() => refreshSkills()) + } + hasParent={!!selectedSkill.parentId} + refetchResource={refreshSkills} + isInheritPermission={selectedSkill.inheritPermission} + resumeInheritPermission={() => + resumeInheritPer(selectedSkill._id).then(() => refreshSkills()) + } + avatar={selectedSkill.avatar} + name={selectedSkill.name} + managePer={{ + defaultRole: ReadRoleVal, + permission: selectedSkill.permission, + onGetCollaboratorList: () => getSkillCollaboratorList(selectedSkill._id), + roleList: SkillRoleList, + onUpdateCollaborators: (props) => + postUpdateSkillCollaborators({ + ...props, + skillId: selectedSkill._id + }), + refreshDeps: [selectedSkill._id, selectedSkill.inheritPermission] + }} + onClose={() => setEditPerSkillId(undefined)} + /> + )} + + ); }; diff --git a/projects/app/src/pageComponents/dashboard/skill/context.tsx b/projects/app/src/pageComponents/dashboard/skill/context.tsx index 8db04390b427..00fd49afdd3d 100644 --- a/projects/app/src/pageComponents/dashboard/skill/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/context.tsx @@ -7,6 +7,7 @@ import React, { } from 'react'; import { createContext } from 'use-context-selector'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useScrollPagination, type ScrollListType } from '@fastgpt/web/hooks/useScrollPagination'; import { getSkillList, getSkillFolderPath, getSkillDetail } from '@/web/core/skill/api'; import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import type { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; @@ -38,6 +39,7 @@ type SkillListContextType = { skills: SkillListItemType[]; isFetchingSkills: boolean; refreshSkills: () => void; + ScrollData: ScrollListType; searchKey: string; setSearchKey: Dispatch>; parentId: string | null; @@ -55,6 +57,7 @@ export const SkillListContext = createContext({ refreshSkills: () => { throw new Error('Function not implemented.'); }, + ScrollData: () => <>, searchKey: '', setSearchKey: () => { throw new Error('Function not implemented.'); @@ -94,26 +97,29 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { applyToolbarFilters && feConfigs.isPlus ? toListTmbIds(listFilters.creator) : undefined; const { - data, - refresh: refreshSkills, - loading: isFetchingSkills - } = useRequest( - () => + data: skills = [], + isLoading: isFetchingSkills, + ScrollData, + fetchData + } = useScrollPagination( + ({ offset = 0, pageSize = 50 }) => getSkillList({ source: 'mine', searchKey, parentId, + page: Number(offset) / Number(pageSize) + 1, + pageSize, ...(applyToolbarFilters ? { sort: listFilters.sort } : {}), ...(tmbIds !== undefined ? { tmbIds } : {}) - }).then((res) => - res.list.map((item) => ({ + }).then((res) => ({ + list: res.list.map((item) => ({ ...item, createTime: new Date(item.createTime), updateTime: new Date(item.updateTime) - })) - ), + })), + total: res.total + })), { - manual: false, refreshDeps: [ searchKey, parentId, @@ -122,10 +128,12 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { feConfigs.isPlus, isPc ], + pageSize: 50, throttleWait: 500, refreshOnWindowFocus: false } ); + const refreshSkills = () => fetchData({ init: true }); // 加载面包屑路径(仅在文件夹内时请求) const { data: paths = [] } = useRequest( @@ -153,9 +161,10 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { ); const contextValue: SkillListContextType = { - skills: data || [], + skills, isFetchingSkills, refreshSkills, + ScrollData, searchKey, setSearchKey, parentId, diff --git a/projects/app/src/pageComponents/dataset/list/List.tsx b/projects/app/src/pageComponents/dataset/list/List.tsx index 0fc2412b5de8..25e147bbaf9e 100644 --- a/projects/app/src/pageComponents/dataset/list/List.tsx +++ b/projects/app/src/pageComponents/dataset/list/List.tsx @@ -50,6 +50,8 @@ function List() { onDelDataset, onUpdateDataset, myDatasets, + ScrollData, + isFetchingDatasets, folderDetail, searchKey, setSearchKey, @@ -395,84 +397,86 @@ function List() { }; return ( - <> - {formatDatasets.length > 0 && ( - - {renderVirtualGridItems(renderDatasetCard)} - - )} - {myDatasets.length === 0 && ( - - )} + + <> + {formatDatasets.length > 0 && ( + + {renderVirtualGridItems(renderDatasetCard)} + + )} + {myDatasets.length === 0 && ( + + )} - {editedDataset && ( - setEditedDataset(undefined)} - onEdit={async (data) => { - await onUpdateDataset({ - id: editedDataset.id, - name: data.name, - intro: data.intro, - avatar: data.avatar - }); - }} - /> - )} + {editedDataset && ( + setEditedDataset(undefined)} + onEdit={async (data) => { + await onUpdateDataset({ + id: editedDataset.id, + name: data.name, + intro: data.intro, + avatar: data.avatar + }); + }} + /> + )} - {!!editPerDataset && ( - - postChangeOwner({ - datasetId: editPerDataset._id, - ownerId: tmbId - }).then(() => loadMyDatasets()) - } - hasParent={!!parentId} - refetchResource={loadMyDatasets} - isInheritPermission={editPerDataset.inheritPermission} - resumeInheritPermission={() => - resumeInheritPer(editPerDataset._id).then(() => Promise.all([loadMyDatasets()])) - } - avatar={editPerDataset.avatar} - name={editPerDataset.name} - managePer={{ - defaultRole: ReadRoleVal, - permission: editPerDataset.permission, - onGetCollaboratorList: () => getCollaboratorList(editPerDataset._id), - roleList: DatasetRoleList, - onUpdateCollaborators: (props) => - postUpdateDatasetCollaborators({ - ...props, - datasetId: editPerDataset._id - }), - refreshDeps: [editPerDataset._id, editPerDataset.inheritPermission] - }} - onClose={() => setEditPerDatasetId(undefined)} - /> - )} - - - + {!!editPerDataset && ( + + postChangeOwner({ + datasetId: editPerDataset._id, + ownerId: tmbId + }).then(() => loadMyDatasets()) + } + hasParent={!!parentId} + refetchResource={loadMyDatasets} + isInheritPermission={editPerDataset.inheritPermission} + resumeInheritPermission={() => + resumeInheritPer(editPerDataset._id).then(() => Promise.all([loadMyDatasets()])) + } + avatar={editPerDataset.avatar} + name={editPerDataset.name} + managePer={{ + defaultRole: ReadRoleVal, + permission: editPerDataset.permission, + onGetCollaboratorList: () => getCollaboratorList(editPerDataset._id), + roleList: DatasetRoleList, + onUpdateCollaborators: (props) => + postUpdateDatasetCollaborators({ + ...props, + datasetId: editPerDataset._id + }), + refreshDeps: [editPerDataset._id, editPerDataset.inheritPermission] + }} + onClose={() => setEditPerDatasetId(undefined)} + /> + )} + + + + ); } diff --git a/projects/app/src/pageComponents/dataset/list/context.tsx b/projects/app/src/pageComponents/dataset/list/context.tsx index 88a47a423476..5af39ecdf61e 100644 --- a/projects/app/src/pageComponents/dataset/list/context.tsx +++ b/projects/app/src/pageComponents/dataset/list/context.tsx @@ -1,5 +1,6 @@ 'use client'; import { + getAllDatasets, getDatasetPaths, putDatasetById, getDatasets, @@ -16,6 +17,7 @@ import { useRouter } from 'next/router'; import React, { useCallback, useState } from 'react'; import { createContext } from 'use-context-selector'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useScrollPagination, type ScrollListType } from '@fastgpt/web/hooks/useScrollPagination'; import { type UpdateDatasetBody } from '@fastgpt/global/openapi/core/dataset/api'; import dynamic from 'next/dynamic'; import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; @@ -38,10 +40,11 @@ const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal')); export type DatasetContextType = { myDatasets: DatasetListItemType[]; - loadMyDatasets: () => Promise; + loadMyDatasets: () => Promise; refetchPaths: () => void; refetchFolderDetail: () => Promise; isFetchingDatasets: boolean; + ScrollData: ScrollListType; setMoveDatasetId: (id: string) => void; paths: ParentTreePathItemType[]; folderDetail?: DatasetItemType; @@ -57,6 +60,7 @@ export type DatasetContextType = { export const DatasetsContext = createContext({ isFetchingDatasets: false, + ScrollData: () => <>, setMoveDatasetId: () => {}, refetchPaths: () => {}, paths: [], @@ -64,7 +68,7 @@ export const DatasetsContext = createContext({ editedDataset: {} as any, setEditedDataset: () => {}, onDelDataset: () => Promise.resolve(), - loadMyDatasets: function (): Promise { + loadMyDatasets: function (): Promise { throw new Error('Function not implemented.'); }, refetchFolderDetail: function (): Promise { @@ -116,19 +120,21 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { const { data: myDatasets = [], - runAsync: loadMyDatasets, - loading: isFetchingDatasets - } = useRequest( - () => + fetchData, + ScrollData, + isLoading: isFetchingDatasets + } = useScrollPagination( + ({ offset = 0, pageSize = 50 }) => getDatasets({ searchKey, parentId, + offset, + pageSize, ...(listType ? { type: listType } : {}), ...(applyToolbarFilters ? { sort: listFilters.sort } : {}), ...(tmbIds !== undefined ? { tmbIds } : {}) }), { - manual: false, refreshDeps: [ parentId, searchKey, @@ -138,9 +144,12 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { feConfigs.isPlus, isPc ], - throttleWait: 300 + pageSize: 50, + throttleWait: 300, + refreshOnWindowFocus: false } ); + const loadMyDatasets = useCallback(() => fetchData({ init: true }), [fetchData]); const { data: folderDetail, runAsync: refetchFolderDetail } = useRequest( () => (parentId ? getDatasetById(parentId) : Promise.resolve(undefined)), @@ -178,7 +187,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { const getDatasetFolderList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { return ( - await getDatasets({ + await getAllDatasets({ parentId, type: DatasetTypeEnum.folder }) @@ -199,6 +208,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { const contextValue = { isFetchingDatasets, + ScrollData, setMoveDatasetId, paths, refetchPaths, diff --git a/projects/app/src/pages/api/core/ai/skill/list.ts b/projects/app/src/pages/api/core/ai/skill/list.ts index 3c918ff50911..eacacd4aaba5 100644 --- a/projects/app/src/pages/api/core/ai/skill/list.ts +++ b/projects/app/src/pages/api/core/ai/skill/list.ts @@ -57,8 +57,8 @@ async function handler(req: ApiRequestProps) { category, type, skillIds: selectedSkillIds, - page, - pageSize, + page: isSkillIdsQuery ? undefined : (page ?? 1), + pageSize: isSkillIdsQuery ? undefined : (pageSize ?? 50), withAppCount, sort, tmbIds diff --git a/projects/app/src/pages/api/core/app/list.ts b/projects/app/src/pages/api/core/app/list.ts index 69dc190248e8..bf04c34cb5b0 100644 --- a/projects/app/src/pages/api/core/app/list.ts +++ b/projects/app/src/pages/api/core/app/list.ts @@ -12,6 +12,7 @@ import { appListSortMongoMap, AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { AppRolePerMap } from '@fastgpt/global/support/permission/app/constant'; import { authApp } from '@fastgpt/service/support/permission/app/auth'; import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; import { replaceRegChars } from '@fastgpt/global/common/string/tools'; @@ -20,7 +21,10 @@ import { getOrgIdSetWithParentByTmbId } from '@fastgpt/service/support/permissio import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { isPrivateResourceByCollaborators, sumPer } from '@fastgpt/global/support/permission/utils'; -import { getResourcePermissionsByTeam } from '@fastgpt/service/support/permission/resourcePermissionService'; +import { + findResourceKeysByCollaboratorsPermission, + getResourcePermissionsByResourceIds +} from '@fastgpt/service/support/permission/resourcePermissionService'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { ListAppBodySchema, @@ -30,18 +34,17 @@ import { } from '@fastgpt/global/openapi/core/app/common/api'; import { Types } from '@fastgpt/service/common/mongo'; -/* - 获取 APP 列表权限 - 1. 校验 folder 权限和获取 team 权限(owner 单独处理) - 2. 获取 team 下所有 app 权限。获取我的所有组。并计算出我所有的app权限。 - 3. 过滤我有权限的 app,并按 parentId 过滤目录层级 - 4. 根据过滤条件获取 app 列表 - 5. 遍历搜索出来的 app,并赋予资源自身 ACL 对应的权限 - 6. 再根据 read 权限进行一次过滤。 -*/ - async function handler(req: ApiRequestProps): Promise { - const { parentId, type, searchKey, sort, tmbIds } = parseApiInput({ + const { + parentId, + type, + searchKey, + sort, + tmbIds, + pageNum = 1, + pageSize = 50, + offset + } = parseApiInput({ req, bodySchema: ListAppBodySchema }).body; @@ -69,57 +72,34 @@ async function handler(req: ApiRequestProps): Promise { + if (teamPer.isOwner) return { readableResourceIds: [], groupIds: [], orgIds: [] }; + + const [groups, orgSet] = await Promise.all([ + getGroupsByTmbId({ tmbId, teamId }), + getOrgIdSetWithParentByTmbId({ teamId, tmbId }) + ]); + const groupIds = groups.map((item) => String(item._id)); + const orgIds = Array.from(orgSet).map(String); + const readableResourceIds = await findResourceKeysByCollaboratorsPermission({ resourceType: PerResourceTypeEnum.app, - teamId - }), - getGroupsByTmbId({ - tmbId, - teamId - }).then((item) => { - const map = new Map(); - item.forEach((item) => { - map.set(String(item._id), 1); - }); - return map; - }), - getOrgIdSetWithParentByTmbId({ teamId, - tmbId - }) - ]); - const roleListMap = new Map(); - roleList.forEach((item) => { - const resourceId = String(item.resourceId); - const list = roleListMap.get(resourceId) ?? []; - list.push(item); - roleListMap.set(resourceId, list); - }); - // Get my permissions - const myPerList = roleList.filter( - (item) => - String(item.tmbId) === String(tmbId) || - myGroupMap.has(String(item.groupId)) || - myOrgSet.has(String(item.orgId)) - ); - const myPerListMap = new Map(); - myPerList.forEach((item) => { - const resourceId = String(item.resourceId); - const list = myPerListMap.get(resourceId) ?? []; - list.push(item); - myPerListMap.set(resourceId, list); - }); + tmbId, + groupIds, + orgIds, + permission: ReadPermissionVal, + matchLogic: 'or', + personalPermissionPriority: true, + rolePerMap: AppRolePerMap + }); + + return { readableResourceIds, groupIds, orgIds }; + })(); const findAppsQuery = (() => { - // Filter apps by permission, if not owner, only get apps that I have permission to access - const idList = { _id: { $in: myPerList.map((item) => item.resourceId) } }; - const appPerQuery = teamPer.isOwner ? {} : idList; - const searchMatch = searchKey ? { $or: [ @@ -141,96 +121,106 @@ async function handler(req: ApiRequestProps): Promise { - if (searchKey) return 50; - return; - })(); - const myApps = await MongoApp.find( - { ...findAppsQuery, deleteTime: null }, - '_id parentId avatar type name intro tmbId createTime updateTime pluginData inheritPermission modules.flowNodeType', - { - limit: limit - } - ) - .sort(appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc]) - .lean(); + const skip = offset ?? (pageNum - 1) * pageSize; + const [myApps, total] = await Promise.all([ + MongoApp.find( + findAppsQuery, + '_id parentId avatar type name intro tmbId createTime updateTime pluginData inheritPermission modules' + ) + .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) + .skip(skip) + .limit(pageSize) + .lean(), + MongoApp.countDocuments(findAppsQuery) + ]); - // Add app permission and filter apps by read permission - const formatApps = myApps - .map((app) => { - const { Per, privateApp } = (() => { - const getPer = (appId: string) => { - // 权限已按资源预分组,避免列表中每个 App 都重新扫描团队全部 ACL。 - const appPerList = myPerListMap.get(appId) ?? []; - const tmbRole = appPerList.find((item) => !!item.tmbId)?.permission; - const groupAndOrgRole = sumPer( - ...appPerList - .filter((item) => !!item.groupId || !!item.orgId) - .map((item) => item.permission) - ); - - return new AppPermission({ - role: tmbRole ?? groupAndOrgRole, - isOwner: String(app.tmbId) === String(tmbId) || teamPer.isOwner - }); - }; - - const resourceClbs = roleListMap.get(String(app._id)) ?? []; - - return { - Per: getPer(String(app._id)), - privateApp: isPrivateResourceByCollaborators({ - resourceClbs - }) - }; - })(); + const pageResourceIds = myApps.map((app) => String(app._id)); + const pageRoleList = await getResourcePermissionsByResourceIds({ + resourceType: PerResourceTypeEnum.app, + teamId, + resourceIds: pageResourceIds + }); + const roleListMap = new Map(); + pageRoleList.forEach((item) => { + const resourceId = String(item.resourceId); + const list = roleListMap.get(resourceId) ?? []; + list.push(item); + roleListMap.set(resourceId, list); + }); - const { modules, ...rest } = app; - const hasInteractiveNode = modules?.some((item) => - [FlowNodeTypeEnum.formInput, FlowNodeTypeEnum.userSelect].includes(item.flowNodeType) - ); + // Add app permission and filter apps by read permission + const formatApps = myApps.map((app) => { + const { Per, privateApp } = (() => { + const resourceClbs = roleListMap.get(String(app._id)) ?? []; + const getPer = () => { + const tmbRole = resourceClbs.find( + (item) => String(item.tmbId) === String(tmbId) + )?.permission; + const groupAndOrgRole = sumPer( + ...resourceClbs + .filter( + (item) => + (item.groupId && groupIds.includes(String(item.groupId))) || + (item.orgId && orgIds.includes(String(item.orgId))) + ) + .map((item) => item.permission) + ); + + return new AppPermission({ + role: tmbRole ?? groupAndOrgRole, + isOwner: String(app.tmbId) === String(tmbId) || teamPer.isOwner + }); + }; return { - ...rest, - createTime: app.createTime ?? new Types.ObjectId(String(app._id)).getTimestamp(), - avatar: app.avatar ?? '', - intro: app.intro ?? '', - parentId: app.parentId, - permission: Per, - private: privateApp, - hasInteractiveNode + Per: getPer(), + privateApp: isPrivateResourceByCollaborators({ + resourceClbs + }) }; - }) - .filter((app) => app.permission.hasReadPer); + })(); + + const { modules, ...rest } = app; + const hasInteractiveNode = modules?.some((item) => + [FlowNodeTypeEnum.formInput, FlowNodeTypeEnum.userSelect].includes(item.flowNodeType) + ); + + return { + ...rest, + createTime: app.createTime ?? new Types.ObjectId(String(app._id)).getTimestamp(), + parentId: app.parentId, + permission: Per, + private: privateApp, + hasInteractiveNode + }; + }); const list = await addSourceMember({ list: formatApps }); - return ListAppResponseSchema.parse(list); + return ListAppResponseSchema.parse({ list, total }); } export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/dataset/list.ts b/projects/app/src/pages/api/core/dataset/list.ts index b1c35172d3ee..d22dc37edbfc 100644 --- a/projects/app/src/pages/api/core/dataset/list.ts +++ b/projects/app/src/pages/api/core/dataset/list.ts @@ -16,18 +16,30 @@ import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { desensitizeSystemModel } from '@fastgpt/service/core/ai/config/utils'; import { findDatasetEmbeddingModel } from '@fastgpt/service/core/dataset/model'; import { isPrivateResourceByCollaborators, sumPer } from '@fastgpt/global/support/permission/utils'; -import { getResourcePermissionsByTeam } from '@fastgpt/service/support/permission/resourcePermissionService'; +import { + findResourceKeysByCollaboratorsPermission, + getResourcePermissionsByResourceIds +} from '@fastgpt/service/support/permission/resourcePermissionService'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { GetDatasetListBodySchema, GetDatasetListResponseSchema, type GetDatasetListResponse } from '@fastgpt/global/openapi/core/dataset/api'; -import { AppListSortEnum } from '@fastgpt/global/core/app/constants'; +import { AppListSortEnum, appListSortMongoMap } from '@fastgpt/global/core/app/constants'; import { Types } from '@fastgpt/service/common/mongo'; async function handler(req: ApiRequestProps): Promise { - const { parentId, type, searchKey, sort, tmbIds } = parseApiInput({ + const { + parentId, + type, + searchKey, + sort, + tmbIds, + pageNum = 1, + pageSize = 50, + offset + } = parseApiInput({ req, bodySchema: GetDatasetListBodySchema }).body; @@ -54,49 +66,33 @@ async function handler(req: ApiRequestProps): Promise { ]); if (Array.isArray(tmbIds) && tmbIds.length === 0) { - return []; + return GetDatasetListResponseSchema.parse({ list: [], total: 0 }); } - // Get team all app permissions - const [roleList, myGroupMap, myOrgSet] = await Promise.all([ - getResourcePermissionsByTeam({ + const { readableResourceIds, groupIds, orgIds } = await (async () => { + if (teamPer.isOwner) return { readableResourceIds: [], groupIds: [], orgIds: [] }; + + const [groups, orgSet] = await Promise.all([ + getGroupsByTmbId({ tmbId, teamId }), + getOrgIdSetWithParentByTmbId({ teamId, tmbId }) + ]); + const groupIds = groups.map((item) => String(item._id)); + const orgIds = Array.from(orgSet).map(String); + const readableResourceIds = await findResourceKeysByCollaboratorsPermission({ resourceType: PerResourceTypeEnum.dataset, - teamId - }), - getGroupsByTmbId({ - tmbId, - teamId - }).then((item) => { - const map = new Map(); - item.forEach((item) => { - map.set(String(item._id), 1); - }); - return map; - }), - getOrgIdSetWithParentByTmbId({ teamId, - tmbId - }) - ]); - const roleListMap = new Map(); - roleList.forEach((item) => { - const resourceId = String(item.resourceId); - const list = roleListMap.get(resourceId) ?? []; - list.push(item); - roleListMap.set(resourceId, list); - }); - const myRoles = roleList.filter( - (item) => - String(item.tmbId) === String(tmbId) || - myGroupMap.has(String(item.groupId)) || - myOrgSet.has(String(item.orgId)) - ); + tmbId, + groupIds, + orgIds, + permission: ReadPermissionVal, + matchLogic: 'or', + personalPermissionPriority: true + }); - const findDatasetQuery = (() => { - // Filter apps by permission, if not owner, only get apps that I have permission to access - const idList = { _id: { $in: myRoles.map((item) => item.resourceId) } }; - const datasetPerQuery = teamPer.isOwner ? {} : idList; + return { readableResourceIds, groupIds, orgIds }; + })(); + const findDatasetQuery = (() => { const searchMatch = searchKey ? { $or: [ @@ -105,93 +101,104 @@ async function handler(req: ApiRequestProps): Promise { ] } : {}; - const listFilter = { + const permissionQuery = teamPer.isOwner ? {} : { _id: { $in: readableResourceIds } }; + const baseQuery = { + teamId, + deleteTime: null, + ...permissionQuery, ...(type ? (Array.isArray(type) ? { type: { $in: type } } : { type }) : {}), ...(tmbIds ? { tmbId: { $in: tmbIds } } : {}) }; if (searchKey) { - const data = { - ...datasetPerQuery, - teamId, - deleteTime: null, // 搜索时也要过滤已删除数据 - ...listFilter, - ...searchMatch + return { + $and: [baseQuery, searchMatch] }; - // @ts-ignore - delete data.parentId; - return data; } return { - ...datasetPerQuery, - teamId, - deleteTime: null, // 关键:只返回未删除的数据 - ...listFilter, + ...baseQuery, ...parseParentIdInMongo(parentId) }; })(); - const datasetSort = ((): Record => { - if (sort === AppListSortEnum.createTimeAsc) return { createTime: 1 }; - if (sort === AppListSortEnum.createTimeDesc) return { createTime: -1 }; - return { updateTime: -1 }; - })(); - - const myDatasets = await MongoDataset.find(findDatasetQuery).sort(datasetSort).lean(); - - const formatDatasets = myDatasets - .map((dataset) => { - const vectorModel = findDatasetEmbeddingModel(dataset); - const { Per, privateDataset } = (() => { - const getPer = (datasetId: string) => { - const tmbRole = myRoles.find( - (item) => String(item.resourceId) === datasetId && !!item.tmbId - )?.permission; - const groupAndOrgRole = sumPer( - ...myRoles - .filter( - (item) => String(item.resourceId) === datasetId && (!!item.groupId || !!item.orgId) - ) - .map((item) => item.permission) - ); - return new DatasetPermission({ - role: tmbRole ?? groupAndOrgRole, - isOwner: String(dataset.tmbId) === String(tmbId) || teamPer.isOwner - }); - }; - const resourceClbs = roleListMap.get(String(dataset._id)) ?? []; + const skip = offset ?? (pageNum - 1) * pageSize; + const [myDatasets, total] = await Promise.all([ + MongoDataset.find(findDatasetQuery) + .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) + .skip(skip) + .limit(pageSize) + .lean(), + MongoDataset.countDocuments(findDatasetQuery) + ]); - return { - Per: getPer(String(dataset._id)), - privateDataset: isPrivateResourceByCollaborators({ - resourceClbs - }) - }; - })(); + const pageRoleList = await getResourcePermissionsByResourceIds({ + resourceType: PerResourceTypeEnum.dataset, + teamId, + resourceIds: myDatasets.map((dataset) => String(dataset._id)) + }); + const roleListMap = new Map(); + pageRoleList.forEach((item) => { + const resourceId = String(item.resourceId); + const list = roleListMap.get(resourceId) ?? []; + list.push(item); + roleListMap.set(resourceId, list); + }); + const formatDatasets = myDatasets.map((dataset) => { + const { Per, privateDataset } = (() => { + const resourceClbs = roleListMap.get(String(dataset._id)) ?? []; + const getPer = () => { + const tmbRole = resourceClbs.find( + (item) => String(item.tmbId) === String(tmbId) + )?.permission; + const groupAndOrgRole = sumPer( + ...resourceClbs + .filter( + (item) => + (item.groupId && groupIds.includes(String(item.groupId))) || + (item.orgId && orgIds.includes(String(item.orgId))) + ) + .map((item) => item.permission) + ); + return new DatasetPermission({ + role: tmbRole ?? groupAndOrgRole, + isOwner: String(dataset.tmbId) === String(tmbId) || teamPer.isOwner + }); + }; return { - _id: dataset._id, - avatar: dataset.avatar, - name: dataset.name, - intro: dataset.intro, - type: dataset.type, - vectorModel: vectorModel ? desensitizeSystemModel(vectorModel) : undefined, - inheritPermission: dataset.inheritPermission, - tmbId: dataset.tmbId, - createTime: dataset.createTime ?? new Types.ObjectId(String(dataset._id)).getTimestamp(), - updateTime: dataset.updateTime, - permission: Per, - private: privateDataset + Per: getPer(), + privateDataset: isPrivateResourceByCollaborators({ + resourceClbs + }) }; - }) - .filter((app) => app.permission.hasReadPer); + })(); + + return { + _id: dataset._id, + avatar: dataset.avatar, + name: dataset.name, + intro: dataset.intro, + type: dataset.type, + vectorModel: (() => { + const vectorModel = findDatasetEmbeddingModel(dataset); + return vectorModel ? desensitizeSystemModel(vectorModel) : undefined; + })(), + inheritPermission: dataset.inheritPermission, + tmbId: dataset.tmbId, + createTime: dataset.createTime ?? new Types.ObjectId(String(dataset._id)).getTimestamp(), + updateTime: dataset.updateTime, + permission: Per, + private: privateDataset + }; + }); - return GetDatasetListResponseSchema.parse( - await addSourceMember({ + return { + list: await addSourceMember({ list: formatDatasets - }) - ); + }), + total + }; } export default NextAPI(handler); diff --git a/projects/app/src/web/core/app/api.ts b/projects/app/src/web/core/app/api.ts index 5c940ac7f25f..fe7a266773e4 100644 --- a/projects/app/src/web/core/app/api.ts +++ b/projects/app/src/web/core/app/api.ts @@ -32,6 +32,19 @@ export const getMyApps = (data?: ListAppBodyType) => maxQuantity: 1 }); +/** 获取当前筛选条件下的全部应用,供需要跨页遍历资源的选择器使用。 */ +export const getAllApps = async ( + data?: Omit +) => { + const list: ListAppResponseType['list'] = []; + const pageSize = 50; + for (let offset = 0; ; offset += pageSize) { + const res = await getMyApps({ ...data, offset, pageSize }); + list.push(...res.list); + if (list.length >= res.total || res.list.length < pageSize) return list; + } +}; + /** * 创建一个应用 */ diff --git a/projects/app/src/web/core/app/api/tool.ts b/projects/app/src/web/core/app/api/tool.ts index 43185dd0ec78..005c49e36ebb 100644 --- a/projects/app/src/web/core/app/api/tool.ts +++ b/projects/app/src/web/core/app/api/tool.ts @@ -3,7 +3,7 @@ import type { FlowNodeTemplateType, NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; -import { getMyApps } from '../api'; +import { getAllApps } from '../api'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; @@ -52,7 +52,7 @@ export const getTeamAppTemplates = async (data?: { })); } } - return getMyApps(data ? listQuery : undefined).then((res) => + return getAllApps(data ? listQuery : undefined).then((res) => res.map((app) => ({ tmbId: app.tmbId, id: app._id, diff --git a/projects/app/src/web/core/dataset/api.ts b/projects/app/src/web/core/dataset/api.ts index 3e8388e9b7d5..081c5f93a5ce 100644 --- a/projects/app/src/web/core/dataset/api.ts +++ b/projects/app/src/web/core/dataset/api.ts @@ -14,6 +14,7 @@ import type { CreateDatasetWithFilesBody, CreateDatasetWithFilesResponse, GetDatasetListBody, + GetDatasetListResponse, UpdateDatasetBody, CreateDatasetFolderBody, SearchDatasetTestBody, @@ -24,7 +25,20 @@ import type { /* ======================== dataset ======================= */ export const getDatasets = (data: GetDatasetListBody) => - POST(`/core/dataset/list`, data, { maxQuantity: 1 }); + POST(`/core/dataset/list`, data, { maxQuantity: 1 }); + +/** 获取当前筛选条件下的全部知识库,供需要跨页遍历资源的选择器使用。 */ +export const getAllDatasets = async ( + data: Omit = {} +) => { + const list: GetDatasetListResponse['list'] = []; + const pageSize = 50; + for (let offset = 0; ; offset += pageSize) { + const res = await getDatasets({ ...data, offset, pageSize }); + list.push(...res.list); + if (list.length >= res.total || res.list.length < pageSize) return list; + } +}; export const getDatasetsByAppIdAndDatasetIds = (data: { appId: string; datasetIdList: string[] }) => POST(`/core/dataset/listByAppIdAndDatasetIds`, data); diff --git a/projects/app/src/web/core/dataset/store/dataset.ts b/projects/app/src/web/core/dataset/store/dataset.ts index fa4ab8e407ba..fc6806fa7db4 100644 --- a/projects/app/src/web/core/dataset/store/dataset.ts +++ b/projects/app/src/web/core/dataset/store/dataset.ts @@ -1,6 +1,6 @@ import { create, devtools, persist, immer } from '@fastgpt/web/common/zustand'; import type { DatasetListItemType } from '@fastgpt/global/core/dataset/type'; -import { getDatasets } from '@/web/core/dataset/api'; +import { getAllDatasets } from '@/web/core/dataset/api'; type State = { myDatasets: DatasetListItemType[]; @@ -13,7 +13,7 @@ export const useDatasetStore = create()( immer((set, get) => ({ myDatasets: [], async loadMyDatasets(parentId = '') { - const res = await getDatasets({ parentId }); + const res = await getAllDatasets({ parentId }); set((state) => { state.myDatasets = res; }); diff --git a/projects/app/src/web/core/skill/api.ts b/projects/app/src/web/core/skill/api.ts index 3573f2656132..66c61170dec1 100644 --- a/projects/app/src/web/core/skill/api.ts +++ b/projects/app/src/web/core/skill/api.ts @@ -43,14 +43,25 @@ import type { export const getSkillList = (data: ListSkillsQuery) => POST('/core/ai/skill/list', data); +/** 获取当前筛选条件下的全部 Skill,供需要跨页遍历资源的选择器使用。 */ +export const getAllSkillList = async (data: Omit) => { + const list: ListSkillsResponse['list'] = []; + const pageSize = 50; + for (let page = 1; ; page += 1) { + const res = await getSkillList({ ...data, page, pageSize }); + list.push(...res.list); + if (list.length >= res.total || res.list.length < pageSize) return list; + } +}; + /** 获取 Skill 文件夹列表(用于移动弹窗) */ export const getSkillFolderList = ({ parentId }: GetResourceFolderListProps) => - getSkillList({ + getAllSkillList({ source: 'mine', type: AgentSkillTypeEnum.folder, parentId: parentId ?? null }).then((res) => - res.list + res .filter((item) => item.permission.hasWritePer) .map((item) => ({ id: item._id, name: item.name })) ); diff --git a/projects/app/test/api/core/ai/skill/list.test.ts b/projects/app/test/api/core/ai/skill/list.test.ts index 6be4bfbb4146..bfadd0b11312 100644 --- a/projects/app/test/api/core/ai/skill/list.test.ts +++ b/projects/app/test/api/core/ai/skill/list.test.ts @@ -124,6 +124,32 @@ describe('POST /api/core/ai/skill/list', () => { expect(empty.data).toEqual({ list: [], total: 0 }); }); + it('按 skillIds 查询时不会被默认分页截断', async () => { + const user = await getUser(`agent-skill-list-ids-${getNanoid(6)}`); + const skills = await MongoAgentSkills.create( + Array.from({ length: 51 }, (_, index) => ({ + name: `Selected Skill ${index + 1}`, + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + teamId: user.teamId, + tmbId: user.tmbId + })) + ); + + const res = await Call, ListSkillsResponse>(handler, { + auth: user, + body: { + source: 'mine', + skillIds: skills.map((skill) => String(skill._id)), + withAppCount: false + } + }); + + expect(res.code).toBe(200); + expect(res.data.total).toBe(51); + expect(res.data.list).toHaveLength(51); + }); + it('按 skillIds 查询时不受父目录过滤影响,并排除已删除 Skill', async () => { const user = await getUser(`agent-skill-list-${getNanoid(6)}`); diff --git a/projects/app/test/api/core/app/list.test.ts b/projects/app/test/api/core/app/list.test.ts index 027714f2b698..4cd85ce2c311 100644 --- a/projects/app/test/api/core/app/list.test.ts +++ b/projects/app/test/api/core/app/list.test.ts @@ -13,6 +13,8 @@ import { Types } from '@fastgpt/service/common/mongo'; import { MongoApp } from '@fastgpt/service/core/app/schema'; import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; import { getFakeUsers } from '@test/datas/users'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getUser } from '@test/datas/users'; import { Call } from '@test/utils/request'; import { describe, expect, it } from 'vitest'; @@ -50,13 +52,13 @@ describe('POST /api/core/app/list', () => { ); expect(response.code).toBe(200); - expect(response.data).toEqual( + expect(response.data.list).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'Interactive app', hasInteractiveNode: true }), expect.objectContaining({ name: 'Regular app', hasInteractiveNode: false }) ]) ); - expect(response.data.every((app) => !('modules' in app))).toBe(true); + expect(response.data.list.every((app) => !('modules' in app))).toBe(true); }); it('returns only apps covered by the current member resource permission group', async () => { @@ -103,8 +105,41 @@ describe('POST /api/core/app/list', () => { ); expect(response.code).toBe(200); - expect(response.data.map((app) => app.name)).toEqual(['Permitted app']); - expect(response.data[0]?.permission.hasReadPer).toBe(true); + expect(response.data.list.map((app) => app.name)).toEqual(['Permitted app']); + expect(response.data.list[0]?.permission.hasReadPer).toBe(true); + }); + + it('returns a stable paginated result for the current directory', async () => { + const user = await getUser(`app-list-${getNanoid(6)}`); + const updateTimes = [ + new Date('2024-01-03T00:00:00.000Z'), + new Date('2024-01-02T00:00:00.000Z'), + new Date('2024-01-01T00:00:00.000Z') + ]; + + await MongoApp.create( + updateTimes.map((updateTime, index) => ({ + name: `App ${index + 1}`, + type: AppTypeEnum.simple, + teamId: user.teamId, + tmbId: user.tmbId, + updateTime + })) + ); + + const res = await Call, ListAppResponseType>(handler, { + auth: user, + body: { + type: AppTypeEnum.simple, + pageNum: 2, + pageSize: 1 + } + }); + + expect(res.code).toBe(200); + expect(res.data.total).toBe(3); + expect(res.data.list).toHaveLength(1); + expect(res.data.list[0].name).toBe('App 2'); }); it('defaults to recently modified order and supports createTime sort', async () => { diff --git a/projects/app/test/api/core/dataset/list.test.ts b/projects/app/test/api/core/dataset/list.test.ts index 7e3307e9b16a..9dcd3fe7d817 100644 --- a/projects/app/test/api/core/dataset/list.test.ts +++ b/projects/app/test/api/core/dataset/list.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import handler from '@/pages/api/core/dataset/list'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { AppListSortEnum } from '@fastgpt/global/core/app/constants'; @@ -8,7 +9,6 @@ import type { GetDatasetListResponse } from '@fastgpt/global/openapi/core/dataset/api'; import { getUser } from '@test/datas/users'; -import { getNanoid } from '@fastgpt/global/common/string/tools'; import { Call } from '@test/utils/request'; import { Types } from '@fastgpt/service/common/mongo'; @@ -63,7 +63,7 @@ describe('POST /api/core/dataset/list', () => { } ); expect(filtered.code).toBe(200); - expect(filtered.data.map((item) => String(item._id))).toEqual([ + expect(filtered.data.list.map((item) => String(item._id))).toEqual([ String(olderDataset._id), String(newerDataset._id) ]); @@ -79,6 +79,42 @@ describe('POST /api/core/dataset/list', () => { body: { parentId: null, tmbIds: [] } } ); - expect(empty.data).toEqual([]); + expect(empty.data).toEqual({ list: [], total: 0 }); + }); + + it('returns a stable paginated result for the current directory', async () => { + const user = await getUser(`dataset-list-${getNanoid(6)}`); + const updateTimes = [ + new Date('2024-01-03T00:00:00.000Z'), + new Date('2024-01-02T00:00:00.000Z'), + new Date('2024-01-01T00:00:00.000Z') + ]; + + await MongoDataset.create( + updateTimes.map((updateTime, index) => ({ + name: `Dataset ${index + 1}`, + type: DatasetTypeEnum.dataset, + teamId: user.teamId, + tmbId: user.tmbId, + updateTime + })) + ); + + const res = await Call, GetDatasetListResponse>( + handler, + { + auth: user, + body: { + type: DatasetTypeEnum.dataset, + pageNum: 2, + pageSize: 1 + } + } + ); + + expect(res.code).toBe(200); + expect(res.data.total).toBe(3); + expect(res.data.list).toHaveLength(1); + expect(res.data.list[0].name).toBe('Dataset 2'); }); }); diff --git a/scripts/test/light.mjs b/scripts/test/light.mjs index 543978bc4628..3b00ecf0073c 100644 --- a/scripts/test/light.mjs +++ b/scripts/test/light.mjs @@ -30,9 +30,7 @@ export const getConfigNames = (testPath) => { return configNames; }; -/** - * 从测试路径向上查找最近的 Vitest 配置,确保测试在所属 workspace 中执行。 - */ +/** Find the nearest Vitest config so each test runs in its owning workspace. */ export const findVitestConfig = (testPath) => { let currentDirectory = statSync(testPath).isDirectory() ? testPath : dirname(testPath); const candidateConfigNames = getConfigNames(testPath); @@ -50,7 +48,7 @@ export const findVitestConfig = (testPath) => { throw new Error(`No Vitest config found for: ${relative(repositoryRoot, testPath)}`); }; -/** 按 Vitest 配置而不是目录分组,避免同一 workspace 的 unit/integration 配置相互覆盖。 */ +/** Group paths by config to avoid unit and integration runs overwriting each other. */ export const groupTestsByConfig = (testPaths) => { const configTests = new Map(); @@ -82,9 +80,6 @@ export const groupTestsByConfig = (testPaths) => { return [...configTests.values()]; }; -/** - * 顺序执行各 workspace 的局部测试,避免多个 Vitest/Mongo 实例争抢 Agent 资源。 - */ const runWorkspaceTests = ({ workspaceRoot, configPath, testPaths, vitestArgs }) => new Promise((resolvePromise, rejectPromise) => { const child = spawn( diff --git a/scripts/test/run.mjs b/scripts/test/run.mjs index be5f6bc5dcd9..5bcc07301068 100644 --- a/scripts/test/run.mjs +++ b/scripts/test/run.mjs @@ -47,9 +47,7 @@ const parseUnitScopes = (scopeValue) => { return [...new Set(expandedScopes)]; }; -/** - * 将测试模式、范围和路径解析为顺序执行计划,集中维护根目录测试入口的语义。 - */ +/** Resolve the root test mode and scope into an ordered execution plan. */ export const resolveTestPlan = ({ args = [], modeValue, scopeValue } = {}) => { if (args.length > 0) { if (modeValue || scopeValue) { @@ -95,7 +93,6 @@ export const resolveTestPlan = ({ args = [], modeValue, scopeValue } = {}) => { throw new Error('FASTGPT_TEST_MODE=all does not accept FASTGPT_TEST_SCOPE'); } - // all 保留原 test:all 的语义:workspace 单测完成后再跑 service 集成测试。 const unitScopes = parseUnitScopes(mode === 'all' ? 'workspace' : scopeValue); const selectedWorkspaces = unitScopes.filter((scope) => scope in workspaceFilters); const plan = []; From 665d1ef7929145aa3226397894cb7130d472127e Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Wed, 26 Aug 2026 18:36:31 +0800 Subject: [PATCH 02/38] fix(agent): prevent dashboard list overflow Co-locate scroll list typing and constrain the agent app list container to preserve pagination scrolling. --- packages/web/hooks/useScrollPagination.tsx | 11 +++++++++++ projects/app/src/pages/dashboard/agent/index.tsx | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/web/hooks/useScrollPagination.tsx b/packages/web/hooks/useScrollPagination.tsx index 69c0a5067068..367eb033fc5d 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -10,6 +10,17 @@ import type { PaginationType, PaginationResponseType } from '@fastgpt/global/ope const thresholdVal = 100; +export type ScrollListType = ({ + children, + ScrollContainerRef, + isLoading, + ...props +}: { + children: ReactNode; + ScrollContainerRef?: RefObject; + isLoading?: boolean; +} & BoxProps) => React.JSX.Element; + export function useScrollPagination< TParams extends PaginationType, TData extends PaginationResponseType diff --git a/projects/app/src/pages/dashboard/agent/index.tsx b/projects/app/src/pages/dashboard/agent/index.tsx index 8ea45bbf0120..e988f20352d8 100644 --- a/projects/app/src/pages/dashboard/agent/index.tsx +++ b/projects/app/src/pages/dashboard/agent/index.tsx @@ -114,7 +114,7 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { pr={folderDetail ? [3, 2] : [3, 6]} pl={6} pt={6} - overflowY={'auto'} + overflowY={'hidden'} overflowX={'hidden'} > {/* Only shown on pc root page */} @@ -197,7 +197,7 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + From 95f6d2769c77fec5d3a44dde82c4bc98fb298562 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Wed, 26 Aug 2026 18:40:38 +0800 Subject: [PATCH 03/38] fix(web): align pagination footer at bottom --- packages/web/hooks/useScrollPagination.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/hooks/useScrollPagination.tsx b/packages/web/hooks/useScrollPagination.tsx index 367eb033fc5d..380ff1699d69 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -183,6 +183,8 @@ export function useScrollPagination< ref={ref} h={'100%'} overflow={'auto'} + display={'flex'} + flexDirection={'column'} isLoading={isLoading || isLoadingProp} {...props} > @@ -194,7 +196,8 @@ export function useScrollPagination< {children} {scrollLoadType === 'bottom' && !isEmpty && (showNoMoreTip || !noMore) && ( Date: Thu, 27 Aug 2026 18:08:51 +0800 Subject: [PATCH 04/38] feat(resource-list): add responsive grid pagination loading skeletons --- packages/global/openapi/core/ai/skill/api.ts | 1 + packages/service/core/ai/skill/manage/list.ts | 5 +- .../web/hooks/useResponsiveGridPageSize.ts | 38 ++++++++++ packages/web/hooks/useScrollPagination.tsx | 41 ++++++----- packages/web/hooks/useVirtualGridList.tsx | 68 ++++++++++++++---- .../hooks/useResponsiveGridPageSize.test.ts | 22 ++++++ .../dashboard/ResourceCardSkeleton.tsx | 48 +++++++++++++ .../pageComponents/dashboard/agent/List.tsx | 43 +++++++++--- .../dashboard/agent/context.tsx | 24 +++++-- .../pageComponents/dashboard/skill/List.tsx | 70 ++++++++++++++----- .../dashboard/skill/context.tsx | 23 ++++-- .../src/pageComponents/dataset/list/List.tsx | 34 +++++++-- .../pageComponents/dataset/list/context.tsx | 16 ++++- .../app/src/pages/api/core/ai/skill/list.ts | 4 +- .../app/src/pages/dashboard/agent/index.tsx | 4 +- .../app/src/pages/dashboard/skill/index.tsx | 4 +- .../app/src/pages/dashboard/tool/index.tsx | 4 +- projects/app/src/pages/dataset/list/index.tsx | 10 +-- 18 files changed, 362 insertions(+), 97 deletions(-) create mode 100644 packages/web/hooks/useResponsiveGridPageSize.ts create mode 100644 packages/web/test/hooks/useResponsiveGridPageSize.test.ts create mode 100644 projects/app/src/pageComponents/dashboard/ResourceCardSkeleton.tsx diff --git a/packages/global/openapi/core/ai/skill/api.ts b/packages/global/openapi/core/ai/skill/api.ts index f413ea21bda1..6f6acd64c44e 100644 --- a/packages/global/openapi/core/ai/skill/api.ts +++ b/packages/global/openapi/core/ai/skill/api.ts @@ -35,6 +35,7 @@ export const ListSkillsQuerySchema = z.object({ type: AgentSkillTypeSchema.optional().describe('技能类型过滤'), skillIds: z.array(IdSchema).optional().describe('按技能 ID 列表过滤,用于校验已关联技能状态'), parentId: NullableParentIdSchema, + offset: z.coerce.number().int().nonnegative().optional().describe('偏移量'), page: z.coerce.number().int().positive().optional().describe('页码'), pageSize: z.coerce.number().int().positive().optional().describe('每页数量'), withAppCount: z.boolean().optional().describe('是否返回引用应用数量'), diff --git a/packages/service/core/ai/skill/manage/list.ts b/packages/service/core/ai/skill/manage/list.ts index cfc0e51687cf..32496b6a08a8 100644 --- a/packages/service/core/ai/skill/manage/list.ts +++ b/packages/service/core/ai/skill/manage/list.ts @@ -63,6 +63,7 @@ export const listReadableAgentSkills = async ({ category, type, skillIds, + offset, page, pageSize, withAppCount, @@ -158,8 +159,8 @@ export const listReadableAgentSkills = async ({ ); })(); - const paged = page !== undefined && pageSize !== undefined; - const skip = paged ? (page! - 1) * pageSize! : 0; + const paged = offset !== undefined || (page !== undefined && pageSize !== undefined); + const skip = paged ? (offset ?? (page! - 1) * pageSize!) : 0; const skillQuery = MongoAgentSkills.find(findSkillQuery) .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) .skip(skip); diff --git a/packages/web/hooks/useResponsiveGridPageSize.ts b/packages/web/hooks/useResponsiveGridPageSize.ts new file mode 100644 index 000000000000..68bd814f7937 --- /dev/null +++ b/packages/web/hooks/useResponsiveGridPageSize.ts @@ -0,0 +1,38 @@ +import { useBreakpointValue } from '@chakra-ui/react'; + +export type ResponsiveGridColumns = { + base: number; + sm?: number; + md?: number; + lg?: number; + xl?: number; + '2xl'?: number; +}; + +/** + * 根据网格列数计算基础分页数量,保证数据完整占据网格行。 + */ +export const getGridPageSize = (columnCount: number, basePageSize = 50) => { + const safeColumnCount = Number.isFinite(columnCount) ? Math.max(Math.floor(columnCount), 1) : 1; + const totalPageSize = Math.ceil(basePageSize / safeColumnCount); + + return Math.max(totalPageSize * safeColumnCount, 1); +}; + +/** + * 计算资源网格的请求数量。首请求需要为顶部的新增卡片预留一个槽位,后续请求使用基础数量。 + */ +export const getGridRequestPageSize = (pageSize: number, offset: number) => + offset === 0 ? Math.max(pageSize - 1, 1) : pageSize; + +/** + * 复用 Chakra 响应式断点计算资源网格列数和对应的分页数量。 + */ +export const useResponsiveGridPageSize = (columns: ResponsiveGridColumns) => { + const columnCount = useBreakpointValue(columns, { ssr: false }) ?? columns.base; + + return { + columnCount, + pageSize: getGridPageSize(columnCount) + }; +}; diff --git a/packages/web/hooks/useScrollPagination.tsx b/packages/web/hooks/useScrollPagination.tsx index 380ff1699d69..97c6bb657821 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -14,11 +14,13 @@ export type ScrollListType = ({ children, ScrollContainerRef, isLoading, + showLoadingOverlay, ...props }: { children: ReactNode; ScrollContainerRef?: RefObject; isLoading?: boolean; + showLoadingOverlay?: boolean; } & BoxProps) => React.JSX.Element; export function useScrollPagination< @@ -145,9 +147,11 @@ export function useScrollPagination< children, ScrollContainerRef, isLoading: isLoadingProp, + showLoadingOverlay = true, ...props }: { isLoading?: boolean; + showLoadingOverlay?: boolean; children: ReactNode; ScrollContainerRef?: RefObject; } & BoxProps) => { @@ -185,7 +189,7 @@ export function useScrollPagination< overflow={'auto'} display={'flex'} flexDirection={'column'} - isLoading={isLoading || isLoadingProp} + isLoading={showLoadingOverlay && (isLoading || isLoadingProp)} {...props} > {scrollLoadType === 'top' && total > 0 && isLoading && ( @@ -194,22 +198,25 @@ export function useScrollPagination< )} {children} - {scrollLoadType === 'bottom' && !isEmpty && (showNoMoreTip || !noMore) && ( - { - if (loadText !== t('common:request_more')) return; - loadData({ init: false }); - }} - > - {loadText} - - )} + {scrollLoadType === 'bottom' && + !isEmpty && + !(isLoading && data.length === 0) && + (showNoMoreTip || !noMore) && ( + { + if (loadText !== t('common:request_more')) return; + loadData({ init: false }); + }} + > + {loadText} + + )} {isEmpty && EmptyTip} ); diff --git a/packages/web/hooks/useVirtualGridList.tsx b/packages/web/hooks/useVirtualGridList.tsx index e91542ec1aa4..3ee19195e0de 100644 --- a/packages/web/hooks/useVirtualGridList.tsx +++ b/packages/web/hooks/useVirtualGridList.tsx @@ -1,5 +1,6 @@ import { Box } from '@chakra-ui/react'; import { + Fragment, type ReactNode, type RefObject, useCallback, @@ -30,6 +31,10 @@ type UseVirtualGridListParams = { overscanRows?: number; /** 最大同时渲染行数,防止内存溢出 */ maxRenderRows?: number; + /** 正在加载时追加到列表尾部的占位卡片数量 */ + loadingItemCount?: number; + /** 渲染占位卡片,参数为占位卡片在完整列表中的索引 */ + renderLoadingItem?: (index: number) => ReactNode; }; type UseVirtualGridListReturn = { @@ -46,6 +51,9 @@ type VirtualGridItemsState = { topPlaceholderHeight: number; bottomPlaceholderHeight: number; loadMoreRef: RefObject; + loadingStartIndex: number; + loadingEndIndex: number; + renderLoadingItem?: (index: number) => ReactNode; }; type VirtualGridItemsProps = VirtualGridItemsState & { @@ -84,7 +92,10 @@ const VirtualGridItems = ({ hasMore, topPlaceholderHeight, bottomPlaceholderHeight, - loadMoreRef + loadMoreRef, + loadingStartIndex, + loadingEndIndex, + renderLoadingItem }: VirtualGridItemsProps) => { return ( <> @@ -96,6 +107,12 @@ const VirtualGridItems = ({ )} {/* 渲染当前视口内的可见项 */} {visibleList.map(renderItem)} + {renderLoadingItem && + Array.from({ length: Math.max(loadingEndIndex - loadingStartIndex, 0) }).map((_, index) => ( + + {renderLoadingItem(loadingStartIndex + index)} + + ))} {/* 底部占位符及加载更多触发器 */} {hasMore && ( ({ estimatedRowGap = defaultEstimatedRowGap, preloadRootMargin = defaultPreloadRootMargin, overscanRows = defaultOverscanRows, - maxRenderRows + maxRenderRows, + loadingItemCount = 0, + renderLoadingItem }: UseVirtualGridListParams): UseVirtualGridListReturn { const gridRef = useRef(null); const loadMoreRef = useRef(null); @@ -142,6 +161,12 @@ export function useVirtualGridList({ }); // 计算最大渲染行数,至少为 batchRows,默认不超过 batchRows * 2 或 30 const resolvedMaxRenderRows = Math.max(maxRenderRows ?? Math.max(batchRows * 2, 30), batchRows); + const resolvedLoadingItemCount = Math.max(loadingItemCount, 0); + + // 计算总槽位数(数据项 + 固定项) + const totalSlotCount = list.length + resolvedLoadingItemCount + reservedSlotCount; + // 计算总行数 + const totalRows = Math.ceil(totalSlotCount / gridColumnCount); /** * 更新网格度量信息(列数、行高、行间距) @@ -200,11 +225,6 @@ export function useVirtualGridList({ }; }, [list.length, updateGridMetrics]); - // 计算总槽位数(数据项 + 固定项) - const totalSlotCount = list.length + reservedSlotCount; - // 计算总行数 - const totalRows = Math.ceil(totalSlotCount / gridColumnCount); - /** * 计算首行需要常驻渲染的数据项数量 * 当固定卡片占用首行部分位置时,需要补齐同一行的剩余数据项,避免滚动占位错位 @@ -350,7 +370,14 @@ export function useVirtualGridList({ useEffect(() => { updateGridMetrics(); schedulePreloadSyncWindowRows(); - }, [leadingItemCount, list.length, listKey, schedulePreloadSyncWindowRows, updateGridMetrics]); + }, [ + leadingItemCount, + list.length, + listKey, + resolvedLoadingItemCount, + schedulePreloadSyncWindowRows, + updateGridMetrics + ]); // 获取当前有效的窗口行状态,如果 key 不匹配则重置 const activeWindowRows = @@ -367,8 +394,13 @@ export function useVirtualGridList({ const endRow = Math.min(Math.max(activeWindowRows.endRow, startRow), totalVirtualRows); // 计算可见项在原始 list 中的索引范围 - const visibleStartIndex = leadingItemCount + startRow * gridColumnCount; - const visibleEndIndex = Math.min(leadingItemCount + endRow * gridColumnCount, list.length); + const virtualStartIndex = startRow * gridColumnCount; + const virtualEndIndex = endRow * gridColumnCount; + const visibleStartIndex = leadingItemCount + virtualStartIndex; + const visibleEndIndex = Math.min(leadingItemCount + virtualEndIndex, list.length); + const virtualListLength = Math.max(list.length - leadingItemCount, 0); + const loadingStartIndex = Math.max(virtualStartIndex - virtualListLength, 0); + const loadingEndIndex = Math.min(virtualEndIndex - virtualListLength, resolvedLoadingItemCount); // 首行固定项列表 const leadingList = useMemo(() => list.slice(0, leadingItemCount), [leadingItemCount, list]); @@ -397,9 +429,21 @@ export function useVirtualGridList({ hasMore, topPlaceholderHeight, bottomPlaceholderHeight, - loadMoreRef + loadMoreRef, + loadingStartIndex, + loadingEndIndex, + renderLoadingItem }), - [bottomPlaceholderHeight, hasMore, leadingList, topPlaceholderHeight, visibleList] + [ + bottomPlaceholderHeight, + hasMore, + leadingList, + loadingEndIndex, + loadingStartIndex, + renderLoadingItem, + topPlaceholderHeight, + visibleList + ] ); // 渲染函数,接收 renderItem 回调 diff --git a/packages/web/test/hooks/useResponsiveGridPageSize.test.ts b/packages/web/test/hooks/useResponsiveGridPageSize.test.ts new file mode 100644 index 000000000000..694cdf3e10ec --- /dev/null +++ b/packages/web/test/hooks/useResponsiveGridPageSize.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { getGridPageSize, getGridRequestPageSize } from '../../hooks/useResponsiveGridPageSize'; + +describe('getGridPageSize', () => { + it('returns a page size that fills complete grid rows', () => { + expect(getGridPageSize(1)).toBe(50); + expect(getGridPageSize(2)).toBe(50); + expect(getGridPageSize(3)).toBe(51); + expect(getGridPageSize(4)).toBe(52); + }); + + it('normalizes invalid column counts', () => { + expect(getGridPageSize(0)).toBe(50); + expect(getGridPageSize(-2)).toBe(50); + expect(getGridPageSize(Number.NaN)).toBe(50); + }); + + it('reserves one slot only for the first request', () => { + expect(getGridRequestPageSize(50, 0)).toBe(49); + expect(getGridRequestPageSize(50, 50)).toBe(50); + }); +}); diff --git a/projects/app/src/pageComponents/dashboard/ResourceCardSkeleton.tsx b/projects/app/src/pageComponents/dashboard/ResourceCardSkeleton.tsx new file mode 100644 index 000000000000..0278968e2313 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/ResourceCardSkeleton.tsx @@ -0,0 +1,48 @@ +import { Box, Flex, Skeleton, SkeletonCircle } from '@chakra-ui/react'; + +const skeletonProps = { + startColor: '#f4f4f5', + endColor: '#e4e4e7', + borderRadius: '2px' +}; + +/** + * 资源列表首次加载和分页加载时使用的卡片占位,尺寸与真实卡片保持稳定以避免网格跳动。 + */ +const ResourceCardSkeleton = () => ( + + + + + + + + + + + + + + + + +); + +export default ResourceCardSkeleton; diff --git a/projects/app/src/pageComponents/dashboard/agent/List.tsx b/projects/app/src/pageComponents/dashboard/agent/List.tsx index af04dae7e8d0..fdcabcfde2ee 100644 --- a/projects/app/src/pageComponents/dashboard/agent/List.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/List.tsx @@ -42,6 +42,8 @@ import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; +import { getGridPageSize } from '@fastgpt/web/hooks/useResponsiveGridPageSize'; +import ResourceCardSkeleton from '@/pageComponents/dashboard/ResourceCardSkeleton'; const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal')); @@ -81,7 +83,9 @@ const List = () => { folderDetail, searchKey, setSearchKey, - listFilters + listFilters, + columnCount, + pageSize } = useContextSelector(AppListContext, (v) => v); const hasCreatePer = folderDetail @@ -96,12 +100,19 @@ const List = () => { const [editedApp, setEditedApp] = useState(); const [editPerAppId, setEditPerAppId] = useState(); + const isInitialLoading = myApps.length === 0 && isFetchingApps; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: myApps, - listKey: `${router.pathname}-${appType}-${parentId || ''}-${searchKey}`, - reservedSlotCount: 1, + listKey: `${router.pathname}-${appType}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, + reservedSlotCount: isInitialLoading ? 0 : 1, estimatedRowHeight: 160, - estimatedRowGap: 20 + estimatedRowGap: 20, + loadingItemCount: isFetchingApps + ? isInitialLoading + ? getGridPageSize(columnCount) + : pageSize + : 0, + renderLoadingItem: () => }); const editPerApp = useMemo( @@ -434,12 +445,24 @@ const List = () => { ); }; - if (myApps.length === 0 && isFetchingApps) return null; - return ( - + <> - {myApps.length === 0 && !folderDetail ? ( + {isInitialLoading ? ( + + {renderVirtualGridItems(renderAppCard)} + + ) : myApps.length === 0 && !folderDetail ? ( hasActiveFilter ? ( ) : isPc && hasCreatePer ? ( @@ -448,7 +471,7 @@ const List = () => { { ref={gridRef} py={4} gridTemplateColumns={ - folderDetail + parentId ? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)'] : ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)'] } diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 224b8e3ed37a..30605f577e6b 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -31,6 +31,10 @@ import { type AppListFilterType, type AppListFilterScene } from './filters/utils'; +import { + getGridRequestPageSize, + useResponsiveGridPageSize +} from '@fastgpt/web/hooks/useResponsiveGridPageSize'; const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal')); type AppListContextType = { @@ -51,6 +55,8 @@ type AppListContextType = { setListFilters: ( next: AppListFilterType | ((prev: AppListFilterType) => AppListFilterType) ) => void; + columnCount: number; + pageSize: number; }; export const AppListContext = createContext({ @@ -80,7 +86,9 @@ export const AppListContext = createContext({ listFilters: defaultAppListFilters, setListFilters: function (): void { throw new Error('Function not implemented.'); - } + }, + columnCount: 1, + pageSize: 50 }); const AppListContextProvider = ({ children }: { children: ReactNode }) => { @@ -130,6 +138,9 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { const sort = applyToolbarFilters ? listFilters.sort : undefined; const persistedTmbIds = applyToolbarFilters && feConfigs.isPlus ? toListTmbIds(listFilters.creator) : undefined; + const { columnCount, pageSize } = useResponsiveGridPageSize( + parentId ? { base: 1, sm: 2, md: 2, lg: 3 } : { base: 1, sm: 2, md: 2, lg: 3, xl: 4 } + ); const { data: myApps = [], @@ -148,7 +159,7 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { type: formatType, searchKey, offset, - pageSize, + pageSize: getGridRequestPageSize(pageSize, offset), ...(sort ? { sort } : {}), ...(tmbIds !== undefined ? { tmbIds } : {}) }); @@ -175,9 +186,10 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { persistedTmbIds === undefined ? 'none' : persistedTmbIds.join(','), router.pathname, feConfigs.isPlus, - isPc + isPc, + pageSize ], - pageSize: 50, + pageSize, throttleWait: 500, refreshOnWindowFocus: true } @@ -257,7 +269,9 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { searchKey, setSearchKey, listFilters, - setListFilters + setListFilters, + columnCount, + pageSize }; return ( diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index ed160fc9e1f0..2f30a89c450b 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -46,10 +46,12 @@ import type { import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; import SkillDashboardEmptyHero from '@/pageComponents/dashboard/skill/SkillDashboardEmptyHero'; import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; +import { getGridPageSize } from '@fastgpt/web/hooks/useResponsiveGridPageSize'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getResourceListDisplayTime } from '@/pageComponents/dashboard/agent/filters/utils'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; +import ResourceCardSkeleton from '@/pageComponents/dashboard/ResourceCardSkeleton'; const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal')); @@ -195,26 +197,46 @@ const List = ({ const router = useRouter(); const { isPc } = useSystem(); - const { skills, refreshSkills, isFetchingSkills, searchKey, folderDetail, listFilters, ScrollData } = - useContextSelector(SkillListContext, (v) => ({ - skills: v.skills, - refreshSkills: v.refreshSkills, - isFetchingSkills: v.isFetchingSkills, - searchKey: v.searchKey, - folderDetail: v.folderDetail, - listFilters: v.listFilters, - ScrollData: v.ScrollData - })); + const { + skills, + refreshSkills, + isFetchingSkills, + searchKey, + folderDetail, + ScrollData, + listFilters, + columnCount, + pageSize, + parentId + } = useContextSelector(SkillListContext, (v) => ({ + skills: v.skills, + refreshSkills: v.refreshSkills, + isFetchingSkills: v.isFetchingSkills, + searchKey: v.searchKey, + folderDetail: v.folderDetail, + ScrollData: v.ScrollData, + listFilters: v.listFilters, + columnCount: v.columnCount, + pageSize: v.pageSize, + parentId: v.parentId + })); const [editedSkill, setEditedSkill] = useState(); const [moveSkillId, setMoveSkillId] = useState(); const [editPerSkillId, setEditPerSkillId] = useState(); + const isInitialLoading = skills.length === 0 && isFetchingSkills; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: skills, - listKey: `${router.pathname}-${router.query.parentId || ''}-${searchKey}`, - reservedSlotCount: 1, + listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, + reservedSlotCount: isInitialLoading ? 0 : 1, estimatedRowHeight: 160, - estimatedRowGap: 20 + estimatedRowGap: 20, + loadingItemCount: isFetchingSkills + ? isInitialLoading + ? getGridPageSize(columnCount) + : pageSize + : 0, + renderLoadingItem: () => }); const selectedSkill = useMemo( @@ -522,12 +544,24 @@ const List = ({ ); }; - if (skills.length === 0 && isFetchingSkills) return null; - return ( - + <> - {skills.length === 0 && !folderDetail ? ( + {isInitialLoading ? ( + + {renderVirtualGridItems(renderSkillCard)} + + ) : skills.length === 0 && !folderDetail ? ( searchKey ? ( ) : onClickCreate && onClickImport ? ( @@ -553,7 +587,7 @@ const List = ({ ref={gridRef} py={4} gridTemplateColumns={ - folderDetail + parentId ? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)'] : ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)'] } diff --git a/projects/app/src/pageComponents/dashboard/skill/context.tsx b/projects/app/src/pageComponents/dashboard/skill/context.tsx index 00fd49afdd3d..87bfe942e2e9 100644 --- a/projects/app/src/pageComponents/dashboard/skill/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/context.tsx @@ -25,6 +25,10 @@ import { toListTmbIds, type ResourceListFilterType } from '@/pageComponents/dashboard/agent/filters/utils'; +import { + getGridRequestPageSize, + useResponsiveGridPageSize +} from '@fastgpt/web/hooks/useResponsiveGridPageSize'; export type SkillListItemType = Omit< ListSkillsResponse['list'][number], @@ -49,6 +53,8 @@ type SkillListContextType = { }; listFilters: ResourceListFilterType; setListFilters: (next: ResourceListFilterType) => void; + columnCount: number; + pageSize: number; }; export const SkillListContext = createContext({ @@ -68,7 +74,9 @@ export const SkillListContext = createContext({ listFilters: defaultAppListFiltersStore.skill, setListFilters: () => { throw new Error('Function not implemented.'); - } + }, + columnCount: 1, + pageSize: 50 }); const SkillListContextProvider = ({ children }: { children: ReactNode }) => { @@ -95,6 +103,9 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { const applyToolbarFilters = isPc; const tmbIds = applyToolbarFilters && feConfigs.isPlus ? toListTmbIds(listFilters.creator) : undefined; + const { columnCount, pageSize } = useResponsiveGridPageSize( + parentId ? { base: 1, sm: 2, md: 2, lg: 3 } : { base: 1, sm: 2, md: 2, lg: 3, xl: 4 } + ); const { data: skills = [], @@ -107,8 +118,8 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { source: 'mine', searchKey, parentId, - page: Number(offset) / Number(pageSize) + 1, - pageSize, + offset, + pageSize: getGridRequestPageSize(pageSize, offset), ...(applyToolbarFilters ? { sort: listFilters.sort } : {}), ...(tmbIds !== undefined ? { tmbIds } : {}) }).then((res) => ({ @@ -128,7 +139,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { feConfigs.isPlus, isPc ], - pageSize: 50, + pageSize, throttleWait: 500, refreshOnWindowFocus: false } @@ -171,7 +182,9 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { paths, folderDetail, listFilters, - setListFilters + setListFilters, + columnCount, + pageSize }; return {children}; diff --git a/projects/app/src/pageComponents/dataset/list/List.tsx b/projects/app/src/pageComponents/dataset/list/List.tsx index 25e147bbaf9e..e83656eac493 100644 --- a/projects/app/src/pageComponents/dataset/list/List.tsx +++ b/projects/app/src/pageComponents/dataset/list/List.tsx @@ -34,6 +34,7 @@ import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; import { formatTimeToChatTime } from '@fastgpt/global/common/string/time'; import { getResourceListDisplayTime } from '@/pageComponents/dashboard/agent/filters/utils'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; +import ResourceCardSkeleton from '@/pageComponents/dashboard/ResourceCardSkeleton'; const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); @@ -55,7 +56,9 @@ function List() { folderDetail, searchKey, setSearchKey, - listFilters + listFilters, + columnCount, + pageSize } = useContextSelector(DatasetsContext, (v) => v); const { userInfo } = useUserStore(); const canCreateDataset = folderDetail @@ -76,12 +79,15 @@ function List() { }), [myDatasets] ); + const isInitialLoading = formatDatasets.length === 0 && isFetchingDatasets; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: formatDatasets, - listKey: `${router.pathname}-${parentId || ''}-${searchKey}`, + listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, estimatedRowHeight: 160, - estimatedRowGap: 20 + estimatedRowGap: 20, + loadingItemCount: isFetchingDatasets ? pageSize : 0, + renderLoadingItem: () => }); const parentDataset = useMemo( @@ -397,14 +403,14 @@ function List() { }; return ( - + <> - {formatDatasets.length > 0 && ( + {isFetchingDatasets ? ( {renderVirtualGridItems(renderDatasetCard)} + ) : ( + formatDatasets.length > 0 && ( + + {renderVirtualGridItems(renderDatasetCard)} + + ) )} {myDatasets.length === 0 && ( import('@/components/common/folder/MoveModal')); @@ -56,6 +57,8 @@ export type DatasetContextType = { setSearchKey: React.Dispatch>; listFilters: DatasetListFilterType; setListFilters: (next: DatasetListFilterType) => void; + columnCount: number; + pageSize: number; }; export const DatasetsContext = createContext({ @@ -85,7 +88,9 @@ export const DatasetsContext = createContext({ listFilters: defaultAppListFiltersStore.dataset, setListFilters: () => { throw new Error('Function not implemented.'); - } + }, + columnCount: 1, + pageSize: 50 }); function DatasetContextProvider({ children }: { children: React.ReactNode }) { @@ -117,6 +122,9 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { applyToolbarFilters && listFilters.type !== 'all' ? [DatasetTypeEnum.folder, listFilters.type] : undefined; + const { columnCount, pageSize } = useResponsiveGridPageSize( + parentId ? { base: 1, sm: 2, md: 2, lg: 3 } : { base: 1, sm: 2, md: 3, lg: 3, xl: 4 } + ); const { data: myDatasets = [], @@ -144,7 +152,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { feConfigs.isPlus, isPc ], - pageSize: 50, + pageSize, throttleWait: 300, refreshOnWindowFocus: false } @@ -223,7 +231,9 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { searchKey, setSearchKey, listFilters, - setListFilters + setListFilters, + columnCount, + pageSize }; return ( diff --git a/projects/app/src/pages/api/core/ai/skill/list.ts b/projects/app/src/pages/api/core/ai/skill/list.ts index eacacd4aaba5..fd961064fc59 100644 --- a/projects/app/src/pages/api/core/ai/skill/list.ts +++ b/projects/app/src/pages/api/core/ai/skill/list.ts @@ -21,7 +21,8 @@ async function handler(req: ApiRequestProps) { pageSize, withAppCount, sort, - tmbIds + tmbIds, + offset } = parseApiInput({ req, bodySchema: ListSkillsQuerySchema }).body; const selectedSkillIds = skillIds?.filter(Boolean) ?? []; const isSkillIdsQuery = selectedSkillIds.length > 0; @@ -57,6 +58,7 @@ async function handler(req: ApiRequestProps) { category, type, skillIds: selectedSkillIds, + offset: isSkillIdsQuery ? undefined : offset, page: isSkillIdsQuery ? undefined : (page ?? 1), pageSize: isSkillIdsQuery ? undefined : (pageSize ?? 50), withAppCount, diff --git a/projects/app/src/pages/dashboard/agent/index.tsx b/projects/app/src/pages/dashboard/agent/index.tsx index e988f20352d8..6e9a5685c286 100644 --- a/projects/app/src/pages/dashboard/agent/index.tsx +++ b/projects/app/src/pages/dashboard/agent/index.tsx @@ -47,12 +47,10 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { const { paths, parentId, - myApps, appType, loadMyApps, onUpdateApp, setMoveAppId, - isFetchingApps, folderDetail, refetchFolderDetail, searchKey, @@ -197,7 +195,7 @@ const MyApps = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + diff --git a/projects/app/src/pages/dashboard/skill/index.tsx b/projects/app/src/pages/dashboard/skill/index.tsx index b6f811bec7b9..e17fa1b5659a 100644 --- a/projects/app/src/pages/dashboard/skill/index.tsx +++ b/projects/app/src/pages/dashboard/skill/index.tsx @@ -49,8 +49,6 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { useSkillSandboxOperationGuard(); const { - skills, - isFetchingSkills, refreshSkills, searchKey, setSearchKey, @@ -173,7 +171,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + { const { paths, parentId, - myApps, loadMyApps, onUpdateApp, setMoveAppId, - isFetchingApps, folderDetail, refetchFolderDetail, searchKey, @@ -181,7 +179,7 @@ const MyTools = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + diff --git a/projects/app/src/pages/dataset/list/index.tsx b/projects/app/src/pages/dataset/list/index.tsx index 53cac027b5be..6610d9c9bbad 100644 --- a/projects/app/src/pages/dataset/list/index.tsx +++ b/projects/app/src/pages/dataset/list/index.tsx @@ -51,9 +51,7 @@ const Dataset = () => { const parentId = normalizeParentId(router.query.parentId); const { - myDatasets, paths, - isFetchingDatasets, refetchPaths, loadMyDatasets, refetchFolderDetail, @@ -108,13 +106,7 @@ const Dataset = () => { ); return ( - + From 30b2b03d0c6b3a1ae7d00303f245c90ae82ecc1f Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Mon, 31 Aug 2026 23:01:23 +0800 Subject: [PATCH 05/38] feat(resource-list): paginate selectors and preserve full-list APIs --- .../core/resource-list-pagination-frontend.md | 287 +++++++++++++++++ packages/global/openapi/core/ai/skill/api.ts | 45 ++- .../global/openapi/core/ai/skill/index.ts | 27 +- .../global/openapi/core/app/common/api.ts | 20 +- .../global/openapi/core/app/common/index.ts | 26 ++ packages/global/openapi/core/dataset/api.ts | 23 +- packages/global/openapi/core/dataset/index.ts | 34 ++- packages/global/test/openapi/core/ai.test.ts | 3 + packages/global/test/openapi/core/app.test.ts | 2 + .../global/test/openapi/core/dataset.test.ts | 5 + packages/service/core/ai/skill/manage/list.ts | 4 +- packages/web/hooks/useVirtualList.tsx | 7 +- .../core/app/DatasetSelectModal.tsx | 288 +++++++++--------- .../components/SelectMarkCollection.tsx | 107 ++++--- .../components/core/dataset/SelectModal.tsx | 56 ++-- .../ToolSelector/SkillSelectModal.tsx | 85 ++++-- .../ToolSelector/hooks/useSkillSelectData.ts | 39 ++- .../Flow/SelectAppModal.tsx | 118 +++++-- .../chat/ChatSetting/AppTree.tsx | 2 +- .../AddFavouriteAppModal.tsx | 65 ++-- .../HomepageSetting/AddQuickAppModal.tsx | 59 ++-- .../dashboard/agent/context.tsx | 4 +- .../dashboard/mcp/EditModal.tsx | 35 +-- .../dashboard/skill/context.tsx | 4 +- .../pageComponents/dataset/list/context.tsx | 4 +- .../app/src/pages/api/core/ai/skill/list.ts | 4 +- .../app/src/pages/api/core/ai/skill/listV2.ts | 74 +++++ projects/app/src/pages/api/core/app/list.ts | 244 +++++++-------- projects/app/src/pages/api/core/app/listV2.ts | 188 ++++++++++++ .../app/src/pages/api/core/dataset/list.ts | 212 +++++-------- .../app/src/pages/api/core/dataset/listV2.ts | 173 +++++++++++ projects/app/src/web/core/app/api.ts | 19 +- projects/app/src/web/core/dataset/api.ts | 17 +- .../app/src/web/core/dataset/store/dataset.ts | 29 -- projects/app/src/web/core/skill/api.ts | 15 +- .../app/test/api/core/ai/skill/list.test.ts | 33 +- projects/app/test/api/core/app/list.test.ts | 46 ++- .../app/test/api/core/dataset/list.test.ts | 38 ++- test/mocks/core/ai/embedding.ts | 9 + 39 files changed, 1686 insertions(+), 764 deletions(-) create mode 100644 .agents/design/core/resource-list-pagination-frontend.md create mode 100644 projects/app/src/pages/api/core/ai/skill/listV2.ts create mode 100644 projects/app/src/pages/api/core/app/listV2.ts create mode 100644 projects/app/src/pages/api/core/dataset/listV2.ts delete mode 100644 projects/app/src/web/core/dataset/store/dataset.ts diff --git a/.agents/design/core/resource-list-pagination-frontend.md b/.agents/design/core/resource-list-pagination-frontend.md new file mode 100644 index 000000000000..a3079c4257d9 --- /dev/null +++ b/.agents/design/core/resource-list-pagination-frontend.md @@ -0,0 +1,287 @@ +# App、Dataset、Skill 列表分页前端改造说明 + +## 1. 目标与边界 + +App、Dataset、Skill 列表同时存在两类前端使用场景: + +1. Dashboard 主页面展示资源卡片,需要避免一次性拉取整个团队资源,并支持触底加载。 +2. 选择器和工具配置弹窗中的当前目录列表可以增量加载;递归资源树和移动目录树继续使用数组协议。 + +本次采用 V1/V2 分工: + +| 场景 | App | Dataset | Skill | +| --- | --- | --- | --- | +| V1 兼容接口 | `/core/app/list` | `/core/dataset/list` | `/core/ai/skill/list` | +| 数组兼容 wrapper | `getAllApps` | `getAllDatasets` | `getAllSkillList` | +| Dashboard 分页接口 | `/core/app/listV2` | `/core/dataset/listV2` | `/core/ai/skill/listV2` | +| Dashboard 分页 wrapper | `getMyAppsV2` | `getDatasetsV2` | `getSkillListV2` | + +V1 的 response shape 和现有调用者保持兼容,V2 使用 `{ list, total }`。本次不把 V2 的第一页直接伪装成全量数组。 + +## 2. 请求 wrapper + +### 2.1 App + +文件:`projects/app/src/web/core/app/api.ts` + +- `getMyApps(data?)` 请求 `/core/app/list`,返回数组。 +- `getMyAppsV2(data?)` 请求 `/core/app/listV2`,返回 `{ list, total }`。 +- `getAllApps(data?)` 直接复用 `getMyApps(data)`,返回当前筛选条件下的数组。 + +调用关系: + +```text +Dashboard App Context + -> getMyAppsV2 + -> useScrollPagination + -> /core/app/listV2 + +递归树 / 移动弹窗 / 未改造 Tool 选择 + -> getAllApps + -> getMyApps + -> /core/app/list + +扁平选择列表 + -> useVirtualList + -> getMyAppsV2 + -> /core/app/listV2 +``` + +旧版 App 接口带 `searchKey` 时保留最多 50 条的历史限制。需要无限搜索结果时,必须另行设计分页选择器,不能通过修改 `getAllApps` 的返回类型解决。 + +### 2.2 Dataset + +文件:`projects/app/src/web/core/dataset/api.ts` + +- `getDatasets(data)` 请求 `/core/dataset/list`,返回数组。 +- `getDatasetsV2(data)` 请求 `/core/dataset/listV2`,返回 `{ list, total }`。 +- `getAllDatasets(data)` 直接复用 `getDatasets(data)`,返回当前筛选条件下的数组。 + +### 2.3 Skill + +文件:`projects/app/src/web/core/skill/api.ts` + +- `getSkillList(data)` 请求 `/core/ai/skill/list`。 +- `getSkillListV2(data)` 请求 `/core/ai/skill/listV2`。 +- `getAllSkillList(data)` 请求旧版 `getSkillList(data)`,取旧接口响应中的 `list` 返回给数组调用者。 +- `skillIds` 查询保持不分页,用于校验已关联 Skill 的完整状态。 + +## 3. Dashboard 分页页面 + +三个 Dashboard Context 已经使用公共 hook `packages/web/hooks/useScrollPagination.tsx`。该 hook 会: + +- 首次请求 `offset=0`。 +- 以当前 `data.length` 作为下一页 offset。 +- 根据 response 的 `total` 判断是否还有更多数据。 +- 在滚动容器接近底部时追加下一页。 +- 搜索词、目录或 page size 变化时重新初始化列表。 + +### 3.1 App Dashboard + +页面入口: + +- `projects/app/src/pages/dashboard/agent/index.tsx` +- `projects/app/src/pages/dashboard/tool/index.tsx` + +复用页面: + +- `projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx` + +数据 Context: + +- `projects/app/src/pageComponents/dashboard/agent/context.tsx` +- `useScrollPagination` 请求 `getMyAppsV2`。 +- 参数包括 `parentId`、App 类型、`searchKey`、`offset` 和 `pageSize`。 +- `getGridRequestPageSize` 处理首屏预留创建卡片后的请求数量。 + +列表展示: + +- `projects/app/src/pageComponents/dashboard/agent/List.tsx` +- 使用 `ScrollData` 作为滚动容器。 +- 使用虚拟网格渲染卡片;虚拟渲染只优化 DOM,不改变分页请求。 + +移动文件夹: + +- `context.tsx` 中的 `getAppFolderList` 保持使用 `getAllApps`。 +- `MoveModal` 当前只接受文件夹数组,没有分页协议,不能直接换成 `getMyAppsV2`。 + +### 3.2 Dataset Dashboard + +页面入口: + +- `projects/app/src/pages/dataset/list/index.tsx` + +数据 Context: + +- `projects/app/src/pageComponents/dataset/list/context.tsx` +- `useScrollPagination` 请求 `getDatasetsV2`。 +- 参数包括 `parentId`、`searchKey`、`offset` 和 `pageSize`。 +- 切换目录或搜索词时重新加载第一页。 + +列表展示: + +- `projects/app/src/pageComponents/dataset/list/List.tsx` +- 使用 `ScrollData` 和虚拟网格。 + +选择和移动文件夹: + +- `projects/app/src/components/core/dataset/SelectModal.tsx` 的 `useDatasetSelect` 使用 `getDatasetsV2` 和 `useScrollPagination`。 +- `projects/app/src/components/core/app/DatasetSelectModal.tsx`、`SelectMarkCollection.tsx` 使用 `ScrollData` 和 `useVirtualGridList`。 +- `projects/app/src/pageComponents/dataset/list/context.tsx` 的文件夹列表使用 `getAllDatasets`。 +- `projects/app/src/web/core/dataset/store/dataset.ts` 无仓库引用,已删除。 + +### 3.3 Skill Dashboard + +页面入口: + +- `projects/app/src/pages/dashboard/skill/index.tsx` + +数据 Context: + +- `projects/app/src/pageComponents/dashboard/skill/context.tsx` +- `useScrollPagination` 请求 `getSkillListV2`。 +- 参数包括 `source`、`parentId`、`searchKey`、`offset` 和 `pageSize`。 +- 将 API 返回的时间字符串转换为 `Date` 后交给列表展示。 + +列表展示: + +- `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- 使用 `ScrollData` 和虚拟网格。 + +选择和移动文件夹: + +- `projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts` 使用 `getSkillListV2` 和 `useScrollPagination`。 +- `SkillSelectModal.tsx` 使用 `ScrollData` 和 `useVirtualGridList`。 +- `projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx` 使用 `getAllSkillList` 建立 Skill 映射和目录加载结果。 +- `projects/app/src/pageComponents/dashboard/skill/List.tsx` 的移动文件夹列表使用 `getSkillFolderList`,间接使用 `getAllSkillList`。 + +## 4. 已改为分页的选择列表 + +### App + +以下组件使用 `getMyAppsV2` 和 `useVirtualList`,分页范围为当前目录或当前搜索结果: + +- `pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx` +- `pageComponents/dashboard/mcp/EditModal.tsx` +- `pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx` +- `pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx` + +Workflow App 选择器采用面包屑加当前目录列表,保留单选和过滤应用 ID 行为。 + +### Dataset 和 Skill + +- Dataset 选择共享 `useDatasetSelect` 的分页状态,两个 Dataset 选择场景使用虚拟网格。 +- Dataset 选择弹窗的“全选/取消全选”在明确操作时读取当前目录或搜索条件下的完整候选,保证跨页选择语义;普通列表展示仍使用 V2 分页。 +- Skill 选择共享 `useSkillSelectData` 的分页状态,保留目录、搜索、创建/导入和数量限制。 +- 搜索词、目录变化会清空已加载页并从 `offset=0` 重新请求。 +- 已选项独立维护,当前目录或搜索结果切换不会清除已选项。 + +## 5. 继续使用全量数组的调用点 + +### App 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `components/Select/AppSelect.tsx` | 通用 App 树选择 | `SelectOneResource.server` 接受数组 | +| `pageComponents/chat/ChatHeader.tsx` | Chat 全部应用目录 | 展开目录时一次渲染当前层级 | +| `web/core/app/api/tool.ts` | Team Tool/Agent 模板 | 对外继续返回模板数组 | + +### Dataset 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `components/core/app/DatasetSelectModal.tsx` | 全选/取消全选 | 明确操作需要当前条件下的完整候选集 | +| `pageComponents/dataset/list/context.tsx` | 移动 Dataset 文件夹 | `MoveModal.server` 接受数组 | + +### Skill 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `hooks/useSkillManager.tsx` | ChatAgent Skill 管理 | 需要完整数组构建 Skill 映射 | +| `packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx` | PromptEditor Skill 树 | 插件维护递归目录和键盘索引 | +| `dashboard/skill/List.tsx` | 移动 Skill 文件夹 | `MoveModal.server` 接受数组 | + +## 6. 当前保留树协议的组件 + +### `SelectOneResource` + +文件:`projects/app/src/components/common/folder/SelectOneResource.tsx` + +当前协议: + +```ts +server: (props: GetResourceFolderListProps) => + Promise +``` + +每个目录节点展开时请求一次,并直接把返回数组写入 `children`。改为 V2 后需要额外维护每个目录的 `offset`、`total`、加载状态和追加逻辑,属于组件级改造。 + +### `MoveModal` + +文件:`projects/app/src/components/common/folder/MoveModal.tsx` + +当前协议: + +```ts +server: (props: GetResourceFolderListProps) => + Promise +``` + +移动目录树同样直接渲染完整数组,没有分页游标或加载更多节点。当前继续使用旧版全量接口。 + +### `ChatHeader` + +`ChatHeader` 当前继续使用 `SelectOneResource`。该组件的目录展开是递归 children 数组模型,后续若要分页,需要同时扩展 `SelectOneResource` 的节点级分页状态和加载更多交互。 + +## 7. 验证清单 + +### API 与类型 + +- App V1 返回数组,App V2 返回 `{ list, total }`。 +- Dataset V1 返回数组,Dataset V2 返回 `{ list, total }`。 +- Skill V1 的旧响应仍由 `getAllSkillList` 转换为数组,Skill V2 供 Dashboard 分页使用。 +- V2 在权限过滤后执行 `skip/limit`,并返回匹配总数。 +- V2 使用稳定排序,分页追加时不因同一更新时间产生重复或遗漏。 + +### 前端场景 + +- Dashboard 三个列表首次只请求一页。 +- Dashboard 继续滚动后能追加下一页,直到 `data.length >= total`。 +- 搜索和切换目录会清空旧数据并重新请求第一页。 +- 树选择器、移动弹窗和 PromptEditor Skill 树仍能获得 V1 数组。 +- 扁平选择列表首次只请求一页,继续滚动能追加下一页,直到 `data.length >= total`。 +- 扁平选择列表搜索和目录切换会重新请求第一页,跨页已选项保持有效。 + +### 测试文件 + +- `projects/app/test/api/core/app/list.test.ts` +- `projects/app/test/api/core/dataset/list.test.ts` +- `projects/app/test/api/core/ai/skill/list.test.ts` + +建议执行: + +```bash +pnpm --dir projects/app test test/api/core/app/list.test.ts +pnpm --dir projects/app test test/api/core/dataset/list.test.ts +pnpm --dir projects/app test test/api/core/ai/skill/list.test.ts +pnpm --dir projects/app typecheck +``` + +## 8. 后续独立改造建议 + +如果后续需要降低选择器的大数据量请求,应单独扩展资源树组件协议,例如: + +```ts +type ResourcePage = { + list: GetResourceListItemResponse[]; + total: number; +}; + +type ResourceServer = (props: { + parentId: ParentIdType; + offset: number; + pageSize: number; +}) => Promise; +``` + +该改造需要同时处理目录级分页、搜索结果、已选项回显、加载状态和空状态,不能在本次只替换 API wrapper 的范围内完成。 diff --git a/packages/global/openapi/core/ai/skill/api.ts b/packages/global/openapi/core/ai/skill/api.ts index 6f6acd64c44e..56e977b09317 100644 --- a/packages/global/openapi/core/ai/skill/api.ts +++ b/packages/global/openapi/core/ai/skill/api.ts @@ -21,6 +21,7 @@ import { ShowUsernameQuerySchema } from '../../../../support/permission/collaborator.schema'; import { AppListSortEnum } from '../../../../core/app/constants'; +import { IntSchema } from '../../../../common/zod'; const IdSchema = z.string().min(1).meta({ description: '资源 ID' }); const SandboxInstanceKeySchema = z.string().min(1).describe('FastGPT sandbox instance key'); @@ -28,22 +29,38 @@ const NullableParentIdSchema = z.string().nullable().optional().meta({ description: '父级目录 ID' }); -export const ListSkillsQuerySchema = z.object({ - source: z.enum(['store', 'mine']).optional().describe('技能来源: store=系统技能, mine=我的技能'), - searchKey: z.string().optional().describe('搜索关键词'), - category: AgentSkillCategorySchema.optional().describe('技能分类'), - type: AgentSkillTypeSchema.optional().describe('技能类型过滤'), - skillIds: z.array(IdSchema).optional().describe('按技能 ID 列表过滤,用于校验已关联技能状态'), - parentId: NullableParentIdSchema, - offset: z.coerce.number().int().nonnegative().optional().describe('偏移量'), - page: z.coerce.number().int().positive().optional().describe('页码'), - pageSize: z.coerce.number().int().positive().optional().describe('每页数量'), - withAppCount: z.boolean().optional().describe('是否返回引用应用数量'), - sort: z.enum(AppListSortEnum).optional().describe('列表排序,缺省按最近修改倒序'), - tmbIds: z.array(ObjectIdSchema).optional().describe('按创建者筛选;空数组返回空列表') -}); +const createListSkillsQuerySchema = () => + z.object({ + source: z + .enum(['store', 'mine']) + .optional() + .describe('技能来源: store=系统技能, mine=我的技能'), + searchKey: z.string().optional().describe('搜索关键词'), + category: AgentSkillCategorySchema.optional().describe('技能分类'), + type: AgentSkillTypeSchema.optional().describe('技能类型过滤'), + skillIds: z.array(IdSchema).optional().describe('按技能 ID 列表过滤,用于校验已关联技能状态'), + parentId: NullableParentIdSchema, + offset: IntSchema.optional().describe('偏移量'), + page: z.coerce.number().int().positive().optional().describe('页码'), + pageSize: z.coerce.number().int().positive().optional().describe('每页数量'), + withAppCount: z.boolean().optional().describe('是否返回引用应用数量'), + sort: z.enum(AppListSortEnum).optional().describe('列表排序,缺省按最近修改倒序'), + tmbIds: z.array(ObjectIdSchema).optional().describe('按创建者筛选;空数组返回空列表') + }); + +export const ListSkillsQuerySchema = createListSkillsQuerySchema(); export type ListSkillsQuery = z.infer; +/* ============================================================================ + * API: 获取技能列表 V2 + * Route: POST /api/core/ai/skill/listV2 + * Method: POST + * Description: 分页获取当前团队可见的系统技能或个人技能。 + * Tags: ['Skill', 'Read'] + * ============================================================================ */ +export const ListSkillsV2QuerySchema = createListSkillsQuerySchema(); +export type ListSkillsV2Query = z.infer; + export const ListSkillsResponseItemSchema = AgentSkillListItemSchema.omit({ createTime: true, updateTime: true diff --git a/packages/global/openapi/core/ai/skill/index.ts b/packages/global/openapi/core/ai/skill/index.ts index d85c5ec5f0e9..66dd4fed2fc9 100644 --- a/packages/global/openapi/core/ai/skill/index.ts +++ b/packages/global/openapi/core/ai/skill/index.ts @@ -19,6 +19,7 @@ import { ListSkillVersionsBodySchema, ListSkillVersionsResponseSchema, ListSkillsQuerySchema, + ListSkillsV2QuerySchema, ListSkillsResponseSchema, SaveDeploySkillBodySchema, SaveDeploySkillResponseSchema, @@ -45,7 +46,7 @@ export const SkillPath: OpenAPIPath = { '/core/ai/skill/list': { post: { summary: '获取技能列表', - description: '分页获取当前团队可见的系统技能或个人技能', + description: '获取当前团队可见的系统技能或个人技能', tags: [DevApiTagsMap.skillBasic], requestBody: { content: { @@ -66,6 +67,30 @@ export const SkillPath: OpenAPIPath = { } } }, + '/core/ai/skill/listV2': { + post: { + summary: '分页获取技能列表', + description: '分页获取当前团队可见的系统技能或个人技能', + tags: [DevApiTagsMap.skillBasic], + requestBody: { + content: { + 'application/json': { + schema: ListSkillsV2QuerySchema + } + } + }, + responses: { + 200: { + description: '成功返回分页技能列表', + content: { + 'application/json': { + schema: ListSkillsResponseSchema + } + } + } + } + } + }, '/core/ai/skill/detail': { get: { summary: '获取技能详情', diff --git a/packages/global/openapi/core/app/common/api.ts b/packages/global/openapi/core/app/common/api.ts index 256ac5461067..c8072c09cb74 100644 --- a/packages/global/openapi/core/app/common/api.ts +++ b/packages/global/openapi/core/app/common/api.ts @@ -300,7 +300,6 @@ export const ListAppBodySchema = z description: '按创建者筛选。缺省不过滤;空数组表示无选中创建者,返回空列表' }) }) - .extend(PaginationSchema.shape) .meta({ example: { parentId: '68ad85a7463006c963799a05', @@ -312,6 +311,16 @@ export const ListAppBodySchema = z }); export type ListAppBodyType = z.infer; +/* ============================================================================ + * API: 获取应用列表 V2 + * Route: POST /api/core/app/listV2 + * Method: POST + * Description: 分页获取当前团队下当前用户可读的应用或文件夹列表。 + * Tags: ['基础管理'] + * ============================================================================ */ +export const ListAppV2BodySchema = ListAppBodySchema.extend(PaginationSchema.shape); +export type ListAppV2BodyType = z.infer; + export const AppListItemSchema = z .object({ _id: ObjectIdSchema.meta({ description: '应用 ID' }), @@ -334,11 +343,16 @@ export const AppListItemSchema = z description: '应用列表项' }) as z.ZodType; -export const ListAppResponseSchema = PaginationResponseSchema(AppListItemSchema).meta({ - description: '应用列表(分页)' +export const ListAppResponseSchema = z.array(AppListItemSchema).meta({ + description: '应用列表' }); export type ListAppResponseType = z.infer; +export const ListAppV2ResponseSchema = PaginationResponseSchema(AppListItemSchema).meta({ + description: '应用列表(分页)' +}); +export type ListAppV2ResponseType = z.infer; + /* ============================================================================ * API: 获取应用详情 * Route: GET /api/core/app/detail diff --git a/packages/global/openapi/core/app/common/index.ts b/packages/global/openapi/core/app/common/index.ts index 3877a2dd492d..3aeaa36cfa70 100644 --- a/packages/global/openapi/core/app/common/index.ts +++ b/packages/global/openapi/core/app/common/index.ts @@ -13,6 +13,8 @@ import { GetAppDetailResponseSchema, ListAppBodySchema, ListAppResponseSchema, + ListAppV2BodySchema, + ListAppV2ResponseSchema, TransitionWorkflowBodySchema, TransitionWorkflowResponseSchema, UpdateAppBodySchema, @@ -45,6 +47,30 @@ export const AppCommonPath: OpenAPIPath = { } } }, + '/core/app/listV2': { + post: { + summary: '分页获取应用列表', + description: '分页获取当前团队下当前用户可读的应用或文件夹列表', + tags: [DevApiTagsMap.appCommon], + requestBody: { + content: { + 'application/json': { + schema: ListAppV2BodySchema + } + } + }, + responses: { + 200: { + description: '成功获取分页应用列表', + content: { + 'application/json': { + schema: ListAppV2ResponseSchema + } + } + } + } + } + }, '/core/app/create': { post: { summary: '创建应用', diff --git a/packages/global/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts index 42090a48fca3..268b94245901 100644 --- a/packages/global/openapi/core/dataset/api.ts +++ b/packages/global/openapi/core/dataset/api.ts @@ -212,13 +212,32 @@ export const GetDatasetListBodySchema = z description: '按创建者筛选;空数组返回空列表' }) }) - .extend(PaginationSchema.shape); + .meta({ + example: { + parentId: null, + type: DatasetTypeEnum.dataset, + searchKey: '产品文档' + } + }); export type GetDatasetListBody = z.infer; +/* ============================================================================ + * API: 获取知识库列表 V2 + * Route: POST /api/core/dataset/listV2 + * Method: POST + * Description: 分页获取当前用户有权限访问的知识库列表。 + * Tags: ['Dataset', 'Read'] + * ============================================================================ */ +export const GetDatasetListV2BodySchema = GetDatasetListBodySchema.extend(PaginationSchema.shape); +export type GetDatasetListV2Body = z.infer; + // 出参复用 DatasetListItemSchema -export const GetDatasetListResponseSchema = PaginationResponseSchema(DatasetListItemSchema); +export const GetDatasetListResponseSchema = z.array(DatasetListItemSchema); export type GetDatasetListResponse = z.infer; +export const GetDatasetListV2ResponseSchema = PaginationResponseSchema(DatasetListItemSchema); +export type GetDatasetListV2Response = z.infer; + /* ============================================================================ * API: 获取知识库路径 * Route: GET /api/core/dataset/paths diff --git a/packages/global/openapi/core/dataset/index.ts b/packages/global/openapi/core/dataset/index.ts index 97a8811d4e84..bea98aca2d26 100644 --- a/packages/global/openapi/core/dataset/index.ts +++ b/packages/global/openapi/core/dataset/index.ts @@ -14,6 +14,9 @@ import { GetDatasetDetailQuerySchema, GetDatasetDetailResponseSchema, GetDatasetListBodySchema, + GetDatasetListResponseSchema, + GetDatasetListV2BodySchema, + GetDatasetListV2ResponseSchema, GetDatasetPathsQuerySchema, UpdateDatasetBodySchema, ResumeDatasetInheritPermissionBodySchema, @@ -102,7 +105,36 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回知识库列表' + description: '成功返回知识库列表', + content: { + 'application/json': { + schema: GetDatasetListResponseSchema + } + } + } + } + } + }, + '/core/dataset/listV2': { + post: { + summary: '分页获取知识库列表', + description: '分页获取当前用户有权限访问的知识库列表', + tags: [DevApiTagsMap.datasetCommon, SystemOpenApiTagMap.dataset], + requestBody: { + content: { + 'application/json': { + schema: GetDatasetListV2BodySchema + } + } + }, + responses: { + 200: { + description: '成功返回分页知识库列表', + content: { + 'application/json': { + schema: GetDatasetListV2ResponseSchema + } + } } } } diff --git a/packages/global/test/openapi/core/ai.test.ts b/packages/global/test/openapi/core/ai.test.ts index 5967cd79b3f4..a8dd2f68a949 100644 --- a/packages/global/test/openapi/core/ai.test.ts +++ b/packages/global/test/openapi/core/ai.test.ts @@ -145,6 +145,9 @@ describe('AI OpenAPI contracts', () => { expect(openAPIDocument.paths?.['/core/ai/skill/list']?.post?.tags).toEqual([ DevApiTagsMap.skillBasic ]); + expect(openAPIDocument.paths?.['/core/ai/skill/listV2']?.post?.tags).toEqual([ + DevApiTagsMap.skillBasic + ]); expect(openAPIDocument.paths?.['/core/ai/skill/runtime/init']?.post?.tags).toEqual([ DevApiTagsMap.skillEdit ]); diff --git a/packages/global/test/openapi/core/app.test.ts b/packages/global/test/openapi/core/app.test.ts index bc30f2327b02..3f4766f2d8d7 100644 --- a/packages/global/test/openapi/core/app.test.ts +++ b/packages/global/test/openapi/core/app.test.ts @@ -29,6 +29,8 @@ const objectId = '68ad85a7463006c963799a05'; describe('App OpenAPI contracts', () => { it('registers app ownership transfer and template type APIs', () => { + expect(openAPIDocument.paths?.['/core/app/list']?.post).toBeDefined(); + expect(openAPIDocument.paths?.['/core/app/listV2']?.post).toBeDefined(); expect(openAPIDocument.paths?.['/proApi/core/app/changeOwner']?.post).toBeDefined(); expect( openAPIDocument.paths?.['/proApi/core/app/template/getTemplateTypes']?.get diff --git a/packages/global/test/openapi/core/dataset.test.ts b/packages/global/test/openapi/core/dataset.test.ts index 600e6fc1ea1c..72838597ea1f 100644 --- a/packages/global/test/openapi/core/dataset.test.ts +++ b/packages/global/test/openapi/core/dataset.test.ts @@ -47,6 +47,11 @@ describe('Dataset OpenAPI contracts', () => { expect(openAPIDocument.paths?.[path]?.[method]).toBeDefined(); }); + it('registers both legacy and paginated dataset list APIs', () => { + expect(openAPIDocument.paths?.['/core/dataset/list']?.post).toBeDefined(); + expect(openAPIDocument.paths?.['/core/dataset/listV2']?.post).toBeDefined(); + }); + it('groups Dataset permission APIs with dataset permission and shared permission tags', () => { expect(openAPIDocument.paths?.['/proApi/core/dataset/changeOwner']?.post?.tags).toEqual([ DevApiTagsMap.permissionResource, diff --git a/packages/service/core/ai/skill/manage/list.ts b/packages/service/core/ai/skill/manage/list.ts index 32496b6a08a8..c301ea3dcbfb 100644 --- a/packages/service/core/ai/skill/manage/list.ts +++ b/packages/service/core/ai/skill/manage/list.ts @@ -19,14 +19,14 @@ import { addSourceMember } from '../../../../support/user/utils'; import { sumPer } from '@fastgpt/global/support/permission/utils'; import type { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants'; import { AgentSkillSourceEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; -import type { ListSkillsQuery } from '@fastgpt/global/core/ai/skill/api'; +import type { ListSkillsV2Query } from '@fastgpt/global/core/ai/skill/api'; import { AppListSortEnum, appListSortMongoMap } from '@fastgpt/global/core/app/constants'; type TeamPermission = { isOwner: boolean; }; -type ListReadableAgentSkillsParams = ListSkillsQuery & { +type ListReadableAgentSkillsParams = ListSkillsV2Query & { teamId: string; tmbId: string; teamPer: TeamPermission; diff --git a/packages/web/hooks/useVirtualList.tsx b/packages/web/hooks/useVirtualList.tsx index d807bb102701..cf62499ec379 100644 --- a/packages/web/hooks/useVirtualList.tsx +++ b/packages/web/hooks/useVirtualList.tsx @@ -28,6 +28,7 @@ type UseVirtualListProps = { showErrorToast?: boolean; disabled?: boolean; showNoMoreTip?: boolean; + throttleWait?: number; }; type VirtualListProps = { @@ -229,7 +230,8 @@ export function useVirtualList< EmptyTip, showErrorToast = true, disabled = false, - showNoMoreTip = true + showNoMoreTip = true, + throttleWait }: UseVirtualListProps ) { const { t } = useTranslation(); @@ -243,7 +245,8 @@ export function useVirtualList< EmptyTip, showErrorToast, disabled, - showNoMoreTip + showNoMoreTip, + throttleWait } ); diff --git a/projects/app/src/components/core/app/DatasetSelectModal.tsx b/projects/app/src/components/core/app/DatasetSelectModal.tsx index 6ee47375eeb9..54fa5932556a 100644 --- a/projects/app/src/components/core/app/DatasetSelectModal.tsx +++ b/projects/app/src/components/core/app/DatasetSelectModal.tsx @@ -24,20 +24,15 @@ import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { useTranslation } from 'next-i18next'; import MyModal from '@fastgpt/web/components/common/MyModal'; import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useDatasetSelect } from '@/components/core/dataset/SelectModal'; import FolderPath from '@/components/common/folder/Path'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import QuickCreateDatasetModal from '@/pageComponents/app/detail/components/QuickCreateDatasetModal'; import { useUserStore } from '@/web/support/user/useUserStore'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; - -type SelectableDatasetListItem = DatasetListItemType & { - vectorModel: NonNullable; -}; - -/** 只有非目录且向量模型仍启用的知识库才能新增到应用配置。 */ -const isSelectableDataset = (item: DatasetListItemType): item is SelectableDatasetListItem => - item.type !== DatasetTypeEnum.folder && item.vectorModel?.isActive === true; +import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; +import { getAllDatasets } from '@/web/core/dataset/api'; // Dataset selection modal component export const DatasetSelectModal = ({ @@ -72,9 +67,24 @@ export const DatasetSelectModal = ({ setSearchKey, datasets, isFetching, - loadDatasets + loadDatasets, + ScrollData } = useDatasetSelect(); + const { gridRef, renderVirtualGridItems } = useVirtualGridList({ + list: datasets, + listKey: `dataset-select-${parentId}-${searchKey}`, + defaultColumnCount: 1, + estimatedRowHeight: 40, + estimatedRowGap: 6 + }); + const isInitialLoading = isFetching && datasets.length === 0; + const { runAsync: fetchAllDatasets, loading: isSelectingAll } = useRequest( + (data: { parentId: typeof parentId; searchKey: string }) => + getAllDatasets({ parentId: data.parentId, searchKey: data.searchKey }), + { manual: true } + ); + // The vector model of the first selected dataset const activeVectorModel = availableSelectedDatasets[0]?.vectorModel?.model; @@ -88,20 +98,23 @@ export const DatasetSelectModal = ({ // Check if a dataset is disabled (vector model mismatch) const isDatasetDisabled = (item: DatasetListItemType) => { - if (!isSelectableDataset(item)) return true; return !!activeVectorModel && activeVectorModel !== item.vectorModel.model; }; // Cache compatible datasets by vector model to avoid repeated filtering const compatibleDatasetsByModel = useMemo(() => { - const visibleDatasets = datasets.filter(isSelectableDataset); + const visibleDatasets = datasets.filter( + (item: DatasetListItemType) => item.type !== DatasetTypeEnum.folder + ); const targetModel = activeVectorModel || visibleDatasets[0]?.vectorModel?.model; if (!targetModel) { return []; } - return visibleDatasets.filter((item) => item.vectorModel.model === targetModel); + return visibleDatasets.filter( + (item: DatasetListItemType) => item.vectorModel.model === targetModel + ); }, [datasets, activeVectorModel]); // Check if all compatible datasets are selected @@ -113,17 +126,46 @@ export const DatasetSelectModal = ({ const selectedDatasetIds = new Set( availableSelectedDatasets.map((dataset) => dataset.datasetId) ); - return compatibleDatasetsByModel.every((item) => selectedDatasetIds.has(item._id)); + return compatibleDatasetsByModel.every((item: DatasetListItemType) => + selectedDatasetIds.has(item._id) + ); }, [availableSelectedDatasets, compatibleDatasetsByModel]); + const onSelectAll = async (checked: boolean) => { + try { + const allDatasets = await fetchAllDatasets({ parentId, searchKey }); + const targetModel = + activeVectorModel || + allDatasets.find((item) => item.type !== DatasetTypeEnum.folder)?.vectorModel?.model; + const compatibleDatasets = allDatasets.filter( + (item) => + item.type !== DatasetTypeEnum.folder && + !!targetModel && + item.vectorModel.model === targetModel + ); + const datasetIds = new Set(compatibleDatasets.map((item) => item._id)); + + if (checked) { + const newSelections = compatibleDatasets + .filter((item) => !isDatasetSelected(item._id)) + .map((item) => ({ + datasetId: item._id, + avatar: item.avatar, + name: item.name, + vectorModel: item.vectorModel, + isDeleted: false + })); + setSelectedDatasets((prev) => [...prev, ...newSelections]); + } else { + setSelectedDatasets((prev) => prev.filter((dataset) => !datasetIds.has(dataset.datasetId))); + } + } catch { + // useRequest 已负责提示请求错误,避免事件处理函数产生未处理 rejection。 + } + }; + const onSelect = (item: DatasetListItemType, checked: boolean) => { if (checked) { - if (!isSelectableDataset(item)) { - return toast({ - status: 'warning', - title: t('dataset:index_model_unavailable') - }); - } if (isDatasetDisabled(item)) { return toast({ status: 'warning', @@ -167,7 +209,7 @@ export const DatasetSelectModal = ({ h={'100%'} minH={'496px'} isCentered - isLoading={isFetching} + isLoading={isInitialLoading} > {/* Main vertical layout */} @@ -271,136 +313,108 @@ export const DatasetSelectModal = ({ )} {/* Dataset list */} - - {datasets.length === 0 && !isFetching && ( - - )} - {datasets.map((item: DatasetListItemType) => ( - - { - if (item.type === DatasetTypeEnum.folder) { - if (searchKey) { - setSearchKey(''); + + + {datasets.length === 0 && !isFetching && ( + + )} + {renderVirtualGridItems((item: DatasetListItemType) => ( + + { + if (item.type === DatasetTypeEnum.folder) { + if (searchKey) { + setSearchKey(''); + } + setParentId(item._id); + } else { + onSelect(item, !isDatasetSelected(item._id)); } - setParentId(item._id); - } else { - onSelect(item, !isDatasetSelected(item._id)); - } - }} - > - e.stopPropagation()} // Prevent parent click when clicking checkbox + }} > - {item.type !== DatasetTypeEnum.folder && ( - { - const checked = e.target.checked; - onSelect(item, checked); - }} - colorScheme="blue" - size="sm" - /> - )} - + e.stopPropagation()} // Prevent parent click when clicking checkbox + > + {item.type !== DatasetTypeEnum.folder && ( + { + const checked = e.target.checked; + onSelect(item, checked); + }} + colorScheme="blue" + size="sm" + /> + )} + - {/* Avatar */} - + {/* Avatar */} + - {/* Name and type */} - - - - {item.name} + {/* Name and type */} + + + + {item.name} + + + + {item.type === DatasetTypeEnum.folder ? ( + <>{t('common:Folder')} + ) : ( + <> + {t('app:Index')}: {item.vectorModel.name} + + )} - - - {item.type === DatasetTypeEnum.folder ? ( - <>{t('common:Folder')} - ) : ( - <> - {t('app:Index')}:{' '} - {item.vectorModel?.isActive - ? item.vectorModel.name - : t('dataset:index_model_unavailable')} - - )} - - {/* Folder expand arrow */} - {item.type === DatasetTypeEnum.folder && ( - - - - )} - - - ))} - + {/* Folder expand arrow */} + {item.type === DatasetTypeEnum.folder && ( + + + + )} + + + ))} + + {/* Select all / Deselect all */} {datasets.length > 0 && ( { - if (e.target.checked) { - const compatibleDatasets = compatibleDatasetsByModel.filter( - (dataset) => { - return !isDatasetSelected(dataset._id); - } - ); - const newSelections = compatibleDatasets.map((item) => ({ - datasetId: item._id, - avatar: item.avatar, - name: item.name, - vectorModel: item.vectorModel, - isDeleted: false - })); - setSelectedDatasets((prev) => [...prev, ...newSelections]); - } else { - const datasetIdsToRemove = compatibleDatasetsByModel.map( - (item) => item._id - ); - setSelectedDatasets((prev) => - prev.filter( - (dataset) => !datasetIdsToRemove.includes(dataset.datasetId) - ) - ); - } - }} + isDisabled={isSelectingAll} + onChange={(e) => onSelectAll(e.target.checked)} colorScheme="blue" size="sm" > diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx index 5632cfb4bbb4..b1a8314e8380 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx @@ -10,6 +10,7 @@ import { type AdminFbkType } from '@fastgpt/global/core/chat/type'; import SelectCollections from '@/web/core/dataset/components/SelectCollections'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; const InputDataModal = dynamic( () => import('@/pageComponents/dataset/detail/components/InputDataModal') @@ -36,7 +37,14 @@ const SelectMarkCollection = ({ }) => { const { t } = useTranslation(); const theme = useTheme(); - const { paths, setParentId, datasets, isFetching } = useDatasetSelect(); + const { paths, setParentId, datasets, isFetching, ScrollData, parentId } = useDatasetSelect(); + const { gridRef, renderVirtualGridItems } = useVirtualGridList({ + list: datasets, + listKey: `mark-dataset-select-${parentId}`, + defaultColumnCount: 3, + estimatedRowHeight: 80, + estimatedRowGap: 12 + }); return ( <> @@ -47,63 +55,54 @@ const SelectMarkCollection = ({ paths={paths} onClose={onClose} setParentId={setParentId} - isLoading={isFetching} + isLoading={isFetching && datasets.length === 0} tips={t('common:core.chat.Select dataset Desc')} > {datasets.length === 0 && } - - {datasets.map((item) => - (() => { - const modelUnavailable = - item.type !== DatasetTypeEnum.folder && item.vectorModel?.isActive !== true; - return ( - { - if (item.type === DatasetTypeEnum.folder) { - setParentId(item._id); - } else if (!modelUnavailable) { - setAdminMarkData({ ...adminMarkData, datasetId: item._id }); - } - }} - > - - - - - {item.name} - - - - - - - {item.type === DatasetTypeEnum.folder - ? t('common:Folder') - : item.vectorModel?.isActive - ? item.vectorModel.name - : t('dataset:index_model_unavailable')} + + + {renderVirtualGridItems((item) => ( + { + if (item.type === DatasetTypeEnum.folder) { + setParentId(item._id); + } else { + setAdminMarkData({ ...adminMarkData, datasetId: item._id }); + } + }} + > + + + + + {item.name} - - - ); - })() - )} - + + + + + {item.vectorModel.name} + + + ))} + + )} diff --git a/projects/app/src/components/core/dataset/SelectModal.tsx b/projects/app/src/components/core/dataset/SelectModal.tsx index aed4d00e018e..03104f993cc9 100644 --- a/projects/app/src/components/core/dataset/SelectModal.tsx +++ b/projects/app/src/components/core/dataset/SelectModal.tsx @@ -1,10 +1,11 @@ -import { getAllDatasets, getDatasetPaths } from '@/web/core/dataset/api'; +import { getDatasetPaths, getDatasetsV2 } from '@/web/core/dataset/api'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import React, { type Dispatch, useState } from 'react'; import { useTranslation } from 'next-i18next'; import { Box } from '@chakra-ui/react'; import FolderPath from '@/components/common/folder/Path'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; import type { ParentIdType, ParentTreePathItemType @@ -34,7 +35,7 @@ const DatasetSelectContainer = ({ title={ ({ + paths={paths.map((path) => ({ parentId: path.parentId, parentName: path.parentName }))} @@ -68,26 +69,27 @@ export function useDatasetSelect() { const [searchKey, setSearchKey] = useState(''); const { - data = { - datasets: [], - paths: [] - }, - loading: isFetching, - runAsync: loadDatasets - } = useRequest( - async () => { - const result = await Promise.all([ - getAllDatasets({ parentId, searchKey }), - // Only get paths when not searching - searchKey.trim() - ? Promise.resolve([]) - : getDatasetPaths({ sourceId: parentId, type: 'current' }) - ]); - return { - datasets: result[0], - paths: result[1] - }; + data: datasets, + isLoading: isLoadingDatasets, + total, + ScrollData, + fetchData, + refreshList + } = useScrollPagination(getDatasetsV2, { + params: { + parentId, + searchKey }, + pageSize: 50, + refreshDeps: [parentId, searchKey], + throttleWait: 300 + }); + + const { data: paths = [], loading: isLoadingPaths } = useRequest( + () => + searchKey.trim() + ? Promise.resolve([]) + : getDatasetPaths({ sourceId: parentId, type: 'current' }), { manual: false, refreshDeps: [parentId, searchKey] @@ -99,10 +101,14 @@ export function useDatasetSelect() { setParentId, searchKey, setSearchKey, - datasets: data.datasets, - paths: data.paths, - isFetching, - loadDatasets + datasets, + total, + paths, + isFetching: isLoadingDatasets || isLoadingPaths, + isLoadingDatasets, + ScrollData, + fetchData, + loadDatasets: refreshList }; } diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx index 47061891ac9d..e92dc8a1707e 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx @@ -17,6 +17,7 @@ import { useSkillSandboxOperationGuard } from '@/components/core/skill/useSkillS import { useUserStore } from '@/web/support/user/useUserStore'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { getSkillDetail } from '@/web/core/skill/api'; +import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; const ImportSkillModal = dynamic(() => import('@/pageComponents/dashboard/skill/ImportSkillModal')); const CreateSkillModal = dynamic(() => import('@/pageComponents/dashboard/skill/CreateSkillModal')); @@ -105,9 +106,17 @@ const SkillSelectModal = ({ paths, parentId, refreshSkillList, + ScrollData, onEnterFolder, onUpdateParentId } = useSkillSelectData(); + const { gridRef, renderVirtualGridItems } = useVirtualGridList({ + list: skillList, + listKey: `skill-select-${parentId}-${searchKey}`, + defaultColumnCount: 2, + estimatedRowHeight: 54, + estimatedRowGap: 12 + }); const isAtLimit = selectedSkills.length >= MAX_SKILL_COUNT; const showEmptyActions = !isLoadingSkillList && @@ -167,7 +176,7 @@ const SkillSelectModal = ({ title: t('common:load_failed') }); } finally { - refreshSkillList().catch(() => {}); + refreshSkillList(); } }, [isAtLimit, onAddSkill, refreshSkillList, selectedSkills, t, toast] @@ -242,43 +251,54 @@ const SkillSelectModal = ({ )} - - - {skillList.length > 0 ? ( - - {skillList.map((item) => ( - s.skillId === item._id)} - isAtLimit={isAtLimit} - onAdd={() => - onAddSkill({ - skillId: item._id, - name: item.name, - description: item.description, - avatar: item.avatar, - isDeleted: false - }) - } - onRemove={() => onRemoveSkill(item._id)} - onOpenFolder={() => onEnterFolder(item)} - /> - ))} - - ) : ( + + + + {renderVirtualGridItems((item) => ( + s.skillId === item._id)} + isAtLimit={isAtLimit} + onAdd={() => + onAddSkill({ + skillId: item._id, + name: item.name, + description: item.description, + avatar: item.avatar, + isDeleted: false + }) + } + onRemove={() => onRemoveSkill(item._id)} + onOpenFolder={() => onEnterFolder(item)} + /> + ))} + + {skillList.length === 0 && !isLoadingSkillList && ( )} - + )} @@ -364,6 +384,7 @@ const SkillCard = React.memo(function SkillCard({ } > { const parentId: ParentIdType = navStack.length > 0 ? navStack[navStack.length - 1].id : null; const { - data: skillList = [], - loading: isLoadingSkillList, - refreshAsync: refreshSkillList - } = useRequest( - async () => { - const list = await getAllSkillList({ - source: 'mine', - parentId, - searchKey: searchKey || undefined, - withAppCount: false - }); - return list; + data: skillList, + isLoading: isLoadingSkillList, + total, + ScrollData, + refreshList: refreshSkillList + } = useScrollPagination(getSkillListV2, { + params: { + source: 'mine', + parentId, + searchKey: searchKey || undefined, + withAppCount: false }, - { - manual: false, - refreshDeps: [parentId, searchKey], - throttleWait: 300 - } - ); + pageSize: 50, + refreshDeps: [parentId, searchKey], + throttleWait: 300 + }); const onEnterFolder = useCallback((item: SkillSelectItemType) => { setNavStack((prev) => [...prev, { id: item._id, name: item.name }]); @@ -64,7 +61,9 @@ export const useSkillSelectData = () => { return { skillList, + total, isLoadingSkillList, + ScrollData, searchKey, setSearchKey, paths, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx index 0eadcbd2d0d4..46eeebf3edd5 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx @@ -1,15 +1,17 @@ -import React, { useCallback, useState } from 'react'; -import { ModalBody, ModalFooter, Button } from '@chakra-ui/react'; +import React, { useState } from 'react'; +import { Avatar, Box, Button, Flex, ModalBody, ModalFooter, VStack } from '@chakra-ui/react'; import MyModal from '@fastgpt/web/components/common/MyModal'; import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type'; import { useTranslation } from 'next-i18next'; -import SelectOneResource from '@/components/common/folder/SelectOneResource'; -import { - type GetResourceFolderListProps, - type GetResourceListItemResponse -} from '@fastgpt/global/common/parentFolder/type'; -import { getAllApps } from '@/web/core/app/api'; +import FolderPath from '@/components/common/folder/Path'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useVirtualList } from '@fastgpt/web/hooks/useVirtualList'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { getAppFolderPath } from '@/web/core/app/api/app'; +import { getMyAppsV2 } from '@/web/core/app/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; const SelectAppModal = ({ value, @@ -24,26 +26,28 @@ const SelectAppModal = ({ }) => { const { t } = useTranslation(); const [selectedApp, setSelectedApp] = useState(value); + const [parentId, setParentId] = useState(''); + const filterAppIdsKey = filterAppIds.join(','); - const getAppList = useCallback( - async ({ parentId }: GetResourceFolderListProps) => { - return getAllApps({ - parentId, - type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] - }).then((res) => - res - .filter((item) => !filterAppIds.includes(item._id)) - .map((item) => ({ - id: item._id, - name: item.name, - avatar: item.avatar, - isFolder: item.type === AppTypeEnum.folder - })) - ); - }, - [filterAppIds] + const { data: paths = [] } = useRequest( + () => getAppFolderPath({ sourceId: parentId, type: 'current' }), + { + manual: false, + refreshDeps: [parentId] + } ); + const { scrollDataList, ScrollList, isLoading } = useVirtualList(getMyAppsV2, { + params: { + parentId, + type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] + }, + pageSize: 50, + itemHeight: 40, + EmptyTip: , + refreshDeps: [parentId, filterAppIdsKey] + }); + return ( - - setSelectedApp(item ? { id: item.id } : undefined)} - server={getAppList} - /> + + {paths.length > 0 && ( + + + + )} + + + {scrollDataList.map(({ data: item }) => { + if (filterAppIds.includes(item._id)) { + // 分页游标仍按服务端原始列表计算,过滤项保留固定行高避免虚拟窗口错位。 + return ; + } + const isFolder = item.type === AppTypeEnum.folder; + const isSelected = selectedApp?.id === item._id; + + return ( + { + if (isFolder) { + setParentId(item._id); + } else { + setSelectedApp({ id: item._id }); + } + }} + > + {isFolder ? ( + + ) : ( + + )} + + {item.name} + + {isFolder && } + + ); + })} + + + } + isLoading={isLoading} + onClick={() => onClickAdd(template)} + /> + + ) : template.isFolder ? ( + + ) : ( + } + isLoading={isLoading} + onClick={() => onClickAdd(template)} + /> + )} + + + + ); + }); + + const PluginListRender = useMemoizedFn(() => { return ( <> {templates.length > 0 ? ( setTooltipEnabledScopeKey(listScopeKey)} gridTemplateColumns={['minmax(0, 1fr)', 'repeat(2, minmax(0, 1fr))']} @@ -363,171 +543,9 @@ const RenderList = React.memo(function RenderList({ rowGap={3} px={[3, 6]} > - {templates.map((template) => { - const selected = selectedTools.some( - (tool) => - getToolIdentityKey(tool.pluginId, tool.source) === - getToolIdentityKey(template.id, template.source) - ); - const name = t(parseI18nString(template.name, i18n.language)); - const intro = - t(parseI18nString(template.intro || '', i18n.language)) || - t('common:core.workflow.Not intro'); - const isDebugTool = isDebugToolSource(template.source); - const isSystemSource = template.source === 'system'; - - return ( - - - - - {name} - - {isDebugTool && } - {isSystemTool && ( - - By {template.author || feConfigs?.systemTitle} - - )} - - - {intro} - - {isSystemTool && ( - - )} - - } - > - - - - - - {name} - - {feConfigs?.enable_team_plugin_upload === true && isSystemSource && ( - - )} - {isDebugTool && } - - - - {selected ? ( - } - onClick={() => onRemoveTool(template)} - /> - ) : template.flowNodeType === 'toolSet' ? ( - <> - - } - isLoading={isLoading} - onClick={() => onClickAdd(template)} - /> - - ) : template.isFolder ? ( - - ) : ( - } - isLoading={isLoading} - onClick={() => onClickAdd(template)} - /> - )} - - - - ); - })} + {renderVirtualGridItems + ? renderVirtualGridItems(renderTemplate) + : templates.map(renderTemplate)} ) : ( @@ -537,8 +555,13 @@ const RenderList = React.memo(function RenderList({ }); return ( - - + + {PluginListRender()} {type === TemplateTypeEnum.systemTools && ( diff --git a/projects/app/src/pages/api/core/app/listV2.ts b/projects/app/src/pages/api/core/app/listV2.ts index 855699c54a88..04fc5d3a9843 100644 --- a/projects/app/src/pages/api/core/app/listV2.ts +++ b/projects/app/src/pages/api/core/app/listV2.ts @@ -40,6 +40,7 @@ async function handler(req: ApiRequestProps): Promise): Promise & { + parentType?: AppTypeEnum; + type?: AppTypeEnum[]; +}; + +type AppListItem = ListAppV2ResponseType['list'][number]; + +const mapAppToTeamTemplate = (app: AppListItem) => ({ + tmbId: app.tmbId, + id: app._id, + pluginId: app._id, + isFolder: + app.type === AppTypeEnum.folder || + app.type === AppTypeEnum.toolFolder || + app.type === AppTypeEnum.httpToolSet || + app.type === AppTypeEnum.httpPlugin || + app.type === AppTypeEnum.mcpToolSet, + templateType: FlowNodeTemplateTypeEnum.teamApp, + flowNodeType: + app.type === AppTypeEnum.workflow + ? FlowNodeTypeEnum.appModule + : app.type === AppTypeEnum.mcpToolSet || app.type === AppTypeEnum.httpToolSet + ? FlowNodeTypeEnum.toolSet + : FlowNodeTypeEnum.pluginModule, + avatar: app.avatar, + name: app.name, + intro: app.intro, + showStatus: false, + version: app.pluginData?.nodeVersion, + isTool: true, + sourceMember: app.sourceMember, + appType: app.type +}); + /* ============ team plugin ============== */ /** parentType 来自已加载的列表项;普通目录和 Agent 查询直接走 list,不额外探测父资源。 */ export const getTeamAppTemplates = async (data?: { @@ -82,6 +121,45 @@ export const getTeamAppTemplates = async (data?: { ); }; +/** Load one paginated team app template page while preserving toolset child behavior. */ +export const getTeamAppTemplatesV2 = async ( + data: TeamAppTemplatesV2Query +): Promise> => { + const { parentId, parentType, searchKey, type, excludeAppId, ...pagination } = data; + + if (parentId) { + const resolvedParentType = parentType ?? (await getAppDetailById(parentId)).type; + if ( + resolvedParentType === AppTypeEnum.mcpToolSet || + resolvedParentType === AppTypeEnum.httpToolSet + ) { + const list = await getTeamAppTemplates({ + parentId, + parentType: resolvedParentType, + searchKey, + type + }); + return { + list, + total: list.length + }; + } + } + + const response = await getMyAppsV2({ + ...pagination, + parentId, + searchKey, + type, + excludeAppId + }); + + return { + list: response.list.map(mapAppToTeamTemplate), + total: response.total + }; +}; + /* ============ Tool ============== */ export const getAppToolTemplates = (data: GetSystemToolTemplatesBodyType) => POST('/core/app/tool/getSystemToolTemplates', data); diff --git a/projects/app/test/api/core/app/list.test.ts b/projects/app/test/api/core/app/list.test.ts index e2d98704c31d..cb9561bd8131 100644 --- a/projects/app/test/api/core/app/list.test.ts +++ b/projects/app/test/api/core/app/list.test.ts @@ -252,6 +252,36 @@ describe('POST /api/core/app/list', () => { expect(emptied.data).toEqual([]); }); + it('excludes an app before applying pagination in V2', async () => { + const user = await getUser(`app-list-v2-exclude-${getNanoid(6)}`); + const [excludedApp] = await MongoApp.create( + [3, 2, 1].map((index) => ({ + name: `App ${index}`, + type: AppTypeEnum.simple, + teamId: user.teamId, + tmbId: user.tmbId, + updateTime: new Date(`2024-01-0${index}T00:00:00.000Z`) + })) + ); + const res = await Call, ListAppV2ResponseType>( + handlerV2, + { + auth: user, + body: { + type: AppTypeEnum.simple, + pageNum: 1, + pageSize: 1, + excludeAppId: String(excludedApp._id) + } + } + ); + + expect(res.code).toBe(200); + expect(res.data.total).toBe(2); + expect(res.data.list).toHaveLength(1); + expect(res.data.list[0].name).toBe('App 2'); + }); + it('normalizes nullish avatar and intro from legacy records in V2', async () => { const user = await getUser(`app-list-v2-legacy-${getNanoid(6)}`); const app = await MongoApp.create({ From 678ed26ef7adfa8495500e06fc41cde4ee3e48f3 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 13:24:34 +0800 Subject: [PATCH 11/38] fix(app): constrain tool selector modal height --- .../Edit/FormComponent/ToolSelector/ToolSelectModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx index 29aed1869142..e5fb1052e5a3 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx @@ -200,7 +200,7 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) maxW={['90vw', '700px']} w={'700px'} h={['90vh', '80vh']} - bodyStyles={{ p: 0, overflow: 'hidden' }} + bodyStyles={{ p: 0, minH: 0, overflow: 'hidden' }} > {/* Header: row and search */} @@ -259,7 +259,7 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) )} - + {isTeamTemplate ? ( - + {PluginListRender()} {type === TemplateTypeEnum.systemTools && ( From aa10e052bd357779c45149bcb69d0d1c9f289f13 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 13:33:48 +0800 Subject: [PATCH 12/38] feat(app): paginate resource selector navigation --- .../core/resource-list-pagination-frontend.md | 67 +++-- packages/web/i18n/en/common.json | 1 + packages/web/i18n/ko-KR/common.json | 1 + packages/web/i18n/zh-CN/common.json | 1 + packages/web/i18n/zh-Hant/common.json | 1 + .../app/src/components/Select/AppSelect.tsx | 42 +-- .../components/common/folder/MoveModal.tsx | 170 ++--------- .../common/folder/SelectOneResource.tsx | 284 ++++++++++-------- .../src/pageComponents/chat/ChatHeader.tsx | 34 ++- .../dashboard/agent/context.tsx | 41 ++- .../pageComponents/dashboard/skill/List.tsx | 35 ++- .../pageComponents/dataset/list/context.tsx | 39 ++- .../app/src/pages/dashboard/skill/index.tsx | 4 +- projects/app/src/pages/dataset/list/index.tsx | 73 +++-- 14 files changed, 383 insertions(+), 410 deletions(-) diff --git a/.agents/design/core/resource-list-pagination-frontend.md b/.agents/design/core/resource-list-pagination-frontend.md index a3079c4257d9..ee3c9bdc6f54 100644 --- a/.agents/design/core/resource-list-pagination-frontend.md +++ b/.agents/design/core/resource-list-pagination-frontend.md @@ -5,7 +5,7 @@ App、Dataset、Skill 列表同时存在两类前端使用场景: 1. Dashboard 主页面展示资源卡片,需要避免一次性拉取整个团队资源,并支持触底加载。 -2. 选择器和工具配置弹窗中的当前目录列表可以增量加载;递归资源树和移动目录树继续使用数组协议。 +2. 选择器和移动弹窗中的当前目录列表可以增量加载,目录层级通过面包屑切换,不再在当前列表内递归展开。 本次采用 V1/V2 分工: @@ -36,10 +36,11 @@ Dashboard App Context -> useScrollPagination -> /core/app/listV2 -递归树 / 移动弹窗 / 未改造 Tool 选择 - -> getAllApps - -> getMyApps - -> /core/app/list +面包屑目录选择 / 移动弹窗 + -> SelectOneResource + -> useScrollPagination + -> getMyAppsV2 + -> /core/app/listV2 扁平选择列表 -> useVirtualList @@ -102,8 +103,8 @@ Dashboard App Context 移动文件夹: -- `context.tsx` 中的 `getAppFolderList` 保持使用 `getAllApps`。 -- `MoveModal` 当前只接受文件夹数组,没有分页协议,不能直接换成 `getMyAppsV2`。 +- `context.tsx` 中的 `getAppFolderList` 使用 `getMyAppsV2`,只返回当前目录的文件夹页。 +- `MoveModal` 复用 `SelectOneResource`,支持分页、面包屑、目录选择、权限禁用和禁止移动到自身。 ### 3.2 Dataset Dashboard @@ -127,7 +128,7 @@ Dashboard App Context - `projects/app/src/components/core/dataset/SelectModal.tsx` 的 `useDatasetSelect` 使用 `getDatasetsV2` 和 `useScrollPagination`。 - `projects/app/src/components/core/app/DatasetSelectModal.tsx`、`SelectMarkCollection.tsx` 使用 `ScrollData` 和 `useVirtualGridList`。 -- `projects/app/src/pageComponents/dataset/list/context.tsx` 的文件夹列表使用 `getAllDatasets`。 +- `projects/app/src/pageComponents/dataset/list/context.tsx` 的文件夹列表使用 `getDatasetsV2`。 - `projects/app/src/web/core/dataset/store/dataset.ts` 无仓库引用,已删除。 ### 3.3 Skill Dashboard @@ -153,7 +154,7 @@ Dashboard App Context - `projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/hooks/useSkillSelectData.ts` 使用 `getSkillListV2` 和 `useScrollPagination`。 - `SkillSelectModal.tsx` 使用 `ScrollData` 和 `useVirtualGridList`。 - `projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx` 使用 `getAllSkillList` 建立 Skill 映射和目录加载结果。 -- `projects/app/src/pageComponents/dashboard/skill/List.tsx` 的移动文件夹列表使用 `getSkillFolderList`,间接使用 `getAllSkillList`。 +- `projects/app/src/pageComponents/dashboard/skill/List.tsx` 的移动文件夹列表使用 `getSkillListV2`。 ## 4. 已改为分页的选择列表 @@ -168,6 +169,8 @@ Dashboard App Context Workflow App 选择器采用面包屑加当前目录列表,保留单选和过滤应用 ID 行为。 +`components/Select/AppSelect.tsx` 和 `pageComponents/chat/ChatHeader.tsx` 复用 `SelectOneResource`,用于评测应用和移动端 Chat 应用切换,同样按当前目录通过 `getMyAppsV2` 分页。 + ### Dataset 和 Skill - Dataset 选择共享 `useDatasetSelect` 的分页状态,两个 Dataset 选择场景使用虚拟网格。 @@ -182,8 +185,6 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 | 文件 | 使用场景 | 保留原因 | | --- | --- | --- | -| `components/Select/AppSelect.tsx` | 通用 App 树选择 | `SelectOneResource.server` 接受数组 | -| `pageComponents/chat/ChatHeader.tsx` | Chat 全部应用目录 | 展开目录时一次渲染当前层级 | | `web/core/app/api/tool.ts` | Team Tool/Agent 模板 | 对外继续返回模板数组 | ### Dataset 直接调用点 @@ -191,7 +192,6 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 | 文件 | 使用场景 | 保留原因 | | --- | --- | --- | | `components/core/app/DatasetSelectModal.tsx` | 全选/取消全选 | 明确操作需要当前条件下的完整候选集 | -| `pageComponents/dataset/list/context.tsx` | 移动 Dataset 文件夹 | `MoveModal.server` 接受数组 | ### Skill 直接调用点 @@ -199,9 +199,8 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 | --- | --- | --- | | `hooks/useSkillManager.tsx` | ChatAgent Skill 管理 | 需要完整数组构建 Skill 映射 | | `packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx` | PromptEditor Skill 树 | 插件维护递归目录和键盘索引 | -| `dashboard/skill/List.tsx` | 移动 Skill 文件夹 | `MoveModal.server` 接受数组 | -## 6. 当前保留树协议的组件 +## 6. 当前目录分页组件 ### `SelectOneResource` @@ -210,11 +209,13 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 当前协议: ```ts -server: (props: GetResourceFolderListProps) => - Promise +server: ( + props: PaginationProps, + cancelToken?: AbortController +) => Promise> ``` -每个目录节点展开时请求一次,并直接把返回数组写入 `children`。改为 V2 后需要额外维护每个目录的 `offset`、`total`、加载状态和追加逻辑,属于组件级改造。 +组件只维护当前目录路径和当前目录的分页列表。点击目录行进入下一级,点击右侧箭头也可下钻,点击面包屑返回上级;目录切换会取消旧请求并从 `offset=0` 加载新目录。 ### `MoveModal` @@ -223,15 +224,14 @@ server: (props: GetResourceFolderListProps) => 当前协议: ```ts -server: (props: GetResourceFolderListProps) => - Promise +server: SelectOneResourceServer ``` -移动目录树同样直接渲染完整数组,没有分页游标或加载更多节点。当前继续使用旧版全量接口。 +移动弹窗通过 `selectFolder` 开启目录选择模式,根目录映射为 `null`,禁用无权限目录和当前资源,并复用同一分页列表组件。 ### `ChatHeader` -`ChatHeader` 当前继续使用 `SelectOneResource`。该组件的目录展开是递归 children 数组模型,后续若要分页,需要同时扩展 `SelectOneResource` 的节点级分页状态和加载更多交互。 +`ChatHeader` 和评测页的 `AppSelect` 均使用 `SelectOneResource` 的面包屑 + 单层列表模式,目录请求走 App V2 分页接口。 ## 7. 验证清单 @@ -248,9 +248,9 @@ server: (props: GetResourceFolderListProps) => - Dashboard 三个列表首次只请求一页。 - Dashboard 继续滚动后能追加下一页,直到 `data.length >= total`。 - 搜索和切换目录会清空旧数据并重新请求第一页。 -- 树选择器、移动弹窗和 PromptEditor Skill 树仍能获得 V1 数组。 -- 扁平选择列表首次只请求一页,继续滚动能追加下一页,直到 `data.length >= total`。 -- 扁平选择列表搜索和目录切换会重新请求第一页,跨页已选项保持有效。 +- 面包屑选择器和移动弹窗首次只请求当前目录一页,继续滚动能追加下一页,直到 `data.length >= total`。 +- 面包屑选择器切换目录会重新请求 `offset=0`,移动弹窗可选择根目录 `null`、禁用目录和当前资源。 +- PromptEditor Skill 树、Dataset 全选等仍按语义继续使用 V1 全量数组。 ### 测试文件 @@ -267,9 +267,9 @@ pnpm --dir projects/app test test/api/core/ai/skill/list.test.ts pnpm --dir projects/app typecheck ``` -## 8. 后续独立改造建议 +## 8. 实现约束 -如果后续需要降低选择器的大数据量请求,应单独扩展资源树组件协议,例如: +需要保留递归树语义的调用方继续使用 V1 数组 wrapper;不能把 V2 第一页直接伪装成全量数组。当前目录分页组件的通用协议如下: ```ts type ResourcePage = { @@ -277,11 +277,14 @@ type ResourcePage = { total: number; }; -type ResourceServer = (props: { - parentId: ParentIdType; - offset: number; - pageSize: number; -}) => Promise; +type ResourceServer = ( + props: { + parentId: ParentIdType; + offset: number; + pageSize: number; + }, + cancelToken?: AbortController +) => Promise; ``` -该改造需要同时处理目录级分页、搜索结果、已选项回显、加载状态和空状态,不能在本次只替换 API wrapper 的范围内完成。 +`SelectOneResource` 已完成目录级分页、面包屑切换、选中态、禁用态、加载态和空列表处理;`MoveModal` 通过 `selectFolder` 复用该组件。需要递归树语义的调用方仍应使用 V1 数组 wrapper。 diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0729a61b27b6..5fe40a1d9f65 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -1039,6 +1039,7 @@ "resume_failed": "Resume Failed", "resume_completed_data_parse_failed": "Couldn't parse the completed response while resuming", "response_processing_error": "An error occurred while processing the response", + "current_location": "Current location:", "root_folder": "Root", "save_failed": "save_failed", "server_error": "Server error", diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index a49d0ae247c5..b7e055b7cc7e 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -1036,6 +1036,7 @@ "resume_failed": "재개 실패", "resume_completed_data_parse_failed": "재개 중 완료된 응답을 분석하지 못했습니다", "response_processing_error": "응답 처리 중 오류가 발생했습니다", + "current_location": "현재 위치:", "root_folder": "루트", "save_failed": "저장 실패", "server_error": "서버 오류", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 9cff264ac8c2..aac2034a52f1 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -1039,6 +1039,7 @@ "resume_failed": "恢复失败", "resume_completed_data_parse_failed": "恢复完成态数据解析失败", "response_processing_error": "响应处理异常", + "current_location": "当前位置:", "root_folder": "根目录", "save_failed": "保存异常", "server_error": "服务器异常", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index c9f3cc8e3ade..7224e4feb906 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -1039,6 +1039,7 @@ "resume_failed": "恢復失敗", "resume_completed_data_parse_failed": "恢復完成態資料解析失敗", "response_processing_error": "處理回應時發生錯誤", + "current_location": "目前位置:", "root_folder": "根目錄", "save_failed": "儲存失敗", "server_error": "伺服器錯誤", diff --git a/projects/app/src/components/Select/AppSelect.tsx b/projects/app/src/components/Select/AppSelect.tsx index 8c45e1227191..4091561d8a5d 100644 --- a/projects/app/src/components/Select/AppSelect.tsx +++ b/projects/app/src/components/Select/AppSelect.tsx @@ -3,34 +3,40 @@ import MyPopover from '@fastgpt/web/components/common/MyPopover'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useCallback, useState } from 'react'; import { useTranslation } from 'next-i18next'; -import SelectOneResource from '../common/folder/SelectOneResource'; -import type { - GetResourceFolderListProps, - GetResourceListItemResponse -} from '@fastgpt/global/common/parentFolder/type'; -import { getAllApps } from '@/web/core/app/api'; +import SelectOneResource, { + type SelectOneResourceItemType, + type SelectOneResourceServer +} from '../common/folder/SelectOneResource'; +import { getMyAppsV2 } from '@/web/core/app/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import Avatar from '@fastgpt/web/components/common/Avatar'; const AppSelect = ({ value, onSelect }: { value: string; onSelect: (id: string) => void }) => { - const [currentApp, setCurrentApp] = useState(null); + const [currentApp, setCurrentApp] = useState(null); const { t } = useTranslation(); - const getAppList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { - return getAllApps({ - parentId, - type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] - }).then((res) => - res - .filter((item) => !item.hasInteractiveNode) - .map((item) => ({ + const getAppList = useCallback( + ({ parentId, offset, pageSize }, cancelToken) => + getMyAppsV2( + { + parentId, + type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow], + offset, + pageSize + }, + cancelToken + ).then(({ list, total }) => ({ + total, + list: list.map((item) => ({ id: item._id, name: item.name, avatar: item.avatar, - isFolder: item.type === AppTypeEnum.folder + isFolder: item.type === AppTypeEnum.folder, + disabled: item.hasInteractiveNode })) - ); - }, []); + })), + [] + ); return ( Promise; + server: SelectOneResourceServer; onConfirm: (id: ParentIdType) => Promise; onClose: () => void; moveHint?: string; @@ -33,138 +23,30 @@ type Props = { const MoveModal = ({ moveResourceId, title, server, onConfirm, onClose, moveHint }: Props) => { const { t } = useTranslation(); - const [selectedId, setSelectedId] = React.useState(); - const [requestingIdList, setRequestingIdList] = useState([]); - const [folderList, setFolderList] = useState([]); + const [selectedId, setSelectedId] = useState(); + const hasSelection = selectedId !== undefined; - const { runAsync: requestServer } = useRequest((e: GetResourceFolderListProps) => { - if (requestingIdList.includes(e.parentId)) return Promise.reject(null); - - setRequestingIdList((state) => [...state, e.parentId]); - return server(e).finally(() => - setRequestingIdList((state) => state.filter((id) => id !== e.parentId)) - ); - }, {}); - - useMount(async () => { - const data = await requestServer({ parentId: null }); - setFolderList([ - { - id: rootId, - name: t('common:root_folder'), - open: true, - children: data.map((item) => ({ - id: item.id, - name: item.name, - open: false - })) - } - ]); - }); - - const RenderList = useMemoizedFn( - ({ list, index = 0 }: { list: FolderItemType[]; index?: number }) => { - return ( - <> - {list - // can not move to itself - .filter((item) => moveResourceId !== item.id) - .map((item) => ( - - setSelectedId(undefined) - } - : { - onClick: () => setSelectedId(item.id) - })} - > - {index !== 0 && ( - 0 ? 'visible' : 'hidden'} - w={'1.25rem'} - h={'1.25rem'} - cursor={'pointer'} - borderRadius={'xs'} - _hover={{ - bg: 'rgba(31, 35, 41, 0.08)' - }} - onClick={async (e) => { - e.stopPropagation(); - if (requestingIdList.includes(item.id)) return; - - if (!item.children) { - const data = await requestServer({ parentId: item.id }); - item.children = data.map((item) => ({ - id: item.id, - name: item.name, - open: false - })); - } - item.open = !item.open; - setFolderList([...folderList]); - }} - > - - - )} - - - {item.name} - - - {item.children && item.open && ( - - - - )} - - ))} - - ); + const onSelect = (item?: SelectOneResourceItemType) => { + if (!item) { + setSelectedId(undefined); + return; } - ); + setSelectedId(item.id === rootId ? null : item.id); + }; const { runAsync: onConfirmSelect, loading: confirming } = useRequest( () => { - if (selectedId) { - return onConfirm(selectedId === rootId ? null : selectedId); - } - return Promise.reject(''); + if (!hasSelection) return Promise.reject(''); + return onConfirm(selectedId); }, { - onSuccess: () => { - onClose(); - }, + onSuccess: onClose, successToast: t('common:move_success') } ); return ( {t('common:Cancel')} - @@ -196,9 +78,17 @@ const MoveModal = ({ moveResourceId, title, server, onConfirm, onClose, moveHint borderRadius={'md'} p={3} flex={'1 0 0'} - overflowY={'auto'} + minH={0} + overflow={'hidden'} > - + ); diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index 5ebbcb98b7bb..f376a167f6eb 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -5,163 +5,189 @@ import { type GetResourceListItemResponse, type ParentIdType } from '@fastgpt/global/common/parentFolder/type'; +import type { PaginationProps, PaginationResponseType } from '@fastgpt/global/openapi/api'; import MyIcon from '@fastgpt/web/components/common/Icon'; -import Loading from '@fastgpt/web/components/common/MyLoading'; import Avatar from '@fastgpt/web/components/common/Avatar'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { useMemoizedFn } from 'ahooks'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants'; import { useTranslation } from 'next-i18next'; -type ResourceItemType = GetResourceListItemResponse & { - open: boolean; - children?: ResourceItemType[]; +export type SelectOneResourceItemType = GetResourceListItemResponse & { + disabled?: boolean; }; +export type SelectOneResourceServer = ( + data: PaginationProps, + cancelToken?: AbortController +) => Promise>; + +type ResourcePathItemType = SelectOneResourceItemType; + const rootId = 'root'; const SelectOneResource = ({ server, value, onSelect, - maxH = ['80vh', '600px'] + maxH = ['80vh', '600px'], + selectFolder = false, + disabledIds = [] }: { - server: (e: GetResourceFolderListProps) => Promise; + server: SelectOneResourceServer; value?: ParentIdType; - onSelect: (e?: ResourceItemType) => any; + onSelect: (e?: SelectOneResourceItemType) => any; maxH?: BoxProps['maxH']; + selectFolder?: boolean; + disabledIds?: string[]; }) => { const { t } = useTranslation(); - const [dataList, setDataList] = useState([]); - const [requestingIdList, setRequestingIdList] = useState([]); - - const concatRoot = useMemo(() => { - const root: ResourceItemType = { + const rootItem = useMemo( + () => ({ id: rootId, - open: true, avatar: FolderImgUrl, name: t('common:root_folder'), - isFolder: true, - children: dataList - }; - return [root]; - }, [dataList, t]); - - const { runAsync: requestServer } = useRequest((e: GetResourceFolderListProps) => { - if (requestingIdList.includes(e.parentId)) return Promise.reject(null); - - setRequestingIdList((state) => [...state, e.parentId]); - return server(e).finally(() => - setRequestingIdList((state) => state.filter((id) => id !== e.parentId)) - ); - }, {}); - - const { loading } = useRequest(() => requestServer({ parentId: null }), { - manual: false, - onSuccess: (data) => { - setDataList( - data.map((item) => ({ - ...item, - open: false - })) - ); - } + isFolder: true + }), + [t] + ); + const [path, setPath] = useState([rootItem]); + const currentParentId = path[path.length - 1]?.id === rootId ? null : path[path.length - 1]?.id; + + const { data, ScrollData } = useScrollPagination(server, { + pageSize: 50, + params: { parentId: currentParentId }, + refreshDeps: [currentParentId], + showNoMoreTip: false }); - const Render = useMemoizedFn( - ({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => { - return ( - <> - {list.map((item) => ( - - onSelect(undefined) - } - : { - onClick: async () => { - if (item.id === rootId) return; - // folder => open(request children) or close - if (item.isFolder) { - if (!item.children) { - const data = await requestServer({ parentId: item.id }); - item.children = data.map((item) => ({ - ...item, - open: false - })); - } - - item.open = !item.open; - setDataList([...dataList]); - } else { - onSelect(item); - } - } - })} - > - {index !== 0 && ( - - - - )} + const isItemDisabled = (item: SelectOneResourceItemType) => + item.disabled || disabledIds.includes(item.id); + + const selectRoot = () => { + if (selectFolder) { + onSelect(value === null ? undefined : rootItem); + } + setPath([rootItem]); + }; + + const enterFolder = (item: ResourcePathItemType) => { + setPath((state) => (state[state.length - 1]?.id === item.id ? state : [...state, item])); + }; + + const onClickItem = (item: SelectOneResourceItemType) => { + if (isItemDisabled(item)) return; + + if (item.isFolder) { + if (selectFolder) { + onSelect(item.id === value ? undefined : item); + } else { + enterFolder(item); + } + return; + } + + onSelect(item.id === value ? undefined : item); + }; + + const enterFolderFromArrow = (item: SelectOneResourceItemType) => { + if (!item.isFolder || isItemDisabled(item)) return; + enterFolder(item); + }; + + return ( + + + + {t('common:current_location')} + + {path.map((item, index) => ( + + {index > 0 && } + { + if (index === 0) { + selectRoot(); + return; + } + setPath((state) => state.slice(0, index + 1)); + }} + > + {item.name} + + + ))} + + + + {data.map((item) => { + const disabled = isItemDisabled(item); + const selected = item.id === value; + return ( + onClickItem(item)} + > + - - {item.name} - - {item.children && item.open && ( - - - + + {item.name} + + {item.isFolder && ( + { + e.stopPropagation(); + enterFolderFromArrow(item); + }} + > + + )} - - ))} - - ); - } - ); - - return loading ? ( - - ) : ( - - + + ); + })} + ); }; diff --git a/projects/app/src/pageComponents/chat/ChatHeader.tsx b/projects/app/src/pageComponents/chat/ChatHeader.tsx index ff7430ac5731..66ce07bb745c 100644 --- a/projects/app/src/pageComponents/chat/ChatHeader.tsx +++ b/projects/app/src/pageComponents/chat/ChatHeader.tsx @@ -14,12 +14,11 @@ import { AppFolderTypeList, AppTypeEnum } from '@fastgpt/global/core/app/constan import { useSystem } from '@fastgpt/web/hooks/useSystem'; import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs'; import { useRouter } from 'next/router'; -import { - type GetResourceFolderListProps, - type GetResourceListItemResponse -} from '@fastgpt/global/common/parentFolder/type'; -import { getAllApps } from '@/web/core/app/api'; -import SelectOneResource from '@/components/common/folder/SelectOneResource'; +import SelectOneResource, { + type SelectOneResourceItemType, + type SelectOneResourceServer +} from '@/components/common/folder/SelectOneResource'; +import { getMyAppsV2 } from '@/web/core/app/api'; import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover'; import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; @@ -126,16 +125,19 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app const [currentTab, setCurrentTab] = useState(TabEnum.recently); - const getAppList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { - return getAllApps({ parentId }).then((res) => - res.map((item) => ({ - id: item._id, - name: item.name, - avatar: item.avatar, - isFolder: AppFolderTypeList.includes(item.type) - })) - ); - }, []); + const getAppList = useCallback( + ({ parentId, offset, pageSize }, cancelToken) => + getMyAppsV2({ parentId, offset, pageSize }, cancelToken).then(({ list, total }) => ({ + total, + list: list.map((item) => ({ + id: item._id, + name: item.name, + avatar: item.avatar, + isFolder: AppFolderTypeList.includes(item.type) + })) + })), + [] + ); const handlePaneChange = useContextSelector(ChatPageContext, (v) => v.handlePaneChange); diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 8e9a2633f61c..9a8146b1500f 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -3,17 +3,18 @@ import { createContext } from 'use-context-selector'; import { useRouter } from 'next/router'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useScrollPagination, type ScrollListType } from '@fastgpt/web/hooks/useScrollPagination'; -import { getAllApps, getAppDetailById, getMyAppsV2, putAppById } from '@/web/core/app/api'; +import { getAppDetailById, getMyAppsV2, putAppById } from '@/web/core/app/api'; +import type { SelectOneResourceServer } from '@/components/common/folder/SelectOneResource'; import { type AppDetailType, type AppListItemType } from '@fastgpt/global/core/app/type'; import { getAppFolderPath } from '@/web/core/app/api/app'; import { - type GetResourceFolderListProps, type ParentIdType, type ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; import { type UpdateAppBodyType } from '@fastgpt/global/openapi/core/app/common/api'; import dynamic from 'next/dynamic'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useTranslation } from 'next-i18next'; @@ -231,23 +232,31 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { [moveAppId, onUpdateApp] ); - const getAppFolderList = useCallback( - ({ parentId }: GetResourceFolderListProps) => { + const getAppFolderList = useCallback( + ({ parentId, offset, pageSize }, cancelToken) => { const folderType = isAgentPage ? AppTypeEnum.folder : AppTypeEnum.toolFolder; - return getAllApps({ - parentId, - type: folderType - }).then((res) => - res - .filter((item) => item.permission.hasWritePer) - .map((item) => ({ - id: item._id, - name: item.name - })) - ); + return getMyAppsV2( + { + parentId, + type: folderType, + offset, + pageSize, + excludeAppId: moveAppId + }, + cancelToken + ).then(({ list, total }) => ({ + total, + list: list.map((item) => ({ + id: item._id, + name: item.name, + avatar: FolderImgUrl, + isFolder: true, + disabled: !item.permission.hasWritePer + })) + })); }, - [isAgentPage] + [isAgentPage, moveAppId] ); useEffect(() => { diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index 2f30a89c450b..e857fa8ab2e5 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -24,7 +24,7 @@ import { postUpdateSkill, postCopySkill, getAppsBySkillId, - getSkillFolderList, + getSkillListV2, resumeInheritPer, postChangeSkillOwner } from '@/web/core/skill/api'; @@ -38,10 +38,9 @@ import MyPopover from '@fastgpt/web/components/common/MyPopover'; import type { ListAppsBySkillIdResponse } from '@fastgpt/global/core/ai/skill/api'; import dynamic from 'next/dynamic'; import type { EditResourceInfoFormType } from '@/components/common/Modal/EditResourceModal'; -import type { - GetResourceFolderListProps, - ParentIdType -} from '@fastgpt/global/common/parentFolder/type'; +import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; +import type { SelectOneResourceServer } from '@/components/common/folder/SelectOneResource'; +import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants'; import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; import SkillDashboardEmptyHero from '@/pageComponents/dashboard/skill/SkillDashboardEmptyHero'; @@ -310,11 +309,29 @@ const List = ({ ); // 获取技能文件夹列表 - const getSkillFolderListForMove = useMemo( + const getSkillFolderListForMove = useMemo( () => - ({ parentId }: GetResourceFolderListProps) => - getSkillFolderList({ parentId }), - [] + ({ parentId, offset, pageSize }, cancelToken) => + getSkillListV2( + { + source: 'mine', + type: AgentSkillTypeEnum.folder, + parentId, + offset, + pageSize + }, + cancelToken + ).then(({ list, total }) => ({ + total, + list: list.map((item) => ({ + id: item._id, + name: item.name, + avatar: FolderImgUrl, + isFolder: true, + disabled: item._id === moveSkillId || !item.permission.hasWritePer + })) + })), + [moveSkillId] ); const renderSkillCard = (skill: (typeof skills)[number]) => { diff --git a/projects/app/src/pageComponents/dataset/list/context.tsx b/projects/app/src/pageComponents/dataset/list/context.tsx index 844f691ff3e7..3a10d5e08f64 100644 --- a/projects/app/src/pageComponents/dataset/list/context.tsx +++ b/projects/app/src/pageComponents/dataset/list/context.tsx @@ -1,6 +1,5 @@ 'use client'; import { - getAllDatasets, getDatasetPaths, putDatasetById, getDatasetsV2, @@ -8,10 +7,10 @@ import { delDatasetById } from '@/web/core/dataset/api'; import { - type GetResourceFolderListProps, type ParentIdType, type ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; +import type { SelectOneResourceServer } from '@/components/common/folder/SelectOneResource'; import { normalizeParentId } from '@fastgpt/global/common/parentFolder/depth'; import { useRouter } from 'next/router'; import React, { useCallback, useState } from 'react'; @@ -21,6 +20,7 @@ import { useScrollPagination, type ScrollListType } from '@fastgpt/web/hooks/use import { type UpdateDatasetBody } from '@fastgpt/global/openapi/core/dataset/api'; import dynamic from 'next/dynamic'; import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants'; import { type DatasetItemType, type DatasetListItemType } from '@fastgpt/global/core/dataset/type'; import { type EditResourceInfoFormType } from '@/components/common/Modal/EditResourceModal'; import { useTranslation } from 'next-i18next'; @@ -193,19 +193,28 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { [moveDatasetId, onUpdateDataset] ); - const getDatasetFolderList = useCallback(async ({ parentId }: GetResourceFolderListProps) => { - return ( - await getAllDatasets({ - parentId, - type: DatasetTypeEnum.folder - }) - ) - .filter((item) => item.permission.hasManagePer) - .map((item) => ({ - id: item._id, - name: item.name - })); - }, []); + const getDatasetFolderList = useCallback( + ({ parentId, offset, pageSize }, cancelToken) => + getDatasetsV2( + { + parentId, + type: DatasetTypeEnum.folder, + offset, + pageSize + }, + cancelToken + ).then(({ list, total }) => ({ + total, + list: list.map((item) => ({ + id: item._id, + name: item.name, + avatar: FolderImgUrl, + isFolder: true, + disabled: item._id === moveDatasetId || !item.permission.hasManagePer + })) + })), + [moveDatasetId] + ); const [editedDataset, setEditedDataset] = useState(); diff --git a/projects/app/src/pages/dashboard/skill/index.tsx b/projects/app/src/pages/dashboard/skill/index.tsx index e17fa1b5659a..4152d15f78e8 100644 --- a/projects/app/src/pages/dashboard/skill/index.tsx +++ b/projects/app/src/pages/dashboard/skill/index.tsx @@ -92,7 +92,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { pr={[3, 6]} pl={6} pt={6} - overflowY={'auto'} + overflowY={'hidden'} overflowX={'hidden'} > @@ -171,7 +171,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + { ); return ( - - - - - - - - {t('common:core.dataset.My Dataset')} - - - } - onClick={(e) => { - router.push({ - query: { - parentId: e - } - }); - }} - /> - + + + + + + + {t('common:core.dataset.My Dataset')} + + + } + onClick={(e) => { + router.push({ + query: { + parentId: e + } + }); + }} + /> {isPc && ( <> @@ -242,13 +249,13 @@ const Dataset = () => { {!isPc && {RenderSearchInput}} - + - + {!!folderDetail && isPc && ( - + resumeInheritPer(folderDetail._id)} isInheritPermission={folderDetail.inheritPermission} @@ -332,7 +339,7 @@ const Dataset = () => { {!feConfigs?.isPlus && ( setProModalOpen(false)} /> )} - + ); }; export async function getServerSideProps(content: any) { From 2778683d087f7c417d8175e9c16a7a0e90368f35 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 14:39:37 +0800 Subject: [PATCH 13/38] fix(app): keep paginated tool list height --- .../detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx index e5fb1052e5a3..a4b680562860 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal.tsx @@ -560,6 +560,7 @@ const RenderList = React.memo(function RenderList({ direction="column" minH={isPaginated ? 0 : undefined} h={isPaginated ? 'auto' : '100%'} + flexShrink={isPaginated ? 0 : undefined} > {PluginListRender()} From 996783f6c90406a786779eaaf12be98d24d0e26c Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 14:47:47 +0800 Subject: [PATCH 14/38] fix(app): refine resource selector hover states --- .../components/common/folder/SelectOneResource.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index f376a167f6eb..9b86d7aac7be 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -115,16 +115,9 @@ const SelectOneResource = ({ ? 'myGray.900' : 'myGray.500' } - bg={ - index === 0 - ? selectFolder && value === null - ? 'primary.50 !important' - : 'myGray.100' - : undefined - } fontSize={'xs'} cursor={'pointer'} - _hover={{ color: 'primary.600' }} + _hover={{ bg: 'myGray.100', color: 'primary.600' }} onClick={() => { if (index === 0) { selectRoot(); @@ -175,7 +168,9 @@ const SelectOneResource = ({ w={'20px'} h={'20px'} ml={2} + borderRadius={'xs'} cursor={disabled ? 'not-allowed' : 'pointer'} + _hover={disabled ? undefined : { bg: 'myGray.100' }} onClick={(e) => { e.stopPropagation(); enterFolderFromArrow(item); From bae70b1f15526e4bb4f07569822ae038f390ac65 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 15:02:30 +0800 Subject: [PATCH 15/38] fix(app): deepen folder arrow hover state --- projects/app/src/components/common/folder/SelectOneResource.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index 9b86d7aac7be..cb2ee4f50d8c 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -170,7 +170,7 @@ const SelectOneResource = ({ ml={2} borderRadius={'xs'} cursor={disabled ? 'not-allowed' : 'pointer'} - _hover={disabled ? undefined : { bg: 'myGray.100' }} + _hover={disabled ? undefined : { bg: 'rgba(31, 35, 41, 0.08)' }} onClick={(e) => { e.stopPropagation(); enterFolderFromArrow(item); From 4542c03cfe3a1ac743ddf51a9610a4fbf2f8d52a Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 16:11:45 +0800 Subject: [PATCH 16/38] fix(app): keep resource selector content visible --- projects/app/src/components/Select/AppSelect.tsx | 5 +++-- .../app/src/components/common/folder/SelectOneResource.tsx | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/app/src/components/Select/AppSelect.tsx b/projects/app/src/components/Select/AppSelect.tsx index 4091561d8a5d..eb51cffbb8af 100644 --- a/projects/app/src/components/Select/AppSelect.tsx +++ b/projects/app/src/components/Select/AppSelect.tsx @@ -74,9 +74,10 @@ const AppSelect = ({ value, onSelect }: { value: string; onSelect: (id: string) } > {({ onClose }) => ( - + { if (!item) return; diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index cb2ee4f50d8c..94e840b4e06c 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -30,6 +30,7 @@ const SelectOneResource = ({ value, onSelect, maxH = ['80vh', '600px'], + h = '100%', selectFolder = false, disabledIds = [] }: { @@ -37,6 +38,7 @@ const SelectOneResource = ({ value?: ParentIdType; onSelect: (e?: SelectOneResourceItemType) => any; maxH?: BoxProps['maxH']; + h?: BoxProps['h']; selectFolder?: boolean; disabledIds?: string[]; }) => { @@ -95,7 +97,7 @@ const SelectOneResource = ({ }; return ( - + {t('common:current_location')} From 520a5384ac2553cefed98d37d622652cfbde1c7f Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 16:18:21 +0800 Subject: [PATCH 17/38] feat(app): lazy load skill picker categories --- .../plugins/SkillPickerPlugin/index.tsx | 816 +++++++++--------- .../Edit/ChatAgent/hooks/useSkillManager.tsx | 221 +++-- .../test/web/core/app/tool/templates.test.ts | 108 ++- 3 files changed, 636 insertions(+), 509 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index b9e2651742ef..e803e02cba2a 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -16,28 +16,28 @@ import { import React, { useState } from 'react'; import ReactDOM from 'react-dom'; import { useCallback, useEffect, useRef, useMemo } from 'react'; -import { Box, Flex, HStack } from '@chakra-ui/react'; +import { Box, Flex } from '@chakra-ui/react'; import { useBasicTypeaheadTriggerMatch } from '../../utils'; import Avatar from '../../../../Avatar'; import MyIcon from '../../../../Icon'; import MyBox from '../../../../MyBox'; -import { useMount } from 'ahooks'; import { useRequest } from '../../../../../../hooks/useRequest'; import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import { useTranslation } from 'next-i18next'; import type { SkillLabelItemType } from '../SkillLabelPlugin'; import { getToolIdentityKey } from '@fastgpt/global/core/app/tool/utils'; -/* - 两列渲染器 - 第三列会展示第二列选中的 tool/toolset 的详细信息:头像、名称、描述、子工具(如有) -*/ +const MENU_WIDTH = 'min(954px, calc(100vw - 16px))'; +const MENU_HEIGHT = '337px'; +const FIRST_COLUMN_WIDTH = '200px'; +const CHILD_COLUMN_WIDTH = '280px'; + export type SkillOptionItemType = { description?: string; list: SkillItemType[]; onSelect?: (id: string) => Promise; onClick?: (id: string, source?: string) => Promise; - onFolderLoad?: (id: string) => Promise; + onFolderLoad?: (id: string, source?: string) => Promise; }; export type SkillClickResult = { @@ -61,11 +61,7 @@ export type SkillItemType = { folderChildren?: SkillItemType[]; // Toolset - tools?: { - id: string; - name: string; - source?: string; - }[]; + tools?: SkillItemType[]; }; const getSkillItemKey = (item: Pick) => @@ -90,18 +86,6 @@ export default function SkillPickerPlugin({ setIsMenuOpen(open); }, []); - useEffect(() => { - const timer = window.setTimeout(() => { - setSkillOptions((state) => { - const newOptions = [...state]; - newOptions[0] = skillOption; - return newOptions; - }); - }, 0); - - return () => window.clearTimeout(timer); - }, [skillOption]); - const [editor] = useLexicalComposerContext(); const [selectedRowIndex, setSelectedRowIndex] = useState>({ 0: 0 @@ -109,11 +93,83 @@ export default function SkillPickerPlugin({ const [currentColumnIndex, setCurrentColumnIndex] = useState(0); const [currentRowIndex, setCurrentRowIndex] = useState(0); const [interactionMode, setInteractionMode] = useState<'mouse' | 'keyboard'>('mouse'); + const selectionRequestIdRef = useRef(0); + const folderOptionsRef = useRef>(new Map()); + const folderRequestsRef = useRef>>(new Map()); + const [loadingFolderIds, setLoadingFolderIds] = useState>(new Set()); // Refs for scroll management const itemRefs = useRef>(new Map()); + const menuAnchorRef = useRef(null); + const menuElementRef = useRef(null); + const menuPositionFrameRef = useRef(null); + + const updateMenuPosition = useCallback(() => { + const anchorElement = menuAnchorRef.current; + const menuElement = menuElementRef.current; + if (!anchorElement || !menuElement) return; + + const menuRect = menuElement.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const edgePadding = 8; + const maxLeft = Math.max(edgePadding, viewportWidth - menuRect.width - edgePadding); + const nextLeft = Math.min(Math.max(menuRect.left, edgePadding), maxLeft); + const currentAnchorLeft = Number.parseFloat(anchorElement.style.left); + + if (!Number.isFinite(currentAnchorLeft)) return; + + const nextAnchorLeft = currentAnchorLeft + nextLeft - menuRect.left; + if (Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { + anchorElement.style.left = `${nextAnchorLeft}px`; + } + }, []); + + const scheduleMenuPosition = useCallback(() => { + if (menuPositionFrameRef.current !== null) { + cancelAnimationFrame(menuPositionFrameRef.current); + } + + menuPositionFrameRef.current = requestAnimationFrame(() => { + menuPositionFrameRef.current = null; + updateMenuPosition(); + }); + }, [updateMenuPosition]); + + useEffect(() => { + if (!isFocus || !isMenuOpen) return; + + const anchorElement = menuAnchorRef.current; + const menuElement = menuElementRef.current; + if (!anchorElement || !menuElement) return; + + scheduleMenuPosition(); + + const anchorObserver = new MutationObserver(scheduleMenuPosition); + anchorObserver.observe(anchorElement, { + attributes: true, + attributeFilter: ['style'] + }); + + const menuObserver = new ResizeObserver(scheduleMenuPosition); + menuObserver.observe(menuElement); - // Scroll selected item into view + window.addEventListener('resize', scheduleMenuPosition); + document.addEventListener('scroll', scheduleMenuPosition, true); + + return () => { + anchorObserver.disconnect(); + menuObserver.disconnect(); + window.removeEventListener('resize', scheduleMenuPosition); + document.removeEventListener('scroll', scheduleMenuPosition, true); + + if (menuPositionFrameRef.current !== null) { + cancelAnimationFrame(menuPositionFrameRef.current); + menuPositionFrameRef.current = null; + } + }; + }, [isFocus, isMenuOpen, scheduleMenuPosition, skillOptions.length]); + + // Scroll the selected row into view and reveal newly appended columns. const scrollIntoView = useCallback((columnIndex: number, rowIndex: number, retryCount = 0) => { const scroll = (currentRetryCount: number) => { const itemKey = `${columnIndex}-${rowIndex}`; @@ -124,13 +180,12 @@ export default function SkillPickerPlugin({ if (container) { container.scrollTop = 0; } - } else { - itemElement.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'nearest' - }); } + itemElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest' + }); } else if (currentRetryCount < 5) { // Retry if element not found yet (DOM not ready) setTimeout(() => { @@ -142,29 +197,37 @@ export default function SkillPickerPlugin({ scroll(retryCount); }, []); - // Recursively collects all visible items including expanded folder children for keyboard navigation - const getFlattenedVisibleItems = useCallback( - (columnIndex: number): SkillItemType[] => { - const column = skillOptions[columnIndex]; - - const flatten = (items: SkillItemType[]): SkillItemType[] => { - const result: SkillItemType[] = []; - items.forEach((item) => { - result.push(item); - // Include folder children only if folder is expanded - if (item.isFolder && item.open && item.folderChildren) { - result.push(...flatten(item.folderChildren)); - } - }); - return result; + const getItemChildOption = useCallback((item: SkillItemType, option: SkillOptionItemType) => { + if (item.children) return item.children; + + if (item.folderChildren) { + return { + description: option.description, + list: item.folderChildren, + onClick: option.onClick, + onFolderLoad: option.onFolderLoad }; + } - return flatten(column.list); - }, - [skillOptions] - ); + if (item.tools?.length) { + return { + list: item.tools, + onClick: option.onClick + }; + } + + return undefined; + }, []); - // Handle item selection (hover/keyboard navigation) + const appendColumn = useCallback((columnIndex: number, option?: SkillOptionItemType) => { + setSkillOptions((prev) => { + const next = prev.slice(0, columnIndex + 1); + if (option) next.push(option); + return next; + }); + }, []); + + // Resolve the hovered item into the next navigation column. const { runAsync: handleItemSelect, loading: isItemSelectLoading } = useRequest( async ({ currentColumnIndex, @@ -175,35 +238,107 @@ export default function SkillPickerPlugin({ item?: SkillItemType; option?: SkillOptionItemType; }) => { - if (!item || !option?.onSelect) return; - - const buffer = item.children; - if (buffer) { - setSkillOptions((prev) => { - const newOptions = [...prev]; - newOptions[currentColumnIndex + 1] = buffer; - return newOptions; - }); + if (!item || !option) return; + + const requestId = ++selectionRequestIdRef.current; + const itemKey = getSkillItemKey(item); + const childOption = getItemChildOption(item, option); + + appendColumn(currentColumnIndex, childOption); + if (childOption) { return; } - const result = await option.onSelect(item.id); + if (item.isFolder && option.onFolderLoad) { + const cachedOption = folderOptionsRef.current.get(itemKey); + if (cachedOption) { + appendColumn(currentColumnIndex, { + ...cachedOption, + description: option.description, + onClick: option.onClick, + onFolderLoad: option.onFolderLoad + }); + return; + } - setSkillOptions((prev) => { - const newOptions = [...prev]; - if (result?.list && result?.list?.length > 0) { - newOptions[currentColumnIndex + 1] = result; - } else { - for (let i = currentColumnIndex + 1; i < newOptions.length; i++) { - // @ts-ignore - newOptions[i] = undefined; + setLoadingFolderIds((prev) => new Set(prev).add(itemKey)); + let pendingRequest = folderRequestsRef.current.get(itemKey); + if (!pendingRequest) { + pendingRequest = Promise.resolve().then(() => option.onFolderLoad!(item.id, item.source)); + folderRequestsRef.current.set(itemKey, pendingRequest); + } + try { + const list = await pendingRequest; + if (selectionRequestIdRef.current !== requestId) return; + + const nextOption: SkillOptionItemType = { + description: option.description, + list, + onClick: option.onClick, + onFolderLoad: option.onFolderLoad + }; + folderOptionsRef.current.set(itemKey, nextOption); + appendColumn(currentColumnIndex, nextOption); + } finally { + if (folderRequestsRef.current.get(itemKey) === pendingRequest) { + folderRequestsRef.current.delete(itemKey); } + setLoadingFolderIds((prev) => { + const next = new Set(prev); + next.delete(itemKey); + return next; + }); } - return newOptions.filter(Boolean); - }); + return; + } + + if (item.isFolder) { + appendColumn(currentColumnIndex, { + description: option.description, + list: [], + onClick: option.onClick + }); + return; + } + + if (!option.onSelect) return; + + const result = await option.onSelect(item.id); + if (selectionRequestIdRef.current !== requestId) return; + + appendColumn(currentColumnIndex, result); + }, + { + refreshDeps: [appendColumn, getItemChildOption] } ); + useEffect(() => { + const timer = window.setTimeout(() => { + selectionRequestIdRef.current += 1; + folderOptionsRef.current.clear(); + setSkillOptions([skillOption]); + setSelectedRowIndex({ 0: 0 }); + setCurrentColumnIndex(0); + setCurrentRowIndex(0); + setInteractionMode('mouse'); + + const firstItem = skillOption.list[0]; + if (firstItem) { + void handleItemSelect({ + currentColumnIndex: 0, + item: firstItem, + option: skillOption + }); + } + }, 0); + + return () => { + window.clearTimeout(timer); + selectionRequestIdRef.current += 1; + }; + }, [handleItemSelect, skillOption]); + const insertSkillNodeText = useCallback( (skillId: string, matchingString?: string | null) => { let inserted = false; @@ -285,95 +420,6 @@ export default function SkillPickerPlugin({ } ); - // Handle folder toggle - const [loadingFolderIds, setLoadingFolderIds] = useState(new Set()); - const { runAsync: handleFolderToggle } = useRequest( - async ({ - currentColumnIndex, - item, - option - }: { - currentColumnIndex: number; - item?: SkillItemType; - option?: SkillOptionItemType; - }) => { - if (!item || !item.isFolder || !option?.onFolderLoad) return; - const currentFolder = item; - - // Step 1: Toggle folder open/closed state - setSkillOptions((prev) => { - const newOptions = [...prev]; - const columnData = { ...newOptions[currentColumnIndex] }; - - // Recursively find and toggle the target folder - const toggleFolderOpen = (items: SkillItemType[]): SkillItemType[] => { - return items.map((item) => { - // Found the target folder, toggle its open state - if (getSkillItemKey(item) === getSkillItemKey(currentFolder)) { - return { ...item, open: !currentFolder.open }; - } - // Recursively search in nested folders - if (item.folderChildren) { - return { ...item, folderChildren: toggleFolderOpen(item.folderChildren) }; - } - return item; - }); - }; - - columnData.list = toggleFolderOpen(columnData.list); - newOptions[currentColumnIndex] = columnData; - return newOptions; - }); - - // Step 2: Load folder children only if folder has no data - if (!currentFolder.open && currentFolder?.folderChildren === undefined) { - setLoadingFolderIds((prev) => { - const next = new Set(prev); - next.add(currentFolder.id); - return next; - }); - - try { - const result = await option.onFolderLoad(currentFolder.id); - - setSkillOptions((prev) => { - const newOptions = [...prev]; - const columnData = { ...newOptions[currentColumnIndex] }; - - const addFolderChildren = (items: SkillItemType[]): SkillItemType[] => { - return items.map((item) => { - if (getSkillItemKey(item) === getSkillItemKey(currentFolder)) { - return { - ...item, - folderChildren: result - }; - } - if (item.folderChildren) { - return { ...item, folderChildren: addFolderChildren(item.folderChildren) }; - } - return item; - }); - }; - - columnData.list = addFolderChildren(columnData.list); - newOptions[currentColumnIndex] = columnData; - return newOptions; - }); - } finally { - setLoadingFolderIds((prev) => { - const next = new Set(prev); - next.delete(currentFolder.id); - return next; - }); - } - } - } - ); - - // First init - useMount(() => { - handleItemSelect({ currentColumnIndex: 0, item: skillOption.list[0], option: skillOption }); - }); // Scroll to selected item when menu opens useEffect(() => { if (isMenuOpen) { @@ -399,14 +445,14 @@ export default function SkillPickerPlugin({ setInteractionMode('keyboard'); if (currentColumnIndex >= 0 && currentColumnIndex < skillOptions.length) { - const columnItems = getFlattenedVisibleItems(currentColumnIndex); + const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; // Use functional update to get the latest row index setCurrentRowIndex((prevRowIndex) => { const newIndex = prevRowIndex > 0 ? prevRowIndex - 1 : columnItems.length - 1; - handleItemSelect({ + void handleItemSelect({ currentColumnIndex: currentColumnIndex, item: columnItems[newIndex], option: skillOptions[currentColumnIndex] @@ -437,14 +483,14 @@ export default function SkillPickerPlugin({ setInteractionMode('keyboard'); if (currentColumnIndex >= 0 && currentColumnIndex < skillOptions.length) { - const columnItems = getFlattenedVisibleItems(currentColumnIndex); + const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; // Use functional update to get the latest row index setCurrentRowIndex((prevRowIndex) => { const newIndex = prevRowIndex < columnItems.length - 1 ? prevRowIndex + 1 : 0; - handleItemSelect({ + void handleItemSelect({ currentColumnIndex: currentColumnIndex, item: columnItems[newIndex], option: skillOptions[currentColumnIndex] @@ -476,7 +522,18 @@ export default function SkillPickerPlugin({ // Use functional updates to get the latest state setCurrentColumnIndex((prevColumnIndex) => { - if (prevColumnIndex >= skillOptions.length - 1) return prevColumnIndex; + if (prevColumnIndex >= skillOptions.length - 1) { + const currentOption = skillOptions[prevColumnIndex]; + const currentItem = currentOption?.list[currentRowIndex]; + if (currentItem && currentOption) { + void handleItemSelect({ + currentColumnIndex: prevColumnIndex, + item: currentItem, + option: currentOption + }); + } + return prevColumnIndex; + } const newColumnIndex = prevColumnIndex + 1; @@ -485,13 +542,13 @@ export default function SkillPickerPlugin({ [prevColumnIndex]: currentRowIndex })); - setCurrentRowIndex(0); + setCurrentRowIndex(selectedRowIndex[newColumnIndex] ?? 0); // Use the latest skillOptions from closure to get the new column items const newColumnOption = skillOptions[newColumnIndex]; const newColumnItems = newColumnOption?.list; if (newColumnItems && newColumnItems.length > 0) { - handleItemSelect({ + void handleItemSelect({ currentColumnIndex: newColumnIndex, item: newColumnItems[0], option: newColumnOption @@ -537,7 +594,7 @@ export default function SkillPickerPlugin({ // Only keep data up to and including the current column setSkillOptions((state) => { - return state.slice(0, prevColumnIndex + 1); + return state.slice(0, newColumnIndex + 1); }); // Scroll into view after state update @@ -563,12 +620,15 @@ export default function SkillPickerPlugin({ setInteractionMode('keyboard'); - const flattenedItems = getFlattenedVisibleItems(currentColumnIndex); - const latestItem = flattenedItems[currentRowIndex]; + const latestItem = skillOptions[currentColumnIndex]?.list[currentRowIndex]; const latestOption = skillOptions[currentColumnIndex]; - if (!(latestItem.open && latestItem.folderChildren?.length === 0)) { - handleFolderToggle({ + if ( + latestItem && + latestOption && + (getItemChildOption(latestItem, latestOption) || latestItem.isFolder) + ) { + void handleItemSelect({ currentColumnIndex, item: latestItem, option: latestOption @@ -591,12 +651,22 @@ export default function SkillPickerPlugin({ setInteractionMode('keyboard'); - const flattenedItems = getFlattenedVisibleItems(currentColumnIndex); - const latestItem = flattenedItems[currentRowIndex]; + const latestItem = skillOptions[currentColumnIndex]?.list[currentRowIndex]; const latestOption = skillOptions[currentColumnIndex]; - if (latestOption?.onClick) { - handleItemClick({ item: latestItem, option: latestOption }); + if (!latestItem || !latestOption) return false; + + if (latestItem.isFolder || getItemChildOption(latestItem, latestOption)) { + void handleItemSelect({ + currentColumnIndex, + item: latestItem, + option: latestOption + }); + return true; + } + + if (latestOption.onClick) { + void handleItemClick({ item: latestItem, option: latestOption }); return true; } @@ -622,213 +692,186 @@ export default function SkillPickerPlugin({ currentRowIndex, skillOptions, handleItemSelect, - handleFolderToggle, handleItemClick, selectedRowIndex, scrollIntoView, - getFlattenedVisibleItems + getItemChildOption ]); - const selectedTool = useMemo(() => { - const item = skillOptions[currentColumnIndex]?.list[currentRowIndex]; - if (!item || !item.canClick) return null; - return item; - }, [skillOptions, currentColumnIndex, currentRowIndex]); + const isExpandable = useCallback( + (item: SkillItemType, option: SkillOptionItemType) => + Boolean(item.isFolder || getItemChildOption(item, option)), + [getItemChildOption] + ); - // Recursively render item list + // Render one flat list per navigation column. const renderItemList = useCallback( ( items: SkillItemType[], columnData: SkillOptionItemType, columnIndex: number, - onSelectOption?: (item: SkillItemType, option: SkillOptionItemType) => void, - depth: number = 0, - startFlatIndex: number = 0 - ): { elements: JSX.Element[]; nextFlatIndex: number } => { - const renderItems = ( - currentItems: SkillItemType[], - currentDepth: number, - currentStartFlatIndex: number - ): { elements: JSX.Element[]; nextFlatIndex: number } => { - const result: JSX.Element[] = []; - const activeRowIndex = selectedRowIndex[columnIndex]; - let currentFlatIndex = currentStartFlatIndex; - - currentItems.forEach((item) => { - const flatIndex = currentFlatIndex; - currentFlatIndex++; - - // 前面的列,才有激活态 - const isActive = columnIndex < currentColumnIndex && flatIndex === activeRowIndex; - // 当前选中的东西 - const isSelected = columnIndex === currentColumnIndex && flatIndex === currentRowIndex; - - result.push( - { - if (el) { - itemRefs.current.set(`${columnIndex}-${flatIndex}`, el as HTMLDivElement); - } else { - itemRefs.current.delete(`${columnIndex}-${flatIndex}`); - } - }} - px={2} - py={1.5} - gap={2} - pl={1 + currentDepth * 4} - borderRadius={'4px'} - cursor={'pointer'} - bg={isActive || isSelected ? 'myGray.100' : ''} - color={isSelected ? 'primary.700' : 'myGray.600'} - display={'flex'} - alignItems={'center'} - size={'sm'} - onMouseDown={(e) => { - e.preventDefault(); - }} - onMouseMove={() => { - if (interactionMode === 'keyboard') { - setInteractionMode('mouse'); - } - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (item.isFolder) { - handleFolderToggle({ - currentColumnIndex: columnIndex, - item, - option: columnData - }); - } else { - if (onSelectOption) { - onSelectOption(item, columnData); - } else { - handleItemClick({ - item, - option: columnData - }); - } - } - }} - onMouseEnter={(e) => { - e.preventDefault(); - - // Ignore mouse hover in keyboard mode - if (interactionMode === 'keyboard') { - return; - } - - if (columnIndex !== currentColumnIndex) { - setSelectedRowIndex((state) => ({ - ...state, - [currentColumnIndex]: currentRowIndex - })); - } - - setCurrentRowIndex(flatIndex); - setCurrentColumnIndex(columnIndex); - handleItemSelect({ + onSelectOption?: (item: SkillItemType, option: SkillOptionItemType) => void + ): JSX.Element[] => { + const activeRowIndex = selectedRowIndex[columnIndex]; + + return items.map((item, rowIndex) => { + const isActive = columnIndex < currentColumnIndex && rowIndex === activeRowIndex; + const isSelected = columnIndex === currentColumnIndex && rowIndex === currentRowIndex; + const expandable = isExpandable(item, columnData); + + return ( + { + if (el) { + itemRefs.current.set(`${columnIndex}-${rowIndex}`, el as HTMLDivElement); + } else { + itemRefs.current.delete(`${columnIndex}-${rowIndex}`); + } + }} + pl={1} + pr={2} + py={1.5} + gap={2} + borderRadius={'4px'} + cursor={'pointer'} + bg={isActive || isSelected ? 'myGray.100' : undefined} + color={'myGray.600'} + display={'flex'} + alignItems={'center'} + h={'33px'} + flexShrink={0} + onMouseDown={(e) => { + e.preventDefault(); + }} + onMouseMove={() => { + if (interactionMode === 'keyboard') { + setInteractionMode('mouse'); + } + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + + if (expandable || (!item.canClick && columnData.onSelect)) { + void handleItemSelect({ currentColumnIndex: columnIndex, item, option: columnData }); - }} - > - {item.isFolder && !(item.open && item.folderChildren?.length === 0) ? ( + return; + } + + if (onSelectOption) { + onSelectOption(item, columnData); + } else { + void handleItemClick({ item, option: columnData }); + } + }} + onMouseEnter={(e) => { + e.preventDefault(); + + // Ignore mouse hover in keyboard mode until the pointer moves again. + if (interactionMode === 'keyboard') return; + + if (columnIndex !== currentColumnIndex) { + setSelectedRowIndex((state) => ({ + ...state, + [currentColumnIndex]: currentRowIndex + })); + } + + setCurrentRowIndex(rowIndex); + setCurrentColumnIndex(columnIndex); + void handleItemSelect({ + currentColumnIndex: columnIndex, + item, + option: columnData + }); + }} + > + {item.icon && ( + + )} + + {item.label} + + {expandable && ( + - ) : columnData.onFolderLoad ? ( - - ) : null} - - {item.icon && } - {/* Folder content */} - - {item.label} - {item.isFolder && item.open && item.folderChildren?.length === 0 && ( - - {t('app:empty_folder')} - - )} - - ); - - // render folderChildren - if ( - item.isFolder && - item.open && - !!item.folderChildren && - item.folderChildren.length > 0 - ) { - const { elements, nextFlatIndex } = renderItems( - item.folderChildren, - currentDepth + 1, - currentFlatIndex - ); - result.push(...elements); - currentFlatIndex = nextFlatIndex; - } - }); - - return { elements: result, nextFlatIndex: currentFlatIndex }; - }; - - return renderItems(items, depth, startFlatIndex); + )} + + ); + }); }, [ selectedRowIndex, currentColumnIndex, currentRowIndex, loadingFolderIds, - t, interactionMode, - handleFolderToggle, handleItemClick, - handleItemSelect + handleItemSelect, + isExpandable ] ); - // Render single column + + // Render a fixed-width navigation column. const renderColumn = useCallback( ( columnData: SkillOptionItemType, columnIndex: number, onSelectOption?: (item: SkillItemType, option: SkillOptionItemType) => void ) => { - const columnWidth = columnData.onFolderLoad ? '280px' : '200px'; - return ( 0 ? 2 : 0} p={1.5} - borderRadius={'sm'} - w={columnWidth} + borderRadius={'6px'} + w={columnIndex === 0 ? FIRST_COLUMN_WIDTH : CHILD_COLUMN_WIDTH} + h={'100%'} boxShadow={'0 4px 10px 0 rgba(19, 51, 107, 0.10), 0 0 1px 0 rgba(19, 51, 107, 0.10)'} bg={'white'} flexShrink={0} - maxH={'350px'} - overflow={'auto'} + overflowY={'auto'} + overflowX={'hidden'} + sx={{ + scrollbarColor: 'var(--chakra-colors-myGray-300) transparent', + scrollbarWidth: 'thin', + '&::-webkit-scrollbar': { width: '6px' }, + '&::-webkit-scrollbar-thumb': { + background: 'var(--chakra-colors-myGray-300)', + borderRadius: '3px' + } + }} > {columnData.description && ( - + {columnData.description} )} - {renderItemList(columnData.list, columnData, columnIndex, onSelectOption).elements} + {renderItemList(columnData.list, columnData, columnIndex, onSelectOption)} + {columnData.list.length === 0 && ( + + {t('app:empty_folder')} + + )} ); }, - [currentColumnIndex, isItemClickLoading, renderItemList] + [currentColumnIndex, isItemClickLoading, isItemSelectLoading, renderItemList, t] ); // For LexicalTypeaheadMenuPlugin compatibility @@ -882,83 +925,50 @@ export default function SkillPickerPlugin({ triggerFn={checkForTriggerMatch} options={menuOptions} menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) => { - const shouldShow = skillOptions.length > 0 && anchorElementRef.current !== null && isFocus; + if (anchorElementRef.current === null) return null; + + menuAnchorRef.current = anchorElementRef.current; + const shouldShow = skillOptions.length > 0 && isFocus; return ReactDOM.createPortal( - { + menuElementRef.current = element; + }} visibility={shouldShow ? 'visible' : 'hidden'} position="relative" - align="flex-start" zIndex={99999} + w={MENU_WIDTH} + h={MENU_HEIGHT} + p={2} + bg={'#fbfbfc'} + borderRadius={'12px'} + overflowX={'auto'} + overflowY={'hidden'} + sx={{ + scrollbarColor: 'var(--chakra-colors-myGray-300) transparent', + scrollbarWidth: 'auto', + '&::-webkit-scrollbar': { height: '8px' }, + '&::-webkit-scrollbar-thumb': { + background: 'var(--chakra-colors-myGray-300)', + borderRadius: '4px' + }, + '&::-webkit-scrollbar-track': { background: 'transparent' } + }} > - {skillOptions.map((column, index) => { - return renderColumn(column, index, (item, option) => { - if (!option.onClick) return; - selectOptionAndCleanUp({ - key: getSkillItemKey(item), - ...item, - onClick: option.onClick - } as any); - }); - })} - - {selectedTool && ( - - - {selectedTool.icon && ( - - )} - {/* Folder content */} - - {selectedTool.label} - - - - {selectedTool.description || t('app:tool_not_desc')} - - {/* Tools */} - {selectedTool.tools && selectedTool.tools.length > 0 && ( - <> - - {t('app:tools')}({selectedTool.tools.length}) - - {selectedTool.tools.map((tool) => ( - - - {tool.name} - - ))} - - )} - - )} - , + + {skillOptions.map((column, index) => + renderColumn(column, index, (item, option) => { + if (!option.onClick || isExpandable(item, option)) return; + selectOptionAndCleanUp({ + key: getSkillItemKey(item), + ...item, + onClick: option.onClick + } as any); + }) + )} + + , anchorElementRef.current! ); }} diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx index a827f4bbc7a8..f4886975a0b0 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx @@ -13,6 +13,7 @@ import { import { useToast } from '@fastgpt/web/hooks/useToast'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; import type { SkillLabelItemType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillLabelPlugin'; import dynamic from 'next/dynamic'; import type { @@ -67,6 +68,21 @@ const toSkillLabelItem = ( configStatus }); +const toSystemToolItem = (item: NodeTemplateListItemType, parentId?: string): SkillItemType => { + const isFolder = item.isFolder || item.flowNodeType === FlowNodeTypeEnum.toolSet; + + return { + parentId: item.parentId ?? parentId, + id: item.id, + source: item.source, + label: item.name, + icon: item.avatar, + description: item.intro, + isFolder, + canClick: !isFolder + }; +}; + const toAgentSkillItem = (item: AgentSkillListItemType): SkillItemType => { const isFolder = item.type === AgentSkillTypeEnum.folder; @@ -113,32 +129,17 @@ export const useSkillManager = ({ const { toast } = useToast(); /* ===== System tool ===== */ + const onFolderLoadSystemTools = useCallback(async (folderId: string, source?: string) => { + const data = await getAppToolTemplates({ parentId: folderId, source }); + return data.map((item) => toSystemToolItem(item, folderId)); + }, []); + const { data: systemTools = [] } = useRequest( async () => { const data = await getAppToolTemplates({ getAll: true }).catch(() => { return []; }); - const apiTools = data - .map((item) => { - return { - id: item.id, - source: item.source, - parentId: item.parentId, - label: item.name, - icon: item.avatar, - description: item.intro, - showArrow: item.isFolder, - canClick: true, - tools: data - .filter((tool) => tool.parentId === item.id) - .map((tool) => ({ - id: tool.id, - name: tool.name, - source: tool.source - })) - }; - }) - .filter((item) => !item.parentId); + const apiTools = data.map((item) => toSystemToolItem(item)); const datasetSearchInfo = systemSubInfo[SubAppIds.datasetSearch]; if (datasetSearchInfo) { @@ -188,39 +189,6 @@ export const useSkillManager = ({ /* ===== Team agents/tools ===== */ const parentTypes = useRef(new Map()); - const { data: allTeamApps = [] } = useRequest(() => getTeamAppTemplates({ parentId: null }), { - manual: false, - onSuccess(list) { - list.forEach(({ id, appType }) => parentTypes.current.set(id, appType)); - } - }); - const myTools = useMemo( - () => - allTeamApps - .filter((item) => [AppTypeEnum.toolFolder, ...ToolTypeList].includes(item.appType)) - .map((item) => ({ - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) - })), - [allTeamApps] - ); - const myAgents = useMemo( - () => - allTeamApps - .filter((item) => [AppTypeEnum.folder, ...AppTypeList].includes(item.appType)) - .map((item) => ({ - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) - })), - [allTeamApps] - ); - const onFolderLoadTeamApps = useCallback(async (folderId: string, types: AppTypeEnum[]) => { const children = await getTeamAppTemplates({ parentId: folderId, @@ -244,6 +212,34 @@ export const useSkillManager = ({ }); }, []); + const rootListCacheRef = useRef>(new Map()); + const rootListRequestsRef = useRef>>(new Map()); + /** Cache successful root lists while sharing an in-flight request per category. */ + const loadRootList = useCallback((key: string, loader: () => Promise) => { + if (rootListCacheRef.current.has(key)) { + return Promise.resolve(rootListCacheRef.current.get(key)!); + } + + const pendingRequest = rootListRequestsRef.current.get(key); + if (pendingRequest) return pendingRequest; + + const request = loader() + .then((list) => { + rootListCacheRef.current.set(key, list); + return list; + }) + .catch(() => { + // Keep failures silent and uncached so the next visit can retry. + return []; + }) + .finally(() => { + rootListRequestsRef.current.delete(key); + }); + + rootListRequestsRef.current.set(key, request); + return request; + }, []); + /* ===== Agent skills ===== */ const agentSkillMapRef = useRef>(new Map()); const cacheAgentSkillList = useCallback((list: AgentSkillListItemType[]) => { @@ -256,22 +252,6 @@ export const useSkillManager = ({ return list.map(toAgentSkillItem); }, []); - const { data: agentSkills = [] } = useRequest( - async () => { - if (!onAddAgentSkill) return []; - - const list = await getAllSkillList({ - source: 'mine', - parentId: '', - withAppCount: false - }); - return cacheAgentSkillList(list); - }, - { - manual: false - } - ); - const onFolderLoadAgentSkills = useCallback( async (folderId: string) => { const list = await getAllSkillList({ @@ -430,6 +410,75 @@ export const useSkillManager = ({ ] ); + /** Lazily load the root-level tools and preserve the folder loader contract. */ + const loadMyTools = useCallback(async () => { + const list = await loadRootList('myTools', async () => { + const apps = await getTeamAppTemplates({ + parentId: null, + type: [AppTypeEnum.toolFolder, ...ToolTypeList] + }); + + return apps.map((item) => ({ + id: item.id, + label: item.name, + icon: item.avatar, + isFolder: item.isFolder ?? false, + canClick: !AppFolderTypeList.includes(item.appType) + })); + }); + + return { + description: t('app:space_to_expand_folder'), + list, + onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, ToolTypeList), + onClick: onAddAppOrTool + }; + }, [loadRootList, onAddAppOrTool, onFolderLoadTeamApps, t]); + + /** Lazily load the root-level agents and preserve the folder loader contract. */ + const loadMyAgents = useCallback(async () => { + const list = await loadRootList('agent', async () => { + const apps = await getTeamAppTemplates({ + parentId: null, + type: [AppTypeEnum.folder, ...AppTypeList] + }); + + return apps.map((item) => ({ + id: item.id, + label: item.name, + icon: item.avatar, + isFolder: item.isFolder ?? false, + canClick: !AppFolderTypeList.includes(item.appType) + })); + }); + + return { + description: t('app:space_to_expand_folder'), + list, + onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList), + onClick: onAddAppOrTool + }; + }, [loadRootList, onAddAppOrTool, onFolderLoadTeamApps, t]); + + /** Lazily load associated skills and update the skill lookup map for selection. */ + const loadAgentSkills = useCallback(async () => { + const list = await loadRootList('agentSkill', async () => { + const skills = await getAllSkillList({ + source: 'mine', + parentId: '', + withAppCount: false + }); + return cacheAgentSkillList(skills); + }); + + return { + description: t('app:space_to_expand_folder'), + list, + onFolderLoad: onFolderLoadAgentSkills, + onClick: onAddSkill + }; + }, [cacheAgentSkillList, loadRootList, onAddSkill, onFolderLoadAgentSkills, t]); + /* ===== Skill option ===== */ const skillOption = useMemo(() => { return { @@ -438,29 +487,15 @@ export const useSkillManager = ({ const data = await onLoadSystemTool({}); return { list: data, + onFolderLoad: onFolderLoadSystemTools, onClick: onAddAppOrTool }; } else if (id === 'myTools') { - return { - description: t('app:space_to_expand_folder'), - list: myTools, - onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, ToolTypeList), - onClick: onAddAppOrTool - }; + return loadMyTools(); } else if (id === 'agent') { - return { - description: t('app:space_to_expand_folder'), - list: myAgents, - onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList), - onClick: onAddAppOrTool - }; + return loadMyAgents(); } else if (id === 'agentSkill') { - return { - description: t('app:space_to_expand_folder'), - list: agentSkills, - onFolderLoad: onFolderLoadAgentSkills, - onClick: onAddSkill - }; + return loadAgentSkills(); } return undefined; }, @@ -498,14 +533,12 @@ export const useSkillManager = ({ }; }, [ onAddAppOrTool, - onAddSkill, onAddAgentSkill, onLoadSystemTool, - myTools, - myAgents, - agentSkills, - onFolderLoadTeamApps, - onFolderLoadAgentSkills, + onFolderLoadSystemTools, + loadMyTools, + loadMyAgents, + loadAgentSkills, t ]); diff --git a/projects/app/test/web/core/app/tool/templates.test.ts b/projects/app/test/web/core/app/tool/templates.test.ts index 73f03d3aac0c..41a265277b97 100644 --- a/projects/app/test/web/core/app/tool/templates.test.ts +++ b/projects/app/test/web/core/app/tool/templates.test.ts @@ -5,7 +5,7 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; const mocks = vi.hoisted(() => ({ getAppDetailById: vi.fn(), GET: vi.fn(), - getMyApps: vi.fn() + getAllApps: vi.fn() })); vi.mock('@/web/common/api/request', () => ({ @@ -15,7 +15,7 @@ vi.mock('@/web/common/api/request', () => ({ vi.mock('@/web/core/app/api', () => ({ getAppDetailById: mocks.getAppDetailById, - getMyApps: mocks.getMyApps + getAllApps: mocks.getAllApps })); import { getTeamAppTemplates } from '@/web/core/app/api/tool'; @@ -25,6 +25,90 @@ describe('getTeamAppTemplates', () => { vi.clearAllMocks(); }); + it('passes root type filters and maps root team apps', async () => { + mocks.getAllApps.mockResolvedValueOnce([ + { + _id: 'tool-folder', + type: AppTypeEnum.toolFolder, + avatar: 'folder-avatar', + name: 'My tools', + intro: 'Tool folder' + }, + { + _id: 'workflow-tool', + type: AppTypeEnum.workflowTool, + avatar: 'tool-avatar', + name: 'My tool', + intro: 'Tool app' + } + ]); + + const templates = await getTeamAppTemplates({ + parentId: null, + type: [AppTypeEnum.toolFolder, AppTypeEnum.workflowTool] + }); + + expect(mocks.getAllApps).toHaveBeenCalledWith({ + parentId: null, + type: [AppTypeEnum.toolFolder, AppTypeEnum.workflowTool] + }); + expect(templates).toMatchObject([ + { + id: 'tool-folder', + pluginId: 'tool-folder', + isFolder: true, + appType: AppTypeEnum.toolFolder + }, + { + id: 'workflow-tool', + pluginId: 'workflow-tool', + isFolder: false, + appType: AppTypeEnum.workflowTool + } + ]); + + mocks.getAllApps.mockResolvedValueOnce([ + { + _id: 'agent-folder', + type: AppTypeEnum.folder, + avatar: 'agent-folder-avatar', + name: 'My agents', + intro: 'Agent folder' + }, + { + _id: 'workflow-agent', + type: AppTypeEnum.workflow, + avatar: 'agent-avatar', + name: 'My agent', + intro: 'Agent app' + } + ]); + + const agentTemplates = await getTeamAppTemplates({ + parentId: null, + type: [AppTypeEnum.folder, AppTypeEnum.workflow] + }); + + expect(mocks.getAllApps).toHaveBeenNthCalledWith(2, { + parentId: null, + type: [AppTypeEnum.folder, AppTypeEnum.workflow] + }); + expect(agentTemplates).toMatchObject([ + { + id: 'agent-folder', + pluginId: 'agent-folder', + isFolder: true, + appType: AppTypeEnum.folder + }, + { + id: 'workflow-agent', + pluginId: 'workflow-agent', + isFolder: false, + appType: AppTypeEnum.workflow + } + ]); + }); + it('marks MCP and HTTP toolset children as selectable tools', async () => { mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.mcpToolSet, @@ -66,11 +150,11 @@ describe('getTeamAppTemplates', () => { searchKey: undefined }); expect(mocks.getAppDetailById).not.toHaveBeenCalled(); - expect(mocks.getMyApps).not.toHaveBeenCalled(); + expect(mocks.getAllApps).not.toHaveBeenCalled(); }); it('lists normal folders directly and preserves filters without sending parentType', async () => { - mocks.getMyApps.mockResolvedValueOnce([]); + mocks.getAllApps.mockResolvedValueOnce([]); const query = { parentId: 'folder', parentType: AppTypeEnum.toolFolder, @@ -78,7 +162,7 @@ describe('getTeamAppTemplates', () => { type: [AppTypeEnum.workflowTool] }; expect(await getTeamAppTemplates(query)).toEqual([]); - expect(mocks.getMyApps).toHaveBeenCalledExactlyOnceWith({ + expect(mocks.getAllApps).toHaveBeenCalledExactlyOnceWith({ parentId: 'folder', searchKey: 'search', type: [AppTypeEnum.workflowTool] @@ -90,22 +174,22 @@ describe('getTeamAppTemplates', () => { it.each([undefined, AppTypeEnum.folder, AppTypeEnum.mcpToolSet])( 'keeps Agent lists on list regardless of cached parent type: %j', async (parentType) => { - mocks.getMyApps.mockResolvedValueOnce([]); + mocks.getAllApps.mockResolvedValueOnce([]); const type = [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]; expect(await getTeamAppTemplates({ parentId: 'agent-folder', parentType, type })).toEqual([]); - expect(mocks.getMyApps).toHaveBeenCalledExactlyOnceWith({ parentId: 'agent-folder', type }); + expect(mocks.getAllApps).toHaveBeenCalledExactlyOnceWith({ parentId: 'agent-folder', type }); expect(mocks.GET).not.toHaveBeenCalled(); } ); it('preserves appType in root items so navigation can select its endpoint without detail', async () => { - mocks.getMyApps.mockResolvedValueOnce([ + mocks.getAllApps.mockResolvedValueOnce([ { _id: 'mcp-set', type: AppTypeEnum.mcpToolSet, name: 'MCP', avatar: '', intro: '' } ]); const [parent] = await getTeamAppTemplates(); mocks.GET.mockResolvedValueOnce({ type: AppTypeEnum.mcpToolSet, tools: [] }); await getTeamAppTemplates({ parentId: parent.id, parentType: parent.appType }); - expect(mocks.getMyApps).toHaveBeenCalledTimes(1); + expect(mocks.getAllApps).toHaveBeenCalledTimes(1); expect(mocks.GET).toHaveBeenCalledExactlyOnceWith('/core/app/tool/getToolSetChildren', { appId: 'mcp-set', searchKey: undefined @@ -114,7 +198,7 @@ describe('getTeamAppTemplates', () => { }); it('loads root without fetching a parent resource', async () => { - mocks.getMyApps.mockResolvedValueOnce([]); + mocks.getAllApps.mockResolvedValueOnce([]); expect(await getTeamAppTemplates()).toEqual([]); expect(mocks.GET).not.toHaveBeenCalled(); }); @@ -124,7 +208,7 @@ describe('getTeamAppTemplates', () => { expect( await getTeamAppTemplates({ parentId: 'empty', parentType: AppTypeEnum.httpToolSet }) ).toEqual([]); - expect(mocks.getMyApps).not.toHaveBeenCalled(); + expect(mocks.getAllApps).not.toHaveBeenCalled(); }); it('propagates denied access without requesting full details', async () => { @@ -132,7 +216,7 @@ describe('getTeamAppTemplates', () => { await expect( getTeamAppTemplates({ parentId: 'denied', parentType: AppTypeEnum.mcpToolSet }) ).rejects.toThrow('unAuthApp'); - expect(mocks.getMyApps).not.toHaveBeenCalled(); + expect(mocks.getAllApps).not.toHaveBeenCalled(); expect(mocks.getAppDetailById).not.toHaveBeenCalled(); }); }); From 8e2eee550ca8097a59ed6ada751be2c8d568f8af Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 16:53:28 +0800 Subject: [PATCH 18/38] fix(app): fit resource selector to content --- projects/app/src/components/Select/AppSelect.tsx | 6 +++--- .../src/components/common/folder/SelectOneResource.tsx | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/projects/app/src/components/Select/AppSelect.tsx b/projects/app/src/components/Select/AppSelect.tsx index eb51cffbb8af..86d518cd3161 100644 --- a/projects/app/src/components/Select/AppSelect.tsx +++ b/projects/app/src/components/Select/AppSelect.tsx @@ -74,10 +74,10 @@ const AppSelect = ({ value, onSelect }: { value: string; onSelect: (id: string) } > {({ onClose }) => ( - + { if (!item) return; diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index 94e840b4e06c..cd5fdf86ebeb 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -61,6 +61,7 @@ const SelectOneResource = ({ refreshDeps: [currentParentId], showNoMoreTip: false }); + const isAutoHeight = h === 'auto'; const isItemDisabled = (item: SelectOneResourceItemType) => item.disabled || disabledIds.includes(item.id); @@ -134,7 +135,14 @@ const SelectOneResource = ({ ))} - + {data.map((item) => { const disabled = isItemDisabled(item); const selected = item.id === value; From 1d7b68f2ed20673a84b03b57e2ca34d532732fec Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 20:21:34 +0800 Subject: [PATCH 19/38] fix(app): add resource selector spacing --- .../app/src/components/common/folder/SelectOneResource.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index cd5fdf86ebeb..1b6c78935fd4 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -150,8 +150,8 @@ const SelectOneResource = ({ Date: Thu, 3 Sep 2026 20:36:53 +0800 Subject: [PATCH 20/38] feat(app): paginate skill picker resources --- packages/global/openapi/core/app/index.ts | 2 + .../global/openapi/core/app/toolSet/api.ts | 34 ++ .../global/openapi/core/app/toolSet/index.ts | 30 ++ packages/global/test/openapi/core/app.test.ts | 1 + .../plugins/SkillPickerPlugin/index.tsx | 376 ++++++++++++++++-- .../Edit/ChatAgent/hooks/useSkillManager.tsx | 246 +++++++----- .../src/pages/api/core/app/toolSet/listV2.ts | 67 ++++ projects/app/src/web/core/app/api/tool.ts | 39 +- .../test/api/core/app/toolSet/listV2.test.ts | 60 +++ 9 files changed, 715 insertions(+), 140 deletions(-) create mode 100644 packages/global/openapi/core/app/toolSet/api.ts create mode 100644 packages/global/openapi/core/app/toolSet/index.ts create mode 100644 projects/app/src/pages/api/core/app/toolSet/listV2.ts create mode 100644 projects/app/test/api/core/app/toolSet/listV2.test.ts diff --git a/packages/global/openapi/core/app/index.ts b/packages/global/openapi/core/app/index.ts index 0875f5c49c9e..2a147e7cc0cf 100644 --- a/packages/global/openapi/core/app/index.ts +++ b/packages/global/openapi/core/app/index.ts @@ -10,6 +10,7 @@ import { AppTemplatePath } from './template'; import { AppPermissionPath } from './permission'; import { ToolPath } from './tool'; import { AppEvaluationPath } from './evaluation'; +import { ToolSetPath } from './toolSet'; export const AppPath: OpenAPIPath = { ...AppCommonPath, @@ -18,6 +19,7 @@ export const AppPath: OpenAPIPath = { ...AppVersionPath, ...AppTemplatePath, ...AppEvaluationPath, + ...ToolSetPath, ...AppLogPath, ...PublishChannelPath, ...McpToolsPath, diff --git a/packages/global/openapi/core/app/toolSet/api.ts b/packages/global/openapi/core/app/toolSet/api.ts new file mode 100644 index 000000000000..035e2594e548 --- /dev/null +++ b/packages/global/openapi/core/app/toolSet/api.ts @@ -0,0 +1,34 @@ +import z from 'zod'; +import { PaginationResponseSchema, PaginationSchema } from '../../../api'; +import { AppTypeEnum } from '../../../../core/app/constants'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; +import { NodeTemplateListItemTypeSchema } from '../../../../core/workflow/type/node'; + +/** + * API: 分页获取 MCP/HTTP 工具集下的工具 + * Route: POST /core/app/toolSet/listV2 + */ +export const ListToolSetV2BodySchema = z + .object({ + parentId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: 'MCP 或 HTTP 工具集应用 ID' + }), + searchKey: z.string().optional().meta({ + example: 'search', + description: '工具名称或描述搜索关键词' + }) + }) + .extend(PaginationSchema.shape); +export type ListToolSetV2BodyType = z.infer; + +export const ToolSetListItemSchema = NodeTemplateListItemTypeSchema.extend({ + appType: z.enum([AppTypeEnum.mcpToolSet, AppTypeEnum.httpToolSet]).meta({ + description: '工具集类型' + }) +}); + +export const ListToolSetV2ResponseSchema = PaginationResponseSchema(ToolSetListItemSchema).meta({ + description: '工具集子工具列表(分页)' +}); +export type ListToolSetV2ResponseType = z.infer; diff --git a/packages/global/openapi/core/app/toolSet/index.ts b/packages/global/openapi/core/app/toolSet/index.ts new file mode 100644 index 000000000000..86dbcd75f0d2 --- /dev/null +++ b/packages/global/openapi/core/app/toolSet/index.ts @@ -0,0 +1,30 @@ +import type { OpenAPIPath } from '../../../type'; +import { DevApiTagsMap } from '../../../tag'; +import { ListToolSetV2BodySchema, ListToolSetV2ResponseSchema } from './api'; + +export const ToolSetPath: OpenAPIPath = { + '/core/app/toolSet/listV2': { + post: { + summary: '分页获取工具集子工具', + description: '分页获取 MCP 或 HTTP 工具集下当前用户可读取的工具', + tags: [DevApiTagsMap.mcpTools, DevApiTagsMap.httpTools], + requestBody: { + content: { + 'application/json': { + schema: ListToolSetV2BodySchema + } + } + }, + responses: { + 200: { + description: '成功获取分页工具列表', + content: { + 'application/json': { + schema: ListToolSetV2ResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/test/openapi/core/app.test.ts b/packages/global/test/openapi/core/app.test.ts index 3f4766f2d8d7..4404e05d35d8 100644 --- a/packages/global/test/openapi/core/app.test.ts +++ b/packages/global/test/openapi/core/app.test.ts @@ -31,6 +31,7 @@ describe('App OpenAPI contracts', () => { it('registers app ownership transfer and template type APIs', () => { expect(openAPIDocument.paths?.['/core/app/list']?.post).toBeDefined(); expect(openAPIDocument.paths?.['/core/app/listV2']?.post).toBeDefined(); + expect(openAPIDocument.paths?.['/core/app/toolSet/listV2']?.post).toBeDefined(); expect(openAPIDocument.paths?.['/proApi/core/app/changeOwner']?.post).toBeDefined(); expect( openAPIDocument.paths?.['/proApi/core/app/template/getTemplateTypes']?.get diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index e803e02cba2a..7e809e033b3b 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -31,13 +31,34 @@ const MENU_WIDTH = 'min(954px, calc(100vw - 16px))'; const MENU_HEIGHT = '337px'; const FIRST_COLUMN_WIDTH = '200px'; const CHILD_COLUMN_WIDTH = '280px'; +const PAGE_SIZE = 50; +const PAGE_LOAD_THRESHOLD = 80; + +export type SkillOptionPageType = { + list: SkillItemType[]; + total: number; +}; + +export type SkillOptionPageLoader = ( + params: { offset: number; pageSize: number }, + cancelToken?: AbortController +) => Promise; + +export type SkillFolderPageLoader = ( + id: string, + source: string | undefined, + params: { offset: number; pageSize: number }, + cancelToken?: AbortController +) => Promise; export type SkillOptionItemType = { description?: string; list: SkillItemType[]; + total?: number; onSelect?: (id: string) => Promise; onClick?: (id: string, source?: string) => Promise; - onFolderLoad?: (id: string, source?: string) => Promise; + loadPage?: SkillOptionPageLoader; + onFolderLoad?: SkillFolderPageLoader; }; export type SkillClickResult = { @@ -67,6 +88,32 @@ export type SkillItemType = { const getSkillItemKey = (item: Pick) => getToolIdentityKey(item.id, item.source); +const mergeSkillItems = (current: SkillItemType[], incoming: SkillItemType[]) => { + const keys = new Set(current.map(getSkillItemKey)); + return current.concat( + incoming.filter((item) => { + const key = getSkillItemKey(item); + if (keys.has(key)) return false; + keys.add(key); + return true; + }) + ); +}; + +type ColumnPageState = { + option: SkillOptionItemType; + offset: number; + total: number; + loading: boolean; + error: boolean; + lastRequestedOffset?: number; + loadedOffsets: Set; + requestId: number; + controller?: AbortController; +}; + +type ColumnPageStateView = Pick; + export default function SkillPickerPlugin({ skillOption, isFocus, @@ -95,8 +142,13 @@ export default function SkillPickerPlugin({ const [interactionMode, setInteractionMode] = useState<'mouse' | 'keyboard'>('mouse'); const selectionRequestIdRef = useRef(0); const folderOptionsRef = useRef>(new Map()); - const folderRequestsRef = useRef>>(new Map()); + const folderRequestsRef = useRef< + Map; controller: AbortController }> + >(new Map()); const [loadingFolderIds, setLoadingFolderIds] = useState>(new Set()); + const columnPageStateRef = useRef>(new Map()); + const columnElementRef = useRef>(new Map()); + const [columnPageStates, setColumnPageStates] = useState>({}); // Refs for scroll management const itemRefs = useRef>(new Map()); @@ -110,7 +162,7 @@ export default function SkillPickerPlugin({ if (!anchorElement || !menuElement) return; const menuRect = menuElement.getBoundingClientRect(); - const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const viewportWidth = window.innerWidth; const edgePadding = 8; const maxLeft = Math.max(edgePadding, viewportWidth - menuRect.width - edgePadding); const nextLeft = Math.min(Math.max(menuRect.left, edgePadding), maxLeft); @@ -130,8 +182,10 @@ export default function SkillPickerPlugin({ } menuPositionFrameRef.current = requestAnimationFrame(() => { - menuPositionFrameRef.current = null; - updateMenuPosition(); + menuPositionFrameRef.current = requestAnimationFrame(() => { + menuPositionFrameRef.current = null; + updateMenuPosition(); + }); }); }, [updateMenuPosition]); @@ -219,14 +273,196 @@ export default function SkillPickerPlugin({ return undefined; }, []); - const appendColumn = useCallback((columnIndex: number, option?: SkillOptionItemType) => { - setSkillOptions((prev) => { - const next = prev.slice(0, columnIndex + 1); - if (option) next.push(option); - return next; + const publishColumnPageState = useCallback((columnIndex: number, state: ColumnPageState) => { + setColumnPageStates((prev) => ({ + ...prev, + [columnIndex]: { + offset: state.offset, + total: state.total, + loading: state.loading, + error: state.error + } + })); + }, []); + + const resetColumnPageState = useCallback( + (columnIndex: number, option?: SkillOptionItemType) => { + const currentState = columnPageStateRef.current.get(columnIndex); + if (option && currentState?.option === option) return; + + currentState?.controller?.abort(); + + if (!option) { + columnPageStateRef.current.delete(columnIndex); + setColumnPageStates((prev) => { + if (!(columnIndex in prev)) return prev; + const next = { ...prev }; + delete next[columnIndex]; + return next; + }); + return; + } + + const total = option.total ?? option.list.length; + const nextState: ColumnPageState = { + option, + offset: option.list.length, + total, + loading: false, + error: false, + loadedOffsets: new Set(option.list.length > 0 || total === 0 ? [0] : []), + requestId: 0 + }; + columnPageStateRef.current.set(columnIndex, nextState); + publishColumnPageState(columnIndex, nextState); + }, + [publishColumnPageState] + ); + + const clearColumnPageStatesAfter = useCallback((columnIndex: number) => { + columnPageStateRef.current.forEach((state, index) => { + if (index < columnIndex) return; + state.controller?.abort(); + columnPageStateRef.current.delete(index); + }); + + setColumnPageStates((prev) => { + const next = { ...prev }; + let changed = false; + Object.keys(next).forEach((key) => { + if (Number(key) < columnIndex) return; + delete next[Number(key)]; + changed = true; + }); + return changed ? next : prev; }); }, []); + const appendColumn = useCallback( + (columnIndex: number, option?: SkillOptionItemType) => { + if (option) { + resetColumnPageState(columnIndex + 1, option); + } else { + clearColumnPageStatesAfter(columnIndex + 1); + } + + setSkillOptions((prev) => { + const next = prev.slice(0, columnIndex + 1); + if (option) next.push(option); + return next; + }); + }, + [clearColumnPageStatesAfter, resetColumnPageState] + ); + + const loadColumnPage = useCallback( + async (columnIndex: number, option: SkillOptionItemType, offset: number) => { + if (!option.loadPage) return; + + let state = columnPageStateRef.current.get(columnIndex); + if (!state || state.option !== option) { + resetColumnPageState(columnIndex, option); + state = columnPageStateRef.current.get(columnIndex); + } + if (!state || state.loading || state.loadedOffsets.has(offset) || offset >= state.total) { + return; + } + + const controller = new AbortController(); + const requestId = state.requestId + 1; + state.loading = true; + state.error = false; + state.lastRequestedOffset = offset; + state.requestId = requestId; + state.controller = controller; + publishColumnPageState(columnIndex, state); + + try { + const page = await option.loadPage({ offset, pageSize: PAGE_SIZE }, controller); + const latestState = columnPageStateRef.current.get(columnIndex); + if ( + controller.signal.aborted || + !latestState || + latestState.option !== option || + latestState.requestId !== requestId + ) { + return; + } + + const nextOption: SkillOptionItemType = { + ...option, + list: mergeSkillItems(option.list, page.list), + total: page.total + }; + latestState.option = nextOption; + latestState.offset = Math.max(latestState.offset, offset + page.list.length); + latestState.total = page.total; + latestState.loadedOffsets.add(offset); + latestState.loading = false; + latestState.error = false; + latestState.controller = undefined; + + setSkillOptions((prev) => { + if (prev[columnIndex] !== option) return prev; + const next = [...prev]; + next[columnIndex] = nextOption; + return next; + }); + publishColumnPageState(columnIndex, latestState); + } catch (error) { + const latestState = columnPageStateRef.current.get(columnIndex); + if ( + controller.signal.aborted || + !latestState || + latestState.option !== option || + latestState.requestId !== requestId + ) { + return; + } + + latestState.loading = false; + latestState.error = true; + latestState.controller = undefined; + publishColumnPageState(columnIndex, latestState); + void error; + } + }, + [publishColumnPageState, resetColumnPageState] + ); + + const handleColumnScroll = useCallback( + (columnIndex: number) => { + const option = skillOptions[columnIndex]; + const state = columnPageStateRef.current.get(columnIndex); + const element = columnElementRef.current.get(columnIndex); + if ( + !option?.loadPage || + !state || + !element || + state.loading || + state.error || + state.offset >= state.total + ) { + return; + } + + if (element.scrollHeight - element.scrollTop - element.clientHeight <= PAGE_LOAD_THRESHOLD) { + void loadColumnPage(columnIndex, option, state.offset); + } + }, + [loadColumnPage, skillOptions] + ); + + const retryColumnPage = useCallback( + (columnIndex: number) => { + const option = skillOptions[columnIndex]; + const state = columnPageStateRef.current.get(columnIndex); + if (!option?.loadPage || !state || state.loading) return; + void loadColumnPage(columnIndex, option, state.lastRequestedOffset ?? state.offset); + }, + [loadColumnPage, skillOptions] + ); + // Resolve the hovered item into the next navigation column. const { runAsync: handleItemSelect, loading: isItemSelectLoading } = useRequest( async ({ @@ -252,28 +488,35 @@ export default function SkillPickerPlugin({ if (item.isFolder && option.onFolderLoad) { const cachedOption = folderOptionsRef.current.get(itemKey); if (cachedOption) { - appendColumn(currentColumnIndex, { - ...cachedOption, - description: option.description, - onClick: option.onClick, - onFolderLoad: option.onFolderLoad - }); + appendColumn(currentColumnIndex, cachedOption); return; } setLoadingFolderIds((prev) => new Set(prev).add(itemKey)); let pendingRequest = folderRequestsRef.current.get(itemKey); if (!pendingRequest) { - pendingRequest = Promise.resolve().then(() => option.onFolderLoad!(item.id, item.source)); + const controller = new AbortController(); + const promise = Promise.resolve().then(() => + option.onFolderLoad!( + item.id, + item.source, + { offset: 0, pageSize: PAGE_SIZE }, + controller + ) + ); + pendingRequest = { promise, controller }; folderRequestsRef.current.set(itemKey, pendingRequest); } try { - const list = await pendingRequest; + const page = await pendingRequest.promise; if (selectionRequestIdRef.current !== requestId) return; const nextOption: SkillOptionItemType = { description: option.description, - list, + list: page.list, + total: page.total, + loadPage: (params, cancelToken) => + option.onFolderLoad!(item.id, item.source, params, cancelToken), onClick: option.onClick, onFolderLoad: option.onFolderLoad }; @@ -314,9 +557,16 @@ export default function SkillPickerPlugin({ ); useEffect(() => { + const columnPageStates = columnPageStateRef.current; + const folderRequests = folderRequestsRef.current; const timer = window.setTimeout(() => { selectionRequestIdRef.current += 1; + columnPageStates.forEach((state) => state.controller?.abort()); + columnPageStates.clear(); + folderRequests.forEach(({ controller }) => controller.abort()); + folderRequests.clear(); folderOptionsRef.current.clear(); + setColumnPageStates({}); setSkillOptions([skillOption]); setSelectedRowIndex({ 0: 0 }); setCurrentColumnIndex(0); @@ -336,9 +586,39 @@ export default function SkillPickerPlugin({ return () => { window.clearTimeout(timer); selectionRequestIdRef.current += 1; + columnPageStates.forEach((state) => state.controller?.abort()); + folderRequests.forEach(({ controller }) => controller.abort()); }; }, [handleItemSelect, skillOption]); + // Fill short columns so the next page is available without requiring a scrollbar first. + useEffect(() => { + if (!isMenuOpen) return; + + const frame = requestAnimationFrame(() => { + skillOptions.forEach((option, columnIndex) => { + const state = columnPageStateRef.current.get(columnIndex); + const element = columnElementRef.current.get(columnIndex); + if ( + !option.loadPage || + !state || + !element || + state.loading || + state.error || + state.offset >= state.total + ) { + return; + } + + if (element.scrollHeight <= element.clientHeight + PAGE_LOAD_THRESHOLD) { + void loadColumnPage(columnIndex, option, state.offset); + } + }); + }); + + return () => cancelAnimationFrame(frame); + }, [columnPageStates, isMenuOpen, loadColumnPage, skillOptions]); + const insertSkillNodeText = useCallback( (skillId: string, matchingString?: string | null) => { let inserted = false; @@ -593,6 +873,7 @@ export default function SkillPickerPlugin({ setCurrentRowIndex(() => newRowIndex); // Only keep data up to and including the current column + clearColumnPageStatesAfter(newColumnIndex + 1); setSkillOptions((state) => { return state.slice(0, newColumnIndex + 1); }); @@ -695,7 +976,8 @@ export default function SkillPickerPlugin({ handleItemClick, selectedRowIndex, scrollIntoView, - getItemChildOption + getItemChildOption, + clearColumnPageStatesAfter ]); const isExpandable = useCallback( @@ -832,11 +1114,24 @@ export default function SkillPickerPlugin({ columnIndex: number, onSelectOption?: (item: SkillItemType, option: SkillOptionItemType) => void ) => { + const pageState = columnPageStates[columnIndex]; + const canLoadMore = Boolean( + columnData.loadPage && pageState && pageState.offset < pageState.total + ); + return ( { + if (element) { + columnElementRef.current.set(columnIndex, element as HTMLDivElement); + } else { + columnElementRef.current.delete(columnIndex); + } + }} key={columnIndex} p={1.5} borderRadius={'6px'} @@ -847,6 +1142,7 @@ export default function SkillPickerPlugin({ flexShrink={0} overflowY={'auto'} overflowX={'hidden'} + onScroll={() => handleColumnScroll(columnIndex)} sx={{ scrollbarColor: 'var(--chakra-colors-myGray-300) transparent', scrollbarWidth: 'thin', @@ -863,15 +1159,48 @@ export default function SkillPickerPlugin({ )} {renderItemList(columnData.list, columnData, columnIndex, onSelectOption)} - {columnData.list.length === 0 && ( + {columnData.list.length === 0 && !pageState?.loading && !pageState?.error && ( {t('app:empty_folder')} )} + {canLoadMore && pageState?.loading && columnData.list.length > 0 && ( + + + + )} + {canLoadMore && pageState?.error && ( + e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + retryColumnPage(columnIndex); + }} + > + + + )} ); }, - [currentColumnIndex, isItemClickLoading, isItemSelectLoading, renderItemList, t] + [ + columnPageStates, + currentColumnIndex, + handleColumnScroll, + isItemClickLoading, + isItemSelectLoading, + renderItemList, + retryColumnPage, + t + ] ); // For LexicalTypeaheadMenuPlugin compatibility @@ -936,7 +1265,6 @@ export default function SkillPickerPlugin({ menuElementRef.current = element; }} visibility={shouldShow ? 'visible' : 'hidden'} - position="relative" zIndex={99999} w={MENU_WIDTH} h={MENU_HEIGHT} diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx index f4886975a0b0..1854f7b84949 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx @@ -1,6 +1,8 @@ import type { SkillOptionItemType, - SkillItemType + SkillItemType, + SkillOptionPageLoader, + SkillOptionPageType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; @@ -23,20 +25,15 @@ import type { import { getAppToolTemplates, getClientToolPreviewNode, - getTeamAppTemplates + getTeamAppTemplatesV2 } from '@/web/core/app/api/tool'; -import { - AppFolderTypeList, - AppTypeEnum, - AppTypeList, - ToolTypeList -} from '@fastgpt/global/core/app/constants'; +import { AppTypeEnum, AppTypeList, ToolTypeList } from '@fastgpt/global/core/app/constants'; import { useLatest } from 'ahooks'; import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools'; import type { SkillClickResult } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin'; -import { getAllSkillList } from '@/web/core/skill/api'; +import { getSkillListV2 } from '@/web/core/skill/api'; import { AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import { inheritToolInputConfig } from '../../FormComponent/ToolSelector/utils'; @@ -44,6 +41,8 @@ import { getToolIdentityKey } from '@fastgpt/global/core/app/tool/utils'; const ConfigToolModal = dynamic(() => import('../../component/ConfigToolModal')); type AgentSkillListItemType = ListSkillsResponse['list'][number]; +type SkillPickerPageParams = Parameters[0]; +const SKILL_PICKER_PAGE_SIZE = 50; const getSkillId = (id?: string, source?: string) => source ? getToolIdentityKey(id, source) : id || ''; @@ -83,6 +82,21 @@ const toSystemToolItem = (item: NodeTemplateListItemType, parentId?: string): Sk }; }; +const toTeamAppItem = (item: NodeTemplateListItemType, parentId?: string): SkillItemType => { + const isFolder = item.isFolder ?? false; + + return { + parentId: item.parentId ?? parentId, + id: item.id, + source: item.source, + label: item.name, + icon: item.avatar, + description: item.intro, + isFolder, + canClick: !isFolder + }; +}; + const toAgentSkillItem = (item: AgentSkillListItemType): SkillItemType => { const isFolder = item.type === AgentSkillTypeEnum.folder; @@ -129,10 +143,20 @@ export const useSkillManager = ({ const { toast } = useToast(); /* ===== System tool ===== */ - const onFolderLoadSystemTools = useCallback(async (folderId: string, source?: string) => { - const data = await getAppToolTemplates({ parentId: folderId, source }); - return data.map((item) => toSystemToolItem(item, folderId)); - }, []); + const onFolderLoadSystemTools = useCallback( + async ( + folderId: string, + source: string | undefined, + { offset, pageSize }: SkillPickerPageParams + ): Promise => { + const data = await getAppToolTemplates({ parentId: folderId, source }); + return { + list: data.slice(offset, offset + pageSize).map((item) => toSystemToolItem(item, folderId)), + total: data.length + }; + }, + [] + ); const { data: systemTools = [] } = useRequest( async () => { @@ -189,33 +213,39 @@ export const useSkillManager = ({ /* ===== Team agents/tools ===== */ const parentTypes = useRef(new Map()); - const onFolderLoadTeamApps = useCallback(async (folderId: string, types: AppTypeEnum[]) => { - const children = await getTeamAppTemplates({ - parentId: folderId, - parentType: parentTypes.current.get(folderId), - type: types - }); - if (!children || children.length === 0) { - return []; - } - children.forEach(({ id, appType }) => parentTypes.current.set(id, appType)); - - return children.map((item) => { + const loadTeamAppPage = useCallback( + async ( + parentId: string | null, + types: AppTypeEnum[], + params: SkillPickerPageParams, + cancelToken?: AbortController + ): Promise => { + const response = await getTeamAppTemplatesV2( + { + parentId, + parentType: parentId ? parentTypes.current.get(parentId) : undefined, + type: types, + ...params + }, + cancelToken + ); + (response.list as (NodeTemplateListItemType & { appType?: AppTypeEnum })[]).forEach( + ({ id, appType }) => { + if (appType) parentTypes.current.set(id, appType); + } + ); return { - parentId: folderId, - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) + list: response.list.map((item) => toTeamAppItem(item, parentId ?? undefined)), + total: response.total }; - }); - }, []); + }, + [] + ); - const rootListCacheRef = useRef>(new Map()); - const rootListRequestsRef = useRef>>(new Map()); - /** Cache successful root lists while sharing an in-flight request per category. */ - const loadRootList = useCallback((key: string, loader: () => Promise) => { + const rootListCacheRef = useRef>(new Map()); + const rootListRequestsRef = useRef>>(new Map()); + /** Cache the first page while sharing an in-flight request per category. */ + const loadRootPage = useCallback((key: string, loader: () => Promise) => { if (rootListCacheRef.current.has(key)) { return Promise.resolve(rootListCacheRef.current.get(key)!); } @@ -224,13 +254,9 @@ export const useSkillManager = ({ if (pendingRequest) return pendingRequest; const request = loader() - .then((list) => { - rootListCacheRef.current.set(key, list); - return list; - }) - .catch(() => { - // Keep failures silent and uncached so the next visit can retry. - return []; + .then((page) => { + rootListCacheRef.current.set(key, page); + return page; }) .finally(() => { rootListRequestsRef.current.delete(key); @@ -253,13 +279,26 @@ export const useSkillManager = ({ }, []); const onFolderLoadAgentSkills = useCallback( - async (folderId: string) => { - const list = await getAllSkillList({ - source: 'mine', - parentId: folderId, - withAppCount: false - }); - return cacheAgentSkillList(list); + async ( + folderId: string, + _source: string | undefined, + { offset, pageSize }: SkillPickerPageParams, + cancelToken?: AbortController + ): Promise => { + const response = await getSkillListV2( + { + source: 'mine', + parentId: folderId, + offset, + pageSize, + withAppCount: false + }, + cancelToken + ); + return { + list: cacheAgentSkillList(response.list), + total: response.total + }; }, [cacheAgentSkillList] ); @@ -410,74 +449,85 @@ export const useSkillManager = ({ ] ); - /** Lazily load the root-level tools and preserve the folder loader contract. */ + /** Lazily load the first page of tools and keep later pages on the same loader. */ const loadMyTools = useCallback(async () => { - const list = await loadRootList('myTools', async () => { - const apps = await getTeamAppTemplates({ - parentId: null, - type: [AppTypeEnum.toolFolder, ...ToolTypeList] - }); - - return apps.map((item) => ({ - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) - })); - }); + const types = [AppTypeEnum.toolFolder, ...ToolTypeList]; + const firstPage = await loadRootPage('myTools', () => + loadTeamAppPage(null, types, { offset: 0, pageSize: SKILL_PICKER_PAGE_SIZE }) + ); return { description: t('app:space_to_expand_folder'), - list, - onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, ToolTypeList), + list: firstPage.list, + total: firstPage.total, + loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => + loadTeamAppPage(null, types, params, cancelToken), + onFolderLoad: ( + folderId: string, + _source: string | undefined, + params: SkillPickerPageParams, + cancelToken?: AbortController + ) => loadTeamAppPage(folderId, ToolTypeList, params, cancelToken), onClick: onAddAppOrTool }; - }, [loadRootList, onAddAppOrTool, onFolderLoadTeamApps, t]); + }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); - /** Lazily load the root-level agents and preserve the folder loader contract. */ + /** Lazily load the first page of agents and keep later pages on the same loader. */ const loadMyAgents = useCallback(async () => { - const list = await loadRootList('agent', async () => { - const apps = await getTeamAppTemplates({ - parentId: null, - type: [AppTypeEnum.folder, ...AppTypeList] - }); - - return apps.map((item) => ({ - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) - })); - }); + const types = [AppTypeEnum.folder, ...AppTypeList]; + const firstPage = await loadRootPage('agent', () => + loadTeamAppPage(null, types, { offset: 0, pageSize: SKILL_PICKER_PAGE_SIZE }) + ); return { description: t('app:space_to_expand_folder'), - list, - onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList), + list: firstPage.list, + total: firstPage.total, + loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => + loadTeamAppPage(null, types, params, cancelToken), + onFolderLoad: ( + folderId: string, + _source: string | undefined, + params: SkillPickerPageParams, + cancelToken?: AbortController + ) => loadTeamAppPage(folderId, AppTypeList, params, cancelToken), onClick: onAddAppOrTool }; - }, [loadRootList, onAddAppOrTool, onFolderLoadTeamApps, t]); + }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); - /** Lazily load associated skills and update the skill lookup map for selection. */ + /** Lazily load the first page of associated skills and update the lookup map. */ const loadAgentSkills = useCallback(async () => { - const list = await loadRootList('agentSkill', async () => { - const skills = await getAllSkillList({ - source: 'mine', - parentId: '', - withAppCount: false - }); - return cacheAgentSkillList(skills); - }); + const loadPage = async ( + params: SkillPickerPageParams, + cancelToken?: AbortController + ): Promise => { + const response = await getSkillListV2( + { + source: 'mine', + parentId: null, + withAppCount: false, + ...params + }, + cancelToken + ); + return { + list: cacheAgentSkillList(response.list), + total: response.total + }; + }; + const firstPage = await loadRootPage('agentSkill', () => + loadPage({ offset: 0, pageSize: SKILL_PICKER_PAGE_SIZE }) + ); return { description: t('app:space_to_expand_folder'), - list, + list: firstPage.list, + total: firstPage.total, + loadPage, onFolderLoad: onFolderLoadAgentSkills, onClick: onAddSkill }; - }, [cacheAgentSkillList, loadRootList, onAddSkill, onFolderLoadAgentSkills, t]); + }, [cacheAgentSkillList, loadRootPage, onAddSkill, onFolderLoadAgentSkills, t]); /* ===== Skill option ===== */ const skillOption = useMemo(() => { diff --git a/projects/app/src/pages/api/core/app/toolSet/listV2.ts b/projects/app/src/pages/api/core/app/toolSet/listV2.ts new file mode 100644 index 000000000000..074621332b5c --- /dev/null +++ b/projects/app/src/pages/api/core/app/toolSet/listV2.ts @@ -0,0 +1,67 @@ +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { NextAPI } from '@/service/middleware/entry'; +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { replaceRegChars } from '@fastgpt/global/common/string/tools'; +import { getHTTPToolList } from '@fastgpt/service/core/app/http'; +import { getMCPChildren } from '@fastgpt/service/core/app/mcp'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { + ListToolSetV2BodySchema, + ListToolSetV2ResponseSchema, + type ListToolSetV2BodyType, + type ListToolSetV2ResponseType +} from '@fastgpt/global/openapi/core/app/toolSet/api'; + +/** Return one paginated page of tools from an authenticated MCP or HTTP toolset. */ +async function handler( + req: ApiRequestProps +): Promise { + const { + parentId, + searchKey, + pageNum = 1, + pageSize = 50, + offset + } = parseApiInput({ req, bodySchema: ListToolSetV2BodySchema }).body; + const { app } = await authApp({ + req, + authToken: true, + authApiKey: true, + appId: parentId, + per: ReadPermissionVal + }); + + if (app.type !== AppTypeEnum.mcpToolSet && app.type !== AppTypeEnum.httpToolSet) { + return Promise.reject(new UserError('the parent is not a mcp or http toolset')); + } + + const tools = + app.type === AppTypeEnum.mcpToolSet ? await getMCPChildren(app) : await getHTTPToolList(app); + const searchRegex = searchKey?.trim() + ? new RegExp(replaceRegChars(searchKey.trim()), 'i') + : undefined; + const filteredTools = searchRegex + ? tools.filter((tool) => searchRegex.test(`${tool.name}\n${tool.description ?? ''}`)) + : tools; + const skip = offset ?? (pageNum - 1) * pageSize; + const list = filteredTools.slice(skip, skip + pageSize).map((tool) => ({ + id: tool.id, + avatar: tool.avatar ?? app.avatar, + name: tool.name, + intro: tool.description ?? '', + flowNodeType: FlowNodeTypeEnum.tool, + templateType: FlowNodeTemplateTypeEnum.teamApp, + appType: app.type, + isTool: true, + isFolder: false + })); + + return ListToolSetV2ResponseSchema.parse({ list, total: filteredTools.length }); +} + +export default NextAPI(handler); diff --git a/projects/app/src/web/core/app/api/tool.ts b/projects/app/src/web/core/app/api/tool.ts index 6ff7d93c027f..93107dd57dc4 100644 --- a/projects/app/src/web/core/app/api/tool.ts +++ b/projects/app/src/web/core/app/api/tool.ts @@ -4,6 +4,10 @@ import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; import type { PaginationResponseType } from '@fastgpt/global/openapi/api'; +import type { + ListToolSetV2BodyType, + ListToolSetV2ResponseType +} from '@fastgpt/global/openapi/core/app/toolSet/api'; import type { ListAppV2BodyType, ListAppV2ResponseType @@ -123,7 +127,8 @@ export const getTeamAppTemplates = async (data?: { /** Load one paginated team app template page while preserving toolset child behavior. */ export const getTeamAppTemplatesV2 = async ( - data: TeamAppTemplatesV2Query + data: TeamAppTemplatesV2Query, + cancelToken?: AbortController ): Promise> => { const { parentId, parentType, searchKey, type, excludeAppId, ...pagination } = data; @@ -133,26 +138,20 @@ export const getTeamAppTemplatesV2 = async ( resolvedParentType === AppTypeEnum.mcpToolSet || resolvedParentType === AppTypeEnum.httpToolSet ) { - const list = await getTeamAppTemplates({ - parentId, - parentType: resolvedParentType, - searchKey, - type - }); - return { - list, - total: list.length - }; + return getToolSetListV2({ parentId, searchKey, ...pagination }, cancelToken); } } - const response = await getMyAppsV2({ - ...pagination, - parentId, - searchKey, - type, - excludeAppId - }); + const response = await getMyAppsV2( + { + ...pagination, + parentId, + searchKey, + type, + excludeAppId + }, + cancelToken + ); return { list: response.list.map(mapAppToTeamTemplate), @@ -160,6 +159,10 @@ export const getTeamAppTemplatesV2 = async ( }; }; +/** Fetch one paginated page from an MCP or HTTP toolset. */ +export const getToolSetListV2 = (data: ListToolSetV2BodyType, cancelToken?: AbortController) => + POST('/core/app/toolSet/listV2', data, { cancelToken }); + /* ============ Tool ============== */ export const getAppToolTemplates = (data: GetSystemToolTemplatesBodyType) => POST('/core/app/tool/getSystemToolTemplates', data); diff --git a/projects/app/test/api/core/app/toolSet/listV2.test.ts b/projects/app/test/api/core/app/toolSet/listV2.test.ts new file mode 100644 index 000000000000..b42e083fc8ec --- /dev/null +++ b/projects/app/test/api/core/app/toolSet/listV2.test.ts @@ -0,0 +1,60 @@ +import handler from '@/pages/api/core/app/toolSet/listV2'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import type { + ListToolSetV2BodyType, + ListToolSetV2ResponseType +} from '@fastgpt/global/openapi/core/app/toolSet/api'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getUser } from '@test/datas/users'; +import { Call } from '@test/utils/request'; +import { describe, expect, it } from 'vitest'; + +describe('POST /api/core/app/toolSet/listV2', () => { + it('filters and paginates HTTP toolset children after authentication', async () => { + const user = await getUser(`toolset-list-v2-${getNanoid(6)}`); + const app = await MongoApp.create({ + name: 'HTTP toolset', + type: AppTypeEnum.httpToolSet, + teamId: user.teamId, + tmbId: user.tmbId, + modules: [ + { + toolConfig: { + httpToolSet: { + toolList: [ + { name: 'create-ticket', description: 'Create a ticket' }, + { name: 'search-ticket', description: 'Search tickets' }, + { name: 'delete-ticket', description: 'Delete a ticket' } + ] + } + } + } + ] + }); + + const response = await Call< + ListToolSetV2BodyType, + Record, + ListToolSetV2ResponseType + >(handler, { + auth: user, + body: { + parentId: String(app._id), + searchKey: 'ticket', + offset: 1, + pageSize: 1 + } + }); + + expect(response.code).toBe(200); + expect(response.data.total).toBe(3); + expect(response.data.list).toHaveLength(1); + expect(response.data.list[0]).toMatchObject({ + name: 'search-ticket', + id: `http-${app._id}/search-ticket`, + flowNodeType: 'tool', + isFolder: false + }); + }); +}); From 49630bb47a1ffb80e03313ea314496136678bee3 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 21:45:15 +0800 Subject: [PATCH 21/38] fix(app): hide empty skill picker background --- .../plugins/SkillPickerPlugin/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index 7e809e033b3b..f380a7c464ea 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -155,6 +155,14 @@ export default function SkillPickerPlugin({ const menuAnchorRef = useRef(null); const menuElementRef = useRef(null); const menuPositionFrameRef = useRef(null); + const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); + + const updateHorizontalOverflow = useCallback(() => { + const menuElement = menuElementRef.current; + if (!menuElement) return; + + setHasHorizontalOverflow(menuElement.scrollWidth > menuElement.clientWidth); + }, []); const updateMenuPosition = useCallback(() => { const anchorElement = menuAnchorRef.current; @@ -197,6 +205,7 @@ export default function SkillPickerPlugin({ if (!anchorElement || !menuElement) return; scheduleMenuPosition(); + updateHorizontalOverflow(); const anchorObserver = new MutationObserver(scheduleMenuPosition); anchorObserver.observe(anchorElement, { @@ -204,7 +213,10 @@ export default function SkillPickerPlugin({ attributeFilter: ['style'] }); - const menuObserver = new ResizeObserver(scheduleMenuPosition); + const menuObserver = new ResizeObserver(() => { + scheduleMenuPosition(); + updateHorizontalOverflow(); + }); menuObserver.observe(menuElement); window.addEventListener('resize', scheduleMenuPosition); @@ -221,7 +233,7 @@ export default function SkillPickerPlugin({ menuPositionFrameRef.current = null; } }; - }, [isFocus, isMenuOpen, scheduleMenuPosition, skillOptions.length]); + }, [isFocus, isMenuOpen, scheduleMenuPosition, skillOptions.length, updateHorizontalOverflow]); // Scroll the selected row into view and reveal newly appended columns. const scrollIntoView = useCallback((columnIndex: number, rowIndex: number, retryCount = 0) => { @@ -1269,7 +1281,7 @@ export default function SkillPickerPlugin({ w={MENU_WIDTH} h={MENU_HEIGHT} p={2} - bg={'#fbfbfc'} + bg={hasHorizontalOverflow ? '#fbfbfc' : 'transparent'} borderRadius={'12px'} overflowX={'auto'} overflowY={'hidden'} From 55b84a596585a2f7f11dc2a14972ae370247a3bf Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 23:39:24 +0800 Subject: [PATCH 22/38] fix(app): stabilize skill picker positioning --- .../plugins/SkillPickerPlugin/index.tsx | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index f380a7c464ea..35750b6d3d38 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -155,6 +155,7 @@ export default function SkillPickerPlugin({ const menuAnchorRef = useRef(null); const menuElementRef = useRef(null); const menuPositionFrameRef = useRef(null); + const matchingStringRef = useRef(null); const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const updateHorizontalOverflow = useCallback(() => { @@ -164,25 +165,52 @@ export default function SkillPickerPlugin({ setHasHorizontalOverflow(menuElement.scrollWidth > menuElement.clientWidth); }, []); + const getTriggerRect = useCallback(() => { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode; + if ( + !selection || + !selection.isCollapsed || + !anchorNode || + anchorNode.nodeType !== Node.TEXT_NODE + ) { + return undefined; + } + + const triggerLength = (matchingStringRef.current?.length ?? 0) + 1; + const startOffset = selection.anchorOffset - triggerLength; + if (startOffset < 0) return undefined; + + const range = document.createRange(); + try { + range.setStart(anchorNode, startOffset); + range.setEnd(anchorNode, selection.anchorOffset); + return range.getBoundingClientRect(); + } catch { + return undefined; + } + }, []); + const updateMenuPosition = useCallback(() => { const anchorElement = menuAnchorRef.current; const menuElement = menuElementRef.current; if (!anchorElement || !menuElement) return; const menuRect = menuElement.getBoundingClientRect(); + const triggerRect = getTriggerRect(); const viewportWidth = window.innerWidth; const edgePadding = 8; const maxLeft = Math.max(edgePadding, viewportWidth - menuRect.width - edgePadding); - const nextLeft = Math.min(Math.max(menuRect.left, edgePadding), maxLeft); + const nextLeft = Math.min(Math.max(triggerRect?.left ?? menuRect.left, edgePadding), maxLeft); const currentAnchorLeft = Number.parseFloat(anchorElement.style.left); if (!Number.isFinite(currentAnchorLeft)) return; - const nextAnchorLeft = currentAnchorLeft + nextLeft - menuRect.left; + const nextAnchorLeft = nextLeft + window.pageXOffset; if (Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { anchorElement.style.left = `${nextAnchorLeft}px`; } - }, []); + }, [getTriggerRect]); const scheduleMenuPosition = useCallback(() => { if (menuPositionFrameRef.current !== null) { @@ -492,8 +520,8 @@ export default function SkillPickerPlugin({ const itemKey = getSkillItemKey(item); const childOption = getItemChildOption(item, option); - appendColumn(currentColumnIndex, childOption); if (childOption) { + appendColumn(currentColumnIndex, childOption); return; } @@ -740,22 +768,18 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; - // Use functional update to get the latest row index - setCurrentRowIndex((prevRowIndex) => { - const newIndex = prevRowIndex > 0 ? prevRowIndex - 1 : columnItems.length - 1; + // Move the highlight before resolving the next navigation column. + const newIndex = currentRowIndex > 0 ? currentRowIndex - 1 : columnItems.length - 1; + setCurrentRowIndex(newIndex); - void handleItemSelect({ - currentColumnIndex: currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); - - // Scroll into view after state update - requestAnimationFrame(() => { - scrollIntoView(currentColumnIndex, newIndex); - }); + void handleItemSelect({ + currentColumnIndex, + item: columnItems[newIndex], + option: skillOptions[currentColumnIndex] + }); - return newIndex; + requestAnimationFrame(() => { + scrollIntoView(currentColumnIndex, newIndex); }); } @@ -778,22 +802,18 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; - // Use functional update to get the latest row index - setCurrentRowIndex((prevRowIndex) => { - const newIndex = prevRowIndex < columnItems.length - 1 ? prevRowIndex + 1 : 0; + // Move the highlight before resolving the next navigation column. + const newIndex = currentRowIndex < columnItems.length - 1 ? currentRowIndex + 1 : 0; + setCurrentRowIndex(newIndex); - void handleItemSelect({ - currentColumnIndex: currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); - - // Scroll into view after state update - requestAnimationFrame(() => { - scrollIntoView(currentColumnIndex, newIndex); - }); + void handleItemSelect({ + currentColumnIndex, + item: columnItems[newIndex], + option: skillOptions[currentColumnIndex] + }); - return newIndex; + requestAnimationFrame(() => { + scrollIntoView(currentColumnIndex, newIndex); }); } @@ -1259,6 +1279,7 @@ export default function SkillPickerPlugin({ return ( { + matchingStringRef.current = matchingString; // Update menu open state based on query updateMenuOpen(matchingString !== null); }} @@ -1278,7 +1299,8 @@ export default function SkillPickerPlugin({ }} visibility={shouldShow ? 'visible' : 'hidden'} zIndex={99999} - w={MENU_WIDTH} + w={'max-content'} + maxW={MENU_WIDTH} h={MENU_HEIGHT} p={2} bg={hasHorizontalOverflow ? '#fbfbfc' : 'transparent'} From 9e2ee74368161bea4812efb1b4f9f0640c5f989c Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Thu, 3 Sep 2026 23:54:37 +0800 Subject: [PATCH 23/38] fix(app): avoid skill picker navigation flicker --- .../plugins/SkillPickerPlugin/index.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index 35750b6d3d38..e2371521cce5 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -126,10 +126,20 @@ export default function SkillPickerPlugin({ const { t } = useTranslation(); const [skillOptions, setSkillOptions] = useState([skillOption]); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuPositioned, setIsMenuPositioned] = useState(false); + const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); + const [menuQueryVersion, setMenuQueryVersion] = useState(0); const isMenuOpenRef = useRef(false); const updateMenuOpen = useCallback((open: boolean) => { + const wasOpen = isMenuOpenRef.current; isMenuOpenRef.current = open; + if (!open || !wasOpen) { + setIsMenuPositioned(false); + } + if (!open) { + setHasHorizontalOverflow(false); + } setIsMenuOpen(open); }, []); @@ -156,7 +166,6 @@ export default function SkillPickerPlugin({ const menuElementRef = useRef(null); const menuPositionFrameRef = useRef(null); const matchingStringRef = useRef(null); - const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const updateHorizontalOverflow = useCallback(() => { const menuElement = menuElementRef.current; @@ -210,6 +219,7 @@ export default function SkillPickerPlugin({ if (Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { anchorElement.style.left = `${nextAnchorLeft}px`; } + setIsMenuPositioned(true); }, [getTriggerRect]); const scheduleMenuPosition = useCallback(() => { @@ -261,7 +271,14 @@ export default function SkillPickerPlugin({ menuPositionFrameRef.current = null; } }; - }, [isFocus, isMenuOpen, scheduleMenuPosition, skillOptions.length, updateHorizontalOverflow]); + }, [ + isFocus, + isMenuOpen, + menuQueryVersion, + scheduleMenuPosition, + skillOptions.length, + updateHorizontalOverflow + ]); // Scroll the selected row into view and reveal newly appended columns. const scrollIntoView = useCallback((columnIndex: number, rowIndex: number, retryCount = 0) => { @@ -504,7 +521,7 @@ export default function SkillPickerPlugin({ ); // Resolve the hovered item into the next navigation column. - const { runAsync: handleItemSelect, loading: isItemSelectLoading } = useRequest( + const { runAsync: handleItemSelect } = useRequest( async ({ currentColumnIndex, item, @@ -1154,7 +1171,7 @@ export default function SkillPickerPlugin({ return ( { @@ -1228,7 +1245,6 @@ export default function SkillPickerPlugin({ currentColumnIndex, handleColumnScroll, isItemClickLoading, - isItemSelectLoading, renderItemList, retryColumnPage, t @@ -1280,6 +1296,9 @@ export default function SkillPickerPlugin({ { matchingStringRef.current = matchingString; + if (matchingString !== null) { + setMenuQueryVersion((version) => version + 1); + } // Update menu open state based on query updateMenuOpen(matchingString !== null); }} @@ -1297,7 +1316,7 @@ export default function SkillPickerPlugin({ ref={(element) => { menuElementRef.current = element; }} - visibility={shouldShow ? 'visible' : 'hidden'} + visibility={shouldShow && isMenuPositioned ? 'visible' : 'hidden'} zIndex={99999} w={'max-content'} maxW={MENU_WIDTH} From a11dbaf6a2f634009a4566f174a0c22fbeb61336 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 10:40:04 +0800 Subject: [PATCH 24/38] fix(app): show skill picker after mount --- .../plugins/SkillPickerPlugin/index.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index e2371521cce5..f204468e70ac 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -213,10 +213,8 @@ export default function SkillPickerPlugin({ const nextLeft = Math.min(Math.max(triggerRect?.left ?? menuRect.left, edgePadding), maxLeft); const currentAnchorLeft = Number.parseFloat(anchorElement.style.left); - if (!Number.isFinite(currentAnchorLeft)) return; - const nextAnchorLeft = nextLeft + window.pageXOffset; - if (Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { + if (!Number.isFinite(currentAnchorLeft) || Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { anchorElement.style.left = `${nextAnchorLeft}px`; } setIsMenuPositioned(true); @@ -235,6 +233,17 @@ export default function SkillPickerPlugin({ }); }, [updateMenuPosition]); + const setMenuElement = useCallback( + (element: HTMLDivElement | null) => { + menuElementRef.current = element; + if (element && isMenuOpenRef.current) { + setIsMenuPositioned(false); + scheduleMenuPosition(); + } + }, + [scheduleMenuPosition] + ); + useEffect(() => { if (!isFocus || !isMenuOpen) return; @@ -1313,9 +1322,7 @@ export default function SkillPickerPlugin({ return ReactDOM.createPortal( { - menuElementRef.current = element; - }} + ref={setMenuElement} visibility={shouldShow && isMenuPositioned ? 'visible' : 'hidden'} zIndex={99999} w={'max-content'} From 78313784822fdd8c35b1ff01c67f620eb4cae005 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 10:52:34 +0800 Subject: [PATCH 25/38] fix(folder): prevent resource item shrinking --- pro | 2 +- projects/app/src/components/common/folder/SelectOneResource.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pro b/pro index 26544a3004ff..9cca902631f1 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 26544a3004ff56b22bdc39d703a7a0340f5b2644 +Subproject commit 9cca902631f116f9d0d861957f8a7f8487a83957 diff --git a/projects/app/src/components/common/folder/SelectOneResource.tsx b/projects/app/src/components/common/folder/SelectOneResource.tsx index 1b6c78935fd4..3ba66fe5e73c 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -151,6 +151,7 @@ const SelectOneResource = ({ key={item.id} alignItems={'center'} h={'36px'} + flexShrink={0} px={3} borderRadius={'md'} cursor={disabled ? 'not-allowed' : 'pointer'} From 5a43b209ea35c74892cf97cc87cf375e7bf29b45 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 12:00:45 +0800 Subject: [PATCH 26/38] fix(app): scope skill picker folder expansion --- .../plugins/SkillPickerPlugin/index.tsx | 91 +++++++++++++------ .../Edit/ChatAgent/hooks/useSkillManager.tsx | 8 +- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index f204468e70ac..82c7365f2781 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -55,6 +55,7 @@ export type SkillOptionItemType = { description?: string; list: SkillItemType[]; total?: number; + folderExpandMode?: 'auto' | 'manual'; onSelect?: (id: string) => Promise; onClick?: (id: string, source?: string) => Promise; loadPage?: SkillOptionPageLoader; @@ -100,6 +101,9 @@ const mergeSkillItems = (current: SkillItemType[], incoming: SkillItemType[]) => ); }; +const isManualFolder = (item: SkillItemType, option: SkillOptionItemType) => + Boolean(item.isFolder && option.folderExpandMode === 'manual'); + type ColumnPageState = { option: SkillOptionItemType; offset: number; @@ -205,17 +209,22 @@ export default function SkillPickerPlugin({ const menuElement = menuElementRef.current; if (!anchorElement || !menuElement) return; + const currentAnchorLeft = Number.parseFloat(anchorElement.style.left); + if (!Number.isFinite(currentAnchorLeft)) return; + const menuRect = menuElement.getBoundingClientRect(); const triggerRect = getTriggerRect(); const viewportWidth = window.innerWidth; const edgePadding = 8; const maxLeft = Math.max(edgePadding, viewportWidth - menuRect.width - edgePadding); const nextLeft = Math.min(Math.max(triggerRect?.left ?? menuRect.left, edgePadding), maxLeft); - const currentAnchorLeft = Number.parseFloat(anchorElement.style.left); + const anchorRect = anchorElement.getBoundingClientRect(); + const nextOffset = nextLeft - anchorRect.left; + const nextTransform = `translate3d(${nextOffset}px, 0, 0)`; - const nextAnchorLeft = nextLeft + window.pageXOffset; - if (!Number.isFinite(currentAnchorLeft) || Math.abs(nextAnchorLeft - currentAnchorLeft) > 0.5) { - anchorElement.style.left = `${nextAnchorLeft}px`; + // Keep Lexical as the single owner of the anchor position. The menu transform only corrects viewport overflow. + if (menuElement.style.transform !== nextTransform) { + menuElement.style.transform = nextTransform; } setIsMenuPositioned(true); }, [getTriggerRect]); @@ -235,6 +244,9 @@ export default function SkillPickerPlugin({ const setMenuElement = useCallback( (element: HTMLDivElement | null) => { + if (!element) { + menuElementRef.current?.style.removeProperty('transform'); + } menuElementRef.current = element; if (element && isMenuOpenRef.current) { setIsMenuPositioned(false); @@ -267,13 +279,11 @@ export default function SkillPickerPlugin({ menuObserver.observe(menuElement); window.addEventListener('resize', scheduleMenuPosition); - document.addEventListener('scroll', scheduleMenuPosition, true); return () => { anchorObserver.disconnect(); menuObserver.disconnect(); window.removeEventListener('resize', scheduleMenuPosition); - document.removeEventListener('scroll', scheduleMenuPosition, true); if (menuPositionFrameRef.current !== null) { cancelAnimationFrame(menuPositionFrameRef.current); @@ -318,12 +328,18 @@ export default function SkillPickerPlugin({ }, []); const getItemChildOption = useCallback((item: SkillItemType, option: SkillOptionItemType) => { - if (item.children) return item.children; + if (item.children) { + const folderExpandMode = item.children.folderExpandMode ?? option.folderExpandMode; + return folderExpandMode === item.children.folderExpandMode + ? item.children + : { ...item.children, folderExpandMode }; + } if (item.folderChildren) { return { description: option.description, list: item.folderChildren, + folderExpandMode: option.folderExpandMode, onClick: option.onClick, onFolderLoad: option.onFolderLoad }; @@ -332,6 +348,7 @@ export default function SkillPickerPlugin({ if (item.tools?.length) { return { list: item.tools, + folderExpandMode: option.folderExpandMode, onClick: option.onClick }; } @@ -529,7 +546,7 @@ export default function SkillPickerPlugin({ [loadColumnPage, skillOptions] ); - // Resolve the hovered item into the next navigation column. + // Resolve a root category or a folder explicitly expanded by the user. const { runAsync: handleItemSelect } = useRequest( async ({ currentColumnIndex, @@ -581,6 +598,7 @@ export default function SkillPickerPlugin({ description: option.description, list: page.list, total: page.total, + folderExpandMode: option.folderExpandMode, loadPage: (params, cancelToken) => option.onFolderLoad!(item.id, item.source, params, cancelToken), onClick: option.onClick, @@ -605,6 +623,7 @@ export default function SkillPickerPlugin({ appendColumn(currentColumnIndex, { description: option.description, list: [], + folderExpandMode: option.folderExpandMode, onClick: option.onClick }); return; @@ -640,7 +659,7 @@ export default function SkillPickerPlugin({ setInteractionMode('mouse'); const firstItem = skillOption.list[0]; - if (firstItem) { + if (firstItem && !isManualFolder(firstItem, skillOption)) { void handleItemSelect({ currentColumnIndex: 0, item: firstItem, @@ -794,15 +813,19 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; - // Move the highlight before resolving the next navigation column. + // Keep manual folders collapsed while preserving automatic expansion elsewhere. const newIndex = currentRowIndex > 0 ? currentRowIndex - 1 : columnItems.length - 1; setCurrentRowIndex(newIndex); - void handleItemSelect({ - currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); + const currentOption = skillOptions[currentColumnIndex]; + const nextItem = columnItems[newIndex]; + if (currentOption && nextItem && !isManualFolder(nextItem, currentOption)) { + void handleItemSelect({ + currentColumnIndex, + item: nextItem, + option: currentOption + }); + } requestAnimationFrame(() => { scrollIntoView(currentColumnIndex, newIndex); @@ -828,15 +851,19 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; - // Move the highlight before resolving the next navigation column. + // Keep manual folders collapsed while preserving automatic expansion elsewhere. const newIndex = currentRowIndex < columnItems.length - 1 ? currentRowIndex + 1 : 0; setCurrentRowIndex(newIndex); - void handleItemSelect({ - currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); + const currentOption = skillOptions[currentColumnIndex]; + const nextItem = columnItems[newIndex]; + if (currentOption && nextItem && !isManualFolder(nextItem, currentOption)) { + void handleItemSelect({ + currentColumnIndex, + item: nextItem, + option: currentOption + }); + } requestAnimationFrame(() => { scrollIntoView(currentColumnIndex, newIndex); @@ -863,7 +890,7 @@ export default function SkillPickerPlugin({ if (prevColumnIndex >= skillOptions.length - 1) { const currentOption = skillOptions[prevColumnIndex]; const currentItem = currentOption?.list[currentRowIndex]; - if (currentItem && currentOption) { + if (currentItem && currentOption && !isManualFolder(currentItem, currentOption)) { void handleItemSelect({ currentColumnIndex: prevColumnIndex, item: currentItem, @@ -885,10 +912,11 @@ export default function SkillPickerPlugin({ // Use the latest skillOptions from closure to get the new column items const newColumnOption = skillOptions[newColumnIndex]; const newColumnItems = newColumnOption?.list; - if (newColumnItems && newColumnItems.length > 0) { + const newColumnItem = newColumnItems?.[0]; + if (newColumnOption && newColumnItem && !isManualFolder(newColumnItem, newColumnOption)) { void handleItemSelect({ currentColumnIndex: newColumnIndex, - item: newColumnItems[0], + item: newColumnItem, option: newColumnOption }); @@ -995,6 +1023,8 @@ export default function SkillPickerPlugin({ if (!latestItem || !latestOption) return false; + if (isManualFolder(latestItem, latestOption)) return true; + if (latestItem.isFolder || getItemChildOption(latestItem, latestOption)) { void handleItemSelect({ currentColumnIndex, @@ -1123,11 +1153,14 @@ export default function SkillPickerPlugin({ setCurrentRowIndex(rowIndex); setCurrentColumnIndex(columnIndex); - void handleItemSelect({ - currentColumnIndex: columnIndex, - item, - option: columnData - }); + + if (!isManualFolder(item, columnData)) { + void handleItemSelect({ + currentColumnIndex: columnIndex, + item, + option: columnData + }); + } }} > {item.icon && ( diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx index 1854f7b84949..c85cd44cb8bf 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx @@ -68,7 +68,7 @@ const toSkillLabelItem = ( }); const toSystemToolItem = (item: NodeTemplateListItemType, parentId?: string): SkillItemType => { - const isFolder = item.isFolder || item.flowNodeType === FlowNodeTypeEnum.toolSet; + const isFolder = item.isFolder ?? false; return { parentId: item.parentId ?? parentId, @@ -460,6 +460,7 @@ export const useSkillManager = ({ description: t('app:space_to_expand_folder'), list: firstPage.list, total: firstPage.total, + folderExpandMode: 'manual' as const, loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => loadTeamAppPage(null, types, params, cancelToken), onFolderLoad: ( @@ -467,7 +468,7 @@ export const useSkillManager = ({ _source: string | undefined, params: SkillPickerPageParams, cancelToken?: AbortController - ) => loadTeamAppPage(folderId, ToolTypeList, params, cancelToken), + ) => loadTeamAppPage(folderId, types, params, cancelToken), onClick: onAddAppOrTool }; }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); @@ -483,6 +484,7 @@ export const useSkillManager = ({ description: t('app:space_to_expand_folder'), list: firstPage.list, total: firstPage.total, + folderExpandMode: 'manual' as const, loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => loadTeamAppPage(null, types, params, cancelToken), onFolderLoad: ( @@ -490,7 +492,7 @@ export const useSkillManager = ({ _source: string | undefined, params: SkillPickerPageParams, cancelToken?: AbortController - ) => loadTeamAppPage(folderId, AppTypeList, params, cancelToken), + ) => loadTeamAppPage(folderId, types, params, cancelToken), onClick: onAddAppOrTool }; }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); From 950e7569f98a0b825353d0226a8f53b4b777f2e9 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 12:07:46 +0800 Subject: [PATCH 27/38] fix(app): open folders with right arrow --- .../plugins/SkillPickerPlugin/index.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index 82c7365f2781..a1c07f1384a7 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -887,10 +887,20 @@ export default function SkillPickerPlugin({ // Use functional updates to get the latest state setCurrentColumnIndex((prevColumnIndex) => { + const currentOption = skillOptions[prevColumnIndex]; + const currentItem = currentOption?.list[currentRowIndex]; + + if (currentItem && currentOption && isManualFolder(currentItem, currentOption)) { + void handleItemSelect({ + currentColumnIndex: prevColumnIndex, + item: currentItem, + option: currentOption + }); + return prevColumnIndex; + } + if (prevColumnIndex >= skillOptions.length - 1) { - const currentOption = skillOptions[prevColumnIndex]; - const currentItem = currentOption?.list[currentRowIndex]; - if (currentItem && currentOption && !isManualFolder(currentItem, currentOption)) { + if (currentItem && currentOption) { void handleItemSelect({ currentColumnIndex: prevColumnIndex, item: currentItem, From 15e4a118ae08e5503dd8ac2338ea3b0074cf313c Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 12:16:08 +0800 Subject: [PATCH 28/38] fix(app): move into folder after right arrow --- .../plugins/SkillPickerPlugin/index.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index a1c07f1384a7..67bd40155cf9 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -565,14 +565,14 @@ export default function SkillPickerPlugin({ if (childOption) { appendColumn(currentColumnIndex, childOption); - return; + return childOption; } if (item.isFolder && option.onFolderLoad) { const cachedOption = folderOptionsRef.current.get(itemKey); if (cachedOption) { appendColumn(currentColumnIndex, cachedOption); - return; + return cachedOption; } setLoadingFolderIds((prev) => new Set(prev).add(itemKey)); @@ -606,6 +606,7 @@ export default function SkillPickerPlugin({ }; folderOptionsRef.current.set(itemKey, nextOption); appendColumn(currentColumnIndex, nextOption); + return nextOption; } finally { if (folderRequestsRef.current.get(itemKey) === pendingRequest) { folderRequestsRef.current.delete(itemKey); @@ -620,13 +621,14 @@ export default function SkillPickerPlugin({ } if (item.isFolder) { - appendColumn(currentColumnIndex, { + const emptyOption = { description: option.description, list: [], folderExpandMode: option.folderExpandMode, onClick: option.onClick - }); - return; + } satisfies SkillOptionItemType; + appendColumn(currentColumnIndex, emptyOption); + return emptyOption; } if (!option.onSelect) return; @@ -635,6 +637,7 @@ export default function SkillPickerPlugin({ if (selectionRequestIdRef.current !== requestId) return; appendColumn(currentColumnIndex, result); + return result; }, { refreshDeps: [appendColumn, getItemChildOption] @@ -895,6 +898,20 @@ export default function SkillPickerPlugin({ currentColumnIndex: prevColumnIndex, item: currentItem, option: currentOption + }).then((nextOption) => { + if (!nextOption || !isMenuOpenRef.current) return; + + const nextColumnIndex = prevColumnIndex + 1; + setSelectedRowIndex((state) => ({ + ...state, + [prevColumnIndex]: currentRowIndex + })); + setCurrentColumnIndex(nextColumnIndex); + setCurrentRowIndex(0); + + requestAnimationFrame(() => { + scrollIntoView(nextColumnIndex, 0); + }); }); return prevColumnIndex; } From c4f42e0e81ee0f8963d78ce08a953832e18bd56d Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 15:03:39 +0800 Subject: [PATCH 29/38] fix(app): wait for resource page before wrapping picker navigation --- .../plugins/SkillPickerPlugin/index.tsx | 257 +++++++++++++----- 1 file changed, 194 insertions(+), 63 deletions(-) diff --git a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index 67bd40155cf9..05f5f73b5342 100644 --- a/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx +++ b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx @@ -118,6 +118,14 @@ type ColumnPageState = { type ColumnPageStateView = Pick; +type ColumnPageLoadResult = 'loaded' | 'failed' | 'stale'; + +type ColumnPageRequest = { + option: SkillOptionItemType; + promise: Promise; + token: symbol; +}; + export default function SkillPickerPlugin({ skillOption, isFocus, @@ -161,6 +169,8 @@ export default function SkillPickerPlugin({ >(new Map()); const [loadingFolderIds, setLoadingFolderIds] = useState>(new Set()); const columnPageStateRef = useRef>(new Map()); + const columnPageRequestsRef = useRef>(new Map()); + const columnPageNavigationRef = useRef>(new Map()); const columnElementRef = useRef>(new Map()); const [columnPageStates, setColumnPageStates] = useState>({}); @@ -374,6 +384,8 @@ export default function SkillPickerPlugin({ if (option && currentState?.option === option) return; currentState?.controller?.abort(); + columnPageRequestsRef.current.delete(columnIndex); + columnPageNavigationRef.current.delete(columnIndex); if (!option) { columnPageStateRef.current.delete(columnIndex); @@ -407,6 +419,8 @@ export default function SkillPickerPlugin({ if (index < columnIndex) return; state.controller?.abort(); columnPageStateRef.current.delete(index); + columnPageRequestsRef.current.delete(index); + columnPageNavigationRef.current.delete(index); }); setColumnPageStates((prev) => { @@ -438,17 +452,30 @@ export default function SkillPickerPlugin({ [clearColumnPageStatesAfter, resetColumnPageState] ); + /** + * Loads one page for a navigation column and reuses an in-flight request for the same option. + * Returns `loaded` after applying the page, `failed` for an active request error, and `stale` + * when the request was cancelled or its column state was replaced before completion. + */ const loadColumnPage = useCallback( - async (columnIndex: number, option: SkillOptionItemType, offset: number) => { - if (!option.loadPage) return; + (columnIndex: number, option: SkillOptionItemType, offset: number) => { + const loadPage = option.loadPage; + if (!loadPage) return Promise.resolve('stale'); let state = columnPageStateRef.current.get(columnIndex); if (!state || state.option !== option) { resetColumnPageState(columnIndex, option); state = columnPageStateRef.current.get(columnIndex); } - if (!state || state.loading || state.loadedOffsets.has(offset) || offset >= state.total) { - return; + + const pendingRequest = columnPageRequestsRef.current.get(columnIndex); + if (state?.loading) { + return pendingRequest?.option === option + ? pendingRequest.promise + : Promise.resolve('stale'); + } + if (!state || state.loadedOffsets.has(offset) || offset >= state.total) { + return Promise.resolve('stale'); } const controller = new AbortController(); @@ -460,55 +487,72 @@ export default function SkillPickerPlugin({ state.controller = controller; publishColumnPageState(columnIndex, state); - try { - const page = await option.loadPage({ offset, pageSize: PAGE_SIZE }, controller); - const latestState = columnPageStateRef.current.get(columnIndex); - if ( - controller.signal.aborted || - !latestState || - latestState.option !== option || - latestState.requestId !== requestId - ) { - return; - } + const requestToken = Symbol(); + const promise = (async (): Promise => { + try { + const page = await loadPage({ offset, pageSize: PAGE_SIZE }, controller); + const latestState = columnPageStateRef.current.get(columnIndex); + if ( + controller.signal.aborted || + !latestState || + latestState.option !== option || + latestState.requestId !== requestId + ) { + return 'stale'; + } - const nextOption: SkillOptionItemType = { - ...option, - list: mergeSkillItems(option.list, page.list), - total: page.total - }; - latestState.option = nextOption; - latestState.offset = Math.max(latestState.offset, offset + page.list.length); - latestState.total = page.total; - latestState.loadedOffsets.add(offset); - latestState.loading = false; - latestState.error = false; - latestState.controller = undefined; - - setSkillOptions((prev) => { - if (prev[columnIndex] !== option) return prev; - const next = [...prev]; - next[columnIndex] = nextOption; - return next; - }); - publishColumnPageState(columnIndex, latestState); - } catch (error) { - const latestState = columnPageStateRef.current.get(columnIndex); - if ( - controller.signal.aborted || - !latestState || - latestState.option !== option || - latestState.requestId !== requestId - ) { - return; + const nextOption: SkillOptionItemType = { + ...option, + list: mergeSkillItems(option.list, page.list), + total: page.total + }; + latestState.option = nextOption; + latestState.offset = Math.max(latestState.offset, offset + page.list.length); + latestState.total = page.total; + latestState.loadedOffsets.add(offset); + latestState.loading = false; + latestState.error = false; + latestState.controller = undefined; + + setSkillOptions((prev) => { + if (prev[columnIndex] !== option) return prev; + const next = [...prev]; + next[columnIndex] = nextOption; + return next; + }); + publishColumnPageState(columnIndex, latestState); + return 'loaded'; + } catch (error) { + const latestState = columnPageStateRef.current.get(columnIndex); + if ( + controller.signal.aborted || + !latestState || + latestState.option !== option || + latestState.requestId !== requestId + ) { + return 'stale'; + } + + latestState.loading = false; + latestState.error = true; + latestState.controller = undefined; + publishColumnPageState(columnIndex, latestState); + void error; + return 'failed'; + } finally { + const currentRequest = columnPageRequestsRef.current.get(columnIndex); + if (currentRequest?.token === requestToken) { + columnPageRequestsRef.current.delete(columnIndex); + } } + })(); - latestState.loading = false; - latestState.error = true; - latestState.controller = undefined; - publishColumnPageState(columnIndex, latestState); - void error; - } + columnPageRequestsRef.current.set(columnIndex, { + option, + promise, + token: requestToken + }); + return promise; }, [publishColumnPageState, resetColumnPageState] ); @@ -646,11 +690,15 @@ export default function SkillPickerPlugin({ useEffect(() => { const columnPageStates = columnPageStateRef.current; + const columnPageRequests = columnPageRequestsRef.current; + const columnPageNavigation = columnPageNavigationRef.current; const folderRequests = folderRequestsRef.current; const timer = window.setTimeout(() => { selectionRequestIdRef.current += 1; columnPageStates.forEach((state) => state.controller?.abort()); columnPageStates.clear(); + columnPageRequests.clear(); + columnPageNavigation.clear(); folderRequests.forEach(({ controller }) => controller.abort()); folderRequests.clear(); folderOptionsRef.current.clear(); @@ -675,6 +723,8 @@ export default function SkillPickerPlugin({ window.clearTimeout(timer); selectionRequestIdRef.current += 1; columnPageStates.forEach((state) => state.controller?.abort()); + columnPageRequests.clear(); + columnPageNavigation.clear(); folderRequests.forEach(({ controller }) => controller.abort()); }; }, [handleItemSelect, skillOption]); @@ -816,6 +866,8 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; + columnPageNavigationRef.current.delete(currentColumnIndex); + // Keep manual folders collapsed while preserving automatic expansion elsewhere. const newIndex = currentRowIndex > 0 ? currentRowIndex - 1 : columnItems.length - 1; setCurrentRowIndex(newIndex); @@ -854,23 +906,95 @@ export default function SkillPickerPlugin({ const columnItems = skillOptions[currentColumnIndex]?.list; if (!columnItems || columnItems.length === 0) return true; - // Keep manual folders collapsed while preserving automatic expansion elsewhere. - const newIndex = currentRowIndex < columnItems.length - 1 ? currentRowIndex + 1 : 0; - setCurrentRowIndex(newIndex); - const currentOption = skillOptions[currentColumnIndex]; - const nextItem = columnItems[newIndex]; - if (currentOption && nextItem && !isManualFolder(nextItem, currentOption)) { - void handleItemSelect({ - currentColumnIndex, - item: nextItem, - option: currentOption + const pageState = columnPageStateRef.current.get(currentColumnIndex); + const isLastLoadedRow = currentRowIndex >= columnItems.length - 1; + + const moveToRow = ({ + option, + items, + rowIndex + }: { + option: SkillOptionItemType; + items: SkillItemType[]; + rowIndex: number; + }) => { + setCurrentRowIndex(rowIndex); + + const nextItem = items[rowIndex]; + if (nextItem && !isManualFolder(nextItem, option)) { + void handleItemSelect({ + currentColumnIndex, + item: nextItem, + option + }); + } + + requestAnimationFrame(() => { + scrollIntoView(currentColumnIndex, rowIndex); }); + }; + + if ( + currentOption?.loadPage && + isLastLoadedRow && + (!pageState || pageState.offset < pageState.total) + ) { + if (columnPageNavigationRef.current.has(currentColumnIndex)) return true; + + const pendingRequest = columnPageRequestsRef.current.get(currentColumnIndex); + if (pageState?.loading && pendingRequest?.option !== currentOption) return true; + + const previousItemCount = columnItems.length; + const navigationToken = Symbol(); + columnPageNavigationRef.current.set(currentColumnIndex, navigationToken); + + void loadColumnPage( + currentColumnIndex, + currentOption, + pageState?.offset ?? columnItems.length + ) + .then((result) => { + if ( + !isMenuOpenRef.current || + result !== 'loaded' || + columnPageNavigationRef.current.get(currentColumnIndex) !== navigationToken + ) { + return; + } + + const latestState = columnPageStateRef.current.get(currentColumnIndex); + if (!latestState || latestState.loading) return; + + const latestOption = latestState.option; + const latestItems = latestOption.list; + if (latestItems.length > previousItemCount) { + moveToRow({ + option: latestOption, + items: latestItems, + rowIndex: previousItemCount + }); + return; + } + + // Wrap only after the completed page confirms that no item remains. + if (latestState.offset < latestState.total) return; + moveToRow({ option: latestOption, items: latestItems, rowIndex: 0 }); + }) + .finally(() => { + if (columnPageNavigationRef.current.get(currentColumnIndex) === navigationToken) { + columnPageNavigationRef.current.delete(currentColumnIndex); + } + }); + + return true; } - requestAnimationFrame(() => { - scrollIntoView(currentColumnIndex, newIndex); - }); + const newIndex = currentRowIndex < columnItems.length - 1 ? currentRowIndex + 1 : 0; + if (currentOption) { + columnPageNavigationRef.current.delete(currentColumnIndex); + moveToRow({ option: currentOption, items: columnItems, rowIndex: newIndex }); + } } return true; @@ -887,6 +1011,7 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); // Use functional updates to get the latest state setCurrentColumnIndex((prevColumnIndex) => { @@ -1013,6 +1138,7 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); const latestItem = skillOptions[currentColumnIndex]?.list[currentRowIndex]; const latestOption = skillOptions[currentColumnIndex]; @@ -1044,6 +1170,7 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); const latestItem = skillOptions[currentColumnIndex]?.list[currentRowIndex]; const latestOption = skillOptions[currentColumnIndex]; @@ -1089,6 +1216,7 @@ export default function SkillPickerPlugin({ skillOptions, handleItemSelect, handleItemClick, + loadColumnPage, selectedRowIndex, scrollIntoView, getItemChildOption, @@ -1143,12 +1271,14 @@ export default function SkillPickerPlugin({ }} onMouseMove={() => { if (interactionMode === 'keyboard') { + columnPageNavigationRef.current.delete(columnIndex); setInteractionMode('mouse'); } }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); + columnPageNavigationRef.current.delete(columnIndex); if (expandable || (!item.canClick && columnData.onSelect)) { void handleItemSelect({ @@ -1170,6 +1300,7 @@ export default function SkillPickerPlugin({ // Ignore mouse hover in keyboard mode until the pointer moves again. if (interactionMode === 'keyboard') return; + columnPageNavigationRef.current.delete(columnIndex); if (columnIndex !== currentColumnIndex) { setSelectedRowIndex((state) => ({ From 3cb64db8a63bf18357628c377ecdfa24bce22c80 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 15:18:22 +0800 Subject: [PATCH 30/38] chore(deps): remove unused lockfile entries --- pnpm-lock.yaml | 75 -------------------------------------------------- 1 file changed, 75 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bd89b67bb2b..21c248598677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1297,9 +1297,6 @@ importers: express: specifier: 'catalog:' version: 4.22.1 - ldapts: - specifier: ^9.0.0 - version: 9.0.0 metadata-saml2: specifier: ^2.0.1 version: 2.1.2 @@ -1319,9 +1316,6 @@ importers: tsdown: specifier: 'catalog:' version: 0.21.10(typescript@6.0.3) - vitest: - specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@26.4.0)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)) projects/app: dependencies: @@ -10755,10 +10749,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - ldapts@9.0.0: - resolution: {integrity: sha512-OaaoYBSuan7g0Nm2e1wsRl+9xol41zY+pDlRRSsBq36iKCd2tG/K8WifevNRDjyEfhz4bvkxKdDvJ6xHXwj7+Q==} - engines: {node: '>=22'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -13646,9 +13636,6 @@ packages: streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} - strict-event-emitter-types@2.0.0: - resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} - strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -21780,14 +21767,6 @@ snapshots: optionalDependencies: vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) - '@vitest/mocker@4.1.5(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))': - dependencies: - '@vitest/spy': 4.1.5 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) - '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 @@ -26167,10 +26146,6 @@ snapshots: dependencies: readable-stream: 2.3.8 - ldapts@9.0.0: - dependencies: - strict-event-emitter-types: 2.0.0 - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -29922,8 +29897,6 @@ snapshots: - bare-abort-controller optional: true - strict-event-emitter-types@2.0.0: {} - strict-uri-encode@2.0.0: {} string-argv@0.3.2: {} @@ -31149,24 +31122,6 @@ snapshots: tsx: 4.20.6 yaml: 2.8.4 - vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - postcss: 8.5.26 - rollup: 4.62.4 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.4.0 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - sass: 1.85.1 - terser: 5.39.0 - tsx: 4.20.6 - yaml: 2.8.4 - vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.10 @@ -31257,36 +31212,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@26.4.0)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 26.4.0 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) - jsdom: 26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - msw - void-elements@3.1.0: {} vue-component-type-helpers@3.3.9: {} From 4557eb596e6ae71a46f22f8ca9afdea252703dcc Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 15:19:42 +0800 Subject: [PATCH 31/38] build(deps): add ldapts and update pro submodule --- pnpm-lock.yaml | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c248598677..6bd89b67bb2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1297,6 +1297,9 @@ importers: express: specifier: 'catalog:' version: 4.22.1 + ldapts: + specifier: ^9.0.0 + version: 9.0.0 metadata-saml2: specifier: ^2.0.1 version: 2.1.2 @@ -1316,6 +1319,9 @@ importers: tsdown: specifier: 'catalog:' version: 0.21.10(typescript@6.0.3) + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@26.4.0)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)) projects/app: dependencies: @@ -10749,6 +10755,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + ldapts@9.0.0: + resolution: {integrity: sha512-OaaoYBSuan7g0Nm2e1wsRl+9xol41zY+pDlRRSsBq36iKCd2tG/K8WifevNRDjyEfhz4bvkxKdDvJ6xHXwj7+Q==} + engines: {node: '>=22'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -13636,6 +13646,9 @@ packages: streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strict-event-emitter-types@2.0.0: + resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -21767,6 +21780,14 @@ snapshots: optionalDependencies: vite: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) + '@vitest/mocker@4.1.5(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 @@ -26146,6 +26167,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + ldapts@9.0.0: + dependencies: + strict-event-emitter-types: 2.0.0 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -29897,6 +29922,8 @@ snapshots: - bare-abort-controller optional: true + strict-event-emitter-types@2.0.0: {} + strict-uri-encode@2.0.0: {} string-argv@0.3.2: {} @@ -31122,6 +31149,24 @@ snapshots: tsx: 4.20.6 yaml: 2.8.4 + vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + sass: 1.85.1 + terser: 5.39.0 + tsx: 4.20.6 + yaml: 2.8.4 + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.10 @@ -31212,6 +31257,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@26.4.0)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@26.4.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 26.4.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vue-component-type-helpers@3.3.9: {} From cc05f5dce02200de826fec9c7a5d64459bfd8a83 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 15:31:52 +0800 Subject: [PATCH 32/38] docs(resource-list): clarify dataset pagination coverage --- .../core/resource-list-pagination-frontend.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/design/core/resource-list-pagination-frontend.md b/.agents/design/core/resource-list-pagination-frontend.md index ee3c9bdc6f54..81d6f9b324b6 100644 --- a/.agents/design/core/resource-list-pagination-frontend.md +++ b/.agents/design/core/resource-list-pagination-frontend.md @@ -126,8 +126,9 @@ Dashboard App Context 选择和移动文件夹: -- `projects/app/src/components/core/dataset/SelectModal.tsx` 的 `useDatasetSelect` 使用 `getDatasetsV2` 和 `useScrollPagination`。 -- `projects/app/src/components/core/app/DatasetSelectModal.tsx`、`SelectMarkCollection.tsx` 使用 `ScrollData` 和 `useVirtualGridList`。 +- `projects/app/src/components/core/dataset/SelectModal.tsx` 的 `useDatasetSelect` 使用 `getDatasetsV2` 和 `useScrollPagination`,当前用于聊天标注的知识库选择。 +- `projects/app/src/components/core/app/DatasetSelectModal.tsx` 尚未适配 `listV2` 分页,仍通过 `getAllDatasets` 请求旧 `/core/dataset/list`,一次加载当前条件下的完整知识库列表。 +- `DatasetSelectModal.tsx` 当前被 ChatAgent、SimpleApp、NodeAgent、Workflow `selectDataset` 输入和插件 I/O 知识库选项复用,因此这些应用/工作流侧入口也尚未使用 `listV2` 分页。 - `projects/app/src/pageComponents/dataset/list/context.tsx` 的文件夹列表使用 `getDatasetsV2`。 - `projects/app/src/web/core/dataset/store/dataset.ts` 无仓库引用,已删除。 @@ -173,8 +174,9 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 ### Dataset 和 Skill -- Dataset 选择共享 `useDatasetSelect` 的分页状态,两个 Dataset 选择场景使用虚拟网格。 -- Dataset 选择弹窗的“全选/取消全选”在明确操作时读取当前目录或搜索条件下的完整候选,保证跨页选择语义;普通列表展示仍使用 V2 分页。 +- 聊天标注的 Dataset 选择共享 `useDatasetSelect` 的分页状态,使用 `getDatasetsV2` 和 `useScrollPagination`。 +- 应用/工作流侧的 `components/core/app/DatasetSelectModal.tsx` 仍使用 V1 全量数组接口,暂未适配 `listV2` 分页;该组件的普通列表展示和“全选/取消全选”目前都依赖完整候选集。 +- 后续若改造 `DatasetSelectModal`,需要单独保留全选/取消全选所需的完整候选集语义,不能只将展示请求替换为 V2 第一页。 - Skill 选择共享 `useSkillSelectData` 的分页状态,保留目录、搜索、创建/导入和数量限制。 - 搜索词、目录变化会清空已加载页并从 `offset=0` 重新请求。 - 已选项独立维护,当前目录或搜索结果切换不会清除已选项。 @@ -191,7 +193,7 @@ Workflow App 选择器采用面包屑加当前目录列表,保留单选和过 | 文件 | 使用场景 | 保留原因 | | --- | --- | --- | -| `components/core/app/DatasetSelectModal.tsx` | 全选/取消全选 | 明确操作需要当前条件下的完整候选集 | +| `components/core/app/DatasetSelectModal.tsx` | 应用/工作流侧知识库选择、全选/取消全选 | 组件尚未适配 `listV2`;当前列表展示和全选操作都依赖旧接口返回的完整候选集 | ### Skill 直接调用点 @@ -249,6 +251,7 @@ server: SelectOneResourceServer - Dashboard 继续滚动后能追加下一页,直到 `data.length >= total`。 - 搜索和切换目录会清空旧数据并重新请求第一页。 - 面包屑选择器和移动弹窗首次只请求当前目录一页,继续滚动能追加下一页,直到 `data.length >= total`。 +- 聊天标注知识库选择器使用 `listV2` 分页;应用/工作流侧 `components/core/app/DatasetSelectModal.tsx` 明确仍使用旧 `/core/dataset/list` 全量接口,暂不纳入分页验证范围。 - 面包屑选择器切换目录会重新请求 `offset=0`,移动弹窗可选择根目录 `null`、禁用目录和当前资源。 - PromptEditor Skill 树、Dataset 全选等仍按语义继续使用 V1 全量数组。 From 65c4697713fda0a661027ad834d5df8386e7caaf Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 16:51:10 +0800 Subject: [PATCH 33/38] feat(workflow): add paginated template scrolling --- .../Flow/NodeTemplatesModal.tsx | 2 + .../Flow/NodeTemplatesPopover.tsx | 3 + .../Flow/components/NodeTemplates/list.tsx | 76 ++++++++----- .../NodeTemplates/useNodeTemplates.tsx | 97 ++++++++++------ .../pageComponents/chat/ChatTeamApp/index.tsx | 10 +- .../app/src/pages/dashboard/tool/index.tsx | 4 +- .../test/web/core/app/tool/templates.test.ts | 107 +++++++++++++++++- 7 files changed, 230 insertions(+), 69 deletions(-) diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx index 0159f86e7142..f9f1122cff75 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesModal.tsx @@ -49,6 +49,7 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { setSearchKey, templatesIsLoading, templates, + TeamScrollData, onUpdateTemplateType, onUpdateParentId, selectedTagIds, @@ -119,6 +120,7 @@ const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => { templates={templates} templateType={templateType} onUpdateParentId={onUpdateParentId} + ScrollData={TeamScrollData} /> ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx index 6f154eb90c09..71f3b5d162cf 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx @@ -55,6 +55,7 @@ const NodeTemplatesPopover = () => { setSearchKey, templatesIsLoading, templates, + TeamScrollData, onUpdateTemplateType, onUpdateParentId, toolTags, @@ -155,6 +156,7 @@ const NodeTemplatesPopover = () => { flexDirection={'column'} py={4} h={'full'} + minH={0} userSelect="none" > { templates={templates} templateType={templateType} onUpdateParentId={onUpdateParentId} + ScrollData={TeamScrollData} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx index 419432bc66d6..c6835d91f8b3 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx @@ -57,6 +57,7 @@ import { isDebugToolSource, getToolIdentityKey } from '@fastgpt/global/core/app/ import DebugToolTag from '@fastgpt/web/components/core/plugin/tool/DebugToolTag'; import SystemToolTag from '@fastgpt/web/components/core/plugin/tool/SystemToolTag'; import { normalizeFlowNodeInputType } from '@fastgpt/global/core/app/formEdit/utils'; +import type { ScrollListType } from '@fastgpt/web/hooks/useScrollPagination'; export type TemplateListProps = { onAddNode: ({ newNodes }: { newNodes: Node[] }) => void; @@ -64,6 +65,7 @@ export type TemplateListProps = { templates: NodeTemplateListItemType[]; templateType: TemplateTypeEnum; onUpdateParentId: (parentId: string, source?: string) => void; + ScrollData?: ScrollListType; }; const NodeTemplateListItem = ({ @@ -238,7 +240,8 @@ const NodeTemplateList = ({ isPopover = false, templates, templateType, - onUpdateParentId + onUpdateParentId, + ScrollData }: TemplateListProps) => { const { t, i18n } = useTranslation(); const { toast } = useToast(); @@ -584,33 +587,52 @@ const NodeTemplateList = ({ ); }); + const listContent = ( + + {formatTemplatesArrayData.length > 1 ? ( + <> + {formatTemplatesArrayData.map(({ list, label }, index) => ( + + + {t(label as any)} + + + {renderNodeList(list)} + + ))} + + ) : ( + <>{renderNodeList(formatTemplatesArrayData?.[0]?.list)} + )} + + ); + + if (ScrollData) { + return ( + + 1 ? 2 : 5}> + {listContent} + + + ); + } + return ( - 1 ? 2 : 5}> - - {formatTemplatesArrayData.length > 1 ? ( - <> - {formatTemplatesArrayData.map(({ list, label }, index) => ( - - - {t(label as any)} - - - {renderNodeList(list)} - - ))} - - ) : ( - <>{renderNodeList(formatTemplatesArrayData?.[0]?.list)} - )} - + 1 ? 2 : 5} + > + {listContent} ); }; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index 42d82d69a3ff..12acae6bd3dc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -5,7 +5,7 @@ import type { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node'; import { isTemplateVisible } from '@fastgpt/global/core/workflow/template/context'; -import { getTeamAppTemplates, getAppToolTemplates } from '@/web/core/app/api/tool'; +import { getTeamAppTemplatesV2, getAppToolTemplates } from '@/web/core/app/api/tool'; import { TemplateTypeEnum } from './header'; import { useContextSelector } from 'use-context-selector'; import { WorkflowBufferDataContext } from '../../../context/workflowInitContext'; @@ -16,6 +16,7 @@ import { getPluginToolTags } from '@/web/core/plugin/toolTag/api'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; export const useNodeTemplates = (context: NodeTemplateContext | null = null) => { const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic); @@ -39,6 +40,43 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => manual: false }); + const isTeamTemplate = + templateType === TemplateTypeEnum.myTools || templateType === TemplateTypeEnum.agent; + const teamTemplateTypes = + templateType === TemplateTypeEnum.myTools + ? [ + AppTypeEnum.toolFolder, + AppTypeEnum.workflowTool, + AppTypeEnum.mcpToolSet, + AppTypeEnum.httpToolSet + ] + : [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]; + + const { + data: teamTemplates, + isLoading: teamTemplatesIsLoading, + ScrollData: TeamScrollData + } = useScrollPagination(getTeamAppTemplatesV2, { + params: { + parentId, + searchKey: searchKey || undefined, + type: teamTemplateTypes, + parentType: parentId ? parentTypes.current.get(parentId) : undefined, + excludeAppId: appId + }, + pageSize: 50, + disabled: !isTeamTemplate, + refreshDeps: [isTeamTemplate, templateType, parentId, searchKey, appId] + }); + + useEffect(() => { + ( + teamTemplates as (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined + )?.forEach(({ id, appType }) => { + if (appType) parentTypes.current.set(id, appType); + }); + }, [teamTemplates]); + const { data: basicNodes } = useRequest( async () => { if (templateType === TemplateTypeEnum.basic) { @@ -92,27 +130,6 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => tags?: string[]; source?: string; }) => { - if (type === TemplateTypeEnum.myTools) { - // app, workflow-plugin, mcp - return getTeamAppTemplates({ - parentId, - parentType: parentId ? parentTypes.current.get(parentId) : undefined, - searchKey: searchVal, - type: [ - AppTypeEnum.toolFolder, - AppTypeEnum.workflowTool, - AppTypeEnum.mcpToolSet, - AppTypeEnum.httpToolSet - ] - }).then((res) => res.filter((app) => app.id !== appId)); - } - if (type === TemplateTypeEnum.agent) { - return getTeamAppTemplates({ - parentId, - searchKey: searchVal, - type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] - }).then((res) => res.filter((app) => app.id !== appId)); - } if (type === TemplateTypeEnum.systemTools) { // systemTool return getAppToolTemplates({ @@ -122,6 +139,7 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => tags }); } + return []; }, { onSuccess(list: (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined) { @@ -135,7 +153,7 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => useDebounceEffect( () => { - if (searchKeyLock.current) { + if (templateType !== TemplateTypeEnum.systemTools || searchKeyLock.current) { return; } @@ -146,7 +164,7 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => source: parentSource }); }, - [searchKey, parentSource], + [searchKey, parentSource, templateType], { wait: 300 } @@ -170,9 +188,11 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => setSearchKey(''); setParentId(parentId); setParentSource(nextParentSource); - loadNodeTemplates({ parentId, source: nextParentSource }); + if (templateType === TemplateTypeEnum.systemTools) { + loadNodeTemplates({ parentId, source: nextParentSource }); + } }, - [loadNodeTemplates, parentSource] + [loadNodeTemplates, parentSource, templateType] ); const onUpdateTemplateType = useCallback( (type: TemplateTypeEnum) => { @@ -182,16 +202,20 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => setParentSource(undefined); setSelectedTagIds([]); setTemplateType(type); - loadNodeTemplates({ type }); + if (type === TemplateTypeEnum.systemTools) { + loadNodeTemplates({ type }); + } }, [loadNodeTemplates] ); const onUpdateSelectedTagIds = useCallback( (tags: string[]) => { setSelectedTagIds(tags); - loadNodeTemplates({ parentId, searchVal: searchKey, tags, source: parentSource }); + if (templateType === TemplateTypeEnum.systemTools) { + loadNodeTemplates({ parentId, searchVal: searchKey, tags, source: parentSource }); + } }, - [loadNodeTemplates, parentId, parentSource, searchKey] + [loadNodeTemplates, parentId, parentSource, searchKey, templateType] ); const templates = useMemo(() => { @@ -202,19 +226,28 @@ export const useNodeTemplates = (context: NodeTemplateContext | null = null) => : true ); } - return (teamAndSystemTools || []).filter((item) => + const templateList = isTeamTemplate ? teamTemplates : teamAndSystemTools; + return (templateList || []).filter((item) => context?.handleId === NodeOutputKeyEnum.selectedTools ? 'isTool' in item && item.isTool === true : true ); - }, [basicNodes, teamAndSystemTools, templateType, context?.handleId]); + }, [ + basicNodes, + context?.handleId, + isTeamTemplate, + teamAndSystemTools, + teamTemplates, + templateType + ]); return { templateType, parentId, parentSource, - templatesIsLoading, + templatesIsLoading: isTeamTemplate ? teamTemplatesIsLoading : templatesIsLoading, templates, + TeamScrollData: isTeamTemplate ? TeamScrollData : undefined, onUpdateParentId, onUpdateTemplateType, searchKey, diff --git a/projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx b/projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx index 2929c2d29b15..ea9616736399 100644 --- a/projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx +++ b/projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx @@ -29,7 +29,7 @@ const MyApps = ({ hideMobileHeader = false, mobileSearchKey }: MyAppsProps) => { const { isPc } = useSystem(); const { feConfigs } = useSystemStore(); - const { paths, myApps, isFetchingApps, setSearchKey } = useContextSelector( + const { paths, myApps, isFetchingApps, ScrollData, setSearchKey } = useContextSelector( AppListContext, (v) => v ); @@ -134,7 +134,7 @@ const MyApps = ({ hideMobileHeader = false, mobileSearchKey }: MyAppsProps) => { flex={'1 0 0'} flexDirection={'column'} h={'100%'} - overflowY={'auto'} + overflowY={'hidden'} overflowX={'hidden'} > { )} - - + + + + diff --git a/projects/app/src/pages/dashboard/tool/index.tsx b/projects/app/src/pages/dashboard/tool/index.tsx index c965b3bb7c65..6c57c92479fa 100644 --- a/projects/app/src/pages/dashboard/tool/index.tsx +++ b/projects/app/src/pages/dashboard/tool/index.tsx @@ -96,7 +96,7 @@ const MyTools = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { pr={folderDetail ? [3, 2] : [3, 6]} pl={6} pt={6} - overflowY={'auto'} + overflowY={'hidden'} overflowX={'hidden'} > @@ -179,7 +179,7 @@ const MyTools = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { )} - + diff --git a/projects/app/test/web/core/app/tool/templates.test.ts b/projects/app/test/web/core/app/tool/templates.test.ts index 41a265277b97..c2f2637d901b 100644 --- a/projects/app/test/web/core/app/tool/templates.test.ts +++ b/projects/app/test/web/core/app/tool/templates.test.ts @@ -5,20 +5,23 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; const mocks = vi.hoisted(() => ({ getAppDetailById: vi.fn(), GET: vi.fn(), - getAllApps: vi.fn() + getAllApps: vi.fn(), + getMyAppsV2: vi.fn(), + post: vi.fn() })); vi.mock('@/web/common/api/request', () => ({ GET: mocks.GET, - POST: vi.fn() + POST: mocks.post })); vi.mock('@/web/core/app/api', () => ({ getAppDetailById: mocks.getAppDetailById, - getAllApps: mocks.getAllApps + getAllApps: mocks.getAllApps, + getMyAppsV2: mocks.getMyAppsV2 })); -import { getTeamAppTemplates } from '@/web/core/app/api/tool'; +import { getTeamAppTemplates, getTeamAppTemplatesV2 } from '@/web/core/app/api/tool'; describe('getTeamAppTemplates', () => { beforeEach(() => { @@ -219,4 +222,100 @@ describe('getTeamAppTemplates', () => { expect(mocks.getAllApps).not.toHaveBeenCalled(); expect(mocks.getAppDetailById).not.toHaveBeenCalled(); }); + + it('loads root team templates through the paginated app list API', async () => { + mocks.getMyAppsV2.mockResolvedValueOnce({ + list: [ + { + _id: 'workflow-tool', + type: AppTypeEnum.workflowTool, + avatar: 'tool-avatar', + name: 'My tool', + intro: 'Tool app' + } + ], + total: 51 + }); + const cancelToken = new AbortController(); + + const result = await getTeamAppTemplatesV2( + { + parentId: '', + searchKey: 'tool', + type: [AppTypeEnum.toolFolder, AppTypeEnum.workflowTool], + excludeAppId: 'current-app', + offset: 50, + pageSize: 50 + }, + cancelToken + ); + + expect(mocks.getMyAppsV2).toHaveBeenCalledWith( + { + parentId: '', + searchKey: 'tool', + type: [AppTypeEnum.toolFolder, AppTypeEnum.workflowTool], + excludeAppId: 'current-app', + offset: 50, + pageSize: 50 + }, + cancelToken + ); + expect(result).toMatchObject({ + total: 51, + list: [ + { + id: 'workflow-tool', + pluginId: 'workflow-tool', + flowNodeType: FlowNodeTypeEnum.pluginModule, + isTool: true + } + ] + }); + }); + + it('loads paginated tools from an MCP or HTTP toolset', async () => { + mocks.getAppDetailById.mockResolvedValueOnce({ + type: AppTypeEnum.mcpToolSet, + _id: 'mcp-set' + }); + mocks.post.mockResolvedValueOnce({ + list: [ + { + id: 'mcp-set/search', + name: 'search', + intro: 'Search', + flowNodeType: FlowNodeTypeEnum.tool, + templateType: 'teamApp', + appType: AppTypeEnum.mcpToolSet, + isTool: true, + isFolder: false + } + ], + total: 1 + }); + const cancelToken = new AbortController(); + + const result = await getTeamAppTemplatesV2( + { + parentId: 'mcp-set', + searchKey: 'search', + offset: 10, + pageSize: 10 + }, + cancelToken + ); + + expect(mocks.post).toHaveBeenCalledWith( + '/core/app/toolSet/listV2', + { + parentId: 'mcp-set', + searchKey: 'search', + offset: 10, + pageSize: 10 + }, + { cancelToken } + ); + expect(result).toMatchObject({ total: 1, list: [{ id: 'mcp-set/search', isTool: true }] }); + }); }); From 22a800725d73e0ca2a5d2ab062afb425b4f026fb Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Fri, 4 Sep 2026 17:22:26 +0800 Subject: [PATCH 34/38] fix(skill): sort folders and skills by update time --- .../pageComponents/dashboard/skill/List.tsx | 3 ++ .../app/src/pages/dashboard/skill/index.tsx | 1 + .../app/test/api/core/ai/skill/list.test.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index e857fa8ab2e5..3fc890d96899 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -201,6 +201,7 @@ const List = ({ refreshSkills, isFetchingSkills, searchKey, + setSearchKey, folderDetail, ScrollData, listFilters, @@ -212,6 +213,7 @@ const List = ({ refreshSkills: v.refreshSkills, isFetchingSkills: v.isFetchingSkills, searchKey: v.searchKey, + setSearchKey: v.setSearchKey, folderDetail: v.folderDetail, ScrollData: v.ScrollData, listFilters: v.listFilters, @@ -457,6 +459,7 @@ const List = ({ }} onClick={() => { if (isFolder) { + setSearchKey(''); router.push({ query: { ...router.query, parentId: skill._id } }); } else { if (isSkillReady && !guardSkillSandboxOperation()) return; diff --git a/projects/app/src/pages/dashboard/skill/index.tsx b/projects/app/src/pages/dashboard/skill/index.tsx index 4152d15f78e8..ea724ae5d09a 100644 --- a/projects/app/src/pages/dashboard/skill/index.tsx +++ b/projects/app/src/pages/dashboard/skill/index.tsx @@ -70,6 +70,7 @@ const SkillPageContent = ({ MenuIcon }: { MenuIcon: JSX.Element }) => { }); const onNavigate = (targetParentId: ParentIdType) => { + setSearchKey(''); router.push({ query: { ...router.query, diff --git a/projects/app/test/api/core/ai/skill/list.test.ts b/projects/app/test/api/core/ai/skill/list.test.ts index de8cb6d0f05f..e44fcb1c931c 100644 --- a/projects/app/test/api/core/ai/skill/list.test.ts +++ b/projects/app/test/api/core/ai/skill/list.test.ts @@ -155,6 +155,42 @@ describe('POST /api/core/ai/skill/list', () => { expect(res.data.list[0].name).toBe('Paged Skill 2'); }); + it('V2 sorts folders and skills by update time', async () => { + const user = await getUser(`agent-skill-list-sort-${getNanoid(6)}`); + await MongoAgentSkills.create([ + { + name: 'Recently Updated Folder', + type: AgentSkillTypeEnum.folder, + source: AgentSkillSourceEnum.personal, + teamId: user.teamId, + tmbId: user.tmbId, + updateTime: new Date('2024-01-02T00:00:00.000Z') + }, + { + name: 'Older Skill', + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + teamId: user.teamId, + tmbId: user.tmbId, + updateTime: new Date('2024-01-01T00:00:00.000Z') + } + ]); + + const res = await Call, ListSkillsResponse>( + handlerV2, + { + auth: user, + body: { source: 'mine', parentId: null, withAppCount: false } + } + ); + + expect(res.code).toBe(200); + expect(res.data.list.map((item) => item.name)).toEqual([ + 'Recently Updated Folder', + 'Older Skill' + ]); + }); + it('按 skillIds 查询时不会被默认分页截断', async () => { const user = await getUser(`agent-skill-list-ids-${getNanoid(6)}`); const skills = await MongoAgentSkills.create( From 22f014f1aee3a932820db82e3f59202edcfac66d Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Mon, 7 Sep 2026 11:32:18 +0800 Subject: [PATCH 35/38] fix(dataset-list): align dataset sort with creation order Add V2 coverage for dataset, app, and skill filters. --- .../app/src/pages/api/core/dataset/list.ts | 11 ++-- .../app/src/pages/api/core/dataset/listV2.ts | 13 ++--- .../app/test/api/core/ai/skill/list.test.ts | 52 +++++++++++++++++++ projects/app/test/api/core/app/list.test.ts | 49 +++++++++++++++++ .../app/test/api/core/dataset/list.test.ts | 34 +++++++++++- 5 files changed, 147 insertions(+), 12 deletions(-) diff --git a/projects/app/src/pages/api/core/dataset/list.ts b/projects/app/src/pages/api/core/dataset/list.ts index d47a39df6960..b1631a50b946 100644 --- a/projects/app/src/pages/api/core/dataset/list.ts +++ b/projects/app/src/pages/api/core/dataset/list.ts @@ -22,7 +22,7 @@ import { GetDatasetListBodySchema, type GetDatasetListResponse } from '@fastgpt/global/openapi/core/dataset/api'; -import { AppListSortEnum, appListSortMongoMap } from '@fastgpt/global/core/app/constants'; +import { AppListSortEnum } from '@fastgpt/global/core/app/constants'; import { Types } from '@fastgpt/service/common/mongo'; async function handler(req: ApiRequestProps): Promise { @@ -102,9 +102,12 @@ async function handler(req: ApiRequestProps): Promise { }; })(); - const myDatasets = await MongoDataset.find(findDatasetQuery) - .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) - .lean(); + const datasetSort = ((): Record => { + if (sort === AppListSortEnum.createTimeAsc) return { _id: 1 }; + if (sort === AppListSortEnum.createTimeDesc) return { _id: -1 }; + return { updateTime: -1, _id: -1 }; + })(); + const myDatasets = await MongoDataset.find(findDatasetQuery).sort(datasetSort).lean(); const formatDatasets = myDatasets .map((dataset) => { const { Per, privateDataset } = (() => { diff --git a/projects/app/src/pages/api/core/dataset/listV2.ts b/projects/app/src/pages/api/core/dataset/listV2.ts index ffbd5606c6b0..9e8b93b99f14 100644 --- a/projects/app/src/pages/api/core/dataset/listV2.ts +++ b/projects/app/src/pages/api/core/dataset/listV2.ts @@ -27,7 +27,7 @@ import { type GetDatasetListV2Body, type GetDatasetListV2Response } from '@fastgpt/global/openapi/core/dataset/api'; -import { AppListSortEnum, appListSortMongoMap } from '@fastgpt/global/core/app/constants'; +import { AppListSortEnum } from '@fastgpt/global/core/app/constants'; async function handler( req: ApiRequestProps @@ -107,12 +107,13 @@ async function handler( })(); const skip = offset ?? (pageNum - 1) * pageSize; + const datasetSort = ((): Record => { + if (sort === AppListSortEnum.createTimeAsc) return { _id: 1 }; + if (sort === AppListSortEnum.createTimeDesc) return { _id: -1 }; + return { updateTime: -1, _id: -1 }; + })(); const [myDatasets, total] = await Promise.all([ - MongoDataset.find(findDatasetQuery) - .sort({ ...appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc], _id: -1 }) - .skip(skip) - .limit(pageSize) - .lean(), + MongoDataset.find(findDatasetQuery).sort(datasetSort).skip(skip).limit(pageSize).lean(), MongoDataset.countDocuments(findDatasetQuery) ]); const pageRoleList = await getResourcePermissionsByResourceIds({ diff --git a/projects/app/test/api/core/ai/skill/list.test.ts b/projects/app/test/api/core/ai/skill/list.test.ts index e44fcb1c931c..85d3a39316f8 100644 --- a/projects/app/test/api/core/ai/skill/list.test.ts +++ b/projects/app/test/api/core/ai/skill/list.test.ts @@ -191,6 +191,58 @@ describe('POST /api/core/ai/skill/list', () => { ]); }); + it('V2 applies creator and sort filters', async () => { + const owner = await getUser(`agent-skill-v2-filter-owner-${getNanoid(6)}`); + const member = await getUser(`agent-skill-v2-filter-member-${getNanoid(6)}`, owner.teamId); + const [olderSkill, newerSkill] = await MongoAgentSkills.create([ + { + name: 'Older V2 member skill', + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + teamId: owner.teamId, + tmbId: member.tmbId, + createTime: new Date('2026-01-01T00:00:00.000Z') + }, + { + name: 'Newer V2 member skill', + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + teamId: owner.teamId, + tmbId: member.tmbId, + createTime: new Date('2026-02-01T00:00:00.000Z') + } + ]); + + const filtered = await Call, ListSkillsResponse>( + handlerV2, + { + auth: owner, + body: { + source: 'mine', + parentId: null, + withAppCount: false, + tmbIds: [String(member.tmbId)], + sort: AppListSortEnum.createTimeAsc + } + } + ); + expect(filtered.code).toBe(200); + expect(filtered.data.total).toBe(2); + expect(filtered.data.list.map((item) => String(item._id))).toEqual([ + String(olderSkill._id), + String(newerSkill._id) + ]); + + const empty = await Call, ListSkillsResponse>( + handlerV2, + { + auth: owner, + body: { source: 'mine', parentId: null, tmbIds: [] } + } + ); + expect(empty.data).toEqual({ list: [], total: 0 }); + }); + it('按 skillIds 查询时不会被默认分页截断', async () => { const user = await getUser(`agent-skill-list-ids-${getNanoid(6)}`); const skills = await MongoAgentSkills.create( diff --git a/projects/app/test/api/core/app/list.test.ts b/projects/app/test/api/core/app/list.test.ts index cb9561bd8131..cf52b466cb38 100644 --- a/projects/app/test/api/core/app/list.test.ts +++ b/projects/app/test/api/core/app/list.test.ts @@ -252,6 +252,55 @@ describe('POST /api/core/app/list', () => { expect(emptied.data).toEqual([]); }); + it('applies shared filters in V2', async () => { + const { owner, members } = await getFakeUsers(2); + const [olderApp, newerApp] = await MongoApp.create([ + { + name: 'Older member app', + type: AppTypeEnum.workflow, + teamId: owner.teamId, + tmbId: members[0].tmbId, + createTime: new Date('2026-01-01T00:00:00.000Z'), + modules: [] + }, + { + name: 'Newer member app', + type: AppTypeEnum.workflow, + teamId: owner.teamId, + tmbId: members[0].tmbId, + createTime: new Date('2026-02-01T00:00:00.000Z'), + modules: [] + } + ]); + + const filtered = await Call, ListAppV2ResponseType>( + handlerV2, + { + auth: owner, + body: { + type: AppTypeEnum.workflow, + tmbIds: [String(members[0].tmbId)], + sort: AppListSortEnum.createTimeAsc + } + } + ); + expect(filtered.code).toBe(200); + expect(filtered.data.total).toBe(2); + expect(filtered.data.list.map((app) => String(app._id))).toEqual([ + String(olderApp._id), + String(newerApp._id) + ]); + + const empty = await Call, ListAppV2ResponseType>( + handlerV2, + { + auth: owner, + body: { type: AppTypeEnum.workflow, tmbIds: [] } + } + ); + expect(empty.data).toEqual({ list: [], total: 0 }); + }); + it('excludes an app before applying pagination in V2', async () => { const user = await getUser(`app-list-v2-exclude-${getNanoid(6)}`); const [excludedApp] = await MongoApp.create( diff --git a/projects/app/test/api/core/dataset/list.test.ts b/projects/app/test/api/core/dataset/list.test.ts index 2dfdcbf4dd57..de7078a28d2c 100644 --- a/projects/app/test/api/core/dataset/list.test.ts +++ b/projects/app/test/api/core/dataset/list.test.ts @@ -66,7 +66,7 @@ describe('POST /api/core/dataset/list', () => { } ); expect(filtered.code).toBe(200); - expect(filtered.data.list.map((item) => String(item._id))).toEqual([ + expect(filtered.data.map((item) => String(item._id))).toEqual([ String(olderDataset._id), String(newerDataset._id) ]); @@ -82,7 +82,37 @@ describe('POST /api/core/dataset/list', () => { body: { parentId: null, tmbIds: [] } } ); - expect(empty.data).toEqual({ list: [], total: 0 }); + expect(empty.data).toEqual([]); + + const filteredV2 = await Call< + GetDatasetListV2Body, + Record, + GetDatasetListV2Response + >(handlerV2, { + auth: owner, + body: { + parentId: null, + type: [DatasetTypeEnum.folder, DatasetTypeEnum.websiteDataset], + tmbIds: [String(member.tmbId)], + sort: AppListSortEnum.createTimeAsc + } + }); + expect(filteredV2.code).toBe(200); + expect(filteredV2.data.total).toBe(2); + expect(filteredV2.data.list.map((item) => String(item._id))).toEqual([ + String(olderDataset._id), + String(newerDataset._id) + ]); + + const emptyV2 = await Call< + GetDatasetListV2Body, + Record, + GetDatasetListV2Response + >(handlerV2, { + auth: owner, + body: { parentId: null, tmbIds: [] } + }); + expect(emptyV2.data).toEqual({ list: [], total: 0 }); }); it('keeps the original array response', async () => { From 68aefb36d37d99a3a4188088a42a45e3937f3ea3 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Mon, 7 Sep 2026 12:12:51 +0800 Subject: [PATCH 36/38] fix(app-list): restore creation timestamps for legacy records --- packages/global/openapi/core/app/common/api.ts | 2 +- projects/app/src/pages/api/core/app/listV2.ts | 4 +++- projects/app/src/pages/api/core/dataset/listV2.ts | 2 ++ projects/app/src/web/core/skill/api.ts | 4 ++-- projects/app/test/api/core/app/list.test.ts | 2 +- projects/app/test/api/core/dataset/list.test.ts | 6 +++--- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/global/openapi/core/app/common/api.ts b/packages/global/openapi/core/app/common/api.ts index a607c948d4f0..e2b52179dbf6 100644 --- a/packages/global/openapi/core/app/common/api.ts +++ b/packages/global/openapi/core/app/common/api.ts @@ -20,7 +20,7 @@ import { OpenAPIStoreNodeItemTypeSchema } from '../../workflow/node'; import { StoreEdgeItemTypeSchema } from '../../../../core/workflow/type/edge'; -import { BoolSchema, IntSchema, NumSchema } from '../../../../common/zod'; +import { BoolSchema, NumSchema } from '../../../../common/zod'; import { PaginationResponseSchema, PaginationSchema } from '../../../api'; import { migrateWorkflowToCurrent } from '../../../../core/workflow/migration'; import z from 'zod'; diff --git a/projects/app/src/pages/api/core/app/listV2.ts b/projects/app/src/pages/api/core/app/listV2.ts index 04fc5d3a9843..53fe262e5254 100644 --- a/projects/app/src/pages/api/core/app/listV2.ts +++ b/projects/app/src/pages/api/core/app/listV2.ts @@ -32,6 +32,7 @@ import { type ListAppV2BodyType, type ListAppV2ResponseType } from '@fastgpt/global/openapi/core/app/common/api'; +import { Types } from '@fastgpt/service/common/mongo'; async function handler(req: ApiRequestProps): Promise { const { @@ -122,7 +123,7 @@ async function handler(req: ApiRequestProps): Promise): Promise @@ -167,6 +168,7 @@ async function handler( })(), inheritPermission: dataset.inheritPermission, tmbId: dataset.tmbId, + createTime: dataset.createTime ?? new Types.ObjectId(String(dataset._id)).getTimestamp(), updateTime: dataset.updateTime, permission: Per, private: privateDataset diff --git a/projects/app/src/web/core/skill/api.ts b/projects/app/src/web/core/skill/api.ts index 2456071d5f43..7935c0fc823f 100644 --- a/projects/app/src/web/core/skill/api.ts +++ b/projects/app/src/web/core/skill/api.ts @@ -9,7 +9,6 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/cons import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type'; import type { ListSkillsQuery, - ListSkillsV2Query, ListSkillsResponse, CreateSkillBody, GetSkillDetailQuery, @@ -37,7 +36,8 @@ import { AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import type { StartChatFnProps } from '@/components/core/chat/ChatContainer/type'; import type { ChangeSkillOwnerBody, - ChangeSkillOwnerResponse + ChangeSkillOwnerResponse, + ListSkillsV2Query } from '@fastgpt/global/openapi/core/ai/skill/api'; /** 获取 Skill 列表(支持分页、搜索、分类、文件夹过滤) */ diff --git a/projects/app/test/api/core/app/list.test.ts b/projects/app/test/api/core/app/list.test.ts index cf52b466cb38..a4c7756941e8 100644 --- a/projects/app/test/api/core/app/list.test.ts +++ b/projects/app/test/api/core/app/list.test.ts @@ -341,7 +341,7 @@ describe('POST /api/core/app/list', () => { updateTime: new Date('2024-01-01T00:00:00.000Z') }); await MongoApp.collection.updateOne( - { _id: app._id }, + { _id: new Types.ObjectId(String(app._id)) }, { $set: { avatar: null }, $unset: { intro: '' } } ); diff --git a/projects/app/test/api/core/dataset/list.test.ts b/projects/app/test/api/core/dataset/list.test.ts index de7078a28d2c..defade1a371f 100644 --- a/projects/app/test/api/core/dataset/list.test.ts +++ b/projects/app/test/api/core/dataset/list.test.ts @@ -71,8 +71,8 @@ describe('POST /api/core/dataset/list', () => { String(newerDataset._id) ]); expect(filtered.data.map((item) => item.createTime)).toEqual([ - olderDataset._id.getTimestamp(), - newerDataset._id.getTimestamp() + olderId.getTimestamp(), + newerId.getTimestamp() ]); const empty = await Call, GetDatasetListResponse>( @@ -180,7 +180,7 @@ describe('POST /api/core/dataset/list', () => { updateTime: new Date('2024-01-01T00:00:00.000Z') }); await MongoDataset.collection.updateOne( - { _id: dataset._id }, + { _id: new Types.ObjectId(String(dataset._id)) }, { $set: { avatar: null }, $unset: { intro: '' } } ); From bd5ca606c5e0e013463f6a77d36bee0489482dd7 Mon Sep 17 00:00:00 2001 From: FinleyGe Date: Mon, 7 Sep 2026 15:40:10 +0800 Subject: [PATCH 37/38] fix(list): prevent premature empty states and stale filter clears --- .../TagFilter/multiSelectFilterUtils.ts | 21 ++++++++++ packages/web/hooks/useScrollPagination.tsx | 10 ++++- .../TagFilter/SingleSelectFilter.test.ts | 41 +++++++++++++++++++ .../test/hooks/useScrollPagination.test.ts | 18 ++++++++ .../support/user/TeamMemberFilter.tsx | 27 ++++++++---- .../pageComponents/dashboard/agent/List.tsx | 7 ++-- .../dashboard/agent/context.tsx | 4 ++ .../dashboard/agent/filters/utils.ts | 29 ++++++++----- .../pageComponents/dashboard/skill/List.tsx | 18 ++++++-- .../dashboard/skill/context.tsx | 4 ++ .../src/pageComponents/dataset/list/List.tsx | 28 +++++++++---- .../pageComponents/dataset/list/context.tsx | 6 ++- .../dashboard/agent/filters/utils.test.ts | 27 ++++++++++++ 13 files changed, 205 insertions(+), 35 deletions(-) diff --git a/packages/web/components/common/TagFilter/multiSelectFilterUtils.ts b/packages/web/components/common/TagFilter/multiSelectFilterUtils.ts index 812320563358..90bd16ea7b5f 100644 --- a/packages/web/components/common/TagFilter/multiSelectFilterUtils.ts +++ b/packages/web/components/common/TagFilter/multiSelectFilterUtils.ts @@ -69,6 +69,27 @@ export const syncSelectedFilterValues = ( return nextIds.length === 0 ? createMultiSelectFilter() : { ...value, values: nextIds }; }; +/** + * Sync selected values only from the current request, preserving values just selected by the user. + * Persisted values can still be cleared when the matching request confirms they are invalid. + */ +export const syncSelectedFilterValuesForRequest = ({ + value, + validValues, + selectedIdsKey, + requestKey, + userSelectedIdsKey +}: { + value: MultiSelectFilterValue; + validValues: T[]; + selectedIdsKey: string; + requestKey: string; + userSelectedIdsKey?: string; +}): MultiSelectFilterValue | null => { + if (requestKey !== selectedIdsKey || userSelectedIdsKey === selectedIdsKey) return null; + return syncSelectedFilterValues(value, validValues); +}; + /** * 把已选但不在当前列表里的项补回去,只影响展示,不改筛选值。 * 日志用户列表随日期变化时,触发器和下拉仍能显示之前选中的人。 diff --git a/packages/web/hooks/useScrollPagination.tsx b/packages/web/hooks/useScrollPagination.tsx index ea3203f887ad..99badb77b622 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -55,12 +55,13 @@ export function useScrollPagination< const [data, setData] = useState([]); const [total, setTotal] = useState(0); + const [hasLoaded, setHasLoaded] = useState(false); const [isLoading, { setTrue, setFalse }] = useBoolean(false); const requestedOffsetRef = useRef(); const requestControllerRef = useRef(); const requestIdRef = useRef(0); const isRequestingRef = useRef(false); - const isEmpty = total === 0 && !isLoading; + const isEmpty = hasLoaded && total === 0 && data.length === 0 && !isLoading; const noMore = data.length >= total; @@ -106,6 +107,9 @@ export function useScrollPagination< setData([]); setTotal(0); } + if (init) { + setHasLoaded(false); + } try { const res = await api( @@ -120,6 +124,9 @@ export function useScrollPagination< if (requestController.signal.aborted || requestId !== requestIdRef.current) return; setTotal(res.total); + if (offset === 0) { + setHasLoaded(true); + } if (scrollLoadType === 'top') { const prevHeight = ScrollContainerRef?.current?.scrollHeight || 0; @@ -278,6 +285,7 @@ export function useScrollPagination< ScrollData, isLoading, total: Math.max(total, data.length), + isEmpty, data, setData, setTotal, diff --git a/packages/web/test/components/common/TagFilter/SingleSelectFilter.test.ts b/packages/web/test/components/common/TagFilter/SingleSelectFilter.test.ts index bc3f8fec70c2..59693a0e87b0 100644 --- a/packages/web/test/components/common/TagFilter/SingleSelectFilter.test.ts +++ b/packages/web/test/components/common/TagFilter/SingleSelectFilter.test.ts @@ -9,6 +9,7 @@ import { getMultiSelectFilterSummary, mergeRememberedFilterOptions, syncSelectedFilterValues, + syncSelectedFilterValuesForRequest, toggleMultiSelectFilterValue, toMultiSelectFilterQuery } from '../../../../components/common/TagFilter/multiSelectFilterUtils'; @@ -137,6 +138,46 @@ describe('multiSelectFilter helpers', () => { }); }); + it('only clears persisted values after the matching request and preserves user selections', () => { + const value = { mode: 'selected' as const, values: ['me'] }; + + expect( + syncSelectedFilterValuesForRequest({ + value, + validValues: [], + selectedIdsKey: 'me', + requestKey: 'me' + }) + ).toEqual({ mode: 'all', values: [] }); + expect( + syncSelectedFilterValuesForRequest({ + value, + validValues: [], + selectedIdsKey: 'me', + requestKey: 'me', + userSelectedIdsKey: 'me' + }) + ).toBeNull(); + expect( + syncSelectedFilterValuesForRequest({ + value: { mode: 'selected', values: ['latest'] }, + validValues: [], + selectedIdsKey: 'latest', + requestKey: 'old', + userSelectedIdsKey: 'latest' + }) + ).toBeNull(); + expect( + syncSelectedFilterValuesForRequest({ + value: { mode: 'selected', values: ['latest'] }, + validValues: [], + selectedIdsKey: 'latest', + requestKey: 'latest', + userSelectedIdsKey: 'latest' + }) + ).toBeNull(); + }); + it('keeps selected options that disappeared from the current list', () => { const current = [{ value: 'u2', label: '一二三' }]; const remembered = [ diff --git a/packages/web/test/hooks/useScrollPagination.test.ts b/packages/web/test/hooks/useScrollPagination.test.ts index 83d2fe78984b..051fa40d6498 100644 --- a/packages/web/test/hooks/useScrollPagination.test.ts +++ b/packages/web/test/hooks/useScrollPagination.test.ts @@ -92,6 +92,24 @@ const renderHarness = async (root: Root, props: HarnessProps) => { }; describe('useScrollPagination', () => { + it('marks an empty result only after the current request succeeds', async () => { + const { api, requests } = createDeferredApi(); + const onState = vi.fn(); + const root = createRoot(document.createElement('div')); + + await renderHarness(root, { query: 'empty', api, onState }); + + expect(onState.mock.lastCall?.[0].isEmpty).toBe(false); + + await act(async () => { + requests[0].resolve({ list: [], total: 0 }); + await Promise.resolve(); + }); + + expect(onState.mock.lastCall?.[0].isEmpty).toBe(true); + root.unmount(); + }); + it('cancels the previous init request and keeps the latest response', async () => { const { api, requests } = createDeferredApi(); const onState = vi.fn(); diff --git a/projects/app/src/components/support/user/TeamMemberFilter.tsx b/projects/app/src/components/support/user/TeamMemberFilter.tsx index 7942772a10be..0161ada5e661 100644 --- a/projects/app/src/components/support/user/TeamMemberFilter.tsx +++ b/projects/app/src/components/support/user/TeamMemberFilter.tsx @@ -1,13 +1,13 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import { MultiSelectFilter, mergeRememberedFilterOptions, - syncSelectedFilterValues, useCommonFilterLabels, type MultiSelectFilterValue } from '@fastgpt/web/components/common/TagFilter'; +import { syncSelectedFilterValuesForRequest } from '@fastgpt/web/components/common/TagFilter/multiSelectFilterUtils'; import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { getTeamMembers } from '@/web/support/user/team/api'; @@ -45,6 +45,16 @@ const TeamMemberFilter = ({ const [searchKey, setSearchKey] = useState(''); const selectedIds = useMemo(() => (value.mode === 'selected' ? value.values : []), [value]); const selectedIdsKey = selectedIds.join(','); + const userSelectedIdsKeyRef = useRef(); + + const handleChange = useCallback( + (next: MultiSelectFilterValue) => { + userSelectedIdsKeyRef.current = + next.mode === 'selected' && next.values.length > 0 ? next.values.join(',') : undefined; + onChange(next); + }, + [onChange] + ); const { data: members = [], ScrollData } = useScrollPagination< any, @@ -89,12 +99,13 @@ const TeamMemberFilter = ({ useEffect(() => { if (value.mode !== 'selected' || selectedIds.length === 0) return; if (isHydratingSelected || !selectedMembers) return; - // useRequest 会保留上一轮 data,旧响应不能清掉刚选中的成员。 - if (selectedMembers.requestKey !== selectedIdsKey) return; - const next = syncSelectedFilterValues( + const next = syncSelectedFilterValuesForRequest({ value, - selectedMembers.list.map((item) => String(item.tmbId)) - ); + validValues: selectedMembers.list.map((item) => String(item.tmbId)), + selectedIdsKey, + requestKey: selectedMembers.requestKey, + userSelectedIdsKey: userSelectedIdsKeyRef.current + }); if (next) onChange(next); }, [isHydratingSelected, onChange, selectedIds.length, selectedIdsKey, selectedMembers, value]); @@ -127,7 +138,7 @@ const TeamMemberFilter = ({ { appType, loadMyApps, isFetchingApps, + isEmpty, onUpdateApp, setMoveAppId, folderDetail, @@ -100,10 +101,10 @@ const List = () => { const [editedApp, setEditedApp] = useState(); const [editPerAppId, setEditPerAppId] = useState(); - const isInitialLoading = myApps.length === 0 && isFetchingApps; + const isInitialLoading = !isEmpty && myApps.length === 0 && isFetchingApps; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: myApps, - listKey: `${router.pathname}-${appType}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, + listKey: `${router.pathname}-${appType}-${parentId || ''}-${searchKey}-${listFilters.type}-${listFilters.creator.mode}-${listFilters.creator.tmbIds.join(',')}-${listFilters.sort}-${columnCount}-${pageSize}-${isInitialLoading}`, reservedSlotCount: isInitialLoading ? 0 : 1, estimatedRowHeight: 160, estimatedRowGap: 20, @@ -462,7 +463,7 @@ const List = () => { > {renderVirtualGridItems(renderAppCard)} - ) : myApps.length === 0 && !folderDetail ? ( + ) : isEmpty && !folderDetail ? ( hasActiveFilter ? ( ) : isPc && hasCreatePer ? ( diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 9a8146b1500f..47d8d2d1ceec 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -44,6 +44,7 @@ type AppListContextType = { myApps: AppListItemType[]; loadMyApps: () => Promise; isFetchingApps: boolean; + isEmpty: boolean; ScrollData: ScrollListType; folderDetail: AppDetailType | undefined | null; paths: ParentTreePathItemType[]; @@ -67,6 +68,7 @@ export const AppListContext = createContext({ throw new Error('Function not implemented.'); }, isFetchingApps: false, + isEmpty: false, ScrollData: () => <>, folderDetail: undefined, paths: [], @@ -146,6 +148,7 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { const { data: myApps = [], isLoading: isFetchingApps, + isEmpty, ScrollData, fetchData } = useScrollPagination( @@ -271,6 +274,7 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { ScrollData, refetchFolderDetail, isFetchingApps, + isEmpty, folderDetail, paths, onUpdateApp, diff --git a/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts b/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts index c22c79b620ef..888fbc8e4c6f 100644 --- a/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts +++ b/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts @@ -124,23 +124,32 @@ export const getResourceListDisplayTime = ({ updateTime: Date; }) => (sort === AppListSortEnum.updateTimeDesc ? updateTime : createTime); +type ResourceListActiveFilterProps = { + searchKey: string; + type?: string; + creatorMode: AppListFilterType['creator']['mode']; + applyToolbarFilters: boolean; +}; + /** - * 工作台列表是否处于「筛选后可能为空」的状态。 - * 排序不算筛选:改排序不会把列表筛空,空态仍应走首次创建。 - * 移动端工具栏筛选项不可见时,只看搜索。 + * 判断资源列表是否存在会改变结果集的筛选条件。 + * 排序只改变顺序,不影响空结果状态;移动端未展示的筛选条件也不参与判断。 */ -export const hasAppListActiveFilter = ({ +export const hasResourceListActiveFilter = ({ searchKey, type, creatorMode, applyToolbarFilters -}: { +}: ResourceListActiveFilterProps) => { + if (searchKey.trim()) return true; + if (!applyToolbarFilters) return false; + return type !== undefined && type !== 'all' ? true : creatorMode === 'selected'; +}; + +/** 判断 Agent 列表是否处于可能返回空结果的筛选状态。 */ +export const hasAppListActiveFilter = (props: { searchKey: string; type: AppListFilterType['type']; creatorMode: AppListFilterType['creator']['mode']; applyToolbarFilters: boolean; -}) => { - if (searchKey.trim()) return true; - if (!applyToolbarFilters) return false; - return type !== 'all' || creatorMode === 'selected'; -}; +}) => hasResourceListActiveFilter(props); diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index 3fc890d96899..91c30e2a1d22 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -48,7 +48,10 @@ import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; import { getGridPageSize } from '@fastgpt/web/hooks/useResponsiveGridPageSize'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; -import { getResourceListDisplayTime } from '@/pageComponents/dashboard/agent/filters/utils'; +import { + getResourceListDisplayTime, + hasResourceListActiveFilter +} from '@/pageComponents/dashboard/agent/filters/utils'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import ResourceCardSkeleton from '@/pageComponents/dashboard/ResourceCardSkeleton'; @@ -200,6 +203,7 @@ const List = ({ skills, refreshSkills, isFetchingSkills, + isEmpty, searchKey, setSearchKey, folderDetail, @@ -212,6 +216,7 @@ const List = ({ skills: v.skills, refreshSkills: v.refreshSkills, isFetchingSkills: v.isFetchingSkills, + isEmpty: v.isEmpty, searchKey: v.searchKey, setSearchKey: v.setSearchKey, folderDetail: v.folderDetail, @@ -225,10 +230,15 @@ const List = ({ const [editedSkill, setEditedSkill] = useState(); const [moveSkillId, setMoveSkillId] = useState(); const [editPerSkillId, setEditPerSkillId] = useState(); - const isInitialLoading = skills.length === 0 && isFetchingSkills; + const hasActiveFilter = hasResourceListActiveFilter({ + searchKey, + creatorMode: listFilters.creator.mode, + applyToolbarFilters: isPc + }); + const isInitialLoading = !isEmpty && skills.length === 0 && isFetchingSkills; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: skills, - listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, + listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${listFilters.creator.mode}-${listFilters.creator.tmbIds.join(',')}-${listFilters.sort}-${columnCount}-${pageSize}-${isInitialLoading}`, reservedSlotCount: isInitialLoading ? 0 : 1, estimatedRowHeight: 160, estimatedRowGap: 20, @@ -582,7 +592,7 @@ const List = ({ {renderVirtualGridItems(renderSkillCard)} ) : skills.length === 0 && !folderDetail ? ( - searchKey ? ( + hasActiveFilter ? ( ) : onClickCreate && onClickImport ? ( diff --git a/projects/app/src/pageComponents/dashboard/skill/context.tsx b/projects/app/src/pageComponents/dashboard/skill/context.tsx index cc53a79306d6..75d204da51d5 100644 --- a/projects/app/src/pageComponents/dashboard/skill/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/context.tsx @@ -42,6 +42,7 @@ export type SkillListItemType = Omit< type SkillListContextType = { skills: SkillListItemType[]; isFetchingSkills: boolean; + isEmpty: boolean; refreshSkills: () => void; ScrollData: ScrollListType; searchKey: string; @@ -60,6 +61,7 @@ type SkillListContextType = { export const SkillListContext = createContext({ skills: [], isFetchingSkills: false, + isEmpty: false, refreshSkills: () => { throw new Error('Function not implemented.'); }, @@ -110,6 +112,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { const { data: skills = [], isLoading: isFetchingSkills, + isEmpty, ScrollData, fetchData } = useScrollPagination( @@ -174,6 +177,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { const contextValue: SkillListContextType = { skills, isFetchingSkills, + isEmpty, refreshSkills, ScrollData, searchKey, diff --git a/projects/app/src/pageComponents/dataset/list/List.tsx b/projects/app/src/pageComponents/dataset/list/List.tsx index e83656eac493..e63c2ef43338 100644 --- a/projects/app/src/pageComponents/dataset/list/List.tsx +++ b/projects/app/src/pageComponents/dataset/list/List.tsx @@ -32,7 +32,10 @@ import UserBox from '@fastgpt/web/components/common/UserBox'; import { ReadRoleVal } from '@fastgpt/global/support/permission/constant'; import { useVirtualGridList } from '@fastgpt/web/hooks/useVirtualGridList'; import { formatTimeToChatTime } from '@fastgpt/global/common/string/time'; -import { getResourceListDisplayTime } from '@/pageComponents/dashboard/agent/filters/utils'; +import { + getResourceListDisplayTime, + hasResourceListActiveFilter +} from '@/pageComponents/dashboard/agent/filters/utils'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import ResourceCardSkeleton from '@/pageComponents/dashboard/ResourceCardSkeleton'; @@ -53,6 +56,7 @@ function List() { myDatasets, ScrollData, isFetchingDatasets, + isEmpty, folderDetail, searchKey, setSearchKey, @@ -79,11 +83,17 @@ function List() { }), [myDatasets] ); - const isInitialLoading = formatDatasets.length === 0 && isFetchingDatasets; + const hasActiveFilter = hasResourceListActiveFilter({ + searchKey, + type: listFilters.type, + creatorMode: listFilters.creator.mode, + applyToolbarFilters: isPc + }); + const isInitialLoading = !isEmpty && formatDatasets.length === 0 && isFetchingDatasets; const { gridRef, renderVirtualGridItems } = useVirtualGridList({ list: formatDatasets, - listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${columnCount}-${pageSize}-${isInitialLoading}`, + listKey: `${router.pathname}-${parentId || ''}-${searchKey}-${listFilters.type}-${listFilters.creator.mode}-${listFilters.creator.tmbIds.join(',')}-${listFilters.sort}-${columnCount}-${pageSize}-${isInitialLoading}`, estimatedRowHeight: 160, estimatedRowGap: 20, loadingItemCount: isFetchingDatasets ? pageSize : 0, @@ -436,16 +446,18 @@ function List() { ) )} - {myDatasets.length === 0 && ( + {isEmpty && ( + /> )} {editedDataset && ( diff --git a/projects/app/src/pageComponents/dataset/list/context.tsx b/projects/app/src/pageComponents/dataset/list/context.tsx index 3a10d5e08f64..6e348d19dc20 100644 --- a/projects/app/src/pageComponents/dataset/list/context.tsx +++ b/projects/app/src/pageComponents/dataset/list/context.tsx @@ -45,6 +45,7 @@ export type DatasetContextType = { refetchPaths: () => void; refetchFolderDetail: () => Promise; isFetchingDatasets: boolean; + isEmpty: boolean; ScrollData: ScrollListType; setMoveDatasetId: (id: string) => void; paths: ParentTreePathItemType[]; @@ -63,6 +64,7 @@ export type DatasetContextType = { export const DatasetsContext = createContext({ isFetchingDatasets: false, + isEmpty: false, ScrollData: () => <>, setMoveDatasetId: () => {}, refetchPaths: () => {}, @@ -130,7 +132,8 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { data: myDatasets = [], fetchData, ScrollData, - isLoading: isFetchingDatasets + isLoading: isFetchingDatasets, + isEmpty } = useScrollPagination( ({ offset = 0, pageSize = 50 }) => getDatasetsV2({ @@ -225,6 +228,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { const contextValue = { isFetchingDatasets, + isEmpty, ScrollData, setMoveDatasetId, paths, diff --git a/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts b/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts index 5cbc54d10567..f872885d2f01 100644 --- a/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts +++ b/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts @@ -6,6 +6,7 @@ import { defaultAppListFilters, getResourceListDisplayTime, hasAppListActiveFilter, + hasResourceListActiveFilter, resolveSceneListType, toListTmbIds } from '@/pageComponents/dashboard/agent/filters/utils'; @@ -87,6 +88,32 @@ describe('app list filter helpers', () => { ).toBe(false); }); + it('shares active-filter semantics with dataset and skill lists', () => { + expect( + hasResourceListActiveFilter({ + searchKey: '', + type: 'all', + creatorMode: 'selected', + applyToolbarFilters: true + }) + ).toBe(true); + expect( + hasResourceListActiveFilter({ + searchKey: '', + creatorMode: 'all', + applyToolbarFilters: true + }) + ).toBe(false); + expect( + hasResourceListActiveFilter({ + searchKey: ' ', + type: 'websiteDataset', + creatorMode: 'all', + applyToolbarFilters: false + }) + ).toBe(false); + }); + it('drops types that do not belong to the current page', () => { expect(resolveSceneListType(AppTypeEnum.workflow, 'agent')).toBe(AppTypeEnum.workflow); expect(resolveSceneListType(AppTypeEnum.workflowTool, 'agent')).toBe('all'); From c73bc4f7e573d7be654e9a31867aa40048b10b9f Mon Sep 17 00:00:00 2001 From: FinleyGe Date: Mon, 7 Sep 2026 16:47:58 +0800 Subject: [PATCH 38/38] fix(agent-filters): preserve selected creators in app requests --- .../dashboard/agent/context.tsx | 35 +++++++------------ .../dashboard/agent/filters/utils.ts | 23 ++++++++++++ .../dashboard/agent/filters/utils.test.ts | 27 ++++++++++++++ 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 47d8d2d1ceec..3df5ed751e40 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -21,10 +21,10 @@ import { useTranslation } from 'next-i18next'; import { usePersistedFilters } from '@fastgpt/web/hooks/usePersistedFilters'; import { useUserStore } from '@/web/support/user/useUserStore'; import { buildFilterStorageKey } from '@/web/common/filter/storageKey'; -import { getTeamMembers } from '@/web/support/user/team/api'; import { getDashboardAppListScene, resolveDashboardAppListTypes } from './utils/appListTypes'; import { AppListFiltersStoreSchema, + buildAppListRequest, defaultAppListFilters, defaultAppListFiltersStore, resolveSceneListType, @@ -158,28 +158,19 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => { type: appType }); const fetchApps = (tmbIds?: string[]) => - getMyAppsV2({ - parentId, - type: formatType, - searchKey, - offset, - pageSize: getGridRequestPageSize(pageSize, offset), - ...(sort ? { sort } : {}), - ...(tmbIds !== undefined ? { tmbIds } : {}) - }); + getMyAppsV2( + buildAppListRequest({ + parentId, + type: formatType, + searchKey, + offset, + pageSize: getGridRequestPageSize(pageSize, offset), + sort, + tmbIds + }) + ); - // 已选创建者先按当前活跃成员校验,避免离职/失效 ID 把列表一直筛空。 - if (!persistedTmbIds?.length) { - return fetchApps(persistedTmbIds); - } - - const selectedMembers = await getTeamMembers({ - tmbIds: persistedTmbIds, - status: 'active', - offset: 0, - pageSize: persistedTmbIds.length - }); - return fetchApps(selectedMembers.list.map((item) => String(item.tmbId))); + return fetchApps(persistedTmbIds); }, { refreshDeps: [ diff --git a/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts b/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts index 888fbc8e4c6f..e9491f7f4678 100644 --- a/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts +++ b/projects/app/src/pageComponents/dashboard/agent/filters/utils.ts @@ -1,5 +1,6 @@ import { AppListSortEnum, AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import type { ListAppV2BodyType } from '@fastgpt/global/openapi/core/app/common/api'; import { toMultiSelectFilterQuery } from '@fastgpt/web/components/common/TagFilter'; import z from 'zod'; @@ -113,6 +114,28 @@ export const resolveSceneListType = ( export const toListTmbIds = (creator?: AppListFilterType['creator']): string[] | undefined => toMultiSelectFilterQuery(creator ? { mode: creator.mode, values: creator.tmbIds } : undefined); +/** Preserve the creator selection when building the paginated app list request. */ +export const buildAppListRequest = ({ + parentId, + type, + searchKey, + offset, + pageSize, + sort, + tmbIds +}: Pick< + ListAppV2BodyType, + 'parentId' | 'type' | 'searchKey' | 'offset' | 'pageSize' | 'sort' | 'tmbIds' +>) => ({ + parentId, + type, + searchKey, + offset, + pageSize, + ...(sort ? { sort } : {}), + ...(tmbIds !== undefined ? { tmbIds } : {}) +}); + /** 卡片时间与排序依据保持一致:最近更新显示更新时间,创建时间排序显示创建时间。 */ export const getResourceListDisplayTime = ({ sort, diff --git a/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts b/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts index f872885d2f01..499ba8851e16 100644 --- a/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts +++ b/projects/app/test/pageComponents/dashboard/agent/filters/utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { AppListFilterSchema, AppListFiltersStoreSchema, + buildAppListRequest, defaultAppListFilters, getResourceListDisplayTime, hasAppListActiveFilter, @@ -45,6 +46,32 @@ describe('app list filter helpers', () => { expect(toListTmbIds({ mode: 'selected', tmbIds: ['me'] })).toEqual(['me']); }); + it('preserves every selected creator when building the paginated app request', () => { + const tmbIds = ['member-1', 'member-2', 'member-3']; + + expect( + buildAppListRequest({ + parentId: '', + type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow], + searchKey: '', + offset: 0, + pageSize: 51, + sort: AppListSortEnum.createTimeDesc, + tmbIds + }) + ).toMatchObject({ tmbIds }); + expect( + buildAppListRequest({ + parentId: null, + type: AppTypeEnum.simple, + searchKey: '', + offset: 0, + pageSize: 51, + tmbIds: [] + }).tmbIds + ).toEqual([]); + }); + it('treats search, type and creator as active filters, but not sort', () => { expect( hasAppListActiveFilter({