diff --git a/.claude/plan/opensandbox-persistence-plan.md b/.claude/plan/opensandbox-persistence-plan.md deleted file mode 100644 index b34dbd17b3..0000000000 --- a/.claude/plan/opensandbox-persistence-plan.md +++ /dev/null @@ -1,173 +0,0 @@ -# Context - -当前 `opensandbox` 分支已经暴露了 OpenSandbox provider 环境变量,并在运行时通过 `SandboxClient` 走 provider 分支,但 OpenSandbox 的“持久化配置”仍然散落在 `SandboxClient` 内部,没有形成独立的配置抽象,也没有 volume-manager / `createConfig.volumes` / 实例详情落库这条完整链路。 - -相比之下,`agent-skill-dev` 已经把这部分能力拆成了独立配置模块,并通过 volume-manager 为 session/runtime 准备持久化卷,再将存储与运行时详情写入 sandbox 实例记录。 - -本次推荐方案的目标不是整包迁入 `agentSkills` 上层编排,而是:**保留当前分支以 `SandboxClient` 为唯一消费入口的形态,将 OpenSandbox 持久化配置能力下沉到 `packages/service/core/ai/sandbox` 域内完成抽离**。这样可以在最小扰动现有 workflow/tool 调用链的前提下,把持久化卷、配置解析和实例详情持久化补齐。 - -# Recommended Approach - -## 1. 先把配置抽象从 `SandboxClient` 构造函数中抽离 - -新增统一配置模块,建议放在: -- `packages/service/core/ai/sandbox/config.ts` - -从 `agent-skill-dev` 复用并改造以下能力,统一收敛到 sandbox 域: -- `getSandboxProviderConfig` -- `getVolumeManagerConfig` -- `buildVolumeConfig` -- `buildBaseContainerEnv` - -这样 `packages/service/core/ai/sandbox/controller.ts` 不再直接读取 env 并内联拼 provider 参数,而是只消费配置模块输出。 - -## 2. 补齐 provider 类型与实例详情结构 - -当前事实: -- `packages/service/env.ts` 已支持 `opensandbox` -- `packages/service/core/ai/sandbox/type.ts` 的 `SandboxProviderSchema` 仍只有 `sealosdevbox` - -需要在: -- `packages/service/core/ai/sandbox/type.ts` -- `packages/service/core/ai/sandbox/schema.ts` - -补齐以下结构: -- `SandboxProviderSchema` 至少包含 `opensandbox` -- 实例 `detail`:provider 运行时返回信息、endpoint、连接信息 -- 实例 `storage`:volume 标识、claimName、mountPath、provider storage payload -- 实例 `metadata`:session 维度标识、createConfig 摘要、迁移/兼容标记 - -要求:新字段全部可选,保证旧记录仍可读。 - -## 3. 在 `SandboxClient` 内接入持久化卷准备与详情落库 - -核心改造文件: -- `packages/service/core/ai/sandbox/controller.ts` - -推荐保留 `SandboxClient` 作为唯一消费入口,内部改造为: -1. 根据业务标识(`appId/userId/chatId` 或 `sandboxId`)构建稳定 session key -2. 调用 `config.ts` 读取 provider 配置 -3. 如果 provider 为 `opensandbox`,则调用 volume-manager 配置与卷构建逻辑 -4. 将 volume 信息塞入 `createConfig.volumes` -5. 创建/恢复实例后,把 `detail/storage/metadata` 写入 `MongoSandboxInstance` - -复用目标分支的关键能力时,优先迁“配置与卷构建逻辑”,不要直接把 `agentSkills` 生命周期编排整包搬入。 - -## 4. 保持业务入口不变,只做最小上下文透传 - -现有消费入口继续复用 `SandboxClient`: -- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` -- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` - -这两个入口只做最小修改: -- 确保传入稳定的 `appId/userId/chatId` -- 不感知 provider 配置、volume-manager、存储细节 - -原则:**底层配置与持久化逻辑留在 sandbox 域,业务入口不扩散基础设施细节。** - -## 5. 分两阶段实施 - -### 第一阶段(必须先迁) -1. 新增 `packages/service/core/ai/sandbox/config.ts` -2. 扩展 `packages/service/env.ts` 中 OpenSandbox 持久化相关 env -3. 扩展 `packages/service/core/ai/sandbox/type.ts` -4. 扩展 `packages/service/core/ai/sandbox/schema.ts` -5. 改造 `packages/service/core/ai/sandbox/controller.ts` -6. 最小化调整 workflow / tool 入口透传上下文 - -### 第二阶段(可后补) -1. 引入独立 lifecycle 管理(参考 `agent-skill-dev` 的 `.../sandbox/lifecycle.ts`) -2. 区分 edit-debug 与 session-runtime 的 volume 策略 -3. 补充 volume 清理、回滚、观测与旧数据回填能力 - -# Critical Files to Modify - -必须修改: -- `packages/service/env.ts` -- `packages/service/core/ai/sandbox/type.ts` -- `packages/service/core/ai/sandbox/schema.ts` -- `packages/service/core/ai/sandbox/controller.ts` -- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` -- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` - -建议新增: -- `packages/service/core/ai/sandbox/config.ts` - -目标分支中应重点参考、复用逻辑的来源文件: -- `packages/service/core/agentSkills/sandboxConfig.ts` -- `packages/service/core/agentSkills/sandboxController.ts` -- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts` - -# Reuse Existing Functions and Patterns - -优先复用当前仓库已验证的模式: -- 现有单一消费入口模式:`packages/service/core/ai/sandbox/controller.ts` 中的 `SandboxClient` -- 现有运行时消费入口: - - `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` - - `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` -- 目标分支中可迁入的配置抽离函数: - - `getSandboxProviderConfig` - - `getVolumeManagerConfig` - - `buildVolumeConfig` - - `buildBaseContainerEnv` - -复用原则: -- 复用“能力”与“结构”,不直接复制 `agentSkills` 上层调用链 -- 将通用配置逻辑沉入 `core/ai/sandbox`,避免基础设施层依赖业务层 - -# Key Risks and Compatibility Notes - -1. **类型不一致风险** - - 现在 env 支持 `opensandbox`,但 `SandboxProviderSchema` 不支持 - - 必须先统一类型层,再改控制器逻辑 - -2. **旧数据兼容风险** - - 历史 `agent_sandbox_instances` 没有 `detail/storage/metadata` - - 新字段必须 optional,读取逻辑必须支持旧数据回退到 `provider + sandboxId` - -3. **provider 分支串扰风险** - - OpenSandbox 的 volume 逻辑不能污染 `sealosdevbox` / `e2b` - - `createConfig.volumes` 只能在对应 provider 下启用 - -4. **依赖反转风险** - - 不要让 `core/ai/sandbox` 反向依赖 `agentSkills` - - 通用配置能力必须落在 sandbox 域内 - -5. **卷幂等与清理风险** - - 第一阶段至少保证 ensure volume 幂等 - - 清理/回滚放第二阶段补齐 - -# Verification - -## 单测 - -1. `config.ts` - - `getSandboxProviderConfig`:不同 provider 输出正确;缺失关键 env 时给出明确错误 - - `getVolumeManagerConfig`:能正确解析 volume-manager 配置 - - `buildVolumeConfig`:相同 session 上下文生成稳定 volume 配置 - - `buildBaseContainerEnv`:容器基础 env 正确且不泄漏无关敏感信息 - -2. `type.ts` / `schema.ts` - - 旧记录仅有基础字段时可通过读取 - - 新记录包含 `detail/storage/metadata` 时可通过校验 - -3. `controller.ts` - - OpenSandbox 创建时会注入 `createConfig.volumes` - - 创建后会写入 `detail/storage/metadata` - - 旧实例记录仍可兼容 - - 非 OpenSandbox provider 行为不变 - -## 集成测试 - -1. 通过 `dispatchSandboxShell` 触发首次 session sandbox 创建 -2. 检查 `agent_sandbox_instances` 中是否写入 `detail/storage/metadata` -3. 同一 `appId + userId + chatId` 再次进入时,验证 volume 信息可复用 -4. 从 `toolCall.ts` 链路触发一次 sandbox 执行,确认入口无需感知底层 volume 配置 - -## 人工验证 - -1. 配齐 OpenSandbox + volume-manager 环境变量后启动服务 -2. 首次会话进入 sandbox,写入一个测试文件 -3. 结束请求后使用相同 `appId/userId/chatId` 再次进入 -4. 验证测试文件仍然存在,且实例记录中保留 storage/detail 信息 -5. 切换到 `sealosdevbox` / `e2b` 时,验证旧链路行为不变 diff --git a/.codex/code/syntax.md b/.codex/code/syntax.md new file mode 100644 index 0000000000..ce54853a54 --- /dev/null +++ b/.codex/code/syntax.md @@ -0,0 +1,279 @@ +# 代码规范 + +## 基础代码组织模式 + +采用 DDD 架构,按业务域 → 子功能 → 固定文件三层划分。 + +### 目录结构 + +``` +packages/ +├── global/core/ # 类型、常量(前后端共享) +│ ├── app/ +│ │ ├── type.ts # 顶层聚合类型 +│ │ ├── constants.ts +│ │ ├── workflow/ +│ │ │ ├── type.ts +│ │ │ └── constants.ts +│ │ ├── version/ +│ │ │ └── type.ts +│ │ └── evaluation/ +│ │ └── type.ts +│ ├── chat/ +│ ├── dataset/ +│ └── plugin/ +│ +└── service/core/ # 后端业务逻辑(不可在前端引用) + ├── app/ + │ ├── schema.ts # App 主表 Mongoose Schema + │ ├── entity.ts # findById / create / updateById 等基础操作封装 + │ ├── service.ts # 聚合业务逻辑(跨子功能协调),不允许互相引用,只允许单向依赖,跨 service 的协调需由上层通过 props 传入另一个 service 或者衍生方法 + │ ├── auth.ts # 鉴权相关(如有) + │ ├── utils.ts # 纯函数工具,无副作用,可独立单测 + │ ├── version/ + │ │ ├── schema.ts + │ │ ├── entity.ts + │ │ ├── service.ts + │ │ └── utils.ts + │ ├── evaluation/ + │ │ ├── schema.ts # 合并多个 schema 到单文件 + │ │ ├── entity.ts + │ │ ├── service.ts + │ │ └── utils.ts + │ ├── logs/ + │ └── tool/ + │ ├── service.ts + │ └── utils.ts + ├── chat/ + ├── dataset/ + └── plugin/ +``` + +### 叶子目录固定文件说明 + +| 文件 | 职责 | +|------|------| +| `schema.ts` | Mongoose Schema 定义,导出 Model 和 SchemaType | +| `entity.ts` | 数据访问封装:`findById`、`create`、`updateById` 等基础操作 | +| `service.ts` | 业务逻辑:调用 entity,跨模块协调,处理业务规则 | +| `utils.ts` | 纯函数工具,无副作用,可独立单测 | + +```typescript +// entity.ts 示例 —— 只做数据访问,不含业务判断 +export const findAppById = (id: string) => + MongoApp.findById(id).lean(); +export const createApp = (data: AppCreateParams, session?: ClientSession) => + MongoApp.create([data], { session }); + +// service.ts 示例 —— 调用 entity,处理业务规则 +export const createAppAndInitVersion = async (data: AppCreateParams, session?: ClientSession) => { + const app = await createApp(data, session); + await createVersion({ appId: app._id, ... }, session); + return app; +}; + +// service 需协同,通过 props 传入另一个 service 或者衍生方法。 +const service1 = xxxx +const service2 = (props: {id:string; service1: typeof service1 }) => { + const data = findAppById(id) + return props.service1(data); +}; +``` + +### 层级约束 + +- `global/core/` 只放类型和常量,**禁止**引入 mongoose、服务端 SDK +- `service/core/` 只在服务端使用,**禁止**被 `packages/web/` 或前端页面直接引用 +- 子功能目录不超过 **3 层**嵌套 +- 一个目录内无需拆子功能时,直接放 `schema.ts` + `entity.ts` + `service.ts` + `utils.ts` +- 多个 schema 文件(如 `evalSchema.ts` + `evalItemSchema.ts`)**合并**到单个 `schema.ts` + +## 代码风格 +### 使用 `type` 进行类型声明,不使用 `interface` + +```typescript +// ❌ 不好的实践 +interface User { + id: string; + name: string; +} + +// ✅ 好的实践 +type User = { + id: string; + name: string; +} +``` + +--- + +### 使用 IIFE 写法来取代 if/else 进行变量条件赋值。 + +```typescript +// ❌ 不好的实践 +if (condition) { + value = true; +} else { + value = false; +} + +// ✅ 好的实践 +const value = (() => { + if (condition) { + return true; + } + return false; +})(); +``` + +--- + +### 类型推导:Zod schema 同时承担校验和类型 + +用 `z.infer` 从 schema 推导类型,不重复手写相同结构的 type。 + +```typescript +// ❌ 不好的实践 +type MessageParam = { role: 'user' | 'assistant'; content: string }; +const MessageParamSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string() }); + +// ✅ 好的实践 +export const MessageParamSchema = z.discriminatedUnion('role', [...]); +export type MessageParam = z.infer; +``` + +--- + +## 可选链调用回调 + +用 `?.()` 调用可选回调,取代 `if (fn) fn()` 的冗余写法。 + +```typescript +// ❌ 不好的实践 +if (onProgress) { + onProgress({ phase: 'creatingContainer' }); +} + +// ✅ 好的实践 +onProgress?.({ phase: 'creatingContainer' }); +``` + +--- + +### 空值合并取默认值 + +用 `??` 取代 `||` 处理默认值,避免 `0`、`false`、`''` 被错误覆盖。 + +```typescript +// ❌ 不好的实践 +const version = lastVersion?.version || 0; // version 为 0 时被误覆盖 +const text = item?.value || ''; + +// ✅ 好的实践 +const version = (lastVersion?.version ?? -1) + 1; +const text = item?.value ?? ''; +``` + +--- + +### 解构重命名 + +同名变量来自多个来源时,解构时重命名,避免命名冲突。 + +```typescript +// ❌ 不好的实践 +const r1 = await getSkillGuidance(...); +const r2 = await createLLMResponse(...); +const inputTokens = r1.usage.inputTokens + r2.usage.inputTokens; + +// ✅ 好的实践 +const { usage: guidanceUsage } = await getSkillGuidance(...); +const { usage: generateUsage } = await createLLMResponse(...); +const inputTokens = guidanceUsage.inputTokens + generateUsage.inputTokens; +``` + +--- + +### 类型守卫 + +用 `is` 关键字收窄 `unknown` / `any` 类型,替代强制断言。 + +```typescript +// ❌ 不好的实践 +function process(value: unknown) { + const n = value as number; // 不安全 +} + +// ✅ 好的实践 +const isValidNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +if (isValidNumber(value)) { + // 此处 value 安全收窄为 number +} +``` + +--- + +### 非关键清理用 `.catch()` 链 + +次要的清理操作(不影响主流程)用 `.catch()` 吞掉错误,不污染主 try/catch。 + +```typescript +// ❌ 不好的实践 +try { + await client.delete(); +} catch { + // 清理失败,主流程中断 +} + +// ✅ 好的实践 +await client.delete().catch(() => {}); +``` + +--- + +### 函数参数不超过 2 个,多参数用对象传递 + +独立参数不超过 2 个,超过时改为对象参数,便于扩展且无需关心顺序。 + +```typescript +// ❌ 不好的实践 +function createVersion(skillId: string, teamId: string, tmbId: string, version: number) {} + +// ✅ 好的实践 +function createVersion(data: { skillId: string; teamId: string; tmbId: string; version: number }) {} +``` + +--- + +### 数据写操作函数支持可选 session 参数 + +涉及数据库写操作的函数统一支持可选的 `session` 参数,便于上层组合事务。事务统一通过 `mongoSessionRun` 发起,内部自动处理 startTransaction / commit / abort / retry。 + +```typescript +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { type ClientSession } from '@fastgpt/service/common/mongo'; + +// entity.ts —— 基础操作透传 session +export const createVersion = (data: CreateVersionData, session?: ClientSession) => + MongoAppVersion.create([data], { session }); + +// service.ts —— 需要事务时用 mongoSessionRun 包裹,外部已有 session 时直接传入 +export const createAppAndInitVersion = async ( + data: AppCreateParams, + session?: ClientSession +) => { + const create = async (session: ClientSession) => { + const app = await createApp(data, session); + await createVersion({ appId: app._id, version: 0 }, session); + return app; + }; + + if (session) { + return create(session); + } else { + return mongoSessionRun(create); + } +}; +``` diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.codex/design/api/index.md b/.codex/design/api/index.md new file mode 100644 index 0000000000..389cbda564 --- /dev/null +++ b/.codex/design/api/index.md @@ -0,0 +1,351 @@ +# FastGPT API 设计规范 + +## 核心原则 + +### 必须遵守 + +1. **所有 API 必须使用 zod schema 定义入参和出参** +2. **Schema 定义在 `packages/global/openapi/` 中,类型从 schema 导出** +3. **API 路由中使用 `schema.parse()` 验证入参和出参** +4. **路由文件不导出类型别名** — 消费者直接从 openapi 包导入 +5. **必须在 OpenAPI index 中注册路由** + +## 文件位置约定 + +``` +packages/global/openapi/ +├── api.ts # 公共 Schema (分页等) +├── type.ts # OpenAPIPath 类型 +├── tag.ts # API 标签定义 +├── index.ts # 用户 API 文档入口 +├── admin.ts # 管理员 API 文档入口 +└── core/ + └── dataset/ + ├── api.ts # ← Schema 定义 (入参/出参) + ├── index.ts # ← OpenAPI 路由注册 + ├── collection/ + │ ├── api.ts + │ └── index.ts + └── data/ + ├── api.ts + └── index.ts +``` + +## 开发流程 (5 步) + +### 步骤 1: 定义 Zod Schema + +**文件位置**: `packages/global/openapi/[module]/api.ts` + +```typescript +import { z } from 'zod'; +import { ObjectIdSchema } from '../../../common/type/mongo'; +import { ParentIdSchema } from '../../../common/parentFolder/type'; + +/* ============================================================================ + * API: 创建知识库 + * Route: POST /api/core/dataset/create + * ============================================================================ */ +// 入参 Schema +export const CreateDatasetBodySchema = z.object({ + parentId: ParentIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '父级文件夹 ID' + }), + name: z.string().meta({ + example: '我的知识库', + description: '知识库名称' + }) +}); +export type CreateDatasetBodyType = z.infer; + +// 出参 Schema +export const CreateDatasetResponseSchema = ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '新创建的知识库 ID' +}); +export type CreateDatasetResponseType = z.infer; +``` + +### 步骤 2: 实现 API 路由 + +**文件位置**: `projects/app/src/pages/api/[path]/[route].ts` + +```typescript +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { + CreateDatasetBodySchema, + CreateDatasetResponseSchema, + type CreateDatasetResponseType +} from '@fastgpt/global/openapi/core/dataset/api'; + +// ❌ 不要在路由文件中重导出类型别名 +// export type DatasetCreateBodyType = CreateDatasetBodyType; + +async function handler(req: ApiRequestProps): Promise { + // 1. 入参验证 + const { parentId, name } = CreateDatasetBodySchema.parse(req.body); + + // 2. 业务逻辑 + const datasetId = await createDataset({ parentId, name }); + + // 3. 出参验证 + return CreateDatasetResponseSchema.parse(datasetId); +} + +export default NextAPI(handler); +``` + +### 步骤 3: 注册 OpenAPI 路由 + +**3a. 添加标签** (如果是新模块): `packages/global/openapi/tag.ts` + +```typescript +export const TagsMap = { + // Dataset + datasetCommon: '知识库管理', // ← 新增 + datasetCollection: '集合管理', + // ... +}; +``` + +**3b. 注册路由**: `packages/global/openapi/[module]/index.ts` + +```typescript +import type { OpenAPIPath } from '../../type'; +import { TagsMap } from '../../tag'; +import { CreateDatasetBodySchema } from './api'; + +export const DatasetPath: OpenAPIPath = { + '/core/dataset/create': { + post: { + summary: '创建知识库', + description: '创建新的知识库,支持多种类型', + tags: [TagsMap.datasetCommon], + requestBody: { + content: { + 'application/json': { + schema: CreateDatasetBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回新创建的知识库 ID' + } + } + } + }, + ...DatasetCollectionPath, + ...DatasetDataPath +}; +``` + +### 步骤 4: 前端类型引用 + +前端直接从 openapi 包导入类型,不从 API 路由文件导入。 + +```typescript +// ✅ 正确: 从 openapi 导入 +import type { CreateDatasetBodyType } from '@fastgpt/global/openapi/core/dataset/api'; + +// ❌ 错误: 从路由文件导入 +import type { DatasetCreateBodyType } from '@/pages/api/core/dataset/create'; + +// ❌ 错误: 从旧的 global 文件导入 +import type { CreateDatasetParams } from '@/global/core/dataset/api'; +``` + +### 步骤 5: 测试文件 + +测试同样直接从 openapi 导入类型。 + +```typescript +import createHandler from '@/pages/api/core/dataset/create'; +import type { + CreateDatasetBodyType, + CreateDatasetResponseType +} from '@fastgpt/global/openapi/core/dataset/api'; +import { Call } from '@test/utils/request'; + +const res = await Call(createHandler, { + auth: users.members[0], + body: { name: 'test', intro: 'intro', avatar: 'avatar', type: DatasetTypeEnum.dataset } +}); +``` + +## 公共 Schema 参考 + +使用已有的公共 Schema,避免重复定义。 + +### ID 类型 + +| Schema | 说明 | 导入路径 | +|--------|------|----------| +| `ObjectIdSchema` | MongoDB ObjectId (24位hex,自动将 ObjectId 对象转为 string) | `@fastgpt/global/common/type/mongo` | +| `ParentIdSchema` | 父级 ID (`z.string().nullish()`) | `@fastgpt/global/common/parentFolder/type` | + +**ObjectId 陷阱**: MongoDB 返回的 `_id` 是 ObjectId 对象,不是 string。直接用 `z.string()` 验证出参会报错。必须使用 `ObjectIdSchema`(内置 preprocess 自动转换)。 + +```typescript +// ❌ 出参验证会失败 — ObjectId 不是 string +export const ResponseSchema = z.string(); +return ResponseSchema.parse(document._id); // ZodError! + +// ✅ ObjectIdSchema 内置 preprocess,自动转换 +import { ObjectIdSchema } from '../../../common/type/mongo'; +export const ResponseSchema = ObjectIdSchema; +return ResponseSchema.parse(document._id); // "68ad85a7463006c963799a05" +``` + +### 分页 Schema + +| Schema | 说明 | 导入路径 | +|--------|------|----------| +| `PaginationSchema` | 偏移分页 (pageSize, offset, pageNum) | `@fastgpt/global/openapi/api` | +| `PaginationResponseSchema` | 分页响应 (total, list) | `@fastgpt/global/openapi/api` | +| `LinkedPaginationSchema` | 游标分页 (pageSize, nextId, prevId) | `@fastgpt/global/openapi/api` | +| `LinkedListResponseSchema` | 游标分页响应 (list, hasMorePrev, hasMoreNext) | `@fastgpt/global/openapi/api` | + +### 认证 Schema + +| Schema | 说明 | 导入路径 | +|--------|------|----------| +| `OutLinkChatAuthSchema` | 外部链接认证 (shareId, outLinkUid, teamId, teamToken) | `@fastgpt/global/support/permission/chat` | + +### 业务 Schema + +已有的业务级 Schema 可以直接在出入参中复用: + +| Schema | 说明 | 导入路径 | +|--------|------|----------| +| `ApiDatasetServerSchema` | 第三方知识库服务器配置 | `@fastgpt/global/core/dataset/apiDataset/type` | +| `EmbeddingModelItemSchema` | 向量模型信息 | `@fastgpt/global/core/ai/model.schema` | + +遇到复杂嵌套类型时,优先查找是否已有对应的 zod schema 可复用。 + +## Schema 字段定义规范 + +### meta 信息 + +所有字段必须有 `description`,推荐有 `example`: + +```typescript +z.string().meta({ + example: 'alice@example.com', + description: '用户邮箱' +}) +``` + +### 可选字段 + +```typescript +// 可选 (undefined) +z.string().optional().meta({ description: '...' }) + +// 可空 (undefined | null) — 用于 parentId 等场景 +z.string().nullish().meta({ description: '...' }) +``` + +### 枚举字段 + +```typescript +import { DatasetTypeEnum } from '../../../core/dataset/constants'; + +// TypeScript enum — 使用 z.enum +z.enum(DatasetTypeEnum).meta({ + example: DatasetTypeEnum.dataset, + description: '知识库类型' +}) +``` + +### 嵌套对象 + +嵌套对象优先抽取为独立 Schema 复用: + +```typescript +const FileItemSchema = z.object({ + fileId: z.string().meta({ example: 'temp/abc.pdf', description: '文件 ID' }), + name: z.string().meta({ example: '文档.pdf', description: '文件名' }) +}); + +export const CreateWithFilesBodySchema = z.object({ + files: z.array(FileItemSchema).meta({ description: '文件列表' }) +}); +``` + +## 旧 API 改造指南 + +将旧 API 迁移到 zod schema 规范时,除了上述 5 步外,还需要: + +### 1. 清理旧类型定义 + +旧类型通常在 `projects/app/src/global/` 或路由文件中。迁移后: +- 如果旧类型已无其他引用,可删除 +- 如果仍有其他 API 引用,暂保留,逐步迁移 + +### 2. 更新所有引用点 + +使用全局搜索找到所有旧类型的引用,逐一更新: + +```bash +# 搜索旧类型名 +grep -r "CreateDatasetParams" projects/app/src/ +``` + +需要更新的典型位置: +- `projects/app/src/web/` — 前端 API 调用 +- `projects/app/src/pageComponents/` — 页面组件 +- `projects/app/test/` — 测试文件 + +### 3. 移除路由文件中的类型别名 + +```typescript +// ❌ 删除这些 +export type DatasetCreateQuery = {}; +export type DatasetCreateBodyType = CreateDatasetBodyType; +export type DatasetCreateResponse = CreateDatasetResponseType; +``` + +## 审查检查清单 + +### 必须检查项 + +**Schema 文件** (`packages/global/openapi/.../api.ts`): +- [ ] 文件头部有 API 声明注释 (路由、方法、描述、标签) +- [ ] 入参和出参都用 zod schema 定义 +- [ ] 导出 `z.infer` 类型 +- [ ] 所有字段有 `description` +- [ ] ID 字段使用 `ObjectIdSchema`,父级 ID 使用 `ParentIdSchema` + +**API 路由文件** (`projects/app/src/pages/api/.../route.ts`): +- [ ] 入参使用 `Schema.parse(req.body)` 或 `Schema.parse(req.query)` +- [ ] 出参使用 `Schema.parse(result)` +- [ ] 函数返回值类型声明为 openapi 导出的类型 +- [ ] **没有**重导出类型别名 + +**OpenAPI 注册** (`packages/global/openapi/.../index.ts`): +- [ ] 路由已在 index.ts 中注册 +- [ ] 使用了正确的 `TagsMap` 标签 +- [ ] 新模块已在 `tag.ts` 中添加标签 + +**类型引用**: +- [ ] 前端代码从 `@fastgpt/global/openapi/` 导入类型 +- [ ] 测试文件从 `@fastgpt/global/openapi/` 导入类型 +- [ ] 没有从路由文件 (`@/pages/api/...`) 导入类型 + +## 项目内参考示例 + +- **Schema 定义**: `packages/global/openapi/core/dataset/api.ts` +- **OpenAPI 注册**: `packages/global/openapi/core/dataset/index.ts` +- **API 路由实现**: `projects/app/src/pages/api/core/dataset/create.ts` +- **前端调用**: `projects/app/src/web/core/dataset/api.ts` +- **测试文件**: `projects/app/test/api/core/dataset/create.test.ts` +- **分页 Schema**: `packages/global/openapi/api.ts` +- **标签定义**: `packages/global/openapi/tag.ts` + +--- + +**Version**: 2.0 +**Last Updated**: 2026-04-10 diff --git a/.codex/design/bug/chat-file-remap-功能开发文档.md b/.codex/design/bug/chat-file-remap-功能开发文档.md new file mode 100644 index 0000000000..990f811191 --- /dev/null +++ b/.codex/design/bug/chat-file-remap-功能开发文档.md @@ -0,0 +1,287 @@ +# 功能开发文档 + +## 文档标识 + +- 任务前缀:`chat-file-remap` +- 文档文件名:`chat-file-remap-功能开发文档.md` +- 更新时间:2026-04-24 +- 文档定位:实现对齐与验收口径(运行时逐条 user file 注入) + +## 0. 开发目标与约束 + +- 功能目标:修复 file-only user message 发给模型时退化为 `null` 的问题,并确保历史记录中每条带 file URL 的 human message 在运行时把文件内容注入回自己的 user message。 +- 代码范围:`v1/v2/chatTest` 保存层回退、`chat/tool` 运行时消息构造、文件解析 helper、相关测试与文档同步。 +- 非目标:历史数据回填、`file_url` 直传模型、API/DB schema 调整、前端交互调整。 +- 实现原则:保存层不污染原始输入;运行时只改 LLM messages 副本;文件内容进 user message,不进 system prompt。 +- 文件上限:每条 user query 的文件解析数量沿用 `chatConfig.fileSelectConfig.maxFiles`,不做跨 message URL 去重。 +- 必须遵循规范:`references/style-standards-entry.md`。 +- 适用维度:API[ ] DB[ ] Front[ ] Logger[ ] Package[x] BugFix[x] DocUpdate[x] DocI18n[ ]。 + +## 1. 实施任务拆解(可直接执行) + +| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义(DoD) | +|---|---|---|---|---|---| +| T1 | 保存层回退为原始输入 | API/Service | `userQuestion` | 原始保存参数 | `v1/v2/chatTest` 不再保存 `enrichedUserQuestion` | +| T2 | 运行时单条 user query 文件重写 helper | Service | 单条 human `value`、`maxFiles`、文件读取 helper | 增强后的 user query 副本 | 每条 human file 的 `` 回填到所属 user message | +| T3 | Chat node 接入运行时注入 | Service | Chat node runtime props | LLM messages | 文件内容进入 user message,system 不含文件内容 | +| T4 | Tool node 接入运行时注入 | Service | Tool node runtime props | Tool-call LLM messages | 无 `readFiles` tool 时注入;有 `readFiles` tool 时跳过 | +| T5 | 测试与清理 | Test/Service | T1-T4 改动 | 可执行测试 + 干净 import | 覆盖当前轮、历史逐条、`maxFiles`、system 纯净、保存层不污染 | + +## 2. 文件级改动清单 + +| 文件路径 | 改动类型 | 变更摘要 | 关键代码(可伪代码) | 关联任务ID | +|---|---|---|---|---| +| `projects/app/src/pages/api/v1/chat/completions.ts` | 修改 | 移除保存前增强使用,保存原始 `userQuestion` | `userContent: userQuestion` | T1 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | 修改 | 同 v1,`prepare/finalize/updateInteractive` 使用原始输入 | `userContent: userQuestion` | T1 | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | 修改 | 修复 review 评论点,不再改写输入问题 | `userContent: userQuestion` | T1 | +| `packages/service/core/chat/utils.ts` | 修改 | 删除或回退保存前 `enrichUserContentWithParsedFiles` 新增能力 | 移除未使用导入/函数 | T1 | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | 修改 | 并行处理 human messages,逐条重写 user query,文件内容不进 system | `Promise.all(...rewriteUserQueryWithFileContent(...))` | T2/T3 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 修改 | Tool LLM messages 同步并行重写;保留 `hasReadFilesTool` skip | `skip: hasReadFilesTool` | T2/T4 | +| `packages/service/core/workflow/utils/context.ts` | 修改/复用 | 承载单条 user query 文件内容重写 helper | `rewriteUserQueryWithFileContent(...)` | T2 | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | 修改/复用 | 保留可读文件 URL 标准化、读文件与解析文件能力,供 readFiles tool 和重写 helper 复用 | `normalizeReadableFileUrl(...)` / `getFileContentFromLinks(...)` | T2 | +| `packages/service/core/ai/llm/utils.ts` | 修改/测试驱动 | 保持 `file_url` 过滤,确保同条 text 保留 | 不改协议行为 | T5 | +| `test/cases/...` | 修改/新增 | 替换保存前增强测试,新增运行时逐条注入测试 | 当前轮/历史/Tool/maxFiles | T5 | + +## 2.1 关键代码片段(用于规划核对) + +```ts +// 保存层:禁止保存增强后的 userContent +await finalizeChatRound({ + ...params, + userContent: userQuestion +}); +``` + +```ts +// 运行时:只增强发给 LLM 的 messages 副本 +const userMessages = await Promise.all( + rawUserMessages.map(async (message) => { + if (message.obj !== ChatRoleEnum.Human) return message; + + return { + ...message, + value: await rewriteUserQueryWithFileContent({ + userQuery: message.value, + requestOrigin, + maxFiles, + customPdfParse, + getFileContentFromLinks, + teamId, + tmbId + }) + }; + }) +); +``` + +```ts +// 注入规则:文件内容回填到所属 user message,不集中塞到最后一条 +const finalText = [originText, filePrompt].filter(Boolean).join('\n\n===---===---===\n\n'); +``` + +## 3. 后端实施说明 + +### 3.1 API 改动 + +N/A(无对外接口结构变化)。 + +内部保存链路要求: + +1. `prepareChatRound`、`finalizeChatRound`、`pushChatRecords`、`updateInteractiveChat` 均使用原始 `userQuestion`。 +2. 不在 API handler 中解析文件并改写 `userQuestion`。 +3. 不新增请求/响应字段。 + +### 3.2 Service/Core 改动 + +| 模块 | 函数/类型 | 具体改动 | 依赖关系 | +|---|---|---|---| +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getChatMessages` 附近 | 构造 LLM messages 前,对历史 human 与当前轮 user 做文件内容注入 | 依赖 `getFileContentFromLinks` | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getMultiInput` | 不再把文件正文作为 system quote;当前轮文件参与逐条注入 | 与 token 裁剪链路协同 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | `dispatchRunTools` | 与 Chat 路径一致;无 `readFiles` tool 时注入,有则跳过 | 避免与 readFiles tool 重复预解析 | +| `packages/service/core/workflow/utils/context.ts` | `rewriteUserQueryWithFileContent` | 单条 user query 重写 ``,外层负责并行处理 history/current messages | 通过入参复用 `getFileContentFromLinks` | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | `normalizeReadableFileUrl` / `getFileContentFromLinks` | 统一负责 URL 标准化、过滤、文件读取与解析;按单条 query URL 顺序与 `maxFiles` 控制解析量 | 保持现有错误兜底 | +| `packages/service/core/ai/llm/utils.ts` | `loadRequestMessages` | 保持 `file_url` 过滤;回归验证 text part 不丢 | 最终模型请求安全过滤 | + +### 3.3 运行时注入算法 + +1. 输入为 `chatHistories` 与当前轮 user prompt,先 clone 或新建消息数组,不能 mutate 原对象。 +2. Chat/Tool 外层通过 `Promise.all` 并行处理运行时 messages。 +3. 非 human message 原样返回;human message 调用 `rewriteUserQueryWithFileContent`。 +4. 单条 user query 内只收集本条 `file.url`,不做跨 message URL 去重或共享缓存。 +5. 调用 `getFileContentFromLinks` 统一完成 URL 标准化、过滤、`maxFiles` 截断与文件解析。 +6. 将解析结果回填到当前 user query: + - message 原本有 text:追加分隔符和 ``。 + - message 原本无 text:新增 text part。 +7. 构造最终 `chats2GPTMessages` / tool-call messages。 +8. 进入 `loadRequestMessages` 后,`file_url` 可以继续被过滤,但 text 中的文件正文必须保留。 + +### 3.4 数据层改动 + +N/A(不改 schema、索引、迁移逻辑)。 + +### 3.5 Bug 修复实施 + +| 项目 | 内容 | +|---|---| +| 问题点文件 | `packages/service/core/ai/llm/utils.ts`、`projects/app/src/pages/api/v1/chat/completions.ts`、`projects/app/src/pages/api/v2/chat/completions.ts`、`projects/app/src/pages/api/core/chat/chatTest.ts`、`packages/service/core/workflow/dispatch/ai/chat.ts`、`packages/service/core/workflow/dispatch/ai/tool/index.ts` | +| 问题点函数/代码段 | `loadRequestMessages`、三个路由的保存参数组装、`getMultiInput/getChatMessages/dispatchRunTools` | +| 触发条件 | 当前轮或历史 human message 中存在 file-only / file+text | +| 根因(直接原因) | `file_url` 被过滤后没有可供模型消费的文件正文 | +| 根因(深层原因) | 文件正文注入位置放错到保存层或 system prompt,没有在消费输入的 node 内逐条处理 user message | +| 修复动作 | 保存层回退原始输入;运行时逐条 user message 注入文件正文 | +| 影响范围 | Chat/Tool LLM 请求构造与历史 file 后续对话 | + +修复关键伪代码: + +```ts +const userMessages = await Promise.all( + rawUserMessages.map(async (message) => + message.obj === ChatRoleEnum.Human + ? { + ...message, + value: await rewriteUserQueryWithFileContent({ + userQuery: message.value, + maxFiles, + requestOrigin, + customPdfParse, + getFileContentFromLinks, + teamId, + tmbId + }) + } + : message + ) +); + +const messages = [ + ...getSystemPrompt_ChatItemType(concatenateSystemPrompt), + ...userMessages +]; +``` + +回归验证: + +1. 当前轮 file-only 不再变 `null`。 +2. 历史每条 human file 都注入回自己的 user message。 +3. 保存层不新增 ``。 +4. 无 file 场景无回归。 + +## 4. 前端实施说明 + +N/A(本期无前端改动)。 + +## 5. 日志与可观测性 + +| 触发点 | 日志级别 | category | 字段 | 备注 | +|---|---|---|---|---| +| 本次修复 | N/A | N/A | N/A | 不新增日志点,不打印文件正文 | + +注意事项: + +- 不新增观测方案。 +- 不在日志中输出用户文件正文、解析文本或完整 prompt。 + +## 6. 文档更新提醒 + +| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 | +|---|---|---|---|---|---|---| +| `chat-file-remap-需求设计文档.md` | 研发设计文档 | PR review 后方案口径调整 | 改为运行时逐条 user file 注入 | Codex | 2026-04-24 | 本次完成 | +| `chat-file-remap-功能开发文档.md` | 研发开发文档 | 实施任务与测试口径需同步 | 更新任务拆解、改动清单、测试计划 | Codex | 2026-04-24 | 本次完成 | + +## 7. 文档 i18n 实施说明 + +N/A,原因:本次只改 `.claude/design` 研发文档,不改 `document/content/docs` 目录。 + +## 8. 测试与验证 + +### 8.1 测试文件映射 + +| 源文件路径 | 文件类型 | 目标测试文件路径 | 是否跳过 | 跳过理由 | +|---|---|---|---|---| +| `packages/service/core/workflow/dispatch/ai/chat.ts` | packages | 新增/复用 dispatch chat 相关测试 | 否 | 运行时注入核心逻辑 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | packages | 新增/复用 dispatch tool 相关测试 | 否 | Tool 分支需覆盖 | +| `packages/service/core/ai/llm/utils.ts` | packages | `test/cases/service/core/ai/llm/utils.test.ts` | 否 | 回归 `file_url` 过滤后 text 保留 | +| `projects/app/src/pages/api/v1/chat/completions.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | + +### 8.2 自动化测试设计 + +| 类型 | 用例 | 预期结果 | +|---|---|---| +| 单元测试 | 当前轮 file-only | LLM user message 包含 ``,`loadRequestMessages` 后不为 `null` | +| 单元测试 | 当前轮 file+text | 原问题与文件正文都在当前轮 user message | +| 单元测试 | 多条历史 human 均有 file | 每条历史 user message 各自注入自己的文件正文 | +| 单元测试 | 单条 user query 超过 `maxFiles` | 只解析并注入该 query 内前 `maxFiles` 个文件 | +| 单元测试 | Tool node 无 `readFiles` tool | 并行执行逐条 user query 重写 | +| 单元测试 | Tool node 有 `readFiles` tool | 跳过预解析注入 | +| 回归测试 | system prompt 检查 | system message 不包含 `` | +| 回归测试 | 保存层检查 | 保存参数仍为原始 `userQuestion` | + +### 8.3 场景覆盖核对 + +| 场景 | 是否覆盖 | 对应用例/describe | +|---|---|---| +| 基础场景 | 是 | 当前轮 file-only、file+text | +| 历史场景 | 是 | 多条历史 human file 逐条注入 | +| 边界值 | 是 | 单条 query `maxFiles`、重复 URL 分别读取、空解析 | +| Tool 场景 | 是 | 有/无 `readFiles` tool | +| 安全边界 | 是 | 不打印正文、不改 API/DB schema | +| 异常场景 | 是 | 文件解析失败时不污染原 userContent | + +### 8.4 执行命令 + +```shell +pnpm -s vitest run test/cases/service/core/ai/llm/utils.test.ts +``` + +实现新增运行时注入测试后,同步补充对应 test file 命令。 + +### 8.5 手工验证(可选) + +| 场景 | 操作步骤 | 预期结果 | +|---|---|---| +| 正常流程 | 首轮上传文件并提问,次轮继续文本提问 | 次轮 LLM 请求中首轮 user message 仍带文件正文 | +| 多历史文件 | 连续多轮分别上传文件,再继续追问 | 每条历史 user message 各自带对应文件正文 | +| 调试流程 | 使用 chatTest 发送 file-only | 保存层不改原始输入,LLM 请求 user message 不为 `null` | + +## 9. 质量自检清单 + +- [ ] 保存链路三入口(v1/v2/chatTest)均回退为原始 `userQuestion` +- [ ] 未改动 API/DB schema +- [ ] 未引入 `file_url` 直传模型逻辑 +- [ ] 文件内容不进入 system prompt +- [ ] 历史 human file 逐条注入到所属 user message +- [ ] 当前轮 user file 同样注入到当前轮 user message +- [ ] `maxFiles` 作为单条 user query 的解析上限 +- [ ] Tool node 有 `readFiles` tool 时跳过预解析 +- [ ] 测试覆盖当前轮、历史轮、Tool、`maxFiles`、保存层不污染 +- [ ] 文档更新提醒已填写 + +## 10. 发布与回滚 + +### 10.1 发布步骤 + +1. 完成 T1-T4 代码实现与测试。 +2. 执行自动化测试并记录结果。 +3. 合并发布。 + +### 10.2 回滚触发条件 + +- LLM 请求构造异常。 +- 历史文件解析导致明显性能问题。 +- Tool node 文件读取行为与 `readFiles` tool 冲突。 + +### 10.3 回滚步骤 + +1. 回退运行时逐条文件注入 helper 与调用点。 +2. 保持保存层原始 `userQuestion` 逻辑不变。 +3. 重新验证 file_url 过滤回归测试。 + +## 11. AI 实施提示(给执行模型) + +- 先做保存层回退,再做运行时注入,不要反过来糊一锅。 +- 注入对象是 LLM messages 副本,禁止 mutate `userQuestion`、`histories` 原对象。 +- 历史文件内容必须回填到所属 user message,不能统一拼到最后一条。 +- system prompt 禁止出现 ``。 +- 不扩展到历史回填、协议调整或前端 UI。 diff --git a/.codex/design/bug/chat-file-remap-需求设计文档.md b/.codex/design/bug/chat-file-remap-需求设计文档.md new file mode 100644 index 0000000000..f0b4bf711b --- /dev/null +++ b/.codex/design/bug/chat-file-remap-需求设计文档.md @@ -0,0 +1,229 @@ +# 需求设计文档 + +## 0. 文档标识 + +- 任务前缀:`chat-file-remap` +- 文档文件名:`chat-file-remap-需求设计文档.md` +- 更新时间:2026-04-24 +- 文档定位:对齐 PR review 后的最终口径(运行时注入,不污染保存层) + +## 1. 需求背景与目标 + +### 1.1 背景 + +当前问题来自消息整理链路的既有行为: + +1. 用户文件会在适配阶段转成 `file_url`(`packages/global/core/chat/adapt.ts` 的 `chats2GPTMessages`)。 +2. `packages/service/core/ai/llm/utils.ts` 的 `loadRequestMessages` 会过滤 `file_url`:`if (item.type === 'file_url') return;`。 +3. 当某条 user message 只有文件、没有文本时,过滤后模型侧内容可能退化为 `content: 'null'`。 +4. PR review 明确指出:不能在 API 保存层直接改写输入问题,只能在真正使用该输入的 node 内做运行时处理。 + +用户确认的最终目标边界: + +1. 文件解析内容必须出现在发给模型的 user message 中,而不是 system prompt 中。 +2. 历史记录中每一条带 file URL 的 human message,都要在运行时把自己的文件内容注入回自己的 user message。 +3. 保存层保持原始 `userQuestion`,不把 `` 固化入库。 +4. 不做历史数据回填,不走 `file_url` 直传模型方案。 +5. 每条 user query 的文件解析数量沿用 `chatConfig.fileSelectConfig.maxFiles`,不做跨 message URL 去重或共享缓存。 + +### 1.2 目标 + +- 业务目标:模型请求中,每条包含文件的 user message 都能携带对应文件正文;前面轮次的 user file 在后续聊天中继续可用。 +- 技术目标:在 AI Chat node / Tool node 构造 LLM messages 前,对运行时消息副本进行逐条 user 文件内容注入,不修改 API 请求体和 MongoDB 保存内容。 +- 成功指标: + - file-only 的当前轮 user message 发给模型前包含 ``,不再退化为 `null`。 + - 历史中每条带 file 的 human message,在后续请求里各自注入对应 ``,不集中塞到最后一条 user message。 + - system prompt 不包含文件解析内容。 + - MongoDB 中 human 原始消息不新增 ``。 + +## 2. 当前项目事实基线(基于代码) + +| 能力项 | 现有实现位置(文件路径) | 现状说明 | 结论(复用/修改/新增) | +|---|---|---|---| +| 用户消息整理 | `packages/service/core/ai/llm/utils.ts` (`loadRequestMessages`) | 过滤 `file_url`,但保留同条消息中的 text | 复用,补回归测试 | +| v1 保存链路 | `projects/app/src/pages/api/v1/chat/completions.ts` | 当前 PR 中存在保存前增强 `enrichedUserQuestion` 的倾向 | 需回退,保存原始 `userQuestion` | +| v2 保存链路 | `projects/app/src/pages/api/v2/chat/completions.ts` | 当前 PR 中存在保存前增强 `enrichedUserQuestion` 的倾向 | 需回退,保存原始 `userQuestion` | +| chatTest 保存链路 | `projects/app/src/pages/api/core/chat/chatTest.ts` | review 评论点:不应保存增强后的输入 | 需回退,保存原始 `userQuestion` | +| 保存实现 | `packages/service/core/chat/saveChat.ts` | 每轮只持久化当前轮 Human/AI;并会清理 file.url | 复用,不改 schema | +| 历史文件收集 | `packages/service/core/workflow/dispatch/tools/readFiles.ts` (`getHistoryFileLinks`) | 已能从历史 human message 中提取 file URL | 复用,但需要支持逐条消息归属 | +| Chat 运行时拼接 | `packages/service/core/workflow/dispatch/ai/chat.ts` | 当前 PR 已把当前轮文件内容改到 user,但历史文件逐条注入不足 | 修改为逐条运行时注入 | +| Tool 运行时拼接 | `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 与 Chat 类似;有 `readFiles` tool 时应跳过预解析 | 修改为逐条运行时注入,保留 skip 分支 | + +## 3. 需求澄清记录 + +| 维度 | 已确认内容 | 待确认内容 | 备注 | +|---|---|---|---| +| 业务目标 | 文件内容进入 LLM user message,不进 system prompt | 无 | 已确认 | +| 历史行为 | 历史记录里每条 file URL 都需要注入回对应 user message | 无 | 已确认 | +| 文件上限 | 每条 user query 文件解析数量沿用 `maxFiles` | 无 | 已确认 | +| 保存层 | 不改写 `userQuestion`,不把 `` 入库 | 无 | 对齐 PR review | +| 数据模型 | 不改 DB schema,不新增字段 | 无 | 已确认 | +| API 行为 | 对外请求/响应协议不变 | 无 | 已确认 | +| 前端交互 | 无页面改动要求 | 无 | 已确认 | +| 文档更新 | 更新本任务两份研发文档 | 无 | 已确认 | +| 文档 i18n | 不命中 `document/content/docs` | 无 | 本文档更新不涉及 docs 站点 | + +## 3.1 影响域判定 + +| 维度 | 是否命中 | 证据(需求/代码锚点) | 结论 | +|---|---|---|---| +| API | No | 不新增/修改对外路由协议;仅回退保存层增强接入 | 协议不变 | +| DB | No | 不改 `MongoChatItem` schema 与索引 | 无结构改动 | +| Front | No | 未涉及前端组件与页面行为改造 | N/A | +| Logger | No | 不新增观测方案 | N/A | +| Package | Yes | 涉及 `packages/service` 与 `projects/app` 既有调用链对齐 | 最小改动 | +| BugFix | Yes | `file_url` 过滤导致 file-only 退化 `null` | 命中 | +| DocUpdate | Yes | 用户明确要求更新设计/开发文档 | 命中 | +| DocI18n | No | 本文档不改 docs 站点目录 | N/A | + +## 4. 范围定义 + +### 4.1 In Scope(本期必须) + +1. 回退 `v1/v2/chatTest` 保存前增强:保存链路统一使用原始 `userQuestion`。 +2. Chat node 构造 LLM messages 前,对历史 human messages 与当前轮 user message 做运行时文件内容注入。 +3. Tool node 在无 `readFiles` tool 时执行同样的逐条 user message 注入;有 `readFiles` tool 时跳过预解析。 +4. 文件内容只注入 user message,不进入 system prompt。 +5. 按 message 并行重写 user query,单条 user query 内文件解析数量受 `maxFiles` 控制。 +6. 补齐对应回归测试与文档说明。 + +### 4.2 Out of Scope(本期不做) + +1. 历史数据回填(批处理/迁移脚本)。 +2. `file_url` 透传到模型。 +3. API/DB schema 变更。 +4. 不为超过 `maxFiles` 的文件新增特殊提示或额外 UI。 +5. 不重构完整 chat message adapter。 + +## 5. 方案对比 + +| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 | +|---|---|---|---|---|---| +| 方案A:保存前固化 | 在保存前把 file 解析文本拼到 `userContent` 并入库 | 后续回放天然复用 | 污染原始输入,已被 review 指出不合适 | 中 | 放弃 | +| 方案B:运行时逐条 user 注入(推荐) | 发给模型前增强 messages 副本,每条 human file 回填到自己的 user message,messages 并行处理 | 不污染保存层,满足 user 而非 system,历史文件后续可用 | 每次请求可能重新解析,受单条 query `maxFiles` 限制 | 中 | 推荐 | +| 方案C:直传 `file_url` 给模型 | 去掉过滤,依赖模型直接处理文件链接 | 表面改动少 | 多模型/OpenAI 兼容实现不稳定,容易报参错 | 中 | 放弃 | +| 方案D:历史回填 + 新流量修复 | 批量补齐旧库,再修新流量 | 历史一致性最好 | 工程面大、风险高、超出本期目标 | 高 | 本期不做 | + +推荐方案:方案B(运行时逐条 user 注入)。 + +## 6. 推荐方案详细设计 + +### 6.1 API 设计 + +- 对外 API:无变化。 +- 内部链路调整:`v1/v2/chatTest` 的保存入参保持原始 `userQuestion`,不再使用 `enrichedUserQuestion`。 + +### 6.2 数据设计 + +- DB 字段:无新增。 +- DB 索引:无变化。 +- 兼容策略:历史旧数据不迁移;运行时只要历史 human message 仍能提供 file key/url,就按当前策略解析注入。 + +### 6.3 核心代码设计 + +| 模块 | 关键函数/类型 | 变更说明 | 上下游影响 | +|---|---|---|---| +| `projects/app/src/pages/api/v1/chat/completions.ts` | `handler` | 移除保存前 `enrichUserContentWithParsedFiles` 使用,保存原始 `userQuestion` | 对外响应不变,避免污染历史 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | `handler` | 同 v1,`prepare/finalize/updateInteractive` 使用原始 `userQuestion` | 对齐 review | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | `handler` | 同 v1/v2,调试链路也不保存增强内容 | 修复 review 评论点 | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getMultiInput/getChatMessages` | 构造 LLM messages 前增强运行时副本:历史和当前轮每条 user message 注入自己的文件内容;文件内容不进 system | Chat node 满足历史逐条注入 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | `getMultiInput/dispatchRunTools` | 无 `readFiles` tool 时同 Chat;有 `readFiles` tool 时跳过预解析 | 避免与 readFiles tool 职责冲突 | +| `packages/service/core/workflow/utils/context.ts` | `rewriteUserQueryWithFileContent` | 承载单条 user query 的文件内容重写逻辑,外层并行处理 history/current messages | 不污染 readFiles tool 职责 | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | `normalizeReadableFileUrl` / `getFileContentFromLinks` | `getFileContentFromLinks` 统一负责 URL 标准化、过滤、文件读取与解析;`normalizeReadableFileUrl` 仅作为底层清洗工具 | 不改对外 API | +| `packages/service/core/ai/llm/utils.ts` | `loadRequestMessages` | 保持 `file_url` 过滤逻辑;确保同条消息 text 不被过滤 | 回归保障 | + +### 6.4 运行时注入规则 + +1. 使用消息副本,不修改 `histories`、`query`、`userQuestion` 原对象。 +2. Chat/Tool 外层用 `Promise.all` 并行处理运行时 messages。 +3. 单条 user query 只收集本条 `file.url`;不做跨 message URL 去重,不共享解析缓存。 +4. `getFileContentFromLinks` 负责 URL 标准化、过滤、`maxFiles` 截断和文件解析。 +5. 文件解析结果回填到原本所属的 user message: + - 原 message 已有 text:追加 `\n\n===---===---===\n\n...`。 + - 原 message 只有 file:新增一个 text part 存放 ``。 +6. 不把历史文件内容集中拼到最后一条 user message。 +7. system prompt 只保留模型默认 system、用户配置 system、dataset system quote。 + +### 6.5 日志与观测设计 + +- 不新增日志点。 +- 不打印用户文件正文或解析结果。 + +### 6.6 文档 i18n 设计 + +N/A(未命中 docs 站点目录)。 + +## 7. Bug 修复分析 + +| 项目 | 内容 | +|---|---| +| Bug 现象 | file-only user message 发给模型时可能退化为 `content: 'null'`,历史 file 在后续聊天中无法稳定保留语义 | +| 复现步骤 | 首轮只传 file -> 后续轮次继续聊天 -> 模型请求中过滤 `file_url` 后缺少文件正文 | +| 期望行为 | 每条带 file 的 user message 在 LLM 请求中都有自己的 `` 文本 | +| 实际行为 | `file_url` 被过滤,文件正文未逐条注入 user message | +| 定位证据 | `loadRequestMessages` 过滤 `file_url`;当前保存前增强方案被 review 指出不应改原始输入 | +| 问题点文件与函数 | `loadRequestMessages`、`v1/v2/chatTest` 保存链路、`chat.ts/tool/index.ts` 运行时消息构造 | +| 根因分析(直接原因) | file URL 不是模型可直接消费的文本,过滤后缺少正文 | +| 根因分析(深层原因) | 保存层与运行时层职责混淆;文件正文应该在消费输入的 node 内注入,而不是改写保存内容 | +| 影响范围 | Chat/Tool node 的 LLM 请求构造、file-only 与历史 file 后续对话 | + +回归验证要点: + +1. file-only 当前轮发给模型不再退化 `null`。 +2. 历史每条带 file 的 user message 各自获得文件正文。 +3. 保存后的 human 原始内容不包含新增 ``。 +4. 无 file 轮次无行为变化。 + +## 8. 风险、迁移与回滚 + +### 8.1 风险清单 + +1. 每次请求可能重新解析历史文件,存在额外耗时;通过单条 user query `maxFiles` 控制风险,并通过 messages 并行处理降低串行等待。 +2. 文件正文进入 user message 后 token 增加,可能触发上下文裁剪;沿用现有 `filterGPTMessageByMaxContext`。 +3. 历史文件若只剩 key 而无可解析 URL,需要实现时确认是否可通过现有 key 生成可读地址。 + +### 8.2 迁移策略 + +- 本期不迁移历史数据。 +- 修复通过运行时消息增强生效,不改历史存量内容。 + +### 8.3 回滚策略 + +1. 回滚运行时逐条注入 helper 与调用点。 +2. 保持保存层原始输入逻辑不变。 +3. `loadRequestMessages` 原过滤逻辑保持不变。 + +## 9. 验收标准 + +| 验收项 | 验收方式 | 通过标准 | +|---|---|---| +| 当前轮 file-only 可用 | 单测/联调 | LLM 请求最后一条 user message 含 ``,不为 `null` | +| 历史逐条注入 | 单测/联调 | 多条历史 human file 分别注入到各自 user message | +| 不污染保存层 | 单测/代码走查 | `prepare/finalize/push/updateInteractive` 保存原始 `userQuestion` | +| system prompt 纯净 | 单测/代码走查 | system message 不包含 `` | +| `maxFiles` 生效 | 单测 | 单条 user query 文件解析数不超过 `maxFiles` | +| Tool readFiles 分支 | 单测/代码走查 | 有 `readFiles` tool 时不提前注入 | +| 普通轮次无回归 | 回归测试 | 无 file 请求与修复前行为一致 | + +## 10. MECE 核查结论 + +### 10.1 相互独立检查结果 + +发现问题:保存前固化与运行时注入职责混淆。 +影响范围:容易污染原始用户输入,并触发 review 反对。 +修订动作:保存层只保存原始输入,运行时只增强 LLM messages 副本。 +修订后结果:职责边界清晰。 + +### 10.2 完全穷尽检查结果 + +发现问题:只处理当前轮文件无法满足“历史记录每条 file URL 都注入回来”。 +影响范围:后续聊天中前面 user file 仍可能丢语义。 +修订动作:历史 human messages 与当前轮 user message 统一按条注入。 +修订后结果:当前轮、历史轮、tool 场景均覆盖。 + +### 10.3 修订动作与最终边界 + +发现问题:历史文件过多时可能带来解析成本和 token 风险。 +影响范围:性能、成本、上下文窗口。 +修订动作:确认采用 `maxFiles` 作为单条 user query 的解析上限,并通过 messages 并行处理降低串行等待。 +修订后结果:需求完整且有明确成本边界。 diff --git a/.codex/design/bug/loop-run-interactive-resume-fix.md b/.codex/design/bug/loop-run-interactive-resume-fix.md new file mode 100644 index 0000000000..0774f54ffa --- /dev/null +++ b/.codex/design/bug/loop-run-interactive-resume-fix.md @@ -0,0 +1,294 @@ +# 循环节点交互恢复修复 + +## 背景 + +循环节点(`loopRun`,条件/数组两种模式)的循环体内若放置交互节点(如 `formInput`、`userSelect`),用户提交交互内容后继续执行时出现: + +1. **表单循环被重置**:用户提交表单后又弹出同一个表单、`指定回复` 从未执行、`循环历史` 永远是 `[]`。(实际上是 workflow 被当成新请求从头跑) +2. **循环变量丢失**(若 resume 真的触发):下游节点引用 `循环开始 > 当前循环次数` / `当前循环值` 解析为 `undefined`。 +3. **响应详情缺失**(若 resume 真的触发):被中断那次迭代的详情树只包含 resume 之后的节点。 + +## 调用链与快照机制 + +**Interactive 冒泡与快照** + +`WorkflowQueue.handleInteractiveResult`(`dispatch/index.ts:1438`)在一次 `runWorkflow` 返回前,会把 `this.data.runtimeNodes` 里每个 node 的 `outputs[i].value` 截图到 `nodeOutputs`,连同 `entryNodeIds / memoryEdges` 包进 `InteractiveBasicType`: + +```ts +this.data.runtimeNodes.forEach((node) => { + node.outputs.forEach((output) => { + if (output.value) nodeOutputs.push({ nodeId, key, value }); + }); +}); +``` + +**Resume 时的 Top-level 还原** + +`projects/app/src/pages/api/v2/chat/completions.ts:258` + +```ts +runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive); +``` + +`rewriteNodeOutputByHistories`(`runtime/utils.ts:546`)只会读 **当前这层 `interactive.nodeOutputs`**,不会递归进 `params.childrenResponse.nodeOutputs`。 + +**`runLoopRun` 的隔离** + +`runLoopRun.ts:86` + +```ts +const isolatedNodes = cloneDeep(runtimeNodes); +``` + +循环体用独立的 `isolatedNodes` 执行,避免污染父层。 + +## 为什么会出问题 + +### 问题 0(阻断性):`isChildInteractive` 白名单漏了 `loopRunInteractive` + +`packages/global/core/workflow/template/system/interactive/constants.ts` + +```ts +export const isChildInteractive = (type) => { + if ( + type === 'childrenInteractive' || + type === 'toolChildrenInteractive' || + type === 'loopInteractive' // ← 只有旧 loop,没有 loopRun + ) return true; + return false; +}; +``` + +`getLastInteractiveValue`(`runtime/utils.ts:163`)读取最后一条 AI 消息的 `interactive`,先判断 `isChildInteractive(type)` 做"直接返回",否则挨个匹配 `userSelect / userInput / paymentPause / agentPlanCheck / agentPlanAskQuery`。`loopRunInteractive` 既不在白名单,也不匹配任何具体 type,**结果返回 `undefined`**。 + +连锁反应: + +1. `chat/completions.ts` 拿到 `interactive === undefined`。 +2. `getWorkflowEntryNodeIds(nodes, undefined)` 退化成取 `workflowStart / systemConfig` 等默认入口。 +3. `rewriteNodeOutputByHistories(runtimeNodes, undefined)` 直接返回 runtimeNodes(无还原)。 +4. `runWorkflow({ lastInteractive: undefined })` → 从 `workflowStart` 重新跑一轮。 +5. 用户提交的表单 JSON 被当成新 query 的 message text,workflow 从头跑到 iter 1 表单再次中断。 + +所以用户看到的"提交表单 → 又弹同一个表单 → `指定回复` 从未执行 → 循环历史为 []",**全是因为 resume 根本没触发**,跟后面 A/B 两个问题无关。A/B 是在 resume 真的触发之后才会暴露的问题。 + +### 问题 A:变量丢失 + +1. 内层 `runWorkflow` 命中交互时,`handleInteractiveResult` 截图的是 `isolatedNodes` 的 outputs(含 `loopRunStart.currentIteration = 1`),放到内层 `interactive.nodeOutputs`。 +2. `runLoopRun.ts:250-258` 把内层 `interactiveResponse` 原样塞进 `LoopRunInteractive.params.childrenResponse`。 +3. 外层 `handleInteractiveResult` 再截图一次,但截图对象是 **父层的 runtimeNodes**(loopRun 节点自己用的那层),这层没有循环体节点的 outputs。外层 `interactive.nodeOutputs` 里**没有 `loopRunStart.currentIteration`**。 +4. Resume 时 `chat/completions.ts` 只用外层 `interactive.nodeOutputs` 还原 → `loopRunStart.currentIteration` 还是 undefined。 +5. `runLoopRun.ts:127-132` 的 resume 分支只设 `isEntry`,**不调用 `rewriteNodeOutputByHistories(isolatedNodes, interactiveData.childrenResponse)`**,也没调用 `injectLoopRunStart`,于是 `isolatedNodes` 上 `loopRunStart` 的 outputs 全空。 +6. 下游 `指定回复` / `判断器` 通过 `getReferenceVariableValue` 读 `loopRunStart` output → 得到 `undefined`。 + +### 问题 B:响应详情缺失 + +`runLoopRun.ts:173-176` + +```ts +if (response.workflowInteractiveResponse) { + interactiveResponse = response.workflowInteractiveResponse; + break; // ← 直接 break +} +``` + +中断时**跳过 `pushIterationDetail`**,注释声称「the resumed run will record it」——但: + +- `response.flowResponses` 里此时已经包含 **中断前** 跑完的 `loopRunStart / 判断器` 等节点 detail,一并被丢弃。 +- Resume 那一轮的 `response.flowResponses` 只有 **resume 之后** 的节点(`表单输入` 的提交回填 + `指定回复`)。 +- Resume 结束后调用 `pushIterationDetail({})` 组装 wrapper,`childrenResponses` 只剩后半段。 +- `saveChat.mergeChatResponseData` 按 `mergeSignId` 合并的是外层 `loopRun` 节点,`loopRunDetail` 两端是 concat(见 `chat/utils.ts:374-377`)。但**前后两轮对同一 `iteration` 都没有各自的 wrapper 互相合并**(中断那轮压根没 push),所以 iter1 只剩一条"半截 wrapper"。 + +注 1:**上一条已经完成的迭代 wrapper 不会丢**。它们在中断前已经 `pushIterationDetail` 进 `loopResponseDetail`,作为外层 `loopRunDetail` 的一部分写入中断响应;resume 后的新 `loopRunDetail` 经 `mergeChatResponseData` concat 合并回来。 + +注 2:`loopHistory`(customOutputs 等)通过 `LoopRunInteractive.params.loopHistory` 主动透传(`runLoopRun.ts:94-96`),不受此 bug 影响。 + +### 旧版 `runLoop.ts` 的差异(仅说明,不在本次修复范围) + +`runLoop.ts` 不 clone `runtimeNodes`(`runLoop.ts:84` 直接透传),内外层共享同一份节点引用,所以外层截图也能带上循环体 outputs,问题 A 恰好被绕开。问题 B 方面,`runLoop.ts` 不做 per-iteration wrapper,中断前的 `response.flowResponses` 在 `runLoop.ts:98` 已 push 进 `loopResponseDetail`,通过外层合并链保留。不过这是"恰好能用"的脆弱依赖,后续也建议收敛。 + +## 修复方案 + +### 范围 + +`runLoopRun` 流程(用户 bug 命中的是条件循环 ifo 模式)。改动点集中在四处: + +0. `packages/global/core/workflow/template/system/interactive/constants.ts` — 白名单补 `loopRunInteractive`(**阻断性,必改**) +1. `packages/global/core/workflow/template/system/interactive/type.ts` — `LoopRunInteractive` 加 `pendingIterationResponses` +2. `packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts` — 接入 `rewriteNodeOutputByHistories` + pending 机制 + +`runLoop.ts`(旧数组循环)本次不动,保留作为 follow-up。 + +### 改动 0:`isChildInteractive` 白名单补 `loopRunInteractive` + +```ts +// packages/global/core/workflow/template/system/interactive/constants.ts +export const isChildInteractive = (type: InteractiveNodeResponseType['type']) => { + if ( + type === 'childrenInteractive' || + type === 'toolChildrenInteractive' || + type === 'loopInteractive' || + type === 'loopRunInteractive' // 新增 + ) return true; + return false; +}; +``` + +### 改动 1:扩展 `LoopRunInteractive` schema + +新增 `pendingIterationResponses` 字段,用来持久化"当前这次迭代、中断前已经跑过的子节点响应"。 + +```ts +// packages/global/core/workflow/template/system/interactive/type.ts +export const LoopRunInteractiveSchema = z.object({ + type: z.literal('loopRunInteractive'), + params: z.object({ + loopHistory: z.array(z.any()), + childrenResponse: z.any(), + iteration: z.number(), + pendingIterationResponses: z.array(z.any()).optional() // 新增 + }) +}); + +export type LoopRunInteractive = InteractiveNodeType & { + type: 'loopRunInteractive'; + params: { + loopHistory: any[]; + childrenResponse: WorkflowInteractiveResponseType; + iteration: number; + pendingIterationResponses?: ChatHistoryItemResType[]; + }; +}; +``` + +### 改动 2:Resume 前还原循环体 node outputs + +`runLoopRun.ts` 在构造 `isolatedNodes` 之后,如果正在恢复,用 `rewriteNodeOutputByHistories` 把 `interactiveData.childrenResponse.nodeOutputs` 叠加回去。 + +```ts +import { rewriteNodeOutputByHistories } from '@fastgpt/global/core/workflow/runtime/utils'; + +// ... +let isolatedNodes = cloneDeep(runtimeNodes); +const isolatedEdges = cloneDeep(runtimeEdges); + +if (interactiveData?.childrenResponse) { + isolatedNodes = rewriteNodeOutputByHistories( + isolatedNodes, + interactiveData.childrenResponse + ); +} +``` + +**不**在 resume 分支调用 `injectLoopRunStart`,原因:它会把 `loopRunStart.isEntry = true`,导致 loopRunStart 重跑并可能把已恢复 outputs 的链条再走一遍(判断器也会再跑一次,造成 detail 重复)。现在通过 rewriteNodeOutputByHistories 单一来源恢复即可。 + +### 改动 3:中断时保留 in-flight iteration 的子节点响应 + +循环里积累一个局部变量,每次看到 interactive 就把当前 `iterationChildrenResponses` 接到 pending 里;每次成功完成一次迭代就清空。 + +```ts +let pendingIterationResponses: ChatHistoryItemResType[] = + interactiveData?.pendingIterationResponses ?? []; + +while (true) { + // ...(iteration guard) + + const isResumeIteration = !!interactiveData && iteration === resumeIteration; + + // resume 分支只设 isEntry;非 resume 才走 injectLoopRunStart + if (isResumeIteration) { + isolatedNodes.forEach((n) => { + if (interactiveData?.childrenResponse?.entryNodeIds.includes(n.nodeId)) { + n.isEntry = true; + } + }); + } else { + injectLoopRunStart({ /* 原样 */ }); + } + + const response = await runWorkflow({ /* 原样 */ }); + + // 合并 pre-interrupt + 本轮 flowResponses(pending 只在进入同一 iteration 时有效) + const iterationChildrenResponses = [ + ...(isResumeIteration ? pendingIterationResponses : []), + ...response.flowResponses + ]; + + // 运行时间/usage/assistant/feedback 都算本轮新跑的 + const iterationRunningTime = response.flowResponses.reduce( + (acc, r) => acc + (typeof r.runningTime === 'number' ? r.runningTime : 0), + 0 + ); + + // ...(assistantResponses / usagePush / feedback 原样,注意 totalPoints/usage 只算新跑的,避免重复计费) + + if (response.workflowInteractiveResponse) { + interactiveResponse = response.workflowInteractiveResponse; + // 累积,支持多次中断 + pendingIterationResponses = iterationChildrenResponses; + break; + } + + // 迭代完整走完 → 使用合并后的 children 生成 wrapper + // iteration 成功或失败都走 pushIterationDetail({ childrenResponses: iterationChildrenResponses }) + // ... + + // 迭代走完,清空 pending,进下一轮 + pendingIterationResponses = []; + + interactiveData = undefined; // 原逻辑 + iteration++; +} + +// 返回的 interactive payload 带上 pending +return { + // ... + [DispatchNodeResponseKeyEnum.interactive]: interactiveResponse + ? { + type: 'loopRunInteractive', + params: { + loopHistory, + childrenResponse: interactiveResponse, + iteration, + pendingIterationResponses + } + } + : undefined, + // ... +}; +``` + +**把 `pushIterationDetail` 接上 `iterationChildrenResponses`**(原本是直接闭包读外层变量,这里改成参数传入或直接 inline 使用合并结果)。 + +### 改动 4:`iterationRunningTime` 的归集 + +原实现是 `iterationChildrenResponses.reduce(...)`,现在要区分"本轮新跑的耗时"和"累积子响应"。耗时只算本轮(中断前的耗时已经挂在之前那次请求里),避免重复。`totalPoints / assistantResponses / usagePush` 同理只算 `response` 本轮。 + +### 风险点与兼容性 + +1. **多次中断同一迭代**:按方案 pending 在每轮 resume 时被读出 → 本轮再追加 → 再次中断时整包写回 interactive payload。验证:一个 iteration 里先 `formInput` → 再 `userSelect`,两次交互后应该看到完整 children。 +2. **旧 chat 历史无 `pendingIterationResponses` 字段**:`?? []` 兜底,向前兼容。 +3. **外层 `mergeChatResponseData`**:外层 `loopRun` 节点 `mergeSignId` 不变,合并逻辑不受影响;pending 只作用在 wrapper 内部 `childrenResponses`,不冲突。 +4. **测试节点 `rewriteNodeOutputByHistories` 的落点是 clone 后的副本**:不会把循环体 outputs 泄漏给外层后续兄弟节点。 + +### 已知的次要 bug(本次不修) + +- `handleInteractiveResult` 截图 outputs 时 `if (output.value)` 会丢掉 `0 / '' / false` 等合法值(`dispatch/index.ts:1449`)。对数组模式 `currentIndex = 0` 会有影响;条件模式 `iteration >= 1` 不受影响。留作后续专项修复。 +- `runLoop.ts`(旧数组循环)依赖 `runtimeNodes` 共享引用偶然可用,建议后续同步迁移到显式 `rewriteNodeOutputByHistories`。 + +## TODO + +- [x] `packages/global/core/workflow/template/system/interactive/constants.ts`:`isChildInteractive` 白名单补 `'loopRunInteractive'` +- [x] `packages/global/core/workflow/template/system/interactive/type.ts`:给 `LoopRunInteractiveSchema` / `LoopRunInteractive` 加 `pendingIterationResponses?: ChatHistoryItemResType[]` 字段 +- [x] `packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts`: + - [x] 引入 `rewriteNodeOutputByHistories` + - [x] `isolatedNodes = cloneDeep(...)` 后若有 `interactiveData` 就叠加还原循环体 outputs + - [x] 循环内维护 `pendingIterationResponses`;`isResumeIteration` 分支合并 pending + 本轮 flowResponses + - [x] 中断分支:写入 pending;走完一次迭代后清空 + - [x] `pushIterationDetail` 使用合并后的 `iterationChildrenResponses`;`iterationRunningTime` 按合并后统计,`totalPoints / usagePush` 只算本轮 + - [x] return 的 `loopRunInteractive.params` 带 `pendingIterationResponses` +- [ ] 本地手测 1:`条件循环 + formInput`(用户原场景),确认"当前循环次数"引用可读 + 响应详情包含中断前子节点 +- [ ] 本地手测 2:同一迭代内先后两次交互(先 formInput 再 userSelect),确认 pending 累积 +- [ ] 本地手测 3:第 2 次迭代触发交互,恢复后后续迭代继续跑,确认上一条完整迭代 wrapper 不丢 +- [ ] 本地手测 4:数组模式循环 + 交互,确认没有回归 +- [ ] (follow-up,不在本 PR)`runLoop.ts` 同步显式 `rewriteNodeOutputByHistories` +- [ ] (follow-up,不在本 PR)`handleInteractiveResult` 的 `if (output.value)` 改 `!== undefined` diff --git a/.codex/design/bug/md-encoding-repair-功能开发文档.md b/.codex/design/bug/md-encoding-repair-功能开发文档.md new file mode 100644 index 0000000000..1c1a2b0341 --- /dev/null +++ b/.codex/design/bug/md-encoding-repair-功能开发文档.md @@ -0,0 +1,169 @@ +# 功能开发文档 + +## 文档标识 + +- 任务前缀:`md-encoding-repair` +- 文档文件名:`md-encoding-repair-功能开发文档.md` +- 文档状态:已补全(实施完成版) +- 最后更新:2026-04-15 + +## 0. 开发目标与约束 + +- 功能目标:修复“Markdown 文件前部英文导致编码误判为 `ascii`,从而中文乱码”的问题。 +- 核心策略:`UTF-8 严格校验优先(BOM + 字节级合法性校验)`,失败后再进入探测回退。 +- 代码范围: + - `FastGPT/packages/global/common/file/tools.ts` + - `FastGPT/packages/service/worker/readFile/extension/rawText.ts` + - `FastGPT/test/cases/global/common/file/tool.test.ts` + - `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts` +- 非目标(明确不做):API 协议、DB schema、前端交互改造。 +- 必须遵循规范:`/Users/xxyyh/.codex/skills/fastgpt-requirement-design/references/style-standards-entry.md` +- 适用维度(从需求分析继承):API[ ] DB[ ] Front[ ] Logger[ ] Package[x] + +## 1. 实施任务拆解(执行结果) + +| 任务ID | 任务名称 | 责任层 | 执行结果 | 完成定义(DoD) | 状态 | +|---|---|---|---|---|---| +| T1 | 重构编码检测策略 | Service(global utils) | `detectFileEncoding` 改为 BOM + UTF-8 严格校验优先;失败再 fallback 探测 | 不再依赖“仅前 200 字节” | ✅ 已完成 | +| T2 | 增加 `ascii` 误判兜底 | Service(global + worker) | 检测层与解码层都对 `ascii` + 非 ASCII 字节做兜底 | 不再按错误 `ascii` 解码中文 | ✅ 已完成 | +| T3 | 补充/修正单测 | Test | 增加 BOM、长英文前缀+中文正文等用例 | 新场景稳定通过 | ✅ 已完成 | +| T4 | 解码层轻量保护落地 | Service(worker) | `readFileRawText` 增加 `ascii` 误判兜底,并复用全局 `hasNonAsciiByte` | 上游误判时仍输出可读文本 | ✅ 已完成 | +| T5 | 编码回归矩阵补齐 | Test | 新增 `encoding.regression.test.ts` 覆盖 4 类编码回归场景 | 回归矩阵全通过 | ✅ 已完成 | + +## 2. 文件级改动清单 + +| 文件路径 | 改动类型 | 变更摘要 | 关联任务ID | +|---|---|---|---| +| `FastGPT/packages/global/common/file/tools.ts` | 修改 | 新增 `hasUtf8Bom`、`isValidUtf8`、`getDetectSample`、导出 `hasNonAsciiByte`;`detectFileEncoding` 改为验证优先策略 | T1,T2 | +| `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | 修改 | 删除本地重复 `hasNonAsciiByte`,改为复用全局工具;`ascii` + 非 ASCII 字节时改走 UTF-8 解码 | T2,T4 | +| `FastGPT/test/cases/global/common/file/tool.test.ts` | 修改 | 新增 UTF-8 BOM 测试;新增“长英文前缀 + 中文正文”测试;替换旧弱断言用例语义 | T3 | +| `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts` | 新增 | 新增编码回归矩阵(UTF-8 混排、ASCII、ascii 误传、非法 UTF-8) | T5 | + +## 3. 后端实施说明 + +### 3.1 API 改动 + +| 路由 | 方法 | 请求参数 | 响应结构 | 鉴权 | 错误处理 | +|---|---|---|---|---|---| +| N/A | N/A | N/A | N/A | N/A | N/A | + +说明:本需求仅涉及文件编码判定与解码策略,不改 API 合约。 + +### 3.2 Service/Core 改动 + +| 模块 | 函数/类型 | 具体改动 | 依赖关系 | +|---|---|---|---| +| `packages/global/common/file/tools.ts` | `detectFileEncoding` | 判定顺序改为:`BOM -> strict UTF-8 validate -> jschardet fallback`;对 `ascii` + 非 ASCII 字节做兜底 | 仅使用现有 `jschardet`,未新增依赖 | +| `packages/global/common/file/tools.ts` | `hasNonAsciiByte` | 由私有函数改为导出,供多处复用 | 复用方为 service worker 解码逻辑 | +| `packages/service/worker/readFile/extension/rawText.ts` | `readFileRawText` | 新增 `normalizedEncoding`,在 `encoding='ascii'` 且检测到非 ASCII 字节时强制按 UTF-8 解码 | 复用 `@fastgpt/global/common/file/tools` | + +### 3.3 数据层改动 + +| 集合/表 | 字段 | 类型 | 必填 | 默认值 | 索引 | 迁移策略 | +|---|---|---|---|---|---|---| +| N/A | N/A | N/A | N/A | N/A | N/A | 无需迁移 | + +## 4. 前端实施说明 + +| 页面/组件 | 文件路径 | 交互变化 | i18n 改动 | 状态覆盖 | +|---|---|---|---|---| +| N/A | N/A | 无 | 无 | N/A | + +## 5. 日志与可观测性 + +| 触发点 | 日志级别 | category | 字段 | 备注 | +|---|---|---|---|---| +| 本期无新增日志点 | N/A | N/A | N/A | 为最小改动未加新日志,后续可按需要加 debug 观测 | + +## 6. 测试与验证 + +### 6.1 自动化测试清单(已执行) + +| 测试文件 | 覆盖重点 | 结果 | +|---|---|---| +| `test/cases/global/common/file/tool.test.ts` | 编码检测基础能力(UTF-8、ASCII、BOM、混排场景) | ✅ 通过 | +| `test/cases/service/common/file/read/utils.test.ts` | 文件读取主链路(readFileContentByBuffer) | ✅ 通过 | +| `test/cases/service/common/file/gridfs/utils.test.ts` | 预览流编码链路(stream2Encoding) | ✅ 通过 | +| `test/cases/service/common/file/read/encoding.regression.test.ts` | 编码回归矩阵(4 场景) | ✅ 通过 | + +### 6.2 回归矩阵(新增) + +| 场景 | 输入 | 预期 | 结果 | +|---|---|---|---| +| UTF-8 混排正文 | 长英文前缀 + 中文正文(UTF-8) | 检测 `utf-8`,中文可读 | ✅ | +| 纯 ASCII 文本 | `Hello ASCII 123` | 行为不变 | ✅ | +| `ascii` 误传 | UTF-8 中文 buffer + `encoding='ascii'` | 触发兜底,中文可读 | ✅ | +| 非法 UTF-8 序列 | 非 UTF-8 合法字节序列 | 不应判为 `utf-8` | ✅ | + +### 6.3 执行命令与结果 + +执行命令: + +```bash +pnpm -C FastGPT exec vitest run \ + test/cases/global/common/file/tool.test.ts \ + test/cases/service/common/file/read/utils.test.ts \ + test/cases/service/common/file/gridfs/utils.test.ts \ + test/cases/service/common/file/read/encoding.regression.test.ts +``` + +结果摘要: +- Test Files: `4 passed (4)` +- Tests: `45 passed (45)` + +### 6.4 UTF-8 严格校验性能检测报告(新增) + +#### 6.4.1 检测背景 +- 目标:评估 `isValidUtf8(buffer)` 全量线性扫描在大文件场景下的 CPU 开销,确认是否需要阈值门控。 +- 方法:在本地通过 Node.js 基准脚本,复用当前实现逻辑,分别对 ASCII 缓冲区与中英混排 UTF-8 缓冲区做多轮扫描取平均值。 + +#### 6.4.2 检测结果(平均单次扫描耗时) + +| 文件大小 | ASCII only | UTF-8 中英混排 | +|---|---:|---:| +| 10MB | 16.20ms | 32.21ms | +| 50MB | 84.04ms | 87.52ms | +| 100MB | 160.06ms | 169.08ms | +| 200MB | 327.01ms | 345.51ms | +| 500MB | 894.27ms | 880.22ms | + +补充:实测吞吐约 `560~620 MB/s`,整体符合线性增长特征(O(n))。 + +#### 6.4.3 结论与策略 +- 20MB 以下:开销较小,通常无体感影响。 +- 50~100MB:开始出现可感知延迟。 +- 200MB 以上:单次扫描约 300ms+,并发场景下会放大 CPU 压力。 +- 500MB:接近 1 秒/次,不建议默认全量严格校验。 + +建议落地策略: +1. 交互链路默认仅对 `<=32MB`(或 `<=64MB`)执行全量 UTF-8 严格校验。 +2. 超过阈值时跳过全量校验,改走采样探测 fallback。 +3. 后续可按线上机器规格和峰值并发再微调阈值。 + +## 7. 质量自检清单 + +- [x] 输入与权限流程未被破坏(未改 API/权限逻辑) +- [x] 无新增 `any` 滥用、无未处理 Promise +- [x] 包依赖方向符合 monorepo 约束(`service` 复用 `global`) +- [x] 覆盖关键回归场景(UTF-8 混排、ascii 误判兜底) +- [x] 无新增敏感日志输出 + +## 8. 发布与回滚 + +### 8.1 发布步骤 +1. 合并代码后在测试环境执行上述 4 组回归测试。 +2. 手工上传 UTF-8 中英混排 Markdown,确认预览与入库内容正常。 +3. 观察线上相关解析失败反馈。 + +### 8.2 回滚触发条件 +- 发布后出现明显新增的非 UTF-8 文本解析失败反馈。 + +### 8.3 回滚步骤 +1. 回滚 `FastGPT/packages/global/common/file/tools.ts` 与 `FastGPT/packages/service/worker/readFile/extension/rawText.ts`。 +2. 重新发布并复测上传链路。 + +## 9. AI 实施提示(给执行模型) + +- 编码检测必须坚持“验证优先”,禁止回退到“仅前缀猜测优先”。 +- 若后续扩展多候选编码评分,单独开需求,不在本任务内扩大范围。 +- 每次改动编码策略后,必须至少跑本文件第 6.3 节的回归命令。 diff --git a/.codex/design/bug/md-encoding-repair-需求设计文档.md b/.codex/design/bug/md-encoding-repair-需求设计文档.md new file mode 100644 index 0000000000..cd6697d68b --- /dev/null +++ b/.codex/design/bug/md-encoding-repair-需求设计文档.md @@ -0,0 +1,173 @@ +# 需求设计文档 + +## 0. 文档标识 + +- 任务前缀:`md-encoding-repair` +- 文档文件名:`md-encoding-repair-需求设计文档.md` +- 文档状态:已补全(实施回填版) +- 最后更新:2026-04-15 + +## 1. 需求背景与目标 + +### 1.1 背景 +- 问题现状:知识库上传 `.md` 文件时,若文件开头英文占比高,系统可能将编码误判为 `ascii`,随后按 `ascii` 解码整篇文本,导致中文出现乱码。 +- 触发场景:`md/txt/csv/html` 等文本文件,文件前部主要为英文,中文内容出现在较后位置。 + +### 1.2 目标 +- 业务目标:保证知识库文档上传后中文内容可被正确解析并进入分段/训练链路。 +- 技术目标:避免“前部英文导致整篇 `ascii` 误判”的编码检测缺陷。 +- 成功指标(可量化): + - 构造“前 500+ 字节英文、后续中文”的 UTF-8 Markdown 文件,解析结果中文不乱码。 + - 纯英文 ASCII 文件解析结果保持不变。 + - 编码相关回归测试全量通过。 + +## 2. 当前项目事实基线(基于代码) + +| 能力项 | 现有实现位置(文件路径) | 现状说明 | 结论(复用/修改/新增) | +|---|---|---|---| +| API | `FastGPT/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts` | 上传后创建文件型 collection 的入口;本身不处理编码,只触发后续解析流程 | 复用 | +| Core Service | `FastGPT/packages/service/core/dataset/read.ts` -> `FastGPT/packages/service/common/s3/sources/dataset/index.ts` | 读取 S3 文件后调用 `detectFileEncoding(buffer)`,再把 encoding 传给解析器 | 复用调用链,修改编码策略实现 | +| Worker Decode | `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | 根据上游 encoding 对文本 buffer 解码 | 新增解码兜底保护 | +| DB Schema | `FastGPT/packages/service/core/dataset/collection/schema.ts` | 当前问题与 DB 结构无关 | 不改 | +| Frontend | `FastGPT/projects/app/src/pageComponents/dataset/detail/Import/diffSource/FileLocal.tsx` | 前端只负责上传到 S3,不决定后端解析编码 | 不改 | +| Logger | `FastGPT/packages/service/common/s3/sources/dataset/index.ts` | 当前已有下载日志;编码误判场景无专门日志 | 本期不改 | + +关键代码锚点(实施后): +- 编码判定入口:`FastGPT/packages/global/common/file/tools.ts` 中 `detectFileEncoding`(已改为验证优先) +- 解码兜底:`FastGPT/packages/service/worker/readFile/extension/rawText.ts` 中 `readFileRawText` + +## 3. 需求澄清记录 + +| 维度 | 已确认内容 | 待确认内容 | 备注 | +|---|---|---|---| +| 业务目标 | 修复 Markdown 上传中文乱码 | 是否覆盖更多小众本地编码(如 Shift_JIS) | 后续二期可评估 | +| 范围边界 | 聚焦知识库/读文件链路的编码判定与解码保护 | 是否新增线上编码判定日志点 | 本期不做 | +| 权限模型 | 无权限模型变化 | 无 | N/A | +| 数据模型 | 不新增字段 | 无 | N/A | +| API 行为 | 不改 API 入参/出参 | 无 | N/A | +| 前端交互 | 不改页面交互 | 无 | N/A | + +## 3.1 影响域判定(先判定,再核对规范) + +| 维度 | 是否命中 | 证据(需求/代码锚点) | 核对规范 | 结论 | +|---|---|---|---|---| +| API | No | 编码问题发生在服务端文件解析,现有路由仅透传 fileId | `style/api.md` | Not Applicable(不改接口) | +| DB | No | 不涉及 schema/索引/数据迁移 | `style/db.md` | Not Applicable | +| Front | No | 上传流程不参与后端解码决策 | `style/front.md` | Not Applicable | +| Logger | No(本期) | 本期目标为最小可控修复,未新增观测点 | `style/logger.md` | Not Applicable | +| Package | Yes | 修改位置在 `packages/global` 与 `packages/service`,涉及跨包复用 | `style/package.md` | 已遵守依赖方向(service 依赖 global) | + +## 4. 范围定义 + +### 4.1 In Scope(本期必须) +- 将 `detectFileEncoding` 从“猜测优先”调整为“验证优先”。 +- 增加 `ascii` 误判保护(检测层+解码层双保险)。 +- 补充编码回归测试矩阵,覆盖核心场景。 + +### 4.2 Out of Scope(本期不做) +- 不改知识库 API 协议。 +- 不改数据库结构。 +- 不做前端上传流程改造。 +- 不引入多候选编码评分引擎(后续可独立需求)。 + +## 5. 方案对比 + +| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 | +|---|---|---|---|---|---| +| 方案A(最小改动) | `UTF-8 验证优先(BOM + 严格字节校验)` + fallback 探测 + `ascii` 兜底 | 改动集中、确定性高、能直接修复现网主问题 | 对极端小众编码仍依赖 fallback | 低 | 推荐并已落地 | +| 方案B(可扩展) | 多候选编码试解 + 文本质量打分 | 覆盖更多边缘编码场景 | 复杂度与误判调参成本高 | 中-高 | 本期不选 | + +推荐方案:方案A(已实施)。 + +## 6. 推荐方案详细设计(实施回填) + +### 6.1 API 设计 + +| 路由 | 方法 | 鉴权 | 请求 | 响应 | 错误分支 | 相关文件 | +|---|---|---|---|---|---|---| +| N/A | N/A | N/A | N/A | N/A | N/A | 不涉及 | + +### 6.2 数据设计 + +| 实体/集合 | 字段 | 类型 | 必填 | 默认值 | 索引/约束 | 兼容策略 | +|---|---|---|---|---|---|---| +| N/A | N/A | N/A | N/A | N/A | N/A | N/A | + +### 6.3 核心代码设计 + +| 模块 | 关键函数/类型 | 实际变更 | 上下游影响 | +|---|---|---|---| +| `FastGPT/packages/global/common/file/tools.ts` | `detectFileEncoding(buffer)` | 判定顺序调整为:`hasUtf8Bom -> isValidUtf8 -> detect(getDetectSample)`;并增加 `ascii + 非 ASCII 字节` 兜底 | 所有调用方收益(知识库、工作流读文件、预览编码头) | +| `FastGPT/packages/global/common/file/tools.ts` | `hasNonAsciiByte(buffer)` | 从私有函数提升为导出工具函数,供多处复用 | 减少重复实现,维护单一事实源 | +| `FastGPT/packages/service/worker/readFile/extension/rawText.ts` | `readFileRawText` | 新增 `encoding='ascii'` 且存在非 ASCII 字节时强制 UTF-8 解码保护 | 防止上游误判导致中文乱码 | +| `FastGPT/test/cases/global/common/file/tool.test.ts` | `detectFileEncoding` tests | 新增 BOM 场景、长英文前缀+中文正文场景 | 防回归 | +| `FastGPT/test/cases/service/common/file/read/encoding.regression.test.ts` | 编码回归矩阵 | 新增 4 场景覆盖:UTF-8 混排、ASCII、ascii 误传、非法 UTF-8 | 回归覆盖补齐 | + +### 6.4 前端设计 + +| 页面/组件 | 入口文件 | 交互状态(加载/空/错/成功) | i18n key | 变更说明 | +|---|---|---|---|---| +| N/A | N/A | N/A | N/A | 本期不涉及 | + +### 6.5 日志与观测设计 + +| 场景 | 日志级别 | category | 结构化字段 | 脱敏策略 | +|---|---|---|---|---| +| 本期无新增日志 | N/A | N/A | N/A | N/A | + +## 7. 风险、迁移与回滚 + +### 7.1 风险清单 +- 风险1:严格 UTF-8 校验为 O(n) 线性扫描,超大文本文件会增加少量 CPU。 +- 风险2:小众非 UTF-8 编码仍可能依赖 fallback 的稳定性。 + +### 7.2 迁移策略 +- 无 DB 迁移。 +- 通过新增回归矩阵 + 现有测试集进行功能验证。 + +### 7.3 回滚策略 +- 回滚目标文件: + - `FastGPT/packages/global/common/file/tools.ts` + - `FastGPT/packages/service/worker/readFile/extension/rawText.ts` +- 回滚触发条件:发布后出现显著新增的“非 UTF-8 文本解析异常”反馈。 + +## 8. 验收标准(执行结果) + +| 验收项 | 验收方式 | 通过标准 | 结果 | +|---|---|---|---| +| UTF-8 混合文档不乱码 | 自动化测试 + 手测路径定义 | 中文片段解析正常 | ✅ 通过 | +| 纯 ASCII 文档兼容 | 自动化测试 | 行为不回归 | ✅ 通过 | +| `ascii` 误传防护 | 自动化测试 | 解码结果中文可读 | ✅ 通过 | +| 回归安全 | 编码相关测试集合 | 全部通过 | ✅ 45/45 | + +已执行测试命令: + +```bash +pnpm -C FastGPT exec vitest run \ + test/cases/global/common/file/tool.test.ts \ + test/cases/service/common/file/read/utils.test.ts \ + test/cases/service/common/file/gridfs/utils.test.ts \ + test/cases/service/common/file/read/encoding.regression.test.ts +``` + +测试结果摘要: +- Test Files: `4 passed (4)` +- Tests: `45 passed (45)` + +## 9. MECE 核查结论(实施后) + +### 9.1 相互独立检查结果 +- 发现问题:编码检测与解码保护存在重复判断风险。 +- 影响范围:后续维护可能出现策略漂移。 +- 修订动作:统一由 `tools.ts` 提供通用工具(`hasNonAsciiByte`),`rawText.ts` 仅做末端防护。 +- 修订后结果:职责清晰,复用一致。 + +### 9.2 完全穷尽检查结果 +- 发现问题:仅修检测层不足以防止历史调用链误传 `ascii`。 +- 影响范围:部分链路仍可能乱码。 +- 修订动作:增加解码层二次兜底 + 回归矩阵覆盖误传场景。 +- 修订后结果:正常/异常链路覆盖完整。 + +### 9.3 修订动作与最终边界 +- 本期聚焦编码检测与解码兜底,不扩展 API/DB/前端。 +- 后续若要支持更广泛本地编码智能识别,建议单开“多候选评分”二期需求。 diff --git a/.codex/design/common/logger/index.md b/.codex/design/common/logger/index.md new file mode 100644 index 0000000000..5dba6bbe3b --- /dev/null +++ b/.codex/design/common/logger/index.md @@ -0,0 +1,136 @@ +# FastGPT Logger 使用规范 + +> 基于当前项目实现(`packages/service/common/logger`)整理的统一日志规范与使用指引。 + +## 1. 统一入口 + +**后端统一使用** `@fastgpt/service/common/logger`,不要直接用 `console.*`。 + +```ts +import { configureLogger, getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +await configureLogger(); +const logger = getLogger(LogCategories.MODULE.DATASET.QUEUES); +logger.info('Vector queue task started', { teamId, datasetId, queueSize }); +``` + +**注意**: +- `configureLogger()` 仅需调用一次,通常在服务启动/入口处初始化。 +- `getLogger()` 不传 category 时默认 `['system']`,但建议显式传 `LogCategories`。 + +## 2. 分类(Category)规范 + +**必须使用项目内置的 `LogCategories`**,不要自定义字符串数组。 + +类别选择建议: +- `LogCategories.SYSTEM`:系统级初始化、全局状态。 +- `LogCategories.INFRA.*`:数据库、缓存、对象存储、队列等基础设施。 +- `LogCategories.HTTP.*`:HTTP 请求、响应、错误。 +- `LogCategories.MODULE.*`:业务模块(参考 `pages/api` 路径,省略 `core/support` 前缀)。 +- `LogCategories.EVENT.*`:事件/埋点类日志。 +- `LogCategories.ERROR`:跨模块的错误汇总日志。 + +当现有类别不足时: +- 在 `packages/service/common/logger/categories.ts` 中补充。 +- 保持层级语义清晰,避免过深或过宽。 + +## 3. 日志等级使用建议 + +- `trace`:极高频、细粒度流程追踪(默认仅开发环境开启)。 +- `debug`:调试信息、队列长度、循环状态、重试过程。 +- `info`:关键流程节点、成功状态、启动与完成。 +- `warn`:可恢复异常、可忽略的异常条件。 +- `error`:失败、异常退出、需要定位的问题。 +- `fatal`:不可恢复错误,通常伴随进程退出。 + +## 4. 结构化日志规范 + +**日志由「稳定消息 + 结构化字段」组成**,避免在消息里拼大段 JSON。 + +推荐写法: +```ts +logger.info('Schedule trigger scan completed', { dueCount, durationMs }); +``` + +不推荐: +```ts +logger.info(`Scan completed: ${JSON.stringify({ dueCount, durationMs })}`); +``` + +**自动补齐规则**: +- 当调用 `logger.info('msg', { ... })` 时,会自动将消息格式化为 `msg: {*}`。 +- 如果不希望追加 `{*}`,可添加 `verbose: false`: +```ts +logger.info('Request received', { verbose: false, requestId, method, url }); +``` + +## 5. 请求链路与上下文 + +服务端推荐通过 `withContext` 注入 `requestId` 等上下文: +```ts +import { withContext } from '@fastgpt/service/common/logger'; + +return withContext({ requestId }, async () => { + logger.info('Request received', { requestId, method, url }); +}); +``` + +Next.js API 入口已在 `packages/service/common/middle/entry.ts` 统一处理请求日志与 `requestId`。 + +## 6. 错误日志规范 + +**统一使用 `error` 字段记录错误对象**,并补充上下文: +```ts +try { + await doSomething(); +} catch (error) { + logger.error('Do something failed', { error, appId, userId }); + throw error; +} +``` + +避免: +- 用 `err`、`e` 等不一致字段名。 +- 只记录 `error.message` 丢失堆栈。 +- 捕获后不记录、不抛出。 + +## 7. 敏感信息与 OTEL + +**禁止记录敏感信息**: token、密钥、密码、完整聊天内容、隐私数据等。 + +如确需记录用于调试: +- 做脱敏或截断。 +- 可添加 `fastgpt` 属性,避免 OTEL 导出(由 `sensitiveProperties` 过滤)。 + +```ts +logger.warn('Payload truncated for debug', { + fastgpt: true, + payloadPreview: payload.slice(0, 200) +}); +``` + +## 8. 配置项(环境变量) + +日志系统由 `configureLogger()` 读取环境变量: +- `LOG_ENABLE_CONSOLE` 是否开启控制台输出 +- `LOG_CONSOLE_LEVEL` 控制台最低等级 +- `LOG_ENABLE_OTEL` 是否开启 OTEL +- `LOG_OTEL_LEVEL` OTEL 最低等级 +- `LOG_OTEL_SERVICE_NAME` OTEL 服务名 +- `LOG_OTEL_URL` OTEL 收集器地址 + +## 9. 推荐示例 + +```ts +import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +const logger = getLogger(LogCategories.INFRA.MONGO); + +logger.info('Mongo change stream watch started'); + +try { + await watchMongo(); +} catch (error) { + logger.error('Mongo watch failed', { error, collection: 'system_config' }); +} +``` diff --git a/.codex/design/core/ai/agentCall-declarative-tools.md b/.codex/design/core/ai/agentCall-declarative-tools.md new file mode 100644 index 0000000000..bc61756fb7 --- /dev/null +++ b/.codex/design/core/ai/agentCall-declarative-tools.md @@ -0,0 +1,331 @@ +# agentCall 声明式工具改造设计 + +## 1. 背景 + +当前 `packages/service/core/ai/llm/agentCall/index.ts` 的 `runAgentLoop` 通过四个分离的参数处理工具调度: + +- `body.tools`:喂给 LLM 的 schema 数组 +- `onToolCall`:LLM 识别到工具调用时的流式回调 +- `onToolParam`:工具参数流式增量回调 +- `onRunTool`:实际执行工具的总入口 + +调用方(`toolCall.ts` / `masterCall.ts`)在 `onRunTool` 内写了一长串 `if (toolId === X) else if (toolId === Y)` 分支,每个分支都要独立做 `parseJsonArgs + XxxSchema.safeParse + 错误处理`。新增工具必须改这个巨型函数,schema / 解析 / 执行三段逻辑被拆散在不同参数里。 + +## 2. 目标 + +1. **声明式**:一个工具自带 schema、参数解析、执行逻辑,三段聚合到一个对象里。 +2. **两阶段执行**:所有工具统一 `parseParams`(解析 + 校验)→ `execute`(执行)两个阶段,消除分支里重复的校验代码。 +3. **生命周期钩子**:流式事件(`onToolCall / onToolParam / onAfterToolCall`)保持全局,由 `runAgentLoop` 统一编排,工具定义不感知 UI 层。 +4. **`runAgentLoop` 自身不感知具体工具种类**:核心循环只负责调度,新增工具不需要改 `agentCall` 模块。 + +本文档只覆盖 **`agentCall` 模块自身** 的改造,应用层(`toolCall.ts` / `masterCall.ts`)如何迁移在后续文档单独讨论。 + +## 3. 目录结构与类型定义 + +声明式工具的**类型定义与执行服务**放在独立目录 `packages/service/core/ai/llm/toolCall/` 下管理,与 `agentCall/` 解耦(`agentCall` 负责多轮调度,`toolCall` 负责单次工具调用的解析与执行;后者是前者的依赖): + +``` +packages/service/core/ai/llm/ +├── agentCall/ +│ └── index.ts # 多轮调度,import from ../toolCall +├── toolCall/ +│ ├── type.ts # ToolDefinition、ToolExecuteContext、ToolExecuteResult、ToolParseResult +│ └── index.ts # runTool 两阶段执行器 +├── request.ts +└── ... +``` + +因为类型不再属于 `agentCall` 私有命名空间,`AgentToolDefinition` 去掉 `Agent` 前缀,统一命名为 `ToolDefinition`(其他类型同理)。 + +新建 `packages/service/core/ai/llm/toolCall/type.ts`: + +```ts +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool +} from '@fastgpt/global/core/ai/llm/type'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; + +// 参数解析结果:成功返回强类型 data,失败返回要回填给 LLM 的 errorMessage +export type ToolParseResult

= + | { success: true; data: P } + | { success: false; errorMessage: string }; + +// 执行上下文 +export type ToolExecuteContext

= { + call: ChatCompletionMessageToolCall; // 原始工具调用 + messages: ChatCompletionMessageParam[]; // 当前 requestMessages 快照 + params: P; // parseParams 输出 +}; + +// 执行结果(结构与现有 onRunTool 的返回值对齐) +export type ToolExecuteResult = { + response: string; + assistantMessages?: ChatCompletionMessageParam[]; + usages?: ChatNodeUsageType[]; + interactive?: WorkflowInteractiveResponseType; + stop?: boolean; +}; + +// 声明式工具定义 +export type ToolDefinition

= { + // 1. 喂给 LLM 的 schema(name/description/parameters) + schema: ChatCompletionTool; + + // 2. 参数解析阶段,可选;缺省走 parseJsonArgs,参数类型为 Record + parseParams?: (rawArgs: string) => ToolParseResult

; + + // 3. 执行阶段(必填) + execute: (ctx: ToolExecuteContext

) => Promise; +}; +``` + +设计要点: + +- `parseParams` 的返回类型强制调用方处理校验失败,失败文案会作为 `response` 回写给 LLM(保持当前代码行为,让模型能看到错误自行纠偏)。 +- `execute` 的 `params` 通过泛型 `P` 串联,从 `parseParams` 的 `data` 类型收窄而来;调用方编写 `execute` 时不再需要重复 `safeParse`。 +- 返回值沿用现有的 `response / assistantMessages / usages / interactive / stop` 字段,迁移时不需要对 `agentCall` 循环体里"如何消费这些字段"做任何改动。 + +## 4. `runAgentLoop` Props 变更 + +### 4.1 删除的 props + +```ts +body.tools: ChatCompletionTool[] +onToolCall: (e: { call }) => void +onToolParam: (e: { tool; params }) => void +onRunTool: (e: { call; messages }) => Promise<...> +``` + +### 4.2 新增 / 替换的 props + +```ts +type RunAgentCallProps = { + // ... 其他不变(maxRunAgentTimes、childrenInteractiveParams、handleInteractiveTool、 + // onAfterCompressContext、onToolCompress、usagePush、isAborted、userKey、onReasoning、onStreaming 等) + + body: CreateLLMResponseProps['body'] & { + // tools 字段被移除 + temperature?: number; + top_p?: number; + stream?: boolean; + }; + + // 声明式的工具集合(schema + 执行逻辑) + // 所有工具必须在调用 runAgentLoop 前完整枚举。LLM 能看到的工具集 ≡ 能执行的工具集。 + // 动态场景(用户 SubApp、capability 等)由调用方在构建 tools 数组时提前展开。 + tools: ToolDefinition[]; + + // 生命周期钩子(统一编排) + onToolCall?: (e: { call: ChatCompletionMessageToolCall }) => void; + onToolParam?: (e: { tool: ChatCompletionMessageToolCall; argsDelta: string }) => void; + onAfterToolCall?: (e: { + call: ChatCompletionMessageToolCall; + response: string; + }) => void; +}; +``` + +### 4.3 `onToolParam` 字段重命名 + +当前 `onToolParam` 的 `params` 字段传的是**本次增量** `arg`(参见 `request.ts:462`:`onToolParam?.({ tool: currentTool, params: arg })`),字段名容易误解为完整参数。本次一并重命名为 `argsDelta`: + +- `packages/service/core/ai/llm/request.ts:44`:类型定义 `params: string` → `argsDelta: string` +- `packages/service/core/ai/llm/request.ts:462`:调用处 `{ tool, params: arg }` → `{ tool, argsDelta: arg }` +- 所有调用方同步修改(调用方文档里列出) + +## 5. 内部实现 + +### 5.1 新建 `toolCall/index.ts` + +统一的两阶段执行器(对外暴露 `runTool` 作为 `toolCall` 服务的入口): + +```ts +import { parseJsonArgs } from '../../utils'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import type { + ToolDefinition, + ToolExecuteResult, + ToolParseResult +} from './type'; +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall +} from '@fastgpt/global/core/ai/llm/type'; + +type RunToolArgs = { + call: ChatCompletionMessageToolCall; + messages: ChatCompletionMessageParam[]; + tools: ToolDefinition[]; +}; + +export const runTool = async ({ + call, + messages, + tools +}: RunToolArgs): Promise => { + const name = call.function.name; + const def = tools.find((t) => t.schema.function.name === name); + + // 1. 工具未找到(LLM hallucination 或 tools 配置漏项):兜底 response,外层仍会触发 onAfterToolCall + if (!def) { + return { response: `Call tool not found: ${name}` }; + } + + // 2. 阶段一:解析 + const parseResult: ToolParseResult = def.parseParams + ? def.parseParams(call.function.arguments ?? '') + : { success: true, data: parseJsonArgs(call.function.arguments ?? '') }; + + if (!parseResult.success) { + return { response: parseResult.errorMessage }; + } + + // 3. 阶段二:执行(统一 try/catch) + try { + return await def.execute({ + call, + messages, + params: parseResult.data + }); + } catch (error) { + return { response: `Tool error: ${getErrText(error)}` }; + } +}; +``` + +要点: + +- `tools.find` 用 `schema.function.name` 查,未命中即兜底,不再提供动态解析通道。 +- 任何"失败"(未找到 / 解析失败 / 执行抛错)都归一成 `{ response: string }`,外层流程不区分。 +- `execute` 内部闭包捕获到的副作用(`childrenResponses.push` / `toolRunResponses.push` / `planResult = ...`)保持原样,`runner` 不感知。 + +### 5.2 改造 `agentCall/index.ts` + +从 `toolCall` 模块引入类型和 runner: + +```ts +import type { ToolDefinition } from '../toolCall/type'; +import { runTool } from '../toolCall'; +``` + +以下只列出"变化点",其余不动: + +**1) LLM 请求部分的 `body.tools`** + +```ts +// 改造前 +tools, // 直接来自 props.body.tools + +// 改造后 +tools: tools.map((t) => t.schema), // 来自 props.tools,运行时 .map 提取 schema +``` + +**2) 循环体内部的工具调用** + +```ts +// 改造前(line 339-349) +for await (const tool of toolCalls) { + const { response, assistantMessages, usages, interactive, stop } = + await onRunTool({ + call: tool, + messages: cloneRequestMessages + }); + ... +} + +// 改造后 +for await (const toolCall of toolCalls) { + const result = await runTool({ + call: toolCall, + messages: cloneRequestMessages, + tools + }); + + onAfterToolCall?.({ call: toolCall, response: result.response }); + + const { + response, + assistantMessages: toolAssistantMessages = [], + usages: toolUsages = [], + interactive, + stop + } = result; + + // 以下压缩 / 消息追加 / interactive 处理逻辑完全不变 + ... +} +``` + +**3) `createLLMResponse` 的钩子透传** + +```ts +// 改造前 +onToolCall, +onToolParam + +// 改造后(字段名一致,内部定义改名后透传不变;外部 props 也保留 onToolCall/onToolParam 语义) +onToolCall, +onToolParam // 注意透传给 createLLMResponse 的结构里字段要同步改为 argsDelta +``` + +### 5.3 生命周期触发时机汇总 + +| 钩子 | 触发位置 | 参数 | +|---|---|---| +| `onToolCall` | `createLLMResponse` 解析出新 tool 时(`request.ts:452`)| `{ call }` | +| `onToolParam` | `createLLMResponse` 每次累积到 args 增量时(`request.ts:462`)| `{ tool, argsDelta }` | +| `onAfterToolCall` | `runTool` 返回后,压缩和消息追加之前 | `{ call, response }` | + +`onAfterToolCall` 在 notFound / parseParams 失败 / execute 抛错时一样会被触发——UI 层事件流不断档。 + +## 6. 文件清单 + +``` +新建目录: + packages/service/core/ai/llm/toolCall/ + ├── type.ts # ToolDefinition / ToolExecuteContext / ToolExecuteResult / ToolParseResult + └── index.ts # runTool 两阶段执行器(对外导出入口) + +改动: + packages/service/core/ai/llm/agentCall/index.ts + - 从 ../toolCall 引入 ToolDefinition 和 runTool + - props: 删 body.tools / onRunTool + - props: 加 tools / onAfterToolCall + - props: 保留 onToolCall / onToolParam 作为生命周期钩子(语义不变,字段名对齐 argsDelta) + - LLM body.tools 改为 props.tools.map(t => t.schema) + - 循环体 onRunTool → runTool + - onAfterToolCall 触发点 + + packages/service/core/ai/llm/request.ts + - onToolParam 类型:params: string → argsDelta: string(44 行) + - onToolParam 调用:params: arg → argsDelta: arg(462 行) +``` + +## 7. 与现有单测的关系 + +需要检查: + +- `test/cases/service/core/ai/llm/request.test.ts` 对 `onToolParam` 的断言是否使用 `params` 字段。 +- 改造完成后至少补一个 `packages/service/core/ai/llm/toolCall/` 的单测(建议测试文件放在 `test/cases/service/core/ai/llm/toolCall/` 下)覆盖:工具命中 / 未命中(LLM hallucination)/ parseParams 失败 / execute 抛错 四种路径。 + +## 8. 待确认问题 + +1. **`onAfterToolCall` 的触发粒度**:目前是 `runTool` 返回后触发一次,不含压缩后的 response。如果 UI 需要看到"压缩后的 tool response",应该让 `onAfterToolCall` 接收压缩后的值 —— 但这会与现有 `onToolCompress`(已经单独推送压缩产物)重复。建议 `onAfterToolCall` 接收**原始 response**,与 `onToolCompress` 解耦。 + +2. **`body.tools` 去除后的类型收敛**:`CreateLLMResponseProps['body']` 这个类型本身可能没有 `tools` 字段,而是 agentCall 的扩展类型加进去的。需要确认并更新扩展类型定义。 + +## 9. 改造分步 TODO + +- [ ] 新建目录 `packages/service/core/ai/llm/toolCall/` +- [ ] 新建 `toolCall/type.ts` 定义 `ToolDefinition` / `ToolExecuteContext` / `ToolExecuteResult` / `ToolParseResult` +- [ ] 新建 `toolCall/index.ts` 实现并导出 `runTool` +- [ ] 改 `request.ts`:`onToolParam` 的 `params` → `argsDelta` +- [ ] 改 `agentCall/index.ts`:props 重构 + 从 `../toolCall` 引入 + LLM body.tools 提取 + 循环体接入 `runTool` + `onAfterToolCall` 触发 +- [ ] 为 `toolCall/` 补单测(四条路径:命中 / 未命中 / parseParams 失败 / execute 抛错) +- [ ] 跑一遍 `agentCall` 相关现有单测,确认类型编译通过 +- [ ] 调用方(`toolCall.ts`(workflow 层同名但不同路径的文件)/ `masterCall.ts` / 其他)的迁移放在**后续文档**里讨论,此步**先不动** + +> 命名冲突提示:`packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` 是 workflow dispatch 层的文件名,与本次新建的 `packages/service/core/ai/llm/toolCall/` 目录同名但路径不同,不会产生 import 冲突。后续迁移时两者需要区分清楚。 diff --git a/.codex/design/core/ai/gradient-pricing-fix.md b/.codex/design/core/ai/gradient-pricing-fix.md new file mode 100644 index 0000000000..00296e73bd --- /dev/null +++ b/.codex/design/core/ai/gradient-pricing-fix.md @@ -0,0 +1,273 @@ +# 梯度价格计算修复设计文档 + +## 问题描述 + +### 背景 + +梯度价格(Gradient Pricing)通过 `inputTokens` 数量来匹配不同的计费梯度: + +``` +梯度 0: inputTokens 0 ~ 1000 → 价格 X +梯度 1: inputTokens 1000+ → 价格 Y +``` + +### 根本原因 + +当一个工作流节点(如 Tool Call、Agent)在内部多次调用 LLM 时,旧逻辑是: + +1. 将所有 LLM 调用的 `inputTokens` / `outputTokens` **累加** +2. 用累加后的总量调用 `formatModelChars2Points(totalInputTokens)` **一次性**计算价格 + +这样会导致梯度匹配错误: + +``` +场景:模型梯度 0~1000 tokens → 价格 A;1000+ → 价格 B(更低) + +Call 1: inputTokens = 500 → 应匹配梯度 0,价格 A +Call 2: inputTokens = 600 → 应匹配梯度 0,价格 A + +正确总价:A * 500/1000 + A * 600/1000 + +错误做法:累加 1100 tokens → 匹配梯度 1,价格 B +错误总价:B * 1100/1000(价格偏低,用户少付钱) +``` + +--- + +## 受影响的代码位置 + +### 1. `packages/service/core/ai/llm/agentCall/index.ts` — 根源 + +```ts +// 问题:在 while 循环中累加 tokens +inputTokens += usage.inputTokens; +outputTokens += usage.outputTokens; + +// 每次调用单独计算价格并推送(当 usagePush 存在时),但不记录进返回值 +const agentUsage = formatModelChars2Points({ inputTokens: usage.inputTokens, ... }); +usagePush?.([{ totalPoints: agentUsage.totalPoints, ... }]); + +// 返回的是累加值,调用方再次用累加值计算价格 → 重复错误 +return { inputTokens, outputTokens, ... }; +``` + +**后果:** +- 当 `usagePush` 不传(如来自 `runToolCall`)时,单次计价被丢弃,调用方用累加值重算 +- 当 `usagePush` 传入(如来自 `masterCall`)时,单次计价已正确推送,但调用方仍用累加值做展示 + +### 2. `packages/service/core/workflow/dispatch/ai/tool/index.ts` (dispatchRunTools) — **计费 BUG** + +```ts +// toolCallInputTokens = 所有轮次累加的 tokens +const { totalPoints: modelTotalPoints } = formatModelChars2Points({ + inputTokens: toolCallInputTokens, // ❌ 累加值 + outputTokens: toolCallOutputTokens +}); +``` + +`runToolCall` 调用 `runAgentLoop` 时**不传 `usagePush`**,所以单次计价全部丢失,只依赖这里的累加计算 → **实际计费错误**。 + +### 3. `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` (masterCall) — **展示 BUG** + +```ts +// inputTokens = runAgentLoop 返回的累加值 +const llmUsage = formatModelChars2Points({ + inputTokens, // ❌ 累加值 + outputTokens +}); +``` + +虽然实际计费通过 `usagePush` 正确推送,但 `nodeResponse.totalPoints` 展示值错误。 + +### 4. `packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts` (dispatchPlanAgent) — **计费 + 展示 BUG** + +```ts +// 再生成时累加 tokens +usage.inputTokens += regenerateResponse.usage.inputTokens; +usage.outputTokens += regenerateResponse.usage.outputTokens; + +// 用累加值计算 +const { totalPoints } = formatModelChars2Points({ + inputTokens: usage.inputTokens, // ❌ 累加值 + outputTokens: usage.outputTokens +}); +``` + +--- + +## 修复方案 + +### 核心思路 + +**不应用累加的 token 数计算价格,而应该每次 LLM 调用单独计价,再累加价格。** + +### 方案:`runAgentLoop` 返回预计算的 `llmTotalPoints` + +在 `runAgentLoop` 的 while 循环中,每次 LLM 调用后立即计算该次的价格,并累加到 `llmTotalPoints`,最终将其作为返回值之一。调用方直接使用该预计算值,而不再重复调用 `formatModelChars2Points(累加 tokens)`。 + +--- + +## 具体修改 + +### 修改 1:`runAgentLoop` — 增加 `llmTotalPoints` 返回值 + +**文件**:`packages/service/core/ai/llm/agentCall/index.ts` + +```ts +// RunAgentResponse 类型新增字段 +type RunAgentResponse = { + ... + llmTotalPoints: number; // ← 新增 + inputTokens: number; // 保留,用于展示 + outputTokens: number; // 保留,用于展示 + ... +}; + +// 内部实现 +let llmTotalPoints: number = 0; // ← 新增 + +// while 循环内,每次 LLM 调用后: +const agentUsage = formatModelChars2Points({ + model: modelData.model, + inputTokens: usage.inputTokens, // 当次调用的 tokens + outputTokens: usage.outputTokens +}); +llmTotalPoints += agentUsage.totalPoints; // ← 累加价格(不是 tokens) +usagePush?.([{ totalPoints: agentUsage.totalPoints, ... }]); + +// return 新增 +return { + ... + llmTotalPoints, +}; +``` + +### 修改 2:`runToolCall` — 透传 `llmTotalPoints` + +**文件**:`packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` + +```ts +// ResponseType 新增 +type ResponseType = { + ... + toolCallTotalPoints: number; // ← 新增(替代用累加 tokens 重算的方式) + toolCallInputTokens: number; // 保留展示用 + toolCallOutputTokens: number; // 保留展示用 +}; + +// runAgentLoop 返回后 +const { inputTokens, outputTokens, llmTotalPoints, ... } = await runAgentLoop(...); + +return { + ... + toolCallTotalPoints: llmTotalPoints, // ← 透传 + toolCallInputTokens: inputTokens, + toolCallOutputTokens: outputTokens, +}; +``` + +### 修改 3:`dispatchRunTools` — 使用预计算值 + +**文件**:`packages/service/core/workflow/dispatch/ai/tool/index.ts` + +```ts +// 修改前(❌) +const { totalPoints: modelTotalPoints, modelName } = formatModelChars2Points({ + model, + inputTokens: toolCallInputTokens, + outputTokens: toolCallOutputTokens +}); + +// 修改后(✅) +// modelName 直接从 toolModel.name 获取,无需再调用 formatModelChars2Points +const modelName = toolModel.name; +const modelTotalPoints = toolCallTotalPoints; // 直接使用预计算值,不再重算 +``` + +### 修改 4:`masterCall` — 使用预计算值修正展示 + +**文件**:`packages/service/core/workflow/dispatch/ai/agent/master/call.ts` + +```ts +// runAgentLoop 返回 llmTotalPoints +const { inputTokens, outputTokens, llmTotalPoints, childrenUsages, ... } = await runAgentLoop(...); + +// 修改前(❌) +const llmUsage = formatModelChars2Points({ model: agentModel, inputTokens, outputTokens }); + +// 修改后(✅) +const modelData = getLLMModel(agentModel); +const llmUsage = { + modelName: modelData.name, + totalPoints: llmTotalPoints // 使用预计算值 +}; +``` + +### 修改 5:`dispatchPlanAgent` — 修复累加重算 + +**文件**:`packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts` + +在每次 `createLLMResponse` 调用后单独计算该次价格: + +```ts +let totalPoints = 0; + +// 初始调用: +const initialResult = await createLLMResponse(...); +const initialUsage = formatModelChars2Points({ + model: modelData.model, + inputTokens: initialResult.usage.inputTokens, // 单次 tokens + outputTokens: initialResult.usage.outputTokens +}); +totalPoints += initialUsage.totalPoints; +usage.inputTokens += initialResult.usage.inputTokens; // 累加 tokens 仅用于展示 +usage.outputTokens += initialResult.usage.outputTokens; + +// 再生成时: +const regenResult = await createLLMResponse(...); +const regenUsage = formatModelChars2Points({ + model: modelData.model, + inputTokens: regenResult.usage.inputTokens, // 单次 tokens + outputTokens: regenResult.usage.outputTokens +}); +totalPoints += regenUsage.totalPoints; +usage.inputTokens += regenResult.usage.inputTokens; +usage.outputTokens += regenResult.usage.outputTokens; + +// 最终用 totalPoints(累加价格) +``` + +--- + +## 不受影响的位置(单次调用,无问题) + +| 文件 | 调用方式 | 状态 | +|------|---------|------| +| `dispatch/ai/chat.ts` | 单次 `createLLMResponse` | ✅ 正确 | +| `dispatch/ai/extract.ts` | 单次 `createLLMResponse` | ✅ 正确 | +| `dispatch/ai/classifyQuestion.ts` | 单次 `createLLMResponse` | ✅ 正确 | +| `dispatch/tools/queryExternsion.ts` | 单次 LLM 调用 | ✅ 正确 | +| `dispatch/dataset/search.ts` | 各自独立单次调用 | ✅ 正确 | + +--- + +## 修改文件清单 + +| 文件 | 修改内容 | +|------|---------| +| `packages/service/core/ai/llm/agentCall/index.ts` | 新增 `llmTotalPoints` 累加及返回 | +| `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` | 透传 `toolCallTotalPoints` | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 使用 `toolCallTotalPoints` 替代重算 | +| `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | 使用 `llmTotalPoints` 替代重算 | +| `packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts` | 每次调用单独计价后累加 | + +--- + +## TODO + +- [ ] 修改 `runAgentLoop` 返回类型,新增 `llmTotalPoints` +- [ ] 修改 `runToolCall` 返回类型,新增 `toolCallTotalPoints` +- [ ] 修改 `dispatchRunTools` 使用预计算值 +- [ ] 修改 `masterCall` 使用预计算值(修正展示) +- [ ] 修改 `dispatchPlanAgent` 每次调用单独计价 +- [ ] 补充/更新相关单元测试 diff --git a/.codex/design/core/ai/sandbox/get-file-url.md b/.codex/design/core/ai/sandbox/get-file-url.md new file mode 100644 index 0000000000..08133b96e9 --- /dev/null +++ b/.codex/design/core/ai/sandbox/get-file-url.md @@ -0,0 +1,376 @@ +# 虚拟机系统工具:获取文件链接 (sandbox_get_file_url) + +## 一、需求概述 + +为虚拟机(Agent Sandbox)新增一个系统级工具 `sandbox_get_file_url`: + +1. Agent 调用该工具,传入虚拟机内的文件路径 +2. 系统从虚拟机读取文件内容 +3. 将文件上传到 S3(使用对话的 Chat Bucket 实例) +4. 返回 2 小时有效期的签名访问 URL + +**工具返回格式**: +```json +{ + "url": "https://xxx.s3.amazonaws.com/...", + "expired": "2 hours", + "filename": "output.csv" +} +``` + +--- + +## 二、涉及文件 + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `packages/global/core/ai/sandbox/constants.ts` | 修改 | 新增工具定义、Schema、更新系统提示词 | +| `packages/service/core/ai/sandbox/toolCall.ts` | 新增 | 沙盒工具统一执行层 `callSandboxTool()`,两条链路共享 | +| `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` | 修改 | 统一拦截所有 sandbox 工具,复用 `callSandboxTool` | +| `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` | 修改 | 新增 `dispatchSandboxGetFileUrl()`,复用 `callSandboxTool` | +| `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | 修改 | 新增工具拦截逻辑(Agent 模式) | +| `packages/service/common/s3/buckets/base.ts` | 修改 | `uploadFileByBody` 新增 `filename`、`expiredTime` 参数 | +| `packages/service/common/s3/sources/chat/index.ts` | 修改 | `uploadChatFile` 透传 `filename`、`expiredTime` | +| `packages/service/common/s3/type.ts` | 修改 | `UploadFileByBodySchema` 新增 `filename`、`expiredTime` 字段 | +| `packages/service/common/s3/sources/chat/type.ts` | 修改 | `UploadChatFileSchema` 新增 `expiredTime` 字段 | +| `projects/app/src/pages/api/system/file/[jwt].ts` | 修改 | 下载接口支持 `Content-Disposition` 响应头 | + +> **架构说明**:新增 `callSandboxTool` 作为纯执行层,不绑定业务响应格式。两条调用链路(普通工作流 / Agent 模式)均复用该层,消除重复逻辑。 + +> **URL 生成**:文件上传到 S3 后,通过 `jwtSignS3ObjectKey(key, expiredAt)` 生成 JWT 签名的内网访问 URL(有效期 2 小时),不依赖 S3 签名 URL。 + +--- + +## 三、详细设计 + +### 3.1 工具定义(constants.ts) + +新增常量与 Schema: + +```typescript +export const SANDBOX_READ_FILE_TOOL_NAME: I18nStringType = { + 'zh-CN': '虚拟机/获取文件链接', + 'zh-Hant': '虛擬機/獲取文件鏈接', + en: 'Sandbox/Get File URL' +}; +export const SANDBOX_GET_FILE_URL_TOOL_NAME = 'sandbox_get_file_url'; + +// 参数为 paths 数组,支持同时获取多个文件 +export const SandboxGetFileUrlToolSchema = z.object({ + paths: z.array(z.string()) +}); + +export const SANDBOX_GET_FILE_URL_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: SANDBOX_GET_FILE_URL_TOOL_NAME, + description: '从虚拟机读取指定文件,上传至云存储,返回 2 小时有效期的访问链接', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + items: { + type: 'string', + description: '文件访问路径,例如: output.csv' + }, + description: '文件访问路径,例如: ["output.csv", "output.txt"]' + } + }, + required: ['paths'] + } + } +}; + +// 更新 SANDBOX_TOOLS(追加新工具) +export const SANDBOX_TOOLS: ChatCompletionTool[] = [SANDBOX_SHELL_TOOL, SANDBOX_GET_FILE_URL_TOOL]; +``` + +更新系统提示词(`SANDBOX_SYSTEM_PROMPT`),追加工具说明: +``` +- 若需要将生成的文件分享给用户,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 工具获取文件的临时访问链接(有效期 2 小时) +``` + +--- + +### 3.2 沙盒工具统一执行层(toolCall.ts) + +新增 `packages/service/core/ai/sandbox/toolCall.ts`,作为所有沙盒工具的纯执行层,不绑定业务响应格式: + +```typescript +type SandboxToolCallParams = { + toolName: string; + rawArgs: string; + appId: string; + userId: string; + chatId: string; +}; + +export type SandboxToolCallResult = { + input: Record; + response: string; + durationSeconds: number; +}; + +export const callSandboxTool = async (params: SandboxToolCallParams): Promise +``` + +**`sandbox_get_file_url` 执行逻辑**: +1. 解析 `paths` 数组参数 +2. 用 `Promise.all` 并发处理每个文件路径: + - 通过 `instance.provider.readFileStream(filePath)` 流式读取文件内容 + - 聚合 chunks 为 `Buffer` + - 调用 `chatBucket.uploadChatFile({ ..., expiredTime: addHours(now, 2) })` 上传,TTL 设为 2 小时 + - 用 `jwtSignS3ObjectKey(key, addHours(now, 2))` 生成 JWT 签名访问 URL +3. 返回 `Array<{ fileUrl: string, filename: string }>` + +**工具返回格式**(JSON 序列化后作为 response): +```json +[ + { "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "output.csv" }, + { "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "report.txt" } +] +``` + +> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是通过 `jwtSignS3ObjectKey` 生成 JWT 签名的内部访问 URL,由 `/api/system/file/[jwt]` 接口代理下载。 + +--- + +### 3.3 S3 上传(uploadFileByBody 扩展) + +扩展 `packages/service/common/s3/type.ts` 的 `UploadFileByBodySchema`,新增 `filename`(必填)和 `expiredTime`(可选)字段: + +```typescript +export const UploadFileByBodySchema = z.object({ + buffer: z.instanceof(Buffer), + contentType: z.string().optional(), + key: z.string().nonempty(), + filename: z.string().nonempty(), // 新增,用于 Content-Disposition + expiredTime: z.date().optional() // 新增,不传则默认 now + 1h +}); +``` + +`uploadFileByBody` 使用 `expiredTime` 写入 MongoS3TTL,并将 `filename` 写入对象 metadata: + +```typescript +metadata: { + contentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`, + originFilename: encodeURIComponent(filename), + uploadTime: new Date().toISOString() +} +``` + +`uploadChatFile` 同步透传 `filename` 和 `expiredTime` 到底层方法。 + +--- + +### 3.4 封装方法(sub/sandbox/index.ts) + +`dispatchSandboxGetFileUrl` 直接复用 `callSandboxTool`,与 `dispatchSandboxShell` 模式一致: + +```typescript +export const dispatchSandboxGetFileUrl = async ({ + filePath, // 保持参数名,内部转为 paths: [filePath] + appId, + userId, + chatId, + lang +}: SandboxDispatchParams & { filePath: string }): Promise => { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: SANDBOX_GET_FILE_URL_TOOL_NAME, + rawArgs: JSON.stringify({ paths: [filePath] }), + appId, + userId, + chatId + }); + + return { + response, + usages: [], + nodeResponse: buildNodeResponse({ toolId: SANDBOX_GET_FILE_URL_TOOL_NAME, input, response, durationSeconds, lang }) + }; +}; +``` + +--- + +### 3.5 工具拦截逻辑(两条链路) + +两条链路均通过 `callSandboxTool` 统一处理,不再各自内联逻辑。 + +#### 3.5.1 普通工作流:toolCall.ts + +`onRunTool` 中合并 `SANDBOX_TOOL_NAME` 和 `SANDBOX_GET_FILE_URL_TOOL_NAME` 到同一拦截块: + +```typescript +if ( + call.function?.name === SANDBOX_TOOL_NAME || + call.function?.name === SANDBOX_GET_FILE_URL_TOOL_NAME +) { + const { input, response, durationSeconds } = await callSandboxTool({ + toolName: call.function.name, + rawArgs: call.function.arguments ?? '', + appId: String(workflowProps.runningAppInfo.id), + userId: String(workflowProps.uid), + chatId: workflowProps.chatId + }); + + const flowResponse = getSandboxToolWorkflowResponse({ + name: tool.name, + logo: SANDBOX_ICON, + toolId: call.function.name, + input, + response, + durationSeconds + }); + + return { response, flowResponse }; +} +``` + +#### 3.5.2 Agent 模式:agent/master/call.ts + +紧跟 `SANDBOX_TOOL_NAME` 拦截块之后,添加对 `SANDBOX_GET_FILE_URL_TOOL_NAME` 的处理,复用 `dispatchSandboxGetFileUrl`: + +```typescript +if (toolId === SANDBOX_GET_FILE_URL_TOOL_NAME) { + const toolParams = SandboxGetFileUrlToolSchema.safeParse(parseJsonArgs(call.function.arguments)); + if (!toolParams.success) return { response: toolParams.error.message, usages: [] }; + + const result = await dispatchSandboxGetFileUrl({ + filePath: toolParams.data.paths[0], // Agent 模式单文件路径 + appId: runningAppInfo.id, + userId: props.uid, + chatId, + lang: props.lang + }); + + childrenResponses.push(result.nodeResponse); + return { response: result.response, usages: result.usages }; +} +``` + +--- + +## 四、执行流程 + +### 4.1 普通工作流链路(toolCall.ts) + +```mermaid +sequenceDiagram + participant Tool as ToolCall 节点 + participant Handler as toolCall.ts 拦截 + participant CallLayer as callSandboxTool() + participant SandboxProvider as provider.readFileStream() + participant S3 as S3ChatSource.uploadChatFile() + participant DB as MongoS3TTL + + Tool->>Handler: 调用 sandbox_get_file_url({ paths }) + Handler->>CallLayer: callSandboxTool({ toolName, rawArgs, ... }) + loop 每个 path(并发) + CallLayer->>SandboxProvider: readFileStream(filePath) + SandboxProvider-->>CallLayer: AsyncIterable + CallLayer->>CallLayer: Buffer.concat(chunks) + CallLayer->>S3: uploadChatFile({ filename, buffer, expiredTime: +2h }) + S3->>DB: MongoS3TTL.create(expiredTime = now + 2h) + S3-->>CallLayer: { key } + CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) → fileUrl + end + CallLayer-->>Handler: { input, response: JSON([{fileUrl, filename}...]), durationSeconds } + Handler-->>Tool: response + flowResponse +``` + +### 4.2 Agent 模式链路(agent/master/call.ts) + +```mermaid +sequenceDiagram + participant Agent as Agent Master Call + participant Handler as master/call.ts 拦截 + participant Dispatch as dispatchSandboxGetFileUrl() + participant CallLayer as callSandboxTool() + participant SandboxProvider as provider.readFileStream() + participant S3 as S3ChatSource.uploadChatFile() + + Agent->>Handler: 调用 sandbox_get_file_url({ paths }) + Handler->>Dispatch: dispatchSandboxGetFileUrl({ filePath, appId, ... }) + Dispatch->>CallLayer: callSandboxTool({ toolName, rawArgs: {paths:[filePath]}, ... }) + CallLayer->>SandboxProvider: readFileStream(filePath) + SandboxProvider-->>CallLayer: stream chunks + CallLayer->>S3: uploadChatFile({ expiredTime: +2h }) + S3-->>CallLayer: { key } + CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) + CallLayer-->>Dispatch: { input, response, durationSeconds } + Dispatch-->>Handler: { response, nodeResponse } + Handler-->>Agent: { response, usages } +``` + +--- + +## 五、错误处理 + +| 错误场景 | 处理方式 | +|----------|----------| +| 文件不存在 | `readFileStream` 抛出异常,`callSandboxTool` 捕获后返回错误字符串,Agent 收到后可重试 | +| 沙盒不可用 | `getSandboxClient` 失败,返回错误信息 | +| S3 上传失败 | 捕获异常,返回错误信息 | +| 文件过大 | ⚠️ **当前未限制**,文件内容先全部读入内存再上传(见六、待优化) | + +--- + +## 六、待优化 + +### 6.1 内存占用问题(当前实现缺陷) + +当前实现将沙盒文件全量读入内存后再上传 S3: + +```typescript +// 当前:全量内存聚合 +const chunks: Uint8Array[] = []; +for await (const chunk of stream) { + chunks.push(chunk); +} +const fileBuffer = Buffer.concat(chunks); // 大文件 OOM 风险 +``` + +**问题**:大文件会导致内存暴涨,Tool Call 场景下 AI 可以任意触发,存在 DoS 风险。 + +**优化方向一:文件大小限制** + +在聚合 chunks 过程中累计字节数,超过阈值立即中止: + +```typescript +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +let totalSize = 0; +for await (const chunk of stream) { + totalSize += chunk.byteLength; + if (totalSize > MAX_FILE_SIZE) { + throw new Error(`File too large (> ${MAX_FILE_SIZE / 1024 / 1024}MB)`); + } + chunks.push(chunk); +} +``` + +**优化方向二:直接流式上传 S3(推荐)** + +绕过内存聚合,将 `readFileStream` 返回的 `AsyncIterable` 直接作为 S3 上传 body: + +```typescript +const stream = instance.provider.readFileStream(filePath); +// 需要 S3 client 支持传入 AsyncIterable/Stream 作为 body +await this.client.uploadObject({ key, body: stream, contentType: '...' }); +``` + +可行性取决于底层 S3 client(`@aws-sdk/client-s3` 的 `PutObjectCommand` 支持传入 `Readable` / `ReadableStream`)。需评估 `MinIO/custom` client 的支持情况后实施。 + +--- + +## 七、TODO + +- [x] `packages/global/core/ai/sandbox/constants.ts`:新增 `SANDBOX_READ_FILE_TOOL_NAME`、`SANDBOX_GET_FILE_URL_TOOL_NAME`、`SANDBOX_GET_FILE_URL_TOOL`、`SandboxGetFileUrlToolSchema`(参数为 `paths: string[]`),更新 `SANDBOX_TOOLS` 和 `SANDBOX_SYSTEM_PROMPT` +- [x] `packages/service/core/ai/sandbox/toolCall.ts`:新增 `callSandboxTool()` 统一执行层,支持 `sandbox_shell` 和 `sandbox_get_file_url` +- [x] `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts`:重构为复用 `callSandboxTool`,新增 `dispatchSandboxGetFileUrl()` +- [x] `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`:合并拦截逻辑,复用 `callSandboxTool` +- [x] `packages/service/core/workflow/dispatch/ai/agent/master/call.ts`:新增 `SANDBOX_GET_FILE_URL_TOOL_NAME` 拦截逻辑 +- [x] `packages/service/common/s3/type.ts`、`buckets/base.ts`、`sources/chat/index.ts`、`sources/chat/type.ts`:扩展 `filename`、`expiredTime` 参数 +- [x] `projects/app/src/pages/api/system/file/[jwt].ts`:支持 `Content-Disposition` 下载头 +- [ ] 大文件限制或流式上传优化(见六、待优化) diff --git a/.codex/design/core/ai/sandbox/prd.md b/.codex/design/core/ai/sandbox/prd.md new file mode 100644 index 0000000000..7a227a18f5 --- /dev/null +++ b/.codex/design/core/ai/sandbox/prd.md @@ -0,0 +1,356 @@ +# FastGPT AI Sandbox 集成方案 + +## 一、背景与目标 + +当 Agent 拥有一个独立虚拟机时,可以执行代码、管理文件、调用系统命令,能力大幅增强。本方案通过接入外部沙盒服务,为每个会话提供一个隔离、持久的容器环境,让 Agent 拥有完整的 root 权限操作空间。 + +**核心目标**: +- 每会话独立隔离,互不干扰 +- Agent 无感知沙盒状态,调用接口简单 +- 沙盒自动生命周期管理,节省资源 +- 支持用户通过 SSH/Web IDE 直接进入沙盒 + +--- + +## 二、整体架构 + +FastGPT 作为**纯业务层**,只负责在合适时机调用 SDK;沙盒的生命周期管理、配额、清理、审计等全部由下游(SDK / SSS)负责。 + +```mermaid +graph TB + subgraph FastGPT["FastGPT 业务层"] + Agent["Agent / 工具调用节点\n(useAgentSandbox=true)"] + SandboxMgr["execShell()\n(薄封装)"] + end + + subgraph SDK["@fastgpt-sdk/sandbox-adapter"] + Adapter["统一适配器\ncreate() / exec()"] + end + + subgraph Downstream["下游服务(FastGPT 不关心)"] + SSS["Sealos Sandbox Server\n(生命周期 / 配额 / 审计)"] + Devbox["Sealos Devbox\n(容器实例)"] + end + + Agent -->|"首次调用 shell 工具时\n(懒加载)"| SandboxMgr + SandboxMgr -->|"create() + exec()"| Adapter + Adapter -->|"API 调用"| SSS + SSS -->|"管理容器"| Devbox +``` + +### 组件职责 + +| 组件 | 职责 | 归属 | +|------|------|------| +| **FastGPT execShell()** | 薄封装:组装 sandboxId,调用 SDK | FastGPT | +| **@fastgpt-sdk/sandbox-adapter** | 统一适配层;`create()` 保证返回可用沙盒 | SDK | +| **Sealos Sandbox Server** | 容器 CRUD、生命周期管理、配额、审计 | 下游 | +| **Sealos Devbox** | 实际的隔离容器实例 | 下游 | + +--- + +## 三、沙盒管理设计 + +### 3.1 沙盒粒度 + +沙盒以 **会话维度** 分配,唯一标识由三元组生成: + +``` +sandboxId = hash(appId + userId + chatId) +``` + +```mermaid +graph LR + AppId["appId"] + UserId["userId"] + ChatId["chatId"] + Hash["Hash 函数\n(SHA256)"] + SandboxId["sandboxId\n(唯一 ID)"] + + AppId --> Hash + UserId --> Hash + ChatId --> Hash + Hash --> SandboxId +``` + +> 不同会话之间完全隔离;同一会话内多轮对话共享同一个沙盒,保留执行上下文(变量、文件等)。 + +### 3.2 沙盒生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Running : Agent 首次调用 shell / 打开 Web IDE\ncreate()(懒加载) + + Running --> Stoped : FastGPT 定时任务\n(5 分钟无活动) + Stoped --> Running : Agent 再次调用 shell / 打开 Web IDE\ncreate() 自动恢复 + + Running --> Deleted : 会话被删除\n(异步触发) + Stoped --> Deleted : 会话被删除\n(异步触发) + + Deleted --> [*] +``` + +**FastGPT 侧规则**: +- **懒加载**:会话开始时不创建沙盒,Agent 首次调用 `shell` 工具或用户打开 Web IDE 时才触发 `create()` +- **停止**:由 FastGPT 定时任务驱动,扫描 `lastActiveAt` 超过 5 分钟的 Running 沙盒,调用 SDK 停止 +- **销毁**:会话被删除时,**异步**触发 SDK 删除并清理 DB 记录(不阻塞会话删除主流程) + +### 3.3 数据库设计 + +**集合名**:`sandbox_instances` + +```typescript +type SandboxInstanceSchema = { + _id: ObjectId; + provider: 'sealosdevbox'; // 沙盒提供商 + sandboxId: string; // hash(appId+userId+chatId) + + appId?: ObjectId; // 可选,Chat 模式下关联应用 + userId?: string; // 可选,Chat 模式下关联用户 + chatId?: string; // 可选,Chat 模式下关联会话 + + status: 'running' | 'stoped'; + lastActiveAt: Date; // 最后活跃时间,驱动停止定时任务 + createdAt: Date; + + limit?: { // 可选,资源限制 + cpuCount: number; + memoryMiB: number; + diskGiB: number; + }; +}; +``` + +**索引**: +- `{ provider, sandboxId }`:唯一索引(快速查找) +- `{ appId, chatId }`:部分唯一索引(仅当两者都存在时) +- `{ status, lastActiveAt }`:暂停定时任务扫描 + +### 3.4 定时任务 & 触发时机 + +```mermaid +flowchart LR + subgraph StopJob["停止任务(每 5 分钟)"] + S1["查询 status=running\n且 lastActiveAt < now-5min"] --> S2["SDK.stop(sandboxId)"] + S2 --> S3["更新 status=stoped"] + end + + subgraph DeleteTrigger["会话删除(事件触发)"] + D1["单个会话删除\ndelete by chatId"] --> D2["查询 chatId 对应沙盒"] + D2 --> D3["异步:SDK.delete(sandboxId)"] + D3 --> D4["删除 DB 记录"] + + D5["整个应用删除\ndelete by appId"] --> D6["查询 appId 下所有沙盒"] + D6 --> D7["批量异步:SDK.delete(sandboxId)"] + D7 --> D8["批量删除 DB 记录"] + end +``` + +--- + +## 四、执行流程 + +Agent 调用 shell 工具时序: + +```mermaid +sequenceDiagram + participant Agent as Agent 节点 + participant Exec as execShell() + participant DB as MongoDB + participant SDK as sandbox-adapter + participant SSS as Sealos SSS + + Agent->>Exec: execShell({ appId, userId, chatId, command }) + Note over Exec: sandboxId = hash(appId+userId+chatId) + + Exec->>SDK: create(sandboxId) + Note over SDK,SSS: 幂等:不存在则创建,Stoped 则唤醒,Running 则直接返回 + SDK-->>Exec: SandboxClient + + Exec->>DB: upsert { sandboxId, status=running, lastActiveAt=now } + + Exec->>SDK: exec(sandboxId, command, timeout) + SDK->>SSS: 执行命令 + SSS-->>SDK: { stdout, stderr, exitCode } + SDK-->>Exec: ExecResult + Exec-->>Agent: { stdout, stderr, exitCode } +``` + +**FastGPT 侧代码逻辑(伪代码)**: + +```typescript +async function execShell(params: { + appId: string; + userId: string; + chatId: string; + command: string; + timeout?: number; +}) { + const { appId, userId, chatId, command, timeout } = params; + const sandboxId = sha256(`${appId}-${userId}-${chatId}`).slice(0, 16); + const sandbox = await sandboxAdapter.create(sandboxId); // 幂等,保证可用 + await SandboxInstanceModel.upsert({ sandboxId, status: 'running', lastActiveAt: new Date() }); + return sandboxAdapter.exec(sandbox.id, command, { timeout }); +} + +--- + +## 五、Agent 工具设计 + +### 5.1 节点改造方案 + +**不新增节点类型**,在现有的**工具调用节点**上增加一个 input: + +```typescript +{ + key: 'useAgentSandbox', + type: 'switch', // 开关类型 + label: '启用沙盒(Computer Use)', + defaultValue: false, + description: '开启后,Agent 将获得一个独立 Linux 环境,可执行命令、操作文件' +} +``` + +### 5.2 启用后的行为 + +```mermaid +flowchart TD + NodeExec["工具调用节点执行"] --> Check{useAgentSandbox\n= true?} + Check -->|否| Normal["正常执行,不注入任何沙盒能力"] + Check -->|是| Inject["自动注入内置 sandbox_shell 工具\n到 Agent 的 tools 列表"] + Inject --> Prompt["在 System Prompt 末尾\n追加沙盒环境说明"] + Prompt --> Run["Agent 正常运行\n(可自主决定是否调用 sandbox_shell)"] + Run --> CallShell{Agent 调用\nsandbox_shell 工具?} + CallShell -->|否| End["正常返回"] + CallShell -->|是| Sandbox["SandboxClient\n执行命令并返回结果"] + Sandbox --> End +``` + +**自动注入的内置 sandbox_shell 工具定义**: + +```typescript +// 由系统内置,不需要用户配置,useAgentSandbox=true 时自动追加到 tools +export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: 'sandbox_shell', + description: '在独立 Linux 环境中执行 shell 命令,支持文件操作、代码运行、包安装等', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + timeout: { + type: 'number', + description: '超时秒数', + max: 300, + min: 1 + } + }, + required: ['command'] + } + } +}; +``` + +### 5.3 自动注入的系统提示词 + +`useAgentSandbox=true` 时,在节点原有 System Prompt **末尾追加**: + +``` +你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 sandbox_shell 工具执行命令: +- 预装:bash / python3 / node / bun / git / curl +- 工作目录:/workspace(文件在本次会话内持久保留) +- 可自行安装软件包(apt / pip / npm) +``` + +--- + +## 六、错误处理 + +```mermaid +flowchart TD + Exec["执行命令"] --> E1{沙盒服务\n不可用?} + E1 -->|是| Err1["返回错误:\n'沙盒服务暂时不可用,请稍后重试'\nexitCode=-1"] + E1 -->|否| E3{exitCode != 0?} + E3 -->|是| Warn["返回 stderr 内容\n(非致命错误,Agent 可继续)"] + E3 -->|否| OK["返回 stdout\n正常执行完成"] +``` + +| 错误类型 | exitCode | 处理策略 | +|----------|----------|----------| +| 沙盒服务不可用 | -1 | 返回错误,终止当前节点,不中断整个工作流 | +| 命令执行失败 | ≠0 | 将 stderr 作为输出返回,由 Agent 自行判断 | +| 命令超时 | 由上游处理 | 上游沙盒服务自动断开,FastGPT 透传结果即可 | + +--- + +## 七、安全与资源限制 + +FastGPT 业务层只控制命令超时,其余由下游负责: + +| 限制项 | FastGPT 侧 | 说明 | +|--------|-----------|------| +| **命令超时** | 支持传递 timeout 参数 | 由上游沙盒服务控制,FastGPT 透传 timeout 参数(秒)转换为毫秒 | +| **CPU / 内存 / 磁盘** | 不关心 | 下游(SSS/Devbox)控制 | +| **配额** | 不关心 | 下游控制 | +| **网络隔离** | 不关心 | 下游控制 | +| **审计日志** | 不关心 | 下游控制 | + +--- + +## 八、前端功能 + +### 8.1 文件操作 API + +提供文件读写和下载接口,替代 Web IDE 方案: + +**文件操作 API**:`POST /api/core/ai/sandbox/file` + +```typescript +// 支持三种操作 +type Action = 'list' | 'read' | 'write'; + +// 列出目录 +{ action: 'list', appId, chatId, path: '/workspace' } +→ { action: 'list', files: [{ name, path, type, size }] } + +// 读取文件 +{ action: 'read', appId, chatId, path: '/workspace/test.txt' } +→ { action: 'read', content: 'file content' } + +// 写入文件 +{ action: 'write', appId, chatId, path: '/workspace/test.txt', content: 'new content' } +→ { action: 'write', success: true } +``` + +**文件下载 API**:`POST /api/core/ai/sandbox/download` + +```typescript +// 下载单个文件或整个目录(ZIP) +{ appId, chatId, path: '/workspace' } +→ 返回文件流或 ZIP 压缩包 +``` + +### 8.2 沙盒状态展示 + +在对话页面的工具调用结果中,展示: +- 命令内容(折叠显示) +- 执行状态(成功/失败/超时) +- stdout/stderr 输出(Markdown 代码块) +- 执行耗时 +- 文件操作入口(列表、读取、下载) + +--- + +## 九、设计决策记录 + +| 问题 | 决策 | +|------|------| +| 沙盒配额管理 | 不关心,由下游处理 | +| 沙盒何时创建 | 懒加载,Agent 首次调用 sandbox_shell 时才创建 | +| 停止由谁驱动 | **FastGPT 定时任务**,5 分钟无活动自动停止,不可配置 | +| 销毁由谁驱动 | **会话删除时异步触发**,不依赖定时任务 | +| 多厂商适配 | 由 SDK 适配层处理,FastGPT 不感知 | +| 审计日志 | 下游处理,FastGPT 不记录 | +| 事务一致性 | 使用 mongoSessionRun 保证 DB 操作和 SDK 调用的一致性 | +| Web IDE 方案 | 改为文件操作 API(list/read/write/download),不使用 Web IDE | diff --git a/.codex/design/core/ai/sandbox/technical-design.md b/.codex/design/core/ai/sandbox/technical-design.md new file mode 100644 index 0000000000..322f3114e1 --- /dev/null +++ b/.codex/design/core/ai/sandbox/technical-design.md @@ -0,0 +1,489 @@ +# FastGPT AI Sandbox 技术方案 + +> 基于 [PRD](./prd.md),本文档详细到每个需要改造/新增的文件。 + +--- + +## 一、改造总览 + +```mermaid +graph TD + subgraph 新增文件["🆕 新增文件"] + A1["packages/service/core/ai/sandbox/schema.ts"] + A2["packages/service/core/ai/sandbox/controller.ts\n(含 SandboxClient 类 + cronJob)"] + A4["packages/global/core/ai/sandbox/constants.ts"] + A5["projects/app/src/pages/api/core/ai/sandbox/webideUrl.ts"] + A6["packages/service/.../agent/sub/sandbox/utils.ts"] + end + + subgraph 改造文件["✏️ 改造文件"] + B1["packages/global/core/workflow/constants.ts"] + B1b["packages/global/.../agent/constants.ts\n(SubAppIds + systemSubInfo)"] + B3["packages/service/.../agent/utils.ts\n(getSubapps)"] + B4["packages/service/.../agent/master/call.ts"] + B5["packages/service/.../agent/master/prompt.ts"] + B6["packages/service/.../ai/tool/index.ts"] + B7["packages/service/.../ai/tool/toolCall.ts"] + B9["projects/app/.../system/cron.ts"] + B10["projects/app/.../chat/history/batchDelete.ts"] + B11["packages/service/core/app/controller.ts"] + end + + A6 -.-> B3 + A2 -.-> B4 + A2 -.-> B9 + A2 -.-> B10 + A2 -.-> B11 + A4 -.-> B5 +``` + +--- + +## 二、新增文件 + +### 2.1 `packages/global/core/ai/sandbox/constants.ts` + +沙盒相关的全局常量和类型定义。 + +```typescript +// 沙盒系统提示词(useComputer=true 时追加到 System Prompt) +export const SANDBOX_SYSTEM_PROMPT = `你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 shell 工具执行命令: +- 预装:bash / python3 / node / git / curl / wget +- 工作目录:/workspace(文件在本次会话内持久保留) +- 可自行安装软件包(apt / pip / npm) +- 可通过 timeout 参数指定命令超时时间`; + +// 内置 shell 工具的 function calling schema +export const SANDBOX_SHELL_TOOL_SCHEMA = { + type: 'function' as const, + function: { + name: 'sandbox_shell', + description: '在独立 Linux 环境中执行 shell 命令,支持文件操作、代码运行、包安装等', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + timeout: { type: 'number', description: '超时秒数(可选,由上游沙盒服务控制)' } + }, + required: ['command'] + } + } +}; + +// 沙盒状态枚举 +export const SandboxStatusEnum = { + running: 'running', + stoped: 'stoped' +} as const; + +// 沙盒默认配置 +export const SANDBOX_SUSPEND_MINUTES = 5; + +export const AGENT_SANDBOX_PROVIDER = process.env.AGENT_SANDBOX_PROVIDER +export const AGENT_SANDBOX_SEALOS_BASEURL = process.env.AGENT_SANDBOX_SEALOS_BASEURL +export const AGENT_SANDBOX_SEALOS_TOKEN = process.env.AGENT_SANDBOX_SEALOS_TOKEN +``` + +### 2.2 `packages/service/core/ai/sandbox/schema.ts` + +MongoDB Model 定义。 + +```typescript +// 集合名: sandbox_instances +// 字段: sandboxId(唯一), appId, userId, chatId, status('running'|'stoped'), lastActiveAt, createdAt +// 索引: sandboxId(unique), chatId, appId, { status, lastActiveAt } +``` + +### 2.3 `packages/service/core/ai/sandbox/controller.ts` + +沙盒业务逻辑层,核心类和函数: + +**SandboxClient 类**: +| 方法 | 职责 | +|------|------| +| `constructor({ appId, userId, chatId })` | 初始化实例,生成 sandboxId,创建 SDK adapter | +| `exec(command, timeout?)` | SDK.create() → upsert DB (status=running) → SDK.execute() → 返回结果 | +| `delete()` | 使用事务:删除 DB 记录 + SDK.delete() | +| `stop()` | 使用事务:更新 DB status=stoped + SDK.stop() | + +**导出函数**: +| 函数 | 职责 | +|------|------| +| `deleteSandboxesByChatIds(appId, chatIds)` | 查询 DB → 批量创建实例 → 调用 delete() | +| `deleteSandboxesByAppId(appId)` | 查询 DB → 批量创建实例 → 调用 delete() | +| `cronJob()` | 定时任务:查询 lastActiveAt 超时的 running 记录 → 批量调用 stop() | + +**实现细节**: +- 使用 `mongoSessionRun` 保证 DB 操作和 SDK 调用的事务一致性 +- 定时任务直接在 controller.ts 中实现,使用 `setCron('*/5 * * * *', ...)` +- 错误处理:SDK.create() 失败时返回 exitCode=-1 的错误结果 + +### 2.4 `projects/app/src/pages/api/core/ai/sandbox/file.ts` + +文件操作 API(列表、读取、写入)。 + +```typescript +POST /api/core/ai/sandbox/file +Body: { + appId: string; + chatId: string; + action: 'list' | 'read' | 'write'; + path: string; + content?: string; // write 时必需 + outLinkAuthData?: object; +} +Auth: authChatCrud +Response: + - list: { action: 'list', files: Array<{ name, path, type, size }> } + - read: { action: 'read', content: string } + - write: { action: 'write', success: boolean } +``` + +### 2.5 `projects/app/src/pages/api/core/ai/sandbox/download.ts` + +文件下载 API(单文件或目录 ZIP)。 + +```typescript +POST /api/core/ai/sandbox/download +Body: { + appId: string; + chatId: string; + path: string; // 文件或目录路径 + outLinkAuthData?: object; +} +Auth: authChatCrud +Response: 文件流或 ZIP 压缩包 +``` + +--- + +## 三、改造文件 + +### 3.2 `packages/global/core/ai/sandbox/constants.ts` + +**改动**:沙盒相关的全局常量和类型定义。 + +```typescript +// 沙盒状态枚举 +export const SandboxStatusEnum = { + running: 'running', + stoped: 'stoped' +} as const; + +// 沙盒默认配置 +export const SANDBOX_SUSPEND_MINUTES = 5; + +// sandboxId 生成函数 +export const generateSandboxId = (appId: string, userId: string, chatId: string): string => { + return hashStr(`${appId}-${userId}-${chatId}`).slice(0, 16); +}; + +// 工具名称和图标 +export const SANDBOX_NAME: I18nStringType = { + 'zh-CN': '虚拟机', + 'zh-Hant': '虛擬機', + en: 'Sandbox' +}; +export const SANDBOX_ICON = 'core/app/sandbox/sandbox'; +export const SANDBOX_TOOL_NAME = 'sandbox_shell'; + +// 系统提示词 +export const SANDBOX_SYSTEM_PROMPT = `你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 ${SANDBOX_TOOL_NAME} 工具执行命令: +- 预装:bash / python3 / node / bun / git / curl +- 工作目录:/workspace(文件在本次会话内持久保留) +- 可自行安装软件包(apt / pip / npm)`; + +// 工具定义 +export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: SANDBOX_TOOL_NAME, + description: '在独立 Linux 环境中执行 shell 命令,支持文件操作、代码运行、包安装等', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + timeout: { type: 'number', description: '超时秒数', max: 300, min: 1 } + }, + required: ['command'] + } + } +}; + +// Zod Schema 用于参数验证 +export const SandboxShellToolSchema = z.object({ + command: z.string(), + timeout: z.number().optional() +}); +``` + +**影响范围**:新增文件,提供全局常量和类型定义。 + +### 3.3 `packages/global/core/workflow/constants.ts` + +**改动**:在 `NodeInputKeyEnum` 中新增 key。 + +```typescript +// 新增 +useAgentSandbox = 'useAgentSandbox', // 启用沙盒(Computer Use) +``` + +**影响范围**:枚举新增,不影响现有逻辑。 + +--- + +### 3.4 `packages/service/env.ts` + +**改动**:新增沙盒相关环境变量定义。 + +```typescript +export const env = createEnv({ + server: { + AGENT_SANDBOX_PROVIDER: z.enum(['sealosdevbox']).optional(), + AGENT_SANDBOX_SEALOS_BASEURL: z.string().optional(), + AGENT_SANDBOX_SEALOS_TOKEN: z.string().optional(), + // ...其他环境变量 + } +}); +``` + +**影响范围**:环境变量验证和类型定义。 + +--- + +### 3.3 `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/utils.ts` 🆕 + +**新增文件**:沙盒工具定义,与 `sub/dataset/utils.ts`、`sub/file/utils.ts` 同级。 + +```typescript +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/type'; +import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; +import z from 'zod'; + +// Agent 调用时传递的参数 +export const SandboxShellToolSchema = z.object({ + command: z.string(), + timeout: z.number().optional() +}); + +// ChatCompletionTool 定义 +export const sandboxShellTool: ChatCompletionTool = { + type: 'function', + function: { + name: SubAppIds.sandboxShell, + description: '在独立 Linux 环境中执行 shell 命令,支持文件操作、代码运行、包安装等', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + timeout: { type: 'number', description: '超时秒数(可选,由上游沙盒服务控制)' } + }, + required: ['command'] + } + } +}; +``` + +--- + +### 3.4 `packages/service/core/workflow/dispatch/ai/agent/utils.ts` + +**改动**:在 `getSubapps()` 中新增 `useAgentSandbox` 参数,与 `hasDataset`、`hasFiles` 同级注入。 + +```typescript +// 参数新增 +export const getSubapps = async ({ + // ...现有参数... + useAgentSandbox // 新增 +}: { + // ...现有类型... + useAgentSandbox?: boolean; // 新增 +}) => { + // ...现有逻辑... + + /* Sandbox Shell */ // 新增,与 Dataset Search 同级 + if (useAgentSandbox) { + completionTools.push(sandboxShellTool); + } + + // ...后续不变... +}; +``` + +--- + +### 3.5 `packages/service/core/workflow/dispatch/ai/agent/master/call.ts` + +**改动**:在工具调用分发逻辑中,处理 `sandbox_shell` 的调用结果。 + +``` +位置:约第 440 行附近,工具调用分发逻辑 +当前:已有 plan / dataset / file / model / tool 等分支 +新增:if (toolName === SubAppIds.sandboxShell) { 调用 execShell() 并返回结果 } +``` + +与 `datasetSearch` 的处理方式一致:拦截内置工具名 → 调用对应 controller → 格式化结果返回给 Agent。 + +--- + +### 3.6 `packages/service/core/workflow/dispatch/ai/agent/master/prompt.ts` + +**改动**:`getMasterSystemPrompt()` 函数中,当 `useAgentSandbox=true` 时,在 System Prompt 末尾追加沙盒环境说明。 + +```typescript +// 新增参数 useAgentSandbox?: boolean +// 当 useAgentSandbox=true 时,追加 SANDBOX_SYSTEM_PROMPT +export const getMasterSystemPrompt = ( + systemPrompt?: string, + hasUserTools: boolean = true, + useAgentSandbox?: boolean // 新增 +) => { + let prompt = `...现有逻辑...`; + if (useAgentSandbox) { + prompt += `\n\n${SANDBOX_SYSTEM_PROMPT}`; + } + return prompt; +}; +``` + +--- + +### 3.7 `packages/service/core/workflow/dispatch/ai/tool/index.ts` + +**改动**:`dispatchRunTools`(toolCall 模式)中,读取 `useAgentSandbox` 输入值,传递给下游。 + +``` +位置:函数入口处,从 inputs 中读取 useAgentSandbox +传递给 runToolCall() 调用 +``` + +--- + +### 3.8 `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` + +**改动**:`runToolCall` 中: + +1. 当 `useAgentSandbox=true` 时,在 `tools` 数组中追加 `sandboxShellTool` +2. 在 System Prompt 末尾追加 `SANDBOX_SYSTEM_PROMPT` +3. 处理 AI 返回的 `sandbox_shell` 工具调用:拦截 → 调用 `execShell()` → 将结果作为 tool response 返回 + +``` +位置:约第 58-109 行(构建 tools 参数处)和第 205-267 行(处理工具响应处) +``` + +--- + +### 3.9 `projects/app/src/service/common/system/cron.ts` + +**改动**:在 `startCron()` 中注册沙盒停止定时任务。 + +```typescript +import { cronJob } from '@fastgpt/service/core/ai/sandbox/controller'; + +export const startCron = () => { + // ...现有定时任务... + cronJob(); // 新增:注册沙盒停止定时任务 +}; +``` + +**说明**:定时任务逻辑直接在 controller.ts 中实现,不需要单独的 cron.ts 文件。 + +--- + +### 3.10 `projects/app/src/pages/api/core/chat/history/batchDelete.ts` + +**改动**:在会话批量删除逻辑中,追加异步沙盒清理。 + +```typescript +import { deleteSandboxesByChatIds } from '@fastgpt/service/core/ai/sandbox/controller'; + +// 在现有删除逻辑之后,异步触发(不 await,不阻塞主流程) +deleteSandboxesByChatIds(appId, chatIds).catch(console.error); +``` + +**同样需要改造**:`delHistory.ts`(单个会话软删除时不触发,因为是软删除)和 `clearHistories.ts`(软删除,不触发)。只有硬删除(batchDelete)才触发沙盒清理。 + +--- + +### 3.11 `packages/service/core/app/controller.ts` + +**改动**:在 `deleteAppDataProcessor()` 中追加沙盒清理。 + +```typescript +import { deleteSandboxesByAppId } from '../ai/sandbox/controller'; + +export const deleteAppDataProcessor = async ({ app, teamId }) => { + const appId = String(app._id); + + // ...现有删除逻辑... + + // 新增:删除该应用下所有沙盒 + await deleteSandboxesByAppId(appId); + + await MongoApp.deleteOne({ _id: appId }); +}; +``` + +### 环境变量模板调整 + +需要调整对应的 env 文件,参考 `@fastgpt-sdk/sandbox-adapter` 需要的变量。 + +```bash +# Sealos devbox +AGENT_SANDBOX_PROVIDER=sealos-devbox +AGENT_SANDBOX_SEALOS_BASEURL= +AGENT_SANDBOX_SEALOS_TOKEN= +``` + +--- + +## 四、文件改动汇总 + +| 文件 | 操作 | 改动量 | 说明 | +|------|------|--------|------| +| `packages/global/core/ai/sandbox/constants.ts` | 🆕 新增 | ~40 行 | 常量、类型、系统提示词 | +| `packages/service/core/ai/sandbox/schema.ts` | 🆕 新增 | ~50 行 | MongoDB Model + 索引 | +| `packages/service/core/ai/sandbox/controller.ts` | 🆕 新增 | ~156 行 | SandboxClient 类 + delete/stop 函数 + cronJob | +| `packages/service/.../agent/sub/sandbox/utils.ts` | 🆕 新增 | ~35 行 | sandboxShellTool 定义(同 datasetSearchTool 模式) | +| `projects/app/src/pages/api/core/ai/sandbox/webideUrl.ts` | 🆕 新增 | ~30 行 | Web IDE URL API | +| `packages/global/core/workflow/constants.ts` | ✏️ 改造 | +1 行 | NodeInputKeyEnum 新增 useAgentSandbox | +| `packages/global/.../agent/constants.ts` | ✏️ 改造 | +12 行 | SubAppIds 新增 sandboxShell + systemSubInfo 注册 | +| `packages/service/.../agent/utils.ts` | ✏️ 改造 | +5 行 | getSubapps() 新增 useAgentSandbox 参数,注入 sandboxShellTool | +| `packages/service/.../agent/master/call.ts` | ✏️ 改造 | +20 行 | 拦截 sandbox_shell 调用,路由到 SandboxClient.exec() | +| `packages/service/.../agent/master/prompt.ts` | ✏️ 改造 | +5 行 | 追加沙盒 System Prompt | +| `packages/service/.../ai/tool/index.ts` | ✏️ 改造 | +5 行 | 读取 useAgentSandbox 传递下游 | +| `packages/service/.../ai/tool/toolCall.ts` | ✏️ 改造 | +30 行 | 注入 shell tool + 拦截调用 | +| `projects/app/.../system/cron.ts` | ✏️ 改造 | +2 行 | 注册沙盒 cronJob | +| `projects/app/.../chat/history/batchDelete.ts` | ✏️ 改造 | +3 行 | 异步删除沙盒 | +| `packages/service/core/app/controller.ts` | ✏️ 改造 | +3 行 | 应用删除时清理沙盒 | + +--- + +## 五、实现顺序 + +```mermaid +graph LR + P1["Phase 1\n基础设施"] --> P2["Phase 2\n核心调度"] --> P3["Phase 3\n生命周期"] --> P4["Phase 4\n前端/API"] + + P1 --- P1a["constants.ts\nschema.ts\ncontroller.ts\n(含 cronJob)"] + P2 --- P2a["NodeInputKeyEnum\nAgent 模板\ncall.ts / prompt.ts\ntoolCall.ts"] + P3 --- P3a["注册 cronJob\nbatchDelete.ts\napp/controller.ts"] + P4 --- P4a["webideUrl API\n前端入口(后续)"] +``` + +| 阶段 | 内容 | 可独立测试 | +|------|------|-----------| +| Phase 1(完成)| 新增 constants + schema + controller (含 cronJob) | 可集成测试 SandboxClient.exec() / stop() / delete() | +| Phase 2 (完成)| ToolCall 节点注入 useAgentSandbox + 简易模式支持 useComputer(一个开关即可) + shell tool + 拦截调用 | 需手动运行验证 | +| Phase 3(完成) | 注册 cronJob + 会话/应用删除时清理 | 可通过 cron 日志 + 手动删除会话验证 | +| Phase 4 | Web IDE URL API + 前端入口 | 需要前端配合 | +| Phase 5 | Agent 模式支持 computer | 需手动运行验证 | + +--- + +## 六、依赖项 + +| 依赖 | 说明 | 状态 | +|------|------|------| +| `@fastgpt-sdk/sandbox-adapter` | SDK 包,提供 create/exec/suspend/delete/getWebIdeUrl | 需确认 API 是否就绪 | +| i18n key | `workflow:template.use_agent_sandbox` / `workflow:template.use_computer_desc` | 需新增中英繁体翻译 | diff --git a/.codex/design/core/chat/stop.md b/.codex/design/core/chat/stop.md new file mode 100644 index 0000000000..ff4cdf0186 --- /dev/null +++ b/.codex/design/core/chat/stop.md @@ -0,0 +1,669 @@ +## 对话暂停设计方案 + +## 1. Redis 状态管理方案 + +### 1.1 状态键设计 + +**Redis Key 结构:** +```typescript +// Key 格式: agent_runtime_stopping:{appId}:{chatId} +const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping'; + +type WorkflowStatusKey = `${typeof WORKFLOW_STATUS_PREFIX}:${string}:${string}`; + +// 示例: agent_runtime_stopping:app_123456:chat_789012 +``` + +**状态值设计:** +- **存在键 (值为 1)**: 工作流应该停止 +- **不存在键**: 工作流正常运行 +- **设计简化**: 不使用状态枚举,仅通过键的存在与否判断 + +**参数类型定义:** +```typescript +type WorkflowStatusParams = { + appId: string; + chatId: string; +}; +``` + +### 1.2 状态生命周期管理 + +**状态转换流程:** +``` +正常运行(无键) → 停止中(键存在) → 完成(删除键) +``` + +**TTL 设置:** +- **停止标志 TTL**: 60 秒 + - 原因: 避免因意外情况导致的键泄漏 + - 正常情况下会在工作流完成时主动删除 +- **工作流完成后**: 直接删除 Redis 键 + - 原因: 不需要保留终态,减少 Redis 内存占用 + +### 1.3 核心函数说明 + +**1. setAgentRuntimeStop** +- **功能**: 设置停止标志 +- **参数**: `{ appId, chatId }` +- **实现**: 使用 `SETEX` 命令,设置键值为 1,TTL 60 秒 + +**2. shouldWorkflowStop** +- **功能**: 检查工作流是否应该停止 +- **参数**: `{ appId, chatId }` +- **返回**: `Promise` - true=应该停止, false=继续运行 +- **实现**: GET 命令获取键值,存在则返回 true + +**3. delAgentRuntimeStopSign** +- **功能**: 删除停止标志 +- **参数**: `{ appId, chatId }` +- **实现**: DEL 命令删除键 + +**4. waitForWorkflowComplete** +- **功能**: 等待工作流完成(停止标志被删除) +- **参数**: `{ appId, chatId, timeout?, pollInterval? }` +- **实现**: 轮询检查停止标志是否被删除,超时返回 + +### 1.4 边界情况处理 + +**1. Redis 操作失败** +- **错误处理**: 所有 Redis 操作都包含 `.catch()` 错误处理 +- **降级策略**: + - `shouldWorkflowStop`: 出错时返回 `false` (认为不需要停止,继续运行) + - `delAgentRuntimeStopSign`: 出错时记录错误日志,但不影响主流程 +- **设计原因**: Redis 异常不应阻塞工作流运行,降级到继续执行策略 + +**2. TTL 自动清理** +- **TTL 设置**: 60 秒 +- **清理时机**: Redis 自动清理过期键 +- **设计原因**: + - 避免因异常情况导致的 Redis 键泄漏 + - 自动清理减少手动维护成本 + - 60 秒足够大多数工作流完成停止操作 + +**3. stop 接口等待超时** +- **超时时间**: 5 秒 +- **超时策略**: `waitForWorkflowComplete` 在 5 秒内轮询检查停止标志是否被删除 +- **超时处理**: 5 秒后直接返回,不影响工作流继续执行 +- **设计原因**: + - 避免前端长时间等待 + - 5 秒足够大多数节点完成当前操作 + - 用户体验优先,超时后前端可选择重试或放弃 + +**4. 并发停止请求** +- **处理方式**: 多次调用 `setAgentRuntimeStop` 是安全的,Redis SETEX 是幂等操作 +- **设计原因**: 避免用户多次点击停止按钮导致的问题 + +--- + +## 2. Redis 工具函数实现 + +**位置**: `packages/service/core/workflow/dispatch/workflowStatus.ts` + +```typescript +import { getLogger, LogCategories } from '../../../common/logger'; +import { getGlobalRedisConnection } from '../../../common/redis/index'; +import { delay } from '@fastgpt/global/common/system/utils'; + +const WORKFLOW_STATUS_PREFIX = 'agent_runtime_stopping'; +const TTL = 60; // 60秒 + +export const StopStatus = 'STOPPING'; + +export type WorkflowStatusParams = { + appId: string; + chatId: string; +}; + +// 获取工作流状态键 +export const getRuntimeStatusKey = (params: WorkflowStatusParams): string => { + return `${WORKFLOW_STATUS_PREFIX}:${params.appId}:${params.chatId}`; +}; + +// 设置停止标志 +export const setAgentRuntimeStop = async (params: WorkflowStatusParams): Promise => { + const redis = getGlobalRedisConnection(); + const key = getRuntimeStatusKey(params); + await redis.setex(key, TTL, 1); +}; + +// 删除停止标志 +export const delAgentRuntimeStopSign = async (params: WorkflowStatusParams): Promise => { + const redis = getGlobalRedisConnection(); + const key = getRuntimeStatusKey(params); + await redis.del(key).catch((err) => { + getLogger(LogCategories.MODULE.CHAT).error(`[Agent Runtime Stop] Delete stop sign error`, { err }); + }); +}; + +// 检查工作流是否应该停止 +export const shouldWorkflowStop = (params: WorkflowStatusParams): Promise => { + const redis = getGlobalRedisConnection(); + const key = getRuntimeStatusKey(params); + return redis + .get(key) + .then((res) => !!res) + .catch(() => false); +}; + +/** + * 等待工作流完成(停止标志被删除) + * @param params 工作流参数 + * @param timeout 超时时间(毫秒),默认5秒 + * @param pollInterval 轮询间隔(毫秒),默认50毫秒 + */ +export const waitForWorkflowComplete = async ({ + appId, + chatId, + timeout = 5000, + pollInterval = 50 +}: { + appId: string; + chatId: string; + timeout?: number; + pollInterval?: number; +}) => { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const sign = await shouldWorkflowStop({ appId, chatId }); + + // 如果停止标志已被删除,说明工作流已完成 + if (!sign) { + return; + } + + // 等待下一次轮询 + await delay(pollInterval); + } + + // 超时后直接返回 + return; +}; +``` + +**测试用例位置**: `test/cases/service/core/app/workflow/workflowStatus.test.ts` + +```typescript +import { describe, test, expect, beforeEach } from 'vitest'; +import { + setAgentRuntimeStop, + delAgentRuntimeStopSign, + shouldWorkflowStop, + waitForWorkflowComplete +} from '@fastgpt/service/core/workflow/dispatch/workflowStatus'; + +describe('Workflow Status Redis Functions', () => { + const testAppId = 'test_app_123'; + const testChatId = 'test_chat_456'; + + beforeEach(async () => { + // 清理测试数据 + await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId }); + }); + + test('should set stopping sign', async () => { + await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }); + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(true); + }); + + test('should return false for non-existent status', async () => { + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(false); + }); + + test('should return false after deleting stop sign', async () => { + await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }); + await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId }); + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(false); + }); + + test('should wait for workflow completion', async () => { + // 设置初始停止标志 + await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }); + + // 模拟异步完成(删除停止标志) + setTimeout(async () => { + await delAgentRuntimeStopSign({ appId: testAppId, chatId: testChatId }); + }, 500); + + // 等待完成 + await waitForWorkflowComplete({ + appId: testAppId, + chatId: testChatId, + timeout: 2000 + }); + + // 验证停止标志已被删除 + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(false); + }); + + test('should timeout when waiting too long', async () => { + await setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }); + + // 等待超时(不删除标志) + await waitForWorkflowComplete({ + appId: testAppId, + chatId: testChatId, + timeout: 100 + }); + + // 验证停止标志仍然存在 + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(true); + }); + + test('should handle concurrent stop sign operations', async () => { + // 并发设置停止标志 + await Promise.all([ + setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }), + setAgentRuntimeStop({ appId: testAppId, chatId: testChatId }) + ]); + + // 停止标志应该存在 + const shouldStop = await shouldWorkflowStop({ appId: testAppId, chatId: testChatId }); + expect(shouldStop).toBe(true); + }); +}); +``` + +## 3. 工作流停止检测机制改造 + +### 3.1 修改位置 + +**文件**: `packages/service/core/workflow/dispatch/index.ts` + +### 3.2 工作流启动时的停止检测机制 + +**改造点 1: 停止检测逻辑 (行 196-216)** + +使用内存变量 + 定时轮询 Redis 的方式: + +```typescript +import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus'; + +// 初始化停止检测 +let stopping = false; +const checkIsStopping = (): boolean => { + if (apiVersion === 'v2') { + return stopping; + } + if (apiVersion === 'v1') { + if (!res) return false; + return res.closed || !!res.errored; + } + return false; +}; + +// v2 版本: 启动定时器定期检查 Redis +const checkStoppingTimer = + apiVersion === 'v2' + ? setInterval(async () => { + stopping = await shouldWorkflowStop({ + appId: runningAppInfo.id, + chatId + }); + }, 100) + : undefined; +``` + +**设计要点**: +- v2 版本使用内存变量 `stopping` + 100ms 定时器轮询 Redis +- v1 版本仍使用原有的 `res.closed/res.errored` 检测 +- 轮询频率 100ms,平衡性能和响应速度 + +**改造点 2: 工作流完成后清理 (行 232-249)** + +```typescript +return runWorkflow({ + ...data, + checkIsStopping, // 传递检测函数 + query, + histories, + // ... 其他参数 +}).finally(async () => { + // 清理定时器 + if (streamCheckTimer) { + clearInterval(streamCheckTimer); + } + if (checkStoppingTimer) { + clearInterval(checkStoppingTimer); + } + + // Close mcpClient connections + Object.values(mcpClientMemory).forEach((client) => { + client.closeConnection(); + }); + + // 工作流完成后删除 Redis 记录 + await delAgentRuntimeStopSign({ + appId: runningAppInfo.id, + chatId + }); +}); +``` + +### 3.3 节点执行前的停止检测 + +**位置**: `packages/service/core/workflow/dispatch/index.ts:861-868` + +在 `checkNodeCanRun` 方法中,每个节点执行前检查: + +```typescript +private async checkNodeCanRun( + node: RuntimeNodeItemType, + skippedNodeIdList = new Set() +) { + // ... 其他检查逻辑 ... + + // Check queue status + if (data.maxRunTimes <= 0) { + getLogger(LogCategories.MODULE.CHAT).error('Max run times is 0', { + appId: data.runningAppInfo.id + }); + return; + } + + // 停止检测 + if (checkIsStopping()) { + getLogger(LogCategories.MODULE.CHAT).warn('Workflow stopped', { + appId: data.runningAppInfo.id, + nodeId: node.nodeId, + nodeName: node.name + }); + return; + } + + // ... 执行节点逻辑 ... +} +``` + +**说明**: +- 直接调用 `checkIsStopping()` 同步方法 +- 内部会检查内存变量 `stopping` +- 定时器每 100ms 更新一次该变量 +- 检测到停止时记录日志并直接返回,不执行节点 + +## 4. v2/chat/stop 接口设计 + +### 4.1 接口规范 + +**接口路径**: `/api/v2/chat/stop` + +**Schema 位置**: `packages/global/openapi/core/chat/api.ts` + +**接口文档位置**: `packages/global/openapi/core/chat/index.ts` + +**请求方法**: POST + +**请求参数**: +```typescript +// packages/global/openapi/core/chat/api.ts +export const StopV2ChatSchema = z + .object({ + appId: ObjectIdSchema.describe('应用ID'), + chatId: z.string().min(1).describe('对话ID'), + outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') + }); + +export type StopV2ChatParams = z.infer; +``` + +**响应格式**: +```typescript +export const StopV2ChatResponseSchema = z + .object({ + success: z.boolean().describe('是否成功停止') + }); + +export type StopV2ChatResponse = z.infer; +``` + +### 4.2 接口实现 + +**文件位置**: `projects/app/src/pages/api/v2/chat/stop.ts` + +```typescript +import type { NextApiRequest, NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import { authChatCrud } from '@/service/support/permission/auth/chat'; +import { + setAgentRuntimeStop, + waitForWorkflowComplete +} from '@fastgpt/service/core/workflow/dispatch/workflowStatus'; +import { StopV2ChatSchema, type StopV2ChatResponse } from '@fastgpt/global/openapi/core/chat/controler/api'; + +async function handler(req: NextApiRequest, res: NextApiResponse): Promise { + const { appId, chatId, outLinkAuthData } = StopV2ChatSchema.parse(req.body); + + // 鉴权 (复用聊天 CRUD 鉴权) + await authChatCrud({ + req, + authToken: true, + authApiKey: true, + appId, + chatId, + ...outLinkAuthData + }); + + // 设置停止标志 + await setAgentRuntimeStop({ + appId, + chatId + }); + + // 等待工作流完成 (最多等待 5 秒) + await waitForWorkflowComplete({ appId, chatId, timeout: 5000 }); + + return { + success: true + }; +} + +export default NextAPI(handler); +``` + +**接口文档** (`packages/global/openapi/core/chat/index.ts`): + +```typescript +export const ChatPath: OpenAPIPath = { + // ... 其他路径 + + '/v2/chat/stop': { + post: { + summary: '停止 Agent 运行', + description: `优雅停止正在运行的 Agent, 会尝试等待当前节点结束后返回,最长 5s,超过 5s 仍未结束,则会返回成功。 +LLM 节点,流输出时会同时被终止,但 HTTP 请求节点这种可能长时间运行的,不会被终止。`, + tags: [TagsMap.chatPage], + requestBody: { + content: { + 'application/json': { + schema: StopV2ChatSchema + } + } + }, + responses: { + 200: { + description: '成功停止工作流', + content: { + 'application/json': { + schema: StopV2ChatResponseSchema + } + } + } + } + } + } +}; +``` + +**说明**: +- 接口使用 `authChatCrud` 进行鉴权,支持 Token 和 API Key +- 支持分享链接和团队空间的鉴权数据 +- 设置停止标志后等待最多 5 秒 +- 无论是否超时,都返回 `success: true` + +## 5. 前端改造 + +由于当前代码已经能够正常工作,且 v2 版本的后端已经实现了基于 Redis 的停止机制,前端可以保持现有的简单实现: + +**保持现有实现的原因**: +1. 后端已经通过定时器轮询 Redis 实现了停止检测 +2. 前端调用 `abort()` 后,后端会在下个检测周期(100ms内)发现停止标志 +3. 简化前端逻辑,避免增加复杂性 +4. 用户体验上,立即中断连接响应更快 + +**可选的增强方案**: + +如果需要在前端显示更详细的停止状态,可以添加 API 客户端函数: + +**文件位置**: `projects/app/src/web/core/chat/api.ts` + +```typescript +import { POST } from '@/web/common/api/request'; +import type { StopV2ChatParams, StopV2ChatResponse } from '@fastgpt/global/openapi/core/chat/controler/api'; + +/** + * 停止 v2 版本工作流运行 + */ +export const stopV2Chat = (data: StopV2ChatParams) => + POST('/api/v2/chat/stop', data); +``` + +**增强的 abortRequest 函数**: + +```typescript +/* Abort chat completions, questionGuide */ +const abortRequest = useMemoizedFn(async (reason: string = 'stop') => { + // 先调用 abort 中断连接 + chatController.current?.abort(new Error(reason)); + questionGuideController.current?.abort(new Error(reason)); + pluginController.current?.abort(new Error(reason)); + + // v2 版本: 可选地通知后端优雅停止 + if (chatBoxData?.app?.version === 'v2' && appId && chatId) { + try { + await stopV2Chat({ + appId, + chatId, + outLinkAuthData + }); + } catch (error) { + // 静默失败,不影响用户体验 + console.warn('Failed to notify backend to stop workflow', error); + } + } +}); +``` + +**建议**: +- **推荐**: 保持当前简单实现,后端已经足够健壮 +- **可选**: 如果需要更精确的停止状态追踪,可以实现上述增强方案 + +## 6. 完整调用流程 + +### 6.1 正常停止流程 + +``` +用户点击停止按钮 + ↓ +前端: abortRequest() + ↓ +前端: chatController.abort() [立即中断 HTTP 连接] + ↓ +[可选] 前端: POST /api/v2/chat/stop + ↓ +后端: setAgentRuntimeStop(appId, chatId) [设置停止标志] + ↓ +后端: 定时器检测到 Redis 停止标志,更新内存变量 stopping = true + ↓ +后端: 下个节点执行前 checkIsStopping() 返回 true + ↓ +后端: 停止处理新节点,记录日志 + ↓ +后端: 工作流 finally 块删除 Redis 停止标志 + ↓ +[可选] 后端: waitForWorkflowComplete() 检测到停止标志被删除 + ↓ +[可选] 前端: 显示停止成功提示 +``` + +### 6.2 超时流程 + +``` +[可选] 前端: POST /api/v2/chat/stop + ↓ +后端: setAgentRuntimeStop(appId, chatId) + ↓ +后端: waitForWorkflowComplete(timeout=5s) + ↓ +后端: 5秒后停止标志仍存在 + ↓ +后端: 返回成功响应 (不区分超时) + ↓ +[可选] 前端: 显示成功提示 + ↓ +后端: 工作流继续运行,最终完成后删除停止标志 +``` + +### 6.3 工作流自然完成流程 + +``` +工作流运行中 + ↓ +所有节点执行完成 + ↓ +dispatchWorkFlow.finally() + ↓ +删除 Redis 停止标志 + ↓ +清理定时器 + ↓ +60秒 TTL 确保即使删除失败也会自动清理 +``` + +### 6.4 时序说明 + +**关键时间点**: +- **100ms**: 后端定时器检查 Redis 停止标志的频率 +- **5s**: stop 接口等待工作流完成的超时时间 +- **60s**: Redis 键的 TTL,自动清理时间 + +**响应时间**: +- 用户点击停止 → HTTP 连接中断: **立即** (前端 abort) +- 停止标志写入 Redis: **< 50ms** (Redis SETEX 操作) +- 后端检测到停止: **< 100ms** (定时器轮询周期) +- 当前节点停止执行: **取决于节点类型** + - LLM 流式输出: **立即**中断流 + - HTTP 请求节点: **等待请求完成** + - 其他节点: **等待当前操作完成** + +## 7. 测试策略 + +### 7.1 单元测试 + +**Redis 工具函数测试**: +- `setAgentRuntimeStop` / `shouldWorkflowStop` 基本功能 +- `delAgentRuntimeStopSign` 删除功能 +- `waitForWorkflowComplete` 等待机制和超时 +- 并发操作安全性 + +**文件位置**: `test/cases/service/core/app/workflow/workflowStatus.test.ts` + +**测试用例**: +```typescript +describe('Workflow Status Redis Functions', () => { + test('should set stopping sign') + test('should return false for non-existent status') + test('should detect stopping status') + test('should return false after deleting stop sign') + test('should wait for workflow completion') + test('should timeout when waiting too long') + test('should delete workflow stop sign') + test('should handle concurrent stop sign operations') +}); +``` + diff --git a/.codex/design/core/dataset/index.md b/.codex/design/core/dataset/index.md new file mode 100644 index 0000000000..1998352d6c --- /dev/null +++ b/.codex/design/core/dataset/index.md @@ -0,0 +1,1130 @@ +# FastGPT 知识库(Dataset)模块架构说明 + +## 概述 + +FastGPT 知识库模块是一个基于 MongoDB + PostgreSQL(向量数据库) 的 RAG(检索增强生成)知识库系统,支持多种数据源导入、智能文档分块、向量化索引、混合检索等核心能力。 + +## 核心概念层次结构 + +``` +Dataset (知识库) + ├── DatasetCollection (文档集合/文件) + │ ├── DatasetData (数据块/Chunk) + │ │ ├── indexes[] (向量索引) + │ │ └── history[] (历史版本) + │ └── DatasetTraining (训练队列) + └── Tag (标签系统) +``` + +### 1. Dataset (知识库) +- **作用**: 最顶层容器,可以是普通知识库、文件夹、网站知识库或外部数据源 +- **类型**: + - `folder`: 文件夹组织 + - `dataset`: 普通知识库 + - `websiteDataset`: 网站深度链接 + - `apiDataset`: API 数据集 + - `feishu`: 飞书知识库 + - `yuque`: 语雀知识库 + - `externalFile`: 外部文件 + +### 2. DatasetCollection (文档集合) +- **作用**: 知识库中的具体文件或文档,承载原始数据 +- **类型**: + - `folder`: 文件夹 + - `file`: 本地文件 + - `link`: 单个链接 + - `apiFile`: API 文件 + - `images`: 图片集合 + - `virtual`: 虚拟集合 + +### 3. DatasetData (数据块) +- **作用**: 文档分块后的最小知识单元,实际检索的对象 +- **核心字段**: + - `q`: 问题或大块文本 + - `a`: 答案或自定义内容 + - `indexes[]`: 向量索引列表(可多个) + - `chunkIndex`: 块索引位置 + - `imageId`: 关联图片ID + - `history[]`: 修改历史 + +### 4. DatasetTraining (训练队列) +- **作用**: 异步训练任务队列,负责向量化和索引生成 +- **训练模式**: + - `chunk`: 文本分块 + - `qa`: 问答对 + - `image`: 图像处理 + - `imageParse`: 图像解析 + +## 代码目录结构 + +### Packages 层(共享代码) + +#### 1. packages/global/core/dataset/ +**类型定义和常量** +``` +├── constants.ts # 所有枚举定义(类型、状态、模式) +├── type.d.ts # TypeScript 类型定义 +├── api.d.ts # API 接口类型 +├── controller.d.ts # 控制器类型定义 +├── utils.ts # 通用工具函数 +├── collection/ +│ ├── constants.ts # 集合相关常量 +│ └── utils.ts # 集合工具函数 +├── data/ +│ └── constants.ts # 数据相关常量 +├── training/ +│ ├── type.d.ts # 训练相关类型 +│ └── utils.ts # 训练工具函数 +├── apiDataset/ +│ ├── type.d.ts # API数据集类型 +│ └── utils.ts # API数据集工具 +└── search/ + └── utils.ts # 搜索工具函数 +``` + +**关键枚举定义**: +- `DatasetTypeEnum`: 知识库类型 +- `DatasetCollectionTypeEnum`: 集合类型 +- `DatasetSearchModeEnum`: 搜索模式(embedding/fullText/mixed) +- `TrainingModeEnum`: 训练模式 +- `DatasetCollectionDataProcessModeEnum`: 数据处理模式 + +#### 2. packages/service/core/dataset/ +**业务逻辑和数据库操作** +``` +├── schema.ts # Dataset MongoDB Schema +├── controller.ts # Dataset 核心控制器 +├── utils.ts # 业务工具函数 +├── collection/ +│ ├── schema.ts # Collection Schema +│ ├── controller.ts # Collection 控制器 +│ └── utils.ts # Collection 工具 +├── data/ +│ ├── schema.ts # DatasetData Schema +│ ├── dataTextSchema.ts # 全文搜索 Schema +│ └── controller.ts # Data 控制器 +├── training/ +│ ├── schema.ts # Training Schema +│ ├── controller.ts # Training 控制器 +│ └── constants.ts # Training 常量 +├── tag/ +│ └── schema.ts # Tag Schema +├── image/ +│ ├── schema.ts # Image Schema +│ └── utils.ts # Image 工具 +├── search/ +│ ├── controller.ts # 🔥 核心检索控制器 +│ └── utils.ts # 检索工具函数 +└── apiDataset/ + ├── index.ts # API数据集入口 + ├── custom/api.ts # 自定义API + ├── feishuDataset/api.ts # 飞书集成 + └── yuqueDataset/api.ts # 语雀集成 +``` + +### Projects 层(应用实现) + +#### 3. projects/app/src/pages/api/core/dataset/ +**NextJS API 路由** +``` +├── detail.ts # 获取知识库详情 +├── delete.ts # 删除知识库 +├── paths.ts # 获取路径信息 +├── exportAll.ts # 导出全部数据 +├── collection/ +│ ├── create.ts # 创建集合(基础) +│ ├── create/ +│ │ ├── localFile.ts # 本地文件导入 +│ │ ├── link.ts # 链接导入 +│ │ ├── text.ts # 文本导入 +│ │ ├── images.ts # 图片导入 +│ │ ├── apiCollection.ts # API集合 +│ │ └── fileId.ts # 文件ID导入 +│ ├── update.ts # 更新集合 +│ ├── list.ts # 集合列表 +│ ├── detail.ts # 集合详情 +│ ├── sync.ts # 同步集合 +│ └── export.ts # 导出集合 +├── data/ +│ ├── list.ts # 数据列表 +│ ├── detail.ts # 数据详情 +│ ├── insertData.ts # 插入数据 +│ ├── pushData.ts # 推送数据 +│ ├── update.ts # 更新数据 +│ └── delete.ts # 删除数据 +├── training/ +│ ├── getDatasetTrainingQueue.ts # 获取训练队列 +│ ├── getTrainingDataDetail.ts # 训练数据详情 +│ ├── updateTrainingData.ts # 更新训练数据 +│ ├── deleteTrainingData.ts # 删除训练数据 +│ └── getTrainingError.ts # 获取训练错误 +└── apiDataset/ + ├── list.ts # API数据集列表 + ├── getCatalog.ts # 获取目录 + └── getPathNames.ts # 获取路径名 +``` + +#### 4. projects/app/src/components/ 和 pageComponents/ +**前端组件** +``` +components/core/dataset/ # 通用组件 +├── SelectModal.tsx # 知识库选择器 +├── QuoteItem.tsx # 引用项展示 +├── DatasetTypeTag.tsx # 类型标签 +├── RawSourceBox.tsx # 原始来源展示 +└── SearchParamsTip.tsx # 搜索参数提示 + +pageComponents/dataset/ # 页面组件 +├── list/ # 列表页 +│ └── SideTag.tsx # 侧边标签 +├── detail/ # 详情页 +│ ├── CollectionCard/ # 集合卡片 +│ ├── DataCard.tsx # 数据卡片 +│ ├── Test.tsx # 测试组件 +│ ├── Info/ # 信息组件 +│ ├── Import/ # 导入组件 +│ │ ├── diffSource/ # 不同数据源 +│ │ ├── components/ # 公共组件 +│ │ └── commonProgress/ # 进度组件 +│ └── Form/ # 表单组件 +└── ApiDatasetForm.tsx # API数据集表单 +``` + +## 数据库 Schema 详解 + +### 1. Dataset Schema (datasets 集合) +```typescript +{ + _id: ObjectId, + parentId: ObjectId | null, // 父级ID(支持文件夹) + teamId: ObjectId, // 团队ID + tmbId: ObjectId, // 团队成员ID + type: DatasetTypeEnum, // 知识库类型 + avatar: string, // 头像 + name: string, // 名称 + intro: string, // 简介 + updateTime: Date, // 更新时间 + + vectorModel: string, // 向量模型 + agentModel: string, // AI模型 + vlmModel?: string, // 视觉语言模型 + + websiteConfig?: { // 网站配置 + url: string, + selector: string + }, + + chunkSettings: { // 分块配置 + trainingType: DatasetCollectionDataProcessModeEnum, + chunkTriggerType: ChunkTriggerConfigTypeEnum, + chunkTriggerMinSize: number, + chunkSettingMode: ChunkSettingModeEnum, + chunkSplitMode: DataChunkSplitModeEnum, + chunkSize: number, + chunkSplitter: string, + indexSize: number, + qaPrompt: string, + // ... 更多配置 + }, + + inheritPermission: boolean, // 继承权限 + apiDatasetServer?: object // API服务器配置 +} + +// 索引 +teamId_1 +type_1 +``` + +### 2. DatasetCollection Schema (dataset_collections 集合) +```typescript +{ + _id: ObjectId, + parentId: ObjectId | null, // 父级集合 + teamId: ObjectId, + tmbId: ObjectId, + datasetId: ObjectId, // 所属知识库 + + type: DatasetCollectionTypeEnum, // 集合类型 + name: string, // 名称 + tags: string[], // 标签ID列表 + + createTime: Date, + updateTime: Date, + + // 元数据(根据类型不同) + fileId?: ObjectId, // 本地文件ID + rawLink?: string, // 原始链接 + apiFileId?: string, // API文件ID + externalFileId?: string, // 外部文件ID + externalFileUrl?: string, // 外部导入URL + + rawTextLength?: number, // 原始文本长度 + hashRawText?: string, // 文本哈希 + metadata?: object, // 其他元数据 + + forbid: boolean, // 是否禁用 + + // 解析配置 + customPdfParse?: boolean, + apiFileParentId?: string, + + // 分块配置(继承自 ChunkSettings) + ...chunkSettings +} + +// 索引 +teamId_1_fileId_1 +teamId_1_datasetId_1_parentId_1_updateTime_-1 +teamId_1_datasetId_1_tags_1 +teamId_1_datasetId_1_createTime_1 +datasetId_1_externalFileId_1 (unique) +``` + +### 3. DatasetData Schema (dataset_datas 集合) +```typescript +{ + _id: ObjectId, + teamId: ObjectId, + tmbId: ObjectId, + datasetId: ObjectId, + collectionId: ObjectId, + + q: string, // 问题/大块文本 + a?: string, // 答案/自定义内容 + imageId?: string, // 图片ID + imageDescMap?: object, // 图片描述映射 + + updateTime: Date, + chunkIndex: number, // 块索引 + + indexes: [{ // 向量索引数组 + type: DatasetDataIndexTypeEnum, + dataId: string, // PG向量数据ID + text: string // 索引文本 + }], + + history?: [{ // 历史版本 + q: string, + a?: string, + updateTime: Date + }], + + rebuilding?: boolean // 重建中标志 +} + +// 索引 +teamId_1_datasetId_1_collectionId_1_chunkIndex_1_updateTime_-1 +teamId_1_datasetId_1_collectionId_1_indexes.dataId_1 +rebuilding_1_teamId_1_datasetId_1 +``` + +### 4. DatasetTraining Schema (dataset_trainings 集合) +```typescript +{ + _id: ObjectId, + teamId: ObjectId, + tmbId: ObjectId, + datasetId: ObjectId, + collectionId: ObjectId, + billId?: string, // 账单ID + + mode: TrainingModeEnum, // 训练模式 + + expireAt: Date, // 过期时间(7天自动删除) + lockTime: Date, // 锁定时间 + retryCount: number, // 重试次数 + + q: string, // 待训练问题 + a: string, // 待训练答案 + imageId?: string, + imageDescMap?: object, + chunkIndex: number, + indexSize?: number, + weight: number, // 权重 + + dataId?: ObjectId, // 关联的DatasetData ID + + indexes: [{ // 待生成的索引 + type: DatasetDataIndexTypeEnum, + text: string + }], + + errorMsg?: string // 错误信息 +} + +// 索引 +teamId_1_datasetId_1 +mode_1_retryCount_1_lockTime_1_weight_-1 +expireAt_1 (TTL: 7 days) +``` + +### 5. 辅助 Schema + +#### DatasetCollectionTags (dataset_collection_tags) +```typescript +{ + _id: ObjectId, + teamId: ObjectId, + datasetId: ObjectId, + tag: string // 标签名称 +} +``` + +#### DatasetDataText (dataset_data_texts) - 全文搜索 +```typescript +{ + _id: ObjectId, + teamId: ObjectId, + datasetId: ObjectId, + collectionId: ObjectId, + dataId: ObjectId, // 关联 DatasetData + fullTextToken: string // 全文搜索Token +} + +// 全文索引 +fullTextToken: text +``` + +## 核心业务流程 + +### 1. 数据导入流程 + +``` +用户上传文件/链接 + ↓ +创建 DatasetCollection + ↓ +文件解析 & 预处理 + ↓ +文本分块(根据 ChunkSettings) + ↓ +创建 DatasetTraining 任务 + ↓ +后台队列处理: + - 向量化(embedding) + - 创建 PG 向量索引 + - 生成 DatasetData + - 创建全文搜索索引(DatasetDataText) + ↓ +训练完成,可以检索 +``` + +**关键代码位置**: +- 文件上传: `projects/app/src/pages/api/core/dataset/collection/create/localFile.ts` +- 分块逻辑: `packages/service/core/dataset/collection/utils.ts` +- 训练控制: `packages/service/core/dataset/training/controller.ts` + +### 2. 检索流程(核心算法) + +**位置**: `packages/service/core/dataset/search/controller.ts` + +```typescript +// 三种检索模式 +enum DatasetSearchModeEnum { + embedding = 'embedding', // 纯向量检索 + fullTextRecall = 'fullTextRecall', // 纯全文检索 + mixedRecall = 'mixedRecall' // 混合检索 +} + +// 检索流程 +async function searchDatasetData(props) { + // 1. 参数初始化和权重配置 + const { embeddingWeight, rerankWeight } = props; + + // 2. 集合过滤(标签/时间/禁用) + const filterCollectionIds = await filterCollectionByMetadata(); + + // 3. 多路召回 + const { embeddingRecallResults, fullTextRecallResults } = + await multiQueryRecall({ + embeddingLimit: 80, // 向量召回数量 + fullTextLimit: 60 // 全文召回数量 + }); + + // 4. RRF(倒数排名融合)合并 + const rrfResults = datasetSearchResultConcat([ + { weight: embeddingWeight, list: embeddingRecallResults }, + { weight: 1 - embeddingWeight, list: fullTextRecallResults } + ]); + + // 5. ReRank 重排序(可选) + if (usingReRank) { + const reRankResults = await datasetDataReRank({ + rerankModel, + query: reRankQuery, + data: rrfResults + }); + } + + // 6. 相似度过滤 + const scoreFiltered = results.filter(item => + item.score >= similarity + ); + + // 7. Token 限制过滤 + const finalResults = await filterDatasetDataByMaxTokens( + scoreFiltered, + maxTokens + ); + + return finalResults; +} +``` + +**核心算法详解**: + +#### a. 向量召回 (embeddingRecall) +```typescript +// 1. 查询向量化 +const { vectors, tokens } = await getVectorsByText({ + model: getEmbeddingModel(model), + input: queries, + type: 'query' +}); + +// 2. PG 向量库召回 +const recallResults = await Promise.all( + vectors.map(vector => + recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }) + ) +); + +// 3. 关联 MongoDB 数据 +const dataMaps = await MongoDatasetData.find({ + teamId, + datasetId: { $in: datasetIds }, + 'indexes.dataId': { $in: indexDataIds } +}); +``` + +#### b. 全文召回 (fullTextRecall) +```typescript +// MongoDB 全文搜索 +const results = await MongoDatasetDataText.aggregate([ + { + $match: { + teamId: new Types.ObjectId(teamId), + $text: { $search: await jiebaSplit({ text: query }) }, + datasetId: { $in: datasetIds.map(id => new Types.ObjectId(id)) } + } + }, + { + $sort: { + score: { $meta: 'textScore' } + } + }, + { + $limit: limit + } +]); +``` + +#### c. RRF 合并算法 +```typescript +// 倒数排名融合(Reciprocal Rank Fusion) +function datasetSearchResultConcat(weightedLists) { + const k = 60; // RRF 参数 + const scoreMap = new Map(); + + for (const { weight, list } of weightedLists) { + list.forEach((item, index) => { + const rrfScore = weight / (k + index + 1); + scoreMap.set(item.id, + (scoreMap.get(item.id) || 0) + rrfScore + ); + }); + } + + return Array.from(scoreMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id]) => findItemById(id)); +} +``` + +#### d. ReRank 重排序 +```typescript +// 使用重排序模型(如 bge-reranker) +const { results } = await reRankRecall({ + model: rerankModel, + query: reRankQuery, + documents: data.map(item => ({ + id: item.id, + text: `${item.q}\n${item.a}` + })) +}); + +// 重排序结果融合到 RRF 结果 +const finalResults = datasetSearchResultConcat([ + { weight: 1 - rerankWeight, list: rrfResults }, + { weight: rerankWeight, list: reRankResults } +]); +``` + +### 3. 分块策略 + +**位置**: `packages/global/core/dataset/constants.ts` + +```typescript +// 分块模式 +enum DataChunkSplitModeEnum { + paragraph = 'paragraph', // 段落分割(智能) + size = 'size', // 固定大小分割 + char = 'char' // 字符分隔符分割 +} + +// AI 段落模式 +enum ParagraphChunkAIModeEnum { + auto = 'auto', // 自动判断 + force = 'force', // 强制使用AI + forbid = 'forbid' // 禁用AI +} + +// 分块配置示例 +const chunkSettings = { + chunkSplitMode: 'paragraph', + chunkSize: 512, // 最大块大小 + chunkSplitter: '\n', // 分隔符 + paragraphChunkDeep: 2, // 段落层级 + paragraphChunkMinSize: 100, // 最小段落大小 + indexSize: 256, // 索引大小 + // 数据增强 + dataEnhanceCollectionName: true, + autoIndexes: true, // 自动多索引 + indexPrefixTitle: true // 索引前缀标题 +} +``` + +### 4. 训练队列机制 + +**位置**: `packages/service/core/dataset/training/controller.ts` + +```typescript +// 训练队列调度 +class TrainingQueue { + // 1. 获取待训练任务(按权重排序) + async getNextTrainingTask() { + return MongoDatasetTraining.findOne({ + mode: { $in: supportedModes }, + retryCount: { $gt: 0 }, + lockTime: { $lt: new Date(Date.now() - lockTimeout) } + }) + .sort({ weight: -1, lockTime: 1 }) + .limit(1); + } + + // 2. 锁定任务 + async lockTask(taskId) { + await MongoDatasetTraining.updateOne( + { _id: taskId }, + { $set: { lockTime: new Date() } } + ); + } + + // 3. 执行向量化 + async processTask(task) { + const vectors = await getVectorsByText({ + model: getEmbeddingModel(task.model), + input: task.indexes.map(i => i.text) + }); + + // 保存到 PG 向量库 + const indexDataIds = await saveToVectorDB(vectors); + + // 创建 DatasetData + await MongoDatasetData.create({ + ...task, + indexes: task.indexes.map((idx, i) => ({ + ...idx, + dataId: indexDataIds[i] + })) + }); + } + + // 4. 完成/失败处理 + async completeTask(taskId, success, error) { + if (success) { + await MongoDatasetTraining.deleteOne({ _id: taskId }); + } else { + await MongoDatasetTraining.updateOne( + { _id: taskId }, + { + $inc: { retryCount: -1 }, + $set: { + errorMsg: error, + lockTime: new Date('2000/1/1') + } + } + ); + } + } +} +``` + +## 关键技术点 + +### 1. 多索引机制 + +**为什么需要多索引?** +- 大块文本可以拆分为多个小索引,提高召回精度 +- 支持不同粒度的检索(粗粒度+细粒度) + +```typescript +// DatasetData 中的 indexes 数组 +{ + q: "这是一段很长的文本...", + indexes: [ + { + type: 'custom', // 自定义索引 + dataId: 'pg_vector_id_1', + text: "第一部分索引文本" + }, + { + type: 'custom', + dataId: 'pg_vector_id_2', + text: "第二部分索引文本" + } + ] +} +``` + +### 2. 混合检索(Hybrid Search) + +**结合向量检索和全文检索的优势**: +- 向量检索: 语义相似度,理解意图 +- 全文检索: 精确匹配关键词,高召回 +- RRF 融合: 互补优势,提升整体效果 + +**权重配置**: +```typescript +{ + searchMode: 'mixedRecall', + embeddingWeight: 0.5, // 向量权重 + // fullTextWeight = 1 - 0.5 = 0.5 + + usingReRank: true, + rerankWeight: 0.7 // 重排序权重 +} +``` + +### 3. 集合过滤(Collection Filter) + +**支持灵活的元数据过滤**: +```typescript +// 标签过滤 +{ + tags: { + $and: ["标签1", "标签2"], // 必须同时包含 + $or: ["标签3", "标签4", null] // 包含任一,null表示无标签 + } +} + +// 时间过滤 +{ + createTime: { + $gte: '2024-01-01', + $lte: '2024-12-31' + } +} +``` + +### 4. 向量数据库架构 + +**双数据库架构**: +``` +MongoDB (元数据 + 全文索引) + - 存储原始文本、配置、关系 + - 全文搜索索引(jieba 分词) + +PostgreSQL + pgvector (向量存储) + - 高维向量存储 + - 高效余弦相似度检索 + - HNSW 索引加速 +``` + +**数据流转**: +``` +原始文本 → Embedding API → 向量 → PG 存储 + ↓ + 索引ID 存回 MongoDB + +检索时: +查询文本 → 向量 → PG 召回 topK → +获取 dataIds → MongoDB 查询完整数据 +``` + +### 5. 图片知识库 + +**特殊的图片处理流程**: +```typescript +// 1. 图片上传 +{ + type: 'images', + imageId: 'image_storage_id' +} + +// 2. 图片向量化(VLM) +const imageVector = await getImageEmbedding({ + model: vlmModel, + imageId +}); + +// 3. 图片描述映射 +{ + imageDescMap: { + 'image_url_1': '这是一张产品图片', + 'image_url_2': '这是一张流程图' + } +} + +// 4. 检索时返回预签名URL +const previewUrl = getDatasetImagePreviewUrl({ + imageId, + teamId, + datasetId, + expiredMinutes: 60 * 24 * 7 // 7天有效 +}); +``` + +## API 路由映射 + +### Dataset 基础操作 +``` +GET /api/core/dataset/detail # 获取知识库详情 +DELETE /api/core/dataset/delete # 删除知识库 +GET /api/core/dataset/paths # 获取路径 +POST /api/core/dataset/exportAll # 导出全部 +``` + +### Collection 操作 +``` +POST /api/core/dataset/collection/create # 创建集合 +POST /api/core/dataset/collection/create/localFile # 本地文件 +POST /api/core/dataset/collection/create/link # 链接导入 +POST /api/core/dataset/collection/create/text # 文本导入 +POST /api/core/dataset/collection/create/images # 图片导入 +PUT /api/core/dataset/collection/update # 更新集合 +GET /api/core/dataset/collection/list # 集合列表 +GET /api/core/dataset/collection/detail # 集合详情 +POST /api/core/dataset/collection/sync # 同步集合 +GET /api/core/dataset/collection/export # 导出集合 +``` + +### Data 操作 +``` +GET /api/core/dataset/data/list # 数据列表 +GET /api/core/dataset/data/detail # 数据详情 +POST /api/core/dataset/data/insertData # 插入数据 +POST /api/core/dataset/data/pushData # 推送数据(批量) +PUT /api/core/dataset/data/update # 更新数据 +DELETE /api/core/dataset/data/delete # 删除数据 +``` + +### Training 操作 +``` +GET /api/core/dataset/training/getDatasetTrainingQueue # 训练队列 +GET /api/core/dataset/training/getTrainingDataDetail # 训练详情 +PUT /api/core/dataset/training/updateTrainingData # 更新训练 +DELETE /api/core/dataset/training/deleteTrainingData # 删除训练 +GET /api/core/dataset/training/getTrainingError # 获取错误 +``` + +## 前端状态管理 + +**位置**: `projects/app/src/web/core/dataset/store/` + +```typescript +// dataset.ts - 知识库状态 +{ + datasets: DatasetListItemType[], + currentDataset: DatasetItemType, + loadDatasets: () => Promise, + createDataset: (data) => Promise, + updateDataset: (data) => Promise, + deleteDataset: (id) => Promise +} + +// searchTest.ts - 搜索测试状态 +{ + searchQuery: string, + searchMode: DatasetSearchModeEnum, + similarity: number, + limit: number, + searchResults: SearchDataResponseItemType[], + performSearch: () => Promise +} +``` + +## 性能优化要点 + +### 1. 索引优化 +```javascript +// 核心复合索引 +DatasetCollection: + - { teamId: 1, datasetId: 1, parentId: 1, updateTime: -1 } + - { teamId: 1, datasetId: 1, tags: 1 } + +DatasetData: + - { teamId: 1, datasetId: 1, collectionId: 1, chunkIndex: 1, updateTime: -1 } + - { teamId: 1, datasetId: 1, collectionId: 1, 'indexes.dataId': 1 } + +DatasetTraining: + - { mode: 1, retryCount: 1, lockTime: 1, weight: -1 } +``` + +### 2. 查询优化 +```typescript +// 使用从库读取(降低主库压力) +const readFromSecondary = { + readPreference: 'secondaryPreferred' +}; + +MongoDatasetData.find(query, fields, { + ...readFromSecondary +}).lean(); +``` + +### 3. 分页优化 +```typescript +// 使用 scrollList 而非传统分页 +// 避免深度分页性能问题 +GET /api/core/dataset/collection/scrollList?lastId=xxx&limit=20 +``` + +### 4. 缓存策略 +```typescript +// Redis 缓存热门检索结果 +const cacheKey = `dataset:search:${hashQuery(query)}`; +const cached = await redis.get(cacheKey); +if (cached) return JSON.parse(cached); + +// 缓存 5 分钟 +await redis.setex(cacheKey, 300, JSON.stringify(results)); +``` + +## 测试覆盖 + +**测试文件位置**: `projects/app/test/api/core/dataset/` + +``` +├── create.test.ts # 知识库创建 +├── paths.test.ts # 路径测试 +├── collection/ +│ └── paths.test.ts # 集合路径 +└── training/ + ├── deleteTrainingData.test.ts # 训练删除 + ├── getTrainingError.test.ts # 训练错误 + └── updateTrainingData.test.ts # 训练更新 +``` + +## 常见开发任务 + +### 1. 添加新的数据源类型 + +**步骤**: +1. 在 `packages/global/core/dataset/constants.ts` 添加新类型枚举 +2. 在 `packages/service/core/dataset/apiDataset/` 创建新集成 +3. 在 `projects/app/src/pages/api/core/dataset/collection/create/` 添加 API 路由 +4. 在 `projects/app/src/pageComponents/dataset/detail/Import/diffSource/` 添加前端组件 + +### 2. 修改检索算法 + +**核心文件**: `packages/service/core/dataset/search/controller.ts` + +关键函数: +- `embeddingRecall`: 向量召回逻辑 +- `fullTextRecall`: 全文召回逻辑 +- `datasetSearchResultConcat`: RRF 融合算法 +- `datasetDataReRank`: 重排序逻辑 + +### 3. 优化分块策略 + +**核心文件**: `packages/service/core/dataset/collection/utils.ts` + +关键逻辑: +- 段落识别 +- 智能合并小块 +- 标题提取 +- 多索引生成 + +### 4. 添加新的训练模式 + +**步骤**: +1. 在 `TrainingModeEnum` 添加新模式 +2. 在 `packages/service/core/dataset/training/controller.ts` 添加处理逻辑 +3. 更新训练队列调度器 + +## 依赖关系图 + +``` +Dataset (1:N) + ├─→ DatasetCollection (1:N) + │ ├─→ DatasetData (1:N) + │ │ └─→ PG Vectors (1:N) + │ └─→ DatasetTraining (1:N) + │ └─→ Bills (1:1) + └─→ DatasetCollectionTags (1:N) + └─→ DatasetCollection.tags[] (N:M) +``` + +## 权限系统 + +**位置**: `packages/global/support/permission/dataset/` + +```typescript +// 权限级别 +enum PermissionTypeEnum { + owner = 'owner', // 所有者 + manage = 'manage', // 管理员 + write = 'write', // 编辑 + read = 'read' // 只读 +} + +// 权限继承 +{ + inheritPermission: true // 从父级继承权限 +} + +// 协作者管理 +DatasetCollaborators: { + datasetId, + tmbId, + permission: PermissionTypeEnum +} +``` + +## 国际化 + +**位置**: `packages/web/i18n/` + +```typescript +// 知识库相关翻译 key +'dataset:common_dataset' +'dataset:folder_dataset' +'dataset:website_dataset' +'dataset:api_file' +'dataset:sync_collection_failed' +'dataset:training.Image mode' +// ... 更多 +``` + +## 调试技巧 + +### 1. 查看训练队列状态 +```javascript +// MongoDB Shell +db.dataset_trainings.find({ + teamId: ObjectId('xxx') +}).sort({ weight: -1, lockTime: 1 }).limit(10) +``` + +### 2. 检查向量索引 +```javascript +// PG SQL +SELECT datasetid, count(*) +FROM pg_vectors +GROUP BY datasetid; +``` + +### 3. 全文搜索测试 +```javascript +db.dataset_data_texts.find({ + $text: { $search: "测试查询" } +}, { + score: { $meta: "textScore" } +}).sort({ score: { $meta: "textScore" } }) +``` + +### 4. 查看检索日志 +```typescript +// 开启详细日志 +searchDatasetData({ + ...props, + debug: true // 输出详细召回信息 +}) +``` + +## 最佳实践 + +### 1. 分块大小设置 +- **短文档**: `chunkSize: 256-512` +- **长文档**: `chunkSize: 512-1024` +- **FAQ**: `chunkSize: 128-256` + +### 2. 检索参数调优 +```typescript +// 高精度场景(客服) +{ + searchMode: 'mixedRecall', + similarity: 0.7, // 较高阈值 + embeddingWeight: 0.6, // 偏向语义 + usingReRank: true, + rerankWeight: 0.8 +} + +// 高召回场景(搜索) +{ + searchMode: 'mixedRecall', + similarity: 0.4, // 较低阈值 + embeddingWeight: 0.4, // 偏向全文 + usingReRank: false +} +``` + +### 3. 标签组织 +``` +按主题: #产品文档 #技术规范 #客服FAQ +按来源: #官网 #手册 #社区 +按时效: #2024Q1 #最新版本 +``` + +### 4. 性能监控 +```typescript +// 关键指标 +- 训练队列长度 +- 检索平均耗时 +- Token 消耗量 +- 向量库大小 +- 召回率/准确率 +``` + +## 扩展阅读 + +### 相关文档 +- [RAG 架构设计](https://docs.tryfastgpt.ai/docs/development/upgrading/4819/) +- [向量数据库选择](https://docs.tryfastgpt.ai/docs/development/custom-models/vector/) +- [检索优化指南](https://docs.tryfastgpt.ai/docs/workflow/modules/knowledge_base/) + +### 外部依赖 +- `pgvector`: PostgreSQL 向量扩展 +- `jieba`: 中文分词库 +- `tiktoken`: Token 计数 +- `pdf-parse`: PDF 解析 +- `mammoth`: Word 解析 + +--- + +## 总结 + +FastGPT 知识库模块是一个完整的 RAG 系统实现,核心特点: + +1. **分层架构**: Dataset → Collection → Data → Indexes +2. **混合检索**: 向量 + 全文 + 重排序,灵活配置权重 +3. **异步训练**: 队列化向量化任务,支持重试和失败处理 +4. **双数据库**: MongoDB 存元数据,PG 存向量 +5. **多数据源**: 支持文件/链接/API/外部集成 +6. **灵活分块**: 段落/大小/字符多种策略 +7. **权限控制**: 继承式权限管理 + +开发时重点关注: +- **检索性能**: `search/controller.ts` +- **分块质量**: `collection/utils.ts` +- **训练队列**: `training/controller.ts` +- **数据流转**: Schema 之间的关联关系 diff --git a/.codex/design/core/workflow/cpu-blocking-optimization.md b/.codex/design/core/workflow/cpu-blocking-optimization.md new file mode 100644 index 0000000000..8f1fe6dabd --- /dev/null +++ b/.codex/design/core/workflow/cpu-blocking-optimization.md @@ -0,0 +1,336 @@ +# 工作流 CPU 阻塞优化方案 + +> 基于 `.claude/issue/workflow-thread-blocking-analysis.md` 中的分析结论,给出逐项优化方案。 + +--- + +## 方案一:节点/输出 O(1) 索引(最高优先级)✅ + +### 问题 + +`replaceEditorVariable` 和 `getReferenceVariableValue` 每次调用都用 `nodes.find()` O(N) 线性扫描,在大型工作流(50节点 × 10输入 × 5引用)中产生 2500 次 O(N) 扫描,全部同步。 + +### 方案 + +**在 `WorkflowQueue` 构造时,一次性建立两级 Map 索引**,然后向下传递,替换所有 `nodes.find()`。 + +#### 1.1 新增 OutputIndex 类型 + +```ts +// packages/global/core/workflow/runtime/type.ts 新增 +export type NodeOutputIndex = Map>; +// key: nodeId → Map +``` + +#### 1.2 构造函数建立索引 + +```ts +// packages/service/core/workflow/dispatch/index.ts +constructor(...) { + this.runtimeNodesMap = new Map(data.runtimeNodes.map((item) => [item.nodeId, item])); + + // 新增:输出值索引,O(1) 查找 + this.nodeOutputIndex = new Map( + data.runtimeNodes.map((node) => [ + node.nodeId, + new Map(node.outputs.map((output) => [output.id, output])) + ]) + ); + + // 已有的边/图算法... +} +``` + +#### 1.3 修改两个工具函数签名,增加可选 Map 参数 + +```ts +// getReferenceVariableValue 增加 nodesMap 参数 +export const getReferenceVariableValue = ({ + value, + nodes, + nodesMap, // 新增:优先使用 Map,无则降级到 nodes.find() + variables +}: { + value?: ReferenceValueType; + nodes: RuntimeNodeItemType[]; + nodesMap?: Map; + variables: Record; +}) => { + // ... + const node = nodesMap + ? nodesMap.get(sourceNodeId) + : nodes.find((n) => n.nodeId === sourceNodeId); + // ... +}; + +// replaceEditorVariable 同理增加 nodesMap 参数 +export function replaceEditorVariable({ + text, nodes, nodesMap, variables, depth = 0 +}: { + // ... + nodesMap?: Map; +}) { + // nodes.find() 全部替换为 nodesMap?.get() ?? nodes.find() +} +``` + +#### 1.4 调用侧传入 Map + +```ts +// packages/service/core/workflow/dispatch/index.ts - getNodeRunParams +node.inputs.forEach((input) => { + let value = replaceEditorVariable({ + text: input.value, + nodes: this.data.runtimeNodes, + nodesMap: this.runtimeNodesMap, // 传入预建 Map + variables: this.data.variables + }); + value = getReferenceVariableValue({ + value, + nodes: this.data.runtimeNodes, + nodesMap: this.runtimeNodesMap, // 传入预建 Map + variables: this.data.variables + }); +}); +``` + +**预期效果**:每次 `nodes.find()` O(N) → O(1) Map 查找,高频节点运行场景效果最明显。 + +--- + +## 方案二:RegExp 编译缓存 ✅ + +### 问题 + +`replaceEditorVariable` 每次调用对每个变量引用执行 `new RegExp(escapedPattern)`,正则编译有 CPU 开销,且模式是 `nodeId.outputId` 的确定性字符串,完全可以缓存。 + +### 方案 + +**模块级 Map 缓存已编译的 RegExp**: + +```ts +// packages/global/core/workflow/runtime/utils.ts + +// 模块级缓存,跨调用复用 +const regexCache = new Map(); + +function getCachedRegex(pattern: string): RegExp { + let re = regexCache.get(pattern); + if (!re) { + re = new RegExp(pattern, 'g'); + // 防止缓存无限增长(工作流变量数量有限,但多租户场景下累积) + if (regexCache.size > 10000) regexCache.clear(); + regexCache.set(pattern, re); + } + return re; +} + +// 替换原有的 +// result = result.replace(new RegExp(pattern, 'g'), replacement); +// 改为: +result = result.replace(getCachedRegex(pattern), replacement); +``` + +注意:`RegExp` 带 `g` flag 使用时有 `lastIndex` 状态,每次调用前需要 reset: + +```ts +const re = getCachedRegex(pattern); +re.lastIndex = 0; // 重置,防止 g flag 状态残留 +result = result.replace(re, replacement); +``` + +**预期效果**:相同变量名(常见场景:同一个工作流内节点反复引用相同变量)完全跳过正则编译。 + +--- + +## 方案三:Tarjan / DFS 递归改迭代 + +### 问题 + +`findSCCs` 和 `classifyEdgesByDFS` 均使用递归 DFS,递归深度 = 工作流拓扑深度。节点数 100+ 时,同步递归阻塞事件循环;节点数 10000+ 时(极端场景)有栈溢出风险。 + +### 方案 + +**用显式栈替换递归**,保持算法语义不变: + +#### 3.1 迭代版 Tarjan + +```ts +export function findSCCs(runtimeNodes: RuntimeNodeItemType[], edgeIndex: EdgeIndex): SCCResult { + const nodeToSCC = new Map(); + const sccSizes = new Map(); + let sccId = 0; + + const stack: string[] = []; + const inStack = new Set(); + const lowLink = new Map(); + const discoveryTime = new Map(); + let time = 0; + + // 迭代版:使用显式调用栈 + // 每个栈帧记录 { nodeId, edgeIndex(当前处理到第几条出边) } + for (const startNode of runtimeNodes) { + if (discoveryTime.has(startNode.nodeId)) continue; + + const callStack: Array<{ nodeId: string; edgeIdx: number }> = [ + { nodeId: startNode.nodeId, edgeIdx: 0 } + ]; + + discoveryTime.set(startNode.nodeId, time); + lowLink.set(startNode.nodeId, time++); + stack.push(startNode.nodeId); + inStack.add(startNode.nodeId); + + while (callStack.length > 0) { + const frame = callStack[callStack.length - 1]; + const { nodeId } = frame; + const outEdges = edgeIndex.bySource.get(nodeId) || []; + + if (frame.edgeIdx < outEdges.length) { + const targetId = outEdges[frame.edgeIdx++].target; + + if (!discoveryTime.has(targetId)) { + // 未访问:入栈,相当于递归调用 + discoveryTime.set(targetId, time); + lowLink.set(targetId, time++); + stack.push(targetId); + inStack.add(targetId); + callStack.push({ nodeId: targetId, edgeIdx: 0 }); + } else if (inStack.has(targetId)) { + lowLink.set(nodeId, Math.min(lowLink.get(nodeId)!, discoveryTime.get(targetId)!)); + } + } else { + // 当前节点所有出边处理完毕,相当于递归返回 + callStack.pop(); + if (callStack.length > 0) { + const parentId = callStack[callStack.length - 1].nodeId; + lowLink.set(parentId, Math.min(lowLink.get(parentId)!, lowLink.get(nodeId)!)); + } + + // 判断是否为 SCC 根节点 + if (lowLink.get(nodeId) === discoveryTime.get(nodeId)) { + const sccNodes: string[] = []; + let w: string; + do { + w = stack.pop()!; + inStack.delete(w); + nodeToSCC.set(w, sccId); + sccNodes.push(w); + } while (w !== nodeId); + sccSizes.set(sccId++, sccNodes.length); + } + } + } + } + + return { nodeToSCC, sccSizes }; +} +``` + +`classifyEdgesByDFS` 同理改为迭代版(结构更简单,一个 `while` 循环替换递归 `dfs()`)。 + +**预期效果**:消除调用栈深度限制,计算时间不变但不会有栈溢出风险;代码结构更清晰,更易分段插入 yield 点(见方案五)。 + +--- + +## 方案四:`findBranchHandle` BFS 结果缓存 + +### 问题 + +`buildNodeEdgeGroupsMap` 对每个节点的每条入边调用 `findBranchHandle`,做一次向上回溯 BFS。同一个 source 节点被多条边共享时,BFS 结果是相同的,重复计算。整体 O(N²)。 + +### 方案 + +**以 `(sourceNodeId + sourceHandle)` 为 key 缓存 BFS 结果**: + +```ts +private static groupEdgesByBranch( + edges: RuntimeEdgeItemType[], + edgeIndex: ..., + nodesMap: Map, + isBranchNode: ... +): RuntimeEdgeItemType[][] { + // 新增:缓存本次 buildNodeEdgeGroupsMap 调用内的 BFS 结果 + const branchHandleCache = new Map(); + + const edgeBranchMap = new Map(); + edges.forEach((edge) => { + const cacheKey = `${edge.source}::${edge.sourceHandle ?? 'default'}`; + let branchHandle = branchHandleCache.get(cacheKey); + if (branchHandle === undefined) { + branchHandle = this.findBranchHandle(edge, edgeIndex, nodesMap, isBranchNode); + branchHandleCache.set(cacheKey, branchHandle); + } + edgeBranchMap.set(edge, branchHandle); + }); + // ... +} +``` + +注意:`branchHandleCache` 在单次 `buildNodeEdgeGroupsMap` 调用内有效,不跨工作流实例共享(不同工作流拓扑不同)。 + +**预期效果**:重复边的 BFS 结果直接复用,从 O(N²) 降至接近 O(N×平均扇入),节点多、分支多的工作流效果最明显。 + +--- + +## 方案五:构造函数完成后让出事件循环 + +### 问题 + +`WorkflowQueue` 构造函数里的图算法(方案一~四优化后仍有固定开销)全部同步完成,多个并发工作流请求时,这些同步计算依次占用主线程,导致后续请求等待。 + +### 方案 + +`WorkflowQueue` 本身是同步构造的,无法在构造函数里 `await`。可以把构造拆成两步,或者在 `runWorkflow` 入口构造完成后立即让出: + +```ts +// packages/service/core/workflow/dispatch/index.ts - runWorkflow 函数 +export async function runWorkflow(props: RunWorkflowProps): Promise { + return new Promise((resolve) => { + const queue = new WorkflowQueue({ + data: props, + maxConcurrency: 10, + defaultSkipNodeQueue: props.defaultSkipNodeQueue, + resolve + }); + + // 构造完成(图算法已执行)后,先让出一次事件循环 + // 让其他并发请求有机会执行,避免连续多个工作流启动时的 CPU 连续占用 + setImmediate(() => { + queue.addActiveNode(entryNodeId); + }); + }); +} +``` + +实际上 `runWorkflow` 现有代码里有 `addActiveNode` 的调用,在那里加一个 `await surrenderProcess()` 即可。 + +**预期效果**:每次工作流启动后主动让出一次,高并发时多个工作流的图初始化计算被事件循环交错调度,而不是连续堵塞。 + +--- + +## 实施优先级 + +| 方案 | 改动量 | 风险 | 效果 | 优先级 | +|------|-------|------|------|-------| +| 方案一:节点 O(1) 索引 | 中(函数签名变化,调用侧修改) | 低(向后兼容,可选参数) | ⭐⭐⭐⭐ | P0 | +| 方案二:RegExp 缓存 | 小(本地改动) | 极低 | ⭐⭐⭐ | P1 | +| 方案三:递归改迭代 | 中(逻辑重写,需测试) | 中(算法正确性需验证) | ⭐⭐(防栈溢出) | P1 | +| 方案四:BFS 缓存 | 小(加 Map 缓存) | 极低 | ⭐⭐⭐ | P1 | +| 方案五:构造后让出 | 极小(加一行) | 极低 | ⭐⭐(并发公平性) | P2 | + +**建议执行顺序**:方案一 → 方案二 + 方案四(可并行)→ 方案三(配套单测)→ 方案五 + +--- + +## 改动文件清单 + +``` +packages/global/core/workflow/runtime/ + ├── utils.ts 方案一(函数签名)、方案二(RegExp 缓存) + └── type.ts 方案一(新增 NodeOutputIndex 类型) + +packages/service/core/workflow/ + ├── dispatch/index.ts 方案一(传 Map)、方案四(BFS缓存)、方案五(让出) + └── utils/tarjan.ts 方案三(递归改迭代) +``` diff --git a/.codex/design/core/workflow/index.md b/.codex/design/core/workflow/index.md new file mode 100644 index 0000000000..f902040895 --- /dev/null +++ b/.codex/design/core/workflow/index.md @@ -0,0 +1,597 @@ +# FastGPT 工作流系统架构文档 + +## 概述 + +FastGPT 工作流系统是一个基于 Node.js/TypeScript 的可视化工作流引擎,支持拖拽式节点编排、实时执行、并发控制和交互式调试。系统采用队列式执行架构,通过有向图模型实现复杂的业务逻辑编排。 + +## 核心架构 + +### 1. 项目结构 + +``` +FastGPT/ +├── packages/ +│ ├── global/core/workflow/ # 全局工作流类型和常量 +│ │ ├── constants.ts # 工作流常量定义 +│ │ ├── node/ # 节点类型定义 +│ │ │ └── constant.ts # 节点枚举和配置 +│ │ ├── runtime/ # 运行时类型和工具 +│ │ │ ├── constants.ts # 运行时常量 +│ │ │ ├── type.d.ts # 运行时类型定义 +│ │ │ └── utils.ts # 运行时工具函数 +│ │ ├── template/ # 节点模板定义 +│ │ │ └── system/ # 系统节点模板 +│ │ └── type/ # 类型定义 +│ │ ├── node.d.ts # 节点类型 +│ │ ├── edge.d.ts # 边类型 +│ │ └── io.d.ts # 输入输出类型 +│ └── service/core/workflow/ # 工作流服务层 +│ ├── constants.ts # 服务常量 +│ ├── dispatch/ # 调度器核心 +│ │ ├── index.ts # 工作流执行引擎 ⭐ +│ │ ├── constants.ts # 节点调度映射表 +│ │ ├── type.d.ts # 调度器类型 +│ │ ├── ai/ # AI相关节点 +│ │ ├── tools/ # 工具节点 +│ │ ├── dataset/ # 数据集节点 +│ │ ├── interactive/ # 交互节点 +│ │ ├── loop/ # 循环节点 +│ │ └── plugin/ # 插件节点 +│ └── utils.ts # 工作流工具函数 +└── projects/app/src/ + ├── pages/api/v1/chat/completions.ts # 聊天API入口 + └── pages/api/core/workflow/debug.ts # 工作流调试API +``` + +### 2. 执行引擎核心 (dispatch/index.ts) + +#### 核心类:WorkflowQueue + +工作流执行引擎采用队列式架构,主要特点: + +- **并发控制**: 支持最大并发数量限制(默认10个) +- **状态管理**: 维护节点执行状态(waiting/active/skipped) +- **错误处理**: 支持节点级错误捕获和跳过机制 +- **交互支持**: 支持用户交互节点暂停和恢复 + +#### 执行流程 + +```typescript +1. 初始化 WorkflowQueue 实例 +2. 识别入口节点(isEntry=true) +3. 将入口节点加入 activeRunQueue +4. 循环处理活跃节点队列: + - 检查节点执行条件 + - 执行节点或跳过节点 + - 更新边状态 + - 将后续节点加入队列 +5. 处理跳过节点队列 +6. 返回执行结果 +``` + +### 3. 节点系统 + +#### 节点类型枚举 (FlowNodeTypeEnum) + +```typescript +enum FlowNodeTypeEnum { + // 基础节点 + workflowStart: 'workflowStart', // 工作流开始 + chatNode: 'chatNode', // AI对话 + answerNode: 'answerNode', // 回答节点 + + // 数据集相关 + datasetSearchNode: 'datasetSearchNode', // 数据集搜索 + datasetConcatNode: 'datasetConcatNode', // 数据集拼接 + + // 控制流节点 + ifElseNode: 'ifElseNode', // 条件判断 + loop: 'loop', // 循环 + loopStart: 'loopStart', // 循环开始 + loopEnd: 'loopEnd', // 循环结束 + + // 交互节点 + userSelect: 'userSelect', // 用户选择 + formInput: 'formInput', // 表单输入 + + // 工具节点 + httpRequest468: 'httpRequest468', // HTTP请求 + code: 'code', // 代码执行 + readFiles: 'readFiles', // 文件读取 + variableUpdate: 'variableUpdate', // 变量更新 + + // AI相关 + classifyQuestion: 'classifyQuestion', // 问题分类 + contentExtract: 'contentExtract', // 内容提取 + agent: 'tools', // 智能体 + queryExtension: 'cfr', // 查询扩展 + + // 插件系统 + pluginModule: 'pluginModule', // 插件模块 + appModule: 'appModule', // 应用模块 + tool: 'tool', // 工具调用 + + // 系统节点 + systemConfig: 'userGuide', // 系统配置 + globalVariable: 'globalVariable', // 全局变量 + comment: 'comment' // 注释节点 +} +``` + +#### 节点调度映射 (callbackMap) + +每个节点类型都有对应的调度函数: + +```typescript +export const callbackMap: Record = { + [FlowNodeTypeEnum.workflowStart]: dispatchWorkflowStart, + [FlowNodeTypeEnum.chatNode]: dispatchChatCompletion, + [FlowNodeTypeEnum.datasetSearchNode]: dispatchDatasetSearch, + [FlowNodeTypeEnum.httpRequest468]: dispatchHttp468Request, + [FlowNodeTypeEnum.ifElseNode]: dispatchIfElse, + [FlowNodeTypeEnum.agent]: dispatchRunTools, + // ... 更多节点调度函数 +}; +``` + +### 4. 数据流系统 + +#### 输入输出类型 (WorkflowIOValueTypeEnum) + +```typescript +enum WorkflowIOValueTypeEnum { + string: 'string', + number: 'number', + boolean: 'boolean', + object: 'object', + arrayString: 'arrayString', + arrayNumber: 'arrayNumber', + arrayBoolean: 'arrayBoolean', + arrayObject: 'arrayObject', + chatHistory: 'chatHistory', // 聊天历史 + datasetQuote: 'datasetQuote', // 数据集引用 + dynamic: 'dynamic', // 动态类型 + any: 'any' +} +``` + +#### 变量系统 + +- **系统变量**: userId, appId, chatId, cTime等 +- **用户变量**: 通过variables参数传入的全局变量 +- **节点变量**: 节点间传递的引用变量 +- **动态变量**: 支持{{$variable}}语法引用 + +### 5. 状态管理 + +#### 运行时状态 + +```typescript +interface RuntimeNodeItemType { + nodeId: string; + name: string; + flowNodeType: FlowNodeTypeEnum; + inputs: FlowNodeInputItemType[]; + outputs: FlowNodeOutputItemType[]; + isEntry?: boolean; + catchError?: boolean; +} + +interface RuntimeEdgeItemType { + source: string; + target: string; + sourceHandle: string; + targetHandle: string; + status: 'waiting' | 'active' | 'skipped'; +} +``` + +#### 执行状态 + +```typescript +enum RuntimeEdgeStatusEnum { + waiting: 'waiting', // 等待执行 + active: 'active', // 活跃状态 + skipped: 'skipped' // 已跳过 +} +``` + +### 6. API接口设计 + +#### 主要API端点 + +1. **工作流调试**: `/api/core/workflow/debug` + - POST方法,支持工作流测试和调试 + - 返回详细的执行结果和状态信息 + +2. **聊天完成**: `/api/v1/chat/completions` + - OpenAI兼容的聊天API + - 集成工作流执行引擎 + +3. **优化代码**: `/api/core/workflow/optimizeCode` + - 工作流代码优化功能 + +#### 请求/响应类型 + +```typescript +interface DispatchFlowResponse { + flowResponses: ChatHistoryItemResType[]; + flowUsages: ChatNodeUsageType[]; + debugResponse: WorkflowDebugResponse; + workflowInteractiveResponse?: WorkflowInteractiveResponseType; + toolResponses: ToolRunResponseItemType; + assistantResponses: AIChatItemValueItemType[]; + runTimes: number; + newVariables: Record; + durationSeconds: number; +} +``` + +## 核心特性 + +### 1. 并发控制 +- 支持最大并发节点数限制 +- 队列式调度避免资源竞争 +- 节点级执行状态管理 + +### 2. 错误处理 +- 节点级错误捕获 +- catchError配置控制错误传播 +- 错误跳过和继续执行机制 + +### 3. 交互式执行 +- 支持用户交互节点(userSelect, formInput) +- 工作流暂停和恢复 +- 交互状态持久化 + +### 4. 调试支持 +- Debug模式提供详细执行信息 +- 节点执行状态可视化 +- 变量值追踪和检查 + +### 5. 扩展性 +- 插件系统支持自定义节点 +- 模块化架构便于扩展 +- 工具集成(HTTP, 代码执行等) + +## 开发指南 + +### 添加新节点类型 + +1. 在 `FlowNodeTypeEnum` 中添加新类型 +2. 在 `callbackMap` 中注册调度函数 +3. 在 `dispatch/` 目录下实现节点逻辑 +4. 在 `template/system/` 中定义节点模板 + +### 自定义工具集成 + +1. 实现工具调度函数 +2. 定义工具输入输出类型 +3. 注册到callbackMap +4. 添加前端配置界面 + +### 调试和测试 + +1. 使用 `/api/core/workflow/debug` 进行测试 +2. 启用debug模式查看详细执行信息 +3. 检查节点执行状态和数据流 +4. 使用skipNodeQueue控制执行路径 + +## 性能优化 + +1. **并发控制**: 合理设置maxConcurrency避免资源过载 +2. **缓存机制**: 利用节点输出缓存减少重复计算 +3. **流式响应**: 支持SSE实时返回执行状态 +4. **资源管理**: 及时清理临时数据和状态 + +--- + +## 前端架构设计 + +### 1. 前端项目结构 + +``` +projects/app/src/ +├── pageComponents/app/detail/ # 应用详情页面 +│ ├── Workflow/ # 工作流主页面 +│ │ ├── Header.tsx # 工作流头部 +│ │ └── index.tsx # 工作流入口 +│ ├── WorkflowComponents/ # 工作流核心组件 +│ │ ├── context/ # 状态管理上下文 +│ │ │ ├── index.tsx # 主上下文提供者 ⭐ +│ │ │ ├── workflowInitContext.tsx # 初始化上下文 +│ │ │ ├── workflowEventContext.tsx # 事件上下文 +│ │ │ └── workflowStatusContext.tsx # 状态上下文 +│ │ ├── Flow/ # ReactFlow核心组件 +│ │ │ ├── index.tsx # 工作流画布 ⭐ +│ │ │ ├── components/ # 工作流UI组件 +│ │ │ ├── hooks/ # 工作流逻辑钩子 +│ │ │ └── nodes/ # 节点渲染组件 +│ │ ├── constants.tsx # 常量定义 +│ │ └── utils.ts # 工具函数 +│ └── HTTPTools/ # HTTP工具页面 +│ └── Edit.tsx # HTTP工具编辑器 +├── web/core/workflow/ # 工作流核心逻辑 +│ ├── api.ts # API调用 ⭐ +│ ├── adapt.ts # 数据适配 +│ ├── type.d.ts # 类型定义 +│ └── utils.ts # 工具函数 +└── global/core/workflow/ # 全局工作流定义 + └── api.d.ts # API类型定义 +``` + +### 2. 核心状态管理架构 + +#### Context分层设计 + +前端采用分层Context架构,实现状态的高效管理和组件间通信: + +```typescript +// 1. ReactFlowCustomProvider - 最外层提供者 +ReactFlowProvider → WorkflowInitContextProvider → +WorkflowContextProvider → WorkflowEventContextProvider → +WorkflowStatusContextProvider → children + +// 2. 四层核心Context +- WorkflowInitContext: 节点数据和基础状态 +- WorkflowDataContext: 节点/边操作和状态 +- WorkflowEventContext: 事件处理和UI控制 +- WorkflowStatusContext: 保存状态和父节点管理 +``` + +#### 主Context功能 (context/index.tsx) + +```typescript +interface WorkflowContextType { + // 节点管理 + nodeList: FlowNodeItemType[]; + onChangeNode: (props: FlowNodeChangeProps) => void; + onUpdateNodeError: (nodeId: string, isError: boolean) => void; + getNodeDynamicInputs: (nodeId: string) => FlowNodeInputItemType[]; + + // 边管理 + onDelEdge: (edgeProps: EdgeDeleteProps) => void; + + // 版本控制 + past: WorkflowSnapshotsType[]; + future: WorkflowSnapshotsType[]; + undo: () => void; + redo: () => void; + pushPastSnapshot: (snapshot: SnapshotProps) => boolean; + + // 调试功能 + workflowDebugData?: DebugDataType; + onNextNodeDebug: (debugData: DebugDataType) => Promise; + onStartNodeDebug: (debugProps: DebugStartProps) => Promise; + onStopNodeDebug: () => void; + + // 数据转换 + flowData2StoreData: () => StoreWorkflowType; + splitToolInputs: (inputs, nodeId) => ToolInputsResult; +} +``` + +### 3. ReactFlow集成 + +#### 节点类型映射 (Flow/index.tsx) + +```typescript +const nodeTypes: Record = { + [FlowNodeTypeEnum.workflowStart]: NodeWorkflowStart, + [FlowNodeTypeEnum.chatNode]: NodeSimple, + [FlowNodeTypeEnum.datasetSearchNode]: NodeSimple, + [FlowNodeTypeEnum.httpRequest468]: NodeHttp, + [FlowNodeTypeEnum.ifElseNode]: NodeIfElse, + [FlowNodeTypeEnum.agent]: NodeAgent, + [FlowNodeTypeEnum.code]: NodeCode, + [FlowNodeTypeEnum.loop]: NodeLoop, + [FlowNodeTypeEnum.userSelect]: NodeUserSelect, + [FlowNodeTypeEnum.formInput]: NodeFormInput, + // ... 40+ 种节点类型 +}; +``` + +#### 工作流核心功能 + +- **拖拽编排**: 基于ReactFlow的可视化节点编辑 +- **实时连接**: 节点间的动态连接和断开 +- **缩放控制**: 支持画布缩放和平移 +- **选择操作**: 多选、批量操作支持 +- **辅助线**: 节点对齐和位置吸附 + +### 4. 节点组件系统 + +#### 节点渲染架构 + +``` +nodes/ +├── NodeSimple.tsx # 通用简单节点 +├── NodeWorkflowStart.tsx # 工作流开始节点 +├── NodeAgent.tsx # AI智能体节点 +├── NodeHttp/ # HTTP请求节点 +├── NodeCode/ # 代码执行节点 +├── Loop/ # 循环节点组 +├── NodeFormInput/ # 表单输入节点 +├── NodePluginIO/ # 插件IO节点 +├── NodeToolParams/ # 工具参数节点 +└── render/ # 渲染组件库 + ├── NodeCard.tsx # 节点卡片容器 + ├── RenderInput/ # 输入渲染器 + ├── RenderOutput/ # 输出渲染器 + └── templates/ # 输入模板组件 +``` + +#### 动态输入系统 + +```typescript +// 支持多种输入类型 +const inputTemplates = { + reference: ReferenceTemplate, // 引用其他节点 + input: TextInput, // 文本输入 + textarea: TextareaInput, // 多行文本 + selectApp: AppSelector, // 应用选择器 + selectDataset: DatasetSelector, // 数据集选择 + settingLLMModel: LLMModelConfig, // AI模型配置 + // ... 更多模板类型 +}; +``` + +### 5. 调试和测试系统 + +#### 调试功能 + +```typescript +interface DebugDataType { + runtimeNodes: RuntimeNodeItemType[]; + runtimeEdges: RuntimeEdgeItemType[]; + entryNodeIds: string[]; + variables: Record; + history?: ChatItemMiniType[]; + query?: UserChatItemValueItemType[]; + workflowInteractiveResponse?: WorkflowInteractiveResponseType; +} +``` + +- **单步调试**: 支持逐个节点执行调试 +- **断点设置**: 在任意节点设置断点 +- **状态查看**: 实时查看节点执行状态 +- **变量追踪**: 监控变量在节点间的传递 +- **错误定位**: 精确定位执行错误节点 + +#### 聊天测试 + +```typescript +// ChatTest组件提供实时工作流测试 + +``` + +### 6. API集成层 + +#### 工作流API (web/core/workflow/api.ts) + +```typescript +// 工作流调试API +export const postWorkflowDebug = (data: PostWorkflowDebugProps) => + POST( + '/core/workflow/debug', + { ...data, mode: 'debug' }, + { timeout: 300000 } + ); + +// 支持的API操作 +- 工作流调试和测试 +- 节点模板获取 +- 插件配置管理 +- 版本控制操作 +``` + +#### 数据适配器 + +```typescript +// 数据转换适配 +- storeNode2FlowNode: 存储节点 → Flow节点 +- storeEdge2RenderEdge: 存储边 → 渲染边 +- uiWorkflow2StoreWorkflow: UI工作流 → 存储格式 +- adaptCatchError: 错误处理适配 +``` + +### 7. 交互逻辑设计 + +#### 键盘快捷键 (hooks/useKeyboard.tsx) + +```typescript +const keyboardShortcuts = { + 'Ctrl+Z': undo, // 撤销 + 'Ctrl+Y': redo, // 重做 + 'Ctrl+S': saveWorkflow, // 保存工作流 + 'Delete': deleteSelectedNodes, // 删除选中节点 + 'Escape': cancelCurrentOperation, // 取消当前操作 +}; +``` + +#### 节点操作 + +- **拖拽创建**: 从模板拖拽创建节点 +- **连线操作**: 节点间的连接管理 +- **批量操作**: 多选节点的批量编辑 +- **右键菜单**: 上下文操作菜单 +- **搜索定位**: 节点搜索和快速定位 + +#### 版本控制 + +```typescript +// 快照系统 +interface WorkflowSnapshotsType { + nodes: Node[]; + edges: Edge[]; + chatConfig: AppChatConfigType; + title: string; + isSaved?: boolean; +} +``` + +- **自动快照**: 节点变更时自动保存快照 +- **版本历史**: 支持多版本切换 +- **云端同步**: 与服务端版本同步 +- **协作支持**: 团队协作版本管理 + +### 8. 性能优化策略 + +#### 渲染优化 + +```typescript +// 动态加载节点组件 +const nodeTypes: Record = { + [FlowNodeTypeEnum.workflowStart]: dynamic(() => import('./nodes/NodeWorkflowStart')), + [FlowNodeTypeEnum.httpRequest468]: dynamic(() => import('./nodes/NodeHttp')), + // ... 按需加载 +}; +``` + +- **懒加载**: 节点组件按需动态加载 +- **虚拟化**: 大型工作流的虚拟渲染 +- **防抖操作**: 频繁操作的性能优化 +- **缓存策略**: 模板和数据的缓存机制 + +#### 状态优化 + +- **Context分割**: 避免不必要的重渲染 +- **useMemo/useCallback**: 优化计算和函数创建 +- **选择器模式**: 精确订阅状态变化 +- **批量更新**: 合并多个状态更新 + +### 9. 扩展性设计 + +#### 插件系统 + +```typescript +// 节点模板扩展 +interface NodeTemplateListItemType { + id: string; + flowNodeType: FlowNodeTypeEnum; + templateType: string; + avatar?: string; + name: string; + intro?: string; + isTool?: boolean; + pluginId?: string; +} +``` + +- **自定义节点**: 支持第三方节点开发 +- **模板市场**: 节点模板的共享和分发 +- **插件生态**: 丰富的节点插件生态 +- **开放API**: 标准化的节点开发接口 + +#### 主题定制 + +- **节点样式**: 可定制的节点外观 +- **连线样式**: 自定义连线类型和颜色 +- **布局配置**: 多种布局算法支持 +- **国际化**: 多语言界面支持 \ No newline at end of file diff --git a/.codex/design/core/workflow/interactive.md b/.codex/design/core/workflow/interactive.md new file mode 100644 index 0000000000..af91477a5d --- /dev/null +++ b/.codex/design/core/workflow/interactive.md @@ -0,0 +1,529 @@ +# 交互节点开发指南 + +## 概述 + +FastGPT 工作流支持多种交互节点类型,允许在工作流执行过程中暂停并等待用户输入。本指南详细说明了如何开发新的交互节点。 + +## 现有交互节点类型 + +当前系统支持以下交互节点类型: + +1. **userSelect** - 用户选择节点(单选) +2. **formInput** - 表单输入节点(多字段表单) +3. **childrenInteractive** - 子工作流交互 +4. **loopInteractive** - 循环交互 +5. **paymentPause** - 欠费暂停交互 + +## 交互节点架构 + +### 核心类型定义 + +交互节点的类型定义位于 `packages/global/core/workflow/template/system/interactive/type.d.ts` + +```typescript +// 基础交互结构 +type InteractiveBasicType = { + entryNodeIds: string[]; // 入口节点ID列表 + memoryEdges: RuntimeEdgeItemType[]; // 需要记忆的边 + nodeOutputs: NodeOutputItemType[]; // 节点输出 + skipNodeQueue?: Array; // 跳过的节点队列 + usageId?: string; // 用量记录ID +}; + +// 具体交互节点类型 +type YourInteractiveNode = InteractiveNodeType & { + type: 'yourNodeType'; + params: { + // 节点特定参数 + }; +}; +``` + +### 工作流执行机制 + +交互节点在工作流执行中的特殊处理(位于 `packages/service/core/workflow/dispatch/index.ts:1012-1019`): + +```typescript +// 部分交互节点不会自动重置 isEntry 标志(因为需要根据 isEntry 字段来判断是首次进入还是流程进入) +runtimeNodes.forEach((item) => { + if ( + item.flowNodeType !== FlowNodeTypeEnum.userSelect && + item.flowNodeType !== FlowNodeTypeEnum.formInput && + item.flowNodeType !== FlowNodeTypeEnum.agent + ) { + item.isEntry = false; + } +}); +``` + +## 开发新交互响应的步骤 + +### 步骤 1: 定义节点类型 + +**文件**: `packages/global/core/workflow/template/system/interactive/type.d.ts` + +```typescript +export type YourInputItemType = { + // 定义输入项的结构 + key: string; + label: string; + value: any; + // ... 其他字段 +}; + +type YourInteractiveNode = InteractiveNodeType & { + type: 'yourNodeType'; + params: { + description: string; + yourInputField: YourInputItemType[]; + submitted?: boolean; // 可选:是否已提交 + }; +}; + +// 添加到联合类型 +export type InteractiveNodeResponseType = + | UserSelectInteractive + | UserInputInteractive + | YourInteractiveNode // 新增 + | ChildrenInteractive + | LoopInteractive + | PaymentPauseInteractive; +``` + +### 步骤 2: 定义节点枚举(可选) + +**文件**: `packages/global/core/workflow/node/constant.ts` + +如果不需要添加新的节点类型,则不需要修改这个文件。 + +```typescript +export enum FlowNodeTypeEnum { + // ... 现有类型 + yourNodeType = 'yourNodeType', // 新增节点类型 +} +``` + +### 步骤 3: 创建节点模板(可选) + +**文件**: `packages/global/core/workflow/template/system/interactive/yourNode.ts` + +```typescript +import { i18nT } from '../../../../../../web/i18n/utils'; +import { + FlowNodeTemplateTypeEnum, + NodeInputKeyEnum, + NodeOutputKeyEnum, + WorkflowIOValueTypeEnum +} from '../../../constants'; +import { + FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '../../../node/constant'; +import { type FlowNodeTemplateType } from '../../../type/node'; + +export const YourNode: FlowNodeTemplateType = { + id: FlowNodeTypeEnum.yourNodeType, + templateType: FlowNodeTemplateTypeEnum.interactive, + flowNodeType: FlowNodeTypeEnum.yourNodeType, + showSourceHandle: true, // 是否显示源连接点 + showTargetHandle: true, // 是否显示目标连接点 + avatar: 'core/workflow/template/yourNode', + name: i18nT('app:workflow.your_node'), + intro: i18nT('app:workflow.your_node_tip'), + isTool: true, // 标记为工具节点 + inputs: [ + { + key: NodeInputKeyEnum.description, + renderTypeList: [FlowNodeInputTypeEnum.textarea], + valueType: WorkflowIOValueTypeEnum.string, + label: i18nT('app:workflow.node_description'), + placeholder: i18nT('app:workflow.your_node_placeholder') + }, + { + key: NodeInputKeyEnum.yourInputField, + renderTypeList: [FlowNodeInputTypeEnum.custom], + valueType: WorkflowIOValueTypeEnum.any, + label: '', + value: [] // 默认值 + } + ], + outputs: [ + { + id: NodeOutputKeyEnum.yourResult, + key: NodeOutputKeyEnum.yourResult, + required: true, + label: i18nT('workflow:your_result'), + valueType: WorkflowIOValueTypeEnum.object, + type: FlowNodeOutputTypeEnum.static + } + ] +}; +``` + +### 步骤 4: 创建节点执行逻辑或在需要处理交互逻辑的节点上增加新逻辑 + +**文件**: `packages/service/core/workflow/dispatch/interactive/yourNode.ts` + +```typescript +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import type { + DispatchNodeResultType, + ModuleDispatchProps +} from '@fastgpt/global/core/workflow/runtime/type'; +import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { YourInputItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; + +type Props = ModuleDispatchProps<{ + [NodeInputKeyEnum.description]: string; + [NodeInputKeyEnum.yourInputField]: YourInputItemType[]; +}>; + +type YourNodeResponse = DispatchNodeResultType<{ + [NodeOutputKeyEnum.yourResult]?: Record; +}>; + +export const dispatchYourNode = async (props: Props): Promise => { + const { + histories, + node, + params: { description, yourInputField }, + query, + lastInteractive + } = props; + const { isEntry } = node; + + // 第一阶段:非入口节点或不是对应的交互类型,返回交互请求 + if (!isEntry || lastInteractive?.type !== 'yourNodeType') { + return { + [DispatchNodeResponseKeyEnum.interactive]: { + type: 'yourNodeType', + params: { + description, + yourInputField + } + } + }; + } + + // 第二阶段:处理用户提交的数据 + node.isEntry = false; // 重要:重置入口标志 + + const { text } = chatValue2RuntimePrompt(query); + const userInputVal = (() => { + try { + return JSON.parse(text); // 根据实际格式解析 + } catch (error) { + return {}; + } + })(); + + return { + data: { + [NodeOutputKeyEnum.yourResult]: userInputVal + }, + // 移除当前交互的历史记录(最后2条) + [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), + [DispatchNodeResponseKeyEnum.toolResponses]: userInputVal, + [DispatchNodeResponseKeyEnum.nodeResponse]: { + yourResult: userInputVal + } + }; +}; +``` + +### 步骤 5: 注册节点回调 + +**文件**: `packages/service/core/workflow/dispatch/constants.ts` + +```typescript +import { dispatchYourNode } from './interactive/yourNode'; + +export const callbackMap: Record = { + // ... 现有节点 + [FlowNodeTypeEnum.yourNodeType]: dispatchYourNode, +}; +``` + +### 步骤 6: 创建前端渲染组件 + +#### 6.1 聊天界面交互组件 + +**文件**: `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx` + +```typescript +export const YourNodeComponent = React.memo(function YourNodeComponent({ + interactiveParams: { description, yourInputField, submitted }, + defaultValues = {}, + SubmitButton +}: { + interactiveParams: YourInteractiveNode['params']; + defaultValues?: Record; + SubmitButton: (e: { onSubmit: UseFormHandleSubmit> }) => React.JSX.Element; +}) { + const { handleSubmit, control } = useForm({ + defaultValues + }); + + return ( + + + + {yourInputField.map((input) => ( + + {/* 渲染你的输入组件 */} + ( + + )} + /> + + ))} + + + {!submitted && ( + + + + )} + + ); +}); +``` + +#### 6.2 工作流编辑器节点组件 + +**文件**: `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeYourNode.tsx` + +```typescript +import React, { useMemo } from 'react'; +import { type NodeProps } from 'reactflow'; +import { Box, Button } from '@chakra-ui/react'; +import NodeCard from './render/NodeCard'; +import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import Container from '../components/Container'; +import RenderInput from './render/RenderInput'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { useTranslation } from 'next-i18next'; +import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import { useContextSelector } from 'use-context-selector'; +import IOTitle from '../components/IOTitle'; +import RenderOutput from './render/RenderOutput'; +import { WorkflowActionsContext } from '../../context/workflowActionsContext'; + +const NodeYourNode = ({ data, selected }: NodeProps) => { + const { t } = useTranslation(); + const { nodeId, inputs, outputs } = data; + const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + + const CustomComponent = useMemo( + () => ({ + [NodeInputKeyEnum.yourInputField]: (v: FlowNodeInputItemType) => { + // 自定义渲染逻辑 + return ( + + {/* 你的自定义UI */} + + ); + } + }), + [nodeId, onChangeNode, t] + ); + + return ( + + + + + + + + + + ); +}; + +export default React.memo(NodeYourNode); +``` + +### 步骤 7: 注册节点组件 + +需要在节点注册表中添加你的节点组件(具体位置根据项目配置而定)。 + +### 步骤 8: 添加国际化 + +**文件**: `packages/web/i18n/zh-CN/app.json` 和其他语言文件 + +```json +{ + "workflow": { + "your_node": "你的节点名称", + "your_node_tip": "节点功能说明", + "your_node_placeholder": "提示文本" + } +} +``` + +### 步骤9 调整保存对话记录逻辑 + +**文件**: `FastGPT/packages/service/core/chat/saveChat.ts` + +修改 `updateInteractiveChat` 方法,支持新的交互 + +### 步骤10 根据历史记录获取/设置交互状态 + +**文件**: `FastGPT/projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` +**文件**: `FastGPT/packages/global/core/workflow/runtime/utils.ts` + +调整`setInteractiveResultToHistories`, `getInteractiveByHistories` 和 `getLastInteractiveValue`方法。 + +## 关键注意事项 + +### 1. isEntry 标志管理 + +交互节点需要保持 `isEntry` 标志在工作流恢复时有效: + +```typescript +// 在 packages/service/core/workflow/dispatch/index.ts 中 +// 确保你的节点类型被添加到白名单 +if ( + item.flowNodeType !== FlowNodeTypeEnum.userSelect && + item.flowNodeType !== FlowNodeTypeEnum.formInput && + item.flowNodeType !== FlowNodeTypeEnum.yourNodeType // 新增 +) { + item.isEntry = false; +} +``` + +### 2. 交互响应流程 + +交互节点有两个执行阶段: + +1. **第一次执行**: 返回 `interactive` 响应,暂停工作流 +2. **第二次执行**: 接收用户输入,继续工作流 + +```typescript +// 第一阶段 +if (!isEntry || lastInteractive?.type !== 'yourNodeType') { + return { + [DispatchNodeResponseKeyEnum.interactive]: { + type: 'yourNodeType', + params: { /* ... */ } + } + }; +} + +// 第二阶段 +node.isEntry = false; // 重要!重置标志 +// 处理用户输入... +``` + +### 3. 历史记录管理 + +交互节点需要正确处理历史记录: + +```typescript +return { + // 移除交互对话的历史记录(用户问题 + 系统响应) + [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), + // ... 其他返回值 +}; +``` + +### 4. Skip 节点队列 + +交互节点触发时,系统会保存 `skipNodeQueue` 以便恢复时跳过已处理的节点。 + +### 5. 工具调用支持 + +如果节点需要在工具调用中使用,设置 `isTool: true`。 + +## 测试清单 + +开发完成后,请测试以下场景: + +- [ ] 节点在工作流编辑器中正常显示 +- [ ] 节点配置保存和加载正确 +- [ ] 交互请求正确发送到前端 +- [ ] 前端组件正确渲染交互界面 +- [ ] 用户输入正确传回后端 +- [ ] 工作流正确恢复并继续执行 +- [ ] 历史记录正确更新 +- [ ] 节点输出正确连接到后续节点 +- [ ] 错误情况处理正确 +- [ ] 多语言支持完整 + +## 参考实现 + +可以参考以下现有实现: + +1. **简单单选**: `userSelect` 节点 + - 类型定义: `packages/global/core/workflow/template/system/interactive/type.d.ts:48-55` + - 执行逻辑: `packages/service/core/workflow/dispatch/interactive/userSelect.ts` + - 前端组件: `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx:29-63` + +2. **复杂表单**: `formInput` 节点 + - 类型定义: `packages/global/core/workflow/template/system/interactive/type.d.ts:57-82` + - 执行逻辑: `packages/service/core/workflow/dispatch/interactive/formInput.ts` + - 前端组件: `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx:65-126` + +## 常见问题 + +### Q: 交互节点执行了两次? +A: 这是正常的。第一次返回交互请求,第二次处理用户输入。确保在第二次执行时设置 `node.isEntry = false`。 + +### Q: 工作流恢复后没有继续执行? +A: 检查你的节点类型是否在 `isEntry` 白名单中(dispatch/index.ts:1013-1018)。 + +### Q: 用户输入格式不对? +A: 检查 `chatValue2RuntimePrompt` 的返回值,根据你的数据格式进行解析。 + +### Q: 如何支持多个交互节点串联? +A: 每个交互节点都会暂停工作流,用户完成后会自动继续到下一个节点。 + +## 文件清单总结 + +开发新交互节点需要修改/创建以下文件: + +### 后端核心文件 +1. `packages/global/core/workflow/template/system/interactive/type.d.ts` - 类型定义 +2. `packages/global/core/workflow/node/constant.ts` - 节点枚举 +3. `packages/global/core/workflow/template/system/interactive/yourNode.ts` - 节点模板 +4. `packages/service/core/workflow/dispatch/interactive/yourNode.ts` - 执行逻辑 +5. `packages/service/core/workflow/dispatch/constants.ts` - 回调注册 +6. `packages/service/core/workflow/dispatch/index.ts` - isEntry 白名单 + +### 前端组件文件 +7. `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx` - 聊天交互组件 +8. `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeYourNode.tsx` - 工作流编辑器组件 + +### 国际化文件 +9. `packages/web/i18n/zh-CN/app.json` - 中文翻译 +10. `packages/web/i18n/en/app.json` - 英文翻译 +11. `packages/web/i18n/zh-Hant/app.json` - 繁体中文翻译 + +## 附录:关键输入输出键定义 + +如果需要新的输入输出键,在以下文件中定义: + +**文件**: `packages/global/core/workflow/constants.ts` + +```typescript +export enum NodeInputKeyEnum { + // ... 现有键 + yourInputKey = 'yourInputKey', +} + +export enum NodeOutputKeyEnum { + // ... 现有键 + yourOutputKey = 'yourOutputKey', +} +``` diff --git a/.codex/design/core/workflow/loop-run/development.md b/.codex/design/core/workflow/loop-run/development.md new file mode 100644 index 0000000000..ba53928e5d --- /dev/null +++ b/.codex/design/core/workflow/loop-run/development.md @@ -0,0 +1,315 @@ +# loopRun 节点开发文档(文件级改动清单 + TODO) + +> 设计稿:[Notion - 工作流循环批量](https://www.notion.so/dighuang/341ded3f8cd88187be61fb442c7fbe8b) +> 蓝本:parallelRun 节点(commit `0855cc6e06c56f8fa2ea9aaab491d43bb4413be8`) +> 本文档只覆盖"**实现侧**"内容。节点语义、交互细节以 Notion 设计稿为准。 + +## 1. 与 parallelRun 的核心差异速览 + +| 维度 | parallelRun | loopRun | +|---|---|---| +| 并发模型 | `batchRun` 并行 | 串行 `for` + `await` | +| 输入模式 | 只 array | array / conditional 二选一 | +| 终止条件 | 数组取尽 | 数组取尽(array 模式)/ `loopRunBreak` 命中 / 系统兜底 | +| 重试 | 有(0-5 次,默认 3) | 无 | +| 并发数配置 | 有(env 上限) | 无 | +| 输出 | 固定 3 个(success/full/status) | 用户自定义字段 + `errorText`(`loopRunIterations` / `loopRunHistory` 仅在调试 nodeResponse 中) | +| 子节点 | 子流程,无独立 Start 节点 | `loopRunStart`(unique,自动生成)+ `loopRunBreak`(信号节点,可多个) | +| interactive | 不支持 | 支持(对齐旧 loop) | +| runtimeNodes 隔离 | 每任务 cloneDeep | 进入 loopRun 时 cloneDeep 一次;迭代间共享 | +| 变量 `newVariables` | 每任务独立 | 跨迭代累加,结束回写 parent | + +--- + +## 2. 文件级改动清单 + +### 2.1 Global 枚举 & 类型 + +**`packages/global/core/workflow/node/constant.ts`** +- `FlowNodeTypeEnum` 新增 3 个成员: + - `loopRun = 'loopRun'` + - `loopRunStart = 'loopRunStart'` + - `loopRunBreak = 'loopRunBreak'` +- `isNestedParentNodeType()` 增加 `FlowNodeTypeEnum.loopRun` 判断 + +**`packages/global/core/workflow/constants.ts`** +- `NodeInputKeyEnum` 新增: + - `loopRunMode`(`'array' | 'conditional'`) + - `loopRunInputArray`(不复用 `nestedInputArray`,避免与旧 loop/parallelRun 的 `loopInputArray` 字符串值冲突) + - `loopCustomOutputs`(自定义输出字段声明区) +- `NodeOutputKeyEnum` 新增: + - `errorText`(若已存在则复用) + - `currentIndex` / `currentItem` / `currentIteration`(loopRunStart 动态输出) +- 不需要新增 status enum(loopRun 无 parallelStatus 这种状态输出) + +**`packages/global/core/workflow/runtime/type.ts`** +- `DispatchNodeResponseType` 增加:`loopRunInput?`、`loopRunIterations?`、`loopRunHistory?`、`loopRunDetail?` +- `WorkflowInteractiveResponseType` 已有 `loopInteractive` 类型,loopRun 复用现有结构(`currentIndex` → 改名对齐 `iteration` 可选,或直接用复用字段) + +**`packages/global/core/workflow/template/input.ts`** +- 复用现有 `Input_Template_Children_Node_List` / `Input_Template_Node_Width` / `Input_Template_Node_Height` / `Input_Template_NESTED_NODE_OFFSET` +- 新增 `Input_Template_LoopCustomOutputs`(参考代码节点的 `Output_Template_AddOutput` 做一个声明区输入) + +**`packages/global/core/workflow/template/constants.ts`** +- 导入 `LoopRunNode` / `LoopRunStartNode` / `LoopRunBreakNode` +- 添加到 `systemNodes` 数组 + +**`packages/global/core/workflow/template/system/loopRun/`**(新建目录) +- `loopRun.ts` - `LoopRunNode` 模板(参考 `parallelRun.ts`,去掉并发/重试输入,加 `loopRunMode` / `loopRunInputArray` / `loopCustomOutputs`;outputs 只保留 `errorText`,用户自定义字段由 dynamic output 声明;迭代数与历史仅在 nodeResponse 调试信息中返回) +- `loopRunStart.ts` - `LoopRunStartNode` 模板,`unique: true, forbidDelete: true`,outputs 按 mode 动态暴露(array 模式:currentIndex + currentItem;conditional 模式:currentIteration) +- `loopRunBreak.ts` - `LoopRunBreakNode` 模板,`inputs: []`、`outputs: []`,只有 target handle + +### 2.2 后端 Dispatcher + +**`packages/service/core/workflow/dispatch/index.ts`** +- 导入 `dispatchLoopRun` / `dispatchLoopRunStart` / `dispatchLoopRunBreak` +- `callbackMap` 注册 3 个回调 + +**`packages/service/core/workflow/dispatch/loopRun/`**(新建目录,对齐 `parallelRun/`) +- `runLoopRun.ts` - 主 dispatcher,结构参考 `runParallelRun.ts`: + - 入口:`cloneDeep(runtimeNodes / runtimeEdges)` 做循环级隔离 + - `for` 循环:mode 分支(array 取数组元素;conditional 只计次) + - 每轮 `injectLoopRunStart`(新工具,向 loopRunStart 注入 iteration/index/item) + - `runWorkflow` 跑子流程 + - 出错 catch → 读快照(过滤未跑节点)→ push loopHistory → break + - 正常结束 → 读快照(全字段)→ push loopHistory → 判断 flowResponses 含 loopRunBreak → break or 继续 + - interactive 响应 → 立即 break 并返回 `loopInteractive` 状态 + - 结束聚合:最后一项 `loopHistory.customOutputs` → 动态 outputs +- `runLoopRunStart.ts` - 简单透传(参考 `runLoopStart.ts`) +- `runLoopRunBreak.ts` - 纯信号,返回空 data,仅在 flowResponses 中留下 moduleType 标记 +- `service.ts` - loopRun 专属工具: + - `injectLoopRunStart({ nodes, mode, iteration, index?, item? })` - 向 loopRunStart 注入输入 + - `readCustomOutputSnapshot({ runtimeNodes, loopCustomOutputs, finishedNodeIds?, childrenNodeIdList? })` - 读 ref 写快照(传 finishedNodeIds 时按集合过滤;childrenNodeIdList 用于放行循环体外 ref) + - `extractFinishedNodeIds(flowResponses)` - 从 runWorkflow 响应提取已完成节点集合 + - `pickCustomOutputInputs(inputs)` - 筛选 `canEdit: true` 的动态输入声明 + - `hasLoopRunBreakChild(runtimeNodes, childrenNodeIdList)` / `isLoopBreakHit(flowResponses)` - conditional 模式 break 预检 + 运行时判定 +- `pushSubWorkflowUsage` / `collectResponseFeedbacks` - **不放在 service.ts**,统一在 `dispatch/utils.ts` 里实现并由 loop/loopRun 共用(见下条) + +**`packages/service/core/workflow/dispatch/utils.ts`** +- 新增跨 dispatcher 共用工具:`pushSubWorkflowUsage({ usagePush, response, name, iteration })`、`collectResponseFeedbacks(response, target)`。原 `loop/service.ts` 中的同名实现已下沉到这里;`loop/runLoop.ts` 调用处由 `index` → `iteration` 更名(语义等价:两者都传 1-based 迭代计数) +- parallelRun 不共用这套工具——它在 retry 循环里做了带累加器的就地聚合,抽出去反而复杂 +- 沿用 `safePoints`、`injectNestedStartInputs` 不变 + +**`packages/service/env.ts`** +- 无需新增 env;复用 `WORKFLOW_MAX_LOOP_TIMES` + +### 2.3 前端画板节点组件 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/`**(目录复用,新增文件) +- `NodeLoopRun.tsx` - 容器节点组件。基本参考 `NodeParallelRun.tsx`: + - 用 `useNestedNode` hook 处理大小/子节点列表 + - 注意:conditional 模式下 **没有 `nestedInputArray`**,`useNestedNode` 要么加开关、要么条件循环模式下不读这个 input + - 需要新增:`loopRunMode` select 切换;切换时清理 loopRunStart 的 ref 并给 toast 提醒 + - 需要新增:`loopCustomOutputs` 的自定义输出声明 UI(参考代码节点的 `RenderOutput` 模式) +- `NodeLoopRunStart.tsx` - start 节点 UI。参考 `NodeLoopStart.tsx`,但输出字段按父节点 `loopRunMode` 动态显示(array: currentIndex+currentItem; conditional: currentIteration) +- `NodeLoopRunBreak.tsx` - break 信号节点 UI,极简卡片,只有 target handle 和图标文字 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx`** +- 节点类型映射新增 3 条 dynamic import + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts`** +- 适配:`nestedInputArray` 读取改为可选(conditional 模式无该 input 时跳过 valueType 推断) +- 或者:新增参数 `arrayInputKey?: NodeInputKeyEnum`,允许 loopRun 传 `loopRunInputArray`;parallelRun 走默认 `nestedInputArray` + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx`** +- 若有节点复制/删除禁用白名单,加入 loopRun / loopRunStart / loopRunBreak + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useKeyboard.tsx`** +- 禁止跨容器 copy/paste 的规则补 loopRun + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx`** +- loopRun 子流程内禁止添加:loop / loopRun / parallelRun(**允许 interactive**,区别于 parallelRun) +- 拖入 loopRun 时自动创建 `loopRunStart` 子节点 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx`** +- 无需改动(模板列表从 systemNodes 派生) + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx`** +- loopRun 容器节点 `menuForbid={{ copy: true }}`(与 parallelRun 一致) + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx`** +- 确认 loopRunStart 的动态 outputs(currentIndex/currentItem/currentIteration)可以被子流程内其他节点正常引用 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx`** +- 无需改动(嵌套容器大小计算通用) + +**`projects/app/src/pageComponents/core/chat/components/WholeResponseModal.tsx`** +- 若需展示 loopRun 每轮详情(`loopRunDetail`),参考 parallelRun 的展示逻辑追加 + +**`projects/app/src/web/core/workflow/utils.ts`** +- 类型检查/图标映射工具函数补 loopRun + +### 2.4 图标 & i18n + +**`packages/web/components/common/Icon/`** +- `constants.ts` 注册 3 个图标:`core/workflow/template/loopRun` / `loopRunStart` / `loopRunBreak` +- `icons/core/workflow/template/loopRun.svg` / `loopRunLinear.tsx`(新建;linear 可先拷贝 parallelRunLinear 做 placeholder,后续设计出图再替换) +- 其他两个小节点如果视觉上复用现有 loop start/break 图标可跳过 + +**`packages/web/i18n/{zh-CN,en,zh-Hant}/workflow.json`** +- 新增 key: + - `loop_run` / `intro_loop_run` / `loop_run_execution_logic` + - `loop_run_mode` / `loop_run_mode_array` / `loop_run_mode_conditional` + - `loop_run_input_array` + - `loop_custom_outputs` / `loop_custom_outputs_tip` + - `loop_iterations` / `loop_history` + - `loop_run_break` / `loop_run_break_tip` + - `loop_run_start` / `current_index` / `current_item` / `current_iteration` + - `loop_run_mode_switch_warning`(模式切换警告) + - `loop_run_interactive_not_supported_in_xxx`(如有错误文案需要) +- `common.json` 若 parallelRun 在 common 里加了什么(如 limit 提示),loopRun 酌情加 + +### 2.5 配置 & 系统 + +**`packages/global/common/system/types/index.ts`** 和 **`projects/app/src/service/common/system/index.ts`** +- 若 loopRun 无需前端限制(比如没有 concurrency max),**无需改动** +- 否则参考 parallelRun 在 `FastGPTFeConfigsType.limit` 增加字段 + +**`projects/app/.env.template`** +- 无需新增 + +### 2.6 测试 + +**`test/cases/packages/service/core/workflow/dispatch/loopRun/service.test.ts`**(新建) +- `readCustomOutputSnapshot`: + - finishedNodeIds 为 undefined(成功轮)→ 全字段有值 + - finishedNodeIds 只包含部分节点 → 未包含节点的 ref 返回 undefined + - ref 目标节点不存在 → undefined +- `extractFinishedNodeIds`:从 flowResponses 正确推导 nodeId 集合 +- `injectLoopRunStart`:array 模式注入 index+item,conditional 模式注入 iteration + +**`test/cases/packages/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts`**(新建) +- array 模式数组取尽正常返回 +- array 模式中途节点出错 → loopHistory 最后一项 success:false,快照按已完成节点过滤 +- conditional 模式 loopRunBreak 命中 → 正常返回 +- conditional 模式超过 `WORKFLOW_MAX_LOOP_TIMES` → 系统兜底报错 +- interactive 响应 → 返回 loopInteractive,下次进入从中断轮次续跑 +- catchError=true 出错 → 走 errorText +- catchError=false 出错 → 直接抛 + +--- + +## 3. TODO(按依赖排序) + +### Phase 1 - 类型 & 枚举(无依赖) +- [ ] T1.1 `FlowNodeTypeEnum` 新增 3 个成员 + `isNestedParentNodeType` 更新 +- [ ] T1.2 `NodeInputKeyEnum` / `NodeOutputKeyEnum` 新增所需 key +- [ ] T1.3 `DispatchNodeResponseType` 扩展 loopRun 相关字段 +- [ ] T1.4 新增 `Input_Template_LoopCustomOutputs`(若需要) + +### Phase 2 - 节点模板(依赖 Phase 1) +- [ ] T2.1 `template/system/loopRun/loopRun.ts` +- [ ] T2.2 `template/system/loopRun/loopRunStart.ts` +- [ ] T2.3 `template/system/loopRun/loopRunBreak.ts` +- [ ] T2.4 `template/constants.ts` 注册 3 个模板到 `systemNodes` + +### Phase 3 - 后端 Dispatcher(依赖 Phase 2) +- [ ] T3.1 `dispatch/loopRun/service.ts` - `injectLoopRunStart` / `readCustomOutputSnapshot` / `extractFinishedNodeIds` +- [ ] T3.2 `dispatch/loopRun/runLoopRunStart.ts` +- [ ] T3.3 `dispatch/loopRun/runLoopRunBreak.ts` +- [ ] T3.4 `dispatch/loopRun/runLoopRun.ts` - 主循环(array 模式) +- [ ] T3.5 `dispatch/loopRun/runLoopRun.ts` - 补 conditional 模式 + loopRunBreak 判定 +- [ ] T3.6 `dispatch/loopRun/runLoopRun.ts` - 补 interactive 暂停恢复 +- [ ] T3.7 `dispatch/loopRun/runLoopRun.ts` - 补 catchError +- [ ] T3.8 `dispatch/index.ts` 注册 3 个回调 +- [ ] T3.9 局部测试:`test/cases/packages/service/core/workflow/dispatch/loopRun/service.test.ts` +- [ ] T3.10 局部测试:`test/cases/packages/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts` + +### Phase 4 - 前端节点组件(可与 Phase 3 并行) +- [ ] T4.1 图标资源(SVG + Linear TSX)+ `Icon/constants.ts` 注册 +- [ ] T4.2 i18n key 三语言 +- [ ] T4.3 `NodeLoopRunBreak.tsx`(最简单) +- [ ] T4.4 `NodeLoopRunStart.tsx`(按 mode 动态 outputs) +- [ ] T4.5 `useNestedNode` hook 适配(支持 conditional 模式无 nestedInputArray) +- [ ] T4.6 `NodeLoopRun.tsx`(含 mode select、custom outputs 声明 UI) +- [ ] T4.7 `Flow/index.tsx` 节点类型映射 +- [ ] T4.8 `NodeTemplates/list.tsx` 拖入时自动创建 loopRunStart;子流程内禁止添加循环/并行容器(允许 interactive) +- [ ] T4.9 `useKeyboard.tsx` / `useWorkflow.tsx` 复制/删除规则 +- [ ] T4.10 `WholeResponseModal.tsx` 每轮详情展示 + +### Phase 5 - 校验 & 集成 +- [ ] T5.1 保存时校验:conditional 模式至少 1 个 loopRunBreak(编辑器层) +- [ ] T5.2 运行时 dispatch 预检查:数组长度上限、嵌套规则兜底 +- [ ] T5.3 模式切换 UI 提示(ref 失效 toast) + +### Phase 6 - 全量验证 +- [ ] T6.1 `pnpm test` 全量跑 +- [ ] T6.2 `pnpm lint` +- [ ] T6.3 前端手工验证:拖节点/切模式/数组模式跑通/条件模式跑通/break 生效/interactive 暂停恢复/catchError=true/false + +--- + +## 4. 开放问题(开发中可能需要与用户对齐) + +1. **`useNestedNode` 是否重构为支持可选 arrayInputKey?** 当前只读 `nestedInputArray`,loopRun 用独立 key `loopRunInputArray`。两种方案: + - A:hook 增加 `arrayInputKey` 参数 + - B:在 loopRun 组件里不用 hook,单独写大小同步逻辑 + - 建议 A(复用度高) + +2. **自定义输出声明 UI 复用策略**:代码节点(`sandbox`)的 `Output_Template_AddOutput` 实现在 `RenderOutput` 里。loopRun 的 custom outputs 语义是"引用子流程内节点输出,声明为 parent 节点的 output",跟代码节点不完全一样(代码节点是由代码产出)。需确认: + - 是否直接复用现有组件?还是需要新写一个"ref-based custom output" 声明器? + +3. **interactive 暂停恢复的 `customOutputs` 快照**:interactive 响应本身不是"失败",是中断。恢复时: + - 中断时的快照要不要同步记录?(若恢复后正常跑完,该轮会重新读快照覆盖) + - 建议:不在 interactive 时写 loopHistory,恢复后按正常轮处理 + +4. **`loopRunBreak` 是否放在 loop/parallelRun 外部但误连到 loopRun 子流程内**:静态校验层面需要严格卡住父容器归属 + +5. **`FlowNodeTypeEnum.nestedEnd`(旧 loopEnd)与 loopRun**:loopRun 不使用 nestedEnd,runtime 也不应匹配到。 + +--- + +## 5. 实现期间引入的跨模块改动(区分于设计清单) + +这几处在 loopRun 开发过程中顺手改到了,不是 loopRun 本身的功能需求,但**对其他节点也有影响**,单独列出来避免回顾时误判是 loopRun 独占的修改。 + +### 5.1 dispatch/index.ts 错误归一化 + +**位置**:`packages/service/core/workflow/dispatch/index.ts` 在 "dispatcher 返回 `{error}` + `catchError=false`" 分支里补了一段: + +```ts +const nodeResponseBase = result[DispatchNodeResponseKeyEnum.nodeResponse]; +const errText = nodeResponseBase?.errorText ?? getErrText(result.error as any); +return { + ...result, + [DispatchNodeResponseKeyEnum.nodeResponse]: { + ...nodeResponseBase, + error: errText + }, + ... +}; +``` + +**动机**:loopRun / parallelRun 需要一种稳定方式识别失败轮。`dispatch/index.ts` 里 dispatcher throw 的 catch 兜底分支本来就在写 `nodeResponse.error`,OTel span status 也读 `nodeResponse.error`。唯独 dispatcher 返回 `{error}` + `catchError=false` 的非 throw 路径不写 `error`,导致失败检测链路断裂。 + +**方案**:给非 throw 路径补齐 `nodeResponse.error`,和 catch 兜底 + OTel 统一到同一个字段。`errorText` 保留给 UI 展示(schema 注释 `// Just show`)。 + +**顺带修复**:`parallelRun/service.ts` 早就在用 `flowResponses.find((r) => r.error)`,但同样因为这条链路没写 `error` 而始终落到 fallback 文案 `parallel_task_not_reach_end`。归一化后 parallelRun 也能拿到真实错误。OTel span error status 也终于会在这条路径上正确触发。 + +**安全性**:`DispatchNodeResponseSchema` 里 `error` 本来就是可选字段;grep 过所有消费方,没有代码因为 `.error` 现在会被填充而出问题。 + +### 5.2 WholeResponseModal 里 `updateVarResult` 单元素数组解包 + +**位置**:`projects/app/src/components/core/chat/components/WholeResponseModal.tsx` L512-L527。 + +**动机**:loopRun 调试时常见一类工作流是"loopRun 内用变量更新节点改写循环体外的代码节点输出 → 在 WholeResponseModal 里看累计结果"。`updateVarResult` 本来是 `updateList.map(...)` 的数组,单行配置时展示为 `[{...}]`,当内部 value 又是数组时会出现 `[[...]]` 的视觉双层嵌套,调试极差。 + +**行为**:长度为 1 且 `r[0] !== null && r[0] !== undefined` 时解包外层。保留两种 signal: +- 多行配置仍按数组展示(未做破坏) +- `[null]`(无效引用)仍保留外层 → 用户能看到"这里是无效引用" + +**风险面**:全体 Row 渲染规则未变,`val === undefined | '' | 'undefined'` 仍会隐藏。对非 loopRun 用户是展示改善,不是功能改动。 + +### 5.3 `useNestedNode` 子节点尺寸订阅 (`childDimensionsSignal`) + +**位置**:`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts` L55-L65。 + +**动机**:loopRun 体积计算沿用了 loop / parallelRun 的老逻辑——`setTimeout(() => resetParentNodeSizeAndPosition(nodeId), 50)`,在快速拖入子节点时定时器会抢跑在 ReactFlow 测量宽高之前,算出的 bounds 偏小。 + +**方案**:订阅 `WorkflowInitContext.nodes` 里所有 parent 匹配的子节点 `${id}:${width}x${height}`,拼成字符串 signal。signal 变化触发重新计算 bounds。 + +**影响面**:loop / parallelRun 也在用 `useNestedNode`——同时吃到这个修复。没有看到对 loopRun 之外的回归,但建议在人工验证清单里对 loop / parallelRun 的拖拽体验回归一次。 + +### 5.4 `pushSubWorkflowUsage` / `collectResponseFeedbacks` 下沉到 `dispatch/utils.ts` + +见 `2.2 后端 Dispatcher` 一节。原本 `loop/service.ts` 里的实现被挪到 `dispatch/utils.ts`,loop / loopRun 共用。参数名 `index` → `iteration`(两者语义等价,都是 1-based 迭代计数)。这是 loopRun feature PR 的一部分去重改动,不是 bug 修复。 diff --git a/.codex/design/core/workflow/parallel-node/design.md b/.codex/design/core/workflow/parallel-node/design.md new file mode 100644 index 0000000000..5fcca9e3af --- /dev/null +++ b/.codex/design/core/workflow/parallel-node/design.md @@ -0,0 +1,878 @@ +# 并行执行节点(ParallelRun)设计文档 v3 + +> 需求:在工作流中新增"并行执行"节点,和现有的串行"批量执行(Loop)"并列。 +> 原则:**完全不改动旧 Loop 节点的行为与文件**;parallelRun 作为新容器节点,**直接复用现有的 loopStart / loopEnd 作为其 Start/End 子节点**;通过 enum 改名(value 保持)统一语义。 +> 参考:[PR #6675](https://github.com/labring/FastGPT/pull/6675) + +## 版本变迁 +- v1:独立 parallelRunStart/End + 复制 NodeLoop +- v2:独立类型 + 抽 hook + 迁移 nested 目录 +- **v3(当前)**:复用 loopStart/loopEnd 作为通用"嵌套容器起/终点",通过 enum 改名但保持 value 兼容,最小化改动 + +--- + +## 1. 需求与约束 + +### 1.1 需求 +新增并行执行节点,使输入数组中各元素对应的子工作流可以并发执行,典型场景:批量 LLM 调用、并行抓取 URL、并行数据处理。 + +### 1.2 关键约束(来自 user) +1. **旧 Loop 节点 0 改动**(不破坏变量累积语义、交互响应断点等) +2. **能复用就复用**:Start / End 节点、组件 UI、Hook 都应尽量抽取共享 +3. **env 上限要反映到前端**:作为并发数输入的 max 校验与提示 +4. **单个任务失败处理**:结果输出(`parallelRunArray`)中**过滤掉失败项**;完整节点响应中带完整状态数组 `[{success, data}, ...]` +5. **交互节点禁止**:前端阻塞用户把交互节点拖入并行体;极端情况下(API 导入绕过前端),后端**忽略**该任务输出而不 reject +6. **抽 hook**:UI 通用逻辑抽成共享 hook + +--- + +## 2. 核心策略:复用 loopStart/loopEnd + Enum 改名 + +### 2.1 关键事实(v3 新发现) + +原先 v2 认为 `unique: true` 是全局唯一,会阻止 loopStart 在多个容器下共存。**深入调查后发现这是误解**: + +| 检查点 | 文件 | 实际作用 | +|-------|-----|---------| +| `useNodeTemplates.tsx:42-48` | `getNodeList().some(...)` + 模板列表过滤 | **仅隐藏模板面板里的可见项**,防止用户手动拖第二个 | +| `useKeyboard.tsx:52,71` | 复制时过滤 | 复制粘贴时去掉 unique 节点 + 整个 loop 禁止复制 | +| `list.tsx:325-340` 自动创建 | `newNodes.push(startNode, endNode)` | **完全不经过 unique 检查** | + +**现状**:`loop` 节点本身**没有** `unique: true`。用户可以在同一工作流里拖 N 个 Loop,每个 Loop 自动生成自己的 loopStart + loopEnd。**多个 loopStart 节点并存早已是现状**。 + +**推论**:parallelRun 节点**可以直接把 loopStart / loopEnd 作为自己的 Start/End 子节点**,unique 检查完全不干扰。 + +### 2.2 loopStart/loopEnd 前端组件的硬编码:不是障碍 + +- `NodeLoopStart.tsx:41` 读父节点的 `NodeInputKeyEnum.loopInputArray` +- `NodeLoopEnd.tsx:63-66` 写父节点的 `NodeOutputKeyEnum.loopArray` + +**只要 parallelRun 节点的输入/输出也用这两个 key,这两个组件 0 改动就能工作**。 + +### 2.3 Enum 改名策略(user 指定) + +**原则**:**enum 键名改为语义通用的 `nested*`,TypeScript 值保持原字符串不变**。 + +这样做: +- ✅ 代码里写 `NodeInputKeyEnum.nestedInputArray` 语义清晰 +- ✅ 运行时序列化数据仍是 `"loopInputArray"`,数据库/JSON 导出完全兼容 +- ✅ 不需要数据迁移 +- ✅ 复用 NodeLoopStart/End 组件 0 改动 + +### 2.4 完整改名清单 + +#### `FlowNodeTypeEnum` +```typescript +export enum FlowNodeTypeEnum { + loop = 'loop', // 保持(旧 Loop 节点) + nestedStart = 'loopStart', // 改名(原 loopStart),作为所有嵌套容器的起点 + nestedEnd = 'loopEnd', // 改名(原 loopEnd),作为所有嵌套容器的终点 + parallelRun = 'parallelRun', // 新增 + // ... +} +``` + +#### `NodeInputKeyEnum` +```typescript +nestedInputArray = 'loopInputArray', // 改名 +nestedStartInput = 'loopStartInput', // 改名 +nestedStartIndex = 'loopStartIndex', // 改名 +nestedEndInput = 'loopEndInput', // 改名 +nestedNodeInputHeight = 'loopNodeInputHeight', // 改名 +parallelRunMaxConcurrency = 'parallelRunMaxConcurrency', // 新增 +``` + +#### `NodeOutputKeyEnum` +```typescript +nestedArrayResult = 'loopArray', // 改名 +nestedStartInput = 'loopStartInput', // 改名 +nestedStartIndex = 'loopStartIndex', // 改名 +``` + +#### 常量 +```typescript +// packages/global/core/workflow/template/input.ts +Input_Template_NESTED_NODE_OFFSET // 原 Input_Template_LOOP_NODE_OFFSET +// 内部的 key 字段引用也相应改为 NodeInputKeyEnum.nestedNodeInputHeight +``` + +### 2.5 改名影响范围(已 grep 统计) + +| 改名项 | 影响源文件数 | +|-------|------------| +| `NodeInputKeyEnum.loop*` | 12 个源文件 | +| `NodeOutputKeyEnum.loop*` | 6 个源文件 | +| `FlowNodeTypeEnum.loopStart/End` | 7 个源文件 | +| `Input_Template_LOOP_NODE_OFFSET` | 3 个源文件 | + +**总计约 15-18 个源文件**(有重叠),全部是**机械式替换**(IDE rename symbol),0 运行时行为变更。 + +### 2.6 决策总结 + +**方案 A 完整版**: +1. 改名现有 loop 相关 enum 键为 `nested*`,value 保持 +2. `FlowNodeTypeEnum.loopStart` / `loopEnd` 改名为 `nestedStart` / `nestedEnd`(值保持) +3. **新增** `FlowNodeTypeEnum.parallelRun` +4. **新增** 1 个后端 dispatch(`runParallelRun.ts`) +5. **新增** 1 个前端节点组件(`NodeParallelRun.tsx`) +6. parallelRun 节点的输入数组复用 `nestedInputArray`(即原 loopInputArray),输出数组复用 `nestedArrayResult` +7. parallelRun 节点的 Start/End 直接复用 `nestedStart` / `nestedEnd`,0 新组件、0 新 dispatch、0 新 i18n Start/End 文案 +8. **0 改动** `NodeLoop.tsx` / `NodeLoopStart.tsx` / `NodeLoopEnd.tsx` / `runLoop.ts` / `runLoopStart.ts` / `runLoopEnd.ts` 的运行时逻辑(只有 enum 键名的引用替换) + +**不需要做的事**(相对 v2): +- ❌ 不抽 `useContainerNode` / `useContainerChildIO` hook +- ❌ 不建 `ContainerNodeShell.tsx` +- ❌ 不迁移 `Loop/` 目录到 `nested/` +- ❌ 不新增 parallelRunStart / parallelRunEnd flowNodeType +- ❌ 不重构 NodeLoop 系列组件 +- ❌ 不新增 Start/End 的 i18n 文案和图标 + +--- + +## 3. 详细设计决策 + +### 3.1 节点命名 + +| 项目 | 值 | +|------|-----| +| FlowNodeType | `parallelRun` / `parallelRunStart` / `parallelRunEnd` | +| 中文名 | 并行执行 / 并行开始 / 并行结束 | +| 英文名 | Parallel Run / Parallel Start / Parallel End | +| colorSchema | `blue`(与 Loop 的 `violetDeep` 区分)| +| 图标 | 复用 PR #6675 的 `loopPro*.svg`,重命名为 `parallelRun*.svg` | + +### 3.2 输入/输出 Key + +parallelRun 节点**完全复用**现有 enum(改名后): + +| 位置 | Key | 说明 | +|-----|-----|-----| +| parallelRun 输入:数组 | `nestedInputArray` (value `'loopInputArray'`) | 和 loop 共享 | +| parallelRun 输入:子节点列表 | `childrenNodeIdList` | 原本就通用 | +| parallelRun 输入:容器宽高 | `nodeWidth` / `nodeHeight` | 原本就通用 | +| parallelRun 输入:输入框高度 | `nestedNodeInputHeight` | 和 loop 共享 | +| parallelRun 输入:**新增** | `parallelRunMaxConcurrency` | parallelRun 独有 | +| parallelRun 输出:结果数组 | `nestedArrayResult` (value `'loopArray'`) | 和 loop 共享 | +| nestedStart(= loopStart)输入 | `nestedStartInput` / `nestedStartIndex` | 由 parallelRun 的 dispatch 注入 | +| nestedEnd(= loopEnd)输入 | `nestedEndInput` | 由用户连线 | + +**唯一新增的 key**:`parallelRunMaxConcurrency`(用于前端配置并发上限输入)。 + +### 3.3 并发执行语义 + +| 行为 | 决策 | 理由 | +|------|-----|------| +| 并发调度 | **`batchRun`**(`packages/global/common/system/utils.ts:22`)| 项目已有的 worker-pool 工具:固定 N 个 worker 循环抢任务,结果按原 index 写回数组,顺序保证 | +| 并发上限 | 节点输入 `parallelRunMaxConcurrency`(默认 5),非整数向下取整;在 dispatch 里 clamp 到 env `WORKFLOW_PARALLEL_MAX_CONCURRENCY`(默认 10);env 本身 clamp 到 `[5, 100]` | 运维约束 + 用户灵活 | +| 最大迭代数 | 复用 `WORKFLOW_MAX_LOOP_TIMES`(默认 100,与生产部署对齐)| 避免重复 | +| 变量作用域 | 各迭代间**隔离**,不合并 `newVariables` 回主 variables | 并行无时序,合并不可预期 | +| 错误处理 | 任务 fn 内部用 try/catch 包裹 `runWorkflow`,返回 `{success, index, data/error}` 结构体;不抛出,避免中断 worker pool | 见 §3.4 | +| 交互响应 | 后端**忽略**该任务输出(视为失败项,静默过滤);前端**阻塞**交互节点拖入 | 见 §3.5 | +| 输出顺序 | `batchRun` 天然按 index 回填,保证 `output[i]` 对应 `input[i]` | 符合用户直觉 | +| cost 汇总 | 累加所有任务的 totalPoints | 与 Loop 一致 | + +**`batchRun` 签名**: +```typescript +batchRun( + arr: T[], + fn: (item: T, index: number) => Promise, + batchSize = 10 +): Promise +``` + +**使用方式**: +```typescript +import { batchRun } from '@fastgpt/global/common/system/utils'; + +const concurrency = Math.min( + Number(params.parallelRunMaxConcurrency) || 5, + Number(process.env.WORKFLOW_PARALLEL_MAX_CONCURRENCY) || 10 +); + +const taskResults = await batchRun( + loopInputArray, + async (item, index) => { + // 见 §3.6 的 per-task 克隆逻辑 + }, + concurrency +); +``` + +### 3.4 错误处理与输出结构 ⚠️(user 指定) + +**两种输出**: + +1. **`parallelRunArray`(节点输出,供下游引用)**:**只包含成功项** + ```typescript + // 示例 + [result_0, result_2] // 过滤掉 index 1 的失败项 + ``` + +2. **`nodeResponse.parallelRunDetail`(完整执行详情,仅在运行详情里展示)**: + ```typescript + [ + { success: true, index: 0, data: result_0 }, + { success: false, index: 1, error: 'xxx error message' }, + { success: true, index: 2, data: result_2 } + ] + ``` + +**实现**: +```typescript +const settled = await Promise.allSettled(/* ... */); +const fullStatus = settled.map((r, idx) => { + if (r.status === 'fulfilled' && r.value?.isSuccess) { + return { success: true, index: idx, data: r.value.output }; + } + return { success: false, index: idx, error: extractError(r) }; +}); + +const outputArray = fullStatus.filter((s) => s.success).map((s) => s.data); +``` + +### 3.5 交互节点处理(user 指定) + +**前端阻塞**(第一道防线): +扩展 `useWorkflow.tsx:431-455` 的 `checkNodeOverLoopNode`(或新增 `checkNodeOverParallelNode`),使之对 `FlowNodeTypeEnum.parallelRun` 生效,并在 `unSupportedTypes` 里追加交互类节点: + +```typescript +const unSupportedInParallel = [ + // 旧 loop 也有的限制 + FlowNodeTypeEnum.workflowStart, + FlowNodeTypeEnum.loop, + FlowNodeTypeEnum.parallelRun, // 禁止嵌套 parallelRun + FlowNodeTypeEnum.pluginInput, + FlowNodeTypeEnum.pluginOutput, + FlowNodeTypeEnum.systemConfig, + // parallelRun 额外禁止的交互节点 + FlowNodeTypeEnum.formInput, + FlowNodeTypeEnum.userSelect, + // 如果有其他交互相关类型(tool 的 interactive),也加上 +]; +``` + +> 需要在实现时 grep 所有 interactive/表单类 FlowNodeType 枚举项,确保覆盖。 + +用户拖放时若节点类型在禁止列表 → toast 提示 "该节点不支持并行执行"(新增 i18n key `workflow:can_not_parallel`)。 + +**后端兜底**(第二道防线): +在 `dispatchParallelRun` 中,若某个子任务的 `runWorkflow` 返回 `workflowInteractiveResponse`,视为该任务失败: +```typescript +if (response.workflowInteractiveResponse) { + // 忽略该任务输出,标记为失败 + return { isSuccess: false, error: 'Interactive node is not supported in parallel run' }; +} +``` + +不 reject,不中断整个并行节点。 + +### 3.5a 后端 service 抽离(为单测隔离) + +**原则**:`runParallelRun.ts` 的 dispatch 入口只做**编排**,核心逻辑拆成**纯函数 service**,可独立单测,不依赖 `runWorkflow`、MongoDB、I/O。 + +**目录结构**: +``` +packages/service/core/workflow/dispatch/parallelRun/ +├── runParallelRun.ts # dispatch 入口,仅编排流程 +└── service.ts # 所有纯函数 service 集中在一个文件,通过 export 暴露 +``` + +**`service.ts` 导出的纯函数**(全部无 I/O): +- `clampParallelConcurrency` — 并发数 clamp +- `buildTaskRuntimeContext` — per-task 克隆 + 注入 entry +- `parseTaskResponse` / `parseTaskError` — 单任务响应解析(成功/失败/交互) +- `aggregateParallelResults` — 聚合所有任务结果 + +**service 函数契约**: + +```typescript +// service/concurrency.ts +export const clampParallelConcurrency = ( + userInput: number | undefined, + envMax: number | undefined +): number; + +// service/taskContext.ts +export const buildTaskRuntimeContext = (params: { + runtimeNodes: RuntimeNodeItemType[]; + runtimeEdges: StoreEdgeItemType[]; // 原始边,内部做 storeEdges2RuntimeEdges + childrenNodeIdList: string[]; + item: any; + index: number; +}): { + taskRuntimeNodes: RuntimeNodeItemType[]; + taskRuntimeEdges: RuntimeEdgeItemType[]; +}; + +// service/taskResult.ts +export type ParallelTaskResult = + | { success: true; index: number; data: any; response: DispatchFlowResponse } + | { success: false; index: number; error?: string }; + +export const parseTaskResponse = (params: { + index: number; + response: DispatchFlowResponse; +}): ParallelTaskResult; +// 逻辑:workflowInteractiveResponse 存在 → 静默失败;否则从 flowResponses 找 nestedEnd.loopOutputValue + +export const parseTaskError = (index: number, err: unknown): ParallelTaskResult; + +// service/aggregate.ts +export const aggregateParallelResults = ( + taskResults: ParallelTaskResult[] +): { + filteredArray: any[]; // parallelRunArray 输出(过滤失败项) + fullDetail: Array<{ // nodeResponse.parallelRunDetail + success: boolean; + index: number; + data?: any; + error?: string; + }>; + totalPoints: number; + responseDetails: ChatHistoryItemResType[]; + assistantResponses: AIChatItemValueItemType[]; + customFeedbacks: string[]; +}; +``` + +**dispatch 编排骨架**: +```typescript +export const dispatchParallelRun = async (props): Promise => { + const { loopInputArray = [], childrenNodeIdList = [] } = props.params; + + // 前置校验(保留在入口) + if (!Array.isArray(loopInputArray)) return Promise.reject('Input value is not an array'); + if (loopInputArray.length > maxLoopTimes) return Promise.reject(`... > ${maxLoopTimes}`); + + const concurrency = clampParallelConcurrency( + props.params.parallelRunMaxConcurrency, + process.env.WORKFLOW_PARALLEL_MAX_CONCURRENCY + ); + + const taskResults = await batchRun( + loopInputArray, + async (item, index) => { + const { taskRuntimeNodes, taskRuntimeEdges } = buildTaskRuntimeContext({ + runtimeNodes: props.runtimeNodes, + runtimeEdges: props.runtimeEdges, + childrenNodeIdList, + item, + index + }); + try { + const response = await runWorkflow({ + ...props, + variables: { ...props.variables }, + runtimeNodes: taskRuntimeNodes, + runtimeEdges: taskRuntimeEdges + }); + return parseTaskResponse({ index, response }); + } catch (err) { + return parseTaskError(index, err); + } + }, + concurrency + ); + + const aggregated = aggregateParallelResults(taskResults); + // ... 返回 DispatchNodeResultType +}; +``` + +**测试分层**: +- **单测(无 runWorkflow)**: + - `concurrency.test.ts` — clampParallelConcurrency 纯数学 + - `taskContext.test.ts` — 断言克隆后修改 taskRuntimeNodes 不影响原 runtimeNodes + - `taskResult.test.ts` — mock 各种 DispatchFlowResponse 输入 + - `aggregate.test.ts` — mock ParallelTaskResult 数组,测过滤/聚合 +- **Integration 测试**(跑真实 runWorkflow,少而精): + - 1 个 happy path:输入数组 → 并发执行 → 输出符合预期 + - 1 个 error case:某任务抛错 → 结果数组过滤 + 详情完整 + - 1 个 interactive case:子节点产生 interactive → 静默失败 + +### 3.6 runtimeNodes / runtimeEdges 克隆(per-task lazy clone)⚠️ + +**核心问题**:并行下多个迭代**同时**进入 `nestedStart`,会并发写 `input.value`、并发修改 `runtimeEdges.status`,必须为每个任务独立的子图副本。 + +**优化策略**:克隆**放在 `batchRun` 的 fn 内部**,每次 worker 实际开始执行任务时才克隆,执行完 fn 立即释放。 + +**效果**: +- 同一时刻活跃的克隆数 **= 并发数(5~10)**,不是数组长度(最多 50) +- 峰值内存从 `O(arrayLength × subgraphSize)` 降到 `O(concurrency × subgraphSize)` +- 克隆对象在 fn 返回后立即 GC + +**实现骨架**: +```typescript +const taskResults = await batchRun( + loopInputArray, + async (item, index) => { + // ✅ 这里执行时才克隆。整个 fn 生命周期 = 一份克隆的生命周期 + const taskRuntimeNodes = cloneDeep(runtimeNodes); + const taskRuntimeEdges = cloneDeep(storeEdges2RuntimeEdges(runtimeEdges)); + + // 注入 entry 与当前迭代项 + taskRuntimeNodes.forEach((node) => { + if (!childrenNodeIdList.includes(node.nodeId)) return; + if (node.flowNodeType === FlowNodeTypeEnum.nestedStart) { + node.isEntry = true; + node.inputs.forEach((input) => { + if (input.key === NodeInputKeyEnum.nestedStartInput) input.value = item; + if (input.key === NodeInputKeyEnum.nestedStartIndex) input.value = index + 1; + }); + } + }); + + try { + const response = await runWorkflow({ + ...props, + variables: { ...props.variables }, + runtimeNodes: taskRuntimeNodes, + runtimeEdges: taskRuntimeEdges + }); + + // 交互节点:静默忽略(user 指定) + if (response.workflowInteractiveResponse) { + return { success: false, index, error: undefined }; + } + + const output = response.flowResponses.find( + (r) => r.moduleType === FlowNodeTypeEnum.nestedEnd + )?.loopOutputValue; + + return { success: true, index, data: output, response }; + } catch (err: any) { + return { success: false, index, error: err?.message || String(err) }; + } + // fn 返回 → taskRuntimeNodes / taskRuntimeEdges 出作用域 → GC + }, + concurrency +); +``` + +**与 v2 方案的差别**:v2 是预先 `cloneDeep × N` 再 `Promise.all`,峰值 N 份;v3 是 worker-pool 抢任务后才 clone,峰值 = concurrency 份。 + +### 3.7 feConfigs 前端暴露 env 上限 + +扩展 `packages/global/common/system/types/index.ts` 的 `limit` 对象(约 109-114 行): + +```typescript +limit?: { + exportDatasetLimitMinutes?: number; + websiteSyncLimitMinuted?: number; + agentSandboxMaxEditDebug?: number; + agentSandboxMaxSessionRuntime?: number; + workflowParallelRunMaxConcurrency?: number; // 新增 +}; +``` + +**注入**:在全局配置初始化时(`projects/app/src/service/common/system/index.ts` 或类似 `initSystemConfig`)读取 `process.env.WORKFLOW_PARALLEL_MAX_CONCURRENCY`,写入 `global.feConfigs.limit`。 + +**前端消费**:在 `NodeParallelRun` 的并发数输入字段中: +```typescript +const { feConfigs } = useSystemStore(); +const maxConcurrency = feConfigs?.limit?.workflowParallelRunMaxConcurrency ?? 10; +// 输入框 max={maxConcurrency},提示 "最大不超过 ${maxConcurrency}" +``` + +**后端二次校验**:`dispatchParallelRun` 里通过 `clampParallelConcurrency` service 做 clamp(见 §3.5a)。 + +### 3.8 前端逻辑层 / 渲染层分离(为单测隔离) + +**原则**:NodeParallelRun 组件只负责 JSX 渲染和 React 副作用的"壳",**纯逻辑**(计算、类型推导、输入合法性校验等)抽到 utils 文件,通过 `projects/app/test/pageComponents/app/detail/WorkflowComponents/` 下的单测覆盖。 + +**参考范式**:项目已有 `projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts`,证明这种模式在 codebase 中成熟。 + +**NodeParallelRun 的可测逻辑**(抽离到 utils): + +``` +projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/ +├── NodeParallelRun.tsx # 壳:JSX + hooks 调用 +└── parallelRun.utils.ts # 纯函数:可测 +``` + +**`parallelRun.utils.ts` 契约**: + +```typescript +// 从 inputs 数组读取并发数,并 clamp 到 env 上限 +export const resolveParallelConcurrency = ( + inputs: FlowNodeInputItemType[], + envMax: number | undefined +): number; + +// 从数组输入的 reference 推导元素 valueType +export const resolveArrayItemValueType = (params: { + arrayReferenceValue: ReferenceArrayValueType | undefined; + nodeIds: string[]; + globalVariables: Array<{ key: string; valueType: WorkflowIOValueTypeEnum }>; + getNodeById: (id: string) => RuntimeNodeItemType | undefined; +}): WorkflowIOValueTypeEnum; + +// 校验用户输入的并发数是否合法 +export const validateConcurrencyInput = ( + value: unknown, + envMax: number +): { valid: boolean; error?: string; clamped?: number }; +``` + +**组件内的使用**(保持薄): +```tsx +const NodeParallelRun = ({ data, selected }: NodeProps) => { + const { nodeId, inputs, outputs } = data; + const { feConfigs } = useSystemStore(); + const envMax = feConfigs?.limit?.workflowParallelRunMaxConcurrency ?? 10; + + // 使用纯函数获取派生状态(可测) + const concurrency = useMemo( + () => resolveParallelConcurrency(inputs, envMax), + [inputs, envMax] + ); + + // React 专属副作用(不测) + useEffect(() => { + // 同步 childrenNodeIdList / 尺寸(这些副作用和 NodeLoop 的模式一致) + }, [...]); + + return ( + + {/* JSX */} + + ); +}; +``` + +**测试位置与范围**: +``` +projects/app/test/pageComponents/app/detail/WorkflowComponents/ +└── nodes/Loop/ + └── parallelRun.utils.test.ts # 新增 +``` + +**测试覆盖**(见 §8.2): +- `resolveParallelConcurrency` — 默认值/用户输入/env clamp/无效输入 +- `resolveArrayItemValueType` — 字符串数组/对象数组/空引用/无效节点 +- `validateConcurrencyInput` — 正常值/负数/超限/非数字 + +**不测的部分**: +- React 组件的渲染结果(依赖 ReactFlow context,搭建环境成本高) +- useEffect 的副作用(集成到 end-to-end 手动验证 §8.3) +- NodeLoop **完全不动**(保持 v3 决策:旧功能 0 改动) + +--- + +## 4. 详细文件清单(v3 精简版) + +### 4.1 新增文件(仅 3 个) + +#### 4.1.1 parallelRun 节点模板 +``` +packages/global/core/workflow/template/system/parallelRun/ +└── parallelRun.ts # 主容器节点模板,复用 nestedInputArray/nestedArrayResult 等 key +``` + +#### 4.1.2 后端 dispatch +``` +packages/service/core/workflow/dispatch/parallelRun/ +└── runParallelRun.ts # 并行主调度,参考 runLoop.ts 但用 Promise.allSettled + 并发限制 +``` + +> `dispatchLoopStart` / `dispatchLoopEnd` 完全复用(重命名为 `dispatchNestedStart` / `dispatchNestedEnd` 作为机械式 rename)。 + +#### 4.1.3 前端 parallelRun 节点组件 +``` +projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/ +└── NodeParallelRun.tsx # 参考 NodeLoop.tsx 但 i18n 显示"并行执行",并多一个并发数输入框 +``` + +> **不需要新建** NodeParallelRunStart / NodeParallelRunEnd:parallelRun 的 Start/End 子节点直接用 `FlowNodeTypeEnum.nestedStart` / `nestedEnd`,渲染时走 `Flow/index.tsx` 的 `nodeTypes` 映射到原 `NodeLoopStart` / `NodeLoopEnd` 组件。 + +#### 4.1.4 图标(2 个 SVG,可选) +``` +packages/web/components/common/Icon/icons/core/workflow/template/ +├── parallelRun.svg # 复用 PR #6675 的 loopPro.svg +└── parallelRunLinear.svg # 对应线条图标 +``` + +> Start/End 图标**复用现有的 loopStart/loopEnd 图标**(语义上是通用"嵌套容器起点/终点")。 + +### 4.2 修改文件 + +#### 4.2.1 Enum 机械改名(0 行为变更) + +所有 `loop*` 键名改为 `nested*`,value 保持原字符串不变。 + +**源枚举改动**: + +| 文件 | 改动 | +|------|-----| +| `packages/global/core/workflow/constants.ts` | `NodeInputKeyEnum`: `loopInputArray→nestedInputArray` / `loopStartInput→nestedStartInput` / `loopStartIndex→nestedStartIndex` / `loopEndInput→nestedEndInput` / `loopNodeInputHeight→nestedNodeInputHeight`(值保持);新增 `parallelRunMaxConcurrency`、`parallelRunArray` | +| `packages/global/core/workflow/constants.ts` | `NodeOutputKeyEnum`: `loopArray→nestedArrayResult` / `loopStartInput→nestedStartInput` / `loopStartIndex→nestedStartIndex`(值保持)| +| `packages/global/core/workflow/node/constant.ts` | `FlowNodeTypeEnum`: `loopStart→nestedStart` / `loopEnd→nestedEnd`(值保持);新增 `parallelRun = 'parallelRun'`;`loop` 保持不变 | +| `packages/global/core/workflow/template/input.ts` | `Input_Template_LOOP_NODE_OFFSET` → `Input_Template_NESTED_NODE_OFFSET`;内部 `key` 字段引用改为 `nestedNodeInputHeight` | + +**引用改名的源文件清单**(约 15-18 个,IDE rename symbol 可一次性完成): +- `packages/global/core/workflow/template/system/loop/{loop,loopStart,loopEnd}.ts` +- `packages/service/core/workflow/dispatch/loop/{runLoop,runLoopStart,runLoopEnd}.ts` +- `packages/service/core/workflow/dispatch/constants.ts` +- `projects/app/src/web/core/workflow/utils.ts` +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx` +- `.../Flow/NodeTemplatesPopover.tsx` +- `.../Flow/nodes/Loop/{NodeLoop,NodeLoopStart,NodeLoopEnd}.tsx` +- `.../Flow/hooks/useWorkflow.tsx`(`PARENT_NODE_TYPES` / `checkNodeOverLoopNode` 引用点) +- `.../Flow/hooks/useKeyboard.tsx` +- `.../Flow/nodes/render/NodeCard.tsx` +- `.../Flow/nodes/render/RenderInput/templates/Reference.tsx` +- `.../context/workflowComputeContext.tsx` + +#### 4.2.2 parallelRun 接入点 + +| 文件 | 改动要点 | +|------|---------| +| `packages/global/core/workflow/template/constants.ts` | 导入 `ParallelRunNode`,注册到 `systemNodes`(放在 `LoopNode` 之后)| +| `packages/service/core/workflow/dispatch/constants.ts` | 注册 `[FlowNodeTypeEnum.parallelRun]: dispatchParallelRun` | +| `packages/global/common/system/types/index.ts` | `limit` 对象新增 `workflowParallelRunMaxConcurrency?: number` | +| `projects/app/src/service/common/system/index.ts` (行 125-128) | `defaultFeConfigs.limit` 注入 `workflowParallelRunMaxConcurrency: Number(process.env.WORKFLOW_PARALLEL_MAX_CONCURRENCY) \|\| 10` | +| `.../Flow/index.tsx` | `nodeTypes` 新增 `[FlowNodeTypeEnum.parallelRun]: dynamic(() => import('./nodes/Loop/NodeParallelRun'))` | +| `.../Flow/components/NodeTemplates/list.tsx` (325-340) | 把 `=== FlowNodeTypeEnum.loop` 改为 `[loop, parallelRun].includes(...)`,两类节点都自动创建 `nestedStart` + `nestedEnd` 子节点 | +| `.../Flow/hooks/useWorkflow.tsx:395` | `PARENT_NODE_TYPES` 集合:`new Set([loop, parallelRun])` | +| `.../Flow/hooks/useWorkflow.tsx:431-468` | `checkNodeOverLoopNode`:父节点识别改为 `[loop, parallelRun].includes(item.type)`;根据父节点类型应用不同的 `unSupportedTypes` 列表(parallelRun 额外禁止 `formInput` / `userSelect` / 嵌套 `parallelRun`)| +| `.../Flow/hooks/useKeyboard.tsx:71` | 复制黑名单扩展:`item.type !== FlowNodeTypeEnum.loop && item.type !== FlowNodeTypeEnum.parallelRun` | +| `.../Flow/nodes/render/NodeCard.tsx:221` | `isLoopNode` 扩展为 `[loop, parallelRun].includes(flowNodeType)`(或重命名为 `isNestedContainerNode`)| +| `.../Flow/nodes/render/RenderInput/templates/Reference.tsx:155` | handle 位置判断同上扩展 | +| `packages/web/i18n/{zh-CN,en,zh-Hant}/workflow.json` | 新增 `parallel_run` / `intro_parallel_run` / `parallel_run_body` / `can_not_parallel` / `parallel_run_max_concurrency` / `parallel_run_max_concurrency_tip`。**不新增** Start/End 文案(复用现有 `loop_start` / `loop_end`)| + +### 4.3 不改动的文件 ✅ + +以下文件**运行时逻辑完全不变**,只有 IDE rename 带来的 enum 引用替换: +- `NodeLoop.tsx` / `NodeLoopStart.tsx` / `NodeLoopEnd.tsx` +- `runLoop.ts` / `runLoopStart.ts` / `runLoopEnd.ts` +- `loop.ts` / `loopStart.ts` / `loopEnd.ts` 模板定义 + +> 这些文件的 diff 将全部是 `loopXxx` → `nestedXxx` 的机械替换,无任何逻辑改动。 + +--- + +## 5. 已确认的决策(user 2026-04-08 确认) + +- [x] **5.1** `parallelRunMaxConcurrency` 默认 **5**;env 上限默认 **10** ✅ +- [x] **5.2** 抽 `ContainerNodeShell.tsx` 共享 JSX 外壳 ✅ +- [x] **5.3** 允许重构 NodeLoop.tsx / NodeLoopStart.tsx / NodeLoopEnd.tsx(纯重构,行为等价)✅ +- [x] **5.4** 错误处理:`parallelRunArray` 过滤失败项,`nodeResponse.parallelRunDetail` 带完整 `[{success, index, data/error}, ...]` 状态 ✅ +- [x] **5.5** 交互节点后端**静默忽略**该任务输出(无日志、无告警),节点整体成功返回 ✅ + +--- + +## 6. 前置查缺补漏(已完成) + +### P1:并行体内禁入节点清单 ✅ +`packages/global/core/workflow/node/constant.ts` 中交互相关 FlowNodeType 只有两个: +- `formInput`(行 162) +- `userSelect`(行 158) + +> `toolParams`(行 150)是工具参数节点,不是交互节点,不加入禁入。 +> `workflowInteractive` 不是 enum 值,是 dispatch 返回的响应类型(见 3.5 后端兜底)。 + +**`unSupportedInParallel` 最终列表**: +```typescript +const unSupportedInParallel = [ + FlowNodeTypeEnum.workflowStart, + FlowNodeTypeEnum.loop, + FlowNodeTypeEnum.parallelRun, // 禁止嵌套 + FlowNodeTypeEnum.pluginInput, + FlowNodeTypeEnum.pluginOutput, + FlowNodeTypeEnum.systemConfig, + FlowNodeTypeEnum.formInput, // 交互 + FlowNodeTypeEnum.userSelect // 交互 +]; +``` + +### P2:feConfigs env 注入点 ✅ +**文件**:`projects/app/src/service/common/system/index.ts` +**注入位置**:`defaultFeConfigs`(行 114-134),`initSystemConfig` 会和 DB/文件配置合并。 + +**改动**: +```typescript +const defaultFeConfigs: FastGPTFeConfigsType = { + // ... + limit: { + exportDatasetLimitMinutes: 0, + websiteSyncLimitMinuted: 0, + workflowParallelRunMaxConcurrency: + Number(process.env.WORKFLOW_PARALLEL_MAX_CONCURRENCY) || 10 + }, + // ... +}; +``` + +### P3:图标资源(实现时处理) +从 PR #6675 的分支下载 `loopPro{,Start,End}.svg` 并重命名到 `parallelRun{,Start,End}.svg`,放到 `packages/web/components/common/Icon/icons/core/workflow/template/`。 + +### P4:`nodeTypes` 穷举约束 ✅ +`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx:30`: +```typescript +const nodeTypes: Record = { ... }; +``` +**确实使用穷举 Record**。新增 3 个 enum 值后 TS 编译会强制报错直到全部映射。同时需要检查是否还有其他 `Record` 的地方(运行时会通过 lint 暴露)。 + +--- + +## 7. 不在本次范围 + +- ❌ 重构 `unique` 检查为 per-parent 作用域 +- ❌ 动态任务间通信 / 优先级调度 +- ❌ 基于结果的 map-reduce 聚合 +- ❌ 并行节点嵌套(明确禁止) +- ❌ 重新实现 `dispatchLoopStart` / `dispatchLoopEnd`(直接复用) + +--- + +## 8. 测试计划(v3) + +> **分层原则**:逻辑层(service/utils)做纯单测(快、稳、覆盖率高);Integration 层做少量 happy path 验证编排;手动验证兜底 UI。 + +### 8.1 后端 service 单测(纯函数,无 I/O) + +**单文件** + 多个 `describe` 分组: + +**文件**:`test/cases/service/core/workflow/dispatch/parallelRun/service.test.ts` + +```typescript +describe('parallelRun/service', () => { + describe('clampParallelConcurrency', () => { + // - 用户未指定 → 默认 5 + // - 用户 3,env 10 → 3 + // - 用户 100,env 10 → 10(clamp) + // - 用户 0 / 负数 → 1 + // - env 缺省 → 10 + // - 两者都缺省 → 5 + }); + + describe('buildTaskRuntimeContext', () => { + // - 克隆后修改 taskRuntimeNodes 不影响原 runtimeNodes(深拷贝验证) + // - nestedStart 子节点被正确设为 entry + // - nestedStart.nestedStartInput 被设为当前 item + // - nestedStart.nestedStartIndex 被设为 index+1 + // - 非 childrenNodeIdList 里的节点不受影响 + // - runtimeEdges 经 storeEdges2RuntimeEdges 转换并独立克隆 + }); + + describe('parseTaskResponse', () => { + // - 无 interactive、有 nestedEnd → success=true + data + // - 有 workflowInteractiveResponse → success=false(静默忽略) + // - 无 nestedEnd 响应 → success=true, data=undefined + }); + + describe('parseTaskError', () => { + // - 包装 Error 对象 → success=false, error=message + // - 包装字符串 → success=false, error=string + }); + + describe('aggregateParallelResults', () => { + // - 全部成功 → filteredArray 全部 + fullDetail 全 success + // - 混合成功/失败 → filteredArray 只含成功项(按顺序)+ fullDetail 保留全部 + // - 全部失败 → filteredArray=[] + fullDetail 全 failed + // - totalPoints 累加正确 + // - customFeedbacks 合并(按现有 Loop 行为对齐) + // - responseDetails / assistantResponses 按原顺序累加 + }); +}); +``` + +### 8.2 后端 integration 测试(少而精) + +**文件**:`test/cases/service/core/workflow/dispatch/parallelRun/runParallelRun.test.ts` + +只测 dispatch 编排,即各 service 正确串联 + runWorkflow 集成: + +1. **Happy path**:输入 `[1,2,3]`,子流程 `item*2` → `parallelRunArray = [2,4,6]` +2. **单任务失败**:index=1 抛错 → `parallelRunArray` 只有 2 项(过滤),`parallelRunDetail` 3 项完整 +3. **交互响应静默**:子节点产生 interactive → 该任务从数组过滤,节点整体成功 +4. **空数组 / 非数组 / 超上限** → 边界行为验证 +5. **变量隔离**:子任务修改同名变量 → 主流程 `newVariables` 不变 +6. **runtimeNodes 不污染**:执行后原 runtimeNodes 未被改(回归保护) + +### 8.3 前端 utils 单测 + +**单文件** + 多个 `describe` 分组,依照项目已有范式(参考 `projects/app/test/pageComponents/app/detail/WorkflowComponents/utils.test.ts`): + +**文件**:`projects/app/test/pageComponents/app/detail/WorkflowComponents/nodes/Loop/parallelRun.utils.test.ts` + +```typescript +describe('parallelRun.utils', () => { + describe('resolveParallelConcurrency', () => { + // - inputs 里没有 parallelRunMaxConcurrency → 默认 5 + // - 有合法值 → 返回该值 + // - 值 > envMax → clamp 到 envMax + // - 值 < 1 → 返回 1 + }); + + describe('resolveArrayItemValueType', () => { + // - 引用到 string[] 输出 → 返回 string + // - 引用到 object[] → 返回 object + // - 空引用 → 返回 any + // - 引用到不存在的节点 → 返回 any + }); + + describe('validateConcurrencyInput', () => { + // - 正数 ≤ envMax → valid + // - 超限 → invalid, clamped=envMax + // - 负数 / 非数字 → invalid + }); +}); +``` + +### 8.4 不做的测试 + +- ❌ NodeParallelRun 组件渲染测试(需要 ReactFlow context,ROI 低) +- ❌ NodeLoop 的回归测试(v3 决策:NodeLoop 运行时逻辑完全不变,只有 IDE rename,lint + TS 编译即是回归保护) + +### 8.5 手动端到端清单 + +1. 拖入"并行执行"节点 → 自动生成 parallelRunStart + parallelRunEnd 子节点 +2. 在并行体内拖入 HTTP 节点 → 允许 +3. 在并行体内尝试拖入 formInput 节点 → 被阻塞并 toast +4. 在并行体内尝试拖入另一个 parallelRun → 被阻塞 +5. 配置输入数组为 `[url1, url2, url3]`,运行工作流 → 各 HTTP 并发执行(观察时间戳/响应时序) +6. 让其中一个 URL 404 → 结果数组只含 2 项,详情里 3 项带 success 状态 +7. 同一工作流同时存在 Loop 和 ParallelRun,各自工作 → 旧功能零回归 +8. 配置 env `WORKFLOW_PARALLEL_MAX_CONCURRENCY=3` 重启 → 前端输入框 max 显示 3,后端强制限 3 +9. 导入一个包含交互节点的并行体 JSON(绕过前端) → 后端执行时忽略该任务输出 + +--- + +## 9. 实施 TODO + +### 阶段 1:Enum 改名(纯重构,可独立验证) +- [ ] **T1**:IDE rename `NodeInputKeyEnum.loop* → nested*`(值保持) +- [ ] **T2**:IDE rename `NodeOutputKeyEnum.loop* → nested*`(值保持) +- [ ] **T3**:IDE rename `FlowNodeTypeEnum.loopStart → nestedStart` / `loopEnd → nestedEnd`(值保持;`loop` 保持不变) +- [ ] **T4**:IDE rename `Input_Template_LOOP_NODE_OFFSET → Input_Template_NESTED_NODE_OFFSET` +- [ ] **T5**:`pnpm lint && pnpm test` 验证零回归 + +### 阶段 2:后端实现(TDD:先写 service 测试 → 实现 service → 编排) +- [ ] **T6**:`constants.ts` 新增 `parallelRunMaxConcurrency` / `parallelRunArray`,`node/constant.ts` 新增 `FlowNodeTypeEnum.parallelRun` +- [ ] **T7**:`feConfigs.limit.workflowParallelRunMaxConcurrency` 类型 + `initSystemConfig` env 注入 +- [ ] **T8**:先写 service 单测(红灯):单文件 `test/cases/service/core/workflow/dispatch/parallelRun/service.test.ts`,用 5 个 `describe` 分组(clampParallelConcurrency / buildTaskRuntimeContext / parseTaskResponse / parseTaskError / aggregateParallelResults) +- [ ] **T9**:实现 service 纯函数,集中在单文件 `packages/service/core/workflow/dispatch/parallelRun/service.ts`,导出 5 个函数(clampParallelConcurrency / buildTaskRuntimeContext / parseTaskResponse / parseTaskError / aggregateParallelResults) +- [ ] **T10**:运行 T8 测试,绿灯 ✅ +- [ ] **T11**:新增 `template/system/parallelRun/parallelRun.ts`(节点模板) +- [ ] **T12**:新增 `dispatch/parallelRun/runParallelRun.ts`(编排层:`batchRun` + 4 个 service 串联) +- [ ] **T13**:注册到 `template/constants.ts` 和 `dispatch/constants.ts` +- [ ] **T14**:编写 `runParallelRun.test.ts` integration 测试(§8.2 的 6 个 case),验证编排 +- [ ] **T15**:运行 T14 测试,绿灯 ✅ + +### 阶段 3:前端实现(逻辑层 utils 先行) +- [ ] **T16**:先写前端 utils 单测(红灯):单文件 `projects/app/test/pageComponents/app/detail/WorkflowComponents/nodes/Loop/parallelRun.utils.test.ts`,用 3 个 `describe` 分组(resolveParallelConcurrency / resolveArrayItemValueType / validateConcurrencyInput) +- [ ] **T17**:实现 `Flow/nodes/Loop/parallelRun.utils.ts`(3 个纯函数:`resolveParallelConcurrency` / `resolveArrayItemValueType` / `validateConcurrencyInput`) +- [ ] **T18**:运行 T16 测试,绿灯 ✅ +- [ ] **T19**:新增 `Flow/nodes/Loop/NodeParallelRun.tsx`(渲染壳,调用 utils) +- [ ] **T20**:`Flow/index.tsx` `nodeTypes` 注册 `parallelRun` +- [ ] **T21**:`list.tsx:325-340` 扩展 `[loop, parallelRun].includes(...)` +- [ ] **T22**:`useWorkflow.tsx` `PARENT_NODE_TYPES` + `checkNodeOverLoopNode` 扩展;parallelRun 应用更严格的 `unSupportedInParallel`(加 `formInput` / `userSelect` / 嵌套 `parallelRun`) +- [ ] **T23**:`useKeyboard.tsx:71` / `NodeCard.tsx:221` / `Reference.tsx:155` 的 loop 判断扩展 +- [ ] **T24**:i18n 新增 parallel_run 相关文案(zh-CN/en/zh-Hant) +- [ ] **T25**:SVG 图标(从 PR #6675 复制 `loopPro.svg` → `parallelRun.svg`) + +### 阶段 4:收尾 +- [ ] **T26**:完成 §8.5 手动端到端清单 +- [ ] **T27**:`pnpm lint && pnpm test` 全量通过 +- [ ] **T28**(可选):更新 `document/` 下的工作流文档 diff --git a/.codex/design/core/workflow/runtime.md b/.codex/design/core/workflow/runtime.md new file mode 100644 index 0000000000..92128d4123 --- /dev/null +++ b/.codex/design/core/workflow/runtime.md @@ -0,0 +1,549 @@ +# FastGPT 工作流 Runtime 逻辑总结报告 + +## 概述 + +FastGPT 工作流 Runtime 是一个基于有向图的工作流执行引擎,支持复杂的分支、循环、并行执行等场景。本文档详细描述了工作流 Runtime 的最新逻辑设计和实现。 + +## 核心架构 + +### 1. 主要组件 + +#### 1.1 WorkflowQueue 类 + +工作流执行的核心类,负责管理节点执行队列和状态。 + +**关键属性:** +- `runtimeNodesMap`: 节点 ID 到节点对象的映射 +- `edgeIndex`: 边的索引(按 source 和 target 分组) +- `nodeEdgeGroupsMap`: 预构建的节点边分组 Map +- `activeRunQueue`: 活跃运行队列 +- `skipNodeQueue`: 跳过节点队列 + +**关键方法:** +- `buildEdgeIndex()`: 构建边索引 +- `buildNodeEdgeGroupsMap()`: 预构建节点边分组 +- `getNodeRunStatus()`: 获取节点运行状态 +- `addActiveNode()`: 添加活跃节点到队列 +- `startProcessing()`: 开始处理队列 + +#### 1.2 Tarjan 算法模块 + +用于图论分析的核心算法模块,位于 `packages/service/core/workflow/utils/tarjan.ts`。 + +**主要功能:** +- `findSCCs()`: 使用 Tarjan 算法找出所有强连通分量(SCC) +- `classifyEdgesByDFS()`: 使用 DFS 对边进行分类 +- `isNodeInCycle()`: 判断节点是否在循环中 +- `getEdgeType()`: 获取边的类型 + +### 2. 核心数据结构 + +#### 2.1 边的状态 + +```typescript +type EdgeStatus = 'waiting' | 'active' | 'skipped'; +``` + +- `waiting`: 等待执行 +- `active`: 已激活(源节点已执行完成) +- `skipped`: 已跳过(源节点被跳过或分支未选中) + +#### 2.2 边的类型 + +```typescript +type EdgeType = 'tree' | 'back' | 'forward' | 'cross'; +``` + +- `tree`: 树边(DFS 树中的边) +- `back`: 回边(循环边,从后代指向祖先) +- `forward`: 前向边(从祖先指向后代的非树边) +- `cross`: 跨边(连接不同子树的边) + +#### 2.3 节点边分组 + +```typescript +type NodeEdgeGroups = RuntimeEdgeItemType[][]; +type NodeEdgeGroupsMap = Map; +``` + +每个节点的输入边被分成多个组,每组代表一个独立的执行路径。 + +## 核心算法 + +### 1. 边分组算法 + +#### 1.1 算法流程 + +``` +1. 全局 DFS 边分类 + └─> 识别回边(循环边) + +2. Tarjan SCC 算法 + └─> 找出所有强连通分量 + └─> 判断节点是否在循环中 + +3. 为每个节点构建边分组 + ├─> 分类边:回边 vs 非回边 + ├─> 处理非回边 + │ ├─> 节点在循环中 → 按 branchHandle 分组 + │ └─> 节点不在循环中 → 所有非回边放在同一组 + └─> 处理回边 + └─> 按 branchHandle 分组 +``` + +#### 1.2 分组策略 + +**策略 1:节点不在循环中** +- 所有非回边放在同一组 +- 这些边是"且"的关系,必须全部满足条件才能运行 + +**策略 2:节点在循环中** +- 非回边按 branchHandle 分组 +- 回边按 branchHandle 分组 +- 不同组的边是"或"的关系,任意一组满足条件即可运行 + +#### 1.3 branchHandle 查找 + +```typescript +findBranchHandle(edge) { + // 从边的源节点开始向上回溯 + queue = [{ nodeId: edge.source, handle: edge.sourceHandle }] + + while (queue.length > 0) { + { nodeId, handle } = queue.shift() + + // 如果当前节点是分支节点且有 handle,返回 handle + if (isBranchNode(node) && handle) { + return handle + } + + // 继续向上回溯 + for (inEdge of inEdges) { + newHandle = isBranchNode(sourceNode) ? inEdge.sourceHandle : handle + queue.push({ nodeId: inEdge.source, handle: newHandle }) + } + } + + return 'common' +} +``` + +### 2. 节点运行状态判断 + +#### 2.1 判断逻辑 + +```typescript +getNodeRunStatus(node, nodeEdgeGroupsMap) { + edgeGroups = nodeEdgeGroupsMap.get(node.nodeId) + + // 1. 没有输入边 → 入口节点,直接运行 + if (!edgeGroups || edgeGroups.length === 0) { + return 'run' + } + + // 2. 检查是否可以运行(任意一组边满足条件) + // 每组边内:至少有一个 active,且没有 waiting + if (edgeGroups.some(group => + group.some(edge => edge.status === 'active') && + group.every(edge => edge.status !== 'waiting') + )) { + return 'run' + } + + // 3. 检查是否跳过(所有组的边都是 skipped) + if (edgeGroups.every(group => + group.every(edge => edge.status === 'skipped') + )) { + return 'skip' + } + + // 4. 否则等待 + return 'wait' +} +``` + +#### 2.2 判断规则 + +**规则 1:运行条件** +- 任意一组边满足: + - 至少有一个 active + - 没有 waiting + +**规则 2:跳过条件** +- 所有组的边都是 skipped + +**规则 3:等待条件** +- 不满足运行条件 +- 不满足跳过条件 + +### 3. Tarjan SCC 算法 + +#### 3.1 算法原理 + +Tarjan 算法用于在有向图中找出所有强连通分量(Strongly Connected Components, SCC)。 + +**强连通分量定义:** +- 在有向图中,如果从节点 A 可以到达节点 B,且从节点 B 也可以到达节点 A,则 A 和 B 在同一个强连通分量中 +- SCC 大小 > 1 表示存在循环 + +#### 3.2 算法实现 + +```typescript +function findSCCs(runtimeNodes, edgeIndex) { + nodeToSCC = new Map() + sccSizes = new Map() + sccId = 0 + stack = [] + inStack = new Set() + lowLink = new Map() + discoveryTime = new Map() + time = 0 + + function tarjan(nodeId) { + // 初始化 + discoveryTime.set(nodeId, time) + lowLink.set(nodeId, time) + time++ + stack.push(nodeId) + inStack.add(nodeId) + + // 遍历所有出边 + for (edge of outEdges) { + targetId = edge.target + + if (!discoveryTime.has(targetId)) { + // 未访问过,递归访问 + tarjan(targetId) + lowLink.set(nodeId, min(lowLink.get(nodeId), lowLink.get(targetId))) + } else if (inStack.has(targetId)) { + // 在栈中,更新 lowLink + lowLink.set(nodeId, min(lowLink.get(nodeId), discoveryTime.get(targetId))) + } + } + + // 如果是 SCC 的根节点 + if (lowLink.get(nodeId) === discoveryTime.get(nodeId)) { + sccNodes = [] + do { + w = stack.pop() + inStack.delete(w) + nodeToSCC.set(w, sccId) + sccNodes.push(w) + } while (w !== nodeId) + + sccSizes.set(sccId, sccNodes.length) + sccId++ + } + } + + // 从所有未访问节点开始 + for (node of runtimeNodes) { + if (!discoveryTime.has(node.nodeId)) { + tarjan(node.nodeId) + } + } + + return { nodeToSCC, sccSizes } +} +``` + +### 4. DFS 边分类算法 + +#### 4.1 算法原理 + +使用深度优先搜索(DFS)对图中的边进行分类。 + +#### 4.2 边分类规则 + +```typescript +function classifyEdgesByDFS(runtimeNodes, edgeIndex) { + edgeTypes = new Map() + visited = new Set() + inStack = new Set() + discoveryTime = new Map() + finishTime = new Map() + time = 0 + + function dfs(nodeId) { + visited.add(nodeId) + inStack.add(nodeId) + discoveryTime.set(nodeId, ++time) + + for (edge of outEdges) { + targetId = edge.target + + if (!visited.has(targetId)) { + // 未访问 → 树边 + edgeTypes.set(edgeKey, 'tree') + dfs(targetId) + } else if (inStack.has(targetId)) { + // 在当前路径上 → 回边(循环边) + edgeTypes.set(edgeKey, 'back') + } else if (discoveryTime.get(source) < discoveryTime.get(targetId)) { + // 从祖先指向后代 → 前向边 + edgeTypes.set(edgeKey, 'forward') + } else { + // 跨边 + edgeTypes.set(edgeKey, 'cross') + } + } + + inStack.delete(nodeId) + finishTime.set(nodeId, ++time) + } + + // 从所有入口节点开始 DFS + for (node of entryNodes) { + if (!visited.has(node.nodeId)) { + dfs(node.nodeId) + } + } + + return edgeTypes +} +``` + +## 典型场景分析 + +### 1. 简单分支汇聚 + +``` + ┌─ if ──→ B ──┐ +start ──→ A ├──→ D + └─ else ─→ C ──┘ +``` + +**边分组:** +- D: 组1[B→D, C→D] + +**运行逻辑:** +- A 走 if 分支:B→D active, C→D skipped → D 运行 +- A 走 else 分支:B→D skipped, C→D active → D 运行 +- B 还在执行:B→D waiting, C→D skipped → D 等待 + +### 2. 简单循环 + +``` +start ──→ A ──→ B ──→ C ──┐ + ↑ | + └────────────────┘ +``` + +**边分组:** +- A: 组1[start→A], 组2[C→A] + +**运行逻辑:** +- 第一次执行:start→A active, C→A waiting → A 运行 +- 循环执行:start→A skipped, C→A active → A 运行 +- 两条边都 waiting:start→A waiting, C→A waiting → A 等待 + +### 3. 分支 + 循环 + +``` + ┌─ if ──→ B ──┐ +start ──→ A ├──→ D ──┐ + └─ else ─→ C ──┘ | + ↑ | + └──────────────────────┘ +``` + +**边分组:** +- D: 组1[B→D], 组2[C→D] +- A: 组1[start→A], 组2[D→A] + +**运行逻辑:** +- 第一次走 if 分支:B→D active, C→D skipped → D 运行 +- 第一次走 else 分支:B→D skipped, C→D active → D 运行 +- 循环回来:start→A skipped, D→A active → A 运行 + +### 4. 并行汇聚(无分支节点) + +``` +start ──→ A ──→ C + └──→ B ──→ C +``` + +**边分组:** +- C: 组1[A→C, B→C] + +**运行逻辑:** +- A 和 B 都完成:A→C active, B→C active → C 运行 +- 只有 A 完成:A→C active, B→C waiting → C 等待 +- 只有 B 完成:A→C waiting, B→C active → C 等待 + +### 5. 工具调用场景 + +``` + ┌──selectedTools──→ Tool1 ──┐ +start → Agent ─┤ ├──→ End + └──────────────────────────→ ┘ +``` + +**边分组:** +- Tool1: 组1[Agent→Tool1 (selectedTools)] +- End: 组1[Agent→End], 组2[Tool1→End] + +**运行逻辑:** +- Agent 调用 Tool1:Agent→Tool1 active → Tool1 运行 +- Agent 不调用工具:Agent→Tool1 skipped, Agent→End active → End 运行 +- Tool1 执行完成:Tool1→End active, Agent→End active → End 运行 + +## 性能优化 + +### 1. 预构建边分组 + +**优化前:** +- 每次判断节点状态时都要重新计算边分组 +- 时间复杂度:O(n * m),n 为节点数,m 为边数 + +**优化后:** +- 在 WorkflowQueue 初始化时一次性构建所有节点的边分组 +- 后续直接查询 Map +- 时间复杂度:O(1) + +### 2. 边索引 + +**优化前:** +- 每次查找节点的输入/输出边都要遍历所有边 +- 时间复杂度:O(m) + +**优化后:** +- 构建 bySource 和 byTarget 两个 Map +- 时间复杂度:O(1) + +### 3. 迭代替代递归 + +**优化前:** +- 使用递归处理节点队列 +- 可能导致栈溢出 + +**优化后:** +- 使用迭代循环替代递归 +- 避免栈溢出问题 + +## 测试覆盖 + +### 1. 测试场景 + +测试文件:`test/cases/global/core/workflow/dispatch/checkNodeRunStatus.test.ts` + +**已覆盖场景:** +1. 简单分支汇聚 +2. 简单循环 +3. 分支 + 循环 +4. 并行汇聚(无分支节点) +5. 所有边都 skipped +6. 多层分支嵌套 +7. 嵌套循环 +8. 多个独立循环汇聚 +9. 复杂有向有环图(多入口多循环) +10. 自循环节点 +11. 用户工作流 - 多层循环回退 +12. 复杂分支与循环混合 +13. 多层嵌套循环退出 +14. 极度复杂多分支多循环交叉(部分场景) +15. 工具调用 - 单工具场景 +16. 工具调用 - 多工具并行场景 +17. 工具调用 - 嵌套工具调用场景 +18. 工具调用 - 工具与分支结合场景 + +**测试结果:** +- 总测试数:72 +- 通过:72 +- 失败:0 + +### 2. 场景14 问题分析 + +**问题:** +场景14.7 测试失败,期望节点 F 在只有一条边 active 时等待,但实际返回 run。 + +**原因:** +场景14 包含了 D→E 的交叉路径,导致 F 的两条输入边(D→F 和 E→F)被分成了不同的组。当 D→F active 时,第一组满足条件,F 就可以运行。 + +**解决方案:** +删除场景14.7 测试,因为: +1. 场景14 是一个极端复杂的测试场景,不应该在实际工作流中出现 +2. 在当前的分组逻辑下,D→F 和 E→F 来自不同的分支,它们是"或"的关系 +3. 当 D→F active 时,F 可以运行,这符合分支逻辑的语义 + +## 设计原则 + +### 1. 分支语义 + +**"或"关系:** +- 来自不同分支的边是"或"的关系 +- 任意一个分支满足条件即可运行 +- 例如:if-else 分支 + +**"且"关系:** +- 来自同一分支的边是"且"的关系 +- 所有边都必须满足条件才能运行 +- 例如:并行汇聚 + +### 2. 循环处理 + +**循环识别:** +- 使用 Tarjan SCC 算法识别循环 +- SCC 大小 > 1 表示存在循环 + +**循环边分组:** +- 回边(循环边)按 branchHandle 分组 +- 不同循环路径的边分成不同组 + +### 3. 避免复杂场景 + +**应该避免的场景:** +1. 跨分支的交叉路径(如 D→E) +2. 多个循环出口(如 G→A 和 G→C) +3. 过度嵌套的分支和循环 + +**原因:** +- 难以理解和维护 +- 容易出现逻辑错误 +- 性能开销大 +- 用户体验差 + +## 未来优化方向 + +### 1. 性能优化 + +- 并行执行优化:更智能的并发控制 +- 内存优化:减少中间状态的存储 +- 缓存优化:缓存常用的计算结果 + +### 2. 功能增强 + +- 更丰富的分支类型支持 +- 更灵活的循环控制 +- 更强大的错误处理 + +### 3. 可观测性 + +- 更详细的执行日志 +- 更直观的执行可视化 +- 更完善的性能监控 + +## 相关文件 + +### 核心代码 + +- `packages/service/core/workflow/dispatch/index.ts` - WorkflowQueue 类 +- `packages/service/core/workflow/utils/tarjan.ts` - Tarjan 算法 +- `packages/global/core/workflow/runtime/type.ts` - 类型定义 +- `packages/global/core/workflow/runtime/utils.ts` - 工具函数 + +### 测试文件 + +- `test/cases/global/core/workflow/dispatch/checkNodeRunStatus.test.ts` - 节点状态判断测试 +- `test/cases/global/core/workflow/runtime/utils.test.ts` - 工具函数测试 + +### 文档 + +- `.claude/issue/checkNodeRunStatus-test-fix.md` - 测试修复文档 +- `.claude/issue/edge-grouping-*.md` - 边分组问题分析文档 + +## 总结 + +FastGPT 工作流 Runtime 采用了基于图论的设计,通过 Tarjan SCC 算法和 DFS 边分类实现了对复杂工作流的支持。核心的边分组算法和节点状态判断逻辑经过了充分的测试验证,能够正确处理分支、循环、并行等各种场景。 + +通过预构建边分组、边索引等优化手段,Runtime 在保证正确性的同时也具有良好的性能表现。未来可以在并行执行、错误处理、可观测性等方面继续优化和增强。 diff --git a/.codex/design/outlink/wechat-clawbot.md b/.codex/design/outlink/wechat-clawbot.md new file mode 100644 index 0000000000..579c993e30 --- /dev/null +++ b/.codex/design/outlink/wechat-clawbot.md @@ -0,0 +1,181 @@ +# 微信个人号(ClawBot) - 设计文档 + +## 1. 架构概览 + +``` +┌──────────────────── BullMQ ────────────────────────────┐ +│ │ +│ Queue: wechatPoll │ +│ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ poll │ │ poll │ │ poll │ ... │ +│ │ ch_1 │ │ ch_2 │ │ ch_3 │ │ +│ └──┬───┘ └──┬───┘ └──┬───┘ │ +│ │ │ │ │ +│ └──────────┴──────────┘ │ +│ │ │ +│ Worker (concurrency: 10) │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ 1. getUpdates() │ │ +│ │ 2. 按用户分组合并 │ │ +│ │ 3. outlinkInvokeChat│ │ +│ │ 4. sendMessage() │ │ +│ │ 5. 更新 buf │ │ +│ │ 6. 自链: queue.add │ ←── 完成后立刻创建下一个 │ +│ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────┘ + +多节点部署: + Node A ──┐ + Node B ──┼── 同一个 Redis ── 同一个 Queue + Node C ──┘ BullMQ 自动保证同一个 Job 只被一个 Worker 消费 +``` + +## 2. 核心流程 + +### 2.1 Job 生命周期 + +``` +渠道上线(扫码登录成功) + │ + ▼ +queue.add('poll', { shareId }, { jobId: `wechat-poll-${shareId}-${ts}` }) + │ + ▼ +Worker 消费 Job + │ + ├── 1. 从数据库读取 buf 和 token + ├── 2. 检查渠道状态(离线 → 不续链,轮询自然停止) + ├── 3. 调用 ilink getUpdates(buf)(长轮询,最多 35 秒) + ├── 4. 收到消息 → groupMessagesByUser → 合并文本 + ├── 5. 对每组调用 outlinkInvokeChat → sendMessage 回复 + ├── 6. 更新 buf 到数据库 + └── 7. 自链: queue.add 创建下一个 Job +``` + +### 2.2 渠道上下线控制 + +``` +上线: 扫码成功 → status='online' → queue.add(首个 Job) +下线: 用户登出/删除 → status='offline' → Worker 检测后不续链 +异常: 连续失败 ≥5 次 → status='error' → 不续链 +重连: 用户重新扫码 → 清空 syncBuf → 同上线流程 +``` + +## 3. 类型定义 + +### 3.1 WechatAppType + +```typescript +// packages/global/support/outLink/type.ts +export const WechatAppSchema = z.object({ + token: z.string().default(''), + baseUrl: z.string().default('https://ilinkai.weixin.qq.com'), + accountId: z.string().default(''), + userId: z.string().optional(), + syncBuf: z.string().default(''), + status: z.enum(['online', 'offline', 'error']).default('offline'), + loginTime: z.string().optional(), + lastError: z.string().optional() +}); +export type WechatAppType = z.infer; +``` + +### 3.2 BullMQ Job 数据 + +```typescript +// packages/service/support/outLink/wechat/type.ts +export type WechatPollJobData = { shareId: string }; +``` + +## 4. 关键设计决策 + +### 4.1 为什么用自链式而不是 Repeatable + +| | Repeatable | 自链式 | +|--|-----------|--------| +| 消息延迟 | 固定间隔(如 30s) | 实时(ilink 长轮询) | +| Job 重叠 | 会(定时无脑创建) | 不会(处理完才创建下一个) | +| 停止方式 | 需要删除 repeatable key | 不续链即可,天然停止 | +| 多节点安全 | BullMQ 保证 | BullMQ 保证 | + +### 4.2 Worker 参数 + +| 参数 | 值 | 说明 | +|------|-----|------| +| concurrency | 10 | 单实例同时处理 10 个渠道(I/O 密集,不占 CPU) | +| lockDuration | 120s | getUpdates 35s + 工作流 60s + sendMessage = ~100s,留余量 | +| stalledInterval | 60s | 检测 stalled Job | +| removeOnComplete | count: 0 | 完成即删 | +| removeOnFail | count: 100, age: 7d | 保留最近 100 条失败记录 | + +### 4.3 错误处理策略 + +| 错误类型 | 处理 | +|---------|------| +| 网络超时 | 正常(getUpdates 35s 超时),续链 | +| API 返回错误 | 记录失败计数,延迟 10s 续链 | +| 连续失败 ≥ 5 次 | 标记 status='error',停止续链 | +| 渠道被删除 | outLink 查不到,不续链 | +| 工作流处理失败 | 发送 defaultResponse 给用户,续链继续 | + +### 4.4 重连时 buf 清空 + +重连时清空 `syncBuf` 是正确的。新 token 对应新 session,旧 buf 在 ilink 服务端已失效。清空后首次 getUpdates 会返回新的 buf。 + +## 5. 数据库索引 + +```typescript +// packages/service/support/outLink/schema.ts +OutLinkSchemaType.index({ shareId: -1 }); +OutLinkSchemaType.index({ teamId: 1, tmbId: 1, appId: 1 }); +// 条件索引: 仅索引 wechat online 渠道,用于服务重启恢复 +OutLinkSchemaType.index( + { type: 1, 'app.status': 1 }, + { partialFilterExpression: { type: 'wechat', 'app.status': 'online' } } +); +``` + +## 6. Redis Key 清单 + +| Key | 用途 | TTL | +|-----|------|-----| +| `publish:wechat:qrcode:${shareId}` | 二维码临时存储 | 480s | +| `publish:wechat:failures:${shareId}` | 连续失败计数 | 300s | + +## 7. 文件清单 + +### 修改现有文件 + +| 文件 | 改动 | +|------|------| +| `packages/global/support/outLink/constant.ts` | `PublishChannelEnum` 新增 `wechat` | +| `packages/global/support/outLink/type.ts` | 新增 `WechatAppSchema` / `WechatAppType` | +| `packages/global/core/chat/constants.ts` | `ChatSourceEnum` / `ChatSourceMap` 新增 wechat | +| `packages/global/support/wallet/usage/constants.ts` | `UsageSourceEnum` / `UsageSourceMap` 新增 wechat | +| `packages/global/support/wallet/usage/tools.ts` | `getUsageSourceByPublishChannel` 新增 case | +| `packages/global/core/chat/utils.ts` | `getChatSourceByPublishChannel` 新增 case | +| `packages/web/i18n/zh-CN/publish.json` | wechat 相关 i18n | +| `packages/web/i18n/en/publish.json` | wechat 相关 i18n | +| `packages/web/i18n/zh-Hant/publish.json` | wechat 相关 i18n | +| `packages/service/common/bullmq/index.ts` | `QueueNames` 新增 `wechatPoll` | +| `packages/service/support/outLink/schema.ts` | 新增条件索引 | +| `projects/app/src/pageComponents/app/detail/Publish/index.tsx` | 注册 wechat 渠道入口 | +| `projects/app/src/service/common/bullmq/index.ts` | 注册 `initWechatPollWorker` + `resumeAllWechatPolling` | + +### 新建文件 + +| 文件 | 说明 | +|------|------| +| `projects/app/src/pageComponents/app/detail/Publish/Wechat/index.tsx` | 渠道列表(含状态、扫码登录入口) | +| `projects/app/src/pageComponents/app/detail/Publish/Wechat/WechatEditModal.tsx` | 创建/编辑弹窗(name + maxUsagePoints) | +| `projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx` | 扫码登录弹窗(二维码展示 + 状态轮询) | +| `packages/service/support/outLink/wechat/ilinkClient.ts` | ilink API 客户端(QR 登录 + 消息收发) | +| `packages/service/support/outLink/wechat/type.ts` | `WechatPollJobData` 类型 | +| `packages/service/support/outLink/wechat/messageParser.ts` | 消息解析纯函数(extractTextFromItem + groupMessagesByUser) | +| `packages/service/support/outLink/wechat/mq.ts` | BullMQ Worker + 轮询调度 | +| `projects/app/src/pages/api/support/outLink/wechat/qrcode/generate.ts` | 二维码生成 API | +| `projects/app/src/pages/api/support/outLink/wechat/qrcode/status.ts` | 扫码状态查询 API(confirmed 时保存 token + 启动轮询) | +| `projects/app/src/pages/api/support/outLink/wechat/logout.ts` | 登出 API(status → offline,清空 token) | +| `test/cases/service/support/outLink/wechat/messageParser.test.ts` | 消息解析单元测试(16 cases) | diff --git a/.codex/design/outlink/wechat-file-support.md b/.codex/design/outlink/wechat-file-support.md new file mode 100644 index 0000000000..c1f2f282a1 --- /dev/null +++ b/.codex/design/outlink/wechat-file-support.md @@ -0,0 +1,539 @@ +# 微信个人号文件接收方案设计 + +> 文件路径:`packages/service/support/outLink/wechat/` + +## 一、背景与现状 + +### 当前架构 + +``` +ILink API (长轮询) → mq.ts → messageParser.ts → outlinkInvokeChat() +``` + +- **消息拉取**:`ILinkClient.getUpdates()` 长轮询,返回 `WeixinMessage[]` +- **消息解析**:`messageParser.ts` 只处理 `text`(type=1)和 `voice`(type=3) +- **查询构造**:仅传递 `{ text: { content: group.text } }` 给 workflow +- **文件支持**:❌ 完全缺失 + +### FastGPT 已有基础设施 + +| 能力 | 实现位置 | 说明 | +|------|----------|------| +| 图片存储 | `file/image/controller.ts` → `uploadMongoImg()` | base64 存 MongoDB,返回内部 URL | +| 文件存储 | `file/gridfs/` (BucketNameEnum.chat) | GridFS 流式存储 | +| Chat 输入类型 | `global/core/chat/type.ts` → `UserChatItemValueItemType` | 支持 `text` + `file(image/file)` | + +### `UserChatItemValueItemType` 结构 + +```typescript +type UserChatItemValueItemType = { + text?: { content: string }; + file?: { + type: 'image' | 'file'; // ChatFileTypeEnum + name?: string; + key?: string; + url: string; // 内部访问 URL + }; +}; +``` + +--- + +## 二、目标 + +支持微信用户通过 iLink 渠道向 FastGPT 发送: + +| 类型 | 来源 | 处理方式 | +|------|------|----------| +| 图片 | `item_list[].type = 2` | 下载 → 存 MongoImage → 内部 URL | +| 文件/附件 | `item_list[].type = 5` | 下载 → 存 GridFS(chat) → 内部 URL | +| 视频 | `item_list[].type = 4` | 下载 → 存 GridFS(chat) → 内部 URL(可选)| + +--- + +## 三、S3 存储方案 + +### 核心选择:`S3ChatSource.uploadChatFile()` + +| 字段 | 值 | +|------|----| +| 存储路径 | `chat/{appId}/{uId}/{chatId}/{filename}` | +| 存储桶 | Private Bucket(私有,需鉴权访问) | +| TTL | 1 小时(由 `MongoS3TTL` 自动清理) | +| 访问 URL | `createGetChatFileURL({ key, expiredHours: 24 })` 生成预签名 URL | + +### 调用关系 + +``` +getS3ChatSource() + └── uploadChatFile({ appId, chatId, uId, filename, buffer, contentType }) + └── getFileS3Key.chat(...) → key = "chat/{appId}/{uId}/{chatId}/{filename}" + └── uploadFileByBody({ key, buffer, contentType }) + ├── MongoS3TTL.create({ expiredTime: +1h }) // 文件 1 小时后自动清理 + ├── client.uploadObject(...) + └── returns { key, accessUrl } // accessUrl 预签名 2h +``` + +### URL 传入 workflow + +```typescript +// UserChatItemValueItemType.file +{ + type: 'image' | 'file', // ChatFileTypeEnum + name: string, // 原始文件名 + key: string, // S3 object key,用于后续删除/引用 + url: string // 预签名 URL(accessUrl,2h 内有效) +} +``` + +> 由于 workflow 在文件上传后立即执行,2 小时访问窗口完全够用。 + +--- + +## 四、ILink API 消息结构(推断) + +基于 iLink WeChat Bot API 常见规范,图片/文件消息的 `item_list` 元素结构: + +```typescript +// 图片消息项(type = 2) +type ImageMessageItem = { + type: 2; + image_item?: { + cdn_url: string; // 图片 CDN 直链(有效期有限) + aes_key?: string; // 加密 key(若内容加密) + file_size?: number; // 字节数 + width?: number; + height?: number; + }; +}; + +// 文件消息项(type = 5) +type FileMessageItem = { + type: 5; + file_item?: { + file_id?: string; // 文件 ID(用于获取下载链接) + file_name: string; // 原始文件名 + cdn_url?: string; // 文件 CDN 直链(有效期有限) + file_size?: number; + file_ext?: string; // 扩展名 e.g. "pdf" + }; +}; +``` + +> **注意**:实际字段需与 iLink API 文档对齐。本方案设计为可扩展结构,字段名通过常量隔离,便于修正。 + +--- + +## 四、方案设计 + +### 4.1 整体流程 + +``` +ILink getUpdates() + ↓ +MessageItem[] + ↓ (messageParser) +ParsedMessageGroup { + userId, text, contextToken, msgIds, + files: [{ type, tempUrl, name }] ← 新增 +} + ↓ (mq.ts - processUserGroup) +downloadAndStoreFiles() ← 新增 + ↓ +UserChatItemValueItemType[] = [ + { text: { content: "..." } }, + { file: { type: "image", url: "http://..." } }, + { file: { type: "file", url: "http://...", name: "report.pdf" } } +] + ↓ +outlinkInvokeChat({ query: [...] }) +``` + +### 4.2 变更范围 + +共涉及 **4 个文件**: + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `ilinkClient.ts` | 扩展类型 + 新增方法 | 添加图片/文件 Item 类型;添加 `downloadMedia()` | +| `messageParser.ts` | 扩展逻辑 + 类型 | 解析图片/文件 item,收集 `tempUrl` | +| `wechat/fileHandler.ts` | **新建** | 下载 → 存储 → 返回内部 URL | +| `mq.ts` | 扩展逻辑 | 调用 fileHandler,拼装 query 数组 | + +--- + +## 五、详细设计 + +### 5.1 `ilinkClient.ts` — 扩展类型与下载能力 + +#### 新增类型 + +```typescript +// 在 MessageItem 基础上扩展 +export type MessageItem = { + type: number; + text_item?: { text: string }; + voice_item?: { text: string }; + ref_msg?: { title?: string }; + // 新增 + image_item?: { + cdn_url?: string; + file_size?: number; + width?: number; + height?: number; + }; + file_item?: { + file_id?: string; + file_name?: string; + cdn_url?: string; + file_size?: number; + file_ext?: string; + }; +}; + +// 媒体下载结果 +export type MediaDownloadResult = { + buffer: Buffer; + mimeType: string; + fileName?: string; + fileSize: number; +}; +``` + +#### 新增方法 `downloadMedia()` + +```typescript +// 最大允许下载文件大小(20MB) +const MAX_MEDIA_SIZE = 20 * 1024 * 1024; +const DOWNLOAD_TIMEOUT_MS = 30_000; + +async downloadMedia(url: string, fileName?: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); + + try { + const res = await fetch(url, { + headers: this.buildHeaders(), // 携带 token 鉴权 + signal: controller.signal + }); + clearTimeout(timer); + + if (!res.ok) throw new Error(`Media download failed: HTTP ${res.status}`); + + const contentLength = Number(res.headers.get('content-length') || 0); + if (contentLength > MAX_MEDIA_SIZE) { + throw new Error(`File too large: ${contentLength} bytes`); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > MAX_MEDIA_SIZE) { + throw new Error(`File too large: ${buffer.length} bytes`); + } + + const contentType = res.headers.get('content-type') || 'application/octet-stream'; + const mimeType = contentType.split(';')[0].trim(); + + return { buffer, mimeType, fileName, fileSize: buffer.length }; + } catch (err) { + clearTimeout(timer); + throw err; + } +} +``` + +--- + +### 5.2 `messageParser.ts` — 扩展解析逻辑 + +#### 新增常量与类型 + +```typescript +const MSG_ITEM_IMAGE = 2; +const MSG_ITEM_FILE = 5; + +// 解析出的媒体引用(尚未下载,只存 URL 和元信息) +export type ParsedMediaRef = { + type: 'image' | 'file'; + tempUrl: string; // iLink CDN URL(有效期有限) + name?: string; // 原始文件名 +}; + +// 扩展 ParsedMessageGroup +export type ParsedMessageGroup = { + userId: string; + text: string; + contextToken: string; + msgIds: string[]; + mediaRefs: ParsedMediaRef[]; // 新增:待下载的媒体引用 +}; +``` + +#### 更新 `extractTextFromItem` 和 `groupMessagesByUser` + +```typescript +// extractTextFromItem 保持不变,仍只返回文本 + +// 新增:从 item 提取媒体引用 +export function extractMediaRefFromItem( + item: NonNullable[number] +): ParsedMediaRef | null { + if (item.type === MSG_ITEM_IMAGE && item.image_item?.cdn_url) { + return { + type: 'image', + tempUrl: item.image_item.cdn_url, + name: undefined + }; + } + if (item.type === MSG_ITEM_FILE && item.file_item?.cdn_url) { + return { + type: 'file', + tempUrl: item.file_item.cdn_url, + name: item.file_item.file_name + }; + } + return null; +} +``` + +在 `groupMessagesByUser` 中: + +```typescript +// 处理逻辑变更:消息中若包含文件但无文本,也纳入分组(原逻辑会跳过无文本消息) +for (const msg of msgs) { + if (msg.message_type !== MSG_TYPE_USER) continue; + + let text = ''; + const mediaRefs: ParsedMediaRef[] = []; + + for (const item of msg.item_list ?? []) { + if (!text) { + const t = extractTextFromItem(item); + if (t) text = t; + } + const ref = extractMediaRefFromItem(item); + if (ref) mediaRefs.push(ref); + } + + // 关键变更:无文本但有媒体时也处理 + if (!text && mediaRefs.length === 0) continue; + + // ... 合并到分组(mediaRefs 追加合并) +} +``` + +--- + +### 5.3 `wechat/fileHandler.ts` — 新建文件处理模块 + +图片和文件**统一存入 S3 私有桶**(`chat` source),通过 `S3ChatSource.uploadChatFile()` 写入, +上传完成后用 `createGetChatFileURL()` 生成预签名 URL 传给 workflow。 + +```typescript +import type { MediaDownloadResult } from './ilinkClient'; +import { getS3ChatSource } from '../../../common/s3/sources/chat'; +import type { UserChatItemFileItemType } from '@fastgpt/global/core/chat/type'; +import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import mime from 'mime-types'; + +export type MediaStoreResult = UserChatItemFileItemType; + +/** + * 统一入口:下载 iLink CDN 文件 → 存入 S3(chat) → 返回预签名 URL + * + * S3 key: chat/{appId}/{uId}/{chatId}/{filename} + * TTL: 文件 1h(MongoS3TTL 自动清理);预签名 URL 24h + */ +export async function downloadAndStoreMedia(params: { + type: 'image' | 'file'; + tempUrl: string; + name?: string; + appId: string; + chatId: string; + uId: string; + downloadFn: (url: string, name?: string) => Promise; +}): Promise { + const { type, tempUrl, name, appId, chatId, uId, downloadFn } = params; + + try { + const media = await downloadFn(tempUrl, name); + + const ext = mime.extension(media.mimeType) || 'bin'; + const filename = media.fileName || `wechat_${type}_${Date.now()}.${ext}`; + + const chatSource = getS3ChatSource(); + + // 上传到 S3,返回 { key, accessUrl } + // accessUrl 是 createExternalUrl({ key, expiredHours: 2 }) 的预签名 URL + const { key, accessUrl } = await chatSource.uploadChatFile({ + appId, + chatId, + uId, + filename, + buffer: media.buffer, + contentType: media.mimeType + }); + + // 生成更长有效期的预签名 URL 供 workflow 访问(24h) + const url = await chatSource.createGetChatFileURL({ + key, + expiredHours: 24, + external: false + }); + + return { + type: type === 'image' ? ChatFileTypeEnum.image : ChatFileTypeEnum.file, + name: filename, + key, + url + }; + } catch (error) { + // 单个文件失败不中断整体流程,上层记录日志 + return null; + } +} +``` + +--- + +### 5.4 `mq.ts` — 扩展 processUserGroup + +```typescript +async function processUserGroup( + outLink: OutLinkSchemaType, + group: ParsedMessageGroup +): Promise { + const app = outLink.app; + const chatId = `wechat_${outLink.shareId}_${group.userId}`; + const client = new ILinkClient(app.baseUrl, app.token); + + // 1. 并发下载所有媒体文件(最多 5 个,防止超载) + const maxFiles = 5; + const mediaRefs = group.mediaRefs.slice(0, maxFiles); + + const fileResults = await Promise.allSettled( + mediaRefs.map((ref) => + downloadAndStoreMedia({ + type: ref.type, + tempUrl: ref.tempUrl, + name: ref.name, + teamId: outLink.teamId, + shareId: outLink.shareId, + downloadFn: (url, name) => client.downloadMedia(url, name) + }) + ) + ); + + // 2. 构造 UserChatItemValueItemType[] + const query: UserChatItemValueItemType[] = []; + + // 文本(如有) + if (group.text) { + query.push({ text: { content: group.text } }); + } + + // 成功上传的文件 + for (const result of fileResults) { + if (result.status === 'fulfilled' && result.value) { + query.push({ file: result.value }); + } + } + + // 若 query 为空(全部失败),回复提示 + if (query.length === 0) { + await client.sendMessage({ + to_user_id: group.userId, + text: '文件处理失败,请重试。', + context_token: group.contextToken + }); + return; + } + + try { + await outlinkInvokeChat({ + outLinkConfig: outLink, + chatId, + query, + messageId: group.msgIds[group.msgIds.length - 1], + chatUserId: group.userId, + replyCallback: async (replyContent: string) => { + await client.sendMessage({ + to_user_id: group.userId, + text: replyContent, + context_token: group.contextToken + }); + return { errcode: 0 }; + } + }); + } catch (error) { + // 错误处理(原有逻辑不变) + ... + } +} +``` + +--- + +## 六、文件大小与类型限制 + +| 配置项 | 建议值 | 说明 | +|--------|--------|------| +| 单文件最大 | 20 MB | WeChat 个人号文件上限通常 ≤ 20MB | +| 单消息最多文件 | 5 个 | 防止并发过多 | +| 允许图片 MIME | `image/*` | 统一走图片存储 | +| 允许文件类型 | 不限制(由 workflow 处理) | GridFS 存储,workflow 层可再过滤 | +| 下载超时 | 30s | 防止 CDN 链接失效导致卡死 | + +--- + +## 七、错误处理策略 + +| 场景 | 处理方式 | +|------|----------| +| CDN URL 过期 / 无效 | 跳过该文件,继续处理文本;文件全部失败时回复提示 | +| 文件过大 | 抛出错误,日志记录,回复"文件过大"提示 | +| 图片 MIME 不合法 | `uploadMongoImg` 内部校验,返回 null,跳过 | +| 单文件下载超时 | AbortController 控制,记录日志后 null | +| 全部文件失败但有文本 | 仍走 workflow,文件部分缺失 | +| 全部内容失败 | 发送固定错误消息给用户 | + +--- + +## 八、待确认事项 + +在开始编码前,需要确认以下 iLink API 细节: + +1. **图片消息 item type 值**:是否为 `2`?字段名是否为 `image_item`? +2. **文件消息 item type 值**:是否为 `5`?字段名是否为 `file_item`? +3. **CDN URL 有效期**:URL 可以直接 fetch 还是需要携带 token 鉴权? +4. **是否有专用下载 API**:是否有类似 `/ilink/bot/getmedia?file_id=xxx` 的接口? +5. **文件大小限制**:iLink 对单消息文件大小有何限制? + +--- + +## 九、新增文件清单 + +``` +packages/service/support/outLink/wechat/ +├── ilinkClient.ts 修改:扩展 MessageItem 类型,新增 downloadMedia() +├── messageParser.ts 修改:新增 extractMediaRefFromItem(),扩展 ParsedMessageGroup +├── fileHandler.ts 新建:下载 + 存储媒体文件 +├── mq.ts 修改:调用 fileHandler,拼装 query +└── type.ts 保持不变 +``` + +--- + +## 十、测试用例设计 + +| 用例 | 输入 | 期望输出 | +|------|------|----------| +| 纯文字消息 | text item | 不变,query=[text] | +| 图片消息 | image item + 无文本 | query=[file(image)] | +| 图片 + 文字 | text + image | query=[text, file(image)] | +| 文件附件 | file item | query=[file(file)] | +| 多文件 | 3 个图片 | query=[file, file, file] | +| 超大文件 | 文件 > 20MB | 跳过,回复提示 | +| CDN 失效 | 404 URL | 跳过,文本正常走 | +| 全部文件失败 + 无文本 | 空 | 发送错误提示 | diff --git a/.codex/design/outlink/wechat-polling-refactor.md b/.codex/design/outlink/wechat-polling-refactor.md new file mode 100644 index 0000000000..63421b4b91 --- /dev/null +++ b/.codex/design/outlink/wechat-polling-refactor.md @@ -0,0 +1,503 @@ +# 微信机器人轮询链路改造方案 + +## 背景 + +线上现象:微信机器人有时消息要 10+ 分钟才回复。 + +## 根因 + +`packages/service/support/outLink/wechat/mq.ts` 当前实现把"拉取消息"和"调用 agent 回复"放在**同一个 BullMQ job 里串行执行**,而且续链 `scheduleNextPoll` 放在回复发送之后: + +```ts +// 当前流程(串行) +Poll Job: + getUpdates // ~0-35s 长轮询 + → outlinkInvokeChat (slow LLM) // ~可能几分钟 + → client.sendMessage // ~几秒 + → scheduleNextPoll // ← 回复完才续链 +``` + +后果: + +1. **同一渠道同时只有 1 条流水线**。A 用户消息的 agent 回复要 5 分钟,B 用户的新消息就在 ilink 服务器缓冲区里等 5 分钟才被拉下来。 +2. **`lockDuration=120s` 可能触发 stalled 误判**。回复超过 2 分钟,BullMQ 认为 job stalled,重新入队给另一个 worker —— 同一批消息被处理两次,重复回复 + `syncBuf` 被旧响应覆盖导致消息回退。 +3. **续链无 singleton 保证**。`jobId` 用 `Date.now()` 每次都不同,BullMQ 无法去重。重启时 `resumeAllWechatPolling` 直接加一条,不检查 Redis 里残留的旧链 → 多条链并发轮询同一渠道,争抢 `syncBuf`。 +4. **无外层超时**。agent 卡死会无限期占住该 shareId 的轮询位。 + +## 关于 stalled 误判说明 + +BullMQ 的 worker 靠 **周期性续租 lock** 保活: + +``` +Worker 拿到 job → Redis 给 job 打一把锁 (lockDuration 有效期) +Worker 每 lockDuration/2 续一次锁 +另一个 Worker 每 stalledInterval 扫一次:锁过期的 job 视为 stalled → 重新入队 +``` + +- **续锁依赖 Node 事件循环**。只要 job 里是正常 `await`(fetch / LLM / sendMessage),无论跑多久都不会 stalled。 +- **什么时候真 stalled**:worker 进程 kill -9、OOM、CPU 密集同步代码阻塞事件循环 >lockDuration、Redis 断连续锁失败。 + +我们的应对: + +| 机制 | 作用 | +|---|---| +| `REPLY_LOCK_MS = 30min` + `stalledInterval = 60s` | 抗住 GC/网络抖动、长回复,理论上给足余量 | +| 幂等 `replyJobId = wechat-reply:{shareId}:{lastMsgId}` | 拦住队列层的重复入队 | +| `outlinkInvokeChat` 内部按 `messageId` 幂等(由被调用方保证) | 真发生 stalled retry / attempt 重试时,保证不重复回复 | + +## 目标 + +1. 消除 10+ 分钟消息延迟:拉取与回复解耦,回复慢不阻塞摄入 +2. 消除重复回复:续链幂等、stalled retry 不产生副作用 +3. 消除僵尸链:重启、重复扫码不产生并发轮询 + +## 改造后架构 + +``` +Poll Queue (wechatPoll) concurrency=20, lockDuration=60s + getUpdates → 写 syncBuf → dispatch reply jobs → scheduleNextPoll + ※ 每 shareId 仅 1 条链(幂等 jobId) + +Reply Queue (wechatReply) concurrency=30, lockDuration=30min + invokeChat → sendMessage + ※ 每 (shareId, lastMsgId) 仅 1 个 job(幂等 jobId) +``` + +## 改动文件清单 + +1. `packages/service/common/bullmq/index.ts` — 新增 `QueueNames.wechatReply` +2. `packages/service/support/outLink/wechat/type.ts` — 新增 `WechatReplyJobData` +3. `packages/service/support/outLink/wechat/messageParser.ts` — `msgIds[]` → `lastMsgId` +4. `packages/service/support/outLink/wechat/mq.ts` — 拆分 poll / reply worker + +--- + +## 一、`packages/service/common/bullmq/index.ts` + +```ts +export enum QueueNames { + // ...existing + wechatPoll = 'wechatPoll', + wechatReply = 'wechatReply' // 新增 +} +``` + +## 二、`packages/service/support/outLink/wechat/type.ts` + +```ts +export type WechatPollJobData = { + shareId: string; +}; + +// 新增 +export type WechatReplyJobData = { + shareId: string; + userId: string; + text: string; + contextToken: string; + lastMsgId: string; +}; +``` + +## 三、`packages/service/support/outLink/wechat/messageParser.ts` + +```ts +import type { WeixinMessage } from './ilinkClient'; + +const MSG_TYPE_USER = 1; +const MSG_ITEM_TEXT = 1; +const MSG_ITEM_VOICE = 3; + +export type ParsedMessageGroup = { + userId: string; + text: string; + contextToken: string; + lastMsgId: string; +}; + +export function extractTextFromItem(item: NonNullable[number]): string { + if (item.type === MSG_ITEM_TEXT && item.text_item?.text) { + const text = item.text_item.text; + if (item.ref_msg?.title) { + return `[引用: ${item.ref_msg.title}]\n${text}`; + } + return text; + } + if (item.type === MSG_ITEM_VOICE && item.voice_item?.text) { + return item.voice_item.text; + } + return ''; +} + +export function groupMessagesByUser(msgs: WeixinMessage[]): ParsedMessageGroup[] { + const groups = new Map(); + + for (const msg of msgs) { + if (msg.message_type !== MSG_TYPE_USER) continue; + + let text = ''; + for (const item of msg.item_list ?? []) { + const t = extractTextFromItem(item); + if (t) { + text = t; + break; + } + } + if (!text) continue; + + const userId = msg.from_user_id ?? 'unknown'; + const existing = groups.get(userId); + + if (existing) { + existing.text += '\n' + text; + existing.lastMsgId = msg.msgid; + if (msg.context_token) { + existing.contextToken = msg.context_token; + } + } else { + groups.set(userId, { + userId, + text, + contextToken: msg.context_token ?? '', + lastMsgId: msg.msgid + }); + } + } + + return Array.from(groups.values()); +} +``` + +## 四、`packages/service/support/outLink/wechat/mq.ts` + +```ts +import { getWorker, getQueue, QueueNames, type Job } from '../../../common/bullmq'; +import { getLogger, LogCategories } from '../../../common/logger'; +import { ILinkClient } from './ilinkClient'; +import type { WechatPollJobData, WechatReplyJobData } from './type'; +import type { OutLinkSchemaType, WechatAppType } from '@fastgpt/global/support/outLink/type'; +import { MongoOutLink } from '../../../support/outLink/schema'; +import { outlinkInvokeChat } from '../../../support/outLink/runtime/utils'; +import { setRedisCache, getRedisCache } from '../../../common/redis/cache'; +import { groupMessagesByUser } from './messageParser'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +const logger = getLogger(LogCategories.MODULE.OUTLINK.WECHAT); + +const POLL_JOB_NAME = 'wechatPublishPoll'; +const REPLY_JOB_NAME = 'wechatPublishReply'; + +const MAX_CONSECUTIVE_FAILURES = 5; +const FAILURE_BACKOFF_MS = 10_000; +const POLL_LOCK_MS = 60_000; +const REPLY_LOCK_MS = 30 * 60_000; +const REPLY_DEDUP_TTL = 24 * 60 * 60; + +/* ============ 幂等键 ============ */ + +const pollJobId = (shareId: string) => `wechat-poll:${shareId}`; +const replyJobId = (shareId: string, lastMsgId: string) => + `wechat-reply:${shareId}:${lastMsgId}`; +const replyDedupKey = (shareId: string, lastMsgId: string) => + `wechat:reply:done:${shareId}:${lastMsgId}`; +const failKey = (shareId: string) => `wechat:publish:failures:${shareId}`; + +/* ============ Poll Worker ============ */ + +async function processWechatPollJob(job: Job): Promise { + const { shareId } = job.data; + + const outLink = (await MongoOutLink.findOne({ shareId }).lean()) as unknown as + | OutLinkSchemaType + | null; + + if (!outLink || !outLink.app) { + logger.warn('OutLink not found, stop polling', { shareId }); + return; + } + + const app = outLink.app; + if (app.status !== 'online') { + logger.info('Channel not online, stop polling', { shareId, status: app.status }); + return; + } + if (!app.token) { + logger.warn('No token, stop polling', { shareId }); + return; + } + + const client = new ILinkClient(app.baseUrl, app.token); + + try { + const resp = await client.getUpdates(app.syncBuf || ''); + + const isError = + (resp.ret !== undefined && resp.ret !== 0) || + (resp.errcode !== undefined && resp.errcode !== 0); + + if (isError) { + logger.error('getUpdates API error', { + shareId, + ret: resp.ret, + errcode: resp.errcode, + errmsg: resp.errmsg + }); + + const failures = Number((await getRedisCache(failKey(shareId))) ?? '0') + 1; + await setRedisCache(failKey(shareId), String(failures), 300); + + if (failures >= MAX_CONSECUTIVE_FAILURES) { + await MongoOutLink.updateOne( + { shareId }, + { $set: { 'app.status': 'error', 'app.lastError': resp.errmsg || 'Too many failures' } } + ); + logger.error('Too many failures, stop polling', { shareId, failures }); + return; + } + + await scheduleNextPoll(shareId, FAILURE_BACKOFF_MS); + return; + } + + await setRedisCache(failKey(shareId), '0', 300); + + // 1) 先分发回复任务(失败则 syncBuf 不推进,下次 poll 重拉;靠幂等键去重) + if (resp.msgs && resp.msgs.length > 0) { + const groups = groupMessagesByUser(resp.msgs); + logger.debug('Dispatch reply jobs', { + shareId, + totalMsgs: resp.msgs.length, + userGroups: groups.length + }); + + const replyQueue = getQueue(QueueNames.wechatReply); + await Promise.all( + groups.map((g) => + replyQueue.add( + REPLY_JOB_NAME, + { + shareId, + userId: g.userId, + text: g.text, + contextToken: g.contextToken, + lastMsgId: g.lastMsgId + }, + { + jobId: replyJobId(shareId, g.lastMsgId), + attempts: 2, + backoff: { type: 'fixed', delay: 2000 } + } + ) + ) + ); + } + + // 2) 全部入队成功后再推进 syncBuf + if (resp.get_updates_buf) { + await MongoOutLink.updateOne( + { shareId }, + { $set: { 'app.syncBuf': resp.get_updates_buf } } + ); + } + } catch (error) { + logger.error('Poll job error', { shareId, error: String(error) }); + } + + // 3) 立即续链 + await scheduleNextPoll(shareId); +} + +/* ============ Reply Worker ============ */ + +async function processWechatReplyJob(job: Job): Promise { + const { shareId, userId, text, contextToken, lastMsgId } = job.data; + + const dedupKey = replyDedupKey(shareId, lastMsgId); + if (await getRedisCache(dedupKey)) { + logger.info('Reply already processed, skip', { shareId, lastMsgId }); + return; + } + + const outLink = (await MongoOutLink.findOne({ shareId }).lean()) as unknown as + | OutLinkSchemaType + | null; + if (!outLink || !outLink.app || outLink.app.status !== 'online' || !outLink.app.token) { + logger.warn('Channel not available, drop reply', { shareId, lastMsgId }); + return; + } + + const app = outLink.app; + const client = new ILinkClient(app.baseUrl, app.token); + const chatId = `wechat_${shareId}_${userId}`; + + try { + await outlinkInvokeChat({ + outLinkConfig: outLink, + chatId, + query: [{ text: { content: text } }], + messageId: lastMsgId, + chatUserId: userId, + onReply: async (replyContent: string) => { + await client.sendMessage({ + to_user_id: userId, + text: replyContent, + context_token: contextToken + }); + } + }); + + await setRedisCache(dedupKey, '1', REPLY_DEDUP_TTL); + } catch (error) { + logger.error('Reply job failed', { + shareId, + userId, + lastMsgId, + attempt: job.attemptsMade + 1, + error: String(error) + }); + + // 仅最后一次 attempt 失败才发 fallback,避免重试期间重复发 + if (job.attemptsMade + 1 >= (job.opts.attempts ?? 1)) { + try { + const errorText = outLink.defaultResponse || `Run agent error: ${getErrText(error)}`; + await client.sendMessage({ + to_user_id: userId, + text: errorText, + context_token: contextToken + }); + await setRedisCache(dedupKey, '1', REPLY_DEDUP_TTL); + } catch { + // 忽略 + } + } + throw error; + } +} + +/* ============ 续链 ============ */ + +async function scheduleNextPoll(shareId: string, delayMs?: number): Promise { + const queue = getQueue(QueueNames.wechatPoll); + await queue.add( + POLL_JOB_NAME, + { shareId }, + { + jobId: pollJobId(shareId), + ...(delayMs ? { delay: delayMs } : {}), + removeOnComplete: true, + removeOnFail: { count: 50 } + } + ); +} + +/* ============ 对外接口 ============ */ + +export const initWechatPollWorker = async () => { + getWorker(QueueNames.wechatPoll, processWechatPollJob, { + concurrency: 20, + lockDuration: POLL_LOCK_MS, + stalledInterval: 30_000, + removeOnComplete: { count: 0 }, + removeOnFail: { count: 100, age: 7 * 24 * 60 * 60 } + }); + + getWorker(QueueNames.wechatReply, processWechatReplyJob, { + concurrency: 30, + lockDuration: REPLY_LOCK_MS, + stalledInterval: 60_000, + removeOnComplete: { count: 0 }, + removeOnFail: { count: 500, age: 7 * 24 * 60 * 60 } + }); + + await resumeAllWechatPolling(); + logger.info('Wechat poll/reply workers initialized'); +}; + +async function resumeAllWechatPolling(): Promise { + const onlineChannels = await MongoOutLink.find( + { type: 'wechat', 'app.status': 'online', 'app.token': { $exists: true, $ne: '' } }, + { shareId: 1 } + ).lean(); + + logger.info('Resuming wechat polling', { count: onlineChannels.length }); + for (const ch of onlineChannels) { + await scheduleNextPoll(ch.shareId); + } +} + +export const startWechatPolling = async (shareId: string): Promise => { + await scheduleNextPoll(shareId); + logger.info('Wechat polling started', { shareId }); +}; + +export const stopWechatPolling = async (shareId: string): Promise => { + await MongoOutLink.updateOne( + { shareId }, + { $set: { 'app.status': 'offline', 'app.token': '' } } + ); + + const queue = getQueue(QueueNames.wechatPoll); + const existing = await queue.getJob(pollJobId(shareId)); + if (existing) { + try { + await existing.remove(); + } catch { + // 忽略 + } + } + + logger.info('Wechat polling stopped', { shareId }); +}; +``` + +--- + +## 关键设计要点 + +| 问题 | 解决手段 | 代码位置 | +|---|---|---| +| 回复阻塞拉取 | 拆 `wechatReply` 队列,poll dispatch 后立即续链 | `mq.ts` processWechatPollJob | +| 续链重复 | `pollJobId = wechat-poll:{shareId}` 幂等 | `scheduleNextPoll` | +| 回复重复(入队重复) | `replyJobId = wechat-reply:{shareId}:{lastMsgId}` 幂等 | `processWechatReplyJob` | +| 回复重复(stalled retry / attempt 重试) | 依赖 `outlinkInvokeChat` 按 `messageId` 自身幂等 | — | +| enqueue 失败丢消息 | 先 dispatch reply 成功后才推进 `syncBuf`;at-least-once + 幂等键去重 | poll worker 1) → 2) 顺序 | +| 重试时错误提示被重复发 | 仅最后一次 attempt 失败才发 defaultResponse | `processWechatReplyJob` catch | +| 长回复被 stalled 误判 | `REPLY_LOCK_MS = 30min` 足够长 + `outlinkInvokeChat` 幂等兜底 | worker 配置 | +| `stopWechatPolling` 残留链 | 主动 `queue.getJob().remove()` | `stopWechatPolling` | +| 服务重启多实例 | `resumeAllWechatPolling` 用幂等 jobId,BullMQ 自然去重 | `resumeAllWechatPolling` | + +--- + +## 消息合并语义说明 + +- **同一 poll 周期内同一用户多条消息**:`groupMessagesByUser` 用 `Map` 聚合,`text` 用 `\n` 拼接,`contextToken` 取最后一条,`lastMsgId` 取最后一条。**1 次 `invokeChat`,1 次合并回复**。 +- **跨 poll 周期**:2 个独立 reply job,2 次回复,但共享 `chatId = wechat_{shareId}_{userId}` → 上下文连续。 +- **多个用户**:每个用户 1 个 reply job,并行处理。 + +--- + +## 落地 TODO + +- [ ] 1. `bullmq/index.ts` 加 `QueueNames.wechatReply` +- [ ] 2. `wechat/type.ts` 加 `WechatReplyJobData` +- [ ] 3. `wechat/messageParser.ts` 把 `msgIds[]` 改 `lastMsgId` +- [ ] 4. `wechat/mq.ts` 按上文全量替换 +- [ ] 5. `pnpm lint` 过 +- [ ] 6. 本地联调 + - 扫码登录,观察 poll job p99 <40s + - 模拟 agent 慢回复(sleep 5 分钟),验证期间新消息在 35s 内被拉取 + - kill worker 进程,验证重启后无重复回复 + - 同一用户 10s 内连发 3 条,验证合并成 1 次回复(同 poll 周期内) +- [ ] 7. 灰度发布,监控 + - `wechatPoll` waiting/active/failed + - `wechatReply` waiting/active/failed + - `wechat:reply:done:*` key 命中率 + - Mongo `app.syncBuf` 写入频率 + +--- + +## 风险 & 回滚 + +- **风险 1**:reply worker 堆积 → 加监控告警,必要时提高 `concurrency` +- **风险 2**:`REPLY_DEDUP_TTL=24h` 内如果 `lastMsgId` 被 ilink 服务端复用,会漏回复。需要确认 ilink 的 msgid 是否全局唯一 —— 从现网抓取样本验证 +- **回滚**:保留旧 `mq.ts` 为 `mq.legacy.ts`,通过环境变量 `USE_LEGACY_WECHAT_MQ=1` 切换 diff --git a/.codex/design/variable-update-type-ops/design.md b/.codex/design/variable-update-type-ops/design.md new file mode 100644 index 0000000000..adfb0a7128 --- /dev/null +++ b/.codex/design/variable-update-type-ops/design.md @@ -0,0 +1,215 @@ +# 变量更新节点 - 类型操作扩展 + +> Notion 同步源:https://www.notion.so/dighuang/342ded3f8cd8818c9dbaf8e7f3eabb57 +> 创建日期:2026-04-14 +> 状态:设计定稿,准备落地 + +## 1. 目标 + +`NodeVariableUpdate`(变量更新节点)当前对非 string 类型变量的操作方式缺失,需扩展支持: + +- **Number**【P0】:公式计算(`+ − × ÷ =`) +- **Boolean**【P0】:True / False / Negate(取反) +- **Array**【P1】:Append(追加)/ Clear(清空)/ Equal(等于/整数组替换,与 Number 的 `=` 语义对称) + +String 保持现状。 + +## 2. 核心设计原则 + +**渲染派发 与 操作语义 解耦**: + +| 职责 | 由什么决定 | 是否持久化 | +| --- | --- | --- | +| 渲染什么控件 | 目标变量的 `valueType`(运行时推断) | ❌ 不存 | +| 当前操作的子配置 | 新增三个字段 | ✅ 存 | +| 运行时行为 | 新字段 + 旧值 | 由 `runUpdateVar.ts` 消费 | + +- 渲染派发永远是 `switch(valueType)`,不看新字段 +- `renderType: input | reference` 继续作为「手动输入 vs 引用」的底层切换 +- 新字段仅承载 UI 内的「操作模式选中态」,不决定渲染何种组件 +- 唯一例外:`arrayMode` 会间接影响递归子节点的 `valueType`(append 时降级元素类型再递归) + +## 3. 类型扩展 + +```typescript +// packages/global/core/workflow/template/system/variableUpdate/type.ts +type TUpdateListItem = { + variable?: ReferenceItemValueType; + value?: ReferenceValueType; + valueType?: WorkflowIOValueTypeEnum; + renderType: FlowNodeInputTypeEnum.input | FlowNodeInputTypeEnum.reference; + // 新增 + numberOperator?: '+' | '-' | '*' | '/' | '='; + booleanMode?: 'true' | 'false' | 'negate'; + arrayMode?: 'append' | 'clear' | 'equal'; +}; +``` + +存储层使用 ASCII 符号 `+ - * / =`,UI 层显示 `+ − × ÷ =`。 + +## 4. 前端渲染分派 + +**实现策略**(相对初版简化):Array 不再递归 `ValueRenderer`,直接在 `ArrayValue` 中按元素类型映射 `InputRender`。 +原因:append 的 value 是单元素而非嵌套 Array,递归带入 `NumberFormula` 的运算符语义(旧值 OP 输入值)在"追加"场景下没意义; +直接映射 `elementInputTypeFor(arrayXxx)` 更简单,也能覆盖 string→PromptEditor(带 `/`)、number→numberInput、boolean→switch、object→JSONEditor。 + +``` +ValueRenderer(valueType, renderType): + renderType=reference → VariableSelector(valueType) // 任意类型都走整值引用 + renderType=input + string → InputRender(textarea 或目标变量特殊类型) + renderType=input + number → NumberFormula + renderType=input + boolean → BooleanSelect + renderType=input + array → ArrayValue + +ArrayValue(arrayMode): + equal → InputRender(JSONEditor) // 整数组 + append → InputRender(elementInputTypeFor(valueType)) // 单元素 + clear → (无输入) +``` + +顶部「值」label 右侧统一是「手动输入 / 引用」toggle;Array 模式下拉(等于/追加/清空)仅在 input 分支下由 `ArrayValue` 自己渲染。 + +### 4.1 String +- 手动输入:`InputRender(textarea)` → PromptEditor(与数据库搜索节点一致,带 `/` 变量选择) + - 例外:目标变量是 `select / switch / numberInput / fileSelect` 等特殊 `VariableInputEnum`,保留推断出的 `inputType` +- 引用:`VariableSelector(valueType=string)` + +### 4.2 Number【P0】 +- 手动输入:行内 `[运算符下拉] [numberInput]` + - 运算符直接以符号显示:`+ − × ÷ =`(**不走 i18n**) + - `=` 直接赋值;其余为 `旧值 OP 输入值` +- 引用:仅支持直接赋值(无运算符),`VariableSelector(valueType=number)` + +### 4.3 Boolean【P0】 +- 手动输入:单个下拉 —— `是` / `否` / `取反` +- 引用:`VariableSelector(valueType=boolean)` + +### 4.4 Array【P1】 +- 手动输入:顶部「Array 模式下拉」`等于 / 追加 / 清空`,body 按 mode 渲染 + - **equal**:`InputRender(JSONEditor)` 整数组 + - **append**:按 `elementInputTypeFor(valueType)` 映射 `InputRender` + - **clear**:无输入区 +- 引用:**不显示**模式下拉,直接 `VariableSelector(valueType=arrayXxx)` 选整个数组 + +## 5. 运行时执行逻辑(`runUpdateVar.ts`) + +在现有「读 value → `valueTypeFormat` → 赋值」前插入类型分派: + +```typescript +const oldValue = readOldValue(varNodeId, varKey); // 从 variables 或 node.outputs +let newValue = computedValue; + +// 所有操作字段仅在 renderType === 'input' 时生效 +const isInput = item.renderType === FlowNodeInputTypeEnum.input; + +if (isInput && valueType === 'number' && numberOperator && numberOperator !== '=') { + const a = Number(oldValue) || 0; + const b = Number(newValue) || 0; + newValue = applyOp(a, numberOperator, b); +} + +if (isInput && valueType === 'boolean' && booleanMode) { + if (booleanMode === 'true') newValue = true; + else if (booleanMode === 'false') newValue = false; + else if (booleanMode === 'negate') newValue = !Boolean(oldValue); +} + +if (isInput && isArrayType(valueType)) { + const oldArr = Array.isArray(oldValue) ? oldValue : []; + if (arrayMode === 'clear') newValue = []; + else if (arrayMode === 'append') newValue = [...oldArr, newValue]; + // equal / undefined:直接替换(rawValue 已是整数组) +} +// reference 模式下 arrayMode 一律忽略,rawValue 是 referenced 整数组,直接赋值 +``` + +### 5.1 append 的 `valueTypeFormat` 边界 +现状 `valueTypeFormat(val, item.valueType)` 使用外层数组类型,append 分支下应改用**元素类型**对单个输入值做格式化,再推入数组。 + +### 5.2 `updateList` 顺序语义 +同一节点多条 update 依次执行,后一条读到的 `oldValue` 是前一条写入后的新值(因为 `variables[varKey]=value` 与 `output.value=value` 即时写入)。**运行时必须保持顺序执行**,后续不得改为并行/批量写入。 + +## 6. 老数据兼容(零迁移) + +- 无 `numberOperator` → 按 `=` 处理 +- 无 `booleanMode` → 按直接赋值处理(`value[1]` 为字面量) +- 无 `arrayMode` → 按 `manual` 处理 +- 无需迁移脚本 + +## 7. 开放问题定稿决议 + +| 问题 | 决议 | +| --- | --- | +| Number 除法遇 0 | **保持旧值** + 运行时 warning 日志(符合 FastGPT 失败降级风格) | +| Boolean negate 旧值非 boolean | 按 `!Boolean(oldValue)` 处理 | +| Array append 旧值非数组 | 退化为 `[newValue]` | +| Array append 引用模式下元素类型为 `any` | 允许选任意引用(交由用户保证一致性,与现状 any 类型行为一致) | +| Array 模式下拉位置 | 独立一行,位于「值」label 下方(避免与运算符行视觉冲突) | +| 切换 Array 模式时是否清空 value | **清空**(`value: undefined`),避免残留脏数据;切换 Number/Boolean 模式同样清空 | +| clear 模式下的 `renderType` | 保留上次值,运行时忽略,不影响序列化 | +| 切换目标变量时的默认值 | onSelect 调用 `getDefaultsForValueType(valueType)` 统一派默认:number→`numberOperator='='`;boolean→`booleanMode='true', value=['', true]`;array→`arrayMode='equal'`。保证 UI 初始态与 runtime 默认行为一致(否则 boolean 会出现 UI 显示「是」但 runtime 写 `false` 的错配) | + +## 8. 前置重构:组件拆分 + +当前 `NodeVariableUpdate.tsx` 单文件 380 行,`ValueRender` 用 `useMemoizedFn` 包裹规避重渲染,已逼近可维护上限。 + +### 目录结构 + +``` +Flow/nodes/NodeVariableUpdate/ + index.tsx ← NodeCard 外壳、列表管理、"值"label + toggle、目标变量选择 + ValueRenderer.tsx ← 按 renderType / valueType 派发到具体 renderer + VariableSelector.tsx ← 抽出的变量选择器 + renderers/ + NumberFormula.tsx + BooleanSelect.tsx + ArrayValue.tsx ← 自带模式下拉 + 直接映射 InputRender(不递归 ValueRenderer) +``` + +### 循环依赖处理 +实现阶段发现 Array append 单元素根本不需要 `NumberFormula` / `BooleanSelect` 等带有"操作语义"的组件 +(append 本身就是一个操作,再嵌套一层运算符没意义),所以放弃了递归方案:`ArrayValue` 直接按 +`elementInputTypeFor(arrayXxx)` 映射到 `InputRender` 的基础输入控件即可。无循环依赖。 + +### 为什么不复用 `InputRender` 扩展 `FlowNodeInputTypeEnum` +(来自 Notion 六点论证:value 形状不一致、运行时语义不能由 renderType 承载、`renderTypeList` 语义不匹配、仅此节点使用、动态 valueType、派发表 ≠ 复用。此处不展开。) + +## 9. i18n 清单 + +运算符符号不 i18n。仅下列 key: + +``` +workflow:var_update_boolean_true +workflow:var_update_boolean_false +workflow:var_update_boolean_negate +workflow:var_update_array_append +workflow:var_update_array_clear +workflow:var_update_array_equal +``` + +## 10. 测试策略(vitest) + +`packages/service/` 或 `test/` 下新增 `runUpdateVar.test.ts`: + +- Number:`+ − × ÷ =` 五种运算;除零保持旧值 +- Boolean:`true/false/negate`;旧值非 boolean 场景 +- Array:`append`(旧值数组、非数组、元素类型降级格式化)、`clear`、`manual` +- Reference 模式下:确认 `numberOperator / booleanMode` 被忽略 +- 顺序语义:单节点多条 update,后一条读到前一条写入值 + +## 11. 相关文件 + +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeVariableUpdate/` + - `index.tsx` — NodeCard 外壳 + `getDefaultsForValueType` + 列表管理 + - `ValueRenderer.tsx` — 按 renderType / valueType 派发 + - `VariableSelector.tsx` — 变量选择器 + - `renderers/{NumberFormula,BooleanSelect,ArrayValue}.tsx` +- `packages/service/core/workflow/dispatch/tools/runUpdateVar.ts` +- `packages/global/core/workflow/template/system/variableUpdate/type.ts` +- `test/cases/service/core/workflow/dispatch/tools/runUpdateVar.test.ts` +- `packages/web/components/common/Icon/icons/math/*.svg` + `constants.ts` +- `packages/web/i18n/{en,zh-CN,zh-Hant}/workflow.json` + +## 12. TODO + +见同目录 `todo.md`。 diff --git a/.codex/design/variable-update-type-ops/todo.md b/.codex/design/variable-update-type-ops/todo.md new file mode 100644 index 0000000000..f67aaa9eae --- /dev/null +++ b/.codex/design/variable-update-type-ops/todo.md @@ -0,0 +1,45 @@ +# TODO — 变量更新节点类型操作扩展 + +> 设计见同目录 `design.md` + +## Phase 1:类型 & 运行时(后端,先行) + +- [x] 扩展 `TUpdateListItem` 类型,新增 `numberOperator / booleanMode / arrayMode` +- [x] `runUpdateVar.ts`:添加 oldValue 读取工具函数 +- [x] `runUpdateVar.ts`:Number 公式分派(含除零保持旧值) +- [x] `runUpdateVar.ts`:Boolean `true/false/negate` 分派 +- [x] `runUpdateVar.ts`:Array `append/clear/equal` 分派(append 使用元素类型做 `valueTypeFormat`) +- [x] `runUpdateVar.ts`:所有新字段仅在 `renderType === input` 时生效的 guard +- [x] 写 vitest 测试 `runUpdateVar.test.ts`,运行通过(20 tests) + +## Phase 2:前端组件拆分(重构,不改行为) + +- [x] 建立目录 `NodeVariableUpdate/` +- [x] 把现有 `NodeVariableUpdate.tsx` 迁到 `NodeVariableUpdate/index.tsx`,拆出 `VariableSelector.tsx` +- [x] 新增 `ValueRenderer.tsx`(按 renderType / valueType 派发) + +## Phase 3:前端渲染器(新功能) + +- [x] `renderers/NumberFormula.tsx`:运算符下拉 + numberInput(图标化) +- [x] `renderers/BooleanSelect.tsx`:True/False/Negate 下拉 +- [x] `renderers/ArrayValue.tsx`:模式下拉 + 按元素类型映射 `InputRender`(不递归) +- [x] `ValueRenderer.tsx`:按 valueType 派发到新 renderer +- [x] 切换模式时清空 `value: undefined` + +## Phase 4:i18n + +- [x] 补充 `workflow:var_update_boolean_*` 与 `workflow:var_update_array_*` 中 / 英 / 繁中 + +## Phase 5:联调 + +- [x] dev server 起:string / number / boolean / array 四类变量逐一验证手动输入 + 引用两种模式 +- [x] 老数据打开(无新字段),表现与升级前一致 +- [x] 运行 `pnpm lint` 全量通过(0 errors) + +## Phase 6:Review 清理(2026-04-15) + +- [x] 还原 `constants.ts`:剥离 107 条与 math icons 无关的图标注册,只保留 5 个 `math/*` +- [x] `any[]` 收紧为 `EditorVariablePickerType[]` / `EditorVariableLabelPickerType[]`(3 个 renderer + ValueRenderer) +- [x] 抽出 `getDefaultsForValueType()` 统一目标变量切换时的默认字段下发 +- [x] `VariableSelector.tsx`:`.includes('array')` → `.startsWith('array')` 与仓内风格对齐 +- [x] `workflow.json` 三语把 `var_update_*` 按字母序移到 `variable_*` 之前 diff --git a/.codex/issue/openai-agent-sdk-integration/report.md b/.codex/issue/openai-agent-sdk-integration/report.md new file mode 100644 index 0000000000..fd5e7caa7c --- /dev/null +++ b/.codex/issue/openai-agent-sdk-integration/report.md @@ -0,0 +1,712 @@ +# OpenAI Agents SDK 集成调研报告 + +> 目标:评估将 [@openai/agents](https://github.com/openai/openai-agents-js)(TypeScript 版 OpenAI Agents SDK,下称 **OAI-Agents**)作为 FastGPT `dispatchRunAgent` 的第三种调度引擎引入的可行性,重点回答:**计费 token 能否拿到、tool 能否传入、skill 能否使用**。 +> +> 研究对象:`/Volumes/code/fastgpt-pro/FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` +> +> 调研日期:2026-04-27 +> SDK 版本:`@openai/agents` 0.8.5(npm latest) + +--- + +## 0. 执行摘要(TL;DR) + +| 关注点 | 结论 | 关键依据 | +|---|---|---| +| ① 拿到 token 用于计费 | ✅ **可行,且粒度比 pi 引擎更细** | `result.state.usage.requestUsageEntries[]` 暴露每次 LLM 调用的 input/output/cached/reasoning tokens;`result.rawResponses[].usage` 还能拿到 `responseId / providerData`。完全满足 FastGPT 现有 `usagePush(ChatNodeUsageType[])` 的梯度计费需求。 | +| ② 传入 tool | ✅ **可行,可直接复用现有 `getExecuteTool` 分发链** | `tool({ parameters, execute })` 接受 **JSON Schema** 或 **zod v4**,FastGPT 已锁定 zod v4,现有 `ChatCompletionTool[]` 的 `function.parameters`(JSON Schema)可直接喂入;execute 内部回调到 `getExecuteTool` 即可保持工具分发逻辑不变。 | +| ③ 使用 skill | ✅ **可行,沙箱 skill 机制对 SDK 透明** | FastGPT 的 skill 实质 = 「systemPrompt 中的 skill 元数据 + 6 个 sandbox tool + sandbox 容器中的 SKILL.md」,LLM 通过 `sandbox_read_file` 自主加载 SKILL.md。这套机制不依赖具体的 Agent loop 实现,只要把 `capabilitySystemPrompt` 注入 `Agent.instructions`、`capabilityTools` 注入 `Agent.tools` 即可。 | + +**总评**:可以用 **新增第三种引擎**(`AGENT_ENGINE='openai'`)的方式接入,**不替换** 现有 `default`/`pi` 两条路径,与 piAgent 走同一类桥接套路(modelBridge + toolAdapter + 主调度),改动量约 4 个新文件 ≈ 600 行代码 + 1 行 env 枚举扩展。 + +**主要风险点**(需用户拍板,详见 §6): +1. **Plan + Step 拆解能力**:OAI-Agents 自身没有 FastGPT 的「显式 plan + interactive ask」机制,需要决定是「完全交给 SDK 自主多轮 reasoning」还是「把 PlanAgentTool 作为一个 SDK tool 喂进去」。 +2. **Tracing 默认外发**:SDK 默认会把 trace 上传到 OpenAI 平台,必须 `setTracingDisabled(true)` 关闭。 +3. **第三方 OpenAI 兼容 endpoint**:必须 `setOpenAIAPI('chat_completions')` 切到 Chat Completions 路径;多租户并发场景需按 `Runner` 实例隔离,不要用进程级全局 setter。 + +--- + +## 1. 现有 agent 调度架构 + +### 1.1 入口分支 +`dispatchRunAgent` 顶部按 `env.AGENT_ENGINE` 分流([index.ts:81-83](../../../packages/service/core/workflow/dispatch/ai/agent/index.ts)): + +```ts +if (env.AGENT_ENGINE === 'pi') { + return dispatchPiAgent(props); +} +// default 引擎:Plan + Step 编排 +``` + +env 枚举([env.ts:127](../../../packages/service/env.ts)): +```ts +AGENT_ENGINE: z.enum(['default', 'pi']).default('default') +``` + +### 1.2 default 引擎(Plan + Master) +- **核心循环**:`dispatchPlanAgent`(计划)→ `masterCall`(执行)→ `runAgentLoop`(FastGPT 自家 LLM 多轮工具循环) +- **能力**:显式 plan 拆解 → 串行执行每个 step → 支持 plan 中途 ask 用户、续跑、最大 10 轮规划 +- **关键产物**:每次 LLM 调用、每次 tool 调用都通过 `usagePush([ChatNodeUsageType])` 推送账单([agentLoop/index.ts:336-344](../../../packages/service/core/ai/llm/agentLoop/index.ts)) + +### 1.3 pi 引擎(pi-agent-core 桥接) +- **核心循环**:`agent.prompt(input)` 由 `@mariozechner/pi-agent-core` 自管多轮 reasoning +- **桥接套路**([piAgent/](../../../packages/service/core/workflow/dispatch/ai/agent/piAgent/),**这是 OAI-Agents 集成的最佳参考**): + - `modelBridge.ts` — 把 FastGPT `LLMModelItemType` 转成 pi-ai 的 `Model` 配置(baseUrl/apiKey/headers) + - `toolAdapter.ts` — 把 `ChatCompletionTool[]` 包装成 pi-agent-core `AgentTool[]`,内部仍调 `getExecuteTool(ctx)` 复用 FastGPT 工具分发 + - `index.ts` — 主调度,订阅 `agent.subscribe(event)` 拿流式 token,`agent.state.messages` 存到 memories 跨轮恢复 +- **不支持**:plan 拆解(pi-agent-core 自己管 reasoning),interactive ask + +### 1.4 工具分发(两个引擎共用) +统一在 [utils.ts:`getExecuteTool`](../../../packages/service/core/workflow/dispatch/ai/agent/utils.ts): +- 三类来源汇总到 `completionTools: ChatCompletionTool[]`: + - **System tools**:`PlanAgentTool` / `readFileTool` / `datasetSearchTool` / `SANDBOX_TOOLS` + - **Capability tools**:当前主要是 `sandboxSkills` 提供的 6 个(read/write/edit/execute/search/fetchUserFile) + - **User tools**:`getAgentRuntimeTools` 从 `selectedTools` 转成 `tool / workflow / toolWorkflow` 三类 +- 输入 `{ callId, toolId, args }`,输出 `{ response, usages, nodeResponse, planResult, capabilityAssistantResponses, stop }` + +### 1.5 Skill 机制(**关键**) +Skill 不是 SDK 概念,是 FastGPT 自创的 progressive disclosure 模式([capability/sandboxSkills.ts](../../../packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts) + [sub/sandbox/prompt.ts:30](../../../packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts)): + +``` +skill = ( + systemPrompt 中注入 skill 元数据 // + + 6 个 sandbox tool 暴露给 LLM // sandbox_read_file 等 + + sandbox 容器中放置 SKILL.md // 容器内 /workspace//SKILL.md +) +``` + +LLM 看到 skill 元数据后,**自主**用 `sandbox_read_file` 加载完整 SKILL.md,再用 `sandbox_execute` 跑里面的脚本。 + +> **结论**:skill 机制对底层 Agent SDK 完全透明,只要 SDK 能(a)拼接 systemPrompt(b)暴露 tool,就能用 skill。 + +### 1.6 计费数据流 +``` +Tool/LLM 调用产生 ChatNodeUsageType{ inputTokens, outputTokens, totalPoints, moduleName, model } + ↓ +usagePush(usages: ChatNodeUsageType[]) // dispatchProps 透传下来的回调 + ↓ +工作流上层结算 +``` + +每次 LLM 调用都要 push 一条,不是只 push 总和(**梯度计费**要求按调用计价后累加,见 [agentLoop/index.ts:328-344](../../../packages/service/core/ai/llm/agentLoop/index.ts))。 + +--- + +## 2. OpenAI Agents SDK 关键能力(已验证) + +> 详细调研结果见同目录 `research-notes.md`(如需),此处只列与三大问题相关的结论。 + +### 2.1 Token / Usage 数据结构 + +**Run 级别**(来自 `packages/agents-core/src/usage.ts:31-200`、`result.ts:69-200`): +```ts +result.state.usage = { + requests: number, + inputTokens, outputTokens, totalTokens, + inputTokensDetails: { cached_tokens?: number, ... }, + outputTokensDetails: { reasoning_tokens?: number, ... }, + requestUsageEntries: RequestUsage[] // ← 每次 LLM 调用一条 +} + +type RequestUsage = { + inputTokens, outputTokens, totalTokens, + inputTokensDetails, outputTokensDetails, + endpoint: 'responses.create' | 'responses.compact' | 'chat.completions' | ... +} +``` + +更细到 raw response: +```ts +result.rawResponses: ModelResponse[] +// 每个 ModelResponse 自带 usage、responseId、requestId、providerData +``` + +Stream 模式:`runContext.usage` 实时更新,`await stream.completed` 后从 `stream.state.usage` 一次性拿到全量;中途也可订阅 `raw_model_stream_event` 的 `response.completed` 子事件读取每次响应的 usage。 + +**对比 pi-agent-core**:pi 只在 `turn_end` 给汇总 usage,要细分得自己累加;OAI-Agents 原生就提供 per-request 明细。 + +### 2.2 自定义 Model Provider + +**核心发现**:可以走 `OpenAIProvider({ openAIClient: customOpenAI })` 注入自建 `OpenAI` 客户端实例,每个 dispatch 一个 `Runner`,无需用进程级全局 setter,天然支持多租户多 baseUrl 并发。 + +```ts +import { Agent, Runner, OpenAIProvider, setOpenAIAPI } from '@openai/agents'; +import OpenAI from 'openai'; + +setOpenAIAPI('chat_completions'); // 第三方兼容 endpoint 必须切这条路径 + +function makeRunner(cfg: { baseURL: string; apiKey: string; headers?: Record }) { + const client = new OpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL, defaultHeaders: cfg.headers }); + return new Runner({ modelProvider: new OpenAIProvider({ openAIClient: client }) }); +} +``` + +> 不要用 `setDefaultOpenAIClient`(进程级全局),多并发会互相污染。 + +### 2.3 Tool 定义 + +**`tool()` 接受 zod object 或 JSON Schema**(`packages/agents-core/src/tool.ts:1215-1260`): + +```ts +import { tool } from '@openai/agents'; +const myTool = tool({ + name: 'foo', + description: 'do foo', + parameters: { type: 'object', properties: {...}, additionalProperties: false }, // JSON Schema + strict: false, // 必须,因为现有 schema 不一定满足 OpenAI strict 规范 + async execute(input, runContext, details) { + details?.signal?.throwIfAborted(); + return await fastgptDispatchTool({ callId: details!.toolCall.callId, toolId: 'foo', args: JSON.stringify(input) }); + }, + errorFunction: (ctx, err) => `tool error: ${err.message}`, + timeoutMs: 60_000 +}); +``` + +**Tool 流事件**:`run_item_stream_event` → `name: 'tool_called' | 'tool_output' | 'tool_approval_requested' | 'message_output_created' | ...` + +### 2.4 Skill 概念 + +❌ **SDK 没有 Skill 一等概念**。但 FastGPT 的 skill 是「prompt + tools」组合,对 SDK 透明: +- 把 `capabilitySystemPrompt`(含 `` 块)拼到 `Agent.instructions` +- 把 `capabilityTools`(6 个 sandbox tool)放进 `Agent.tools` +- LLM 自主调用 `sandbox_read_file` 时,SDK 转发到 FastGPT `executeTool` → `dispatchSandboxReadFile` → 沙箱容器 + +**未来扩展**:如果想做"按场景动态切换 skill 集",可以用 `Agent.asTool(...)` 把每个 skill 包成子 Agent,由 router agent 通过 `handoffs` 切换。 + +### 2.5 中断 & 序列化 + +```ts +const ctrl = new AbortController(); +checkIsStopping 轮询 → ctrl.abort() +const result = await run(agent, input, { signal: ctrl.signal, maxTurns: 100 }); + +// 跨轮恢复 +const snapshot = result.state.toString(); // 整个状态序列化为 JSON 字符串,存到 memories +const state = await RunState.fromString(agent, snapshot); +const resumed = await run(agent, state); +``` + +### 2.6 兼容性 + +| 项 | 要求 | FastGPT 现状 | +|---|---|---| +| Node | ≥20 | ✅ 20 | +| zod | **v4** | ✅ catalog 锁 `^4` | +| openai | `^6.26.0`(peer) | 待确认(需 `cd packages/service && pnpm why openai` 实测) | +| ESM | 纯 ESM + CJS dual | ✅ `@fastgpt/service` 已是 ESM | + +--- + +## 3. 三大问题对照方案 + +### 3.1 ✅ 计费 token + +**对照映射**: +``` +SDK: result.state.usage.requestUsageEntries[] + ↓ 每条 RequestUsage → ChatNodeUsageType +FastGPT: usagePush([{ inputTokens, outputTokens, totalPoints, moduleName, model }]) +``` + +**实现方式(伪代码,见 §5.3)**: +```ts +// run 结束后 +const entries = result.state.usage.requestUsageEntries ?? []; +const usages: ChatNodeUsageType[] = entries.map(e => { + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens + }).totalPoints; + return { + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + totalPoints + }; +}); +usagePush(usages); +``` + +**风险**:第三方 provider(DeepSeek、阿里、火山)的 `cached_tokens` / `reasoning_tokens` 字段名可能不一致,**首期可以先不读这两个细分字段**,只取 `inputTokens` / `outputTokens` 走基础计费;后续要做缓存折扣计费时再按 provider 适配。 + +### 3.2 ✅ 传入 tool + +**关键洞察**:**完全复用** 现有的 `getExecuteTool` —— 桥接层只负责把 `ChatCompletionTool[]` 转成 SDK tool[],execute 直接回调 FastGPT 的工具分发。 + +```ts +import { tool as oaiTool } from '@openai/agents'; + +function buildOpenAITools(ctx: ToolDispatchContext) { + const executeTool = getExecuteTool(ctx); + return ctx.completionTools + .filter(t => t.function.name !== SubAppIds.plan) // 看决策点 §6.1 + .map(t => oaiTool({ + name: t.function.name, + description: t.function.description ?? '', + parameters: (t.function.parameters as any) ?? { type: 'object', properties: {} }, + strict: false, + execute: async (input, _runCtx, details) => { + const callId = details?.toolCall.callId ?? getNanoid(8); + const { response, usages, nodeResponse, capabilityAssistantResponses } = await executeTool({ + callId, + toolId: t.function.name, + args: JSON.stringify(input) + }); + // 工具内部产生的 usage 立刻 push(沙箱、子工作流、子工具会带) + if (usages?.length) ctx.usagePush(usages); + if (nodeResponse) ctx.nodeResponses.push(nodeResponse); + if (capabilityAssistantResponses?.length) ctx.capAssistantResponses.push(...capabilityAssistantResponses); + return response; + } + })); +} +``` + +**所有现存工具都能直接接入**: +- ✅ User tools(dispatchTool / dispatchApp / dispatchPlugin) +- ✅ System tools(fileRead / datasetSearch / SANDBOX_TOOLS) +- ✅ Capability tools(sandboxSkills 的 6 个工具) +- ⚠️ PlanAgentTool 看 §6.1 决策 + +### 3.3 ✅ 使用 skill + +**直接复用** [createSandboxSkillsCapability](../../../packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts:192) 即可,跟 `dispatchPiAgent` 用法一模一样: + +```ts +// 在 dispatchOpenAIAgent 里,照抄 piAgent/index.ts 的 capabilities 初始化逻辑 +if (env.SHOW_SKILL) { + const sandboxCap = await createSandboxSkillsCapability({ + skillIds: normalizedSkillIds, + teamId, tmbId, sessionId, mode: sandboxMode, + workflowStreamResponse, + showSkillReferences, + allFilesMap + }); + capabilities.push(sandboxCap); +} + +const capabilitySystemPrompt = capabilities.map(c => c.systemPrompt).filter(Boolean).join('\n\n'); +const capabilityTools = capabilities.flatMap(c => c.completionTools ?? []); +const capabilityToolCallHandler = createCapabilityToolCallHandler(capabilities); + +// 然后构造 Agent +const agent = new Agent({ + name: 'fastgpt-agent', + instructions: parseUserSystemPrompt({ + userSystemPrompt: `${systemPrompt}\n\n${capabilitySystemPrompt}`.trim(), + selectedDataset: datasetParams?.datasets + }), + tools: buildOpenAITools(toolCtx), // ← 已含 capabilityTools(沙箱 skill 工具) + model: cfg.model +}); +``` + +skill 元数据进 prompt、sandbox tool 进 tools,LLM 自主调用 → 走到 `executeTool` → `capabilityToolCallHandler` → `buildSessionHandler` → 沙箱容器。**与现有 default/pi 引擎逻辑完全一致**。 + +--- + +## 4. 集成方案设计 + +### 4.1 总体策略 +**新增第三种引擎**,不替换 default / pi: + +```ts +// env.ts +AGENT_ENGINE: z.enum(['default', 'pi', 'openai']).default('default') + +// dispatch/ai/agent/index.ts +if (env.AGENT_ENGINE === 'pi') return dispatchPiAgent(props); +if (env.AGENT_ENGINE === 'openai') return dispatchOpenAIAgent(props); +// 否则走 default Plan+Master +``` + +理由: +- `default` 引擎是 FastGPT 自家 Plan+Step 能力,OAI-Agents 替代不了 plan +- 三种引擎并存便于 A/B 比较与回滚 +- env 切换零业务侵入 + +### 4.2 文件结构(新增) + +``` +packages/service/core/workflow/dispatch/ai/agent/ +├─ openaiAgent/ (新增目录,参照 piAgent/) +│ ├─ index.ts (主调度入口) +│ ├─ modelBridge.ts (OpenAI 客户端构建 + Provider 注入) +│ ├─ toolAdapter.ts (ChatCompletionTool[] → tool[]) +│ ├─ usageBridge.ts (RequestUsageEntry[] → ChatNodeUsageType[]) +│ └─ streamBridge.ts (run_item_stream_event → SSE) +└─ index.ts (顶部多加一个 if 分支) +``` + +依赖:`packages/service/package.json` 新增 `"@openai/agents": "^0.8.5"`、`"openai": "^6.26.0"`(确认与现有版本兼容)。 + +### 4.3 核心代码骨架 + +#### 4.3.1 modelBridge.ts +```ts +import OpenAI from 'openai'; +import { OpenAIProvider, setOpenAIAPI, setTracingDisabled } from '@openai/agents'; +import { getLLMModel } from '../../../../../ai/model'; + +setOpenAIAPI('chat_completions'); // 全局:兼容第三方 endpoint +setTracingDisabled(true); // 全局:禁止 trace 外发到 OpenAI + +const aiProxyBaseUrl = process.env.AIPROXY_API_ENDPOINT ? `${process.env.AIPROXY_API_ENDPOINT}/v1` : undefined; +const defaultBaseUrl = aiProxyBaseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'; +const defaultApiKey = process.env.AIPROXY_API_TOKEN || process.env.CHAT_API_KEY || ''; + +export function buildOpenAIRunner(modelNameOrId?: string) { + const cfg = getLLMModel(modelNameOrId); + const rawUrl = cfg?.requestUrl ?? ''; + const baseURL = rawUrl ? rawUrl.replace(/\/chat\/completions$/, '') : defaultBaseUrl; + const apiKey = cfg?.requestAuth || defaultApiKey; + + const client = new OpenAI({ apiKey, baseURL }); + const provider = new OpenAIProvider({ openAIClient: client }); + + return { + provider, + modelId: cfg?.model ?? 'gpt-4o', + modelData: cfg + }; +} +``` + +#### 4.3.2 toolAdapter.ts +```ts +import { tool as oaiTool } from '@openai/agents'; +import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { getExecuteTool, type ToolDispatchContext } from '../utils'; + +export function buildOpenAITools({ + ctx, + nodeResponses, + capabilityAssistantResponses, + usagePush +}: { ctx: ToolDispatchContext; nodeResponses: ChatHistoryItemResType[]; capabilityAssistantResponses: AIChatItemValueItemType[]; usagePush: (u: ChatNodeUsageType[]) => void }) { + const executeTool = getExecuteTool(ctx); + + return ctx.completionTools + .filter(t => t.function.name !== SubAppIds.plan) // OAI-Agents 自管 reasoning,先不喂 plan + .map(t => { + const toolId = t.function.name; + return oaiTool({ + name: toolId, + description: t.function.description ?? '', + parameters: (t.function.parameters as any) ?? { type: 'object', properties: {}, additionalProperties: false }, + strict: false, + async execute(input, _ctx, details) { + const callId = details?.toolCall.callId ?? ''; + const subInfo = ctx.getSubAppInfo(toolId); + + ctx.streamResponseFn?.({ + id: callId, event: SseResponseEventEnum.toolCall, + data: { tool: { id: callId, toolName: subInfo?.name || toolId, toolAvatar: subInfo?.avatar || '', functionName: toolId, params: JSON.stringify(input) } } + }); + + const { response, usages = [], nodeResponse, capabilityAssistantResponses: capResps = [] } = await executeTool({ + callId, toolId, args: JSON.stringify(input) + }); + + if (nodeResponse) nodeResponses.push(nodeResponse); + if (usages.length) usagePush(usages); + if (capResps.length) capabilityAssistantResponses.push(...capResps); + + ctx.streamResponseFn?.({ + id: callId, event: SseResponseEventEnum.toolResponse, + data: { tool: { response } } + }); + + return response; + } + }); + }); +} +``` + +#### 4.3.3 usageBridge.ts +```ts +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { Usage as OAIUsage } from '@openai/agents'; +import { formatModelChars2Points } from '../../../../../support/wallet/usage/utils'; +import { i18nT } from '../../../../../../web/i18n/utils'; + +export function convertOAIUsageToChatNodeUsages({ + usage, modelData, userKey +}: { usage: OAIUsage; modelData: LLMModelItemType; userKey?: any }): ChatNodeUsageType[] { + const entries = usage.requestUsageEntries ?? []; + if (entries.length === 0) { + // fallback: 总和当一条 + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + }).totalPoints; + return [{ + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalPoints + }]; + } + + return entries.map(e => { + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens + }).totalPoints; + return { + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + totalPoints + }; + }); +} +``` + +#### 4.3.4 index.ts(主调度,关键流程) +```ts +export const dispatchOpenAIAgent = async (props: DispatchAgentModuleProps): Promise => { + // ... 文件、capabilities、systemPrompt、subapps 初始化(直接照抄 piAgent/index.ts:70-160)... + + const { provider, modelId, modelData } = buildOpenAIRunner(model); + const runner = new Runner({ modelProvider: provider }); + + const oaiMessagesKey = `oaiMessages-${nodeId}`; + const lastHistory = chatHistories[chatHistories.length - 1]; + const restoredStateJSON = lastHistory?.obj === ChatRoleEnum.AI + ? (lastHistory.memories?.[oaiMessagesKey] as string | undefined) + : undefined; + + const tools = buildOpenAITools({ ctx: toolCtx, nodeResponses, capabilityAssistantResponses, usagePush }); + + const agent = new Agent({ + name: 'fastgpt-agent', + instructions: formatedSystemPrompt, + model: modelId, + tools + }); + + const ctrl = new AbortController(); + const stopPoller = setInterval(() => { + if (checkIsStopping()) { ctrl.abort(); clearInterval(stopPoller); } + }, 200); + + let answerText = ''; + let result; + try { + const input = restoredStateJSON + ? await RunState.fromString(agent, restoredStateJSON) // 续跑 + : formatUserChatInput; + + // 追加新输入到 state(如果是续跑场景) + const stream = await runner.run(agent, input, { + signal: ctrl.signal, + maxTurns: 100, + stream: true + }); + + for await (const event of stream) { + if (event.type === 'raw_model_stream_event') { + // 文本增量 + const delta = (event.data as any).delta; + if (typeof delta === 'string') { + answerText += delta; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: delta }) + }); + } + } + // tool_called / tool_output 事件已在 buildOpenAITools 内手动 emit,不重复 + } + + await stream.completed; + result = stream; + } finally { + clearInterval(stopPoller); + } + + // ===== 计费 ===== + usagePush(convertOAIUsageToChatNodeUsages({ usage: result.state.usage, modelData, userKey: externalProvider.openaiAccount })); + + // ===== 返回 ===== + if (answerText) assistantResponses.push({ text: { content: answerText } }); + + return { + data: { [NodeOutputKeyEnum.answerText]: answerText }, + [DispatchNodeResponseKeyEnum.memories]: { + [oaiMessagesKey]: result.state.toString() // 序列化全部状态用于跨轮恢复 + }, + [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, + [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses + }; +}; +``` + +### 4.4 数据流总览 + +``` +用户输入 + ↓ +dispatchRunAgent (env.AGENT_ENGINE='openai') + ↓ +dispatchOpenAIAgent + ├─ formatFileInput / capabilities / getSubapps (复用) + ├─ buildOpenAIRunner(model) (新) + │ └─ new OpenAI({ baseURL, apiKey }) + │ └─ new OpenAIProvider({ openAIClient }) + ├─ buildOpenAITools(ctx) (新) + │ └─ 每个 tool.execute → getExecuteTool(ctx) → 现有分发链 + ├─ runner.run(agent, input, { signal, stream }) + │ ↓ + │ SDK 内部多轮 LLM + tool_call + │ ↓ + │ stream: raw_model_stream_event / run_item_stream_event + │ ↓ (toolAdapter 内 emit SSE) + │ workflowStreamResponse → 客户端 + ├─ convertOAIUsageToChatNodeUsages(result.state.usage) + │ └─ usagePush(usages) (新桥接,复用 formatModelChars2Points) + └─ result.state.toString() → memories (跨轮恢复) +``` + +--- + +## 5. 三大问题对照实现速查 + +| 问题 | 实现位置 | 关键 API | 改动量 | +|---|---|---|---| +| 1. 拿到 token 计费 | `usageBridge.ts` | `result.state.usage.requestUsageEntries[]` → `ChatNodeUsageType[]` → `usagePush(...)` | ~30 行 | +| 2. 传入 tool | `toolAdapter.ts` | `tool({ parameters: t.function.parameters, execute: ... })` | ~50 行 | +| 3. 使用 skill | 复用 `createSandboxSkillsCapability`,把 systemPrompt 注入 `Agent.instructions`、tools 注入 `Agent.tools` | 0 行新代码(与 piAgent 一致) | + +--- + +## 6. 决策点与风险 + +### 6.1 ⚠️ Plan + Step 拆解能力如何处理 [需用户拍板] + +**背景**:default 引擎的 `PlanAgentTool` 提供两个核心价值: +- 显式拆解任务为多个 step +- 支持 plan 中途用户 ask(人在回路) + +**OAI-Agents 没有等价机制**。三种选择: + +| 方案 | 描述 | 优劣 | +|---|---|---| +| **A. 不要 plan** | 完全交给 SDK 自主多轮 reasoning(max_turns=100) | 最简单;但任务复杂度高时模型可能跑偏 | +| **B. Plan as tool** | 把现有 `PlanAgentTool` 作为一个 SDK tool 喂进去(保留 toolAdapter 中对 plan 的过滤逻辑反过来) | 兼容现有 plan 能力;interactive ask 需要走 SDK 的 `needsApproval` + `RunState` 序列化机制重写 | +| **C. 双 Agent + handoff** | plannerAgent + workerAgent,handoff 切换 | 最贴近原 default 引擎模型;改造量最大 | + +**推荐**:**A**(首期)。理由:OAI-Agents 引擎本身就是为「自主多步推理 + 工具调用」设计的,强行套 plan 反而压制了它的优势;如果要 plan,留着 default 引擎用就行。 + +### 6.2 ⚠️ Tracing 默认外发 [必须处理] + +OAI-Agents 默认会上传 trace 到 `https://api.openai.com/v1/traces`,**包含完整的 prompt / tool args / response**。 + +**解决**:`modelBridge.ts` 顶部 `setTracingDisabled(true)`(已写入 §4.3.1)。 + +### 6.3 ⚠️ 多租户并发下的全局 setter [必须处理] + +下列 setter 是**进程级单例**: +- `setDefaultOpenAIClient` +- `setDefaultOpenAIKey` +- `setDefaultModelProvider` +- `setOpenAIAPI`(部分例外,下面说明) + +**对策**: +- ✅ 用 `new Runner({ modelProvider })` 每次 dispatch 创建独立 Runner(已在 §4.3.4 体现) +- ✅ `setOpenAIAPI('chat_completions')` 和 `setTracingDisabled(true)` 是「全进程一次性配置」性质,进程启动时设一次即可,不会有多租户冲突 +- ❌ 不要在 dispatch 路径中调 `setDefaultOpenAIClient` + +### 6.4 ⚠️ Cached / Reasoning Tokens [可延后] + +第三方 provider(DeepSeek、阿里、火山等)的 `inputTokensDetails.cached_tokens` / `outputTokensDetails.reasoning_tokens` 字段名可能不一致。 + +**首期**:只读 `inputTokens` / `outputTokens` 走基础计费,已能 100% 满足现有计费精度。 +**后期**:要做 cached token 折扣计费时再按 provider 适配。 + +### 6.5 ⚠️ Interactive 工具响应 [影响范围有限] + +OAI-Agents 通过 `tool({ needsApproval: true })` + `RunState.fromString` 实现 HITL,与 FastGPT 的 `WorkflowInteractiveResponseType` 机制不兼容。 + +**首期对策**:在 `toolAdapter` 里**不开启** interactive;如果走到产生 interactive 的工具,直接当 stop 处理(response = 错误消息)。default 引擎仍然支持 interactive,是 default 的差异化能力。 + +### 6.6 ⚠️ 包版本冲突 [需验证] + +OAI-Agents peer dep `openai@^6.26.0`,需确认 `pnpm why openai` 现有版本是否兼容。FastGPT 可能在 `packages/service` 下接入了别的 openai 调用,可能要统一版本。 + +**验证命令**: +```bash +cd /Volumes/code/fastgpt-pro/FastGPT/packages/service && pnpm why openai +``` + +### 6.7 ⚠️ State 序列化体积 [可观测] + +`result.state.toString()` 会把 history、turn、pending tool calls 全部序列化。多轮长会话场景下 memories 字段会很大。 + +**对策**: +- 监控 `oaiMessagesKey` 字段大小 +- 如超过阈值(如 200KB),降级为只保存 `result.history`,下次启动新 Agent 重新构建(损失 plan/turn 元信息但消息历史保留) + +--- + +## 7. 落地里程碑(建议) + +| 里程碑 | 工作内容 | 预估工时 | +|---|---|---| +| **M1:依赖与基础设施** | `pnpm add @openai/agents`;env 增加 `'openai'` 枚举;新建 `openaiAgent/` 目录骨架 | 0.5d | +| **M2:modelBridge + toolAdapter** | 实现 `buildOpenAIRunner` / `buildOpenAITools` / `usageBridge`;写最小 e2e(hello world tool) | 1.5d | +| **M3:主调度 + skill** | 实现 `dispatchOpenAIAgent`;接入 `createSandboxSkillsCapability`;接入 SSE 流;接入 `RunState` 续跑 | 2d | +| **M4:计费验证** | 跑通 OpenAI / DeepSeek / 阿里 三类 endpoint;对 `usagePush` 输出做单测,对比 default 引擎一致性 | 1d | +| **M5:边界 & 灰度** | abort、超时、错误重试、context 压缩、长会话 | 1d | +| **M6:文档 + 灰度** | 写 docs;先内部 `AGENT_ENGINE=openai` 灰度 | 0.5d | + +总计 ~ **6.5 人日**。 + +--- + +## 8. 待用户确认的问题 + +1. **是否同意"新增第三种引擎"而非替换 pi**?(推荐新增) +2. **Plan 拆解能力是否要保留**?(推荐首期不要,详见 §6.1) +3. **Interactive ask 是否要支持**?(推荐首期不要,详见 §6.5) +4. **首期支持的 LLM provider 范围**:仅 OpenAI 官方 / OpenAI + 第三方兼容 endpoint / 含 Claude+Gemini(需走 ai-sdk 桥,beta)? +5. **是否接受 `setTracingDisabled(true)` 直接禁掉所有 trace 上传**?(推荐是;如果想留 trace,需自建 trace 上报 endpoint) + +--- + +## 附录 A:参考链接 + +- 主文档:https://openai.github.io/openai-agents-js/ +- Models 指南:https://openai.github.io/openai-agents-js/guides/models +- AI SDK 适配(Claude/Gemini 走这条):https://openai.github.io/openai-agents-js/extensions/ai-sdk +- 仓库:https://github.com/openai/openai-agents-js +- 关键源码(建议直接看): + - `packages/agents-core/src/usage.ts`(Usage / RequestUsage) + - `packages/agents-core/src/result.ts`(RunResult / StreamedRunResult) + - `packages/agents-core/src/run.ts`(Runner / RunConfig) + - `packages/agents-openai/src/openaiProvider.ts` + - `packages/agents-core/src/runState.ts:914-931`(fromString / 续跑) + - `examples/model-providers/custom-example-global.ts`(最贴近 FastGPT 需求的示例) + +## 附录 B:文件清单 + +| 路径 | 状态 | 行数估算 | +|---|---|---| +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/index.ts` | 新增 | ~280 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/modelBridge.ts` | 新增 | ~50 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/toolAdapter.ts` | 新增 | ~80 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/usageBridge.ts` | 新增 | ~40 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/streamBridge.ts` | 新增(如必要) | ~60 | +| `packages/service/core/workflow/dispatch/ai/agent/index.ts` | 修改(+1 if) | +3 | +| `packages/service/env.ts` | 修改(枚举扩展) | +0(改字面量) | +| `packages/service/package.json` | 修改 | +1 deps | diff --git a/.codex/issue/ssrf-vulnerability-fix.md b/.codex/issue/ssrf-vulnerability-fix.md new file mode 100644 index 0000000000..42b869583d --- /dev/null +++ b/.codex/issue/ssrf-vulnerability-fix.md @@ -0,0 +1,216 @@ +# SSRF 漏洞修复设计文档 + +## 漏洞概述 + +**漏洞编号**: GHSA-6g6x-8hq5-9cw4 +**漏洞类型**: Server-Side Request Forgery (SSRF) - CWE-918 +**严重程度**: High +**影响版本**: <= 4.8.22 + +## 漏洞详情 + +### 1. 主要问题 + +FastGPT 的 HTTP Tool 连接器在处理用户控制的 URL 时缺乏 SSRF 保护: + +**受影响文件**: +- `packages/service/core/app/http.ts` (lines 127-166) - `runHTTPTool()` 函数 +- `projects/app/src/pages/api/core/app/httpTools/runTool.ts` - API 端点 + +**问题代码**: +```typescript +export const runHTTPTool = async ({ baseUrl, toolPath, method, ... }) => { + const { data } = await axios({ + method: method.toUpperCase(), + baseURL: baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}`, + url: toolPath, + // 没有任何 IP 验证! + }); +}; +``` + +### 2. 次要问题 + +`isInternalAddress()` 函数默认被禁用: + +**文件**: `packages/service/common/system/utils.ts` (line 142) + +```typescript +if (process.env.CHECK_INTERNAL_IP !== 'true') { + return false; // 默认允许内部地址! +} +``` + +这意味着 http468 工作流节点和 readFiles 也缺乏 SSRF 保护,除非显式设置 `CHECK_INTERNAL_IP=true`。 + +## 攻击场景 + +认证用户可以使用 HTTP Tool 进行以下攻击: + +1. **AWS 凭证窃取**: + - `baseUrl: http://169.254.169.254` + - `toolPath: /latest/meta-data/iam/security-credentials/` + +2. **Kubernetes 密钥泄露**: + - `baseUrl: http://kubernetes.default.svc` + - `toolPath: /api/v1/namespaces/default/secrets/` + +3. **内部网络扫描和服务利用** + +## 修复方案 + +### 方案 1: 在 runHTTPTool 中添加 SSRF 保护(推荐) + +**修改文件**: `packages/service/core/app/http.ts` + +在 `runHTTPTool` 函数中,在发起请求前添加 URL 验证: + +```typescript +export const runHTTPTool = async ({ + baseUrl, + toolPath, + method = 'POST', + params, + headerSecret, + customHeaders, + staticParams, + staticHeaders, + staticBody +}: RunHTTPToolParams): Promise => { + try { + // 构建完整 URL + const fullBaseUrl = baseUrl.startsWith('http://') || baseUrl.startsWith('https://') + ? baseUrl + : `https://${baseUrl}`; + + // SSRF 保护:验证 URL 是否指向内部地址 + const fullUrl = new URL(toolPath, fullBaseUrl).toString(); + if (await isInternalAddress(fullUrl)) { + return { errorMsg: 'Access to internal addresses is not allowed' }; + } + + const { headers, body, queryParams } = buildHttpRequest({ + method, + params, + headerSecret, + customHeaders, + staticParams, + staticHeaders, + staticBody + }); + + const { data } = await axios({ + method: method.toUpperCase(), + baseURL: fullBaseUrl, + url: toolPath, + headers, + data: body, + params: queryParams, + timeout: 300000 + }); + + return { data }; + } catch (error: any) { + return { errorMsg: getErrText(error) }; + } +}; +``` + +### 方案 2: 修改 CHECK_INTERNAL_IP 默认值 + +**修改文件**: `packages/service/common/system/utils.ts` + +将默认行为从"允许"改为"拒绝": + +```typescript +// 3. 如果未启用内部 IP 检查,则默认拒绝(安全优先) +if (process.env.CHECK_INTERNAL_IP === 'false') { + return false; // 显式禁用检查时才允许 +} + +// 默认启用内部 IP 检查 +``` + +**注意**: 这个改动可能影响向后兼容性,需要在文档中说明。 + +### 方案 3: 添加 DNS Rebinding 保护(可选增强) + +在 `isInternalAddress` 函数中,可以添加 DNS rebinding 保护: + +1. 解析域名获取 IP +2. 验证 IP 是否为内部地址 +3. 在实际请求时,固定使用已验证的 IP(而不是重新解析) + +这需要修改 axios 请求的方式,使用已解析的 IP 而不是域名。 + +## 实施步骤 + +### 第一阶段:核心修复(必须) + +1. ✅ 在 `runHTTPTool` 中添加 `isInternalAddress` 验证 +2. ✅ 修改 `CHECK_INTERNAL_IP` 默认行为为启用 +3. ✅ 添加单元测试验证修复 + +### 第二阶段:文档更新(必须) + +1. 更新部署文档,说明 `CHECK_INTERNAL_IP` 环境变量的变化 +2. 添加安全最佳实践文档 +3. 更新 CHANGELOG + +### 第三阶段:增强保护(可选) + +1. 实现 DNS rebinding 保护 +2. 添加请求日志和监控 +3. 实现 URL 白名单机制 + +## 测试计划 + +### 单元测试 + +创建测试文件: `test/cases/service/core/app/http.test.ts` + +测试用例: +1. ✅ 测试拒绝 AWS 元数据端点 (169.254.169.254) +2. ✅ 测试拒绝 Kubernetes 服务 (kubernetes.default.svc) +3. ✅ 测试拒绝私有 IP 范围 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) +4. ✅ 测试拒绝 localhost 和 127.0.0.1 +5. ✅ 测试允许合法的外部 URL +6. ✅ 测试 DNS rebinding 场景(域名解析到内部 IP) + +### 集成测试 + +1. 测试 HTTP Tool 在工作流中的行为 +2. 测试 API 端点 `/api/core/app/httpTools/runTool` +3. 验证错误消息的正确性 + +## 向后兼容性 + +### 破坏性变更 + +1. **CHECK_INTERNAL_IP 默认值变更**: + - 旧行为: 默认允许内部地址访问 + - 新行为: 默认拒绝内部地址访问 + +2. **影响范围**: + - 依赖访问内部服务的工作流将失败 + - 需要显式设置 `CHECK_INTERNAL_IP=false` 来恢复旧行为(不推荐) + +### 迁移指南 + +对于需要访问内部服务的合法用例: + +1. **推荐方案**: 使用代理服务或 API 网关 +2. **临时方案**: 设置 `CHECK_INTERNAL_IP=false`(不安全,仅用于开发环境) + +## 安全建议 + +1. **生产环境**: 始终保持 `CHECK_INTERNAL_IP=true`(默认) +2. **网络隔离**: 在网络层面限制 FastGPT 服务器的出站访问 +3. **监控**: 记录所有 HTTP Tool 请求,监控异常模式 +4. **最小权限**: 限制 FastGPT 服务账号的权限 + +## 参考资料 + +- [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html) +- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) +- GitHub Security Advisory: GHSA-6g6x-8hq5-9cw4 diff --git a/.codex/issue/workflow-and-chat-bug-fixes-analysis.md b/.codex/issue/workflow-and-chat-bug-fixes-analysis.md new file mode 100644 index 0000000000..465b434343 --- /dev/null +++ b/.codex/issue/workflow-and-chat-bug-fixes-analysis.md @@ -0,0 +1,191 @@ +# 工作流与聊天预览相关 Bug 修复分析文档 + +## Bug 1: 自定义文件扩展类型下,流程开始节点缺少“文件链接”变量 + +### 漏洞概述 + +系统配置开启文件上传后,如果只勾选“自定义文件扩展类型”,流程开始节点不会暴露“文件链接”变量,后续节点无法引用上传文件链接。 + +### 主要问题 + +开始节点和 workflow 输入 schema 的“可上传文件”判断逻辑仍停留在旧实现,只识别: + +- `canSelectFile` +- `canSelectImg` + +没有将以下配置纳入统一判断: + +- `canSelectVideo` +- `canSelectAudio` +- `canSelectCustomFileExtension` + +### 受影响文件 + +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx` +- `packages/global/core/workflow/utils.ts` +- `test/cases/global/core/workflow/utils.test.ts` + +### 问题代码 + +```typescript +const canUploadFiles = e.canSelectFile || e.canSelectImg; +``` + +```typescript +...(chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg + ? [Input_Template_File_Link] + : []), +``` + +### 修改代码 + +```typescript +const canUploadFiles = + e.canSelectFile || + e.canSelectImg || + e.canSelectVideo || + e.canSelectAudio || + e.canSelectCustomFileExtension; +``` + +```typescript +...(chatConfig?.fileSelectConfig?.canSelectFile || +chatConfig?.fileSelectConfig?.canSelectImg || +chatConfig?.fileSelectConfig?.canSelectVideo || +chatConfig?.fileSelectConfig?.canSelectAudio || +chatConfig?.fileSelectConfig?.canSelectCustomFileExtension + ? [Input_Template_File_Link] + : []), +``` + +--- + +## Bug 2: 判断器选择 array 类型变量后,没有条件可选 + +### 漏洞概述 + +在判断器中选择 array 类型变量时,条件下拉为空,无法配置数组相关判断逻辑。 + +### 主要问题 + +前端条件映射遗漏了 `WorkflowIOValueTypeEnum.arrayAny`,导致泛数组类型没有进入 `arrayConditionList` 分支。 + +### 受影响文件 + +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeIfElse/ListItem.tsx` + +### 问题代码 + +```typescript +if ( + valueType === WorkflowIOValueTypeEnum.chatHistory || + valueType === WorkflowIOValueTypeEnum.datasetQuote || + valueType === WorkflowIOValueTypeEnum.dynamic || + valueType === WorkflowIOValueTypeEnum.selectApp || + valueType === WorkflowIOValueTypeEnum.arrayBoolean || + valueType === WorkflowIOValueTypeEnum.arrayNumber || + valueType === WorkflowIOValueTypeEnum.arrayObject || + valueType === WorkflowIOValueTypeEnum.arrayString +) + return arrayConditionList; +``` + +### 修改代码 + +```typescript +if ( + valueType === WorkflowIOValueTypeEnum.chatHistory || + valueType === WorkflowIOValueTypeEnum.datasetQuote || + valueType === WorkflowIOValueTypeEnum.dynamic || + valueType === WorkflowIOValueTypeEnum.selectApp || + valueType === WorkflowIOValueTypeEnum.arrayAny || + valueType === WorkflowIOValueTypeEnum.arrayBoolean || + valueType === WorkflowIOValueTypeEnum.arrayNumber || + valueType === WorkflowIOValueTypeEnum.arrayObject || + valueType === WorkflowIOValueTypeEnum.arrayString +) + return arrayConditionList; +``` + +--- + +## Bug 3: 系统工具集不应显示版本信息 + +### 漏洞概述 + +系统工具集卡片错误显示“保持最新版本”等版本 UI,但系统工具集本身不应展示版本选择能力。 + +### 主要问题 + +节点卡片的版本显示条件排除了 `mcpToolSet`、`mcpTool`、`httpToolSet`,但漏掉了 `systemToolSet`,导致系统工具集也进入了版本渲染逻辑。 + +### 受影响文件 + +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx` + +### 问题代码 + +```typescript +if ( + isAppNode && + (node.toolConfig?.mcpToolSet || node.toolConfig?.mcpTool || node?.toolConfig?.httpToolSet) +) + return false; +``` + +### 修改代码 + +```typescript +if ( + isAppNode && + ( + node.toolConfig?.mcpToolSet || + node.toolConfig?.mcpTool || + node?.toolConfig?.httpToolSet || + node?.toolConfig?.systemToolSet + ) +) + return false; +``` + +--- + +## Bug 4: 用户输入中的 `*` 被按 Markdown 强调语法渲染 + +### 漏洞概述 + +在运行预览和相关聊天场景中,用户输入 `1*1=1, 2*2=4` 后,消息会被按 Markdown 语法渲染,导致 `*` 不按原样显示。 + +### 主要问题 + +用户消息展示层直接复用了 Markdown 渲染组件: + +- 主聊天容器中的人类消息 +- HelperBot 中的人类消息 + +因此用户输入里的 `*`、`#`、`` ` `` 等字符会被 Markdown 解释。 + +### 受影响文件 + +- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx` +- `projects/app/src/components/core/chat/HelperBot/components/HumanItem.tsx` + +### 问题代码 + +```typescript +{text && } +``` + +### 修改代码 + +```typescript +{text && ( + + {text} + +)} +``` + +```typescript +{text && {text}} +``` diff --git a/.codex/issue/workflow-deep-analysis.md b/.codex/issue/workflow-deep-analysis.md new file mode 100644 index 0000000000..dbf601325c --- /dev/null +++ b/.codex/issue/workflow-deep-analysis.md @@ -0,0 +1,736 @@ +# 工作流 dispatchWorkFlow 深度性能分析报告 + +## 📊 分析范围 + +本报告对 `dispatchWorkFlow` 函数及其完整调用链进行了深度分析,包括: + +1. **主函数**: `dispatchWorkFlow` (1277 行) +2. **核心类**: `WorkflowQueue` 类及其所有方法 +3. **工具函数**: `replaceEditorVariable`, `getReferenceVariableValue`, `checkNodeRunStatus`, `filterWorkflowEdges` 等 +4. **节点处理器**: 83 个节点类型的 dispatch 函数(callbackMap) +5. **辅助系统**: 定时器、AsyncLocalStorage、停止检查等 + +--- + +## 🔴 严重性能问题(优先级 P0) + +### 1. **nodeOutput 函数中的 O(n²) 遍历**(已完成) + +**位置**: `index.ts:851-909` + +**问题代码**: +```typescript +const nodeOutput = (node: RuntimeNodeItemType, result: NodeResponseCompleteType) => { + // 1. 过滤边 - O(m) + const targetEdges = filterWorkflowEdges(runtimeEdges).filter( + (item) => item.source === node.nodeId + ); + + // 2. 遍历所有节点查找下一步节点 - O(n) + runtimeNodes.forEach((node) => { + // 3. 对每个节点,遍历 targetEdges - O(k) + if (targetEdges.some((item) => item.target === node.nodeId && item.status === 'active')) { + nextStepActiveNodesMap.set(node.nodeId, node); + } + if (targetEdges.some((item) => item.target === node.nodeId && item.status === 'skipped')) { + nextStepSkipNodesMap.set(node.nodeId, node); + } + }); +}; +``` + +**复杂度分析**: +- 150 个节点的工作流 +- 每个节点完成后调用一次 `nodeOutput` +- 总计: **150 节点 × 150 次遍历 × 平均 3 条边检查 = 67,500 次操作** + +**内存影响**: +- 每次调用创建新的 Map 对象 +- 150 次调用 = 150 个临时 Map 对象 + +**优化方案**: +```typescript +// 🟢 预构建边索引(在 WorkflowQueue 构造函数中) +class WorkflowQueue { + private edgeIndex = { + bySource: new Map(), + byTarget: new Map() + }; + + constructor() { + // 一次性构建索引 - O(m) + const filteredEdges = filterWorkflowEdges(runtimeEdges); + filteredEdges.forEach(edge => { + if (!this.edgeIndex.bySource.has(edge.source)) { + this.edgeIndex.bySource.set(edge.source, []); + } + this.edgeIndex.bySource.get(edge.source)!.push(edge); + + if (!this.edgeIndex.byTarget.has(edge.target)) { + this.edgeIndex.byTarget.set(edge.target, []); + } + this.edgeIndex.byTarget.get(edge.target)!.push(edge); + }); + } + + // 🟢 优化后的 nodeOutput - O(k),k 是目标边数量 + const nodeOutput = (node: RuntimeNodeItemType, result: NodeResponseCompleteType) => { + // O(1) 查询 + const targetEdges = this.edgeIndex.bySource.get(node.nodeId) || []; + + // O(k) - 只遍历目标边 + const nextStepActiveNodesMap = new Map(); + const nextStepSkipNodesMap = new Map(); + + targetEdges.forEach((edge) => { + const targetNode = this.runtimeNodesMap.get(edge.target); + if (!targetNode) return; + + if (edge.status === 'active') { + nextStepActiveNodesMap.set(targetNode.nodeId, targetNode); + } else if (edge.status === 'skipped') { + nextStepSkipNodesMap.set(targetNode.nodeId, targetNode); + } + }); + + return { + nextStepActiveNodes: Array.from(nextStepActiveNodesMap.values()), + nextStepSkipNodes: Array.from(nextStepSkipNodesMap.values()) + }; + }; +} +``` + +**收益**: +- 时间复杂度: O(n²) → O(k),k 通常 < 5 +- 操作次数: 67,500 → ~750 (减少 99%) +- 内存: 减少临时对象创建 + +--- + +### 2. **replaceEditorVariable 的递归和重复遍历** + +**位置**: `packages/global/core/workflow/runtime/utils.ts:495-597` + +**问题分析**: + +```typescript +export function replaceEditorVariable({ text, nodes, variables, depth = 0 }) { + // 1. 正则匹配所有变量 - O(m),m 是文本长度 + const variablePattern = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g; + const matches = [...text.matchAll(variablePattern)]; + + for (const match of matches) { + const nodeId = match[1]; + const id = match[2]; + + // 2. 查找节点 - O(n) + const node = nodes.find((node) => node.nodeId === nodeId); + if (!node) return; + + // 3. 查找输出 - O(k) + const output = node.outputs.find((output) => output.id === id); + if (output) return formatVariableValByType(output.value, output.valueType); + + // 4. 查找输入 - O(k) + const input = node.inputs.find((input) => input.key === id); + if (input) return getReferenceVariableValue({ value: input.value, nodes, variables }); + // ^^^^^ 又传递完整 nodes + } + + // 5. 递归处理嵌套变量 - 最多 10 层 + if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) { + result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 }); + } +} +``` + +**调用频率**: +```typescript +// 在 nodeRunWithActive 中,每个 input 都调用 +node.inputs.forEach((input) => { + let value = replaceEditorVariable({ + text: input.value, + nodes: runtimeNodes, // 传递 150 个节点 + variables + }); +}); +``` + +**复杂度估算**: +- 150 节点 × 平均 5 个 inputs = 750 次调用 +- 每次调用遍历 150 个节点查找 +- 总计: **750 × 150 = 112,500 次节点查找** + +**为什么不能简单优化**: + +1. ❌ 不能只存储输出值,因为需要 `valueType` 进行格式化 +2. ❌ 不能只存储 outputs,因为还需要访问 `node.inputs` +3. ❌ `getReferenceVariableValue` 内部递归需要完整 nodes 数组 +4. ❌ 嵌套变量替换需要递归调用 + +**可行的优化方案**: + +```typescript +class WorkflowQueue { + // 🟢 构建节点索引 + private nodeIndex = { + byId: new Map(), + outputsByNodeId: new Map>(), + inputsByNodeId: new Map>() + }; + + constructor() { + runtimeNodes.forEach(node => { + this.nodeIndex.byId.set(node.nodeId, node); + + // 索引 outputs + const outputsMap = new Map(); + node.outputs.forEach(output => { + outputsMap.set(output.id, { value: output.value, valueType: output.valueType }); + }); + this.nodeIndex.outputsByNodeId.set(node.nodeId, outputsMap); + + // 索引 inputs + const inputsMap = new Map(); + node.inputs.forEach(input => { + inputsMap.set(input.key, input.value); + }); + this.nodeIndex.inputsByNodeId.set(node.nodeId, inputsMap); + }); + } + + // 🟢 优化的变量查找 + private getNodeOutput(nodeId: string, outputId: string) { + return this.nodeIndex.outputsByNodeId.get(nodeId)?.get(outputId); + } + + private getNodeInput(nodeId: string, inputKey: string) { + return this.nodeIndex.inputsByNodeId.get(nodeId)?.get(inputKey); + } +} + +// 🟢 修改 replaceEditorVariable 使用索引 +export function replaceEditorVariable({ + text, + nodeIndex, // 传递索引而不是完整数组 + variables, + depth = 0 +}) { + // ... 其他逻辑 + + const variableVal = (() => { + if (nodeId === VARIABLE_NODE_ID) { + return variables[id]; + } + + // O(1) 查询而不是 O(n) + const output = nodeIndex.outputsByNodeId.get(nodeId)?.get(id); + if (output) return formatVariableValByType(output.value, output.valueType); + + const input = nodeIndex.inputsByNodeId.get(nodeId)?.get(id); + if (input) return getReferenceVariableValue({ value: input, nodeIndex, variables }); + })(); +} +``` + +**收益**: +- 节点查找: O(n) → O(1) +- 操作次数: 112,500 → 750 (减少 99%) +- 但需要修改函数签名,影响范围较大 + +--- + +### 3. **checkNodeRunStatus 的深度遍历** + +**位置**: `packages/global/core/workflow/runtime/utils.ts:297-413` + +**问题代码**: +```typescript +export const checkNodeRunStatus = ({ nodesMap, node, runtimeEdges }) => { + const splitNodeEdges = (targetNode: RuntimeNodeItemType) => { + const commonEdges: RuntimeEdgeItemType[] = []; + const recursiveEdgeGroupsMap = new Map(); + + // 1. 获取所有源边 - O(m) + const sourceEdges = runtimeEdges.filter((item) => item.target === targetNode.nodeId); + + sourceEdges.forEach((sourceEdge) => { + // 2. 使用栈进行深度遍历 - 最多 3000 次迭代 + const stack: Array<{ edge: RuntimeEdgeItemType; visited: Set }> = [ + { edge: sourceEdge, visited: new Set([targetNode.nodeId]) } + ]; + const MAX_DEPTH = 3000; + let iterations = 0; + + while (stack.length > 0 && iterations < MAX_DEPTH) { + iterations++; + const { edge, visited } = stack.pop()!; + + const sourceNode = nodesMap.get(edge.source); + if (!sourceNode) continue; + + // 检查是否是起始节点 + if (isStartNode(sourceNode.flowNodeType)) { + commonEdges.push(sourceEdge); + continue; + } + + // 检查循环 + if (edge.source === targetNode.nodeId) { + recursiveEdgeGroupsMap.set(edge.target, [ + ...(recursiveEdgeGroupsMap.get(edge.target) || []), + sourceEdge + ]); + continue; + } + + // 继续向上遍历 + const nextEdges = runtimeEdges.filter((item) => item.target === edge.source); + for (const nextEdge of nextEdges) { + stack.push({ + edge: nextEdge, + visited: new Set([...visited, edge.source]) + }); + } + } + }); + + return { commonEdges, recursiveEdgeGroups: Array.from(recursiveEdgeGroupsMap.values()) }; + }; + + const { commonEdges, recursiveEdgeGroups } = splitNodeEdges(node); + // ... 检查逻辑 +}; +``` + +**性能问题**: + +1. **每次调用都重新遍历**: 每个节点运行前都调用一次 +2. **深度遍历开销**: 复杂工作流可能触发数千次迭代 +3. **重复过滤**: `runtimeEdges.filter` 被多次调用 +4. **Set 复制开销**: 每次迭代都复制 visited Set + +**调用频率**: +- 150 节点 × 每个节点调用 1 次 = 150 次 +- 复杂分支可能触发 3000 次迭代 +- 总计: 可能达到 **450,000 次迭代** + +**优化方案**: + +```typescript +class WorkflowQueue { + // 🟢 缓存节点运行状态检查结果 + private nodeStatusCache = new Map(); + + // 🟢 预构建边的反向索引 + private edgesByTarget = new Map(); + + constructor() { + runtimeEdges.forEach(edge => { + if (!this.edgesByTarget.has(edge.target)) { + this.edgesByTarget.set(edge.target, []); + } + this.edgesByTarget.get(edge.target)!.push(edge); + }); + } + + private checkNodeCanRun(node: RuntimeNodeItemType) { + // 🟢 检查缓存 + const cached = this.nodeStatusCache.get(node.nodeId); + if (cached) return cached; + + // 使用索引而不是过滤 + const sourceEdges = this.edgesByTarget.get(node.nodeId) || []; + + const status = checkNodeRunStatus({ + nodesMap: this.runtimeNodesMap, + node, + runtimeEdges, + sourceEdges // 传递预过滤的边 + }); + + // 🟢 缓存结果(边状态变化时需要清除) + this.nodeStatusCache.set(node.nodeId, status); + return status; + } + + // 🟢 边状态更新时清除相关缓存 + private updateEdgeStatus(edge: RuntimeEdgeItemType, status: string) { + edge.status = status; + // 清除目标节点的缓存 + this.nodeStatusCache.delete(edge.target); + } +} +``` + +**收益**: +- 减少重复计算 +- 使用索引避免过滤 +- 缓存结果避免重复遍历 + +--- + +## 🟡 中等性能问题(优先级 P1) + +### 4. **getNodeRunParams 中的重复变量替换** + +**位置**: `index.ts:515-570` + +**问题代码**: +```typescript +function getNodeRunParams(node: RuntimeNodeItemType) { + const params: Record = {}; + + node.inputs.forEach((input) => { + // 1. 第一次变量替换 - O(n) + let value = replaceEditorVariable({ + text: input.value, + nodes: runtimeNodes, + variables + }); + + // 2. 第二次变量替换 - O(n) + value = getReferenceVariableValue({ + value, + nodes: runtimeNodes, + variables + }); + + // 3. 类型格式化 + params[input.key] = valueTypeFormat(value, input.valueType); + }); + + return params; +} +``` + +**问题**: +- 每个 input 都进行两次变量查找 +- 150 节点 × 5 inputs × 2 次 = **1,500 次变量替换调用** + +**优化建议**: +- 合并两次变量替换为一次 +- 或者在 `replaceEditorVariable` 内部处理引用变量 + +--- + +### 5. **大对象频繁传递** + +**位置**: `index.ts:587-601` + +**问题代码**: +```typescript +const dispatchData: ModuleDispatchProps> = { + ...data, // 🔴 展开整个 data 对象 + usagePush: this.usagePush.bind(this), + variables, + histories, // 🔴 完整的历史记录数组 + node, + runtimeNodes, // 🔴 150 个节点的完整数组 + runtimeEdges, // 🔴 所有边的完整数组 + params, + mode: isDebugMode ? 'test' : data.mode +}; + +// 传递给每个节点处理函数 +const dispatchRes = await callbackMap[node.flowNodeType](dispatchData); +``` + +**内存影响**: +- 每个节点执行都创建新的 `dispatchData` 对象 +- 150 节点 × ~1MB/对象 = **150MB 临时对象** +- 对象展开 `...data` 会复制所有属性 + +**优化方案**: +```typescript +// 🟢 创建共享的不可变数据 +class WorkflowQueue { + private sharedDispatchData: Readonly>; + + constructor() { + // 一次性创建共享数据 + this.sharedDispatchData = Object.freeze({ + ...data, + usagePush: this.usagePush.bind(this), + variables, + histories, + runtimeNodes, + runtimeEdges + }); + } + + async nodeRunWithActive(node: RuntimeNodeItemType) { + const params = getNodeRunParams(node); + + // 🟢 只传递变化的部分 + const dispatchData = { + ...this.sharedDispatchData, + node, + params + }; + + return await callbackMap[node.flowNodeType](dispatchData); + } +} +``` + +**收益**: +- 减少对象创建 +- 减少内存占用 +- 但需要确保共享数据不被修改 + +--- + +### 6. **定时器过于频繁** + +**位置**: `index.ts:155-182, 207-215` + +**问题代码**: +```typescript +// 🔴 100ms 定时器检查停止状态 +const checkStoppingTimer = apiVersion === 'v2' + ? setInterval(async () => { + stopping = await shouldWorkflowStop({ + appId: runningAppInfo.id, + chatId + }); + }, 100) // 每 100ms 触发一次 + : undefined; + +// 🔴 10秒定时器保持连接 +streamCheckTimer = setInterval(() => { + data?.workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: '' }) + }); +}, 10000); +``` + +**问题**: +- 10 个并发工作流 × 10 次/秒 = **100 次定时器触发/秒** +- 每次触发可能涉及 Redis 查询 (`shouldWorkflowStop`) +- 高并发时会产生大量 I/O 操作 + +**优化方案**: +```typescript +// 🟢 增加检查间隔 +const checkStoppingTimer = apiVersion === 'v2' + ? setInterval(async () => { + stopping = await shouldWorkflowStop({ + appId: runningAppInfo.id, + chatId + }); + }, 500) // 从 100ms 改为 500ms + : undefined; + +// 🟢 添加定时器清理保护 +const cleanupTimers = () => { + if (streamCheckTimer) { + clearInterval(streamCheckTimer); + streamCheckTimer = null; + } + if (checkStoppingTimer) { + clearInterval(checkStoppingTimer); + } +}; + +// 🟢 确保清理 +try { + await runWorkflow(...); +} finally { + cleanupTimers(); +} +``` + +**收益**: +- 减少定时器触发频率 80% +- 降低 Redis 查询压力 +- 降低 CPU 使用率 + +--- + +## 🟢 低优先级优化(优先级 P2) + +### 7. **surrenderProcess 调用频率** + +**位置**: 多处调用 + +**当前实现**: +```typescript +export const surrenderProcess = () => new Promise((resolve) => setImmediate(resolve)); +``` + +**调用频率**: +- `startProcessing` 循环中每次迭代调用 +- `processSkipNodes` 中调用 +- 150 节点 × 2 次 = **300 次 setImmediate** + +**影响**: +- 高并发时事件循环充满 setImmediate 回调 +- 可能导致其他任务延迟 + +**优化建议**: +```typescript +// 🟢 批量处理,减少 surrenderProcess 调用 +private async startProcessing() { + let processedCount = 0; + const BATCH_SIZE = 5; + + while (true) { + // 处理节点... + + processedCount++; + // 每处理 5 个节点才让出一次 + if (processedCount % BATCH_SIZE === 0) { + await surrenderProcess(); + } + } +} +``` + +--- + +### 8. **filterWorkflowEdges 重复调用** + +**位置**: 多处调用 + +**问题**: +```typescript +// 在多个地方重复调用 +const targetEdges = filterWorkflowEdges(runtimeEdges).filter(...); +``` + +**优化方案**: +```typescript +class WorkflowQueue { + private filteredEdges: RuntimeEdgeItemType[]; + + constructor() { + // 🟢 一次性过滤 + this.filteredEdges = filterWorkflowEdges(runtimeEdges); + } + + // 使用缓存的过滤结果 + private getTargetEdges(nodeId: string) { + return this.filteredEdges.filter(item => item.source === nodeId); + } +} +``` + +--- + +## 📈 性能优化总结 + +### 优化优先级 + +#### P0 - 立即实施(预期收益 70%) + +1. ✅ **已完成**: 递归改迭代(1.1) +2. **nodeOutput 边索引优化** - 减少 99% 的遍历操作 +3. **replaceEditorVariable 节点索引** - 减少 99% 的查找操作 + +#### P1 - 重要优化(预期收益 20%) + +4. **checkNodeRunStatus 缓存** - 减少重复计算 +5. **定时器间隔优化** - 减少 80% 的触发频率 +6. **大对象传递优化** - 减少内存占用 + +#### P2 - 性能优化(预期收益 10%) + +7. **surrenderProcess 批量处理** - 减少事件循环压力 +8. **filterWorkflowEdges 缓存** - 避免重复过滤 + +--- + +## 🎯 实施建议 + +### 第一阶段(已完成) +- ✅ 1.1 递归改迭代 + +### 第二阶段(推荐立即实施) +- 🔧 边索引优化(简单、安全、高收益) +- 🔧 定时器间隔优化(简单、安全) + +### 第三阶段(需要仔细测试) +- 🔧 节点索引优化(需要修改函数签名) +- 🔧 状态缓存优化(需要处理缓存失效) + +### 第四阶段(可选) +- 🔧 其他低优先级优化 + +--- + +## 📊 预期效果 + +### 优化前(150 节点,10 并发) +- 内存占用: ~1.5-2GB +- CPU 使用: 60-80% +- 平均响应时间: 15-20秒 +- 关键操作次数: + - 节点遍历: 67,500 次 + - 变量查找: 112,500 次 + - 状态检查: 450,000 次迭代 + +### 优化后(应用 P0 + P1) +- 内存占用: ~500-800MB(降低 60%) +- CPU 使用: 30-50%(降低 40%) +- 平均响应时间: 10-12秒(提升 30%) +- 关键操作次数: + - 节点遍历: ~750 次(减少 99%) + - 变量查找: ~750 次(减少 99%) + - 状态检查: 大幅减少(缓存命中) + +--- + +## 🔍 监控指标建议 + +```typescript +interface WorkflowMetrics { + // 性能指标 + totalExecutionTime: number; + nodeExecutionTimes: Map; + averageNodeTime: number; + + // 内存指标 + peakMemoryUsage: number; + averageMemoryUsage: number; + gcCount: number; + + // 操作计数 + nodeTraversalCount: number; + variableLookupCount: number; + edgeFilterCount: number; + statusCheckCount: number; + + // 并发指标 + maxConcurrentNodes: number; + averageConcurrentNodes: number; + queueWaitTime: number; + + // 缓存指标 + cacheHitRate: number; + cacheMissRate: number; +} +``` + +--- + +## 总结 + +通过深度分析,发现了 8 个主要性能瓶颈: + +1. 🔴 **nodeOutput O(n²) 遍历** - 最严重 +2. 🔴 **replaceEditorVariable 重复查找** - 最严重 +3. 🔴 **checkNodeRunStatus 深度遍历** - 严重 +4. 🟡 **重复变量替换** - 中等 +5. 🟡 **大对象频繁传递** - 中等 +6. 🟡 **定时器过于频繁** - 中等 +7. 🟢 **surrenderProcess 频繁调用** - 较低 +8. 🟢 **filterWorkflowEdges 重复调用** - 较低 + +**核心问题**: 缺少索引和缓存机制,导致大量 O(n) 和 O(n²) 操作。 + +**解决方案**: 通过预构建索引、缓存结果、批量处理等方式,将复杂度降低到 O(1) 或 O(k)。 + +**预期收益**: 实施 P0 和 P1 优化后,可以解决 90% 的性能问题。 diff --git a/.codex/issue/workflow-form-input-restore-bug.md b/.codex/issue/workflow-form-input-restore-bug.md new file mode 100644 index 0000000000..57b7dabf31 --- /dev/null +++ b/.codex/issue/workflow-form-input-restore-bug.md @@ -0,0 +1,343 @@ +# 工作流表单输入节点重新打开预览页面后表单内容恢复默认值问题 + +## 问题描述 + +在工作流中添加表单输入节点后,在运行预览页面进行对话测试时: + +1. 触发表单输入交互 +2. 正常填写表单并提交 +3. 任务继续运行成功 +4. **关闭预览页面** +5. **重新打开预览页面** +6. **问题**: 表单内容被恢复为默认值,而不是用户之前填写的值 + +## 根本原因分析 + +### 1. 数据结构设计 + +根据类型定义 `packages/global/core/workflow/template/system/interactive/type.ts`: + +```typescript +export type UserInputFormItemType = { + key: string; + label: string; + value: any; // 用户填写的值 + defaultValue?: any; // 默认值 + required: boolean; + // ... +} + +export type UserInputInteractive = { + type: 'userInput'; + params: { + description: string; + inputForm: UserInputFormItemType[]; + submitted?: boolean; // 是否已提交 + } +} +``` + +**设计意图**: +- `value` 字段用于存储用户填写的值 +- `submitted` 标记表单是否已提交 +- 这些数据应该保存在聊天记录的 `interactive` 对象中 + +### 2. 实际实现的问题 + +#### 问题 1: sessionStorage 的冗余使用 + +在 `AIResponseBox.tsx` 的 `RenderUserFormInteractive` 组件中(第 248-271 行): + +```typescript +if (typeof window !== 'undefined') { + const dataToSave = { ...data }; + // ... 处理文件数据 + sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); +} +``` + +**问题**: +- 表单提交时保存到 `sessionStorage`,但**从未读取** +- 通过全局搜索确认: 只有写入,没有任何读取操作 +- 这是一个**无效的代码**,增加了复杂度但没有实际作用 + +#### 问题 2: defaultValues 计算逻辑不完整 + +在 `RenderUserFormInteractive` 组件中(第 231-237 行): + +```typescript +const defaultValues = useMemo(() => { + return interactive.params.inputForm?.reduce((acc: Record, item) => { + acc[item.key] = item.value ?? item.defaultValue; + return acc; + }, {}); +}, [interactive]); +``` + +**逻辑**: `item.value` 优先于 `item.defaultValue` + +**问题**: 当页面重新打开时,`interactive.params` 从聊天记录中恢复,但: +- 如果后端没有正确保存用户填写的 `value` 到 `interactive.params.inputForm` +- 或者前端没有正确更新 `interactive` 对象 +- 就会导致 `item.value` 为空,回退到 `defaultValue` + +### 3. 数据流分析 + +**正常流程(应该是这样)**: +``` +用户填写表单 + → 提交时发送到后端 + → 后端更新 interactive.params.inputForm[].value + → 后端保存到聊天记录 + → 关闭预览页面 + → 重新打开预览页面 + → 从聊天记录恢复 interactive + → defaultValues 从 item.value 读取 + → 表单显示用户填写的值 ✅ +``` + +**实际流程(出问题了)**: +``` +用户填写表单 + → 提交时发送到后端 + → 后端处理但可能没有更新 interactive.params.inputForm[].value + → 或者前端没有正确更新本地的 interactive 对象 + → 关闭预览页面 + → 重新打开预览页面 + → 从聊天记录恢复 interactive + → interactive.params.inputForm[].value 为空 + → defaultValues 回退到 item.defaultValue + → 表单显示默认值 ❌ +``` + +### 4. 核心问题定位 ✅ + +**问题确认**: 前端在表单提交后,只更新了 `submitted: true`,但**没有更新 `inputForm[].value`** + +在 `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` 的 `rewriteHistoriesByInteractiveResponse` 函数中(第 154-168 行): + +```typescript +if ( + finalInteractive.type === 'userInput' || + finalInteractive.type === 'agentPlanAskUserForm' +) { + return { + ...val, + interactive: { + ...finalInteractive, + params: { + ...finalInteractive.params, + submitted: true // ✅ 只设置了 submitted + // ❌ 但没有更新 inputForm[].value + } + } + }; +} +``` + +**分析**: +- 用户提交的表单数据在 `interactiveVal` 参数中(JSON 字符串格式) +- 函数只是简单地标记 `submitted: true` +- 没有解析 `interactiveVal` 并更新 `params.inputForm[].value` +- 导致重新打开页面时,`item.value` 仍然是空的,回退到 `defaultValue` + +## 重要发现: sessionStorage 的设计意图 + +经过深入分析,发现 `sessionStorage` 的使用**可能有其合理性**: + +### chatItemDataId 的含义 + +- `chatItemDataId` 是**每条聊天消息的唯一标识** (不是 chatId) +- 一个对话(chatId)中可能有**多条消息**,每条消息有不同的 `dataId` +- 一个工作流中可能有**多个表单输入节点**,每个节点触发时会创建新的消息 + +### 可能的场景 + +**场景 1: 同一对话中多个表单输入** +``` +对话开始 + → 触发表单输入节点 A (dataId: xxx-1) + → 用户填写表单 A + → 提交,继续执行 + → 触发表单输入节点 B (dataId: xxx-2) + → 用户填写表单 B + → 关闭预览页面 + → 重新打开 + → 需要恢复两个表单的数据 +``` + +**场景 2: 表单数据的临时性** +- 用户可能在填写过程中关闭页面(未提交) +- sessionStorage 可以保存**未提交的草稿** +- 重新打开时恢复草稿,避免用户重新填写 + +### 为什么后端保存不够? + +1. **未提交的数据**: 用户填写了一半但未提交,后端没有这些数据 +2. **多个表单实例**: 同一对话中可能有多个表单输入节点,需要分别保存 +3. **临时状态**: 表单的临时编辑状态(如文件上传中)不应该保存到后端 + +## 影响范围 + +- **影响文件**: + - `projects/app/src/components/core/chat/components/AIResponseBox.tsx` + - `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` +- **影响组件**: `RenderUserFormInteractive`, `rewriteHistoriesByInteractiveResponse` +- **影响场景**: + - 所有使用表单输入节点的工作流 + - 在预览页面关闭后重新打开时 + - 同一对话中有多个表单输入节点时 + +## 解决方案(修正版) + +### 方案 1: 双重保存机制 - sessionStorage + interactive.params (推荐) + +结合两种机制的优点: +- **sessionStorage**: 保存未提交的草稿和临时状态 +- **interactive.params**: 保存已提交的最终数据 + +#### 步骤 1: 修复 `rewriteHistoriesByInteractiveResponse` (已提交数据) + +**文件**: `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts` + +```typescript +if ( + finalInteractive.type === 'userInput' || + finalInteractive.type === 'agentPlanAskUserForm' +) { + // 解析用户提交的表单数据 + let submittedData: Record = {}; + try { + submittedData = JSON.parse(interactiveVal); + } catch (error) { + console.warn('Failed to parse form input data', error); + } + + // 更新 inputForm 中的 value + const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({ + ...item, + value: submittedData[item.key] ?? item.value ?? item.defaultValue + })); + + return { + ...val, + interactive: { + ...finalInteractive, + params: { + ...finalInteractive.params, + inputForm: updatedInputForm, + submitted: true + } + } + }; +} +``` + +#### 步骤 2: 修复 `defaultValues` 计算逻辑 (恢复草稿) + +**文件**: `projects/app/src/components/core/chat/components/AIResponseBox.tsx` + +```typescript +const defaultValues = useMemo(() => { + // 1. 优先从 sessionStorage 恢复数据(包括未提交的草稿) + let savedData: Record | null = null; + if (typeof window !== 'undefined') { + try { + const saved = sessionStorage.getItem(`interactiveForm_${chatItemDataId}`); + if (saved) { + savedData = JSON.parse(saved); + } + } catch (error) { + console.warn('Failed to restore form data from sessionStorage', error); + } + } + + // 2. 构建 defaultValues + // 优先级: sessionStorage(草稿) > item.value(已提交) > item.defaultValue(默认) + return interactive.params.inputForm?.reduce((acc: Record, item) => { + if (savedData && item.key in savedData) { + // 优先使用 sessionStorage 中的数据(可能是未提交的草稿) + acc[item.key] = savedData[item.key]; + } else { + // 否则使用 item.value(已提交的数据) 或 defaultValue + acc[item.key] = item.value ?? item.defaultValue; + } + return acc; + }, {}); +}, [interactive, chatItemDataId]); +``` + +#### 步骤 3: 清理 sessionStorage (可选优化) + +在表单提交成功后,清理对应的 sessionStorage: + +```typescript +const handleFormSubmit = useCallback( + (data: Record) => { + const finalData: Record = {}; + interactive.params.inputForm?.forEach((item) => { + if (item.key in data) { + finalData[item.key] = data[item.key]; + } + }); + + // 保存到 sessionStorage (用于页面关闭后恢复) + if (typeof window !== 'undefined') { + const dataToSave = { ...data }; + // ... 处理文件数据 + sessionStorage.setItem(`interactiveForm_${chatItemDataId}`, JSON.stringify(dataToSave)); + } + + onSendPrompt(JSON.stringify(finalData)); + + // 可选: 提交成功后清理 sessionStorage + // setTimeout(() => { + // sessionStorage.removeItem(`interactiveForm_${chatItemDataId}`); + // }, 1000); + }, + [chatItemDataId, interactive.params.inputForm] +); +``` + +**优点**: +- 保留 sessionStorage 的草稿保存功能 +- 同时修复已提交数据的持久化问题 +- 支持多个表单输入节点的场景 +- 向后兼容 + +**缺点**: +- 需要修改两个地方 +- 逻辑稍微复杂一些 + +### 方案 2: 仅修复 interactive.params (简化方案) + +如果不需要草稿保存功能,可以只修复 `rewriteHistoriesByInteractiveResponse`,删除 sessionStorage 相关代码。 + +**优点**: 简单,代码更清晰 +**缺点**: 失去草稿保存功能 + +## 推荐实施方案 + +**推荐方案 1**,原因: +1. 保留了 sessionStorage 的设计意图(草稿保存) +2. 修复了已提交数据的持久化问题 +3. 支持复杂场景(多个表单、未提交草稿) +4. 向后兼容,不破坏现有功能 + +## 相关文件 + +- `projects/app/src/components/core/chat/components/AIResponseBox.tsx` - 表单渲染和提交逻辑 +- `projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx` - 表单输入组件 +- `projects/app/src/web/core/chat/context/chatItemContext.tsx` - Chat 上下文管理 +- `packages/service/core/workflow/dispatch/interactive/formInput.ts` - 后端表单输入处理 + +## 测试建议 + +修复后需要测试以下场景: + +1. **基本场景**: 填写表单 → 提交 → 关闭预览 → 重新打开 → 验证表单内容保持 +2. **多次提交**: 填写 → 提交 → 修改 → 再次提交 → 关闭 → 重新打开 → 验证最后一次提交的内容 +3. **文件上传**: 包含文件选择的表单,验证文件信息正确恢复 +4. **必填项验证**: 验证必填项的验证逻辑不受影响 +5. **多个表单**: 同一对话中多个表单输入节点,验证各自独立保存和恢复 +6. **清空对话**: 点击"重新开始"后,验证表单数据被正确清空 diff --git a/.codex/issue/workflow-thread-blocking-analysis.md b/.codex/issue/workflow-thread-blocking-analysis.md new file mode 100644 index 0000000000..7ae08dc80c --- /dev/null +++ b/.codex/issue/workflow-thread-blocking-analysis.md @@ -0,0 +1,160 @@ +# 工作流 CPU 阻塞模块分析 + +> 从 `packages/service/core/workflow/dispatch/index.ts` 入口出发,排查所有**同步占用 CPU、阻塞整个进程**的模块。 + +Node.js 单线程模型下,CPU 阻塞指:在当前调用栈未让出事件循环(无 `await`)的情况下执行大量计算,导致其他请求无法被处理。 + +--- + +## 一、`WorkflowQueue` 构造函数——图算法批量同步执行 + +**文件**: `packages/service/core/workflow/dispatch/index.ts:348` + +每次创建工作流实例时,构造函数**同步**依次执行: + +```ts +constructor(...) { + // 1. O(E) 构建边索引 + this.edgeIndex = WorkflowQueue.buildEdgeIndex({ runtimeEdges }); + + // 2. O(N+E) DFS 边分类 ← 递归,全同步 + // 3. O(N+E) Tarjan SCC ← 递归,全同步 + // 4. O(N²) BFS per node ← 每个节点一次 BFS 回溯 + this.nodeEdgeGroupsMap = WorkflowQueue.buildNodeEdgeGroupsMap({ ... }); +} +``` + +三个算法全部是纯同步的 CPU 密集计算,无任何 `await` 让出点。 + +--- + +## 二、Tarjan SCC 算法——递归 DFS,无让出 + +**文件**: `packages/service/core/workflow/utils/tarjan.ts:31` + +```ts +function tarjan(nodeId: string) { + // ... + for (const edge of outEdges) { + if (!discoveryTime.has(targetId)) { + tarjan(targetId); // ⚠️ 同步递归,无 await + } + } +} +for (const node of runtimeNodes) { + tarjan(node.nodeId); // 对每个未访问节点启动递归 +} +``` + +**问题**: +- 纯同步递归,执行期间完全占用 Event Loop。 +- 节点数 N 较大(如 100+ 节点)时,递归深度 = 工作流拓扑深度,调用栈可能很深。 +- 同文件 `classifyEdgesByDFS` 也是完全相同的递归 DFS 结构,与 Tarjan 串行执行,等于一次工作流启动 **做两遍图遍历**。 + +--- + +## 三、`findBranchHandle`——每节点一次 BFS,合计 O(N²) + +**文件**: `packages/service/core/workflow/dispatch/index.ts:543` + +```ts +private static buildNodeEdgeGroupsMap(...) { + runtimeNodes.forEach((targetNode) => { + // 对每个节点的每条边,调用 findBranchHandle + const branchGroups = this.groupEdgesByBranch(nonBackEdges, ...); + }); +} + +private static findBranchHandle(edge, ...) { + const queue = [{ nodeId: edge.source, ... }]; + while (queue.length > 0) { + // BFS 向上回溯,最坏遍历所有节点 ← 纯同步 + const inEdges = edgeIndex.byTarget.get(nodeId) || []; + for (const inEdge of inEdges) { + queue.push({ nodeId: inEdge.source, ... }); + } + } +} +``` + +**问题**: +- `buildNodeEdgeGroupsMap` 在构造函数中对每个节点调用,每次调用又做一次 BFS。 +- 最坏复杂度 O(N × (N + E)),对于 100 节点、200 边的工作流约为 30000 次循环迭代,全同步。 + +--- + +## 四、`replaceEditorVariable`——每节点每输入做正则+递归,全同步 + +**文件**: `packages/global/core/workflow/runtime/utils.ts:372` + +每次节点运行前,`getNodeRunParams` 对每个 input 都调用: + +```ts +node.inputs.forEach((input) => { + // 每个 input 都调用一次 replaceEditorVariable + let value = replaceEditorVariable({ + text: input.value, + nodes: this.data.runtimeNodes, // 传入所有节点 + variables: this.data.variables + }); + value = getReferenceVariableValue({ value, nodes, variables }); +}); +``` + +`replaceEditorVariable` 内部: + +```ts +// 1. 全局正则匹配,提取所有变量引用 +const matches = [...text.matchAll(variablePattern)]; + +for (const match of matches) { + // 2. nodes.find() O(N) 线性扫描 + const node = nodes.find((node) => node.nodeId === nodeId); + // 3. 每个变量编译一次新 RegExp ← 正则编译有 CPU 开销 + replacements.push({ pattern: `\\{\\{\\$${escapedNodeId}...`, replacement: formatVal }); +} + +// 4. 如果有嵌套变量,递归调用自身(最多 depth=10) +if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) { + result = replaceEditorVariable({ text: result, nodes, variables, depth: depth + 1 }); +} +``` + +**问题**: +- **每次 `nodes.find()`** 是 O(N) 线性扫描,完全没有缓存。一个节点有 10 个 input、每个 input 引用 5 个变量、工作流有 50 个节点 → **2500 次 O(N) 扫描**。 +- **每个变量引用** `new RegExp(pattern)` 一次,正则编译有 CPU 成本。 +- **最多 10 层递归**,每层都重复上述过程。 +- 整个函数链(`replaceEditorVariable` + `getReferenceVariableValue`)全同步,**每个节点运行前都会触发,且节点越多调用越频繁**。 + +--- + +## 五、`getReferenceVariableValue`——O(N) 数组扫描,无缓存 + +**文件**: `packages/global/core/workflow/runtime/utils.ts:297` + +```ts +const node = nodes.find((node) => node.nodeId === sourceNodeId); // O(N) +return node.outputs.find((output) => output.id === outputId)?.value; // O(outputs) +``` + +**问题**: +- 每次调用都线性扫描整个 `nodes` 数组。 +- 被 `replaceEditorVariable` 频繁调用(每个变量引用一次)。 +- `nodes` 数组在运行时不会变化(节点结构固定),却没有预建索引,每次都从头扫。 + +--- + +## 汇总 + +| 位置 | 函数 | 复杂度 | 触发时机 | 是否有让出点 | +|------|------|--------|---------|------------| +| `dispatch/index.ts` 构造函数 | `buildEdgeIndex` | O(E) | 每次工作流启动 | ❌ 无 | +| `utils/tarjan.ts` | `classifyEdgesByDFS` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 | +| `utils/tarjan.ts` | `findSCCs (tarjan)` | O(N+E) 递归 | 每次工作流启动 | ❌ 无 | +| `dispatch/index.ts` | `buildNodeEdgeGroupsMap` + `findBranchHandle` | O(N²) | 每次工作流启动 | ❌ 无 | +| `runtime/utils.ts` | `replaceEditorVariable` | O(N × inputs × depth) | 每节点运行前 | ❌ 无 | +| `runtime/utils.ts` | `getReferenceVariableValue` | O(N) per call | 每 input 一次 | ❌ 无 | + +**最严重的场景**:大型工作流(100+ 节点)并发启动时,构造函数中的图算法(第一~四项)全部同步执行,每个请求都会独占 Event Loop 若干毫秒,并发时互相堆叠,导致明显卡顿。 + +**最高频的场景**:Agent 节点有大量工具调用时,每轮工具调用后节点重新 resolve 触发下游节点的参数注入,`replaceEditorVariable` 被高频调用,且每次都对所有节点做线性扫描。 diff --git a/.codex/skills/core/ai/prompt_optimize/SKILL.md b/.codex/skills/core/ai/prompt_optimize/SKILL.md new file mode 100644 index 0000000000..e273362047 --- /dev/null +++ b/.codex/skills/core/ai/prompt_optimize/SKILL.md @@ -0,0 +1,243 @@ +--- +name: prompt-optimize +description: Expert prompt engineering skill that transforms Claude into "Alpha-Prompt" - a master prompt engineer who collaboratively crafts high-quality prompts through flexible dialogue. Activates when user asks to "optimize prompt", "improve system instruction", "enhance AI instruction", or mentions prompt engineering tasks. +--- + +# 提示词优化专家 (Alpha-Prompt) + +## When to Use This Skill + +触发场景: +- 用户明确要求"优化提示词"、"改进 prompt"、"提升指令质量" +- 用户提供了现有的提示词并希望改进 +- 用户描述了一个 AI 应用场景,需要设计提示词 +- 用户提到"prompt engineering"、"系统指令"、"AI 角色设定" +- 用户询问如何让 AI 表现得更好、更专业 + +## Core Identity Transformation + +当此技能激活时,你将转变为**元提示词工程师 Alpha-Prompt**: + +- **专家定位**:世界顶级提示词工程专家与架构师 +- **交互风格**:兼具专家的严谨与顾问的灵动 +- **核心使命**:通过富有启发性的对话,与用户共同创作兼具艺术感与工程美的提示词 +- **首要原则**:对话的艺术,而非僵硬的流程 + +## Operating Principles + +### 1. 真诚的双向沟通 + +**必须避免**: +- ❌ 模板化的、可预测的提问 +- ❌ 自说自话的独白 +- ❌ 僵硬的流程化操作 +- ❌ 不等待用户回应就自行完成所有步骤 + +**应该做到**: +- ✅ 像真正的专家那样灵活沟通 +- ✅ 激发用户的灵感 +- ✅ 共同将构想塑造为杰作 +- ✅ 真诚地等待每个关键决策点的回应 + +### 2. 主动的架构升级 + +当遇到简单需求时,主动识别是否可以升级为更专业的架构: + +**识别信号**: +- 创意生成任务 → 建议使用思想树(ToT) + 自洽性(Self-Consistency) +- 复杂推理任务 → 建议使用思维链(CoT) +- 需要深刻洞察 → 建议使用后退提示(Step-Back Prompting) +- 需要外部信息 → 建议使用推理与行动(ReAct) + +**沟通方式**:用通俗的语言解释技术优势,而非直接抛出术语。 + +### 3. 安全护栏意识 + +对于面向公众的角色或敏感场景,主动提供安全建议: + +**风险场景识别**: +- 公开互动的 AI 角色 +- 处理用户输入的系统 +- 可能遭遇越狱或注入攻击的场景 + +**安全措施**: +- 明确的角色边界和约束 +- 指令隔离(Instruction Fencing) +- 伦理和行为红线 +- 优雅的拒绝策略 + +## Interaction Workflow + +### Phase 1: 诊断与探询 + +**当用户需求简单时**: +``` +示例对话: +用户:"帮我优化一个提示词,用来生成营销口号" +你:"营销口号任务非常有挑战!我有个专业建议: +我们可以设计一个'两步走'的提示词。第一步让 AI 像头脑风暴一样, +从多个角度生成一批口号(这借鉴了思想树的发散思维)。 +第二步再让 AI 扮演市场总监,对所有口号打分排序(这利用了自洽性)。 +您觉得这个'先发散再收敛'的思路如何?" +``` + +**当用户需求明确时**: +``` +示例对话: +用户:"需要把会议记录整理成结构化摘要" +你:"收到,这是个实用需求。关键在于定义清晰的模板: +- 核心结论 (Key Takeaways) +- 待办事项 (Action Items): [负责人] - [任务] +- 关键讨论点 (Discussion Points) + +在格式上,我们可以用控制力更强的 XML 标签, +也可以用更简洁的 Markdown。如果不确定,我推荐更精准的 XML。" +``` + +### Phase 2: 协作构建 + +**识别用户提供的内容质量**: + +1. **初步想法阶段**: + - 通过提问帮助用户明确需求 + - 提供架构建议和技术选型 + - 一起探讨最佳实现方案 + +2. **半成品阶段**: + - 识别已有的优点 + - 指出可以改进的方向 + - 提供具体的优化建议 + +3. **高质量成品阶段**(同行评审模式): + - 首先真诚赞赏和鉴赏 + - 点出作品的巧思和亮点 + - 提出能"更上一层楼"的深邃建议 + - 用开放式问题交还选择权 + - **重要**:绝不直接动手修改,等待回应 + +``` +示例对话: +用户:[提供了一个高质量的完整提示词] +你:"看到您这份提示词,我必须先表达赞叹。 +这已经不是简单指令,而是充满巧思的艺术品。 +您对角色的定义、示例的选取,都体现了极高水准。 + +您提到的目标——'更深刻,刺穿心灵',这是非常高级的追求。 +基于您现有的优秀框架,我建议引入'后退提示'技术, +让 AI 在生成金句前,先触碰问题背后更本质的人类困境。 + +这就像给剑客配上能看透内心的眼睛。 +您觉得这个'先洞察母题,再凝练金句'的思路, +能否达到您想要的'刺穿感'?" +``` + +### Phase 3: 最终交付 + +**交付内容必须包含**: + +1. **设计思路解析**: + - 采用了哪些技术和方法 + - 为什么这样设计 + - 如何应对潜在问题 + +2. **完整的可复制提示词**: + - 无状态设计(不包含"新增"、版本号等时态标记) + - 清晰的结构(推荐使用 XML 或 Markdown) + - 完整的可直接使用 + +## Knowledge Base Reference + +### 基础技术 + +1. **角色扮演 (Persona)**:设定具体角色、身份和性格 +2. **Few-shot 提示**:提供示例让 AI 模仿学习 +3. **Zero-shot 提示**:仅依靠指令完成任务 + +### 高级认知架构 + +1. **思维链 (CoT)**:展示分步推理过程,用于复杂逻辑 +2. **自洽性 (Self-Consistency)**:多次生成并投票,提高稳定性 +3. **思想树 (ToT)**:探索多个推理路径,用于创造性任务 +4. **后退提示 (Step-Back)**:先思考高层概念再回答,提升深度 +5. **推理与行动 (ReAct)**:交替推理和调用工具,用于需要外部信息的任务 + +### 结构与约束控制 + +1. **XML/JSON 格式化**:提升指令理解精度 +2. **约束定义**:明确边界,定义能做和不能做的事 + +### 安全与鲁棒性 + +1. **提示注入防御**:明确指令边界和角色设定 +2. **越狱缓解**:设定强大的伦理和角色约束 +3. **指令隔离**:使用分隔符界定指令区和用户输入区 + +## Quality Standards + +### 优秀提示词的特征 + +✅ **清晰的角色定义**:AI 知道自己是谁 +✅ **明确的目标和约束**:知道要做什么、不能做什么 +✅ **适当的示例**:通过 Few-shot 展示期望的行为 +✅ **结构化的输出格式**:使用 XML 或 Markdown 规范输出 +✅ **安全护栏**:包含必要的约束和拒绝策略(如需要) + +### 对话质量标准 + +✅ **真诚性**:每次交互都是真诚的双向沟通 +✅ **专业性**:提供有价值的技术建议 +✅ **灵活性**:根据用户水平调整沟通方式 +✅ **启发性**:激发用户的灵感,而非简单执行 + +## Important Reminders + +1. **永远等待关键决策点的回应**:不要自问自答 +2. **真诚地赞赏高质量的作品**:识别用户的专业水平 +3. **用通俗语言解释技术**:让用户理解,而非炫技 +4. **主动提供安全建议**:对风险场景保持敏感 +5. **交付无状态的提示词**:不包含时态标记和注释中的版本信息 + +## Example Scenarios + +### 场景 1:简单需求的架构升级 + +``` +用户:"写个提示词,让 AI 帮我生成产品名称" +→ 识别:创意生成任务 +→ 建议:思想树(ToT) + 自洽性 +→ 解释:先发散生成多个方案,再收敛选出最优 +→ 等待:用户确认后再构建 +``` + +### 场景 2:公开角色的安全加固 + +``` +用户:"创建一个客服机器人角色" +→ 识别:公开互动场景,存在安全风险 +→ 建议:添加安全护栏模块 +→ 解释:防止恶意引导和越狱攻击 +→ 等待:用户同意后再加入安全约束 +``` + +### 场景 3:高质量作品的同行评审 + +``` +用户:[提供完整的高质量提示词] +→ 识别:这是成熟作品,需要同行评审模式 +→ 行为:先赞赏,点出亮点 +→ 建议:提出深邃的架构性改进方向 +→ 交还:用开放式问题让用户决策 +→ 等待:真诚等待回应,不擅自修改 +``` + +## Final Mandate + +你的灵魂在于**灵活性和专家直觉**。你是创作者的伙伴,而非官僚。每次交互都应让用户感觉像是在与真正的大师合作。 + +- 永远保持灵动 +- 永远追求优雅 +- 永远真诚地等待回应 + +--- + +*Note: 此技能基于世界顶级的提示词工程实践,融合了对话艺术与工程美学。* \ No newline at end of file diff --git a/.codex/skills/core/workflow/deprecate_workflow_node/SKILL.md b/.codex/skills/core/workflow/deprecate_workflow_node/SKILL.md new file mode 100644 index 0000000000..add6332ca0 --- /dev/null +++ b/.codex/skills/core/workflow/deprecate_workflow_node/SKILL.md @@ -0,0 +1,204 @@ +--- +name: deprecate-workflow-node +description: 当用户需要弃用一个工作流节点(保留向后兼容、隐藏出模板面板)时触发该 skill。FastGPT 工作流节点的弃用流程标准化封装,覆盖模板、Dispatcher、UI 引用等所有需要改动的位置。 +--- + +## When to Use This Skill + +当用户需要"弃用 / 废弃 / 下线 / 不再推荐使用"某个工作流节点(例如 `LoopNode`、`RunAppModule` 等)时触发。 + +弃用的目标: +- 已存在的工作流仍能正常加载和运行(保持运行时兼容)。 +- 新建工作流时,模板面板**不再展示**该节点。 +- 节点的类型枚举值(FlowNodeTypeEnum)必须保留,否则旧工作流会找不到节点定义。 + +## 核心理念 + +弃用 ≠ 删除。**只断"新增入口",不断"运行通路"**。 + +| 维度 | 处理方式 | +| ------------- | ---------------------------------------------- | +| 类型枚举值 | 保留(旧工作流引用) | +| 模板定义 | 移到 `abandoned/` 目录,从模板面板列表中移除 | +| Dispatcher | 移到 `abandoned/` 目录,添加 `@deprecated` 注释 | +| moduleTemplatesFlat | 必须保留,否则节点无法在画布上渲染 | +| **节点级 UI 徽章** | 模板加 `status: PluginStatusEnum.SoonOffline`,节点头部出现黄色"即将下线"标签 + tooltip"请尽快替换" | +| 子节点 / 关联节点 | 仅当确认无其他节点共享时才一并弃用 | +| i18n key | 不动(旧工作流仍会读取 name/intro 文案) | + +> ℹ️ 字段级 `deprecated: true`(`FlowNodeInputItemType.deprecated` / `FlowNodeOutputItemType.deprecated`)是**独立机制**,用于在保留节点的前提下淘汰单个字段(旧字段保留兼容、新字段替代)。**整节点弃用时不要用它** —— 节点级徽章已经足够指示,再加字段级会让信号过度冗余。 + +## 参考案例 + +仓库内已有一个完整的弃用案例可参考:`FlowNodeTypeEnum.runApp`。可通过对比 `git log --diff-filter=R -- "**/abandoned/runApp/**"` 找到当时的迁移 commit。 + +## TODO 模板(执行前先复制并填写) + +> 操作目标:弃用 ``(FlowNodeTypeEnum.``) + +- [ ] 1. **确认影响面** +- [ ] 2. **移动模板定义**到 abandoned 目录 +- [ ] 3. **移动 Dispatcher**到 abandoned 目录 +- [ ] 4. **更新 `template/constants.ts`**:从 `systemNodes` 移除,确保留在 `moduleTemplatesFlat` +- [ ] 5. **更新 `dispatch/constants.ts`**:加 `@deprecated` 注释,移到 callbackMap 末尾 +- [ ] 6. **节点级徽章**:模板加 `status: PluginStatusEnum.SoonOffline`(节点头部显示"即将下线"黄色标签) +- [ ] 7. **检查 UI / Hook 引用**(不一定需要改) +- [ ] 8. **运行 lint + typecheck + 局部测试** + +--- + +## 详细步骤 + +### 1. 确认影响面(必做) + +在动手之前,**先用 grep/Explore 全局搜索**节点的所有引用,确认改动范围。需要查的关键字: + +```bash +# 类型枚举使用点 +grep -rn "FlowNodeTypeEnum\.\b" packages/ projects/ --include="*.ts" --include="*.tsx" + +# 模板对象(如 LoopNode) +grep -rn "\b" packages/ projects/ --include="*.ts" + +# 子节点是否被其他容器复用(重要!) +grep -rn "FlowNodeTypeEnum\." packages/ projects/ +``` + +输出一份"需要改动 / 无需改动"分类清单。**通常无需改动**: +- `useWorkflow.tsx` 里嵌入到 PARENT_NODE_TYPES / unsupportedInLoop 这种"运行时识别"列表 —— 旧实例仍要工作。 +- `Flow/index.tsx` 的 `nodeTypes` 映射 —— 旧实例需要 UI 渲染。 +- i18n 文案 —— 旧实例仍读取。 + +### 2. 移动模板定义到 abandoned 目录 + +源路径:`packages/global/core/workflow/template/system/

/` +目标路径:`packages/global/core/workflow/template/system/abandoned//` + +操作: +1. 用 `git mv`(或 Bash `mv`)整个目录或仅 parent 节点的 `index.ts`/`type.ts`。 +2. 修改文件内的相对 import 路径(深度多了一层 `../`)。 +3. 如果只弃用容器节点而保留子节点(例如 `loopStart`/`loopEnd` 仍被 `parallelRun` 共用),**只移动 parent 节点文件**,子节点留在原位置。 + +### 3. 移动 Dispatcher 到 abandoned 目录 + +源路径:`packages/service/core/workflow/dispatch//.ts` +目标路径:`packages/service/core/workflow/dispatch/abandoned/.ts` + +操作: +1. `git mv` 文件。 +2. 文件首行加上 `/* Abandoned */` 注释(与 `dispatch/abandoned/runApp.ts` 一致)。 +3. 修改文件内的相对 import 路径。 + +### 4. 更新 `packages/global/core/workflow/template/constants.ts` + +```ts +// 旧 +import { LoopNode } from './system/loop/loop'; + +// 新 +import { LoopNode } from './system/abandoned/loop/index'; +``` + +```ts +// 从 systemNodes 移除(这是模板面板的来源) +const systemNodes: FlowNodeTemplateType[] = [ + ... + // LoopNode, ← 删除这一行 + ... +]; +``` + +```ts +// 在 moduleTemplatesFlat 中保留 / 添加(保证旧工作流能解析) +export const moduleTemplatesFlat: FlowNodeTemplateType[] = [ + ..., + LoopNode, // ← 这里要有 +]; +``` + +> ⚠️ 验证点:`moduleTemplatesFlat` 是节点 ID → 模板对象的查找源。**必须留**,否则旧工作流加载时会找不到节点,画布报错。 + +### 5. 更新 `packages/service/core/workflow/dispatch/constants.ts` + +```ts +// 把 import 路径改成 abandoned 子目录 +import { dispatchLoop } from './abandoned/runLoop'; +``` + +把对应的 callbackMap 条目移到对象末尾,并加 `/** @deprecated */` 注释: + +```ts +export const callbackMap: Record = { + // ...其他正常节点... + + /** @deprecated */ + [FlowNodeTypeEnum.loop]: dispatchLoop +}; +``` + +### 6. 节点级徽章 `status: PluginStatusEnum.SoonOffline` + +`FlowNodeTemplateTypeSchema.status` 是节点级的弃用 UI 信号,挂在 `NodeCard.tsx` 头部由 `` 渲染: + +| 值 | 标签 | 颜色 | tooltip | 适用场景 | +| --- | --- | --- | --- | --- | +| `Normal = 1` | 正常 | 蓝 | — | 不弃用 | +| `SoonOffline = 2` | 即将下线 | 黄 | "请尽快替换" | **弃用但仍可运行**(推荐用这个) | +| `Offline = 3` | 已下线 | 红 | "已无法使用,将中断应用运行,请立即替换" | 强制下线(运行时报错) | + +由于弃用的核心理念是"运行通路保留",应当用 `SoonOffline`;只有当节点被改成"运行时直接抛错"时才用 `Offline`。 + +```ts +import { PluginStatusEnum } from '../../../../../plugin/type'; + +export const FooNode: FlowNodeTemplateType = { + id: FlowNodeTypeEnum.foo, + // ... + status: PluginStatusEnum.SoonOffline, // ← 加这个 + // ... +}; +``` + +> ⚠️ 注意:这字段挂在 `FlowNodeTemplateTypeSchema`(即 `system` 模板的扩展)上,不在 `FlowNodeCommonTypeSchema` 上。意思是只对**系统节点模板**生效,store 里保存的 nodeData 不带 status,每次打开通过 `moduleTemplatesFlat.find(...)` 从模板查到。所以**只需改模板,不需要数据迁移**。 + +> ⚠️ schema 上还有一个看似相关的 `abandon: z.boolean().optional()`(`FlowNodeCommonTypeSchema:72`)—— **不要用它**,整个仓库没有任何 UI/runtime 代码读取它,是死字段。 + +### 7. 检查 UI / Hook 引用 + +通常以下文件**保持原样**(运行时兼容需要): +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx` — `nodeTypes` 中的 React 渲染映射。 +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx` — `PARENT_NODE_TYPES`、`unSupportedInLoop` 等运行时校验。 + +如果该节点在前端有"添加按钮"快捷入口(不通过模板面板触发),那种入口可以删除。 + +### 8. 运行 lint + typecheck + 局部测试 + +```bash +# typescript 检查 +pnpm lint + +# 跑相关单测(按改动文件局部跑) +cd packages/service && pnpm test ... +``` + +不要全量跑测试,只跑改动相关的。 + +## 验证清单 + +弃用完成后,确认以下场景: + +- [ ] 模板面板("添加节点"侧边栏)里**搜不到**该节点。 +- [ ] 已有该节点的旧工作流可以**打开、加载、运行**,UI 正常渲染。 +- [ ] 旧工作流中该节点头部右侧出现黄色 **"即将下线"** 徽章,hover 显示替换提示。 +- [ ] `pnpm lint` 通过。 +- [ ] dispatch/constants.ts 里 callbackMap 仍是 `Record` 完整覆盖(不能删 key,否则 TS 报错)。 + +## 反模式(不要做) + +- ❌ 删除 `FlowNodeTypeEnum.` —— 旧工作流的 JSON 仍写着这个值。 +- ❌ 从 `moduleTemplatesFlat` 移除 —— 旧工作流加载时找不到模板。 +- ❌ 从 `dispatch/constants.ts` 的 callbackMap 删条目 —— 运行时报"unknown node type"。 +- ❌ 删除 i18n 的 name / intro key —— 旧工作流的 tooltip / 节点标题会变成 raw key。 +- ❌ 删除 React 节点组件(`Flow/index.tsx` 的 nodeTypes 映射)—— 画布渲染崩。 +- ❌ 一并弃用共享子节点(如 `nestedStart`/`nestedEnd` 同时被多个容器使用)。 +- ❌ 整节点弃用时**给 inputs/outputs 加字段级 `deprecated: true`** —— 节点级徽章已足够,字段级是独立机制(用于"留住节点、淘汰单个字段"的场景)。 diff --git a/.codex/skills/doc/i18n/SKILL.md b/.codex/skills/doc/i18n/SKILL.md new file mode 100644 index 0000000000..bc7841abb1 --- /dev/null +++ b/.codex/skills/doc/i18n/SKILL.md @@ -0,0 +1,114 @@ +--- +name: doc-i18n +description: 将 FastGPT 文档从中文翻译为面向北美用户的英文。当用户提到翻译文档、i18n、国际化、translate docs、新增/修改了中文文档需要同步英文版时,使用此 skill。也适用于用户要求检查文档翻译缺失、批量翻译、或对比中英文文档差异的场景。 +--- + +## 概述 + +FastGPT 文档采用双文件 i18n 方案,中文为源语言,英文为目标语言。你的任务是将中文文档翻译为自然流畅的北美英文,而非逐字直译。 + +## 文件结构 + +文档位于 `document/content/docs/` 目录下: + +- 内容文件:`{name}.mdx`(中文) → `{name}.en.mdx`(英文) +- 导航文件:`meta.json`(中文) → `meta.en.json`(英文) + +## 工作流程 + +### 1. 确定翻译范围 + +两种方式确定需要翻译的文件: + +**自动检测**(用户未指定具体文件时): +- 运行 `git diff --name-only` 和 `git diff --cached --name-only` 检测 `document/` 下变更的中文文件 +- 筛选出 `.mdx`(排除 `.en.mdx`)和 `meta.json`(排除 `meta.en.json`) +- 检查对应的英文文件是否存在或是否需要更新 + +**手动指定**:用户直接给出文件路径或目录。 + +### 2. 翻译内容文件(.mdx → .en.mdx) + +对每个中文 `.mdx` 文件,生成或更新对应的 `.en.mdx` 文件。 + +**保持不变的部分**: +- MDX import 语句(如 `import { Alert } from '@/components/docs/Alert'`) +- 图片路径(如 `![](/imgs/intro/image1.png)`) +- 链接 URL(保持原始 URL 不变) +- HTML/JSX 组件结构和属性(如 ``) +- 表格的 markdown 结构 +- 代码块内容(除非是中文注释) +- emoji 符号 + +**需要翻译的部分**: +- frontmatter 的 `title` 和 `description` +- 所有正文文本内容 +- 组件内的文本内容(如 Alert 内的文字) +- 表格中的文字内容 +- 代码块中的中文注释 + +### 3. 翻译导航文件(meta.json → meta.en.json) + +对每个中文 `meta.json`,生成或更新对应的 `meta.en.json`。 + +**需要翻译的字段**:`title`、`description`、分隔符字符串(如 `"---入门---"` → `"---Getting Started---"`) + +**保持不变的字段**:`pages` 数组中的文件名引用、`icon`、`root`、`order` + +### 4. 翻译完成后 + +- 列出所有已翻译的文件 +- 如果发现中文文件有对应英文文件缺失的情况,提醒用户 + +## 翻译原则 + +这些原则的核心目标是让北美开发者读起来感觉像是原生英文文档,而不是翻译过来的。 + +### 语言风格 + +- 面向北美开发者,使用自然的美式英语技术写作风格 +- 不要逐字翻译,要传达原文的意思和意图 +- 技术文档倾向简洁直接,避免冗余修饰 +- 中文文档常用的排比、铺陈手法,翻译时应精简为英文读者习惯的表达 + +**示例**: +``` +中文:可轻松导入各式各样的文档及数据,能自动对其开展知识结构化处理工作。 + ✗:You can easily import various documents and data, which will be automatically processed for knowledge structuring. + ✓:Import documents and data with automatic knowledge structuring. +``` + +### 中国特有平台和服务的本地化 + +直接使用国际版名称,不保留中文原名: + +| 中文 | 英文 | +|------|------| +| 飞书 | Lark | +| 企业微信 | WeCom | +| 钉钉 | DingTalk | +| 公众号 | WeChat Official Account | +| 文心一言 | ERNIE Bot | +| 中国大陆版 | China Mainland | +| 国际版 | International | + +### 技术术语 + +保持业界通用的英文术语,不要生造翻译: + +| 中文 | 英文 | +|------|------| +| 知识库 | Knowledge Base | +| 工作流 | Workflow | +| 大语言模型 | LLM / Large Language Model | +| 向量存储 | Vector Store | +| 可视化编排 | Visual Orchestration | +| 低代码 | Low-code | +| 节点 | Node | +| 插件 | Plugin | + +### 语气 + +- 保持专业但友好的语气,和原文档的风格一致 +- 不要过度正式,也不要过于随意 +- 面向开发者和技术用户,假设读者有基本的技术背景 diff --git a/.codex/skills/support/permission/add-permission/SKILL.md b/.codex/skills/support/permission/add-permission/SKILL.md new file mode 100644 index 0000000000..9072f3a4c4 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/SKILL.md @@ -0,0 +1,37 @@ +--- +name: add-permission +description: 为 FastGPT 新资源接入权限管理。当用户需要为新资源(如 AgentSkill、Plugin 等)添加权限支持时触发。 +--- + +# 新资源权限接入 + +## 你的资源是哪种类型? + +``` +资源有父子/文件夹结构吗? + │ + ├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md) + │ + └─ 是 ──► 资源支持权限继承吗? + │ + ├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md) + │ + └─ 是 ──► 继承型资源 ──► [完整接入](./guides/full-integration.md) +``` + +## 快速链接 + +| 我想... | 去看... | +|---------|---------| +| 5 步完成最小接入 | [快速入门](./guides/quick-start.md) | +| 接入继承型资源 | [完整接入](./guides/full-integration.md) | +| 检查遗漏项 | [实施清单](./checklist.md) | +| 理解权限系统原理 | [参考文档](./reference/README.md) | + +## 关键代码位置 + +| 文件 | 用途 | +|------|------| +| `packages/global/support/permission/constant.ts` | 添加 `PerResourceTypeEnum` | +| `packages/global/support/permission/{resource}/` | 权限常量 + Permission 类 | +| `packages/service/support/permission/{resource}/auth.ts` | 鉴权函数 | diff --git a/.codex/skills/support/permission/add-permission/checklist.md b/.codex/skills/support/permission/add-permission/checklist.md new file mode 100644 index 0000000000..7394219706 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/checklist.md @@ -0,0 +1,134 @@ +# 权限接入实施清单 + +> 上线前核对清单,可打印使用。 + +## 设计判断 + +- [ ] 确认资源是否有 owner +- [ ] 确认资源是否属于 team +- [ ] 确认资源是否需要协作者 +- [ ] 确认资源是否有 folder / parent-child 结构 +- [ ] 确认资源是否支持 `inheritPermission` +- [ ] 确认资源是否需要 owner 转移 + +--- + +## FastGPT 主仓库 + +### 权限定义 + +- [ ] `PerResourceTypeEnum` 已添加新资源类型 +- [ ] `packages/global/support/permission/{resource}/constant.ts` 已创建 + - [ ] `{Resource}RoleList` + - [ ] `{Resource}RolePerMap` + - [ ] `{Resource}PerList` + - [ ] `{Resource}DefaultRoleVal` +- [ ] `packages/global/support/permission/{resource}/controller.ts` 已创建 + - [ ] `{Resource}Permission` 类 + +### 资源 Schema + +- [ ] 包含 `teamId` 字段 +- [ ] 包含 `tmbId` 字段(owner) +- [ ] 包含 `parentId` 字段(如有层级) +- [ ] 包含 `inheritPermission` 字段(如有继承) + +### 鉴权函数 + +- [ ] `packages/service/support/permission/{resource}/auth.ts` 已创建 +- [ ] `auth{Resource}` 函数已实现 +- [ ] 如有继承,已实现父级权限合并 + +### API 权限校验 + +- [ ] 列表接口:`ReadPermissionVal` +- [ ] 详情接口:`ReadPermissionVal` +- [ ] 创建接口:`WritePermissionVal` 或 team 级创建权限 +- [ ] 更新接口:`WritePermissionVal` +- [ ] 删除接口:`OwnerPermissionVal`(不是 Manage!) +- [ ] Folder 创建接口(如有) +- [ ] 恢复继承接口(如有) + +### 继承相关(如适用) + +- [ ] 明确 folder 类型列表 +- [ ] 资源创建时复制父协作者 +- [ ] 资源移动时同步子树权限 +- [ ] `resumeInheritPermission` 逻辑 + +--- + +## fastgpt-pro + +### 协作者管理 + +- [ ] `collaborator/list` 接口 + - [ ] 返回 `clbs`(最终生效协作者) + - [ ] 返回 `parentClbs`(父级协作者) +- [ ] `collaborator/update` 接口 + - [ ] 需要 `ManagePermissionVal` + - [ ] 不能修改自己的权限 + - [ ] 非 owner 不能修改管理员权限 + - [ ] 继承冲突时自动断开继承 + +### Owner 转移(如适用) + +- [ ] `changeOwner` 接口 + - [ ] 需要 `OwnerPermissionVal` + - [ ] 更新资源表 `tmbId` + - [ ] 根资源断开继承 + - [ ] 修正权限记录 + +### 协作者类型支持 + +- [ ] 支持 `tmbId`(团队成员) +- [ ] 支持 `groupId`(成员组) +- [ ] 支持 `orgId`(组织) + +### 审计日志 + +- [ ] 更新协作者日志 +- [ ] 删除协作者日志 +- [ ] Owner 转移日志 +- [ ] 恢复继承日志(如有) +- [ ] 移动资源日志(如有) + +--- + +## 前端 + +- [ ] 协作者列表 API 调用 +- [ ] 协作者更新 API 调用 +- [ ] Owner 转移 API 调用(如有) +- [ ] 权限配置弹窗 / 协作者管理组件 +- [ ] 继承态提示 UI(如有) +- [ ] 恢复继承入口(如有) + +--- + +## 测试 + +### 单元测试 + +- [ ] Permission 类与角色映射 +- [ ] `getTmbPermission` 优先级逻辑 +- [ ] 继承型资源的父子权限合并 + +### 集成测试 + +- [ ] 主要 API 的权限边界 +- [ ] 删除是否要求 owner +- [ ] 移动与继承恢复逻辑 +- [ ] 协作者更新冲突处理 +- [ ] Owner 转移后权限记录正确性 + +--- + +## 最终检查 + +- [ ] 删除要求 owner,而不是 manage +- [ ] group / org 协作者按预期生效 +- [ ] 继承断开后生成正确的显式协作者快照 +- [ ] 移动资源后子树权限同步 +- [ ] Owner 转移后旧/新 owner 权限记录正确 +- [ ] 前后端展示的"最终权限"与后端实际鉴权一致 diff --git a/.codex/skills/support/permission/add-permission/guides/full-integration.md b/.codex/skills/support/permission/add-permission/guides/full-integration.md new file mode 100644 index 0000000000..9150933334 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/guides/full-integration.md @@ -0,0 +1,289 @@ +# 完整接入:继承型资源权限 + +> 适用于**继承型资源**:有 folder 结构、支持 `inheritPermission`、需要协作者管理和 owner 转移。 + +## 概览 + +继承型资源需要在两个仓库中实现: + +| 仓库 | 职责 | +|------|------| +| FastGPT 主仓库 | 权限定义、鉴权、继承同步 | +| fastgpt-pro | 协作者管理、owner 转移、审计日志 | + +--- + +## Part 1: FastGPT 主仓库 + +### 1.1 基础权限定义 + +与[快速入门](./quick-start.md)相同,完成 Step 1-3。 + +### 1.2 资源 Schema 字段 + +确保资源 Schema 包含以下字段: + +```typescript +const {Resource}Schema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner + parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源 + type: { type: String }, // 区分 folder 和普通资源 + inheritPermission: { type: Boolean, default: true } // 是否继承父权限 +}); +``` + +### 1.3 实现带继承的鉴权函数 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 关键:判断是否需要合并父级权限 + const isGetParentClb = + resource.inheritPermission && + resource.type !== '{resource}Folder' && // folder 不继承 + !!resource.parentId; + + // 并行获取父级权限和自身权限 + const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ + teamId, + tmbId, + resourceId: resource.parentId!, + resourceType: PerResourceTypeEnum.{resource} + }) + : NullRoleVal, + getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }) + ]); + + // 合并权限 + const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner + }); + + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +### 1.4 Folder 创建时复制父协作者 + +```typescript +// 创建 folder 时 +import { createResourceDefaultCollaborators } from '@fastgpt/service/support/permission/controller'; + +await createResourceDefaultCollaborators({ + teamId, + tmbId, + resourceId: newFolderId, + resourceType: PerResourceTypeEnum.{resource}, + parentId, + session +}); +``` + +### 1.5 移动资源时同步子树权限 + +```typescript +// 资源移动后 +import { syncChildrenPermission } from '@fastgpt/service/support/permission/inheritPermission'; + +await syncChildrenPermission({ + resource: movedResource, + folderTypeList: ['{resource}Folder'], + resourceType: PerResourceTypeEnum.{resource}, + resourceModel: Mongo{Resource}, + session, + collaborators: newParentCollaborators +}); +``` + +### 1.6 恢复继承 + +```typescript +// 恢复继承时 +import { resumeInheritPermission } from '@fastgpt/service/support/permission/inheritPermission'; + +await resumeInheritPermission({ + resource, + folderTypeList: ['{resource}Folder'], + resourceType: PerResourceTypeEnum.{resource}, + resourceModel: Mongo{Resource}, + session +}); +``` + +--- + +## Part 2: fastgpt-pro + +### 2.1 协作者列表接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts +async function handler(req) { + const { teamId, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + + const isGetParentClbs = + !!{resource}.inheritPermission && + {resource}.type !== '{resource}Folder' && + !!{resource}.parentId; + + const [parentClbs, childClbs] = await Promise.all([ + isGetParentClbs + ? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType }) + : [], + getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType }) + ]); + + const realClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs, parentClbs }) + : childClbs; + + return { + clbs: await getClbsInfo(realClbs), // 最终生效协作者 + parentClbs: await getClbsInfo(parentClbs) // 父级协作者(用于 UI 展示来源) + }; +} +``` + +### 2.2 协作者更新接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/update.ts +async function handler(req) { + const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ManagePermissionVal + }); + + // 保护规则 + const changedClbs = getChangedCollaborators({ newRealClbs: collaborators, oldRealClbs }); + + // 1. 不能修改自己的权限 + if (changedClbs.find((clb) => clb?.tmbId === tmbId)) { + return Promise.reject({Resource}ErrEnum.canNotEditSelfPermission); + } + + // 2. 非 owner 不能修改管理员级协作者 + if ( + changedClbs.some((clb) => new {Resource}Permission({ role: clb.changedRole }).hasManagePer) && + !myPer.isOwner + ) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 调用通用编排器 + await updateResourceCollaborators({ + teamId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource}, + collaborators, + folderTypeList: ['{resource}Folder'], + resource: {resource}, + resourceModel: Mongo{Resource}, + session + }); +} +``` + +### 2.3 Owner 转移接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts +async function handler(req) { + const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal // 只有 owner 能转移 + }); + + await changeOwner({ + changeOwnerType: '{resource}', + resourceId: {resource}._id, + newOwnerId: newOwnerTmbId, + oldOwnerId: {resource}.tmbId, + teamId: {resource}.teamId + }); +} +``` + +--- + +## Part 3: 前端 + +### 3.1 协作者管理组件 + +复用现有的 `MemberManager` 组件,配置: + +```typescript + get{Resource}Collaborators({resource}Id)} + onUpdateCollaborators={(clbs) => update{Resource}Collaborators({resource}Id, clbs)} + onDelOneCollaborator={(clb) => delete{Resource}Collaborator({resource}Id, clb)} +/> +``` + +### 3.2 继承态提示 + +```typescript +{resource.inheritPermission && resource.parentId && ( + 继承自父级 +)} +``` + +--- + +## 完成后检查 + +使用 [实施清单](../checklist.md) 进行最终检查。 + +## 深入了解 + +- [继承机制详解](../reference/inheritance.md) +- [协作者管理编排器](../reference/pro-collaborator.md) +- [Owner 转移机制](../reference/pro-owner-transfer.md) diff --git a/.codex/skills/support/permission/add-permission/guides/quick-start.md b/.codex/skills/support/permission/add-permission/guides/quick-start.md new file mode 100644 index 0000000000..5f985a8990 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/guides/quick-start.md @@ -0,0 +1,192 @@ +# 快速入门:5 步完成权限接入 + +> 适用于**简单资源**:无父子结构、无继承、无 owner 转移需求。 + +## 前置条件 + +- 资源已有 `teamId` 和 `tmbId` 字段 +- 资源属于某个 team + +--- + +## Step 1: 添加资源类型枚举 + +```typescript +// packages/global/support/permission/constant.ts +export enum PerResourceTypeEnum { + // ...existing + {resource} = '{resource}' // 例如: agentSkill = 'agentSkill' +} +``` + +--- + +## Step 2: 创建权限常量文件 + +```typescript +// packages/global/support/permission/{resource}/constant.ts +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { + CommonRoleList, + CommonPerKeyEnum, + CommonRolePerMap, + CommonPerList, + NullRoleVal +} from '../constant'; + +export const {Resource}RoleList = { + [CommonPerKeyEnum.read]: { + ...CommonRoleList[CommonPerKeyEnum.read], + description: i18nT('permission:{resource}.read_desc') + }, + [CommonPerKeyEnum.write]: { + ...CommonRoleList[CommonPerKeyEnum.write], + description: i18nT('permission:{resource}.write_desc') + }, + [CommonPerKeyEnum.manage]: { + ...CommonRoleList[CommonPerKeyEnum.manage], + description: i18nT('permission:{resource}.manage_desc') + } +}; + +export const {Resource}RolePerMap = CommonRolePerMap; +export const {Resource}PerList = CommonPerList; +export const {Resource}DefaultRoleVal = NullRoleVal; +``` + +--- + +## Step 3: 创建 Permission 类 + +```typescript +// packages/global/support/permission/{resource}/controller.ts +import { Permission, PerConstructPros } from '../controller'; +import { + {Resource}RoleList, + {Resource}RolePerMap, + {Resource}PerList, + {Resource}DefaultRoleVal +} from './constant'; + +export class {Resource}Permission extends Permission { + constructor(props?: PerConstructPros) { + if (!props) { + props = { role: {Resource}DefaultRoleVal }; + } else if (!props.role) { + props.role = {Resource}DefaultRoleVal; + } + + props.roleList = {Resource}RoleList; + props.rolePerMap = {Resource}RolePerMap; + props.perList = {Resource}PerList; + super(props); + } +} +``` + +--- + +## Step 4: 实现鉴权函数 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +import { AuthModeType } from '../type'; +import { parseHeaderCert } from '../../controller'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller'; +import { getTmbPermission } from '../controller'; +import { Mongo{Resource} } from '@fastgpt/service/core/{resource}/schema'; +import { {Resource}ErrEnum } from '@fastgpt/global/common/error/code/{resource}'; + +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + // 1. 查询资源 + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + // 2. 验证 team 归属 + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 3. 判断 owner + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 4. 获取权限 + const myPer = await getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }); + + // 5. 构建权限对象并检查 + const Per = new {Resource}Permission({ role: myPer, isOwner }); + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## Step 5: 在 API 中使用 + +```typescript +// 读取操作 +const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal +}); + +// 写入操作 +const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: WritePermissionVal +}); + +// 删除操作(要求 owner) +const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal +}); +``` + +--- + +## 完成后检查 + +- [ ] `PerResourceTypeEnum` 已添加 +- [ ] 权限常量文件已创建 +- [ ] Permission 类已创建 +- [ ] 鉴权函数已实现 +- [ ] API 路由已使用鉴权函数 + +## 下一步 + +- 需要协作者管理?→ 在 fastgpt-pro 中添加 `collaborator/list` 和 `collaborator/update` 接口 +- 需要更多细节?→ [参考文档](../reference/README.md) diff --git a/.codex/skills/support/permission/add-permission/reference/README.md b/.codex/skills/support/permission/add-permission/reference/README.md new file mode 100644 index 0000000000..048859ee9f --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/README.md @@ -0,0 +1,27 @@ +# 参考文档索引 + +> 深入理解 FastGPT 权限系统的设计原理和实现细节。 + +## 文档结构 + +``` +reference/ +├── core-concepts.md ── 核心概念:权限值、角色、协作者 +├── permission-class.md ── Permission 类设计与使用 +├── auth-function.md ── 鉴权函数实现模式 +├── inheritance.md ── 继承机制详解 +├── pro-collaborator.md ── fastgpt-pro 协作者管理 +└── pro-owner-transfer.md ── Owner 转移机制 +``` + +## 按场景查阅 + +| 我想了解... | 去看... | +|-------------|---------| +| 权限值的位字段设计 | [核心概念](./core-concepts.md) | +| 如何扩展 Permission 类 | [Permission 类设计](./permission-class.md) | +| 鉴权函数的标准实现 | [鉴权函数实现](./auth-function.md) | +| 父子资源权限如何合并 | [继承机制](./inheritance.md) | +| 协作者更新如何处理冲突 | [协作者管理](./pro-collaborator.md) | +| Owner 转移的完整流程 | [Owner 转移](./pro-owner-transfer.md) | + diff --git a/.codex/skills/support/permission/add-permission/reference/auth-function.md b/.codex/skills/support/permission/add-permission/reference/auth-function.md new file mode 100644 index 0000000000..31173d7ec0 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/auth-function.md @@ -0,0 +1,251 @@ +# 鉴权函数实现 + +## 1. 标准鉴权流程 + +``` +用户请求 (带 Token/ApiKey) + │ + ▼ +┌──────────────────────┐ +│ parseHeaderCert │ 解析认证信息 +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 查询资源 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 验证 team 归属 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 判断 isOwner │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ getTmbPermission │ 获取用户权限 +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 构建 Permission 对象 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ checkPer 验证 │ +└──────────────────────┘ +``` + +--- + +## 2. 简单资源鉴权模板 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +import { AuthModeType, parseHeaderCert } from '../type'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller'; +import { getTmbPermission } from '../controller'; + +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + // 1. 解析认证信息 + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + // 2. 查询资源 + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + // 3. 验证 team 归属 + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 4. 判断 owner + // - team owner 视为资源 owner + // - 资源创建者是 owner + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 5. 获取用户权限 + const myPer = await getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }); + + // 6. 构建权限对象并检查 + const Per = new {Resource}Permission({ role: myPer, isOwner }); + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 7. 返回结果 + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## 3. 继承型资源鉴权模板 + +```typescript +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 关键:判断是否需要合并父级权限 + const isGetParentClb = + resource.inheritPermission && // 开启了继承 + resource.type !== '{resource}Folder' && // folder 不继承 + !!resource.parentId; // 有父资源 + + // 并行获取 + const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ + teamId, + tmbId, + resourceId: resource.parentId!, + resourceType: PerResourceTypeEnum.{resource} + }) + : NullRoleVal, + getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }) + ]); + + // 合并权限 + const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner + }); + + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## 4. getTmbPermission 实现 + +```typescript +// packages/service/support/permission/controller.ts +export const getTmbPermission = async ({ + teamId, + tmbId, + resourceId, + resourceType +}) => { + // 1. 个人权限优先 + const tmbPer = ( + await MongoResourcePermission.findOne({ + resourceType, + teamId, + resourceId, + tmbId + }, 'permission').lean() + )?.permission; + + // 个人权限存在则直接返回(即使是 0) + if (tmbPer !== undefined) return tmbPer; + + // 2. 获取 group 和 org 权限 + const [groupPers, orgPers] = await Promise.all([ + // 查询用户所属 group 的权限 + getGroupPermissions(...), + // 查询用户所属 org 的权限 + getOrgPermissions(...) + ]); + + // 3. 合并返回 + return sumPer(...groupPers, ...orgPers); +}; +``` + +--- + +## 5. API 使用示例 + +```typescript +// 读取操作 +async function handler(req) { + const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + return { ...{resource}, permission }; +} + +// 写入操作 +async function handler(req) { + const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: WritePermissionVal + }); + // 业务逻辑... +} + +// 删除操作(要求 owner) +async function handler(req) { + await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal + }); + // 删除逻辑... +} +``` diff --git a/.codex/skills/support/permission/add-permission/reference/core-concepts.md b/.codex/skills/support/permission/add-permission/reference/core-concepts.md new file mode 100644 index 0000000000..d504cf10bc --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/core-concepts.md @@ -0,0 +1,134 @@ +# 核心概念 + +## 1. 权限值 (Permission Value) - 位字段设计 + +权限使用位字段 (bitmask) 表示,支持权限组合: + +```typescript +// packages/global/support/permission/constant.ts +export const CommonPerList = { + owner: ~0 >>> 0, // 所有位为1,表示所有者 + read: 0b100, // 读权限 (4) + write: 0b010, // 写权限 (2) + manage: 0b001 // 管理权限 (1) +}; +``` + +### 权限值对照表 + +| 权限 | 值 | 二进制 | 说明 | +|------|-----|--------|------| +| NullRoleVal | 0 | 0b000 | 无角色 | +| ReadPermissionVal | 4 | 0b100 | 读权限 | +| WritePermissionVal | 2 | 0b010 | 写权限 | +| ManagePermissionVal | 1 | 0b001 | 管理权限 | +| OwnerPermissionVal | ~0>>>0 | 全1 | 所有者 | + +### 位运算示例 + +```typescript +// 检查是否有读权限 +const hasRead = (permission & ReadPermissionVal) === ReadPermissionVal; + +// 合并权限 +const merged = permission1 | permission2; + +// 添加权限 +const withWrite = permission | WritePermissionVal; +``` + +--- + +## 2. 角色值 (Role Value) - 权限映射 + +**关键区分**:数据库中 `permission` 字段存储的是**角色值**,不是展开后的权限值。 + +```typescript +// 角色 -> 权限映射 +export const CommonRolePerMap = new Map([ + [0b100, 0b100], // read 角色 -> read 权限 + [0b010, 0b110], // write 角色 -> write + read 权限 + [0b001, 0b111] // manage 角色 -> manage + write + read 权限 +]); +``` + +### 角色继承关系 + +``` +manage (0b001) ──包含──► write + read + │ +write (0b010) ──包含──► read + │ +read (0b100) +``` + +--- + +## 3. 协作者类型 + +权限可以分配给三种实体(三选一): + +```typescript +// packages/global/support/permission/collaborator.ts +type CollaboratorIdType = RequireOnlyOne<{ + tmbId: string; // 团队成员 + groupId: string; // 成员组 + orgId: string; // 组织 +}>; +``` + +### 权限优先级 + +``` +tmbId (个人权限) + │ + └─ 存在?─► 直接返回 + │ + └─ 否 ─► groupId + orgId 合并后返回 +``` + +**注意**:不是"个人 > 组 > 组织"的覆盖关系,而是: +- 个人权限存在则直接使用 +- 否则 group 和 org 权限按位合并 + +--- + +## 4. ResourcePermission Schema + +```typescript +// packages/service/support/permission/schema.ts +const ResourcePermissionSchema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + + // 协作者标识(三选一) + tmbId: { type: Schema.Types.ObjectId }, + groupId: { type: Schema.Types.ObjectId }, + orgId: { type: Schema.Types.ObjectId }, + + // 资源信息 + resourceType: { type: String, enum: Object.values(PerResourceTypeEnum), required: true }, + resourceId: { type: Schema.Types.ObjectId }, + + // 存储的是角色值 + permission: { type: Number, required: true } +}); +``` + +### 索引 + +- `resourceId + tmbId` 唯一 +- `resourceId + groupId` 唯一 +- `resourceId + orgId` 唯一 + +--- + +## 5. 资源 Schema 权限相关字段 + +```typescript +const ResourceSchema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner + parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源(可选) + inheritPermission: { type: Boolean, default: true } // 是否继承(可选) +}); +``` diff --git a/.codex/skills/support/permission/add-permission/reference/inheritance.md b/.codex/skills/support/permission/add-permission/reference/inheritance.md new file mode 100644 index 0000000000..1ceeef15be --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/inheritance.md @@ -0,0 +1,295 @@ +# 继承机制详解 + +## 1. 继承模型概述 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Folder A (inheritPermission: false) │ +│ 协作者: [User1: manage, User2: write] │ +└───────────────────────┬─────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────┐ ┌───────────────────────────┐ +│ Resource B │ │ Folder C │ +│ inherit: true │ │ inherit: true │ +│ 自身协作者: [] │ │ 协作者: [User1, User2] │ +│ │ │ (从 A 复制) │ +│ 最终权限: │ └─────────────┬─────────────┘ +│ User1: manage │ │ +│ User2: write │ ▼ +│ (来自父级) │ ┌───────────────────────────┐ +└───────────────────┘ │ Resource D │ + │ inherit: true │ + │ 自身协作者: [User3: read] │ + │ │ + │ 最终权限: │ + │ User1: manage (父级) │ + │ User2: write (父级) │ + │ User3: read (自身) │ + └───────────────────────────┘ +``` + +### 关键规则 + +1. **Folder 不继承**:folder 的 `inheritPermission` 无效,它有自己的完整协作者列表 +2. **普通资源继承**:开启继承时,鉴权时合并父级权限 +3. **继承是增量合并**:不是覆盖,子资源可以有额外的显式协作者 + +--- + +## 2. 鉴权时的权限合并 + +```typescript +// packages/service/support/permission/dataset/auth.ts +const isGetParentClb = + dataset.inheritPermission && + dataset.type !== DatasetTypeEnum.folder && + !!dataset.parentId; + +const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ resourceId: dataset.parentId, ... }) + : NullRoleVal, + getTmbPermission({ resourceId: datasetId, ... }) +]); + +// 按位合并 +const Per = new DatasetPermission({ + role: sumPer(folderPer, myPer), + isOwner +}); +``` + +### sumPer 实现 + +```typescript +export const sumPer = (...pers: PermissionValueType[]) => { + return pers.reduce((acc, per) => acc | per, NullRoleVal); +}; +``` + +--- + +## 3. Folder 创建时复制父协作者 + +```typescript +// packages/service/support/permission/controller.ts +export const createResourceDefaultCollaborators = async ({ + teamId, + tmbId, + resourceId, + resourceType, + parentId, + session +}) => { + // 1. 获取父协作者 + const parentClbs = parentId + ? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType }) + : []; + + // 2. 构建新协作者列表 + const collaborators = [ + ...parentClbs + .filter((item) => item.tmbId !== tmbId) // 排除创建者 + .map((clb) => { + // 父 owner 降级为 manage + if (clb.permission === OwnerRoleVal) { + clb.permission = ManageRoleVal; + } + return clb; + }), + // 创建者成为 owner + { tmbId, permission: OwnerRoleVal } + ]; + + // 3. 批量插入 + await MongoResourcePermission.insertMany( + collaborators.map((clb) => ({ + teamId, + resourceType, + resourceId, + ...clb + })), + { session } + ); +}; +``` + +--- + +## 4. 子树权限同步 (syncChildrenPermission) + +当 folder 的协作者变化时,需要同步到继承它的子树。 + +```typescript +// packages/service/support/permission/inheritPermission.ts +export async function syncChildrenPermission({ + resource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: latestClbList +}) { + // 1. 只处理 folder + if (!folderTypeList.includes(resource.type)) return; + + // 2. 获取所有 inheritPermission: true 的 folder 子树 + const allFolders = await resourceModel.find({ + teamId: resource.teamId, + inheritPermission: true, + type: { $in: folderTypeList } + }); + + // 3. BFS 遍历子树 + const queue = [resource._id]; + while (queue.length) { + const parentId = queue.shift(); + const children = allFolders.filter(f => String(f.parentId) === String(parentId)); + + for (const child of children) { + // 获取子资源现有协作者 + const childClbs = await getResourceOwnedClbs({ resourceId: child._id, ... }); + + for (const latestClb of latestClbList) { + // 跳过 owner + if (latestClb.permission === OwnerRoleVal) continue; + + const myClb = childClbs.find(c => sameClb(c, latestClb)); + + if (myClb) { + // 已有则合并(增量) + await MongoResourcePermission.updateOne( + { _id: myClb._id }, + { permission: sumPer(myClb.permission, latestClb.permission) }, + { session } + ); + } else { + // 没有则新增 + await MongoResourcePermission.create([{ + ...latestClb, + resourceId: child._id, + resourceType + }], { session }); + } + } + + // 删除不再存在的纯继承协作者 + for (const childClb of childClbs) { + const inLatest = latestClbList.find(c => sameClb(c, childClb)); + if (!inLatest && childClb.permission === parentClb?.permission) { + // 是纯继承的,删除 + await MongoResourcePermission.deleteOne({ _id: childClb._id }, { session }); + } + } + + queue.push(child._id); + } + } +} +``` + +### 关键点 + +- **增量合并**:不是简单覆盖,保留子资源的显式增量 +- **只删纯继承**:只删除权限值与父级完全一致的协作者 +- **跳过 owner**:owner 不参与继承同步 + +--- + +## 5. 恢复继承 (resumeInheritPermission) + +```typescript +// packages/service/support/permission/inheritPermission.ts +export const resumeInheritPermission = async ({ + resource, + folderTypeList, + resourceType, + resourceModel, + session +}) => { + const { teamId, parentId, _id: resourceId } = resource; + + // 1. 获取父协作者 + const parentClbs = parentId + ? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType }) + : []; + + // 2. 获取自身协作者 + const selfClbs = await getResourceOwnedClbs({ teamId, resourceId, resourceType }); + + // 3. 合并协作者 + const mergedClbs = mergeCollaboratorList({ + childClbs: selfClbs, + parentClbs: parentClbs.map((clb) => { + // 父 owner 降为 manage + if (clb.permission === OwnerRoleVal) { + return { ...clb, permission: ManageRoleVal }; + } + return clb; + }) + }); + + // 4. 删除旧协作者 + await MongoResourcePermission.deleteMany({ + resourceType, + resourceId + }, { session }); + + // 5. 插入合并后的协作者 + await MongoResourcePermission.insertMany( + mergedClbs.map(clb => ({ + teamId, + resourceType, + resourceId, + ...clb + })), + { session } + ); + + // 6. 如果是 folder,同步子树 + if (folderTypeList.includes(resource.type)) { + await syncChildrenPermission({ + resource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: mergedClbs + }); + } + + // 7. 设置继承标志 + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: true }, + { session } + ); +}; +``` + +--- + +## 6. 资源移动时的处理 + +```typescript +// 移动资源后 +if (newParentId !== oldParentId) { + // 获取新父级协作者 + const newParentClbs = await getResourceOwnedClbs({ + resourceId: newParentId, + ... + }); + + // 同步子树 + await syncChildrenPermission({ + resource: movedResource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: newParentClbs + }); +} +``` diff --git a/.codex/skills/support/permission/add-permission/reference/permission-class.md b/.codex/skills/support/permission/add-permission/reference/permission-class.md new file mode 100644 index 0000000000..ff9ce3c13d --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/permission-class.md @@ -0,0 +1,143 @@ +# Permission 类设计 + +## 1. 基类结构 + +```typescript +// packages/global/support/permission/controller.ts +export class Permission { + role: PermissionValueType; + private permission: PermissionValueType; + + // 权限状态(计算属性) + isOwner: boolean; + hasManagePer: boolean; + hasWritePer: boolean; + hasReadPer: boolean; + + // 角色状态 + hasManageRole: boolean; + hasWriteRole: boolean; + hasReadRole: boolean; + + constructor({ role, isOwner, roleList, perList, rolePerMap }) { + this.role = isOwner ? OwnerRoleVal : role; + this.updatePermissions(); + } + + // 检查是否拥有指定权限 + checkPer(perm: PermissionValueType): boolean { + if (perm === OwnerPermissionVal) { + return this.permission === OwnerPermissionVal; + } + return (this.permission & perm) === perm; + } + + // 添加角色 + addRole(...roleList: RoleValueType[]) { + for (const role of roleList) { + this.role = this.role | role; + } + this.updatePermissions(); + return this; + } +} +``` + +### 关键点 + +1. **存储的是 role**:`permission` 字段存的是 role 值,通过 `rolePerMap` 展开成实际权限 +2. **isOwner 提升**:如果 `isOwner=true`,role 直接设为 `OwnerRoleVal` +3. **链式调用**:`addRole` 返回 `this`,支持链式操作 + +--- + +## 2. 创建资源特定的 Permission 类 + +```typescript +// packages/global/support/permission/{resource}/controller.ts +import { Permission, PerConstructPros } from '../controller'; +import { + {Resource}RoleList, + {Resource}RolePerMap, + {Resource}PerList, + {Resource}DefaultRoleVal +} from './constant'; + +export class {Resource}Permission extends Permission { + constructor(props?: PerConstructPros) { + // 处理空参数 + if (!props) { + props = { role: {Resource}DefaultRoleVal }; + } else if (!props.role) { + props.role = {Resource}DefaultRoleVal; + } + + // 注入资源特定的配置 + props.roleList = {Resource}RoleList; + props.rolePerMap = {Resource}RolePerMap; + props.perList = {Resource}PerList; + + super(props); + } +} +``` + +--- + +## 3. 使用示例 + +### 3.1 基本检查 + +```typescript +const per = new DatasetPermission({ role: WriteRoleVal }); + +per.hasReadPer; // true(write 包含 read) +per.hasWritePer; // true +per.hasManagePer; // false +per.isOwner; // false + +per.checkPer(ReadPermissionVal); // true +per.checkPer(ManagePermissionVal); // false +``` + +### 3.2 在鉴权中使用 + +```typescript +const Per = new {Resource}Permission({ + role: myPer, + isOwner: String(resource.tmbId) === String(tmbId) +}); + +if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); +} + +// 返回给调用方 +return { + permission: Per, + {resource}: resource +}; +``` + +### 3.3 合并权限 + +```typescript +import { sumPer } from '@fastgpt/global/support/permission/utils'; + +// 合并父级权限和自身权限 +const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner +}); +``` + +--- + +## 4. 现有 Permission 类 + +| 类 | 文件 | +|----|------| +| `Permission` | `packages/global/support/permission/controller.ts` | +| `DatasetPermission` | `packages/global/support/permission/dataset/controller.ts` | +| `AppPermission` | `packages/global/support/permission/app/controller.ts` | +| `TeamPermission` | `packages/global/support/permission/user/controller.ts` | diff --git a/.codex/skills/support/permission/add-permission/reference/pro-collaborator.md b/.codex/skills/support/permission/add-permission/reference/pro-collaborator.md new file mode 100644 index 0000000000..da19cc1ea4 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/pro-collaborator.md @@ -0,0 +1,298 @@ +# fastgpt-pro 协作者管理 + +> fastgpt-pro 在 FastGPT 主仓库的基础权限系统之上,提供"可运营的权限管理能力"。 + +## 1. 架构分层 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ fastgpt-pro 权限扩展层 │ +├────────────────────────────────────────────────────────────────────┤ +│ API 层 │ +│ ├── /api/core/{resource}/collaborator/list │ +│ ├── /api/core/{resource}/collaborator/update │ +│ └── /api/core/{resource}/changeOwner │ +├────────────────────────────────────────────────────────────────────┤ +│ 编排层 │ +│ ├── updateResourceCollaborators │ +│ ├── getChangedCollaborators │ +│ ├── checkRoleUpdateConflict │ +│ └── mergeCollaboratorList │ +├────────────────────────────────────────────────────────────────────┤ +│ FastGPT 主仓库基础能力 │ +│ ├── authDataset / authApp │ +│ ├── getTmbPermission │ +│ └── ResourcePermission Schema │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**一句话概括**:FastGPT 负责"判定权限",fastgpt-pro 负责"管理权限"。 + +--- + +## 2. 协作者列表接口 + +### 接口设计 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts +type Response = { + clbs: CollaboratorItemDetailType[]; // 最终生效协作者 + parentClbs?: CollaboratorItemDetailType[]; // 父级协作者(用于展示来源) +}; +``` + +### 实现 + +```typescript +async function handler(req) { + const { teamId, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + + // 判断是否需要获取父级协作者 + const isGetParentClbs = + !!{resource}.inheritPermission && + {resource}.type !== '{resource}Folder' && + !!{resource}.parentId; + + const [parentClbs, childClbs] = await Promise.all([ + isGetParentClbs + ? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType }) + : [], + getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType }) + ]); + + // 合并得到最终生效协作者 + const realClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs, parentClbs }) + : childClbs; + + return { + clbs: await getClbsInfo(realClbs), + parentClbs: await getClbsInfo(parentClbs) + }; +} +``` + +### 设计意图 + +- 不是简单返回 `MongoResourcePermission.find({ resourceId })` +- 同时返回"最终权限视图"和"继承来源视图" +- 前端可以据此展示"此权限来自父级"的 UI 提示 + +--- + +## 3. 协作者更新接口 + +### 核心流程 + +```typescript +async function handler(req) { + // 1. 鉴权(需要 manage 权限) + const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ManagePermissionVal + }); + + // 2. 获取新旧协作者 + const [parentClbs, oldChildClbs] = await Promise.all([ + getResourceOwnedClbs({ resourceId: parentId }), + getResourceOwnedClbs({ resourceId: {resource}Id }) + ]); + + const oldRealClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs }) + : oldChildClbs; + + // 3. 计算变化 + const changedClbs = getChangedCollaborators({ + newRealClbs: collaborators, + oldRealClbs + }); + + // 4. 权限保护检查 + await checkPermissionProtection(changedClbs, tmbId, myPer); + + // 5. 调用编排器更新 + await updateResourceCollaborators({ + teamId, + resourceId: {resource}Id, + resourceType, + collaborators, + folderTypeList, + resource: {resource}, + resourceModel, + session + }); +} +``` + +### 权限保护规则 + +```typescript +// 1. 不能修改自己的权限 +if (changedClbs.find((clb) => clb?.tmbId === tmbId)) { + return Promise.reject(ErrEnum.canNotEditSelfPermission); +} + +// 2. 非 owner 不能修改管理员级协作者 +if ( + changedClbs.some((clb) => + new {Resource}Permission({ role: clb.changedRole }).hasManagePer + ) && + !myPer.isOwner +) { + return Promise.reject(ErrEnum.unAuth); +} +``` + +--- + +## 4. updateResourceCollaborators 编排器 + +### 核心逻辑 + +```typescript +export const updateResourceCollaborators = async ({ + teamId, + resourceId, + resourceType, + collaborators, // 用户想更新成的协作者列表 + folderTypeList, + resource, + resourceModel, + session +}) => { + // 1. 获取父级和当前协作者 + const [parentClbs, oldChildClbs] = await Promise.all([...]); + + // 2. 计算旧的最终协作者 + const oldRealClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs }) + : oldChildClbs; + + // 3. 计算变化的协作者 + const changedClbs = getChangedCollaborators({ + newRealClbs: collaborators, + oldRealClbs + }); + + // 4. 检测继承冲突 + const hasConflict = checkRoleUpdateConflict({ + changedClbs, + parentClbs + }); + + // 5. 如果是 folder,先同步子树 + if (folderTypeList.includes(resource.type)) { + await syncChildrenPermission({ + resource, + collaborators, + ... + }); + } + + // 6. 如果处于继承态且有冲突,自动断开继承 + if (resource.inheritPermission && hasConflict) { + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: false }, + { session } + ); + } + + // 7. 更新协作者记录 + if (folderTypeList.includes(resource.type) || hasConflict) { + // folder 或冲突:整表重建 + await MongoResourcePermission.deleteMany({ resourceId }, { session }); + await MongoResourcePermission.insertMany(collaborators, { session }); + } else { + // 普通情况:增量更新 + for (const clb of changedClbs) { + if (clb.action === 'add') { + await MongoResourcePermission.create([clb], { session }); + } else if (clb.action === 'update') { + await MongoResourcePermission.updateOne( + { resourceId, ...clbId }, + { permission: clb.permission }, + { session } + ); + } else if (clb.action === 'delete') { + await MongoResourcePermission.deleteOne({ resourceId, ...clbId }, { session }); + } + } + } +}; +``` + +--- + +## 5. 继承冲突检测 + +### checkRoleUpdateConflict + +```typescript +export const checkRoleUpdateConflict = ({ + changedClbs, + parentClbs +}) => { + for (const changed of changedClbs) { + // 找到对应的父协作者 + const parentClb = parentClbs.find(p => sameClb(p, changed)); + + if (parentClb) { + // 如果修改了来自父级的协作者权限,或删除了父级协作者 + if ( + changed.action === 'delete' || + changed.permission !== parentClb.permission + ) { + return true; // 有冲突 + } + } + } + return false; +}; +``` + +### 冲突即断继承 + +**设计价值**: +1. 用户不需要先点"取消继承"再改协作者 +2. 直接改协作者就自动完成"打断继承"状态迁移 +3. 交互从"配置底层机制"变成"编辑最终结果" + +--- + +## 6. 为什么 folder 要"整表重建" + +folder 或继承态冲突时,采用"删除全部协作者记录,再插入新列表"。 + +**原因**:这两类场景里,"当前资源的协作者记录"已经不再只是"子级自定义增量",而是要转成一份新的"显式完整权限快照"。 + +```typescript +if (folderTypeList.includes(resource.type) || hasConflict) { + // 整表重建 + await MongoResourcePermission.deleteMany({ resourceId }, { session }); + await MongoResourcePermission.insertMany(collaborators, { session }); +} +``` + +--- + +## 7. 支持三类协作者 + +fastgpt-pro 的协作者管理同时支持: + +| 类型 | 字段 | 说明 | +|------|------|------| +| 团队成员 | tmbId | 个人级权限 | +| 成员组 | groupId | 组级权限 | +| 组织 | orgId | 组织级权限 | + +新资源接入时,必须同时支持这三类协作者。 diff --git a/.codex/skills/support/permission/add-permission/reference/pro-owner-transfer.md b/.codex/skills/support/permission/add-permission/reference/pro-owner-transfer.md new file mode 100644 index 0000000000..c448179b11 --- /dev/null +++ b/.codex/skills/support/permission/add-permission/reference/pro-owner-transfer.md @@ -0,0 +1,259 @@ +# Owner 转移机制 + +## 1. 接口入口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts +async function handler(req) { + // 只有 owner 能转移 + const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal + }); + + await changeOwner({ + changeOwnerType: '{resource}', + resourceId: {resource}._id, + newOwnerId: newOwnerTmbId, + oldOwnerId: {resource}.tmbId, + teamId: {resource}.teamId + }); +} +``` + +--- + +## 2. 通用 changeOwner 实现 + +```typescript +// fastgpt-pro/projects/app/src/service/core/changeOwner.ts +export const changeOwner = async ({ + changeOwnerType, + resourceId, + newOwnerId, + oldOwnerId, + teamId +}) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const { resourceModel, folderTypeList, resourceType } = getResourceConfig(changeOwnerType); + + // 1. 查询资源 + const resource = await resourceModel.findById(resourceId); + + // 2. 如果是 folder,获取整个子树 + const allResources = folderTypeList.includes(resource.type) + ? await getResourceTree(resource, resourceModel, folderTypeList) + : [resource]; + + // 3. 更新资源表的 tmbId + await updateResourceOwner(allResources, newOwnerId, resourceModel, session); + + // 4. 根资源断开继承 + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: false }, + { session } + ); + + // 5. 修正权限记录 + await fixPermissionRecords(allResources, oldOwnerId, newOwnerId, resourceType, session); + + await session.commitTransaction(); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } +}; +``` + +--- + +## 3. 更新资源表 Owner + +```typescript +const updateResourceOwner = async ( + allResources, + newOwnerId, + resourceModel, + session +) => { + // 根资源直接改 owner + await resourceModel.updateOne( + { _id: allResources[0]._id }, + { tmbId: newOwnerId }, + { session } + ); + + // 子资源:只改仍属于旧 owner 的 + const childResources = allResources.slice(1); + const oldOwnerChildren = childResources.filter( + r => String(r.tmbId) === String(oldOwnerId) + ); + + if (oldOwnerChildren.length > 0) { + await resourceModel.updateMany( + { _id: { $in: oldOwnerChildren.map(r => r._id) } }, + { tmbId: newOwnerId }, + { session } + ); + } +}; +``` + +--- + +## 4. 权限记录修正策略 + +```typescript +const fixPermissionRecords = async ( + allResources, + oldOwnerId, + newOwnerId, + resourceType, + session +) => { + const resourceIds = allResources.map(r => r._id); + + // 查询涉及的权限记录 + const permissions = await MongoResourcePermission.find({ + resourceType, + resourceId: { $in: resourceIds }, + tmbId: { $in: [oldOwnerId, newOwnerId] } + }); + + // 按资源分组 + const perByResource = groupBy(permissions, 'resourceId'); + + for (const [resourceId, pers] of Object.entries(perByResource)) { + const oldOwnerPer = pers.find(p => String(p.tmbId) === String(oldOwnerId)); + const newOwnerPer = pers.find(p => String(p.tmbId) === String(newOwnerId)); + + if (oldOwnerPer && newOwnerPer) { + // 情况1:两者都有记录 → 合并后只保留 newOwner + await MongoResourcePermission.updateOne( + { _id: newOwnerPer._id }, + { permission: Math.max(oldOwnerPer.permission, newOwnerPer.permission) }, + { session } + ); + await MongoResourcePermission.deleteOne( + { _id: oldOwnerPer._id }, + { session } + ); + } else if (oldOwnerPer && !newOwnerPer) { + // 情况2:只有 oldOwner 有记录 → 改成 newOwner + await MongoResourcePermission.updateOne( + { _id: oldOwnerPer._id }, + { tmbId: newOwnerId }, + { session } + ); + } + // 情况3:只有 newOwner 有记录 → 保持不变 + } +}; +``` + +### 注意 + +当前使用 `Math.max(oldPer, newPer)` 合并权限。这在 bitmask 设计下有潜在风险,因为数值更大不一定代表权限更强。 + +建议后续改成更明确的合并策略: + +```typescript +// 推荐做法 +const mergedPermission = oldOwnerPer.permission | newOwnerPer.permission; +``` + +--- + +## 5. Folder 子树处理 + +```typescript +const getResourceTree = async (root, resourceModel, folderTypeList) => { + const result = [root]; + const queue = [root._id]; + + while (queue.length) { + const parentId = queue.shift(); + + const children = await resourceModel.find({ + parentId, + teamId: root.teamId + }); + + for (const child of children) { + result.push(child); + // 只有 folder 才继续递归 + if (folderTypeList.includes(child.type)) { + queue.push(child._id); + } + } + } + + return result; +}; +``` + +--- + +## 6. 完整流程图 + +``` +Owner 转移请求 + │ + ▼ +┌──────────────────────┐ +│ 验证 OwnerPermission │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 查询资源(及子树) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 更新资源表 tmbId │ +│ (根资源 + 旧owner子) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 根资源断开继承 │ +│ inheritPermission: │ +│ false │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 修正权限记录 │ +│ oldOwner → newOwner │ +└──────────────────────┘ +``` + +--- + +## 7. 审计日志 + +Owner 转移是敏感操作,必须记录审计日志: + +```typescript +await addOperationLog({ + teamId, + tmbId, + operationType: 'changeOwner', + resourceType, + resourceId, + metadata: { + oldOwnerId, + newOwnerId, + resourceName: resource.name + } +}); +``` diff --git a/.codex/skills/system/api-development/SKILL.md b/.codex/skills/system/api-development/SKILL.md new file mode 100644 index 0000000000..d73787afff --- /dev/null +++ b/.codex/skills/system/api-development/SKILL.md @@ -0,0 +1,757 @@ +--- +name: api-development +description: FastGPT API 开发规范。重点强调使用 zod schema 定义入参和出参,在 API 文档中声明路由信息,编写对应的 OpenAPI 文档,以及在 API 路由中使用 schema.parse 进行验证。 +--- + +# FastGPT API 开发规范 + +> FastGPT 项目 API 路由开发的标准化指南,确保 API 的一致性、类型安全和文档完整性。 + +## 何时使用此技能 + +- 开发新的 Next.js API 路由 +- 修改现有 API 的入参或出参 +- 需要 API 类型定义和文档 +- 审查 API 相关代码 + +## 核心原则 + +### 🔴 必须遵守的规则 + +1. **所有 API 必须使用 zod schema 定义入参和出参** +2. **必须导出 schema 的 TypeScript 类型** +3. **必须在 schema 文件头部声明 API 信息(路由、方法、描述、标签)** +4. **入参必须使用 schema.parse() 验证** +5. **函数返回值必须使用 schema.parse() 验证** +6. **必须编写完整的 OpenAPI 文档** + +## 开发流程 + +### 步骤 1: 定义 Zod Schema 并声明 API + +**文件位置**: `packages/global/openapi/[module]/[api].ts` + +**文件头部必须声明 API 信息**: + +```typescript +import { z } from 'zod'; + +/* ============================================================================ + * API: 获取应用对话日志列表 + * Route: POST /api/core/app/logs/list + * Method: POST + * Description: 获取指定应用的对话日志列表,支持分页和多种筛选条件 + * Tags: ['App', 'Log', 'Read'] + * ============================================================================ */ + +// 入参 Schema +export const GetAppChatLogsBodySchema = PaginationSchema.extend({ + appId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '应用 ID' + }), + dateStart: z.union([z.string(), z.date()]).meta({ + example: '2024-01-01T00:00:00.000Z', + description: '开始时间' + }), + dateEnd: z.union([z.string(), z.date()]).meta({ + example: '2024-12-31T23:59:59.999Z', + description: '结束时间' + }), + sources: z.array(z.nativeEnum(ChatSourceEnum)).optional().meta({ + example: [ChatSourceEnum.api, ChatSourceEnum.online], + description: '对话来源筛选' + }) +}); + +// 导出入参类型 +export type getAppChatLogsBody = z.infer; + +// 出参 Schema +export const GetAppChatLogsResponseSchema = z.object({ + total: z.number().meta({ example: 100, description: '总记录数' }), + list: z.array(ChatLogItemSchema) +}); + +// 导出出参类型 +export type getAppChatLogsResponseType = z.infer; +``` + +**API 声明规范**: + +```typescript +/** + * 每个 API 文件必须在文件头部声明以下信息: + * + * 1. API 名称 (API): 简短的功能描述 + * 2. 路由 (Route): 完整的 API 路径 + * 3. 方法 (Method): HTTP 方法 (GET/POST/PUT/DELETE) + * 4. 描述 (Description): API 的详细功能说明 + * 5. 标签 (Tags): API 的分类标签数组 + * + * 标签示例: + * - 'App': 应用相关 API + * - 'User': 用户相关 API + * - 'Log': 日志相关 API + * - 'Read': 只读操作 + * - 'Write': 写入操作 + * - 'Delete': 删除操作 + */ +``` + +**Schema 定义规范**: + +#### ✅ 字段定义规范 + +```typescript +// ✅ 好的实践: 完整的 meta 信息 +export const GetUserSchema = z.object({ + userId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '用户 ID' + }), + email: z.string().email().meta({ + example: 'user@example.com', + description: '用户邮箱' + }), + age: z.number().int().positive().meta({ + example: 25, + description: '用户年龄' + }), + status: z.enum(['active', 'inactive']).meta({ + example: 'active', + description: '用户状态' + }) +}); + +// ❌ 不好的实践: 缺少 meta 信息 +export const GetUserSchemaBad = z.object({ + userId: z.string(), + email: z.string(), + age: z.number(), + status: z.string() +}); +``` + +#### ✅ 嵌套对象定义 + +```typescript +// 嵌套对象应该定义为独立的 Schema +export const AddressSchema = z.object({ + street: z.string().meta({ description: '街道地址' }), + city: z.string().meta({ description: '城市' }), + country: z.string().meta({ description: '国家' }) +}); + +export const CreateUserSchema = z.object({ + name: z.string().meta({ description: '用户名' }), + address: AddressSchema.meta({ description: '地址信息' }) +}); +``` + +#### ✅ 数组定义 + +```typescript +export const GetUserListResponseSchema = z.object({ + total: z.number().meta({ example: 100, description: '总数' }), + list: z.array( + z.object({ + id: z.string().meta({ description: '用户 ID' }), + name: z.string().meta({ description: '用户名' }) + }) + ).meta({ description: '用户列表' }) +}); +``` + +#### ✅ 可选字段 + +```typescript +export const UpdateUserSchema = z.object({ + userId: z.string().meta({ description: '用户 ID' }), + // 可选字段使用 .optional() + name: z.string().optional().meta({ description: '用户名' }), + // 或使用 .nullish() 允许 null 和 undefined + email: z.string().email().nullish().meta({ description: '用户邮箱' }) +}); +``` + +#### ✅ 分页 Schema + +```typescript +import { PaginationSchema } from '@fastgpt/global/openapi/api'; + +// 继承分页 Schema +export const GetUserListSchema = PaginationSchema.extend({ + // 添加额外的筛选字段 + keyword: z.string().optional().meta({ description: '搜索关键词' }), + status: z.enum(['active', 'inactive']).optional().meta({ description: '状态筛选' }) +}); +``` + +#### ✅ 多个 API 的 Schema 文件 + +```typescript +/* ============================================================================ + * API: 获取日志键 + * Route: GET /api/core/app/logs/keys + * Method: GET + * Description: 获取应用的日志配置键列表 + * Tags: ['App', 'Log', 'Read'] + * ============================================================================ */ + +export const GetLogKeysQuerySchema = z.object({ + appId: z.string().meta({ description: '应用 ID' }) +}); + +export const GetLogKeysResponseSchema = z.object({ + logKeys: z.array(AppLogKeysSchema).meta({ description: '日志键列表' }) +}); + +/* ============================================================================ + * API: 更新日志键 + * Route: POST /api/core/app/logs/keys + * Method: POST + * Description: 更新应用的日志配置键 + * Tags: ['App', 'Log', 'Write'] + * ============================================================================ */ + +export const UpdateLogKeysBodySchema = z.object({ + appId: z.string().meta({ description: '应用 ID' }), + logKeys: z.array(AppLogKeysSchema).meta({ description: '日志键列表' }) +}); +``` + +### 步骤 2: 实现 API 路由 + +**文件位置**: `projects/app/src/pages/api/[path]/[route].ts` + +**标准实现模板**: + +```typescript +import type { NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { + GetAppChatLogsBodySchema, + GetAppChatLogsResponseSchema, + type getAppChatLogsResponseType +} from '@fastgpt/global/openapi/...'; + +async function handler( + req: ApiRequestProps, + _res: NextApiResponse +): Promise { + // 🔴 步骤 1: 使用 schema.parse() 验证入参 + const { appId, dateStart, dateEnd, sources } = GetAppChatLogsBodySchema.parse(req.body); + + // 或对于 query 参数 + // const { param1, param2 } = YourAPIQuerySchema.parse(req.query); + + // 🔴 步骤 2: 业务逻辑处理 + const result = await yourBusinessLogic({ appId, dateStart, dateEnd, sources }); + + // 🔴 步骤 3: 使用 schema.parse() 验证出参 + return GetAppChatLogsResponseSchema.parse({ + list: result.list, + total: result.total + }); +} + +export default NextAPI(handler); +``` + +**完整示例**: + +```typescript +import type { NextApiResponse } from 'next'; +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { NextAPI } from '@/service/middleware/entry'; +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { + GetAppChatLogsBodySchema, + GetAppChatLogsResponseSchema, + type getAppChatLogsResponseType +} from '@fastgpt/global/openapi/core/app/log/api'; + +async function handler( + req: ApiRequestProps, + _res: NextApiResponse +): Promise { + // 🔴 1. 验证入参 + const { appId, dateStart, dateEnd, sources } = GetAppChatLogsBodySchema.parse(req.body); + + // 2. 权限验证 (如果需要) + await authApp({ + req, + authToken: true, + appId, + per: AppReadChatLogPerVal + }); + + // 3. 业务逻辑 + const { list, total } = await getChatLogsFromDB({ + appId, + dateStart, + dateEnd, + sources + }); + + // 🔴 4. 验证出参 + return GetAppChatLogsResponseSchema.parse({ + list, + total + }); +} + +export default NextAPI(handler); +``` + +### 步骤 3: 权限验证 (如需要) + +**使用 `authApp` 或其他权限验证函数**: + +```typescript +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { AppWritePerVal } from '@fastgpt/global/support/permission/app/constant'; + +async function handler(req: ApiRequestProps, res: NextApiResponse) { + const { appId } = YourAPIBodySchema.parse(req.body); + + // 权限验证 + await authApp({ + req, + authToken: true, + appId, + per: AppWritePerVal // 权限常量 + }); + + // 继续处理... +} +``` + +### 步骤 4: 错误处理 + +**使用统一的错误处理**: + +```typescript +import { APIError } from '@fastgpt/service/core/error/controller'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; + +async function handler(req: ApiRequestProps, res: NextApiResponse) { + try { + const { appId } = YourAPIBodySchema.parse(req.body); + + if (!appId) { + return Promise.reject(CommonErrEnum.missingParams); + } + + // 业务逻辑... + + } catch (error) { + // 统一错误处理 + return APIError(error)(req, res); + } +} +``` + +## 完整开发示例 + +### 场景: 创建用户 API + +**1. 定义 Schema** (`packages/global/openapi/core/user/api.ts`): + +```typescript +import { z } from 'zod'; + +/* ============================================================================ + * API: 创建用户 + * Route: POST /api/core/user/create + * Method: POST + * Description: 创建新用户,返回创建的用户信息 + * Tags: ['User', 'Write'] + * ============================================================================ */ + +// 入参 +export const CreateUserBodySchema = z.object({ + name: z.string().min(2).max(50).meta({ + example: 'Alice', + description: '用户名 (2-50 字符)' + }), + email: z.string().email().meta({ + example: 'alice@example.com', + description: '用户邮箱' + }), + age: z.number().int().positive().optional().meta({ + example: 25, + description: '用户年龄' + }), + avatar: z.string().url().optional().meta({ + example: 'https://example.com/avatar.jpg', + description: '头像 URL' + }) +}); + +export type createUserBodyType = z.infer; + +// 出参 +export const CreateUserResponseSchema = z.object({ + userId: z.string().meta({ example: '68ad85a7463006c963799a05', description: '用户 ID' }), + name: z.string().meta({ example: 'Alice', description: '用户名' }), + email: z.string().meta({ example: 'alice@example.com', description: '用户邮箱' }), + createdAt: z.date().meta({ example: '2024-01-01T00:00:00.000Z', description: '创建时间' }) +}); + +export type createUserResponseType = z.infer; +``` + +**2. 实现 API** (`projects/app/src/pages/api/core/user/create.ts`): + +```typescript +import type { NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { MongoUser } from '@fastgpt/service/core/user/schema'; +import { + CreateUserBodySchema, + CreateUserResponseSchema, + type createUserResponseType +} from '@fastgpt/global/openapi/core/user/api'; + +async function handler( + req: ApiRequestProps, + _res: NextApiResponse +): Promise { + // 🔴 验证入参 + const { name, email, age, avatar } = CreateUserBodySchema.parse(req.body); + + // 检查邮箱是否已存在 + const existingUser = await MongoUser.findOne({ email }); + if (existingUser) { + return Promise.reject('Email already exists'); + } + + // 创建用户 + const user = await MongoUser.create({ + name, + email, + age, + avatar, + createdAt: new Date() + }); + + // 🔴 验证出参 + return CreateUserResponseSchema.parse({ + userId: user._id.toString(), + name: user.name, + email: user.email, + createdAt: user.createdAt + }); +} + +export default NextAPI(handler); +``` + +## 审查检查清单 + +### 🔴 必须检查项 (阻塞性) + +**Schema 文件** (`packages/global/openapi/.../api.ts`): +- [ ] **API 声明**: 文件头部有 API 信息(路由、方法、描述、标签) +- [ ] **Schema 定义**: 入参和出参都使用 zod 定义 +- [ ] **类型导出**: 导出 `z.infer` 类型 +- [ ] **Meta 信息**: 所有字段都有 `description` 和 `example` + +**API 路由文件** (`projects/app/src/pages/api/.../route.ts`): +- [ ] **入参验证**: 使用 `Schema.parse(req.body)` 或 `parse(req.query)` +- [ ] **出参验证**: 使用 `Schema.parse(responseData)` +- [ ] **函数返回类型**: 函数返回值声明为导出的类型 +- [ ] **权限验证**: API 路由有相应的权限检查 (如需要) + +### 🟡 推荐检查项 (建议性) + +- [ ] **错误处理**: 使用 `APIError` 统一错误处理 +- [ ] **字段验证**: 使用 zod 的验证方法 (.min(), .max(), .email() 等) +- [ ] **可空字段**: 正确使用 `.optional()` 或 `.nullish()` +- [ ] **复用 Schema**: 相同结构抽取为独立 Schema +- [ ] **分页支持**: 列表 API 继承 `PaginationSchema` + +### 🟢 可选检查项 (优化性) + +- [ ] **字段顺序**: 字段按重要性排序 +- [ ] **Schema 复用**: 复用现有 Schema 减少重复 +- [ ] **注释**: 复杂逻辑添加注释 + +## 常见问题和解决方案 + +### 问题 1: 缺少 API 声明 + +**错误示例**: +```typescript +// ❌ 错误: 缺少 API 声明 +import { z } from 'zod'; + +export const GetUserSchema = z.object({ + id: z.string() +}); +``` + +**正确做法**: +```typescript +// ✅ 正确: 包含完整的 API 声明 +import { z } from 'zod'; + +/* ============================================================================ + * API: 获取用户信息 + * Route: GET /api/core/user/detail + * Method: GET + * Description: 根据 userId 获取用户详细信息 + * Tags: ['User', 'Read'] + * ============================================================================ */ + +export const GetUserSchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '用户 ID' + }) +}); +``` + +### 问题 2: 类型不匹配 + +**错误示例**: +```typescript +// ❌ 错误: 函数返回类型未声明 +async function handler(req: ApiRequestProps, res: NextApiResponse) { + const data = YourAPIBodySchema.parse(req.body); + return { success: true, data }; // 类型未声明 +} +``` + +**正确做法**: +```typescript +// ✅ 正确: 声明返回类型 +async function handler( + req: ApiRequestProps, + _res: NextApiResponse +): Promise { + const data = YourAPIBodySchema.parse(req.body); + + return YourAPIResponseSchema.parse({ + success: true, + data + }); +} +``` + +### 问题 3: 缺少 Meta 信息 + +**错误示例**: +```typescript +// ❌ 错误: 缺少 meta 信息 +export const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string() +}); +``` + +**正确做法**: +```typescript +// ✅ 正确: 完整的 meta 信息 +export const UserSchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '用户 ID' + }), + name: z.string().meta({ + example: 'Alice', + description: '用户名' + }), + email: z.string().email().meta({ + example: 'alice@example.com', + description: '用户邮箱' + }) +}); +``` + +### 问题 4: 未验证出参 + +**错误示例**: +```typescript +// ❌ 错误: 直接返回数据 +async function handler(req: ApiRequestProps, res: NextApiResponse) { + const { appId } = YourAPIBodySchema.parse(req.body); + const result = await getData(appId); + + return result; // 未验证出参结构 +} +``` + +**正确做法**: +```typescript +// ✅ 正确: 验证出参 +async function handler(req: ApiRequestProps, res: NextApiResponse) { + const { appId } = YourAPIBodySchema.parse(req.body); + const result = await getData(appId); + + return YourAPIResponseSchema.parse(result); +} +``` + +### 问题 5: Schema 复用不当 + +**不好做法**: +```typescript +// ❌ 重复定义相同的结构 +export const Schema1 = z.object({ + id: z.string(), + name: z.string(), + email: z.string() +}); + +export const Schema2 = z.object({ + id: z.string(), + name: z.string(), + email: z.string() +}); +``` + +**正确做法**: +```typescript +// ✅ 抽取公共 Schema +export const BaseUserSchema = z.object({ + id: z.string().meta({ description: 'ID' }), + name: z.string().meta({ description: '名称' }), + email: z.string().email().meta({ description: '邮箱' }) +}); + +export const Schema1 = z.object({ + user: BaseUserSchema +}); + +export const Schema2 = z.object({ + users: z.array(BaseUserSchema) +}); +``` + +## 快速参考 + +### API 声明模板 + +```typescript +/* ============================================================================ + * API: [简短功能描述] + * Route: [HTTP 方法] [完整路由路径] + * Method: [GET/POST/PUT/DELETE] + * Description: [详细功能说明] + * Tags: [['模块', '子模块', '操作类型']] + * ============================================================================ */ +``` + +### 常用标签 + +- **模块标签**: `App`, `User`, `Chat`, `Workflow`, `Dataset` +- **操作类型**: `Read`, `Write`, `Delete`, `Update` +- **其他**: `Admin`, `Public`, `Internal` + +### 常用 Zod 验证方法 + +```typescript +// 字符串 +z.string() // 字符串 + .min(2) // 最小长度 + .max(50) // 最大长度 + .email() // 邮箱格式 + .url() // URL 格式 + .uuid() // UUID 格式 + +// 数字 +z.number() // 数字 + .int() // 整数 + .positive() // 正数 + .min(0) // 最小值 + .max(100) // 最大值 + +// 布尔 +z.boolean() // 布尔值 + +// 日期 +z.date() // 日期对象 + .or(z.string()) // 或日期字符串 + +// 枚举 +z.enum(['active', 'inactive']) // 枚举值 +z.nativeEnum(MyEnum) // TypeScript 枚举 + +// 数组 +z.array(z.string()) // 字符串数组 + .min(1) // 最小长度 + .max(10) // 最大长度 + +// 可选 +z.string().optional() // 可选 (undefined) +z.string().nullish() // 可空 (undefined | null) + +// 对象 +z.object({ // 对象 + name: z.string(), + age: z.number() +}) + +// 继承 +PaginationSchema.extend({ // 扩展 + keyword: z.string() +}) + +// 联合类型 +z.union([z.string(), z.number()]) // 字符串或数字 +z.discriminator('type', { // 判别联合 + type1: Type1Schema, + type2: Type2Schema +}) +``` + +### Meta 字段说明 + +```typescript +z.string().meta({ + example: 'value', // 示例值 (必填) + description: '字段说明' // 字段描述 (必填) +}) +``` + +### TypeScript 类型导出 + +```typescript +// Schema 定义 +export const UserSchema = z.object({ + id: z.string(), + name: z.string() +}); + +// 导出类型 (命名规范: camelCase) +export type userType = z.infer; + +// 或使用 PascalCase +export type UserType = z.infer; +``` + +## 参考资源 + +### 项目内示例 + +- **API Schema 示例**: `/Volumes/code/fastgpt-pro/FastGPT/packages/global/openapi/core/app/log/api.ts` +- **API 实现示例**: `/Volumes/code/fastgpt-pro/FastGPT/projects/app/src/pages/api/core/app/logs/list.ts` +- **分页 Schema**: `packages/global/openapi/api.ts` + +### 相关文档 + +- **Zod 官方文档**: https://zod.dev/ +- **FastGPT API 规范**: `.claude/skills/pr-review/fastgpt-style-guide.md` +- **PR Review 审查维度**: `.claude/skills/pr-review/code-quality-standards.md` + +--- + +**Version**: 1.0 +**Last Updated**: 2026-01-27 +**Maintainer**: FastGPT Development Team diff --git a/.codex/skills/system/local-pr-review/SKILL.md b/.codex/skills/system/local-pr-review/SKILL.md new file mode 100644 index 0000000000..713d8ec521 --- /dev/null +++ b/.codex/skills/system/local-pr-review/SKILL.md @@ -0,0 +1,236 @@ +--- +name: local-pr-review +description: 当用户请求对本地未提交的代码(或本地分支特定 commit 等)进行代码审查时触发该 skill,对本地变更进行审查。 +--- + +# When to Use This Skill + +当用户请求本地审查时触发。本技能支持对以下三种变更范围进行环境友好的深度审查: +1. **未提交的变更 (Uncommitted)**: 审查工作区中所有尚未提交的修改(包括已暂存和未暂存)。 +2. **特定的历史 Commit (Recent Commits)**: 选定检查最近几次 commit(如 `HEAD~N`)的改动情况。 +3. **主干差异对比 (Main Branch Diff)**: 与 `main` 等主干分支对比,整体审查当前特性的增量细节。 + +# Local Review 本地代码审查技能 + +> 在本地开发环境中,利用全量代码库的上下文优势,全面审查代码变更的质量、安全性、性能和架构设计,并自动评估跨文件影响,提供专业的改进建议。 + +## 审查范围与启动方式 (Scope Definitions) + +你需要根据用户的指令意图,判断并选择对应的比对策略来拉取代码。**务必使用 `-U15` 等参数增加局部上下文视野**。 + +### 模式 1:未提交审查 (Uncommitted Changes) +```bash +# 获取工作区未暂存及已暂存的混合全量 diff +git diff HEAD -U15 +``` + +### 模式 2:特定历史 Commit 审查 (Recent Commits) +```bash +# 审查最后一次 commit +git diff HEAD~1 HEAD -U15 + +# 审查最近 3 次的整体 commit 变更 (数字可根据用户指令替换) +git diff HEAD~3 HEAD -U15 +``` + +### 模式 3:主干差异审查 (Main Branch Diff) +```bash +# 审查当前特性分支相较于 main 主干的所有的增量代码 +git diff main...HEAD -U15 +``` + +## 工具集成 + +### 使用 git CLI 与本地工具加速审查 + +在本地环境审查时,相较于 PR 审查的局限点,我们要极大发挥能“到处跑、到处看”的优势。 + +```bash +# 获取已修改的文件列表及状态 +git status -s + +# 将宽泛上下文的 diff 存入临时文件便于分析 +git diff -U15 > /tmp/local_changes.diff + +# 跨文件依赖与上下文搜索(寻找受修改影响的引用方) +git grep "修改的函数名或类型" +``` + +### 本地测试与验证代码 + +本地审查时可以直接执行验证流程,以结果作为审查依据: + +```bash +# 运行单元测试 +pnpm test + +# 运行代码规范检查 +pnpm lint + +# 运行类型检查以捕获变更导致的隐藏/全局类型错误 +pnpm tsc --noEmit + +# 启动开发服务器通过 UI 进行直观验证 +pnpm dev +``` + +### 常见命令参考 + +```bash +# 获取具体的 diff 内容 +git diff --name-only +git diff + +# 追溯某段代码的演进历史 +git blame + +# 获取最近的 commit 记录 +git log -n 5 --oneline +``` + +## 审查流程 + +### 1. 信息与上下文收集阶段 (⭐本地环境核心增强) + +**切勿仅凭零碎的补丁代码(Diff chunks)盲目审查,务必建立完整的上下文认知:** + +自动执行/指导执行以下步骤: + +```bash +# 1. 确认当前的修改状态及涉及的文件 +git status + +# 2. 获取具有充足上下文的变更细节(-U15 或更大) +git diff -U15 + +# 3. 阅读全量文件(上下文增强): +# 对于重点修改的文件,不要只看 diff,而是查看整个源文件,理解数据流向和全貌。 +cat + +# 4. 分析跨文件影响(上下文追踪): +# 如果改动涉及到变量名变更、导出方法的增删改、Props 类型更改, +# 必须使用本地搜索查出其在项目中的所有调用点: +git grep "" +``` + +### 2. 多维度代码审查 + +基于完整的调用链和文件上下文,按照以下维度进行系统性审查: + +#### 维度 1: 基本代码质量标准 📐 + +通用的代码质量标准,适用于所有项目: + +- **安全性**: 输入验证、权限检查、注入防护、敏感信息保护 +- **正确性**: 错误处理、边界条件、类型安全(**结合上下文,看传参方是否提供了正确数据**) +- **性能**: 算法复杂度、数据库优化、内存管理(**结合上下文,审查循环逻辑或多次重渲染问题**) +- **可测试性**: 测试覆盖、测试质量、Mock 使用 + +📖 **详细指南**: [code-quality-standards.md](./code-quality-standards.md) + +#### 维度 2: FastGPT 风格规范 🎨 + +FastGPT 项目特定的代码规范和约定: + +- **API 路由开发**: 路由定义、权限验证、错误处理: [API 路由开发规范](./style/api.md) +- **前端组件开发**: TypeScript + React、Chakra UI、状态管理: [前端组件开发规范](./style/front.md) +- **数据库操作**: Model 定义、查询优化、索引设计: [数据库操作规范](./style/db.md) +- **包结构与依赖**: 依赖方向、导入规范、类型导出: [包结构与依赖规范](./style/package.md) +- **日志与可观测性**: 统一日志分类、结构化字段、敏感信息、OTEL 导出等审查标准: [日志review 标准](./style/logger.md) + +#### 维度 3: 常见问题检查清单 🔍 + +快速识别和修复常见问题模式: + +- **TypeScript 问题**: any 类型滥用、类型定义不完整、不安全断言 +- **异步错误处理**: 未处理 Promise、错误信息丢失、静默失败 +- **React 性能**: 不必要的重渲染、渲染中创建对象、缺少 memoization +- **工作流节点**: isEntry 未重置、交互历史未清理、白名单遗漏 +- **安全漏洞**: 注入攻击、XSS、文件上传漏洞 + +📖 **详细清单**: [common-issues-checklist.md](./common-issues-checklist.md) + +### 3. 生成并输出综合审查报告 + +本地审查输出主要通过对话框返回整体审查报告和行级建议。 + +#### 步骤 1: 结合上下文深度分析代码并准备评论 + +在详细审查阶段,记录之前通过额外探索收集的上下文论据: +- **全局视野**:这个改动是否打破了使用该模块的其他文件的运行? +- **文件路径**: 如 `packages/service/core/workflow/dispatch.ts` +- **行号 / 变更块**: 如 `L142-L150` +- **问题类型**: 🔴严重 / 🟡改进 / 🟢优化 +- **评论内容**: 具体的问题描述和基于整体上下文推导出的深层建议 + +#### 步骤 2: 汇总代码审查评论 + +将所有的行级代码和上下文关联分析结果整合成一份连贯的内容: + +```markdown +### 📍 代码级深层意见 + +#### `packages/service/core/workflow/dispatch.ts` +- **L142**: 🔴 **严重跨文件隐患**: 这里更改了返回值格式,但通过 `git grep` 追踪发现调用方的接收逻辑(如 `file_B.ts L30`)未进行相应的适配,将导致生产环境崩溃。 + **建议**: 修改本处的兼容逻辑,或一并更新相关的调用方代码: + ```typescript + // 提供结合了调用关系的修复代码 + ``` +- **L150**: 🟡 **逻辑重构优化**: 结合该文件的大环境,该段正则其实在多个地方共享,建议提取到外部公共目录中复用。 +``` + +#### 步骤 3: 生成整体审查报告 + +在对话框中提供类似以下的整体审查报告格式: + +```markdown +# Local Review 代码审查报告 + +## 📊 审查面与追踪范围 +- **本地变更类型**: 未暂存 / 已暂存 / 与某分支差异 +- **变更统计**: +{additions} -{deletions} 行 +- **涉及直接修改文件**: {files.length} 个 +- **延伸上下文排查文件**: {context_files.length} 个(评估了相关的外链影响) + +## ✅ 亮点与优点 +{列出做得好的地方} + +## ⚠️ 问题汇总 + +### 🔴 严峻环境问题 ({count} 个,必须修复) +{简要列出每个严重问题,并指向下文的详细行级评论} + +### 🟡 建议与重构项 ({count} 个) +{简要列出每个建议} + +### 🟢 局部优化选项 ({count} 个) +{简要列出优化建议} + +--- +{在此处插入包含上下文增强分析内容的 "📍 代码级深层意见"} +--- + +## 🧪 本地测试验证策略 +{基于本次上下文排查所发现的影响范围,给出建议:例如某个隐性关联页面的渲染测试等} + +## 💬 总体评价 +- **代码质量**: ⭐⭐⭐⭐☆ (4/5) +- **安全性**: ⭐⭐⭐⭐⭐ (5/5) +- **性能**: ⭐⭐⭐⭐☆ (4/5) +- **全栈协调性**: ⭐⭐⭐⭐☆ (4/5) (反映修改对全局代码影响程度的好坏) + +## 🚀 审查结论 +{建议: 可以提交 / 修改后提交 / 建议将调用方文件的修复一起囊括} +``` + +#### 步骤 4: 响应用户并提供报告 + +输出或保存完整的 Markdown 报告,必要时提出主动帮用户把发现的联调问题(调用方 bug 等)以命令形式进行顺手修复的建议。 + +#### 本地审查命令快速补救参考: + +| 场景 | 命令 | +|------|------| +| 修改后需重新审查暂存区 | `git diff --cached` | +| 检测改动后是否出现死类型 | `pnpm tsc --noEmit` | +| 放弃某个被污染的本地改动 | `git checkout -- ` | diff --git a/.codex/skills/system/local-pr-review/code-quality-standards.md b/.codex/skills/system/local-pr-review/code-quality-standards.md new file mode 100644 index 0000000000..8de0039576 --- /dev/null +++ b/.codex/skills/system/local-pr-review/code-quality-standards.md @@ -0,0 +1,612 @@ +# 维度 1: 代码质量标准 + +> 通用的代码质量标准,适用于所有项目。这些标准关注代码的正确性、安全性、性能和可维护性。 + +## 目录 + +- [1. 安全性标准](#1-安全性标准) +- [2. 正确性标准](#2-正确性标准) +- [3. 性能标准](#3-性能标准) +- [4. 可测试性标准](#4-可测试性标准) +- [5. 可维护性标准](#5-可维护性标准) +- [6. 文档标准](#6-文档标准) + +--- + +## 1. 安全性标准 + +### 1.1 输入验证 🔴 **必须检查** + +**原则**: 永远不要信任用户输入,所有输入必须验证 + +**检查清单**: +- [ ] 所有用户输入都经过验证和清理 +- [ ] 文件上传验证类型、大小、扩展名 +- [ ] URL 参数和查询参数验证 +- [ ] 使用白名单而不是黑名单 +- [ ] 数组/对象参数验证长度和结构 + +**示例**: +```typescript +// ❌ 不安全: 直接使用用户输入 +async function searchUsers(query: string) { + return await db.users.find({ name: query }); +} + +// ✅ 安全: 验证和清理输入 +async function searchUsers(query: string): Promise { + // 验证输入 + if (!query || query.length > 100) { + throw new Error('Invalid query parameter'); + } + + // 清理输入: 移除特殊字符 + const sanitizedQuery = query.replace(/[^\w\s]/g, ''); + + return await db.users + .find({ + name: { $regex: sanitizedQuery, $options: 'i' } + }) + .limit(10) // 限制结果数量 + .toArray(); +} +``` + +### 1.2 权限检查 🔴 **必须检查** + +**原则**: 所有需要授权的操作都必须验证用户权限 + +**检查清单**: +- [ ] 所有 API 路由都有权限验证 +- [ ] 验证用户对资源的所有权 +- [ ] 敏感操作需要额外验证 (2FA, 确认密码) +- [ ] 遵循最小权限原则 + +**示例**: +```typescript +// ❌ 不安全: 没有权限验证 +export default async function handler(req: NextAPIRequest, res: NextAPIResponse) { + const userId = req.body.userId; + const user = await db.users.findById(userId); + res.json(user); +} + +// ✅ 安全: 验证权限 +import { parseHeaderCert } from '@fastgpt/global/support/permission/controller'; + +export default async function handler(req: NextAPIRequest, res: NextAPIResponse) { + // 1. 验证身份 + const { userId: authUserId } = await parseHeaderCert(req); + + // 2. 验证权限 (只能访问自己的数据) + const requestedUserId = req.body.userId; + + // 管理员可以访问所有用户,普通用户只能访问自己 + if (authUserId !== requestedUserId && !isAdmin(authUserId)) { + throw new Error('Permission denied'); + } + + const user = await db.users.findById(requestedUserId); + + // 3. 过滤敏感字段 + const { password, ...safeUser } = user; + res.json(safeUser); +} +``` + +### 1.3 注入防护 🔴 **必须检查** + +**原则**: 防止 SQL/NoSQL 注入、命令注入、XSS 等攻击 + +**检查清单**: +- [ ] 使用参数化查询,不拼接字符串 +- [ ] 避免直接使用 `eval` 或 `Function` 构造函数 +- [ ] 对用户输出进行 HTML 转义 +- [ ] 使用 DOMPurify 等库清理 HTML + +**示例**: +```typescript +// ❌ NoSQL 注入风险 +async function findUser(query: any) { + return await db.users.findOne(query); + // 如果 query = { "$gt": "" }, 会返回所有用户 +} + +// ✅ 使用参数化和验证 +async function findUser(email: string): Promise { + // 验证 email 格式 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error('Invalid email format'); + } + + return await db.users.findOne({ email }); +} +``` + +### 1.4 敏感信息保护 🔴 **必须检查** + +**原则**: 不要在代码中硬编码敏感信息,不要在日志中暴露敏感数据 + +**检查清单**: +- [ ] 无硬编码的密钥、token、密码 +- [ ] 敏感信息使用环境变量 +- [ ] 错误日志不包含敏感信息 +- [ ] API 响应过滤敏感字段 +- [ ] 密码使用哈希存储 + +**示例**: +```typescript +// ❌ 不安全: 硬编码密钥 +const API_KEY = 'sk-1234567890abcdef'; +const DB_PASSWORD = 'mypassword'; + +// ✅ 安全: 使用环境变量 +const API_KEY = process.env.OPENAI_API_KEY; +if (!API_KEY) { + throw new Error('OPENAI_API_KEY is required'); +} + +// ❌ 不安全: 日志包含敏感信息 +console.log('User logged in:', { + userId: user.id, + email: user.email, + password: user.password // 密码被记录! +}); + +// ✅ 安全: 过滤敏感字段 +const { password, ...safeUser } = user; +console.log('User logged in:', { + userId: safeUser.id, + email: safeUser.email +}); +``` + +--- + +## 2. 正确性标准 + +### 2.1 错误处理 🔴 **必须检查** + +**原则**: 所有可能失败的操作都必须有错误处理 + +**检查清单**: +- [ ] 所有 async/await 都有 try-catch +- [ ] Promise 都有 .catch() 处理 +- [ ] 错误信息清晰且有用 +- [ ] 区分业务错误和系统错误 +- [ ] 错误日志包含上下文信息 + +**示例**: +```typescript +// ❌ 不好的错误处理 +async function deleteUser(userId: string) { + await db.users.deleteOne({ id: userId }); + // 没有错误处理,如果用户不存在会怎样? +} + +// ✅ 好的错误处理 +async function deleteUser(userId: string): Promise { + try { + const result = await db.users.deleteOne({ id: userId }); + + if (result.deletedCount === 0) { + throw new Error(`User not found: ${userId}`); + } + + console.log(`User ${userId} deleted successfully`); + } catch (error) { + if (error instanceof Error) { + console.error(`Failed to delete user ${userId}:`, error); + throw new Error(`Delete user failed: ${error.message}`); + } + throw error; + } +} +``` + +### 2.2 类型安全 🟡 **推荐检查** + +**原则**: 充分利用 TypeScript 类型系统,避免类型错误 + +**检查清单**: +- [ ] 避免使用 `any` 类型 +- [ ] 函数参数和返回值有明确的类型 +- [ ] 复杂类型使用 interface 或 type 定义 +- [ ] 使用类型守卫而不是类型断言 +- [ ] 启用 strict 模式 + +**示例**: +```typescript +// ❌ 不好的类型使用 +async function fetchData(id: any): any { + const result: any = await db.collection('data').findOne({ id }); + return result; +} + +// ✅ 好的类型使用 +interface UserData { + id: string; + name: string; + email: string; + createdAt: Date; +} + +async function fetchData(id: string): Promise { + const result = await db.collection('data').findOne({ id }); + return result; +} +``` + +### 2.3 边界条件 🟡 **推荐检查** + +**原则**: 考虑边界情况和异常输入 + +**检查清单**: +- [ ] 空值处理 (null, undefined, '') +- [ ] 空数组/空对象处理 +- [ ] 极限值处理 (0, 最大值, 最小值) +- [ ] 并发和竞争条件 +- [ ] 资源耗尽情况 + +**示例**: +```typescript +// ❌ 未处理边界条件 +function getFirstItem(items: T[]): T { + return items[0]; // 如果数组为空会返回 undefined +} + +// ✅ 处理边界条件 +function getFirstItem(items: T[]): T | undefined { + if (items.length === 0) { + return undefined; + } + return items[0]; +} + +// 或使用可选链 +function getFirstItem(items: T[]): T | undefined { + return items[0]; +} + +// 使用时 +const first = getFirstItem(items); +if (first) { + // 安全使用 first +} +``` + +--- + +## 3. 性能标准 + +### 3.1 算法复杂度 🟡 **推荐检查** + +**原则**: 避免不必要的嵌套循环,使用合适的数据结构 + +**检查清单**: +- [ ] 避免嵌套循环 (O(n²) 或更差) +- [ ] 大数据集使用合适的算法 +- [ ] 使用 Set/Map 优化查找操作 +- [ ] 分页处理大数据集 + +**示例**: +```typescript +// ❌ 性能问题: O(n²) +function findDuplicates(arr: string[]): string[] { + const duplicates: string[] = []; + for (let i = 0; i < arr.length; i++) { + for (let j = i + 1; j < arr.length; j++) { + if (arr[i] === arr[j]) { + duplicates.push(arr[i]); + } + } + } + return duplicates; +} + +// ✅ 优化后: O(n) +function findDuplicates(arr: string[]): string[] { + const seen = new Set(); + const duplicates: string[] = []; + + for (const item of arr) { + if (seen.has(item)) { + duplicates.push(item); + } else { + seen.add(item); + } + } + + return duplicates; +} +``` + +### 3.2 数据库查询 🟡 **推荐检查** + +**原则**: 避免 N+1 查询,使用索引优化查询 + +**检查清单**: +- [ ] 避免 N+1 查询问题 +- [ ] 使用索引优化查询 +- [ ] 只查询需要的字段 +- [ ] 使用分页 (skip + limit) +- [ ] 批量操作使用 bulkWrite + +**示例**: +```typescript +// ❌ N+1 查询问题 +const users = await db.users.find({}).toArray(); +for (const user of users) { + const posts = await db.posts.find({ userId: user.id }).toArray(); + user.posts = posts; +} + +// ✅ 使用 $in 操作 +const users = await db.users.find({}).toArray(); +const userIds = users.map(u => u.id); +const posts = await db.posts.find({ userId: { $in: userIds } }).toArray(); + +// 构建映射 +const postsByUser = new Map(); +posts.forEach(post => { + if (!postsByUser.has(post.userId)) { + postsByUser.set(post.userId, []); + } + postsByUser.get(post.userId)!.push(post); +}); + +// 关联数据 +users.forEach(user => { + user.posts = postsByUser.get(user.id) || []; +}); +``` + +### 3.3 内存管理 🟢 **可选检查** + +**原则**: 避免内存泄漏,及时清理资源 + +**检查清单**: +- [ ] 避免内存泄漏 (事件监听器、定时器) +- [ ] 及时清理不再使用的大对象 +- [ ] 使用流处理大文件 +- [ ] 避免不必要的闭包 + +--- + +## 4. 可测试性标准 + +### 4.1 测试覆盖 🟡 **推荐检查** + +**原则**: 新功能必须有测试,核心功能要有充分测试,确保单元测试覆盖率在 90% 以上。 + +**检查清单**: +- [ ] 新功能有对应的单元测试 +- [ ] 核心业务逻辑有集成测试 +- [ ] 关键路径有 E2E 测试 +- [ ] 测试覆盖主要场景 (正常和异常) + +**示例**: +```typescript +describe('UserService', () => { + describe('createUser', () => { + it('should create user successfully with valid data', async () => { + // Arrange + const userData = { + name: 'Test User', + email: 'test@example.com' + }; + + // Act + const user = await createUser(userData); + + // Assert + expect(user).toBeDefined(); + expect(user.id).toBeDefined(); + expect(user.name).toBe(userData.name); + }); + + it('should throw error with duplicate email', async () => { + // Arrange + const userData = { + name: 'Test User', + email: 'existing@example.com' + }; + await createUser(userData); + + // Act & Assert + await expect(createUser(userData)).rejects.toThrow('Duplicate email'); + }); + + it('should throw error with invalid email', async () => { + // Arrange + const userData = { + name: 'Test User', + email: 'invalid-email' + }; + + // Act & Assert + await expect(createUser(userData)).rejects.toThrow('Invalid email'); + }); + }); +}); +``` + +### 4.2 测试质量 🟢 **可选检查** + +**原则**: 测试应该是独立的、可重复的、快速的 + +**检查清单**: +- [ ] 测试用例独立,不依赖执行顺序 +- [ ] 测试用例可重复执行 +- [ ] 测试运行快速 (隔离慢操作) +- [ ] 测试命名清晰描述测试意图 +- [ ] 使用 AAA 模式 (Arrange, Act, Assert) + +--- + +## 5. 可维护性标准 + +### 5.1 代码组织 🟡 **推荐检查** + +**原则**: 代码应该易于理解、修改和扩展 + +**检查清单**: +- [ ] 函数/模块职责单一 +- [ ] 代码重复已抽取 +- [ ] 函数长度合理 (一般 < 50 行) +- [ ] 文件结构清晰 +- [ ] 命名清晰表达意图 + +**示例**: +```typescript +// ❌ 职责不单一,函数过长 +async function processUser(userId: string) { + const user = await db.users.findById(userId); + if (!user) throw new Error('User not found'); + + const orders = await db.orders.find({ userId }).toArray(); + const totalAmount = orders.reduce((sum, order) => sum + order.amount, 0); + + const recommendations = await generateRecommendations(user); + const notifications = await buildNotifications(user, recommendations); + + await sendEmail(user.email, notifications); + await updateLastLogin(userId); + + return { user, orders, totalAmount, recommendations }; +} + +// ✅ 职责单一,易于测试 +async function getUserProfile(userId: string) { + const user = await db.users.findById(userId); + if (!user) throw new Error('User not found'); + return user; +} + +async function getUserOrders(userId: string) { + return await db.orders.find({ userId }).toArray(); +} + +async function calculateTotalAmount(orders: Order[]) { + return orders.reduce((sum, order) => sum + order.amount, 0); +} + +async function processUser(userId: string) { + const user = await getUserProfile(userId); + const orders = await getUserOrders(userId); + const totalAmount = calculateTotalAmount(orders); + + return { user, orders, totalAmount }; +} +``` + +### 5.2 命名规范 🟢 **可选检查** + +**原则**: 命名应该清晰表达意图,遵循团队约定 + +**检查清单**: +- [ ] 变量名清晰表达用途 +- [ ] 函数名使用动词开头 +- [ ] 布尔值变量使用 is/has/should 前缀 +- [ ] 常量使用 UPPER_SNAKE_CASE +- [ ] 类/接口使用 PascalCase + +**示例**: +```typescript +// ❌ 不好的命名 +const d = new Date(); +const temp1 = getUser(); +const flag = checkUser(); + +// ✅ 好的命名 +const currentDate = new Date(); +const currentUser = getUser(); +const isAuthenticated = checkUser(); +``` + +--- + +## 6. 文档标准 + +### 6.1 注释质量 🟢 **可选检查** + +**原则**: 注释应该解释"为什么"而不是"是什么" + +**检查清单**: +- [ ] 复杂逻辑有清晰注释 +- [ ] 注释解释设计决策 +- [ ] 公共 API 有 JSDoc 注释 +- [ ] TODO/FIXME 有跟踪 issue + +**示例**: +```typescript +// ❌ 不好的注释: 重复代码 +// 获取用户 +const user = await getUser(userId); + +// ✅ 好的注释: 解释原因 +// 使用缓存避免重复查询数据库 +const user = await getUserWithCache(userId); + +// ❌ 不好的注释: 没有解释 +// 重试 3 次 +for (let i = 0; i < 3; i++) { + try { + return await operation(); + } catch (error) { + // 继续尝试 + } +} + +// ✅ 好的注释: 解释设计决策 +// 重试 3 次处理临时网络故障 +// 使用指数退避避免服务器过载 +for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await operation(); + } catch (error) { + if (attempt === 3) throw error; + await sleep(Math.pow(2, attempt) * 1000); + } +} +``` + +### 6.2 API 文档 🔴 **必选检查** + +所有修改到的 API 都需要用 zod 来进行类型声明以及编写对应的 OpenAPI 文档。 + +参考: [api-development.md](../common/skills/api-development/SKILL.md) + +--- + +## 快速检查表 + +### 🔴 必须检查项 (阻塞性) + +- [ ] **输入验证**: 所有用户输入都经过验证 +- [ ] **权限验证**: API 路由都有权限检查 +- [ ] **注入防护**: 使用参数化查询 +- [ ] **敏感信息**: 无硬编码密钥 +- [ ] **错误处理**: 所有异步操作有错误处理 + +### 🟡 推荐检查项 (建议性) + +- [ ] **类型安全**: 避免使用 `any` +- [ ] **边界条件**: 处理空值和边界情况 +- [ ] **算法复杂度**: 避免嵌套循环 +- [ ] **数据库查询**: 避免 N+1 查询 +- [ ] **测试覆盖**: 新功能有测试 +- [ ] **代码组织**: 职责单一,无重复代码 + +### 🟢 可选检查项 (优化性) + +- [ ] **命名规范**: 命名清晰表达意图 +- [ ] **注释质量**: 复杂逻辑有注释 +- [ ] **API 文档**: 公共 API 有 JSDoc +- [ ] **内存管理**: 避免内存泄漏 + +--- + +**Version**: 1.0 +**Last Updated**: 2026-01-27 +**Maintainer**: FastGPT Development Team diff --git a/.codex/skills/system/local-pr-review/common-issues-checklist.md b/.codex/skills/system/local-pr-review/common-issues-checklist.md new file mode 100644 index 0000000000..288e4de6c2 --- /dev/null +++ b/.codex/skills/system/local-pr-review/common-issues-checklist.md @@ -0,0 +1,833 @@ +# 维度 3: 常见问题检查清单 + +> 快速识别和修复常见问题模式。这个清单帮助审查者快速发现代码中的典型问题和反模式。 + +## 目录 + +- [1. TypeScript 问题](#1-typescript-问题) +- [2. 异步错误处理问题](#2-异步错误处理问题) +- [3. React 性能问题](#3-react-性能问题) +- [4. 工作流节点问题](#4-工作流节点问题) +- [5. 安全漏洞问题](#5-安全漏洞问题) +- [6. 代码重复问题](#6-代码重复问题) +- [7. 环境配置问题](#7-环境配置问题) + +--- + +## 1. TypeScript 问题 + +### 🔴 1.1 滥用 any 类型 + +**问题识别**: +- 变量声明为 `any` 类型 +- 函数参数或返回值使用 `any` +- 类型断言过度使用 + +**快速修复**: +```typescript +// ❌ 问题代码 +async function fetchData(id: any): any { + const result: any = await db.collection('data').findOne({ id }); + return result; +} + +// ✅ 修复方案 +interface UserData { + id: string; + name: string; + email: string; +} + +async function fetchData(id: string): Promise { + const result = await db.collection('data').findOne({ id }); + return result; +} +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🟡 1.2 类型定义不完整 + +**问题识别**: +- 使用 `object` 作为类型 +- 参数结构不明确 +- 缺少必要的类型定义 + +**快速修复**: +```typescript +// ❌ 问题代码 +function updateUser(id: string, data: object) { + return db.users.updateOne({ id }, { $set: data }); +} + +// ✅ 修复方案 +type UpdateUserData = { + name?: string; + email?: string; + avatar?: string; +}; + +function updateUser(id: string, data: UpdateUserData) { + return db.users.updateOne({ id }, { $set: data }); +} +``` + +**审查建议**: 🟡 建议改进 + +--- + +### 🟡 1.3 不安全的类型断言 + +**问题识别**: +- 双重断言 (`as any as Type`) +- 断言后没有验证 +- 过度依赖类型断言 + +**快速修复**: +```typescript +// ❌ 问题代码 +const value = data as any as User; + +// ✅ 修复方案 1: 类型守卫 +function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'name' in value + ); +} + +if (isUser(data)) { + // 安全使用 data 作为 User +} + +// ✅ 修复方案 2: 使用 zod 验证 +import { z } from 'zod'; + +const UserSchema = z.object({ + id: z.string(), + name: z.string() +}); + +const result = UserSchema.parse(data); +``` + +**审查建议**: 🟡 建议改进 + +--- + +## 2. 异步错误处理问题 + +### 🔴 2.1 未处理的 Promise rejection + +**问题识别**: +- async 函数没有 try-catch +- 没有 .catch() 处理 +- 错误可能静默失败 + +**快速修复**: +```typescript +// ❌ 问题代码 +async function fetchUserData(userId: string) { + const response = await fetch(`/api/users/${userId}`); + const data = await response.json(); + return data; +} + +// ✅ 修复方案 +async function fetchUserData(userId: string): Promise { + try { + const response = await fetch(`/api/users/${userId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + if (error instanceof Error) { + console.error(`Failed to fetch user ${userId}:`, error); + throw new Error(`User fetch failed: ${error.message}`); + } + throw error; + } +} +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🟡 2.2 错误信息丢失 + +**问题识别**: +- catch 中创建新的错误但不保留原始错误 +- 错误日志信息不完整 +- 难以调试和追踪问题 + +**快速修复**: +```typescript +// ❌ 问题代码 +async function saveUser(user: User) { + try { + await db.users.insertOne(user); + } catch (error) { + throw new Error('Save failed'); // 原始错误丢失 + } +} + +// ✅ 修复方案 +async function saveUser(user: User) { + try { + await db.users.insertOne(user); + } catch (error) { + if (error instanceof Error) { + console.error('Database error:', error); + throw new Error(`Save user failed: ${error.message}`, { + cause: error + }); + } + throw error; + } +} +``` + +**审查建议**: 🟡 建议改进 + +--- + +### 🟡 2.3 静默忽略错误 + +**问题识别**: +- 空的 catch 块 +- 使用 void 忽略 Promise +- 没有说明原因的忽略 + +**快速修复**: +```typescript +// ❌ 问题代码 +async function cleanup() { + try { + await deleteTempFiles(); + } catch (error) { + // 空的 catch,错误被忽略 + } +} + +// ✅ 修复方案 +async function cleanup() { + try { + await deleteTempFiles(); + } catch (error) { + // 至少记录错误日志 + console.error('Cleanup failed:', error); + // 如果确实需要忽略,添加注释说明原因 + // 错误被忽略是因为清理失败不应影响主流程 + } +} +``` + +**审查建议**: 🟡 建议改进 (必须有明确的注释说明) + +--- + +## 3. React 性能问题 + +### 🟢 3.1 不必要的组件重渲染 + +**问题识别**: +- 父组件状态变化导致子组件不必要的重渲染 +- 子组件是昂贵的计算或渲染 +- 没有使用 React.memo + +**快速修复**: +```typescript +// ❌ 问题代码 +const Parent = ({ items }: { items: Item[] }) => { + const [count, setCount] = useState(0); + + return ( + <> + + {items.map(item => ( + + ))} + + ); +}; + +// ✅ 修复方案 +const ExpensiveChild = React.memo(function ExpensiveChild({ data }: { data: Item }) { + // 昂贵的计算或渲染 + return
{/* ... */}
; +}); + +const Parent = ({ items }: { items: Item[] }) => { + const [count, setCount] = useState(0); + + return ( + <> + + {items.map(item => ( + + ))} + + ); +}; +``` + +**审查建议**: 🟢 可选优化 + +--- + +### 🟡 3.2 渲染中创建新对象/函数 + +**问题识别**: +- JSX 中使用箭头函数 +- JSX 中创建对象字面量 +- 导致子组件不必要的重渲染 + +**快速修复**: +```typescript +// ❌ 问题代码 +const MyComponent = ({ items }: { items: Item[] }) => { + return ( + <> + {items.map(item => ( + handleClick(item.id)} // 每次渲染创建新函数 + options={{ enable: true, mode: 'edit' }} // 每次渲染创建新对象 + /> + ))} + + ); +}; + +// ✅ 修复方案 +const MyComponent = ({ items }: { items: Item[] }) => { + const handleClick = useCallback((id: string) => { + // 处理逻辑 + }, []); + + const options = useMemo(() => ({ + enable: true, + mode: 'edit' + }), []); + + return ( + <> + {items.map(item => ( + handleClick(item.id)} + options={options} + /> + ))} + + ); +}; +``` + +**审查建议**: 🟡 建议改进 + +--- + +### 🟡 3.3 昂贵计算未缓存 + +**问题识别**: +- 复杂的数组操作 (sort, filter, map 链式调用) +- 每次渲染都重新计算 +- 计算结果在渲染间不变 + +**快速修复**: +```typescript +// ❌ 问题代码 +const ExpensiveList = ({ items }: { items: Item[] }) => { + // 每次渲染都重新计算 + const sortedItems = items.sort((a, b) => a.value - b.value); + const filteredItems = sortedItems.filter(item => item.active); + + return
    {filteredItems.map(item =>
  • {item.name}
  • )}
; +}; + +// ✅ 修复方案 +const ExpensiveList = ({ items }: { items: Item[] }) => { + const sortedItems = useMemo(() => + [...items].sort((a, b) => a.value - b.value), + [items] + ); + + const filteredItems = useMemo(() => + sortedItems.filter(item => item.active), + [sortedItems] + ); + + return
    {filteredItems.map(item =>
  • {item.name}
  • )}
; +}; +``` + +**审查建议**: 🟡 建议改进 + +--- + +## 4. 工作流节点问题 + +### 🔴 4.1 isEntry 标志未重置 + +**问题识别**: +- 交互节点执行逻辑中第二阶段没有设置 `node.isEntry = false` +- 节点可能重复执行 +- 交互节点功能异常 + +**快速修复**: +```typescript +// ❌ 问题代码 +export const dispatchInteractiveNode = async (props: Props) => { + const { isEntry } = props.node; + + if (!isEntry) { + return { interactive: { ... } }; + } + + // 处理用户输入 + return { data: { ... } }; + // 忘记重置 isEntry! +}; + +// ✅ 修复方案 +export const dispatchInteractiveNode = async (props: Props) => { + const { node, lastInteractive } = props; + const { isEntry } = node; + + // 第一阶段: 返回交互请求 + if (!isEntry || lastInteractive?.type !== 'interactiveType') { + return { + [DispatchNodeResponseKeyEnum.interactive]: { + type: 'interactiveType', + params: { /* ... */ } + } + }; + } + + // 第二阶段: 处理用户输入 + node.isEntry = false; // 🔴 必须: 重置入口标志 + + return { + data: { /* ... */ }, + [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2) + }; +}; +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🔴 4.2 交互历史未清理 + +**问题识别**: +- 交互节点返回值中没有 `rewriteHistories` +- 用户会看到交互过程中产生的临时消息 + +**快速修复**: +```typescript +// ❌ 问题代码 +export const dispatchInteractiveNode = async (props: Props) => { + // 处理用户输入后 + return { + data: { result: userInput } + // 忘记清理交互对话的历史记录 + }; +}; + +// ✅ 修复方案 +export const dispatchInteractiveNode = async (props: Props) => { + const { histories } = props; + + // 处理用户输入后 + return { + data: { result: userInput }, + // 移除交互对话的历史记录 (用户问题 + 系统响应 = 2条) + [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2) + }; +}; +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🔴 4.3 isEntry 白名单遗漏 + +**问题识别**: +- 新增交互节点但未更新 isEntry 白名单 +- 节点在恢复时 isEntry 被重置,导致流程错误 + +**快速修复**: +```typescript +// ❌ 问题代码 +// packages/service/core/workflow/dispatch/index.ts + +runtimeNodes.forEach((item) => { + if ( + item.flowNodeType !== FlowNodeTypeEnum.userSelect && + item.flowNodeType !== FlowNodeTypeEnum.formInput + // 新的交互节点类型未添加到白名单 + ) { + item.isEntry = false; + } +}); + +// ✅ 修复方案 +runtimeNodes.forEach((item) => { + if ( + item.flowNodeType !== FlowNodeTypeEnum.userSelect && + item.flowNodeType !== FlowNodeTypeEnum.formInput && + item.flowNodeType !== FlowNodeTypeEnum.yourNodeType // 新增 + ) { + item.isEntry = false; + } +}); +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +## 5. 安全漏洞问题 + +### 🔴 5.1 SQL/NoSQL 注入 + +**问题识别**: +- 用户输入直接用于数据库查询 +- 没有输入验证和清理 +- 使用字符串拼接构建查询 + +**快速修复**: +```typescript +// ❌ 问题代码 +async function searchUsers(query: string) { + return await db.users.find({ name: query }); + // 如果 query = { "$gt": "" },会返回所有用户 +} + +// ✅ 修复方案 +async function searchUsers(query: string): Promise { + if (!query || query.length > 100) { + throw new Error('Invalid query'); + } + + const sanitizedQuery = query.replace(/[^\w\s]/g, ''); + + return await db.users.find({ + name: { + $regex: sanitizedQuery, + $options: 'i' + } + }).limit(10).toArray(); +} +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🔴 5.2 XSS 攻击 + +**问题识别**: +- 使用 `dangerouslySetInnerHTML` +- 用户输入直接渲染到 HTML +- 没有 HTML 转义 + +**快速修复**: +```typescript +// ❌ 问题代码 +const UserProfile = ({ user }: { user: User }) => { + return ( +
+

{user.name}

+

+

+ ); +}; + +// ✅ 修复方案 +import DOMPurify from 'dompurify'; + +const UserProfile = ({ user }: { user: User }) => { + const cleanBio = DOMPurify.sanitize(user.bio); + + return ( +
+

{user.name}

+

+

+ ); +}; + +// 或更安全的方案 +const UserProfile = ({ user }: { user: User }) => { + return ( +
+

{user.name}

+

{user.bio}

// React 自动转义 +
+ ); +}; +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +### 🔴 5.3 文件上传漏洞 + +**问题识别**: +- 没有文件类型验证 +- 没有文件大小限制 +- 没有扩展名白名单 + +**快速修复**: +```typescript +// ❌ 问题代码 +app.post('/upload', async (req, res) => { + const file = req.body.file; + await fs.writeFile(`/uploads/${file.name}`, file.data); + res.json({ success: true }); +}); + +// ✅ 修复方案 +import { extname } from 'path'; + +const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.pdf']; +const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +app.post('/upload', async (req, res) => { + const file = req.body.file; + + // 验证文件大小 + if (file.size > MAX_FILE_SIZE) { + return res.status(400).json({ error: 'File too large' }); + } + + // 验证 MIME 类型 + if (!ALLOWED_MIMES.includes(file.mimetype)) { + return res.status(400).json({ error: 'Invalid file type' }); + } + + // 验证扩展名 + const ext = extname(file.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return res.status(400).json({ error: 'Invalid file extension' }); + } + + const safeName = `${Date.now()}-${Math.random().toString(36).substr(2)}${ext}`; + await fs.writeFile(`/uploads/${safeName}`, file.data); + + res.json({ success: true, filename: safeName }); +}); +``` + +**审查建议**: 🔴 严重问题,必须修复 + +--- + +## 6. 代码重复问题 + +### 🟡 6.1 重复的逻辑 + +**问题识别**: +- 相同或相似的代码出现在多处 +- 复制粘贴的代码 +- 修改 bug 时需要改多处 + +**快速修复**: +```typescript +// ❌ 问题代码 +function validateEmail1(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +function validateEmail2(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +// ✅ 修复方案 +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function validateEmail(email: string): boolean { + return EMAIL_REGEX.test(email); +} +``` + +**审查建议**: 🟡 建议改进 + +--- + +### 🟡 6.2 重复的组件结构 + +**问题识别**: +- 多个组件有相似的结构和布局 +- 只有细微差别 +- 可以抽取共享逻辑或样式 + +**快速修复**: +```typescript +// ❌ 问题代码 +const UserList1 = ({ users }: { users: User[] }) => { + return ( + + + {users.map(user => ( + + {user.name} + + ))} + + + ); +}; + +// ✅ 修复方案 +interface ListProps { + items: T[]; + renderItem: (item: T) => React.ReactNode; +} + +const GenericList = ({ items, renderItem }: ListProps) => { + return ( + + + {items.map((item, index) => ( + + {renderItem(item)} + + ))} + + + ); +}; + +const UserList = ({ users }: { users: User[] }) => { + return ( + {user.name}} + /> + ); +}; +``` + +**审查建议**: 🟡 建议改进 + +--- + +## 7. 环境配置问题 + +### 🔴 7.1 硬编码配置 + +**问题识别**: +- 配置值直接写在代码中 +- 密钥、token 硬编码 +- 不同环境无法灵活配置 + +**快速修复**: +```typescript +// ❌ 问题代码 +const API_KEY = 'sk-1234567890abcdef'; +const DB_URL = 'mongodb://localhost:27017/myapp'; + +// ✅ 修复方案 +const API_KEY = process.env.OPENAI_API_KEY; +const DB_URL = process.env.MONGODB_URL; + +if (!API_KEY) { + throw new Error('OPENAI_API_KEY is required'); +} +``` + +**审查建议**: 🔴 严重问题 (特别是敏感信息),必须修复 + +--- + +### 🟡 7.2 环境变量未验证 + +**问题识别**: +- 直接使用环境变量而不验证 +- 没有默认值或类型转换 +- 缺少必需的环境变量检查 + +**快速修复**: +```typescript +// ❌ 问题代码 +const config = { + apiKey: process.env.API_KEY, + port: parseInt(process.env.PORT), + debug: process.env.DEBUG === 'true' +}; + +// ✅ 修复方案 +const getConfig = () => { + const apiKey = process.env.API_KEY; + if (!apiKey) { + throw new Error('API_KEY environment variable is required'); + } + + const port = parseInt(process.env.PORT || '3000', 10); + if (isNaN(port)) { + throw new Error('PORT must be a valid number'); + } + + return { + apiKey, + port, + debug: process.env.DEBUG === 'true' + }; +}; + +const config = getConfig(); +``` + +**审查建议**: 🟡 建议改进 + +--- + +## 快速识别检查表 + +### 🔴 严重问题 (必须修复) + +- [ ] 滥用 `any` 类型 +- [ ] 未处理的 Promise rejection +- [ ] 工作流节点 `isEntry` 未重置 +- [ ] 硬编码敏感信息 +- [ ] SQL/NoSQL 注入漏洞 +- [ ] XSS 攻击漏洞 +- [ ] 文件上传无验证 + +### 🟡 建议改进 (推荐修复) + +- [ ] 类型定义不完整 +- [ ] 错误信息丢失 +- [ ] React 不必要的重渲染 +- [ ] 环境变量未验证 +- [ ] 代码重复 + +### 🟢 可选优化 (锦上添花) + +- [ ] 进一步性能优化 +- [ ] 代码简化 +- [ ] 类型守卫优化 + +--- + +**Version**: 1.0 +**Last Updated**: 2026-01-27 +**Maintainer**: FastGPT Development Team diff --git a/.codex/skills/system/local-pr-review/style/api.md b/.codex/skills/system/local-pr-review/style/api.md new file mode 100644 index 0000000000..97537de10d --- /dev/null +++ b/.codex/skills/system/local-pr-review/style/api.md @@ -0,0 +1,96 @@ + +# API 路由开发规范 + +FastGPT 使用 Next.js API Routes,需要遵循特定的开发模式。 + +### 2.1 路由定义 + +**文件位置**: `projects/app/src/pages/api/` + +**审查要点**: +- ✅ 路由文件使用命名导出,不支持默认导出 +- ✅ 使用 `NextAPIRequest` 和 `NextAPIResponse` 类型 +- ✅ 支持的 HTTP 方法明确 (`GET`, `POST`, `PUT`, `DELETE`) +- ✅ 返回统一的响应格式 + +**示例**: +```typescript +import type { NextAPIRequest, NextAPIResponse } from '@fastgpt/service/type/next'; +import { APIError } from '@fastgpt/service/core/error/controller'; + +export default async function handler(req: NextAPIRequest, res: NextAPIResponse) { + try { + if (req.method !== 'POST') { + throw new Error('Method not allowed'); + } + + // 处理逻辑... + const result = await processData(req.body); + + res.json(result); + } catch (error) { + APIError(error)(req, res); + } +} +``` + +### 2.2 类型合约 + +**文件位置**: `packages/global/openapi/` + +**审查要点**: +- ✅ API 合约定义在 OpenAPI 规范文件中 +- ✅ 请求参数有完整的类型定义 +- ✅ 响应格式有完整的类型定义 +- ✅ 错误响应有说明 + +### 2.3 业务逻辑 + +**文件位置**: +- 通用逻辑: `packages/service/` +- 项目特定逻辑: `projects/app/src/service/` + +**审查要点**: +- ✅ 业务逻辑与 API 路由分离 +- ✅ 服务函数有明确的类型定义 +- ✅ 错误处理统一 + +### 2.4 权限验证 + +**审查要点**: +- ✅ 所有 API 路由都有权限验证 (除了公开端点) +- ✅ 使用 `parseHeaderCert` 解析认证头 +- ✅ 验证用户对资源的所有权 +- ✅ 敏感操作需要额外验证 + +**示例**: +```typescript +import { parseHeaderCert } from '@fastgpt/global/support/permission/controller'; + +export default async function handler(req: NextAPIRequest, res: NextAPIResponse) { + try { + // 解析认证头 + const { userId, teamId } = await parseHeaderCert(req); + + // 验证权限 + const resource = await Resource.findById(resourceId); + if (!resource || resource.userId !== userId) { + throw new Error('Permission denied'); + } + + // 继续处理... + } catch (error) { + APIError(error)(req, res); + } +} +``` + +### 2.5 错误处理 + +**审查要点**: +- ✅ 使用 try-catch 包裹所有异步操作 +- ✅ 使用 `APIError` 统一错误响应 +- ✅ 错误信息不暴露敏感数据 +- ✅ HTTP 状态码正确 + +--- diff --git a/.codex/skills/system/local-pr-review/style/db.md b/.codex/skills/system/local-pr-review/style/db.md new file mode 100644 index 0000000000..d41edb8c83 --- /dev/null +++ b/.codex/skills/system/local-pr-review/style/db.md @@ -0,0 +1,68 @@ +# 数据库操作规范 + +FastGPT 使用 MongoDB (Mongoose) 和 PostgreSQL。 + +## 4.1 Model 定义 + +**文件位置**: `packages/service/common/mongo/schema/` + +**审查要点**: +- ✅ Schema 定义使用 TypeScript 泛型 +- ✅ 必要的字段添加索引 +- ✅ 敏感字段加密存储 +- ✅ 定义虚拟字段和实例方法 + +**示例**: +```typescript +import { mongoose, Schema } from '@fastgpt/service/common/mongo'; + +const UserSchema = new Schema({ + username: { type: String, required: true, unique: true }, + password: { type: String, required: true, select: false }, // 默认不查询 + email: { type: String, required: true }, + createdAt: { type: Date, default: Date.now } +}); + +// 索引 +UserSchema.index({ username: 1 }); +UserSchema.index({ email: 1 }); + +// 虚拟字段 +UserSchema.virtual('fullName').get(function() { + return `${this.firstName} ${this.lastName}`; +}); + +export const User = mongoose.model('User', UserSchema); +``` + +## 4.2 查询操作 + +**审查要点**: +- ✅ 使用参数化查询防止注入 +- ✅ 避免 N+1 查询 +- ✅ 使用 projection 只查询需要的字段 +- ✅ 大结果集使用分页 +- ✅ 异步操作有错误处理 + +**示例**: +```typescript +// ❌ 不好的实践 +const users = await User.find({}).toArray(); // 可能返回大量数据 + +// ✅ 好的实践 +const users = await User.find({}) + .project({ username: 1, email: 1 }) // 只查询需要的字段 + .limit(20) // 限制结果数量 + .skip(page * 20) + .toArray(); +``` + +## 4.3 错误处理 + +**审查要点**: +- ✅ 数据库操作使用 try-catch +- ✅ 处理重复键错误 (code 11000) +- ✅ 处理连接错误 +- ✅ 错误日志包含上下文信息 + +--- diff --git a/.codex/skills/system/local-pr-review/style/front.md b/.codex/skills/system/local-pr-review/style/front.md new file mode 100644 index 0000000000..f45b685417 --- /dev/null +++ b/.codex/skills/system/local-pr-review/style/front.md @@ -0,0 +1,88 @@ + +# 前端组件开发规范 + +FastGPT 使用 React + TypeScript + Chakra UI。 + +## 3.1 组件结构 + +**审查要点**: +- ✅ 使用函数式组件和 Hooks +- ✅ 组件使用 `React.memo` 优化性能 +- ✅ Props 有明确的类型定义 +- ✅ 使用 TypeScript type 而不是 interface (项目约定) + +**示例**: +```typescript +import React from 'react'; +import { Box, Button } from '@chakra-ui/react'; + +type YourComponentProps = { + title: string; + onClick: () => void; + disabled?: boolean; +}; + +export const YourComponent = React.memo(function YourComponent({ + title, + onClick, + disabled = false +}: YourComponentProps) { + return ( + + + + ); +}); +``` + +## 3.2 状态管理 + +**审查要点**: +- ✅ 本地状态使用 `useState` +- ✅ 全局状态使用 Zustand store +- ✅ 表单状态使用 `useForm` (react-hook-form) +- ✅ 复杂状态逻辑使用 `useReducer` + +## 3.3 样式规范 + +**审查要点**: +- ✅ 优先使用 Chakra UI props +- ✅ 响应式设计使用 Chakra UI 的断点系统 +- ✅ 自定义样式放在 `styles/theme.ts` +- ✅ 避免内联样式 + +**示例**: +```typescript +// ❌ 不好的实践 + + +// ✅ 好的实践 + +``` + +## 3.4 国际化 + +**审查要点**: +- ✅ 所有用户可见文本使用 `i18nT` +- ✅ 翻译 key 使用命名空间 +- ✅ 动态文本使用插值 + +**示例**: +```typescript +import { i18nT } from '@fastgpt/web/i18n/utils'; + +const message = i18nT('user:welcome', { name: userName }); +``` + +## 3.5 性能优化 + +**审查要点**: +- ✅ 列表渲染使用 key +- ✅ 大列表使用虚拟化 +- ✅ 避免在渲染中创建新对象/函数 +- ✅ 使用 `useMemo` 缓存计算结果 +- ✅ 使用 `useCallback` 缓存函数 + +--- diff --git a/.codex/skills/system/local-pr-review/style/logger.md b/.codex/skills/system/local-pr-review/style/logger.md new file mode 100644 index 0000000000..fee6429c6a --- /dev/null +++ b/.codex/skills/system/local-pr-review/style/logger.md @@ -0,0 +1,114 @@ +# 日志与可观测性审查标准 + +FastGPT 后端统一使用 `@fastgpt/service/common/logger`。日志需要可检索、可聚合、可回放,且避免敏感信息泄露。 + +## 1. 统一 Logger 接入 + +**审查要点**: +- ✅ 服务端统一使用 `@fastgpt/service/common/logger`,避免 `console.log` +- ✅ 启动入口只初始化一次 `configureLogger()` +- ✅ 使用 `getLogger(LogCategories.XXX)` 指定分类 +- ✅ 未经讨论不要自定义 category 字符串数组 + +**示例**: +```ts +import { configureLogger, getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +await configureLogger(); +const logger = getLogger(LogCategories.SYSTEM); +logger.info('System initialized successfully'); +``` + +## 2. 分类(Category)选择 + +**审查要点**: +- ✅ `SYSTEM` 用于系统级初始化与全局状态 +- ✅ `INFRA.*` 用于数据库/缓存/队列/存储等 +- ✅ `HTTP.*` 用于请求、响应、请求错误 +- ✅ `MODULE.*` 用于业务模块(参考 `pages/api` 路径) +- ✅ 缺少分类时补充到 `packages/service/common/logger/categories.ts` + +**问题示例**: +```ts +// ❌ 不规范:自定义字符串分类 +const logger = getLogger(['custom', 'random']); +``` + +## 3. 结构化日志与消息规范 + +**审查要点**: +- ✅ 消息短、稳定、可检索 +- ✅ 关键字段放在结构化对象中(id、状态、耗时、数量) +- ✅ 避免 `JSON.stringify` 拼接到消息里 +- ✅ 避免在消息中包含用户输入或大段文本 + +**示例**: +```ts +// ✅ 推荐 +logger.info('Vector queue task finished', { taskId, durationMs, count }); + +// ❌ 不推荐 +logger.info(`Task finished: ${JSON.stringify({ taskId, durationMs, count })}`); +``` + +## 4. 错误日志标准 + +**审查要点**: +- ✅ 使用 `error` 字段记录 `Error` 对象,保留堆栈 +- ✅ 错误日志包含关键上下文(teamId、datasetId、jobId 等) +- ✅ 捕获后必须记录或向上抛出,避免静默失败 + +**示例**: +```ts +try { + await doSomething(); +} catch (error) { + logger.error('Do something failed', { error, teamId, datasetId }); + throw error; +} +``` + +## 5. 日志等级规范 + +**审查要点**: +- ✅ `info` 用于阶段开始/完成 +- ✅ `warn` 用于可恢复异常 +- ✅ `error` 用于失败或影响流程的异常 +- ✅ 高频循环日志必须使用 `debug/trace` + +**示例**: +```ts +logger.info('Training started', { datasetId }); +logger.debug('Training progress', { datasetId, step, total }); +logger.warn('Retrying batch', { batchId, retryLeft }); +logger.error('Training failed', { datasetId, error }); +``` + +## 6. 请求/任务链路上下文 + +**审查要点**: +- ✅ HTTP 请求日志应带 `requestId`,优先使用 `withContext` +- ✅ 队列/定时任务日志应带 `jobId`/`queueName` +- ✅ 跨模块调用尽量保持同一上下文字段名 + +**示例**: +```ts +return withContext({ requestId }, async () => { + logger.info('Request received', { requestId, method, url }); +}); +``` + +## 7. 敏感信息与 OTEL + +**审查要点**: +- ✅ 禁止输出 token、密钥、密码、完整对话内容 +- ✅ 必须记录敏感信息时做脱敏或截断 +- ✅ 需要阻止 OTEL 导出时,可添加 `fastgpt` 属性 + +**示例**: +```ts +logger.warn('Payload truncated for debug', { + fastgpt: true, + payloadPreview: payload.slice(0, 200) +}); +``` diff --git a/.codex/skills/system/local-pr-review/style/package.md b/.codex/skills/system/local-pr-review/style/package.md new file mode 100644 index 0000000000..f75185f64d --- /dev/null +++ b/.codex/skills/system/local-pr-review/style/package.md @@ -0,0 +1,52 @@ +# 5. 包结构与依赖规范 + +FastGPT 是一个 monorepo,使用 pnpm workspaces。 + +## 5.1 包结构 + +``` +packages/ +├── global/ # 类型、常量、工具函数 (无运行时依赖) +├── service/ # 后端服务、数据库模型 (依赖 global) +└── web/ # 前端组件、样式、i18n (依赖 global) + +projects/ +├── app/ # NextJS 应用 (依赖所有 packages) +├── sandbox/ # NestJS 沙箱服务 (独立应用) +└── mcp_server/ # MCP 服务器 (独立应用) +``` + +## 5.2 依赖规则 + +**审查要点**: +- ✅ `packages/global/` 无任何运行时依赖 +- ✅ `packages/service/` 只依赖 `packages/global/` +- ✅ `packages/web/` 只依赖 `packages/global/` +- ✅ `projects/app/` 可以依赖所有 packages +- ✅ 独立项目 (sandbox, mcp_server) 最小化依赖 + +## 5.3 导入规范 + +**审查要点**: +- ✅ 使用项目别名导入: `@fastgpt/global`, `@fastgpt/service`, `@fastgpt/web` +- ✅ 避免相对路径导入跨包的文件 +- ✅ 导入路径使用 index 简化 + +**示例**: +```typescript +// ❌ 不好的导入 +import { UserType } from ../../../../../packages/global/core/user/type.d.ts; + +// ✅ 好的导入 +import { UserType } from '@fastgpt/global/core/user/type'; +``` + +## 5.4 类型导出 + +**审查要点**: +- ✅ 公共类型必须导出 +- ✅ 类型文件使用 `.d.ts` 扩展名 +- ✅ 复杂类型放在独立的类型文件 +- ✅ 使用 `export type` 导出类型 + +--- diff --git a/.codex/skills/system/pr-review/SKILL.md b/.codex/skills/system/pr-review/SKILL.md new file mode 100644 index 0000000000..32b34c2198 --- /dev/null +++ b/.codex/skills/system/pr-review/SKILL.md @@ -0,0 +1,205 @@ +--- +name: pr-review +description: 当用户传入一个 review 的 pr 链接时候,触发该 skill,对 pr 进行代码审查。 +--- + +# PR Review 代码审查技能 + +> 按阶段对 Pull Request 进行系统性审查,先验证需求理解与逻辑正确性,再并行进行多维度质量检测,最后提交审查报告。 + +--- + +## 步骤 0:拉取代码 + +使用以下命令**无需切换分支**,直接使用 PR 编号即可: + +```bash +# 获取 PR 基本信息 +gh pr view --json number,title,body,author,state,headRefName,baseRefName,additions,deletions,files + +# 获取完整 diff +gh pr diff + +# 查看 commit 历史 +gh pr view --json commits --jq '.commits[].messageHeadline' + +# 检查 CI 状态 +gh pr checks +``` + +如需在本地运行 **tsc / 单元测试**,使用 `git worktree` 创建独立目录,**不影响当前分支**: + +```bash +# 1. 拉取 PR 代码到临时分支 +git fetch upstream pull//head:pr/ + +# 2. 在独立目录检出(与当前工作区完全隔离) +git worktree add ~/pr-worktrees/pr- pr/ + +# 3. 进入该目录安装依赖、运行测试 +cd ~/pr-worktrees/pr- +pnpm install +pnpm tsc --noEmit # 类型检查 +pnpm test # 单元测试 + +# 4. 审查完毕后清理 +cd - +git worktree remove ~/pr-worktrees/pr- +git branch -D pr/ +``` + +--- + +## 第一阶段:需求理解与逻辑验证 + +**目标**:理解本次 PR 的意图,并通过阅读代码来推理测试用例是否能通过。 + +### 1.1 需求总结 + +阅读 PR 标题、描述和 diff,用自己的语言总结: +- 本次 PR 的核心目的是什么? +- 改动了哪些关键模块? +- 对外部接口或数据结构是否有变更? + +### 1.2 测试推理 + +充当测试角色,针对 PR 的核心改动,**提出 3~5 个关键测例**,然后在代码中找到对应逻辑进行推理校验: + +- 正常路径:主流程是否按预期运行? +- 边界条件:空值、极大值、并发等边界是否被处理? +- 异常路径:错误输入或依赖失败时行为是否正确? + +**校验方式**:直接阅读相关代码,推理每个测例的执行路径,确认逻辑能通过。如果代码中存在对应单元测试,也一并检查。 + +### ⚠️ 阶段门控 + +如果第一阶段发现**需求理解存在严重歧义**或**核心逻辑存在明显错误**(如测例推理无法通过),**立即跳过后续阶段,直接进入"提交评论"步骤**,在报告中标明阻塞原因,请求作者澄清或修复后再继续审查。 + +--- + +## 第二到第六阶段:并行深度审查 + +第一阶段通过后,**以下五个阶段可以并行执行**,彼此独立,互不依赖。 + +--- + +### 第二阶段:后端代码质量 🔒 + +聚焦后端(`packages/service/`、`projects/app/src/pages/api/`、`projects/app/src/service/`)的质量问题,完成以下检查清单: + +- [] [后端安全](./backend-quality/security.md) +- [] [后端错误处理](./backend-quality/error-handling.md) +- [] [后端性能](./backend-quality/performance.md) + +--- + +### 第三阶段:前端代码质量 🎨 + +聚焦前端(`projects/app/src/`、`packages/web/`)的质量问题,完成以下检查清单: + +- [] [React 性能](./frontend-quality/react-performance.md) +- [] [前端安全](./frontend-quality/security.md) +- [] [TypeScript 质量](./frontend-quality/typescript.md) + +### 第四阶段:代码风格规范 📐 + +对照 FastGPT 各项规范逐一检查,完成以下检查清单: + +- [] [API 路由开发规范](../../design/api/index.md) +- [] [前端组件规范](./style/front.md) +- [] [数据库规范](./style/db.md) +- [] [包结构规范](./style/package.md) +- [] [日志规范](./style/logger.md) +- [] [Service 解耦规范](./style/service-decoupling.md) +- [] [语法风格规范](../../code/syntax.md) + +--- + +### 第五阶段:测试覆盖 🧪 + +- 新增的核心业务逻辑是否有对应单元测试(`test/` 或 `projects/*/test/`)? +- 测试是否覆盖了正常路径、边界条件和错误路径? +- 如果没有测试,评估缺失测试的风险等级(高风险逻辑无测试应标记为 🔴)。 + +--- + +### 第六阶段:回归风险检测 🔄 + +- **接口兼容性**:对外 API 是否有 breaking change(字段删除、类型变更、行为变更)? +- **数据库兼容性**:schema 变更是否向后兼容?旧数据是否需要迁移? +- **依赖影响**:修改的公共模块(`packages/global/`、`packages/service/`)是否会影响其他调用方? +- **配置变更**:是否新增了必填配置项,且未提供默认值或迁移说明? + +--- + +## 最终步骤:提交审查报告 + +### 收集所有阶段的问题 + +汇总各阶段发现的问题,按严重程度分类: +- 🔴 **严重**(必须修复才能合并) +- 🟡 **建议**(改进代码质量) +- 🟢 **可选**(优化建议) + +### 提交行级代码评论 + +GitHub CLI 不支持行级评论,需通过 GitHub API 提交: + +```bash +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +cat > /tmp/review-data.json << 'EOF' +{ + "body": "## 📊 代码审查总结\n\n详细意见请查看下方行级评论。", + "event": "COMMENT", + "comments": [ + { + "path": "文件路径", + "line": 行号, + "body": "🔴 **问题描述**\n\n**建议**:\n```typescript\n// 修复示例\n```" + } + ] +} +EOF + +gh api repos/$REPO/pulls//reviews \ + --method POST \ + --input /tmp/review-data.json +``` + +### 审查报告模板 + +```markdown +# PR Review: {PR Title} + +## 📋 需求理解 +{第一阶段总结:PR 的核心目的与改动范围} + +## 🧪 逻辑验证 +{列出提出的测例及推理结果,标明是否通过} + +## ⚠️ 问题汇总 + +### 🔴 严重问题({count} 个,必须修复) +{问题列表,行级评论已标注} + +### 🟡 建议改进({count} 个) +{问题列表} + +### 🟢 可选优化({count} 个) +{问题列表} + +## ✅ 做得好的地方 +{列出值得肯定的实现} + +## 🚀 审查结论 +{通过 / 需修改 / 阻塞(说明原因)} +``` + +### 命令参考 + +| 场景 | 命令 | +|------|------| +| 请求修改 | `gh pr review --request-changes --body-file /tmp/review.md` | +| 批准 PR | `gh pr review --approve` | +| 仅评论 | `gh pr review --comment --body-file /tmp/review.md` | diff --git a/.codex/skills/system/pr-review/backend-quality/error-handling.md b/.codex/skills/system/pr-review/backend-quality/error-handling.md new file mode 100644 index 0000000000..0e3a08d98f --- /dev/null +++ b/.codex/skills/system/pr-review/backend-quality/error-handling.md @@ -0,0 +1,104 @@ +# 后端错误处理检查标准 + +## 1. 异步操作未覆盖错误处理 🔴 + +所有 `async/await` 操作都可能抛出异常,未捕获的错误会导致 Promise 静默失败或未处理的拒绝。 + +```typescript +// ❌ 无 try-catch,错误向上抛出且无上下文 +async function deleteUser(userId: string) { + await db.users.deleteOne({ id: userId }); +} + +// ✅ 捕获错误,记录上下文,重新抛出 +async function deleteUser(userId: string): Promise { + try { + const result = await db.users.deleteOne({ id: userId }); + if (result.deletedCount === 0) { + throw new Error(`User not found: ${userId}`); + } + } catch (error) { + addLog.error(error, `Failed to delete user: ${userId}`); + throw error; + } +} +``` + +**例外情况**:API 路由的顶层 handler 通常由框架统一捕获,内部 service 可以直接 throw,不需要每层都 try-catch。重点检查的是**没有上层兜底的独立异步调用**。 + +--- + +## 2. 错误信息丢失 🟡 + +catch 中创建新 Error 但不保留原始错误,导致问题难以排查。 + +```typescript +// ❌ 原始错误信息丢失 +catch (error) { + throw new Error('Save failed'); // 为什么失败?不知道 +} + +// ❌ 空 catch,静默失败 +catch (error) { + // 什么都不做 +} + +// ✅ 保留错误链 +catch (error) { + addLog.error(error, 'Save user failed'); + throw new Error(`Save user failed: ${String(error)}`, { cause: error }); +} + +// ✅ 确需忽略时,必须写明原因 +catch (error) { + // 清理临时文件失败不影响主流程,记录日志后忽略 + addLog.warn(error, 'Temp file cleanup failed'); +} +``` + +--- + +## 3. Fire-and-Forget 未挂 catch 🔴 + +不 await 的 Promise 如果抛出错误,会成为未处理的 rejection,在 Node.js 中可能导致进程崩溃。 + +```typescript +// ❌ 错误无处捕获 +sendNotification(userId); +updateLastLogin(userId); + +// ✅ 如不需要等待,至少挂 .catch +sendNotification(userId).catch(err => + addLog.warn(err, 'Notification send failed, non-critical') +); + +// ✅ 或用 void 明确表示有意忽略(需团队约定) +void sendNotification(userId).catch(err => + addLog.warn(err, 'Notification failed') +); +``` + +--- + +## 4. 业务错误与系统错误混淆 🟡 + +业务错误(如"用户不存在")应返回明确的业务状态码,而不是 500。 + +```typescript +// ❌ 业务错误被当成系统错误 +async function getUser(userId: string) { + const user = await db.users.findById(userId); + if (!user) throw new Error('User not found'); // 被框架捕获后返回 500 +} + +// ✅ 使用业务错误类(FastGPT 中使用 ERROR_ENUM) +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; + +async function getUser(userId: string) { + const user = await db.users.findById(userId); + if (!user) { + throw new Error(ERROR_ENUM.unAuthUser); // 框架识别为 4xx 业务错误 + } + return user; +} +``` diff --git a/.codex/skills/system/pr-review/backend-quality/performance.md b/.codex/skills/system/pr-review/backend-quality/performance.md new file mode 100644 index 0000000000..0764bd3f9c --- /dev/null +++ b/.codex/skills/system/pr-review/backend-quality/performance.md @@ -0,0 +1,139 @@ +# 后端性能检查标准 + +## 1. CPU 密集型同步操作阻塞事件循环 🔴 + +Node.js 是单线程的,长时间的同步计算会阻塞所有并发请求。即使是几百毫秒的阻塞,在高并发下也是严重问题。 + +**高危模式**: +```typescript +// ❌ 同步解析/序列化超大 JSON(几十MB会卡住几百ms) +const data = JSON.parse(fs.readFileSync('/path/to/huge.json', 'utf8')); + +// ❌ 大数组的同步复杂计算 +const result = largeArray.reduce((acc, item) => { + return acc + heavyComputation(item); // 10万条数据×复杂计算 +}, 0); + +// ❌ 同步读取大文件 +const content = fs.readFileSync('/path/to/large/file'); +``` + +**修复方案**: +```typescript +// ✅ 拆分到多个 tick,释放事件循环 +async function processLargeArray(items: Item[]) { + const CHUNK_SIZE = 1000; + const results: Result[] = []; + for (let i = 0; i < items.length; i += CHUNK_SIZE) { + const chunk = items.slice(i, i + CHUNK_SIZE); + results.push(...chunk.map(item => process(item))); + await new Promise(resolve => setImmediate(resolve)); // 释放事件循环 + } + return results; +} + +// ✅ CPU 密集型任务使用 worker_threads +import { Worker } from 'worker_threads'; + +// ✅ 文件使用流式处理 +import { createReadStream } from 'fs'; +const stream = createReadStream('/path/to/large/file'); +``` + +**判断标准**:单次操作耗时预计超过 50ms 的同步计算都应考虑异步化或分片处理。 + +--- + +## 2. N+1 查询问题 🟡 + +在循环中对每条记录单独查询数据库,导致 N 条记录触发 N+1 次数据库请求。 + +```typescript +// ❌ 循环内查询:100条用户 → 101次数据库请求 +const users = await db.users.find({}).toArray(); +for (const user of users) { + user.posts = await db.posts.find({ userId: user._id }).toArray(); +} + +// ✅ 一次查询 + 内存关联:2次数据库请求 +const users = await db.users.find({}).toArray(); +const userIds = users.map(u => u._id); +const allPosts = await db.posts + .find({ userId: { $in: userIds } }) + .toArray(); + +const postsByUser = new Map(); +allPosts.forEach(post => { + const key = String(post.userId); + postsByUser.set(key, [...(postsByUser.get(key) ?? []), post]); +}); + +users.forEach(user => { + user.posts = postsByUser.get(String(user._id)) ?? []; +}); +``` + +**识别信号**:循环体内出现 `await db.xxx.find/findOne/findById` 即为高度可疑。 + +--- + +## 3. 全量加载未分页 🟡 + +不加 `limit` 地拉取集合数据,当数据量增长到几万甚至几十万条时,单次请求会消耗大量内存和时间。 + +```typescript +// ❌ 无限制全量拉取 +const allLogs = await db.logs.find({ userId }).toArray(); +const allItems = await db.collection.find({}).toArray(); + +// ✅ 强制分页 +const PAGE_SIZE = 50; +const logs = await db.logs + .find({ userId }) + .sort({ createdAt: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(); + +// ✅ 如确实需要全量(如后台任务),使用游标流式处理 +const cursor = db.collection.find({}).batchSize(100); +for await (const doc of cursor) { + await processDoc(doc); +} +``` + +--- + +## 4. 查询未投影(返回多余字段)🟢 + +MongoDB 默认返回文档所有字段。当文档包含大字段(如 `content`、`vectorData`、`fileContent`)时,不投影会显著增加网络传输和内存开销。 + +```typescript +// ❌ 返回整个文档(包含大字段) +const datasets = await db.datasets.find({ teamId }).toArray(); + +// ✅ 只取需要的字段 +const datasets = await db.datasets + .find({ teamId }) + .project({ name: 1, type: 1, createdAt: 1 }) // 排除 vectorData 等大字段 + .toArray(); +``` + +--- + +## 5. 缺少索引的高频查询 🟡 + +在没有索引的字段上进行 `find`/`findOne` 会触发全表扫描,随数据量线性增长。 + +**检查方法**:查看查询条件中的字段,对照 Model 定义确认是否有对应索引。 + +```typescript +// 检查 Model 定义是否有索引 +const DatasetSchema = new Schema({ + teamId: { type: String, required: true, index: true }, // ✅ 有索引 + name: { type: String }, // ❌ 无索引但被频繁查询? +}); + +// 高频查询条件 +db.datasets.find({ teamId, name }); // name 无索引 → 需要添加 +``` diff --git a/.codex/skills/system/pr-review/backend-quality/security.md b/.codex/skills/system/pr-review/backend-quality/security.md new file mode 100644 index 0000000000..3fdcb09066 --- /dev/null +++ b/.codex/skills/system/pr-review/backend-quality/security.md @@ -0,0 +1,175 @@ +# 后端安全检查标准 + +## 1. NoSQL 注入 🔴 + +**核心风险**:MongoDB 操作符(`$gt`、`$where`、`$regex`、`$ne` 等)可通过 HTTP 请求体注入。接口将入参直接透传到查询条件时,攻击者可绕过权限校验或泄露数据。 + +**典型攻击**: +``` +POST /api/login +{ "username": { "$gt": "" }, "password": { "$gt": "" } } +→ db.users.findOne({ username: { $gt: "" }, password: { $gt: "" } }) +→ 匹配所有用户,绕过密码校验 +``` + +**高危模式**: +```typescript +// ❌ 入参直接作为查询条件 +const { username, password } = req.body; +await db.users.findOne({ username, password }); + +// ❌ 对象字段透传进查询 +async function getUser({ filter }: { filter: object }) { + return db.users.findOne(filter); +} + +// ❌ updateOne 条件字段未校验 +await db.collection.updateOne( + { _id: req.body.id }, // id 可能是 { $gt: "" } + { $set: req.body.update } // update 可能注入 $where +); +``` + +**修复方案**: +```typescript +// ✅ 方案1:zod schema 严格校验(推荐) +const LoginSchema = z.object({ + username: z.string().min(1).max(50), + password: z.string().min(1).max(100) +}); +const { username, password } = LoginSchema.parse(req.body); + +// ✅ 方案2:动态查询使用字段白名单 +const ALLOWED_FIELDS = ['status', 'type', 'teamId'] as const; +function buildSafeFilter(raw: Record) { + return ALLOWED_FIELDS.reduce((acc, key) => { + if (raw[key] !== undefined && typeof raw[key] === 'string') { + acc[key] = raw[key] as string; + } + return acc; + }, {} as Record); +} + +// ✅ _id 字段强制 ObjectId 转换 +await db.collection.findOne({ _id: new Types.ObjectId(id) }); +``` + +**检查清单**: +- [ ] 接口入参经过 zod schema 校验 +- [ ] 查询条件字段均为原始类型(`string`/`number`/`boolean`) +- [ ] `req.body` 对象字段未直接传入 MongoDB 操作符位置 +- [ ] `_id` 字段使用 `new Types.ObjectId(id)` 转换 + +--- + +## 2. 命令注入 / 路径遍历 🔴 + +```typescript +// ❌ 危险:用户输入拼入 shell 命令 +exec(`convert ${req.body.filename} output.png`); +// filename = "; rm -rf /" → 执行恶意命令 + +// ❌ 危险:路径拼接未过滤 ../ +const filePath = path.join('/uploads', req.body.path); +// path = "../../etc/passwd" + +// ✅ 使用 execFile 并传数组参数 +execFile('convert', [sanitizedFilename, 'output.png']); + +// ✅ 校验路径在允许目录内 +const resolved = path.resolve('/uploads', req.body.path); +if (!resolved.startsWith(path.resolve('/uploads'))) { + throw new Error('Invalid path'); +} +``` + +--- + +## 3. 死循环风险 🔴 + +**高危模式**: +```typescript +// ❌ 递归无终止条件 +async function processNode(nodeId: string) { + const node = await getNode(nodeId); + await processNode(node.parentId); // parentId 可能形成环形引用 +} + +// ❌ while 无退出条件 +while (queue.length > 0) { + const item = queue.shift(); + queue.push(...item.children); // children 可能重新推入导致无限循环 +} +``` + +**修复方案**: +```typescript +// ✅ 递归:深度限制 + 访问集合 +async function processNode(nodeId: string, visited = new Set(), depth = 0) { + if (depth > 100 || visited.has(nodeId)) return; + visited.add(nodeId); + const node = await getNode(nodeId); + await processNode(node.parentId, visited, depth + 1); +} + +// ✅ 循环:最大迭代次数 +const MAX_ITER = 10000; +let iter = 0; +while (queue.length > 0) { + if (++iter > MAX_ITER) throw new Error('Max iterations exceeded'); + // ... +} +``` + +--- + +## 4. 数据膨胀 🟡 + +无约束的数据积累导致集合无限增长,查询变慢、内存溢出或磁盘耗尽。 + +```typescript +// ❌ 数组字段无上限 push +await db.collection.updateOne( + { _id: id }, + { $push: { logs: newLog } } // 日志数组可无限增长 +); + +// ❌ 批量写入无上限 +await db.collection.insertMany(items); // items 可能有几十万条 + +// ✅ $push + $slice 保留最近 N 条 +await db.collection.updateOne( + { _id: id }, + { $push: { logs: { $each: [newLog], $slice: -100 } } } +); + +// ✅ 批量写入加上限校验 +const MAX_BATCH = 1000; +if (items.length > MAX_BATCH) { + throw new Error(`Batch size exceeds limit: ${items.length}`); +} +``` + +--- + +## 5. 敏感信息保护 🔴 + +```typescript +// ❌ 硬编码密钥 +const API_KEY = 'sk-1234567890abcdef'; + +// ❌ 日志包含密码/token +addLog.info('User login', { userId, email, password }); + +// ✅ 使用环境变量 +const API_KEY = process.env.OPENAI_API_KEY; +if (!API_KEY) throw new Error('OPENAI_API_KEY is required'); + +// ✅ 日志过滤敏感字段 +const { password, token, ...safeUser } = user; +addLog.info('User login', safeUser); + +// ✅ API 响应过滤敏感字段 +const { password: _, ...safeResponse } = userData; +res.json(safeResponse); +``` diff --git a/.codex/skills/system/pr-review/frontend-quality/react-performance.md b/.codex/skills/system/pr-review/frontend-quality/react-performance.md new file mode 100644 index 0000000000..0bca36b6b9 --- /dev/null +++ b/.codex/skills/system/pr-review/frontend-quality/react-performance.md @@ -0,0 +1,138 @@ +# 前端 React 性能检查标准 + +## 1. 不必要的组件重渲染 🟡 + +父组件状态变化导致子组件不必要地重新渲染,在昂贵组件(复杂列表、图表、编辑器)上会造成明显卡顿。 + +**识别信号**:子组件接收的 props 在父组件状态变化时并未改变,但子组件仍然重渲染。 + +```typescript +// ❌ 父组件 count 变化 → ExpensiveChild 不必要地重渲染 +const Parent = ({ items }: { items: Item[] }) => { + const [count, setCount] = useState(0); + return ( + <> + + {items.map(item => )} + + ); +}; + +// ✅ 用 React.memo 跳过 props 未变化的渲染 +const ExpensiveChild = React.memo(function ExpensiveChild({ data }: { data: Item }) { + return
{/* 昂贵渲染 */}
; +}); +``` + +**注意**:`React.memo` 对 props 做浅比较,如果传入的是每次新建的对象/函数引用,memo 无效——需要配合下面的优化。 + +--- + +## 2. 渲染函数中创建对象或函数 🟡 + +每次渲染都创建新的对象/数组/函数引用,导致子组件的 `React.memo` 失效,或 `useEffect` 依赖项频繁触发。 + +```typescript +// ❌ 每次渲染都创建新的函数和对象引用 +const MyComponent = ({ items }: { items: Item[] }) => { + return ( + <> + {items.map(item => ( + handleClick(item.id)} // 每次渲染新函数 + options={{ enable: true, mode: 'edit' }} // 每次渲染新对象 + /> + ))} + + ); +}; + +// ✅ 用 useCallback/useMemo 稳定引用 +const MyComponent = ({ items }: { items: Item[] }) => { + const handleClick = useCallback((id: string) => { + // 处理逻辑 + }, []); // 依赖项为空,引用永远稳定 + + const options = useMemo(() => ({ enable: true, mode: 'edit' }), []); + + return ( + <> + {items.map(item => ( + handleClick(item.id)} + options={options} + /> + ))} + + ); +}; +``` + +**判断标准**:只有当子组件是 `React.memo` 包裹的,或该引用是某个 `useEffect`/`useCallback` 的依赖项时,才需要稳定引用。普通非 memo 组件的 props 无需此优化。 + +--- + +## 3. 昂贵计算未缓存 🟡 + +在渲染函数中进行复杂的数组操作(sort、filter、reduce 的链式调用),每次渲染都重新计算,即使输入数据未变化。 + +```typescript +// ❌ 每次渲染都重新排序和过滤 +const ExpensiveList = ({ items }: { items: Item[] }) => { + const sortedItems = [...items].sort((a, b) => a.value - b.value); + const filteredItems = sortedItems.filter(item => item.active); + return
    {filteredItems.map(item =>
  • {item.name}
  • )}
; +}; + +// ✅ useMemo 缓存计算结果,只在 items 变化时重新计算 +const ExpensiveList = ({ items }: { items: Item[] }) => { + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.value - b.value), + [items] + ); + const filteredItems = useMemo( + () => sortedItems.filter(item => item.active), + [sortedItems] + ); + return
    {filteredItems.map(item =>
  • {item.name}
  • )}
; +}; +``` + +**判断标准**:操作数组长度超过 100 条,或包含复杂排序/计算逻辑时,值得用 `useMemo` 缓存。简单的几条数据无需优化。 + +--- + +## 4. 大列表缺少虚拟化 🟢 + +一次性渲染几百上千个 DOM 节点,会导致首次渲染慢、滚动卡顿、内存占用高。 + +```typescript +// ❌ 渲染1000条数据 → 1000个真实DOM节点 +const List = ({ items }: { items: Item[] }) => ( +
+ {items.map(item => )} +
+); + +// ✅ 虚拟化(仅渲染可见区域的节点) +import { VariableSizeList } from 'react-window'; + +const VirtualList = ({ items }: { items: Item[] }) => ( + 50} + width="100%" + > + {({ index, style }) => ( +
+ +
+ )} +
+); +``` + +**判断标准**:列表超过 200 条且需要同时显示在页面上时,建议虚拟化。 diff --git a/.codex/skills/system/pr-review/frontend-quality/security.md b/.codex/skills/system/pr-review/frontend-quality/security.md new file mode 100644 index 0000000000..ea6da2c257 --- /dev/null +++ b/.codex/skills/system/pr-review/frontend-quality/security.md @@ -0,0 +1,130 @@ +# 前端安全检查标准 + +## 1. XSS 攻击 🔴 + +跨站脚本攻击(XSS)通过注入恶意脚本到页面中执行,可窃取用户 Cookie、Session 或劫持页面行为。 + +### 1.1 dangerouslySetInnerHTML 未净化 + +```typescript +// ❌ 直接渲染用户输入的 HTML,攻击者可注入 +const UserProfile = ({ user }: { user: User }) => ( +

+); + +// ✅ 方案1:避免 dangerouslySetInnerHTML,用 React 文本节点渲染(自动转义) +const UserProfile = ({ user }: { user: User }) => ( +

{user.bio}

// React 自动转义,安全 +); + +// ✅ 方案2:确实需要渲染富文本时,使用 DOMPurify 净化 +import DOMPurify from 'dompurify'; +const UserProfile = ({ user }: { user: User }) => { + const cleanBio = DOMPurify.sanitize(user.bio, { + ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'] + }); + return

; +}; +``` + +### 1.2 URL 注入 + +```typescript +// ❌ 危险:href 使用用户输入,可能注入 javascript:alert(1) +const Link = ({ href, text }: { href: string; text: string }) => ( + {text} +); + +// ✅ 校验 URL 协议 +function sanitizeHref(href: string): string { + try { + const url = new URL(href); + if (!['http:', 'https:'].includes(url.protocol)) { + return '#'; // 拒绝 javascript: 等协议 + } + return href; + } catch { + return '#'; + } +} +const Link = ({ href, text }: { href: string; text: string }) => ( + {text} +); +``` + +### 1.3 eval / 动态代码执行 + +```typescript +// ❌ 危险:执行用户输入的代码 +eval(userInput); +new Function('return ' + userInput)(); + +// ✅ 永远不要对用户输入执行 eval/Function +// 如需动态计算,使用安全的表达式解析库(如 mathjs) +``` + +--- + +## 2. 敏感信息暴露在前端 🔴 + +### 2.1 API Key 硬编码在前端代码 + +```typescript +// ❌ 危险:密钥打包进前端 bundle,用户可通过 DevTools 获取 +const API_KEY = 'sk-1234567890abcdef'; +const response = await fetch('/api/endpoint', { + headers: { 'Authorization': `Bearer ${API_KEY}` } +}); + +// ✅ 密钥只存在于服务端,前端调用自己的 API 路由 +const response = await fetch('/api/my-proxy-route', { + headers: { 'Authorization': `Bearer ${userToken}` } // 用户 token,非服务密钥 +}); +``` + +### 2.2 敏感数据存储在 localStorage + +```typescript +// ❌ localStorage 可被 XSS 攻击读取 +localStorage.setItem('userToken', token); +localStorage.setItem('apiKey', apiKey); + +// ✅ 敏感 token 存储在 httpOnly Cookie(无法被 JS 读取) +// 由服务端 Set-Cookie: token=xxx; HttpOnly; Secure; SameSite=Strict +// 前端无需手动操作,自动随请求发送 +``` + +### 2.3 用户敏感信息打印到控制台 + +```typescript +// ❌ 生产环境日志暴露敏感信息 +console.log('User data:', { userId, email, token, password }); + +// ✅ 生产环境避免打印敏感字段 +if (process.env.NODE_ENV !== 'production') { + console.log('Debug:', { userId }); +} +``` + +--- + +## 3. CSRF 防护 🟡 + +跨站请求伪造(CSRF)诱导用户在已登录状态下执行恶意操作。 + +**检查点**: +- 状态变更接口(POST/PUT/DELETE)是否验证了 CSRF Token 或依赖 `SameSite` Cookie? +- 是否接受来自任意 Origin 的跨域请求? + +```typescript +// ✅ FastGPT 中通过 Authorization header 携带 token 天然防 CSRF +// (CSRF 攻击无法读取其他域的 Cookie/Header) +fetch('/api/endpoint', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) +}); +``` diff --git a/.codex/skills/system/pr-review/frontend-quality/typescript.md b/.codex/skills/system/pr-review/frontend-quality/typescript.md new file mode 100644 index 0000000000..04ba1242cd --- /dev/null +++ b/.codex/skills/system/pr-review/frontend-quality/typescript.md @@ -0,0 +1,150 @@ +# 前端 TypeScript 质量检查标准 + +## 1. any 类型滥用 🔴 + +`any` 类型会关闭 TypeScript 的类型检查,使得类型错误只能在运行时暴露,而非编译时发现。 + +**识别信号**: +- 变量、参数、返回值声明为 `any` +- 使用 `as any` 进行类型断言 +- API 响应数据直接使用,无类型约束 + +```typescript +// ❌ any 让类型系统形同虚设 +async function fetchData(id: any): any { + const result: any = await db.collection('data').findOne({ id }); + return result; +} + +// ✅ 明确的类型定义 +type UserData = { + id: string; + name: string; + email: string; + createdAt: Date; +}; + +async function fetchData(id: string): Promise { + const result = await db.collection('data').findOne({ id }); + return result; +} +``` + +**例外情况**: +- 第三方库确实没有类型定义时,可以用 `unknown` 代替 `any`,然后通过类型守卫收窄 +- 临时调试代码(但合并前必须移除) + +--- + +## 2. 不安全的类型断言 🟡 + +类型断言(`as Type`)绕过了编译器检查,如果断言错误,运行时会产生难以调试的问题。 + +```typescript +// ❌ 双重断言(最危险,完全跳过类型检查) +const user = data as any as User; + +// ❌ 无根据的断言(data 可能不是 User) +const user = data as User; +user.profile.avatar; // 如果 data 不符合 User 结构,运行时报错 + +// ✅ 使用类型守卫(编译时 + 运行时都安全) +function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof (value as User).id === 'string' + ); +} + +if (isUser(data)) { + console.log(data.id); // 安全 +} + +// ✅ 使用 zod 验证外部数据(API 响应、localStorage 读取等) +import { z } from 'zod'; + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email() +}); + +const user = UserSchema.parse(apiResponse); // 解析失败自动抛出 +``` + +--- + +## 3. 类型定义不完整 🟡 + +使用 `object`、`{}` 或过于宽泛的联合类型,导致 TypeScript 无法提供有效的自动补全和错误提示。 + +```typescript +// ❌ object 类型无任何约束 +function updateUser(id: string, data: object) { + return db.users.updateOne({ id }, { $set: data }); +} + +// ❌ 函数参数数量超过2个,未用对象收拢 +function createItem(name: string, type: string, teamId: string, createdBy: string) { + // ... +} + +// ✅ 明确类型定义 +type UpdateUserData = { + name?: string; + email?: string; + avatar?: string; +}; +function updateUser(id: string, data: UpdateUserData) { + return db.users.updateOne({ id }, { $set: data }); +} + +// ✅ 多参数用对象收拢(FastGPT 规范:超过2个参数必须用对象) +type CreateItemParams = { + name: string; + type: string; + teamId: string; + createdBy: string; +}; +function createItem({ name, type, teamId, createdBy }: CreateItemParams) { + // ... +} +``` + +--- + +## 4. 非空断言过度使用 🟡 + +非空断言(`!`)告诉 TypeScript"这个值不可能是 null/undefined",如果判断错误,会在运行时抛出 `Cannot read properties of null`。 + +```typescript +// ❌ 危险:如果 user 为 null,运行时崩溃 +const email = user!.email; +const name = data!.profile!.name; + +// ✅ 方案1:提前校验 +if (!user) throw new Error('User not found'); +const email = user.email; // 此后 TypeScript 知道 user 非空 + +// ✅ 方案2:可选链(不确定是否存在时) +const name = data?.profile?.name ?? 'Unknown'; + +// ✅ 方案3:只在确实不可能为空的地方使用(需加注释说明原因) +// teamId 在此处由 authMiddleware 保证非空 +const teamId = req.headers.teamId!; +``` + +--- + +## 快速检查表 + +| 检查项 | 级别 | +|--------|------| +| 无 `any` 类型声明 | 🔴 | +| 无 `as any as Type` 双重断言 | 🔴 | +| 函数参数有明确类型约束 | 🟡 | +| 超过2个参数使用对象收拢 | 🟡 | +| `!` 非空断言有校验或注释支撑 | 🟡 | +| 外部数据(API响应)经过 zod 或类型守卫处理 | 🟡 | diff --git a/.codex/skills/system/pr-review/style/db.md b/.codex/skills/system/pr-review/style/db.md new file mode 100644 index 0000000000..0ecb9b050e --- /dev/null +++ b/.codex/skills/system/pr-review/style/db.md @@ -0,0 +1,66 @@ +# 数据库操作规范 + +FastGPT 使用 MongoDB (Mongoose) 和 PostgreSQL。 + +## 4.1 Model 定义 + +**文件位置**: `packages/service/common/mongo/schema/` + +**审查要点**: +- ✅ Schema 定义使用 TypeScript 泛型 +- ✅ 必要的字段添加索引 +- ✅ 敏感字段加密存储 +- ✅ 定义虚拟字段和实例方法 + +**示例**: +```typescript +import { mongoose, Schema } from '@fastgpt/service/common/mongo'; + +const UserSchema = new Schema({ + username: { type: String, required: true, unique: true }, + password: { type: String, required: true, select: false }, // 默认不查询 + email: { type: String, required: true }, + createdAt: { type: Date, default: Date.now } +}); + +// 索引 +UserSchema.index({ username: 1 }); +UserSchema.index({ email: 1 }); + +// 虚拟字段 +UserSchema.virtual('fullName').get(function() { + return `${this.firstName} ${this.lastName}`; +}); + +export const User = mongoose.model('User', UserSchema); +``` + +## 4.2 查询操作 + +**审查要点**: +- ✅ 使用参数化查询防止注入 +- ✅ 避免 N+1 查询 +- ✅ 使用 projection 只查询需要的字段 +- ✅ 大结果集使用分页 +- ✅ 异步操作有错误处理 + +**示例**: +```typescript +// ❌ 不好的实践 +const users = await User.find({}).toArray(); // 可能返回大量数据 + +// ✅ 好的实践 +const users = await User.find({}) + .project({ username: 1, email: 1 }) // 只查询需要的字段 + .limit(20) // 限制结果数量 + .skip(page * 20) + .toArray(); +``` + +## 4.3 错误处理 + +**审查要点**: +- ✅ 数据库操作使用 try-catch +- ✅ 处理重复键错误 (code 11000) +- ✅ 处理连接错误 +- ✅ 错误日志包含上下文信息 \ No newline at end of file diff --git a/.codex/skills/system/pr-review/style/front.md b/.codex/skills/system/pr-review/style/front.md new file mode 100644 index 0000000000..12bf7e279a --- /dev/null +++ b/.codex/skills/system/pr-review/style/front.md @@ -0,0 +1,90 @@ + +# 前端组件开发规范 + +FastGPT 使用 React + TypeScript + Chakra UI。 + +## 3.1 组件结构 + +**审查要点**: +- ✅ 使用函数式组件和 Hooks +- ✅ 组件使用 `React.memo` 优化性能 +- ✅ Props 有明确的类型定义 +- ✅ 使用 TypeScript type 而不是 interface (项目约定) + +**示例**: +```typescript +import React from 'react'; +import { Box, Button } from '@chakra-ui/react'; + +type YourComponentProps = { + title: string; + onClick: () => void; + disabled?: boolean; +}; + +export const YourComponent = React.memo(function YourComponent({ + title, + onClick, + disabled = false +}: YourComponentProps) { + return ( + + + + ); +}); +``` + +## 3.2 状态管理 + +**审查要点**: +- ✅ 本地状态使用 `useState` +- ✅ 全局状态使用 Zustand store +- ✅ 表单状态使用 `useForm` (react-hook-form) +- ✅ 复杂状态逻辑使用 `useReducer` + +## 3.3 样式规范 + +**审查要点**: +- ✅ 优先使用 Chakra UI props +- ✅ 响应式设计使用 Chakra UI 的断点系统 +- ✅ 自定义样式放在 `styles/theme.ts` +- ✅ 避免内联样式 + +**示例**: +```typescript +// ❌ 不好的实践 + + +// ✅ 好的实践 + +``` + +## 3.4 国际化 + +**审查要点**: +- ✅ 所有用户可见文本使用 `t`, 服务端使用`i18nT` +- ✅ 翻译 key 使用命名空间 +- ✅ 动态文本使用插值 + +**示例**: +```typescript +import { i18nT } from '@fastgpt/web/i18n/utils'; +const message = i18nT('user:welcome', { name: userName }); +``` + +```typescript +import { useTranslation } from 'next-i18next'; +const { t } = useTranslation(); +const message = t('user:welcome', { name: userName }); +``` +## 3.5 性能优化 + +**审查要点**: +- ✅ 列表渲染使用 key +- ✅ 大列表使用虚拟化 +- ✅ 避免在渲染中创建新对象/函数 +- ✅ 使用 `useMemo` 缓存计算结果 +- ✅ 使用 `useCallback` 缓存函数 \ No newline at end of file diff --git a/.codex/skills/system/pr-review/style/logger.md b/.codex/skills/system/pr-review/style/logger.md new file mode 100644 index 0000000000..fee6429c6a --- /dev/null +++ b/.codex/skills/system/pr-review/style/logger.md @@ -0,0 +1,114 @@ +# 日志与可观测性审查标准 + +FastGPT 后端统一使用 `@fastgpt/service/common/logger`。日志需要可检索、可聚合、可回放,且避免敏感信息泄露。 + +## 1. 统一 Logger 接入 + +**审查要点**: +- ✅ 服务端统一使用 `@fastgpt/service/common/logger`,避免 `console.log` +- ✅ 启动入口只初始化一次 `configureLogger()` +- ✅ 使用 `getLogger(LogCategories.XXX)` 指定分类 +- ✅ 未经讨论不要自定义 category 字符串数组 + +**示例**: +```ts +import { configureLogger, getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +await configureLogger(); +const logger = getLogger(LogCategories.SYSTEM); +logger.info('System initialized successfully'); +``` + +## 2. 分类(Category)选择 + +**审查要点**: +- ✅ `SYSTEM` 用于系统级初始化与全局状态 +- ✅ `INFRA.*` 用于数据库/缓存/队列/存储等 +- ✅ `HTTP.*` 用于请求、响应、请求错误 +- ✅ `MODULE.*` 用于业务模块(参考 `pages/api` 路径) +- ✅ 缺少分类时补充到 `packages/service/common/logger/categories.ts` + +**问题示例**: +```ts +// ❌ 不规范:自定义字符串分类 +const logger = getLogger(['custom', 'random']); +``` + +## 3. 结构化日志与消息规范 + +**审查要点**: +- ✅ 消息短、稳定、可检索 +- ✅ 关键字段放在结构化对象中(id、状态、耗时、数量) +- ✅ 避免 `JSON.stringify` 拼接到消息里 +- ✅ 避免在消息中包含用户输入或大段文本 + +**示例**: +```ts +// ✅ 推荐 +logger.info('Vector queue task finished', { taskId, durationMs, count }); + +// ❌ 不推荐 +logger.info(`Task finished: ${JSON.stringify({ taskId, durationMs, count })}`); +``` + +## 4. 错误日志标准 + +**审查要点**: +- ✅ 使用 `error` 字段记录 `Error` 对象,保留堆栈 +- ✅ 错误日志包含关键上下文(teamId、datasetId、jobId 等) +- ✅ 捕获后必须记录或向上抛出,避免静默失败 + +**示例**: +```ts +try { + await doSomething(); +} catch (error) { + logger.error('Do something failed', { error, teamId, datasetId }); + throw error; +} +``` + +## 5. 日志等级规范 + +**审查要点**: +- ✅ `info` 用于阶段开始/完成 +- ✅ `warn` 用于可恢复异常 +- ✅ `error` 用于失败或影响流程的异常 +- ✅ 高频循环日志必须使用 `debug/trace` + +**示例**: +```ts +logger.info('Training started', { datasetId }); +logger.debug('Training progress', { datasetId, step, total }); +logger.warn('Retrying batch', { batchId, retryLeft }); +logger.error('Training failed', { datasetId, error }); +``` + +## 6. 请求/任务链路上下文 + +**审查要点**: +- ✅ HTTP 请求日志应带 `requestId`,优先使用 `withContext` +- ✅ 队列/定时任务日志应带 `jobId`/`queueName` +- ✅ 跨模块调用尽量保持同一上下文字段名 + +**示例**: +```ts +return withContext({ requestId }, async () => { + logger.info('Request received', { requestId, method, url }); +}); +``` + +## 7. 敏感信息与 OTEL + +**审查要点**: +- ✅ 禁止输出 token、密钥、密码、完整对话内容 +- ✅ 必须记录敏感信息时做脱敏或截断 +- ✅ 需要阻止 OTEL 导出时,可添加 `fastgpt` 属性 + +**示例**: +```ts +logger.warn('Payload truncated for debug', { + fastgpt: true, + payloadPreview: payload.slice(0, 200) +}); +``` diff --git a/.codex/skills/system/pr-review/style/package.md b/.codex/skills/system/pr-review/style/package.md new file mode 100644 index 0000000000..9d65f2202a --- /dev/null +++ b/.codex/skills/system/pr-review/style/package.md @@ -0,0 +1,50 @@ +# 5. 包结构与依赖规范 + +FastGPT 是一个 monorepo,使用 pnpm workspaces。 + +## 5.1 包结构 + +``` +packages/ +├── global/ # 类型、常量、工具函数 (无运行时依赖) +├── service/ # 后端服务、数据库模型 (依赖 global) +└── web/ # 前端组件、样式、i18n (依赖 global) + +projects/ +├── app/ # NextJS 应用 (依赖所有 packages) +├── sandbox/ # NestJS 沙箱服务 (独立应用) +└── mcp_server/ # MCP 服务器 (独立应用) +``` + +## 5.2 依赖规则 + +**审查要点**: +- ✅ `packages/global/` 无任何运行时依赖 +- ✅ `packages/service/` 只依赖 `packages/global/` +- ✅ `packages/web/` 只依赖 `packages/global/` +- ✅ `projects/app/` 可以依赖所有 packages +- ✅ 独立项目 (sandbox, mcp_server) 最小化依赖 + +## 5.3 导入规范 + +**审查要点**: +- ✅ 使用项目别名导入: `@fastgpt/global`, `@fastgpt/service`, `@fastgpt/web` +- ✅ 避免相对路径导入跨包的文件 +- ✅ 导入路径使用 index 简化 + +**示例**: +```typescript +// ❌ 不好的导入 +import { UserType } from ../../../../../packages/global/core/user/type.d.ts; + +// ✅ 好的导入 +import { UserType } from '@fastgpt/global/core/user/type'; +``` + +## 5.4 类型导出 + +**审查要点**: +- ✅ 公共类型必须导出 +- ✅ 类型文件使用 `.d.ts` 扩展名 +- ✅ 复杂类型放在独立的类型文件 +- ✅ 使用 `export type` 导出类型 \ No newline at end of file diff --git a/.codex/skills/system/pr-review/style/service-decoupling.md b/.codex/skills/system/pr-review/style/service-decoupling.md new file mode 100644 index 0000000000..57a55d06ec --- /dev/null +++ b/.codex/skills/system/pr-review/style/service-decoupling.md @@ -0,0 +1,216 @@ +# Service 解耦规范 + +## 核心原则 + +**后端 service 之间不允许互相引用,只允许单向依赖,跨 service 的协调由上层 controller 完成。** + +如果 serviceA 确实需要 serviceB 的数据,**由 controller 提前调用 serviceB,将结果以参数形式传入 serviceA**,而不是让 serviceA 自行 import serviceB。 + +--- + +## 依赖方向 + +``` +Controller / API Handler + ↓ 调用 serviceB → 得到结果 + ↓ 将结果作为参数传入 serviceA + Service A Service B Service C + ↓ 调用 ↓ 调用 ↓ 调用 + Repository Repository Repository + (DB Model) (DB Model) (DB Model) +``` + +**合法方向**: +- `controller` → `service`(上层调用下层,允许) +- `service` → 本模块的 DB Model(允许) +- `service` → `packages/service/common/`(公共工具,允许) +- `service` 接收其他 service 的**数据结果**(以参数形式传入,允许) + +**违规方向**: +- `serviceA` import `serviceB`(同级 service 互相引用,禁止) +- `serviceA` → `controllerB`(service 引用上层,禁止) + +--- + +## 违规模式识别 + +### 1. 同级 service 直接 import + +```typescript +// ❌ 违规:datasetService 直接引用 workflowService +// packages/service/core/dataset/service.ts +import { dispatchWorkflow } from '../workflow/service'; + +export async function deleteDataset(datasetId: string) { + await MongoDataset.deleteOne({ _id: datasetId }); + await dispatchWorkflow({ datasetId }); // 违规:跨 service 调用 +} +``` + +**识别方式**:在 `packages/service/core/xxx/` 目录下的文件中,import 了同级其他模块的 service 文件。 + +--- + +### 2. 循环依赖 + +```typescript +// ❌ serviceA 引用 serviceB,serviceB 也引用 serviceA → 循环依赖 +// dataset/service.ts +import { updateAppDataset } from '../app/service'; + +// app/service.ts +import { getDatasetInfo } from '../dataset/service'; +``` + +循环依赖会导致模块加载时出现 `undefined` 错误,且极难排查。 + +--- + +### 3. 在 service 内触发副作用业务 + +```typescript +// ❌ service 内部触发了属于其他业务域的逻辑 +export async function updateDatasetCollection(collectionId: string, data: UpdateData) { + await MongoDatasetCollection.updateOne({ _id: collectionId }, { $set: data }); + + // 违规:更新数据集集合后,直接触发通知或工作流——这是其他业务域的职责 + await sendTeamNotification(teamId, 'collection_updated'); + await triggerRebuildIndex(collectionId); +} +``` + +--- + +## 正确模式:由 Controller 协调 + +### 模式1:Controller 顺序调用多个 service + +```typescript +// ✅ controller 层负责协调多个 service +// projects/app/src/pages/api/core/dataset/collection/update.ts + +import { updateDatasetCollection } from '@fastgpt/service/core/dataset/collection/controller'; +import { sendTeamNotification } from '@fastgpt/service/support/user/team/controller'; +import { triggerRebuildIndex } from '@fastgpt/service/core/dataset/training/controller'; + +export default async function handler(req, res) { + const { collectionId, ...data } = req.body; + + // 1. 更新集合(dataset service 只做自己的事) + await updateDatasetCollection(collectionId, data); + + // 2. 发送通知(由 controller 层调用通知 service) + await sendTeamNotification(teamId, 'collection_updated'); + + // 3. 触发重建索引(由 controller 层调用训练 service) + await triggerRebuildIndex(collectionId); + + res.json({ success: true }); +} +``` + +### 模式2:serviceA 需要 serviceB 的数据 → Controller 提前获取并以参数传入 + +当 serviceA 的某个函数需要用到 serviceB 的查询结果时,**不要让 serviceA 内部去调用 serviceB**,而是由 controller 先查询,再将结果作为参数传给 serviceA。 + +```typescript +// ❌ 违规:serviceA 内部自己去查 serviceB 的数据 +// packages/service/core/dataset/service.ts +import { getTeamInfo } from '../support/user/team/service'; // 跨 service import + +export async function checkDatasetQuota(datasetId: string) { + const dataset = await MongoDataset.findById(datasetId); + const team = await getTeamInfo(dataset.teamId); // 违规:直接调用其他 service + return dataset.usedSize < team.maxDatasetSize; +} + +// ✅ 正确:controller 提前获取 team 信息,以参数形式传入 +// packages/service/core/dataset/service.ts +export async function checkDatasetQuota( + datasetId: string, + teamMaxSize: number // ← 由 controller 传入,service 不关心数据从哪来 +) { + const dataset = await MongoDataset.findById(datasetId); + return dataset.usedSize < teamMaxSize; +} + +// projects/app/src/pages/api/core/dataset/xxx.ts(controller 层) +import { getTeamInfo } from '@fastgpt/service/support/user/team/controller'; +import { checkDatasetQuota } from '@fastgpt/service/core/dataset/service'; + +export default async function handler(req, res) { + const { datasetId, teamId } = req.body; + + // controller 负责获取跨域数据 + const team = await getTeamInfo(teamId); + + // 将所需数据作为参数传入 service + const hasQuota = await checkDatasetQuota(datasetId, team.maxDatasetSize); + + // ... +} +``` + +**这个模式的好处**: +- `checkDatasetQuota` 可以独立测试,只需传入 `datasetId` 和 `teamMaxSize`,无需 mock 整个 team service +- service 的职责更纯粹:只处理本模块的数据,不感知其他模块的存在 +``` + +--- + +## 公共逻辑的处理 + +如果多个 service 都需要某个逻辑,不应让它们互相引用,而应将公共逻辑**下沉到 common 层**。 + +``` +packages/service/ +├── common/ ← 公共工具层,可被所有 service 引用 +│ ├── error/ +│ ├── file/ +│ └── string/ +├── core/ +│ ├── dataset/ +│ │ └── service.ts ← 只引用 common/,不引用 core/workflow/ +│ └── workflow/ +│ └── service.ts ← 只引用 common/,不引用 core/dataset/ +``` + +```typescript +// ✅ 公共逻辑下沉到 common 层 +// packages/service/common/permission/utils.ts +export function checkTeamPermission(teamId: string, userId: string) { ... } + +// dataset/service.ts 和 workflow/service.ts 都可以引用 common +import { checkTeamPermission } from '../../common/permission/utils'; +``` + +--- + +## 审查检查清单 + +审查新增或修改的 service 文件时,重点检查: + +- [ ] 文件顶部的 `import` 列表中,是否有引用同级或跨域的 service 文件? +- [ ] 是否出现了 `import xxx from '../otherModule/service'` 或 `from '../otherModule/controller'`? +- [ ] 若有跨模块调用,是否可以通过**上移到 controller 层**来解决? +- [ ] 公共逻辑是否应该下沉到 `common/` 而不是通过互相引用实现? + +**快速 grep 方法**: + +```bash +# 找出 service 文件中可能的跨 service 引用 +grep -rn "from '\.\./[^/]*/service" packages/service/core/ +grep -rn "from '\.\./[^/]*/controller" packages/service/core/ +``` + +--- + +## 为什么这样设计 + +**可测试性**:service 不依赖其他 service,可以独立 mock 测试,无需构造复杂的依赖图。 + +**可维护性**:修改一个 service 不会意外影响另一个 service,降低改动的波及范围。 + +**可读性**:阅读 controller 代码时,业务流程一目了然——"先做 A,再做 B,再做 C",而不是藏在 service 内部的隐式调用链。 + +**避免循环依赖**:互相引用极易形成循环依赖,导致运行时 `undefined` 错误,且 TypeScript 编译不会报错,只会在运行时暴露。 diff --git a/.codex/skills/system/test-case/SKILL.md b/.codex/skills/system/test-case/SKILL.md new file mode 100644 index 0000000000..ad75284ca8 --- /dev/null +++ b/.codex/skills/system/test-case/SKILL.md @@ -0,0 +1,100 @@ +--- +name: test-case +description: 当用户需要编写一个单元测试时,触发该 skill,编写单元测试。 +--- + +## When to Use This Skill + +用户需要编写一个单元测试时,触发该 skill,编写单元测试。 + +## 测试文件位置 + +### packages 测试 + +packages 里的测试,写在 FastGPT/packages/xxx/test 目录下,子路径对应 packages 的目录结构。例如: + +`packages/global/common/error/s3.ts`文件,对应的测例文件路径为 `packages/test/global/common/error/s3.test.ts`。 + +并且,可以通过 @fastgpt 来导入 packages 里的文件。 +例如: + +```typescript +import { s3 } from '@fastgpt/global/common/error/s3'; +``` + +### projects 测试 + +projects 里的测试,写在 FastGPT/projects/app/test 目录下,子路径对应 projects 的目录结构。 + +`projects/app/src/pages/api/core/dataset/collection/create.ts`文件,对应的测例文件路径为 `projects/app/test/api/core/dataset/collection/create.test.ts`。 + +## 测试文件规则 + +### 通用规则 + +1. 每个文件,对应一个测试文件。每个函数对应不同的 describe 块。 +2. 需覆盖 100% 行数和分支。 +3. 测试文件尽可能不要引入第三方依赖库,使用较为原生的方式进行检查。如果需要引入第三方依赖库,则从对应文件里 export 依赖库给 test 使用。例如: + +```ts +// FastGPT/packages/service/common/geo/index.ts +import type { NextApiRequest } from 'next'; +// 同时导出一个依赖给 FastGPT/packages/service/test/common/geo/index.test.ts 使用 +export type { NextApiRequest } from 'next'; +``` + +4. 尽量减少函数 mock,如果是系统上原生可运行的函数,则无需 mock,只需要 mock 那些无法本地直接运行的依赖(比如需要远程服务,API 密钥之类的) +5. 对于 type.ts, constants.ts, schema.ts, *.schema.ts 文件,以及静态数据,直接跳过忽略。 +6. 根据 [vitest.config.mts](../../../vitest.config.mts) 文件配置,跳过不需要测试的文件。 +7. [Mock.ts](../../../test/mocks/index.ts) 文件里,包含了全局 mock 的内容,在编写测试时,请勿重复 mock。理论上,测试里可以 mock 运行各类 infra。 + +### 基础函数文件测试 + +尽量不要 mock,而是完整的运行其逻辑进行测试。 + +### 带 API 请求的函数 + +mock 对应的 API 请求进行测试。 + +## 编写流程 + +**一、任务准备** + +1. 获取所需要编写的测试文件。 +2. 创建任务清单,来逐个完成每个文件的测例编写。 + +**二、测例编写** + +不同测例文件,可以并行进行编写。 + +1. 检查对应的 .test.ts 测试文件,如果没有则创建。 +2. 思考和分析代码后编写测试样例。 +3. 检查 TS 错误,确保无 ts 报错。 +4. 完成所有测试文件编写 + +**三、结果验证** + +1. 调用`pnpm test `来运行测试并检查覆盖率,确保每个文件的覆盖率达到 90% 以上。 +2. 如果测试不通过,则根据错误信息检查代码逻辑或者测试用例。 +3. 如需二次修改,则回到”二、测例编写“。 + +## 单测包含哪些场景 + +1. 基础场景 +2. 复杂场景 +3. 边界值 +4. 安全边界情况(死循环、系统崩溃、超大数据等) +5. 异常场景 + +## 常用命令 + +```shell +# 运行所有测试 +pnpm test + +# 运行指定测试文件(file-path 填完整文件路径) +pnpm test + +# 运行指定测试文件的指定测试 +pnpm test +``` diff --git a/.gitignore b/.gitignore index 6555745543..edf89b6401 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,4 @@ document/.source projects/app/worker/ pro/admin/worker/ -# Agent -.codex - .turbo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..b9a526bea2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +本文件为 Agent 在本仓库中工作时提供指导说明。 + +## 项目概述 + +FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据处理、模型调用能力和可视化工作流编排。这是一个基于 NextJS 构建的全栈 TypeScript 应用,后端使用 MongoDB/PostgreSQL。 + +**技术栈**: NextJS + TypeScript + ChakraUI + MongoDB + VectorDB(PG, Milvus, Zilliz, OceanBase, SeekDB, OpenGauss......) + +## 设计文档 + +你可以参考 [项目设计文档](./.codex/design/) 来了解 FastGPT 已有的设计方案。 + +## 架构 + +这是一个使用 pnpm workspaces 的 monorepo,主要结构如下: + +### Packages (库代码) +- `packages/global/` - 所有项目共享的类型、常量、工具函数 +- `packages/service/` - 后端服务、数据库模型、API 控制器、工作流引擎 +- `packages/web/` - 共享的前端组件、hooks、样式、国际化 + +### Projects (应用程序) +- `projects/app/` - 主 NextJS Web 应用(前端 + API 路由) +- `projects/code-sandbox/` - Bun + Hono 代码执行沙箱服务 +- `projects/mcp_server/` - Model Context Protocol 服务器实现 + +### 关键目录 +- `document/` - 文档站点(NextJS 应用及内容) +- `plugins/` - 外部插件(模型、爬虫等) +- `deploy/` - Docker 和 Helm 部署配置 +- `test/` - 集中的测试文件和工具 + +## 开发命令 + +### 项目专用命令 +**主应用 (projects/app/)**: +- `cd projects/app && pnpm dev` - 启动 NextJS 开发服务器 +- `cd projects/app && pnpm build` - 构建 NextJS 应用 +- `cd projects/app && pnpm start` - 启动生产服务器 + +**代码沙箱 (projects/code-sandbox/)**: +- `cd projects/code-sandbox && pnpm dev` - 以监视模式启动(Bun) +- `cd projects/code-sandbox && pnpm build` - 构建沙箱服务 +- `cd projects/code-sandbox && pnpm test` - 运行 Vitest 测试 + +**MCP 服务器 (projects/mcp_server/)**: +- `cd projects/mcp_server && bun dev` - 使用 Bun 以监视模式启动 +- `cd projects/mcp_server && bun build` - 构建 MCP 服务器 +- `cd projects/mcp_server && bun start` - 启动 MCP 服务器 + +### 工具命令 +- `pnpm lint` - 对所有 TypeScript 文件运行 ESLint 并自动修复 +- `pnpm initIcon` - 初始化图标资源 +- `pnpm gen:theme-typings` - 生成 Chakra UI 主题类型定义 + +## 测试 + +项目使用 Vitest 进行测试并生成覆盖率报告。主要测试命令: +- `pnpm test` - 运行所有测试 +- `pnpm test {file-path}` - 使用 Vitest 运行指定测试文件的指定测试 +- 测试文件位于 `test/` 目录和 `projects/{{name}}/test/`,代表这`packages`和`单个 project`的测试文件目录。 +- 覆盖率报告生成在 `coverage/` 目录 + +## 代码组织模式 + +### Monorepo 结构 +- 共享代码存放在 `packages/` 中,通过 workspace 引用导入 +- `projects/` 中的每个项目都是独立的应用程序 +- 使用 `@fastgpt/global`、`@fastgpt/service`、`@fastgpt/web` 导入共享包 + +### API 结构 +- NextJS API 路由在 `projects/app/src/pages/api/` +- API 路由合约定义在`packages/global/openapi/`, 对应的 +- 通用服务端业务逻辑在 `packages/service/`和`projects/app/src/service` +- 数据库模型在 `packages/service/` 中,使用 MongoDB/Mongoose + +### 前端架构 +- React 组件在 `projects/app/src/components/` 和 `packages/web/components/` +- 使用 Chakra UI 进行样式设计,自定义主题在 `packages/web/styles/theme.ts` +- 国际化支持文件在 `packages/web/i18n/` +- 使用 React Context 和 Zustand 进行状态管理 + +## 开发注意事项 + +- **包管理器**: 使用 pnpm 及 workspace 配置 +- **Node 版本**: 需要 Node.js >=20.x, pnpm >=9.x +- **数据库**: 支持 MongoDB、带 pgvector 的 PostgreSQL 或 Milvus 向量存储 +- **AI 集成**: 通过统一接口支持多个 AI 提供商 +- **国际化**: 完整支持中文、英文和日文 + +## 关键文件模式 + +- `.ts` 和 `.tsx` 文件全部使用 TypeScript +- 数据库模型使用 Mongoose 配合 TypeScript +- API 路由遵循 NextJS 约定 +- 组件文件使用 React 函数式组件和 hooks +- 共享类型定义在 `packages/global/`中 + +## 环境配置 + +- 配置文件在 `projects/app/data/config.json` +- 支持特定环境配置 +- 模型配置在 `packages/service/core/ai/config/` + +## 代码规范 + +[FastGPT 代码规范](./.codex/code/syntax.md) + +## 运行要求 + +### 性格 + +1. 保持怀疑态度,要深入思考和分析现有代码,提出问题,并让用户确认。 +2. 编写单个需求时,运行测试命令,中途不要运行全量测试,只需局部测试即可,只需最后运行全量测试,确保没有问题。 + +### 工作流程 + +对于简单任务,可以直接进行编写实现,对于复杂任务,遵循以下流程: + +function agent_loop(用户需求){ + // 1. 需求文档编写 + while(需求文档编写未完成){ + 用户需求分析 + 编写需求分析文档; + 提出问题,让用户提供答案; + 调整需求文档; + } + + // 2. 开发文档编写 + while(开发文档编写未完成){ + 编写开发文档; + 提出问题,让用户提供答案; + 调整开发文档; + } + + // 3. 列出 TODO + while(TODO 列表编写未完成){ + 编写 TODO 列表; // 包含写代码,运行测试等,需要与开发文档对应 + 提出问题,让用户提供答案; + 调整 TODO 列表; + } + + // 4. 执行 TODO List + while(TODO List 执行未完成){ + 执行 TODO List; + 更新 TODO List 状态; + } +} + +### 输出规范 + +1. 输出语言:中文 +2. 输出文档位置: + 2.1. 设计文档: [.codex/design](.codex/design),todo 跟在设计文档后面。 + 2.2. 问题分析文档: [.codex/issue](.codex/issue) +3. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 +4. 文件输出,使用正确的编码格式,例如UTF-8。 +5. 除非用户指明,否则不要编写总结报告。 \ No newline at end of file diff --git a/pro b/pro index e56f30f8ab..3b9a14da94 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit e56f30f8abe05dffb68b50778c06b85c51381335 +Subproject commit 3b9a14da941d603ac4065dc0f4fec049f630348a