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..81d6f9b324b6 --- /dev/null +++ b/.agents/design/core/resource-list-pagination-frontend.md @@ -0,0 +1,293 @@ +# 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 + +面包屑目录选择 / 移动弹窗 + -> SelectOneResource + -> useScrollPagination + -> getMyAppsV2 + -> /core/app/listV2 + +扁平选择列表 + -> 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` 使用 `getMyAppsV2`,只返回当前目录的文件夹页。 +- `MoveModal` 复用 `SelectOneResource`,支持分页、面包屑、目录选择、权限禁用和禁止移动到自身。 + +### 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` 尚未适配 `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` 无仓库引用,已删除。 + +### 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` 的移动文件夹列表使用 `getSkillListV2`。 + +## 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 行为。 + +`components/Select/AppSelect.tsx` 和 `pageComponents/chat/ChatHeader.tsx` 复用 `SelectOneResource`,用于评测应用和移动端 Chat 应用切换,同样按当前目录通过 `getMyAppsV2` 分页。 + +### Dataset 和 Skill + +- 聊天标注的 Dataset 选择共享 `useDatasetSelect` 的分页状态,使用 `getDatasetsV2` 和 `useScrollPagination`。 +- 应用/工作流侧的 `components/core/app/DatasetSelectModal.tsx` 仍使用 V1 全量数组接口,暂未适配 `listV2` 分页;该组件的普通列表展示和“全选/取消全选”目前都依赖完整候选集。 +- 后续若改造 `DatasetSelectModal`,需要单独保留全选/取消全选所需的完整候选集语义,不能只将展示请求替换为 V2 第一页。 +- Skill 选择共享 `useSkillSelectData` 的分页状态,保留目录、搜索、创建/导入和数量限制。 +- 搜索词、目录变化会清空已加载页并从 `offset=0` 重新请求。 +- 已选项独立维护,当前目录或搜索结果切换不会清除已选项。 + +## 5. 继续使用全量数组的调用点 + +### App 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `web/core/app/api/tool.ts` | Team Tool/Agent 模板 | 对外继续返回模板数组 | + +### Dataset 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `components/core/app/DatasetSelectModal.tsx` | 应用/工作流侧知识库选择、全选/取消全选 | 组件尚未适配 `listV2`;当前列表展示和全选操作都依赖旧接口返回的完整候选集 | + +### Skill 直接调用点 + +| 文件 | 使用场景 | 保留原因 | +| --- | --- | --- | +| `hooks/useSkillManager.tsx` | ChatAgent Skill 管理 | 需要完整数组构建 Skill 映射 | +| `packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx` | PromptEditor Skill 树 | 插件维护递归目录和键盘索引 | + +## 6. 当前目录分页组件 + +### `SelectOneResource` + +文件:`projects/app/src/components/common/folder/SelectOneResource.tsx` + +当前协议: + +```ts +server: ( + props: PaginationProps, + cancelToken?: AbortController +) => Promise> +``` + +组件只维护当前目录路径和当前目录的分页列表。点击目录行进入下一级,点击右侧箭头也可下钻,点击面包屑返回上级;目录切换会取消旧请求并从 `offset=0` 加载新目录。 + +### `MoveModal` + +文件:`projects/app/src/components/common/folder/MoveModal.tsx` + +当前协议: + +```ts +server: SelectOneResourceServer +``` + +移动弹窗通过 `selectFolder` 开启目录选择模式,根目录映射为 `null`,禁用无权限目录和当前资源,并复用同一分页列表组件。 + +### `ChatHeader` + +`ChatHeader` 和评测页的 `AppSelect` 均使用 `SelectOneResource` 的面包屑 + 单层列表模式,目录请求走 App V2 分页接口。 + +## 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`。 +- 搜索和切换目录会清空旧数据并重新请求第一页。 +- 面包屑选择器和移动弹窗首次只请求当前目录一页,继续滚动能追加下一页,直到 `data.length >= total`。 +- 聊天标注知识库选择器使用 `listV2` 分页;应用/工作流侧 `components/core/app/DatasetSelectModal.tsx` 明确仍使用旧 `/core/dataset/list` 全量接口,暂不纳入分页验证范围。 +- 面包屑选择器切换目录会重新请求 `offset=0`,移动弹窗可选择根目录 `null`、禁用目录和当前资源。 +- PromptEditor Skill 树、Dataset 全选等仍按语义继续使用 V1 全量数组。 + +### 测试文件 + +- `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. 实现约束 + +需要保留递归树语义的调用方继续使用 V1 数组 wrapper;不能把 V2 第一页直接伪装成全量数组。当前目录分页组件的通用协议如下: + +```ts +type ResourcePage = { + list: GetResourceListItemResponse[]; + total: number; +}; + +type ResourceServer = ( + props: { + parentId: ParentIdType; + offset: number; + pageSize: number; + }, + cancelToken?: AbortController +) => Promise; +``` + +`SelectOneResource` 已完成目录级分页、面包屑切换、选中态、禁用态、加载态和空列表处理;`MoveModal` 通过 `selectFolder` 复用该组件。需要递归树语义的调用方仍应使用 V1 数组 wrapper。 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/ai/skill/api.ts b/packages/global/openapi/core/ai/skill/api.ts index f413ea21bda1..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,21 +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, - 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 b83b263bc08b..e2b52179dbf6 100644 --- a/packages/global/openapi/core/app/common/api.ts +++ b/packages/global/openapi/core/app/common/api.ts @@ -21,6 +21,7 @@ import { } from '../../workflow/node'; import { StoreEdgeItemTypeSchema } from '../../../../core/workflow/type/edge'; import { BoolSchema, NumSchema } from '../../../../common/zod'; +import { PaginationResponseSchema, PaginationSchema } from '../../../api'; import { migrateWorkflowToCurrent } from '../../../../core/workflow/migration'; import z from 'zod'; @@ -310,6 +311,20 @@ 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({ + excludeAppId: ObjectIdSchema.optional().meta({ + description: '排除指定应用,适用于不允许选择当前编辑应用的场景' + }) +}).extend(PaginationSchema.shape); +export type ListAppV2BodyType = z.infer; + export const AppListItemSchema = z .object({ _id: ObjectIdSchema.meta({ description: '应用 ID' }), @@ -337,6 +352,11 @@ export const ListAppResponseSchema = z.array(AppListItemSchema).meta({ }); 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/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/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts index 1a60ed4e954c..268b94245901 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,36 +187,57 @@ export type GetDatasetDetailResponse = 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 = 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..4404e05d35d8 100644 --- a/packages/global/test/openapi/core/app.test.ts +++ b/packages/global/test/openapi/core/app.test.ts @@ -29,6 +29,9 @@ 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?.['/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/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 2fa7cf4ee16f..c301ea3dcbfb 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'; @@ -13,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; @@ -57,6 +63,7 @@ export const listReadableAgentSkills = async ({ category, type, skillIds, + offset, page, pageSize, withAppCount, @@ -72,61 +79,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 +133,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 +143,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 +159,59 @@ export const listReadableAgentSkills = async ({ ); })(); - const mySkills = await MongoAgentSkills.find(findSkillQuery) - .sort(appListSortMongoMap[sort ?? AppListSortEnum.updateTimeDesc]) - .lean(); + 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); + 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 +239,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/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/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx b/packages/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin/index.tsx index b9e2651742ef..05f5f73b5342 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,50 @@ 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'; +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; + folderExpandMode?: 'auto' | 'manual'; onSelect?: (id: string) => Promise; onClick?: (id: string, source?: string) => Promise; - onFolderLoad?: (id: string) => Promise; + loadPage?: SkillOptionPageLoader; + onFolderLoad?: SkillFolderPageLoader; }; export type SkillClickResult = { @@ -61,16 +83,49 @@ export type SkillItemType = { folderChildren?: SkillItemType[]; // Toolset - tools?: { - id: string; - name: string; - source?: string; - }[]; + tools?: 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; + }) + ); +}; + +const isManualFolder = (item: SkillItemType, option: SkillOptionItemType) => + Boolean(item.isFolder && option.folderExpandMode === 'manual'); + +type ColumnPageState = { + option: SkillOptionItemType; + offset: number; + total: number; + loading: boolean; + error: boolean; + lastRequestedOffset?: number; + loadedOffsets: Set; + requestId: number; + controller?: AbortController; +}; + +type ColumnPageStateView = Pick; + +type ColumnPageLoadResult = 'loaded' | 'failed' | 'stale'; + +type ColumnPageRequest = { + option: SkillOptionItemType; + promise: Promise; + token: symbol; +}; + export default function SkillPickerPlugin({ skillOption, isFocus, @@ -83,25 +138,23 @@ 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); }, []); - 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 +162,154 @@ 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< + Map; controller: AbortController }> + >(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>({}); // Refs for scroll management const itemRefs = useRef>(new Map()); + const menuAnchorRef = useRef(null); + const menuElementRef = useRef(null); + const menuPositionFrameRef = useRef(null); + const matchingStringRef = useRef(null); + + const updateHorizontalOverflow = useCallback(() => { + const menuElement = menuElementRef.current; + if (!menuElement) return; + + 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 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 anchorRect = anchorElement.getBoundingClientRect(); + const nextOffset = nextLeft - anchorRect.left; + const nextTransform = `translate3d(${nextOffset}px, 0, 0)`; + + // 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]); + + const scheduleMenuPosition = useCallback(() => { + if (menuPositionFrameRef.current !== null) { + cancelAnimationFrame(menuPositionFrameRef.current); + } + + menuPositionFrameRef.current = requestAnimationFrame(() => { + menuPositionFrameRef.current = requestAnimationFrame(() => { + menuPositionFrameRef.current = null; + updateMenuPosition(); + }); + }); + }, [updateMenuPosition]); + + const setMenuElement = useCallback( + (element: HTMLDivElement | null) => { + if (!element) { + menuElementRef.current?.style.removeProperty('transform'); + } + menuElementRef.current = element; + if (element && isMenuOpenRef.current) { + setIsMenuPositioned(false); + scheduleMenuPosition(); + } + }, + [scheduleMenuPosition] + ); + + useEffect(() => { + if (!isFocus || !isMenuOpen) return; + + const anchorElement = menuAnchorRef.current; + const menuElement = menuElementRef.current; + if (!anchorElement || !menuElement) return; + + scheduleMenuPosition(); + updateHorizontalOverflow(); + + const anchorObserver = new MutationObserver(scheduleMenuPosition); + anchorObserver.observe(anchorElement, { + attributes: true, + attributeFilter: ['style'] + }); + + const menuObserver = new ResizeObserver(() => { + scheduleMenuPosition(); + updateHorizontalOverflow(); + }); + menuObserver.observe(menuElement); + + window.addEventListener('resize', scheduleMenuPosition); + + return () => { + anchorObserver.disconnect(); + menuObserver.disconnect(); + window.removeEventListener('resize', scheduleMenuPosition); - // Scroll selected item into view + if (menuPositionFrameRef.current !== null) { + cancelAnimationFrame(menuPositionFrameRef.current); + menuPositionFrameRef.current = null; + } + }; + }, [ + 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) => { const scroll = (currentRetryCount: number) => { const itemKey = `${columnIndex}-${rowIndex}`; @@ -124,13 +320,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,30 +337,261 @@ 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)); - } + const getItemChildOption = useCallback((item: SkillItemType, option: SkillOptionItemType) => { + 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 + }; + } + + if (item.tools?.length) { + return { + list: item.tools, + folderExpandMode: option.folderExpandMode, + onClick: option.onClick + }; + } + + return undefined; + }, []); + + 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(); + columnPageRequestsRef.current.delete(columnIndex); + columnPageNavigationRef.current.delete(columnIndex); + + if (!option) { + columnPageStateRef.current.delete(columnIndex); + setColumnPageStates((prev) => { + if (!(columnIndex in prev)) return prev; + const next = { ...prev }; + delete next[columnIndex]; + return next; }); - return result; + 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); + columnPageRequestsRef.current.delete(index); + columnPageNavigationRef.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] + ); + + /** + * 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( + (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); + } + + 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(); + const requestId = state.requestId + 1; + state.loading = true; + state.error = false; + state.lastRequestedOffset = offset; + state.requestId = requestId; + state.controller = controller; + publishColumnPageState(columnIndex, state); + + 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); + 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); + } + } + })(); + + columnPageRequestsRef.current.set(columnIndex, { + option, + promise, + token: requestToken + }); + return promise; + }, + [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] + ); - return flatten(column.list); + 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); }, - [skillOptions] + [loadColumnPage, skillOptions] ); - // Handle item selection (hover/keyboard navigation) - const { runAsync: handleItemSelect, loading: isItemSelectLoading } = useRequest( + // Resolve a root category or a folder explicitly expanded by the user. + const { runAsync: handleItemSelect } = useRequest( async ({ currentColumnIndex, item, @@ -175,35 +601,162 @@ 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; - }); - return; + if (!item || !option) return; + + const requestId = ++selectionRequestIdRef.current; + const itemKey = getSkillItemKey(item); + const childOption = getItemChildOption(item, option); + + if (childOption) { + appendColumn(currentColumnIndex, childOption); + return childOption; } - const result = await option.onSelect(item.id); + if (item.isFolder && option.onFolderLoad) { + const cachedOption = folderOptionsRef.current.get(itemKey); + if (cachedOption) { + appendColumn(currentColumnIndex, cachedOption); + return cachedOption; + } - 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) { + 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 page = await pendingRequest.promise; + if (selectionRequestIdRef.current !== requestId) return; + + const nextOption: SkillOptionItemType = { + 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, + onFolderLoad: option.onFolderLoad + }; + folderOptionsRef.current.set(itemKey, nextOption); + appendColumn(currentColumnIndex, nextOption); + return 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) { + const emptyOption = { + description: option.description, + list: [], + folderExpandMode: option.folderExpandMode, + onClick: option.onClick + } satisfies SkillOptionItemType; + appendColumn(currentColumnIndex, emptyOption); + return emptyOption; + } + + if (!option.onSelect) return; + + const result = await option.onSelect(item.id); + if (selectionRequestIdRef.current !== requestId) return; + + appendColumn(currentColumnIndex, result); + return result; + }, + { + refreshDeps: [appendColumn, getItemChildOption] } ); + 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(); + setColumnPageStates({}); + setSkillOptions([skillOption]); + setSelectedRowIndex({ 0: 0 }); + setCurrentColumnIndex(0); + setCurrentRowIndex(0); + setInteractionMode('mouse'); + + const firstItem = skillOption.list[0]; + if (firstItem && !isManualFolder(firstItem, skillOption)) { + void handleItemSelect({ + currentColumnIndex: 0, + item: firstItem, + option: skillOption + }); + } + }, 0); + + return () => { + window.clearTimeout(timer); + selectionRequestIdRef.current += 1; + columnPageStates.forEach((state) => state.controller?.abort()); + columnPageRequests.clear(); + columnPageNavigation.clear(); + 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; @@ -285,95 +838,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,25 +863,27 @@ 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; + columnPageNavigationRef.current.delete(currentColumnIndex); - handleItemSelect({ - currentColumnIndex: currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); + // Keep manual folders collapsed while preserving automatic expansion elsewhere. + const newIndex = currentRowIndex > 0 ? currentRowIndex - 1 : columnItems.length - 1; + setCurrentRowIndex(newIndex); - // Scroll into view after state update - requestAnimationFrame(() => { - scrollIntoView(currentColumnIndex, newIndex); + const currentOption = skillOptions[currentColumnIndex]; + const nextItem = columnItems[newIndex]; + if (currentOption && nextItem && !isManualFolder(nextItem, currentOption)) { + void handleItemSelect({ + currentColumnIndex, + item: nextItem, + option: currentOption }); + } - return newIndex; + requestAnimationFrame(() => { + scrollIntoView(currentColumnIndex, newIndex); }); } @@ -437,26 +903,98 @@ 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({ - currentColumnIndex: currentColumnIndex, - item: columnItems[newIndex], - option: skillOptions[currentColumnIndex] - }); + const currentOption = skillOptions[currentColumnIndex]; + 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 + }); + } - // Scroll into view after state update requestAnimationFrame(() => { - scrollIntoView(currentColumnIndex, newIndex); + scrollIntoView(currentColumnIndex, rowIndex); }); + }; - return newIndex; - }); + 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; + } + + const newIndex = currentRowIndex < columnItems.length - 1 ? currentRowIndex + 1 : 0; + if (currentOption) { + columnPageNavigationRef.current.delete(currentColumnIndex); + moveToRow({ option: currentOption, items: columnItems, rowIndex: newIndex }); + } } return true; @@ -473,10 +1011,46 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); // Use functional updates to get the latest state setCurrentColumnIndex((prevColumnIndex) => { - if (prevColumnIndex >= skillOptions.length - 1) return prevColumnIndex; + const currentOption = skillOptions[prevColumnIndex]; + const currentItem = currentOption?.list[currentRowIndex]; + + if (currentItem && currentOption && isManualFolder(currentItem, currentOption)) { + void handleItemSelect({ + 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; + } + + if (prevColumnIndex >= skillOptions.length - 1) { + if (currentItem && currentOption) { + void handleItemSelect({ + currentColumnIndex: prevColumnIndex, + item: currentItem, + option: currentOption + }); + } + return prevColumnIndex; + } const newColumnIndex = prevColumnIndex + 1; @@ -485,15 +1059,16 @@ 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({ + const newColumnItem = newColumnItems?.[0]; + if (newColumnOption && newColumnItem && !isManualFolder(newColumnItem, newColumnOption)) { + void handleItemSelect({ currentColumnIndex: newColumnIndex, - item: newColumnItems[0], + item: newColumnItem, option: newColumnOption }); @@ -536,8 +1111,9 @@ 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, prevColumnIndex + 1); + return state.slice(0, newColumnIndex + 1); }); // Scroll into view after state update @@ -562,13 +1138,17 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); - 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 @@ -590,13 +1170,26 @@ export default function SkillPickerPlugin({ e.stopPropagation(); setInteractionMode('keyboard'); + columnPageNavigationRef.current.delete(currentColumnIndex); - 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 (isManualFolder(latestItem, latestOption)) return true; + + 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 +1215,240 @@ export default function SkillPickerPlugin({ currentRowIndex, skillOptions, handleItemSelect, - handleFolderToggle, handleItemClick, + loadColumnPage, selectedRowIndex, scrollIntoView, - getFlattenedVisibleItems + getItemChildOption, + clearColumnPageStatesAfter ]); - 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') { + columnPageNavigationRef.current.delete(columnIndex); + setInteractionMode('mouse'); + } + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + columnPageNavigationRef.current.delete(columnIndex); + + 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; + columnPageNavigationRef.current.delete(columnIndex); + + if (columnIndex !== currentColumnIndex) { + setSelectedRowIndex((state) => ({ + ...state, + [currentColumnIndex]: currentRowIndex + })); + } + + setCurrentRowIndex(rowIndex); + setCurrentColumnIndex(columnIndex); + + if (!isManualFolder(item, columnData)) { + 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'; + 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} - ml={columnIndex > 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'} + onScroll={() => handleColumnScroll(columnIndex)} + 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 && !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, renderItemList] + [ + columnPageStates, + currentColumnIndex, + handleColumnScroll, + isItemClickLoading, + renderItemList, + retryColumnPage, + t + ] ); // For LexicalTypeaheadMenuPlugin compatibility @@ -875,6 +1495,10 @@ export default function SkillPickerPlugin({ return ( { + matchingStringRef.current = matchingString; + if (matchingString !== null) { + setMenuQueryVersion((version) => version + 1); + } // Update menu open state based on query updateMenuOpen(matchingString !== null); }} @@ -882,83 +1506,48 @@ 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( - - {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/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 69c0a5067068..99badb77b622 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -1,8 +1,8 @@ -import React, { type ReactNode, type RefObject, useRef, useState } from 'react'; +import React, { type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'; import { Box, type BoxProps } from '@chakra-ui/react'; import { useToast } from './useToast'; import { getErrText } from '@fastgpt/global/common/error/utils'; -import { useBoolean, useLockFn, useMemoizedFn, useScroll, useThrottleEffect } from 'ahooks'; +import { useBoolean, useMemoizedFn, useScroll, useThrottleEffect } from 'ahooks'; import MyBox from '../components/common/MyBox'; import { useTranslation } from 'next-i18next'; import { useRequest } from './useRequest'; @@ -10,11 +10,24 @@ import type { PaginationType, PaginationResponseType } from '@fastgpt/global/ope const thresholdVal = 100; +export type ScrollListType = ({ + children, + ScrollContainerRef, + isLoading, + showLoadingOverlay, + ...props +}: { + children: ReactNode; + ScrollContainerRef?: RefObject; + isLoading?: boolean; + showLoadingOverlay?: boolean; +} & BoxProps) => React.JSX.Element; + export function useScrollPagination< TParams extends PaginationType, TData extends PaginationResponseType >( - api: (data: TParams) => Promise, + api: (data: TParams, cancelToken?: AbortController) => Promise, { scrollLoadType = 'bottom', @@ -42,13 +55,17 @@ 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 isEmpty = total === 0 && !isLoading; + const requestControllerRef = useRef(); + const requestIdRef = useRef(0); + const isRequestingRef = useRef(false); + const isEmpty = hasLoaded && total === 0 && data.length === 0 && !isLoading; const noMore = data.length >= total; - const loadData = useLockFn( + const loadData = useMemoizedFn( async ({ init = false, ScrollContainerRef, @@ -58,33 +75,58 @@ export function useScrollPagination< ScrollContainerRef?: RefObject; silent?: boolean; } = {}) => { - if (noMore && !init) return; + if (!init && (noMore || isRequestingRef.current)) return; const offset = init ? 0 : data.length; // 请求完成到 React 提交列表更新之间,滚动监听可能再次读到旧 data.length。 // 用同步游标拦截相同 offset,避免同一页在这个时间窗口被重复请求。 if (!init && requestedOffsetRef.current === offset) return; - requestedOffsetRef.current = offset; + + if (init) { + // An init request represents new filters, so cancel the old request and start immediately. + requestControllerRef.current?.abort(); + requestedOffsetRef.current = undefined; + } else { + requestedOffsetRef.current = offset; + } + + const requestController = new AbortController(); + const requestId = ++requestIdRef.current; + requestControllerRef.current = requestController; + isRequestingRef.current = true; // 静默刷新用于后台校准数据,保留旧列表并避免整块 loading 闪烁。 if (!silent) { setTrue(); + } else if (init) { + setFalse(); } if (init && !silent) { setData([]); setTotal(0); } + if (init) { + setHasLoaded(false); + } try { - const res = await api({ - offset, - pageSize, - ...params - } as TParams); + const res = await api( + { + offset, + pageSize, + ...params + } as TParams, + requestController + ); + + 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; @@ -112,6 +154,8 @@ export function useScrollPagination< setData(newData); } } catch (error: any) { + if (requestController.signal.aborted || requestId !== requestIdRef.current) return; + requestedOffsetRef.current = undefined; if (showErrorToast) { toast({ @@ -120,23 +164,36 @@ export function useScrollPagination< }); } console.log(error); - } - - if (!silent) { - setFalse(); + } finally { + if (requestId === requestIdRef.current) { + requestControllerRef.current = undefined; + isRequestingRef.current = false; + if (!silent) { + setFalse(); + } + } } } ); + useEffect(() => { + return () => { + requestIdRef.current += 1; + requestControllerRef.current?.abort(); + }; + }, []); + const ScrollRef = useRef(null); const ScrollData = useMemoizedFn( ({ children, ScrollContainerRef, isLoading: isLoadingProp, + showLoadingOverlay = true, ...props }: { isLoading?: boolean; + showLoadingOverlay?: boolean; children: ReactNode; ScrollContainerRef?: RefObject; } & BoxProps) => { @@ -172,7 +229,9 @@ export function useScrollPagination< ref={ref} h={'100%'} overflow={'auto'} - isLoading={isLoading || isLoadingProp} + display={'flex'} + flexDirection={'column'} + isLoading={showLoadingOverlay && (isLoading || isLoadingProp)} {...props} > {scrollLoadType === 'top' && total > 0 && isLoading && ( @@ -181,21 +240,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} ); @@ -222,6 +285,7 @@ export function useScrollPagination< ScrollData, isLoading, total: Math.max(total, data.length), + isEmpty, data, setData, setTotal, 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/hooks/useVirtualList.tsx b/packages/web/hooks/useVirtualList.tsx index d807bb102701..7c6e6db29102 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 = { @@ -219,7 +220,7 @@ export function useVirtualList< TParams extends PaginationType, TData extends PaginationResponseType >( - api: (data: TParams) => Promise, + api: (data: TParams, cancelToken?: AbortController) => Promise, { refreshDeps, itemHeight, @@ -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/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/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/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/packages/web/test/hooks/useScrollPagination.test.ts b/packages/web/test/hooks/useScrollPagination.test.ts new file mode 100644 index 000000000000..051fa40d6498 --- /dev/null +++ b/packages/web/test/hooks/useScrollPagination.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom + +import React, { useEffect } from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import type { PaginationType, PaginationResponseType } from '@fastgpt/global/openapi/api'; +import { useScrollPagination } from '../../hooks/useScrollPagination'; + +vi.mock('next-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('../../hooks/useToast', () => ({ + useToast: () => ({ toast: vi.fn() }) +})); + +vi.mock('../../components/common/MyBox', () => ({ + default: () => null +})); + +vi.mock('@chakra-ui/react', () => ({ + Box: () => null +})); + +vi.mock('../../hooks/useRequest', async () => { + const { useEffect } = await vi.importActual('react'); + + return { + useRequest: ( + service: () => Promise, + options: { manual?: boolean; refreshDeps?: unknown[] } = {} + ) => { + const refreshDepsKey = JSON.stringify(options.refreshDeps ?? []); + + useEffect(() => { + if (options.manual === false) { + void service(); + } + // The mock intentionally models dependency refresh without service identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.manual, refreshDepsKey]); + + return {}; + } + }; +}); + +type ListParams = PaginationType & { query: string }; +type ListResponse = PaginationResponseType; +type RequestRecord = { + params: ListParams; + controller?: AbortController; + resolve: (response: ListResponse) => void; +}; + +type HarnessProps = { + query: string; + api: (params: ListParams, controller?: AbortController) => Promise; + onState: (state: ReturnType>) => void; +}; + +const Harness = ({ query, api, onState }: HarnessProps) => { + const state = useScrollPagination(api, { + pageSize: 10, + params: { query }, + refreshDeps: [query], + showErrorToast: false + }); + + useEffect(() => onState(state), [onState, state]); + + return null; +}; + +const createDeferredApi = () => { + const requests: RequestRecord[] = []; + const api = vi.fn((params: ListParams, controller?: AbortController) => { + return new Promise((resolve) => { + requests.push({ params, controller, resolve }); + }); + }); + + return { api, requests }; +}; + +const renderHarness = async (root: Root, props: HarnessProps) => { + await act(async () => { + root.render(React.createElement(Harness, props)); + await Promise.resolve(); + }); +}; + +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(); + const root = createRoot(document.createElement('div')); + + await renderHarness(root, { query: 'first', api, onState }); + await renderHarness(root, { query: 'second', api, onState }); + + expect(requests).toHaveLength(2); + expect(requests[0].params.query).toBe('first'); + expect(requests[1].params.query).toBe('second'); + expect(requests[0].controller?.signal.aborted).toBe(true); + + await act(async () => { + requests[1].resolve({ list: ['second'], total: 1 }); + await Promise.resolve(); + }); + + const latestState = onState.mock.lastCall?.[0]; + expect(latestState?.data).toEqual(['second']); + + await act(async () => { + requests[0].resolve({ list: ['first'], total: 1 }); + await Promise.resolve(); + }); + + expect(onState.mock.lastCall?.[0].data).toEqual(['second']); + root.unmount(); + }); +}); 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/Select/AppSelect.tsx b/projects/app/src/components/Select/AppSelect.tsx index 1f075e7d369e..86d518cd3161 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 { getMyApps } 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 getMyApps({ - 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 ( {({ onClose }) => ( - + { diff --git a/projects/app/src/components/common/folder/MoveModal.tsx b/projects/app/src/components/common/folder/MoveModal.tsx index 4ea279a27227..4ac0963d68d1 100644 --- a/projects/app/src/components/common/folder/MoveModal.tsx +++ b/projects/app/src/components/common/folder/MoveModal.tsx @@ -1,31 +1,21 @@ import React, { useState } from 'react'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import { useTranslation } from 'next-i18next'; -import { Box, Button, Flex } from '@chakra-ui/react'; -import { - type GetResourceFolderListProps, - type GetResourceFolderListItemResponse, - type ParentIdType -} from '@fastgpt/global/common/parentFolder/type'; -import { useMemoizedFn, useMount } from 'ahooks'; -import MyIcon from '@fastgpt/web/components/common/Icon'; -import { FolderIcon } from '@fastgpt/global/common/file/image/constants'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { Box, Button } from '@chakra-ui/react'; +import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import LightTip from '@fastgpt/web/components/common/LightTip'; - -type FolderItemType = { - id: string; - name: string; - open: boolean; - children?: FolderItemType[]; -}; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import SelectOneResource, { + type SelectOneResourceItemType, + type SelectOneResourceServer +} from './SelectOneResource'; const rootId = 'root'; type Props = { moveResourceId: string; title: string; - server: (e: GetResourceFolderListProps) => 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..3ba66fe5e73c 100644 --- a/projects/app/src/components/common/folder/SelectOneResource.tsx +++ b/projects/app/src/components/common/folder/SelectOneResource.tsx @@ -5,163 +5,195 @@ 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'], + h = '100%', + selectFolder = false, + disabledIds = [] }: { - server: (e: GetResourceFolderListProps) => Promise; + server: SelectOneResourceServer; value?: ParentIdType; - onSelect: (e?: ResourceItemType) => any; + onSelect: (e?: SelectOneResourceItemType) => any; maxH?: BoxProps['maxH']; + h?: BoxProps['h']; + 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 isAutoHeight = h === 'auto'; + + 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; - 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 && ( - - - - )} + 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/components/core/app/DatasetSelectModal.tsx b/projects/app/src/components/core/app/DatasetSelectModal.tsx index 6ee47375eeb9..d648d6898c84 100644 --- a/projects/app/src/components/core/app/DatasetSelectModal.tsx +++ b/projects/app/src/components/core/app/DatasetSelectModal.tsx @@ -17,25 +17,28 @@ import { ChevronRightIcon, CloseIcon } from '@chakra-ui/icons'; import Avatar from '@fastgpt/web/components/common/Avatar'; import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; import type { DatasetListItemType } from '@fastgpt/global/core/dataset/type'; -import type { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; +import type { + ParentIdType, + ParentTreePathItemType +} from '@fastgpt/global/common/parentFolder/type'; import { useToast } from '@fastgpt/web/hooks/useToast'; import MyIcon from '@fastgpt/web/components/common/Icon'; 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 { useDatasetSelect } from '@/components/core/dataset/SelectModal'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; 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'; +import { getAllDatasets, getDatasetPaths } from '@/web/core/dataset/api'; type SelectableDatasetListItem = DatasetListItemType & { vectorModel: NonNullable; }; -/** 只有非目录且向量模型仍启用的知识库才能新增到应用配置。 */ const isSelectableDataset = (item: DatasetListItemType): item is SelectableDatasetListItem => item.type !== DatasetTypeEnum.folder && item.vectorModel?.isActive === true; @@ -63,17 +66,34 @@ export const DatasetSelectModal = ({ const { toast } = useToast(); const { userInfo } = useUserStore(); - // Use server-side search, following the logic of the dataset list page + const [parentId, setParentId] = useState(''); + const [searchKey, setSearchKey] = useState(''); const { - paths, - parentId, - setParentId, - searchKey, - setSearchKey, - datasets, - isFetching, - loadDatasets - } = useDatasetSelect(); + data = { + datasets: [], + paths: [] + }, + loading: isFetching, + runAsync: loadDatasets + } = useRequest( + async () => { + const result = await Promise.all([ + getAllDatasets({ parentId, searchKey }), + searchKey.trim() + ? Promise.resolve([]) + : getDatasetPaths({ sourceId: parentId, type: 'current' }) + ]); + return { + datasets: result[0], + paths: result[1] + }; + }, + { + manual: false, + refreshDeps: [parentId, searchKey] + } + ); + const { datasets, paths } = data; // The vector model of the first selected dataset const activeVectorModel = availableSelectedDatasets[0]?.vectorModel?.model; @@ -113,7 +133,9 @@ 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 onSelect = (item: DatasetListItemType, checked: boolean) => { @@ -284,7 +306,7 @@ export const DatasetSelectModal = ({ )} {datasets.map((item: DatasetListItemType) => ( - + {t('app:Index')}:{' '} - {item.vectorModel?.isActive - ? item.vectorModel.name - : t('dataset:index_model_unavailable')} + {item.vectorModel?.name ?? t('dataset:index_model_unavailable')} )} @@ -378,9 +398,7 @@ export const DatasetSelectModal = ({ onChange={(e) => { if (e.target.checked) { const compatibleDatasets = compatibleDatasetsByModel.filter( - (dataset) => { - return !isDatasetSelected(dataset._id); - } + (dataset) => !isDatasetSelected(dataset._id) ); const newSelections = compatibleDatasets.map((item) => ({ datasetId: item._id, 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..ed65c73abab8 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,56 @@ 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 ?? t('dataset:index_model_unavailable')} + + + + ))} + + )} diff --git a/projects/app/src/components/core/dataset/SelectModal.tsx b/projects/app/src/components/core/dataset/SelectModal.tsx index 10e75f514a71..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 { getDatasets, 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([ - getDatasets({ 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/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 = ({ 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 || ''; @@ -67,6 +67,36 @@ const toSkillLabelItem = ( configStatus }); +const toSystemToolItem = (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 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; @@ -113,32 +143,27 @@ export const useSkillManager = ({ const { toast } = useToast(); /* ===== System tool ===== */ + 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 () => { 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,60 +213,57 @@ 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 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 { + list: response.list.map((item) => toTeamAppItem(item, parentId ?? undefined)), + total: response.total + }; + }, + [] ); - 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 []; + 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)!); } - children.forEach(({ id, appType }) => parentTypes.current.set(id, appType)); - return children.map((item) => { - return { - parentId: folderId, - id: item.id, - label: item.name, - icon: item.avatar, - isFolder: item.isFolder ?? false, - canClick: !AppFolderTypeList.includes(item.appType) - }; - }); + const pendingRequest = rootListRequestsRef.current.get(key); + if (pendingRequest) return pendingRequest; + + const request = loader() + .then((page) => { + rootListCacheRef.current.set(key, page); + return page; + }) + .finally(() => { + rootListRequestsRef.current.delete(key); + }); + + rootListRequestsRef.current.set(key, request); + return request; }, []); /* ===== Agent skills ===== */ @@ -256,30 +278,27 @@ export const useSkillManager = ({ return list.map(toAgentSkillItem); }, []); - const { data: agentSkills = [] } = useRequest( - async () => { - if (!onAddAgentSkill) return []; - - const { list } = await getSkillList({ - source: 'mine', - parentId: '', - withAppCount: false - }); - return cacheAgentSkillList(list); - }, - { - manual: false - } - ); - const onFolderLoadAgentSkills = useCallback( - async (folderId: string) => { - const { list } = await getSkillList({ - 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] ); @@ -430,6 +449,88 @@ export const useSkillManager = ({ ] ); + /** Lazily load the first page of tools and keep later pages on the same loader. */ + const loadMyTools = useCallback(async () => { + 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: firstPage.list, + total: firstPage.total, + folderExpandMode: 'manual' as const, + loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => + loadTeamAppPage(null, types, params, cancelToken), + onFolderLoad: ( + folderId: string, + _source: string | undefined, + params: SkillPickerPageParams, + cancelToken?: AbortController + ) => loadTeamAppPage(folderId, types, params, cancelToken), + onClick: onAddAppOrTool + }; + }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); + + /** Lazily load the first page of agents and keep later pages on the same loader. */ + const loadMyAgents = useCallback(async () => { + 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: firstPage.list, + total: firstPage.total, + folderExpandMode: 'manual' as const, + loadPage: (params: SkillPickerPageParams, cancelToken?: AbortController) => + loadTeamAppPage(null, types, params, cancelToken), + onFolderLoad: ( + folderId: string, + _source: string | undefined, + params: SkillPickerPageParams, + cancelToken?: AbortController + ) => loadTeamAppPage(folderId, types, params, cancelToken), + onClick: onAddAppOrTool + }; + }, [loadRootPage, loadTeamAppPage, onAddAppOrTool, t]); + + /** Lazily load the first page of associated skills and update the lookup map. */ + const loadAgentSkills = useCallback(async () => { + 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: firstPage.list, + total: firstPage.total, + loadPage, + onFolderLoad: onFolderLoadAgentSkills, + onClick: onAddSkill + }; + }, [cacheAgentSkillList, loadRootPage, onAddSkill, onFolderLoadAgentSkills, t]); + /* ===== Skill option ===== */ const skillOption = useMemo(() => { return { @@ -438,29 +539,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 +585,12 @@ export const useSkillManager = ({ }; }, [ onAddAppOrTool, - onAddSkill, onAddAgentSkill, onLoadSystemTool, - myTools, - myAgents, - agentSkills, - onFolderLoadTeamApps, - onFolderLoadAgentSkills, + onFolderLoadSystemTools, + loadMyTools, + loadMyAgents, + loadAgentSkills, t ]); 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({ } > void }) => { const { t } = useTranslation(); const { appDetail } = useContextSelector(AppContext, (v) => v); @@ -81,64 +92,63 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) const [searchKey, setSearchKey] = useState(''); const [selectedTagIds, setSelectedTagIds] = useState([]); + const isTeamTemplate = templateType !== TemplateTypeEnum.systemTools; + const teamTemplateTypes = + templateType === TemplateTypeEnum.myTools ? teamToolTypes : teamAgentTypes; + const { - data: rawTemplates = [], - runAsync: loadTemplates, - loading: isLoading - } = useRequest( - async ({ - type = templateType, - parentId = '', - searchVal = searchKey, - source - }: { - type?: TemplateTypeEnum; - parentId?: ParentIdType; - searchVal?: string; - source?: string; - }) => { - if (type === TemplateTypeEnum.systemTools) { - return getAppToolTemplates({ - parentId, - searchKey: searchVal, - source: parentId ? source : undefined - }); - } else if (type === TemplateTypeEnum.myTools) { - 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 !== appDetail._id)); - } else if (type === TemplateTypeEnum.agent) { - return getTeamAppTemplates({ - parentId, - searchKey: searchVal, - type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] - }).then((res) => res.filter((app) => app.id !== appDetail._id)); - } + data: teamTemplates, + isLoading: isTeamLoading, + ScrollData: TeamScrollData + } = useScrollPagination(getTeamAppTemplatesV2, { + params: { + parentId, + searchKey: searchKey || undefined, + type: teamTemplateTypes, + parentType: parentId ? parentTypes.current.get(parentId) : undefined, + excludeAppId: appDetail._id + }, + pageSize: 50, + disabled: !isTeamTemplate, + refreshDeps: [isTeamTemplate, templateType, parentId, searchKey, appDetail._id] + }); + + const { data: systemTemplates = [], loading: isSystemLoading } = useRequest( + () => { + if (templateType !== TemplateTypeEnum.systemTools) + return Promise.resolve([]); + return getAppToolTemplates({ + parentId, + searchKey, + source: parentId ? parentSource : undefined + }); }, { - onSuccess( - list: (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined, - [{ type = templateType, parentId = '', source }] - ) { - list?.forEach(({ id, appType }) => { - if (appType) parentTypes.current.set(id, appType); - }); - setTemplateType(type); - setParentId(parentId); - setParentSource(parentId ? (source ?? parentSource) : undefined); - }, - refreshDeps: [templateType, searchKey, parentId] + manual: false, + throttleWait: 300, + refreshDeps: [templateType, parentId, parentSource, searchKey] } ); + useEffect(() => { + ( + teamTemplates as (NodeTemplateListItemType & { appType?: AppTypeEnum })[] | undefined + )?.forEach(({ id, appType }) => { + if (appType) parentTypes.current.set(id, appType); + }); + }, [teamTemplates]); + + const isLoading = isTeamTemplate ? isTeamLoading : isSystemLoading; + const rawTemplates = isTeamTemplate ? teamTemplates : systemTemplates; + + const { gridRef, renderVirtualGridItems } = useVirtualGridList({ + list: teamTemplates, + listKey: `${templateType}:${parentId ?? ''}:${searchKey}`, + defaultColumnCount: 2, + estimatedRowHeight: 54, + estimatedRowGap: 12 + }); + const templates = useMemo(() => { if (selectedTagIds.length === 0 || templateType !== TemplateTypeEnum.systemTools) { return rawTemplates; @@ -166,26 +176,16 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) }); const onUpdateParentId = useCallback( - (parentId: ParentIdType, source?: string) => { + (nextParentId: ParentIdType, source?: string) => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } - const nextParentSource = parentId ? (source ?? parentSource) : undefined; - - loadTemplates({ - parentId, - source: nextParentSource - }); + setParentId(nextParentId); + setParentSource(nextParentId ? (source ?? parentSource) : undefined); }, - [loadTemplates, parentSource] + [parentSource] ); - useRequest(() => loadTemplates({ searchVal: searchKey }), { - manual: false, - throttleWait: 300, - refreshDeps: [searchKey] - }); - return ( 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 */} @@ -225,12 +225,11 @@ const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) py={'5px'} px={'15px'} value={templateType} - onChange={(e) => - loadTemplates({ - type: e as TemplateTypeEnum, - parentId: null - }) - } + onChange={(e) => { + setTemplateType(e as TemplateTypeEnum); + setParentId(null); + setParentSource(undefined); + }} /> void }) )} - - - - + + {isTeamTemplate ? ( + + + + ) : ( + + + + )} ); @@ -290,7 +306,10 @@ const RenderList = React.memo(function RenderList({ onRemoveTool, setParentId, selectedTools, - fileSelectConfig + fileSelectConfig, + isPaginated, + gridRef, + renderVirtualGridItems }: Props & { templates: NodeTemplateListItemType[]; type: TemplateTypeEnum; @@ -298,6 +317,11 @@ const RenderList = React.memo(function RenderList({ searchKey: string; selectedTagIds: string[]; setParentId: (parentId: ParentIdType, source?: string) => any; + isPaginated?: boolean; + gridRef?: React.RefObject; + renderVirtualGridItems?: ( + renderItem: (template: NodeTemplateListItemType) => React.ReactNode + ) => React.ReactNode; }) { const { i18n } = useTranslation(); const { t } = useSafeTranslation(); @@ -350,12 +374,168 @@ const RenderList = React.memo(function RenderList({ } ); - const PluginListRender = useMemoizedFn(() => { + const renderTemplate = useMemoizedFn((template: NodeTemplateListItemType) => { const isSystemTool = type === TemplateTypeEnum.systemTools; + 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)} + /> + )} + + + + ); + }); + + 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,14 @@ const RenderList = React.memo(function RenderList({ }); return ( - - + + {PluginListRender()} {type === TemplateTypeEnum.systemTools && ( 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..4443a9166ec7 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 { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; +import { getSkillListV2 } from '@/web/core/skill/api'; import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; @@ -20,25 +20,22 @@ export const useSkillSelectData = () => { const parentId: ParentIdType = navStack.length > 0 ? navStack[navStack.length - 1].id : null; const { - data: skillList = [], - loading: isLoadingSkillList, - refreshAsync: refreshSkillList - } = useRequest( - async () => { - const { list } = await getSkillList({ - 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/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/SelectAppModal.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/SelectAppModal.tsx index 2d6aad89fad7..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 { getMyApps } 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 getMyApps({ - 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 && } + + ); + })} + +