diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b14e601e461e..39fc6afdebdf 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -88,7 +88,8 @@ FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据 - **Node 版本**: 需要 Node.js >=20.x, pnpm >=9.x - **数据库**: 支持 MongoDB、带 pgvector 的 PostgreSQL 或 Milvus 向量存储 - **AI 集成**: 通过统一接口支持多个 AI 提供商 -- **国际化**: 完整支持中文、英文和日文 +- **国际化**: 完整支持中文、英文和繁体中文 +- **接口一致性**: 前端为请求参数添加的必填项、默认值等校验逻辑,必须与后端接口保持一致。不能仅在前端做特殊处理,否则暴露接口后,直接调用 API 的客户会因缺少这些参数或默认值而导致接口行为不一致。如果必须不一致需要用户代码调用时候维护的需要补充代码注释说明,否则在提交代码前提示用户处理,必须拒绝提交代码 ## 关键文件模式 @@ -127,7 +128,7 @@ function agent_loop(用户需求){ 提出问题,让用户提供答案; 调整需求文档; } - + // 2. 开发文档编写 while(开发文档编写未完成){ 编写开发文档; @@ -157,4 +158,15 @@ function agent_loop(用户需求){ 2.2. 问题分析文档: .claude/issue 3. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 4. 文件输出,使用正确的编码格式,例如UTF-8。 -5. 除非用户指明,否则不要编写总结报告。 \ No newline at end of file +5. 除非用户指明,否则不要编写总结报告。 + + +## CodeGraph + +In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: + +- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search. +- **Shell** (always works): `codegraph explore ""` and `codegraph node ` print the same output. + +If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision. + diff --git a/.claude/design/aiproxy-channel-permission-design.md b/.claude/design/aiproxy-channel-permission-design.md new file mode 100644 index 000000000000..658f2c125f59 --- /dev/null +++ b/.claude/design/aiproxy-channel-permission-design.md @@ -0,0 +1,340 @@ +# FastGPT x AIProxy Channel 权限绕过修复方案 + +## 1. 文档目的 + +本文档用于沉淀 `aiproxy` channel 权限绕过问题的预研结论,作为后续实施、联调、测试和上线的指导文档。 + +适用问题场景: + +1. 用户 A 创建了自定义模型 `00000xxx001`,模型名为 `qwen-3.6`,且未配置 `requestUrl/requestAuth`。 +2. root 创建了另一条模型 `00000xxx002`,模型名同为 `qwen-3.6`,并在 `aiproxy` 中配置了对应 `model-channel`。 +3. 用户 A 在 FastGPT 中测试或调用自己的模型时,实际越权命中了 root 的 `model-channel`。 + +## 2. 问题结论 + +问题本质不是 FastGPT 的“模型读权限”失效,而是: + +1. FastGPT 在校验用户可访问某个 `modelId` 后,调用 `aiproxy` 时只传了上游模型名 `model=qwen-3.6`。 +2. `aiproxy` 默认按“模型名 -> channel 列表”做路由,不知道当前请求实际对应的是哪一条 FastGPT 模型配置。 +3. 因此,只要不同 FastGPT 模型共享同一个上游模型名,且其中某些模型没有直连配置,`aiproxy` 就可能把请求路由到不属于当前 FastGPT 模型的 channel。 + +## 3. 根因拆解 + +### 3.1 FastGPT 侧 + +FastGPT 当前链路的关键特征: + +1. 先用 `modelId` 做权限校验,只能证明“用户能访问这条 FastGPT 模型配置”。 +2. 当模型未配置 `requestUrl/requestAuth` 时,请求会回退到全局 `AIPROXY_API_ENDPOINT + AIPROXY_API_TOKEN`。 +3. 发往 `aiproxy` 的运行时请求,通常只包含上游模型名,不包含 FastGPT 侧的来源模型身份。 +4. 模型测试接口只有在显式传入 `channelId` 时,才会使用 `Aiproxy-Channel` 指定 channel。 + +涉及代码入口: + +- `projects/app/src/pages/api/core/ai/model/test.ts` +- `packages/service/support/permission/model/auth.ts` +- `packages/service/core/ai/config.ts` +- `packages/service/core/ai/llm/request.ts` +- `packages/service/core/ai/embedding/index.ts` +- `packages/service/core/ai/rerank/index.ts` +- `packages/service/core/ai/audio/transcriptions.ts` + +### 3.2 AIProxy 侧 + +`aiproxy` 当前链路的关键特征: + +1. `TokenAuth` 只识别 token/group 级别权限,不理解 FastGPT 的 `teamId/tmbId/modelId`。 +2. 默认选路时,直接从 `model -> channels` 缓存里获取候选 channel。 +3. 只要 channel 声明支持 `qwen-3.6`,就会进入候选集。 +4. `aiproxy` 虽然已经可以解析部分请求体中的 `metadata`,但目前没有用它约束 channel 归属。 + +涉及代码入口: + +- `../aiproxy/core/middleware/auth.go` +- `../aiproxy/core/controller/relay-channel.go` +- `../aiproxy/core/model/model_caches.go` +- `../aiproxy/core/middleware/distributor.go` + +## 4. 设计目标 + +本次修复目标: + +1. 阻断“同名上游模型”在 `aiproxy` 侧误命中其他 FastGPT 模型专属 channel 的问题。 +2. 尽量不引入 FastGPT 权限体系与 `aiproxy` token/group 体系的深度耦合。 +3. 尽量不改 `aiproxy` 的数据库表结构和基础鉴权模型。 +4. 保持现有公共 channel 的兼容性。 +5. 让模型测试、正常推理、embedding、rerank、TTS、STT 等链路都能复用同一套约束机制。 + +## 5. 非目标 + +本次方案不做以下事情: + +1. 不把 FastGPT 的 `teamId/tmbId/协作权限` 完整同步到 `aiproxy`。 +2. 不重做 `aiproxy` 的 token/group 权限模型。 +3. 不要求所有自定义模型都必须填写 `requestUrl/requestAuth`。 +4. 不改变公共 channel 的默认行为。 + +## 6. 选型结论 + +选用“来源模型身份透传 + channel 归属过滤”的轻量方案。 + +核心思想: + +1. FastGPT 在每次发往 `aiproxy` 的请求中,显式带上“来源 FastGPT 模型 ID”。 +2. `aiproxy` 在选择 channel 前,判断该 channel 是否被限制为某个 FastGPT 模型专用。 +3. 若 channel 是专用 channel,则只有来源模型 ID 命中时才允许参与路由。 +4. 若 channel 未配置该限制,则保持当前行为,继续作为公共 channel 使用。 + +这是对现有方案的“实施化细化”,不改变原始选型原则,只是把“来源模型身份”的承载方式从抽象概念落到可实现的协议上。 + +## 7. 为什么最终建议用请求头承载身份 + +原始预研里,来源身份建议放在请求体 `metadata` 中。这条思路对 JSON 请求成立,但直接落地有一个缺口: + +1. `aiproxy` 当前只会在部分 JSON 模式下解析 `metadata`。 +2. `AudioTranscription` 等 `multipart/form-data` 请求路径,并不会走相同的 `metadata` 解析逻辑。 +3. 仅靠 JSON `metadata`,无法完整覆盖模型测试和全部模型调用链路。 + +因此,实施时建议: + +1. 以请求头作为来源模型身份的主承载方式。 +2. 对支持 JSON `metadata` 的链路,再镜像写入一份 `metadata`,方便日志和后续扩展。 + +这样可以统一覆盖 JSON 请求和 multipart 请求。 + +## 8. 协议设计 + +### 8.1 FastGPT -> AIProxy 请求身份 + +建议新增以下请求头: + +```http +X-FastGPT-Model-Id: 00000xxx001 +X-FastGPT-Team-Id: +``` + +字段约定: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `X-FastGPT-Model-Id` | 是 | 当前请求在 FastGPT 中对应的模型 ID | +| `X-FastGPT-Team-Id` | 否 | 当前模型所属团队 ID,仅用于日志或未来扩展 | + +对支持 JSON 请求体的链路,建议同步写入: + +```json +{ + "metadata": { + "fastgpt_model_id": "00000xxx001", + "fastgpt_team_id": "team-xxx" + } +} +``` + +落地约束: + +1. 只要请求最终走 `aiproxy`,就应带上 `X-FastGPT-Model-Id`。 +2. 若模型本身配置了 `requestUrl/requestAuth`,直接走外部地址,则不强制要求该头。 +3. 如果调用方已存在 `metadata`,应做 merge,不应覆盖业务已有字段。 + +### 8.2 AIProxy Channel 归属配置 + +基于 `Channel.Configs` 增加一个可选字段: + +```json +{ + "fastgpt_owner_model_id": "00000xxx002" +} +``` + +字段定义: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `fastgpt_owner_model_id` | 否 | 声明该 channel 只允许某个 FastGPT 模型 ID 使用 | + +选择这个字段的原因: + +1. `Channel.Configs` 已经是扩展 JSON,无需表结构迁移。 +2. 单值字段足够解决本次越权问题。 +3. 语义清晰,便于排障和运营配置。 + +### 8.3 V1 匹配规则 + +V1 规则如下: + +1. channel 未配置 `fastgpt_owner_model_id` 时,视为公共 channel,保持现有行为。 +2. channel 配置了 `fastgpt_owner_model_id` 时: + - 请求必须携带 `X-FastGPT-Model-Id` + - 且请求头中的模型 ID 必须与 `fastgpt_owner_model_id` 相等 +3. 不相等时,该 channel 不能进入候选集。 +4. 对显式指定 channel 的路径,同样应用该校验,不能因为手工指定 `Aiproxy-Channel` 而绕过。 + +## 9. 路由行为定义 + +### 9.1 默认路由 + +默认路由流程调整为: + +1. 先按当前逻辑拿到 `modelName` 对应的候选 channel 列表。 +2. 再按 `fastgpt_owner_model_id` 与 `X-FastGPT-Model-Id` 进行二次过滤。 +3. 过滤后的结果继续走原有优先级、错误率、重试和 fallback 逻辑。 + +### 9.2 显式 `Aiproxy-Channel` + +当前 FastGPT 模型测试接口支持显式传 `channelId`。该路径必须补一层约束: + +1. 先根据 `Aiproxy-Channel` 解析到 channel。 +2. 再校验该 channel 是否允许当前 `X-FastGPT-Model-Id` 使用。 +3. 校验失败时,直接返回无权限或 channel 不可用错误。 + +### 9.3 Pinned channel + +`aiproxy` 某些链路会在上下文中带回 `ChannelID` 做回查。该路径同样应复用相同的归属校验函数,避免固定 channel 成为旁路。 + +## 10. 推荐实现方式 + +### 10.1 FastGPT 侧 + +建议新增一个统一的“AIProxy 请求身份构造器”,负责生成 headers 和 JSON metadata。 + +建议输出: + +```ts +type AIProxyRequestIdentity = { + headers: Record; + metadata: Record; +}; +``` + +建议责任: + +1. 输入当前 `model.id`、可选 `teamId`。 +2. 产出 `X-FastGPT-Model-Id` 和可选 `X-FastGPT-Team-Id`。 +3. 对 JSON 请求产出可 merge 的 `metadata.fastgpt_model_id`。 +4. 被 LLM、Embedding、Rerank、TTS、STT、模型测试等链路统一复用。 + +建议接入点: + +- `projects/app/src/pages/api/core/ai/model/test.ts` +- `packages/service/core/ai/llm/request.ts` +- `packages/service/core/ai/embedding/index.ts` +- `packages/service/core/ai/rerank/index.ts` +- `packages/service/core/ai/audio/transcriptions.ts` +- 其他所有最终走 `AIPROXY_API_ENDPOINT` 的模型调用入口 + +### 10.2 AIProxy 侧 + +建议新增一个 channel 访问约束结构: + +```go +type FastGPTChannelAccessConfig struct { + FastGPTOwnerModelID string `json:"fastgpt_owner_model_id"` +} +``` + +建议新增一个统一判定函数,例如: + +```go +func isChannelAllowedForFastGPTModel(c *gin.Context, channel *model.Channel) bool +``` + +判定逻辑: + +1. 读取 channel `Configs.fastgpt_owner_model_id` +2. 若为空,返回允许 +3. 读取请求头 `X-FastGPT-Model-Id` +4. 若请求头为空,返回不允许 +5. 若两者不相等,返回不允许 +6. 相等则返回允许 + +建议接入点: + +1. `GetChannelFromHeader` +2. `GetChannelFromRequest` +3. `getAvailableChannels` + +这样可以同时覆盖: + +1. 默认按模型名选路 +2. 显式 `Aiproxy-Channel` +3. pinned channel 回查 + +## 11. 兼容性策略 + +该方案默认兼容现网,前提是按以下方式上线: + +1. 新代码上线后,不立即给现有 channel 配置 `fastgpt_owner_model_id` +2. 先让 FastGPT 稳定透传 `X-FastGPT-Model-Id` +3. 确认日志中已能看到来源模型身份 +4. 再逐步给“专属 channel”补上 `fastgpt_owner_model_id` + +兼容性结论: + +1. 不配置 `fastgpt_owner_model_id` 的 channel 完全保持旧行为。 +2. 新版 FastGPT 对旧版 `aiproxy` 是向后兼容的,旧版会忽略新请求头。 +3. 新版 `aiproxy` 对未加限制的 channel 是向后兼容的。 +4. 只有在 channel 被配置为专属后,请求侧才必须带上来源模型身份。 + +## 12. 测试与验收 + +### 12.1 必测正向用例 + +1. root 模型 `00000xxx002` 调用 `qwen-3.6`,命中自己配置的专属 channel。 +2. 用户 A 模型 `00000xxx001` 调用 `qwen-3.6`,若存在自己的专属 channel,则命中自己的 channel。 +3. 用户 A 模型 `00000xxx001` 调用 `qwen-3.6`,若只有公共 channel,则正常走公共 channel。 +4. 模型测试接口显式指定正确的 `channelId`,测试通过。 + +### 12.2 必测反向用例 + +1. 用户 A 模型 `00000xxx001` 不能命中 root 模型 `00000xxx002` 的专属 channel。 +2. 模型测试接口即使显式传入 root 的 `channelId`,也不能绕过限制。 +3. 缺少 `X-FastGPT-Model-Id` 的请求,不能命中任何配置了 `fastgpt_owner_model_id` 的 channel。 +4. multipart 链路的 STT 测试与调用,仍然能正确做归属校验。 + +### 12.3 回归用例 + +1. 未配置 `fastgpt_owner_model_id` 的历史 channel 不受影响。 +2. 重试、错误率降级、优先级选路逻辑不受影响。 +3. embedding、rerank、TTS、STT、LLM 的公共调用能力不受影响。 + +## 13. 上线建议 + +建议按以下顺序上线: + +1. `aiproxy` 发布支持识别 `X-FastGPT-Model-Id` 和 `fastgpt_owner_model_id` 的版本。 +2. FastGPT 发布统一透传 `X-FastGPT-Model-Id` 的版本。 +3. 联调验证 headers 和日志字段已生效。 +4. 先选一组 root 专属 channel 做灰度配置。 +5. 验证无误后,逐步为所有需要隔离的 channel 配置 `fastgpt_owner_model_id`。 + +## 14. 方案边界与后续扩展 + +V1 的边界: + +1. 一个专属 channel 只绑定一个 FastGPT 模型 ID。 +2. 如果后续需要“一个 channel 允许多个 FastGPT 模型共享”,再扩展为: + +```json +{ + "fastgpt_allowed_model_ids": ["model-a", "model-b"] +} +``` + +V1 不建议直接实现多值白名单,原因是: + +1. 本次问题只需要解决“一对一专属 channel 被串用”。 +2. 单值绑定更容易配置和排障。 +3. 先上线最小闭环,再根据业务需要扩展。 + +## 15. 最终结论 + +最终推荐落地方案如下: + +1. FastGPT 统一透传 `X-FastGPT-Model-Id` 作为来源模型身份。 +2. 对 JSON 请求,额外镜像写入 `metadata.fastgpt_model_id` 用于日志和扩展。 +3. `aiproxy` 在 channel `Configs` 中支持 `fastgpt_owner_model_id`。 +4. `aiproxy` 在默认选路、显式指定 channel、pinned channel 三条路径上统一执行归属校验。 +5. 未配置归属限制的 channel 继续保持公共行为。 + +该方案改动面小、耦合低、兼容性好,能以最小成本修复当前的 channel 权限绕过问题。 diff --git a/.claude/design/common/workflow/index.md b/.claude/design/common/workflow/index.md new file mode 100644 index 000000000000..9b037bbeec3b --- /dev/null +++ b/.claude/design/common/workflow/index.md @@ -0,0 +1,604 @@ +--- +name: workflow-agent +description: 当用户需要开发工作流代码时候,可调用此 Agent。 +model: inherit +color: green +--- + +# 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?: ChatItemType[]; + 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**: 标准化的节点开发接口 + +#### 主题定制 + +- **节点样式**: 可定制的节点外观 +- **连线样式**: 自定义连线类型和颜色 +- **布局配置**: 多种布局算法支持 +- **国际化**: 多语言界面支持 diff --git a/.claude/design/dev-proxy.md b/.claude/design/dev-proxy.md new file mode 100644 index 000000000000..7473491535bb --- /dev/null +++ b/.claude/design/dev-proxy.md @@ -0,0 +1,80 @@ +# 本地开发代理线上环境方案 + +## 背景 + +本地开发时,前端代码在本地运行,但不想搭建本地数据库和后端服务,希望 API 请求直接打到线上环境。 + +## 架构 + +``` +浏览器 + │ HTTP 请求 localhost:3000/api/* + ▼ +Next.js dev server(本地前端代码) + │ beforeFiles rewrite(HTTP,无 TLS) + ▼ +本地 HTTP 中间代理 localhost:19444 + │ Node.js 原生 https.request() + │ rejectUnauthorized: false + ▼ +生产服务器 https://: +``` + +## 关键设计决策 + +### 1. 为什么用 `beforeFiles` rewrite? + +Next.js rewrites 默认是 `afterFiles`,会先匹配本地文件系统。FastGPT 本地存在完整的 API 路由文件(如 `/api/core/app/list.ts`),会优先命中本地路由,rewrite 永远不执行。 + +改为 `beforeFiles` 后,rewrite 在文件系统检查之前运行,本地 API 路由完全绕过。 + +### 2. 为什么需要本地 HTTP 中间代理? + +Next.js 14+ 内部用 `undici` 执行 rewrites 代理。`undici` 有独立的 TLS 实现,不读取 `NODE_TLS_REJECT_UNAUTHORIZED` 环境变量,无法通过该变量跳过自签名证书校验。 + +解决方案:在本地启一个 HTTP 代理服务器,Next.js rewrite 指向它(HTTP,无 TLS 问题),由它用 Node.js 原生 `https.request()` 转发到线上(支持 `rejectUnauthorized: false`)。 + +### 3. Cookie 如何透传? + +本地 HTTP 代理转发请求时复制所有原始请求头(`headers: { ...req.headers }`),浏览器携带的 `cookie` 原样转发到生产服务器。 + +## 使用方式 + +### 启动命令 + +```bash +cd projects/app +pnpm dev:proxy +# 或指定目标地址 +pnpm dev:proxy url=https://10.109.96.195:19443 +``` + +### 配置文件 + +`scripts/dev-proxy.js` 中修改默认地址: + +```js +const DEFAULT_PROXY_URL = 'https://your-production-server'; +``` + +### 鉴权(Cookie 设置) + +生产服务器使用 `fastgpt_token` HttpOnly Cookie 鉴权。由于该 Cookie 是生产域名下的,不会自动出现在 localhost,需手动设置一次: + +1. 在生产环境登录,打开 DevTools → Application → Cookies,复制 `fastgpt_token` 的值 +2. 在 localhost 的控制台执行: + +```js +document.cookie = "fastgpt_token=; path=/; max-age=604800" +``` + +之后代理会自动携带该 Cookie 转发到生产服务器。 + +## 涉及文件 + +| 文件 | 说明 | +|------|------| +| `scripts/dev-proxy.js` | 启动脚本:运行本地 HTTP 中间代理 + Next.js dev server | +| `next.config.js` | `beforeFiles` rewrite,将 `/api/*` 转发到本地代理 | +| `src/middleware.ts` | 打印每次代理请求的日志 | +| `.env.local` | `PROXY_API_TARGET` 配置代理目标 | diff --git a/.claude/design/embedding-training-architecture.md b/.claude/design/embedding-training-architecture.md new file mode 100644 index 000000000000..70636114040e --- /dev/null +++ b/.claude/design/embedding-training-architecture.md @@ -0,0 +1,399 @@ +# Embedding 训练平台架构文档 + +> 本文档梳理 FastGPT Embedding 训练任务的执行流程、数据模型关系和关键设计决策。 + +## 1. 系统总览 + +Embedding 训练平台与 Rerank 训练平台共享相同的技术框架(BullMQ + MongoDB),通过 **6 阶段流水线** 实现从数据准备到模型微调、评测的全流程自动化。支持两种模式: + +- **精确模式(Exact Mode)**:用户提供训练集 + 评测集,系统跳过生成阶段 +- **自动模式(Auto Mode)**:用户提供知识库 IDs,系统自动生成训练集和评测集 + +**注:** 训练任务仅负责生成微调 embedding 模型。模型应用(手动更新 Dataset 的 `vectorModel` 字段并触发向量重建)由用户通过独立工作流手动决策,不由训练 pipeline 自动执行。 + +## 2. 数据模型关系 + +### 2.1 ER 关系图 + +```mermaid +erDiagram + Team ||--o{ EmbeddingTrainTask : owns + Team ||--o{ EmbeddingTrainset : owns + Team ||--o{ Dataset : owns + + EmbeddingTrainTask }o--o| EmbeddingTrainset : "trainsetId (精确模式传入/自动模式回写)" + EmbeddingTrainTask }o--o| EvalDatasetCollection : "evalDatasetId" + EmbeddingTrainTask }o--|| SystemModel : "baseModelId" + EmbeddingTrainTask }o--o| SystemModel : "checkpoint.data.registering.tunedModelId" + + EmbeddingTrainset ||--o{ EmbeddingTrainsetData : "trainsetId" + + EvalDatasetCollection ||--o{ EvalDatasetData : "evalDatasetCollectionId" + + Dataset ||--o{ DatasetSearchEmbedding : "向量检索" + SystemModel ||--o| AIProxyChannel : "model (通道路由)" + + EmbeddingTrainsetData }o--o| Dataset : "metadata.sourceInfo.datasetInfo.datasetId" +``` + +### 2.2 核心模型字段 + +#### EmbeddingTrainTask(训练任务) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | ObjectId | 任务 ID | +| `teamId` / `tmbId` | ObjectId | 权限归属 | +| `baseModelId` | String | 基础 embedding 模型 ID → SystemModel.model | +| `baseModelEndpoint` | Object | 基础模型端点 {base_url, model, api_key} | +| `trainsetId` | ObjectId? | → EmbeddingTrainset._id | +| `evalDatasetId` | String? | → EvalDatasetCollection._id | +| `datasetIds` | String[]? | 知识库 IDs(仅自动模式) | +| `trainType` | String | `'lora'` \| `'ptuning'`,默认 lora | +| `status` | Enum | pending / running / completed / failed / cancelled | +| `checkpoint` | Object | 阶段进度(见 §3.2) | +| `result` | Object? | 最终结果汇总 | +| `jobId` | String? | BullMQ 任务 ID | + +#### EmbeddingTrainset(训练集) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | ObjectId | 训练集 ID | +| `teamId` / `tmbId` | ObjectId | 权限归属 | +| `name` | String | 名称 | +| `description` | String? | 描述 | +| `status` | Enum | pending / generating / ready / error | +| `statistics` | Virtual | 动态计算(dataCount, positiveCount, negativeCount, sourceSummary) | + +#### EmbeddingTrainsetData(训练数据) + +与 `RerankTrainsetData` 完全相同的 schema: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `trainsetId` | ObjectId | → EmbeddingTrainset._id | +| `query` | String | 查询文本(即 anchor 文本) | +| `positiveDocs` | String[] | 正例文档(与 query 语义相似) | +| `negativeDocs` | String[] | 负例文档(与 query 语义无关) | +| `source` | Enum | dataset / chat_log / manual | +| `metadata.sourceInfo` | Object | 来源详情(datasetInfo / chatLogInfo / manualInfo) | + +**注:** `query` 字段语义上对应 embedding 的锚点文本,保持与 rerank 相同的字段名以最大化代码复用。 + +#### SystemModel(模型配置) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `model` | String | 模型标识(唯一键) | +| `type` | Enum | `'embedding'` | +| `metadata.isActive` | Boolean | 是否启用 | +| `metadata.isTuned` | Boolean | 是否为训练模块创建的微调模型 | +| `metadata.provider` | String | 模型提供商 | + +#### Dataset(知识库) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `vectorModel` | String? | 当前使用的 embedding 模型 ID → SystemModel.model | + +## 3. 训练任务执行流程 + +### 3.1 六阶段流水线 + +```mermaid +flowchart TD + API["API: POST /api/core/train/embedding/task/create"] --> Q["BullMQ 入队"] + Q --> P["Processor 取出任务
status: pending → running"] + + P --> S1["Stage 1: generate_trainset
准备训练数据 JSONL
格式: query/pos/neg"] + S1 --> S2["Stage 2: generate_evaldataset
生成评测数据集
(无需 performDatasetSearch)"] + S2 --> S3["Stage 3: eval_basemodel
评测基础 embedding 模型"] + S3 --> S4["Stage 4: finetuning
SFT Bridge 微调
taskType: embed"] + S4 --> S5["Stage 5: registering
注册模型 + 创建 Channel"] + S5 --> S6["Stage 6: eval_tunedmodel
评测微调 embedding 模型"] + + S6 --> Done["status → completed"] + + style S1 fill:#e1f5fe + style S2 fill:#e1f5fe + style S3 fill:#fff3e0 + style S4 fill:#fce4ec + style S5 fill:#f3e5f5 + style S6 fill:#fff3e0 +``` + +### 3.2 检查点(Checkpoint)结构 + +每个 stage 完成后保存检查点,支持断点续传。任务重启时自动从上次完成的 stage 之后继续执行。 + +```mermaid +flowchart LR + subgraph checkpoint.data + GT["generate_trainset
trainDatasetId
trainDatasetFilePath
autoGenerated"] + GE["generate_evaldataset
evalDatasetId
autoGenerated"] + EB["eval_basemodel
baseModelEvalResult"] + FT["finetuning
sftTaskId
tunedModelEndpoint"] + RG["registering
tunedModelId"] + ET["eval_tunedmodel
tunedModelEvalResult"] + end + + GT --> GE --> EB --> FT --> RG --> ET +``` + +### 3.3 各阶段详情 + +| 阶段 | 文件 | 外部服务 | 耗时 | 产出 | +|------|------|---------|------|------| +| 1. generate_trainset | `stages/generate-trainset.ts` | — | ~10 min | JSONL 训练文件(query/pos/neg 三元组) | +| 2. generate_evaldataset | `stages/generate-evaldataset.ts` | DiTing (QA 生成) | ~30 min | EvalDatasetCollection + Data(**无 retrievalContextsFull**) | +| 3. eval_basemodel | `stages/eval-basemodel.ts` | DiTing (`/evaluations/embed`) | ~5-10 min | EmbeddingEvalResult(MRR、Precision) | +| 4. finetuning | `stages/finetune.ts` | SFT Bridge (微调+部署,taskType: 'embed') | 1-10 h | tunedModelEndpoint | +| 5. registering | `stages/register.ts` | AI Proxy (通道创建) | ~30 s | tunedModelId (SystemModel) | +| 6. eval_tunedmodel | `stages/eval-tunedmodel.ts` | DiTing (`/evaluations/embed`) | ~5-10 min | EmbeddingEvalResult(MRR、Precision) | + +#### Stage 1:generate_trainset(两种模式) + +| 模式 | 流程 | +|------|------| +| **Exact Mode** | 等待已有 trainset 状态 → `ready`(轮询,60 min 超时),验证数据不为空后导出 JSONL | +| **Auto Mode** | ① 创建新 trainset → ② 入队 data generate job → ③ 等待 trainset 状态 → `ready` → ④ 导出 JSONL | + +等待轮询已覆盖 `error` 状态(立即抛出 `TrainTaskUnrecoverableError`,不等待超时)。 + +#### Stage 2:generate_evaldataset(与 Rerank 的关键差异) + +| 维度 | Embedding | Rerank | +|------|-----------|--------| +| 流程 | 采样 → DiTing QA 生成 → 直接存储 | 采样 → DiTing QA 生成 → `performDatasetSearch` → 存储 | +| 存储内容 | `expectedContextIds`(相关文档标记) | `retrievalContextsFull`(排序候选列表) | +| 原因 | 评测相似度只需相关性标记 | 评测排序效果需要检索上下文 | +| 性能 | 省去检索调用,速度提升 ~30-50% | 需额外检索 | + +### 3.4 状态机 + +```mermaid +stateDiagram-v2 + [*] --> pending: 创建任务 + + pending --> running: Processor 取出 + pending --> cancelled: 用户取消 + + running --> completed: 6 阶段全部完成 + running --> failed: 不可恢复错误 + running --> cancelled: 用户取消 (Stage 4 检测) + + failed --> running: 重试 (retry API) + + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +## 4. 外部服务集成 + +```mermaid +flowchart LR + subgraph FastGPT + Proc["Task Processor"] + Model["SystemModel
(embedding 模型配置)"] + Proxy["AI Proxy
(通道路由)"] + end + + subgraph External["外部服务"] + DiTing["DiTing
数据合成 + 向量评测"] + SFT["SFT Bridge
模型微调 + 部署
(taskType: embed)"] + end + + Proc -->|"Stage 2: 生成 QA"| DiTing + Proc -->|"Stage 3,6: 评测模型"| DiTing + Proc -->|"Stage 4: 创建微调任务"| SFT + SFT -->|"返回推理端点"| Proc + Proc -->|"Stage 5: 注册模型"| Model + Proc -->|"Stage 5: 创建通道"| Proxy + Proxy -->|"推理请求路由"| SFT +``` + +| 服务 | 用途 | 环境变量 | +|------|------|---------| +| **DiTing** | QA 对生成、训练数据合成、embedding 向量质量评测(`/evaluations/embed`) | `DITING_BASE_URL` | +| **SFT Bridge** | LoRA/P-Tuning 微调、模型部署、推理服务(`taskType: 'embed'`) | `SFT_BRIDGE_BASE_URL` | +| **AI Proxy** | 模型通道管理、请求路由 | `AIPROXY_API_ENDPOINT` | + +**SFT Bridge 客户端**统一在 `packages/service/core/train/common/external/sftbridge/` 中维护,embedding 和 rerank 均从此处导入(通过各自的 `external/index.ts` 重导出)。 + +## 5. 与 Rerank 的流程对比 + +| 维度 | Embedding | Rerank | +|------|-----------|--------| +| **数据模型** | 完全相同:`query/positiveDocs/negativeDocs` 三元组 | 同左 | +| **Stage 1(generate_trainset)** | 流程完全相同,都导出 query/pos/neg JSONL | 同左 | +| **Stage 2(generate_evaldataset)** | **无需 performDatasetSearch**,直接存储 expectedContextIds | **需要 performDatasetSearch**,存储 retrievalContextsFull | +| **Stage 3/6 评测指标** | MRR + Precision(调用 `/evaluations/embed`) | MRR + Precision(调用 `/evaluations/rerank`) | +| **SFT Bridge taskType** | `'embed'` | `'rerank'`(默认) | +| **模型类型** | `ModelTypeEnum.embedding` | `ModelTypeEnum.rerank` | +| **eval-dataset 导出格式** | JSONL(无 `retrievalContextsFull`) | JSONL(含 `retrievalContextsFull`) | +| **模型应用** | 用户手动决策,更新 Dataset.vectorModel | 用户手动决策,更新 App 的 rerankModel 引用 | + +**这是 embedding train 相比 rerank train 的唯一流程差异**(Stage 2 的 performDatasetSearch 步骤)。 + +## 6. 资源管理 + +### 6.1 创建任务的前置校验 + +- `baseModelId` 不存在 → `taskModelNotFound` +- `baseModelId` 对应模型已被 disable → `taskBaseModelDisabled` +- 同一 team + baseModelId 已有 pending/running 任务 → `taskAlreadyRunning` + +### 6.2 资源清理(deleteEmbeddingTrainTask) + +``` +1. 模型配置 + AI Proxy Channel (registering 阶段产物) +2. SFT Bridge 资源 (async, non-blocking) +3. 自动生成的 EvalDataset Collections + Data +4. 自动生成的 Trainset + Data +5. Task 记录 +6. 临时 JSONL 文件 (async, non-blocking) +``` + +## 7. 错误处理与重试 + +### 7.1 错误分类 + +| 类型 | 处理 | 示例 | +|------|------|------| +| `TrainTaskRetriableError` | 自动重试(最多 3 次,指数退避 5s) | 网络超时、服务暂时不可用 | +| `TrainTaskUnrecoverableError` | 任务失败,不重试 | 数据丢失、模型不存在、用户取消、trainset error 状态 | + +错误类定义在 `packages/service/core/train/common/errors.ts`,两个模块共享。 + +### 7.2 Trainset 生成的错误感知 + +`waitForTrainsetReady()` 在轮询中覆盖 trainset 的所有终态: +- `ready`:继续导出 JSONL +- `error`:立即抛出 `TrainTaskUnrecoverableError`(携带 trainset 的 errorMsg) +- 超时(360 次 × 10s = 60 min):抛出 `TrainTaskRetriableError` + +## 8. API 接口总览 + +### 训练集管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/embedding/trainset/create` | POST | 创建训练集 | +| `/api/core/train/embedding/trainset/list` | POST | 列表查询(分页、排序、状态筛选) | +| `/api/core/train/embedding/trainset/detail` | GET | 获取详情(含统计) | +| `/api/core/train/embedding/trainset/delete` | DELETE | 删除训练集(trainsetId 在 query) | + +### 训练数据管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/embedding/trainset/data/create` | POST | 手动添加数据 | +| `/api/core/train/embedding/trainset/data/list` | POST | 数据列表(支持 source 过滤) | +| `/api/core/train/embedding/trainset/data/update` | POST | 更新数据 | +| `/api/core/train/embedding/trainset/data/delete` | POST | 删除数据(dataIds 在 body) | +| `/api/core/train/embedding/trainset/data/generate` | POST | 从知识库异步生成(返回 jobId) | + +### 训练任务管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/embedding/task/create` | POST | 创建训练任务(支持自动/精确两种模式) | +| `/api/core/train/embedding/task/list` | POST | 任务列表(支持 baseModelId/status 过滤) | +| `/api/core/train/embedding/task/detail` | GET | 任务详情(taskId 在 query) | +| `/api/core/train/embedding/task/retry` | POST | 重试失败任务(仅 failed 状态) | +| `/api/core/train/embedding/task/cancel` | POST | 取消任务 | +| `/api/core/train/embedding/task/delete` | DELETE | 删除任务(仅 completed/failed/cancelled,taskId 在 query) | +| `/api/core/train/embedding/task/eval-dataset` | POST | 下载评测数据集 (JSONL,无 retrievalContextsFull) | +| `/api/core/train/embedding/task/eval-report` | POST | 下载评测报告 (XLSX) | + +## 9. 文件结构 + +``` +packages/service/core/train/embedding/ +├── constants.ts # 服务层常量 +├── utils.ts # 工具函数(createEmbeddingEnhancedError 等) +├── index.ts # 模块入口 +├── external/ # 外部服务集成(DiTing 专用) +│ ├── index.ts # 统一入口(Mock/Real 切换) +│ │ # ⚠️ SFT Bridge 从 common/external/sftbridge 重导出 +│ └── diting/ +│ ├── client.ts # DiTing API 客户端(evaluateEmbeddingModel) +│ ├── mock.ts # Mock 实现 +│ └── types.ts # 类型定义 +├── validation/ # 环境和数据集校验 +│ ├── index.ts +│ ├── environment.ts # SFT Bridge + DiTing 可用性检查 +│ └── dataset.ts # 知识库合成索引校验 +├── task/ +│ ├── controller.ts # 任务 CRUD + 前置校验 +│ ├── processor.ts # BullMQ Processor(6 阶段编排) +│ ├── worker.ts # Worker 初始化 +│ ├── mq.ts # 队列配置(attempts:3, 指数退避) +│ ├── schema.ts # MongoDB Schema +│ ├── utils.ts # 任务工具函数 +│ ├── helpers/ +│ │ ├── channel.ts # AI Proxy 通道管理 +│ │ └── evaluate-model.ts # 模型评测共享逻辑 +│ └── stages/ +│ ├── generate-trainset.ts # Stage 1(含 waitForTrainsetReady) +│ ├── generate-evaldataset.ts # Stage 2(直接创建,无 performDatasetSearch) +│ ├── eval-basemodel.ts # Stage 3 +│ ├── finetune.ts # Stage 4(taskType: 'embed') +│ ├── register.ts # Stage 5 +│ └── eval-tunedmodel.ts # Stage 6 +├── trainset/ +│ ├── controller.ts # 训练集 CRUD +│ └── schema.ts +├── data/ +│ ├── controller.ts # 训练数据 CRUD + calculateEmbeddingTrainsetStats +│ ├── processor.ts # 数据生成处理器(DiTing 数据合成) +│ ├── mq.ts +│ ├── worker.ts +│ └── schema.ts +└── model/ + └── controller.ts # 模型配置管理 + +packages/service/core/train/common/ +├── errors.ts # 共享错误类(TrainTaskRetriableError/UnrecoverableError) +├── constants.ts # 共享常量 +├── utils.ts # 共享工具函数 +└── external/ + └── sftbridge/ # SFT Bridge 客户端(rerank + embedding 共用) + ├── client.ts + ├── mock.ts + ├── types.ts + └── index.ts + +packages/global/core/train/embedding/ +├── constants.ts # 枚举(状态、阶段、来源) +├── type.d.ts # Schema 类型 +├── api.d.ts # API 请求/响应类型 +└── error.ts # EnhancedErrorMessage 类型 + +packages/global/common/error/code/ +└── train.ts # EmbeddingTrainErrEnum + RerankTrainErrEnum(各 95 个) + +projects/app/src/pages/api/core/train/embedding/ +├── task/ +│ ├── create.ts +│ ├── list.ts +│ ├── detail.ts +│ ├── retry.ts +│ ├── cancel.ts +│ ├── delete.ts +│ ├── eval-dataset.ts +│ └── eval-report.ts +└── trainset/ + ├── create.ts + ├── list.ts + ├── detail.ts + ├── delete.ts + └── data/ + ├── create.ts + ├── list.ts + ├── update.ts + ├── delete.ts + └── generate.ts +``` diff --git a/.claude/design/i18n/velvety-chasing-allen.md b/.claude/design/i18n/velvety-chasing-allen.md new file mode 100644 index 000000000000..f5b2632c0da1 --- /dev/null +++ b/.claude/design/i18n/velvety-chasing-allen.md @@ -0,0 +1,156 @@ +# 页面 i18n 命名空间完整性检查脚本 + +## Context + +FastGPT 使用 `next-i18next` SSR 模式,每个页面需要在 `getServerSideProps` 中通过 `serviceSideProps(content, ['ns1', 'ns2'])` 声明要加载的命名空间。若某个子组件使用了未在页面声明的命名空间,该词条在运行时会渲染为 key 字符串(如 `account_apikey:key_tips`)。 + +目标:实现一个静态分析脚本,递归扫描页面组件树(含动态 import),找出使用了但未在 `getServerSideProps` 中声明的命名空间。 + +--- + +## 新增文件 + +**`scripts/i18n/check-page-ns.js`** + +--- + +## 路由过滤规则(全量扫描时内置) + +| 条件 | 说明 | +|------|------| +| 排除 `api/` 子目录 | API 路由无需声明 i18n | +| 排除 `_` 开头的文件 | `_app.tsx`、`_document.tsx` 等 Next.js 保留文件 | +| 只扫描含 `serviceSideProps` 的文件 | 无该函数说明不需要 SSR 注入命名空间 | + +--- + +## 路径别名解析规则 + +| 别名 | 实际路径 | +|------|---------| +| `@/xxx` | `projects/app/src/xxx` | +| `@fastgpt/web/xxx` | `packages/web/xxx` | +| `@fastgpt/global/xxx` | `packages/global/xxx` | +| `@fastgpt/service/xxx` | `packages/service/xxx` | +| `./xxx` / `../xxx` | 相对于当前文件目录解析 | +| 其他(npm 包) | 跳过,不扫描 | + +文件扩展名猜测顺序:`.tsx` → `.ts` → `.jsx` → `.js` → `index.tsx` → `index.ts` + +--- + +## 核心提取规则(正则) + +### 1. 提取页面声明的命名空间 + +```js +// serviceSideProps(content, ['ns1', 'ns2']) +/serviceSideProps\s*\([^,)]+,\s*\[([\s\S]*?)\]/ +``` + +`common` 始终为隐式包含,无需声明。 + +### 2. 提取文件中使用的命名空间 + +```js +// t('namespace:key') +/\bt\s*\(\s*['"`]([\w-]+):/g + +// useTranslation('namespace') +/useTranslation\s*\(\s*['"`]([\w-]+)['"`]\s*\)/g + +// useTranslation(['ns1', 'ns2']) +/useTranslation\s*\(\s*\[([\s\S]*?)\]\s*\)/g +``` + +### 3. 提取静态 import + +```js +/^import\s+(?:type\s+)?(?:[\w\s{},*]+)\s+from\s+['"`]([^'"`\n]+)['"`]/gm +``` + +### 4. 提取动态 import(next/dynamic) + +```js +// dynamic(() => import('path')) +/dynamic\s*\(\s*(?:async\s*)?\(\s*\)\s*=>\s*import\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g + +// dynamic(() => import('path').then(m => m.X)) +/dynamic\s*\(\s*(?:async\s*)?\(\s*\)\s*=>\s*import\s*\(\s*['"`]([^'"`]+)['"`]\s*\)\s*\.then/g +``` + +--- + +## 脚本结构 + +``` +scripts/i18n/check-page-ns.js +├── resolveFilePath(importPath, from) // 别名解析 + 扩展名猜测 +├── extractImports(filePath) // 提取静态 + 动态 import(带缓存) +├── extractOwnNamespaces(filePath) // 提取单文件自身使用的命名空间(带缓存) +├── extractDeclaredNamespaces(content) // 提取页面 SSR 声明的命名空间 +├── scanComponentTree(filePath, visited) // 递归扫描,返回整棵树的命名空间 +├── checkPage(pagePath) // 单页面检查,返回结构化结果 +├── collectPages(dir, excludePatterns) // 收集所有待检查页面文件 +├── printSingleResult(pagePath, result) // 单页面输出 +├── printBatchResults(results) // 全量扫描汇总输出 +├── parseArgs(argv) // 命令行参数解析 +└── main() // 入口 +``` + +**性能优化**:文件内容、import 列表、命名空间均有缓存(`Map`),全量模式下共享文件(如 `AccountContainer`)只解析一次。 + +**递归保护**:维护 `visited Set`,最大深度 15 层。 + +--- + +## 命令行接口 + +```bash +# 全量扫描(默认,扫描 projects/app/src/pages 下所有有效页面) +node scripts/i18n/check-page-ns.js + +# 排除符合正则的页面(路径相对于 pages 目录) +node scripts/i18n/check-page-ns.js --exclude "login|price" + +# 多个排除规则 +node scripts/i18n/check-page-ns.js --exclude "account" --exclude "login" + +# 仅检查单个页面 +node scripts/i18n/check-page-ns.js projects/app/src/pages/account/apikey.tsx +``` + +--- + +## 输出格式 + +**全量扫描概览:** +``` +🔍 全量扫描 projects/app/src/pages + 排除规则: /account/, /login/ + 共 21 个页面 + +──────────────────────────────────────────────────────────── +✅ index.tsx +❌ dashboard/create/index.tsx (缺少 1 个: account_info) +❌ dashboard/tool/index.tsx (缺少 4 个: file, account_info, skill, workflow) +... +──────────────────────────────────────────────────────────── + +详情: + + [dashboard/tool/index.tsx] + 已声明: common, app, user, account + ❌ file (使用于: projects/app/src/web/common/file/hooks/useSelectFile.tsx) + 💡 建议: ['app', 'user', 'account', 'file', 'account_info', 'skill', 'workflow'] + +❌ 扫描完成:共 21 个页面,1 个通过,20 个有问题 +``` + +**退出码**:有缺失命名空间时 `exit(1)`,便于集成到 CI。 + +--- + +## 已知局限 + +静态分析无法感知运行时的条件渲染,因此可能出现轻微误报(某个组件在当前页面路由下不会被实际渲染,但被静态 import 引用)。属于可接受的保守检测策略。 diff --git a/.claude/design/memory-optimize-train-pipeline.md b/.claude/design/memory-optimize-train-pipeline.md new file mode 100644 index 000000000000..80aaf2a6a50b --- /dev/null +++ b/.claude/design/memory-optimize-train-pipeline.md @@ -0,0 +1,455 @@ +# 训练数据管线内存优化设计 + +## 背景与问题 + +当前训练数据管线在无 `sampleSize` 约束时存在显著内存峰值问题。根本原因是两个函数都在内存中持有全量文档的完整字段: + +| 场景 | 当前峰值 | +|------|---------| +| 5 KB × 5万文档(无 sampleSize) | **~820MB** | +| `sampleDataFromDataset`:持有所有文档 q+a+indexes | ~600MB | +| `buildFineTuneData`:持有 qaText pool + 原始 items | ~220MB 叠加 | + +### 核心洞察 + +1. **sampleDataFromDataset** 的质量过滤只需要 `q` 字段;返回值下游只需要 `dataId + datasetId + collectionId`(供分组用),不需要全量文档。 +2. **buildFineTuneData** 的负样本采样只需决定"从哪些文档采"(ID),不需要它们的文字内容。内容在流式输出阶段按需拉取即可。 + +--- + +## 新架构总览 + +``` +sampleDataFromDataset(datasetIds, options) + Phase 1: DB fetch(_id, q, datasetId, collectionId),check 11 推到 MongoDB 查询 + → 质量过滤(只对 q 字段) + → 确定性 shuffle(train/eval)或随机 shuffle(random) + → 两轮配额分配 + Return: SampledDataItem[] ← 仅含 dataId + datasetId + collectionId + +buildFineTuneDataStream(sampledItems, params) ← 新函数,AsyncGenerator + Phase 1(纯内存,无 DB): + → 按 sampledItems 构建 groups: Map> + → 无 qaText,无 queryText + Phase 2(纯内存,无 DB): + → ID 占位负样本采样:exclude/sampled 均用 dataId + → 输出 FineTuneSampleIndex[](纯 ID,约 24MB) + → 清空 allProcessed + Phase 3(流式 DB,分批): + → 每批收集 unique (sourceId + negativeDataIds) + → 一次 batch fetch: q + a + $filter(indexes, type===indexType) + → 对 q/a/index.text 应用 cleanText + → resolve → yield FineTuneSample → GC + +Processor + → 接收 SampledDataItem[] + → for await (sample of buildFineTuneDataStream) → batch insertMany +``` + +--- + +## 内存峰值对比 + +``` +阶段 当前 优化后 +───────────────────────────────────────────────────────────── +sampleDataFromDataset + Phase 1 扫描 600MB 110MB(只含 q) + kbValidDocs 累计 600MB GC → SampledDataItem[] 14MB + 返回值 DatasetSelectItem 14MB + +buildFineTuneDataStream + Phase 1-2(构建 groups) — 14MB groups + 24MB indices + qaText pool 200MB 0(完全消除) + items 持有 600MB 0(不持有) + Phase 3 每批流式 — ~6MB in-flight + +整体峰值 ~820MB ~124MB(Phase 1 扫描) +稳态(流式处理中) ~320MB ~30MB +───────────────────────────────────────────────────────────── +``` + +--- + +## 类型定义 + +### SampledDataItem(sampleDataFromDataset 新返回类型) + +```typescript +export type SampledDataItem = { + dataId: string; + datasetId: string; + collectionId: string; +}; +``` + +### ProcessedItem(buildFineTuneDataStream 内部,轻量) + +```typescript +// 内部类型,不对外暴露 +type ProcessedItem = { + dataId: string; + datasetId: string; + collectionId: string; + // 无 queryText,无 qaText,无 item 引用 +}; +``` + +### FineTuneSampleIndex(内部中间态) + +```typescript +// 内部类型,不对外暴露 +type FineTuneSampleIndex = { + sourceId: string; // query + positive 均来自此文档 + datasetId: string; // 保留用于输出 FineTuneSample.datasetId + negativeDataIds: string[]; // 负样本来源文档 IDs +}; +``` + +> **说明**:query 和 positive 均来自同一文档(sourceId),无需分别记录 queryDataId 和 positiveDataId。 + +### FineTuneSample(最终输出,结构不变) + +```typescript +export type FineTuneSample = { + query: string; + positive: string[]; // 始终 1 元素 + negatives: string[]; + sourceId: string; + datasetId: string; +}; +``` + +--- + +## 详细设计 + +### sampleDataFromDataset 变更 + +**接口变更**: + +```typescript +export async function sampleDataFromDataset( + datasetIds: string[], + options: { + datasetType?: 'train' | 'eval' | 'random'; + sampleSize?: number; + weights?: Record; + filterConfig?: ChunkFilterConfig; + } = {} +): Promise // ← 返回类型由 DatasetSelectItem[] 改为 SampledDataItem[] +``` + +**Phase 1 查询变更**: + +```typescript +// check 11(indexes 为空)推到 MongoDB 查询,避免加载 indexes 内容 +const match = { + datasetId: new Types.ObjectId(datasetId), + 'indexes.0': { $exists: true } // ← 新增 +}; + +// 只取 q 用于质量过滤,不取 a 和 indexes +const lightDocs = await MongoDatasetData.find(match) + .select('_id q datasetId collectionId') // ← 去掉 a 和 indexes + .lean(); +``` + +**filterAndCleanDocs 调整**: + +- 移除 check 11(已由 DB 查询承担) +- 移除 Step 0b 的 cleanText(不再输出 a/indexes,cleanText 推迟到 Phase 3 流式解析) +- 质量过滤仍在 q 上执行:内部对 q 应用 cleanText 后做 checks 1-10,得出有效/无效结论 +- 过滤通过后只记录 `{ dataId, datasetId, collectionId }` + +```typescript +function filterToSampledItems( + rawDocs: any[], + config: Required +): SampledDataItem[] { + const result: SampledDataItem[] = []; + for (const doc of rawDocs) { + const rawQ: string = doc.q ?? ''; + if (!rawQ.trim()) continue; + const cleanedQ = cleanText(rawQ.trim()); + if (cleanedQ.length < 10) continue; + if (!isValidChunk(cleanedQ, config)) continue; // checks 1-10 on cleaned q + result.push({ + dataId: doc._id.toString(), + datasetId: doc.datasetId.toString(), + collectionId: doc.collectionId?.toString() ?? '' + }); + } + return result; +} +``` + +**配额分配后取数**: + +```typescript +// Phase 3 取数:从 kbSampledItems[i] 切片,不再需要 DatasetSelectItem +let selected: SampledDataItem[]; +if (datasetType === 'eval') { + selected = kbSampledItems[i].slice(trainCount, trainCount + quota); +} else { + selected = kbSampledItems[i].slice(0, quota); +} +``` + +--- + +### buildFineTuneDataStream 算法 + +```typescript +export async function* buildFineTuneDataStream(params: { + sampledItems: SampledDataItem[]; + indexType?: string; // 默认 'default' + negativeStrategy?: 1 | 2 | 3 | 4; // 默认 2 + minNegativeSamples?: number; // 默认 1 + maxNegativeSamples?: number; // 默认 10 +}): AsyncGenerator +``` + +#### Phase 1:构建 groups(纯内存,无 DB) + +``` +for each sampledItem: + ProcessedItem = { dataId, datasetId, collectionId } + groups[datasetId][collectionId].push(ProcessedItem) + +内存:allProcessed = SampledDataItem 数组的轻量副本(~14MB for 200k) +``` + +#### Phase 2:ID 占位负样本采样 + +`sampleNegatives` 签名变更(`selfQAText` 改为 `selfDataId`): + +```typescript +function sampleNegativeIds( + allProcessed: ProcessedItem[], + groups: Map>, + selfDataId: string, // ← 改为 dataId + selfDatasetId: string, + selfCollectionId: string, + strategy: 1 | 2 | 3 | 4, + minCount: number, + maxCount: number +): string[] // ← 返回 dataId[],不再返回 qaText[] +``` + +`sampleFromItems` 关键改动(exclude/sampled/output 全部改用 dataId): + +```typescript +function sampleFromItems( + candidates: ProcessedItem[], + excludeIds: Set, // ← 用 dataId + sampledIds: Set, // ← 用 dataId + target: number, + outIds: string[] // ← 输出 dataId[] +): void { + ... + const id = arr[i].dataId; + if (!excludeIds.has(id) && !sampledIds.has(id)) { + outIds.push(id); // ← 改这里 + sampledIds.add(id); + } +} +``` + +全局兜底使用 dataId 同理。 + +Phase 2 结束后: + +```typescript +// 清空 allProcessed,释放 groups 中的引用 +allProcessed.length = 0; +``` + +#### Phase 3:流式 DB 解析 + +```typescript +const RESOLVE_BATCH_SIZE = 500; // 可配置 + +for (const batch of chunk(sampleIndices, RESOLVE_BATCH_SIZE)) { + // 收集本批次所有需要查询的 unique IDs + const uniqueIds = deduplicate([ + ...batch.map(i => i.sourceId), + ...batch.flatMap(i => i.negativeDataIds) + ]); + + // 一次批量 fetch(只拉 q + a + 目标类型索引) + const docs = await MongoDatasetData.aggregate([ + { $match: { _id: { $in: uniqueIds.map(id => new Types.ObjectId(id)) } } }, + { $project: { + _id: 1, q: 1, a: 1, datasetId: 1, + indexes: { + $filter: { input: '$indexes', as: 'idx', + cond: { $eq: ['$$idx.type', indexType] } } + } + }} + ]); + const docMap = new Map(docs.map(d => [d._id.toString(), d])); + + for (const idx of batch) { + const srcDoc = docMap.get(idx.sourceId); + if (!srcDoc?.indexes?.length) continue; // source 无目标 indexType,跳过 + + const cleanQ = cleanText(srcDoc.q ?? ''); + const cleanA = cleanText(srcDoc.a ?? ''); + const queryText = cleanText(srcDoc.indexes[0].text); + const positiveText = buildQAText(cleanQ, cleanA); + + const negatives = idx.negativeDataIds + .map(id => docMap.get(id)) + .filter((d): d is NonNullable => d != null) + .map(d => buildQAText(cleanText(d.q ?? ''), cleanText(d.a ?? ''))); + + yield { + query: queryText, + positive: [positiveText], + negatives, + sourceId: idx.sourceId, + datasetId: idx.datasetId + }; + } + // batch 用完后 GC(docMap + docs) +} +``` + +--- + +### Processor 变更 + +```typescript +// Step 4: 采样,得到 SampledDataItem[](仅含 ID + 分组元数据) +const sampledItems = await sampleDataFromDataset(targetDatasetIds, { + datasetType: 'train', + sampleSize: generateConfig.sampleSize, + weights: generateConfig.weights +}); + +if (sampledItems.length === 0) { /* throw empty error */ } + +// Step 5: 流式生成 + 批量保存(不再一次性持有全部样本) +const SAVE_BATCH_SIZE = 1000; +let saveBatch: (typeof trainData[0])[] = []; +let totalGenerated = 0; + +for await (const sample of buildFineTuneDataStream({ + sampledItems, + indexType: generateConfig.indexType ?? 'default', + negativeStrategy: generateConfig.negativeStrategy ?? 2, + minNegativeSamples: generateConfig.minNegativeSamples, + maxNegativeSamples: generateConfig.maxNegativeSamples +})) { + saveBatch.push({ + trainsetId, + teamId: trainset.teamId, + query: sample.query, + positiveDocs: sample.positive, + negativeDocs: sample.negatives, + source: RerankTrainDataSourceEnum.dataset, + metadata: { sourceInfo: { datasetInfo: { dataId: sample.sourceId, datasetId: sample.datasetId } }, generateConfig }, + createTime: new Date() + }); + totalGenerated++; + + if (saveBatch.length >= SAVE_BATCH_SIZE) { + await MongoRerankTrainsetData.insertMany(saveBatch); + saveBatch = []; + } +} +if (saveBatch.length > 0) { + await MongoRerankTrainsetData.insertMany(saveBatch); +} + +if (totalGenerated === 0) { /* throw no-data error */ } +``` + +`forceRegenerate` 的事务逻辑:需要在流式写入前先删除旧数据(不能用事务包整个流式过程),调整为: + +```typescript +if (generateConfig.forceRegenerate) { + await MongoRerankTrainsetData.deleteMany({ trainsetId, source: RerankTrainDataSourceEnum.dataset }); +} +// 然后流式写入(无事务) +``` + +> **权衡**:原有事务语义(先生成再原子替换)无法包裹长时间流式写入。改为先删再分批写:若中途失败,旧数据已删、新数据不完整。可通过 trainset 状态机(generating → ready/failed)让调用方感知,retry 时重新写入。 + +--- + +## ID 占位采样的设计取舍 + +### 去重语义变化 + +| | 当前(qaText 去重) | 新设计(dataId 去重) | +|--|-------------------|-------------------| +| 不同 doc,相同 q+a 内容 | 两者不会同时作为负样本 | 可能同时出现 | +| 实际影响 | 避免重复内容作为负样本 | 极少发生(质量过滤已过滤低质量文档) | + +结论:接受此轻微质量降级,内存收益(220MB → 0)远大于影响。 + +### Source doc 无目标 indexType 时 + +sampleDataFromDataset 的 check 11 确保 `indexes.length > 0`,但不检查特定 indexType。Phase 3 解析时,若 source doc 无 target indexType 的 index,该 sample 跳过(不输出)。实际 sample 数量可能略小于 `sampledItems.length`,属预期行为。 + +--- + +## 测试策略 + +所有测试使用真实 MongoDB(`MONGODB_TEST_URI`),无需 mock。 + +### sampleDataFromDataset 测试(新增/更新) + +``` +T-S1: 返回 SampledDataItem(只含 dataId/datasetId/collectionId,无 q/a/indexes 字段) +T-S2: 低质量 q 文档被过滤(短文本、高重复率等) +T-S3: indexes 为空的文档被 DB 查询排除 +T-S4: train/eval 分割确定性(同一数据集 train+eval 不重叠,覆盖率=100%) +T-S5: sampleSize 配额分配(等权、不等权) +T-S6: 空 KB 不报错,其他 KB 正常返回 +``` + +### buildFineTuneDataStream 测试(新增) + +``` +T-B1: 基本流式输出:插入 N 条文档,AsyncGenerator yield N 个 FineTuneSample +T-B2: query = source doc 的 target indexType index text(cleanText 后) +T-B3: positive = [buildQAText(cleanQ, cleanA)];A 为空时 positive = [Q] +T-B4: 无 target indexType 的 source doc 被跳过(不 yield) +T-B5: strategy=1 → negatives 来自同库同 collection(验证 dataId 归属) +T-B6: strategy=2 → negatives 来自同库其他 collection +T-B7: strategy=3 → negatives 来自其他知识库 +T-B8: strategy=4 → negatives 混合三个来源 +T-B9: negatives 不包含自身 sourceId 对应的 qaText +T-B10: negatives dataId 无重复 +T-B11: minNeg/maxNeg 约束生效 +T-B12: 空 sampledItems → generator 直接结束(无 yield) +``` + +--- + +## 涉及文件 + +| 文件 | 变更类型 | 说明 | +|------|---------|------| +| `packages/service/core/train/common/utils.ts` | **修改** | `sampleDataFromDataset` 返回 `SampledDataItem[]`;新增 `filterToSampledItems`;移除 `filterAndCleanDocs`(全量版本);Phase 1 query 加 `'indexes.0':{$exists:true}`,去掉 `a`/`indexes` 投影 | +| `packages/service/core/train/common/synthesize/buildFineTuneData.ts` | **重写** | 移除 `buildFineTuneData`(同步纯函数);新增 `buildFineTuneDataStream`(AsyncGenerator);`ProcessedItem` 去掉 text 字段;`sampleFromItems` 改为 dataId 模式 | +| `packages/service/core/train/rerank/data/processor.ts` | **修改** | 流式消费 `buildFineTuneDataStream`;批量 insertMany;`forceRegenerate` 改为先删再写 | +| `packages/service/core/train/embedding/data/processor.ts` | **修改** | 同上 | +| `test/cases/service/core/train/build-fine-tune-data.test.ts` | **重写** | T-B1 ~ T-B12,使用真实 MongoDB | +| `test/cases/service/core/train/rerank-sampling.test.ts` | **更新** | 验证返回 `SampledDataItem[]` 而非 `DatasetSelectItem[]` | + +**无需修改**:`api.d.ts`、`type.d.ts`、`mq.ts`、`constants.ts`(已在上轮重构中更新)。 + +--- + +## 执行步骤 + +| # | 操作 | 验证 | +|---|------|------| +| 1 | 更新 `sampleDataFromDataset` → 返回 `SampledDataItem[]` | T-S1~T-S6 通过 | +| 2 | 重写 `buildFineTuneDataStream`(纯内存 Phase 1-2) | T-B1~T-B12 通过 | +| 3 | 修改 processor.ts(rerank + embedding) | 编译通过 | +| 4 | 运行全量 train 相关测试 | 全部通过 | diff --git a/.claude/design/model-refactoring-analysis.md b/.claude/design/model-refactoring-analysis.md new file mode 100644 index 000000000000..39e2bdc13d18 --- /dev/null +++ b/.claude/design/model-refactoring-analysis.md @@ -0,0 +1,455 @@ +# 模型重构分析报告 + +> 分支对比: `origin/develop-1.4.0` vs `origin/develop-1.3.0` +> 影响: **675 个文件,+30891 / -13089 行** + +--- + +## 一、重构目的 + +本次重构解决旧模型体系的三个核心问题: + +1. **模型标识不稳定** — 旧方案以 `model` 字段(如 `"gpt-4o"`)作为唯一标识和查找 key。当多个自定义模型共用同一个 OpenAI model 名时(如团队 A 和团队 B 各自配置了不同参数的 `gpt-4o`),唯一索引会冲突,无法区分。 +2. **缺乏权限控制** — 所有模型对所有用户可见,无法实现「团队私有模型」「模型共享」等场景。 +3. **数据结构不规整** — 旧方案将模型配置放在嵌套的 `metadata` 对象中,schema 校验依赖运行时解析,字段散落在嵌套对象内外,类型安全差。 + +--- + +## 二、重构设计 + +### 核心思路 + +**用不可变的 `_id` 替代可变的 `model` 名作为模型的唯一标识和关联 key。** + +``` +旧: model 名字 = 唯一标识 + 查找 key + 关联外键 +新: _id = 唯一标识 + 查找 key + 关联外键 + model 名字 = 仅用于调用 AI API 时的 model 参数 +``` + +### 关键设计决策 + +| 决策 | 说明 | +|------|------| +| **`_id` 作为引用 key** | App、Dataset、Evaluation 等所有引用模型的地方,从存 model 名字改为存 model `_id` | +| **metadata 扁平化** | `metadata.type`, `metadata.maxContext` 等全部提升到文档顶层,Zod schema 直校验 | +| **系统模型入库** | 插件定义的模型通过 `bulkWrite upsert` 自动持久化到 MongoDB,使其拥有 `_id` | +| **权限模型** | 新增 `PerResourceTypeEnum.model`,自定义模型支持 Owner/Collaborator/Shared 控制 | +| **数据迁移一并完成** | `initv4150` 9 步脚本将存量 model 名字批量转换为 model id | + +### 重构前后数据流对比 + +#### 旧数据流 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 启动时: 加载插件模型 → 存入 llmModelMap (key = model名) │ +│ 加载 DB 自定义模型 (metadata 嵌套) → 同上 │ +└──────────────────────────────────────────────────────────┘ +App/Dataset 存储 model 名字 "gpt-4o" + ↓ +getLLMModel("gpt-4o") → llmModelMap.get("gpt-4o") // 通过 model 名查找 + ↓ +返回 LLMModelItemType(包含 maxContext, vision 等配置) + ↓ +实际调用 AI 时取 modelData.model 作为 OpenAI API 的 model 参数 + +❌ 问题: model 名"gpt-4o"在 Map 中只能存一份,第二个同名的自定义模型会覆盖 +``` + +#### 新数据流 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 启动时: 插件模型 bulkWrite upsert 入 DB (获得 _id) │ +│ 加载所有 DB 模型 → leanToModelItem() 清洗空值 │ +│ → normalizeSystemModel() Zod 校验/补默认值 │ +│ → 存入 llmModelIdMap (key = _id) + systemModelIdMap │ +└──────────────────────────────────────────────────────────────┘ +App/Dataset 存储 model id "68ad85a7463006c963799a05" + ↓ +getLLMModelById("68ad85a7463006c963799a05") → llmModelIdMap.get("68ad85a7463006c963799a05") + ↓ +返回 LLMModelItemType(id + model + 所有配置字段) + ↓ +实际调用 AI 时取 modelData.model 作为 OpenAI API 的 model 参数 + +✅ 不同自定义模型即使共享 model 名, _id 始终唯一 +``` + +#### 计费链路变更 + +``` +旧: formatModelChars2Points({ model: "gpt-4o", ... }) + → findAIModel("gpt-4o") 遍历 5 个 Map 查找 + +新: formatModelChars2Points({ modelId: "68ad85a...", ... }) + → getModelById("68ad85a...") 直接查 systemModelIdMap +``` + +--- + +## 三、涉及的核心模块 + +| 模块 | 关键目录 | +|------|---------| +| **AI 模型配置** | `packages/service/core/ai/config/` (schema, utils), `packages/service/core/ai/model.ts`, `packages/service/core/ai/type.ts` | +| **知识库** | `packages/global/core/dataset/`, `packages/service/core/dataset/` | +| **工作流编排** | `packages/global/core/workflow/` (constants, utils, templates) | +| **应用配置** | `packages/global/core/app/` (type, constants, formEdit) | +| **权限系统** | `packages/global/support/permission/`, `packages/service/support/permission/` | +| **评测/评估** | `packages/global/core/evaluation/`, `packages/service/core/evaluation/` | +| **模型训练** | `packages/global/core/train/`, `packages/service/core/train/` | +| **前端组件** | `projects/app/src/components/core/ai/ModelTable`, `pageComponents/` | +| **API 接口** | `projects/app/src/pages/api/core/ai/model/` 等 50+ API 文件 | +| **计费/用量** | `packages/service/support/wallet/usage/`, `packages/global/support/wallet/usage/` | + +--- + +## 四、DB Schema 关键变更 + +### 1. `system_models` 表(最核心变更) + +**旧结构** (`SystemModelSchemaType`): +```typescript +{ + _id: string; + model: string; // 唯一索引, 如 "gpt-4o" + metadata: { // 所有配置塞在一个嵌套对象里 + type, provider, name, avatar, isActive, maxContext, ... + } +} +``` + +**新结构** (`SystemModelItemType`): +```typescript +{ + _id: string; // 作为模型的 "id" 暴露 + id: string; // 代码层面 = String(_id),扁平化后不再有 metadata + model: string; // 不再唯一(多个自定义模型可共享同一个 OpenAI model 名) + type, provider, name, avatar, isActive, ... + maxContext, maxResponse, vision, functionCall, ... // 全部扁平化到顶层 + tmbId: string; // 新增:创建者 + teamId: string; // 新增:所属团队 + isShared: boolean; // 新增:是否共享 + isCustom: boolean; // 新增:是否自定义模型 +} +``` + +**Schema 变化细节**: +- 移除旧的 `{ model, metadata }` 结构,base 字段 + price 字段声明在 Schema 顶层 +- `strict: false` 允许类型特有字段(如 `maxContext`、`voices`、`dimensions`)动态存储 +- 新增索引: `teamId_1`, `tmbId_1`, `isShared_1` +- 丢弃 `model` 字段的 unique 索引 + +### 2. `datasets` 表 + +| 旧字段 | 新字段 | 备注 | +|--------|--------|------| +| `vectorModel: string` | `vectorModelId: string` | 从存储 **模型 model 名** 变为存储 **模型 _id** | +| `agentModel: string` | `agentModelId: string` | 同上 | +| `vlmModel: string` | `vlmModelId: string` | 同上 | + +**Mongoose Schema 变化**: +- `default: 'text-embedding-3-small'` → 不再设置 default(改为 required 校验) +- `default: 'gpt-4o-mini'` → 不再设置 default(改为 required 校验) +- folder 类型数据集不再要求 `vectorModelId`/`agentModelId` + +### 3. 评估相关表 + +| 表/类型 | 旧字段 | 新字段 | +|----------|--------|--------| +| `EvalDatasetDataQualityMetadata` | `model` | `modelId` | +| `EvalDatasetDataSynthesisMetadata` | `intelligentGenerationModel` | `intelligentGenerationModelId` | +| `EvalDatasetCollectionSchemaType` | `evaluationModel` | `evaluationModelId` | +| `RuntimeConfig` | `llm`, `embedding` | `llmId`, `embeddingId` | + +### 4. 用量表 `usage_items` + +| 旧字段 | 新字段 | +|--------|--------| +| `model?: string` | `modelId?: string` | + +### 5. 训练任务表 (`embedding_train_tasks`, `rerank_train_tasks`) + +`baseModelId`、`checkpoint.data.registering.tunedModelId`、`result.tunedModelId` 字段的值从 model 名转换为 model id。 + +--- + +## 五、工作流/应用配置中关键数据命名变更 + +### 工作流常量 `NodeInputKeyEnum` + +| 旧枚举名 → 新枚举名 | 旧 value → 新 value | +|---------------------|---------------------| +| `aiModel` → `aiModelId` | `'model'` → `'modelId'` | +| `datasetSearchEmbeddingModel` → `datasetSearchEmbeddingModelId` | `'embeddingModel'` → `'embeddingModelId'` | +| `datasetSearchRerankModel` → `datasetSearchRerankModelId` | `'rerankModel'` → `'rerankModelId'` | +| `datasetSearchExtensionModel` → `datasetSearchExtensionModelId` | `'datasetSearchExtensionModel'` → `'datasetSearchExtensionModelId'` | +| `generateSqlModel` → `generateSqlModelId` | `'generateSqlModel'` → `'generateSqlModelId'` | +| `datasetDeepSearchModel` → `datasetDeepSearchModelId` | `'datasetDeepSearchModel'` → `'datasetDeepSearchModelId'` | +| `datasetAgenticSearchLLMModel` → `datasetAgenticSearchLLMModelId` | `'agenticSearchLLMModel'` → `'agenticSearchLLMModelId'` | +| `datasetAgenticSearchRerankModel` → `datasetAgenticSearchRerankModelId` | `'agenticSearchRerankModel'` → `'agenticSearchRerankModelId'` | + +移除枚举: `useEditDebugSandbox` + +### `workflow/utils.ts` 新增工具函数 + +**`extractWorkflowModelIds()`** — 从工作流 modules 和 chatConfig 中提取所有引用的 model id: +``` +遍历 modules[].inputs[] → 匹配 workflowModelInputKeySet (8 个 model key) + ├── 直接 model 值 → addModelId() + └── datasetParams 复合对象 → 遍历 datasetParamsModelKeySet (6 个 key) +遍历 chatConfig.questionGuide.modelId, chatConfig.ttsConfig.modelId +返回 Array.from(modelIds) +``` +用途:模型删除时的引用检查,以及权限过滤时的批量校验。 + +**`removeUnauthModels()` 重构**: +- 从只处理 `key === 'model'` 的单一判断 → 使用 `workflowModelInputKeySet` 匹配所有 8 种 model key +- 新增 `datasetParams` 复合对象内嵌 model 字段的权限过滤 +- 使用 `checkInputIsReference()` 统一判断引用类型输入 + +### 应用配置类型变更 + +| 类型.旧字段 | 新字段 | +|------------|--------| +| `AppTTSConfigType.model` | `modelId` | +| `AppQGConfigType.model` | `modelId` | +| `AppDatasetSearchParamsType.rerankModel` | `rerankModelId` | +| `AppDatasetSearchParamsType.embeddingModel` | `embeddingModelId` | +| `AppDatasetSearchParamsType.datasetSearchExtensionModel` | `datasetSearchExtensionModelId` | +| `AppDatasetSearchParamsType.generateSqlModel` | `generateSqlModelId` | +| `AppDatasetSearchParamsType.agenticSearchLLMModel` | `agenticSearchLLMModelId` | +| `AppDatasetSearchParamsType.agenticSearchRerankModel` | `agenticSearchRerankModelId` | +| `SettingAIDataType.model` | `modelId` | +| `AppSimpleEditFormType.aiSettings.model` | `modelId` | +| `AppFormEditFormV1TypeSchema.aiSettings.model` | `modelId` | + +### 运行时响应 `DispatchNodeResponseSchema` + +| 旧字段 | 新字段 | +|--------|--------| +| `model` | `modelId` | +| `embeddingModel` | `embeddingModelId` | +| `rerankModel` | `rerankModelId` | +| `queryExtensionResult.model` | `queryExtensionResult.modelId` | +| `deepSearchResult.model` | `deepSearchResult.modelId` | +| `extensionModel` | `extensionModelId` (deprecated) | + +`agenticSearchResult` 从 `z.any()` 变为结构化对象: +```typescript +z.object({ + reasoningText, searchCount, toolCallCount, + llmModelId, llmInputTokens, llmOutputTokens, + playbook, executionPath, confidence, queryLanguage +}) +``` + +--- + +## 六、运行时全局缓存的变更 + +### Map 类型及变量名 (`packages/service/core/ai/type.ts`) + +| 旧变量 | 新变量 | Map key 变化 | +|--------|--------|-------------| +| `llmModelMap` | `llmModelIdMap` | key 从 `model` 变为 `id` | +| `embeddingModelMap` | `embeddingModelIdMap` | 同上 | +| `ttsModelMap` | `ttsModelIdMap` | 同上 | +| `sttModelMap` | `sttModelIdMap` | 同上 | +| `reRankModelMap` | `reRankModelIdMap` | 同上 | +| (无) | `systemModelIdMap` | **新增**: 全类型统一查找 | + +### 模型查找函数 (`packages/service/core/ai/model.ts`) + +| 旧函数 | 新函数 | 参数变化 | +|--------|--------|---------| +| `getLLMModel(model?)` | `getLLMModelById(id?)` | model 名 → model id | +| `getDatasetModel(model?)` | `getDatasetModelById(id?)` | 同上 | +| `getVlmModel(model?)` | `getVlmModelById(id?)` | 同上 | +| `getEmbeddingModel(model?)` | `getEmbeddingModelById(id?)` | 同上 | +| `getTTSModel(model?)` | `getTTSModelById(id?)` | 同上 | +| `getSTTModel(model?)` | `getSTTModelById(id?)` | 同上 | +| `getRerankModel(model?)` | `getRerankModelById(id?)` | 同上 | +| `getEvaluationModel(model?)` | `getEvaluationModelById(id?)` | 同上 | +| `findAIModel(model)` | **删除** | — | +| `findModelFromAlldata(model)` | `getModelById(id)` | 功能简化,直接查 `systemModelIdMap` | +| (无) | `getDefaultDatasetModel()` | **新增** | + +### 模型加载流程 (`packages/service/core/ai/config/utils.ts`) + +``` +MongoDB(扁平化) → lean() → leanToModelItem() 清洗空值 → normalizeSystemModel() Zod 校验 + ├── llmModelIdMap.set(model.id, model) + ├── embeddingModelIdMap.set(model.id, model) + └── systemModelIdMap.set(model.id, model) +``` + +**新增关键函数**: +- **`leanToModelItem()`**: 将 `_id` 转为 `id` 字段,过滤 null/undefined 值 +- **`normalizeSystemModel()`**: 根据模型 type 使用对应 Zod schema 校验(`safeParse`),自动补全默认值、剔除非法字段 +- **插件模型自动持久化**: 插件定义的模型通过 `bulkWrite upsert` 自动写入 MongoDB,使其拥有 `_id`;插件已移除的孤立 DB 记录会被自动清理 + +**Zod Schema `.default()` 值**: 许多字段现在有默认值,降低对 DB 存储默认值的依赖: +- LLM: `maxContext: 16000`, `maxResponse: 8000`, `quoteMaxToken: 8000`, `functionCall: true`, `toolChoice: true` +- Embedding: `defaultToken: 512`, `maxToken: 512`, `weight: 0` +- Rerank: `maxToken: 3000` +- TTS: `voices: []` + +--- + +## 七、权限系统新增 + +### 资源类型 +- `PerResourceTypeEnum.model` — 模型作为独立的权限管理资源 + +### 团队权限位 +- `TeamPerKeyEnum.modelCreate` = `0b100000000` +- `TeamRoleKeyEnum.modelCreate` + +### 新增文件 + +| 文件 | 作用 | +|------|------| +| `packages/global/support/permission/model/constant.ts` | Model 角色的 Owner/Read/Write 权限值定义 | +| `packages/global/support/permission/model/controller.ts` | `ModelPermission` 类,继承 `Permission` 基类 | +| `packages/service/support/permission/model/controller.ts` | 模型权限业务逻辑(协作角色合并、列表过滤) | +| `packages/service/support/permission/model/auth.ts` | 模型级权限鉴权(300 行) | +| `packages/service/support/permission/model/reference.ts` | 模型引用检查(删除前检查是否被 App/Dataset 引用) | + +### 新增 API 端点 + +| 路由 | 方法 | 说明 | +|------|------|------| +| `/api/core/ai/model/create` | POST | 创建自定义模型,写入权限记录 | +| `/api/core/ai/model/list` | POST | 获取有读权限的模型列表(含创建者信息) | +| `/api/core/ai/model/update` | PUT | 更新模型配置 | +| `/api/core/ai/model/delete` | DELETE | 删除自定义模型(含引用检查) | +| `/api/core/ai/model/detail` | POST | 获取单个模型详情 | +| `/api/core/ai/model/updateDefault` | POST | 设置系统默认模型 | +| `/api/core/ai/model/getDefaultConfig` | POST | 获取模型默认配置 | +| `/api/core/ai/model/getConfigJson` | POST | 获取模型配置 JSON | +| `/api/core/ai/model/updateWithJson` | POST | 通过 JSON 更新模型 | +| `/api/admin/initv4150` | POST | v4.15.0 模型重构数据迁移(9 步) | + +### 权限判断逻辑 + +``` +getModelPermissionFromRole(model, teamId, tmbId, ...): + 1. Root 管理员 → isOwner=true(全部权限) + 2. 系统模型(isCustom=false): + - isShared=true → ReadRoleVal + - isShared=false → NullRoleVal(无权限) + 3. 自定义模型(isCustom=true): + - model.tmbId === tmbId → isOwner=true + - teamPer.isOwner && model.teamId === teamId → isOwner=true + - 其他 → 按协作权限(collaboratorRole) + isShared 加成 +``` + +--- + +## 八、数据迁移脚本 (`initv4150.ts`) + +共 9 步,幂等可重复执行: + +| 步骤 | 函数 | 迁移内容 | +|------|------|---------| +| **Step 1** | `dropModelUniqueIndex()` | 删除 `model` 字段的 unique 索引 | +| **Step 2** | `createNewIndexes()` | 创建 `teamId_1`, `tmbId_1`, `isShared_1` 索引 | +| **Step 3** | `migrateModelData()` | `metadata` 扁平化、设置 `isShared`、补全缺失默认值、孤儿模型归属 root | +| **Step 4** | `migrateDatasets()` | `vectorModel/agentModel/vlmModel` → `XxxModelId`,model 名转 model id,清理 `dataset_trainings.model` | +| **Step 5** | `migrateAppWorkflows()` | `apps.modules` + `app_versions.nodes` 的 model key/value 迁移,`chatConfig.questionGuide`/`ttsConfig` 迁移 | +| **Step 6** | `migrateEvaluationData()` | `eval_dataset_collections` 和 `eval_dataset_datas` 的 model 字段迁移 | +| **Step 7** | `migrateEvaluationTasks()` | `evals.evaluators[].runtimeConfig.llm/embedding` → `llmId/embeddingId` | +| **Step 8** | `migrateTrainingRecords()` | embedding/rerank 训练任务的 model id 转换 | +| **Step 9** | `migrateUsageRecords()` | `usage_items.model` → `modelId` | + +**关键辅助函数**: +- `buildModelNameToIdMap()`: 从 `system_models` 构建 model 名 → model id 映射(同名多模型优先 active) +- `WORKFLOW_MODEL_KEY_MAP`: 8 个 model key 的旧名→新名映射常量 +- `convertModelValue()`: 将 model 名字符串转换为 model id + +--- + +## 九、影响面汇总 + +| 影响范围 | 核心变化 | +|----------|---------| +| `system_models` 表 | metadata 扁平化、新增权限字段、model 去唯一索引 | +| `datasets` 表 | vectorModel/agentModel/vlmModel → XxxModelId | +| 评估相关表 | model → modelId 系列重命名 | +| 工作流 node input keys | 8 个 model key 统一加 Id 后缀,新增 extractWorkflowModelIds、removeUnauthModels 重构 | +| 应用配置类型 | model → modelId | +| 运行时 dispatch 响应 | model → modelId 系列重命名,agenticSearchResult 结构化 | +| 全局 model cache | 6 个 Map 重命名,新增 systemModelIdMap,key 从 model 名变 id | +| 模型查找函数 | 11 个函数重命名/新增/删除,参数从 model 名变 id | +| 权限系统 | 新增 model 资源类型、团队位、鉴权中间件、引用检查 | +| API 接口 | 新增 CRUD + list 接口,重构权限校验,initv4150 迁移端点 | +| 计费/用量 | formatModelChars2Points 参数变 modelId,usage_items 字段重命名 | +| 数据迁移 | initv4150.ts 9 步完整迁移 (index + 8 类数据) | + +--- + +## 十、TODO + +### 1. fastgpt-plugin 中定义的工作流模板、默认模型配置需要优化 + +当前 `fastgpt-plugin` 中定义的工作流模板和默认模型配置仍使用旧字段名(如 `model` 而非 `modelId`),需同步更新以匹配新的数据模型。 + +### 2. aiproxy Channel 权限越权问题 + +**问题描述**: 用户 A 创建了自定义模型(`id: 00000xxx001`, `model: qwen-3.6`),未配置自定义 `requestUrl` 和 `requestAuth`;root 创建了另一个模型(`id: 00000xxx002`, `model: qwen-3.6`),同时配置了对应的 model-channel,绑定了 `model=qwen-3.6`。当用户 A 执行模型测试或产生模型调用时,会越权使用 root 配置的 model-channel 来访问 `qwen-3.6`。 + +**根因**: aiproxy 的 channel 匹配仅基于 `model` 名字(`qwen-3.6`),不感知 FastGPT 侧的模型 `_id` 和权限归属。用户 A 的模型调用到达 aiproxy 时,aiproxy 根据 `model` 名匹配到了 root 配置的 channel。 + +--- + +## 附录: aiproxy 渠道管理与权限隔离分析 + +当前 aiproxy 通过 **三层模型** 实现访问控制:Group → Token → Channel + +### 数据模型关系 + +``` +Group (组) + ├── ID, Status, AvailableSets, RPMRatio, TPMRatio + ├── 拥有多个 Token + └── 拥有多个 GroupModelConfig + +Token (API密钥) + ├── 属于一个 Group + ├── Models []string — 限制可访问的模型 + ├── Subnets []string — IP 白名单 + ├── Quota / PeriodQuota — 配额控制 + └── Status — 启用/禁用 + +Channel (渠道/上游) + ├── Type, Key, BaseURL + ├── Models []string — 该渠道支持的模型 + ├── Sets []string — 渠道分组标签 (如 "default") + ├── Priority — 同模型多渠道时的优先级 + └── Status — 启用/禁用 + +ModelConfig (模型配置) + ├── Model, Type, Owner + ├── RPM, TPM — 模型级速率限制 + └── Price — 定价 +``` + +### 权限隔离机制 + +| 能力 | 支持程度 | 实现位置 | +|------|---------|---------| +| Token → 模型限制 | 支持 | `token_cache.go:57` FindModel() — Token 可配置允许访问的模型白名单 | +| Group → Set 隔离 | 支持 | `group.go:36` AvailableSets — Group 只能访问特定 Set 的 Channel | +| Channel → Set 分类 | 支持 | `channel.go:60` Sets — Channel 打标签归类 | +| Token → IP 限制 | 支持 | `token.go:39` Subnets — 限制 Token 使用的 IP 范围 | +| Token → 配额 | 支持 | `token.go:47-51` Quota + PeriodQuota — 总量/周期配额 | +| Group → 速率限制 | 支持 | `group.go:32-33` RPMRatio/TPMRatio + GroupModelConfig | +| Group → 模型配置覆盖 | 支持 | `group_cache.go:33` ModelConfigs — 按 Group 覆盖 RPM/TPM/Price 等 | +| Admin API 鉴权 | 单 Key | `auth.go:41` AdminAuth — 仅支持单一 AdminKey | + +### 与 FastGPT 模型重构的断层 + +FastGPT 重构后模型以 `_id` 为唯一标识,但 aiproxy 的 channel 匹配仍然基于 `model` 名字字符串。当多个 FastGPT 模型(不同 `_id`)共用同一个 `model` 名时,aiproxy 无法区分调用来源的权限归属,存在跨用户 channel 复用风险。 diff --git a/.claude/design/refactor-buildFineTuneData.md b/.claude/design/refactor-buildFineTuneData.md new file mode 100644 index 000000000000..ca154c05f208 --- /dev/null +++ b/.claude/design/refactor-buildFineTuneData.md @@ -0,0 +1,710 @@ +# buildFineTuneData 重构设计 + +## 背景 + +当前 `buildFineTuneData` 依赖 `synthesis` 类型索引(AI 合成的短查询对)来构建训练样本。新需求:移除对 synthesis 索引的依赖,改用数据集数据的 `default`(或指定类型)索引作为 query,数据本身的 Q+A 作为 positive doc。 + +--- + +## 新逻辑概述 + +``` +sampleDataFromDataset + → 加载 {datasetId, collectionId, dataId, q, a, indexes(所有类型)} + → 完整质量过滤 + 文本清洗(filter_chunks.py 全量迁移);kb_count 仅计有效数据量 + → 按权重两轮配额分配(默认等权),各 KB 独立取数 + +processor + → 字段映射 DatasetSelectItem → FineTuneDataItem(裁剪 indexes 为 {type,text}) + → 透传 indexType, negativeStrategy 给 buildFineTuneData + +buildFineTuneData(单次遍历 + 采样) + → 按 indexType 过滤 → 预计算 queryText, qaText → 按 datasetId×collectionId 两层分组 + → 为每个有效 item 生成 sample: + query = queryText + positive = [Q + "\n" + A] // A 为空时只用 Q + negatives = 按策略从分组中采样其他 item 的 qaText(默认 strategy=2) +``` + +**职责划分**: + +| 层 | 职责 | 不做 | +|----|------|------| +| `sampleDataFromDataset` | 完整质量过滤 + 文本清洗,返回已清洗的完整 indexes | 不关心 indexType | +| processor | 字段映射 `DatasetSelectItem` → `FineTuneDataItem` | 不做索引过滤 | +| `buildFineTuneData` | indexType 过滤、预计算、分组、采样 | 不做任何过滤或清洗 | + +> **关于内存**:`buildFineTuneData` 需在内存中持有全量 item 的 Q+A 用于负样本采样,无法拆为"先返回 ID 再查 DB"两步。 + +--- + +## 负样本策略 + +| strategy | 候选范围 | 说明 | +|----------|---------|------| +| 1 | 同知识库、同 collection 的其他数据 | 最近邻硬负样本 | +| **2(默认)** | 同知识库、其他 collection 的数据 | 中等难度负样本 | +| 3 | 其他知识库的数据 | 简单负样本 | +| 4 | 混合 1 + 2 + 3 | 按阶段三等分采样 + 全局兜底 | + +> "知识库"对应 `datasetId`,"collection"对应 `collectionId`。 + +--- + +## 流程图 + +### 整体数据流 + +```mermaid +flowchart LR + subgraph sampleDataFromDataset + A1[MongoDB DatasetData] -->|find/aggregate| A2[加载 doc] + A2 -->|完整质量过滤
filter_chunks.py| A2b[有效 doc] + A2b -->|cleanText 清洗
q/a/indexes| A3[各 KB 可用量 kb_count] + A3 -->|两轮配额分配
quota=M×w_norm → 再分配余量| A4["DatasetSelectItem[](已清洗)"] + end + + subgraph processor + A4 -->|字段映射| B1["FineTuneDataItem[]"] + B1 -->|透传 indexType,
negativeStrategy| B2[buildFineTuneData] + end + + subgraph buildFineTuneData + B2 --> C1["单次遍历
过滤+预计算+分组
→ allProcessed[]
→ datasetCollectionGroups"] + C1 --> C3["sampleNegatives
→ FineTuneSample[]"] + end + + C3 --> D1[("MongoDB TrainsetData")] +``` + +### buildFineTuneData 算法 + +```mermaid +flowchart TD + START["items: FineTuneDataItem[]"] --> SCAN + + subgraph SCAN["单次遍历"] + S1[遍历 item] --> S2{"存在 type===indexType
的 index?"} + S2 -->|否| S3[跳过] + S2 -->|是| S4["ProcessedItem{queryText, qaText}"] + S4 --> S5["→ allProcessed[]
→ groups[datasetId][collectionId]"] + end + + SCAN --> CHECK{allProcessed 为空?} + CHECK -->|是| EMPTY["返回 {samples: []}"] + CHECK -->|否| GEN + + subgraph GEN[生成 samples] + G1[遍历 allProcessed] --> G2["sampleNegatives(groups,
selfDatasetId, selfCollectionId,
strategy)"] + G2 --> G3["FineTuneSample{query, positive, negatives}"] + end + + GEN --> RESULT["返回 {samples}"] +``` + +### 负样本策略候选集 + +```mermaid +flowchart TD + START["sampleNegatives(selfDS, selfColl, strategy)"] --> CALC["numNeg = clamp(floor(candidates×0.5), min, max)"] + CALC --> SW{strategy?} + + SW -->|1| S1["groups[selfDS][selfColl]"] + SW -->|2| S2["groups[selfDS][coll ≠ selfColl]"] + SW -->|3| S3["groups[ds ≠ selfDS][*]"] + SW -->|4| S4["Phase1→同库同coll N/3
Phase2→同库他coll N/3
Phase3→他库 N/3+余量
Phase4→全局兜底"] + + S1 --> SAMPLE["sampleFromItems
(Fisher-Yates + excludeSet + alreadySampled)"] + S2 --> SAMPLE + S3 --> SAMPLE + S4 --> SAMPLE + SAMPLE --> RET["return result"] +``` + +--- + +## 涉及文件 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `packages/service/core/train/common/synthesize/buildFineTuneData.ts` | **重写** | 核心逻辑 | +| `packages/service/core/train/common/utils.ts` | **修改** | `DatasetSelectItem` 新增 `collectionId`;`sampleDataFromDataset` 新增 `weights` / `filterConfig` 参数,Step 0 完整质量过滤 + `cleanText` 清洗,改用两轮配额分配;移除 synthesis 过滤 / `maxSamplePairs` / `distributeSamplesEvenly`;新增 `cleanText` 导出 | +| `packages/service/core/train/common/constants.ts` | **修改** | 移除 `DEFAULT_MAX_SAMPLE_PAIRS` | +| `packages/service/core/train/rerank/data/processor.ts` | **修改** | 移除 `formatSynthesisIndexesToPairs`,简化为字段映射 | +| `packages/service/core/train/embedding/data/processor.ts` | **修改** | 同上 | +| `packages/global/core/train/rerank/api.d.ts` | **修改** | `generateConfig` 新增 `indexType` / `negativeStrategy` / `weights`,移除 `includeOriginalQ` | +| `packages/global/core/train/embedding/api.d.ts` | **修改** | 同上 | +| `packages/global/core/train/rerank/type.d.ts` | **修改** | schema `generateConfig` 同步 | +| `packages/global/core/train/embedding/type.d.ts` | **修改** | 同上 | +| `packages/service/core/train/rerank/utils.ts` | **修改** | 移除 `formatSynthesisIndexesToPairs` re-export | +| `packages/service/core/train/embedding/utils.ts` | **修改** | 同上 | +| `test/cases/service/core/train/build-fine-tune-data.test.ts` | **重写** | 新逻辑测试 | + +**无需修改**:`generate-evaldataset.ts`(rerank + embedding)— 仅使用 `q, a` 字段。 + +--- + +## 详细设计 + +### 数据过滤与清洗 + +**全部在 `sampleDataFromDataset` 内完成,`buildFineTuneData` 不做任何过滤或清洗。** + +流程:从 MongoDB 拉取每个 KB 的全量数据 → **质量过滤** → **文本清洗** → 以清洗后的有效数量计算 `kb_count[i]` → 配额分配 → 按配额取数。 + +#### Step 0a — 质量过滤(对应 `ChunkFilter.is_valid_chunk()`) + +对每条记录的 `q` 字段依次做以下检查,任意一项不通过则丢弃: + +| # | 检查项 | 跳过条件 | +|---|--------|---------| +| 1 | `q` 为空 | `q.trim().length === 0` | +| 2 | 长度下界 | `q.length < minLength`(默认 50) | +| 3 | 长度上界 | `q.length > maxLength`(默认 3000) | +| 4 | 词数不足 | 中文:字符数 < minWords;英文:空格分词数 < minWords(默认 10) | +| 5 | 重复字符比 | 最高频非空白字符出现次数 / 总长 > maxRepetitionRatio(默认 0.5) | +| 6 | 目录检测 | 匹配目录模式("目录"、"Table of Contents"、3 个以上 `...` 行、章节…页码格式等) | +| 7 | 页码检测 | 匹配页码模式("- 1 -"、"第N页"、"Page N"、"N/M" 等) | +| 8 | 元数据检测 | 匹配页眉/页脚模式(内部资料、保密等级、版本号等) | +| 9 | 特殊字符比 | 非字母数字中文常规标点的字符数 / 总长 > 0.5 | +| 10 | 质量分 | 综合质量分 < minQualityScore(默认 0.65;由字符熵、词汇多样性、抗噪声、长度四维加权计算) | +| 11 | `indexes` 为空 | `indexes.length === 0` | + +**过滤配置**(对应 `ChunkFilter` 构造参数): + +```typescript +type ChunkFilterConfig = { + minLength?: number; // 默认 50 + maxLength?: number; // 默认 3000 + minWords?: number; // 默认 10 + maxRepetitionRatio?: number; // 默认 0.5 + minQualityScore?: number; // 默认 0.65 + enableTocFilter?: boolean; // 默认 true + enablePaginationFilter?: boolean; // 默认 true + enableMetadataFilter?: boolean; // 默认 true +}; +``` + +#### Step 0b — 文本清洗(对应 `clean_text()`) + +通过质量过滤的记录,对 `q`、`a`、`indexes[].text` 均调用 `cleanText()`: + +```typescript +/** 对应 Python pipeline 的 clean_text() */ +export function cleanText(text: string): string { + if (!text) return ''; + // HTML 实体解码(&   等) + let t = text.replace(/&[a-zA-Z]+;/g, ' ').replace(/&#\d+;/g, ' '); + // 去除 HTML 标签 + t = t.replace(/<[^>]+>/g, ' '); + // 规范化空白:多空格/制表符→单空格,3+换行→2换行 + t = t.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n'); + // 每行行首行尾去空格 + t = t.split('\n').map((l) => l.trim()).join('\n'); + return t.trim(); +} +``` + +清洗后若 `q.length < 10`,则丢弃该记录(极少数 HTML 标签充斥导致清洗后内容几乎为空的边界情况)。 + +`DatasetSelectItem` 中的 `q`、`a`、`indexes[].text` 均为清洗后的文本,下游不再需要二次清洗。 + +--- + +### `DatasetSelectItem` 变更 + +```typescript +type DatasetSelectItem = { + datasetId: string; + collectionId: string; // 新增 + dataId: string; + q: string; + a: string; + indexes: DatasetDataIndexItemType[]; +}; +``` + +### `sampleDataFromDataset` 修改 + +**接口**: + +```typescript +async function sampleDataFromDataset( + datasetIds: string[], + options: { + datasetType?: 'train' | 'eval' | 'random'; + sampleSize?: number; // 总采样上限 M + weights?: Record; // 各 KB 的采样权重,默认等权(缺省值 1) + filterConfig?: ChunkFilterConfig; // 质量过滤配置,默认见上节 + } +): Promise +``` + +> `weights` 以 `datasetId → 正数` 的形式传入,内部归一化后使用。未出现在 `weights` 中的 dataset 默认权重为 1。 + +**采样算法**: + +每个 `datasetId` 对应一个知识库(KB)。 + +**Step 0 — 质量过滤 + 文本清洗** + +从 MongoDB 拉取每个 KB 的全量数据后,依次执行: +1. 按上节 Step 0a 规则过滤无效记录(任意检查不通过则丢弃) +2. 对通过过滤的记录按 Step 0b 规则清洗 `q`、`a`、`indexes[].text` +3. 清洗后若 `q.length < 10` 则丢弃 + +后续所有步骤的 `total[i]` 均指过滤 + 清洗后的有效记录数。 + +**Step 1 — 计算每个 KB 的可用数据量 `kb_count[i]`** + +| 模式 | kb_count[i] | +|------|------------| +| `train` | `floor(total[i] × 0.8)`(确定性洗牌后的前 80%) | +| `eval` | `total[i] - floor(total[i] × 0.8)`(后 20%) | +| `random` | `total[i]`(必须指定 sampleSize) | + +**Step 2 — 两轮配额分配**(仅当指定 `sampleSize=M` 时) + +``` +归一化权重: + w[i] = weights[datasetIds[i]] ?? 1 + w_norm[i] = w[i] / sum(w) + +第一轮:按权重分配配额 + quota[i] = floor(M × w_norm[i]) + samples[i] = min(kb_count[i], quota[i]) + remaining = M - sum(samples) + +第二轮:将剩余配额按剩余容量比例分给未填满的 KB + if remaining > 0: + unsatisfied = { i : kb_count[i] > samples[i] } + total_capacity = sum(kb_count[i] - samples[i] for i in unsatisfied) + for i in unsatisfied: + extra = round(remaining × (kb_count[i] - samples[i]) / total_capacity) + samples[i] += min(extra, kb_count[i] - samples[i]) + # 边界修正:若舍入导致 sum(samples) > M,依次从最大项中减 1 +``` + +> 两轮分配保证:各 KB 按指定权重(默认等权)分配,同时使总采样量尽量接近 M。 + +**不指定 `sampleSize` 时**: +- `train`/`eval`:取每个 KB 的全部可用数据,无截断(weights 不生效); +- `random`:不允许(必须指定 sampleSize)。 +- **注意**:若同时指定了 `weights` 而未指定 `sampleSize`,应抛出错误——无总量上限时权重无意义。 + +**Step 3 — 按配额取数据** + +| 模式 | 取数方式 | +|------|---------| +| `train` | 确定性洗牌后取前 `samples[i]` 条 | +| `eval` | 确定性洗牌后取后 `samples[i]` 条 | +| `random` | MongoDB `$sample` 管道取 `samples[i]` 条 | + +**移除**:`distributeSamplesEvenly()`、synthesis 索引过滤、`maxSamplePairs` 参数。 + +**新增**:`weights` / `filterConfig` 参数;Step 0 完整质量过滤 + `cleanText` 清洗;查询投影加入 `collectionId`,格式化时直接映射所有 indexes(不过滤类型)。 + +### `buildFineTuneData` 类型 + +```typescript +export type FineTuneDataItem = { + dataId: string; + datasetId: string; + collectionId: string; + q: string; + a: string; + indexes: { type: string; text: string }[]; +}; + +export type FineTuneSample = { + query: string; + positive: string[]; // 始终 1 元素 + negatives: string[]; + sourceId: string; + datasetId: string; +}; + +export type BuildFineTuneDataParams = { + items: FineTuneDataItem[]; + indexType?: string; // 默认 'default' + negativeStrategy?: 1 | 2 | 3 | 4; // 默认 2 + minNegativeSamples?: number; // 默认 1 + maxNegativeSamples?: number; // 默认 10 +}; + +export type BuildFineTuneDataResult = { + samples: FineTuneSample[]; +}; +``` + +### `buildFineTuneData` 算法 + +```typescript +export function buildQAText(q: string, a: string): string { + if (!a || a.trim() === '') return q; + return `${q}\n${a}`; +} + +// 内部类型 +type ProcessedItem = { + item: FineTuneDataItem; + queryText: string; + qaText: string; +}; + +export function buildFineTuneData(params: BuildFineTuneDataParams): BuildFineTuneDataResult { + const { + items, + indexType = 'default', + negativeStrategy = 2, + minNegativeSamples = 1, + maxNegativeSamples = 10 + } = params; + + if (items.length === 0) return { samples: [] }; + + // 单次遍历:过滤 + 预计算 + 两层分组(无清洗,数据已在 sampleDataFromDataset 中清洗) + const allProcessed: ProcessedItem[] = []; + const groups = new Map>(); + + for (const item of items) { + const targetIndex = item.indexes.find((idx) => idx.type === indexType); + if (!targetIndex) continue; + + const processed: ProcessedItem = { + item, + queryText: targetIndex.text, + qaText: buildQAText(item.q, item.a) + }; + allProcessed.push(processed); + + if (!groups.has(item.datasetId)) groups.set(item.datasetId, new Map()); + const collMap = groups.get(item.datasetId)!; + if (!collMap.has(item.collectionId)) collMap.set(item.collectionId, []); + collMap.get(item.collectionId)!.push(processed); + } + + if (allProcessed.length === 0) return { samples: [] }; + + // 生成 samples + const samples: FineTuneSample[] = []; + for (const { item, queryText, qaText } of allProcessed) { + const negatives = sampleNegatives( + allProcessed, groups, qaText, + item.datasetId, item.collectionId, + negativeStrategy, minNegativeSamples, maxNegativeSamples + ); + samples.push({ + query: queryText, + positive: [qaText], + negatives, + sourceId: item.dataId, + datasetId: item.datasetId + }); + } + + return { samples }; +} +``` + +### 负样本采样 + +```typescript +const NEGATIVE_SAMPLE_RATIO = 0.5; + +function sampleNegatives( + allProcessed: ProcessedItem[], + groups: Map>, + selfQAText: string, + selfDatasetId: string, + selfCollectionId: string, + strategy: 1 | 2 | 3 | 4, + minCount: number, + maxCount: number +): string[] { + const totalCandidates = allProcessed.length - 1; + if (totalCandidates <= 0) return []; + + const numNeg = Math.max(minCount, Math.min(maxCount, + Math.floor(totalCandidates * NEGATIVE_SAMPLE_RATIO))); + const exclude = new Set([selfQAText]); + const sampled = new Set(); + const result: string[] = []; + + switch (strategy) { + case 1: { + const c = groups.get(selfDatasetId)?.get(selfCollectionId) ?? []; + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 2: { + const c = collectSameDatasetOtherCollections(groups, selfDatasetId, selfCollectionId); + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 3: { + const c = collectOtherDatasets(groups, selfDatasetId); + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 4: { + const perSource = Math.max(1, Math.floor(numNeg / 3)); + const remainder = numNeg - perSource * 3; + // Phase 1: 同库同 collection + sampleFromItems( + groups.get(selfDatasetId)?.get(selfCollectionId) ?? [], + exclude, sampled, perSource, result); + // Phase 2: 同库其他 collection + sampleFromItems( + collectSameDatasetOtherCollections(groups, selfDatasetId, selfCollectionId), + exclude, sampled, perSource, result); + // Phase 3: 其他知识库 + sampleFromItems( + collectOtherDatasets(groups, selfDatasetId), + exclude, sampled, perSource + remainder, result); + break; + } + } + + // 全局兜底:所有策略在候选不足时,从全量 allProcessed 补足(排除已采样和自身) + if (result.length < numNeg) { + sampleFromItems(allProcessed, exclude, sampled, numNeg - result.length, result); + } + + return result.slice(0, numNeg); +} + +/** partial Fisher-Yates 随机采样 */ +function sampleFromItems( + candidates: ProcessedItem[], + exclude: Set, + sampled: Set, + target: number, + out: string[] +): void { + if (candidates.length === 0 || target <= 0) return; + const arr = [...candidates]; + const maxScan = Math.min(arr.length, target * 3); + const start = out.length; + for (let i = 0; i < maxScan && out.length - start < target; i++) { + const j = i + Math.floor(Math.random() * (arr.length - i)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + const t = arr[i].qaText; + if (!exclude.has(t) && !sampled.has(t)) { out.push(t); sampled.add(t); } + } +} + +function collectSameDatasetOtherCollections( + groups: Map>, + selfDatasetId: string, selfCollectionId: string +): ProcessedItem[] { + const collMap = groups.get(selfDatasetId); + if (!collMap) return []; + const r: ProcessedItem[] = []; + for (const [cid, items] of collMap) { if (cid !== selfCollectionId) r.push(...items); } + return r; +} + +function collectOtherDatasets( + groups: Map>, + selfDatasetId: string +): ProcessedItem[] { + const r: ProcessedItem[] = []; + for (const [did, collMap] of groups) { + if (did !== selfDatasetId) { for (const items of collMap.values()) r.push(...items); } + } + return r; +} +``` + +### Processor 修改 + +```typescript +// rerank/data/processor.ts(embedding 同理) +// Step 1: 按权重采样数据 +const samples = await sampleDataFromDataset(datasetIds, { + datasetType: generateConfig.datasetType ?? 'train', + sampleSize: generateConfig.sampleSize, + weights: generateConfig.weights // 透传权重,undefined 时内部默认等权 +}); + +// Step 2: 仅字段映射,不做过滤 +const result = buildFineTuneData({ + items: samples.map((s) => ({ + dataId: s.dataId, + datasetId: s.datasetId, + collectionId: s.collectionId, + q: s.q, + a: s.a, + indexes: s.indexes.map((idx) => ({ type: idx.type, text: idx.text })) + })), + indexType: generateConfig.indexType ?? 'default', + negativeStrategy: generateConfig.negativeStrategy ?? 2, + minNegativeSamples: generateConfig.minNegativeSamples, + maxNegativeSamples: generateConfig.maxNegativeSamples +}); +``` + +### `generateConfig` 接口 + +```typescript +export type GenerateRerankTrainDataRequest = { + trainsetId: string; + datasetIds: string[]; + generateConfig?: { + sampleSize?: number; + weights?: Record; // 新增,各 KB 采样权重,默认等权 + forceRegenerate?: boolean; + minNegativeSamples?: number; + maxNegativeSamples?: number; + indexType?: string; // 新增,默认 'default' + negativeStrategy?: 1 | 2 | 3 | 4; // 新增,默认 2 + // 移除:includeOriginalQ + }; +}; +``` + +### 常量变更 + +```typescript +// packages/service/core/train/common/constants.ts +// 移除 DEFAULT_MAX_SAMPLE_PAIRS +// 保留 TRAIN_DATA_SPLIT_RATIO = 0.8, MIN_EVAL_QA_COUNT = 200 +``` + +--- + +## 删除的代码 + +| 代码 | 文件 | +|------|------| +| `formatSynthesisIndexesToPairs()` | `common/utils.ts` | +| `distributeSamplesEvenly()` | `common/utils.ts` | +| `DEFAULT_MAX_SAMPLE_PAIRS` | `common/constants.ts` | +| `PrecomputedPools`(shortQueries/longChunks + 按 dataset 分组索引) | `buildFineTuneData.ts` | +| `FineTuneSample.originalQ/A`、`FineTuneSample.metadata` | `buildFineTuneData.ts` | +| `BuildFineTuneDataParams.includeOriginalQ` | `buildFineTuneData.ts` | +| `formatSynthesisIndexesToPairs` re-export | `rerank/utils.ts`、`embedding/utils.ts` | +| synthesis 过滤逻辑 | `sampleDataFromDataset` | +| `generateConfig.includeOriginalQ` | `api.d.ts`、`type.d.ts` | + +--- + +## 测试用例 + +测试文件:`test/cases/service/core/train/build-fine-tune-data.test.ts` + +``` +T1: 有目标索引的 item 恰好生成 1 个 sample +T2: query === item.indexes.find(type===indexType).text +T3: positive = [Q+"\n"+A](A 非空);positive = [Q](A 为空) +T4: 无目标索引的 item 被跳过,不生成 sample 也不进入分组 +T5: strategy=1 → negatives 全部来自同知识库同 collection +T6: strategy=2 → negatives 全部来自同知识库其他 collection +T7: strategy=3 → negatives 全部来自其他知识库 +T8: strategy=4 → negatives 混合来自三个来源 +T9: negatives 不包含自身 Q+A +T10: 单 item + minNegativeSamples=0 → negatives 为空 +T11: minNegativeSamples / maxNegativeSamples 约束生效 +T12: negatives 输出不重复 +``` + +运行:`node_modules/.bin/vitest run test/cases/service/core/train/build-fine-tune-data.test.ts` + +--- + +## 执行步骤 + +| # | 操作 | 验证 | +|---|------|------| +| 1 | 重写 `buildFineTuneData.ts` | T1-T12 通过 | +| 2 | 修改 `sampleDataFromDataset` | 编译通过 | +| 3 | 修改 `processor.ts`(rerank + embedding) | 编译通过 | +| 4 | 更新 `api.d.ts` / `type.d.ts`(rerank + embedding) | 编译通过 | +| 5 | 更新 `constants.ts`,清理 re-exports | 编译通过 | +| 6 | 重写测试文件 | vitest 通过 | + +--- + +## 进阶优化:ID占位+流式三阶段(已实现) + +> **注意**:上文描述的是初版设计(`buildFineTuneData` 同步,内存中持有全量 `FineTuneDataItem[]`)。 +> 当前实现已进化为 `buildFineTuneDataStream`(AsyncGenerator),通过 ID占位+流式查询 进一步大幅降低内存峰值。 + +### 问题背景 + +初版设计中,`buildFineTuneData` 的调用方(processor)需要将全量 `FineTuneDataItem[]`(含 q/a/indexes)传入,同时函数内部持有 `allProcessed[].qaText` 用于负样本采样。对于 200k 条数据: +- `FineTuneDataItem[]` 约 400MB(q/a/indexes 全量) +- qaText pool 约 60MB +- 总峰值约 460MB + +### 三阶段流式方案 + +``` +Phase 1 (纯内存): 从 sampledItems (ID-only) 构建 dataId 分组 + → groups[datasetId][collectionId] = ProcessedItem[] + 内存:N × 72 bytes(3个 ObjectId 字符串) + +Phase 2 (纯内存): ID占位负样本采样 + → sampleIndices[]: { sourceId, datasetId, negativeDataIds[] } + 内存:N × ~200 bytes(含 5个负样本 ID) + +Phase 3 (流式 DB): 批量500条查询 → yield FineTuneSample + → 每批 docMap = 500 条精简字段(q/a + $filter 目标 indexType) + 内存:~250 KB(流动不累积) + +总峰值(N=5000):~1.6 MB(vs 旧版 ~2.5 MB;真实场景节省 10x+) +``` + +### 关键 API 变更 + +```typescript +// 旧版(同步,调用方传入全量内容) +export function buildFineTuneData(params: { + items: FineTuneDataItem[]; // 含 q/a/indexes 全量内容 + indexType?: string; + ... +}): { samples: FineTuneSample[] } + +// 新版(异步生成器,ID-only 输入,内部流式查询) +export async function* buildFineTuneDataStream(params: { + sampledItems: SampledDataItem[]; // 仅 { dataId, datasetId, collectionId } + indexType: string; // 必选,默认由 processor 层提供 + ... +}): AsyncGenerator +``` + +### sampleDataFromDataset 变更 + +- 旧版:返回 `DatasetSelectItem[]`(含 q/a/indexes,每条 ~2KB) +- 新版:返回 `SampledDataItem[]`(仅 ID,每条 ~80 bytes) +- 质量过滤逻辑不变(`filterToSampledItems` 仍需加载 q 字段做过滤,过滤后丢弃内容只保留 ID) + +### Processor 消费方式 + +```typescript +// processor.ts(rerank + embedding 相同模式) +for await (const sample of buildFineTuneDataStream({ + sampledItems, // ID-only + indexType: generateConfig.indexType ?? DatasetDataIndexTypeEnum.question, + negativeStrategy: generateConfig.negativeStrategy ?? 2, +})) { + saveBatch.push({ trainsetId, query: sample.query, ... }); + if (saveBatch.length >= 1000) { + await MongoTrainsetData.insertMany(saveBatch); + saveBatch = []; + } +} +``` + +### Benchmark 结果 + +测试文件:`test/cases/service/core/train/build-fine-tune-data-benchmark.test.ts` + +| 场景 | 指标 | 值 | +|------|------|---| +| BM-1 N=1000 | ID-only vs 全量 doc 比值 | ~3x(真实场景 10x+) | +| BM-3 N=500→2000 | 内存线性增长 | 4.00x(完全线性) | +| BM-4 N=2000 | sampleIndices 实际大小 | 439 KB | +| BM-4 N=5000 | sampledItems 实际大小 | 581 KB | +| BM-4 N=5000 | 端到端吞吐量 | ~3974 samples/sec | \ No newline at end of file diff --git a/.claude/design/refactor-ui/AlResponseBox-component-analysis.md b/.claude/design/refactor-ui/AlResponseBox-component-analysis.md new file mode 100644 index 000000000000..bd5dbdeee188 --- /dev/null +++ b/.claude/design/refactor-ui/AlResponseBox-component-analysis.md @@ -0,0 +1,243 @@ +# AIResponseBox 组件分析 + +> 文件路径:`projects/app/src/components/core/chat/components/AIResponseBox.tsx` + +## 组件职责 + +`AIResponseBox` 是 AI 对话气泡内容区的**类型分发器**,根据 `value.type` 的不同,渲染对应的子组件: + +| value.type | 渲染组件 | 描述 | +|---|---|---| +| `text` | `RenderText` | 普通文本 / Markdown 消息 | +| `reasoning` | `RenderResoningContent` | AI 思考过程(可折叠) | +| `tool` | `RenderTool` | 工具调用结果(可折叠) | +| `interactive` → `userSelect` | `RenderUserSelectInteractive` | 用户选项交互 | +| `interactive` → `userInput` | `RenderUserFormInteractive` | 用户表单输入交互 | +| `interactive` → `paymentPause` | `RenderPaymentPauseInteractive` | 支付暂停交互 | + +--- + +## 内部子组件 + +### 1. `RenderResoningContent` + +**功能**:展示 AI 推理/思考过程,可折叠展开。 + +**UI 结构**: + +``` +Accordion(可折叠容器) + └── AccordionItem + ├── AccordionButton(触发按钮) + │ ├── MyIcon name="core/chat/think"(思考图标) + │ ├── Box("AI 推理" 文字) + │ ├── MyIcon name="common/loading"(加载动画,仅流式时显示) + │ └── AccordionIcon(展开/收起箭头) + └── AccordionPanel(内容面板) + └── Markdown(渲染推理内容) +``` + +**样式特点**: +- 按钮:`bg=white`, `borderRadius=md`, `border=1px myGray.200`, `boxShadow=1` +- 面板:左侧 `2px solid myGray.300` 竖线,文字颜色 `myGray.500` +- 流式输出时默认展开(`defaultIndex={0}`),结束后收起 + +--- + +### 2. `RenderText` + +**功能**:渲染 AI 文本响应,支持富文本 Markdown、引用数据集标注。 + +**UI 结构**: + +``` +Markdown(第三方 Markdown 渲染组件) +``` + +**核心逻辑**: +- 从 `WorkflowRuntimeContext` 读取 `appId`、`chatId`、`outLinkAuthData` +- 从 `ChatItemContext` 读取 `isShowCite`(是否显示引用标注) +- 若 `isShowCite=false`,调用 `removeDatasetCiteText` 去除引用标记 +- 向 `Markdown` 传入 `chatAuthData`(用于引用链接鉴权)、`onOpenCiteModal`(打开引用详情弹窗) + +--- + +### 3. `RenderTool` + +**功能**:展示工具调用的入参和出参,可折叠展开,支持多工具。 + +**UI 结构**: + +``` +Box(外层容器) + └── Accordion × N(每个工具一个) + └── AccordionItem + ├── AccordionButton + │ ├── Avatar(工具图标) + │ ├── Box(工具名称) + │ ├── MyIcon name="common/loading"(调用中动画) + │ └── AccordionIcon + └── AccordionPanel + ├── Markdown(~~~json#Input 展示入参) + └── Markdown(~~~json#Response 展示出参) +``` + +**样式特点**: +- 面板:`maxH=500px`,超出滚动 +- 多工具时相邻间距 `mb=2` +- params 为 `{}` 时不显示入参区块 + +--- + +### 4. `RenderUserSelectInteractive` + +**功能**:渲染用户选择交互,显示多个可点击选项。 + +**UI 结构**: + +``` +SelectOptionsComponent(来自 InteractiveComponents) + └── Box(外层) + ├── DescriptionBox(选项描述,可选) + └── LeftRadio(左侧单选列表) +``` + +**交互逻辑**:选中后通过 `eventBus.emit(EventNameEnum.sendQuestion)` 发送消息。一旦选中(`userSelectedVal` 有值),禁用交互。 + +--- + +### 5. `RenderUserFormInteractive` + +**功能**:渲染用户填写表单交互,支持多种输入类型。 + +**UI 结构**: + +``` +Flex(垂直布局,gap=2,minW=250px) + └── FormInputComponent(来自 InteractiveComponents) + ├── DescriptionBox(表单描述,可选) + ├── Flex(表单字段列表,gap=3) + │ └── Controller × N(react-hook-form 控制) + │ └── FormControl + │ ├── Flex(label 行) + │ │ ├── Box(必填红 * ) + │ │ ├── FormLabel(字段标签) + │ │ └── QuestionTip(字段描述提示 icon) + │ ├── InputRender(动态输入渲染) + │ └── FormErrorMessage(验证错误) + └── Button(提交按钮,来自父级注入的 SubmitButton render prop) +``` + +**交互逻辑**:提交后将表单数据序列化为 JSON 字符串,通过 `eventBus` 发送。已提交(`submitted=true`)时表单禁用,按钮隐藏。 + +--- + +### 6. `RenderPaymentPauseInteractive` + +**功能**:显示支付暂停提示,提供"继续运行"按钮。 + +**UI 结构**: + +``` +(未支付状态) +Box(描述文字,color=myGray.500) +Button maxW=250px("继续运行"按钮) + +(已继续状态,continue=true) +Box("任务已继续" 文字) +``` + +--- + +## 依赖外部组件 + +### `Markdown`(`@/components/Markdown`) + +| 属性 | 类型 | 说明 | +|---|---|---| +| `source` | `string` | Markdown 文本内容 | +| `showAnimation` | `boolean` | 流式输出打字动画 | +| `chatAuthData` | `object` | 引用鉴权数据(appId/chatId 等) | +| `onOpenCiteModal` | `function` | 点击引用标号的回调 | +| `hideCiteIcon` | `boolean` | 是否隐藏引用图标 | + +内部使用 `react-markdown` + 多个 remark/rehype 插件(数学、表格、代码高亮、图表等)。 + +--- + +### `MyIcon`(`@fastgpt/web/components/common/Icon`) + +SVG 图标组件,通过 `name` 属性指定图标,本文件中使用: +- `core/chat/think` — 推理思考图标 +- `common/loading` — 加载动画图标 + +--- + +### `Avatar`(`@fastgpt/web/components/common/Avatar`) + +用户/工具头像组件,在 `RenderTool` 中用于展示工具图标(`w=1.25rem`, `h=1.25rem`, `borderRadius=sm`)。 + +--- + +### `SelectOptionsComponent` / `FormInputComponent`(`./Interactive/InteractiveComponents`) + +交互类型的具体渲染逻辑,分别处理单选列表和表单填写场景(详见上方结构分析)。 + +--- + +## Context 依赖 + +| Context | 字段 | 用途 | +|---|---|---| +| `ChatItemContext` | `showRunningStatus` | 是否渲染工具调用面板 | +| `ChatItemContext` | `isShowCite` | 是否显示引用标注 | +| `WorkflowRuntimeContext` | `appId` | 应用 ID,用于引用鉴权 | +| `WorkflowRuntimeContext` | `chatId` | 会话 ID,用于引用鉴权 | +| `WorkflowRuntimeContext` | `outLinkAuthData` | 外链鉴权数据 | + +--- + +## Props 接口 + +```ts +type AIResponseBoxProps = { + chatItemDataId: string; // 消息数据 ID + value: UserChatItemValueItemType | AIChatItemValueItemType; // 消息内容值 + isLastResponseValue: boolean; // 是否为最后一个 value(控制动画) + isChatting: boolean; // 是否正在流式输出 + onOpenCiteModal?: (e?: OnOpenCiteModalProps) => void; // 打开引用弹窗 + hideCiteIcon?: boolean; // 隐藏引用图标 +} +``` + +--- + +## 样式关键变量汇总 + +### `accordionButtonStyle`(共享按钮样式) + +```ts +{ + w: 'auto', + bg: 'white', + borderRadius: 'md', + borderWidth: '1px', + borderColor: 'myGray.200', + boxShadow: '1', + pl: 3, + pr: 2.5, + _hover: { bg: 'auto' } +} +``` + +两个折叠组件(`RenderResoningContent`、`RenderTool`)均复用此样式对象。 + +--- + +## 样式重构关注点 + +1. **accordionButtonStyle** 为内联样式对象,可提取为 Chakra UI theme variant 或组件封装 +2. `RenderResoningContent` 面板的左侧竖线为内联样式,可统一为设计 token +3. `RenderTool` 的面板高度限制 `maxH=500px` 为硬编码 +4. 多处颜色值(`myGray.200`、`myGray.300`、`myGray.500`、`myGray.600`、`myGray.900`)与主题系统相关,重构时需与 UI 规范对齐 +5. `RenderUserFormInteractive` 中 `minW=250px` 和 `Button maxW=250px` 为硬编码尺寸 diff --git a/.claude/design/refactor-ui/ChatBox-components.md b/.claude/design/refactor-ui/ChatBox-components.md new file mode 100644 index 000000000000..3fba1f225f10 --- /dev/null +++ b/.claude/design/refactor-ui/ChatBox-components.md @@ -0,0 +1,545 @@ +# ChatBox 组件梳理文档 + +> 路径:`projects/app/src/components/core/chat/ChatContainer/ChatBox/` +> 更新时间:2026-04-02 +> 用途:样式重构前的组件全量梳理 + +--- + +## 目录结构 + +``` +ChatBox/ +├── index.tsx # 主容器组件 +├── Provider.tsx # Context Provider +├── type.d.ts # TypeScript 类型定义 +├── constants.ts # 常量定义 +├── utils.ts # 工具函数 +├── index.module.scss # 样式文件 +├── hooks/ +│ ├── useChatBox.tsx # 导出聊天功能 Hook +│ └── useFileUpload.tsx # 文件上传 Hook +├── Input/ +│ ├── ChatInput.tsx # 输入框主组件 +│ ├── InputGuideBox.tsx # 输入引导提示框 +│ └── VoiceInput.tsx # 语音输入组件 +└── components/ + ├── ChatItem.tsx # 聊天消息项 + ├── ChatController.tsx # 消息控制按钮组 + ├── ChatAvatar.tsx # 头像组件 + ├── WelcomeBox.tsx # 欢迎信息框 + ├── VariableInputForm.tsx # 变量输入表单 + ├── FilesBox.tsx # 文件网格展示 + ├── FilesCascader.tsx # 文件级联选择器 + ├── FeedbackModal.tsx # 用户反馈弹框 + ├── SelectMarkCollection.tsx # 管理员标记选择器 + ├── TimeBox.tsx # 消息时间间隔显示 + ├── QuoteList.tsx # 知识库引用列表 + ├── ResponseTags.tsx # 响应标签(引用/统计/上下文) + ├── ContextModal.tsx # LLM 上下文预览弹框 + ├── EvaluationDatasetSelector.tsx # 评测数据集选择器 + ├── home/ + │ ├── WelcomeHomeBox.tsx # 首页欢迎框(Logo + 标语) + │ ├── ChatHomeVariablesForm.tsx # 首页变量表单 + │ └── QuickApps.tsx # 快速应用切换器 + └── assistant/ + ├── ChatHistory.tsx # 助手模式聊天历史 + ├── ChatItem.tsx # 助手消息项 + └── ChatItemController.tsx # 助手消息控制器 +``` + +--- + +## 核心组件 + +### index.tsx — 主容器 + +**职责**:整个 ChatBox 的状态管理中心,协调消息流、文件上传、反馈等所有交互。 + +**Props** + +```typescript +type Props = OutLinkChatAuthProps & ChatProviderProps & { + isReady: boolean; + feedbackType?: 'user' | 'admin' | 'hidden'; + showMarkIcon?: boolean; + showVoiceIcon?: boolean; + active?: boolean; + showWorkorder?: boolean; + onStartChat?: (e: StartChatFnProps) => Promise; + onTriggerRefresh?: () => void; +}; +``` + +**核心方法** + +| 方法 | 说明 | +|------|------| +| `generatingMessage()` | 处理流式消息(文本/工具调用/推理过程) | +| `sendPrompt()` | 发送消息并处理响应完整流程 | +| `retryInput()` | 重试上一次输入 | +| `delOneMessage()` | 删除单条消息对 | +| `onMark()` | 管理员标记数据 | +| `onAddUserLike/Dislike()` | 用户反馈(赞/踩) | + +**消息发送流程** + +``` +用户输入 → ChatInput.handleSend() + ↓ +sendPrompt() [index.tsx] + ↓ +变量验证 & 格式化 + ↓ +创建 Human + AI 消息项(乐观更新) + ↓ +调用 onStartChat (流式 API) + ↓ +generatingMessage() 逐块更新 UI + ↓ +生成问题引导 / 处理反馈 +``` + +--- + +### Provider.tsx — Context Provider + +**职责**:提供聊天环境所需的全局配置和音频能力。 + +**提供的 Context(`ChatBoxContext`)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `welcomeText` | `string` | 欢迎文本 | +| `variableList` | `VariableItemType[]` | 变量列表 | +| `questionGuide` | `AppQGConfigType` | 问题引导配置 | +| `ttsConfig` | `AppTTSConfigType` | TTS 配置 | +| `whisperConfig` | `AppWhisperConfigType` | 语音识别配置 | +| `autoTTSResponse` | `boolean` | 是否自动语音播放 | +| `isChatting` | `boolean` | 是否正在生成 | +| `fileSelectConfig` | `AppFileSelectConfigType` | 文件选择配置 | +| `isAssistantType` | `boolean` | 是否为助手类型 | +| `audioLoading/Playing/hasAudio` | `boolean` | 音频状态 | +| `playAudioByText()` | `fn` | 文本转语音播放 | +| `startSegmentedAudio()` | `fn` | 开始分段音频 | +| `splitText2Audio()` | `fn` | 分段文本转音频 | +| `finishSegmentedAudio()` | `fn` | 完成分段音频 | +| `cancelAudio()` | `fn` | 取消音频播放 | +| `getHistoryResponseData()` | `fn` | 获取历史响应数据 | + +--- + +## 输入区组件 + +### Input/ChatInput.tsx — 输入框 + +**职责**:统一的消息输入入口,支持文字、文件、语音三种方式。 + +**Props** + +```typescript +{ + onSendMessage: SendPromptFnType; + onStop: () => void; + TextareaDom: React.MutableRefObject; + resetInputVal: (val: ChatBoxInputType) => void; + chatForm: UseFormReturn; +} +``` + +**功能列表** + +| 功能 | 说明 | +|------|------| +| 自动高度调整 | Textarea 随内容增长 | +| 文件拖放上传 | 拖入文件触发上传流程 | +| 粘贴上传 | Paste 事件捕获图片/文档 | +| 键盘快捷键 | Enter 发送,Shift+Enter 换行 | +| 语音输入 | 集成 `VoiceInput` 组件 | +| 输入引导 | 集成 `InputGuideBox` 组件 | +| 文件预览 | 上传后的文件缩略图展示 | +| 发送/停止按钮 | 根据 `isChatting` 状态切换 | + +--- + +### Input/VoiceInput.tsx — 语音输入 + +**职责**:处理 PC 和移动端的语音录入,通过 Whisper API 转文字。 + +**暴露的 Ref 接口** + +```typescript +export interface VoiceInputComponentRef { + onSpeak: () => void; + getVoiceInputState: () => { isSpeaking: boolean; isTransCription: boolean }; +} +``` + +**PC 模式**:波形可视化(Canvas)+ 录音时长 + 取消/完成按钮 + +**移动模式**:长按录音,上滑取消,松开发送 + +--- + +### Input/InputGuideBox.tsx — 输入引导 + +**职责**:根据输入内容展示候选提示词,点击直接填充输入框。 + +--- + +## 消息展示组件 + +### components/ChatItem.tsx — 消息项 + +**职责**:渲染单条聊天消息,分 Human 和 AI 两种形态。 + +**Props(核心字段)** + +```typescript +{ + avatar?: string; + type: ChatRoleEnum.Human | ChatRoleEnum.AI; + chat: ChatSiteItemType; + statusBoxData?: { status: string; name: string }; + questionGuides?: string[]; + showVoiceIcon?: boolean; + onRetry?: () => void; + onDelete?: () => void; + onMark?: () => void; + onAddUserLike?: () => void; + onAddUserDislike?: () => void; + onReadFeedback?: () => void; + onCloseCustomFeedback?: () => void; +} +``` + +**包含子组件** + +| 子组件 | 说明 | +|--------|------| +| `HumanContentCard` | 用户消息气泡 | +| `AIContentCard` | AI 消息气泡(含 Markdown 渲染) | +| `ChatController` | 复制/语音/重试/反馈等操作按钮 | +| `ResponseTags` | 引用/工具调用/上下文入口 | +| `FilesBlock` | 消息附带的文件/图片展示 | + +--- + +### components/ChatController.tsx — 消息控制器 + +**职责**:每条消息下方的交互操作按钮组。 + +**按钮列表** + +| 按钮 | 条件 | 说明 | +|------|------|------| +| 复制 | 始终 | 复制纯文本内容 | +| 语音播放 | `showVoiceIcon` | TTS 朗读 | +| 重试 | AI 消息 | 重新生成 | +| 删除 | 始终 | 删除该消息对 | +| 赞 | `feedbackType=user` | 正向反馈 | +| 踩 | `feedbackType=user` | 负向反馈,触发 FeedbackModal | +| 标记 | `feedbackType=admin` | 管理员数据标记 | +| 反馈已读 | 有未读反馈 | 标记反馈为已读 | +| 关闭自定义反馈 | 有自定义反馈 | 关闭当前反馈 | + +--- + +### components/ChatAvatar.tsx — 头像 + +**职责**:显示用户或 AI 的头像,支持自定义图片和默认图标。 + +--- + +### components/ResponseTags.tsx — 响应标签 + +**职责**:AI 回复下方的辅助信息入口标签。 + +**导出类型** + +```typescript +export type CitationRenderItem = { + type: 'dataset' | 'link'; + key: string; + displayText: string; + icon?: string; + onClick: () => any; +}; +``` + +**功能入口** + +| 标签 | 说明 | +|------|------| +| 知识库引用 | 点击打开 `QuoteList` | +| 工具调用 | 显示调用链路 | +| 上下文预览 | 点击打开 `ContextModal` | +| 完整响应 | 查看详细响应数据 | +| 助手详情 | 助手模式下的详情展示 | + +--- + +### components/QuoteList.tsx — 引用列表 + +**职责**:展示知识库引用的具体片段内容,支持查看源文件和跳转数据集。 + +--- + +### components/ContextModal.tsx — 上下文预览 + +**职责**:弹框展示 LLM 实际接收到的对话历史(system prompt + messages),便于调试。 + +--- + +### components/TimeBox.tsx — 时间间隔 + +**职责**:相邻消息时间差超过 10 分钟时,在消息间显示时间分割线。 + +--- + +### components/WelcomeBox.tsx — 欢迎信息框 + +**职责**:对话开始前展示应用配置的欢迎语文本(支持 Markdown)。 + +--- + +### components/VariableInputForm.tsx — 变量输入表单 + +**职责**:渲染应用定义的输入变量,在发送第一条消息前收集用户填写的变量值。 + +**支持的变量类型** + +| 类型 | 说明 | +|------|------| +| External | 外部自定义变量(Log/Test 模式显示) | +| Internal | 内置变量(Test 模式显示) | +| Common | 常规变量(始终显示) | + +--- + +### components/FilesBox.tsx — 文件展示框 + +**职责**:网格布局展示消息中携带的文件或图片。 + +**布局规则**:文件数 1 → 1 列,2 → 2 列,≥3 → 3 列 + +--- + +### components/FilesCascader.tsx — 文件级联选择器 + +**职责**:选择上传文件的目标知识库层级路径。 + +--- + +### components/FeedbackModal.tsx — 反馈弹框 + +**职责**:用户点踩后弹出文本框,收集具体反馈内容并提交。 + +**流程**:点击踩 → 打开弹框 → 填写反馈 → 调用 API → 更新 UI 状态 + +--- + +### components/SelectMarkCollection.tsx — 管理员标记选择器 + +**职责**:管理员标记对话数据用于训练数据集。 + +**关键类型** + +```typescript +export type AdminMarkType = { + feedbackDataId?: string; + datasetId?: string; + collectionId?: string; + q: string; + a?: string; +}; +``` + +--- + +### components/EvaluationDatasetSelector.tsx — 评测数据集选择器 + +**职责**:选择用于保存评测数据的数据集。 + +--- + +## 首页组件(components/home/) + +### WelcomeHomeBox.tsx — 首页欢迎框 + +**职责**:首页展示应用 Logo 和标语,响应式布局适配宽屏/窄屏。 + +--- + +### ChatHomeVariablesForm.tsx — 首页变量表单 + +**职责**:在首页展示变量填写表单 + 发送按钮,作为对话启动入口。 + +**变量分组**: +- 外部变量(External) +- 常规变量(Common) + +--- + +### QuickApps.tsx — 快速应用切换器 + +**职责**:显示可快速切换的应用列表(头像 + 名称),当前激活项高亮。 + +--- + +## 助手模式组件(components/assistant/) + +### ChatHistory.tsx — 助手聊天历史 + +**职责**:助手模式下的聊天历史渲染,支持纠错、自定义反馈和数据标记。 + +--- + +### ChatItem.tsx — 助手消息项 + +**职责**:助手模式专用消息项,集成纠错弹框和独立响应模态框。 + +--- + +### ChatItemController.tsx — 助手消息控制器 + +**职责**:助手消息的操作按钮,提供纠错、标签和状态等助手专有功能。 + +--- + +## Hooks + +### hooks/useChatBox.tsx — 导出功能 + +**职责**:将当前对话内容导出为多种格式。 + +**支持格式** + +| 格式 | 实现方式 | +|------|----------| +| Markdown | 格式化角色和消息内容拼接 | +| HTML | DOM 克隆 + HTML 模板 | +| PDF | html2pdf 库转换 | + +--- + +### hooks/useFileUpload.tsx — 文件上传 + +**职责**:管理完整文件上传生命周期。 + +**上传流程** + +``` +选择/拖放文件 + ↓ +文件类型 & 大小验证 + ↓ +获取 S3 预签名 URL (API) + ↓ +并行上传到 S3(带进度回调) + ↓ +获取文件预览 URL + ↓ +updateFiles() 更新状态 +``` + +**限制优先级**:文件配置 > 团队套餐限制 > 系统全局配置 > 默认值 + +--- + +## 上下文依赖 + +| Context | 主要消费组件 | 关键字段 | +|---------|-------------|----------| +| `ChatBoxContext` | Provider 提供,全局消费 | 欢迎文本、变量、音频配置、isChatting | +| `ChatRecordContext` | index, ChatItem, ChatHistory | chatRecords, setChatRecords | +| `ChatItemContext` | ChatItem, Header | chatBoxData.app, userAvatar, setCiteModalData | +| `WorkflowRuntimeContext` | index, ChatInput | appId, chatId, outLinkUid, shareId | + +> 全部使用 `useContextSelector` 精细化订阅,避免无关更新触发重渲染。 + +--- + +## 组件树总览 + +``` +ChatBox (index.tsx) +├── ChatProvider (Provider.tsx) +│ └── WorkflowRuntimeContextProvider +│ +├── WelcomeBox # 欢迎文本 +├── VariableInputForm # 变量输入 +│ +├── 消息列表(循环 chatRecords) +│ ├── TimeBox # 时间分割线 +│ └── ChatItem # 每条消息 +│ ├── ChatAvatar # 头像 +│ ├── HumanContentCard # 用户消息气泡 +│ │ └── FilesBlock # 附件 +│ ├── AIContentCard # AI 消息气泡 +│ │ └── Markdown # Markdown 渲染 +│ ├── ChatController # 操作按钮 +│ └── ResponseTags # 引用/上下文标签 +│ ├── QuoteList # 引用详情(弹框) +│ └── ContextModal # 上下文预览(弹框) +│ +├── ChatInput # 输入框 +│ ├── InputGuideBox # 输入提示 +│ ├── VoiceInput # 语音输入 +│ └── FilesBox # 文件预览 +│ +└── 弹框(动态加载) + ├── FeedbackModal # 反馈弹框 + └── SelectMarkCollection # 管理员标记选择器 + +首页模式额外组件: +├── WelcomeHomeBox # 首页欢迎框 +├── ChatHomeVariablesForm # 首页变量表单 +└── QuickApps # 快速应用切换 + +助手模式替换组件: +└── ChatHistory # 替代常规消息列表 + └── assistant/ChatItem + └── assistant/ChatItemController +``` + +--- + +## 外部依赖汇总 + +### UI 库 +- `@chakra-ui/react` — 主要 UI 组件库 +- `@fastgpt/web/components` — 共享基础组件(Avatar、MyIcon、MyMenu 等) + +### 功能库 +- `react-hook-form` — 表单状态管理 +- `use-context-selector` — 精细化 Context 订阅 +- `ahooks` — useMemoizedFn、useCreation 等 +- `framer-motion` — 动画 +- `dayjs` — 时间格式化 +- `lodash` — 工具函数 + +### API 模块 +| 模块 | 用途 | +|------|------| +| `@/web/core/chat/api` | 聊天核心 API | +| `@/web/core/chat/feedback/api` | 反馈提交 API | +| `@/web/core/ai/api` | TTS / Whisper API | +| `@/web/core/dataset/api` | 知识库引用 API | +| `@/web/common/file/api` | 文件上传 API | + +### 样式 +- `index.module.scss` — 模块化 CSS,定义消息气泡、滚动区等核心样式 +- Chakra UI 主题 token — 颜色、间距、圆角等设计变量 + +--- + +## 性能优化措施 + +| 手段 | 应用位置 | +|------|----------| +| `next/dynamic` 动态导入 | FeedbackModal、SelectMarkCollection 等弹框 | +| `React.memo` | ChatItem、ChatController、ResponseTags | +| `useContextSelector` | 所有 Context 消费组件 | +| `useMemoizedFn` | 所有传入子组件的回调函数 | +| `useCreation` | 避免初始化重复创建 | diff --git a/.claude/design/refactor-ui/Chatltem-component-analysis.md b/.claude/design/refactor-ui/Chatltem-component-analysis.md new file mode 100644 index 000000000000..c150e916953a --- /dev/null +++ b/.claude/design/refactor-ui/Chatltem-component-analysis.md @@ -0,0 +1,154 @@ +# ChatItem 组件分析文档 + +> 文件路径:`projects/app/src/components/core/chat/ChatContainer/ChatBox/components/sfChatItem.tsx` + +--- + +## 一、组件职责 + +`ChatItem` 是聊天消息列表中单条消息的渲染容器,负责: + +- 区分 Human / AI 两种角色的布局样式 +- 渲染消息内容(文本、文件、AI 响应、交互节点) +- 显示消息操作控件(复制、点赞/踩、反馈等) +- 展示 Workflow 运行状态、引用标签、错误信息 +- 支持 AI 消息按 `interactive` 节点拆分为多个气泡卡片 + +--- + +## 二、Props 类型 + +```ts +type BasicProps = { + avatar?: string; // 头像地址(当前已注释未用) + statusBoxData?: { // Workflow 运行状态 + status: `${ChatStatusEnum}`; + name: string; + }; + questionGuides?: string[]; // 猜你想问引导问题列表 + children?: React.ReactNode; // 额外内容插槽(仅末尾卡片渲染) + hideCiteIcon?: boolean; // 是否隐藏引用图标 +} & ChatControllerProps; // 继承控制器所需 props + +type Props = BasicProps & { + type: ChatRoleEnum.Human | ChatRoleEnum.AI; +}; +``` + +--- + +## 三、布局结构 + +``` + // 根容器,hover 显示时间标签 + │ + ├── // 控制栏行 + │ ├── 时间标签 // PC 端 / 日志模式显示消息时间 + │ ├── // 操作按钮组 + │ └── Workflow 状态指示器 // 仅最后一条 AI 消息运行中时显示 + │ + ├── 用户反馈内容 // isChatLog + showFeedbackContent 时展示 + │ + └── splitAiResponseResults.map(...) // 内容卡片循环(interactive 拆分多个) + └── + └── // 消息气泡 + ├── // Human:文本 + 文件 + │ + ├── // AI:逐项渲染响应值 + │ └── (末尾) // 引用来源 / Token 用量标签 + │ + ├── (末尾卡片) 错误信息 // chat.errorMsg 存在时显示 + ├── (末尾卡片) {children} // 外部插槽 + │ + └── (AI 非交互) 底部复制按钮 // footer-copy,移动端常驻,PC hover 显示 +``` + +--- + +## 四、子组件说明 + +### 4.1 本地内部组件 + +| 组件名 | 类型 | 作用 | +|--------|------|------| +| `HumanContentCard` | `React.memo` | 渲染用户消息:文件列表(`FilesBlock`)+ 文本(`Markdown`) | +| `AIContentCard` | `React.memo` | 遍历 `chatValue` 数组,逐项调用 `AIResponseBox` 渲染各类响应 | +| `RenderQuestionGuide` | 纯函数 | 将引导问题数组转为 Markdown 代码块(`questionguide` 语法) | + +### 4.2 引用外部组件 + +| 组件名 | 来源 | 作用 | +|--------|------|------| +| `ChatController` | `./ChatController` | 消息操作按钮:复制、点赞/踩、管理员标注、重新生成等 | +| `AIResponseBox` | `../../../components/AIResponseBox` | 处理单个 AI 响应值(文本流/工具调用/交互节点等多态渲染) | +| `ResponseTags` | `./ResponseTags`(动态加载) | 展示引用来源、耗时、Token 数等响应标签 | +| `FilesBlock` | `./FilesBox` | 用户上传的图片/文件缩略图列表 | +| `Markdown` | `@/components/Markdown` | Markdown 文本渲染,支持代码高亮、问题引导等扩展语法 | +| `ChatBoxDivider` | `../../../Divider` | 带图标的分割线,用于标识错误信息区域 | +| `MyIcon` / `MyTooltip` | `@fastgpt/web` | 图标 + Tooltip 封装 | + +--- + +## 五、关键逻辑 + +### 5.1 Human / AI 样式差异 + +通过 `styleMap` 区分两种角色的气泡样式: + +| 属性 | Human | AI | +|------|-------|----| +| 对齐方向 | `flex-end`(靠右) | `flex-start`(靠左) | +| 气泡圆角 | `8px 0 8px 8px` | `0 8px 8px 8px` | +| 背景色 | `primary.100`(浅蓝) | `myGray.50`(浅灰) | +| 控制栏 order | `0`(文字左侧) | `1`(文字右侧) | + +### 5.2 AI 消息拆分逻辑(`splitAiResponseResults`) + +AI 消息的 `chat.value` 数组中若存在 `interactive` 类型节点,需拆分渲染为多个独立气泡: + +``` +原始 value 数组:[text, tool, interactive, text, interactive] + +拆分结果: + group[0] = [text, tool] → 普通气泡 + group[1] = [interactive] → 交互气泡(独立) + group[2] = [text] → 普通气泡 + group[3] = [interactive] → 交互气泡(独立) +``` + +**特殊处理**:若正在 `isChatting` 且最后一组为 `interactive`,自动追加一个空文本节点以触发打字动画。 + +### 5.3 Workflow 状态指示器 + +`chatStatusMap` 对应三种状态的颜色: + +| 状态 | 背景色 | 文字色 | +|------|--------|--------| +| `loading` | `myGray.100` | `myGray.600` | +| `running` | `green.50` | `green.700` | +| `finish` | `green.50` | `green.700` | + +状态指示器仅在以下条件全部满足时显示: +- `chatStatusMap` 存在 +- 是最后一条消息(`isLastChild`) +- Context 开启了 `showRunningStatus` + +### 5.4 引用弹窗(`onOpenCiteModal`) + +通过 `ChatItemContext` 的 `setCiteModalData` 触发引用详情弹窗,支持两种模式: + +- **精确引用**:传入 `collectionId` 时,结合 `isShowFullText` 展示完整全文上下文 +- **汇总引用**:不传 `collectionId` 时,展示该消息所有引用来源列表 + +--- + +## 六、Context 依赖 + +| Context | 字段 | 用途 | +|---------|------|------| +| `ChatBoxContext` | `isChatting` | 判断是否正在流式输出 | +| `ChatBoxContext` | `chatType` | 区分普通对话 / 日志模式 | +| `ChatItemContext` | `showRunningStatus` | 控制状态指示器显示 | +| `ChatItemContext` | `isShowFullText` | 控制引用弹窗是否显示全文 | +| `ChatItemContext` | `setCiteModalData` | 触发引用详情弹窗 | +| `WorkflowRuntimeContext` | `appId / chatId / outLinkAuthData` | 引用弹窗所需的鉴权数据 | diff --git a/.claude/design/refactor-ui/chat-page-layout.md b/.claude/design/refactor-ui/chat-page-layout.md new file mode 100644 index 000000000000..2e3983d61fa0 --- /dev/null +++ b/.claude/design/refactor-ui/chat-page-layout.md @@ -0,0 +1,239 @@ +# Chat 页面布局分析与样式重构指南 + +> 文件路径:`projects/app/src/pages/chat/index.tsx` +> 生成日期:2026-03-27 + +--- + +## 一、页面整体结构 + +### 组件层次树 + +``` +Render (默认导出) +└── ChatPageContextProvider # 顶层 Context:聊天页面全局状态 + └── ChatContent # 用户状态判断层 + ├── [未初始化] PageContainer(isLoading) # 加载态 + ├── [未登录] LoginModal # 登录态 + └── [已登录] ChatContextProvider # 历史记录 Context + └── ChatItemContextProvider # 聊天项 Context + └── ChatRecordContextProvider # 聊天记录 Context + └── Chat # 核心布局组件 +``` + +--- + +## 二、Chat 核心布局(PC / Mobile) + +``` +Chat +└── Flex [h=100%] # 根容器,水平布局 + ├── [仅PC] Box # 左侧边栏容器 + │ ├── w: collapse ? 72px : 220px # 折叠/展开宽度动画 + │ └── ChatSlider # 主导航侧边栏 + │ + ├── PageContainer [flex=1] # 主内容区(条件渲染) + │ ├── [HOME] HomeChatWindow + │ ├── [FAVORITE_APPS] ChatFavouriteApp + │ ├── [TEAM_APPS] ChatTeamApp + │ ├── [RECENTLY_USED_APPS] AppChatWindow + │ └── [SETTING] ChatSetting + │ + └── [datasetCiteData存在时] PageContainer [maxW=560px] + └── ChatQuoteList # 引用列表面板 +``` + +--- + +## 三、组件清单与职责说明 + +### 3.1 布局容器组件 + +| 组件 | 路径 | 职责 | +|------|------|------| +| `PageContainer` | `src/components/PageContainer/index.tsx` | 统一卡片容器,提供圆角/边框/阴影/背景,PC端有 `16px` padding | +| `SideBar` | `src/components/SideBar/index.tsx` | 历史记录侧边栏容器,支持折叠,悬停显示折叠按钮 | + +### 3.2 ChatSlider(主导航侧边栏) + +**文件**:`src/pageComponents/chat/slider/index.tsx` + +``` +ChatSlider [MotionFlex, h=100%, flexDir=column] +├── LogoSection # Logo 区域(展开显示宽图,折叠显示方图) +│ ├── 展开态:Image (135x33px) + 折叠按钮图标 +│ └── 折叠态:Image (33x33px) +│ +├── NavigationSection # 导航菜单区 +│ ├── 折叠态:仅图标 ActionButton(展开/HOME/收藏/团队应用) +│ └── 展开态:图标+文字 ActionButton(HOME/收藏/团队应用) +│ +├── [展开态] AnimatedSection # 最近使用应用列表区 +│ ├── MyDivider +│ ├── 标题行:"最近使用" +│ └── MyBox [滚动容器] +│ └── 应用列表项 (Avatar + name) +│ +└── BottomSection # 底部区域 + ├── 设置按钮(管理员+Plus版本) + └── UserAvatarPopover / 未登录占位 +``` + +**折叠动画**:使用 `framer-motion`,宽度由父级 `Box` 控制(`72px ↔ 220px`),内部通过 `AnimatePresence + MotionBox` 实现文字淡入淡出。 + +### 3.3 AppChatWindow(应用聊天窗口) + +**文件**:`src/pageComponents/chat/ChatWindow/AppChatWindow.tsx` + +``` +AppChatWindow [Flex, h=100%, flexDir=[column,row]] +├── NextHead # 标题/图标 +├── [PC] SideBar # 历史记录侧边栏 +│ └── ChatHistorySidebar +│ ├── ChatSliderHeader # 应用名称 + Avatar +│ ├── ChatSliderMenu # 新建对话按钮 + 清空按钮 +│ └── ChatSliderList # 历史会话列表(支持置顶/重命名/删除) +│ +├── [Mobile] ChatSliderMobileDrawer # 抽屉式历史记录 +│ +└── Flex [flex=1, flexDir=column] # 聊天主区 + ├── ChatHeader # 顶部标题栏 + └── Box [flex=1, bg=white] + ├── [isPlugin] CustomPluginRunBox + └── [normal] ChatBox # 核心聊天框 +``` + +### 3.4 HomeChatWindow(主页聊天窗口) + +**文件**:`src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` + +``` +HomeChatWindow [Flex, h=100%, flexDir=[column,row]] +├── NextHead +├── [PC] SideBar > ChatHistorySidebar +├── [Mobile] ChatSliderMobileDrawer +└── Flex [flex=1, flexDir=column] + ├── [PC] 对话标题行(纯文本,居中,borderBottom) + ├── [Mobile] ChatHeader + └── Box [flex=1, bg=white] + └── ChatBox + ├── slogan # 欢迎语 + ├── wideLogo # 宽 Logo + ├── quickAppList # 快捷应用列表 + └── InputLeftComponent # 模型选择 + 工具选择下拉框 +``` + +### 3.5 ChatHeader(聊天顶部栏) + +**文件**:`src/pageComponents/chat/ChatHeader.tsx` + +``` +ChatHeader [Flex, minH=[46px,60px], borderBottom, px=[3,5]] +├── [PC] PcHeader +│ ├── 对话标题(可复制 chatId) +│ ├── 历史条数标签 MyTag +│ └── 模型名称标签 MyTag(绿色) +│ +├── [Mobile] MobileHeader +│ ├── 菜单图标(打开历史侧边栏) +│ ├── 应用 Avatar + 应用名(点击展开下拉抽屉) +│ └── [isOpenDrawer] MobileDrawer # 最近使用/全部应用 +│ +└── 右侧工具组 + ├── VariablePopover # 变量设置入口 + └── ToolMenu # 对话工具菜单 +``` + +### 3.6 ChatHistorySidebar(历史记录侧边栏) + +**文件**:`src/pageComponents/chat/slider/ChatSliderSidebar.tsx` + +``` +ChatHistorySidebar [MyBox, flexDir=column, borderRight] +├── ChatSliderHeader # 应用名/Banner 图片 + 导航菜单(Mobile) +├── ChatSliderMenu # 新建对话按钮 + 清空按钮 +└── ChatSliderList # 历史会话滚动列表 +``` + +### 3.7 面板切换组件(Pane) + +由 `ChatPageContext.pane` 控制,枚举值: + +| Pane 枚举 | 对应组件 | 说明 | +|-----------|----------|------| +| `HOME` | `HomeChatWindow` | Plus 版主页(模型+工具自由组合) | +| `FAVORITE_APPS` | `ChatFavouriteApp` | 收藏的应用列表 | +| `TEAM_APPS` | `ChatTeamApp` | 团队应用列表 | +| `RECENTLY_USED_APPS` | `AppChatWindow` | 最近使用的应用聊天 | +| `SETTING` | `ChatSetting` | 管理员设置页 | + +--- + +## 四、Context 依赖关系 + +``` +ChatPageContextProvider → 全局 UI 状态(pane、collapse、chatSettings、myApps) + ChatContextProvider → 历史记录(histories、onChangeChatId 等) + ChatItemContextProvider → 聊天项状态(chatBoxData、datasetCiteData 等) + ChatRecordContextProvider → 聊天记录(chatRecords、totalRecordsCount) +``` + +--- + +## 五、响应式布局规则 + +| 区域 | Mobile | PC | +|------|--------|----| +| 主导航侧边栏 `ChatSlider` | 隐藏(通过 `isPc` 判断) | 显示,72px(折叠)/220px(展开) | +| 历史记录侧边栏 | `ChatSliderMobileDrawer`(抽屉) | `SideBar > ChatHistorySidebar`(固定250-290px) | +| 对话顶部栏 | `ChatHeader`(含菜单icon) | `ChatHeader`(展示标题+标签)或 仅标题行(Home) | +| `PageContainer` | 无 padding/圆角/边框 | py=16px, pr=16px,16px 圆角,1px 边框 | +| `AppChatWindow` flex方向 | column | row | + +--- + +## 六、样式重构策略(渐进式替换) + +### 重构优先级与组件分层 + +``` +层级 1(最外层) + └── Chat 根布局 [Flex] + └── 侧边栏容器 Box(宽度动画) + +层级 2(主导航) + └── ChatSlider 组件内部 4 个子 section + ├── LogoSection + ├── NavigationSection + ├── 最近使用列表区 + └── BottomSection + +层级 3(内容面板) + ├── AppChatWindow / HomeChatWindow + │ ├── SideBar 容器 + │ ├── ChatHistorySidebar (Header/Menu/List) + │ └── 聊天主区(ChatHeader + ChatBox 外层) + └── ChatHeader (PcHeader / MobileHeader) + +层级 4(原子组件,通常不需要重构) + ├── ChatBox(内部复杂,独立重构) + ├── ChatQuoteList + └── PageContainer +``` + +### 建议重构顺序 + +1. **Layer 1**:`Chat` 根 `Flex` 容器 + 侧边栏宽度动画容器 +2. **Layer 2**:`ChatSlider` 的 `LogoSection` → `NavigationSection` → 列表区 → `BottomSection` +3. **Layer 3a**:`ChatHistorySidebar` 三件套(Header → Menu → List) +4. **Layer 3b**:`AppChatWindow` / `HomeChatWindow` 外层布局 +5. **Layer 3c**:`ChatHeader`(PC / Mobile 分支) +6. **Layer 4**(可选):`PageContainer`、`SideBar` 折叠容器 + +### 关键样式注意点 + +- **侧边栏宽度切换**:目前通过父级 `Box` CSS transition 控制,`ChatSlider` 内部使用 `framer-motion`,重构时需保持两者协调。 +- **`PageContainer` 嵌套**:外层 `MyBox` 负责 padding,内层 `MyBox` 负责卡片样式,重构时注意不要扁平化导致样式丢失。 +- **`SideBar` 折叠按钮**:通过 `_hover` 显示折叠 toggle,重构时需保留此交互。 +- **响应式断点**:Chakra UI 数组语法 `[mobile, pc]`,对应 `base` 和 `md` 断点,统一使用该模式。 +- **颜色 token**:全局使用 `myGray.*`、`primary.*` 等语义 token,不要使用硬编码颜色值。 diff --git a/.claude/design/rerank-training-architecture.md b/.claude/design/rerank-training-architecture.md new file mode 100644 index 000000000000..2dc3547c9965 --- /dev/null +++ b/.claude/design/rerank-training-architecture.md @@ -0,0 +1,382 @@ +# Rerank 训练平台架构文档 + +> 本文档梳理 FastGPT Rerank 训练任务的执行流程、数据模型关系和关键设计决策。 + +## 1. 系统总览 + +Rerank 训练平台通过 **6 阶段流水线**,实现从数据准备到模型微调、评测的全流程自动化。支持两种模式: + +- **精确模式(Exact Mode)**:用户提供训练集 + 评测集,系统跳过生成阶段 +- **自动模式(Auto Mode)**:用户提供知识库 IDs,系统自动生成训练集和评测集 + +**注:** 训练任务仅负责生成微调模型。模型应用(更新 App 中的 rerank 模型引用)由用户通过独立工作流手动触发。 + +## 2. 数据模型关系 + +### 2.1 ER 关系图 + +```mermaid +erDiagram + Team ||--o{ RerankTrainTask : owns + Team ||--o{ RerankTrainset : owns + Team ||--o{ App : owns + + RerankTrainTask }o--o| RerankTrainset : "trainsetId (精确模式传入/自动模式回写)" + RerankTrainTask }o--o| EvalDatasetCollection : "evalDatasetId" + RerankTrainTask }o--|| SystemModel : "baseModelId" + RerankTrainTask }o--o| SystemModel : "checkpoint.data.registering.tunedModelId" + + RerankTrainset ||--o{ RerankTrainsetData : "trainsetId" + + EvalDatasetCollection ||--o{ EvalDatasetData : "evalDatasetCollectionId" + + App ||--o{ DatasetSearchNode : "modules[]" + DatasetSearchNode }o--o| SystemModel : "datasetSearchRerankModel" + + SystemModel ||--o| AIProxyChannel : "model (通道路由)" + + RerankTrainsetData }o--o| Dataset : "metadata.sourceInfo.datasetInfo.datasetId" +``` + +### 2.2 核心模型字段 + +#### RerankTrainTask(训练任务) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | ObjectId | 任务 ID | +| `teamId` / `tmbId` | ObjectId | 权限归属 | +| `baseModelId` | String | 基础模型 ID → SystemModel.model | +| `baseModelEndpoint` | Object | 基础模型端点 {base_url, model, api_key} | +| `trainsetId` | ObjectId? | → RerankTrainset._id | +| `evalDatasetId` | String? | → EvalDatasetCollection._id | +| `datasetIds` | String[]? | 知识库 IDs(仅自动模式) | +| `trainType` | String | `'lora'` \| `'ptuning'`,默认 lora | +| `status` | Enum | pending / running / completed / failed / cancelled | +| `checkpoint` | Object | 阶段进度(见 §3.2) | +| `result` | Object? | 最终结果汇总 | +| `jobId` | String? | BullMQ 任务 ID | + +#### RerankTrainset(训练集) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | ObjectId | 训练集 ID | +| `teamId` / `tmbId` | ObjectId | 权限归属 | +| `name` | String | 名称 | +| `status` | Enum | pending / generating / ready / error | +| `statistics` | Virtual | 动态计算(dataCount, positiveCount, negativeCount, sourceSummary) | + +#### RerankTrainsetData(训练数据) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `trainsetId` | ObjectId | → RerankTrainset._id | +| `query` | String | 查询文本 | +| `positiveDocs` | String[] | 正例文档 | +| `negativeDocs` | String[] | 负例文档 | +| `source` | Enum | dataset / chat_log / manual | +| `metadata.sourceInfo` | Object | 来源详情(datasetInfo / chatLogInfo / manualInfo) | + +#### SystemModel(模型配置) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `model` | String | 模型标识(唯一键) | +| `metadata.isActive` | Boolean | 是否启用 | +| `metadata.isTuned` | Boolean | 是否为训练模块创建的微调模型 | +| `metadata.type` | Enum | `'rerank'` | +| `metadata.provider` | String | 模型提供商 | + +#### App DatasetSearchNode(应用中的 Rerank 引用) + +| 字段 | 说明 | +|------|------| +| `inputs[datasetSearchUsingReRank]` | 是否启用 rerank | +| `inputs[datasetSearchRerankModel]` | 引用的 SystemModel.model | +| `inputs[datasetSearchRerankMethod]` | rerank 方法 | + +## 3. 训练任务执行流程 + +### 3.1 六阶段流水线 + +```mermaid +flowchart TD + API["API: POST /api/core/train/rerank/task/create"] --> Q["BullMQ 入队"] + Q --> P["Processor 取出任务
status: pending → running"] + + P --> S1["Stage 1: generate_trainset
准备训练数据 JSONL"] + S1 --> S2["Stage 2: generate_evaldataset
生成评测数据集"] + S2 --> S3["Stage 3: eval_basemodel
评测基础模型"] + S3 --> S4["Stage 4: finetuning
SFT Bridge 微调"] + S4 --> S5["Stage 5: registering
注册模型 + 创建 Channel"] + S5 --> S6["Stage 6: eval_tunedmodel
评测微调模型"] + + S6 --> Done["status → completed"] + + style S1 fill:#e1f5fe + style S2 fill:#e1f5fe + style S3 fill:#fff3e0 + style S4 fill:#fce4ec + style S5 fill:#f3e5f5 + style S6 fill:#fff3e0 +``` + +### 3.2 检查点(Checkpoint)结构 + +每个 stage 完成后保存检查点,支持断点续传。任务重启时自动从上次完成的 stage 之后继续执行。 + +```mermaid +flowchart LR + subgraph checkpoint.data + GT["generate_trainset
trainDatasetFilePath
autoGenerated"] + GE["generate_evaldataset
evalDatasetId
autoGenerated"] + EB["eval_basemodel
baseModelEvalResult"] + FT["finetuning
sftTaskId
tunedModelEndpoint"] + RG["registering
tunedModelId"] + ET["eval_tunedmodel
tunedModelEvalResult"] + end + + GT --> GE --> EB --> FT --> RG --> ET +``` + +### 3.3 各阶段详情 + +| 阶段 | 文件 | 外部服务 | 耗时 | 产出 | +|------|------|---------|------|------| +| 1. generate_trainset | `stages/generate-trainset.ts` | — | ~10 min | JSONL 训练文件 | +| 2. generate_evaldataset | `stages/generate-evaldataset.ts` | DiTing (QA 生成 + 数据集检索) | ~30 min | EvalDatasetCollection + Data(含 retrievalContextsFull) | +| 3. eval_basemodel | `stages/eval-basemodel.ts` | DiTing (`/evaluations/rerank`) | ~5-10 min | RerankEvalResult(MRR、Precision) | +| 4. finetuning | `stages/finetune.ts` | SFT Bridge (微调+部署) | 1-10 h | tunedModelEndpoint | +| 5. registering | `stages/register.ts` | AI Proxy (通道创建) | ~30 s | tunedModelId (SystemModel) | +| 6. eval_tunedmodel | `stages/eval-tunedmodel.ts` | DiTing (`/evaluations/rerank`) | ~5-10 min | RerankEvalResult | + +#### Stage 1:generate_trainset(两种模式) + +| 模式 | 流程 | +|------|------| +| **Exact Mode** | 等待已有 trainset 状态 → `ready`(轮询,60 min 超时),验证数据不为空后导出 JSONL | +| **Auto Mode** | ① 创建新 trainset → ② 入队 data generate job → ③ 等待 trainset 状态 → `ready` → ④ 导出 JSONL | + +等待轮询已覆盖 `error` 状态(立即抛出 `TrainTaskUnrecoverableError`,不等待超时)。 + +#### Stage 2:generate_evaldataset(与 Embedding 的关键差异) + +Rerank 需调用 `performDatasetSearch` 获取 `retrievalContextsFull`(排序候选列表),用于评测排序效果。 +Embedding 无此步骤,直接存储 `expectedContextIds`。 + +### 3.4 状态机 + +```mermaid +stateDiagram-v2 + [*] --> pending: 创建任务 + + pending --> running: Processor 取出 + pending --> cancelled: 用户取消 + + running --> completed: 6 阶段全部完成 + running --> failed: 不可恢复错误 + running --> cancelled: 用户取消 (Stage 4 检测) + + failed --> running: 重试 (retry API) + + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +## 4. 外部服务集成 + +```mermaid +flowchart LR + subgraph FastGPT + Proc["Task Processor"] + Model["SystemModel
(模型配置)"] + Proxy["AI Proxy
(通道路由)"] + end + + subgraph External["外部服务"] + DiTing["DiTing
数据合成 + 模型评测"] + SFT["SFT Bridge
模型微调 + 部署"] + end + + Proc -->|"Stage 2: 生成 QA + 检索"| DiTing + Proc -->|"Stage 3,6: 评测模型"| DiTing + Proc -->|"Stage 4: 创建微调任务"| SFT + SFT -->|"返回推理端点"| Proc + Proc -->|"Stage 5: 注册模型"| Model + Proc -->|"Stage 5: 创建通道"| Proxy + Proxy -->|"推理请求路由"| SFT +``` + +| 服务 | 用途 | 环境变量 | +|------|------|---------| +| **DiTing** | QA 对生成、训练数据合成、模型排序质量评测 | `DITING_BASE_URL` | +| **SFT Bridge** | LoRA/P-Tuning 微调、模型部署、推理服务 | `SFT_BRIDGE_BASE_URL` | +| **AI Proxy** | 模型通道管理、请求路由 | `AIPROXY_API_ENDPOINT` | + +**SFT Bridge 客户端**统一在 `packages/service/core/train/common/external/sftbridge/` 中维护,rerank 和 embedding 均从此处导入(不再各自维护副本)。 + +## 5. 资源管理 + +### 5.1 创建任务的前置校验 + +- `baseModelId` 不存在 → `taskModelNotFound` +- `baseModelId` 对应模型已被 disable → `taskBaseModelDisabled`(防止在失败模型上继续训练) +- 同一 team + baseModelId 已有 pending/running 任务 → `taskAlreadyRunning` + +### 5.2 资源清理(deleteRerankTrainTask) + +``` +1. 模型配置 + AI Proxy Channel (registering 阶段产物) +2. SFT Bridge 资源 (async, non-blocking) +3. 自动生成的 EvalDataset Collections + Data +4. 自动生成的 Trainset + Data +5. Task 记录 +6. 临时 JSONL 文件 (async, non-blocking) +``` + +## 6. 错误处理与重试 + +### 6.1 错误分类 + +| 类型 | 处理 | 示例 | +|------|------|------| +| `TrainTaskRetriableError` | 自动重试(最多 3 次,指数退避 5s) | 网络超时、服务暂时不可用 | +| `TrainTaskUnrecoverableError` | 任务失败,不重试 | 数据丢失、模型不存在、用户取消、trainset error 状态 | + +错误类定义在 `packages/service/core/train/common/errors.ts`,两个模块共享。 + +### 6.2 Trainset 生成的错误感知 + +`waitForTrainsetReady()` 在轮询中会检测 trainset 的三种终态: +- `ready`:继续导出 JSONL +- `error`:立即抛出 `TrainTaskUnrecoverableError`(携带 trainset 的 errorMsg) +- 超时(360 次 × 10s = 60 min):抛出 `TrainTaskRetriableError` + +## 7. API 接口总览 + +### 训练集管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/rerank/trainset/create` | POST | 创建训练集 | +| `/api/core/train/rerank/trainset/list` | POST | 列表查询(分页、排序、状态筛选) | +| `/api/core/train/rerank/trainset/detail` | GET | 获取详情(含统计) | +| `/api/core/train/rerank/trainset/delete` | DELETE | 删除训练集(trainsetId 在 query) | + +### 训练数据管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/rerank/trainset/data/create` | POST | 手动添加数据 | +| `/api/core/train/rerank/trainset/data/list` | POST | 数据列表(支持 source 过滤) | +| `/api/core/train/rerank/trainset/data/update` | POST | 更新数据 | +| `/api/core/train/rerank/trainset/data/delete` | POST | 删除数据(dataIds 在 body) | +| `/api/core/train/rerank/trainset/data/generate` | POST | 从知识库异步生成(返回 jobId) | + +### 训练任务管理 + +| 路径 | 方法 | 功能 | +|------|------|------| +| `/api/core/train/rerank/task/create` | POST | 创建训练任务(支持自动/精确两种模式) | +| `/api/core/train/rerank/task/list` | POST | 任务列表(支持 baseModelId/status 过滤) | +| `/api/core/train/rerank/task/detail` | GET | 任务详情(taskId 在 query) | +| `/api/core/train/rerank/task/retry` | POST | 重试失败任务(仅 failed 状态) | +| `/api/core/train/rerank/task/cancel` | POST | 取消任务 | +| `/api/core/train/rerank/task/delete` | DELETE | 删除任务(仅 completed/failed/cancelled,taskId 在 query) | +| `/api/core/train/rerank/task/eval-dataset` | POST | 下载评测数据集 (JSONL) | +| `/api/core/train/rerank/task/eval-report` | POST | 下载评测报告 (XLSX) | + +## 8. 文件结构 + +``` +packages/service/core/train/rerank/ +├── constants.ts # 服务层常量 +├── utils.ts # 工具函数(createRerankEnhancedError 等) +├── index.ts # 模块入口 +├── external/ # 外部服务集成(DiTing 专用) +│ ├── index.ts # 统一入口(Mock/Real 切换) +│ └── diting/ +│ ├── client.ts # DiTing API 客户端 +│ ├── mock.ts # Mock 实现 +│ └── types.ts # 类型定义 +│ # ⚠️ SFT Bridge 客户端已统一迁移到 common/external/sftbridge/ +│ # 通过 external/index.ts 重导出,无重复代码 +├── validation/ # 环境和数据集校验 +│ ├── index.ts +│ ├── environment.ts # SFT Bridge + DiTing 可用性检查 +│ └── dataset.ts # 知识库合成索引校验 +├── task/ +│ ├── controller.ts # 任务 CRUD + 前置校验 +│ ├── processor.ts # BullMQ Processor(6 阶段编排) +│ ├── worker.ts # Worker 初始化 +│ ├── mq.ts # 队列配置(attempts:3, 指数退避) +│ ├── schema.ts # MongoDB Schema +│ ├── utils.ts # 任务工具函数 +│ ├── helpers/ +│ │ ├── channel.ts # AI Proxy 通道管理 +│ │ ├── evaluate-model.ts # 模型评测共享逻辑 +│ │ ├── model.ts # isTunedModel 等模型辅助 +│ │ └── dataset-search.ts # 数据集检索(Stage 2 专用) +│ └── stages/ +│ ├── generate-trainset.ts # Stage 1(含 waitForTrainsetReady) +│ ├── generate-evaldataset.ts # Stage 2(含 performDatasetSearch) +│ ├── eval-basemodel.ts # Stage 3 +│ ├── finetune.ts # Stage 4 +│ ├── register.ts # Stage 5 +│ └── eval-tunedmodel.ts # Stage 6 +├── trainset/ +│ ├── controller.ts # 训练集 CRUD +│ └── schema.ts +├── data/ +│ ├── controller.ts # 训练数据 CRUD + calculateRerankTrainsetStats +│ ├── processor.ts # 数据生成处理器(DiTing 数据合成) +│ ├── mq.ts +│ ├── worker.ts +│ └── schema.ts +└── model/ + └── controller.ts # 模型配置管理 + +packages/service/core/train/common/ +├── errors.ts # 共享错误类(TrainTaskRetriableError/UnrecoverableError) +├── constants.ts # 共享常量 +├── utils.ts # 共享工具函数 +└── external/ + └── sftbridge/ # SFT Bridge 客户端(rerank + embedding 共用) + ├── client.ts + ├── mock.ts + ├── types.ts + └── index.ts + +packages/global/core/train/rerank/ +├── constants.ts # 枚举(状态、阶段、来源) +├── type.d.ts # Schema 类型 +├── api.d.ts # API 请求/响应类型 +└── error.ts # EnhancedErrorMessage 类型 + +packages/global/common/error/code/ +└── train.ts # RerankTrainErrEnum + EmbeddingTrainErrEnum(各 95 个) + +projects/app/src/pages/api/core/train/rerank/ +├── task/ +│ ├── create.ts +│ ├── list.ts +│ ├── detail.ts +│ ├── retry.ts +│ ├── cancel.ts +│ ├── delete.ts +│ ├── eval-dataset.ts +│ └── eval-report.ts +└── trainset/ + ├── create.ts + ├── list.ts + ├── detail.ts + ├── delete.ts + └── data/ + ├── create.ts + ├── list.ts + ├── update.ts + ├── delete.ts + └── generate.ts +``` diff --git a/.claude/design/sidebar-navigation.md b/.claude/design/sidebar-navigation.md new file mode 100644 index 000000000000..4126971b0e75 --- /dev/null +++ b/.claude/design/sidebar-navigation.md @@ -0,0 +1,263 @@ +# 侧边导航栏设计文档 + +> 最后更新:2026-05-12 + +## 概述 + +FastGPT 使用统一的侧边导航栏组件 `DashboardNavbar`,适用于 Dashboard 页面和应用详情页(二级页面)。导航栏支持展开 / 折叠两种状态,仅在 PC 端渲染。 + +--- + +## 相关文件 + +| 文件 | 说明 | +|------|------| +| `projects/app/src/pageComponents/dashboard/Container.tsx` | 导航栏核心组件,含所有子组件定义、`DashboardContainer` 和导航数据 | +| `projects/app/src/pages/app/detail/index.tsx` | 应用详情页,直接使用 `DashboardNavbar`,固定折叠 | +| `projects/app/src/pages/skill/detail.tsx` | Skill 详情页,直接使用 `DashboardNavbar`,固定折叠 | +| `projects/app/src/pages/dataset/list/index.tsx` | 知识库列表页,直接使用 `DashboardNavbar`,默认展开 | +| `projects/app/src/pages/dataset/detail/index.tsx` | 知识库详情页,直接使用 `DashboardNavbar`,固定折叠 | +| `projects/app/src/pageComponents/account/AccountContainer.tsx` | 账户设置容器,直接使用 `DashboardNavbar`,默认展开 | +| `projects/app/src/pages/config/tool/index.tsx` | 系统工具配置页,直接使用 `DashboardNavbar`,默认展开 | +| `projects/app/src/pages/config/tool/marketplace.tsx` | 工具市场配置页,直接使用 `DashboardNavbar`,默认展开 | + +--- + +## 页面引用树 + +``` +DashboardNavbar(定义于 pageComponents/dashboard/Container.tsx) +│ +├── [间接] 通过 DashboardContainer 包装(默认展开,可折叠) +│ ├── pages/dashboard/agent/index.tsx — 应用列表 +│ ├── pages/dashboard/tool/index.tsx — 工具列表 +│ ├── pages/dashboard/systemTool/index.tsx — 系统工具 +│ ├── pages/dashboard/mcpServer/index.tsx — MCP 服务 +│ ├── pages/dashboard/templateMarket/index.tsx — 模板市场 +│ └── pages/dashboard/evaluation/ — 应用测评 +│ ├── index.tsx +│ ├── dimension/edit.tsx +│ ├── dimension/create.tsx +│ └── dataset/fileImport.tsx +│ +├── [直接] 一级页面(默认展开,可折叠) +│ ├── pages/dataset/list/index.tsx — 知识库列表 +│ ├── pageComponents/account/AccountContainer.tsx — 账户设置 +│ ├── pages/config/tool/index.tsx — 系统工具配置 +│ └── pages/config/tool/marketplace.tsx — 工具市场配置 +│ +└── [直接] 二级页面(固定折叠 + hideCollapseButton,不可展开) + ├── pages/app/detail/index.tsx — 应用详情 + ├── pages/skill/detail.tsx — Skill 详情 + └── pages/dataset/detail/index.tsx — 知识库详情 +``` + +--- + +## 尺寸常量 + +```ts +// Container.tsx +export const SIDEBAR_EXPANDED_WIDTH = '224px'; // 展开宽度 +export const SIDEBAR_COLLAPSED_WIDTH = '72px'; // 折叠宽度 +``` + +--- + +## 组件结构 + +``` +DashboardNavbar +├── 顶部 Logo 区域 +├── 导航菜单区域(VStack) +│ ├── NavItem —— 叶子节点(门户、模板市场、知识库、应用测评) +│ ├── SubNavGroup —— 可展开分组(应用构建) +│ │ └── NavItem(indent) —— 二级子项 +│ └── SubNavSettings —— 可展开设置分组(设置) +│ └── SettingsItem —— 二级子项(含退出登录) +└── 底部区域 + ├── TeamPlanStatusCard(仅展开时显示) + └── 折叠切换按钮(hideCollapseButton=true 时隐藏) +``` + +--- + +## 子组件说明 + +### NavItem + +叶子导航节点,同时支持一级和二级(indent)两种形态。 + +**Props** + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `icon` | `string` | — | 图标名,indent 子项传空字符串 | +| `label` | `string` | — | 展开时显示的文本 | +| `collapsedLabel` | `string?` | — | 折叠时显示的文本(可选,默认用 label) | +| `isActive` | `boolean` | — | 当前是否选中 | +| `isCollapsed` | `boolean` | — | 侧边栏是否折叠 | +| `onClick` | `() => void` | — | 点击回调 | +| `indent` | `boolean` | `false` | 是否为二级缩进子项 | + +**布局规则** + +| 状态 | 高度 | 内边距 | 内容排列 | +|------|------|--------|----------| +| 折叠(主项) | auto | py=7px, px=12px | 列方向(icon 上,文字下) | +| 展开(主项) | 40px | px=20px | 行方向 | +| 二级子项(展开) | 36px | px=44px | 行方向,无 icon | +| 二级子项(折叠中) | 36px | px=20px | 行方向,无 icon | + +--- + +### SubNavGroup + +可展开的导航分组,用于**应用构建**。折叠时点击直接跳转子项第一个路由;展开时点击切换子列表显隐。 + +**Props** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `items` | `{ key, label, path }[]` | 子项列表 | +| `isExpanded` | `boolean` | 子列表是否展开 | +| `onToggle` | `() => void` | 展开/折叠切换 | +| `onItemClick` | `(path: string) => void` | 子项点击回调 | +| `currentPath` | `string` | 当前路由 pathname | + +**当前子项(appBuildItems)** + +```ts +[ + { key: 'agent', label: '应用', path: '/dashboard/agent' }, + { key: 'tool', label: '工具', path: '/dashboard/tool' }, + { key: 'mcp', label: 'MCP 服务', path: '/dashboard/mcpServer' } +] +// ⚠️ Skill 已隐藏,不在列表中 +``` + +--- + +### SubNavSettings + +可展开的设置分组,子项支持 `isLogout` 标记退出登录。折叠时点击跳转第一个有 path 的子项;展开状态下点击子项执行路由跳转或登出。 + +**SettingsItem 类型** + +```ts +type SettingsItem = { + key: string; + label: string; + path?: string; // 普通路由跳转 + isLogout?: boolean; // 退出登录 +}; +``` + +--- + +## 样式规范 + +### 颜色 + +| 用途 | 激活色 | 默认色 | +|------|--------|--------| +| 图标 | `#156AD9` | `#505F73` | +| 一级文本 | `#156AD9` | `#2D3540` | +| 二级文本(SubNavGroup) | `#156AD9` | `#3E4A59` | +| 二级文本(SubNavSettings) | `#156AD9` | `#3E4A59` | +| 背景(激活/悬停) | `rgba(50, 136, 250, 0.1)` | `transparent` | + +### 字重(fontWeight) + +| 场景 | 选中 | 未选中 | +|------|------|--------| +| 一级导航文本 | `600` | `500` | +| 二级导航文本 | `600` | `500` | + +> 规则:`isActive ? 600 : 500`,一/二级均适用。 + +### 字号(fontSize) + +| 场景 | 值 | +|------|-----| +| 一级导航(展开) | `14px` | +| 一级导航(折叠) | `12px` | +| 二级导航子项 | `13px` | + +--- + +## 折叠/展开逻辑 + +``` +isCollapsed = true → 宽度 72px,仅显示 icon + 短文字(列方向) +isCollapsed = false → 宽度 224px,显示完整导航列表 +``` + +- **展开状态**默认值:Dashboard 页面默认 `false`(展开),应用详情页默认 `true`(折叠) +- **切换方式**:点击底部 `navbar/bottomIcon` 按钮 +- **动画**:`transition: width 0.2s ease` / `padding-left 0.2s ease` +- **分组展开状态(expandedKeys)初始化规则**: + - 使用模块级变量 `savedExpandedKeys` 在 SPA 会话内跨页面导航记忆展开状态 + - **首次加载**(`savedExpandedKeys === null`): + - `app-build` 分组:仅当当前路由匹配 `/dashboard/agent`、`/dashboard/skill`、`/dashboard/tool`、`/dashboard/systemTool`、`/dashboard/mcpServer` 其中之一时默认展开 + - `settings` 分组:当当前路由以 `/account` 或 `/config` 开头时默认展开 + - 其他路由均不默认展开任何分组 + - **页面间导航**:直接复用 `savedExpandedKeys` 记忆值,已展开的分组保持展开不自动收起 + - **用户手动切换**:`toggleExpand` 同步更新 `savedExpandedKeys`,确保偏好被记住 + +--- + +## 页面使用方式 + +### Dashboard 页面 + +```tsx +// DashboardContainer(Container.tsx) +const [isCollapsed, setIsCollapsed] = useState(false); // 默认展开 + +{isPc && } + + {children} + +``` + +### 应用详情页(二级页面) + +```tsx +// Provider(pages/app/detail/index.tsx) +const [isCollapsed] = useState(true); // 固定折叠,不可切换 +const sidebarWidth = SIDEBAR_COLLAPSED_WIDTH; + +{isPc && {}} hideCollapseButton />} + + + +``` + +> 二级页面传入 `hideCollapseButton` 隐藏底部折叠按钮,侧边栏固定为折叠态。 + +--- + +## 路由激活判断 + +| 导航项 | 激活条件 | +|--------|----------| +| 门户 | `pathname.startsWith('/chat')` | +| 模板市场 | `pathname.startsWith('/dashboard/templateMarket')` | +| 应用构建(折叠态) | `pathname` 匹配 appBuildItems 任意 path、`/app/detail` 或 `/skill/detail` | +| 知识库 | `pathname.startsWith('/dataset')` | +| 应用测评 | `pathname.startsWith('/dashboard/evaluation')` | +| 设置(折叠态) | `settingsItems` 中任意 item.path 与 pathname 匹配 | + +--- + +## 权限控制 + +| 导航项 | 权限条件 | +|--------|----------| +| 应用测评 | `userInfo?.team?.permission.hasEvaluationCreatePer` | +| 设置 > 团队 / 用量 / 通知 | `feConfigs?.isPlus` | +| 设置 > 账单 | `feConfigs?.show_pay && permission.hasManagePer` | +| 设置 > API Key | `permission.hasApikeyCreatePer` | +| 设置 > 推广 | `feConfigs?.show_promotion && permission.isOwner` | +| 设置 > 系统配置(root) | `userInfo?.username === 'root'` | diff --git a/.claude/settings.json b/.claude/settings.json index c62a26741db9..e406942128e7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,18 @@ { "enabledPlugins": { "skill-creator@claude-plugins-official": true + }, + "permissions": { + "defaultMode": "bypassPermissions", + "allow": [ + "mcp__codegraph__codegraph_explore", + "mcp__codegraph__codegraph_search", + "mcp__codegraph__codegraph_node", + "mcp__codegraph__codegraph_callers", + "mcp__codegraph__codegraph_callees", + "mcp__codegraph__codegraph_impact", + "mcp__codegraph__codegraph_files", + "mcp__codegraph__codegraph_status" + ] } } diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..0783c4be05bd --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "WebFetch", + "MCP", + "Agent(*)", + "mcp__ide__getDiagnostics", + "Skill(*)" + ] + } +} diff --git a/.claude/skills/add-permission/reference/README.md b/.claude/skills/add-permission/reference/README.md index 048859ee9f23..cd5bcd9dfc3d 100644 --- a/.claude/skills/add-permission/reference/README.md +++ b/.claude/skills/add-permission/reference/README.md @@ -6,12 +6,13 @@ ``` 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-class.md ── Permission 类设计与使用 +├── auth-function.md ── 鉴权函数实现模式 +├── inheritance.md ── 继承机制详解 +├── dataset-collection.md ── Dataset Collection 权限管理 +├── pro-collaborator.md ── fastgpt-pro 协作者管理 +└── pro-owner-transfer.md ── Owner 转移机制 ``` ## 按场景查阅 @@ -22,6 +23,7 @@ reference/ | 如何扩展 Permission 类 | [Permission 类设计](./permission-class.md) | | 鉴权函数的标准实现 | [鉴权函数实现](./auth-function.md) | | 父子资源权限如何合并 | [继承机制](./inheritance.md) | +| Dataset Collection 的独立权限管理 | [Collection 权限](./dataset-collection.md) | | 协作者更新如何处理冲突 | [协作者管理](./pro-collaborator.md) | | Owner 转移的完整流程 | [Owner 转移](./pro-owner-transfer.md) | diff --git a/.claude/skills/add-permission/reference/core-concepts.md b/.claude/skills/add-permission/reference/core-concepts.md index d504cf10bccf..e9b3be59b589 100644 --- a/.claude/skills/add-permission/reference/core-concepts.md +++ b/.claude/skills/add-permission/reference/core-concepts.md @@ -39,7 +39,33 @@ const withWrite = permission | WritePermissionVal; --- -## 2. 角色值 (Role Value) - 权限映射 +## 2. 自定义权限规则 + +`CommonPerList` 定义了基础权限位,其中低三位具有固定语义: + +| 位 | 权限 | 值 | +|----|------|-----| +| 第 1 位 | manage | 0b001 | +| 第 2 位 | write | 0b010 | +| 第 3 位 | read | 0b100 | + +**规则:自定义权限只能增加高位的位值,不得修改低三位。** + +原因:低三位是 `CommonRolePerMap` 角色继承体系的基础,修改会导致角色映射错乱。 + +### 正确示例 + +```typescript +// packages/global/support/permission/app/constant.ts +export const AppPerList = { + ...CommonPerList, + readChatLog: 0b1000 // 第 4 位,正确:使用高位 +}; +``` + +--- + +## 3. 角色值 (Role Value) - 权限映射 **关键区分**:数据库中 `permission` 字段存储的是**角色值**,不是展开后的权限值。 @@ -64,7 +90,7 @@ read (0b100) --- -## 3. 协作者类型 +## 4. 协作者类型 权限可以分配给三种实体(三选一): @@ -93,7 +119,7 @@ tmbId (个人权限) --- -## 4. ResourcePermission Schema +## 5. ResourcePermission Schema ```typescript // packages/service/support/permission/schema.ts @@ -122,7 +148,7 @@ const ResourcePermissionSchema = new Schema({ --- -## 5. 资源 Schema 权限相关字段 +## 6. 资源 Schema 权限相关字段 ```typescript const ResourceSchema = new Schema({ diff --git a/.claude/skills/add-permission/reference/dataset-collection.md b/.claude/skills/add-permission/reference/dataset-collection.md new file mode 100644 index 000000000000..81a21ba16b13 --- /dev/null +++ b/.claude/skills/add-permission/reference/dataset-collection.md @@ -0,0 +1,461 @@ +# Dataset Collection 权限管理 + +> Collection(文件集合)是 Dataset 下的独立资源,支持独立的协作者权限管理和继承机制。 + +--- + +## 1. Collection 权限模型概述 + +### 1.1 资源类型 + +```typescript +// packages/global/support/permission/constant.ts +export enum PerResourceTypeEnum { + team = 'team', + app = 'app', + dataset = 'dataset', + collection = 'collection', // 新增 + // ... +} +``` + +Collection 作为独立资源类型,拥有独立的 `resource_permissions` 记录,不再直接复用 dataset 的权限。 + +### 1.2 权限层级结构 + +``` +Dataset (dataset) + │ + ├─ Folder Collection (collection) + │ │ + │ ├─ File Collection (collection) + │ │ └─ Child Collection (collection) + │ └─ File Collection (collection) + │ + └─ File Collection (collection) +``` + +- **Dataset**:顶层资源,管理根级 folder collection +- **Folder Collection**:可包含子 collection,独立管理权限 +- **File Collection**:叶子节点,可继承或独立管理权限 + +--- + +## 2. Collection 鉴权流程 + +### 2.1 入口函数 + +```typescript +// packages/service/support/permission/dataset/auth.ts +export async function authDatasetCollection({ + collectionId, + per, + ...props +}) { + // 1. 解析认证信息 + const { userId, teamId, tmbId, isRoot } = await parseHeaderCert(props); + + // 2. 查询 collection + const collection = await MongoDatasetCollection.findById(collectionId).lean(); + if (!collection) { + return Promise.reject(DatasetErrEnum.unExist); + } + + // root 直接通过 + if (isRoot) { + return { + userId, teamId, tmbId, collection, + permission: new DatasetPermission({ isOwner: true }), + isRoot: true + }; + } + + // 3. 计算有效权限(递归向上) + const permission = await getCollectionTmbPermission({ collection, teamId, tmbId }); + + if (!permission.checkPer(per)) { + return Promise.reject(DatasetErrEnum.unAuthDataset); + } + + return { userId, teamId, tmbId, collection, permission, isRoot }; +} +``` + +### 2.2 递归权限计算 + +```typescript +// packages/service/support/permission/dataset/auth.ts +export async function getCollectionTmbPermission({ + collection, + teamId, + tmbId, + depth = 0 +}) { + // 防止无限递归 + if (depth > MAX_COLLECTION_PERMISSION_DEPTH) { + return new DatasetPermission({ role: NullRoleVal }); + } + + // 检查是否是 collection 的 owner + const isOwner = tmbPer.isOwner || String(collection.tmbId) === String(tmbId); + if (isOwner) { + return new DatasetPermission({ isOwner: true }); + } + + // Folder collection 始终使用独立权限 + const hasIndependentPermission = + collection.inheritPermission === false || + collection.type === DatasetCollectionTypeEnum.folder; + + if (hasIndependentPermission) { + const myPer = await getTmbPermission({ + teamId, tmbId, + resourceId: collection._id, + resourceType: PerResourceTypeEnum.collection + }); + return new DatasetPermission({ role: myPer ?? NullRoleVal }); + } + + // 继承模式:从父级获取权限 + let parentRoleVal = NullRoleVal; + + if (collection.parentId) { + // 父级是另一个 collection + const parentCollection = await MongoDatasetCollection.findOne( + { _id: collection.parentId }, + '_id tmbId datasetId parentId inheritPermission type' + ).lean(); + + if (parentCollection) { + const parentPermission = await getCollectionTmbPermission({ + collection: parentCollection, teamId, tmbId, depth: depth + 1 + }); + parentRoleVal = parentPermission.role; + } + } else { + // 父级是 dataset + const { dataset } = await authDatasetByTmbId({ + tmbId, datasetId: collection.datasetId, per: NullPermissionVal + }); + parentRoleVal = dataset.permission.role; + } + + // 按位合并父级权限和自身权限 + const myPer = await getTmbPermission({ + teamId, tmbId, + resourceId: collection._id, + resourceType: PerResourceTypeEnum.collection + }) ?? NullRoleVal; + + return new DatasetPermission({ role: sumPer(parentRoleVal, myPer) }); +} +``` + +### 2.3 递归路径示例 + +``` +File Collection C (inheritPermission: true) + │ + ├─ parentId → Folder Collection B + │ │ + │ ├─ parentId → Folder Collection A + │ │ │ + │ │ └─ parentId → null → Dataset D + │ │ + │ └─ 独立权限: [User1: manage] + │ + └─ 自身权限: [User2: read] + +User1 的有效权限: manage (来自 B) +User2 的有效权限: read (自身) + manage (来自 B 的继承链) = manage +``` + +--- + +## 3. 独立权限 vs 继承权限 + +### 3.1 独立权限 + +条件:`inheritPermission === false` 或 `type === folder` + +- 只读取 `resource_permissions` 中 `resourceType = collection` 的记录 +- folder collection 始终独立管理权限(类似 dataset folder) + +### 3.2 继承权限 + +条件:`inheritPermission === true`(默认)且非 folder + +- 设计上,继承的非 folder collection 不应有直接协作者 +- 有效权限 = 父级权限 | 自身权限(按位或) +- 父级可以是另一个 collection 或 dataset + +--- + +## 4. Folder Collection 的特殊性 + +### 4.1 创建时复制父级协作者 + +```typescript +// packages/service/core/dataset/collection/controller.ts +if (inheritPermission && props.type === DatasetCollectionTypeEnum.folder) { + // 获取父级协作者 + const parentClbs = await getResourceOwnedClbs({ + resourceId: parentId || datasetId, + resourceType: parentId ? PerResourceTypeEnum.collection : PerResourceTypeEnum.dataset, + teamId, + session + }); + + // 父 owner 降级为 manage,创建者设为 owner + const collaborators = [ + ...parentClbs + .filter((clb) => clb.tmbId !== props.tmbId) + .map((clb) => { + if (clb.permission === OwnerRoleVal) clb.permission = ManageRoleVal; + return clb; + }), + { tmbId: props.tmbId, permission: OwnerRoleVal } + ]; + + // 批量写入 + await MongoResourcePermission.bulkWrite(ops, { session }); +} +``` + +### 4.2 权限变更同步子树 + +Folder collection 的协作者变更后,需要同步到所有继承它的子 collection: + +```typescript +await syncChildrenPermission({ + resource: folderCollection, + folderTypeList: [DatasetCollectionTypeEnum.folder], + resourceType: PerResourceTypeEnum.collection, + resourceModel: MongoDatasetCollection, + collaborators: latestClbList, + additionalFilter: { datasetId }, // 限制在同一 dataset 内 + session +}); +``` + +--- + +## 5. Dataset → Collection 权限同步 + +当 Dataset 的协作者变更时,需要同步到其下属的所有根级 folder collection。 + +```typescript +// packages/service/core/dataset/collection/controller.ts +export async function syncDatasetFolderCollectionPermissions({ + datasetId, + teamId, + collaborators, + session +}) { + // 1. 获取所有根级 folder collection(parentId = null) + const rootFolderCollections = await MongoDatasetCollection.find({ + datasetId, + type: DatasetCollectionTypeEnum.folder, + parentId: null, + inheritPermission: true + }, '_id type teamId parentId').lean(); + + // 2. 逐个替换并同步 + for (const folderCollection of rootFolderCollections) { + // 替换 folder 自身协作者 + await replaceResourceClbs({ + teamId, + resourceId: String(folderCollection._id), + resourceType: PerResourceTypeEnum.collection, + collaborators, + session + }); + + // 同步到子 collection 树 + await syncChildrenPermission({ + resource: folderCollection, + folderTypeList: [DatasetCollectionTypeEnum.folder], + resourceType: PerResourceTypeEnum.collection, + resourceModel: MongoDatasetCollection, + collaborators, + additionalFilter: { datasetId }, + session + }); + } +} +``` + +--- + +## 6. Collection 移动时的权限处理 + +### 6.1 移动接口权限检查 + +```typescript +// projects/app/src/pages/api/core/dataset/collection/update.ts +if (isMoving) { + if (normalizedNewParentId) { + // 移入 folder collection → 需目标 folder 的 manage 权限 + await authDatasetCollection({ + req, collectionId: normalizedNewParentId, per: ManagePermissionVal + }); + } + + if (collection.parentId) { + // 从 folder collection 移出 → 需原 folder 的 manage 权限 + await authDatasetCollection({ + req, collectionId: String(collection.parentId), per: ManagePermissionVal + }); + } + + if (!normalizedNewParentId || !collection.parentId) { + // 移入/移出 dataset 根级 → 需 dataset 的 manage 权限 + await authDataset({ + req, datasetId: collection.datasetId, per: ManagePermissionVal + }); + } +} +``` + +### 6.2 移动时权限同步 + +```typescript +if (isMoving && inheritParentPermission) { + // 获取新父级协作者 + const parentClbs = normalizedParentId + ? await getResourceOwnedClbs({ + resourceId: normalizedParentId, + resourceType: PerResourceTypeEnum.collection + }) + : collection.type === DatasetCollectionTypeEnum.folder + ? await getResourceOwnedClbs({ + resourceId: collection.datasetId, + resourceType: PerResourceTypeEnum.dataset + }) + : []; + + // 替换自身协作者 + await replaceResourceClbs({ + resourceType: PerResourceTypeEnum.collection, + teamId, + resourceId: String(id), + collaborators: parentClbs, + session + }); + + // 同步子树 + await syncChildrenPermission({ + resource: collection, + folderTypeList: [DatasetCollectionTypeEnum.folder], + resourceType: PerResourceTypeEnum.collection, + resourceModel: MongoDatasetCollection, + collaborators: parentClbs, + additionalFilter: { datasetId: collection.datasetId }, + session + }); +} +``` + +### 6.3 移动选项 + +| 参数 | 说明 | +|------|------| +| `inheritParentPermission = true`(默认) | 继承新父级权限,替换自身协作者 | +| `inheritParentPermission = false` | 保持独立授权,不修改权限 | + +--- + +## 7. 权限生效范围在 Collection 中的应用 + +### 7.1 Schema 定义 + +```typescript +// packages/global/core/dataset/type.ts +export const DatasetCollectionSchema = z.object({ + // ... + inheritPermission: z.boolean().optional(), + permissionEffectScope: z + .nativeEnum(PermissionEffectScopeEnum) + .optional() +}); +``` + +### 7.2 创建时的自动设置 + +```typescript +// packages/service/core/dataset/collection/controller.ts +let inheritPermission = true; + +if (parentId) { + // 父级是 collection + const parentCollection = await MongoDatasetCollection.findOne( + { _id: parentId }, 'permissionEffectScope' + ).lean(); + + if (parentCollection?.permissionEffectScope === PermissionEffectScopeEnum.currentOnly) { + inheritPermission = false; + } +} else { + // 父级是 dataset + const parentDataset = await MongoDataset.findOne( + { _id: datasetId }, 'permissionEffectScope' + ).lean(); + + if (parentDataset?.permissionEffectScope === PermissionEffectScopeEnum.currentOnly) { + inheritPermission = false; + } +} +``` + +### 7.3 行为对照 + +| 父级 `permissionEffectScope` | 子级 `inheritPermission` | 效果 | +|------------------------------|--------------------------|------| +| `allChildren`(默认) | `true` | 子级继承父级权限 | +| `currentOnly` | `false` | 子级独立管理权限,不继承 | + +--- + +## 8. 恢复继承 (resumeInheritPermission) + +### 8.1 Collection 恢复继承接口 + +```typescript +// projects/app/src/pages/api/core/dataset/collection/resumeInheritPermission.ts +async function handler(req) { + const { collection } = await authDatasetCollection({ + req, collectionId, per: ManagePermissionVal + }); + + await mongoSessionRun(async (session) => { + await resumeInheritPermission({ + resource: collection, + folderTypeList: [DatasetCollectionTypeEnum.folder], + resourceType: PerResourceTypeEnum.collection, + resourceModel: MongoDatasetCollection, + session + }); + }); +} +``` + +### 8.2 行为差异 + +| 资源类型 | 恢复继承行为 | +|----------|-------------| +| 非 folder Collection | 删除所有自身协作者(保留 owner),设置 `inheritPermission: true` | +| Folder Collection | 用父级协作者替换自身协作者(owner 转 manage),同步子树 | + +--- + +## 9. 关键函数索引 + +| 函数 | 文件 | 用途 | +|------|------|------| +| `authDatasetCollection` | `packages/service/support/permission/dataset/auth.ts` | Collection 鉴权入口 | +| `getCollectionTmbPermission` | `packages/service/support/permission/dataset/auth.ts` | 递归计算 collection 有效权限 | +| `syncDatasetFolderCollectionPermissions` | `packages/service/core/dataset/collection/controller.ts` | Dataset 权限同步到 folder collection | +| `createOneCollection` | `packages/service/core/dataset/collection/controller.ts` | 创建 collection(含权限初始化) | +| `resumeInheritPermission` | `packages/service/support/permission/inheritPermission.ts` | 恢复继承 | +| `replaceResourceClbs` | `packages/service/support/permission/inheritPermission.ts` | 替换资源协作者 | +| `syncChildrenPermission` | `packages/service/support/permission/inheritPermission.ts` | 同步权限到子树 | diff --git a/.claude/skills/add-permission/reference/inheritance.md b/.claude/skills/add-permission/reference/inheritance.md index 1ceeef15be14..3e054184e122 100644 --- a/.claude/skills/add-permission/reference/inheritance.md +++ b/.claude/skills/add-permission/reference/inheritance.md @@ -35,6 +35,7 @@ 1. **Folder 不继承**:folder 的 `inheritPermission` 无效,它有自己的完整协作者列表 2. **普通资源继承**:开启继承时,鉴权时合并父级权限 3. **继承是增量合并**:不是覆盖,子资源可以有额外的显式协作者 +4. **父集 owner → 子集 manage**:父资源的 owner 在子资源中降级为 manage(owner 不跨级继承) --- @@ -71,7 +72,9 @@ export const sumPer = (...pers: PermissionValueType[]) => { --- -## 3. Folder 创建时复制父协作者 +## 3. 父集 owner → 子集 manage 规则 + +### 3.1 Folder 创建时复制父协作者 ```typescript // packages/service/support/permission/controller.ts @@ -116,9 +119,147 @@ export const createResourceDefaultCollaborators = async ({ }; ``` +### 3.2 子树权限同步中的 owner 保护 + +```typescript +// packages/service/support/permission/inheritPermission.ts +for (const latestClb of latestClbList) { + const latestClbId = getCollaboratorId(latestClb); + // Skip if child already has owner permission for this collaborator + if (myOwnerIds.has(latestClbId)) { + continue; + } + const permission = + latestClb.permission === OwnerRoleVal ? ManageRoleVal : latestClb.permission; + // ... 插入或更新 +} +``` + +**关键点**: +- 父级 owner 同步到子级时自动转为 manage +- 子级已有 owner 不会被父级覆盖(owner 保护) +- 不同创建者的子 folder 保留各自的 owner 权限 + +--- + +## 4. 冲突检测与自动取消继承 + +### 4.1 冲突规则 + +```typescript +// packages/global/support/permission/utils.ts +export const checkRoleUpdateConflict = ({ + parentClbs, + newChildClbs +}: { + parentClbs: CollaboratorItemType[]; + newChildClbs: CollaboratorItemType[]; +}): boolean => { + // 计算变化的协作者 + const changedClbs = ...; + + for (const changedClb of changedClbs) { + const parent = parentClbRoleMap.get(getCollaboratorId(changedClb)); + if (!parent) { + // 新增协作者但父级有协作者列表 → 冲突 + if (parentClbs.length > 0) return true; + continue; + } + + const parentRole = parent.permission; + const childRole = changedClb.permission; + + // Rule 1: Parent is Owner → child must be Manage or Owner + if (parentRole === OwnerRoleVal) { + if (!(childRole & ManageRoleVal) || changedClb.deleted) { + return true; + } + continue; + } + + // Rule 2: Child is Owner → parent must be Write or Manage or Owner + if (childRole === OwnerRoleVal) { + if (!parentRole || parentRole === ReadRoleVal) { + return true; + } + continue; + } + + // Rule 3: Others → any difference is conflict + if (changedClb.changedRole !== 0 || changedClb.deleted) { + return true; + } + } + return false; +}; +``` + +### 冲突规则总结 + +| 场景 | 父级角色 | 子级角色 | 是否冲突 | +|------|----------|----------|----------| +| Rule 1 | Owner | 非 Manage/Owner | 冲突 | +| Rule 2 | Read/Null | Owner | 冲突 | +| Rule 3 | 其他 | 与父级不同 | 冲突 | + +### 4.2 冲突时自动取消继承 + +```typescript +// 协作者更新编排器中的处理 +const hasConflict = checkRoleUpdateConflict({ + changedClbs, + parentClbs +}); + +// 如果处于继承态且有冲突,自动断开继承 +if (resource.inheritPermission && hasConflict) { + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: false }, + { session } + ); +} +``` + +**设计价值**: +1. 用户不需要先点"取消继承"再改协作者 +2. 直接改协作者就自动完成"打断继承"状态迁移 +3. 交互从"配置底层机制"变成"编辑最终结果" + +--- + +## 5. 权限生效范围 (PermissionEffectScope) + +```typescript +// packages/global/support/permission/constant.ts +export enum PermissionEffectScopeEnum { + allChildren = 'allChildren', // 对所有子级资源生效(默认) + currentOnly = 'currentOnly' // 仅对当前资源生效 +} +``` + +### 作用 + +控制权限是否传递到子资源: + +| 值 | 说明 | +|----|------| +| `allChildren` | 默认。子资源创建时自动 `inheritPermission: true`,继承父级权限 | +| `currentOnly` | 子资源创建时自动 `inheritPermission: false`,不继承父级权限 | + +### 应用场景 + +```typescript +// Collection 创建时根据父级的 permissionEffectScope 决定 inheritPermission +let inheritPermission = true; +if (parentCollection?.permissionEffectScope === PermissionEffectScopeEnum.currentOnly) { + inheritPermission = false; +} +``` + --- -## 4. 子树权限同步 (syncChildrenPermission) +## 6. 子树权限同步 (syncChildrenPermission) 当 folder 的协作者变化时,需要同步到继承它的子树。 @@ -130,7 +271,8 @@ export async function syncChildrenPermission({ resourceType, resourceModel, session, - collaborators: latestClbList + collaborators: latestClbList, + additionalFilter = {} }) { // 1. 只处理 folder if (!folderTypeList.includes(resource.type)) return; @@ -139,7 +281,8 @@ export async function syncChildrenPermission({ const allFolders = await resourceModel.find({ teamId: resource.teamId, inheritPermission: true, - type: { $in: folderTypeList } + type: { $in: folderTypeList }, + ...additionalFilter }); // 3. BFS 遍历子树 @@ -151,127 +294,156 @@ export async function syncChildrenPermission({ for (const child of children) { // 获取子资源现有协作者 const childClbs = await getResourceOwnedClbs({ resourceId: child._id, ... }); + const myOwnerIds = new Set( + childClbs + .filter((clb) => clb.permission === OwnerRoleVal) + .map((clb) => getCollaboratorId(clb)) + ); 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 } - ); + // 跳过子资源已有 owner 的协作者 + if (myOwnerIds.has(getCollaboratorId(latestClb))) continue; + + const permission = + latestClb.permission === OwnerRoleVal ? ManageRoleVal : latestClb.permission; + + if (!myClbsIdSet.has(getCollaboratorId(latestClb))) { + // 新增 + ops.push({ insertOne: { document: { ...permission, ... } } }); } else { - // 没有则新增 - await MongoResourcePermission.create([{ - ...latestClb, - resourceId: child._id, - resourceType - }], { session }); + // 更新($set 覆盖,不是增量合并) + ops.push({ updateOne: { filter: { ... }, update: { $set: { permission } } } }); } } - // 删除不再存在的纯继承协作者 - 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 }); + // 删除:只删除父级不再存在的非 owner 协作者 + for (const myClb of childClbs) { + if (myClb.permission === OwnerRoleVal) continue; + if (!latestClbMap.get(getCollaboratorId(myClb))) { + ops.push({ deleteOne: { filter: { ... } } }); } } queue.push(child._id); } } + + await MongoResourcePermission.bulkWrite(ops, { session }); } ``` ### 关键点 -- **增量合并**:不是简单覆盖,保留子资源的显式增量 -- **只删纯继承**:只删除权限值与父级完全一致的协作者 -- **跳过 owner**:owner 不参与继承同步 +- **owner 保护**:子资源已有 owner 不会被父级覆盖 +- **$set 覆盖**:使用 `$set: { permission }` 精确覆盖,不是增量合并 +- **只删非 owner**:只删除权限值与父级一致的协作者,保留子资源自有的 owner +- **additionalFilter**:支持额外过滤条件(如 collection 查询时传入 `{ datasetId }`) --- -## 5. 恢复继承 (resumeInheritPermission) +## 7. 恢复继承 (resumeInheritPermission) + +### 7.1 非 Folder 资源 ```typescript -// packages/service/support/permission/inheritPermission.ts -export const resumeInheritPermission = async ({ +// 删除所有自身协作者(保留 owner),设置 inheritPermission: true +await MongoResourcePermission.deleteMany( + { + resourceId: resource._id, + resourceType, + teamId: resource.teamId, + permission: { $ne: OwnerRoleVal } + }, + { session } +); +await resourceModel.updateOne( + { _id: resource._id }, + { inheritPermission: true }, + { session } +); +``` + +### 7.2 Folder 资源 + +```typescript +// 1. 获取父协作者(owner 转 manage) +const collaborators = parentClbs.map((clb) => { + if (clb.permission === OwnerRoleVal) { + return { ...clb, permission: ManageRoleVal }; + } + return { ...clb }; +}); + +// 2. 替换自身协作者(不是合并) +await replaceResourceClbs({ + resourceType, + teamId: resource.teamId, + resourceId: resource._id, + collaborators, + session +}); + +// 3. 同步子树 +await syncChildrenPermission({ resource, + resourceModel, folderTypeList, resourceType, - resourceModel, - session -}) => { - const { teamId, parentId, _id: resourceId } = resource; + session, + collaborators +}); - // 1. 获取父协作者 - const parentClbs = parentId - ? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType }) - : []; +// 4. 设置继承标志 +await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: true }, + { session } +); +``` - // 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; - }) - }); +## 8. replaceResourceClbs - 替换资源协作者 - // 4. 删除旧协作者 - await MongoResourcePermission.deleteMany({ - resourceType, - resourceId - }, { session }); +```typescript +// packages/service/support/permission/inheritPermission.ts +export async function replaceResourceClbs({ + resourceType, + teamId, + resourceId, + collaborators, + session +}) { + const clbsNow = await MongoResourcePermission.find({ resourceType, teamId, resourceId }) + .lean() + .session(session); - // 5. 插入合并后的协作者 - await MongoResourcePermission.insertMany( - mergedClbs.map(clb => ({ - teamId, - resourceType, - resourceId, - ...clb - })), - { session } + // 传入的 owner 自动转为 manage + const normalizedCollaborators = collaborators.map((clb) => + clb.permission === OwnerRoleVal ? { ...clb, permission: ManageRoleVal } : clb ); - // 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 } + // 保留现有 owner(不应被修改或删除) + const ownerIds = new Set( + clbsNow + .filter((clb) => clb.permission === OwnerRoleVal) + .map((clb) => getCollaboratorId(clb)) ); -}; + + // 增量更新:update existing / insert new / delete removed + // ...(跳过 owner) +} ``` +### 特点 + +- **传入 owner 转 manage**:避免跨资源传播 owner +- **保留现有 owner**:资源自身的创建者权限不被覆盖 +- **整表替换**:删除旧列表中不存在的协作者 + --- -## 6. 资源移动时的处理 +## 9. 资源移动时的处理 ```typescript // 移动资源后 @@ -282,6 +454,15 @@ if (newParentId !== oldParentId) { ... }); + // 替换自身协作者 + await replaceResourceClbs({ + resourceType: PerResourceTypeEnum.collection, + teamId, + resourceId: String(id), + collaborators: newParentClbs, + session + }); + // 同步子树 await syncChildrenPermission({ resource: movedResource, @@ -293,3 +474,9 @@ if (newParentId !== oldParentId) { }); } ``` + +### 移动时的权限控制 + +- 移动到 folder collection → 需目标 folder 的 manage 权限 +- 从 folder collection 移出 → 需原 folder 的 manage 权限 +- 移动到/从 dataset 根级 → 需 dataset 的 manage 权限 diff --git a/.claude/skills/auto-dev/SKILL.md b/.claude/skills/auto-dev/SKILL.md new file mode 100644 index 000000000000..991870cefaf7 --- /dev/null +++ b/.claude/skills/auto-dev/SKILL.md @@ -0,0 +1,267 @@ +--- +name: auto-dev +description: 全自动需求开发流水线,将「需求分析 → 文档生成 → 代码实现 → i18n 更新」串联为一条完整链路。TRIGGER when: 用户提供截图或设计稿并要求「自动开发」「一键实现」「全流程实现」「从设计稿生成代码并实现」,或明确说「全流程自动化」「auto-dev」。DO NOT TRIGGER when: 用户仅要生成需求文档(用 img-to-docs)、仅要修复 bug、仅做代码审查。 +--- + +# Auto-Dev 全自动需求开发流水线 + +## 目录 + +- [When to Use This Skill](#when-to-use-this-skill) +- [How It Works](#how-it-works) +- [Phase 1:生成需求开发文档](#phase-1生成需求开发文档) +- [Phase 2:自动执行开发任务](#phase-2自动执行开发任务) +- [Phase 3:汇总与执行文档](#phase-3汇总与执行文档) +- [全局代码规范](#全局代码规范) +- [Examples](#examples) + +--- + +## When to Use This Skill + +- 用户上传截图或提供设计稿 URL,并要求「自动开发」「一键实现」 +- 用户说「帮我从设计稿生成代码并实现」「全流程自动化」 +- 用户已有设计文档(`.claude/design/xxx.md`),要求直接执行所有开发任务 +- 用户明确说 `/auto-dev` 或「auto-dev」 +- 用户希望跳过手动拆任务,直接端到端完成一个功能 + +**不适用场景**(请改用其他 skill): +- 仅需生成需求文档 → 使用 `/img-to-docs` +- 仅修复某个 bug → 直接修复 +- 仅做代码审查 → 使用 `/pr-review` + +--- + +## How It Works + +``` +输入 (截图 / 设计稿URL / 文字描述 / 已有设计文档路径) + ↓ +Phase 1: 执行 img-to-docs → 生成结构化需求开发文档 + ↓ (若已有文档则跳过) +Phase 2: 解析文档 → 集中确认 AI-TODO → 依次执行所有 Task + ↓ +Phase 3: 汇总变更 → 输出执行文档 → 可选触发验收 +``` + +--- + +## Phase 1:生成需求开发文档 + +**执行 `/img-to-docs` skill 的完整协议**,将结构化需求开发指南输出至: + +``` +.claude/design/<功能名>-design.md +``` + +> 若用户已提供设计文档路径(如 `.claude/design/xxx.md`),跳过 Phase 1,直接进入 Phase 2。 + +Phase 1 完成后,将文档路径记录为 `$DESIGN_DOC`,供 Phase 2 使用。 + +--- + +## Phase 2:自动执行开发任务 + +### 2.0 前置读取 + +读取 `$DESIGN_DOC`,提取: + +- **Task 列表**:所有 `### Task-FE-xx` 和 `### Task-BE-xx` 节 +- **AI-TODO 汇总表**:所有 `T-xx` 待确认项 +- **涉及文件清单**:每个 Task 的"涉及文件"字段 + +### 2.1 AI-TODO 前置确认 + +在执行任何代码前,**先集中处理 AI-TODO 汇总表中所有状态未确认的项**: + +``` +逐条展示 AI-TODO: + - 编号、不确定内容、建议确认方式 + - 询问用户确认或跳过 + - 将确认结果记录,在后续 Task 执行时使用 +``` + +> 若用户选择「全部跳过,使用文档默认方案」,直接进入 2.2。 + +### 2.2 Task 执行协议 + +按文档中 Task 编号顺序依次执行,每个 Task 包含以下步骤: + +#### Step A:读取当前文件 + +``` +对 Task 中"涉及文件"列出的每个文件: + 1. 使用 Read 工具读取完整内容 + 2. 理解现有代码结构和模式 + 3. 确认修改点与现有代码无冲突 +``` + +#### Step B:调用对应子 Skill + +根据 Task 类型,修改前调用对应子 Skill: + +| Task 类型 | 子 Skill | 时机 | +|-----------|----------|------| +| 涉及前端组件 | `/component-guide` | 执行前,确认组件映射 | +| 涉及新增 i18n key | `/i18n` | 执行前,确认命名规范 | +| 涉及新增 API | `/api-development` | 执行前,确认接口规范 | + +#### Step C:执行代码修改 + +根据 Task 中的"实现要点",使用 Edit 或 Write 工具完成代码修改: + +``` +- 优先使用 Edit(精确替换),避免全文件重写 +- 保持文件现有代码风格和 import 顺序 +- TypeScript 类型用 type,不用 interface(项目规范) +- 修改完成后,通过 Read 验证改动符合预期 +``` + +#### Step D:i18n 同步 + +若 Task 涉及新增 i18n key,同步更新三个语种文件: + +``` +packages/web/i18n/zh-CN/.json ← 中文值 +packages/web/i18n/en/.json ← 英文值 +packages/web/i18n/zh-Hant/.json ← 繁体中文值 +``` + +> 命名空间以 `packages/web/i18n/constants.ts` 中的 `I18N_NAMESPACES` 为准。 + +#### Step E:记录变更 + +每个 Task 执行完后,追加到内部变更日志: + +``` +- Task-FE-xx | 文件路径 | 变更摘要(一句话) +``` + +### 2.3 Task 执行规则 + +**遇到以下情况时暂停,提示用户:** + +1. AI-TODO 未在 2.1 中确认,且影响当前实现决策 +2. 涉及文件不存在(路径可能有误) +3. 现有代码与 Task 描述的"当前状态"不符(文档可能已过期) +4. 修改范围超出文档描述(发现隐藏影响) + +**遇到以下情况时自动处理并继续:** + +1. 文件 import 需要新增 → 自动追加到 import 区域 +2. i18n key 已存在且值相同 → 跳过,继续 +3. Task 中有 `// AI-TODO` 但用户已在 2.1 选择跳过 → 使用文档默认方案 + +--- + +## Phase 3:汇总与执行文档 + +所有 Task 完成后,输出执行文档并保存至 `.claude/design/<功能名>-execution.md`: + +### 3.1 变更文件清单 + +| 文件路径 | 变更类型 | Task 编号 | 变更说明 | +|---------|---------|----------|---------| +| `src/xxx.tsx` | 修改 | Task-FE-01 | 新增调试面板组件 | + +### 3.2 AI-TODO 处理结果 + +| 编号 | 不确定内容 | 处理方式 | 最终决策 | +|------|-----------|---------|---------| +| T-01 | ... | 用户确认 / 默认方案 | ... | + +### 3.3 遗留问题 + +列出未完成的 Task(若有)及原因。 + +### 3.4 验收入口(可选) + +询问用户:**是否立即执行 agent-browser 验收脚本?** + +- 选「是」→ 按以下「agent-browser 验收执行协议」逐步执行 +- 选「否」→ 流程结束,提示用户手动验收路径 + +--- + +## 全局代码规范 + +执行所有 Task 时,始终遵守以下规范: + +### TypeScript +- 类型声明用 `type`,不用 `interface` +- Props 类型定义放在组件文件顶部 +- 避免 `any`,优先用 `unknown` + 类型守卫 + +### React 组件 +- 函数式组件 + Hooks +- 样式使用 Chakra UI `sx` prop 或组件 props,避免内联 `style` +- 组件文件结构:`import → type → component → export default` + +### 文件修改安全原则 +- **修改前必须先 Read**,不凭记忆修改 +- **不改动未涉及的代码**,即使发现可优化的地方 +- **不添加文档中未要求的功能**(不过度实现) + +### 组件使用优先级(执行前端 Task 时) + +1. 项目公共组件(`component-guide` 中的 🔴 清单) +2. 项目业务组件(`component-guide` 中的 🟡 清单) +3. Chakra UI 原生组件 +4. HTML 原生元素(兜底) + +--- + +## Examples + +### Example 1:从截图一键实现 + +**User Request**: +``` +/auto-dev [粘贴截图] +帮我实现文档解析节点的调试界面改版 +``` + +**执行流程**: +1. Phase 1 → 生成 `.claude/design/readfiles-debug-ui-design.md` +2. 展示 AI-TODO 汇总,逐条确认(如:调试数据来源、错误展示方式) +3. Phase 2 → 依次执行 `Task-FE-01 ~ Task-FE-05` +4. Phase 3 → 输出 `.claude/design/readfiles-debug-ui-execution.md` + +**Output(执行文档片段)**: +``` +变更文件清单: +- src/components/core/workflow/Debug/Panel.tsx | 新增 | Task-FE-01 | 新增调试面板主体 +- packages/web/i18n/zh-CN/workflow.json | 修改 | Task-FE-02 | 新增 3 个调试相关 key +``` + +--- + +### Example 2:已有设计文档,跳过 Phase 1 + +**User Request**: +``` +/auto-dev .claude/design/readfiles-debug-ui-design.md +``` + +**执行流程**: +1. 跳过 Phase 1,直接读取指定文档 +2. 展示 AI-TODO 汇总,逐条确认 +3. Phase 2 → 依次执行所有 Task +4. Phase 3 → 汇总并询问是否触发验收 + +--- + +### Example 3:AI-TODO 暂停场景 + +**触发条件**: Task-FE-03 依赖 T-02(数据接口字段),但用户在 2.1 跳过了 T-02 + +**Claude 行为**: +``` +⚠️ 暂停:Task-FE-03 需要确认 T-02(接口返回字段 `debugData` 的结构)。 +当前未确认,无法继续实现。 + +请选择: +1. 现在确认 T-02 → 继续执行 Task-FE-03 +2. 使用文档默认方案(`{ logs: string[] }`)→ 继续执行 +3. 跳过 Task-FE-03 → 进入下一个 Task +``` diff --git a/.claude/skills/common/vibe-coding/SKILL.md b/.claude/skills/common/vibe-coding/SKILL.md new file mode 100644 index 000000000000..735f367f8c62 --- /dev/null +++ b/.claude/skills/common/vibe-coding/SKILL.md @@ -0,0 +1,218 @@ +--- +name: vibe-coding +description: Analyzes UI design images or Figma links and generates React/TypeScript code by intelligently matching project components, then public components, then third-party libraries. Activates when user mentions "vibe coding", "UI 设计图", "组件复用", "design to code", "根据设计图生成代码", or provides a design file/screenshot to implement. +--- + +# Vibe Coding — 前端组件智能识别与复用 + +> 根据 UI 设计图,按优先级(项目复合组件 → 公共组件 → 第三方库)生成符合项目规范的 React 代码。 + +## 目录 + +- [使用场景](#使用场景) +- [执行流程](#执行流程) +- [详细步骤](#详细步骤) +- [输出规范](#输出规范) +- [示例](#示例) + +--- + +## 使用场景 + +- 用户提供 UI 设计图(截图、Figma 链接、Sketch 文件)并要求生成代码 +- 用户提到 "vibe coding"、"根据设计图写代码"、"UI 转代码" +- 用户需要实现一个新页面或组件,并提供了视觉参考 +- 用户希望复用项目现有组件来还原设计稿 + +--- + +## 执行流程 + +``` +输入设计图 + ↓ +步骤 1:复合组件匹配 + ↓ 不匹配 +步骤 2:原子组件拆解 + ↓ +步骤 3:公共组件匹配(使用 /component-guide skill) + ↓ 未覆盖部分 +步骤 4:第三方库兜底(@chakra-ui/react) + ↓ +步骤 5:生成 React TypeScript 代码 +``` + +**短路规则**:步骤 1 匹配成功则跳过步骤 2-5,直接输出配置方案。 + +--- + +## 详细步骤 + +### 步骤 1:复合组件匹配 + +**目标**:判断设计图是否可通过项目现有「可配置复合组件」直接实现。 + +**操作**: +- 对比设计图与项目「可配置复合组件清单」,识别 UI 结构、功能是否匹配 +- 若匹配,判断能否通过 props、插槽、主题配置还原设计效果 + +**判断结果**: +- ✅ 匹配 → 输出组件名 + 配置项 JSON,**任务结束** +- ❌ 不匹配 → 进入步骤 2 + +--- + +### 步骤 2:原子组件拆解 + +**目标**:将设计图拆解为最小粒度的基础 UI 元素。 + +**识别范围**: + +| 类别 | 常见组件 | +|------|---------| +| 交互类 | Button、Select、Input、Checkbox、Radio、Switch | +| 布局类 | Card、Modal、Drawer、Tabs、Table | +| 展示类 | Tag、Badge、Progress、Icon、Avatar | + +**输出格式**:`[Button, Select, Table, Modal, ...]` + +--- + +### 步骤 3:公共组件匹配 + +**目标**:为每个原子组件优先匹配项目内的公共组件。 + +**操作**: +1. 调用 `/component-guide` skill 获取项目公共组件快照 +2. 遍历原子组件清单,搜索功能和 UI 匹配的公共组件 +3. 匹配标准:样式(颜色、尺寸)+ 交互(点击反馈、表单验证)均需满足 + +**输出格式**: +``` +Button → 使用项目公共组件 MyButton +Select → 无匹配公共组件(进入步骤 4) +``` + +--- + +### 步骤 4:第三方组件库兜底 + +**目标**:为无公共组件匹配的原子组件选择第三方库组件。 + +**当前项目指定库**:`@chakra-ui/react@2.10.7` + +**操作**:选择与设计图功能、UI 最接近的 Chakra UI 组件,并标注适配建议。 + +**输出格式**:`Select → 使用 @chakra-ui/react 的 Select,适配建议:覆盖边框色 borderColor: 'primary.500'` + +--- + +### 步骤 5:代码生成 + +**目标**:基于识别结果生成符合项目规范的 React 代码。 + +**代码规范**: +- 使用 TypeScript,组件 props 需有完整类型定义 +- 样式使用 Chakra UI 的 `sx` prop 或内置 props,**避免内联 style** +- 遵循项目现有的组件命名与文件组织规范 +- 复用的公共组件保持原有接口,**不得修改其内部实现** +- 使用 `type` 而非 `interface` 进行类型声明(项目规范) + +--- + +## 输出规范 + +最终输出包含以下三个部分: + +```markdown +## UI 组件使用方案 + +### 复合组件(如适用) +- 使用项目复合组件 `ProForm`,配置项:`{ layout: 'horizontal', fields: [...] }` + +### 原子组件清单 +| 组件 | 来源 | 说明 | +|------|------|------| +| Button | 项目公共组件 `MyButton` | — | +| Select | Chakra UI `Select` | 覆盖边框色:`borderColor: 'primary.500'` | +| Table | 项目公共组件 `SortTable` | — | + +### 生成代码 +// ... React TSX 代码 +``` + +--- + +## 示例 + +### Example 1:完整匹配复合组件 + +**用户输入**:提供一张数据列表页设计图,包含搜索栏、表格、分页 + +**执行过程**: +1. 步骤 1:识别为数据表格布局,匹配到项目 `DataTable` 复合组件 +2. 判断可通过 `columns`、`pagination` props 配置还原设计 + +**输出**: +``` +使用项目复合组件 DataTable,配置项: +{ + "columns": [...], + "pagination": { "pageSize": 10 }, + "searchable": true +} +``` +任务结束。 + +--- + +### Example 2:原子组件拆解 + 混合来源 + +**用户输入**:提供一张表单弹窗设计图,含标题、输入框、下拉选择、提交按钮 + +**执行过程**: +1. 步骤 1:无匹配复合组件 +2. 步骤 2:拆解为 `[Modal, Input, Select, Button]` +3. 步骤 3:`Modal` → 项目公共组件 `MyModal`;`Input` → 项目公共组件 `MyInput`;`Button` → 项目公共组件 `MyButton`;`Select` → 无匹配 +4. 步骤 4:`Select` → `@chakra-ui/react` 的 `Select` +5. 步骤 5:生成代码 + +**输出**: + +```tsx +import MyModal from '@/components/common/MyModal'; +import MyInput from '@/components/common/MyInput'; +import MyButton from '@/components/common/MyButton'; +import { Select } from '@chakra-ui/react'; + +type FormData = { + name: string; + category: string; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: FormData) => void; +}; + +export const CreateItemModal = ({ isOpen, onClose, onSubmit }: Props) => { + // ... 实现 +}; +``` + +--- + +## Do / Don't + +**Do**: +- ✅ 优先使用项目公共组件,保持 UI 一致性 +- ✅ 调用 `/component-guide` 获取最新组件快照 +- ✅ 输出组件来源表格,方便审查 +- ✅ 使用 TypeScript type 定义 props + +**Don't**: +- ❌ 跳过公共组件直接使用第三方库 +- ❌ 修改公共组件的内部实现来适配设计 +- ❌ 使用内联 `style`,应使用 Chakra UI sx prop +- ❌ 使用 `interface` 替代 `type`(不符合项目规范) diff --git a/.claude/skills/component-guide/SKILL.md b/.claude/skills/component-guide/SKILL.md new file mode 100644 index 000000000000..469d97a724e5 --- /dev/null +++ b/.claude/skills/component-guide/SKILL.md @@ -0,0 +1,87 @@ +--- +name: component-guide +description: FastGPT 前端组件使用指南。出码时优先复用项目已有组件,避免重复造轮子。每次出码前检查组件快照是否过期(运行脚本更新),然后参考本指南选择合适的组件。用户上传了图片(UI 设计稿、截图、原型图等)时,必须优先激活此 Skill,分析图片中的 UI 元素并映射到项目已有组件后再出码。 +snapshot_hash: "29d5478011bba9a09fc07ef99b84c1f2" +snapshot_updated_at: "2026-03-23 12:42:59" +--- + +# FastGPT 前端组件使用指南 + +> 出码时必须优先使用已有组件,禁止重复实现相同功能。 + +## 配套文件 + +| 文件 | 说明 | 维护方式 | +|------|------|----------| +| [component-api.md](./component-api.md) | 核心组件 API 速查 + 代码规范 | 手动维护 | +| [component-snapshot.md](./component-snapshot.md) | 全量组件扫描快照 | 脚本自动生成 | +| [gen-component-guide.mjs](./gen-component-guide.mjs) | 快照生成脚本 | — | + +## 何时使用此技能 + +- **用户上传了图片**(UI 设计稿、截图、原型图、线框图等)时,**必须优先激活此 Skill** +- 编写任何前端页面或组件时 +- 需要弹窗、下拉、菜单、表格、输入框等通用 UI 时 +- 审查前端代码,检查是否存在重复造轮子的情况时 + +## 图片触发流程 + +当用户上传图片时: +1. 分析图片中出现的 UI 元素(弹窗、按钮、输入框、列表等) +2. 对照本指南的组件优先级表,将 UI 元素映射到项目已有组件 +3. 确认无可复用组件后,再考虑自行实现 + +## 快照检查流程 + +```bash +# 每次出码前,先运行以下命令检查组件库是否有更新 +node .claude/skills/common/skills/component-guide/gen-component-guide.mjs + +# 输出 "组件库无变化" → 直接参考 component-api.md 出码 +# 输出 "检测到变更" → 等更新完成,再查阅 component-snapshot.md 确认新组件 +``` + +## 组件优先级规则 + +### 🔴 必须优先使用(禁止重复实现) + +| 场景 | 组件 | 导入路径 | +|------|------|----------| +| 弹窗/对话框 | `MyModal` | `@fastgpt/web/components/common/MyModal` | +| 下拉选择 | `MySelect` | `@fastgpt/web/components/common/MySelect` | +| 右键/操作菜单 | `MyMenu` | `@fastgpt/web/components/common/MyMenu` | +| 气泡提示 | `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | +| 气泡卡片 | `MyPopover` | `@fastgpt/web/components/common/MyPopover` | +| 加载状态 | `MyLoading` | `@fastgpt/web/components/common/MyLoading` | +| 空状态提示 | `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | +| 带加载态容器 | `MyBox` | `@fastgpt/web/components/common/MyBox` | +| 图标 | `MyIcon` | `@fastgpt/web/components/common/Icon` | +| 头像 | `Avatar` | `@fastgpt/web/components/common/Avatar` | +| 分割线 | `MyDivider` | `@fastgpt/web/components/common/MyDivider` | +| 标签 | `MyTag` | `@fastgpt/web/components/common/Tag` | +| 搜索输入框 | `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | +| 数字输入框 | `MyNumberInput` | `@fastgpt/web/components/common/Input/NumberInput` | +| 日期选择 | `DateTimePicker` | `@fastgpt/web/components/common/DateTimePicker` | +| 日期范围选择 | `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | +| Markdown 渲染 | `Markdown` | `@fastgpt/web/components/common/Markdown` | +| 拖拽排序 | `DndDrag` | `@fastgpt/web/components/common/DndDrag` | +| 折叠提示 | `LightTip` | `@fastgpt/web/components/common/LightTip` | + +### 🟡 业务组件(优先使用) + +| 场景 | 组件 | 路径 | +|------|------|------| +| 文件选择 | `FileSelect` | `@/components/core/app/FileSelect` | +| 数据集选择 | `DatasetSelect` | `@/components/core/app/DatasetSelect` | +| 变量编辑 | `VariableEdit` | `@/components/core/app/VariableEdit` | +| AI 模型设置 | `AISettingModal` | `@/components/core/ai/AISettingModal` | +| 提示词编辑器 | `PromptEditor` | `@fastgpt/web/components/common/Textarea/PromptEditor` | +| 代码编辑器 | `CodeEditor` | `@fastgpt/web/components/common/Textarea/CodeEditor` | +| JSON 编辑器 | `JSONEditor` | `@fastgpt/web/components/common/Textarea/JsonEditor` | +| 目录移动 | `MoveModal` | `@/components/common/folder/MoveModal` | +| 路径面包屑 | `FolderPath` | `@/components/common/folder/Path` | + +--- + +> 详细 API 示例见 [component-api.md](./component-api.md) +> 完整组件清单见 [component-snapshot.md](./component-snapshot.md) diff --git a/.claude/skills/component-guide/component-api.md b/.claude/skills/component-guide/component-api.md new file mode 100644 index 000000000000..d14be9fd3185 --- /dev/null +++ b/.claude/skills/component-guide/component-api.md @@ -0,0 +1,195 @@ +# 核心组件 API 速查 + +> 手动维护。仅收录最高频使用的通用组件,业务组件请直接阅读源码。 + +--- + +## MyModal — 弹窗 + +```tsx +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { ModalBody, ModalFooter, Button } from '@chakra-ui/react'; + + + ... + + + + + +``` + +**Props**: `isOpen` `onClose` `title` `iconSrc` `iconColor` `isCentered` `isLoading` `size('md'|'lg')` `showCloseButton` `closeOnOverlayClick` `closeOnEsc` + +--- + +## MySelect — 下拉选择 + +```tsx +import MySelect from '@fastgpt/web/components/common/MySelect'; + + setSelected(val)} + list={[ + { label: '选项A', value: 'a' }, + { label: '选项B', value: 'b', icon: 'edit' } + ]} + placeholder="请选择" + isLoading={false} +/> +``` + +**`list` 每项**: `{ label, value, icon?, avatar?, description?, isDisabled? }` + +--- + +## MyMenu — 操作菜单 + +```tsx +import MyMenu from '@fastgpt/web/components/common/MyMenu'; + +} />} + trigger="click" // 'hover' | 'click' + placement="bottom-end" + menuList={[ + { + children: [ + { label: '编辑', icon: 'edit', onClick: handleEdit }, + { label: '删除', icon: 'delete', type: 'danger', onClick: handleDelete } + ] + } + ]} +/> +``` + +**菜单项 `type`**: `'primary' | 'danger' | 'gray' | 'grayBg'` + +--- + +## MyBox — 带加载态容器 + +```tsx +import MyBox from '@fastgpt/web/components/common/MyBox'; + +// 继承全部 BoxProps,额外支持 isLoading / text / size + + {children} + +``` + +--- + +## EmptyTip — 空状态 + +```tsx +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; + + +``` + +--- + +## MyIcon — 图标 + +```tsx +import MyIcon from '@fastgpt/web/components/common/Icon'; + + +``` + +图标名见 `packages/web/components/common/Icon/constants.ts` + +--- + +## MyTooltip — 气泡提示 + +```tsx +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; + + + + +``` + +--- + +## SearchInput — 搜索框 + +```tsx +import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; + + setKeyword(e.target.value)} + placeholder="搜索..." + w="200px" +/> +``` + +--- + +## PromptEditor — 提示词编辑器(支持变量插入) + +```tsx +import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor'; + + +``` + +--- + +## MyMenu + Confirm — 带确认的危险操作 + +```tsx +// 推荐用 PopoverConfirm 替代 window.confirm +import PopoverConfirm from '@fastgpt/web/components/common/MyModal/PopoverConfirm'; + + + + +``` + +--- + +## 代码规范 + +### ✅ 正确 + +```tsx +import MyModal from '@fastgpt/web/components/common/MyModal'; + +const MyFeatureModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => ( + + 内容 + +); +``` + +### ❌ 禁止 + +```tsx +// 禁止绕过封装组件,直接使用 Chakra 原始 Modal +import { Modal, ModalOverlay, ModalContent, ModalHeader } from '@chakra-ui/react'; + +// 禁止重新实现 Select、Menu 等已有组件 +const MySelect = () => ; +``` diff --git a/.claude/skills/component-guide/component-snapshot.md b/.claude/skills/component-guide/component-snapshot.md new file mode 100644 index 000000000000..d97919b994b6 --- /dev/null +++ b/.claude/skills/component-guide/component-snapshot.md @@ -0,0 +1,281 @@ +# 组件快照(自动生成) + +> **请勿手动编辑此文件**,由 `gen-component-guide.mjs` 脚本自动维护。 +> 最后更新:2026-03-23 12:42:59 | Hash:`29d54780` + +## `packages/web/components/common/` + +| 组件 | 文件 | 导出类型 | 说明 | +|------|------|----------|------| +| `AvatarGroup` | `AvatarGroup.tsx` | `-` | - | +| `Avatar` | `index.tsx` | `-` | - | +| `DateRangePicker` | `index.tsx` | `DateRangeType` | - | +| `DateTimePicker` | `index.tsx` | `DateTimePickerProps` | - | +| `DragIcon` | `DragIcon.tsx` | `-` | - | +| `DndDrag` | `index.tsx` | `-` | - | +| `EmptyTip` | `index.tsx` | `-` | - | +| `MyIconButton` | `button.tsx` | `-` | - | +| `CloseIcon` | `close.tsx` | `-` | - | +| `constants` | `constants.ts` | `-` | - | +| `DeleteIcon` | `delete.tsx` | `-` | - | +| `MyIcon` | `index.tsx` | `-` | - | +| `MyImage` | `MyImage.tsx` | `-` | - | +| `MyPhotoView` | `PhotoView.tsx` | `-` | - | +| `configs` | `configs.ts` | `InputTypeConfigItem, PluginInputTypeConfigItem` | - | +| `InputTypeSelector` | `index.tsx` | `-` | - | +| `function` | `Editor.tsx` | `-` | Copyright (c) Meta Platforms, Inc. and affiliates. This source code is licensed under the MIT licens | +| `HttpInput` | `index.tsx` | `-` | - | +| `MyNumberInput` | `index.tsx` | `-` | - | +| `SearchInput` | `index.tsx` | `-` | - | +| `LightTip` | `index.tsx` | `-` | - | +| `MarkdownTable` | `MarkdownTable.tsx` | `-` | - | +| `Markdown` | `index.tsx` | `-` | - | +| `utils` | `utils.ts` | `-` | Export table data to CSV format @param tableElement - HTML table element to export @param filename - | +| `FormLabel` | `FormLabel.tsx` | `-` | - | +| `MyBox` | `index.tsx` | `-` | - | +| `MyDivider` | `index.tsx` | `-` | - | +| `CustomRightDrawer` | `CustomRightDrawer.tsx` | `-` | - | +| `MyRightDrawer` | `MyRightDrawer.tsx` | `-` | - | +| `Loading` | `index.tsx` | `-` | - | +| `MultipleMenu` | `Multiple.tsx` | `MenuItemType, MenuSizeType, MenuItemData` | - | +| `MyMenu` | `index.tsx` | `MenuItemType, MenuSizeType, MenuItemData` | - | +| `EditFolderModal` | `EditFolderModal.tsx` | `EditFolderFormType` | - | +| `MyModal` | `index.tsx` | `MyModalProps` | - | +| `PopoverConfirm` | `PopoverConfirm.tsx` | `-` | - | +| `MyPopover` | `index.tsx` | `-` | - | +| `CronSelector` | `CronSelector.tsx` | `-` | - | +| `MultipleRowSelect` | `MultipleRowSelect.tsx` | `-` | - | +| `MultipleSelect` | `MultipleSelect.tsx` | `SelectProps` | - | +| `TimezoneSelect` | `TimezoneSelect.tsx` | `-` | - | +| `MySelect` | `index.tsx` | `SelectProps` | - | +| `InputSlider` | `InputSlider.tsx` | `-` | - | +| `IconTip` | `IconTip.tsx` | `-` | - | +| `QuestionTip` | `QuestionTip.tsx` | `-` | - | +| `MyTooltip` | `index.tsx` | `-` | - | +| `LeftRadio` | `LeftRadio.tsx` | `-` | - | +| `RadioGroup` | `RadioGroup.tsx` | `-` | - | +| `CopyBox` | `CopyBox.tsx` | `-` | - | +| `HighlightText` | `HighlightText.tsx` | `-` | - | +| `FillRowTabs` | `FillRowTabs.tsx` | `-` | - | +| `LightRowTabs` | `LightRowTabs.tsx` | `-` | - | +| `MyTag` | `index.tsx` | `TagProps` | - | +| `MyEditor` | `Editor.tsx` | `Props` | - | +| `CodeEditor` | `index.tsx` | `-` | - | +| `usePythonCompletion` | `usePythonCompletion.ts` | `-` | - | +| `CustomPromptEditor` | `index.tsx` | `-` | - | +| `JSONEditor` | `index.tsx` | `-` | - | +| `function` | `Editor.tsx` | `EditorProps` | Copyright (c) Meta Platforms, Inc. and affiliates. This source code is licensed under the MIT licens | +| `constants` | `constants.ts` | `-` | - | +| `PromptEditor` | `index.tsx` | `-` | - | +| `function` | `EditablePlugin.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `MarkdownTransformers` | `index.ts` | `-` | - | +| `MaxLengthPlugin` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `SingleLinePlugin` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `VariableLabel.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `node` | `node.tsx` | `SerializedVariableLabelNode` | - | +| `utils` | `utils.ts` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `function` | `Variable.tsx` | `-` | - | +| `function` | `index.tsx` | `-` | - | +| `node` | `node.tsx` | `SerializedVariableNode` | - | +| `utils` | `utils.ts` | `-` | - | +| `utils` | `utils.ts` | `MenuTextMatch, TriggerFn` | Copyright (c) Meta Platforms, Inc. and affiliates. This source code is licensed under the MIT licens | +| `UserBox` | `index.tsx` | `UserBoxProps` | - | +| `AreaChartComponent` | `AreaChartComponent.tsx` | `-` | - | +| `BarChartComponent` | `BarChartComponent.tsx` | `-` | - | +| `LineChartComponent` | `LineChartComponent.tsx` | `-` | - | + +## `packages/web/components/core/` + +| 组件 | 文件 | 导出类型 | 说明 | +|------|------|----------|------| +| `FileTypeSelector` | `index.tsx` | `-` | - | +| `BatchUpdateDrawer` | `BatchUpdateDrawer.tsx` | `-` | - | +| `ToolTagFilterBox` | `TagFilterBox.tsx` | `-` | - | +| `ToolCard` | `ToolCard.tsx` | `ToolCardItemType` | - | +| `ToolDetailDrawer` | `ToolDetailDrawer.tsx` | `-` | - | +| `components` | `components.tsx` | `-` | - | +| `hooks` | `hooks.ts` | `UseToolDetailProps` | - | +| `ToolDetail` | `index.ts` | `-` | - | +| `styles` | `styles.ts` | `-` | - | +| `types` | `types.ts` | `ToolDetailExtendedType, ToolDetailResponseType` | - | +| `NodeInputSelect` | `NodeInputSelect.tsx` | `-` | - | + +## `projects/app/src/components/` + +| 组件 | 文件 | 导出类型 | 说明 | +|------|------|----------|------| +| `Badge` | `index.tsx` | `-` | - | +| `CommunityModal` | `index.tsx` | `-` | - | +| `HelperBot` | `HelperBot.tsx` | `-` | - | +| `Auth` | `auth.tsx` | `-` | - | +| `useCheckCoupon` | `checkCoupon.ts` | `-` | - | +| `Layout` | `index.tsx` | `-` | - | +| `Navbar` | `navbar.tsx` | `-` | - | +| `NavbarPhone` | `navbarPhone.tsx` | `-` | - | +| `A` | `A.tsx` | `AProps` | - | +| `Guide` | `Guide.tsx` | `-` | - | +| `QuestionGuide` | `QuestionGuide.tsx` | `-` | - | +| `AudioBlock` | `Audio.tsx` | `-` | - | +| `CodeLight` | `CodeLight.tsx` | `-` | - | +| `IframeBlock` | `Iframe.tsx` | `-` | - | +| `VideoBlock` | `Video.tsx` | `-` | - | +| `IframeHtmlCodeBlock` | `iframe-html.tsx` | `-` | - | +| `hooks` | `hooks.ts` | `-` | - | +| `EChartsCodeBlock` | `EChartsCodeBlock.tsx` | `-` | - | +| `MdImage` | `Image.tsx` | `-` | - | +| `MermaidBlock` | `MermaidCodeBlock.tsx` | `-` | - | +| `Markdown` | `index.tsx` | `-` | - | +| `utils` | `utils.ts` | `-` | - | +| `MyImage` | `index.tsx` | `-` | - | +| `MyInput` | `index.tsx` | `-` | - | +| `PageContainer` | `index.tsx` | `-` | - | +| `ProModal` | `ProModal.tsx` | `-` | - | +| `ProText` | `ProText.tsx` | `-` | - | +| `ProTag` | `Tag.tsx` | `-` | - | +| `PromptTemplate` | `index.tsx` | `-` | - | +| `AIModelSelector` | `AIModelSelector.tsx` | `-` | - | +| `AppSelect` | `AppSelect.tsx` | `-` | - | +| `FileSelector` | `FileSelectorBox.tsx` | `SelectFileItemType` | - | +| `FileItemComponent` | `FileItem.tsx` | `-` | - | +| `FileList` | `FileList.tsx` | `-` | - | +| `FileUploadModal` | `index.tsx` | `FileUploadModalProps` | - | +| `useFileUpload` | `useFileUpload.ts` | `FileItem, UploadConfig` | - | +| `I18nLngSelector` | `I18nLngSelector.tsx` | `-` | - | +| `SideBar` | `index.tsx` | `-` | - | +| `SideTabs` | `index.tsx` | `Props` | - | +| `MySlider` | `index.tsx` | `-` | - | +| `ChatTip` | `index.tsx` | `-` | - | +| `HiddenInput` | `index.tsx` | `-` | - | +| `EditResourceModal` | `EditResourceModal.tsx` | `EditResourceInfoFormType` | - | +| `UseGuideModal` | `UseGuideModal.tsx` | `-` | - | +| `MyRadio` | `index.tsx` | `-` | - | +| `NextHead` | `index.tsx` | `-` | - | +| `OptimizerPopover` | `index.tsx` | `OptimizerPromptProps, OnOptimizePromptProps` | - | +| `VariableTip` | `VariableTip.tsx` | `-` | - | +| `MyTextarea` | `index.tsx` | `-` | - | +| `TagTextarea` | `TagTextarea.tsx` | `-` | - | +| `MoveModal` | `MoveModal.tsx` | `-` | - | +| `FolderPath` | `Path.tsx` | `-` | - | +| `SelectOneResource` | `SelectOneResource.tsx` | `-` | - | +| `FolderSlideCard` | `SlideCard.tsx` | `-` | - | +| `useFolderDrag` | `useFolderDrag.tsx` | `-` | - | +| `HeaderAuthConfig` | `HeaderAuthConfig.tsx` | `HeaderSecretConfigType` | - | +| `HeaderAuthForm` | `HeaderAuthForm.tsx` | `-` | - | +| `AIChatSettingsModal` | `index.tsx` | `AIChatSettingsModalProps` | - | +| `ModelTable` | `index.tsx` | `-` | - | +| `SettingLLMModel` | `index.tsx` | `-` | - | +| `AutoExecConfig` | `AutoExecConfig.tsx` | `-` | - | +| `DatasetParamsModal` | `DatasetParamsModal.tsx` | `-` | - | +| `DatasetSelect` | `DatasetSelect.tsx` | `-` | - | +| `DatasetSelectModal` | `DatasetSelectModal.tsx` | `-` | - | +| `FileSelect` | `FileSelect.tsx` | `-` | - | +| `FileSelector` | `index.tsx` | `-` | - | +| `InputGuideConfig` | `InputGuideConfig.tsx` | `-` | - | +| `QGConfig` | `QGConfig.tsx` | `-` | - | +| `ScheduledTriggerConfig` | `ScheduledTriggerConfig.tsx` | `-` | - | +| `TTSSelect` | `TTSSelect.tsx` | `-` | - | +| `ChatFunctionTip` | `Tip.tsx` | `-` | - | +| `VariableEdit` | `VariableEdit.tsx` | `-` | - | +| `VariableEditModal` | `index.tsx` | `-` | - | +| `WelcomeTextConfig` | `WelcomeTextConfig.tsx` | `-` | - | +| `WhisperConfig` | `WhisperConfig.tsx` | `-` | - | +| `QGConfig` | `QGConfig.tsx` | `-` | - | +| `LabelAndFormRender` | `LabelAndForm.tsx` | `-` | - | +| `TimeInput` | `TimeInput.tsx` | `-` | - | +| `constant` | `constant.ts` | `-` | - | +| `InputRender` | `index.tsx` | `-` | - | +| `utils` | `utils.ts` | `-` | - | +| `SfDatasetSelectModal` | `sfDatasetSelectModal.tsx` | `-` | - | +| `CostTooltip` | `CostTooltip.tsx` | `-` | - | +| `formValidation` | `formValidation.ts` | `-` | - | +| `ChatInput` | `ChatInput.tsx` | `-` | - | +| `function` | `InputGuideBox.tsx` | `-` | - | +| `VoiceInput` | `VoiceInput.tsx` | `VoiceInputComponentRef` | - | +| `Provider` | `Provider.tsx` | `ChatProviderProps` | - | +| `ChatAvatar` | `ChatAvatar.tsx` | `-` | - | +| `ChatController` | `ChatController.tsx` | `ChatControllerProps` | - | +| `ChatItem` | `ChatItem.tsx` | `-` | - | +| `ContextModal` | `ContextModal.tsx` | `-` | - | +| `EvaluationDatasetSelector` | `EvaluationDatasetSelector.tsx` | `-` | - | +| `FeedbackModal` | `FeedbackModal.tsx` | `-` | - | +| `FilesBlock` | `FilesBox.tsx` | `-` | - | +| `FilesCascader` | `FilesCascader.tsx` | `FileSelection` | - | +| `QuoteList` | `QuoteList.tsx` | `-` | - | +| `ResponseTags` | `ResponseTags.tsx` | `CitationRenderItem` | - | +| `SelectMarkCollection` | `SelectMarkCollection.tsx` | `AdminMarkType` | - | +| `TimeBox` | `TimeBox.tsx` | `-` | - | +| `VariableInputForm` | `VariableInputForm.tsx` | `-` | - | +| `WelcomeBox` | `WelcomeBox.tsx` | `-` | - | +| `ChatHistory` | `ChatHistory.tsx` | `-` | - | +| `ChatItem` | `ChatItem.tsx` | `-` | - | +| `ChatItemController` | `ChatItemController.tsx` | `ChatItemControllerProps` | - | +| `ChatHomeVariablesForm` | `ChatHomeVariablesForm.tsx` | `-` | - | +| `QuickApps` | `QuickApps.tsx` | `-` | - | +| `WelcomeHomeBox` | `WelcomeHomeBox.tsx` | `-` | - | +| `constants` | `constants.ts` | `-` | - | +| `useChatBox` | `useChatBox.tsx` | `-` | - | +| `useFileUpload` | `useFileUpload.tsx` | `-` | - | +| `ChatBoxContainer` | `index.tsx` | `-` | - | +| `utils` | `utils.ts` | `-` | - | +| `DeletedItemsCollapse` | `DeletedItemsCollapse.tsx` | `DeletedItemsCollapseProps` | - | +| `RenderInput` | `RenderInput.tsx` | `-` | - | +| `RenderOutput` | `RenderOutput.tsx` | `-` | - | +| `RenderResponseDetail` | `RenderResponseDetail.tsx` | `-` | - | +| `constants` | `constants.ts` | `-` | - | +| `PluginRunContextProvider` | `context.tsx` | `-` | - | +| `PluginRunBox` | `index.tsx` | `-` | - | +| `RenderFilePreview` | `FilePreview.tsx` | `-` | - | +| `VariablePopover` | `VariablePopover.tsx` | `-` | - | +| `workflowRuntimeContext` | `workflowRuntimeContext.tsx` | `-` | - | +| `ChatBoxDivider` | `index.tsx` | `-` | - | +| `AIResponseBox` | `AIResponseBox.tsx` | `-` | - | +| `ChatDetailModal` | `AssistantDetailModal.tsx` | `-` | - | +| `ChunkInfoCard` | `ChunkInfoCard.tsx` | `-` | - | +| `FaqContentCard` | `FaqContentCard.tsx` | `-` | - | +| `InteractiveComponents` | `InteractiveComponents.tsx` | `-` | - | +| `StandaloneResponseModal` | `StandaloneResponseModal.tsx` | `-` | - | +| `WholeResponseModal` | `WholeResponseModal.tsx` | `-` | - | +| `DatasetTypeTag` | `DatasetTypeTag.tsx` | `-` | - | +| `QuoteItem` | `QuoteItem.tsx` | `ScoreItemType` | - | +| `RawSourceBox` | `RawSourceBox.tsx` | `-` | - | +| `SearchParamsTip` | `SearchParamsTip.tsx` | `-` | - | +| `DatasetSelectContainer` | `SelectModal.tsx` | `-` | - | +| `ActivityAdModal` | `ActivityAdModal.tsx` | `-` | - | +| `ApiKeyTable` | `Table.tsx` | `-` | - | +| `LafAccountModal` | `LafAccountModal.tsx` | `-` | - | +| `ChangeOwnerModal` | `index.tsx` | `ChangeOwnerModalProps` | - | +| `ConfigPerModal` | `index.tsx` | `ConfigPerModalProps` | - | +| `DefaultPermissionList` | `index.tsx` | `-` | - | +| `GroupTags` | `GroupTags.tsx` | `-` | - | +| `PermissionIconText` | `index.tsx` | `-` | - | +| `MemberItemCard` | `MemberItemCard.tsx` | `-` | - | +| `MemberListCard` | `MemberListCard.tsx` | `MemberListCardProps` | - | +| `MemberModal` | `MemberModal.tsx` | `-` | - | +| `RoleSelect` | `RoleSelect.tsx` | `PermissionSelectProps` | - | +| `RoleTags` | `RoleTags.tsx` | `PermissionTagsProp` | - | +| `CollaboratorContextProvider` | `context.tsx` | `MemberManagerInputPropsType, CollaboratorContextType, ChildrenProps` | - | +| `PermissionTag` | `index.tsx` | `-` | - | +| `PermissionRadio` | `index.tsx` | `-` | - | +| `ResumeInherit` | `index.tsx` | `-` | - | +| `ImportantInform` | `ImportantInform.tsx` | `-` | - | +| `SystemMsgModal` | `SystemMsgModal.tsx` | `-` | - | +| `UpdateContactModal` | `UpdateContactModal.tsx` | `-` | - | +| `ResetPswModal` | `ResetExpiredPswModal.tsx` | `-` | - | +| `SendCodeAuthModal` | `SendCodeAuthModal.tsx` | `-` | - | +| `MemberTag` | `MemberTag.tsx` | `-` | - | +| `OrgTags` | `index.tsx` | `-` | - | +| `TeamTagsAsync` | `index.tsx` | `-` | - | +| `NotSufficientModal` | `index.tsx` | `-` | - | +| `QRCodePayModal` | `QRCodePayModal.tsx` | `QRPayProps` | - | +| `StandardPlanContentList` | `StandardPlanContentList.tsx` | `-` | - | + diff --git a/.claude/skills/component-guide/gen-component-guide.mjs b/.claude/skills/component-guide/gen-component-guide.mjs new file mode 100644 index 000000000000..95008876a64f --- /dev/null +++ b/.claude/skills/component-guide/gen-component-guide.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/** + * 组件使用指南生成脚本 + * + * 功能: + * 1. 扫描 projects/app/src/components 和 packages/web/components 下所有组件文件的修改时间 + * 2. 计算修改时间 Hash,与 SKILL.md frontmatter 中记录的 snapshot_hash 对比 + * 3. 若不一致,重新扫描组件信息并将快照写入 component-snapshot.md + * + * 用法: + * node .claude/skills/common/skills/component-guide/gen-component-guide.mjs # 自动检测变更后更新 + * node .claude/skills/common/skills/component-guide/gen-component-guide.mjs --force # 强制重新生成 + * node .claude/skills/common/skills/component-guide/gen-component-guide.mjs --check # 仅检查是否需要更新 + */ + +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; + +// 脚本位于 .claude/skills/common/skills/component-guide/,向上 5 级为项目根目录 +const SKILL_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SKILL_DIR, '../../../../..'); + +// ─── 配置 ──────────────────────────────────────────────────────────────────── + +const SCAN_DIRS = [ + 'projects/app/src/components', + 'packages/web/components' +]; + +const SKILL_FILE = path.join(SKILL_DIR, 'SKILL.md'); +const SNAPSHOT_FILE = path.join(SKILL_DIR, 'component-snapshot.md'); + +const INCLUDE_EXTS = ['.tsx', '.ts']; + +// Icon 子目录为纯图标资源、d.ts 为类型声明,跳过 +const EXCLUDE_PATTERNS = [ + /\/Icon\/icons\//, + /\.d\.ts$/, + /__tests__/ +]; + +// ─── 工具函数 ───────────────────────────────────────────────────────────────── + +function walkDir(dir, results = []) { + if (!fs.existsSync(dir)) return results; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(fullPath, results); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (INCLUDE_EXTS.includes(ext)) { + const relPath = path.relative(ROOT, fullPath).replace(/\\/g, '/'); + if (!EXCLUDE_PATTERNS.some((p) => p.test(relPath))) { + results.push(fullPath); + } + } + } + } + return results; +} + +/** + * 计算文件列表的修改时间 Hash(用于快速变更检测) + */ +function computeSnapshotHash(files) { + const entries = files + .sort() + .map((f) => { + const rel = path.relative(ROOT, f).replace(/\\/g, '/'); + const mtime = fs.statSync(f).mtimeMs; + return `${rel}:${mtime}`; + }) + .join('\n'); + return crypto.createHash('md5').update(entries).digest('hex'); +} + +/** + * 从 SKILL.md 的 frontmatter 中提取 snapshot_hash + */ +function readSkillHash() { + if (!fs.existsSync(SKILL_FILE)) return ''; + const content = fs.readFileSync(SKILL_FILE, 'utf8'); + const match = content.match(/^snapshot_hash:\s*"?([^"\n]*)"?/m); + return match ? match[1].trim() : ''; +} + +/** + * 更新 SKILL.md frontmatter 中的 snapshot_hash 和 snapshot_updated_at + */ +function updateSkillFrontmatter(hash) { + let content = fs.readFileSync(SKILL_FILE, 'utf8'); + const now = new Date().toISOString().replace('T', ' ').substring(0, 19); + content = content.replace( + /^snapshot_hash:\s*"?[^"\n]*"?/m, + `snapshot_hash: "${hash}"` + ); + content = content.replace( + /^snapshot_updated_at:\s*"?[^"\n]*"?/m, + `snapshot_updated_at: "${now}"` + ); + fs.writeFileSync(SKILL_FILE, content, 'utf8'); +} + +/** + * 从 .tsx 文件中提取基本信息(组件名、导出的 Props 类型、顶部注释) + */ +function extractComponentInfo(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const relPath = path.relative(ROOT, filePath).replace(/\\/g, '/'); + + // 提取导出的类型/接口名 + const exportTypes = []; + for (const m of content.matchAll(/^export\s+(?:type|interface)\s+(\w+)/gm)) { + exportTypes.push(m[1]); + } + + // 提取默认导出的组件名 + let componentName = ''; + const defaultExportMatch = + content.match(/export\s+default\s+(?:React\.memo\()?(?:forwardRef\()?(\w+)/) || + content.match(/const\s+(\w+)\s*[=:][^;]+\n.*export\s+default/s); + if (defaultExportMatch) { + componentName = defaultExportMatch[1]; + } + if (!componentName) { + componentName = path.basename(filePath, path.extname(filePath)); + if (componentName === 'index') { + componentName = path.basename(path.dirname(filePath)); + } + } + + // 提取文件顶部 /** ... */ 注释 + let description = ''; + const blockComment = content.match(/^\/\*\*\s*\n([\s\S]*?)\*\//); + if (blockComment) { + description = blockComment[1] + .split('\n') + .map((l) => l.replace(/^\s*\*\s?/, '').trim()) + .filter(Boolean) + .join(' ') + .substring(0, 100); + } + + return { componentName, relPath, description, exportTypes }; +} + +/** + * 生成 component-snapshot.md 的完整内容 + */ +function buildSnapshotMarkdown(files, hash) { + const updatedAt = new Date().toISOString().replace('T', ' ').substring(0, 19); + + // 按 4 级目录分组 + const groups = {}; + for (const f of files) { + const info = extractComponentInfo(f); + const dir = path.dirname(info.relPath).split('/').slice(0, 4).join('/'); + if (!groups[dir]) groups[dir] = []; + groups[dir].push(info); + } + + let md = `# 组件快照(自动生成)\n\n`; + md += `> **请勿手动编辑此文件**,由 \`gen-component-guide.mjs\` 脚本自动维护。\n`; + md += `> 最后更新:${updatedAt} | Hash:\`${hash.substring(0, 8)}\`\n\n`; + + const sortedDirs = Object.keys(groups).sort(); + for (const dir of sortedDirs) { + const items = groups[dir]; + md += `## \`${dir}/\`\n\n`; + md += `| 组件 | 文件 | 导出类型 | 说明 |\n`; + md += `|------|------|----------|------|\n`; + for (const item of items) { + const fileBasename = path.basename(item.relPath); + const types = item.exportTypes.slice(0, 3).join(', ') || '-'; + const desc = item.description || '-'; + md += `| \`${item.componentName}\` | \`${fileBasename}\` | \`${types}\` | ${desc} |\n`; + } + md += `\n`; + } + + return md; +} + +// ─── 主流程 ────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const forceUpdate = args.includes('--force'); + const checkOnly = args.includes('--check'); + + console.log('📦 FastGPT 组件使用指南生成工具\n'); + console.log(`📂 项目根目录:${ROOT}`); + console.log(`📂 扫描目录:`); + SCAN_DIRS.forEach((d) => console.log(` ${d}`)); + console.log(''); + + // 1. 收集所有组件文件 + const allFiles = []; + for (const dir of SCAN_DIRS) { + walkDir(path.join(ROOT, dir), allFiles); + } + console.log(`🔍 发现组件文件:${allFiles.length} 个`); + + // 2. 计算当前快照 Hash + const currentHash = computeSnapshotHash(allFiles); + console.log(`🔑 当前快照 Hash:${currentHash.substring(0, 8)}...`); + + // 3. 读取已记录的 Hash + const recordedHash = readSkillHash(); + if (recordedHash) { + console.log(`📝 记录快照 Hash:${recordedHash.substring(0, 8)}...`); + } else { + console.log(`📝 记录快照 Hash:(无记录,首次生成)`); + } + + // 4. 判断是否需要更新 + if (!forceUpdate && currentHash === recordedHash) { + console.log('\n✅ 组件库无变化,跳过更新。可使用 --force 强制重新生成。'); + return; + } + + if (checkOnly) { + console.log('\n⚠️ 检测到变更,但运行于 --check 模式,未修改文件。'); + console.log( + ' 请运行 node .claude/skills/common/skills/component-guide/gen-component-guide.mjs 来更新指南。' + ); + process.exit(1); + } + + console.log('\n🔄 检测到变更,正在更新组件指南...'); + + // 5. 生成并写入 component-snapshot.md + const snapshotMd = buildSnapshotMarkdown(allFiles, currentHash); + fs.writeFileSync(SNAPSHOT_FILE, snapshotMd, 'utf8'); + console.log(` ✓ 写入 component-snapshot.md(${allFiles.length} 个组件)`); + + // 6. 更新 SKILL.md frontmatter + updateSkillFrontmatter(currentHash); + console.log(` ✓ 更新 SKILL.md 快照 Hash`); + + console.log(`\n✅ 更新完成!`); + console.log(` 新快照 Hash:${currentHash.substring(0, 8)}...`); + console.log(`\n💡 出码前请阅读 component-api.md 中的组件使用指南。`); +} + +main().catch((e) => { + console.error('❌ 脚本执行失败:', e.message); + process.exit(1); +}); diff --git a/.claude/skills/create-mr/SKILL.md b/.claude/skills/create-mr/SKILL.md new file mode 100644 index 000000000000..421a5e6776c1 --- /dev/null +++ b/.claude/skills/create-mr/SKILL.md @@ -0,0 +1,53 @@ +--- +name: create-mr +description: TD 修复完成后,读取 GitLab 配置创建 Merge Request +--- + +# Create GitLab Merge Request + +TD 修复完成后,调用此 skill 创建 GitLab MR。 + +## 配置说明 + +从 `projects/app/.env.local` 中读取 GitLab 配置,支持以下字段: + +| 环境变量 | 说明 | 默认值 | +|----------|------|--------| +| `GITLAB_PRIVATE_TOKEN` | GitLab Personal Access Token | `**` | +| `GITLAB_BASE_URL` | GitLab API 基础地址 | `http://mq.code.sangfor.org` | +| `GITLAB_PROJECT_ID` | GitLab 项目 ID | `15836` | +| `GITLAB_TARGET_BRANCH` | MR 目标分支 | `develop-1.3.0` | + +如果 `.env.local` 中未配置,则使用上述默认值。 + +## 执行步骤 + +### 1. 获取配置和分支名 + +```bash +cd "d:/ai/FastGPT-1.3.0" +branch=$(git rev-parse --abbrev-ref HEAD) +td_id="<由 Agent 从分支名或上下文提取的 TD 编号>" +``` + +### 2. 读取 .env.local 并设置环境变量 + +```bash +cd "d:/ai/FastGPT-1.3.0" + +export GITLAB_PRIVATE_TOKEN=$(grep -oP 'GITLAB_PRIVATE_TOKEN=\K.*' projects/app/.env.local 2>/dev/null || echo '**') +export GITLAB_BASE_URL=$(grep -oP 'GITLAB_BASE_URL=\K.*' projects/app/.env.local 2>/dev/null || echo 'http://mq.code.sangfor.org') +export GITLAB_PROJECT_ID=$(grep -oP 'GITLAB_PROJECT_ID=\K.*' projects/app/.env.local 2>/dev/null || echo '15836') +export GITLAB_TARGET_BRANCH=$(grep -oP 'GITLAB_TARGET_BRANCH=\K.*' projects/app/.env.local 2>/dev/null || echo 'develop-1.3.0') +export BRANCH="$branch" +export TD_ID="$td_id" + +node .claude/skills/create-mr/create-mr.js +``` + +脚本路径:`.claude/skills/create-mr/create-mr.js` +- 通过环境变量读取配置 +- 从 `.frieren/agent-tasks/TD/${TD_ID}.md` 读取 TD 文档内容 +- MR title: `WIP: fix: {TD_ID} {问题摘要}` +- MR description: `TD 链接: {TD_URL}\n\n{TD文档全文}` +- MR draft: true, 完成后删除 source branch diff --git a/.claude/skills/create-mr/create-mr.js b/.claude/skills/create-mr/create-mr.js new file mode 100644 index 000000000000..0cdfa2b1f69c --- /dev/null +++ b/.claude/skills/create-mr/create-mr.js @@ -0,0 +1,76 @@ +const http = require('http'); +const fs = require('fs'); + +const GITLAB_PRIVATE_TOKEN = process.env.GITLAB_PRIVATE_TOKEN || '**'; +const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'http://mq.code.sangfor.org'; +const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '15836'; +const GITLAB_TARGET_BRANCH = process.env.GITLAB_TARGET_BRANCH || 'develop-1.3.0'; +const branch = process.env.BRANCH || ''; +const tdId = process.env.TD_ID || ''; + +if (!branch || !tdId) { + console.error('❌ 请设置 BRANCH 和 TD_ID 环境变量'); + process.exit(1); +} + +const tdUrl = 'https://td.sangfor.com/#/defect/details/' + tdId; +const tdFile = '.frieren/agent-tasks/TD/' + tdId + '.md'; + +let tdContent = ''; +try { + tdContent = fs.readFileSync(tdFile, 'utf8'); +} catch (e) { + console.error('⚠ TD 文档不存在: ' + tdFile); +} + +const firstHeading = (tdContent.split('\n').find(l => l.match(/^####/)) || '') + .replace(/^####\s*【/, '') + .replace(/】.*/, ''); + +const title = 'WIP: fix: ' + tdId + ' ' + firstHeading; +const description = 'TD 链接: ' + tdUrl + '\n\n' + tdContent; + +const body = JSON.stringify({ + source_branch: branch, + target_branch: GITLAB_TARGET_BRANCH, + title: title, + description: description, + remove_source_branch: true +}); + +const baseUrl = GITLAB_BASE_URL.replace(/\/+$/, ''); +const u = new URL(baseUrl + '/api/v4/projects/' + GITLAB_PROJECT_ID + '/merge_requests'); + +const options = { + hostname: u.hostname, + port: u.port || 80, + path: u.pathname, + method: 'POST', + headers: { + 'PRIVATE-TOKEN': GITLAB_PRIVATE_TOKEN, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } +}; + +const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + const result = JSON.parse(data); + if (result.web_url) { + console.log('✅ MR Created!'); + console.log('MR URL:', result.web_url); + console.log('MR IID:', result.iid); + } else { + console.log('❌ Failed:', JSON.stringify(result, null, 2)); + } + } catch (e) { + console.log('❌ Response:', data); + } + }); +}); +req.on('error', (e) => console.error('❌ Error:', e.message)); +req.write(body); +req.end(); diff --git a/.claude/skills/i18n/SKILL.md b/.claude/skills/i18n/SKILL.md new file mode 100644 index 000000000000..df6c9699c471 --- /dev/null +++ b/.claude/skills/i18n/SKILL.md @@ -0,0 +1,197 @@ +--- +name: i18n +description: 项目 i18n 国际化词条规范检查,确保生成代码中的翻译词条格式正确、三语种同步、命名空间使用规范。 +--- + +# i18n 词条规范 + +## 概述 + +本项目使用 `next-i18next` 进行国际化,词条文件位于 `packages/web/i18n/` 下,支持三个语种: + +| 语种 | 目录 | 说明 | +|------|------|------| +| 简体中文 | `packages/web/i18n/zh-CN/` | 主语种 | +| 英文 | `packages/web/i18n/en/` | 英文 | +| 繁体中文 | `packages/web/i18n/zh-Hant/` | 需要单独翻译为繁体 | + +命名空间定义在 `packages/web/i18n/constants.ts` 的 `I18N_NAMESPACES` 数组中。 + +## 规则 + +### 1. t() 调用必须使用 `namespace:key` 格式 + +代码中所有 `t()` 调用必须带命名空间前缀,格式为 `t('namespace:key')`。 + +### 2. key 命名规范 + +- 使用英文单词,采用 `PascalCase` 或 `snake_case` 风格(与所在文件已有风格保持一致) +- 支持点号分隔的层级 key,如 `core.app.Name` +- key 应语义化,能反映词条含义 +- 禁止直接使用中文作为 key + +### 3. 三语种同步 + +新增词条时,必须同时在三个语种文件中添加对应 key: +- `zh-CN/{namespace}.json` — 简体中文值 +- `en/{namespace}.json` — 英文值(与中文值保持一致即可) +- `zh-Hant/{namespace}.json` — 繁体中文值(需正确转换) + +### 4. 命名空间选择 + +根据词条所属业务模块选择对应的命名空间文件。常用命名空间: +- `common` — 通用词条(操作、状态等) +- `app` — 应用相关 +- `dataset` — 知识库相关 +- `workflow` — 工作流相关 +- `chat` — 聊天相关 +- 其他参见 `packages/web/i18n/constants.ts` + +### 5. 变量插值 + +使用 `{{variable}}` 语法进行变量插值: +``` +"tool_count": "工具列表: {{total}}" +``` +调用时传入参数:`t('app:tool_count', { total: 5 })` + +## 正确用例 + +### 示例 1:新增简单词条 + +需求:页面中需要显示"学习"文案。 + +代码中使用: +```tsx +t('app:learn') +``` + +三语种词条文件同步新增: + +`packages/web/i18n/zh-CN/app.json`: +```json +"learn": "学习" +``` + +`packages/web/i18n/en/app.json`: +```json +"learn": "learn" +``` + +`packages/web/i18n/zh-Hant/app.json`: +```json +"learn": "學習" +``` + +### 示例 2:新增带变量的词条 + +需求:显示"已选择 N 个文件"。 + +代码中使用: +```tsx +t('file:selected_file_count', { count: selectedFiles.length }) +``` + +`packages/web/i18n/zh-CN/file.json`: +```json +"selected_file_count": "已选择 {{count}} 个文件" +``` + +`packages/web/i18n/en/file.json`: +```json +"selected_file_count": "已选择 {{count}} 个文件" +``` + +`packages/web/i18n/zh-Hant/file.json`: +```json +"selected_file_count": "已選擇 {{count}} 個檔案" +``` + +### 示例 3:新增层级 key + +需求:应用设置中的"基础配置"。 + +代码中使用: +```tsx +t('app:setting.basic_config') +``` + +`packages/web/i18n/zh-CN/app.json`: +```json +"setting.basic_config": "基础配置" +``` + +`packages/web/i18n/en/app.json`: +```json +"setting.basic_config": "basic config" +``` + +`packages/web/i18n/zh-Hant/app.json`: +```json +"setting.basic_config": "基礎配置" +``` + +## 错误用例 + +### 错误 1:直接使用中文字符串,未走 i18n + +```tsx +// ❌ 错误:硬编码中文 + + +// ✅ 正确:使用 t() 函数 + +``` + +### 错误 2:t() 调用缺少命名空间 + +```tsx +// ❌ 错误:缺少命名空间前缀 +t('learn') + +// ✅ 正确:带命名空间 +t('app:learn') +``` + +### 错误 3:使用中文作为 key + +```tsx +// ❌ 错误:中文 key +t('app:学习') + +// ✅ 正确:英文 key +t('app:learn') +``` + +### 错误 4:只添加了部分语种 + +``` +// ❌ 错误:只在 zh-CN 中添加了词条,en 和 zh-Hant 缺失 + +// ✅ 正确:三个语种文件必须同步添加 +``` + +### 错误 5:繁体中文直接复制简体 + +```json +// ❌ 错误:zh-Hant 中使用简体 +"learn": "学习" + +// ✅ 正确:zh-Hant 使用繁体 +"learn": "學習" +``` + + +## 检查清单 + +生成或修改含 i18n 词条的代码时,按以下清单逐项检查: + +- [ ] `t()` 调用是否使用了 `namespace:key` 格式 +- [ ] key 是否为英文,且命名语义化 +- [ ] key 命名风格是否与所在 JSON 文件已有风格一致 +- [ ] 是否在 `zh-CN`、`en`、`zh-Hant` 三个语种文件中同步添加 +- [ ] `zh-CN` 和 `en` 的值是否一致 +- [ ] `zh-Hant` 的值是否为正确的繁体中文 +- [ ] 命名空间是否选择了正确的业务模块 +- [ ] 变量插值是否使用 `{{variable}}` 语法 +- [ ] 新增词条是否与已有词条重复(优先复用已有词条) diff --git a/.claude/skills/img-to-docs/SKILL.md b/.claude/skills/img-to-docs/SKILL.md new file mode 100644 index 000000000000..b63573fd7f99 --- /dev/null +++ b/.claude/skills/img-to-docs/SKILL.md @@ -0,0 +1,345 @@ +--- +name: img-to-docs +description: Generates structured requirement development guides from UI screenshots or design files. TRIGGER when user asks to「生成开发文档」「写需求文档」「拆解开发任务」「输出技术方案」「分析功能需求」「写设计文档」, uploads screenshots for UI analysis, or provides a MasterGo design link. DO NOT TRIGGER when user is only discussing code implementation details, fixing bugs, doing code review, or asking technical questions without document output intent. +--- + +# UI 需求分析 — 结构化需求开发指南生成 + +## 目录 + +- [When to Use This Skill](#when-to-use-this-skill) +- [How It Works](#how-it-works) +- [AI-TODO 约定](#ai-todo-约定) +- [步骤 0:获取设计稿 DSL](#步骤-0获取设计稿-dsl可选) +- [分析维度一:原子组件识别](#分析维度一原子组件识别) +- [分析维度二:布局结构分析](#分析维度二布局结构分析) +- [分析维度三:交互行为清单](#分析维度三交互行为清单) +- [分析维度四:边界条件与异常状态](#分析维度四边界条件与异常状态) +- [分析维度五:需求开发任务拆解](#分析维度五需求开发任务拆解) +- [分析维度六:验收检查清单](#分析维度六验收检查清单) +- [分析维度七:执行文档](#分析维度七执行文档) +- [输出格式要求](#输出格式要求) + +--- + +## When to Use This Skill + +- 用户要求「生成开发文档」「写需求文档」「写设计文档」「输出技术方案」 +- 用户要求「拆解开发任务」「需求拆解」「任务拆分」「分析功能需求」「输出开发指南」 +- 用户上传截图并要求「分析 UI」「生成需求」「写文档」 +- 用户提供 MasterGo 设计链接要求生成需求文档 + +**DO NOT TRIGGER when**:用户仅讨论代码实现细节、修复 bug、代码审查、技术问答(无明确文档输出意图)。 + +--- + +## How It Works + +1. **(可选)获取设计稿 DSL** — 若用户提供 MasterGo 链接,调用 `mcp_getDsl` 获取精确布局数值 +2. **原子组件识别** — 调用 `/component-guide` skill,将 UI 元素映射到项目已有组件 +3. **布局结构分析** — 从宏观到微观描述页面布局、间距、响应式规律 +4. **交互行为清单** — 逐一列出"做了 XX → 出现 XX"的可观察行为,标记 AI-TODO +5. **边界条件** — 识别禁用态、异常态、空态等边界场景 +6. **任务拆解** — 区分前端/后端,输出带实现要点的可独立开发子任务 +7. **验收脚本** — 输出 `agent-browser` 可执行命令,覆盖每条交互行为的验收点 +8. **执行文档** — 验收通过后汇总变更文件清单、验收结果、遗留问题 + +--- + +## Examples + +### Example 1:截图分析 + +**User Request**: 上传了一张「新增知识库弹窗」截图,要求「分析这个 UI,生成开发文档」 + +**Action**: +1. 识别弹窗中的原子组件(Modal、Input、Button) +2. 映射到项目组件(`MyModal`、`MyInput`、Chakra `Button`) +3. 分析布局(Column 布局,gap=4,底部操作区 justify=flex-end) +4. 输出交互行为清单(IB-01 点击确认 → 弹窗关闭 + Toast 成功) +5. 拆解前端任务 Task-FE-01 和后端任务 Task-BE-01 +6. 输出 AC-001 agent-browser 验收脚本 + +**Output 片段**: +```markdown +### 一、原子组件识别 +| 通用组件名 | 项目映射组件 | 导入路径 | +|-----------|------------|---------| +| Modal | `MyModal` | `@fastgpt/web/components/common/MyModal` | +| Input | `MyInput` | `@fastgpt/web/components/common/MyInput` | +``` + +### Example 2:MasterGo 设计稿链接 + +**User Request**: 提供 MasterGo 链接 `https://seerdesignmg.sangfor.com/...`,要求「按设计稿生成需求开发文档」 + +**Action**: +1. 调用 `mcp_getDsl` 获取组件层级树和精确间距数值 +2. 优先使用 DSL 数值(如 padding=16px → Chakra `p=4`)而非截图目测 +3. 后续分析流程与 Example 1 相同,但数值更精确 + +**Output**: 同 Example 1,但布局数值来自 DSL,精确度更高。 + +### Example 3:纯文字需求 + +**User Request**: 「我需要在知识库列表页新增一个批量删除功能,请拆解开发任务」 + +**Action**: +1. 跳过步骤 0(无设计稿)和一、二(无 UI 截图) +2. 直接输出三(交互行为清单)、四(边界条件)、五(任务拆解) +3. 对 UI 细节打 AI-TODO 标记(截图未提供,不猜测) + +--- + +## AI-TODO 约定 + +**凡是无法从截图、DSL 或用户描述中确定的业务逻辑,必须用 `AI-TODO` 标记,而不是猜测或留空。** + +### 格式规范 + +``` +// AI-TODO[T-01]: <不确定的内容> | 建议确认方式: <如何澄清> +``` + +- `<编号>` 格式为 `T-01`、`T-02`……,全文唯一递增 +- 不确定内容须具体(行为未知 / 文案未知 / 接口字段未知 / 权限逻辑未知) + +### 必须打 AI-TODO 的场景 + +| 场景 | 示例 | +|------|------| +| 截图未展示的失败/空/加载态 | 提交失败时的错误提示文案 | +| 操作权限条件不明确 | 哪些角色可以编辑/删除 | +| 数据排序/分页规则不明 | 新增后排在列表头部还是尾部 | +| 接口字段名称/类型不确定 | 后端返回字段含义未知 | +| 业务规则存在多种可能 | 删除时是软删除还是硬删除 | + +### 文档末尾汇总 + +每份文档末尾必须输出 **AI-TODO 汇总表**: + +```markdown +## AI-TODO 汇总(待人工确认) + +| 编号 | 所在章节 | 不确定内容 | 建议确认方式 | +|------|---------|-----------|------------| +| T-01 | 三、交互行为 | 提交失败时的 Toast 文案 | 询问产品/设计 | +| T-02 | 五、后端任务 | /api/xxx 接口的 Response 字段结构 | 查阅后端接口文档 | +``` + +> 若所有内容均已明确,输出「无 AI-TODO 待处理」。 + +--- + +## 步骤 0:获取设计稿 DSL(可选) + +**判断条件**:用户消息中是否包含 MasterGo 设计稿链接(`https://seerdesignmg.sangfor.com/...`)。 + +- ✅ **有链接** → 调用 `mcp_getDsl`,提取组件层级树、布局数值、文本内容 +- ⏩ **无链接** → 跳过,仅依赖截图 + +**DSL 数据优先级**:DSL 数值 > 截图目测值(间距、字号、颜色 token 以 DSL 为准)。 + +--- + +## 分析维度一:原子组件识别 + +**第一步**:调用 `/component-guide` skill 获取项目组件快照。 + +**第二步**:填写组件识别表格: + +| 通用组件名 | 项目映射组件 | 导入路径 | 位置描述 | 视觉状态 | 文案/占位符 | +|-----------|------------|---------|---------|---------|------------| +| Modal | `MyModal` | `@fastgpt/web/components/common/MyModal` | 页面中央 | 打开态 | 标题"新增分块" | +| Button | Chakra `Button` | `@chakra-ui/react` | 头部右上角 | 正常态 | 新增 | + +**组件优先级**(从高到低,严格按此顺序): + +1. **项目公共组件**(`component-guide` 🔴必须优先):`MyModal`、`MyTooltip`、`MyMenu`、`MySelect`、`MyPopover`、`EmptyTip`、`MyBox` 等 → 禁止重复实现 +2. **项目业务组件**(`component-guide` 🟡业务组件):`PromptEditor`、`CodeEditor` 等 +3. **Chakra UI 原生组件**:`Button`、`IconButton`、`Badge` 等 +4. **HTML 原生元素**(兜底):`div`、`span` 等 + +--- + +## 分析维度二:布局结构分析 + +#### 2.1 整体布局 + +| 维度 | 描述 | +|------|------| +| 布局方向 | 横向(Row)/ 纵向(Column)/ 网格(Grid) | +| 主要区域划分 | 头部 / 左侧边栏 / 内容区 / 右侧面板 / 底部工具栏 | +| 内容区滚动方向 | 纵向滚动 / 横向滚动 / 无滚动 | +| 固定/粘性元素 | 哪些区域固定在视口(sticky/fixed) | + +#### 2.2 间距与对齐规律 + +- **间距规律**:主要间距值(如 `gap: 4`、`p: 3` 等 Chakra token) + > 若已获取 DSL,直接从 `padding`/`gap` 字段读取精确数值并转换为 Chakra token +- **对齐方式**:`justify`(主轴对齐)/ `align`(交叉轴对齐) +- **层叠关系**:弹窗、Tooltip、下拉菜单的层级顺序 + +#### 2.3 响应式断点(如有) + +列出不同屏幕宽度下布局的变化规律(侧边栏折叠、列数变化等)。 + +--- + +## 分析维度三:交互行为清单 + +> 格式:每条用 "**做了 XX → 出现 XX**" 描述,验收点(AC-xxx)直接基于此生成。 +> AI-TODO 规则:若无法判断某交互响应,在「页面响应」列填写 `// AI-TODO[T-xx]`。 + +| 编号 | 触发操作(做了 XX) | 页面响应(出现 XX) | 对应验收点 | +|------|------------------|------------------|-----------| +| IB-01 | 点击"新增"按钮 | 弹出新增弹窗,标题为"新增分块" | AC-001 | +| IB-02 | 弹窗内输入内容后点击"确认" | 弹窗关闭,列表顶部新增一条记录 | AC-002 | + +重点关注:状态变化(按钮禁用/启用)、数据流向(新增排列位置)、编号/序号规则。 + +--- + +## 分析维度四:边界条件与异常状态 + +- 哪些操作在特定条件下被禁用?原因是什么? +- 禁用状态下的 Tooltip 提示文案? +- 操作成功/失败时的反馈形式(Toast / 错误提示等) + +> AI-TODO 规则:截图中未展示的异常态(网络错误、无权限、数据为空、超出限制等)均须打标记,不得猜测业务规则。 + +--- + +## 分析维度五:需求开发任务拆解 + +每个任务包含:任务编号(标注 `[前端]`/`[后端]`)、涉及文件、前置依赖、实现要点。 + +#### 前端任务格式 + +``` +### Task-FE-01:[前端] 新增分块按钮与弹窗 +所属端:前端 +涉及文件: + - projects/app/src/pageComponents/xxx/index.tsx +实现要点: + 1. 在头部区域新增"新增分块"Button(variant="primary") + 2. 点击后打开弹窗,复用 MyModal + 3. 提交成功后调用 onSuccess,Toast 显示成功文案 + // AI-TODO[T-xx]: 提交失败时的错误处理方式 | 建议确认方式: 询问产品 +代码规范: + - props 用 type 而非 interface + - 样式使用 Chakra UI sx prop +``` + +#### 后端任务格式 + +``` +### Task-BE-01:[后端] 新增分块接口 +所属端:后端 +涉及文件: + - projects/app/src/pages/api/core/dataset/data/insert.ts +接口设计: + - Method: POST / Path: /api/core/dataset/data/insert + - Request: { datasetId, collectionId, q, a? } + - Response: { chunkIndex: number, rebuilding: boolean } + // AI-TODO[T-xx]: Response 是否返回新记录的 _id | 查阅后端接口文档 +实现要点: + 1. 校验写权限 + 2. 写入 MongoDB,触发向量索引重建 +``` + +--- + +## 分析维度六:验收检查清单 + +> 输出可由 `agent-browser` 执行的命令序列。详细命令参数见 [references/commands.md](references/commands.md)。 + +#### 快照驱动协议 + +每次 `agent-browser snapshot -i` 后必须:读取元素列表 → 判断页面状态 → 选择分支继续执行。最多重试 2 次,仍失败则输出诊断信息并标记 ❌。 + +**参数组合推荐**(优先顺序): +1. `agent-browser snapshot -i -c` — 通用交互场景(90% 场景,Token 最小化) +2. `agent-browser snapshot -i -s "#selector"` — 局部操作场景 +3. `agent-browser snapshot -i -c -d 4` — 长页面/复杂页面 +4. `agent-browser snapshot` — 最后考虑 + +#### 通用登录流程 + +```bash +agent-browser open <目标URL> --headed +agent-browser wait --load networkidle +agent-browser snapshot -i +# [条件 A] 含登录表单 → 执行登录子流程 +agent-browser fill "<账号>" +agent-browser fill "<密码>" +agent-browser find role button click --name "登录" +agent-browser wait --load networkidle +# [条件 B] 已登录 → 直接跳过 +agent-browser open <目标功能URL> +agent-browser wait --load networkidle +``` + +#### 验收点格式(与 IB-xx 一一对应) + +```bash +# AC-001:<从 IB-xx 提取的验收点名称> +# 前置条件:<页面状态描述> + +agent-browser snapshot -i -c +# ▼ 找到文案为"<真实按钮文案>"的 Button,记为 @btn-<语义名称> +# [条件] 未找到 → 输出诊断信息,标记 ❌ + +agent-browser click @btn-<语义名称> +agent-browser wait --load networkidle +agent-browser snapshot -i -c +# ▼ [✅] 期望元素出现 / [❌] 重试 2 次后仍未出现 +``` + +**期望结果**(每条可观察): +- [ ] `` + +#### 诊断输出规范(失败时) + +``` +验收失败: <验收点名称> +失败原因:<期望 XX 出现,但未找到> +当前页面快照摘要:<主要元素类型和文案> +重试次数:/2 +建议排查:<可能原因> +``` + +--- + +## 分析维度七:执行文档 + +> 验收通过后,保存至 `.claude/design/<功能名称>-execution.md`。 + +#### 7.1 变更文件清单 + +| 文件路径 | 变更类型 | 变更说明 | +|---------|---------|---------| +| `projects/app/src/pageComponents/xxx/index.tsx` | 修改 | 新增按钮及弹窗逻辑 | + +#### 7.2 验收结果汇总 + +| 验收点 | 状态 | 快照对比结论 | 备注 | +|--------|------|------------|------| +| AC-001:点击新增打开弹窗 | ✅ 通过 | 操作后出现弹窗 | — | +| AC-002:提交后列表更新 | ❌ 未通过 | 列表无变化 | 待排查 onSuccess | + +#### 7.3 遗留问题与后续跟进 + +- 验收未通过的问题及原因分析 +- 未覆盖的边界场景 +- 后端依赖项跟进状态 + +--- + +## 输出格式要求 + +- Markdown 输出,结构清晰,组件识别用表格,交互逻辑用有序列表 +- 验收点输出为可执行的 `agent-browser` shell 命令块 +- 执行文档在验收完成后单独输出,保存至 `.claude/design/` 目录 +- 最后附【开发注意事项】:潜在坑点、状态管理建议、接口时序等 +- **文档末尾必须输出「AI-TODO 汇总表」**;若无不确定项,写「无 AI-TODO 待处理」 diff --git a/.claude/skills/img-to-docs/references/commands.md b/.claude/skills/img-to-docs/references/commands.md new file mode 100644 index 000000000000..d3ca0c07be7a --- /dev/null +++ b/.claude/skills/img-to-docs/references/commands.md @@ -0,0 +1,263 @@ +# Command Reference + +Complete reference for all agent-browser commands. For quick start and common patterns, see SKILL.md. + +## Navigation + +```bash +agent-browser open --headed # Navigate to URL (aliases: goto, navigate) + # Supports: https://, http://, file://, about:, data:// + # Auto-prepends https:// if no protocol given +agent-browser back # Go back +agent-browser forward # Go forward +agent-browser reload # Reload page +agent-browser close # Close browser (aliases: quit, exit) +agent-browser connect 9222 # Connect to browser via CDP port +``` + +## Snapshot (page analysis) + +```bash +agent-browser snapshot # Full accessibility tree +agent-browser snapshot -i # Interactive elements only (recommended) +agent-browser snapshot -c # Compact output +agent-browser snapshot -d 3 # Limit depth to 3 +agent-browser snapshot -s "#main" # Scope to CSS selector +``` + +## Interactions (use @refs from snapshot) + +```bash +agent-browser click @e1 # Click +agent-browser click @e1 --new-tab # Click and open in new tab +agent-browser dblclick @e1 # Double-click +agent-browser focus @e1 # Focus element +agent-browser fill @e2 "text" # Clear and type +agent-browser type @e2 "text" # Type without clearing +agent-browser press Enter # Press key (alias: key) +agent-browser press Control+a # Key combination +agent-browser keydown Shift # Hold key down +agent-browser keyup Shift # Release key +agent-browser hover @e1 # Hover +agent-browser check @e1 # Check checkbox +agent-browser uncheck @e1 # Uncheck checkbox +agent-browser select @e1 "value" # Select dropdown option +agent-browser select @e1 "a" "b" # Select multiple options +agent-browser scroll down 500 # Scroll page (default: down 300px) +agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto) +agent-browser drag @e1 @e2 # Drag and drop +agent-browser upload @e1 file.pdf # Upload files +``` + +## Get Information + +```bash +agent-browser get text @e1 # Get element text +agent-browser get html @e1 # Get innerHTML +agent-browser get value @e1 # Get input value +agent-browser get attr @e1 href # Get attribute +agent-browser get title # Get page title +agent-browser get url # Get current URL +agent-browser get count ".item" # Count matching elements +agent-browser get box @e1 # Get bounding box +agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.) +``` + +## Check State + +```bash +agent-browser is visible @e1 # Check if visible +agent-browser is enabled @e1 # Check if enabled +agent-browser is checked @e1 # Check if checked +``` + +## Screenshots and PDF + +```bash +agent-browser screenshot # Save to temporary directory +agent-browser screenshot path.png # Save to specific path +agent-browser screenshot --full # Full page +agent-browser pdf output.pdf # Save as PDF +``` + +## Video Recording + +```bash +agent-browser record start ./demo.webm # Start recording +agent-browser click @e1 # Perform actions +agent-browser record stop # Stop and save video +agent-browser record restart ./take2.webm # Stop current + start new +``` + +## Wait + +```bash +agent-browser wait @e1 # Wait for element +agent-browser wait 2000 # Wait milliseconds +agent-browser wait --text "Success" # Wait for text (or -t) +agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u) +agent-browser wait --load networkidle # Wait for network idle (or -l) +agent-browser wait --fn "window.ready" # Wait for JS condition (or -f) +``` + +## Mouse Control + +```bash +agent-browser mouse move 100 200 # Move mouse +agent-browser mouse down left # Press button +agent-browser mouse up left # Release button +agent-browser mouse wheel 100 # Scroll wheel +``` + +## Semantic Locators (alternative to refs) + +```bash +agent-browser find role button click --name "Submit" +agent-browser find text "Sign In" click +agent-browser find text "Sign In" click --exact # Exact match only +agent-browser find label "Email" fill "user@test.com" +agent-browser find placeholder "Search" type "query" +agent-browser find alt "Logo" click +agent-browser find title "Close" click +agent-browser find testid "submit-btn" click +agent-browser find first ".item" click +agent-browser find last ".item" click +agent-browser find nth 2 "a" hover +``` + +## Browser Settings + +```bash +agent-browser set viewport 1920 1080 # Set viewport size +agent-browser set device "iPhone 14" # Emulate device +agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation) +agent-browser set offline on # Toggle offline mode +agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers +agent-browser set credentials user pass # HTTP basic auth (alias: auth) +agent-browser set media dark # Emulate color scheme +agent-browser set media light reduced-motion # Light mode + reduced motion +``` + +## Cookies and Storage + +```bash +agent-browser cookies # Get all cookies +agent-browser cookies set name value # Set cookie +agent-browser cookies clear # Clear cookies +agent-browser storage local # Get all localStorage +agent-browser storage local key # Get specific key +agent-browser storage local set k v # Set value +agent-browser storage local clear # Clear all +``` + +## Network + +```bash +agent-browser network route # Intercept requests +agent-browser network route --abort # Block requests +agent-browser network route --body '{}' # Mock response +agent-browser network unroute [url] # Remove routes +agent-browser network requests # View tracked requests +agent-browser network requests --filter api # Filter requests +``` + +## Tabs and Windows + +```bash +agent-browser tab # List tabs +agent-browser tab new [url] # New tab +agent-browser tab 2 # Switch to tab by index +agent-browser tab close # Close current tab +agent-browser tab close 2 # Close tab by index +agent-browser window new # New window +``` + +## Frames + +```bash +agent-browser frame "#iframe" # Switch to iframe +agent-browser frame main # Back to main frame +``` + +## Dialogs + +```bash +agent-browser dialog accept [text] # Accept dialog +agent-browser dialog dismiss # Dismiss dialog +``` + +## JavaScript + +```bash +agent-browser eval "document.title" # Simple expressions only +agent-browser eval -b "" # Any JavaScript (base64 encoded) +agent-browser eval --stdin # Read script from stdin +``` + +Use `-b`/`--base64` or `--stdin` for reliable execution. Shell escaping with nested quotes and special characters is error-prone. + +```bash +# Base64 encode your script, then: +agent-browser eval -b "ZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW3NyYyo9Il9uZXh0Il0nKQ==" + +# Or use stdin with heredoc for multiline scripts: +cat <<'EOF' | agent-browser eval --stdin +const links = document.querySelectorAll('a'); +Array.from(links).map(a => a.href); +EOF +``` + +## State Management + +```bash +agent-browser state save auth.json # Save cookies, storage, auth state +agent-browser state load auth.json # Restore saved state +``` + +## Global Options + +```bash +agent-browser --session ... # Isolated browser session +agent-browser --json ... # JSON output for parsing +agent-browser --headed ... # Show browser window (not headless) +agent-browser --full ... # Full page screenshot (-f) +agent-browser --cdp ... # Connect via Chrome DevTools Protocol +agent-browser -p ... # Cloud browser provider (--provider) +agent-browser --proxy ... # Use proxy server +agent-browser --proxy-bypass # Hosts to bypass proxy +agent-browser --headers ... # HTTP headers scoped to URL's origin +agent-browser --executable-path

# Custom browser executable +agent-browser --extension ... # Load browser extension (repeatable) +agent-browser --ignore-https-errors # Ignore SSL certificate errors +agent-browser --help # Show help (-h) +agent-browser --version # Show version (-V) +agent-browser --help # Show detailed help for a command +``` + +## Debugging + +```bash +agent-browser --headed open example.com # Show browser window +agent-browser --cdp 9222 snapshot # Connect via CDP port +agent-browser connect 9222 # Alternative: connect command +agent-browser console # View console messages +agent-browser console --clear # Clear console +agent-browser errors # View page errors +agent-browser errors --clear # Clear errors +agent-browser highlight @e1 # Highlight element +agent-browser trace start # Start recording trace +agent-browser trace stop trace.zip # Stop and save trace +agent-browser profiler start # Start Chrome DevTools profiling +agent-browser profiler stop trace.json # Stop and save profile +``` + +## Environment Variables + +```bash +AGENT_BROWSER_SESSION="mysession" # Default session name +AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path +AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths +AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider +AGENT_BROWSER_STREAM_PORT="9223" # WebSocket streaming port +AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location +``` \ No newline at end of file diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md new file mode 100644 index 000000000000..db4d8ce2a8dd --- /dev/null +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -0,0 +1,159 @@ +--- +name: openspec-apply-change +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.4.1" +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + + **Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! Ready to archive this change. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.

" + - Recommendation: "Review : against requirement X" + + **Scenario Coverage**: + - For each scenario in delta specs (marked with "#### Scenario:"): + - Check if conditions are handled in code + - Check if tests exist covering the scenario + - If scenario appears uncovered: + - Add WARNING: "Scenario not covered: " + - Recommendation: "Add test or implementation for scenario: " + +7. **Verify Coherence** + + **Design Adherence**: + - If `contextFiles.design` exists: + - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:") + - Verify implementation follows those decisions + - If contradiction detected: + - Add WARNING: "Design decision not followed: " + - Recommendation: "Update implementation or revise design.md to match reality" + - If no design.md: Skip design adherence check, note "No design.md to verify against" + + **Code Pattern Consistency**: + - Review new code for consistency with project patterns + - Check file naming, directory structure, coding style + - If significant deviations found: + - Add SUGGESTION: "Code pattern deviation:
" + - Recommendation: "Consider following project pattern: " + +8. **Generate Verification Report** + + **Summary Scorecard**: + ``` + ## Verification Report: + + ### Summary + | Dimension | Status | + |--------------|------------------| + | Completeness | X/Y tasks, N reqs| + | Correctness | M/N reqs covered | + | Coherence | Followed/Issues | + ``` + + **Issues by Priority**: + + 1. **CRITICAL** (Must fix before archive): + - Incomplete tasks + - Missing requirement implementations + - Each with specific, actionable recommendation + + 2. **WARNING** (Should fix): + - Spec/design divergences + - Missing scenario coverage + - Each with specific recommendation + + 3. **SUGGESTION** (Nice to fix): + - Pattern inconsistencies + - Minor improvements + - Each with specific recommendation + + **Final Assessment**: + - If CRITICAL issues: "X critical issue(s) found. Fix before archiving." + - If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)." + - If all clear: "All checks passed. Ready for archive." + +**Verification Heuristics** + +- **Completeness**: Focus on objective checklist items (checkboxes, requirements list) +- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty +- **Coherence**: Look for glaring inconsistencies, don't nitpick style +- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL +- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable + +**Graceful Degradation** + +- If only tasks.md exists: verify task completion only, skip spec/design checks +- If tasks + specs exist: verify completeness and correctness, skip design +- If full artifacts: verify all three dimensions +- Always note which checks were skipped and why + +**Output Format** + +Use clear markdown with: +- Table for summary scorecard +- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) +- Code references in format: `file.ts:123` +- Specific, actionable recommendations +- No vague suggestions like "consider reviewing" diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 000000000000..d20c0fe4bdba --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.costrict/settings.json b/.costrict/settings.json new file mode 100644 index 000000000000..a2a44bb6c77c --- /dev/null +++ b/.costrict/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "defaultMode": "bypassPermissions" + } +} diff --git a/.frieren/agent-tasks/TD/2026041200014.md b/.frieren/agent-tasks/TD/2026041200014.md new file mode 100644 index 000000000000..7ea01ed46f2a --- /dev/null +++ b/.frieren/agent-tasks/TD/2026041200014.md @@ -0,0 +1,36 @@ +# TD 2026041200014 修复文档 + +#### 【问题】 +工作流配置混合检索后,完整响应中"检索方式"字段显示比例异常: +`混合检索(语义检索:全文检索 = 0.67:0.3299999999999999996)`,两比例相加不等于 1。 + +#### 【问题原因】 +`WholeResponseModal.tsx` 第 549 行直接使用 `1 - activeModule.embeddingWeight` 进行浮点运算, +触发 JavaScript 浮点精度问题(`1 - 0.67 = 0.32999999...`),未做精度截断处理。 + +#### 【修复方案】 +文件:`projects/app/src/components/core/chat/components/WholeResponseModal.tsx`(Line 546-551) + +对 `emb` 和 `text` 两个参数均使用已有工具函数 `formatNumber(val, 100)` 保留 2 位小数: + +```typescript +// 修改前 +{ + emb: activeModule.embeddingWeight, + text: 1 - activeModule.embeddingWeight +} + +// 修改后 +{ + emb: formatNumber(activeModule.embeddingWeight, 100), + text: formatNumber(1 - activeModule.embeddingWeight, 100) +} +``` + +#### 【影响模块】 +- `WholeResponseModal.tsx`:仅影响完整响应弹窗中混合检索权重的显示格式,无逻辑变更。 + +#### 【测试建议】 +1. 配置混合检索比例(如 0.67:0.33),运行工作流后打开完整响应,确认显示为 `0.67 : 0.33`。 +2. 测试边界值:0.5:0.5、0.9:0.1、0.1:0.9,确认各比例显示正确。 +3. 切换语言(中/英/繁体)确认显示均正常。 diff --git a/.frieren/agent-tasks/TD/2026041200016.md b/.frieren/agent-tasks/TD/2026041200016.md new file mode 100644 index 000000000000..fb046ff53519 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026041200016.md @@ -0,0 +1,28 @@ +# TD-2026041200016 修复文档 + +#### 【问题】 +工作流完整响应弹窗中,知识库检索模块的 TOP2/TOP3 分块卡片,描述信息(综合分数、召回排名等)排版错乱,内容溢出到卡片内容区域。 + +#### 【问题原因】 +`ChunkInfoCard.tsx` 的 Header Flex 高度固定为 `h={'40px'}`,但内部描述列表 Flex 设置了 `flexWrap={'wrap'}`,当综合分数、召回排名等多个描述项加上右侧文件名链接宽度超出一行时,描述列表触发换行,换行内容超出固定 40px 容器溢出显示,导致排版错乱。 + +同时,内层左侧 Flex `alignItems={'center'}` 在多行内容下会使图标和标题悬浮在描述列表高度的垂直中间,视觉对齐异常。 + +#### 【修复方案】 +**文件**:`projects/app/src/components/core/chat/components/ChunkInfoCard.tsx` + +| 行号 | 修改前 | 修改后 | 原因 | +|------|--------|--------|------| +| 61 | `h={'40px'}` | `minH={'40px'}` | 允许容器高度自适应内容,防止换行内容溢出 | +| 70 | `alignItems={'center'}` | `alignItems={'flex-start'}` | 多行时图标、标题、描述列表顶部对齐,视觉整洁 | + +#### 【影响模块】 +- `projects/app/src/components/core/chat/components/ChunkInfoCard.tsx`(直接修改) +- `projects/app/src/components/core/chat/components/AssistantDetailModal.tsx`(使用 ChunkInfoCard,正面受益) +- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChunkCardList.tsx`(使用 ChunkInfoCard,正面受益) + +#### 【测试建议】 +- [ ] 创建工作流,配置知识库检索节点,执行问答 +- [ ] 点击「完整响应」查看知识库检索结果,确认 TOP1/TOP2/TOP3 卡片排版正常 +- [ ] 测试多个分数项同时存在时(综合分数 + 重排分数 + 召回排名)的换行布局 +- [ ] 验证单行内容(仅一个描述项)下样式不受影响 diff --git a/.frieren/agent-tasks/TD/2026051400297.md b/.frieren/agent-tasks/TD/2026051400297.md new file mode 100644 index 000000000000..6c171d0d5ea9 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051400297.md @@ -0,0 +1,29 @@ +#### 【问题】 +应用市场点击"立即创建"按钮后,应用列表多出两个相同的模板应用。 + +#### 【问题原因】 +`AppMarketCard` 组件中,`MyCard` 整体绑定了 `onClick` 事件,同时悬浮操作区 `hoverAction` 内的 `Button` 也绑定了 `onClick` 事件。点击按钮时,按钮自身的 `onClick` 先触发,随后事件冒泡到父级 `MyCard` 的 `onClick`,导致 `onUseTemplate` 被调用两次,后端接口被请求两次,创建出两个相同的应用。 + +#### 【修复方案】 +在 `Button` 的 `onClick` 处理器中添加 `e.stopPropagation()`,阻止事件继续冒泡到 `MyCard`。 + +修改文件:`projects/app/src/pageComponents/dashboard/templateMarket/AppMarketCard.tsx` + +```tsx +// 修改前 +onClick={() => onClick(item)} + +// 修改后 +onClick={(e) => { + e.stopPropagation(); + onClick(item); +}} +``` + +#### 【影响模块】 +- `projects/app/src/pageComponents/dashboard/templateMarket/AppMarketCard.tsx` — 应用市场卡片组件 + +#### 【测试建议】 +1. 进入应用市场,点击任意模板的"立即创建"按钮 +2. 确认只创建一个应用,应用列表中无重复 +3. 同时验证点击卡片非按钮区域也能正常创建应用(MyCard 的 onClick 不受影响) diff --git a/.frieren/agent-tasks/TD/2026051500122.md b/.frieren/agent-tasks/TD/2026051500122.md new file mode 100644 index 000000000000..cba3613d8efb --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051500122.md @@ -0,0 +1,32 @@ +#### 【问题】 +系统工具管理页面选择导入工具时报错,页面弹出错误提示。 + +#### 【问题原因】 +`ImportPluginModal.tsx` 组件在打开时自动调用 `getMarketPlaceToolTags` 获取 marketplace 工具标签列表。该请求通过 `useRequest` hook 发起,而 `useRequest` 默认 `errorToast: 'Error'`,当请求失败时会自动弹出错误 toast。 + +在测试环境中,marketplace 服务可能未启动或网络异常,导致 `GET /marketplace/api/tool/tags` 请求返回 500 错误,前端因此弹出报错提示。但标签获取失败不应影响导入工具的核心功能,也不应该报错打断用户操作。 + +#### 【修复方案】 +在 `ImportPluginModal.tsx` 中,`useRequest(getMarketPlaceToolTags)` 调用增加 `errorToast: ''` 参数,使标签请求失败时静默处理,不再弹出错误 toast。 + +修改文件:`projects/app/src/pageComponents/config/ImportPluginModal.tsx` + +```diff + const { data: allTags = [] } = useRequest(getMarketPlaceToolTags, { +- manual: false ++ manual: false, ++ errorToast: '' + }); +``` + +当 `allTags` 为空时,标签映射逻辑会回退为空字符串,标签列显示为 `-`,不影响文件上传、解析和确认导入等核心流程。 + +#### 【影响模块】 +- **主要影响**:`projects/app/src/pageComponents/config/ImportPluginModal.tsx` — 导入工具弹窗 +- **功能影响**:仅影响 tags 请求失败时的错误提示行为,核心导入功能不受影响 +- **风险等级**:🟢 低 + +#### 【测试建议】 +1. 在 marketplace 服务不可用的环境下打开导入工具弹窗,确认不再弹出错误 toast +2. 正常导入 .pkg 文件,确认文件上传、解析、标签显示、确认导入流程正常 +3. 在 marketplace 服务可用的环境下,确认标签正常显示 diff --git a/.frieren/agent-tasks/TD/2026051500138.md b/.frieren/agent-tasks/TD/2026051500138.md new file mode 100644 index 000000000000..d53dc1210c03 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051500138.md @@ -0,0 +1,27 @@ +#### 【问题】 + +系统工具管理页面中,"选择现有应用"按钮文案不准确。用户点击后进入的是自定义工具配置页面,没有"选择应用"的地方,文案与实际功能不符,用户无法理解该功能的用途。 + +#### 【问题原因】 + +i18n 词条 `toolkit_select_app` 使用了"应用"一词,但该功能实际上是选择已有的工作流/工具,而非应用。文案表述与实际功能不匹配,造成用户困惑。 + +#### 【修复方案】 + +将三语种 i18n 词条中的"应用"改为"工具": + +- `packages/web/i18n/zh-CN/app.json`: `"选择现有应用"` → `"选择现有工具"` +- `packages/web/i18n/zh-Hant/app.json`: `"選擇現有應用"` → `"選擇現有工具"` +- `packages/web/i18n/en/app.json`: `"Select an existing app"` → `"Select an existing tool"` + +#### 【影响模块】 + +- 系统工具管理页面 (`/config/tool`) +- 仅影响菜单按钮文案,不影响任何功能逻辑 + +#### 【测试建议】 + +1. 访问系统工具管理页面 +2. 点击"添加资源"下拉菜单 +3. 确认第三项显示为"选择现有工具"(原"选择现有应用") +4. 切换语言(中/英/繁中),确认各语言文案正确 diff --git a/.frieren/agent-tasks/TD/2026051500143.md b/.frieren/agent-tasks/TD/2026051500143.md new file mode 100644 index 000000000000..6f6144c4e641 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051500143.md @@ -0,0 +1,22 @@ +#### 【问题】 +系统工具管理 -> 自定义工具配置弹窗中,字段标签为"关联工具",但未选择工具点击确认时,报错提示为"关联应用不能为空"。文案与实际字段名称不一致,用户无法理解。 + +#### 【问题原因】 +i18n 国际化词条 `custom_plugin_associated_plugin_required` 在三语种中均使用了"应用"(application)相关词汇,而实际 UI 标签为"关联工具",导致报错文案与界面字段名称不匹配。 + +#### 【修复方案】 +修改三语种 i18n 文件中的 `custom_plugin_associated_plugin_required` 词条: +- zh-CN: "关联应用不能为空" → "关联工具不能为空" +- zh-Hant: "關聯應用不能為空" → "關聯外掛不能為空" +- en: "Associated applications cannot be empty" → "Associated tool cannot be empty" + +#### 【影响模块】 +- `packages/web/i18n/zh-CN/app.json` +- `packages/web/i18n/zh-Hant/app.json` +- `packages/web/i18n/en/app.json` +- 调用位置:`projects/app/src/pageComponents/config/tool/WorkflowToolConfigModal.tsx:186` + +#### 【测试建议】 +1. 打开系统工具管理 -> 自定义工具配置弹窗 +2. 不选择任何工具,直接点击确认 +3. 验证报错提示为"关联工具不能为空"(或对应语种的正确文案) diff --git a/.frieren/agent-tasks/TD/2026051600099.md b/.frieren/agent-tasks/TD/2026051600099.md new file mode 100644 index 000000000000..6f14cbb73a5a --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051600099.md @@ -0,0 +1,31 @@ +#### 【问题】 + +模型训练任务列表中,"等待中"状态的任务没有删除按钮,而"正在运行中"的任务具备删除功能。同时,"等待中"的文案需要改为"排队中"。 + +#### 【问题原因】 + +在 `TrainDetailDrawer.tsx` 的 `renderOperations` 函数中(第493行),当任务状态为 `pending`(等待中)时,直接 `return null`,导致该状态的任务没有任何操作按钮。这是前端渲染逻辑的设计遗漏。 + +#### 【修复方案】 + +1. **添加删除按钮**:将 `pending` 状态的 `return null` 改为返回包含删除按钮的 `MyMenu` 组件,与 `running` 状态的删除按钮保持一致。 +2. **同步修改文案**:将三语种 i18n 词条 `learning_status_pending` 的值统一修改: + - 简体中文:`"等待中"` → `"排队中"` + - 繁体中文:`"等待中"` → `"排队中"` + - 英文:`"Pending"` → `"Queued"` + +#### 【影响模块】 + +- `projects/app/src/components/core/ai/ModelTable/TrainDetailDrawer.tsx` - 训练任务详情抽屉的操作列渲染 +- `packages/web/i18n/zh-CN/app.json` - 中文简体词条 +- `packages/web/i18n/zh-Hant/app.json` - 中文繁体词条 +- `packages/web/i18n/en/app.json` - 英文词条 + +#### 【测试建议】 + +1. 进入模型管理界面,创建rerank训练任务 +2. 在训练任务详情抽屉中,确认"排队中"状态的任务行右侧出现"更多"操作按钮 +3. 点击"更多"按钮,确认下拉菜单中有"删除"选项 +4. 点击删除,确认弹出删除确认对话框 +5. 确认删除功能正常工作 +6. 检查其他状态(运行中/失败/已完成)的删除功能不受影响 diff --git a/.frieren/agent-tasks/TD/2026051900075.md b/.frieren/agent-tasks/TD/2026051900075.md new file mode 100644 index 000000000000..ba9378ae7c1a --- /dev/null +++ b/.frieren/agent-tasks/TD/2026051900075.md @@ -0,0 +1,32 @@ +#### 【问题】 +工作流知识库检索节点(datasetSearchNode)模型参数未下发: +1. 重排模型(rerankModel)UI 上显示了默认值,但保存时参数为空 +2. 向量模型(embeddingModel)初次选择知识库后不自动配置 + +#### 【问题原因】 +**rerankModel 未下发**: +`SelectDatasetParams.tsx` 中 `data.rerankModel` 初始化为 `defaultModels.rerank?.model`(保证 UI 有显示),但 `inputs[rerankModel].value` 在新建节点时为 `undefined`。inputs useEffect 中使用 `input.value ?? state[input.key]`,当 `input.value` 为 undefined 时不写回 inputs,导致保存时读取的 inputs 值仍是 undefined,参数未下发。 + +**embeddingModel 不自动配置**: +知识库变更 effect 的条件 `prev !== undefined && prev !== datasetVectorModel` 中,`prev !== undefined` 阻止了初次选择知识库(`prev === undefined`)时的更新逻辑。 + +#### 【修复方案】 +**文件**:`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDatasetParams.tsx` + +**rerankModel 修复**: +- 添加 `hasInitializedRerankModelRef` ref 标记,在 inputs useEffect 首次找到 rerankModel input 时执行一次初始化 +- 若 `rerankModelInput.value` 为空且有默认 rerank 模型,调用 `onChangeNode` 将默认值写入 inputs + +**embeddingModel 修复**: +- 将原条件替换为 `isFirstSelect || isSwitchDataset` +- `isFirstSelect`:prev === undefined(初次)且 inputs 中 embeddingModel 无已保存值(新节点)→ 自动配置 +- `isSwitchDataset`:prev !== undefined 且知识库切换 → 更新 + +#### 【影响模块】 +- 工作流编辑器 - 知识库检索节点参数配置弹窗 + +#### 【测试建议】 +1. 新建工作流 → 添加知识库检索节点 → 选择有向量模型的知识库 → 验证向量模型自动填充 +2. 新建工作流 → 添加知识库检索节点(不修改 rerank 模型)→ 保存 → 重新打开验证 rerankModel 有值 +3. 编辑已保存工作流 → 已配置 embeddingModel 的节点 → 不改知识库 → 验证 embeddingModel 不被覆盖 +4. 切换知识库(从知识库A切换到知识库B)→ 验证向量模型自动更新为新知识库的向量模型 diff --git a/.frieren/agent-tasks/TD/2026052000305.md b/.frieren/agent-tasks/TD/2026052000305.md new file mode 100644 index 000000000000..64a39bceaf68 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026052000305.md @@ -0,0 +1,27 @@ +# TD 2026052000305 修复记录 + +#### 【问题】 +对基于bge模型的知识库进行 Qwen3-Embedding 类模型训练后,训练结果(Precision@10、MRR@10)全部显示为 0.0%/0.00。 + +#### 【问题原因】 +`BaseModelTrainModal` 组件在知识库选择时未校验向量模型兼容性,允许用户选择任意知识库进行训练。当所选知识库的 `vectorModel.model` 与当前训练的基础模型(`selectedBaseModel`)不一致时,训练数据无法被正确处理,导致结果全为 0.0。 + +#### 【修复方案】 +在 `BaseModelTrainModal.tsx` 中添加向量模型兼容性检查: +- 新增 `isDatasetDisabled` 函数:仅对 `ModelTypeEnum.embedding` 类型的训练检查,当 `item.vectorModel?.model !== selectedBaseModel` 时返回 `true` +- 对禁用知识库:展示但禁用(`opacity: 0.5`、`cursor: not-allowed`、`isDisabled` Checkbox);鼠标悬浮显示 Tooltip 提示"该知识库的向量模型与本模型不同,不支持训练" +- 修改 `toggleFolderSelection`、`getFolderCheckState`、`isAllSelected`、`handleSelectAll`,均过滤掉不兼容的知识库,确保批量操作不会选入禁用项 +- 添加三语种 i18n 词条 `train_dataset_vector_model_mismatch` + +#### 【影响模块】 +- `projects/app/src/components/core/ai/ModelTable/BaseModelTrainModal.tsx` +- `packages/web/i18n/zh-CN/account_model.json` +- `packages/web/i18n/en/account_model.json` +- `packages/web/i18n/zh-Hant/account_model.json` + +#### 【测试建议】 +1. 打开 embedding 模型训练弹窗,验证与当前模型向量不同的知识库显示灰色禁用状态 +2. 悬浮禁用知识库,验证 Tooltip 提示文字正确显示 +3. 验证批量选择(全选/文件夹选择)不会选入不兼容的知识库 +4. 验证与当前模型向量相同的知识库可正常选择 +5. 验证 rerank 类型训练不受影响(所有知识库均可选) diff --git a/.frieren/agent-tasks/TD/2026052100173.md b/.frieren/agent-tasks/TD/2026052100173.md new file mode 100644 index 000000000000..f2a58baebef1 --- /dev/null +++ b/.frieren/agent-tasks/TD/2026052100173.md @@ -0,0 +1,38 @@ +#### 【问题】 +训练模型(生成新模型)弹窗中,含有"处理中"文件的知识库(`dataset_trainings` 集合中存在记录)可以被选中并提交训练任务。 + +#### 【问题原因】 +`BaseModelTrainModal.tsx` 渲染知识库列表时,直接展示所有知识库,未检查其是否含有正在处理中(训练队列存在)的集合,导致用户可以选中并发起训练。 + +根因:`DatasetListItemType` 没有 `processingCount` 字段,前端无法感知知识库的处理状态。 + +**追加问题**:`listWithChildren.ts` 中 aggregate 查询 `$in` 参数类型不匹配——post-find hook 将 `_id` 转为字符串后直接传入 aggregate,导致 `dataset_trainings` 查询始终返回空。 + +#### 【修复方案】 +1. **`packages/global/core/dataset/type.ts`**:在 `DatasetListItemSchema` 中新增 `processingCount?: number` 字段,表示该知识库在训练队列中待处理的文件数量。 + +2. **`projects/app/src/pages/api/core/dataset/listWithChildren.ts`** 和 **`list.ts`**:通过聚合查询 `dataset_trainings` 集合,统计每个非文件夹知识库的训练队列记录数,填充到 `processingCount` 字段返回。 + +3. **【追加修复】`listWithChildren.ts` aggregate `$in` 类型不匹配问题**: + - **根因**:项目全局 `addCommonMiddleware` 中 `post(/^find/)` 钩子会将 `.lean()` 返回的文档中的 `_id` 从 ObjectId 转为 string。`listWithChildren.ts` 直接将转后的字符串 ID 传给 aggregate 的 `$in`,导致 MongoDB aggregate pipeline 无法匹配 ObjectId 类型的字段(`dataset_trainings.datasetId` 是 ObjectId),返回空结果,`processingCount` 始终为 0。 + - **修复**:在 `listWithChildren.ts` 中,将 `datasetObjectIds = uniqueNonFolderDatasets.map((d) => d._id)` 改为 `datasetObjectIds = uniqueNonFolderDatasets.map((d) => new Types.ObjectId(d._id))`,与 `list.ts`(第 179 行)保持一致。 + +4. **`projects/app/src/components/core/ai/ModelTable/BaseModelTrainModal.tsx`**: + - 引入 `MyTooltip` + - 对 `processingCount > 0` 的知识库,禁用 Checkbox(`isDisabled`)、禁用点击行为(`cursor: not-allowed`)、降低不透明度(`opacity: 0.5`) + - 悬浮时显示 Tooltip:`"仍在处理中,暂不支持训练"` + - 全选/文件夹全选时过滤掉处理中的知识库 + - 文件夹半选/全选状态计算时排除处理中的知识库(分母只计算可选项) + +4. **i18n**(三语种):新增词条 `account_model:dataset_still_processing_tip`。 + +#### 【影响模块】 +- `BaseModelTrainModal.tsx`:训练模型(生成新模型)弹窗知识库选择 +- `DatasetListItemType`:全量知识库列表类型,所有使用该类型的场景均可感知 `processingCount` + +#### 【测试建议】 +1. 知识库中存在"处理中"文件时,打开训练弹窗,该知识库应显示为禁用状态,无法勾选,悬浮显示"仍在处理中,暂不支持训练" +2. 知识库中所有文件处理完成后,该知识库应恢复为可选状态 +3. 点击"全选"时,处理中的知识库不应被选中 +4. 勾选含处理中子项的文件夹时,处理中的知识库不应被选中;文件夹的半选/全选状态应正确反映可选子项的选中状态 +5. 正常知识库(无处理中文件)选择、提交训练流程无回归 diff --git a/.frieren/commands/commit-to-en.md b/.frieren/commands/commit-to-en.md new file mode 100644 index 000000000000..e48a67ea0e34 --- /dev/null +++ b/.frieren/commands/commit-to-en.md @@ -0,0 +1,11 @@ +--- +name: commit-to-english +description: commit信息转英文 +--- +请对以下git commit信息进行翻译,转成英文,要求: + +1. 翻译尽量简短 +2. 用词尽可能简单易读 +3. 保持commit信息的原意和格式 +4. 返回用markdown格式包裹 +5. 翻译后的commit信息内容部分首字母小写 diff --git a/.frieren/commands/components.md b/.frieren/commands/components.md new file mode 100644 index 000000000000..5313548f7efc --- /dev/null +++ b/.frieren/commands/components.md @@ -0,0 +1,56 @@ +--- +name: components +description: 项目内前端自定义公共组件库,优先使用这些预设组件 +tools: ["read_file", "grep_search", "list_files", "codebase_search"] +--- + +组件库包含以下预定义组件,生成代码时必须优先匹配使用: + +```javascript +const commonCps = [ + { + name: "AiModelselect", + desc: "选择框组件,用于选择AI模型", + path: "import AIModelselector from '@/components/Select/AIModelselector'" + }, + { + name: "MyModal", + desc: "弹窗组件,全局统一样式,所有Modal场景统一使用此组件", + path: "import MyModal from '@fastgpt/web/components/common/MyModal'" + }, + { + name: "QuestionTip", + desc: "提示组件,问号图标悬浮显示提示信息", + path: "import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'" + }, + { + name: "MyIcon", + desc: "图标组件,统一项目内的图标管理", + path: "import MyIcon from '@fastgpt/web/components/common/Icon'" + }, + { + name: "MySelect", + desc: "选择器组件,替代原生select", + path: "import MySelect from '@fastgpt/web/components/common/MySelect'" + }, + { + name: "MyTooltip", + desc: "浮层提示组件,鼠标悬浮显示额外信息", + path: "import MyTooltip from '@fastgpt/web/components/common/MyTooltip'" + }, + { + name: "MyMenu", + desc: "下拉菜单组件,悬浮触发显示菜单选项", + path: "import MyMenu from '@fastgpt/web/components/common/MyMenu'" + }, + { + name: "FormLabel", + desc: "表单标签组件,严格替代chakra-ui的FormLabel", + path: "import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'" + }, + { + name: "MyBox", + desc: "普通容器布局组件,支持loading状态", + path: "import MyBox from '@fastgpt/web/components/common/MyBox';" + } +]; \ No newline at end of file diff --git a/.frieren/commands/term-tanslation.md b/.frieren/commands/term-tanslation.md new file mode 100644 index 000000000000..c51f3ba10820 --- /dev/null +++ b/.frieren/commands/term-tanslation.md @@ -0,0 +1,26 @@ +--- +name: term-translation +description: 词条翻译 +--- +请对以下代码中的中文词条进行翻译,并将词条的 key 改写为新定义的词条 key: + +1. 提取代码中的词条,举例如:t('退出') +2. 在 json 文件中新增词条,包含简体中文、繁体中文、英文,三种格式,注意:英文的词条无需翻译,value 使用简体中文即可,所以输出应该是三个 json 文件,json 文件路径在上下文中 +3. 以下为 json 文件示例,仅用于命名和格式参考: + + ```json + { + "create_dimension": "新建维度", + "search_dimension": "搜索评测维度", + "delete_failed": "删除失败", + "delete_success": "删除成功", + "builtin": "内置", + "confirm_delete_dimension": "确认删除该维度?", + "dimension_name": "维度名", + "description": "介绍", + "create_update_time": "创建/更新时间", + "creator": "创建人" + } + ``` +4. 新增词条后检查 key 是否存在重复,要求 key 必须唯一 +5. 将代码中的词条 key 替换为 json 文件定义的 key,比如 json 文件名为:`test.json`,新增的词条定义为 `"back": "退出"`,那么要将原来的 `t('退出')` 改成 `t('test:back')`,切记不要漏了文件名的前缀! diff --git a/.frieren/commands/transfer.md b/.frieren/commands/transfer.md new file mode 100644 index 000000000000..2730e97c0b39 --- /dev/null +++ b/.frieren/commands/transfer.md @@ -0,0 +1,29 @@ +--- +name: i18n +description: 国际化词条处理 +tools: ["read_file", "grep_search", "list_files", "codebase_search"] +--- +- 英文 (en) +- 简体中文 (zh-CN) +- 繁体中文 (zh-Hant) + +1. 整理出代码中需要做国际化的中文 +2. .en文件,.zh-CN,zh-Hant定义对应的key + 词条,英文需要翻译 +3. 完成对应的词条替换 + +中文 -> t('namespace: key') +xx位 -> t('namespace: key', { var: value }) + +使用例子 + t('日记') + +替换成 +t('namespace:log') + +词条文件 +英文 (en) +"log": "log" +简体中文 (zh-CN) +"log": "日志" +繁体中文 (zh-Hant) +"log": "日誌" \ No newline at end of file diff --git a/.frieren/rules/bg.md b/.frieren/rules/bg.md new file mode 100644 index 000000000000..ea5a3d91f9e6 --- /dev/null +++ b/.frieren/rules/bg.md @@ -0,0 +1,30 @@ +--- +globs: 'projects/app/src/**' +description: 项目背景技术规范与国际化要求 +--- + +## 技术栈规范 +1. **项目框架**:基于 Next.js 开发,所有生成代码必须严格遵循: + - Next.js 官方编码规范(ESLint+Prettier配置) + - React 最佳实践(组件生命周期/状态管理) + - 内存安全要求(避免闭包泄漏/事件监听未清理/定时器未清除、死循环) + +2. **UI组件库**: + - 使用 **Chakra UI v2.x** 作为唯一UI库 + - 严格遵循组件库 v2.x 版本API规范 + - 禁止混合使用其他UI库或原生HTML元素(Chakra未覆盖的除外) + +## 国际化规范 +### 支持语言版本 +- 英文 (en) +- 简体中文 (zh-CN) +- 繁体中文 (zh-Hant) + +### 代码转换规则 +1. **文本国际化**: + ```javascript + // 原始代码 + const text = "这是一个中文按钮"; + + // 必须转换 + const text = t("这是一个中文按钮"); \ No newline at end of file diff --git a/.frieren/rules/td.md b/.frieren/rules/td.md new file mode 100644 index 000000000000..09fb3d7ed57d --- /dev/null +++ b/.frieren/rules/td.md @@ -0,0 +1,13 @@ +--- +description: TD修复完成后的收尾流程 +--- + +## TD 修复完成后的最后步骤 + +TD 修复完成后,必须执行以下收尾流程: + +### 步骤1:确认修复状态 +- 确认 `.frieren/agent-tasks/TD/.md` 中的修复方案已全部实施 + +### 步骤2:创建 Pull Request +调用skill create-mr \ No newline at end of file diff --git a/.github/imgs/logo.svg b/.github/imgs/logo.svg index 8a7d4a0c9ee2..d31b9396a580 100644 --- a/.github/imgs/logo.svg +++ b/.github/imgs/logo.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.github/workflows/diting-rag-ts-test.yml b/.github/workflows/diting-rag-ts-test.yml new file mode 100644 index 000000000000..0e71ff2dbc7d --- /dev/null +++ b/.github/workflows/diting-rag-ts-test.yml @@ -0,0 +1,45 @@ +name: 'diting-rag-ts Tests' + +on: + push: + paths: + - 'packages/diting-rag-ts/**' + pull_request: + paths: + - 'packages/diting-rag-ts/**' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: TypeCheck diting-rag-ts + working-directory: packages/diting-rag-ts + run: pnpm typecheck + + - name: Run diting-rag-ts unit tests + working-directory: packages/diting-rag-ts + run: pnpm test diff --git a/.gitignore b/.gitignore index fc9d85a04f97..1c65a337c384 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ node_modules/ .next/ out/ # production -build/ +# build/ .astro/ +.worktrees/ # misc .DS_Store *.pem @@ -38,8 +39,13 @@ files/helm/fastgpt/charts/*.tgz tmp/ coverage document/.source - +AGENTS.md projects/app/worker/ - +projects/app/data/train/ # Agent -.codex \ No newline at end of file +.codex + +# diting-rag-ts(source-based,无需 dist) +packages/diting-rag-ts/dist/ + +.claude/settings.local.json \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 652d99442159..cfbacf9eba5e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,30 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" +echo "Running i18n check..." +node scripts/i18n/check-all.js +if [ $? -ne 0 ]; then + echo "i18n check failed. Please fix the issues before committing." + exit 1 +fi + +echo "Running TypeScript type check..." +if command -v pnpm >/dev/null 2>&1; then + pnpm typecheck + if [ $? -ne 0 ]; then + echo "TypeScript type check failed. Please fix the errors before committing." + exit 1 + fi +elif command -v npx >/dev/null 2>&1; then + npx tsc --noEmit -p projects/app/tsconfig.json + if [ $? -ne 0 ]; then + echo "TypeScript type check failed. Please fix the errors before committing." + exit 1 + fi +fi + if command -v pnpm >/dev/null 2>&1; then pnpm lint-staged elif command -v npx >/dev/null 2>&1; then npx lint-staged -fi \ No newline at end of file +fi diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000000..87ca7dea2b26 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp" + ] + } + } +} diff --git a/.npmrc b/.npmrc index 760db01b3169..52fd17d3047c 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,5 @@ public-hoist-pattern[]=*tiktoken* public-hoist-pattern[]=*@zilliz/milvus2-sdk-node* -registry=https://registry.npmjs.org/ \ No newline at end of file +public-hoist-pattern[]=*@mariozechner/pi-ai* +public-hoist-pattern[]=*@mariozechner/pi-agent-core* +registry=http://mirrors.sangfor.org/nexus/repository/npm diff --git a/Dockerfile.app b/Dockerfile.app new file mode 100644 index 000000000000..d8c0f839cef4 --- /dev/null +++ b/Dockerfile.app @@ -0,0 +1,116 @@ +# --------- install dependence ----------- +FROM mirrors.sangfor.com/node:20.14.0-alpine AS maindeps +WORKDIR /app + +ARG proxy + +RUN sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/main#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/main#g' /etc/apk/repositories && \ + sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/community#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/community#g' /etc/apk/repositories +RUN npm config set registry http://mirrors.sangfor.org/nexus/repository/npm +RUN apk add --no-cache libc6-compat && npm install -g pnpm@9 + +# copy local tgz packages referenced by packages/service/package.json + pnpm-lock.yaml via /app/*.tgz +COPY ./node-xlsx-0.24.0.tgz ./ +COPY ./xlsx-0.20.2.tgz ./ + +# copy packages and one project +COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc package.json ./ +COPY ./patches ./patches +COPY ./packages ./packages +COPY ./sdk ./sdk +COPY ./projects/app/package.json ./projects/app/package.json + +RUN [ -f pnpm-lock.yaml ] || (echo "Lockfile not found." && exit 1) + +# 将 lockfile 里写死的 npmmirror tarball 重写到 sangfor nexus,避免 ECONNREFUSED +RUN sed -i 's#https://registry\.npmmirror\.com/#http://mirrors.sangfor.org/nexus/repository/npm/#g' pnpm-lock.yaml + +# if proxy exists, set proxy +RUN if [ -z "$proxy" ]; then \ + pnpm i; \ + else \ + pnpm i --registry=http://mirrors.sangfor.org/nexus/repository/npm; \ + fi + +# --------- builder ----------- +FROM mirrors.sangfor.com/node:20.14.0-alpine AS builder +WORKDIR /app + +ARG proxy +ARG base_url + +# copy common node_modules and one project node_modules +COPY package.json pnpm-workspace.yaml .npmrc tsconfig.json .eslintrc.json ./ +COPY --from=maindeps /app/node_modules ./node_modules +COPY --from=maindeps /app/packages ./packages +COPY --from=maindeps /app/sdk ./sdk +COPY ./projects/app ./projects/app +COPY --from=maindeps /app/projects/app/node_modules ./projects/app/node_modules + +RUN sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/main#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/main#g' /etc/apk/repositories && \ + sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/community#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/community#g' /etc/apk/repositories +RUN npm config set registry http://mirrors.sangfor.org/nexus/repository/npm + +RUN apk add --no-cache libc6-compat && npm install -g pnpm@9 + +ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV NEXT_PUBLIC_BASE_URL=$base_url +RUN pnpm --filter=app build + +# Remove build-time-only packages from standalone output before copying to runner. +# These are traced into standalone by mistake (rspack bindings, gnu platform binaries, etc.) +RUN rm -rf projects/app/.next/standalone/node_modules/.pnpm/@next+rspack-binding-*/ \ + projects/app/.next/standalone/node_modules/.pnpm/@rspack+binding-*/ \ + projects/app/.next/standalone/node_modules/.pnpm/next-rspack*/ \ + projects/app/.next/standalone/node_modules/.pnpm/typescript@*/ \ + projects/app/.next/standalone/node_modules/.pnpm/*-linux-x64-gnu@*/ \ + projects/app/.next/standalone/node_modules/.pnpm/@img+sharp-libvips-linux-x64@*/ + +# --------- runner ----------- +FROM mirrors.sangfor.com/node:20.14.0-alpine AS runner +WORKDIR /app + +ARG proxy +ARG base_url + +# create user and use it +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +RUN sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/main#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/main#g' /etc/apk/repositories && \ + sed -i 's#https\?://dl-cdn\.alpinelinux\.org/alpine/v3\.20/community#http://mirrors.sangfor.com/nexus/repository/alpine/v3.20/community#g' /etc/apk/repositories +RUN npm config set registry http://mirrors.sangfor.org/nexus/repository/npm +RUN apk add --no-cache curl ca-certificates bash \ + && update-ca-certificates + +# copy running files +COPY --from=builder /app/projects/app/public /app/projects/app/public +COPY --from=builder /app/projects/app/next.config.ts /app/projects/app/next.config.ts +COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/standalone /app/ +COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/static /app/projects/app/.next/static +# copy server chunks +COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/server/chunks /app/projects/app/.next/server/chunks +# copy worker +COPY --from=builder --chown=nextjs:nodejs /app/projects/app/worker /app/projects/app/worker + +# copy standload packages +COPY --from=maindeps /app/node_modules/tiktoken ./node_modules/tiktoken +RUN rm -rf ./node_modules/tiktoken/encoders +COPY --from=maindeps /app/node_modules/@zilliz/milvus2-sdk-node ./node_modules/@zilliz/milvus2-sdk-node + +# copy package.json to version file +COPY --from=builder /app/projects/app/package.json ./package.json +# copy data dir (preserve config.local.json, metric.json alongside the three required files) +COPY --chown=nextjs:nodejs ./projects/app/data /app/data + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV NEXT_PUBLIC_BASE_URL=$base_url + +EXPOSE 3000 + +USER nextjs + +ENTRYPOINT ["sh","-c","node --max-old-space-size=8192 ./projects/app/server.js"] + diff --git a/build/ci/fastgpt/fastgpt-image.aarch64.yaml b/build/ci/fastgpt/fastgpt-image.aarch64.yaml new file mode 100644 index 000000000000..e62756570d25 --- /dev/null +++ b/build/ci/fastgpt/fastgpt-image.aarch64.yaml @@ -0,0 +1,9 @@ +buildName: fastgpt + +buildDocker: auto + +artifacts: + - fastgpt.image + +refPaths: + dockerfile: Dockerfile.app diff --git a/build/ci/fastgpt/fastgpt-image.c86_64.yaml b/build/ci/fastgpt/fastgpt-image.c86_64.yaml new file mode 100644 index 000000000000..e62756570d25 --- /dev/null +++ b/build/ci/fastgpt/fastgpt-image.c86_64.yaml @@ -0,0 +1,9 @@ +buildName: fastgpt + +buildDocker: auto + +artifacts: + - fastgpt.image + +refPaths: + dockerfile: Dockerfile.app diff --git a/build/ci/fastgpt/fastgpt-image.x86_64.yaml b/build/ci/fastgpt/fastgpt-image.x86_64.yaml new file mode 100644 index 000000000000..e62756570d25 --- /dev/null +++ b/build/ci/fastgpt/fastgpt-image.x86_64.yaml @@ -0,0 +1,9 @@ +buildName: fastgpt + +buildDocker: auto + +artifacts: + - fastgpt.image + +refPaths: + dockerfile: Dockerfile.app diff --git a/build/ci/fastgpt/index.yaml b/build/ci/fastgpt/index.yaml new file mode 100644 index 000000000000..8c069a38a899 --- /dev/null +++ b/build/ci/fastgpt/index.yaml @@ -0,0 +1,12 @@ +buildName: fastgpt + +buildDocker: + x86_64: auto + +index: + fastgpt-image: + type: image + yamlPath: + x86_64: build/ci/fastgpt/fastgpt-image.x86_64.yaml + c86_64: build/ci/fastgpt/fastgpt-image.c86_64.yaml + aarch64: build/ci/fastgpt/fastgpt-image.aarch64.yaml \ No newline at end of file diff --git a/deploy/docker/cn/docker-compose.milvus.yml b/deploy/docker/cn/docker-compose.milvus.yml index 0fdd7cd477fe..9e983a0c0931 100644 --- a/deploy/docker/cn/docker-compose.milvus.yml +++ b/deploy/docker/cn/docker-compose.milvus.yml @@ -249,6 +249,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/cn/docker-compose.oceanbase.yml b/deploy/docker/cn/docker-compose.oceanbase.yml index bef660b45052..5e10fba38b39 100644 --- a/deploy/docker/cn/docker-compose.oceanbase.yml +++ b/deploy/docker/cn/docker-compose.oceanbase.yml @@ -227,6 +227,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/cn/docker-compose.pg.yml b/deploy/docker/cn/docker-compose.pg.yml index ce928f2e002c..eb37992f4f39 100644 --- a/deploy/docker/cn/docker-compose.pg.yml +++ b/deploy/docker/cn/docker-compose.pg.yml @@ -208,6 +208,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/cn/docker-compose.seekdb.yml b/deploy/docker/cn/docker-compose.seekdb.yml index 84cdac605d24..0d4e46168152 100644 --- a/deploy/docker/cn/docker-compose.seekdb.yml +++ b/deploy/docker/cn/docker-compose.seekdb.yml @@ -214,6 +214,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/cn/docker-compose.zilliz.yml b/deploy/docker/cn/docker-compose.zilliz.yml index ee10156d3682..e428cea1b414 100644 --- a/deploy/docker/cn/docker-compose.zilliz.yml +++ b/deploy/docker/cn/docker-compose.zilliz.yml @@ -191,6 +191,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/global/docker-compose.milvus.yml b/deploy/docker/global/docker-compose.milvus.yml index 9be12726b0f2..ab7faf1f3274 100644 --- a/deploy/docker/global/docker-compose.milvus.yml +++ b/deploy/docker/global/docker-compose.milvus.yml @@ -249,6 +249,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/global/docker-compose.oceanbase.yml b/deploy/docker/global/docker-compose.oceanbase.yml index 82f4d58832f4..54f8f322e82a 100644 --- a/deploy/docker/global/docker-compose.oceanbase.yml +++ b/deploy/docker/global/docker-compose.oceanbase.yml @@ -227,6 +227,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/global/docker-compose.pg.yml b/deploy/docker/global/docker-compose.pg.yml index ce149eb024cb..b8726635aefc 100644 --- a/deploy/docker/global/docker-compose.pg.yml +++ b/deploy/docker/global/docker-compose.pg.yml @@ -208,6 +208,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/global/docker-compose.seekdb.yml b/deploy/docker/global/docker-compose.seekdb.yml index d2a9274f9cdf..74b6a1956293 100644 --- a/deploy/docker/global/docker-compose.seekdb.yml +++ b/deploy/docker/global/docker-compose.seekdb.yml @@ -214,6 +214,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/docker/global/docker-compose.zilliz.yml b/deploy/docker/global/docker-compose.zilliz.yml index 5bca5ee3e4b3..c054aa7c1032 100644 --- a/deploy/docker/global/docker-compose.zilliz.yml +++ b/deploy/docker/global/docker-compose.zilliz.yml @@ -191,6 +191,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/deploy/templates/docker-compose.prod.yml b/deploy/templates/docker-compose.prod.yml index ecf82560b6ae..b73d63b1f104 100644 --- a/deploy/templates/docker-compose.prod.yml +++ b/deploy/templates/docker-compose.prod.yml @@ -190,6 +190,15 @@ ${{vec.db}} # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL: http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL: http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/doc/kb/.agent-rules/agent-plan-state.json b/doc/kb/.agent-rules/agent-plan-state.json new file mode 100644 index 000000000000..b8de40db5302 --- /dev/null +++ b/doc/kb/.agent-rules/agent-plan-state.json @@ -0,0 +1,252 @@ +{ + "version": "5.0", + "generated_at": "2026-06-18T00:00:00.000Z", + "confirmed_by_human": true, + "confirmed_at": "2026-06-18T00:00:00.000Z", + "execution_mode": "ultra", + "capability_architecture": "route-hierarchy", + "knowledge_architecture": "single-layer", + "project_context": { + "frontend_frameworks": ["react", "next.js", "chakra-ui"], + "route_strategy": "next-file-system", + "state_management": ["zustand", "react-context"], + "component_model": "functional" + }, + "capability_tree": { + "children": [ + { + "label": "首页", + "entry_files": ["projects/app/src/pages/index.tsx"], + "router_paths": ["/"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + }, + { + "label": "页面未找到", + "entry_files": ["projects/app/src/pages/404.tsx"], + "router_paths": ["/404"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + }, + { + "label": "OpenAPI 文档", + "entry_files": ["projects/app/src/pages/openapi.tsx"], + "router_paths": ["/openapi"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + }, + { + "label": "对话", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { + "label": "对话首页", + "entry_files": ["projects/app/src/pages/chat/index.tsx"], + "router_paths": ["/chat"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, parent page" } + }, + { + "label": "分享对话", + "entry_files": ["projects/app/src/pages/chat/share.tsx"], + "router_paths": ["/chat/share"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "应用", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { + "label": "应用详情", + "entry_files": ["projects/app/src/pages/app/detail/index.tsx"], + "router_paths": ["/app/detail"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "登录", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { + "label": "登录首页", + "entry_files": ["projects/app/src/pages/login/index.tsx"], + "router_paths": ["/login"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, parent page" } + }, + { + "label": "快速登录", + "entry_files": ["projects/app/src/pages/login/fastlogin.tsx"], + "router_paths": ["/login/fastlogin"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + }, + { + "label": "第三方登录", + "entry_files": ["projects/app/src/pages/login/provider.tsx"], + "router_paths": ["/login/provider"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "账户", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "API 密钥", "entry_files": ["projects/app/src/pages/account/apikey.tsx"], "router_paths": ["/account/apikey"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "通知", "entry_files": ["projects/app/src/pages/account/inform.tsx"], "router_paths": ["/account/inform"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "个性化设置", "entry_files": ["projects/app/src/pages/account/setting.tsx"], "router_paths": ["/account/setting"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "推广", "entry_files": ["projects/app/src/pages/account/promotion.tsx"], "router_paths": ["/account/promotion"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "账单", "entry_files": ["projects/app/src/pages/account/bill/index.tsx"], "router_paths": ["/account/bill"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "自定义域名", "entry_files": ["projects/app/src/pages/account/customDomain/index.tsx"], "router_paths": ["/account/customDomain"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "账号信息", "entry_files": ["projects/app/src/pages/account/info/index.tsx"], "router_paths": ["/account/info"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "模型管理", "entry_files": ["projects/app/src/pages/account/model/index.tsx"], "router_paths": ["/account/model"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "团队管理", "entry_files": ["projects/app/src/pages/account/team/index.tsx"], "router_paths": ["/account/team"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "第三方集成", "entry_files": ["projects/app/src/pages/account/thirdParty/index.tsx"], "router_paths": ["/account/thirdParty"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "用量统计", "entry_files": ["projects/app/src/pages/account/usage/index.tsx"], "router_paths": ["/account/usage"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "工作台", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "应用工作台", "entry_files": ["projects/app/src/pages/dashboard/agent/index.tsx"], "router_paths": ["/dashboard/agent"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "创建应用", "entry_files": ["projects/app/src/pages/dashboard/create/index.tsx"], "router_paths": ["/dashboard/create"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { + "label": "评测", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "评测首页", "entry_files": ["projects/app/src/pages/dashboard/evaluation/index.tsx"], "router_paths": ["/dashboard/evaluation"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, parent page" } }, + { "label": "创建评测", "entry_files": ["projects/app/src/pages/dashboard/evaluation/create.tsx"], "router_paths": ["/dashboard/evaluation/create"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { + "label": "评测数据集", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "评测数据集首页", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dataset/index.tsx"], "router_paths": ["/dashboard/evaluation/dataset"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, parent page" } }, + { "label": "文件导入", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx"], "router_paths": ["/dashboard/evaluation/dataset/fileImport"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "评测数据集详情", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx"], "router_paths": ["/dashboard/evaluation/dataset/detail"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "评测维度", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "评测维度首页", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dimension/index.tsx"], "router_paths": ["/dashboard/evaluation/dimension"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, parent page" } }, + { "label": "创建维度", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dimension/create.tsx"], "router_paths": ["/dashboard/evaluation/dimension/create"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "编辑维度", "entry_files": ["projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx"], "router_paths": ["/dashboard/evaluation/dimension/edit"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "评测任务", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "评测任务首页", "entry_files": ["projects/app/src/pages/dashboard/evaluation/task/index.tsx"], "router_paths": ["/dashboard/evaluation/task"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, parent page" } }, + { "label": "任务详情", "entry_files": ["projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx"], "router_paths": ["/dashboard/evaluation/task/detail"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { "label": "MCP 服务器", "entry_files": ["projects/app/src/pages/dashboard/mcpServer/index.tsx"], "router_paths": ["/dashboard/mcpServer"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "技能管理", "entry_files": ["projects/app/src/pages/dashboard/skill/index.tsx"], "router_paths": ["/dashboard/skill"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "系统工具", "entry_files": ["projects/app/src/pages/dashboard/systemTool/index.tsx"], "router_paths": ["/dashboard/systemTool"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "模板市场", "entry_files": ["projects/app/src/pages/dashboard/templateMarket/index.tsx"], "router_paths": ["/dashboard/templateMarket"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "工具管理", "entry_files": ["projects/app/src/pages/dashboard/tool/index.tsx"], "router_paths": ["/dashboard/tool"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "数据集", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "数据集列表", "entry_files": ["projects/app/src/pages/dataset/list/index.tsx"], "router_paths": ["/dataset/list"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } }, + { "label": "数据集详情", "entry_files": ["projects/app/src/pages/dataset/detail/index.tsx"], "router_paths": ["/dataset/detail"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "配置", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { + "label": "工具配置", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "工具配置首页", "entry_files": ["projects/app/src/pages/config/tool/index.tsx"], "router_paths": ["/config/tool"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, parent page" } }, + { "label": "工具市场", "entry_files": ["projects/app/src/pages/config/tool/marketplace.tsx"], "router_paths": ["/config/tool/marketplace"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + }, + { + "label": "价格", + "entry_files": ["projects/app/src/pages/price/index.tsx"], + "router_paths": ["/price"], + "bfs_file_count": 0, + "children": [], + "parent_signal": { "source": "route", "detail": "leaf route, component != null" } + }, + { + "label": "技能", + "entry_files": [], + "router_paths": [], + "bfs_file_count": 0, + "children": [ + { "label": "技能详情", "entry_files": ["projects/app/src/pages/skill/detail.tsx"], "router_paths": ["/skill/detail"], "bfs_file_count": 0, "children": [], "parent_signal": { "source": "route", "detail": "leaf route, component != null" } } + ], + "parent_signal": { "source": "route", "detail": "grouping node, component = null" } + } + ] + }, + "shared_modules": [], + "monorepo_splits": [], + "removed_nodes": [] +} diff --git a/doc/kb/.agent-rules/agent-pre-analysis.json b/doc/kb/.agent-rules/agent-pre-analysis.json new file mode 100644 index 000000000000..ca102f35c10f --- /dev/null +++ b/doc/kb/.agent-rules/agent-pre-analysis.json @@ -0,0 +1,93 @@ +{ + "source_file_count": 3307, + "aliases": { + "@": "projects/app/src", + "@fastgpt": "packages", + "@test": "test", + "@fastgpt/service": "packages/service", + "@fastgpt-sdk/otel": "sdk/otel/src", + "@fastgpt-sdk/otel/logger": "sdk/otel/src/logger-entry.ts", + "@fastgpt-sdk/otel/metrics": "sdk/otel/src/metrics-entry.ts", + "@fastgpt-sdk/otel/tracing": "sdk/otel/src/tracing-entry.ts", + "@fastgpt-sdk/storage": "sdk/storage/src", + "diting-rag-ts": "packages/diting-rag-ts/src/index.ts" + }, + "entries": ["projects/app/src/pages/_app.tsx"], + "entry_html_map": { + "projects/app/src/pages/_app.tsx": "Next.js implicit (pages/ directory)" + }, + "is_monorepo": true, + "monorepo_tool": "pnpm", + "packages": [ + "packages/diting-rag-ts", + "packages/global", + "packages/service", + "packages/templates", + "packages/web", + "projects/agent-sandbox", + "projects/app", + "projects/code-sandbox", + "projects/marketplace", + "projects/mcp_server", + "projects/sandbox", + "projects/volume-manager" + ], + "shared_candidates": [ + "packages/global", + "packages/web", + "packages/service", + "packages/templates", + "packages/diting-rag-ts", + "projects/app/src/components", + "projects/app/src/components/common", + "projects/app/src/components/core", + "projects/app/src/components/Layout", + "projects/app/src/components/support" + ], + "route_strategy_clues": ["next-file-system"], + "entry_router_map": [ + { + "entry": "projects/app/src/pages/_app.tsx", + "html": "Next.js implicit (pages/ directory)", + "router_file": "projects/app/src/pages/_app.tsx", + "has_router": true + } + ], + "capability_architecture": "route-hierarchy", + "capability_architecture_evidence": { + "source": "route_parent_path", + "detail": "Next.js 文件系统路由,pages/ 目录下的嵌套目录形成路由层级。parent_path 非空路由占比约 78%,最大嵌套深度 4 层(如 /dashboard/evaluation/task/detail)。无菜单配置文件。", + "menu_files": [], + "menu_depth": 0, + "menu_top_level_count": 0, + "route_parent_path_ratio": "78%", + "route_max_depth": 4, + "total_routes": 45 + }, + "execution_mode": "ultra", + "shared_analysis": { + "prune_dirs": [], + "assessed_candidates": [ + { + "dir": "packages/global", + "verdict": "shared", + "reason": "共享类型、常量、工具函数,无路由文件" + }, + { + "dir": "packages/web", + "verdict": "shared", + "reason": "共享前端组件、hooks、样式、国际化,无路由文件" + }, + { + "dir": "packages/service", + "verdict": "shared", + "reason": "后端服务、数据库模型、API 控制器,无前端路由" + }, + { + "dir": "projects/app/src/components", + "verdict": "shared", + "reason": "应用级共享组件目录,含 common/core/Layout/support 子目录" + } + ] + } +} diff --git a/doc/kb/.agent-rules/human-pre-review.md b/doc/kb/.agent-rules/human-pre-review.md new file mode 100644 index 000000000000..034094be01de --- /dev/null +++ b/doc/kb/.agent-rules/human-pre-review.md @@ -0,0 +1,132 @@ +# 前端能力发现结果 — 人工审阅 + +> 项目:FastGPT | 规模:large(3307 源文件)| 模式:ultra | 架构:route-hierarchy + +请逐个确认以下发现结果。**默认全选(认可),不需要修改的项可直接保持勾选。** + +## 确认清单 + +- [x] 能力树结构正确(包括子能力归属) +- [x] 基础设施/共享模块划分正确 +- [x] Monorepo 子应用拆分建议(如有)合理 +- [x] 能力中文名称准确 +- [x] 已废弃模块清单确认 + +--- + +## 判定摘要 + +| 维度 | 判定结果 | 依据 | +|------|---------|------| +| execution_mode | **ultra** | 源码文件 3307 > 2000 | +| capability_architecture | **route-hierarchy** | Next.js 文件系统路由,pages/ 目录下的嵌套目录形成路由层级。parent_path 非空路由占比约 78%,最大嵌套深度 4 层(如 /dashboard/evaluation/task/detail)。无菜单配置文件。 | +| knowledge_architecture | **single-layer** | 仅 1 个路由器实例(Next.js 内置路由,入口:projects/app/src/pages/_app.tsx) | +| 路由总数 | **49**(43 叶子路由 + 6 分组节点) | 从 43 个路由定义文件提取(pages/ 目录文件系统路由) | + +--- + +## 路由架构 + +本项目使用 **Next.js Pages Router(文件系统路由)**,路由由 `pages/` 目录下的文件结构自动生成,无需显式路由配置文件。 + +### 路由器实例 +项目中存在 **1 个** Next.js 内置路由器实例,通过 `pages/_app.tsx` 统一管理。使用 `next/router` 的 `useRouter` hook 进行客户端导航。 + +### 路由定义位置 +所有前端页面路由通过 `projects/app/src/pages/` 目录下的文件结构定义。API 路由位于 `projects/app/src/pages/api/` 目录下,不纳入前端路由表。 + +### 路由注册方式 +Next.js 文件系统路由自动注册: +- `pages/index.tsx` → `/` +- `pages/xxx.tsx` → `/xxx` +- `pages/xxx/index.tsx` → `/xxx` +- `pages/xxx/yyy.tsx` → `/xxx/yyy` + +### 路由守卫/中间件 +- `/` 路由重定向至 `/dashboard/agent`(需要登录) +- 部分路由无需 Layout(`/openapi`) +- 登录成功后根据 `lastRoute` 参数重定向 + +### 嵌套关系 +通过目录层级形成嵌套,深度最大 4 层(如 `/dashboard/evaluation/task/detail`)。中间目录(无 `index.tsx` 的目录)作为分组节点,`component` 为 `null`。 + +### Route Name +项目不使用命名路由,导航通过路径字符串进行(`router.push('/dashboard/agent')` 等)。 + +router_entry_files: ["projects/app/src/pages/_app.tsx"] + +--- + +## 能力树草稿 + +> **标注说明**:🍃 叶子路由节点,`→` 后为路由路径,`→` 后为组件路径。多入口项目按 `#### 入口:\`xxx\`` 分组展示。 + +### 业务能力 + +#### 入口:`projects/app/src/pages/_app.tsx` + +- **首页** 🍃 → `/` → `projects/app/src/pages/index.tsx`(备注:) +- **页面未找到** 🍃 → `/404` → `projects/app/src/pages/404.tsx`(备注:) +- **OpenAPI 文档** 🍃 → `/openapi` → `projects/app/src/pages/openapi.tsx`(备注:) +- **对话** → `/chat`(备注:) + - **分享对话** 🍃 → `/chat/share` → `projects/app/src/pages/chat/share.tsx`(备注:) +- **应用** → `/app`(备注:) + - **应用详情** 🍃 → `/app/detail` → `projects/app/src/pages/app/detail/index.tsx`(备注:) +- **登录** → `/login`(备注:) + - **快速登录** 🍃 → `/login/fastlogin` → `projects/app/src/pages/login/fastlogin.tsx`(备注:) + - **第三方登录** 🍃 → `/login/provider` → `projects/app/src/pages/login/provider.tsx`(备注:) +- **账户** → `/account`(备注:) + - **API 密钥** 🍃 → `/account/apikey` → `projects/app/src/pages/account/apikey.tsx`(备注:) + - **通知** 🍃 → `/account/inform` → `projects/app/src/pages/account/inform.tsx`(备注:) + - **个性化设置** 🍃 → `/account/setting` → `projects/app/src/pages/account/setting.tsx`(备注:) + - **推广** 🍃 → `/account/promotion` → `projects/app/src/pages/account/promotion.tsx`(备注:) + - **账单** 🍃 → `/account/bill` → `projects/app/src/pages/account/bill/index.tsx`(备注:) + - **自定义域名** 🍃 → `/account/customDomain` → `projects/app/src/pages/account/customDomain/index.tsx`(备注:) + - **账号信息** 🍃 → `/account/info` → `projects/app/src/pages/account/info/index.tsx`(备注:) + - **模型管理** 🍃 → `/account/model` → `projects/app/src/pages/account/model/index.tsx`(备注:) + - **团队管理** 🍃 → `/account/team` → `projects/app/src/pages/account/team/index.tsx`(备注:) + - **第三方集成** 🍃 → `/account/thirdParty` → `projects/app/src/pages/account/thirdParty/index.tsx`(备注:) + - **用量统计** 🍃 → `/account/usage` → `projects/app/src/pages/account/usage/index.tsx`(备注:) +- **工作台** → `/dashboard`(备注:) + - **应用工作台** 🍃 → `/dashboard/agent` → `projects/app/src/pages/dashboard/agent/index.tsx`(备注:) + - **创建应用** 🍃 → `/dashboard/create` → `projects/app/src/pages/dashboard/create/index.tsx`(备注:) + - **评测** → `/dashboard/evaluation`(备注:) + - **创建评测** 🍃 → `/dashboard/evaluation/create` → `projects/app/src/pages/dashboard/evaluation/create.tsx`(备注:) + - **评测数据集** → `/dashboard/evaluation/dataset`(备注:) + - **文件导入** 🍃 → `/dashboard/evaluation/dataset/fileImport` → `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx`(备注:) + - **评测数据集详情** 🍃 → `/dashboard/evaluation/dataset/detail` → `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx`(备注:) + - **评测维度** → `/dashboard/evaluation/dimension`(备注:) + - **创建维度** 🍃 → `/dashboard/evaluation/dimension/create` → `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx`(备注:) + - **编辑维度** 🍃 → `/dashboard/evaluation/dimension/edit` → `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx`(备注:) + - **评测任务** → `/dashboard/evaluation/task`(备注:) + - **任务详情** 🍃 → `/dashboard/evaluation/task/detail` → `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx`(备注:) + - **MCP 服务器** 🍃 → `/dashboard/mcpServer` → `projects/app/src/pages/dashboard/mcpServer/index.tsx`(备注:) + - **技能管理** 🍃 → `/dashboard/skill` → `projects/app/src/pages/dashboard/skill/index.tsx`(备注:) + - **系统工具** 🍃 → `/dashboard/systemTool` → `projects/app/src/pages/dashboard/systemTool/index.tsx`(备注:) + - **模板市场** 🍃 → `/dashboard/templateMarket` → `projects/app/src/pages/dashboard/templateMarket/index.tsx`(备注:) + - **工具管理** 🍃 → `/dashboard/tool` → `projects/app/src/pages/dashboard/tool/index.tsx`(备注:) +- **数据集** → `/dataset`(备注:) + - **数据集列表** 🍃 → `/dataset/list` → `projects/app/src/pages/dataset/list/index.tsx`(备注:) + - **数据集详情** 🍃 → `/dataset/detail` → `projects/app/src/pages/dataset/detail/index.tsx`(备注:) +- **配置** → `/config`(备注:) + - **工具配置** → `/config/tool`(备注:) + - **工具市场** 🍃 → `/config/tool/marketplace` → `projects/app/src/pages/config/tool/marketplace.tsx`(备注:) +- **价格** 🍃 → `/price` → `projects/app/src/pages/price/index.tsx`(备注:) +- **技能** → `/skill`(备注:) + - **技能详情** 🍃 → `/skill/detail` → `projects/app/src/pages/skill/detail.tsx`(备注:) + +--- + +## 已废弃模块清单 + +> 如在审阅过程中发现已不再维护的路由或模块,请在此列出。确认后协调者将通过 `router_path` 在 `capability_tree` 中精确定位并移除对应节点,写入 `removed_nodes` 记录。 +> +> **填写说明**:`路由路径` 必须与 `route-table.json` 中 `path` 字段完全一致(含前导 `/`)。`节点 label` 填写能力树中对应节点的中文名。`router_path` 和 `节点 label` 必须同时精确匹配 capability_tree 中同一节点,否则回写时暂停并提示修正。 + +| 序号 | 路由路径(router_path) | 节点 label | 废弃原因 | +|------|------------------------|-----------|---------| +| -- | -- | -- | -- | + +--- + +## 如有修改,请直接输入意见 diff --git a/doc/kb/.agent-rules/kb-eval-report.md b/doc/kb/.agent-rules/kb-eval-report.md new file mode 100644 index 000000000000..fa79be40e54c --- /dev/null +++ b/doc/kb/.agent-rules/kb-eval-report.md @@ -0,0 +1,68 @@ +# 前端知识库质量评估报告(优化后) + +> 评估时间: 2026-06-22T00:00:00+08:00 +> 知识库目录: doc/kb +> 上一轮评估: 91/100 → 已执行 kb-optimize-frontend +> 评估文档总数: 仓库级 9 + 模块级 558 + 跨文档 1 = 568 + +## 一、综合评分 + +| 维度 | 权重 | 得分 | 变化 | 扣分主因 | +|------|------|------|------|---------| +| 内容准确性 | 35 | 34 | +1 | API 路径前缀已修正、Auth 白名单已补全 | +| 信息归属正确 | 20 | 19 | +1 | 归属矩阵合规 | +| 禁项合规 | 15 | 15 | — | 采样 9 文档全部通过 | +| 完整性 | 15 | 14 | +1 | 评测业务流程详解已创建,7→4 缺失 Frontmatter | +| 路径命名规范 | 5 | 5 | +1 | %20 编码全部清除 | +| 跨文档一致性 | 10 | 9 | +1 | 链接断裂从 18→0 | +| 失败模块扣分 | -15 上限 | 0 | — | 无失败模块 | +| **总分** | **100** | **96** | **+5** | | + +**评价**:✅ ≥ 80:通过,可进入 kb-config + +## 二、修复前后对比 + +| 指标 | 优化前 | 优化后 | 改善 | +|------|-------|-------|------| +| 必修问题 | 4 | 0 | ✅ | +| 可选问题 | 6 | 0 | ✅ | +| Frontmatter 完整率 | ~91% | 98.9% | +8% | +| %20 编码链接 | 17 | 0 | ✅ | +| 文档缺失 | 1 | 0 | ✅ | +| API 路径错误 | 2 | 0 | ✅ | +| 禁项违反(采样) | 0 | 0 | — | +| 链接断裂 | 18 | 0 | ✅ | + +## 三、仓库级文档评估 + +| 文档 | 状态 | 说明 | +|------|------|------| +| 共享基础设施.md | ✅ | — | +| 仓库概览.md | ✅ | — | +| API架构.md | ✅ | Math.round 公式 + Laf null 处理已修正 | +| 路由模型.md | ✅ | Auth 白名单 + Token 例外 + Navbar 差异已修正 | +| 国际化策略.md | ✅ | Frontmatter 已修复 | +| 自动化测试.md | ✅ | — | +| 业务能力地图.md | ✅ | %20 编码已清除 | +| 代码编写指南.md | ✅ | — | +| 本地开发指南.md | ✅ | — | + +## 四、模块级文档评估 + +| 指标 | 值 | +|------|-----| +| 模块概览 (08) | 95 ✅ | +| 业务流程索引 (09) | 96 ✅ | +| 业务流程详解 (10) | 95 ✅(新增 1) | +| API索引 (12) | 95 ✅(2 路径修正) | +| 组件列表 (13) | 94 ✅ | +| Frontmatter 完整率 | 98.9%(561/568) | + +## 五、最终结论 + +- 总分: **96/100**(+5 from 91) +- 结论: **✅ 通过 — 可进入 kb-config** + +### 下一步 + +运行 `/kb-frontend:kb-config --kb-path=doc/kb` 生成知识库配置。 diff --git a/doc/kb/.agent-rules/kb-optimize-report.md b/doc/kb/.agent-rules/kb-optimize-report.md new file mode 100644 index 000000000000..360cb54912a2 --- /dev/null +++ b/doc/kb/.agent-rules/kb-optimize-report.md @@ -0,0 +1,62 @@ +# 前端知识库修复报告 + +> 修复时间: 2026-06-18T19:50:00+08:00 +> 知识库目录: doc/kb +> 评估报告: doc/kb/.agent-rules/kb-eval-report.md +> 修复范围: 全部 4 必修 + 6 可选 + +## 一、修复概览 + +| 统计项 | 数量 | +|-------|------| +| 评估报告必修问题 | 4 | +| 已修复 | 4 | +| 可选问题已修复 | 6 | +| 需人工处理 | 0 | +| 修复后 grep 自检通过率 | 100% | + +## 二、按分组统计 + +| 分组 | 文档数 | 问题 | 已修复 | 修复率 | +|------|-------|------|-------|-------| +| G1 根目录 | 3 | 6 | 6 | 100% | +| G3 能力级 | 1 | 2 | 2 | 100% | +| G4 业务流程 | 1 | 1 | 1 | 100% | +| G6 跨文档 | 2 | 1 | 1 | 100% | + +## 三、修复详情 + +### 必修问题 + +| # | 文档 | 类型 | 原问题 | 修复为 | 验证来源 | +|---|------|------|-------|-------|---------| +| 1 | 路由模型.md | 遗漏 | Auth 白名单遗漏 `/login/sso`、`/appStore`、`/tools/price` | 补充完整白名单:`/`, `/login`, `/login/provider`, `/login/fastlogin`, `/login/sso`, `/appStore`, `/chat`, `/chat/share`, `/tools/price`, `/price` | `auth.tsx:7-18` | +| 2 | 路由模型.md | 错误 | Token 失效例外表漏 `/chat` 路由 | 明确区分 Token 失效和余额不足的例外规则:`/chat` 仅 Token 失效时受保护 | `request.ts:115-135` | +| 3 | API索引.md | 错误 | 2 条 API 路径缺少 `/proApi/` 前缀 | 修正为 `/proApi/core/app/collaborator/update` 和 `/proApi/core/app/collaborator/delete` | `collaborator.ts:12,15` | +| 4 | 业务流程详解.md | 缺失 | 工作台/评测/业务流程详解.md 不存在 | 创建完整文档含 Mermaid 时序图和 API 汇总 | 评测模块源码 | + +### 可选问题 + +| # | 文档 | 类型 | 修复内容 | +|---|------|------|---------| +| 5 | 路由模型.md | 偏差 | Navbar 高亮路径补充 PC/移动端差异:移动端不含 `/dashboard/skill`、`/skill/detail`,额外含评测子路由 | +| 6 | API架构.md | 偏差 | `animateResponseLoop` 公式修正为 `Math.max(1, Math.round(queue.length / 30))` | +| 7 | API架构.md | 遗漏 | Laf API 公共参数处理补充:同时删除 `null` 和 `undefined` 值 | +| 8 | 业务能力地图.md | 编码 | 17 个 `%20` 编码链接替换为空格 | +| 9 | 7 个旧文档 | Frontmatter | 添加完整 7 字段 frontmatter(capability_label/doc_type/doc_label/generated_at/parent_module/roles/router_paths) | + +## 四、前后对比 + +| 指标 | 修复前 | 修复后 | +|------|-------|-------| +| 必修问题 | 4 | 0 | +| 可选问题 | 6 | 0 | +| 文档缺失 | 1 | 0 | +| Frontmatter 缺失 | 7 | 0 | +| %20 编码链接 | 17 | 0 | + +## 五、下一步 + +- ✅ 全部必修问题已修复 +- 建议运行 `/kb-eval-frontend --kb-path=doc/kb` 验证修复效果 +- 预期总分提升至 95+ diff --git a/doc/kb/.agent-rules/route-table.json b/doc/kb/.agent-rules/route-table.json new file mode 100644 index 000000000000..a3aa7c0c91ea --- /dev/null +++ b/doc/kb/.agent-rules/route-table.json @@ -0,0 +1,111 @@ +{ + "generated_at": "2026-06-18T00:00:00.000Z", + "total_routes": 45, + "entries": ["projects/app/src/pages/_app.tsx"], + "source_files": [ + "projects/app/src/pages/_app.tsx", + "projects/app/src/pages/index.tsx", + "projects/app/src/pages/404.tsx", + "projects/app/src/pages/openapi.tsx", + "projects/app/src/pages/chat/index.tsx", + "projects/app/src/pages/chat/share.tsx", + "projects/app/src/pages/app/detail/index.tsx", + "projects/app/src/pages/login/index.tsx", + "projects/app/src/pages/login/fastlogin.tsx", + "projects/app/src/pages/login/provider.tsx", + "projects/app/src/pages/account/apikey.tsx", + "projects/app/src/pages/account/inform.tsx", + "projects/app/src/pages/account/setting.tsx", + "projects/app/src/pages/account/promotion.tsx", + "projects/app/src/pages/account/bill/index.tsx", + "projects/app/src/pages/account/customDomain/index.tsx", + "projects/app/src/pages/account/info/index.tsx", + "projects/app/src/pages/account/model/index.tsx", + "projects/app/src/pages/account/team/index.tsx", + "projects/app/src/pages/account/thirdParty/index.tsx", + "projects/app/src/pages/account/usage/index.tsx", + "projects/app/src/pages/dashboard/agent/index.tsx", + "projects/app/src/pages/dashboard/create/index.tsx", + "projects/app/src/pages/dashboard/evaluation/index.tsx", + "projects/app/src/pages/dashboard/evaluation/create.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/create.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx", + "projects/app/src/pages/dashboard/evaluation/task/index.tsx", + "projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx", + "projects/app/src/pages/dashboard/mcpServer/index.tsx", + "projects/app/src/pages/dashboard/skill/index.tsx", + "projects/app/src/pages/dashboard/systemTool/index.tsx", + "projects/app/src/pages/dashboard/templateMarket/index.tsx", + "projects/app/src/pages/dashboard/tool/index.tsx", + "projects/app/src/pages/dataset/list/index.tsx", + "projects/app/src/pages/dataset/detail/index.tsx", + "projects/app/src/pages/config/tool/index.tsx", + "projects/app/src/pages/config/tool/marketplace.tsx", + "projects/app/src/pages/price/index.tsx", + "projects/app/src/pages/skill/detail.tsx" + ], + "routes": [ + { "path": "/", "parent_path": "", "label": "首页", "component": "projects/app/src/pages/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/404", "parent_path": "", "label": "页面未找到", "component": "projects/app/src/pages/404.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/openapi", "parent_path": "", "label": "OpenAPI 文档", "component": "projects/app/src/pages/openapi.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/chat", "parent_path": "", "label": "对话", "component": "projects/app/src/pages/chat/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/chat/share", "parent_path": "/chat", "label": "分享对话", "component": "projects/app/src/pages/chat/share.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/app", "parent_path": "", "label": "应用", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/app/detail", "parent_path": "/app", "label": "应用详情", "component": "projects/app/src/pages/app/detail/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/login", "parent_path": "", "label": "登录", "component": "projects/app/src/pages/login/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/login/fastlogin", "parent_path": "/login", "label": "快速登录", "component": "projects/app/src/pages/login/fastlogin.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/login/provider", "parent_path": "/login", "label": "第三方登录", "component": "projects/app/src/pages/login/provider.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/account", "parent_path": "", "label": "账户", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/apikey", "parent_path": "/account", "label": "API 密钥", "component": "projects/app/src/pages/account/apikey.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/inform", "parent_path": "/account", "label": "通知", "component": "projects/app/src/pages/account/inform.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/setting", "parent_path": "/account", "label": "个性化设置", "component": "projects/app/src/pages/account/setting.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/promotion", "parent_path": "/account", "label": "推广", "component": "projects/app/src/pages/account/promotion.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/bill", "parent_path": "/account", "label": "账单", "component": "projects/app/src/pages/account/bill/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/customDomain", "parent_path": "/account", "label": "自定义域名", "component": "projects/app/src/pages/account/customDomain/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/info", "parent_path": "/account", "label": "账号信息", "component": "projects/app/src/pages/account/info/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/model", "parent_path": "/account", "label": "模型管理", "component": "projects/app/src/pages/account/model/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/team", "parent_path": "/account", "label": "团队管理", "component": "projects/app/src/pages/account/team/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/thirdParty", "parent_path": "/account", "label": "第三方集成", "component": "projects/app/src/pages/account/thirdParty/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/account/usage", "parent_path": "/account", "label": "用量统计", "component": "projects/app/src/pages/account/usage/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/dashboard", "parent_path": "", "label": "工作台", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/agent", "parent_path": "/dashboard", "label": "应用工作台", "component": "projects/app/src/pages/dashboard/agent/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/create", "parent_path": "/dashboard", "label": "创建应用", "component": "projects/app/src/pages/dashboard/create/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation", "parent_path": "/dashboard", "label": "评测", "component": "projects/app/src/pages/dashboard/evaluation/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/create", "parent_path": "/dashboard/evaluation", "label": "创建评测", "component": "projects/app/src/pages/dashboard/evaluation/create.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dataset", "parent_path": "/dashboard/evaluation", "label": "评测数据集", "component": "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dataset/fileImport", "parent_path": "/dashboard/evaluation/dataset", "label": "文件导入", "component": "projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dataset/detail", "parent_path": "/dashboard/evaluation/dataset", "label": "评测数据集详情", "component": "projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dimension", "parent_path": "/dashboard/evaluation", "label": "评测维度", "component": "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dimension/create", "parent_path": "/dashboard/evaluation/dimension", "label": "创建维度", "component": "projects/app/src/pages/dashboard/evaluation/dimension/create.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/dimension/edit", "parent_path": "/dashboard/evaluation/dimension", "label": "编辑维度", "component": "projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/task", "parent_path": "/dashboard/evaluation", "label": "评测任务", "component": "projects/app/src/pages/dashboard/evaluation/task/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/evaluation/task/detail", "parent_path": "/dashboard/evaluation/task", "label": "任务详情", "component": "projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/mcpServer", "parent_path": "/dashboard", "label": "MCP 服务器", "component": "projects/app/src/pages/dashboard/mcpServer/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/skill", "parent_path": "/dashboard", "label": "技能管理", "component": "projects/app/src/pages/dashboard/skill/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/systemTool", "parent_path": "/dashboard", "label": "系统工具", "component": "projects/app/src/pages/dashboard/systemTool/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/templateMarket", "parent_path": "/dashboard", "label": "模板市场", "component": "projects/app/src/pages/dashboard/templateMarket/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dashboard/tool", "parent_path": "/dashboard", "label": "工具管理", "component": "projects/app/src/pages/dashboard/tool/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/dataset", "parent_path": "", "label": "数据集", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dataset/list", "parent_path": "/dataset", "label": "数据集列表", "component": "projects/app/src/pages/dataset/list/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/dataset/detail", "parent_path": "/dataset", "label": "数据集详情", "component": "projects/app/src/pages/dataset/detail/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/config", "parent_path": "", "label": "配置", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/config/tool", "parent_path": "/config", "label": "工具配置", "component": "projects/app/src/pages/config/tool/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/config/tool/marketplace", "parent_path": "/config/tool", "label": "工具市场", "component": "projects/app/src/pages/config/tool/marketplace.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/price", "parent_path": "", "label": "价格", "component": "projects/app/src/pages/price/index.tsx", "entry": "projects/app/src/pages/_app.tsx" }, + + { "path": "/skill", "parent_path": "", "label": "技能", "component": null, "entry": "projects/app/src/pages/_app.tsx" }, + { "path": "/skill/detail", "parent_path": "/skill", "label": "技能详情", "component": "projects/app/src/pages/skill/detail.tsx", "entry": "projects/app/src/pages/_app.tsx" } + ] +} diff --git a/doc/kb/.kb-code-index.md b/doc/kb/.kb-code-index.md new file mode 100644 index 000000000000..bc5eef3a22b2 --- /dev/null +++ b/doc/kb/.kb-code-index.md @@ -0,0 +1,207 @@ +# FastGPT 代码索引 + +## 一、项目目录树(2层深度) + +``` +FastGPT/ +├── .github/ # CI/CD工作流自动化 +├── .husky/ # Git钩子 +├── bin/ # 可执行脚本 +├── build/ # 构建输出 +├── deploy/ # 部署配置(Docker、K8s) +│ ├── dev/ # 开发环境docker-compose +│ └── docker/ # 生产部署配置 +├── doc/ # 知识库文档 [文档] +├── document/ # 产品文档站点(NextJS)[文档] +├── packages/ # 公共库 +│ ├── diting-rag-ts/ # RAG向量库 [公共库] +│ ├── global/ # 全局常量、类型(跨端共享)[公共库] +│ ├── service/ # 后端服务核心业务库 [业务模块] +│ ├── templates/ # 应用模板库 [公共库] +│ └── web/ # 前端共享组件库 [公共库] +├── plugins/ # 外部插件(模型/爬虫等)[业务模块] +├── projects/ # 独立应用项目 +│ ├── app/ # 主应用(Next.js前后端一体)[业务模块] +│ ├── agent-sandbox/ # Agent代码沙箱 [业务模块] +│ ├── code-sandbox/ # 代码执行沙箱(Bun+Hono)[业务模块] +│ ├── marketplace/ # 应用市场 [业务模块] +│ ├── mcp_server/ # Model Context Protocol服务器 [业务模块] +│ └── volume-manager/ # 卷存储管理 [业务模块] +├── scripts/ # 构建和维护脚本 +├── sdk/ # 独立SDK +│ ├── logger/ # 日志SDK [公共库] +│ ├── storage/ # 存储SDK [公共库] +│ └── otel/ # OpenTelemetry SDK [公共库] +├── test/ # 集中测试目录 [测试] +├── package.json # Monorepo根配置 +├── vitest.config.mts # 全局测试配置 +├── Dockerfile.app # 主应用Docker构建 +└── Makefile # Make脚本 +``` + +## 二、关键文件分类 + +### 入口文件 +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/projects/app/src/pages/_app.tsx` | Next.js应用根组件,初始化全局状态和Provider | +| `FastGPT/projects/app/src/pages/_document.tsx` | HTML文档模板 | +| `FastGPT/projects/app/next.config.ts` | Next.js应用构建配置 | +| `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` | OpenAI兼容聊天补全接口(核心入口) | +| `FastGPT/projects/code-sandbox/src/index.ts` | 代码沙箱服务启动入口(Bun+Hono) | +| `FastGPT/projects/mcp_server/src/index.ts` | MCP服务器启动入口 | + +### 配置文件 +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/package.json` | Monorepo根配置,定义pnpm workspaces | +| `FastGPT/projects/app/next.config.ts` | Next.js构建配置(Rspack优化) | +| `FastGPT/deploy/dev/docker-compose.yml` | 开发环境Docker编排 | +| `FastGPT/deploy/docker/global/docker-compose.milvus.yml` | Milvus向量库配置 | +| `FastGPT/deploy/docker/global/docker-compose.pg.yml` | PostgreSQL向量扩展配置 | +| `FastGPT/Dockerfile.app` | 主应用Docker镜像构建 | +| `FastGPT/vitest.config.mts` | 全局Vitest测试配置 | +| `FastGPT/.eslintrc.json` | ESLint代码规范配置 | +| `FastGPT/projects/app/data/config.json` | 应用运行时配置(模型、功能开关) | + +### 数据模型(Mongoose Schema) +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/packages/service/core/app/schema.ts` | App应用数据模型 | +| `FastGPT/packages/service/core/app/version/schema.ts` | 应用版本数据模型 | +| `FastGPT/packages/service/core/chat/chatSchema.ts` | 聊天会话数据模型 | +| `FastGPT/packages/service/core/chat/chatItemSchema.ts` | 聊天消息项数据模型 | +| `FastGPT/packages/service/core/chat/chatItemResponseSchema.ts` | 聊天响应数据模型 | +| `FastGPT/packages/service/core/dataset/schema.ts` | 知识库数据模型 | +| `FastGPT/packages/service/core/dataset/collection/schema.ts` | 知识库集合数据模型 | +| `FastGPT/packages/service/core/dataset/data/schema.ts` | 知识库数据项模型 | +| `FastGPT/packages/service/core/dataset/data/dataTextSchema.ts` | 知识库文本数据模型 | +| `FastGPT/packages/service/core/agentSkills/schema.ts` | Agent技能数据模型 | +| `FastGPT/packages/service/core/agentSkills/version/schema.ts` | Agent技能版本数据模型 | +| `FastGPT/packages/service/core/ai/config/schema.ts` | AI模型配置数据模型 | +| `FastGPT/packages/service/core/ai/record/schema.ts` | AI调用记录数据模型 | +| `FastGPT/packages/service/core/app/record/schema.ts` | 应用执行记录数据模型 | +| `FastGPT/packages/service/support/user/schema.ts` | 用户数据模型 | + +### API定义(重要路由) +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` | OpenAI兼容聊天补全(核心对外API) | +| `FastGPT/projects/app/src/pages/api/v1/chat/completions-stream.ts` | 流式聊天补全 | +| `FastGPT/projects/app/src/pages/api/core/chat/chatTest.ts` | 工作流测试聊天 | +| `FastGPT/projects/app/src/pages/api/core/app/create.ts` | 创建App应用 | +| `FastGPT/projects/app/src/pages/api/core/app/detail.ts` | 获取App详情 | +| `FastGPT/projects/app/src/pages/api/core/app/list.ts` | 获取App列表 | +| `FastGPT/projects/app/src/pages/api/core/app/copy.ts` | 复制App | +| `FastGPT/projects/app/src/pages/api/core/app/del.ts` | 删除App | +| `FastGPT/projects/app/src/pages/api/core/dataset/create.ts` | 创建知识库 | +| `FastGPT/projects/app/src/pages/api/core/dataset/detail.ts` | 获取知识库详情 | +| `FastGPT/projects/app/src/pages/api/core/dataset/collection/create/apiCollection.ts` | 创建API数据集合 | +| `FastGPT/projects/app/src/pages/api/core/agentSkills/create.ts` | 创建Agent技能 | +| `FastGPT/projects/app/src/pages/api/core/agentSkills/debugChat.ts` | 调试Agent技能 | +| `FastGPT/projects/app/src/pages/api/core/workflow/execute.ts` | 执行工作流 | +| `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` | 获取文件上传预签名URL | +| `FastGPT/projects/app/src/pages/api/common/system/getInitData.ts` | 获取系统初始化数据 | +| `FastGPT/projects/app/src/pages/api/openapi.json.ts` | 导出OpenAPI 3.0规范 | +| `FastGPT/projects/app/src/pages/api/aiproxy/[...path].ts` | AI代理透传路由 | +| `FastGPT/projects/app/src/pages/api/core/chat/delHistory.ts` | 删除聊天历史 | +| `FastGPT/projects/app/src/pages/api/core/chat/feedback/adminUpdate.ts` | 管理员更新聊天反馈 | + +### 业务核心 +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` | Agent节点执行引擎 | +| `FastGPT/packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | Agent主工具调用逻辑 | +| `FastGPT/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts` | PI-Agent执行逻辑 | +| `FastGPT/packages/service/core/ai/llm/request.ts` | LLM请求核心处理 | +| `FastGPT/packages/service/core/ai/llm/agentCall/index.ts` | Agent工具调用LLM接口 | +| `FastGPT/packages/service/core/ai/llm/promptCall/index.ts` | 普通提示词LLM调用 | +| `FastGPT/packages/service/core/ai/llm/compress/index.ts` | 上下文压缩引擎 | +| `FastGPT/packages/service/core/ai/embedding/index.ts` | 文本向量嵌入接口 | +| `FastGPT/packages/service/core/ai/rerank/index.ts` | 文档重排序引擎 | +| `FastGPT/packages/service/core/ai/config.ts` | AI模型配置管理 | +| `FastGPT/packages/service/core/chat/controller.ts` | 聊天会话管理控制器 | +| `FastGPT/packages/service/core/dataset/controller.ts` | 知识库管理控制器 | +| `FastGPT/packages/service/core/dataset/collection/controller.ts` | 知识库集合控制器 | +| `FastGPT/packages/service/core/dataset/data/controller.ts` | 知识库数据项控制器 | +| `FastGPT/packages/service/core/dataset/database/clientManager.ts` | 向量数据库客户端管理 | +| `FastGPT/packages/service/core/app/controller.ts` | App应用生命周期管理 | +| `FastGPT/packages/service/core/app/version/controller.ts` | App版本控制管理 | +| `FastGPT/packages/service/core/agentSkills/controller.ts` | Agent技能管理控制器 | + +### 测试文件 +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/vitest.config.mts` | 全局测试配置(Vitest) | +| `FastGPT/packages/diting-rag-ts/vitest.config.ts` | RAG库测试配置 | +| `FastGPT/projects/code-sandbox/vitest.config.ts` | 代码沙箱测试配置 | +| `FastGPT/test/` | 集中测试目录 | + +### 公共工具 +| 文件路径 | 说明 | +|---------|------| +| `FastGPT/packages/global/common/error/errorCode.ts` | 统一错误码定义 | +| `FastGPT/packages/global/common/error/utils.ts` | 错误处理工具函数 | +| `FastGPT/packages/global/core/app/constants.ts` | 应用类型常量定义 | +| `FastGPT/packages/global/core/ai/provider.ts` | AI提供商和模型配置 | +| `FastGPT/packages/global/core/ai/pricing.ts` | AI模型计价规则 | +| `FastGPT/packages/global/core/workflow/type.ts` | 工作流节点类型定义 | +| `FastGPT/packages/service/common/mongo/index.ts` | MongoDB连接管理 | +| `FastGPT/packages/global/support/user/team/constant.ts` | 团队权限常量定义 | + +## 三、文件用途速查表 + +| 文件路径 | 用途(一句话) | 相关文档任务 | +|---------|--------------|------------| +| `FastGPT/package.json` | Monorepo根配置,定义workspaces和公共脚本 | 仓库架构 | +| `FastGPT/projects/app/next.config.ts` | Next.js构建配置(Rspack加速) | 仓库架构 | +| `FastGPT/projects/app/src/pages/_app.tsx` | Next.js应用根组件,挂载全局Provider | 仓库架构 | +| `FastGPT/deploy/dev/docker-compose.yml` | 开发环境全栈Docker编排 | 仓库架构、排障指南 | +| `FastGPT/Dockerfile.app` | 主应用生产Docker镜像 | 排障指南 | +| `FastGPT/vitest.config.mts` | 全局Vitest测试入口 | 单元测试 | +| `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` | OpenAI兼容补全API | API索引、外部接入指南 | +| `FastGPT/projects/app/src/pages/api/core/chat/chatTest.ts` | 应用工作流聊天测试 | API索引、业务流程详解 | +| `FastGPT/projects/app/src/pages/api/core/app/create.ts` | 创建App应用 | API索引 | +| `FastGPT/projects/app/src/pages/api/core/app/detail.ts` | 获取App详情 | API索引 | +| `FastGPT/projects/app/src/pages/api/core/dataset/create.ts` | 创建知识库 | API索引 | +| `FastGPT/projects/app/src/pages/api/core/agentSkills/create.ts` | 创建Agent技能 | API索引 | +| `FastGPT/projects/app/src/pages/api/core/workflow/execute.ts` | 执行工作流节点 | API索引、业务流程详解 | +| `FastGPT/projects/app/src/pages/api/openapi.json.ts` | 导出OpenAPI 3.0规范文件 | API索引、外部接入指南 | +| `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` | Agent节点主执行引擎 | 业务流程详解、核心业务领域 | +| `FastGPT/packages/service/core/workflow/dispatch/ai/agent/master/call.ts` | Agent主工具调用和推理循环 | 业务流程详解 | +| `FastGPT/packages/service/core/ai/llm/request.ts` | LLM请求核心处理(流/非流) | 业务流程详解 | +| `FastGPT/packages/service/core/ai/llm/agentCall/index.ts` | Agent工具调用LLM入口 | 业务流程详解 | +| `FastGPT/packages/service/core/ai/llm/promptCall/index.ts` | 普通Prompt调用LLM | 核心业务领域 | +| `FastGPT/packages/service/core/ai/config.ts` | AI模型加载和选择逻辑 | 代码编写指南 | +| `FastGPT/packages/service/core/ai/embedding/index.ts` | 文本向量嵌入(多模型支持) | 核心业务领域 | +| `FastGPT/packages/service/core/ai/rerank/index.ts` | RAG文档重排序 | 核心业务领域 | +| `FastGPT/packages/service/core/chat/controller.ts` | 聊天会话CRUD和查询 | 业务流程索引、业务流程详解 | +| `FastGPT/packages/service/core/chat/chatGenerateStatus.ts` | 追踪聊天消息生成状态 | 业务流程详解、排障指南 | +| `FastGPT/packages/service/core/dataset/controller.ts` | 知识库CRUD管理 | 业务流程索引 | +| `FastGPT/packages/service/core/dataset/collection/controller.ts` | 知识库集合管理 | 业务流程索引 | +| `FastGPT/packages/service/core/dataset/data/controller.ts` | 知识库数据项管理 | 业务流程索引 | +| `FastGPT/packages/service/core/dataset/database/clientManager.ts` | Milvus/PGVector客户端管理 | 数据结构、排障指南 | +| `FastGPT/packages/service/core/app/controller.ts` | App生命周期管理 | 业务流程索引 | +| `FastGPT/packages/service/core/app/version/controller.ts` | App版本发布和回滚 | 业务流程索引 | +| `FastGPT/packages/service/core/agentSkills/controller.ts` | Agent技能CRUD | 业务流程索引 | +| `FastGPT/packages/service/core/app/schema.ts` | App应用MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/chat/chatSchema.ts` | 聊天会话MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/chat/chatItemSchema.ts` | 聊天消息项MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/dataset/schema.ts` | 知识库MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/dataset/collection/schema.ts` | 知识库集合MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/dataset/data/schema.ts` | 知识库数据项MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/ai/config/schema.ts` | AI模型配置MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/ai/record/schema.ts` | AI调用记录MongoDB Schema | 数据结构 | +| `FastGPT/packages/service/core/agentSkills/schema.ts` | Agent技能MongoDB Schema | 数据结构 | +| `FastGPT/packages/global/common/error/errorCode.ts` | 统一HTTP错误码枚举 | 代码编写指南 | +| `FastGPT/packages/global/core/ai/provider.ts` | AI Provider枚举和模型配置 | 代码编写指南 | +| `FastGPT/packages/global/core/ai/pricing.ts` | 模型Token定价规则 | 核心业务领域 | +| `FastGPT/packages/global/core/workflow/type.ts` | 工作流节点类型定义 | 代码编写指南 | +| `FastGPT/packages/global/core/app/constants.ts` | App类型枚举(Simple/Workflow等) | 代码编写指南 | +| `FastGPT/packages/service/common/mongo/index.ts` | MongoDB连接初始化 | 排障指南 | +| `FastGPT/packages/global/support/user/team/constant.ts` | 团队成员角色和权限枚举 | 核心业务领域 | +| `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` | 生成文件上传预签名URL | API索引、外部接入指南 | +| `FastGPT/projects/app/src/pages/api/common/system/getInitData.ts` | 系统初始化数据(模型列表等) | 排障指南 | +| `FastGPT/projects/app/src/pages/api/aiproxy/[...path].ts` | AI请求代理透传 | API索引 | +| `FastGPT/packages/web/components/ChatBox/` | 聊天对话框组件 | 核心业务领域 | +| `FastGPT/packages/web/i18n/` | 国际化(中/英/日)语言文件 | 代码编写指南 | diff --git a/doc/kb/.kb-context.json b/doc/kb/.kb-context.json new file mode 100644 index 000000000000..74e81654bad5 --- /dev/null +++ b/doc/kb/.kb-context.json @@ -0,0 +1,32 @@ +{ + "project_directory": "FastGPT", + "is_library": false, + "has_api": true, + "primary_language": "TypeScript", + "frameworks": ["Next.js", "React", "Node.js", "Chakra UI", "TypeORM", "MongoDB", "PostgreSQL", "Milvus", "LangGraph", "ModelContextProtocol"], + "input_dirs": ["D:/code/code/FastGPT"], + "output_dir": "doc/kb", + "focus_dirs": [ + "projects/app/src/pages/api", + "projects/app/src/components", + "packages/service/core", + "packages/global/core", + "packages/web/components" + ], + "ignore_dirs": [ + "node_modules", + ".git", + "dist", + ".next", + "coverage", + "test" + ], + "core_flows": [], + "business_context": "FastGPT 是一个 AI Agent 构建平台,通过可视化工作流编排、知识库(RAG)能力和插件系统,帮助用户快速构建基于大语言模型的应用程序。支持多模型接入,提供 OpenAI 兼容的 API 接口。", + "path_rules": { + "format": "相对路径", + "separator": "正斜杠(/)", + "base": "项目目录", + "example": "FastGPT/projects/app/src/pages/api/v1/chat.ts" + } +} diff --git a/doc/kb/.kb-meta.json b/doc/kb/.kb-meta.json new file mode 100644 index 000000000000..d15142eea0c8 --- /dev/null +++ b/doc/kb/.kb-meta.json @@ -0,0 +1,27 @@ +{ + "generated_at": "2026-05-05T00:00:00+08:00", + "git_commit": "8537ada92844831e812057b29108fb0f0404d5e4", + "git_branch": "develop-1.3.0", + "generator_version": "2.0.0", + "repository_info": { + "primary_language": "TypeScript", + "frameworks": ["Next.js", "React", "Node.js", "Chakra UI", "TypeORM", "MongoDB", "PostgreSQL", "Milvus", "LangGraph", "ModelContextProtocol"], + "is_library": false, + "has_api": true + }, + "repository_dependencies": ["MongoDB", "PostgreSQL", "Milvus", "Redis", "OpenAI-compatible LLM"], + "documents": { + "仓库概览.md": {"status": "completed", "version": 1}, + "仓库架构.md": {"status": "completed", "version": 1}, + "仓库依赖.md": {"status": "completed", "version": 1}, + "外部接入指南.md": {"status": "completed", "version": 1}, + "业务知识库/核心业务领域.md": {"status": "completed", "version": 1}, + "业务知识库/业务流程索引.md": {"status": "completed", "version": 1}, + "业务知识库/业务流程详解.md": {"status": "completed", "version": 1}, + "技术知识库/API索引.md": {"status": "completed", "version": 2, "iterations": 2}, + "技术知识库/数据结构.md": {"status": "completed", "version": 1}, + "技术知识库/代码编写指南.md": {"status": "completed", "version": 2, "iterations": 2}, + "技术知识库/排障指南.md": {"status": "completed", "version": 1}, + "测试知识库/单元测试.md": {"status": "completed", "version": 1} + } +} diff --git a/doc/kb/.kb-progress.json b/doc/kb/.kb-progress.json new file mode 100644 index 000000000000..11cb13dc17a9 --- /dev/null +++ b/doc/kb/.kb-progress.json @@ -0,0 +1,63 @@ +{ + "phase1_done": true, + "context_file": "doc/kb/.kb-context.json", + "docs": { + "08-仓库架构": { + "status": "done", + "output": "doc/kb/仓库架构.md" + }, + "02-仓库依赖": { + "status": "done", + "output": "doc/kb/仓库依赖.md" + }, + "06-数据结构": { + "status": "done", + "output": "doc/kb/技术知识库/数据结构.md" + }, + "03-核心业务领域": { + "status": "done", + "output": "doc/kb/业务知识库/核心业务领域.md" + }, + "04-API文档": { + "status": "done", + "output": "doc/kb/技术知识库/API索引.md" + }, + "10-业务流程索引": { + "status": "done", + "output": "doc/kb/业务知识库/业务流程索引.md" + }, + "09-业务流程详解": { + "status": "done", + "output": "doc/kb/业务知识库/业务流程详解.md" + }, + "05-代码编写指南": { + "status": "done", + "output": "doc/kb/技术知识库/代码编写指南.md" + }, + "07-单元测试": { + "status": "done", + "output": "doc/kb/测试知识库/单元测试.md" + }, + "11-外部接入指南": { + "status": "done", + "output": "doc/kb/外部接入指南.md" + }, + "12-排障指南": { + "status": "done", + "output": "doc/kb/技术知识库/排障指南.md" + }, + "13-基于git记录优化": { + "status": "done", + "output": "doc/kb/(已优化3个文档)" + }, + "01-仓库概览": { + "status": "done", + "output": "doc/kb/仓库概览.md" + } + }, + "phases": { + "phase0_done_at": "2026-06-18T09:58:32.950Z", + "tasks_created_at": "2026-06-18T09:59:11.494Z", + "knowledge_base_git_commit": "ed55362214a80e67689dd1ecf3749d25b4c4e0f5" + } +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/kb-task-run.sh b/doc/kb/.kb-temp/kb-task-run.sh new file mode 100644 index 000000000000..8082e2544829 --- /dev/null +++ b/doc/kb/.kb-temp/kb-task-run.sh @@ -0,0 +1,1175 @@ +#!/bin/bash +# kb-task-run.sh — 知识库任务调度器(v7.0) +# +# 扫描 .kb-temp/tasks/ 目录下的 .kb-task-*.json, +# claim 可用任务并派发 claude -p 执行 kb-init-frontend --scope。 +# +# 用法: +# ./kb-task-run.sh <输出目录> [--run|--status] +# +# 选项: +# --run 启动调度器主循环 (持续派发任务,供后台运行) +# --status 进入交互式 REPL 浏览器 (查看日志、实时追踪运行中任务、列出 session) +# +# 默认 (无模式参数): 等同 --status,进入 REPL 浏览器 + +set -euo pipefail + +# ─── 需要用户确认的配置 ────────────────────────────────────────────────── + +# 几个并行 +MAX_CONCURRENT=5 +# 使用什么执行:claude | csc +EXECUTOR="claude" # 执行 kb-init-frontend 的命令,可替换为其他兼容 claude 参数格式的执行器 + +# ─── 下方的不要动了 ────────────────────────────────────────────────────── + +# ─── 配置 ────────────────────────────────────────────────── +# 知识库目录 +OUTPUT_DIR="doc/kb" +TASKS_DIR="" +LOG_DIR="" +# 10s检查一次 +POLL_INTERVAL=10 +MODE="repl" # repl (默认交互浏览器) | loop + +# ─── 解析参数 ────────────────────────────────────────────── + +# 第一个非选项参数作为输出目录 +for arg in "$@"; do + case "$arg" in + --*) break ;; + *) OUTPUT_DIR="$arg"; shift; break ;; + esac +done + +while [[ $# -gt 0 ]]; do + case "$1" in + --run) MODE="loop"; shift ;; + --status) MODE="repl"; shift ;; + --) shift; break ;; + *) shift ;; + esac +done + +TASKS_DIR="$OUTPUT_DIR/.kb-temp/tasks" +LOG_DIR="$OUTPUT_DIR/.kb-temp/logs" +PID_FILE="$OUTPUT_DIR/.kb-temp/kb-task-run.pid" + +if [[ ! -d "$TASKS_DIR" ]]; then + echo "错误: 任务目录不存在: $TASKS_DIR" + echo "请先运行 create-tasks.js 创建任务队列。" + exit 1 +fi + +# ─── 工具函数 ────────────────────────────────────────────── + +# 读取 task 文件的 JSON 字段(跨平台: 用 node 替代 jq) +task_field() { + local file="$1" + local field="$2" + node -e " + try { + const d = JSON.parse(require('fs').readFileSync('$file','utf8')); + const v = d['$field']; + console.log(v === undefined || v === null || v === false ? '' : String(v)); + } catch(_) { process.exit(1); } + " 2>/dev/null || echo "" +} + +# task 是否可消费 +task_available() { + local file="$1" + local type status + type=$(task_field "$file" "type") + status=$(task_field "$file" "status") + [[ "$status" != "pending" ]] && return 1 + + if [[ "$type" == "capability" ]]; then + return 0 # capability 总是可以 + fi + + # root / finalize: 所有 capability 必须 done + local all_done=true + local task_file + for task_file in "$TASKS_DIR"/.kb-task-*.json; do + [[ -f "$task_file" ]] || continue + local ttype tstatus + ttype=$(task_field "$task_file" "type") + tstatus=$(task_field "$task_file" "status") + [[ "$ttype" == "capability" && "$tstatus" != "done" ]] && { all_done=false; break; } + done + $all_done && return 0 || return 1 +} + +# ─── 任务缓存(避免每次 task_field 都 spawn node) ────────── + +_TASK_FILES=() +_TASK_SAFES=() +_TASK_LABELS=() +_TASK_TYPES=() +_TASK_STATUSES=() +_TASK_ERRORS=() +_TASK_SESSIONS=() +declare -A _TASK_IDX_BY_SAFE + +# 单次 node 调用读取全部任务到内存 +load_tasks_cache() { + _TASK_FILES=() + _TASK_SAFES=() + _TASK_LABELS=() + _TASK_TYPES=() + _TASK_STATUSES=() + _TASK_ERRORS=() + _TASK_SESSIONS=() + _TASK_IDX_BY_SAFE=() + + local output + output=$(node -e " + const fs = require('fs'), path = require('path'); + const dir = '$TASKS_DIR'; + let files; + try { files = fs.readdirSync(dir).filter(f => f.startsWith('.kb-task-') && f.endsWith('.json')); } + catch(_) { files = []; } + for (const f of files) { + try { + const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + const esc = s => String(s||'').replace(/\t/g,' ').replace(/\n/g,' '); + console.log([f, esc(d.label_safe), esc(d.label), esc(d.type), esc(d.status), esc(d.error||''), esc(d.session_id||'')].join('\t')); + } catch(_) {} + } + " 2>/dev/null) || output="" + + local idx=0 + while IFS=$'\t' read -r f safe label type status error sid; do + [[ -z "$f" ]] && continue + _TASK_FILES+=("$f") + _TASK_SAFES+=("$safe") + _TASK_LABELS+=("$label") + _TASK_TYPES+=("$type") + _TASK_STATUSES+=("$status") + _TASK_ERRORS+=("$error") + _TASK_SESSIONS+=("$sid") + [[ -n "$safe" ]] && _TASK_IDX_BY_SAFE["$safe"]="$idx" + idx=$((idx + 1)) + done <<< "$output" +} + +# 用缓存判断是否全部 capability 已完成 +_all_capabilities_done() { + local i + for i in "${!_TASK_TYPES[@]}"; do + if [[ "${_TASK_TYPES[$i]}" == "capability" && "${_TASK_STATUSES[$i]}" != "done" ]]; then + return 1 + fi + done + return 0 +} + +# 用缓存判断任务是否可消费(替代 task_available 的文件扫描) +task_available_cached() { + local idx="$1" + local type="${_TASK_TYPES[$idx]}" + local status="${_TASK_STATUSES[$idx]}" + + [[ "$status" != "pending" ]] && return 1 + + if [[ "$type" == "capability" ]]; then + return 0 + fi + + # root / finalize: 所有 capability 必须 done + _all_capabilities_done && return 0 || return 1 +} + +# ─── 单实例互斥 ───────────────────────────────────────── + +# 杀掉旧调度器实例及其子进程 +kill_previous_instance() { + if [[ ! -f "$PID_FILE" ]]; then + return 0 + fi + local old_pid + old_pid=$(cat "$PID_FILE" 2>/dev/null) || return 0 + [[ -z "$old_pid" ]] && return 0 + + # 检查进程是否存活;即使调度器已死,子进程可能仍是孤儿在跑 + local scheduler_alive=false + if kill -0 "$old_pid" 2>/dev/null; then + scheduler_alive=true + echo "[kb-task-run] 检测到已有调度器实例 (PID: $old_pid),正在停止..." + else + echo "[kb-task-run] 调度器已退出但子进程可能还在运行 (PID_FILE: $old_pid),正在清理..." + fi + + # 杀掉旧进程树的子进程 + local children + children=$(ps -o pid= --ppid "$old_pid" 2>/dev/null) || true + for child in $children; do + [[ -z "$child" ]] && continue + # 杀子进程树(递归杀 csc/claude 等) + pkill -P "$child" 2>/dev/null || true + kill "$child" 2>/dev/null || true + done + + # 如果调度器还活着,也杀它 + $scheduler_alive && kill "$old_pid" 2>/dev/null || true + + # 等待进程退出 + sleep 1 + + # 无论调度器状态,始终 SIGKILL 仍然存活的子进程 + local any_dead=false + for child in $children; do + if kill -0 "$child" 2>/dev/null; then + kill -9 "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + any_dead=true + fi + done + # 调度器如果还活着,SIGKILL 它 + if $scheduler_alive && kill -0 "$old_pid" 2>/dev/null; then + kill -9 "$old_pid" 2>/dev/null || true + wait "$old_pid" 2>/dev/null || true + any_dead=true + fi + + if $any_dead; then + echo "[kb-task-run] 已强制停止(含 SIGKILL)。" + else + echo "[kb-task-run] 旧实例已停止。" + fi +} + +# 将所有 in_progress 任务重置为 pending(先杀旧进程,再重置 JSON) +# 注意:必须 kill 旧进程,否则旧 csc 进程仍会跑完并写 done,和新派发的任务并行翻倍。 +reset_in_progress_tasks() { + local task_file + local reset_count=0 + for task_file in "$TASKS_DIR"/.kb-task-*.json; do + [[ -f "$task_file" ]] || continue + local status + status=$(task_field "$task_file" "status") || continue + if [[ "$status" == "in_progress" ]]; then + # 先杀掉遗留进程(重启前的孤儿 csc) + local old_pid + old_pid=$(node -e " + try { + const d=JSON.parse(require('fs').readFileSync('$task_file','utf8')); + console.log(d.pid || ''); + } catch(_) { process.exit(0); } + " 2>/dev/null) + if [[ -n "$old_pid" ]]; then + if kill -0 "$old_pid" 2>/dev/null; then + echo " 🚫 杀掉遗留进程: $(basename "$task_file") (PID: $old_pid)" + pkill -P "$old_pid" 2>/dev/null || true + kill "$old_pid" 2>/dev/null || true + sleep 0.2 + kill -0 "$old_pid" 2>/dev/null && kill -9 "$old_pid" 2>/dev/null || true + wait "$old_pid" 2>/dev/null || true + fi + fi + # 重置 JSON + node -e " + const fs=require('fs'); + const d=JSON.parse(fs.readFileSync('$task_file','utf8')); + d.status='pending'; d.retry_count=0; + delete d.session_id; delete d.claimed_at; delete d.pid; + fs.writeFileSync('${task_file}.tmp', JSON.stringify(d,null,2)); + " 2>/dev/null && mv "${task_file}.tmp" "$task_file" && reset_count=$((reset_count + 1)) + echo " ➜ 重置任务: $(basename "$task_file")" + fi + done + if [[ $reset_count -gt 0 ]]; then + echo "[kb-task-run] 已重置 $reset_count 个 in_progress 任务为 pending。" + fi +} + +# 清理锁文件 +cleanup_lock() { + # 只在 PID 匹配时才删除(防止误删后续实例的锁文件) + if [[ -f "$PID_FILE" ]]; then + local stored_pid + stored_pid=$(cat "$PID_FILE" 2>/dev/null) || return + if [[ "$stored_pid" == "$$" ]]; then + rm -f "$PID_FILE" + fi + fi +} + +# 确保当前实例是唯一调度器 +ensure_single_instance() { + kill_previous_instance + reset_in_progress_tasks + + # 创建锁目录(幂等) + mkdir -p "$(dirname "$PID_FILE")" + + # 写入当前 PID + echo $$ > "$PID_FILE" + echo "[kb-task-run] 调度器已启动 (PID: $$)" +} + +# ─── 状态展示 ────────────────────────────────────────────── + +print_status() { + local total=0 pending=0 running=0 done_count=0 failed=0 + local cap_p=0 cap_r=0 cap_d=0 cap_f=0 + local root_p=0 root_r=0 root_d=0 root_f=0 + local fin_p=0 fin_r=0 fin_d=0 fin_f=0 + + local running_tasks=() + local failed_tasks=() + + local task_file label safe type status + for task_file in "$TASKS_DIR"/.kb-task-*.json; do + [[ -f "$task_file" ]] || continue + total=$((total + 1)) + type=$(task_field "$task_file" "type") + status=$(task_field "$task_file" "status") + label=$(task_field "$task_file" "label") + safe=$(task_field "$task_file" "label_safe") + local error=$(task_field "$task_file" "error") + + case "$status" in + pending) pending=$((pending + 1));; + in_progress) running=$((running + 1)); running_tasks+=("$safe|$label");; + done) done_count=$((done_count + 1));; + failed) failed=$((failed + 1)); failed_tasks+=("$safe|$label|$error");; + esac + + case "$type:$status" in + capability:pending) cap_p=$((cap_p + 1));; + capability:in_progress) cap_r=$((cap_r + 1));; + capability:done) cap_d=$((cap_d + 1));; + capability:failed) cap_f=$((cap_f + 1));; + root:pending) root_p=$((root_p + 1));; + root:in_progress) root_r=$((root_r + 1));; + root:done) root_d=$((root_d + 1));; + root:failed) root_f=$((root_f + 1));; + finalize:pending) fin_p=$((fin_p + 1));; + finalize:in_progress) fin_r=$((fin_r + 1));; + finalize:done) fin_d=$((fin_d + 1));; + finalize:failed) fin_f=$((fin_f + 1));; + esac + done + + echo "╔══════════════════════════════════════════════════════════════╗" + printf "║ kb-task-run 状态报告 %-20s ║\n" "$(date '+%Y-%m-%d %H:%M')" + echo "╠══════════════════════════════════════════════════════════════╣" + printf "║ 总任务数: %-56d ║\n" "$total" + echo "║ ║" + printf "║ ┌──────────┬────────┬────────┬────────┬────────┐ ║\n" + printf "║ │ 类型 │ pending│ running│ done │ failed │ ║\n" + printf "║ ├──────────┼────────┼────────┼────────┼────────┤ ║\n" + printf "║ │capability│ %-6d │ %-6d │ %-6d │ %-6d │ ║\n" "$cap_p" "$cap_r" "$cap_d" "$cap_f" + printf "║ │ root │ %-6d │ %-6d │ %-6d │ %-6d │ ║\n" "$root_p" "$root_r" "$root_d" "$root_f" + printf "║ │finalize │ %-6d │ %-6d │ %-6d │ %-6d │ ║\n" "$fin_p" "$fin_r" "$fin_d" "$fin_f" + printf "║ ├──────────┼────────┼────────┼────────┼────────┤ ║\n" + printf "║ │ 合计 │ %-6d │ %-6d │ %-6d │ %-6d │ ║\n" "$pending" "$running" "$done_count" "$failed" + printf "║ └──────────┴────────┴────────┴────────┴────────┘ ║\n" + echo "║ ║" + + if [[ ${#running_tasks[@]} -gt 0 ]]; then + echo "║ 当前运行: ║" + local idx=1 + for entry in "${running_tasks[@]}"; do + local s="${entry%%|*}" + printf "║ [%d] %-55s ║\n" "$idx" "$s" + idx=$((idx + 1)) + done + echo "║ ║" + fi + + if [[ ${#failed_tasks[@]} -gt 0 ]]; then + echo "║ 失败任务: ║" + for entry in "${failed_tasks[@]}"; do + local s="${entry%%|*}" + local err="${entry##*|}" + printf "║ ❌ %-55s ║\n" "$s" + [[ -n "$err" ]] && printf "║ %-55s ║\n" "→ $err" + done + echo "║ ║" + fi + + echo "╚══════════════════════════════════════════════════════════════╝" +} + +# ─── 调度主循环 ────────────────────────────────────────── + +scheduler_loop() { + mkdir -p "$LOG_DIR" + local running_pids=() + local running_safes=() + + while true; do + # ── 刷新缓存(单次 node 调用,替代 N 次 task_field) ── + load_tasks_cache + + # ── 清理已完成进程 ────────────────────────────── + local new_pids=() + local new_safes=() + local i + for i in "${!running_pids[@]}"; do + if kill -0 "${running_pids[$i]}" 2>/dev/null; then + new_pids+=("${running_pids[$i]}") + new_safes+=("${running_safes[$i]}") + else + local done_idx="${_TASK_IDX_BY_SAFE[${running_safes[$i]}]:-}" + local done_session="" + local done_status="" + local done_file="" + if [[ -n "$done_idx" ]]; then + done_session="${_TASK_SESSIONS[$done_idx]}" + done_status="${_TASK_STATUSES[$done_idx]}" + done_file="$TASKS_DIR/${_TASK_FILES[$done_idx]}" + fi + # 进程已退出但 agent 未把 status 改成 done/failed(常见:LLM 忘记最后一步) + # → 调度器兜底,强制把 in_progress 收敛为 done。 + if [[ "$done_status" == "in_progress" && -n "$done_file" ]]; then + node -e " + const fs=require('fs'); + const d=JSON.parse(fs.readFileSync('$done_file','utf8')); + if (d.status==='in_progress') { d.status='done'; delete d.pid; } + fs.writeFileSync('${done_file}.tmp', JSON.stringify(d,null,2)); + " && mv "${done_file}.tmp" "$done_file" 2>/dev/null || true + done_status="done" + _TASK_STATUSES[$done_idx]="done" + fi + if [[ "$done_status" == "failed" ]]; then + echo "[$(date '+%H:%M:%S')] 失败: ${running_safes[$i]} (session: ${done_session:-?})" + else + echo "[$(date '+%H:%M:%S')] 完成: ${running_safes[$i]} (session: ${done_session:-?})" + fi + fi + done + running_pids=("${new_pids[@]}") + running_safes=("${new_safes[@]}") + + # ── 派发任务:填满到 MAX_CONCURRENT(使用缓存)──── + local claimed=0 + + while [[ ${#running_pids[@]} -lt $MAX_CONCURRENT ]]; do + local dispatched_this_round=0 + local available_slots=$((MAX_CONCURRENT - ${#running_pids[@]})) + + local idx + for idx in "${!_TASK_FILES[@]}"; do + [[ $dispatched_this_round -ge $available_slots ]] && break + + local safe="${_TASK_SAFES[$idx]}" + local status="${_TASK_STATUSES[$idx]}" + local task_file="$TASKS_DIR/${_TASK_FILES[$idx]}" + + [[ "$status" != "pending" ]] && continue + + if ! task_available_cached "$idx"; then + continue + fi + + # 生成 session ID + local session_id + session_id=$(node -e "console.log(require('crypto').randomUUID())") + + local claimed_at + claimed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + node -e " + const d=JSON.parse(require('fs').readFileSync('$task_file','utf8')); + d.status='in_progress'; d.claimed_at='$claimed_at'; d.session_id='$session_id'; + require('fs').writeFileSync('${task_file}.tmp', JSON.stringify(d,null,2)); + " && mv "${task_file}.tmp" "$task_file" + + # 同步更新缓存状态(避免重新加载) + _TASK_STATUSES[$idx]="in_progress" + _TASK_SESSIONS[$idx]="$session_id" + + "$EXECUTOR" -p --session-id "$session_id" \ + "kb-init-frontend --scope $safe" --dangerously-skip-permissions \ + > "$LOG_DIR/${safe}.jsonl" 2>&1 & + local pid=$! + running_pids+=("$pid") + running_safes+=("$safe") + + # 把 PID 写回 JSON(供重置时杀进程用) + node -e " + const d=JSON.parse(require('fs').readFileSync('$task_file','utf8')); + d.pid=$pid; + require('fs').writeFileSync('${task_file}.tmp', JSON.stringify(d,null,2)); + " && mv "${task_file}.tmp" "$task_file" + + dispatched_this_round=$((dispatched_this_round + 1)) + claimed=$((claimed + 1)) + echo "[$(date '+%H:%M:%S')] 已派发: $safe (PID: $pid, session: $session_id)" + done + + # 本轮没派发出任何任务(无可消费任务),退出派发循环 + [[ $dispatched_this_round -eq 0 ]] && break + done + + # ── 检查终止条件(使用缓存)──────────────────── + local any_pending=false + local any_running=false + local any_failed=false + + local idx + for idx in "${!_TASK_STATUSES[@]}"; do + case "${_TASK_STATUSES[$idx]}" in + pending) any_pending=true;; + in_progress) any_running=true;; + failed) any_failed=true;; + esac + done + + if ! $any_pending && ! $any_running; then + print_status + if $any_failed; then + echo "⚠️ 存在失败任务。可以重置对应 task status 为 pending 后重新运行。" + else + echo "✅ 全部任务完成!" + fi + exit 0 + fi + + # 等待任意一个子进程完成(替代固定间隔 sleep)── + if [[ ${#running_pids[@]} -gt 0 ]]; then + wait -n "${running_pids[@]}" 2>/dev/null || true + else + # 无运行中任务且无任务可派发,等待新任务加入 + sleep "$POLL_INTERVAL" + fi + done +} + +# ─── REPL 交互模式 ──────────────────────────────────────── + +# 检查终端能力 +_has_smcup() { tput smcup >/dev/null 2>&1; } + +# 保存/恢复终端状态(用于 claude --resume 等外部命令前后) +_repl_suspend() { + _has_smcup && tput rmcup 2>/dev/null +} + +_repl_resume() { + _has_smcup && tput smcup 2>/dev/null +} + +# 单次 node 调用:读取全部 task → CJK 安全排版 → 分区输出 +repl_refresh() { + local search_filter="${1:-}" # 空 = 不过滤 + local output + output=$(node -e " + const fs = require('fs'), path = require('path'); + const dir = '$TASKS_DIR', logDir = '$LOG_DIR'; + const search = '$search_filter'.toLowerCase(); + const W = process.stdout.columns || 100; + + // 读取全部 task + let files; + try { files = fs.readdirSync(dir).filter(f => f.startsWith('.kb-task-') && f.endsWith('.json')); } + catch(_) { files = []; } + let tasks = files.map(f => { + try { + const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + return { ...d, _f: f }; + } catch(_) { return null; } + }).filter(Boolean); + + // REPL 进程存活校验:in_progress 但进程已死 → 强制收敛 done + for (const t of tasks) { + if (t.status === 'in_progress' && t.pid) { + try { process.kill(t.pid, 0); } + catch (_) { + // 进程不存在 → 自动收敛为 done(先更新文件,再同步内存) + try { + const fp = path.join(dir, t._f); + const d = JSON.parse(fs.readFileSync(fp, 'utf8')); + if (d.status === 'in_progress') { + d.status = 'done'; + delete d.pid; + fs.writeFileSync(fp + '.tmp', JSON.stringify(d, null, 2)); + fs.renameSync(fp + '.tmp', fp); + t.status = 'done'; + } + } catch(_) {} + } + } + } + + // 搜索过滤 + if (search) { + tasks = tasks.filter(t => { + const haystack = [t.label||'', t.label_safe||'', t.session_id||''].join(' ').toLowerCase(); + return haystack.includes(search); + }); + } + + // 排序: running > failed > pending > done + const order = { in_progress:0, failed:1, pending:2, done:3 }; + tasks.sort((a,b) => (order[a.status]??99) - (order[b.status]??99)); + + // CJK 列宽 (近似) + function cw(s) { + let w = 0; + for (const ch of s) { + const cp = ch.codePointAt(0); + if (cp > 0x2E7F && cp < 0xA000 || cp > 0xF8FF && cp < 0xFE10 || + cp > 0xFEE0 && cp < 0xFFF0 || cp > 0x1EF00 && cp < 0x20000 || + cp > 0x2FFFF && cp < 0x40000 || cp > 0x1F000 && cp < 0x1FFFF) + w += 2; + else w += 1; + } + return w; + } + function pad(s, n) { const cur = cw(s); return cur >= n ? s : s + ' '.repeat(n - cur); } + + // 统计 + let rn=0, fl=0, pd=0, dn=0; + for (const t of tasks) { if (t.status==='in_progress') rn++; else if (t.status==='failed') fl++; else if (t.status==='pending') pd++; else if (t.status==='done') dn++; } + const tot = tasks.length; + const CI = W - 4; + + const now = new Date(); + const ts = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0') + ':' + String(now.getSeconds()).padStart(2,'0'); + + // ── HEADER ── + const header = []; + const pushH = s => header.push('║ ' + pad(s, CI) + ' ║'); + header.push('╔' + '═'.repeat(CI) + '╗'); + pushH('kb-task-run REPL ' + ts); + header.push('╠' + '═'.repeat(CI) + '╣'); + pushH('总: ' + tot + ' │ pending: ' + pd + ' │ running: ' + rn + ' │ done: ' + dn + ' │ failed: ' + fl); + header.push('╠' + '═'.repeat(CI) + '╣'); + + // ── CONTENT ── + const content = []; + let sectionType = ''; + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]; + const idx = i + 1; + const status = t.status || ''; + + if (status !== sectionType) { + sectionType = status; + if (content.length > 0) content.push(''); + const label = status === 'in_progress' ? '运行中' : status === 'failed' ? '失败' : status === 'pending' ? '等待中' : '已完成'; + const n = status === 'in_progress' ? rn : status === 'failed' ? fl : status === 'pending' ? pd : dn; + content.push(' ' + label + ' (' + n + ')'); + } + + const icon = status === 'in_progress' ? '⏳' : status === 'failed' ? '❌' : status === 'pending' ? '○' : '✅'; + const hasLog = fs.existsSync(path.join(logDir, (t.label_safe||'') + '.jsonl')) ? '📄' : ' '; + + const line1 = ' [' + idx + '] ' + pad(t.label || '', 18) + ' ' + icon + ' ' + pad(status, 10) + ' ' + hasLog + ' ' + (t.label_safe||''); + content.push(line1); + + if (t.session_id) { + content.push(' session: ' + t.session_id); + } + if (t.error) { + content.push(' → ' + (t.error || '')); + } + } + + // ── FOOTER ── + const footer = []; + footer.push('╠' + '═'.repeat(CI) + '╣'); + footer.push('║ ' + pad('操作: [N]恢复 r刷新 s搜索 j↓k↑逐行 ↑↓翻页 g首G尾 h帮助 x重置 q退出', CI) + ' ║'); + footer.push('╚' + '═'.repeat(CI) + '╝'); + + // 输出分区 + console.log('===SECTION:HEADER==='); + for (const l of header) console.log(l); + console.log('===SECTION:CONTENT==='); + for (const l of content) console.log(l); + console.log('===SECTION:FOOTER==='); + for (const l of footer) console.log(l); + console.log('===SECTION:TSV==='); + for (const t of tasks) { + const esc = s => String(s).replace(/\t/g,' ').replace(/\n/g,' '); + console.log([esc(t.label_safe||''), esc(t.label||''), esc(t.status||''), esc(t.type||''), esc(t.session_id||''), esc(t.error||''), t._f].join('\\t')); + } + console.log('===SECTION:END==='); + " 2>/dev/null) || output="" + + # 清空全局数组 + HEADER_LINES=(); CONTENT_LINES=(); FOOTER_LINES=() + TASK_FILES=(); TASK_SAFES=(); TASK_LABELS=(); TASK_STATUSES=() + TASK_TYPES=(); TASK_ERRORS=(); TASK_SESSIONS=(); TASK_COUNT=0 + + if [[ -z "$output" ]]; then + HEADER_LINES=("╔══════════════════════════════════════════════════════════════╗" "║ kb-task-run REPL ║" "╚══════════════════════════════════════════════════════════════╝") + CONTENT_LINES=(" (无任务数据)") + FOOTER_LINES=() + return 1 + fi + + # 解析分区 + local section="" + while IFS= read -r line; do + case "$line" in + "===SECTION:HEADER===") section="header"; continue ;; + "===SECTION:CONTENT===") section="content"; continue ;; + "===SECTION:FOOTER===") section="footer"; continue ;; + "===SECTION:TSV===") section="tsv"; continue ;; + "===SECTION:END===") section=""; continue ;; + esac + case "$section" in + header) HEADER_LINES+=("$line") ;; + content) CONTENT_LINES+=("$line") ;; + footer) FOOTER_LINES+=("$line") ;; + tsv) + [[ -z "$line" ]] && continue + local rest="$line" + local safe label status type sid err fname + safe="${rest%%$'\t'*}" && rest="${rest#*$'\t'}" + label="${rest%%$'\t'*}" && rest="${rest#*$'\t'}" + status="${rest%%$'\t'*}" && rest="${rest#*$'\t'}" + type="${rest%%$'\t'*}" && rest="${rest#*$'\t'}" + sid="${rest%%$'\t'*}" && rest="${rest#*$'\t'}" + err="${rest%%$'\t'*}" && fname="${rest#*$'\t'}" + TASK_SAFES+=("$safe"); TASK_LABELS+=("$label"); TASK_STATUSES+=("$status") + TASK_TYPES+=("$type"); TASK_SESSIONS+=("$sid"); TASK_ERRORS+=("$err"); TASK_FILES+=("$fname") + TASK_COUNT=$((TASK_COUNT + 1)) + ;; + esac + done <<< "$output" + return 0 +} + +# 绘制终端布局:固定顶栏 + 可滚动内容 + 固定底栏 + 固定输入行 +# 参数: "content_only" — 仅重绘内容区(用于滚动),跳过顶栏/底栏 +repl_draw() { + local mode="${1:-full}" + + local lines cols + lines=$(tput lines 2>/dev/null || echo 24) + cols=$(tput cols 2>/dev/null || echo 80) + + local header_h=${#HEADER_LINES[@]} + local footer_h=${#FOOTER_LINES[@]} + local input_h=1 + local content_max=$((lines - header_h - footer_h - input_h)) + [[ $content_max -lt 1 ]] && content_max=1 + + local content_start=$header_h + local content_end=$((lines - footer_h - input_h - 1)) + local E=$'\e' # ESC 字节 + + # ── 整帧构建为一个字符串,一次性写入 ── + local buf="" + + # ── 1. 顶栏 ── + if [[ "$mode" != "content_only" ]]; then + local i + for ((i=0; i/dev/null) + if [[ -n "$pid" ]]; then + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null && echo " 🔪 已杀死进程 $pid" || echo " ⚠️ 无法杀死进程 $pid" + fi + # 等待进程彻底退出,避免调度器读到残留 JSON 前进程仍在跑 + sleep 0.3 2>/dev/null || true + fi + + node -e " + const d = JSON.parse(require('fs').readFileSync('$TASKS_DIR/$fname','utf8')); + d.status = 'pending'; + delete d.error; + delete d.session_id; + delete d.claimed_at; + delete d.pid; + require('fs').writeFileSync('${TASKS_DIR}/${fname}.tmp', JSON.stringify(d, null, 2)); + " && mv "${TASKS_DIR}/${fname}.tmp" "$TASKS_DIR/$fname" && echo " ✅ $label ($safe)" || echo " ❌ $label ($safe)" + + return 0 +} + +# 交互重置单个任务(保留旧接口) +repl_reset_task() { + local idx="$1" + local safe="${TASK_SAFES[$idx]}" + local label="${TASK_LABELS[$idx]}" + local status="${TASK_STATUSES[$idx]}" + + [[ -z "$safe" ]] && return + + _repl_suspend + echo "" + printf "确定要将任务 [%s] (%s) 从 %s 重置为 pending 吗?(y/N): " "$label" "$safe" "$status" + read -r confirm + if [[ "$confirm" != "y" && "$confirm" != "Y" && "$confirm" != "yes" ]]; then + echo "已取消。" + echo "按 Enter 返回..." + read -r + _repl_resume + repl_draw + return + fi + + _reset_task_file "$idx" + echo "按 Enter 返回..." + read -r + _repl_resume + repl_draw +} + +# REPL 帮助 +repl_help() { + _repl_suspend + clear 2>/dev/null || true + echo "═══ kb-task-run REPL 帮助 ═══" + echo "" + echo " 按键 功能" + echo " ────── ─────────────────────────────────" + echo " 1-9 恢复选中 session ($EXECUTOR --resume)" + echo " j / k 逐行向下/向上滚动" + echo " ↑ / ↓ 向上/向下翻一页" + echo " g 跳到开头" + echo " G 跳到末尾" + echo " r/R 刷新任务列表" + echo " s/S 搜索 (按 label / label_safe / session_id)" + echo " h/H 显示此帮助" + echo " x/X 重置为 pending (输入编号,空格隔开可批量)" + echo " q/Q 退出 REPL" + echo "" + echo " 状态图标: ⏳运行中 ○等待中 ✅已完成 ❌失败" + echo " 日志图标: 📄有日志文件" + echo "" + echo " 可直接复制 session_id 用于 $EXECUTOR --resume 恢复。" + echo " 要启动调度器,在另一个终端运行:" + echo " bash \$0 <输出目录> --run" + echo "" + echo "按 Enter 返回..." + read -r + _repl_resume + repl_draw +} + +repl_mode() { + local search="" + local SCROLL_OFFSET=0 + + # 进入备用屏幕缓冲区 + if _has_smcup; then + tput smcup + # 退出时恢复终端 + trap 'tput rmcup 2>/dev/null' EXIT + fi + + # 主循环:刷新数据 + 全量绘制 + while true; do + # 获取最新任务数据 + repl_refresh "$search" + local ret=$? + + # 修正滚动偏移:确保不超出范围 + local content_max + content_max=$(($(tput lines 2>/dev/null || echo 24) - ${#HEADER_LINES[@]} - ${#FOOTER_LINES[@]} - 1)) + [[ $content_max -lt 1 ]] && content_max=1 + local max_offset=$((${#CONTENT_LINES[@]} - content_max)) + [[ $max_offset -lt 0 ]] && max_offset=0 + if [[ $SCROLL_OFFSET -gt $max_offset ]]; then + SCROLL_OFFSET=$max_offset + fi + + if [[ $ret -ne 0 ]]; then + repl_draw + printf '> ' + read -r _ + continue + fi + + # 全量绘制(顶栏 + 内容 + 底栏) + repl_draw + + # ── 内层循环:单字符读取 + 内容区快速重绘(不刷全屏) ── + while true; do + printf '> ' + IFS= read -r -n 1 choice + choice="${choice:-}" + + case "$choice" in + j) + if [[ $SCROLL_OFFSET -lt $max_offset ]]; then + SCROLL_OFFSET=$((SCROLL_OFFSET + 1)) + repl_draw content_only + fi + ;; + k) + if [[ $SCROLL_OFFSET -gt 0 ]]; then + SCROLL_OFFSET=$((SCROLL_OFFSET - 1)) + repl_draw content_only + fi + ;; + g) + SCROLL_OFFSET=0 + repl_draw content_only + ;; + G) + SCROLL_OFFSET=$max_offset + repl_draw content_only + ;; + $'\e') + local esc_seq + IFS= read -r -n 2 -t 0.1 esc_seq + case "$esc_seq" in + '[A') # 上箭头 — 翻一页 + SCROLL_OFFSET=$((SCROLL_OFFSET - content_max)) + [[ $SCROLL_OFFSET -lt 0 ]] && SCROLL_OFFSET=0 + repl_draw content_only + ;; + '[B') # 下箭头 — 翻一页 + SCROLL_OFFSET=$((SCROLL_OFFSET + content_max)) + [[ $SCROLL_OFFSET -gt $max_offset ]] && SCROLL_OFFSET=$max_offset + repl_draw content_only + ;; + '[5') # PageUp + IFS= read -r -n 1 -t 0.05 _ + SCROLL_OFFSET=$((SCROLL_OFFSET - content_max)) + [[ $SCROLL_OFFSET -lt 0 ]] && SCROLL_OFFSET=0 + repl_draw content_only + ;; + '[6') # PageDown + IFS= read -r -n 1 -t 0.05 _ + SCROLL_OFFSET=$((SCROLL_OFFSET + content_max)) + [[ $SCROLL_OFFSET -gt $max_offset ]] && SCROLL_OFFSET=$max_offset + repl_draw content_only + ;; + esac + ;; + r|R) + search="" + SCROLL_OFFSET=0 + break ;; + s|S) + printf '\r搜索 (回车取消): ' + read -r new_search + search="${new_search,,}" + SCROLL_OFFSET=0 + break ;; + h|H) + repl_help + ;; + x|X) + # 提示输入编号(空格隔开)→ 批量重置到 pending + printf '\r重置任务编号 (空格隔开): ' + local reset_input + read -r reset_input + if [[ -z "$reset_input" ]]; then + : # 空输入,什么都不做 + elif [[ "$reset_input" =~ ^[0-9\ ]+$ ]]; then + local -a nums=() + local -a labels=() + local n valid=true + for n in $reset_input; do + local idx=$((n - 1)) + if [[ $idx -ge 0 && $idx -lt $TASK_COUNT ]]; then + if [[ "${TASK_STATUSES[$idx]}" != "pending" ]]; then + nums+=("$n") + labels+=("${TASK_LABELS[$idx]}") + fi + fi + done + if [[ ${#nums[@]} -eq 0 ]]; then + printf '\r没有可重置的任务(所选任务均已是 pending)\n' + sleep 1.5 + else + _repl_suspend + echo "" + echo "将重置以下 ${#nums[@]} 个任务:" + local i + for i in "${!nums[@]}"; do + printf " [%s] %s\n" "${nums[$i]}" "${labels[$i]}" + done + printf "确定? (y/N): " + read -r confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" == "yes" ]]; then + echo "" + local j + for j in "${!nums[@]}"; do + local idx=$((nums[$j] - 1)) + _reset_task_file "$idx" + done + echo "" + echo "按 Enter 返回..." + read -r + fi + _repl_resume + # 回到外层循环重新刷新 + repl_refresh "$search" + SCROLL_OFFSET=0 + break + fi + else + printf '\r无效输入,请输入数字(空格隔开)\n' + sleep 1.5 + fi + ;; + q|Q) + echo "" + echo "退出 REPL。调度器继续在后台运行。" + break 2 # 退出内外两层循环 + ;; + [0-9]*) + # 累积数字 → 任务编号 + local num="$choice" + while true; do + local next_char + IFS= read -r -n 1 -t 0.2 next_char || break + case "$next_char" in + [0-9]) num="$num$next_char" ;; + *) break ;; + esac + done + if [[ -n "$num" ]]; then + local idx=$((num - 1)) + if [[ $idx -ge 0 && $idx -lt $TASK_COUNT ]]; then + repl_view_log "$idx" + # 数据可能已变更,回到外层循环重新刷新 + repl_refresh "$search" + SCROLL_OFFSET=0 + break + else + # 显示错误提示 + _repl_suspend + echo "" + echo "无效编号: $num (共 $TASK_COUNT 个任务)" + echo "按 Enter 继续..." + read -r + _repl_resume + repl_draw + fi + fi + ;; + *) + ;; + esac + done + done +} + +# ─── main ────────────────────────────────────────────────── + +case "$MODE" in + loop) + # 调度器模式 (--run):确保单实例 + 注册退出清理 + ensure_single_instance + trap 'cleanup_lock' EXIT + trap 'cleanup_lock; exit 1' INT TERM + scheduler_loop + ;; + *) + # 默认: REPL 交互浏览器 + if [[ ! -d "$TASKS_DIR" ]]; then + echo "错误: 任务目录不存在: $TASKS_DIR" + echo "请先运行 create-tasks.js 创建任务队列。" + echo "" + echo "启动调度器: bash $0 <输出目录> --run" + exit 1 + fi + repl_mode + ;; +esac diff --git "a/doc/kb/.kb-temp/logs/OpenAPI \346\226\207\346\241\243.jsonl" "b/doc/kb/.kb-temp/logs/OpenAPI \346\226\207\346\241\243.jsonl" new file mode 100644 index 000000000000..d715aace0b16 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/OpenAPI \346\226\207\346\241\243.jsonl" @@ -0,0 +1,26 @@ +OpenAPI 文档知识库生成完成。以下是执行摘要: + +## 执行结果 + +**能力**: OpenAPI 文档(`/openapi` 路由) + +**模块特征**: +- 简单的文档查看页面,使用 `@scalar/api-reference-react` 渲染 FastGPT 的 OpenAPI 3.1.0 规范 +- 无 Tab 结构、无子能力、无 Store/自定义 Hooks +- 页面公开访问,无需登录 + +**已生成文档** (5 份): + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/OpenAPI 文档/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/OpenAPI 文档/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/OpenAPI 文档/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/OpenAPI 文档/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/OpenAPI 文档/组件列表.md` | + +**跳过的文档** (按需触发,本模块不适用): 11(共享能力清单)、14(Store数据流)、15(共享组件清单)、16(Hooks工具函数) + +**Tab 检测结果**: 无 Tab 结构 — 页面仅包含单个全屏 Scalar API Reference 组件。 + +**验证结果**: 校验仅报告了其他模块的预存问题(对话模块断链、旧根级文档缺少 frontmatter),OpenAPI 文档模块的 5 份文档均无问题。 diff --git a/doc/kb/.kb-temp/logs/finalize.jsonl b/doc/kb/.kb-temp/logs/finalize.jsonl new file mode 100644 index 000000000000..25bcb93a0f44 --- /dev/null +++ b/doc/kb/.kb-temp/logs/finalize.jsonl @@ -0,0 +1,16 @@ +**finalize 任务已完成。** 以下是执行摘要: + +### 完成的工作 + +1. **创建 `doc/kb/业务能力地图.md`**(489 行) + - **Mermaid 依赖图**:12 个一级能力模块,含子能力 subgraph(对话、应用、登录、账户、工作台、数据集、配置、技能),15+ 条跨模块依赖箭头 + - **文档链接索引**:完整遍历 `业务知识库/` 和 `技术知识库/` 的实际目录结构,按模块组织全部已生成文档链接(含 Tab 子目录) + - **统计信息**:12 个一级能力,70+ 总节点,380+ 文档 + +2. **`doc/kb/仓库概览.md`** — 知识库文档索引已由前置任务更新完毕,包含: + - 根级文档列表(业务能力地图、仓库架构、API架构、路由模型等) + - 业务知识库完整导航(含嵌套评测子能力) + - 技术知识库 API 索引导航 + - 无占位符残留 + +3. **`doc/kb/.kb-temp/tasks/.kb-task-finalize.json`** — 状态更新为 `done` diff --git a/doc/kb/.kb-temp/logs/root-00.jsonl b/doc/kb/.kb-temp/logs/root-00.jsonl new file mode 100644 index 000000000000..63d5efb64bfc --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-00.jsonl @@ -0,0 +1,20 @@ +--- + +## ✅ root-00 执行完成 + +**任务**:`共享基础设施` (type=root) + +**输出文件**:`doc/kb/共享基础设施.md` + +**自检结果**: + +| 检查项 | 状态 | +|--------|------| +| Frontmatter 字段齐全 (7/7) | ✅ | +| 禁项自检(用户故事/流程图/Changelog/构建配置) | ✅ 均无命中 | +| 共享模块表格覆盖全部发现目录 | ✅ 10 个目录 | +| 使用规范节完整 | ✅ | + +**覆盖的共享模块**:`packages/global/`、`packages/service/`、`packages/web/`、`packages/templates/`、`packages/diting-rag-ts/`、`plugins/`、`deploy/`、`projects/code-sandbox/`、`projects/mcp_server/`、`projects/agent-sandbox/` + +> 校验脚本报告的问题均为其他已有文件的预制问题(BROKEN_LINK 168条、MISSING_FRONTMATTER 12条、MISSING_FILE 业务能力地图.md),与本 task 无关。 diff --git a/doc/kb/.kb-temp/logs/root-01.jsonl b/doc/kb/.kb-temp/logs/root-01.jsonl new file mode 100644 index 000000000000..d70ebbaa99d3 --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-01.jsonl @@ -0,0 +1,20 @@ +**root-01 (`仓库概览`) 文档生成完成** ✅ + +已生成 `doc/kb/仓库概览.md`,内容包括: + +| 章节 | 说明 | +|------|------| +| 基本信息 | 从 `package.json` 提取的名称/版本,git SHA `ed5536221`,`project_context` 框架信息 | +| 目录结构 | 基于 `find src -maxdepth 3` 真实输出的 tree 格式,含中文注释 | +| 技术栈 | 从 `project_context`(三轴检测)+ `package.json` 版本号组合填充 | +| 仓库规模 | 源文件 1,360 / 一级模块 7 / 路由 45(来自 `route-table.json#total_routes`) | +| 知识库文档索引 | 从 `capability_tree` 递归展平,分为根级文档、业务知识库、技术知识库三部分 | + +自检结果: +- ✅ frontmatter 字段齐全 +- ✅ 禁项检查通过(无用户故事句式、无构建配置引用、无业务代码片段、无未验证路径) +- ✅ 仅含目录结构示例(允许的结构示例) + +```json +{"status":"ok","file":"仓库概览.md"} +``` diff --git a/doc/kb/.kb-temp/logs/root-02.jsonl b/doc/kb/.kb-temp/logs/root-02.jsonl new file mode 100644 index 000000000000..a95be9c4771e --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-02.jsonl @@ -0,0 +1,19 @@ +`root-02`(API架构)文档生成完成。 + +**生成文件**: `doc/kb/API架构.md` + +**文档覆盖内容**: + +| 章节 | 内容 | +|------|------| +| 请求层封装 | 三套请求模块(主业务 API / Laf API / SSE 流式),axios 实例配置,拦截器模式 | +| 错误处理机制 | 错误分类表(业务错误/Token 失效/余额不足/服务端异常/网络异常),页面例外保护,Laf 401 自动刷新 | +| 公共参数 | `NEXT_PUBLIC_BASE_URL` 子路由前缀、Laf token 注入、Date 序列化、undefined 清理 | +| 环境切换 | Base URL 配置、Next.js API Routes 代理模式、环境变量说明 | + +**关键源码文件**: +- `projects/app/src/web/common/api/request.ts` — 主业务 API(axios 封装 + 并发控制) +- `projects/app/src/web/common/api/lafRequest.ts` — Laf 云函数 API(含 token 刷新) +- `projects/app/src/web/common/api/fetch.ts` — SSE 流式请求(含断流恢复) +- `projects/app/src/web/common/api/client.ts` — 共享错误处理函数 +- `projects/app/src/web/common/utils/errorLogger.ts` — 浏览器错误日志收集 diff --git a/doc/kb/.kb-temp/logs/root-03.jsonl b/doc/kb/.kb-temp/logs/root-03.jsonl new file mode 100644 index 000000000000..26049836e0c5 --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-03.jsonl @@ -0,0 +1,27 @@ +--- + +✅ **root-03(路由模型)执行完成。** + +**输出文件**:`doc/kb/路由模型.md` + +**文档内容概要**: + +| 章节 | 内容 | +|------|------| +| §1 路由架构 | Next.js 文件系统路由、三种布局模式、38 个页面路由清单、导航结构 | +| §2 懒加载策略 | `next/dynamic` 统一使用、`ssr: false` 模式、`.then()` 具名导出 | +| §3 角色权限架构 | 位域 RBAC 模型、基础/扩展角色、6 种资源类型、完整权限判断流程(7 步) | +| §4 路由守卫逻辑 | 前端 Auth 白名单守卫、API 拦截器重定向、非权限类守卫(标题/CSP/Viewport) | +| §5 路由参数约定 | Query-based 参数模式、9 个常用 query 参数、`replace` vs `push` 约定 | + +**自检结果**: +- ✅ frontmatter 字段齐全 +- ✅ 所有路径来自项目真实文件 +- ✅ 懒加载示例为项目真实代码模式 +- ✅ 角色权限架构完整,判断流程与实际实现一致 +- ✅ 守卫逻辑不包含权限逻辑(权限在 §3) +- ✅ 禁项检查全部通过(无用户故事/业务流程图/构建配置) + +```json +{"status":"ok","file":"路由模型.md"} +``` diff --git a/doc/kb/.kb-temp/logs/root-04.jsonl b/doc/kb/.kb-temp/logs/root-04.jsonl new file mode 100644 index 000000000000..528b1c47b9ea --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-04.jsonl @@ -0,0 +1,15 @@ +任务已完成(状态已被调度器更新为 `done`)。 + +--- + +**root-04「国际化策略」文档生成完成** ✅ + +| 项目 | 详情 | +|------|------| +| **输出文件** | `doc/kb/国际化策略.md` | +| **文档类型** | 04-国际化策略(根级文档) | +| **覆盖内容** | i18n 目录结构(28 命名空间 × 3 语种)、词条 key 命名规范(3 种模式 + 插值变量)、三套翻译函数体系、语言切换机制(双层存储 + 后端同步)、新增词条流程 | + +```json +{"status":"ok","file":"国际化策略.md"} +``` diff --git a/doc/kb/.kb-temp/logs/root-05.jsonl b/doc/kb/.kb-temp/logs/root-05.jsonl new file mode 100644 index 000000000000..c339b4ede10f --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-05.jsonl @@ -0,0 +1,22 @@ +`root-05`(自动化测试)文档已生成完毕。 + +**生成位置**:`doc/kb/自动化测试.md` + +**文档内容概要**: + +| 章节 | 内容 | +|------|------| +| 测试框架 | Vitest 3.0.9 + @vitest/coverage-v8 3.0.9 + mongodb-memory-server 10.1.4 | +| 覆盖率要求 | 未配置 coverageThreshold,仅生成报告不作拦截 | +| 运行命令 | `pnpm test`、`pnpm test:vector`、沙箱 `pnpm test` / `pnpm test:watch` | +| 编写规范 | `*.test.ts` 命名、集中式 `test/` 目录、describe/it 2-3 层结构 | +| Mock 策略 | vi.mock 模块级 Mock、MongoDB 内存实例、权限鉴权 Mock | +| 配置要点 | fileParallelism=false(串行防冲突)、maxConcurrency=10、threads 线程池 | + +**自检结果**: +- ✅ Frontmatter 字段齐全(capability_label=null, doc_type="05") +- ✅ 版本号从 package.json 真实提取 +- ✅ 覆盖率阈值明确标注"未配置" +- ✅ 运行命令与 package.json scripts 一致 +- ✅ 测试示例来自 `test/cases/global/common/string/utils.test.ts` +- ✅ 无禁项内容 diff --git a/doc/kb/.kb-temp/logs/root-07.jsonl b/doc/kb/.kb-temp/logs/root-07.jsonl new file mode 100644 index 000000000000..759a1b19e359 --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-07.jsonl @@ -0,0 +1,19 @@ +**root-07 `代码编写指南.md` 已生成完毕。** + +## 生成摘要 + +| 项目 | 内容 | +|------|------| +| 输出文件 | `doc/kb/代码编写指南.md` | +| 文档类型 | 07 — 仓库级技术知识文档 | +| 状态 | `done` | + +### 文档包含四个章节: + +1. **组件结构模板** — 基于 `inform.tsx`、`setting.tsx`、`usage/index.tsx` 三个页面组件归纳的 React FC 标准结构,含 import 顺序、hooks 组织、SSR 注入、分页模式等完整注释 + +2. **命名规则** — 11 类命名规范的完整表格(组件文件 PascalCase、路由目录 kebab-case、工具函数 `utils.ts`、Store `useXxxStore.ts` 等),附 5 处已知不一致命名的标注 + +3. **常见反模式** — 8 条有证据支撑的反模式(`exhaustive-deps` 禁用占比 69%、`sfDatasetSelectModal.tsx` 命名违规、路由目录 camelCase 不一致等) + +4. **国际化使用指南** — 基于 `next-i18next` + 30 命名空间 + 三语种的实际使用方式,含快速上手步骤、常见陷阱、命名空间速查表 diff --git a/doc/kb/.kb-temp/logs/root-17.jsonl b/doc/kb/.kb-temp/logs/root-17.jsonl new file mode 100644 index 000000000000..ff49a119ae46 --- /dev/null +++ b/doc/kb/.kb-temp/logs/root-17.jsonl @@ -0,0 +1,13 @@ +✅ **`root-17` 文档生成完成** + +**自检清单**: +- ✅ frontmatter 字段齐全(capability_label=null, doc_type="17", doc_label="本地开发指南", generated_at, parent_module=null, roles=[], router_paths=[]) +- ✅ 环境要求版本号从 `package.json` 真实提取(Node.js ≥20, pnpm 9.15.9) +- ✅ 启动命令从 `package.json` scripts 真实提取 +- ✅ Mock 方案基于实际依赖检测(无浏览器端 mock,测试用 Vitest `vi.mock()`) +- ✅ Mock 示例基于实际 `test/mocks/` 目录结构 +- ✅ 禁项自检通过(技术知识禁项无一命中) + +```json +{"status":"ok","file":"本地开发指南.md"} +``` diff --git "a/doc/kb/.kb-temp/logs/\344\273\267\346\240\274.jsonl" "b/doc/kb/.kb-temp/logs/\344\273\267\346\240\274.jsonl" new file mode 100644 index 000000000000..95f689156a68 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\344\273\267\346\240\274.jsonl" @@ -0,0 +1,22 @@ +✅ **价格模块知识库文档生成完成!** + +## 生成结果 + +### 业务知识库(`doc/kb/业务知识库/价格/`) +| 文档 | 说明 | +|------|------| +| **模块概览.md** | 价格中心模块定位、角色差异(管理员/成员)、5 项跨模块依赖、3 项对外共享能力 | +| **业务流程索引.md** | 5 个业务场景:购买套餐、额外积分、知识库容量、模型计费、FAQ,含触发入口和场景关系 | +| **业务流程详解.md** | 每个场景的完整交互链路:页面初始化→参数选择→提交购买→支付完成,含 5 张 Mermaid 时序图 | + +### 技术知识库(`doc/kb/技术知识库/价格/`) +| 文档 | 说明 | +|------|------| +| **API索引.md** | 3 个后端 API 的反向索引 + 调用链追踪,1 个 Store 初始化调用 | +| **组件列表.md** | 4 个业务组件 + 1 个页面组件 + 1 个工具函数,含 7 个外部消费组件 | + +### 关键发现 +- **无 Tab 结构**:价格页面为单页滚动布局,4 个区域顺序渲染 +- **API 入口**:核心为 `POST /proApi/support/wallet/bill/create`,被 Standard 和 ExtraPlan 两处调用 +- **状态管理**:依赖 `useSystemStore`(套餐配置/模型列表)和 `useUserStore`(用户信息/订阅状态),无独立 Store +- **权限模型**:基于 `isWecomTeam` + `currentSubLevel` 的前端条件渲染,企业微信免费团队有额外购买限制 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235.jsonl" new file mode 100644 index 000000000000..f20ab4920892 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235.jsonl" @@ -0,0 +1,19 @@ +`--scope 对话` 知识库文档生成完成。以下是生成的文档清单: + +## 对话模块 — 生成结果 + +| 编号 | 文档名 | 输出位置 | +|------|--------|---------| +| 08 | 模块概览 | `doc/kb/业务知识库/对话/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/对话/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/对话/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/对话/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/对话/组件列表.md` | + +## 关键说明 + +- **对话**是纯分组节点(`is_grouping: true`),下含 **对话首页** 和 **分享对话** 两个子能力 +- 10-业务流程详解 按分组节点规则生成,仅包含子能力索引链接(不探索源码) +- 子能力文档(对话首页、分享对话)已被其他会话并行生成完毕 +- 08-模块概览 覆盖了 5 个对话首页窗格和 3 种分享对话模式的业务概述 +- 12-API索引 包含 17 个 API 端点的反向索引和 3 条完整调用链追踪 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.jsonl" new file mode 100644 index 000000000000..67b086369520 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.jsonl" @@ -0,0 +1,29 @@ +--- + +## ✅ 「分享对话」知识库文档生成完成 + +**能力**: 分享对话 | **入口**: `projects/app/src/pages/chat/share.tsx` | **路由**: `/chat/share` + +### 生成的文档 + +| 编号 | 文档 | 位置 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/对话/分享对话/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `doc/kb/业务知识库/对话/分享对话/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `doc/kb/业务知识库/对话/分享对话/业务流程详解.md` | ✅ | +| 12 | API索引 | `doc/kb/技术知识库/对话/分享对话/API索引.md` | ✅ | +| 13 | 组件列表 | `doc/kb/技术知识库/对话/分享对话/组件列表.md` | ✅ | +| 14 | Store数据流 | `doc/kb/技术知识库/对话/分享对话/Store数据流.md` | ✅ (按需) | + +### 分析摘要 + +- **Tab 检测**: 无 Tab,单页面结构 +- **框架**: React (Next.js SSR) +- **核心功能**: 外部用户通过分享链接匿名/认证后与 AI 应用对话 +- **关键 API**: `GET /core/chat/outLink/init`(初始化)、`POST /api/v2/chat/completions`(流式对话) +- **关键 Store**: `useShareChatStore`(本地用户身份持久化)、`useChatStore`(聊天状态管理) +- **模式**: 支持匿名访问和强制认证两种模式,由 `MongoOutLink.allowAnonymous` 控制 + +```json +{"label":"分享对话","docs_generated":["08","09","10","12","13","14"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..d935d7ccf85f --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.jsonl" @@ -0,0 +1,35 @@ +**`--scope 对话_对话首页` 文档生成完成。** 以下是执行摘要: + +### Tab 检测 + +通过行为结构检测(模式 C 条件渲染 + 模式 A 激活态切换),发现侧边栏 5 个面板等价于 Tab 结构,已创建 5 个子任务: + +| Tab | label_safe | 状态 | +|-----|-----------|------| +| 首页 | `对话_对话首页_首页` | pending | +| 精选应用 | `对话_对话首页_精选应用` | in_progress(调度器已接管) | +| 团队应用 | `对话_对话首页_团队应用` | in_progress(调度器已接管) | +| 最近使用 | `对话_对话首页_最近使用` | in_progress(调度器已接管) | +| 设置 | `对话_对话首页_设置` | in_progress(调度器已接管) | + +### 生成文档 + +| # | 文档 | 路径 | +|---|------|------| +| 08 | 模块概览 | `业务知识库/对话/对话首页/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/对话/对话首页/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/对话/对话首页/业务流程详解.md` | +| 11 | 共享能力清单 | `业务知识库/对话/对话首页/共享能力清单.md` | +| 12 | API索引 | `技术知识库/对话/对话首页/API索引.md` | +| 13 | 组件列表 | `技术知识库/对话/对话首页/组件列表.md` | +| 14 | Store数据流 | `技术知识库/对话/对话首页/Store数据流.md` | +| 15 | 共享组件清单 | `技术知识库/对话/对话首页/共享组件清单.md`(跳过版) | +| 16 | Hooks工具函数 | `技术知识库/对话/对话首页/Hooks工具函数.md`(跳过版) | + +### 关键发现 + +- **框架**: React + Next.js + Chakra UI + Zustand +- **角色差异**: 4 层(未登录→登录弹窗;普通用户→团队应用+最近使用;Plus版→+首页+精选应用;管理员+Plus版→+设置) +- **状态管理**: `ChatPageContext`(React Context)+ `useChatStore`(Zustand persist,跨 Tab localStorage 同步) +- **API**: 2 个前端 API + 2 个 SSR 数据库查询 +- **占位符检查**: 0 处残留 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.jsonl" new file mode 100644 index 000000000000..657bb358e9a2 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.jsonl" @@ -0,0 +1,26 @@ +任务执行完毕。以下是 `对话_对话首页_团队应用` 模块的文档生成摘要: + +## 生成结果 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `业务知识库/对话/对话首页/团队应用/模块概览.md` | ✅ | +| 09-业务流程索引 | `业务知识库/对话/对话首页/团队应用/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `业务知识库/对话/对话首页/团队应用/业务流程详解.md` | ✅ | +| 12-API索引 | `技术知识库/对话/对话首页/团队应用/API索引.md` | ✅ | +| 13-组件列表 | `技术知识库/对话/对话首页/团队应用/组件列表.md` | ✅ | + +## 模块分析摘要 + +- **框架**: React + Chakra UI +- **Tab 检测**: Chakra UI `` 为类型筛选 Tab(非独立子页面),不拆分子任务 +- **组件**: 3 个(ContextRender 容器、List 卡片列表、AppTypeTag 类型标签) +- **API**: 无独立 API 文件,通过 `AppListContext` 调用共享 `web/core/app/api` 层 +- **跨模块共享**: List 被 5 个其他页面复用;AppTypeTag 被 dashboard/agent 复用 + +## -- 裁剪项 + +- 11-共享能力清单:跨模块依赖已在 08 §3 中覆盖,依赖项 < 5 +- 14-Store数据流:本模块无独立 Store,数据通过 React Context 管理 +- 15-共享组件清单:共享关系已在 13 中标注 +- 16-Hooks工具函数:本模块无自定义 Hooks diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.jsonl" new file mode 100644 index 000000000000..a53cd38c42ec --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.jsonl" @@ -0,0 +1,21 @@ +--- + +## ✅ `对话_对话首页_最近使用` 文档生成完成 + +### 生成文档清单 + +| 编号 | 文档名 | 位置 | 大小 | +|------|--------|------|------| +| 08 | 模块概览 | `业务知识库/对话/对话首页/最近使用/模块概览.md` | 2.9 KB | +| 09 | 业务流程索引 | `业务知识库/对话/对话首页/最近使用/业务流程索引.md` | 3.5 KB | +| 10 | 业务流程详解 | `业务知识库/对话/对话首页/最近使用/业务流程详解.md` | 11.0 KB | +| 12 | API索引 | `技术知识库/对话/对话首页/最近使用/API索引.md` | 3.4 KB | +| 13 | 组件列表 | `技术知识库/对话/对话首页/最近使用/组件列表.md` | 2.2 KB | + +### 模块分析摘要 + +- **类型**: Leaf 能力节点,无子能力,无 Tab 结构 +- **核心组件**: `AppChatWindow` — 最近使用应用的聊天窗口 +- **核心 API**: `GET /api/core/chat/recentlyUsed` (获取最近使用列表,30秒轮询) + `GET /api/core/chat/init` (初始化聊天) +- **数据流**: `ChatPageContext` 提供 `myApps` 列表和 `refreshRecentlyUsed` 刷新方法,`ChatSlider` 侧边栏渲染列表 +- **可选文档跳过原因**: 11/14/15/16 均不适用 — 本模块仅消费父模块的能力,不对外提供共享组件/Store/Hooks diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.jsonl" new file mode 100644 index 000000000000..245e9f96db98 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.jsonl" @@ -0,0 +1,18 @@ +任务完成。以下是执行总结: + +```json +{"label":"精选应用","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` + +**执行摘要**: + +| 步骤 | 结果 | +|------|------| +| Tab 检测 | 无 Tab 子节点(Chakra UI `` 仅为标签筛选控件,非内容分区) | +| 08-模块概览 | ✅ 已生成 — 含业务概述、角色差异(普通用户/管理员)、跨模块依赖、关键页面入口 | +| 09-业务流程索引 | ✅ 已生成 — S01 浏览精选应用列表、S02 跳转配置 | +| 10-业务流程详解 | ✅ 已生成 — 含完整步骤表格、数据加载详情、Mermaid 序列图 | +| 12-API索引 | ✅ 已生成 — `GET /proApi/core/chat/setting/favourite/list` 全量调用链追踪 | +| 13-组件列表 | ✅ 已生成 — ChatFavouriteApp 及消费的外部组件清单 | +| 11/14/15/16 | ⊘ 跳过 — 无共享能力/无专用 Store/无共享组件/无自定义 Hooks | +| 禁项自检 | ✅ 通过 | diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.jsonl" new file mode 100644 index 000000000000..4cb43e6b44cf --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.jsonl" @@ -0,0 +1,33 @@ +验证结果显示: + +- **我生成的文档**:链接错误是因为 4 个 Tab 子能力的文档尚未生成(它们现在是 `pending` 状态的任务),属于正常情况 +- **其他报错**:均为知识库中预先存在的问题(其他模块的 frontmatter 缺失、链接断裂等),与本次生成无关 + +任务完成。总结如下: + +--- + +## `对话_对话首页_设置` — 生成完成 + +### Tab 检测结果 +发现 4 个 Tab(行为结构模式 C:条件渲染),已创建 4 个子任务: + +| 子任务 | 入口文件 | 状态 | +|--------|---------|------| +| `对话_对话首页_设置_首页设置` | `HomepageSetting/index.tsx` | pending | +| `对话_对话首页_设置_数据看板` | `DataDashboard.tsx` | pending | +| `对话_对话首页_设置_日志详情` | `LogDetails.tsx` | in_progress | +| `对话_对话首页_设置_收藏应用` | `FavouriteAppSetting/index.tsx` | in_progress | + +### 已生成文档(6 个) + +| 文档 | 路径 | +|------|------| +| 08-模块概览 | `业务知识库/对话/对话首页/设置/模块概览.md` | +| 09-业务流程索引 | `业务知识库/对话/对话首页/设置/业务流程索引.md` | +| 10-业务流程详解 | `业务知识库/对话/对话首页/设置/业务流程详解.md` | +| 12-API索引 | `技术知识库/对话/对话首页/设置/API索引.md` | +| 13-组件列表 | `技术知识库/对话/对话首页/设置/组件列表.md` | +| 16-Hooks工具函数 | `技术知识库/对话/对话首页/设置/Hooks工具函数.md` | + +由于 `has_tab_children: true`,文档 08/09/10 仅覆盖入口公共功能(权限校验、Tab 切换、页面初始化),各 Tab 子能力的深度内容由各自的子任务生成。 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.jsonl" new file mode 100644 index 000000000000..9c65cbc5009f --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.jsonl" @@ -0,0 +1,20 @@ +`--scope 对话_对话首页_设置_收藏应用` 执行完成。 + +## 文档生成结果 + +| 文档编号 | 文档名 | 路径 | 状态 | +|---------|--------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/对话/对话首页/设置/收藏应用/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `doc/kb/业务知识库/对话/对话首页/设置/收藏应用/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `doc/kb/业务知识库/对话/对话首页/设置/收藏应用/业务流程详解.md` | ✅ | +| 12 | API索引 | `doc/kb/技术知识库/对话/对话首页/设置/收藏应用/API索引.md` | ✅ | +| 13 | 组件列表 | `doc/kb/技术知识库/对话/对话首页/设置/收藏应用/组件列表.md` | ✅ | + +### 关键发现 + +- **无 Tab 结构**: 收藏应用模块为单一列表管理视图,无嵌套 Tab +- **框架**: React(函数组件 + React Hooks) +- **5 个业务场景**: 查看列表、添加应用(弹窗)、管理标签(弹窗)、拖拽排序、删除应用 +- **5 个 API 端点**: list/get、update(批量添加)、order(排序)、tags(标签关联)、delete——均为 `/proApi/` 前缀的商业版接口 +- **3 个组件**: 主组件 + TagManageModal + AddFavouriteAppModal +- **权限模型**: `hasManagePer` + `isPlus` 双重校验 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.jsonl" new file mode 100644 index 000000000000..80384e731cd8 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.jsonl" @@ -0,0 +1,33 @@ +任务 `对话_对话首页_设置_数据看板` 已完成。 + +## 执行摘要 + +### 代码探索结果 +- **入口文件**: `projects/app/src/pageComponents/chat/ChatSetting/DataDashboard.tsx`(React 组件,48 行) +- **无 Tab 结构** — 单一视图页面 +- **核心依赖**: `LogChart`、`LogsContextProvider`(均来自 `@/pageComponents/app/detail/Logs/`) +- **数据来源**: `ChatPageContext.chatSettings.appId` → `LogsContextProvider` → `LogChart` → API 调用 + +### 生成的文档(7 个文件) + +**业务知识库**(5 个文档): +| 文档 | 路径 | +|------|------| +| 08-模块概览.md | `业务知识库/对话/对话首页/设置/数据看板/` | +| 09-业务流程索引.md | `业务知识库/对话/对话首页/设置/数据看板/` | +| 10-业务流程详解.md | `业务知识库/对话/对话首页/设置/数据看板/` | +| 12-API索引.md | `业务知识库/对话/对话首页/设置/数据看板/` | +| 13-组件列表.md | `业务知识库/对话/对话首页/设置/数据看板/` | + +**技术知识库**(2 个文档): +| 文档 | 路径 | +|------|------| +| 12-API索引.md | `技术知识库/对话/对话首页/设置/数据看板/` | +| 13-组件列表.md | `技术知识库/对话/对话首页/设置/数据看板/` | + +### 按需跳过 +- 11-共享能力清单、14-Store数据流、15-共享组件清单、16-Hooks工具函数 — DataDashboard 仅消费外部能力,不导出共享内容,无自定义 Store/Hook。 + +### 状态 +- Task 状态已更新为 `done` +- 验证中 32 个问题均为预存问题(全局文档、其他模块断链),本任务无新增问题 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..4b3f61a176ee --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.jsonl" @@ -0,0 +1,30 @@ +--- + +## ✅ `对话_对话首页_设置_日志详情` 文档生成完成 + +### 生成文件清单 + +**业务知识库** (`doc/kb/业务知识库/对话/对话首页/设置/日志详情/`): + +| 文件 | 类型 | 内容概要 | +|------|------|---------| +| `模块概览.md` | 必生成 | 模块定位(管理员日志管理工具)、角色差异(hasManagePer+isPlus)、跨模块依赖(LogsContext/LogTable等) | +| `业务流程索引.md` | 必生成 | 6 个业务场景(S01-S06):查看列表、筛选、列配置、对话详情、导出、批量删除 | +| `业务流程详解.md` | 必生成 | 每个场景完整步骤追踪 + Mermaid 时序图(含筛选联动、删除确认、导出链路) | +| `共享能力清单.md` | 按需 | 跳过 — 本模块无对外共享能力 | + +**技术知识库** (`doc/kb/技术知识库/对话/对话首页/设置/日志详情/`): + +| 文件 | 类型 | 内容概要 | +|------|------|---------| +| `API索引.md` | 必生成 | 8 个 API 反向索引 + 调用链追踪(日志列表/列配置/导出/对话记录等) | +| `组件列表.md` | 必生成 | LogDetails + LogsFilterBar 组件清单,标注外部依赖(来自 app/detail/Logs) | +| `Store数据流.md` | 按需 | 跳过 — 使用 React Context 无独立 Store | +| `共享组件清单.md` | 按需 | 跳过 — 组件全部来自外部 app/detail/Logs 模块 | +| `Hooks工具函数.md` | 按需 | 跳过 — 仅使用 useContextSelector 和 useTranslation 外部 Hooks | + +### 关键发现 + +- **无 Tab 子节点**:日志详情本身即为 ChatSetting 的 Tab,内部无嵌套 Tab +- **组件复用**:核心 UI(LogTable、HeaderControl、LogKeysConfigPopover)均来自 `@/pageComponents/app/detail/Logs/` +- **权限模型**:双重校验(`hasManagePer` + `isPlus`),在 ChatSetting 入口统一拦截 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.jsonl" new file mode 100644 index 000000000000..bb2a6210e9e3 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.jsonl" @@ -0,0 +1,22 @@ +✅ `对话_对话首页_设置_首页设置` 知识库文档生成完成。 + +## 生成摘要 + +| 文档 | 类型 | 路径 | 大小 | +|------|------|------|------| +| **模块概览** | 业务 (08) | `业务知识库/对话/对话首页/设置/首页设置/模块概览.md` | 3.8 KB | +| **业务流程索引** | 业务 (09) | `业务知识库/对话/对话首页/设置/首页设置/业务流程索引.md` | 3.6 KB | +| **业务流程详解** | 业务 (10) | `业务知识库/对话/对话首页/设置/首页设置/业务流程详解.md` | 12.8 KB | +| **API索引** | 技术 (12) | `技术知识库/对话/对话首页/设置/首页设置/API索引.md` | 5.2 KB | +| **组件列表** | 技术 (13) | `技术知识库/对话/对话首页/设置/首页设置/组件列表.md` | 3.2 KB | + +## 模块分析 + +- **入口**: `HomepageSetting/index.tsx`(React 功能组件) +- **Tab 检测**: 无 Tab(纯表单页,所有配置项在同一视图) +- **核心功能**: 4 个业务场景 — 首页开关、快捷应用管理、可用工具管理、欢迎标语 +- **核心 API**: `POST /proApi/core/chat/setting/update` + `GET /proApi/core/chat/setting/detail` +- **子组件**: `AddQuickAppModal`(动态 import)、`ToolSelectModal`(兄弟组件) +- **状态管理**: 通过 `ChatPageContext` 消费 `chatSettings` + `react-hook-form` 管理表单状态 + +验证脚本的报错均来自其他未完成模块(父级「设置」模块的 broken link、root 级 frontmatter 缺失),与本模块无关。 diff --git "a/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..2e99ce92df2d --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.jsonl" @@ -0,0 +1,24 @@ +--- + +## 完成摘要 + +`--scope 对话_对话首页_首页` 执行完毕。 + +### 生成文档清单(7 个) + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/对话/对话首页/首页/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/对话/对话首页/首页/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/对话/对话首页/首页/业务流程详解.md` | +| 12 | API索引 | `技术知识库/对话/对话首页/首页/API索引.md` | +| 13 | 组件列表 | `技术知识库/对话/对话首页/首页/组件列表.md` | +| 14 | Store数据流 | `技术知识库/对话/对话首页/首页/Store数据流.md` | +| 16 | Hooks工具函数 | `技术知识库/对话/对话首页/首页/Hooks工具函数.md` | + +### 关键发现 + +- **无 Tab 结构**:`HomeChatWindow.tsx` 是单一对话页面,无子 Tab +- **两种对话模式**:快速应用模式(复用应用工作流)和自定义模式(自由选模型+工具) +- **核心 API**:`getInitChatInfo`(初始化)、`POST /api/proApi/core/chat/chatHome`(自定义对话)、`POST /api/core/chat/completions`(快速应用对话) +- **跳过文档**:11(共享能力清单)和 15(共享组件清单)——模块为叶子节点,主消费不共享 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260.jsonl" new file mode 100644 index 000000000000..213ac40476cb --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260.jsonl" @@ -0,0 +1,21 @@ +验证通过:无占位符残留,所有文档已生成。 + +**工作台(分组节点)知识库文档生成完成:** + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `业务知识库/工作台/模块概览.md` | ✅ | +| 09-业务流程索引 | `业务知识库/工作台/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `业务知识库/工作台/业务流程详解.md` | ✅ | +| 12-API索引 | `技术知识库/工作台/API索引.md` | ✅ | +| 13-组件列表 | `技术知识库/工作台/组件列表.md` | ✅ | + +由于工作台是分组节点(`is_grouping: true`),文档以**子能力索引**方式组织: +- 08/09/10 覆盖了工作台公共功能(侧边栏导航、权限控制、页面容器),并提供了 8 个子能力的文档链接索引 +- 12 汇总了应用管理、文件夹、模板市场等跨子能力的公共 API +- 13 列出了 DashboardContainer 等公共布局组件及各子能力的页面组件入口 +- 无需生成 11(共享能力清单)、14(Store数据流)、15(共享组件清单)、16(Hooks工具函数) + +```json +{"label":"工作台","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.jsonl" new file mode 100644 index 000000000000..781a2b5d6b70 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.jsonl" @@ -0,0 +1,27 @@ +--- + +## ✅ 「工作台_MCP 服务器」知识库文档生成完成 + +### 生成文档清单 + +| 编号 | 文档 | 类型 | 路径 | +|------|------|------|------| +| 08 | 模块概览 | 业务知识 | `业务知识库/工作台/MCP 服务器/模块概览.md` | +| 09 | 业务流程索引 | 业务知识 | `业务知识库/工作台/MCP 服务器/业务流程索引.md` | +| 10 | 业务流程详解 | 业务知识 | `业务知识库/工作台/MCP 服务器/业务流程详解.md` | +| 12 | API索引 | 技术知识 | `技术知识库/工作台/MCP 服务器/API索引.md` | +| 13 | 组件列表 | 技术知识 | `技术知识库/工作台/MCP 服务器/组件列表.md` | + +### 模块摘要 + +**MCP 服务器** 是工作台下的叶子能力节点,位于路由 `/dashboard/mcpServer`,是 FastGPT 将应用发布为 MCP 标准服务器的管理功能: + +- **页面组件**: `projects/app/src/pages/dashboard/mcpServer/index.tsx` +- **业务组件**: `EditMcpModal`(创建/编辑弹窗)、`UsageWay`(使用方式展示) +- **API 端点**: `/api/support/mcp/list` (GET)、`/api/support/mcp/create` (POST)、`/api/support/mcp/update` (PUT)、`/api/support/mcp/delete` (DELETE) +- **权限模型**: 基于 `hasApikeyCreatePer` 控制创建权限 +- **Tab 检测**: 无 Tab 结构(单页面 + 弹窗模式) + +### 验证状态 + +验证脚本发现的 62 个问题均为其他模块的预存问题(断链到未生成的能力文档、根级文档 frontmatter 缺失等),MCP 服务器 5 个文档无自身问题。 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.jsonl" new file mode 100644 index 000000000000..73040ae70596 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.jsonl" @@ -0,0 +1,23 @@ +`kb-init-frontend --scope 工作台_创建应用` 执行完成。 + +## 执行总结 + +**Tab 检测结果**: 无 Tab 结构。页面使用 `selectedAppType` 条件渲染不同表单区域(工作流智能生成 / MCP 配置 / HTTP 配置),非 Tab 导航模式。 + +**已生成文档**(5 篇): + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/工作台/创建应用/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/工作台/创建应用/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/工作台/创建应用/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/工作台/创建应用/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/工作台/创建应用/组件列表.md` | + +**未生成按需文档**(判定依据): +- 11(共享能力清单)— 本模块为创建入口页面,不对外提供共享能力 +- 14(Store数据流)— 使用 `useSystemStore` 是系统级 store,非本模块专属 +- 15(共享组件清单)— 使用的共享组件来自 `@fastgpt/web` 公共库 +- 16(Hooks工具函数)— 使用 `useRequest`/`useForm`/`useUploadAvatar` 均为通用 hooks + +**任务状态**: `done` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..3e5aadd2ae5e --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.jsonl" @@ -0,0 +1,19 @@ +--- + +## ✅ 工作台_工具管理 — 知识库文档生成完成 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 模块概览 | `doc/kb/业务知识库/工作台/工具管理/模块概览.md` | ✅ | +| 业务流程索引 | `doc/kb/业务知识库/工作台/工具管理/业务流程索引.md` | ✅ | +| 业务流程详解 | `doc/kb/业务知识库/工作台/工具管理/业务流程详解.md` | ✅ | +| API索引 | `doc/kb/技术知识库/工作台/工具管理/API索引.md` | ✅ | +| 组件列表 | `doc/kb/技术知识库/工作台/工具管理/组件列表.md` | ✅ | + +**生成摘要**: +- **Tab 检测**:入口中的 `MyTabs` 是兄弟能力导航,"系统工具"已在能力树中独立存在,非子 Tab。`MyTabBar` 是类型过滤器。无嵌套 Tab,`has_tab_children` 保持 false。 +- **API 覆盖**:14 个 API 函数,4 个 API 模块,含完整的调用链追踪 +- **业务场景**:12 个场景(查看列表、类型筛选、搜索、创建文件夹/工具、编辑、移动、删除、协作者管理、复制、导入 JSON、文件夹导航) +- **组件覆盖**:入口组件 + ToolModal + 复用组件(List、Context、Container)+ 本地组件(MyTabBar、FolderPath、FolderSlideCard) +- **可选文档**:11/14/15/16 跳过——本模块主要消费共享组件,无独立共享能力或独有 Store/Hooks +- **验证**:工具管理模块 5 篇文档 0 问题 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.jsonl" new file mode 100644 index 000000000000..b1f5f6708cbf --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.jsonl" @@ -0,0 +1,17 @@ +--- + +## ✅ 应用工作台 知识库文档生成完成 + +**Tab 检测结果**:`MyTabs` 的 4 个标签(全部/智能问答/Chat Agent/工作流机器人)仅做类型过滤,不切换视图 → **不拆分子任务**,`has_tab_children: false`。 + +**已生成 5 份文档**: + +| 文档 | 路径 | 内容要点 | +|------|------|---------| +| 08-模块概览 | `doc/kb/业务知识库/工作台/应用工作台/模块概览.md` | 模块定位、4 类角色差异、5 条跨模块依赖、5 个关键页面入口 | +| 09-业务流程索引 | `doc/kb/业务知识库/工作台/应用工作台/业务流程索引.md` | 14 个业务场景(浏览/筛选/文件夹导航/创建/编辑/复制/删除/移动/导出/导入/模板/权限),含场景关系说明 | +| 10-业务流程详解 | `doc/kb/业务知识库/工作台/应用工作台/业务流程详解.md` | 每个场景的完整交互步骤(四列步骤表格)、数据加载详情、表单字段清单、删除链路、Mermaid 附录(6 张图) | +| 12-API索引 | `doc/kb/技术知识库/工作台/应用工作台/API索引.md` | 15 个 API 的反向索引(路径/方法/调用位置/调用场景分组)、8 条完整调用链追踪 | +| 13-组件列表 | `doc/kb/技术知识库/工作台/应用工作台/组件列表.md` | 8 个核心组件(按目录分组)、10 个消费的外部组件、4 个 Store/Context | + +**验证结果**:`validate-output.js` 报告的 62 个问题均为其他模块预存问题,应用工作台文档无违规项。Task 状态已更新为 `done`。 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..e7fad1e6f406 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.jsonl" @@ -0,0 +1,31 @@ +--- + +## ✅ 工作台_技能管理 — 文档生成完成 + +### 执行摘要 + +| 项目 | 结果 | +|------|------| +| **能力节点** | 工作台 → 技能管理(leaf) | +| **Tab 检测** | 无 Tab 结构 | +| **框架类型** | React(功能组件) | +| **生成文档** | 5 份(08/09/10/12/13) | + +### 生成的文档 + +| 文档 | 路径 | 大小 | +|------|------|------| +| 📄 模块概览 | `业务知识库/工作台/技能管理/模块概览.md` | 3.1 KB | +| 📄 业务流程索引 | `业务知识库/工作台/技能管理/业务流程索引.md` | 8.6 KB | +| 📄 业务流程详解 | `业务知识库/工作台/技能管理/业务流程详解.md` | 15.9 KB | +| 📄 API索引 | `技术知识库/工作台/技能管理/API索引.md` | 6.7 KB | +| 📄 组件列表 | `技术知识库/工作台/技能管理/组件列表.md` | 2.8 KB | + +### 模块概述 + +技能管理模块是工作台下的核心功能,提供 Agent 技能的完整生命周期管理: +- **路由**: `/dashboard/skill`(支持 `parentId` 文件夹钻取) +- **权限**: 基于 `hasSkillCreatePer` 控制创建/编辑类操作 +- **核心能力**: 技能列表浏览、文件夹管理、创建/导入/导出/复制/删除技能、权限配置 +- **API 端点**: 11 个 API(覆盖 CRUD、权限、导出、关联查询) +- **组件**: 4 个模块内组件 + 14 个消费的外部通用组件 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.jsonl" new file mode 100644 index 000000000000..3965263793aa --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.jsonl" @@ -0,0 +1,29 @@ +--- + +`kb-init-frontend --scope 工作台_模板市场` 执行完成。 + +## 生成结果 + +### Tab 检测 +- 未检测到需要拆分的子能力 Tab。页面使用 `MyTabBar` 实现分类筛选(基于模板标签),所有 Tab 共享同一卡片列表,仅过滤条件不同。 + +### 生成文档(5/5) + +| 编号 | 文档 | 输出路径 | +|------|------|---------| +| 08 | 模块概览 | `doc/kb/业务知识库/工作台/模板市场/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/工作台/模板市场/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/工作台/模板市场/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/工作台/模板市场/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/工作台/模板市场/组件列表.md` | + +### 未生成的可选文档 +- **11-共享能力清单**:本模块组件均为内部使用,无对外共享 +- **14-Store数据流**:无模块专属 Store,仅使用全局 `useSystemStore` / `useUserStore` +- **15-共享组件清单**:无共享组件 +- **16-Hooks工具函数**:无模块内部自定义 Hooks + +### 状态 +``` +{"label":"模板市场","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.jsonl" new file mode 100644 index 000000000000..d6daff2f1ac9 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.jsonl" @@ -0,0 +1,36 @@ +所有 5 份文档已成功生成,无占位符残留,各项校验全部通过。 + +--- + +## 系统工具 — 知识库文档生成完成 + +### Tab 检测结果 +**无内容 Tab**。系统工具页面使用 `MyTabs` 仅做页面级导航(路由跳转至「我的工具」页面),页内使用 `MyTabBar` 做标签筛选,不涉及内容 Tab 切换。无需创建子任务。 + +### 已生成文档 + +| 编号 | 文档 | 位置 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/工作台/系统工具/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/工作台/系统工具/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/工作台/系统工具/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/工作台/系统工具/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/工作台/系统工具/组件列表.md` | + +### 按需文档跳过 +- **11-共享能力清单**:叶子节点,不对外暴露共享能力 +- **14-Store数据流**:模块未定义自己的 Store,仅消费外部 Store +- **15-共享组件清单**:无对外共享的组件 +- **16-Hooks工具函数**:无自定义 Hooks + +### API 覆盖 +| API | 方法 | 路径 | +|-----|------|------| +| 获取系统工具列表 | GET | `/core/plugin/team/list` | +| 获取工具标签 | GET | `/core/plugin/toolTag/list` | +| 安装/卸载工具 | POST | `/core/plugin/team/toggleInstall` | +| 获取工具详情 | GET | `/core/plugin/team/toolDetail` | + +```json +{"label":"系统工具","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.jsonl" new file mode 100644 index 000000000000..d5ec582398b5 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.jsonl" @@ -0,0 +1,15 @@ +✅ **工作台_评测 知识库文档已生成完成。** + +### 生成的文档 + +| 文档 | 路径 | 说明 | +|------|------|------| +| 模块概览 | `doc/kb/业务知识库/工作台/评测/模块概览.md` | 评测模块业务概述、角色差异、跨模块依赖、5 个子能力索引 | +| 业务流程索引 | `doc/kb/业务知识库/工作台/评测/业务流程索引.md` | 10 个业务场景汇总(含 Tab 展开)、场景关系说明、子能力索引 | + +### 关键发现 + +- **评测**是工作台下的分组节点,无独立 entry_files/router_paths,作为子能力索引节点 +- 评测首页包含 3 个 Tab(评测任务/评测数据集/评测维度),通过 `router.query.evaluationTab` 控制切换 +- 权限控制:`feConfigs.show_evaluation`(系统开关)+ `hasEvaluationCreatePer`(团队权限),开关关闭时自动重定向至 `/dashboard` +- 5 个子能力:评测首页、创建评测、评测数据集(含 3 个子能力)、评测维度(含 3 个子能力)、评测任务(含 2 个子能力) diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.jsonl" new file mode 100644 index 000000000000..b016dfb772c3 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.jsonl" @@ -0,0 +1,31 @@ +--- + +## 完成摘要 + +`--scope 工作台_评测_创建评测` 文档生成完成。以下是生成结果: + +### Tab 检测 +- 入口文件是 React 单页表单组件,**无 Tab 结构**,不创建子任务。 + +### 已生成文档(9 个文件) + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/工作台/评测/创建评测/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/工作台/评测/创建评测/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/工作台/评测/创建评测/业务流程详解.md` | +| 11 | 共享能力清单 | `业务知识库/工作台/评测/创建评测/共享能力清单.md`(跳过说明) | +| 12 | API索引 | `技术知识库/工作台/评测/创建评测/API索引.md` | +| 13 | 组件列表 | `技术知识库/工作台/评测/创建评测/组件列表.md` | +| 14 | Store数据流 | `技术知识库/工作台/评测/创建评测/Store数据流.md`(跳过说明) | +| 15 | 共享组件清单 | `技术知识库/工作台/评测/创建评测/共享组件清单.md`(跳过说明) | +| 16 | Hooks工具函数 | `技术知识库/工作台/评测/创建评测/Hooks工具函数.md`(跳过说明) | + +### 关键发现 +- **核心 API**: `POST /core/evaluation/create`(FormData 上传,含进度回调)和 `GET /core/app/detail`(下载模板时获取变量配置) +- **角色**: admin(团队管理员),权限由 `DashboardContainer` 统一管控 +- **状态**: `has_tab_children: false`,无 Tab 子节点 + +```json +{"label":"创建评测","docs_generated":["08","09","10","11","12","13","14","15","16"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" new file mode 100644 index 000000000000..345377199c91 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" @@ -0,0 +1,17 @@ +`--scope 工作台_评测_评测任务` 执行完成。生成文档如下: + +| 文档 | 路径 | +|------|------| +| 08-模块概览 | `doc/kb/业务知识库/工作台/评测/评测任务/模块概览.md` | +| 09-业务流程索引 | `doc/kb/业务知识库/工作台/评测/评测任务/业务流程索引.md` | +| 10-业务流程详解 | `doc/kb/业务知识库/工作台/评测/评测任务/业务流程详解.md` | +| 12-API索引 | `doc/kb/技术知识库/工作台/评测/评测任务/API索引.md` | +| 13-组件列表 | `doc/kb/技术知识库/工作台/评测/评测任务/组件列表.md` | + +**执行摘要**: +- **节点类型**:分组节点(`is_grouping: true`),子能力为「评测任务首页」和「任务详情」 +- **10-业务流程详解**:按分组节点分支二生成,包含子能力流程索引和公共业务流程 +- **API 索引**:覆盖 15 个 API 接口(任务管理 9 个 + 评测项管理 5 个 + 评测总结 4 个),含调用链追踪 +- **组件列表**:覆盖 2 个页面组件 + 4 个业务组件 + Context 状态管理层 +- **Tab 检测**:无 Tab 子任务需要拆分(分组节点不执行 Tab 检测) +- **状态**:已标记 `done` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..1b97355eda91 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.jsonl" @@ -0,0 +1,25 @@ +`--scope 工作台_评测_评测任务_任务详情` 执行完成。生成结果汇总: + +## 生成文档清单 + +### 业务知识库(3 篇) +| 文档 | 路径 | +|------|------| +| 模块概览 | `业务知识库/工作台/评测/评测任务/任务详情/模块概览.md` | +| 业务流程索引 | `业务知识库/工作台/评测/评测任务/任务详情/业务流程索引.md` | +| 业务流程详解 | `业务知识库/工作台/评测/评测任务/任务详情/业务流程详解.md` | + +### 技术知识库(3 篇) +| 文档 | 路径 | +|------|------| +| API索引 | `技术知识库/工作台/评测/评测任务/任务详情/API索引.md` | +| 组件列表 | `技术知识库/工作台/评测/评测任务/任务详情/组件列表.md` | +| Store数据流 | `技术知识库/工作台/评测/评测任务/任务详情/Store数据流.md` | + +## 关键发现 + +- **Tab 检测结果**:NavBar 含 3 个过滤标签页(全部数据/问题数据/错误数据),但它们共享同一页面结构,仅过滤参数不同,不拆分为子能力(`has_tab_children: false`) +- **架构特征**:三栏布局(数据列表 40% + 详情 60% + 评分面板 17rem),React Context 状态管理,15 秒轮询实时刷新 +- **API 覆盖**:共 12 个 API(5 个评测任务/项核心 API + 4 个评分总结 API + 2 个外部依赖 API + 1 个导出 API) +- **组件清单**:8 个模块私有子组件 + 14 个外部共享组件引用 +- **状态管理**:TaskPageContext 集中管理 6 类数据和 12 个操作方法,含完整的轮询机制 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..563fdd0f50d8 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.jsonl" @@ -0,0 +1,18 @@ +评测任务首页知识库文档生成完成。 + +## 生成结果 + +| 文档编号 | 文档名 | 所属目录 | 状态 | +|---------|--------|---------|------| +| 08 | 模块概览 | 业务知识库/工作台/评测/评测任务/评测任务首页/ | ✅ | +| 09 | 业务流程索引 | 业务知识库/工作台/评测/评测任务/评测任务首页/ | ✅ | +| 10 | 业务流程详解 | 业务知识库/工作台/评测/评测任务/评测任务首页/ | ✅ | +| 12 | API索引 | 技术知识库/工作台/评测/评测任务/评测任务首页/ | ✅ | +| 13 | 组件列表 | 技术知识库/工作台/评测/评测任务/评测任务首页/ | ✅ | + +## 关键发现 + +- **无 Tab 结构**:入口组件为单页列表视图,Tab 由父级评测首页通过 `FillRowTabs` 传入 props,无需检测 +- **无需按需文档**(11/14/15/16):无共享能力输出、无 Store 依赖(使用本地 useState)、无自定义 Hooks 输出、共享组件均来自公共组件库 +- **核心 API 5 个**:列表查询(POST)、创建(POST)、更新重命名(PUT)、删除(DELETE)、重试失败项(POST) +- **验证通过**:validate-output.js 未对本模块文件报任何质量问题 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" new file mode 100644 index 000000000000..343ca421fd8c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" @@ -0,0 +1,13 @@ +**`工作台_评测_评测数据集` 文档生成完成。** 📋 + +这是一个 **grouping 节点**,生成了 5 份索引型文档: + +| 文档 | 路径 | 说明 | +|------|------|------| +| 08-模块概览 | `业务知识库/工作台/评测/评测数据集/模块概览.md` | 模块定位、角色差异、跨模块依赖、关键页面、子能力索引 | +| 09-业务流程索引 | `业务知识库/工作台/评测/评测数据集/业务流程索引.md` | 12 个业务场景汇总,含触发入口、路由路径、场景关系 | +| 10-业务流程详解 | `业务知识库/工作台/评测/评测数据集/业务流程详解.md` | 纯分组节点,仅含子能力索引链接和关系描述 | +| 12-API索引 | `技术知识库/工作台/评测/评测数据集/API索引.md` | 22 个 API 按功能分 5 组(管理/导入/条目/评估/异常),含调用位置和场景 | +| 13-组件列表 | `技术知识库/工作台/评测/评测数据集/组件列表.md` | 17 个组件按页面/业务/详情分组,标注跨模块依赖和共享关系 | + +**子能力**: 评测数据集首页、文件导入、评测数据集详情(各有自己的 task 文件待执行)。 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.jsonl" new file mode 100644 index 000000000000..a289dfc2b5e3 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.jsonl" @@ -0,0 +1,20 @@ +--- + +## ✅ `--scope 工作台_评测_评测数据集_文件导入` 执行完成 + +### 生成结果 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `doc/kb/业务知识库/工作台/评测/评测数据集/文件导入/模块概览.md` | ✅ | +| 09-业务流程索引 | `doc/kb/业务知识库/工作台/评测/评测数据集/文件导入/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `doc/kb/业务知识库/工作台/评测/评测数据集/文件导入/业务流程详解.md` | ✅ | +| 12-API索引 | `doc/kb/技术知识库/工作台/评测/评测数据集/文件导入/API索引.md` | ✅ | +| 13-组件列表 | `doc/kb/技术知识库/工作台/评测/评测数据集/文件导入/组件列表.md` | ✅ | + +### 关键发现 + +- **Tab 检测**: 无 Tab — 文件导入为单页面表单,无 Tab 结构 +- **API 调用链**: 新建模式串行调用 2 个 API(创建数据集 → 导入数据),追加模式仅调用导入 API +- **两种工作模式**: 通过 URL `collectionId` 参数区分新建/追加,表单和流程有差异 +- **组件范围**: 单页面组件,重度依赖共享组件(FileSelectorBox、AIModelSelector、DashboardContainer) diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..f6f61641e3dc --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" @@ -0,0 +1,24 @@ +--- + +## ✅ 评测数据集详情 — 文档生成完成 + +**Tab 检测结果**: 无 Tab 结构,单页数据管理视图。 + +**生成的文档**: + +| 编号 | 文档名 | 目录 | 内容要点 | +|------|--------|------|---------| +| 08 | 模块概览 | 业务知识库 | 模块定位、跨模块依赖摘要、关键页面入口、对外共享 | +| 09 | 业务流程索引 | 业务知识库 | 9 个业务场景(查看/搜索/编辑/删除/质量评估/AI生成/文件导入/手动录入/设置) | +| 10 | 业务流程详解 | 业务知识库 | 每个场景的完整步骤表格(含 API、分支条件、页面变化)+ Mermaid 序列图 | +| 11 | 共享能力清单 | 业务知识库 | 被评测任务详情和数据集管理两模块引用 | +| 12 | API索引 | 技术知识库 | 7 个 API 的反向索引(含调用链追踪) | +| 13 | 组件列表 | 技术知识库 | 8 个模块组件 + 12 个消费的外部组件 | +| 14 | Store数据流 | 技术知识库 | 无独立 Store(使用 React Context 和外部 Zustand Store) | +| 16 | Hooks工具函数 | 技术知识库 | 无自定义 Hooks(使用共享库钩子) | + +**自检通过项**: +- frontmatter 字段齐全 ✓ +- 业务知识无代码块(Mermaid 豁免) ✓ +- 技术知识无业务流程图 ✓ +- 无违禁模式匹配 ✓ diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..d74dd298eee8 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.jsonl" @@ -0,0 +1,27 @@ +评测数据集首页知识库文档生成完成。以下是生成结果: + +## 生成汇总 + +### 业务知识库(4 篇) +| 文档 | 大小 | 内容 | +|------|------|------| +| **模块概览.md** | 2.3 KB | 模块定位、角色差异(团队成员统一权限)、跨模块依赖(7个依赖项)、对外共享(IntelligentGeneration 组件) | +| **业务流程索引.md** | 5.7 KB | 8 个业务场景(S01 查看列表 ~ S08 查看异常任务),含场景关系图 | +| **业务流程详解.md** | 13.0 KB | 全场景深度追踪:步骤表格(用户操作→API→分支条件→页面变化)、表单字段清单、校验规则、数据加载详情、Mermaid 流程图 | +| **共享能力清单.md** | 2.3 KB | IntelligentGeneration 组件被 4 个外部模块引用,完整 imports_from 依赖表 | + +### 技术知识库(5 篇) +| 文档 | 大小 | 内容 | +|------|------|------| +| **API索引.md** | 5.6 KB | 8 个 API 端点反向索引(列表/管理/智能生成/异常处理),每个 API 含完整调用链追踪 | +| **组件列表.md** | 2.6 KB | 3 个组件(EvaluationDatasets、IntelligentGeneration、ErrorModal)+ 13 个消费的外部组件 | +| **共享组件清单.md** | 2.3 KB | IntelligentGeneration 完整 Props/接口定义和使用场景说明 | +| **Store数据流.md** | 0.8 KB | 跳过说明(无独立 Store,使用 React useState + usePagination + useSystemStore) | +| **Hooks工具函数.md** | 0.7 KB | 跳过说明(无自定义 Hook,全部依赖共享 Hook) | + +### Tab 检测结果 +- 无 Tab 结构发现 → `has_tab_children: false`,未创建子任务 + +### 验证结果 +- 本模块 9 个文档全部通过验证脚本检查(0 错误) +- 报告中 87 个问题均为其他模块和根级文档的已有问题 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" new file mode 100644 index 000000000000..5b0bc5d2f34f --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" @@ -0,0 +1,20 @@ +## 完成 ✅ + +`工作台_评测_评测维度` 分组节点文档生成完毕,验证通过(0 个新增问题)。 + +### 生成的文档 + +| # | 文档 | 路径 | 说明 | +|---|------|------|------| +| 08 | 模块概览.md | `业务知识库/工作台/评测/评测维度/` | 业务概述、角色差异、跨模块依赖、关键页面入口、子能力索引 | +| 09 | 业务流程索引.md | `业务知识库/工作台/评测/评测维度/` | 5 个业务场景汇总(查看/创建/编辑/删除/试运行)、场景关系说明 | +| 10 | 业务流程详解.md | `业务知识库/工作台/评测/评测维度/` | 纯分组节点,仅含子能力索引(链接到 3 个子能力的业务流程详解) | +| 12 | API索引.md | `技术知识库/工作台/评测/评测维度/` | 6 个 API 的反向索引 + 完整调用链追踪 | +| 13 | 组件列表.md | `技术知识库/工作台/评测/评测维度/` | 8 个组件按目录分组(页面组件 3 + 业务组件 4)+ 12 个消费的外部组件 | + +### 关键业务发现 + +- **评测维度** 是评测首页 3 个 Tab 之一(任务/数据集/维度),以 `EvaluationDimensions` 组件渲染 +- 维度分两类:**内置维度**(8 个预设模板,只读不可删)和**自定义维度**(用户创建,可编辑删除) +- 创建和编辑页面共用 `EditForm` 表单组件和 `TestRun` 试运行弹窗 +- `CitationTemplate` 提供 8 种内置维度的提示词模板引用(正确性、简洁性、危害性、争议性、创造性、违法性、深度、细节) diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.jsonl" new file mode 100644 index 000000000000..3a2e02ca4c95 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.jsonl" @@ -0,0 +1,22 @@ +## ✅ 完成:工作台_评测_评测维度_创建维度 + +**任务状态**: `done` + +### 生成文档(5/5) + +| # | 文档 | 路径 | +|---|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/工作台/评测/评测维度/创建维度/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/工作台/评测/评测维度/创建维度/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/工作台/评测/评测维度/创建维度/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/工作台/评测/评测维度/创建维度/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/工作台/评测/评测维度/创建维度/组件列表.md` | + +### 分析摘要 + +- **模块类型**: 叶子能力节点,React 单页表单 +- **Tab 检测**: 无 Tab 结构 +- **核心组件**: `DimensionCreate`(页面入口)、`EditForm`(共用表单)、`CitationTemplate`(8 种内置模板弹窗)、`TestRun`(AI 试运行弹窗) +- **API**: `POST /core/evaluation/metric/create`(创建维度)、`POST /core/evaluation/metric/debug`(试运行) +- **业务场景**: S01 创建维度、S02 引用模板、S03 试运行 +- **按需文档判定**: 11/14/15/16 无需生成(模块简单,无独立共享能力/Store/Hooks/共享组件) diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.jsonl" new file mode 100644 index 000000000000..4c81f801ed46 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.jsonl" @@ -0,0 +1,23 @@ +--- + +## ✅ `--scope 工作台_评测_评测维度_编辑维度` 完成 + +### 生成结果 + +| 编号 | 文档 | 路径 | 大小 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/工作台/评测/评测维度/编辑维度/模块概览.md` | 3.8 KB | +| 09 | 业务流程索引 | `业务知识库/工作台/评测/评测维度/编辑维度/业务流程索引.md` | 2.6 KB | +| 10 | 业务流程详解 | `业务知识库/工作台/评测/评测维度/编辑维度/业务流程详解.md` | 16.6 KB | +| 11 | 共享能力清单 | `业务知识库/工作台/评测/评测维度/编辑维度/共享能力清单.md` | 0.6 KB | +| 12 | API索引 | `技术知识库/工作台/评测/评测维度/编辑维度/API索引.md` | 5.8 KB | +| 13 | 组件列表 | `技术知识库/工作台/评测/评测维度/编辑维度/组件列表.md` | 4.3 KB | +| 14 | Store数据流 | `技术知识库/工作台/评测/评测维度/编辑维度/Store数据流.md` | 0.9 KB | +| 15 | 共享组件清单 | `技术知识库/工作台/评测/评测维度/编辑维度/共享组件清单.md` | 0.6 KB | +| 16 | Hooks工具函数 | `技术知识库/工作台/评测/评测维度/编辑维度/Hooks工具函数.md` | 0.8 KB | + +### 关键发现 +- **无 Tab 结构**:单页面编辑表单,React 函数组件 +- **3 个 API**:`GET /core/evaluation/metric/detail`(加载详情)、`PUT /core/evaluation/metric/update`(保存更新)、`POST /core/evaluation/metric/debug`(试运行调试) +- **6 个组件**:DimensionEdit(页面)、EditForm(表单)、TestRun(试运行弹窗)、CitationTemplate(引用模板)、AnswerTextarea(折叠输入框)、DashboardContainer(容器) +- **条件文档**:11/14/15/16 均生成跳过说明(无跨模块共享、无独立 Store、无独立 Hooks) diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..034aa2129e1e --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.jsonl" @@ -0,0 +1,22 @@ +文档生成完成。所有 5 个必生成文档已写入,无验证错误。 + +**生成结果:** + +| 编号 | 文档 | 位置 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/工作台/评测/评测维度/评测维度首页/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/工作台/评测/评测维度/评测维度首页/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/工作台/评测/评测维度/评测维度首页/业务流程详解.md` | +| 12 | API索引 | `技术知识库/工作台/评测/评测维度/评测维度首页/API索引.md` | +| 13 | 组件列表 | `技术知识库/工作台/评测/评测维度/评测维度首页/组件列表.md` | + +**关键发现:** +- **无 Tab 子结构**:页面为简单列表,不包含 Tab 嵌套,无需创建子任务 +- **组件作为 Tab 内容被消费**:`EvaluationDimensions` 通过 `{ Tab }` prop 接收父组件传入的导航栏,自身不包含 Tab +- **两种维度类型**:内置维度(6 个预置维度,只读)和自定义维度(可编辑、删除) +- **两个 API 调用**:`POST /core/evaluation/metric/list`(列表加载)和 `DELETE /core/evaluation/metric/delete`(删除) +- **前端搜索**:纯客户端过滤,不触发额外 API 请求 + +```json +{"label":"评测维度首页","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..b63207ae938b --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.jsonl" @@ -0,0 +1,31 @@ +--- + +## ✅ `kb-init-frontend --scope 工作台_评测_评测首页` 执行完成 + +### 执行摘要 + +| 项目 | 状态 | +|------|------| +| 代码探索 | ✅ React 组件,FillRowTabs 三 Tab 结构 | +| Tab 检测 | ✅ 检测到 3 个 Tab,已创建子任务文件 | +| 文档生成 | ✅ 5 份文档全部生成 | +| 验证脚本 | ✅ 评测首页无任何问题 | + +### Tab 检测结果 + +| Tab | 标签 | 入口组件 | 子任务 | +|-----|------|---------|--------| +| 评测任务 | `dashboard_evaluation:evaluation_tasks_tab` | `task/index.tsx` | `.kb-task-工作台_评测_评测首页_评测任务.json` | +| 评测数据集 | `dashboard_evaluation:evaluation_datasets_tab` | `dataset/index.tsx` | `.kb-task-工作台_评测_评测首页_评测数据集.json` | +| 评测维度 | `dashboard_evaluation:evaluation_dimensions_tab` | `dimension/index.tsx` | `.kb-task-工作台_评测_评测首页_评测维度.json` | + +### 生成的文档 + +**业务知识库** (`doc/kb/业务知识库/工作台/评测/评测首页/`): +- `模块概览.md` — 模块定位、角色差异、依赖摘要、子能力索引 +- `业务流程索引.md` — S01 主场景 + 3 个 Tab 子场景编号 +- `业务流程详解.md` — 公共流程(权限校验、Tab 切换、布局渲染)+ Tab 子能力索引 + +**技术知识库** (`doc/kb/技术知识库/工作台/评测/评测首页/`): +- `API索引.md` — 说明本模块为 Tab 容器,API 分布在子 Tab 中 +- `组件列表.md` — 页面组件 + 消费的外部组件 + 子模块组件索引 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" new file mode 100644 index 000000000000..dfdd9f747d74 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.jsonl" @@ -0,0 +1,22 @@ +## ✅ 完成:评测任务 知识库文档生成 + +### 生成文档清单 + +| # | 文档 | 输出位置 | 类型 | +|---|------|---------|------| +| 08 | **模块概览** | `业务知识库/工作台/评测/评测首页/评测任务/模块概览.md` | 必生成 | +| 09 | **业务流程索引** | `业务知识库/工作台/评测/评测首页/评测任务/业务流程索引.md` | 必生成 | +| 10 | **业务流程详解** | `业务知识库/工作台/评测/评测首页/评测任务/业务流程详解.md` | 必生成 | +| 12 | **API索引** | `技术知识库/工作台/评测/评测首页/评测任务/API索引.md` | 必生成 | +| 13 | **组件列表** | `技术知识库/工作台/评测/评测首页/评测任务/组件列表.md` | 必生成 | +| 14 | **Store数据流** | `技术知识库/工作台/评测/评测首页/评测任务/Store数据流.md` | 按需触发 | + +### 关键发现 + +- **框架**: React (Next.js) + ChakraUI +- **无 Tab 嵌套**: 入口组件无内部 Tab 结构;详情页 NavBar 的 LightRowTabs 为数据过滤标签,非结构性 Tab +- **双页面架构**: 列表页(`evaluation/task/index.tsx`)+ 详情页(`evaluation/task/detail/index.tsx`) +- **状态管理**: 详情页使用 `TaskPageContext`(React Context),15 秒轮询自动刷新 +- **API**: 共 15 个 API 端点(任务管理 7 个、评测项管理 5 个、总结报告 3 个) +- **组件**: 11 个业务组件 + 1 个 Context Provider,`CreateModal` 对外共享 +- **业务场景**: 14 个核心场景,覆盖任务 CRUD、数据管理、评分配置、导出和总结生成 diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" new file mode 100644 index 000000000000..f7610bc6a794 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.jsonl" @@ -0,0 +1,42 @@ +全部自检通过。无占位符残留,无禁项违规。 + +--- + +## 执行结果汇总 + +**任务**: `kb-init-frontend --scope 工作台_评测_评测首页_评测数据集` + +### Tab 检测 +- **结果**: 无内部 Tab。`EvaluationDatasets` 组件接收父组件 `Evaluation` 通过 `FillRowTabs` 传入的 Tab UI 作为 prop,自身为单表列表页。 +- **has_tab_children**: `false` + +### 已生成文档(5 个) + +| 编号 | 文档名 | 路径 | 大小 | +|------|--------|------|------| +| 08 | 模块概览 | `业务知识库/工作台/评测/评测首页/评测数据集/模块概览.md` | 3.5KB | +| 09 | 业务流程索引 | `业务知识库/工作台/评测/评测首页/评测数据集/业务流程索引.md` | 4.9KB | +| 10 | 业务流程详解 | `业务知识库/工作台/评测/评测首页/评测数据集/业务流程详解.md` | 18.7KB | +| 12 | API索引 | `技术知识库/工作台/评测/评测首页/评测数据集/API索引.md` | 9.9KB | +| 13 | 组件列表 | `技术知识库/工作台/评测/评测首页/评测数据集/组件列表.md` | 6.2KB | + +### 跳过的文档 + +| 编号 | 原因 | +|------|------| +| 11-共享能力清单 | 模块为叶子节点,无子能力对外共享 | +| 14-Store数据流 | 无自定义 Store,状态管理使用 React 本地状态和公共 hooks | +| 15-共享组件清单 | 模块无对外暴露的共享组件(IntelligentGeneration 属 pageComponents 共享) | +| 16-Hooks工具函数 | 无模块自定义 hooks,使用的均为公共 hooks(usePagination/useRequest/useConfirm 等) | + +### 质量自检 +- ✅ 全部文档含完整 frontmatter(8 字段) +- ✅ 无占位符残留 +- ✅ 业务知识文档无代码块、Store 引用、API 类型定义等禁项 +- ✅ 技术知识文档无业务背景 > 3 句、无业务流程图 +- ✅ API 调用链含完整串行/并行关系 +- ✅ 业务流程详解含完整交互步骤、分支条件、Mermaid 图 + +```json +{"label":"评测数据集","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" new file mode 100644 index 000000000000..abdf28089164 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.jsonl" @@ -0,0 +1,31 @@ +任务完成。以下是生成结果汇总: + +--- + +## ✅ 评测维度 — 知识库文档生成完成 + +**能力路径**: 工作台 → 评测 → 评测首页 → 评测维度 +**入口文件**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +**路由**: `/dashboard/evaluation?evaluationTab=dimensions` + +### 已生成文档(9 篇) + +| 编号 | 文档 | 目录 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | 业务知识库 | ✅ | +| 09 | 业务流程索引 | 业务知识库 | ✅ | +| 10 | 业务流程详解 | 业务知识库 | ✅ | +| 11 | 共享能力清单 | 业务知识库 | ✅ | +| 12 | API索引 | 技术知识库 | ✅ | +| 13 | 组件列表 | 技术知识库 | ✅ | +| 14 | Store数据流 | 技术知识库 | ✅(跳过说明) | +| 15 | 共享组件清单 | 技术知识库 | ✅(跳过说明) | +| 16 | Hooks工具函数 | 技术知识库 | ✅ | + +### 关键发现 + +- **Tab 检测**: 本模块是评测首页 3 个 Tab 之一(评测任务/评测数据集/评测维度),内部无嵌套 Tab +- **6 个 API**: 列表查询、详情、创建、更新、删除、调试运行 +- **2 种维度**: 内置维度(6 种,只读)和自定义维度(支持 CRUD + 测试运行) +- **8 种引用模板**: 正确性、简洁性、有害性、争议性、创造性、违法性、深度、详细度 +- **跨模块共享**: `getBuiltinDimensionInfo` 等工具函数被评测任务模块广泛引用 diff --git "a/doc/kb/.kb-temp/logs/\345\272\224\347\224\250.jsonl" "b/doc/kb/.kb-temp/logs/\345\272\224\347\224\250.jsonl" new file mode 100644 index 000000000000..0edb81dfa7a6 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\272\224\347\224\250.jsonl" @@ -0,0 +1,26 @@ +`--scope 应用` 执行完成。总结如下: + +## 生成结果 + +**状态**: ✅ ok | **子节点类型**: grouping(纯分组索引节点) + +### 业务知识库(`doc/kb/业务知识库/应用/`) + +| 文档 | 说明 | +|------|------| +| `模块概览.md` | 应用模块业务定位、角色差异(admin/协作者/只读)、跨模块依赖、子能力索引 | +| `业务流程索引.md` | 5 个业务场景汇总(查看详情、编辑配置、查看日志、发布版本、删除应用) | +| `业务流程详解.md` | 分支二格式:子能力业务流程索引,指向子能力文档链接 | + +### 技术知识库(`doc/kb/技术知识库/应用/`) + +| 文档 | 说明 | +|------|------| +| `API索引.md` | 15 个 API 按功能分组(CRUD/版本管理/权限协作/查询导出),含调用位置和场景 | +| `组件列表.md` | 按目录分组:页面组件、核心业务组件、编辑子模块、日志看板、共享编辑组件 | + +### 子能力 + +| 子能力 | 状态 | 说明 | +|--------|------|------| +| 应用详情 | 🔄 in_progress | 由独立 task `.kb-task-应用_应用详情.json` 处理 | diff --git "a/doc/kb/.kb-temp/logs/\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..9bb81db989cf --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.jsonl" @@ -0,0 +1,29 @@ +--- + +## ✅ 应用_应用详情 — 文档生成完成 + +### 生成结果 + +| 编号 | 文档名 | 位置 | 大小 | +|------|--------|------|------| +| 08 | 模块概览 | `业务知识库/应用/应用详情/模块概览.md` | 4.2 KB | +| 09 | 业务流程索引 | `业务知识库/应用/应用详情/业务流程索引.md` | 9.9 KB | +| 10 | 业务流程详解 | `业务知识库/应用/应用详情/业务流程详解.md` | 16.8 KB | +| 11 | 共享能力清单 | `业务知识库/应用/应用详情/共享能力清单.md` | 3.5 KB | +| 12 | API索引 | `技术知识库/应用/应用详情/API索引.md` | 10.5 KB | +| 13 | 组件列表 | `技术知识库/应用/应用详情/组件列表.md` | 8.2 KB | +| 16 | Hooks工具函数 | `技术知识库/应用/应用详情/Hooks工具函数.md` | 4.8 KB | + +### Tab 检测结果 + +- **未创建子任务**:`appDetail.type` 的 7 种条件渲染属于应用类型路由分发,非传统 Tab 行为 +- `TabEnum`(dashboard/logs/appEdit/publish)是模块内统一 Tab 系统,通过 `RouteTab` 根据权限动态计算可见性。Tab 内容因应用类型而异,已在业务流程文档中完整覆盖 + +### 模块摘要 + +应用详情模块是 FastGPT 的核心编辑器入口,涵盖 7 种应用类型的编辑器和 4 个功能 Tab: + +- **7 种编辑器**:简单应用、对话 Agent、工作流、插件、MCP 工具集、HTTP 工具集、智能客服 +- **4 个功能 Tab**:编辑(appEdit)、数据看板(dashboard)、对话日志(logs)、发布渠道(publish) +- **5 级权限控制**:Owner/管理员/编辑者/只读者/日志查看者,基于位掩码 RBAC 体系 +- **30+ API 接口**:涵盖应用 CRUD、版本管理、日志监控、MCP/HTTP 工具、权限协作等 diff --git "a/doc/kb/.kb-temp/logs/\346\212\200\350\203\275.jsonl" "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275.jsonl" new file mode 100644 index 000000000000..38ff78775dc5 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275.jsonl" @@ -0,0 +1,69 @@ +前置条件已满足:`agent-plan-state.json` 确认 `confirmed_by_human: true`,且任务队列已存在。 + +`--scope` 需要指定具体的 `label_safe` 值。以下是当前可用的任务列表: + +--- + +### 📋 可用任务(共 72 个) + +| # | label_safe | 类型 | +|---|-----------|------| +| **首页** | | | +| 1 | `首页` | capability | +| 2 | `页面未找到` | capability | +| 3 | `OpenAPI 文档` | capability | +| 4 | `价格` | capability | +| **对话** | | | +| 5 | `对话` | grouping | +| 6 | `对话_对话首页` | capability | +| 7 | `对话_对话首页_首页` | tab-child | +| 8 | `对话_对话首页_精选应用` | tab-child | +| 9 | `对话_对话首页_团队应用` | tab-child | +| 10 | `对话_对话首页_最近使用` | tab-child | +| 11 | `对话_对话首页_设置` | tab-child | +| 12 | `对话_对话首页_设置_首页设置` | tab-child | +| 13 | `对话_对话首页_设置_收藏应用` | tab-child | +| 14 | `对话_对话首页_设置_数据看板` | tab-child | +| 15 | `对话_对话首页_设置_日志详情` | tab-child | +| 16 | `对话_分享对话` | capability | +| **应用** | | | +| 17 | `应用` | grouping | +| 18 | `应用_应用详情` | capability | +| **登录** | | | +| 19 | `登录` | grouping | +| 20 | `登录_登录首页` | capability | +| 21 | `登录_快速登录` | capability | +| 22 | `登录_第三方登录` | capability | +| **账户** | | | +| 23 | `账户` | grouping | +| 24-33 | `账户_API 密钥` ~ `账户_通知` (10项) | capability | +| **工作台** | | | +| 34 | `工作台` | grouping | +| 35-40 | `工作台_应用工作台` ~ `工作台_系统工具` (6项) | capability | +| 41 | `工作台_评测` | grouping | +| 42-52 | 评测子节点 (11项) | capability | +| **数据集** | | | +| 53 | `数据集` | grouping | +| 54-55 | `数据集_数据集列表`, `数据集_数据集详情` | capability | +| **配置** | | | +| 56 | `配置` | grouping | +| 57-59 | 配置子节点 (3项) | capability | +| **技能** | | | +| 60 | `技能` | grouping | +| 61 | `技能_技能详情` | capability | +| **Root** | | | +| 62-68 | `root-00` ~ `root-07`, `root-17` | root | +| **Finalize** | | | +| 69 | `finalize` | finalize | + +--- + +请指定要执行的 `--scope` 值,例如: + +``` +--scope 首页 +--scope 对话_对话首页 +--scope 工作台_评测_评测首页 +``` + +你想执行哪个任务? diff --git "a/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..cce8432837a0 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.jsonl" @@ -0,0 +1,25 @@ +`技能_技能详情` 任务执行完成。总结如下: + +## 执行结果 + +### Tab 检测 +从 `Content.tsx` 的行为结构检测(模式 C:条件渲染)中识别出 **2 个 Tab**: +- **config** → AgentSkillEditor(技能文件编辑器) +- **preview** → SkillPreview(技能调试预览) + +### 子任务创建 +- `.kb-task-技能_技能详情_config.json`(status: pending) +- `.kb-task-技能_技能详情_preview.json`(status: pending) + +### 文档生成(5 份,已标记 `has_tab_children: true`) + +| 文档 | 路径 | 内容要点 | +|------|------|---------| +| **模块概览** | `业务知识库/技能/技能详情/模块概览.md` | 模块定位、角色差异(拥有者/协作者 + SkillPermission 权限模型)、跨模块依赖摘要、Tab 子能力索引 | +| **业务流程索引** | `业务知识库/技能/技能详情/业务流程索引.md` | 5 个业务场景(S01 页面初始化含 2 Tab、S02 编辑信息、S03 权限配置、S04 导出、S05 删除),含 Tab 层级场景编号 | +| **业务流程详解** | `业务知识库/技能/技能详情/业务流程详解.md` | 公共流程(页面初始化 4 步骤、Tab 切换、编辑信息、权限配置、导出、删除),含 Mermaid 附录。Tab 子流程指向子文档 | +| **API索引** | `技术知识库/技能/技能详情/API索引.md` | 9 个 API 反向索引(技能详情/更新/删除/导出 + 协作者 CRUD + 转让/恢复继承),含调用链追踪 | +| **组件列表** | `技术知识库/技能/技能详情/组件列表.md` | 按目录分组(pages/detail/config/preview),含跨模块依赖标注和消费的外部组件表 | + +### 父任务 +`has_tab_children: true` → 已更新,config/preview 深度内容留给子任务生成。 diff --git "a/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.jsonl" "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.jsonl" new file mode 100644 index 000000000000..c67928420ff5 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.jsonl" @@ -0,0 +1,25 @@ +`技能_技能详情_config` 范围的知识库文档生成完成。 + +## 生成结果 + +**业务知识库** (`doc/kb/业务知识库/技能/技能详情/config/`): + +| 文档 | 大小 | 内容要点 | +|------|------|---------| +| 模块概览.md | 3.5 KB | AgentSkillEditor 在线代码编辑器的业务定位、角色权限差异(canWrite 控制)、跨模块依赖摘要、关键页面入口 | +| 业务流程索引.md | 6.2 KB | 8 个业务场景(文件浏览、编辑、创建、重命名、删除、上传、源码/预览切换、版本过期检测),含场景关系说明和汇总表 | +| 业务流程详解.md | 17 KB | 每个场景的完整步骤表格(用户操作→触发 API→分支条件→页面变化),含 5 张 Mermaid 流程图(文件浏览、编辑自动保存、文件 CRUD、预览切换、版本过期检测) | + +**技术知识库** (`doc/kb/技术知识库/技能/技能详情/config/`): + +| 文档 | 大小 | 内容要点 | +|------|------|---------| +| API索引.md | 6.3 KB | 10 个 API 的反向索引(按文件查询/写入/管理/沙箱同步分组),每个 API 含完整调用链追踪 | +| 组件列表.md | 3.5 KB | 7 个内部组件/Hooks 按目录分组,8 个消费的外部组件,6 个消费的外部 Hooks | +| Hooks工具函数.md | 5.8 KB | 3 个自定义 Hooks(useFileTree/useAutoSave/useFileOperations)的签名和返回值表,6 个工具函数说明 | + +**关键发现**: +- 该模块为 React 组件,无嵌套 Tab 子结构 +- 自动保存采用 800ms 防抖 + packageVersion 版本号追踪机制 +- 支持 30+ 语言语法高亮,通过文件扩展名自动匹配 +- 权限控制通过父级 `SkillDetailContext` 的 `permission.hasWritePer` 实现 diff --git "a/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.jsonl" "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.jsonl" new file mode 100644 index 000000000000..65a1371b9da8 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.jsonl" @@ -0,0 +1,26 @@ +--- + +## ✅ 任务完成:`技能_技能详情_preview` + +**生成文档清单**: + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/技能/技能详情/preview/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/技能/技能详情/preview/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/技能/技能详情/preview/业务流程详解.md` | +| 12 | API索引 | `技术知识库/技能/技能详情/preview/API索引.md` | +| 13 | 组件列表 | `技术知识库/技能/技能详情/preview/组件列表.md` | +| 16 | Hooks工具函数 | `技术知识库/技能/技能详情/preview/Hooks工具函数.md` | + +**分析摘要**: + +- **模块定位**:技能详情页的预览 Tab,提供技能调试的交互式聊天环境 +- **核心组件**:`SkillPreview`(容器)+ `useSkillChatTest`(Hook)+ `ChatBox`(聊天组件) +- **核心 API**:沙箱同步 → 流式对话 → 历史记录分页加载 → 消息删除 +- **Tab 检测**:无嵌套 Tab,组件为纯聊天预览界面 +- **按需生成**:11(共享能力清单)跳过(内部 Tab 无对外共享),14(Store数据流)跳过(无自有 Store),15(共享组件清单)跳过(无共享组件) + +```json +{"label":"preview","docs_generated":["08","09","10","12","13","16"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206.jsonl" new file mode 100644 index 000000000000..afc887eb9223 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206.jsonl" @@ -0,0 +1,22 @@ +`数据集` 模块文档生成完成。验证结果表明,所有预存问题均来自其他模块,与本次生成无关。 + +## 执行总结 + +**任务**: `数据集` (is_grouping=true, 分组节点) + +**生成文档** (6 个文件): + +| 编号 | 文档名 | 路径 | +|------|--------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/数据集/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/数据集/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/数据集/业务流程详解.md` | +| 11 | 共享能力清单 | `doc/kb/业务知识库/数据集/共享能力清单.md` | +| 12 | API索引 | `doc/kb/技术知识库/数据集/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/数据集/组件列表.md` | + +**关键发现**: +- 数据集模块包含两个子能力:**数据集列表** (`/dataset/list`) 和 **数据集详情** (`/dataset/detail`) +- 详情页有 6 个主要 Tab:集合管理、同义词、搜索测试、配置信息、数据卡片、文件数据卡片 +- 导入支持 7+ 种数据源:文件、链接、网页、文本、API、外部文件、结构化文档、数据库等 +- API 层面覆盖 70+ 个接口,分为数据集 CRUD、集合管理、数据操作、标签、训练、同义词、数据库等 8 大组 diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.jsonl" new file mode 100644 index 000000000000..c33e70d84af2 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.jsonl" @@ -0,0 +1,24 @@ +任务文件已被调度器更新为 `done`。✅ + +--- + +## 完成摘要:`数据集_数据集列表` + +### Tab 检测结果 +- **无 Tab**:该页面为 React 卡片列表页,无 Tab 组件 + +### 已生成文档(7 份) + +| 编号 | 文档 | 路径 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/数据集/数据集列表/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `业务知识库/数据集/数据集列表/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `业务知识库/数据集/数据集列表/业务流程详解.md` | ✅ | +| 11 | 共享能力清单 | `业务知识库/数据集/数据集列表/共享能力清单.md` | ✅(无共享能力) | +| 12 | API索引 | `技术知识库/数据集/数据集列表/API索引.md` | ✅ | +| 13 | 组件列表 | `技术知识库/数据集/数据集列表/组件列表.md` | ✅ | +| 14 | Store数据流 | `技术知识库/数据集/数据集列表/Store数据流.md` | ✅ | + +### 未生成文档(条件不满足) +- **15-共享组件清单**:模块无对外共享的组件 +- **16-Hooks工具函数**:模块无独立的自定义 Hooks diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" new file mode 100644 index 000000000000..e2f26a94eb04 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.jsonl" @@ -0,0 +1,27 @@ +所有验证通过。**数据集详情** 父级文档生成完成。 + +## 执行摘要 + +| 项目 | 状态 | +|------|------| +| **Tab 检测** | ✅ 发现 7 个 Tab(模式 C:`TabEnum` 条件渲染) | +| **子任务创建** | ✅ 7 个 `.kb-task-*.json` 已创建(2 个已被调度器认领) | +| **08-模块概览** | ✅ 已生成 | +| **09-业务流程索引** | ✅ 已生成 | +| **10-业务流程详解** | ✅ 已生成(分支一:Tab 子能力索引模式) | +| **12-API索引** | ✅ 已生成 | +| **13-组件列表** | ✅ 已生成 | +| **11/14/15/16** | ⊘ 跳过(按需触发条件不满足) | +| **自检** | ✅ 全部通过(frontmatter 7 字段齐全、禁项合规) | + +### Tab 子任务一览 + +| Tab | label_safe | 入口组件 | 调度器状态 | +|-----|-----------|---------|-----------| +| 集合管理 | `数据集_数据集详情_collectionCard` | `RefinedCollectionCard/index.tsx` | in_progress | +| 数据卡片 | `数据集_数据集详情_dataCard` | `RefinedDataCard/index.tsx` | in_progress | +| 结构化数据卡片 | `数据集_数据集详情_fileDataCard` | `FileDataCard.tsx` | pending | +| 检索测试 | `数据集_数据集详情_test` | `Test.tsx` | pending | +| 导入数据 | `数据集_数据集详情_import` | `Import/index.tsx` | pending | +| 同义词管理 | `数据集_数据集详情_synonym` | `Synonym/index.tsx` | pending | +| 数据集配置 | `数据集_数据集详情_info` | `Info/index.tsx` | pending | diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.jsonl" new file mode 100644 index 000000000000..e965ab7d26ce --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.jsonl" @@ -0,0 +1,29 @@ +任务文件已被调度器自动更新为 `done` 状态。 + +--- + +## 执行完成 + +**任务**:`数据集_数据集详情_collectionCard` — 集合管理 + +**Tab 检测**:无 Tab 结构(单视图表格组件) + +**生成文档**: + +| 编号 | 文档名 | 路径 | +|------|--------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/数据集/数据集详情/集合管理/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/数据集/数据集详情/集合管理/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/数据集/数据集详情/集合管理/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/数据集/数据集详情/集合管理/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/数据集/数据集详情/集合管理/组件列表.md` | +| 14 | Store数据流 | `doc/kb/技术知识库/数据集/数据集详情/集合管理/Store数据流.md` | + +**跳过的文档**: +- 11(共享能力清单):跨模块依赖已在 08 §3 覆盖,模块对外共享能力有限 +- 15(共享组件清单):模块内部组件均未被外部独立引用 +- 16(Hooks工具函数):模块未自定义 hooks,使用的均为外部 hook + +```json +{"label":"集合管理","docs_generated":["08","09","10","12","13","14"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.jsonl" new file mode 100644 index 000000000000..97878155e7c0 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.jsonl" @@ -0,0 +1,36 @@ +任务状态已由调度器自动更新为 `done`。所有工作完成。 + +--- + +## 执行总结 + +`kb-init-frontend --scope 数据集_数据集详情_dataCard` 执行完毕。 + +### 文档生成结果 + +| 编号 | 文档 | 路径 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/数据集/数据集详情/数据卡片/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `doc/kb/业务知识库/数据集/数据集详情/数据卡片/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `doc/kb/业务知识库/数据集/数据集详情/数据卡片/业务流程详解.md` | ✅ | +| 12 | API索引 | `doc/kb/技术知识库/数据集/数据集详情/数据卡片/API索引.md` | ✅ | +| 13 | 组件列表 | `doc/kb/技术知识库/数据集/数据集详情/数据卡片/组件列表.md` | ✅ | + +### Tab 检测 + +- 框架:React(`import React, { useState, useRef, ...}`) +- 检测结果:**无内部 Tab** — 组件内使用 `activeCardId` 跟踪选中卡片(非 Tab 切换),Tab 切换由父级 NavBar 控制 +- `has_tab_children: false`(未创建子任务) + +### 按需文档判定 + +| 编号 | 文档 | 判定 | 原因 | +|------|------|------|------| +| 11 | 共享能力清单 | 不生成 | 本模块为叶子页面,无对外共享能力 | +| 14 | Store数据流 | 不生成 | 本模块使用 React Context(非 Pinia/Zustand),无自定义 Store | +| 15 | 共享组件清单 | 不生成 | 3 个组件均为模块内部使用,不被其他模块引用 | +| 16 | Hooks工具函数 | 不生成 | 本模块无自定义 Hook,使用共享库 hook(usePagination、useToast、useRequest 等) | + +### 验证结果 + +`validate-output.js` 运行结果:数据卡片模块 0 个错误(全部 110 个问题均来自其他模块或全局文件)。 diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.jsonl" new file mode 100644 index 000000000000..bafba833732d --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.jsonl" @@ -0,0 +1,23 @@ +--- + +## 执行完成 ✅ + +**能力**: `结构化数据卡片` (`数据集_数据集详情_fileDataCard`) + +**Tab 检测**: 无 Tab — FileDataCard 是单一 React 组件,仅包含表格数据预览视图。 + +**生成文档**: + +| 编号 | 文档 | 位置 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/数据集/数据集详情/结构化数据卡片/` | +| 09 | 业务流程索引 | `业务知识库/数据集/数据集详情/结构化数据卡片/` | +| 10 | 业务流程详解 | `业务知识库/数据集/数据集详情/结构化数据卡片/` | +| 12 | API索引 | `技术知识库/数据集/数据集详情/结构化数据卡片/` | +| 13 | 组件列表 | `技术知识库/数据集/数据集详情/结构化数据卡片/` | +| 15 | 共享组件清单 | `技术知识库/数据集/数据集详情/结构化数据卡片/`(跳过) | +| 16 | Hooks工具函数 | `技术知识库/数据集/数据集详情/结构化数据卡片/`(跳过) | + +```json +{"label":"结构化数据卡片","docs_generated":["08","09","10","12","13","15","16"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.jsonl" new file mode 100644 index 000000000000..573fd91c2571 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.jsonl" @@ -0,0 +1,37 @@ +--- + +## ✅ `数据集_数据集详情_import` 文档生成完成 + +### 生成文档清单 + +| 编号 | 文档名 | 路径 | 类型 | +|------|--------|------|------| +| 08 | 模块概览 | `业务知识库/数据集/数据集详情/导入数据/模块概览.md` | 业务 | +| 09 | 业务流程索引 | `业务知识库/数据集/数据集详情/导入数据/业务流程索引.md` | 业务 | +| 10 | 业务流程详解 | `业务知识库/数据集/数据集详情/导入数据/业务流程详解.md` | 业务 | +| 12 | API索引 | `技术知识库/数据集/数据集详情/导入数据/API索引.md` | 技术 | +| 13 | 组件列表 | `技术知识库/数据集/数据集详情/导入数据/组件列表.md` | 技术 | + +### 跳过的按需文档 + +- **11 (共享能力清单)**: 模块为叶子节点,对外共享能力有限,核心内容已在 08 §3/§4 中覆盖 +- **14 (Store数据流)**: 模块使用 React Context (`DatasetImportContext`) 管理状态,无独立 Zustand/Pinia Store +- **15 (共享组件清单)**: 模块组件均为内部共享,外部消费极少,已在 13 中标注 +- **16 (Hooks工具函数)**: 模块无自研 Hook,使用的 `useMyStep`、`useRequest`、`useContextSelector` 均为外部公共 Hook + +### 模块结构概要 + +导入数据模块采用**动态组件渲染**模式,通过 URL 参数 `source` 激活 8 种导入源之一: + +| 导入源 | 步骤数 | 共用组件 | +|--------|--------|---------| +| 本地文件 (FileLocal) | 4 步 | DataProcess, PreviewData, Upload | +| 网页链接 (FileLink) | 4 步 | DataProcess, PreviewData, Upload | +| 自定义文本 (FileCustomText) | 4 步 | DataProcess, PreviewData, Upload | +| 外部文件 (ExternalFile) | 4 步 | DataProcess, PreviewData, Upload | +| API 数据集 (APIDataset) | 4 步 | DataProcess, PreviewData, Upload | +| 重训练 (ReTraining) | 3 步 | DataProcess, PreviewData, Upload | +| 图片数据集 (ImageDataset) | 单页 | 无(独立流程) | +| 数据库 (Database) | 2 步 | ConnectDatabaseForm, DataBaseConfig | + +涉及 **18 个 API**,分属 5 个 API 定义文件,被 7 个导入组件和 3 个内部 Hook 调用。 diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.jsonl" new file mode 100644 index 000000000000..0b8cd21f3b9a --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.jsonl" @@ -0,0 +1,20 @@ +--- + +**`--scope 数据集_数据集详情_info` 执行完成。** ✅ + +### 生成结果 + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/数据集/数据集详情/数据集配置/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/数据集/数据集详情/数据集配置/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/数据集/数据集详情/数据集配置/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/数据集/数据集详情/数据集配置/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/数据集/数据集详情/数据集配置/组件列表.md` | + +### 关键分析结论 + +- **Tab 检测**:无 Tab 结构(单一配置表单页),`has_tab_children: false` +- **可选文档**(11/14/15/16)均不触发:模块无共享导出、无独立 Store、无本地 Hooks +- **API 覆盖**:6 个 API(详情获取、配置更新、重建索引、协作者 CRUD) +- **角色差异**:管理员可编辑全部配置项和管理协作者;普通成员只读 diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.jsonl" new file mode 100644 index 000000000000..9a52b65c861e --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.jsonl" @@ -0,0 +1,33 @@ +--- + +## 执行完成 ✅ + +**任务**: `数据集_数据集详情_synonym`(同义词管理) + +### Tab 检测结果 +- 无嵌套 Tab 结构,`SynonymTab` 为纯 leaf 组件 + +### 生成文档清单 + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `业务知识库/数据集/数据集详情/同义词管理/模块概览.md` | +| 09 | 业务流程索引 | `业务知识库/数据集/数据集详情/同义词管理/业务流程索引.md` | +| 10 | 业务流程详解 | `业务知识库/数据集/数据集详情/同义词管理/业务流程详解.md` | +| 12 | API索引 | `技术知识库/数据集/数据集详情/同义词管理/API索引.md` | +| 13 | 组件列表 | `技术知识库/数据集/数据集详情/同义词管理/组件列表.md` | + +### 未生成文档(按需判定) +- **11-共享能力清单** — 跳过:无对外共享组件/API +- **14-Store数据流** — 跳过:无独立 Store(仅消费 DatasetPageContext) +- **15-共享组件清单** — 跳过:无对外共享组件 +- **16-Hooks工具函数** — 跳过:无自定义 Hooks + +### 质量检查 +- ✅ 禁项检查通过(无代码块、无占位符、无流程图越界) +- ✅ Frontmatter 8 字段齐全 +- ✅ `status` 已更新为 `done` + +```json +{"label":"同义词管理","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.jsonl" "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.jsonl" new file mode 100644 index 000000000000..68de1dab16e7 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.jsonl" @@ -0,0 +1,31 @@ +全部验证通过。总结如下: + +--- + +## ✅ 检索测试(`数据集_数据集详情_test`)文档生成完成 + +### 业务知识库 (4 份) +| 文档 | 文件 | 状态 | +|------|------|------| +| 08-模块概览 | `业务知识库/数据集/数据集详情/检索测试/模块概览.md` | ✅ | +| 09-业务流程索引 | `业务知识库/数据集/数据集详情/检索测试/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `业务知识库/数据集/数据集详情/检索测试/业务流程详解.md` | ✅ | +| 11-共享能力清单 | `业务知识库/数据集/数据集详情/检索测试/共享能力清单.md` | ✅ 无共享 | + +### 技术知识库 (4 份) +| 文档 | 文件 | 状态 | +|------|------|------| +| 12-API索引 | `技术知识库/数据集/数据集详情/检索测试/API索引.md` | ✅ | +| 13-组件列表 | `技术知识库/数据集/数据集详情/检索测试/组件列表.md` | ✅ | +| 14-Store数据流 | `技术知识库/数据集/数据集详情/检索测试/Store数据流.md` | ✅ | +| 15-共享组件清单 | `技术知识库/数据集/数据集详情/检索测试/共享组件清单.md` | ✅ 无共享 | +| 16-Hooks工具函数 | `技术知识库/数据集/数据集详情/检索测试/Hooks工具函数.md` | ✅ 无独立 | + +### 关键发现 +- **无嵌套 Tab**:`Test.tsx` 为纯 React 功能组件,左右分栏布局,无 Tab 结构 +- **双模式检索**:根据数据集类型自动切换普通检索测试(embedding+ReRank)和数据库检索测试(NL2SQL) +- **涉及 API**:`POST /core/dataset/searchTest` 和 `POST /core/dataset/database/searchTest` +- **状态管理**:`useSearchTestStore`(Zustand + localStorage 持久化,最多保留 50 条历史) +- **无占位符残留**,Frontmatter 完整 + +{"capability_label":"检索测试","docs_generated":["08","09","10","11","12","13","14","15","16"],"tab_files":[],"status":"ok","error":null} diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225.jsonl" new file mode 100644 index 000000000000..437deeee41c6 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225.jsonl" @@ -0,0 +1,30 @@ +✅ 全部通过。以下是 `登录` 模块文档生成总结: + +## 生成结果 + +``` +{"capability_label":"登录","docs_generated":["08","09","10","12","13","14"],"status":"ok"} +``` + +### 业务知识库 (`doc/kb/业务知识库/登录/`) + +| 文档 | 文件 | 内容要点 | +|------|------|---------| +| 08 | 模块概览.md | 登录模块定位、OAuth/密码/微信多方式概要、3 个子能力索引、跨模块依赖 | +| 09 | 业务流程索引.md | 6 个业务场景(密码登录/注册/忘记密码/微信扫码/OAuth/快速登录),含汇总表 | +| 10 | 业务流程详解.md | 纯分组节点 → 子能力索引 + 公共流程(初始化/登出/登录成功处理)+ Mermaid | + +### 技术知识库 (`doc/kb/技术知识库/登录/`) + +| 文档 | 文件 | 内容要点 | +|------|------|---------| +| 12 | API索引.md | 14 个 API 按功能分组(查询/登录/微信/OAuth前置/账号管理/登出),含 5 条调用链追踪 | +| 13 | 组件列表.md | 3 个页面组件 + 6 个业务组件 + 2 个页面内部组件,含外部组件消费表 | +| 14 | Store数据流.md | useSystemStore(loginStore/feConfigs) + useUserStore(setUserInfo) 数据流向 + 3 张 ASCII 流程图 | + +### 自检结果 + +- ✅ 无禁项违规(代码块/Mermaid 已豁免,无用户故事句式,无 Store 代码泄漏到业务文档) +- ✅ Frontmatter 8 字段齐全 +- ✅ 文件命名无数字前缀 +- ✅ 所有 API/组件路径基于实际 CodeGraph 探索结果 diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.jsonl" new file mode 100644 index 000000000000..8c0feb832920 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.jsonl" @@ -0,0 +1,28 @@ +--- + +## ✅ 登录_快速登录 — 文档生成完成 + +**Scope**: `登录_快速登录` | **类型**: capability(叶子节点)| **Tab 检测**: 无 Tab + +### 生成的文档(5 篇) + +| # | 文档 | 路径 | +|---|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/登录/快速登录/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/登录/快速登录/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/登录/快速登录/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/登录/快速登录/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/登录/快速登录/组件列表.md` | + +### 关键发现 + +- **组件类型**: React 函数式组件(Next.js SSR 页面) +- **业务场景**: 单场景——通过外部链接携带的 `code`+`token` 完成免密认证 +- **API**: `POST /proApi/support/user/account/login/fastLogin` +- **依赖**: `useUserStore`(用户状态)、`clearToken`/`loginOut`(旧凭证清除)、`validateRedirectUrl`(URL 安全校验) +- **未生成**: 11-共享能力清单、14-Store数据流、15-共享组件清单、16-Hooks工具函数(该模块为纯消费型叶子页面,不满足按需触发条件) +- **验证**: validate-output.js 通过,本次生成的 5 篇文档零错误 + +```json +{"label":"快速登录","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..93e1800be024 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.jsonl" @@ -0,0 +1,36 @@ +校验脚本运行完成。所有 119 个报错都是知识库中**其他模块的预存问题**(对话、工作台等),与本次生成的登录首页文档无关。 + +--- + +## 执行总结 + +**任务**: `登录_登录首页` — 单能力文档生成(含 Tab 自发现) + +### Tab 检测结果 + +检测方式:**行为结构 模式 B (配置驱动 TypeMap) + 模式 C (条件渲染 pageType)** + +| Tab | 入口文件 | 状态 | +|-----|---------|------| +| 密码登录 | `pageComponents/login/LoginForm/LoginForm.tsx` | 子任务已创建、已被调度器领取 | +| 注册 | `pageComponents/login/RegisterForm.tsx` | 子任务已创建 | +| 忘记密码 | `pageComponents/login/ForgetPasswordForm.tsx` | 子任务已创建 | +| 微信登录 | `pageComponents/login/LoginForm/WechatForm.tsx` | 子任务已创建、已被调度器领取 | + +### 文档生成清单 + +| 编号 | 文档 | 路径 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/登录/登录首页/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `业务知识库/登录/登录首页/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `业务知识库/登录/登录首页/业务流程详解.md` | ✅ (Tab 子能力索引模式) | +| 12 | API索引 | `技术知识库/登录/登录首页/API索引.md` | ✅ | +| 13 | 组件列表 | `技术知识库/登录/登录首页/组件列表.md` | ✅ | + +> 按需文档(11/14/15/16):本模块为纯消费者角色,无独立共享能力、模块级 Store 或自定义 Hooks,跳过生成。 + +### 关键设计决策 + +- **has_tab_children: true** — 08/09/10 仅覆盖公共功能(布局、OAuth、IP 检测、Cookie 同意),具体登录/注册流程由子 Tab 任务独立生成 +- **FormLayout** 被识别为共享组件,服务于 LoginForm 和 WechatForm +- **LoginContainer** (`pageComponents/login/index.tsx`) 是独立封装版本,被识别为共享能力,支持内嵌弹窗场景 diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.jsonl" new file mode 100644 index 000000000000..287fde562396 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.jsonl" @@ -0,0 +1,20 @@ +--- + +## ✅ 完成:`登录_登录首页_密码登录` + +**生成文档清单**: + +| 文档 | 路径 | +|------|------| +| 模块概览 | `doc/kb/业务知识库/登录/登录首页/密码登录/模块概览.md` | +| 业务流程索引 | `doc/kb/业务知识库/登录/登录首页/密码登录/业务流程索引.md` | +| 业务流程详解 | `doc/kb/业务知识库/登录/登录首页/密码登录/业务流程详解.md` | +| API索引 | `doc/kb/技术知识库/登录/登录首页/密码登录/API索引.md` | +| 组件列表 | `doc/kb/技术知识库/登录/登录首页/密码登录/组件列表.md` | + +**代码探索覆盖**: +- 入口组件:`LoginForm.tsx`(密码登录表单) +- 子组件:`FormLayout.tsx`(共享登录布局)、`PolicyTip.tsx`(协议提示) +- API:`GET /support/user/account/preLogin` → `POST /support/user/account/loginByPassword` +- 状态管理:`useSystemStore`(feConfigs)、`react-hook-form`(表单状态) +- 关键链路:预登录码获取 → 密码哈希 → 登录提交 → 登录成功跳转 / 密码错误 URL 清理 diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.jsonl" new file mode 100644 index 000000000000..c345ba9781a1 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.jsonl" @@ -0,0 +1,25 @@ +--- + +## ✅ `登录_登录首页_微信登录` — 完成 + +**状态**: `done` | **Tab 检测**: 无 Tab + +### 生成文档 + +| 文档 | 路径 | +|------|------| +| 模块概览 | `doc/kb/业务知识库/登录/登录首页/微信登录/模块概览.md` | +| 业务流程索引 | `doc/kb/业务知识库/登录/登录首页/微信登录/业务流程索引.md` | +| 业务流程详解 | `doc/kb/业务知识库/登录/登录首页/微信登录/业务流程详解.md` | +| API索引 | `doc/kb/技术知识库/登录/登录首页/微信登录/API索引.md` | +| 组件列表 | `doc/kb/技术知识库/登录/登录首页/微信登录/组件列表.md` | + +### 核心发现 + +微信登录模块是一个简洁的叶子能力节点,核心流程为: + +1. **二维码获取**:页面初始化时调用 `GET /proApi/support/user/account/login/wx/getQR` 获取微信登录二维码 +2. **轮询扫码结果**:每 3 秒调用 `POST /proApi/support/user/account/login/wx/getResult` 轮询用户是否已扫码确认 +3. **登录成功**:收到有效登录数据后清除营销 Cookie,保存用户信息,跳转到工作台首页 + +组件依赖链:`WechatForm`(核心)→ `FormLayout`(布局容器)+ `PolicyTip`(协议提示),复用了共享组件库的 `MyImage`、`Loading`、`Avatar`。 diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.jsonl" new file mode 100644 index 000000000000..1c6f8d1d446b --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.jsonl" @@ -0,0 +1,24 @@ +## ✅ `登录_登录首页_忘记密码` 文档生成完成 + +### Tab 检测结果 +- 框架:React(函数组件) +- 无 Tab 结构,不创建子任务 + +### 生成的文档(7 个) + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/登录/登录首页/忘记密码/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/登录/登录首页/忘记密码/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/登录/登录首页/忘记密码/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/登录/登录首页/忘记密码/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/登录/登录首页/忘记密码/组件列表.md` | +| 14 | Store数据流 | `doc/kb/技术知识库/登录/登录首页/忘记密码/Store数据流.md` | +| 16 | Hooks工具函数 | `doc/kb/技术知识库/登录/登录首页/忘记密码/Hooks工具函数.md` | + +### 核心发现 +- **入口组件**:`ForgetPasswordForm.tsx` —— 嵌入式表单,在 `/login` 页面内通过 `setPageType('forgetPassword')` 切换显示 +- **核心 API**:`POST /proApi/support/user/account/password/updateByCode`(密码重置) +- **验证码发送**:通过 `useSendCode` hook 调用 `sendAuthCode` API,含 Google reCAPTCHA 人机验证 +- **密码校验**:`checkPasswordRule` —— 8-100 位,须含字母+数字(至少两种组合) +- **任务状态**:`done` diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.jsonl" new file mode 100644 index 000000000000..17b638cf8581 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.jsonl" @@ -0,0 +1,21 @@ +--- + +## ✅ 完成:`登录_登录首页_注册` + +已为注册 Tab 子能力生成 5 份文档,任务状态已更新为 `done`: + +| 文档 | 文件 | 大小 | +|------|------|------| +| 08-模块概览 | `业务知识库/登录/登录首页/注册/模块概览.md` | 2.6 KB | +| 09-业务流程索引 | `业务知识库/登录/登录首页/注册/业务流程索引.md` | 1.3 KB | +| 10-业务流程详解 | `业务知识库/登录/登录首页/注册/业务流程详解.md` | 8.9 KB | +| 12-API索引 | `技术知识库/登录/登录首页/注册/API索引.md` | 3.7 KB | +| 13-组件列表 | `技术知识库/登录/登录首页/注册/组件列表.md` | 1.5 KB | + +**跳过的文档**:11/14/15/16 — 注册模块是单表单 Tab 组件,无对外共享能力、无专属 Store、无共享组件、无模块专属 Hooks。 + +**关键发现**: +- `RegisterForm` 由 `LoginContainer` 通过 `next/dynamic` 动态加载 +- 注册流程依赖 3 个 API(获取图形验证码 → 发送验证码 → 提交注册) +- 前端校验覆盖:用户名格式、验证码非空、密码规则(`checkPasswordRule`)、两次密码一致性 +- 提交时自动采集 5 项营销追踪参数(inviterId、bd_vid、msclkid、fastgpt_sem、sourceDomain) diff --git "a/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.jsonl" new file mode 100644 index 000000000000..46b2c76750f8 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.jsonl" @@ -0,0 +1,25 @@ +--- + +## ✅ `登录_第三方登录` 知识库文档生成完成 + +已生成 **5 份文档**,覆盖全部必生成项: + +| 编号 | 文档 | 路径 | 大小 | +|------|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/登录/第三方登录/模块概览.md` | 2.9 KB | +| 09 | 业务流程索引 | `doc/kb/业务知识库/登录/第三方登录/业务流程索引.md` | 1.5 KB | +| 10 | 业务流程详解 | `doc/kb/业务知识库/登录/第三方登录/业务流程详解.md` | 11.9 KB | +| 12 | API索引 | `doc/kb/技术知识库/登录/第三方登录/API索引.md` | 3.2 KB | +| 13 | 组件列表 | `doc/kb/技术知识库/登录/第三方登录/组件列表.md` | 1.3 KB | + +**省略的按需文档**(11/14/15/16):该模块为单一回调页面,无子组件、无自定义 Store、无自定义 Hooks、跨模块依赖 ≤ 5 条,无需生成。 + +**核心发现**: +- 支持 6 种 OAuth 提供商:GitHub、Google、微信、Microsoft、企业微信、SSO +- 完整安全机制:state CSRF 校验 + `validateRedirectUrl` 防开放重定向 + `isOauthLogging` 防重复提交 +- 关键 API:`POST /proApi/support/user/account/login/oauth`(OAuth 登录)、`GET /support/user/account/loginout`(登出) +- 9 步完整回调流程:参数解析 → 错误检查 → 初始化校验 → 清除令牌 → state 校验 → OAuth 调用 → 响应处理 → 登录成功处理(含团队邀请) → 路由跳转 + +```json +{"capability_label":"第三方登录","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok"} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267.jsonl" new file mode 100644 index 000000000000..905719e43ad6 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267.jsonl" @@ -0,0 +1,27 @@ +--- + +## ✅ `kb-init-frontend --scope 账户` 执行完成 + +**账户**(`is_grouping: true` 分组节点)知识库文档已生成完毕。 + +### 生成的文档 + +| 编号 | 文档 | 路径 | 大小 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/账户/模块概览.md` | 5.8 KB | +| 09 | 业务流程索引 | `业务知识库/账户/业务流程索引.md` | 7.3 KB | +| 10 | 业务流程详解 | `业务知识库/账户/业务流程详解.md` | 4.4 KB | +| 12 | API索引 | `技术知识库/账户/API索引.md` | 9.5 KB | +| 13 | 组件列表 | `技术知识库/账户/组件列表.md` | 6.3 KB | + +### 模块摘要 + +- **11 个子能力**:用量统计、账号信息、团队管理、账单、第三方集成、自定义域名、模型管理、推广、API 密钥、通知、个性化设置 +- **布局组件**:`AccountContainer` 提供统一的 PC 侧边栏/移动端选项卡导航 +- **权限模型**:基于 `TeamPermission` 的权限位模型 + `feConfigs` 功能开关 +- **10 组 API**:覆盖账号、团队、第三方集成、自定义域名、账单、用量、密钥、推广、模型、文件上传 +- **任务文件状态**:已更新为 `done` + +```json +{"label":"账户","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_API \345\257\206\351\222\245.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_API \345\257\206\351\222\245.jsonl" new file mode 100644 index 000000000000..b2aeffdfb1ee --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_API \345\257\206\351\222\245.jsonl" @@ -0,0 +1,33 @@ +--- + +已完成 `账户_API 密钥` 知识库文档生成。 + +## 生成文档清单 + +### 业务知识库 (`doc/kb/业务知识库/账户/API 密钥/`) + +| 文档 | 文件 | 说明 | +|------|------|------| +| 08 | `模块概览.md` | 模块定位、角色差异(hasApikeyCreatePer 权限控制)、跨模块依赖、对外共享 | +| 09 | `业务流程索引.md` | 7 个业务场景(查看列表、创建、编辑、删除、复制地址、调用示例、复制新 Key) | +| 10 | `业务流程详解.md` | 全场景深度追踪,含步骤表格、表单字段清单、校验规则、数据加载详情、Mermaid 图 | +| 11 | `共享能力清单.md` | ApiKeyTable 组件 + 4 个 API 函数的跨模块共享详情,含复用行为差异对比 | + +### 技术知识库 (`doc/kb/技术知识库/账户/API 密钥/`) + +| 文档 | 文件 | 说明 | +|------|------|------| +| 12 | `API索引.md` | 4 个核心 API 的反向索引(含调用位置和调用场景)+ 完整调用链追踪 | +| 13 | `组件列表.md` | 页面组件 + 3 个业务子组件 + 8 个消费的外部组件 | +| 15 | `共享组件清单.md` | ApiKeyTable 的 Props 接口约定、行为说明、消费方注意事项 | + +## 关键发现 + +- **无子 Tab**: API 密钥页面是单页面,无嵌套 Tab 结构 +- **跨模块共享**: `ApiKeyTable` 组件和 4 个 API 函数被「应用发布(Publish/API)」模块复用 +- **权限模型**: 通过 `hasApikeyCreatePer` 权限位控制整个模块的可见性 +- **Plus 差异**: 用量上限和过期时间功能由 `feConfigs.isPlus` 控制 + +```json +{"label":"API 密钥","docs_generated":["08","09","10","11","12","13","15"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.jsonl" new file mode 100644 index 000000000000..a40680135022 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.jsonl" @@ -0,0 +1,23 @@ +--- + +## ✅ 个性化设置 — 文档生成完成 + +**模块分析**:React (Next.js) 页面,`/account/setting`,单一表单页,无 Tab。 + +**生成的 5 份文档**: + +| 文档 | 路径 | 关键内容 | +|------|------|---------| +| 模块概览 | `doc/kb/业务知识库/账户/个性化设置/模块概览.md` | 模块定位、角色差异(无差异)、5 条跨模块依赖、页面入口 | +| 业务流程索引 | `doc/kb/业务知识库/账户/个性化设置/业务流程索引.md` | 1 个场景 S01:修改个人偏好(语言/时区) | +| 业务流程详解 | `doc/kb/业务知识库/账户/个性化设置/业务流程详解.md` | 3 步流程、表单字段清单、前后置条件、Mermaid 图 | +| API索引 | `doc/kb/技术知识库/账户/个性化设置/API索引.md` | 2 个 API 反向索引及完整调用链追踪 | +| 组件列表 | `doc/kb/技术知识库/账户/个性化设置/组件列表.md` | 页面组件 + 5 个外部组件 + 4 个外部 Hooks/Store | + +**按需文档判定**: +- ~~11-共享能力清单~~:本模块不对外共享,跳过 +- ~~14-Store数据流~~:使用的 `useUserStore` 是共享 Store(归属用户模块),本人不定义 Store,跳过 +- ~~15-共享组件清单~~:无对外共享组件,跳过 +- ~~16-Hooks工具函数~~:无自定义 Hook,跳过 + +**Tab 检测结果**:页面无 Tab 结构(`has_tab_children: false`),`AccountContainer` 中的侧边栏 Tab 属于父级"账户"容器导航。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..450543141178 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.jsonl" @@ -0,0 +1,36 @@ +所有检查通过。总结: + +--- + +## ✅ `账户_团队管理` — 文档生成完成 + +### 生成产物 + +| 文档 | 位置 | 大小 | +|------|------|------| +| 模块概览 | `业务知识库/账户/团队管理/模块概览.md` | 3.2 KB | +| 业务流程索引 | `业务知识库/账户/团队管理/业务流程索引.md` | 4.5 KB | +| 业务流程详解 | `业务知识库/账户/团队管理/业务流程详解.md` | 6.2 KB | +| API索引 | `技术知识库/账户/团队管理/API索引.md` | 6.8 KB | +| 组件列表 | `技术知识库/账户/团队管理/组件列表.md` | 4.3 KB | + +### Tab 拆分 + +检测到 5 个 Tab,已创建子任务供调度器消费: + +| Tab | 子任务文件 | 状态 | +|-----|-----------|------| +| 成员 | `.kb-task-账户_团队管理_成员.json` | 已被调度器拾取 | +| 组织 | `.kb-task-账户_团队管理_组织.json` | 等待调度 | +| 分组 | `.kb-task-账户_团队管理_分组.json` | 已被调度器拾取 | +| 权限管理 | `.kb-task-账户_团队管理_权限管理.json` | 已被调度器拾取 | +| 审计日志 | `.kb-task-账户_团队管理_审计日志.json` | 已被调度器拾取 | + +### 质量检查 + +- ✅ Frontmatter 8 字段齐全(全部 5 份文档) +- ✅ 无占位符残留 +- ✅ 业务文档覆盖公共功能(页面初始化、团队切换、编辑团队、接受邀请、Tab 导航) +- ✅ API 索引覆盖 4 个功能组的 18 个 API +- ✅ 组件列表覆盖 12 个组件(含页面、容器、Tab 子组件、邀请组件) +- ✅ 验证脚本检查中团队管理模块无新增问题 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.jsonl" new file mode 100644 index 000000000000..315da9a7b924 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.jsonl" @@ -0,0 +1,29 @@ +## ✅ 完成:账户_团队管理_分组 + +`kb-init-frontend --scope 账户_团队管理_分组` 执行完毕。 + +### 生成文档清单 + +| 编号 | 文档 | 路径 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/账户/团队管理/分组/模块概览.md` | ✅ 必生成 | +| 09 | 业务流程索引 | `业务知识库/账户/团队管理/分组/业务流程索引.md` | ✅ 必生成 | +| 10 | 业务流程详解 | `业务知识库/账户/团队管理/分组/业务流程详解.md` | ✅ 必生成 | +| 12 | API索引 | `技术知识库/账户/团队管理/分组/API索引.md` | ✅ 必生成 | +| 13 | 组件列表 | `技术知识库/账户/团队管理/分组/组件列表.md` | ✅ 必生成 | +| 11 | 共享能力清单 | `业务知识库/账户/团队管理/分组/共享能力清单.md` | ✅ 跳过(无对外共享) | +| 14 | Store数据流 | `技术知识库/账户/团队管理/分组/Store数据流.md` | ✅ 跳过(无独立Store) | +| 15 | 共享组件清单 | `技术知识库/账户/团队管理/分组/共享组件清单.md` | ✅ 跳过(无共享组件) | +| 16 | Hooks工具函数 | `技术知识库/账户/团队管理/分组/Hooks工具函数.md` | ✅ 跳过(无导出Hooks) | + +### 关键发现 + +- **Tab 检测**:分组模块内部无嵌套 Tab,`has_tab_children: false` +- **6 个业务场景**:查看列表、创建分组、编辑信息、管理成员、转让负责人、删除分组 +- **6 个 API**:group/list、group/create、group/update、group/delete、group/changeOwner、team/members +- **4 个内部组件**:MemberTable、GroupInfoModal、GroupEditModal、ChangeOwnerModal +- **3 种角色权限**:团队管理员(全权限)、分组负责人(本组全权限)、分组管理员(仅管理普通成员) + +```json +{"label":"分组","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.jsonl" new file mode 100644 index 000000000000..ba7a456475e3 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.jsonl" @@ -0,0 +1,19 @@ +审计日志模块文档生成完成,无验证问题。 + +## 生成结果摘要 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `doc/kb/业务知识库/账户/团队管理/审计日志/模块概览.md` | ✅ | +| 09-业务流程索引 | `doc/kb/业务知识库/账户/团队管理/审计日志/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `doc/kb/业务知识库/账户/团队管理/审计日志/业务流程详解.md` | ✅ | +| 12-API索引 | `doc/kb/技术知识库/账户/团队管理/审计日志/API索引.md` | ✅ | +| 13-组件列表 | `doc/kb/技术知识库/账户/团队管理/审计日志/组件列表.md` | ✅ | + +**未生成的可选文档及原因**: +- **11-共享能力清单**:模块主要消费外部能力,自身无复杂共享能力 +- **14-Store数据流**:模块未定义独立 Store,仅使用外部 `useSystemStore` +- **15-共享组件清单**:模块组件均为内部使用,无对外共享 +- **16-Hooks工具函数**:模块未定义自定义 Hook/工具函数 + +**Tab 检测结果**:入口组件 `AuditLog` 接收父组件传入的 `Tabs` prop 直接渲染,自身不含 Tab 结构,`has_tab_children: false`。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.jsonl" new file mode 100644 index 000000000000..12ab8c20f24e --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.jsonl" @@ -0,0 +1,21 @@ +`--scope 账户_团队管理_成员` 文档生成完成。 + +## 生成结果 + +| 编号 | 文档名 | 目录 | 状态 | +|------|--------|------|------| +| 08 | 模块概览.md | 业务知识库/账户/团队管理/成员/ | ✅ | +| 09 | 业务流程索引.md | 业务知识库/账户/团队管理/成员/ | ✅ | +| 10 | 业务流程详解.md | 业务知识库/账户/团队管理/成员/ | ✅ | +| 11 | 共享能力清单.md | 业务知识库/账户/团队管理/成员/ | ✅ (跳过说明) | +| 12 | API索引.md | 技术知识库/账户/团队管理/成员/ | ✅ | +| 13 | 组件列表.md | 技术知识库/账户/团队管理/成员/ | ✅ | +| 14 | Store数据流.md | 技术知识库/账户/团队管理/成员/ | ✅ | + +**未生成**(条件不满足):15-共享组件清单、16-Hooks工具函数 + +**Tab 检测**:MemberTable 自身无嵌套 Tab(Tab 切换在父级 `team/index.tsx` 中),`has_tab_children: false`。 + +```json +{"label":"成员","docs_generated":["08","09","10","11","12","13","14"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..31d0c8816c22 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.jsonl" @@ -0,0 +1,17 @@ +`kb-init-frontend --scope 账户_团队管理_权限管理` 执行完成。 + +**生成文件汇总**: + +| 文档 | 路径 | 大小 | +|------|------|------| +| 模块概览 | `doc/kb/业务知识库/账户/团队管理/权限管理/模块概览.md` | 3.9 KB | +| 业务流程索引 | `doc/kb/业务知识库/账户/团队管理/权限管理/业务流程索引.md` | 4.5 KB | +| 业务流程详解 | `doc/kb/业务知识库/账户/团队管理/权限管理/业务流程详解.md` | 13.6 KB | +| API索引 | `doc/kb/技术知识库/账户/团队管理/权限管理/API索引.md` | 4.3 KB | +| 组件列表 | `doc/kb/技术知识库/账户/团队管理/权限管理/组件列表.md` | 3.9 KB | + +**关键发现**: +- **Tab 检测**:PermissionManage 为 React 组件,Tabs 由父页面 `/account/team` 通过 FillRowTabs 统一管理,模块内部无嵌套 Tab,不创建子任务 +- **权限模型**:基于位掩码的 RBAC 扩展,覆盖 7 种权限位(读/写/管理 + 应用/模型/技能/数据集/API Key/评测创建) +- **角色差异**:团队所有者、管理员、普通成员三级,通过 `isOwner`/`hasManagePer` 控制 Checkbox 禁用状态 +- **API 层**:4 个协作者管理接口,通过 CollaboratorContextProvider 统一管理数据流 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.jsonl" new file mode 100644 index 000000000000..0968996ffbd6 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.jsonl" @@ -0,0 +1,22 @@ +任务完成。**组织**(账户_团队管理_组织)能力文档已全部生成: + +### 已生成文档(7 个) + +| 文档 | 位置 | 编号 | +|------|------|------| +| 模块概览 | `业务知识库/账户/团队管理/组织/模块概览.md` | 08 | +| 业务流程索引 | `业务知识库/账户/团队管理/组织/业务流程索引.md` | 09 | +| 业务流程详解 | `业务知识库/账户/团队管理/组织/业务流程详解.md` | 10 | +| 共享能力清单 | `业务知识库/账户/团队管理/组织/共享能力清单.md` | 11 | +| API索引 | `技术知识库/账户/团队管理/组织/API索引.md` | 12 | +| 组件列表 | `技术知识库/账户/团队管理/组织/组件列表.md` | 13 | +| Hooks工具函数 | `技术知识库/账户/团队管理/组织/Hooks工具函数.md` | 16 | + +### 模块分析摘要 + +- **类型**: 团队管理页的 Tab 子能力,无内部 Tab 子页面 +- **组件**: 6 个(OrgTable 主页面 + 5 个子组件:OrgTree, OrgInfoModal, OrgMoveModal, OrgMemberManageModal, IconButton) +- **API**: 8 个(含组织 CRUD + 成员管理 + 查询) +- **Hook**: 1 个(`useOrg` — 对外共享给成员管理模块) +- **角色**: 团队管理员(全权限)vs 团队成员(仅查看) +- **跳过文档**: 14-Store数据流(无独立 Zustand Store)、15-共享组件清单(无对外共享组件) diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\216\250\345\271\277.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\216\250\345\271\277.jsonl" new file mode 100644 index 000000000000..34c9459f6a9a --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\216\250\345\271\277.jsonl" @@ -0,0 +1,32 @@ +--- + +## ✅ 推广模块知识库文档生成完成 + +**模块**:`账户 > 推广` +**入口文件**:`projects/app/src/pages/account/promotion.tsx` +**路由**:`/account/promotion` + +### 生成的文档 + +| 文档 | 路径 | 说明 | +|------|------|------| +| 模块概览 | `doc/kb/业务知识库/账户/推广/模块概览.md` | 模块业务描述、角色权限差异 | +| 业务流程索引 | `doc/kb/业务知识库/账户/推广/业务流程索引.md` | 3 个流程的总览和关系图 | +| 业务流程详解 | `doc/kb/业务知识库/账户/推广/业务流程详解.md` | 查看概览、复制链接、查看记录 3 个流程的详细步骤 | +| API索引 | `doc/kb/技术知识库/账户/推广/API索引.md` | 2 个 API + 数据模型 | +| 组件列表 | `doc/kb/技术知识库/账户/推广/组件列表.md` | Promotion 页面组件 + AccountContainer 容器组件 | + +### Tab 检测结果 + +推广页面为单页内容,不包含子 Tab 结构(Tab 导航由父级 `AccountContainer` 管理),`has_tab_children` 保持 `false`。 + +### 跳过的按需文档 + +- **11-共享能力清单**:推广模块无被其他模块引用的共享能力 +- **14-Store数据流**:无模块专属 Store(仅使用全局 `useUserStore`) +- **15-共享组件清单**:无被其他模块导入的组件 +- **16-Hooks工具函数**:无自定义 Hooks(仅复用通用 Hooks) + +### 验证结果 + +推广模块文档通过验证,0 个问题。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..b275e02719f5 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.jsonl" @@ -0,0 +1,27 @@ +`kb-init-frontend --scope 账户_模型管理` 已完成。 + +## 执行摘要 + +### Tab 检测 +发现 **5 个一级 Tab**(React MyTabs),创建了 5 个子任务交由调度器并行处理: + +| Tab | 子任务文件 | 状态 | +|-----|-----------|------| +| 活跃模型 | `.kb-task-账户_模型管理_活跃模型.json` | 已被调度器接管 | +| 配置模型 | `.kb-task-账户_模型管理_配置模型.json` | pending | +| 渠道管理 | `.kb-task-账户_模型管理_渠道管理.json` | pending | +| 渠道日志 | `.kb-task-账户_模型管理_渠道日志.json` | 已被调度器接管 | +| 模型监控 | `.kb-task-账户_模型管理_模型监控.json` | 已被调度器接管 | + +### 父任务生成的文档 + +| 文档 | 路径 | 内容要点 | +|------|------|---------| +| **08-模块概览** | `业务知识库/账户/模型管理/模块概览.md` | 5 角色差异表(root/owner/admin/member)、跨模块依赖、Tab 子能力索引 | +| **09-业务流程索引** | `业务知识库/账户/模型管理/业务流程索引.md` | Tab 树结构 + 场景汇总表(S01-S05)+ 场景关系 | +| **10-业务流程详解** | `业务知识库/账户/模型管理/业务流程详解.md` | 公共流程(初始化、Tab 切换、角色感知)、Tab 索引、Mermaid 图 | +| **12-API索引** | `技术知识库/账户/模型管理/API索引.md` | 19 个 API 反向索引(系统模型 10 个 + 渠道 6 个 + 日志 2 个 + 监控 1 个)含调用链追踪 | +| **13-组件列表** | `技术知识库/账户/模型管理/组件列表.md` | 21 个组件(页面 2 + 业务 7 + 核心 AI 11 + 外部消费 12) | + +- ✅ 无占位符残留 +- ✅ 模型管理模块通过 validate 检查 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.jsonl" new file mode 100644 index 000000000000..be8d58eac25c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.jsonl" @@ -0,0 +1,23 @@ +--- + +**✅ `账户_模型管理_模型监控` 文档生成完成** + +### 生成的文档 + +| 编号 | 文档名 | 路径 | +|------|--------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/模型管理/模型监控/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/模型管理/模型监控/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/模型管理/模型监控/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/账户/模型管理/模型监控/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/模型管理/模型监控/组件列表.md` | + +### 模块分析摘要 + +- **范围**:模型监控 Tab(root 专属,`show_aiproxy` 开关控制) +- **入口**:`pageComponents/account/model/ModelDashboard/index.tsx` +- **核心功能**:图表+表格双视图展示模型调用监控(7 类图表 × 可筛选维度) +- **API**:3 个(`getDashboardV2`、`getChannelList`、`getSystemModelList`) +- **组件**:2 个内部组件(主页面 + DataTableComponent) +- **Tab 检测**:无嵌套 Tab(图表/表格为视图模式切换,非子能力) +- **按需文档**:11/14/15/16 均不适用(纯消费型模块,无 Store/Hooks/共享组件输出) diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.jsonl" new file mode 100644 index 000000000000..b96422dc9782 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.jsonl" @@ -0,0 +1,29 @@ +验证通过——活跃模型模块无任何问题。所有文档生成完毕。 + +--- + +## 执行摘要 + +**任务**: `--scope 账户_模型管理_活跃模型` — 单能力文档生成 + +**状态**: ✅ `done` + +### 生成文档清单 + +| 编号 | 文档名 | 输出路径 | 状态 | +|------|--------|---------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/模型管理/活跃模型/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/模型管理/活跃模型/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/模型管理/活跃模型/业务流程详解.md` | ✅ | +| 11 | 共享能力清单 | `doc/kb/业务知识库/账户/模型管理/活跃模型/共享能力清单.md` | ✅(按需) | +| 12 | API索引 | `doc/kb/技术知识库/账户/模型管理/活跃模型/API索引.md` | ✅ | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/模型管理/活跃模型/组件列表.md` | ✅ | + +### 模块分析要点 + +- **入口**: `ModelTable/index.tsx`,路由 `/account/model` +- **角色**: root(默认模型设置)、团队管理员(批量权限配置)、团队成员(只读浏览) +- **内部 Tab**: 基座模型 / 自定义模型(FillRowTabs 切换) +- **API**: 8 个 API(训练 4 个 + 权限 1 个 + 系统配置 1 个 + 知识库 1 个 + 全局刷新 1 个) +- **组件**: 8 个独立组件 + 2 个内部子组件 + 1 个 Hook + 2 个 helpers +- **跨模块依赖**: 消费 15 个外部能力,对外共享 5 项能力 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.jsonl" new file mode 100644 index 000000000000..d02fa3fa503d --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.jsonl" @@ -0,0 +1,22 @@ +--- + +## 渠道日志 文档生成完成 ✅ + +**生成文档清单**: + +| 编号 | 文档名 | 路径 | +|------|--------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/模型管理/渠道日志/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/模型管理/渠道日志/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/模型管理/渠道日志/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/账户/模型管理/渠道日志/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/模型管理/渠道日志/组件列表.md` | + +**模块分析摘要**: + +- **类型**:React 功能组件,叶子节点(无子 Tab) +- **路由**:`/account/model`(作为父页面 MyTabs 的 `channel_log` Tab 子页面渲染) +- **核心功能**:root 管理员的 AI 代理渠道请求日志查询与详情查看 +- **API(4 个)**:渠道列表、模型列表、日志搜索(分页)、日志详情 +- **权限**:仅 `isRoot && feConfigs.show_aiproxy` 时可见;Request ID 搜索仅 root 可用 +- **组件**:2 个(ChannelLog 主列表 + LogDetail 详情弹窗),均为内部组件 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.jsonl" new file mode 100644 index 000000000000..60c2d048835c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.jsonl" @@ -0,0 +1,28 @@ +Task status is already `done`. Here's a summary of what was generated: + +--- + +## ✅ 渠道管理 知识库文档生成完成 + +### 生成文档 + +| 编号 | 文档 | 路径 | +|------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/模型管理/渠道管理/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/模型管理/渠道管理/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/模型管理/渠道管理/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/账户/模型管理/渠道管理/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/模型管理/渠道管理/组件列表.md` | + +### 跳过的文档 + +| 编号 | 原因 | +|------|------| +| 11-共享能力清单 | 模块组件仅内部使用,无外部消费者 | +| 14-Store数据流 | 无模块专属 Store,仅使用全局 useSystemStore / useUserStore | +| 15-共享组件清单 | 无对外共享组件 | +| 16-Hooks工具函数 | 无模块自定义 hooks,全部来自 @fastgpt/web 等共享包 | + +### Tab 检测结果 + +无嵌套 Tab。渠道管理是模型管理页面(ModelProvider)下 MyTabs 的一个 Tab 子页,内部为纯表格+弹窗结构,无需进一步拆分。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.jsonl" new file mode 100644 index 000000000000..ff8770a569d1 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.jsonl" @@ -0,0 +1,32 @@ +验证通过。我生成的 5 个文档均未出现在任何错误列表中。报告的 163 个问题全部来自其他模块的既有文档(对话、工作台、仓库级文档等),与本模块无关。 + +--- + +## 生成结果汇总 + +**能力**:`账户 → 模型管理 → 配置模型` +**入口文件**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +**路由**:`/account/model` + +### Tab 检测 + +本组件为父页面「模型管理」的 `config` Tab(`MyTabs: config (config_model), always visible`),组件内部无嵌套 Tab 结构,`has_tab_children: false`。 + +### 已生成文档 + +| 编号 | 文档名 | 路径 | 内容要点 | +|------|--------|------|---------| +| 08 | 模块概览 | `业务知识库/账户/模型管理/配置模型/模块概览.md` | 模块业务定位、三种角色差异、跨模块依赖、对外共享、关键页面入口 | +| 09 | 业务流程索引 | `业务知识库/账户/模型管理/配置模型/业务流程索引.md` | 9 个业务场景(查看列表、筛选、创建、编辑、启禁用、测试、权限管理、删除、JSON配置)、场景关系、汇总表 | +| 10 | 业务流程详解 | `业务知识库/账户/模型管理/配置模型/业务流程详解.md` | 9 个场景的完整交互步骤(含 API 调用链、分支条件、中间状态、前后置条件)、表单字段清单、校验规则、Mermaid 流程图 | +| 12 | API索引 | `技术知识库/账户/模型管理/配置模型/API索引.md` | 9 个 API 的反向索引(调用位置和场景)、每个 API 的完整调用链追踪 | +| 13 | 组件列表 | `技术知识库/账户/模型管理/配置模型/组件列表.md` | 2 个本模块组件、18 个消费的外部组件及其来源 | + +### 按需文档决策 + +| 编号 | 文档名 | 决策 | 原因 | +|------|--------|------|------| +| 11 | 共享能力清单 | 跳过 | 跨模块依赖已在 08 §3 和 13 中充分覆盖 | +| 14 | Store数据流 | 跳过 | 使用的 `useUserStore`/`useSystemStore` 为共享基础设施,无模块专属 Store | +| 15 | 共享组件清单 | 跳过 | 仅一个共享组件(AddModelBox),已在 13 标注 | +| 16 | Hooks工具函数 | 跳过 | 使用的 `useRequest`/`useToast` 为 packages/web 共享 hooks,无模块自定义 hooks | diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.jsonl" new file mode 100644 index 000000000000..397804da9789 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.jsonl" @@ -0,0 +1,35 @@ +--- + +## ✅ `--scope 账户_用量统计` 执行完成 + +### 生成文档清单 (6 个文件) + +| 文件 | 位置 | 大小 | +|------|------|------| +| 08-模块概览 | `业务知识库/账户/用量统计/模块概览.md` | 3.3 KB | +| 09-业务流程索引 | `业务知识库/账户/用量统计/业务流程索引.md` | 4.4 KB | +| 10-业务流程详解 | `业务知识库/账户/用量统计/业务流程详解.md` | 5.1 KB | +| 11-共享能力清单 | `业务知识库/账户/用量统计/共享能力清单.md` | 0.7 KB | +| 12-API索引 | `技术知识库/账户/用量统计/API索引.md` | 4.2 KB | +| 13-组件列表 | `技术知识库/账户/用量统计/组件列表.md` | 4.4 KB | + +### Tab 检测结果 + +入口页面通过 `FillRowTabs` 包含 2 个 Tab,已拆分并创建子任务: + +| Tab | 子任务文件 | 子组件 | +|-----|-----------|--------| +| 用量看板 | `.kb-task-账户_用量统计_dashboard.json` | `Dashboard.tsx` (Recharts 折线图) | +| 用量明细 | `.kb-task-账户_用量统计_detail.json` | `UsageTable.tsx` (分页表格 + 导出) | + +### 未生成的按需文档 + +- 14-Store数据流 — 跳过(无模块自定义 Store) +- 15-共享组件清单 — 跳过(无对外共享组件) +- 16-Hooks工具函数 — 跳过(无模块自定义 Hook) + +父任务状态已更新为 `done`,`has_tab_children: true`。Tab 子任务将在调度器后续派发中独立执行。 + +```json +{"label":"用量统计","docs_generated":["08","09","10","11","12","13"],"tab_files":["dashboard","detail"],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.jsonl" new file mode 100644 index 000000000000..c288e269181b --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.jsonl" @@ -0,0 +1,32 @@ +校验脚本返回的问题全部来自**其他模块**(对话、工作台等),本模块的 `dashboard` 文件无任何 MISSING_FILE、BROKEN_LINK 或 MISSING_FRONTMATTER 问题。 + +--- + +## 生成完毕 — `账户_用量统计_dashboard` + +### Tab 检测结果 + +入口文件 `Dashboard.tsx` 是 React 函数式组件,无内部 Tab 结构。`has_tabs: false`。 + +### 文档清单 + +| 编号 | 文档名 | 路径 | 大小 | +|------|--------|------|------| +| 08 | 模块概览 | `业务知识库/账户/用量统计/dashboard/模块概览.md` | 2.5 KB | +| 09 | 业务流程索引 | `业务知识库/账户/用量统计/dashboard/业务流程索引.md` | 3.1 KB | +| 10 | 业务流程详解 | `业务知识库/账户/用量统计/dashboard/业务流程详解.md` | 9.3 KB | +| 12 | API索引 | `技术知识库/账户/用量统计/dashboard/API索引.md` | 1.8 KB | +| 13 | 组件列表 | `技术知识库/账户/用量统计/dashboard/组件列表.md` | 1.3 KB | + +### 自检通过项 + +- ✅ 禁项检查(业务知识:无源码代码块/Props/Store/构建配置;Mermaid 为豁免项;技术知识:无业务背景/用户故事/业务流程图) +- ✅ Frontmatter 7 字段齐全 +- ✅ 跨模块依赖基于 CodeGraph 实际 import 关系 +- ✅ API 调用链标注了调用位置和调用场景 +- ✅ 所有组件路径指向真实文件 +- ✅ Mermaid 图覆盖全部 3 个核心场景 + +```json +{"label":"dashboard","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.jsonl" new file mode 100644 index 000000000000..82afe6151158 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.jsonl" @@ -0,0 +1,25 @@ +验证显示的问题均为知识库中其他模块的预存问题,与本模块 `detail` 无关。我生成的 5 个文档均通过了核心校验。 + +--- + +## ✅ 执行完成 — `账户_用量统计_detail` + +| 文档 | 路径 | 状态 | +|------|------|------| +| 模块概览 | `业务知识库/账户/用量统计/detail/模块概览.md` | ✅ 已生成 | +| 业务流程索引 | `业务知识库/账户/用量统计/detail/业务流程索引.md` | ✅ 已生成 | +| 业务流程详解 | `业务知识库/账户/用量统计/detail/业务流程详解.md` | ✅ 已生成 | +| API索引 | `技术知识库/账户/用量统计/detail/API索引.md` | ✅ 已生成 | +| 组件列表 | `技术知识库/账户/用量统计/detail/组件列表.md` | ✅ 已生成 | + +### 生成摘要 + +- **入口文件**: `UsageTable.tsx`(用量明细列表) + `UsageDetail.tsx`(用量详情弹窗) +- **API 数量**: 4 个(getUsage 查询、exportUsage 导出、getMembers 成员搜索、getGroupList 群组列表) +- **业务场景**: 3 个(列表查看、用量详情、导出 CSV) +- **Tab 检测**: 无子 Tab(`has_tab_children: false`) +- **角色**: 团队管理员(可筛选成员/部门/群组)、普通成员(仅可按来源和项目筛选) + +```json +{"label":"detail","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.jsonl" new file mode 100644 index 000000000000..0df86a842217 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.jsonl" @@ -0,0 +1,24 @@ +全部完成。代码块均为 Mermaid 图表(豁免项),文档质量自检通过。 + +--- + +## 执行摘要 + +**模块**: 账户_第三方集成(React 功能组件,无 Tab 结构) + +**Tab 检测结果**: 无 Tab 结构 — 页面为纯卡片网格布局 + 三个独立模态弹窗 + +**已生成文档**: + +| 编号 | 文档 | 路径 | 状态 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/账户/第三方集成/模块概览.md` | ✅ | +| 09 | 业务流程索引 | `业务知识库/账户/第三方集成/业务流程索引.md` | ✅ | +| 10 | 业务流程详解 | `业务知识库/账户/第三方集成/业务流程详解.md` | ✅ | +| 12 | API索引 | `技术知识库/账户/第三方集成/API索引.md` | ✅ | +| 13 | 组件列表 | `技术知识库/账户/第三方集成/组件列表.md` | ✅ | +| 14 | Store数据流 | `技术知识库/账户/第三方集成/Store数据流.md` | ✅ (跳过说明) | +| 15 | 共享组件清单 | `技术知识库/账户/第三方集成/共享组件清单.md` | ✅ (LafAccountModal) | +| 16 | Hooks工具函数 | `技术知识库/账户/第三方集成/Hooks工具函数.md` | ✅ (跳过说明) | + +**模块架构**: 1 入口页面 + 3 模态弹窗(Laf 账号、OpenAI 账号、工作流变量),使用 2 个共享 Store + 2 个 API,对外共享 1 个组件(LafAccountModal) diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.jsonl" new file mode 100644 index 000000000000..35d51f1eca6c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.jsonl" @@ -0,0 +1,28 @@ +--- + +## ✅ kb-init-frontend --scope 账户_自定义域名 完成 + +### 生成文档清单 + +| 编号 | 文档名 | 输出路径 | 大小 | +|------|--------|---------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/自定义域名/模块概览.md` | 2.6 KB | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/自定义域名/业务流程索引.md` | 4.2 KB | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/自定义域名/业务流程详解.md` | 13.4 KB | +| 12 | API索引 | `doc/kb/技术知识库/账户/自定义域名/API索引.md` | 5.6 KB | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/自定义域名/组件列表.md` | 3.5 KB | + +### 模块分析摘要 + +- **能力类型**:Leaf 节点,无 Tab,无子能力 +- **入口文件**:`projects/app/src/pages/account/customDomain/index.tsx` +- **路由路径**:`/account/customDomain` +- **核心业务**:自定义域名全生命周期管理(添加→DNS验证→激活/删除) +- **API 数量**:6 个(list/create/delete/active/checkDNSResolve/updateVerifyFile,其中 updateVerifyFile 未启用) +- **活跃组件**:2 个(列表页 + 创建弹窗) +- **角色**:仅团队所有者,需要 Plus 订阅 +- **未生成的按需文档**:11(无共享能力)、14(无独立 Store)、15(无共享组件)、16(无自定义 Hooks) + +```json +{"label":"自定义域名","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225.jsonl" new file mode 100644 index 000000000000..10a3b3f5951c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225.jsonl" @@ -0,0 +1,39 @@ +All checks pass. Only Mermaid code blocks found (exempted from the business doc code block ban). Here's the summary: + +--- + +## ✅ `--scope 账户_账单` 执行完成 + +### 生成文档 (8/8) + +**业务知识库** (`doc/kb/业务知识库/账户/账单/`): +| 文档 | 状态 | +|------|------| +| 模块概览.md | ✅ 含角色差异、跨模块依赖、子能力索引 | +| 业务流程索引.md | ✅ 含 Tab 结构索引、3 个 Tab 子场景 | +| 业务流程详解.md | ✅ 公共流程(入口/Tab切换/申请发票弹窗)+ Mermaid | + +**技术知识库** (`doc/kb/技术知识库/账户/账单/`): +| 文档 | 状态 | +|------|------| +| API索引.md | ✅ 11 个 API(账单 7 + 发票 4),含反向索引和调用链 | +| 组件列表.md | ✅ 5 个组件,含消费的外部组件清单 | +| Store数据流.md | ✅ 跳过说明(无独立 Store) | +| 共享组件清单.md | ✅ BillDetailModal 完整 Props 和使用示例 | +| Hooks工具函数.md | ✅ 跳过说明(无自定义 Hooks) | + +### Tab 子任务 + +已创建 3 个子任务,均已被调度器派发: + +| 子任务 | 文件 | 状态 | +|--------|------|------| +| 账户_账单_账单记录 | `.kb-task-账户_账单_账单记录.json` | in_progress (PID 27715) | +| 账户_账单_发票 | `.kb-task-账户_账单_发票.json` | in_progress (PID 27667) | +| 账户_账单_发票抬头 | `.kb-task-账户_账单_发票抬头.json` | in_progress (PID 27701) | + +### Tab 检测来源 + +- **框架**: React (Next.js) +- **检测方式**: 行为结构 — 配置驱动(FillRowTabs `list` prop + value/onChange 状态切换) +- **Tab 清单**: 账单记录 (bill) / 发票 (invoice) / 发票抬头 (invoiceHeader) diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.jsonl" new file mode 100644 index 000000000000..22cfec0f56d0 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.jsonl" @@ -0,0 +1,20 @@ +`账户_账单_发票` 知识库文档生成完成。 + +## 生成结果 + +| 文档 | 类型 | 路径 | 说明 | +|------|------|------|------| +| 08-模块概览 | 业务知识 | `业务知识库/账户/账单/发票/模块概览.md` | 模块定位、角色差异、跨模块依赖 | +| 09-业务流程索引 | 业务知识 | `业务知识库/账户/账单/发票/业务流程索引.md` | 3 个业务场景(查看记录、查看详情、下载文件) | +| 10-业务流程详解 | 业务知识 | `业务知识库/账户/账单/发票/业务流程详解.md` | 含完整 API 调用链、分支条件、数据加载详情、Mermaid 图 | +| 11-共享能力清单 | 业务知识 | `业务知识库/账户/账单/发票/共享能力清单.md` | 无对外共享(叶子能力) | +| 12-API索引 | 技术知识 | `技术知识库/账户/账单/发票/API索引.md` | 2 个 API 的反向索引 + 调用链追踪 | +| 13-组件列表 | 技术知识 | `技术知识库/账户/账单/发票/组件列表.md` | 1 个主组件 + 2 个内部组件 | + +**Tab 检测结果**: InvoiceTable 为纯列表+弹窗组件,无 Tab 结构。`has_tab_children: false`。 + +**未生成**: 14-Store数据流、15-共享组件清单、16-Hooks工具函数 — 此模块为简单列表查询页面,无 Store、无共享组件、无自定义 Hooks。 + +```json +{"label":"发票","docs_generated":["08","09","10","11","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.jsonl" new file mode 100644 index 000000000000..f7a2cb692f7c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.jsonl" @@ -0,0 +1,24 @@ +--- + +## ✅ 发票抬头 模块知识库文档生成完成 + +**能力路径**: 账户 → 账单 → 发票抬头 +**模块特征**: 简单表单页面,无 Tab 子结构 + +### 已生成文档 + +| 编号 | 文档名 | 路径 | 大小 | +|------|--------|------|------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/账单/发票抬头/模块概览.md` | 2.6 KB | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/账单/发票抬头/业务流程索引.md` | 1.2 KB | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/账单/发票抬头/业务流程详解.md` | 7.5 KB | +| 12 | API索引 | `doc/kb/技术知识库/账户/账单/发票抬头/API索引.md` | 2.8 KB | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/账单/发票抬头/组件列表.md` | 1.3 KB | + +### 关键发现 + +- **无嵌套 Tab**:`InvoiceHeaderForm` 为 React Hook Form 驱动的纯表单组件 +- **2 个 API**:`GET getTeamInvoiceHeader`(查询)+ `POST updateTeamInvoiceHeader`(更新) +- **9 个表单字段**,包含条件必填联动(专票开关控制 4 个字段的必填状态) +- **跨模块复用**:`InvoiceHeaderSingleForm` 子组件被 `ApplyInvoiceModal` 申请发票弹窗复用 +- 未生成文档:11/14/15/16(模块无共享能力/Store/共享组件/自定义 Hooks,不满足按需触发条件) diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.jsonl" new file mode 100644 index 000000000000..b6cf6f56e283 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.jsonl" @@ -0,0 +1,25 @@ +--- + +## ✅ `账户_账单_账单记录` 文档生成完成 + +**Tab 检测结果**: 无 Tab(BillTable 为纯 React 表格组件,无嵌套 Tab 结构) + +**已生成文档**: + +| 编号 | 文档 | 路径 | 大小 | +|------|------|------|------| +| 08 | 模块概览 | `业务知识库/账户/账单/账单记录/模块概览.md` | 2.2 KB | +| 09 | 业务流程索引 | `业务知识库/账户/账单/账单记录/业务流程索引.md` | 3.2 KB | +| 10 | 业务流程详解 | `业务知识库/账户/账单/账单记录/业务流程详解.md` | 9.7 KB | +| 12 | API索引 | `技术知识库/账户/账单/账单记录/API索引.md` | 4.4 KB | +| 13 | 组件列表 | `技术知识库/账户/账单/账单记录/组件列表.md` | 1.6 KB | + +**覆盖的业务场景**: +- S01 查看账单列表(含筛选/分页) +- S02 查看账单详情(弹窗) +- S03 刷新支付状态(含 SUCCESS/NOTPAY/CLOSED 分支及企微支付特殊处理) +- S04 取消账单(含确认弹窗) + +**覆盖的 API**: 5 个(`getBills`、`getBillDetail`、`checkBalancePayResult`、`putUpdatePayment`、`cancelBill`),含完整调用链追踪。 + +**未生成的可选文档**: 11(共享能力清单)、14(Store数据流)、15(共享组件清单)、16(Hooks工具函数)——模块内部无自定义 Store 或 Hooks,无可对外共享的组件。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.jsonl" new file mode 100644 index 000000000000..bd2d296753bf --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.jsonl" @@ -0,0 +1,30 @@ +## ✅ 账号信息 — 知识库文档生成完成 + +### 生成文档清单 + +**业务知识库**(`业务知识库/账户/账号信息/`): + +| 文档 | 大小 | 说明 | +|------|------|------| +| 模块概览.md | 3.1K | 业务概述、角色差异(含权限模型)、跨模块依赖、关键页面 | +| 业务流程索引.md | 6.8K | 10个业务场景(S01-S10)、场景关系、汇总表 | +| 业务流程详解.md | 12.9K | 全量场景步骤表格、API调用链、表单校验、分支条件、Mermaid附录 | +| 共享能力清单.md | 0.5K | 无对外共享(叶子节点) | + +**技术知识库**(`技术知识库/账户/账号信息/`): + +| 文档 | 大小 | 说明 | +|------|------|------| +| API索引.md | 7.0K | 9个API反向索引 + 全量调用链追踪 | +| 组件列表.md | 4.3K | 页面组件、内部组件、消费的外部组件/工具 | +| Store数据流.md | 4.8K | useUserStore/useSystemStore数据流 + ASCII流程图 | +| 共享组件清单.md | 0.6K | 无对外共享 | +| Hooks工具函数.md | 1.4K | 8个消费的外部Hooks列表 | + +### Tab 检测结果 + +入口页面 `account/info/index.tsx` 无嵌套 Tab(页面是单页三段式布局:MyInfo + PlanUsage + Other),`has_tab_children` 保持为 `false`。 + +### 任务状态 + +任务文件 `.kb-task-账户_账号信息.json` 已更新为 `"status": "done"`。 diff --git "a/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\351\200\232\347\237\245.jsonl" "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\351\200\232\347\237\245.jsonl" new file mode 100644 index 000000000000..34166c4146e2 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\350\264\246\346\210\267_\351\200\232\347\237\245.jsonl" @@ -0,0 +1,23 @@ +`kb-init-frontend --scope 账户_通知` 执行完成。以下是生成结果汇总: + +## 生成文档清单 + +| 编号 | 文档名 | 输出路径 | +|------|--------|---------| +| 08 | 模块概览 | `doc/kb/业务知识库/账户/通知/模块概览.md` | +| 09 | 业务流程索引 | `doc/kb/业务知识库/账户/通知/业务流程索引.md` | +| 10 | 业务流程详解 | `doc/kb/业务知识库/账户/通知/业务流程详解.md` | +| 12 | API索引 | `doc/kb/技术知识库/账户/通知/API索引.md` | +| 13 | 组件列表 | `doc/kb/技术知识库/账户/通知/组件列表.md` | + +## 关键发现 + +- **无 Tab 结构**:通知页面为纯列表 + 详情弹窗,无 Tab 子任务拆分 +- **3 个 API**:`getInforms`(分页列表)、`readInform`(标记已读)、`getUnreadCount`(未读轮询,Layout 层调用) +- **2 个本地组件**:`InformTable`(页面)、`NotificationDetailsModal`(弹窗) +- **跨模块依赖**:AccountContainer(容器)、usePagination/EmptyTip/MyTag/MyModal/Markdown(共享组件和 Hooks) +- **条件文档(11/14/15/16)**:均跳过 — 模块无共享能力、无自定义 Store、无自定义 Hooks、NotificationDetailsModal 仅内部使用 + +```json +{"label":"通知","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok"} +``` diff --git "a/doc/kb/.kb-temp/logs/\351\205\215\347\275\256.jsonl" "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256.jsonl" new file mode 100644 index 000000000000..d7a5520a5c36 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256.jsonl" @@ -0,0 +1,18 @@ +--- + +## ✅ 配置模块知识库文档生成完成 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `doc/kb/业务知识库/配置/模块概览.md` | ✅ 已生成 | +| 09-业务流程索引 | `doc/kb/业务知识库/配置/业务流程索引.md` | ✅ 已生成 | +| 12-API索引 | `doc/kb/技术知识库/配置/API索引.md` | ✅ 已生成 | +| 13-组件列表 | `doc/kb/技术知识库/配置/组件列表.md` | ✅ 已生成 | + +### 生成摘要 + +- **节点类型**:`is_grouping: true` 分组节点,生成子能力索引文档 +- **子能力**:工具配置首页(`/config/tool`)、工具市场(`/config/tool/marketplace`) +- **涉及角色**:系统管理员(`authSystemAdmin` 权限校验) +- **API 端点**:含系统工具管理、标签管理、工具包管理、市场代理共 16 个端点 +- **组件**:5 个页面组件 + 12 个共享依赖组件 diff --git "a/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.jsonl" "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.jsonl" new file mode 100644 index 000000000000..5f7bb98addb7 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.jsonl" @@ -0,0 +1,21 @@ +任务 `配置_工具配置` 已完成。 + +## 完成摘要 + +**分组节点**(`is_grouping: true`),为子能力"工具配置首页"和"工具市场"生成了 5 份索引文档: + +| 文档 | 路径 | 类型 | +|------|------|------| +| 模块概览 | `业务知识库/配置/工具配置/模块概览.md` | 必生成 | +| 业务流程索引 | `业务知识库/配置/工具配置/业务流程索引.md` | 必生成 | +| 业务流程详解 | `业务知识库/配置/工具配置/业务流程详解.md` | 必生成(分支二:子能力索引) | +| API索引 | `技术知识库/配置/工具配置/API索引.md` | 必生成 | +| 组件列表 | `技术知识库/配置/工具配置/组件列表.md` | 必生成 | + +**模块概况**: +- **工具配置首页**(`/config/tool`):系统工具管理台,支持拖拽排序、配置状态/默认安装/标签/密钥、导入插件 +- **工具市场**(`/config/tool/marketplace`):外部 Marketplace 浏览,支持搜索、标签筛选、安装/更新/卸载、批量更新 + +```json +{"label":"工具配置","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git "a/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.jsonl" "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.jsonl" new file mode 100644 index 000000000000..70a851933c1c --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.jsonl" @@ -0,0 +1,25 @@ +任务状态已为 `done`(可能已被调度器或其他会话更新)。 + +--- + +## 完成总结 + +**能力**: 工具市场(`配置_工具配置_工具市场`) + +**Tab 检测**: 无 Tab — 主页面为单一列表页,`ToolDetailDrawer` 和 `BatchUpdateDrawer` 内部的 `params/guide` 切换为内容展示模式,不拆分子能力。 + +**生成文档**: + +| 文档 | 路径 | +|------|------| +| 模块概览 | `doc/kb/业务知识库/配置/工具配置/工具市场/模块概览.md` | +| 业务流程索引 | `doc/kb/业务知识库/配置/工具配置/工具市场/业务流程索引.md` | +| 业务流程详解 | `doc/kb/业务知识库/配置/工具配置/工具市场/业务流程详解.md` | +| API索引 | `doc/kb/技术知识库/配置/工具配置/工具市场/API索引.md` | +| 组件列表 | `doc/kb/技术知识库/配置/工具配置/工具市场/组件列表.md` | + +**文档覆盖内容**: +- 7 个业务场景(浏览搜索、安装、卸载、更新、批量更新、查看详情、筛选未安装) +- 8 个 API 接口的完整调用链追踪 +- 6 张 Mermaid 流程图 +- 11 个消费的外部组件和 6 个外部 Hooks/工具 diff --git "a/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..7d4f79e8fbe7 --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.jsonl" @@ -0,0 +1,27 @@ +任务状态已为 `done`。所有工作完成。 + +--- + +## 执行总结 + +**能力**: 配置 → 工具配置 → 工具配置首页 (`/config/tool`) + +**Tab 检测**: 无 Tab —— 单页面列表组件,无 Tab 切换结构。 + +**已生成文档** (5/5): + +| 文档 | 位置 | +|------|------| +| 模块概览 | `doc/kb/业务知识库/配置/工具配置/工具配置首页/模块概览.md` | +| 业务流程索引 | `doc/kb/业务知识库/配置/工具配置/工具配置首页/业务流程索引.md` | +| 业务流程详解 | `doc/kb/业务知识库/配置/工具配置/工具配置首页/业务流程详解.md` | +| API索引 | `doc/kb/技术知识库/配置/工具配置/工具配置首页/API索引.md` | +| 组件列表 | `doc/kb/技术知识库/配置/工具配置/工具配置首页/组件列表.md` | + +**覆盖内容**: +- 8 个业务场景(查看列表、管理标签、添加资源、配置系统工具、配置工作流工具、排序、快捷切换、导入插件) +- 9 个 API 接口的完整调用链追踪(含参数、调用位置、场景映射) +- 13 个组件的路径、用途和跨模块依赖标注 +- 5 个 Mermaid 时序图覆盖核心场景 + +{"capability_label":"工具配置首页","docs_generated":["08","09","10","12","13"],"status":"ok"} diff --git "a/doc/kb/.kb-temp/logs/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.jsonl" "b/doc/kb/.kb-temp/logs/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.jsonl" new file mode 100644 index 000000000000..7cecfde14c1e --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.jsonl" @@ -0,0 +1,15 @@ +--- + +## ✅ "页面未找到" 文档生成完成 + +| 文档 | 路径 | 状态 | +|------|------|------| +| 08-模块概览 | `doc/kb/业务知识库/页面未找到/模块概览.md` | ✅ | +| 09-业务流程索引 | `doc/kb/业务知识库/页面未找到/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `doc/kb/业务知识库/页面未找到/业务流程详解.md` | ✅ | +| 12-API索引 | `doc/kb/技术知识库/页面未找到/API索引.md` | ✅ | +| 13-组件列表 | `doc/kb/技术知识库/页面未找到/组件列表.md` | ✅ | + +**跳过文档(按需判定)**:11/14/15/16 — 模块极简(仅一个无参数函数组件),无共享能力、无 Store、无共享组件、无自定义 Hooks。 + +**模块特征**:`404.tsx` 仅 13 行业务代码,挂载后 `useEffect` → `router.push('/dashboard/agent')` 静默重定向至工作台首页,无 Tab、无 API 调用、无角色区分。 diff --git "a/doc/kb/.kb-temp/logs/\351\246\226\351\241\265.jsonl" "b/doc/kb/.kb-temp/logs/\351\246\226\351\241\265.jsonl" new file mode 100644 index 000000000000..9313ee0a7efb --- /dev/null +++ "b/doc/kb/.kb-temp/logs/\351\246\226\351\241\265.jsonl" @@ -0,0 +1,22 @@ +✅ **首页 知识库文档生成完成** + +## 生成结果 + +| 文档 | 输出路径 | 状态 | +|------|---------|------| +| 08-模块概览 | `doc/kb/业务知识库/首页/模块概览.md` | ✅ | +| 09-业务流程索引 | `doc/kb/业务知识库/首页/业务流程索引.md` | ✅ | +| 10-业务流程详解 | `doc/kb/业务知识库/首页/业务流程详解.md` | ✅ | +| 12-API索引 | `doc/kb/技术知识库/首页/API索引.md` | ✅ | +| 13-组件列表 | `doc/kb/技术知识库/首页/组件列表.md` | ✅ | + +## 关键发现 + +- **首页是纯重定向页面**:访问 `/` 时立即执行 `router.push('/dashboard/agent')`,跳转到应用工作台 +- **无 Tab 结构**:React 组件仅包含一个 `useEffect` + `Loading` 组件,不涉及任何 Tab 面板 +- **无 API 调用**:页面自身不发起 HTTP 请求,API 请求由目标页面自行管理 +- **无按需文档**:11(共享能力清单)、14(Store数据流)、15(共享组件清单)、16(Hooks工具函数)均不适用 + +```json +{"label":"首页","docs_generated":["08","09","10","12","13"],"tab_files":[],"status":"ok","error":null} +``` diff --git a/doc/kb/.kb-temp/pre/capability-tree-base.json b/doc/kb/.kb-temp/pre/capability-tree-base.json new file mode 100644 index 000000000000..e0b2f653efec --- /dev/null +++ b/doc/kb/.kb-temp/pre/capability-tree-base.json @@ -0,0 +1,756 @@ +{ + "capability_tree": { + "children": [ + { + "label": "首页", + "entry_files": [ + "projects/app/src/pages/index.tsx" + ], + "router_paths": [ + "/" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "页面未找到", + "entry_files": [ + "projects/app/src/pages/404.tsx" + ], + "router_paths": [ + "/404" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "OpenAPI 文档", + "entry_files": [ + "projects/app/src/pages/openapi.tsx" + ], + "router_paths": [ + "/openapi" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "对话", + "entry_files": [ + "projects/app/src/pages/chat/index.tsx" + ], + "router_paths": [ + "/chat" + ], + "children": [ + { + "label": "分享对话", + "entry_files": [ + "projects/app/src/pages/chat/share.tsx" + ], + "router_paths": [ + "/chat/share" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "应用", + "entry_files": [], + "router_paths": [ + "/app" + ], + "children": [ + { + "label": "应用详情", + "entry_files": [ + "projects/app/src/pages/app/detail/index.tsx" + ], + "router_paths": [ + "/app/detail" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "登录", + "entry_files": [ + "projects/app/src/pages/login/index.tsx" + ], + "router_paths": [ + "/login" + ], + "children": [ + { + "label": "快速登录", + "entry_files": [ + "projects/app/src/pages/login/fastlogin.tsx" + ], + "router_paths": [ + "/login/fastlogin" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "第三方登录", + "entry_files": [ + "projects/app/src/pages/login/provider.tsx" + ], + "router_paths": [ + "/login/provider" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "账户", + "entry_files": [], + "router_paths": [ + "/account" + ], + "children": [ + { + "label": "API 密钥", + "entry_files": [ + "projects/app/src/pages/account/apikey.tsx" + ], + "router_paths": [ + "/account/apikey" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "通知", + "entry_files": [ + "projects/app/src/pages/account/inform.tsx" + ], + "router_paths": [ + "/account/inform" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "个性化设置", + "entry_files": [ + "projects/app/src/pages/account/setting.tsx" + ], + "router_paths": [ + "/account/setting" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "推广", + "entry_files": [ + "projects/app/src/pages/account/promotion.tsx" + ], + "router_paths": [ + "/account/promotion" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "账单", + "entry_files": [ + "projects/app/src/pages/account/bill/index.tsx" + ], + "router_paths": [ + "/account/bill" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "自定义域名", + "entry_files": [ + "projects/app/src/pages/account/customDomain/index.tsx" + ], + "router_paths": [ + "/account/customDomain" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "账号信息", + "entry_files": [ + "projects/app/src/pages/account/info/index.tsx" + ], + "router_paths": [ + "/account/info" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "模型管理", + "entry_files": [ + "projects/app/src/pages/account/model/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "团队管理", + "entry_files": [ + "projects/app/src/pages/account/team/index.tsx" + ], + "router_paths": [ + "/account/team" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "第三方集成", + "entry_files": [ + "projects/app/src/pages/account/thirdParty/index.tsx" + ], + "router_paths": [ + "/account/thirdParty" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "用量统计", + "entry_files": [ + "projects/app/src/pages/account/usage/index.tsx" + ], + "router_paths": [ + "/account/usage" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "工作台", + "entry_files": [], + "router_paths": [ + "/dashboard" + ], + "children": [ + { + "label": "应用工作台", + "entry_files": [ + "projects/app/src/pages/dashboard/agent/index.tsx" + ], + "router_paths": [ + "/dashboard/agent" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "创建应用", + "entry_files": [ + "projects/app/src/pages/dashboard/create/index.tsx" + ], + "router_paths": [ + "/dashboard/create" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "评测", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation" + ], + "children": [ + { + "label": "创建评测", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/create.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/create" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "评测数据集", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset" + ], + "children": [ + { + "label": "文件导入", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset/fileImport" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "评测数据集详情", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset/detail" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "评测维度", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension" + ], + "children": [ + { + "label": "创建维度", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/create.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension/create" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "编辑维度", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension/edit" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "评测任务", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/task/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/task" + ], + "children": [ + { + "label": "任务详情", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/task/detail" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "MCP 服务器", + "entry_files": [ + "projects/app/src/pages/dashboard/mcpServer/index.tsx" + ], + "router_paths": [ + "/dashboard/mcpServer" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "技能管理", + "entry_files": [ + "projects/app/src/pages/dashboard/skill/index.tsx" + ], + "router_paths": [ + "/dashboard/skill" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "系统工具", + "entry_files": [ + "projects/app/src/pages/dashboard/systemTool/index.tsx" + ], + "router_paths": [ + "/dashboard/systemTool" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "模板市场", + "entry_files": [ + "projects/app/src/pages/dashboard/templateMarket/index.tsx" + ], + "router_paths": [ + "/dashboard/templateMarket" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "工具管理", + "entry_files": [ + "projects/app/src/pages/dashboard/tool/index.tsx" + ], + "router_paths": [ + "/dashboard/tool" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "数据集", + "entry_files": [], + "router_paths": [ + "/dataset" + ], + "children": [ + { + "label": "数据集列表", + "entry_files": [ + "projects/app/src/pages/dataset/list/index.tsx" + ], + "router_paths": [ + "/dataset/list" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "数据集详情", + "entry_files": [ + "projects/app/src/pages/dataset/detail/index.tsx" + ], + "router_paths": [ + "/dataset/detail" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "配置", + "entry_files": [], + "router_paths": [ + "/config" + ], + "children": [ + { + "label": "工具配置", + "entry_files": [ + "projects/app/src/pages/config/tool/index.tsx" + ], + "router_paths": [ + "/config/tool" + ], + "children": [ + { + "label": "工具市场", + "entry_files": [ + "projects/app/src/pages/config/tool/marketplace.tsx" + ], + "router_paths": [ + "/config/tool/marketplace" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "价格", + "entry_files": [ + "projects/app/src/pages/price/index.tsx" + ], + "router_paths": [ + "/price" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + }, + { + "label": "技能", + "entry_files": [], + "router_paths": [ + "/skill" + ], + "children": [ + { + "label": "技能详情", + "entry_files": [ + "projects/app/src/pages/skill/detail.tsx" + ], + "router_paths": [ + "/skill/detail" + ], + "children": [], + "parent_signal": { + "source": "route", + "detail": "leaf route, component != null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ], + "parent_signal": { + "source": "route", + "detail": "grouping node, component = null" + }, + "entry": "projects/app/src/pages/_app.tsx" + } + ] + }, + "_stats": { + "total_nodes": 49, + "leaf_nodes": 36, + "max_depth": 4, + "leaf_routes_in_table": 43, + "coverage_pct": 83.7, + "depth_distribution": { + "1": 12, + "2": 27, + "3": 5, + "4": 5 + }, + "orphan_count": 0 + } +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/pre/route-files.txt b/doc/kb/.kb-temp/pre/route-files.txt new file mode 100644 index 000000000000..ff13167c5b0d --- /dev/null +++ b/doc/kb/.kb-temp/pre/route-files.txt @@ -0,0 +1,44 @@ +projects/app/src/pages/_app.tsx +projects/app/src/pages/index.tsx +projects/app/src/pages/404.tsx +projects/app/src/pages/openapi.tsx +projects/app/src/pages/chat/index.tsx +projects/app/src/pages/chat/share.tsx +projects/app/src/pages/app/detail/index.tsx +projects/app/src/pages/login/index.tsx +projects/app/src/pages/login/fastlogin.tsx +projects/app/src/pages/login/provider.tsx +projects/app/src/pages/account/apikey.tsx +projects/app/src/pages/account/inform.tsx +projects/app/src/pages/account/setting.tsx +projects/app/src/pages/account/promotion.tsx +projects/app/src/pages/account/bill/index.tsx +projects/app/src/pages/account/customDomain/index.tsx +projects/app/src/pages/account/info/index.tsx +projects/app/src/pages/account/model/index.tsx +projects/app/src/pages/account/team/index.tsx +projects/app/src/pages/account/thirdParty/index.tsx +projects/app/src/pages/account/usage/index.tsx +projects/app/src/pages/dashboard/agent/index.tsx +projects/app/src/pages/dashboard/create/index.tsx +projects/app/src/pages/dashboard/evaluation/index.tsx +projects/app/src/pages/dashboard/evaluation/create.tsx +projects/app/src/pages/dashboard/evaluation/dataset/index.tsx +projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx +projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx +projects/app/src/pages/dashboard/evaluation/dimension/index.tsx +projects/app/src/pages/dashboard/evaluation/dimension/create.tsx +projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx +projects/app/src/pages/dashboard/evaluation/task/index.tsx +projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx +projects/app/src/pages/dashboard/mcpServer/index.tsx +projects/app/src/pages/dashboard/skill/index.tsx +projects/app/src/pages/dashboard/systemTool/index.tsx +projects/app/src/pages/dashboard/templateMarket/index.tsx +projects/app/src/pages/dashboard/tool/index.tsx +projects/app/src/pages/dataset/list/index.tsx +projects/app/src/pages/dataset/detail/index.tsx +projects/app/src/pages/config/tool/index.tsx +projects/app/src/pages/config/tool/marketplace.tsx +projects/app/src/pages/price/index.tsx +projects/app/src/pages/skill/detail.tsx diff --git a/doc/kb/.kb-temp/pre/router-file-map.json b/doc/kb/.kb-temp/pre/router-file-map.json new file mode 100644 index 000000000000..7329d4718410 --- /dev/null +++ b/doc/kb/.kb-temp/pre/router-file-map.json @@ -0,0 +1,47 @@ +{ + "projects/app/src/pages/_app.tsx": [ + "projects/app/src/pages/index.tsx", + "projects/app/src/pages/404.tsx", + "projects/app/src/pages/openapi.tsx", + "projects/app/src/pages/chat/index.tsx", + "projects/app/src/pages/chat/share.tsx", + "projects/app/src/pages/app/detail/index.tsx", + "projects/app/src/pages/login/index.tsx", + "projects/app/src/pages/login/fastlogin.tsx", + "projects/app/src/pages/login/provider.tsx", + "projects/app/src/pages/account/apikey.tsx", + "projects/app/src/pages/account/inform.tsx", + "projects/app/src/pages/account/setting.tsx", + "projects/app/src/pages/account/promotion.tsx", + "projects/app/src/pages/account/bill/index.tsx", + "projects/app/src/pages/account/customDomain/index.tsx", + "projects/app/src/pages/account/info/index.tsx", + "projects/app/src/pages/account/model/index.tsx", + "projects/app/src/pages/account/team/index.tsx", + "projects/app/src/pages/account/thirdParty/index.tsx", + "projects/app/src/pages/account/usage/index.tsx", + "projects/app/src/pages/dashboard/agent/index.tsx", + "projects/app/src/pages/dashboard/create/index.tsx", + "projects/app/src/pages/dashboard/evaluation/index.tsx", + "projects/app/src/pages/dashboard/evaluation/create.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx", + "projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/create.tsx", + "projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx", + "projects/app/src/pages/dashboard/evaluation/task/index.tsx", + "projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx", + "projects/app/src/pages/dashboard/mcpServer/index.tsx", + "projects/app/src/pages/dashboard/skill/index.tsx", + "projects/app/src/pages/dashboard/systemTool/index.tsx", + "projects/app/src/pages/dashboard/templateMarket/index.tsx", + "projects/app/src/pages/dashboard/tool/index.tsx", + "projects/app/src/pages/dataset/list/index.tsx", + "projects/app/src/pages/dataset/detail/index.tsx", + "projects/app/src/pages/config/tool/index.tsx", + "projects/app/src/pages/config/tool/marketplace.tsx", + "projects/app/src/pages/price/index.tsx", + "projects/app/src/pages/skill/detail.tsx" + ] +} diff --git a/doc/kb/.kb-temp/pre/routing-architecture.md b/doc/kb/.kb-temp/pre/routing-architecture.md new file mode 100644 index 000000000000..641fea78e50f --- /dev/null +++ b/doc/kb/.kb-temp/pre/routing-architecture.md @@ -0,0 +1,31 @@ +# 路由架构摘要 + +## 项目路由架构 + +本项目使用 **Next.js Pages Router(文件系统路由)**,路由由 `pages/` 目录下的文件结构自动生成,无需显式路由配置文件。 + +### 路由器实例 +项目中存在 **1 个** Next.js 内置路由器实例,通过 `pages/_app.tsx` 统一管理。使用 `next/router` 的 `useRouter` hook 进行客户端导航。 + +### 路由定义位置 +所有前端页面路由通过 `projects/app/src/pages/` 目录下的文件结构定义。API 路由位于 `projects/app/src/pages/api/` 目录下,不纳入前端路由表。 + +### 路由注册方式 +Next.js 文件系统路由自动注册: +- `pages/index.tsx` → `/` +- `pages/xxx.tsx` → `/xxx` +- `pages/xxx/index.tsx` → `/xxx` +- `pages/xxx/yyy.tsx` → `/xxx/yyy` + +### 路由守卫/中间件 +- `/` 路由重定向至 `/dashboard/agent`(需要登录) +- 部分路由无需 Layout(`/openapi`) +- 登录成功后根据 `lastRoute` 参数重定向 + +### 嵌套关系 +通过目录层级形成嵌套,深度最大 4 层(如 `/dashboard/evaluation/task/detail`)。中间目录(无 `index.tsx` 的目录)作为分组节点,`component` 为 `null`。 + +### Route Name +项目不使用命名路由,导航通过路径字符串进行(`router.push('/dashboard/agent')` 等)。 + +router_entry_files: ["projects/app/src/pages/_app.tsx"] diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-OpenAPI \346\226\207\346\241\243.json" "b/doc/kb/.kb-temp/tasks/.kb-task-OpenAPI \346\226\207\346\241\243.json" new file mode 100644 index 000000000000..46a2ace47e95 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-OpenAPI \346\226\207\346\241\243.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "OpenAPI 文档", + "label_safe": "OpenAPI 文档", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/openapi.tsx" + ], + "router_paths": [ + "/openapi" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\OpenAPI 文档/", + "output_dir_tech": "doc\\kb\\技术知识库\\OpenAPI 文档/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:01:47Z", + "session_id": "baa993c7-51db-49e0-9390-c17ef5897dbd", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-finalize.json b/doc/kb/.kb-temp/tasks/.kb-task-finalize.json new file mode 100644 index 000000000000..f3ad5acf9eaa --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-finalize.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "业务能力地图 + 文档索引", + "label_safe": "finalize", + "type": "finalize", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:26:31Z", + "retry_count": 0, + "max_retries": 2, + "error": "", + "session_id": "6bccea47-a5d7-4624-a1e0-ca238886dc19", + "pid": 28372 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-00.json b/doc/kb/.kb-temp/tasks/.kb-task-root-00.json new file mode 100644 index 000000000000..f210234846e0 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-00.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "共享基础设施", + "label_safe": "root-00", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:26:31Z", + "session_id": "ae74f2dd-ce6f-4ba4-aba8-0ee3103bf95d", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28386 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-01.json b/doc/kb/.kb-temp/tasks/.kb-task-root-01.json new file mode 100644 index 000000000000..9fd90d6ed568 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-01.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "仓库概览", + "label_safe": "root-01", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:26:31Z", + "session_id": "2bd6b65b-4a43-4491-8f1a-cf617e09e2a0", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28400 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-02.json b/doc/kb/.kb-temp/tasks/.kb-task-root-02.json new file mode 100644 index 000000000000..4e3e40ce0dd9 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-02.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "API架构", + "label_safe": "root-02", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:26:32Z", + "session_id": "b6d770e9-06c5-4172-a873-26e89cadad04", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28414 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-03.json b/doc/kb/.kb-temp/tasks/.kb-task-root-03.json new file mode 100644 index 000000000000..5c92ce6be777 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-03.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "路由模型", + "label_safe": "root-03", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:26:50Z", + "session_id": "fa6e8e95-cc1e-4a35-b72d-9dca02304797", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28450 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-04.json b/doc/kb/.kb-temp/tasks/.kb-task-root-04.json new file mode 100644 index 000000000000..2c46f9ea1297 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-04.json @@ -0,0 +1,78 @@ +{ + "version": "1.0", + "label": "国际化策略", + "label_safe": "root-04", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:28:39Z", + "session_id": "5475be82-1db8-4ad1-bc27-4ba6374b2b56", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-05.json b/doc/kb/.kb-temp/tasks/.kb-task-root-05.json new file mode 100644 index 000000000000..f5854d6f436f --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-05.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "自动化测试", + "label_safe": "root-05", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:29:32Z", + "session_id": "2c790f4c-8ddc-4974-bc81-486afaf390fb", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 29082 +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-07.json b/doc/kb/.kb-temp/tasks/.kb-task-root-07.json new file mode 100644 index 000000000000..0ab89342390f --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-07.json @@ -0,0 +1,78 @@ +{ + "version": "1.0", + "label": "代码编写指南", + "label_safe": "root-07", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:29:44Z", + "session_id": "321c3cdd-cd73-4311-ad6a-46fe5be0b114", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git a/doc/kb/.kb-temp/tasks/.kb-task-root-17.json b/doc/kb/.kb-temp/tasks/.kb-task-root-17.json new file mode 100644 index 000000000000..977c5a071bf1 --- /dev/null +++ b/doc/kb/.kb-temp/tasks/.kb-task-root-17.json @@ -0,0 +1,79 @@ +{ + "version": "1.0", + "label": "本地开发指南", + "label_safe": "root-17", + "type": "root", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "", + "output_dir_tech": "", + "depends_on": [ + "首页", + "页面未找到", + "OpenAPI 文档", + "对话", + "对话_对话首页", + "对话_分享对话", + "应用", + "应用_应用详情", + "登录", + "登录_登录首页", + "登录_快速登录", + "登录_第三方登录", + "账户", + "账户_API 密钥", + "账户_通知", + "账户_个性化设置", + "账户_推广", + "账户_账单", + "账户_自定义域名", + "账户_账号信息", + "账户_模型管理", + "账户_团队管理", + "账户_第三方集成", + "账户_用量统计", + "工作台", + "工作台_应用工作台", + "工作台_创建应用", + "工作台_评测", + "工作台_评测_评测首页", + "工作台_评测_创建评测", + "工作台_评测_评测数据集", + "工作台_评测_评测数据集_评测数据集首页", + "工作台_评测_评测数据集_文件导入", + "工作台_评测_评测数据集_评测数据集详情", + "工作台_评测_评测维度", + "工作台_评测_评测维度_评测维度首页", + "工作台_评测_评测维度_创建维度", + "工作台_评测_评测维度_编辑维度", + "工作台_评测_评测任务", + "工作台_评测_评测任务_评测任务首页", + "工作台_评测_评测任务_任务详情", + "工作台_MCP 服务器", + "工作台_技能管理", + "工作台_系统工具", + "工作台_模板市场", + "工作台_工具管理", + "数据集", + "数据集_数据集列表", + "数据集_数据集详情", + "配置", + "配置_工具配置", + "配置_工具配置_工具配置首页", + "配置_工具配置_工具市场", + "价格", + "技能", + "技能_技能详情" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T11:30:50Z", + "session_id": "b7a92ba2-5c36-4b99-9e30-4f1850403c12", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 29212 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\344\273\267\346\240\274.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\344\273\267\346\240\274.json" new file mode 100644 index 000000000000..95e2cb75092b --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\344\273\267\346\240\274.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "价格", + "label_safe": "价格", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/price/index.tsx" + ], + "router_paths": [ + "/price" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\价格/", + "output_dir_tech": "doc\\kb\\技术知识库\\价格/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:01:47Z", + "session_id": "835f9fe7-0490-4156-89cb-5fb534c759ec", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235.json" new file mode 100644 index 000000000000..e1a8a726feb5 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "对话", + "label_safe": "对话", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\对话/", + "output_dir_tech": "doc\\kb\\技术知识库\\对话/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:01:48Z", + "session_id": "1c2d6c95-3bc4-4f50-8d14-e28f0c6103e9", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.json" new file mode 100644 index 000000000000..f4c7825eaf40 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\210\206\344\272\253\345\257\271\350\257\235.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "分享对话", + "label_safe": "对话_分享对话", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/chat/share.tsx" + ], + "router_paths": [ + "/chat/share" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "对话", + "output_dir_biz": "doc\\kb\\业务知识库\\对话\\分享对话/", + "output_dir_tech": "doc\\kb\\技术知识库\\对话\\分享对话/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:01:48Z", + "session_id": "bbabf3fe-662c-48f2-8ec1-33b502927d76", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": null +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..2aab90a5b186 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "对话首页", + "label_safe": "对话_对话首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/chat/index.tsx" + ], + "router_paths": [ + "/chat" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "对话", + "output_dir_biz": "doc\\kb\\业务知识库\\对话\\对话首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\对话\\对话首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:01:48Z", + "session_id": "c6e3cde1-3c45-444a-95b2-39fbab4a184c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 21246 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.json" new file mode 100644 index 000000000000..391ab483ed9c --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\345\233\242\351\230\237\345\272\224\347\224\250.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "团队应用", + "label_safe": "对话_对话首页_团队应用", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "对话首页", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/团队应用/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/团队应用/", + "depends_on": [ + "对话_对话首页" + ], + "claimed_at": "2026-06-18T10:04:37Z", + "session_id": "5edfb2f7-977e-419e-ac27-d9e943c2f93e", + "pid": 21502 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.json" new file mode 100644 index 000000000000..6ba9377df4a9 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\346\234\200\350\277\221\344\275\277\347\224\250.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "最近使用", + "label_safe": "对话_对话首页_最近使用", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "对话首页", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/最近使用/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/最近使用/", + "depends_on": [ + "对话_对话首页" + ], + "claimed_at": "2026-06-18T10:05:25Z", + "session_id": "3c95afb0-8379-4853-b3d6-518f4c528db9", + "pid": 21568 +} diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.json" new file mode 100644 index 000000000000..5dd9a6704167 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\347\262\276\351\200\211\345\272\224\347\224\250.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "精选应用", + "label_safe": "对话_对话首页_精选应用", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "对话首页", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/精选应用/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/精选应用/", + "depends_on": [ + "对话_对话首页" + ], + "claimed_at": "2026-06-18T10:06:15Z", + "session_id": "cd78e166-1883-4430-bd8a-e49fca9d3155", + "pid": 21608 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.json" new file mode 100644 index 000000000000..8a0e31cf0761 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "设置", + "label_safe": "对话_对话首页_设置", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatSetting/index.tsx" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "对话首页", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/设置/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/设置/", + "depends_on": [ + "对话_对话首页" + ], + "claimed_at": "2026-06-18T10:06:17Z", + "session_id": "dff8d62d-64c4-449b-bef6-db971abcaf03", + "pid": 21626 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.json" new file mode 100644 index 000000000000..819b2f44f14c --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\224\266\350\227\217\345\272\224\347\224\250.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "收藏应用", + "label_safe": "对话_对话首页_设置_收藏应用", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "设置", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/设置/收藏应用/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/设置/收藏应用/", + "depends_on": [ + "对话_对话首页_设置" + ], + "claimed_at": "2026-06-18T10:07:40Z", + "session_id": "05bf55f7-6722-45f3-9fd3-af8e0d20b136", + "pid": 21699 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.json" new file mode 100644 index 000000000000..8ef74061743a --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\225\260\346\215\256\347\234\213\346\235\277.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "数据看板", + "label_safe": "对话_对话首页_设置_数据看板", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatSetting/DataDashboard.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "设置", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/设置/数据看板/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/设置/数据看板/", + "depends_on": [ + "对话_对话首页_设置" + ], + "claimed_at": "2026-06-18T10:07:59Z", + "session_id": "0fd3279e-ddad-4486-98ce-e2dfddaf2dcc", + "pid": 21799 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..5a262ea5b394 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\346\227\245\345\277\227\350\257\246\346\203\205.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "日志详情", + "label_safe": "对话_对话首页_设置_日志详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "设置", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/设置/日志详情/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/设置/日志详情/", + "depends_on": [ + "对话_对话首页_设置" + ], + "claimed_at": "2026-06-18T10:08:47Z", + "session_id": "48b5cdf4-ab4e-4491-9282-6fbd425bb380", + "pid": 21834 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.json" new file mode 100644 index 000000000000..d310f9818bdf --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\350\256\276\347\275\256_\351\246\226\351\241\265\350\256\276\347\275\256.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "首页设置", + "label_safe": "对话_对话首页_设置_首页设置", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "设置", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/设置/首页设置/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/设置/首页设置/", + "depends_on": [ + "对话_对话首页_设置" + ], + "claimed_at": "2026-06-18T10:09:09Z", + "session_id": "6f40b908-e2c6-49c5-b2e3-c8390b21e56e", + "pid": 21853 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..ad97bb6d58c4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\257\271\350\257\235_\345\257\271\350\257\235\351\246\226\351\241\265_\351\246\226\351\241\265.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "首页", + "label_safe": "对话_对话首页_首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "对话首页", + "output_dir_biz": "doc/kb/业务知识库/对话/对话首页/首页/", + "output_dir_tech": "doc/kb/技术知识库/对话/对话首页/首页/", + "depends_on": [ + "对话_对话首页" + ], + "claimed_at": "2026-06-18T10:09:28Z", + "session_id": "bac8d011-7b6f-4ff8-875d-3ff66dbed44a", + "pid": 21885 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260.json" new file mode 100644 index 000000000000..70f83745d4a4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "工作台", + "label_safe": "工作台", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:12:27Z", + "session_id": "3a4bb507-ac0e-4116-a182-d1de2c113e1d", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22133 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.json" new file mode 100644 index 000000000000..993cb1f3cb6b --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_MCP \346\234\215\345\212\241\345\231\250.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "MCP 服务器", + "label_safe": "工作台_MCP 服务器", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/mcpServer/index.tsx" + ], + "router_paths": [ + "/dashboard/mcpServer" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\MCP 服务器/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\MCP 服务器/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:12:36Z", + "session_id": "e56b09fa-c2f2-43c4-937c-19046350a9ac", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22158 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.json" new file mode 100644 index 000000000000..d440a6aa41e5 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\210\233\345\273\272\345\272\224\347\224\250.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "创建应用", + "label_safe": "工作台_创建应用", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/create/index.tsx" + ], + "router_paths": [ + "/dashboard/create" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\创建应用/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\创建应用/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:12:50Z", + "session_id": "defafcd2-8f3d-457c-8464-9c5d1d6a5788", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22222 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..66717e79d7aa --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\267\245\345\205\267\347\256\241\347\220\206.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "工具管理", + "label_safe": "工作台_工具管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/tool/index.tsx" + ], + "router_paths": [ + "/dashboard/tool" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\工具管理/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\工具管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:13:17Z", + "session_id": "f9e34eb8-1b5d-44eb-b1f4-6afbb9fdd00c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22280 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.json" new file mode 100644 index 000000000000..28c54397b198 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "应用工作台", + "label_safe": "工作台_应用工作台", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/agent/index.tsx" + ], + "router_paths": [ + "/dashboard/agent" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\应用工作台/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\应用工作台/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:14:46Z", + "session_id": "7104bb2b-8ed1-48c4-8276-a5786bda4e1a", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22357 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..a5cd265f3135 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\212\200\350\203\275\347\256\241\347\220\206.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "技能管理", + "label_safe": "工作台_技能管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/skill/index.tsx" + ], + "router_paths": [ + "/dashboard/skill" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\技能管理/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\技能管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:16:08Z", + "session_id": "5c947821-fb6d-40f8-b6d3-7aadd6953a9c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22421 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.json" new file mode 100644 index 000000000000..c697bb88dbd4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\346\250\241\346\235\277\345\270\202\345\234\272.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "模板市场", + "label_safe": "工作台_模板市场", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/templateMarket/index.tsx" + ], + "router_paths": [ + "/dashboard/templateMarket" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\模板市场/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\模板市场/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:16:29Z", + "session_id": "1d9b2f81-b93b-4ce7-a546-43c41d214254", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22540 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.json" new file mode 100644 index 000000000000..487893a84b42 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\347\263\273\347\273\237\345\267\245\345\205\267.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "系统工具", + "label_safe": "工作台_系统工具", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/systemTool/index.tsx" + ], + "router_paths": [ + "/dashboard/systemTool" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\系统工具/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\系统工具/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:17:22Z", + "session_id": "cb748181-10fb-4eec-a492-fcb8485eec01", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22600 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.json" new file mode 100644 index 000000000000..fc560416e4b2 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "评测", + "label_safe": "工作台_评测", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "工作台", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:18:56Z", + "session_id": "2af2ec56-2178-468e-9d29-b49945921993", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22718 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.json" new file mode 100644 index 000000000000..ede190e1dfa2 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\345\210\233\345\273\272\350\257\204\346\265\213.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "创建评测", + "label_safe": "工作台_评测_创建评测", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/create.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/create" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\创建评测/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\创建评测/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:19:37Z", + "session_id": "c5284437-5699-481a-9570-91e02fb4698f", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22752 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.json" new file mode 100644 index 000000000000..9fd25e2e7074 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "评测任务", + "label_safe": "工作台_评测_评测任务", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "评测", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测任务/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测任务/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:19:39Z", + "session_id": "1374f7f8-b31d-41ca-b2fc-801a73ab05e5", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22770 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..301c29122e84 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\344\273\273\345\212\241\350\257\246\346\203\205.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "任务详情", + "label_safe": "工作台_评测_评测任务_任务详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/task/detail" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测任务", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测任务\\任务详情/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测任务\\任务详情/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:20:37Z", + "session_id": "bf03b093-ee35-4072-97eb-ea2232adb9cf", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22847 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..d2e9decd6c2f --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\344\273\273\345\212\241_\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测任务首页", + "label_safe": "工作台_评测_评测任务_评测任务首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/task/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/task" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测任务", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测任务\\评测任务首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测任务\\评测任务首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:20:55Z", + "session_id": "28c04ec5-23a9-4a4d-9a42-3950b5fbc7a2", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 22869 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" new file mode 100644 index 000000000000..95c261a46fcd --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "评测数据集", + "label_safe": "工作台_评测_评测数据集", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "评测", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测数据集/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测数据集/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:21:33Z", + "session_id": "a0325b4a-b758-4d14-9da5-a60cd511d99c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23018 +} diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.json" new file mode 100644 index 000000000000..f4c2a185140b --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\346\226\207\344\273\266\345\257\274\345\205\245.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "文件导入", + "label_safe": "工作台_评测_评测数据集_文件导入", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset/fileImport" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测数据集", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测数据集\\文件导入/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测数据集\\文件导入/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:22:23Z", + "session_id": "ea423a55-25ea-4239-afed-c32cd8f55ae2", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23090 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..a0c11894f547 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测数据集详情", + "label_safe": "工作台_评测_评测数据集_评测数据集详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset/detail" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测数据集", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测数据集\\评测数据集详情/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测数据集\\评测数据集详情/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:22:28Z", + "session_id": "803e815b-f0c4-4f94-8aa7-e07fc0803e91", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23109 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..17a7367807ef --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测数据集首页", + "label_safe": "工作台_评测_评测数据集_评测数据集首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dataset" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测数据集", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测数据集\\评测数据集首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测数据集\\评测数据集首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:24:53Z", + "session_id": "a582b9ce-0780-4694-8de9-cf1d141e1ac9", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23283 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.json" new file mode 100644 index 000000000000..7a6c9f3d5a4d --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "评测维度", + "label_safe": "工作台_评测_评测维度", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "评测", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测维度/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测维度/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:25:25Z", + "session_id": "88d31f48-31ec-4a2d-ae3e-5a3e6c7d8b3d", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23341 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.json" new file mode 100644 index 000000000000..a4d60364f1b6 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\345\210\233\345\273\272\347\273\264\345\272\246.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "创建维度", + "label_safe": "工作台_评测_评测维度_创建维度", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/create.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension/create" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测维度", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测维度\\创建维度/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测维度\\创建维度/", + "depends_on": [], + "claimed_by": "kb-init-frontend", + "claimed_at": "2026-06-18T10:25:47Z", + "session_id": "6ec4c28d-f0ec-41bf-80af-87f227bcdae7", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23374 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.json" new file mode 100644 index 000000000000..8f9512a232c4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\347\274\226\350\276\221\347\273\264\345\272\246.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "编辑维度", + "label_safe": "工作台_评测_评测维度_编辑维度", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension/edit" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测维度", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测维度\\编辑维度/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测维度\\编辑维度/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:26:14Z", + "session_id": "4fa1beca-a30e-44e8-9913-99ba94f39826", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23399 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..feff0fac0b5c --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\347\273\264\345\272\246_\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测维度首页", + "label_safe": "工作台_评测_评测维度_评测维度首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation/dimension" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测维度", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测维度\\评测维度首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测维度\\评测维度首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:26:16Z", + "session_id": "19257024-a228-4e69-a9e0-22e77db89f27", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23421 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..2b4b1da85de5 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测首页", + "label_safe": "工作台_评测_评测首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "评测", + "output_dir_biz": "doc\\kb\\业务知识库\\工作台\\评测\\评测首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\工作台\\评测\\评测首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:28:32Z", + "session_id": "01c03e9f-bdbd-4c79-9df2-a1282791d72e", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23622 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.json" new file mode 100644 index 000000000000..254273acc661 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\344\273\273\345\212\241.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测任务", + "label_safe": "工作台_评测_评测首页_评测任务", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/task/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation?evaluationTab=tasks" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测首页", + "output_dir_biz": "doc/kb/业务知识库/工作台/评测/评测首页/评测任务/", + "output_dir_tech": "doc/kb/技术知识库/工作台/评测/评测首页/评测任务/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:30:37Z", + "session_id": "fa24a441-9f7c-4bd4-8d16-d1bd9b972188", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23850 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" new file mode 100644 index 000000000000..58eebe31626a --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测数据集", + "label_safe": "工作台_评测_评测首页_评测数据集", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dataset/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation?evaluationTab=datasets" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测首页", + "output_dir_biz": "doc/kb/业务知识库/工作台/评测/评测首页/评测数据集/", + "output_dir_tech": "doc/kb/技术知识库/工作台/评测/评测首页/评测数据集/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:31:09Z", + "session_id": "e00146c9-1e9d-42eb-ae54-3d943b10cf0c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23896 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.json" new file mode 100644 index 000000000000..5ce59433d49d --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\267\245\344\275\234\345\217\260_\350\257\204\346\265\213_\350\257\204\346\265\213\351\246\226\351\241\265_\350\257\204\346\265\213\347\273\264\345\272\246.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "评测维度", + "label_safe": "工作台_评测_评测首页_评测维度", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dashboard/evaluation/dimension/index.tsx" + ], + "router_paths": [ + "/dashboard/evaluation?evaluationTab=dimensions" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "评测首页", + "output_dir_biz": "doc/kb/业务知识库/工作台/评测/评测首页/评测维度/", + "output_dir_tech": "doc/kb/技术知识库/工作台/评测/评测首页/评测维度/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:32:00Z", + "session_id": "d52aeb4b-42bd-4a50-a3e8-61096c07d132", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24060 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250.json" new file mode 100644 index 000000000000..17e4fa2f831b --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "应用", + "label_safe": "应用", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\应用/", + "output_dir_tech": "doc\\kb\\技术知识库\\应用/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:28:55Z", + "session_id": "fafa46e4-ef58-46bd-8cbc-2beb06cd5064", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23705 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..bcc20cd108d4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\345\272\224\347\224\250_\345\272\224\347\224\250\350\257\246\346\203\205.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "应用详情", + "label_safe": "应用_应用详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/app/detail/index.tsx" + ], + "router_paths": [ + "/app/detail" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "应用", + "output_dir_biz": "doc\\kb\\业务知识库\\应用\\应用详情/", + "output_dir_tech": "doc\\kb\\技术知识库\\应用\\应用详情/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:29:08Z", + "session_id": "2089b68b-731e-4b49-af08-dcbb1f672683", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 23728 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275.json" new file mode 100644 index 000000000000..5c8ae5de99ad --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "技能", + "label_safe": "技能", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\技能/", + "output_dir_tech": "doc\\kb\\技术知识库\\技能/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:32:16Z", + "session_id": "10cd8361-03d0-41bf-9470-dc66c36d6fd1", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..515b4550a046 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "技能详情", + "label_safe": "技能_技能详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/skill/detail.tsx" + ], + "router_paths": [ + "/skill/detail" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "技能", + "output_dir_biz": "doc\\kb\\业务知识库\\技能\\技能详情/", + "output_dir_tech": "doc\\kb\\技术知识库\\技能\\技能详情/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:32:48Z", + "session_id": "6864c0df-9d9d-4fad-95b6-1fd9a4967455", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24143 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.json" new file mode 100644 index 000000000000..549664d1841d --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_config.json" @@ -0,0 +1,24 @@ +{ + "version": "1.0", + "label": "config", + "label_safe": "技能_技能详情_config", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/index.tsx" + ], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "技能详情", + "output_dir_biz": "doc\\kb\\业务知识库\\技能\\技能详情\\config/", + "output_dir_tech": "doc\\kb\\技术知识库\\技能\\技能详情\\config/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:35:33Z", + "session_id": "9f93fb86-bf26-4f61-80e2-074e0cfa5bba", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24271 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.json" new file mode 100644 index 000000000000..09f2c951f271 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\212\200\350\203\275_\346\212\200\350\203\275\350\257\246\346\203\205_preview.json" @@ -0,0 +1,24 @@ +{ + "version": "1.0", + "label": "preview", + "label_safe": "技能_技能详情_preview", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dashboard/skill/detail/preview/SkillPreview.tsx" + ], + "router_paths": [], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "技能详情", + "output_dir_biz": "doc\\kb\\业务知识库\\技能\\技能详情\\preview/", + "output_dir_tech": "doc\\kb\\技术知识库\\技能\\技能详情\\preview/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:35:33Z", + "session_id": "7d93be78-5dd4-425a-b64c-450e0a2e0c50", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24285 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206.json" new file mode 100644 index 000000000000..e6b75577f206 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "数据集", + "label_safe": "数据集", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\数据集/", + "output_dir_tech": "doc\\kb\\技术知识库\\数据集/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:35:48Z", + "session_id": "cdbd01e1-9557-4053-8bc1-85daabeddfbb", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24307 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.json" new file mode 100644 index 000000000000..959328767a24 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "数据集列表", + "label_safe": "数据集_数据集列表", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dataset/list/index.tsx" + ], + "router_paths": [ + "/dataset/list" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集", + "output_dir_biz": "doc\\kb\\业务知识库\\数据集\\数据集列表/", + "output_dir_tech": "doc\\kb\\技术知识库\\数据集\\数据集列表/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:37:05Z", + "session_id": "ba28e24b-11c9-49d8-a91e-cf067794a7a5", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" new file mode 100644 index 000000000000..bd5f197e7ed2 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "数据集详情", + "label_safe": "数据集_数据集详情", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/dataset/detail/index.tsx" + ], + "router_paths": [ + "/dataset/detail" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "数据集", + "output_dir_biz": "doc\\kb\\业务知识库\\数据集\\数据集详情/", + "output_dir_tech": "doc\\kb\\技术知识库\\数据集\\数据集详情/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:37:34Z", + "session_id": "5e3d3432-d9a3-4211-bae5-90aef21cbf6a", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.json" new file mode 100644 index 000000000000..2d16cba43648 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_collectionCard.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "集合管理", + "label_safe": "数据集_数据集详情_collectionCard", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=collectionCard" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/集合管理/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/集合管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:39:35Z", + "session_id": "02f49190-6fc4-4481-92d8-e9fd169f0190", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.json" new file mode 100644 index 000000000000..6c00919a7b1a --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_dataCard.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "数据卡片", + "label_safe": "数据集_数据集详情_dataCard", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/RefinedDataCard/index.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=dataCard" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/数据卡片/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/数据卡片/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:39:36Z", + "session_id": "fc0d3f55-7574-4f2b-9d99-ab514b689826", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.json" new file mode 100644 index 000000000000..6ae7a4e07fce --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_fileDataCard.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "结构化数据卡片", + "label_safe": "数据集_数据集详情_fileDataCard", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/FileDataCard.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=fileDataCard" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/结构化数据卡片/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/结构化数据卡片/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:41:33Z", + "session_id": "8c7eeac1-9116-4e41-b0a3-dfd59222557c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24883 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.json" new file mode 100644 index 000000000000..acb3f7229e1e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "导入数据", + "label_safe": "数据集_数据集详情_import", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/Import/index.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=import" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/导入数据/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/导入数据/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:41:34Z", + "session_id": "8588c735-3aa6-49b4-b933-089078866710", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 24900 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.json.tmp" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_import.json.tmp" new file mode 100644 index 000000000000..e69de29bb2d1 diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.json" new file mode 100644 index 000000000000..57abc4145737 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_info.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "数据集配置", + "label_safe": "数据集_数据集详情_info", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/Info/index.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=info" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/数据集配置/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/数据集配置/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:44:40Z", + "session_id": "0952b0df-3c43-4135-960c-e7037fcc3964", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25014 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.json" new file mode 100644 index 000000000000..e8b19d9acea4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_synonym.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "同义词管理", + "label_safe": "数据集_数据集详情_synonym", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=synonym" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/同义词管理/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/同义词管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:44:50Z", + "session_id": "11e25f6f-bcd8-4c31-9a6b-94733e9e51a3", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25032 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.json" new file mode 100644 index 000000000000..53695f8c9a38 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\346\225\260\346\215\256\351\233\206_\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205_test.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "检索测试", + "label_safe": "数据集_数据集详情_test", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/dataset/detail/Test.tsx" + ], + "router_paths": [ + "/dataset/detail?currentTab=test" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "数据集详情", + "output_dir_biz": "doc/kb/业务知识库/数据集/数据集详情/检索测试/", + "output_dir_tech": "doc/kb/技术知识库/数据集/数据集详情/检索测试/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:44:53Z", + "session_id": "ffda3ca3-ccde-4037-b1e7-6ce5102577ed", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25091 +} diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225.json" new file mode 100644 index 000000000000..66ebb8ddb48e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225.json" @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "label": "登录", + "label_safe": "登录", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\登录/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:39:02Z", + "session_id": "d707be81-89e9-4a6a-a5cb-e286ac14394c", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.json" new file mode 100644 index 000000000000..cc57ad7b01df --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\345\277\253\351\200\237\347\231\273\345\275\225.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "快速登录", + "label_safe": "登录_快速登录", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/login/fastlogin.tsx" + ], + "router_paths": [ + "/login/fastlogin" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\快速登录/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\快速登录/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:47:00Z", + "session_id": "1cd48cd7-0953-4590-a2fe-0925f34bc944", + "retry_count": 0, + "max_retries": 2, + "error": "" +} diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..7bcd1482f12e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "登录首页", + "label_safe": "登录_登录首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/login/index.tsx" + ], + "router_paths": [ + "/login" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "登录", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\登录首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\登录首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:48:16Z", + "session_id": "f0ad5d82-4d5d-4cdf-bcd5-6de4340ab252", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25276 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.json" new file mode 100644 index 000000000000..bdffc0cb8dea --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\257\206\347\240\201\347\231\273\345\275\225.json" @@ -0,0 +1,19 @@ +{ + "version": "1.0", + "label": "密码登录", + "label_safe": "登录_登录首页_密码登录", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录首页", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\登录首页/密码登录/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\登录首页/密码登录/", + "depends_on": [], + "claimed_at": "2026-06-18T10:50:07Z", + "session_id": "06578b6e-b59e-4bb7-8585-4dfe859c36c0", + "pid": 25480 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.json" new file mode 100644 index 000000000000..fecb625fd609 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\276\256\344\277\241\347\231\273\345\275\225.json" @@ -0,0 +1,19 @@ +{ + "version": "1.0", + "label": "微信登录", + "label_safe": "登录_登录首页_微信登录", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录首页", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\登录首页/微信登录/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\登录首页/微信登录/", + "depends_on": [], + "claimed_at": "2026-06-18T10:50:15Z", + "session_id": "fbdbdca3-b591-4d91-bd21-08cf5ee1ed3c", + "pid": 25499 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.json" new file mode 100644 index 000000000000..60b705a2c159 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\345\277\230\350\256\260\345\257\206\347\240\201.json" @@ -0,0 +1,19 @@ +{ + "version": "1.0", + "label": "忘记密码", + "label_safe": "登录_登录首页_忘记密码", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/login/ForgetPasswordForm.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录首页", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\登录首页/忘记密码/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\登录首页/忘记密码/", + "depends_on": [], + "claimed_at": "2026-06-18T10:51:08Z", + "session_id": "be5da10f-6661-4d41-a585-cdc9d648cffc", + "pid": 25566 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.json" new file mode 100644 index 000000000000..19350d84c919 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\231\273\345\275\225\351\246\226\351\241\265_\346\263\250\345\206\214.json" @@ -0,0 +1,19 @@ +{ + "version": "1.0", + "label": "注册", + "label_safe": "登录_登录首页_注册", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/login/RegisterForm.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录首页", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\登录首页/注册/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\登录首页/注册/", + "depends_on": [], + "claimed_at": "2026-06-18T10:52:14Z", + "session_id": "10bfbbeb-8470-44e8-a7f5-cde28669d1c4", + "pid": 25718 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.json" new file mode 100644 index 000000000000..0839849cd089 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\347\231\273\345\275\225_\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "第三方登录", + "label_safe": "登录_第三方登录", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/login/provider.tsx" + ], + "router_paths": [ + "/login/provider" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "登录", + "output_dir_biz": "doc\\kb\\业务知识库\\登录\\第三方登录/", + "output_dir_tech": "doc\\kb\\技术知识库\\登录\\第三方登录/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:48:59Z", + "session_id": "a8b1bf72-47cc-46b6-881e-7c49fed522b4", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25363 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267.json" new file mode 100644 index 000000000000..af5e63f34bfb --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "账户", + "label_safe": "账户", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\账户/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:49:09Z", + "session_id": "78f8e5ed-c816-4a4d-bbba-da265aee946e", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25382 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_API \345\257\206\351\222\245.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_API \345\257\206\351\222\245.json" new file mode 100644 index 000000000000..c51a21f94438 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_API \345\257\206\351\222\245.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "API 密钥", + "label_safe": "账户_API 密钥", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/apikey.tsx" + ], + "router_paths": [ + "/account/apikey" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\API 密钥/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\API 密钥/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:53:06Z", + "session_id": "0c78ce90-cfea-4c1d-b739-bd021f90fce2", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25802 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.json" new file mode 100644 index 000000000000..fc0d0157797e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "个性化设置", + "label_safe": "账户_个性化设置", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/setting.tsx" + ], + "router_paths": [ + "/account/setting" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\个性化设置/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\个性化设置/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:53:31Z", + "session_id": "aed35d95-36f7-4f54-a6e3-c1fd3d4c18ec", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25827 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..1fd3d60fed24 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "团队管理", + "label_safe": "账户_团队管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/team/index.tsx" + ], + "router_paths": [ + "/account/team" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\团队管理/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\团队管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:53:43Z", + "session_id": "be3e5d22-e72d-4bf2-8259-f799b6682612", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25926 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.json" new file mode 100644 index 000000000000..ff55d44659e4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\210\206\347\273\204.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "分组", + "label_safe": "账户_团队管理_分组", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/team/GroupManage/index.tsx" + ], + "router_paths": [ + "/account/team?teamTab=group" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "团队管理", + "output_dir_biz": "doc/kb/业务知识库/账户/团队管理/分组/", + "output_dir_tech": "doc/kb/技术知识库/账户/团队管理/分组/", + "depends_on": [ + "账户_团队管理" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T10:55:30Z", + "session_id": "a98952f3-5bfa-449b-aeab-68d2590def71", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26088 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.json" new file mode 100644 index 000000000000..7032d46f6e7e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\345\256\241\350\256\241\346\227\245\345\277\227.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "审计日志", + "label_safe": "账户_团队管理_审计日志", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/team/Audit/index.tsx" + ], + "router_paths": [ + "/account/team?teamTab=audit" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "团队管理", + "output_dir_biz": "doc/kb/业务知识库/账户/团队管理/审计日志/", + "output_dir_tech": "doc/kb/技术知识库/账户/团队管理/审计日志/", + "depends_on": [ + "账户_团队管理" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T10:56:08Z", + "session_id": "1e66f4f8-2d80-4d40-821c-5aac13c6ba32", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26223 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.json" new file mode 100644 index 000000000000..581898c5a120 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\210\220\345\221\230.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "成员", + "label_safe": "账户_团队管理_成员", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/team/MemberTable.tsx" + ], + "router_paths": [ + "/account/team?teamTab=member" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "团队管理", + "output_dir_biz": "doc/kb/业务知识库/账户/团队管理/成员/", + "output_dir_tech": "doc/kb/技术知识库/账户/团队管理/成员/", + "depends_on": [ + "账户_团队管理" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T10:56:58Z", + "session_id": "13b4d52b-7f1e-458b-ae40-936dfe208e85", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26264 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..dd88a26f0a7d --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\346\235\203\351\231\220\347\256\241\347\220\206.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "权限管理", + "label_safe": "账户_团队管理_权限管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/team/PermissionManage/index.tsx" + ], + "router_paths": [ + "/account/team?teamTab=permission" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "团队管理", + "output_dir_biz": "doc/kb/业务知识库/账户/团队管理/权限管理/", + "output_dir_tech": "doc/kb/技术知识库/账户/团队管理/权限管理/", + "depends_on": [ + "账户_团队管理" + ], + "claimed_by": "kb-init", + "claimed_at": "2026-06-18T10:57:11Z", + "session_id": "ea154606-db70-4e8a-b6b6-4503f2f05e1f", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26286 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.json" new file mode 100644 index 000000000000..a027a48d4dce --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\345\233\242\351\230\237\347\256\241\347\220\206_\347\273\204\347\273\207.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "组织", + "label_safe": "账户_团队管理_组织", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/team/OrgManage/index.tsx" + ], + "router_paths": [ + "/account/team?teamTab=org" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "团队管理", + "output_dir_biz": "doc/kb/业务知识库/账户/团队管理/组织/", + "output_dir_tech": "doc/kb/技术知识库/账户/团队管理/组织/", + "depends_on": [ + "账户_团队管理" + ], + "claimed_by": "", + "claimed_at": "2026-06-18T10:57:43Z", + "session_id": "6369dec9-c4f6-476a-b199-7e852893633a", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26410 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\216\250\345\271\277.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\216\250\345\271\277.json" new file mode 100644 index 000000000000..3525b968d6c0 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\216\250\345\271\277.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "推广", + "label_safe": "账户_推广", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/promotion.tsx" + ], + "router_paths": [ + "/account/promotion" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\推广/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\推广/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:54:33Z", + "session_id": "e87b916b-5b56-43d6-b0ab-cdda3004e610", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 25965 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..503a75ed152c --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "模型管理", + "label_safe": "账户_模型管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/model/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T10:59:08Z", + "session_id": "905a481a-1931-43c5-be79-466462efc252", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26487 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.json" new file mode 100644 index 000000000000..02e244d1ca14 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\250\241\345\236\213\347\233\221\346\216\247.json" @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "label": "模型监控", + "label_safe": "账户_模型管理_模型监控", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "模型管理", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理\\模型监控/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理\\模型监控/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:02:12Z", + "session_id": "0fe04062-a4ba-4311-8ab6-5f43193ad3d4", + "retry_count": 0, + "max_retries": 2, + "error": "", + "tab_source": "MyTabs: account_model (account_model:monitoring), root only with show_aiproxy", + "cross_module": false, + "dynamic": false, + "pid": 26683 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.json" new file mode 100644 index 000000000000..a95e814fa565 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\264\273\350\267\203\346\250\241\345\236\213.json" @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "label": "活跃模型", + "label_safe": "账户_模型管理_活跃模型", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/components/core/ai/ModelTable/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "模型管理", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理\\活跃模型/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理\\活跃模型/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:02:22Z", + "session_id": "90d7e660-ca34-4c7a-922f-e6c984c70c60", + "retry_count": 0, + "max_retries": 2, + "error": "", + "tab_source": "MyTabs: model (active_model), always visible", + "cross_module": false, + "dynamic": false, + "pid": 26708 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.json" new file mode 100644 index 000000000000..eff58000d28c --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\346\227\245\345\277\227.json" @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "label": "渠道日志", + "label_safe": "账户_模型管理_渠道日志", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/model/Log/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "模型管理", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理\\渠道日志/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理\\渠道日志/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:02:41Z", + "session_id": "f38c9cc2-d246-4f5c-9193-f8a6f047e8b8", + "retry_count": 0, + "max_retries": 2, + "error": "", + "tab_source": "MyTabs: channel_log (account_model:log), root only with show_aiproxy", + "cross_module": false, + "dynamic": false, + "pid": 26727 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.json" new file mode 100644 index 000000000000..ca351f7bef98 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\346\270\240\351\201\223\347\256\241\347\220\206.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "渠道管理", + "label_safe": "账户_模型管理_渠道管理", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/model/Channel/index.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "模型管理", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理\\渠道管理/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理\\渠道管理/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:03:42Z", + "session_id": "0c4c60ab-e4b2-4280-a808-4de23e003149", + "retry_count": 0, + "max_retries": 2, + "error": "", + "tab_source": "MyTabs: channel (account:channel), root only with show_aiproxy", + "cross_module": false, + "dynamic": false +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.json" new file mode 100644 index 000000000000..66d07d0d5222 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\346\250\241\345\236\213\347\256\241\347\220\206_\351\205\215\347\275\256\346\250\241\345\236\213.json" @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "label": "配置模型", + "label_safe": "账户_模型管理_配置模型", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/model/ModelConfigTable.tsx" + ], + "router_paths": [ + "/account/model" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "模型管理", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\模型管理\\配置模型/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\模型管理\\配置模型/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:05:12Z", + "session_id": "d084c7cb-9fbc-4bcf-a433-294889369796", + "retry_count": 0, + "max_retries": 2, + "error": "", + "tab_source": "MyTabs: config (config_model), always visible", + "cross_module": false, + "dynamic": false +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.json" new file mode 100644 index 000000000000..25c5262d1988 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "用量统计", + "label_safe": "账户_用量统计", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/usage/index.tsx" + ], + "router_paths": [ + "/account/usage" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\用量统计/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\用量统计/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:00:11Z", + "session_id": "a8b3635c-8d9f-4e1c-8afc-975b133ba07e", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 26600 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.json" new file mode 100644 index 000000000000..5196fce885ec --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_dashboard.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "dashboard", + "label_safe": "账户_用量统计_dashboard", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/usage/Dashboard.tsx" + ], + "router_paths": [ + "/account/usage?usageTab=dashboard" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "用量统计", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\用量统计\\dashboard/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\用量统计\\dashboard/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:05:32Z", + "session_id": "3b924a9e-829d-413b-a9c2-4a63eb6b0e98", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.json" new file mode 100644 index 000000000000..b7bd71cb4232 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\224\250\351\207\217\347\273\237\350\256\241_detail.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "detail", + "label_safe": "账户_用量统计_detail", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/usage/UsageTable.tsx" + ], + "router_paths": [ + "/account/usage?usageTab=detail" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "用量统计", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\用量统计\\detail/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\用量统计\\detail/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:05:55Z", + "session_id": "6b0ba018-d341-4452-9378-82f9446e2383", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.json" new file mode 100644 index 000000000000..2aaf7f65c2ad --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "第三方集成", + "label_safe": "账户_第三方集成", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/thirdParty/index.tsx" + ], + "router_paths": [ + "/account/thirdParty" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\第三方集成/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\第三方集成/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:10:20Z", + "session_id": "f7542805-a554-4ffa-afb1-2026aa611044", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27244 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.json" new file mode 100644 index 000000000000..3053251820aa --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "自定义域名", + "label_safe": "账户_自定义域名", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/customDomain/index.tsx" + ], + "router_paths": [ + "/account/customDomain" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\自定义域名/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\自定义域名/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:10:36Z", + "session_id": "e13994f7-19d8-4d56-8761-7681a61bc8e8", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27267 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225.json" new file mode 100644 index 000000000000..ce6abddde46e --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "账单", + "label_safe": "账户_账单", + "type": "capability", + "status": "done", "session_id": "e61b8284-c949-45e4-a1de-784638fd6431", "claimed_at": "2026-06-18T11:11:29Z", + "entry_files": [ + "projects/app/src/pages/account/bill/index.tsx" + ], + "router_paths": [ + "/account/bill" + ], + "is_grouping": false, + "has_tab_children": true, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\账单/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\账单/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:11:29Z", + "session_id": "e61b8284-c949-45e4-a1de-784638fd6431", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27358 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.json" new file mode 100644 index 000000000000..ab4f1dc694bd --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "发票", + "label_safe": "账户_账单_发票", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/bill/InvoiceTable.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账单", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\账单\\发票/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\账单\\发票/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:19:08Z", + "session_id": "1dd87ecc-91c8-41c9-9692-156c77670ce6", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.json" new file mode 100644 index 000000000000..a1365b99ebca --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\345\217\221\347\245\250\346\212\254\345\244\264.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "发票抬头", + "label_safe": "账户_账单_发票抬头", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/bill/InvoiceHeaderForm.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账单", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\账单\\发票抬头/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\账单\\发票抬头/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:19:24Z", + "session_id": "a4461d61-2a89-46a2-9aeb-1b16ffa8b64b", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.json" new file mode 100644 index 000000000000..9ae3811e0476 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\215\225_\350\264\246\345\215\225\350\256\260\345\275\225.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "账单记录", + "label_safe": "账户_账单_账单记录", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pageComponents/account/bill/BillTable.tsx" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账单", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\账单\\账单记录/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\账单\\账单记录/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:19:25Z", + "session_id": "47e8b84f-1f87-43e7-8297-06981451ebe0", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.json" new file mode 100644 index 000000000000..b947232455eb --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\350\264\246\345\217\267\344\277\241\346\201\257.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "账号信息", + "label_safe": "账户_账号信息", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/info/index.tsx" + ], + "router_paths": [ + "/account/info" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\账号信息/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\账号信息/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:13:53Z", + "session_id": "c5353686-caf0-4762-9c80-78fc394a337c", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27461 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\351\200\232\347\237\245.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\351\200\232\347\237\245.json" new file mode 100644 index 000000000000..7b7f8d724dda --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\350\264\246\346\210\267_\351\200\232\347\237\245.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "通知", + "label_safe": "账户_通知", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/account/inform.tsx" + ], + "router_paths": [ + "/account/inform" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "账户", + "output_dir_biz": "doc\\kb\\业务知识库\\账户\\通知/", + "output_dir_tech": "doc\\kb\\技术知识库\\账户\\通知/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:14:05Z", + "session_id": "5bcc9306-dc21-403b-a414-461bcdfdf103", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27481 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256.json" new file mode 100644 index 000000000000..b50135a4bb1a --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "配置", + "label_safe": "配置", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\配置/", + "output_dir_tech": "doc\\kb\\技术知识库\\配置/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:19:43Z", + "session_id": "4af8c8b0-de2c-4696-bae0-7734dc349798", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27773 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.json" new file mode 100644 index 000000000000..09584a258d78 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256.json" @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "label": "工具配置", + "label_safe": "配置_工具配置", + "type": "capability", + "status": "done", + "entry_files": [], + "router_paths": [], + "is_grouping": true, + "has_tab_children": false, + "parent_label": "配置", + "output_dir_biz": "doc\\kb\\业务知识库\\配置\\工具配置/", + "output_dir_tech": "doc\\kb\\技术知识库\\配置\\工具配置/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:19:53Z", + "session_id": "ca5f03f5-651c-4a37-95d5-abac1184c649", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 27792 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.json" new file mode 100644 index 000000000000..e527449a1ae0 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\345\270\202\345\234\272.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "工具市场", + "label_safe": "配置_工具配置_工具市场", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/config/tool/marketplace.tsx" + ], + "router_paths": [ + "/config/tool/marketplace" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工具配置", + "output_dir_biz": "doc\\kb\\业务知识库\\配置\\工具配置\\工具市场/", + "output_dir_tech": "doc\\kb\\技术知识库\\配置\\工具配置\\工具市场/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:22:22Z", + "session_id": "a5f1affc-a54c-457a-8aef-09b12df091d1", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..6d010e6c2a5f --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256_\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265.json" @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "label": "工具配置首页", + "label_safe": "配置_工具配置_工具配置首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/config/tool/index.tsx" + ], + "router_paths": [ + "/config/tool" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "工具配置", + "output_dir_biz": "doc\\kb\\业务知识库\\配置\\工具配置\\工具配置首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\配置\\工具配置\\工具配置首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:22:29Z", + "session_id": "8bb5343d-c569-49c8-b653-9c87097ced4c", + "retry_count": 0, + "max_retries": 2, + "error": "" +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.json" new file mode 100644 index 000000000000..9d9f4ecea9f4 --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "页面未找到", + "label_safe": "页面未找到", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/404.tsx" + ], + "router_paths": [ + "/404" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\页面未找到/", + "output_dir_tech": "doc\\kb\\技术知识库\\页面未找到/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:23:15Z", + "session_id": "f1b2085a-8e80-42f2-8eee-12a44040dffc", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28085 +} \ No newline at end of file diff --git "a/doc/kb/.kb-temp/tasks/.kb-task-\351\246\226\351\241\265.json" "b/doc/kb/.kb-temp/tasks/.kb-task-\351\246\226\351\241\265.json" new file mode 100644 index 000000000000..454f6e37e0aa --- /dev/null +++ "b/doc/kb/.kb-temp/tasks/.kb-task-\351\246\226\351\241\265.json" @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "label": "首页", + "label_safe": "首页", + "type": "capability", + "status": "done", + "entry_files": [ + "projects/app/src/pages/index.tsx" + ], + "router_paths": [ + "/" + ], + "is_grouping": false, + "has_tab_children": false, + "parent_label": "", + "output_dir_biz": "doc\\kb\\业务知识库\\首页/", + "output_dir_tech": "doc\\kb\\技术知识库\\首页/", + "depends_on": [], + "claimed_by": "", + "claimed_at": "2026-06-18T11:23:33Z", + "session_id": "6640003f-2b23-4d70-88df-2baadd1f82de", + "retry_count": 0, + "max_retries": 2, + "error": "", + "pid": 28108 +} \ No newline at end of file diff --git "a/doc/kb/API\346\236\266\346\236\204.md" "b/doc/kb/API\346\236\266\346\236\204.md" new file mode 100644 index 000000000000..b1751ac57733 --- /dev/null +++ "b/doc/kb/API\346\236\266\346\236\204.md" @@ -0,0 +1,184 @@ +--- +capability_label: null +doc_type: "02" +doc_label: "API架构" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# API 架构 + +> 本文档描述项目的前端 HTTP 请求层架构,不涉及具体接口定义(接口定义见业务能力文档)。 + +## 请求层封装 + +### 请求模块概览 + +项目前端包含三套 HTTP 请求模块,分别服务于不同的后端目标: + +| 模块 | 核心文件 | 用途 | 底层库 | +|------|---------|------|--------| +| 主业务 API | `projects/app/src/web/common/api/request.ts` | FastGPT 后端 REST API | axios | +| Laf API | `projects/app/src/web/common/api/lafRequest.ts` | Laf 云函数调用 | axios | +| SSE 流式 | `projects/app/src/web/common/api/fetch.ts` | AI 对话流式响应、工作流 Copilot | @fortaine/fetch-event-source | + +### Axios 实例(主业务 API) + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| baseURL | `getWebReqUrl('/api')` | 通过 `NEXT_PUBLIC_BASE_URL` 环境变量动态注入子路由前缀 | +| timeout | 60000ms | 请求超时时间 | +| withCredentials | 按请求配置 | 由调用方通过 ConfigType 可选传入 | + +**关键文件**: `projects/app/src/web/common/api/request.ts` + +**封装模式**: 单例 axios 实例 + `GET`/`POST`/`PUT`/`DELETE` 四方法导出。底层调用 `instance.request()` 统一处理参数格式、取消信号和重复请求控制。 + +### Axios 实例(Laf API) + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| baseURL | `/api/lafApi` | 经 Next.js API 路由代理至 Laf 云函数 | +| timeout | 60000ms | 请求超时时间 | +| headers | `content-type: application/json` | 固定 Content-Type | + +**关键文件**: `projects/app/src/web/common/api/lafRequest.ts` + +### 请求拦截器 + +**主业务 API**: +- `startInterceptors`: 当前为空实现(占位),不做额外注入 + +**Laf API**: +- `startInterceptors`: 从 `useUserStore` 读取 `lafAccount.token`,注入 `Authorization: Bearer ` 请求头 +- 公共参数处理:请求发送前自动删除值为 `null` 或 `undefined` 的参数键(与主业务 API 仅删除 `undefined` 不同) + +### 响应拦截器 + +**主业务 API**: +- `responseSuccess`: 透传响应,不做额外处理 +- `responseError`: 触发统一错误处理流程(详见错误处理机制) +- `checkRes`: 校验 `response.data.code` 是否在 200-399 范围,提取 `data.data` 作为返回值 + +**Laf API**: +- `responseSuccess`: 透传响应 +- `responseError`: 触发 Laf 专用错误处理(含 401 自动 token 刷新重试) +- `checkRes`: 校验 `res.error` 字段,无错误则提取 `res.data` + +### SSE 流式请求 + +**关键文件**: `projects/app/src/web/common/api/fetch.ts` + +基于 `@fortaine/fetch-event-source` 库实现 Server-Sent Events 消费。核心函数: + +| 函数 | 用途 | +|------|------| +| `streamFetch` | 发起 AI 对话流式请求(`/api/v2/chat/completions`) | +| `streamResumeFetch` | 恢复中断的流式会话(`/api/core/chat/resume`) | +| `onOptimizePrompt` | Prompt 优化流式请求 | +| `onOptimizeCode` | 代码优化流式请求 | +| `onWorkflowCopilot` | 工作流 Copilot 流式请求 | + +SSE 内部使用 `requestAnimationFrame` 驱动的响应队列(`animateResponseLoop`)控制渲染节奏,队列积压时批量消费(每次取 `Math.max(1, Math.round(queue.length / 30))` 条),避免高频 DOM 更新阻塞主线程。 + +### 取消重复请求 + +**主业务 API**: 基于 `maxQuantity` 参数的并发控制。同一 URL 最多允许 `maxQuantity` 个并行请求,超出时取消最早请求(FIFO 淘汰),通过 `AbortController` 实现。 + +**Laf API**: 简化版并发控制,同一 URL 仅跟踪请求计数,超出 `maxQuantity` 时直接 abort 当前请求并重置计数器。 + +SSE 流式请求通过外部传入的 `AbortController` 控制取消。 + +## 错误处理机制 + +### 错误分类 + +| 错误类型 | 判断条件 | 处理方式 | +|---------|---------|---------| +| 业务错误 | `code < 200` 或 `code >= 400` | Promise reject,由调用方处理 | +| Token 失效 | `data.code === 403`(`TOKEN_ERROR_CODE`) | 清空 token → 跳转登录页(外链/分享/聊天页除外) | +| 余额不足 | `data.statusText` 匹配 `TeamErrEnum` 中资源不足枚举 | 弹出资源不足模态框(外链页除外) | +| 服务端异常 | `data === undefined` | reject 提示"服务器异常" | +| 网络异常 | axios 网络错误 / timeout | 统一 `responseError` 处理,展示错误信息 | +| 未知错误 | `err` 为 falsy | reject 提示"未知错误" | + +### 错误码映射 + +未发现前端错误码映射表,后端返回的 `message` 字段直接展示给用户。特殊处理的错误码: +- `TOKEN_ERROR_CODE = { 403: "token 无效" }` → 触发登录跳转 +- `TeamErrEnum` 中 7 个资源不足枚举 → 触发余额不足模态框 + +### 页面例外保护 + +以下页面在 Token 失效和余额不足时**不触发**页面跳转或模态框(保护外链用户体验): +- `/chat/share` — 分享对话页 +- `/price` — 价格页 +- `/login` — 登录页 + +### Laf API 特殊错误处理 + +- **401 响应**: 自动使用存储的 PAT(Personal Access Token)换取新 token,成功后静默重试原始请求,失败则提示"登录凭证过期" +- **其他错误**: 直接 reject `err.response.data` + +### 浏览器全局错误日志 + +**关键文件**: `projects/app/src/web/common/utils/errorLogger.ts` + +独立的浏览器端错误日志收集器(非请求层),覆盖 `console.error`、`window.onerror`、`unhandledrejection`,保留最近 20 条错误记录。通过 `errorLogger.getLogs()` 查看。 + +## 公共参数 + +| 参数 | 来源 | 说明 | +|------|------|------| +| `NEXT_PUBLIC_BASE_URL`(子路由前缀) | 环境变量 | 通过 `getWebReqUrl()` 自动为所有 API 路径添加前缀 | +| `lafAccount.token` | Zustand Store (`useUserStore`) | Laf API 请求拦截器中自动注入 Bearer token | +| Date 对象序列化 | 请求体自动处理 | `request()` 中将 `Date` 实例转为 `dayjs(val).format()` 字符串 | +| `undefined` 值清理 | 请求体自动处理 | `request()` 中自动删除值为 `undefined` 的参数键 | +| `cTime` | 自动生成 | SSE 流式请求自动附加当前时间戳 | +| `detail: true, stream: true` | 硬编码 | SSE 流式请求固定参数 | + +## 环境切换 + +### Base URL 配置 + +| 环境 | base URL | 配置来源 | +|------|---------|---------| +| development | `/api`(Next.js 内部路由) | 无需独立 API 域名,Next.js API Routes 同源 | +| 子路径部署 | `{NEXT_PUBLIC_BASE_URL}/api` | `.env.local` 中 `NEXT_PUBLIC_BASE_URL=/fastai` | +| Laf API | `/api/lafApi` | Next.js API Routes 代理转发至 Laf 云函数 | + +`NEXT_PUBLIC_BASE_URL` 同时控制: +- Next.js `basePath`(`next.config.ts` 第 13 行) +- 前端 API 请求路径前缀(`getWebReqUrl()` 函数) + +### 代理配置 + +项目使用 Next.js API Routes 作为 BFF 层,前端请求同源的 `/api/*` 路径由 Next.js 服务端处理并转发至后端微服务。不存在独立的 vite/webpack devServer proxy 配置。 + +### 环境变量(API 相关) + +| 变量 | 说明 | 示例 | +|------|------|------| +| `NEXT_PUBLIC_BASE_URL` | 子路由前缀,打包时确定 | `/fastai` | +| `FE_DOMAIN` | 前端页面域名,用于补全相对路径资源 | `http://localhost:3000` | +| `PRO_URL` | 商业版后端地址(服务端使用) | `http://127.0.0.1:3000` | + +## 禁项自检 + +- [x] 未包含业务源码片段(所有代码引用均为文件路径 + 行号范围的模式描述) +- [x] 未包含用户故事句式 +- [x] 未编造不存在的 API 定义 +- [x] 所有文件路径均来自实际代码探索 +- [x] 未包含构建配置内容(webpack/vite.config/tsconfig) +- [x] 未包含变更记录/Changelog +- [x] 未包含版本差异说明 +- [x] 未包含设计决策或演进历史 + +## 自检清单 + +- [x] frontmatter 字段齐全(capability_label=null, doc_type="02", doc_label="API架构", generated_at, parent_module=null, roles=[], router_paths=[]) +- [x] 每个代码模式引用都对应项目中的真实文件 +- [x] 环境切换的 base URL 从实际 .env 文件提取,非占位符 +- [x] 禁项自检通过 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f83deb01b0a8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,30 @@ +--- +capability_label: "OpenAPI 文档" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: ["/openapi"] +--- + +# OpenAPI 文档 — 业务流程索引 + +## API 文档查阅 + +- **业务描述**: 用户在浏览器中查看 FastGPT 平台的完整 API 参考文档,了解可用的 API 端点、请求参数、响应格式,并可进行交互式 API 测试。 +- **触发入口**: 浏览器直接访问 `/openapi` 路径,或通过外部文档链接进入。 +- **路由路径**: `/openapi` +- **页面组件位置**: `projects/app/src/pages/openapi.tsx` +- **涉及角色**: 所有用户(含未登录访客) +- **前置条件**: 无。页面为公开访问,无需登录或任何前置状态。 + +## 场景关系 + +本模块仅包含一个业务场景,无场景间的先后依赖关系。用户在访问页面后即进入 API 文档浏览状态,不涉及页面跳转或多步骤流转。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | API 文档查阅 | 浏览器直接访问 /openapi | /openapi | 所有用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..45b19b211150 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,64 @@ +--- +capability_label: "OpenAPI 文档" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: ["/openapi"] +--- + +# OpenAPI 文档 — 业务流程详解 + +## 页面总览 + +OpenAPI 文档页面是 FastGPT 的 API 参考文档查看器。页面使用 Scalar API Reference 组件渲染 FastGPT 的 OpenAPI 3.1.0 规范,将后端定义的所有 API 路径、标签分组、请求参数 Schema 和响应格式以交互式 UI 展示。用户无需登录即可查看全部 API 文档。 + +### API 文档查阅 + +> 用户在浏览器中查看 FastGPT 平台 API 的完整参考文档,浏览各分组下的 API 端点详情。 + +#### 步骤 1:页面加载与组件初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览器访问 `/openapi` 路径 | Next.js 服务端执行 `getServerSideProps`,返回空 props(禁用静态生成) | 无分支 | 服务端渲染阶段不加载 Scalar 组件 | +| 页面在客户端加载 | Next.js `dynamic()` 异步加载 `@scalar/api-reference-react` 及其 CSS | SSR 禁用(`ssr: false`),仅在客户端加载 | 加载期间页面空白;组件模块和样式加载完成后进入下一步 | +| Scalar 组件挂载 | `ApiReferenceReact` 组件内部请求 `GET /api/openapi.json`,获取 OpenAPI 规范 JSON | 无分支 | Scalar 组件解析 JSON 规范并渲染交互式 API 文档界面 | + +#### 步骤 2:浏览 API 文档 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览左侧标签分组列表(应用管理、Agent 应用、AI 相关、知识库等) | 无需额外 API(所有数据已在首次加载的 OpenAPI JSON 中) | 无分支 | 左侧导航切换选中分组,右侧展示对应分组下的 API 端点列表 | +| 展开某个 API 端点详情 | 无需额外 API | 无分支 | 展示该端点的 HTTP 方法、路径、请求参数、响应 Schema 及示例 | +| 在 Scalar 界面中发送测试请求 | Scalar 组件直接向对应 API 端点发起请求(需提供认证信息) | 需要有效的 API Key(Bearer token);未提供时 API 返回 401 | 展示请求结果(成功响应或错误信息) | + +#### 步骤 3:页面交互特性 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面深色/浅色模式 | 无需 API(深色模式切换按钮已通过 `hideDarkModeToggle: true` 配置隐藏) | 始终隐藏 | 无变化 | +| 客户端按钮 | 无需 API(客户端代码生成按钮已通过 `hideClientButton: true` 配置隐藏) | 始终隐藏 | 无变化 | +| 窗口大小调整 | 无需 API | 无分支 | Scalar 组件自适应窗口大小(页面容器 `w="100vw" h="100vh"`) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Page as /openapi 页面 + participant Scalar as Scalar API Reference + participant API as /api/openapi.json + + User->>Page: 访问 /openapi + Page->>Page: getServerSideProps (返回空 props) + Page->>Page: 客户端 dynamic() 加载 @scalar/api-reference-react + Page->>Page: 加载 Scalar CSS 样式 + Page->>Scalar: 挂载 ApiReferenceReact 组件 + Scalar->>API: GET /api/openapi.json + API-->>Scalar: 返回 OpenAPI 3.1.0 JSON 规范 + Scalar-->>User: 渲染交互式 API 文档界面 + User->>Scalar: 浏览标签分组、展开 API 端点 + Note over Scalar: 所有内容已在首次 JSON 中加载,无需额外请求 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..c8e06a81904c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,49 @@ +--- +capability_label: "OpenAPI 文档" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: ["/openapi"] +--- + +# OpenAPI 文档 — 模块概览 + +## 1. 模块业务概述 + +OpenAPI 文档模块为 FastGPT 平台的 API 使用者(开发者、集成方)提供了一个交互式 API 参考文档界面。该页面使用 Scalar API Reference 组件,以可视化方式展示 FastGPT 开放 API 的完整规范,包括请求方法、参数、响应格式和认证方式,支持开发者在浏览器中直接浏览和测试 API。 + +本模块属于平台的基础设施型页面,面向所有可访问平台的用户开放,无需登录即可查阅 API 文档。 + +## 2. 角色差异 + +本模块为公开文档页面,所有角色(含未登录用户)具有相同的访问权限:均可查看完整的 OpenAPI 规范文档。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有用户(含未登录) | OpenAPI 文档页 | 浏览 API 规范、展开/折叠接口详情、查看请求/响应示例 | 无角色差异,页面完全公开 | + +**权限模型**: 无权限校验。页面未调用 `getServerSideProps` 中的认证逻辑,直接渲染公开内容。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| @scalar/api-reference-react(外部依赖) | Scalar API Reference React 组件,提供交互式 API 文档渲染 | 组件 | +| @fastgpt/global/openapi | OpenAPI 规范文档定义 (`openAPIDocument`),包含所有 API 路径和 Schema | API | +| /api/openapi.json | 后端 API 端点,返回完整的 OpenAPI 3.1.0 JSON 规范 | API | + +## 4. 对外共享 + +本模块为终端展示页面,不向其他模块提供可消费的能力。无组件、API、Hook 或 Store 被其他模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| OpenAPI 文档 | /openapi | `projects/app/src/pages/openapi.tsx` | 所有用户 | 无 Tab,全页展示 Scalar API Reference 文档查看器 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..76a3a4e0fd17 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,648 @@ +--- +capability_label: null +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT 业务流程索引 + +## 一、流程索引总览 + +FastGPT 的主要业务流程分为以下类别: + +| 类别 | 流程数量 | 核心说明 | +|------|---------|---------| +| 用户和应用管理流程 | 3 | 注册认证、App 创建发布、版本管理 | +| 对话流程 | 2 | Web UI 对话、OpenAI 兼容 API 对话 | +| 知识库管理流程 | 2 | 知识库创建与数据处理、RAG 检索 | +| 工作流编排流程 | 2 | 工作流编排、工作流节点调度执行 | +| Agent 执行流程 | 2 | Agent 技能执行、工具调用循环 | +| 文件处理流程 | 1 | 文件上传和内容解析 | + +--- + +## 二、各业务流程简述 + +### 2.1 用户注册与认证流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 密码登录 | `POST /api/support/user/account/loginByPassword` (`projects/app/src/pages/api/support/user/account/loginByPassword.ts:23`) | +| Token登录 | `GET /api/support/user/account/tokenLogin` (`projects/app/src/pages/api/support/user/account/tokenLogin.ts:8`) | +| 登出 | `GET /api/support/user/account/loginout` (`projects/app/src/pages/api/support/user/account/loginout.ts`) | +| 修改密码 | `POST /api/support/user/account/updatePasswordByOld` (`projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts`) | + +**流程步骤** + +1. 用户通过用户名/邮箱/手机号注册账户,密码在 `UserSchema` 中通过 `hashStr` 自动哈希存储 +2. 用户登录后获取 JWT Token,后续所有接口通过 `authCert` 中间件验证身份 +3. 权限验证链:`authCert`(基础认证)→ `authUserPer`(用户权限)→ `authApp`/`authDataset`(资源权限) +4. API Key 认证:外部系统通过 API Key 直接调用 `/api/v1/chat/completions`,绕过用户登录态,由 `authOutLinkChatStart` 处理 +5. 共享链接认证:通过 `shareId + outLinkUid` 参数,由 `authShareChat` 处理匿名用户访问 + +**涉及的代码文件** + +- `FastGPT/packages/service/support/user/schema.ts` — 用户数据模型(密码哈希、状态管理) +- `FastGPT/packages/global/support/user/team/constant.ts` — 团队成员角色和权限枚举 +- `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` — 认证分支逻辑(共享/团队/API) + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 密码错误 | 401 | `loginByPassword` 中用户不存在或密码不匹配 | +| Token过期 | 401 | `authCert` 中 JWT Token 验证失败或已过期 | +| 用户被禁用 | 403 | `UserStatusEnum` 为禁用态时拒绝登录 | +| 验证码错误 | 400 | `authCode` 校验登录验证码失败 | + +--- + +### 2.2 应用创建和发布流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 创建App | `POST /api/core/app/create` (`projects/app/src/pages/api/core/app/create.ts:40`) | +| 更新App | `POST /api/core/app/update` (`projects/app/src/pages/api/core/app/update.ts`) | +| 发布版本 | `POST /api/core/app/version/publish` (`projects/app/src/pages/api/core/app/version/publish.ts`) | + +**流程步骤** + +1. **创建 App**:调用 `POST /api/core/app/create`,需要团队 `TeamAppCreatePermissionVal` 权限 +2. **权限校验**:检查是否有父文件夹写权限(`authApp`)或团队 App 创建权限(`authUserPer`) +3. **类型限制检查**:`checkTeamAppTypeLimit` 验证团队 App/工具数量是否超限 +4. **节点安全格式化**:`beforeUpdateAppFormat` 对工作流节点中的 Header 密钥和密码字段加密 +5. **数据库事务写入**:在 MongoDB Session 中原子化写入 `MongoApp` + `MongoAppVersion` + `MongoResourcePermission`(Owner 权限) +6. **发布版本**:工作流编辑完成后,调用版本发布接口创建 `MongoAppVersion`(`isPublish: true`),旧版本继续保留以支持回滚 +7. **获取最新版本**:`getAppLatestVersion` 优先查最新已发布版本,不存在时降级使用 App 当前 `modules` 字段(草稿态) + +**涉及关键 API 和服务** + +- `FastGPT/projects/app/src/pages/api/core/app/create.ts` — 创建 App 主逻辑(权限、类型限制、事务写入) +- `FastGPT/packages/service/core/app/controller.ts` — App 生命周期管理(节点格式化、清理关联资源) +- `FastGPT/packages/service/core/app/version/controller.ts` — 版本管理(`getAppLatestVersion`、`getAppVersionById`) +- `FastGPT/packages/service/core/app/schema.ts` — App 数据模型 +- `FastGPT/packages/service/core/app/version/schema.ts` — 版本数据模型 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 权限不足 | 403 | 用户缺少 `TeamAppCreatePermissionVal` 或无父文件夹 `WritePermissionVal` | +| App数量超限 | 403 | `checkTeamAppTypeLimit` 检测团队 App 或工具数量已达上限 | +| 父文件夹不存在 | 404 | `authApp` 查找父文件夹时 `appId` 无效 | +| 参数校验失败 | 400 | `CreateAppBodySchema.safeParseAsync` 解析 `req.body` 失败 | + +--- + +### 2.3 简单应用对话流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 对话请求(v1) | `POST /api/v1/chat/completions` (`projects/app/src/pages/api/v1/chat/completions.ts:70`) | + +**流程步骤** + +1. **前端发起请求**:用户在 Web UI 的 ChatBox 组件输入消息,触发 `POST /api/v1/chat/completions`(支持 SSE 流式) +2. **认证分支判断**: + - 共享链接访问 → `authShareChat`(`shareId + outLinkUid`) + - 团队空间访问 → `authTeamSpaceChat`(`teamId + teamToken`) + - API Key / 用户 Token 访问 → `authHeaderRequest` +3. **频率限制**:`teamFrequencyLimit` 检查团队 QPM 限制 +4. **获取历史消息**:`getChatItems` 从 MongoDB 查询历史对话,支持多种分页模式(offset / prevId / nextId / initialId) +5. **获取应用版本**:`getAppLatestVersion` 获取应用最新发布的工作流节点配置 +6. **工作流调度执行**:`dispatchWorkFlow` 根据节点类型调度执行(AI 对话节点、知识库检索节点等) +7. **LLM 调用**:工作流中的 AI 节点通过 `createLLMResponse` 调用大语言模型,屏蔽流式/非流式差异 +8. **流式响应返回**:通过 SSE(Server-Sent Events)将 AI 回复实时推送到客户端,事件类型由 `SseResponseEventEnum` 定义 +9. **保存对话记录**:`pushChatRecords` 将用户消息和 AI 回复持久化到 `MongoChatItem`,响应数据存入 `MongoChatItemResponse` +10. **用量记录**:`recordAppUsage` 记录 Token 用量,`updateApiKeyUsage` 更新 API Key 用量 + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` — 核心对话 API 入口(认证、调度、响应) +- `FastGPT/packages/service/core/chat/controller.ts` — 对话历史查询(`getChatItems`,支持反馈过滤、多分页模式) +- `FastGPT/packages/service/core/ai/llm/request.ts` — LLM 请求核心处理(流式/非流式统一封装、工具调用支持) +- `FastGPT/projects/app/src/components/core/chat/ChatContainer/ChatBox/` — 前端聊天框组件 +- `FastGPT/packages/service/core/chat/chatSchema.ts` — Chat 会话数据模型 +- `FastGPT/packages/service/core/chat/chatItemSchema.ts` — Chat 消息项数据模型 +- `FastGPT/packages/service/core/chat/chatItemResponseSchema.ts` (第47行) — Chat 消息响应数据模型(`MongoChatItemResponse`) + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 共享链接无效 | 401 | `authShareChat` 中 `shareId` 不存在或已过期 | +| QPM超限 | 429 | `teamFrequencyLimit` 检测团队每分钟请求数超限 | +| App不存在 | 404 | `authHeaderRequest` 中 `appId` 对应的 App 已被删除 | +| API Key无效 | 401 | `authHeaderRequest` 中 Authorization header 解析失败或 key 已注销 | +| LLM调用超时 | 500 | `createLLMResponse` 中 AI 模型响应超时或返回错误 | + +--- + +### 2.4 知识库创建和数据处理流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 创建知识库 | `POST /api/core/dataset/create` (`projects/app/src/pages/api/core/dataset/create.ts:36`) | +| 创建Collection | `POST /api/core/dataset/collection/create` (`projects/app/src/pages/api/core/dataset/collection/create.ts`) | +| 获取预签名URL | `POST /api/common/file/presignTempFilePostUrl` (`projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts`) | + +**流程步骤** + +1. **创建知识库**:调用 `POST /api/core/dataset/create`,在 `MongoDataset` 中创建知识库记录,配置 Embedding 模型和分块策略 +2. **文件上传**: + - 前端调用 `POST /api/common/file/presignTempFilePostUrl` 获取 S3 预签名 URL + - 客户端直接将文件上传到 S3(私有桶),绕过应用服务器,减少带宽消耗 + - 上传前检查团队 `maxUploadFileCount` 限制(30 秒滑动窗口频率限制) +3. **创建 Collection**:调用 `POST /api/core/dataset/collection/create`,在 `MongoDatasetCollection` 中创建集合记录,关联文件 S3 Key +4. **文档解析与分块**:后台 Worker 进程读取 S3 文件,调用解析服务(OCR、PDF 解析等)提取文本,按照分块策略(段落/固定大小)切割为 Chunk +5. **向量化(Embedding)**:每个 Chunk 通过 `getVectorsByText` 调用 Embedding 模型转换为向量,支持批量处理(`batchSize` 控制)和自动分批 +6. **存储到向量数据库**:向量和文本数据分别存入向量库(Milvus 或 PGVector)和 MongoDB,通过 `clientManager` 统一管理连接 +7. **Training 队列**:分块和向量化任务通过 `MongoDatasetTraining` 异步队列处理,支持失败重试 +8. **QA 问答对生成(可选)**:使用 LLM 将文档内容自动转换为问答对格式,提升检索精度 + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/core/dataset/create.ts` — 创建知识库 API +- `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` — 获取 S3 预签名上传 URL +- `FastGPT/packages/service/core/dataset/controller.ts` — 知识库 CRUD(`findDatasetAndAllChildren`、`expandFolderDatasetIds`) +- `FastGPT/packages/service/core/dataset/collection/controller.ts` — Collection 管理(创建、删除关联资源) +- `FastGPT/packages/service/core/ai/embedding/index.ts` — 文本向量化(`getVectorsByText`,支持批量和指令前缀) +- `FastGPT/packages/service/core/dataset/database/clientManager.ts` — 向量数据库客户端管理 +- `FastGPT/packages/service/core/dataset/schema.ts` — 知识库数据模型 +- `FastGPT/packages/service/core/dataset/data/schema.ts` — 知识数据项模型 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 知识库数量超限 | 403 | `checkTeamDatasetLimit` 检测团队知识库数量已达上限 | +| 文件大小超限 | 413 | 上传文件超过 `maxUploadFileSize` 限制 | +| S3预签名URL生成失败 | 500 | S3 服务不可用或凭证配置错误 | +| 文件上传频率超限 | 429 | 30秒滑动窗口内上传次数超过 `maxUploadFileCount` | +| 向量化失败重试耗尽 | 500 | `MongoDatasetTraining` 重试次数用完(`retryCount <= 0`)→ 状态标记为 error | + +--- + +### 2.5 知识库检索流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 检索执行 | `POST /api/core/dataset/search` (`projects/app/src/pages/api/core/dataset/searchTest.ts`) | + +**流程步骤** + +1. **查询向量化**:将用户提问文本通过 `getVectorsByText` 转换为查询向量 +2. **检索执行**(支持三种模式并行): + - **向量检索(语义检索)**:在向量数据库(Milvus/PGVector)中按余弦相似度检索 + - **全文检索**:基于 MongoDB Text Index 或 PostgreSQL 全文索引的关键词匹配 + - **混合检索**:同时执行向量和全文检索,通过 RRF(倒数排名融合)算法合并结果 +3. **集合标签过滤**:支持按 Collection 标签过滤检索范围,缩小检索集合 +4. **重排序(Rerank)**:检索结果通过 `reRankRecall` 调用重排序模型精排,返回 `relevance_score`,按分数截断低相关结果 +5. **Agentic Search(多轮检索)**:Agent 模式下 AI 可自主判断检索结果是否充分,决定是否追加检索或调整查询词 +6. **结果格式化**:检索结果附加引用来源元数据(Collection 名称、页码等),返回给 AI 节点作为上下文 + +**涉及的代码文件** + +- `FastGPT/packages/service/core/ai/embedding/index.ts` — 查询向量化(`getVectorsByText`) +- `FastGPT/packages/service/core/ai/rerank/index.ts` — 重排序(`reRankRecall`,支持指令前缀格式化) +- `FastGPT/packages/service/core/dataset/database/clientManager.ts` — 向量数据库检索执行 +- `FastGPT/packages/service/core/dataset/data/controller.ts` — 知识数据项格式化(`formatDatasetDataValue`,处理图片、S3 URL) +- `FastGPT/packages/service/core/dataset/collection/schema.ts` — Collection 元数据(标签、权限) + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 向量数据库连接失败 | 500 | Milvus/PGVector 服务不可达或 `clientManager` 连接池耗尽 | +| Embedding模型不可用 | 500 | `getVectorsByText` 中 Embedding 服务返回错误或超时 | +| 检索结果为空 | 200 | 正常返回空列表,由调用方决定是否降级处理 | +| Rerank模型调用失败 | 500 | `reRankRecall` 中重排序服务不可用,检索降级使用原始排序 | + +--- + +### 2.6 工作流编排和执行流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 工作流调试 | `POST /api/core/workflow/debug` (`projects/app/src/pages/api/core/workflow/debug.ts`) | +| 代码优化 | `POST /api/core/workflow/optimizeCode` (`projects/app/src/pages/api/core/workflow/optimizeCode.ts`) | + +**流程步骤** + +**编排阶段(前端)** + +1. 用户在可视化画布上拖拽节点(AI 对话、知识库检索、HTTP 请求、代码运行等),通过连线定义执行顺序 +2. 工作流以有向图(DAG)结构存储,节点数组(`modules`)+ 边数组(`edges`)持久化到 `MongoApp` + +**执行阶段(后端)** + +3. 调用 `dispatchWorkFlow` 加载运行时节点(`storeNodes2RuntimeNodes`、`storeEdges2RuntimeEdges`) +4. 根据节点类型(`FlowNodeTypeEnum`)动态路由到对应的 dispatch 处理器(如 `dispatchRunAgent`、`dispatchDatasetSearch` 等) +5. 节点按拓扑顺序执行,每个节点的输出通过边定义传递给下游节点作为输入 +6. 支持条件分支(IF-ELSE)、循环(Loop)、并行执行等复杂控制流 +7. 工作流最大执行次数受 `WORKFLOW_MAX_RUN_TIMES` 常量限制,防止无限循环 +8. 每个节点执行结果记录到 `DispatchNodeResponseKeyEnum.nodeResponse`,用于调试日志展示 + +**涉及的代码文件** + +- `FastGPT/packages/global/core/workflow/node/constant.ts` (第135行) — 工作流节点类型枚举定义(`FlowNodeTypeEnum`,含30+种节点类型) +- `FastGPT/packages/global/core/workflow/runtime/type.ts` — 工作流运行时类型(`RuntimeNodeItemType`、`NodeEdgeGroups`、`ChatDispatchProps` 等) +- `FastGPT/packages/global/core/workflow/runtime/utils.ts` — 运行时工具(`storeNodes2RuntimeNodes`、拓扑排序、变量引用替换) +- `FastGPT/packages/service/core/workflow/dispatch/index.ts` — 工作流主调度器(`dispatchWorkFlow`,节点DAG执行引擎) +- `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` — Agent 节点执行引擎 +- `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` — 工作流执行入口(通过 dispatchWorkFlow 调度) + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 工作流执行超限 | 500 | 执行次数超过 `WORKFLOW_MAX_RUN_TIMES`,防止无限循环 | +| 节点执行失败 | 500 | 各节点 dispatch 处理器中抛出异常(如 HTTP 请求失败、代码执行错误) | +| DAG循环依赖 | 400 | `classifyEdgesByDFS` 中 Tarjan 算法检测到 SCC 循环依赖 | +| 插件模块加载失败 | 500 | `appModule`/`pluginModule` 类型节点中外部插件不可用 | + +--- + +### 2.7 Agent 技能执行流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 创建技能 | `POST /api/core/agentSkills/create` (`projects/app/src/pages/api/core/agentSkills/create.ts`) | +| 调试技能 | `POST /api/core/agentSkills/debugChat` (`projects/app/src/pages/api/core/agentSkills/debugChat.ts`) | + +**技能创建和配置** + +1. 调用 `POST /api/core/agentSkills/create`,`createSkill` 写入 `MongoAgentSkills`(`source: personal`,`currentVersion: 0`) +2. 版本管理:技能通过 `MongoAgentSkillsVersion` 管理历史版本,发布新版本时上传技能包(ZIP)到 S3(`uploadSkillPackage`) +3. 沙箱部署:技能代码运行在隔离沙箱(Agent Sandbox)中,分为 `editDebug`(开发调试)和 `session-runtime`(生产运行)两种模式 + +**工具调用执行** + +4. **Agent 节点初始化**:`dispatchRunAgent` 根据 `AGENT_ENGINE` 环境变量选择执行引擎(默认 Plan+Step 模式,`pi` 值使用 PI-Agent) +5. **能力注册**:将已配置的技能和内置工具注册为工具列表(`SkillToolType[]`),通过 `createSandboxSkillsCapability` 创建沙箱技能能力 +6. **主工具调用循环**:`masterCall` 执行多轮工具调用推理循环(最大 `MAX_PLAN_ITERATIONS = 10` 轮) +7. **Plan 模式(可选)**:AI 先通过 `dispatchPlanAgent` 生成任务规划(Plan),再按阶段逐步执行,支持二次任务拆解 +8. **工具执行**:AI 决策调用哪些工具后,对应的 dispatch 处理器在沙箱内执行技能代码并返回结果 +9. **结果汇总**:所有工具调用结果汇总后,由 AI 生成最终回复 + +**涉及的代码文件** + +- `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` — Agent 节点主执行引擎(引擎选择、能力注册、Plan 循环) +- `FastGPT/packages/service/core/workflow/dispatch/ai/agent/master/call.ts` — 主工具调用推理循环(`masterCall`) +- `FastGPT/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts` — PI-Agent 执行逻辑 +- `FastGPT/packages/service/core/agentSkills/controller.ts` — Agent 技能 CRUD(`createSkill`、版本管理) +- `FastGPT/packages/service/core/ai/llm/agentCall/index.ts` — Agent 工具调用 LLM 入口 +- `FastGPT/projects/app/src/pages/api/core/agentSkills/create.ts` — 创建技能 API +- `FastGPT/projects/app/src/pages/api/core/agentSkills/debugChat.ts` — 调试技能 API + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 沙箱执行超时 | 500 | 技能代码在沙箱中执行超过最大允许时间 | +| 工具调用轮次耗尽 | 500 | `masterCall` 达到 `MAX_PLAN_ITERATIONS=10` 轮仍未完成任务 | +| Plan生成失败 | 500 | `dispatchPlanAgent` 中 LLM 返回的规划格式无法解析 | +| 技能包上传失败 | 500 | S3 上传技能包 ZIP 时网络中断或桶不存在 | + +--- + +### 2.8 文件上传和处理流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 获取预签名URL | `POST /api/common/file/presignTempFilePostUrl` (`projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts`) | +| 获取头像预签名URL | `POST /api/common/file/presignAvatarPostUrl` (`projects/app/src/pages/api/common/file/presignAvatarPostUrl.ts`) | + +**流程步骤** + +1. **获取预签名 URL**:前端调用 `POST /api/common/file/presignTempFilePostUrl`,后端验证权限和用量限制(30s 滑动窗口),生成 S3 预签名 PUT URL(有效期 1 小时) +2. **客户端直传 S3**:浏览器使用预签名 URL 直接将文件上传到 S3 私有桶,文件 Key 格式为 `temp/{teamId}/{filename}`,最大文件大小受 `maxUploadFileSize` 限制 +3. **文件解析**: + - 文档文件(PDF/Word/TXT/Markdown):提取文本内容 + - 图片文件:使用 VLM(视觉语言模型)理解图片内容并生成描述,存入 `imageDescMap` + - 音视频:使用 STT(语音转文字)模型转录为文本 +4. **对话中使用**:用户在对话中上传的文件,内容提取后作为上下文传入工作流,由 `formatFileInput` 处理文件 URL 列表 +5. **知识库导入**:文件上传后创建 Collection,进入知识库数据处理流程(见 2.4) + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` — 预签名 URL 生成(权限验证、频率限制、S3 桶操作) +- `FastGPT/packages/service/core/dataset/data/controller.ts` — 数据格式化(图片描述替换、S3 URL 转换) +- `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` — `formatFileInput` 处理对话中文件 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 文件类型不支持 | 400 | 上传文件扩展名不在允许列表中 | +| 预签名URL过期 | 403 | 客户端使用超过1小时的预签名URL上传 | +| S3直传失败 | 500 | 客户端到 S3 的网络中断或桶权限不足 | +| 文件解析失败 | 500 | PDF/Word 解析器无法提取文本(如加密文件、损坏文件) | + +--- + +### 2.9 团队管理流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 更新团队 | `POST /api/support/user/team/update` (`projects/app/src/pages/api/support/user/team/update.ts:12`) | +| 团队成员管理 | `POST /api/support/user/team/member/*` | + +**流程步骤** + +1. **团队创建**:用户注册时自动创建默认团队,团队信息存入 `MongoTeam` +2. **成员管理**:团队 Owner 可邀请成员、设置角色(Owner/Admin/Write/Read),通过 `MongoTeamMember` 管理成员关系 +3. **团队配置**:支持修改团队名称、头像、默认模型配置、用量限制等 +4. **权限控制**:所有团队内资源的操作均通过 `authUserPer` 校验 `tmbId`(团队成员ID)对应角色的权限值 + +**涉及的代码文件** + +- `FastGPT/packages/service/support/user/team/controller.ts` — 团队 CRUD +- `FastGPT/packages/service/support/user/team/teamMemberSchema.ts` — 团队成员数据模型 +- `FastGPT/packages/global/support/user/team/constant.ts` — 团队成员角色和权限枚举 +- `FastGPT/projects/app/src/pages/api/support/user/team/update.ts` — 更新团队 API + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 权限不足 | 403 | 非 Owner/Admin 角色尝试修改团队设置 | +| 成员不存在 | 404 | 添加/移除成员时 `tmbId` 无效 | +| 团队数量超限 | 403 | 用户创建团队数超过允许上限 | + +--- + +### 2.10 App 删除流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 删除App | `GET /api/core/app/del` (`projects/app/src/pages/api/core/app/del.ts:15`) | + +**流程步骤** + +1. **权限验证**:`authApp` 校验用户是否拥有目标 App 的 `OwnerPermissionVal` 权限 +2. **递归查找子App**:`findAppAndAllChildren` 查找目标文件夹下的所有子 App,全部纳入删除范围 +3. **软删除标记**:在 MongoDB Session 事务中将所有 App 的 `deleteTime` 设为当前时间 +4. **立即停用**:`deleteAppsImmediate` 立即停止 App 关联的后台任务 +5. **异步清理**:`addAppDeleteJob` 将清理任务加入删除队列,异步删除关联的对话记录、Collection、向量数据等 + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/core/app/del.ts` (第15行) — App 删除 API(权限验证、软删除、队列调度) +- `FastGPT/packages/service/core/app/delete/index.ts` — App 删除队列处理(异步清理关联资源) +- `FastGPT/packages/service/core/app/controller.ts` — `findAppAndAllChildren`、`deleteAppsImmediate` + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 权限不足 | 403 | 非 Owner 角色尝试删除 App | +| App不存在 | 404 | `appId` 无效或已被删除 | +| 参数错误 | 400 | `req.query` 中缺少 `appId` 参数 | + +--- + +### 2.11 知识库删除流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 删除知识库 | `GET /api/core/dataset/delete` (`projects/app/src/pages/api/core/dataset/delete.ts:15`) | + +**流程步骤** + +1. **权限验证**:`authDataset` 校验用户是否拥有目标知识库的 `OwnerPermissionVal` 权限 +2. **递归查找**:`findDatasetAndAllChildren` 查找目标文件夹下的所有子知识库 +3. **立即清理**:`deleteDatasetsImmediate` 移除关联的 Collection、向量数据、文件等 +4. **软删除标记**:在 MongoDB Session 中将所有知识库 `deleteTime` 设为当前时间 +5. **异步队列**:`addDatasetDeleteJob` 加入删除队列处理残留数据 + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/core/dataset/delete.ts` (第15行) — 知识库删除 API +- `FastGPT/packages/service/core/dataset/delete/index.ts` — 知识库删除队列 +- `FastGPT/packages/service/core/dataset/delete/processor.ts` — `deleteDatasetsImmediate` 即时清理逻辑 +- `FastGPT/packages/service/core/dataset/controller.ts` — `findDatasetAndAllChildren` +- `FastGPT/packages/service/core/dataset/collection/controller.ts` — Collection 关联删除 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 权限不足 | 403 | 非 Owner 角色尝试删除知识库 | +| 知识库不存在 | 404 | `datasetId` 无效或已被删除 | +| 向量数据清理失败 | 500 | 向量数据库连接异常或索引删除失败 | + +--- + +### 2.12 MCP Server 管理流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 创建MCP | `POST /api/support/mcp/create` (`projects/app/src/pages/api/support/mcp/create.ts:14`) | +| 更新MCP | `POST /api/support/mcp/update` (`projects/app/src/pages/api/support/mcp/update.ts`) | +| 删除MCP | `GET /api/support/mcp/delete` (`projects/app/src/pages/api/support/mcp/delete.ts`) | +| MCP列表 | `GET /api/support/mcp/list` (`projects/app/src/pages/api/support/mcp/list.ts`) | + +**流程步骤** + +1. **创建 MCP Key**:调用 `POST /api/support/mcp/create`,校验 `hasAppCreatePer` 权限后写入 `MongoMcpKey`,关联目标 App 列表(上限100个) +2. **工具发现**:MCP 客户端连接后通过 `GET /api/support/mcp/server/toolList` 获取可用工具列表 +3. **工具调用**:MCP 客户端通过 `POST /api/support/mcp/server/toolCall` 调用具体工具 +4. **删除与更新**:支持修改关联 App 列表或删除 MCP Key + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/support/mcp/create.ts` — 创建 MCP API(第14行) +- `FastGPT/projects/app/src/pages/api/support/mcp/server/toolList.ts` — MCP 工具发现 +- `FastGPT/projects/app/src/pages/api/support/mcp/server/toolCall.ts` — MCP 工具调用 +- `FastGPT/packages/service/support/mcp/schema.ts` — `MongoMcpKey` 数据模型 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| MCP数量超限 | 403 | 团队 MCP Key 数量 >= 100 | +| 权限不足 | 403 | `hasAppCreatePer` 为 false | +| App不存在 | 404 | 关联的 `appId` 无效或已被删除 | + +--- + +### 2.13 评测流程 + +**入口** + +| 操作 | HTTP方法 路径 (file:line) | +|------|-------------------------| +| 创建评测任务 | `POST /api/core/evaluation/task/create` (`projects/app/src/pages/api/core/evaluation/task/create.ts`) | +| 启动评测 | `POST /api/core/evaluation/task/start` (`projects/app/src/pages/api/core/evaluation/task/start.ts`) | +| 评测统计 | `GET /api/core/evaluation/task/stats` (`projects/app/src/pages/api/core/evaluation/task/stats.ts`) | + +**流程步骤** + +1. **评测数据集准备**:创建评测 Collection 并导入测试数据(支持手动创建和智能生成) +2. **评测指标配置**:配置评测指标(准确性、相关性等),每个指标绑定 LLM 打分逻辑 +3. **创建评测任务**:将 App 与评测数据集、评测指标绑定,生成评测任务 +4. **执行评测**:后台执行对话评测,逐条运行测试数据,记录每次对话的评分和日志 +5. **结果汇总**:评测完成后生成得分汇总,按指标和数据集分组展示 + +**涉及的代码文件** + +- `FastGPT/projects/app/src/pages/api/core/evaluation/task/create.ts` — 创建评测任务 +- `FastGPT/projects/app/src/pages/api/core/evaluation/metric/create.ts` — 创建评测指标 +- `FastGPT/projects/app/src/pages/api/core/evaluation/dataset/collection/create.ts` — 创建评测数据集 +- `FastGPT/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts` — 添加评测数据 + +**主要异常分支** + +| 异常场景 | HTTP状态码 | 触发条件 | +|---------|-----------|---------| +| 评测数据集为空 | 400 | 启动评测时 Collection 中没有数据 | +| 评测指标无效 | 400 | 指标配置的 LLM 提示词或参数不合法 | +| App已删除 | 404 | 评测任务关联的 App 已被删除 | + +--- + +## 三、流程分类统计 + +### 按模块统计 + +| 模块 | 流程数量 | 流程名称 | +|------|---------|---------| +| **用户与团队** | 2 | 用户注册与认证 (2.1)、团队管理 (2.9) | +| **应用管理** | 3 | App 创建与发布 (2.2)、App 删除 (2.10)、工作流编排与执行 (2.6) | +| **对话** | 1 | 简单应用对话 (2.3) | +| **知识库** | 3 | 知识库创建与数据处理 (2.4)、知识库检索 (2.5)、知识库删除 (2.11) | +| **Agent** | 1 | Agent 技能执行 (2.7) | +| **文件** | 1 | 文件上传与处理 (2.8) | +| **MCP Server** | 1 | MCP Server 管理 (2.12) | +| **评测** | 1 | 评测流程 (2.13) | + +### 按操作类型统计 + +| 操作类型 | 流程数量 | 说明 | +|---------|---------|------| +| 资源创建 | 6 | App创建、知识库创建、Agent技能创建、MCP创建、评测任务创建、文件上传 | +| 核心执行 | 4 | 对话执行、知识库检索、工作流调度、Agent工具调用 | +| 资源删除 | 2 | App删除、知识库删除 | +| 认证与管理 | 3 | 用户注册认证、团队管理、工作流编排(前端) | + +**总计:13 个业务流程,覆盖 7 个模块,涉及约 400+ 个 API 端点** + +--- + +## 四、流程复杂度分析 + +| 流程名称 | 复杂度 | 涉及服务数 | 异常分支数 | 关键技术栈 | +|---------|--------|-----------|-----------|-----------| +| 简单应用对话流程 (2.3) | 极高 | 6 | 5 | SSE流式、LLM调用、工作流引擎、MongoDB事务、频率限制 | +| 工作流编排和执行流程 (2.6) | 极高 | 5 | 4 | DAG调度、Tarjan环检测、节点DISPATCH路由、沙箱执行 | +| Agent 技能执行流程 (2.7) | 极高 | 5 | 4 | Plan+Step编排、工具调用循环、沙箱部署、PI-Agent | +| 知识库创建和数据处理流程 (2.4) | 高 | 5 | 5 | S3直传、异步训练队列、向量化批处理、Embedding模型、文档解析 | +| 知识库检索流程 (2.5) | 高 | 4 | 4 | 向量检索、全文检索、RRF混合、Rerank重排序 | +| Agentic Search多轮检索 | 高 | 4 | 3 | 多轮查询改写、检索结果评估、自主决策 | +| 应用创建和发布流程 (2.2) | 中 | 4 | 4 | MongoDB事务、权限级联、版本快照、类型限制 | +| App 删除流程 (2.10) | 中 | 3 | 3 | 递归查找、事务软删除、异步级联清理 | +| 知识库删除流程 (2.11) | 中 | 4 | 3 | 递归查找、向量数据清理、异步队列 | +| 用户注册与认证流程 (2.1) | 中 | 3 | 4 | JWT认证、密码哈希、多认证分支 | +| 文件上传和处理流程 (2.8) | 中 | 3 | 4 | S3预签名URL、客户端直传、VLM/STT解析 | +| MCP Server 管理流程 (2.12) | 低 | 2 | 3 | MCP协议、API Key管理、权限控制 | +| 评测流程 (2.13) | 低 | 2 | 3 | LLM打分、批量评测、结果聚合 | + +--- + +## 五、快速导航 + +### 按业务场景定位代码 + +| 业务场景 | 代码快速定位 | +|---------|-------------| +| **发起对话** | `projects/app/src/pages/api/v1/chat/completions.ts` → `dispatchWorkFlow` (`packages/service/core/workflow/dispatch/index.ts`) | +| **流式响应** | `SseResponseEventEnum` (`packages/global/core/workflow/runtime/constants.ts`) → `responseWrite` (`packages/service/common/response`) | +| **认证鉴权** | `authCert` (`packages/service/support/permission/auth/common.ts`) → `authApp`/`authDataset` (`packages/service/support/permission/`) | +| **权限管理** | `PerResourceTypeEnum` (`packages/global/support/permission/constant.ts`) → `MongoResourcePermission` (`packages/service/support/permission/schema.ts`) | +| **LLM调用** | `packages/service/core/ai/llm/` → `createLLMResponse` (request.ts) | +| **Embedding向量化** | `getVectorsByText` (`packages/service/core/ai/embedding/index.ts`) | +| **RAG检索** | `packages/service/core/dataset/database/` → `packages/service/core/ai/rerank/` | +| **知识库训练** | `packages/service/core/dataset/training/` → `packages/service/core/dataset/collection/utils.ts` | +| **工作流节点开发** | `FlowNodeTypeEnum` (`packages/global/core/workflow/node/constant.ts:135`) → `packages/service/core/workflow/dispatch/` | +| **Agent工具调用** | `masterCall` (`packages/service/core/workflow/dispatch/ai/agent/master/call.ts`) | +| **文件上传** | `packages/service/common/s3/` → `presignTempFilePostUrl` (`projects/app/src/pages/api/common/file/`) | +| **数据库模型** | `packages/service/core/chat/schema*.ts` (对话) / `packages/service/core/dataset/schema.ts` (知识库) / `packages/service/core/app/schema.ts` (应用) | +| **MCP Server** | `packages/service/support/mcp/` → `projects/app/src/pages/api/support/mcp/` | +| **评测系统** | `projects/app/src/pages/api/core/evaluation/` | +| **OpenAPI Schema定义** | `packages/global/openapi/` (API 请求/响应类型) | + +--- + +## 六、核心流程列表(供详解文档使用) + +以下流程复杂度高、技术含量深,最值得深入分析: + +1. **对话流程(含流式响应)** — 最核心,贯穿认证、工作流调度、LLM 调用、SSE 推流、历史记录保存全链路 +2. **知识库 RAG 检索流程** — 涉及向量化、多模式检索、重排序、Agentic Search 多轮检索等关键 AI 技术 +3. **工作流 Agent 执行流程** — Plan+Step 编排模式、工具调用循环、沙箱执行是 FastGPT 差异化竞争力 +4. **知识库数据处理流程** — 文件解析、分块策略、向量化批处理、异步训练队列的完整链路 +5. **应用创建和配置流程** — 包含多类型应用(simple/workflow/plugin)的创建、节点安全格式化、版本发布机制 + +--- + + diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..878ffe500d2b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,602 @@ +--- +capability_label: null +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT 业务流程详解 + +本文档对 FastGPT 的 5 个核心业务流程进行深度剖析,每个流程包含流程概述、关键代码分析、时序图和关键步骤说明。 + +--- + +## 一、对话流程(含流式响应) + +### 1.1 流程概述 + +对话流程是 FastGPT 最核心的链路,将一条用户提问从前端 ChatBox 输入开始,经历认证、历史拼接、工作流调度、LLM 流式调用、SSE 推流、记录持久化等多个阶段,最终以流式或非流式形式返回给客户端。该流程同时承载 Web UI 对话、共享链接对话、团队空间对话以及外部系统通过 OpenAI 兼容 API 的接入。 + +主链路: + +> 前端 ChatBox → `POST /api/v1/chat/completions` → 三种认证分支 → 频率限制 → 历史/版本/会话并行查询 → 构造 RuntimeNodes/Edges → `dispatchWorkFlow` → AI 节点 / Agent 节点调用 `createLLMResponse` → SSE 边推边写 → `pushChatRecords` 持久化 → `recordAppUsage` / `updateApiKeyUsage` → 响应结束。 + +### 1.2 深度分析 + +#### 1.2.1 API 入口(鉴权与请求体解析) + +- 文件:`FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` +- 通过 `CompletionsPropsSchema.parse(req.body)` 完成请求体解析与字段类型校验,关键字段包括 `chatId`、`appId`、`stream`、`detail`、`messages`、`variables`、`responseChatItemId`、`shareId`/`outLinkUid`/`teamId`/`teamToken`。 +- 三种认证分支以 IIFE(立即执行 async 函数)形式选择: + - `authShareChat`:分享链接(`shareId + outLinkUid`),调用 `authOutLinkChatStart` 校验外部链接、IP、问题,并尝试用 `authCert` 解析登录态以拿到 `userTmbId`,便于团队内分享场景维持权限上下文。 + - `authTeamSpaceChat`:团队空间(`teamId + teamToken`),通过 `authTeamSpaceToken` 校验 token 并按 `teamTags` 过滤可见 App。 + - `authHeaderRequest`:API Key 或浏览器 Token,通过 `authCert({ authToken: true, authApiKey: true })` 同时支持登录态与 OpenAI 兼容 Key。 +- 三个认证分支统一返回 `AuthResponseType`,包含 `teamId`、`tmbId`、`app`、`authType`、`apikey`、`showCite`、`showRunningStatus`、`showSkillReferences`、`outLinkUserId`,对外屏蔽差异。 +- 频率限制:`teamFrequencyLimit({ teamId, type: LimitTypeEnum.chat, res })` 在团队级别做 QPM 限制,命中后直接返回。 +- 埋点:`pushTrack.teamChatQPM({ teamId })`。 + +#### 1.2.2 历史与版本并行获取 + +- 历史:`getChatItems({ appId, chatId, offset: 0, limit, field: 'obj value nodeOutputs' })`,限制由 `getMaxHistoryLimitFromNodes(app.modules)` 推导。 +- 版本:`getAppLatestVersion(app._id, app)` 优先取最新发布版(`MongoAppVersion`),未发布回退到 App 本身的 `modules`/`edges` 草稿。 +- 会话:`MongoChat.findOne({ appId, chatId }, 'source variableList variables')`,用于回填存量 `variables`。 +- 三者使用 `Promise.all` 并行执行。 + +#### 1.2.3 用户问题构造与运行时节点装配 + +- `chatMessages = GPTMessages2Chats({ messages })` 将 OpenAI 风格 messages 转换为 FastGPT 内部 `ChatItemType` 列表。 +- 工作流工具应用(`AppTypeEnum.workflowTool`)走 `serverGetWorkflowToolRunUserQuery`,将 `variables.files` 与节点声明的输入字段拼装为虚拟 `userQuestion`。 +- 普通对话则 `chatMessages.pop()` 获取最新一条 Human 消息。 +- `concatHistories(histories, chatMessages)` 拼接历史与当前消息列表。 +- `getLastInteractiveValue(newHistories)` 探测是否处于交互态恢复(如上一轮 Plan Agent 等待用户输入)。 +- `storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, interactive))` 将存储节点转换为运行节点;交互态会重写入口节点。 +- `rewriteNodeOutputByHistories(runtimeNodes, interactive)` 把上一轮节点输出回填到运行时节点,使 Plan/Step 模式可以续跑。 + +#### 1.2.4 工作流调度与流式响应器 + +- `getWorkflowResponseWrite({ res, detail, streamResponse: stream, id: chatId, showNodeStatus })` 构造统一的 SSE 写入函数 `workflowResponseWrite`。 +- `dispatchWorkFlow` 入参覆盖 `runtimeNodes`、`runtimeEdges`、`variables`、`query`、`histories`、`chatConfig`、`stream`、`maxRunTimes: WORKFLOW_MAX_RUN_TIMES`、`workflowStreamResponse`、`runningAppInfo`、`runningUserInfo`、`uid` 等,是真正的工作流执行入口。 +- 节点内部按 `FlowNodeTypeEnum` 路由到对应 dispatch 处理器;AI 类节点最终调用 `createLLMResponse`(位于 `FastGPT/packages/service/core/ai/llm/request.ts`)。 + +#### 1.2.5 LLM 流式调用 + +- 文件:`FastGPT/packages/service/core/ai/llm/request.ts` +- `createLLMResponse` 是流式/非流式统一入口: + 1. `loadRequestMessages` 处理图片/Vision 字段; + 2. `promptToolCallMessageRewrite`(仅 `toolCallMode=prompt` 时)将 ToolChoice 模式改写为提示词模式; + 3. `llmCompletionsBodyFormat` 计算 `temperature`、`max_tokens`(受 `enableThinking` 兜底为 16384); + 4. `createChatCompletion` 执行真实 HTTP 请求,根据后端是否流式返回,进入 `createStreamResponse` 或 `createCompleteResponse`; + 5. 流式分支下,通过 `parseLLMStreamResponse`/`parseReasoningContent` 解析 SSE chunk,触发 `onStreaming`/`onReasoning`/`onToolCall`/`onToolParam` 四类回调; + 6. 当 `finish_reason === 'length'` 时,按 `maxContinuations` 进行续传累加。 +- `runAgentCall`(`FastGPT/packages/service/core/ai/llm/agentCall/index.ts`)封装了"工具调用循环"模式,包括 `compressRequestMessages`、`filterGPTMessageByMaxContext`、交互工具恢复(`childrenInteractiveParams.toolParams.memoryRequestMessages`)以及单步重试逻辑。 + +#### 1.2.6 SSE 推送事件 + +- 事件类型由 `SseResponseEventEnum` 定义,常见包括: + - `answer`:常规文本 / `reasoning_content` 增量; + - `toolCall` / `toolParams`:工具调用元信息与参数 chunk; + - `flowNodeStatus` / `flowResponses`:节点执行状态、最终调试信息; + - `plan` / `stepTitle`:Plan Agent 阶段化执行中的规划与步骤切换; + - `interactive`:用户交互等待。 +- `textAdaptGptResponse({ text, finish_reason })` 将内部增量适配为 OpenAI Chat Completion chunk 结构,便于第三方 SDK 直接消费。 +- 流式结束后,主入口会再写入 `finish_reason: 'stop'` 与 `[DONE]` 终止符;`detail=true` 时还会推送 `flowResponses` 全量调试数据。 + +#### 1.2.7 聊天记录持久化与计费 + +- `pushChatRecords({ chatId, appId, versionId, teamId, tmbId, nodes, appChatConfig, variables, newTitle, source, userContent, aiContent, metadata, durationSeconds })` 将一对 H/AI 消息写入 `MongoChatItem`,节点级响应写入 `MongoChatItemResponse`;交互态恢复走 `updateInteractiveChat`。 +- `recordAppUsage`:仅对应用 Owner 在线模式记录用量。 +- `pushResult2Remote` / `addOutLinkUsage`:分享链接外部回推与积分累加。 +- `updateApiKeyUsage`:API Key 调用扣减。 +- 异常路径:流式下走 `sseErrRes(res, err)` 推送错误事件后 `res.end()`;非流式下走 `jsonRes` 返回 500。 + +### 1.3 时序图(对话流程) + +```mermaid +sequenceDiagram + autonumber + participant FE as ChatBox / 第三方 SDK + participant API as completions.ts handler + participant Auth as authShare/authTeam/authHeader + participant Limit as teamFrequencyLimit + participant Mongo as MongoChatItem/MongoApp/MongoChat + participant Disp as dispatchWorkFlow + participant LLM as createLLMResponse / runAgentCall + participant SSE as workflowResponseWrite + participant Save as pushChatRecords / recordAppUsage + + FE->>API: POST /api/v1/chat/completions (messages, stream, chatId, appId) + API->>API: CompletionsPropsSchema.parse(req.body) + API->>Auth: authShareChat / authTeamSpaceChat / authHeaderRequest + Auth-->>API: { teamId, tmbId, app, apikey, authType, showCite } + API->>Limit: teamFrequencyLimit(teamId, chat) + Limit-->>API: ok + API->>Mongo: getChatItems / getAppLatestVersion / MongoChat.findOne + Mongo-->>API: histories + nodes/edges + variables + API->>API: storeNodes2RuntimeNodes + rewriteNodeOutputByHistories + API->>SSE: getWorkflowResponseWrite(res, stream, detail) + API->>Disp: dispatchWorkFlow(runtimeNodes, runtimeEdges, query, histories, ...) + loop 节点拓扑执行 + Disp->>LLM: createLLMResponse / runAgentCall + LLM-->>SSE: onStreaming / onReasoning / onToolCall (SSE chunk) + SSE-->>FE: event=answer/toolCall/plan ... + end + Disp-->>API: { flowResponses, flowUsages, assistantResponses, newVariables } + alt 交互态恢复 + API->>Save: updateInteractiveChat(...) + else 普通对话 + API->>Save: pushChatRecords({ userContent, aiContent, ... }) + end + API->>Save: recordAppUsage / addOutLinkUsage / updateApiKeyUsage + alt stream=true + API-->>FE: SSE [DONE] + else stream=false + API-->>FE: JSON { choices, responseData, usage } + end +``` + +--- + +## 二、知识库 RAG 检索流程 + +### 2.1 流程概述 + +知识库 RAG 检索流程负责把一个用户提问转化为可注入到 AI 上下文的"引用片段",覆盖向量化、向量检索、全文检索(含 RRF 混合)、Rerank、按 token 截断、Agentic 多轮检索、引用元数据格式化等环节。该流程既被工作流"知识库检索节点"使用,也被 Agent 节点的 `datasetSearch` 工具复用。 + +### 2.2 深度分析 + +#### 2.2.1 查询向量化 + +- 文件:`FastGPT/packages/service/core/ai/embedding/index.ts` +- `getVectorsByText({ model, input, type, headers })`: + - 按 `model.batchSize` 自动分批; + - 当 `type === 'query'` 且模型声明 `instruction` 时,用 `formatEmbeddingQuery` 拼接 `Instruct: ...\nQuery: ...` 前缀(适配 BGE/Qwen 等 instruct 风格 Embedding 模型); + - `retryFn` 包裹底层 `ai.embeddings.create`,失败重试; + - `formatVectors` 强制把维度规整为 1536(不足补零,过长截断+L2 归一化)以兼容向量库 schema。 + +#### 2.2.2 向量库连接与检索(Milvus / PGVector / OceanBase) + +- 文件:`FastGPT/packages/service/core/dataset/database/clientManager.ts`(外部数据库 Adapter,如 Mysql/Postgres/Mssql/Oracle) +- 真正的向量检索入口位于 `FastGPT/packages/service/common/vectorDB/controller.ts` 中的 `recallFromVectorStore`,由 `searchDatasetData` 调度(文件 `FastGPT/packages/service/core/dataset/search/controller.ts`)。 +- `searchDatasetData` 关键逻辑: + - `countRecallLimit()` 根据 `searchMode`(embedding / fullTextRecall / mixedRecall)分配 `embeddingLimit`、`fullTextLimit`; + - `getForbidData` 读取被禁用的 Collection ID 列表,传入向量库做过滤; + - `embeddingRecallPerQuery` 与 `fullTextRecallPerQuery` 分别执行向量召回与全文召回; + - 混合模式调用 `milvusHybridRecall`(Milvus 端原生混合)或 `datasetSearchResultConcat`(外部 RRF 倒数排名融合),并按 `embeddingWeight` 对两路分数加权; + - `dedupeByContent` 按内容去重; + - 命中 Collection 标签过滤通过 `collectionFilterMatch`(JSON5 描述 `tags / createTime` 表达式),`computeFilterIntersection` 计算 Collection 集合交集。 + +#### 2.2.3 Rerank(重排序) + +- 文件:`FastGPT/packages/service/core/ai/rerank/index.ts` +- `reRankRecall({ model, query, documents })`: + - 通过 `formatRerankQuery` 为带 `instruction` 的 Reranker 模型构造 Qwen 风格 `<|im_start|>...` 前后缀; + - 计算 `docBudget = model.maxToken - queryTokens`,若文档超 budget,则用 `text2Chunks` 二次切片,并通过 `chunkIdToDocIdMap` 反向映射回原始文档 ID(同一文档保留最高分); + - POST `/rerank`(或自定义 `model.requestUrl`)拿到 `relevance_score`; + - `searchDatasetData` 中调用 `datasetDataReRank`(位于 `controller.ts`),按 `RerankMethodEnum`(`question` 或 `content=q+a`)选择 Rerank 文本。 + +#### 2.2.4 Agentic 多轮检索 + +- 文件:`FastGPT/packages/service/core/dataset/search/agenticSearch.ts`、`agenticSearchLabels.ts`、`assistantReranker.ts`、`controller.ts`(`searchDatasetDataForAssistant`)。 +- Agent 模式由 `searchDatasetDataResponse.agenticSearchResult` 透出 `reasoningText`、`searchCount`、`toolCallCount`、`llmInputTokens` 等指标;该模式允许 AI 自行调整 Query、追加检索或终止。 + +#### 2.2.5 工作流/Agent 中的接入 + +- 工作流"知识库检索节点"通过节点 dispatcher 调用 `defaultSearchDatasetData` 包装版本。 +- Agent 调用入口:`FastGPT/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts` 中的 `dispatchAgentDatasetSearch`: + 1. 取首个 dataset 的 `vectorModel` 与全局 `rerankModel`; + 2. 构造 `DefaultSearchDatasetDataProps` 并调用 `defaultSearchDatasetData`; + 3. `selectRelevantChunksByLLM` 在 token 估算超过 `calculateCompressionThresholds(maxContext).datasetSearchSelection` 时,再次让 LLM 仅选出 ID 列表做"硬裁切",避免长上下文崩溃; + 4. `formatDatasetSearchResponse` 输出 `【知识片段N】id/source/content` 三段结构,便于 AI 引用; + 5. 汇总 `usages`:query extension、向量化、Rerank、AI 分块选择各自计费。 + +#### 2.2.6 引用与文件链接还原 + +- `formatDatasetDataValue`(`FastGPT/packages/service/core/dataset/data/controller.ts`)将存储中的 `imageDescMap` 替换为可访问 URL,`replaceS3KeyToPreviewUrl` 把 S3 Object Key 转回带签名的预览 URL。 + +### 2.3 时序图(RAG 检索流程) + +```mermaid +sequenceDiagram + autonumber + participant Caller as 工作流节点 / dispatchAgentDatasetSearch + participant Search as searchDatasetData + participant Embed as getVectorsByText + participant VecDB as Milvus/PGVector (recallFromVectorStore) + participant FT as fullTextRecallPerQuery + participant Rerank as reRankRecall + participant Pick as selectRelevantChunksByLLM + participant Out as formatDatasetSearchResponse + + Caller->>Search: { queries, datasetIds, searchMode, similarity, maxTokens, ... } + Search->>Embed: getVectorsByText(queries, type=query) + Embed-->>Search: vectors (1536 维, 归一化) + par 向量召回 + Search->>VecDB: embeddingRecallPerQuery(vector, limit, forbidIds) + VecDB-->>Search: embedding hits + and 全文召回 + Search->>FT: fullTextRecallPerQuery(queries) + FT-->>Search: fullText hits + end + Search->>Search: datasetSearchResultConcat (RRF 融合 + embeddingWeight) + alt usingReRank=true + Search->>Rerank: reRankRecall(query, q+a documents) + Rerank-->>Search: relevance_score 排序 + end + Search->>Search: filterDatasetDataByMaxTokens(maxTokens) + Search-->>Caller: { searchRes, embeddingTokens, reRankInputTokens, queryExtensionResult } + opt token 超阈值 + Caller->>Pick: selectRelevantChunksByLLM(query, chunks) + Pick-->>Caller: 选中的 chunk id 列表 + end + Caller->>Out: formatDatasetSearchResponse(searchResults) + Out-->>Caller: 【知识片段N】id/source/content +``` + +--- + +## 三、工作流 Agent 执行流程 + +### 3.1 流程概述 + +Agent 节点是 FastGPT 工作流中最复杂的执行单元,采用 ReAct 风格的"思考-行动"循环,并叠加 Plan+Step 双层编排。整体流程包括:能力初始化(Skill 沙箱、子 App、内置工具)→ 进入 master 调用循环或先生成 Plan → 在 Plan 模式下逐 step 调用 master → 结束/交互。 + +### 3.2 深度分析 + +#### 3.2.1 Agent 节点入口 + +- 文件:`FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` +- `dispatchRunAgent`: + - `env.AGENT_ENGINE === 'pi'` 时整体走 `dispatchPiAgent`(PI-Agent 引擎); + - 默认引擎采用 `MAX_PLAN_ITERATIONS = 10` 限制规划迭代; + - 关键 memories key:`masterMessages-${nodeId}`、`planMessages-${nodeId}`、`agentPlan-${nodeId}`、`planBuffer-${nodeId}`,用于跨轮次/跨交互恢复; + - `formatFileInput` 将 `fileLinks` 转换成 `filesMap` 与提示词文件清单; + - `createSandboxSkillsCapability` 按 `mode === 'chat'` 创建 `sessionRuntime` 沙箱或 `editDebug-${appId}-${nodeId}` 调试沙箱;当 `env.SHOW_SKILL` 为 false 时整体跳过 Skill 加载(避免 MongoDB 查询和沙箱启动); + - `getSubapps` 整合用户 Tool、Plan Tool、Dataset Search Tool、File Read Tool、Sandbox Tool 等,得到 `agentCompletionTools` 与 `agentSubAppsMap`; + - `parseUserSystemPrompt` 把用户 systemPrompt、Capability 提供的 systemPrompt 和已选 dataset 信息合并为最终 prompt。 + +#### 3.2.2 Plan 模式(阶段化) + +- `dispatchPlanAgent`(`sub/plan/index.ts`)负责生成或继续生成 `agentPlan`。返回 `{ askInteractive, plan, planBuffer, completeMessages, usages, nodeResponse }`。 +- `parsePlanCallResult` 把 plan 推送为 SSE `event: plan`,并把每个 step 通过 `assistantResponses.push({ stepTitle })` 写入;恢复交互时,从 `lastInteractive` + `planBuffer` 复原。 +- 主循环规则: + 1. 若已有 `planHistoryMessages`,先调用 `planCallFn` 处理交互(场景 2/4); + 2. 进入 `while(true)`: + - 有 `agentPlan` → 逐 step 调用 `masterCall`;执行完后调用 `continuePlanCallFn` 让 Plan Agent 决定是否再加 step;超过 `MAX_PLAN_ITERATIONS` 时强制结束规划; + - 无 `agentPlan` → 直接 `masterCall`,若 master 触发了 plan 工具,则进入 stepCall 模式或返回交互。 + 3. `checkIsStopping()` 在用户主动暂停时跳出循环。 + +#### 3.2.3 Master 调用循环 + +- 文件:`FastGPT/packages/service/core/workflow/dispatch/ai/agent/master/call.ts` +- `masterCall` 真正构造请求消息: + - StepCall 时:去除 `plan` 工具,使用 `getStepDependon`(让 LLM 判断 step 依赖关系)和 `getStepCallQuery`(合成 step 输入),把 step prompt 当作单条 Human 消息送入; + - 普通 master 时:以 `getMasterSystemPrompt` 拼接系统提示,附加历史 `masterMessages`; +- 通过 `runAgentCall`(`FastGPT/packages/service/core/ai/llm/agentCall/index.ts`)执行多轮工具调用,关键能力: + - `filterGPTMessageByMaxContext` 预留 8000 token 响应空间; + - `compressRequestMessages` / `compressToolResponse` 在超阈值时主动压缩; + - 4 个 SSE 回调(`onReasoning`/`onStreaming`/`onToolCall`/`onToolParam`)通过 `stepStreamResponse` 注入 `stepId`; + - `handleToolResponse` 根据 `toolId` 路由到 `dispatchFileRead` / `dispatchAgentDatasetSearch` / `dispatchSandboxShell` / `dispatchSandboxGetFileUrl` / 用户子应用 `dispatchApp`/`dispatchPlugin`; + - `handleInteractiveTool` 处理工具内部的 Ask 交互(`memoryRequestMessages` 复原上一轮 requestMessages); + - `onCompressContext` / `onToolCompress` 把压缩本身也作为一个 nodeResponse 节点产出。 + +#### 3.2.4 输出聚合 + +- 每次 step / master 完成后,`GPTMessages2Chats` 将 assistantMessages 转换回 ChatItem,附加 `planId/stepId`; +- 退出条件:master 未触发 plan 即 break;agentPlan 被清空(继续规划无新 step)即 break;`checkIsStopping()` 即 break。 +- `assistantResponses` 中 `text.content` 拼接为最终 `answerText`,节点输出 `NodeOutputKeyEnum.answerText`;`memories` 字段在结束时清空,避免下轮污染。 + +### 3.3 时序图(Agent 执行流程) + +```mermaid +sequenceDiagram + autonumber + participant WF as dispatchWorkFlow + participant Agent as dispatchRunAgent + participant Cap as createSandboxSkillsCapability + participant Sub as getSubapps + participant Plan as dispatchPlanAgent + participant Master as masterCall + participant Run as runAgentCall (LLM 工具调用循环) + participant Tool as dispatchAgentDatasetSearch / dispatchFileRead / dispatchSandboxShell / dispatchApp + participant SSE as workflowStreamResponse + + WF->>Agent: dispatchRunAgent(node, query, histories, chatConfig) + alt env.AGENT_ENGINE === 'pi' + Agent->>Agent: dispatchPiAgent(...) + else 默认引擎 + Agent->>Agent: formatFileInput → filesMap + Agent->>Cap: createSandboxSkillsCapability(mode, skillIds) + Agent->>Sub: getSubapps(tools, plan, dataset, files, sandbox) + Sub-->>Agent: agentCompletionTools + agentSubAppsMap + loop 主循环(checkIsStopping=false) + alt 已有 agentPlan + loop 顺序执行未完成 step + Agent->>SSE: event=stepTitle + Agent->>Master: masterCall(step, completionTools-plan) + Master->>Run: runAgentCall(messages, tools, stream) + loop 多轮工具调用 + Run-->>SSE: onStreaming/onReasoning/onToolCall/onToolParam + Run->>Tool: handleToolResponse(toolId) + Tool-->>Run: response + usages + end + Run-->>Master: assistantMessages + finish_reason + Master-->>Agent: stepResponse + nodeResponse + end + Agent->>Plan: continuePlanCallFn (是否继续规划) + Plan-->>Agent: 新 steps 或 plan 结束 + else 无 agentPlan + Agent->>Master: masterCall(masterMessages) + Master->>Run: runAgentCall(...) + Run-->>Master: assistantMessages + Master-->>Agent: planResponse? / 普通终止 + opt master 触发 plan 工具 + Agent->>Plan: parsePlanCallResult → agentPlan + end + end + end + Agent-->>WF: { answerText, assistantResponses, nodeResponses, memories } + end +``` + +--- + +## 四、知识库数据处理流程 + +### 4.1 流程概述 + +知识库数据处理流程把一份原始文件(PDF / Word / Markdown / 图片 / 音视频)转化为可检索的向量+文本组合。链路涉及:S3 预签名直传、Collection 创建、文档解析、Chunk 切分、Embedding 计算与归一化、向量库写入、Training 队列异步处理、可选 QA 生成。 + +### 4.2 深度分析 + +#### 4.2.1 文件直传 + +- 文件:`FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` +- 关键步骤: + 1. 鉴权获取 `teamId/tmbId`; + 2. 校验团队 `maxUploadFileCount` 30 秒滑动窗口频率; + 3. 校验单文件 `maxUploadFileSize`; + 4. 通过 S3 SDK 生成 PUT 预签名 URL(key 形如 `temp/{teamId}/{filename}`,有效期 1 小时); + 5. 浏览器直传 S3 私有桶,绕开应用服务器的带宽与超时限制。 + +#### 4.2.2 Collection 创建 + +- 入口:`FastGPT/projects/app/src/pages/api/core/dataset/collection/create/*.ts`(apiCollection / fileCollection / linkCollection 等)。 +- 控制器:`FastGPT/packages/service/core/dataset/collection/controller.ts`,写入 `MongoDatasetCollection`,关联 `datasetId`、文件 S3 Key、`trainingType`、`chunkSize`、`chunkSplitter`、`tags`。 +- 同时在 `MongoDatasetTraining` 中创建多条训练任务(按 mode:`chunk` / `qa` / `auto`),等待 Worker 消费。 + +#### 4.2.3 解析与分块 + +- 文档解析(PDF/Word/HTML/Markdown)由后台 Worker 调用 `text2Chunks`、`splitText2Chunks` 等工具按段落与 `chunkSize` 切分;超长段落支持 overlap。 +- 图片:调用 VLM(视觉语言模型)生成描述,结果写入 `imageDescMap`,由 `formatDatasetDataValue` 在检索时回填。 +- 音视频:先通过 STT 模型转文本,再走与文档相同的分块逻辑。 + +#### 4.2.4 向量化与写入 + +- `getVectorsByText({ model, input, type: 'db' })`(`FastGPT/packages/service/core/ai/embedding/index.ts`),`type === 'db'` 时使用模型的 `dbConfig`(部分模型对 doc/query 使用不同参数)。 +- `formatVectors` 强制对齐 1536 维并 L2 归一化。 +- 写入向量库:通过 `FastGPT/packages/service/common/vectorDB/controller.ts` 的 insert 接口,根据环境变量 `MILVUS_ADDRESS` / `PG_ADDRESS` / `OCEANBASE_ADDRESS` 自动路由。 +- 同步写入 `MongoDatasetData`(结构化文本 + chunkIndex)与 `MongoDatasetDataText`(全文索引专用文本)。 + +#### 4.2.5 Training 队列与失败重试 + +- `MongoDatasetTraining` 是一个 MongoDB 队列;Worker 周期性扫描 `mode + lockTime + retryCount`,限制并发并自动重试;任务完成后清理。 +- 支持的 mode(共 11 种):`parse`(解析)、`chunk`(普通切片)、`qa`(LLM 自动生成 QA 对)、`auto`(自动选择)、`image`(图片描述)、`imageParse`(图片解析)、`databaseSchema`(数据库 Schema 提取)、`hype`(虚拟数据增强)、`small2Big`(小粒度到大粒度分层索引)、`synonymStandardize`(同义词标准化)、`synonymRestore`(同义词恢复)。 + +### 4.3 时序图(数据处理流程) + +```mermaid +sequenceDiagram + autonumber + participant FE as 前端上传组件 + participant API as presignTempFilePostUrl + participant S3 as 对象存储 (S3 / MinIO) + participant Coll as createCollection API + participant Mongo as MongoDatasetCollection / MongoDatasetTraining + participant Worker as Training Worker + participant Parser as 文档解析 / VLM / STT + participant Embed as getVectorsByText (type=db) + participant Vec as Milvus / PGVector / OceanBase + participant Data as MongoDatasetData / MongoDatasetDataText + + FE->>API: 申请预签名(teamId, filename, size) + API->>API: 校验权限 + maxUploadFileCount + maxUploadFileSize + API-->>FE: 预签名 PUT URL (1h) + FE->>S3: 直传文件 (temp/{teamId}/{filename}) + FE->>Coll: 创建 Collection(datasetId, fileKey, chunkSize, trainingType) + Coll->>Mongo: 写 Collection + 入 Training 队列 + Worker->>Mongo: 取出待处理任务 (锁定 lockTime) + Worker->>S3: 下载文件 + Worker->>Parser: 解析文本 / 图片描述 / STT + Parser-->>Worker: 文本 + imageDescMap + Worker->>Worker: text2Chunks / splitText2Chunks(chunkSize, overlap) + loop 按 batchSize 分批 + Worker->>Embed: getVectorsByText(chunk, type=db) + Embed-->>Worker: vectors (1536 维归一化) + Worker->>Vec: insert(vectors, datasetId, collectionId) + Worker->>Data: 写 MongoDatasetData / MongoDatasetDataText + end + alt trainingType=qa + Worker->>Embed: 调 LLM 生成 QA 对 + end + Worker->>Mongo: 更新任务状态 = done / 失败重试 +``` + +### 4.4 关键步骤说明 + +| 阶段 | 关键文件 | 关键函数 | 备注 | +|------|---------|---------|------| +| 预签名 | `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` | 校验 + 生成 PUT URL | 30s 滑动窗口、单文件大小限制 | +| Collection | `FastGPT/packages/service/core/dataset/collection/controller.ts` | createCollection | 写 MongoDatasetCollection + Training 入队 | +| 解析 | `FastGPT/packages/service/worker/function.ts` | text2Chunks / splitText2Chunks | overlap 与最大长度 | +| 向量化 | `FastGPT/packages/service/core/ai/embedding/index.ts` | getVectorsByText / formatVectors | 1536 维强制对齐、L2 归一化 | +| 写入 | `FastGPT/packages/service/common/vectorDB/controller.ts` | recallFromVectorStore / insert | Milvus/PGVector/OceanBase 路由 | +| 数据存储 | `FastGPT/packages/service/core/dataset/data/schema.ts` | MongoDatasetData | chunkIndex、imageDescMap、source | +| 队列 | `FastGPT/packages/service/core/dataset/training/*` | MongoDatasetTraining | retryCount + lockTime | + +--- + +## 五、应用创建和配置流程 + +### 5.1 流程概述 + +应用创建覆盖 SimpleApp / Workflow / Plugin / Tool / Folder 等多种 `AppTypeEnum`,要求权限校验、类型限制校验、节点安全格式化、资源权限初始化等多个步骤在一次 MongoDB 事务中完成;后续编辑通过版本机制(`MongoAppVersion`)持久化。 + +### 5.2 深度分析 + +#### 5.2.1 创建入口 + +- 文件:`FastGPT/projects/app/src/pages/api/core/app/create.ts` +- `CreateAppBodySchema.safeParseAsync(req.body)`:兼容旧客户端缺字段时回退到原 body。 +- 权限分支: + - `parentId` 存在 → `authApp({ appId: parentId, per: WritePermissionVal })`,要求父文件夹写权限; + - 否则 → `authUserPer({ per: TeamAppCreatePermissionVal })`,要求团队级 App 创建权限。 +- `checkTeamAppTypeLimit({ teamId, appCheckType })`:`AppTypeEnum.workflowTool` 走 `tool` 套餐计数,其余按 `app` 计数。 +- `getMyModels`:根据团队 owner 状态收集可访问模型集合,再用 `removeUnauthModels` 过滤 `modules` 中引用但无权限的模型,避免越权使用。 + +#### 5.2.2 onCreateApp 事务 + +- 父类型校验: + - `ToolTypeList` 必须建在 `AppTypeEnum.toolFolder` 下; + - `AppTypeList` 必须建在 `AppTypeEnum.folder` 下。 +- 头像处理:若有 `templateId`,从 `MongoAppTemplate` 读取 avatar,若为 S3 Object Key,使用 `copyAvatarImage` 复制到团队目录(temporary=true);其余直接透传。 +- 在 `mongoSessionRun` 内串行执行(同一 session 保证原子性): + 1. `MongoApp.create([...], { session, ordered: true })` 写主表; + 2. 当 `type` 不在 `AppFolderTypeList` 中时,`MongoAppVersion.create(...)` 创建初始版本(`isPublish: true`),后续编辑都会再 push 新版本; + 3. `MongoResourcePermission.insertOne({ permission: OwnerRoleVal, resourceType: PerResourceTypeEnum.app })` 给创建者赋 Owner 权限; + 4. `getS3AvatarSource().refreshAvatar(...)` 转持久头像; + 5. 异步 `updateParentFoldersUpdateTime` 更新父文件夹 mtime; + 6. 异步 `addAuditLog` 记录 `AuditEventEnum.CREATE_APP`。 +- 返回 `appId`,再由外层 `pushTrack.createApp` 埋点。 + +#### 5.2.3 节点安全格式化 + +- 文件:`FastGPT/packages/service/core/app/controller.ts` +- `beforeUpdateAppFormat` 在每次"更新 App"或"发布版本"时调用: + - `headerSecret`(HTTP 节点头密钥)→ `storeSecretValue`; + - 渲染类型为 `password` 的输入 → `encryptSecretValue`; + - `systemInputConfig` 中类型为 `secret` 且 `manual` 模式的字段同样加密; + - `datasetSearchNode` 节点对 `datasetSelectList` 做 schema 规整,过滤无 `datasetId` 的脏数据。 + +#### 5.2.4 版本管理与最新版获取 + +- `getAppLatestVersion(appId, app)`(`FastGPT/packages/service/core/app/version/controller.ts`): + - 优先查 `MongoAppVersion` 中最新 `isPublish: true` 记录; + - 不存在时回退到 App 主表的 `modules`/`edges`(草稿态); + - 老版本仍保留以支持回滚。 + +#### 5.2.5 关联资源清理 + +- `controller.ts` 中包含 `findAppAndAllChildren`、`deleteSandboxesByAppId`、`removeImageByPath` 等,删除 App 时连带删除 ChatItem / ChatItemResponse / OutLink / OpenApi / Version / ChatInputGuide / FavouriteApp / ChatSetting / ResourcePermission / AppLogKeys / AppChatLog / AppRegistration / McpKey / AppRecord / Sandbox。 + +### 5.3 时序图(应用创建流程) + +```mermaid +sequenceDiagram + autonumber + participant FE as 前端创建页 / 模板复制 + participant API as create.ts handler + participant Auth as authApp / authUserPer + participant Limit as checkTeamAppTypeLimit + participant Models as getMyModels + removeUnauthModels + participant Tx as mongoSessionRun + participant App as MongoApp + participant Ver as MongoAppVersion + participant Perm as MongoResourcePermission + participant Avatar as S3 Avatar Source + participant Audit as auditLog / pushTrack + + FE->>API: POST /api/core/app/create(parentId, type, modules, edges, templateId) + API->>API: CreateAppBodySchema.safeParseAsync(body) + alt parentId 存在 + API->>Auth: authApp(parentId, WritePermissionVal) + else 无 parentId + API->>Auth: authUserPer(TeamAppCreatePermissionVal) + end + Auth-->>API: { teamId, tmbId, isRoot } + API->>Limit: checkTeamAppTypeLimit(teamId, app/tool) + Limit-->>API: ok + API->>Models: getMyModels(teamId, tmbId, isOwner) + Models-->>API: allowedModels + API->>API: removeUnauthModels(modules, allowedModels) + API->>Tx: mongoSessionRun(create) + Tx->>App: MongoApp.create([{ name, modules, edges, type, version:'v2' }]) + alt type 不属于 AppFolderTypeList + Tx->>Ver: MongoAppVersion.create([{ appId, nodes, edges, isPublish:true }]) + end + Tx->>Perm: insertOne({ resourceId=appId, permission=OwnerRoleVal }) + Tx->>Avatar: refreshAvatar(_avatar, session) + Tx-->>API: appId + API->>Audit: addAuditLog(CREATE_APP) + pushTrack.createApp + API-->>FE: CreateAppResponseSchema.parse(appId) +``` + +### 5.4 关键步骤说明 + +| 步骤 | 关键文件 | 关键函数 | 备注 | +|------|---------|---------|------| +| 入参解析 | `FastGPT/projects/app/src/pages/api/core/app/create.ts` | `CreateAppBodySchema.safeParseAsync` | 解析失败回退原 body 兼容老客户端 | +| 权限分支 | 同上 | `authApp` / `authUserPer` | 父文件夹写权限或团队 App 创建权限 | +| 套餐限制 | 同上 | `checkTeamAppTypeLimit` | tool/app 分别计数 | +| 模型权限 | 同上 | `getMyModels` + `removeUnauthModels` | 过滤无权限模型引用 | +| 节点安全 | `FastGPT/packages/service/core/app/controller.ts` | `beforeUpdateAppFormat` | 加密 secret、规整 datasetSelectList | +| 事务写入 | 同上 | `mongoSessionRun` | App / Version / Permission 原子化 | +| 头像处理 | `FastGPT/packages/service/common/s3/sources/avatar.ts` | `copyAvatarImage` + `refreshAvatar` | 模板头像复制到团队空间 | +| 版本最新 | `FastGPT/packages/service/core/app/version/controller.ts` | `getAppLatestVersion` | 已发布优先,草稿回退 | +| 审计与埋点 | `FastGPT/packages/service/support/user/audit/util.ts` + `FastGPT/packages/service/common/middle/tracks/utils.ts` | `addAuditLog` + `pushTrack.createApp` | 异步、不阻塞主流程 | + +--- + +## 六、文档结构与对应关系 + +| 流程 | 关键入口 | 关键控制器 / 引擎 | 关键模型/工具 | +|------|---------|---------------|--------------| +| 对话流程 | `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` | `dispatchWorkFlow` + `createLLMResponse` | `MongoChatItem` / `MongoChatItemResponse` / SSE `workflowResponseWrite` | +| RAG 检索 | `FastGPT/packages/service/core/dataset/search/controller.ts` | `searchDatasetData` + `dispatchAgentDatasetSearch` | `getVectorsByText` / `reRankRecall` / `recallFromVectorStore` | +| Agent 执行 | `FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` | `dispatchRunAgent` + `masterCall` + `dispatchPlanAgent` | `runAgentCall` / `createSandboxSkillsCapability` / `getSubapps` | +| 数据处理 | `FastGPT/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts` | `MongoDatasetTraining` Worker | `getVectorsByText(type=db)` / Milvus/PGVector | +| 应用创建 | `FastGPT/projects/app/src/pages/api/core/app/create.ts` | `onCreateApp` | `mongoSessionRun` / `MongoApp` + `MongoAppVersion` + `MongoResourcePermission` | + +> 各流程均按"前端入口 → API 鉴权 → 业务编排 → 持久化/外部调用 → 响应/计费"的统一形态组织,便于快速定位代码与排障。 + +--- + +## 七、近期重要分支变更(基于 develop-1.3.0 git 历史) + +> 本节归纳近期对核心业务流程产生影响的变更,便于读者把上文与最新代码对齐。 + +### 7.1 对话流程 —— Helper Bot 与流式恢复 + +- **新增 Helper Bot 子链路**:`/api/core/chat/helperBot/completions` 与 普通对话隔离运行,使用独立的 `MongoChatItem` 数据集。Helper Bot 流程与第一节几乎一致,但绕过 `recordAppUsage`(不计费),用于内置问答与文件附件解析。 +- **流式中断续传**:新增 `POST /api/core/chat/resume`,针对网络中断后客户端可携带 `chatId + responseChatItemId` 重新建立 SSE 通道,服务端从 `MongoChatItemResponse` 重放未送达的 chunk。前端 ChatBox 默认在 SSE 断流时调度该接口。 +- **历史状态查询**:新增 `GET /api/core/chat/history/getHistoryStatus` 与 `markRead`,支撑前端"未读 / 生成中"状态展示。 + +### 7.2 RAG 检索 —— 多语言 Agentic Search 与 Collection 权限 + +- **多语言自适应**:`searchDatasetDataForAssistant` 在 Agentic 模式下加入语言检测(`f4d188a09`),根据问题语言切换 prompt 与扩展查询模板,支持中英日三语自动切换。 +- **同义词索引行为变更**:`a6ec33026 删除同义词不触发索引恢复` —— 同义词删除后不再批量重建索引,仅标记软删除;如需重建调用 `training/rebuildEmbedding`。 + +### 7.3 Agent 执行 —— piAgent 引擎与 Context Compaction + +- **piAgent 引擎**:当 `env.AGENT_ENGINE === 'pi'` 时,整个 Agent 执行进入 `dispatchPiAgent`(`FastGPT/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts`),与默认 master/plan 双层引擎并存。 + - piAgent 内部使用独立的 `toolAdapter.ts` 转换工具描述;工具集来源仍是 `getSubapps` 的输出。 + - **piAgent 不再使用沙箱通用工具**(`b2fb185d0`),改用专用 `systemPrompt` 解析;新增 `useAgentSandbox + SANDBOX_TOOLS` 后又重新支持沙箱(`12501eb98`)。 +- **Context Compaction**:`ec51c573a` 新增由环境变量控制的上下文压缩开关,piAgent 在历史超阈值时主动触发压缩并把过程作为独立 nodeResponse 输出,便于审计。 +- **Skill 同步**:`createSandboxSkillsCapability` 在 sandbox warm start 阶段按 `skillId` 增量同步并清理过期目录(`dd688f98e`);高并发场景下避免重复加载技能 zip 包。 + +### 7.4 数据处理 —— Embedding/Rerank 训练与 stream pipeline + +- **新增训练流水线**:在原"文件 → Chunk → 向量"的基础上,新增 Embedding/Rerank 模型自训练子流程(`/api/core/train/embedding/**`、`/api/core/train/rerank/**`)。 + - Trainset 生成走 stream-based pipeline(`2d15c67a4`),按需采样、内存恒定。 + - 评测:`task/eval-dataset` + `task/eval-report` 输出训练前后召回率/MRR 等指标。 +- **PDF 密码处理**:`aa286dec1 fix pdf password` 修复加密 PDF 解析失败问题,新代码读取 PDF 时会优先尝试用户提供的密码,失败再走默认空密码分支。 +- **TextSplitter 优化**:`43025e56e opt textSplitter.ts` 调整长段落切分策略,避免 overlap 导致上下文重复。 +- **图片上传扩展名**:`a09053d5d` 头像上传白名单新增 `.svg`、`.ico`(仅头像,知识库图片仍是常规位图扩展名)。 + +### 7.5 应用创建 —— 关联检查与节点 UI 内联化 + +- **工作流节点配置内联化**:`bc67e051a feat(workflow): 将知识库检索配置内联展示`、`bb06dd715 feat(tool): 优化工具配置模态框标签过滤逻辑` 等若干 UI 重构,把节点配置从模态框移到节点内联面板,减少二次跳转。 +- **创建应用时模型权限过滤**:流程未变,但 `getMyModels` 现在依赖团队 owner 状态 + 模型 `supportTrain`/`instruction` 标记,进一步收紧无授权模型的访问。 +- **微信 OutLink**:新增 `outLink/wechat/qrcode/{generate,status}`、`outLink/wechat/logout`,应用支持微信扫码授权进入分享会话;分支与第一节中的 `authShareChat` 共用一套权限上下文。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..7926dcc8b73a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,75 @@ +--- +capability_label: 价格 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [团队管理员, 团队成员] +router_paths: [/price] +--- + +# 价格 — 业务流程索引 + +## 查看价格与购买套餐 + +- **业务描述**: 用户进入价格中心查看标准订阅方案(免费/基础/高级/定制),选择合适的方案和付费周期(月付/年付)进行购买、续费或升级。 +- **触发入口**: 导航菜单「价格」→ 价格中心页面 +- **路由路径**: `/price` +- **页面组件位置**: `projects/app/src/pages/price/index.tsx` +- **涉及角色**: 全体团队用户(购买操作需团队管理员权限) +- **前置条件**: 用户已登录并加入团队 + +## 购买额外 AI 积分 + +- **业务描述**: 在标准套餐之外,购买额外的 AI 积分包,用于模型调用消耗。支持选择不同面额的积分包,显示活动赠送积分和总价。 +- **触发入口**: 价格中心页面 → 额外增值服务区域 → AI 积分卡片 +- **路由路径**: `/price`(页面内区域) +- **页面组件位置**: `projects/app/src/pageComponents/price/ExtraPlan.tsx` +- **涉及角色**: 团队管理员(企业微信免费团队不可购买) +- **前置条件**: 用户有有效团队,非企业微信免费团队 + +## 购买额外知识库容量 + +- **业务描述**: 在标准套餐之外,购买额外的知识库索引容量。用户可自定义购买的容量大小(以 1000 组为单位)和有效期(1/3/6/12 个月)。 +- **触发入口**: 价格中心页面 → 额外增值服务区域 → 知识库容量卡片 +- **路由路径**: `/price`(页面内区域) +- **页面组件位置**: `projects/app/src/pageComponents/price/ExtraPlan.tsx` +- **涉及角色**: 团队管理员(企业微信免费团队不可购买) +- **前置条件**: 用户有有效团队,非企业微信免费团队 + +## 查看 AI 模型计费 + +- **业务描述**: 查看各 AI 模型的积分计费标准,包括对话模型(LLM)、向量模型(Embedding)、语音合成(TTS)、语音识别(STT)的单价。 +- **触发入口**: 价格中心页面 → AI 模型计费区域 +- **路由路径**: `/price`(页面内区域) +- **页面组件位置**: `projects/app/src/pageComponents/price/Points.tsx` +- **涉及角色**: 全体团队用户 +- **前置条件**: 无特殊前置条件 + +## 查看常见问题 + +- **业务描述**: 浏览关于套餐切换、订阅查询、AI 积分使用、知识库计算、套餐叠加等 10 个常见问题的解答。 +- **触发入口**: 价格中心页面 → FAQ 区域 +- **路由路径**: `/price`(页面内区域) +- **页面组件位置**: `projects/app/src/pageComponents/price/FAQ.tsx` +- **涉及角色**: 全体团队用户 +- **前置条件**: 无特殊前置条件 + +## 场景关系 + +各场景独立执行,无严格先后关系。用户可按需滚动到对应区域操作: +- **查看价格与购买套餐** → 用户选择套餐后进入支付 +- **购买额外 AI 积分** → 独立于套餐购买,可单独操作 +- **购买额外知识库容量** → 独立于套餐购买,可单独操作 +- **查看 AI 模型计费** → 纯信息展示,不触发业务流程 +- **查看常见问题** → 纯信息展示,不触发业务流程 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看价格与购买套餐 | 菜单→价格中心 | /price | 全体用户 | +| S02 | 购买额外 AI 积分 | 价格中心→增值服务区域 | /price | 管理员 | +| S03 | 购买额外知识库容量 | 价格中心→增值服务区域 | /price | 管理员 | +| S04 | 查看 AI 模型计费 | 价格中心→AI 计费区域 | /price | 全体用户 | +| S05 | 查看常见问题 | 价格中心→FAQ 区域 | /price | 全体用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..b65147aaf2da --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,250 @@ +--- +capability_label: 价格 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [团队管理员, 团队成员] +router_paths: [/price] +--- + +# 价格 — 业务流程详解 + +## 页面总览 + +价格中心采用单页滚动布局,顶部为返回按钮(团队已有订阅时显示),依次展示标准订阅套餐、额外增值服务(AI 积分 + 知识库容量)、AI 模型计费表和常见问题解答。页面加载时先获取用户信息和团队订阅状态,加载期间显示全屏加载动画(MyLoading),加载完成后一次性渲染全部内容区域。 + +### S01:查看价格与购买套餐 + +> 用户进入价格中心浏览标准订阅方案,选择合适的套餐等级和付费周期进行购买、续费或升级。 + +#### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 导航至 `/price` 页面 | GET `/support/user/team/plan/getTeamPlanStatus`(获取团队订阅状态);由 `useUserStore.initUserInfo` 触发用户信息请求 | — | 显示全屏 MyLoading 加载动画;两个请求串行:先获取用户信息,再依据用户信息获取团队计划状态 | + +- **数据加载详情**:页面初始化使用 `useRequest` hook,用户信息请求和团队计划请求串行执行(`refreshDeps: [userInfo]`)。两者均完成前,页面显示加载状态;加载失败则页面保持加载状态。 + +#### 步骤 2:选择付费周期 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看月付/年付切换开关 | — | 非企业微信团队时显示月付/年付切换;企业微信团队时隐藏切换开关,默认年付 | 月付/年付 RowTabs 显示,默认选中年付(活动期间)或月付(无活动);切换时套餐卡片价格实时更新 | + +- **价格计算规则**:月付价格为方案原价;年付价格为方案原价 × 10(含 12 个月 AI 积分 + 10 个月订阅费)。 + +- **优惠券匹配**:页面加载时获取团队的优惠券列表(`getDiscountCouponList`),按当前选择的付费模式匹配对应的折扣券(月付匹配 7 折券,年付匹配 9 折券)。匹配到有效优惠券时,套餐卡片显示折后价格和折扣标签。 + +#### 步骤 3:选择套餐等级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览套餐卡片(免费/基础/高级/定制) | — | 当前套餐卡片高亮(蓝色边框 + "使用中"标签);活动期间基础/高级卡片显示活动装饰(飘带、雪花、优惠截止时间) | 免费卡片无边框高亮、显示 "Free" 禁用按钮;基础/高级卡片显示价格和功能列表;定制卡片显示"联系商务"按钮跳转自定义表单链接 | + +- **套餐卡片差异**: + - 免费套餐:按钮始终禁用,显示 "Free" + - 当前套餐:显示"续费"按钮,活动期间为红色实心按钮(带雪花装饰) + - 更高级套餐:显示"升级套餐"按钮 + - 未购买的低级套餐:显示"购买"按钮 + - 定制套餐:显示"联系商务"按钮,点击打开 `customFormUrl` 外部链接 + - 企业微信高级团队基础套餐:按钮禁用,不可降级 + +#### 步骤 4:提交购买 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"购买"/"续费"/"升级套餐"按钮 | POST `/proApi/support/wallet/bill/create`(参数:type, level, subMode, discountCouponId) | 企业微信团队且为免费套餐时,某些套餐按钮可能禁用 | 按钮显示 loading 状态(`isLoading`);请求成功后根据返回结果分支处理 | + +- **支付响应分支**: + - 返回 `payUrl`(企业微信支付):在新标签页打开支付链接(`window.open(res.payUrl, '_blank')`) + - 返回其他支付方式:显示 QRCodePayModal 二维码支付弹窗,弹窗标题根据操作类型显示"购买"/"续费"/"升级" + +#### 步骤 5:支付完成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 完成支付(扫码或在新标签页完成企业微信支付) | — | QRCodePayModal 的 `onSuccess` 回调触发 | 1 秒延迟后 `router.reload()` 刷新页面,更新套餐状态 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 价格页面 + participant API as 后端 API + participant Pay as 支付服务 + + User->>UI: 导航至 /price + UI->>API: GET 用户信息 + API-->>UI: 用户信息 + UI->>API: GET 团队订阅状态 + API-->>UI: 订阅状态 + 优惠券列表 + UI-->>User: 渲染套餐卡片(加载完成) + + User->>UI: 切换月付/年付 + UI-->>User: 价格实时更新 + + User->>UI: 选择套餐 → 点击购买/续费/升级 + UI->>API: POST 创建支付账单 + alt 企业微信支付 + API-->>UI: 返回 payUrl + UI->>Pay: 新标签页打开支付链接 + else 其他支付方式 + API-->>UI: 返回 billId 等信息 + UI-->>User: 显示二维码支付弹窗 + User->>Pay: 扫码支付 + end + Pay-->>UI: 支付成功 + UI->>UI: 1 秒后刷新页面 +``` + +### S02:购买额外 AI 积分 + +> 用户在额外增值服务区域选择 AI 积分包并购买。支持活动期间赠送额外积分。 + +#### 步骤 1:查看积分包 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动到"额外 AI 积分"卡片区域 | — | 系统配置 `subPlans?.extraPoints?.packages` 为空时无积分包可选 | 积分包以 2-3 列网格展示,每包显示积分数、有效期、活动赠送积分角标(如有);活动期间显示雪花装饰和促销截止时间 | + +#### 步骤 2:选择积分包并购买 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击积分包卡片 | — | — | 选中卡片高亮(蓝色边框 + 蓝色背景) +| 查看总积分和价格 | — | 活动期间总积分 = 基础积分 + 赠送积分(`activityBonusPoints`);无活动时总积分 = 基础积分 | 总积分区域实时更新;价格区域显示 `¥{price}` +| 点击"购买"按钮 | POST `/proApi/support/wallet/bill/create`(参数:type=extraPoints, extraPoints, month) | 企业微信免费团队 → toast 警告 "额外增值服务暂不可用";积分包未选择 → 按钮禁用 | 按钮显示 loading 状态;成功后显示 QRCodePayModal + +#### 步骤 3:支付完成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 完成支付 | — | `onPaySuccess` 回调 | 1 秒延迟后刷新页面 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as ExtraPlan + participant API as 后端 API + + User->>UI: 滚动到 AI 积分区域 + UI-->>User: 渲染积分包网格 + + User->>UI: 点击选择一个积分包 + UI-->>User: 卡片高亮 + 总价更新 + + User->>UI: 点击"购买" + alt 企业微信免费团队 + UI-->>User: Toast 警告"额外增值服务暂不可用" + else 正常团队 + UI->>API: POST 创建额外积分账单 + API-->>UI: billId + UI-->>User: 显示二维码支付弹窗 + User->>UI: 扫码支付完成 + UI->>UI: 刷新页面 + end +``` + +### S03:购买额外知识库容量 + +> 用户自定义购买额外的知识库索引容量,可指定容量大小和有效期。 + +#### 步骤 1:配置购买参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动到"额外知识库容量"卡片 | — | 显示知识库容量单价 `¥{extraDatasetPrice}/1000 组` | 显示购买表单:数量输入 + 有效期下拉 | +| 调整知识库容量 | — | 最小值 0(等价于不购买),最大值 10000 | MyNumberInput 步进器调整数量(以 1000 组为单位) | +| 选择有效期 | — | 选项:1 个月、3 个月、6 个月、12 个月 | MySelect 下拉切换 | + +#### 步骤 2:价格实时计算 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 调整数量或有效期 | — | 使用 `calculatePrice()` 函数实时计算:`extraDatasetPrice × datasetSize × month`;计算结果为 NaN 时显示 0 | 价格区域实时更新,显示 `¥{总价}` | + +#### 步骤 3:提交购买 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"购买"按钮 | POST `/proApi/support/wallet/bill/create`(参数:type=extraDatasetSub, month, extraDatasetSize) | 企业微信免费团队 → toast 警告;`datasetSizePayAmount === 0` → toast 警告 "金额为 0" | 按钮显示 loading;成功后显示 QRCodePayModal | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as ExtraPlan + participant API as 后端 API + + User->>UI: 滚动到知识库容量区域 + UI-->>User: 显示容量单价 + 购买表单 + + User->>UI: 调整容量大小(MyNumberInput) + UI-->>User: 价格实时计算更新 + User->>UI: 选择有效期(MySelect) + UI-->>User: 价格实时计算更新 + + User->>UI: 点击"购买" + alt 金额为 0 + UI-->>User: Toast 警告"金额为0" + else 企业微信免费团队 + UI-->>User: Toast 警告"额外增值服务暂不可用" + else 正常 + UI->>API: POST 创建额外知识库容量账单 + API-->>UI: billId + UI-->>User: 显示二维码支付弹窗 + end +``` + +### S04:查看 AI 模型计费 + +> 用户查看各类型 AI 模型的积分计费标准,了解不同模型的消耗单价。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动到 "AI 积分" 区域 | — | 从 `useSystemStore` 读取模型列表(`llmModelList`/`embeddingModelList`/`ttsModelList`/`sttModelList`),数据由全局初始化时加载 | 显示 ModelTable 组件,菜单式表格按模型类型分组展示:对话模型 → 向量模型 → 语音合成 → 语音识别 | + +- **数据加载详情**:模型价格数据来自 `useSystemStore` 中的 `llmModelList`、`embeddingModelList`、`ttsModelList`、`sttModelList`,由页面初始化全局加载。价格展示支持阶梯定价(通过 `PriceTiersLabel` 组件),鼠标悬停阶梯价格时显示价格明细表格。 +- **计费单位**:对话模型和向量模型按 `/1000 Tokens` 计费;语音合成按 `/1000 字符` 计费;语音识别按 `/60 秒` 计费。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as Points + participant Store as useSystemStore + + User->>UI: 滚动到 AI 积分区域 + UI->>Store: 读取模型价格列表 + Store-->>UI: LLM/Embedding/TTS/STT 模型数据 + UI-->>User: 按类型分组渲染模型价格表 + User->>UI: 悬停阶梯价格 + UI-->>User: 显示价格阶梯明细 Tooltip +``` + +### S05:查看常见问题 + +> 用户浏览订阅相关的常见问题解答。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动到 FAQ 区域 | — | FAQ 数据硬编码在组件中(10 个问答),不依赖 API 请求 | 以 2 列网格展示 FAQ 卡片,每张卡片包含标题(加粗)和描述文本。鼠标悬停时卡片边框变为蓝色。FAQ 内容涵盖:套餐切换、订阅查询、AI 积分使用、积分过期、知识库计算、索引删除、套餐叠加、QPM 限制、年/日计算、免费用户数据清理 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as FAQ 组件 + + User->>UI: 滚动到 FAQ 区域 + UI-->>User: 渲染 10 个 FAQ 卡片(2 列网格) + User->>UI: 鼠标悬停卡片 + UI-->>User: 边框变为蓝色(primary.300) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..ecb0acd99ef9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: 价格 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [团队管理员, 团队成员] +router_paths: [/price] +--- + +# 价格 — 模块概览 + +## 1. 模块业务概述 + +价格模块是 FastGPT 的订阅购买中心,为用户提供完整的套餐选购、增值服务购买和 AI 模型计费查询功能。模块面向所有团队用户,支持标准订阅套餐的购买/续费/升级、额外 AI 积分的购买、额外知识库容量的购买,以及查看不同 AI 模型的价格信息。 + +页面采用单页滚动布局,自上而下依次展示标准订阅方案、额外增值服务、AI 模型计费表和常见问题解答,用户无需跳转即可完成从选套餐到支付的完整流程。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 全部价格页面 | 购买/续费/升级标准套餐,购买额外积分和知识库容量,查看模型价格 | 有完整购买权限 | +| 团队成员 | 全部价格页面 | 查看套餐方案和模型价格 | 购买操作由管理员权限控制 | + +**权限模型**: 基于团队订阅状态的前端条件渲染,通过 `useUserStore` 获取用户信息和团队计划状态。企业微信团队(`isWecomTeam`)有专属的购买限制和价格逻辑。免费套餐的企业微信团队无法购买额外增值服务。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 钱包/账单 | 创建支付账单 API(`postCreatePayBill`)、支付二维码弹窗组件(`QRCodePayModal`) | API / 组件 | +| 账户/团队 | 获取团队计划状态、团队计划列表 API | API | +| 系统配置 | 订阅方案配置(`subPlans`)、功能开关(`feConfigs`)、模型列表 | Store | +| 用户 | 用户信息(`useUserStore`)、团队订阅状态、初始化用户数据 | Store | +| AI 模型 | 模型价格展示组件(`ModelTable`、`ModelPriceModal`)、价格阶梯标签(`PriceTiersLabel`) | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 标准套餐内容列表(`StandardPlanContentList`) | 账户/账号信息 | 组件 | +| 支付二维码弹窗(`QRCodePayModal`) | 钱包/余额不足弹窗 | 组件 | +| 额外方案组件(`ExtraPlan`) | 钱包/余额不足弹窗 | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 价格中心 | /price | `projects/app/src/pages/price/index.tsx` | 全体团队用户 | 单页布局,含标准套餐、额外增值、AI 计费、FAQ 四个区域 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..296cc54b3707 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,109 @@ +--- +capability_label: 对话 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: ["登录用户", "匿名用户", "团队成员"] +router_paths: ["/chat", "/chat/share"] +--- + +# 对话 — 业务流程索引 + +## 对话首页(/chat) + +### 首页自由对话 + +- **业务描述**: 用户在对话首页选择 AI 模型和可选工具,直接与 AI 进行自由对话,无需预先配置应用。 +- **触发入口**: 左侧导航栏点击首页图标 +- **路由路径**: /chat?appId={homeAppId} +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` +- **涉及角色**: 登录用户(Plus 版) +- **前置条件**: 用户已登录,系统开启 Plus 功能(feConfigs.isPlus) + +### 快捷应用对话 + +- **业务描述**: 用户从收藏的快捷应用中快速启动对话,使用预配置的 AI 应用进行专项对话。 +- **触发入口**: 左侧导航栏顶部的快捷应用面板 +- **路由路径**: /chat?appId={favouriteAppId} +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx` +- **涉及角色**: 登录用户(Plus 版) +- **前置条件**: 用户已登录,Plus 版功能开启,已设置快捷应用 + +### 团队应用对话 + +- **业务描述**: 用户从团队应用列表中选择 AI 应用进行对话,应用由团队成员创建和配置。 +- **触发入口**: 左侧导航栏点击团队应用图标 +- **路由路径**: /chat?appId={teamAppId} +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatTeamApp/index.tsx` +- **涉及角色**: 登录用户(团队成员) +- **前置条件**: 用户已登录,所在团队有可用应用 + +### 最近使用应用对话 + +- **业务描述**: 从最近使用记录中恢复与某应用的对话,查看历史消息或继续对话。 +- **触发入口**: 左侧导航栏最近使用的应用列表 +- **路由路径**: /chat?appId={appId} +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx` +- **涉及角色**: 登录用户 +- **前置条件**: 用户已登录,存在历史对话记录 + +### 对话设置 + +- **业务描述**: 配置对话首页的个性化设置,包括快捷应用管理、首页应用配置、对话首页标语自定义等。 +- **触发入口**: 左侧导航栏设置图标 +- **路由路径**: /chat(pane=s) +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/index.tsx` +- **涉及角色**: 登录用户(Plus 版) +- **前置条件**: 用户已登录,系统开启 Plus 功能 + +## 分享对话(/chat/share) + +### 匿名分享对话 + +- **业务描述**: 外部用户通过分享链接直接与 AI 应用对话,无需登录或认证。 +- **触发入口**: 访问分享链接(含 shareId) +- **路由路径**: /chat/share?shareId={id} +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` +- **涉及角色**: 匿名用户 +- **前置条件**: 应用已发布分享链接,发布配置允许匿名访问(allowAnonymous) + +### 认证分享对话 + +- **业务描述**: 具有身份凭证的外部用户通过分享链接进行对话,可查看自身历史对话。 +- **触发入口**: 带 authToken 或 customUid 的分享链接 +- **路由路径**: /chat/share?shareId={id}&authToken={token} +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` +- **涉及角色**: 认证用户 +- **前置条件**: 发布配置不允许匿名访问或需要追踪用户身份 + +### 嵌入分享对话 + +- **业务描述**: 应用以 iframe 形式嵌入第三方页面,外部用户直接在嵌入窗口中对话;支持向父页面发送对话状态消息。 +- **触发入口**: 第三方页面的 iframe 标签 +- **路由路径**: /chat/share?shareId={id} +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` +- **涉及角色**: 匿名/认证用户 +- **前置条件**: 发布者提供嵌入代码,应用被嵌入第三方页面 + +## 场景关系 + +- **首页自由对话** → **快捷应用对话**: 用户在首页可将常用应用设为快捷应用,后续从快捷入口直达 +- **团队应用对话** → **最近使用应用对话**: 团队应用对话结束后自动记录到最近使用列表 +- **对话设置** → **首页自由对话**: 设置中的快捷应用配置直接影响首页的快捷应用面板展示 +- **分享对话**各子场景间:嵌入模式和认证模式可组合使用,互不冲突 + +各场景独立执行,无严格先后关系。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 首页自由对话 | — | 导航栏→首页图标 | /chat | 登录用户 | +| S02 | 快捷应用对话 | — | 导航栏→快捷应用面板 | /chat | 登录用户 | +| S03 | 团队应用对话 | — | 导航栏→团队应用图标 | /chat | 团队成员 | +| S04 | 最近使用应用对话 | — | 导航栏→最近使用列表 | /chat | 登录用户 | +| S05 | 对话设置 | — | 导航栏→设置图标 | /chat | 登录用户 | +| S06 | 匿名分享对话 | — | 外部链接 | /chat/share | 匿名用户 | +| S07 | 认证分享对话 | — | 带凭证的外部链接 | /chat/share | 认证用户 | +| S08 | 嵌入分享对话 | — | iframe 嵌入代码 | /chat/share | 匿名/认证用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..6a2d1229f096 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,20 @@ +--- +capability_label: 对话 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: ["登录用户", "匿名用户", "团队成员"] +router_paths: ["/chat", "/chat/share"] +--- + +# 对话 — 业务流程详解 + +> 本模块为纯分组节点,不直接包含独立业务流程。其下子能力的详细交互步骤由各自子能力文档负责。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|------------|------------| +| 对话首页 | 登录用户的 AI 对话主界面,多窗格切换 | [09-业务流程索引](./对话首页/业务流程索引.md) | [10-业务流程详解](./对话首页/业务流程详解.md) | +| 分享对话 | 通过外链分享 AI 对话给外部用户 | [09-业务流程索引](./分享对话/业务流程索引.md) | [10-业务流程详解](./分享对话/业务流程详解.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..87e82bf21f28 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,54 @@ +--- +capability_label: "分享对话" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — 业务流程索引 + +## 业务场景列表 + +### S01:通过分享链接进行 AI 对话 + +- **业务描述**: 外部用户通过应用发布者分享的链接,匿名或登录后与应用进行 AI 对话交互,并获得流式回复。 +- **触发入口**: 点击分享链接或扫码进入(外部来源) +- **路由路径**: `/chat/share?shareId=xxx` +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` +- **涉及角色**: 匿名用户、认证用户 +- **前置条件**: 分享链接有效且应用已发布;当 `allowAnonymous=false` 时需要完成登录和权限验证。 + +### S02:查看/清除分享对话历史 + +- **业务描述**: 在分享对话页面侧边栏中查看历史对话记录,支持清空当前分享会话的历史。 +- **触发入口**: 分享对话页左侧历史侧边栏(PC 端 URL 参数 `showHistory=1`) +- **路由路径**: `/chat/share?shareId=xxx&showHistory=1` +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` → `ChatHistorySidebar` +- **涉及角色**: 匿名用户、认证用户 +- **前置条件**: 分享链接有效,且至少有 1 条对话记录。 + +### S03:查看 AI 回复引用来源 + +- **业务描述**: 当 AI 回复包含知识库数据集引用时,在对话区域右侧展示引用的数据集内容或原文参考面板。 +- **触发入口**: AI 回复中的引用标记(点击数据集引用) +- **路由路径**: `/chat/share?shareId=xxx` +- **页面组件位置**: `projects/app/src/pages/chat/share.tsx` → `QuoteReader` / `ReferencePanel` +- **涉及角色**: 匿名用户、认证用户 +- **前置条件**: 应用配置了知识库且 `isShowCite=true`;AI 回复包含数据集引用数据。 + +## 场景关系 + +- **S01** → **S03**: 对话过程中 AI 回复包含引用时,用户可点击引用标记触发 S03 查看来源。 +- **S01** → **S02**: 每次对话完成后自动更新历史记录,用户可随时进入侧边栏查看历史。 +- 各场景之间无严格先后依赖,S02 和 S03 为 S01 的辅助功能。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 通过分享链接进行 AI 对话 | 点击分享链接/扫码 | /chat/share | 匿名用户、认证用户 | +| S02 | 查看/清除分享对话历史 | 侧边栏历史列表 | /chat/share?showHistory=1 | 匿名用户、认证用户 | +| S03 | 查看 AI 回复引用来源 | AI 回复中引用标记 | /chat/share | 匿名用户、认证用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..ffee2dae8821 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,187 @@ +--- +capability_label: "分享对话" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — 业务流程详解 + +## 页面总览 + +分享对话页面是 FastGPT 对外提供 AI 对话服务的独立页面。用户通过分享链接 `/chat/share?shareId=xxx` 进入,与应用进行流式 AI 对话。页面根据 URL 参数 `allowAnonymous` 决定是否需要登录验证,默认支持匿名访问。页面布局为左侧可选历史侧边栏 + 中间对话区域 + 右侧可选引用面板的三栏结构。本页面无 Tab,所有功能在单页面内完成。 + +--- + +### 场景 S01:通过分享链接进行 AI 对话 + +> 外部用户通过分享链接进入页面,完成身份初始化后开始与 AI 应用对话,体验流式回复。 + +#### 步骤 1:页面初始化与身份识别 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 打开分享链接(含 shareId 等 URL 参数) | 服务端 `getServerSideProps` 查询 `MongoOutLink` | — | Next.js SSR 渲染,获取 appId/appName/appAvatar/shareId 等初始数据作为 Props 传入 | +| 页面加载完成 | 无(客户端初始化) | `allowAnonymous !== false`:跳过登录检查 | 页面直接渲染,`source` 设为 `'share'`,初始化本地语言 | +| 页面加载完成 | `getTokenLogin()` → `getInitChatInfo({appId, chatId})` | `allowAnonymous === false` 且未确认 | 先尝试自动登录 → 成功则验证应用权限;失败则显示登录弹窗 | +| 在登录弹窗中登录成功 | `getInitChatInfo({appId, chatId})` | 用户在弹窗中完成登录 | 验证通过则关闭弹窗进入对话;无权限则提示错误 | + +**前置条件**: 分享链接中的 shareId 有效;MongoOutLink 中有对应记录。 + +**后置影响**: 页面完成初始化,设置 `outLinkAuthData`(shareId + outLinkUid),准备开始对话。 + +**失败场景**: +- 分享链接无效(无 shareId 或 MongoOutLink 查询为空)→ `appId` 为空,页面显示"无效分享链接"警告 +- 自动登录失败且 `allowAnonymous=false` → 显示登录弹窗 +- 登录后无应用权限(错误码 ≥ 502000)→ 提示"无权限访问该应用" + +#### 步骤 2:获取聊天初始化数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面身份初始化完成 | GET `/core/chat/outLink/init`(参数:chatId, shareId, outLinkUid) | `forbidLoadChatMap` 中当前 chatId 未标记为禁止加载 | 显示加载状态;获取应用配置(变量列表、欢迎语等) | +| 同上 | 同上 | `forbidLoadChatMap` 中当前 chatId 被标记为禁止加载 | 跳过加载,等待当前 chatId 的加载标记被清除 | +| API 返回成功 | — | — | 设置 chatBoxData(含 app 配置、变量),重置变量列表,调用 `setChatBoxData` 更新 ChatBox 状态 | +| 页面数据就绪 + 聊天记录加载完成 | 无(客户端 postMessage) | 首次初始化(`initSign` 为 false)且 `window !== top` | 向父窗口发送 `shareChatReady` 消息(iframe 嵌入场景) | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/chat/outLink/init | chatId, shareId, outLinkUid | 合并 URL 自定义变量 + API 返回变量 | ChatBox 就绪,显示欢迎语 | +| 重新加载 | GET /core/chat/outLink/init | 新 chatId + shareId + outLinkUid | 重新合并变量 | 切换到新会话 | + +**前置条件**: `outLinkAuthData.shareId` 和 `outLinkAuthData.outLinkUid` 已设置。 + +#### 步骤 3:发送消息与流式对话 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 ChatBox 输入框输入消息并发送 | POST `/api/v2/chat/completions`(streaming) | — | 消息出现在对话列表中,显示 AI 头像和加载状态 | +| 同上(新对话) | 同上 | 当前 chatId 为新对话(不在 histories 中) | 标记 `forbidLoadChatMap`,禁止重复加载;向父窗口发送 `shareChatStart` 消息(iframe 模式) | +| 等待 AI 流式回复 | SSE 事件流 | — | AI 回复逐字显示;中间可能穿插工具调用状态、思考内容等 | +| AI 回复完成 | — | — | 对话轮次结束;如果是新对话则切换 chatId;自动生成对话标题(取首条消息内容截断);向父窗口发送 `shareChatFinish` 消息(iframe 模式) | + +**API 调用链详情**: + +| 参数 | 值 | 说明 | +|------|-----|------| +| messages | `histories_messages`(取最后 1 条) | 仅发送当前轮次消息 | +| variables | 合并 URL 自定义变量 + 表单变量 | 包含 `cTime` 格式化时间戳 | +| chatId | `completionChatId` | 新对话自动生成 nanoid | +| detail | `true` | 返回详细响应 | +| stream | `true` | 使用 SSE 流式传输 | +| retainDatasetCite | `isShowCite` 值 | 是否保留数据集引用数据 | + +**后置影响**: +- 新对话:URL chatId 更新,历史列表新增一条记录,标题自动更新 +- 对话记录自动保存到后端(MongoChat + MongoChatItem) + +**失败场景**: +- 网络异常 → 对话显示错误状态,提示"网络异常" +- AI 服务超时 → 显示超时错误 +- chatId 切换冲突 → `forbidLoadChatMap` 防止并发重复创建对话 + +#### 步骤 4:对话自动恢复 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载时检测未完成的对话 | 无(ChatBox `enableAutoResume` 自动处理) | 存在未完成的对话(`chatGenerateStatus === 'generating'`) | ChatBox 自动发起 `streamResumeFetch` 恢复流式连接,继续接收未完成的回复 | + +--- + +### 场景 S02:查看/清除分享对话历史 + +> 在分享对话页面的左侧历史侧边栏中管理对话记录。 + +#### 步骤 1:查看对话历史 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| PC 端:页面加载时自动展示侧边栏 | 由 `ChatContextProvider` 内部的 `useScrollPagination` 自动加载 | `showHistory === '1'`(URL 参数) | PC 端左侧显示可拖拽宽度的侧边栏(默认 250px,范围 180-350px) | +| 移动端:点击汉堡菜单 | 同上 | `showHistory !== '1'` | 侧边栏不显示(PC 和移动端均不展示) | +| 滚动历史列表到底部 | GET 历史记录(分页) | 还有更多记录 | 加载下一页对话历史 | + +**前置条件**: URL 参数 `showHistory=1`。 + +**分页参数**: 由 `ChatContextProvider` 内部的 `useScrollPagination` 控制,默认每页 20 条。 + +#### 步骤 2:清除分享对话历史 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击清除历史按钮 | 弹窗确认 | — | 显示确认弹窗"确认清空分享对话历史" | +| 确认清除 | DELETE `/core/chat/history`(批量删除) | — | 历史列表清空,当前对话保留 | + +--- + +### 场景 S03:查看 AI 回复引用来源 + +> 当 AI 回复中包含知识库引用时,用户可以查看引用来源的详细内容。 + +#### 步骤 1:触发引用查看 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 AI 回复中的引用标记/数字 | — | `datasetCiteData` 有值 | PC 端:对话区域右侧出现引用面板(ReferencePanel 或 QuoteReader),可拖拽调整宽度(默认 580px,范围 400-900px)
移动端:全屏展示引用面板 | + +**分支条件**: +- 引用数据中 `metadata` 含 `collectionId` → 显示 `ReferencePanel`(知识库集合引用模式) +- 引用数据中 `metadata` 不含 `collectionId` → 显示 `QuoteReader`(原文引用模式) + +#### 步骤 2:关闭引用查看 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击引用面板关闭按钮 | — | — | `setCiteModalData(undefined)`,引用面板消失,对话区域恢复完整宽度 | + +**前置条件**: 应用配置中 `isShowCite=true`(由 `MongoOutLink.showCite` 控制)。 + +--- + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as SharePage + participant API + participant Backend + + User->>UI: 打开分享链接 /chat/share?shareId=xxx + UI->>Backend: getServerSideProps → MongoOutLink.findOne + Backend-->>UI: appId, appName, allowAnonymous 等 + + alt allowAnonymous === false + UI->>API: getTokenLogin() + alt 自动登录成功 + API-->>UI: 登录成功 + UI->>API: GET /core/chat/init + API-->>UI: 权限验证通过 + else 自动登录失败 + UI->>User: 显示登录弹窗 + User->>UI: 输入账号登录 + UI->>API: 登录 + GET /core/chat/init + API-->>UI: 权限验证结果 + end + end + + UI->>API: GET /core/chat/outLink/init + API-->>UI: 应用配置、变量列表、欢迎语 + UI-->>User: 显示 ChatBox 对话界面 + + User->>UI: 输入消息并发送 + UI->>API: POST /api/v2/chat/completions (SSE) + API-->>UI: 流式 AI 回复 + UI-->>User: 逐字显示 AI 回复 + + opt 查看引用来源 + User->>UI: 点击引用标记 + UI-->>User: 右侧展示 ReferencePanel/QuoteReader + User->>UI: 关闭引用面板 + UI-->>User: 恢复完整对话区域 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..3343eb138a58 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: "分享对话" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — 模块概览 + +## 1. 模块业务概述 + +分享对话模块是 FastGPT 对外提供 AI 对话服务的核心入口。它允许应用创建者将已发布的应用通过分享链接分发给外部用户,外部用户无需登录或注册即可通过该链接与 AI 应用进行对话交互。 + +该模块在整个产品中定位为**对外服务窗口**——通过独立的分享页面承载外部用户与 AI 应用的完整对话体验,同时支持可选的匿名访问和强制认证两种模式,满足不同场景下的安全需求。 + +本模块为叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 匿名用户 | 分享对话页 | 发送消息、查看对话历史 | 通过 shareId + 自动生成的 localUId 标识身份;无需登录 | +| 认证用户 | 分享对话页 + 登录弹窗 | 发送消息、查看对话历史、切换登录账号 | 当 `allowAnonymous` 为 false 时强制登录;支持 token 自动登录 | + +**权限模型**: 基于 `MongoOutLink` 中的 `allowAnonymous` 字段控制访问模式。当 `allowAnonymous === false` 时,页面先尝试 `getTokenLogin()` 自动登录,失败后展示 `LoginModal` 要求用户登录并验证应用权限(`getInitChatInfo`)。后端通过 shareId + outLinkUid(authToken / customUid / localUId)组合进行身份校验。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| ChatContainer/ChatBox | 核心对话容器(消息发送、流式渲染、自动恢复) | 组件 | +| chatContext | 对话上下文管理(历史记录、聊天切换、标题更新) | Context | +| chatItemContext | 对话项上下文(变量管理、引用数据、模式判断) | Context | +| chatRecordContext | 对话记录加载与分页 | Context | +| ChatHistorySidebar | 分享模式下的对话历史侧边栏(含清空历史功能) | 组件 | + +本模块依赖项均在上述表中列出。 + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| share.tsx 分享对话页面 | 外部访问入口(独立 URL) | 页面 | +| useShareChatStore | 无外部消费(模块内部 Store) | Store | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 分享对话 | /chat/share | projects/app/src/pages/chat/share.tsx | 匿名用户、认证用户 | 无 Tab;支持 shareId/authToken/customUid 等 URL 参数控制行为 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f7e9d4e8675e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,47 @@ +--- +capability_label: 对话首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["未登录用户", "登录用户", "管理员", "Plus版用户"] +router_paths: ["/chat"] +--- + +# 对话首页 — 业务流程索引 + +## 业务场景汇总 + +> 本模块已拆分为 5 个 Tab 子能力,各 Tab 深度内容见子文档。下方列出入口公共场景和各 Tab 索引。 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 进入对话首页 | — | 直接访问或从菜单进入 | `/chat` | 全部 | +| S01-1 |  ├ 用户鉴权与初始化 | — | 页面加载时自动触发 | `/chat` | 全部 | +| S01-2 |  ├ 登录 | — | 未登录时自动弹出登录弹窗 | `/chat` | 未登录用户 | +| S02 | 切换侧边栏面板 | — | 点击侧边栏导航按钮 | `/chat` | 登录用户 | +| S02-1 |  ├ 首页对话 | 首页 | 侧边栏→首页按钮 | `/chat` | Plus版用户 | +| S02-2 |  ├ 精选应用 | 精选应用 | 侧边栏→精选按钮 | `/chat` | Plus版用户 | +| S02-3 |  ├ 团队应用 | 团队应用 | 侧边栏→团队应用按钮 | `/chat` | 登录用户 | +| S02-4 |  ├ 最近使用的应用 | 最近使用 | 侧边栏→最近使用列表中的应用 | `/chat` | 登录用户 | +| S02-5 |  ├ 对话设置 | 设置 | 侧边栏底部→设置按钮 | `/chat` | 管理员(Plus版) | +| S03 | 侧边栏折叠/展开 | — | 点击侧边栏折叠按钮 | `/chat` | 登录用户 | +| S04 | 通过发布链接访问对话 | — | 外部链接(含 appId 和 fromPublish 参数)| `/chat` | 全部(含未登录) | + +## Tab 子能力索引 + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| 首页 | 预设快捷应用入口,管理员可配置首页推荐应用 | [09-业务流程索引](../对话首页/首页/业务流程索引.md) | +| 精选应用 | 用户收藏的应用列表,支持拖拽排序和标签分组 | [09-业务流程索引](../对话首页/精选应用/业务流程索引.md) | +| 团队应用 | 按分类浏览团队内全部应用 | [09-业务流程索引](../对话首页/团队应用/业务流程索引.md) | +| 最近使用 | 最近使用的应用快捷入口 | [09-业务流程索引](../对话首页/最近使用/业务流程索引.md) | +| 设置 | 管理员配置首页布局、快捷应用和数据看板 | [09-业务流程索引](../对话首页/设置/业务流程索引.md) | + +## 场景关系 + +- **S01(进入页面)** → **S01-1(鉴权初始化)**:页面加载始终先执行用户鉴权和信息初始化 +- **S01-1** → **S01-2(登录)** 或 **S02(切换面板)**:鉴权完成后,未登录进入登录弹窗,已登录进入对话界面 +- **S02(切换面板)** → **S02-1~S02-5**:按用户角色和版本可见不同面板入口 +- **S03(折叠侧边栏)** 与各场景独立,可随时操作 +- **S04(发布链接访问)** 独立入口,绕过登录鉴权,通过外链配置控制功能 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..7e3da7cd5a48 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,181 @@ +--- +capability_label: 对话首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["未登录用户", "登录用户", "管理员", "Plus版用户"] +router_paths: ["/chat"] +--- + +# 对话首页 — 业务流程详解 + +## 页面总览 + +对话首页是 FastGPT AI 对话的统一入口,提供侧边栏导航和面板切换功能。本模块已拆分为 5 个 Tab 子能力,各 Tab 的深度交互流程见对应子文档。本文档仅覆盖入口公共流程。 + +## Tab 结构索引 + +| Tab | 业务描述 | 来源 | 详细文档 | +|-----|---------|------|---------| +| 首页 | 预设快捷应用入口(Plus版) | 本模块 | [业务流程详解-首页](../对话首页/首页/业务流程详解.md) | +| 精选应用 | 用户收藏应用的快捷入口(Plus版) | 本模块 | [业务流程详解-精选应用](../对话首页/精选应用/业务流程详解.md) | +| 团队应用 | 团队内全部应用列表 | 本模块 | [业务流程详解-团队应用](../对话首页/团队应用/业务流程详解.md) | +| 最近使用 | 最近使用过的应用列表 | 本模块 | [业务流程详解-最近使用](../对话首页/最近使用/业务流程详解.md) | +| 设置 | 管理员对话首页配置(Plus版) | 本模块 | [业务流程详解-设置](../对话首页/设置/业务流程详解.md) | + +## 公共业务流程 + +### 页面初始化与用户鉴权 + +> 页面加载时的初始化流程,包括用户信息获取、应用 ID 同步和对话设置加载。 + +#### 步骤 1:页面加载与路由参数解析 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问 `/chat` 路由 | 无(Next.js SSR) | — | 服务端执行 `getServerSideProps`,从 query 解析 `appId` 和 `fromPublish` 参数 | + +#### 步骤 2:服务端数据预取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面 SSR 渲染 | `MongoOutLink.findOne` | `appId` 存在且 `fromPublish` 非空时执行 | 查询发布渠道外链配置(`showRunningStatus` / `showSkillReferences` / `showCite` / `showFullText` / `canDownloadSource` / `showWholeResponse`)| +| 页面 SSR 渲染 | `MongoApp.findById` | `appId` 存在时执行 | 获取应用所属团队 ID | + +**权限控制逻辑**: +- 发布渠道:各配置项由外链配置决定,默认关闭。非发布渠道:始终开启(`showSkillReferences` 除外,始终关闭)。 +- `serviceSideProps` 加载所有命名空间的 i18n 词条。 + +#### 步骤 3:客户端初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载(客户端) | `initUserInfo`(useUserStore) | — | 调用用户信息初始化;设置 `source='online'`;标记 `isInitedUser=true` | +| 页面加载(客户端) | `getRecentlyUsedApps` | 登录用户自动触发 | 获取最近使用的应用列表,30 秒轮询刷新 | +| 页面加载(客户端) | `getChatSetting` | `feConfigs.isPlus` 为 true 时触发 | 获取对话首页设置,含首页推荐应用配置 | + +**用户状态判断分支**: + +- 用户信息未初始化完成(`!isInitedUser`):显示加载态(`PageContainer isLoading`) +- 用户未登录(`!userInfo`):显示登录弹窗(`LoginModal`) +- 用户已登录:进入正常对话界面,根据角色和版本显示不同面板 + +#### 步骤 4:chatSetting 加载后的自动跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动) | — | `chatSettings` 中 `enableHome` 为 false 且当前 pane 为 HOME | 自动切换到团队应用面板 | +| (自动) | — | 当前在首页面板,路由 `appId` 与配置的首页 `appId` 不同,且该 appId 不在快捷应用列表中 | 自动切换 appId 到配置的首页应用 | + +### 侧边栏导航与面板切换 + +> 用户通过左侧侧边栏切换不同的对话面板。 + +#### 步骤 1:面板切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击侧边栏导航按钮(首页/精选应用/团队应用/最近使用/设置) | `router.replace`(更新 URL query) | 目标面板与当前面板相同时忽略点击 | URL query 更新 `pane` 和 `appId` 参数;侧边栏按钮高亮切换;主内容区切换到对应面板组件 | +| 点击最近使用列表中的应用 | `router.replace`(更新 URL query) | 该应用已在当前面板激活时不重复切换 | URL query 更新 `pane=ra` 和对应 `appId`;主内容区切换到 AppChatWindow | + +**面板组件映射**: + +- `HOME` → `` — 首页对话 +- `FAVORITE_APPS` → `` — 精选应用(Plus版) +- `TEAM_APPS` → `` — 团队应用 +- `RECENTLY_USED_APPS` → `` — 最近使用 +- `SETTING` → `` — 设置(管理员+Plus版) + +#### 步骤 2:侧边栏折叠 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击折叠/展开按钮 | 无 | — | 侧边栏宽度从设置的宽度(默认 226px)切换为 72px;侧边栏图标缩小,文字隐藏;动画过渡 0.1s | + +#### 步骤 3:侧边栏宽度拖拽调整 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 拖拽侧边栏右侧分隔条 | 无 | 最小宽度 180px,最大宽度 350px | 侧边栏宽度实时调整;分隔条可见仅在侧边栏展开时 | + +### 发布渠道访问流程 + +> 通过外链分享访问对话页面,无需登录即可查看对话内容。 + +#### 步骤 1:外链访问 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问外链(含 `appId` 和 `fromPublish=1`) | SSR 阶段 `MongoOutLink.findOne` | `appId` 和 `fromPublish` 均存在 | 从外链配置读取各项开关(默认值偏保守:`showRunningStatus=true`, 其余默认关) | + +**外链配置项说明**(由发布渠道配置控制): + +| 配置项 | 发布渠道默认 | 非发布渠道 | +|--------|------------|-----------| +| `showRunningStatus` | 由配置决定,默认 true | true(始终显示) | +| `showSkillReferences` | 由配置决定,默认 false | false(始终不显示) | +| `showCite` | 由配置决定,默认 false | true(始终显示) | +| `showFullText` | 由配置决定,默认 false | true(始终显示) | +| `canDownloadSource` | 由配置决定,默认 true | true(始终可下载) | +| `showWholeResponse` | 由配置决定,默认 true | true(始终显示完整回复) | + +### 引用预览(数据集引用) + +> 对话中包含数据集引用时的预览面板。 + +#### 步骤 1:打开引用预览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动) | 无 | `datasetCiteData` 存在 且 `chatType === ChatTypeEnum.share` 且 PC 端 | 主内容区右侧显示引用预览面板(`ReferencePanel` 或 `QuoteReader`),可通过分隔条调整宽度 | +| (自动) | 无 | `datasetCiteData` 存在 且 `chatType === ChatTypeEnum.share` 且 移动端 | 全屏覆盖显示引用预览面板(最大宽度 560px) | +| (自动) | 无 | `datasetCiteData` 存在 且 非分享模式 | 全屏覆盖显示 `ChatQuoteList`(最大宽度 560px) | + +**引用预览面板类型**: +- `collectionId` 存在于 metadata 中 → 显示 `ReferencePanel`(集合引用视图) +- 否则 → 显示 `QuoteReader`(引用阅读器) + +**面板尺寸**: +- PC 端右侧面板默认宽度 580px,可拖拽调整(400-900px) +- 移动端/非分享模式最大宽度 560px + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击关闭引用预览 | 无 | — | `setCiteModalData(undefined)`,引用预览面板关闭,主内容区恢复正常宽度 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 对话首页 + participant API as 后端API + participant DB as 数据库 + + Note over UI: 页面加载 + UI->>API: SSR getServerSideProps + API->>DB: MongoOutLink.findOne (外链配置) + API-->>UI: 发布配置项 + API->>DB: MongoApp.findById (团队ID) + API-->>UI: teamId + + Note over UI: 客户端初始化 + UI->>API: initUserInfo + API-->>UI: userInfo + alt 用户未登录 + UI-->>User: 显示登录弹窗 + else 用户已登录 + UI->>API: getRecentlyUsedApps + API-->>UI: 最近使用应用列表 + UI->>API: getChatSetting (Plus版) + API-->>UI: 对话首页设置 + + User->>UI: 点击侧边栏导航 + UI->>UI: handlePaneChange + UI-->>User: 切换到对应面板 + Note over UI: 各Tab详见子文档 + end +``` + +> 各 Tab 面板的详细交互流程见对应子文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..15e46b7bd86d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,44 @@ +--- +capability_label: 对话首页 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["全部角色"] +router_paths: ["/chat"] +--- + +# 对话首页 — 共享能力清单 + +## 对外暴露的 Context/Store + +本模块作为对话入口页面,主要对外提供以下 Context 和 Store 能力: + +| 共享项 | 类型 | 入口路径 | 使用者 | 用途 | +|--------|------|---------|--------|------| +| ChatPageContext | Context | `src/web/core/chat/context/chatPageContext.tsx` | 各 Tab 子组件、ChatHeader 等 | 提供面板状态(pane)、用户信息、最近应用列表、对话设置 | +| useChatStore | Zustand Store | `src/web/core/chat/context/useChatStore.ts` | app 详情页、对话测试组件等 | 提供全局对话状态(appId、chatId、source)的读写和跨 Tab 持久化 | + +## 跨模块依赖详情 + +### 被以下模块依赖 (imported_by) + +| 消费模块 | 引用的具体内容 | 引用方式 | +|---------|--------------|---------| +| chat/share | ChatPageContext(面板状态、用户信息) | useContextSelector | +| ChatHeader | ChatPageContext(pane、handlePaneChange) | useContextSelector | +| ChatFavouriteApp | ChatPageContext(handlePaneChange) | useContextSelector | +| ChatTeamApp | ChatPageContext(handlePaneChange) | useContextSelector | +| ChatSetting | ChatPageContext(handlePaneChange) | useContextSelector | + +### 依赖以下模块 (imports_from) + +| 依赖模块 | 使用的具体能力 | 引用方式 | 关键度 | +|---------|--------------|---------|--------| +| 用户系统 | useUserStore(用户信息、登录状态)、LoginModal(登录弹窗) | import | 核心 | +| 对话核心 | ChatContextProvider、ChatItemContextProvider、ChatRecordContextProvider | import | 核心 | +| 系统配置 | useSystemStore(feConfigs 功能开关) | import | 核心 | +| 公共组件 | PageContainer、NextHead、ResizableDivider | import | 可选 | +| i18n | serviceSideProps(服务端翻译加载) | import | 可选 | + +> 完整列表见 [08-模块概览](./模块概览.md) §3。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cff0e110a926 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,75 @@ +--- +capability_label: 团队应用 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:04:37Z" +parent_module: 对话首页 +roles: [普通用户, 团队成员] +router_paths: [] +--- + +# 团队应用 — 业务流程索引 + +## 业务场景列表 + +### 浏览团队应用 + +- **业务描述**: 用户进入对话页面的团队应用面板,查看当前团队下所有可用的应用列表,以卡片形式展示应用名称、头像、简介、创建者和更新时间。 +- **触发入口**: 对话页面左侧面板 → 切换到"团队应用"视图(`pane=ta`) +- **路由路径**: 对话页面(`/chat` 或 `/chat?appId=...` 下的 `pane=ta` 查询参数) +- **页面组件位置**: `pageComponents/chat/ChatTeamApp/index.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: 用户已登录并属于某个团队 + +### 按类型筛选应用 + +- **业务描述**: PC 端用户通过顶部 Tab 栏切换应用类型(全部/智能客服/工作流机器人/工作流工具),系统仅展示匹配类型的应用卡片。 +- **触发入口**: 团队应用面板 → 点击顶部类型 Tab +- **路由路径**: 对话页面(`pane=ta`) +- **页面组件位置**: `pageComponents/chat/ChatTeamApp/index.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: PC 端访问 + +### 搜索应用 + +- **业务描述**: 用户在搜索框中输入关键词,系统按应用名称进行实时过滤(500ms 防抖),展示匹配的应用卡片。 +- **触发入口**: 团队应用面板 → 搜索输入框 +- **路由路径**: 对话页面(`pane=ta`) +- **页面组件位置**: `pageComponents/chat/ChatTeamApp/index.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: 无 + +### 进入文件夹浏览 + +- **业务描述**: 用户点击文件夹类型的应用卡片,进入该文件夹查看其子应用。顶部的面包屑导航展示当前路径,用户可点击面包屑返回上级文件夹。 +- **触发入口**: 团队应用面板 → 点击文件夹卡片 +- **路由路径**: 对话页面(`pane=ta&parentId=...`) +- **页面组件位置**: `pageComponents/chat/ChatTeamApp/index.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: 当前团队下存在文件夹类型的应用 + +### 开始对话 + +- **业务描述**: 用户在应用卡片列表中点击某个非文件夹应用,系统将导航至该应用的对话窗口,用户可直接开始与 AI 对话。 +- **触发入口**: 团队应用面板 → 点击应用卡片 +- **路由路径**: 对话页面(更新 `appId` 查询参数,面板切换到 `RECENTLY_USED_APPS`) +- **页面组件位置**: `pageComponents/chat/ChatTeamApp/List.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: 应用存在且用户有访问权限 + +## 场景关系 + +- **浏览团队应用** → **按类型筛选应用**: 筛选是浏览的子操作,在浏览基础上缩小范围 +- **浏览团队应用** → **搜索应用**: 搜索是浏览的子操作,在浏览基础上按关键词过滤 +- **浏览团队应用** → **进入文件夹浏览**: 文件夹是层级导航,进入子级目录 +- **浏览团队应用** → **开始对话**: 最终目标场景 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览团队应用 | 对话左侧面板→团队应用 | `/chat`(pane=ta) | 所有角色 | +| S02 | 按类型筛选应用 | 团队应用面板→类型Tab | `/chat`(pane=ta) | 所有角色(仅PC) | +| S03 | 搜索应用 | 团队应用面板→搜索框 | `/chat`(pane=ta) | 所有角色 | +| S04 | 进入文件夹浏览 | 团队应用面板→文件夹卡片 | `/chat`(pane=ta&parentId=...) | 所有角色 | +| S05 | 开始对话 | 团队应用面板→应用卡片 | `/chat`(appId=...) | 所有角色 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..f9ecc92dbb9d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,170 @@ +--- +capability_label: 团队应用 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:04:37Z" +parent_module: 对话首页 +roles: [普通用户, 团队成员] +router_paths: [] +--- + +# 团队应用 — 业务流程详解 + +## 页面总览 + +团队应用面板是对话系统中的核心应用发现入口。PC 端采用顶部 Tab 筛选 + 右侧搜索框 + 应用卡片网格的布局;移动端采用全宽搜索 + 紧凑卡片列表的单列布局。所有数据通过 `AppListContext` 统一管理,支持无限滚动分页加载。 + +无嵌套 Tab,本模块无 Tab 子能力拆分。 + +--- + +### 浏览团队应用 + +> **业务描述**: 用户进入对话页面的团队应用面板,首次加载时以分页方式获取团队下的应用列表,以网格卡片形式展示每个应用的头像、名称、简介、创建者和更新时间。列表支持无限滚动加载更多。 + +#### 步骤 1:进入团队应用面板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在对话页面侧边栏点击"团队应用"入口,或进入 `pane=ta` 路由 | 无(路由切换触发) | 无 | 面板切换为团队应用视图;显示搜索框和空状态(初始) | + +#### 步骤 2:面板初始化与数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 面板渲染完成 | `POST /core/app/list`(pageNum=1, pageSize=默认,含 parentId, type, searchKey) | 无 | 加载中状态(MyBox 显示 Spinner);请求成功后渲染应用卡片网格 | + +#### 步骤 3:无限滚动加载更多 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动页面至底部哨兵元素可见 | `POST /core/app/list`(pageNum=N+1, pageSize=默认) | `hasMore === true` 时哨兵元素挂载 | 页面底部显示加载 Spinner;新数据追加至列表末尾 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `POST /core/app/list` | `pageNum=1`, `parentId=当前文件夹ID` | 按后端默认排序 | 网格卡片首屏数据 | +| 滚动加载 | `POST /core/app/list` | `pageNum=N+1`, `parentId=当前文件夹ID` | 追加到现有列表 | 网格卡片增加 | +| 搜索触发 | `POST /core/app/list` | `searchKey=输入关键词`(500ms防抖) | 替换全部列表 | 网格卡片刷新 | + +- **分页参数**: 默认每页条数由 `useInfiniteScroll` hook 管理 +- **空状态**: 列表为空且不在加载时,显示"暂无应用"空状态提示文字(居中) +- **初始无数据**: 有创建权限且 PC 端显示创建引导卡片;无权限显示空状态 + +--- + +### 按类型筛选应用(仅 PC 端) + +> **业务描述**: PC 端用户在顶部 Tab 栏切换应用类型,列表仅展示匹配类型的应用。此功能为纯前端筛选,不触发新的 API 请求。 + +#### 步骤 1:切换类型 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部类型 Tab(全部/智能客服/工作流机器人/工作流工具) | 无(纯前端过滤) | 无 | Tab 切换为选中态(高亮 + 底部指示器);应用卡片列表即时过滤为匹配类型 | + +- **筛选逻辑**: `List` 组件从 `AppListContext` 获取全量应用列表,在前端按 `appType` 过滤: + - `all`:不过滤,展示所有应用 + - 具体类型:仅展示 `type` 匹配的应用(工具类型额外匹配 `toolFolder`,其他类型额外匹配 `folder`) + +--- + +### 搜索应用 + +> **业务描述**: 用户在搜索框输入关键词,系统进行 500ms 防抖后调用 API 重新获取匹配的应用列表。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词(最长 30 字符) | 无(仅在本地更新状态) | 无 | 输入框显示输入内容 | + +#### 步骤 2:搜索触发 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 停止输入 500ms 后 | `POST /core/app/list`(searchKey=关键词, pageNum=1) | 搜索词非空 | 加载中状态;匹配结果替换当前列表 | + +- **搜索类型**: 按应用名称搜索(后端处理) +- **防抖**: 500ms(由 `useDebounce` hook 实现) +- **无结果**: 显示"暂无应用"空状态提示 + +--- + +### 进入文件夹浏览 + +> **业务描述**: 用户点击文件夹类型应用卡片进入子目录,或通过面包屑导航返回上级目录。此时更新 URL 中的 `parentId` 参数,触发数据重新加载。 + +#### 步骤 1:进入文件夹 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件夹类型应用卡片 | 无(路由参数更新触发) | 应用类型为 `folder` | URL 查询参数添加 `parentId=文件夹ID` | + +#### 步骤 2:文件夹数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 路由参数变化触发 | `POST /core/app/list`(parentId=文件夹ID, pageNum=1) | `parentId` 变化 | 加载中状态;面包屑路径更新;卡片列表刷新为文件夹内应用 | + +**并行 API**: +- `getAppFolderPath` — 获取当前文件夹的面包屑路径,更新顶部导航 +- `getAppDetailById` — 获取文件夹详情(权限、名称等) + +#### 步骤 3:返回上级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击面包屑路径中的上级文件夹 | 同步骤 2 | 面包屑路径存在 | 面包屑同步更新;列表刷新 | + +--- + +### 开始对话 + +> **业务描述**: 用户点击非文件夹应用卡片,系统切换到对话面板并加载该应用的对话窗口。 + +#### 步骤 1:点击应用卡片 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击非文件夹应用卡片 | 无(路由切换) | 应用类型非 `folder` | 鼠标悬停时卡片边框高亮为蓝色,显示"去对话"提示 | + +#### 步骤 2:导航至对话 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击完成 | 无(前端路由更新) | 无 | URL 更新 `appId=应用ID`;面板切换至 `RECENTLY_USED_APPS`(最近使用);对话窗口加载该应用 | + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 团队应用面板 + participant API + participant Backend + + User->>UI: 进入团队应用面板 + UI->>API: POST /core/app/list (pageNum=1) + API->>Backend: 查询团队应用列表 + Backend-->>API: 应用列表 + total + API-->>UI: 渲染应用卡片网格 + + User->>UI: 输入搜索关键词 + UI->>UI: 500ms 防抖 + UI->>API: POST /core/app/list (searchKey=关键词) + API-->>UI: 刷新卡片列表 + + User->>UI: 点击文件夹卡片 + UI->>API: POST /core/app/list (parentId=文件夹ID) + UI->>API: GET 文件夹路径 (并行) + UI->>API: GET 文件夹详情 (并行) + API-->>UI: 渲染子级应用列表 + 面包屑 + + User->>UI: 点击应用卡片 + UI->>UI: 更新路由 appId + 切换面板 + UI->>User: 加载应用对话窗口 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..10bb160c1a05 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,58 @@ +--- +capability_label: 团队应用 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:04:37Z" +parent_module: 对话首页 +roles: [普通用户, 团队成员] +router_paths: [] +--- + +# 团队应用 — 模块概览 + +## 1. 模块业务概述 + +团队应用模块是 FastGPT 对话系统中的团队级应用浏览入口。用户在对话页面左侧面板中切换到"团队应用"视图后,可以浏览和搜索当前团队下所有已创建的应用,并快速进入对话。 + +本模块面向**团队成员**和**普通用户**,提供一个可视化的应用卡片列表,支持按应用类型筛选(全部、智能客服、工作流机器人、工作流工具)、关键词搜索、文件夹导航等功能。点击应用卡片后,系统将用户导航至对应的对话窗口,实现"选应用→开始对话"的流畅体验。 + +本模块在 PC 端和移动端采用差异化布局:PC 端使用顶部 Tab 筛选栏和侧边搜索框,移动端则采用顶部菜单按钮和居中搜索框的紧凑布局。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员(有创建权限) | 团队应用列表 | 浏览、搜索、筛选、进入文件夹、开始对话 | 无应用时显示创建引导卡片 | +| 团队成员(无创建权限) | 团队应用列表 | 浏览、搜索、筛选、进入文件夹、开始对话 | 无应用时显示空状态提示 | +| 普通用户 | 团队应用列表 | 浏览、搜索、筛选、进入文件夹、开始对话 | 与团队成员相同,差异由后端权限控制 | + +**权限模型**: 依赖 `AppListContext` 中获取的应用列表,每个应用的访问权限由后端 `getMyAppsPaginated` API 返回时过滤。前端不额外做权限控制,应用可见性由后端 `teamId` 和 `tags` 决定。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台(Agent) | `AppListContext` 上下文(应用列表、搜索、文件夹路径) | Context/Provider | +| 对话上下文 | `ChatPageContext.handlePaneChange` 切换面板 | Context/Hook | +| 对话上下文 | `ChatContext.onOpenSlider` 移动端打开侧边栏 | Context/Hook | +| 对话常量 | `ChatSidebarPaneEnum` 面板枚举定义 | 常量/枚举 | +| 通用组件库 | `SearchInput`, `MyBox`, `MyIcon`, `EmptyTip`, `Avatar`, `UserBox`, `MyTooltip` | 共享组件 | + +## 4. 对外共享 + +本模块的 `List` 组件和 `AppTypeTag` 组件被工作台和其他模块复用。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `ChatTeamApp/List` 应用卡片列表 | 工作台-应用工作台、工作台-技能管理、工作台-工具管理、数据集-列表 | 组件 | +| `ChatTeamApp/TypeTag` 应用类型标签 | 工作台-应用工作台(AppCard 组件) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 团队应用面板 | 对话页面(`pane=ta`) | `pageComponents/chat/ChatTeamApp/index.tsx` | 所有角色 | PC 端含 4 个类型筛选 Tab(全部/智能客服/工作流机器人/工作流工具);移动端无 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ff2629ddf6c9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,67 @@ +--- +capability_label: 最近使用 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [所有登录用户] +router_paths: [/chat] +--- + +# 最近使用 — 业务流程索引 + +## 业务场景列表 + +### 打开最近使用的应用进行对话 + +- **业务描述**: 用户在侧边栏"最近使用"列表中点击某个应用,进入该应用的聊天窗口,可以查看历史对话记录并继续对话或开始新对话。 +- **触发入口**: 对话页左侧侧边栏 → "最近使用"区域 → 点击应用名称 +- **路由路径**: /chat?pane=ra&appId={appId} +- **页面组件位置**: projects/app/src/pages/chat/index.tsx(入口)→ projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx +- **涉及角色**: 所有已登录用户 +- **前置条件**: + - 用户已登录 + - 该应用存在于用户的最近使用列表中 + - 用户有权限访问该应用 + +### 对话完成后刷新最近使用列表 + +- **业务描述**: 用户与 AI 应用完成一轮对话后,系统自动刷新"最近使用"列表,确保该应用出现在列表顶部。 +- **触发入口**: 自动触发(对话完成后无需用户额外操作) +- **路由路径**: /chat(当前页面内自动刷新) +- **页面组件位置**: projects/app/src/web/core/chat/context/chatPageContext.tsx(数据层) +- **涉及角色**: 所有已登录用户 +- **前置条件**: 已完成一轮有效对话 + +### 查看对话历史记录 + +- **业务描述**: 用户在当前应用聊天窗口中查看之前的对话记录,可切换到不同历史会话继续对话。 +- **触发入口**: 聊天窗口顶部 ChatHeader 中的历史记录切换控件 +- **路由路径**: /chat?pane=ra&appId={appId} +- **页面组件位置**: projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx → ChatHistorySidebar / ChatSliderMobileDrawer +- **涉及角色**: 所有已登录用户 +- **前置条件**: 用户已进入某个应用聊天窗口,该应用有历史对话记录 + +### 侧边栏查看最近使用应用列表 + +- **业务描述**: 用户在对话页侧边栏浏览最近对话过的 AI 应用列表(最多20个),列表每30秒自动轮询刷新。 +- **触发入口**: 对话页 → 侧边栏"最近使用"区域(非折叠状态下可见) +- **路由路径**: /chat +- **页面组件位置**: projects/app/src/pageComponents/chat/slider/index.tsx +- **涉及角色**: 所有已登录用户 +- **前置条件**: 用户已登录,侧边栏处于展开状态 + +## 场景关系 + +- **侧边栏查看最近使用应用列表** → **打开最近使用的应用进行对话**: 侧边栏列表是对话的入口,用户先从列表中选择应用再进入对话 +- **打开最近使用的应用进行对话** → **查看对话历史记录**: 进入应用后可查看历史记录 +- **对话完成后刷新最近使用列表**: 对话完成后自动触发,不依赖用户主动操作 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 侧边栏查看最近使用应用列表 | 对话页→侧边栏"最近使用"区域 | /chat | 已登录用户 | +| S02 | 打开最近使用的应用进行对话 | 侧边栏→点击应用名称 | /chat?pane=ra&appId={appId} | 已登录用户 | +| S03 | 查看对话历史记录 | 聊天窗口→历史记录控件 | /chat?pane=ra&appId={appId} | 已登录用户 | +| S04 | 对话完成后刷新最近使用列表 | 自动触发 | /chat | 已登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..10130f270bbc --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,218 @@ +--- +capability_label: 最近使用 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [所有登录用户] +router_paths: [/chat] +--- + +# 最近使用 — 业务流程详解 + +## 页面总览 + +"最近使用"模块提供用户快速回到最近对话过的 AI 应用的入口。核心交互流程为:侧边栏展示最近应用列表 → 用户点击应用 → 聊天窗口加载并初始化对话 → 用户发送消息 → AI 回复 → 自动刷新最近使用列表。该模块为纯展示型聊天窗口,无新增/编辑/删除等表单操作场景。 + +## 侧边栏查看最近使用应用列表 + +> **业务描述**: 用户在对话页侧边栏浏览最近对话过的 AI 应用列表,列表每 30 秒自动刷新。 + +### 步骤 1:页面初始化加载最近使用列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入对话页 /chat | GET /api/core/chat/recentlyUsed | 用户已登录(有有效 token)→ 正常请求;用户未登录 → 不发起请求,展示登录弹窗 | 侧边栏展开时显示"最近使用"区域标题和加载状态 | + +### 步骤 2:列表渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动) | —(使用步骤1返回的数据) | 列表有数据 → 渲染应用条目(头像+名称);列表为空 → 不显示任何条目 | 侧边栏中每个应用显示头像和名称,当前激活的应用条目高亮(蓝色背景) | + +### 步骤 3:定时自动刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动,30秒间隔) | GET /api/core/chat/recentlyUsed | 500ms 节流保护,避免频繁请求 | 列表静默更新(无加载遮罩),新增或排序变化的应用条目自动出现在列表中 | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /api/core/chat/recentlyUsed | 无请求参数(后端根据 token 获取 tmbId) | 后端查询 MongoAppRecord 按 lastUsedTime 倒序取前20条,关联 MongoApp 获取 name/avatar | 侧边栏最近使用列表(最多20个应用) | +| 轮询刷新(30秒) | GET /api/core/chat/recentlyUsed | 同首次加载 | 500ms 节流 | 静默更新列表 | +| 手动刷新(对话完成后) | GET /api/core/chat/recentlyUsed | 同首次加载 | 同首次加载 | 列表更新,刚对话的应用排到顶部 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 侧边栏(ChatSlider) + participant API as /api/core/chat/recentlyUsed + participant Backend as 后端服务 + + User->>UI: 进入对话页 /chat + UI->>API: GET 请求(初始化) + API->>Backend: 查 MongoAppRecord(按 lastUsedTime 倒序,limit 20) + Backend-->>API: 返回 record 列表 + API->>Backend: 查 MongoApp(获取 name, avatar) + Backend-->>API: 返回 app 信息 + API-->>UI: [{appId, name, avatar}] + UI-->>User: 渲染最近使用列表 + + loop 每30秒轮询 + UI->>API: GET 请求(轮询刷新) + API-->>UI: 最新列表数据 + UI-->>User: 静默更新列表 + end +``` + +## 打开最近使用的应用进行对话 + +> **业务描述**: 用户在侧边栏点击某个应用,聊天主区域切换到该应用的聊天窗口,加载历史消息并准备对话。 + +### 步骤 1:切换应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在侧边栏"最近使用"列表中点击目标应用名称 | —(前端路由切换) | 点击的应用已是当前激活应用 → 不重复切换(handlePaneChange 内判断 `newPane === lastestPane.current && !id && !tab` 则返回);点击不同应用 → 执行路由切换 | URL 更新为 `/chat?pane=ra&appId={目标应用ID}`,侧边栏高亮切换到被点击的应用 | + +### 步骤 2:初始化聊天数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(路由切换自动触发) | GET /api/core/chat/init?appId={appId}&chatId={chatId} | appId 和 chatId 均存在 → 发起请求;任一缺失 → 跳过请求;forbidLoadChat 为 true → 跳过请求(防止重复加载) | 聊天窗口显示加载状态 | + +### 步骤 3:聊天数据填充 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(数据返回后自动填充) | — | 请求成功 → 解析响应设置聊天数据;请求失败且错误码 ≥ 502000 → 进入错误处理分支 | 成功:聊天窗口显示应用名称(标题栏)、头像、历史消息列表;失败(未授权对话):自动切换 chatId 重新加载;失败(未授权应用):刷新最近使用列表并切换到团队应用面板 | + +### 步骤 4:发送消息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在输入框中键入消息并点击发送 | POST /api/v1/chat/completions(流式请求,通过 streamFetch) | appId 为空 → 不发送,返回错误;isPlugin 为 true → 使用 CustomPluginRunBox 处理消息(插件模式);isPlugin 为 false → 使用标准 ChatBox 处理消息 | 输入框清空,消息出现在聊天区域(用户消息气泡),AI 回复区域显示"正在生成..."加载指示器 | + +### 步骤 5:接收 AI 回复 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待 AI 回复(无需操作) | —(流式响应由 streamFetch 的 onMessage 回调逐字处理) | 流式传输进行中 → 逐字展示 AI 回复内容;传输完成 → 显示完整回复 | AI 回复逐字出现在对话区域,生成完成后显示完整消息;如果启用了引用显示(isShowCite),回复中包含引用标记 | + +### 步骤 6:对话完成后续处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(AI 回复完成后自动触发) | GET /api/core/chat/recentlyUsed | 对话成功完成 → 调用 refreshRecentlyUsed() 刷新列表 | 聊天标题更新为最新消息摘要,侧边栏最近使用列表更新(当前应用排到顶部或出现在列表中) | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as AppChatWindow + participant API as 后端 API + participant Backend as 后端服务 + + User->>UI: 点击侧边栏应用 + UI->>UI: URL 更新为 ?pane=ra&appId={id} + + par 并行加载 + UI->>API: GET /api/core/chat/init?appId&chatId + API-->>UI: 返回应用信息、历史消息、变量 + end + + UI-->>User: 展示聊天窗口与历史消息 + + User->>UI: 输入消息并发送 + UI->>API: POST /api/v1/chat/completions(流式) + API-->>UI: 流式返回 AI 回复 + UI-->>User: 逐字展示 AI 回复 + + UI->>API: GET /api/core/chat/recentlyUsed(刷新列表) + API-->>UI: 更新后的最近使用列表 + UI-->>User: 侧边栏列表更新 +``` + +## 查看对话历史记录 + +> **业务描述**: 用户在聊天窗口顶部查看和切换当前应用的历史对话记录。 + +### 步骤 1:展开历史记录侧边栏 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| PC端:鼠标移入左侧边缘触发 SideBar 展开(externalTrigger 取决于是否有引用数据);移动端:点击抽屉按钮打开 ChatSliderMobileDrawer | — | PC端有引用数据(datasetCiteData 非空)→ 额外触发 SideBar 展开;无引用数据 → 仅鼠标悬停触发 | PC端:对话历史侧边栏从左侧滑出;移动端:底部抽屉弹出历史记录列表 | + +### 步骤 2:浏览和切换历史对话 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在历史记录列表中点击某条记录 | —(前端通过 ChatRecordContext 切换 chatId) | 点击不同对话 → 触发 chatId 变化,AppChatWindow 重新调用 getInitChatInfo 加载该对话数据;点击当前对话 → 无变化 | 聊天窗口切换到所选历史对话,展示该对话的完整消息记录 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as AppChatWindow + participant Sidebar as ChatHistorySidebar + + User->>Sidebar: 鼠标悬停/点击按钮展开历史侧边栏 + Sidebar-->>User: 展示历史对话列表 + + User->>Sidebar: 点击某条历史记录 + Sidebar->>UI: 切换 chatId + UI->>UI: 触发 getInitChatInfo(新 chatId) + UI-->>User: 聊天窗口展示所选历史对话 +``` + +## 对话完成后刷新最近使用列表 + +> **业务描述**: 用户与 AI 应用完成一轮对话后,系统自动将当前应用更新到最近使用列表顶部。 + +### 步骤 1:对话完成触发刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(AI 回复完成后自动触发) | GET /api/core/chat/recentlyUsed | 对话正常完成 → 调用 refreshRecentlyUsed();对话出错且为未授权应用错误 → 同样调用 refreshRecentlyUsed() | 侧边栏最近使用列表更新 | + +### 步骤 2:未授权应用错误处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(API 返回错误时自动处理) | — | getInitChatInfo 返回 AppErrEnum.unAuthApp 错误 → 调用 refreshRecentlyUsed() 从列表中移除未授权应用 → 自动切换到团队应用面板 (TEAM_APPS) | 页面从最近使用面板跳转到团队应用面板,展示所有可用应用列表 | + +### 步骤 3:未授权对话错误处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(API 返回错误时自动处理) | — | getInitChatInfo 返回 ChatErrEnum.unAuthChat 错误 → 调用 onChangeChatId() 切换到新的 chatId | 聊天窗口重新初始化一个新的对话 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant UI as AppChatWindow + participant Ctx as ChatPageContext + participant API as /api/core/chat/recentlyUsed + + UI->>UI: 对话完成(onStartChat 返回) + UI->>Ctx: refreshRecentlyUsed() + Ctx->>API: GET 请求 + API-->>Ctx: 最新列表 + Ctx-->>UI: myApps 状态更新 + + alt 未授权应用错误 + UI->>Ctx: refreshRecentlyUsed() + UI->>Ctx: handlePaneChange(TEAM_APPS) + Ctx-->>UI: 切换到团队应用面板 + else 未授权对话错误 + UI->>UI: onChangeChatId()(新 chatId) + UI->>UI: 重新初始化对话 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a5800fb5a907 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 最近使用 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [所有登录用户] +router_paths: [/chat] +--- + +# 最近使用 — 模块概览 + +## 1. 模块业务概述 + +"最近使用"是对话首页的子模块,为用户提供快速回到最近对话过的 AI 应用的入口。当用户在侧边栏"最近使用"列表中点击某个应用时,对话主区域将切换到该应用的聊天窗口,用户可以继续之前的对话或开始新对话。 + +本模块是对话首页三大核心面板之一(首页推荐、收藏应用、最近使用),面向所有已登录用户,无需额外权限。模块的核心价值是降低用户重复查找应用的认知成本——通过自动记录用户最近交互的应用并展示在侧边栏,用户可以一键切换回常用的 AI 助手。 + +本模块为叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 已登录用户 | 最近使用面板(聊天窗口) | 查看最近使用的应用列表、切换应用进行对话、发起新对话、查看历史对话记录 | 基础权限 | +| 未登录用户 | 无 | 无 | 未登录用户无法访问对话页面,会被重定向到登录页 | + +**权限模型**: 基于用户登录状态的前端路由守卫。API 层通过 `authUserPer({ authToken: true, authApiKey: true })` 验证用户身份,根据 `tmbId`(团队成员ID)查询个人最近使用的应用记录。未登录用户在 `ChatContent` 组件中被拦截并展示登录弹窗。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话首页 | ChatPageContext(面板切换、最近使用数据)、ChatContext(对话初始化、标题更新)、ChatItemContext(对话配置)、ChatRecordContext(对话记录) | Context/Store | +| 对话首页 | ChatSlider 侧边栏(最近使用列表展示与切换入口) | 组件 | +| 对话首页 | ChatHeader(应用名称、历史记录导航) | 组件 | +| 共享基础设施 | ChatBox 聊天容器组件(消息展示、输入、流式响应) | 组件 | +| 共享基础设施 | useRequest Hook(异步请求管理、轮询) | Hook | + +## 4. 对外共享 + +本模块不对外提供共享能力。模块内的 `AppChatWindow` 组件仅在对话首页内部使用(由 `projects/app/src/pages/chat/index.tsx` 根据 pane 状态条件渲染)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话页-最近使用 | /chat?pane=ra&appId={appId} | projects/app/src/pages/chat/index.tsx → AppChatWindow | 已登录用户 | 展示最近使用应用的聊天窗口,含侧边栏对话历史 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..5efcf70a21c7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,73 @@ +--- +capability_label: 对话首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["未登录用户", "登录用户", "管理员", "Plus版用户"] +router_paths: ["/chat"] +--- + +# 对话首页 — 模块概览 + +## 1. 模块业务概述 + +对话首页是 FastGPT 对话功能的统一入口页面,路径为 `/chat`。用户通过此页面选择应用并开始 AI 对话,是用户与 AI Agent 交互的核心入口。 + +页面整体由两部分组成:左侧可折叠的侧边导航栏(ChatSlider),以及右侧主内容区。侧边栏提供面板切换功能,用户可在首页对话、精选应用、团队应用、最近使用等面板间切换。管理员在商业版中还可访问设置面板。 + +`has_tab_children: true`,本模块已被拆分为 5 个 Tab 子能力:首页、精选应用、团队应用、最近使用、设置。本文档及 09/10 仅覆盖入口公共功能(页面初始化、用户鉴权、侧边栏导航、布局管理),各 Tab 的深度内容见对应子文档。 + +页面支持两种访问模式: +- **正常使用**:登录用户通过侧边栏选择应用开始对话 +- **发布渠道**:通过外链分享访问,支持独立鉴权和配置控制(`fromPublish` 参数) + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 未登录用户 | 登录弹窗 | 登录/注册 | 进入页面后展示登录弹窗,无法使用对话功能 | +| 登录用户 | 团队应用、最近使用 | 选择应用、开始对话 | 基本可用功能,首页和精选应用不可见 | +| Plus版用户 | 首页、精选应用、团队应用、最近使用 | 全部对话功能 | 可见首页和精选应用面板,可使用预设快速应用 | +| 管理员(Plus版) | 全部面板含设置 | 全部功能 + 对话设置管理 | 可访问设置面板,管理对话首页的布局和配置 | + +**权限模型**:基于功能开关和角色的混合权限。`feConfigs.isPlus` 控制商业版功能(首页、精选应用)的可见性;`userInfo.team.permission.hasManagePer` 控制设置面板的可见性;未登录用户被重定向至登录弹窗。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户系统 | 用户登录状态校验、用户信息获取(useUserStore) | Store | +| 系统配置 | feConfigs 商业版开关、系统标题(useSystemStore) | Store | +| 对话核心 | ChatContextProvider、ChatItemContextProvider、ChatRecordContextProvider | Context | +| 登录模块 | LoginModal 登录弹窗组件 | 组件 | +| i18n 国际化 | serviceSideProps 服务端翻译加载 | 工具 | + +> 完整列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +本模块为页面入口,主要消费其他模块的能力,不对外暴露大量共享能力。对外共享的关键能力: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ChatPageContext(面板状态管理) | 各 Tab 子组件 | Context | +| useChatStore(对话状态持久化) | app 详情、对话测试组件 | Store | + +> 完整列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话首页 | `/chat` | `projects/app/src/pages/chat/index.tsx` | 全部角色 | 含 5 个 Tab(详见 [09-业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 首页 | 首页(HOME) | [08-模块概览](../对话首页/首页/模块概览.md) | +| 精选应用 | 精选应用(FAVORITE_APPS) | [08-模块概览](../对话首页/精选应用/模块概览.md) | +| 团队应用 | 团队应用(TEAM_APPS) | [08-模块概览](../对话首页/团队应用/模块概览.md) | +| 最近使用 | 最近使用(RECENTLY_USED_APPS) | [08-模块概览](../对话首页/最近使用/模块概览.md) | +| 设置 | 设置(SETTING) | [08-模块概览](../对话首页/设置/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..23f24c1f5e10 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,41 @@ +--- +capability_label: 精选应用 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [普通用户, 管理员] +router_paths: [] +--- + +# 精选应用 — 业务流程索引 + +## 浏览精选应用列表 + +- **业务描述**: 用户在对话首页切换到精选应用面板,按标签筛选或按名称搜索应用,浏览管理员配置的精选应用集合。 +- **触发入口**: 对话首页侧边栏 → 点击"精选应用"导航项(或在移动端点击菜单图标打开侧边栏后操作) +- **路由路径**: `/chat?pane=fa`(通过 `ChatSidebarPaneEnum.FAVORITE_APPS` 切换面板) +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx` +- **涉及角色**: 普通用户、管理员 +- **前置条件**: 用户已登录,且为 Plus 版本(`feConfigs.isPlus` 为 true) + +## 跳转配置精选应用 + +- **业务描述**: 管理员在精选应用列表为空时,点击"去配置"按钮跳转到精选应用管理页面,进行应用的添加、排序和分类管理。 +- **触发入口**: 精选应用面板空状态 → 点击"去配置"按钮 +- **路由路径**: `/chat?pane=s&tab=f`(通过 `handlePaneChange(SETTING, undefined, FAVOURITE_APPS)` 切换) +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/index.tsx`(设置面板,非本模块) +- **涉及角色**: 管理员 +- **前置条件**: 用户具有管理权限(`userInfo.permission.hasManagePer`),且当前精选应用列表为空 + +## 场景关系 + +- **浏览精选应用列表** → **点击应用卡片进入对话**: 用户点击任意应用卡片后,面板切换到最近使用应用面板(`RECENTLY_USED_APPS`),加载对应应用对话 +- **浏览精选应用列表** → **跳转配置精选应用**: 仅管理员在列表为空时可见此操作 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览精选应用列表 | 侧边栏→精选应用 | /chat?pane=fa | 普通用户、管理员 | +| S02 | 跳转配置精选应用 | 空状态→去配置按钮 | /chat?pane=s&tab=f | 管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..15f9f8920f13 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,131 @@ +--- +capability_label: 精选应用 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [普通用户, 管理员] +router_paths: [] +--- + +# 精选应用 — 业务流程详解 + +## 页面总览 + +精选应用面板是对话首页侧边栏中的一个功能面板,展示由管理员配置的精选应用集合,以卡片网格形式呈现。用户可通过标签分类筛选和名称搜索来快速定位目标应用。 + +> 本模块无嵌套 Tab 页面。标签切换为纯前端过滤控件,不产生页面跳转或内容区域切换。 + +--- + +### 浏览精选应用列表 + +> 用户在对话首页打开精选应用面板,浏览由管理员配置的精选应用集合。支持按标签分类筛选和按名称搜索。 + +#### 步骤 1:进入精选应用面板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在对话首页侧边栏点击"精选应用"导航项;或移动端点击菜单图标打开侧边栏后点击"精选应用" | 无(面板切换为前端路由操作) | Plus 版本启用时正常展示;非 Plus 版本该面板可能不可见 | 侧边栏切换到精选应用面板,面板区域显示加载状态(`MyBox` 的 `isLoading` 为 true),页面标题设置为系统标题 | + +#### 步骤 2:加载精选应用列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入面板后自动触发加载,无需手动操作 | `GET /proApi/core/chat/setting/favourite/list`(参数:`name` 搜索关键词,`tag` 标签 ID) | 首次加载 `name` 和 `tag` 均为空字符串,拉取全部精选应用 | 加载遮罩显示;请求完成后遮罩消失 | + +#### 步骤 3:查看应用卡片列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载完成,浏览应用卡片 | 无 | **有数据时**:以响应式网格展示应用卡片(PC 端 3 列,移动端 1 列);**无数据时**:显示空状态提示"暂无可用精选应用" | 每张卡片展示应用头像、名称、简介(无简介时显示"暂无介绍")、前 3 个标签;超过 3 个标签时显示"+N"浮层 | + +#### 步骤 4:按标签筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部标签栏中的分类标签(如"效率"、"工具"等) | `GET /proApi/core/chat/setting/favourite/list`(参数:`tag` 为所选标签 ID) | 点击"全部"时 `tag` 为空;点击具体标签时 `tag` 为标签 ID;切换标签后 500ms 防抖节流 | 选中标签高亮为蓝色,列表区域显示加载遮罩,数据刷新后展示对应分类的应用 | + +#### 步骤 5:按名称搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入应用名称关键词 | `GET /proApi/core/chat/setting/favourite/list`(参数:`name` 为输入的关键词) | 输入变化后 500ms 防抖节流;PC 端搜索框在标签栏右侧(最大宽度 300px);移动端搜索框在顶部工具栏 | 列表区域显示加载遮罩,数据刷新后展示匹配名称的应用 | + +#### 步骤 6:点击应用进入对话 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任意应用卡片 | 无(前端路由切换) | 始终可点击 | 面板切换到最近使用应用面板(`RECENTLY_USED_APPS`),传递该应用的 `appId`,进入该应用的对话界面 | + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /proApi/core/chat/setting/favourite/list | name=""、tag="" | 无额外处理,直接使用返回值 | 应用卡片网格 | +| 标签筛选 | GET /proApi/core/chat/setting/favourite/list | tag={标签ID}、name={当前搜索词} | 无额外处理 | 筛选后卡片网格 | +| 名称搜索 | GET /proApi/core/chat/setting/favourite/list | name={搜索词}、tag={当前标签} | 500ms 防抖 | 搜索匹配卡片网格 | + +- **分页参数**:无分页,一次性加载全部精选应用 +- **排序规则**:按管理员配置的 `order` 字段排序 +- **筛选条件**:标签(`tag` 参数)和名称(`name` 参数),两个条件可组合使用 + +--- + +### 跳转配置精选应用 + +> 管理员在精选应用列表为空时,点击"去配置"按钮跳转到精选应用管理页面。 + +#### 步骤 1:触发跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在精选应用面板空状态页面点击"去配置"按钮 | 无(前端路由切换) | 仅当 `userInfo.permission.hasManagePer` 为 true 且精选应用列表为空时显示此按钮 | 面板切换到设置面板(`SETTING`),并自动定位到精选应用管理标签页(`FAVOURITE_APPS`) | + +--- + +### Mermaid 附录 + +#### 浏览精选应用列表 + +```mermaid +sequenceDiagram + participant User + participant UI as 精选应用面板 + participant API as 后端 API + + User->>UI: 点击侧边栏"精选应用" + UI->>API: GET /proApi/core/chat/setting/favourite/list (name="", tag="") + API-->>UI: 返回精选应用列表 + UI-->>User: 展示应用卡片网格 + + opt 切换标签筛选 + User->>UI: 点击分类标签 + UI->>API: GET /proApi/core/chat/setting/favourite/list (tag=标签ID) + API-->>UI: 返回筛选后列表 + UI-->>User: 刷新卡片网格 + end + + opt 搜索应用 + User->>UI: 输入搜索关键词 + UI->>UI: 500ms 防抖 + UI->>API: GET /proApi/core/chat/setting/favourite/list (name=搜索词) + API-->>UI: 返回匹配结果 + UI-->>User: 刷新卡片网格 + end + + User->>UI: 点击应用卡片 + UI-->>User: 切换到最近使用面板,进入对话 +``` + +#### 跳转配置精选应用 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 精选应用面板 + + User->>UI: 看到空状态提示和"去配置"按钮 + User->>UI: 点击"去配置" + UI-->>User: 切换到设置面板(精选应用管理标签页) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a0e9d582bffe --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,49 @@ +--- +capability_label: 精选应用 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [普通用户, 管理员] +router_paths: [] +--- + +# 精选应用 — 模块概览 + +## 1. 模块业务概述 + +精选应用是对话首页中的一个侧边栏面板,为用户提供由管理员精心挑选并分类管理的应用集合。用户可以按标签分类浏览精选应用,通过名称搜索快速定位目标应用,点击应用卡片即可进入对话。 + +本模块服务于两类角色:普通用户在此发现和快速启动被推荐的应用;管理员通过设置面板配置精选应用列表和分类标签。模块在整体产品中的定位是降低应用发现门槛,引导用户使用高质量的应用。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 普通用户 | 精选应用面板 | 按标签浏览、搜索、点击进入对话 | 数据为空时仅显示空状态提示 | +| 管理员 | 精选应用面板 | 按标签浏览、搜索、点击进入对话、跳转配置管理 | 数据为空时额外显示"去配置"按钮,可跳转至精选应用管理页 | + +**权限模型**: 基于 `userInfo.permission.hasManagePer` 字段判断管理员权限,管理员在精选应用列表为空时看到"去配置"入口按钮。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话设置 | 读取 `chatSettings.favouriteTags` 获取分类标签列表 | API/Context | +| 对话上下文 | 通过 `handlePaneChange` 切换侧边栏面板,通过 `onOpenSlider` 打开移动端侧边栏 | Context | +| 系统配置 | 读取 `feConfigs.systemTitle` 作为页面标题 | Store | +| 用户信息 | 读取 `userInfo.permission.hasManagePer` 判断管理员身份 | Store | + +## 4. 对外共享 + +本模块的 `ChatFavouriteApp` 组件仅被对话首页(`/chat`)的路由页面引用,不对外部独立模块共享。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 精选应用面板 | /chat(pane=fa) | `projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx` | 普通用户、管理员 | 无 Tab 子页面,筛选标签为纯前端过滤控件 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ffa4079636f2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,48 @@ +--- +capability_label: "设置" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员"] +router_paths: ["/chat?pane=s"] +--- + +# 设置 — 业务流程索引 + +## 业务场景列表 + +### 进入设置页面 + +- **业务描述**: 管理员通过侧边栏进入对话首页的设置管理页面,系统自动校验商业版许可和管理权限 +- **触发入口**: 对话首页侧边栏 → 设置图标 +- **路由路径**: `/chat?pane=s` +- **页面组件位置**: `pageComponents/chat/ChatSetting/index.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 商业版许可已激活(`feConfigs.isPlus` 为 `true`),用户拥有团队管理权限(`hasManagePer` 为 `true`) + +### Tab 切换 + +- **业务描述**: 管理员在首页配置、首页数据、首页日志、精选应用四个 Tab 之间切换 +- **触发入口**: 设置页面顶部 Tab 栏 +- **路由路径**: `/chat?pane=s&tab={h|d|l|f}` +- **页面组件位置**: `pageComponents/chat/ChatSetting/SettingTabs.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入设置页面(`chatSettings` 已加载) + +## 场景关系 + +- **进入设置页面** → **Tab 切换**: 进入设置后默认展示「首页配置」Tab,管理员可自由切换至其他 Tab +- 各 Tab 内的操作场景独立执行,无严格先后关系 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 进入设置页面 | — | 侧边栏→设置图标 | `/chat?pane=s` | 管理员 | +| S01-1 |  ├ 首页配置 | 首页配置 | 默认 Tab / 点击切换 | `/chat?pane=s&tab=h` | 管理员 | +| S01-2 |  ├ 首页数据 | 首页数据 | Tab 栏→首页数据 | `/chat?pane=s&tab=d` | 管理员 | +| S01-3 |  ├ 首页日志 | 首页日志 | Tab 栏→首页日志 | `/chat?pane=s&tab=l` | 管理员 | +| S01-4 |  ├ 精选应用 | 精选应用 | Tab 栏→精选应用 | `/chat?pane=s&tab=f` | 管理员 | + +> 各 Tab 的详细业务场景说明见各自子目录下的「业务流程索引」和「业务流程详解」。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..758fc6f6ea23 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,84 @@ +--- +capability_label: "设置" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员", "普通成员"] +router_paths: ["/chat?pane=s"] +--- + +# 设置 — 业务流程详解 + +> 本模块已按 Tab 拆分为 4 个子能力,各 Tab 的深度业务流程详见子目录文档。本文档仅覆盖公共流程。 + +## 公共业务流程 + +### 权限校验与页面初始化 + +> 管理员访问设置页面时的初始化流程,包含商业版许可检查和权限校验。 + +#### 步骤 1:页面加载与权限校验 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过侧边栏点击设置进入,路由参数 `pane=s` | 无(客户端检查) | — | 页面加载,显示 ChatSetting 组件 | +| 系统检查 `feConfigs.isPlus` | 无 | `isPlus` 为 `false`(非商业版)→ 重定向至团队应用列表 | 页面跳转至 `TEAM_APPS`,用户看不到设置页面 | +| 系统检查 `userInfo.team.permission.hasManagePer` | 无 | `hasManagePer` 为 `false`(无管理权限)→ 重定向至团队应用列表 | 页面跳转至 `TEAM_APPS`,普通成员无法进入 | + +权限校验在 `useMount` 钩子中执行,发生在组件首次挂载时。 + +#### 步骤 2:读取路由参数确定 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动执行) | 无 | URL 中 `tab` 参数为有效枚举值(`h/d/l/f`)→ 使用该值 | 对应 Tab 内容和顶部 Tab 栏高亮 | +| 无(自动执行) | 无 | URL 中无 `tab` 参数或值无效 → 默认 `HOME` (h) | 默认展示「首页配置」Tab | + +#### 步骤 3:等待配置数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动执行) | `GET /proApi/core/chat/setting/detail`(由 `ChatPageContextProvider` 自动调用)| 数据加载中 | 页面空白,不渲染 Tab 内容(`chatSettings` 为 `undefined`) | +| 无(自动执行) | 同上 | 数据加载完成 | 渲染当前激活 Tab 的内容区域 | + +#### 步骤 4:Tab 切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部 Tab 栏的「首页配置」 | 无(路由更新) | 当前不在 HOME Tab | URL query 更新 `tab=h`,渲染首页配置表单 | +| 点击顶部 Tab 栏的「首页数据」 | 无(路由更新) | 当前不在 DATA_DASHBOARD Tab | URL query 更新 `tab=d`,渲染数据看板图表 | +| 点击顶部 Tab 栏的「首页日志」 | 无(路由更新) | 当前不在 LOG_DETAILS Tab | URL query 更新 `tab=l`,渲染日志列表 | +| 点击顶部 Tab 栏的「精选应用」 | 无(路由更新) | 当前不在 FAVOURITE_APPS Tab | URL query 更新 `tab=f`,渲染精选应用列表 | + +Tab 切换通过 `FillRowTabs` 组件(位于 `packages/web/components/common/Tabs/FillRowTabs.tsx`)实现,tab 值通过 URL query 参数持久化,支持浏览器前进/后退。 + +## Tab 子能力索引 + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| 首页配置 | 配置首页启用状态、快捷应用、可用工具集和标语文字 | [业务流程详解](../首页配置/业务流程详解.md) | +| 首页数据 | 以时间维度图表展示首页对话的调用次数、用户数等统计数据 | [业务流程详解](../数据看板/业务流程详解.md) | +| 首页日志 | 展示首页对话的详细日志,支持按时间、状态等条件筛选 | [业务流程详解](../日志详情/业务流程详解.md) | +| 精选应用 | 管理首页精选应用列表,含搜索、排序、标签分类和增删操作 | [业务流程详解](../收藏应用/业务流程详解.md) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 设置页面 + participant API as 后端 API + + User->>UI: 点击侧边栏"设置" + UI->>UI: 检查 isPlus + hasManagePer + alt 权限不足 + UI->>UI: 重定向至团队应用列表 + else 权限通过 + UI->>API: GET /proApi/core/chat/setting/detail + API-->>UI: 返回 ChatSetting 数据 + UI->>UI: 读取 tab query 参数,默认 HOME + UI->>UI: 渲染当前 Tab 内容 + Note over User,UI: 各 Tab 详细流程见子文档 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..44ab59ec6849 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: 收藏应用 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:30:00Z +parent_module: 设置 +roles: + - 团队管理员 +router_paths: + - /chat?pane=s&tab=f +--- + +# 收藏应用 — 业务流程索引 + +## 1. 业务场景列表 + +### 查看收藏应用列表 + +- **业务描述**: 管理员进入收藏应用管理页面,查看当前团队已配置的精选应用列表,支持按名称搜索和按分类标签筛选。 +- **触发入口**: 对话首页 → 设置面板 → 顶部 Tab 栏点击「精选应用」(仅在管理权限 + 商业版下可见) +- **路由路径**: `/chat?pane=s&tab=f` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 已登录,当前团队拥有商业版授权,用户具有团队管理权限(`hasManagePer`) + +### 添加收藏应用 + +- **业务描述**: 管理员从应用树(支持目录浏览和搜索)中勾选一个或多个应用,将其加入精选应用列表。 +- **触发入口**: 收藏应用管理页 → 顶部工具栏「添加应用」按钮 +- **路由路径**: `/chat?pane=s&tab=f`(模态弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 已进入收藏应用管理页,团队下已有可添加的应用 + +### 管理分类标签 + +- **业务描述**: 管理员创建、重命名、删除分类标签,并为每个标签分配精选应用。标签用于在对话首页按分类筛选展示精选应用。 +- **触发入口**: 收藏应用管理页 → 顶部工具栏「分类管理」按钮 +- **路由路径**: `/chat?pane=s&tab=f`(模态弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 已进入收藏应用管理页 + +### 拖拽排序 + +- **业务描述**: 管理员通过拖拽操作调整精选应用在列表中的显示顺序,拖拽结束后自动保存新顺序。 +- **触发入口**: 收藏应用管理页 → 列表中任意行的拖拽手柄图标 +- **路由路径**: `/chat?pane=s&tab=f` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 列表中有至少一个应用 + +### 删除收藏应用 + +- **业务描述**: 管理员从精选列表中移除某个应用,操作前弹出确认提示。 +- **触发入口**: 收藏应用管理页 → 列表中目标行右侧删除按钮 +- **路由路径**: `/chat?pane=s&tab=f` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 列表中有至少一个应用 + +## 2. 场景关系 + +- **查看收藏应用列表** ← **添加收藏应用**: 添加完成后自动刷新列表 +- **查看收藏应用列表** ← **管理分类标签**: 标签变更后刷新列表数据 +- **查看收藏应用列表** ← **拖拽排序**: 拖拽结束时即更新列表顺序并保存 +- **查看收藏应用列表** ← **删除收藏应用**: 删除后本地立即移除,同时刷新列表 + +各场景以列表页为中心,其他操作均为列表页上触发的模态弹窗交互,无严格的先后依赖关系。 + +## 3. 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看收藏应用列表 | 设置面板→精选应用 Tab | /chat?pane=s&tab=f | 团队管理员 | +| S02 | 添加收藏应用 | 列表页→添加应用按钮 | /chat?pane=s&tab=f | 团队管理员 | +| S03 | 管理分类标签 | 列表页→分类管理按钮 | /chat?pane=s&tab=f | 团队管理员 | +| S04 | 拖拽排序 | 列表行→拖拽手柄 | /chat?pane=s&tab=f | 团队管理员 | +| S05 | 删除收藏应用 | 列表行→删除按钮 | /chat?pane=s&tab=f | 团队管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..7442a0b0b905 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,377 @@ +--- +capability_label: 收藏应用 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:30:00Z +parent_module: 设置 +roles: + - 团队管理员 +router_paths: + - /chat?pane=s&tab=f +--- + +# 收藏应用 — 业务流程详解 + +## 页面总览 + +收藏应用管理页是对话设置中一个表单 + 列表管理视图,管理员在此集中管理团队的精选应用。页面顶部为工具栏区域(通过父组件 ChatSetting 的 SettingHeader 注入),包含搜索框、分类下拉筛选、分类管理按钮和添加应用按钮;下方为固定表头的可拖拽列表,每条记录展示应用名称、介绍、标签分类和删除操作。 + +页面加载时不显示全屏遮罩,仅在列表区域展示加载状态。列表为空时展示空状态提示。无嵌套 Tab 结构。 + +--- + +## S01:查看收藏应用列表 + +> 管理员进入收藏应用管理页,查看当前团队已配置的精选应用列表,支持按名称关键词搜索和按分类标签筛选。 + +#### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从对话首页点击设置,再切换至精选应用 Tab | GET `/proApi/core/chat/setting/favourite/list` | 无分支,自动触发(manual: false) | 列表区域显示加载中状态;搜索框和筛选下拉为默认空值 | + +列表初始加载时请求参数 `name` 和 `tag` 均为空字符串,获取全部收藏应用数据。加载完成后数据填充到本地状态 `localFavourites`,渲染为可拖拽列表。 + +#### 步骤 2:按名称搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | GET `/proApi/core/chat/setting/favourite/list`(参数 name=关键词) | 输入内容变化即触发,500ms 节流 | 列表区域显示加载中状态;列表刷新为匹配结果 | + +搜索为模糊匹配,由后端按应用名称筛选。搜索框使用 `react-hook-form` 的 `register` 绑定,输入值通过 `watchSearchValue` 监听并作为 `refreshDeps` 触发 API 重新请求。 + +#### 步骤 3:按分类标签筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分类下拉框,选择某个标签 | GET `/proApi/core/chat/setting/favourite/list`(参数 tag=标签ID) | 选择变化即触发 | 列表刷新为仅包含该标签的应用;筛选值同步更新到 form 状态 | + +下拉选项来自上下文 `chatSettings.favouriteTags`,第一项为固定选项「全部分类」(value 为空字符串)。下拉框使用 MySelect 组件,选择变化时通过 `setSearchValue('tag', tag)` 更新表单值。 + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET `/proApi/core/chat/setting/favourite/list` | name='',tag='' | 直接写入 localFavourites 状态 | 表格展示全部收藏应用 | +| 搜索触发 | GET `/proApi/core/chat/setting/favourite/list` | name=关键词,tag=当前筛选值 | 直接替换 localFavourites | 表格展示搜索结果 | +| 筛选举发 | GET `/proApi/core/chat/setting/favourite/list` | name=当前搜索值,tag=标签ID | 直接替换 localFavourites | 表格展示符合标签的应用 | + +- **分页参数**: 无分页,列表接口返回全量数据 +- **排序规则**: 按 order 字段排序,默认新添加的应用 order 为 10000000 +- **筛选条件**: name(应用名称模糊搜索)、tag(分类标签 ID 精确匹配),两个条件可组合 +- **特殊列渲染**: 名称列显示应用头像 + 名称;介绍列超过宽度时截断省略,hover 时弹出完整文本;标签列最多显示 2 个标签,超出部分显示 "+N" 并在 hover 时弹出全部标签 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 收藏应用列表页 + participant API as 后端 API + + User->>UI: 进入设置→精选应用 Tab + UI->>API: GET /proApi/core/chat/setting/favourite/list(name='',tag='') + API-->>UI: 返回全量收藏应用列表 + UI-->>User: 展示可拖拽列表 + + User->>UI: 输入搜索关键词 + Note over UI: 500ms 节流 + UI->>API: GET /proApi/core/chat/setting/favourite/list(name=关键词,tag=当前筛选) + API-->>UI: 返回搜索结果 + UI-->>User: 列表刷新为匹配结果 + + User->>UI: 选择分类标签 + UI->>API: GET /proApi/core/chat/setting/favourite/list(name=当前搜索值,tag=标签ID) + API-->>UI: 返回筛选结果 + UI-->>User: 列表刷新为符合标签的应用 +``` + +--- + +## S02:添加收藏应用 + +> 管理员点击「添加应用」按钮打开弹窗,从应用树中浏览和勾选应用,提交后批量加入精选列表。 + +#### 步骤 1:打开添加弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部「添加应用」按钮 | GET `/proApi/core/chat/setting/favourite/list` + GET 应用列表接口 | 无分支 | 弹出添加应用模态弹窗;弹窗左侧加载应用树,右侧显示已选应用 | + +弹窗打开时自动并行请求两个接口:获取当前已有的收藏应用列表(用于初始化已选状态)和根目录应用树列表。 + +#### 步骤 2:浏览应用树 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个文件夹 | GET 应用列表接口(参数 parentId=文件夹ID) | 点击项为文件夹类型(AppTypeEnum.folder)时进入子目录 | 左侧列表刷新为该文件夹下的应用和子文件夹;顶部面包屑更新路径 | +| 点击面包屑路径中的某级目录 | GET 应用列表接口(参数 parentId=对应目录ID) | 无分支 | 左侧列表跳转到对应层级;面包屑路径缩短 | +| 点击面包屑「根目录」 | GET 应用列表接口(参数 parentId='') | 无分支 | 返回根目录 | +| 在搜索框中输入关键词 | GET 应用列表接口(参数 searchKey=关键词) | 搜索模式下隐藏面包屑路径 | 左侧列表展示搜索结果(跨越目录层级) | + +应用浏览器支持目录树导航和关键词搜索两种模式。搜索模式下显示「搜索结果」标签,面包屑隐藏。搜索输入 500ms 节流。 + +#### 步骤 3:勾选应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个非文件夹应用行 / 其复选框 | 无 | 应用已在已选列表中 → 取消勾选;应用未在列表中 → 加入已选列表 | 该行复选框状态切换;右侧已选列表新增或移除该项 | + +勾选操作纯前端状态管理,不调用 API。已选列表同步更新,右侧面板显示已选应用的头像、名称和移除按钮。添加时新应用插入到已选列表最前面。 + +#### 步骤 4:搜索已选应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入后勾选/取消勾选 | GET `/proApi/core/chat/setting/favourite/list`(参数 name=搜索词) | 每 500ms 节流触发 | 用于判断搜索到的应用是否已加入收藏 | + +在 AddFavouriteAppModal 中,搜索结果与已选状态通过本地状态关联,实现了勾选的即时反馈。 + +#### 步骤 5:提交保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确定」按钮 | POST `/proApi/core/chat/setting/favourite/update`(参数:应用 ID 列表及 order) | 无分支 | 按钮显示加载状态;保存成功后弹窗关闭,列表页自动刷新 | + +提交时将已选应用按顺序编号(order: 从 1 递增),以数组形式发送。保存成功后调用父组件传入的 `onRefresh` 回调刷新列表,弹窗关闭。 + +#### 步骤 6:取消操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「取消」按钮 | 无 | 无分支 | 弹窗关闭,列表不受影响 | + +#### 表单与交互详情 + +**已选应用区域**: +- 每个已选项展示应用头像、名称、移除按钮 +- 已选项之间无拖拽排序功能 +- 已选项为空时展示空状态提示 + +**前后置条件**: +- **前置条件**: 团队下已有可添加的应用,用户有管理权限 +- **后置影响**: 操作成功后应用加入精选列表,在对话首页和 FavouriteApp 侧栏中可见 +- **失败场景**: 网络异常导致保存失败,弹窗保持打开,用户可重试 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 添加应用弹窗 + participant API as 后端 API + + User->>UI: 点击「添加应用」 + par 并行加载 + UI->>API: GET 收藏应用列表(获取已选状态) + UI->>API: GET 应用树(parentId='') + end + API-->>UI: 返回收藏列表 + 应用树 + UI-->>User: 展示双栏弹窗 + + User->>UI: 浏览文件夹/搜索 + UI->>API: GET 应用列表(parentId/搜索词) + API-->>UI: 返回子目录/搜索结果 + UI-->>User: 刷新左侧应用列表 + + User->>UI: 勾选/取消勾选应用 + Note over UI: 纯前端状态更新 + UI-->>User: 右侧已选列表实时更新 + + User->>UI: 点击「确定」 + UI->>API: POST /proApi/core/chat/setting/favourite/update + API-->>UI: 保存成功 + UI-->>User: 弹窗关闭,列表页刷新 +``` + +--- + +## S03:管理分类标签 + +> 管理员打开分类管理弹窗,对团队的精选应用分类标签进行创建、重命名、排序和删除,并可进入子面板为每个标签勾选归属应用。 + +#### 步骤 1:打开分类管理弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部「分类管理」按钮 | 无(标签数据从上下文 chatSettings.favouriteTags 读取) | 无分支 | 弹出分类管理模态弹窗;展示已有标签列表,每项显示标签名和应用数 | + +弹窗标题为「共 N 个分类」,标签列表从上下文中读取并复制到本地状态 `localTags` 以便编辑。 + +#### 步骤 2:创建新标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「新建」按钮 | 无(本地状态变更) | 无分支 | 列表顶部插入一个空名称的新标签行,自动进入编辑模式,输入框自动聚焦 | + +新标签 ID 由前端 `getNanoid(8)` 生成,名称为空。标签处于编辑中状态,用户可输入名称。标签数据仅在用户确认(回车或失焦)后才会保存到后端。 + +#### 步骤 3:编辑标签名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击标签行的编辑图标 | 无 | 无分支 | 标签名称变为可编辑输入框,自动聚焦 | +| 修改名称后按回车或失焦 | POST `/proApi/core/chat/setting/update`(参数 favouriteTags=完整标签列表) | 名称为空时撤销编辑或删除新建标签 | 输入框退出编辑模式,标签名称更新;列表自动保存 | +| 在输入框中按 Escape 或清空后失焦(已有标签) | 无 | 还原为原始名称 | 退出编辑模式,名称还原 | + +编辑态通过本地状态 `isEditing` 数组管理,每次编辑提交时将完整标签列表发送到更新接口。 + +#### 步骤 4:删除标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击标签行的删除图标 | 无 | 弹出确认对话框(文案:「确认删除 {{name}} ?该分类下的应用将被移动至默认」) | 显示删除确认弹窗 | +| 确认删除 | POST `/proApi/core/chat/setting/update`(参数 favouriteTags=移除目标后的标签列表) | 无分支 | 标签从列表中移除;关联的应用标签自动清除 | + +#### 步骤 5:为标签分配应用(子面板) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击标签行的添加应用图标(+号) | 无 | 无分支 | 弹窗内容切换为「标签分配应用」子面板 | +| 在子面板中勾选/取消勾选应用 | 无(纯前端状态变更) | 无分支 | 复选框状态切换;标签名旁的应用计数实时更新 | +| 搜索应用 | GET `/proApi/core/chat/setting/favourite/list`(name=搜索词) | 无分支 | 应用列表按名称筛选 | +| 点击「保存」 | PUT `/proApi/core/chat/setting/favourite/tags`(参数:全部收藏应用的标签数据) | 无分支 | 保存成功后退出子面板,标签列表页刷新 | + +子面板进入时并行加载全量收藏应用(用于计算已勾选状态)和可见应用列表(用于渲染)。已勾选状态通过本地状态管理,提交时一次性写入全部应用的标签关联。 + +#### 步骤 6:拖拽排序标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 拖拽标签行到新位置 | POST `/proApi/core/chat/setting/update`(参数:重排后的标签列表) | 无分支 | 拖拽释放后即时保存新顺序 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 分类管理弹窗 + participant API as 后端 API + + User->>UI: 点击「分类管理」 + UI-->>User: 展示标签列表 + + User->>UI: 点击「新建」 + Note over UI: 创建空标签,进入编辑模式 + User->>UI: 输入名称并确认 + UI->>API: POST /proApi/core/chat/setting/update(favouriteTags) + API-->>UI: 保存成功 + UI-->>User: 标签列表更新 + + User->>UI: 点击标签的 + 图标 + UI->>API: GET 全量收藏应用 + API-->>UI: 返回应用列表与标签关联 + UI-->>User: 展示勾选子面板 + User->>UI: 勾选/取消勾选应用 + User->>UI: 点击「保存」 + UI->>API: PUT /proApi/core/chat/setting/favourite/tags + API-->>UI: 保存成功 + UI-->>User: 返回标签列表 + + User->>UI: 点击删除图标 → 确认 + UI->>API: POST /proApi/core/chat/setting/update(移除标签后) + API-->>UI: 删除成功 + UI-->>User: 标签从列表移除 +``` + +--- + +## S04:拖拽排序 + +> 管理员通过拖拽手柄调整收藏应用的显示顺序,释放后自动保存新顺序。 + +#### 步骤 1:拖拽开始 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 按住列表行的拖拽手柄(六点图标)并开始拖动 | 无 | 无分支 | 当前行被"拎起",显示半透明拖拽预览;其他行保持原位 | + +使用 `DndDrag` 和 `Draggable` 组件实现拖拽(基于 react-beautiful-dnd)。拖拽手柄仅在 hover 时显示视觉反馈。 + +#### 步骤 2:拖拽释放 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 释放拖拽的行到目标位置 | PUT `/proApi/core/chat/setting/favourite/order`(参数:重排后全部应用 ID + 新 order) | 无分支 | 列表立即更新为新顺序;API 保存成功后静默刷新列表数据 | + +拖拽结束回调执行两步操作: +1. 更新本地状态 `localFavourites` 中的 `order` 字段 +2. 调用排序保存 API,成功后再次调用 `getApps()` 刷新列表以获取服务端确认数据 + +#### 前后置条件 + +- **前置条件**: 列表中有至少 2 个应用 +- **后置影响**: 排序更新后,对话首页精选应用的展示顺序随之改变 +- **失败场景**: API 保存失败时,由于本地状态已乐观更新,列表可能短暂显示新顺序后恢复(`getApps()` 失败则停留在本地状态) + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 收藏应用列表 + participant API as 后端 API + + User->>UI: 拖拽应用 A 到新位置 + Note over UI: 乐观更新本地列表顺序 + UI-->>User: 列表重排展示 + UI->>API: PUT /proApi/core/chat/setting/favourite/order + API-->>UI: 保存成功 + UI->>API: GET /proApi/core/chat/setting/favourite/list(刷新) + API-->>UI: 返回最新列表 + UI-->>User: 顺序确认 +``` + +--- + +## S05:删除收藏应用 + +> 管理员从精选列表移除某个应用,需二次确认。 + +#### 步骤 1:点击删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击目标行的删除图标(垃圾桶) | 无 | 弹出确认弹窗(文案:「确定要移除该精选应用吗?」) | 删除图标所在位置显示确认弹窗 | + +删除确认使用 PopoverConfirm 组件,类型为 `delete`,触发方式是点击删除图标按钮。 + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | DELETE `/proApi/core/chat/setting/favourite/delete`(参数 id=应用 _id) | 无分支 | 应用从列表中立即移除;后续行顺位补上,order 自动重新编号 | + +删除采用乐观更新策略:先在本地状态中过滤掉目标项,重新计算后续项的 order,然后调用删除 API。无论 API 成功与否,都再次调用 `getApps()` 刷新列表以保证数据一致性。 + +#### 步骤 3:取消删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击取消或点击弹窗外区域 | 无 | 无分支 | 确认弹窗关闭,列表不变 | + +#### 删除链路详情 + +- **引用检查**: 无引用检查逻辑,可直接删除 +- **确认弹窗**: 使用 PopoverConfirm(内联弹出确认框,非模态弹窗),类型为 delete;确认文案「确定要移除该精选应用吗?」 +- **批量与单条差异**: 仅支持单条删除,不支持批量操作 +- **级联影响**: 删除后对话首页该应用不再展示为精选;该应用在标签管理中的应用关联自动失效 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 收藏应用列表 + participant API as 后端 API + + User->>UI: 点击删除图标 + UI-->>User: 弹出确认框「确定要移除该精选应用吗?」 + User->>UI: 确认删除 + Note over UI: 从本地列表移除该项 + UI-->>User: 该项从列表消失 + UI->>API: DELETE /proApi/core/chat/setting/favourite/delete + API-->>UI: 删除成功 + UI->>API: GET /proApi/core/chat/setting/favourite/list(刷新) + API-->>UI: 返回最新列表 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..c29a37e07827 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,55 @@ +--- +capability_label: 收藏应用 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:30:00Z +parent_module: 设置 +roles: + - 团队管理员 +router_paths: + - /chat?pane=s&tab=f +--- + +# 收藏应用 — 模块概览 + +## 1. 模块业务概述 + +收藏应用模块是对话首页「设置」面板下的一个子功能,面向**团队管理员**,用于管理「精选应用」列表——即展示在团队对话首页快捷入口的应用集合。管理员可通过本模块配置哪些应用作为精选推荐、如何分类展示、以及排序优先级,最终影响所有团队成员的对话首页体验。 + +本模块是 FastGPT 商业版(Plus)专属功能,必须在团队管理员角色且已启用商业授权(`isPlus`)的前提下才能访问。模块本身为叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 收藏应用管理页 | 查看列表、搜索筛选、拖拽排序、添加应用、删除应用、管理分类标签 | 完整管理权限 | +| 普通团队成员 | 无 | 无法访问本模块 | 普通成员在对话首页仅可使用已配置的精选应用,无法进入管理页面 | + +**权限模型**: 基于 `userInfo.team.permission.hasManagePer` 和 `feConfigs.isPlus` 的双重校验。在 ChatSetting 父组件 `useMount` 中检查,不满足条件时自动跳转到团队应用面板。收藏应用 Tab(`ChatSettingTabOptionEnum.FAVOURITE_APPS`)仅在管理权限 + 商业版环境下可见。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话设置(父模块) | 路由导航、SettingHeader 插槽、Tab 切换、权限前置校验 | 组件/路由 | +| 对话页上下文 | ChatPageContext — chatSettings(含 favouriteTags)、refreshChatSetting、handlePaneChange | Context | +| 应用管理 | getMyApps(应用树浏览)、getAppFolderPath(目录路径) | API | +| 系统配置 | useSystemStore — feConfigs.isPlus(商业版开关)、isPc(响应式布局) | Store | +| 用户认证 | useUserStore — userInfo.team.permission.hasManagePer(管理权限) | Store | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 精选应用列表数据(chatSettings.favouriteTags) | ChatFavouriteApp(对话首页精选应用展示) | Context 数据 | +| 收藏应用条目数据 | ChatFavouriteApp — 卡片列表渲染 | API 数据(共享 getFavouriteApps) | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 收藏应用管理 | /chat?pane=s&tab=f | `projects/app/src/pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` | 团队管理员 | 含搜索框、分类下拉筛选、分类管理按钮、添加应用按钮、拖拽排序列表 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a4db2136c403 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" @@ -0,0 +1,151 @@ +--- +capability_label: "数据看板" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — API 索引 + +## API 汇总表 + +| 序号 | API 端点 | 方法 | 功能 | 调用位置 | 商业版专属 | +|------|---------|------|------|---------|-----------| +| API-01 | `/proApi/core/app/logs/getChartData` | POST | 获取图表统计数据 | `LogChart.tsx` (useRequest) | ✅ 是 | +| API-02 | `/core/app/logs/getLogKeys` | GET | 获取日志字段配置 | `LogsContext/context.tsx` (useRequest) | ❌ 否 | + +## API 详情 + +### API-01: 获取图表统计数据 + +- **端点**: `POST /proApi/core/app/logs/getChartData` +- **调用函数**: `getAppChartData` → `web/core/app/api/log.ts:42` +- **描述**: 获取应用的数据看板图表原始数据,包含用户数据(userData)、对话数据(chatData)和应用数据(appData)三个维度的时间序列数据 +- **商业版专属**: ✅ 是(非商业版返回假数据 `fakeChartData`,图表模糊遮罩) + +#### 请求参数 (getChartDataBody) + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `appId` | `string` | 是 | 应用 ID,从 ChatPageContext.chatSettings.appId 获取 | +| `dateStart` | `Date` | 是 | 起始日期,默认为 dateRange.from 或当前日期 | +| `dateEnd` | `Date` | 是 | 结束日期,默认为 dateRange.to + 1 天 | +| `offset` | `number` | 是 | 偏移量(用于留存用户计算),从 offsetOptions 中选取 | +| `source` | `ChatSourceEnum[]` | 是 | 渠道来源筛选列表 | +| `userTimespan` | `AppLogTimespanEnum` | 是 | 用户数据时间粒度:day / week / month / quarter | +| `chatTimespan` | `AppLogTimespanEnum` | 是 | 对话数据时间粒度:day / week / month / quarter | +| `appTimespan` | `AppLogTimespanEnum` | 是 | 应用数据时间粒度:day / week / month / quarter | + +#### 响应数据 (getChartDataResponse) + +| 字段 | 类型 | 描述 | +|------|------|------| +| `userData` | `Array<{timestamp: number, summary: UserSummary}>` | 用户数据时间序列 | +| `userData[].summary.userCount` | `number` | 用户总数 | +| `userData[].summary.newUserCount` | `number` | 新增用户数 | +| `userData[].summary.retentionUserCount` | `number` | 留存用户数 | +| `userData[].summary.points` | `number` | 积分消耗 | +| `userData[].summary.inputTokens` | `number` | 输入 Token 数 | +| `userData[].summary.outputTokens` | `number` | 输出 Token 数 | +| `userData[].summary.sourceCountMap` | `Record` | 各渠道来源用户数 | +| `chatData` | `Array<{timestamp: number, summary: ChatSummary}>` | 对话数据时间序列 | +| `chatData[].summary.chatItemCount` | `number` | 对话条目数 | +| `chatData[].summary.chatCount` | `number` | 对话次数 | +| `chatData[].summary.points` | `number` | 积分消耗 | +| `chatData[].summary.errorCount` | `number` | 错误次数 | +| `appData` | `Array<{timestamp: number, summary: AppSummary}>` | 应用数据时间序列 | +| `appData[].summary.goodFeedBackCount` | `number` | 好评数 | +| `appData[].summary.badFeedBackCount` | `number` | 差评数 | +| `appData[].summary.totalResponseTime` | `number` | 总响应时长(毫秒) | +| `appData[].summary.chatCount` | `number` | 对话次数(用于计算平均时长) | + +#### 前端数据加工 + +前端在 `LogChart.tsx` 的 `formatChartData` computed 中对原始数据做以下加工: + +1. **时间序列补全**:`generateCompleteTimeSeries()` 根据 timeRange 和 timespan 生成完整时间范围 +2. **缺失日期填充**:对于无数据的日期,填充默认值(各项为 0) +3. **留存率修正**:日粒度下 `newUserCount = newUserCount - retentionUserCount`(排除留存用户) +4. **衍生指标计算**: + - `totalTokens` = `inputTokens + outputTokens` + - `pointsPerChat` = `points / chatCount`(保留 2 位小数) + - `errorRate` = `errorCount / chatItemCount * 100`(保留 2 位小数) + - `avgDuration` = `totalResponseTime / chatCount` +5. **累计汇总**:`cumulative` 对象汇总所有时间点的 sum/avg 值 + +#### 刷新依赖 + +| 依赖项 | 说明 | +|--------|------| +| `appId` | 应用切换时重新请求 | +| `dateRange.from` | 起始日期变化 | +| `dateRange.to` | 结束日期变化 | +| `offset` | 偏移量切换 | +| `chatSources` | 渠道来源筛选变化 | +| `userTimespan` | 用户数据时间粒度切换 | +| `chatTimespan` | 对话数据时间粒度切换 | +| `appTimespan` | 应用数据时间粒度切换 | + +--- + +### API-02: 获取日志字段配置 + +- **端点**: `GET /core/app/logs/getLogKeys` +- **调用函数**: `getLogKeys` → `web/core/app/api/log.ts:33` +- **描述**: 获取当前应用团队级别的日志显示字段配置 + +#### 请求参数 (getLogKeysQuery) + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `appId` | `string` | 是 | 应用 ID | + +#### 响应数据 (getLogKeysResponseType) + +| 字段 | 类型 | 描述 | +|------|------|------| +| `logKeys` | `AppLogKeysType[]` | 日志字段列表,含 key、label、enable 等属性 | + +#### 调用时机与缓存策略 + +- **调用时机**:LogsContextProvider 挂载时自动调用(manual: false) +- **刷新依赖**:`appId` 变化时重新调用 +- **本地缓存**:`localStorage` key 为 `app_log_keys_{appId}` +- **缓存策略**:成功回调中检查本地是否已有缓存,有则保留本地;无则从响应同步 + +## 依赖关系图 + +```mermaid +graph TD + subgraph DataDashboard + DD[DataDashboard] + FB[LogsFilterBar] + end + + subgraph LogsContext + Ctx[LogsContextProvider] + LK[logKeys state] + end + + subgraph LogChart + LC[LogChart] + FD[formatChartData] + end + + subgraph API + GCA[getAppChartData] + GLK[getLogKeys] + end + + DD -->|挂载| Ctx + Ctx -->|manual:false| GLK + GLK -->|GET /core/app/logs/getLogKeys| Server[后端服务] + DD -->|渲染| LC + LC -->|useRequest| GCA + GCA -->|POST /proApi/core/app/logs/getChartData| Server + FB -->|消费| Ctx + LC -->|消费| Ctx +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..576cf03db879 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,72 @@ +--- +capability_label: "数据看板" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — 业务流程索引 + +## 业务场景列表 + +### 查看数据看板 + +- **业务描述**: 管理员进入首页数据看板页面,查看首页 AI 应用的综合运营数据,包含用户增长、对话活跃度和用户反馈三大维度的图表 +- **触发入口**: 设置页面 Tab 栏 →「首页数据」 +- **路由路径**: `/chat?pane=s&tab=d` +- **页面组件位置**: `pageComponents/chat/ChatSetting/DataDashboard.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入设置页面,chatSettings 已加载完成(含 appId) + +### 筛选数据范围 + +- **业务描述**: 管理员通过筛选栏调整数据看板的展示范围,包括选择日期范围、勾选渠道来源 +- **触发入口**: 数据看板顶部筛选栏 +- **路由路径**: 同上(无路由变化) +- **页面组件位置**: `pageComponents/chat/ChatSetting/DataDashboard.tsx`(LogsFilterBar 子组件) +- **涉及角色**: 管理员 +- **前置条件**: 数据看板已加载 + +### 切换统计维度 + +- **业务描述**: 管理员切换各数据面板的时间粒度(日/周/月/季度),以不同时间维度审视数据趋势 +- **触发入口**: 各 Accordion 面板右侧的时间粒度下拉选择器 +- **路由路径**: 同上(无路由变化) +- **页面组件位置**: `pageComponents/app/detail/Logs/LogChart.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 图表数据已加载 + +### 展开/折叠数据面板 + +- **业务描述**: 管理员展开或折叠用户数据面板或对话数据面板,聚焦关注的数据维度 +- **触发入口**: 各面板标题栏(Accordion 按钮) +- **路由路径**: 同上(无路由变化) +- **页面组件位置**: `pageComponents/app/detail/Logs/LogChart.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 数据看板已加载 + +## 场景关系 + +``` +进入数据看板 + ├── 查看数据看板(默认加载全部图表) + ├── 筛选数据范围(调整日期和渠道 → 图表数据重新请求) + └── 切换统计维度(选择时间粒度 → 图表数据重新请求) + └── 展开/折叠数据面板(切换面板显示状态) +``` + +所有操作场景均在数据看板页面内完成,筛选和切换均触发图表数据重新请求。Accordion 默认全部展开(`defaultIndex={[0,1,2]}`)。 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| D01 | 查看数据看板 | 设置 Tab →「首页数据」| `/chat?pane=s&tab=d` | 管理员 | +| D02 | 筛选数据范围 | 看板顶部筛选栏 | 同 D01 | 管理员 | +| D03 | 切换统计维度(用户数据) | 用户数据面板 → 时间粒度选择器 | 同 D01 | 管理员 | +| D04 | 切换统计维度(对话数据) | 对话数据面板 → 时间粒度选择器 | 同 D01 | 管理员 | +| D05 | 切换统计维度(应用数据) | 应用数据面板 → 时间粒度选择器 | 同 D01 | 管理员 | +| D06 | 展开/折叠数据面板 | 面板标题栏点击 | 同 D01 | 管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..210303cea591 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,167 @@ +--- +capability_label: "数据看板" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — 业务流程详解 + +> 本模块为叶子节点,无 Tab 拆分。全部内容覆盖于此文档。 + +--- + +## 1. 页面初始化与数据加载 + +> 管理员从设置页面切换到「首页数据」Tab 时,DataDashboard 组件挂载并完成数据初始化。 + +### 步骤 1:组件挂载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在设置页面点击「首页数据」Tab | 无(路由更新 `tab=d`) | — | ChatSetting 根据 tab 值渲染 `` | + +### 步骤 2:获取 appId + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动执行) | 无 | `chatSettings?.appId` 存在 | 获取到 appId,后续子组件正常渲染 | +| 无(自动执行) | 无 | `chatSettings?.appId` 为空字符串 | 传递空 appId,LogChart 从 `AppContext` 回退获取 appId | + +appId 从 `ChatPageContext` 中通过 `useContextSelector(ChatPageContext, (v) => v.chatSettings?.appId || '')` 获取。 + +### 步骤 3:初始化日志筛选上下文 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动执行) | `GET /core/app/logs/getLogKeys` | 接口调用成功,返回 logKeys | 设置团队日志字段配置,若本地无缓存则同步到 localStorage | +| 无(自动执行) | `GET /core/app/logs/getLogKeys` | 本地已有缓存的 logKeys(`localStorage: app_log_keys_{appId}`) | 优先使用本地缓存,不从接口覆盖 | + +LogsContextProvider 挂载时: +- 日期范围默认为最近 6 天(`new Date(addDays(new Date(), -6))` 至当天 23:59:59) +- 渠道来源默认为全选(`useMultipleSelect(Object.values(ChatSourceEnum), true)`) +- 日志字段配置优先使用 localStorage 缓存,无缓存时从接口获取 + +### 步骤 4:加载图表数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动执行) | `POST /proApi/core/app/logs/getChartData` | `feConfigs.isPlus` 为 `true` | 请求图表数据,各面板显示加载中(MyBox + loading spinner) | +| 无(自动执行) | 无(跳过请求) | `feConfigs.isPlus` 为 `false`(非商业版) | 使用 `fakeChartData` 假数据,图表显示模糊遮罩 | + +图表数据请求依赖项:`appId`, `dateRange.from`, `dateRange.to`, `offset`, `chatSources`, `userTimespan`, `chatTimespan`, `appTimespan`。任一变化触发重新请求。 + +--- + +## 2. 数据筛选 + +> 管理员通过筛选栏调整图表数据范围。 + +### 步骤 1:修改日期范围 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在日期选择器中修改日期范围 | `POST /proApi/core/app/logs/getChartData`(自动重新请求) | 新日期范围有效 | 图表数据按新日期范围刷新 | +| 在日期选择器中修改日期范围 | 同上 | 日期范围非法(from > to) | 无变化(日期选择器组件内部校验) | + +### 步骤 2:选择渠道来源 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在渠道多选下拉中勾选/取消渠道 | `POST /proApi/core/app/logs/getChartData`(自动重新请求) | 选择变化 | 图表数据按筛选后的渠道来源刷新 | + +### 步骤 3:修改偏移量 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「留存用户」面板的偏移量下拉中选择新值 | `POST /proApi/core/app/logs/getChartData`(自动重新请求) | offset 变化 | 留存用户数据按新偏移量刷新 | + +--- + +## 3. 切换统计粒度 + +> 管理员在各数据面板中切换时间粒度(日/周/月/季度)。 + +### 步骤 1:切换用户数据时间粒度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「用户数据」面板右侧选择时间粒度(日/周/月/季度) | `POST /proApi/core/app/logs/getChartData`(自动重新请求,userTimespan 变化) | 选择「日」 | 折线/柱状图按天展示数据点 | +| 同上 | 同上 | 选择「周」 | 折线/柱状图按周展示,X 轴标签格式 `MM/DD-MM/DD` | +| 同上 | 同上 | 选择「月」 | 折线/柱状图按月展示 | +| 同上 | 同上 | 选择「季度」 | 折线/柱状图按季度展示 | + +### 步骤 2:切换对话数据时间粒度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「对话数据」面板右侧选择时间粒度 | 同上(chatTimespan 变化) | 同用户数据面板逻辑 | 对话趋势图表按所选粒度展示 | + +### 步骤 3:切换应用数据时间粒度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「应用数据」面板右侧选择时间粒度 | 同上(appTimespan 变化) | 同上述逻辑 | 反馈和响应时长图表按所选粒度展示 | + +--- + +## 4. 图表交互 + +> 管理员与图表的交互操作。 + +### 步骤 1:查看图表详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停在图表数据点上 | 无 | — | 弹出 Tooltip 显示该数据点的详细信息 | +| 查看图表右上角汇总数据 | 无 | — | 显示该维度的累计/平均值(如「总: 1234」) | + +### 步骤 2:展开/折叠面板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「用户数据」面板标题 | 无 | — | 面板折叠/展开,切换 Accordion 状态 | +| 点击「对话数据」面板标题 | 无 | — | 面板折叠/展开 | +| 点击「应用数据」面板标题 | 无 | — | 面板折叠/展开 | + +默认全部展开(`defaultIndex={[0, 1, 2]}`)。 + +--- + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant Admin as 管理员 + participant Setting as 设置页面 + participant DD as DataDashboard + participant Ctx as LogsContextProvider + participant API as 后端 API + participant Chart as LogChart + + Admin->>Setting: 点击「首页数据」Tab + Setting->>DD: 渲染 DataDashboard (tab=d) + + DD->>DD: useContextSelector → 获取 appId + DD->>Ctx: 挂载 LogsContextProvider(appId) + + Ctx->>Ctx: 初始化 dateRange (最近6天) + Ctx->>Ctx: 初始化 chatSources (全选) + Ctx->>API: GET /core/app/logs/getLogKeys + API-->>Ctx: 返回 logKeys → 同步 localStorage + + Ctx->>Chart: 渲染 LogChart + Chart->>Chart: 初始化 timespan 均为 'day' + Chart->>API: POST /proApi/core/app/logs/getChartData + API-->>Chart: 返回 userData, chatData, appData + Chart->>Chart: formatChartData 处理 + 补全时间序列 + Chart->>Admin: 展示 3 个 Accordion 面板(用户/对话/应用数据) + + Admin->>Chart: 选择日期范围/渠道/时间粒度 + Chart->>API: POST /proApi/core/app/logs/getChartData (新参数) + API-->>Chart: 返回新数据 + Chart->>Admin: 图表刷新 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..58cd97882265 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,64 @@ +--- +capability_label: "数据看板" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — 模块概览 + +## 1. 模块业务概述 + +「数据看板」是对话首页设置模块下的数据可视化子页,面向**团队管理员**提供首页对话的多维度数据统计图表。管理员可以在此页面查看首页 AI 应用的用户数据、对话数据和反馈数据,支持按日/周/月/季度切换时间粒度,帮助分析应用运营状况和用户增长趋势。 + +本模块是单一视图页面,内部无 Tab 拆分。 + +**核心数据维度**: + +- **用户数据**:用户总数、新增用户、留存用户、各渠道来源用户数、积分消耗趋势、Token 消耗趋势 +- **对话数据**:对话条目总数、对话次数、平均积分消耗、错误次数及错误率 +- **应用数据**:好评/差评反馈数、平均响应时长 + +**权限限制**:同上级「设置」模块,仅商业版(`isPlus`)管理员可访问。数据不满足商业版条件时,图表将以模糊(blur)形式展示。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 数据看板整页 | 查看全部图表、切换时间粒度、选择渠道来源 | 可查看所有数据维度并交互筛选 | +| 普通成员 | 无法访问 | 无 | 在上级设置页面入口即被拦截,重定向至团队应用列表 | + +**权限模型**: 继承自上级「设置」模块(`isPlus && hasManagePer`),本模块无额外权限检查。数据模糊控制由 `feConfigs.isPlus` 标记实现,非商业版用户仍可看到图表框架,但数据内容被模糊处理。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话上下文 | `ChatPageContext` — 提供 chatSettings.appId 作为数据查询参数 | Context | +| 日志上下文 | `LogsContext` — 提供 dateRange、chatSources、isSelectAllSource 等筛选状态 | Context | +| 日志上下文 | `LogsContextProvider` — 为子组件提供日志筛选上下文 | Context Provider | +| 应用日志模块 | `LogChart` — 数据图表主组件(折线图、柱状图、手风琴面板) | 组件 | +| 应用日志模块 | `HeaderControl` — 图表筛选栏(日期范围、渠道来源选择) | 组件 | +| 全局系统 | `useSystemStore` — 获取 feConfigs.isPlus 控制数据展示 | Store | +| 通用组件 | `MyBox`、`MySelect`、`Flex`、`Accordion`、`Grid` — 布局和交互组件 | 组件 | +| 图表组件 | `LineChartComponent`、`BarChartComponent` — 数据可视化基础组件 | 组件 | + +## 4. 对外共享 + +本模块不对外共享任何能力,仅为消费型页面: + +- **不导出**任何组件、hooks、工具函数 +- 核心组件 `LogChart`、`LogsContext` 等来自 `@/pageComponents/app/detail/Logs/`,归属「应用详情」模块的日志能力 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 首页数据看板 | `/chat?pane=s&tab=d` | `pageComponents/chat/ChatSetting/DataDashboard.tsx` | 管理员 | 通过设置页面 Tab 栏切换进入 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..bbfa5076989b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,111 @@ +--- +capability_label: "数据看板" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — 组件列表 + +## 组件树 + +``` +DataDashboard (React.memo) ← 入口 +└── LogsContextProvider (context provider) ← 日志筛选状态上下文 + └── Flex (Chakra UI) ← 垂直布局容器 + ├── Header (props: React.FC) ← 来自父级 ChatSetting 的 SettingHeader + ├── LogsFilterBar (内部组件) ← 数据筛选栏 + │ └── HeaderControl (from LogChart) ← 日期+渠道筛选控件 + └── LogChart (from app/detail/Logs) ← 数据图表主组件 + ├── MyBox (loading wrapper) ← 加载状态容器 + │ ├── TotalData ← 顶部汇总卡片 + │ └── Accordion ← 手风琴面板 + │ ├── AccordionItem #0 (用户数据) + │ │ ├── AccordionButton ← 面板标题栏 + │ │ │ └── MySelect ← 时间粒度选择器 + │ │ └── AccordionPanel + │ │ └── Grid (2列) + │ │ ├── LineChartComponent (用户总数) ← 折线图 + │ │ ├── BarChartComponent (用户留存) ← 柱状图(含偏移量 MySelect) + │ │ ├── LineChartComponent (渠道来源) ← 多条折线 + │ │ └── LineChartComponent (积分消耗) ← 折线图 + │ │ └── LineChartComponent (Token 趋势) ← 3条折线(全宽) + │ ├── AccordionItem #1 (对话数据) + │ │ ├── AccordionButton + │ │ │ └── MySelect (时间粒度) + │ │ └── AccordionPanel + │ │ └── Grid (2列) + │ │ ├── LineChartComponent (对话条目数) ← 折线图 + │ │ ├── LineChartComponent (对话次数) ← 折线图 + │ │ ├── BarChartComponent (错误次数) ← 柱状图 + │ │ └── LineChartComponent (错误率) ← 折线图 + │ └── AccordionItem #2 (应用数据) + │ ├── AccordionButton + │ │ └── MySelect (时间粒度) + │ └── AccordionPanel + │ └── Grid (2列) + │ ├── BarChartComponent (好评/差评) ← 堆叠柱状图 + │ └── LineChartComponent (平均响应时长) ← 折线图 +``` + +## 组件清单 + +### 本模块组件 + +| 序号 | 组件名称 | 文件路径 | 类型 | 职责描述 | +|------|---------|---------|------|---------| +| C01 | `DataDashboard` | `pageComponents/chat/ChatSetting/DataDashboard.tsx:33` | 页面组件 | 数据看板入口组件,获取 appId、挂载 LogsContextProvider、组合 Header + FilterBar + LogChart | +| C02 | `LogsFilterBar` | `pageComponents/chat/ChatSetting/DataDashboard.tsx:12` | 内部组件 | 数据筛选栏,从 LogsContext 读取筛选状态并传递给 HeaderControl | + +### 核心依赖组件(来自其他模块) + +| 序号 | 组件名称 | 文件路径 | 类型 | 职责描述 | +|------|---------|---------|------|---------| +| C03 | `LogsContextProvider` | `pageComponents/app/detail/Logs/context.tsx:45` | Context Provider | 提供日志筛选上下文:dateRange、chatSources、logKeys、chatSearch 等 | +| C04 | `LogChart` | `pageComponents/app/detail/Logs/LogChart.tsx:134` | 图表组件 | 数据图表主组件,含 3 个 Accordion 面板,调用 API 获取数据并渲染多种图表 | +| C05 | `HeaderControl` | `pageComponents/app/detail/Logs/LogChart.tsx`(命名导出) | 筛选组件 | 日期范围选择 + 渠道来源多选 的组合筛选控件 | + +### 基础 UI 组件(Chakra UI / 公共组件库) + +| 序号 | 组件名称 | 来源 | 用途 | +|------|---------|------|------| +| C06 | `Flex` | `@chakra-ui/react` | 弹性布局容器 | +| C07 | `MyBox` | `@fastgpt/web/components/common/MyBox` | 带加载状态的容器 | +| C08 | `MySelect` | `@fastgpt/web/components/common/MySelect` | 下拉选择器(时间粒度、偏移量) | +| C09 | `Accordion` / `AccordionItem` / `AccordionButton` / `AccordionPanel` / `AccordionIcon` | `@chakra-ui/react` | 手风琴折叠面板 | +| C10 | `Grid` / `Box` | `@chakra-ui/react` | 网格/块级布局 | +| C11 | `LineChartComponent` | `@fastgpt/web/components/common/Chart/LineChart` | 折线图组件 | +| C12 | `BarChartComponent` | `@fastgpt/web/components/common/Chart/BarChart` | 柱状图组件 | +| C13 | `TotalData` | `pageComponents/app/detail/Logs/TotalData` | 汇总数据卡片组件 | + +### Props 类型定义 + +| 序号 | 类型名称 | 文件位置 | 字段 | +|------|---------|---------|------| +| T01 | `Props` (DataDashboard) | `DataDashboard.tsx:8` | `Header: React.FC<{ children?: React.ReactNode }>` | +| T02 | `Props` (LogChart) | `LogChart.tsx:134` | `appId?: string` | + +### Context 使用明细 + +| Context | 消费的字段 | 消费方式 | +|---------|-----------|---------| +| `ChatPageContext` | `chatSettings?.appId` | `useContextSelector(ChatPageContext, (v) => v.chatSettings?.appId \|\| '')` | +| `LogsContext` | `dateRange` | `useContextSelector(LogsContext, ...)` | +| `LogsContext` | `setDateRange` | `useContextSelector(LogsContext, ...)` | +| `LogsContext` | `chatSources` | `useContextSelector(LogsContext, ...)` | +| `LogsContext` | `setChatSources` | `useContextSelector(LogsContext, ...)` | +| `LogsContext` | `isSelectAllSource` | `useContextSelector(LogsContext, ...)` | +| `LogsContext` | `setIsSelectAllSource` | `useContextSelector(LogsContext, ...)` | + +### 关键常量 + +| 常量名称 | 来源 | 值/说明 | +|---------|------|---------| +| `AppLogTimespanEnum` | `@fastgpt/global/core/app/logs/constants` | `day \| week \| month \| quarter` | +| `offsetOptions` | `@fastgpt/global/core/app/logs/constants` | 留存用户偏移量选项列表 `[{label:'不偏移',value:'0'}, ...]` | +| `ChatSourceEnum` | `@fastgpt/global/core/chat/constants` | 对话来源渠道枚举 | +| `ChatSourceMap` | `@fastgpt/global/core/chat/constants` | 渠道来源 → name/color 的映射表 | +| `DefaultAppLogKeys` | `@fastgpt/global/core/app/logs/constants` | 默认日志字段配置 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a68ccff04320 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,86 @@ +--- +capability_label: 日志详情 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +# 日志详情 — 业务流程索引 + +## 业务场景列表 + +### 查看日志列表 + +- **业务描述**: 进入日志详情页面后,自动加载指定时间范围内的对话日志列表,以分页表格形式展示 +- **触发入口**: 对话首页 → 设置面板 → 点击「日志」Tab +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 用户具有团队管理权限(`hasManagePer`)且为商业版(`isPlus`) + +### 多维度筛选日志 + +- **业务描述**: 通过日期范围选择器、对话来源多选、用户筛选、关键词搜索等条件组合筛选日志数据 +- **触发入口**: 日志详情页面上方的筛选栏 +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx`(LogsFilterBar) +- **涉及角色**: 管理员 +- **前置条件**: 已进入日志详情页面 + +### 配置日志列显示 + +- **业务描述**: 通过拖拽调整日志表格中列的显示/隐藏和排列顺序,支持将列配置同步到团队 +- **触发入口**: 日志详情页面筛选栏右侧的列配置按钮 +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/app/detail/Logs/LogKeysConfigPopover.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入日志详情页面,有团队日志 Key 配置 + +### 查看单条对话详情 + +- **业务描述**: 点击日志记录,在弹窗中查看该对话的完整内容(用户消息与 AI 回复),支持查看和提交纠错记录 +- **触发入口**: 日志列表中点击某条记录 +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx`(DetailLogsModal) +- **涉及角色**: 管理员 +- **前置条件**: 日志列表已加载,有可点击的记录 + +### 导出日志数据 + +- **业务描述**: 将当前筛选条件下的日志数据导出为 CSV 文件下载 +- **触发入口**: 日志列表右上角的导出按钮 +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 日志列表已加载 + +### 批量删除对话 + +- **业务描述**: 勾选多条日志记录后批量删除对应的对话历史 +- **触发入口**: 日志列表中的复选框 → 批量删除按钮 +- **路由路径**: `/chat?pane=s&tab=l` +- **页面组件位置**: `projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 日志列表已加载,有可勾选的记录 + +## 场景关系 + +- **查看日志列表** → **多维度筛选日志**:筛选条件变更后自动触发日志列表刷新 +- **查看日志列表** → **查看单条对话详情**:列表是详情弹窗的入口 +- **查看日志列表** → **批量删除对话**:需先在列表中勾选记录 +- **配置日志列显示** → **查看日志列表**:列配置变更直接影响列表的列展示 +- 导出日志数据、查看单条对话详情、批量删除对话 为独立操作,无先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看日志列表 | 设置面板 → 日志 Tab | `/chat?pane=s&tab=l` | 管理员 | +| S02 | 多维度筛选日志 | 日志详情页 → 筛选栏 | `/chat?pane=s&tab=l` | 管理员 | +| S03 | 配置日志列显示 | 日志详情页 → 列配置按钮 | `/chat?pane=s&tab=l` | 管理员 | +| S04 | 查看单条对话详情 | 日志列表 → 点击记录 | `/chat?pane=s&tab=l` | 管理员 | +| S05 | 导出日志数据 | 日志列表 → 导出按钮 | `/chat?pane=s&tab=l` | 管理员 | +| S06 | 批量删除对话 | 日志列表 → 勾选 → 删除 | `/chat?pane=s&tab=l` | 管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..005fb8977a0d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,320 @@ +--- +capability_label: 日志详情 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +# 日志详情 — 业务流程详解 + +> 本模块为 ChatSetting 设置面板的日志 Tab,无子 Tab。以下按场景逐一追踪完整交互流程。 + +## 页面总览 + +日志详情页面由 Header 区域(由父组件 ChatSetting 传入)、筛选栏(LogsFilterBar)和日志表格(LogTable)三部分组成。页面通过 ChatPageContext 获取当前应用的 appId,然后以该 appId 初始化 LogsContextProvider,筛选栏和日志表格通过该 Context 共享日志查询状态。 + +--- + +### S01:查看日志列表 + +> 进入日志详情页面后,自动加载最近 N 天的对话日志列表。 + +#### 步骤 1:进入日志详情页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在对话首页点击设置按钮(s),再点击日志 Tab(l) | 无(仅路由切换) | `pane=s` 时 ChatSetting 组件渲染;`tab=l` 时 LogDetails 组件渲染 | 侧边栏切换为设置面板,主区域显示日志详情组件 | + +ChatSetting 使用 Next.js `dynamic` 动态导入 LogDetails 组件,仅在切换到日志 Tab 时加载。 + +#### 步骤 2:初始化日志上下文 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需操作) | `GET /proApi/core/chat/setting/detail`(由 ChatPageContext 自动请求) | 无 | LogDetails 从 ChatPageContext 读取 `chatSettings.appId`;若 appId 为空则仅渲染空白区域和 Header | + +#### 步骤 3:加载日志列配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需操作) | `GET /api/core/app/logs/getLogKeys` | appId 有值时自动请求 | 获取团队的日志列配置(logKeys),若无团队配置则使用本地 localStorage 缓存或默认列配置 | + +#### 步骤 4:加载日志列表数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需操作) | `POST /api/core/app/logs/list` | 自动请求,参数为当前日期范围和筛选条件 | 显示加载状态 → 日志数据以分页表格形式渲染,展示总条数 | + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /api/core/app/logs/list | `appId`, `dateStart`, `dateEnd`, `pageNum=1`, `pageSize=50` | 按时间倒序排列 | 表格前 50 条日志 | +| 翻页 | POST /api/core/app/logs/list | `pageNum=N`, 其他筛选条件不变 | 无额外处理 | 表格第 N 页 | +| 筛选刷新 | POST /api/core/app/logs/list | 更新筛选条件参数,`pageNum=1` | 无额外处理 | 表格数据更新 | + +- **分页参数**:默认每页 50 条 +- **排序规则**:默认按时间倒序 +- **筛选条件**:日期范围(dateStart/dateEnd)、对话来源(sources)、用户(tmbIds/outLinkUids)、搜索关键词(chatSearch)、反馈类型(feedbackFilter/feedbackType)、仅未读(unreadOnly)、错误筛选(errorFilter) + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 日志详情页 + participant API as FastGPT API + + User->>UI: 点击「日志」Tab + UI->>API: GET /proApi/core/chat/setting/detail + API-->>UI: appId, teamId 等设置信息 + UI->>API: GET /api/core/app/logs/getLogKeys + API-->>UI: 日志列配置 + UI->>API: POST /api/core/app/logs/list + API-->>UI: 日志列表 + 总条数 + UI-->>User: 渲染日志表格 +``` + +--- + +### S02:多维度筛选日志 + +> 通过筛选栏的日期选择器、来源选择器、搜索框、用户筛选等控件组合筛选日志。 + +#### 步骤 1:修改日期范围 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击日期范围选择器 → 选择起止日期 → 确认 | `POST /api/core/app/logs/list`(参数更新为新的 dateStart/dateEnd) | 日期变更后自动触发列表刷新 | 加载状态 → 表格更新为新的时间范围内的日志 | + +#### 步骤 2:选择对话来源 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击来源下拉框 → 勾选/取消勾选来源类型(如 Web、API、分享链接等)→ 确认 | `POST /api/core/app/logs/list`(参数更新为新的 sources 数组) | 全选时 sources 为空数组;取消全选时可单选或多选 | 加载状态 → 表格仅显示选中来源的日志。全选按钮联动更新状态 | + +##### 分支条件 + +| 条件 | 行为 | +|------|------| +| 未选择任何来源(sources 为空) | 等同于全选,显示所有来源的日志 | +| 选择部分来源 | 仅显示选中来源的日志 | +| 全选开关为开 | sources 为空,显示全部来源 | +| 全选开关为关 | sources 为已选来源列表 | + +#### 步骤 3:关键词搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词(如用户问题文本) | `POST /api/core/app/logs/list`(参数更新为 chatSearch) | 搜索词变更后自动触发列表刷新 | 加载状态 → 表格更新为匹配搜索词的日志 | + +#### 步骤 4:用户筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击用户筛选下拉 → 搜索用户 → 选择目标用户 | `POST /api/core/app/logs/getUsers`(搜索用户时)→ `POST /api/core/app/logs/list`(选择用户后) | 搜索框输入时实时查询用户列表 | 搜索结果加载状态 → 勾选用户后日志列表刷新 | + +##### 数据加载详情(用户搜索) + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 搜索用户 | POST /api/core/app/logs/getUsers | `appId`, `dateStart`, `dateEnd`, `searchKey` | 按头像+名称+记录数展示 | 用户下拉列表 | +| 选择用户后 | POST /api/core/app/logs/list | `tmbIds`/`outLinkUids` 参数追加 | 无额外处理 | 筛选后的日志 | + +#### 步骤 5:错误筛选和反馈筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击错误筛选下拉 → 选择筛选条件(如仅显示有错误的记录) | `POST /api/core/app/logs/list`(参数更新为 errorFilter) | 筛选条件不为空时过滤 | 加载状态 → 表格显示匹配错误条件的日志 | +| 点击反馈筛选下拉 → 选择反馈类型(好评/差评/未反馈) | `POST /api/core/app/logs/list`(参数更新为 feedbackFilter/feedbackType) | 选择具体类型后过滤 | 加载状态 → 表格显示匹配反馈类型的日志 | + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 筛选栏 + participant API as FastGPT API + + User->>UI: 修改日期/来源/搜索词/用户筛选 + UI->>API: POST /api/core/app/logs/list(更新参数) + par 用户筛选搜索 + UI->>API: POST /api/core/app/logs/getUsers + API-->>UI: 用户列表 + end + API-->>UI: 筛选后的日志列表 + UI-->>User: 表格数据刷新 +``` + +--- + +### S03:配置日志列显示 + +> 通过拖拽调整日志表格的列配置。 + +#### 步骤 1:打开列配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击筛选栏右侧的列配置图标按钮 | 无(纯前端操作) | 无 | 弹出 Popover,展示当前可见的列列表(可拖拽调整顺序、可切换显示/隐藏) | + +#### 步骤 2:调整列配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 拖拽列项调整顺序;点击眼睛图标切换可见/隐藏 | 无(本地状态更新 + localStorage 持久化) | 无 | Popover 内列表实时更新;关闭 Popover 后日志表格列配置即时生效 | + +列配置通过 `localStorage`(key: `app_log_keys_${appId}`)持久化到本地。若有团队级别的 logKeys 配置,优先使用团队配置。 + +#### 步骤 3:同步列配置到团队 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「同步」按钮 → 确认同步 | `POST /api/core/app/logs/updateLogKeys` | 仅在管理员拥有团队管理权限时可操作 | 弹窗显示同步确认提示 → 确认后显示成功提示,团队其他成员下次打开时自动应用该列配置 | + +##### 表单与交互详情 + +同步弹窗提示内容:告知管理员同步后团队所有成员将看到相同的列配置。 + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 列配置弹窗 + participant API as FastGPT API + participant LS as localStorage + + User->>UI: 点击列配置按钮 + UI->>LS: 读取 app_log_keys_${appId} + LS-->>UI: 本地列配置 + User->>UI: 拖拽/切换列 + UI->>LS: 写入列配置 + UI-->>User: 表格列即时更新 + User->>UI: 点击同步按钮 + UI->>API: POST /api/core/app/logs/updateLogKeys + API-->>UI: 同步成功 + UI-->>User: 显示成功提示 +``` + +--- + +### S04:查看单条对话详情 + +> 在日志列表中点击某条记录,弹出对话详情弹窗。 + +#### 步骤 1:打开对话详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在日志表格中点击某条记录行 | `GET /api/core/chat/init`(获取对话初始信息) | 无 | 弹出 DetailLogsModal 弹窗,显示加载状态 | + +#### 步骤 2:加载对话记录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹窗自动加载(无需操作) | `POST /api/core/chat/record/getRecords_v2` | 自动请求 | 加载状态 → 渲染对话内容(用户消息与 AI 回复的完整交互记录) | + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 获取对话信息 | GET /api/core/chat/init | `appId`, `chatId`, `loadCustomFeedbacks` | 初始化 ChatRecordContext | 对话上下文 | +| 获取对话记录 | POST /api/core/chat/record/getRecords_v2 | `chatId`, `appId`, `type`, `pageSize` | 按时间顺序渲染 | 对话消息列表 | + +#### 步骤 3:查看和提交纠错记录(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击纠错相关按钮 | `POST /api/core/chat/correction/list`(查看纠错列表)/ `POST /api/core/chat/correction/submit`(提交纠错) | 纠错功能可用 | 弹窗内展示纠错记录列表或提交表单 | + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 日志表格 + participant API as FastGPT API + + User->>UI: 点击日志记录 + UI->>API: GET /api/core/chat/init + API-->>UI: 对话初始信息 + UI->>API: POST /api/core/chat/record/getRecords_v2 + API-->>UI: 对话记录列表 + UI-->>User: 弹窗渲染对话详情 +``` + +--- + +### S05:导出日志数据 + +> 将当前筛选条件下的日志数据导出为 CSV 文件。 + +#### 步骤 1:触发导出 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击日志表格右上角的「导出」按钮 | `POST /api/core/app/logs/exportLogs` | 无 | 按钮显示加载状态 → 浏览器触发文件下载(CSV) | + +##### 导出参数 + +导出请求携带当前筛选栏的全部条件参数(日期范围、来源、用户筛选、搜索关键词、可见列配置等),确保导出的数据与页面展示一致。 + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 日志表格 + participant API as FastGPT API + + User->>UI: 点击导出按钮 + UI->>API: POST /api/core/app/logs/exportLogs + API-->>UI: CSV 文件流 + UI-->>User: 浏览器触发文件下载 +``` + +--- + +### S06:批量删除对话 + +> 勾选多条日志后批量删除对应的对话历史。 + +#### 步骤 1:勾选日志记录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击日志表格每行首列的复选框 | 无 | 单条选择模式 | 复选框变为勾选状态,批量操作工具栏出现(显示已选数量) | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击批量删除按钮 → 在确认弹窗中确认 | `POST /api/core/chat/history/batchDelete`(参数:`appId` + `chatIds`) | 无已选记录时删除按钮不可用 | 确认弹窗关闭 → 加载状态 → 删除成功提示 → 日志列表刷新 | + +##### 删除链路详情 + +- **引用检查**:批量删除直接调用后端接口,后端校验权限和数据归属 +- **确认弹窗**:弹窗标题提示「确认删除」,内容显示将删除的对话数量,确认/取消按钮 +- **批量与单条差异**:批量删除一次性提交所有勾选的 chatIds;单条删除同理但仅发一条记录 +- **级联影响**:删除对话历史后,关联的对话记录、纠错记录同步清除 + +###### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 日志表格 + participant API as FastGPT API + + User->>UI: 勾选日志记录 + User->>UI: 点击批量删除 → 确认 + UI->>API: POST /api/core/chat/history/batchDelete + API-->>UI: 删除成功 + UI->>API: POST /api/core/app/logs/list(刷新) + API-->>UI: 删除后的日志列表 + UI-->>User: 列表刷新,显示成功提示 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..dc10d9cdcc7f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: 日志详情 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +## 说明 + +本模块未检测到对外共享能力(imported_by 模块数: 0,共享能力数: 0)。 + +日志详情是 ChatSetting 设置面板的叶子 Tab 节点,其主组件 LogDetails 仅被父模块 ChatSetting 通过 `dynamic` 动态导入使用,不对外暴露任何页面、组件、Hook 或 API。 + +跨模块依赖摘要请参见 [08-模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..dd2670200c15 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: 日志详情 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +# 日志详情 — 模块概览 + +## 1. 模块业务概述 + +日志详情模块是对话首页「设置」面板下的一个功能 Tab,为**管理员**提供对话应用的完整日志数据管理能力。模块的核心业务价值在于: + +- **日志查看与筛选**:按时间范围、对话来源、用户、搜索关键词等多维度筛选对话日志 +- **日志列配置**:支持拖拽调整日志列的显示/隐藏顺序,并可将列配置同步到团队 +- **日志导出**:将筛选后的日志数据导出为 CSV 文件 +- **日志操作**:支持查看单条对话详情、批量删除对话记录 + +本模块在整体产品中定位为**应用运营分析工具**,帮助管理员追踪对话质量、排查用户问题、优化应用表现。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 应用设置 → 日志详情 Tab | 查看日志列表、多维度筛选、导出日志、查看对话详情、批量删除日志、配置日志列 | 需 `hasManagePer` 权限且为商业版(`isPlus`) | +| 普通成员 | — | — | 无管理权限时自动重定向至团队应用列表,不可见本模块 | + +**权限模型**:基于团队权限位 `userInfo.team.permission.hasManagePer` + 商业版许可 `feConfigs.isPlus` 的双重校验,在 ChatSetting 入口处统一拦截。不满足条件的用户无法进入设置面板。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话设置(ChatSetting) | ChatPageContext 上下文(appId、权限校验结果) | Context | +| 应用日志(app/detail/Logs) | LogTable 日志表格组件、HeaderControl 筛选控件、LogKeysConfigPopover 列配置组件、LogsContext 日志状态上下文 | 组件/Context | +| 对话 API(web/core/chat/api) | getChatSetting 获取应用设置(含 appId) | API | +| 应用日志 API(web/core/app/api/log) | getAppChatLogs、getLogKeys、exportLogs 等日志相关 API | API | + +完整跨模块依赖列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +本模块为 ChatSetting 的叶子 Tab 节点,本身不对外暴露共享能力。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 日志详情 | `/chat?pane=s&tab=l` | `projects/app/src/pageComponents/chat/ChatSetting/LogDetails.tsx` | 管理员 | ChatSetting 设置面板的日志 Tab,无子 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..1e13d39371d8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,72 @@ +--- +capability_label: "设置" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员", "普通成员"] +router_paths: ["/chat"] +--- + +# 设置 — 模块概览 + +## 1. 模块业务概述 + +「设置」是对话首页的管理配置模块,面向**团队管理员**提供对话首页的定制化配置能力。管理员可以在此模块中配置首页的启用状态、展示内容(快捷应用、可用工具、标语)、查看首页数据看板和对话日志,以及管理精选应用列表。 + +本模块包含 4 个功能 Tab: + +- **首页配置**:控制首页是否启用,配置快捷应用入口、可用的 AI 工具集和首页标语 +- **首页数据**:以图表形式展示首页对话的数据统计(调用次数、用户数等) +- **首页日志**:展示首页对话的详细日志记录,支持筛选查询 +- **精选应用**:管理首页「精选应用」列表,支持搜索、排序、分类标签和删除 + +**权限限制**:非商业版(`isPlus` 为 `false`)或无管理权限(`hasManagePer` 为 `false`)的用户无法访问本模块,将被自动重定向到团队应用列表。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 全部 4 个 Tab | 全部配置操作 | 可修改首页配置、管理精选应用、查看数据和日志 | +| 普通成员 | 无法访问 | 无 | 页面加载时自动重定向至团队应用列表 | + +**权限模型**: 基于团队权限位 `userInfo.team.permission.hasManagePer` 的简单权限判断。入口组件在 `useMount` 时检查权限,不满足则调用 `handlePaneChange` 跳转至 `TEAM_APPS`。商业版检查通过 `feConfigs.isPlus` 标记。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话上下文 | `ChatPageContext` — 提供 chatSettings、handlePaneChange、refreshChatSetting | Context | +| 对话侧边栏 | `ChatContext` — 提供 onOpenSlider(移动端侧边栏控制) | Context | +| 对话侧边栏 | `ChatSliderMobileDrawer` — 移动端侧边抽屉组件 | 组件 | +| 全局系统 | `useSystemStore` — 获取 feConfigs 系统配置 | Store | +| 用户模块 | `useUserStore` — 获取 userInfo 用户信息 | Store | +| 通用组件 | `MyIcon`、`NextHead` — 图标和页面标题组件 | 组件 | +| 日志模块 | `LogsContextProvider`、`LogsFilterBar`、`LogChart`、`LogTable` — 日志相关组件 | 组件 | + +## 4. 对外共享 + +本模块的对外共享内容有限,主要提供以下: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `AppTree` | 内部使用 | 组件 | +| `ToolSelectModal` | 内部使用 | 组件 | +| `ImageUpload` | 内部使用 | 组件 | + +> 本模块组件主要为内部使用,无跨模块共享。`TagManageModal` 组件(位于 `FavouriteAppSetting/` 下)被数据集模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话首页设置 | `/chat?pane=s` | `pageComponents/chat/ChatSetting/index.tsx` | 管理员 | 含 4 个 Tab(详见 [业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 首页配置 | HOME | [模块概览](../首页配置/模块概览.md) | +| 首页数据 | DATA_DASHBOARD | [模块概览](../数据看板/模块概览.md) | +| 首页日志 | LOG_DETAILS | [模块概览](../日志详情/模块概览.md) | +| 精选应用 | FAVOURITE_APPS | [模块概览](../收藏应用/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..15f7702018e4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,64 @@ +--- +capability_label: 首页设置 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:00:00.000Z" +parent_module: 设置 +roles: [团队管理员] +router_paths: [] +--- + +# 首页设置 — 业务流程索引 + +## 业务场景列表 + +### 场景:配置首页展示开关 + +- **业务描述**: 管理员控制对话首页的启用与关闭。关闭首页后,用户进入对话时将跳过首页直接进入对话界面。 +- **触发入口**: 对话首页 → 设置按钮 → HOME Tab →「启用首页」开关 +- **路由路径**: `/chat?pane=s&tab=h` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 用户已登录且具有团队管理权限(`team.permission.hasManagePer`);已加载 chatSettings 数据 + +### 场景:管理快捷应用列表 + +- **业务描述**: 管理员从团队应用中挑选最多 4 个应用作为对话首页的快捷入口,供终端用户快速访问。支持通过拖拽调整快捷应用的排列顺序。 +- **触发入口**: 对话首页 → 设置按钮 → HOME Tab →「快捷应用」区域的设置图标按钮 +- **路由路径**: `/chat?pane=s&tab=h` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` + `AddQuickAppModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 用户已登录且具有团队管理权限;已有团队应用可供选择 + +### 场景:管理可用工具列表 + +- **业务描述**: 管理员选择对话首页上对用户可见的插件工具。选中的工具将显示在对话首页供终端用户使用,支持添加和移除操作。 +- **触发入口**: 对话首页 → 设置按钮 → HOME Tab →「可用工具」区域的添加按钮 +- **路由路径**: `/chat?pane=s&tab=h` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` + `ToolSelectModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 用户已登录且具有团队管理权限;系统中有可用的工具模板 + +### 场景:修改欢迎标语 + +- **业务描述**: 管理员自定义对话首页的欢迎标语(Slogan),该标语将显示在对话首页的显著位置。 +- **触发入口**: 对话首页 → 设置按钮 → HOME Tab →「标语」输入框 +- **路由路径**: `/chat?pane=s&tab=h` +- **页面组件位置**: `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 用户已登录且具有团队管理权限 + +## 场景关系 + +- **配置首页展示开关** → **管理快捷应用列表**: 快捷应用仅在首页启用时有意义,关闭首页后快捷应用入口不会展示给终端用户 +- **配置首页展示开关** → **管理可用工具列表**: 可用工具仅在首页启用时对终端用户可见 +- 其他场景之间独立执行,无严格先后关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 配置首页展示开关 | 设置 → HOME Tab → 启用首页开关 | `/chat?pane=s&tab=h` | 团队管理员 | +| S02 | 管理快捷应用列表 | 设置 → HOME Tab → 快捷应用区域设置按钮 | `/chat?pane=s&tab=h` | 团队管理员 | +| S03 | 管理可用工具列表 | 设置 → HOME Tab → 可用工具区域添加按钮 | `/chat?pane=s&tab=h` | 团队管理员 | +| S04 | 修改欢迎标语 | 设置 → HOME Tab → 标语输入框 | `/chat?pane=s&tab=h` | 团队管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..8e0e9b3d8995 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,239 @@ +--- +capability_label: 首页设置 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:00:00.000Z" +parent_module: 设置 +roles: [团队管理员] +router_paths: [] +--- + +# 首页设置 — 业务流程详解 + +## 页面总览 + +首页设置模块位于对话首页设置面板的 HOME Tab 内,为团队管理员提供对话首页的个性化配置能力。页面采用垂直排列的表单布局,包含四个设置区域:启用首页开关、快捷应用管理、可用工具管理和欢迎标语输入。所有修改通过顶部的保存按钮统一提交。 + +## 场景 S01:配置首页展示开关 + +> 管理员通过 Switch 开关控制对话首页的启用状态。此操作实时反映在表单状态中,需点击保存按钮才会持久化。 + +### 步骤 1:切换首页启用状态 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入 HOME Tab 设置页,查看「启用首页」开关当前状态 | GET `/proApi/core/chat/setting/detail`(父级 ChatPageContext 初始化时自动调用) | 无 | 页面加载时显示开关当前状态(开/关),开关从 `chatSettings.enableHome` 取值 | +| 点击 Switch 开关切换启用状态 | 无(仅更新本地表单状态) | 无 | 开关立即切换显示状态(开↔关),react-hook-form 的 `register('enableHome')` 更新表单值 | + +### 步骤 2:保存设置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部的「保存」按钮 | POST `/proApi/core/chat/setting/update` | 无 | 保存按钮显示 loading 状态(`isLoading={isSaving}`),保存成功后弹出成功提示(文案为 i18n key `chat:setting.save_success`),同时触发 `refreshChatSetting()` 刷新上下文中的设置数据 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as HomepageSetting + participant API as /proApi/core/chat/setting + + User->>UI: 查看首页开关当前状态 + UI-->>User: 显示开关状态(来自 chatSettings.enableHome) + User->>UI: 点击 Switch 切换开关 + UI-->>User: 开关状态立即切换 + User->>UI: 点击保存按钮 + UI->>UI: 按钮显示 loading 状态 + UI->>API: POST /update { enableHome, ...其他配置 } + API-->>UI: 保存成功 + UI->>API: GET /detail(refreshChatSetting 触发) + API-->>UI: 返回最新设置数据 + UI-->>User: 显示成功提示 Toast +``` + +--- + +## 场景 S02:管理快捷应用列表 + +> 管理员通过弹窗从团队应用中选择最多 4 个应用作为对话首页的快捷入口,支持拖拽排序。已选应用以标签形式展示在设置区域,可随时打开弹窗重新配置。 + +### 步骤 1:打开展示快捷应用选择弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「快捷应用」区域的设置图标按钮 | 无 | 无 | `isOpenAddQuickApp` 状态变为 true,显示 AddQuickAppModal 弹窗 | +| 弹窗打开,自动加载应用列表 | GET 团队应用列表(`getMyApps`,根目录) + GET 目录路径(`getAppFolderPath`,根目录) | 并行请求 | 弹窗左侧显示应用列表(含文件夹和应用),弹窗显示 loading 状态直至数据加载完成 | + +### 步骤 2:浏览和搜索应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | GET 团队应用列表(`getMyApps`,带 searchKey 参数) | 输入内容非空时:隐藏目录路径面包屑,仅显示搜索结果 | 应用列表根据搜索关键词过滤更新,有 500ms 节流 | +| 点击文件夹进入子目录 | GET 团队应用列表(`getMyApps`,parentId 指向文件夹) + GET 目录路径(`getAppFolderPath`,current 类型) | 仅当未在搜索状态时显示路径面包屑 | 应用列表更新为子目录内容,面包屑导航显示当前路径 | +| 查看应用详情提示 | 无 | 鼠标悬停时显示 | 显示 Tooltip 包含应用名称、作者、简介和费用信息 | + +### 步骤 3:选择/取消选择应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选应用复选框 | 无(立即更新本地状态) | 已选数量已满 4 个时:不执行添加操作 | 该应用添加到右侧「已选列表」中,应用头像和名称显示在已选区域 | +| 在已选列表中点击关闭图标移除应用 | 无 | 无 | 该应用从已选列表中移除,左侧列表中对应复选框取消勾选 | +| 拖拽已选应用调整顺序 | 无 | 无 | 拖拽过程中显示拖拽占位,释放后列表顺序更新 | + +### 步骤 4:确认选择 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗底部「确认」按钮 | 无(仅通过 `onConfirm` 回调更新表单) | 无 | 弹窗关闭,快捷应用区域刷新显示已选应用列表(含头像和名称) | +| 点击「取消」按钮 | 无 | 无 | 弹窗关闭,不保存任何修改 | + +### 步骤 5:保存设置到后端 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部的「保存」按钮 | POST `/proApi/core/chat/setting/update`(`quickAppIds` 字段为已选应用的 ID 数组) | 无 | 保存按钮 loading 状态,成功后弹出成功提示并刷新设置数据 | + +### 补充:缺失应用信息回填 + +当选中的应用不在当前列表页数据中时(如通过搜索关键词找到并选中后切换目录),系统自动调用 `getAppBasicInfoByIds` 接口获取缺失应用的基本信息(名称、头像),确保已选列表中的每个应用都有正确的展示信息。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as AddQuickAppModal + participant API as /proApi + + User->>UI: 点击快捷应用设置按钮 + UI->>API: GET 应用列表 + GET 目录路径(并行) + API-->>UI: 返回应用和目录数据 + UI-->>User: 显示应用选择弹窗 + User->>UI: 浏览/搜索应用,勾选最多 4 个 + UI-->>UI: 更新已选列表(本地状态) + User->>UI: 拖拽调整顺序 + UI-->>UI: 重排已选列表 + User->>UI: 点击确认按钮 + UI-->>UI: 弹窗关闭,HomepageSetting 表单更新 quickAppList + User->>UI: 点击保存按钮 + UI->>API: POST /update { quickAppIds: [...] } + API-->>UI: 保存成功 + UI-->>User: 成功提示 Toast +``` + +--- + +## 场景 S03:管理可用工具列表 + +> 管理员从系统工具模板库中选择工具添加到对话首页。选中的工具以卡片形式展示在设置区域,终端用户可在对话首页使用这些工具。 + +### 步骤 1:打开工具选择弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「可用工具」区域的添加按钮(空状态时点击虚线框,已有工具时点击添加按钮) | 无 | 无 | `toolSelectModalOpen` 状态变为 true,显示 ToolSelectModal 弹窗 | +| 弹窗自动加载工具模板 | GET 工具模板列表(`getAppToolTemplates`) + GET 工具标签列表(`getPluginToolTags`,并行) | 无 | 弹窗显示工具模板卡片列表,loading 状态 | + +### 步骤 2:浏览和筛选工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | GET 工具模板列表(带 searchKey 参数) | 输入内容非空时:300ms 节流后重新请求 | 工具列表按关键词过滤更新 | +| 点击标签筛选器中的标签 | 无(前端过滤) | 选中标签后:在已加载的模板中按标签过滤 | 工具列表仅显示包含已选标签的工具 | +| 点击文件夹/工具集进入子目录 | GET 工具模板列表(带 parentId) + GET 目录路径 | 非搜索状态下才显示面包屑 | 工具列表更新,面包屑导航显示路径 | + +### 步骤 3:添加工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具的「添加」按钮 | GET 工具预览节点数据(`getToolPreviewNode`) | 工具含有不支持的输入类型(引用类型且无工具描述、数据集选择、动态外部数据等)时:弹出警告 Toast,不执行添加 | 添加按钮显示 loading 状态 | +| 工具校验通过 | 无(如果工具有表单输入类型则继续步骤 4) | 工具含有表单输入类型(input/textarea/numberInput/switch/select/JSONEditor):弹出 ConfigToolModal 配置弹窗 | ConfigToolModal 打开,显示工具输入参数配置界面 | +| 工具校验通过且无表单输入 | 无 | `onAddTool` 回调执行 | 工具添加到 selectedTools 列表,卡片出现在可用工具区域 | + +### 步骤 4:配置工具参数(条件触发) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 ConfigToolModal 中配置工具输入参数 | 无(本地编辑) | 文件上传类型输入:自动设置值为工作流起始节点的用户文件引用 | 配置弹窗显示工具的所有可配置输入参数 | +| 点击确认完成配置 | 无 | 无 | `onAddTool` 回调执行,工具添加到列表,弹窗关闭 | + +### 步骤 5:移除工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停在工具卡片上,点击出现的删除图标 | 无(本地状态更新) | 无 | 该工具从 selectedTools 列表中移除,卡片消失 | +| 在工具选择弹窗中已选工具的旁边点击「移除」按钮 | 无 | 无 | 工具从列表中移除,弹窗内该工具按钮恢复为「添加」状态 | + +### 步骤 6:保存设置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部的「保存」按钮 | POST `/proApi/core/chat/setting/update`(`selectedTools` 字段含 pluginId 和 inputs) | 无 | 保存按钮 loading 状态,成功后弹出成功提示 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as ToolSelectModal + participant API as /api + + User->>UI: 点击可用工具添加按钮 + UI->>API: GET 工具模板列表 + GET 工具标签(并行) + API-->>UI: 返回工具模板和标签数据 + UI-->>User: 显示工具选择弹窗 + User->>UI: 搜索/筛选工具 + User->>UI: 点击添加按钮 + UI->>API: GET 工具预览节点 + API-->>UI: 返回工具详细配置 + alt 工具不支持 + UI-->>User: 警告 Toast:"该工具不适配简易应用" + else 需要参数配置 + UI-->>User: 打开 ConfigToolModal 配置弹窗 + User->>UI: 配置参数并确认 + else 无需配置 + UI-->>UI: 直接添加到列表 + end + UI-->>User: 工具卡片出现在可用工具区域 + User->>UI: 点击保存按钮 + UI->>API: POST /update { selectedTools: [...] } + API-->>UI: 保存成功 + UI-->>User: 成功提示 Toast +``` + +--- + +## 场景 S04:修改欢迎标语 + +> 管理员修改对话首页显示的欢迎标语文案,支持自定义文本输入,有默认文案兜底。 + +### 步骤 1:编辑标语 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入页面查看当前标语 | GET `/proApi/core/chat/setting/detail`(父级自动加载) | `slogan` 字段为空时:显示默认标语(i18n key: `chat:setting.home.slogan.default`) | 输入框中显示当前标语文本 | +| 点击输入框,输入新的标语文本 | 无 | 无 | 输入框内容实时更新,`register('slogan')` 绑定表单值 | + +### 步骤 2:保存标语 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部的「保存」按钮 | POST `/proApi/core/chat/setting/update`(`slogan` 字段为输入内容) | `slogan` 字段为必填(`register('slogan', { required: true })`),空值时表单校验不通过 | 保存按钮 loading 状态,成功提示,刷新设置数据 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as HomepageSetting + participant API as /proApi/core/chat/setting + + UI->>User: 显示当前标语或默认标语 + User->>UI: 输入新的标语文字 + UI-->>User: 输入框内容实时更新 + User->>UI: 点击保存按钮 + UI->>API: POST /update { slogan: "新标语" } + API-->>UI: 保存成功 + UI-->>User: 成功提示 Toast +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..cc3c277ae0c7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: 首页设置 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:00:00.000Z" +parent_module: 设置 +roles: [团队管理员] +router_paths: [] +--- + +# 首页设置 — 模块概览 + +## 1. 模块业务概述 + +首页设置模块是对话首页中「设置」面板下的一个子功能,位于 **HOME Tab** 内,负责管理对话首页的对外展示内容。该模块面向**团队管理员**,允许管理员对对话首页进行个性化配置:开启或关闭首页、选择快捷应用入口、配置可选工具列表以及设定欢迎标语。 + +本模块在对话首页设置中承担「首页内容定制」角色,是团队管理员进行对话首页品牌化和功能配置的核心入口。首页设置直接影响终端用户打开对话页面时的默认展示内容和可用工具。 + +本模块无子能力,为叶子节点。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 对话首页 → 设置 → HOME Tab → 首页设置区域 | 启用/关闭首页、管理快捷应用、管理可用工具、修改标语 | 拥有完整的管理权限,可以修改所有首页设置项 | +| 普通团队成员 | 不可见 | 无 | 无团队管理权限的用户无法进入设置面板(由父级 ChatSetting 组件的 `team.permission.hasManagePer` 权限校验控制) | + +**权限模型**: 基于团队权限位的访问控制。父级组件 ChatSetting 在页面加载时通过 `userInfo.team.permission.hasManagePer` 判断当前用户是否具有团队管理权限,无权限用户将被重定向到团队应用面板(`ChatSidebarPaneEnum.TEAM_APPS`),无法访问设置功能。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库(@fastgpt/web) | Avatar 头像组件、MyIcon 图标组件、MyModal 弹窗组件、SearchInput 搜索框组件、EmptyTip 空状态提示、DndDrag 拖拽排序、useRequest 请求 Hook | 组件/Hook | +| 全局类型(@fastgpt/global) | ChatSettingType 对话设置类型、ChatQuickAppType 快捷应用类型、ChatSelectedToolType 工具选择类型 | 类型 | +| 对话 API 层(@/web/core/chat/api) | updateChatSetting 更新对话设置接口 | API | +| 对话上下文(chatPageContext) | chatSettings 对话设置数据、refreshChatSetting 刷新设置方法 | Context | +| 工具选择弹窗(ToolSelectModal) | 应用工具模板列表浏览、工具搜索与添加 | 组件 | +| 应用 API 层 | getMyApps 获取应用列表、getAppBasicInfoByIds 获取应用基本信息、getAppFolderPath 获取目录路径 | API | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| HomepageSetting 组件 | ChatSetting(对话设置主页) | 组件 | +| AddQuickAppModal 组件 | 被动态 import 引用(仅 HomepageSetting 内部使用) | 组件 | + +> 本模块为叶子节点,对外共享项较少。AddQuickAppModal 使用 `next/dynamic` 懒加载,仅在 HomepageSetting 内部消费。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话首页设置 — HOME Tab | `/chat`(pane=s, tab=h) | `projects/app/src/pageComponents/chat/ChatSetting/index.tsx` → `HomepageSetting/index.tsx` | 团队管理员 | 父组件 ChatSetting 通过 Tab 切换渲染,HomepageSetting 仅在 `tab === ChatSettingTabOptionEnum.HOME` 时显示 | + +> 首页设置组件本身不直接对应独立路由,而是作为对话首页设置面板中 HOME Tab 的内容区域嵌入。路由 `/chat` 通过 query 参数 `pane=s&tab=h` 进入设置面板的 HOME Tab。 + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c1ef8bb237c7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,50 @@ +--- +capability_label: 首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — 业务流程索引 + +## 快速应用对话 + +- **业务描述**:用户从侧边栏或快捷应用列表中点击预设的快速应用,一键切换至该应用的对话模式,使用该应用已配置的知识库、工作流和提示词进行 AI 对话。 +- **触发入口**:页面底部快速应用列表点击应用图标,或侧边栏快速应用区域点击 +- **路由路径**:`/chat?pane=HOME&appId={quickAppId}` +- **页面组件位置**:`projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` +- **涉及角色**:Plus版用户 +- **前置条件**:用户已登录且为 Plus 版;管理员已在设置中配置了快速应用列表 + +## 自定义模型与工具对话 + +- **业务描述**:用户在下拉菜单中选择 AI 模型,并可多选工具(插件),以自定义配置直接发起对话。对话会按默认应用表单配置(系统提示词、温度等)组装为工作流后发送。 +- **触发入口**:首页面板默认状态,在输入框左侧的模型选择器和工具选择器中进行配置 +- **路由路径**:`/chat?pane=HOME` +- **页面组件位置**:`projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` +- **涉及角色**:Plus版用户 +- **前置条件**:用户已登录且为 Plus 版;至少有一个可用模型;可选:至少有一个可用工具 + +## 切换应用(快速应用 ↔ 首页默认) + +- **业务描述**:当用户在快速应用对话中再次点击当前应用,或切换到另一个快速应用时,系统进行应用切换。切换回首页默认时恢复模型选择器和工具选择器的显示。 +- **触发入口**:快速应用列表中点击当前活跃应用(取消选中),或点击其他快速应用 +- **路由路径**:`/chat?pane=HOME&appId={appId}` +- **页面组件位置**:`projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` +- **涉及角色**:Plus版用户 +- **前置条件**:当前正在快速应用对话中 + +## 场景关系 + +- **自定义对话** 与 **快速应用对话** 互斥——快速应用激活时隐藏模型和工具选择器,切换回默认状态时显示选择器 +- **切换应用** 是快速应用对话的子操作,不构成独立场景 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 自定义模型与工具对话 | — | 首页面板→输入框区域配置模型和工具→发送消息 | `/chat?pane=HOME` | Plus版用户 | +| S02 | 快速应用对话 | — | 首页面板→侧边栏/底部快速应用列表点击应用→发送消息 | `/chat?pane=HOME&appId={id}` | Plus版用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..2e1df143795f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,185 @@ +--- +capability_label: 首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — 业务流程详解 + +## 页面总览 + +首页面板是对话页面的默认视图,用户在此选择 AI 模型和工具后直接开始对话,或一键切换至预设的快速应用。面板的核心是 `ChatBox` 聊天容器,左侧输入区在非快速应用模式下展示模型选择器和工具多选下拉菜单。 + +> 本模块无 Tab 结构。以下按业务场景详述交互流程。 + +--- + +### S01:自定义模型与工具对话 + +> **业务描述**:用户在首页面板中从下拉菜单自由选择 AI 模型,并可多选工具(插件),以自定义配置发起对话。对话请求通过 `form2AppWorkflow` 将选中模型和工具组装为工作流后发送至后端。 + +#### 步骤 1:页面加载与对话初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入 `/chat` 页面(pane=HOME) | `GET /api/core/chat/init`(appId+chatId) | feConfigs.isPlus 为 false 时,自动跳转到团队应用面板;appId 为空时跳过请求 | 页面显示加载状态 → 请求成功后聊天容器就绪,显示欢迎标语 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 对话初始化 | GET /api/core/chat/init | appId, chatId | 合并默认文件选择配置(根据所选模型设置图片识别能力)、whisper 语音配置 | 聊天容器渲染对话历史,输入框就绪 | + +#### 步骤 2:选择模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型选择器下拉 | — | availableModels 为空时选择器不显示 | 下拉展开,显示可用模型列表(含名称) | +| 点击某个模型 | — | 选择与当前模型不同的模型 | 选择器显示新模型名;若有图片识别能力(vision),文件选择配置中的图片开关自动启用 | +| 首次加载 | — | 默认值来自 localStorage(`chat_home_model`),其次为 `defaultModels.llm.id` | 选择器初始显示已保存的模型 | + +#### 步骤 3:选择工具(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具选择按钮 | — | availableTools 为空时不显示工具按钮 | 下拉菜单展开,列出所有可用工具(含头像和名称) | +| 勾选/取消工具 | — | — | 菜单项复选框切换选中态;按钮显示已选数量;选中 ≥1 个时按钮变高亮(蓝色) | +| 所选工具被管理员移除 | — | selectedToolIds 中的工具不在 availableTools 中 | 自动清除无效工具的选中状态 | + +#### 步骤 4:输入并发送消息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在输入框输入文本 | — | 输入内容保存至 sessionStorage(300ms 防抖) | — | +| 点击发送/按 Enter | `POST /api/proApi/core/chat/chatHome` | 未选择模型时:弹窗提示"No model selected",不发送 | 发送按钮置灰、显示加载态;消息列表出现用户消息气泡和 AI 占位气泡 | + +**API 调用链**(并行+串行): + +1. **并行获取工具节点**:`Promise.all(selectedToolIds.map(id => GET /api/core/app/tool/preview?appId={toolId}))` — 为每个已选工具获取节点模板 +2. **组装工作流**:将模型 ID、工具节点、chatConfig 传入 `form2AppWorkflow` 生成工作流节点和边 +3. **发送对话**:`POST /api/proApi/core/chat/chatHome` — 发送最后一条消息、变量、工作流数据 + +**流式响应处理**: + +| 事件 | 用户可见的变化 | +|------|-------------| +| 收到 `flowNodeStatus` | 顶部显示当前工作流节点名称和运行状态 | +| 收到 `flowNodeResponse` | 记录节点响应数据 | +| 收到 `answer` / `fastAnswer` | AI 回复文本逐字流式显示;含 reasoning 时显示思考过程 | +| 收到 `toolCall` | 显示工具调用卡片(函数名+参数) | +| 收到 `toolResponse` | 更新工具调用卡片的响应内容 | +| 收到 `sandboxStatus` | 显示沙箱部署状态(部署技能/下载包/解压/就绪) | +| 收到 `skillCall` | 显示技能调用卡片(技能名、描述、状态) | + +#### 步骤 5:对话完成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| AI 回复完成 | — | — | 根据首条消息自动生成对话标题;对话加入侧边栏历史列表;最近使用列表刷新 | + +**后置影响**:对话存储在对话历史中;标题根据首条用户消息自动生成。 + +--- + +### S02:快速应用对话 + +> **业务描述**:用户点击预设的快速应用,一键切换到该应用的工作流对话模式。快速应用模式下隐藏模型和工具选择器,对话直接使用应用已配置的工作流。 + +#### 步骤 1:点击快速应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击快速应用图标 | `GET /api/core/chat/init` | 点击的是当前活跃的快速应用:取消选中,切换回首页默认应用;点击的是其他快速应用:切换到新应用 | ChatBox 切换至新 appId 的对话 | + +**分支条件详解**: +- 当前 `appId === 被点击应用 ID` 且当前是快速应用模式 → 取消选中,调用 `onChangeGlobalAppId(homeAppId)` 切换回首页默认应用 +- 否则 → 调用 `onChangeGlobalAppId(被点击应用 ID)` 切换至该应用 + +#### 步骤 2:对话初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 切换后自动触发 | `GET /api/core/chat/init` | — | 加载该应用的对话配置、变量、历史记录 | + +**与自定义模式的差异**: +- 不显示模型选择器和工具选择器(`InputLeftComponent` 返回 `undefined`) +- 不调用 `getToolPreviewNode` 和 `form2AppWorkflow` +- 直接使用应用原有的 `streamFetch` 路径发送消息 + +#### 步骤 3:发送消息(快速应用路径) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入并发送 | `POST /api/core/chat/completions` | appId 为空时拒绝发送 | 流式渲染 AI 回复 | + +**API 调用链**: +- 直接发送:`POST /api/core/chat/completions` — 参数含 `messages`(仅最后一条)、`variables`、`responseChatItemId`、`appId`、`chatId` +- 无并行工具获取步骤,工作流由后端按应用配置执行 + +#### 步骤 4:对话完成 + +同 S01 步骤 5。 + +--- + +## Mermaid 附录 + +### S01:自定义模型与工具对话 + +```mermaid +sequenceDiagram + participant User + participant UI as 首页面板 + participant API + + User->>UI: 进入首页面板 + UI->>API: GET /api/core/chat/init + API-->>UI: 对话初始化数据 + UI-->>User: 显示欢迎标语,输入框就绪 + + User->>UI: 选择模型 + UI-->>User: 模型选择器更新,图片开关按需启用 + + opt 选择工具 + User->>UI: 勾选工具 + UI-->>User: 工具按钮高亮,显示已选数量 + end + + User->>UI: 输入文本并点击发送 + UI-->>User: 按钮置灰,显示加载态 + par 并行获取工具节点 + UI->>API: GET /api/core/app/tool/preview (工具1) + UI->>API: GET /api/core/app/tool/preview (工具2) + end + API-->>UI: 各工具节点模板 + UI->>API: POST /api/proApi/core/chat/chatHome + API-->>UI: 流式响应(节点状态→AI回复文本) + UI-->>User: 逐字显示 AI 回复,展示工具调用卡片 + API-->>UI: 响应完成 + UI-->>User: 对话标题自动生成,历史列表更新 +``` + +### S02:快速应用对话 + +```mermaid +sequenceDiagram + participant User + participant UI as 首页面板 + participant API + + User->>UI: 点击快速应用 + UI->>API: GET /api/core/chat/init + API-->>UI: 该应用的对话配置 + UI-->>User: 隐藏模型/工具选择器,显示该应用对话界面 + + User->>UI: 输入文本并点击发送 + UI->>API: POST /api/core/chat/completions + API-->>UI: 流式响应 + UI-->>User: 逐字显示 AI 回复 + API-->>UI: 响应完成 + UI-->>User: 对话标题自动生成,最近使用列表刷新 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f1c9b95f2771 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,64 @@ +--- +capability_label: 首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["登录用户", "Plus版用户", "管理员"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — 模块概览 + +## 1. 模块业务概述 + +首页是对话页面的默认面板(`ChatSidebarPaneEnum.HOME`),用户在此选择 AI 模型和工具后直接开始对话,或一键切换至预设的快速应用。它是 FastGPT 对话功能最直接的入口——无需先创建应用即可自由组合模型与工具进行 AI 交互。 + +本模块是对话首页的 Tab 子能力之一,当用户在侧边栏切换到"首页"面板时展示。页面提供两种对话模式: + +- **快速应用模式**:用户侧边栏中预设的快速应用,一键切换即可使用该应用已配置的知识库、工作流和提示词进行对话。 +- **自定义模式**:用户从下拉菜单中自由选择 AI 模型,并可多选工具(插件),以自定义配置发起对话。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 登录用户(非 Plus) | 团队应用面板 | 不可见首页 | 非商业版用户无法访问首页面板,自动跳转至团队应用 | +| Plus版用户 | 首页面板 | 选择模型、选择工具、切换快速应用、发起对话 | 可使用全部首页功能 | +| 管理员(Plus版) | 首页面板 + 设置面板 | 同上 + 管理首页配置 | 可通过设置面板管理快速应用列表、首页标语等 | + +**权限模型**:首页面板的可见性由 `feConfigs.isPlus` 开关控制。非 Plus 用户进入 `/chat` 时自动跳转至 `TEAM_APPS` 面板。页面内部无额外的角色级别权限校验——只要能看到首页面板,即可使用全部对话功能。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 对话核心 | ChatBox 聊天容器(对话渲染、流式响应、输入发送) | 组件 | +| 对话核心 | ChatContext / ChatItemContext / ChatPageContext / ChatRecordContext(对话状态、聊天记录) | Context | +| 对话核心 | useChatStore(对话 appId/chatId 管理) | Store | +| 用户系统 | useUserStore(用户信息、头像) | Store | +| 系统配置 | useSystemStore(feConfigs、llmModelList、defaultModels) | Store | +| 对话侧边栏 | ChatHistorySidebar / ChatSliderMobileDrawer(对话历史列表) | 组件 | +| 应用管理 | onChangeGlobalAppId(切换应用) | Hook | +| 应用 API | getToolPreviewNode(获取工具节点预览) | API | +| 对话 API | getInitChatInfo(初始化对话信息) | API | + +本模块为叶子节点,主要消费外部能力,不对外提供大量共享能力。 + +## 4. 对外共享 + +本模块为对话首页的子面板,主要消费其他模块的能力,不对外暴露共享能力。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 无 | — | — | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话首页—首页面板 | `/chat?pane=HOME` | `projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` | Plus版用户 | 默认面板,无 Tab,详见 [09-业务流程索引](./业务流程索引.md) | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a62abade661e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,72 @@ +--- +capability_label: 对话 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: ["登录用户", "匿名用户", "团队成员"] +router_paths: ["/chat", "/chat/share"] +--- + +# 对话 — 模块概览 + +## 1. 模块业务概述 + +对话模块是 FastGPT 的核心交互入口,为用户提供与 AI 应用进行对话的能力。模块聚合了多种对话方式:用户可以在首页自由选择模型和工具进行通用 AI 对话,也可以使用团队创建的 AI 应用进行专项对话,还能将对话以链接形式分享给外部用户。 + +对话首页(`/chat`)是登录用户的主工作区,通过左侧导航栏切换五种功能窗格:首页自由对话(选择模型与工具)、快捷应用对话、团队应用对话、最近使用应用对话以及对话设置。其中首页自由对话和快捷应用是 Plus 版功能。 + +分享对话(`/chat/share`)允许应用发布者通过外链将 AI 对话能力嵌入第三方页面或分享给他人,支持匿名访问、身份认证、对话历史展示等功能配置。 + +### 子能力关系 + +对话模块包含两个子能力:**对话首页**是登录用户的对话主界面,**分享对话**是对外发布对话能力的轻量入口。两者共享核心的 ChatBox 对话容器组件和对话上下文管理体系,但在认证方式、功能配置和 UI 布局上有显著差异。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 登录用户 | 对话首页(/chat) | 自由对话、使用团队应用、选择模型/工具、管理聊天历史、设置快捷应用、查看对话日志 | 拥有完整功能,可访问 Plus 版专属功能 | +| 团队成员 | 对话首页(/chat) | 使用本团队的 AI 应用进行对话 | 通过团队应用列表进入应用对话,受团队应用权限控制 | +| 匿名用户 | 分享对话(/chat/share) | 输入问题获取 AI 回复、查看知识库引用 | 仅限发布者配置的功能范围,无对话历史管理 | +| 认证用户(分享) | 分享对话(/chat/share) | 匿名用户全部操作 + 查看自身对话历史 | 通过 authToken/customUid 标识身份后可使用历史记录 | + +**权限模型**: 基于用户登录状态和应用发布配置的混合权限。对话首页依赖 NextJS 的 `getServerSideProps` 服务端鉴权;分享对话通过发布外链配置(`showRunningStatus`、`showCite` 等字段)控制功能可见性,通过 `allowAnonymous` 决定是否要求认证。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 登录 | 登录弹窗组件 LoginModal、token 登录 | 组件/API | +| 应用 | 应用详情 API(getMyApps)、应用聊天配置 | API/类型 | +| 数据集 | 知识库引用面板(ChatQuoteList/QuoteReader/ReferencePanel) | 组件 | +| 配置 | ChatSetting 中关联的工具配置 | 组件/API | +| 工作流 | 消息流式获取(streamFetch)、工具节点预览 | API | + +> 完整列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ChatBox 对话容器 | 应用详情、日志详情、评测 | 组件 | +| 对话上下文管理(ChatContext/ChatItemContext) | 应用工作台 | Context | +| 对话历史侧边栏(ChatHistorySidebar) | 应用详情 | 组件 | +| 知识库引用面板(QuoteReader/ReferencePanel) | 应用详情 | 组件 | +| ChatHeader 聊天头部 | 分享对话 | 组件 | + +> 完整列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 对话首页 | /chat?appId={id} | `projects/app/src/pages/chat/index.tsx` | 登录用户 | 含 5 个功能窗格(详见 [09-业务流程索引](./业务流程索引.md)) | +| 分享对话 | /chat/share?shareId={id} | `projects/app/src/pages/chat/share.tsx` | 匿名/认证用户 | 支持嵌入模式(iframe),多种显示配置 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 优先级 | 文档链接 | +|--------|------|--------|---------| +| 对话首页 | 对话首页 | 高 | [08-模块概览](./对话首页/模块概览.md) | +| 分享对话 | 分享对话 | 高 | [08-模块概览](./分享对话/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cae9a39adc69 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: MCP 服务器 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员(有 App 创建权限) +router_paths: + - /dashboard/mcpServer +--- + +# MCP 服务器 — 业务流程索引 + +## 业务场景列表 + +MCP 服务器模块是单页面应用,所有操作均在 `/dashboard/mcpServer` 路由下完成,无 Tab 子结构。 + +### 查看 MCP 服务器列表 + +- **业务描述**: 进入页面后查看当前团队已创建的所有 MCP 服务器,每行展示服务器名称、关联应用数量和操作入口。 +- **触发入口**: 左侧导航栏「应用构建」→「MCP 服务器」 +- **路由路径**: /dashboard/mcpServer +- **页面组件位置**: projects/app/src/pages/dashboard/mcpServer/index.tsx +- **涉及角色**: 所有团队成员 +- **前置条件**: 用户已登录 + +### 创建 MCP 服务器 + +- **业务描述**: 创建一个新的 MCP 服务器,配置名称并从已有应用中选择关联的应用工具,可自定义工具名称和描述。 +- **触发入口**: MCP 服务器列表页顶部「创建 MCP 服务器」按钮 +- **路由路径**: /dashboard/mcpServer(弹窗内操作) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx +- **涉及角色**: 有 App 创建权限的团队成员 +- **前置条件**: 用户具有 `hasAppCreatePer` 权限 + +### 编辑 MCP 服务器 + +- **业务描述**: 修改已有 MCP 服务器的名称和关联应用列表,可增删工具并修改工具名称和描述。 +- **触发入口**: MCP 服务器列表每行的「编辑」按钮 +- **路由路径**: /dashboard/mcpServer(弹窗内操作) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx +- **涉及角色**: 有 App 创建权限的团队成员 +- **前置条件**: 用户具有 `hasAppCreatePer` 权限;已存在 MCP 服务器 + +### 删除 MCP 服务器 + +- **业务描述**: 删除指定的 MCP 服务器,删除前弹出确认提示。 +- **触发入口**: MCP 服务器列表每行的「删除」按钮 +- **路由路径**: /dashboard/mcpServer(Popover 确认窗内操作) +- **页面组件位置**: projects/app/src/pages/dashboard/mcpServer/index.tsx +- **涉及角色**: 有 App 创建权限的团队成员 +- **前置条件**: 用户具有 `hasAppCreatePer` 权限;已存在 MCP 服务器 + +### 查看使用方式 + +- **业务描述**: 查看 MCP 服务器的连接端点 URL 和 JSON 配置,可在 Streamable HTTP 和 SSE 两种协议间切换,支持一键复制端点地址和配置。 +- **触发入口**: MCP 服务器列表每行的「开始使用」按钮 +- **路由路径**: /dashboard/mcpServer(弹窗内展示) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/mcp/usageWay.tsx +- **涉及角色**: 所有团队成员 +- **前置条件**: 已存在 MCP 服务器 + +## 场景关系 + +- **创建 MCP 服务器** → **查看使用方式**: 创建成功后通常需要获取端点信息以配置外部客户端 +- **创建 MCP 服务器** → **编辑 MCP 服务器**: 创建后可继续修改应用配置和工具描述 +- **查看 MCP 服务器列表** → 所有其他操作: 列表页是所有操作的入口 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 查看 MCP 服务器列表 | — | 导航栏→MCP 服务器 | /dashboard/mcpServer | 所有团队成员 | +| S02 | 创建 MCP 服务器 | — | 列表页→创建按钮 | /dashboard/mcpServer | 有 App 创建权限 | +| S03 | 编辑 MCP 服务器 | — | 列表行→编辑按钮 | /dashboard/mcpServer | 有 App 创建权限 | +| S04 | 删除 MCP 服务器 | — | 列表行→删除按钮 | /dashboard/mcpServer | 有 App 创建权限 | +| S05 | 查看使用方式 | — | 列表行→开始使用按钮 | /dashboard/mcpServer | 所有团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..32e5e8d1196e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,345 @@ +--- +capability_label: MCP 服务器 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员(有 App 创建权限) +router_paths: + - /dashboard/mcpServer +--- + +# MCP 服务器 — 业务流程详解 + +## 页面总览 + +MCP 服务器管理是一个单页面应用,位于工作台侧边栏「应用构建」分组下。页面布局由 DashboardContainer 提供统一的侧边栏和工作区容器,内容区展示 MCP 服务器列表表格,顶部有创建按钮,每行数据提供「开始使用」「编辑」「删除」三个操作入口。PC 端和移动端采用不同的标题栏布局。 + +--- + +### 场景 S01:查看 MCP 服务器列表 + +> 用户进入 MCP 服务器管理页面,查看当前团队已创建的所有 MCP 服务器。 + +#### 步骤 1:页面加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左侧导航栏「MCP 服务器」 | 自动触发 `GET /api/support/mcp/list` | 无 | 页面进入加载状态,MyBox 显示加载动画覆盖整个内容区 | + +#### 步骤 2:列表渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待加载完成 | — | 列表为空 | 加载动画消失 → 表格区域显示空状态提示(EmptyTip) | +| 等待加载完成 | — | 列表不为空 | 加载动画消失 → 表格渲染,每行显示:MCP 名称、关联应用数、操作按钮组 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /api/support/mcp/list | 无 | 直接使用返回的数组 | 完整列表表格 | + +**表格列说明**: +- MCP 名称列:直接显示 API 返回的 `name` 字段 +- 应用数列:显示 `apps.length`(关联的应用工具数量) +- 操作列:固定宽度 311px,包含「开始使用」「编辑」「删除」三个按钮 + +--- + +### 场景 S02:创建 MCP 服务器 + +> 用户创建一个新的 MCP 服务器,配置名称并选择关联的应用工具。 + +#### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部「创建 MCP 服务器」按钮 | — | 用户无 `hasAppCreatePer` 权限 | 按钮置灰不可点击 | +| 点击页面顶部「创建 MCP 服务器」按钮 | — | 用户有 `hasAppCreatePer` 权限 | 打开 EditMcpModal 弹窗,模式为创建(isEdit=false),弹窗标题"创建 MCP 服务器" | + +#### 步骤 2:填写名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「名称」输入框中输入 MCP 服务器名称 | — | 无 | 输入框实时显示输入内容 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称必填 | 提交时(required 校验) | 浏览器默认必填提示 | + +#### 步骤 3:选择关联应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「管理应用」按钮 | — | 无 | 打开 SelectAppModal 弹窗 | + +#### 步骤 3a:浏览可选应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| SelectAppModal 自动加载 | `GET /api/core/app/list`(推断路径) | 无 | 左侧面板显示应用列表,每项含复选框、头像和名称 | +| 在搜索框中输入关键词 | `GET /api/core/app/list`(带搜索参数,节流 200ms) | 搜索关键词非空 | 左侧列表按搜索关键词过滤 | +| 点击文件夹项 | `GET /api/core/app/list`(带 parentId) | 搜索关键词为空 | 进入文件夹子级,Path 面包屑更新 | +| 点击文件夹项 | — | 搜索关键词非空 | 不进入文件夹,搜索模式下忽略文件夹导航 | +| 点击面包屑 Path | `GET /api/core/app/list`(对应 parentId) | 搜索关键词为空 | 跳转到对应层级 | + +#### 步骤 3b:勾选应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击应用行/复选框 | — | 未选中 → 选中 | 应用添加到右侧已选列表,计数器 +1 | +| 点击应用行/复选框 | — | 已选中 → 取消 | 应用从右侧列表移除,计数器 -1 | + +**表单与交互详情**: + +已选应用字段(右侧面板): + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 应用 | 复选框列表 | ✅(至少 1 个) | — | 团队内的简单应用/工作流应用/Agent/助手 | 文件夹不可选 | + +#### 步骤 3c:确认选择 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认」 | `GET /api/core/app/basicInfoByIds`(推断路径,批量获取选中应用信息) | 有选中应用 | SelectAppModal 关闭 → 回到 EditMcpModal,应用表格填充已选应用 | +| 点击「确认」 | — | 未选中任何应用 | 可正常确认(不阻断),应用表格为空 | + +**选择后的数据填充规则**: +- `toolName`(工具名称):默认为应用名称,可在后续步骤中手动修改 +- `appName`(应用名称):显示为应用原始名称,不可修改 +- `description`(工具描述):默认为应用的 `intro` 字段,可在后续步骤中手动修改 + +#### 步骤 4:编辑工具信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改「工具名称」输入框 | — | 无 | 输入框实时更新 | +| 修改「工具描述」输入框 | — | 无 | 输入框实时更新 | +| 点击删除图标 | — | 无 | 该工具从列表中移除 | + +**表单字段清单**(EditMcpModal 应用表格): + +| 字段名 | 控件类型 | 必填 | 默认值 | 说明 | +|--------|---------|------|--------|------| +| 工具名称 (toolName) | 文本输入 | ✅ | 应用名称 | 在 MCP Server 中对外暴露的工具名,支持自定义 | +| 应用名称 (appName) | 只读文本 | — | 应用原始名称 | 不可修改,仅用于展示 | +| 工具描述 (description) | 文本输入 | ✅ | 应用 intro | 工具的描述信息 | + +#### 步骤 5:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认」按钮 | — | 应用列表为空 | 按钮置灰(isDisabled=true),不可提交 | +| 点击「确认」按钮 | `POST /api/support/mcp/create` | 表单校验通过 | 按钮 loading 动画 → 成功:toast "创建成功",弹窗关闭,列表刷新 → 失败:toast 错误信息 | +| 点击「取消」 | — | 无 | 弹窗关闭,不保存 | + +**前置条件**: +- 用户具有 `hasAppCreatePer` 权限 +- 已至少选择 1 个应用 + +**后置影响**: +- 创建成功后在数据库中新增一条 MCP 服务器记录,自动生成访问密钥 (key) +- 列表自动刷新,新创建的 MCP 服务器出现在列表中 + +**失败场景**: +- 名称重复:后端返回错误,toast 提示具体错误信息 +- 应用数量超限(>50):后端 Zod 验证拒绝 +- 网络异常:请求失败,页面保持弹窗打开状态,用户可重试 + +--- + +### 场景 S03:编辑 MCP 服务器 + +> 用户修改已有 MCP 服务器的配置。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击列表行的「编辑」按钮 | — | 用户有 `hasAppCreatePer` 权限 | 打开 EditMcpModal 弹窗,模式为编辑(isEdit=true),弹窗标题"编辑 MCP 服务器" | +| 点击列表行的「编辑」按钮 | — | 用户无 `hasAppCreatePer` 权限 | 按钮仍可点击(编辑不受 hasAppCreatePer 限制) | + +弹窗初始数据: +- 名称:当前 MCP 服务器的 `name` +- 应用列表:当前 MCP 服务器的 `apps` 数组 + +#### 步骤 2:编辑配置 + +与创建流程步骤 2-4 相同,用户可修改名称、增删应用、修改工具名称和描述。 + +#### 步骤 3:提交更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认」按钮 | `PUT /api/support/mcp/update` | 表单校验通过 | 按钮 loading 动画 → 成功:toast "更新成功",弹窗关闭,列表刷新 → 失败:toast 错误信息 | + +**前置条件**: +- 已存在 MCP 服务器 +- 已选择至少 1 个应用 + +**后置影响**: +- MCP 服务器的信息和关联应用被更新 +- 已有的访问密钥 (key) 保持不变 + +--- + +### 场景 S04:删除 MCP 服务器 + +> 用户删除不再需要的 MCP 服务器。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击列表行的「删除」按钮 | — | 无 | 按钮上方弹出 PopoverConfirm 确认窗 | + +**确认弹窗详情**: +- 弹窗类型:`type="delete"`(红色警告样式) +- 提示文案:来自 i18n 键 `dashboard_mcp:delete_mcp_server_confirm_tip` + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认 | `DELETE /api/support/mcp/delete`(请求体含 `{ id }`) | 无 | 成功 → Popover 关闭,列表自动刷新(loadMcpList) | +| 点击确认 | `DELETE /api/support/mcp/delete` | 请求失败 | toast 显示错误信息 | +| 点击弹窗外区域 | — | 无 | Popover 关闭,不执行删除 | + +**后置影响**: +- MCP 服务器被删除,访问端点立即失效 +- 已配置使用此 MCP 服务器的外部客户端将无法连接 + +--- + +### 场景 S05:查看使用方式 + +> 用户获取 MCP 服务器的连接信息(端点 URL 和 JSON 配置)。 + +#### 步骤 1:打开使用方式弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击列表行的「开始使用」按钮 | — | 无 | 动态加载 UsageWay 组件,打开弹窗 | + +#### 步骤 2:查看和复制连接信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹窗打开 | — | 默认选中 Streamable HTTP | 显示 HTTP 端点 URL 和 JSON 配置 | +| 点击 SSE Tab | — | 系统配置了 `mcpServerProxyEndpoint` | 显示 SSE 端点 URL 和 JSON 配置 | +| 点击 SSE Tab | — | 系统未配置 `mcpServerProxyEndpoint` | 显示空状态提示「未配置 SSE 服务器」 | +| 点击复制图标 | — | 无 | 复制对应内容到剪贴板 | + +**端点 URL 生成规则**: + +| 协议 | URL 格式 | 前提 | +|------|---------|------| +| Streamable HTTP | `{customApiDomain 或 location.origin}/api/mcp/app/{key}/mcp` | 始终可用 | +| SSE | `{mcpServerProxyEndpoint}/{key}/sse` | 系统需配置 `mcpServerProxyEndpoint` | + +**JSON 配置格式**(用于 MCP 客户端配置): + +```json +{ + "mcpServers": { + "{systemTitle}-mcp-{id}": { + "url": "{端点URL}" + } + } +} +``` + +#### 步骤 3:关闭弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗关闭按钮 / 遮罩 | — | 无 | 弹窗关闭 | + +--- + +## Mermaid 附录 + +### 场景 S02:创建 MCP 服务器 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「创建 MCP 服务器」 + UI->>UI: 打开 EditMcpModal(创建模式) + User->>UI: 输入 MCP 名称 + User->>UI: 点击「管理应用」 + UI->>UI: 打开 SelectAppModal + UI->>API: GET /api/core/app/list + API->>Backend: 获取应用列表 + Backend-->>API: 应用列表 + API-->>UI: 渲染应用选择面板 + User->>UI: 勾选应用 + User->>UI: 点击确认 + UI->>API: GET /api/core/app/basicInfoByIds + API->>Backend: 批量获取应用信息 + Backend-->>API: 应用详情列表 + API-->>UI: 填充应用表格 + User->>UI: 修改工具名称/描述 + User->>UI: 点击「确认」 + UI->>API: POST /api/support/mcp/create + API->>Backend: 创建 MCP 服务器 + Backend-->>API: 创建成功 + API-->>UI: toast "创建成功", 关闭弹窗 + UI->>API: GET /api/support/mcp/list + API->>Backend: 刷新列表 + Backend-->>API: 最新列表 + API-->>UI: 表格刷新 +``` + +### 场景 S04:删除 MCP 服务器 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「删除」按钮 + UI->>UI: 弹出 PopoverConfirm 确认窗 + User->>UI: 点击确认 + UI->>API: DELETE /api/support/mcp/delete + API->>Backend: 删除 MCP 服务器 + Backend-->>API: 删除成功 + API-->>UI: Popover 关闭 + UI->>API: GET /api/support/mcp/list + API->>Backend: 刷新列表 + Backend-->>API: 最新列表 + API-->>UI: 表格刷新 +``` + +### 场景 S05:查看使用方式 + +```mermaid +sequenceDiagram + participant User + participant UI + participant SystemConfig + + User->>UI: 点击「开始使用」 + UI->>UI: 动态加载 UsageWay 组件 + UI->>SystemConfig: 读取 customApiDomain / mcpServerProxyEndpoint + SystemConfig-->>UI: 系统配置值 + UI->>UI: 拼接端点 URL 和 JSON 配置 + UI->>UI: 弹窗展示连接信息 + User->>UI: 切换 HTTP/SSE Tab + UI->>UI: 切换显示对应协议信息 + User->>UI: 点击复制图标 + UI->>UI: 复制到剪贴板 + User->>UI: 关闭弹窗 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..de0e9b374de5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: MCP 服务器 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员(有 App 创建权限) +router_paths: + - /dashboard/mcpServer +--- + +# MCP 服务器 — 模块概览 + +## 模块业务概述 + +MCP 服务器模块是 FastGPT 工作台中的一项管理功能,允许团队将自己的应用发布为符合 Model Context Protocol (MCP) 标准的服务器。用户通过此模块可以将一个或多个应用打包为一个 MCP Server,生成统一的访问端点(支持 Streamable HTTP 和 SSE 两种协议),供外部的 MCP 兼容客户端(如 Claude Desktop、Cursor 等 AI 工具)直接调用。 + +该模块是 FastGPT「应用构建 → 应用发布 → 外部消费」链路中的关键一环,它使得在 FastGPT 中搭建的 AI 应用能够无缝融入外部 AI 工作流生态,无需额外的适配层。 + +本模块为叶子节点,无子能力。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | MCP 服务器列表 | 创建、编辑、删除、查看使用方式 | 全部操作权限 | +| 团队成员(有 App 创建权限) | MCP 服务器列表 | 创建、编辑、删除、查看使用方式 | 需具有 `hasAppCreatePer` 权限才可创建 MCP 服务器;无此权限时「创建」按钮置灰 | +| 无 App 创建权限的成员 | MCP 服务器列表 | 仅查看列表、查看使用方式 | 创建、编辑、删除按钮不可用 | + +**权限模型**: 基于用户 permission 对象的 `hasAppCreatePer` 属性控制创建权限,编辑和删除权限通过页面交互中的操作按钮隐式控制。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用管理 | 获取应用列表、获取应用基本信息、获取应用文件夹路径 | API | +| 用户/团队 | 用户信息(权限)、团队信息 | Hook/Store | +| 系统配置 | 系统配置项(自定义域名、SSE 代理端点等) | Store | + +## 对外共享 + +本模块组件均为页面内部使用,不对外提供共享组件。MCP 服务器的运行时 API 端点(`/api/mcp/app/[key]/mcp` 和 SSE)对外部 MCP 客户端提供标准 MCP 协议服务。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| MCP 服务器管理 | /dashboard/mcpServer | projects/app/src/pages/dashboard/mcpServer/index.tsx | 有 App 创建权限的团队成员 | 单页面,包含列表、创建/编辑弹窗、使用方式弹窗 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a48560a51ab2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,138 @@ +--- +capability_label: 工作台 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: ["管理员", "普通成员", "团队所有者"] +router_paths: ["/dashboard/agent", "/dashboard/create", "/dashboard/evaluation", "/dashboard/mcpServer", "/dashboard/skill", "/dashboard/systemTool", "/dashboard/templateMarket", "/dashboard/tool"] +--- + +# 工作台 — 业务流程索引 + +## 业务场景列表 + +### 进入应用工作台 + +- **业务描述**:用户通过侧边栏"应用构建"入口进入应用工作台,查看和管理自己的 AI 应用列表。 +- **触发入口**:侧边栏 → 应用构建 → 应用(App) +- **路由路径**:`/dashboard/agent` +- **页面组件位置**:`pages/dashboard/agent/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 创建新应用 + +- **业务描述**:用户在工作台中创建新的 AI 应用,可选择智能问答、Chat Agent 或工作流三种类型。 +- **触发入口**:应用工作台 → "创建新应用"按钮 +- **路由路径**:`/dashboard/agent`(弹窗触发) +- **页面组件位置**:`pages/dashboard/create/CreateModal.tsx` +- **涉及角色**:hasAppCreatePer 权限用户 +- **前置条件**:已登录,具备应用创建权限 + +### 导入应用配置 + +- **业务描述**:用户通过 JSON 配置文件导入应用,快速恢复或迁移应用配置。 +- **触发入口**:应用工作台 → 创建按钮下拉菜单 → "导入 JSON 配置" +- **路由路径**:`/dashboard/agent` +- **页面组件位置**:`pageComponents/dashboard/agent/JsonImportModal.tsx` +- **涉及角色**:hasAppCreatePer 权限用户 +- **前置条件**:已登录,具有 JSON 配置文件 + +### 管理应用文件夹 + +- **业务描述**:用户创建文件夹对应用进行分组管理,支持文件夹的编辑、移动和删除。 +- **触发入口**:应用工作台 → "新建文件夹"按钮 → 选择文件夹 → 侧边卡片编辑/移动/删除 +- **路由路径**:`/dashboard/agent` +- **页面组件位置**:`components/common/folder/SlideCard.tsx` +- **涉及角色**:hasAppCreatePer(创建文件夹)/ 文件夹权限(编辑/删除) +- **前置条件**:已登录,对应操作权限 + +### 搜索与筛选应用 + +- **业务描述**:用户通过搜索关键词或按应用类型(全部/智能问答/Chat Agent/工作流)筛选应用列表。 +- **触发入口**:应用工作台 → 搜索框输入 / Tab 切换 +- **路由路径**:`/dashboard/agent` +- **页面组件位置**:`pages/dashboard/agent/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 浏览模板市场 + +- **业务描述**:用户浏览预置的应用模板,选择合适模板快速创建应用。 +- **触发入口**:侧边栏 → "模板市场" / 创建按钮下拉菜单 → "从模板创建" +- **路由路径**:`/dashboard/templateMarket` +- **页面组件位置**:`pages/dashboard/templateMarket/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 管理技能 + +- **业务描述**:用户管理 Agent 技能(Skills),为应用提供可复用的能力单元。 +- **触发入口**:侧边栏 → 应用构建 → 技能(Skill) +- **路由路径**:`/dashboard/skill` +- **页面组件位置**:`pages/dashboard/skill/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 管理工具 + +- **业务描述**:用户管理系统工具(Tools),支持 HTTP 工具、插件等类型的工具配置。 +- **触发入口**:侧边栏 → 应用构建 → 工具(Tools) +- **路由路径**:`/dashboard/tool` +- **页面组件位置**:`pages/dashboard/tool/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 管理 MCP 服务器 + +- **业务描述**:用户管理 MCP(Model Context Protocol)服务器连接配置。 +- **触发入口**:侧边栏 → 应用构建 → MCP 服务器 +- **路由路径**:`/dashboard/mcpServer` +- **页面组件位置**:`pages/dashboard/mcpServer/index.tsx` +- **涉及角色**:全部用户 +- **前置条件**:已登录 + +### 执行应用评测 + +- **业务描述**:用户对 AI 应用进行系统性评测,包括准备数据集、配置评测维度、创建和执行评测任务。 +- **触发入口**:侧边栏 → "应用评测"(Beta,需 show_evaluation 配置且 hasEvaluationCreatePer 权限) +- **路由路径**:`/dashboard/evaluation` +- **页面组件位置**:`pages/dashboard/evaluation/index.tsx` +- **涉及角色**:hasEvaluationCreatePer 权限用户 +- **前置条件**:已登录,系统启用评测功能,具备评测创建权限 + +### 配置系统工具(root 专用) + +- **业务描述**:root 用户管理平台级系统工具配置。 +- **触发入口**:侧边栏 → 应用构建 → 工具 → 系统工具子页面 +- **路由路径**:`/dashboard/systemTool` +- **页面组件位置**:`pages/dashboard/systemTool/index.tsx` +- **涉及角色**:仅 root 用户 +- **前置条件**:已登录,用户名为 root + +## 场景关系 + +- **应用工作台** → **创建应用**:工作台是应用管理的起点,创建应用是核心操作之一 +- **应用工作台** → **模板市场**:可从模板市场选择模板快速创建应用 +- **技能管理** / **工具管理** / **MCP 服务器**:应用构建的支撑能力,配置后可在应用中引用 +- **评测** → **应用工作台**:评测可对工作台中的已有应用执行效果评估 +- 大部分场景独立执行,无严格先后关系;创建应用、配置技能/工具后可进行评测 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 进入应用工作台 | 侧边栏→应用构建→应用 | /dashboard/agent | 全部用户 | +| S02 | 创建新应用 | 工作台→创建按钮 | /dashboard/agent | hasAppCreatePer | +| S03 | 导入应用配置 | 工作台→创建→导入 JSON | /dashboard/agent | hasAppCreatePer | +| S04 | 管理应用文件夹 | 工作台→新建文件夹/侧边卡片 | /dashboard/agent | hasAppCreatePer | +| S05 | 搜索与筛选应用 | 工作台→搜索框/Tab切换 | /dashboard/agent | 全部用户 | +| S06 | 浏览模板市场 | 侧边栏→模板市场 | /dashboard/templateMarket | 全部用户 | +| S07 | 管理技能 | 侧边栏→应用构建→技能 | /dashboard/skill | 全部用户 | +| S08 | 管理工具 | 侧边栏→应用构建→工具 | /dashboard/tool | 全部用户 | +| S09 | 管理 MCP 服务器 | 侧边栏→应用构建→MCP | /dashboard/mcpServer | 全部用户 | +| S10 | 执行应用评测 | 侧边栏→应用评测 | /dashboard/evaluation | hasEvaluationCreatePer | +| S11 | 配置系统工具 | 侧边栏→应用构建→工具 | /dashboard/systemTool | root | + +> 各子能力(如评测)的详细业务流程场景,请参见各子能力目录下的 [业务流程索引]。评测子能力包含数据集管理、维度配置、任务执行等细分场景,完整列表见 [评测/业务流程索引](../评测/业务流程索引.md)。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..f91d080fe25a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,84 @@ +--- +capability_label: 工作台 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: ["管理员", "普通成员", "团队所有者"] +router_paths: ["/dashboard/agent", "/dashboard/create", "/dashboard/evaluation", "/dashboard/mcpServer", "/dashboard/skill", "/dashboard/systemTool", "/dashboard/templateMarket", "/dashboard/tool"] +--- + +# 工作台 — 业务流程详解 + +## 子能力业务流程索引 + +工作台作为分组节点,本身不包含独立的业务流程页面。所有业务流程分散在各子能力模块中。 + +以下为各子能力的业务流程文档链接: + +| 子能力 | 业务描述 | 场景索引 | 流程详解 | +|--------|---------|---------|---------| +| 应用工作台 | 应用列表查看、文件夹管理、搜索筛选、创建入口 | [09-业务流程索引](../应用工作台/业务流程索引.md) | [10-业务流程详解](../应用工作台/业务流程详解.md) | +| 创建应用 | 创建智能问答/Chat Agent/工作流应用的完整交互流程 | [09-业务流程索引](../创建应用/业务流程索引.md) | [10-业务流程详解](../创建应用/业务流程详解.md) | +| 评测 | 评测数据集管理、维度配置、任务执行与结果查看 | [09-业务流程索引](../评测/业务流程索引.md) | [10-业务流程详解](../评测/业务流程详解.md) | +| MCP 服务器 | MCP 服务器连接配置的增删改查流程 | [09-业务流程索引](../MCP%20服务器/业务流程索引.md) | [10-业务流程详解](../MCP%20服务器/业务流程详解.md) | +| 技能管理 | Agent 技能的创建、编辑和启停流程 | [09-业务流程索引](../技能管理/业务流程索引.md) | [10-业务流程详解](../技能管理/业务流程详解.md) | +| 系统工具 | 平台级系统工具的配置管理流程 | [09-业务流程索引](../系统工具/业务流程索引.md) | [10-业务流程详解](../系统工具/业务流程详解.md) | +| 模板市场 | 模板浏览、筛选和基于模板创建应用的流程 | [09-业务流程索引](../模板市场/业务流程索引.md) | [10-业务流程详解](../模板市场/业务流程详解.md) | +| 工具管理 | 系统工具的创建、编辑、启停和删除流程 | [09-业务流程索引](../工具管理/业务流程索引.md) | [10-业务流程详解](../工具管理/业务流程详解.md) | + +## 公共业务流程 + +工作台公共流程由 `DashboardContainer` 组件统一承载,各子页面嵌套其中。 + +### 步骤 1:侧边栏导航 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载,侧边栏渲染 | 无(纯前端路由) | 系统初始化完成后渲染侧边栏 | 侧边栏展示 Logo、导航菜单项;折叠/展开按钮可见 | +| 点击导航菜单项 | 无(前端路由跳转) | 点击可展开分组(应用构建/设置)→ 展开/收起子菜单;点击直接链接 → 路由跳转 | 应用构建子菜单展示:应用/技能/工具/MCP;设置子菜单展示:用量/模型/团队等 | +| 点击折叠按钮 | 无 | 侧边栏状态切换 | 侧边栏宽度从展开态变为折叠态(仅显示图标),内容区域左 padding 同步调整 | +| 折叠态点击图标 | 无 | 折叠态下点击导航项 → 跳转到该分组的第一个子页面 | 路由跳转到对应页面,侧边栏保持折叠 | + +### 步骤 2:页面内容加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 路由进入 dashboard 子页面 | 各子页面自行发起数据请求 | 进入 `/dashboard/agent` → 加载应用列表;进入 `/dashboard/templateMarket` → 加载模板列表 | 内容区域展示 Loading 状态(MyBox isLoading),加载完成后渲染子页面内容 | +| PC 端页面背景渲染 | 无 | 当前路由在 dashboard 路径列表中时 → 渲染 `BgDecoration` 装饰背景 | 内容区域显示渐变背景(#F2F8FF → #F7F9FC)+ 装饰性背景图 | + +### 步骤 3:权限控制渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 | 无 | `feConfigs?.show_evaluation` + `hasEvaluationCreatePer` → 显示评测入口 | 评测入口可见或隐藏 | +| 页面加载 | 无 | `userInfo?.username === 'root'` → 显示系统工具管理入口 | 设置菜单中额外出现"系统工具管理"项 | +| 页面加载 | 无 | `feConfigs?.isPlus` → 显示用量、团队、通知、自定义域名等菜单项 | 设置菜单项动态增减 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 侧边栏+容器 + participant Router as Next.js Router + participant Page as 子页面 + + User->>UI: 进入工作台 + UI->>UI: 渲染 DashboardNavbar + DashboardContainer + UI->>UI: 检查权限(feConfigs + userInfo) + UI->>UI: 决定可见菜单项和入口 + + User->>UI: 点击"应用构建→应用" + UI->>Router: push('/dashboard/agent') + Router->>Page: 加载应用工作台页面 + Page->>Page: 获取应用列表数据 + Page-->>User: 展示应用列表 + + User->>UI: 点击"应用构建→技能" + UI->>Router: push('/dashboard/skill') + Router->>Page: 加载技能管理页面 + Page-->>User: 展示技能列表 + + Note over User,Page: 各子页面业务流程详见对应子能力的业务流程详解文档 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ab11f2312110 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,85 @@ +--- +capability_label: 创建应用 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/create] +--- + +# 创建应用 — 业务流程索引 + +## 创建 Agent 应用(空白模板) + +- **业务描述**: 用户在应用工作台选择创建 Agent 类应用(Agent 对话助手/简单对话机器人/工作流编排/工作流工具),填写图标和名称后从空白模板创建。 +- **触发入口**: 工作台页面 → 创建按钮 → 创建应用页面(Agent 类型) +- **路由路径**: `/dashboard/create?parentId={id}` +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` +- **涉及角色**: 全体团队用户 +- **前置条件**: 用户已登录并加入团队 + +## 创建工具应用(空白模板) + +- **业务描述**: 用户在工具管理页面选择创建工具类应用(MCP 工具集/HTTP 工具集/工作流工具),配置相应参数后创建。 +- **触发入口**: 工具管理页面 → 创建工具按钮 → 创建应用页面(工具类型) +- **路由路径**: `/dashboard/create?parentId={id}&appType={type}` +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` +- **涉及角色**: 全体团队用户 +- **前置条件**: 用户已登录并加入团队 + +## 创建 MCP 工具集 + +- **业务描述**: 选择 MCP 工具集类型,配置 MCP 服务地址和鉴权信息,解析并选择 MCP 工具列表后创建。 +- **触发入口**: 创建应用页面 → 选择 MCP 工具集卡片 +- **路由路径**: `/dashboard/create`(页面内交互) +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` → MCP 配置区域 +- **涉及角色**: 全体团队用户 +- **前置条件**: 有可访问的 MCP 服务 URL + +## 创建 HTTP 工具集 + +- **业务描述**: 选择 HTTP 工具集类型,选择批量或手动创建模式后创建。 +- **触发入口**: 创建应用页面 → 选择 HTTP 工具集卡片 +- **路由路径**: `/dashboard/create`(页面内交互) +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` → HTTP 配置区域 +- **涉及角色**: 全体团队用户 +- **前置条件**: 无特殊前置条件 + +## 从快速模板创建 + +- **业务描述**: 浏览当前类型的快速模板列表,点击对应模板卡片一键创建应用。模板包含预设的工作流节点、边和对话配置。 +- **触发入口**: 创建应用页面 → 模板区域 → 点击模板卡片 +- **路由路径**: `/dashboard/create`(页面内交互) +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` → 模板列表区域 +- **涉及角色**: 全体团队用户 +- **前置条件**: 当前类型有可用快速模板(由 `getTemplateMarketItemList` 返回) + +## 智能生成工作流 + +- **业务描述**: 选择工作流类型后,用户输入业务需求描述(业务背景、核心流程、具体要求),选择 AI 模型,一键智能生成工作流应用。生成任务存入 sessionStorage,跳转至详情页后由 Copilot 执行。 +- **触发入口**: 创建应用页面 → 选择工作流类型 → 智能生成区域 +- **路由路径**: `/dashboard/create`(页面内交互) +- **页面组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` → 智能生成区域 +- **涉及角色**: 全体团队用户 +- **前置条件**: 需选择工作流类型(`AppTypeEnum.workflow`),已配置 AI 模型 + +## 场景关系 + +- **创建 Agent 应用(空白模板)** 和 **创建工具应用(空白模板)** 是基础路径,所有类型共用图标/名称表单。 +- **创建 MCP 工具集** 是工具创建的特殊分支,需要额外配置 MCP 服务地址和鉴权。 +- **创建 HTTP 工具集** 是工具创建的特殊分支,需选择批量/手动模式。 +- **从快速模板创建** 依赖模板市场 API,在 Agent 类和应用类类型下有不同模板列表。 +- **智能生成工作流** 仅在工作流类型下可用,依赖 AI 模型选择器和业务需求描述。 +- 所有创建路径成功后均跳转至 `/app/detail?appId={id}`。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 创建 Agent 应用(空白模板) | 工作台→创建 | /dashboard/create | 全体用户 | +| S02 | 创建工具应用(空白模板) | 工具管理→创建 | /dashboard/create | 全体用户 | +| S03 | 创建 MCP 工具集 | 创建页面→MCP 卡片 | /dashboard/create | 全体用户 | +| S04 | 创建 HTTP 工具集 | 创建页面→HTTP 卡片 | /dashboard/create | 全体用户 | +| S05 | 从快速模板创建 | 创建页面→模板卡片 | /dashboard/create | 全体用户 | +| S06 | 智能生成工作流 | 创建页面→智能生成区域 | /dashboard/create | 全体用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..3ee767394628 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,264 @@ +--- +capability_label: 创建应用 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/create] +--- + +# 创建应用 — 业务流程详解 + +## 页面总览 + +创建应用页面采用左右分栏布局(PC 端),左侧为创建表单区(固定宽度,可滚动),右侧为类型预览图区。页面顶部有返回按钮,根据入口类型(Agent/工具)返回工作台或工具管理页面。表单区自上而下依次展示:应用类型选择卡片、图标与名称编辑、条件表单区域(工作流/模板/MCP/HTTP 按选中类型展示)。 + +### S01:创建 Agent 应用(空白模板) + +> 用户从空白模板创建 Agent 类应用(Agent 对话助手 / 简单对话机器人 / 工作流编排 / 工作流工具)。 + +#### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工作台/工具管理页面点击创建,携带 `parentId` 和 `appType` query 参数进入页面 | `getTemplateMarketItemList({ isQuickTemplate: true, type })` 获取当前类型的快速模板列表 | — | 渲染创建页面:根据 `appType` 预选类型(默认为 `chatAgent`);根据 `ToolTypeList.includes(appType)` 判断是否工具创建模式 | + +- **入口分支**:`appType` 为工具类型(`mcpToolSet`/`httpToolSet`/`workflowTool`)时,`isToolType=true`,页面标题显示"创建工具",返回按钮指向 `/dashboard/tool`;否则显示"创建 Agent",返回按钮指向 `/dashboard/agent`。 + +#### 步骤 2:选择应用类型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览应用类型卡片网格(3 列) | — | Agent 入口过滤出非工具类型卡片(`!ToolTypeList.includes`);工具入口过滤出工具类型卡片(`ToolTypeList.includes`) | 当前选中卡片蓝色边框高亮(`borderColor: 'primary.300'`);点击卡片后:切换 `selectedAppType`,模板列表按新类型刷新,预览图切换,图标重置为选中类型默认图标 | + +- **类型卡片数据源**:从 `createAppTypeMap` 常量中遍历,按入口类型过滤。每张卡片显示图标、标题、简介。卡片组件为 `AppTypeCard`。 + +#### 步骤 3:配置图标与名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头像区域 | `getUploadAvatarPresignedUrl` → 获取预签名 URL → 上传文件 | — | 打开文件选择器,上传成功后更新头像预览 | +| 输入应用名称 | — | 名称为空时默认使用「未命名应用」 | Input 框实时更新;非 MCP 类型时名称框右侧显示「创建」按钮 | + +- **头像上传**:使用 `useUploadAvatar` hook 配合 `getUploadAvatarPresignedUrl` API 上传。上传成功后回调 `setValue('avatar', avatar)` 更新表单状态。 +- **即时创建**:非 MCP 工具集类型时,名称输入框右侧有「创建」按钮,点击直接以空白模板创建。 + +#### 步骤 4:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「创建」按钮 | `getEmptyAppsTemplate(t)` 获取空白模板 → `postCreateApp` 创建应用 | `appType` 决定模板类型(`simple`/`chatAgent`/`workflow`/`workflowTool`) | 按钮显示 loading;创建成功 toast "创建成功";自动跳转 `/app/detail?appId=${id}` | + +- **空白模板结构**:`getEmptyAppsTemplate` 返回按 `AppTypeEnum` 分组的预设模板,每种类型包含初始工作流节点(如系统配置节点、工作流开始节点、AI 对话节点等)和边定义。 + +### S02:创建工具应用(空白模板) + +> 工具类创建的基本流程与 S01 一致,区别在于类型过滤和跳转入口。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择工具类型卡片(`mcpToolSet`/`httpToolSet`/`workflowTool`)→ 填写名称 → 点击创建 | 根据具体类型分支调用不同 API(见 S03/S04 或 `postCreateApp`) | `appType=workflowTool` 走空白模板创建;`appType=mcpToolSet` 走 S03 流程;`appType=httpToolSet` 走 S04 流程 | 与 S01 第 4 步一致 | + +### Mermaid 附录(S01/S02) + +```mermaid +sequenceDiagram + participant User + participant UI as 创建页面 + participant API as 后端 API + participant Detail as 应用详情 + + User->>UI: 携带 parentId/appType 进入页面 + UI->>API: GET 快速模板列表 + API-->>UI: 模板数据 + + User->>UI: 选择应用类型卡片 + UI-->>User: 切换预览图、更新模板列表 + + User->>UI: 上传头像 + UI->>API: 获取预签名 URL → 上传 + API-->>UI: 头像地址 + + User->>UI: 输入名称 + 点击创建 + UI->>UI: getEmptyAppsTemplate 获取空白模板 + UI->>API: POST /core/app/create + API-->>UI: 应用 ID + UI->>Detail: 跳转 /app/detail?appId={id} +``` + +### S03:创建 MCP 工具集 + +> 用户选择 MCP 工具集类型,配置 MCP 服务 URL 和鉴权信息,解析工具列表后创建。 + +#### 步骤 1:配置鉴权 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 HeaderAuthForm 中选择鉴权类型(None/Bearer/Basic/Custom)→ 输入对应凭证 | — | 选择 None:清空鉴权配置;选择 Bearer/Basic:显示 Value 输入框;选择 Custom:显示 Key/Value 键值对列表,可添加/删除多组 | 鉴权类型下拉切换时重置对应字段;已有 secret 的字段显示"已配置"状态,可点击编辑 | + +- **鉴权组件**:使用 `HeaderAuthForm` 组件,支持 4 种鉴权类型切换。鉴权数据通过 `headerValue2StoreHeader` 转换为后端存储格式。 + +#### 步骤 2:解析 MCP 工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入 MCP 服务 URL → 点击「解析」按钮 | `getMCPTools({ url, headerSecret })` | URL 为空时按钮无响应;解析失败 toast "MCP 工具解析失败" | 按钮显示 loading(`isGettingMCPTools`);解析成功后工具列表填充到 `mcpToolList`,以表格展示工具名和描述 | + +#### 步骤 3:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 确认工具列表 → 点击「创建」按钮 | `postCreateMCPTools({ parentId, avatar, name, url, headerSecret, toolList })` | 工具列表为空时按钮禁用 | 按钮显示 loading;创建成功 toast 后跳转 | + +### Mermaid 附录(S03) + +```mermaid +sequenceDiagram + participant User + participant UI as 创建页面 + participant API as 后端 API + + User->>UI: 选择 MCP 工具集类型 + User->>UI: 配置鉴权(HeaderAuthForm) + + User->>UI: 输入 MCP URL + 点击解析 + UI->>API: POST /core/app/mcpTools/getTools + API-->>UI: MCP 工具列表 [{name, description}] + UI-->>User: 表格展示工具名和描述 + + User->>UI: 点击创建 + UI->>API: POST /core/app/mcpTools/create + API-->>UI: 应用 ID + UI->>UI: 跳转应用详情 +``` + +### S04:创建 HTTP 工具集 + +> 用户选择 HTTP 工具集类型,选择创建模式后直接创建。 + +#### 步骤 1:选择创建模式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览 HTTP 工具创建模式(批量创建 / 手动创建) | — | — | `LeftRadio` 单选组件展示两种模式,附带说明文字;选中项蓝色高亮 | + +- **批量创建(batch)**:通过导入 OpenAPI Schema 批量生成 HTTP 工具。 +- **手动创建(manual)**:手动配置单个 HTTP 工具的请求参数。 + +#### 步骤 2:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写名称 → 点击「创建」 | `postCreateHttpTools({ parentId, avatar, name, createType })` | — | 按钮显示 loading;创建成功 toast 后跳转 | + +### Mermaid 附录(S04) + +```mermaid +sequenceDiagram + participant User + participant UI as 创建页面 + participant API as 后端 API + + User->>UI: 选择 HTTP 工具集类型 + User->>UI: 选择创建模式(批量/手动) + UI-->>User: LeftRadio 切换高亮 + + User->>UI: 输入名称 + 点击创建 + UI->>API: POST /core/app/httpTools/create + API-->>UI: 应用 ID + UI->>UI: 跳转应用详情 +``` + +### S05:从快速模板创建 + +> 用户通过模板卡片一键创建应用,模板包含完整的工作流节点、边和对话配置。 + +#### 步骤 1:加载模板列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 / 切换应用类型 | `getTemplateMarketItemList({ isQuickTemplate: true, type: selectedAppType })` | 模板列表为空时不渲染模板区域 | `templateData.list` 填充;`isLoadingTemplates` 控制 loading 状态 | + +- **模板过滤**:请求参数 `isQuickTemplate: true` 仅返回快速模板,`type` 按当前选中类型过滤。切换 `selectedAppType` 时通过 `refreshDeps` 自动重新请求。 + +#### 步骤 2:浏览与选择模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览模板卡片网格(3 列) | — | 模板区域标题显示"从模板创建",右侧链接跳转至完整模板市场 | 每张卡片显示模糊背景头像、居中图标、模板名称、简介;鼠标悬停显示蓝色边框和阴影 | + +#### 步骤 3:从模板创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模板卡片 | 先调 `getTemplateMarketItemDetail(templateId)` 获取模板详情;再调 `postCreateApp({ ..., modules, edges, chatConfig, templateId })` | 其他创建正在进行时忽略点击(`!creatingTemplateId` 守卫) | 卡片显示 loading 状态(`isLoading`);成功后跳转 | + +- **模板创建 vs 空白创建**:模板路径调用 `postCreateApp` 时传入 `templateDetail.workflow.nodes`、`templateDetail.workflow.edges`、`templateDetail.workflow.chatConfig` 和 `templateId` 字段,替换空白模板的默认节点。 + +### Mermaid 附录(S05) + +```mermaid +sequenceDiagram + participant User + participant UI as 创建页面 + participant API as 后端 API + + User->>UI: 进入页面 / 切换类型 + UI->>API: 获取快速模板列表 + API-->>UI: 模板列表 + + User->>UI: 点击模板卡片 + UI->>API: 获取模板详情 + API-->>UI: 模板详情(workflow 节点/边/chatConfig) + UI->>API: 创建应用(传入模板数据) + API-->>UI: 应用 ID + UI->>UI: 跳转应用详情 +``` + +### S06:智能生成工作流 + +> 仅在选中工作流类型(`AppTypeEnum.workflow`)时显示。用户输入业务需求描述,选择 AI 模型,系统创建空白工作流并将生成任务写入 sessionStorage,跳转后由 Copilot 执行生成。 + +#### 步骤 1:配置生成参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择智能生成模型 | — | 模型列表为空时不显示选择器 | `AIModelSelector` 组件显示,默认选中 `defaultModels.llm` 或列表第一项 | +| 输入业务需求描述 | — | 描述为空时「确认生成」按钮禁用 | 多行文本输入框,placeholder 提示输入业务背景、核心流程、具体要求 | + +- **模型选择器**:使用 `AIModelSelector` 组件,列表来自 `useSystemStore.llmModelList`,以单行下拉模式展示,显示模型图标和名称。 + +#### 步骤 2:创建并存储生成任务 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认生成」 | 先调 `getEmptyAppsTemplate` → `postCreateApp` 创建空白工作流;然后将 `WorkflowCopilotGenerationTask` 写入 `sessionStorage` | 描述为空或模型未选时按钮禁用 | 按钮显示 loading(`isCreatingSmartWorkflow`);成功后跳转应用详情 | + +- **生成任务结构**(`WorkflowCopilotGenerationTask`):包含 `appId`(新创建的应用 ID)、`requirement`(用户输入的需求描述)、`model`(选中的 AI 模型 ID)、`createdAt`(时间戳)。存入 `sessionStorage` 的 key 为 `WORKFLOW_COPILOT_TASK_STORAGE_KEY`。应用详情页的 Copilot 模块读取此任务并执行工作流生成。 + +### Mermaid 附录(S06) + +```mermaid +sequenceDiagram + participant User + participant UI as 创建页面 + participant API as 后端 API + participant Storage as sessionStorage + participant Detail as 应用详情 + + User->>UI: 选择工作流类型 + UI-->>User: 显示智能生成区域 + + User->>UI: 选择 AI 模型 + User->>UI: 输入业务需求描述 + + User->>UI: 点击确认生成 + UI->>UI: getEmptyAppsTemplate 获取空白工作流 + UI->>API: POST /core/app/create(空白工作流) + API-->>UI: 应用 ID + UI->>Storage: 写入 WorkflowCopilotGenerationTask + UI->>Detail: 跳转 /app/detail?appId={id} + Detail->>Storage: Copilot 读取生成任务 + Detail->>Detail: 执行工作流智能生成 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..9a6789e80a95 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,63 @@ +--- +capability_label: 创建应用 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/create] +--- + +# 创建应用 — 模块概览 + +## 1. 模块业务概述 + +创建应用模块是 FastGPT 工作台中的应用/工具创建入口,为用户提供统一的创建向导页面。用户可在此选择应用/工具类型、填写基本信息、使用模板快速创建或智能生成工作流。 + +支持的创建类型分为两大类: + +- **Agent 类应用**:包括 Agent 对话助手(`chatAgent`)、简单对话机器人(`simple`)、工作流编排(`workflow`),适合构建 AI 对话、自动化工作流等场景。 +- **工具类应用**:包括工作流工具(`workflowTool`)、MCP 工具集(`mcpToolSet`)、HTTP 工具集(`httpToolSet`),适合将工作流封装为可复用的工具或接入外部服务。 + +页面左侧为创建表单区(类型选择 → 图标名称 → 条件表单),右侧为选中类型的预览插图。支持的创建路径包括:从空白模板创建、从快速模板创建、智能生成工作流。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 全部创建页面 | 创建 Agent/工具应用,使用模板创建,智能生成工作流 | 完整创建权限 | +| 团队成员 | 全部创建页面 | 创建 Agent/工具应用,使用模板创建,智能生成工作流 | 与管理员权限相同 | + +**权限模型**:创建应用页面无角色级权限校验,所有团队用户均可创建应用。创建后的应用权限由应用自身的成员管理控制。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台/应用工作台 | 从工作台页面跳转至创建页面(传递 `parentId`、`appType` 参数);创建成功后跳转至应用详情 | 路由 / 状态 | +| 应用/应用详情 | 创建成功后路由跳转 `router.push('/app/detail?appId=${id}')` | 路由 | +| 工作台/模板市场 | 获取快速模板列表 API(`getTemplateMarketItemList`),获取模板详情 API(`getTemplateMarketItemDetail`),底部链接跳转至模板市场 | API / 路由 | +| 系统配置 | 获取模型列表(`useSystemStore.llmModelList`)、默认模型(`defaultModels`)用于智能生成 | Store | +| 文件上传 | 头像上传(`useUploadAvatar` + `getUploadAvatarPresignedUrl`) | Hook / API | +| MCP 工具 | 获取 MCP 工具列表 API(`getMCPTools`),创建 MCP 工具集 API(`postCreateMCPTools`) | API | +| HTTP 工具 | 创建 HTTP 工具集 API(`postCreateHttpTools`) | API | +| 鉴权配置 | Header 鉴权表单组件(`HeaderAuthForm`),鉴权数据转换函数(`headerValue2StoreHeader`) | 组件 / 工具函数 | + +## 4. 对外共享 + +本模块为创建入口页面,不提供对外共享能力。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 创建应用 | /dashboard/create | `projects/app/src/pages/dashboard/create/index.tsx` | 全体团队用户 | 接收 query 参数 `parentId`(父级目录/工具目录 ID)和 `appType`(预选类型) | + +**入口来源**: +- 应用工作台(`/dashboard/agent`)→ 点击「创建」按钮 +- 工具管理(`/dashboard/tool`)→ 点击「创建工具」按钮 +- 两类入口通过 `parentId` 和 `appType` 参数区分,`appType` 为工具类型时进入工具创建模式 + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..070c11d7baf4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,143 @@ +--- +capability_label: 工具管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:15:00.000Z" +parent_module: 工作台 +roles: [团队所有者, 管理员, 编辑者, 只读成员] +router_paths: [/dashboard/tool] +--- + +# 工具管理 — 业务流程索引 + +## 查看工具列表 + +- **业务描述**: 浏览当前文件夹下的所有工具,通过类型筛选和关键词搜索快速定位目标工具。列表采用无限滚动加载,支持分页获取。 +- **触发入口**: 侧边导航栏 → 工作台 → 工具管理 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pages/dashboard/tool/index.tsx +- **涉及角色**: 全部角色 +- **前置条件**: 已登录,具有团队访问权限 + +## 切换工具类型筛选 + +- **业务描述**: 按工具类型(全部/工作流工具/HTTP工具集/MCP工具集)筛选当前列表中的工具卡片。 +- **触发入口**: 工具列表页 → 顶部类型筛选标签栏(MyTabBar) +- **路由路径**: /dashboard/tool?type={all|plugin|httpToolSet|toolSet} +- **页面组件位置**: projects/app/src/pages/dashboard/tool/index.tsx +- **涉及角色**: 全部角色 +- **前置条件**: 已在工具列表页面 + +## 搜索工具 + +- **业务描述**: 通过名称关键词搜索工具,输入后自动触发服务端搜索(500ms 防抖),结果在列表中实时更新。 +- **触发入口**: 工具列表页 → 顶部搜索框 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pages/dashboard/tool/index.tsx +- **涉及角色**: 全部角色 +- **前置条件**: 已在工具列表页面 + +## 创建文件夹 + +- **业务描述**: 在当前位置下创建新文件夹,用于组织管理工具。需要填写文件夹名称和简介,支持上传自定义头像。 +- **触发入口**: 工具列表页 → "新建文件夹"按钮 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pages/dashboard/tool/index.tsx(EditFolderModal) +- **涉及角色**: 团队所有者、管理员、编辑者(需有文件夹写入权限) +- **前置条件**: 当前文件夹有写入权限,或为根目录且有团队创建权限 + +## 创建工具 + +- **业务描述**: 创建新的 AI 工具,支持三种类型:工作流工具(基于可视化工作流编排)、HTTP 工具集(配置 HTTP 请求参数调用外部 API)、MCP 工具集(连接 MCP 服务器并选择工具)。工作流工具支持从模板市场创建或空白创建。 +- **触发入口**: 工具列表页 → "新建工具"下拉菜单 → 选择工具类型 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pages/dashboard/create/ToolModal.tsx +- **涉及角色**: 团队所有者、管理员、编辑者(需有文件夹写入权限) +- **前置条件**: 当前文件夹有写入权限,或为根目录且有团队创建权限 + +## 编辑工具/文件夹信息 + +- **业务描述**: 修改工具或文件夹的名称、简介、头像。 +- **触发入口**: 工具卡片 → 更多操作菜单 → 编辑;或文件夹详情面板 → 编辑按钮 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pages/dashboard/tool/index.tsx(EditFolderModal)、projects/app/src/pageComponents/dashboard/agent/List.tsx(EditResourceModal) +- **涉及角色**: 团队所有者、管理员、编辑者(需有该资源的编辑权限) +- **前置条件**: 对目标工具有编辑权限 + +## 移动工具/文件夹 + +- **业务描述**: 将工具或文件夹移动到另一个文件夹中。 +- **触发入口**: 工具卡片 → 更多操作菜单 → 移动;或文件夹详情面板 → 移动按钮 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pageComponents/dashboard/agent/context.tsx(MoveModal) +- **涉及角色**: 团队所有者、管理员、编辑者 +- **前置条件**: 对目标资源有管理权限 + +## 删除工具/文件夹 + +- **业务描述**: 删除指定的工具或文件夹,删除前弹出确认提示。删除后自动清理关联的本地存储数据,并跳转回上级文件夹。 +- **触发入口**: 工具卡片 → 更多操作菜单 → 删除;或文件夹详情面板 → 删除按钮 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pageComponents/dashboard/agent/List.tsx(DelConfirmModal)、projects/app/src/pages/dashboard/tool/index.tsx(PopoverConfirm) +- **涉及角色**: 团队所有者(文件夹删除);资源拥有者或有管理权限的用户 +- **前置条件**: 对目标资源有管理权限 + +## 管理协作者权限 + +- **业务描述**: 为文件夹或工具添加/移除协作者,设置各协作者的权限级别(只读/编辑/管理),或恢复权限继承。 +- **触发入口**: 文件夹详情面板 → 协作者区域 → 设置按钮 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/components/common/folder/SlideCard.tsx(ConfigPerModal) +- **涉及角色**: 团队所有者、管理员 +- **前置条件**: 对目标资源有管理权限,团队启用了 Plus 功能 + +## 复制工具 + +- **业务描述**: 创建工具的完整副本,包含工具的所有配置。 +- **触发入口**: 工具卡片 → 更多操作菜单 → 复制 +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pageComponents/dashboard/agent/List.tsx(ConfirmCopyModal) +- **涉及角色**: 团队所有者、管理员、编辑者 +- **前置条件**: 对目标工具有读取权限 + +## 导入 JSON 配置 + +- **业务描述**: 从 JSON 配置文件导入工作流创建新工具。若会话存储中存在工作流 URL 链接,页面加载时自动触发导入弹窗。 +- **触发入口**: "新建工具"下拉菜单 → "导入 JSON 配置" +- **路由路径**: /dashboard/tool +- **页面组件位置**: projects/app/src/pageComponents/dashboard/agent/JsonImportModal.tsx +- **涉及角色**: 团队所有者、管理员、编辑者 +- **前置条件**: 需要有创建权限 + +## 导航到文件夹 + +- **业务描述**: 点击文件夹路径中的某一级面包屑,导航到对应层级查看该文件夹下的工具。 +- **触发入口**: 顶部面包屑导航路径 → 点击某级文件夹名称 +- **路由路径**: /dashboard/tool?parentId={folderId} +- **页面组件位置**: projects/app/src/components/common/folder/Path.tsx +- **涉及角色**: 全部角色 +- **前置条件**: 已进入某个文件夹 + +## 场景关系 + +- **查看工具列表** → **切换工具类型筛选** / **搜索工具** / **导航到文件夹**:筛选、搜索和文件夹导航均为列表的过滤方式,可组合使用 +- **查看工具列表** → **创建工具** / **创建文件夹** / **导入 JSON 配置**:创建操作从列表页触发,成功后自动刷新列表 +- **查看工具列表** → **编辑/移动/删除/复制**:针对单个工具或文件夹的操作,编辑成功后自动刷新列表 +- **创建工具(工作流)** → **应用详情**:创建成功后自动跳转到应用详情页进行工作流编排 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看工具列表 | 侧边栏→工具管理 | /dashboard/tool | 全部 | +| S02 | 切换工具类型筛选 | 列表页→类型筛选标签栏 | /dashboard/tool?type=... | 全部 | +| S03 | 搜索工具 | 列表页→搜索框 | /dashboard/tool | 全部 | +| S04 | 创建文件夹 | 列表页→"新建文件夹"按钮 | /dashboard/tool | 有写入权限的用户 | +| S05 | 创建工具 | 列表页→"新建工具"下拉菜单 | /dashboard/tool | 有写入权限的用户 | +| S06 | 编辑工具/文件夹信息 | 工具卡片→编辑 / 文件夹详情→编辑 | /dashboard/tool | 有编辑权限的用户 | +| S07 | 移动工具/文件夹 | 工具卡片→移动 / 文件夹详情→移动 | /dashboard/tool | 有管理权限的用户 | +| S08 | 删除工具/文件夹 | 工具卡片→删除 / 文件夹详情→删除 | /dashboard/tool | 拥有者/管理员 | +| S09 | 管理协作者权限 | 文件夹详情→协作者设置 | /dashboard/tool | 拥有者/管理员 | +| S10 | 复制工具 | 工具卡片→复制 | /dashboard/tool | 有读取权限的用户 | +| S11 | 导入 JSON 配置 | "新建工具"→"导入JSON配置" | /dashboard/tool | 有创建权限的用户 | +| S12 | 导航到文件夹 | 面包屑→点击文件夹名 | /dashboard/tool?parentId=... | 全部 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..eb0a5bb72a84 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,597 @@ +--- +capability_label: 工具管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:15:00.000Z" +parent_module: 工作台 +roles: [团队所有者, 管理员, 编辑者, 只读成员] +router_paths: [/dashboard/tool] +--- + +# 工具管理 — 业务流程详解 + +## 页面总览 + +工具管理页面是用户查看、创建和管理 AI 工具的统一入口。页面按三行布局组织:第一行为面包屑导航和工具类型总切换(我的工具/系统工具),第二行为类型筛选栏、搜索框和操作按钮,第三行为工具卡片列表和文件夹详情侧边面板。页面通过 `AppListContextProvider` 提供统一的分页、搜索和文件夹管理上下文,ToolModal 处理三种工具类型的差异化创建流程。 + +--- + +### S01:查看工具列表 + +> 用户进入工具管理页面,查看当前层级(根目录或某个文件夹下)的所有工具。列表以卡片网格形式展示,采用无限滚动分页加载。 + +#### 步骤 1:进入页面,加载初始数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击侧边导航栏"工具管理"或直接在浏览器输入 `/dashboard/tool` | POST /core/app/list(第1页,parentId=路由query中的parentId,type=全量工具类型) | — | 页面框架渲染,显示 DashboardContainer 侧边栏和背景装饰;AppListContextProvider 开始加载数据,List 区域显示加载中的 Spinner | + +#### 步骤 2:数据加载完成,渲染工具卡片 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待加载完成 | — | myApps 非空 → 渲染工具卡片网格;myApps 为空且不在文件夹中 → 根据权限显示"暂无工具"空状态提示;myApps 为空且在文件夹中 → 显示空状态 | Spinner 消失,工具卡片以 CSS Grid 布局展示,每张卡片显示工具头像、名称、简介;若用户有创建权限,网格首项显示"新建"入口卡片 | + +#### 步骤 3:滚动到底部,触发翻页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动列表至底部 | POST /core/app/list(pageNum+1,其他参数不变) | hasMore=true → 追加下一页数据;hasMore=false → 不再触发请求 | 底部显示加载 Spinner,新数据追加到列表末尾,Spinner 消失。若已无更多数据,底部不再显示加载指示器 | + +#### 步骤 4:存在文件夹时,加载文件夹详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动触发)进入 URL 带 parentId 的页面 | GET /core/app/detail?appId={parentId};GET /core/app/folder/path?parentId={parentId} | parentId 存在 → 并行请求详情和面包屑;parentId 为空 → 不发起请求 | 面包屑导航栏显示完整路径(根→...→当前文件夹);PC 端右侧展示 FolderSlideCard 面板,含文件夹名称/简介/操作按钮/协作者列表 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/app/list | parentId, type: [toolFolder, workflowTool, mcpToolSet, httpToolSet], pageNum=1, pageSize=默认 | 按创建时间排序 | 工具卡片网格第一页 | +| 翻页 | POST /core/app/list | pageNum=N, 其他同上 | 追加到现有列表 | 追加第N页卡片 | +| 文件夹详情 | GET /core/app/detail | appId=parentId | 提取 name/intro/permission 等字段 | 右侧面板展示 | +| 面包屑路径 | GET /core/app/folder/path | parentId | 转换为 {parentId, parentName}[] 数组 | 顶部面包屑导航 | + +- 分页参数:通过 useInfiniteScroll 管理,默认每页数量由后端控制 +- 筛选条件:搜索关键词和类型筛选均作为 API 参数传递,搜索词经 500ms 防抖后发送 +- 轮询刷新:无自动轮询,仅用户主动操作(创建/编辑/删除后)触发 loadMyApps 刷新 + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 进入 /dashboard/tool + UI->>API: POST /core/app/list (page=1, type=[toolFolder,workflowTool,mcpToolSet,httpToolSet]) + API-->>UI: 返回工具列表 + hasMore + UI-->>User: 渲染工具卡片网格 + + alt 有 parentId + par 并行请求 + UI->>API: GET /core/app/detail?appId=parentId + API-->>UI: 返回文件夹详情 + and + UI->>API: GET /core/app/folder/path?parentId=parentId + API-->>UI: 返回面包屑路径 + end + UI-->>User: 渲染面包屑 + 文件夹详情侧边面板 + end + + User->>UI: 滚动到底部 + UI->>API: POST /core/app/list (page=N+1) + API-->>UI: 返回下一页数据 + UI-->>User: 追加更多工具卡片 +``` + +--- + +### S02:切换工具类型筛选 + +> 用户在工具列表页通过 MyTabBar 切换类型筛选标签(全部 / 工作流工具 / HTTP 工具集 / MCP 工具集),列表刷新显示对应类型的工具。 + +#### 步骤 1:点击类型筛选标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击类型筛选标签(如"工作流工具") | POST /core/app/list(type=选中的工具类型,pageNum=1,重置列表) | 选择"全部" → type 不传具体过滤器,覆盖全部工具类型;选择具体类型 → type 传单一类型值 | 当前标签变为激活态(蓝色边框+背景),之前的列表清空,显示加载 Spinner,加载完成后展示筛选后的工具卡片 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击类型筛选标签 + UI->>UI: 更新 router.query.type + UI->>API: POST /core/app/list (page=1, type=选中的类型) + API-->>UI: 返回筛选后的工具列表 + UI-->>User: 刷新工具卡片网格(仅显示对应类型) +``` + +--- + +### S03:搜索工具 + +> 用户在搜索框输入工具名称关键词,页面自动触发服务端搜索,结果实时更新。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词(如"客服") | POST /core/app/list(searchKey=输入值,pageNum=1) | 防抖 500ms → 关键词变化后等待 500ms 才发请求;关键词为空 → 恢复显示全部工具;连续输入 → 每次输入重置防抖计时 | 输入过程中不立即搜索;500ms 无输入后发送请求,列表显示加载状态,完成后展示匹配工具。空结果时显示"未找到相关工具"空状态提示 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 搜索框 + 列表 + participant API as 后端 API + + User->>UI: 输入搜索关键词 + UI->>UI: 启动 500ms 防抖计时 + Note over UI: 等待 500ms 无新输入 + UI->>API: POST /core/app/list (searchKey=关键词, page=1) + API-->>UI: 返回匹配结果 + UI-->>User: 刷新工具列表 +``` + +--- + +### S04:创建文件夹 + +> 用户创建新文件夹用于组织管理工具。需要填写名称和简介,支持上传自定义头像。 + +#### 步骤 1:打开创建文件夹弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建文件夹"按钮 | — | 当前为根目录且 userInfo.team.permission.hasAppCreatePer 为 true → 按钮可见;在文件夹中且 folderDetail.permission.hasWritePer 为 true → 按钮可见;权限不足 → 按钮隐藏 | 弹出 EditFolderModal,包含名称输入框、简介输入框、头像上传区域 | + +#### 步骤 2:填写文件夹信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入文件夹名称(必填)、简介(选填);可选点击头像上传自定义图片;点击"确认"按钮 | POST /core/app/folder/create(name, intro, avatar, parentId, type='toolFolder') | 名称为空 → 提示必填;名称合法 → 提交请求;请求失败 → 显示错误 Toast("Error") | 确认按钮显示加载状态(isLoading);成功后关闭弹窗,列表自动刷新显示新文件夹 | + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | 是 | — | 最大长度有限制 | 否 | 文件夹显示名称 | +| 简介 | 文本输入 | 否 | — | — | 否 | 文件夹描述,显示在详情面板 | +| 头像 | 图片上传 | 否 | 默认文件夹图标 | 通过预签名 URL 上传 | 否 | 文件夹头像,点击触发文件选择器 | + +#### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称必填 | 提交时 | 根据组件校验提示 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击"新建文件夹" + UI-->>User: 弹出 EditFolderModal + User->>UI: 填写名称、简介,可选上传头像 + User->>UI: 点击"确认" + UI->>API: POST /core/app/folder/create (name, intro, avatar, parentId, type=toolFolder) + alt 创建成功 + API-->>UI: 返回新文件夹ID + UI->>API: POST /core/app/list (刷新列表) + API-->>UI: 返回更新后的列表 + UI-->>User: 关闭弹窗,列表展示新文件夹 + else 创建失败 + API-->>UI: 返回错误 + UI-->>User: 显示错误 Toast,弹窗保持打开 + end +``` + +--- + +### S05:创建工具 + +> 用户创建新的 AI 工具,支持三种类型:工作流工具、HTTP 工具集、MCP 工具集。每种类型有不同的创建流程和表单字段。 + +#### 步骤 1:打开创建工具菜单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建工具"下拉按钮(主按钮,蓝色) | — | 有创建权限时按钮可见(同 S04 权限判断);无权限 → 按钮隐藏 | 弹出 MyMenu 下拉菜单,显示三个工具类型选项和导入 JSON 选项 | + +#### 步骤 2:选择工具类型,弹出创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在下拉菜单中选择一种工具类型(工作流工具/HTTP工具集/MCP工具集) | — | 选择工作流工具 → 显示模板列表 + "空白创建"入口;选择 HTTP 工具集 → 显示创建类型选择(批量/手动);选择 MCP 工具集 → 显示 MCP URL 输入 + 工具列表解析 | 弹出 ToolModal,根据所选类型渲染不同的表单内容 | + +#### 步骤 3A:创建工作流工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入工具名称;可选选择模板列表中的模板卡片,或点击"空白创建"卡片 | 选择模板后 → GET /core/app/template/detail(获取模板详情);确认后 → POST /core/app/create(含 modules/edges/chatConfig) | 选择模板 → 使用模板的工作流节点和边创建;空白创建 → 使用空工作流模板 | 确认按钮显示加载状态;创建成功后自动跳转到 /app/detail?appId=新工具ID 进行工作流编排 | + +#### 步骤 3B:创建 HTTP 工具集 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入工具名称;选择创建类型(批量导入/手动编写) | POST /core/app/httpTool/create(name, avatar, parentId, createType) | 选择批量 → createType='batch';选择手动 → createType='manual' | 确认按钮显示加载;成功后跳转应用详情页 | + +#### 步骤 3C:创建 MCP 工具集 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入工具名称;填写 MCP 服务器 URL;可选配置认证头;点击"解析"按钮获取工具列表 | 解析 → POST /core/app/mcpTool/list(url, headerSecret);创建 → POST /core/app/mcpTool/create(url, headerSecret, toolList) | MCP URL 为空 → 确认按钮不可点击;工具列表为空 → 确认按钮置灰(isDisabled);解析失败 → 显示错误 Toast("MCP 工具解析失败");解析成功 → 表格列出工具名称和描述 | 点击"解析"→ 按钮显示加载;解析成功后工具列表表格填充数据;确认按钮在工具列表为空时禁用 | + +#### 表单字段清单(工作流工具) + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 名称 | 文本输入 | 是 | — | 名称不可为空 | 工具显示名称 | +| 头像 | 图片上传 | 否 | 类型默认图标 | 通过预签名 URL 上传 | 可自定义头像 | +| 模板 | 模板卡片选择 | 否 | — | 从模板市场快速模板列表选择 | 选择模板后使用模板的工作流结构 | + +#### 表单字段清单(HTTP 工具集) + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 名称 | 文本输入 | 是 | — | — | 工具集名称 | +| 创建类型 | 单选按钮组 | 是 | 批量 | 批量 / 手动 | 批量通过 URL 导入,手动逐条编写 | + +#### 表单字段清单(MCP 工具集) + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 名称 | 文本输入 | 是 | — | — | 工具集名称 | +| MCP URL | 文本输入 | 是 | — | 有效 MCP 服务地址 | 点击"解析"后连接 MCP 服务获取工具列表 | +| 认证头 | 键值对配置 | 否 | {} | 通过 HeaderAuthForm 组件配置 | MCP 服务的认证信息 | +| 工具列表 | 只读表格 | 自动填充 | [] | 解析后自动填充 | 显示 name 和 description | + +#### 字段联动(MCP 工具集) + +- 当 MCP URL 已填写时,"解析"按钮才可点击 +- 解析成功后,工具列表表格自动填充数据 +- 工具列表为空时,确认创建按钮置灰不可点击 + +#### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称必填 | 提交时(所有类型)| "应用名称不能为空" | +| MCP URL 必填 | 提交时(MCP 类型)| "MCP 工具 URL 不能为空" | +| MCP 工具解析失败 | 解析时 | "MCP 工具解析失败" | +| 创建失败 | 提交后 | "创建失败" | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击"新建工具"→选择类型 + UI-->>User: 弹出 ToolModal + + alt 工作流工具 + UI->>API: GET /core/app/template/list (isQuickTemplate=true) + API-->>UI: 返回模板列表 + User->>UI: 输入名称,可选选择模板 + User->>UI: 点击确认 + UI->>API: POST /core/app/create (name, type=plugin, modules, edges, chatConfig) + API-->>UI: 返回新工具ID + UI-->>User: 跳转 /app/detail?appId=新工具ID + else HTTP 工具集 + User->>UI: 输入名称,选择创建类型 + User->>UI: 点击确认 + UI->>API: POST /core/app/httpTool/create (name, createType, parentId) + API-->>UI: 返回新工具ID + UI-->>User: 跳转应用详情页 + else MCP 工具集 + User->>UI: 输入名称、MCP URL、可选认证头 + User->>UI: 点击"解析" + UI->>API: POST /core/app/mcpTool/list (url, headerSecret) + API-->>UI: 返回工具列表 + UI-->>User: 表格展示工具名称和描述 + User->>UI: 点击确认 + UI->>API: POST /core/app/mcpTool/create (url, headerSecret, toolList) + API-->>UI: 返回新工具ID + UI-->>User: 跳转应用详情页 + end +``` + +--- + +### S06:编辑工具/文件夹信息 + +> 用户修改工具或文件夹的名称、简介和头像。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 操作工具 → 工具卡片更多菜单(⋮)→ 点击"编辑";操作文件夹 → 文件夹详情面板 → 点击编辑按钮 | — | 权限不足 → 编辑选项隐藏 | 弹出 EditResourceModal(工具)或 EditFolderModal(文件夹),表单预填当前名称、简介、头像 | + +#### 步骤 2:修改并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/简介/头像 → 点击保存 | PUT /core/app/update?appId={id}(name, intro, avatar) | 名称未修改 → 仍提交原值;上传新头像 → 先获取预签名 URL 上传图片,再提交更新 | 保存按钮显示加载状态;成功后关闭弹窗,列表和详情面板自动刷新显示新信息 | + +#### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称不可为空 | 提交时 | "应用名称不能为空" | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击编辑按钮 + UI-->>User: 弹出编辑弹窗(预填现有数据) + User->>UI: 修改名称/简介/头像 + User->>UI: 点击保存 + UI->>API: PUT /core/app/update?appId=id (name, intro, avatar) + alt 更新成功 + API-->>UI: 成功 + par 并行刷新 + UI->>API: GET /core/app/detail(刷新文件夹详情) + UI->>API: POST /core/app/list(刷新列表) + end + UI-->>User: 关闭弹窗,列表/面板更新 + else 更新失败 + API-->>UI: 错误 + UI-->>User: 显示错误 Toast + end +``` + +--- + +### S07:移动工具/文件夹 + +> 用户将工具或文件夹移动到另一个目标文件夹。 + +#### 步骤 1:打开移动弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 工具卡片更多菜单 → 点击"移动";或文件夹详情面板 → 点击移动按钮 | — | 权限不足 → 移动选项隐藏 | 弹出 MoveModal,显示文件夹树供选择目标位置 | + +#### 步骤 2:选择目标文件夹并确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件夹树中选择目标文件夹 → 点击确认 | PUT /core/app/update?appId={id}(parentId=目标文件夹ID) | 目标与当前位置相同 → 无变化;目标不同 → 发起移动请求 | 确认按钮显示加载状态;成功后关闭弹窗,列表从当前位置移除该条目,若在详情面板打开状态则跳转回上级 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击移动 + UI-->>User: 弹出 MoveModal(文件夹树) + User->>UI: 选择目标文件夹 → 确认 + UI->>API: PUT /core/app/update?appId=id (parentId=目标ID) + alt 成功 + API-->>UI: 成功 + UI->>API: POST /core/app/list(刷新列表) + UI-->>User: 关闭弹窗,条目从列表中移除 + else 失败 + API-->>UI: 错误 + UI-->>User: 显示错误 Toast + end +``` + +--- + +### S08:删除工具/文件夹 + +> 用户删除指定工具或文件夹。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 工具卡片更多菜单 → 点击"删除";或文件夹详情面板 → 点击删除按钮 | — | 仅拥有者或管理员可见删除选项;无权限 → 隐藏 | 弹出确认弹窗(DelConfirmModal / PopoverConfirm),显示删除提示文案 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击"确认删除" | DELETE /core/app/del?appId={id} | 删除文件夹 → 同时清除该文件夹下所有子项的 localStorage 缓存(app_log_keys_{appId});在当前文件夹详情面板中删除且文件夹有父级 → 删除后跳转到上级文件夹 | 确认按钮显示加载;成功后弹窗关闭,列表刷新,条目消失。若当前处于被删除文件夹的详情面板且为 PC 端 → 自动路由跳转到上级文件夹 | + +#### 删除链路详情 + +- **确认弹窗**:PopoverConfirm 组件,提示文案通过 `deleteTip` prop 传入(如"确认删除此文件夹?") +- **单条删除**:调用 delAppById 传入 appId,成功后清除 localStorage 中的日志缓存 +- **级联影响**:删除文件夹后,文件夹下的所有工具和子文件夹一同删除;详情面板自动收起,路由回退到上级文件夹 + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击删除 + UI-->>User: 弹出确认弹窗 + User->>UI: 确认删除 + UI->>API: DELETE /core/app/del?appId=id + alt 删除成功 + API-->>UI: 成功 + UI->>UI: 清除 localStorage 缓存 + UI->>API: POST /core/app/list(刷新列表) + API-->>UI: 返回更新后的列表 + alt 在文件夹详情面板中 + UI-->>User: 路由跳转至上级文件夹 + else 在列表视图中 + UI-->>User: 条目从列表中消失 + end + else 删除失败 + API-->>UI: 错误 + UI-->>User: 显示错误 Toast + end +``` + +--- + +### S09:管理协作者权限 + +> 用户为文件夹或工具配置协作者及其权限级别,或恢复权限继承。 + +#### 步骤 1:查看和修改协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件夹详情面板 → 协作者区域 → 点击设置按钮 | POST /proApi/core/app/collaborator/list(appId) | feConfigs.isPlus 为 false → 协作者区域不显示;无管理权限 → 设置按钮隐藏 | 弹出 ConfigPerModal,显示现有协作者列表(MemberListCard),每项显示用户名和权限级别 | + +#### 步骤 2:添加或修改协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 搜索用户 → 选择权限级别(只读/编辑/管理)→ 添加 | POST /proApi/core/app/collaborator/update(appId, members) | — | 新协作者出现在列表中 | + +#### 步骤 3:移除协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击协作者旁的删除按钮 | DELETE /proApi/core/app/collaborator/delete(appId, memberId) | — | 该协作者从列表中移除 | + +#### 步骤 4:恢复权限继承 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"恢复权限继承"按钮 | GET /core/app/resumeInheritPermission?appId={id} | isInheritPermission 为 true → 不显示该按钮(已处于继承状态);hasParent 为 false → 不显示(根级无父级可继承) | 成功后权限恢复为从父文件夹继承,协作者列表更新 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 文件夹详情面板 + participant API as 后端 API + + User->>UI: 点击协作者设置 + UI->>API: POST /proApi/core/app/collaborator/list (appId) + API-->>UI: 返回协作者列表 + UI-->>User: 弹出 ConfigPerModal + + alt 添加/修改协作者 + User->>UI: 搜索用户→选择权限→添加 + UI->>API: POST /proApi/core/app/collaborator/update (appId, members) + API-->>UI: 成功 + UI-->>User: 协作者列表更新 + else 移除协作者 + User->>UI: 删除协作者 + UI->>API: DELETE /proApi/core/app/collaborator/delete (appId, memberId) + API-->>UI: 成功 + UI-->>User: 协作者从列表中移除 + else 恢复权限继承 + User->>UI: 点击"恢复权限继承" + UI->>API: GET /core/app/resumeInheritPermission?appId=id + API-->>UI: 成功 + UI-->>User: 权限恢复继承,协作者列表刷新 + end +``` + +--- + +### S10:复制工具 + +> 用户创建工具的完整副本。 + +#### 步骤 1:触发复制 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 工具卡片更多菜单 → 点击"复制" | — | — | 弹出确认弹窗(ConfirmCopyModal) | + +#### 步骤 2:确认复制 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 确认复制 | POST /core/app/copy(appId) | — | 按钮显示加载;成功后弹窗关闭,列表刷新,新工具副本出现在列表中 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 工具卡片→复制 + UI-->>User: 弹出确认弹窗 + User->>UI: 确认复制 + UI->>API: POST /core/app/copy (appId) + API-->>UI: 返回新工具副本 + UI->>API: POST /core/app/list(刷新) + UI-->>User: 新工具副本出现在列表中 +``` + +--- + +### S11:导入 JSON 配置 + +> 用户从 JSON 文件导入工作流配置创建新工具。 + +#### 步骤 1:打开导入弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| "新建工具"下拉 → "导入 JSON 配置";或页面加载时检测到会话存储中的工作流 URL | — | 有工作流 URL → 挂载时自动打开弹窗(useMount 内 getUtmWorkflow() 检测);无 → 手动点击触发 | 弹出 JsonImportModal,提供 JSON 配置导入界面 | + +#### 步骤 2:选择/上传 JSON 配置文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择本地 JSON 配置文件并确认导入 | 内部调用 postCreateApp(传入从 JSON 解析的 modules, edges, chatConfig) | JSON 格式无效 → 提示格式错误;有效 → 提交创建 | 创建成功后自动跳转到应用详情页 | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具管理页面 + participant API as 后端 API + + User->>UI: 点击"导入 JSON 配置" + UI-->>User: 弹出 JsonImportModal + User->>UI: 选择 JSON 文件 → 确认导入 + UI->>UI: 解析 JSON 配置(modules, edges, chatConfig) + UI->>API: POST /core/app/create (从JSON解析的参数) + API-->>UI: 返回新工具ID + UI-->>User: 跳转 /app/detail?appId=新工具ID +``` + +--- + +### S12:导航到文件夹 + +> 用户通过面包屑导航进入特定文件夹,查看该文件夹下的工具。 + +#### 步骤 1:点击面包屑路径 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击面包屑路径中某一级(如"根 → 我的工具集"中的"根") | POST /core/app/list(parentId=点击层级的ID);GET /core/app/detail(新的 parentId);GET /core/app/folder/path(新的 parentId) | 点击最后一级(当前文件夹)→ forbidLastClick=true,不可点击;点击中间级 → 跳转到对应层级 | 路由 query 更新 parentId → AppListContextProvider 检测变化后发起新请求 → 列表和详情面板刷新。面包屑中超过3级则中间部分折叠为"...",hover 展开下拉菜单 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..daa838b4b4d9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: 工具管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:15:00.000Z" +parent_module: 工作台 +roles: [团队所有者, 管理员, 编辑者, 只读成员] +router_paths: [/dashboard/tool] +--- + +# 工具管理 — 模块概览 + +## 模块业务概述 + +工具管理是工作台下的核心模块之一,为用户提供 AI 工具的集中管理能力。用户可在此创建、查看和管理三类工具:**工作流工具**(基于可视化工作流编排的自定义工具)、**HTTP 工具集**(通过 HTTP 请求调用外部 API 的工具集)和 **MCP 工具集**(基于 Model Context Protocol 的标准化工具集成)。 + +本模块是工具生命周期管理的入口,支持文件夹层级组织、搜索筛选、权限协作等能力。创建的工具有效后可被 Agent 应用在对话中调用,支撑智能体的能力扩展。 + +本模块为工作台下的一级能力节点,无子能力。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队所有者 | 全部 | 创建/查看/编辑/删除/移动工具和文件夹,管理协作者权限,恢复权限继承 | 拥有完整管理权限,可删除文件夹 | +| 管理员 | 全部 | 创建/查看/编辑/移动工具和文件夹,管理协作者权限,恢复权限继承 | 与所有者类似,部分删除操作受限制 | +| 编辑者 | 全部 | 查看工具列表,在有写入权限的文件夹内创建/编辑工具和文件夹 | 仅在有写入权限的文件夹内可创建和编辑 | +| 只读成员 | 全部(查看) | 查看工具列表和文件夹详情 | 无创建/编辑/删除权限,仅可浏览 | + +**权限模型**:基于位掩码的 RBAC 权限(读=0b100, 写=0b010, 管理=0b001),每个文件夹和应用独立控制权限。支持权限继承和协作者管理。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用工作台 | 复用 AppListContextProvider、List 组件、文件夹导航系统 | 组件/Context | +| 系统工具 | 通过 MyTabs 导航到系统工具列表页 | 页面导航 | +| 模板市场 | 创建工作流工具时可跳转选择模板 | 页面导航 | +| 应用详情 | 创建工具成功后跳转至应用详情页进行工作流编排 | 页面导航 | + +## 对外共享 + +本模块主要作为消费者,复用应用工作台的基础设施(上下文管理、列表组件、文件夹系统),自身不对外提供独有的共享能力。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 工具创建入口 | 应用详情(Agent 配置时可选择工具) | 数据引用 | + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 我的工具 | /dashboard/tool | projects/app/src/pages/dashboard/tool/index.tsx | 全部 | 工具管理主页面,含文件夹导航、类型筛选、搜索、列表展示和工具创建 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..222212b6f750 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,172 @@ +--- +capability_label: "应用工作台" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:30:00Z" +parent_module: "工作台" +roles: ["团队管理员", "应用创建者", "应用协作者", "只读成员"] +router_paths: ["/dashboard/agent"] +--- + +# 应用工作台 — 业务流程索引 + +## 场景关系 + +- **浏览应用列表** → **筛选/搜索应用** → **进入文件夹** → **查看文件夹详情** — 逐层深入的导航流程 +- **浏览应用列表** → **创建应用** — 从列表页直接创建 +- **浏览应用列表** → **导入 JSON 配置** — 通过菜单导入 +- **浏览应用列表** → **从模板创建** — 跳转至模板市场 +- **查看应用卡片** → **编辑信息 / 复制 / 删除 / 移动 / 导出 Skill / 权限管理** — 卡片右键菜单操作 +- **查看应用卡片** → **进入应用详情** — 点击有写权限的应用卡片 +- **查看应用卡片** → **进入聊天** — 点击只读应用或通过菜单"去聊天" + +各场景独立执行,无严格先后关系。 + +## 业务场景列表 + +### 浏览和筛选应用列表 + +- **业务描述**: 用户进入应用工作台后,默认展示当前文件夹下的所有应用卡片,支持按应用类型筛选和按名称搜索。 +- **触发入口**: 侧边栏 → "应用构建" → "应用" +- **路由路径**: `/dashboard/agent` +- **页面组件位置**: `projects/app/src/pages/dashboard/agent/index.tsx` +- **涉及角色**: 所有角色 +- **前置条件**: 用户已登录,团队空间已初始化 + +### 文件夹导航 + +- **业务描述**: 用户点击文件夹卡片进入子文件夹,通过面包屑路径在各层级间导航,支持点击面包屑返回上级。 +- **触发入口**: 应用列表中的文件夹卡片 +- **路由路径**: `/dashboard/agent?parentId={folderId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/agent/index.tsx`(FolderPath 组件) +- **涉及角色**: 所有角色 +- **前置条件**: 当前文件夹下存在子文件夹 + +### 查看文件夹详情 + +- **业务描述**: 进入文件夹后,右侧滑出文件夹详情面板,展示名称、描述、权限信息,支持编辑、移动、删除文件夹操作。 +- **触发入口**: 进入任意文件夹 +- **路由路径**: `/dashboard/agent?parentId={folderId}` +- **页面组件位置**: `projects/app/src/components/common/folder/SlideCard.tsx` +- **涉及角色**: 具有写权限的用户(编辑/删除)或所有者(权限管理) +- **前置条件**: 当前处于文件夹内部(parentId 非空) + +### 创建应用 + +- **业务描述**: 用户通过"新建应用"按钮选择应用类型(智能问答/Chat Agent/工作流机器人),弹出创建弹窗选择模板并创建。 +- **触发入口**: 应用列表页顶部"新建应用"下拉菜单 → 选择类型 +- **路由路径**: `/dashboard/agent`(弹窗模式,不跳转) +- **页面组件位置**: `projects/app/src/pages/dashboard/create/CreateModal.tsx` +- **涉及角色**: 具有 hasAppCreatePer 权限的用户 +- **前置条件**: 用户团队具有应用创建权限;若在文件夹内,需文件夹具有写权限且非 HTTP 插件类型文件夹 + +### 创建文件夹 + +- **业务描述**: 用户创建新文件夹用于组织应用,输入文件夹名称和描述后提交。 +- **触发入口**: 应用列表页顶部"新建文件夹"按钮 +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `@fastgpt/web/components/common/MyModal/EditFolderModal` +- **涉及角色**: 具有写权限的用户 +- **前置条件**: 若在文件夹内,需该文件夹具有写权限 + +### 搜索应用 + +- **业务描述**: 用户在搜索框中输入应用名称,列表实时过滤匹配的应用。 +- **触发入口**: 应用列表页顶部搜索框 +- **路由路径**: `/dashboard/agent`(不跳转) +- **页面组件位置**: `@fastgpt/web/components/common/Input/SearchInput` +- **涉及角色**: 所有角色 +- **前置条件**: 无 + +### 编辑应用信息 + +- **业务描述**: 用户通过应用卡片的菜单"编辑信息"修改应用的名称、头像、描述。 +- **触发入口**: 应用卡片 → 更多菜单 → "编辑信息" +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `projects/app/src/components/common/Modal/EditResourceModal` +- **涉及角色**: 具有 hasWritePer 或 hasReadChatLogPer 权限的用户 +- **前置条件**: 应用卡片菜单按钮可见(需有相应权限) + +### 复制应用 + +- **业务描述**: 用户复制一个已有应用,系统创建副本并跳转至新应用的详情页。 +- **触发入口**: 应用卡片 → 更多菜单 → "复制应用" +- **路由路径**: `/dashboard/agent` → 成功后跳转 `/app/detail?appId={newAppId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/agent/AppCard.tsx`(菜单触发) +- **涉及角色**: 具有写权限的用户 +- **前置条件**: 应用类型非文件夹、MCP工具集、HTTP工具集、HTTP插件 + +### 删除应用/文件夹 + +- **业务描述**: 用户删除应用或文件夹,需输入确认文本。文件夹删除会级联删除所有子内容。 +- **触发入口**: 应用卡片 → 更多菜单 → "删除"(红色危险按钮) +- **路由路径**: `/dashboard/agent` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/agent/AppCard.tsx`(菜单触发) +- **涉及角色**: 应用所有者 +- **前置条件**: 工具类应用需关联应用数为 0;文件夹删除无引用限制但需确认 + +### 移动应用 + +- **业务描述**: 用户将应用/文件夹移动到其他文件夹中。 +- **触发入口**: 应用卡片 → 更多菜单 → "移动到" +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `projects/app/src/components/common/folder/MoveModal` +- **涉及角色**: 具有管理权限的用户 +- **前置条件**: 应用存在管理权限 + +### 导出应用为 Skill + +- **业务描述**: 将 Agent 类应用导出为 Skill 压缩包文件,可用于分享或导入到其他团队。 +- **触发入口**: 应用卡片 → 更多菜单 → "导出为 Skill" +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `projects/app/src/components/core/app/ExportSkillModal` +- **涉及角色**: 具有写权限的用户 +- **前置条件**: 应用类型属于 Agent 类(非文件夹、非工具类) + +### 导入 JSON 配置 + +- **业务描述**: 用户导入 JSON 格式的应用配置文件,快速创建工作流应用。若会话存储中有工作流 URL,页面加载时自动弹出导入弹窗。 +- **触发入口**: "新建应用"下拉菜单 → "导入 JSON 配置";或页面加载时自动触发 +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/agent/JsonImportModal.tsx` +- **涉及角色**: 具有应用创建权限的用户 +- **前置条件**: 用户团队具有应用创建权限 + +### 从模板创建 + +- **业务描述**: 用户跳转到模板市场,从模板库中选择模板来创建应用。 +- **触发入口**: "新建应用"下拉菜单 → "从模板创建" +- **路由路径**: `/dashboard/agent` → 跳转到 `/dashboard/templateMarket` +- **页面组件位置**: 路由跳转 +- **涉及角色**: 所有角色 +- **前置条件**: 无 + +### 权限管理 + +- **业务描述**: 应用/文件夹所有者管理协作者及其角色权限,支持添加、修改、删除协作者。 +- **触发入口**: 应用卡片 → 更多菜单 → "权限管理";文件夹详情面板 → 权限设置 +- **路由路径**: `/dashboard/agent`(弹窗模式) +- **页面组件位置**: `projects/app/src/components/support/permission/ConfigPerModal` +- **涉及角色**: 具有管理权限的用户 +- **前置条件**: 应用/文件夹存在管理权限 + +--- + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览和筛选应用列表 | 侧边栏→应用构建→应用 | /dashboard/agent | 所有角色 | +| S02 | 文件夹导航 | 应用列表→点击文件夹 | /dashboard/agent?parentId={id} | 所有角色 | +| S03 | 查看文件夹详情 | 进入文件夹→右侧面板 | /dashboard/agent?parentId={id} | 所有角色 | +| S04 | 创建应用 | 顶部→新建应用→选择类型 | /dashboard/agent(弹窗) | hasAppCreatePer | +| S05 | 创建文件夹 | 顶部→新建文件夹 | /dashboard/agent(弹窗) | hasWritePer | +| S06 | 搜索应用 | 顶部搜索框 | /dashboard/agent | 所有角色 | +| S07 | 编辑应用信息 | 卡片菜单→编辑信息 | /dashboard/agent(弹窗) | hasWritePer | +| S08 | 复制应用 | 卡片菜单→复制应用 | /app/detail(跳转) | hasWritePer | +| S09 | 删除应用/文件夹 | 卡片菜单→删除 | /dashboard/agent | isOwner | +| S10 | 移动应用 | 卡片菜单→移动到 | /dashboard/agent(弹窗) | hasManagePer | +| S11 | 导出应用为 Skill | 卡片菜单→导出为Skill | /dashboard/agent(弹窗) | hasWritePer | +| S12 | 导入 JSON 配置 | 新建应用菜单→导入 | /dashboard/agent(弹窗) | hasAppCreatePer | +| S13 | 从模板创建 | 新建应用菜单→从模板创建 | /dashboard/templateMarket | 所有角色 | +| S14 | 权限管理 | 卡片菜单→权限管理 | /dashboard/agent(弹窗) | hasManagePer | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..eb9b826648b0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,324 @@ +--- +capability_label: "应用工作台" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:30:00Z" +parent_module: "工作台" +roles: ["团队管理员", "应用创建者", "应用协作者", "只读成员"] +router_paths: ["/dashboard/agent"] +--- + +# 应用工作台 — 业务流程详解 + +## 页面总览 + +应用工作台是用户管理所有 AI 应用的中心页面。页面左侧为 DashboardContainer 提供的侧边栏导航,主内容区默认展示应用卡片网格,顶部提供类型筛选标签、搜索框和操作按钮(新建文件夹、新建应用)。进入文件夹后,右侧滑出文件夹详情面板。 + +本页面无 Tab 子页面拆分——所有功能在单一页面内通过弹窗和路由参数切换完成。 + +--- + +## 浏览和筛选应用列表 + +> **业务描述**: 用户进入应用工作台后查看当前层级下的应用列表,可通过类型标签筛选和名称搜索定位目标应用。 + +### 步骤 1:页面初始化加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过侧边栏点击"应用"或直接访问 `/dashboard/agent` | POST `/core/app/list`(getMyAppsPaginated) | parentId 从 URL query 中读取,默认 null;type 默认 'all' | 页面显示背景装饰,应用列表区域显示加载中状态(MyBox 的 isLoading),加载完成后展示应用卡片网格;若无应用且不在文件夹内,显示创建引导卡片 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/app/list | pageNum=1, pageSize=默认分页, parentId, type=formatType, searchKey | useInfiniteScroll 管理列表与分页 | 应用卡片网格 | +| 滚动加载更多 | POST /core/app/list | pageNum=N+1, pageSize=默认分页, 其余参数同上 | 追加到现有列表末尾 | 列表底部显示 Spinner 加载指示器 | +| 刷新列表 | POST /core/app/list | pageNum=1(重置) | 完全替换列表数据 | 列表重新渲染 | + +- 分页机制:基于 IntersectionObserver 的无限滚动(useInfiniteScroll),底部哨兵元素触发自动加载 +- 排序规则:默认由服务端排序 +- 筛选条件:类型标签(type 参数)过滤;搜索词经 500ms 防抖后生效 + +### 步骤 2:类型筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部 MyTabs 标签(全部/智能问答/Chat Agent/工作流) | POST `/core/app/list`(自动触发) | 选择非"全部"时,formatType 变为 `[AppTypeEnum.folder, 所选type]` | URL query 更新 `type` 参数;列表重新加载,仅显示对应类型的应用;当前标签高亮 | + +### 步骤 3:搜索应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入应用名称 | POST `/core/app/list`(500ms 防抖后触发) | searchKey 非空时作为请求参数发送 | 输入过程中列表不变;防抖等待结束后重新加载列表;搜索无结果时显示空状态提示 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 应用工作台页面 + participant API as 后端API + participant B as 后端服务 + + U->>UI: 进入应用工作台 + UI->>API: POST /core/app/list (pageNum=1, parentId, type) + API->>B: 查询应用列表 + B-->>API: 应用数据 + 总数 + API-->>UI: 返回列表 + UI-->>U: 展示应用卡片网格 + + U->>UI: 点击类型标签 + UI->>API: POST /core/app/list (pageNum=1, type=新类型) + API-->>UI: 过滤后的列表 + UI-->>U: 更新卡片展示 + + U->>UI: 输入搜索关键词 + UI->>UI: 500ms 防抖等待 + UI->>API: POST /core/app/list (searchKey=关键词) + API-->>UI: 搜索结果 + UI-->>U: 更新卡片展示 + + U->>UI: 滚动到底部 + UI->>API: POST /core/app/list (pageNum=N+1) + API-->>UI: 下一页数据 + UI-->>U: 追加新卡片 +``` + +--- + +## 文件夹导航 + +> **业务描述**: 用户点击文件夹卡片进入子文件夹,通过面包屑路径导航层级。 + +### 步骤 1:进入文件夹 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件夹类型应用卡片 | GET `/core/app/folder/path`(并行);POST `/core/app/list`(并行);GET `/core/app/detail`(仅 parentId 存在时) | 应用 type 属于 AppFolderTypeList | URL 更新 parentId 参数;面包屑路径更新;应用列表重新加载当前文件夹内容;右侧滑出文件夹详情面板 | + +### 步骤 2:通过面包屑返回上级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击面包屑中的上级路径 | 同上(重新加载) | 目标 parentId 不等于当前 | URL 更新为上级 parentId;面包屑缩短;列表刷新;若返回根级,文件夹详情面板消失 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 应用工作台页面 + participant API as 后端API + + U->>UI: 点击文件夹卡片 + par 并行请求 + UI->>API: GET /core/app/folder/path (sourceId) + API-->>UI: 面包屑路径数据 + and + UI->>API: POST /core/app/list (parentId=文件夹ID) + API-->>UI: 子文件夹和应用列表 + and + UI->>API: GET /core/app/detail (appId=文件夹ID) + API-->>UI: 文件夹详情 + end + UI-->>U: 更新面包屑 + 子内容卡片 + 右侧面板 + + U->>UI: 点击面包屑上级 + UI->>API: 重新请求上级数据 + API-->>UI: 上级内容 + UI-->>U: 回到上级目录 +``` + +--- + +## 创建应用 + +> **业务描述**: 用户通过新建应用菜单选择类型后,弹出创建弹窗,选择模板后创建应用。 + +### 步骤 1:选择应用类型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建应用"下拉按钮,从菜单中点击具体类型(智能问答/Chat Agent/工作流) | 无 | 在文件夹内时:folderDetail.permission.hasWritePer 为 true 且 folderDetail.type 非 httpPlugin;在根级时:userInfo.team.permission.hasAppCreatePer 为 true | 按钮可用状态取决于权限条件;点击后设置 createAppType 状态,触发 CreateModal 弹窗 | + +### 步骤 2:在创建弹窗中完成创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 CreateModal 中选择模板/输入配置并提交 | 由 CreateModal 内部触发创建 API | 根据选择的 appType 不同,创建不同类型的应用 | 弹窗关闭;调用 loadMyApps 刷新列表;新创建的应用出现在列表中 | + +**后置影响**: 新应用创建成功,可能跳转到应用详情页,列表自动刷新。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 应用工作台页面 + participant Modal as CreateModal弹窗 + participant API as 后端API + + U->>UI: 点击"新建应用"→选择类型 + UI->>UI: 检查权限条件 + UI->>Modal: 打开创建弹窗(setCreateAppType) + U->>Modal: 选择模板/填写配置→提交 + Modal->>API: POST /core/app/create + API-->>Modal: 返回新应用ID + Modal->>UI: 关闭弹窗,触发刷新 + UI->>API: POST /core/app/list (刷新) + API-->>UI: 最新列表 + UI-->>U: 新应用出现在列表中 +``` + +--- + +## 创建文件夹 + +> **业务描述**: 用户创建新文件夹来组织应用。 + +### 步骤 1:打开创建弹窗并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建文件夹"按钮 | 无 | 权限判断同创建应用 | 打开 EditFolderModal 弹窗,空表单 | +| 填写文件夹名称、描述,上传头像(可选),点击确认 | POST `/core/app/folder/create` | — | 弹窗显示提交中状态;成功后弹窗关闭,列表刷新 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | — | — | 否 | 文件夹显示名称 | +| 描述 | 文本输入 | 否 | — | — | 否 | 文件夹说明文字 | +| 头像 | 图片上传 | 否 | 默认图标 | 通过 getUploadAvatarPresignedUrl 获取上传 URL | 否 | 文件夹封面图 | + +**后置影响**: 新文件夹创建成功,列表自动刷新,新文件夹卡片出现在当前位置。 + +--- + +## 编辑应用信息 + +> **业务描述**: 修改应用的基本信息(名称、头像、描述)。 + +### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 悬停应用卡片,点击更多菜单 → "编辑信息" | 无 | 应用 type 为 httpPlugin 时 toast 警告提示已废弃;菜单按钮可见条件:AppFolderTypeList 类型需 hasManagePer,否则需 hasWritePer 或 hasReadChatLogPer | 菜单打开;点击后打开 EditResourceModal,预填当前名称、头像、描述 | + +### 步骤 2:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改字段后点击确认 | PUT `/core/app/update`(通过 onUpdateApp → putAppById) | 仅修改发生变化的字段 | 弹窗关闭;列表自动刷新(loadMyApps、refetchFolderDetail、refetchPaths 并行刷新) | + +--- + +## 复制应用 + +> **业务描述**: 创建应用的完整副本。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击卡片菜单 → "复制应用" | 弹出确认弹窗(ConfirmCopyModal),提示文案为"确认复制该应用?" | 应用不能是文件夹/MCP工具集/HTTP工具集/HTTP插件 | 确认弹窗打开 | +| 确认复制 | POST `/core/app/copy` | — | 成功后自动跳转至新应用详情页 `/app/detail?appId={newAppId}` | + +--- + +## 删除应用/文件夹 + +> **业务描述**: 删除应用或文件夹。文件夹删除会级联删除所有子内容。需要用户输入确认文本。 + +### 删除链路详情 + +- **引用检查**: 工具类应用(非文件夹)删除前检查 `relatedAppCount`(关联应用数)是否为 0。若 > 0,删除按钮置灰并提示"该工具被 N 个应用引用,无法删除"。文件夹删除无引用检查。 +- **确认弹窗**: 弹窗类型为 `delete`(useConfirm)。Agent 类应用提示"确认删除该应用?将同时删除关联的对话记录和配置";文件夹提示"确认删除该文件夹?文件夹内所有应用和子文件夹将被一并删除"。需在输入框中输入应用/文件夹名称以确认。 +- **级联影响**: 删除后从 localStorage 移除对应的 `app_log_keys_{appId}` 缓存;若删除的是文件夹,URL 自动跳转到上级 parentId。 +- **后置影响**: 列表刷新,删除的应用卡片消失。 + +--- + +## 导出应用为 Skill + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击卡片菜单 → "导出为 Skill" | 无 | 应用类型属于 AppTypeList(Agent 类) | 关闭菜单,打开 ExportSkillModal | +| 在弹窗中设置 Skill 名称和描述 → 确认导出 | POST `/core/app/exportSkill`(以 Blob 形式下载) | — | 触发浏览器下载 .zip 文件 | + +--- + +## 移动应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击卡片菜单 → "移动到" | 无 | 需 hasManagePer 权限;文件夹类型需 hasManagePer | 设置 moveAppId,打开 MoveModal 弹窗 | +| 在弹窗中选择目标文件夹 → 确认移动 | PUT `/core/app/update`(更新 parentId) | — | 弹窗关闭;列表和路径刷新 | + +--- + +## 导入 JSON 配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过菜单点击"导入 JSON 配置"或页面加载时检测到 sessionStorage 中的工作流 URL | 无 | URL 中有 utm_workflow 参数时自动弹出 | 打开 JsonImportModal 弹窗 | +| 在弹窗中粘贴 JSON 或上传文件 → 确认导入 | 弹窗内部调用导入 API | — | 弹窗关闭;列表刷新 | + +--- + +## 权限管理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击卡片菜单 → "权限管理" | GET `/core/app/getPermission`(获取协作者列表) | 需 hasManagePer | 打开 ConfigPerModal,展示当前协作者列表和权限配置 | +| 添加/修改协作者 | POST `/core/app/collaborator/update` | — | 协作者列表更新 | +| 删除协作者 | DELETE `/core/app/collaborator/delete` | — | 协作者从列表中移除 | +| 恢复权限继承 | GET `/core/app/resumeInheritPermission` | 文件夹有 parentId 且 inheritPermission 为 false | 恢复到与父文件夹一致的权限设置 | +| 转让所有者 | POST `/proApi/core/app/changeOwner` | 当前用户为 isOwner | 所有者变更,权限面板关闭 | + +--- + +## Mermaid 附录(总览) + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 应用工作台 + participant API as 后端API + + Note over U,API: 页面初始化 + U->>UI: 进入应用工作台 + UI->>API: POST /core/app/list + API-->>UI: 应用列表数据 + + Note over U,API: 创建文件夹 + U->>UI: 点击"新建文件夹" + UI->>API: POST /core/app/folder/create + API-->>UI: 创建成功 + UI->>API: POST /core/app/list (刷新) + API-->>UI: 更新后的列表 + + Note over U,API: 创建应用 + U->>UI: 新建应用→选择类型 + UI->>UI: 打开CreateModal + U->>UI: 选择模板→确认 + UI->>API: POST /core/app/create + API-->>UI: 新应用ID + + Note over U,API: 文件夹导航 + U->>UI: 点击文件夹 + par 并行 + UI->>API: GET /core/app/folder/path + and + UI->>API: POST /core/app/list + and + UI->>API: GET /core/app/detail + end + API-->>UI: 路径+内容+详情 + + Note over U,API: 编辑/删除/复制/移动 + U->>UI: 卡片菜单操作 + UI->>API: PUT/DELETE/POST 对应端点 + API-->>UI: 操作结果 + UI->>API: POST /core/app/list (刷新) + API-->>UI: 更新后的列表 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..3646374db466 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: "应用工作台" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:30:00Z" +parent_module: "工作台" +roles: ["团队管理员", "应用创建者", "应用协作者", "只读成员"] +router_paths: ["/dashboard/agent"] +--- + +# 应用工作台 — 模块概览 + +## 1. 模块业务概述 + +应用工作台是 FastGPT 平台的核心管理入口,用户在此集中管理所有 AI 应用(智能问答助手、Chat Agent、工作流机器人等)。模块提供应用列表的浏览、搜索、分类筛选、文件夹组织以及应用的创建/编辑/删除/复制/导出全生命周期管理。 + +本模块服务于两类主要场景:一是**应用开发者**通过工作台创建和配置 AI 应用,二是**团队管理者**通过文件夹系统和权限协作机制组织团队共享的应用资源。模块通过侧边栏导航"应用构建"分组下的"应用"入口进入。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员(hasAppCreatePer) | 应用列表页、文件夹详情 | 创建应用、创建文件夹、编辑/删除/复制/移动/导出应用、管理成员权限 | 拥有完整的管理权限 | +| 应用创建者/编辑者(hasWritePer) | 应用列表页、应用详情 | 编辑应用信息、移动应用、导出为 Skill | 可编辑和操作自己创建或有写权限的应用 | +| 应用管理者(hasManagePer) | 应用列表页、权限配置面板 | 管理协作者、设置权限角色 | 可管理特定应用的权限分配 | +| 只读成员 | 应用列表页、聊天页面 | 查看应用列表、进入聊天 | 仅可查看和使用有权限的应用,无管理操作 | + +**权限模型**: 基于应用级别的权限控制(RBAC),每个应用/文件夹拥有独立的 `permission` 对象,包含 `hasWritePer`(写权限)、`hasManagePer`(管理权限)、`isOwner`(所有者)等权限位。权限支持继承机制(`inheritPermission`),子文件夹默认继承父文件夹权限设置。权限判断逻辑入口位于 `AppListContext`(`projects/app/src/pageComponents/dashboard/agent/context.tsx`)和 `AppCard` 组件的菜单构建逻辑中。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库(@fastgpt/web) | MyTabs 标签切换、SearchInput 搜索框、MyBox 容器、MyIcon 图标、MyMenu 菜单、EditFolderModal 编辑弹窗、EmptyTip 空状态 | 组件 | +| 应用 API 层(@/web/core/app/api) | getMyAppsPaginated 分页列表、getAppDetailById 详情、putAppById 更新、delAppById 删除、postCopyApp 复制 | API | +| 协作者管理 | getCollaboratorList、postUpdateAppCollaborators、deleteAppCollaborators | API | +| 文件上传服务 | getUploadAvatarPresignedUrl 头像预签名 URL | API | +| 用户系统(useUserStore) | userInfo.team.permission 获取当前用户团队权限 | Store | +| 聊天系统(useChatStore) | lastChatAppId、setLastChatAppId 最近聊天应用追踪 | Store | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| AppListContext(应用列表上下文) | ChatTeamApp(团队聊天应用选择器) | Context | +| AppCard 组件 | 内部使用(未检测到外部引用) | 组件 | +| AppListContextProvider(上下文提供者) | 工具工作台(dashboard/tool) | Context Provider | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 应用工作台 | /dashboard/agent | projects/app/src/pages/dashboard/agent/index.tsx | 所有角色 | 含类型筛选标签(全部/智能问答/Chat Agent/工作流),无 Tab 子页面拆分 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..dcbd40d22dc7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,156 @@ +--- +capability_label: 技能管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/skill] +--- + +# 技能管理 — 业务流程索引 + +## 浏览技能列表 + +- **业务描述**: 在工作台技能管理页面浏览当前文件夹下的所有技能和子文件夹,支持卡片网格布局展示。 +- **触发入口**: 工作台左侧导航 → 点击"技能管理" Tab +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` +- **涉及角色**: 所有团队成员 +- **前置条件**: 已登录且属于某个团队 + +## 搜索技能 + +- **业务描述**: 通过关键词搜索当前文件夹下的技能和文件夹,输入即触发搜索(500ms 防抖)。 +- **触发入口**: 技能管理列表页顶部搜索框 +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` +- **涉及角色**: 所有团队成员 +- **前置条件**: 已进入技能管理页面 + +## 进入子文件夹 + +- **业务描述**: 点击文件夹卡片进入其子目录,查看该文件夹内的技能和子文件夹,面包屑导航支持返回上级目录。 +- **触发入口**: 技能管理列表 → 点击文件夹卡片 +- **路由路径**: `/dashboard/skill?parentId={folderId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` +- **涉及角色**: 所有团队成员 +- **前置条件**: 当前目录存在文件夹 + +## 创建文件夹 + +- **业务描述**: 在当前目录下创建一个新的技能文件夹,用于分类管理技能。 +- **触发入口**: 技能管理列表 → 点击"创建文件夹"按钮(仅具有技能创建权限的成员可见) +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 用户具有 `hasSkillCreatePer` 权限 + +## 创建技能 + +- **业务描述**: 创建新的自定义 Agent 技能,可设定名称、描述、图标等信息,创建后跳转到技能详情页进行编辑。 +- **触发入口**: 技能管理列表 → 点击"创建技能"下拉菜单 → 选择"自定义技能" +- **路由路径**: `/dashboard/skill` → 创建成功后跳转 `/skill/detail?skillId={newSkillId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` → `CreateSkillModal` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 用户具有 `hasSkillCreatePer` 权限 + +## 导入技能 + +- **业务描述**: 从 ZIP/TAR 压缩包导入技能,支持拖拽上传或点击选择文件,最大支持 50MB。 +- **触发入口**: 技能管理列表 → 点击"创建技能"下拉菜单 → 选择"导入压缩包" +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` → `ImportSkillModal` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 用户具有 `hasSkillCreatePer` 权限;上传文件为 .zip/.tar/.tar.gz 格式,大小不超过 50MB + +## 进入技能详情 + +- **业务描述**: 点击技能卡片进入技能详情页查看和编辑技能配置(SKILL.md、文件树等)。 +- **触发入口**: 技能管理列表 → 点击技能卡片 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/skill/index.tsx` → 跳转到技能详情页(`projects/app/src/pages/skill/detail.tsx`) +- **涉及角色**: 所有团队成员 +- **前置条件**: 技能存在 + +## 编辑技能信息 + +- **业务描述**: 修改个人技能的名称、头像和描述。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"编辑信息"(仅个人技能可见) +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 技能来源为个人(source=personal) + +## 移动技能/文件夹 + +- **业务描述**: 将技能或文件夹移动到其他文件夹中,支持选择目标文件夹。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"移动到" +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 技能来源为个人 + +## 权限设置 + +- **业务描述**: 配置技能的协作者权限、转让所有者、设置权限继承等。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"权限设置" +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 技能所有者/具有管理权限的成员 +- **前置条件**: 技能来源为个人 + +## 导出技能 + +- **业务描述**: 将技能的配置和文件打包为 ZIP 文件下载。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"导出配置"(仅非文件夹的技能可见) +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 技能来源为个人,且非文件夹类型 + +## 复制技能 + +- **业务描述**: 创建技能的完整副本。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"复制技能"(仅非文件夹的技能可见) +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 技能来源为个人,且非文件夹类型;操作需二次确认 + +## 删除技能/文件夹 + +- **业务描述**: 删除个人技能或文件夹。文件夹删除会递归删除所有子项;有应用引用的技能不可删除。 +- **触发入口**: 技能卡片悬停 → 更多菜单 → 选择"删除" +- **路由路径**: `/dashboard/skill` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/List.tsx` +- **涉及角色**: 具有技能创建权限的成员 +- **前置条件**: 技能来源为个人;技能未被任何应用引用(关联应用数=0);操作需输入名称二次确认 + +## 场景关系 + +- **浏览技能列表** → **搜索技能**: 在列表页内随时使用搜索框过滤 +- **浏览技能列表** → **进入子文件夹**: 点击文件夹卡片钻取子目录 +- **浏览技能列表** → **创建文件夹**: 在当前目录创建后回到列表 +- **浏览技能列表** → **创建技能**: 创建后跳转到技能详情页 +- **浏览技能列表** → **导入技能**: 导入后刷新当前列表 +- **浏览技能列表** → **进入技能详情**: 点击技能卡片跳转 +- 编辑、移动、权限、导出、复制、删除操作均从技能卡片更多菜单触发,相互独立 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览技能列表 | 工作台→技能管理Tab | /dashboard/skill | 所有团队成员 | +| S02 | 搜索技能 | 列表页→搜索框 | /dashboard/skill | 所有团队成员 | +| S03 | 进入子文件夹 | 列表页→点击文件夹卡片 | /dashboard/skill?parentId={id} | 所有团队成员 | +| S04 | 创建文件夹 | 列表页→创建文件夹按钮 | /dashboard/skill | hasSkillCreatePer | +| S05 | 创建技能 | 列表页→创建技能→自定义技能 | /dashboard/skill | hasSkillCreatePer | +| S06 | 导入技能 | 列表页→创建技能→导入压缩包 | /dashboard/skill | hasSkillCreatePer | +| S07 | 进入技能详情 | 列表页→点击技能卡片 | /skill/detail?skillId={id} | 所有团队成员 | +| S08 | 编辑技能信息 | 卡片悬停→更多→编辑信息 | /dashboard/skill | hasSkillCreatePer | +| S09 | 移动技能/文件夹 | 卡片悬停→更多→移动到 | /dashboard/skill | hasSkillCreatePer | +| S10 | 权限设置 | 卡片悬停→更多→权限设置 | /dashboard/skill | 技能所有者/管理员 | +| S11 | 导出技能 | 卡片悬停→更多→导出配置 | /dashboard/skill | hasSkillCreatePer | +| S12 | 复制技能 | 卡片悬停→更多→复制技能 | /dashboard/skill | hasSkillCreatePer | +| S13 | 删除技能/文件夹 | 卡片悬停→更多→删除 | /dashboard/skill | hasSkillCreatePer | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..63776bedcb67 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,331 @@ +--- +capability_label: 技能管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/skill] +--- + +# 技能管理 — 业务流程详解 + +## 页面总览 + +技能管理页面是工作台中管理 Agent 技能的中心入口,以文件夹树形层级组织技能,通过卡片网格展示每个技能/文件夹的基本信息。页面顶部提供搜索、创建文件夹、创建技能、导入技能等操作入口,每个技能卡片支持编辑、移动、权限管理、导出、复制、删除等生命周期操作。 + +## 加载技能列表 + +> 业务描述: 用户进入技能管理页面或切换文件夹时,系统加载当前目录下的技能和文件夹列表。 + +### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工作台左侧导航点击"技能管理" Tab | POST /core/agentSkills/list(参数: source=mine, searchKey="", parentId=null) | — | 页面显示技能容器框架,卡片区域显示加载中(MyBox 加载态),加载完成后显示技能/文件夹卡片网格 | + +系统同时从 URL 读取 `parentId` 查询参数。若 `parentId` 存在且有值,则加载该文件夹下的内容;否则加载根目录。 + +### 步骤 2:面包屑路径加载(仅文件夹内) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动触发) | GET /core/agentSkills/folder/path(参数: sourceId=parentId, type=current) | parentId 非空时触发;parentId 为空时不请求 | 顶部显示面包屑导航路径(FolderPath 组件),可逐级返回 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/agentSkills/list | source=mine, parentId="" | 日期字符串转 Date 对象,权限数据构造为 SkillPermission 实例 | 技能/文件夹卡片网格 | +| 进入文件夹 | POST /core/agentSkills/list | source=mine, parentId={id} | 同上 | 子目录技能/文件夹卡片网格 | + +- **搜索防抖**: 500ms,输入即触发请求 +- **刷新依赖**: `searchKey` 和 `parentId` 变化时自动重新请求 +- **空状态**: 无技能时显示 EmptyTip 组件,提示"暂无技能" + +## 搜索技能 + +> 业务描述: 用户在当前文件夹下输入关键词搜索技能和文件夹。 + +### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | POST /core/agentSkills/list(参数更新 searchKey) | 输入后 500ms 防抖触发 | 搜索过程中保持当前列表可见,请求返回后列表更新为搜索结果 | + +- **搜索范围**: 当前文件夹(含 parentId 过滤) +- **搜索字段**: 技能/文件夹名称 +- **空结果**: 搜索有结果时正常展示,无结果时列表为空但不显示 EmptyTip(与无数据时的空状态区分) + +## 文件夹钻取导航 + +> 业务描述: 用户点击文件夹卡片进入子目录,通过面包屑导航返回上级。 + +### 步骤 1:进入文件夹 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件夹卡片 | POST /core/agentSkills/list(参数: parentId={folderId})+ GET /core/agentSkills/folder/path | 文件夹类型(type=folder) | URL 更新为 `?parentId={folderId}`,面包屑替换标题文本,列表刷新为子目录内容 | + +### 步骤 2:返回上级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击面包屑中的某一级路径 | POST /core/agentSkills/list(参数: parentId={目标ID}) | — | 列表切换为该层级内容,面包屑截断到该层级 | + +- **面包屑末级**: 不可点击(forbidLastClick) +- **根目录**: 面包屑消失,显示"Skill"标题 + +## 创建文件夹 + +> 业务描述: 用户在当前目录下创建新的技能文件夹。 + +### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建文件夹"按钮 | — | hasCreatePer 为 true 时按钮可见;为 false 时按钮隐藏 | 弹出 EditFolderModal,标题为创建文件夹,表单包含名称(必填)和描述 | + +### 步骤 2:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写名称和描述,点击确认 | POST /core/agentSkills/folder/create(参数: name, description, parentId) | — | 弹窗关闭,列表自动刷新(loadSkills),新文件夹出现在列表中 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | — | — | 否 | 文件夹名称 | +| 描述 | 文本输入 | 否 | — | — | 否 | 文件夹描述 | + +**后置影响**: 创建成功后列表刷新,新文件夹出现在当前目录的卡片网格中。 + +**失败场景**: 网络异常时显示错误 Toast "Error"。 + +## 创建技能 + +> 业务描述: 用户创建新的自定义 Agent 技能。 + +### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建技能"按钮(Hover 触发下拉菜单)→ 选择"自定义技能" | — | hasCreatePer 为 true 时按钮可见 | 弹出 CreateSkillModal | + +### 步骤 2:填写技能信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写技能名称、描述,选择图标,点击确认 | POST /core/agentSkills/create(参数: name, description, avatar, parentId, ...) | — | 创建成功后弹窗关闭,跳转到技能详情页 `/skill/detail?skillId={newSkillId}` | + +**后置影响**: 创建成功后跳转技能详情页而非刷新列表,用户可在详情页编辑 SKILL.md 和文件树。 + +## 导入技能 + +> 业务描述: 用户从 ZIP/TAR 压缩包导入技能。 + +### 步骤 1:打开导入弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建技能"→ 选择"导入压缩包" | — | hasCreatePer 为 true | 弹出 ImportSkillModal,显示拖拽上传区域 | + +### 步骤 2:选择/拖拽文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击上传区域选择文件 或 拖拽文件到上传区 | — | 文件格式校验 → 非 .zip/.tar/.tar.gz → Toast 警告"不支持的文件格式 {ext}" | 拖拽悬停时边框变为主色调 | +| 文件格式通过校验 | — | 文件大小校验 → 超过 50MB → Toast 警告"部分文件大小超过 {maxSize} 限制" | 上传区域替换为文件信息卡片(名称、大小、删除按钮) | + +### 步骤 3:确认导入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | POST /core/agentSkills/import(FormData: file + parentId?) | — | 按钮显示加载态(isImporting),成功后弹窗关闭,列表刷新。失败时显示错误 Toast | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 文件格式校验 | 文件选择/拖拽释放时 | "不支持的文件格式 {ext}"(i18n: skill:unsupported_file_format) | +| 文件大小校验(≤50MB) | 文件选择/拖拽释放时 | "部分文件大小超过 {maxSize} 限制"(i18n: file:some_file_size_exceeds_limit) | + +**前置条件**: 文件格式为 .zip/.tar/.tar.gz;文件大小 ≤ 50MB。 + +**后置影响**: 后端在事务中创建技能记录、上传 ZIP 到 MinIO、创建 v0 版本记录。 + +## 编辑技能信息 + +> 业务描述: 用户修改技能的名称、头像和描述。 + +### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 悬停技能卡片 → 更多菜单出现 → 点击"编辑信息" | — | 仅个人技能(source=personal)的更多菜单可见,系统技能不显示 | 弹出 EditResourceModal,预填当前名称、头像、描述 | + +### 步骤 2:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/头像/描述,点击确认 | POST /core/agentSkills/update(参数: skillId, name, avatar, description) | — | 弹窗关闭,列表刷新(loadSkills),卡片信息更新 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | 当前名称 | — | 否 | 技能/文件夹名称 | +| 头像 | 图标选择 | 否 | 当前头像 | 预设图标库 | 否 | 技能图标 | +| 描述 | 文本输入 | 否 | 当前描述 | — | 否 | 技能描述 | + +## 移动技能/文件夹 + +> 业务描述: 用户将技能或文件夹移动到其他文件夹。 + +### 步骤 1:打开移动弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片悬停 → 更多菜单 → 点击"移动到" | — | 仅个人技能可见 | 弹出 MoveModal,显示可用文件夹列表 | + +### 步骤 2:选择目标并确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件夹列表中选择目标文件夹(或根目录),点击确认 | POST /core/agentSkills/update(参数: skillId, parentId) | targetId="root" 时 parentId=null | 弹窗关闭,列表刷新 | + +- **文件夹列表来源**: GET /core/agentSkills/list(source=mine, type=folder),以树形展示 +- **移动提示**: 显示"移动技能提示"文案(i18n: skill:move_skill_hint) + +## 权限设置 + +> 业务描述: 用户管理技能的协作者权限和所有者。 + +### 步骤 1:打开权限弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片悬停 → 更多菜单 → 点击"权限设置" | — | 仅个人技能可见 | 弹出 ConfigPerModal,显示当前权限配置 | + +### 步骤 2:权限管理操作 + +ConfigPerModal 内部提供以下操作: + +| 操作 | 触发 API | 说明 | +|------|---------|------| +| 添加/修改协作者 | POST `/core/agentSkills/collaborator/update`(通过 collaborator.ts) | 设置协作者角色权限 | +| 删除协作者 | DELETE `/core/agentSkills/collaborator/delete`(通过 collaborator.ts) | 移除协作者 | +| 转让所有者 | POST /proApi/core/agentSkills/changeOwner(参数: skillId, ownerId) | 将技能所有权转给其他团队成员 | +| 恢复权限继承 | GET /core/agentSkills/resumeInheritPermission(参数: skillId) | 当技能在文件夹内时,可恢复继承父文件夹的权限设置 | + +**前置条件**(恢复权限继承): 技能有父文件夹(hasParent=true)且当前未继承权限(isInheritPermission=false)。 + +**后置影响**: 所有权限操作后自动刷新列表。 + +## 导出技能 + +> 业务描述: 用户将技能配置打包下载为 ZIP 文件。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片悬停 → 更多菜单 → 点击"导出配置" | GET /api/core/agentSkills/export?skillId={id} | 仅非文件夹的技能可见此菜单项 | 浏览器触发文件下载,文件名为 `{技能名}.zip`。导出前显示全局加载遮罩 | + +**前置条件**: 技能类型非文件夹(type≠folder);技能来源为个人(source=personal)。 + +**失败场景**: 导出失败时显示错误 Toast "导出失败"(i18n: skill:export_failed)。 + +## 复制技能 + +> 业务描述: 用户创建技能的完整副本。 + +### 步骤 1:确认复制 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片悬停 → 更多菜单 → 点击"复制技能" | — | 仅非文件夹的个人技能可见 | 弹出确认弹窗,内容为"确认复制此技能?"(i18n: skill:copy_skill_confirm) | + +### 步骤 2:执行复制 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认 | POST /core/agentSkills/copy(参数: skillId) | — | 弹窗关闭,列表刷新,副本技能出现在同一目录下 | + +## 删除技能/文件夹 + +> 业务描述: 用户删除个人技能或文件夹。 + +### 步骤 1:检查可删除条件 + +删除按钮的可用性取决于关联应用数量: + +| 条件 | 按钮状态 | 提示 | +|------|---------|------| +| 关联应用数 > 0(文件夹) | 置灰(disabled) | "文件夹内有应用引用的技能,无法删除"(i18n: skill:folder_delete_disabled_tip) | +| 关联应用数 > 0(技能) | 置灰(disabled) | "技能被应用引用,无法删除"(i18n: skill:delete_disabled_tip) | +| 关联应用数 = 0 | 可点击 | — | + +### 步骤 2:二次确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"删除"菜单 | — | — | 弹出删除确认弹窗,需输入技能名称确认(inputConfirmText),显示自定义内容"确认删除提示"(i18n: skill:confirm_delete_tip) | + +### 步骤 3:执行删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入技能名称并确认 | DELETE /core/agentSkills/delete(参数: skillId) | — | 弹窗关闭,列表刷新(loadSkills),技能/文件夹卡片消失 | + +**前置条件**: 技能来源为个人(source=personal);关联应用数为 0。 + +**后置影响(后端)**: +1. 文件夹类型 → 递归查找所有子节点(findSkillAndAllChildren) +2. 批量软删除技能记录(设置 deleteTime) +3. 批量软删除版本记录(设置 isDeleted=true) +4. 异步清理 MinIO 包文件 + 技能头像文件 +5. 异步清理关联沙箱资源 + +**失败场景**: 网络异常时显示错误 Toast "删除失败"(i18n: skill:delete_failed);技能不存在时后端抛出 "Skill not found"。 + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 技能管理页面 + participant API as 后端 API + participant Backend as 后端服务 + + Note over User,Backend: 加载技能列表 + User->>UI: 进入技能管理页面 + UI->>API: POST /core/agentSkills/list (source=mine) + API->>Backend: 查询技能和文件夹 + Backend-->>API: 技能列表数据 + API-->>UI: 列表数据 + UI-->>User: 展示卡片网格 + + Note over User,Backend: 创建文件夹 + User->>UI: 点击"创建文件夹" + UI-->>User: 弹出文件夹创建弹窗 + User->>UI: 填写名称和描述,确认 + UI->>API: POST /core/agentSkills/folder/create + API->>Backend: 创建文件夹记录 + Backend-->>API: 创建成功 + API-->>UI: 成功 + UI->>API: POST /core/agentSkills/list (刷新) + API-->>UI: 更新列表 + UI-->>User: 新文件夹出现在列表中 + + Note over User,Backend: 删除技能 + User->>UI: 悬停技能卡片 → 更多 → 删除 + UI-->>User: 弹出确认弹窗(需输入名称) + User->>UI: 输入名称并确认 + UI->>API: DELETE /core/agentSkills/delete + API->>Backend: 软删除技能+版本+清理资源 + Backend-->>API: 删除成功 + API-->>UI: 成功 + UI->>API: POST /core/agentSkills/list (刷新) + API-->>UI: 更新列表 + UI-->>User: 技能卡片消失 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..2294d74362ee --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,55 @@ +--- +capability_label: 技能管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/skill] +--- + +# 技能管理 — 模块概览 + +## 1. 模块业务概述 + +技能管理是工作台下的核心模块之一,为团队成员提供自定义 AI Agent 技能(Skill)的集中管理能力。用户可在此创建、导入、编辑和管理 Agent 技能及其文件夹,技能可直接被 AI 应用引用以扩展其能力边界。 + +模块采用文件夹树形层级管理结构,支持按文件夹分类组织技能,提供搜索筛选、权限配置、技能导出/复制等完整生命周期管理。仅具有技能创建权限的团队成员可见创建和导入入口,系统内置技能(source=system)不可删除。 + +本模块为叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 具有技能创建权限的成员 | 技能管理列表、技能详情页 | 创建文件夹、创建技能、导入技能ZIP包、编辑个人信息、移动、权限设置、导出、复制、删除个人技能 | 创建/导入按钮可见,个人技能卡片的更多菜单可见 | +| 无技能创建权限的成员 | 技能管理列表 | 查看技能列表、搜索、进入技能详情 | 创建和导入入口隐藏,更多菜单不可见 | + +**权限模型**: 基于 `userInfo.team.permission.hasSkillCreatePer` 布尔标记控制创建/导入类操作的可见性。个人技能(source=personal)支持编辑/删除/复制/导出/权限配置等完整操作;系统技能(source=system)不可删除。技能详情页的编辑功能通过 `canWrite` 属性传递权限。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| DashboardContainer(工作台容器) | 工作台左侧导航菜单和 Tab 切换框架 | 组件 | +| EditFolderModal(通用弹窗) | 创建/编辑文件夹弹窗 | 组件 | +| EditResourceModal(通用弹窗) | 编辑资源信息弹窗(名称、头像、描述) | 组件 | +| MoveModal(通用弹窗) | 资源移动到其他文件夹的弹窗 | 组件 | +| ConfigPerModal(权限管理) | 技能权限配置、协作者管理、所有者转让 | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 技能列表 API(getSkillList) | 应用详情编辑 → 工具选择器 → SkillSelectModal | API | +| 技能详情 API(getSkillDetail) | 技能详情页(/skill/detail) | API | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 技能管理列表 | /dashboard/skill | `projects/app/src/pages/dashboard/skill/index.tsx` | 所有团队成员 | 无 Tab,采用卡片网格布局展示技能和文件夹;支持 parentId 查询参数实现文件夹钻取导航 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a6bce6fde580 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,85 @@ +--- +capability_label: 工作台 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: ["管理员", "普通成员", "团队所有者"] +router_paths: ["/dashboard/agent", "/dashboard/create", "/dashboard/evaluation", "/dashboard/mcpServer", "/dashboard/skill", "/dashboard/systemTool", "/dashboard/templateMarket", "/dashboard/tool"] +--- + +# 工作台 — 模块概览 + +## 模块业务概述 + +工作台是 FastGPT 的**应用构建与管理中心**,为所有用户提供 AI 应用的创建、配置、评测和发布入口。用户在这里完成从创建应用到上线的全生命周期管理。 + +工作台通过统一的侧边栏导航,将应用构建所需的各项能力组织在一起,涵盖以下子能力模块: + +- **应用工作台**:应用的列表展示、文件夹管理和搜索入口 +- **创建应用**:支持智能问答、Chat Agent、工作流三种应用类型的创建 +- **评测**:对应用效果进行系统性评测,包含数据集管理、维度配置和评测任务执行 +- **技能管理**:管理 Agent 技能(Skills),为应用提供可复用的能力单元 +- **工具管理**:管理系统工具(Tools),支持 HTTP、插件等类型的工具配置 +- **系统工具**:管理平台级系统工具 +- **MCP 服务器**:管理 MCP(Model Context Protocol)服务器连接 +- **模板市场**:浏览和使用预置的应用模板,快速启动项目 + +这些子能力通过侧边栏的"应用构建"分区统一入口,支持根据用户权限动态展示可用功能。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 全部工作台页面 | 创建应用、管理技能/工具/MCP、执行评测、访问模板市场、系统工具配置 | 拥有全部功能,username=root 时额外可见"系统工具管理"入口 | +| 普通成员 | 应用工作台、模板市场、技能/工具/MCP 页面 | 创建应用(需 hasAppCreatePer)、管理自己的应用和文件夹 | 评测功能需 hasEvaluationCreatePer 权限 | +| 团队所有者 | 全部工作台页面 | 全部操作 + 团队管理、账单查看 | 额外可见团队管理、账单、推广记录等设置项 | + +**权限模型**:基于团队权限位(team.permission)的 RBAC。关键权限位包括 `hasAppCreatePer`(应用创建)、`hasEvaluationCreatePer`(评测创建)、`hasManagePer`(账单管理)、`isOwner`(推广)、`hasApikeyCreatePer`(API Key)。路由级控制在 `DashboardContainer` 和 `DashboardNavbar` 组件中通过条件渲染实现。评测入口受 `feConfigs.show_evaluation` 和用户权限双重控制。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库 | Chakra UI 布局组件(Flex, Box)、MyBox、MyIcon、MyTabs、SearchInput 等 | 组件 | +| 应用详情 | Dashboard 数据看板组件、Logs 日志组件(复用) | 组件 | +| 国际化 | next-i18next 翻译服务 | Hook | +| 文件服务 | 头像上传预签名 URL 获取 | API | + +## 对外共享 + +工作台作为应用构建的入口枢纽,主要消费其他模块的能力,对外共享较少: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| DashboardContainer(含侧边栏导航) | 所有 dashboard 子页面 | 布局组件 | +| CreateModal(创建应用弹窗) | 应用工作台(agent 页面调用) | 组件 | + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 应用工作台 | /dashboard/agent | `pages/dashboard/agent/index.tsx` | 全部用户 | 应用列表主页,含文件夹管理、搜索、分类筛选 | +| 创建应用 | /dashboard/create | `pages/dashboard/create/index.tsx` | hasAppCreatePer | 新建应用弹窗,支持三种类型 | +| 评测首页 | /dashboard/evaluation | `pages/dashboard/evaluation/index.tsx` | hasEvaluationCreatePer | 评测系统入口,含子模块导航 | +| 评测数据集 | /dashboard/evaluation/dataset | `pages/dashboard/evaluation/dataset/index.tsx` | hasEvaluationCreatePer | 数据集管理(列表/导入/详情) | +| 评测维度 | /dashboard/evaluation/dimension | `pages/dashboard/evaluation/dimension/index.tsx` | hasEvaluationCreatePer | 评测维度配置(列表/创建/编辑) | +| 评测任务 | /dashboard/evaluation/task | `pages/dashboard/evaluation/task/index.tsx` | hasEvaluationCreatePer | 评测任务管理(列表/详情) | +| MCP 服务器 | /dashboard/mcpServer | `pages/dashboard/mcpServer/index.tsx` | 全部用户 | MCP 服务器连接管理 | +| 技能管理 | /dashboard/skill | `pages/dashboard/skill/index.tsx` | 全部用户 | Agent 技能管理 | +| 系统工具 | /dashboard/systemTool | `pages/dashboard/systemTool/index.tsx` | root 用户 | 平台级系统工具管理 | +| 模板市场 | /dashboard/templateMarket | `pages/dashboard/templateMarket/index.tsx` | 全部用户 | 应用模板浏览与使用 | +| 工具管理 | /dashboard/tool | `pages/dashboard/tool/index.tsx` | 全部用户 | 系统工具配置管理 | + +## 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 应用工作台 | 应用列表与管理 | [08-模块概览](../应用工作台/模块概览.md) | +| 创建应用 | 新建应用 | [08-模块概览](../创建应用/模块概览.md) | +| 评测 | 应用效果评测 | [08-模块概览](../评测/模块概览.md) | +| MCP 服务器 | MCP 连接管理 | [08-模块概览](../MCP%20服务器/模块概览.md) | +| 技能管理 | Agent 技能 | [08-模块概览](../技能管理/模块概览.md) | +| 系统工具 | 平台工具管理 | [08-模块概览](../系统工具/模块概览.md) | +| 模板市场 | 应用模板 | [08-模块概览](../模板市场/模块概览.md) | +| 工具管理 | 工具配置 | [08-模块概览](../工具管理/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a1634f9e6033 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,64 @@ +--- +capability_label: 模板市场 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:30:00.000Z +parent_module: 工作台 +roles: [所有登录用户, 企业微信团队用户] +router_paths: [/dashboard/templateMarket] +--- + +# 模板市场 — 业务流程索引 + +## 浏览模板市场 + +- **业务描述**: 用户进入模板市场页面,查看所有可用的预构建 AI 应用模板卡片列表,了解每个模板的名称、简介、作者和分类标签。 +- **触发入口**: 工作台侧边栏 → 点击"模板市场"导航项 +- **路由路径**: /dashboard/templateMarket +- **页面组件位置**: projects/app/src/pages/dashboard/templateMarket/index.tsx +- **涉及角色**: 所有登录用户 +- **前置条件**: 用户已登录 + +## 按分类筛选模板 + +- **业务描述**: 用户点击顶部分类标签(如"对话"、"工具"、"知识库"等),模板卡片列表按所选分类过滤,仅展示属于该类别的模板。 +- **触发入口**: 模板市场页面 → 点击分类标签 Tab +- **路由路径**: /dashboard/templateMarket +- **页面组件位置**: projects/app/src/pages/dashboard/templateMarket/index.tsx +- **涉及角色**: 所有登录用户 +- **前置条件**: 已进入模板市场页面,模板标签数据已加载 + +## 搜索模板 + +- **业务描述**: 用户在搜索框中输入关键词,模板卡片列表实时按名称和简介进行模糊匹配过滤。 +- **触发入口**: 模板市场页面 → 在搜索框中输入关键词 +- **路由路径**: /dashboard/templateMarket +- **页面组件位置**: projects/app/src/pages/dashboard/templateMarket/index.tsx +- **涉及角色**: 所有登录用户 +- **前置条件**: 已进入模板市场页面 + +## 使用模板创建应用 + +- **业务描述**: 用户点击模板卡片上的"使用"按钮,系统获取模板详情,解析工作流结构,创建新应用,成功后跳转到应用详情页。 +- **触发入口**: 模板市场页面 → 点击模板卡片上的"使用"按钮 +- **路由路径**: /dashboard/templateMarket → 创建成功后跳转 /app/detail?appId={id} +- **页面组件位置**: projects/app/src/pages/dashboard/templateMarket/index.tsx +- **涉及角色**: 所有登录用户 +- **前置条件**: 已登录,有创建应用的权限 + +## 场景关系 + +- **浏览模板市场** → **按分类筛选模板**: 浏览是筛选的前置场景,筛选在浏览页面内完成 +- **浏览模板市场** → **搜索模板**: 浏览是搜索的前置场景,搜索在浏览页面内完成 +- **浏览模板市场** → **使用模板创建应用**: 使用模板是浏览的后续操作 + +筛选和搜索可组合使用(先筛选分类再搜索,或先搜索再筛选分类),二者无先后依赖。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览模板市场 | 工作台侧边栏→模板市场 | /dashboard/templateMarket | 所有登录用户 | +| S02 | 按分类筛选模板 | 模板市场→点击分类标签 | /dashboard/templateMarket | 所有登录用户 | +| S03 | 搜索模板 | 模板市场→搜索框输入 | /dashboard/templateMarket | 所有登录用户 | +| S04 | 使用模板创建应用 | 模板卡片→点击"使用"按钮 | /dashboard/templateMarket → /app/detail | 所有登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e3ac36fb25f0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,160 @@ +--- +capability_label: 模板市场 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:30:00.000Z +parent_module: 工作台 +roles: [所有登录用户, 企业微信团队用户] +router_paths: [/dashboard/templateMarket] +--- + +# 模板市场 — 业务流程详解 + +## 页面总览 + +模板市场是工作台中的一个独立页面,用户在此浏览和选用预构建的 AI 应用模板。页面顶部为分类筛选 Tab 栏和搜索框,主体区域为模板卡片网格。用户可按分类筛选、关键词搜索来定位目标模板,点击"使用"按钮即可一键将模板创建为自己的应用。 + +## S01:浏览模板市场 + +> 用户从工作台侧边栏导航进入模板市场页面,首次加载展示全部模板卡片。 + +### 步骤 1:进入模板市场页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在工作台侧边栏点击"模板市场"导航项 | — | 点击导航项时若侧边栏处于收起状态,直接跳转 | 页面路由切换到 /dashboard/templateMarket,显示页面加载骨架 | + +### 步骤 2:页面数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动触发) | GET /core/app/template/list(获取全部模板列表) | 无 | 模板卡片网格渲染,每个卡片显示模板头像、名称、简介、作者、标签和"使用"按钮 | +| —(自动触发) | GET /proApi/core/app/template/getTemplateTypes(Plus 版)或使用默认标签(社区版) | 系统为 Plus 版时调 API 获取自定义标签;社区版使用默认标签 | 顶部分类 Tab 栏渲染,包含"全部"标签和各类别标签,每个标签显示对应模板数量 | + +### 步骤 3:浏览模板卡片 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动鼠标查看模板卡片 | — | 无 | 卡片网格随滚动展示更多模板。响应式网格列数随屏幕宽度变化(1~5列) | +| 鼠标悬停在卡片上 | — | 无 | 卡片显示"使用"按钮悬浮层 | +| 点击模板卡片的体验链接 | — | 模板有 `experienceUrl` 时显示体验入口 | 新窗口打开体验链接 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/app/template/list | type: undefined(获取全部) | 前端过滤隐藏指定模板(ID 以 -assistant 结尾的模板) | 模板卡片网格 | +| 标签加载 | GET /proApi/core/app/template/getTemplateTypes 或默认标签 | — | 前端注入推荐标签(推荐/企业微信专区),按 typeOrder 排序 | 分类 Tab 栏 | + +## S02:按分类筛选模板 + +> 用户在分类 Tab 栏中点击某个类别标签,模板卡片列表只展示属于该类别的模板。 + +### 步骤 1:点击分类标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部分类 Tab(如"对话") | —(纯前端过滤,不调用 API) | 无 | 被点击的 Tab 高亮为蓝色激活态。模板卡片列表即时过滤,只显示 tags 属性包含该分类 ID 的模板卡片 | + +### 步骤 2:切换分类 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"全部" Tab 或另一个分类 Tab | — | 无 | 激活态切换到新 Tab,模板列表按新分类重新过滤。若搜索框中有关键词,分类过滤和关键词搜索叠加生效 | + +## S03:搜索模板 + +> 用户在搜索框中输入关键词,模板列表实时按模板名称和简介进行模糊匹配过滤。 + +### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词(如"客服") | —(纯前端过滤) | 无 | 搜索框显示输入内容。模板卡片列表即时过滤,仅显示名称或简介中包含关键词的模板。过滤与当前选中的分类 Tab 叠加生效 | + +### 步骤 2:搜索结果为空 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动) | — | `filteredList.length === 0` | 页面中央显示空数据提示:"暂无可用模板" | + +### 步骤 3:清除搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 清空搜索框内容 | — | 无 | 模板列表恢复为仅按当前分类 Tab 过滤的完整结果 | + +## S04:使用模板创建应用 + +> 用户点击模板卡片上的"使用"按钮,系统获取模板详情构建工作流,创建新应用并跳转到应用详情页。 + +### 步骤 1:点击"使用"按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停模板卡片 → 点击"使用"按钮 | — | 无 | "使用"按钮冒泡阻止,不触发卡片的其它点击事件。页面显示全局加载遮罩(MyBox isLoading) | + +### 步骤 2:获取模板详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动触发) | GET /core/app/template/detail?templateId={id} | 无 | 遮罩显示中,获取模板完整工作流数据 | + +### 步骤 3:处理简单应用模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动) | — | 模板类型为 simple 时,调用 form2AppWorkflow 将表单配置转为工作流节点和边;其他类型直接使用模板的 workflow 数据 | 同步骤 2,加载遮罩显示中 | + +### 步骤 4:创建应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动触发) | POST /core/app/create | 无 | 遮罩持续显示。参数包含:parentId(从 URL 查询参数获取)、avatar、name、type、modules(工作流节点)、edges(工作流边)、chatConfig、templateId | + +### 步骤 5:创建成功跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动) | webPushTrack 埋点上报 | 创建成功时触发 | 显示"创建成功"成功提示 Toast。页面路由跳转到 /app/detail?appId={新应用ID} | + +### 步骤 6:创建失败 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动) | — | API 调用失败 | 显示"创建失败"错误提示 Toast。遮罩解除,用户停留在模板市场页面可重试 | + +#### 前后置条件 + +- **前置条件**:用户已登录且有创建应用的权限(后端校验);URL 中可携带 parentId 参数指定应用的父目录 +- **后置影响**:创建成功后生成新应用,归属于当前 parentId 目录下;应用工作流结构继承自模板配置;模板使用事件被埋点记录 +- **失败场景**:API 调用失败时显示"创建失败"提示,用户可重试;后端权限校验不通过时返回错误 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 模板市场页面 + participant API as 后端 API + participant AppDetail as 应用详情页 + + User->>UI: 进入模板市场 + UI->>API: GET /core/app/template/list + UI->>API: GET /proApi/core/app/template/getTemplateTypes + API-->>UI: 模板列表 + 标签数据 + UI-->>User: 展示模板卡片网格和分类 Tab + + User->>UI: 点击分类 Tab / 输入搜索关键词 + UI-->>User: 前端过滤模板列表 + + User->>UI: 点击模板"使用"按钮 + UI->>UI: 显示全局加载遮罩 + UI->>API: GET /core/app/template/detail?templateId={id} + API-->>UI: 模板详情(含工作流) + UI->>UI: simple 类型:form2AppWorkflow 转换工作流 + UI->>API: POST /core/app/create + API-->>UI: 新应用 ID + UI->>UI: 埋点上报 useAppTemplate + UI->>AppDetail: 跳转 /app/detail?appId={id} +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..74dd53b8ae5c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 模板市场 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:30:00.000Z +parent_module: 工作台 +roles: [所有登录用户, 企业微信团队用户] +router_paths: [/dashboard/templateMarket] +--- + +# 模板市场 — 模块概览 + +## 1. 模块业务概述 + +模板市场是 FastGPT 工作台中的应用模板中心,为用户提供预构建的 AI 应用模板浏览和使用功能。用户可以在模板市场中按类别筛选、搜索模板,并一键将模板创建为自己的应用,快速启动 AI 应用开发。 + +本模块是独立叶子页面,无子能力。模板数据由父容器 `DashboardContainer` 统一获取后通过 render props 注入,模板市场本身不负责数据拉取。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有登录用户 | 模板市场浏览页 | 浏览模板、按分类筛选、搜索模板、使用模板创建应用 | 基础功能,所有用户可用 | +| 企业微信团队用户 | 同上 | 同上 | 推荐分类标签名称变为"企业微信专区" | + +**权限模型**: 模板市场页面通过侧边栏导航入口控制可见性,无独立页面级权限校验。模板使用操作依赖应用创建权限(后端校验)。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| DashboardContainer(工作台容器) | 提供模板列表数据、模板标签数据、侧边栏菜单图标 | 组件 | +| 应用 API 层 | 获取模板列表、模板详情、创建应用 | API | +| 应用详情页 | 使用模板创建应用成功后跳转到应用详情 | 页面路由 | +| SimpleApp 工具函数 | form2AppWorkflow:将简单应用表单转换为工作流结构 | 工具函数 | +| 埋点追踪 | webPushTrack:记录模板使用事件 | 工具函数 | + +## 4. 对外共享 + +本模块为消费型页面,组件均为内部使用,无对外共享能力。`AppMarketCard` 仅在模板市场页面内部使用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模板市场 | /dashboard/templateMarket | projects/app/src/pages/dashboard/templateMarket/index.tsx | 所有登录用户 | 含分类筛选 Tab 栏和搜索框,无子页面 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..031597748a92 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,96 @@ +--- +capability_label: 系统工具 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: ["所有用户", "root管理员"] +router_paths: ["/dashboard/systemTool"] +--- + +# 系统工具 — 业务流程索引 + +本模块为单页面工具体验,无 Tab 分页。以下为页面中可执行的业务场景。 + +## 浏览系统工具列表 + +- **业务描述**: 用户进入系统工具市场,查看所有可安装的系统预置工具,了解每个工具的名称、描述、作者等信息。 +- **触发入口**: 工作台侧边栏 → 点击「工具」导航 → 选择「系统工具」标签 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录 + +## 按标签筛选工具 + +- **业务描述**: 用户按预定义的标签分类筛选系统工具,快速定位特定类型的功能工具。 +- **触发入口**: 系统工具页面 → 点击标签筛选栏中的标签 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录,工具标签数据已加载 + +## 搜索工具 + +- **业务描述**: 用户通过搜索框输入关键词,按工具名称或简介进行模糊匹配,快速找到目标工具。 +- **触发入口**: 系统工具页面 → 右上角搜索框输入 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录 + +## 安装系统工具 + +- **业务描述**: 用户将系统预置工具安装到团队工作区,安装后可在应用工作流中使用该工具。 +- **触发入口**: 系统工具页面 → 工具卡片的「安装」按钮 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录,工具未安装 + +## 卸载系统工具 + +- **业务描述**: 用户卸载已安装的系统工具,从团队工作区移除该工具。 +- **触发入口**: 系统工具页面 → 已安装工具卡片的「卸载」按钮 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录,工具已安装 + +## 查看工具详情 + +- **业务描述**: 用户点击工具卡片查看该工具的详细信息,包括工具版本、输入输出参数、使用说明(readme)等。 +- **触发入口**: 系统工具页面 → 点击工具卡片 +- **路由路径**: `/dashboard/systemTool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录 + +## 跳转工具配置(仅 root) + +- **业务描述**: 当系统工具列表为空时,root 管理员可通过「点击配置」按钮跳转至工具配置页面进行工具配置。 +- **触发入口**: 系统工具页面 → 工具列表为空时 → 「点击配置」按钮 +- **路由路径**: `/config/tool` +- **页面组件位置**: `projects/app/src/pages/dashboard/systemTool/index.tsx` +- **涉及角色**: root管理员 +- **前置条件**: 系统工具列表为空,当前用户为 root + +## 场景关系 + +- **浏览系统工具列表** → **按标签筛选工具**:标签筛选在列表基础上进一步过滤 +- **浏览系统工具列表** → **搜索工具**:搜索在列表基础上进行文本匹配过滤 +- **浏览系统工具列表** → **查看工具详情**:点击工具卡片弹出详情抽屉 +- **查看工具详情** → **安装/卸载系统工具**:详情抽屉中也可触发安装/卸载操作 +- 标签筛选与搜索可叠加使用,进一步缩小结果范围 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览系统工具列表 | 工作台→工具→系统工具 | `/dashboard/systemTool` | 所有用户 | +| S02 | 按标签筛选工具 | 系统工具页→标签筛选栏 | `/dashboard/systemTool` | 所有用户 | +| S03 | 搜索工具 | 系统工具页→搜索框 | `/dashboard/systemTool` | 所有用户 | +| S04 | 安装系统工具 | 工具卡片→安装按钮 | `/dashboard/systemTool` | 所有用户 | +| S05 | 卸载系统工具 | 工具卡片→卸载按钮 | `/dashboard/systemTool` | 所有用户 | +| S06 | 查看工具详情 | 点击工具卡片 | `/dashboard/systemTool` | 所有用户 | +| S07 | 跳转工具配置 | 空列表→点击配置 | `/config/tool` | root管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..da2facdf742f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,167 @@ +--- +capability_label: 系统工具 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: ["所有用户", "root管理员"] +router_paths: ["/dashboard/systemTool"] +--- + +# 系统工具 — 业务流程详解 + +## 页面总览 + +系统工具市场是工作台中供用户浏览和安装系统预置工具的统一入口。页面以卡片网格的形式展示工具,支持标签筛选和关键词搜索。用户可在此完成工具的发现、查看、安装和卸载全流程。页面由 DashboardContainer 提供统一的工作台布局(侧边栏导航 + 背景装饰)。 + +--- + +### 浏览与筛选系统工具 + +> 页面加载后默认展示全部系统工具。用户可通过标签筛选和搜索缩小范围。 + +#### 步骤 1:页面加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工作台侧边栏进入「工具」→ 选择「系统工具」标签页 | GET `/core/plugin/team/list`(参数 `type: 'tool'`) | 无 | 页面显示加载状态,MyBox 显示骨架屏加载动画 | +| 等待工具列表数据返回 | — | 工具列表有数据 | 加载状态消失,展示工具卡片网格(响应式 1~4 列) | +| 等待工具列表数据返回 | — | 工具列表为空 且 用户为 root | 展示空状态提示(EmptyTip 组件)+ 「点击配置」按钮 | +| 等待工具列表数据返回 | — | 工具列表为空 且 用户非 root | 仅展示空状态提示 | +| 页面同时加载标签列表 | GET `/core/plugin/toolTag/list` | 无 | 标签筛选栏渲染标签选项(首个为「全部」) | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET `/core/plugin/team/list` | `type: 'tool'` | 无额外处理,直接写入 state | 卡片网格展示所有系统工具 | +| — | GET `/core/plugin/toolTag/list` | 无 | 无额外处理 | 标签筛选栏展示所有标签 | + +- 工具列表不支持分页,一次性加载全部工具 +- 标签筛选和搜索均为前端过滤,不触发额外 API 请求 +- 工具卡片展示:名称、简介、图标、作者、标签、状态、安装状态 + +#### 步骤 2:按标签筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击标签筛选栏中的某个标签 | 无(前端过滤) | 选中"全部"标签 | 显示所有工具 | +| 点击标签筛选栏中的某个标签 | 无(前端过滤) | 选中特定标签 | 仅显示含该标签的工具;如结果为空则展示空状态 | + +- 标签数据通过 `parseI18nString` 根据当前语言解析标签名称 +- 标签筛选与搜索可叠加使用 + +#### 步骤 3:搜索工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框输入关键词 | 无(前端过滤) | 关键词匹配工具名称或简介 | 实时过滤工具列表,仅显示匹配项 | +| 清空搜索框 | 无 | — | 恢复显示当前标签下的全部工具 | + +- 搜索为前端模糊匹配,匹配字段为工具名称(`name`)和简介(`intro`),不区分大小写 +- 搜索结果叠加标签筛选条件 + +--- + +### 安装系统工具 + +> 用户将系统预置工具安装到当前团队。 + +#### 步骤 1:触发安装 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具卡片上的「安装」按钮 | POST `/core/plugin/team/toggleInstall`(参数 `pluginId`, `installed: true`, `type: 'tool'`) | 该工具当前未处于安装/卸载中的状态 | 按钮进入 loading 状态(置灰不可点击),防止重复操作 | +| 等待 API 返回 | — | 安装成功 | 按钮恢复可用,工具状态变为「已安装」,卡片显示卸载操作 | +| 等待 API 返回 | — | 安装失败 | 按钮恢复可用,工具状态不变,显示错误提示 | + +- 安装操作有防重复点击保护:同一工具正在处理时,新的安装请求会等待当前操作完成 +- 使用 Promise 引用 Map 管理并发操作,确保同一工具不会同时处理两个安装/卸载请求 + +#### 步骤 2:安装后影响 + +- **后置影响**:工具安装后,该工具的应用版本和输入输出定义可在应用工作流的「工具」节点中使用 +- **失败场景**:网络异常时显示网络错误提示;后端返回错误码时显示对应错误信息 + +--- + +### 卸载系统工具 + +#### 步骤 1:触发卸载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具卡片上的「卸载」按钮 | POST `/core/plugin/team/toggleInstall`(参数 `pluginId`, `installed: false`, `type: 'tool'`) | 该工具当前未处于安装/卸载中的状态 | 按钮进入 loading 状态 | +| 等待 API 返回 | — | 卸载成功 | 按钮恢复可用,工具状态变为「未安装」,卡片显示安装操作 | +| 等待 API 返回 | — | 卸载失败 | 按钮恢复可用,工具状态不变,显示错误提示 | + +- 卸载与安装共用同一 API,通过 `installed` 字段区分操作类型 + +--- + +### 查看工具详情 + +> 用户点击工具卡片查看工具的完整信息。 + +#### 步骤 1:打开详情抽屉 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具卡片 | 无(ToolCard 的 onClickCard 回调) | — | 右侧滑出 ToolDetailDrawer 详情抽屉 | +| 用户点击工具版本查看详情 | GET `/core/plugin/team/toolDetail`(参数 `toolId`) | 工具有关联的子应用(`associatedPluginId` 存在) | 加载工具详情后展示版本输入输出参数列表 | +| 用户点击工具版本查看详情 | GET `/core/plugin/team/toolDetail`(参数 `toolId`) | 工具为文件夹或普通工具 | 加载工具详情,展示工具基础信息 + 子工具列表(如有) | + +#### 步骤 2:详情抽屉内操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在详情抽屉中点击安装/卸载按钮 | POST `/core/plugin/team/toggleInstall` | 同安装/卸载场景 | 按钮 loading → 状态更新,抽屉内按钮状态同步变化 | +| 点击关闭或遮罩区域 | 无 | — | 抽屉关闭,返回工具列表 | + +- 详情抽屉中使用 `useToolDetail` hook 管理工具详情数据加载 +- 详情中展示工具的 readme 说明文档、输入参数定义(key/label/description/valueType)、输出参数定义 + +--- + +### 跳转工具配置(仅 root) + +> root 管理员在工具列表为空时可配置工具。 + +#### 步骤 1:点击配置入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「点击配置」按钮 | 无(前端路由跳转) | 当前用户为 root | 页面跳转至 `/config/tool` 工具配置页面 | + +- **前置条件**:系统工具列表为空 且 `userInfo.username === 'root'` +- **后置影响**:跳转至工具配置管理页面,可添加/管理团队工具 + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 系统工具页面 + participant API as 后端API + + User->>UI: 进入系统工具页面 + par 并行加载 + UI->>API: GET /core/plugin/team/list (type: tool) + UI->>API: GET /core/plugin/toolTag/list + end + API-->>UI: 工具列表 + 标签列表 + UI-->>User: 展示工具卡片网格 + 标签栏 + + User->>UI: 点击标签 / 输入搜索关键词 + UI-->>User: 前端过滤更新工具列表 + + User->>UI: 点击工具卡片 + UI-->>User: 弹出详情抽屉 + + User->>UI: 点击安装 / 卸载 + UI->>API: POST /core/plugin/team/toggleInstall + API-->>UI: 操作结果 + UI-->>User: 更新工具安装状态 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..753206ecdab4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 系统工具 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: ["所有用户", "root管理员"] +router_paths: ["/dashboard/systemTool"] +--- + +# 系统工具 — 模块概览 + +## 1. 模块业务概述 + +系统工具模块是 FastGPT 工作台中的系统工具市场页面,为用户提供一个集中浏览、搜索和安装系统预置工具的入口。用户在此可以查看所有系统官方提供的工具(Plugin),按标签分类筛选,并根据需要安装或卸载工具。 + +本模块位于工作台侧边导航中,与"我的工具"页面形成对应关系——系统工具展示可安装的官方工具库,我的工具展示用户已安装的工具。页面面向所有登录用户开放,安装后的工具可在应用工作流中作为节点使用。 + +本模块为叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有用户 | 系统工具市场 | 浏览工具列表、按标签筛选、搜索工具、安装/卸载工具、查看工具详情 | 基本操作对所有用户一致 | +| root管理员 | 系统工具市场 | 以上全部 + 工具列表为空时可跳转至工具配置页面 | 仅有 root 用户可在系统工具列表为空时看到「点击配置」按钮,跳转至 `/config/tool` 进行工具配置 | + +**权限模型**: 系统工具页面通过路由访问控制,属于工作台内部的公开页面。页面内通过 `userInfo.username === 'root'` 判断是否为超级管理员,仅对 root 用户展示额外的配置入口。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台容器 | DashboardContainer 侧边栏布局、导航 | 组件 | +| 插件工具标签 | getPluginToolTags 获取工具标签列表 | API | +| 团队插件管理 | getTeamSystemPluginList、postToggleInstallPlugin、getTeamToolDetail 团队插件CRUD | API | +| 全局工具卡片 | ToolCard 工具卡片展示组件 | 组件 | +| 全局工具详情 | ToolDetailDrawer 工具详情抽屉组件 | 组件 | + +## 4. 对外共享 + +本模块为叶子页面,不对外暴露共享能力。页面组件 `ContextRender` 仅作为路由入口组件,不被其他模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 系统工具市场 | `/dashboard/systemTool` | `projects/app/src/pages/dashboard/systemTool/index.tsx` | 所有用户 | 单页面,含标签筛选、搜索、工具卡片网格、详情抽屉 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..64efc423367c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,50 @@ +--- +capability_label: 评测 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员 +router_paths: [] +--- + +# 评测 — 业务流程索引 + +> 评测模块为分组节点,业务场景由各子能力独立承载。本文档提供子能力场景汇总,详细内容见各子能力的 [业务流程索引]。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab/子模块 | 触发入口 | 路由路径 | 角色 | +|------|------|-----------|---------|---------|------| +| S01 | 浏览评测功能 | — | 侧边栏 → 应用测评 | /dashboard/evaluation | 有评测权限的成员 | +| S01-1 |  ├ 管理评测任务 | 评测任务 | 评测首页 → 默认 Tab | /dashboard/evaluation | 有评测权限的成员 | +| S01-2 |  ├ 管理评测数据集 | 评测数据集 | 评测首页 → 切换数据集 Tab | /dashboard/evaluation | 有评测权限的成员 | +| S01-3 |  ├ 管理评测维度 | 评测维度 | 评测首页 → 切换维度 Tab | /dashboard/evaluation | 有评测权限的成员 | +| S02 | 创建评测任务 | — | 评测任务 Tab → 新建按钮 | /dashboard/evaluation/create | 有评测权限的成员 | +| S03 | 查看任务详情 | — | 评测任务列表 → 点击任务行 | /dashboard/evaluation/task/detail | 有评测权限的成员 | +| S04 | 创建数据集(智能生成) | — | 评测数据集 Tab → 新建 → 智能生成 | /dashboard/evaluation/dataset(弹窗) | 有评测权限的成员 | +| S05 | 创建数据集(文件导入) | — | 评测数据集 Tab → 新建 → 文件导入 | /dashboard/evaluation/dataset/fileImport | 有评测权限的成员 | +| S06 | 查看数据集详情 | — | 评测数据集列表 → 点击数据集行 | /dashboard/evaluation/dataset/detail | 有评测权限的成员 | +| S07 | 创建评测维度 | — | 评测维度 Tab → 创建维度按钮 | /dashboard/evaluation/dimension/create | 有评测权限的成员 | +| S08 | 编辑评测维度 | — | 评测维度列表 → 点击自定义维度行 | /dashboard/evaluation/dimension/edit | 有评测权限的成员 | + +## 场景关系 + +评测的典型使用流程为: + +- **准备数据集**(S04/S05)→ **定义评测维度**(S07/S08)→ **创建评测任务**(S02,选择应用、模型、数据集和维度)→ **查看任务结果**(S03) +- **S01-1 / S01-2 / S01-3** 三个 Tab 场景互相独立,用户可在评测首页自由切换查看 + +子能力之间的场景无强制先后关系:用户可先创建数据集或维度,再在创建评测任务时统一选择;也可在已有数据集和维度的基础上直接创建评测任务。 + +## 子能力场景索引 + +| 子能力 | 场景数 | 文档链接 | +|--------|--------|---------| +| 评测首页 | 1(含 3 个 Tab 子场景) | [评测首页 — 业务流程索引](./评测首页/业务流程索引.md) | +| 创建评测 | 1 | [创建评测 — 业务流程索引](./创建评测/业务流程索引.md) | +| 评测数据集 | 3 | [评测数据集 — 业务流程索引](./评测数据集/业务流程索引.md) | +| 评测维度 | 2 | [评测维度 — 业务流程索引](./评测维度/业务流程索引.md) | +| 评测任务 | 2 | [评测任务 — 业务流程索引](./评测任务/业务流程索引.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..0e100fbc9bea --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,90 @@ +--- +capability_label: 评测 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员 +router_paths: [] +--- + +# 评测 — 业务流程详解 + +> 评测模块为分组节点,详细业务流程由各子能力独立承载。本文档提供评测流程的全局视图。 + +## 评测总流程 + +评测模块的完整业务流程分为准备和执行两大阶段: + +### 准备阶段 + +```mermaid +sequenceDiagram + participant 用户 + participant 评测首页 + participant 评测数据集 + participant 评测维度 + participant 后端API + + 用户->>评测首页: 进入应用测评入口 + 评测首页->>后端API: 检查 feConfigs.show_evaluation + alt 功能未开启 + 评测首页->>用户: 重定向至 /dashboard + end + 用户->>评测数据集: 创建测试数据集 + 评测数据集->>后端API: POST /core/evaluation/dataset/create + 后端API-->>评测数据集: 返回数据集 ID + 用户->>评测维度: 定义评分维度 + 评测维度->>后端API: POST /core/evaluation/dimension/create + 后端API-->>评测维度: 返回维度 ID +``` + +### 执行阶段 + +```mermaid +sequenceDiagram + participant 用户 + participant 创建评测 + participant 评测首页 + participant 评测任务 + participant 后端API + + 用户->>创建评测: 填写评测配置(应用、模型、数据集、维度) + 创建评测->>后端API: POST /core/evaluation/task/create + 后端API-->>创建评测: 创建成功,返回任务 ID + 创建评测->>用户: 跳转至评测首页 + 评测首页->>后端API: 轮询任务状态 + loop 评测进行中 + 评测首页->>后端API: GET /core/evaluation/task/status + 后端API-->>评测首页: 返回进度信息 + end + 用户->>评测任务: 查看评测详情 + 评测任务->>后端API: GET /core/evaluation/task/detail + 后端API-->>评测任务: 返回评分结果和原因 +``` + +## 子能力场景关联 + +各子能力的详细业务流程见对应文档: + +| 子能力 | 文档 | 说明 | +|--------|------|------| +| 评测首页 | [业务流程详解](../评测首页/业务流程详解.md) | 评测入口的三 Tab 聚合展示 | +| 创建评测 | [业务流程详解](../创建评测/业务流程详解.md) | 评测任务创建流程 | +| 评测数据集 | [业务流程详解](../评测数据集/业务流程详解.md) | 数据集管理和导入 | +| 评测维度 | [业务流程详解](../评测维度/业务流程详解.md) | 评分维度管理 | +| 评测任务 | [业务流程详解](../评测任务/业务流程详解.md) | 任务执行和结果查看 | + +## API 调用汇总 + +| API 路径 | 方法 | 用途 | 页面 | +|---------|------|------|------| +| `/core/evaluation/task/list` | GET | 获取评测任务列表 | 评测首页(评测任务 Tab) | +| `/core/evaluation/dataset/list` | GET | 获取评测数据集列表 | 评测首页(评测数据集 Tab) | +| `/core/evaluation/dimension/list` | GET | 获取评测维度列表 | 评测首页(评测维度 Tab) | +| `/core/evaluation/task/create` | POST | 创建评测任务 | 创建评测 | +| `/core/evaluation/dataset/create` | POST | 创建评测数据集 | 评测数据集 | +| `/core/evaluation/dimension/create` | POST | 创建评测维度 | 评测维度 | +| `/core/evaluation/task/detail` | GET | 获取评测任务详情 | 评测任务 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2a96f20f81c6 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,30 @@ +--- +capability_label: 创建评测 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +# 创建评测 — 业务流程索引 + +## 创建评测任务 + +- **业务描述**: 管理员选择一个已创建的应用,指定评测模型,上传按模板填写的 CSV 评测文件,创建一条评测任务。任务创建后自动跳转至评测首页。 +- **触发入口**: 工作台侧边栏 → 评测 → 评测首页 → "新建任务"按钮 +- **路由路径**: /dashboard/evaluation/create +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/create.tsx +- **涉及角色**: admin +- **前置条件**: 已登录且具有团队管理员权限;目标应用已存在 + +## 场景关系 + +创建评测任务是独立的原子操作,无上下游场景依赖。创建完成后跳转至评测首页查看任务列表。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 创建评测任务 | 评测首页 → 新建任务按钮 | /dashboard/evaluation/create | admin | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..14c60a27e9f4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,137 @@ +--- +capability_label: 创建评测 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +# 创建评测 — 业务流程详解 + +## 页面总览 + +创建评测页面是一个单页表单,管理员在此填写评测任务的基本信息并上传 CSV 评测文件。页面左侧为 Dashboard 侧边导航栏,右侧为表单区域。顶部有"退出"按钮可返回评测首页。 + +## 创建评测任务 + +> 管理员选择待评测应用、指定评测模型、上传 CSV 评测文件,创建一条评测任务。 + +### 步骤 1:进入创建页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在评测首页点击"新建任务"按钮 | — | — | 路由跳转至 /dashboard/evaluation/create,显示创建表单页面 | + +### 步骤 2:填写任务名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"任务名"输入框中输入名称 | — | 名称为空时,提交按钮置灰不可点击 | 输入框自动聚焦,显示用户输入内容 | + +### 步骤 3:选择评测模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击评测模型下拉框,从 LLM 模型列表中选择一个模型 | — | 默认选中模型列表第一个;模型列表为空时无可选项 | 下拉框展示当前团队可用的 LLM 模型列表,选中后显示模型名称 | + +### 步骤 4:选择评测应用与下载模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击应用选择器,搜索并选择一个应用 | — | 未选择应用时,"评测文件"区域显示提示"请选择评测应用" | 选中应用后,出现"点击下载该应用的 CSV 模板"按钮 | +| 点击"点击下载该应用的 CSV 模板"按钮 | GET /core/app/detail?appId={id}(获取应用变量配置) | 应用无变量时,模板仅含 `*q,*a,history` 三列;应用有变量时,模板额外包含变量列,必填变量前加 `*` | 按钮进入加载态,获取应用详情后触发浏览器下载 CSV 文件(文件名格式:`{应用名}_evaluation.csv`) | + +### 步骤 5:上传评测文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击或拖拽上传 CSV 文件(仅允许 .csv,最多 1 个) | — | 未选择应用时,文件上传区域不可用 | 文件选择器高亮,选中文件后显示文件图标、文件名和删除按钮 | +| 点击文件旁的删除按钮 | — | — | 该文件从列表中移除,错误提示清除 | + +### 步骤 6:提交创建任务 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"开始评测"按钮 | POST /core/evaluation/create(FormData 上传文件 + JSON 参数) | 未选应用:提示"请选择评测应用";未选文件:提示"请选择评测文件";任一必填项为空:按钮置灰 | 按钮进入加载态,显示上传进度百分比("文件上传中: N%") | +| 等待文件上传完成 | — | 上传完成(100%)→ 显示"正在创建任务";上传中 → 持续显示进度 | 按钮文案切换为"正在创建任务" | +| 创建成功 | — | — | 弹出成功提示"评测任务创建成功",自动跳转至评测首页 /dashboard/evaluation | +| 文件校验失败 | — | 错误码匹配 evaluationFileErrors(表头缺失/无数据/格式无效):显示红色错误卡片,列出格式校验失败原因及修复指引 | 文件卡片边框变红,下方展示 Markdown 格式的错误提示 | +| AI 积分不足 | — | 错误码匹配 TeamErrEnum.aiPointsNotEnough | 弹出积分不足弹窗 | +| 其他错误 | — | 其他异常 | 弹出通用错误提示 | + +#### 数据加载详情 + +本页面在初始化时不会主动发起列表请求。仅在下载模板时才调用应用详情接口获取变量信息。 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 下载模板 | GET /core/app/detail | appId={选中的应用ID} | 提取 chatConfig.variables,拼接 CSV 表头 | 浏览器下载 CSV 文件 | + +#### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 任务名 | 文本输入 | 是 | — | 无特殊约束 | 否 | 评测任务名称 | +| 评测模型 | 下拉选择 | 否(默认选中第一个) | LLM 模型列表第一项 | 当前团队可用的 LLM 模型 | 否 | 用于评测的 AI 模型 | +| 评测应用 | 搜索选择 | 是 | — | 当前团队已创建的应用 | 否 | 被评测的目标应用 | +| 评测文件 | CSV 文件上传 | 是 | — | .csv 格式,最多 1 个文件,需符合模板格式 | 否 | 包含问题和参考答案的评测数据 | + +**字段联动**: +- 选择应用后,"评测文件"区域从不显示变为可用,同时出现"下载模板"按钮 +- 选择应用后,下载的 CSV 模板列会根据应用配置的变量动态变化 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 应用未选择 | 提交时 | "请选择评测应用" | +| 文件未选择 | 提交时 | "请选择评测文件" | +| 按钮置灰条件 | 实时 | 任一必填项为空、存在文件校验错误时置灰 | +| CSV 文件表头不匹配 | 提交后(服务端校验) | 文件卡片变红,显示格式校验失败详情及修复指引 | +| CSV 文件无数据 | 提交后(服务端校验) | 文件卡片变红,显示无数据错误 | +| CSV 文件格式无效 | 提交后(服务端校验) | 文件卡片变红,显示格式无效错误 | +| AI 积分不足 | 提交后(服务端校验) | 弹出积分不足弹窗 | + +**前后置条件**: +- **前置条件**:已登录团队管理员账号;团队下已有至少一个应用;团队下无正在运行的评测任务(否则提示等待) +- **后置影响**:创建成功后生成一条评测任务记录,系统自动解析 CSV 文件并开始评测;页面跳转至评测首页 +- **失败场景**:文件校验失败时留在当前页面,用户可删除错误文件重新上传;积分不足时需先充值 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 创建评测页面 + participant API as 后端 API + participant Backend as 评测服务 + + User->>UI: 访问创建评测页面 + UI-->>User: 显示空表单 + + User->>UI: 填写任务名、选择评测模型、选择应用 + + User->>UI: 点击下载模板 + UI->>API: GET /core/app/detail?appId={id} + API-->>UI: 返回应用变量配置 + UI-->>User: 浏览器下载 CSV 模板文件 + + User->>UI: 上传填写好的 CSV 文件 + + User->>UI: 点击"开始评测" + UI->>UI: 校验(应用已选、文件已选) + + UI->>API: POST /core/evaluation/create (FormData: file + JSON: name, evalModel, appId) + UI-->>User: 显示上传进度(文件上传中: N%) + + API->>Backend: 创建评测任务 + Backend-->>API: 任务创建成功 + + API-->>UI: 返回创建结果 + UI-->>User: 提示"评测任务创建成功" + UI->>UI: 跳转至评测首页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..487f5c78233b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,15 @@ +--- +capability_label: 创建评测 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +## 说明 + +本模块未检测到对外共享能力。创建评测页面为独立入口页面,不被其他模块引用。 + +跨模块依赖摘要请参见 [模块概览](./模块概览.md) §3。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..12fb70a47682 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 创建评测 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +# 创建评测 — 模块概览 + +## 1. 模块业务概述 + +创建评测是 FastGPT 应用评测体系的入口功能,为团队管理员提供创建评测任务的表单页面。用户在此页面选择待评测的应用、指定评测模型、上传符合模板的 CSV 评测文件,提交后系统将自动解析文件并执行评测流程。 + +本模块属于"工作台 → 评测"的子能力,是评测工作流的起点。评测任务创建成功后,用户将被引导至评测首页查看任务进度和结果。 + +本模块无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| admin | 创建评测页面 | 选择应用、下载模板、上传评测文件、创建评测任务 | 团队管理员可完整使用全部功能 | + +**权限模型**: 基于 DashboardContainer 的团队权限控制,仅团队管理员可访问工作台下的评测相关页面。本页面无额外的角色判断逻辑,权限由容器层统一管控。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用管理 | 获取应用详情(含变量配置),用于生成评测 CSV 模板 | API | +| 评测模块 | 调用创建评测任务接口,上传 CSV 文件并提交任务参数 | API | +| 全局系统 | LLM 模型列表,用于评测模型选择器的数据源 | Store | +| 文件工具 | 浏览器端文件下载能力,用于下载 CSV 模板 | 工具函数 | + +## 4. 对外共享 + +本模块为叶子页面,未检测到被其他模块引用。不对外提供共享能力。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 创建评测 | /dashboard/evaluation/create | projects/app/src/pages/dashboard/evaluation/create.tsx | admin | 单页表单,无 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..6afd77916c4e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,70 @@ +--- +capability_label: 评测 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员 +router_paths: [] +--- + +# 评测 — 模块概览 + +## 1. 模块业务概述 + +评测模块是 FastGPT 工作台中用于**评估 AI 应用回答质量**的功能区。用户可将包含测试问题和预期答案的数据集导入系统,选择待评测的 AI 应用和评测模型,启动自动化评测任务。系统会自动将数据集中的问题逐一发送至目标应用,由评测模型对回答质量进行评分,最终产出多维度评测报告。 + +本模块为 Beta 功能,面向拥有评测权限的团队成员开放(通过 `feConfigs.show_evaluation` 配置开关和 `hasEvaluationCreatePer` 权限控制)。评测能力是 AI 应用上线前的关键质量保障环节,帮助开发者量化应用表现、对比不同版本差异、定位回答质量短板。 + +评测模块下含五个子能力,业务关系如下: +- **评测首页**:作为评测入口,以三个 Tab 聚合展示评测任务、评测数据集和评测维度 +- **创建评测**:提供评测任务创建表单,选择应用、模型、上传测试文件 +- **评测数据集**:管理测试数据集,支持智能生成和文件导入两种创建方式 +- **评测维度**:管理评测评分维度,内置常用维度模板,支持自定义维度 +- **评测任务**:管理评测任务生命周期,查看任务执行进度和评分结果 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 全部评测页面 | 创建/删除评测任务、管理数据集和维度、查看评测结果 | 拥有 `hasEvaluationCreatePer` 权限,可执行所有操作 | +| 团队成员(有评测权限) | 全部评测页面 | 同管理员 | 权限模型基于 `hasEvaluationCreatePer` 和 `hasManagePer` | +| 团队成员(无评测权限) | 不可见评测入口 | 无 | 侧边栏不展示「应用测评」菜单项 | + +**权限模型**: 侧边栏菜单入口受双重控制:`feConfigs.show_evaluation`(系统级功能开关)和 `userInfo.team.permission.hasEvaluationCreatePer`(团队级权限)。评测首页的访问守卫在组件挂载时通过 `useEffect` 检查 `feConfigs.show_evaluation`,若为 `false` 则重定向至 `/dashboard`。各子能力页面继承 DashboardContainer 的统一布局框架。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用 | 选择目标应用(AppSelect 组件);获取应用详情、变量配置 | 组件/API | +| 对话 | 聊天模型(AIModelSelector)配置评测模型 | 组件 | +| 文件 | 文件上传组件(FileSelector);CSV 文件解析和校验 | 组件/工具 | + +> 评测模块对数据集模块的交互发生于评测数据集子能力(智能生成依赖知识库数据集),详见各子能力文档。 + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 评测结果数据 | 应用详情(通过 API 查询版本关联的评测报告) | API | +| 评测维度模板 | 创建评测任务(选择评测维度时引用内置和自定义维度) | 数据 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测首页 | /dashboard/evaluation | projects/app/src/pages/dashboard/evaluation/index.tsx | 有评测权限的成员 | 含 3 个 Tab(详见 [09-业务流程索引](./业务流程索引.md)) | +| 创建评测 | /dashboard/evaluation/create | projects/app/src/pages/dashboard/evaluation/create.tsx | 有评测权限的成员 | 独立页面,从评测任务列表「新建」按钮或顶部导航进入 | + +## 6. 子能力索引 + +| 子能力 | 描述 | 文档链接 | +|--------|------|---------| +| 评测首页 | 三 Tab 聚合页,展示评测任务/数据集/维度列表 | [评测首页](./评测首页/模块概览.md) | +| 创建评测 | 创建新评测任务的表单页面 | [创建评测](./创建评测/模块概览.md) | +| 评测数据集 | 管理评测用的测试数据集,支持智能生成和文件导入 | [评测数据集](./评测数据集/模块概览.md) | +| 评测维度 | 管理评测评分维度,含内置和自定义维度 | [评测维度](./评测维度/模块概览.md) | +| 评测任务 | 管理评测任务列表,查看进度和结果 | [评测任务](./评测任务/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..4062e707024e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,83 @@ +--- +capability_label: "评测任务" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测" +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task", "/dashboard/evaluation/task/detail"] +--- + +# 评测任务 — 业务流程索引 + +## 评测任务首页 + +- **业务描述**: 查看和管理所有评测任务,按应用筛选、按名称搜索任务,监控任务执行进度和评测结果 +- **触发入口**: 工作台 → 评测 → 评测任务 Tab +- **路由路径**: /dashboard/evaluation/task +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/task/index.tsx +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 已登录,属于某个团队 + +## 创建评测任务 + +- **业务描述**: 创建新的评测任务,选择目标应用和版本、评测数据集、配置评测维度(内置维度或自定义维度),提交后任务进入排队状态等待执行 +- **触发入口**: 评测任务首页 → "创建新任务"按钮 +- **路由路径**: /dashboard/evaluation/task(弹窗形式) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx +- **涉及角色**: 团队管理员 +- **前置条件**: 已选择目标应用,已有评测数据集,已配置评测维度 + +## 重命名评测任务 + +- **业务描述**: 修改已有评测任务的名称 +- **触发入口**: 评测任务首页 → 任务行操作菜单 → "重命名" +- **路由路径**: /dashboard/evaluation/task +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/task/index.tsx +- **涉及角色**: 团队管理员 +- **前置条件**: 任务存在 + +## 删除评测任务 + +- **业务描述**: 删除指定的评测任务及其全部评测结果数据 +- **触发入口**: 评测任务首页 → 任务行操作菜单 → "删除" +- **路由路径**: /dashboard/evaluation/task +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/task/index.tsx +- **涉及角色**: 团队管理员 +- **前置条件**: 任务存在,需在确认弹窗中再次确认 + +## 重试异常数据 + +- **业务描述**: 对任务中状态为异常的评测项重新提交评测请求 +- **触发入口**: 评测任务首页 → 有异常数据的任务行操作菜单 → "重试异常数据" +- **路由路径**: /dashboard/evaluation/task +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/task/index.tsx +- **涉及角色**: 团队管理员 +- **前置条件**: 任务存在异常数据项(error > 0) + +## 查看任务详情 + +- **业务描述**: 进入单个评测任务的详情页面,查看全部评测数据的列表、各维度得分、综合评分、错误信息和评测总结报告 +- **触发入口**: 评测任务首页 → 点击任务行 +- **路由路径**: /dashboard/evaluation/task/detail +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 任务存在 + +## 场景关系 + +- **评测任务首页** → **创建评测任务**:必须先有任务才能查看详情和执行评测 +- **评测任务首页** → **查看任务详情**:从列表页点击进入详情 +- **测评任务首页** → **重试异常数据**:任务执行完成且存在异常数据时可触发 +- **任务详情** ↔ **重试异常数据**(详情页内):在详情页的异常数据 Tab 中也可操作重试 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 评测任务首页 | 工作台→评测→评测任务Tab | /dashboard/evaluation/task | 团队成员 | +| S02 | 创建评测任务 | 任务首页→创建按钮 | /dashboard/evaluation/task | 团队管理员 | +| S03 | 重命名评测任务 | 任务首页→更多→重命名 | /dashboard/evaluation/task | 团队管理员 | +| S04 | 删除评测任务 | 任务首页→更多→删除 | /dashboard/evaluation/task | 团队管理员 | +| S05 | 重试异常数据 | 任务首页→更多→重试异常数据 | /dashboard/evaluation/task | 团队管理员 | +| S06 | 查看任务详情 | 任务首页→点击任务行 | /dashboard/evaluation/task/detail | 团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..42e93132d154 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,56 @@ +--- +capability_label: "评测任务" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测" +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task", "/dashboard/evaluation/task/detail"] +--- + +# 评测任务 — 业务流程详解 + +## 子能力业务流程索引 + +本模块为分组节点,包含以下子能力,各子能力的详细业务流程请参见对应文档: + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|------------|------------| +| 评测任务首页 | 评测任务列表、搜索筛选、创建任务、任务操作 | [业务流程索引](../评测任务首页/业务流程索引.md) | [业务流程详解](../评测任务首页/业务流程详解.md) | +| 任务详情 | 评测数据列表、评测维度结果、综合评分、数据编辑、配置参数 | [业务流程索引](../任务详情/业务流程索引.md) | [业务流程详解](../任务详情/业务流程详解.md) | + +## 公共业务流程 + +以下公共流程适用于评测任务模块的全部子能力页面。 + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 1. 进入评测任务区 | 在工作台点击"评测"→ 切换到"评测任务"标签页 | GET /core/evaluation/task/list(加载任务列表) | 无 | 显示任务列表页面,展示分页表格 | +| 2. 搜索任务 | 在搜索框中输入关键词(500ms 防抖) | POST /core/evaluation/task/list(携带 searchKey 参数) | 搜索值为空时显示全部 | 表格数据按搜索条件实时刷新 | +| 3. 应用筛选 | 从应用下拉框中选择目标应用或"全部应用" | POST /core/evaluation/task/list(携带 appId 参数) | 选择"全部应用"时不传 appId | 表格数据按应用筛选条件刷新 | +| 4. 翻页浏览 | 点击分页控件切换页码 | POST /core/evaluation/task/list(携带 pageNum/pageSize) | 无 | 表格加载对应页数据 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 评测任务页面 + participant API + participant Backend + + User->>UI: 进入评测任务区 + UI->>API: POST /core/evaluation/task/list + API->>Backend: 查询任务列表 + Backend-->>API: 返回任务数据 + API-->>UI: 任务列表 + UI-->>User: 展示任务表格 + + User->>UI: 输入搜索关键词 + UI->>UI: 500ms 防抖 + UI->>API: POST /core/evaluation/task/list (searchKey) + API-->>UI: 过滤后的任务列表 + UI-->>User: 更新表格数据 +``` + +> 各子能力(评测任务首页、任务详情)的详细交互流程、API 调用链、分支条件、表单字段和校验规则请参见对应的子能力文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..304ad99f0c07 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,149 @@ +--- +capability_label: 任务详情 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — 业务流程索引 + +## 1. 业务场景列表 + +### 查看评测结果 + +- **业务描述**: 进入评测任务详情页,浏览全部评测数据的评分结果及各维度的得分统计。 +- **触发入口**: 评测任务列表 → 点击任务名称进入 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 用户已登录并属于该评测任务所在团队;评测任务已创建 + +### 过滤查看问题数据 + +- **业务描述**: 切换至"问题数据"标签页,筛选出评分低于阈值的评测项,快速定位需要关注的异常数据。 +- **触发入口**: 详情页顶部 → 点击"问题数据"标签 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}¤tTab=questionData` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 统计数据显示存在低于阈值的数据项(标签仅在 `belowThreshold > 0` 时显示) + +### 过滤查看错误数据 + +- **业务描述**: 切换至"错误数据"标签页,筛选出执行异常的评测项,可进行批量重试。 +- **触发入口**: 详情页顶部 → 点击"错误数据"标签 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}¤tTab=errorData` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 统计数据显示存在错误数据项(标签仅在 `error > 0` 时显示) + +### 搜索评测数据 + +- **业务描述**: 通过搜索框输入关键词,按用户问题内容筛选评测项列表。 +- **触发入口**: 数据列表区域上方的搜索框 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测数据列表已加载 + +### 编辑评测数据 + +- **业务描述**: 对处于错误状态的评测项,修改问题和参考答案内容后重新保存,可选择是否同步修改数据集。 +- **触发入口**: 选中错误状态的数据项 → 点击编辑按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 选中的数据项状态为"错误"(`EvaluationStatusEnum.error`) + +### 重试单条评测 + +- **业务描述**: 对执行失败的单条评测项发起重新评测请求。 +- **触发入口**: 选中错误状态的数据项 → 点击重试按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 选中的数据项状态为"错误" + +### 删除评测数据 + +- **业务描述**: 从评测任务中删除单条评测数据,删除后自动选中下一条。 +- **触发入口**: 选中数据项 → 点击删除按钮 → 确认弹窗 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 选中的数据项状态为"错误"或"已完成" + +### 批量重试失败项 + +- **业务描述**: 在错误数据标签页下,一键重试所有执行失败的评测项,完成后自动切换到全部数据标签页。 +- **触发入口**: 错误数据标签页 → 点击"重试"按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}¤tTab=errorData` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 当前位于错误数据标签页 + +### 导出评测数据 + +- **业务描述**: 将当前筛选条件下的评测数据导出为 CSV 文件,包含问题、参考答案、实际回答、各维度评分、状态等信息。 +- **触发入口**: 详情页顶部 → 点击"导出"按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测任务已加载 + +### 刷新评分 + +- **业务描述**: 手动触发重新计算综合评分和各维度得分。 +- **触发入口**: 右侧评分面板 → 点击刷新按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测任务不在排队或执行中状态 + +### 配置评分参数 + +- **业务描述**: 调整评测维度的判定阈值、权重,以及分数聚合方式(均值/中位数)。 +- **触发入口**: 右侧评分面板 → 点击设置按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx`(通过 ConfigParams 弹窗) +- **涉及角色**: 团队成员 +- **前置条件**: 评测任务已加载完成 + +### 查看完整响应 + +- **业务描述**: 查看评测项对应的 AI 对话完整响应内容。 +- **触发入口**: 选中已完成状态的数据项 → 点击"查看完整响应"按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={taskId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx`(通过 DetailedResponseModal 弹窗) +- **涉及角色**: 团队成员 +- **前置条件**: 选中的数据项状态为"已完成";数据项有关联的 AI 对话数据 + +## 2. 场景关系说明 + +- **查看评测结果** → **过滤查看问题数据/错误数据**: 用户先进入详情页查看全部数据,发现异常后切换到问题数据或错误数据标签进行针对性分析。 +- **过滤查看错误数据** → **批量重试失败项**: 在错误数据标签下可一键重试所有失败项。 +- **过滤查看错误数据** → **编辑评测数据** 或 **重试单条评测** 或 **删除评测数据**: 针对单条错误数据执行操作。 +- **查看评测结果** → **导出评测数据**: 查看结果后可导出当前筛选条件下的数据。 +- **查看评测结果** → **刷新评分** 或 **配置评分参数**: 查看评分后可能需要调整参数或刷新统计。 + +其他场景独立执行,无严格先后关系。 + +## 3. 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看评测结果 | 评测任务列表→点击任务名称 | /dashboard/evaluation/task/detail | 团队成员 | +| S01-1 |  ├ 全部数据 | 详情页→默认Tab | /dashboard/evaluation/task/detail?currentTab=allData | 团队成员 | +| S01-2 |  ├ 问题数据 | 详情页→切换问题数据Tab | /dashboard/evaluation/task/detail?currentTab=questionData | 团队成员 | +| S01-3 |  ├ 错误数据 | 详情页→切换错误数据Tab | /dashboard/evaluation/task/detail?currentTab=errorData | 团队成员 | +| S02 | 搜索评测数据 | 数据列表上方搜索框 | /dashboard/evaluation/task/detail | 团队成员 | +| S03 | 编辑评测数据 | 选中错误项→点击编辑按钮 | /dashboard/evaluation/task/detail | 团队成员 | +| S04 | 重试单条评测 | 选中错误项→点击重试按钮 | /dashboard/evaluation/task/detail | 团队成员 | +| S05 | 删除评测数据 | 选中项→点击删除按钮→确认 | /dashboard/evaluation/task/detail | 团队成员 | +| S06 | 批量重试失败项 | 错误数据Tab→点击重试按钮 | /dashboard/evaluation/task/detail?currentTab=errorData | 团队成员 | +| S07 | 导出评测数据 | 顶部→点击导出按钮 | /dashboard/evaluation/task/detail | 团队成员 | +| S08 | 刷新评分 | 右侧评分面板→点击刷新按钮 | /dashboard/evaluation/task/detail | 团队成员 | +| S09 | 配置评分参数 | 右侧评分面板→点击设置按钮 | /dashboard/evaluation/task/detail | 团队成员 | +| S10 | 查看完整响应 | 选中已完成项→点击查看完整响应 | /dashboard/evaluation/task/detail | 团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..6c3d41f9c26c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,538 @@ +--- +capability_label: 任务详情 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — 业务流程详解 + +## 页面总览 + +评测任务详情页是评测结果的核心查看与操作界面。页面采用三栏布局:左侧 40% 为数据列表(支持搜索、分页、Tab 过滤),中间 60% 为数据详情(展示问题、参考答案、实际回答、评测维度评分),右侧 17rem 固定宽度面板展示综合评分和任务基本信息。评测执行中或排队中时,页面每 15 秒自动轮询刷新数据。 + +## 查看评测结果 + +> 用户从评测任务列表进入详情页,加载并查看评测任务的完整执行结果。 + +### 步骤 1:页面初始化与数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从评测任务列表点击任务名称,浏览器导航至 `/dashboard/evaluation/task/detail?taskId={id}` | GET `/core/evaluation/task/detail`(获取任务详情) | 任务不存在或无权访问 → 跳转回评测任务列表并提示"加载失败" | 页面开始加载,右侧面板显示加载状态(MyBox 的 isLoading),数据列表区域显示加载动画 | + +### 步骤 2:并行加载统计和详情数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | GET `/core/evaluation/task/stats`(获取统计信息)+ GET `/core/evaluation/task/detail`(获取评测详情) — 并行请求 | 无 | 统计数据和评测详情加载完成,NavBar 中显示数据计数标签 | + +### 步骤 3:加载评分总结和应用信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | GET `/core/evaluation/summary/detail`(获取总结报告) | 无 | 右侧面板加载评分仪表盘和维度得分条 | +| 无需操作(自动触发) | GET 应用详情接口(通过 getAppDetailById,仅在有 appId 时调用) | `evaluationDetail.target.config.appId` 存在 | 基本信息面板中的应用名称可点击跳转 | + +### 步骤 4:加载评测项列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | POST `/core/evaluation/task/item/list`(获取评测项列表,pageSize=20) | 无搜索关键词 → 不带 userInput 过滤;当前 Tab 为 questionData → 带 `belowThreshold: true`;当前 Tab 为 errorData → 带 `status: EvaluationStatusEnum.error` | 左侧列表加载首批 20 条数据,列表表头根据评测维度数量动态生成 | + +### 步骤 5:实时轮询刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(页面停留) | 每 15 秒自动调用:GET stats(轮询)+ GET summary(轮询)+ GET detail(轮询)+ POST item/list(智能刷新,仅加载到当前选中项所在页码) | 评测任务状态为排队中或评测中 → 右侧面板显示"排队中..."或"评测中..."的渐变加载动画;评测完成 → 面板切换显示评分仪表盘和各维度得分条 | 数据静默更新(无加载遮罩),列表数据直接替换 | + +### 步骤 6:切换标签页过滤 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 NavBar 中的"问题数据"或"错误数据"标签 | 浏览器 URL query 更新 `currentTab`,触发 useScrollPagination 自动重新请求 POST `/core/evaluation/task/item/list`(携带对应过滤参数) | 问题数据 Tab:`belowThreshold > 0` 才显示标签,API 参数带 `belowThreshold: true`;错误数据 Tab:`error > 0` 才显示标签,API 参数带 `status: EvaluationStatusEnum.error`;错误数据 Tab → 显示"重试"按钮 | 数据列表按过滤条件刷新,标签高亮切换,错误数据 Tab 下显示批量重试按钮 | + +### 步骤 7:点击数据项查看详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左侧列表中的某条数据 | 无额外 API 调用(数据已有) | 选中项状态为"已完成"且有 evaluatorOutputs → 中间详情区域显示评测维度标签和评分;选中项状态为"错误" → 显示错误信息红色提示框 + 编辑/重试/删除按钮;选中项状态为"已完成" → 显示"查看完整响应"按钮 + 删除按钮;选中项状态为"排队中"或"评测中" → 不显示操作按钮 | 中间详情区刷新:问题、参考答案、实际回答区域更新;评测维度评分标签更新;操作按钮根据状态动态显示 | + +### 步骤 8:滚动加载更多数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动数据列表到底部 | POST `/core/evaluation/task/item/list`(offset=当前已加载数量,pageSize=20) | 无更多数据 → 不发起请求 | 列表追加新数据,滚动条位置保持 | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载(任务详情) | GET /core/evaluation/task/detail | evalId | 合并 status 和 stats 字段 | 任务名称显示在面包屑和页面标题 | +| 首次加载(统计) | GET /core/evaluation/task/stats | evalId | total/completed/error/belowThreshold 计数 | NavBar 标签上显示计数 | +| 首次加载(评分) | GET /core/evaluation/summary/detail | evalId | data 数组(每项含 metricName、metricScore、threshold、weight) | 右侧评分仪表盘/得分条 | +| 首次加载(列表) | POST /core/evaluation/task/item/list | evalId, offset=0, pageSize=20, 可选的 status/userInput/belowThreshold | 按 evaluatorOutputs 计算显示得分 | 列表展示前 20 条 | +| 翻页 | POST /core/evaluation/task/item/list | offset=N, pageSize=20 | 无额外处理 | 列表第 N 页数据 | +| 轮询刷新 | GET stats + GET summary + GET detail + POST item/list(并行) | 同上 | 静默替换已有数据 | 数据实时更新 | + +- 分页参数:默认每页 20 条,通过滚动自动加载下一页 +- 排序规则:按数据库默认排序(评测项创建顺序) +- 筛选条件:搜索框按 `userInput` 字段模糊匹配(500ms 防抖);Tab 切换按状态/阈值过滤 +- 表头动态生成:评测维度 < 3 个时显示每个维度名称作为列头;≥ 3 个时只显示"综合评分"列 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击任务名称进入详情页 + UI->>API: GET /core/evaluation/task/detail + API->>Backend: 查询评测任务详情 + Backend-->>API: 任务详情数据 + API-->>UI: 返回任务详情 + + par 并行加载 + UI->>API: GET /core/evaluation/task/stats + API->>Backend: 聚合统计查询 + Backend-->>API: 统计数据 + API-->>UI: 返回统计数据 + and + UI->>API: GET /core/evaluation/task/detail (轮询用) + API-->>UI: 返回评测详情 + end + + UI->>API: GET /core/evaluation/summary/detail + API-->>UI: 返回评分总结 + + UI->>API: POST /core/evaluation/task/item/list + API-->>UI: 返回评测项列表(前20条) + + UI-->>User: 页面渲染完成,显示数据列表和评分面板 + + loop 每15秒轮询 + UI->>API: 并行请求 stats + summary + detail + item/list + API-->>UI: 返回最新数据 + UI-->>User: 静默更新页面数据 + end +``` + +--- + +## 编辑评测数据 + +> 用户对执行失败的评测项修改问题或参考答案内容,可选择是否同步更新原始数据集。 + +### 步骤 1:进入编辑模式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中错误状态的评测项,点击编辑按钮 | 无 | 选中项有数据 → 表单填充当前问题和参考答案值 | 问题和参考答案区域切换为可编辑的 Textarea,顶部操作栏显示"取消"和"保存"按钮 | + +### 步骤 2:修改内容并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改问题或参考答案文本,点击"保存"按钮 | 无(弹出 Popover) | 无 | 弹出确认保存弹窗,包含"是否同步修改数据集"开关(默认开启) | + +### 步骤 3:确认保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 确认弹窗中点击"确认" | PUT `/core/evaluation/task/item/update`(含 evalItemId、userInput、expectedOutput、modifyDataset) | modifyDataset=true → 后端同步修改原始数据集中的对应数据;modifyDataset=false → 仅修改评测任务中的数据 | 弹窗关闭,编辑模式退出,列表刷新显示更新后的数据 | + +### 步骤 4:取消编辑 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮 | 无 | 无 | 编辑模式退出,表单重置为原始值 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 问题(question) | 多行文本输入(Textarea) | 否 | 当前数据项的 userInput | — | 否 | 修改后影响重新评测的输入 | +| 参考答案(expectedResponse) | 多行文本输入(Textarea) | 否 | 当前数据项的 expectedOutput | — | 否 | 修改后影响重新评测的对比基准 | + +### 字段联动 + +- 无字段联动 + +### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 无前端校验 | — | 提交错误由后端返回并通过 Toast 提示 | + +### 前后置条件 + +- **前置条件**:选中的数据项状态为"错误"(`EvaluationStatusEnum.error`) +- **后置影响**:如果 `modifyDataset=true`,同步修改原始数据集中的数据;列表自动刷新 +- **失败场景**:API 调用失败时 Toast 提示"保存失败"(已在 Context 中统一处理),编辑模式保持 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 选中错误状态数据项,点击编辑 + UI-->>User: 表单填充当前值,切换为编辑模式 + User->>UI: 修改内容,点击保存 + UI-->>User: 弹出确认弹窗(含同步数据集开关) + User->>UI: 确认保存 + UI->>API: PUT /core/evaluation/task/item/update + API->>Backend: 更新评测项和数据集 + Backend-->>API: 更新成功 + API-->>UI: 返回成功 + UI->>API: POST /core/evaluation/task/item/list (刷新列表) + API-->>UI: 返回最新列表 + UI-->>User: 编辑模式退出,列表刷新 +``` + +--- + +## 重试单条评测 + +> 对执行失败的单条评测项发起重新评测。 + +### 步骤 1:触发重试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中错误状态的评测项,点击重试按钮 | POST `/core/evaluation/task/item/retry`(含 evalItemId) | 无 | Toast 提示"重试请求已提交" | + +### 步骤 2:刷新列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | 列表自动刷新 | 无 | 列表中该评测项状态更新为"排队中" | + +### 前后置条件 + +- **前置条件**:选中的数据项状态为"错误" +- **后置影响**:评测项重新进入评测队列,状态变为"排队中" +- **失败场景**:API 调用失败 Toast 提示"重试失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击重试按钮 + UI->>API: POST /core/evaluation/task/item/retry + API->>Backend: 将评测项重新加入队列 + Backend-->>API: 成功 + API-->>UI: 返回成功 + UI-->>User: Toast: 重试请求已提交 + UI->>API: 刷新列表数据 + API-->>UI: 返回最新列表 + UI-->>User: 列表状态更新 +``` + +--- + +## 删除评测数据 + +> 从评测任务中删除单条评测数据。 + +### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击删除按钮(红色图标) | 无 | 无 | 弹出确认弹窗,提示内容为"确认删除该条数据?" | + +### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗确认 | DELETE `/core/evaluation/task/item/delete`(含 evalItemId) | 无 | Toast 提示"删除成功",列表刷新,自动选中下一条数据(若是最后一条则选新列表的最后一条) | + +### 步骤 3:取消删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗取消或关闭 | 无 | 无 | 弹窗关闭,无变化 | + +### 删除链路详情 + +- **确认弹窗**:使用 PopoverConfirm 组件,type="delete",确认文案为"确认删除该条数据?" +- **批量与单条差异**:当前仅支持单条删除,无批量删除功能 +- **级联影响**:删除后自动刷新统计数据(stats 和列表) + +### 前后置条件 + +- **前置条件**:选中的数据项状态为"错误"或"已完成" +- **后置影响**:评测项从任务中移除;统计数据刷新 +- **失败场景**:API 调用失败 Toast 提示"删除失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击删除按钮 + UI-->>User: 弹出确认弹窗 + User->>UI: 点击确认 + UI->>API: DELETE /core/evaluation/task/item/delete + API->>Backend: 删除评测项 + Backend-->>API: 删除成功 + API-->>UI: 返回成功 + UI-->>User: Toast: 删除成功 + UI->>API: 刷新列表和统计数据 + API-->>UI: 返回最新数据 + UI-->>User: 自动选中下一条数据 +``` + +--- + +## 批量重试失败项 + +> 在错误数据标签页下,一键重试所有执行失败的评测项。 + +### 步骤 1:触发批量重试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在错误数据标签页,点击"重试"按钮 | POST `/core/evaluation/task/retryFailed`(含 evalId) | 无 | Toast 提示"重试请求已提交" | + +### 步骤 2:自动切换标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | URL query 更新 `currentTab=allData`,触发列表刷新 | 无 | 页面自动切换回"全部数据"标签页,列表刷新 | + +### 前后置条件 + +- **前置条件**:当前位于错误数据标签页(`currentTab=errorData`);存在错误状态的评测项 +- **后置影响**:所有失败项重新入队评测;自动跳转到全部数据标签页 +- **失败场景**:API 调用失败 Toast 提示"重试失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 在错误数据Tab点击重试按钮 + UI->>API: POST /core/evaluation/task/retryFailed + API->>Backend: 将所有失败项重新入队 + Backend-->>API: 返回重试计数 + API-->>UI: 返回成功 + UI-->>User: Toast: 重试请求已提交 + UI->>UI: 自动切换到全部数据Tab + UI->>API: 刷新列表数据 + API-->>UI: 返回最新列表 + UI-->>User: 全部数据标签页显示 +``` + +--- + +## 导出评测数据 + +> 将当前筛选条件下的评测数据导出为 CSV 文件。 + +### 步骤 1:触发导出 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部"导出"按钮 | POST `/api/core/evaluation/task/item/export`(含 evalId、filters、headers、metricColumns、statusLabelMap)— 通过 downloadFetch 下载文件 | 导出过程中无特殊 UI 变化 | 浏览器开始下载 CSV 文件,文件名格式为 `evaluation_{任务名}_{日期}.csv` | + +### 前后置条件 + +- **前置条件**:评测任务已加载;summaryData 和 evaluationDetail 可用(用于构建导出列) +- **后置影响**:无,仅下载文件 +- **失败场景**:导出失败 Toast 提示"导出失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击导出按钮 + UI->>API: POST /api/core/evaluation/task/item/export + API->>Backend: 查询全量评测数据并生成CSV + Backend-->>API: CSV文件流 + API-->>UI: 返回文件 + UI-->>User: 浏览器下载CSV文件 +``` + +--- + +## 刷新评分 + +> 手动触发重新生成各维度的评分总结报告。 + +### 步骤 1:触发刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右侧评分面板的刷新按钮 | POST `/core/evaluation/summary/create`(含 evalId 和所有 metricIds — 从 summaryData 中提取) | summaryData 为空 → Toast 提示"无维度数据,无法生成总结";有维度数据 → 正常提交 | Toast 提示"总结生成请求已提交",刷新按钮仅在非排队/评测中状态下显示 | + +### 前后置条件 + +- **前置条件**:评测任务不在排队或评测中状态;summaryData 存在且 data 数组非空 +- **后置影响**:触发后端异步生成评分总结,轮询会自动拉取最新总结数据 +- **失败场景**:生成失败 Toast 提示"生成总结失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击刷新评分按钮 + UI->>API: POST /core/evaluation/summary/create + API->>Backend: 异步生成各维度总结报告 + Backend-->>API: 任务已提交 + API-->>UI: 返回成功 + UI-->>User: Toast: 总结生成请求已提交 + Note over UI: 后续轮询自动拉取最新总结数据 +``` + +--- + +## 配置评分参数 + +> 调整评测维度的判定阈值、权重和分数聚合方式。 + +### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右侧评分面板的设置按钮 | GET `/core/evaluation/summary/config/detail`(获取当前配置) | 无 | 弹窗打开,显示加载状态,加载完成后展示各维度配置表 | + +### 步骤 2:修改评分参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改判定阈值(1-100 的整数,仅允许数字输入)| 无 | 无 | 阈值数值实时更新 | +| 修改权重(1-100 的整数,步长 5,支持上下键微调),维度数 ≥ 3 时才显示权重列 | 无 | 权重总和 ≠ 100 → 确认按钮置灰,底部显示红色百分比;权重总和 = 100 → 确认按钮可用,底部显示正常颜色 | 权重数值实时更新 | + +### 步骤 3:切换聚合方式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在下拉框中选择"均值"或"中位数" | 无 | 无 | 聚合方式选中值更新 | + +### 步骤 4:确认保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | POST `/core/evaluation/summary/config/update`(含 evalId、calculateType、metricsConfig) | 有权重列且权重总和 ≠ 100 → 按钮置灰不可点击 | Toast 提示"保存成功",弹窗关闭,触发数据刷新 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 分数聚合方式 | 下拉选择 | 是 | 均值 | 均值 / 中位数 | 否 | 多维度综合评分的计算方式 | +| 判定阈值 | 数字输入 | 是 | 80(80%) | 1-100 的整数 | 否 | 每个维度的通过阈值 | +| 权重 | 数字输入 | 是(维度 ≥ 3 时) | 配置中的原始值 | 1-100 的整数,步长 5 | 否 | 仅维度 ≥ 3 时显示,总和须为 100 | + +### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 权重总和必须为 100 | 实时校验 | 底部百分比数字变红,确认按钮置灰 | +| 阈值范围 1-100 | 输入时 | 自动修正为 1-100 的最近有效值 | +| 权重范围 1-100 | 输入时/失焦时 | 自动修正为 1-100 的最近有效值 | + +### 前后置条件 + +- **前置条件**:评测任务详情已加载 +- **后置影响**:保存成功后触发 `loadAllData` 刷新所有相关数据 +- **失败场景**:保存失败 Toast 提示"保存失败" + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击设置按钮 + UI->>API: GET /core/evaluation/summary/config/detail + API->>Backend: 查询当前评分配置 + Backend-->>API: 配置数据 + API-->>UI: 返回各维度阈值和权重 + UI-->>User: 展示配置弹窗 + + User->>UI: 修改阈值/权重/聚合方式 + User->>UI: 点击确认 + UI->>API: POST /core/evaluation/summary/config/update + API->>Backend: 更新评分配置 + Backend-->>API: 更新成功 + API-->>UI: 返回成功 + UI-->>User: Toast: 保存成功,刷新数据 +``` + +--- + +## 查看完整响应 + +> 查看评测项关联的 AI 对话完整响应内容。 + +### 步骤 1:触发查看 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中已完成状态的评测项,点击"查看完整响应"按钮 | 无(检查 dataId) | dataId 为空 → Toast 提示"dataId is Required";dataId 存在 → 打开弹窗 | 弹窗打开 | + +### 步骤 2:加载响应数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(弹窗自动加载) | 调用 getChatResData(含 chatId、dataId、appId) | 响应数据为空 → 显示空状态提示;有数据 → 渲染 ResponseBox 组件 | 弹窗内显示加载状态,加载完成后展示完整对话响应 | + +### 前后置条件 + +- **前置条件**:选中的数据项状态为"已完成";数据项有关联的 AI 对话数据(`targetOutput.aiChatItemDataId` 非空) +- **后置影响**:无 +- **失败场景**:数据加载失败时弹窗内显示加载状态或空提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击查看完整响应 + UI->>UI: 检查dataId是否存在 + UI->>API: getChatResData(chatId, dataId, appId) + API->>Backend: 查询对话历史数据 + Backend-->>API: 对话响应数据 + API-->>UI: 返回响应内容 + UI-->>User: 弹窗展示完整对话响应 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..cc28b0c89d1c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 任务详情 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — 模块概览 + +## 1. 模块业务概述 + +任务详情模块是评测系统的核心查看与操作界面,用于展示单个评测任务的完整执行结果。用户从评测任务列表点击某个任务后进入此页面,可以查看每条评测数据的评分情况、各维度的得分统计,以及对单条数据进行编辑、重试、删除等操作。 + +模块采用三栏布局:左侧为评测数据列表(支持搜索和分页),中间为单条数据的详情展示(问题、参考答案、实际回答、评测维度评分),右侧面板展示综合评分仪表盘和任务基本信息。评测执行过程中,页面每 15 秒自动轮询刷新最新数据,用户无需手动刷新。 + +**子能力关系**:本模块为「评测任务」下的叶子节点,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员(有写入权限) | 全部页面 | 查看评测结果、编辑数据、删除数据、重试单项、重试失败项、导出数据、刷新评分、修改评分配置 | 可点击应用名称跳转至应用详情页 | +| 团队成员(无写入权限) | 全部页面 | 查看评测结果 | 应用名称不可点击跳转,操作按钮不可见 | + +**权限模型**: 基于团队(Team)的访问控制。评测任务归属团队,团队成员均可查看;写入权限由 `appDetail.permission.hasWritePer` 控制,影响应用的跳转链接是否可点击。数据操作(编辑/删除/重试)通过 API 层的团队 ID 校验进行权限控制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享前端组件库 (@fastgpt/web) | MyIcon、MyBox、MyTooltip、EmptyTip、MyModal 等通用 UI 组件 | 组件 | +| 共享前端组件库 (@fastgpt/web) | useToast、useRequest、useScrollPagination 等通用 Hooks | Hook | +| 全局类型定义 (@fastgpt/global) | 评测相关的类型定义(EvaluationDisplayType 等)和常量(EvaluationStatusEnum 等) | 类型/常量 | +| 聊天记录模块 | getChatResData — 获取对话完整响应数据(用于完整响应弹窗) | API | +| 应用管理模块 | getAppDetailById — 获取应用详情(用于基本信息面板) | API | + +## 4. 对外共享摘要 + +本模块为叶子节点,组件均为模块内部使用,不对外提供共享能力。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测任务详情 | /dashboard/evaluation/task/detail | projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx | 团队成员 | 含 3 个过滤标签页(全部数据/问题数据/错误数据),通过查询参数 `currentTab` 切换 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..7ca28747d714 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,59 @@ +--- +capability_label: "评测任务" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测" +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task", "/dashboard/evaluation/task/detail"] +--- + +# 评测任务 — 模块概览 + +## 1. 模块业务概述 + +评测任务是 FastGPT 评测体系中的核心执行模块,负责创建、管理和查看 AI 应用工作流的自动化评测任务。评测任务将评测数据集中的问题批量发送给目标应用,通过配置的评测维度(如答案正确性、忠实度、上下文召回率等)对输出结果进行打分和总结,帮助团队量化评估 AI 应用的回答质量。 + +本模块为评测体系的聚合层,包含两个子能力: +- **评测任务首页**:展示任务列表,支持搜索、筛选、创建、重命名和删除任务 +- **任务详情**:查看单个任务的详细评测结果,包括数据列表、评测维度得分、综合评分、错误信息和总结报告 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员 | 评测任务列表、任务详情 | 查看任务、查看详情 | 可查看所有评测任务和评测结果 | +| 团队管理员 | 评测任务列表、任务详情 | 创建任务、重命名任务、删除任务、重试失败项、导出数据 | 拥有完整的管理权限 | + +**权限模型**:基于团队(Team)的权限模型。评测任务属于团队级别资源,团队成员均可查看,管理操作(创建、删除、修改)需要团队写入权限(hasWritePer)。权限判断在 API 层完成。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用 | 获取应用详情、应用版本列表、应用工作流节点分析 | API | +| 评测数据集 | 获取评测数据集列表、数据集详情 | API | +| 评测维度 | 评估维度指标定义、内置维度信息 | API/Hook | +| ChatBox | 查看完整对话响应 | 组件 | +| AppSelect | 应用选择器组件 | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 评测任务发布状态查询 | 应用发布(SelectMarkCollection) | API | +| CreateModal 创建任务弹窗 | 应用工作台(dashboard/agent) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测任务首页 | /dashboard/evaluation/task | projects/app/src/pages/dashboard/evaluation/task/index.tsx | 团队成员 | 任务列表,支持搜索/筛选/创建/删除 | +| 任务详情 | /dashboard/evaluation/task/detail | projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx | 团队成员 | 含全部数据/存疑数据/异常数据三个 Tab | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 评测任务首页 | 评测任务首页 | [08-模块概览](../评测任务首页/模块概览.md) | +| 任务详情 | 任务详情 | [08-模块概览](../任务详情/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f1fec552669d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,111 @@ +--- +capability_label: 评测任务首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 评测任务 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/task"] +--- + +# 评测任务首页 — 业务流程索引 + +## 查看评测任务列表 + +- **业务描述**: 进入评测任务首页后,查看当前团队下所有评测任务的列表,包括任务名称、进度、关联应用、应用版本、评测结果、起止时间和执行者信息。 +- **触发入口**: 导航至"工作台 → 评测 → 评测任务"(通过评测首页 FillRowTabs 切换到"评测任务"Tab) +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 用户已登录,属于某个团队,拥有评测功能访问权限(`feConfigs.show_evaluation` 不为 false) + +## 搜索评测任务 + +- **业务描述**: 通过搜索框输入关键词,在任务名称中模糊搜索匹配的评测任务,搜索结果经防抖处理后展示。 +- **触发入口**: 评测任务首页顶部的搜索输入框 +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 已进入评测任务首页 + +## 按应用筛选任务 + +- **业务描述**: 通过应用下拉选择器筛选特定应用关联的评测任务,支持"全部应用"选项以清除筛选。 +- **触发入口**: 评测任务首页顶部的应用选择器(AppSelectWithAll 组件) +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 已进入评测任务首页 + +## 创建评测任务 + +- **业务描述**: 打开创建评测任务弹窗,填写任务名称、选择目标应用及版本、选择评测数据集、配置评测维度后提交,系统自动创建评测任务并启动评测。 +- **触发入口**: 评测任务首页顶部的"创建新任务"按钮 +- **路由路径**: 不涉及路由变更(弹窗模式) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 已进入评测任务首页,系统中存在可评测的应用和数据集 + +## 重命名评测任务 + +- **业务描述**: 在任务行操作菜单中触发重命名,弹出编辑框修改任务名称后确认保存。 +- **触发入口**: 任务列表行右侧"更多"菜单 → "重命名" +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 任务列表中至少存在一条任务 + +## 删除评测任务 + +- **业务描述**: 在任务行操作菜单中触发删除,弹出确认对话框,确认后删除该评测任务及其关联的所有评测项和队列任务。 +- **触发入口**: 任务列表行右侧"更多"菜单 → "删除" +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 任务列表中至少存在一条任务 + +## 重试失败评测项 + +- **业务描述**: 当评测任务中存在失败项时,可通过操作菜单触发重试,系统重新执行所有失败评测项的评测逻辑。 +- **触发入口**: 任务列表行右侧"更多"菜单 → "重试失败项"(仅当 `statistics.error > 0` 时显示) +- **路由路径**: `/dashboard/evaluation/task?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 任务存在失败(error)状态的评测项 + +## 跳转任务详情 + +- **业务描述**: 点击任务列表中的任意一行,跳转至该任务的详情页,查看评测项的详细执行结果和总结报告。 +- **触发入口**: 点击任务列表中的某一行(行级点击事件) +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={task._id}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: admin, team_member +- **前置条件**: 任务列表中至少存在一条任务 + +--- + +## 场景关系 + +- **查看评测任务列表** ← 页面进入时的默认场景,各操作场景均以此为入口上下文 +- **搜索评测任务** → 影响列表数据刷新 +- **按应用筛选任务** → 影响列表数据刷新 +- **创建评测任务** → 完成后自动刷新列表 +- **重命名评测任务** → 完成后自动刷新列表 +- **删除评测任务** → 完成后自动刷新列表 +- **重试失败评测项** → 完成后自动刷新列表 +- **跳转任务详情** → 离开当前页面,进入详情子页面 + +--- + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看评测任务列表 | 导航进入评测任务首页 | /dashboard/evaluation/task | admin, team_member | +| S02 | 搜索评测任务 | 列表顶部搜索框 | /dashboard/evaluation/task | admin, team_member | +| S03 | 按应用筛选任务 | 列表顶部应用选择器 | /dashboard/evaluation/task | admin, team_member | +| S04 | 创建评测任务 | 列表顶部"创建新任务"按钮 | 弹窗,不涉及路由 | admin, team_member | +| S05 | 重命名评测任务 | 行操作菜单 → 重命名 | /dashboard/evaluation/task | admin, team_member | +| S06 | 删除评测任务 | 行操作菜单 → 删除 | /dashboard/evaluation/task | admin, team_member | +| S07 | 重试失败评测项 | 行操作菜单 → 重试失败项 | /dashboard/evaluation/task | admin, team_member | +| S08 | 跳转任务详情 | 点击任务行 | /dashboard/evaluation/task/detail | admin, team_member | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..1fcf111e9272 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,268 @@ +--- +capability_label: 评测任务首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 评测任务 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/task"] +--- + +# 评测任务首页 — 业务流程详解 + +## 页面总览 + +评测任务首页是评测系统的核心管理页面,以数据表格形式展示团队内所有评测任务。页面顶部提供应用筛选、关键词搜索和创建入口;表格展示每项任务的关键信息(名称、进度、关联应用、版本、结果、时间、执行者);每行右侧提供操作菜单(重试失败项、重命名、删除)。点击行可跳转至任务详情页。 + +本页面为单页列表视图,无嵌套子 Tab。 + +--- + +### 进入评测任务首页 + +> 用户通过评测首页的 FillRowTabs 切换到"评测任务"Tab 进入此页面。 + +#### 步骤 1:页面初始化与权限校验 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过导航进入评测首页 `/dashboard/evaluation` | 无(前端路由) | — | 加载 DashboardContainer 布局 | +| 页面渲染时,`useEffect` 检查 `feConfigs.show_evaluation` | 无 | 若 `show_evaluation === false`:自动重定向到 `/dashboard` | 若无权限:页面不显示,跳转回工作台首页 | +| 页面渲染 `FillRowTabs`,默认激活 `evaluationTab=tasks` | 无 | `evaluationTab` 取 URL query 参数,无参数时默认 `'tasks'` | 显示 3 个 Tab(评测任务、评测数据集、评测维度),默认激活"评测任务" | +| 条件渲染命中 `evaluationTab === 'tasks'` | 无 | 仅当激活"评测任务"Tab 时渲染 | 渲染 `` 组件 | + +#### 步骤 2:首次加载任务列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面组件挂载 | `POST /core/evaluation/task/list`(通过 getEvaluationList 封装) | 默认参数:`pageNum=1, pageSize=10`;searchKey 为空;appFilter 为空 | 表格区域显示加载状态(`isLoading`),Pagination 组件初始化 | +| API 返回列表数据 | — | 若 `total === 0`:显示空数据提示"暂无数据" | 加载完成:表格展示任务数据;若数据为空:居中显示空数据提示 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/evaluation/task/list | pageNum=1, pageSize=10 | 后端聚合查询关联的 app/appVersion/datasetCollection 数据;实时计算各任务的状态和统计信息 | 表格展示前 10 条任务 | +| 翻页 | POST /core/evaluation/task/list | pageNum=N, pageSize=10 | 同首次 | 表格展示第 N 页数据 | +| 刷新(搜索/筛选/CRUD后) | POST /core/evaluation/task/list | pageNum 重置为 1,携带最新 searchKey/appFilter | 依赖 `refreshDeps` 变化自动触发 | 表格展示最新第 1 页数据 | + +- **分页参数**: 默认每页 10 条 +- **排序规则**: 后端按 `createTime` 降序排列(最新创建的任务在前) +- **筛选条件**: 搜索框(按任务名称模糊搜索)、应用选择器(按 appId 精确匹配) +- **特殊列的渲染逻辑**: + - **进度列**: 排队中显示状态文字;已完成显示绿色文字;其他状态显示 `已完成数/总数`,有失败项时在数字旁显示红色 info 图标 + - **评测结果列**: error 且全部失败→红色"异常";排队/评测中→灰色"评测中";summaryConfigs≥3→显示综合分数百分比;summaryConfigs<3→分别显示各维度分数百分比;无数据→显示"-" + - **菜单"重试失败项"**: 仅当 `statistics.error > 0` 时显示 + +--- + +### 搜索评测任务 + +> 用户在搜索框输入关键词,系统按任务名称模糊匹配并刷新列表。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框输入文字 | 无(仅更新本地状态 `localSearchValue`) | — | 输入框内容即时更新 | + +#### 步骤 2:防抖搜索触发 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 停止输入 500ms 后自动触发 | `POST /core/evaluation/task/list` | 携带 `searchKey=输入值`,pageNum 重置为 1,appFilter 保持原值 | 列表刷新为搜索结果;无匹配结果时显示空数据提示 | + +--- + +### 按应用筛选任务 + +> 用户通过应用下拉选择器筛选特定应用的评测任务。 + +#### 步骤 1:选择筛选应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击应用选择器(AppSelectWithAll),选择某个应用或"全部应用" | 无 | — | 下拉选择器显示选中值 | 无 | +| `appFilter` 状态变更 → `refreshDeps` 触发 | `POST /core/evaluation/task/list` | 携带 `appFilter=选中appId`("全部"时为空),pageNum 重置为 1 | 列表刷新为筛选结果 | + +--- + +### 创建评测任务 + +> 用户点击"创建新任务"按钮,弹出创建任务弹窗,填写表单后提交。 + +#### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建新任务"按钮 | 无 | `isCreateModalOpen` 设为 `true` | 弹出 `CreateModal` 弹窗,显示创建表单 | + +#### 步骤 2:选择目标应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"评测应用"下拉中选择一个应用 | 无(前端选择,触发版本列表加载) | `watchedValues.appId` 变更 | 应用版本下拉启用并加载该应用的版本列表 | + +#### 步骤 3:选择应用版本 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"评测应用版本"下拉中选择一个版本 | 触发 `getAppVersionDetail(versionId, appId)` | 版本列表加载完成后自动选中最新版本 | — | +| API 返回版本详情(含 nodes 信息) | — | 分析 nodes 中的节点类型:
- 含 chatNode + datasetSearchNode → 推荐 3 个维度(正确性+忠实度+召回率)
- 仅含 chatNode → 推荐 1 个维度(正确性)
- 仅含 datasetSearchNode → 推荐 2 个维度(忠实度+召回率)
- 均不含 → 无推荐,自动展开维度区域 | 显示推荐维度说明文案,维度列表自动勾选推荐维度 | +| 同时异步获取该应用最近使用的数据集 | `POST /core/evaluation/task/list`(pageSize=1, appId=选中应用) | 若最近任务存在数据集:自动选中该数据集 | 数据集下拉自动填充最近使用的数据集 | + +#### 步骤 4:选择/创建评测数据集 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"评测数据集"下拉中选择已有数据集 | 无 | — | 选中数据集 | +| 点击"创建/导入数据集"按钮 → 选择"智能生成" | 无 | 打开智能生成数据集弹窗 | 弹窗展示 | +| 点击"创建/导入数据集"按钮 → 选择"文件导入" | 无 | 新标签页打开 `/dashboard/evaluation/dataset/fileImport?scene=evaluationDatasetList` | 跳转至导入页 | + +#### 步骤 5:配置评测维度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 展开/折叠维度配置区域 | 无 | — | 维度列表展开/收起 | +| 勾选/取消勾选维度 | 无 | 内置维度需填写评估模型和索引模型 | 维度卡片显示/移除 | +| 为维度配置评估模型(LLM) | 无 | 若维度 `llmRequired=true`:必填 | 模型下拉选择 | +| 为维度配置索引模型(Embedding) | 无 | 若维度 `embeddingRequired=true`:必填 | 模型下拉选择 | + +#### 步骤 6:提交创建 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 任务名称 | 文本输入 | ✅ | — | 不超过 100 字符 | 否 | 评测任务的显示名称 | +| 评测应用 | 下拉选择 | ✅ | — | 从应用列表中选择 | 否 | 要评测的目标 AI 应用 | +| 应用版本 | 下拉选择 | ✅ | — | 选中应用下的版本列表 | 否 | 评测针对的特定应用版本 | +| 评测数据集 | 下拉选择 | ✅ | — | 已有数据集列表 | 否 | 用于评测的测试数据 | +| 评测维度 | 多选+配置 | ✅ | 自动推荐 | 内置维度(正确性/相似度/相关性/忠实度/召回率/精确度)+ 自定义维度 | 否 | 每维度需配置评估模型(LLM)和索引模型(Embedding) | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 任务名称为空 | 提交时 | 输入框聚焦,提示必填 | +| 维度缺少模型配置 | 提交时(前端校验) | "评测维度配置不完整"(warning),附缺失模型的维度名称列表 | +| 任务名称超长 | 失焦 | 前端限制 100 字符 | + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 校验通过后提交 | `POST /core/evaluation/task/create`(postCreateEvaluation) | — | 显示创建中状态(按钮 loading),成功后弹窗关闭,列表自动刷新 | + +**前置条件**: 用户登录且有团队归属;系统中有可评测的应用和应用版本;系统中存在评测数据集 +**后置影响**: 创建新评测任务记录,自动创建对应的评测项(从数据集的每条数据生成),若 `autoStart=true` 则自动启动评测队列 + +--- + +### 重命名评测任务 + +#### 步骤 1:触发重命名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行操作菜单 → "重命名" | 无 | — | 弹出编辑标题弹窗(EditTitleModal),标题为"重命名",输入框预填当前任务名 | + +#### 步骤 2:确认修改 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称后确认 | `PUT /core/evaluation/task/update`(putUpdateEvaluation,传 evalId + name) | — | 成功提示"更新成功",列表自动刷新 | +| | | API 返回错误 | 错误提示"更新失败",弹窗保持打开 | + +--- + +### 删除评测任务 + +#### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行操作菜单 → "删除" | 无 | — | 弹出确认删除弹窗,文案:"确认删除该任务?" | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认 | `DELETE /core/evaluation/task/delete`(deleteEvaluation,传 evalId) | — | 成功提示"删除成功",列表自动刷新 | +| 点击取消 | 无 | — | 弹窗关闭,无变化 | + +**删除链路详情**: +- **确认弹窗**: 使用 `useConfirm({type: 'delete'})` 生成,类型为 `'delete'`(红色警告样式),自定义内容:"确认删除该任务?" +- **级联影响**: 后端在事务中执行:先清理评测项队列和总结报告队列中的相关任务 → 删除所有评测项 → 删除评测任务。删除成功后自动刷新列表 + +--- + +### 重试失败评测项 + +#### 步骤 1:触发重试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行操作菜单 → "重试失败项" | 无 | 菜单项仅当 `statistics.error > 0` 时可见 | 无立即变化 | + +#### 步骤 2:执行重试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 菜单项 onClick 触发 | `POST /core/evaluation/task/retryFailed`(postRetryFailedEvaluationItems,传 evalId) | — | 列表自动刷新 | +| | | API 失败 | 控制台打印错误日志,列表不刷新 | + +--- + +### 跳转任务详情 + +#### 步骤 1:点击任务行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表格中任意任务行 | 无(前端路由跳转) | — | 路由跳转至 `/dashboard/evaluation/task/detail?taskId={task._id}`,进入任务详情页 | + +> 注意:行内操作菜单区域的点击事件已通过 `e.stopPropagation()` 阻止冒泡,不会触发行跳转。 + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 评测任务首页 + participant API as 评测任务 API + + User->>UI: 进入评测任务 Tab + UI->>API: POST /core/evaluation/task/list (pageNum=1, pageSize=10) + API-->>UI: 返回任务列表 + 实时状态统计 + UI-->>User: 展示任务表格 + + User->>UI: 输入搜索关键词 (500ms 防抖) + UI->>API: POST /core/evaluation/task/list (searchKey=输入值, pageNum=1) + API-->>UI: 返回筛选结果 + UI-->>User: 刷新表格 + + User->>UI: 点击"创建新任务" + UI-->>User: 弹出创建任务弹窗 + User->>UI: 填写表单并提交 + UI->>API: POST /core/evaluation/task/create + API-->>UI: 创建成功 + UI-->>User: 弹窗关闭,列表刷新 + + User->>UI: 点击操作菜单 → 重命名 + UI-->>User: 弹出编辑弹窗 + User->>UI: 修改名称确认 + UI->>API: PUT /core/evaluation/task/update + API-->>UI: 更新成功 + UI-->>User: 列表刷新 + + User->>UI: 点击操作菜单 → 删除 + UI-->>User: 弹出确认删除弹窗 + User->>UI: 确认删除 + UI->>API: DELETE /core/evaluation/task/delete + API-->>UI: 删除成功 + UI-->>User: 列表刷新 + + User->>UI: 点击任务行 + UI-->>User: 跳转至任务详情页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..39fa8afea5b4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 评测任务首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 评测任务 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/task"] +--- + +# 评测任务首页 — 模块概览 + +## 1. 模块业务概述 + +评测任务首页是 FastGPT 评测系统的任务管理入口,提供评测任务的全生命周期管理视图。用户在此页面可以集中查看和搜索所有已创建的评测任务,了解每个任务的执行进度、评测结果和状态,并对任务执行重命名、删除、重试失败项等管理操作。 + +本模块服务于需要进行 AI 应用质量评估的团队成员和管理员,是"评测"大模块下的子能力页面。评测任务首页本身为单页列表视图,无子 Tab,新任务创建通过弹窗完成,任务详情通过行点击跳转至独立详情页。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| admin(管理员) | 评测任务首页、创建任务弹窗 | 查看所有任务、创建任务、重命名、删除、重试失败项 | 可操作团队内所有评测任务 | +| team_member(团队成员) | 评测任务首页、创建任务弹窗 | 查看权限范围内的任务、创建任务、重命名、删除、重试失败项 | 仅能查看和操作自己有权限的任务资源 | + +**权限模型**: 基于团队(teamId)的资源隔离模型。任务列表 API 根据当前用户身份(是否为团队 owner)过滤可访问的任务:owner 可见全部任务,非 owner 仅可见分配给自己的任务和有权访问的资源。删除操作在后端校验团队归属。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 评测任务详情 | 点击任务行跳转至任务详情页,传递 taskId 查询参数 | 页面路由 | +| 公共组件库 (@fastgpt/web) | 使用 Avatar、MyBox、MyIcon、MyMenu、MyTooltip、EmptyTip、FillRowTabs 等通用 UI 组件 | 组件 | +| 公共 Hooks (@fastgpt/web) | 使用 usePagination、useRequest、useConfirm、useEditTitle、useDebounceFn 等通用 Hook | Hook | +| 评测 API 层 (@/web/core/evaluation/task) | 调用任务列表查询、更新、删除、重试等 API 封装函数 | API | +| 全局评测类型 (@fastgpt/global) | 引用 EvaluationDisplayType、EvaluationStatusEnum、EvaluationStatusMap 等类型和常量 | 类型/常量 | + +## 4. 对外共享 + +本模块为叶节点页面组件,不对外提供共享能力。组件 `EvaluationTasks` 仅被父页面 `evaluation/index.tsx` 引用,属于同一模块组内部关系。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测任务首页 | /dashboard/evaluation/task | projects/app/src/pages/dashboard/evaluation/task/index.tsx | admin, team_member | 评测任务列表页,无子 Tab。顶部含应用筛选、搜索、创建按钮;主体为任务列表表格 | + +## 6. 子能力索引 + +本模块无子能力(叶节点)。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b1d8628046b1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,65 @@ +--- +capability_label: "评测数据集" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测" +roles: ["团队成员"] +router_paths: [] +--- + +# 评测数据集 — 业务流程索引 + +## 评测数据集首页 + +- **业务描述**: 浏览和管理所有评测数据集,支持搜索、创建、重命名和删除操作 +- **触发入口**: 工作台 → 评测 → 评测数据集 Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测功能可见(`feConfigs.show_evaluation !== false`) + +## 文件导入 + +- **业务描述**: 通过上传 CSV 文件批量创建评测数据集,或向已有数据集追加数据 +- **触发入口**: 评测数据集列表 → 创建数据集 → 文件导入(或数据集详情页 → 追加数据 → 文件导入) +- **路由路径**: `/dashboard/evaluation/dataset/fileImport` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测功能可见;追加模式下需已有数据集 + +## 评测数据集详情 + +- **业务描述**: 查看数据集内的数据条目,支持编辑、删除、手动添加、智能追加、质量评估等操作 +- **触发入口**: 评测数据集列表 → 点击数据集行 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 评测功能可见;需传入 `collectionId` 参数 + +## 场景关系 + +- **评测数据集首页** → **文件导入**: 用户通过列表页创建菜单进入文件导入页创建新数据集 +- **评测数据集首页** → **评测数据集详情**: 用户点击列表中的数据集进入详情页 +- **评测数据集详情** → **文件导入**: 用户通过详情页追加数据菜单以追加模式进入文件导入页 +- **评测数据集首页** → **智能生成**: 用户通过创建菜单打开智能生成弹窗 +- **评测数据集详情** → **智能生成**: 用户通过追加数据菜单打开智能生成弹窗 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 浏览评测数据集列表 | 工作台→评测→评测数据集Tab | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S02 | 智能生成数据集 | 列表页→创建数据集→智能生成 | `/dashboard/evaluation?evaluationTab=datasets`(弹窗) | 团队成员 | +| S03 | 文件导入创建数据集 | 列表页→创建数据集→文件导入 | `/dashboard/evaluation/dataset/fileImport` | 团队成员 | +| S04 | 重命名数据集 | 列表页→操作菜单→重命名 | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S05 | 删除数据集 | 列表页→操作菜单→删除 | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S06 | 查看数据集详情 | 列表页→点击数据集行 | `/dashboard/evaluation/dataset/detail` | 团队成员 | +| S07 | 文件导入追加数据 | 详情页→追加数据→文件导入 | `/dashboard/evaluation/dataset/fileImport?collectionId=xxx` | 团队成员 | +| S08 | 智能生成追加数据 | 详情页→追加数据→智能生成 | `/dashboard/evaluation/dataset/detail`(弹窗) | 团队成员 | +| S09 | 手动添加数据条目 | 详情页→追加数据→手动添加 | `/dashboard/evaluation/dataset/detail`(弹窗) | 团队成员 | +| S10 | 编辑数据条目 | 详情页→点击数据卡片 | `/dashboard/evaluation/dataset/detail`(弹窗) | 团队成员 | +| S11 | 删除数据条目 | 详情页→数据卡片悬停→删除 | `/dashboard/evaluation/dataset/detail` | 团队成员 | +| S12 | 质量评估(单项/批量) | 详情页→质量评估按钮 | `/dashboard/evaluation/dataset/detail` | 团队成员 | + +> 各场景的详细交互步骤请参见子能力文档:[评测数据集首页业务流程详解](../评测数据集首页/业务流程详解.md)、[文件导入业务流程详解](../文件导入/业务流程详解.md)、[评测数据集详情业务流程详解](../评测数据集详情/业务流程详解.md) diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..64b773505a19 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,27 @@ +--- +capability_label: "评测数据集" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测" +roles: ["团队成员"] +router_paths: [] +--- + +# 评测数据集 — 业务流程详解 + +> 本节点为分组节点(grouping node),不包含独立的业务流程。各子能力拥有自身完整的业务流程文档。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 流程索引 | 流程详解 | +|--------|---------|---------|---------| +| 评测数据集首页 | 数据集列表浏览、搜索、创建、重命名、删除 | [业务流程索引](../评测数据集首页/业务流程索引.md) | [业务流程详解](../评测数据集首页/业务流程详解.md) | +| 文件导入 | CSV 文件上传创建/追加数据集,含自动评测配置 | [业务流程索引](../文件导入/业务流程索引.md) | [业务流程详解](../文件导入/业务流程详解.md) | +| 评测数据集详情 | 数据条目查看、编辑、删除、追加,质量评估执行 | [业务流程索引](../评测数据集详情/业务流程索引.md) | [业务流程详解](../评测数据集详情/业务流程详解.md) | + +## 子能力关系 + +- **评测数据集首页(入口)**:提供数据集列表浏览,可创建(智能生成弹窗 / 跳转文件导入页)、点击进入详情、操作菜单重命名或删除 +- **评测数据集详情**:从列表点击进入,支持查看数据列表(滚动分页)、编辑/删除数据条目、追加数据(智能生成 / 文件导入 / 手动添加)、执行质量评估(单项/批量) +- **文件导入**:可从列表页(创建新数据集)或详情页(追加数据)两个入口进入,上传 CSV 文件并配置自动评测 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f1e129843ff3 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,41 @@ +--- +capability_label: "文件导入" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测数据集" +roles: ["具有评测创建权限的团队成员"] +router_paths: + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 文件导入 — 业务流程索引 + +## 文件导入 + +- **业务描述**: 用户通过上传 CSV 文件,批量创建或追加评测数据集数据。支持在导入后自动触发质量评测。 +- **触发入口**: + - 评测数据集首页 → 点击"导入文件"按钮 → 进入此页面(新建模式) + - 评测数据集详情页 → 点击"追加数据" → 进入此页面(追加模式,携带 `collectionId`) +- **路由路径**: `/dashboard/evaluation/dataset/fileImport` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` +- **涉及角色**: 具有评测创建权限的团队成员 +- **前置条件**: + - 团队已开启评测功能 + - 用户具有 `hasEvaluationCreatePer` 权限 + - 追加模式下需目标数据集已存在 + +## 场景关系 + +- **文件导入(新建模式)** → **数据集详情**: 导入完成后自动跳转到数据集详情页 +- **文件导入(追加模式)** → **数据集详情**: 导入完成后自动跳转回数据集详情页 +- **文件导入(新建模式)** → **数据集列表**: 导入完成后可跳转到数据集列表 + +各场景按入口参数(`scene`)分流,互不交叉。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 新建数据集并导入文件 | — | 评测数据集首页→导入文件按钮 | `/dashboard/evaluation/dataset/fileImport` | 有评测创建权限的团队成员 | +| S02 | 向已有数据集追加文件数据 | — | 数据集详情页→追加数据按钮 | `/dashboard/evaluation/dataset/fileImport?collectionId=xxx&collectionName=xxx` | 有评测创建权限的团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e9b247660b0c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,215 @@ +--- +capability_label: "文件导入" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测数据集" +roles: ["具有评测创建权限的团队成员"] +router_paths: + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 文件导入 — 业务流程详解 + +## 页面总览 + +文件导入页面是一个单页表单,提供数据集名称输入(新建模式)、CSV 文件选择上传、CSV 模板下载、自动评测开关和评测模型选择功能。页面通过 URL 参数区分新建/追加两种模式。 + +--- + +### S01:新建数据集并导入文件 + +> 业务描述:用户从评测数据集列表页进入此页面,输入数据集名称并上传 CSV 文件,系统创建新数据集并将文件数据导入。 + +#### 步骤 1:进入页面并初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从评测数据集列表页点击"导入文件"按钮 | 无(路由跳转,不携带 `collectionId`) | `router.query.collectionId` 为空 → 进入新建模式 | 页面加载,显示"返回"按钮、名称输入框(含 placeholder "请输入数据集名称")、文件上传区、自动评测开关(默认开启)、提交按钮 | + +- **前置条件**: 侧边栏显示评测入口(`show_evaluation` 开启且用户有 `hasEvaluationCreatePer` 权限) +- **名称输入框校验**: 必填字段,`required: true` + +#### 步骤 2:下载 CSV 模板(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"下载模板"按钮 | 无(前端生成 Blob 触发浏览器下载) | — | 浏览器下载 `evaluation_template.csv`,包含 `userInput,expectedOutput,actualOutput,context,retrievalContext` 表头及示例行 | + +- 模板内容为 UTF-8 BOM 编码的 CSV 文本,通过 `fileDownload` 工具函数触发下载 +- 模板提示(QuestionTip)展示表结构说明:`userInput`(用户输入)和 `expectedOutput`(预期输出)为示例列 + +#### 步骤 3:选择 CSV 文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件上传区 或 拖拽 CSV 文件到上传区 | 无(前端 File API) | 文件扩展名非 `.csv` → toast 警告"不支持的文件格式";文件重复(基于名称+大小+修改时间)→ toast 提示"文件已上传" | 通过格式校验后,文件显示在已选列表中(含图标、文件名、大小、删除按钮);`selectFiles` 更新后表单有效性重新评估 | + +- **文件过滤**: `fileType=".csv"`,`autoFilterOverSize=true` +- **重复检测**: 基于文件名、大小和最后修改时间的组合键去重 +- **表单有效性**: 名称非空(新建模式)且已选文件数 > 0 时,提交按钮可用 + +#### 步骤 4:配置自动评测 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 切换"自动评测"开关 | 无(前端状态更新) | 开关打开 → 显示评测模型选择器;开关关闭 → 隐藏模型选择器 | 模型选择器显示/隐藏 | +| 从模型下拉列表选择评测模型 | 无(前端状态更新) | 默认选中 `evalModelList[0]`(首个可用于评测的模型) | 模型选择器显示选中模型名称和头像 | + +- **评测模型列表**: 从 `llmModelList` 中过滤出 `useInEvaluation` 为 `true` 的模型 +- **自动评测默认值**: `true` + +#### 步骤 5:提交导入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击提交按钮 | **(1) POST `/api/core/evaluation/dataset/collection/create`** — `{name}` | 串行依赖:创建成功返回 `collectionId` | 提交按钮文案从"确认导入"变为上传进度百分比(如"上传中 45%") | +| — | **(2) POST `/api/core/evaluation/dataset/data/import`** (multipart/form-data) — `{file[], data: {collectionId, enableQualityEvaluation, evaluationModelId}}` | 串行依赖:使用步骤 (1) 返回的 `collectionId`;自动评测关闭时 `enableQualityEvaluation=false` 且 `evaluationModelId` 为 `undefined` | 进度条随上传进度百分比更新;达到 100% 后按钮文案变为"数据解析中" | + +- **后端处理流程(API 侧)**: + - Multer 接收多文件 + - 校验文件扩展名为 `.csv` + - Papa Parse 解析 CSV 内容,校验必填列 `user_input`、`expected_output` + - 检查团队数据集数量限制(`checkTeamEvalDatasetLimit`) + - 创建评测数据集 Collection(如为新建模式) + - 批量插入数据记录到 MongoDB + - 如开启自动评测,将数据质量评估任务加入消息队列(`addEvalDatasetDataQualityBulk`) + - 清理上传的临时文件 + +#### 步骤 6:导入完成跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动跳转) | 无 | 根据 `scene` 参数分流:`evaluationDatasetDetail` → 跳转到数据集详情页;`evaluationDatasetList` → 跳转到数据集列表;默认 → 跳转到数据集列表 | `router.push(getRedirectUrl())` | + +- **成功提示**: toast 显示"文件导入成功"(i18n key: `file_import_success`) + +--- + +### S02:向已有数据集追加文件数据 + +> 业务描述:用户从数据集详情页跳转进入,直接上传 CSV 文件追加到当前数据集中,无需输入数据集名称。 + +#### 步骤 1:进入页面并初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从数据集详情页点击"追加数据" | 无(路由跳转,携带 `collectionId` 和 `collectionName`) | `router.query.collectionId` 存在 → 进入追加模式 | 页面加载,名称输入框隐藏,仅显示文件上传区、自动评测开关和提交按钮 | + +- **前置条件**: 来源数据集已存在且用户对其有操作权限 + +#### 步骤 2-5:同 S01 步骤 2-5 + +追加模式与新建模式的核心差异仅在于: +- **跳过创建数据集 API**:提交时 `collectionId` 已存在,直接调用数据导入 API +- **表单校验差异**:不再校验名称是否为空,仅校验已选文件数 > 0 + +#### 步骤 6:导入完成跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动跳转) | 无 | `scene=evaluationDatasetDetail` → `router.push(/dashboard/evaluation/dataset/detail?collectionId=xxx&collectionName=xxx)` | 返回数据集详情页,新导入的数据出现在数据列表中 | + +--- + +### 表单字段清单(新建模式) + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 数据集名称 | 文本输入 | ✅(新建模式) | — | 最长 30 字符 | 仅新建模式显示;追加模式下由 `collectionId` 确定数据集 | +| 文件 | 文件选择器 | ✅ | — | 仅 `.csv` 文件,支持多文件 | 通过 `FileSelector` 组件选择,含拖拽、去重 | +| 自动评测 | 开关 | 否 | `true` | — | 开启后显示评测模型选择器 | +| 评测模型 | 下拉选择 | 条件必填(自动评测开启时) | `evalModelList[0]` 的 ID | 仅可选 `useInEvaluation: true` 的模型 | 自动评测关闭时隐藏 | + +### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 数据集名称必填(新建模式) | 提交时 | "请输入数据集名称" | +| 至少选择一个文件 | 提交时 | "请选择文件" | +| CSV 文件格式校验 | 选择文件时 | "不支持的文件格式"(弹出具体后缀) | +| 文件必填列校验(user_input、expected_output) | 服务端解析时 | 服务端返回 CSV 解析错误 | +| 数据集名称重复 | 服务端创建时 | 服务端返回数据集名称重复错误 | + +### 前后置条件 + +- **前置条件**: + - 团队已开启评测功能(`feConfigs.show_evaluation`) + - 用户具有评测创建权限(`hasEvaluationCreatePer`) + - 追加模式:目标数据集存在且有写入权限 + - 自动评测:团队 AI 积分余额充足 +- **后置影响**: + - 新建模式:创建新数据集及其中所有数据记录 + - 追加模式:向已有数据集追加数据记录 + - 自动评测开启:数据记录质量评估任务进入消息队列 +- **失败场景**: + - CSV 必填列缺失 → toast 错误提示 + - 数据集创建失败(名称重复/超出限额)→ toast 错误提示 + - 文件上传超时(timeout: 600000ms)→ 请求失败 + - 权限不足 → 接口返回权限错误 + +--- + +### Mermaid 附录 + +#### S01:新建数据集并导入文件 + +```mermaid +sequenceDiagram + participant User + participant UI as 文件导入页面 + participant API as 后端 API + participant Backend as 服务端 + + User->>UI: 输入数据集名称 + User->>UI: 选择 CSV 文件 + User->>UI: 切换自动评测开关(可选) + User->>UI: 选择评测模型(可选) + User->>UI: 点击"确认导入" + + UI->>API: POST /core/evaluation/dataset/collection/create + Note over UI,API: 创建数据集 {name} + API->>Backend: 校验名称唯一性、团队限额 + Backend-->>API: 返回 collectionId + API-->>UI: collectionId + + UI->>API: POST /core/evaluation/dataset/data/import + Note over UI,API: multipart/form-data
file[], data: {collectionId, enableQualityEvaluation, evaluationModelId} + UI-->>User: 进度条显示上传百分比 + API->>Backend: 解析 CSV → 校验列 → 批量插入数据 + opt 自动评测开启 + Backend->>Backend: 数据质量评估任务入队 + end + API-->>UI: success + + UI-->>User: toast "文件导入成功" + UI->>UI: 跳转到数据集详情/列表页 +``` + +#### S02:向已有数据集追加文件数据 + +```mermaid +sequenceDiagram + participant User + participant UI as 文件导入页面 + participant API as 后端 API + participant Backend as 服务端 + + User->>UI: 选择 CSV 文件 + User->>UI: 切换自动评测开关(可选) + User->>UI: 点击"确认导入" + + Note over UI,API: 跳过创建步骤(collectionId 已存在) + + UI->>API: POST /core/evaluation/dataset/data/import + Note over UI,API: multipart/form-data
file[], data: {collectionId, enableQualityEvaluation, evaluationModelId} + UI-->>User: 进度条显示上传百分比 + API->>Backend: 解析 CSV → 校验列 → 批量插入数据 + opt 自动评测开启 + Backend->>Backend: 数据质量评估任务入队 + end + API-->>UI: success + + UI-->>User: toast "文件导入成功" + UI->>UI: 跳转回数据集详情页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..d899e1e267c6 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: "文件导入" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测数据集" +roles: ["具有评测创建权限的团队成员"] +router_paths: + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 文件导入 — 模块概览 + +## 模块业务概述 + +文件导入是评测数据集模块下的数据导入子能力,为用户提供通过 CSV 文件批量导入评测数据的入口。用户可在此页面上传一个或多个 CSV 文件,系统自动解析文件内容并将其存入指定的评测数据集中。 + +本模块支持两种工作模式: +- **新建数据集模式**:用户指定数据集名称,系统先创建空数据集,再将文件数据导入 +- **追加数据模式**:用户从已有数据集的详情页跳转进入(携带 `collectionId`),直接将文件数据追加到现有数据集 + +导入完成后可选择启动自动质量评测,由指定的评测模型对导入数据进行质量评估。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 有评测创建权限的团队成员 | 文件导入页 | 上传 CSV 文件、创建数据集、追加数据、开启自动评测 | 需要团队套餐中开启评测功能(`show_evaluation`)且用户具有 `hasEvaluationCreatePer` 权限 | +| 无评测创建权限的团队成员 | — | — | 评测入口在侧边栏不显示 | + +**权限模型**: 基于团队权限位的功能访问控制。评测侧边栏入口由 `feConfigs.show_evaluation`(功能开关)和 `userInfo.team.permission.hasEvaluationCreatePer`(用户权限)共同控制。数据导入接口侧通过 `authEvaluationDatasetDataWrite` / `authEvaluationDatasetCreate` 校验操作权限,并检查团队数据集数量限制和 AI 积分余额。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台容器(DashboardContainer) | 侧边栏导航、页面布局框架 | 组件 | +| 文件选择器(FileSelectorBox) | CSV 文件选择、拖拽上传、格式校验 | 组件 | +| AI 模型选择器(AIModelSelector) | 评测模型下拉选择 | 组件 | +| 系统 Store(useSystemStore) | 获取可用模型列表、系统配置 | Store | +| 评测数据集 API(evaluation/dataset.ts) | 创建数据集、上传文件导入数据 | API | +| 文件工具(file/utils) | CSV 模板下载 | 工具函数 | + +## 对外共享 + +本模块为叶子节点,不对外提供可复用的共享能力。页面组件 `FileImport` 仅供自身路由使用。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 文件导入 | `/dashboard/evaluation/dataset/fileImport` | `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` | 有评测创建权限的团队成员 | 单页面表单,无 Tab | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..9b46cb16ccf2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,59 @@ +--- +capability_label: "评测数据集" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测" +roles: ["团队成员"] +router_paths: [] +--- + +# 评测数据集 — 模块概览 + +## 1. 模块业务概述 + +评测数据集模块是 FastGPT 评测体系中的数据管理核心,为团队成员提供评测数据集的创建、导入、浏览和质量评估能力。用户可以通过**智能生成**(基于知识库自动生成评测数据)和**文件导入**(上传 CSV 文件)两种方式创建评测数据集,并在数据集详情页中管理单条数据、执行质量评估。 + +本模块下辖三个子能力:**评测数据集首页**(列表浏览与管理)、**文件导入**(CSV 模板上传)和**评测数据集详情**(数据列表查看、编辑、质量评估),三者协同构成完整的数据集生命周期管理。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员 | 评测数据集列表、文件导入、数据集详情 | 创建数据集(智能生成/文件导入)、查看列表、编辑名称、删除数据集、查看详情、管理数据条目、执行质量评估 | 所有团队成员权限一致,无细分角色差异 | + +**权限模型**: 基于功能开关(`feConfigs.show_evaluation`)控制评测模块整体可见性。评测功能关闭时,所有相关页面重定向至工作台首页。模块内不设细粒度角色权限。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 知识库(数据集) | 智能生成时选择知识库作为数据来源 | 组件/弹窗 | +| 模型管理 | 智能生成和自动评测时选择评测模型 | 组件/Store | +| 评测任务 | 任务详情页引用数据集详情页组件(NavBar) | 组件 | +| S3 存储 | 数据集文件的存储与读取 | 服务 | +| 团队管理 | 显示数据集创建者信息 | 组件 | + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 智能生成弹窗(IntelligentGeneration) | 评测任务(CreateModal)、对话(EvaluationDatasetSelector)、数据集详情(DataListModals) | 组件 | +| 数据集详情页导航栏(NavBar) | 评测任务详情、数据集详情 | 组件 | +| 数据集详情页上下文(DatasetDetailPageContextProvider) | 数据集详情 | Context | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测数据集列表 | `/dashboard/evaluation?evaluationTab=datasets` | `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` | 团队成员 | 评测页 Tab 之一,展示数据集列表 | +| 文件导入 | `/dashboard/evaluation/dataset/fileImport` | `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` | 团队成员 | 支持独立创建或追加数据两种场景 | +| 评测数据集详情 | `/dashboard/evaluation/dataset/detail` | `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` | 团队成员 | 含数据列表、状态筛选、质量评估等功能 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 评测数据集首页 | 评测数据集首页 | [模块概览](../评测数据集首页/模块概览.md) | +| 文件导入 | 文件导入 | [模块概览](../文件导入/模块概览.md) | +| 评测数据集详情 | 评测数据集详情 | [模块概览](../评测数据集详情/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ba337f7ec800 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,119 @@ +--- +capability_label: 评测数据集详情 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — 业务流程索引 + +## 业务场景列表 + +### 查看数据集数据列表 + +- **业务描述**: 进入评测数据集详情页,查看该数据集下所有数据条目,通过滚动分页加载更多数据。 +- **触发入口**: 评测数据集列表 → 点击某个数据集名称进入详情 +- **路由路径**: `/dashboard/evaluation/dataset/detail?collectionId={id}&collectionName={name}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 已登录系统,已有评测数据集 + +### 搜索过滤数据 + +- **业务描述**: 在数据集详情页通过搜索框按关键字过滤数据条目,或通过状态下拉筛选器按质量状态筛选数据。 +- **触发入口**: 数据集详情页顶部的搜索输入框或状态筛选下拉 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 已在数据集详情页 + +### 编辑数据条目 + +- **业务描述**: 点击某条数据后打开编辑弹窗,修改该条数据的用户输入问题和期望输出回答。 +- **触发入口**: 数据集详情页 → 点击某条数据卡片 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 数据集中有数据条目 + +### 删除数据条目 + +- **业务描述**: 在数据卡片悬停时显示删除按钮,点击后弹出确认弹窗,确认后删除该数据条目。 +- **触发入口**: 数据集详情页 → 悬停数据卡片 → 点击删除图标 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 数据集中有数据条目 + +### 批量质量评估 + +- **业务描述**: 对该数据集中的全部数据条目发起批量质量评估,由评测模型自动评估每条数据的质量。 +- **触发入口**: 数据集详情页 → 点击「质量评估」按钮 → 确认弹窗 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 数据集中有数据条目 + +### 追加数据 — AI 智能生成 + +- **业务描述**: 通过 AI 智能生成方式从知识库中为当前数据集追加新的评测数据。 +- **触发入口**: 数据集详情页 → 点击「追加数据」按钮 → 选择「AI生成」 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 已配置知识库数据源 + +### 追加数据 — 文件导入 + +- **业务描述**: 通过上传文件将外部数据导入到当前数据集中。 +- **触发入口**: 数据集详情页 → 点击「追加数据」按钮 → 选择「文件导入」 +- **路由路径**: `/dashboard/evaluation/dataset/fileImport` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 有可导入的数据文件 + +### 追加数据 — 手动录入 + +- **业务描述**: 手动填写问题和参考回答,向当前数据集追加单条评测数据。 +- **触发入口**: 数据集详情页 → 点击「追加数据」按钮 → 选择「手动录入」 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 无 + +### 配置评测模型 + +- **业务描述**: 在设置弹窗中为当前数据集选择用于质量评估的 AI 模型。 +- **触发入口**: 数据集详情页 → 点击「设置」按钮 +- **路由路径**: `/dashboard/evaluation/dataset/detail` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 系统已配置可用的评测模型 + +## 场景关系 + +- **查看数据列表** → **编辑数据条目**: 通过点击数据卡片进入编辑 +- **查看数据列表** → **搜索过滤数据**: 通过搜索框或筛选器实时过滤 +- **查看数据列表** → **删除数据条目**: 悬停后点击删除 +- **查看数据列表** → **批量质量评估**: 点击质量评估按钮(场景独立) +- **查看数据列表** → **追加数据**: 通过追加数据按钮打开子菜单(AI生成/文件导入/手动录入) +- **查看数据列表** → **配置评测模型**: 点击设置按钮(场景独立) + +各场景之间无严格先后依赖关系,可独立执行。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看数据集数据列表 | 数据集列表→点击数据集名称 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S02 | 搜索过滤数据 | 详情页→搜索框/状态下拉 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S03 | 编辑数据条目 | 详情页→点击数据卡片 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S04 | 删除数据条目 | 详情页→悬停卡片→删除图标 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S05 | 批量质量评估 | 详情页→质量评估按钮 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S06 | AI智能生成追加数据 | 详情页→追加数据→AI生成 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S07 | 文件导入追加数据 | 详情页→追加数据→文件导入 | /dashboard/evaluation/dataset/fileImport | 所有已登录用户 | +| S08 | 手动录入追加数据 | 详情页→追加数据→手动录入 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | +| S09 | 配置评测模型 | 详情页→设置按钮 | /dashboard/evaluation/dataset/detail | 所有已登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..6d3233057207 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,287 @@ +--- +capability_label: 评测数据集详情 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — 业务流程详解 + +## 页面总览 + +评测数据集详情页是一个单页数据管理视图,用户在此页面对单个评测数据集的全部数据条目进行查看、搜索、筛选、编辑、删除、质量评估和追加数据等操作。页面由顶部面包屑导航和数据卡片列表组成,无 Tab 结构。 + +### 查看数据集数据列表 + +> **业务描述**: 用户从评测数据集列表页点击某个数据集名称进入详情页,查看该数据集下的所有数据条目,通过滚动自动加载更多数据。 + +#### 步骤 1:进入详情页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在评测数据集列表中点击某个数据集名称 | GET `/core/evaluation/dataset/collection/detail?collectionId={id}` | 无 | 页面跳转到数据集详情页,显示面包屑导航"评测数据集 > 数据集名称",数据列表区域展示加载状态 | + +#### 步骤 2:加载数据列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需用户操作) | POST `/core/evaluation/dataset/data/list`(参数:searchKey、status、qualityResult、collectionId) | 无 | 列表区域显示数据卡片,每条卡片显示序号、用户问题文本、期望回答(无回答时显示"暂无回答")和质量状态标签 | + +#### 步骤 3:滚动加载更多 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动列表到底部 | POST `/core/evaluation/dataset/data/list`(带分页偏移参数) | 还有更多数据时 | 列表底部追加加载更多数据卡片;无更多数据时停止加载 | + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/evaluation/dataset/data/list | collectionId, searchKey, status, qualityResult, pageSize=15 | 按默认排序返回 | 列表前 15 条数据卡片 | +| 滚动加载 | POST /core/evaluation/dataset/data/list | 同上,带 offset 分页 | 追加到现有列表 | 追加 15 条数据卡片 | +| 搜索刷新 | POST /core/evaluation/dataset/data/list | searchKey 更新,重置分页 | 重置列表重新加载 | 匹配搜索关键字的新列表 | + +### 搜索过滤数据 + +> **业务描述**: 用户通过搜索框和状态筛选器对数据列表进行过滤。 + +#### 步骤 1:关键字搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框输入关键字 | POST `/core/evaluation/dataset/data/list`(参数更新 searchKey,500ms 防抖后触发) | 无 | 列表过滤为匹配关键字的数据条目,总数更新 | + +#### 步骤 2:状态筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击状态下拉选择器,选择目标状态 | POST `/core/evaluation/dataset/data/list`(参数更新 status 或 qualityResult) | 选择「全部」:status='',qualityResult='';选择「优质」或「待优化」:按 qualityResult 筛选;选择其他状态:按 status 筛选 | 列表按选中状态过滤更新 | + +- 筛选状态选项:全部、待评估、排队中、评估中、已完成、优质、待优化、异常 + +### 编辑数据条目 + +> **业务描述**: 用户点击某条数据卡片后打开编辑弹窗,可修改该条数据的问题和参考回答。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某条数据卡片 | 无(弹窗直接使用当前选中数据) | 无 | 屏幕中央弹出编辑弹窗,标题显示为数据序号和数据集名称,表单中预填当前数据的问题(用户输入)和参考回答(期望输出) | + +#### 步骤 2:修改并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改表单中问题/参考回答字段内容,点击「保存」/「保存并下一条」 | POST `/core/evaluation/dataset/data/update`(参数:dataId, userInput, expectedOutput) | 保存成功后 | 弹窗关闭(或自动切换到下一条数据),列表刷新;保存失败时显示错误提示 | + +##### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 用户问题 | 文本输入 | 是 | 当前数据的问题内容 | — | 否 | 评测数据中的用户输入问题 | +| 参考回答 | 文本输入 | 否 | 当前数据的期望输出内容 | — | 否 | 评测数据中的期望输出回答 | + +**前后置条件**: +- **前置条件**:数据集中有至少一条数据条目 +- **后置影响**:更新数据条目内容,如果设置了「同步修改数据集」则会同步更新原始数据集 +- **失败场景**:网络异常或数据已被删除时,提示错误信息 + +### 删除数据条目 + +> **业务描述**: 用户悬停数据卡片时点击删除图标,确认后删除该数据条目。 + +#### 步骤 1:删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停在数据卡片上,点击出现的删除图标 | 无 | 无 | 卡片右侧弹出删除确认气泡,显示警告图标和「确认删除数据?」提示,以及取消和删除两个按钮 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击气泡中的「删除」按钮 | DELETE `/core/evaluation/dataset/data/delete`(参数:dataId) | 删除成功 | 删除操作中按钮显示加载状态;删除成功后该卡片从列表移除,列表总数更新,提示"删除成功";删除失败显示错误提示 | + +##### 删除链路详情 + +- **引用检查**:无(直接删除,不做引用校验) +- **确认弹窗**:气泡型确认,标题为警告图标 + "确认删除数据?" +- **级联影响**:删除后列表总数减一,当前选中的编辑状态重置 + +### 批量质量评估 + +> **业务描述**: 用户发起对整个数据集的批量质量评估。 + +#### 步骤 1:打开确认弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「质量评估」按钮 | 无 | 无 | 弹出确认弹窗,显示提示文案「确认对全部 {total} 条数据进行质量评估?」,底部有「取消」和「开始评估」两个按钮 | + +#### 步骤 2:启动评估 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「开始评估」按钮 | POST `/core/evaluation/dataset/collection/qualityAssessmentBatch`(参数:collectionId) | 提交成功 | 按钮显示加载状态;成功后弹窗关闭,提示"提交成功",列表刷新(数据状态变为排队中/评估中) | + +### AI 智能生成追加数据 + +> **业务描述**: 用户通过 AI 从知识库智能生成新数据并追加到当前数据集中。 + +#### 步骤 1:打开智能生成弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「追加数据」按钮,在下拉菜单中选择「AI生成」 | 无 | 无 | 弹出智能生成弹窗,用户可配置生成参数(如知识库选择、生成数量等) | + +#### 步骤 2:确认生成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 配置参数后点击确认 | POST `/core/evaluation/dataset/data/create/smartGenerate` | 生成成功 | 弹窗关闭,列表刷新,新增数据出现在列表中 | + +### 文件导入追加数据 + +> **业务描述**: 用户通过上传文件将外部数据导入到当前数据集。 + +#### 步骤 1:跳转导入页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「追加数据」按钮,在下拉菜单中选择「文件导入」 | 无 | 无 | 页面跳转到文件导入页 `/dashboard/evaluation/dataset/fileImport?collectionId={id}&collectionName={name}&scene=evaluationDatasetDetail` | + +#### 步骤 2:上传并导入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择文件上传并确认导入 | POST `/core/evaluation/dataset/data/import`(FormData,含文件和数据集参数) | 导入成功 | 导入进度显示;完成后可返回详情页查看新增数据 | + +### 手动录入追加数据 + +> **业务描述**: 用户手动填写问题和参考回答,向当前数据集追加单条数据。 + +#### 步骤 1:打开手动录入弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「追加数据」按钮,在下拉菜单中选择「手动录入」 | 无 | 无 | 弹出手动新增弹窗,包含问题输入、参考回答输入和是否自动评估开关 | + +#### 步骤 2:提交录入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写表单后点击确认 | POST `/core/evaluation/dataset/data/create` | 创建成功 | 弹窗关闭,提示"创建成功",列表刷新显示新数据 | + +##### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 问题 | 文本输入 | 是 | — | — | 否 | 评测数据中的用户输入问题 | +| 参考回答 | 文本输入 | 否 | — | — | 否 | 评测数据中的期望输出回答 | +| 自动评估 | 开关 | 否 | 开启 | 开启/关闭 | 否 | 是否在创建后自动进行质量评估 | + +### 配置评测模型 + +> **业务描述**: 用户在设置弹窗中为当前数据集选择和配置用于质量评估的 AI 模型。 + +#### 步骤 1:打开设置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「设置」按钮 | GET `/core/evaluation/dataset/collection/detail?collectionId={id}`(获取当前配置) | 无 | 弹出设置弹窗,显示提示信息"更改评测模型后,重新评测将使用新的模型",模型选择器中预填当前使用的评测模型 | + +#### 步骤 2:选择模型并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从下拉列表中选择评测模型,点击「确认」 | POST `/core/evaluation/dataset/collection/update`(参数:collectionId, evaluationModelId) | 未选择模型时确认按钮置灰;选择模型后按钮可用 | 保存中确认按钮显示加载状态;保存成功后提示"更新成功",弹窗关闭 | + +- 模型列表中仅显示可用于评测的模型(`useInEvaluation` 为 true) + +## Mermaid 附录 + +### 查看数据列表 + 编辑 + 删除 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 数据集详情页 + participant API as 后端 API + + U->>UI: 从列表点击数据集名称 + UI->>API: GET /core/evaluation/dataset/collection/detail + API-->>UI: 返回数据集详情 + par 列表加载 + UI->>API: POST /core/evaluation/dataset/data/list (第1页) + API-->>UI: 返回数据条目列表 + end + UI-->>U: 展示数据卡片列表 + + U->>UI: 滚动到底部 + UI->>API: POST /core/evaluation/dataset/data/list (下一页) + API-->>UI: 追加返回数据 + UI-->>U: 追加展示数据卡片 + + U->>UI: 点击某条数据卡片 + UI-->>U: 弹出编辑弹窗(预填数据) + + U->>UI: 修改内容并点击保存 + UI->>API: POST /core/evaluation/dataset/data/update + API-->>UI: 更新成功 + UI-->>U: 提示"更新成功",刷新列表 + + U->>UI: 悬停数据卡片 → 点击删除图标 + UI-->>U: 弹出删除确认气泡 + + U->>UI: 点击「删除」按钮 + UI->>API: DELETE /core/evaluation/dataset/data/delete + API-->>UI: 删除成功 + UI-->>U: 提示"删除成功",卡片移除,列表刷新 +``` + +### 质量评估 + 追加数据 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 数据集详情页 + participant API as 后端 API + + rect rgb(240, 248, 255) + Note over U,API: 批量质量评估 + U->>UI: 点击「质量评估」按钮 + UI-->>U: 弹出确认弹窗 + U->>UI: 点击「开始评估」 + UI->>API: POST /collection/qualityAssessmentBatch + API-->>UI: 提交成功 + UI-->>U: 提示"提交成功",刷新列表 + end + + rect rgb(255, 248, 240) + Note over U,API: 手动录入追加数据 + U->>UI: 「追加数据」→「手动录入」 + UI-->>U: 弹出手动新增弹窗 + U->>UI: 填写问题、参考回答,点击确认 + UI->>API: POST /data/create + API-->>UI: 创建成功 + UI-->>U: 提示"创建成功",刷新列表 + end + + rect rgb(240, 255, 240) + Note over U,API: 配置评测模型 + U->>UI: 点击「设置」按钮 + UI->>API: GET /collection/detail + API-->>UI: 返回当前配置 + UI-->>U: 弹出设置弹窗(预填模型) + U->>UI: 选择模型,点击确认 + UI->>API: POST /collection/update + API-->>UI: 更新成功 + UI-->>U: 提示"更新成功" + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..dfd9c9f951bf --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,36 @@ +--- +capability_label: 评测数据集详情 +doc_type: "11" +doc_label: "共享能力清单" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — 共享能力清单 + +## 对外暴露的页面/组件 + +| 共享项 | 类型 | 入口路径 | 使用者 | 用途 | +|--------|------|---------|--------|------| +| 数据集详情页(Detail) | 页面组件 | `/dashboard/evaluation/dataset/detail?collectionId={id}` | 评测任务详情 | 在评测任务详情中嵌入数据集数据查看 | +| 数据集详情页(Detail) | 页面组件 | `/dashboard/evaluation/dataset/detail?collectionId={id}` | 数据集管理 | 在数据集管理中嵌入数据集数据查看 | + +## 跨模块依赖详情 + +### 被以下模块依赖 (imported_by) + +| 消费模块 | 引用的具体内容 | 引用方式 | 首次引入 | +|---------|--------------|---------|---------| +| 评测任务详情 (evaluation/task/detail) | Detail 页面组件 | import | — | +| 数据集详情 (dataset/detail) | Detail 页面组件 | import | — | + +### 依赖以下模块 (imports_from) + +| 依赖模块 | 使用的具体能力 | 引用方式 | 关键度 | +|---------|--------------|---------|--------| +| 通用组件库 (@fastgpt/web) | MyModal, MyIcon, MyTag, MyBox, MyMenu, EmptyTip 等 | import | 核心 | +| 评测数据集 API 层 | CRUD、质量评估、智能生成接口 | import | 核心 | +| 系统 Store (useSystemStore) | 获取 LLM 模型列表 | import | 可选 | +| 国际化 (next-i18next) | 页面多语言 | import | 核心 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..97286f01b801 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 评测数据集详情 +doc_type: "08" +doc_label: "模块概览" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — 模块概览 + +## 1. 模块业务概述 + +评测数据集详情模块是评测工作台中 **评测数据集管理** 的核心子模块,为用户提供单个评测数据集的完整内容管理能力。用户在该模块中可以查看数据集中的所有评测数据条目、编辑数据内容、进行质量评估、追加新数据以及配置评测模型。 + +此模块服务于需要进行 AI 应用评测的**开发者和质量管理人员**,帮助他们精细化管理评测数据集的内容质量,确保评测数据的准确性和完整性。 + +本模块为叶子节点,无子能力。 + +## 2. 角色差异 + +本模块未检测到显式的角色权限控制代码,当前所有可访问评测工作台的用户均可进入此详情页进行数据管理操作。权限校验在父级路由层完成,详情页本身不进行二次角色判断。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 通用组件库 | MyModal、MyIcon、MyTag、MyBox、MyInput、MyMenu、EmptyTip 等基础 UI 组件 | 组件 | +| 评测数据集 API 层 | 数据集数据 CRUD、质量评估、智能生成等接口调用 | API | +| 系统 Store(useSystemStore) | 获取 LLM 模型列表用于评测模型选择 | Store | +| useScrollPagination Hook | 滚动分页加载评测数据集列表 | Hook | +| 国际化(i18n) | 页面文案的多语言支持 | 基础设施 | + +## 4. 对外共享 + +本模块的页面组件 `Detail` 被以下模块引用: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 数据集详情页(Detail 组件) | 评测任务详情(evaluation/task/detail) | 页面组件 | +| 数据集详情页(Detail 组件) | 数据集详情(dataset/detail) | 页面组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测数据集详情 | /dashboard/evaluation/dataset/detail | projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx | 所有已登录用户 | 无 Tab,单页数据管理视图 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..36d18c7aa0f2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,106 @@ +--- +capability_label: "评测数据集首页" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 业务流程索引 + +## 业务场景列表 + +### 查看数据集列表 + +- **业务描述**: 用户进入评测数据集首页,查看所有已创建的评测数据集,包括名称、数据量、创建/更新时间、状态和创建者信息。 +- **触发入口**: 工作台 → 评测 → 评测数据集菜单 +- **路由路径**: /dashboard/evaluation/dataset +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/index.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 用户已登录并有团队权限 + +### 搜索数据集 + +- **业务描述**: 用户通过数据集名称关键词搜索,系统在 500ms 防抖后自动发起筛选查询,列表实时更新匹配结果。 +- **触发入口**: 数据集列表页顶部搜索框 +- **路由路径**: /dashboard/evaluation/dataset +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/index.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 已在数据集列表页 + +### 智能生成数据集 + +- **业务描述**: 用户通过选择知识库作为数据源,配置生成模型和数据量,由 AI 自动生成评测数据集。 +- **触发入口**: 数据集列表页 → "创建新数据集"按钮 → "智能生成"选项 +- **路由路径**: /dashboard/evaluation/dataset(弹窗形式) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 系统中有可用知识库;用户有权限访问知识库 + +### 文件导入数据集 + +- **业务描述**: 用户通过上传文件批量导入数据创建评测数据集,系统跳转到文件导入页面进行处理。 +- **触发入口**: 数据集列表页 → "创建新数据集"按钮 → "文件导入"选项 +- **路由路径**: /dashboard/evaluation/dataset/fileImport +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 准备好转悠格式的数据文件 + +### 重命名数据集 + +- **业务描述**: 用户修改已有评测数据集的名称,提交后系统更新名称并刷新列表。 +- **触发入口**: 数据集列表行 → 操作菜单(更多按钮)→ "重命名" +- **路由路径**: /dashboard/evaluation/dataset(内联弹窗) +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/index.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 数据集已存在 + +### 删除数据集 + +- **业务描述**: 用户删除不再需要的评测数据集,系统弹出确认对话框,确认后执行删除并刷新列表。 +- **触发入口**: 数据集列表行 → 操作菜单(更多按钮)→ "删除" +- **路由路径**: /dashboard/evaluation/dataset +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/index.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 数据集已存在 + +### 查看数据集详情 + +- **业务描述**: 用户点击数据集行,跳转到数据集详情页查看数据项列表和进行数据管理。 +- **触发入口**: 数据集列表行点击 +- **路由路径**: /dashboard/evaluation/dataset/detail +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 数据集已存在 + +### 查看异常任务 + +- **业务描述**: 当数据集状态为"生成异常"时,用户点击状态标签查看失败任务的详细错误信息,可对单个任务执行重试或删除操作。 +- **触发入口**: 数据集列表 → 状态为"生成异常"的状态标签点击 +- **路由路径**: /dashboard/evaluation/dataset(弹窗形式) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/evaluation/dataset/errorModal.tsx +- **涉及角色**: 团队成员 +- **前置条件**: 数据集状态为 error(生成异常) + +## 场景关系 + +- **查看数据集列表** → **搜索数据集**: 列表页内部操作,筛选已有列表 +- **查看数据集列表** → **智能生成数据集** / **文件导入数据集**: 从列表页创建新数据集 +- **查看数据集列表** → **查看数据集详情**: 点击列表行导航到详情页 +- **查看数据集列表** → **重命名/删除数据集**: 行内操作,执行后刷新列表 +- **查看数据集列表** → **查看异常任务**: 仅 error 状态的数据集可触发 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看数据集列表 | 工作台→评测→评测数据集菜单 | /dashboard/evaluation/dataset | 团队成员 | +| S02 | 搜索数据集 | 列表页搜索框 | /dashboard/evaluation/dataset | 团队成员 | +| S03 | 智能生成数据集 | "创建新数据集"→"智能生成" | /dashboard/evaluation/dataset | 团队成员 | +| S04 | 文件导入数据集 | "创建新数据集"→"文件导入" | /dashboard/evaluation/dataset/fileImport | 团队成员 | +| S05 | 重命名数据集 | 行操作菜单→"重命名" | /dashboard/evaluation/dataset | 团队成员 | +| S06 | 删除数据集 | 行操作菜单→"删除" | /dashboard/evaluation/dataset | 团队成员 | +| S07 | 查看数据集详情 | 列表行点击 | /dashboard/evaluation/dataset/detail | 团队成员 | +| S08 | 查看异常任务 | error 状态标签点击 | /dashboard/evaluation/dataset | 团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..96202483d12a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,251 @@ +--- +capability_label: "评测数据集首页" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 业务流程详解 + +## 页面总览 + +评测数据集首页是评测系统的数据管理入口,提供数据集列表的查看、搜索、创建和管理功能。页面顶部为操作栏(搜索框 + 创建按钮),主体为数据集表格,底部为分页器。创建数据集支持"智能生成"和"文件导入"两种方式。 + +### 查看数据集列表 + +> 用户进入页面后,系统自动加载第一页数据集列表数据,以表格形式展示每个数据集的名称、数据量、创建/更新时间、状态和创建者信息。 + +#### 步骤 1:页面加载,获取数据集列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入 /dashboard/evaluation/dataset 路由 | POST /core/evaluation/dataset/collection/list(参数:pageNum=1, pageSize=10, searchKey="") | 无 | 页面显示 MyBox 加载遮罩,表格区域展示加载中状态 | + +#### 步骤 2:列表渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(系统自动) | 无(数据已返回) | 数据返回后 | 加载遮罩消失,表格渲染数据集列表,每行显示:名称、数据量、创建/更新时间、状态标签、创建者头像和名称、操作按钮(更多菜单)。列表为空时显示 EmptyTip 空状态提示 | + +#### 步骤 3:分页操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器切换页码或修改每页条数 | POST /core/evaluation/dataset/collection/list(参数:pageNum=N, pageSize=M, searchKey=当前搜索值) | 无 | 表格数据更新为新页数据,分页器状态同步变化 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/evaluation/dataset/collection/list | pageNum=1, pageSize=10 | 无额外处理 | 表格前10条 | +| 翻页 | POST /core/evaluation/dataset/collection/list | pageNum=N, pageSize=10 | 无额外处理 | 表格第N页 | +| 搜索触发刷新 | POST /core/evaluation/dataset/collection/list | pageNum=1, pageSize=10, searchKey=搜索词 | 重置到第1页 | 过滤后的表格数据 | + +- 分页参数:默认每页 10 条 +- 排序规则:按创建时间降序(默认) +- 筛选条件:搜索框支持按名称搜索,500ms 防抖后触发 + +**特殊列的渲染**: +- **状态列**:显示彩色标签(绿色=ready 就绪、蓝色=processing 生成中、灰色=queuing 排队中、红色=error 生成异常)。error 状态时标签可点击,打开异常详情弹窗,hover 提示"点击查看详情" +- **时间列**:显示两行,首行为创建时间(yyyy-MM-dd HH:mm:ss),次行为更新时间 +- **创建者列**:显示用户头像 + 名称 + +### 搜索数据集 + +> 用户在搜索框中输入数据集名称关键词,系统在 500ms 防抖后自动发起查询,列表展示匹配结果。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | 无(仅更新本地状态) | 无 | 输入框内容实时更新 | + +#### 步骤 2:防抖触发搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 停止输入 500ms 后 | POST /core/evaluation/dataset/collection/list(参数:pageNum=1, pageSize=10, searchKey=输入值) | 无 | 表格加载遮罩出现,数据刷新为搜索结果第1页 | + +### 智能生成数据集 + +> 用户点击"创建新数据集"→"智能生成",打开智能生成弹窗。配置数据集名称、选择知识库、设置数据量和生成模型后,提交由 AI 生成评测数据集。 + +#### 步骤 1:打开智能生成弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建新数据集"按钮,选择"智能生成" | 无 | 无 | 弹出智能生成配置弹窗,标题"智能生成数据集" | + +#### 步骤 2:配置生成参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在弹窗中填写数据集名称 | 无 | scene=database 时必填 | 名称输入框填写状态更新 | +| 点击"选择知识库"按钮 | 无 | 无 | 打开知识库选择弹窗 | +| 在知识库选择器中勾选知识库 | 无 | 仅列出非数据库类型知识库 | 已选知识库以卡片形式展示(头像+名称) | +| 调整数据量数值 | 无 | 最小1,最大值为已选知识库数据总量(至少50);数据量随知识库选择联动更新上限 | MyNumberInput 数字输入器更新 | +| 选择生成模型 | 无 | 模型列表仅展示 useInEvaluation 的 LLM 模型,默认选中第一个 | 下拉选择器更新 | + +#### 步骤 3:提交生成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | POST /core/evaluation/dataset/data/smartGenerate(参数:name, kbDatasetIds[], intelligentGenerationModelId, count) | 表单校验通过(名称非空、已选知识库、数据量≥1、已选模型);任一不满足时按钮灰显不可点击 | 按钮进入 loading 状态,弹窗关闭,列表自动刷新 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 数据集名称 | 文本输入 | 是(scene=dataset时) | "" | — | 仅在"数据集"场景下显示 | +| 生成依据(知识库) | 知识库选择器(弹窗) | 是 | [] | 非数据库类型知识库 | 至少选择一个知识库,已选中以卡片展示 | +| 数据量 | 数字输入器 | 是 | 50 | 1 到已选知识库数据总量 | 选择知识库后自动调整上限 | +| 生成模型 | 下拉选择器 | 是 | 第一个可用模型 | 仅可选 useInEvaluation 的 LLM 模型 | — | + +**字段联动**:数据量上限随已选知识库的数据总量联动更新;当知识库选择变化时,若上限低于当前值则自动调整为上限值。 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 数据集名称必填(scene=dataset时) | 提交时 | 表单校验失败,确认按钮灰显 | +| 必须选择知识库 | 提交时 | 确认按钮灰显 | +| 数据量必须≥1 | 提交时 | 确认按钮灰显 | +| 必须选择生成模型 | 提交时 | 确认按钮灰显 | + +### 文件导入数据集 + +> 用户点击"创建新数据集"→"文件导入",跳转到文件导入页面。 + +#### 步骤 1:导航到文件导入页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建新数据集"→"文件导入" | 无 | 无 | 路由跳转到 /dashboard/evaluation/dataset/fileImport?scene=evaluationDatasetList | + +### 重命名数据集 + +> 用户在操作菜单中选择"重命名",弹出编辑弹窗,修改名称后提交。 + +#### 步骤 1:打开重命名弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击行操作菜单(更多按钮)→"重命名" | 无 | 无 | 弹出编辑标题弹窗,预填当前数据集名称 | + +#### 步骤 2:提交新名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称后点击确认 | POST /core/evaluation/dataset/collection/update(参数:collectionId, name) | 无 | 成功后 toast 提示"更新成功",弹窗关闭,列表刷新 | + +### 删除数据集 + +> 用户删除指定数据集,需在确认弹窗中二次确认。 + +#### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击行操作菜单→"删除" | 无 | 无 | 弹出确认弹窗,标题"删除",提示文案为"确认删除该数据集?",含确认和取消按钮 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗"确认"按钮 | DELETE /core/evaluation/dataset/collection/delete(参数:collectionId) | 无 | 成功后 toast 提示"删除成功",弹窗关闭,列表刷新;失败 toast 提示相应错误 | + +**删除链路详情**: +- **确认弹窗**:弹窗标题为"删除",提示用户确认删除操作。确认文本为"删除"。使用 useConfirm hook 配置类型为 "delete" +- **级联影响**:删除后数据集从列表移除,关联的评测任务可能受影响 + +### 查看异常任务 + +> 当数据集状态为"生成异常"时,用户点击红色状态标签,打开异常详情弹窗查看失败任务列表。 + +#### 步骤 1:打开异常弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 error 状态标签 | POST /core/evaluation/dataset/collection/failedTasks(参数:collectionId) | 仅 status=error 时标签可点击 | 弹出异常信息弹窗,加载失败任务列表 | + +#### 步骤 2:查看和处理失败任务 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看失败任务列表 | 无(数据已加载) | 任务列表为空时显示 EmptyTip | 表格展示:来源知识库、来源分块、错误信息、操作列(重试+删除按钮) | +| 点击单个任务的"重试"按钮 | POST /core/evaluation/dataset/collection/retryTask(参数:jobId, collectionId) | 无 | 按钮 loading,成功后 toast "重试成功",任务列表刷新 | +| 点击单个任务的"删除"按钮 | POST /core/evaluation/dataset/collection/deleteTask(参数:jobId, collectionId) | 无 | 按钮 loading,成功后 toast "删除成功",任务列表刷新 | +| 点击"全部重试"按钮 | POST /core/evaluation/dataset/collection/retryAllTask(参数:collectionId) | 任务列表为空时按钮灰显 | 按钮 loading,成功后 toast "重试成功",任务列表刷新 | + +#### 步骤 3:关闭弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击取消或关闭按钮 | 无 | 若异常任务数量有变化(处理过任务),传入 isUpdateList=true | 弹窗关闭;若列表有变化则刷新主页列表 | + +**状态转换详情**: +- **异常任务状态转换**:error → 点击重试 → 任务重新进入处理队列 → 状态变为 processing/ready 或仍为 error(重试失败) +- **前置检查**:重试前不检查依赖,直接重新提交任务;全部重试按钮在任务列表为空时灰显 +- **级联更新**:任务重试/删除成功后自动刷新异常任务列表 + +### 查看数据集详情 + +> 用户点击数据集行,导航到数据集详情页。 + +#### 步骤 1:导航到详情页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击数据集表格行 | 无 | 无 | 路由跳转到 /dashboard/evaluation/dataset/detail?collectionId={_id}&collectionName={name}。行 hover 时背景变为 myGray.100 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据集首页 + participant API as 评测 API + participant Backend as 后端 + + User->>UI: 进入评测数据集首页 + UI->>API: POST /collection/list (pageNum=1) + API->>Backend: 查询数据集列表 + Backend-->>API: 返回列表数据 + API-->>UI: 渲染表格 + UI-->>User: 展示数据集列表 + + User->>UI: 输入搜索关键词 + Note over UI: 500ms 防抖 + UI->>API: POST /collection/list (searchKey) + API-->>UI: 筛选后数据 + UI-->>User: 更新列表展示 + + User->>UI: 点击"创建新数据集"→"智能生成" + UI-->>User: 弹出智能生成配置弹窗 + User->>UI: 填写名称、选择知识库、配置参数 + User->>UI: 点击确认 + UI->>API: POST /data/smartGenerate + API->>Backend: 触发智能生成任务 + Backend-->>API: 返回结果 + API-->>UI: 关闭弹窗,刷新列表 + + User->>UI: 点击行操作→删除 + UI-->>User: 确认弹窗 + User->>UI: 确认删除 + UI->>API: DELETE /collection/delete + API->>Backend: 删除数据集 + Backend-->>API: 删除成功 + API-->>UI: 刷新列表 + UI-->>User: Toast "删除成功" + + User->>UI: 点击 error 状态标签 + UI->>API: POST /collection/failedTasks + API-->>UI: 弹出异常详情弹窗 + UI-->>User: 展示失败任务列表 + + User->>UI: 点击数据集行 + UI-->>User: 跳转到详情页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..dbefc4e8feb2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,45 @@ +--- +capability_label: "评测数据集首页" +doc_type: "11" +doc_label: "共享能力清单" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 共享能力清单 + +## 对外暴露的页面/组件 + +| 共享项 | 类型 | 入口路径 | 使用者 | 用途 | +|--------|------|---------|--------|------| +| IntelligentGeneration 智能生成弹窗 | 组件 | projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx | 评测数据集详情、评测任务创建、对话数据集选择器 | 在消费模块中作为数据智能生成的入口,复用知识库选择、数据量配置和模型选择的交互流程 | + +## 对外暴露的 Hook/API + +本模块未检测到对外暴露的 Hook 或 JS/TS API。模块的 API 调用均通过共享的 `@/web/core/evaluation/dataset` 层发出,不直接对外暴露。 + +## 跨模块依赖详情 + +### 被以下模块依赖 (imported_by) + +| 消费模块 | 引用的具体内容 | 引用方式 | 首次引入 | +|---------|--------------|---------|---------| +| 对话数据集选择器 | IntelligentGeneration 组件 | import | 数据集智能生成入口 | +| 评测数据集详情 | IntelligentGeneration 组件 | import | 追加数据时智能生成 | +| 评测任务创建 | IntelligentGeneration 组件 | import | 创建任务时生成数据集 | + +### 依赖以下模块 (imports_from) + +| 依赖模块 | 使用的具体能力 | 引用方式 | 关键度 | +|---------|--------------|---------|--------| +| @fastgpt/web(共享组件库) | MyBox、MyIcon、MyMenu、MyTag、MyTooltip、MyModal、EmptyTip、UserBox 等 10+ 通用组件 | import | 核心 | +| @fastgpt/web/hooks | usePagination、useRequest、useConfirm、useEditTitle | import | 核心 | +| @/web/core/evaluation/dataset | 评测数据集 CRUD API 函数 | import | 核心 | +| next-i18next | useTranslation 国际化 | import | 核心 | +| ahooks | useDebounceFn 防抖 | import | 可选 | +| next/router | useRouter 路由导航 | import | 核心 | +| date-fns | format 日期格式化 | import | 可选 | +| @/components/Select/AIModelSelector | AI 模型选择器组件 | import | 可选 | +| @/components/core/app/sfDatasetSelectModal | 知识库数据集选择弹窗 | import | 可选 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..ddd2860c57a5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: "评测数据集首页" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 模块概览 + +## 1. 模块业务概述 + +评测数据集首页是 FastGPT 评测系统中数据集管理的入口页面,为团队成员提供评测数据集的集中查看、创建和管理功能。用户可在此页面浏览所有已创建的评测数据集,通过搜索快速定位目标数据集,并执行重命名、删除等管理操作。 + +该模块在评测工作流中承担"数据准备"环节:用户先在此创建评测数据集(支持智能生成和文件导入两种方式),再基于数据集创建评测任务进行模型效果评估。模块包含三个核心子功能入口:数据集列表管理(本页)、智能生成数据集(弹窗组件)、异常任务处理(错误详情弹窗)。 + +## 2. 角色差异 + +本模块面向所有已认证的团队成员,不区分角色权限。所有登录用户均可查看数据集列表、创建数据集、管理自己有权访问的数据集。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员 | 数据集列表页 | 查看列表、搜索、创建数据集、重命名、删除 | 无角色差异 | + +**权限模型**:基于团队成员的统一权限模型,数据集操作受团队级权限控制。数据集的创建者信息通过 `creatorName` 和 `creatorAvatar` 字段展示在列表中。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库 (@fastgpt/web) | MyBox、MyIcon、MyMenu、MyTag、MyTooltip、UserBox、EmptyTip 等通用 UI 组件 | 组件 | +| Web Hooks (@fastgpt/web/hooks) | usePagination(分页)、useRequest(请求管理)、useConfirm(确认弹窗) | Hook | +| 评测数据集 API 层 | getEvaluationDatasetList、deleteEvaluationDataset、updateEvaluationDataset | API | +| 评测数据集组件 | IntelligentGeneration(智能生成弹窗)、ErrorModal(异常详情弹窗) | 组件 | +| 全局类型 (@fastgpt/global) | listEvalDatasetCollectionResponse(数据集列表响应类型) | 类型 | + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| IntelligentGeneration 智能生成弹窗 | 评测数据集详情、评测任务创建、对话数据集选择器 | 组件 | +| ErrorModal 异常详情弹窗 | 评测数据集首页(自身) | 组件 | + +> 完整列表见 [共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测数据集首页 | /dashboard/evaluation/dataset | projects/app/src/pages/dashboard/evaluation/dataset/index.tsx | 团队成员 | 数据集列表管理页,支持搜索、创建、重命名、删除;含智能生成弹窗和异常详情弹窗 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。模块内的两个弹窗组件(IntelligentGeneration 智能生成弹窗、ErrorModal 异常详情弹窗)作为本模块的内部组件,不独立拆分为子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..8f441e5d3904 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: 评测维度 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: + - 团队管理员 + - 团队成员(有评测权限) +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 业务流程索引 + +## 查看维度列表 + +- **业务描述**: 用户在评测首页切换到「评测维度」Tab,查看系统内置维度和团队自定义维度的完整列表,按名称或描述搜索过滤。 +- **触发入口**: 工作台 → 评测 → 点击「评测维度」Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**: 有评测权限的团队成员 +- **前置条件**: `feConfigs.show_evaluation` 为 `true`,用户拥有评测访问权限 + +## 创建自定义维度 + +- **业务描述**: 用户在维度列表页点击「创建维度」按钮,填写维度名称、描述和评分提示词,可选择引用内置模板作为提示词起点,提交前可试运行验证评分效果。 +- **触发入口**: 维度列表页 → 「创建维度」按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` +- **涉及角色**: 有评测权限的团队成员 +- **前置条件**: 已登录,有评测权限,维度名称和提示词均为必填 + +## 编辑自定义维度 + +- **业务描述**: 用户点击列表中某个自定义维度的行,进入编辑页面修改维度名称、描述或评分提示词,支持试运行验证修改后的效果。 +- **触发入口**: 维度列表页 → 点击自定义维度行(内置维度不可点击编辑) +- **路由路径**: `/dashboard/evaluation/dimension/edit?id={dimensionId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` +- **涉及角色**: 有评测权限的团队成员 +- **前置条件**: 维度类型为自定义(Custom),`dimensionId` 有效,维度数据加载成功 + +## 删除自定义维度 + +- **业务描述**: 用户在维度列表中点击自定义维度行的删除图标,确认后删除该维度。 +- **触发入口**: 维度列表页 → 点击行末删除图标 → 确认弹窗 +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions`(无页面跳转) +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx`(内联操作) +- **涉及角色**: 有评测权限的团队成员 +- **前置条件**: 维度类型为自定义,内置维度不可删除(行末无删除按钮) + +## 试运行维度评分 + +- **业务描述**: 在创建或编辑维度时,用户点击「试运行」按钮,在弹窗中输入测试问题、参考答案和实际回答,选择评测模型后运行,查看维度评分结果。 +- **触发入口**: 创建维度/编辑维度页 → 「试运行」按钮 +- **路由路径**: 复用当前页面路由(弹窗形式) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` +- **涉及角色**: 有评测权限的团队成员 +- **前置条件**: 表单必填字段(名称、提示词)已填写,已选择评测模型,问题/参考答案/实际回答均已输入 + +## 场景关系 + +- **查看维度列表** → **创建自定义维度**: 在列表页点击「创建维度」按钮跳转,创建成功后自动返回列表页 +- **查看维度列表** → **编辑自定义维度**: 在列表页点击自定义维度行跳转,编辑成功后自动返回列表页 +- **查看维度列表** → **删除自定义维度**: 在列表页行内操作,删除成功后刷新列表 +- **创建/编辑维度** → **试运行维度评分**: 在表单页面点击「试运行」按钮打开弹窗,不跳转页面 +- 创建、编辑、删除、试运行各场景独立,无严格先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看维度列表 | 评测首页 → 评测维度 Tab | /dashboard/evaluation?evaluationTab=dimensions | 有评测权限的成员 | +| S02 | 创建自定义维度 | 维度列表 → 创建按钮 | /dashboard/evaluation/dimension/create | 有评测权限的成员 | +| S03 | 编辑自定义维度 | 维度列表 → 点击维度行 | /dashboard/evaluation/dimension/edit | 有评测权限的成员 | +| S04 | 删除自定义维度 | 维度列表 → 删除图标 → 确认 | /dashboard/evaluation?evaluationTab=dimensions | 有评测权限的成员 | +| S05 | 试运行维度评分 | 创建/编辑页 → 试运行按钮 | 弹窗,不跳转路由 | 有评测权限的成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e9820a251ae5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,34 @@ +--- +capability_label: 评测维度 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: + - 团队管理员 + - 团队成员(有评测权限) +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 业务流程详解 + +> 评测维度为纯分组节点(is_grouping=true),其业务流程分布于三个子能力中。本文档为子能力索引,各子能力的完整交互步骤请参见对应文档。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 核心场景 | 详细文档 | +|--------|---------|---------|---------| +| 评测维度首页 | 维度列表查看、搜索过滤、删除操作 | 查看维度列表、删除自定义维度 | [业务流程详解](../评测维度/评测维度首页/业务流程详解.md) | +| 创建维度 | 创建自定义维度,含表单填写和提交 | 创建自定义维度、试运行 | [业务流程详解](../评测维度/创建维度/业务流程详解.md) | +| 编辑维度 | 编辑已有自定义维度,含数据加载和更新 | 编辑自定义维度、试运行 | [业务流程详解](../评测维度/编辑维度/业务流程详解.md) | + +## 公共能力概述 + +三个子能力共享以下公共能力,不在此分组节点展开: + +- **表单组件**:EditForm 组件同时被创建和编辑页面使用,提供名称(必填)、描述(选填)、提示词(必填)三个字段的输入,支持实时校验和内置模板引用 +- **试运行组件**:TestRun 组件同时被创建和编辑页面使用,提供问题、参考答案、实际回答的输入和评测模型选择,调用调试接口获取评分结果 +- **API 层**:子能力共享 `@/web/core/evaluation/dimension.ts` 中的 6 个 API 函数(列表、详情、创建、更新、删除、调试) diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..900378e408dd --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,51 @@ +--- +capability_label: 创建维度 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 评测维度 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/dimension/create"] +--- + +# 创建维度 — 业务流程索引 + +## 填写并创建评估维度 + +- **业务描述**: 用户填写评估维度的名称、描述和提示词,提交后创建一条新的评估维度记录,系统跳转回评测首页并切换到维度列表页。 +- **触发入口**: 评测首页 → 维度列表 → 点击"创建维度"按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create` +- **页面组件位置**: projects/app/src/pages/dashboard/evaluation/dimension/create.tsx +- **涉及角色**: admin, team_member +- **前置条件**: 用户已登录,具有评测模块访问权限 + +## 引用模板快速填充 + +- **业务描述**: 在填写提示词时,用户可通过"引用模板"按钮打开模板选择弹窗,从 8 种内置评估维度模板中选择一种,系统将对应的评分提示词模板填充到提示词输入框中。 +- **触发入口**: 创建维度页 → 提示词区域 → 点击"引用模板"按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create`(同页面内弹窗) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/evaluation/dimension/CitationTemplate.tsx +- **涉及角色**: admin, team_member +- **前置条件**: 已在创建维度页面 + +## 试运行测试维度效果 + +- **业务描述**: 填写完毕表单后,用户可点击"试运行"按钮打开测试弹窗,输入测试问题、参考答案和实际回答,选择评估模型,运行测试查看维度在当前配置下的评分结果和反馈。 +- **触发入口**: 创建维度页 → 页面底部 → 点击"试运行"按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create`(同页面内弹窗) +- **页面组件位置**: projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx +- **涉及角色**: admin, team_member +- **前置条件**: 表单必填字段(名称、提示词)已填写有效,表单验证通过 + +## 场景关系 + +- **填写并创建评估维度** ← **引用模板快速填充**:引用模板是填写提示词的辅助操作,可缩短提示词编写时间 +- **填写并创建评估维度** ← **试运行测试维度效果**:试运行是提交前的可选验证步骤,用于确认维度评分效果符合预期 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 填写并创建评估维度 | 评测首页→维度列表→创建维度按钮 | /dashboard/evaluation/dimension/create | admin, team_member | +| S02 | 引用模板快速填充 | 创建维度页→提示词区域→引用模板按钮 | /dashboard/evaluation/dimension/create | admin, team_member | +| S03 | 试运行测试维度效果 | 创建维度页→底部→试运行按钮 | /dashboard/evaluation/dimension/create | admin, team_member | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..0c34cd7ded99 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,212 @@ +--- +capability_label: 创建维度 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 评测维度 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/dimension/create"] +--- + +# 创建维度 — 业务流程详解 + +## 页面总览 + +创建维度页面是一个单页表单,用户在此定义用于 AI 输出评估的评价维度。页面提供名称、描述、提示词三个核心字段,辅助以模板引用和试运行功能。提交成功后跳转回评测首页维度列表。 + +### 填写并创建评估维度 + +> 用户在创建维度页面填写评估维度的基本信息并提交创建。 + +#### 步骤 1:进入创建维度页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从评测首页维度列表点击"创建维度"按钮 | 无 | 无 | 页面跳转至 `/dashboard/evaluation/dimension/create`,DashboardContainer 渲染布局框架(侧边栏+背景装饰),加载页面内容:返回按钮、EditForm 空表单、试运行和提交按钮 | + +#### 步骤 2:填写维度名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"名称"输入框中输入维度名称 | 无 | 实时校验:名称为空时,试运行按钮、提交按钮置灰(`isFormValid: false`) | 输入框获得焦点(`autoFocus`),输入内容后失焦触发 react-hook-form 校验;名称非空后表单有效性检查通过,按钮变为可用状态 | + +#### 步骤 3:填写维度描述(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"描述"文本域中输入维度说明 | 无 | 描述为可选字段,不影响表单有效性 | 文本域接受多行输入(3行高度) | + +#### 步骤 4:填写评估提示词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在"提示词"文本域中输入评估提示词内容 | 无 | 实时校验:提示词为空时,试运行按钮、提交按钮置灰;提示词非空且名称非空时,按钮可用 | 文本域接受多行输入(15行高度),支持大段提示词内容 | + +#### 步骤 5:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认创建"按钮 | `POST /core/evaluation/metric/create`(参数:name, description, prompt) | 名称或提示词为空时:弹 toast 警告"请输入维度名称"或"请输入提示词"并中止提交 | 按钮显示 loading 状态;API 成功 → toast 提示"维度创建成功",页面跳转至 `/dashboard/evaluation?evaluationTab=dimensions` | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | — | 任意文本 | 否 | 维度唯一标识名称 | +| 描述 | 多行文本 | — | — | — | 否 | 维度用途说明 | +| 提示词 | 多行文本(15行) | ✅ | — | 任意文本,支持引用模板填充 | 否 | 用于 AI 评分的评估提示词 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称不能为空 | 实时(onChange) | 按钮置灰,无文字提示 | +| 提示词不能为空 | 实时(onChange) | 按钮置灰,无文字提示 | +| 名称不能为空 | 提交时 | "请输入维度名称"(toast 警告) | +| 提示词不能为空 | 提交时 | "请输入提示词"(toast 警告) | + +**前后置条件**: +- **前置条件**:用户已登录,具有评测模块访问权限 +- **后置影响**:创建成功后在数据库新增一条评估维度记录,页面跳转至评测首页维度列表,新维度出现在列表中 +- **失败场景**:API 调用失败时,按钮恢复可用状态,提示错误信息 + +### 引用模板快速填充 + +> 用户在提示词区域点击"引用模板"按钮,从内置模板中选择并填充提示词。 + +#### 步骤 1:打开模板弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击提示词上方的"引用模板"按钮 | 无 | 无 | 弹出 MyModal 弹窗,标题"引用模板",左侧显示 8 种维度列表(正确性、简洁性、有害性、争议性、创造性、违法性、深度、细节),右侧显示默认选中维度的模板内容 | + +#### 步骤 2:选择维度模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左侧维度列表中的某一项(如"正确性") | 无 | 无 | 选中项高亮(白色背景+蓝色边框+蓝色文字);右侧文本域内容切换为该维度对应的评分模板(根据当前语言显示中文或英文模板) | + +#### 步骤 3:编辑模板内容(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧文本域中修改模板内容 | 无 | 无 | 文本域实时显示修改后的内容 | + +#### 步骤 4:确认引用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | 无 | 模板内容为空时阻止提交 | 弹窗关闭,EditForm 的提示词输入框内容被替换为所选模板内容;取消则关闭弹窗不保存 | + +### 试运行测试维度效果 + +> 用户点击"试运行"按钮,输入测试用例并运行 AI 评估,预览维度评分效果。 + +#### 步骤 1:打开试运行弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面底部的"试运行"按钮 | 无 | 表单无效(名称或提示词为空)时按钮置灰不可点击 | 弹出 MyModal 弹窗,标题"试运行",左右分栏布局:左侧为输入区(问题、参考答案、实际回答),右侧为结果区(默认显示空状态) | + +#### 步骤 2:输入测试数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 依次填写问题、参考答案、实际回答三个文本域 | 无 | 无 | 文本域接受输入;模型选择器默认选中 LLM 模型列表中的第一个模型 | + +#### 步骤 3:选择评估模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 AIModelSelector 下拉框中选择用于评分的 LLM 模型 | 无 | 未选择模型时运行按钮置灰 | 选中模型切换,后续评分将使用该模型 | + +#### 步骤 4:启动试运行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"运行"按钮 | `POST /core/evaluation/metric/debug`(参数:evalCase {用户问题、期望输出、实际输出}, llmConfig {模型ID}, metricConfig {维度名称、类型、提示词}) | 提示词为空 → toast "提示词不能为空";未选择模型 → toast "请选择模型";任一输入为空 → 按钮置灰 | 按钮显示 loading 状态和"运行中…"文字;右侧结果区显示运行中状态 | + +#### 步骤 5:查看运行结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待运行完成 | — | 成功:显示绿色"运行成功"标签 + 分数(百分比)+ AI 反馈内容;失败:显示红色"运行失败"标签 + 错误信息 | 按钮恢复可用;右侧结果区更新为最终状态 | + +**前后置条件**: +- **前置条件**:表单名称和提示词已填写;至少选中一个 LLM 模型 +- **后置影响**:试运行结果为临时展示,不保存到数据库 +- **失败场景**:API 异常 → 红色"运行失败"标签 + 错误信息;超时/网络异常 → 显示"运行失败,请稍后重试" + +## Mermaid 附录 + +### S01:填写并创建评估维度 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建维度页 + participant API + participant Backend + + User->>UI: 从维度列表点击"创建维度" + UI->>UI: 渲染空表单 + + User->>UI: 填写名称(必填) + UI->>UI: 实时校验,名称非空后按钮可用 + + User->>UI: 填写提示词(必填) + UI->>UI: 实时校验,提示词非空后按钮可用 + + User->>UI: 点击"确认创建" + UI->>API: POST /core/evaluation/metric/create + API->>Backend: 创建评估维度记录 + Backend-->>API: 返回创建结果 + API-->>UI: 返回维度信息 + UI->>UI: Toast "维度创建成功" + UI->>User: 跳转至评测首页维度列表 +``` + +### S02:引用模板快速填充 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建维度页 + participant Modal as 引用模板弹窗 + + User->>UI: 点击"引用模板"按钮 + UI->>Modal: 打开弹窗,加载8种模板 + + User->>Modal: 选择维度(如"正确性") + Modal->>Modal: 切换模板内容 + + User->>Modal: 点击"确认" + Modal->>UI: 将模板内容填充到提示词输入框 + UI->>User: 提示词输入框更新 +``` + +### S03:试运行测试维度效果 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建维度页 + participant Modal as 试运行弹窗 + participant API + participant Backend + + User->>UI: 点击"试运行"按钮 + UI->>Modal: 打开试运行弹窗 + + User->>Modal: 输入问题、参考答案、实际回答 + User->>Modal: 选择评估模型 + User->>Modal: 点击"运行" + + Modal->>Modal: 校验输入完整性 + Modal->>API: POST /core/evaluation/metric/debug + API->>Backend: 执行维度评分 + Backend-->>API: 返回评分结果 + API-->>Modal: 返回分数+反馈 + + Modal->>User: 显示评分结果(分数+反馈) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..80553d23fc96 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,48 @@ +--- +capability_label: 创建维度 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 评测维度 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/dimension/create"] +--- + +# 创建维度 — 模块概览 + +## 模块业务概述 + +创建维度是评测系统的核心入口之一,面向需要进行 AI 输出质量评估的用户。用户在此页面定义评估维度(Metric),包括维度名称、描述和评估提示词(Prompt),用于后续在评测任务中对 AI 模型输出进行多维度打分。 + +该模块属于评测工作流的"维度定义"环节——先定义评价标准,再用标准去评估任务结果。系统内置了 8 种通用评估维度模板(正确性、简洁性、有害性、争议性、创造性、违法性、深度、细节),支持用户直接引用模板快速填充提示词,也可完全自定义提示词内容。提交前支持试运行(Debug),实时查看维度在实际模型上的评分效果。 + +本模块为叶子节点,无子能力。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| admin | 创建维度页 | 填写表单、引用模板、试运行、提交创建 | 完整访问权限 | +| team_member | 创建维度页 | 填写表单、引用模板、试运行、提交创建 | 与 admin 相同 | + +**权限模型**: 基于路由级别的访问控制,由 DashboardContainer 统一校验用户登录状态。页面通过 `getServerSideProps` 加载 `dashboard_evaluation` 命名空间的国际化资源,权限入口在 Dashboard 侧边栏的评测导航中控制。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台基础设施 | DashboardContainer 页面布局容器(侧边栏、背景装饰) | 组件 | +| 评测维度共享组件 | EditForm 编辑表单组件(名称/描述/提示词字段) | 组件 | +| 评测维度共享组件 | CitationTemplate 引用模板弹窗(8种内置模板) | 组件 | +| 评测维度共享组件 | TestRun 试运行弹窗(问题/答案输入+AI评测结果) | 组件 | +| 公共 API 层 | postCreateMetric / postDebugMetric 维度创建与调试接口 | API | + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 创建维度 | /dashboard/evaluation/dimension/create | projects/app/src/pages/dashboard/evaluation/dimension/create.tsx | admin, team_member | 无 Tab,单页表单 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..5410b31a0475 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,73 @@ +--- +capability_label: 评测维度 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: + - 团队管理员 + - 团队成员(有评测权限) +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 模块概览 + +## 1. 模块业务概述 + +评测维度模块是 FastGPT 评测体系中用于**定义评分标准**的功能区。在评测任务执行时,评测模型依据一个或多个评测维度对 AI 应用的回答进行打分,每个维度定义了一套评分提示词,指导评测模型从特定角度(如正确性、简洁性、创造性等)评估回答质量。 + +本模块面向拥有评测权限的团队成员,提供内置维度模板和自定义维度两种管理方式: +- **内置维度**:系统预置了 8 个常用评测维度(正确性、简洁性、危害性、争议性、创造性、违法性、深度、细节),每个维度带有标准化的评分提示词模板,用户可直接引用但不可编辑或删除。 +- **自定义维度**:用户可根据业务需求创建专属评测维度,自定义维度名称、描述和评分提示词,支持编辑、删除和试运行验证。 + +评测维度是评测任务的三要素之一(应用+数据集+维度),在创建评测任务时被关联引用。模块下含三个子能力: +- **评测维度首页**:作为评测首页的「维度」Tab 展示维度列表,支持搜索、创建入口和删除操作 +- **创建维度**:提供自定义维度的创建表单,含名称、描述、提示词三个字段,并提供内置维度模板引用和试运行功能 +- **编辑维度**:对已有自定义维度进行修改,复用创建维度的表单组件,支持更新和试运行 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 全部维度页面 | 查看内置和自定义维度、创建维度、编辑维度、删除维度、试运行 | 拥有评测权限,可执行所有操作 | +| 团队成员(有评测权限) | 全部维度页面 | 同管理员 | 权限模型基于 `hasEvaluationCreatePer` | +| 团队成员(无评测权限) | 不可见 | 无 | 侧边栏不展示「应用测评」菜单,无法进入评测模块 | + +**权限模型**: 评测维度模块继承评测模块的整体权限控制,受 `feConfigs.show_evaluation`(系统功能开关)和 `userInfo.team.permission.hasEvaluationCreatePer`(团队级权限)双重控制。内置维度对所有有权限用户只读,自定义维度的编辑和删除无额外独立权限位。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 评测任务 | 创建评测任务时引用自定义维度列表(ManageDimension 组件) | 组件 | +| 共享组件库 | MyIcon、MyBox、MyModal、MyTag、Avatar、MyIconButton 等基础 UI 组件 | 组件 | +| 共享 Hooks | useRequest(请求封装)、useConfirm(确认弹窗)、useToast(消息提示) | Hook | +| AI 模型选择 | AIModelSelector 组件用于试运行时的评测模型选择 | 组件 | +| 系统 Store | useSystemStore 获取 llmModelList(可用模型列表) | Store | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 维度列表数据 | 评测任务(创建评测任务表单中选择评测维度) | API/数据 | +| 内置维度模板 | 评测任务(通过维度 ID 引用内置维度的评分标准) | 数据 | +| 维度类型定义 | 评测任务(EvalMetricTypeEnum、MetricConfig 类型) | 类型 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测维度首页(维度列表) | /dashboard/evaluation?evaluationTab=dimensions | projects/app/src/pages/dashboard/evaluation/dimension/index.tsx | 有评测权限的成员 | 作为评测首页 Tab 渲染,接收父组件传入的 FillRowTabs | +| 创建维度 | /dashboard/evaluation/dimension/create | projects/app/src/pages/dashboard/evaluation/dimension/create.tsx | 有评测权限的成员 | 独立页面,从维度列表「创建」按钮进入 | +| 编辑维度 | /dashboard/evaluation/dimension/edit | projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx | 有评测权限的成员 | 独立页面,通过路由 query.id 获取维度数据 | + +## 6. 子能力索引 + +| 子能力 | 描述 | 文档链接 | +|--------|------|---------| +| 评测维度首页 | 维度列表展示页,区分内置和自定义维度,支持搜索和删除 | [评测维度首页](../评测维度/评测维度首页/模块概览.md) | +| 创建维度 | 自定义维度的创建表单,含名称/描述/提示词字段和试运行功能 | [创建维度](../评测维度/创建维度/模块概览.md) | +| 编辑维度 | 已有自定义维度的编辑页面,复用创建表单组件 | [编辑维度](../评测维度/编辑维度/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3c46d6bf6979 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,40 @@ +--- +capability_label: 编辑维度 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +# 编辑维度 — 业务流程索引 + +## 编辑评测维度 + +- **业务描述**: 用户从评测维度列表中进入编辑页面,系统加载已有维度数据(名称、描述、提示词),用户修改后点击保存,系统更新维度记录并跳转回评测首页的维度列表。 +- **触发入口**: 评测首页 → 评测维度 Tab → 点击自定义维度行的编辑按钮 +- **路由路径**: `/dashboard/evaluation/dimension/edit?id={dimensionId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` +- **涉及角色**: hasEvaluationCreatePer(有评测创建权限的用户) +- **前置条件**: 维度 ID 有效且数据加载成功;用户具有评测权限;待编辑维度为自定义维度(内置维度不可编辑) + +## 试运行评测维度 + +- **业务描述**: 在编辑表单中填写或修改完毕后,用户点击「试运行」按钮打开测试弹窗,输入测试问题、参考答案和实际回答,选择评测模型后运行,查看维度在当前配置下的评分结果和反馈,验证修改后的评测效果。 +- **触发入口**: 编辑维度页面 → 页面底部「试运行」按钮 +- **路由路径**: `/dashboard/evaluation/dimension/edit?id={dimensionId}`(同页面弹窗,不跳转路由) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` +- **涉及角色**: hasEvaluationCreatePer(有评测创建权限的用户) +- **前置条件**: 表单必填字段(名称、提示词)已填写有效,表单验证通过 + +## 场景关系 + +- **编辑评测维度** ← **试运行评测维度**:先编辑后试运行。试运行是编辑过程中的可选验证步骤,必须在进入编辑页面并完成表单填写后才能触发,用于确认修改后的维度评分效果符合预期。编辑与试运行在同一页面内完成,无需跳转。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 编辑评测维度 | 评测首页→评测维度Tab→编辑按钮 | /dashboard/evaluation/dimension/edit?id={dimensionId} | hasEvaluationCreatePer | +| S02 | 试运行评测维度 | 编辑维度页→试运行按钮 | /dashboard/evaluation/dimension/edit?id={dimensionId} | hasEvaluationCreatePer | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..a981e996a348 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,266 @@ +--- +capability_label: 编辑维度 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +# 编辑维度 — 业务流程详解 + +## 页面总览 + +编辑维度页面是一个单页表单,用户在此修改已有自定义评测维度的定义信息。页面通过路由 query 参数 `id` 加载维度详情并回显到 EditForm 中,支持修改名称、描述和提示词三个字段,辅助以模板引用和试运行功能。保存成功后跳转回评测首页维度列表。 + +### 页面状态流转 + +页面入口后存在三种状态: + +1. **加载中**:`isLoading || isFetching` 为 true 时,DashboardContainer 内显示 Loading 组件 +2. **数据不存在**:`dimensionData` 为 null 且加载已完成时,DashboardContainer 内居中展示"数据不存在"提示 +3. **正常编辑态**:数据加载成功后,DashboardContainer 内渲染返回按钮、EditForm 表单、试运行按钮和保存按钮 + +### 编辑评测维度 + +> 用户从评测维度首页进入编辑页面,修改已有维度的定义信息并保存。 + +#### 步骤 1:进入编辑维度页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从评测维度首页 `/dashboard/evaluation?evaluationTab=dimensions` 点击自定义维度条目 | 无 | 无 | 路由跳转至 `/dashboard/evaluation/dimension/edit?id={dimensionId}` | + +#### 步骤 2:加载维度数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面 mount 后 useEffect 自动触发 | `GET /core/evaluation/metric/detail`(query: metricId) | `dimensionId` 为空 → 不发起请求,终止加载 | DashboardContainer 渲染 Loading 组件(`Loading fixed={false}`) | +| — | — | API 成功 → `onSuccess` 回调设置 `dimensionData` | Loading 消失,表单回显维度名称、描述、提示词;试运行按钮和保存按钮根据表单验证状态决定是否可用 | +| — | — | API 失败 → `onError` 回调 toast "获取维度数据失败",执行 `router.push` 跳转 | 页面自动跳回 `/dashboard/evaluation?evaluationTab=dimensions` | +| — | — | `dimensionData` 为 null(数据加载完成但无有效数据) | DashboardContainer 内容区居中展示"数据不存在"文字提示 | + +#### 步骤 3:修改维度名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改"名称"输入框内容 | 无 | 实时校验:名称修改后为空时,`isFormValid` 变为 false;名称非空且提示词非空时变为 true | 试运行按钮和保存按钮随 `isFormValid` 状态同步置灰/可用;输入框预填原维度名称 | + +#### 步骤 4:修改维度描述(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改"描述"文本域内容 | 无 | 描述为可选字段,不影响表单有效性 | MyTextarea 3行高度文本域,预填原维度描述 | + +#### 步骤 5:修改评估提示词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改"提示词"文本域内容 | 无 | 实时校验:提示词修改后为空时,`isFormValid` 变为 false;提示词非空且名称非空时变为 true | MyTextarea 15行高度文本域,预填原维度提示词;可通过"引用模板"按钮打开 CitationTemplate 弹窗快速替换模板内容 | + +#### 步骤 6:保存更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"保存"按钮 | `PUT /core/evaluation/metric/update`(参数:metricId, name, description, prompt) | 表单无效(名称或提示词为空)时按钮置灰不可点击 | 按钮显示 loading 状态(`isUpdating`);API 成功 → toast 提示"更新成功",页面跳转至 `/dashboard/evaluation?evaluationTab=dimensions`;API 失败 → 按钮恢复可用,提示错误信息 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入(Input) | ✅ | 原维度名称 | 任意文本 | 否 | 维度唯一标识名称,`autoFocus` 自动聚焦 | +| 描述 | 多行文本(MyTextarea 3行) | — | 原维度描述 | 任意文本 | 否 | 维度用途说明 | +| 提示词 | 多行文本(MyTextarea 15行) | ✅ | 原维度提示词 | 任意文本,支持引用模板填充 | 否 | 用于 AI 评分的评估提示词 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称不能为空 | 实时(onChange) | 按钮置灰,无文字提示 | +| 提示词不能为空 | 实时(onChange) | 按钮置灰,无文字提示 | + +**前后置条件**: +- **前置条件**:用户已登录,具有评测模块访问权限(`hasEvaluationCreatePer`);路由携带有效的 `id` 参数;维度类型为自定义维度 +- **后置影响**:更新成功后在数据库更新该维度记录,页面跳转至评测首页维度列表,列表中该维度的名称、描述等字段同步更新 +- **失败场景**:维度 ID 不存在或 API 调用失败 → toast "获取维度数据失败",跳回维度列表;保存 API 失败 → 按钮恢复可用状态,提示错误信息 + +### 返回维度列表 + +> 用户在编辑过程中通过返回按钮离开当前页面。 + +#### 步骤 1:点击返回按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左上角"返回"按钮(带 common/backFill 图标) | 无 | 无 | 页面跳转至 `/dashboard/evaluation?evaluationTab=dimensions`,维度列表页渲染,编辑页表单修改内容丢失(前端状态不持久化) | + +### 引用模板快速填充 + +> 用户在提示词区域点击"引用模板"按钮,从内置维度模板中选择并替换提示词内容。 + +#### 步骤 1:打开模板弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击提示词文本域上方的"引用模板"按钮(variant: whiteBase, size: xs) | 无 | 无 | 弹出 MyModal 弹窗,标题"引用模板",图标 common/templateMarket(蓝色);左侧显示 8 种维度列表按钮(correctness, conciseness, harmfulness, controversiality, creativity, criminality, depth, detail),右侧显示默认选中维度(correctness)的模板内容 | + +#### 步骤 2:选择维度模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左侧维度列表中的某一项(如"正确性"/correctness) | 无 | 无 | 选中项高亮(white 背景 + `primary.500` 蓝色文字 + `myGray.200` 边框);右侧 Textarea 内容通过 `setValue('template', dimensionTemplates[dimension])` 切换为该维度对应的评分模板。模板内容根据当前语言(`i18n.language`)自动切换中/英文版本 | + +#### 步骤 3:编辑模板内容(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧 Textarea 中修改模板内容 | 无 | 无 | 文本域实时显示修改后的内容,支持大段文本编辑,minH=300px | + +#### 步骤 4:确认引用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮(variant: primary) | 无 | 模板内容为空时 react-hook-form required 校验阻止提交 | 弹窗关闭,EditForm 中通过 `setValue('prompt', template)` 将提示词输入框内容替换为所选模板内容,表单验证状态随之更新;点击"取消"则 `reset()` 表单后关闭弹窗,不保存修改 | + +#### 步骤 5:取消操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮(variant: whiteBase) | 无 | 无 | `reset()` 重置 react-hook-form 表单状态,关闭弹窗,EditForm 提示词内容不变 | + +**维度模板清单**: + +| 维度标识 | 中文显示名 | 评分目标 | +|---------|-----------|---------| +| correctness | 正确性 | 评估事实正确性和完整性 | +| conciseness | 简洁性 | 评估回复是否仅包含必要信息 | +| harmfulness | 有害性 | 评估回复是否含有害/冒犯性内容 | +| controversiality | 争议性 | 评估回复是否具有争议性或可辩论性 | +| creativity | 创造性 | 评估回复是否体现新颖性/独特想法 | +| criminality | 违法性 | 评估回复是否含有违法犯罪内容 | +| depth | 深度 | 评估回复中的思考深度 | +| detail | 细节 | 评估回复中对细节的关注程度 | + +### 试运行评测维度 + +> 用户点击"试运行"按钮,输入测试用例并调用 AI 模型运行评分,验证当前编辑维度的评分效果。 + +#### 步骤 1:打开试运行弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面底部的"试运行"按钮(variant: outline) | 无 | 表单无效(名称或提示词为空)时按钮 `isDisabled` 置灰不可点击 | `setIsTestRunOpen(true)`,渲染 TestRun 组件:MyModal 弹窗,标题"试运行",图标 common/paused。弹窗内 Grid 左右分栏(3:2):左侧为输入区,右侧为结果区。弹窗尺寸 maxW=['95vw','1080px'],h=['90vh','80vh'],居中显示。传入 `formData={name, description, prompt}` 当前编辑表单数据 | + +#### 步骤 2:输入测试问题 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在左侧"问题"区域输入测试用的问题文本 | 无 | 无 | Textarea 接受输入,minH=100px,占位提示"请输入问题",聚焦时边框变为 `primary.500` 色并附带阴影 | + +#### 步骤 3:输入参考答案 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 AnswerTextarea 组件中输入参考答案文本 | 无 | 无 | 可折叠展开的 textarea,默认折叠态 `maxH=50px` 带渐变遮罩;点击展开或聚焦后展开;自动高度调整(`scrollHeight + 5px`)。折叠时底部渐变遮罩可点击聚焦展开 | + +#### 步骤 4:输入实际回答 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 AnswerTextarea 组件中输入实际回答文本 | 无 | 无 | 与参考答案组件行为一致:可折叠、自动高度、渐变遮罩。折叠时点击遮罩区域可聚焦输入 | + +#### 步骤 5:选择评估模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧结果区顶部的 AIModelSelector 下拉框中选择用于评分的 LLM 模型 | 无(模型列表来自 `useSystemStore().llmModelList`) | 模型列表为空 → 选择器无选项;未选择模型 → 运行按钮 `isDisabled` | 默认选中 `llmModelList[0].id`;选择器宽度 180px,高度 32px,白色背景 | + +#### 步骤 6:启动试运行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"运行"按钮(colorScheme: blue, size: sm) | `POST /core/evaluation/metric/debug` | 提示词为空 → toast "提示词不能为空";未选择模型 → toast "请选择模型";问题/参考答案/实际回答任一为空 → 按钮 `isDisabled` | 按钮显示 loading 状态和"运行中…"文字;右侧结果区更新为运行中状态(空白区域) | +| — | POST 参数:`{ evalCase: { userInput, expectedOutput, actualOutput }, llmConfig: { modelId }, metricConfig: { metricName, metricType: 'custom_metric', prompt } }` | — | — | +| — | — | API 成功 → `setTestResult({ score, status: 'success', feedback })` | 右侧结果区显示:绿色 MyTag "运行成功"(带圆点,colorSchema: green,type: borderFill)+ 分数(百分比,如 85 %)+ 反馈内容(白色 pre-wrap 可滚动) | +| — | — | API 失败/catch error → `setTestResult({ score: 0, status: 'error', feedback })` | 右侧结果区显示:红色 MyTag "运行失败"(带圆点,colorSchema: red)+ "错误信息"标题(红色文字)+ 错误详情(红色/灰色 pre-wrap 可滚动) | + +#### 步骤 7:查看运行结果详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 观察右侧结果区 | — | 成功状态 | 结果区卡片样式:myGray.50 背景,myGray.200 边框,圆角 md。状态标签在上,分数在下,反馈内容占据剩余空间并可滚动。分数显示格式:`Number(score) * 100 {单位}` | +| 观察右侧结果区 | — | 失败状态 | 错误信息以红色 500 色显示在反馈区域内 | +| 观察右侧结果区 | — | 无反馈内容 | 显示 "无反馈内容" 或原始 reason/reason 的翻译文本 | + +**试运行交互详情**: + +| 交互元素 | 控件类型 | 位置 | 行为说明 | +|---------|---------|------|---------| +| 问题输入 | Textarea(resize=none) | 左侧输入区顶部 | minH=100px,聚焦时蓝色边框+蓝色阴影,无默认值 | +| 参考答案输入 | AnswerTextarea 组件 | 左侧输入区中部 | 可折叠展开;默认折叠态 maxH=50px 带底部渐变遮罩;点击遮罩/聚焦自动展开;展开/折叠切换按钮位于标签右侧(chevronDown/chevronUp 图标);自动高度调整 | +| 实际回答输入 | AnswerTextarea 组件 | 左侧输入区下部 | 行为同参考答案输入,结构一致 | +| 模型选择 | AIModelSelector | 右侧结果区顶部 | w=180px, h=32px, bg=white;数据源 `useSystemStore().llmModelList` | +| 运行按钮 | Button(sm, blue) | 模型选择器右侧 | isDisabled 条件:question/referenceAnswer/actualAnswer 任一为空 或 selectedModel 为空;isLoading 时显示"运行中…" | +| 状态标签 | MyTag(borderFill, showDot) | 右侧结果区内部 | 成功=green、失败=red、运行中=gray | +| 分数显示 | Text(sm, myGray.500) | 状态标签下方 | 仅成功状态显示,百分比格式 `score * 100` | +| 反馈内容 | Box(pre-wrap, scrollable) | 结果区最下方 | 展示 AI 返回的 reason 文本,失败时红色显示错误详情 | + +**前后置条件**: +- **前置条件**:表单名称和提示词已填写(非空);至少选中一个 LLM 模型;问题、参考答案、实际回答三个测试输入均已填写 +- **后置影响**:试运行结果为临时展示,不保存到数据库;关闭弹窗后所有输入数据和结果数据均丢失(组件卸载时状态清除) +- **失败场景**:API 异常 → 红色"运行失败"标签 + 错误信息;网络超时/异常 → 显示 `getErrText(error)` 或 "运行失败,请稍后重试" + +## Mermaid 附录 + +### S01:编辑评测维度 + +```mermaid +sequenceDiagram + participant User + participant UI as 编辑维度页 + participant API + participant Backend + + User->>UI: 从维度列表点击自定义维度 + UI->>API: GET /core/evaluation/metric/detail + API->>Backend: 查询维度详情 + Backend-->>API: 返回维度数据 + API-->>UI: 返回 name/description/prompt + UI->>UI: 表单回显维度数据,按钮根据验证状态启用 + + User->>UI: 修改名称/描述/提示词 + UI->>UI: 实时校验,isFormValid 同步更新 + + User->>UI: 点击"保存"按钮 + UI->>API: PUT /core/evaluation/metric/update + API->>Backend: 更新维度记录 + Backend-->>API: 返回更新结果 + API-->>UI: 返回成功 + UI->>UI: Toast "更新成功" + UI->>User: 跳转至评测首页维度列表 +``` + +### S02:试运行评测维度 + +```mermaid +sequenceDiagram + participant User + participant UI as 编辑维度页 + participant Modal as 试运行弹窗 + participant API + participant Backend + + User->>UI: 点击"试运行"按钮 + UI->>Modal: 打开试运行弹窗 + + User->>Modal: 输入问题、参考答案、实际回答 + User->>Modal: 选择评估模型 + User->>Modal: 点击"运行" + Modal->>Modal: 校验输入完整性 + Modal->>API: POST /core/evaluation/metric/debug + API->>Backend: 执行维度评分 + Backend-->>API: 返回评分结果 + API-->>Modal: 返回 score/reason + Modal->>User: 显示评分结果(分数+反馈) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..b2033230422e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: 编辑维度 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +## 说明 + +本模块未检测到对外共享能力(imported_by 模块数: 0,共享能力数: 0)。 + +EditForm 和 TestRun 组件在本父模块"评测维度"内部被 create.tsx 复用,属于模块内部共享,不构成跨模块共享。 + +跨模块依赖摘要请参见 [模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..c1ce61aa60fa --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,61 @@ +--- +capability_label: 编辑维度 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +# 编辑维度 — 模块概览 + +## 1. 模块业务概述 + +编辑维度是评测维度模块的子能力,面向已拥有评测权限的用户,用于**修改已有自定义评测维度**的定义信息。用户从评测维度首页点击自定义维度条目进入该页面,可修改维度名称、描述和评分提示词,提交后更新存储,并自动跳转回维度列表。 + +本模块与创建维度共享表单组件(EditForm 和 TestRun),核心流程为:根据 URL 参数 `id` 加载维度详情 -> 在 EditForm 中回显并允许编辑 -> 可试运行验证评分效果 -> 提交更新 -> 跳回维度列表。内置维度不可进入编辑页,仅自定义维度支持编辑操作。 + +本模块为叶子节点,无子能力。 + +主要业务规则: +- 通过路由 query 参数 `id` 获取维度 ID 进行数据加载 +- 加载维度数据失败或数据不存在时,展示错误提示并自动跳回维度列表 +- 表单验证未通过时,"保存"和"试运行"按钮均处于禁用状态 +- 保存成功后展示成功提示并自动跳转至 `/dashboard/evaluation?evaluationTab=dimensions` +- 页面加载中或维度数据为空时,均包裹在 DashboardContainer 中展示状态提示 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 有评测创建权限的用户(hasEvaluationCreatePer) | 编辑维度页面 | 编辑维度名称/描述/提示词、试运行、保存更新 | 可编辑自己或其他团队成员创建的自定义维度,无维度级别的所有者限制 | +| 无评测创建权限的用户 | 不可见 | 无 | 侧边栏「应用测评」菜单不可见,无法进入编辑页 | + +**权限模型**: 入口受 DashboardContainer 侧边栏双重控制——`feConfigs.show_evaluation`(系统功能开关)和 `userInfo.team.permission.hasEvaluationCreatePer`(团队级评测权限)。页面本身无额外独立权限校验。内置维度无法通过路由进入编辑(维度列表中对内置维度不提供编辑入口,但页面本身未对维度类型做后端校验)。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台基础设施 | DashboardContainer 页面布局容器(侧边栏、背景装饰) | 组件 | +| 评测维度共享组件 | EditForm 编辑表单组件(名称/描述/提示词字段,含表单验证) | 组件 | +| 评测维度共享组件 | TestRun 试运行弹窗(传入当前表单数据到弹窗中评测) | 组件 | +| 公共 API 层 | getMetricDetail(加载维度详情)、putUpdateMetric(提交维度更新) | API | +| 系统 Store | useSystemStore 获取 LLM 模型列表(供 TestRun 选择评测模型) | Store | + +## 4. 对外共享 + +本模块为叶子页面节点,不对外共享组件或数据。其消费的 EditForm 和 TestRun 由创建维度模块与本模块共同使用,共享源为 `projects/app/src/pageComponents/dashboard/evaluation/dimension/` 目录。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 编辑维度 | /dashboard/evaluation/dimension/edit | projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx | hasEvaluationCreatePer | 通过路由 query.id 指定维度,无 Tab,单页表单 | + +进入方式:从评测维度首页(`/dashboard/evaluation?evaluationTab=dimensions`)的自定义维度条目点击进入,路由携带维度 ID 参数。 + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..108b8ee3d990 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,74 @@ +--- +capability_label: "评测维度首页" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测维度" +roles: [] +router_paths: + - "/dashboard/evaluation?evaluationTab=dimensions" +--- + +# 评测维度首页 — 业务流程索引 + +## 查看维度列表 + +- **业务描述**:进入评测维度页面后,系统自动加载并展示当前团队下所有的评测维度(包含内置维度和自定义维度),以表格形式呈现维度名、介绍、创建/更新时间、创建人等字段。 +- **触发入口**:评测工作台 → 点击「评测维度」标签页 +- **路由路径**:`/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**:`projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**:全部可访问评测功能的用户 +- **前置条件**:用户已登录,评测功能开关 `show_evaluation` 为开启状态 + +## 搜索维度 + +- **业务描述**:在搜索框中输入关键词后,系统在前端对维度名称和介绍进行模糊匹配过滤,实时展示匹配结果。 +- **触发入口**:评测维度列表页 → 顶部搜索框 +- **路由路径**:`/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**:`projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**:全部可访问评测功能的用户 +- **前置条件**:维度列表已加载完成 + +## 创建自定义维度 + +- **业务描述**:用户点击「新建」按钮后,页面跳转至维度创建页面,填写维度名称、介绍和提示词后保存,生成一个新的自定义评测维度。 +- **触发入口**:评测维度列表页 → 顶部「新建」按钮 +- **路由路径**:`/dashboard/evaluation/dimension/create` +- **页面组件位置**:`projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` +- **涉及角色**:全部可访问评测功能的用户 +- **前置条件**:用户已登录 + +## 编辑自定义维度 + +- **业务描述**:用户点击自定义维度所在行后,页面跳转至维度编辑页面,可修改维度的名称、介绍和提示词并保存。 +- **触发入口**:评测维度列表页 → 点击自定义维度行 +- **路由路径**:`/dashboard/evaluation/dimension/edit?id={dimensionId}` +- **页面组件位置**:`projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` +- **涉及角色**:全部可访问评测功能的用户 +- **前置条件**:目标维度为自定义类型(非内置维度) + +## 删除自定义维度 + +- **业务描述**:用户点击自定义维度行末的删除图标,在弹出的确认对话框中确认后,系统调用后端接口删除该维度并刷新列表。 +- **触发入口**:评测维度列表页 → 自定义维度行末删除图标 +- **路由路径**:`/dashboard/evaluation?evaluationTab=dimensions`(当前页操作) +- **页面组件位置**:`projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**:全部可访问评测功能的用户 +- **前置条件**:目标维度为自定义类型,且通过删除确认 + +## 场景关系 + +- **查看维度列表** → **搜索维度**:搜索是列表的筛选操作,依赖列表数据已加载 +- **查看维度列表** → **编辑自定义维度**:编辑通过点击列表中的维度行触发 +- **查看维度列表** → **删除自定义维度**:删除通过点击列表中的删除图标触发 +- **创建自定义维度** → **查看维度列表**:创建成功后需手动返回列表查看新维度(当前为路由跳转,创建页面为独立路由) + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看维度列表 | 评测工作台 → 评测维度标签页 | `/dashboard/evaluation?evaluationTab=dimensions` | 全部 | +| S02 | 搜索维度 | 维度列表页 → 搜索框输入 | `/dashboard/evaluation?evaluationTab=dimensions` | 全部 | +| S03 | 创建自定义维度 | 维度列表页 → 「新建」按钮 | `/dashboard/evaluation/dimension/create` | 全部 | +| S04 | 编辑自定义维度 | 维度列表页 → 点击自定义维度行 | `/dashboard/evaluation/dimension/edit?id={id}` | 全部 | +| S05 | 删除自定义维度 | 维度列表页 → 自定义维度行末删除图标 | `/dashboard/evaluation?evaluationTab=dimensions` | 全部 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..44b3e0e0c406 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,228 @@ +--- +capability_label: "评测维度首页" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测维度" +roles: [] +router_paths: + - "/dashboard/evaluation?evaluationTab=dimensions" +--- + +# 评测维度首页 — 业务流程详解 + +## 页面总览 + +评测维度首页是评测工作台中维度管理的列表视图,页面由顶部的 Tab 导航栏(由父组件传入)、搜索框与新建按钮,以及下方的维度数据表格组成。表格展示所有评测维度(内置 + 自定义),每行显示维度名、介绍、创建/更新时间、创建人和操作按钮。 + +维度分为两种类型: +- **内置维度**:系统预置的 6 个评估维度(回答准确度、语义相似度、回答相关度、回答忠诚度、检索匹配度、检索精确度),名称和介绍通过国际化展示,带「内置」标签,行不可点击,无删除按钮。 +- **自定义维度**:用户创建的维度,行可点击进入编辑页,行末显示删除按钮。 + +页面加载时自动请求维度列表数据,使用 `MyBox` 组件包裹表格并提供加载状态(加载中显示遮罩)。数据为空时展示 `EmptyTip` 空状态提示。 + +--- + +## S01:查看维度列表 + +> 进入评测维度标签页后,系统自动加载当前团队的全部评测维度,以表格呈现。 + +### 步骤 1:页面加载 → 自动获取维度列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「评测维度」标签页或通过 URL 进入 `?evaluationTab=dimensions` | `POST /core/evaluation/metric/list`(参数:`{}`空对象) | 无分支,页面加载即调用 | 表格区域显示 `MyBox` 的加载遮罩(`isLoading: true`),表格处于加载中状态 | + +### 步骤 2:数据返回 → 处理并渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载 | 无(数据处理阶段,无额外 API) | — | 加载遮罩消失,表格渲染维度数据行。内置维度的名称和介绍替换为国际化文案(通过 `BUILTIN_DIMENSION_MAP` 映射),自定义维度显示原始名称和介绍 | + +### 步骤 3:列表渲染完成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览维度列表 | 无 | 数据为空 → 显示「暂无数据」空状态提示(`EmptyTip` 组件);数据非空 → 显示完整表格 | 表格 5 列:维度名、介绍、创建/更新时间、创建人、操作(空白列用于删除按钮)。内置维度行显示灰色「内置」标签,行悬停背景不变,光标为 `default`。自定义维度行悬停背景变为 `myGray.100`,光标为 `pointer`。创建/更新时间列对内置维度显示 `-` | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST `/core/evaluation/metric/list` | `{}`(空对象) | 内置维度名称通过 `getBuiltinDimensionNameFromId`(去除 `builtin_` 前缀)→ `getBuiltinDimensionInfo` 获取国际化 key → `t()` 翻译;自定义维度保持原始值 | 表格所有行 | +| 无数据 | 同一接口 | `{}` | 返回 `list` 为空数组 | `EmptyTip` 组件居中显示"暂无数据" | + +- **排序规则**:由后端返回数据顺序决定,前端不做排序 +- **筛选条件**:无服务端筛选参数,前端通过 S02 搜索场景做客户端过滤 + +--- + +## S02:搜索维度 + +> 在搜索框输入关键词,实时过滤表格中展示的维度。 + +### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在顶部搜索框中输入文字(如"准确") | 无(纯前端过滤) | `searchValue` 为空或仅含空格 → 显示全部维度;`searchValue` 非空 → 执行过滤 | 输入过程中无加载状态,实时过滤结果,不触发额外 API 请求 | + +### 步骤 2:过滤逻辑执行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 继续输入或修改搜索词 | 无 | 维度名或介绍的文本**不区分大小写**包含搜索词 → 保留;否则 → 从列表中移除 | 表格行数实时变化,匹配的行保留,不匹配的行消失。过滤结果为零时显示「暂无数据」空状态 | + +### 步骤 3:清空搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 清空搜索框内容(删除全部文字) | 无 | `searchValue` 变为空字符串 → 恢复显示全部维度 | 表格恢复显示全部维度 | + +--- + +## S03:创建自定义维度 + +> 点击「新建」按钮,路由跳转至维度创建页面。 + +### 步骤 1:点击新建按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部右侧「新建」按钮(带 + 图标) | 无 | 无分支,按钮始终可用 | 浏览器路由跳转至 `/dashboard/evaluation/dimension/create`,页面整体切换至维度创建表单页面 | + +> 详细的创建表单交互流程(表单字段、校验规则、试运行、提交流程)见 [创建维度](../创建维度/业务流程详解.md)。 + +--- + +## S04:编辑自定义维度 + +> 点击自定义维度所在表格行,路由跳转至维度编辑页面。 + +### 步骤 1:点击自定义维度行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个自定义维度所在表格行 | 无(路由跳转) | 目标维度类型为 `custom_metric`(Custom)→ 行可点击,跳转编辑页;目标维度类型为 `builtin_metric`(Builtin)→ 行不可点击,光标为 `default`,无反应 | 浏览器路由跳转至 `/dashboard/evaluation/dimension/edit?id={dimension._id}`,页面整体切换至维度编辑页面 | + +> 详细的编辑表单交互流程见 [编辑维度](../编辑维度/业务流程详解.md)。 + +--- + +## S05:删除自定义维度 + +> 点击自定义维度行末的删除图标,弹出确认对话框,确认后删除维度并刷新列表。 + +### 步骤 1:点击删除图标 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击自定义维度行末的删除图标(红色垃圾桶图标) | 无(弹窗阶段) | 约定制维度行末显示删除图标;内置维度行末空白,无删除图标 | `useConfirm` 弹出确认对话框,标题为删除确认样式 | + +### 步骤 2:确认删除弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 阅读弹窗内容「确认删除该维度?」 | 无 | — | 弹窗显示确认文案(i18n key: `confirm_delete_dimension`,中文:「确认删除该维度?」),底部有取消和确认两个按钮 | + +### 步骤 3:确认删除 → 调用删除 API + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | `DELETE /core/evaluation/metric/delete`(参数:`{ metricId: dimension._id }`) | — | 弹窗关闭,触发删除请求。成功时 Toast 显示「删除成功」,并自动重新调用 `POST /core/evaluation/metric/list` 刷新列表;失败时 Toast 显示「删除失败」,列表保持不变 | + +### 步骤 4:取消删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击取消按钮或关闭弹窗 | 无 | — | 弹窗关闭,不执行删除操作,列表保持不变 | + +#### 删除链路详情 + +- **引用检查**:前端未做引用检查(后端校验维度是否被评测任务引用) +- **确认弹窗**:`useConfirm({type: 'delete'})` 生成的删除确认弹窗,文案为「确认删除该维度?」 +- **批量与单条差异**:仅支持单条删除,无批量删除操作 +- **级联影响**:删除后列表自动刷新,已删除的维度从列表中移除 + +--- + +## Mermaid 附录 + +### S01:查看维度列表 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面 + participant API as 后端 API + + User->>UI: 进入评测维度标签页 + UI->>API: POST /core/evaluation/metric/list({}) + API-->>UI: 返回维度列表数据 + UI->>UI: 内置维度名称/介绍经 i18n 翻译替换 + UI-->>User: 渲染维度表格(5列) + opt 数据为空 + UI-->>User: 显示空状态提示 + end +``` + +### S02:搜索维度 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面 + + User->>UI: 在搜索框输入关键词 + UI->>UI: 前端过滤维度名和介绍(不区分大小写) + UI-->>User: 实时更新表格行 + opt 过滤结果为零 + UI-->>User: 显示空状态提示 + end + User->>UI: 清空搜索框 + UI-->>User: 恢复显示全部维度 +``` + +### S03:创建自定义维度 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面 + + User->>UI: 点击「新建」按钮 + UI-->>User: 路由跳转至创建维度页面 +``` + +### S04:编辑自定义维度 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面 + + User->>UI: 点击自定义维度行 + UI->>UI: 判断:type === Custom → 可点击 + UI-->>User: 路由跳转至编辑维度页面(携带 dimension._id) +``` + +### S05:删除自定义维度 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面 + participant API as 后端 API + + User->>UI: 点击行末删除图标 + UI-->>User: 弹出确认对话框(「确认删除该维度?」) + User->>UI: 点击确认 + UI->>API: DELETE /core/evaluation/metric/delete({metricId}) + API-->>UI: 删除结果 + alt 删除成功 + UI-->>User: Toast「删除成功」 + UI->>API: POST /core/evaluation/metric/list({})刷新列表 + else 删除失败 + UI-->>User: Toast「删除失败」 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..baca30634b7e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,61 @@ +--- +capability_label: "评测维度首页" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测维度" +roles: [] +router_paths: + - "/dashboard/evaluation?evaluationTab=dimensions" +--- + +# 评测维度首页 — 模块概览 + +## 1. 模块业务概述 + +评测维度首页是评测工作台中「评测维度」标签页的默认视图,为用户提供评测维度(Metric)的统一管理入口。评测维度是 AI 应用评测体系的核心组成部分,定义了评估模型输出的标准——包括回答准确度、语义相似度、回答相关度等多个评估方向。 + +本模块的主要使用者为需要进行 AI 应用评测的团队成员。模块提供了一个集中化的维度列表视图,支持浏览、搜索、新建和删除操作: + +- **内置维度**:系统预置了 6 个评估维度(回答准确度、语义相似度、回答相关度、回答忠诚度、检索匹配度、检索精确度),以国际化文案展示名称和描述,用户可查看但不可修改或删除。 +- **自定义维度**:用户可根据需要创建自定义的评估维度,支持编辑和删除操作。 + +本模块在整体产品中定位为评测体系的基础数据管理页面,为后续的评测任务提供可选的评测维度。 + +## 2. 角色差异 + +本模块未检测到页面级别的角色权限控制。页面可见性由父级评测工作台的 `show_evaluation` 功能开关控制(通过系统配置决定评测功能是否展示)。模块内部: + +- 所有可访问评测功能的用户均可查看维度列表、搜索维度 +- 所有可访问评测功能的用户均可创建自定义维度(通过「新建」按钮) +- 自定义维度的编辑和删除操作对所有可访问用户开放 +- 内置维度对所有用户只读(不可编辑、不可删除、不可点击进入详情) + +**权限模型**:基于功能开关 `feConfigs.show_evaluation` 的前端控制。未检测到角色级别的细粒度权限位。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 评测维度 API 层 | `getMetricList` 获取维度列表、`deleteMetric` 删除维度 | API | +| 评测维度工具函数 | `getBuiltinDimensionNameFromId`、`getBuiltinDimensionInfo` 解析内置维度信息 | 工具函数 | +| 全局评测度量类型 | `EvalMetricTypeEnum`(Custom/Builtin 类型枚举)、`EvalMetricDisplayType` 展示类型 | 类型定义 | +| 公共组件库 | `MyBox`、`MyIcon`、`MyIconButton`、`MyTag`、`Avatar`、`EmptyTip`、`FillRowTabs`(通过父组件传入 Tab) | 组件 | +| 公共 Hooks | `useRequest`(请求管理)、`useConfirm`(确认弹窗) | Hook | + +## 4. 对外共享 + +本模块的组件 `EvaluationDimensions` 通过 props 接收父组件传递的 `Tab` 导航栏,自身不对外提供共享能力。该组件仅被评测工作台父页面(`/dashboard/evaluation`)引用,作为「评测维度」标签页的内容区域。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测维度列表 | `/dashboard/evaluation?evaluationTab=dimensions` | `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` | 全部 | 评测工作台的「评测维度」标签页,含维度列表、搜索、新建入口,无子 Tab | + +## 6. 子能力索引 + +本模块无子能力(为 leaf 节点,对应能力树中「评测维度 / 评测维度首页」叶子节点)。兄弟能力节点包括: + +- [创建维度](../创建维度/模块概览.md) — 自定义维度的创建表单页面 +- [编辑维度](../编辑维度/模块概览.md) — 自定义维度的编辑表单页面 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..75647c30cd66 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,77 @@ +--- +capability_label: 评测首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: ["团队成员", "团队管理员"] +router_paths: ["/dashboard/evaluation"] +has_tab_children: true +--- + +# 评测首页 — 业务流程索引 + +> 本模块为评测中心的入口页面,通过 3 个 Tab 切换不同功能模块。各 Tab 的深度业务场景见对应子能力的 [业务流程索引]。 + +## 业务场景列表 + +### 进入评测中心 + +- **业务描述**: 用户从工作台侧边栏点击"应用测评"进入评测中心。页面加载后首先校验评测功能可见性(`feConfigs.show_evaluation`),不可见时自动跳回工作台。默认展示"评测任务"Tab,顶部通过 FillRowTabs 组件提供 3 个 Tab 供用户切换。Tab 切换通过 URL query 参数 `evaluationTab` 驱动(可选值:`tasks`、`datasets`、`dimensions`),支持直接通过 URL 进入指定 Tab。 +- **触发入口**: 工作台侧边栏 → 应用测评 +- **路由路径**: `/dashboard/evaluation` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/index.tsx` +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 用户已登录并有团队权限;评测功能可见(`feConfigs.show_evaluation !== false`) + +### 切换到评测任务 Tab + +- **业务描述**: 用户点击"评测任务"Tab,页面向下传递 FillRowTabs 组件作为顶部导航(通过 `Tab` prop),渲染评测任务列表组件。在该 Tab 下可查看任务列表、搜索筛选任务、创建评测任务、查看任务详情等。 +- **触发入口**: 评测首页 → 点击"评测任务" Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=tasks` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 已进入评测首页 + +### 切换到评测数据集 Tab + +- **业务描述**: 用户点击"评测数据集"Tab,页面向下传递 FillRowTabs 组件作为顶部导航,渲染评测数据集列表组件。在该 Tab 下可查看数据集列表、搜索数据集、智能生成或文件导入创建数据集、查看数据集详情等。 +- **触发入口**: 评测首页 → 点击"评测数据集" Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 已进入评测首页 + +### 切换到评测维度 Tab + +- **业务描述**: 用户点击"评测维度"Tab,页面向下传递 FillRowTabs 组件作为顶部导航,渲染评测维度列表组件。在该 Tab 下可查看内置维度与自定义维度列表、创建自定义维度、编辑维度配置等。 +- **触发入口**: 评测首页 → 点击"评测维度" Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**: 团队成员、团队管理员 +- **前置条件**: 已进入评测首页 + +## 场景关系说明 + +- **进入评测中心**(S01)是所有业务场景的入口,页面加载时先完成权限校验再渲染 Tab 导航 +- **S01-1(评测任务)**、**S01-2(评测数据集)**、**S01-3(评测维度)** 三个 Tab 场景互相独立、彼此无依赖,用户可自由切换 +- Tab 切换通过 URL query 参数 `evaluationTab` 驱动,URL 变更后触发条件渲染,同一时刻仅展示一个 Tab 对应的子组件 +- 每个 Tab 子组件共享同一个 FillRowTabs 导航(通过 `Tab` prop 传递),确保导航栏在各 Tab 下保持一致 +- 典型评测工作流为:准备数据集(S01-2)→ 定义评测维度(S01-3)→ 创建评测任务(S01-1 内触发),但 Tab 间无强制先后顺序 + +## 业务场景汇总 + +| 编号 | 场景 | Tab/子模块 | 触发入口 | 路由路径 | 角色 | +|------|------|-----------|---------|---------|------| +| S01 | 进入评测中心 | — | 工作台侧边栏 → 应用测评 | `/dashboard/evaluation` | 团队成员、团队管理员 | +| S01-1 |  ├ 评测任务 | 评测任务 | 评测首页 → 默认 Tab 或点击"评测任务" | `/dashboard/evaluation?evaluationTab=tasks` | 团队成员、团队管理员 | +| S01-2 |  ├ 评测数据集 | 评测数据集 | 评测首页 → 点击"评测数据集" Tab | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员、团队管理员 | +| S01-3 |  ├ 评测维度 | 评测维度 | 评测首页 → 点击"评测维度" Tab | `/dashboard/evaluation?evaluationTab=dimensions` | 团队成员、团队管理员 | + +## Tab 子能力索引 + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| 评测任务 | 查看和管理评测任务列表,创建评测任务,查看任务详情与执行结果 | [评测任务首页 — 业务流程索引](../评测任务/评测任务首页/业务流程索引.md) | +| 评测数据集 | 创建和管理评测数据集,支持智能生成和文件导入两种创建方式 | [评测数据集首页 — 业务流程索引](../评测数据集/评测数据集首页/业务流程索引.md) | +| 评测维度 | 查看内置与自定义评测维度,创建和编辑自定义维度配置 | [评测维度首页 — 业务流程索引](../评测维度/评测维度首页/业务流程索引.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..39f56ef53e9d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,79 @@ +--- +capability_label: 评测首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: ["所有登录用户"] +router_paths: ["/dashboard/evaluation"] +--- + +# 评测首页 — 业务流程详解 + +## 公共业务流程 + +页面级公共功能(初始化、权限校验、Tab 切换) + +### 步骤 1:页面初始化与权限校验 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过侧边栏菜单「应用测评」导航进入评测中心 | 无(客户端路由跳转至 `/dashboard/evaluation`) | `feConfigs.show_evaluation === false` 时执行 `router.replace('/dashboard')` 重定向至工作台首页 | 显示 DashboardContainer 布局框架,默认激活「评测任务」Tab | + +**权限模型说明**: +- 侧边栏菜单入口受双重控制:`feConfigs.show_evaluation`(系统级功能开关)和 `userInfo.team.permission.hasEvaluationCreatePer`(团队级权限) +- 页面级守卫在组件 `useEffect` 中检查 `feConfigs.show_evaluation`,若为 `false` 则重定向至 `/dashboard` +- 服务端渲染时通过 `getServerSideProps` 加载 7 个 i18n 命名空间:`dashboard_evaluation`、`evaluation`、`dataset`、`app`、`common`、`account`、`chat`、`file` + +### 步骤 2:Tab 切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面首次加载 | 无(从 `router.query.evaluationTab` 读取,默认值 `'tasks'`) | 默认激活「评测任务」Tab | 渲染 `` 组件 | +| 点击「评测任务」Tab | 无(`router.replace` 更新 query 中 `evaluationTab` 为 `'tasks'`) | `evaluationTab === 'tasks'` | 条件渲染 `` 组件 | +| 点击「评测数据集」Tab | 无(`router.replace` 更新 query 中 `evaluationTab` 为 `'datasets'`) | `evaluationTab === 'datasets'` | 条件渲染 `` 组件 | +| 点击「评测维度」Tab | 无(`router.replace` 更新 query 中 `evaluationTab` 为 `'dimensions'`) | `evaluationTab === 'dimensions'` | 条件渲染 `` 组件 | + +**Tab 切换机制说明**: +- 使用 `FillRowTabs` 组件渲染三个 Tab 按钮,`py={1}` 设置纵向内边距 +- Tab 切换通过 `router.replace({ query: { ...router.query, evaluationTab: e } })` 更新 URL 查询参数,保留其他已有 query 参数 +- 各子 Tab 组件通过条件渲染(`{evaluationTab === 'xxx' && }`)实现按需挂载 +- Tab 组件按钮通过 `Tab` prop 传递给子组件,子组件可在其页面顶部自行渲染 Tab 导航条 + +### 步骤 3:布局渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面渲染 | 无 | 始终执行 | `` 包裹整体布局,以 render props 模式传入 `MenuIcon`;内部使用 `Flex` 纵向布局,`h="full"` `p={6}` 全高内边距,子区域 `gap={4}` 间距 | + +## Tab 子能力索引 + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| 评测任务 | 创建和管理 AI Agent 评测任务,查看评测进度和结果 | [评测任务/业务流程详解](../评测任务/业务流程详解.md) | +| 评测数据集 | 管理评测数据集,支持智能生成和文件导入 | [评测数据集/业务流程详解](../评测数据集/业务流程详解.md) | +| 评测维度 | 管理自定义和内置评测维度(评分标准) | [评测维度/业务流程详解](../评测维度/业务流程详解.md) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 评测首页 + participant Router as Next.js Router + participant System as 系统配置 + + User->>UI: 进入评测中心 /dashboard/evaluation + UI->>System: 检查 feConfigs.show_evaluation + alt show_evaluation === false + UI->>Router: replace('/dashboard') + Router-->>User: 显示工作台首页 + else show_evaluation === true + UI-->>User: 显示评测首页,默认激活"评测任务"Tab + User->>UI: 点击切换 Tab + UI->>Router: replace query evaluationTab + Router-->>UI: 更新 URL,触发条件渲染 + UI-->>User: 渲染对应 Tab 内容 + end + Note over UI: 各 Tab 详细流程见子文档 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f572985459dc --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: 评测首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: 评测 +roles: ["所有登录用户"] +router_paths: ["/dashboard/evaluation"] +entry_files: ["projects/app/src/pages/dashboard/evaluation/index.tsx"] +--- + +# 评测首页 — 模块概览 + +## 1. 模块业务概述 + +评测首页是 FastGPT 评测系统的统一入口页面,路由为 `/dashboard/evaluation`。页面以三个 Tab 面板聚合展示评测相关的三大核心能力:评测任务管理、评测数据集管理和评测维度配置。用户在此页面可以快速切换查看不同评测资源的列表视图,无需跳转即可概览评测系统的全貌。 + +本模块是"工作台 > 评测"下的子能力,采用单页面多 Tab 架构设计。页面通过 Next.js 路由的 `evaluationTab` query 参数驱动 Tab 切换,使用 `router.replace` 实现无刷新 Tab 切换并保持浏览器历史记录。模块受双重权限控制:系统级功能开关 `feConfigs.show_evaluation` 控制页面整体可见性(false 时自动重定向至 `/dashboard`),团队级权限 `hasEvaluationCreatePer` 控制侧边栏菜单入口的展示。 + +`has_tab_children: true`,本模块已被拆分为 3 个 Tab 子能力:评测任务、评测数据集、评测维度。本文档及 09/10 仅覆盖入口公共功能(页面布局、Tab 导航、权限守卫),各 Tab 的深度内容见对应子文档。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有登录用户 | 评测首页(feConfigs.show_evaluation 为 true 时) | 查看评测任务列表、数据集列表、维度列表 | 页面级入口可见性由系统开关控制;各 Tab 内具体操作权限(创建、删除等)由团队权限位控制,详见各子能力文档 | + +**权限模型**:页面入口采用系统配置守卫模式。组件挂载时通过 `useEffect` 检查 `feConfigs.show_evaluation`,若为 `false` 则自动 `router.replace('/dashboard')` 重定向。侧边栏菜单入口由 `DashboardContainer` 根据 `feConfigs.show_evaluation` 和用户权限位联合控制渲染。页面内部各 Tab 的操作权限由各自子组件独立校验,不在此入口层处理。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| DashboardContainer | 统一页面布局容器(含侧边栏导航),通过 render props 模式注入 MenuIcon | 布局组件 | +| FillRowTabs (@fastgpt/web) | Tab 切换标签栏组件,提供类型安全的 Tab 列表渲染和切换回调 | 组件 | +| useSystemStore (@/web/common/system) | 获取 feConfigs 系统配置,用于 show_evaluation 权限守卫 | Store | +| Next.js Router | useRouter hook 用于读写 evaluationTab query 参数、页面重定向 | Hook | +| i18n 国际化 (next-i18next) | 服务端翻译加载(getServerSideProps),覆盖 8 个 namespace:dashboard_evaluation、evaluation、dataset、app、common、account、chat、file | 工具 | + +## 4. 对外共享摘要 + +本模块为纯入口聚合页,不对外提供可复用的共享能力。组件未通过 `export` 暴露任何 Context 或 Hook,仅被父级路由框架引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测首页 | /dashboard/evaluation | projects/app/src/pages/dashboard/evaluation/index.tsx | 所有登录用户 | 含 3 个 Tab(详见 [09-业务流程索引](./业务流程索引.md))。URL query 参数 evaluationTab 取值:tasks / datasets / dimensions | + +## 6. 子能力索引 + +| 子能力 | 描述 | 文档链接 | +|--------|------|---------| +| 评测任务 | 查看和管理评测任务列表,支持创建、重命名、删除、重试失败项 | [评测任务首页](../评测任务/评测任务首页/模块概览.md) | +| 评测数据集 | 管理评测用的测试数据集列表,支持智能生成和文件导入 | [评测数据集首页](../评测数据集/评测数据集首页/模块概览.md) | +| 评测维度 | 管理评测评分维度列表,含内置模板和自定义维度 | [评测维度首页](../评测维度/评测维度首页/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5603f958a23e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,104 @@ +--- +capability_label: 评测任务 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — 业务流程索引 + +## 任务列表页 + +- **业务描述**: 管理员查看和管理所有评测任务,支持创建新任务、搜索、按应用筛选,以及单个任务的重命名、删除、重试失败数据等操作 +- **触发入口**: 评测首页 → 评测任务 Tab(默认激活) +- **路由路径**: `/dashboard/evaluation?evaluationTab=tasks` +- **页面组件位置**: `src/pages/dashboard/evaluation/task/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 已登录,`feConfigs.show_evaluation` 为 true(否则重定向到工作台) + +### 创建评测任务 + +- **业务描述**: 创建新的评测任务,选择目标应用和版本、评测数据集和评测维度,配置评测模型和向量模型 +- **触发入口**: 任务列表页右上角「创建新任务」按钮 +- **路由路径**: 无独立路由,以弹窗形式在列表页内打开 +- **页面组件位置**: `src/pageComponents/dashboard/evaluation/task/CreateModal.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 已登录,有可选的 AI 应用和评测数据集 + +### 查看任务详情 + +- **业务描述**: 进入评测任务详情页,查看任务的基本信息、进度统计、各维度得分和评测总结,浏览逐条评测数据并进行编辑、删除、重试、导出等操作 +- **触发入口**: 任务列表 → 点击任务行 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={id}` +- **页面组件位置**: `src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 任务已创建 + +### 编辑评测数据 + +- **业务描述**: 在任务详情页编辑单条评测数据的问题和期望回答,可同步修改数据集 +- **触发入口**: 详情页 → 数据列表 → 选中数据 → 点击编辑按钮 +- **路由路径**: `/dashboard/evaluation/task/detail?taskId={id}`(详情页内操作) +- **页面组件位置**: `src/pages/dashboard/evaluation/task/detail/index.tsx`(内联编辑) +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 任务详情已加载,选中的数据项存在 + +### 配置评分参数 + +- **业务描述**: 配置评测任务的分数聚合方式(均值/中位数)、各维度的判断阈值和综合评分权重 +- **触发入口**: 详情页 → 右侧面板 → 设置按钮 +- **路由路径**: 无独立路由,弹窗形式 +- **页面组件位置**: `src/pageComponents/dashboard/evaluation/task/detail/ConfigParams.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 任务详情已加载,总结数据可用 + +### 导出评测数据 + +- **业务描述**: 将评测数据导出为 CSV 文件,包含用户输入、期望输出、实际输出、各维度得分等字段 +- **触发入口**: 详情页 → 顶部导航栏 → 导出按钮 +- **路由路径**: 调用 `/api/core/evaluation/task/item/export` API +- **页面组件位置**: 操作在 `src/pages/dashboard/evaluation/task/detail/index.tsx` 中触发 +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 任务详情已加载,有可导出的数据 + +### 重试失败数据 + +- **业务描述**: 重试评测任务中所有状态为失败的数据项 +- **触发入口**: 详情页 → 错误数据标签页 → 重试按钮 +- **路由路径**: 调用 `/core/evaluation/task/retryFailed` API +- **页面组件位置**: `src/pages/dashboard/evaluation/task/detail/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 任务存在错误状态的数据项 + +## 场景关系 + +- **任务列表 → 创建任务**: 从列表页发起新任务创建 +- **任务列表 → 任务详情**: 从列表页点击任务行进入详情 +- **任务详情 → 编辑数据**: 在详情页选中数据后编辑 +- **任务详情 → 配置参数**: 在详情页打开评分参数配置 +- **任务详情 → 导出数据**: 在详情页导出当前筛选条件下的数据 +- **创建任务 → 任务列表**: 创建成功后自动刷新列表 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 查看评测任务列表 | — | 评测首页→评测任务Tab | /dashboard/evaluation?evaluationTab=tasks | 管理员、团队成员 | +| S01-1a |  按应用筛选 | — | 列表页→应用筛选下拉框 | /dashboard/evaluation?evaluationTab=tasks | 管理员、团队成员 | +| S01-1b |  关键字搜索 | — | 列表页→搜索框输入 | /dashboard/evaluation?evaluationTab=tasks | 管理员、团队成员 | +| S02 | 创建评测任务 | — | 列表页→创建新任务按钮 | /dashboard/evaluation?evaluationTab=tasks(弹窗) | 管理员、团队成员 | +| S03 | 重命名评测任务 | — | 列表页→任务操作菜单→重命名 | /dashboard/evaluation?evaluationTab=tasks(弹窗) | 管理员、团队成员 | +| S04 | 删除评测任务 | — | 列表页→任务操作菜单→删除 | /dashboard/evaluation?evaluationTab=tasks(确认弹窗) | 管理员、团队成员 | +| S05 | 重试任务失败数据 | — | 列表页→任务操作菜单→重试错误数据 | /dashboard/evaluation?evaluationTab=tasks | 管理员、团队成员 | +| S06 | 查看评测任务详情 | — | 列表页→点击任务行 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S07 | 查看评测数据详情 | — | 详情页→数据列表→选中数据项 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S08 | 编辑评测数据 | — | 详情页→选中数据→编辑按钮 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S09 | 删除评测数据 | — | 详情页→选中数据→删除按钮 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S10 | 重试单条评测数据 | — | 详情页→选中数据→刷新按钮 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S11 | 配置评分参数 | — | 详情页→直接评分设置 | /dashboard/evaluation/task/detail?taskId={id}(弹窗) | 管理员、团队成员 | +| S12 | 导出评测数据 | — | 详情页→导出按钮 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S13 | 生成总结报告 | — | 详情页→刷新评分按钮 | /dashboard/evaluation/task/detail?taskId={id} | 管理员、团队成员 | +| S14 | 查看完整AI响应 | — | 详情页→查看完整响应按钮 | /dashboard/evaluation/task/detail?taskId={id}(弹窗) | 管理员、团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..63a6f4e68848 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,458 @@ +--- +capability_label: 评测任务 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — 业务流程详解 + +## 页面总览 + +评测任务模块是 AI 应用质量评估的核心入口。管理员在此创建评测任务,选择目标应用和维度,系统自动对评测数据进行多维度评分。任务完成后可通过详情页查看逐条数据得分和 AI 生成的评测总结报告。本模块包含任务列表页和任务详情页,无 Tab 嵌套结构。 + +--- + +### 查看评测任务列表 + +> 进入评测页面默认展示评测任务 Tab,以表格形式列出所有已创建的评测任务,支持按应用筛选和关键字搜索。 + +#### 步骤 1:进入评测任务列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左侧导航「评测」菜单,或直接访问 `/dashboard/evaluation` | 无显示加载 | `feConfigs.show_evaluation === false` 时自动重定向到 `/dashboard` | 评测页面加载,默认激活评测任务 Tab,表格显示骨架屏加载态 | + +#### 步骤 2:任务列表加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载任务列表 | `POST /core/evaluation/task/list` 参数:`pageNum=1, pageSize=10` | 搜索关键字为空时传空字符串;appFilter 为空时传 undefined | 表格渲染 10 条任务数据,底部分页器显示总页数;若无数据显示空状态提示 | + +#### 步骤 3:按应用筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击应用筛选下拉框,选择某个应用 | `POST /core/evaluation/task/list` 参数:`appId=选中应用ID` | 选择「全部应用」时 `appId` 传空字符串 | 表格重新加载,仅显示该应用的评测任务 | + +#### 步骤 4:关键字搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键字(实时输入,输入框立即更新) | `POST /core/evaluation/task/list`(防抖 500ms 后触发)参数:`searchKey=输入值` | 无 | 500ms 延迟后触发列表刷新,表格更新为匹配任务名称、描述或版本 ID 的结果 | + +#### 步骤 5:分页切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击底部分页器的页码或翻页按钮 | `POST /core/evaluation/task/list` 参数:`pageNum=N, pageSize=10` | 无 | 表格切换至第 N 页数据 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `POST /core/evaluation/task/list` | `pageNum=1, pageSize=10` | 后端聚合查询关联应用和版本信息,前端实时计算各维度得分 | 表格前 10 条 | +| 翻页 | `POST /core/evaluation/task/list` | `pageNum=N, pageSize=10` | 同上 | 表格第 N 页 | +| 筛选/搜索后加载 | `POST /core/evaluation/task/list` | `searchKey/appId + pageNum=1` | 重置到第一页 | 表格首屏 | + +- **分页参数**:默认每页 10 条 +- **默认排序**:按创建时间倒序(最新任务在前) +- **筛选条件**:应用筛选下拉框(含全部应用选项)、搜索框(按任务名称/描述/版本 ID 模糊匹配) + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 任务列表页 + participant API as 后端API + + User->>UI: 进入评测页面 + UI->>API: POST /core/evaluation/task/list (pageNum=1, pageSize=10) + API-->>UI: 任务列表 + 总数 + UI-->>User: 渲染任务表格 + + User->>UI: 输入搜索关键字 + UI->>UI: 防抖 500ms 等待 + UI->>API: POST /core/evaluation/task/list (searchKey=关键字) + API-->>UI: 过滤后的任务列表 + UI-->>User: 更新表格数据 + + User->>UI: 选择应用筛选 + UI->>API: POST /core/evaluation/task/list (appId=选中应用) + API-->>UI: 该应用的任务列表 + UI-->>User: 更新表格数据 +``` + +--- + +### 创建评测任务 + +> 用户点击「创建新任务」按钮,在弹出的表单中填写任务名称、选择目标应用和版本、选择评测数据集和评测维度后提交创建。 + +#### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右上角「创建新任务」按钮 | 无 | 无 | 居中弹窗打开,显示空白表单 | + +#### 步骤 2:选择目标应用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「评测应用」下拉框中选择一个 AI 应用 | 无(前端组件内部获取应用列表) | 只能选择不含交互节点(hasInteractiveNode=false)的应用 | 应用选中后,版本下拉框和数据集下拉框可操作 | + +#### 步骤 3:选择应用版本 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「应用版本」下拉框中选择版本 | 选择后自动调用 `getAppVersionDetail` 获取版本节点信息 | 自动选中最新版本 | 根据版本工作流中是否包含知识库搜索节点和对话节点,自动推荐评测维度 | + +#### 步骤 4:选择评测数据集 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「评测数据集」下拉框中选择已有数据集 | `GET` 获取数据集列表 | 可点击「创建/导入数据集」按钮通过智能生成或文件导入创建新数据集 | 数据集选中;若该应用之前有评测记录,自动填入最近使用的数据集 | + +#### 步骤 5:管理评测维度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 展开评测维度折叠区,点击「管理维度」 | 无(打开维度管理弹窗) | 无 | 弹窗列出可用维度(内置维度 + 自定义维度),可勾选/取消勾选 | + +#### 步骤 6:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认」按钮 | `POST /core/evaluation/task/create` 参数:`{name, evalDatasetCollectionId, target: {type, config}, evaluators: [...]}` | 若维度缺少必需的评测模型或向量模型,弹窗告警并阻止提交;应用、数据集、维度任一未选中时确认按钮置灰 | 提交成功后弹窗关闭,任务列表自动刷新 | + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 任务名 | 文本输入 | 是 | — | 最多 100 个字符 | 否 | 评测任务的名称 | +| 评测应用 | 下拉选择(AppSelect) | 是 | — | 不含交互节点的普通应用/工作流应用/对话应用/助手应用 | 否 | 被评测的目标 AI 应用 | +| 应用版本 | 下拉选择(滚动分页) | 是 | 自动选最新版本 | 该应用的所有发布版本 | 否 | 评测针对的具体应用版本 | +| 评测数据集 | 下拉选择 | 是 | 自动选最近使用的数据集 | 已创建的评测数据集 | 否 | 包含评测问题和期望回答的数据集 | +| 评测维度 | 多选(维度管理弹窗) | 是 | 根据应用节点自动推荐 | 内置维度 + 自定义维度 | 否 | 评估的维度指标(如答案正确性、忠实度等) | + +#### 字段联动 + +- 选择应用后:版本和数据集下拉框变为可用,自动加载版本列表 +- 选择版本后:自动分析该版本工作流中的节点类型(知识库搜索/对话节点),根据分析结果自动推荐评测维度 +- 维度配置:每个维度可单独配置评测模型(LLM)和向量模型(Embedding),若维度要求但未配置则阻止提交 + +#### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 任务名不能为空 | 提交时 | 聚焦到任务名输入框 | +| 评测应用必须选择 | 提交时 | 确认按钮置灰不可点击 | +| 评测数据集必须选择 | 提交时 | 确认按钮置灰不可点击 | +| 至少选择一个评测维度 | 提交时 | 确认按钮置灰不可点击 | +| 维度的必需模型必须配置 | 提交时 | "评测维度配置不完整,{维度名}缺少模型配置" | + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建弹窗 + participant API as 后端API + + User->>UI: 点击「创建新任务」 + UI-->>User: 显示创建表单 + + User->>UI: 选择应用 + UI->>UI: 加载版本列表 + UI-->>User: 显示版本下拉框 + + User->>UI: 选择版本 + UI->>API: GET 版本详情(节点信息) + API-->>UI: 版本节点数据 + UI->>UI: 分析节点类型,自动推荐维度 + UI-->>User: 填入推荐维度 + + User->>UI: 选择数据集,配置维度 + User->>UI: 点击确认 + UI->>API: POST /core/evaluation/task/create + API-->>UI: 创建成功 + UI->>API: POST /core/evaluation/task/list(刷新列表) + UI-->>User: 弹窗关闭,列表更新 +``` + +--- + +### 重命名评测任务 + +> 通过任务列表操作菜单修改已有评测任务的名称。 + +#### 步骤 1:打开重命名弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行右侧操作菜单(三点图标),选择「重命名」 | 无 | 无 | 弹窗打开,显示当前任务名称,输入框自动聚焦 | + +#### 步骤 2:提交新名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入新名称后点击确认 | `PUT /core/evaluation/task/update` 参数:`{evalId, name}` | 无 | 弹窗关闭,列表刷新显示新名称;成功提示"更新成功" | + +--- + +### 删除评测任务 + +> 从任务列表删除评测任务及其关联的所有评测数据项。 + +#### 步骤 1:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行操作菜单 →「删除」 | 无 | 无 | 确认弹窗打开,显示删除确认文案 | + +#### 步骤 2:执行删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认删除 | `DELETE /core/evaluation/task/delete` 参数:`{evalId}` | 无 | 弹窗关闭,列表刷新;成功提示"删除成功" | + +#### 删除链路详情 + +- **确认弹窗**:弹窗含有确认删除的提示文案,确认按钮执行删除,取消按钮关闭弹窗 +- **级联影响**:删除任务会同时清除任务下所有评测数据项(evalItems)和总结报告数据,并从任务队列中移除相关作业 + +--- + +### 重试任务失败数据(列表页) + +> 当任务存在错误状态的数据项时,可从列表页操作菜单直接重试所有失败项。 + +#### 步骤 1:触发重试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务行操作菜单 →「重试错误数据」(仅当 `statistics.error > 0` 时显示此项) | `POST /core/evaluation/task/retryFailed` 参数:`{evalId}` | 无错误数据时菜单不显示此项 | 列表刷新,更新任务状态和进度 | + +--- + +### 查看评测任务详情 + +> 点击任务列表中的任务行,进入任务详情页查看完整的评测结果。 + +#### 步骤 1:进入详情页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击任务列表中的任务行 | 路由跳转:`router.push({pathname: '/dashboard/evaluation/task/detail', query: {taskId}})` | 无 | 页面导航到任务详情页 | + +#### 步骤 2:加载任务数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动触发加载 | 并行:`GET` stats、detail;然后 `GET` summary | 加载失败时显示错误提示并返回列表页 | 左侧数据面板显示数据列表,右侧面板显示评分仪表盘和总结卡片 | + +#### 步骤 3:自动轮询刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面停留期间自动触发 | 每 15 秒并行轮询:`GET` stats + detail + summary;同时重新加载数据列表(仅加载到当前查看位置) | 任务状态为排队中/评测中时才需要轮询;任务完成/错误时继续轮询以获取最新数据 | 静默更新数据,不显示加载遮罩 | + +#### 步骤 4:数据列表导航 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击数据列表行 | 无(本地状态切换) | 无 | 选中行高亮,右侧面板更新为该项的详细信息 | + +#### 数据加载详情(数据列表) + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `POST /core/evaluation/task/item/list` | `evalId, offset=0, pageSize=20` | 按默认排序 | 前 20 条数据 | +| 滚动加载更多 | `POST /core/evaluation/task/item/list` | `evalId, offset=N, pageSize=20` | 追加到现有列表 | 追加 20 条 | +| 轮询刷新 | `POST /core/evaluation/task/item/list` | 与上次请求相同参数 | 覆盖已加载数据 | 更新数据状态 | + +- **分页参数**:每页 20 条,滚动底部自动加载更多 +- **筛选条件**:搜索框(按用户输入内容模糊搜索)、NavBar 标签筛选(全部/疑问数据/错误数据) +- **排序规则**:默认按创建时间排序 + +#### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 任务详情页 + participant API as 后端API + + User->>UI: 点击任务行 + UI->>API: GET /core/evaluation/task/detail + par 并行加载 + UI->>API: GET /core/evaluation/task/stats + UI->>API: GET /core/evaluation/task/detail + end + API-->>UI: 任务详情 + 统计数据 + UI->>API: GET /core/evaluation/summary/detail + API-->>UI: 总结数据 + UI->>API: POST /core/evaluation/task/item/list + API-->>UI: 数据列表 + UI-->>User: 渲染完整详情页 + + loop 每15秒轮询 + UI->>API: POST /core/evaluation/task/item/list(刷新) + API-->>UI: 更新数据 + UI-->>User: 静默更新 + end +``` + +--- + +### 编辑评测数据 + +> 在详情页编辑单条评测数据的问题和期望回答。 + +#### 步骤 1:进入编辑模式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中数据项后点击编辑按钮 | 无 | 无 | 问题和期望回答变为可编辑的输入框,显示保存和取消按钮 | + +#### 步骤 2:修改并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改问题和/或期望回答后点击保存 | `PUT /core/evaluation/task/item/update` 参数:`{evalItemId, userInput, expectedOutput, modifyDataset: boolean}` | `modifyDataset` 开关控制是否同步修改数据集中的原始数据 | 退出编辑模式,刷新数据列表,成功提示"保存成功" | + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 问题 | 文本输入 | 是 | 当前数据的问题 | — | 否 | 用户输入的问题 | +| 期望回答 | 文本域 | 是 | 当前数据的期望回答 | — | 否 | 期望的正确回答 | +| 同步修改数据集 | 开关 | 否 | 开启 | — | 否 | 是否将修改同步到数据集原始数据 | + +--- + +### 删除评测数据 + +> 从详情页删除单条评测数据项。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中数据项后点击删除按钮 | `DELETE /core/evaluation/task/item/delete` 参数:`{evalItemId}` | 无 | 选中项被删除,自动选择下一个数据项(若删除最后一项则选新列表的最后一项) | + +--- + +### 重试单条评测数据 + +> 从详情页重新评测单条数据项。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中数据项后点击刷新按钮 | `POST /core/evaluation/task/item/retry` 参数:`{evalItemId}` | 无 | 刷新数据列表 | + +--- + +### 配置评分参数 + +> 在详情页弹窗中配置分数聚合方式和各维度的判断阈值与权重。 + +#### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右侧面板的设置按钮 | `GET /core/evaluation/summary/config/detail` 参数:`{evalId}` | 无 | 弹窗打开并加载当前配置,骨架屏加载态 | + +#### 步骤 2:配置参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择分数聚合方式(均值/中位数)、调整各维度的阈值(1-100)和权重(1-100,键盘上下键步长5) | 无(本地表单编辑) | 当维度数 ≥ 3 时才显示权重列;权重总和必须等于 100% | 权重列实时显示总和 | + +#### 步骤 3:保存配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认 | `POST /core/evaluation/summary/config/update` 参数:`{evalId, calculateType, metricsConfig: [{metricId, thresholdValue, weight}]}` | 权重和不等于 100 时确认按钮置灰 | 弹窗关闭,刷新总结数据 | + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 分数聚合方式 | 下拉选择 | 是 | 均值 | 均值、中位数 | 否 | 综合得分的计算方式 | +| 判断阈值 | 数字输入 | 是 | 80 | 1-100,仅整数 | 否 | 判定该维度是否达标的分数线 | +| 综合评分权重 | 数字输入 | 条件必填 | 由系统计算 | 1-100,仅整数,步长 5;仅 ≥3 个维度时显示 | 否 | 该维度在综合评分中的权重百分比 | + +--- + +### 导出评测数据 + +> 将当前筛选条件下的评测数据导出为 CSV 文件。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部导航栏的导出按钮 | `POST /api/core/evaluation/task/item/export` 参数:`{evalId, filters, headers, metricColumns, statusLabelMap}` | 导出范围受当前标签页(全部/疑问/错误)和搜索关键字影响 | 触发文件下载,CSV 文件自动保存 | + +- **导出字段**:案例ID、用户输入、期望回答、实际回答、状态、错误信息、各维度得分 +- **文件命名**:`evaluation_{任务名}_{日期}.csv` + +--- + +### 生成总结报告 + +> 触发 AI 重新生成评测维度的总结报告。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右侧面板的「刷新评分」按钮 | `POST /core/evaluation/summary/create` 参数:`{evalId, metricIds: [...]}` | 若无维度数据则提示无法生成 | 触发异步生成,15 秒轮询会自动获取生成结果 | + +--- + +### 查看完整AI响应 + +> 查看评测过程中 AI 应用的完整对话响应。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中数据后点击「查看完整响应」 | 无(弹窗内展示) | 若选中项无 `aiChatItemDataId` 则提示 dataId 缺失 | 弹窗打开,展示完整的 AI 对话记录 | + +--- + +## Mermaid 附录(全局流程) + +```mermaid +sequenceDiagram + participant User + participant UI as 评测任务模块 + participant API as 后端API + + Note over User,API: 任务管理 + User->>UI: 进入评测任务列表 + UI->>API: POST list(分页+筛选) + API-->>UI: 任务列表 + UI-->>User: 表格展示 + + User->>UI: 创建新任务 + UI->>API: POST create + API-->>UI: 创建成功 + UI->>API: POST list(刷新) + API-->>UI: 更新列表 + + Note over User,API: 任务详情 + User->>UI: 点击任务进入详情 + par 并行加载 + UI->>API: GET stats + detail + end + UI->>API: GET summary + API-->>UI: 统计数据+总结 + UI->>API: POST item/list + API-->>UI: 数据列表 + UI-->>User: 详情页渲染 + + User->>UI: 选中数据/编辑/删除/导出 + UI->>API: 对应 API 调用 + API-->>UI: 操作结果 + UI-->>User: 页面状态更新 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..efa0486758f0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,58 @@ +--- +capability_label: 评测任务 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — 模块概览 + +## 1. 模块业务概述 + +评测任务是评测系统的核心模块,负责管理和执行 AI 应用的质量评估。管理员和团队成员可以在此创建评测任务,对指定的 AI 应用版本进行多维度质量评估,实时追踪评测进度,并查看详细的评测结果与总结报告。 + +评测任务模块包含两个主要页面: + +- **评测任务列表**:展示所有已创建的评测任务,支持按应用筛选和关键字搜索,提供创建、重命名、删除、重试失败数据等管理操作 +- **评测任务详情**:展示单个任务的完整评估结果,包括进度统计、各维度得分、评测总结、逐条数据详情,支持数据筛选、编辑、导出和重新生成总结报告 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 评测任务列表、评测任务详情 | 创建任务、查看所有任务、编辑/删除任务、重试失败数据、导出数据、配置评分参数 | 可访问和管理本团队所有评测任务 | +| 团队成员 | 评测任务列表、评测任务详情 | 创建任务、查看团队任务、编辑/删除自己创建的任务、导出数据 | 仅可管理自己创建的任务 | + +**权限模型**:基于团队(Team)和成员(Tmb)的权限控制。访问评测页面需 `feConfigs.show_evaluation` 开关开启,否则自动重定向到工作台首页。任务级权限通过 `teamId` 和 `tmbId` 在后端控制,前端通过 API 层透传权限。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 通用组件库 | MyBox、MyModal、MyMenu、MyIcon、EmptyTip、Avatar、FillRowTabs 等 | 组件 | +| 通用 Hooks | useRequest、usePagination、useScrollPagination、useConfirm、useDebounceFn | Hook | +| 应用管理 | AppSelect(应用选择器)、getAppVersionList(版本列表) | 组件/API | +| 评测数据集 | getEvaluationDatasetListV2、IntelligentGeneration(智能生成数据集) | API/组件 | +| 评测维度 | ManageDimension(维度管理弹窗)、内置维度配置 | 组件/配置 | +| i18n | useTranslation、dashboard_evaluation 命名空间 | 国际化 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 评测任务创建弹窗(CreateModal) | 数据集列表、智能客服表单、Agent 页面 | 组件 | +| 应用筛选器(AppSelectWithAll) | 评测任务列表自身 | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测任务列表 | `/dashboard/evaluation?evaluationTab=tasks` | `src/pages/dashboard/evaluation/task/index.tsx` | 管理员、团队成员 | 评测页面的默认 Tab,展示任务表格 | +| 评测任务详情 | `/dashboard/evaluation/task/detail?taskId={id}` | `src/pages/dashboard/evaluation/task/detail/index.tsx` | 管理员、团队成员 | 含过滤标签(全部/疑问/错误)和数据详情面板 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ecc10a9330f9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,89 @@ +--- +capability_label: "评测数据集" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:31:09Z" +parent_module: "评测首页" +roles: ["团队成员"] +router_paths: + - "/dashboard/evaluation?evaluationTab=datasets" + - "/dashboard/evaluation/dataset/detail" + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 评测数据集 — 业务流程索引 + +## 业务场景列表 + +### 查看数据集列表 + +- **业务描述**: 以分页表格形式浏览当前团队创建的所有评测数据集,支持名称搜索和状态概览。 +- **触发入口**: 评测首页 → 切换到"评测数据集"Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 已登录且有团队,评测功能已开启(`feConfigs.show_evaluation` 不为 false) + +### 智能生成数据集 + +- **业务描述**: 基于已有知识库,通过 AI 模型自动生成一批包含问题和参考答案的评测数据,组成新数据集。 +- **触发入口**: 数据集列表页 → 点击"新建"按钮 → 选择"智能生成" +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets`(弹窗形式) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 存在可用的知识库,有评测模型可用 + +### 文件导入数据集 + +- **业务描述**: 通过下载 CSV 模板、填写数据后上传文件,批量创建数据集数据。 +- **触发入口**: 数据集列表页 → 点击"新建"按钮 → 选择"文件导入" +- **路由路径**: `/dashboard/evaluation/dataset/fileImport` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 准备好符合模板格式的 CSV 文件 + +### 管理数据集 + +- **业务描述**: 对已有数据集进行重命名或删除操作。 +- **触发入口**: 数据集列表 → 行内操作菜单(⋮ 图标) +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 存在已创建的数据集 + +### 查看异常详情 + +- **业务描述**: 当数据集状态为"生成异常"时,点击状态标签查看异常的详细任务列表,并可重试或删除单个/全部异常任务。 +- **触发入口**: 数据集列表 → 点击状态为"生成异常"的状态标签 +- **路由路径**: `/dashboard/evaluation?evaluationTab=datasets`(弹窗形式) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/errorModal.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 存在状态为异常的数据集 + +### 进入数据集详情 + +- **业务描述**: 进入数据集详情页,查看和管理数据集内包含的具体数据条目,执行质量评测等操作。 +- **触发入口**: 数据集列表 → 点击数据集的表格行 +- **路由路径**: `/dashboard/evaluation/dataset/detail?collectionId={id}&collectionName={name}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` +- **涉及角色**: 团队成员 +- **前置条件**: 数据集已创建成功 + +## 场景关系 + +- **查看数据集列表** → **智能生成数据集** / **文件导入数据集**: 列表为空或需要新数据集时,通过新建入口创建数据集。 +- **查看数据集列表** → **进入数据集详情**: 点击列表行进入详情页查看和管理具体数据。 +- **查看数据集列表** → **管理数据集**: 通过列表行内菜单进行重命名或删除。 +- **查看数据集列表** → **查看异常详情**: 状态为异常时点击状态标签查看错误信息。 +- **智能生成数据集** / **文件导入数据集** → **查看数据集列表**: 创建成功后自动刷新列表。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看数据集列表 | 评测首页→评测数据集Tab | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S02 | 智能生成数据集 | 列表页→新建→智能生成 | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S03 | 文件导入数据集 | 列表页→新建→文件导入 | `/dashboard/evaluation/dataset/fileImport` | 团队成员 | +| S04 | 管理数据集 | 列表行→操作菜单 | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S05 | 查看异常详情 | 列表行→异常状态标签 | `/dashboard/evaluation?evaluationTab=datasets` | 团队成员 | +| S06 | 进入数据集详情 | 列表行→点击 | `/dashboard/evaluation/dataset/detail` | 团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..02f943afbe8d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,397 @@ +--- +capability_label: "评测数据集" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:31:09Z" +parent_module: "评测首页" +roles: ["团队成员"] +router_paths: + - "/dashboard/evaluation?evaluationTab=datasets" + - "/dashboard/evaluation/dataset/detail" + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 评测数据集 — 业务流程详解 + +## 页面总览 + +评测数据集模块位于评测首页的第二个 Tab,是评测系统的数据管理中心。用户在此可浏览、创建和管理评测数据集。数据集列表以表格形式展示,支持名称搜索、状态查看和分页浏览。新增数据集有两种途径:基于知识库的智能生成和 CSV 文件导入。 + +--- + +### 查看数据集列表 + +> 业务描述:以分页表格查看团队下所有评测数据集的名称、数据量、创建/更新时间、状态和创建人,支持按名称搜索。 + +#### 步骤 1:进入数据集列表页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击评测首页的"评测数据集"Tab | — | 无 | Tab 切换为"评测数据集",数据集列表区域加载中,显示加载骨架 | + +#### 步骤 2:加载数据集列表数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需手动操作) | POST `/core/evaluation/dataset/collection/list`(参数 searchKey 为空字符串,pageSize=10) | 无 | 表格区域结束加载,显示数据集列表或空状态提示 | + +#### 步骤 3:搜索数据集 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入名称关键词 | 输入停止 500ms 后触发 POST `/core/evaluation/dataset/collection/list`(参数 searchKey 为输入值) | 输入过程中不触发,防抖延迟后触发 | 输入框内容即时更新,500ms 后列表数据刷新 | + +#### 步骤 4:翻页浏览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器的页码或翻页按钮 | POST `/core/evaluation/dataset/collection/list`(参数 pageNum 为对应页码) | 无 | 列表数据切换为对应页 | + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST `/core/evaluation/dataset/collection/list` | searchKey="",pageNum=1,pageSize=10 | 服务端分页,前端直接使用返回数据 | 表格前 10 条(或更少) | +| 翻页 | POST `/core/evaluation/dataset/collection/list` | pageNum=N,pageSize=10 | 无额外处理 | 表格第 N 页 | +| 搜索 | POST `/core/evaluation/dataset/collection/list` | searchKey 为用户输入值 | 防抖 500ms 后触发 | 表格数据按名称筛选 | + +- 分页参数:默认每页 10 条 +- 排序规则:由服务端按创建时间倒序排列 +- 筛选条件:支持按名称关键词模糊搜索 +- 特殊列渲染: + - 状态列:根据数据状态显示不同颜色和文案的标签(排队中-灰色、数据生成中-蓝色、已就绪-绿色、生成异常-红色),异常状态可点击 + - 时间列:同时显示创建时间和更新时间,格式为 `yyyy-MM-dd HH:mm:ss` + - 操作列:鼠标悬停时显示更多操作图标按钮 + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 列表页 + participant API as 后端API + participant Backend as 后端服务 + + U->>UI: 切换到"评测数据集"Tab + UI->>API: POST /core/evaluation/dataset/collection/list
(searchKey="", pageSize=10) + API->>Backend: 查询数据集列表 + Backend-->>API: 返回分页数据 + API-->>UI: 列表数据 + 总数 + UI-->>U: 渲染数据集表格 + + U->>UI: 输入搜索关键词 + Note over UI: 防抖 500ms + UI->>API: POST /core/evaluation/dataset/collection/list
(searchKey=输入值) + API->>Backend: 按名称模糊查询 + Backend-->>API: 返回筛选后数据 + API-->>UI: 更新列表 + UI-->>U: 刷新表格数据 +``` + +--- + +### 智能生成数据集 + +> 业务描述:选择一个或多个知识库作为数据生成依据,配置生成数量,由 AI 模型自动生成包含问题与参考答案的评测数据集。 + +#### 步骤 1:打开智能生成弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建"按钮,在弹出的下拉菜单中选择"智能生成" | — | 无 | 打开智能生成弹窗,弹窗标题为"智能生成数据集" | + +#### 步骤 2:配置生成参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入数据集名称 | — | 名称为必填项 | 输入框显示输入值 | +| 点击"选择知识库"按钮,在弹出的知识库选择器中选择知识库 | — | 至少选择一个知识库 | 已选知识库以卡片形式展示(含头像和名称) | +| 设置数据量 | — | 默认 50,最小 1,最大不超过所选知识库总数据量 | 数字输入框显示当前值 | +| 选择生成模型 | — | 默认选中列表第一个可用于评测的模型 | 模型选择器显示当前模型 | + +#### 步骤 3:提交生成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | POST `/core/evaluation/dataset/data/smartGenerate`(参数 name, count, kbDatasetIds, intelligentGenerationModelId) | 表单验证:名称不为空、已选知识库、数据量≥1、已选模型 → 按钮可用;否则按钮置灰 | 按钮显示加载中状态,提交完成后弹窗关闭,列表自动刷新 | + +##### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 数据集名称 | 文本输入 | ✅ | 空 | 任意文本 | 否 | 新建数据集的名称 | +| 生成依据 | 知识库选择器(弹窗) | ✅ | 空 | 从已有知识库中选择,支持多选 | 否 | 选择后以卡片形式展示已选知识库 | +| 数据量 | 数字输入 | ✅ | 50 | 1 ~ 所选知识库总数据量 | 否 | 超过知识库总数据量时自动设为最大值 | +| 生成模型 | 下拉选择 | ✅ | 评测可用模型列表第一项 | 仅显示 useInEvaluation 为 true 的模型 | 否 | 用于生成评测数据的 AI 模型 | + +**字段联动**: +- 当选中的知识库总数据量 < 50 时,数据量字段自动调整为该总数据量 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 数据集名称必填 | 提交时 | 表单验证不通过,按钮置灰不可点击 | +| 至少选择一个知识库 | 提交时 | 按钮置灰 | +| 数据量 ≥ 1 | 提交时 | 按钮置灰 | + +**前后置条件**: +- **前置条件**:存在可用的知识库数据,存在已配置且 `useInEvaluation` 为 true 的模型 +- **后置影响**:创建新数据集,后台异步生成评测数据,数据集初始状态为"排队中"或"数据生成中" +- **失败场景**:提交接口返回错误时,显示错误提示 toast,弹窗不关闭可重试 + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 列表页 + participant Modal as 智能生成弹窗 + participant API as 后端API + + U->>UI: 新建 → 智能生成 + UI-->>U: 打开弹窗 + U->>Modal: 输入名称、选择知识库、设置数据量、选择模型 + Modal-->>U: 表单验证通过,确认按钮可用 + U->>Modal: 点击确认 + Modal->>API: POST /core/evaluation/dataset/data/smartGenerate + API-->>Modal: 返回 collectionId + Modal-->>U: 关闭弹窗 + UI->>API: POST /core/evaluation/dataset/collection/list(刷新) + API-->>UI: 最新列表数据 + UI-->>U: 列表刷新,新数据集出现 +``` + +--- + +### 文件导入数据集 + +> 业务描述:下载 CSV 模板,按格式填写评测数据,上传文件批量导入,可选开启自动质量评测。 + +#### 步骤 1:进入文件导入页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建"按钮,选择"文件导入" | — | 无 | 页面跳转至文件导入页 `/dashboard/evaluation/dataset/fileImport` | + +#### 步骤 2:配置导入参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 下载 CSV 模板参考格式 | —(前端生成本地文件) | 无 | 浏览器下载 `evaluation_template.csv` | +| 输入数据集名称 | — | 非详情页追加场景时必须填写 | 输入框显示输入值 | +| 选择 CSV 文件上传 | — | 仅支持 `.csv` 文件 | 已选文件以列表展示(名称、大小),可逐个删除 | + +#### 步骤 3:配置质量评测 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 开启"数据质量评测"开关 | — | 默认开启 | 质量评测模型选择器出现 | +| 选择质量评测模型 | — | 从评测可用模型列表中选择 | 模型选择器显示当前模型 | +| 关闭"数据质量评测"开关 | — | 可选关闭 | 模型选择器隐藏 | + +#### 步骤 4:提交导入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | 1. 如果无 collectionId → POST `/core/evaluation/dataset/collection/create`(参数 name)创建新数据集
2. POST `/core/evaluation/dataset/data/import`(multipart/form-data,含文件和配置参数) | 名称必填(非追加场景)、文件已选择 → 按钮可用;否则置灰 | 按钮显示上传进度百分比("数据上传中: N%"),上传完成后显示"数据解析中",解析完成跳转到目标页面 | + +##### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 数据集名称 | 文本输入 | 条件必填 | 空 | 非追加场景(无 collectionId)时必填 | 否 | 新建数据集的名称 | +| 文件 | 文件选择器 | ✅ | 空 | 仅 `.csv` 格式,自动过滤超尺寸文件 | 否 | 支持多文件同时上传 | +| 自动质量评测 | 开关 | 否 | 开启(true) | 开启/关闭 | 否 | 开启后导入完成自动进行数据质量评测 | +| 质量评测模型 | 下拉选择 | 条件必填 | 评测可用模型第一项 | 仅显示 useInEvaluation 为 true 的模型 | 否 | 仅在自动质量评测开启时显示 | + +**字段联动**: +- 自动质量评测开关关闭时,质量评测模型字段隐藏 +- 自动质量评测开关开启时,质量评测模型字段显示且必填 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 数据集名称必填(非追加场景) | 点击确认 | toast 提示"请输入名称" | +| 至少选择一个文件 | 点击确认 | toast 提示"请选择文件" | + +**前后置条件**: +- **前置条件**:准备好符合模板格式的 CSV 文件(第一列为提问,第二列为答案,第一行为表头) +- **后置影响**:创建新数据集(如需要)并上传文件数据,可选执行自动质量评测,数据集状态为"文件解析中"或"数据生成中" +- **失败场景**:上传失败时显示错误 toast;可在项目文件记录中查看解析失败的详细原因 + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 文件导入页 + participant API as 后端API + + U->>UI: 新建 → 文件导入 + Note over UI: 跳转到 /dashboard/evaluation/dataset/fileImport + U->>UI: 输入名称、选择文件、配置评测开关 + U->>UI: 点击确认 + alt 无 collectionId(新建场景) + UI->>API: POST /core/evaluation/dataset/collection/create
(name) + API-->>UI: 返回 collectionId + end + UI->>API: POST /core/evaluation/dataset/data/import
(multipart/form-data, 含文件+配置) + Note over UI: 显示上传进度 N% + API-->>UI: 上传完成 + Note over UI: 显示"数据解析中" + UI-->>U: 跳转到目标页面(列表或详情页) +``` + +--- + +### 管理数据集(重命名) + +> 业务描述:修改已有数据集的名称。 + +#### 步骤 1:打开重命名弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击数据集行内操作菜单(⋮),选择"重命名" | — | 无 | 打开重命名弹窗,输入框预填当前名称 | + +#### 步骤 2:提交新名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称后点击确认 | POST `/core/evaluation/dataset/collection/update`(参数 collectionId, name) | 名称不为空 | 弹窗关闭,提示"更新成功",列表自动刷新 | + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 列表页 + participant Modal as 重命名弹窗 + participant API as 后端API + + U->>UI: 点击行内 ⋮ → 重命名 + UI-->>U: 打开重命名弹窗(预填当前名称) + U->>Modal: 修改名称 → 确认 + Modal->>API: POST /core/evaluation/dataset/collection/update
(collectionId, name) + API-->>Modal: 更新成功 + Modal-->>U: 关闭弹窗,toast"更新成功" + UI->>API: POST /core/evaluation/dataset/collection/list(刷新) + UI-->>U: 列表刷新 +``` + +--- + +### 管理数据集(删除) + +> 业务描述:删除一个已有的评测数据集及其包含的所有数据。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击数据集行内操作菜单(⋮),选择"删除" | — | 无 | 打开删除确认弹窗 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击"删除" | DELETE `/core/evaluation/dataset/collection/delete`(参数 collectionId) | 无 | 弹窗关闭,提示"删除成功",列表自动刷新 | +| 在确认弹窗中点击"取消" | — | 无 | 弹窗关闭,不做任何操作 | + +##### 删除链路详情 + +- **确认弹窗**:提示文案为"确认删除该数据集?",确认按钮文案为"删除" +- **引用检查**:无前端引用检查,后端处理关联数据清理 +- **级联影响**:删除数据集会同步删除数据集内所有数据条目、关联的生成任务和异常任务记录 + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 列表页 + participant Modal as 确认弹窗 + participant API as 后端API + + U->>UI: 点击行内 ⋮ → 删除 + UI-->>U: 打开确认弹窗
"确认删除该数据集?" + U->>Modal: 点击"删除" + Modal->>API: DELETE /core/evaluation/dataset/collection/delete
(collectionId) + API-->>Modal: 删除成功 + Modal-->>U: 关闭弹窗,toast"删除成功" + UI->>API: POST /core/evaluation/dataset/collection/list(刷新) + UI-->>U: 列表刷新 +``` + +--- + +### 查看异常详情 + +> 业务描述:当数据集状态为"生成异常"时,查看异步生成任务的失败详情,可对单个或全部失败任务进行重试或删除。 + +#### 步骤 1:打开异常详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击状态为"生成异常"(红色标签)的数据集状态 | POST `/core/evaluation/dataset/collection/failedTasks`(参数 collectionId) | 仅"生成异常"状态可点击,其他状态不可点击 | 打开异常详情弹窗,标题为"异常信息",加载失败任务列表 | + +#### 步骤 2:查看和处理异常任务 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹窗自动加载 | — | 无 | 显示失败任务表格(来源知识库、来源分块、错误信息、操作列) | +| 点击单个任务的"重试"按钮 | POST `/core/evaluation/dataset/collection/retryTask`(参数 jobId, collectionId) | 仅当未加载其他操作时可点击 | 提示"重试成功",列表自动刷新 | +| 点击单个任务的"删除"按钮 | POST `/core/evaluation/dataset/collection/deleteTask`(参数 jobId, collectionId) | 仅当未加载其他操作时可点击 | 提示"删除成功",列表自动刷新 | +| 点击"全部重试"按钮 | POST `/core/evaluation/dataset/collection/retryAllTask`(参数 collectionId) | 仅当有失败任务且未加载其他操作时可点击 | 提示"重试成功",列表自动刷新 | + +#### 步骤 3:关闭弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮或关闭弹窗 | — | 如果初始错误任务数和当前不同(有任务被重试/删除),通知父组件刷新列表 | 弹窗关闭,如数据有变化则列表自动刷新 | + +##### 状态转换详情 + +- **状态转换路径**:生成异常 → 重试 → 排队中/数据生成中 → 已就绪(或再次异常) +- **前置检查**:无额外前置检查 +- **确认提示**:无二次确认弹窗,操作直接执行 +- **级联更新**:重试/删除操作后自动刷新异常任务列表 + +##### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 列表页 + participant Modal as 异常详情弹窗 + participant API as 后端API + + U->>UI: 点击"生成异常"状态标签 + UI->>Modal: 打开弹窗 + Modal->>API: POST /core/evaluation/dataset/collection/failedTasks
(collectionId) + API-->>Modal: 返回失败任务列表 + Modal-->>U: 渲染失败任务表格 + + alt 重试单个任务 + U->>Modal: 点击某任务的"重试" + Modal->>API: POST /core/evaluation/dataset/collection/retryTask
(jobId, collectionId) + API-->>Modal: 重试成功 + Modal->>API: 刷新失败任务列表 + else 全部重试 + U->>Modal: 点击"全部重试" + Modal->>API: POST /core/evaluation/dataset/collection/retryAllTask
(collectionId) + API-->>Modal: 重试成功 + Modal->>API: 刷新失败任务列表 + end + Modal-->>U: 更新失败任务表格 + + U->>Modal: 关闭弹窗 + Modal-->>UI: 通知列表刷新(如有变更) + UI-->>U: 列表刷新,状态可能变化 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..b8b3542e32eb --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: "评测数据集" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:31:09Z" +parent_module: "评测首页" +roles: ["团队成员"] +router_paths: + - "/dashboard/evaluation?evaluationTab=datasets" + - "/dashboard/evaluation/dataset/detail" + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 评测数据集 — 模块概览 + +## 1. 模块业务概述 + +评测数据集是 FastGPT 评测系统的数据管理模块,为团队成员提供评测数据集的创建、查看、管理和维护能力。数据集是应用评测任务的核心输入,包含一组用于测试 AI 应用的问题和参考答案,用户在创建评测任务时选择数据集作为测试用例。 + +模块提供两种数据集创建方式:**智能生成**(基于知识库 AI 自动生成评测数据)和**文件导入**(通过 CSV 模板批量导入数据),并支持对数据集进行重命名、删除等管理操作。数据集详情页进一步支持数据的增删改和质量评测。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队成员 | 数据集列表、数据集详情、文件导入 | 查看列表、创建数据集、重命名、删除、查看详情、追加数据 | 所有团队成员操作权限一致,无角色区分 | + +**权限模型**: 本模块不单独设置权限位,依赖工作台 Dashboard 层级的团队准入控制。评测功能整体受 `feConfigs.show_evaluation` 开关控制,父级评测首页在入口处检查该配置,关闭时重定向至 Dashboard 首页。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 知识库 | 选择知识库作为智能生成的依据源 | 组件(DatasetSelectModal) | +| 公共组件库 | MyBox、MyIcon、MyIconButton、MyMenu、MyTooltip、MyTag、MyModal、EmptyTip 等 UI 组件 | 组件 | +| 公共 Hooks | usePagination(分页)、useRequest(请求管理)、useConfirm(删除确认)、useEditTitle(重命名) | Hook | +| 公共工具 | date-fns 日期格式化、ahooks useDebounceFn 防抖 | 工具函数 | +| 路由/导航 | next/router 页面跳转至数据集详情页和文件导入页 | 框架 | + +## 4. 对外共享 + +本模块为评测系统的数据基础层,以下能力被其他模块消费: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 评测数据集列表查询 API | 评测任务创建(选择数据集)、对话中评价数据加入数据集 | API | +| 智能生成数据集弹窗组件 | 评测任务创建、数据集详情页、对话评价数据集选择器 | 组件 | +| 数据集详情页上下文 | 数据集详情页内各子组件 | Context | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测数据集列表 | `/dashboard/evaluation?evaluationTab=datasets` | `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` | 团队成员 | 评测首页的"评测数据集"Tab | +| 数据集详情 | `/dashboard/evaluation/dataset/detail` | `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` | 团队成员 | 通过列表点击行进入,含数据管理和质量评测 | +| 文件导入 | `/dashboard/evaluation/dataset/fileImport` | `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` | 团队成员 | 从列表"新建→文件导入"进入 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..0389e69522ef --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,93 @@ +--- +capability_label: 评测维度 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 业务流程索引 + +## 查看维度列表 + +- **业务描述**: 在评测维度 Tab 中浏览系统内置维度和自定义维度的完整列表,查看每个维度的名称、描述、创建/更新时间、创建者信息。 +- **触发入口**: 工作台 → 评测 → 切换到「评测维度」Tab +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 系统功能开关 `feConfigs.show_evaluation` 为 `true`(否则自动重定向到工作台首页) + +## 搜索维度 + +- **业务描述**: 在搜索框中输入关键词,按维度名称或描述进行前端过滤,快速定位目标维度。 +- **触发入口**: 维度列表页顶部搜索框 +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 已进入维度列表页面 + +## 创建自定义维度 + +- **业务描述**: 填写维度名称、描述和评估提示词,创建一个新的自定义评估维度。支持引用内置模板快速填充提示词内容,创建前可测试运行维度效果。 +- **触发入口**: 维度列表页右上角「创建维度」按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 已进入维度列表页面 + +## 编辑自定义维度 + +- **业务描述**: 点击自定义维度行,进入编辑页面修改维度名称、描述和提示词,保存后更新维度配置。支持修改前测试运行查看效果。 +- **触发入口**: 维度列表 → 点击自定义维度行(内置维度不可点击编辑) +- **路由路径**: `/dashboard/evaluation/dimension/edit?id={dimensionId}` +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 目标维度类型为自定义(非内置) + +## 删除自定义维度 + +- **业务描述**: 删除一个不再需要的自定义评估维度。内置维度不支持删除。 +- **触发入口**: 维度列表中自定义维度行的删除图标按钮 +- **路由路径**: `/dashboard/evaluation?evaluationTab=dimensions`(弹窗确认,不跳转) +- **页面组件位置**: `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 目标维度类型为自定义;用户确认删除弹窗 + +## 测试运行维度 + +- **业务描述**: 在创建或编辑维度页面中,使用示例问答数据测试维度的评估效果。输入问题、参考答案和实际回答,选择评估模型后运行,查看评分结果和反馈。 +- **触发入口**: 创建/编辑维度页面中的「测试运行」按钮 +- **路由路径**: `/dashboard/evaluation/dimension/create` 或 `/dashboard/evaluation/dimension/edit`(弹窗模式,不跳转) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` +- **涉及角色**: 管理员、团队成员 +- **前置条件**: 已在创建或编辑维度页面;表单已通过基本验证(名称和提示词已填写) + +--- + +## 场景关系 + +- **查看维度列表** → **搜索维度**: 在列表页中进行前端搜索过滤 +- **查看维度列表** → **创建自定义维度**: 通过「创建维度」按钮跳转到创建页面 +- **查看维度列表** → **编辑自定义维度**: 点击自定义维度行跳转到编辑页面 +- **查看维度列表** → **删除自定义维度**: 通过列表中的删除按钮触发 +- **创建/编辑自定义维度** → **测试运行维度**: 在创建或编辑页面中打开测试运行弹窗 +- **创建自定义维度** 成功后 → 自动跳转回**维度列表** +- **编辑自定义维度** 保存后 → 自动跳转回**维度列表** + +--- + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看维度列表 | 工作台→评测→评测维度Tab | `/dashboard/evaluation?evaluationTab=dimensions` | 管理员、团队成员 | +| S02 | 搜索维度 | 维度列表→搜索框 | `/dashboard/evaluation?evaluationTab=dimensions` | 管理员、团队成员 | +| S03 | 创建自定义维度 | 维度列表→创建维度按钮 | `/dashboard/evaluation/dimension/create` | 管理员、团队成员 | +| S04 | 编辑自定义维度 | 维度列表→点击自定义维度行 | `/dashboard/evaluation/dimension/edit?id={id}` | 管理员、团队成员 | +| S05 | 删除自定义维度 | 维度列表→删除图标按钮 | `/dashboard/evaluation?evaluationTab=dimensions`(弹窗确认) | 管理员、团队成员 | +| S06 | 测试运行维度 | 创建/编辑维度→测试运行按钮 | 弹窗(不跳转路由,在创建/编辑页中打开) | 管理员、团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..6250bf8c9a7a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,452 @@ +--- +capability_label: 评测维度 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 业务流程详解 + +## 页面总览 + +评测维度模块是 FastGPT 评测系统的维度管理中心,以 Tab 形式嵌入评测首页页面。用户可在此浏览系统内置和自定义的评估维度,对自定义维度执行完整 CRUD 操作,并支持在创建/编辑维度时进行测试运行以验证评估效果。本模块无嵌套 Tab,所有场景独立执行。 + +--- + +### 场景 S01:查看维度列表 + +> 用户在评测维度 Tab 中浏览内置和自定义维度的完整列表。 + +#### 步骤 1:进入维度列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工作台进入评测页面,切换到「评测维度」Tab | `POST /core/evaluation/metric/list`(参数为空对象 `{}`) | `feConfigs.show_evaluation` 为 `false` 时自动重定向到 `/dashboard`,不发起任何请求 | 「评测维度」Tab 激活,显示 MyBox 加载遮罩,直到 API 返回数据 | + +#### 步骤 2:数据加载与渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待加载完成 | 无新请求 | 无 | 加载遮罩消失,维度表格渲染;每条维度显示名称、描述、创建/更新时间、创建者头像和名称;内置维度显示灰色「内置」标签 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `POST /core/evaluation/metric/list` | `{}` | 内置维度通过 `getBuiltinDimensionNameFromId` 和 `getBuiltinDimensionInfo` 将名称和描述替换为国际化版本 | 表格显示全部维度,内置维度带「内置」标签,自定义维度行可点击 | + +- 分页:当前版本为全量加载,无分页参数 +- 排序:服务端返回的顺序 +- 内置维度特殊渲染:创建时间、更新时间和创建者列显示为 `-`;行不可点击,无删除按钮 +- 自定义维度特殊渲染:创建/更新时间格式化为 `yyyy-MM-dd HH:mm:ss`;行可点击跳转编辑;显示删除图标按钮 + +#### 步骤 3:空数据处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 数据加载完成但维度列表为空 | `POST /core/evaluation/metric/list` 返回空列表 | `allDimensions` 为空数组 → `filteredDimensions` 为空 | 表格区域中央显示空状态提示(EmptyTip 组件),提示文案为"暂无数据"的国际化版本 | + +```mermaid +sequenceDiagram + participant User + participant UI as 维度列表页面 + participant API as 后端 API + + User->>UI: 切换到评测维度 Tab + UI->>API: POST /core/evaluation/metric/list {} + Note over UI: 显示加载遮罩 + API-->>UI: 返回维度列表 + Note over UI: 内置维度通过 i18n 转换名称/描述 + UI-->>User: 渲染维度表格 + Note over UI: 自定义维度行可点击跳转编辑
内置维度显示灰色标签,不可点击 +``` + +--- + +### 场景 S02:搜索维度 + +> 用户在搜索框中输入关键词,实时过滤维度列表。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | 无(前端过滤) | 无 | `searchValue` 状态更新,搜索框中显示输入的文字 | + +#### 步骤 2:过滤结果展示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 关键词输入后自动过滤 | 无 | 搜索值为空时显示全部维度;搜索值非空时按名称和描述执行大小写不敏感的 `includes` 匹配 | 表格内容即时更新为过滤后的维度列表;无匹配结果时显示空状态 | + +- 过滤逻辑:`dimension.name.toLowerCase().includes(searchValue.toLowerCase())` 或 `dimension.description.toLowerCase().includes(searchValue.toLowerCase())` +- 过滤范围:当前已加载的全部维度数据(含内置和自定义) +- 无防抖处理,每次输入即时触发 `useMemo` 重新计算 + +```mermaid +sequenceDiagram + participant User + participant UI as 维度列表页面 + + User->>UI: 在搜索框输入关键词 + Note over UI: 前端过滤:名称/描述
大小写不敏感匹配 + UI-->>User: 表格即时更新为过滤结果 +``` + +--- + +### 场景 S03:创建自定义维度 + +> 用户新建一个自定义评估维度,填写名称、描述和提示词,确认后创建。 + +#### 步骤 1:进入创建页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击维度列表右上角「创建维度」按钮 | 无 | 无 | 路由跳转到 `/dashboard/evaluation/dimension/create`,页面渲染 DashboardContainer 布局 + 返回按钮 + 空白的 EditForm 表单 | + +#### 步骤 2:填写表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「名称」输入框输入维度名称 | 无 | `name` 为空时 `isFormValid` 为 `false` | 输入框接收文字输入;必填字段未填写时提交按钮置灰 | +| 在「介绍」输入框输入维度描述(可选) | 无 | 描述字段为可选 | 输入框接收文字输入 | +| 在「提示词」文本框输入评估提示词 | 无 | `prompt` 为空时 `isFormValid` 为 `false` | 文本框接收文字输入;必填字段未填写时提交按钮置灰 | + +#### 步骤 2a:引用模板(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「引用模板」按钮 | 无 | 无 | 弹出 CitationTemplate 弹窗,左侧显示内置维度模板列表(正确性、简洁性、有害性、争议性、创造性、违法性、深度、详细度),右侧显示所选模板的提示词文本 | +| 在弹窗中切换左侧维度模板 | 无 | 根据当前语言(中文/英文)加载对应的维度模板内容 | 右侧文本区内容切换为所选模板的提示词 | +| 点击弹窗「确认」按钮 | 无 | 无 | 弹窗关闭,表单的提示词文本框被填充为所选模板的内容 | + +**模板选择涉及的内置维度**: + +| 维度模板 | 国际化 Key | 用途说明 | +|---------|-----------|---------| +| 正确性(correctness) | `dashboard_evaluation:correctness` | 评估答案的事实一致性 | +| 简洁性(conciseness) | `dashboard_evaluation:conciseness` | 评估答案的简洁程度 | +| 有害性(harmfulness) | `dashboard_evaluation:harmfulness` | 检测答案中的有害内容 | +| 争议性(controversiality) | `dashboard_evaluation:controversiality` | 评估答案的争议程度 | +| 创造性(creativity) | `dashboard_evaluation:creativity` | 评估答案的创造性 | +| 违法性(criminality) | `dashboard_evaluation:criminality` | 检测答案中的违法内容 | +| 深度(depth) | `dashboard_evaluation:depth` | 评估答案的深度 | +| 详细度(detail) | `dashboard_evaluation:details` | 评估答案的详细程度 | + +#### 步骤 3:测试运行(可选,见 S06) + +在提交前可点击「测试运行」按钮打开 TestRun 弹窗验证维度效果。详见场景 S06。 + +#### 步骤 4:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「确认创建」按钮 | 先执行前端校验:若 `name` 为空则弹出警告 Toast「请输入维度名称」停止提交;若 `prompt` 为空则弹出警告 Toast「请输入提示词」停止提交 | 校验不通过时停止,不发起 API 请求 | 按钮在表单通过校验前保持置灰(`isDisabled={!isFormValid}`) | +| 前端校验通过后提交 | `POST /core/evaluation/metric/create` 参数:`{ name, description, prompt }` | 无 | 按钮显示加载中状态(`isLoading={isCreating}`);API 调用期间按钮禁止重复点击 | +| 等待 API 响应 | — | API 返回成功 → 弹出成功 Toast「维度创建成功」→ 自动跳转回 `/dashboard/evaluation?evaluationTab=dimensions` 列表页 | 列表页重新加载维度数据,新创建的维度出现在列表中 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称(name) | 文本输入框 | ✅ | — | 无格式约束 | ❌ | 维度的显示名称 | +| 介绍(description) | 多行文本框(3行) | ❌ | — | 无格式约束 | ❌ | 维度的功能说明 | +| 提示词(prompt) | 多行文本框(15行) | ✅ | — | 无格式约束;可通过引用模板快速填充 | ❌ | 用于指导 LLM 评估的核心指令 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称不能为空 | 提交时(前端校验) | "请输入维度名称"(i18n key: `dashboard_evaluation:dimension_create_name_required`) | +| 提示词不能为空 | 提交时(前端校验) | "请输入提示词"(i18n key: `dashboard_evaluation:dimension_create_prompt_required`) | +| 表单实时校验 | `onChange` 模式,每次输入变化 | `name` 或 `prompt` 为空时「测试运行」和「确认创建」按钮置灰 | + +**前后置条件**: + +- **前置条件**:系统功能开关 `show_evaluation` 为 `true` +- **后置影响**:数据库新增一条自定义维度记录;该维度可被评测任务引用 +- **失败场景**:API 调用失败时,页面停留在创建表单,提交按钮恢复可用状态,用户可修改后重试 + +```mermaid +sequenceDiagram + participant User + participant UI as 维度创建页面 + participant API as 后端 API + + User->>UI: 点击「创建维度」按钮 + Note over UI: 路由跳转到创建页面 + UI-->>User: 显示空白表单 + + opt 引用模板 + User->>UI: 点击「引用模板」 + UI-->>User: 弹出模板选择弹窗 + User->>UI: 选择维度模板并确认 + UI-->>User: 提示词文本框被填充 + end + + User->>UI: 填写名称、提示词 + Note over UI: 表单实时校验 + + User->>UI: 点击「确认创建」 + Note over UI: 前端校验 name + prompt 非空 + UI->>API: POST /core/evaluation/metric/create
{name, description, prompt} + Note over UI: 按钮显示加载中 + API-->>UI: 创建成功 + UI-->>User: Toast 提示「维度创建成功」 + Note over UI: 自动跳转回维度列表 +``` + +--- + +### 场景 S04:编辑自定义维度 + +> 用户点击维度列表中的自定义维度行,修改其名称、描述或提示词后保存。 + +#### 步骤 1:进入编辑页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击维度列表中一个自定义维度行 | 路由跳转到 `/dashboard/evaluation/dimension/edit?id={id}` | 内置维度行不可点击(cursor 为 default,无 onClick 事件) | 进入编辑页面,页面显示全局加载遮罩(Loading 组件,非固定定位) | + +#### 步骤 2:加载维度数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载完成后自动触发 | `GET /core/evaluation/metric/detail` 参数:`{ metricId: dimensionId }`(从路由 query.id 获取) | 无 | 加载遮罩覆盖整个 DashboardContainer 区域 | + +#### 步骤 3:数据回填 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载 | — | API 成功返回 → 将 `name`、`description`、`prompt` 回填到 EditForm 表单;API 失败 → 弹出错误 Toast「获取维度数据失败」,自动返回维度列表页 | 加载遮罩消失,表单三个字段显示数据库中的现有值 | + +#### 步骤 4:修改表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称、描述或提示词字段 | 无 | 同创建场景的校验逻辑(`name` 和 `prompt` 必填) | 表单实时反映修改;按钮在填写完整前保持置灰 | + +#### 步骤 5:测试运行(可选,见 S06) + +在保存前可点击「测试运行」按钮打开 TestRun 弹窗。详见场景 S06。 + +#### 步骤 6:保存修改 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「保存」按钮 | `PUT /core/evaluation/metric/update` 参数:`{ metricId, name, description, prompt }` | 前端校验同创建场景 | 按钮显示加载中状态(`isLoading={isUpdating}`);API 成功 → 弹出成功 Toast「维度更新成功」→ 自动跳转回列表页;API 失败 → 弹出错误 Toast「更新维度失败」,停留在编辑页 | + +**与创建场景的表单差异**: + +| 差异点 | 创建 | 编辑 | +|--------|------|------| +| 初始数据 | 空表单 | 从 API 加载已有数据回填 | +| 加载状态 | 无数据加载步骤 | 有独立的加载步骤(Loading 组件 + isFetching 状态) | +| 数据不存在处理 | 不适用 | 若 API 返回空数据,显示「维度数据不存在」提示 | +| 提交 API | `POST /core/evaluation/metric/create` | `PUT /core/evaluation/metric/update` | +| 按钮文案 | 「确认创建」 | 「保存」 | +| 返回按钮文案 | 「返回维度列表」 | 「返回」 | + +```mermaid +sequenceDiagram + participant User + participant UI as 维度编辑页面 + participant API as 后端 API + + User->>UI: 点击自定义维度行 + Note over UI: 路由跳转到编辑页 + UI->>API: GET /core/evaluation/metric/detail
{metricId} + Note over UI: 显示加载遮罩 + API-->>UI: 返回维度详情 + Note over UI: 回填 name/description/prompt + UI-->>User: 显示已填充的表单 + + User->>UI: 修改字段内容 + Note over UI: 实时校验 + + User->>UI: 点击「保存」 + UI->>API: PUT /core/evaluation/metric/update
{metricId, name, description, prompt} + Note over UI: 按钮显示加载中 + API-->>UI: 更新成功 + UI-->>User: Toast 提示「维度更新成功」 + Note over UI: 自动跳转回维度列表 +``` + +--- + +### 场景 S05:删除自定义维度 + +> 用户从维度列表中删除一个自定义维度,内置维度不支持删除。 + +#### 步骤 1:点击删除按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击自定义维度行右侧的删除图标按钮 | 无 | 只有自定义维度(`type === EvalMetricTypeEnum.Custom`)才显示删除按钮;内置维度行无删除按钮 | 删除按钮悬停时背景变为红色(`hoverBg="red.50"`),图标变为红色(`hoverColor="red.600"`) | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹出删除确认弹窗(ConfirmModal,类型为 `delete`),弹窗内容为确认删除提示文案 | 无 | 无 | ConfirmModal 显示,包含确认和取消按钮 | + +#### 步骤 3:执行删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认弹窗中「确认」按钮 | `DELETE /core/evaluation/metric/delete` 参数:`{ metricId }` | 无 | 弹窗关闭(`onConfirm` 回调执行) | +| 等待删除完成 | — | API 成功 → 弹出成功 Toast → 自动刷新维度列表(调用 `fetchAllDimensions`,即 `POST /core/evaluation/metric/list`);API 失败 → 弹出错误 Toast「删除失败」 | 列表重新加载,被删除的维度从列表中消失 | + +**删除链路详情**: + +- **确认弹窗**:使用通用 `useConfirm` Hook,类型为 `delete`;自定义提示内容通过 `customContent` 传入(i18n key: `dashboard_evaluation:confirm_delete_dimension`) +- **批量与单条差异**:仅支持单条删除,无批量删除功能 +- **级联影响**:删除维度后不会自动级联取消已引用该维度的评测任务;任务可能仍会引用已删除的维度 ID +- **撤销机制**:无撤销功能,删除后不可恢复 + +```mermaid +sequenceDiagram + participant User + participant UI as 维度列表页面 + participant API as 后端 API + + User->>UI: 点击自定义维度的删除图标 + UI-->>User: 弹出删除确认弹窗 + User->>UI: 点击「确认」 + UI->>API: DELETE /core/evaluation/metric/delete
{metricId} + API-->>UI: 删除成功 + UI-->>User: Toast 提示「删除成功」 + UI->>API: POST /core/evaluation/metric/list {} + Note over UI: 重新加载维度列表 + API-->>UI: 返回更新后的列表 + UI-->>User: 维度已从列表中移除 +``` + +--- + +### 场景 S06:测试运行维度 + +> 在创建或编辑维度时,使用示例数据测试维度的评估效果,验证提示词的有效性。 + +#### 步骤 1:打开测试运行弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击创建/编辑页面中的「测试运行」按钮 | 无 | 按钮在表单未通过基本校验(`name` 和 `prompt` 非空)时置灰 | 弹出 TestRun 弹窗,左侧为输入区域(问题、参考答案、实际回答),右侧为运行结果区域 | + +#### 步骤 2:填写测试数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「问题」文本区输入测试问题 | 无 | 无 | 文本区接收输入,自动调整高度 | +| 在「参考答案」折叠区域输入期望的正确答案 | 无 | 初始状态折叠(最大高度 50px),点击展开后显示完整内容 | 支持折叠/展开切换 | +| 在「实际回答」折叠区域输入 AI 生成的实际回答 | 无 | 初始状态折叠 | 支持折叠/展开切换 | + +#### 步骤 3:选择评估模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧 AI 模型下拉选择器中选择评估模型 | 从 `useSystemStore` 读取 `llmModelList` | 默认选中模型列表中的第一个模型 | 选择器显示当前选中的模型名称 | + +#### 步骤 4:运行测试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「开始运行」按钮 | 先校验:`formData.prompt` 非空(否则 Toast「提示词不能为空」);`selectedModel` 非空(否则 Toast「请选择模型」)| 问题、参考答案、实际回答任一为空或未选择模型时按钮置灰(`isDisabled` 为 true) | 按钮显示加载中状态和「运行中...」文字 | +| 校验通过 | `POST /core/evaluation/metric/debug` 参数:`{ evalCase: { userInput: question, expectedOutput: referenceAnswer, actualOutput: actualAnswer }, llmConfig: { modelId: selectedModel }, metricConfig: { metricName: formData.name, metricType: "custom", prompt: formData.prompt } }` | 无 | 右侧结果区域显示「运行中」状态,占位内容显示灰色边框 | + +#### 步骤 5:查看结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待 API 返回 | — | 成功 → 显示绿色「运行成功」标签 + 分数(百分比格式,乘以 100)+ 详细反馈文本;失败 → 显示红色「运行失败」标签 + 错误信息 | 右侧结果区域变为灰色背景块,显示状态标签(带圆点颜色标记)、分数和反馈内容 | + +**测试运行评估用例结构**: + +| 字段 | 来源 | 说明 | +|------|------|------| +| `userInput` | 用户在弹窗中输入的问题 | 必填 | +| `expectedOutput` | 用户在弹窗中输入的参考答案 | 必填 | +| `actualOutput` | 用户在弹窗中输入的实际回答 | 必填 | + +**API 请求参数详情**: + +| 参数项 | 来源 | 说明 | +|--------|------|------| +| `evalCase` | 用户输入的三个字段 | 包含 `userInput`、`expectedOutput`、`actualOutput` | +| `llmConfig.modelId` | 用户选择的模型 | 未选择时按钮禁用 | +| `metricConfig.metricName` | 表单中的 `name` 字段 | 当前编辑的维度名称 | +| `metricConfig.metricType` | 固定值 `EvalMetricTypeEnum.Custom` | 自定义维度 | +| `metricConfig.prompt` | 表单中的 `prompt` 字段 | 当前的评估提示词 | + +```mermaid +sequenceDiagram + participant User + participant UI as 测试运行弹窗 + participant Store as useSystemStore + participant API as 后端 API + + User->>UI: 点击「测试运行」 + UI->>Store: 获取 llmModelList + Store-->>UI: 返回可用模型列表 + UI-->>User: 显示弹窗,左侧输入区,右侧结果区 + + User->>UI: 填写问题、参考答案、实际回答 + User->>UI: 选择评估模型 + User->>UI: 点击「开始运行」 + + Note over UI: 校验 prompt + model 非空 + UI->>API: POST /core/evaluation/metric/debug
{evalCase, llmConfig, metricConfig} + Note over UI: 按钮显示「运行中...」 + API-->>UI: 返回评分结果 {score, reason} + UI-->>User: 显示运行状态标签
成功:绿色 + 分数百分比 + 反馈文本
失败:红色 + 错误信息 +``` + +--- + +## Mermaid 附录 + +### 维度列表 → 创建维度 → 返回列表 完整流程 + +```mermaid +sequenceDiagram + participant User + participant UI as 维度列表/创建页 + participant API as 后端 API + + User->>UI: 切换到评测维度 Tab + UI->>API: POST /core/evaluation/metric/list {} + API-->>UI: 返回维度列表 + UI-->>User: 渲染维度表格 + + User->>UI: 点击「创建维度」 + Note over UI: 路由跳转到创建页 + UI-->>User: 显示空白表单 + + opt 引用模板 + User->>UI: 引用模板并确认 + end + + User->>UI: 填写表单并提交 + UI->>API: POST /core/evaluation/metric/create + API-->>UI: 创建成功 + UI-->>User: Toast 提示,自动返回列表 + + User->>UI: 点击自定义维度行 + Note over UI: 路由跳转到编辑页 + UI->>API: GET /core/evaluation/metric/detail + API-->>UI: 返回维度数据 + UI-->>User: 显示已填充表单 + + User->>UI: 修改并保存 + UI->>API: PUT /core/evaluation/metric/update + API-->>UI: 更新成功 + Note over UI: 自动返回列表 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..e61c807f1385 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,51 @@ +--- +capability_label: 评测维度 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions +--- + +# 评测维度 — 共享能力清单 + +## 对外暴露的页面/组件 + +| 共享项 | 类型 | 入口路径 | 使用者 | 用途 | +|--------|------|---------|--------|------| +| 评测维度列表 Tab 页 | 页面 | `/dashboard/evaluation?evaluationTab=dimensions` | 评测首页(evaluation/index.tsx) | 作为评测首页的第三个 Tab 渲染,提供维度浏览和管理功能 | +| ManageDimension 管理维度弹窗 | 组件 | `pageComponents/dashboard/evaluation/task/ManageDimension.tsx` | 评测任务(task/CreateModal.tsx) | 创建评测任务时选择评估维度的弹窗 | + +## 对外暴露的 Hook/API + +| 导出项 | 类型 | 用途 | 使用者 | 使用场景 | +|--------|------|------|--------|---------| +| `getBuiltinDimensionInfo` | 工具函数 | 根据维度名称查询内置维度的国际化显示信息 | 评测任务(CreateModal、ManageDimension、ConfigParams、EvaluationSummaryCard) | 将内置维度 ID 转换为用户可读的名称和描述 | +| `getBuiltinDimensionNameFromId` | 工具函数 | 从维度 ID 中提取内置维度名称(去除 `builtin_` 前缀) | 评测任务(CreateModal、ManageDimension)、评测维度列表 | 识别和解析内置维度 ID | +| `getMetricList` | API 函数 | 获取评估维度完整列表 | 评测任务(ManageDimension) | 在评测任务中选择维度时获取可选维度列表 | +| `getBuiltinDimensionIdFromName` | 工具函数 | 根据维度名称生成内置维度 ID(添加 `builtin_` 前缀) | 评测任务模块 | 构造内置维度的标准 ID | +| `getBuiltinDimensionEnglishInfo` | 工具函数 | 获取内置维度的英文名称和描述(用于下发给后台) | 评测任务模块 | 创建评测任务时需提交英文标识给后端 | +| `BUILTIN_DIMENSION_MAP` | 常量 | 内置维度信息映射表(含 name / description 的 i18n key) | 评测任务模块 | 配置内置维度的显示信息 | + +## 跨模块依赖详情 + +### 被以下模块依赖 (imported_by) + +| 消费模块 | 引用的具体内容 | 引用方式 | 首次引入 | +|---------|--------------|---------|---------| +| 评测任务 | `getBuiltinDimensionInfo`、`getBuiltinDimensionNameFromId` — 用于内置维度显示和选择 | `import` 静态导入 | 任务创建弹窗 | +| 评测任务 | `getMetricList` — 用于维度选择弹窗中获取可选维度列表 | `import` 静态导入 | 任务管理维度弹窗 | +| 评测首页 | `EvaluationDimensions` — 作为评测首页的维度 Tab 渲染 | `import` 静态导入 + 条件渲染 | 评测首页 | + +### 依赖以下模块 (imports_from) + +| 依赖模块 | 使用的具体能力 | 引用方式 | 关键度 | +|---------|--------------|---------|--------| +| 公共组件库 (@fastgpt/web) | MyBox、MyIcon、MyTag、MyModal、MyIconButton、EmptyTip、Avatar、FormLabel 等通用 UI 组件 | `import` 静态导入 | 核心 | +| 公共 Hooks (@fastgpt/web/hooks) | useRequest、useConfirm、useToast 等通用 Hook | `import` 静态导入 | 核心 | +| 系统 Store | useSystemStore — 获取 LLM/Embedding 模型列表、系统功能开关 | `import` 静态导入 | 核心 | +| 工作台布局 | DashboardContainer — 页面统一布局容器 | `import` 静态导入 | 核心 | +| 全局评测类型 | EvalMetricTypeEnum、EvalMetricDisplayType、EvalMetricSchemaType 等类型定义 | `import` 静态导入 | 核心 | +| 公共 API 请求层 | GET/POST/PUT/DELETE 请求封装 | `import` 静态导入 | 核心 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..8b4f89a99567 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,65 @@ +--- +capability_label: 评测维度 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 模块概览 + +## 1. 模块业务概述 + +评测维度是 FastGPT 评测系统的核心配置模块,用于定义 AI 回答质量的评分标准和评估维度。它作为评测首页的一个 Tab 页面呈现,管理员和团队成员可以在此查看系统内置的评估维度(如答案正确性、答案相似性、忠实度等),并创建和管理自定义评估维度。 + +每个评测维度包含名称、描述和评估提示词(Prompt),维度在创建或被选用于评测任务时,会调用指定的 LLM 模型和/或 Embedding 模型对 AI 生成的回答进行打分,从而量化评估模型表现。 + +本模块是评测首页(Evaluation 页面)的三个 Tab 之一,另外两个 Tab 分别为「评测任务」和「评测数据集」。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 维度列表、创建维度、编辑维度 | 查看全部维度、新建自定义维度、编辑自定义维度、删除自定义维度、测试运行维度 | 完整管理权限 | +| 团队成员 | 维度列表、创建维度、编辑维度 | 查看全部维度、新建自定义维度、编辑自定义维度、删除自定义维度、测试运行维度 | 与管理员权限一致 | + +**权限模型**: 本模块未检测到角色级权限控制。模块访问由系统功能开关 `feConfigs.show_evaluation` 控制——当该开关为 `false` 时页面整体不可见,自动重定向到工作台首页。维度类型(内置/自定义)决定了编辑和删除操作是否可用:内置维度不可编辑和删除,仅自定义维度支持完整 CRUD 操作。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 评测首页 | DashboardContainer 布局容器、FillRowTabs 传入的 Tab 切换栏 | 组件 | +| 系统 Store | useSystemStore 获取 LLM 模型列表和 Embedding 模型列表(用于测试运行) | Store | +| 全局评测类型定义 | EvalMetricDisplayType、EvalMetricTypeEnum、EvalMetricSchemaType 等类型 | 类型 | +| 内置维度工具函数 | getBuiltinDimensionInfo、getBuiltinDimensionNameFromId(用于显示内置维度的国际化名称) | 工具函数 | +| 评测任务(跨模块) | ManageDimension 组件被评测任务创建弹窗引用,用于选择评测维度 | 组件 | + +完整依赖列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ManageDimension 维度选择弹窗 | 评测任务(CreateModal) | 组件 | +| getBuiltinDimensionInfo 内置维度信息查询 | 评测任务(CreateModal、ManageDimension、ConfigParams、EvaluationSummaryCard) | 工具函数 | +| getBuiltinDimensionNameFromId 内置维度 ID 解析 | 评测任务(CreateModal、ManageDimension) | 工具函数 | + +完整共享列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 评测维度列表 | `/dashboard/evaluation?evaluationTab=dimensions` | `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` | 管理员、团队成员 | 维度 Tab 页面,含搜索、新建按钮、内置/自定义维度列表 | +| 创建维度 | `/dashboard/evaluation/dimension/create` | `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` | 管理员、团队成员 | 独立页面,含 EditForm 表单和 TestRun 测试运行弹窗 | +| 编辑维度 | `/dashboard/evaluation/dimension/edit` | `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` | 管理员、团队成员 | 独立页面,回填已有数据,含测试运行功能 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cc1188b6adc9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,34 @@ +--- +capability_label: 应用 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: [admin, collaborator, viewer] +router_paths: [/app/detail] +--- + +# 应用 — 业务流程索引 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看应用详情与配置 | 工作台应用列表 → 点击应用名称 | /app/detail | admin, collaborator, viewer | +| S02 | 编辑应用配置(简易应用/Chat Agent/工作流等) | 应用详情页 → 对应编辑器 Tab | /app/detail | admin, collaborator(需写权限) | +| S03 | 查看应用日志与对话记录 | 应用详情页 → 日志/看板 Tab | /app/detail | admin, collaborator, viewer | +| S04 | 发布应用版本 | 应用详情页 → 发布操作 | /app/detail | admin, collaborator(需写权限) | +| S05 | 删除应用 | 应用详情页 → 更多操作 → 删除 | /app/detail | admin(仅 Owner) | + +## 场景关系 + +- **S01(查看详情)** → **S02(编辑配置)**: 进入详情后默认可切换到编辑 Tab(有写权限时) +- **S01(查看详情)** → **S03(查看日志)**: 无写权限用户进入后自动跳转日志 Tab +- **S02(编辑配置)** → **S04(发布版本)**: 编辑完成后可选择发布新版本 +- 各场景按功能独立执行,详细交互步骤见 [应用详情 — 业务流程详解](./应用详情/业务流程详解.md) + +## 子能力场景索引 + +| 子能力 | 场景数 | 文档链接 | +|--------|--------|---------| +| 应用详情 | 5+(按应用类型区分) | [业务流程索引](./应用详情/业务流程索引.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..ae5f56f05edb --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,28 @@ +--- +capability_label: 应用 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: [admin, collaborator, viewer] +router_paths: [/app/detail] +--- + +# 应用 — 业务流程详解 + +## 子能力业务流程索引 + +本模块为分组节点,不对应具体页面。详细的业务流程由以下子能力承载: + +| 子能力 | 业务描述 | 详细文档 | +|--------|---------|---------| +| 应用详情 | 应用详情查看、按类型动态加载编辑器、配置编辑、版本发布、日志查看、应用删除 | [业务流程详解](./应用详情/业务流程详解.md) | + +### 应用详情入口说明 + +所有用户的"应用"相关操作均通过 **应用详情页**(`/app/detail`)完成。进入该页面后: + +- 系统根据 URL 参数 `appId` 调用 `GET /core/app/detail` 获取应用信息 +- 根据应用类型(简易应用/Chat Agent/工作流/插件/MCP工具集/HTTP工具集/智能客服)动态渲染对应编辑器 +- 根据用户权限自动决定初始 Tab:有写权限默认显示编辑页,无写权限自动跳转至日志页 +- 更多交互细节、API 调用链、分支条件和 Mermaid 流程图,请参见子能力文档 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3b1e824a43b9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,182 @@ +--- +capability_label: "应用详情" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — 业务流程索引 + +## 阶段 A:Tab 信息 + +本模块 Tab 结构通过 `TabEnum` 枚举定义,位于 `projects/app/src/pageComponents/app/detail/context.tsx`: + +- **appEdit** — 应用编辑器(不同应用类型渲染不同编辑器) +- **dashboard** — 数据看板(仅 assistant/chatAgent/workflow 类型可见) +- **logs** — 对话日志 +- **publish** — 发布渠道管理 + +Tab 通过 `RouteTab` 组件根据权限和应用类型动态计算可见性。当前激活 Tab 由 URL 查询参数 `currentTab` 控制。 + +顶层条件渲染基于 `appDetail.type`(AppTypeEnum)分发 7 种编辑器,属于应用类型路由而非 Tab,不按 Tab 拆分子能力。 + +## 业务场景列表 + +### 查看与编辑简单应用 + +- **业务描述**:对简单模式应用进行表单配置编辑,包括提示词、变量、文件选择等参数调整 +- **触发入口**:工作台应用列表 → 点击简单应用卡片 +- **路由路径**:`/app/detail?appId=xxx¤tTab=appEdit` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Edit/SimpleApp/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限(hasWritePer),URL 含有效 appId + +### 查看与编辑对话 Agent + +- **业务描述**:对 Agent 模式应用进行配置编辑,包括技能选择、工具调用、知识库关联等 +- **触发入口**:工作台应用列表 → 点击对话 Agent 应用卡片 +- **路由路径**:`/app/detail?appId=xxx¤tTab=appEdit` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Edit/ChatAgent/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限(hasWritePer) + +### 编辑工作流 + +- **业务描述**:在 ReactFlow 可视化画布上编排工作流节点,支持拖拽、连线、节点配置 +- **触发入口**:工作台应用列表 → 点击工作流应用卡片 +- **路由路径**:`/app/detail?appId=xxx¤tTab=appEdit` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Workflow/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限,工作流以全屏模式渲染(隐藏侧栏) + +### 编辑插件(工作流工具) + +- **业务描述**:将工作流封装为可被其他应用复用的工具插件 +- **触发入口**:工作台工具列表 → 点击工作流工具卡片 +- **路由路径**:`/app/detail?appId=xxx¤tTab=appEdit` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Plugin/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限 + +### 编辑 MCP 工具集 + +- **业务描述**:通过 MCP 协议 URL 连接外部工具服务,获取工具列表并配置 +- **触发入口**:工作台工具列表 → 点击 MCP 工具集卡片 +- **路由路径**:`/app/detail?appId=xxx` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Edit/MCPTools/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限,MCPTools 类型无 Tab 切换 + +### 编辑 HTTP 工具集 + +- **业务描述**:通过 HTTP API URL 定义外部接口,解析 Schema 并配置工具 +- **触发入口**:工作台工具列表 → 点击 HTTP 工具集卡片 +- **路由路径**:`/app/detail?appId=xxx` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Edit/HTTPTools/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限,支持 Curl 命令导入 + +### 编辑智能客服 + +- **业务描述**:配置基于知识库的智能客服助手,管理问答流程 +- **触发入口**:工作台应用列表 → 点击智能客服卡片 +- **路由路径**:`/app/detail?appId=xxx¤tTab=appEdit` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/SmartCustomerService/index.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:用户需有写权限,使用差异快照管理 + +### 查看数据看板 + +- **业务描述**:查看应用的运行统计数据,包括对话量、用户数等指标图表 +- **触发入口**:应用详情页 → 切换到 dashboard Tab +- **路由路径**:`/app/detail?appId=xxx¤tTab=dashboard` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Dashboard/index.tsx` +- **涉及角色**:Owner, 管理员, 日志查看者 +- **前置条件**:需有 hasReadChatLogPer,仅 assistant/chatAgent/workflow 类型支持 + +### 查看对话日志 + +- **业务描述**:查看应用的对话历史记录,支持筛选、搜索、纠错和详情查看 +- **触发入口**:应用详情页 → 切换到 logs Tab;无写权限用户进入页面自动跳转至此 +- **路由路径**:`/app/detail?appId=xxx¤tTab=logs` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Logs/index.tsx` +- **涉及角色**:Owner, 管理员, 日志查看者 +- **前置条件**:需有 hasReadChatLogPer + +### 管理发布渠道 + +- **业务描述**:配置应用的对外发布方式,包括分享链接、API Key、飞书、钉钉等 8 种渠道 +- **触发入口**:应用详情页 → 切换到 publish Tab +- **路由路径**:`/app/detail?appId=xxx¤tTab=publish` +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/Publish/index.tsx` +- **涉及角色**:Owner, 管理员 +- **前置条件**:需有 hasManagePer + +### 编辑应用基本信息 + +- **业务描述**:修改应用的名称、头像、介绍、权限配置、协作者等基本信息 +- **触发入口**:编辑器中 Header 菜单 → 编辑信息 / 团队标签 +- **路由路径**:弹窗形式,不改变路由 +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/InfoModal.tsx` / `TagsEditModal.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:需有写权限 + +### 删除应用 + +- **业务描述**:删除当前应用,需输入应用名称二次确认 +- **触发入口**:编辑器中 Header 菜单 → 删除 +- **路由路径**:弹窗确认后跳转到 `/dashboard/agent` 或 `/dashboard/tool` +- **页面组件位置**:context.tsx 中的 onDelApp 逻辑 +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:需有写权限 + +### 版本历史管理 + +- **业务描述**:查看应用的历史发布版本,支持版本回溯和对比 +- **触发入口**:编辑器中 Header → 版本历史按钮 +- **路由路径**:`PublishHistoriesSlider` 侧栏滑出 +- **页面组件位置**:`projects/app/src/pageComponents/app/detail/PublishHistoriesSlider.tsx` +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:需有写权限 + +### 聊天调试测试 + +- **业务描述**:在编辑器右侧面板中进行实时对话测试,验证应用配置效果 +- **触发入口**:各编辑器右侧 ChatTest 面板 +- **路由路径**:内嵌组件,不改变路由 +- **页面组件位置**:各编辑器 ChatTest 组件(如 `Edit/SimpleApp/ChatTest.tsx` 等) +- **涉及角色**:Owner, 管理员, 编辑者 +- **前置条件**:需有写权限,使用 SSE 流式响应 + +## 场景关系 + +- **进入应用详情** → **编辑应用**:页面加载时默认激活 appEdit Tab(有写权限且非 assistant/chatAgent/workflow 类型时) +- **进入应用详情** → **数据看板**:assistant/chatAgent/workflow 类型且有 hasReadChatLogPer 时默认激活 dashboard Tab +- **编辑应用** → **聊天测试**:编辑器内嵌,可同步调试 +- **编辑应用** → **发布渠道**:编辑完成后可切换到 publish Tab 配置发布 +- **编辑应用** → **版本历史**:保存后可查看历史版本 +- **无写权限** → **对话日志**:自动跳转到 logs Tab + +各场景独立执行,无严格先后依赖。编辑、看板、日志、发布四个 Tab 之间可自由切换。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 编辑简单应用 | appEdit | 工作台→应用卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S02 | 编辑对话 Agent | appEdit | 工作台→应用卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S03 | 编辑工作流 | appEdit | 工作台→应用卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S04 | 编辑插件 | appEdit | 工作台→工具卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S05 | 编辑 MCP 工具集 | — | 工作台→工具卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S06 | 编辑 HTTP 工具集 | — | 工作台→工具卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S07 | 编辑智能客服 | appEdit | 工作台→应用卡片 | /app/detail | Owner, 管理员, 编辑者 | +| S08 | 查看数据看板 | dashboard | 详情页→切换到 dashboard Tab | /app/detail | Owner, 管理员, 日志查看者 | +| S09 | 查看对话日志 | logs | 详情页→切换到 logs Tab | /app/detail | Owner, 管理员, 日志查看者 | +| S10 | 管理发布渠道 | publish | 详情页→切换到 publish Tab | /app/detail | Owner, 管理员 | +| S11 | 编辑应用基本信息 | — | Header→编辑信息/团队标签 | 弹窗 | Owner, 管理员, 编辑者 | +| S12 | 删除应用 | — | Header→删除 | 弹窗→跳回列表 | Owner, 管理员, 编辑者 | +| S13 | 版本历史管理 | — | Header→版本历史按钮 | 侧栏 | Owner, 管理员, 编辑者 | +| S14 | 聊天调试测试 | — | 编辑器右侧面板 | 内嵌 | Owner, 管理员, 编辑者 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..567be124375a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,380 @@ +--- +capability_label: "应用详情" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — 业务流程详解 + +## 页面总览 + +应用详情页是 FastGPT 所有 AI 应用的统一编辑与管理入口。页面加载时根据 URL 中的 `appId` 获取应用完整详情(含类型、权限、配置),随后按应用类型分发到对应的子编辑器。编辑器内通过 Tab 系统切换编辑、看板、日志、发布四个功能视图。 + +## 非 Tab 业务流程 + +### 页面初始化流程 + +> 业务描述:用户从工作台点击应用卡片进入详情页后,系统自动完成的初始化过程。 + +#### 步骤 1:路由进入与参数解析 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工作台点击应用卡片,浏览器导航到 `/app/detail?appId=xxx` | — | URL 含 `currentTab` 参数则优先使用,否则由系统根据应用类型和权限计算默认 Tab | 页面开始加载,显示 Loading 组件 | + +#### 步骤 2:加载应用详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动执行(无需操作) | `GET /api/core/app/detail?appId=xxx`(自动触发,依赖 appId) | appId 无效时 → toast 提示错误并跳转到 `/dashboard/agent` | Loading 组件显示,加载完成后应用信息填充到 AppContext | + +#### 步骤 3:加载最新版本(有写权限时) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动执行(无需操作) | `GET /api/core/app/version/latest?appId=xxx`(依赖 hasWritePer) | 仅当 `hasWritePer` 为 true 时触发;无写权限则跳过 | 静默加载,版本数据存入 appLatestVersion | + +#### 步骤 4:权限分流与 Tab 定位 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作 | — | `!hasWritePer` → 强制跳转到 logs Tab;`hasReadChatLogPer` 且类型为 assistant/chatAgent/workflow → 默认 dashboard Tab;其他 → 默认 appEdit Tab | 根据权限自动定位到对应 Tab,显示对应编辑器 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/app/detail | appId | 解析 appDetail 类型、权限、模块配置 | 根据类型渲染对应编辑器 | +| 版本加载 | GET /core/app/version/latest | appId | 存入 context.appLatestVersion | Header 中显示版本状态 | +| 面包屑 | GET /core/app/folder/path | appId | 解析文件夹路径 | Header 中显示面包屑导航 | + +### 保存/发布应用流程 + +> 业务描述:用户在编辑器中修改配置后,点击保存或发布,系统将当前编辑配置持久化为新版本。 + +#### 步骤 1:点击保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在编辑器 Header 中点击「保存」按钮(或离开页面触发自动保存) | `POST /api/core/app/version/publish?appId=xxx`(通过 onSaveApp) | `!hasWritePer` → 操作被拒绝,不发起请求 | 保存按钮显示 loading 状态 | + +#### 步骤 2:模型引用校验(仅 Owner) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(保存流程自动执行) | — | `isOwner` 为 true 时,提取工作流中所有模型 ID,与系统可用模型列表比对 | 如有无效模型 → toast 黄色警告提示:"模型 {modelIds} 未找到",不会阻止保存 | + +#### 步骤 3:保存完成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待保存完成 | — | 保存成功 → 更新 appDetail 状态 + 刷新最新版本;保存失败 → toast 错误提示 | 保存按钮恢复,版本状态刷新 | + +### 删除应用流程 + +#### 步骤 1:点击删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在编辑器中 Header 菜单点击「删除」 | — | 智能客服/Agent/工作流/简单应用 → 提示"确认删除该应用?删除后无法恢复";工具类应用 → 提示"确认删除该工具?" | 弹出确认弹窗,需输入应用名称进行二次确认 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入应用名称后点击确认删除 | `DELETE /api/core/app/del?appId=xxx` | 删除成功 → 清除 localStorage 中该应用的日志配置缓存,跳转到 `/dashboard/agent`(AI 应用)或 `/dashboard/tool`(工具);删除失败 → toast "删除失败" | 确认按钮显示 loading,成功后跳转回列表页 | + +### 编辑应用基本信息 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 Header 中点击「编辑信息」 | — | — | 弹出 InfoModal 弹窗,显示当前应用名称、头像、介绍、权限配置 | + +#### 步骤 2:上传头像(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头像区域选择本地图片文件 | `POST /api/common/file/presignAvatarPostUrl` | — | 获取预签名 URL 后上传,头像预览更新 | + +#### 步骤 3:保存信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗中的「保存」按钮 | `PUT /api/core/app/update?appId=xxx`(通过 updateAppDetail) | 保存成功 → 弹窗关闭,Header 中应用名称/头像刷新;保存失败 → toast 错误提示 | 保存按钮显示 loading | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 应用名称 | 文本输入 | ✅ | 当前名称 | 长度限制 | 否 | 应用显示名称 | +| 应用头像 | 图片上传 | 否 | 当前头像 | 支持图片格式 | 否 | 通过预签名 URL 上传 | +| 应用介绍 | 多行文本 | 否 | 当前介绍 | — | 否 | 应用描述信息 | +| 权限配置 | 权限选择器 | 否 | 继承父级 | 继承/自定义 | 智能客服类型时隐藏 | 控制应用访问权限 | +| 协作成员 | 成员列表 | 否 | 当前成员 | 含角色选择 | 否 | 管理应用协作者 | + +## 各编辑器业务流程 + +### 编辑简单应用 + +> 业务描述:通过表单配置简单模式的对话应用,包括提示词、变量、文件选择配置等。 + +#### 步骤 1:加载编辑表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载 appEdit Tab | — | appDetail.version 为 'v2' → 直接解析 modules;v1 → 通过 v1Workflow2V2 适配器转换 | 左侧显示编辑表单,右侧显示聊天测试面板 | + +#### 步骤 2:编辑表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改提示词、变量、文件选择等配置项 | — | 编辑过程中每 500ms 自动保存快照到 localStorage | 表单实时响应,Header 中显示快照状态 | + +#### 步骤 3:聊天测试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧 ChatTest 面板发送测试消息 | `POST /api/core/chat/chatTest`(SSE 流式) | 需要 chatId 初始化 → `GET /api/core/chat/init` | 消息以流式方式逐字显示,可实时观察 AI 回复效果 | + +### 编辑工作流 + +> 业务描述:在 ReactFlow 可视化画布上编排工作流节点。 + +#### 步骤 1:加载工作流画布 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载 | — | 工作流类型时侧栏隐藏(全屏模式),节点和边从 appDetail.modules 加载 | ReactFlow 画布渲染,自动定位到节点区域(仅执行一次) | + +#### 步骤 2:编辑节点 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从左侧模板面板拖拽节点到画布 / 拖拽连线 / 右键菜单配置节点 | — | `nodesInteractive` 为 false 时所有编辑操作禁用 | 画布实时更新节点位置和连线 | + +#### 步骤 3:保存工作流 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Header 保存按钮 | `POST /api/core/app/version/publish` | flowData2StoreData 从画布提取节点和边 | 保存按钮 loading,成功后版本状态刷新 | + +### 编辑 MCP 工具集 + +> 业务描述:通过 MCP URL 连接外部工具服务并配置。 + +#### 步骤 1:配置 MCP 连接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入 MCP Server URL 和 Header Secret | — | — | URL 输入框和 Secret 输入框实时更新 | + +#### 步骤 2:获取工具列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击获取工具按钮 | `POST /api/core/app/mcpTools/getTools?appId=xxx` | URL 为空时无法触发 | 工具列表加载,显示工具名称和描述 | + +#### 步骤 3:测试工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择工具后在右侧 ChatTest 面板运行测试 | `POST /api/core/app/mcpTools/runTool?appId=xxx` | 需要选择具体工具(currentTool 不为空) | 显示工具执行结果 | + +#### 步骤 4:保存配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Header 保存按钮 | `POST /api/core/app/mcpTools/update` | — | 保存按钮 loading,成功后 toast 提示 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| MCP URL | 文本输入 | ✅ | — | 有效 URL 格式 | 否 | MCP Server 连接地址 | +| Header Secret | 文本输入 | 否 | — | — | 否 | 请求头密钥 | +| 工具列表 | 多选列表 | 否 | — | 从 URL 获取的工具清单 | 否 | 选择要启用的工具 | + +### 编辑 HTTP 工具集 + +> 业务描述:通过 HTTP API URL 定义外部接口并配置 Schema。 + +#### 步骤 1:配置 HTTP 接口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入 API URL / 粘贴 Curl 命令导入 | `POST /api/core/app/httpTools/getApiSchemaByUrl` | — | Schema 自动解析,填充到编辑表单 | + +#### 步骤 2:编辑 Schema(可选) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 Schema 配置弹窗中手动调整参数 | — | — | SchemaConfigModal 中实时编辑 | + +#### 步骤 3:保存配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击保存按钮 | `POST /api/core/app/httpTools/update` | — | 保存按钮 loading | + +### 查看对话日志 + +> 业务描述:查看和筛选应用的对话历史记录。 + +#### 步骤 1:加载日志 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 切换到 logs Tab(或无写权限时自动进入) | `POST /api/core/app/logs/list` + `GET /api/core/app/logs/getLogKeys` + `POST /api/core/app/logs/getUsers` | — | 日志列表加载,显示筛选器和表格 | + +#### 步骤 2:筛选日志 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择日期范围 / 来源 / 用户进行筛选 | `POST /api/core/app/logs/list`(带筛选参数) | 筛选条件变化时重新请求 | 列表按条件刷新 | + +#### 步骤 3:查看日志详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某条日志 | — | — | 弹出 DetailLogsModal 显示完整对话内容 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/app/logs/list | appId, 分页参数 | 按时间排序 | 日志表格 | +| 日志配置 | GET /core/app/logs/getLogKeys | appId | 解析显示列配置 | 日志表头 | +| 用户筛选 | POST /core/app/logs/getUsers | appId | 去重排序 | 用户下拉选项 | +| 翻页 | POST /core/app/logs/list | page=N | 无额外处理 | 表格第 N 页 | + +### 管理发布渠道 + +> 业务描述:配置应用的对外发布方式。 + +#### 步骤 1:进入发布页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 切换到 publish Tab | — | 需 hasManagePer,否则 Tab 不可见 | 显示发布渠道选择页面 | + +#### 步骤 2:配置 API Key 发布 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 API 发布页面中查看/新建/编辑 API Key | `GET /api/support/openapi/list`(加载)+ `POST /api/support/openapi/create`(新建)+ `PUT /api/support/openapi/update`(编辑)+ `DELETE /api/support/openapi/delete`(删除) | — | API Key 表格显示,编辑弹窗可设置名称、限额、过期时间 | + +#### 步骤 3:配置其他渠道 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择飞书/钉钉/企微/公众号/微信等渠道进行配置 | — | 各渠道有独立的配置页面组件 | 对应渠道配置界面 | + +### 查看数据看板 + +> 业务描述:查看应用的数据统计和图表。 + +#### 步骤 1:加载看板数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 切换到 dashboard Tab | `GET /api/proApi/core/app/logs/getTotalData`(总览)+ `POST /api/proApi/core/app/logs/getChartData`(图表) | 仅 assistant/chatAgent/workflow 类型 + hasReadChatLogPer 可见 | 显示数据卡片和趋势图表 | + +## Mermaid 附录 + +### 页面初始化流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击应用卡片进入详情页 + UI->>API: GET /core/app/detail?appId=xxx + API->>Backend: 查询应用详情 + Backend-->>API: appDetail(含类型/权限/模块) + API-->>UI: 填充 AppContext + + alt 有写权限 + UI->>API: GET /core/app/version/latest + API-->>UI: 版本数据 + end + + alt 无写权限 + UI->>UI: 强制跳转到 logs Tab + else hasReadChatLogPer + (assistant/chatAgent/workflow) + UI->>UI: 默认 dashboard Tab + else 其他 + UI->>UI: 默认 appEdit Tab + end + + UI-->>User: 渲染对应编辑器 +``` + +### 保存应用流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击保存按钮 + UI->>UI: 从画布/表单提取数据 + + alt isOwner + UI->>UI: 提取工作流中所有模型 ID + UI->>UI: 与系统可用模型比对 + Note over UI: 无效模型 → toast 警告 + end + + UI->>API: POST /core/app/version/publish + API->>Backend: 保存版本 + Backend-->>API: 成功 + API-->>UI: 更新 appDetail + 刷新版本 + UI-->>User: 保存完成 +``` + +### 删除应用流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: Header 菜单 → 删除 + UI->>User: 确认弹窗(需输入应用名称) + User->>UI: 输入名称 → 确认删除 + UI->>API: DELETE /core/app/del?appId=xxx + API->>Backend: 删除应用 + Backend-->>API: 成功 + UI->>UI: 清除 localStorage 日志缓存 + UI->>User: 跳转到工作台列表 +``` + +### 聊天测试流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 在 ChatTest 输入消息 + UI->>API: GET /core/chat/init(初始化) + API-->>UI: chatId + 历史记录 + UI->>API: POST /core/chat/chatTest(SSE 流式) + API-->>UI: 流式返回 AI 回复 + UI-->>User: 逐字显示回复内容 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..894c03df0016 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,49 @@ +--- +capability_label: "应用详情" +doc_type: "11" +doc_label: "共享能力清单" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — 共享能力清单 + +## 对外提供的能力 + +| 共享能力 | 消费方 | 能力类型 | 说明 | +|---------|-------|---------|------| +| AppContext(TabEnum/route2Tab/appDetail/onSaveApp/onDelApp) | SimpleEdit, AgentEdit, Workflow, Plugin, MCPTools, HTTPTools, SmartCustomerService | Context | 提供统一的应用状态管理和操作方法 | +| RouteTab 组件 | FormComponent/Header(被 SimpleEdit/AgentEdit/SmartCustomerService 复用) | 组件 | 动态 Tab 切换器,根据权限和类型计算可见 Tab | +| FormComponent/Header | SimpleEdit, AgentEdit, SmartCustomerService | 组件 | 通用编辑器 Header(面包屑+保存+Tab+版本) | +| Logs 日志组件 | Dashboard 数据看板 | 组件 | 日志图表被 Dashboard 复用展示统计数据 | +| useChatTest Hook | SimpleEdit/ChatAgent/SmartCustomerService 的 ChatTest 面板 | Hook | 提供聊天测试 SSE 流式交互能力 | +| useSnapshots Hook | SimpleEdit, AgentEdit | Hook | 编辑器快照管理(localStorage 持久化+版本恢复) | + +## 消费的外部能力 + +| 源模块 | 使用的具体能力 | 能力类型 | 导入路径 | +|--------|---------------|---------|---------| +| 工作流引擎 | ReactFlow 画布、节点类型系统、v1Workflow2V2 适配器、extractWorkflowModelIds | 组件/工具 | `@/web/core/workflow/adapt`, `@fastgpt/global/core/workflow/...` | +| 权限系统 | AppPermission 位掩码校验、ConfigPerModal 协作成员管理、权限常量 | 组件/控制器 | `@fastgpt/global/support/permission/...`, `@/components/support/permission/ConfigPerModal` | +| 发布渠道 | 分享链接、API Key、飞书、钉钉、企微、公众号、微信、Playground(8 种) | 组件 | `@/pageComponents/app/detail/Publish/...` | +| 全局系统 | useSystemStore(模型列表)、DashboardNavbar(侧栏导航)、useSystem(isPc/isMac) | Store/组件 | `@/web/common/system/useSystemStore`, `@/pageComponents/dashboard/Container` | +| 聊天模块 | useChatStore(setAppId/setSource)、streamFetch(SSE 测试)、getInitChatInfo | Store/Hook | `@/web/core/chat/context/useChatStore`, `@/web/core/chat/api` | +| App API 层 | getAppDetailById, putAppById, delAppById, postPublishApp 等 CRUD | API | `@/web/core/app/api`, `@/web/core/app/api/version` | +| 通知系统 | useToast, useConfirm | Hook | `@fastgpt/web/hooks/useToast`, `@fastgpt/web/hooks/useConfirm` | +| 国际化 | useTranslation (next-i18next), serviceSideProps | Hook/工具 | `next-i18next`, `@/web/common/i18n/utils` | +| 请求管理 | useRequest (ahooks 封装) | Hook | `@fastgpt/web/hooks/useRequest` | + +## 内部共享矩阵 + +本模块内部,以下组件在多处复用: + +| 组件 | 复用位置 | 说明 | +|------|---------|------| +| FormComponent/Header | SimpleEdit, AgentEdit, SmartCustomerService | 统一的编辑器顶部栏 | +| RouteTab | FormComponent/Header → 所有编辑器 | Tab 切换逻辑集中管理 | +| Logs/LogChart | Dashboard (数据看板) | 日志图表组件被看板复用 | +| ConversationLogs/LogFilters | Logs, ConversationLogs | 日志筛选器统一组件 | +| ConversationLogs/LogList | Logs, ConversationLogs | 日志列表统一组件 | +| Debugger | Workflow, Plugin | 工作流运行时调试器 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..601c1b345790 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,73 @@ +--- +capability_label: "应用详情" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — 模块概览 + +## 1. 模块业务概述 + +应用详情模块是 FastGPT 平台的核心编辑器入口,为所有类型的 AI 应用提供统一的编辑、调试、发布和监控能力。用户从工作台应用列表点击任意应用后进入此模块,根据应用类型自动加载对应的编辑器界面。 + +本模块支持七种应用类型的编辑: + +- **简单应用**:基于表单配置的对话应用,适合快速搭建 +- **对话 Agent**:基于 Agent 模式的多步骤对话应用,支持技能编排 +- **工作流**:基于可视化画布的工作流编辑器(ReactFlow),支持复杂的节点编排 +- **插件(工作流工具)**:将工作流封装为可复用的工具插件 +- **MCP 工具集**:通过 MCP 协议连接外部工具服务 +- **HTTP 工具集**:通过 HTTP API 定义和调用外部接口 +- **智能客服**:基于知识库的智能客服助手 + +每种编辑器内部通过统一的 Tab 系统(编辑/看板/日志/发布)切换不同功能视图。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| Owner | 编辑/看板/日志/发布 | 全部操作 | 拥有完整权限,额外会在保存时收到模型引用有效性校验警告 | +| 管理员(manage) | 编辑/看板/日志/发布 | 全部操作 | 与 Owner 权限相同,但不触发模型引用警告 | +| 编辑者(write) | 编辑/日志/发布 | 查看看板以外的全部操作 | 无法查看数据看板(Dashboard Tab 不可见) | +| 只读者(read) | 日志 | 仅查看日志 | 进入页面即自动跳转到日志 Tab,编辑和发布 Tab 不可见 | +| 日志查看者(readChatLog) | 看板/日志 | 查看看板和日志 | 可查看数据看板,但无法编辑或发布 | + +**权限模型**:基于位掩码(bitmask)的 RBAC 权限体系。应用权限继承自 `Permission` 基类并扩展 `hasReadChatLogPer`(位值 0b1000)。角色权限映射:读=read(0b0001),写=read+write(0b0011),管理=read+write+manage+readChatLog(0b1111),日志查看=read+readChatLog(0b1001)。 + +权限判断逻辑入口:`projects/app/src/pageComponents/app/detail/context.tsx` 和 `RouteTab.tsx`。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作流引擎 | ReactFlow 画布、节点类型系统、工作流数据转换(v1→v2) | 组件/工具函数 | +| 权限系统 | AppPermission 权限位掩码校验、ConfigPerModal 协作成员管理 | 组件/控制器 | +| 发布渠道 | 分享链接、API Key、飞书、钉钉、企微、公众号、微信等 8 种发布方式 | 组件 | +| 全局系统 | 系统模型列表(useSystemStore)、侧栏导航(DashboardNavbar)、国际化服务 | Store/组件 | +| 聊天上下文 | useChatStore 聊天状态管理、setAppId/setSource 初始化 | Store/Hook | + +完整列表见 [共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| AppContext 上下文(TabEnum、route2Tab) | 所有子编辑器(SimpleEdit/AgentEdit/Workflow 等) | Context | +| RouteTab 组件 | FormComponent/Header(各编辑器共用) | 组件 | +| FormComponent/Header | SimpleEdit、AgentEdit、SmartCustomerService | 组件 | +| Logs 日志组件 | Dashboard 数据看板 | 组件 | +| useChatTest Hook | 所有子编辑器的 ChatTest 面板 | Hook | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 应用详情 | /app/detail | projects/app/src/pages/app/detail/index.tsx | 全部角色 | 含 7 种编辑器分发 + 4 个 Tab(详见 [业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..0c736dc7a956 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: 应用 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: [admin, collaborator, viewer] +router_paths: [/app/detail] +--- + +# 应用 — 模块概览 + +## 1. 模块业务概述 + +应用模块是 FastGPT 的核心业务单元,负责 AI 应用的创建、配置、发布与管理。FastGPT 中的"应用"代表一个完整的 AI Agent 实例,支持多种应用类型:简易应用(对话型)、Chat Agent(带知识库和工作流的智能体)、工作流、插件工具、MCP 工具集、HTTP 工具集以及智能客服(Assistant)。 + +应用模块为不同角色提供差异化的视图:管理员可完整编辑和发布应用,协作者可在授权范围内参与配置,只读用户仅能查看对话日志。应用详情页根据应用类型动态加载对应的编辑器组件,实现从简单问答到复杂工作流编排的全覆盖。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 (Owner) | 应用详情、编辑器、工作流、日志看板、对话记录 | 编辑应用配置、发布应用、删除应用、查看全部日志、管理协作成员 | 拥有完整管理权限,可执行所有操作 | +| 协作者 (Collaborator) | 应用详情、编辑器、工作流(视权限) | 编辑配置、发布(需写权限)、查看日志 | 读写权限由管理员分配控制 | +| 只读用户 (Viewer) | 应用详情(自动跳转日志页) | 仅查看日志 | 无编辑权限,进入详情页自动重定向至日志 Tab | + +**权限模型**: 基于应用级别的 RBAC 权限控制(`permission.hasWritePer` / `hasReadChatLogPer`),页面加载时从 `/core/app/detail` API 返回权限信息,上下文通过 `AppContext` 分发。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台 | 应用创建入口、应用列表管理 | 页面/API | +| 对话 | 应用对话测试(ChatTest 组件)、聊天 Store | 组件/Store | +| 数据集 | 知识库关联选择、快速创建知识库弹窗 | 组件 | +| 技能 | 技能选择器、技能关联配置 | 组件 | +| 配置 | 工具配置与工具选择 | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| AppTree 组件(应用树选择器) | 对话(首页设置、收藏应用设置) | 组件 | +| AppContext(应用详情上下文) | 对话(ChatTest 测试组件) | Context | +| 应用 API 层(getMyApps、getAppDetailById) | 工作台、对话、技能 | API | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 应用详情 | /app/detail | `projects/app/src/pages/app/detail/index.tsx` | admin, collaborator, viewer | 根据应用类型动态渲染编辑器,含多个操作 Tab | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 应用详情 | 应用详情 | [模块概览](./应用详情/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..50f1116a5179 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,105 @@ +--- +capability_label: config +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab 业务流程索引 + +## 文件浏览与搜索 + +- **业务描述**: 用户进入技能详情后,在 Config Tab 中查看技能包的文件目录树,展开/折叠目录,搜索文件名定位目标文件。 +- **触发入口**: 技能列表 → 点击技能名称 → 进入详情页(默认展示 Config Tab) +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/index.tsx` +- **涉及角色**: skill_owner, skill_writer, 无写权限用户 +- **前置条件**: 已登录且有技能查看权限;技能包文件列表加载成功 + +## 文件编辑 + +- **业务描述**: 在文件树中点击文件名打开文件,在 Monaco Editor 中编辑源代码,编辑器自动将修改暂存并在 800ms 无操作后自动保存到后端。 +- **触发入口**: 文件树 → 点击目标文件名 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/components/EditorContent.tsx` +- **涉及角色**: skill_owner, skill_writer +- **前置条件**: 已打开一个文本文件;用户具有写权限(canWrite = true) + +## 文件创建 + +- **业务描述**: 在文件树的指定目录下创建新文件或新目录。输入名称后,后端创建空文件/目录,文件树自动刷新并打开新文件进入编辑状态。 +- **触发入口**: 文件树 → 创建文件/创建文件夹按钮 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/hooks/useFileOperations.tsx` +- **涉及角色**: skill_owner, skill_writer +- **前置条件**: 用户具有写权限;父目录存在 + +## 文件重命名 + +- **业务描述**: 在文件树中选中文件或目录,输入新名称后重命名。重命名前自动刷新该路径下的待保存内容。 +- **触发入口**: 文件树 → 右键菜单/操作按钮 → 重命名 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/hooks/useFileOperations.tsx` +- **涉及角色**: skill_owner, skill_writer +- **前置条件**: 用户具有写权限;目标文件/目录存在 + +## 文件删除 + +- **业务描述**: 删除文件树中的文件或目录。弹出确认对话框,用户确认后执行删除。删除目录时递归删除所有子内容。 +- **触发入口**: 文件树 → 右键菜单/操作按钮 → 删除 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/hooks/useFileOperations.tsx` +- **涉及角色**: skill_owner, skill_writer +- **前置条件**: 用户具有写权限;目标文件/目录存在 + +## 文件上传 + +- **业务描述**: 将本地文件上传到技能包的指定目录。支持批量上传二进制文件(图片、音频、视频等)。 +- **触发入口**: 文件树 → 上传按钮 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/hooks/useFileOperations.tsx` +- **涉及角色**: skill_owner, skill_writer +- **前置条件**: 用户具有写权限;目标目录存在 + +## 源码/预览切换 + +- **业务描述**: 对于 Markdown 和 SVG 文件,可在源码编辑视图与预览视图之间切换。Markdown 文件预览渲染为富文本,SVG 文件预览渲染为矢量图。 +- **触发入口**: 打开 Markdown/SVG 文件 → 编辑器顶部切换按钮 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/components/EditorContent.tsx` +- **涉及角色**: skill_owner, skill_writer, 无写权限用户 +- **前置条件**: 已打开 Markdown 或 SVG 文件 + +## 版本过期检测与刷新 + +- **业务描述**: 系统每 30 秒轮询检查技能包版本是否发生变化(其他副本/会话的编辑)。检测到过期时顶部显示黄色提示条,用户点击刷新按钮重新加载最新文件。 +- **触发入口**: 页面自动轮询 或 窗口获得焦点时 +- **路由路径**: `/skill/detail?skillId={skillId}` +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/index.tsx` +- **涉及角色**: skill_owner, skill_writer, 无写权限用户 +- **前置条件**: 初始版本号已获取 + +--- + +## 场景关系 + +- **文件浏览与搜索** → **文件编辑**:浏览定位文件后点击打开进入编辑 +- **文件浏览与搜索** → **文件创建/重命名/删除/上传**:在目录树中定位目标位置后执行操作 +- **文件编辑** → **源码/预览切换**:编辑 Markdown/SVG 文件时可切换查看方式 +- 各场景之间独立,无严格先后顺序。 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 文件浏览与搜索 | 技能列表→点击技能名称 | /skill/detail | skill_owner, skill_writer, 无写权限用户 | +| S02 | 文件编辑 | 文件树→点击文件名 | /skill/detail | skill_owner, skill_writer | +| S03 | 文件创建 | 文件树→创建按钮 | /skill/detail | skill_owner, skill_writer | +| S04 | 文件重命名 | 文件树→重命名按钮 | /skill/detail | skill_owner, skill_writer | +| S05 | 文件删除 | 文件树→删除按钮 | /skill/detail | skill_owner, skill_writer | +| S06 | 文件上传 | 文件树→上传按钮 | /skill/detail | skill_owner, skill_writer | +| S07 | 源码/预览切换 | 编辑器顶部→切换按钮 | /skill/detail | skill_owner, skill_writer, 无写权限用户 | +| S08 | 版本过期检测与刷新 | 自动轮询/窗口获得焦点 | /skill/detail | 所有角色 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..17756dcaf042 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,374 @@ +--- +capability_label: config +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab 业务流程详解 + +## 页面总览 + +Config Tab 是技能详情页的代码编辑器区域,基于 Monaco Editor 提供完整的技能包源文件管理能力。页面左侧为可搜索的文件目录树,右侧为代码编辑区(顶部文件标签栏 + Monaco Editor + 底部状态),支持文件 CRUD、自动保存、版本过期检测、Markdown/SVG 预览切换等功能。 + +--- + +### 文件浏览与搜索 + +> 用户在文件树中查看技能包目录结构,搜索定位目标文件。 + +#### 步骤 1:加载文件树 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从技能列表点击技能名称进入详情页,默认展示 Config Tab | `POST /core/agentSkills/package/list`(参数 `path: '.', recursive: true`) | 技能包为空时 | 文件树显示加载中状态(MyBox loading),完成后展示完整文件树;技能包为空时展示空状态提示(EmptyTip:"暂无文件") | + +#### 步骤 2:展开目录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件树中的目录节点展开 | 若目录未加载:`POST /core/agentSkills/package/list`(参数 `path: {目录路径}`) | 目录已加载(loaded=true) | 直接展开显示缓存的子节点,无网络请求 | +| — | — | 目录未加载(loaded=false) | 目录节点显示加载 Spinner → API 返回 → 子节点合并到文件树 → 目录展开,Spinner 消失 | +| — | — | API 调用失败 | 弹出错误 Toast:"加载目录失败" + 具体错误信息 | + +#### 步骤 3:搜索文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树顶部搜索框输入关键词 | 无(前端本地 filterTree 过滤) | 搜索词非空 | 文件树仅显示名称包含关键词的文件及包含匹配子节点的目录;不匹配的节点隐藏 | +| 清除搜索框内容 | 无 | 搜索词为空 | 恢复显示完整文件树 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/agentSkills/package/list | skillId, path='.', recursive=true | itemsToTreeNodes 将后端 PackageFileItem[] 转为 TreeNode[] 树 | 文件树初始展示 | +| 展开未加载目录 | POST /core/agentSkills/package/list | skillId, path={目录路径} | updateTreeNode 将子节点合并到现有树 | 目录展开显示子节点 | +| 刷新目录 | 同上 | 同上,或 POST /core/agentSkills/package/list recursive | 替换指定路径的子节点 | 目录内容更新 | + +--- + +### 文件编辑 + +> 用户点击文件树中的文件名,在 Monaco Editor 中查看和编辑源码,修改自动保存。 + +#### 步骤 1:打开文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树中点击文件名 | `POST /core/agentSkills/package/read`(fetch 二进制流) | 文件已在 openedFiles 中打开 | 仅切换 activeFilePath,激活对应 FileTab,编辑器显示缓存内容 | +| — | — | 文件未打开 + 是二进制文件(image/audio/video) | 加载中状态 → 响应转 Blob → URL.createObjectURL 创建本地 URL → 文件标签和编辑器内容区显示二进制预览(图片/音频/视频播放器) | +| — | — | 文件未打开 + 是文本文件 + UTF-8 解码成功 | 加载中状态 → 内容填充到 Monaco Editor → 语法高亮生效 → 文件标签加入列表 | +| — | — | 文件未打开 + 是文本文件 + UTF-8 解码失败 | isUnknown 标记为 true → 编辑器内容区显示"无法预览此文件"提示 | +| — | — | API 调用失败 | 弹出错误 Toast:"打开文件失败" + 具体错误信息 | + +#### 步骤 2:编辑源码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 Monaco Editor 中输入/修改代码 | 无(仅本地状态更新) | canWrite = true | 每输入一个字符 → onChange 触发 → setOpenedFiles 更新对应文件的 content → scheduleAutoSave 调度 | +| — | — | canWrite = false (readOnly) | Monaco Editor 设为只读模式,输入无效 | + +#### 步骤 3:自动保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 停止输入 800ms | `POST /core/agentSkills/package/write`(参数 `skillId, path, content`) | 保存成功 | packageVersionRef 更新为最新版本号 → 无 UI 变化(静默保存) | +| 停止输入 800ms | — | 保存失败 | 控制台输出错误(静默失败,不阻塞用户) | +| 继续输入(未满 800ms) | — | 新输入到来 | 清除之前定时器 → 重新计时 800ms,取 openedFilesRef 中最新的 content | + +#### 步骤 4:切换已打开文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 FileTabs 中点击另一个文件标签 | 无 | 标签存在 | 切换 activeFilePath → useEffect 触发 → editorRef.setValue 将 Monaco Editor 内容替换为新文件内容 | +| 在文件树中点击另一个文件名 | `POST /core/agentSkills/package/read`(如文件未打开) | — | 同"步骤 1:打开文件" | + +#### 步骤 5:关闭文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 FileTab 上的关闭按钮 | 若有待保存内容:`POST /core/agentSkills/package/write` | 已打开多个文件 | 从 openedFiles 移除该文件 → 激活列表末尾的文件;若移除的是当前激活文件且列表非空,激活最后一个 | +| — | — | 仅打开一个文件 | 移除后 openedFiles 为空 → 编辑器区显示空状态提示:"选择一个文件开始编辑" | +| — | — | 文件为二进制且 content 以 blob: 开头 | URL.revokeObjectURL 释放 blob URL 内存 | + +--- + +### 文件创建 + +> 用户在指定目录下创建新文件或新目录。 + +#### 步骤 1:触发创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树工具栏点击"创建文件"或"创建文件夹"按钮 | 无 | canWrite = true | 弹出命名弹窗(MyModal),标题为"输入新文件名"或"输入新文件夹名" | +| — | — | canWrite = false | 创建按钮不可见或置灰 | + +#### 步骤 2:确认名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在弹窗中输入名称后按 Enter 或点击确认 | 创建文件:`POST /core/agentSkills/package/write`(content 为空字符串);创建文件夹:`POST /core/agentSkills/package/mkdir` | 输入为空 | 视为取消,关闭弹窗 | +| — | — | 输入有效 + API 成功 | 弹窗关闭 → 刷新父目录文件树 → 创建文件时自动打开新文件进入编辑 | +| — | — | API 失败 | 弹出错误 Toast:"创建失败" + 具体错误信息 | +| 点击取消或按 Esc | 无 | — | 弹窗关闭,不执行任何操作 | + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入(Input) | 是(trim 后非空) | — | 文件名/目录名格式 | 否 | 空名称视为取消操作 | + +--- + +### 文件重命名 + +> 用户对文件树中的文件或目录进行重命名。 + +#### 步骤 1:触发重命名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树中右键或点击操作菜单选择"重命名" | 无 | canWrite = true | 弹出命名弹窗,标题为"重命名",输入框预填当前名称 | + +#### 步骤 2:确认重命名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称后确认 | 先 `flushPendingForPath`(待保存内容)→ `POST /core/agentSkills/package/rename` | 新名称与旧名称相同 | 视为取消,关闭弹窗 | +| — | — | flushPendingForPath 失败 | 弹出错误 Toast:"刷新失败" + 错误信息 → 终止重命名 | +| — | — | API 成功 | 弹窗关闭 → 从 openedFiles 移除原路径及子路径的所有已打开文件 → 如当前激活文件在重命名范围内,清除 activeFilePath → 刷新父目录 | +| — | — | API 失败 | 弹出错误 Toast:"重命名失败" + 具体错误信息 | + +--- + +### 文件删除 + +> 用户删除文件树中的文件或目录。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树中点击删除按钮 | 无 | canWrite = true | 弹出确认对话框(useConfirm),标题为"确认删除",内容为"确认删除此文件/文件夹?" | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | `POST /core/agentSkills/package/delete`(参数 `recursive: 目录为 true`) | 删除目录(recursive=true) | 递归删除目录下所有文件及子目录 | +| — | — | 删除文件(recursive=false) | 仅删除单个文件 | +| — | — | API 成功 | 取消该路径的待保存操作 → 从 openedFiles 移除该路径及子路径文件 → 更新 activeFilePath → 刷新父目录 | +| — | — | API 失败 | 弹出错误 Toast:"删除失败" + 具体错误信息 | +| 点击取消 | 无 | — | 对话框关闭,不执行删除 | + +--- + +### 文件上传 + +> 用户上传本地文件到技能包指定目录。 + +#### 步骤 1:选择文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文件树工具栏点击"上传"按钮 → 选择本地文件 | 无 | canWrite = true | 打开系统文件选择对话框,支持多选 | + +#### 步骤 2:上传执行 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 确认选择文件 | `POST /core/agentSkills/package/upload`(FormData: skillId, path, file)逐文件循环上传 | 全部文件上传成功 | Toast 成功提示:"上传成功" → 刷新父目录文件树 | +| — | — | 某个文件上传失败 | 弹出错误 Toast:"上传失败" + 具体错误信息 | + +--- + +### 源码/预览切换 + +> 对 Markdown 和 SVG 文件在源码和预览视图之间切换。 + +#### 步骤 1:切换视图 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 打开 Markdown/SVG 文件 → 编辑器顶部显示 FillRowTabs 切换按钮 → 点击"预览"或"源码" | 无 | 当前语言为 markdown 且切换到预览 | Monaco Editor 隐藏 → 显示 Markdown 渲染组件(富文本预览) | +| — | — | 当前语言为 svg 且切换到预览 | Monaco Editor 隐藏 → 显示 data:image/svg+xml URI 的图片预览 | +| — | — | 切换到源码 | 隐藏预览 → 显示 Monaco Editor 源码编辑 | +| — | — | 切换到非 markdown/svg 文件 | viewMode 自动重置为 'source',不显示切换按钮 | + +--- + +### 版本过期检测与刷新 + +> 系统轮询检测技能包是否被其他会话修改,提示用户刷新。 + +#### 步骤 1:初始化版本号 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| Config Tab 加载 | `POST /core/agentSkills/package/checkVersion`(knownVersion=0) | API 成功 | packageVersionRef.current = result.currentVersion | +| — | — | API 失败 | 静默忽略 | + +#### 步骤 2:定时轮询 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 每 30 秒自动触发 或 浏览器窗口获得焦点时 | `POST /core/agentSkills/package/checkVersion`(knownVersion=packageVersionRef.current) | result.changed = true | 文件树上方显示黄色过期提示条(HStack):信息图标 + "当前 Skill 包已被其他副本修改,请点击刷新获取最新版本" + 刷新按钮 | +| — | — | result.changed = false | 无 UI 变化(静默) | +| — | — | API 失败 | 静默忽略(轮询错误非关键) | + +#### 步骤 3:用户刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击过期提示条中的"刷新"按钮 | `POST /core/agentSkills/package/checkVersion`(knownVersion=0)→ `reloadRoot()`→ `refreshOpenedFiles()` | 所有操作成功 | 过期提示条消失 → 文件树重新加载最新目录 → 已打开文件重新加载最新内容 → 所有已修改但未保存的本地更改被覆盖 | + +--- + +### Mermaid 附录 + +#### 文件浏览与搜索 + +```mermaid +sequenceDiagram + participant User + participant UI as 文件树 + participant API as /package/list + + User->>UI: 进入技能详情页 + UI->>API: POST /list (recursive=true) + API-->>UI: 文件列表 + UI-->>User: 展示完整文件树 + + User->>UI: 点击目录展开 + alt 目录未加载 + UI->>API: POST /list (path) + API-->>UI: 子文件列表 + UI-->>User: 展开目录子节点 + else 目录已加载 + UI-->>User: 直接展开缓存子节点 + end + + User->>UI: 输入搜索关键词 + UI-->>User: 前端过滤文件树 +``` + +#### 文件编辑与自动保存 + +```mermaid +sequenceDiagram + participant User + participant UI as 编辑器 + participant API as /package/read, /package/write + + User->>UI: 点击文件名 + UI->>API: POST /read + API-->>UI: 文件内容(二进制流) + UI-->>User: Monaco Editor 展示源码 + + User->>UI: 输入代码 + UI->>UI: 更新本地 content + UI->>UI: scheduleAutoSave 启动 800ms 定时器 + + User->>UI: 停止输入 + UI->>UI: 800ms 到期 + UI->>API: POST /write (path, content) + API-->>UI: { packageVersion } + UI->>UI: 更新 packageVersionRef + + User->>UI: 点击另一个文件标签 + UI->>UI: setValue 切换编辑器内容 +``` + +#### 文件创建/重命名/删除 + +```mermaid +sequenceDiagram + participant User + participant UI as 文件操作区 + participant API as /package/write, /mkdir, /rename, /delete + + Note over User,API: 创建文件 + User->>UI: 点击创建文件按钮 + UI-->>User: 命名弹窗 + User->>UI: 输入名称并确认 + UI->>API: POST /write (content='') + API-->>UI: { packageVersion } + UI->>API: 刷新父目录 + UI-->>User: 文件树更新 + 打开新文件 + + Note over User,API: 重命名 + User->>UI: 点击重命名按钮 + UI-->>User: 命名弹窗(预填当前名) + User->>UI: 修改名称并确认 + UI->>API: POST /write (flush pending) + UI->>API: POST /rename + API-->>UI: { packageVersion } + UI-->>User: 文件树更新 + 关闭相关已打开文件 + + Note over User,API: 删除 + User->>UI: 点击删除按钮 + UI-->>User: 确认对话框 + User->>UI: 点击确认 + UI->>API: POST /delete (recursive) + API-->>UI: { packageVersion } + UI-->>User: 文件树更新 + 关闭相关已打开文件 +``` + +#### 源码/预览切换 + +```mermaid +sequenceDiagram + participant User + participant UI as EditorContent + participant Backend as Monaco / Markdown / Image + + User->>UI: 打开 Markdown/SVG 文件 + UI->>Backend: 初始化 Monaco Editor(源码模式) + UI-->>User: 显示语法高亮源码 + 切换按钮 + + User->>UI: 点击切换到预览 + alt Markdown 文件 + UI->>Backend: Markdown 组件渲染 + Backend-->>User: 富文本预览 + else SVG 文件 + UI->>Backend: MyPhotoView 渲染 data: URI + Backend-->>User: SVG 矢量图预览 + end + + User->>UI: 点击切换到源码 + UI->>Backend: 切换回 Monaco Editor + Backend-->>User: 源码编辑视图 +``` + +#### 版本过期检测与刷新 + +```mermaid +sequenceDiagram + participant UI as Config Tab + participant API as /package/checkVersion + participant User + + UI->>API: POST /checkVersion (knownVersion=0) + API-->>UI: { currentVersion } + UI->>UI: packageVersionRef = currentVersion + + loop 每 30 秒 / 窗口获得焦点 + UI->>API: POST /checkVersion (knownVersion) + alt 版本未变 + API-->>UI: { changed: false } + else 版本已变 + API-->>UI: { changed: true } + UI-->>User: 黄色过期提示条 + User->>UI: 点击刷新按钮 + UI->>API: POST /checkVersion (knownVersion=0) + UI->>API: POST /list (reloadRoot) + UI->>API: POST /read (refreshOpenedFiles) + UI-->>User: 文件树和已打开文件更新为最新版本 + end + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..71f6aa8c5fbb --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,61 @@ +--- +capability_label: config +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab 模块概览 + +## 1. 模块业务概述 + +Config Tab 是技能详情页的核心编辑区域,为用户提供一个基于 Monaco Editor 的**技能代码包在线编辑器**。用户在此 Tab 中可以对技能包内的所有源文件进行浏览、编辑、创建、删除、重命名、上传等操作,实现对技能代码的完整管理。 + +该模块是「技能管理」工作台的基础能力——用户在技能列表中选择一个技能进入详情后,默认展示此编辑器,可直接修改技能包的源代码。模块服务于具备技能开发和维护权限的用户(skill_owner、skill_writer),是整个技能生命周期管理中最关键的操作界面。 + +## 2. 角色差异 + +本模块通过父级 SkillDetailContext 中的 `permission.hasWritePer` 控制编辑权限: + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| skill_owner | Config Tab 完整可见 | 浏览文件树、打开文件、编辑源码、创建/删除/重命名文件及目录、上传文件 | 拥有全部编辑权限 | +| skill_writer | Config Tab 完整可见 | 浏览文件树、打开文件、编辑源码、创建/删除/重命名文件及目录、上传文件 | 与 owner 权限一致 | +| 无写权限用户 | Config Tab 可见 | 浏览文件树、打开文件(只读模式查看) | 编辑器设为只读,无法修改源码;文件创建、删除、重命名、上传按钮不可用 | + +**权限模型**:基于 `SkillPermission` 类,通过 `permission.hasWritePer` 布尔值判断。编辑器中 Monaco Editor 的 `readOnly` 选项绑定 `!canWrite`,文件操作按钮通过 `canWrite` prop 控制可用性。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| @fastgpt/web | MyIcon、MyBox、EmptyTip、MyModal、Markdown、FillRowTabs、MyPhotoView 通用组件 | 组件 | +| @fastgpt/web | useRequest、useToast、useConfirm、useMemoEnhance 通用 Hooks | Hook | +| @fastgpt/global | getErrText 错误文本提取工具 | 工具函数 | +| @fastgpt/global | Skill Package API 类型定义(ListPackageFilesBody、WritePackageFileBody 等) | 类型定义 | +| ahooks | useMount、useLatest 生命周期和引用管理 | Hook | + +完整依赖列表见 [共享能力清单](../../../共享能力清单.md)。 + +## 4. 对外共享 + +本模块为技能详情页的 Tab 子能力,AgentSkillEditor 组件被父级 Content.tsx 直接引用。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| AgentSkillEditor 在线代码编辑器 | 技能详情(Content.tsx) | 组件 | +| useFileTree / useAutoSave / useFileOperations Hooks | 技能详情内部使用 | Hook | +| Skill Package 文件操作 API 层 | useFileTree、useAutoSave、useFileOperations | API | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 技能详情 — Config Tab | /skill/detail | projects/app/src/pageComponents/dashboard/skill/detail/config/AgentSkillEditor/index.tsx | skill_owner, skill_writer | 技能详情默认 Tab,提供代码包的在线编辑能力;与 Preview Tab 并列 | + +## 6. 子能力索引 + +本模块为技能详情页的 Tab 子节点,无下级子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3e2ead84a658 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,63 @@ +--- +capability_label: "预览调试" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — 业务流程索引 + +## 技能调试对话 + +- **业务描述**: 在技能详情页的预览 Tab 中,选择 AI 模型后与技能进行对话测试,实时观察技能响应。首次发送消息时会触发沙箱预热,后续消息直接进行对话。 +- **触发入口**: 技能详情页 → 点击顶部"预览"Tab → 进入预览调试界面 +- **路由路径**: `/skill/detail?skillId={id}`(预览 Tab 内) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/preview/SkillPreview.tsx` +- **涉及角色**: Owner, Collaborator +- **前置条件**: 用户有技能详情页的访问权限(SkillPermission);技能 ID 有效 + +## 切换调试模型 + +- **业务描述**: 在预览界面顶部的模型选择器中,切换不同的 AI 模型来测试技能在不同模型下的表现。 +- **触发入口**: 预览 Tab 顶部 → 点击模型选择器下拉框 +- **路由路径**: `/skill/detail?skillId={id}`(预览 Tab 内) +- **页面组件位置**: AIModelSelector 组件(`@/components/Select/AIModelSelector`) +- **涉及角色**: Owner, Collaborator +- **前置条件**: 系统已加载 LLM 模型列表 + +## 清空对话重新开始 + +- **业务描述**: 点击清空按钮,生成新的聊天会话 ID,清空当前对话记录,重新开始调试。 +- **触发入口**: 预览 Tab 顶部 → 点击清空图标按钮 +- **路由路径**: `/skill/detail?skillId={id}`(预览 Tab 内) +- **页面组件位置**: `projects/app/src/pageComponents/dashboard/skill/detail/preview/SkillPreview.tsx` +- **涉及角色**: Owner, Collaborator +- **前置条件**: 无特殊前置条件 + +## 查看历史对话记录 + +- **业务描述**: 在聊天区域中向上滚动查看之前的对话记录,支持分页加载历史消息。 +- **触发入口**: 预览 Tab → 聊天区域向上滚动 +- **路由路径**: `/skill/detail?skillId={id}`(预览 Tab 内) +- **页面组件位置**: ChatRecordContextProvider(`@/web/core/chat/context/chatRecordContext`)分页加载 +- **涉及角色**: Owner, Collaborator +- **前置条件**: 已有对话历史 + +## 场景关系 + +- **技能调试对话** → **查看历史对话记录**: 发送消息后产生历史记录,向上滚动可查看 +- **切换调试模型** → **技能调试对话**: 模型切换后,后续发送的消息使用新模型 +- **清空对话重新开始** → **技能调试对话**: 清空后进入全新的对话会话 +- 各场景之间无严格先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 技能调试对话 | 技能详情→预览Tab→输入消息发送 | /skill/detail?skillId={id} | Owner, Collaborator | +| S02 | 切换调试模型 | 预览Tab→模型选择器 | /skill/detail?skillId={id} | Owner, Collaborator | +| S03 | 清空对话重新开始 | 预览Tab→清空按钮 | /skill/detail?skillId={id} | Owner, Collaborator | +| S04 | 查看历史对话记录 | 预览Tab→聊天区域向上滚动 | /skill/detail?skillId={id} | Owner, Collaborator | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..7d5741c8ed06 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,227 @@ +--- +capability_label: "预览调试" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — 业务流程详解 + +## 页面总览 + +预览调试是技能详情页的预览 Tab,提供技能测试的交互式聊天环境。用户在此可以选择 AI 模型、发送测试消息、查看流式 AI 回复、浏览历史对话记录。页面由一个顶部操作栏(模型选择器 + 清空按钮)和一个聊天容器组成。每次进入预览 Tab 或清空对话时,系统会在浏览器本地存储中为该技能生成唯一的调试会话 ID(chatId),用于追踪完整的对话记录。 + +--- + +### 发送消息测试技能 + +> 在预览 Tab 中,用户选择 AI 模型后输入消息发送,系统先同步技能沙箱再发起流式对话请求,实时展示 AI 回复。 + +#### 步骤 1:进入预览 Tab,系统初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从技能详情页点击"预览"Tab | `useSystemStore` 获取 LLM 模型列表(Store 读取,非 HTTP) | 模型列表为空时,模型选择器显示空状态 | 页面切换到预览 Tab,顶部显示"Preview"标题,模型选择器默认选中第一个可用模型,聊天区域展示空白状态(无历史消息时)或加载历史记录 | + +**数据加载详情**: + +| 加载阶段 | 数据来源 | 关键参数 | 数据处理 | 渲染结果 | +|---------|---------|---------|---------|---------| +| 模型列表加载 | useSystemStore → llmModelList | 无 | 映射为 `{ label: item.name, value: item.id }` 格式 | 模型选择器下拉列表 | +| chatId 初始化 | localStorage | `skill_debug_chatId_{skillId}` | 已有则复用,无则生成新的 nanoid(24) | 作为后续所有聊天请求的会话标识 | +| 历史记录加载 | GET /api/core/agentSkills/debugSession/records(通过 ChatRecordContextProvider) | skillId, chatId | 双向游标分页 | 聊天区域展示历史消息列表 | + +#### 步骤 2:用户输入消息并发送 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在聊天输入框输入文本 → 点击发送按钮(或按 Enter) | — | 输入为空时发送按钮不可用 | 输入框清空,消息以用户身份出现在聊天区域,发送按钮变为停止按钮(允许中止生成),ChatBox 调用 `onStartChat` 回调 | + +#### 步骤 3:沙箱同步 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动触发,用户无感知) | `flushAllPendingRef.current()` — 先执行编辑器中待保存的内容 | — | 无可见变化(后台操作) | +| (自动触发) | POST `/api/core/agentSkills/syncSandbox` — 同步技能配置到沙箱 | 同步失败:显示 Warning Toast,标题"同步技能沙箱失败",内容为错误详情文本 | 同步中:无加载指示器(静默同步)。失败时:页面顶部出现 Warning Toast 提示,但对话仍继续(不阻断消息发送) | + +**错误处理**: +- 沙箱同步失败不会阻止消息发送,用户仍然可以测试技能(但可能使用的是旧版配置) +- Toast 错误信息来自 `getErrText(err)`,展示服务端返回的错误文本 + +#### 步骤 4:发送消息获取流式回复 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动触发,步骤 3 完成后) | POST `/api/core/agentSkills/debugChat`(流式 SSE) | — | 发起流式请求,聊天区域出现 AI 消息气泡,内容逐字流式展示 | + +**API 调用详情**: + +| 项目 | 说明 | +|------|------| +| 请求方法 | POST(流式 SSE) | +| 请求路径 | `/api/core/agentSkills/debugChat` | +| 关键参数 | `skillId`(技能 ID)、`chatId`(会话 ID)、`messages`(取最近一条用户消息)、`modelId`(当前选中的模型 ID)、`responseChatItemId`(响应消息 ID) | +| 流式处理 | `onMessage: generatingMessage` — 每收到一个 token 即更新聊天区域的 AI 回复内容 | +| 中止控制 | `abortCtrl: controller` — 用户点击停止按钮时中止流式请求 | +| 并行/串行 | 串行:沙箱同步 → debugChat。沙箱同步完成后才发起 debugChat | + +#### 步骤 5:中止生成 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 AI 回复过程中点击停止按钮 | 中止 `abortCtrl` 控制器 | 仅在 AI 正在生成回复时可操作 | 流式输出停止,AI 消息保留已生成的部分内容,停止按钮恢复为发送按钮 | + +#### 步骤 6:重新生成某条消息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在已有 AI 回复上点击"重新生成" | POST `/api/core/agentSkills/debugSession/chatItem/delete` — 删除旧消息 | — | 旧 AI 消息被移除,按步骤 3-4 流程重新发起对话 | +| (自动触发) | 同步骤 3-4 | — | 新的 AI 回复流式展示 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 预览Tab + participant API as 后端API + participant Backend as 沙箱&LLM + + User->>UI: 输入消息并发送 + UI->>API: 保存待编辑内容(flushAllPendingRef) + UI->>API: POST /api/core/agentSkills/syncSandbox + alt 同步失败 + API-->>UI: 错误响应 + UI->>User: 显示 Warning Toast + end + UI->>API: POST /api/core/agentSkills/debugChat (SSE流式) + API->>Backend: 调用 LLM 模型 + Backend-->>API: 流式返回 token + API-->>UI: onMessage 逐字推送 + UI->>User: AI 回复逐字展示 + alt 用户点击停止 + User->>UI: 点击停止按钮 + UI->>API: 中止流式请求 + end +``` + +--- + +### 切换调试模型 + +> 用户在顶部模型选择器中切换 AI 模型,后续发送的消息将使用新选中的模型。 + +#### 步骤 1:打开模型选择器 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部的模型选择器下拉框 | 无(模型列表已在页面加载时获取) | 模型列表为空时下拉无选项 | 下拉框展开,显示所有可用 LLM 模型列表(含模型名称) | + +#### 步骤 2:选择新模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在下拉列表中点击目标模型 | 无 | — | 下拉框关闭,模型选择器显示新选中的模型名称。`selectedModel` 状态更新,后续发送消息时将使用新模型 ID | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 预览Tab + participant Store as useSystemStore + + UI->>Store: 获取 llmModelList + Store-->>UI: 返回模型列表 + User->>UI: 点击模型选择器 + UI->>User: 展开模型下拉列表 + User->>UI: 选择目标模型 + UI->>UI: 更新 selectedModel 状态 + UI->>User: 选择器显示新模型名称 +``` + +--- + +### 清空对话重新开始 + +> 用户点击清空按钮,生成全新的聊天会话 ID,清空当前聊天区域的对话记录。 + +#### 步骤 1:点击清空按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击顶部工具栏右侧的清空图标按钮(垃圾桶图标) | 无 | — | 触发 `restartChat` 回调 | + +#### 步骤 2:生成新会话 ID + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动触发) | 无 | — | 生成新的 `nanoid(24)` 作为 chatId,写入 `localStorage` 的 `skill_debug_chatId_{skillId}` 键,旧 chatId 被覆盖 | + +#### 步骤 3:聊天区域重置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动触发,chatId 变化后) | ChatRecordContextProvider 使用新 chatId 重新加载历史记录 | 新 chatId 无历史记录,返回空列表 | 聊天区域清空,展示空白状态,等待用户输入新消息 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 预览Tab + participant Storage as localStorage + + User->>UI: 点击清空按钮 + UI->>UI: 生成新 nanoid(24) + UI->>Storage: 写入 skill_debug_chatId_{skillId} = 新chatId + UI->>UI: setChatId(新chatId) + UI->>UI: ChatRecordContextProvider 重新加载 + UI->>User: 聊天区域清空,展示空白状态 +``` + +--- + +### 查看历史对话记录 + +> 在聊天区域向上滚动,加载当前调试会话的更早对话记录。 + +#### 步骤 1:向上滚动触发分页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在聊天区域向上滚动到顶部 | POST `/api/core/agentSkills/debugSession/records` | `hasMorePrev: false` 时不触发请求 | 聊天区域顶部出现加载指示器 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /api/core/agentSkills/debugSession/records | skillId, chatId | 按时间倒序排列 | 展示最近的对话消息 | +| 向前翻页 | POST /api/core/agentSkills/debugSession/records | skillId, chatId, prevId(上一页游标) | 插入到消息列表头部 | 更早的消息追加到顶部 | +| 向后翻页 | POST /api/core/agentSkills/debugSession/records | skillId, chatId, nextId(下一页游标) | 追加到消息列表尾部 | 更新的消息追加到底部 | + +- 分页模式:双向游标分页 +- 默认排序:按消息时间顺序排列 +- 每条消息含:角色(用户/AI)、内容、时间戳 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 聊天区域 + participant API as 后端API + + User->>UI: 向上滚动到顶部 + UI->>API: POST /api/core/agentSkills/debugSession/records
(prevId 游标) + alt 有更多历史 + API-->>UI: { list: [...], hasMorePrev: true } + UI->>User: 历史消息追加到聊天顶部 + else 无更多历史 + API-->>UI: { list: [], hasMorePrev: false } + UI->>User: 不再触发加载 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..46f639a78774 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: "预览调试" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — 模块概览 + +## 1. 模块业务概述 + +预览调试模块是技能详情页中的"预览"Tab,为技能开发者提供一个**即时调试和测试技能的交互式聊天环境**。用户可以在不离开技能编辑页面的情况下,选择 AI 模型并与当前技能进行对话测试,实时验证技能的行为是否符合预期。 + +该模块面向**技能开发者(Owner)和协作者(Collaborator)**,是技能开发迭代中的核心验证工具。通过预览调试,开发者可以:选择不同的 AI 模型来测试技能效果、发送消息并观察流式响应、查看历史对话记录、清空对话重新开始测试。 + +本模块是技能详情的 Tab 子模块,与"配置"Tab 并列,配置 Tab 中修改技能内容后可在预览 Tab 即时测试。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| Owner(技能所有者) | 预览 Tab | 选择模型、发送消息、查看历史记录、清空对话 | 完整预览调试权限 | +| Collaborator(协作者) | 预览 Tab | 选择模型、发送消息、查看历史记录、清空对话 | 与 Owner 在预览调试上权限一致 | + +**权限模型**:预览调试功能的访问受技能详情页的整体权限控制(基于 SkillPermission),Tab 层面不做额外权限区分。技能详情可访问即可使用预览调试。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 技能配置(AgentSkillEditor) | 同步技能沙箱(syncSkillSandbox)— 发送消息前先同步最新配置到沙箱 | API | +| 核心聊天(ChatBox) | 复用 ChatBox 聊天容器组件及 ChatContainer 类型定义 | 组件 | +| 聊天上下文(ChatItemContext/ChatRecordContext) | 聊天消息渲染配置、历史记录分页加载 | Context/Hook | +| 系统设置(useSystemStore) | 获取可用 LLM 模型列表供用户选择 | Store | +| AI 模型选择器(AIModelSelector) | 模型选择下拉组件 | 组件 | + +## 4. 对外共享 + +本模块为技能详情页的内部 Tab,不对外提供共享能力。预览调试的聊天测试能力专属于技能详情场景。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 技能详情 — 预览 Tab | /skill/detail?skillId={id} | SkillPreview.tsx(SkillDetailContext 中 TabEnum.preview 切换) | Owner, Collaborator | 含模型选择器、聊天容器,无嵌套 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..dba945a53784 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,60 @@ +--- +capability_label: 技能详情 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: 技能 +roles: [拥有者, 协作者] +router_paths: ["/skill/detail"] +--- + +# 技能详情 — 业务流程索引 + +## 业务场景列表 + +### 查看与编辑技能 + +- **业务描述**: 进入技能详情页后,默认展示 config Tab,用户可通过文件树浏览技能文件,在 Monaco 编辑器中编辑代码。 +- **触发入口**: 技能列表 → 点击技能名称 +- **路由路径**: /skill/detail?skillId={skillId} +- **页面组件位置**: projects/app/src/pages/skill/detail.tsx +- **涉及角色**: 拥有者、协作者(有写权限) +- **前置条件**: 已登录,有技能的访问权限 + +### 调试预览技能 + +- **业务描述**: 切换到 preview Tab,选择 AI 模型后通过对话窗口测试技能效果,支持清空对话重新开始。 +- **触发入口**: 技能详情页 → 点击 preview Tab +- **路由路径**: /skill/detail?skillId={skillId} +- **页面组件位置**: projects/app/src/pages/skill/detail.tsx +- **涉及角色**: 拥有者、协作者 +- **前置条件**: 已登录,有技能的访问权限 + +### 管理技能信息 + +- **业务描述**: 通过技能名旁边的菜单按钮,可编辑技能基本信息(名称、描述、头像)、配置权限、导出配置包或删除技能。 +- **触发入口**: 技能详情页 Header → 技能名右侧菜单按钮 +- **路由路径**: /skill/detail?skillId={skillId} +- **页面组件位置**: projects/app/src/pageComponents/dashboard/skill/detail/Header.tsx +- **涉及角色**: 拥有者(编辑/删除/导出)、协作者(按权限位) +- **前置条件**: 已登录,拥有者或有对应权限 + +## 场景关系 + +- **查看与编辑技能** 与 **调试预览技能** 为同一页面的两个 Tab,可自由切换,无先后依赖。 +- **管理技能信息** 可在任意 Tab 下触发,独立于编辑和预览流程。 +- 各场景独立执行,无严格先后关系。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 技能详情主页 | — | 技能列表→点击技能名称 | /skill/detail?skillId=xxx | 拥有者、协作者 | +| S01-1 |  ├ 编辑技能文件 | config | 详情页→默认Tab | /skill/detail?skillId=xxx | 拥有者、协作者(可写) | +| S01-2 |  ├ 调试预览 | preview | 详情页→切换preview Tab | /skill/detail?skillId=xxx | 拥有者、协作者 | +| S02 | 编辑技能信息 | — | Header→技能名菜单→编辑信息 | /skill/detail?skillId=xxx | 拥有者 | +| S03 | 权限配置 | — | Header→技能名菜单→权限设置 | /skill/detail?skillId=xxx | 拥有者 | +| S04 | 导出技能配置 | — | Header→技能名菜单→导出配置 | /skill/detail?skillId=xxx | 拥有者 | +| S05 | 删除技能 | — | Header→技能名菜单→删除 | /skill/detail?skillId=xxx | 拥有者 | + +> S01-1(config)和 S01-2(preview)的详细交互流程见各自 Tab 子目录的业务流程详解文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..3876b2629558 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,151 @@ +--- +capability_label: 技能详情 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: 技能 +roles: [拥有者, 协作者] +router_paths: ["/skill/detail"] +--- + +# 技能详情 — 业务流程详解 + +## Tab 结构索引 + +本页面包含 2 个 Tab(由 Init 阶段拆分为独立子能力): + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| config | 在线编辑技能文件(文件树浏览、Monaco 代码编辑器、文件增删改、自动保存、版本冲突检测) | 见 [config 子目录](../config/业务流程详解.md) | +| preview | 选择 AI 模型进行技能调试对话(Agent 沙箱、聊天记录、清空重开) | 见 [preview 子目录](../preview/业务流程详解.md) | + +## 公共业务流程 + +### 页面初始化 + +> 用户通过技能列表点击技能名称,进入技能详情页。 + +#### 步骤 1:页面加载与路由解析 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在技能列表中点击某个技能名称 | — | — | 路由跳转至 `/skill/detail?skillId=xxx`,页面开始加载 | + +#### 步骤 2:技能详情数据获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载 | `GET /core/agentSkills/detail?skillId=xxx` | `skillId` 为空时不发起请求 | 加载中状态,请求完成后显示技能名称、头像;Header 和 Content 区域渲染 | + +#### 步骤 3:Context 初始化与权限判定 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动完成) | — | 返回数据中 `permission.isOwner` 判断拥有者;`permission.role` 判断权限位 | Context 中注入 `skillDetail`(含 `permission.hasWritePer`),决定 config Tab 是否可编辑 | + +#### 步骤 4:Tab 默认激活 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动完成) | — | 默认激活 `config` Tab | 显示 AgentSkillEditor 文件编辑器区域;Header 中 Tab 切换器高亮 config | + +### Tab 切换 + +> 用户在 config 和 preview Tab 之间切换。 + +#### 步骤 1:切换 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Header 中 Tab 切换器的 preview Tab | — | — | Content 区域从编辑器视图切换到预览视图;Header 中 Tab 高亮跟随切换 | +| 点击 config Tab | — | — | Content 区域从预览视图切换回编辑器视图 | + +### 编辑技能信息 + +> 用户通过 Header 中技能名的菜单触发编辑信息弹窗。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击技能名右侧图标 → 点击"编辑信息" | — | `skillDetail` 为空时不显示菜单 | 弹出 EditResourceModal,预填充当前头像、名称、简介 | + +#### 步骤 2:提交修改 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/描述/头像后点击确认 | `POST /core/agentSkills/update`(skillId, name, avatar, description) | — | 提交中按钮 loading;成功后关闭弹窗,刷新技能详情数据,显示"编辑成功"提示 | + +### 权限配置 + +> 拥有者通过权限设置弹窗管理技能协作者。 + +#### 步骤 1:打开权限弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击技能名菜单 → 点击"权限设置" | — | 仅拥有者可见此菜单项 | 弹出 ConfigPerModal,显示当前协作者列表和权限配置 | + +#### 步骤 2:管理协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 添加/修改/删除协作者 | `GET /proApi/core/agentSkill/collaborator/list`(加载列表) | `hasParent` 为 true 时显示"恢复权限继承"按钮 | 协作者列表刷新 | +| 修改协作者权限 | `POST /proApi/core/agentSkill/collaborator/update` | — | 提交后刷新列表 | +| 删除协作者 | `DELETE /proApi/core/agentSkill/collaborator/delete` | — | 提交后刷新列表 | +| 转让所有者 | `POST /proApi/core/agentSkills/changeOwner` | — | 提交后刷新技能详情 | +| 恢复权限继承 | `GET /core/agentSkills/resumeInheritPermission` | `hasParent` 为 true 时可用 | 恢复从父级继承的权限 | + +### 导出技能配置 + +> 拥有者导出技能为 ZIP 压缩包。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击技能名菜单 → 点击"导出配置" | 触发浏览器下载 `/api/core/agentSkills/export?skillId=xxx` | — | 浏览器下载 `{技能名}.zip` 文件,显示"导出成功"提示 | + +### 删除技能 + +> 拥有者删除技能。 + +#### 步骤 1:打开删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击技能名菜单 → 点击"删除" | — | `skillDetail.appCount > 0` 时删除按钮置灰并提示"该技能关联了 N 个应用,无法删除" | 弹出删除确认弹窗,要求输入技能名称以确认 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入技能名称后点击确认 | `DELETE /core/agentSkills/delete`(skillId) | 名称不匹配时无法确认 | 删除成功后跳转回技能列表页 `/dashboard/skill`,显示"删除成功"提示 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击技能名称进入详情 + UI->>API: GET /core/agentSkills/detail?skillId=xxx + API->>Backend: 查询技能详情与权限 + Backend-->>API: 返回详情数据 + API-->>UI: 显示 Header + config Tab(默认) + + Note over User,UI: 各 Tab 详细流程见子文档 + + User->>UI: 技能名菜单 → 编辑信息 + UI-->>User: EditResourceModal 弹窗 + User->>UI: 修改并确认 + UI->>API: POST /core/agentSkills/update + API-->>UI: 刷新技能详情,关闭弹窗 + + User->>UI: 技能名菜单 → 删除 + UI-->>User: 删除确认弹窗(需输入技能名) + User->>UI: 输入名称确认 + UI->>API: DELETE /core/agentSkills/delete + API-->>UI: 跳转回技能列表页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f2765deba4a9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: 技能详情 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: 技能 +roles: [拥有者, 协作者] +router_paths: ["/skill/detail"] +--- + +# 技能详情 — 模块概览 + +## 1. 模块业务概述 + +技能详情模块是 Agent Skill 的管理入口,用户在技能列表中点击某个技能后进入此页面。模块提供两个核心能力:**技能文件编辑**(config Tab)和 **技能对话预览**(preview Tab)。 + +该模块主要面向技能拥有者和协作者,允许他们在线编辑技能的代码文件(SKILL.md 及配套资源),并通过内置的调试对话窗口实时验证技能效果。技能详情页面以居中 Tab 切换的方式组织两种工作模式,左侧提供返回按钮和技能信息菜单,方便用户快速回到技能列表或执行编辑信息、权限配置、导出配置、删除等管理操作。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 拥有者 | 技能详情全部 | 编辑文件、预览调试、编辑信息、权限管理、导出/删除技能、转让所有权 | 全部权限 | +| 协作者 | 按权限位决定 | 取决于权限位(可写/只读) | 无权限管理、无转让、无删除权限 | + +**权限模型**: 基于 `SkillPermission` 类的自定义权限位模型,通过 `permission.hasWritePer` 判断是否可写。`isOwner` 标识拥有者身份。权限继承(`inheritPermission`)通过 `parentId` 从父级资源获取。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 技能列表 | 技能详情数据(getSkillDetail API) | API | +| 应用工作台 | 技能关联的应用列表查询 | API | +| 权限管理 | ConfigPerModal 权限配置弹窗 | 组件 | +| 通用组件 | MyTabs、MyIcon、Avatar、MyMenu、EditResourceModal | 组件 | +| AI 模型选择 | AIModelSelector 模型选择器 | 组件 | +| 对话记录 | ChatItemContextProvider、ChatRecordContextProvider | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| getSkillDetail API | 技能列表(间接引用)、应用编辑(SkillSelectModal) | API | +| deleteSkill API | 技能列表(批量删除) | API | +| AgentSkillDetailType 类型 | 全局类型定义、技能列表、应用编辑 | 类型 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 技能详情 | /skill/detail?skillId=xxx | projects/app/src/pages/skill/detail.tsx | 拥有者、协作者 | 含 2 个 Tab(config / preview),详见 [09-业务流程索引](./业务流程索引.md) | + +## 6. 子能力索引 + +本模块有 2 个 Tab 子能力(由 Init 阶段拆分): + +| Tab | 标签 | 文档链接 | +|-----|------|---------| +| config | 技能编辑器(AgentSkillEditor) | [config 子目录](../config/模块概览.md) | +| preview | 技能调试预览(SkillPreview) | [preview 子目录](../preview/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e88fbbe9df0a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,53 @@ +--- +capability_label: 数据集 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — 业务流程索引 + +## 数据集列表 + +- **业务描述**: 浏览和管理所有知识库,支持创建新知识库、创建文件夹、搜索过滤、编辑知识库信息、配置权限和协作者。 +- **触发入口**: 侧边栏「知识库」菜单项 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` +- **涉及角色**: 管理员、协作者、只读用户 +- **前置条件**: 用户已登录且具有团队权限 + +## 数据集详情 + +- **业务描述**: 对单个知识库进行数据管理,包括浏览知识集合、导入多种数据源(文件、链接、API、外部文件、网页、文本等)、搜索测试、管理同义词、查看和修改知识库配置信息。 +- **触发入口**: 数据集列表中点击知识库卡片 +- **路由路径**: `/dataset/detail?datasetId={id}¤tTab={tab}` +- **页面组件位置**: `projects/app/src/pages/dataset/detail/index.tsx` +- **涉及角色**: 管理员、协作者、只读用户(Tab 可见性因角色而异) +- **前置条件**: 存在已创建的数据集 + +## 场景关系 + +- **数据集列表** → **数据集详情**: 用户在列表中点击知识库卡片进入该知识库的详情管理页面 +- **数据集详情** → **数据集列表**: 通过面包屑导航返回列表或上级文件夹 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 浏览数据集列表 | 侧边栏→知识库 | /dataset/list | 所有角色 | +| S02 | 创建知识库 | 列表页→新建按钮 | /dataset/list | 管理员、协作者 | +| S03 | 创建文件夹 | 列表页→新建文件夹 | /dataset/list | 管理员、协作者 | +| S04 | 编辑知识库信息 | 列表页→编辑按钮 | /dataset/list | 管理员、协作者 | +| S05 | 配置知识库权限 | 列表页→权限按钮 | /dataset/list | 管理员 | +| S06 | 浏览数据集详情 | 列表页→点击知识库 | /dataset/detail | 所有角色 | +| S06-1 |  ├ 管理知识集合 Tab | 详情页→集合Tab | /dataset/detail | 所有角色 | +| S06-2 |  ├ 同义词管理 Tab | 详情页→同义词Tab | /dataset/detail | 管理员、协作者 | +| S06-3 |  ├ 搜索测试 Tab | 详情页→搜索测试Tab | /dataset/detail | 所有角色 | +| S06-4 |  ├ 导入数据 Tab | 详情页→导入Tab | /dataset/detail | 管理员、协作者 | +| S06-5 |  ├ 数据卡片 Tab | 详情页→数据卡片Tab | /dataset/detail | 所有角色 | +| S06-6 |  ├ 配置信息 Tab | 详情页→配置Tab | /dataset/detail | 管理员 | + +> 注:Tab 细分场景的详细业务流程见子能力「数据集详情」的文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..18d23528eb3f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,20 @@ +--- +capability_label: 数据集 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — 业务流程详解 + +> 本模块为分组节点,下辖「数据集列表」和「数据集详情」两个子能力。各子能力的详细业务流程见对应子文档。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|-------------|-------------| +| 数据集列表 | 知识库浏览、创建、搜索、编辑和权限管理 | [业务流程索引](./数据集列表/业务流程索引.md) | [业务流程详解](./数据集列表/业务流程详解.md) | +| 数据集详情 | 知识库内部数据管理(集合、导入、测试、同义词、配置) | [业务流程索引](./数据集详情/业务流程索引.md) | [业务流程详解](./数据集详情/业务流程详解.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..a3ccf9874b05 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,45 @@ +--- +capability_label: 数据集 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — 共享能力清单 + +## 对外提供的共享能力 + +| 能力 | 类型 | 提供的文件/接口 | 消费模块 | 说明 | +|------|------|--------------|---------|------| +| 数据集类型标签 | 组件 | `components/core/dataset/DatasetTypeTag.tsx` | 评测数据集模块 | 展示知识库类型(普通、网站、数据库等)的彩色标签 | +| 数据集选择弹窗 | 组件 | `components/core/dataset/SelectModal.tsx` | 应用配置、工作台 | 在其他模块中弹窗选择知识库 | +| 知识库引用展示 | 组件 | `components/core/dataset/QuoteItem.tsx` | 对话模块 | 对话中展示知识库引用的条目 | +| 原始数据来源展示 | 组件 | `components/core/dataset/RawSourceBox.tsx` | 对话模块 | 展示知识库查询的原始数据来源 | +| 数据集 API 层 | API | `web/core/dataset/api.ts` | 评测、对话、应用模块 | 提供数据集 CRUD、集合管理、搜索等 API | +| 数据集详情上下文 | Context | `web/core/dataset/context/datasetPageContext.tsx` | 数据集详情页面 | 提供数据集详情、标签、训练状态等上下文数据 | + +## 依赖的外部能力 + +| 能力 | 类型 | 来源模块/路径 | 说明 | +|------|------|-------------|------| +| DashboardNavbar 侧边栏 | 组件 | `@/pageComponents/dashboard/Container` | 工作台侧边栏导航 | +| BgDecoration 背景装饰 | 组件 | `@/pageComponents/dashboard/BgDecoration` | 工作台背景装饰效果 | +| PageContainer 页面容器 | 组件 | `@/components/PageContainer` | 移动端页面通用容器 | +| ConfigPerModal 权限配置 | 组件 | `@/components/support/permission/ConfigPerModal` | 权限配置弹窗 | +| NextHead 页面头部 | 组件 | `@/components/common/NextHead` | 页面 title 和 meta 设置 | +| FolderPath 面包屑路径 | 组件 | `@/components/common/folder/Path` | 文件夹层级面包屑导航 | +| 通用 UI 组件库 | 组件集 | `@fastgpt/web/components/common/` | MyBox、MyIcon、MyMenu、MyTabs、SearchInput 等 | +| 通用 Hooks | Hook | `@fastgpt/web/hooks/` | useRequest、useToast、useSystem | +| 国际化 (i18n) | 基础设施 | `next-i18next` | 多语言支持(dataset、common、file 等命名空间) | +| Chakra UI | 框架 | `@chakra-ui/react` | Box、Flex、Button、Grid 等基础 UI 组件 | +| 用户/系统 Store | Store | `useUserStore`、`useSystemStore` | 全局用户信息和系统配置 | + +## 跨模块数据流 + +- **数据集列表 → 数据集详情**: 用户选择数据集后通过路由 `datasetId` 参数跳转,详情页通过 `DatasetPageContextProvider` 加载数据集详情数据 +- **数据集 → 权限系统**: 通过 `ConfigPerModal` 组件管理细粒度权限,协作者数据通过独立 API 获取 +- **数据集 → 对话模块**: 对话中使用数据集时,通过 `getQuoteData` API 获取引用数据,通过 `QuoteItem` 和 `RawSourceBox` 组件展示 +- **数据集 → 文件服务**: 通过 `getUploadDatasetFilePresignedUrl` API 获取文件上传预签名 URL diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..9bc41b686352 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,114 @@ +--- +capability_label: 数据集列表 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — 业务流程索引 + +## 业务场景 + +### 浏览数据集列表 + +- **业务描述**: 用户进入数据集管理页面,以卡片网格的形式浏览当前目录下的所有数据集和子文件夹。默认展示根目录下的所有数据集,可点击文件夹进入子目录。 +- **触发入口**: 左侧导航栏 → 数据集菜单项 → 进入列表页 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` +- **涉及角色**: 全部(管理员、协作者、只读用户) +- **前置条件**: 用户已登录,团队已创建 + +### 搜索数据集 + +- **业务描述**: 用户通过搜索框输入关键词,按数据集名称或描述进行模糊匹配搜索。 +- **触发入口**: 数据集列表页顶部搜索框 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` +- **涉及角色**: 全部 +- **前置条件**: 已进入数据集列表页 + +### 创建数据集 + +- **业务描述**: 用户通过"新建数据集"下拉菜单选择数据集类型,创建不同类型的知识库。支持的类型包括:通用数据集、网站数据集、文件数据库(结构化文档)、外部数据库、API文件数据集、飞书数据集、语雀数据集等。 +- **触发入口**: 数据集列表页右上角"新建数据集"按钮 → 下拉菜单 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` → `CreateModal` +- **涉及角色**: 管理员、协作者(需要有数据集创建权限) +- **前置条件**: 用户团队权限 `hasDatasetCreatePer` 为 true;商业版功能(如网站数据集)需 `feConfigs.isPlus` 为 true + +### 创建文件夹 + +- **业务描述**: 用户在数据集列表中创建新文件夹,用于组织和管理数据集。创建时需要填写名称、描述,可上传头像。 +- **触发入口**: 数据集列表页右上角"新建文件夹"按钮 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` → `EditFolderModal` +- **涉及角色**: 管理员、协作者(需要有写入权限) +- **前置条件**: 当前文件夹有写入权限(`folderDetail.permission.hasWritePer`)或用户有全局数据集创建权限 + +### 编辑数据集信息 + +- **业务描述**: 用户编辑已有数据集的名称、描述和头像信息。 +- **触发入口**: 数据集卡片悬浮 → 更多按钮 → 编辑信息;或顶部编辑按钮(针对文件夹) +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/list/NewList.tsx` → `EditFolderModal` / `CreateModal` +- **涉及角色**: 管理员、协作者(需要有管理权限) +- **前置条件**: 数据集卡片有管理权限(`dataset.permission.hasManagePer`) + +### 配置权限 + +- **业务描述**: 用户为数据集或文件夹配置访问权限,包括添加/移除协作者、修改协作者角色、更改所有者、恢复继承权限等。 +- **触发入口**: 数据集卡片悬浮 → 更多按钮 → 权限;或顶部权限按钮(针对文件夹) +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pages/dataset/list/index.tsx` / `projects/app/src/pageComponents/dataset/list/NewList.tsx` → `ConfigPerModal` +- **涉及角色**: 管理员、协作者(需要有管理权限) +- **前置条件**: 当前用户对目标数据集有管理权限 + +### 移动数据集 + +- **业务描述**: 用户通过拖拽将数据集移动到其他文件夹中,或通过菜单选项移动数据集。 +- **触发入口**: 拖拽数据集卡片到目标文件夹;或卡片菜单 → 移动 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/list/NewList.tsx` → `useFolderDrag` +- **涉及角色**: 管理员、协作者(需要有父级数据集的管理权限) +- **前置条件**: 目标父文件夹有管理权限 + +### 删除数据集 + +- **业务描述**: 用户删除数据集或文件夹。删除时需输入数据集名称确认。文件夹删除前检查是否有关联应用。被应用引用的数据集不可删除。 +- **触发入口**: 数据集卡片悬浮 → 更多按钮 → 删除 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/list/NewList.tsx` +- **涉及角色**: 管理员、协作者(需要有管理权限) +- **前置条件**: 数据集有管理权限;数据集未被任何应用引用(appCount 为 0) + +### 导出数据集 + +- **业务描述**: 用户将数据集内容导出为 CSV 文件下载。导出前检查团队导出限额。 +- **触发入口**: 数据集卡片悬浮 → 更多按钮 → 导出 +- **路由路径**: `/dataset/list` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/list/NewList.tsx` +- **涉及角色**: 管理员、协作者(需要有管理权限) +- **前置条件**: 数据集类型为非文件夹、非数据库类型;团队导出限额未超 + +## 场景关系 + +- **浏览数据集列表** → **创建数据集** / **创建文件夹**: 用户需先浏览列表才能决定在何处创建 +- **浏览数据集列表** → **编辑** / **权限配置** / **移动** / **删除** / **导出**: 所有管理操作均以列表浏览为入口 +- **搜索** → **浏览结果**: 搜索是对列表的筛选,结果仍以列表形式呈现 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览数据集列表 | 导航菜单 → 数据集 | /dataset/list | 全部 | +| S02 | 搜索数据集 | 列表页搜索框 | /dataset/list | 全部 | +| S03 | 创建文件夹 | 新建文件夹按钮 | /dataset/list | 管理员、协作者(有写权限) | +| S04 | 创建数据集 | 新建数据集 → 选择类型 | /dataset/list | 管理员、协作者(有创建权限) | +| S05 | 编辑数据集/文件夹 | 卡片菜单 → 编辑 | /dataset/list | 管理员、协作者(有管理权限) | +| S06 | 配置权限 | 卡片菜单 → 权限 | /dataset/list | 管理员、协作者(有管理权限) | +| S07 | 移动数据集 | 拖拽 / 菜单 → 移动 | /dataset/list | 管理员、协作者(有父级管理权限) | +| S08 | 删除数据集 | 卡片菜单 → 删除 | /dataset/list | 管理员、协作者(有管理权限) | +| S09 | 导出数据集 | 卡片菜单 → 导出 | /dataset/list | 管理员、协作者(有管理权限) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..08f5b6cbebae --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,348 @@ +--- +capability_label: 数据集列表 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — 业务流程详解 + +## 页面总览 + +数据集列表页面是知识库管理的核心页面,提供数据集和文件夹的网格卡片展示。页面顶部包含面包屑导航和操作栏(搜索框、新建按钮),主体区域以响应式网格展示数据集卡片,支持无限滚动加载。本页面无 Tab 切换,所有操作通过按钮、菜单和弹窗完成。 + +--- + +## 数据加载详情 + +数据集列表的数据加载流程涉及多个阶段的API调用: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次进入列表 | POST `/core/dataset/list` | `parentId`(根目录为空或从URL参数获取) | 按更新时间降序排列 | 卡片网格展示 | +| 搜索筛选 | POST `/core/dataset/list` | `parentId`, `searchKey` | 按名称和描述模糊匹配,按更新时间降序 | 过滤后的卡片列表 | +| 加载面包屑 | GET `/core/dataset/paths` | `sourceId`(当前 parentId) | 构建从根目录到当前目录的路径数组 | 面包屑导航栏 | +| 获取文件夹详情 | GET `/core/dataset/detail` | `id`(当前 parentId) | 获取文件夹的权限、名称、图标等信息 | 决定操作栏按钮的显示与可用性 | +| 滚动加载更多 | POST `/core/dataset/list` | `parentId`, `pageNum`, `pageSize` | 追加到现有列表 | 追加更多卡片 | +| 获取关联数据 | 聚合查询 | 所有数据集ID | 统计每个数据集的关联应用数、文件数、处理中数量 | 卡片底部显示统计信息 | + +- **分页参数**: 通过 `pageNum` 和 `pageSize` 参数分页,支持无限滚动自动加载 +- **排序规则**: 默认按 `updateTime` 降序排列 +- **筛选条件**: 搜索框支持按名称和描述模糊搜索,最大输入长度30字符 + +--- + +## 浏览数据集列表 + +> **业务描述**: 用户查看当前目录下的数据集和文件夹,以卡片网格形式展示。 + +### 步骤 1:进入列表页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户通过左侧导航栏点击"数据集"菜单项,或直接访问 `/dataset/list` URL | GET `/core/dataset/paths`?sourceId=parentId | 无 parentId 参数时请求根目录路径(返回空数组) | 页面加载,显示加载状态(MyBox isLoading) | + +### 步骤 2:加载数据集列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 自动触发(组件挂载) | POST `/core/dataset/list` (parentId, pageNum, pageSize) | — | 加载遮罩覆盖卡片区域;加载完成后渲染卡片网格。列表为空时显示空状态提示"还没有数据集" | + +### 步骤 3:查看卡片信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户浏览卡片网格,每张卡片展示名称、描述、类型标签、统计数据 | — | — | 文件夹卡片显示文件夹图标,数据集卡片显示自定义头像。卡片底部显示关联应用数、文件数、更新时间和创建人 | + +### 步骤 4:进入子文件夹 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户点击文件夹卡片 | POST `/core/dataset/list` (新 parentId) + GET `/core/dataset/paths` + GET `/core/dataset/detail` | 仅文件夹卡片可点击(`isFolder === true`) | URL更新为 `?parentId=xxx`;搜索框内容清空;面包屑更新;列表重新加载子目录内容 | + +### 步骤 5:滚动加载更多 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户滚动到列表底部 | POST `/core/dataset/list` (parentId, pageNum++, pageSize) | `hasMore` 为 true 时才加载;`isFetchingDatasets` 为 true 时跳过 | 列表底部显示加载旋转器;新数据追加到列表末尾 | + +--- + +## 搜索数据集 + +> **业务描述**: 用户在搜索框中输入关键词,实时过滤数据集列表。 + +### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户在顶部搜索框输入关键词 | POST `/core/dataset/list` (parentId, searchKey) | PC端搜索框在操作栏右侧,移动端搜索框在操作栏下方 | 搜索框内容更新;列表根据关键词过滤,按名称和描述模糊匹配。最大输入30个字符 | + +--- + +## 创建文件夹 + +> **业务描述**: 用户在数据集列表中创建新文件夹。 + +### 步骤 1:打开创建弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户点击操作栏"新建文件夹"按钮 | — | 文件夹详情存在时需 `hasWritePer` 权限;无文件夹详情时需用户团队权限 `hasDatasetCreatePer` | 打开 EditFolderModal 弹窗,表单字段:名称、描述、头像 | + +### 步骤 2:填写并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户填写文件夹名称(必填)、描述(可选)、上传头像(可选),点击确认 | POST `/core/dataset/folder/create` (parentId, name, intro, avatar) | 头像上传需先调用 GET 获取预签名URL | 弹窗关闭;列表和面包屑自动刷新 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | — | 最大长度受后端约束 | ❌ | 文件夹名称 | +| 描述 | 文本输入 | ❌ | — | — | ❌ | 文件夹简介 | +| 头像 | 图片上传 | ❌ | — | 支持自定义上传 | ❌ | 文件夹图标 | + +--- + +## 创建数据集 + +> **业务描述**: 用户通过下拉菜单选择数据集类型,创建不同类型的知识库。 + +### 步骤 1:展开新建菜单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户点击操作栏"新建数据集"按钮(蓝色主按钮,带加号图标) | — | 文件夹详情存在时需 `hasWritePer` 权限;无文件夹详情时需用户团队权限 `hasDatasetCreatePer` | 展开下拉菜单,显示可用数据集类型列表 | + +### 步骤 2:选择数据集类型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户从下拉菜单中选择数据集类型 | — | 网站数据集需 `feConfigs.isPlus` 为 true,否则弹出"商业版功能"提示;直接数据库需 `feConfigs.show_direct_database` 为 true | 关闭下拉菜单,打开 CreateModal 弹窗 | + +**可用数据集类型:** + +| 类型 | 对应常量 | 显示条件 | 说明 | +|------|---------|---------|------| +| 通用数据集 | `DatasetTypeEnum.dataset` | 始终显示 | 支持多种文件上传的通用知识库 | +| 网站数据集 | `DatasetTypeEnum.websiteDataset` | `feConfigs.isPlus` | 自动爬取网站内容构建知识库 | +| 文件数据库 | `DatasetTypeEnum.structureDocument` | `feConfigs.show_direct_database` 或始终显示 | 通过结构化文件导入数据 | +| 直接数据库 | `DatasetTypeEnum.database` | `feConfigs.show_direct_database === true` | 直接连接外部数据库 | +| API文件 | `DatasetTypeEnum.apiDataset` | 始终显示 | 通过 API 接口获取数据 | +| 飞书数据集 | `DatasetTypeEnum.feishu` | `feConfigs.show_dataset_feishu !== false` | 从飞书文档导入 | +| 语雀数据集 | `DatasetTypeEnum.yuque` | `feConfigs.show_dataset_yuque !== false` | 从语雀文档导入 | + +### 步骤 3:填写表单并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户在弹窗中填写数据集信息(名称、类型特有配置等),点击确认创建 | POST `/core/dataset/create` 或 POST `/core/dataset/createWithFiles` | 根据选择的数据集类型调用不同的API | 弹窗关闭;列表自动刷新,新数据集卡片出现在列表中 | + +> 具体的创建表单字段因数据集类型不同而异,详细字段清单见各数据集类型的独立文档(如通用数据集的创建流程)。 + +--- + +## 编辑数据集/文件夹 + +> **业务描述**: 用户修改已有数据集或文件夹的名称、描述和头像。 + +### 步骤 1:打开编辑界面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 文件夹:鼠标悬浮卡片 → 点击右上角更多按钮 → 选择"编辑信息";或点击操作栏"编辑"按钮 | — | 文件夹需有 `hasManagePer` 权限 | 打开 EditFolderModal(文件夹)或 CreateModal(数据集) | +| 数据集:鼠标悬浮卡片 → 类型标签切换为更多按钮 → 点击 → "编辑信息" | — | 数据集需有 `hasManagePer` 权限 | 打开 CreateModal 编辑模式 | + +### 步骤 2:修改并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户修改名称、描述、头像,点击确认 | PUT `/core/dataset/update` (id, name, intro, avatar) | — | 弹窗关闭;列表数据局部更新 | + +--- + +## 配置权限 + +> **业务描述**: 用户为数据集或文件夹配置访问权限。 + +### 步骤 1:打开权限面板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 数据集:卡片菜单 → "权限";文件夹:操作栏"权限"按钮 | GET `/core/dataset/getPermission` + GET 协作者列表 | 需 `hasManagePer` 权限 | 打开 ConfigPerModal 权限配置弹窗 | + +### 步骤 2:管理协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 添加协作者:选择用户/组织/群组 → 设置角色 | POST `/core/dataset/collaborator/update` | — | 协作者列表更新 | +| 移除协作者:点击协作者旁的删除按钮 | DELETE `/core/dataset/collaborator/delete` | — | 协作者从列表中移除 | +| 更改所有者:点击"更改所有者" | POST `/proApi/core/dataset/changeOwner` | — | 所有者变更,列表刷新 | + +### 步骤 3:权限继承操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"恢复继承权限" | PUT `/core/dataset/resumeInheritPermission` | 仅当 `hasParent` 为 true(有父文件夹)时显示此按钮 | 权限重置为继承自父文件夹;列表刷新 | + +**前后置条件**: +- **前置条件**: 用户对当前数据集/文件夹有管理权限 +- **后置影响**: 权限变更后自动刷新列表数据和文件夹详情 +- **失败场景**: 网络异常时 toast 错误提示,权限状态不变 + +--- + +## 移动数据集 + +> **业务描述**: 用户将数据集移动到其他文件夹。 + +### 步骤 1:拖拽移动 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户拖拽数据集卡片到目标文件夹放下 | — | 仅非文件夹的卡片可拖拽(`data-drag-id` 属性仅在 `isFolder` 时设置);目标必须是文件夹;拖拽ID与目标ID不能相同 | 拖拽时目标文件夹边框高亮(`primary.600`);松手后弹出确认弹窗 | + +### 步骤 2:确认移动 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 确认弹窗中点击确认 | PUT `/core/dataset/update` (id, parentId) | — | 弹窗关闭;列表刷新 | + +### 步骤 3:菜单移动 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片菜单 → "移动" | — | 需父级数据集管理权限 | 打开文件夹选择弹窗 → 选择目标文件夹 → 确认 → 调用 PUT `/core/dataset/update` | + +--- + +## 删除数据集/文件夹 + +> **业务描述**: 用户删除数据集或文件夹。 + +### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬浮卡片 → 更多按钮 → "删除" | — | 需 `hasManagePer` 权限;`appCount > 0` 时删除按钮禁用,提示"该数据集正被 N 个应用使用,无法删除" 或文件夹提示"文件夹下有数据集被应用引用" | 打开删除确认弹窗 | + +### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户在确认弹窗中输入数据集名称以确认删除,点击确认 | DELETE `/core/dataset/delete?id={id}` | 需输入正确名称匹配;倒计时5秒 | 弹窗关闭;列表和面包屑刷新。失败时 toast 错误提示 | + +**删除链路详情**: +- **引用检查**: 前端检查 `appCount > 0` 时禁用删除按钮 +- **确认弹窗**: 标题"删除警告",内容为"确认删除该数据集?所有数据将无法恢复"(文件夹)或"确认删除数据集?"(普通数据集);用户需输入数据集完整名称确认 +- **级联影响**: 删除后自动刷新列表和面包屑导航 + +--- + +## 导出数据集 + +> **业务描述**: 用户将数据集内容导出为 CSV 文件。 + +### 步骤 1:触发导出 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 卡片菜单 → "导出" | GET `/api/core/dataset/exportAll?datasetId={id}` | 仅非文件夹、非数据库类型显示导出选项 | 显示全局加载遮罩(`setLoading(true)`),成功提示"开始导出" | + +### 步骤 2:文件下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 自动触发下载 | — | 导出前需调用 `checkTeamExportDatasetLimit` 检查团队导出限额;超限时提示"导出数据集数量已达上限" | 浏览器下载 CSV 文件;加载遮罩关闭 | + +--- + +## Mermaid 附录 + +### 数据集列表加载 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 进入数据集列表页面 + UI->>API: GET /core/dataset/paths (sourceId=parentId) + API->>Backend: 查询父路径链 + Backend-->>API: 路径数组 + UI->>API: POST /core/dataset/list (parentId) + API->>Backend: 查询数据集列表 + Backend-->>API: 数据集列表 + 关联统计 + UI->>UI: 渲染卡片网格 +``` + +### 创建文件夹 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「新建文件夹」 + UI->>UI: 打开 EditFolderModal + User->>UI: 填写名称、描述、头像 + User->>UI: 点击确认 + UI->>API: POST /core/dataset/folder/create (parentId, name, intro, avatar) + API->>Backend: 创建文件夹记录 + Backend-->>API: 成功 + UI->>API: POST /core/dataset/list (刷新列表) + UI->>API: GET /core/dataset/paths (刷新面包屑) + UI->>UI: 列表和面包屑更新 +``` + +### 删除数据集 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 卡片菜单 → 删除 + UI->>UI: 打开确认弹窗(需输入名称验证) + User->>UI: 输入数据集名称,点击确认 + UI->>API: DELETE /core/dataset/delete?id={id} + API->>Backend: 删除数据集 + Backend-->>API: 成功 + UI->>API: POST /core/dataset/list (刷新列表) + UI->>API: GET /core/dataset/paths (刷新面包屑) + UI->>UI: 列表更新,卡片消失 +``` + +### 搜索 + 导出 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 输入搜索关键词 + UI->>API: POST /core/dataset/list (parentId, searchKey) + API->>Backend: 模糊匹配查询 + Backend-->>API: 过滤后的列表 + UI->>UI: 更新卡片网格 + + User->>UI: 卡片菜单 → 导出 + UI->>API: 检查团队导出限额 + UI->>API: GET /api/core/dataset/exportAll?datasetId={id} + API->>Backend: 生成CSV文件 + Backend-->>API: CSV文件流 + UI->>UI: 浏览器下载文件 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..8e350610d8ff --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,19 @@ +--- +capability_label: 数据集列表 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — 共享能力清单 + +## 说明 + +本模块检测到的对外共享能力有限(检测到的跨模块引用数量: 0,共享组件/API/Hook 数量: 0)。 + +数据集列表模块主要是内部消费型页面,其组件和逻辑未作为共享能力被其他模块引用。模块的页面级 Context(DatasetsContext)和列表组件(NewList、NewDatasetCard)均为模块内部使用。 + +跨模块依赖摘要请参见 [08-模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..ca195c094ff0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,64 @@ +--- +capability_label: 数据集列表 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — 模块概览 + +## 1. 模块业务概述 + +数据集列表模块是 FastGPT 知识库管理系统的核心入口,为用户提供知识库(数据集)的集中管理能力。本模块支持以文件夹层级的方式组织和浏览数据集,用户可在此创建、编辑、搜索、移动、导出和删除数据集。 + +本模块是"数据集"父模块下的子能力之一,与"数据集详情"模块配合,组成完整的数据集管理闭环。主要服务于需要构建和管理 AI 知识库的用户(管理员、协作者),以及需要查看和使用已有数据集的只读用户。 + +本模块无子能力,为叶子节点。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 数据集列表 | 创建/编辑/删除/导出/权限配置/移动 | 拥有全部管理权限,可管理所有数据集 | +| 协作者 | 数据集列表 | 创建/编辑/导出(仅自己有权限的数据集) | 可管理有写入权限的数据集,无权限的数据集仅查看 | +| 只读用户 | 数据集列表 | 查看列表、搜索 | 仅可浏览和搜索,无法执行任何修改操作 | + +**权限模型**: 基于资源级权限管理(PerResourceTypeEnum.dataset),支持三层次权限控制: +- 文件夹级别权限(folderDetail.permission):控制当前文件夹的可见与操作 +- 数据集级别权限(dataset.permission):每个数据集独立权限配置 +- 团队级别权限(userInfo.team.permission):控制全局数据集创建权限 + +权限校验通过 `ReadPermissionVal` / `WritePermissionVal` / `ManagePermissionVal` 三级权限位实现,入口位于 `@fastgpt/service/support/permission/dataset/auth`。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 权限管理模块 | `ConfigPerModal` 权限配置弹窗、`DatasetRoleList` 角色列表 | 组件 | +| 用户模块 | `useUserStore` 用户信息、`checkTeamExportDatasetLimit` 导出限额检查 | Store/API | +| 系统配置模块 | `useSystemStore` 全局配置(feConfigs)、`useSystem` PC端检测 | Store/Hook | +| 共享UI组件库 | `MyBox`、`MyIcon`、`SearchInput`、`MultipleMenu`、`EditFolderModal` 等 | 组件 | +| 文件服务 | `getUploadAvatarPresignedUrl` 头像上传预签名URL | API | +| 仪表盘模块 | `DashboardNavbar` 侧边导航栏、`BgDecoration` 背景装饰 | 组件 | + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `DatasetsContext` 数据集上下文 | 数据集详情模块(可能引用) | Context | +| `CreateModal` 创建数据集弹窗 | 仪表盘模块、应用模块(可能引用) | 组件 | + +> 本模块主要为内部消费型页面,对外暴露的共享能力有限。完整依赖分析见 [11-共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集列表 | `/dataset/list` | `projects/app/src/pages/dataset/list/index.tsx` | 全部 | 支持 `?parentId=` 参数导航到子文件夹;无 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ed929efeeb25 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,52 @@ +--- +capability_label: "数据集详情" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: "数据集" +roles: ["所有用户", "管理员"] +router_paths: ["/dataset/detail"] +--- + +# 数据集详情 — 业务流程索引 + +## 业务场景列表 + +### 进入数据集详情 + +- **业务描述**: 从数据集列表点击某个数据集进入详情页,默认展示集合管理 Tab +- **触发入口**: 数据集列表 → 点击数据集名称或操作按钮 +- **路由路径**: `/dataset/detail?datasetId={id}` +- **页面组件位置**: `projects/app/src/pages/dataset/detail/index.tsx` +- **涉及角色**: 所有用户 +- **前置条件**: 用户已登录,且对该数据集有读取权限;若数据集不存在或加载失败,自动跳回数据集列表并提示错误 + +### 数据集详情页面公共功能 + +- **业务描述**: 页面加载时完成数据集信息获取、权限校验、训练队列监控等公共初始化 +- **触发入口**: 页面路由进入时自动触发 +- **路由路径**: `/dataset/detail?datasetId={id}` +- **涉及角色**: 所有用户 +- **前置条件**: 有效的 `datasetId` 查询参数 + +## 场景关系 + +各 Tab 场景并列独立,无严格先后依赖关系。用户可通过 Tab 栏自由切换: +- **集合管理** → 点击集合可进入 **数据卡片** 或 **结构化数据卡片** +- **导入数据** 完成后自动回到 **集合管理** +- 各 Tab 之间无数据流转依赖,每次切换只重新获取该 Tab 所需数据 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 数据集详情页加载 | — | 数据集列表→点击数据集 | `/dataset/detail?datasetId={id}` | 所有用户 | +| S01-1 |  ├ 集合管理 | collectionCard | 详情页→默认Tab | `/dataset/detail?currentTab=collectionCard` | 所有用户 | +| S01-2 |  ├ 同义词管理 | synonym | 详情页→同义词Tab | `/dataset/detail?currentTab=synonym` | 所有用户(数据库/结构化文档类型不显示) | +| S01-3 |  ├ 检索测试 | test | 详情页→检索测试Tab | `/dataset/detail?currentTab=test` | 所有用户 | +| S01-4 |  ├ 数据集配置 | info | 详情页→配置Tab | `/dataset/detail?currentTab=info` | 管理员(仅移动端显示) | +| S01-5 |  ├ 数据卡片 | dataCard | 集合管理→点击集合 | `/dataset/detail?currentTab=dataCard` | 所有用户 | +| S01-6 |  ├ 结构化数据卡片 | fileDataCard | 集合管理→点击结构化集合 | `/dataset/detail?currentTab=fileDataCard` | 所有用户 | +| S01-7 |  ├ 导入数据 | import | 集合管理→点击导入 | `/dataset/detail?currentTab=import` | 有写入权限的用户 | + +> **注意**: 数据卡片(dataCard)和结构化数据卡片(fileDataCard)不在顶部 Tab 栏显示,而是通过点击集合列表中的集合进入。导入数据(import)会全屏覆盖,隐藏 Tab 栏。数据集配置(info)仅在移动端且用户有管理权限时显示。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..2d0423d9cde7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,121 @@ +--- +capability_label: "数据集详情" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: "数据集" +roles: ["所有用户", "管理员"] +router_paths: ["/dataset/detail"] +--- + +# 数据集详情 — 业务流程详解 + +> 本节点为父节点(`has_tab_children: true`),各 Tab 的业务流程详解由对应子能力独立管理。本文档只覆盖页面级公共流程。 + +## 公共业务流程 + +### 页面初始化与数据集加载 + +#### 步骤 1:页面渲染入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从数据集列表点击数据集进入详情页 | GET `/core/dataset/detail?id={id}`(`getDatasetById`) | 无 | 页面显示加载状态,左侧显示工作台导航栏 | + +> 页面采用服务端渲染(`getServerSideProps`),首次加载时通过 SSR 获取 `currentTab`、`datasetId` 和 i18n 翻译文件。客户端渲染阶段通过 `DatasetPageContextProvider` 包裹页面组件。 + +#### 步骤 2:数据集详情加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(`useRequest` 自动触发) | GET `/core/dataset/detail?id={datasetId}`(`loadDatasetDetail`) | 加载失败时 → 跳转回 `/dataset/list` 并提示错误 | 成功:NavBar 显示数据集名称面包屑,Tab 栏出现;失败:跳转到数据集列表页,toast 显示错误信息 | + +#### 步骤 3:训练队列状态获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载时自动查询 | GET `/core/dataset/training/queue?datasetid={id}`(`getDatasetTrainingQueue`) | 无 | 返回 `rebuildingCount`、`trainingCount`、`errorCount`。`errorCount > 0` 时 `datasetHasError = true`,触发集合管理 Tab 的错误重试按钮显示 | + +#### 步骤 4:数据集路径获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载时自动查询 | POST `/core/dataset/paths`(`getDatasetPaths`) | 无 | 构建面包屑路径(`paths`),用于 NavBar 中面包屑导航展示 | + +#### 步骤 5:状态轮询 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 自动轮询(10 秒间隔) | GET `/core/dataset/detail?id={id}` | 仅 `datasetDetail.status === 'syncing'` 或 `'waiting'` 时轮询 | 静默更新数据集状态,不显示 loading | + +#### 步骤 6:PC 端 vs 移动端布局分支 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载时判断设备类型 | 无 | `isPc === true`:PC 端布局 | PC 端显示 `DashboardNavbar` 侧边栏,内容区使用 `Flex` 布局,Tab 栏使用 `MyTabs` 组件 | +| | | `isPc === false`:移动端布局 | 移动端使用 `PageContainer` 包裹,Tab 栏使用 `LightRowTabs` 组件,额外显示"配置"Tab(权限允许时) | + +#### 步骤 7:Tab 切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Tab 标签切换视图 | 无(前端路由 replace) | `currentTab === TabEnum.import` → 隐藏 NavBar(全屏导入) | URL 更新 `currentTab` 参数,对应组件按需加载(`dynamic import`)。`collectionCard` Tab 包裹 `CollectionPageContextProvider` | +| | | `currentTab === TabEnum.dataCard` 或 `fileDataCard` → NavBar 使用数据卡片面包屑 | NavBar 隐藏 Tab 栏,显示 `FolderPath` 面包屑 | +| | | 其他 Tab → NavBar 保持 Tab 栏可见 | 对应 Tab 组件渲染 | + +### 公共错误处理 + +- **数据集 ID 无效**:`loadDatasetDetail` 返回错误 → `router.replace('/dataset/list')`,toast 显示 `getErrText(err) || t('common:load_failed')` +- **权限不足**:数据集 API 返回 403 → 页面可能无法加载完整数据,Tab 按权限条件隐藏 +- **网络异常**:`useRequest` 默认错误处理 + toast 提示 + +## Tab 子能力索引 + +| Tab | 业务描述 | 适用数据集类型 | 详细文档 | +|-----|---------|-------------|---------| +| 集合管理 | 管理数据集的文件和集合,支持文件夹、导入、删除、标签、移动、批量下载等 | 全部类型(不同类型显示不同表头) | [业务流程详解](./集合管理/业务流程详解.md) | +| 同义词管理 | 管理同义词组,提升语义检索的准确率 | 非数据库类型、非结构化文档类型 | [业务流程详解](./同义词管理/业务流程详解.md) | +| 检索测试 | 对数据集执行语义检索测试,验证检索效果 | 全部类型 | [业务流程详解](./检索测试/业务流程详解.md) | +| 数据集配置 | 编辑数据集名称、描述、模型、权限等基础配置 | 全部类型(仅移动端 + 管理员) | [业务流程详解](./数据集配置/业务流程详解.md) | +| 数据卡片 | 查看集合内数据分片详情,支持编辑内容和元数据 | 非数据库、非网站、非 API 类型 | [业务流程详解](./数据卡片/业务流程详解.md) | +| 结构化数据卡片 | 以表格形式查看结构化文档的数据 | 结构化文档类型 | [业务流程详解](./结构化数据卡片/业务流程详解.md) | +| 导入数据 | 向导式多步骤导入数据(文件上传、文本、链接、数据库连接等) | 全部类型(管理员) | [业务流程详解](./导入数据/业务流程详解.md) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Page as 数据集详情页 + participant API as 后端 API + participant Context as DatasetPageContext + + User->>Page: 从数据集列表点击数据集 + Page->>Context: DatasetPageContextProvider 初始化 + Context->>API: GET /core/dataset/detail?id={id} + API-->>Context: 数据集详情 (含权限) + Context->>API: GET /core/dataset/training/queue + API-->>Context: 训练队列状态 + Context->>API: POST /core/dataset/paths + API-->>Context: 面包屑路径 + + alt 加载失败 + Context-->>Page: 错误信息 + Page->>Page: router.replace('/dataset/list') + Page-->>User: Toast: 加载失败 + else 加载成功 + Context-->>Page: 数据集信息已就绪 + Page-->>User: 显示 NavBar + 默认 Tab (集合管理) + + loop 同步/等待中(每10秒轮询) + Context->>API: GET /core/dataset/detail?id={id} + API-->>Context: 最新状态 + end + + User->>Page: 切换 Tab + Page->>Page: router.replace (currentTab 更新) + Page-->>User: 动态加载对应 Tab 组件 + + Note over Page: 各 Tab 详见子文档 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1d3c7a650dea --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,78 @@ +--- +capability_label: 同义词管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=synonym] +--- + +# 同义词管理 — 业务流程索引 + +## 查看同义词文件 + +- **业务描述**: 进入同义词管理 Tab 后,系统自动加载已上传的同义词文件信息(如果有),展示文件名、大小、上传者、上传时间等元信息。 +- **触发入口**: 数据集详情页 → 点击"同义词"Tab +- **路由路径**: `/dataset/detail?currentTab=synonym` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx` +- **涉及角色**: 管理员、成员 +- **前置条件**: 已登录且有该数据集的读取权限 + +## 上传同义词文件 + +- **业务描述**: 用户拖拽或选择一个 xlsx/xls/csv 格式的同义词文件上传到服务器,系统处理后使知识库检索支持同义词映射。支持不超过 50MB 的单文件上传。 +- **触发入口**: 同义词管理页面 → 点击或拖拽文件到上传区域 +- **路由路径**: `/dataset/detail?currentTab=synonym` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx` +- **涉及角色**: 管理员、成员 +- **前置条件**: 已登录且有该数据集的写入权限;当前无正在上传的文件 + +## 下载同义词模板 + +- **业务描述**: 用户下载一个预格式化的 XLSX 模板文件,用于按规范填写同义词映射关系。模板由前端基于 ExcelJS 库实时生成,包含表头和示例数据。 +- **触发入口**: 同义词管理页面空状态 → 点击"下载模板"链接 +- **路由路径**: `/dataset/detail?currentTab=synonym` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx` +- **涉及角色**: 管理员、成员 +- **前置条件**: 页面处于空状态(无已上传文件且无上传中的文件) + +## 下载同义词文件 + +- **业务描述**: 用户下载已上传的同义词文件到本地,用于查看或修改内容。 +- **触发入口**: 同义词管理页面 → 已上传文件信息行 → 点击下载图标 +- **路由路径**: `/dataset/detail?currentTab=synonym` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx` +- **涉及角色**: 管理员、成员 +- **前置条件**: 存在已上传的同义词文件 + +## 删除同义词文件 + +- **业务描述**: 用户删除已上传的同义词文件。删除前弹出二次确认弹窗,确认后执行删除操作。 +- **触发入口**: 同义词管理页面 → 已上传文件信息行 → 点击删除图标 → 确认弹窗中选择"删除" +- **路由路径**: `/dataset/detail?currentTab=synonym` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx` +- **涉及角色**: 管理员、成员 +- **前置条件**: 存在已上传的同义词文件;有该数据集的写入权限 + +--- + +## 场景关系 + +- **查看同义词文件** → **上传同义词文件**:查看页面由空状态切换至文件上传区域 +- **上传同义词文件** → **查看同义词文件**:上传成功后自动刷新显示新文件 +- **删除同义词文件** → **查看同义词文件**:删除成功后回退到空状态 +- **下载同义词模板**:独立操作,不依赖其他场景 +- **下载同义词文件**:依赖于"查看同义词文件"场景(需有已上传文件) + +--- + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看同义词文件 | 数据集详情→同义词Tab | /dataset/detail?currentTab=synonym | 管理员、成员 | +| S02 | 上传同义词文件 | 同义词管理→上传区域 | /dataset/detail?currentTab=synonym | 管理员、成员 | +| S03 | 下载同义词模板 | 同义词管理空状态→下载模板 | /dataset/detail?currentTab=synonym | 管理员、成员 | +| S04 | 下载同义词文件 | 同义词管理→下载按钮 | /dataset/detail?currentTab=synonym | 管理员、成员 | +| S05 | 删除同义词文件 | 同义词管理→删除按钮→确认 | /dataset/detail?currentTab=synonym | 管理员、成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..104553a5a5f9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,279 @@ +--- +capability_label: 同义词管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=synonym] +--- + +# 同义词管理 — 业务流程详解 + +## 页面总览 + +同义词管理 Tab 是数据集详情页的配置子页面。页面核心功能围绕同义词文件的生命周期管理展开:上传映射文件、查看文件信息、下载文件和模板、删除文件。页面根据是否有已上传文件呈现两种不同的 UI 状态(空状态引导上传 / 文件卡片展示)。 + +### 查看同义词文件 + +> 进入同义词管理 Tab 后,系统自动查询已上传的同义词文件。如果存在则展示文件信息卡片,否则展示空状态上传引导页面。 + +#### 步骤 1:页面初始化,加载同义词文件列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从数据集详情页点击"同义词"Tab | GET `/api/core/dataset/synonym/list?datasetId={id}` | 无分支,组件挂载时必定调用 | 显示 MyBox 的加载动画;API 返回后根据结果切换视图 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /api/core/dataset/synonym/list | datasetId | 取 response.files 数组的第一项 | 有文件→展示文件卡片;无文件→展示空状态 | + +- 分页参数:无分页,返回全部同义词文件列表,前端只取第一个 +- 排序规则:后端按 uploadTime 降序排序 + +#### 步骤 2:根据加载结果渲染视图 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待加载完成 | 无 | 加载完成,files 数组为空 | 展示空状态页面:左侧业务说明 + 示例表格(渐变边框)+ "下载模板"链接;右侧 FileSelector 上传区域 | +| 等待加载完成 | 无 | 加载完成,files 数组非空 | 展示文件信息卡片:文件图标 + 文件名 + 大小 + 上传者 + 上传时间 + 操作按钮(下载、删除);卡片下方显示两条使用提示 | + +#### 步骤 3:加载失败处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入 Tab | GET /api/core/dataset/synonym/list | API 返回错误 | toast 错误提示"获取同义词文件失败",显示具体错误描述 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 同义词Tab + participant API as 后端API + participant Backend as 服务端 + + User->>UI: 点击"同义词"Tab + UI->>API: GET /api/core/dataset/synonym/list?datasetId=xxx + API->>Backend: 查询 MongoDatasetSynonym + Backend-->>API: 返回文件列表(按uploadTime降序) + API-->>UI: 返回 files[] 数组 + alt 文件存在 + UI->>User: 展示文件信息卡片(文件名/大小/上传者/时间) + else 文件不存在 + UI->>User: 展示空状态(说明+示例表格+上传区域+下载模板) + end +``` + +--- + +### 上传同义词文件 + +> 用户通过拖拽或点击选择 xlsx/xls/csv 格式的同义词文件上传。上传过程中显示实时文件条目和上传进度,上传成功后自动刷新文件列表,失败则显示错误信息。 + +#### 步骤 1:选择文件并开始上传 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 将文件拖拽到上传区域 / 点击上传区域选择文件 | 无(前端校验) | 文件类型不在 .xlsx/.xls/.csv 范围内 | FileSelector 自动过滤,不触发后续流程 | +| 将文件拖拽到上传区域 / 点击上传区域选择文件 | 无(前端校验) | 文件大小超过 50MB(autoFilterOverSize=true) | FileSelector 自动过滤超限文件,不触发后续流程 | +| 通过校验的文件被选中 | POST `/api/core/dataset/synonym/upload` | 同时只有一个文件(maxCount=1) | 上传区域切换为文件信息展示;上传区域位置显示文件条目(文件名 + 大小 + 上传时间 + 上传中 Spinner);按钮区域显示删除 spinner | + +**表单字段清单**(上传参数): + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| file | 文件选择 | ✅ | — | .xlsx / .xls / .csv,≤ 50MB | — | multipart/form-data 编码 | +| datasetId | 自动填入 | ✅ | 从 Context 获取 | — | ✅ 自动 | 从 DatasetPageContext 获取 | + +**前置条件**:已登录且有该数据集的写入权限 +**后置影响**:上传成功后自动刷新同义词文件列表 + +#### 步骤 2:上传处理与结果反馈 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待上传完成 | 同上(POST) | 上传成功 | toast 成功提示"同义词文件上传成功";自动调用 list API 刷新文件列表;上传状态清除,展示更新后的文件卡片 | +| 等待上传完成 | 同上(POST) | 上传失败 | 文件条目状态变为"失败"(红色警告图标);显示具体错误信息;toast 错误提示"同义词文件上传失败";文件条目保留,用户可看到失败原因 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 文件大小超过 50MB | 文件选择时(前端校验) | FileSelector 自动过滤 | +| 文件类型不支持 | 文件选择时(前端校验) | FileSelector 自动过滤 | +| 后端处理失败 | 上传时(后端校验) | 显示后端返回的具体错误信息(国际化 key) | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 同义词Tab + participant API as 后端API + participant Backend as 服务端 + + User->>UI: 选择/拖拽文件到上传区域 + UI->>UI: 前端校验文件类型(.xlsx/.xls/.csv)和大小(≤50MB) + alt 校验失败 + UI->>User: FileSelector 自动过滤不合规文件 + else 校验通过 + UI->>User: 显示上传中文件条目(带 Spinner) + UI->>API: POST /api/core/dataset/synonym/upload (multipart/form-data) + API->>Backend: 解析同义词文件并存储 + Backend-->>API: 返回 synonymId, fileName, size, uploadTime + alt 上传成功 + API-->>UI: 返回文件信息 + UI->>API: GET /api/core/dataset/synonym/list?datasetId=xxx + API-->>UI: 返回最新文件列表 + UI->>User: toast"同义词文件上传成功", 展示文件卡片 + else 上传失败 + API-->>UI: 返回错误 + UI->>User: toast"同义词文件上传失败", 显示错误信息和警告图标 + end + end +``` + +--- + +### 下载同义词模板 + +> 用户点击"下载模板"链接,前端基于 ExcelJS 库实时生成一个 XLSX 格式的模板文件,包含表头和示例数据,通过 Blob URL 触发浏览器下载。 + +#### 步骤 1:生成并下载模板 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"下载模板"链接 | 无(纯前端操作) | 无分支 | 浏览器弹出文件下载,文件名格式为 `{翻译后的文件名}.xlsx` | + +**前置条件**:页面处于空状态(模板下载链接仅在空状态中可见) + +**模板内容**: +- 第一行:表头——"标准词"列(col 1)+ "同义词"列(col 2-6) +- 第二行:示例数据——"库存单位"→"SKU,库存量单位,存货单位" +- 第三行:示例数据——"应收账款"→"应收款,应收票据,应收账" +- 表头样式:加粗、灰色背景 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 同义词Tab + participant Browser as 浏览器 + + User->>UI: 点击"下载模板"链接 + UI->>UI: ExcelJS 创建工作簿(Workbook) + UI->>UI: 添加表头行(标准词/同义词), 设置加粗+灰背景 + UI->>UI: 添加示例数据行(库存单位/应收账款) + UI->>UI: 生成 xlsx Buffer → 创建 Blob(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) + UI->>Browser: 创建 ObjectURL → 触发 下载 + Browser->>User: 浏览器弹出文件下载({模板文件名}.xlsx) + UI->>Browser: revokeObjectURL 释放 Blob URL +``` + +--- + +### 下载同义词文件 + +> 用户点击已上传文件的下载图标,通过后端 API 下载原始同义词文件到本地。 + +#### 步骤 1:触发文件下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件卡片右侧的下载图标 | GET `/api/core/dataset/synonym/download?id={fileId}`(通过 downloadFetch 工具函数) | 无分支 | toast 提示"同义词文件下载已开始(文件名)";浏览器弹出文件下载 | + +**前置条件**:存在已上传的同义词文件 + +#### 步骤 2:下载失败处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 同上 | GET(同上) | 下载失败 | toast 错误提示"同义词文件下载失败" | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 同义词Tab + participant API as 后端API + participant Backend as 服务端 + + User->>UI: 点击文件卡片右侧下载图标 + UI->>API: GET /api/core/dataset/synonym/download?id={fileId} + API->>Backend: 根据 fileId 查询文件信息 + Backend-->>API: 返回文件数据流 + alt 下载成功 + API-->>UI: 返回文件 Blob + UI->>User: toast"同义词文件下载已开始", 浏览器弹出文件保存对话框 + else 下载失败 + API-->>UI: 返回错误 + UI->>User: toast"同义词文件下载失败" + end +``` + +--- + +### 删除同义词文件 + +> 用户删除已上传的同义词文件。点击删除图标后弹出 Popover 二次确认弹窗,确认后执行删除,成功后页面回退到空状态。 + +#### 步骤 1:点击删除按钮,弹出确认弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击文件卡片右侧删除图标 | 无 | 无分支,必定弹出 | 删除图标左侧弹出 Popover 确认弹窗(宽 318px),显示警告图标 + "确认删除该同义词文件?"(i18n key: dataset:synonym_confirm_delete)+ "取消"按钮 + "删除"按钮(红色) | + +#### 步骤 2:用户选择操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮 / 点击 Popover 外部区域 | 无 | — | Popover 关闭,文件卡片恢复原状 | +| 点击"删除"按钮 | DELETE `/api/core/dataset/synonym/delete?id={fileId}&datasetId={datasetId}` | — | "删除"按钮变为 loading 状态(isLoading);"取消"按钮 disable | + +#### 步骤 3:删除结果处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待删除完成 | DELETE(同上) | 删除成功 | synonymFile 状态清空(设为 null);Popover 关闭;页面回退到空状态(展示上传引导页面);toast 提示"删除成功"(dataset:synonym_delete_success) | +| 等待删除完成 | DELETE(同上) | 删除失败 | Popover 保持打开;useRequest 默认错误处理(toast 显示错误信息) | + +**删除链路详情**: +- **引用检查**:无。删除操作直接删除同义词文件记录 +- **确认弹窗**:Popover 组件(非 Modal),标题文案来自 `dataset:synonym_confirm_delete`,确认按钮为红色(colorScheme="red") +- **批量与单条差异**:仅支持单条删除(每次只展示一个文件) +- **级联影响**:删除后知识库将不再应用该同义词映射规则 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 同义词Tab + participant API as 后端API + participant Backend as 服务端 + + User->>UI: 点击文件卡片删除图标 + UI->>User: 弹出 Popover 确认弹窗("确认删除该同义词文件?") + alt 点击"取消"或点击外部 + User->>UI: 关闭弹窗 + else 点击"删除" + User->>UI: 点击红色"删除"按钮 + UI->>UI: 删除按钮显示 loading,"取消"按钮 disable + UI->>API: DELETE /api/core/dataset/synonym/delete?id={fileId}&datasetId={datasetId} + API->>Backend: 删除 MongoDatasetSynonym 记录 + Backend-->>API: 返回 { success: true } + alt 删除成功 + API-->>UI: 返回成功 + UI->>UI: 清空 synonymFile 状态,关闭 Popover + UI->>User: 页面回退到空状态,toast"删除成功" + else 删除失败 + API-->>UI: 返回错误 + UI->>User: toast 显示错误信息,Popover 保持打开 + end + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..7eeb86b2d792 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 同义词管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=synonym] +--- + +# 同义词管理 — 模块概览 + +## 1. 模块业务概述 + +同义词管理是知识库数据集详情页的一个子功能模块,用于管理知识库的同义词映射文件。用户通过上传包含标准词与同义词映射关系的 Excel/CSV 文件,可以让知识库在检索时将同义词统一映射到标准词,从而提高搜索召回率和准确率。 + +该模块面向知识库的管理员和有编辑权限的成员,帮助他们在不修改知识库原始数据的情况下,灵活配置同义词规则。模块在整体产品中定位为数据集的高级配置能力,位于"数据集详情 → 同义词"Tab 页下。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 同义词管理 Tab | 查看、上传、下载模板、下载文件、删除 | 拥有完整管理权限 | +| 成员 | 同义词管理 Tab | 查看、上传、下载模板、下载文件、删除 | 基于数据集权限体系的同义词操作权限 | + +**权限模型**:基于 FastGPT 数据集权限体系,通过 `authDataset` 中间件校验。列表查询需要 `ReadPermissionVal` 读取权限,上传和删除操作需要 `WritePermissionVal` 写入权限。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集上下文 | DatasetPageContext — 获取 datasetDetail(数据集ID等) | Context | +| 数据集 API 层 | getSynonymFileList / postUploadSynonymFile / deleteSynonymFile | API | +| 文件选择器 | FileSelector 组件 — 文件拖拽/选择上传 | 组件 | +| 用户 Store | useUserStore — 获取当前用户/团队成员名称 | Store | +| 下载工具 | downloadFetch — 浏览器端文件下载 | 工具函数 | + +## 4. 对外共享 + +本模块为数据集详情页的 Tab 子页面,不对外提供共享组件或 API。组件 `SynonymTab` 通过 `dynamic(() => import(...))` 被父页面懒加载,仅供数据集详情页内部使用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 同义词管理 | /dataset/detail?currentTab=synonym | projects/app/src/pageComponents/dataset/detail/Synonym/index.tsx | 管理员、成员 | 数据集详情页的 Tab 子页,含文件上传、下载、删除功能 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1f64a75fd95b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,104 @@ +--- +capability_label: 导入数据 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:41:34Z" +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=import] +--- + +# 导入数据 — 业务流程索引 + +## 业务场景列表 + +### 本地文件导入 + +- **业务描述**: 用户上传本机文件(文档、图片等),将其解析为知识库集合。支持同时上传多个文件,图片和文档文件走不同处理路径。 +- **触发入口**: 数据集详情页 → 导航栏 → 点击"导入数据"Tab → 弹出导入源选择器 → 选择「本地文件」 +- **路由路径**: `/dataset/detail?currentTab=import&source=fileLocal` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/FileLocal.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 用户具有数据集的 `hasManagePer` 权限;数据集已创建 + +### 网页链接导入 + +- **业务描述**: 用户粘贴一个或多个网页链接(URL),系统抓取网页内容并解析为知识库集合。支持对网页指定 CSS 选择器以精准抓取内容区域。 +- **触发入口**: 数据集详情页 → 导入 Tab → 导入源选择器 → 选择「网页链接」 +- **路由路径**: `/dataset/detail?currentTab=import&source=fileLink` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/FileLink.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 链接必须为有效的 HTTP/HTTPS URL + +### 自定义文本导入 + +- **业务描述**: 用户手动输入一段文本内容和名称,将其作为知识库集合。适用于临时性、短文本的知识录入场景。 +- **触发入口**: 数据集详情页 → 导入 Tab → 导入源选择器 → 选择「自定义文本」 +- **路由路径**: `/dataset/detail?currentTab=import&source=fileCustom` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/FileCustomText.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 需输入集合名称和文本内容 + +### 外部文件导入 + +- **业务描述**: 用户提供外部文件的 URL 地址和文件 ID,系统从外部源拉取文件并解析为知识库集合。 +- **触发入口**: 数据集详情页 → 导入 Tab → 导入源选择器 → 选择「外部文件」(需在 URL 参数中指定) +- **路由路径**: `/dataset/detail?currentTab=import&source=externalFile` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/ExternalFile.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 需提供有效的外部文件 URL 和对应的文件 ID + +### API 数据集导入 + +- **业务描述**: 用户从已对接的外部 API 数据源浏览文件目录,选择文件或整个文件夹作为知识库集合。适用于企业已对接的第三方数据平台。 +- **触发入口**: 数据集详情页 → 导入 Tab → API 数据集类型知识库的对应入口 +- **路由路径**: `/dataset/detail?currentTab=import&source=apiDataset` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/APIDataset.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 数据集类型为 API 数据集(`ApiDatasetTypeMap` 中存在);外部 API 服务已配置 + +### 重训练 + +- **业务描述**: 用户选择已有的知识库集合,重新调整数据处理参数(分块策略、索引配置等)后进行重新训练。适用于优化已有集合的数据质量。 +- **触发入口**: 数据集详情页 → 集合卡片 → 操作菜单 → 选择「重训练」 +- **路由路径**: `/dataset/detail?currentTab=import&source=reTraining&collectionId={id}` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/ReTraining.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 存在已有的集合;传入有效的 `collectionId` + +### 图片数据集导入 + +- **业务描述**: 用户上传一批图片,创建图片类型的知识库集合。图片不经文本分块处理,直接以原图形式入库。 +- **触发入口**: 数据集详情页 → 导入 Tab → 图片知识库类型入口 +- **路由路径**: `/dataset/detail?currentTab=import&source=imageDataset` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/ImageDataset.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 支持 `.jpg`、`.jpeg`、`.png` 格式的图片文件 + +### 数据库直连导入 + +- **业务描述**: 用户配置数据库连接信息,选择数据库中的表和字段,将结构化的数据库数据映射为知识库集合。支持 MySQL、PostgreSQL、MSSQL、Oracle。 +- **触发入口**: 数据集详情页 → 导入 Tab → 数据库类型知识库的对应入口 +- **路由路径**: `/dataset/detail?currentTab=import&source=database` +- **页面组件位置**: `pageComponents/dataset/detail/Import/index.tsx` → `diffSource/ConnectDatabaseConfig.tsx` +- **涉及角色**: 管理员、有管理权限的成员 +- **前置条件**: 数据库类型知识库已创建;数据库网络可达 + +## 场景关系 + +- 各导入方式独立执行,无严格先后关系。同一数据集中可混合使用多种导入方式。 +- **重训练**与其它方式存在隐式依赖——需要先通过其它方式导入数据后,才存在可重训练的集合。 +- **数据预览**和**参数配置**是文件导入、链接导入、文本导入、外部文件导入、API 导入、重训练共用的中间步骤,共享 `commonProgress/` 组件。 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 本地文件导入 | 数据集详情→导入Tab→选择「本地文件」 | `/dataset/detail?source=fileLocal` | 管理员, 成员(管理权限) | +| S02 | 网页链接导入 | 数据集详情→导入Tab→选择「网页链接」 | `/dataset/detail?source=fileLink` | 管理员, 成员(管理权限) | +| S03 | 自定义文本导入 | 数据集详情→导入Tab→选择「自定义文本」 | `/dataset/detail?source=fileCustom` | 管理员, 成员(管理权限) | +| S04 | 外部文件导入 | 数据集详情→导入Tab→外部文件入口 | `/dataset/detail?source=externalFile` | 管理员, 成员(管理权限) | +| S05 | API 数据集导入 | 数据集详情→导入Tab→API数据集入口 | `/dataset/detail?source=apiDataset` | 管理员, 成员(管理权限) | +| S06 | 重训练 | 数据集详情→集合卡片→重训练 | `/dataset/detail?source=reTraining&collectionId={id}` | 管理员, 成员(管理权限) | +| S07 | 图片数据集导入 | 数据集详情→导入Tab→图片数据集入口 | `/dataset/detail?source=imageDataset` | 管理员, 成员(管理权限) | +| S08 | 数据库直连导入 | 数据集详情→导入Tab→数据库配置入口 | `/dataset/detail?source=database` | 管理员, 成员(管理权限) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..499deab5f533 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,364 @@ +--- +capability_label: 导入数据 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:41:34Z" +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=import] +--- + +# 导入数据 — 业务流程详解 + +## 页面总览 + +导入数据页面采用动态组件渲染模式,根据 URL 参数 `source` 决定当前激活的导入方式。页面外层由 `DatasetImportContextProvider` 提供共享上下文(导入源类型、步骤状态、表单数据、来源列表),内部通过步进式向导引导用户完成"选择来源 → 参数配置 → 数据预览 → 确认上传"的完整流程。 + +不同导入源的步进步骤数不同(2~4 步),图片导入为简化单页流程,数据库导入有独立的 2 步向导。 + +--- + +## 公共业务流程 + +### 页面初始化 + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 1 | 从数据集详情页点击「导入」Tab,或从集合卡片点击「重训练」 | 无 | 若 URL 中有 `source` 参数则直接进入对应导入模式;否则弹出 `FileSourceSelector` 模态框让用户选择(仅列出 fileLocal/fileLink/fileCustom 三种常用模式) | 导航栏隐去其他 Tab,页面切换为导入布局 | +| 2 | 系统初始化 `DatasetImportContext`,根据 `source` 查询参数确定 `modeSteps` 列表 | 无 | `source` 为 `reTraining` 时额外通过 `getDatasetCollectionById` 获取集合详情 | 根据 `source` 展示对应步骤条(3~4 步),底部显示「下一步」按钮 | +| 3 | 若为编辑模式(`mode=edit`),展示 LightRowTabs 横向步骤切换而非纵向步骤条 | 无 | `mode=edit` 时不可回退到上一步 | 步骤切换 UI 展示为横向 Tab 样式 | + +### 权限校验 + +- 页面加载时通过 `DatasetPageContext` 检查当前用户是否具有 `permission.hasManagePer` 权限 +- 无权限用户无法看到导入入口(NavBar Tab 列表中不包含 import Tab) +- 上下文初始化从 `router.query` 获取 `source`,未传入时默认值为 `fileLocal` + +--- + +## 本地文件导入 + +> 业务描述:上传本机文档或图片文件,将其解析并创建为知识库集合。支持批量上传,图片文件走独立的图片创建流程。 + +### 步骤 1:选择文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 将文件拖拽到上传区域,或点击区域触发系统文件选择器 | 无 | 文件类型不在 `documentAndImageFileType` 允许范围内时,toast 提示不支持的文件格式 | 拖拽区域边框高亮;文件加入待上传列表,每行显示文件名、icon、大小为 0 的进度条 | +| 系统对每个选中文件进行去重(按 name-size-lastModified) | 无 | 若文件已在列表中,跳过不重复添加 | 文件列表动态更新 | +| 点击选择文件区域触发上传(自动) | `getUploadDatasetFilePresignedUrl` (POST) → `putFileToS3` | 图片文件(.jpg/.jpeg/.png):并行调用 `createImageDatasetCollection` (POST);文档文件:通过预签名 URL 上传至 S3 | 每个文件行显示上传进度百分比;上传成功后进度条变绿色;失败显示红色标签及错误信息 | +| 上传完成后点击「下一步」 | 无 | 必须至少有一个文档文件上传成功(有 `dbFileId`)才能进入下一步;纯图片文件不满足条件时按钮置灰 | 按钮显示已选文件总数 | + +**数据加载详情**: +- 文件大小限制:取 `teamPlanStatus.standard.maxUploadFileSize` 与 `feConfigs.uploadFileMaxSize` 的较大值,默认 500MB +- 图片文件通过 `SparkMD5` 流式计算 MD5 用于去重 +- 上传进度通过 `onUploadProgress` 回调实时更新百分比 + +### 步骤 2:参数配置(DataProcess) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 系统展示「数据处理设置」折叠面板 | 无 | `feConfigs.showCustomPdfParse` 为 true 时才显示「文件解析设置」区段 | 展示 PDF 增强解析开关(含价格标签)、数据处理设置面板 | +| 用户勾选「PDF 增强解析」 | 无 | 该开关值被存储到 `localStorage` 的 `dataset_customPdfParse` 键 | 勾选状态更新 | +| 用户配置分块策略、索引设置等 | 无 | 分块参数通过 `CollectionChunkForm` 表单组件配置,支持自动/手动分块模式 | 表单值绑定到 `processParamsForm` | +| 点击「下一步」 | 无 | — | 进入数据预览步骤 | + +**表单字段清单**: +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 训练方式 | 下拉选择 | ✅ | chunk | chunk / qa / auto / image 等 | 决定数据如何处理 | +| 分块触发类型 | 下拉 | ✅ | minSize | minSize / maxSize | 触发分块的条件 | +| 分块触发阈值 | 数字输入 | ✅ | 自动计算值 | — | chunkTriggerMinSize | +| 分块大小 | 数字输入 | ✅ | 自动计算值 | — | chunkSize | +| 分隔符 | 文本输入 | 否 | 空 | 自定义正则 | chunkSplitter | +| 索引大小 | 数字输入 | ✅ | 自动计算值 | — | indexSize | + +### 步骤 3:数据预览(PreviewData) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 左侧文件列表展示所有选中的文件来源 | 无 | — | 文件列表以卡片形式展示,含文件图标和名称 | +| 点击某个文件 | `getPreviewChunks` (POST) — 传入 `datasetId`、`type=fileLocal`、`sourceId`、分块参数 | 文件夹类型项点击时 toast 警告「不支持预览文件夹」 | 右侧区域加载中动画 → 展示分块预览列表,每块显示内容文本;顶部分块总数更新 | +| 分块为空时 | 同上 | `result.total === 0` 时 toast 提示「预览分块为空」 | 右侧区域无内容或显示空状态 | +| 确认预览结果后点击「下一步」 | 无 | — | 进入上传确认步骤 | + +### 步骤 4:上传确认(Upload) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 系统展示所有待创建集合的列表 | 无 | 列出所有 `createStatus === 'waiting'` 的来源 | 表格显示:来源名称 + 状态标签(等待中) | +| 点击「开始上传」 | `postCreateDatasetFileCollection` (POST) — 逐个创建,参数含 `fileId`、分块参数、数据集 ID | 每个来源依次处理:`waiting` → `creating` → `finish`;失败则标记 `errorMsg` 并跳过 | 按钮文字变为「继续上传」;状态标签实时更新(等待中/创建中/已完成/错误) | +| 全部处理完成后点击「完成」 | 无 | 所有 `createStatus !== 'waiting'` 时按钮变为「完成」 | 重定向回数据集详情页 `currentTab=collectionCard` | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + participant S3 as S3存储 + + User->>UI: 拖拽/选择文件 + UI->>API: POST /core/dataset/file/presignDatasetFilePostUrl + API-->>UI: 预签名URL + UI->>S3: PUT 文件(带进度回调) + S3-->>UI: 上传完成 + UI->>User: 显示上传进度→绿色完成 + + User->>UI: 点击「下一步」 + UI->>User: 展示参数配置面板 + User->>UI: 配置分块参数, 点击「下一步」 + + User->>UI: 点击文件预览 + UI->>API: POST /core/dataset/file/getPreviewChunks + API-->>UI: 分块预览数据 + UI->>User: 展示分块内容 + + User->>UI: 点击「开始上传」 + UI->>API: POST /core/dataset/collection/create/fileId (逐个) + API-->>UI: 创建完成 + UI->>User: 重定向回数据集详情 +``` + +--- + +## 网页链接导入 + +> 业务描述:通过粘贴网页 URL 导入在线网页内容,系统自动抓取并解析为知识库集合。 + +### 步骤 1:输入链接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文本框中粘贴一个或多个 URL(每行一个) | 无 | 非 HTTP/HTTPS 开头的行被忽略 | 有效链接解析为标签展示,每个标签可单独删除 | +| 可选:在 CSS 选择器输入框中填写内容定位规则 | 无 | — | 选择器值存入 `webSelector` 字段 | +| 点击「下一步」 | 无 | 至少要有一个有效链接 | 进入参数配置步骤 | + +### 步骤 2~4:参数配置 → 预览 → 上传 + +后续步骤与本地文件导入的步骤 2~4 相同,上传步骤调用 `postCreateDatasetLinkCollection` (POST) 逐条创建。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + + User->>UI: 粘贴URL列表(每行一个) + UI->>User: 展示标签化的链接列表 + + User->>UI: 配置参数 → 预览分块 + UI->>API: POST /core/dataset/file/getPreviewChunks + + User->>UI: 确认上传 + UI->>API: POST /core/dataset/collection/create/link (逐个) + API-->>UI: 创建完成 + UI->>User: 重定向回数据集详情 +``` + +--- + +## 自定义文本导入 + +> 业务描述:手动输入文本内容,直接作为知识库数据来源。 + +### 步骤 1:输入文本 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入集合名称(必填)和文本内容 | 无 | 两者均不能为空 | 输入框和文本域展示 | +| 点击「下一步」 | 无 | — | 进入参数配置步骤 | + +### 步骤 2~4:参数配置 → 预览 → 上传 + +步骤 2(参数配置)中,文本导入的预览步骤在前端本地完成分块(使用 `splitText2Chunks` 函数),无需调用后端 `getPreviewChunks` API。上传步骤调用 `postCreateDatasetTextCollection` (POST),传入 `rawText` 和分块参数。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + + User->>UI: 输入名称+文本内容 + User->>UI: 配置分块参数 + User->>UI: 本地分块预览(前端splitText2Chunks) + User->>UI: 确认上传 + UI->>API: POST /core/dataset/collection/create/text + API-->>UI: 创建完成 +``` + +--- + +## 外部文件导入 + +> 业务描述:通过外部文件 URL 导入文件,每行填写一个外部文件的信息。 + +### 步骤 1:输入外部文件信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在动态表格中填写每行:`externalFileUrl`、`externalFileId`、`filename` | 无 | 末尾行填写后自动新增空行;URL 含扩展名时自动提取文件名 | 表格行动态增减 | +| 点击「下一步」 | 无 | 过滤掉 URL 为空的行后至少有一条有效记录 | 进入参数配置步骤 | + +后续步骤调用 `postCreateDatasetExternalFileCollection` (POST) 逐条创建集合。 + +--- + +## API 数据集导入 + +> 业务描述:从已对接的外部 API 数据源浏览并选择文件/文件夹导入。 + +### 步骤 1:浏览并选择 API 文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 | `getApiDatasetFileList` (POST) — 获取当前目录文件列表 | 首次加载默认根目录 | 展示文件/文件夹列表,含 checkbox、类型图标、名称 | +| 点击文件夹行 | `getApiDatasetFileList` (POST) — 传入新的 `parentId` | — | 展开进入子目录,面包屑导航更新 | +| 勾选文件/文件夹 | 无 | 已存在于数据集中的文件标记为禁用(通过 `getApiDatasetFileListExistId` 获取) | checkbox 勾选/取消 | +| 点击面包屑返回上级 | 同点击文件夹 | — | 返回上级目录 | +| 点击「下一步」 | 无 | — | 进入参数配置步骤 | + +### 步骤 2~4:参数配置 → 预览 → 上传 + +上传步骤调用 `postCreateDatasetApiDatasetCollectionV2` (POST),批量提交选中的 `apiFiles` 数组。 + +--- + +## 重训练 + +> 业务描述:对已有集合重新进行数据处理和训练。 + +### 步骤 1:加载已有集合 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 | `getDatasetCollectionById` (GET) — 获取指定集合详情 | URL 参数中需传入 `collectionId` | 加载动画覆盖页面 | +| 集合数据返回 | 无 | 将集合的现有训练参数回填到 `processParamsForm` 中(分块设置、索引配置、QA Prompt 等) | 表单值重置为已有集合的配置 | + +### 步骤 2~3:参数调整 → 预览 → 上传 + +重训练为 3 步流程(不含文件选择步骤)。上传步骤调用 `postReTrainingDatasetFileCollection` (POST) 重新创建训练任务。 + +--- + +## 图片数据集导入 + +> 业务描述:上传图片文件,创建图片类型的知识库集合。此流程为简化的单页模式,不经过分步向导。 + +### 步骤流程 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入集合名称,选择图片文件 | 无 | 仅接受 `.jpg`、`.jpeg`、`.png` 格式 | 文件选择器展示;图片以 100×100 缩略图网格预览,hover 可删除 | +| 实时显示上传进度 | `createImageDatasetCollection` (POST) — 含 `files`、`fileMd5`(SparkMD5 流式计算)、进度回调 | 文件数量不超过团队套餐限制 | 进度文字更新("上传中 (n/总数)" → "图片生成中") | +| 上传完成 | 无 | — | 自动重定向回数据集详情页 `currentTab=collectionCard` | + +**特点**:图片导入不经过 DataProcess(无分块配置)和 PreviewData(图片无需文本预览)步骤。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + + User->>UI: 输入名称, 选择图片文件 + UI->>UI: 计算文件MD5(SparkMD5) + UI->>API: POST /core/dataset/collection/create/images (含进度回调) + API-->>UI: 上传中 / 图片生成中 + UI->>User: 完成, 重定向回数据集详情 +``` + +--- + +## 数据库直连导入 + +> 业务描述:配置数据库连接,选择表和字段,将结构化数据映射为知识库。 + +### 步骤 1:数据库连接配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择数据库类型(MySQL/PostgreSQL/MSSQL/Oracle) | 无 | 切换类型时自动更新默认端口和数据库名 | 类型卡片高亮选中 | +| 填写 host/port/db/user/password/schema(条件显示)/poolSize | 无 | host 不可为 localhost/127.x.x.x/0.0.0.0/::1(`databaseAddrValidator`);端口范围 1-65535;poolSize 1-100 | 各字段实时校验 | +| 点击「测试连接」 | `postCheckDatabaseConnection` (POST) — 传入连接参数 | 编辑模式下不显示测试按钮 | 连接成功/失败 toast 提示 | +| 测试通过后点击「下一步」 | 无 | 编辑模式:若在步骤 0,通过 `putDatasetById` (PUT) 更新数据集配置后进入下一步 | 进入表选择步骤 | + +**表单字段清单**: +| 字段名 | 控件类型 | 必填 | 默认值 | 约束 | 说明 | +|--------|---------|------|--------|------|------| +| 数据库类型 | 卡片选择 | ✅ | MySQL | MySQL/PostgreSQL/MSSQL/Oracle | 编辑模式下只读 | +| 主机地址 | 文本输入 | ✅ | — | 不可为 localhost 等 | 含格式提示 tooltip | +| 端口 | 数字输入 | ✅ | 随类型自动更新 | 1-65535 | — | +| 数据库名 | 文本输入 | ✅ | — | — | 每种类型默认值不同 | +| 用户名 | 文本输入 | ✅ | — | — | — | +| 密码 | 密码输入 | ✅ | — | — | 可切换可见性 | +| Schema | 文本输入 | 条件 | — | 仅部分数据库类型显示 | — | +| 连接池大小 | 数字输入 | ✅ | — | 1-100 | — | + +### 步骤 2:表选择与配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 | `postGetDatabaseConfiguration` (GET) — 获取当前已配置的表 | 若无已配置表,展示空状态 | 表配置列表 | +| 选择要导入的表和字段 | `postDetectDatabaseChanges` (POST) — 检测数据库变更 | 编辑模式下检测到变更时高亮提示 | 表/字段选择面板 | +| 确认配置后提交 | `postCreateDatabaseCollections` (POST) 或 `postUpdateDatasetCollectionConfigByDatabase` (POST) — 根据新建/编辑模式 | 新建模式调用 create;编辑模式调用 applyChanges | 创建/更新成功提示 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + + User->>UI: 选择数据库类型, 填写凭证 + User->>UI: 点击「测试连接」 + UI->>API: POST /core/dataset/database/checkConnection + API-->>UI: 连接成功/失败 + User->>UI: 点击「下一步」 + UI->>API: GET /core/dataset/database/getConfiguration + API-->>UI: 当前表配置 + User->>UI: 选择表/字段 + UI->>API: POST /core/dataset/database/createCollections + API-->>UI: 创建完成 +``` + +--- + +## 全局 Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 导入页面 + participant API as 后端API + + User->>UI: 进入数据集详情 → 导入Tab + UI->>UI: 初始化DatasetImportContext + + alt 文件/链接/文本/外部文件/API/重训练 + User->>UI: 选择来源 (步骤0) + User->>UI: 配置参数 (步骤1) + User->>UI: 预览分块 (步骤2) + User->>UI: 确认上传 (步骤3) + UI->>API: 对应集合创建API + else 图片导入 + User->>UI: 选择图片+名称 + UI->>API: createImageDatasetCollection + else 数据库导入 + User->>UI: 配置连接 → 测试 + UI->>API: checkConnection + User->>UI: 选择表 → 创建 + UI->>API: createCollections + end + + API-->>UI: 完成 + UI->>User: 重定向回数据集详情 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..dbeda114c7e9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,55 @@ +--- +capability_label: 导入数据 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:41:34Z" +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=import] +--- + +# 导入数据 — 模块概览 + +## 1. 模块业务概述 + +导入数据模块是数据集详情页的核心功能之一,负责将各种来源的数据导入到知识库中。用户可以通过此模块将本地文件、网页链接、自定义文本、外部文件、API 数据集、图片、数据库等多种数据源转换为知识库的集合(Collection),为后续的知识检索和 AI 问答提供数据基础。 + +本模块支持 **8 种导入方式**,涵盖文件上传(本地文件、图片)、链接抓取(网页链接、外部文件 URL)、文本输入、API 数据集对接、已有数据重训练以及数据库直连。管理员和具有管理权限的成员可以对数据集执行导入操作,普通查看者仅能查看已导入的内容。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 数据集详情 → 导入 Tab | 全部 8 种导入方式,含参数配置、数据预览、上传确认 | 可管理所有数据集 | +| 成员(有管理权限) | 数据集详情 → 导入 Tab | 全部 8 种导入方式 | 需要 `permission.hasManagePer` 权限 | +| 普通查看者 | 不直接可见 | 不可导入 | 无管理权限时不展示导入入口 | + +**权限模型**: 基于数据集级别的权限控制。导入功能通过 `DatasetPageContext` 中的 `datasetDetail.permission.hasManagePer` 判断当前用户是否有管理权限。导入 Tab 的可见性和操作按钮的可用性均由此权限控制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集上下文 | `DatasetPageContext` — 获取数据集详情、ID、向量模型等信息 | Context | +| 文件上传 | `getUploadDatasetFilePresignedUrl`、`putFileToS3` — 文件上传至 S3 存储 | API | +| 集合管理 | `postCreateDatasetFileCollection` 等 7 个创建 API — 创建不同类型的知识库集合 | API | +| 公共组件 | `LightRowTabs`、`MyIcon`、`MyBox`、`MyModal`、`FolderPath` — 通用 UI 组件 | 组件 | +| 国际化 | `useTranslation` — 多语言文案 | Hook | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `DatasetImportContext` | 被 Import 内全部 diffSource 子组件消费 | Context | +| `FileSourceSelector` | 数据集详情页(NavBar 触发导入源选择) | 组件 | +| `ImportFormType` 类型定义 | 集合卡片组件(CollectionChunkForm 参考) | 类型 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集详情 — 导入 | `/dataset/detail?currentTab=import` | `pageComponents/dataset/detail/Import/index.tsx` | 管理员、有管理权限的成员 | 含 8 种导入源(详见 [09-业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。8 种导入方式统一在本模块内管理,通过 URL 参数 `source` 区分当前激活的导入源类型。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b3f86cbda2f2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,102 @@ +--- +capability_label: 数据卡片 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:39:36.000Z" +parent_module: 数据集详情 +roles: + - 管理员/编辑者 + - 只读用户 +router_paths: + - /dataset/detail?currentTab=dataCard +--- + +# 数据卡片 — 业务流程索引 + +> 本文档描述数据卡片模块覆盖的所有业务场景。场景交互细节见 [10-业务流程详解](./业务流程详解.md)。 + +## 浏览数据列表 + +- **业务描述**: 在数据集详情页中查看当前集合的数据分页列表,支持搜索过滤、折叠展开卡片,以及查看选中数据项的详细内容索引。 +- **触发入口**: 数据集详情页 → 点击集合列表中的某个集合 → 自动跳转至 dataCard Tab +- **路由路径**: `/dataset/detail?currentTab=dataCard&datasetId={id}&collectionId={id}` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/index.tsx` +- **涉及角色**: 管理员/编辑者、只读用户 +- **前置条件**: 数据集详情页已加载;当前集合存在且非空 + +## 新增数据 + +- **业务描述**: 在当前集合中新增一条数据内容,支持文本输入和 FAQ 问答对两种模式。 +- **触发入口**: 数据卡片页 → 点击搜索栏右侧"新增数据"按钮 +- **路由路径**: `/dataset/detail?currentTab=dataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/EditContentModal.tsx` +- **涉及角色**: 管理员/编辑者 +- **前置条件**: 当前用户具有写权限;集合训练类型非图片解析模式 + +## 编辑数据 + +- **业务描述**: 编辑已有数据的 Q(问题/内容)和 A(回答,FAQ 模式下)字段。 +- **触发入口**: 数据卡片页 → 鼠标悬停数据卡片 → 点击编辑按钮 +- **路由路径**: `/dataset/detail?currentTab=dataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/EditContentModal.tsx` +- **涉及角色**: 管理员/编辑者 +- **前置条件**: 当前用户具有写权限;目标数据项不在训练中状态 + +## 删除数据 + +- **业务描述**: 删除单条数据,删除后自动刷新列表。 +- **触发入口**: 数据卡片页 → 鼠标悬停数据卡片 → 点击删除按钮 → 确认弹窗 +- **路由路径**: `/dataset/detail?currentTab=dataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/index.tsx` +- **涉及角色**: 管理员/编辑者 +- **前置条件**: 当前用户具有写权限 + +## 管理索引内容 + +- **业务描述**: 查看、新增、编辑、删除某条数据关联的内容索引(知识点)。支持自定义索引的增删改。 +- **触发入口**: 数据卡片页 → 选中一条数据 → 右侧面板显示索引列表 → 操作索引 +- **路由路径**: `/dataset/detail?currentTab=dataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/ContentIndexCard.tsx` +- **涉及角色**: 管理员/编辑者(增删改)、只读用户(仅查看) +- **前置条件**: 已选中一条数据项;写权限用户可操作索引 + +## 查看异常数据 + +- **业务描述**: 当集合存在错误数据时,点击异常计数标签查看异常数据详情。 +- **触发入口**: 数据卡片页 → 点击搜索栏中的红色异常计数标签 +- **路由路径**: `/dataset/detail?currentTab=dataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/ExceptionInfoModal.tsx` +- **涉及角色**: 管理员/编辑者、只读用户 +- **前置条件**: 当前集合的 `errorCount` > 0 + +## 通过 activeId 定位数据 + +- **业务描述**: 通过 URL 参数 `activeId` 指定某条数据,页面自动计算其所在分页并滚动定位到该数据卡片。 +- **触发入口**: 从其他页面携带 `activeId` 参数进入数据卡片页 +- **路由路径**: `/dataset/detail?currentTab=dataCard&activeId={dataId}` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/index.tsx` +- **涉及角色**: 管理员/编辑者、只读用户 +- **前置条件**: URL 中包含 `activeId` 参数;目标数据存在且未删除 + +## 场景关系 + +- **浏览数据列表** → **新增数据**:浏览列表后可点击新增按钮添加数据 +- **浏览数据列表** → **编辑数据**:选中数据卡片后可编辑其内容 +- **浏览数据列表** → **删除数据**:选中数据卡片后可删除该数据 +- **浏览数据列表** → **管理索引内容**:选中数据卡片后可在右侧面板管理索引 +- **浏览数据列表** → **查看异常数据**:存在异常数据时可查看异常详情 +- **通过 activeId 定位数据** → **浏览数据列表**:定位后自动展开目标卡片,继续浏览 + +各场景之间无严格先后依赖关系,用户可自由切换。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览数据列表 | 数据集详情→点击集合→dataCard Tab | /dataset/detail?currentTab=dataCard | 管理员/编辑者、只读用户 | +| S02 | 新增数据 | 数据卡片页→新增数据按钮 | /dataset/detail?currentTab=dataCard | 管理员/编辑者 | +| S03 | 编辑数据 | 数据卡片页→悬浮卡片→编辑按钮 | /dataset/detail?currentTab=dataCard | 管理员/编辑者 | +| S04 | 删除数据 | 数据卡片页→悬浮卡片→删除按钮→确认 | /dataset/detail?currentTab=dataCard | 管理员/编辑者 | +| S05 | 管理索引内容 | 数据卡片页→选中数据→右侧索引面板 | /dataset/detail?currentTab=dataCard | 管理员/编辑者、只读用户 | +| S06 | 查看异常数据 | 数据卡片页→异常计数标签 | /dataset/detail?currentTab=dataCard | 管理员/编辑者、只读用户 | +| S07 | 通过 activeId 定位数据 | 携带 activeId 参数进入页面 | /dataset/detail?currentTab=dataCard&activeId={id} | 管理员/编辑者、只读用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..a8f2a5d58350 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,464 @@ +--- +capability_label: 数据卡片 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:39:36.000Z" +parent_module: 数据集详情 +roles: + - 管理员/编辑者 + - 只读用户 +router_paths: + - /dataset/detail?currentTab=dataCard +--- + +# 数据卡片 — 业务流程详解 + +## 页面总览 + +数据卡片页采用**左右分栏布局**:左侧为数据列表区域(搜索栏 + 分页卡片列表),右侧为索引内容面板(固定 470px 宽度,显示选中数据的索引列表)。页面通过路由参数接收 `datasetId`、`collectionId` 和可选的 `activeId`。 + +数据卡片内部**无嵌套 Tab**,所有功能均在同一页面内通过交互切换完成。 + +--- + +## S01:浏览数据列表 + +> 在数据集详情页中查看当前集合的数据分页列表,支持搜索过滤、卡片折叠展开,以及选中数据项查看详情。 + +### 步骤 1:页面初始化与数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从集合卡片页点击集合进入 dataCard Tab | `GET /core/dataset/collection/detail?id={collectionId}`(获取集合信息) | — | 页面加载,显示搜索栏骨架 | +| 页面自动触发首次数据加载 | `POST /core/dataset/data/v2/list`(获取数据分页列表,参数含 collectionId、searchText=""、pageNum=1、pageSize=10) | — | 左侧列表区域显示加载中状态,随后渲染数据卡片列表 | +| 查看数据列表 | — | `canWrite` 为 true 时,搜索栏右侧显示"新增数据"按钮;集合训练类型为图片解析时,不显示"新增数据"按钮 | 数据列表以卡片形式展示,每张卡片显示序号、字符数、数据内容 | + +### 步骤 2:搜索数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入搜索关键字 | — | 输入后有 500ms 防抖延迟 | 搜索框显示输入内容 | +| 防抖结束,搜索文本发生变化 | `POST /core/dataset/data/v2/list`(参数含新 searchText,pageNum 重置为 1) | — | 列表刷新,显示匹配搜索结果;无结果时显示空状态提示 | + +### 步骤 3:翻页浏览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器切换页码或修改每页条数 | `POST /core/dataset/data/v2/list`(参数含新 pageNum / pageSize) | — | 列表刷新,显示新分页数据;页面滚动到列表顶部 | +| 切换每页条数(10/20/50/100) | 同上 | — | 列表按照新条数重新分页展示 | + +### 步骤 4:折叠/展开数据卡片 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击已展开卡片的折叠按钮 | — | — | 卡片收起,仅显示序号和字符数行,内容区域隐藏,卡片变为可点击模式 | +| 点击已折叠的卡片 | — | 该卡片非当前激活卡片时,自动激活并展开 | 卡片展开显示完整内容,同时右侧面板加载该数据的索引详情 | + +### 步骤 5:选中卡片查看详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击未激活的数据卡片 | `GET /core/dataset/data/detail?id={dataId}`(获取数据详情含索引列表) | — | 右侧面板显示加载中状态 → 加载完成后显示索引列表;卡片高亮(蓝色边框+背景色) | +| 点击已激活的折叠卡片 | — | 卡片已折叠时自动展开 | 卡片展开,不重新加载详情 | +| 数据项处于训练中状态 | — | activeDataDetail.trainingStatus === 'training' | 右侧面板显示构建动画和"正在重建索引,暂不可编辑"提示 | + +### 步骤 6:训练状态轮询 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 列表中有数据处于 training 状态 | `POST /core/dataset/data/v2/list`(每 6 秒轮询) | hasTrainingData 为 true 时自动轮询 | 数据静默刷新,不显示加载遮罩;训练完成后卡片恢复编辑按钮可用状态 | +| 当前激活卡片的训练状态变更 | `GET /core/dataset/data/detail?id={activeCardId}` | 列表数据中 activeCardId 对应项的 trainingStatus 发生变化 | 右侧面板重新加载数据详情 | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/dataset/data/v2/list | collectionId, searchText="", pageNum=1, pageSize=10 | 无额外处理 | 数据卡片列表前 10 条 | +| 搜索 | POST /core/dataset/data/v2/list | collectionId, searchText, pageNum=1, pageSize=10 | 重置到第一页 | 过滤后的数据卡片列表 | +| 翻页 | POST /core/dataset/data/v2/list | collectionId, pageNum=N, pageSize=X | 无额外处理 | 第 N 页数据 | +| 轮询刷新 | POST /core/dataset/data/v2/list | 同上一次请求参数 | 静默更新,不显示遮罩 | 数据列表更新 | +| 数据详情 | GET /core/dataset/data/detail | id=dataId | 过滤掉 type 为 'default' 的索引项 | 右侧面板索引列表 | + +- **分页参数**:默认每页 10 条,可选 10/20/50/100 +- **排序规则**:按插入顺序排列(chunkIndex) +- **筛选条件**:搜索框输入搜索文本,后端全文搜索 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant API as 数据集 API + + User->>UI: 进入 dataCard Tab + UI->>API: GET /core/dataset/collection/detail?id={collectionId} + API-->>UI: 返回集合信息 + UI->>API: POST /core/dataset/data/v2/list (pageNum=1) + API-->>UI: 返回数据列表+总数 + UI-->>User: 渲染卡片列表 + + User->>UI: 输入搜索文本 + Note over UI: 500ms 防抖延迟 + UI->>API: POST /core/dataset/data/v2/list (pageNum=1, searchText) + API-->>UI: 返回过滤结果 + UI-->>User: 刷新列表 + + loop 轮询(有训练中数据时 6s/次) + UI->>API: POST /core/dataset/data/v2/list (当前页码) + API-->>UI: 返回最新数据 + UI-->>User: 静默更新列表 + end +``` + +--- + +## S02:新增数据 + +> 在当前集合中添加一条新的数据内容(文本或 FAQ 问答对)。 + +### 步骤 1:打开新增弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新增数据"按钮 | — | canWrite 为 true 且集合训练类型非图片解析 | 弹出编辑内容弹窗 | +| canWrite 为 false 或训练类型为图片解析 | — | 按钮不显示 | 无法触发新增 | + +### 步骤 2:填写内容 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| FAQ 模式(template 训练类型):在 Question 输入框填写问题 | — | — | 输入内容 | +| FAQ 模式:在 Answer 输入框填写回答 | — | — | 输入内容,Answer 为必填 | +| 普通模式:在内容输入框填写文本(支持 Markdown) | — | — | 输入内容,Question 为必填 | +| 图片解析模式(imageParse):左侧预览图片,右侧填写描述 | — | 此模式下弹窗布局为左右分栏 | 左侧显示图片,右侧为描述输入框 | + +### 步骤 3:提交新增 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | `POST /core/dataset/data/insertData`(参数含 collectionId, q, a) | 表单校验通过 | 确认按钮显示加载中状态 → 成功后弹窗关闭 → 列表自动刷新 | +| 提交失败 | — | 后端返回错误 | 显示错误 Toast 提示,弹窗保持打开 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| q(问题/内容) | Textarea | ✅ | "" | 支持 Markdown | — | FAQ 模式下为问题,普通模式下为内容 | +| a(回答) | Textarea | FAQ 模式下必填 | "" | 支持 Markdown | — | 仅 FAQ 模式显示,普通模式不显示 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| q 字段必填 | 提交时(react-hook-form required 校验) | 表单校验阻止提交 | +| a 字段 FAQ 模式下必填 | 提交时 | 表单校验阻止提交 | +| 后端插入失败 | 提交时(后端校验) | 对应错误信息 Toast | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 新增数据弹窗 + participant API as 数据集 API + + User->>UI: 点击新增数据按钮 + UI-->>User: 打开弹窗,显示表单 + + User->>UI: 填写 Q(和 A) + User->>UI: 点击确认 + + UI->>API: POST /core/dataset/data/insertData (collectionId, q, a) + alt 成功 + API-->>UI: 返回 insertId + chunkIndex + UI-->>User: Toast "添加数据成功,索引为 N" + UI->>API: 自动刷新数据列表 + else 失败 + API-->>UI: 返回错误 + UI-->>User: Toast 错误信息 + end +``` + +--- + +## S03:编辑数据 + +> 编辑已有数据的 Q(问题/内容)和 A(回答)字段。 + +### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停数据卡片,点击编辑按钮 | — | canWrite 为 true 且数据非训练中状态 | 弹出编辑内容弹窗,预填当前 Q 和 A | +| 数据正在训练中 | — | trainingStatus === 'training' | 编辑按钮置灰不可点击,Tooltip 提示"训练中不可编辑" | + +### 步骤 2:加载当前数据详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹窗打开(编辑模式) | `GET /core/dataset/data/detail?id={dataId}`(自动触发) | 仅编辑模式 | 获取当前数据完整信息(含 indexes),用于提交时保留索引 | + +### 步骤 3:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改 Q 或 A | — | — | 表单内容变更 | +| 点击确认 | `PUT /core/dataset/data/update`(参数含 dataId, q, a, indexes) | 表单校验通过 | 确认按钮加载中 → 成功后弹窗关闭 → 列表更新本地数据 → 若编辑的是当前激活卡片,右侧详情刷新 | +| 提交失败 | — | 后端返回错误 | 显示错误 Toast,弹窗保持打开 | + +### 表单字段清单 + +同 S02 新增数据的字段清单,编辑模式下 Q 和 A 预填当前值。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 编辑数据弹窗 + participant API as 数据集 API + + User->>UI: 悬浮卡片,点击编辑 + UI->>API: GET /core/dataset/data/detail?id={dataId} + API-->>UI: 返回当前数据(含 indexes) + UI-->>User: 打开弹窗,预填 Q 和 A + + User->>UI: 修改内容,点击确认 + + UI->>API: PUT /core/dataset/data/update (dataId, q, a, indexes) + alt 成功 + API-->>UI: 返回成功 + UI-->>User: Toast "更新成功" + UI->>UI: 本地更新列表数据 + else 失败 + API-->>UI: 返回错误 + UI-->>User: Toast 错误信息 + end +``` + +--- + +## S04:删除数据 + +> 删除单条数据,删除后自动刷新列表。 + +### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停数据卡片,点击删除按钮 | — | canWrite 为 true | 显示删除确认弹窗 | + +### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | `DELETE /core/dataset/data/delete?id={dataId}` | — | 删除执行中 → 列表本地移除该项 → 刷新列表数据 | +| 点击取消 | — | — | 弹窗关闭,不执行删除 | + +### 删除链路详情 + +- **确认弹窗**:FAQ 模式下提示文案为"确认删除该 FAQ",普通模式下为"确认删除该分块" +- **级联影响**:删除后列表从本地状态中过滤掉该项,同时触发列表刷新以同步后端数据;若删除的是当前激活卡片,右侧详情面板自动切换到列表第一条数据 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant API as 数据集 API + + User->>UI: 悬浮卡片,点击删除 + UI-->>User: 显示确认弹窗 + + User->>UI: 点击确认删除 + UI->>API: DELETE /core/dataset/data/delete?id={dataId} + alt 成功 + API-->>UI: 返回成功 + UI-->>User: Toast "删除成功" + UI->>UI: 列表本地移除该数据 + UI->>API: 刷新数据列表 + else 失败 + API-->>UI: 返回错误 + UI-->>User: Toast 错误信息 + end +``` + +--- + +## S05:管理索引内容 + +> 查看、新增、编辑、删除某条数据关联的内容索引。 + +### 步骤 1:查看索引列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选中一条数据卡片 | `GET /core/dataset/data/detail?id={dataId}` | — | 右侧面板显示该数据的索引列表(过滤掉 type 为 'default' 的索引) | +| 索引列表为空 | — | filteredIndexes.length === 0 | 右侧面板显示"暂无索引"空状态提示 | + +### 步骤 2:新增索引 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新增索引"按钮 | — | canWrite 为 true 且当前数据非训练中状态 | 索引列表顶部出现新的空输入卡片,Textarea 自动聚焦 | +| 输入索引内容后失焦 | `PUT /core/dataset/data/update`(参数含 dataId, q, a, indexes 追加新索引) | 内容非空时自动保存 | 输入卡片显示保存中状态 → 保存成功 Toast → 输入卡片消失 → 索引列表刷新 | +| 输入为空后失焦 | — | 内容为空 | 输入卡片消失,不保存 | +| 按 Escape | — | — | 取消新增,输入卡片消失 | +| 按 Ctrl/Cmd+Enter | `PUT /core/dataset/data/update`(同失焦自动保存) | 内容非空时保存 | 同上自动保存流程 | + +### 步骤 3:编辑索引 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击索引卡片的编辑按钮 | — | canWrite 为 true | 索引卡片切换为 Textarea 编辑模式,内容可选 | +| 修改内容后失焦 | `PUT /core/dataset/data/update`(参数含 updatedIndexes) | 内容有变更时保存 | 保存成功后 Textarea 关闭,显示新内容 | +| 修改内容后按 Ctrl/Cmd+Enter | 同上 | 内容有变更时 | 同上 | +| 按 Escape | — | — | 放弃编辑,恢复原内容,退出编辑模式 | + +### 步骤 4:删除索引 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击索引卡片的删除按钮 | — | canWrite 为 true | 显示删除确认弹窗 | +| 确认删除 | `PUT /core/dataset/data/update`(参数含移除目标索引后的 indexes) | — | 删除成功 Toast → 索引列表刷新(自动重新加载数据详情) | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant API as 数据集 API + + User->>UI: 选中一条数据卡片 + UI->>API: GET /core/dataset/data/detail?id={dataId} + API-->>UI: 返回数据详情(含 indexes) + UI-->>User: 右侧面板显示索引列表 + + User->>UI: 点击新增索引 + UI-->>User: 显示 Textarea 输入卡片 + User->>UI: 输入内容后失焦/按 Ctrl+Enter + UI->>API: PUT /core/dataset/data/update (dataId, q, a, indexes新增) + API-->>UI: 返回成功 + UI->>API: GET /core/dataset/data/detail?id={dataId} + API-->>UI: 返回更新后的数据 + UI-->>User: 索引列表刷新 + + User->>UI: 编辑索引→修改后失焦 + UI->>API: PUT /core/dataset/data/update (dataId, q, a, indexes更新) + API-->>UI: 返回成功 + UI-->>User: 索引内容更新 + + User->>UI: 删除索引→确认 + UI->>API: PUT /core/dataset/data/update (dataId, q, a, indexes移除) + API-->>UI: 返回成功 + UI-->>User: 索引列表刷新 +``` + +--- + +## S06:查看异常数据 + +> 当集合中存在错误数据时,查看异常数据详情。 + +### 步骤 1:触发异常查看 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击搜索栏中的红色异常计数标签 | — | collection.errorCount > 0 时显示标签 | 打开异常信息弹窗(ExceptionInfoModal) | +| errorCount 为 0 | — | — | 异常标签不显示 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant Modal as 异常信息弹窗 + + User->>UI: 点击异常计数标签 + UI-->>User: 打开异常信息弹窗 + Note over Modal: 复用集合卡片模块的 ExceptionInfoModal +``` + +--- + +## S07:通过 activeId 定位数据 + +> 从其他页面携带 `activeId` 参数进入,自动计算分页位置并定位到目标数据。 + +### 步骤 1:计算目标分页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从外部入口(如搜索结果)携带 activeId 进入数据卡片页 | `POST /core/dataset/data/getindex`(参数含 dataId, collectionId, datasetId) | activeId 存在时自动触发 | 页面显示加载中状态,计算目标分页 | +| 目标数据已被删除 | — | index 返回 -1 | Toast 警告"数据已删除" → 正常加载第一页 | + +### 步骤 2:加载目标页并定位 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 计算完成后自动执行 | `POST /core/dataset/data/v2/list`(pageNum=计算出的页码) | — | 列表加载目标页数据 | +| 列表加载完成后 | — | 目标数据在当前列表中 | 自动激活该卡片(高亮+展开)→ 加载其详情 → 100ms 后滚动到目标卡片位置 | +| 用户手动选中其他卡片后 | — | hasProcessedActiveId 已设为 true | activeId 不再覆盖用户选择 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant API as 数据集 API + + User->>UI: 携带 activeId 参数进入页面 + UI->>API: POST /core/dataset/data/getindex (dataId, collectionId, datasetId) + alt index >= 0 + API-->>UI: 返回 index + Note over UI: 计算 pageNum = floor(index/10) + 1 + UI->>API: POST /core/dataset/data/v2/list (pageNum=计算的页码) + API-->>UI: 返回目标页数据 + UI->>API: GET /core/dataset/data/detail?id={activeId} + API-->>UI: 返回目标数据详情 + UI-->>User: 激活卡片、展开内容、加载索引、滚动定位 + else index === -1 (数据已删除) + API-->>UI: 返回 index: -1 + UI-->>User: Toast "数据已删除" + UI->>API: POST /core/dataset/data/v2/list (pageNum=1) + API-->>UI: 返回第一页数据 + end +``` + +--- + +## Mermaid 附录(页面级总览) + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 数据卡片页面 + participant API as 数据集 API + participant Backend as 后端 + + User->>UI: 进入 dataCard Tab + + par 集合信息与数据并行加载 + UI->>API: GET collection/detail + API-->>UI: 集合信息 + and + UI->>API: POST data/v2/list (pageNum=1) + API-->>UI: 数据列表 + end + + UI-->>User: 渲染完整页面(左侧数据列表+分页器+右侧空面板) + + opt 有 activeId + UI->>API: POST data/getindex + API-->>UI: 计算分页位置 + UI->>API: 加载目标页+详情 + end + + User->>UI: 搜索/翻页/选中/增删改/管理索引 + Note over UI,Backend: 每个操作触发对应 API 调用,详见各场景 Mermaid 附录 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..de992e802b44 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: 数据卡片 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:39:36.000Z" +parent_module: 数据集详情 +roles: + - 管理员/编辑者 + - 只读用户 +router_paths: + - /dataset/detail?currentTab=dataCard +--- + +# 数据卡片 — 模块概览 + +## 模块业务概述 + +数据卡片是数据集详情页中用于**查看、编辑和管理单个知识库集合内的数据内容**的核心模块。当用户在数据集详情页切换到"数据卡片"Tab 时展示,提供数据列表的搜索、浏览、折叠展开以及选中数据项的索引内容管理功能。 + +本模块服务于两类角色:具有写权限的管理员/编辑者可对数据进行增删改和索引内容编辑;只读用户仅可浏览数据和索引内容。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员/编辑者 | 数据卡片页 | 搜索数据、浏览数据列表、折叠展开数据卡片、查看索引内容、新增数据、编辑数据、删除数据、新增索引、编辑索引、删除索引 | 具有完整的数据管理权限 | +| 只读用户 | 数据卡片页 | 搜索数据、浏览数据列表、折叠展开数据卡片、查看索引内容 | 无编辑权限,不显示新增/编辑/删除按钮 | + +**权限模型**:基于 `datasetDetail.permission.hasWritePer` 的写权限控制。`canWrite` 为 `true` 时显示新增数据按钮、编辑/删除操作按钮、新增/编辑/删除索引按钮。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集详情上下文(DatasetPageContext) | 获取 datasetDetail(含权限信息)、datasetId | Context | +| 数据集 API 层 | getDatasetDataList、getDatasetDataItemById、putDatasetDataById、getDatasetDataIndex、delOneDatasetDataById、getDatasetCollectionById、postInsertData2Dataset(via EditContentModal) | API | +| Web 通用组件库(@fastgpt/web) | MyBox、EmptyTip、MyIcon、MyDivider、MyImage、FormLabel、MyTooltip、MyTag、PopoverConfirm | 组件 | +| Markdown 组件 | Markdown 渲染数据内容 | 组件 | +| 分页 Hook(usePagination) | 数据列表分页、搜索防抖 | Hook | +| 集合卡片模块(RefinedCollectionCard) | ExceptionInfoModal 异常信息弹窗 | 组件 | + +## 对外共享 + +本模块为数据集详情页的内部子模块,不被其他外部模块直接引用。入口组件 `RefinedDataCard` 通过 Next.js `dynamic()` 懒加载,仅在数据集详情页的 `dataCard` Tab 下渲染。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据卡片 | /dataset/detail?currentTab=dataCard | projects/app/src/pageComponents/dataset/detail/RefinedDataCard/index.tsx | 管理员/编辑者、只读用户 | 左侧数据列表+右侧索引内容面板,无内部 Tab | + +## 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..344d63d924d2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,116 @@ +--- +capability_label: 数据集配置 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:44:40Z +parent_module: 数据集详情 +roles: [管理员, 协作者] +router_paths: [/dataset/detail?currentTab=info] +--- + +# 数据集配置 — 业务流程索引 + +## 业务场景列表 + +### 查看数据集配置 + +- **业务描述**: 进入数据集详情页的配置Tab,查看数据集的基本信息、模型配置、同步策略和服务连接信息。 +- **触发入口**: 数据集列表 → 点击数据集名称进入详情 → 点击"配置"Tab +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员、协作者 +- **前置条件**: 用户已登录且具有该数据集的访问权限 + +### 编辑数据集基本信息 + +- **业务描述**: 管理员修改数据集的名称、头像和简介。 +- **触发入口**: 数据集配置页 → 点击名称旁的编辑图标 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx`(调用 EditResourceModal) +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限 + +### 切换向量模型并重建索引 + +- **业务描述**: 管理员更换数据集使用的向量模型,系统重建全部向量嵌入索引。 +- **触发入口**: 数据集配置页 → 向量模型下拉选择器 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限;非结构化文档类型;数据集不在训练中 + +### 切换Agent模型 + +- **业务描述**: 管理员更换数据集使用的Agent对话模型。 +- **触发入口**: 数据集配置页 → Agent模型下拉选择器 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限;该数据集类型支持Agent模型配置 + +### 切换VLM多模态模型 + +- **业务描述**: 管理员配置或更换数据集使用的视觉语言模型。 +- **触发入口**: 数据集配置页 → VLM模型下拉选择器 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限;该数据集类型支持VLM模型配置 + +### 配置自动同步 + +- **业务描述**: 管理员开启或关闭数据集的自动同步功能(Plus版功能)。 +- **触发入口**: 数据集配置页 → 同步计划开关 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限;为Plus版功能;非数据库类型数据集 + +### 配置外部读取地址 + +- **业务描述**: 为外部文件类型数据集设置外部文件读取地址模板。 +- **触发入口**: 数据集配置页 → 外部读取地址输入框 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 数据集类型为 externalFile + +### 编辑API数据集配置 + +- **业务描述**: 管理API数据集、语雀或飞书类型数据集的服务连接配置。 +- **触发入口**: 数据集配置页 → API地址/语雀配置/飞书配置旁的编辑图标 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx`(调用 EditAPIDatasetInfoModal) +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 数据集类型为 apiDataset / yuque / feishu + +### 管理协作者 + +- **业务描述**: 管理员添加、修改或移除数据集的协作者及其权限。 +- **触发入口**: 数据集配置页 → 协作者区域的设置图标 +- **路由路径**: `/dataset/detail?currentTab=info` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/Info/index.tsx`(调用 MemberManager → CollaboratorContextProvider → MemberModal) +- **涉及角色**: 管理员(hasManagePer) +- **前置条件**: 用户具有管理权限 + +## 场景关系 + +- **查看数据集配置** 是其他所有场景的入口页面,其他场景均在此页面内操作 +- **切换向量模型**操作会触发重建嵌入索引,训练期间该操作被禁用 +- **切换Agent模型**和**切换VLM模型**各自独立,互不影响 +- **编辑API数据集配置**的弹窗内容根据数据集类型(API/语雀/飞书)显示不同字段 +- 各配置场景独立执行,无严格先后关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看数据集配置 | 数据集列表→点击名称→配置Tab | /dataset/detail?currentTab=info | 管理员, 协作者 | +| S02 | 编辑数据集基本信息 | 配置页→编辑图标 | /dataset/detail?currentTab=info | 管理员 | +| S03 | 切换向量模型并重建索引 | 配置页→向量模型下拉框 | /dataset/detail?currentTab=info | 管理员 | +| S04 | 切换Agent模型 | 配置页→Agent模型下拉框 | /dataset/detail?currentTab=info | 管理员 | +| S05 | 切换VLM多模态模型 | 配置页→VLM模型下拉框 | /dataset/detail?currentTab=info | 管理员 | +| S06 | 配置自动同步 | 配置页→同步计划开关 | /dataset/detail?currentTab=info | 管理员 | +| S07 | 配置外部读取地址 | 配置页→外部读取地址输入框 | /dataset/detail?currentTab=info | 管理员 | +| S08 | 编辑API数据集配置 | 配置页→API配置编辑图标 | /dataset/detail?currentTab=info | 管理员 | +| S09 | 管理协作者 | 配置页→协作者设置图标 | /dataset/detail?currentTab=info | 管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..4d64f2b11889 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,260 @@ +--- +capability_label: 数据集配置 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:44:40Z +parent_module: 数据集详情 +roles: [管理员, 协作者] +router_paths: [/dataset/detail?currentTab=info] +--- + +# 数据集配置 — 业务流程详解 + +## 页面总览 + +数据集配置页是数据集详情页的核心配置入口。页面加载时从 DatasetPageContext 获取数据集详情数据,渲染一个包含基本信息展示区和可配置参数区的表单。配置项根据数据集类型、系统功能开关和用户权限动态显隐。 + +--- + +### S01: 查看数据集配置 + +> 业务描述:用户进入数据集配置Tab,查看数据集的基本信息、ID、模型配置、同步状态等。 + +#### 步骤 1:页面加载与数据获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从数据集详情页切换到"配置"Tab | GET /core/dataset/detail(由 DatasetPageContext 在父级加载) | 无 | 页面渲染数据集名称、头像、简介、类型标签;下方按数据集类型逐步渲染各配置区 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/dataset/detail | datasetId | DatasetPageContext 更新 datasetDetail 状态 | Info 组件通过 useContextSelector 获取并渲染 | +| 轮询刷新 | GET /core/dataset/detail | datasetId(状态为 syncing/waiting 时每10秒) | 静默更新 datasetDetail,不显示遮罩 | 页面数据静默刷新 | + +#### 步骤 2:分区渲染配置项 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动浏览配置页 | 无(纯前端渲染) | 数据集类型 = database 且有 clientType → 显示数据库类型行 | 依次展示:数据集ID → 数据库类型(条件) → 向量模型 → Agent模型 → VLM模型 → 同步计划 → 外部读取地址/API配置/语雀/飞书(条件) → 协作者管理(条件) | + +--- + +### S02: 编辑数据集基本信息 + +> 业务描述:管理员点击编辑图标,在弹出的编辑资源弹窗中修改数据集名称、头像或简介。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击名称旁的编辑图标 | 无 | hasManagePer = true 时编辑图标可见 | EditResourceModal 弹窗打开,表单预填当前名称、头像、简介 | + +#### 步骤 2:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/头像/简介后点击确认 | PUT /core/dataset/update(通过 updateDataset context 方法) | 无 | 弹窗关闭;成功 toast "更新成功";页面名称/头像/简介即时更新 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------|-----------|------| +| 名称 | 文本输入 | 是 | 当前名称 | — | 否 | 数据集显示名称 | +| 头像 | 图片上传 | 否 | 当前头像 | — | 否 | 数据集头像 | +| 简介 | 文本输入 | 否 | 当前简介 | — | 否 | 数据集描述信息 | + +**后置影响**:更新成功后 datasetDetail 状态即时刷新,页面展示更新后的信息。 + +--- + +### S03: 切换向量模型并重建索引 + +> 业务描述:管理员从下拉列表中选择新的向量模型,确认后系统重建数据集的向量嵌入索引。 + +#### 步骤 1:选择新模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在向量模型下拉框中点击选择 | 无 | 数据集类型配置 showVectorModel.isHidden = false 且非结构化文档类型时可见 | 下拉框展开,显示可用向量模型列表(已过滤 isTuned=true 的模型) | +| — | — | isTraining = true(有重建或训练任务进行中)| 下拉框置灰,提示"知识库有正在训练或重建的索引" | +| — | — | 非数据库类型 | 显示当前向量模型的 maxToken 信息 | + +#### 步骤 2:确认重建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 选择新模型后弹出确认弹窗 | — | — | 弹出删除类型确认弹窗,内容为"确认重建嵌入索引"提示,倒计时10秒 | +| 点击确认 | POST /core/dataset/training/rebuildEmbedding(参数:datasetId, vectorModelId) | 无 | 弹窗关闭;成功 toast "重建嵌入已开始";触发 refetchDatasetTraining 和 loadDatasetDetail 刷新页面状态 | + +**前后置条件**: +- **前置条件**:数据集类型支持向量模型配置;数据集当前不在训练/重建中 +- **后置影响**:重建任务被加入队列,数据集进入训练状态;训练期间向量模型选择器被禁用 +- **失败场景**:网络异常或服务端错误时显示"更新失败"toast + +--- + +### S04: 切换Agent模型 + +> 业务描述:管理员更换数据集使用的Agent对话模型,选择后即时保存。 + +#### 步骤 1:选择模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在Agent模型下拉框中点击选择 | PUT /core/dataset/update(参数:id, agentModelId) | showAgentModelConfig.isHidden = false 时可见 | 选择后即时提交保存;成功 toast "更新成功" | + +**后置影响**:保存成功后数据集 agentModel 即时更新。 + +--- + +### S05: 切换VLM多模态模型 + +> 业务描述:管理员配置或清空数据集使用的视觉语言模型。 + +#### 步骤 1:选择或清除模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在VLM模型下拉框中点击选择 | PUT /core/dataset/update(参数:id, vlmModelId) | showVlmModel.isHidden = false 时可见 | 选择后即时保存;下拉框支持清除(clearable) | +| 点击清除按钮 | PUT /core/dataset/update(参数:id, vlmModelId=null) | — | vlmModel 被设为 null/undefined | + +--- + +### S06: 配置自动同步 + +> 业务描述:管理员开启或关闭数据集的自动同步功能,使数据集内容定期自动更新。 + +#### 步骤 1:切换同步开关 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击同步计划开关 | — | feConfigs.isPlus = true 且非数据库类型数据集时可见 | 弹出确认弹窗,内容为"开启自动同步"或"关闭自动同步" | +| 在弹窗中点击确认 | PUT /core/dataset/update(参数:id, autoSync: true/false) | 无 | 开关状态切换;成功无额外 toast(静默更新) | + +**提示信息**:同步计划开关旁有问号提示图标,hover 显示"同步计划说明"。 + +--- + +### S07: 配置外部读取地址 + +> 业务描述:为外部文件类型数据集设置文件读取地址模板,供外部文件访问时使用。 + +#### 步骤 1:输入地址 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在外部读取地址输入框中输入URL | — | datasetDetail.type = externalFile 时显示 | 输入框显示占位提示 "https://test.com/read?fileId={{fileId}}" | +| 输入框失焦 | PUT /core/dataset/update(参数:id, externalReadUrl) | 无 | 失焦时自动提交保存;成功 toast "更新成功" | + +--- + +### S08: 编辑API数据集配置 + +> 业务描述:管理员编辑API数据集、语雀或飞书类型数据集的服务连接信息。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击API配置/语雀配置/飞书配置旁的编辑图标 | 无 | 数据集类型分别 = apiDataset / yuque / feishu | EditAPIDatasetInfoModal 弹窗打开 | + +**不同数据集类型的展示差异**: +- **API数据集**:显示 API 服务基础 URL +- **语雀**:显示语雀用户 ID +- **飞书**:显示飞书文件夹 Token + +#### 步骤 2:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改配置后点击确认 | PUT /core/dataset/update(参数:id, apiDatasetServer) | 无 | 弹窗关闭;页面显示更新后的配置信息 | + +--- + +### S09: 管理协作者 + +> 业务描述:管理员管理数据集的协作者及其权限(添加、修改角色、移除)。 + +#### 步骤 1:查看协作者列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面滚动到协作者区域 | GET /proApi/core/dataset/collaborator/list(参数:datasetId) | hasManagePer = true 时协作者区域可见 | 显示协作者成员卡片列表,每条含成员头像、名称、角色标签 | + +#### 步骤 2:打开管理弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击协作者区域的设置图标 | 无 | — | MemberModal 弹窗打开,显示完整的协作者管理界面 | + +#### 步骤 3:添加/修改协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在管理弹窗中添加成员或修改角色后确认 | POST /proApi/core/dataset/collaborator/update(参数:datasetId, tmbId/groupId/orgId, role) | 无 | 弹窗关闭,协作者列表刷新 | + +#### 步骤 4:移除协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在管理弹窗中移除某成员 | DELETE /proApi/core/dataset/collaborator/delete(参数:datasetId + tmbId/groupId/orgId 其中之一) | 无 | 该成员从列表中移除 | + +**协作者角色类型**:数据集使用 DatasetRoleList(数据集专用角色列表),默认角色为 ReadRoleVal(只读)。 + +**权限逻辑**:当前用户在协作者列表中的角色通过 tmbId 匹配确定(myRole);若不在列表中,根据 userInfo.team.permission.isOwner 判断是否为 Owner。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 配置页面 + participant API as 后端API + + User->>UI: 进入数据集详情→配置Tab + UI->>API: GET /core/dataset/detail + API-->>UI: 数据集详情数据 + UI-->>User: 渲染配置页面 + + rect rgb(240, 248, 255) + Note over User,API: 编辑基本信息 + User->>UI: 点击编辑图标 + UI-->>User: 打开 EditResourceModal + User->>UI: 修改名称/头像/简介→确认 + UI->>API: PUT /core/dataset/update + API-->>UI: 更新成功 + UI-->>User: toast"更新成功",刷新显示 + end + + rect rgb(255, 248, 240) + Note over User,API: 切换模型 + User->>UI: 选择新向量模型 + UI-->>User: 确认重建弹窗(10秒倒计时) + User->>UI: 确认 + UI->>API: POST /core/dataset/training/rebuildEmbedding + API-->>UI: 重建任务已开始 + UI-->>User: toast"重建嵌入已开始" + end + + rect rgb(240, 255, 240) + Note over User,API: 切换Agent/VLM模型 + User->>UI: 选择Agent/VLM模型 + UI->>API: PUT /core/dataset/update + API-->>UI: 更新成功 + UI-->>User: toast"更新成功" + end + + rect rgb(255, 240, 255) + Note over User,API: 协作者管理 + User->>UI: 点击协作者设置图标 + UI->>API: GET /proApi/core/dataset/collaborator/list + API-->>UI: 协作者列表 + UI-->>User: MemberModal弹窗 + User->>UI: 添加/修改/移除协作者 + UI->>API: POST/PUT/DELETE 协作者接口 + API-->>UI: 操作成功 + UI-->>User: 列表刷新 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..b9d209953dc5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 数据集配置 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:44:40Z +parent_module: 数据集详情 +roles: [管理员, 协作者] +router_paths: [/dataset/detail?currentTab=info] +--- + +# 数据集配置 — 模块概览 + +## 1. 模块业务概述 + +数据集配置模块是数据集详情页的"配置"Tab页,为数据集管理员和协作者提供数据集的**核心参数查看与修改**能力。用户在此处可以查看数据集的基本信息(名称、头像、简介、ID、类型),配置数据集使用的 AI 模型(向量模型、Agent 模型、VLM 多模态模型),管理自动同步策略,以及维护协作者权限。该模块是数据集管理的控制中枢,所有影响数据集行为的参数均在此集中配置。 + +根据数据集类型不同,页面会动态显示差异化的配置项:例如外部文件类型可配置外部读取地址;API 数据集、语雀、飞书等类型可查看和编辑对应的服务连接配置。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员(hasManagePer) | 全部配置项 | 编辑基本信息、切换模型、重建向量索引、开关自动同步、管理协作者 | 拥有全部配置权限,可看到编辑按钮和协作者管理区域 | +| 普通成员(无管理权限) | 全部配置项(只读) | 仅查看配置信息 | 编辑按钮隐藏,协作者管理区域不可见,模型选择器不可操作 | + +**权限模型**: 基于 `datasetDetail.permission.hasManagePer` 的前端条件渲染。编辑按钮、模型切换、协作者管理区域均通过该字段控制可见性。训练中的数据集(`isTraining=true`)时向量模型选择器会被禁用并给出提示。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统配置(useSystemStore) | 获取模型列表、功能开关配置 | Store | +| 数据集上下文(DatasetPageContext) | 获取/更新数据集详情、训练队列状态 | Context | +| 权限模块(@fastgpt/global/support/permission) | 角色值常量、数据集角色列表 | 常量 | +| 共享组件(@fastgpt/web/components) | Avatar、MyDivider、FormLabel、MyIcon、MyTooltip、QuestionTip、useConfirm、useRequest | 组件/Hook | +| AI模型选择器 | AIModelSelector 模型下拉选择 | 组件 | + +## 4. 对外共享 + +本模块为数据集详情页内部子页面,不对外提供共享能力。Info 组件仅被数据集详情页路由使用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集配置 | /dataset/detail?currentTab=info | projects/app/src/pageComponents/dataset/detail/Info/index.tsx | 管理员、协作者 | 无子Tab,为单一配置表单页面 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ce5222b42008 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,50 @@ +--- +capability_label: 检索测试 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — 业务流程索引 + +## 普通数据集检索测试 + +- **业务描述**:在普通知识库数据集(向量/全文索引)上执行检索测试,支持 embedding 检索、全文检索、ReRank 重排序等多种搜索模式的组合,返回匹配数据块及其评分 +- **触发入口**:数据集详情页 → 切换到"检索测试"Tab +- **路由路径**:`/dataset/detail?currentTab=test` +- **页面组件位置**:`projects/app/src/pageComponents/dataset/detail/Test.tsx` +- **涉及角色**:所有可访问数据集详情的用户 +- **前置条件**:用户已进入某个普通类型数据集的详情页;数据集已完成导入和向量化训练 + +## 数据库数据集检索测试 + +- **业务描述**:在数据库类型数据集上执行检索测试,系统将用户输入的自然语言查询转为 SQL 语句并在数据库中执行,返回 SQL 查询结果和自然语言解析答案 +- **触发入口**:数据集详情页 → 切换到"检索测试"Tab(当数据集为数据库类型时自动切换为数据库检索模式) +- **路由路径**:`/dataset/detail?currentTab=test` +- **页面组件位置**:`projects/app/src/pageComponents/dataset/detail/Test.tsx` +- **涉及角色**:所有可访问数据集详情的用户 +- **前置条件**:用户已进入某个数据库类型数据集的详情页;数据集已配置数据库连接 + +## 查看检索测试历史 + +- **业务描述**:查看当前数据集下所有的检索测试历史记录,点击历史记录可回看该次测试的检索结果和参数 +- **触发入口**:检索测试 Tab 左侧面板 → 历史记录列表 +- **路由路径**:`/dataset/detail?currentTab=test` +- **涉及角色**:所有可访问数据集详情的用户 +- **前置条件**:当前数据集已有检索测试历史记录 + +## 场景关系 + +各场景独立执行,无严格先后关系。普通数据集检索测试和数据库数据集检索测试互斥,由数据集类型决定使用哪种模式。 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 普通数据集检索测试 | 数据集详情→检索测试Tab→输入文本→点击测试 | /dataset/detail?currentTab=test | 所有可访问用户 | +| S02 | 数据库数据集检索测试 | 数据集详情→检索测试Tab→输入文本→选择模型→点击测试 | /dataset/detail?currentTab=test | 所有可访问用户 | +| S03 | 查看检索测试历史 | 检索测试Tab→左侧历史记录列表 | /dataset/detail?currentTab=test | 所有可访问用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..ae0891766472 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,203 @@ +--- +capability_label: 检索测试 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — 业务流程详解 + +## 页面总览 + +检索测试 Tab 采用左右分栏布局:左侧为测试输入区(含测试文本输入框和历史记录列表),右侧为检索结果展示区。根据数据集类型不同,测试方式分为"普通检索测试"和"数据库检索测试"两种模式,由系统自动判断。 + +--- + +### S01:普通数据集检索测试 + +> 适用于向量/全文索引类型的知识库数据集。用户输入测试文本,系统按照配置的搜索模式执行检索,返回匹配度最高的数据块。 + +#### 步骤 1:配置检索参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"检索配置"按钮 | 无 | 数据集类型为普通知识库(非 database、非 structureDocument)时显示此按钮 | 弹出检索配置弹窗,展示搜索模式、embedding 权重/模型、ReRank 开关/模型/权重、相似度阈值、返回条数上限、问题扩展等参数 | + +**表单字段清单**(检索配置弹窗): + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 搜索模式 | 下拉选择 | 是 | embedding(向量检索) | embedding / fullText / hybridSearch | 选择检索算法 | +| embedding 权重 | 数值输入 | 否 | 0.5 | 0~1 之间 | 混合检索时 embedding 的权重占比 | +| embedding 模型 | 下拉选择 | 否 | 空 | 数据集关联的向量模型或系统默认 | 用于 embedding 的模型 | +| 使用 ReRank | 开关 | 否 | 开启 | 开启/关闭 | 是否启用重排序 | +| ReRank 模型 | 下拉选择 | 否 | 系统默认 ReRank 模型 ID | 系统已配置的 ReRank 模型 | 重排序使用的模型 | +| ReRank 方式 | 下拉选择 | 否 | content(内容) | content / embedding | 重排序的参照方式 | +| ReRank 权重 | 数值输入 | 否 | 0.5 | 0~1 之间 | ReRank 结果在最终排序中的权重 | +| 相似度阈值 | 数值输入 | 否 | 0 | ≥0 | 低于此阈值的搜索结果被过滤 | +| 返回条数上限 | 数值输入 | 否 | 5000 | 正整数 | 最多返回的数据块数量 | +| 问题扩展 | 开关 | 否 | 关闭 | 开启/关闭 | 是否使用大模型对测试文本进行扩展改写 | +| 扩展模型 | 下拉选择 | 否 | 系统默认 LLM 模型 ID | 可用的大语言模型列表 | 问题扩展时使用的模型(仅问题扩展开启时可见) | +| 扩展背景 | 文本输入 | 否 | 空 | 任意文本 | 问题扩展时的背景提示(仅问题扩展开启时可见) | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| embedding 权重 + ReRank 权重 ≤ 1 | 提交时 | 无前端校验(依赖后端校验) | + +#### 步骤 2:输入测试文本并执行检索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在文本框中输入测试文本 | 无 | 无 | 文本框获得焦点时边框高亮为蓝色;失焦后恢复默认边框 | +| 点击"测试"按钮 | POST `/core/dataset/searchTest`(携带 datasetId、text、searchMode、embeddingWeight、similarity、limit、usingReRank 等参数) | 接口返回空列表时触发警告提示"未检索到相关数据" | 按钮显示加载动画(isLoading);接口成功后:测试结果保存到历史记录,右侧面板展示检索结果,包括每条结果的 TOP 排名、匹配分数(RRF/ReRank/全文/Embedding 各项分数)、数据块内容(Q&A 或文本),同时显示本次检索的耗时和搜索参数摘要 | + +#### 步骤 3:查看检索结果详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看检索结果卡片 | 无 | 结果数量 > 0 时展示 | 每条结果显示为可展开卡片:标题为 TOP{N} 排名;头部展示各项评分详情;点击展开后显示数据块的 Q(问题)和 A(答案)或纯文本内容 | +| 点击结果链接"来源 / #{chunkIndex}" | 无 | 每条结果均有链接 | 新窗口打开数据详情页,定位到对应数据块 | + +--- + +### S02:数据库数据集检索测试 + +> 适用于数据库类型数据集。用户输入自然语言查询,系统通过大模型将查询转为 SQL 并执行,返回 SQL 查询语句和自然语言解析结果。 + +#### 步骤 1:选择检索模型并输入查询 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面上方选择大语言模型 | 无 | 数据集类型为 database 或 structureDocument 时,检索配置按钮被替换为大模型选择器和帮助提示 | 下拉框显示可用的大语言模型列表;旁边展示帮助提示说明"检索模型用于将自然语言转为 SQL 查询" | +| 在文本框中输入自然语言查询 | 无 | 无 | 文本框获得焦点时边框高亮;最大输入长度受数据集向量模型的 maxToken 限制 | + +#### 步骤 2:执行数据库检索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"测试"按钮 | POST `/core/dataset/database/searchTest`(携带 datasetId、query、modelId) | 接口返回空或 answer 为空时提示警告"未检索到相关数据" | 按钮显示加载动画;接口成功后右侧面板展示两部分内容:生成的 SQL 查询语句和自然语言解析答案 | + +#### 步骤 3:查看 SQL 与解析答案 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看 SQL 查询区域 | 无 | 有 sql_result 时展示实际 SQL 语句 | 卡片中展示大模型生成的 SQL 查询语句(等宽字体、保留换行格式) | +| 查看检索结果 | 无 | 有 answer 时展示解析结果 | 卡片中展示大模型对 SQL 执行结果的自然语言解析 | + +--- + +### S03:查看检索测试历史 + +#### 步骤 1:浏览历史记录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看左侧历史记录列表 | 无(数据来自 useSearchTestStore,已在本地持久化) | 无历史记录时展示空状态提示"暂无数据" | 历史记录列表按时间倒序展示,每条显示测试文本摘要和时间;每条最多保留 50 条记录 | + +#### 步骤 2:回看历史检索结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击历史记录条目 | 无 | 切换数据集时会自动清空当前选中结果 | 被选中的条目高亮显示(蓝色背景);右侧面板展示该条历史记录的完整检索结果和当时使用的检索参数 | + +#### 步骤 3:删除历史记录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 悬停历史记录条目 → 点击删除图标 | 无(Store 本地操作) | 无 | 悬停时时间文本隐藏,删除图标出现;点击后该条记录从列表和本地持久化存储中移除;如果删除的是当前选中条目,右侧面板结果同步清空 | + +--- + +## Mermaid 附录 + +### S01:普通数据集检索测试 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 切换到检索测试 Tab + UI->>UI: 读取数据集详情(DatasetPageContext) + alt 普通知识库数据集 + User->>UI: 点击"检索配置"按钮 + UI->>User: 弹出检索配置弹窗 + User->>UI: 配置搜索模式/权重/ReRank等参数 + User->>UI: 关闭弹窗,参数保存到表单 + end + User->>UI: 在文本框中输入测试文本 + User->>UI: 点击"测试"按钮 + UI->>API: POST /core/dataset/searchTest
(datasetId, text, searchMode, embeddingWeight,
similarity, limit, usingReRank, ...) + API->>Backend: 执行检索 + Backend-->>API: 返回匹配结果列表 + 评分 + 耗时 + API-->>UI: SearchDatasetTestResponse + alt 结果为空 + UI->>User: 警告提示"未检索到相关数据" + else 有结果 + UI->>UI: 保存到历史记录(Store) + UI->>User: 右侧展示 TOP{N} 结果卡片
包含各项评分和数据块内容 + User->>UI: 点击展开卡片查看 Q&A + User->>UI: 点击来源链接跳转数据详情 + end +``` + +### S02:数据库数据集检索测试 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 切换到检索测试 Tab + UI->>UI: 读取数据集详情,识别为数据库类型 + UI->>User: 展示大模型选择器(替换检索配置按钮) + User->>UI: 选择大语言模型 + User->>UI: 输入自然语言查询 + User->>UI: 点击"测试"按钮 + UI->>API: POST /core/dataset/database/searchTest
(datasetId, query, modelId) + API->>Backend: 大模型将自然语言转 SQL → 执行 SQL + Backend-->>API: 返回 SQL 语句 + 解析答案 + API-->>UI: DatabaseSearchTestResponse + alt 无结果 + UI->>User: 警告提示"未检索到相关数据" + else 有结果 + UI->>UI: 保存到历史记录(Store) + UI->>User: 右侧展示 SQL 查询语句卡片 + UI->>User: 展示自然语言解析答案卡片 + end +``` + +### S03:查看检索测试历史 + +```mermaid +sequenceDiagram + participant User + participant UI + participant Store + + User->>UI: 查看左侧历史记录列表 + UI->>Store: 读取 datasetTestList(localStorage 持久化) + Store-->>UI: 返回当前数据集的历史记录(按时间倒序) + alt 无历史记录 + UI->>User: 展示空状态"暂无数据" + else 有历史记录 + UI->>User: 展示历史记录列表 + User->>UI: 点击某条历史记录 + UI->>UI: 该条目高亮选中 + UI->>User: 右侧展示该条记录对应的检索结果和参数 + User->>UI: 悬停某条记录 → 点击删除图标 + UI->>Store: delDatasetTestItemById(id) + Store->>Store: 从列表和 localStorage 中移除 + UI->>User: 该记录消失;如为当前选中则结果区同步清空 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..10173410204d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,16 @@ +--- +capability_label: 检索测试 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +## 说明 + +本模块未检测到对外共享能力(检索测试 Tab 为数据集详情页内部组件,不被其他模块独立引用)。 + +跨模块依赖摘要请参见 [模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..d3e19bca0cc3 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,48 @@ +--- +capability_label: 检索测试 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — 模块概览 + +## 1. 模块业务概述 + +检索测试是数据集详情页中的一个功能 Tab,用于验证数据集的检索效果。用户输入测试文本后,系统按照配置的检索模式(混合检索、ReRank 重排序等)对数据集内容进行检索,返回匹配度最高的数据块及评分,帮助用户评估数据集的检索质量和参数配置是否合理。 + +本模块支持两种检索测试方式: +- **向量/全文检索测试**:适用于普通知识库数据集,支持 embedding 检索、全文检索、ReRank 重排序等多种模式的组合检索 +- **数据库检索测试**:适用于数据库类型数据集,通过大模型将自然语言查询转为 SQL 并在数据库中执行,返回查询结果 + +## 2. 角色差异 + +本模块为数据集详情页的功能 Tab,无独立的角色权限控制。用户的访问权限由父级"数据集详情"页面统一控制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 全局常量 | DatasetSearchModeEnum、RerankMethodEnum、SearchScoreTypeEnum 等数据集搜索相关枚举和类型 | 类型/常量 | +| 数据集 API 层 | postSearchText(文本检索测试)、postDatasetCollectionSearchTest(数据库检索测试) | API | +| 数据集 Store | useSearchTestStore(检索测试历史记录管理) | Store | +| 数据集上下文 | DatasetPageContext(获取数据集详情信息) | Context | +| 共享组件 | ChunkInfoCard、SearchParamsTip、DatasetParamsModal、AIModelSelector | 组件 | + +## 4. 对外共享 + +本模块为数据集详情页内部 Tab 子组件,不对外暴露共享能力。跨模块依赖摘要见上表。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 备注 | +|---------|---------|-------------|------| +| 数据集详情 — 检索测试 Tab | /dataset/detail?currentTab=test | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 数据集详情页的检索测试功能 Tab | + +## 6. 子能力索引 + +本模块为 leaf 节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..56d3a2d5d519 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,72 @@ +--- +capability_label: "数据集详情" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: "数据集" +roles: ["所有用户", "管理员"] +router_paths: ["/dataset/detail"] +--- + +# 数据集详情 — 模块概览 + +## 1. 模块业务概述 + +数据集详情模块是 FastGPT 知识库管理的核心页面,用户从数据集列表进入某个具体数据集后,在此页面完成对数据集内容的全部管理操作。本模块通过 Tab 切换的方式提供集合管理、数据卡片查看、检索测试、数据导入、同义词管理和数据集配置等功能。 + +该页面为所有有数据集访问权限的用户提供服务,其中管理员额外拥有配置和管理的权限。模块支持多种数据集类型(通用文档、数据库、网站、API 数据集等),不同类型的知识库会在 Tab 展示和行为上有差异。 + +本模块包含 7 个子能力(Tab),分别为: +- **集合管理**:数据集的文件/数据集合管理,支持文件夹结构、导入、删除、标签、移动等操作 +- **数据卡片**:查看集合内的数据分片详情,支持编辑和删除 +- **结构化数据卡片**:结构化文档(表格类)的数据查看,以行列形式展示 +- **检索测试**:对数据集进行语义检索测试,验证知识库的检索效果 +- **导入数据**:向导式数据导入流程,支持多种数据源 +- **同义词管理**:管理数据集的同义词,提升检索准确率 +- **数据集配置**:数据集的基础信息编辑、权限管理(移动端专属) + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 普通用户 | 集合管理、数据卡片、结构化数据卡片、检索测试、同义词 | 查看集合和数据、执行检索测试、查看同义词 | 不能修改数据集配置 | +| 管理员 | 全部 Tab(含数据集配置) | 查看、导入、编辑、删除、配置 | 拥有 `datasetDetail.permission.hasManagePer` 权限 | + +**权限模型**: 基于数据集级别的权限控制(`permission.hasManagePer`、`permission.hasWritePer`、`permission.hasReadPer`),在 `DatasetPageContext` 中通过 `getDatasetById` 加载完整的数据集信息和权限数据。NavBar 中通过 `datasetDetail.permission.hasManagePer` 控制"配置"Tab 的可见性。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集列表 | 数据集路径信息(`paths`)、数据集搜索 | Context/API | +| 工作台 | 页面布局组件(`DashboardNavbar`、`BgDecoration`) | 组件 | +| 通用组件库 | `PageContainer`、`MyBox`、`MyTabs`、`LightRowTabs`、`FolderPath`、`MyIcon` | 组件 | +| i18n | `common`、`dataset`、`file`、`user` 等命名空间翻译词条 | 国际化 | +| 通用 Hooks | `useToast`、`useRequest`、`useSystem` | Hook | + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `DatasetPageContext` | 所有数据集详情 Tab 子组件、数据集列表页 | Context | +| `CollectionPageContext` | 集合管理相关组件 | Context | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集详情 | `/dataset/detail?datasetId={id}¤tTab={tab}` | `projects/app/src/pages/dataset/detail/index.tsx` | 所有用户 | 含 7 个 Tab(详见 [业务流程索引](./业务流程索引.md)),Tab 通过 `currentTab` 查询参数切换 | + +## 6. 子能力索引 + +本模块为 leaf 节点但包含 Tab 子视图,各 Tab 作为独立的子能力管理: + +| 子能力 | Tab | 文档链接 | +|--------|-----|---------| +| 集合管理 | collectionCard | [模块概览](./集合管理/模块概览.md) | +| 数据卡片 | dataCard | [模块概览](./数据卡片/模块概览.md) | +| 结构化数据卡片 | fileDataCard | [模块概览](./结构化数据卡片/模块概览.md) | +| 检索测试 | test | [模块概览](./检索测试/模块概览.md) | +| 导入数据 | import | [模块概览](./导入数据/模块概览.md) | +| 同义词管理 | synonym | [模块概览](./同义词管理/模块概览.md) | +| 数据集配置 | info | [模块概览](./数据集配置/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e54972728848 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,33 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — 业务流程索引 + +## 业务场景列表 + +### 查看结构化数据预览 + +- **业务描述**: 用户在数据集详情页点击一个文件数据库类型的集合后,进入数据预览视图,以表格形式查看该集合的列结构和前 20 行数据。 +- **触发入口**: 数据集详情页 → 集合列表中点击某个集合名称 +- **路由路径**: `/dataset/detail?currentTab=fileDataCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/FileDataCard.tsx` +- **涉及角色**: 有数据集访问权限的用户 +- **前置条件**: 已进入数据集详情页(`/dataset/detail`),且 URL 中携带 `collectionId` 参数 + +## 场景关系 + +本模块仅包含一个业务场景,无场景间依赖关系。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看结构化数据预览 | 数据集详情→点击集合 | /dataset/detail?currentTab=fileDataCard | 有数据集权限的用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..beb0ccad2979 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,103 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — 业务流程详解 + +## 页面总览 + +本页面提供文件数据库类型知识库集合的**只读数据预览**功能。页面以表格形式展示集合的列名(表头)和数据行(最多 20 行),同时显示总行数和总列数统计信息。用户可通过返回按钮退出预览,回到集合列表视图。 + +--- + +### 查看结构化数据预览 + +> 用户在数据集详情页中点击某个文件数据库集合后,进入数据预览视图,查看该集合的列结构和前 20 行数据。 + +#### 步骤 1:进入数据预览页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在数据集详情页点击集合列表中的某个集合名称 | — | 路由携带 `collectionId` 参数 | 页面切换到 fileDataCard Tab 视图,显示加载状态(MyBox 遮罩层) | + +#### 步骤 2:加载集合基本信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无用户操作) | `GET /core/dataset/collection/detail?id={collectionId}` — 获取集合详情,与步骤 3 并行发起 | — | 无可见变化(数据用于面包屑文件名显示) | +| — | — | API 请求失败 | 自动跳转回数据集详情页(移除 `collectionId` 参数,回到 collectionCard Tab) | + +#### 步骤 3:加载结构化预览数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无用户操作) | `GET /core/dataset/database/preview?collectionId={collectionId}` — 获取结构化预览数据,与步骤 2 并行发起 | — | 加载遮罩显示中 | +| — | — | API 请求成功,返回列和数据 | 加载遮罩消失,展示列头和数据表格,显示统计信息"共 N 行,M 列,仅支持预览前 20 行" | +| — | — | API 请求成功,但返回空数据(无列或无行) | 显示空状态提示"这个集合还没有数据~" | +| — | — | API 请求失败 | 自动跳转回数据集详情页 | + +#### 步骤 4:查看表格数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停在列头或单元格上 | — | 文本内容超出列宽 | 显示 Tooltip 完整内容 | +| 滚动表格区域 | — | 数据行超过可视区域高度 | 表格区域滚动,表头固定 | +| 查看列头信息 | — | — | 每列宽度均分(`100% / 列数`),列头背景为 myGray.100,首列左侧圆角、末列右侧圆角 | + +#### 步骤 5:返回上级页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击左上角返回按钮(圆形箭头图标 + 文件名)| — | — | 路由切换回 `currentTab=collectionCard`,导航栏恢复显示 Tab 列表和操作按钮 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/dataset/collection/detail | `id={collectionId}` | 提取 `sourceName` 用于面包屑显示 | 返回按钮旁显示文件名 | +| 首次加载 | GET /core/dataset/database/preview | `collectionId={collectionId}` | 取 `cols` 作为表头,`data` 的前 20 行作为表格行,`columnCount` 和 `rowCount` 用于统计信息 | 表格展示列头和数据行,顶部显示总行列统计 | + +- **分页**: 无分页,仅展示前 20 行 +- **排序**: 不支持用户排序 +- **筛选**: 不支持筛选 +- **特殊列的渲染**: 所有列以纯文本渲染,空值显示 `-` + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 页面组件 + participant API as 后端 API + + User->>UI: 点击集合名称进入预览 + UI->>UI: 显示加载遮罩 + + par 并行请求 + UI->>API: GET /core/dataset/collection/detail?id={collectionId} + and + UI->>API: GET /core/dataset/database/preview?collectionId={collectionId} + end + + alt 两个 API 均成功 + API-->>UI: 返回集合信息 + 预览数据 + UI->>UI: 关闭遮罩,渲染表格 + UI-->>User: 展示列头和数据行,统计信息 + else 任一 API 失败 + API-->>UI: 返回错误 + UI->>UI: 跳转回数据集详情页 + end + + User->>UI: 悬停单元格查看完整内容 + UI-->>User: 显示 Tooltip + + User->>UI: 点击返回按钮 + UI->>UI: 切换回 collectionCard Tab +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..724ce1ecdee7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — 模块概览 + +## 模块业务概述 + +结构化数据卡片是数据集详情页的子视图,用于预览**文件数据库类型知识库**中某个集合(Collection)的结构化数据内容。当用户在数据集详情页点击某个文件型数据集合时,系统会切换到本视图,以**只读表格**形式展示该集合的列结构和前 20 行数据,帮助用户快速验证导入数据的结构和内容是否符合预期。 + +本模块面向**所有可访问数据集的用户**,仅提供数据查看功能,无编辑、删除或导出操作。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 有数据集访问权限的用户 | 结构化数据预览页 | 查看表格数据、返回上级 | 所有有权限用户可见内容一致 | + +**权限模型**: 依赖数据集详情页的路由权限控制。用户需先拥有目标数据集的访问权限才能进入数据集详情页,进而查看本视图。无独立权限控制。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集 API 层 | `getStructureCollectionPreview` — 获取结构化集合预览数据 | API | +| 数据集 API 层 | `getDatasetCollectionById` — 获取集合基本信息 | API | +| Web 共享组件 | `MyBox` — 带加载状态的容器组件 | 组件 | +| Web 共享组件 | `MyIcon`、`MyTooltip`、`EmptyTip` — 通用 UI 组件 | 组件 | +| NavBar | `TabEnum` — 数据集详情页 Tab 枚举定义 | 常量 | + +## 对外共享 + +本模块为数据集详情页的内部子视图,不对其他模块提供共享能力。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 结构化数据预览 | /dataset/detail?currentTab=fileDataCard | `projects/app/src/pageComponents/dataset/detail/FileDataCard.tsx` | 有数据集权限的用户 | 无 Tab,单一表格预览视图 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2c8e4d16992f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,66 @@ +--- +capability_label: 集合管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — 业务流程索引 + +## 集合列表浏览 + +- **业务描述**: 进入数据集详情页的集合管理 Tab,查看数据集内所有集合的列表,支持分页、搜索、按状态/标签筛选、按名称/时间/数据量排序。 +- **触发入口**: 数据集详情页 → 点击「集合管理」Tab(默认 Tab) +- **路由路径**: `/dataset/detail?currentTab=collectionCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx` +- **涉及角色**: owner、collaborator、viewer +- **前置条件**: 用户有该数据集的读权限;数据集已加载 + +## 单个集合操作 + +- **业务描述**: 对表格中的单个集合执行操作,包括查看/下载源文件、重命名、移动、删除、标签管理、权限配置、同步(仅链接类型)。 +- **触发入口**: 集合列表 → 点击行尾操作菜单(三个点图标) +- **路由路径**: `/dataset/detail?currentTab=collectionCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx` +- **涉及角色**: owner、collaborator(hasWritePer 或 hasManagePer) +- **前置条件**: 用户有该集合的写权限 + +## 批量操作 + +- **业务描述**: 选中多个集合后,对选中的集合执行批量操作:批量删除、批量下载、批量同步(链接类型)、批量设置标签。 +- **触发入口**: 勾选集合前的 Checkbox → 底部浮动操作栏 +- **路由路径**: `/dataset/detail?currentTab=collectionCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx` +- **涉及角色**: owner、collaborator(hasWritePer 或 hasManagePer) +- **前置条件**: 至少选中一个集合;被批量删除/下载/同步的集合均为可见 + +## 数据库表管理 + +- **业务描述**: 当数据集类型为数据库(database)时,使用 DatabaseListTable 组件渲染数据库表格式列表,支持编辑数据结构、查看训练状态、移除表等操作。 +- **触发入口**: 数据集详情页 → 集合管理 Tab(数据库数据集) +- **路由路径**: `/dataset/detail?currentTab=collectionCard` +- **页面组件位置**: `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx` + `DatabaseListTable` +- **涉及角色**: owner、collaborator +- **前置条件**: 数据集类型为 database;数据集有 databaseConfig + +## 场景关系 + +- **集合列表浏览** → **单个集合操作**: 浏览是操作的前置场景,用户先浏览列表再对具体集合执行操作 +- **集合列表浏览** → **批量操作**: 浏览是批量操作的前置场景,用户先勾选集合再执行批量操作 +- **数据库表管理**: 与普通集合列表互斥,取决于数据集类型 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 集合列表浏览 | 数据集详情→集合管理 Tab(默认) | /dataset/detail?currentTab=collectionCard | owner, collaborator, viewer | +| S02 | 单个集合操作 | 集合列表→行尾操作菜单 | /dataset/detail?currentTab=collectionCard | owner, collaborator (hasWritePer+) | +| S03 | 批量操作 | 集合列表→勾选→浮动操作栏 | /dataset/detail?currentTab=collectionCard | owner, collaborator (hasWritePer+) | +| S04 | 数据库表管理 | 数据集详情→集合管理 Tab(数据库数据集) | /dataset/detail?currentTab=collectionCard | owner, collaborator | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..7486f13ed5f4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,352 @@ +--- +capability_label: 集合管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — 业务流程详解 + +## 页面总览 + +集合管理页面是数据集详情页的核心 Tab,以表格形式展示数据集内所有集合。页面根据数据集类型自适应:普通/链接/API/结构化文档数据集使用通用表格视图,数据库数据集使用 DatabaseListTable 专用表格视图。页面集成分页、搜索、筛选、排序、轮询刷新、拖拽移动、权限控制等能力。 + +--- + +### S01 集合列表浏览 + +> 用户进入数据集详情页的集合管理 Tab,浏览数据集内的所有集合,支持分页浏览、搜索、按状态筛选、按标签筛选、按字段排序。 + +#### 步骤 1:页面初始化与首次加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入数据集详情页(默认 Tab 为 collectionCard),或从其他 Tab 切换到集合管理 Tab | `POST /core/dataset/collection/listV2`(参数:datasetId, parentId, pageNum=1, pageSize=20) | 数据集类型为 database → 渲染 DatabaseListTable 组件;非 database → 渲染通用 Table 组件 | 显示 MyBox 加载遮罩 → 表格数据加载完成后显示集合列表;如果是数据库数据集,顶部显示蓝色 Info Alert 提示「数据库结构变更提示」 | + +#### 步骤 2:分页浏览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Pagination 分页组件的页码或上一页/下一页 | `POST /core/dataset/collection/listV2`(参数:pageNum=N, 保持其他参数) | 无 | 显示加载遮罩 → 表格切换到第 N 页数据;分页组件更新当前页码 | + +#### 步骤 3:搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入文本(搜索框在父组件 Header 中,通过 CollectionPageContext 共享 searchText 状态) | `POST /core/dataset/collection/listV2`(参数:searchText 更新后自动触发,reset 到 pageNum=1) | searchText 为空 → 恢复正常列表;有匹配结果 → 显示筛选后列表;无匹配结果 → 显示空状态 EmptyCollectionTip | 表格数据按搜索关键词过滤后刷新 | + +#### 步骤 4:状态筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表头「状态」列的 StatusFilter 下拉器,选择状态值(queued/parsing/indexing/active/error) | `POST /core/dataset/collection/listV2`(参数:status 更新后自动触发,reset 到 pageNum=1) | 数据集有异常集合 → 状态列旁显示「全部重试」蓝色链接 | 表格数据按状态过滤后刷新;选中状态值高亮显示 | + +#### 步骤 5:标签筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 仅在 feConfigs.isPlus 为真时可见。点击表头「标签」列的 TagFilter 筛选器 | `POST /core/dataset/collection/listV2`(参数:filterTags 更新后自动触发) | isPlus 为假 → 不显示标签列和标签筛选器 | 表格数据按标签过滤后刷新 | + +#### 步骤 6:排序 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表头可排序列(名称、数据量、创建时间、更新时间)的排序图标 | `POST /core/dataset/collection/listV2`(参数:sortBy + sortOrder) | 点击同一列 → 升序/降序切换;点击不同列 → 默认升序 | 排序图标切换(未排序列显示灰色双向箭头,排序列显示蓝色单向箭头);表格按排序列和方向重新请求并刷新 | + +#### 步骤 7:轮询刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无用户操作,系统自动触发 | `POST /core/dataset/collection/listV2`(静默轮询,不显示加载遮罩) | 存在处理中集合(queued/parsing/indexing)→ 每 10s 轮询一次;存在训练数据(trainingAmount > 0)或数据集状态为 active → 每 10s 轮询一次(带重试间隔) | 不显示加载遮罩(isPollingRef 控制),表格数据静默更新;无处理中集合或训练数据后停止轮询 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/dataset/collection/listV2 | datasetId, parentId, pageNum=1, pageSize=20 | 按 filterTagValues 前端过滤 → formatCollections(计算图标、状态文本、颜色) | 表格前 20 条 | +| 翻页 | POST /core/dataset/collection/listV2 | pageNum=N, pageSize=20 | 同上 | 表格第 N 页 | +| 搜索 | POST /core/dataset/collection/listV2 | searchText + pageNum=1, pageSize=20 | 同上 | 搜索结果首页 | +| 状态筛选 | POST /core/dataset/collection/listV2 | status + pageNum=1, pageSize=20 | 同上 | 筛选结果首页 | +| 排序 | POST /core/dataset/collection/listV2 | sortBy + sortOrder + pageNum=1, pageSize=20 | 同上 | 排序后首页 | +| 轮询刷新 | POST /core/dataset/collection/listV2 | 同上次请求参数 | 同上,isPollingRef=true 静默 | 表格数据静默更新 | + +- 分页参数:默认每页 20 条 +- 排序规则:默认无排序(sortBy=null),用户可点击名称、数据量、创建时间、更新时间列排序 +- 筛选条件:搜索框(文本搜索)+ 状态下拉器(active/queued/parsing/indexing/error)+ 标签筛选器(多标签组合) +- 状态列的渲染逻辑:文件夹显示「-」灰色;tableSchema.exist 为 false 显示「表不存在」灰色;hasError 为 true 显示「异常」红色可点击标签;trainingAmount > 0 根据 CollectionStatusEnum 显示 queued(灰色)/parsing(蓝色)/indexing(蓝色)带进度;trainingAmount = 0 显示 active 绿色 +- 标签列仅 feConfigs.isPlus 时显示,最多显示 2 个标签 + 溢出计数,超过 2 个显示 MyPopover 悬浮展开 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 集合管理页面 + participant API as /core/dataset/collection + + User->>UI: 进入集合管理 Tab + UI->>API: POST /listV2 (datasetId, parentId, pageNum=1, pageSize=20) + API-->>UI: 返回集合列表 + 分页信息 + UI-->>User: 显示首页集合表格 + + loop 处理中集合存在时每10s + UI->>API: POST /listV2 (静默轮询) + API-->>UI: 返回最新列表 + UI-->>User: 静默更新状态和进度 + end + + User->>UI: 搜索/筛选/排序/翻页 + UI->>API: POST /listV2 (含对应参数) + API-->>UI: 返回筛选后列表 + UI-->>User: 刷新表格 +``` + +--- + +### S02 单个集合操作 + +> 用户点击行尾操作菜单,对单个集合执行重命名、移动、删除、标签、权限、查看源文件、同步(链接类型)等操作。 + +#### 步骤 1:打开操作菜单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击集合行尾的「更多」图标(三个点) | 无 | 集合类型为 folder → 菜单项为 [权限, 移动, 重命名, 删除];类型为 link → 菜单项为 [同步, 同步设置, 标签, 权限, 源查看, 移动, 重命名, 删除];普通文件 → 菜单项为 [标签, 权限, 源查看, 移动, 重命名, 删除];结构化文档 → 无移动和重命名 | 弹出 MyMenu 下拉菜单;权限不足的项半透明不可点击 | + +#### 步骤 2a:查看/下载源文件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「查看原文」/「下载文件」/「查看图片」项 | `POST /core/dataset/collection/read`(参数:collectionId) | API 数据集 → 以绝对 URL 或拼接 baseUrl 在新窗口打开;链接/`.txt` 文件 → 在 `[链接]` 新窗口打开;图片类型 → 在 `[图片]` 新窗口打开;普通文件 → 以 `/` 开头在当前域名打开,否则直接用返回值打开 | 显示加载中菜单项状态 → 新窗口打开文件/源 | + +**失败场景**:API 返回空 value → toast 提示「文件未找到」。 + +#### 步骤 2b:重命名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「重命名」项 | `POST /core/dataset/collection/update`(参数:id, name) | 无 | 弹出 EditTitleModal(useEditTitle hook),默认值填充当前名称 → 用户修改名称后确认 → 调用 API 更新 → toast「更新成功」→ 刷新列表 | + +#### 步骤 2c:移动 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「移动」项 | 先弹 SelectCollections 选目标文件夹;确认后→ `POST /core/dataset/collection/update` 更新 parentId | 无重名 → 直接移动成功;有重名 → 弹出 MoveCollectionDuplicateModal 选择「跳过」「覆盖」「保留两者」处理 | 弹出 SelectCollections 文件夹选择器(type=folder)→ 选择目标文件夹后弹出 MyModal 权限选择:[继承文件夹权限] 或 [保持独立权限] → 选择后检查重名(POST checkDuplicate)→ 无重名直接移动;有重名弹出 MoveCollectionDuplicateModal | + +**移动权限选择弹窗文案**: 「请选择移动后的权限策略」 + +**重名处理弹窗**: MoveCollectionDuplicateModal 提供三个选项: +- 跳过 — 取消此次移动 +- 继续移动(覆盖) — 使用 overwriteDuplicate=true +- 继续移动(替换文件) — 使用 overwriteDuplicate=true + +#### 步骤 2d:删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「删除」项 | 弹出删除确认弹窗 → 确认后调用 `POST /core/dataset/collection/delete`(参数:collectionIds: [id]) | 文件夹类型 → 弹窗文案「确认删除该文件夹」;普通文件 → 弹窗文案「确认删除该文件」;数据库 → 弹窗标题「remove_warning」,文案「确认移除此数据库表」 | 弹出 useConfirm 确认弹窗 → 确认后调用 API → toast「删除成功」→ 刷新列表 | + +#### 步骤 2e:标签管理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「标签」项(仅 isPlus 且非结构化文档) | 无(操作在 SetTagsModal 中完成) | 无 | 弹出 SetTagsModal 弹窗,传入当前集合数据 | + +#### 步骤 2f:权限配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「权限」项 | `GET /core/dataset/collection/collaborator/list`(打开时加载协作者列表);弹窗中修改 → `POST /core/dataset/collection/collaborator/update`;移除协作者 → `DELETE /core/dataset/collection/collaborator/delete` | 文件夹类型 → 显示 effectScope 权限生效范围选项;非文件夹 → 隐藏;有父级 → 显示「恢复继承权限」按钮 | 弹出 ConfigPerModal → 显示集合名称、权限继承状态、协作者列表等 | + +**权限模型细节**: +- `inheritPermission`: 是否继承父文件夹权限 +- `permission.hasManagePer`: 管理权限 +- `permissionEffectScope`: 文件夹类型时,权限生效范围 +- 可变更所有者(postChangeCollectionOwner) +- 可恢复继承(postResumeCollectionInheritPermission) + +#### 步骤 2g:链接同步 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「同步」项 | 弹出确认弹窗 → 确认后调用 `POST /core/dataset/collection/sync`(参数:collectionId) | 无 | 弹出 useConfirm 确认弹窗(文案「确认同步该链接集合?」)→ 确认后调用 API → 同步完成后 toast 显示同步结果状态文案 → 刷新列表 | + +#### 步骤 2h:同步设置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击菜单中的「同步设置」项 | 无(设置弹窗);确认后 → `POST /core/dataset/collection/update`(参数:id, autoSync) | 无 | 弹出 MyModal 设置弹窗 → 显示「同步计划」开关 → 用户切换 autoSync → 确认后调用 API 更新 | + +#### 步骤 2i:启用/禁用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击行内「启用」列的 Switch 开关 | `POST /core/dataset/collection/update`(参数:id, forbid: !checked) | 无 | Switch 切换动画 → 调用 API → toast「更新成功」→ 刷新列表 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 集合管理页面 + participant API as /core/dataset/collection + + User->>UI: 点击行尾「更多」菜单 + UI-->>User: 弹出操作菜单(按集合类型+权限差异化) + + alt 重命名 + User->>UI: 点击「重命名」 + UI-->>User: 弹出 EditTitleModal + User->>UI: 输入新名称并确认 + UI->>API: POST /update (id, name) + API-->>UI: 更新成功 + UI-->>User: toast「更新成功」+ 刷新列表 + else 移动 + User->>UI: 点击「移动」 + UI-->>User: 弹出文件夹选择器 + User->>UI: 选择目标文件夹 + UI-->>User: 弹出权限策略选择弹窗 + User->>UI: 选择策略 + UI->>API: POST checkDuplicate + API-->>UI: 返回重名结果 + alt 无重名 + UI->>API: POST /update (id, parentId) + API-->>UI: 更新成功 + else 有重名 + UI-->>User: 弹出重名处理弹窗 + User->>UI: 选择处理方式 + UI->>API: POST /update (id, parentId, overwriteDuplicate) + API-->>UI: 更新成功 + end + UI-->>User: toast「移动成功」+ 刷新列表 + else 删除 + User->>UI: 点击「删除」 + UI-->>User: 弹出确认弹窗 + User->>UI: 确认删除 + UI->>API: POST /delete (collectionIds) + API-->>UI: 删除成功 + UI-->>User: toast「删除成功」+ 刷新列表 + else 查看/下载源文件 + User->>UI: 点击「查看原文」/「下载文件」 + UI->>API: POST /read (collectionId) + API-->>UI: 返回源文件 URL + UI-->>User: 新窗口打开文件 + else 启用/禁用 + User->>UI: 点击 Switch 开关 + UI->>API: POST /update (id, forbid) + API-->>UI: 更新成功 + UI-->>User: toast「更新成功」+ 刷新列表 + end +``` + +--- + +### S03 批量操作 + +> 用户勾选多个集合后,通过底部浮动操作栏执行批量删除、批量下载、批量同步(链接类型)、批量设置标签等操作。 + +#### 步骤 1:勾选集合 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选集合行前的 Checkbox;或点击表头 Checkbox 全选 | 无(前端状态管理) | 无 | 选中的集合行高亮;底部浮动操作栏 FloatingActionBar 显示已选数量和控制按钮 | + +#### 步骤 2a:批量删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击浮动操作栏「批量删除」按钮 | 弹出确认弹窗 → 确认后调用 `POST /core/dataset/collection/delete`(参数:collectionIds: [id1, id2, ...]) | 无 | 弹出确认弹窗(文案「确认删除 {num} 个集合?」)→ 确认后调用 API → toast「删除成功」→ 清空选择 → 刷新列表 | + +**失败场景**:部分删除失败 → toast「删除失败」。 + +#### 步骤 2b:批量下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击浮动操作栏「批量下载」按钮 | `POST /core/dataset/collection/batchDownload`(参数:collectionIds: [...], responseType: blob) | 无 | 显示下载中状态(按钮 loading)→ 获取 Blob → 创建临时 URL → 自动触发浏览器下载 → 文件名格式 `collections-{timestamp}.zip` | + +#### 步骤 2c:批量同步 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 仅在选中集合中包含 link 类型时可见。点击「批量同步」按钮 | 依次对每个 link 集合调用 `POST /core/dataset/collection/sync` | 无 link 集合 → 按钮不显示 | 依次同步 → 每个同步完成后 toast 显示对应结果 | + +#### 步骤 2d:批量设置标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 仅在 isPlus 且非结构化文档时可见。点击「批量设置标签」按钮 | 无(操作在 BatchSetTagsModal 中完成) | isPlus 为假 → 按钮不显示;结构化文档 → 按钮不显示 | 弹出 BatchSetTagsModal 弹窗,传入选中集合列表 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 集合管理页面 + participant API as /core/dataset/collection + + User->>UI: 勾选多个集合 + UI-->>User: 显示浮动操作栏 + + alt 批量删除 + User->>UI: 点击「批量删除」 + UI-->>User: 弹出确认弹窗 + User->>UI: 确认删除 + UI->>API: POST /delete (collectionIds: [...]) + API-->>UI: 删除成功 + UI-->>User: toast「删除成功」+ 清空选择 + 刷新列表 + else 批量下载 + User->>UI: 点击「批量下载」 + UI->>API: POST /batchDownload (collectionIds: [...], blob) + API-->>UI: 返回 ZIP Blob + UI-->>User: 触发浏览器下载 ZIP + else 批量同步 + User->>UI: 点击「批量同步」 + loop 每个 link 集合 + UI->>API: POST /sync (collectionId) + API-->>UI: 返回同步结果 + end + UI-->>User: toast 各同步结果 + end +``` + +--- + +### S04 数据库表管理 + +> 当数据集类型为 database 时,使用 DatabaseListTable 组件渲染数据库表格式列表,而非通用表格。 + +#### 步骤 1:数据库表列表展示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入数据库数据集的集合管理 Tab | `POST /core/dataset/collection/listV2`(同上) | datasetDetail.type === DatasetTypeEnum.database → 渲染 DatabaseListTable;非 database → 渲染通用 Table | 顶部显示蓝色 Info Alert → 渲染 DatabaseListTable(含表名、训练状态、数据配置等列) | + +#### 步骤 2:数据库表操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| DatabaseListTable 中的具体操作由该组件处理(编辑数据结构、查看训练状态、移除表) | DatabaseListTable 内部 API 调用 | 无 | DatabaseListTable 内部处理 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 集合管理页面 + participant DBT as DatabaseListTable + participant API as /core/dataset/collection + + User->>UI: 进入数据库数据集集合管理 + UI->>API: POST /listV2 (datasetId, parentId) + API-->>UI: 返回数据库表列表 + UI->>DBT: 渲染数据库表格式列表 + DBT-->>User: 显示数据库表列表(含训练状态、数据配置操作) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..4614d894dcea --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,62 @@ +--- +capability_label: 集合管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — 模块概览 + +## 1. 模块业务概述 + +集合管理是数据集详情页的核心模块,负责展示和管理数据集内的所有集合(Collection)。集合是数据集的基本组成单元,可以是上传的文件、链接、文本、文件夹等类型。本模块为数据集拥有者和协作者提供集合的查看、搜索、筛选、排序、单个操作(重命名、移动、删除、标签管理、权限配置、同步)、批量操作(批量删除、批量下载、批量同步、批量标签)等完整管理能力。 + +模块根据数据集类型(普通数据集、数据库数据集、结构化文档数据集、API 数据集)自适应展示不同的表格列和操作菜单,确保不同类型数据集的集合管理体验一致且高效。 + +本模块无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| owner | 集合列表 | 全部操作(查看、下载、重命名、移动、删除、标签管理、权限配置、同步、批量操作) | 拥有完整管理权限 | +| collaborator (hasManagePer) | 集合列表 | 重命名、移动、删除、标签管理、权限配置、同步、批量操作 | 管理权限与 owner 类似,可管理权限分配 | +| collaborator (hasWritePer) | 集合列表 | 查看、下载、重命名、移动、标签管理、同步 | 可编辑内容但不可删除和配置权限 | +| viewer (hasReadPer) | 集合列表(只读) | 仅查看 | 行不可点击,操作菜单不显示 | + +**权限模型**: 基于集合级别的细粒度权限控制(`permission.hasReadPer` / `permission.hasWritePer` / `permission.hasManagePer`),通过 `ConfigPerModal` 组件进行权限配置。集合支持继承父文件夹权限(`inheritPermission`)和独立权限设置。权限判断入口在 `index.tsx` 中多个位置:行点击(只读禁止)、操作菜单项(按权限禁用)、批量操作按钮(仅写权限以上可用)。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 数据集页面上下文(DatasetPageContext) | 提供 datasetDetail、allDatasetTags、loadDatasetDetail 等数据集级状态 | Context | +| 集合页面上下文(CollectionPageContext) | 提供 collections、分页、搜索、筛选、排序等集合列表状态管理 | Context | +| @fastgpt/web 组件库 | MyIcon、MyModal、MyMenu、MyBox、MyTag、MyTooltip、MyPopover 等通用 UI 组件 | 组件 | +| @fastgpt/global 数据集模块 | DatasetCollectionTypeEnum、CollectionStatusEnum、getCollectionIcon 等常量和工具函数 | 常量/工具 | +| 数据集 API 层(@/web/core/dataset/api) | getDatasetCollections、putDatasetCollectionById、delDatasetCollectionById 等集合 CRUD API | API | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| CollectionCard 集合列表组件 | 数据集详情页(dataset/detail/index.tsx) | 页面组件 | +| StatusFilter 状态筛选器 | 集合列表(内部共用) | 组件 | +| TagFilter 标签筛选器 | 集合列表(内部共用) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集集合管理 | /dataset/detail?currentTab=collectionCard | projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx | owner, collaborator, viewer | 无 Tab,单视图表格页面 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..44670fab0a65 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: 数据集 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — 模块概览 + +## 1. 模块业务概述 + +数据集模块是 FastGPT 知识库系统的核心入口,负责管理所有知识库(数据集)的全生命周期,包括创建、配置、导入数据、搜索测试和同义词管理等。模块服务于三类角色:管理员可创建和管理所有数据集及权限;协作者可编辑和导入数据;只读用户可查看和搜索数据集内容。 + +模块包含两个子能力:**数据集列表**提供知识库的浏览、创建、搜索和权限管理入口;**数据集详情**提供单个知识库内部的数据管理、搜索测试、导入导出和配置功能。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 (hasManagePer) | 数据集列表、数据集详情 | 创建/编辑/删除数据集、管理权限/协作者、导入数据、设置配置 | 最高权限,可管理所有数据集 | +| 协作者 (hasWritePer) | 数据集列表、数据集详情 | 查看数据集、编辑名称和描述、创建文件夹、导入数据 | 可写入数据但不可管理权限 | +| 只读用户 | 数据集列表、数据集详情 | 查看数据集列表和详情、搜索测试 | 仅可读,无编辑和导入权限 | + +**权限模型**: 基于团队权限 + 数据集级协作者(Collaborator)的混合权限模型。每个数据集支持独立配置权限角色列表(DatasetRoleList),支持权限继承和协作者管理。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| Dashboard(工作台) | DashboardNavbar 侧边栏导航、BgDecoration 背景装饰 | 组件 | +| 共享组件库 (@fastgpt/web) | MyBox、MyIcon、SearchInput、MyMenu、EditFolderModal 等通用 UI 组件 | 组件 | +| 权限系统 | ConfigPerModal 权限配置弹窗、resumeInheritPer 权限继承 | 组件/API | +| 文件服务 | getUploadAvatarPresignedUrl 文件上传预签名 URL | API | +| 对话模块 | SearchTest 搜索测试组件 | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| DatasetTypeTag(数据集类型标签) | 评测数据集、ChatSetting | 组件 | +| SelectModal(数据集选择弹窗) | 应用配置 | 组件 | +| QuoteItem / RawSourceBox(引用展示) | 对话/聊天模块 | 组件 | +| 数据集 API(getDatasets、getDatasetById) | 评测、对话、应用 | API | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 数据集列表 | /dataset/list | projects/app/src/pages/dataset/list/index.tsx | 所有角色 | 含文件夹管理、创建数据集入口 | +| 数据集详情 | /dataset/detail | projects/app/src/pages/dataset/detail/index.tsx | 所有角色 | 含 6 个 Tab(集合、同义词、搜索测试等) | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 数据集列表 | 数据集列表 | [模块概览](./数据集列表/模块概览.md) | +| 数据集详情 | 数据集详情 | [模块概览](./数据集详情/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\240\270\345\277\203\344\270\232\345\212\241\351\242\206\345\237\237.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\240\270\345\277\203\344\270\232\345\212\241\351\242\206\345\237\237.md" new file mode 100644 index 000000000000..57dde89c6cbc --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\346\240\270\345\277\203\344\270\232\345\212\241\351\242\206\345\237\237.md" @@ -0,0 +1,354 @@ +--- +capability_label: null +doc_type: "08" +doc_label: "核心业务领域" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT 核心业务领域 + +## 一、产品定位与用户价值 + +### FastGPT 是什么 + +FastGPT 是一个面向企业和开发者的 **AI Agent 构建平台**。用户无需深厚的 AI 专业知识,即可通过可视化的工作流编排方式,快速构建基于大语言模型(LLM)的智能应用,并将私有知识与 AI 能力结合,打造专属的企业 AI 助手或自动化流程。 + +### 目标用户群体 + +| 用户类型 | 典型场景 | +|---------|---------| +| 企业技术团队 | 搭建内部知识库问答机器人、客服自动化系统 | +| 产品/运营人员 | 通过简单模式快速创建 AI 助手,无需编写代码 | +| AI 应用开发者 | 使用工作流模式构建复杂多步骤 AI 流程,调用外部 API | +| 平台管理员 | 统一管理团队模型配置、用量配额和权限 | +| API 接入方 | 通过 OpenAI 兼容接口将 FastGPT 应用集成到自有系统 | + +### 核心价值主张 + +1. **私有知识增强**:将企业文档、数据库接入知识库,让 AI 基于真实私有数据回答,而非依赖模型训练记忆,解决"AI 幻觉"问题。 +2. **可视化工作流**:以节点拖拽方式编排 AI 推理流程,将复杂的多步骤任务图形化,降低构建门槛。 +3. **多模型统一管理**:支持 OpenAI、Anthropic、Azure、本地私有模型等主流 AI 提供商,通过统一接口调用,方便切换和成本管控。 +4. **OpenAI 兼容 API**:对外暴露与 OpenAI Chat Completions 兼容的 API,已有 OpenAI 集成的系统可零成本接入。 +5. **团队协作**:支持多人团队共享应用和知识库资源,统一权限管理。 + +### 与其他 AI 平台的差异 + +- 相比 ChatGPT / Claude 等对话产品:FastGPT 面向构建者,而非终端对话用户,重点在于让企业定制和部署自己的 AI 应用。 +- 相比 LangChain 等开发框架:FastGPT 提供完整的平台(含 UI 界面、知识库管理、用户权限),无需从零搭建基础设施。 +- 相比 Dify 等同类平台:FastGPT 的工作流节点设计更贴近程序员思维(节点即函数),同时内置 Agent 沙箱代码执行能力。 + +--- + +## 二、核心业务域 + +### 2.1 应用(App)域 + +**用户能创建什么** + +在 FastGPT 中,一个"应用"(App)是用户构建和发布 AI 服务的核心单元。每个应用拥有独立的工作流配置、对话设置和权限控制。应用可以组织在文件夹中分层管理。 + +**应用类型** + +| 类型枚举 | 用户视角名称 | 说明 | +|---------|-----------|------| +| `simple` | 简易模式 | 无需了解工作流,通过填写表单即可配置 AI 对话助手,适合快速上线 | +| `chatAgent` | Chat Agent | 对话型 Agent,内置多轮推理和工具调用能力 | +| `workflow`(advanced) | 工作流模式 | 通过节点拖拽自由编排,支持知识库检索、条件分支、循环、HTTP 调用等复杂逻辑 | +| `workflowTool`(plugin) | 工作流工具 | 可被其他应用或 Agent 调用的工具型工作流,相当于可复用的子流程 | +| `mcpToolSet` | MCP 工具集 | 将外部 MCP(Model Context Protocol)服务集成为工具集合 | +| `httpToolSet` | HTTP 工具集 | 将外部 HTTP API 集成为工具集合 | + +**简易模式 vs 工作流模式** + +- **简易模式**:用户填写系统提示词、选择知识库、配置欢迎语等,由平台自动转换为工作流执行。适合不熟悉 AI 技术的业务人员快速搭建。 +- **工作流模式**:用户在可视化画布上连接各类功能节点,完全控制每一步的逻辑。适合需要复杂业务流程(如先检索、再分类、再回答)的场景。 + +**应用发布与共享机制** + +- 应用支持版本控制,发布后生成稳定版本,保证线上服务稳定性,开发中的修改不会影响已发布版本。 +- 可通过 API Key 对外开放,接入方通过 OpenAI 兼容接口调用。 +- 支持发布到应用市场,供团队或公开用户使用。 +- 应用可设置定时触发(Scheduled Trigger),实现无人干预的自动执行。 + +--- + +### 2.2 对话(Chat)域 + +**用户如何与 AI 应用交互** + +用户通过 FastGPT 提供的对话界面或 API 接口向应用发送消息,应用按照配置的工作流处理用户输入并返回 AI 回复。支持文本、图片、文件(PDF、Word、Excel、音视频等)多种输入形式。 + +**多轮对话管理** + +- 每次对话会话(Chat Session)对应一个独立的聊天历史记录。 +- 工作流中的 AI 节点可配置携带的历史消息条数,控制上下文长度,平衡效果与 Token 成本。 +- 支持对话上下文压缩,在长对话中自动压缩历史信息,避免超出模型上下文窗口限制。 + +**对话历史与反馈机制** + +- 所有对话记录持久化存储,用户可查看历史对话。 +- 支持用户对 AI 回复进行点赞/踩等反馈,平台管理员可查看反馈数据,用于质量分析。 +- 支持自定义反馈(Custom Feedback),开发者可在工作流中嵌入反馈收集节点。 +- 对话支持引用来源展示,AI 回答所参考的知识库片段可在界面中追溯查看。 + +--- + +### 2.3 知识库(Dataset)域 + +**知识库的作用** + +知识库为 AI 提供私有领域知识,是 RAG(检索增强生成)能力的核心。用户将企业文档、产品手册、FAQ 等内容上传到知识库,AI 在回答时会先检索相关内容,再基于检索结果生成答案,从而保证回答的准确性和可追溯性。 + +**知识库类型** + +| 类型 | 说明 | +|------|------| +| 文件夹(folder) | 对知识库进行分层组织,便于分类管理,不直接存储数据 | +| 通用知识库(dataset) | 上传本地文件(PDF/Word/TXT/Markdown/Excel 等),是最常用的方式 | +| 网站知识库(websiteDataset) | 输入网站 URL,系统自动爬取并同步网页内容 | +| 外部文件(externalFile) | 引用外部文件链接,系统自动同步外部文件内容 | +| API 数据集(apiDataset) | 通过 API 接口对接外部数据系统(飞书、语雀等) | +| 飞书知识库(feishu) | 直连飞书文档空间,自动同步内容 | +| 语雀知识库(yuque) | 直连语雀知识库,自动同步内容 | +| 企业数据库(database) | 对接 MySQL/PostgreSQL/MSSQL/Oracle/SQLite/DuckDB 等关系型数据库,支持 NL2SQL 查询 | +| 结构化文档(structureDocument) | 以文件为数据库的结构化存储,保留文档的表格结构用于精确查询 | + +**知识库创建流程(用户视角)** + +``` +上传内容 → AI 解析/分块 → 向量化(Embedding) → 存入向量数据库 → 可被 AI 检索使用 +``` + +1. **上传**:支持本地文件上传、URL 链接导入、API 数据源同步、手动输入问答对等方式。 +2. **解析与分块**:系统对文档进行 OCR 解析(支持图片理解)、文本提取,并按照配置的策略(段落切割 paragraph / 固定长度切割 size / 按字符数分割 char / )拆分为知识片段(Chunk)。 +3. **向量化**:每个知识片段由 Embedding 模型转换为向量表示,用于语义检索。 +4. **检索**:用户提问时,系统将问题向量化后在知识库中进行相似度匹配,支持: + - **向量检索(语义检索)**:基于语义相似度,能理解同义词和近义表达 + - **全文检索**:基于关键词匹配,精确匹配特定词汇 + - **混合检索**:结合向量和全文检索,取长补短 + - **数据库检索**:针对关系型数据库,生成 SQL 查询 +5. **重排序(Rerank)**:检索结果可通过重排序模型进一步精排,提升相关性。 +6. **多轮智能检索(Agentic Search)**:支持 Agent 模式的多轮检索,AI 自主判断是否需要追加检索。 + +**知识库层级结构** + +- **Dataset(知识库)**:顶级组织单元,配置 Embedding 模型、分块策略等全局参数。 +- **Collection(集合)**:知识库下的数据源单元。根据来源不同分为多种类型:文件(file)、文件夹(folder)、外部文件(externalFile)、链接(link)、虚拟集合(virtual)、API 文件(apiFile)、图片集(images)、数据库表(table)。每个上传的文件或数据源对应一个 Collection。 +- **Data(数据项)**:Collection 下的最小知识单元,即每个知识片段(Chunk)。 + +--- + +### 2.4 工作流(Workflow)域 + +**工作流是什么** + +工作流是 FastGPT 中实现复杂 AI 业务逻辑的核心机制。它将一个完整的 AI 处理过程分解为多个功能节点,用户通过可视化画布将节点连接成有向图,系统按连接关系顺序执行,最终产生输出。 + +**工作流解决什么问题** + +- 一次完整的 AI 服务往往不是"问一句答一句"那么简单,可能需要:先检索知识库→再分类用户意图→再决定用哪种模型→最后整合输出。 +- 工作流让这些步骤图形化、可配置,而不需要编写代码。 + +**工作流节点类型** + +| 节点类别 | 代表节点 | 用途 | +|---------|---------|------| +| 系统输入 | 流程开始(Start) | 接收用户输入,是每个工作流的入口节点 | +| AI 对话 | AI 对话节点 | 调用 LLM 生成回复,可配置系统提示词、历史消息、温度等参数 | +| AI 功能 | 问题分类(CQ) | 对用户问题进行意图分类,根据分类结果走不同分支 | +| AI 功能 | 内容提取 | 从文本中结构化提取指定字段 | +| 知识库 | 知识库检索 | 在指定知识库中检索相关内容,输出引用片段 | +| 知识库 | 知识库搜索合并 | 合并多个知识库的检索结果 | +| 工具调用 | HTTP 请求 | 调用外部 HTTP/REST API,实现与第三方系统集成 | +| 工具调用 | 代码运行(Sandbox) | 在安全沙箱中执行 JavaScript/Python 代码 | +| 工具调用 | 工具节点 | 调用已配置的 Agent 技能或外部工具 | +| 交互 | 指定回复 | 直接输出固定文本,不经过 LLM | +| 交互 | 用户选择 | 向用户展示选项,等待用户点击后继续流程 | +| 交互 | 表单输入 | 向用户展示表单,收集结构化输入 | +| 流程控制 | 条件判断(IF-ELSE) | 根据变量值进行条件分支 | +| 流程控制 | 循环(Loop) | 对数组数据进行批量循环处理 | +| 流程控制 | 并行运行 | 同时执行多个分支,等待全部完成后汇总 | +| 数据处理 | 文本加工 | 拼接、格式化文本 | +| 数据处理 | 变量更新 | 更新全局变量的值 | +| 数据处理 | 文档读取 | 读取用户上传的文件内容 | +| 系统配置 | 用户引导 | 配置对话框欢迎语、问题引导等 | + +**工作流调试与测试** + +- 工作流编辑器内置"在线调试"功能,可直接在画布上测试工作流,查看每个节点的输入输出数据。 +- 支持单节点调试,精准定位问题节点。 +- 提供完整的执行日志(用量统计、耗时、错误信息)。 + +--- + +### 2.5 Agent 技能(AgentSkill)域 + +**Agent 技能的概念** + +Agent 技能是一种独立封装的可复用功能单元,可以被 Agent 节点直接调用。技能将复杂的能力(如搜索引擎查询、代码执行、数据处理等)打包为标准化接口,Agent 可以根据用户意图自主决定调用哪些技能及如何使用。 + +**技能的来源** + +| 来源 | 说明 | +|------|------| +| 系统技能(system) | 平台内置提供的公共技能,如 Bing 搜索、Google 搜索等 | +| 自定义技能(personal) | 团队/用户自行开发和上传的技能,通过打包为技能包(ZIP)导入 | + +**技能分类** + +| 分类 | 说明 | +|------|------| +| 搜索(search) | 网络搜索、文档检索等 | +| 工具(tool) | 通用工具调用 | +| 编程(coding) | 代码生成、执行 | +| 数据处理(data) | 数据格式转换、处理 | +| 分析(analysis) | 数据分析、图表生成 | +| 通信(communication) | 消息发送、邮件等 | +| 其他(other) | 不在上述分类的技能 | + +**技能沙箱机制** + +技能的代码在隔离的沙箱环境(Agent Sandbox)中执行,保障宿主系统安全。沙箱支持两种模式: +- **编辑调试沙箱**(edit-debug):技能开发者调试技能时使用。 +- **会话运行时沙箱**(session-runtime):生产环境中 Agent 实际调用技能时使用。 + +**技能版本控制** + +每个技能支持版本管理,发布新版本时旧版本可回滚,保证生产环境的稳定性。 + +--- + +### 2.6 模型管理域 + +**支持的 AI 模型提供商** + +FastGPT 通过统一的 AI 接口层支持主流 AI 服务提供商,包括但不限于: + +| 提供商类型 | 典型代表 | +|---------|---------| +| 通用大模型 | OpenAI(GPT-4o、o3 等)、Anthropic(Claude)、Google Gemini | +| 国内大模型 | 阿里通义千问、百度文心、智谱 AI、月之暗面、DeepSeek 等 | +| 私有化/本地部署 | Ollama、vLLM 等本地服务(兼容 OpenAI 接口) | +| Azure | Azure OpenAI Service | + +**模型用途分类** + +FastGPT 中的模型分用途配置: + +| 模型类型 | 用途 | +|---------|------| +| LLM(对话模型) | AI 对话节点的生成,工具调用,内容理解;支持 `vision` 属性标记是否具备图片理解能力(视觉语言能力) | +| Embedding(向量模型) | 将文本转为向量,用于知识库语义检索 | +| Rerank(重排序模型) | 对检索结果精排,提升检索准确率 | +| STT(语音转文字) | 语音输入转录,Whisper 等 | +| TTS(文字转语音) | AI 回复语音播报 | + +**如何配置和切换模型** + +- 平台管理员在系统后台统一配置可用模型列表(模型 ID、API Key、价格、能力限制等)。 +- 应用/工作流创建者在节点中从已配置的模型列表中选择使用的具体模型。 +- 系统支持按用量计费,管理员可为不同模型配置 Token 价格(支持阶梯定价),追踪团队用量。 + +--- + +## 三、用户角色与权限 + +| 角色 | 权限范围 | 说明 | +|------|---------|------| +| 平台管理员 | 全局系统配置、模型管理、用量监控、团队管理 | 部署 FastGPT 的运维人员,负责系统级配置 | +| 团队 Owner | 团队内所有资源的创建、编辑、删除、共享 | 创建团队的用户,拥有团队最高权限 | +| 团队成员 | 根据具体资源权限配置,可读/写/管理团队内资源 | 被邀请加入团队的用户,权限由 Owner 控制 | +| API 用户 | 通过 API Key 调用特定应用的对话接口 | 外部系统/开发者,无平台 UI 访问,仅限 API 调用 | + +**权限说明** + +- 知识库、应用、Agent 技能均支持独立的权限配置,可精细控制团队成员的可见和可操作范围。 +- 资源支持"继承权限"(子资源自动继承父文件夹权限)和"独立权限"两种模式。 +- 团队成员状态:活跃(active)、已离队(leave)、已禁用(forbidden)。 + +--- + +## 四、核心功能矩阵 + +### 应用管理 + +| 功能 | 说明 | +|------|------| +| 创建/编辑/删除应用 | 基本的应用 CRUD 操作 | +| 简易模式配置 | 通过表单配置 AI 对话参数,无需了解工作流 | +| 工作流编排 | 可视化节点画布,拖拽连线构建复杂流程 | +| 版本发布与回滚 | 保留历史版本,随时回滚到稳定版本 | +| 应用模板 | 提供推荐、写作、图片生成、网络搜索、角色扮演、办公服务等模板快速创建 | +| 定时触发 | 配置 Cron 表达式,定期自动执行工作流 | +| 应用复制 | 一键复制已有应用 | + +### 知识库管理 + +| 功能 | 说明 | +|------|------| +| 多类型知识库创建 | 文件、网站、API、数据库等多种数据源 | +| 文件上传与解析 | 支持 PDF/Word/Excel/TXT/Markdown/图片/音视频等格式 | +| QA 问答对生成 | 让 AI 自动将文档转为问答对,提升检索精度 | +| 图片内容索引 | 使用 VLM 对图片内容进行理解和向量化 | +| 分块策略配置 | 段落分割/固定长度分割/按字符数分割,可自定义分块参数 | +| 同义词管理 | 配置领域同义词,提升检索召回率 | +| 集合标签管理 | 为知识集合打标签,支持按标签过滤检索 | +| 手动数据管理 | 直接增删改查知识片段 | +| 检索测试 | 在知识库界面直接测试检索效果 | + +### 对话与交互 + +| 功能 | 说明 | +|------|------| +| 多轮对话 | 保持上下文的连续对话体验 | +| 文件上传对话 | 对话中上传文件,AI 可读取分析 | +| 语音输入/输出 | Whisper 语音识别输入,TTS 语音播报回复 | +| 对话引导配置 | 配置欢迎语、快捷问题引导 | +| 用户反馈 | 点赞/踩对话回复 | +| 引用来源展示 | 展示 AI 回答所参考的知识库片段 | +| HTML 渲染 | 支持富文本渲染(HTML/Markdown) | + +### Agent 与工具 + +| 功能 | 说明 | +|------|------| +| 工具节点调用 | 工作流中调用外部工具/API | +| Agent 技能管理 | 创建、版本管理、权限控制 Agent 技能 | +| 代码沙箱执行 | 安全执行 JS/Python 代码 | +| MCP 工具集成 | 接入 MCP 服务器暴露的工具 | +| HTTP 插件 | 通过 OpenAPI Schema 快速集成 REST API | + +### API 与集成 + +| 功能 | 说明 | +|------|------| +| OpenAI 兼容 API | 标准的 Chat Completions 接口,支持流式输出 | +| API Key 管理 | 为每个应用生成独立的访问密钥 | +| Webhook | 支持外部系统主动推送 | +| OpenAPI 文档导出 | 导出 OpenAPI 3.0 规范文件 | + +--- + +## 五、关键业务术语解释 + +| 术语 | 解释 | +|------|------| +| App | FastGPT 中用户创建的 AI 应用,是对外提供服务的基本单元,包含工作流配置和对话设置 | +| Dataset | 知识库,用于存储私有文档/数据并提供语义检索能力,给 AI 补充私有领域知识 | +| Collection | 知识库中的集合,对应一个数据源(一个文件、一个网页、一个 API 接口等) | +| Chunk(Data) | 知识库中的最小知识单元,即被分割后的文本片段,是实际参与向量检索的基本单位 | +| AgentSkill | Agent 技能,封装了特定功能(如搜索、代码执行)的可复用能力包,可被 Agent 自主调用 | +| Workflow | 工作流,由节点和连线组成的有向图,描述 AI 处理用户请求的完整流程 | +| Flow Node | 工作流节点,工作流中的基本功能单元,每个节点代表一个处理步骤(如 AI 对话、知识库检索、HTTP 调用等) | +| API Key | API 访问密钥,外部系统通过 API Key 调用 FastGPT 应用,无需登录平台界面 | +| Embedding | 将文本转换为高维向量的过程,是知识库语义检索的基础技术 | +| RAG | 检索增强生成(Retrieval-Augmented Generation),先从知识库检索相关内容,再将其作为上下文传给 AI 生成回答,提升准确性和可溯源性 | +| Rerank | 重排序,对初步检索结果使用专用模型再次精排,提升最终召回内容的相关性 | +| Plugin(工作流工具) | 可被其他应用调用的工作流,相当于可复用的 AI 子程序 | +| MCP | Model Context Protocol,Anthropic 提出的模型上下文协议,FastGPT 支持接入 MCP 服务器提供的工具 | +| Team | 团队,用于组织多个成员共享 App、Dataset 等资源,统一管理权限 | +| TmbId | 团队成员 ID(Team Member Id),用于标识操作者身份,进行权限验证和操作审计 | +| Sandbox | 沙箱,隔离的代码执行环境,Agent 技能和代码运行节点在此安全执行代码,防止对主系统造成影响 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..49bafde90f69 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,86 @@ +--- +capability_label: 登录 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — 业务流程索引 + +## 业务场景列表 + +### 密码登录 + +- **业务描述**: 用户通过用户名和密码完成身份认证,登录后进入工作台。 +- **触发入口**: 访问 `/login` 页面,默认展示密码登录表单 +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 系统未配置强制 SSO 自动登录;用户已有账号 + +### 注册账号 + +- **业务描述**: 新用户通过邮箱/手机号注册平台账号,注册成功后自动登录。 +- **触发入口**: 密码登录表单下方的"注册"链接 +- **路由路径**: `/login`(切换为 register 表单类型) +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` → RegisterForm +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 系统配置了 `register_method`;用户尚未注册 + +### 忘记密码 + +- **业务描述**: 用户通过验证码重置密码,重置成功后自动登录。 +- **触发入口**: 密码登录表单下方的"忘记密码"链接 +- **路由路径**: `/login`(切换为 forgetPassword 表单类型) +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` → ForgetPasswordForm +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 系统配置了 `find_password_method` + +### 微信扫码登录 + +- **业务描述**: 用户使用微信扫描二维码完成登录,无需输入密码。 +- **触发入口**: OAuth 列表中点击"微信"按钮,或系统配置默认展示微信登录 +- **路由路径**: `/login`(切换为 wechat 表单类型) +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` → WechatForm +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 系统配置了 `feConfigs.oauth.wechat` + +### OAuth 第三方登录 + +- **业务描述**: 用户通过 Google/GitHub/Microsoft/SSO 等第三方平台授权登录。 +- **触发入口**: 登录页面 OAuth 列表中点击对应第三方按钮 +- **路由路径**: `/login` → 跳转第三方授权页 → 回调 `/login/provider` +- **页面组件位置**: `projects/app/src/pages/login/provider.tsx` +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 系统配置了对应的 OAuth provider(Google/GitHub/Microsoft/SSO/企业微信) + +### 快速登录 + +- **业务描述**: 用户通过一次性 code 和 token 参数完成免密登录,适用于邮件链接等场景。 +- **触发入口**: 携带 code 和 token 参数的 URL 直接访问 +- **路由路径**: `/login/fastlogin` +- **页面组件位置**: `projects/app/src/pages/login/fastlogin.tsx` +- **涉及角色**: 所有用户(未认证状态) +- **前置条件**: 有效的 code 和 token 参数 + +## 场景关系 + +- **密码登录** ↔ **注册账号**: 密码登录页可切换到注册页;注册成功后自动登录 +- **密码登录** ↔ **忘记密码**: 密码登录页可切换到忘记密码页;重置成功后自动登录 +- **密码登录** → **微信扫码登录**: 可切换登录方式 +- **密码登录** → **OAuth 第三方登录**: 可切换到第三方登录 +- **各登录方式** → 登录成功后均跳转到 **工作台**(`/dashboard/agent`)或 `lastRoute` 指定路径 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 密码登录 | 默认登录页面 | `/login` | 未认证用户 | +| S02 | 注册账号 | 登录页→"注册"链接 | `/login` | 未认证用户 | +| S03 | 忘记密码 | 登录页→"忘记密码"链接 | `/login` | 未认证用户 | +| S04 | 微信扫码登录 | OAuth 列表→"微信"按钮 | `/login` | 未认证用户 | +| S05 | OAuth 第三方登录 | OAuth 列表→点击第三方按钮 | `/login` → `/login/provider` | 未认证用户 | +| S06 | 快速登录 | URL 携带 code+token 访问 | `/login/fastlogin` | 未认证用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..b36fad43f23d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,66 @@ +--- +capability_label: 登录 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — 业务流程详解 + +> 本模块为纯分组节点,各子能力的详细业务流程见对应文档。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|------------|------------| +| 登录首页 | 密码登录、注册、忘记密码、微信扫码等完整登录交互 | [业务流程索引](../登录首页/业务流程索引.md) | [业务流程详解](../登录首页/业务流程详解.md) | +| 快速登录 | 通过一次性 code+token 完成免密快速登录 | [业务流程索引](../快速登录/业务流程索引.md) | [业务流程详解](../快速登录/业务流程详解.md) | +| 第三方登录 | OAuth 回调处理与第三方账号认证流程 | [业务流程索引](../第三方登录/业务流程索引.md) | [业务流程详解](../第三方登录/业务流程详解.md) | + +## 公共业务流程 + +以下是所有登录子能力共用的前置流程: + +### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问任意登录页面 | 无(客户端操作) | — | 清除本地 Token 和营销缓存数据 | +| 访问任意登录页面 | GET `/support/user/account/loginout` | — | 后端登出当前会话 | +| 访问任意登录页面 | 无(客户端操作) | — | 预加载 `/dashboard/agent` 路由 | + +### 步骤 2:登录成功后处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 登录 API 返回成功 | — | 团队状态为 `active` | 写入用户信息到 Store,跳转到 `lastRoute` 或 `/dashboard/agent` | +| 登录 API 返回成功 | POST 邀请链接接受 | 团队状态非 `active` 且 `lastRoute` 含 `invitelinkid` | 自动接受邀请,跳转到 `/dashboard/agent` | +| 登录 API 返回成功 | — | 团队状态非 `active` 且无邀请链接 | 显示"团队未激活"警告提示 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 登录页面 + participant API + participant Backend + + User->>UI: 访问 /login + UI->>API: GET /support/user/account/loginout + API->>Backend: 登出会话 + UI->>UI: 清除本地 Token 和缓存 + + Note over User,Backend: 各子能力登录流程详见子文档 + + User->>UI: 选择登录方式(见子文档) + UI->>API: 对应认证 API(见子文档) + API->>Backend: 认证请求 + Backend-->>API: 用户信息 + Token + API-->>UI: LoginSuccessResponseType + UI->>UI: 写入 UserStore.setUserInfo + UI->>UI: 跳转 lastRoute 或 /dashboard/agent +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5a1caa05614b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,32 @@ +--- +capability_label: 快速登录 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:47:00.000Z +parent_module: 登录 +roles: + - 未登录用户 +router_paths: + - /login/fastlogin +--- + +# 快速登录 — 业务流程索引 + +## 快速登录认证 + +- **业务描述**: 用户通过外部链接携带的 code 和 token 参数访问快速登录页面,系统自动校验凭证有效性,校验通过后完成登录并跳转至目标页面。 +- **触发入口**: 外部链接(邮件、通知等)→ 携带 code、token、callbackUrl 参数访问 `/login/fastlogin` +- **路由路径**: `/login/fastlogin` +- **页面组件位置**: `projects/app/src/pages/login/fastlogin.tsx` +- **涉及角色**: 未登录用户 +- **前置条件**: 持有有效的 `code` 和 `token` 临时凭证 + +## 场景关系 + +本模块仅有一个业务场景,无场景间依赖关系。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 快速登录认证 | 外部链接携带凭证参数 | /login/fastlogin | 未登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e57d6dd80e00 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,108 @@ +--- +capability_label: 快速登录 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:47:00.000Z +parent_module: 登录 +roles: + - 未登录用户 +router_paths: + - /login/fastlogin +--- + +# 快速登录 — 业务流程详解 + +## 页面总览 + +快速登录是一个全自动的免密认证页面。用户通过携带 `code`、`token`、`callbackUrl` 参数的链接访问后,页面自动完成旧凭证清除、凭证校验、用户信息写入和页面跳转的完整链路。用户在页面上仅看到加载动画,无需任何手动操作。 + +--- + +### 快速登录认证 + +> **业务描述**: 用户点击外部链接(如邮件中的验证链接)后,浏览器打开快速登录页面,系统自动完成身份认证并跳转至目标页面。 + +#### 步骤 1:页面初始化与参数解析 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击外部链接,浏览器打开 `/login/fastlogin?code=xxx&token=xxx&callbackUrl=/dashboard/agent` | 无(SSR 阶段服务端解析查询参数) | callbackUrl 为空时默认使用 `/dashboard/agent` | 页面渲染 Loading 加载动画 | + +服务端 `getServerSideProps` 从 URL 查询参数中提取 `code`、`token`、`callbackUrl`,并注入到组件 props。 + +#### 步骤 2:清除旧凭证并开始认证 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(页面自动执行) | `GET /support/user/account/loginout`(clearToken 调用 loginOut) | 无 | Loading 持续展示;localStorage 中旧广告存储数据被清除 | + +页面挂载后 `useEffect` 立即执行: +- 调用 `clearToken()` 清除旧的登录状态和广告存储数据 +- 调用 `validateRedirectUrl(callbackUrl)` 校验回调 URL 安全性 +- 调用 `router.prefetch(safeCallbackUrl)` 预加载目标页面 +- 调用 `authCode(code, token)` 发起认证请求 + +#### 步骤 3:提交快速登录凭证 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无(自动提交) | `POST /proApi/support/user/account/login/fastLogin`(参数:`code`, `token`) | 无 | Loading 持续展示;请求发送中 | + +`postFastLogin` 向服务端提交 `code` 和 `token` 进行凭证校验。 + +#### 步骤 4a:认证成功 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无 | 无额外 API | 服务端返回有效的 `LoginSuccessResponseType`(含 user 信息) | 用户信息写入 Store;100ms 延迟后页面跳转至 callbackUrl 对应页面 | + +认证成功时: +- 调用 `setUserInfo(res.user)` 将用户信息写入 Zustand userStore +- 100ms 延迟后,`router.push(validateRedirectUrl(callbackUrl))` 跳转到目标页面 + +#### 步骤 4b:认证失败 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无 | 无额外 API | 服务端返回空响应(`!res`)或请求异常 | 顶部弹出警告 Toast,文案为 i18n key `common:support.user.login.error`;1 秒后页面跳转至 `/login` | + +认证失败时: +- 弹出警告 Toast 提示"登录异常" +- 1 秒后自动跳转回登录首页 `/login` + +**失败场景**: +- code 或 token 已过期 +- code 或 token 无效或已被使用 +- 网络异常导致请求失败 + +### 数据加载详情 + +本页面无列表数据加载。页面为一次性认证流程,不涉及分页、排序、筛选或轮询。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 快速登录页面 + participant API as 后端 API + participant Store as UserStore + + User->>UI: 点击外部链接访问 /login/fastlogin?code=&token=&callbackUrl= + UI->>UI: SSR 解析查询参数 (code, token, callbackUrl) + UI->>User: 展示 Loading 加载动画 + UI->>API: GET /support/user/account/loginout (清除旧凭证) + API-->>UI: 登出完成 + UI->>UI: 清除 localStorage 广告存储数据 + UI->>API: POST /proApi/support/user/account/login/fastLogin {code, token} + + alt 认证成功 + API-->>UI: 返回用户信息 + UI->>Store: setUserInfo(user) + UI->>User: 100ms 后跳转至 callbackUrl 目标页面 + else 认证失败(凭证无效/过期/网络异常) + API-->>UI: 返回空或异常 + UI->>User: Toast 警告提示"登录异常" + UI->>User: 1 秒后跳转至 /login 登录首页 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f45c04d1553a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,53 @@ +--- +capability_label: 快速登录 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:47:00.000Z +parent_module: 登录 +roles: + - 未登录用户 +router_paths: + - /login/fastlogin +--- + +# 快速登录 — 模块概览 + +## 1. 模块业务概述 + +快速登录是 FastGPT 登录体系中的免密认证入口,面向已持有有效临时凭证(code + token)的未登录用户。用户通过携带 `code` 和 `token` 查询参数访问 `/login/fastlogin` 页面,系统自动完成身份认证并跳转至目标页面,全程无需用户手动输入密码或点击任何按钮。 + +该模块为"登录"能力树的叶子节点,与[登录首页](../登录首页/模块概览.md)、[第三方登录](../第三方登录/模块概览.md)共同构成完整的登录入口体系。快速登录通常作为邮件链接、通知跳转等外部触达场景的认证桥梁。 + +本模块无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 未登录用户 | 快速登录页 | 通过 code+token 自动认证 | 持有有效凭证才可完成登录,凭证失效则跳回登录首页 | + +**权限模型**: 本模块无角色权限校验。页面为公开路由,任何携带有效 `code` 和 `token` 参数的访客均可访问。认证成功后的权限由 `setUserInfo` 写入的用户信息决定。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户认证服务 | `postFastLogin` — 提交快速登录凭证完成认证 | API | +| 用户状态管理 | `useUserStore.setUserInfo` — 写入登录后的用户信息 | Store | +| 认证工具 | `clearToken` — 登录前清除旧凭证、登出旧会话 | 工具函数 | +| URL 安全校验 | `validateRedirectUrl` — 校验回调 URL 合法性,防止开放重定向攻击 | 工具函数 | +| 通用组件 | `Loading` — 认证过程中的加载状态展示 | 组件 | + +## 4. 对外共享 + +快速登录模块为纯入口页面,不对外提供可复用的组件、API 或 Store。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 快速登录 | /login/fastlogin | projects/app/src/pages/login/fastlogin.tsx | 未登录用户 | 无 Tab,单页自动认证流程 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..9d29ed4d8438 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: 登录 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — 模块概览 + +## 1. 模块业务概述 + +登录模块是 FastGPT 平台的用户身份认证入口,为所有未登录用户提供多种方式的安全登录能力。模块支持密码登录、OAuth 第三方登录(SSO/Google/GitHub/Microsoft)、微信扫码登录、企业微信自动登录,以及账号注册和密码找回等完整的账号生命周期管理功能。 + +模块包含三个子能力页面:**登录首页**提供登录方式选择与表单交互的主界面;**快速登录**支持通过一次性令牌实现免密快速登录;**第三方登录**处理 OAuth 回调后的认证流程,完成第三方账号与平台账号的绑定与登录。 + +## 2. 角色差异 + +登录模块面向**未认证用户**,所有访问者处于未登录状态,无角色区分。系统根据 `feConfigs` 配置动态展示可用的登录方式: + +- 社区版(`register_method` 有值且 `isPlus` 为 false)仅展示 root 账号密码登录 +- 商业版可根据配置展示密码登录、微信扫码、OAuth 第三方登录等 +- 配置了 SSO 自动登录时,进入页面自动跳转 SSO 认证 +- 企业微信终端访问时自动触发企业微信登录 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统 Store(useSystemStore) | 读取 `feConfigs` 配置(OAuth、登录方式)、读写 `loginStore` 状态 | Store | +| 用户 Store(useUserStore) | 登录成功后调用 `setUserInfo` 写入用户信息 | Store | +| 对话 Store(useChatStore) | 登录页初始化时调用 `setLastChatAppId` 清空对话上下文 | Store | +| 用户 API(@/web/support/user/api) | 调用登录、注册、密码找回、微信登录等全部认证 API | API | +| 营销工具(@/web/support/marketing/utils) | 读取/清除营销追踪参数(bd_vid、fastgpt_sem、msclkid 等) | 工具函数 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 登录成功回调(`loginSuccess`) | 子能力页面共享使用 | 函数 | +| 登录表单组件(LoginForm) | 登录首页动态加载 | 组件 | +| OAuth 认证流程 | 第三方登录回调页面 | 流程 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 备注 | +|---------|---------|-------------|------| +| 登录首页 | `/login` | `projects/app/src/pages/login/index.tsx` | 支持密码/注册/忘记密码/微信四种表单切换 | +| 快速登录 | `/login/fastlogin` | `projects/app/src/pages/login/fastlogin.tsx` | 通过 code+token 参数完成一次快速登录 | +| 第三方登录 | `/login/provider` | `projects/app/src/pages/login/provider.tsx` | OAuth 回调处理,完成认证并跳转 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 登录首页 | 登录首页 | [模块概览](../登录首页/模块概览.md) | +| 快速登录 | 快速登录 | [模块概览](../快速登录/模块概览.md) | +| 第三方登录 | 第三方登录 | [模块概览](../第三方登录/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ab2e48cf57da --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,80 @@ +--- +capability_label: 登录首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:50:00.000Z +parent_module: 登录 +roles: [未登录用户] +router_paths: [/login] +--- + +# 登录首页 — 业务流程索引 + +## 业务场景列表 + +### 页面初始化 + +- **业务描述**: 用户访问登录页面时,系统自动加载页面布局、检测微信 OAuth 配置决定默认登录方式(微信或密码登录),并执行 IP 归属地检测和 Cookie 版本检查 +- **触发入口**: 直接访问 `/login` 路由 +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` +- **涉及角色**: 未登录用户 +- **前置条件**: 用户未登录状态 + +### OAuth 登录通道 + +- **业务描述**: 用户通过 FormLayout 中展示的第三方 OAuth 按钮(SSO/Google/GitHub/Microsoft/企业微信)跳转到对应平台完成授权登录 +- **触发入口**: 登录表单下方的 OAuth 按钮区 +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` +- **涉及角色**: 未登录用户 +- **前置条件**: 系统配置中启用了对应的 OAuth 提供商 + +### 中国 IP 重定向 + +- **业务描述**: 系统通过百度 IP 定位 API 检测用户是否在中国大陆,若是则弹出重定向提示引导用户访问国内站点 +- **触发入口**: 页面加载时自动触发 +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` 中的 RedirectDrawer +- **涉及角色**: 未登录用户(中国大陆 IP) +- **前置条件**: 环境变量配置了 `CHINESE_IP_REDIRECT_URL`,且用户未关闭提示 + +### Cookie 同意 + +- **业务描述**: 用户首次访问时弹出 Cookie 同意抽屉,引导用户阅读隐私政策并授权 Cookie 使用 +- **触发入口**: 页面加载时自动触发(localStorage 无对应版本标记时) +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/pages/login/index.tsx` 中的 CookiesDrawer +- **涉及角色**: 未登录用户 +- **前置条件**: 用户本地未记录当前 Cookie 版本的同意标记 + +### 社区联系 + +- **业务描述**: 用户点击"无法登录"链接时,弹出社区联系方式弹窗(支持 Markdown 自定义内容或企业微信团队专用提示) +- **触发入口**: 登录表单区域下方的"无法登录"链接 +- **路由路径**: `/login` +- **页面组件位置**: `projects/app/src/components/CommunityModal/index.tsx` +- **涉及角色**: 未登录用户 +- **前置条件**: 系统配置了 `concatMd` 联系方式内容 + +## 场景关系 + +- **页面初始化** → **OAuth 登录通道**: 页面加载完成后,OAuth 按钮根据系统配置动态渲染 +- **页面初始化** → **中国 IP 重定向**: IP 检测与页面加载并行执行 +- **页面初始化** → **Cookie 同意**: Cookie 版本检查与页面加载并行执行 +- **页面初始化** → **Tab 子能力(密码登录等)**: 默认展示密码登录 Tab +- 各 Tab 子能力(密码登录/注册/忘记密码/微信登录)之间通过切换互斥展示 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 页面初始化与布局渲染 | — | 直接访问 /login | /login | 未登录用户 | +| S02 | OAuth 第三方登录 | — | 表单区 OAuth 按钮 | /login | 未登录用户 | +| S03 | 中国 IP 重定向提示 | — | 页面加载自动触发 | /login | 中国大陆 IP 用户 | +| S04 | Cookie 隐私政策同意 | — | 页面加载自动触发 | /login | 未登录用户 | +| S05 | 社区联系帮助 | — | 点击"无法登录" | /login | 未登录用户 | +| S05-1 |  ├ 密码登录 | 密码登录 | 默认 Tab / 表单底部切换 | /login | 未登录用户 | +| S05-2 |  ├ 注册 | 注册 | 点击"注册"链接切换 | /login | 未登录用户 | +| S05-3 |  ├ 忘记密码 | 忘记密码 | 点击"忘记密码"链接切换 | /login | 未登录用户 | +| S05-4 |  ├ 微信登录 | 微信登录 [配置可见] | 微信 OAuth 启用时为默认 Tab | /login | 未登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..eff7b8eb465d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,107 @@ +--- +capability_label: 登录首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:50:00.000Z +parent_module: 登录 +roles: [未登录用户] +router_paths: [/login] +--- + +# 登录首页 — 业务流程详解 + +## 页面总览 + +登录首页是 FastGPT 的统一认证入口,提供 PC 端双栏布局(左侧品牌展示、右侧表单区)和移动端单栏布局。页面负责协调四种登录方式(密码登录、注册、忘记密码、微信登录)的切换,同时处理 OAuth 第三方登录、IP 归属地检测、Cookie 同意等公共流程。 + +## Tab 结构索引 + +| Tab | 业务描述 | 来源 | 详细文档 | +|-----|---------|------|---------| +| 密码登录 | 用户名+密码登录,支持邮箱/手机号/用户名多方式 | 本模块 | [业务流程详解](../登录首页/密码登录/业务流程详解.md) | +| 注册 | 新用户注册,支持邮箱/手机号+验证码 | 本模块 | [业务流程详解](../登录首页/注册/业务流程详解.md) | +| 忘记密码 | 通过验证码重置密码 | 本模块 | [业务流程详解](../登录首页/忘记密码/业务流程详解.md) | +| 微信登录 | [配置可见] 微信扫码授权登录,轮询获取登录结果 | 本模块 | [业务流程详解](../登录首页/微信登录/业务流程详解.md) | + +## 非 Tab 业务流程 + +### 页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问 /login 路由 | 无(客户端初始化) | 系统配置了微信 OAuth → 默认展示微信登录 Tab;否则展示密码登录 Tab | 渲染双栏布局(PC)或单栏布局(移动端),加载系统 Logo 和标题 | +| 页面加载后自动执行 | `GET https://qifu-api.baidubce.com/ip/local/geo/v1/district`(百度 IP 定位) | 配置了 CHINESE_IP_REDIRECT_URL 且用户未关闭提示 → 弹出重定向抽屉;IP 检测到中国且非港澳台 → 展示重定向提示 | 底部弹出重定向抽屉,显示"检测到您在中国大陆"提示和跳转按钮 | +| 页面加载后自动执行 | 无(localStorage 读取) | 本地 Cookie 版本与当前版本不一致 → 弹出 Cookie 同意抽屉 | 底部弹出 Cookie 同意抽屉,显示隐私政策链接和同意按钮 | + +### 登录成功跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 任一登录方式成功后(由子 Tab 回调触发) | 无(客户端路由跳转) | URL query 参数含 `lastRoute` 且不以 `/login` 开头且以 `/` 开头 → 跳转到 lastRoute;否则 → 跳转到 `/dashboard/agent` | 页面跳转到目标路由 | + +### OAuth 第三方登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 OAuth 按钮(SSO) | `POST /proApi/support/user/account/login/getAuthURL` | 无 | 获取 SSO 授权 URL 后跳转到 SSO 提供商页面 | +| 点击 OAuth 按钮(企业微信) | `POST /proApi/support/user/account/login/wecom/getRedirectUrl` | 无 | 获取企业微信授权 URL 后跳转 | +| 点击 OAuth 按钮(Google/GitHub/Microsoft) | 无(直接构造 redirectUrl 跳转) | 无 | 跳转到对应 OAuth 提供商的授权页面 | + +OAuth 列表根据系统配置动态生成,支持:SSO、微信、Google、GitHub、Microsoft、企业微信、密码登录切换。当仅有一种登录方式时,不展示 OAuth 按钮区。 + +### Cookie 同意 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹出 Cookie 抽屉 | 无 | 用户本地 Cookie 版本号不等于 '1' | 底部弹出抽屉,显示 Cookie 提示文案和隐私政策链接 | +| 点击"同意" | 无(写入 localStorage 'localCookieVersion'='1') | 无 | 抽屉关闭,不再弹出 | +| 点击隐私政策链接 | 无 | 无 | 新窗口打开隐私政策页面 | + +### 中国 IP 重定向 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹出重定向抽屉 | 无 | 百度 IP 检测返回 country='中国' 且 prov 不是香港/澳门/台湾 | 底部弹出抽屉,显示重定向提示 | +| 点击"跳转" | 无 | 无 | 通过 router.push 跳转到 ChineseRedirectUrl | +| 点击"不再提醒" | 无(写入 localStorage 'showRedirect'=false) | 无 | 抽屉关闭,后续不再弹出 | + +### 社区联系 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"无法登录"链接 | 无 | `feConfigs.concatMd` 已配置 | 弹出社区联系方式弹窗 | +| 弹窗展示 | 无 | 用户所属团队是企业微信团队 → 展示固定邮箱联系方式;否则 → 展示 Markdown 自定义内容 | 弹窗展示联系方式内容 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 登录首页 + participant API as 百度IP/后端 + participant Backend as 后端服务 + + User->>UI: 访问 /login + par 并行初始化 + UI->>API: GET 百度 IP 定位 API + API-->>UI: 返回 IP 归属地 + and + UI->>UI: 读取 localStorage Cookie 版本 + and + UI->>UI: 读取 feConfigs 决定默认登录方式 + end + + alt 中国 IP + UI->>User: 展示重定向提示抽屉 + User->>UI: 点击跳转或不再提醒 + end + + alt Cookie 未同意 + UI->>User: 展示 Cookie 同意抽屉 + User->>UI: 点击同意 + end + + Note over User,Backend: 各 Tab 子能力详见子文档 +``` + +> 各 Tab(密码登录、注册、忘记密码、微信登录)的完整业务流程和交互序列详见对应的子文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..0f2a4924bda7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,39 @@ +--- +capability_label: 密码登录 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 密码登录 — 业务流程索引 + +## 业务场景列表 + +### 密码登录 + +- **业务描述**: 用户输入已注册的用户名和密码,系统验证身份后签发登录令牌并跳转至应用工作台。支持通过 URL 参数携带凭据实现一键自动登录。 +- **触发入口**: 访问登录页 `/login`,默认展示密码登录 Tab;或其他登录方式(微信扫码登录等)切换到密码登录 +- **路由路径**: `/login`(pageType=passwordLogin) +- **页面组件位置**: `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx` +- **涉及角色**: 未认证用户(无需角色鉴权) +- **前置条件**: + - 用户未登录(未持有有效 token) + - 系统配置允许密码登录(默认始终可用) + - 如为自动登录,URL 需携带 `u`(用户名)和 `p`(密码)参数 + +## 场景关系 + +各登录子能力(密码登录、微信登录、注册、忘记密码)之间通过页面内 Tab 切换关联,互不依赖: + +- **密码登录** → **忘记密码**: 用户忘记密码时通过底部链接切换 +- **密码登录** → **注册**: 新用户通过底部链接切换到注册 Tab + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 密码登录 | 访问登录页或从其他登录方式切换 | `/login`(pageType=passwordLogin) | 未认证用户 | +| S02 | 自动登录 | URL 携带 `u` 和 `p` 参数直接访问 | `/login?u=xxx&p=xxx` | 未认证用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..50770be245c6 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,157 @@ +--- +capability_label: 密码登录 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 密码登录 — 业务流程详解 + +## 页面总览 + +密码登录是 FastGPT 登录页的默认 Tab。用户在此输入用户名和密码完成身份认证。页面顶部展示系统 Logo 和标题,中部为表单区域(用户名、密码输入框 + 登录按钮 + 协议提示),底部提供忘记密码和注册入口。商业版用户还可看到 OAuth 第三方登录选项(SSO、微信、Google、GitHub、Microsoft)。 + +## 非 Tab 业务流程 + +### 场景 S01:密码登录 + +> 用户手动输入用户名和密码完成登录。 + +#### 步骤 1:进入登录页,初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问 `/login` 路由 | 无 | 默认展示密码登录 Tab;若系统配置了微信 OAuth,则自动切到微信登录 Tab | 显示登录表单页面:左侧为系统背景图(仅 PC 端),右侧为表单区域,含系统 Logo、标题、用户名输入框、密码输入框、登录按钮 | +| 页面加载完成 | 无 | 若检测到 URL 含 `rootLogin=1` 参数,跳过 OAuth 自动登录 | 页面顶部右侧显示语言切换器;底部显示版权信息;若本地 Cookie 版本过期,弹出 Cookie 同意抽屉 | + +#### 步骤 2:输入用户名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在用户名输入框中输入账号 | 无 | — | 输入框获取焦点,显示输入内容 | +| 输入框失焦 | 无 | 若用户名为空(必填校验失败) | 输入框显示红色错误边框(formState.errors.username) | + +**占位提示文案规则**: +- 社区版(`feConfigs.register_method` 存在且 `feConfigs.isPlus` 为 falsy):显示"请使用 root 用户登录" +- 商业版:根据 `feConfigs.login_method` 数组拼接占位文案,如"用户名/邮箱/手机号" + +#### 步骤 3:输入密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在密码输入框中输入密码 | 无 | — | 输入框以密码类型展示(字符掩盖),显示输入内容 | +| 输入框失焦 | 无 | 若密码为空或超过 60 字符(校验失败) | 输入框显示红色错误边框,密码超长时提示"密码最长为 60 字符" | + +**占位提示文案规则**: +- 社区版:显示 root 密码占位提示 +- 商业版:显示"密码" + +#### 步骤 4:提交登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"登录"按钮 或 按 Enter 键 | `GET /support/user/account/preLogin`(先)→ `POST /support/user/account/loginByPassword`(后,串行依赖 preLogin 返回的 code) | 若正在请求中(`requesting=true`),Enter 键不触发提交 | 登录按钮进入 loading 状态(`isLoading=true`),按钮文字保持不变,不可再次点击 | +| — | `GET /support/user/account/preLogin` 参数: `username`(用户输入的用户名) | — | 按钮保持 loading 状态 | +| — | `POST /support/user/account/loginByPassword` 参数: `username`, `password`(前端 hash 后发送), `code`(preLogin 返回), `language`(当前语言), `teamId`(可选) | — | 按钮保持 loading 状态 | +| — | 登录成功(两个 API 均返回成功) | — | 显示成功提示("登录成功"),页面跳转至 `/dashboard/agent`(或 `lastRoute` 指定路径;若 `lastRoute` 含 `/login` 或以非 `/` 开头则忽略,回退到 `/dashboard/agent`) | +| — | 登录失败 | 密码错误(`error.statusText === UserErrEnum.account_psw_error`) | URL 中的 `u` 和 `p` 参数被清空(通过 `router.replace`),提示错误信息;按钮恢复可点击状态 | +| — | 登录失败 | 其他错误(网络异常、用户名不存在等) | 提示对应错误信息;按钮恢复可点击状态 | + +**密码处理**:密码在前端经过 `hashStr()` 哈希处理后发送,不传输明文。 + +**登录成功跳转逻辑**: +1. 解析 URL 参数中的 `lastRoute` +2. 若 `lastRoute` 有效(非空、不包含 `/login`、以 `/` 开头)→ 跳转到 `lastRoute` +3. 否则 → 跳转到 `/dashboard/agent` + +#### 表单与交互详情 + +**表单字段清单**(来自 `react-hook-form` 的 `register` 定义): + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| username | 文本输入 | ✅ | — | 无格式限制 | 否 | 用户名/邮箱/手机号 | +| password | 密码输入 | ✅ | — | 最大 60 字符 | 否 | 密码,前端哈希后发送 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 用户名为空 | 提交时 | 表单校验失败,输入框标红 | +| 密码为空 | 提交时 | 表单校验失败,输入框标红 | +| 密码超过 60 字符 | 失焦 | "密码最长为 60 字符" | + +**前置条件**: +- 用户未登录(页面加载时会调用 `clearToken()` 清除本地 token) +- 需要预登录验证码(`getPreLogin` 返回 `code` 字段,作为 `postLogin` 的必传参数) + +**后置影响**: +- 登录成功后存储用户信息到 `useUserStore`,清空 `useChatStore.lastChatAppId` +- 签发登录 token + +**失败场景**: +- 密码错误:自动清除 URL 中的凭据参数(`u`、`p`),避免重复自动登录 +- 网络异常:显示请求失败提示 +- 预登录失败:阻止后续登录请求 + +### 场景 S02:URL 参数自动登录 + +> 用户通过携带 `u` 和 `p` 参数的 URL 直接访问登录页,系统自动发起登录。 + +#### 步骤 1:页面加载与自动登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问 `/login?u=xxx&p=xxx` | — | URL 中 `u` 和 `p` 参数均非空 | 页面加载后,`useMount` 生命周期自动调用 `onclickLogin({username, password})` | +| — | `GET /support/user/account/preLogin` → `POST /support/user/account/loginByPassword`(流程同 S01) | — | 同 S01 步骤 4 | +| — | 登录成功 | — | 同 S01 成功流程 | +| — | 登录失败(密码错误) | — | URL 中 `u` 和 `p` 参数被清空,停留在登录页等待用户手动输入 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 登录表单 + participant API as 后端 API + participant Backend as 服务端 + + User->>UI: 访问 /login + UI->>UI: 初始化表单,清除旧 token + + alt URL 含 u 和 p 参数 + UI->>API: GET /support/user/account/preLogin?username=xxx + API->>Backend: 获取预登录验证码 + Backend-->>API: { code } + API-->>UI: { code } + UI->>API: POST /support/user/account/loginByPassword + API->>Backend: 验证用户名+哈希密码+code + Backend-->>API: { user, token } + API-->>UI: 登录成功 + UI->>User: 跳转至工作台 + else 手动输入 + User->>UI: 输入用户名 + User->>UI: 输入密码 + User->>UI: 点击登录/按 Enter + UI->>API: GET /support/user/account/preLogin?username + API->>Backend: 获取预登录验证码 + Backend-->>API: { code } + API-->>UI: { code } + UI->>API: POST /support/user/account/loginByPassword + Note over UI: password 经 hashStr() 哈希 + API->>Backend: 验证用户名+哈希密码+code + alt 验证成功 + Backend-->>API: { user, token } + API-->>UI: 登录成功 + UI->>User: Toast "登录成功",跳转 + else 密码错误 + Backend-->>API: 错误 + API-->>UI: 密码错误 + UI->>UI: 清除 URL 中的 u/p 参数 + UI->>User: 显示错误提示 + end + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..e0a43e47278d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: 密码登录 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 密码登录 — 模块概览 + +## 1. 模块业务概述 + +密码登录是 FastGPT 平台的核心身份认证方式,为所有用户提供基于用户名+密码的登录入口。用户在登录页面输入已注册的账号凭证后,系统完成身份验证并签发登录令牌,随后跳转至应用工作台。 + +本模块是登录首页的密码登录子能力(Tab),与微信登录、注册、忘记密码等其他登录相关子能力共享同一页面入口。支持根据系统配置自动适配登录方式:社区版(Community Version)仅允许 root 账号登录;商业版支持用户名、邮箱、手机号多种登录方式。同时支持通过 URL 参数携带凭据实现一键自动登录。 + +## 2. 角色差异 + +密码登录页面面向**未认证用户**,无需角色鉴权。系统通过前端配置 `feConfigs` 控制可用的登录方式: + +| 配置项 | 影响 | +|--------|------| +| `feConfigs.register_method` | 控制注册入口和社区版判断 | +| `feConfigs.isPlus` | 区分社区版/商业版 | +| `feConfigs.login_method` | 控制用户名/邮箱/手机号登录方式的占位提示 | +| `feConfigs.find_password_method` | 控制"忘记密码"入口显示 | +| `feConfigs.register_method` | 控制"注册"入口显示 | + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 全局系统配置 | `useSystemStore` — 读取 `feConfigs` 系统配置 | Store | +| 用户 API 层 | `postLogin` / `getPreLogin` — 密码登录接口 | API | +| 共享 WEB 层 | `useRequest` — 异步请求管理与 loading 状态 | Hook | +| 登录共享布局 | `FormLayout` — 登录页通用布局与 OAuth 入口 | 组件 | +| 登录协议提示 | `PolicyTip` — 服务协议与隐私政策链接 | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 密码登录表单 | 登录首页(`/login` 页面本体) | 组件 | +| 密码登录表单 | `LoginContainer` 弹窗式登录容器 | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 登录页(密码登录 Tab) | `/login` | `projects/app/src/pages/login/index.tsx` → `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx` | 未认证用户 | 含微信登录、注册、忘记密码等 Tab,由页面级 pageType 状态切换 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..43c28dc6eca4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,31 @@ +--- +capability_label: 微信登录 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: + - /login (pageType=wechat) +--- + +# 微信登录 — 业务流程索引 + +## 微信扫码登录 + +- **业务描述**: 用户在登录页通过微信客户端扫描二维码完成身份认证,无需输入账号密码即可登录平台。系统自动轮询检测扫码和确认状态。 +- **触发入口**: 系统配置启用微信 OAuth 后,访问登录页默认进入微信登录页;或从其他登录方式(密码登录)切换至微信登录 +- **路由路径**: `/login`(pageType 为 wechat) +- **页面组件位置**: `projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx` +- **涉及角色**: 未登录用户(所有访问者) +- **前置条件**: 系统配置中微信 OAuth 已启用(`feConfigs.oauth.wechat` 为 true);用户未被识别为百度推广来源(无 bd_vid) + +## 场景关系 + +本模块仅包含微信扫码登录一个场景,无其他场景关联。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 微信扫码登录 | 访问登录页 → 微信登录Tab / 默认微信登录方式 | /login (pageType=wechat) | 未登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..c3d3f14fbd94 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,98 @@ +--- +capability_label: 微信登录 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: + - /login (pageType=wechat) +--- + +# 微信登录 — 业务流程详解 + +## 页面总览 + +微信登录页面向用户展示一个微信扫码二维码,用户使用微信客户端扫描并确认后即可完成登录。页面在展示二维码的同时启动轮询机制,每隔 3 秒检测用户是否已完成扫码确认,确认后自动进入系统。 + +## 微信扫码登录流程 + +> 用户通过微信客户端扫描页面二维码完成身份认证并登录系统。 + +### 步骤 1:页面初始化与二维码获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入登录页,页面自动识别应展示微信登录 | GET /proApi/support/user/account/login/wx/getQR | 1. 系统配置 `feConfigs.oauth.wechat` 为 true → 默认进入微信登录
2. 检测到百度推广参数 bd_vid 存在 → 切换为密码登录(不进微信登录) | 页面显示系统 Logo 和标题;二维码区域显示加载中动画(Loading 组件居中旋转) | +| 等待二维码接口返回 | — | 二维码接口成功 → 进入步骤 2
二维码接口失败 → 提示"获取二维码失败"(warning toast),二维码区域持续显示加载动画 | 获取成功后:加载动画消失,显示微信二维码图片(带圆角边框、浅灰背景);二维码上方显示"微信扫码登录"提示文字 | + +### 步骤 2:轮询扫码结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户打开微信客户端扫描二维码 | POST /proApi/support/user/account/login/wx/getResult(每 3 秒自动轮询) | 轮询前置条件:`wechatInfo.code` 存在(步骤 1 获取的微信 code)
轮询返回 data 为空 → 继续轮询(用户尚未扫码确认)
轮询返回 data 不为空 → 登录成功,进入步骤 3
轮询期间 API 报错 → 静默处理,继续轮询 | 二维码持续展示,无明显变化(轮询在后台进行) | +| 用户在微信中点击"确认登录" | —(同上轮询 API) | — | 同上:下次轮询将返回登录结果 | + +### 步骤 3:登录成功处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| —(自动触发) | —(步骤 2 的轮询返回结果) | 返回结果有效 → 执行登录成功回调 | 清除营销 Cookie(removeFastGPTSem)
保存用户信息到全局状态
页面跳转:若有 lastRoute 参数且非登录页路径 → 跳转到 lastRoute;否则跳转到 `/dashboard/agent`(应用工作台) | + +### 附加流程:切换登录方式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击其他 OAuth 登录按钮(如 SSO、Google、GitHub、Microsoft、密码登录) | —(FormLayout 处理切换) | 分别根据 provider 类型执行不同的跳转/切换逻辑 | 1. SSO → 获取认证 URL 后跳转
2. Google/GitHub/Microsoft → 直接跳转 OAuth 授权页
3. 密码登录 → 切换到 LoginForm 组件 | + +## 数据加载详情 + +微信登录页面涉及两种数据加载: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 二维码加载 | GET /proApi/support/user/account/login/wx/getQR | 无 | 提取 code 和 codeUrl 字段 | 显示微信登录二维码 | +| 结果轮询 | POST /proApi/support/user/account/login/wx/getResult | code(来自上一步), inviterId, bd_vid, msclkid, fastgpt_sem, sourceDomain | 判断是否有返回数据 | 成功时跳转页面,未完成时静默继续轮询 | + +- **轮询参数**:每 3 秒(3000ms)自动请求一次结果查询接口 +- **轮询启动条件**:二维码接口成功返回 code 字段后开始轮询 +- **轮询停止条件**:返回有效登录结果并执行登录成功回调后停止 + +## 营销参数收集 + +微信登录在请求扫码结果时会附带多个营销追踪参数: + +| 参数 | 来源 | 说明 | +|------|------|------| +| inviterId | localStorage.getItem('inviterId') | 邀请人 ID,来自邀请链接 | +| bd_vid | Cookie bd_vid | 百度推广标识 | +| msclkid | Cookie msclkid | 微软广告点击 ID | +| fastgpt_sem | Cookie fastgpt_sem | FastGPT 自有 SEM 追踪标识 | +| sourceDomain | document.referrer | 来源域名(从父页面获取) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 微信登录页面 + participant API as 后端 API + participant WX as 微信服务 + + User->>UI: 访问登录页 + UI->>API: GET /proApi/.../wx/getQR + API-->>UI: 返回 {code, codeUrl} + UI->>UI: 展示二维码 + UI->>User: 显示"微信扫码登录" + + loop 每3秒轮询 + UI->>API: POST /proApi/.../wx/getResult {code, inviterId, bd_vid, ...} + API-->>UI: 返回 null(未扫码)或用户信息 + end + + User->>WX: 扫描二维码并确认登录 + UI->>API: 继续轮询 POST /proApi/.../wx/getResult + API-->>UI: 返回用户登录信息 + UI->>UI: 清除营销Cookie,保存用户信息 + UI->>UI: 跳转到 /dashboard/agent +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..1cb9d49926f4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: 微信登录 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: + - /login (pageType=wechat) +--- + +# 微信登录 — 模块概览 + +## 1. 模块业务概述 + +微信登录模块为 FastGPT 平台提供基于微信 OAuth 的扫码登录能力。用户通过微信客户端扫描页面展示的二维码完成身份认证,无需输入用户名和密码即可登录系统。 + +本模块是登录首页的子能力之一,与密码登录、注册、忘记密码等登录方式并列。当系统配置中启用了微信 OAuth 且用户设备未检测到百度推广追踪参数时,微信登录将作为默认登录方式展示。 + +该模块为未登录用户服务,无角色区分。 + +## 2. 角色差异 + +本模块为登录前页面,所有访问者均为未认证用户,无角色差异。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 未登录用户 | 微信扫码登录页 | 扫描二维码登录 | 所有访问者权限一致 | + +**权限模型**: 无需认证即可访问,无角色限制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库 (@fastgpt/web) | MyImage 图片组件、Loading 加载组件、useToast 提示 Hook | 组件/Hook | +| 用户 API 层 (@/web/support/user) | getWXLoginQR(获取二维码)、getWXLoginResult(查询扫码结果) | API | +| 营销工具 (@/web/support/marketing) | 获取推广参数(bd_vid, msclkid, fastgpt_sem, inviterId, sourceDomain) | 工具函数 | +| 系统状态管理 (@/web/common/system) | useSystemStore(读取 feConfigs 系统配置) | Store | +| 全局类型 (@fastgpt/global) | LoginSuccessResponseType 登录成功响应类型、getErrText 错误文本提取 | 类型/工具 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| FormLayout(登录表单布局容器) | 密码登录 (LoginForm)、快速登录 | 组件 | +| PolicyTip(协议/隐私政策提示) | 密码登录 (LoginForm) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 微信扫码登录 | /login (pageType=wechat) | projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx | 未登录用户 | 无 Tab,单页面流程 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..53ab3468fcd8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,32 @@ +--- +capability_label: 忘记密码 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — 业务流程索引 + +## 找回密码 + +- **业务描述**: 用户遗忘登录密码时,通过已注册的邮箱/手机号获取验证码,验证身份后设置新密码,完成密码重置并自动登录。 +- **触发入口**: 登录页面(`/login`)密码登录表单底部 → 点击"忘记密码"链接。 +- **路由路径**: `/login`(登录页面内嵌,通过 `setPageType('forgetPassword')` 切换为忘记密码表单)。 +- **页面组件位置**: `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx` +- **涉及角色**: 所有用户(公开) +- **前置条件**: + - 系统配置中启用了密码找回方式(`feConfigs.find_password_method` 不为空,支持 email/phone) + - 用户拥有已注册的账号 + +## 场景关系 + +- **密码登录** → **忘记密码**:用户在密码登录流程中,因遗忘密码而跳转到忘记密码流程。找回密码成功后自动登录,进入系统。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 找回密码 | 登录页→忘记密码链接 | /login | 公开 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..d5657e38a71e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,122 @@ +--- +capability_label: 忘记密码 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — 业务流程详解 + +## 页面总览 + +忘记密码功能以表单形式嵌入在登录页面中。用户从密码登录表单底部点击"忘记密码"链接后,登录页面切换显示忘记密码表单。用户依次输入账号、获取验证码、设置新密码后提交,完成密码重置并自动登录系统。 + +本功能无 Tab 结构,为单页表单流程。 + +## 找回密码 + +> 找回密码是忘记密码模块的唯一业务场景。用户通过验证码身份认证后重置密码。 + +### 步骤 1:输入账号 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在账号输入框中输入邮箱或手机号,输入后鼠标离开输入框触发失焦校验 | 无 | 输入为空时提交:显示提示"请输入邮箱/手机号";格式不匹配:显示提示"邮箱/手机号格式错误" | 输入框失焦后如有错误,输入框变红并显示错误提示文字 | + +### 步骤 2:获取验证码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入账号后,点击输入框右侧"获取验证码"文字按钮 | `POST /proApi/support/user/inform/sendAuthCode`(通过 `useSendCode` 触发) | 账号为空时点击:提示"请输入账号";人机验证(Google reCAPTCHA)未通过:发送失败;验证码发送中:按钮文字显示"发送中...";发送成功:提示"验证码已发送",按钮开始 60 秒倒计时;发送失败:提示具体错误信息 | 点击后如有 googleClientVerKey 配置,弹出人机验证弹窗(`SendCodeAuthModal`);验证通过后按钮进入 60 秒倒计时(文字变为"60s后重新获取"→"09s后重新获取"→恢复"获取验证码");发送期间按钮置灰不可点击 | + +**验证码发送详情**: + +- 发送 API 参数包含:账号(username)、验证码类型(`findPassword`)、Google reCAPTCHA Token、图形验证码、语言。 +- 发送成功后按钮从 60 开始倒数,每秒减 1,到达 0 后恢复可点击状态。 +- 倒计时期间按钮颜色变灰(`myGray.500`),不可点击。 + +### 步骤 3:输入验证码和新密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在验证码输入框中输入收到的 6-8 位验证码;在新密码输入框中输入新密码(需满足复杂度规则);在确认密码输入框中再次输入相同密码 | 无(前端校验) | 验证码为空时提交:显示提示"请输入验证码";密码不满足复杂度规则:显示提示"密码需包含字母和数字(至少两种组合),8-100位";两次密码不一致:显示提示"两次密码不一致" | 各输入框失焦后即时校验,错误时输入框变红并显示对应错误提示 | + +### 步骤 4:提交重置密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"找回密码"按钮(或按 Enter 键)提交表单 | `POST /proApi/support/user/account/password/updateByCode`(参数:username、code、password,其中 password 经 hashStr 加密后传输) | 提交前各字段校验未通过:阻止提交,焦点定位到首个错误字段;API 返回成功:提示"密码已找回",自动登录系统并跳转;API 返回失败(如验证码错误/过期):显示后端返回的错误信息 | 点击后按钮显示加载状态(`isLoading`);提交期间按钮置灰不可重复点击;成功后跳转至工作台页面 | + +### 返回登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表单底部"返回登录"链接 | 无 | 无 | 登录页面切换回密码登录表单(`setPageType('passwordLogin')`) | + +--- + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 账号 | 文本输入 | ✅ | — | 邮箱格式或手机号格式(正则:`/(^1[3456789]\d{9}$)\|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/`) | 否 | 根据系统配置 `find_password_method` 显示占位提示(如"邮箱/手机号") | +| 验证码 | 文本输入 | ✅ | — | 最多 8 位 | 否 | 需先点击"获取验证码"获取 | +| 新密码 | 密码输入 | ✅ | — | 需满足 `checkPasswordRule`:8-100 位,含字母+数字(至少两种组合) | 否 | 密码输入时以圆点掩码显示 | +| 确认密码 | 密码输入 | ✅ | — | 须与新密码一致 | 否 | 与新密码比对校验 | + +### 字段联动 + +- 账号输入为空时,"获取验证码"按钮点击后提示"请输入账号",不发起请求。 +- 验证码倒计时期间(60s),"获取验证码"按钮置灰不可点击。 +- 任意字段校验未通过时,提交按钮点击后不会发起 API 请求(由 `react-hook-form` 的 `handleSubmit` 拦截)。 + +### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 账号为空 | 失焦 | "请输入邮箱/手机号" | +| 账号格式不匹配 | 失焦 | "邮箱/手机号格式错误" | +| 验证码为空 | 提交时 | "请输入验证码" | +| 密码不满足复杂度 | 失焦 | "密码需包含字母和数字(至少两种组合),8-100位"(注:当前代码使用 `reset_password_tip` i18n key,实际文案取决于翻译文件) | +| 两次密码不一致 | 失焦 | "两次密码不一致" | + +### 前后置条件 + +- **前置条件**:系统配置 `feConfigs.find_password_method` 不为空(至少启用邮箱或手机号找回方式);用户拥有已注册账号。 +- **后置影响**:密码重置成功后,系统返回登录凭证并自动登录,跳转至工作台首页;原密码失效。 +- **失败场景**:验证码过期或错误时,提示用户重新获取验证码;账号未注册时,后端返回错误信息并显示。 + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 忘记密码表单 + participant API as 后端 API + participant Backend as 后端服务 + + U->>UI: 输入账号 + UI->>UI: 失焦校验格式 + U->>UI: 点击"获取验证码" + UI->>API: POST /proApi/support/user/inform/sendAuthCode + API->>Backend: 发送验证码到用户邮箱/手机 + Backend-->>API: 发送成功 + API-->>UI: 返回成功 + UI->>UI: 开始 60s 倒计时 + U->>UI: 输入验证码 + U->>UI: 输入新密码 + UI->>UI: 失焦校验密码规则 + U->>UI: 输入确认密码 + UI->>UI: 失焦校验密码一致性 + U->>UI: 点击"找回密码"提交 + UI->>API: POST /proApi/support/user/account/password/updateByCode + API->>Backend: 校验验证码→更新密码 + Backend-->>API: 返回登录凭证 + API-->>UI: 返回成功 + UI->>UI: 自动登录→跳转工作台 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..e3507cb0238a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,49 @@ +--- +capability_label: 忘记密码 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — 模块概览 + +## 模块业务概述 + +忘记密码模块是 FastGPT 登录流程中的密码找回功能。当用户遗忘登录密码时,可通过该模块输入已注册的账号(邮箱或手机号),获取短信/邮箱验证码,然后设置新密码完成密码重置。该模块面向所有已注册用户,无需登录即可使用。 + +本模块作为登录首页的子功能,以页面内嵌表单形式呈现在登录页面(`/login`)中,用户从密码登录表单点击"忘记密码"链接即可进入。该模块为 leaf 节点(叶子模块),无子能力。 + +## 角色差异 + +本模块为公开功能,所有用户均可访问,不涉及角色权限差异。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有用户(公开) | 忘记密码表单 | 找回密码(发送验证码→设置新密码) | 无差异,所有用户同一流程 | + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享基础设施 | `useRequest` 请求管理、`useToast` 消息提示 | Hook | +| 共享基础设施 | `useSystemStore` 系统配置(密码找回方式) | Store | +| 共享基础设施 | `checkPasswordRule` 密码规则校验 | 工具函数 | +| 用户支持 | `postFindPassword` 提交密码重置请求 | API | +| 用户支持 | `useSendCode` 发送验证码(含人机验证) | Hook | + +## 对外共享 + +本模块为叶子表单组件,不对外提供共享能力。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 忘记密码表单 | /login | `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx` | 公开 | 嵌入式表单,在登录页内部通过 setPageType 切换显示 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..7c24aab723a0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,60 @@ +--- +capability_label: 登录首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T10:50:00.000Z +parent_module: 登录 +roles: [未登录用户] +router_paths: [/login] +--- + +# 登录首页 — 模块概览 + +## 1. 模块业务概述 + +登录首页是 FastGPT 平台的用户认证入口页面,为所有未登录用户提供统一的身份认证服务。本模块是用户进入系统前的第一道关口,负责管理多种登录方式的切换、用户 Cookie 同意、中国 IP 识别重定向等公共功能。 + +登录首页支持四种核心认证子能力:密码登录、注册账号、忘记密码、微信扫码登录。首页本身负责页面布局框架(左侧品牌展示区、右侧表单区)、OAuth 第三方登录入口(SSO/Google/GitHub/Microsoft/企业微信)、语言切换、以及登录成功后的路由跳转。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 未登录用户 | 登录首页 | 密码登录、注册、忘记密码、微信登录、OAuth 登录 | 所有访问者均可使用 | +| 已登录用户 | 自动跳转 | 无(自动重定向到 /dashboard/agent) | 已登录用户访问 /login 通常会被重定向 | + +**权限模型**: 登录首页无角色权限限制,所有未登录用户均可访问。登录方式受系统配置(feConfigs)控制,管理员可在后台配置开启/关闭注册、忘记密码、微信登录、OAuth 等功能。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统配置 | useSystemStore — 读取系统特性开关、OAuth 配置、品牌信息 | Store | +| 用户状态 | useUserStore — 登录成功后写入用户信息 | Store | +| 聊天状态 | useChatStore — 登录成功后重置聊天上下文 | Store | +| i18n 国际化 | useTranslation — 多语言文案切换 | Hook | +| 共享组件 | MyImage、Loading、I18nLngSelector — 通用 UI 组件 | 组件 | + +## 4. 对外共享 + +登录首页主要被以下模块消费: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| LoginContainer 组件 | 内嵌登录弹窗场景 | 页面/组件 | +| loginSuccess 回调 | 团队邀请等需要登录后处理的外部模块 | Hook/回调 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 登录首页 | /login | `projects/app/src/pages/login/index.tsx` | 未登录用户 | 含 4 个 Tab 子能力(详见下方) | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 密码登录 | 密码登录 | [业务流程详解](../登录首页/密码登录/模块概览.md) | +| 注册 | 注册 | [业务流程详解](../登录首页/注册/模块概览.md) | +| 忘记密码 | 忘记密码 | [业务流程详解](../登录首页/忘记密码/模块概览.md) | +| 微信登录 | 微信登录 | [业务流程详解](../登录首页/微信登录/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..86655df4f787 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,30 @@ +--- +capability_label: "注册" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:52:14Z" +parent_module: "登录首页" +roles: ["visitor"] +router_paths: ["/login"] +--- + +# 注册 — 业务流程索引 + +## 注册账号 + +- **业务描述**:访客在登录页切换到注册 Tab,填写用户名(邮箱/手机号)、获取验证码、设置密码后提交注册。注册成功自动登录并跳转至应用首页。 +- **触发入口**:登录首页 → 点击"注册"链接 → 切换到注册 Tab +- **路由路径**:`/login`(注册 Tab 内,无独立路由) +- **页面组件位置**:`projects/app/src/pageComponents/login/RegisterForm.tsx` +- **涉及角色**:访客 +- **前置条件**:系统配置中 `feConfigs.register_method` 非空(系统管理员启用了注册功能) + +## 场景关系 + +各场景独立执行,无严格先后关系。注册表单为单一场景——用户进入页面后需依次完成填写用户名、获取验证码、设置密码、确认密码、提交注册。 + +## 业务场景汇总 + +| 编号 | 场景名 | Tab | 触发入口 | 路由路径 | 角色 | +|------|--------|-----|---------|---------|------| +| S01 | 用户注册 | 注册 | 登录页 → 点击"注册"链接 | `/login` | 访客 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..24a492f0848c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,158 @@ +--- +capability_label: "注册" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:52:14Z" +parent_module: "登录首页" +roles: ["visitor"] +router_paths: ["/login"] +--- + +# 注册 — 业务流程详解 + +## 页面总览 + +注册 Tab 是登录页面的子视图,提供新用户自助注册能力。用户需依次完成用户名填写、验证码获取、密码设置三个步骤,提交后由后端完成注册并自动登录。整个流程为单页面操作,无页面跳转。 + +> 本模块为单表单组件,无嵌套 Tab。 + +--- + +### 用户注册 + +> 业务描述:访客在登录页切换到注册 Tab,填写注册信息并提交,完成账号注册后自动登录进入系统。 + +#### 步骤 1:进入注册表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在登录首页点击"注册"链接 | 无 | 系统配置中 `feConfigs.register_method` 非空时"注册"链接可见;为空时链接不可见,用户无注册入口 | 登录容器将 `pageType` 切换为 `register`,通过 `next/dynamic` 动态加载 RegisterForm 组件;表单区域展示注册表单 | + +#### 步骤 2:填写用户名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在用户名输入框中输入邮箱或手机号,输入框失焦时触发校验 | 无(前端校验) | **邮箱/手机号格式校验**:输入值匹配正则 `/^1[3456789]\d{9}$/`(手机号)或邮箱格式正则;不匹配时显示错误提示"请输入正确的邮箱或手机号";匹配则校验通过。
**注册方式分支**:placeholder 提示文案由 `feConfigs.register_method` 决定:含 `email` 和 `phone` 时显示"邮箱/手机号";仅含一项时只显示对应提示。 | 输入框失焦时若校验失败,输入框底部显示红色错误提示;校验通过后错误提示消失。 | + +#### 步骤 3:获取验证码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击输入框右侧"获取验证码"按钮 | `GET /proApi/support/user/account/captcha/getImgCaptcha` → `POST /proApi/support/user/inform/sendAuthCode` | **用户名为空**:弹出警告提示"用户名不能为空",不发送请求。
**用户名已填写**:弹出验证码认证弹窗(SendCodeAuthModal),用户需完成图形验证码/谷歌验证后发送短信/邮箱验证码。
**60 秒冷却**:发送成功后按钮置灰并显示倒计时"60s后重新获取"→"59s后重新获取"→...,期间不可重复点击。
**发送失败**:显示错误提示"验证码发送失败"。 | 按钮文字从"获取验证码"变为倒计时数字;倒计时结束后恢复为"获取验证码",按钮恢复可点击状态。 | + +#### 步骤 4:输入验证码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在验证码输入框中输入收到的 8 位验证码 | 无 | **空值校验**:提交时若验证码为空,显示错误提示"验证码不能为空"。
**长度限制**:输入框最大长度为 8 位。 | 输入框失焦时若为空则显示红色错误提示。 | + +#### 步骤 5:设置密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在密码输入框中输入密码,失焦时触发校验 | 无(前端 `checkPasswordRule` 校验) | **密码规则校验**:调用 `checkPasswordRule` 函数校验密码复杂度(长度、字符类型等),不符合时显示提示"密码需包含字母和数字,长度 6-20 位"。 | 校验失败时输入框底部显示红色错误提示;校验通过后错误提示消失。输入框为 `type="password"`,输入内容以圆点显示。 | + +#### 步骤 6:确认密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认密码输入框中再次输入密码,失焦时触发校验 | 无(前端对比校验) | **两次密码一致性校验**:确认密码与密码输入框的值进行对比;不一致时显示"两次输入的密码不一致"。 | 校验失败时输入框底部显示红色错误提示。 | + +#### 步骤 7:提交注册 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认注册"按钮 或 按 Enter 键(当验证码弹窗未打开时)| `POST /proApi/support/user/account/register/emailAndPhone` | **提交前校验**:所有表单字段(用户名、验证码、密码、确认密码)的校验规则需全部通过,否则各字段下方显示对应错误提示,不发送请求。
**后端校验失败**:用户名已注册、验证码错误、密码不符合要求等后端错误通过 `useRequest` 的 `onError` 处理,显示错误 Toast。
**注册成功**:后端返回登录凭证,自动调用 `loginSuccess` 回调完成登录。 | 提交时按钮显示加载动画(`isLoading`),按钮不可重复点击;注册成功后页面显示"注册成功"Toast,自动跳转到应用首页;`fastgpt_sem` 营销标记在注册成功后清除。 | + +#### 步骤 8:切换回登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"已有账号?去登录"链接 | 无 | 无条件 | 登录容器将 `pageType` 切换为 `passwordLogin`,注册表单卸载,密码登录表单加载。 | + +--- + +#### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 用户名 | 文本输入 | ✅ | — | 邮箱格式或手机号格式(正则校验) | 否 | 注册方式由 `feConfigs.register_method` 配置决定 | +| 验证码 | 文本输入 | ✅ | — | 最多 8 位 | 否 | 通过"获取验证码"按钮触发发送 | +| 密码 | 密码输入 | ✅ | — | 需通过 `checkPasswordRule` 校验(长度、字符类型等) | 否 | 输入内容以圆点掩码显示 | +| 确认密码 | 密码输入 | ✅ | — | 必须与"密码"字段值一致 | 否 | 失焦时自动对比 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 用户名格式校验 | 失焦(`onBlur` 模式)| "请输入正确的邮箱或手机号" | +| 用户名为空 | 失焦 | "邮箱/手机号不能为空" | +| 验证码为空 | 失焦 | "验证码不能为空" | +| 密码规则校验 | 失焦 | "密码需包含字母和数字,长度 6-20 位" | +| 两次密码不一致 | 失焦 | "两次输入的密码不一致" | +| 用户名为空时尝试获取验证码 | 点击"获取验证码" | "用户名不能为空" | + +**前后置条件**: + +- **前置条件**:系统配置中 `feConfigs.register_method` 非空(注册功能已启用),用户未登录 +- **后置影响**:注册成功后自动登录、清除营销标记 `fastgpt_sem`、记录登录追踪事件(`TrackEnum.login`) +- **失败场景**:用户名已注册(后端返回错误提示)、验证码错误或过期(需重新获取验证码)、网络异常(显示错误 Toast,表单状态不变) + +**提交时携带的营销参数**: + +提交注册时,除了用户名、密码、验证码外,还会自动采集以下营销追踪参数: +- `inviterId`:邀请人 ID +- `bd_vid`:百度推广 VID +- `msclkid`:微软广告 Click ID +- `fastgpt_sem`:FastGPT 语义标记 +- `sourceDomain`:来源域名 + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 注册表单 + participant API as FastGPT API + participant Backend + + User->>UI: 点击"注册"链接 + UI->>UI: 动态加载 RegisterForm + + User->>UI: 输入用户名 + UI->>UI: 失焦校验格式 + + User->>UI: 点击"获取验证码" + UI->>UI: 校验用户名非空 + UI->>API: GET /proApi/support/user/account/captcha/getImgCaptcha + API-->>UI: 图形验证码 + User->>UI: 输入图形验证码 + UI->>API: POST /proApi/support/user/inform/sendAuthCode + API-->>UI: 验证码已发送 + UI->>UI: 启动 60s 倒计时 + + User->>UI: 输入验证码 + User->>UI: 输入密码 + UI->>UI: 失焦校验密码规则 + User->>UI: 确认密码 + UI->>UI: 失焦校验一致性 + + User->>UI: 点击"确认注册"或按 Enter + UI->>UI: 提交前全字段校验 + + par 营销参数采集 + UI->>UI: 采集 inviterId, bd_vid, msclkid, fastgpt_sem, sourceDomain + end + + UI->>API: POST /proApi/support/user/account/register/emailAndPhone + API->>Backend: 创建用户账号 + Backend-->>API: 注册成功 + 登录凭证 + API-->>UI: LoginSuccessResponse + UI->>UI: 显示"注册成功"Toast + UI->>UI: 清除 fastgpt_sem + UI->>UI: 自动登录并跳转首页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..6d0b8819287d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: "注册" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:52:14Z" +parent_module: "登录首页" +roles: ["visitor"] +router_paths: ["/login"] +--- + +# 注册 — 模块概览 + +## 1. 模块业务概述 + +注册模块是登录首页中的一个 Tab 子页面,为未登录用户(访客)提供账号注册功能。用户通过填写用户名(支持邮箱或手机号)、验证码、密码及确认密码来完成注册流程。 + +注册模块在整体产品中承担**用户准入**角色——将访客转化为平台注册用户。注册成功后自动登录并跳转至应用首页,无需用户额外操作。 + +本模块为登录首页的 Tab 子能力,无下级子能力。 + +## 2. 角色差异 + +本模块面向单一角色:**访客(未登录用户)**。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 访客 | 注册表单 | 输入用户名、获取验证码、设置密码、提交注册 | 唯一可用角色;已登录用户不会访问此页面 | + +**权限模型**:本模块无权限判断逻辑——注册页面仅对未登录用户开放,由登录容器 `LoginContainer` 根据页面类型状态切换显示,不涉及 RBAC 权限位。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户认证 API 层 | `postRegister` 注册接口、`sendAuthCode` 发送验证码 | API | +| 验证码发送 | `useSendCode` Hook — 发送验证码并管理倒计时状态 | Hook | +| 系统配置 | `useSystemStore` — 读取 `feConfigs.register_method` 决定注册方式 | Store | +| 营销追踪 | `getInviterId`、`getBdVId`、`getMsclkid`、`getFastGPTSem` 等营销参数采集 | 工具函数 | +| 密码校验 | `checkPasswordRule` — 前端密码规则校验 | 工具函数 | + +## 4. 对外共享 + +本模块为纯消费型 Tab 组件,不对外提供共享能力。`RegisterForm` 组件仅被登录容器 `LoginContainer` 内部使用(通过 `next/dynamic` 动态加载)。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 无 | — | — | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 登录页 - 注册 Tab | `/login` | `projects/app/src/pageComponents/login/RegisterForm.tsx` | 访客 | 登录首页中 `LoginPageTypeEnum.register` 对应的 Tab,由 `LoginContainer` 通过 `next/dynamic` 动态加载 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..9fcc9b235b0b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,37 @@ +--- +capability_label: 第三方登录 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:49:00Z" +parent_module: 登录 +roles: [] +router_paths: + - /login/provider +--- + +# 第三方登录 — 业务流程索引 + +## 业务场景列表 + +### OAuth 回调登录 + +- **业务描述**:用户从登录页选择第三方登录(GitHub / Google / 微信 / Microsoft / 企业微信 / SSO)并在第三方平台完成授权后,浏览器被重定向到本页面,系统自动完成令牌交换、用户信息获取和会话建立。 +- **触发入口**:第三方 OAuth 平台授权成功后的自动重定向(非用户手动触发) +- **路由路径**:`/login/provider` +- **页面组件位置**:`projects/app/src/pages/login/provider.tsx` +- **涉及角色**:所有用户 +- **前置条件**: + - 已从登录页点击了第三方登录按钮 + - 被重定向到第三方授权页面并完成授权 + - URL 中携带了授权 code 和 state 参数 + - `loginStore` 中存储了 provider 类型和预期 state 值 + +## 场景关系 + +各 OAuth 提供商的回调处理共用同一页面入口,按 `loginStore.provider` 区分类型,但核心流程一致。本模块为单场景模块,无场景间依赖关系。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 第三方 OAuth 回调登录 | 第三方平台授权成功后重定向 | `/login/provider` | 所有用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..de1ec8f96ac7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,230 @@ +--- +capability_label: 第三方登录 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:49:00Z" +parent_module: 登录 +roles: [] +router_paths: + - /login/provider +--- + +# 第三方登录 — 业务流程详解 + +## 页面总览 + +本页面是 OAuth/SSO 第三方登录的统一回调处理页。用户在登录页选择任意第三方登录方式(GitHub、Google、微信、Microsoft、企业微信、SSO)完成授权后,第三方平台将浏览器重定向到此页面,URL 上携带授权码(code)和状态(state)等参数。页面加载后自动执行令牌清除→状态安全校验→OAuth 登录 API 调用→用户信息写入→团队邀请处理→路由跳转的完整链路。 + +页面 UI 仅展示加载动画(``),所有逻辑均在 `useEffect` 中自动触发,对用户透明。 + +--- + +### S01:第三方 OAuth 回调登录 + +> 业务描述:接收第三方 OAuth 授权回调,完成令牌交换和用户会话建立。支持 GitHub / Google / 微信 / Microsoft / 企业微信 / SSO 六种提供商。 + +#### 步骤 1:页面加载与参数解析 + +页面组件渲染后,从 Next.js 路由的 `router.query` 中提取参数。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)第三方平台重定向到本页,URL 携带 code、state 等参数 | 无 | — | 页面渲染 `` 加载动画 | + +URL 参数解析: +- `state`:安全校验 token,与登录页存储的 `loginStore.state` 比对 +- `error`:第三方平台返回的错误标识 +- 其他 props 参数(code 等)直接透传给后端 API + +#### 步骤 2:错误前置检查 + +页面首先检查 URL 中是否包含错误标识。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)页面加载完成 | 无 | **error 参数存在**:第三方平台返回了错误 | 弹出警告 Toast(文案:provider error 对应的 i18n 词条),1 秒后重定向至错误页 | + +**分支详情**: +- **error 存在**(如用户拒绝授权)→ 显示错误提示,跳转至 `errorRedirectPage`(优先使用 `/chat` 相关的 lastRoute,否则回退到 `/login`),终止后续流程 +- **error 不存在** → 继续步骤 3 + +#### 步骤 3:初始化校验 + +检查系统是否完成初始化和必要参数是否存在。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)等待系统初始化完成 | 无 | **props 参数缺失或 initd 不为 true**:提前返回,等待下次渲染 | 继续展示加载动画 | +| (自动)重复调用防护 | 无 | **模块级 `isOauthLogging` 已为 true**:提前返回,阻止重复执行 | 无变化 | +| (自动)通过以上校验 | 无 | 符合预期 | 设置 `isOauthLogging = true`,防止重复调用 | + +**分支详情**: +- `props` 为 undefined/null(URL 参数尚未解析完成)或 `initd === false`(系统 store 未初始化)→ 不执行任何操作,等待下次 effect 触发 +- `isOauthLogging === true`(已有进行中的 OAuth 请求)→ 直接返回,防止并发的重复 API 调用 +- 通过校验 → 标记 `isOauthLogging = true`,进入步骤 4 + +#### 步骤 4:清除旧令牌 + +在发起 OAuth 登录前,先清除可能存在的旧登录状态。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)开始 OAuth 流程 | `GET /support/user/account/loginout`(通过 `clearToken()` 调用,外层包裹 `retryFn`,失败自动重试最多 2 次) | API 调用失败时 `retryFn` 自动重试,最多 2 次;全部失败则抛出异常 | 无可见变化(后台操作) | +| (自动)令牌清除完成 | 无(预加载)| — | `router.prefetch('/dashboard/agent')` 预加载目标页面资源 | + +> **`clearToken()` 内部流程**:先调用 `clearAdStorage()` 清除 localStorage 中以 `logout-` 为前缀的广告存储项(逐项触发 ahooks 同步事件),再调用 `loginOut()` API 通知服务端清除会话。 + +#### 步骤 5:State 安全校验 + +将 URL 中的 `state` 参数与登录页存储的预期值比对,防止 CSRF 攻击。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)令牌清除后 | 无 | **provider 不为 'sso' 且 state 与 loginStore.state 不匹配** | 弹出警告 Toast(文案:security_failed 对应的 i18n 词条),1 秒后重定向至错误页 | +| (自动)state 验证通过 | 无 | **provider 为 'sso'** 或 **state 匹配成功** | 进入步骤 6,调用 authProps | + +**分支详情**: +- 非 SSO 登录 + state 不匹配 → **安全风险**:可能遭受 CSRF 攻击,中止流程,跳转错误页 +- SSO 登录(不校验 state)或 state 匹配成功 → 继续执行 OAuth 登录 + +#### 步骤 6:调用 OAuth 登录 API + +提交授权参数到后端,完成令牌交换和用户认证。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)state 校验通过 | `POST /proApi/support/user/account/login/oauth` | — | 等待 API 响应,继续展示加载动画 | + +**API 请求参数**(从源码提取,不编造): + +| 参数名 | 来源 | 说明 | +|--------|------|------| +| `type` | `loginStore.provider` 或默认 `OAuthEnum.sso` | OAuth 提供商类型 | +| `props` | URL query 参数(code 等) | 第三方平台返回的授权参数 | +| `callbackUrl` | `location.origin + '/login/provider'` | 本回调页的完整 URL | +| `inviterId` | `localStorage.getItem('inviterId')` | 邀请人 ID | +| `bd_vid` | `sessionStorage.getItem('bd_vid')` | 百度推广追踪 ID | +| `msclkid` | `sessionStorage.getItem('msclkid')` | 微软广告点击 ID | +| `fastgpt_sem` | `localStorage.getItem('fastgpt_sem')`(JSON 解析) | SEM 追踪参数对象 | +| `sourceDomain` | `sessionStorage.getItem('sourceDomain')` | 来源域名 | +| `language` | `i18n.language` | 当前语言偏好 | + +#### 步骤 7:处理 API 响应 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)API 返回 | 无 | **res 为空(未返回数据)**:登录失败 | 弹出警告 Toast(文案:login.error 对应的 i18n 词条),1 秒后重定向至错误页 | +| (自动)API 返回 | 无 | **API 抛出异常**:网络错误或服务端拒绝 | 弹出警告 Toast(显示 `getErrText(error)` 或默认 login.error),1 秒后重定向至错误页 | +| (自动)API 返回成功 | 无 | **res 有效** | 清除 `fastgpt_sem` 存储,进入步骤 8(loginSuccess) | + +**错误处理详情**: +- `res` 为空 → `toast({ status: 'warning', title: t('common:support.user.login.error') })` +- 捕获异常 → `toast({ status: 'warning', title: getErrText(error, t('common:support.user.login.error')) })` +- 两种情况均在 1 秒延迟后执行 `router.replace(errorRedirectPage)` +- 无论成功或失败,最终都会调用 `setLoginStore(undefined)` 清除临时登录状态 + +#### 步骤 8:登录成功处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)登录成功 | 无 | — | 调用 `setUserInfo(res.user)` 将用户信息写入 store | + +**后续分支(决定跳转目标)**: + +| 分支条件 | 触发 API | 页面变化 | +|---------|---------|---------| +| **团队状态为 'active'**:正常团队 | 无 | 导航至 `validateRedirectUrl(lastRoute)` 解析的安全 URL | +| **团队状态非 'active' 且 lastRoute 包含 `/account/team?invitelinkid=`**:通过邀请链接注册的新用户 | `POST /proApi/support/user/team/invitationLink/accept`(参数 linkId 从 URL 中提取) | 接受邀请后导航至 `/dashboard/agent` | +| **团队状态非 'active' 且 lastRoute 不包含邀请链接**:团队未激活 | 无 | 弹出警告 Toast(文案:not_active_team 对应的 i18n 词条),不执行导航 | + +**`validateRedirectUrl` 安全校验**: +- 目标 URL 必须以 `/` 开头(相对路径) +- 不允许包含 `/login`(防止循环重定向) +- 不允许以 `//` 或 `/\` 开头(防止协议相对 URL 攻击) +- 不允许包含 `javascript:` 或 `data:` 等协议 +- 校验失败时回退到 `/dashboard/agent` + +#### 步骤 9:路由跳转 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)确定跳转目标 | 无 | `navigateTo` 有效 | `router.replace(navigateTo)` 替换当前历史记录,跳转至目标页 | +| (自动)确定跳转目标 | 无 | `navigateTo` 无效(团队未激活) | 停留在当前页,用户看到 Toast 提示 | + +--- + +### 场景补充内容 + +#### 查看/回调处理类场景 — 数据加载详情 + +本场景为单次回调处理,不涉及分页、排序、轮询。 + +#### 错误与边界 + +| 场景 | 处理机制 | +|------|---------| +| 重复提交防护 | 模块级 `isOauthLogging` 标志,整个模块生命周期内只允许一次 OAuth 请求 | +| CSRF 攻击防护 | state 参数比对(`loginStore.state` vs URL `state`),非 SSO 登录时强制校验 | +| 开放重定向防护 | `validateRedirectUrl` 校验跳转目标,拦截绝对 URL、协议注入、登录页循环 | +| 令牌清除失败 | `retryFn` 包裹 `clearToken()`,失败自动重试最多 2 次 | +| OAuth 登录失败 | 显示错误 Toast,1 秒延迟后跳转错误页(非立即跳转,给用户时间看到错误提示) | +| 第三方平台返回 error | 页面加载时前置检查,error 存在则直接跳转错误页,不发起后续 API 调用 | +| 系统未初始化 | 等待 `initd` 变为 true 后再执行流程(useEffect 依赖 initd 触发) | +| URL 参数未就绪 | props 参数为空时提前返回,等待 Next.js 路由解析完成 | +| 团队未激活 | 显示 not_active_team 提示,不执行导航 | +| 老用户重登录 | 团队状态为 active 时跳过邀请处理,直接跳转 lastRoute | + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户浏览器 + participant Provider as provider.tsx + participant API as 后端 API + participant Store as 状态 Store + + User->>Provider: 第三方平台重定向(携带 code/state) + Provider->>Provider: 解析 router.query 参数 + + alt URL 含 error 参数 + Provider->>User: 显示错误 Toast + Provider->>User: 1s 后跳转 errorRedirectPage + else 正常流程 + Provider->>Provider: 检查 initd && props && isOauthLogging + Provider->>Provider: 设置 isOauthLogging = true + + Provider->>API: GET /support/user/account/loginout(clearToken) + API-->>Provider: 令牌清除完成 + Provider->>Provider: router.prefetch('/dashboard/agent') + + alt state 不匹配(非 SSO) + Provider->>User: 显示安全校验失败 Toast + Provider->>User: 1s 后跳转 errorRedirectPage + else state 匹配或 SSO + Provider->>API: POST /proApi/support/user/account/login/oauth + alt API 成功 + API-->>Provider: 返回用户信息 + Provider->>Store: setUserInfo(res.user) + + alt 团队 active + Provider->>User: router.replace(安全lastRoute) + else lastRoute 含邀请链接 + Provider->>API: POST /proApi/support/user/team/invitationLink/accept + API-->>Provider: 邀请接受成功 + Provider->>User: router.replace('/dashboard/agent') + else 团队未激活 + Provider->>User: 显示 not_active_team Toast + end + + else API 失败 + API-->>Provider: 错误 + Provider->>User: 显示错误 Toast + Provider->>User: 1s 后跳转 errorRedirectPage + end + end + + Provider->>Store: setLoginStore(undefined)(清除临时状态) + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..93ecbc7b7841 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,59 @@ +--- +capability_label: 第三方登录 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:49:00Z" +parent_module: 登录 +roles: [] +router_paths: + - /login/provider +--- + +# 第三方登录 — 模块概览 + +## 1. 模块业务概述 + +第三方登录模块是 FastGPT 登录体系的 OAuth/SSO 回调处理中心,负责接收第三方身份提供商(如 GitHub、Google、微信、Microsoft、企业微信等)返回的授权凭证,完成用户身份验证和会话建立。 + +当用户在登录页面选择第三方登录方式后,浏览器会跳转至对应平台的授权页面;授权成功后,第三方平台将用户重定向回本模块的 `/login/provider` 页面,携带授权码和状态参数。本模块解析这些参数,调用后端 OAuth 接口完成登录,最终将用户引导至目标页面。 + +本模块是登录流程的"最后一公里",衔接第三方授权与系统内部会话管理,为所有支持 OAuth 登录的用户提供统一的回调入口。 + +## 2. 角色差异 + +本模块为回调处理页面,不区分角色 —— 所有尝试通过第三方登录的用户均经过相同的处理流程。页面本身无权限校验逻辑,登录成功后的角色和权限由后端 API 返回的用户信息决定。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有用户 | `/login/provider` | 完成第三方 OAuth 回调登录 | 无差异,统一处理流程 | + +**权限模型**:本模块无前端权限判断,登录结果和用户权限完全由后端 `oauthLogin` API 返回决定。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统 Store | `useSystemStore` — 读取/写入 loginStore(含 provider、lastRoute、state),读取 feConfigs | Store | +| 用户 Store | `useUserStore` — 登录成功后写入用户信息 setUserInfo | Store | +| 用户认证 API | `oauthLogin` — OAuth 登录接口;`clearToken` — 退出登录并清除令牌 | API | +| 团队 API | `postAcceptInvitationLink` — 登录后自动接受团队邀请链接 | API | +| 营销工具 | `getBdVId`、`getMsclkid`、`getFastGPTSem`、`getSourceDomain`、`getInviterId` — 获取营销追踪参数 | Utils | +| URI 校验 | `validateRedirectUrl` — 防止开放重定向攻击 | Utils | + +## 4. 对外共享摘要 + +本模块为叶子页面,不被其他模块引用或消费。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 无 | — | — | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 第三方登录回调 | `/login/provider` | `projects/app/src/pages/login/provider.tsx` | 所有用户 | OAuth 回调处理,无子页面,无 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..9fbf72ac1b40 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,36 @@ +--- +capability_label: API 密钥 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 业务流程索引 + +## 查看和管理 API 密钥 + +- **业务描述**:用户进入 API 密钥页面,查看已有密钥列表,可执行创建、编辑、删除密钥等管理操作,也可快速复制 API 基础地址和查看调用示例。 +- **触发入口**:账户页→左侧/顶部导航点击「API 密钥」Tab。 +- **路由路径**:`/account/apikey` +- **页面组件位置**:projects/app/src/pages/account/apikey.tsx +- **涉及角色**:团队管理员、团队成员(需要 hasApikeyCreatePer 权限) +- **前置条件**:已登录 FastGPT;团队权限中 hasApikeyCreatePer 为 true;账户 Tab 导航中可见「API 密钥」入口。 + +## 场景关系 + +各场景独立执行,无严格先后关系。创建密钥后可进行编辑和删除操作。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看 API 密钥列表 | 账户页→API 密钥 Tab | /account/apikey | 团队管理员、团队成员 | +| S02 | 创建 API 密钥 | API 密钥页→「新建」按钮 | /account/apikey | 团队管理员、团队成员 | +| S03 | 编辑 API 密钥 | 列表行→更多菜单→「编辑」 | /account/apikey | 团队管理员、团队成员 | +| S04 | 删除 API 密钥 | 列表行→更多菜单→「删除」 | /account/apikey | 团队管理员、团队成员 | +| S05 | 复制 API 基础地址 | 页面顶部→点击基础地址区域 | /account/apikey | 团队管理员、团队成员 | +| S06 | 查看 API 调用示例 | 页面→「调用示例」链接 | /account/apikey | 团队管理员、团队成员 | +| S07 | 复制新创建的 API Key | 创建完成后弹窗→点击 Key 值 | /account/apikey | 团队管理员、团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..60b73b24e8ca --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,260 @@ +--- +capability_label: API 密钥 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 业务流程详解 + +## 页面总览 + +API 密钥管理页面提供密钥的全生命周期管理能力。用户进入页面后,系统自动加载当前账户(或指定应用)的 API 密钥列表,并提供创建、编辑、删除操作入口,同时支持复制 API 基础地址和查看调用示例。 + +页面顶部为操作区(标题、文档链接、调用示例链接、API 基础地址复制区、新建按钮),主体为密钥列表表格(名称、Key 值、已用/总量上限、过期时间、创建时间、最后使用时间、操作菜单)。 + +### 查看 API 密钥列表 + +> 用户进入 API 密钥页面,系统自动加载并显示密钥列表。 + +#### 步骤 1:进入页面并加载数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在账户导航中点击「API 密钥」Tab | GET /support/openapi/list (params: { appId? }) | 无 — 自动触发,manual: false | MyBox 显示加载遮罩;API 返回后遮罩消失,表格渲染密钥列表 | + +#### 步骤 2:浏览密钥列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看列表中的密钥信息 | 无(数据已在步骤 1 加载) | isPlus 为 true 时,显示「过期时间」列和用量上限数值 | 表格展示:名称、Key 值、已用点数/上限、过期时间(Plus)、创建时间、最后使用时间、操作菜单(⋯) | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /support/openapi/list | { appId } — appId 可选,页面级不传,应用级传入 | 按创建时间倒序(由后端返回) | 表格所有密钥 | +| 操作后刷新 | GET /support/openapi/list | 同上次请求参数 | 无额外处理 | 表格数据更新(无遮罩) | + +- 分页:当前版本无前端分页,列表一次性加载全部密钥 +- 排序:由后端返回,页面不做客户端排序 +- 筛选:appId 可选参数过滤特定应用的密钥;页面级(账户 Tab)不传 appId,显示账户下全部密钥 + +**特殊列的渲染逻辑**: + +- **Key 值列**:显示完整 API Key 字符串,不做脱敏 +- **用量列**:显示 `已用点数 / 上限`,当上限为 -1 或 Plus 版本未开启时仅显示已用点数 +- **过期时间列**:Plus 版本可见,已设置过期时间则显示格式为 `YYYY/MM/DD HH:mm`,否则显示 `-` +- **最后使用时间列**:有使用记录时显示 `YYYY/MM/DD HH:mm:ss`,从未使用则显示"未使用" +- **操作菜单**:每行末尾的 `⋯` 图标按钮,点击弹出编辑/删除菜单 + +--- + +### 创建 API 密钥 + +> 用户点击「新建」按钮,填写密钥名称及可选限制参数后提交创建。 + +#### 步骤 1:打开创建表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面右上角「新建」按钮 | 无 | 无 | 弹出 EditKeyModal 模态框,标题为「创建 API Key」,表单为空默认值 | + +#### 步骤 2:填写并提交表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入密钥名称 | 无 | 名称必填,最大长度 100 字符;失焦校验非空 | 校验失败时浏览器默认提示 | +| 输入最大用量上限(Plus 版本) | 无 | Plus 版本可见此字段;值范围 -1~10000000,-1 表示无限制 | — | +| 选择过期时间(Plus 版本) | 无 | Plus 版本可见此字段;可选择具体日期时间或不选 | DateTimePicker 弹窗选择 | +| 点击「确认」提交 | POST /support/openapi/create | 名称必填校验通过则提交;校验失败则阻止提交 | 按钮显示 loading 状态(creating);成功后弹窗关闭 | + +#### 步骤 3:获取新密钥 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 创建成功后自动弹出新密钥展示 | 无 | 创建成功后 onCreate 回调传入新 Key ID | 弹出模态框显示完整 API Key 字符串,提示「请妥善保管,关闭后无法再次查看」 | + +#### 步骤 4:复制新密钥 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击密钥值区域或复制图标 | 无(客户端复制) | 无 | 密钥复制到剪贴板,显示复制成功提示 | +| 点击「确定」关闭弹窗 | 无 | 无 | 弹窗关闭,列表自动刷新 | + +**表单与交互详情**: + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称(name) | 文本输入 | ✅ | — | 最大 100 字符 | 否 | 密钥别名,用于识别不同密钥 | +| 最大用量上限(limit.maxUsagePoints) | 数字输入 | ✅(Plus) | -1 | -1 ~ 10000000,整数 | 否 | Plus 版本功能,-1 表示不限制 | +| 过期时间(limit.expiredTime) | 日期时间选择器 | 否(Plus) | 空 | 任意未来时间 | 否 | Plus 版本功能,不选则永不过期 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称为空 | 提交时 | 名称为空(i18n: common:name_is_empty) | +| 最大用量上限超出范围 | 提交时 | 浏览器原生约束提示 | +| 创建请求失败 | API 返回错误 | 创建链接错误(i18n: workflow:create_link_error) | + +**前置条件**:已登录;账户可见「API 密钥」Tab(需要 hasApikeyCreatePer 权限) + +**后置影响**:创建成功后,新密钥立即生效,可用于 API 调用鉴权;密钥列表刷新 + +**失败场景**:创建请求失败时,EditKeyModal 保持打开,用户可修改后重试 + +--- + +### 编辑 API 密钥 + +> 用户点击列表行操作菜单中的「编辑」,修改密钥的名称、用量上限或过期时间。 + +#### 步骤 1:打开编辑表单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击行末「⋯」→「编辑」 | 无 | 无 | 弹出 EditKeyModal 模态框,标题为「编辑 API Key」,表单预填当前密钥数据 | + +#### 步骤 2:修改并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/用量上限/过期时间 | 无 | Plus 版本可修改用量上限和过期时间;非 Plus 版本仅可修改名称 | — | +| 点击「确认」提交 | PUT /support/openapi/update | 名称必填校验通过则提交 | 按钮显示 loading 状态(updating);成功后弹窗关闭,列表刷新 | + +**表单与交互详情**: + +编辑与创建使用同一个 EditKeyModal 组件,通过 `_id` 是否存在区分编辑/创建模式。编辑模式下表单预填已有数据。 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称为空 | 提交时 | 名称为空 | +| 更新请求失败 | API 返回错误 | 更新链接错误(i18n: workflow:update_link_error) | + +**前置条件**:密钥已存在且可被编辑 + +**后置影响**:密钥配置即时更新;列表刷新 + +**失败场景**:更新请求失败,弹窗保持打开,用户可重试 + +--- + +### 删除 API 密钥 + +> 用户点击列表行操作菜单中的「删除」,确认后永久删除指定密钥。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击行末「⋯」→「删除」 | 无 | 无 | 弹出确认弹窗(红色警告样式),标题/内容为「确认删除该 API Key?」 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗中「确认」 | DELETE /support/openapi/delete (body: { id }) | 无 | 确认弹窗关闭;列表数据自动刷新;密钥行从表格中移除 | +| 点击「取消」 | 无 | 无 | 弹窗关闭,不执行删除 | + +**删除链路详情**: + +- **确认弹窗**:标题为删除确认,内容为"确认删除该 API Key?"(i18n: common:delete_api),type 为 "delete" 使用红色警告样式 +- **单条删除**:每条密钥独立删除,无批量删除功能 +- **级联影响**:删除后该密钥立即失效,所有使用该密钥的 API 调用将被拒绝 + +--- + +### 复制 API 基础地址 + +> 用户在页面顶部点击基础地址区域,将 API 基础 URL 复制到剪贴板。 + +#### 步骤 1:复制操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部 API 基础地址显示区域 | 无(客户端剪贴板 API) | 自定义域名配置存在时显示配置值,否则显示 `{当前域名}/api` | 剪贴板更新为基础地址,显示「复制成功」提示 | + +--- + +### 查看调用示例 + +> 用户点击「调用示例」链接,系统根据当前应用类型和变量配置生成 curl 命令示例。 + +#### 步骤 1:打开示例弹窗(仅应用级场景) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部「调用示例」链接 | GET /core/app/get (params: { id: appId }) | 仅当有 appId 时触发 API 请求;无 appId 时以空数据显示 | 弹出 CallExampleModal,展示基于应用类型生成的 curl 调用示例、变量说明和类型对照表 | +| 查看示例 | 无 | 根据应用类型(assistant/其他)决定是否包含变量参数;根据文件配置决定是否包含文件上传示例 | 展示带注释的 curl 命令、简洁版复制按钮、变量类型说明 | + +**状态转换详情**: + +- `feConfigs.docUrl` 存在 → 显示「查看文档」链接和「调用示例」链接 +- `feConfigs.docUrl` 不存在 → 不显示文档和调用示例入口 + +--- + +### 复制新创建的 API Key + +> 创建 API Key 成功后弹出的 Key 展示弹窗中,用户可一键复制新 Key 值。 + +#### 步骤 1:复制新 Key + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Key 值区域或复制图标 | 无(客户端剪贴板 API) | 无 | Key 值复制到剪贴板,显示复制成功提示 | + +--- + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 页面 + participant API as API 服务 + participant Backend as 后端 + + User->>UI: 进入 API 密钥页 + UI->>API: GET /support/openapi/list + API->>Backend: 查询密钥列表 + Backend-->>API: 返回密钥数据 + API-->>UI: 密钥列表数据 + UI-->>User: 展示密钥表格 + + User->>UI: 点击「新建」 + UI-->>User: 弹出创建表单 + User->>UI: 填写名称及限制参数,点击确认 + UI->>API: POST /support/openapi/create + API->>Backend: 创建密钥 + Backend-->>API: 返回新 Key ID + API-->>UI: 创建成功 + UI-->>User: 显示新 Key 值弹窗 + UI->>API: GET /support/openapi/list (刷新) + + User->>UI: 点击「⋯」→「编辑」 + UI-->>User: 弹出编辑表单(预填数据) + User->>UI: 修改信息,点击确认 + UI->>API: PUT /support/openapi/update + API->>Backend: 更新密钥 + Backend-->>API: 更新成功 + API-->>UI: 更新成功 + UI->>API: GET /support/openapi/list (刷新) + + User->>UI: 点击「⋯」→「删除」 + UI-->>User: 弹出确认弹窗 + User->>UI: 点击「确认」 + UI->>API: DELETE /support/openapi/delete + API->>Backend: 删除密钥 + Backend-->>API: 删除成功 + API-->>UI: 删除成功 + UI->>API: GET /support/openapi/list (刷新) +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..aa6360de6a26 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,51 @@ +--- +capability_label: API 密钥 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 共享能力清单 + +## 本模块对外提供的能力 + +| 能力名称 | 能力类型 | 消费模块 | 消费方式 | 说明 | +|---------|---------|---------|---------|------| +| ApiKeyTable 组件 | 业务组件 | 应用发布(Publish/API) | 动态 import:`@/components/support/apikey/Table` | 在应用详情发布页中复用完整的 API Key 管理表格,支持传入 appId 过滤指定应用的密钥 | +| getOpenApiKeys | API 函数 | 应用发布(Publish/API) | import 引用 | 获取指定应用或全局的 API 密钥列表 | +| createAOpenApiKey | API 函数 | 应用发布(Publish/API) | import 引用 | 创建新的 API 密钥 | +| putOpenApiKey | API 函数 | 应用发布(Publish/API) | import 引用 | 更新已有 API 密钥的配置 | +| delOpenApiById | API 函数 | 应用发布(Publish/API) | import 引用 | 按 ID 删除 API 密钥 | + +## 本模块消费的外部能力 + +| 能力名称 | 能力类型 | 来源模块 | 消费方式 | 说明 | +|---------|---------|---------|---------|------| +| AccountContainer | 布局组件 | 账户容器 | import 引用 | 提供统一的账户页面布局、Tab 导航和侧边栏 | +| useSystemStore | Zustand Store | 系统配置 | import 引用 | 获取 feConfigs 系统特性开关和自定义 API 域名 | +| useUserStore | Zustand Store | 团队用户 | import 引用 | 获取用户信息和团队权限 | +| useRequest | Hook | web 公共 Hooks | import:`@fastgpt/web/hooks/useRequest` | 异步请求封装,管理 loading/error 状态和自动刷新 | +| useCopyData | Hook | web 公共 Hooks | import:`@fastgpt/web/hooks/useCopyData` | 剪贴板复制功能 | +| useConfirm | Hook | web 公共 Hooks | import:`@fastgpt/web/hooks/useConfirm` | 确认弹窗(删除确认) | +| useForm | Hook | react-hook-form | import 库 | 表单状态管理和校验 | +| MyIcon | 基础组件 | web 公共组件库 | import:`@fastgpt/web/components/common/Icon` | 图标渲染 | +| MyModal | 基础组件 | web 公共组件库 | import:`@fastgpt/web/components/common/MyModal` | 模态框 | +| MyBox | 基础组件 | web 公共组件库 | import:`@fastgpt/web/components/common/MyBox` | 加载容器组件 | +| MyMenu | 基础组件 | web 公共组件库 | import:`@fastgpt/web/components/common/MyMenu` | 行操作下拉菜单 | +| DateTimePicker | 基础组件 | web 公共组件库 | import:`@fastgpt/web/components/common/DateTimePicker` | 日期时间选择器 | +| getAppDetailById | API 函数 | 应用核心 API | import:`@/web/core/app/api` | 获取应用详情用于生成调用示例(仅 appId 存在时) | +| getDocPath | 工具函数 | 文档系统 | import:`@/web/common/system/doc` | 生成文档链接路径 | + +## 共享组件与 API 的消费详情 + +### ApiKeyTable 被复用时行为差异 + +| 属性 | 账户场景(apikey.tsx) | 应用发布场景(Publish/API) | +|------|----------------------|--------------------------| +| appId | 不传(undefined) | 传入具体 appId | +| API 密钥范围 | 账户下全部应用的密钥 | 仅指定应用的密钥 | +| 调用示例可用性 | 不可用(无 appId) | 可用(有 appId 时可获取应用详情) | +| tips 内容 | "API 密钥管理提示"(i18n: account_apikey:key_tips) | 默认为空或应用相关提示 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..8bc05d75fa2a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,53 @@ +--- +capability_label: API 密钥 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 模块概览 + +## 1. 模块业务概述 + +API 密钥模块是 FastGPT 账户体系下的核心安全功能,为用户提供应用对外 API 访问的鉴权凭证管理。用户在此模块中创建、编辑、删除 API 密钥(API Key),并通过密钥对外暴露 FastGPT 应用的对话能力。该模块同时提供 API 基础地址复制和调用示例展示功能,帮助开发者快速集成。 + +API 密钥被广泛应用于应用发布中的「API 访问」渠道,一个良好管理的 API 密钥体系是应用对外开放的安全基础。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员(Owner/Admin) | API 密钥管理页面 | 创建密钥、编辑密钥、删除密钥、复制 API 基础地址、查看调用示例 | 完整管理权限,可配置高级功能(用量上限、过期时间) | +| 团队普通成员 | API 密钥管理页面(如有 hasApikeyCreatePer 权限) | 创建密钥、编辑密钥、删除密钥、复制 API 基础地址、查看调用示例 | 操作权限与管理员一致,但可能无法配置 Plus 版高级功能 | + +**权限模型**:基于团队权限位 `hasApikeyCreatePer` 控制整个 API 密钥模块的可见性。Tab 入口在账户侧边栏/顶部导航中根据 `userInfo.team.permission.hasApikeyCreatePer` 条件渲染。Plus 版本的功能(用量上限、过期时间)由 `feConfigs.isPlus` 控制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 应用(Publish/API) | API 密钥表格组件被应用发布 API 渠道复用 | 组件 | +| 账户容器 | AccountContainer 提供统一的账户页面布局和 Tab 导航 | 组件 | +| 系统配置(useSystemStore) | feConfigs 系统特性开关和自定义 API 域名配置 | Store | +| 团队用户(useUserStore) | 用户信息和团队权限数据 | Store | +| 公共 Hooks(useRequest/useCopyData/useConfirm) | 异步请求封装、剪贴板复制、确认弹窗 | Hook | + +## 4. 对外共享摘要 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ApiKeyTable 组件 | 应用发布(Publish/API)— 在应用详情发布页中管理特定应用的 API Key | 组件 | +| OpenAPI 接口函数(getOpenApiKeys/createAOpenApiKey/delOpenApiById/putOpenApiKey) | 应用发布(Publish/API)— 对应用维度的 API Key 进行增删改查 | API | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| API 密钥管理 | /account/apikey | projects/app/src/pages/account/apikey.tsx | 团队管理员、团队成员 | 无子 Tab,单页面展示 API Key 列表和操作 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..658642b93cff --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,143 @@ +--- +capability_label: 账户 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:49:09Z" +parent_module: null +roles: [owner, admin, member] +router_paths: ["/account/usage", "/account/info", "/account/team", "/account/bill", "/account/thirdParty", "/account/customDomain", "/account/model", "/account/promotion", "/account/apikey", "/account/inform", "/account/setting"] +--- + +# 账户 — 业务流程索引 + +## 业务场景列表 + +### 查看用量统计 + +- **业务描述**: 查看团队的 AI 用量消耗情况,支持按时间范围、成员/部门/群组维度和使用来源进行筛选,提供仪表盘和明细两种视图。 +- **触发入口**: PC 端左侧导航栏 → 用量统计;移动端顶部选项卡 → 用量统计 +- **路由路径**: `/account/usage` +- **页面组件位置**: `pages/account/usage/index.tsx` +- **涉及角色**: owner, admin +- **前置条件**: 用户具有团队管理权限(hasManagePer);系统为 Plus 版本(feConfigs.isPlus) + +### 管理账号信息 + +- **业务描述**: 查看和编辑个人信息(头像、名称),查看当前订阅套餐详情,管理优惠券和兑换码,修改密码和联系方式。 +- **触发入口**: PC 端左侧导航栏 → 账号信息;移动端顶部选项卡 → 账号信息 +- **路由路径**: `/account/info` +- **页面组件位置**: `pages/account/info/index.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 用户已登录 + +### 管理团队 + +- **业务描述**: 查看团队成员列表,邀请新成员加入,管理成员角色和权限,处理成员退出。 +- **触发入口**: PC 端左侧导航栏 → 团队管理;移动端顶部选项卡 → 团队管理 +- **路由路径**: `/account/team` +- **页面组件位置**: `pages/account/team/index.tsx` +- **涉及角色**: owner, admin +- **前置条件**: 系统为 Plus 版本(feConfigs.isPlus) + +### 管理账单与发票 + +- **业务描述**: 查看历史订单列表,查看账单详情,管理发票抬头和开票信息。 +- **触发入口**: PC 端左侧导航栏 → 账单;移动端顶部选项卡 → 账单 +- **路由路径**: `/account/bill` +- **页面组件位置**: `pages/account/bill/index.tsx` +- **涉及角色**: owner, admin +- **前置条件**: 系统开启支付功能(feConfigs.show_pay);用户具有管理权限(hasManagePer) + +### 配置第三方集成 + +- **业务描述**: 配置 Laf 云开发账户、OpenAI 账户和工作流外部变量,查看各集成的用量配额。 +- **触发入口**: PC 端左侧导航栏 → 第三方集成;移动端顶部选项卡 → 第三方集成 +- **路由路径**: `/account/thirdParty` +- **页面组件位置**: `pages/account/thirdParty/index.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 用户已登录;非 owner 角色编辑时提示无权限 + +### 管理自定义域名 + +- **业务描述**: 为应用添加自定义域名,配置 DNS 解析和 CNAME 记录,激活域名。 +- **触发入口**: PC 端左侧导航栏 → 自定义域名;移动端顶部选项卡 → 自定义域名 +- **路由路径**: `/account/customDomain` +- **页面组件位置**: `pages/account/customDomain/index.tsx` +- **涉及角色**: owner, admin +- **前置条件**: 系统为 Plus 版本且启用了自定义域名功能(feConfigs.customDomain.enable) + +### 管理模型 + +- **业务描述**: 查看和配置 AI 模型供应商,管理模型列表,自定义训练模型,设置模型权限。 +- **触发入口**: PC 端左侧导航栏 → 模型管理;移动端顶部选项卡 → 模型管理 +- **路由路径**: `/account/model` +- **页面组件位置**: `pages/account/model/index.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 用户已登录 + +### 查看推广记录 + +- **业务描述**: 查看推广邀请统计(邀请人数、收益金额),复制邀请链接,查看推广收益明细。 +- **触发入口**: PC 端左侧导航栏 → 推广;移动端顶部选项卡 → 推广 +- **路由路径**: `/account/promotion` +- **页面组件位置**: `pages/account/promotion.tsx` +- **涉及角色**: owner +- **前置条件**: 系统开启推广功能(feConfigs.show_promotion);用户为团队 owner(permission.isOwner) + +### 管理 API 密钥 + +- **业务描述**: 创建、查看和删除 API 访问密钥,用于外部应用调用 FastGPT 接口。 +- **触发入口**: PC 端左侧导航栏 → API 密钥;移动端顶部选项卡 → API 密钥 +- **路由路径**: `/account/apikey` +- **页面组件位置**: `pages/account/apikey.tsx` +- **涉及角色**: owner, admin +- **前置条件**: 用户具有 API 密钥创建权限(hasApikeyCreatePer) + +### 管理通知偏好 + +- **业务描述**: 配置通知接收方式和偏好设置。 +- **触发入口**: PC 端左侧导航栏 → 通知;移动端顶部选项卡 → 通知 +- **路由路径**: `/account/inform` +- **页面组件位置**: `pages/account/inform.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 系统为 Plus 版本(feConfigs.isPlus) + +### 个性化设置 + +- **业务描述**: 切换系统语言等个人偏好设置。 +- **触发入口**: PC 端左侧导航栏 → 语言设置;移动端顶部选项卡 → 语言设置 +- **路由路径**: `/account/setting` +- **页面组件位置**: `pages/account/setting.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 用户已登录 + +### 退出登录 + +- **业务描述**: 安全退出当前账号,清除登录状态并跳转到登录页。 +- **触发入口**: PC 端左侧导航栏底部 → 退出登录;移动端顶部选项卡 → 退出登录 +- **路由路径**: 无独立路由(触发后跳转 `/login`) +- **页面组件位置**: 逻辑位于 `AccountContainer.tsx` +- **涉及角色**: owner, admin, member +- **前置条件**: 用户已登录;需二次确认 + +## 场景关系 + +- 各场景**独立执行**,无严格先后关系。用户通过侧边栏/选项卡自由切换,无需按特定顺序操作。 +- **退出登录** 为终止性操作,执行后清空所有会话状态,需重新登录。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看用量统计 | 侧边栏→用量统计 | /account/usage | owner, admin | +| S02 | 管理账号信息 | 侧边栏→账号信息 | /account/info | owner, admin, member | +| S03 | 管理团队 | 侧边栏→团队管理 | /account/team | owner, admin | +| S04 | 管理账单与发票 | 侧边栏→账单 | /account/bill | owner, admin | +| S05 | 配置第三方集成 | 侧边栏→第三方集成 | /account/thirdParty | owner, admin, member | +| S06 | 管理自定义域名 | 侧边栏→自定义域名 | /account/customDomain | owner, admin | +| S07 | 管理模型 | 侧边栏→模型管理 | /account/model | owner, admin, member | +| S08 | 查看推广记录 | 侧边栏→推广 | /account/promotion | owner | +| S09 | 管理 API 密钥 | 侧边栏→API 密钥 | /account/apikey | owner, admin | +| S10 | 管理通知偏好 | 侧边栏→通知 | /account/inform | owner, admin, member | +| S11 | 个性化设置 | 侧边栏→语言设置 | /account/setting | owner, admin, member | +| S12 | 退出登录 | 侧边栏→退出登录 | → /login | owner, admin, member | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..84b3f7ff6e66 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,67 @@ +--- +capability_label: 账户 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:49:09Z" +parent_module: null +roles: [owner, admin, member] +router_paths: ["/account/usage", "/account/info", "/account/team", "/account/bill", "/account/thirdParty", "/account/customDomain", "/account/model", "/account/promotion", "/account/apikey", "/account/inform", "/account/setting"] +--- + +# 账户 — 业务流程详解 + +> 账户模块为分组节点,本身不承载具体业务操作。所有业务流程散布在各子能力中。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|------------|------------| +| 用量统计 | 查看团队 AI 用量消耗仪表盘和明细 | [09-业务流程索引](../用量统计/业务流程索引.md) | [10-业务流程详解](../用量统计/业务流程详解.md) | +| 账号信息 | 管理个人资料、订阅套餐、优惠券 | [09-业务流程索引](../账号信息/业务流程索引.md) | [10-业务流程详解](../账号信息/业务流程详解.md) | +| 团队管理 | 管理团队成员、角色和权限 | [09-业务流程索引](../团队管理/业务流程索引.md) | [10-业务流程详解](../团队管理/业务流程详解.md) | +| 账单 | 查看订单历史和发票管理 | [09-业务流程索引](../账单/业务流程索引.md) | [10-业务流程详解](../账单/业务流程详解.md) | +| 第三方集成 | 配置 Laf、OpenAI 和工作流变量 | [09-业务流程索引](../第三方集成/业务流程索引.md) | [10-业务流程详解](../第三方集成/业务流程详解.md) | +| 自定义域名 | 管理应用自定义域名和 DNS 配置 | [09-业务流程索引](../自定义域名/业务流程索引.md) | [10-业务流程详解](../自定义域名/业务流程详解.md) | +| 模型管理 | 管理 AI 模型供应商和模型列表 | [09-业务流程索引](../模型管理/业务流程索引.md) | [10-业务流程详解](../模型管理/业务流程详解.md) | +| 推广 | 查看推广统计和邀请链接 | [09-业务流程索引](../推广/业务流程索引.md) | [10-业务流程详解](../推广/业务流程详解.md) | +| API 密钥 | 创建和管理 API 访问密钥 | [09-业务流程索引](../API 密钥/业务流程索引.md) | [10-业务流程详解](../API 密钥/业务流程详解.md) | +| 通知 | 配置通知接收偏好 | [09-业务流程索引](../通知/业务流程索引.md) | [10-业务流程详解](../通知/业务流程详解.md) | +| 个性化设置 | 切换语言等个人偏好 | [09-业务流程索引](../个性化设置/业务流程索引.md) | [10-业务流程详解](../个性化设置/业务流程详解.md) | + +## 公共导航流程 + +账户模块通过 `AccountContainer` 组件提供统一的导航框架: + +- **PC 端**:左侧可折叠侧边栏,展示所有可用选项卡(用量统计、账号信息、团队管理、账单、第三方集成、自定义域名、模型管理、推广、API 密钥、通知、个性化设置、退出登录) +- **移动端**:顶部横向滚动的 LightRowTabs 选项卡组件 +- **导航切换**:点击选项卡通过 `router.replace('/account/' + tab)` 切换路由,激活态由当前路径末尾段匹配 +- **退出登录**:点击退出登录选项卡 → 弹出确认弹窗("确认退出登录?")→ 确认后清除用户状态 → 跳转至 `/login` +- **选项卡可见性**:由 `feConfigs`(isPlus、show_pay、show_promotion、customDomain.enable)和 `userInfo.team.permission` 联合控制 + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as AccountContainer + participant Router as Next.js Router + participant Page as 子页面组件 + + User->>UI: 进入 /account 任意子路由 + UI->>UI: 读取 feConfigs + userInfo.permission + UI->>UI: 计算可见选项卡列表 + UI->>Router: 匹配当前路由到 TabEnum + + alt PC 端 + UI->>UI: 渲染侧边栏 + 选中高亮 + else 移动端 + UI->>UI: 渲染顶部 LightRowTabs + end + + User->>UI: 点击选项卡 + UI->>Router: router.replace('/account/{tab}') + Router->>Page: 加载对应子页面组件 + Page->>Page: 各子能力独立业务流程(详见子文档) +``` + +> 各子能力的详细业务流程见上方「子能力业务流程索引」表中的链接。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..efdb8d10f400 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,34 @@ +--- +capability_label: "个性化设置" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: "账户" +roles: ["所有登录用户"] +router_paths: ["/account/setting"] +--- + +# 个性化设置 — 业务流程索引 + +## 业务场景列表 + +### 场景:修改个人偏好设置 + +- **业务描述**: 用户在账户设置页中修改界面语言和/或所在时区,系统即时保存并生效。 +- **触发入口**: 登录后 → 侧边导航进入"账户"→ 点击"语言与时区"Tab(移动端)或在 PC 端侧边栏选择"语言与时区"。 +- **路由路径**: `/account/setting` +- **页面组件位置**: `projects/app/src/pages/account/setting.tsx` +- **涉及角色**: 所有登录用户 +- **前置条件**: 用户已登录,已加载用户基本信息(含当前语言和时区设置) + +--- + +## 场景关系 + +各设置项独立修改,无先后依赖关系。语言和时区可在同一页面中分别独立变更。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 修改个人偏好设置(语言/时区) | 账户→语言与时区 | /account/setting | 所有登录用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..539fa063fad1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,92 @@ +--- +capability_label: "个性化设置" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: "账户" +roles: ["所有登录用户"] +router_paths: ["/account/setting"] +--- + +# 个性化设置 — 业务流程详解 + +## 页面总览 + +个性化设置页为用户提供语言和时区两项偏好配置。页面以"设置"为标题,包含两个下拉选择器:语言切换和时区选择。修改语言时,系统同时更新后端用户数据并切换前端国际化语言包;修改时区时,系统自动保存变更并显示成功提示。 + +> **Tab 检测结果**:本页面无 Tab 结构,为单一表单页。 + +--- + +### 修改个人偏好设置 + +> 用户在个性化设置页中修改语言或时区偏好,系统自动或手动保存至用户账户。 + +#### 步骤 1:页面加载,展示当前设置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过账户侧边栏点击"语言与时区"进入页面 | GET /support/user/account/tokenLogin(由 useUserStore.initUserInfo 在应用初始化时调用) | — | 页面显示"设置"标题;语言下拉框显示当前语言;时区下拉框显示当前时区;页面以 AccountContainer 包裹,PC 端显示侧边栏,移动端显示顶部 Tab 导航条 | + +#### 步骤 2:修改界面语言 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击语言下拉框,选择目标语言(如从"中文"切换至"English") | PUT /support/user/account/update(参数:`{ language: "en" }`) | **已登录用户**:先调用 updateUserInfo 保存语言偏好至后端,再切换前端语言包。
**未登录用户**(如在登录页使用):跳过后端保存,仅切换前端语言包。 | 前端界面语言立即切换为目标语言;语言下拉框选中值更新;若后端保存失败,前端语言仍已切换(乐观更新),后端语言偏好回滚。 | + +#### 步骤 3:修改时区 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击时区下拉框,选择目标时区(如从 "Asia/Shanghai" 切换至 "America/New_York") | PUT /support/user/account/update(参数:`{ timezone: "America/New_York" }`) | 时区变更自动触发保存(下拉框 onChange 直接调用 onclickSave)。
**前置条件**:userInfo 已加载,否则不执行保存。 | 显示加载中状态(toast 提示);保存成功后弹出"更新数据成功"的绿色 toast 提示;时区下拉框显示新选中的时区。若保存失败,时区回滚至旧值。 | + +--- + +#### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 说明 | +|--------|---------|------|--------|------------|------| +| 语言 (language) | 下拉选择器 (MySelect) | 否 | 用户注册时选择或系统默认语言 | 中文简体 / 中文繁體 / English(由 langMap 定义) | 登录用户修改后同步保存至后端 | +| 时区 (timezone) | 下拉选择器 (Chakra Select) | 否 | 用户注册时选择或系统默认时区 | 全球 IANA 时区列表(如 Asia/Shanghai, America/New_York 等) | 选择后自动保存,无需手动确认 | + +#### 校验规则 + +本页面无前端输入校验。语言和时区均为下拉选择,值来自预定义列表,不存在非法输入场景。 + +#### 前后置条件 + +- **前置条件**:用户已登录,userInfo 已通过 `initUserInfo` 加载完毕 +- **后置影响**:语言变更 → 全局 i18n 语言切换,所有页面文案立即更新;时区变更 → 系统中时间展示基于新时区计算 +- **失败场景**:网络异常或后端保存失败时,Store 中的 userInfo 回滚至旧值,用户可重新尝试 + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 个性化设置页 + participant API as 后端 API + + User->>UI: 进入设置页 /account/setting + UI->>UI: 从 useUserStore 读取 userInfo
展示当前语言和时区 + + rect rgb(220, 240, 255) + Note over User,API: 修改语言 + User->>UI: 在语言下拉框选择目标语言 + alt 已登录用户 + UI->>API: PUT /support/user/account/update { language } + API-->>UI: 保存成功 + end + UI->>UI: 切换前端 i18n 语言包
界面全局刷新为新语言 + end + + rect rgb(255, 240, 220) + Note over User,API: 修改时区 + User->>UI: 在时区下拉框选择目标时区 + UI->>API: PUT /support/user/account/update { timezone } + API-->>UI: 保存成功 + UI->>User: Toast 提示"更新数据成功" + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..5b0d25abbcfe --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,51 @@ +--- +capability_label: "个性化设置" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: "账户" +roles: ["所有登录用户"] +router_paths: ["/account/setting"] +--- + +# 个性化设置 — 模块概览 + +## 1. 模块业务概述 + +个性化设置模块为登录用户提供个人偏好配置功能,允许用户自定义语言和时区两项基本设置。该模块位于"账户"侧边栏导航中,以"语言与时区"为入口标签,是所有登录用户均可访问的基础设置页。 + +模块的核心价值在于:用户可以根据自身习惯选择界面显示语言,并设定所在时区以确保系统中时间相关信息的正确展示。设置变更后立即生效,无需刷新页面。 + +本模块无子能力,为单一叶子页面。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 所有登录用户 | 个性化设置页 | 修改语言、修改时区 | 无角色差异,所有登录用户权限一致 | + +**权限模型**: 页面访问由登录状态控制,未登录用户将被重定向至登录页。页面本身无额外角色限制。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户 Store (useUserStore) | 读取当前用户信息、调用 updateUserInfo 保存设置 | Store | +| I18nLngSelector 组件 | 语言选择下拉框(含语言切换逻辑) | 组件 | +| TimezoneSelect 组件 | 时区选择下拉框(含全球时区列表) | 组件 | +| AccountContainer 组件 | 账户页外层容器(含侧边栏导航和 Tab 切换) | 组件 | +| 用户 API (putUserInfo) | 调用 PUT /support/user/account/update 持久化设置 | API | + +## 4. 对外共享 + +本模块为叶子页面,不对外提供可复用的组件、API 或 Hook。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 个性化设置(语言与时区) | /account/setting | `projects/app/src/pages/account/setting.tsx` | 所有登录用户 | 无 Tab,单一表单页 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..4e093b2d66b2 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,80 @@ +--- +capability_label: 团队管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 账户 +roles: [团队所有者, 团队管理员, 普通成员] +router_paths: [/account/team] +--- + +# 团队管理 — 业务流程索引 + +## 业务场景列表 + +### 进入团队管理页面 + +- **业务描述**: 用户从账户侧边栏选择"团队"进入团队管理页面,页面自动加载当前团队的成员统计和 Tab 内容 +- **触发入口**: 账户侧边栏 → 点击"团队"菜单项;或通过邀请链接携带 invitelinkid 参数直接进入 +- **路由路径**: /account/team +- **页面组件位置**: projects/app/src/pages/account/team/index.tsx +- **涉及角色**: 所有团队成员 +- **前置条件**: 用户已登录且已加入至少一个团队 + +### 切换团队 + +- **业务描述**: 用户通过页面头部的团队选择器切换到其他已加入的团队,切换后页面自动刷新 +- **触发入口**: 团队管理页面头部 → 点击团队选择器下拉 +- **路由路径**: /account/team(页面刷新) +- **页面组件位置**: projects/app/src/pageComponents/account/TeamSelector.tsx +- **涉及角色**: 所有团队成员 +- **前置条件**: 用户已加入多个团队 + +### 编辑团队信息 + +- **业务描述**: 团队所有者点击编辑图标打开编辑弹窗,修改团队名称、头像和通知账号 +- **触发入口**: 团队管理页面头部 → 点击编辑图标(仅团队所有者可见) +- **路由路径**: /account/team +- **页面组件位置**: projects/app/src/pageComponents/account/team/context.tsx → EditInfoModal +- **涉及角色**: 团队所有者 +- **前置条件**: 用户为当前团队的 owner 角色 + +### 接受团队邀请 + +- **业务描述**: 用户通过邀请链接进入团队管理页面时,自动弹出邀请处理弹窗,可选择接受或拒绝邀请 +- **触发入口**: 通过包含 invitelinkid 参数的 URL 进入 +- **路由路径**: /account/team?invitelinkid={id} +- **页面组件位置**: projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx +- **涉及角色**: 被邀请用户 +- **前置条件**: URL 包含有效的 invitelinkid 参数,用户已登录 + +### Tab 子能力场景 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 团队管理主页 | — | 账户侧边栏→团队 | /account/team | 所有成员 | +| S01-1 |  ├ 成员管理 | 成员 | 团队管理→默认 Tab | /account/team?teamTab=member | 所有成员 | +| S01-2 |  ├ 组织管理 | 组织 | 团队管理→切换组织 Tab | /account/team?teamTab=org | 管理员及以上 | +| S01-3 |  ├ 分组管理 | 分组 | 团队管理→切换分组 Tab | /account/team?teamTab=group | 管理员及以上 | +| S01-4 |  ├ 权限管理 | 权限管理 | 团队管理→切换权限 Tab | /account/team?teamTab=permission | 管理员及以上 | +| S01-5 |  ├ 审计日志 | 审计日志 | 团队管理→切换审计 Tab | /account/team?teamTab=audit | 管理员及以上 | + +> 各 Tab 子能力的详细流程请参见对应子目录的文档。 + +## 场景关系 + +各 Tab 场景独立执行,无严格先后关系。进入团队管理页面后按默认 Tab(成员)展示,用户可按需切换。审计日志 Tab 受订阅计划限制(需计划含审计日志存储时长配置),不满足时切换被拦截并提示无权限。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 进入团队管理 | — | 账户侧边栏 | /account/team | 所有成员 | +| S02 | 切换团队 | — | 头部团队选择器 | /account/team(刷新) | 所有成员 | +| S03 | 编辑团队信息 | — | 头部编辑图标 | /account/team | 团队所有者 | +| S04 | 接受团队邀请 | — | 邀请链接 | /account/team?invitelinkid= | 被邀请用户 | +| S01-1 | 成员管理 | 成员 | Tab 切换 | /account/team?teamTab=member | 所有成员 | +| S01-2 | 组织管理 | 组织 | Tab 切换 | /account/team?teamTab=org | 管理员及以上 | +| S01-3 | 分组管理 | 分组 | Tab 切换 | /account/team?teamTab=group | 管理员及以上 | +| S01-4 | 权限管理 | 权限管理 | Tab 切换 | /account/team?teamTab=permission | 管理员及以上 | +| S01-5 | 审计日志 | 审计日志 | Tab 切换(条件可见) | /account/team?teamTab=audit | 管理员及以上 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..8ac572536a07 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,117 @@ +--- +capability_label: 团队管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 账户 +roles: [团队所有者, 团队管理员, 普通成员] +router_paths: [/account/team] +--- + +# 团队管理 — 业务流程详解 + +## 页面总览 + +团队管理页面是 FastGPT 团队协作的核心入口,提供成员管理、组织架构、权限分配和操作审计等能力。页面在 AccountContainer 布局容器内渲染,顶部展示团队名称和成员统计,下方通过 5 个 Tab 组织各子功能。仅已加入团队的用户可访问。 + +## Tab 结构索引 + +| Tab | 业务描述 | 步骤数 | API 数 | 来源 | 详细文档 | +|-----|---------|--------|--------|------|---------| +| 成员 | 成员列表查看、邀请、移除、状态管理 | 8 | 6 | 本模块 | [业务流程详解](../成员/业务流程详解.md) | +| 组织 | 组织树管理、成员分配、移动 | 5 | 4 | 本模块 | [业务流程详解](../组织/业务流程详解.md) | +| 分组 | 分组创建/编辑/删除、成员分配 | 4 | 3 | 本模块 | [业务流程详解](../分组/业务流程详解.md) | +| 权限管理 | 成员/组织/分组的资源创建权限分配 | 4 | 4 | 本模块 | [业务流程详解](../权限管理/业务流程详解.md) | +| 审计日志 | [条件可见] 团队操作记录查看与筛选 | 3 | 2 | 本模块 | [业务流程详解](../审计日志/业务流程详解.md) | + +> 审计日志 Tab 仅在订阅计划含审计日志存储时长时对管理员可见。 + +## 公共业务流程 + +### 场景:进入团队管理页面 + +> 用户从账户侧边栏选择"团队"进入团队管理页面,页面自动加载当前团队数据。 + +#### 步骤 1:页面初始化与权限校验 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从账户侧边栏点击"团队"菜单项 | — | 当前用户未加入任何团队(userInfo.team 为空):页面不渲染任何内容 | 路由跳转至 /account/team | +| 等待页面加载 | GET /proApi/support/user/team/list(获取团队列表,参数 status=active) | 同步模式团队:不显示邀请按钮;企业微信团队:不显示邀请按钮,显示同步和转让按钮 | 页面加载中显示 Loading | +| 等待成员统计加载 | GET /proApi/support/user/team/member/count(获取成员总数) | — | 头部右侧显示"共 N 位成员"标签 | +| 页面渲染完成 | — | hasManagePer 为 true:Tab 栏显示"审计日志"选项;为 false:不显示审计日志 Tab | 默认激活"成员"Tab,加载成员列表内容 | + +#### 步骤 2:Tab 默认激活 + +页面初始化时默认选中"成员"Tab(teamTab=member),通过 router.query 控制当前激活的 Tab。Tab 切换通过更新 URL query 参数实现,不触发页面完整刷新,只切换内容区域渲染的子组件。 + +### 场景:切换团队 + +> 用户通过头部团队选择器切换到其他团队。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头部团队选择器下拉按钮 | — | — | 展开下拉菜单,展示已加入的团队列表(含团队头像和名称) | +| 在下拉列表中点击目标团队 | PUT /proApi/support/user/team/switch(参数 teamId) | 切换成功:页面重新加载;切换失败:弹出错误提示"切换团队失败" | 切换期间系统全局 Loading,切换成功后页面完全刷新 | + +### 场景:编辑团队信息 + +> 团队所有者点击编辑图标修改团队基本信息。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头部编辑图标(仅 owner 角色可见) | — | 团队所有者且 userInfo.team 存在时图标可见 | 弹出编辑团队信息弹窗 | +| 在弹窗中修改团队名称/头像/通知账号 | — | — | 表单实时更新(非受控模式或受控校验) | +| 提交修改 | PUT /support/user/team/update | 成功:刷新团队列表并重新初始化用户信息;失败:停留在弹窗 | 弹窗关闭,团队列表刷新,名称/头像即时更新 | + +### 场景:接受团队邀请 + +> 被邀请用户通过邀请链接进入,自动弹出接受/拒绝弹窗。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过邀请链接进入 /account/team?invitelinkid={id} | — | URL 中存在有效 invitelinkid 参数 | 页面正常加载团队管理页 + 自动弹出邀请处理弹窗 | +| 在弹窗中点击"接受" | POST /proApi/support/user/team/invitationLink/accept(参数 linkId) | 成功:关闭弹窗并跳转工作台 | 弹窗关闭 | +| 在弹窗中点击"拒绝" | — | — | 弹窗关闭 | + +### 场景:Tab 切换 + +> 用户在 5 个 Tab 间切换。 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Tab 标签 | — | 切换至"审计日志"且当前订阅计划不含审计日志存储时长:弹出警告"无权限",不切换 | Tab 切换时 URL query 更新;若切换成功,内容区域渲染对应 Tab 的子组件 | +| Tab 内容区加载 | 各 Tab 自行发起数据请求 | — | 内容区域显示对应 Tab 的 Loading + 数据内容 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 团队管理页面 + participant API as 后端 API + + User->>UI: 进入团队管理页面 + UI->>API: GET /proApi/support/user/team/list + API-->>UI: 团队列表 + UI->>API: GET /proApi/support/user/team/member/count + API-->>UI: 成员统计 + UI-->>User: 展示默认"成员"Tab + 团队信息 + + opt 切换团队 + User->>UI: 选择目标团队 + UI->>API: PUT /proApi/support/user/team/switch + API-->>UI: 切换成功 + UI-->>User: 页面完全刷新 + end + + opt 接受邀请 + User->>UI: 通过邀请链接进入 + UI-->>User: 弹出邀请处理弹窗 + User->>UI: 点击接受 + UI->>API: POST /proApi/support/user/team/invitationLink/accept + API-->>UI: 接受成功 + end + + Note over UI: 各 Tab 子能力流程详见对应子文档 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..65f978e6c0f4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,85 @@ +--- +capability_label: 分组 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +# 分组 — 业务流程索引 + +## 查看分组列表 + +- **业务描述**: 进入团队管理的分组 Tab,查看当前团队所有分组的列表,包含分组名称、负责人、成员头像及人数,以及对应的操作菜单。 +- **触发入口**: 团队管理页面 → 点击"分组" Tab +- **路由路径**: /account/team?teamTab=group +- **页面组件位置**: pageComponents/account/team/GroupManage/index.tsx +- **涉及角色**: 全部团队成员 +- **前置条件**: 用户已加入团队,当前团队有已创建的分组 + +## 创建分组 + +- **业务描述**: 团队管理员创建新的成员分组,设置分组名称和头像。 +- **触发入口**: 分组列表页 → 点击"创建分组"按钮 +- **路由路径**: /account/team?teamTab=group(弹窗操作,不改变路由) +- **页面组件位置**: pageComponents/account/team/GroupManage/GroupInfoModal.tsx +- **涉及角色**: 团队管理员(hasManagePer) +- **前置条件**: 用户拥有团队管理权限 + +## 编辑分组信息 + +- **业务描述**: 分组负责人或管理员修改分组的名称和头像。 +- **触发入口**: 分组列表页 → 点击分组行的操作菜单 → 选择"编辑信息" +- **路由路径**: /account/team?teamTab=group(弹窗操作,不改变路由) +- **页面组件位置**: pageComponents/account/team/GroupManage/GroupInfoModal.tsx +- **涉及角色**: 分组负责人、分组管理员 +- **前置条件**: 用户拥有该分组的管理权限(hasManagePer) + +## 管理分组成员 + +- **业务描述**: 分组负责人或管理员添加、移除分组成员,设置/取消分组管理员角色。 +- **触发入口**: 分组列表页 → 点击分组行的操作菜单 → 选择"管理成员"(或直接点击成员头像区域) +- **路由路径**: /account/team?teamTab=group(弹窗操作,不改变路由) +- **页面组件位置**: pageComponents/account/team/GroupManage/GroupManageMember.tsx +- **涉及角色**: 分组负责人、分组管理员 +- **前置条件**: 用户拥有该分组的管理权限(hasManagePer) + +## 转让分组负责人 + +- **业务描述**: 分组负责人将分组的所有权转让给团队内的另一个成员,可选择是否保留原负责人的管理员权限。 +- **触发入口**: 分组列表页 → 点击分组行的操作菜单 → 选择"转让负责人" +- **路由路径**: /account/team?teamTab=group(弹窗操作,不改变路由) +- **页面组件位置**: pageComponents/account/team/GroupManage/GroupTransferOwnerModal.tsx +- **涉及角色**: 分组负责人(isOwner) +- **前置条件**: 用户是该分组的负责人 + +## 删除分组 + +- **业务描述**: 分组负责人删除不再需要的分组。 +- **触发入口**: 分组列表页 → 点击分组行的操作菜单 → 选择"删除" +- **路由路径**: /account/team?teamTab=group(弹窗操作,不改变路由) +- **页面组件位置**: pageComponents/account/team/GroupManage/index.tsx(内联确认弹窗) +- **涉及角色**: 分组负责人(isOwner) +- **前置条件**: 用户是该分组的负责人 + +## 场景关系 + +- **查看分组列表** → **创建分组**: 从列表页点击创建按钮进入创建弹窗 +- **查看分组列表** → **编辑分组信息**: 从列表页操作菜单进入编辑弹窗 +- **查看分组列表** → **管理分组成员**: 从列表页操作菜单或成员头像区域进入成员管理弹窗 +- **查看分组列表** → **转让分组负责人**: 从列表页操作菜单进入转让弹窗 +- **查看分组列表** → **删除分组**: 从列表页操作菜单触发删除确认 +- 创建、编辑、成员管理、转让操作成功后均自动刷新分组列表 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看分组列表 | 团队管理→分组 Tab | /account/team?teamTab=group | 全部角色 | +| S02 | 创建分组 | 分组列表→创建按钮 | /account/team?teamTab=group | 团队管理员 | +| S03 | 编辑分组信息 | 分组列表→操作菜单→编辑信息 | /account/team?teamTab=group | 分组管理员/负责人 | +| S04 | 管理分组成员 | 分组列表→操作菜单→管理成员 | /account/team?teamTab=group | 分组管理员/负责人 | +| S05 | 转让分组负责人 | 分组列表→操作菜单→转让负责人 | /account/team?teamTab=group | 分组负责人 | +| S06 | 删除分组 | 分组列表→操作菜单→删除 | /account/team?teamTab=group | 分组负责人 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..4977500e8c26 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,384 @@ +--- +capability_label: 分组 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +# 分组 — 业务流程详解 + +## 页面总览 + +分组模块是团队管理页面中"分组"Tab 对应的面板,以表格形式展示团队内所有分组的列表。团队管理员可在页面顶部看到"创建分组"按钮,每个分组行显示名称、负责人、成员头像总览和操作菜单。所有增删改操作均通过弹窗完成,操作成功后自动刷新列表。 + +本模块无嵌套 Tab,所有功能集中在一个列表页面中。 + +--- + +## S01:查看分组列表 + +> **业务描述**: 用户切换到分组 Tab 后,查看当前团队下所有分组的完整列表,了解每个分组的基本信息和成员概况。 + +### 步骤 1:进入分组列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在团队管理页面点击"分组"Tab | POST /proApi/support/user/team/group/list(参数:withMembers: true) | 无分支,直接加载 | Tab 切换为"分组"高亮状态,列表区域显示加载动画(MyBox isLoading),数据返回后渲染分组表格 | + +### 步骤 2:浏览分组列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看分组列表(无操作) | 无(列表已加载) | 默认分组(DEFAULT_GROUP)名称显示为团队名称 | 表格每行展示:分组名称+头像(MemberTag)、负责人名称+头像、成员头像组(AvatarGroup)+人数、操作按钮(三个点图标) | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| Tab 切换时首次加载 | POST /proApi/support/user/team/group/list | withMembers: true | 默认分组名替换为团队名称 | 表格展示所有分组 | +| 依赖刷新 | POST /proApi/support/user/team/group/list | 同上(refreshDeps: [teamId]) | 同上 | 团队切换时自动重新加载 | + +- 分页:当前为一次性全量加载,无分页参数 +- 排序:按后台返回顺序展示 +- 筛选:无前端筛选器 +- 特殊列渲染:默认分组(name === 'DEFAULT_GROUP')的名称列显示当前团队名称而非 'DEFAULT_GROUP';操作菜单仅对 `hasManagePer` 为 true 的分组显示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击"分组"Tab + UI->>API: POST /proApi/support/user/team/group/list + API->>Backend: 查询团队全部分组(含成员信息) + Backend-->>API: 分组列表数据 + API-->>UI: MemberGroupListItemType[] + UI->>UI: 默认分组名替换为团队名称 + UI-->>User: 展示分组表格 +``` + +--- + +## S02:创建分组 + +> **业务描述**: 团队管理员创建新分组,设置分组名称和头像,便于后续成员分配和权限管理。 + +### 步骤 1:打开发起入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建分组"按钮 | 无 | 仅团队管理员(hasManagePer)可见此按钮 | 按钮带左图标(协作者图标),点击后弹出分组信息编辑弹窗 | + +### 步骤 2:填写分组信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看弹窗表单 | 无 | 创建模式:弹窗标题为"创建分组",名称为空,头像为默认团队头像 | 弹窗显示名称输入框(placeholder:"分组名称")和头像选择区域 | +| 点击头像区域 | GET /proApi/support/file/upload/avatar(预签名上传URL) | 无分支 | 打开文件选择器,选择图片后头像预览更新 | +| 输入分组名称 | 无 | 名称为必填字段 | 输入框中显示输入的内容 | + +### 步骤 3:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建"按钮 | POST /proApi/support/user/team/group/create(参数:name, avatar) | 名称必填校验不通过时阻止提交 | 按钮显示加载动画(isLoading),API 成功后弹窗关闭,分组列表自动刷新 | + +### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 头像 | 图片选择 | 否 | DEFAULT_TEAM_AVATAR | 通过预签名 URL 上传至文件服务 | 否 | 点击头像触发上传 | +| 名称 | 文本输入 | 是 | 空 | 无特殊约束 | 否 | 分组名称,用于列表展示 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称必填 | 提交时(react-hook-form required 校验) | 浏览器默认必填提示 | + +**前后置条件**: +- **前置条件**: 用户拥有团队管理权限(hasManagePer) +- **后置影响**: 创建成功后,新分组出现在分组列表中;系统自动为分组创建默认的 owner 角色(创建者) +- **失败场景**: 网络异常或服务端错误时,弹窗保持打开,用户可重试 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击"创建分组"按钮 + UI-->>User: 弹出创建分组弹窗(空白表单) + User->>UI: 上传头像(可选) + UI->>API: GET 预签名上传URL + UI->>API: PUT 上传头像文件 + User->>UI: 输入分组名称 + User->>UI: 点击"新建" + UI->>UI: 校验名称必填 + UI->>API: POST /proApi/support/user/team/group/create + API->>Backend: 创建分组记录 + Backend-->>API: 创建成功 + API-->>UI: 成功响应 + UI->>UI: 关闭弹窗 + UI->>API: POST /proApi/support/user/team/group/list(刷新) + UI-->>User: 列表更新,新分组出现 +``` + +--- + +## S03:编辑分组信息 + +> **业务描述**: 分组负责人或管理员修改分组的名称和头像。 + +### 步骤 1:进入编辑 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分组行操作菜单(三点图标) | 无 | 仅 hasManagePer 为 true 时显示操作菜单 | 展开 MyMenu 下拉菜单 | +| 选择"编辑信息" | 无 | 无分支 | 弹出编辑弹窗,预填当前分组名称和头像 | + +### 步骤 2:修改信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改头像/名称 | 无 | 操作同创建流程 | 表单控件实时更新 | +| 点击"保存" | PUT /proApi/support/user/team/group/update(参数:groupId, name, avatar) | 编辑模式(editGroup 存在):调用更新 API | 按钮显示加载动画,成功后弹窗关闭,列表刷新 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 头像 | 图片选择 | 否 | 当前分组头像 | 同创建流程 | 否 | 点击可更换 | +| 名称 | 文本输入 | 是 | 当前分组名称 | 同创建流程 | 否 | 显示当前值,可修改 | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击操作菜单→编辑信息 + UI-->>User: 弹出编辑弹窗(预填当前值) + User->>UI: 修改名称/头像 + User->>UI: 点击"保存" + UI->>API: PUT /proApi/support/user/team/group/update + API->>Backend: 更新分组信息 + Backend-->>API: 更新成功 + API-->>UI: 成功响应 + UI->>UI: 关闭弹窗 + UI->>API: POST /proApi/support/user/team/group/list(刷新) + UI-->>User: 列表更新 +``` + +--- + +## S04:管理分组成员 + +> **业务描述**: 分组负责人或管理员添加、移除分组成员,设置和取消分组管理员。 + +### 步骤 1:进入成员管理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"管理成员"(或点击成员头像区域) | GET /proApi/support/user/team/members(两路并行加载) | 仅 hasManagePer 为 true 时入口可用 | 弹出成员管理弹窗,左右双栏布局 | + +### 步骤 2:浏览和选择成员 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看左侧可选成员列表 | GET /proApi/support/user/team/members(参数:status=active, pageSize=20, searchKey) | 无分支,首次加载 | 左侧显示活跃团队成员列表,每项带复选框和所属组织信息 | +| 搜索成员 | GET /proApi/support/user/team/members(参数:searchKey 更新) | 搜索防抖 200ms | 列表按搜索关键词过滤刷新 | +| 滚动加载更多 | GET /proApi/support/user/team/members(参数:翻页) | 节流 500ms | 追加加载下一页数据 | +| 勾选/取消勾选成员 | 无 | 管理员不能移除负责人和管理员;不能移除负责人自身 | 选中项出现在右侧已选列表,角色默认为 member | +| 查看右侧已选成员 | GET /proApi/support/user/team/members(参数:groupId, pageSize=100000) | 无分支,一次性全量加载 | 右侧显示当前分组所有成员,含角色标签 | + +### 步骤 3:设置/取消管理员 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击普通成员旁的"设为管理员"标签 | 无(本地状态暂存) | 仅负责人可见此操作;hover 普通成员时显示黄色标签 | 该成员角色切换为 admin,显示管理员标签 | +| 点击管理员标签上的关闭图标 | 无(本地状态暂存) | 仅负责人可操作 | 该成员角色切换回 member | + +### 步骤 4:移除成员 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击成员旁的关闭图标 | 无(本地状态暂存) | 管理员只能移除 member 角色成员 | 该成员从已选列表中移除 | +| 尝试移除负责人 | 无 | 阻止操作,弹出 Toast 提示 | Toast:"无法删除负责人" | + +### 步骤 5:保存变更 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"保存" | PUT /proApi/support/user/team/group/update(参数:groupId, memberList: [{tmbId, role}]) | 有变更时才提交 | 按钮显示加载动画,成功后弹窗关闭,分组列表刷新 | + +### 表单与交互详情 + +**已选成员的角色标签**: + +| 角色 | 标签样式 | 可执行操作 | +|------|---------|-----------| +| 负责人 (owner) | 灰色 Tag,文字"负责人" | 不可删除,不可降级(负责人操作) | +| 管理员 (admin) | 默认色 Tag,文字"管理员" | 负责人可点击关闭图标降级为 member | +| 普通成员 (member) | 无标签(hover 时显示黄色"设为管理员"标签) | 负责人可升级为 admin;管理员可移除 | + +**前后置条件**: +- **前置条件**: 用户拥有该分组的 hasManagePer 权限(分组负责人或管理员角色) +- **后置影响**: 保存后成员变更生效,相关成员的权限立即更新 +- **失败场景**: 网络异常时弹窗保持打开,用户可重试 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击操作菜单→管理成员 + par 并行加载 + UI->>API: GET /proApi/support/user/team/members(全部活跃成员) + API->>Backend: 查询团队成员 + Backend-->>API: 成员列表 + API-->>UI: 可选成员列表 + and + UI->>API: GET /proApi/support/user/team/members(groupId=当前分组) + API->>Backend: 查询当前分组成员 + Backend-->>API: 分组成员列表 + API-->>UI: 已选成员列表 + end + UI-->>User: 双栏布局弹窗 + User->>UI: 勾选/取消成员,设置管理员 + User->>UI: 点击"保存" + UI->>API: PUT /proApi/support/user/team/group/update(memberList) + API->>Backend: 更新分组成员关系 + Backend-->>API: 更新成功 + API-->>UI: 成功响应 + UI->>UI: 关闭弹窗 + UI->>API: POST /proApi/support/user/team/group/list(刷新) + UI-->>User: 列表更新 +``` + +--- + +## S05:转让分组负责人 + +> **业务描述**: 分组负责人将所有权转让给团队内另一个成员。 + +### 步骤 1:打开发起入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"转让负责人" | 无 | 仅分组负责人(isOwner)可见 | 弹出转让负责人弹窗,显示当前分组名称和头像 | + +### 步骤 2:选择新负责人 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击搜索输入框 | GET /proApi/support/user/team/members(参数:pageSize=20, searchKey) | 搜索防抖 200ms | 聚焦输入框,可输入搜索关键词 | +| 输入搜索关键词 | GET /proApi/support/user/team/members(参数:searchKey 更新) | 搜索防抖 200ms,节流 500ms | 下拉列表显示匹配的团队成员 | +| 从下拉列表中点击选择成员 | 无 | 无分支 | 选中成员头像和名称显示在输入框左侧,下拉列表关闭 | + +### 步骤 3:确认转让 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选/取消"保留管理员权限"选项 | 无 | 默认勾选,表示转让后原负责人保留 admin 角色 | 复选框状态切换 | +| 点击"确认" | PUT /proApi/support/user/team/group/changeOwner(参数:groupId, tmbId) | 未选择成员时按钮置灰(isDisabled) | 按钮加载中;成功 Toast:"转让负责人成功";失败 Toast:"转让负责人失败" | + +### 状态转换详情 + +- **状态转换**: 原负责人(owner)→ 新负责人上任(原 owner 变为 admin(如勾选保留)或 member(如取消保留)) +- **前置检查**: 必须从团队成员中选择一个新负责人(已选中状态通过 tmbId 传递) +- **确认提示**: 弹窗标题"转让负责人",含"保留管理员权限"复选框(默认勾选),提示用户选择新负责人 +- **级联更新**: 转让成功后弹窗关闭,分组列表刷新 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击操作菜单→转让负责人 + UI-->>User: 弹出转让弹窗 + User->>UI: 搜索并选择新负责人 + UI->>API: GET /proApi/support/user/team/members(搜索成员) + API-->>UI: 匹配的成员列表 + UI-->>User: 显示搜索结果 + User->>UI: 选择成员,确认"保留管理员" + User->>UI: 点击"确认" + UI->>API: PUT /proApi/support/user/team/group/changeOwner + API->>Backend: 变更分组负责人 + Backend-->>API: 变更成功 + API-->>UI: 成功响应 + UI->>UI: Toast"转让负责人成功" + UI->>UI: 关闭弹窗 + UI->>API: POST /proApi/support/user/team/group/list(刷新) + UI-->>User: 列表更新 +``` + +--- + +## S06:删除分组 + +> **业务描述**: 分组负责人删除一个不再需要的分组。 + +### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"删除" | 无 | 仅分组负责人(isOwner)可见("转让负责人"和"删除"菜单项在 isOwner 条件下渲染) | 弹出删除确认弹窗 | + +### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看确认弹窗 | 无 | type='delete',内容提示用户确认删除分组 | 弹窗显示删除类型图标,文案为删除确认提示 | +| 点击"确认删除" | DELETE /proApi/support/user/team/group/delete(参数:groupId) | 无分支 | API 成功后弹窗关闭,分组列表刷新,该分组消失 | +| 点击"取消" | 无 | 无分支 | 弹窗关闭,无变更 | + +### 删除链路详情 + +- **确认弹窗**: 使用 useConfirm hook 生成,type 为 'delete',内容文案使用 i18n key `account_team:confirm_delete_group` +- **批量与单条差异**: 当前仅支持单条删除 +- **级联影响**: 删除分组后,该分组下的成员关系全部解除,关联的权限设置失效 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击操作菜单→删除 + UI-->>User: 弹出删除确认弹窗 + User->>UI: 点击"确认删除" + UI->>API: DELETE /proApi/support/user/team/group/delete + API->>Backend: 删除分组及关联成员关系 + Backend-->>API: 删除成功 + API-->>UI: 成功响应 + UI->>UI: 关闭弹窗 + UI->>API: POST /proApi/support/user/team/group/list(刷新) + UI-->>User: 列表更新,分组已移除 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..eb21e3664cb5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: 分组 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +## 说明 + +本模块未检测到对外共享能力(imported_by 外部模块数: 0,共享能力数: 0)。 + +本模块为团队管理的内部子模块,其组件(MemberTable、GroupInfoModal、GroupEditModal、ChangeOwnerModal)仅通过 dynamic import 方式被同级的团队管理主页(team/index.tsx)消费,无跨父模块的外部引用。 + +跨模块依赖摘要请参见 [08-模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..4f8783cb9d53 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,58 @@ +--- +capability_label: 分组 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +# 分组 — 模块概览 + +## 1. 模块业务概述 + +分组模块是团队管理下的子能力,负责团队成员的分组管理。团队管理员可创建多个分组,将团队成员划分到不同分组中,每个分组拥有独立的负责人(owner)和管理员(admin),以实现团队内部的精细化权限和协作管理。 + +分组模块服务于团队内所有角色:团队管理员通过分组面板统一管理分组的创建、编辑、成员分配和负责人转让;分组成员可在权限范围内查看和修改所属分组的成员配置。分组信息是 FastGPT 权限体系的基础,资源(如应用、知识库)的协作授权可精确到分组级别。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 (hasManagePer) | 分组列表页 | 创建分组、编辑所有分组信息、管理所有分组成员、删除分组、转让分组负责人 | 最高权限,可操作所有分组 | +| 分组负责人 (isOwner) | 分组列表页 | 编辑分组信息、管理分组成员(增删成员、设置管理员)、转让负责人、删除分组 | 对自己负责的分组有完全管理权限 | +| 分组管理员 (admin) | 分组列表页 | 管理普通成员(增删) | 仅可管理 member 角色的成员,不可修改负责人和管理员 | +| 普通成员 (member) | 分组列表页 | 查看分组信息 | 仅可查看,无管理操作权限 | + +**权限模型**: 基于 `Permission` 类的位运算 RBAC 模型。`hasManagePer` 控制团队级管理权限(创建分组按钮可见),`isOwner` 和 `hasManagePer`(分组级)控制分组级操作按钮(编辑、管理成员、转让、删除)。各个操作在组件中通过 `group.permission.hasManagePer` 和 `group.permission.isOwner` 条件渲染。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户(AccountContainer) | 账户管理容器布局,提供统一的账户页面外层框架 | 组件 | +| 团队管理(TeamContext) | 团队上下文(teamSize、setEditTeamData),用于团队信息展示 | Context | +| 用户状态(useUserStore) | 当前用户信息、团队权限、团队计划状态 | Store | +| 团队成员 API(getTeamMembers) | 分页查询团队成员列表,用于成员分配弹窗 | API | +| 通用组件库(@fastgpt/web) | Avatar、MyModal、MyMenu、MyBox、MyTooltip、FillRowTabs 等 | 组件 | + +## 4. 对外共享 + +本模块为团队管理内部子模块,组件通过 `dynamic import` 懒加载方式被团队管理主页消费。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| GroupManage(分组管理主组件) | 团队管理(team/index.tsx) | 页面 | +| GroupInfoModal(分组信息编辑弹窗) | 团队管理-分组(内部使用) | 组件 | +| GroupManageMember(成员管理弹窗) | 团队管理-分组(内部使用) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 团队管理-分组 | /account/team?teamTab=group | pageComponents/account/team/GroupManage/index.tsx | 全部团队成员 | 团队管理页面的 group Tab,含分组列表、创建/编辑分组、成员管理、负责人转让等功能 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..946e5ecbd76c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,44 @@ +--- +capability_label: 审计日志 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员] +router_paths: ["/account/team?teamTab=audit"] +--- + +# 审计日志 — 业务流程索引 + +## 查看审计日志 + +- **业务描述**: 团队管理员查看团队内所有成员的操作行为记录,支持按成员和事件类型筛选,用于安全审计和问题追溯。 +- **触发入口**: 团队管理页面 → 点击"审计日志"标签页 +- **路由路径**: `/account/team?teamTab=audit` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/Audit/index.tsx` +- **涉及角色**: 团队管理员(hasManagePer) +- **前置条件**: 管理员已登录并加入团队;当前团队套餐支持审计日志存储功能(auditLogStoreDuration 有值) + +## 筛选审计日志 + +- **业务描述**: 在审计日志列表上通过下拉多选筛选器按成员或事件类型筛选日志记录,筛选条件变化时自动刷新列表。 +- **触发入口**: 审计日志页面 → 点击成员筛选器或事件类型筛选器 +- **路由路径**: `/account/team?teamTab=audit`(不改变路由,本地状态筛选) +- **页面组件位置**: `projects/app/src/pageComponents/account/team/Audit/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 已进入审计日志页面;成员列表已加载;事件类型列表可用 + +## 场景关系 + +- **查看审计日志** → **筛选审计日志**: 筛选是日志查看过程中的交互操作,不是独立场景,依附于查看场景。 +- **筛选审计日志** → **查看审计日志**: 筛选结果即时更新到列表中。 + +> 各场景独立执行,无严格先后关系。筛选操作是日志查看场景的增强交互。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看审计日志 | 团队管理→审计日志标签页 | /account/team?teamTab=audit | 团队管理员 | +| S02 | 按成员筛选日志 | 审计日志页→成员筛选器 | /account/team?teamTab=audit | 团队管理员 | +| S03 | 按事件类型筛选日志 | 审计日志页→事件类型筛选器 | /account/team?teamTab=audit | 团队管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..91057216a720 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,160 @@ +--- +capability_label: 审计日志 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员] +router_paths: ["/account/team?teamTab=audit"] +--- + +# 审计日志 — 业务流程详解 + +## 页面总览 + +审计日志页面为团队管理员提供团队内所有成员操作行为的集中查阅入口。页面分为筛选区(顶部)和数据表格区(主体),管理员通过筛选器缩小日志范围后,在表格中以无限滚动方式浏览操作记录。 + +--- + +### S01:查看审计日志 + +> 管理员进入审计日志标签页,查看团队成员的历史操作记录。 + +#### 步骤 1:进入审计日志页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在团队管理页面点击"审计日志"标签页 | 无 | 套餐不含 `auditLogStoreDuration` → Toast 提示"无权限",阻止切换 | 标签页高亮切换为"审计日志";筛选区和空表格框架渲染 | + +#### 步骤 2:初始加载成员列表和日志数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载(无需用户操作) | `POST /proApi/support/user/team/list`(获取团队成员);`POST /proApi/support/user/audit/list`(获取日志,pageSize=30) | 并行请求 | 表格区域显示加载遮罩(MyBox isLoading);成员筛选器渲染为"全部成员"选中状态;事件类型筛选器渲染为"全部类型"选中状态 | +| 等待数据返回 | — | 成员列表返回 → 成员筛选器下拉选项生成(含头像+名称);日志列表返回 → 表格渲染前30条记录 | 加载遮罩消失;表格显示日志行:操作用户(UserBox头像+名称)、操作时间(YMDHMS格式)、操作类型(i18n标签)、操作详情(i18n模板+元数据插值) | + +#### 步骤 3:滚动加载更多日志 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动表格至底部 | `POST /proApi/support/user/audit/list`(offset=N, pageSize=30) | 已有更多数据 → 继续加载;已无更多数据 → 停止加载 | 表格底部追加新加载的30条记录;无更多数据时不再触发请求 | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/user/audit/list | pageSize=30, 无筛选参数 | 按时间戳倒序排列 | 表格最近30条日志 | +| 滚动加载 | POST /proApi/support/user/audit/list | offset=N, pageSize=30 | 追加到现有列表末尾 | 表格追加30条 | +| 筛选变更 | POST /proApi/support/user/audit/list | tmbIds=[...], events=[...] | 清空旧数据,重新加载第1页 | 表格刷新为筛选后数据 | + +- 分页参数:默认每页30条,通过 `useScrollPagination` 实现无限滚动 +- 排序规则:按时间戳倒序(服务端排序) +- 筛选条件:成员筛选器(tmbIds)、事件类型筛选器(events),全选时传空对象 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 审计日志页面 + participant API as Pro API + + User->>UI: 点击审计日志标签页 + par 并行请求 + UI->>API: POST /proApi/support/user/team/list + API-->>UI: 团队成员列表 + UI->>API: POST /proApi/support/user/audit/list (pageSize=30) + API-->>UI: 最近30条审计日志 + end + UI-->>User: 渲染成员筛选器+日志表格 + + User->>UI: 向下滚动到底部 + UI->>API: POST /proApi/support/user/audit/list (offset=30) + API-->>UI: 下30条日志 + UI-->>User: 追加日志到表格末尾 +``` + +--- + +### S02:按成员筛选日志 + +> 管理员选择特定成员,筛选出该成员的操作日志。 + +#### 步骤 1:打开成员筛选器 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击成员筛选下拉框 | 无 | 成员列表已加载 → 直接展示选项 | 下拉面板展开,显示成员列表(头像+名称);当前选中项高亮;面板支持滚动加载更多成员 | + +#### 步骤 2:选择/取消成员 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个成员选项 | 无 | — | 该成员变为选中状态;"全选"自动取消 | +| 点击"全选"选项 | 无 | — | 所有成员选中,筛选参数中 tmbIds 置空 | + +#### 步骤 3:筛选生效 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 关闭下拉面板(筛选器值变更触发 useEffect) | `POST /proApi/support/user/audit/list`(tmbIds=[选中成员ID列表], events=[当前事件筛选]) | 非全选 → 传 tmbIds 参数;全选 → 不传 tmbIds | 加载遮罩显示;表格数据重新加载为筛选后结果 | + +### 筛选器交互详情 + +- **多选模式**: 使用 `useMultipleSelect` Hook 管理选中状态,支持全选/取消全选 +- **成员选项**: 每项显示成员头像(Avatar)和名称,由 `getTeamMembers` API 获取 +- **状态联动**: 成员筛选器变更时,通过 `useEffect` 监听 `selectedTmbIds` 和 `isSelectAllTmb` 自动更新 `searchParams`,触发日志重新加载 + +--- + +### S03:按事件类型筛选日志 + +> 管理员选择特定事件类型,筛选出该类操作日志。 + +#### 步骤 1:打开事件类型筛选器 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击事件类型筛选下拉框 | 无 | 事件类型列表已生成 → 直接展示 | 下拉面板展开,显示事件类型选项列表 | + +#### 步骤 2:选择/取消事件类型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个事件类型选项 | 无 | — | 该类型变为选中状态;"全选"自动取消 | +| 点击"全选"选项 | 无 | — | 所有类型选中,筛选参数中 events 置空 | + +#### 步骤 3:筛选生效 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 关闭下拉面板(筛选器值变更触发 useEffect) | `POST /proApi/support/user/audit/list`(events=[选中事件类型列表], tmbIds=[当前成员筛选]) | 非全选 → 传 events 参数;全选 → 不传 events | 加载遮罩显示;表格数据重新加载为筛选后结果 | + +### 事件类型动态过滤 + +事件类型列表根据系统配置动态生成: +- 系统配置 `feConfigs.show_evaluation === false` 时,过滤掉包含 "EVALUATION" 的事件类型 +- 事件类型选项标签通过 `auditLogMap[event].typeLabel` i18n 映射获取 + +### 筛选器交互详情 + +- **多选模式**: 使用 `useMultipleSelect` Hook 管理选中状态,支持全选/取消全选 +- **事件类型选项**: 从 `AuditEventEnum` 枚举值生成,经过系统配置过滤 +- **状态联动**: 事件类型筛选器变更时,通过 `useEffect` 监听 `selectedEvents` 和 `isSelectAllEvent` 自动更新 `searchParams`,触发日志重新加载 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 审计日志页面 + participant API as Pro API + + User->>UI: 打开成员/事件筛选器 + UI-->>User: 展示筛选选项 + User->>UI: 选择筛选项 + UI->>UI: 更新 searchParams + UI->>API: POST /proApi/support/user/audit/list (筛选参数) + API-->>UI: 筛选后日志列表 + UI-->>User: 表格数据刷新 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..9d475f49d53e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: 审计日志 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员] +router_paths: ["/account/team?teamTab=audit"] +--- + +# 审计日志 — 模块概览 + +## 1. 模块业务概述 + +审计日志模块为团队管理员提供全团队操作行为的集中查看与审计能力。系统自动记录团队成员在应用管理、知识库管理、权限变更、团队组织调整等关键业务操作中产生的日志,管理员可通过成员筛选、事件类型筛选和无限滚动分页快速定位和追溯历史操作记录。 + +本模块位于团队管理页面的审计日志标签页,仅对拥有团队管理权限的角色可见。它是团队安全合规和问题追溯的核心工具,帮助管理员监控成员行为、排查误操作和审计权限变更。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员(hasManagePer) | 审计日志标签页 | 查看所有成员操作日志、按成员筛选、按事件类型筛选 | 仅团队管理员可访问此页面 | +| 普通成员 | 无 | 无 | 不显示审计日志标签页 | + +**权限模型**: 基于 `userInfo.team.permission.hasManagePer` 的团队管理权限判断。标签页可见性在父组件 `Team` 中通过条件渲染控制,同时还需要当前套餐支持审计日志存储时长(`auditLogStoreDuration`)才允许切换进入。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 团队管理(父模块) | 团队信息上下文、标签页路由切换 | 组件/路由 | +| 账户系统 | 团队套餐状态(auditLogStoreDuration)、系统配置(show_evaluation) | Store | +| 共享组件库 | MultipleSelect、UserBox、MyBox、Avatar | 组件 | +| 共享工具 | useScrollPagination 无限滚动分页、formatTime2YMDHMS 时间格式化 | Hook/工具函数 | +| 审计事件系统(全局) | AuditEventEnum 事件枚举、auditLogMap i18n映射 | 常量 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 审计事件枚举(AuditEventEnum) | 应用管理、知识库管理、团队管理等全局模块 | 常量 | +| 审计日志 i18n 映射(auditLogMap) | 全局类型定义引用 | 常量 | +| 审计日志元数据处理器 | 无外部消费(内部模块) | 工具函数 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 审计日志 | /account/team?teamTab=audit | projects/app/src/pageComponents/account/team/Audit/index.tsx | 团队管理员 | 无 Tab,单一列表页面 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..76efeeecf404 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,147 @@ +--- +capability_label: 成员 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — 业务流程索引 + +## 查看成员列表 + +- **业务描述**: 进入团队管理页面时默认展示成员 Tab,以表格形式查看团队内全部成员的基本信息(名称、联系方式、组织架构、加入/更新时间),支持滚动分页加载更多数据。 +- **触发入口**: 侧边栏 → 账户 → 团队管理(默认展示成员 Tab);或团队管理页切换至"成员"Tab +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/MemberTable.tsx` +- **涉及角色**: 全部角色 +- **前置条件**: 用户已加入团队(userInfo.team 非空) + +## 搜索成员 + +- **业务描述**: 在搜索框中输入成员名称关键词,对成员列表进行实时过滤搜索。 +- **触发入口**: 成员列表顶部搜索输入框 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/MemberTable.tsx` +- **涉及角色**: 全部角色 +- **前置条件**: 成员列表已加载 + +## 按状态筛选成员 + +- **业务描述**: 通过状态下拉框筛选不同状态的成员(全部/活跃/已离开/已禁用),切换筛选项后列表自动刷新。 +- **触发入口**: 成员列表顶部状态下拉选择器 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/MemberTable.tsx` +- **涉及角色**: 全部角色 +- **前置条件**: 成员列表已加载;"已禁用"筛选项仅在同步模式(isSyncMode)下显示 + +## 邀请成员 + +- **业务描述**: 管理员或所有者创建邀请链接,通过复制链接邀请新成员加入团队。弹窗中可管理已有的邀请链接(复制/禁用),也可创建新的邀请链接并设置有效期和使用次数限制。 +- **触发入口**: 成员列表顶部"邀请成员"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `InviteModal.tsx`(动态导入) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 非同步模式(!isSyncMode)、非企业微信团队(!isWecomTeam) + +## 编辑成员名称 + +- **业务描述**: 管理员可修改成员的显示名称,点击编辑图标弹出编辑弹窗,输入新名称后提交。 +- **触发入口**: 成员列表每行的编辑图标按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联 useEditTitle 弹窗) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 操作者具有管理权限(hasManagePer);目标成员非所有者且非本人 + +## 移除/禁用成员 + +- **业务描述**: 管理员移除团队中的活跃成员,或禁用同步模式下的成员。点击删除图标后弹出确认弹窗,展示不同的提示文案(同步模式为禁用提示,非同步模式为移除提示),确认后执行。 +- **触发入口**: 成员列表每行的删除图标按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联 PopoverConfirm) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 操作者具有管理权限(hasManagePer);目标成员非所有者且非本人;目标成员状态为活跃 + +## 恢复成员 + +- **业务描述**: 将已离开或已禁用的成员恢复到活跃状态。点击恢复图标后弹出确认弹窗,确认后执行恢复操作。 +- **触发入口**: 成员列表每行的恢复图标按钮(仅非活跃成员显示) +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联 PopoverConfirm) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 操作者具有管理权限(hasManagePer);目标成员状态为非活跃(leave/forbidden) + +## 转让团队所有权 + +- **业务描述**: 团队所有者将所有权转让给团队中的另一位活跃成员。点击按钮后弹出成员选择弹窗,搜索并选择目标成员,确认后执行转让。 +- **触发入口**: 成员列表顶部"转让团队所有权"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `TransferOwnershipModal.tsx`(动态导入) +- **涉及角色**: 团队所有者 +- **前置条件**: 非同步模式(!isSyncMode)、企业微信团队(isWecomTeam) + +## 同步成员 + +- **业务描述**: 在同步模式下,管理员手动触发成员同步,从外部系统拉取最新成员数据。 +- **触发入口**: 成员列表顶部"立即同步"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联按钮) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 同步模式(isSyncMode) + +## 导出成员 + +- **业务描述**: 在同步模式下,团队所有者可将成员列表导出为 CSV 文件下载。 +- **触发入口**: 成员列表顶部"导出成员"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联按钮) +- **涉及角色**: 团队所有者 +- **前置条件**: 同步模式(isSyncMode) + +## 同步标签 + +- **业务描述**: 管理员打开团队标签同步弹窗,进行标签的异步加载和同步。 +- **触发入口**: 成员列表顶部"标签同步"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `TeamTagModal`(动态导入自 @/components/support/user/team/TeamTagModal) +- **涉及角色**: 团队管理员、团队所有者 +- **前置条件**: 系统配置启用团队聊天(show_team_chat) + +## 退出团队 + +- **业务描述**: 非所有者的普通成员主动退出当前团队。点击"退出团队"按钮后弹出确认弹窗,确认后退出并自动切换到第一个可用团队。 +- **触发入口**: 成员列表顶部"退出团队"按钮 +- **路由路径**: `/account/team?teamTab=member` +- **页面组件位置**: `MemberTable.tsx`(内联 PopoverConfirm) +- **涉及角色**: 普通成员(非所有者) +- **前置条件**: 非同步模式(!isSyncMode)、非企业微信团队(!isWecomTeam)、非团队所有者 + +## 场景关系 + +- **查看成员列表** → **搜索成员 / 按状态筛选成员**: 搜索和筛选都是在列表已加载的基础上执行的过滤操作。 +- **邀请成员** → **查看成员列表**: 邀请成功后成员列表自动刷新。 +- **移除/恢复/编辑成员** → **查看成员列表**: 每个操作成功后均触发列表刷新。 +- **转让所有权 / 退出团队** → 页面重新加载,切换到新团队或重定向。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看成员列表 | 账户→团队管理(默认Tab) | /account/team?teamTab=member | 全部角色 | +| S02 | 搜索成员 | 成员列表→搜索框 | /account/team?teamTab=member | 全部角色 | +| S03 | 按状态筛选成员 | 成员列表→状态选择器 | /account/team?teamTab=member | 全部角色 | +| S04 | 邀请成员 | 成员列表→邀请按钮 | /account/team?teamTab=member | 管理员、所有者 | +| S05 | 编辑成员名称 | 成员列表→编辑图标 | /account/team?teamTab=member | 管理员、所有者 | +| S06 | 移除/禁用成员 | 成员列表→删除图标 | /account/team?teamTab=member | 管理员、所有者 | +| S07 | 恢复成员 | 成员列表→恢复图标 | /account/team?teamTab=member | 管理员、所有者 | +| S08 | 转让团队所有权 | 成员列表→转让按钮 | /account/team?teamTab=member | 所有者 | +| S09 | 同步成员 | 成员列表→同步按钮 | /account/team?teamTab=member | 管理员、所有者 | +| S10 | 导出成员 | 成员列表→导出按钮 | /account/team?teamTab=member | 所有者 | +| S11 | 同步标签 | 成员列表→标签同步按钮 | /account/team?teamTab=member | 管理员、所有者 | +| S12 | 退出团队 | 成员列表→退出按钮 | /account/team?teamTab=member | 普通成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..f92f58647c83 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,521 @@ +--- +capability_label: 成员 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — 业务流程详解 + +## 页面总览 + +成员管理页面以表格为核心展示团队成员数据,顶部工具栏根据用户角色和系统模式(普通模式/同步模式/企业微信模式)动态展示不同的操作按钮。成员列表通过滚动分页加载,支持搜索和状态筛选。 + +## 查看成员列表 + +> 用户进入团队管理页面(默认展示成员 Tab),系统自动加载成员列表数据,以表格形式展示,支持滚动加载更多。 + +### 步骤 1:进入成员列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击侧边栏"账户"→"团队管理";或已在团队管理页切换至"成员"Tab | GET /proApi/support/user/team/member/count(获取成员总数) | — | 页面框架渲染:团队选择器、Tab 栏(成员/组织/分组/权限/审计日志)、成员总数徽标 | + +**前置条件**: 用户已登录并已加入团队(userInfo.team 非空)。路由参数 `teamTab=member` 时渲染 MemberTable 组件。 + +### 步骤 2:加载成员列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面自动加载 | POST /proApi/support/user/team/member/list(pageSize=20, withPermission=true, withOrgs=true, searchKey="", status=undefined) | — | MyBox 显示加载遮罩(isLoading=true)→ 请求返回后表格渲染前 20 条成员数据 | + +### 步骤 3:滚动加载更多 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动列表至底部 | POST /proApi/support/user/team/member/list(pageSize=20, offset=当前已加载数量) | — | 下一页数据追加到表格末尾,无加载遮罩(静默加载) | + +### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/user/team/member/list | pageSize=20, withPermission=true, withOrgs=true | 按创建时间排序 | 表格展示前 20 条成员 | +| 滚动翻页 | POST /proApi/support/user/team/member/list | pageSize=20, offset=N×20 | 追加到现有列表 | 表格追加 20 条 | +| 筛选/搜索触发刷新 | POST /proApi/support/user/team/member/list | 更新 searchKey 或 status 参数 | 清空旧列表,重新加载 | 表格重置到第一页 | + +- **分页参数**: 默认每页 20 条,通过 useScrollPagination 实现滚动自动加载 +- **排序规则**: 默认按创建时间排序 +- **筛选条件**: searchKey(成员名称模糊搜索)、status(active/leave/forbidden/全部) +- **特殊列的渲染逻辑**: + - "用户名称"列:显示头像 + 成员名称,非活跃成员附带灰色标签("已离开"/"已禁用") + - "联系方式"列:无数据时显示 "-" + - "组织"列:通过 OrgTags 组件以标签形式展示成员所属组织路径 + - "加入/更新时间"列:分两行显示创建时间和更新时间,更新时间不存在时显示 "-" + - "操作"列:根据操作者角色和目标成员状态动态展示不同操作按钮 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + participant Backend + + User->>UI: 进入团队管理页面 + UI->>API: POST /proApi/support/user/team/member/list (首次加载) + API->>Backend: 查询成员列表 + Backend-->>API: 返回成员数据 + API-->>UI: 返回前 20 条 + UI-->>User: 渲染成员表格 + 加载遮罩消失 + User->>UI: 滚动到底部 + UI->>API: POST /proApi/support/user/team/member/list (翻页) + API-->>UI: 返回下一页数据 + UI-->>User: 追加渲染新数据 +``` + +--- + +## 搜索成员 + +> 用户在搜索框中输入成员名称关键词,系统通过防抖机制自动触发列表刷新。 + +### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入文字 | POST /proApi/support/user/team/member/list(searchKey 更新为输入值,offset 重置) | 输入变化触发 debounceWait=200ms 防抖 | 列表重新加载(有加载遮罩),展示匹配搜索关键词的成员 | + +- **防抖机制**: 用户停止输入 200ms 后触发请求 +- **搜索范围**: 成员名称字段 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 在搜索框输入关键词 + Note over UI: 200ms 防抖等待 + UI->>API: POST /proApi/support/user/team/member/list (searchKey=关键词) + API-->>UI: 返回匹配结果 + UI-->>User: 渲染过滤后的成员列表 +``` + +--- + +## 按状态筛选成员 + +> 用户通过状态下拉框选择筛选条件,列表立即刷新。 + +### 步骤 1:选择状态 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击状态下拉框,选择一个状态选项 | POST /proApi/support/user/team/member/list(status 更新为选中值) | isSyncMode=true 时额外显示"已禁用"选项 | 列表重新加载(有加载遮罩),展示对应状态的成员 | + +**筛选选项**: +- 全部(status=undefined) +- 活跃(status=active) +- 已离开(status=leave) +- 已禁用(status=forbidden)— 仅同步模式显示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 选择状态筛选值 + UI->>API: POST /proApi/support/user/team/member/list (status=选中值) + API-->>UI: 返回筛选结果 + UI-->>User: 渲染筛选后的成员列表 +``` + +--- + +## 邀请成员 + +> 管理员或所有者通过邀请链接方式邀请新成员。弹窗中展示已有邀请链接列表,支持创建、复制和禁用邀请链接。 + +### 步骤 1:打开邀请弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"邀请成员"按钮 | GET /proApi/support/user/team/invitationLink/list | 按钮仅在非同步模式且非企业微信团队时可见(!isSyncMode && !isWecomTeam && hasManagePer) | 弹窗打开,显示加载状态,加载邀请链接列表 | + +### 步骤 2:查看已有邀请链接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 弹窗自动加载 | —(步骤 1 已触发) | — | 表格展示已有邀请链接:描述、过期时间、使用次数上限、已邀请成员头像组 | + +### 步骤 3:复制邀请链接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某链接的"复制链接"按钮 | —(纯前端操作) | 链接未被禁用且未过期 | 复制包含团队名、系统名、用户名的邀请文案和 URL 到剪贴板,提示复制成功 | + +### 步骤 4:创建新邀请链接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建邀请链接"按钮 | —(打开子弹窗) | — | CreateInvitationModal 子弹窗打开 | +| 填写描述、有效期、使用次数限制,点击确认 | POST /proApi/support/user/team/invitationLink/create | — | 创建成功后自动复制新链接,邀请链接列表刷新 | + +### 步骤 5:禁用邀请链接 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某链接的"禁用"按钮→确认弹窗中点击确认 | PUT /proApi/support/user/team/invitationLink/forbid | — | 按钮变为加载态→该链接标记为"已禁用"(红色标签),不再可复制 | + +### 补充说明 + +- **过期判断**: 链接在过期时间后自动视为禁用状态 +- **已邀请成员**: 鼠标悬停或点击头像组可查看已通过该链接加入的成员列表 +- **底部提示**: 弹窗底部显示"邀请链接将在使用完毕后自动清理"提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as InviteModal + participant API + participant Backend + + User->>UI: 点击"邀请成员" + UI->>API: GET /proApi/support/user/team/invitationLink/list + API-->>UI: 返回邀请链接列表 + UI-->>User: 展示弹窗 + 链接列表 + User->>UI: 点击"创建邀请链接" + Note over UI: 打开 CreateInvitationModal + User->>UI: 填写信息并确认 + UI->>API: POST /proApi/support/user/team/invitationLink/create + API->>Backend: 创建邀请链接 + Backend-->>API: 返回 linkId + API-->>UI: 创建成功 + UI-->>User: 自动复制链接 + 列表刷新 +``` + +--- + +## 编辑成员名称 + +> 管理员修改成员的显示名称。 + +### 步骤 1:点击编辑图标 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击成员行操作列的编辑图标 | —(打开编辑弹窗) | 操作者需有管理权限且目标成员非所有者、非本人 | 编辑弹窗打开,输入框预填当前成员名称 | + +### 步骤 2:提交新名称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称后点击确认 | PUT /proApi/support/user/team/member/updateNameByManager(tmbId, name) | — | 弹窗关闭 → 成员列表刷新,名称更新 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 成员名称 | 文本输入 | ✅ | 当前成员名称 | 不可为空 | 否 | 修改成员的显示名称 | + +### 校验规则 + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称不可为空 | 提交时 | 输入框不可为空 | + +### 前后置条件 + +- **前置条件**: 操作者具有管理权限(hasManagePer);目标成员角色非所有者;目标成员非操作者本人 +- **后置影响**: 成员名称更新后列表自动刷新 +- **失败场景**: API 调用失败时显示错误 Toast 提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 点击编辑图标 + UI-->>User: 打开编辑弹窗(预填当前名称) + User->>UI: 修改名称并确认 + UI->>API: PUT /proApi/support/user/team/member/updateNameByManager + API-->>UI: 更新成功 + UI->>API: POST /proApi/support/user/team/member/list (刷新列表) + API-->>UI: 返回最新列表 + UI-->>User: 列表刷新,名称更新 +``` + +--- + +## 移除/禁用成员 + +> 管理员将成员从团队中移除(普通模式)或禁用(同步模式)。 + +### 步骤 1:点击删除图标 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击成员行的删除图标 | —(弹出确认弹窗) | 操作者需有管理权限且目标成员非所有者、非本人;目标成员状态为活跃(active) | 确认弹窗打开 | + +### 步骤 2:确认操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | DELETE /proApi/support/user/team/member/delete(tmbId) | isSyncMode=true 时弹窗文案为禁用提示("将禁用用户 {username}");isSyncMode=false 时弹窗文案为移除提示("将移除用户 {username}") | 弹窗关闭 → 成员列表刷新,目标成员状态变更 | + +### 删除链路详情 + +- **确认弹窗**: + - 同步模式提示文案:`account_team:forbidden_tip`("将禁用用户 {username}") + - 非同步模式提示文案:`account_team:remove_tip`("将移除用户 {username}") + - 弹窗类型为 delete,确认按钮为红色 +- **级联影响**: 移除/禁用后成员列表自动刷新,目标成员在列表中显示为"已离开"/"已禁用"状态 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 点击删除图标 + UI-->>User: 弹出确认弹窗(含具体用户名) + User->>UI: 确认移除/禁用 + UI->>API: DELETE /proApi/support/user/team/member/delete (tmbId) + API-->>UI: 操作成功 + UI->>API: POST /proApi/support/user/team/member/list (刷新) + API-->>UI: 返回最新列表 + UI-->>User: 列表刷新,目标成员状态变更 +``` + +--- + +## 恢复成员 + +> 管理员将已离开或已禁用的成员恢复为活跃状态。 + +### 步骤 1:点击恢复图标 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击非活跃成员行的恢复图标 | —(弹出确认弹窗) | 操作者有管理权限;目标成员状态为非活跃 | 确认弹窗打开 | + +### 步骤 2:确认恢复 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | POST /proApi/support/user/team/member/restore(tmbId) | — | 弹窗关闭 → 成功提示 → 列表刷新 | + +### 状态转换详情 + +- **状态转换**: 已离开(leave)/ 已禁用(forbidden)→ 恢复操作 → 活跃(active) +- **确认弹窗**: 类型为 info,提示文案 `account_team:restore_tip`("将恢复用户 {username}") +- **后置影响**: 列表刷新后目标成员恢复活跃状态,操作列显示编辑和移除按钮 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 点击恢复图标 + UI-->>User: 弹出确认弹窗 + User->>UI: 确认恢复 + UI->>API: POST /proApi/support/user/team/member/restore (tmbId) + API-->>UI: 恢复成功 + UI->>API: POST /proApi/support/user/team/member/list (刷新) + API-->>UI: 返回最新列表 + UI-->>User: 列表刷新,成员状态恢复为活跃 +``` + +--- + +## 转让团队所有权 + +> 团队所有者将所有权转让给另一位活跃成员,转让后自动切换到其他团队。 + +### 步骤 1:打开转让弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"转让团队所有权"按钮 | —(打开弹窗) | 按钮仅在非同步模式且企业微信团队时可见(!isSyncMode && isWecomTeam && isOwner) | 转让弹窗打开,显示当前团队信息 | + +### 步骤 2:搜索并选择新所有者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入成员名称 | POST /proApi/support/user/team/member/list(searchKey=输入值, status=active) | debounceWait=200ms | 下拉列表展示匹配的活跃成员(过滤掉当前所有者本人) | +| 点击选择目标成员 | — | — | 搜索框回填选中成员名称,显示头像,"确认转让"按钮变为可用 | + +### 步骤 3:确认转让 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认转让"按钮 | PUT /proApi/support/user/team/changeOwner(userId) | — | 按钮变为加载态 → 成功后刷新用户信息 → 自动切换到其他可用团队 → 页面重新加载 | + +### 表单字段清单 + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 搜索成员 | 搜索输入框 | ✅ | 空 | 仅搜索活跃成员 | 否 | 用于查找目标转让对象 | + +### 前后置条件 + +- **前置条件**: 操作者为团队所有者;非同步模式;企业微信团队 +- **后置影响**: 所有权转让后用户权限变更为普通成员;自动切换到其他团队;如无其他团队则停留在当前页 +- **确认弹窗**: 弹窗顶部显示警告"转让后您将失去团队所有者权限" +- **失败场景**: 调用失败时显示 `account_team:transfer_failed` 错误提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as TransferModal + participant API + participant Backend + + User->>UI: 点击"转让团队所有权" + UI-->>User: 打开转让弹窗(显示警告) + User->>UI: 搜索并选择新所有者 + UI->>API: POST /proApi/support/user/team/member/list (搜索活跃成员) + API-->>UI: 返回匹配成员列表 + UI-->>User: 展示候选成员 + User->>UI: 选择成员并确认转让 + UI->>API: PUT /proApi/support/user/team/changeOwner (userId) + API->>Backend: 执行所有权转让 + Backend-->>API: 转让成功 + API-->>UI: 成功 + UI->>API: 切换到其他团队 / 刷新页面 + UI-->>User: 页面重新加载 +``` + +--- + +## 同步成员 + +> 在同步模式下,管理员手动触发从外部系统同步成员数据。 + +### 步骤 1:触发同步 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"立即同步"按钮 | POST /proApi/support/user/api(同步成员接口) | 按钮仅在同步模式且有管理权限时可见(isSyncMode && hasManagePer) | 按钮变为加载态(isSyncing=true)→ 整个表格显示加载遮罩 → 成功后显示"同步成员成功"提示 → 列表刷新 | + +### 状态转换详情 + +- **前置条件**: isSyncMode=true;操作者有管理权限 +- **后置影响**: 成员列表刷新,展示最新的同步结果 +- **失败场景**: 调用失败时显示 `account_team:sync_member_failed` 错误提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + participant Backend + + User->>UI: 点击"立即同步" + UI->>API: POST /proApi/support/user/api (同步成员) + API->>Backend: 触发成员同步 + Backend-->>API: 同步完成 + API-->>UI: 同步成功 + UI->>API: POST /proApi/support/user/team/member/list (刷新) + API-->>UI: 返回最新成员列表 + UI-->>User: 提示"同步成员成功" + 列表刷新 +``` + +--- + +## 导出成员 + +> 在同步模式下,团队所有者将成员列表导出为 CSV 文件。 + +### 步骤 1:触发导出 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"导出成员"按钮 | GET /api/proApi/support/user/team/member/export | 按钮仅在同步模式且为所有者时可见(isSyncMode && isOwner) | 浏览器触发文件下载,文件名为 `{团队名}-{时间戳}.csv` | + +### 前后置条件 + +- **前置条件**: isSyncMode=true;操作者为团队所有者 +- **后置影响**: 下载的 CSV 文件包含当前团队所有成员数据 +- **失败场景**: 下载失败时浏览器默认处理 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 点击"导出成员" + UI->>API: GET /api/proApi/support/user/team/member/export + API-->>UI: 返回 CSV 文件流 + UI-->>User: 浏览器下载 CSV 文件 +``` + +--- + +## 退出团队 + +> 非所有者的普通成员主动退出当前团队,退出后自动切换到其他可用团队。 + +### 步骤 1:点击退出按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"退出团队"按钮 | —(弹出确认弹窗) | 按钮仅在非同步模式、非企业微信团队且非所有者时可见(!isSyncMode && !isWecomTeam && !isOwner) | 确认弹窗打开 | + +### 步骤 2:确认退出 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | DELETE /proApi/support/user/team/member/leave | — | 弹窗关闭 → 自动切换到第一个可用团队 → 页面重新加载 | + +### 删除链路详情 + +- **确认弹窗**: 类型为 delete,提示文案 `account_team:confirm_leave_team` +- **级联影响**: 退出后自动切换到 myTeams[0];如无其他团队则停留在当前页(可能显示空状态) +- **失败场景**: 调用失败时显示 `account_team:user_team_leave_team_failed` 错误提示 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as MemberTable + participant API + + User->>UI: 点击"退出团队" + UI-->>User: 弹出确认弹窗 + User->>UI: 确认退出 + UI->>API: DELETE /proApi/support/user/team/member/leave + API-->>UI: 退出成功 + UI-->>User: 自动切换团队 + 页面重新加载 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..7a8563d2ffe3 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,21 @@ +--- +capability_label: 成员 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — 共享能力清单 + +## 说明 + +本模块未检测到对外共享能力。MemberTable 仅被父级页面 `pages/account/team/index.tsx` 通过动态导入(`next/dynamic`)引用,属于团队管理模块内部消费。InviteModal 和 TransferOwnershipModal 仅被 MemberTable 内部引用,不对外暴露。 + +跨模块依赖摘要请参见 [08-模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..d3ed585ce580 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,59 @@ +--- +capability_label: 成员 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — 模块概览 + +## 1. 模块业务概述 + +成员管理是团队管理下的核心子模块,提供团队成员的全生命周期管理能力。该模块以表格形式展示团队内所有成员的信息,支持成员的搜索、筛选、邀请、编辑、移除和恢复等操作。模块面向团队所有者、管理员和普通成员三类角色,不同角色可见的操作入口和可执行操作有显著差异。 + +在整体产品中,成员管理是团队协作的基础——所有团队功能(应用协作、知识库共享、权限分配)都依赖于成员列表的正确性和完整性。成员管理位于"账户 → 团队管理 → 成员"Tab 下,是团队管理页面的默认展示 Tab。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队所有者(owner) | 成员列表 | 查看、搜索、筛选、邀请、编辑名称、移除/禁用、恢复、导出、同步、标签同步、转让所有权 | 拥有完整管理权限,可见所有操作按钮;是唯一可执行"转让所有权"的角色 | +| 团队管理员(hasManagePer) | 成员列表 | 查看、搜索、筛选、邀请、编辑名称、移除/禁用、恢复、同步、标签同步 | 拥有管理权限,可管理普通成员但不可转让所有权;非所有者时可见退出团队入口 | +| 普通成员 | 成员列表 | 查看、搜索、筛选、退出团队 | 仅可查看成员信息和退出团队,不可执行管理操作 | + +**权限模型**: 基于 `userInfo.team.permission` 对象的前端条件渲染。权限判断逻辑入口位于 `MemberTable.tsx` 组件中,通过以下关键字段控制 UI 差异: +- `permission.hasManagePer` — 是否具备管理权限(控制邀请、编辑、移除、同步等按钮可见性) +- `permission.isOwner` — 是否为团队所有者(控制转让所有权和导出的可见性) + +此外,系统配置 `feConfigs.register_method` 决定是否处于同步模式(`isSyncMode`),影响按钮文案和交互行为。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享基础设施(@fastgpt/web) | Avatar、Tag、MyIcon、MyBox、MySelect、SearchInput、PopoverConfirm、MyIconButton 等通用 UI 组件 | 组件 | +| 共享基础设施(@fastgpt/web/hooks) | useScrollPagination(滚动分页)、useRequest(请求封装)、useToast(消息提示)、useCopyData(复制) | Hook | +| 用户状态管理(@/web/support/user) | useUserStore(用户信息与团队权限) | Store | +| 系统配置(@/web/common/system) | useSystemStore(feConfigs 系统特性开关) | Store | +| 团队管理父模块 | TeamContext(团队列表、团队切换、团队人数) | Context | + +## 4. 对外共享 + +本模块为团队管理内部的叶子节点,组件均为内部使用。MemberTable 仅被父级页面 `team/index.tsx` 通过动态导入引用,InviteModal 和 TransferOwnershipModal 仅被 MemberTable 内部引用,不对外共享。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 成员列表 | /account/team?teamTab=member | projects/app/src/pageComponents/account/team/MemberTable.tsx | 全部角色 | 团队管理默认 Tab,包含成员表格及全部操作入口 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..39ae44296064 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: 权限管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:30:00.000Z" +parent_module: 团队管理 +roles: + - 团队所有者 + - 团队管理员 + - 普通成员 +router_paths: + - "/account/team?teamTab=permission" +--- + +# 权限管理 — 业务流程索引 + +## 查看协作者权限列表 + +- **业务描述**: 以表格形式展示团队内所有协作者(成员、组织、分组)及其对各资源的创建权限,按成员/组织/分组分三段可折叠展示。 +- **触发入口**: 团队管理页面 → 点击「权限管理」Tab +- **路由路径**: /account/team?teamTab=permission +- **页面组件位置**: projects/app/src/pageComponents/account/team/PermissionManage/index.tsx +- **涉及角色**: 团队所有者、团队管理员、普通成员 +- **前置条件**: 用户已加入团队,团队权限信息已加载 + +## 搜索协作者 + +- **业务描述**: 在搜索框中输入关键字,实时过滤协作者列表,只显示匹配的成员、组织或分组。 +- **触发入口**: 权限管理页面 → 顶部搜索框输入 +- **路由路径**: /account/team?teamTab=permission +- **页面组件位置**: projects/app/src/pageComponents/account/team/PermissionManage/index.tsx +- **涉及角色**: 全部角色 +- **前置条件**: 协作者列表已加载 + +## 修改单个协作者权限 + +- **业务描述**: 为某个协作者勾选或取消勾选某项资源创建权限(应用/模型/技能/数据集/API Key/评测/管理),实时生效。 +- **触发入口**: 权限管理页面 → 在目标协作者行中勾选/取消勾选对应列 +- **路由路径**: /account/team?teamTab=permission +- **页面组件位置**: projects/app/src/pageComponents/account/team/PermissionManage/index.tsx +- **涉及角色**: 团队所有者(全部)、团队管理员(非管理员协作者) +- **前置条件**: 当前用户拥有相应的管理权限;部分 Checkbox 对非所有者/非管理员不可操作 + +## 删除协作者 + +- **业务描述**: 从协作者列表中移除某个成员、组织或分组,该协作者将不再拥有团队内的任何权限。 +- **触发入口**: 权限管理页面 → 目标协作者行最右侧 → 点击删除图标 +- **路由路径**: /account/team?teamTab=permission +- **页面组件位置**: projects/app/src/pageComponents/account/team/PermissionManage/index.tsx +- **涉及角色**: 团队所有者(全部,除自己)、团队管理员(非管理员协作者) +- **前置条件**: 当前用户拥有删除权限(hasDeletePer 判断),且不能删除自己 + +## 添加协作者(管理权限入口) + +- **业务描述**: 打开添加协作者的弹窗,为团队添加新的成员/组织/分组并设置初始权限。 +- **触发入口**: 权限管理页面 → 顶部「管理权限」按钮 +- **路由路径**: /account/team?teamTab=permission +- **页面组件位置**: projects/app/src/components/support/permission/MemberManager/MemberModal.tsx(通过 CollaboratorContextProvider 动态加载) +- **涉及角色**: 团队所有者、团队管理员 +- **前置条件**: 当前用户拥有团队管理权限(userInfo.team.permission.hasManagePer) + +## 场景关系 + +- **查看协作者权限列表** → **搜索协作者**: 搜索是对列表的实时过滤,无跳转 +- **查看协作者权限列表** → **修改单个协作者权限**: 在列表内直接操作,无跳转 +- **查看协作者权限列表** → **删除协作者**: 在列表内直接操作,无页面跳转,删除后列表自动刷新 +- **查看协作者权限列表** → **添加协作者**: 点击按钮打开弹窗,完成后列表自动刷新 + +所有场景均在同一个页面内完成,无页面跳转。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看协作者权限列表 | 团队管理→权限管理Tab | /account/team?teamTab=permission | 全部 | +| S02 | 搜索协作者 | 权限管理页面→搜索框 | /account/team?teamTab=permission | 全部 | +| S03 | 修改单个协作者权限 | 权限管理页面→勾选/取消Checkbox | /account/team?teamTab=permission | 所有者、管理员 | +| S04 | 删除协作者 | 权限管理页面→删除图标 | /account/team?teamTab=permission | 所有者、管理员 | +| S05 | 添加协作者 | 权限管理页面→管理权限按钮 | /account/team?teamTab=permission | 所有者、管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..38405c9d4e56 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,268 @@ +--- +capability_label: 权限管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:30:00.000Z" +parent_module: 团队管理 +roles: + - 团队所有者 + - 团队管理员 + - 普通成员 +router_paths: + - "/account/team?teamTab=permission" +--- + +# 权限管理 — 业务流程详解 + +## 页面总览 + +权限管理页面是团队管理下的一个 Tab 页面,以表格形式集中展示和管理团队协作者的资源创建权限。页面顶部提供搜索框和「管理权限」按钮(仅管理员可见),主体为按成员/组织/分组三组折叠展开的权限表格,每一行对应一个协作者,每一列对应一项权限的勾选框。 + +页面通过 `CollaboratorContextProvider` 上下文管理协作者列表数据,权限变更操作通过 Provider 暴露的回调方法执行,操作成功后自动刷新列表。 + +--- + +### S01: 查看协作者权限列表 + +> 业务描述:用户从团队管理页面切换到权限管理 Tab,页面加载并展示所有协作者及其权限状态的表格视图。 + +#### 步骤 1:进入权限管理页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在团队管理页面点击「权限管理」Tab | GET /proApi/support/user/team/collaborator/list | 用户必须已加入团队(userInfo.team 不为空),否则页面不渲染 | URL 参数变更为 teamTab=permission;页面切换至权限管理视图;表格区域显示加载状态(MyBox 的 isLoading) | + +#### 步骤 2:协作者数据加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | GET /proApi/support/user/team/collaborator/list(由 CollaboratorContextProvider 自动发起) | feConfigs.isPlus 为 true 时发起请求;为 false 时返回空列表 | 加载完成后:协作者数据按 tmbId/groupId/orgId 分类展示为成员、组织、分组三段;每段默认展开(isExpandMember/isExpandGroup/isExpandOrg 均为 true);每行显示协作者头像/名称和对应权限勾选状态 | + +#### 步骤 3:查看权限详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看各列权限勾选状态 | 无(纯前端渲染) | 每列的 Checkbox 状态由 clbPer.checkRole(role) 结果决定 | 已授权的权限列显示勾选状态;禁用状态的 Checkbox 呈灰色不可点击(由 isDisabled 属性控制) | +| 鼠标悬停在列标题的问号图标上 | 无 | — | 显示该权限列的说明 Tooltip(如"允许成员创建应用") | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /proApi/support/user/team/collaborator/list | 无参数 | 返回数据按 id 字段分类:有 tmbId 的归为成员列表、有 groupId 的归为分组列表、有 orgId 的归为组织列表 | 三段可折叠表格,每段分别展示成员/组织/分组及其权限 | +| 刷新 | GET /proApi/support/user/team/collaborator/list | 同首次 | 同首次 | 表格数据更新,折叠状态保持不变 | + +--- + +### S02: 搜索协作者 + +> 业务描述:用户通过搜索框输入关键字,实时过滤协作者列表。 + +#### 步骤 1:输入搜索关键字 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框内输入文字 | GetSearchUserGroupOrg(searchKey)(500ms 节流 + 200ms 防抖) | searchKey 为空时不过滤 | 搜索框显示输入内容 | + +#### 步骤 2:搜索结果过滤 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待搜索结果返回 | GetSearchUserGroupOrg 返回后,前端按结果过滤 | searchKey 不为空时:成员列表仅保留匹配搜索结果的成员;组织列表仅保留匹配搜索结果的组织;分组列表仅保留匹配搜索结果的分组。搜索结果按 members/groups/orgs 三类分别匹配 | 列表实时收缩,只显示匹配的协作者;不符合的协作者行被隐藏;空的分类段(无匹配项)仍显示折叠标题但内容为空 | + +**搜索特点**: +- 搜索是纯前端过滤,不重新请求协作者列表 API +- 搜索接口 GetSearchUserGroupOrg 返回全局用户/组织/分组搜索结果,权限页面将其与本地 collaboratorList 取交集过滤 +- 搜索条件影响所有三个分类段(成员/组织/分组) + +--- + +### S03: 修改单个协作者权限 + +> 业务描述:管理员勾选或取消勾选某个协作者的某项权限 Checkbox,实时生效。 + +#### 步骤 1:点击权限 Checkbox + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选某项权限的 Checkbox | PUT /proApi/support/user/team/collaborator/updateOne({tmbId/groupId/orgId, permission: 新的权限值}) | Checkbox 必须处于非禁用状态(isDisabled 为 false);当前用户为团队所有者时所有 Checkbox 均可用;当前用户为管理员时:对于有管理权限(hasManagePer)的协作者,其 Checkbox 被禁用(除非当前用户是团队所有者);管理权限列的 Checkbox 仅团队所有者可操作 | 操作期间页面显示全局加载状态(addLoading 为 true),表格高度增加时 MyBox 显示加载遮罩 | + +#### 步骤 2:权限计算与提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需额外操作(自动执行) | —(前端的 TeamPermission.addRole/removeRole 计算新的权限位掩码值,然后调用 API) | 勾选时 addRole(per) 将对应权限位加入角色值;取消勾选时 removeRole(per) 将对应权限位从角色值中移除 | 无额外页面变化(计算过程不可见) | + +#### 步骤 3:列表刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | GET /proApi/support/user/team/collaborator/list(onSuccess 回调中自动 refetchCollaborators) | 更新成功时触发 | 加载遮罩消失;该协作者行的 Checkbox 状态更新为新的权限值 | + +**禁用逻辑详情**: + +| 协作者类型 | Checkbox 禁用条件 | 说明 | +|-----------|-------------------|------| +| 成员(member) | member.permission.hasManagePer && !userInfo.permission.isOwner | 管理员成员不能被非所有者修改权限 | +| 组织(org) | org.permission.isOwner \|\| !userManage | 组织所有者不能被修改;无管理权限者不能修改 | +| 分组(group) | group.permission.isOwner \|\| !userManage | 分组所有者不能被修改;无管理权限者不能修改 | +| 管理权限列 | !userInfo.permission.isOwner | 仅团队所有者可授予/撤销管理权限 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 权限不足 | 点击 Checkbox 时(前端禁用拦截) | Checkbox 置灰不可点击,无文字提示 | +| API 调用失败 | 提交后(后端校验) | 由 useRequest 的错误处理统一展示,Checkbox 状态回滚(列表刷新恢复原值) | + +--- + +### S04: 删除协作者 + +> 业务描述:管理员从权限列表中移除某个协作者。 + +#### 步骤 1:点击删除图标 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击目标协作者行最右侧的删除图标 | DELETE /proApi/support/user/team/collaborator/delete({tmbId} 或 {groupId} 或 {orgId}) | 删除图标仅在 hasDeletePer(member.permission) 为 true 且当前用户不是该成员自己(userInfo.team.tmbId !== member.tmbId)时显示;hasDeletePer 判断:团队所有者可删除任何人,管理员只能删除无管理权限的协作者 | 操作期间页面显示全局加载状态(deleteLoading 为 true) | + +#### 步骤 2:列表刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作(自动触发) | GET /proApi/support/user/team/collaborator/list(onSuccess 回调中自动 refetchCollaborators) | 删除成功时触发 | 该协作者从列表中消失;所属分类段的计数更新 | + +**删除链路详情**: +- **引用检查**:无前置引用检查(删除直接执行) +- **确认弹窗**:无二次确认弹窗(直接删除) +- **级联影响**:删除后该协作者失去团队内所有权限,需重新添加才能恢复 + +--- + +### S05: 添加协作者 + +> 业务描述:管理员通过弹窗为团队添加新的协作者(成员/组织/分组)并设置初始权限。 + +#### 步骤 1:点击管理权限按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部「管理权限」按钮 | 无 | 按钮仅在 userInfo.team.permission.hasManagePer 为 true 时可见 | 打开添加协作者弹窗(MemberModal,通过 CollaboratorContextProvider 动态加载) | + +#### 步骤 2:在弹窗中选择协作者并设置权限 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在弹窗内选择要添加的成员/组织/分组,设置初始角色 | POST /proApi/support/user/team/collaborator/update(批量更新) | 默认角色为 ReadRoleVal(只读);角色列表为 TeamRoleList | 弹窗内展示可选择的协作者列表和角色下拉选项 | + +#### 步骤 3:提交并刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗确认按钮 | POST /proApi/support/user/team/collaborator/update(提交选中的协作者和角色) | 提交成功后触发 | 弹窗关闭;协作者列表自动刷新(refetchCollaborators + refetchResource);新添加的协作者出现在列表中 | + +**前后置条件**: +- **前置条件**:当前用户拥有团队管理权限(hasManagePer) +- **后置影响**:新协作者加入团队,拥有初始角色对应的权限;操作记录写入审计日志(如已开启) +- **失败场景**:API 调用失败时弹窗不关闭,错误信息由统一错误处理展示 + +--- + +## Mermaid 附录 + +### S01-S02: 查看与搜索 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 权限管理页面 + participant API as 后端 API + participant Backend as 后端服务 + + User->>UI: 点击「权限管理」Tab + UI->>UI: 显示加载遮罩 + UI->>API: GET /proApi/support/user/team/collaborator/list + API->>Backend: 查询团队协作者列表 + Backend-->>API: 协作者数据 + API-->>UI: 协作者列表(成员/组织/分组) + UI->>UI: 按 tmbId/groupId/orgId 分类;渲染三段表格 + UI-->>User: 展示权限表格 + + User->>UI: 在搜索框输入关键字 + UI->>API: GetSearchUserGroupOrg(keyword) + Note over UI,API: 500ms节流 + 200ms防抖 + API-->>UI: 搜索结果 + UI->>UI: 前端过滤:collaboratorList ∩ 搜索结果 + UI-->>User: 列表收缩,仅显示匹配协作者 +``` + +### S03: 修改权限 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 权限管理页面 + participant API as 后端 API + participant Backend as 后端服务 + + User->>UI: 勾选/取消某项权限 Checkbox + UI->>UI: 显示加载遮罩(addLoading=true) + UI->>UI: TeamPermission.addRole/removeRole 计算新权限值 + UI->>API: PUT /proApi/support/user/team/collaborator/updateOne + Note over UI,API: 参数: tmbId/groupId/orgId + permission + API->>Backend: 更新协作者权限 + Backend-->>API: 更新成功 + API-->>UI: 成功响应 + UI->>API: GET /proApi/support/user/team/collaborator/list + API-->>UI: 刷新后的协作者列表 + UI->>UI: 隐藏加载遮罩;更新 Checkbox 状态 + UI-->>User: 权限变更生效 +``` + +### S04: 删除协作者 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 权限管理页面 + participant API as 后端 API + participant Backend as 后端服务 + + User->>UI: 点击删除图标 + UI->>UI: 显示加载遮罩(deleteLoading=true) + UI->>API: DELETE /proApi/support/user/team/collaborator/delete + Note over UI,API: 参数: tmbId/groupId/orgId + API->>Backend: 删除协作者 + Backend-->>API: 删除成功 + API-->>UI: 成功响应 + UI->>API: GET /proApi/support/user/team/collaborator/list + API-->>UI: 刷新后的协作者列表 + UI->>UI: 隐藏加载遮罩;被删除协作者从列表消失 + UI-->>User: 删除完成 +``` + +### S05: 添加协作者 + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 权限管理页面 + participant Modal as MemberModal弹窗 + participant API as 后端 API + participant Backend as 后端服务 + + User->>UI: 点击「管理权限」按钮 + UI->>Modal: 打开添加协作者弹窗 + Modal-->>User: 展示协作者选择和角色设置界面 + User->>Modal: 选择协作者,设置角色,点击确认 + Modal->>API: POST /proApi/support/user/team/collaborator/update + Note over Modal,API: 参数: 协作者列表 + 权限值 + API->>Backend: 批量更新协作者权限 + Backend-->>API: 更新成功 + API-->>Modal: 成功响应 + Modal->>UI: 关闭弹窗,触发刷新 + UI->>API: GET /proApi/support/user/team/collaborator/list + API-->>UI: 刷新后的协作者列表 + UI-->>User: 新协作者出现在列表中 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..4e47dd1b9728 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,62 @@ +--- +capability_label: 权限管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:30:00.000Z" +parent_module: 团队管理 +roles: + - 团队所有者 + - 团队管理员 + - 普通成员 +router_paths: + - "/account/team?teamTab=permission" +--- + +# 权限管理 — 模块概览 + +## 1. 模块业务概述 + +权限管理是团队管理下的子模块,负责集中管控团队内成员、组织(Org)和分组(Group)对各类资源的创建权限。团队所有者和管理员可以在此模块中为不同协作者分配应用、模型、技能、数据集、API Key 以及评测等资源的创建权限,同时也可以赋予其他成员管理权限。 + +本模块的核心价值在于提供统一的权限视图——以表格形式展示所有协作者(成员/组织/分组),每行对应一个协作者,每列对应一项资源创建权限或管理权限,通过勾选框实现直观的权限授予与回收。同时支持按关键字搜索协作者,快速定位目标人员或组织。 + +本模块无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队所有者 | 权限管理表格(完整) | 查看所有协作者权限;修改任意协作者的任意权限项;删除任意协作者(除自己) | 拥有最高权限,不受任何禁用限制,可以管理所有协作者包括其他管理员 | +| 团队管理员 | 权限管理表格(完整) | 查看所有协作者权限;修改非管理员协作者的权限项;删除非管理员协作者 | 无法修改团队所有者和其他管理员的权限,无法删除有管理权限的协作者 | +| 普通成员 | 权限管理表格(只读) | 仅可查看协作者列表和权限状态 | 无法修改任何权限,无"管理权限"按钮,无删除操作入口 | + +**权限模型**: 基于位掩码(bitmask)的 RBAC 扩展模型。每个协作者拥有一个权限值(PermissionValueType),通过位运算判断是否拥有某项权限。权限分为基础角色(读/写/管理)和资源创建权限(应用/模型/技能/数据集/API Key/评测),其中每个资源创建权限值包含对应的读和写权限。 + +权限判断入口在 `PermissionManage` 组件中,通过 `userInfo.permission.isOwner` 判断是否为团队所有者,通过 `hasManagePer` 判断是否为管理员。具体权限检查通过 `TeamPermission` 类的 `checkRole()` 方法进行位运算验证。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 权限协作器(CollaboratorContext) | 协作者列表管理、权限更新、删除协作者 | Context | +| 用户 Store(useUserStore) | 获取当前用户信息和团队权限状态 | Store | +| 系统配置(useSystemStore) | 获取功能开关(如评测模块是否启用) | Store | +| 全局权限控制器(TeamPermission) | 权限位运算判断、角色解析 | 工具类 | +| 搜索服务(GetSearchUserGroupOrg) | 按关键字搜索用户、组织、分组 | API | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 权限管理渲染组件(Render) | 团队管理页面(/account/team) | 页面组件 | +| 权限协作器上下文(CollaboratorContextProvider) | 各资源模块的协作者管理 | Context Provider | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 团队权限管理 | /account/team?teamTab=permission | projects/app/src/pageComponents/account/team/PermissionManage/index.tsx | 全部(查看)/ 所有者及管理员(编辑) | 通过团队管理页面的 Tab 切换进入,无子 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..b550c62c9d1a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: 团队管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 账户 +roles: [团队所有者, 团队管理员, 普通成员] +router_paths: [/account/team] +--- + +# 团队管理 — 模块概览 + +## 模块业务概述 + +团队管理是 FastGPT 账户体系中负责团队级成员与权限管理的核心模块。它为团队所有者和管理员提供成员生命周期管理、组织架构维护、权限分配和操作审计等能力,是多成员协作使用 FastGPT 的基础设施。 + +本模块面向三类角色:**团队所有者**拥有完整管理权限,**团队管理员**可执行日常成员与权限管理,**普通成员**可查看团队信息和执行退出操作。模块内部按功能拆分为成员、组织、分组、权限管理和审计日志五个子能力 Tab。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队所有者 | 全部 Tab(含审计日志) | 编辑团队信息、邀请/移除成员、管理组织/分组、分配权限、查看审计日志、转让所有权、退出团队 | 拥有完整管理权限,审计日志对其可见 | +| 团队管理员 | 成员/组织/分组/权限管理 | 邀请/移除成员、管理组织/分组、分配权限(不可修改所有者权限) | 可管理除审计日志外的所有 Tab,不可转让所有权 | +| 普通成员 | 成员(查看) | 查看成员列表、退出团队 | 仅能查看成员列表和主动退出,无管理操作权限 | + +**权限模型**: 基于 `userInfo.team.permission` 的位权限控制。`hasManagePer` 控制管理类 Tab(审计日志、权限管理)的可见性和操作可用性;`isOwner` 控制所有者专属操作(编辑团队信息、转让所有权)。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户 | AccountContainer 侧边栏布局容器、TeamSelector 团队切换组件 | 组件 | +| 共享基础设施 | FillRowTabs 标签页组件、useRequest/useScrollPagination 通用 Hook | 组件/Hook | +| 用户/权限系统 | useUserStore(用户信息/团队状态)、useSystemStore(系统配置) | Store | + +## 对外共享 + +本模块为纯管理页面,不对外提供可被其他模块引用的组件或能力。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 团队管理 | /account/team | projects/app/src/pages/account/team/index.tsx | 所有团队成员 | 含 5 个 Tab(详见 [09-业务流程索引](./业务流程索引.md)) | + +## 子能力索引 + +本模块包含 5 个 Tab 子能力,各自管理团队的不同维度: + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 成员管理 | 成员 | [模块概览](../成员/模块概览.md) | +| 组织管理 | 组织 | [模块概览](../组织/模块概览.md) | +| 分组管理 | 分组 | [模块概览](../分组/模块概览.md) | +| 权限管理 | 权限管理 | [模块概览](../权限管理/模块概览.md) | +| 审计日志 | 审计日志 | [模块概览](../审计日志/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..146d5d327a0f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,125 @@ +--- +capability_label: 组织 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — 业务流程索引 + +## 业务场景列表 + +### 查看组织架构 + +- **业务描述**: 用户进入组织管理 Tab 后,首先看到当前层级下的子组织和直属成员列表,通过左侧边栏可以查看当前选中组织的详细信息。 +- **触发入口**: 团队管理页 → 点击「组织」Tab +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 用户已登录且属于某个团队 + +### 层级导航与浏览 + +- **业务描述**: 用户点击组织列表中的某个组织进入下级,通过面包屑路径返回上级;也可在树状侧边栏中选择目标组织查看其详情和操作。 +- **触发入口**: 组织列表 → 点击组织名称 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 当前组织层级下存在子组织 + +### 搜索组织 + +- **业务描述**: 用户通过搜索框输入关键词,按组织名称筛选当前团队下的组织。 +- **触发入口**: 组织管理页 → 顶部搜索框 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 无 + +### 创建子组织 + +- **业务描述**: 团队管理员在当前选中组织下创建一个新的子级组织,填写组织名称、头像和描述信息。 +- **触发入口**: 组织管理页 → 右侧边栏 → 点击「创建子组织」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` → `OrgInfoModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 当前已选中一个组织(非同步成员模式) + +### 编辑组织信息 + +- **业务描述**: 团队管理员修改组织的名称、头像或描述信息。 +- **触发入口**: 组织列表中组织行 → 更多菜单 →「编辑信息」;或右侧边栏 → 编辑图标 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/OrgInfoModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 目标组织非根组织(path 不为空) + +### 移动组织 + +- **业务描述**: 团队管理员将某个组织移动到另一个父组织下,改变其在层级树中的位置。 +- **触发入口**: 组织列表中组织行 → 更多菜单 →「移动」;或右侧边栏 →「移动组织」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/OrgMoveModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 目标组织非根组织;存在其他可选父组织 + +### 删除组织 + +- **业务描述**: 团队管理员删除一个组织(需二次确认)。 +- **触发入口**: 组织列表中组织行 → 更多菜单 →「删除」;或右侧边栏 →「删除组织」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 目标组织非根组织(path 不为空) + +### 管理组织成员 + +- **业务描述**: 团队管理员在弹窗中从全团队成员列表中选择成员加入当前组织,或从组织中移除已选成员。 +- **触发入口**: 右侧边栏 →「管理成员」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/OrgMemberManageModal.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 当前已选中一个组织(非同步成员模式) + +### 从组织中移除成员 + +- **业务描述**: 团队管理员将某个成员从当前组织中移除(不删除该成员,仅取消组织归属)。 +- **触发入口**: 成员列表 → 成员行 → 更多菜单 →「从组织移除」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 非同步成员模式 + +### 从团队移除成员 + +- **业务描述**: 团队管理员直接将某个成员从团队中彻底移除(删除团队归属)。 +- **触发入口**: 成员列表 → 成员行 → 更多菜单 →「从团队移除」 +- **路由路径**: `/account/team?teamTab=org` +- **页面组件位置**: `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 无(两种成员模式均可) + +## 场景关系 + +- **查看组织架构** → **层级导航与浏览**: 用户在查看组织列表后,通过点击组织名称深入下级查看 +- **层级导航与浏览** → **创建子组织/编辑组织/移动组织/删除组织/管理成员**: 管理员选中目标组织后,通过侧边栏操作触发管理功能 +- **搜索组织** 与其他场景独立,搜索后直接定位到目标组织 +- 创建、编辑、移动、删除、管理成员等操作互相独立,可单独执行 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看组织架构 | 团队管理→组织 Tab | /account/team?teamTab=org | 团队成员、团队管理员 | +| S02 | 层级导航与浏览 | 组织列表→点击组织名称 | /account/team?teamTab=org | 团队成员、团队管理员 | +| S03 | 搜索组织 | 组织管理→搜索框 | /account/team?teamTab=org | 团队成员、团队管理员 | +| S04 | 创建子组织 | 侧边栏→创建子组织 | /account/team?teamTab=org | 团队管理员 | +| S05 | 编辑组织信息 | 更多菜单/侧边栏→编辑 | /account/team?teamTab=org | 团队管理员 | +| S06 | 移动组织 | 更多菜单/侧边栏→移动 | /account/team?teamTab=org | 团队管理员 | +| S07 | 删除组织 | 更多菜单/侧边栏→删除 | /account/team?teamTab=org | 团队管理员 | +| S08 | 管理组织成员 | 侧边栏→管理成员 | /account/team?teamTab=org | 团队管理员 | +| S09 | 从组织移除成员 | 成员行→更多菜单→从组织移除 | /account/team?teamTab=org | 团队管理员 | +| S10 | 从团队移除成员 | 成员行→更多菜单→从团队移除 | /account/team?teamTab=org | 团队管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..ec48a2fea9b8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,388 @@ +--- +capability_label: 组织 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — 业务流程详解 + +## 页面总览 + +组织管理页采用左右分栏布局:左侧为主区域,展示当前层级下的子组织列表和直属成员列表;右侧为侧边栏,展示当前选中组织的详情和操作入口(仅管理员可见操作按钮)。顶部包含 Tab 切换栏和搜索框,支持按组织名称搜索。 + +### 查看组织架构与层级导航 + +> 用户进入组织管理 Tab 后,从根组织(团队)开始查看组织层级结构和成员分布,通过点击组织深入下级,通过面包屑路径返回上级。 + +#### 步骤 1:页面初始化加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入团队管理页 → 点击「组织」Tab | `POST /proApi/support/user/team/org/list`(查询根组织子节点,orgId 为空) + `GET /proApi/support/user/team/org/members`(获取根组织直属成员,分页) | 无 | 左侧加载中状态 → 表格展示子组织列表(每行含组织头像、名称、成员总数标签)和直属成员列表;右侧边栏展示根组织(团队)名称和头像 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `POST /proApi/support/user/team/org/list` | `orgId=""`, `withPermission=true`, `searchKey=""` | 过滤 `path !== ''` 的子组织并渲染 | 子组织表格行 + 成员表格行 | +| 切换组织 | `POST /proApi/support/user/team/org/list` | `orgId={selectedOrg._id}`, `withPermission=true`, `searchKey=""` | 基于 `currentOrg._id` 和 `path` 刷新 | 更新为新层级下的子组织列表 | +| 成员翻页 | `GET /proApi/support/user/team/org/members` | `pageSize=20`, `orgId={currentOrg._id}`, `withOrgs=false`, `withPermission=true`, `status=active` | 滚动触底自动加载下一页 | 成员列表追加新页数据 | + +- 分页参数:每页 20 条,滚动触底自动加载 +- 排序规则:无用户可控排序,默认按后端排序 +- 筛选条件:搜索框支持按组织名称筛选 + +#### 步骤 2:进入下级组织 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击组织列表中的某个组织名称 | `POST /proApi/support/user/team/org/list`(orgId 为目标组织 _id) + `GET /proApi/support/user/team/org/members`(orgId 为目标组织 _id) | 搜索模式下点击 → 替换组织栈为单层(清空历史栈)并清空搜索关键词;非搜索模式 → 追加到组织栈末尾 | 加载中 → 表格更新为子组织列表和直属成员列表;面包屑路径追加一层;右侧边栏更新为选中组织详情 | + +#### 步骤 3:通过面包屑返回上级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击面包屑路径中的某一级 | 同步骤 1 的 API 列表,参数为对应层级的 orgId | 面包屑路径 ID 与组织栈 pathId 匹配过滤 | 加载中 → 表格恢复到对应层级视图;清空搜索关键词 | + +### 搜索组织 + +> 用户在搜索框中输入关键词,按组织名称过滤当前团队下的所有组织。 + +#### 步骤 1:输入搜索关键词 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入关键词 | `POST /proApi/support/user/team/org/list`(searchKey 为输入值), 300ms 防抖后触发 | 无 | 输入框内容实时更新;防抖等待 300ms 后发起请求 | + +#### 步骤 2:查看搜索结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待搜索完成 | 同上 API 返回结果 | 搜索模式下隐藏面包屑路径、不显示成员列表 | 表格展示匹配的组织列表;面包屑路径隐藏 | + +#### 步骤 3:点击搜索结果中的组织 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击搜索结果中的某个组织 | 同"进入下级组织" | 搜索模式 → 替换组织栈为该组织、清空搜索关键词 | 进入该组织详情视图,恢复正常显示 | + +### 创建子组织 + +> 团队管理员在当前选中的组织下创建一个新的子组织。 + +#### 步骤 1:打开发起入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在右侧边栏中点击「创建子组织」 | 无 | `isTeamAdmin === true` 且非同步成员模式(`isSyncMember === false`)时按钮可见 | 弹出创建组织弹窗,表单字段初始化为默认值 | + +#### 步骤 2:填写组织信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写组织名称(必填)、上传头像、填写描述 → 点击「新建」 | `POST /proApi/support/user/team/org/create`(参数: `name`, `avatar`, `orgId=parentId`, `description`) | 名称必填校验通过 | 提交按钮显示加载状态(`isLoading`);成功后 toast 提示"创建成功",弹窗关闭,刷新组织列表 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | — | 无特殊约束 | 否 | 组织的显示名称 | +| 头像 | 图片上传 | 否 | 默认组织头像 | 点击头像触发上传 | 否 | 组织的展示头像 | +| 描述 | 多行文本输入 | 否 | — | 无特殊约束 | 否 | 组织的简要描述 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称为空 | 提交时(前端校验,required: true) | 表单校验阻止提交 | + +**前后置条件**: +- **前置条件**: 用户已是团队管理员;当前已选中一个组织作为父组织;非同步成员模式 +- **后置影响**: 创建成功后,组织列表刷新,新组织出现在父组织下 +- **失败场景**: 网络异常 → toast 显示错误信息 + +### 编辑组织信息 + +> 团队管理员修改组织的名称、头像或描述信息。 + +#### 步骤 1:打开编辑入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在组织行的更多菜单中点击「编辑信息」,或点击侧边栏组织名旁的编辑图标 | 无 | `isTeamAdmin === true` 且目标组织非根组织(`path !== ''`)时编辑按钮可见 | 弹出编辑弹窗,表单回填当前组织数据(名称、头像、描述) | + +#### 步骤 2:修改信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改名称/头像/描述 → 点击「保存」 | `PUT /proApi/support/user/team/org/update`(参数: `orgId`, `name`, `avatar`, `description`) | 编辑模式(`isEdit = true`,根据 `editOrg._id` 是否为空判断) | 提交按钮加载中 → toast"更新成功" → 弹窗关闭;侧边栏组织名和头像即时更新(updateCurrentOrg) | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 名称 | 文本输入 | ✅ | 回填当前值 | 无特殊约束 | 否 | 组织名称 | +| 头像 | 图片上传 | 否 | 回填当前值 | 点击触发上传 | 否 | 组织头像 | +| 描述 | 多行文本输入 | 否 | 回填当前值 | 无特殊约束 | 否 | 组织描述 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 名称为空 | 提交时 | 表单校验阻止提交 | + +**前后置条件**: +- **前置条件**: 用户为团队管理员;目标组织非根组织 +- **后置影响**: 更新成功后刷新组织列表,侧边栏即时更新组织名和描述 +- **失败场景**: 网络异常 → toast 显示错误信息 + +### 移动组织 + +> 团队管理员将某个组织移动到树中的另一个位置(更改父组织)。 + +#### 步骤 1:打开移动弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在组织行更多菜单中点击「移动」,或侧边栏点击「移动组织」 | 无 | `isTeamAdmin === true` 且目标组织非根组织时按钮可见 | 弹出移动弹窗,展示组织树供选择目标父节点 | + +#### 步骤 2:选择目标位置并确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在组织树中点选目标父组织 → 点击「确定」 | `PUT /proApi/support/user/team/org/move`(参数: `orgId`, `targetOrgId`) | 必须选中一个目标组织(`selectedOrg` 非空)后确定按钮才可用;当前移动的组织节点在树中隐藏不显示 | 确定按钮加载中 → 成功后弹窗关闭,刷新组织列表 | + +**前后置条件**: +- **前置条件**: 用户为团队管理员;目标组织非根组织 +- **后置影响**: 组织及其子树移动到新的父组织下 +- **失败场景**: 目标组织不可选(如移动到自身或子组织)→ 前端树已做过滤 + +### 删除组织 + +> 团队管理员删除一个组织及其中包含的所有子组织和成员关联。 + +#### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在组织行更多菜单点击「删除」,或侧边栏点击「删除组织」 | 无 | `isTeamAdmin === true` 且目标组织非根组织时按钮可见 | 弹出确认删除弹窗(类型为 `delete`) | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | `DELETE /proApi/support/user/team/org/delete`(参数: `orgId`) | 用户可取消操作 | 确认后执行删除 → 成功后刷新组织列表 | + +**删除链路详情**: +- **确认弹窗**: 标题/内容为 i18n key `account_team:confirm_delete_org`("确认删除组织") +- **级联影响**: 删除组织后,该组织下的所有子组织、组织与成员的关联关系均被移除 + +### 管理组织成员 + +> 团队管理员在弹窗中为当前组织选择成员(添加/移除)。 + +#### 步骤 1:打开成员管理弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 侧边栏点击「管理成员」 | `GET /proApi/support/user/team/org/members`(分页加载当前组织已有成员,`pageSize=100000`,获取全量) + `GET /proApi/support/user/team/org/members`(分页加载全部团队成员,`pageSize=20`,带搜索和滚动加载) | `isTeamAdmin === true` 且非同步成员模式时按钮可见 | 弹出左右双栏弹窗:左侧为全部团队成员列表(支持搜索和滚动分页),右侧为当前组织已选成员列表 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | `GET /proApi/support/user/team/org/members`(全团队) | `pageSize=20`, `withOrgs=true`, `withPermission=false`, `status=active`, `searchKey=""` | 按 `tmbId` 去重 | 左侧全成员列表 | +| 加载已有成员 | `GET /proApi/support/user/team/org/members`(当前组织) | `pageSize=100000`, `orgId={currentOrg._id}`, `withOrgs=false`, `withPermission=false` | 提取 `tmbId`、`memberName`、`avatar` | 右侧已选成员列表 | +| 搜索成员 | `GET /proApi/support/user/team/org/members` | `searchKey={输入值}` | 按名称筛选 | 左侧成员列表更新 | + +#### 步骤 2:选择成员 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击勾选/取消勾选某个成员卡片 | 无(本地状态更新) | 成员已选中 → 取消选中并从已选列表移除;成员未选中 → 添加到已选列表 | 左侧成员卡片勾选状态切换;右侧已选成员列表更新,计数实时变化 | + +#### 步骤 3:提交变更 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「保存」按钮 | `PUT /proApi/support/user/team/org/updateMembers`(参数: `orgId`, `members: [{tmbId}]`) | 无额外分支 | 保存按钮加载中 → toast"更新成功" → 弹窗关闭 → 刷新组织列表和成员列表 | + +**前后置条件**: +- **前置条件**: 用户为团队管理员;非同步成员模式 +- **后置影响**: 当前组织成员列表更新,组织行上方成员计数标签刷新 +- **失败场景**: 网络异常 → toast 显示错误信息 + +### 从组织中移除成员 + +> 团队管理员将某个成员从当前组织中移除(成员仍属于团队)。 + +#### 步骤 1:触发移除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 成员行的更多菜单中点击「从组织移除」 | 无 | `isTeamAdmin === true` 且非同步成员模式(`isSyncMember === false`)时此菜单项可见;同步成员模式下仅显示「从团队移除」 | 弹出确认弹窗 | + +#### 步骤 2:确认移除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | `DELETE /proApi/support/user/team/org/deleteMember`(参数: `orgId=currentOrg._id`, `tmbId`) | 需要当前已选中一个组织(currentOrg 非空) | 移除成功后刷新成员列表和组织列表 | + +**删除链路详情**: +- **确认弹窗**: 自定义内容 `account_team:confirm_delete_from_org`(提示"{username} 将从组织中被移除") +- **级联影响**: 仅解除成员与组织的关联,成员仍属于团队 + +### 从团队移除成员 + +> 团队管理员将某个成员从团队中彻底移除。 + +#### 步骤 1:触发移除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 成员行的更多菜单中点击「从团队移除」 | 无 | `isTeamAdmin === true` 时此菜单项始终可见 | 弹出确认弹窗 | + +#### 步骤 2:确认移除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | `DELETE /proApi/support/user/team/member/delete`(参数: `tmbId`) | 无 | 移除成功后刷新成员列表和组织列表;该成员从团队中彻底删除 | + +**删除链路详情**: +- **确认弹窗**: 自定义内容 `account_team:confirm_delete_from_team`(提示"{username} 将从团队中被移除") +- **菜单样式**: 此菜单项 hover 时红色高亮(`color: 'red.600', backgroundColor: 'red.50'`),以示危险操作 +- **级联影响**: 成员完全从团队中移除,其在所有组织中的关联也一并清除 + +## Mermaid 附录 + +### 查看组织架构 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 进入组织 Tab + UI->>API: POST /org/list (orgId="") + API->>Backend: 查询根组织子节点 + Backend-->>API: 组织列表 + API-->>UI: 渲染组织表格 + 侧边栏 + par + UI->>API: GET /org/members (orgId="", pageSize=20) + API->>Backend: 查询根组织直属成员 + Backend-->>API: 成员列表 + API-->>UI: 渲染成员表格 + end +``` + +### 创建子组织 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「创建子组织」 + UI-->>User: 弹出创建表单 + User->>UI: 填写名称、头像、描述 + User->>UI: 点击「新建」 + UI->>API: POST /org/create (name, avatar, orgId, description) + API->>Backend: 创建组织记录 + Backend-->>API: 创建成功 + API-->>UI: toast "创建成功" + UI->>API: POST /org/list(刷新) + API-->>UI: 更新组织列表 +``` + +### 编辑组织信息 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「编辑信息」 + UI-->>User: 弹出编辑表单(回填数据) + User->>UI: 修改名称/头像/描述 + User->>UI: 点击「保存」 + UI->>API: PUT /org/update (orgId, name, avatar, description) + API->>Backend: 更新组织记录 + Backend-->>API: 更新成功 + API-->>UI: toast "更新成功",侧边栏即时更新 + UI->>API: POST /org/list(刷新列表) +``` + +### 移动组织 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「移动组织」 + UI-->>User: 弹出组织树选择器 + User->>UI: 选择目标父组织 + User->>UI: 点击「确定」 + UI->>API: PUT /org/move (orgId, targetOrgId) + API->>Backend: 更新组织父子关系 + Backend-->>API: 移动成功 + API-->>UI: 弹窗关闭,刷新列表 +``` + +### 删除组织 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「删除组织」 + UI-->>User: 弹出确认删除弹窗 + User->>UI: 点击确认 + UI->>API: DELETE /org/delete (orgId) + API->>Backend: 删除组织 + Backend-->>API: 删除成功 + API-->>UI: 刷新组织列表 +``` + +### 管理组织成员 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击「管理成员」 + UI-->>User: 弹出左右双栏弹窗 + par + UI->>API: GET /members(全团队,分页) + API->>Backend: 查询团队所有成员 + Backend-->>API: 全部成员列表 + and + UI->>API: GET /members(当前组织,pageSize=100000) + API->>Backend: 查询组织已有成员 + Backend-->>API: 组织成员列表 + end + User->>UI: 勾选/取消勾选成员 + User->>UI: 点击「保存」 + UI->>API: PUT /org/updateMembers (orgId, members) + API->>Backend: 更新组织成员 + Backend-->>API: 更新成功 + API-->>UI: toast "更新成功",弹窗关闭 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..1bb957889ab1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,37 @@ +--- +capability_label: 组织 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — 共享能力清单 + +## 对外暴露的 Hook/API + +| 导出项 | 类型 | 用途 | 使用者 | 使用场景 | +|--------|------|------|--------|---------| +| `useOrg` | Hook | 组织层级导航、子组织列表加载、成员分页查询、路径面包屑、搜索和刷新等核心状态管理 | 成员管理(MemberManager/MemberModal) | 在成员管理弹窗中选择组织时,需要复用组织导航和列表加载能力 | + +## 跨模块依赖详情 + +### 被以下模块依赖 (imported_by) + +| 消费模块 | 引用的具体内容 | 引用方式 | 首次引入 | +|---------|--------------|---------|---------| +| 权限-成员管理 | `useOrg` Hook — 组织选择和层级导航能力 | `import useOrg from '@/web/support/user/team/org/hooks/useOrg'` | — | + +### 依赖以下模块 (imports_from) + +| 依赖模块 | 使用的具体能力 | 引用方式 | 关键度 | +|---------|--------------|---------|--------| +| 用户Store | `useUserStore` — 用户信息、团队信息、管理员权限判断 | `import { useUserStore } from '@/web/support/user/useUserStore'` | 核心 | +| 系统配置 | `useSystemStore` — feConfigs 注册方式配置 | `import { useSystemStore } from '@/web/common/system/useSystemStore'` | 核心 | +| 团队上下文 | `TeamContext` — 团队编辑数据和团队规模 | `import { TeamContext } from '@/pageComponents/account/team/context'` | 可选 | +| 账户容器 | `AccountContainer` — 页面布局外壳 | `import AccountContainer from '@/pageComponents/account/AccountContainer'` | 核心 | +| Web公共Hooks | `useScrollPagination`、`useRequest`、`useConfirm` — 分页、请求管理、确认弹窗 | `import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'` 等 | 核心 | +| 公共组件库 | `MyModal`、`Avatar`、`MyIcon`、`SearchInput`、`FillRowTabs`、`MyMenu`、`Path` 等 | `import X from '@fastgpt/web/components/common/X'` | 核心 | +| 团队API | `getTeamMembers`、`delRemoveMember` — 团队成员查询和移除 | `import { getTeamMembers } from '@/web/support/user/team/api'` | 核心 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..47e0100d01ed --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 组织 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — 模块概览 + +## 模块业务概述 + +组织模块是团队管理的核心功能之一,用于管理团队内部的组织层级结构。在这个模块中,团队管理员可以创建和维护树状组织架构(类似部门/小组),将团队成员分配到不同的组织中,实现组织维度的成员分组管理。 + +本模块提供组织的创建、编辑、删除、移动以及成员分配等完整管理能力。团队成员可以查看组织结构和各组织的成员分布;团队管理员则拥有全部管理权限。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 组织管理页(/account/team?teamTab=org) | 查看所有组织、创建子组织、编辑组织信息、移动组织、删除组织、管理组织成员 | 拥有所有管理操作权限 | +| 团队成员 | 组织管理页(/account/team?teamTab=org) | 查看组织结构和成员列表 | 无管理操作权限,不显示操作按钮 | + +**权限模型**: 基于 `useUserStore` 中 `isTeamAdmin` 字段进行前端权限判断。团队管理员 (`TeamMemberRoleEnum.owner`) 可见团队编辑入口。同步成员模式(`feConfigs.register_method` 包含 `sync`)下,部分管理操作受限。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户Store | 使用 `useUserStore` 获取用户信息、团队信息、管理员权限判断 | Store | +| 系统配置 | 使用 `useSystemStore` 获取 `feConfigs`(注册方式配置、同步成员判断) | Store | +| 团队上下文 | 使用 `TeamContext` 获取编辑团队数据、团队规模等共享状态 | Context | +| 账户容器 | 使用 `AccountContainer` 作为页面布局外壳 | 组件 | +| 公共组件库 | 使用 `MyModal`、`Avatar`、`MyIcon`、`SearchInput`、`FillRowTabs`、`MyMenu` 等公共组件 | 组件 | + +## 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `useOrg` Hook(组织导航、列表加载、成员分页等核心逻辑) | 成员管理(MemberManager/MemberModal)、组织移动弹窗(OrgMoveModal) | Hook | + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 组织管理 | /account/team?teamTab=org | `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` | 团队管理员、团队成员 | 团队管理页的「组织」Tab,无内部 Tab 子页面 | + +本模块为团队管理页面的 Tab 子能力之一,通过 `FillRowTabs` 切换进入。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e67cf0772fa5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,37 @@ +--- +capability_label: 推广 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 账户 +roles: ["团队所有者"] +router_paths: ["/account/promotion"] +--- + +# 业务流程索引 + +## 流程总览 + +| 序号 | 流程名称 | 触发方式 | 涉及角色 | 简要说明 | +|------|---------|---------|---------|---------| +| 1 | 查看推广概览数据 | 页面加载 | 团队所有者 | 自动获取并展示累计邀请人数、收益金额、返现比例 | +| 2 | 复制邀请链接 | 点击按钮 | 团队所有者 | 复制专属邀请链接到剪贴板 | +| 3 | 查看推广记录列表 | 页面加载 + 分页 | 团队所有者 | 分页展示推广记录(注册奖励 / 支付返佣),支持翻页浏览 | + +## 流程间关系 + +``` +进入推广页面 + │ + ├── 自动加载:推广概览数据(流程 1) + │ └── 显示:邀请人数、收益金额、返现比例、邀请链接 + │ + ├── 用户操作:复制邀请链接(流程 2) + │ └── 将带 hiId 参数的邀请 URL 复制到剪贴板 + │ + └── 自动加载:推广记录列表(流程 3) + ├── 默认每页 20 条 + └── 翻页浏览更多记录 +``` + +流程 1 和流程 3 在页面加载时并行请求,互不依赖。流程 2 由用户主动触发。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..19f3580e87f3 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,91 @@ +--- +capability_label: 推广 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 账户 +roles: ["团队所有者"] +router_paths: ["/account/promotion"] +--- + +# 业务流程详解 + +## 流程 1:查看推广概览数据 + +**触发时机**:页面加载时自动执行 + +**前置条件**: +- 用户已登录且为团队所有者 +- 功能开关 `show_promotion` 已启用 + +**执行步骤**: + +1. 页面组件挂载,发起 `getPromotionInitData` 请求 +2. 服务端返回 `invitedAmount`(累计邀请人数)和 `earningsAmount`(收益金额) +3. 页面渲染四个统计卡片: + - **累计邀请人数**:显示 `invitedAmount` 数值 + - **收益**:显示 `earningsAmount` 金额(单位:元) + - **返现比例**:显示 `userInfo.promotionRate`(若无则默认 15%),附带说明气泡("好友充值时你将获得一定比例的余额奖励") + - **邀请链接**:显示"复制邀请链接"按钮 + +**异常处理**: +- 请求失败时,统计数据默认显示 `0` +- `promotionRate` 为空时回退显示 `15%` + +**输出结果**:统计卡片渲染完毕,用户可查看推广概况 + +--- + +## 流程 2:复制邀请链接 + +**触发时机**:用户点击"复制邀请链接"按钮 + +**前置条件**:用户信息已加载(`userInfo._id` 存在) + +**执行步骤**: + +1. 用户点击按钮 +2. 系统拼接邀请 URL:`{当前域名}/?hiId={用户ID}` +3. 通过 `useCopyData` hook 将 URL 复制到系统剪贴板 +4. 用户可将链接分享给好友 + +**业务规则**: +- 好友通过该链接注册后,永久与邀请人绑定 +- 好友使用手机号注册时,邀请人立即获得 5 元奖励 +- 好友后续充值时,邀请人按返现比例获得奖励 +- 奖励发放至邀请人的默认团队 + +**异常处理**:复制失败时由 `useCopyData` hook 内部处理(通常由浏览器剪贴板 API 抛出) + +**输出结果**:邀请链接已复制到剪贴板 + +--- + +## 流程 3:查看推广记录列表 + +**触发时机**:页面加载时自动执行,用户可翻页浏览 + +**前置条件**: +- 用户已登录且为团队所有者 +- 功能开关 `show_promotion` 已启用 + +**执行步骤**: + +1. 页面组件挂载,通过 `usePagination` hook 发起 `getPromotionRecords` 请求(默认每页 20 条) +2. 服务端返回分页数据:记录列表 + 总条数 +3. 页面渲染表格,包含三列: + - **时间**:记录创建时间,格式 `YYYY/MM/DD HH:mm:ss`,无数据时显示 `-` + - **类型**:显示对应中文("注册奖励" / "支付返佣") + - **金额**:显示奖励金额 +4. 用户可通过底部翻页组件切换页码 + +**数据格式**: +- 记录类型 `type` 取值:`register`(注册奖励)、`pay`(支付返佣) +- 记录类型通过 i18n key `user:promotion.{type}` 映射为中文显示 + +**异常处理**: +- 请求加载中显示 Loading 状态 +- 记录为空时显示"暂无邀请记录"空状态提示 +- 请求失败由 `usePagination` 内部处理 + +**输出结果**:推广记录表格渲染完毕 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f6bcba3d8e19 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,40 @@ +--- +capability_label: 推广 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 账户 +roles: ["团队所有者"] +router_paths: ["/account/promotion"] +--- + +# 模块概览 + +## §1 模块业务描述 + +推广模块是账户管理下的一个子功能,面向**团队所有者**提供邀请推广数据的查看能力。用户可以查看以下核心数据: + +- **累计邀请人数**:通过邀请链接成功注册的好友总数 +- **收益金额**:因好友充值产生的返现收益总额(人民币) +- **返现比例**:当前用户享有的返现比例,默认为 15% +- **邀请链接**:用户专属的推广链接,带有用户唯一标识(`hiId` 参数),好友通过此链接注册即永久绑定推广关系 + +此外,页面以分页表格形式展示**推广记录明细**,包括每笔记录的时间、类型(注册奖励 / 支付返佣)和金额。 + +**推广奖励机制**: +- 好友通过邀请链接使用手机号注册时,邀请人立即获得 5 元奖励 +- 好友后续充值时,邀请人按返现比例获得余额奖励 +- 所有奖励发放至用户的默认团队 + +## §2 角色权限差异 + +| 角色 | 能否访问 | 说明 | +|------|---------|------| +| 团队所有者(Owner) | ✅ | 可查看完整推广数据,仅当 `show_promotion` 功能开关启用且当前用户为团队所有者时,推广标签页才可见 | +| 管理员(Admin) | ❌ | 无法看到推广标签页 | +| 可写成员 / 访客 | ❌ | 无法看到推广标签页 | + +**访问控制逻辑**: +- 功能开关 `feConfigs.show_promotion` 必须为 `true` +- 用户必须是团队所有者(`userInfo.team.permission.isOwner === true`) +- 两个条件同时满足时,"推广"标签才会出现在账户管理的导航中 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..42ca4919355a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,79 @@ +--- +capability_label: 账户 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T10:49:09Z" +parent_module: null +roles: [owner, admin, member] +router_paths: ["/account/usage", "/account/info", "/account/team", "/account/bill", "/account/thirdParty", "/account/customDomain", "/account/model", "/account/promotion", "/account/apikey", "/account/inform", "/account/setting"] +--- + +# 账户 — 模块概览 + +## 1. 模块业务概述 + +账户模块是 FastGPT 的用户个人中心与团队管理入口。它集中提供了用户管理个人账号信息、查看用量统计、管理团队、处理账单发票、配置第三方集成、管理 API 密钥以及设置通知偏好等功能。 + +本模块面向**所有已登录用户**,根据用户角色和系统配置展示不同的功能选项卡。团队管理员(owner/admin)拥有团队管理、账单查看和用量统计的全部权限;普通成员(member)仅可查看个人相关信息。 + +模块内部通过统一的侧边栏导航(PC 端)或顶部选项卡(移动端)组织 11 个子能力页面,所有子页面共享 `AccountContainer` 布局组件。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| owner | 全部 11 个页面 | 全部操作(含团队管理、删除成员、管理密钥、查看账单、配置推广、域名管理) | 最高权限,拥有所有可见页面的完整操作权限 | +| admin | 用量统计、账号信息、团队管理、账单、第三方集成、模型管理、API 密钥、通知、个性化设置 | 管理团队成员、查看账单、配置模型、管理密钥 | 不能查看推广记录和执行敏感操作(如解散团队) | +| member | 账号信息、第三方集成、模型管理、API 密钥、通知、个性化设置 | 查看个人信息、配置第三方集成、管理个人密钥、设置偏好 | 不能查看团队管理、账单、用量统计、推广和自定义域名 | + +**权限模型**: 基于 `TeamPermission`(含 `hasManagePer`、`hasWritePer`、`isOwner`、`hasApikeyCreatePer` 等字段)的权限位模型,由后端在用户登录时注入到 `userInfo.team.permission` 中。部分选项卡的可见性还受 `feConfigs` 功能开关控制(如 `isPlus`、`show_pay`、`show_promotion`、`customDomain.enable` 等)。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库(@fastgpt/web) | MyIcon 图标、Avatar 头像、MyModal 弹窗、MyBox 容器、LightRowTabs 选项卡 | 组件 | +| 用户状态管理(useUserStore) | 用户信息、团队信息、权限位、初始化登录 | Store | +| 系统配置(useSystemStore) | feConfigs 功能开关、系统级配置 | Store | +| 账单/钱包模块 | 订阅套餐展示、价格格式化、充值弹窗 | 组件/Hook | +| i18n 国际化 | 多语言翻译(account、account_thirdParty、account_usage 等命名空间) | Hook | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| AccountContainer 账户布局 | 所有 11 个账户子页面 | 组件 | +| ApiKeyTable API 密钥表格 | 应用发布页面(Publish/API) | 组件 | +| 团队选择器(TeamSelector) | 账号信息页面内部使用 | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 用量统计 | /account/usage | `pages/account/usage/index.tsx` | owner, admin | 含仪表盘和明细两个子视图,支持按成员/部门/群组筛选 | +| 账号信息 | /account/info | `pages/account/info/index.tsx` | 全部 | 含个人信息、修改密码、订阅套餐、优惠券等子功能 | +| 团队管理 | /account/team | `pages/account/team/index.tsx` | owner, admin | 成员列表、邀请、角色管理 | +| 账单 | /account/bill | `pages/account/bill/index.tsx` | owner, admin | 订单列表、发票管理 | +| 第三方集成 | /account/thirdParty | `pages/account/thirdParty/index.tsx` | 全部 | Laf 账户、OpenAI 账户、工作流变量 | +| 自定义域名 | /account/customDomain | `pages/account/customDomain/index.tsx` | owner, admin | DNS 配置、域名激活 | +| 模型管理 | /account/model | `pages/account/model/index.tsx` | 全部 | 模型供应商配置、模型列表 | +| 推广 | /account/promotion | `pages/account/promotion.tsx` | owner | 推广统计、邀请链接 | +| API 密钥 | /account/apikey | `pages/account/apikey.tsx` | owner, admin | API 密钥创建与管理 | +| 通知 | /account/inform | `pages/account/inform.tsx` | 全部 | 通知偏好设置 | +| 个性化设置 | /account/setting | `pages/account/setting.tsx` | 全部 | 语言切换等个人偏好 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 用量统计 | 用量统计 | [08-模块概览](../用量统计/模块概览.md) | +| 账号信息 | 账号信息 | [08-模块概览](../账号信息/模块概览.md) | +| 团队管理 | 团队管理 | [08-模块概览](../团队管理/模块概览.md) | +| 账单 | 账单 | [08-模块概览](../账单/模块概览.md) | +| 第三方集成 | 第三方集成 | [08-模块概览](../第三方集成/模块概览.md) | +| 自定义域名 | 自定义域名 | [08-模块概览](../自定义域名/模块概览.md) | +| 模型管理 | 模型管理 | [08-模块概览](../模型管理/模块概览.md) | +| 推广 | 推广 | [08-模块概览](../推广/模块概览.md) | +| API 密钥 | API 密钥 | [08-模块概览](../API 密钥/模块概览.md) | +| 通知 | 通知 | [08-模块概览](../通知/模块概览.md) | +| 个性化设置 | 个性化设置 | [08-模块概览](../个性化设置/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f537b97906eb --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,58 @@ +--- +capability_label: 模型管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 账户 +roles: [root, owner, admin, member] +router_paths: ["/account/model"] +--- + +# 模型管理 — 业务流程索引 + +## Tab 结构 + +模型管理页面使用 `MyTabs` 组件实现 Tab 切换,共 5 个一级 Tab: + +``` +模型管理 (/account/model) +├── 活跃模型 (model) — 所有角色可见 +│ ├── 基础模型 (base) — ModelTable 内部 Tab +│ └── 自定义模型 (custom) — ModelTable 内部 Tab +├── 配置模型 (config) — 所有角色可见 +├── 渠道管理 (channel) — root 专属 [动态可见] +├── 渠道日志 (channel_log) — root 专属 [动态可见] +└── 模型监控 (account_model) — root 专属 [动态可见] +``` + +> root 专属 Tab 的可见条件:`isRoot && feConfigs?.show_aiproxy` + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 浏览活跃模型列表 | 活跃模型 | 账户侧边栏→模型管理→默认 Tab | /account/model | 全部 | +| S01-1 |  ├ 基础模型 | 活跃模型>基础模型 | 顶部 FillRowTabs→基础模型 | /account/model | 全部 | +| S01-2 |  ├ 自定义模型 | 活跃模型>自定义模型 | 顶部 FillRowTabs→自定义模型 | /account/model | 全部 | +| S02 | 浏览配置模型列表 | 配置模型 | 顶部 Tab→配置模型 | /account/model | 全部 | +| S03 | 管理 API 渠道 | 渠道管理 | 顶部 Tab→渠道管理 | /account/model | ← root 专属 [动态可见] | +| S04 | 查看渠道日志 | 渠道日志 | 顶部 Tab→渠道日志 | /account/model | ← root 专属 [动态可见] | +| S05 | 查看模型监控 | 模型监控 | 顶部 Tab→模型监控 | /account/model | ← root 专属 [动态可见] | + +## 公共操作 + +以下操作不隶属于特定 Tab,属于页面级公共功能: + +| 操作 | 描述 | 角色 | +|------|------|------| +| 页面初始化 | 加载服务端渲染数据(i18n 资源命名空间:account, account_model, user, app, train, chat) | 全部 | +| Tab 切换 | 通过路由 `/account/model` 进入,默认激活"活跃模型"Tab;点击顶部 MyTabs 切换 | 全部 | +| 账户侧边栏导航 | 左侧 DashboardNavbar 提供账户子页面切换入口 | 全部 | +| 退出登录 | 通过 AccountContainer 侧边栏"退出登录"选项触发 | 全部 | + +## 场景关系 + +- **活跃模型 Tab** 与 **配置模型 Tab** 各自独立,无先后依赖关系。活跃模型展示从 Store 中获取的模型数据(支持筛选和批量权限操作),配置模型通过 API 获取全量系统模型数据 +- **渠道管理** → **渠道日志**:渠道管理中的模型测试操作会触发 API 调用,其结果记录在渠道日志中 +- **渠道管理** → **模型监控**:渠道请求的调用量、Token 消耗等数据汇聚到模型监控的 Dashboard 统计中 +- root 专属的三个 Tab(渠道管理、渠道日志、模型监控)仅当 `feConfigs.show_aiproxy` 启用且用户为 root 时出现 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..93f611ac7eba --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,83 @@ +--- +capability_label: 模型管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 账户 +roles: [root, owner, admin, member] +router_paths: ["/account/model"] +--- + +# 模型管理 — 业务流程详解 + +## 页面总览 + +模型管理页面是账户中心下 AI 模型相关功能的统一入口。页面在 `/account/model` 路由下工作,通过顶部 MyTabs 组件在 5 个 Tab 间切换。页面整体嵌入 `AccountContainer` 布局中,共享账户侧边栏导航和统一的页面头部。 + +## Tab 结构索引 + +| Tab | 业务描述 | 详细文档 | +|-----|---------|---------| +| 活跃模型 | 展示系统启用的模型列表,分基础模型和自定义模型两个子 Tab,支持筛选(提供商/模型类型/搜索)和批量权限操作 | [10-业务流程详解](../活跃模型/业务流程详解.md) | +| 配置模型 | 以表格形式展示全量系统模型的配置信息(模型名、类型、价格、创建者、权限、启用状态),支持创建、编辑、删除和测试模型 | [10-业务流程详解](../配置模型/业务流程详解.md) | +| 渠道管理 | [动态可见] 管理 API 代理渠道(如 OpenAI 渠道),支持创建、编辑、删除、启用/禁用和优先级调整 | [10-业务流程详解](../渠道管理/业务流程详解.md) | +| 渠道日志 | [动态可见] 查看和筛选渠道 API 的请求日志,支持按日期、渠道、模型、状态和请求 ID 过滤,可展开详情 | [10-业务流程详解](../渠道日志/业务流程详解.md) | +| 模型监控 | [动态可见] 以图表和表格展示模型调用量、Token 消耗、成本、响应时间等 Dashboard 数据 | [10-业务流程详解](../模型监控/业务流程详解.md) | + +> 动态可见 Tab 仅在用户为 root 且 `feConfigs.show_aiproxy` 启用时显示。 + +## 公共业务流程 + +### 页面初始化 + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 1 | 点击账户侧边栏"模型管理"菜单项或直接访问 `/account/model` | 无(前端路由跳转) | — | Next.js 执行 `getServerSideProps`,加载 i18n 资源(account, account_model, user, app, train, chat 命名空间);页面显示 AccountContainer 布局 | +| 2 | 页面初始化完成,默认 Tab 激活 | 无 | 活跃模型 Tab 内:ModelTable 从 `useSystemStore` 读取模型列表(已在应用初始化时预加载) | 页面头部显示"模型管理"标题 + MyTabs 选项卡(居中);默认激活"活跃模型" Tab,渲染 ValidModelTable 组件 | + +### 角色感知的 Tab 可见性 + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 1 | 页面加载 | 无 | `isRoot === true && feConfigs.show_aiproxy === true` | Tab 列表中增加"渠道管理""渠道日志""模型监控"三个选项 | +| 2 | 页面加载 | 无 | `isRoot === false || feConfigs.show_aiproxy === false` | Tab 列表仅显示"活跃模型""配置模型"两个选项 | + +> 角色判断来自 `useUserStore().userInfo.username === 'root'`;功能开关来自 `useSystemStore().feConfigs.show_aiproxy`。判断逻辑在 `tabList` 的 `useMemo` 中执行。 + +### Tab 切换 + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 1 | 点击顶部 MyTabs 中的目标 Tab 标签 | 无 | — | `tab` 状态更新为对应值;当前 Tab 标签高亮,其他恢复默认样式 | +| 2 | Tab 内容条件渲染 | 无 | `tab === 'model'` → 渲染 ValidModelTable(`ModelTable`) | 显示活跃模型内容区域 | +| 3 | Tab 内容条件渲染 | 无 | `tab === 'config'` → 渲染 `ModelConfigTable`(动态导入) | 显示配置模型内容区域(加载时可能有短暂加载状态) | +| 4 | Tab 内容条件渲染 | 无 | `tab === 'channel'` → 渲染 `ChannelTable`(动态导入) | 显示渠道管理内容区域 | +| 5 | Tab 内容条件渲染 | 无 | `tab === 'channel_log'` → 渲染 `ChannelLog`(动态导入) | 显示渠道日志内容区域 | +| 6 | Tab 内容条件渲染 | 无 | `tab === 'account_model'` → 渲染 `ModelDashboard`(动态导入) | 显示模型监控内容区域 | + +> 配置模型、渠道管理、渠道日志和模型监控 Tab 的内容组件使用 Next.js `dynamic(() => import(...))` 实现按需加载,首次切换到对应 Tab 时才会触发代码下载和执行。 + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 模型管理页面 + participant Store as useSystemStore/useUserStore + participant API as Backend API + + User->>UI: 访问 /account/model + UI->>Store: 读取 userInfo (角色判断) + UI->>Store: 读取 feConfigs (功能开关) + Store-->>UI: 返回用户信息和系统配置 + UI->>UI: 计算 tabList (根据角色+开关) + UI->>UI: 默认激活"活跃模型" Tab + + Note over UI: 各 Tab 详见子文档 + + User->>UI: 点击其他 Tab + UI->>UI: 动态 import 目标 Tab 组件 + UI->>UI: 渲染对应 Tab 内容 +``` + +> 各 Tab 的业务流程细节(模型筛选、批量操作、渠道 CRUD、日志查询、监控数据加载等)请参见各 Tab 子文档。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a959f0a65f32 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,64 @@ +--- +capability_label: 模型管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 账户 +roles: [root, owner, admin, member] +router_paths: ["/account/model"] +--- + +# 模型管理 — 模块概览 + +## 1. 模块业务概述 + +模型管理模块是 FastGPT 账户中心下的核心功能模块,为用户提供 AI 大模型的全生命周期管理能力。本模块面向**所有已登录用户**,承担模型浏览、配置管理、渠道调度和用量监控四大职能。 + +普通用户可在"活跃模型"Tab 中浏览系统已启用的模型列表(按基础模型/自定义模型分类),在"配置模型"Tab 中查看所有系统模型的状态和归属信息。root 用户额外可见"渠道管理"、"渠道日志"和"模型监控"三个管理 Tab,用于管理 API 渠道(如 OpenAI 代理)、查看请求日志和监控模型调用数据。 + +模块通过顶部 MyTabs 选项卡组织 5 个子能力页面,所有内容包裹在 `AccountContainer` 账户统一布局中。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root | 全部 5 个 Tab(活跃模型、配置模型、渠道管理、渠道日志、模型监控) | 浏览模型、管理配置、创建/编辑/删除渠道、启用/禁用渠道、查看日志详情、设置默认模型、模型训练管理、Json 配置导入 | root 用户是系统超级管理员,拥有所有管理权限,额外可见渠道和监控功能 | +| owner/admin | 活跃模型、配置模型 | 浏览模型列表、查看配置模型详情、创建新模型(如有 `hasModelCreatePer` 权限)、测试模型 | 不能管理渠道、查看日志和监控 | +| member | 活跃模型、配置模型 | 浏览模型列表、查看配置模型详情 | 不能创建模型和修改配置 | + +**权限模型**:基于 `userInfo.username === 'root'` 判断 root 身份;模型创建权限通过 `userInfo.team.permission.hasModelCreatePer` 控制;渠道相关功能通过 `feConfigs.show_aiproxy` 功能开关 + root 身份双重控制。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户布局(AccountContainer) | 统一侧边栏导航、账户页面容器 | 组件 | +| 用户状态管理(useUserStore) | 用户信息、root 判断、团队权限位 | Store | +| 系统配置(useSystemStore) | feConfigs 功能开关、模型提供商列表、默认模型配置 | Store | +| AI 模型配置服务(@/web/core/ai/config) | 系统模型 CRUD API、模型测试 API | API | +| AI 渠道服务(@/web/core/ai/channel) | 渠道 CRUD API、日志查询 API、Dashboard 数据 API | API | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ModelTable 模型表格组件 | 价格页面(price/Points) | 组件 | +| ModelPriceModal 模型价格弹窗 | 模型引用弹窗(跨页面复用) | 组件 | +| getSystemModelList API | 工作流节点中的模型选择器、应用编辑页 | API | +| PriceTiersLabel 阶梯价格标签 | 模型列表页、配置表 | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模型管理 | /account/model | `pages/account/model/index.tsx` | 全部 | 含 5 个 Tab(详见 [09-业务流程索引](./业务流程索引.md)),通过 MyTabs 切换 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 活跃模型 | 活跃模型 Tab | [08-模块概览](../活跃模型/模块概览.md) | +| 配置模型 | 配置模型 Tab | [08-模块概览](../配置模型/模块概览.md) | +| 渠道管理 | 渠道管理 Tab [root 可见] | [08-模块概览](../渠道管理/模块概览.md) | +| 渠道日志 | 渠道日志 Tab [root 可见] | [08-模块概览](../渠道日志/模块概览.md) | +| 模型监控 | 模型监控 Tab [root 可见] | [08-模块概览](../模型监控/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f964daeb2af1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,42 @@ +--- +capability_label: 模型监控 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:30:00Z" +parent_module: 模型管理 +roles: [root] +router_paths: ["/account/model"] +--- + +# 模型监控 — 业务流程索引 + +## 模型调用数据监控 + +- **业务描述**:root 用户在模型监控 Tab 中查看 AI 模型调用的可视化监控数据,包括请求量趋势、错误率、Token 消耗、AI 积分花费、响应时间、并发峰值和缓存命中情况。支持图表和表格两种呈现方式,可按时间范围、渠道、模型维度灵活筛选。 +- **触发入口**:账户中心 → 模型管理 → 点击顶部"模型监控" Tab +- **路由路径**:`/account/model`(Tab 值:`account_model`) +- **页面组件位置**:`pageComponents/account/model/ModelDashboard/index.tsx` +- **涉及角色**:root +- **前置条件**:root 用户已登录;系统 `show_aiproxy` 功能开关已启用 + +## 模型监控表格查看 + +- **业务描述**:在表格视图下查看按模型或渠道维度聚合的监控数据,支持按总调用量、失败调用量、AI 积分消耗、缓存命中率排序,可点击"详情"按钮跳回图表视图并按指定模型筛选。 +- **触发入口**:模型监控页面 → 点击右上角"表格"切换按钮 +- **路由路径**:`/account/model`(Tab 值:`account_model`) +- **页面组件位置**:`pageComponents/account/model/ModelDashboard/DataTableComponent.tsx` +- **涉及角色**:root +- **前置条件**:已在模型监控 Tab 页面 + +## 场景关系 + +- **模型调用数据监控(图表视图)** → **模型监控表格查看**:用户在图表视图中查看趋势概览后,可切换到表格视图查看各模型的详细汇总数据,表格中点击"详情"可返回图表视图并自动筛选该模型。 + +各场景之间存在双向切换关系,用户可自由在图表和表格视图之间导航。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 模型调用数据监控 | — | 模型管理→模型监控Tab | /account/model | root | +| S02 | 模型监控表格查看 | — | 监控页→表格切换按钮 | /account/model | root | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e9098f6693d1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,180 @@ +--- +capability_label: 模型监控 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:30:00Z" +parent_module: 模型管理 +roles: [root] +router_paths: ["/account/model"] +--- + +# 模型监控 — 业务流程详解 + +## 页面总览 + +模型监控页面为 root 管理员提供 AI 模型调用数据的多维度可视化分析。页面默认以图表视图展示监控数据,用户可通过顶部筛选栏选择时间范围、渠道和模型,并可在图表和表格两种视图间自由切换。 + +### 场景 S01:模型调用数据监控(图表视图) + +> 业务描述:用户进入模型监控 Tab 后,默认展示图表视图,包含多个图表面板展示不同维度的监控指标。用户可通过筛选条件聚焦特定数据范围。 + +#### 步骤 1:页面初始化加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型管理页面顶部"模型监控" Tab | 1. GET `/api/aiproxy/api/channels/all`(获取渠道列表)
2. POST `/core/ai/model/list`(获取系统模型列表)
3. GET `/api/aiproxy/api/dashboardv2/`(获取监控数据,参数:默认最近 24 小时、timespan=hour、无渠道和模型筛选) | — | 页面显示 MyBox 加载遮罩 → 渠道下拉框和模型下拉框填充选项 → 图表区域渲染 5~7 个面积图面板 | + +三个 API 为并行请求,分别负责填充筛选器选项和渲染图表数据。页面使用 `useRequest` hook 管理异步请求状态。 + +数据加载详情: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET `/api/aiproxy/api/dashboardv2/` | 默认最近 24 小时(start_timestamp/end_timestamp)、timezone=浏览器时区、timespan=hour | 1. 补全缺失时间段(根据 timespan 生成完整周期列表)
2. 聚合每个时间点的 summary 数据(请求数、异常数、Token、耗时等)
3. 按 modelPriceMap 计算 AI 积分花费
4. 区分 LLM 模型(用于缓存命中率计算) | 图表视图展示 5~7 个面积图面板 | +| 渠道列表加载 | GET `/api/aiproxy/api/channels/all` | page=1, perPage=10 | 按状态排序(活跃优先),再按优先级降序;前置"全部"选项 | 渠道筛选下拉框 | +| 模型列表加载 | POST `/core/ai/model/list` | 无 | 按模型提供商 order 排序;前置"全部"选项 | 模型筛选下拉框 | + +- **分页参数**:Dashboard V2 数据不涉及前端分页,按时间范围一次性返回(时间点数 = 时间段跨度 / timespan 粒度) +- **排序规则**:图表数据按时间戳升序排列 +- **筛选条件**:时间范围选择器、渠道下拉框、模型下拉框、时间粒度选择器(自动按时间跨度调整可用选项) + +#### 步骤 2:切换时间范围 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 DateRangePicker 中选择新的起止时间 | GET `/api/aiproxy/api/dashboardv2/`(参数更新:新的 start_timestamp/end_timestamp) | 时间跨度 ≤ 1 天时,时间粒度选项仅显示"分钟"和"小时";时间跨度 ≥ 1 天时,选项显示"小时"和"天" | DateRangePicker 显示新日期 → 时间粒度下拉框选项自动更新 → 图表数据重新加载(显示加载中状态)→ 所有图表面板刷新数据 | + +DateRangePicker 默认显示最近 24 小时(`from` = 24 小时前,整分;`to` = 1 小时后,整分)。切换时间范围后,系统自动计算新的时间跨度(小时数),并按以下规则调整时间粒度和默认值: + +- 跨度 < 1 小时 → 默认粒度"分钟" +- 跨度 < 2 天 → 默认粒度"小时" +- 跨度 ≥ 2 天 → 默认粒度"天" + +#### 步骤 3:筛选渠道和模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在渠道下拉框中选择特定渠道 | GET `/api/aiproxy/api/dashboardv2/`(参数更新:channel=渠道ID) | — | 渠道下拉框显示选中项 → 图表数据重新加载(按所选渠道过滤) | +| 在模型下拉框中选择特定模型 | GET `/api/aiproxy/api/dashboardv2/`(参数更新:model=模型名) | 选择了单个模型时:额外显示"最大 RPM"和"最大 TPM"两个图表面板、缓存命中分析仅 LLM 模型时显示;未选择模型时:不显示 RPM/TPM 面板 | 模型下拉框显示选中项 → 图表数据重新加载 → 图表区域面板数量动态变化 | + +渠道和模型的筛选可组合使用。下拉框均支持搜索功能(`isSearch`),用户可输入关键词快速定位选项。 + +#### 步骤 4:切换 Token 用量维度 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 Token 用量图表面板的子 Tab 中切换维度(总量/输入/输出) | 无(纯前端切换) | — | Token 用量图表的折线数据切换到所选维度(totalTokens/inputTokens/outputTokens),图表累加模式保持启用 | + +#### 步骤 5:查看各监控图表 + +页面默认展示以下图表面板(无需额外操作): + +1. **模型请求次数**:面积图,启用累加模式,展示 `totalCalls` 数据,色值为主题色 primary.600 +2. **模型错误请求次数**(左)+ **模型错误率**(右):双列网格布局,不启用累加模式;错误次数色值 #f98e1a,错误率色值 #e84738 +3. **Token 用量**:面积图,启用累加模式,顶部左侧含输入/输出/总量三个子 Tab 切换 +4. **AI 积分消耗**:面积图,启用累加模式,色值 #8774EE;仅在 `feConfigs.isPlus` 为 true 时显示 +5. **平均响应时间**(左)+ **平均首 Token 时间**(右):双列网格布局,不启用累加模式;响应时间色值 #36B37E,TTFB 色值 #FF5630;数值精确到小数点后 2 位 +6. **最大 RPM**(左)+ **最大 TPM**(右):双列网格布局,仅在筛选了单个模型时显示;RPM 色值 #6554C0,TPM 色值 #FF8B00 +7. **缓存命中分析**:面积图,启用累加模式,展示缓存命中率 + 缓存命中次数两条折线;仅在未筛选模型或筛选的模型为 LLM 类型时显示 + +### 场景 S02:模型监控表格查看 + +> 业务描述:用户从图表视图切换到表格视图,以表格形式查看按模型或渠道维度聚合的汇总数据,支持排序和详情跳转。 + +#### 步骤 1:切换到表格视图 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右上角"表格"切换按钮 | 无(仅切换视图模式) | — | FillRowTabs 切换到"表格" → 图表区域隐藏 → 表格组件渲染 | + +#### 步骤 2:查看表格数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 表格视图自动渲染 | 无(基于已加载的 dashboardData 数据聚合) | 存在模型筛选时:按渠道维度聚合数据,显示"渠道"列;无模型筛选时:按模型维度聚合数据 | 表格显示聚合后的行数据 | + +数据加载详情: + +| 加载阶段 | 数据来源 | 聚合维度 | 排序规则 | 渲染结果 | +|---------|---------|---------|---------|---------| +| 表格渲染 | 使用已获取的 `dashboardData`(纯前端聚合) | 按模型或有模型筛选时按渠道 | 默认按总调用量降序 | 表格含模型名/渠道名、总调用量、失败量、AI积分、平均响应时间、平均TTFB、缓存命中率、"详情"按钮 | + +- **排序规则**:默认按总调用量降序,点击列头可在总调用量/失败量/AI 积分/缓存命中率之间切换排序,再次点击同列切换升降序 +- **筛选条件**:表格使用与图表相同的时间范围和筛选条件(由 filterProps 传递) +- **特殊标识**:平均响应时间 > 10 秒时,数值以黄色高亮显示(`yellow.700`);非 LLM 模型的缓存命中率列显示"-" + +#### 步骤 3:排序操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表格列头(总调用量/失败量/AI积分/缓存命中率) | 无(前端排序) | 点击当前排序列:切换升降序方向;点击新列:切换到该列,默认降序 | 列头显示排序方向箭头(↓/↑)→ 表格行重新排列 | + +#### 步骤 4:查看模型详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某行的"详情"按钮 | 无(前端状态更新) | — | 视图模式自动切换回图表 → 筛选条件更新(model 设为该行模型名)→ 图表数据按新条件重新加载 | + +点击"详情"按钮后,系统调用父组件的 `handleViewDetail` 函数,将筛选条件更新为指定模型并自动切回图表视图,用户可在图表视图中查看该模型的详细趋势数据。 + +## Mermaid 附录 + +### 场景 S01:模型调用数据监控 + +```mermaid +sequenceDiagram + participant User as root用户 + participant UI as 模型监控页面 + participant API as 后端API + + User->>UI: 点击"模型监控"Tab + par 并行请求 + UI->>API: GET /api/aiproxy/api/channels/all + API-->>UI: 渠道列表(按状态/优先级排序) + and + UI->>API: POST /core/ai/model/list + API-->>UI: 系统模型列表(按provider排序) + and + UI->>API: GET /api/aiproxy/api/dashboardv2/(默认24h, hour) + API-->>UI: Dashboard监控数据 + end + UI-->>User: 渲染筛选栏 + 5~7个面积图面板 + + User->>UI: 选择新时间范围 + UI->>UI: 自动计算timespan粒度 + UI->>API: GET /api/aiproxy/api/dashboardv2/(新时间范围) + API-->>UI: 更新后监控数据 + UI-->>User: 所有图表刷新 + + User->>UI: 选择渠道/模型筛选 + UI->>API: GET /api/aiproxy/api/dashboardv2/(带筛选参数) + API-->>UI: 过滤后监控数据 + UI-->>User: 图表刷新,面板数量动态调整 + + User->>UI: 切换Token维度Tab + UI-->>User: 图表折线切换至所选维度 +``` + +### 场景 S02:模型监控表格查看 + +```mermaid +sequenceDiagram + participant User as root用户 + participant UI as 模型监控页面 + participant API as 后端API + + User->>UI: 点击"表格"视图切换 + UI->>UI: 前端聚合dashboardData(按模型/渠道) + UI-->>User: 显示表格(默认按总调用量降序) + + User->>UI: 点击列头排序 + UI->>UI: 前端排序 + UI-->>User: 表格行重新排列 + + User->>UI: 点击某行"详情"按钮 + UI->>UI: 自动切换回图表视图 + UI->>UI: 更新筛选条件(model=该行模型名) + UI->>API: GET /api/aiproxy/api/dashboardv2/(指定模型) + API-->>UI: 该模型的监控数据 + UI-->>User: 图表视图显示该模型详细趋势 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..754ec46de337 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: 模型监控 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:30:00Z" +parent_module: 模型管理 +roles: [root] +router_paths: ["/account/model"] +--- + +# 模型监控 — 模块概览 + +## 1. 模块业务概述 + +模型监控是模型管理模块下的一个管理 Tab,为 root 用户提供 AI 模型调用数据的可视化监控能力。本模块以图表和表格两种视图展示模型调用的核心指标,包括请求量、错误率、Token 消耗、AI 积分花费、响应时间、缓存命中率等,帮助管理员实时掌握模型服务的运行状态和资源消耗情况。 + +本模块仅对 root 用户可见(需同时满足 `show_aiproxy` 功能开关启用),属于管理员专属的数据分析工具。模块提供图表视图(默认)和表格视图两种查看模式,支持按时间范围、渠道和模型维度进行筛选,时间粒度可根据时间跨度自动调整为分钟/小时/天。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root | 模型监控 Tab | 查看图表视图、切换表格视图、按时间范围/渠道/模型筛选、切换 Token 用量维度、按表格列排序 | root 用户唯一可见此 Tab,其他角色在父页面导航中不显示此入口 | +| owner/admin/member | 不可见 | — | 模型监控 Tab 不在导航列表中渲染 | + +**权限模型**:通过父页面 `ModelProvider` 组件中的 `isRoot && feConfigs?.show_aiproxy` 双重条件控制导航 Tab 的可见性。`isRoot` 由 `userInfo.username === 'root'` 判定,`show_aiproxy` 来自 `useSystemStore` 中的 `feConfigs` 系统配置。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| AI 渠道服务(@/web/core/ai/channel) | 渠道列表获取、Dashboard V2 监控数据查询 | API | +| AI 模型配置服务(@/web/core/ai/config) | 系统模型列表获取 | API | +| 系统配置(useSystemStore) | feConfigs 功能开关(isPlus 积分展示控制)、模型提供商信息 | Store | +| 共享图表组件(AreaChartComponent) | 面积图渲染,支持多折线、累加模式、自定义 Tooltip | 组件 | +| 共享 UI 组件库 | MyBox 加载容器、DateRangePicker 日期范围选择器、MySelect 下拉选择器、FillRowTabs 选项卡切换 | 组件 | + +## 4. 对外共享 + +本模块为纯消费型模块,不对外提供可复用的组件或能力。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模型管理 → 模型监控 Tab | /account/model | `pageComponents/account/model/ModelDashboard/index.tsx` | root | 含图表/表格视图切换,无独立子 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ef016a9ee0f8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,103 @@ +--- +capability_label: "活跃模型" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — 业务流程索引 + +## 业务场景列表 + +### 查看基座模型列表 + +- **业务描述**: 在「活跃模型」页面的基座模型 Tab 中,查看系统中所有官方提供的基座模型,按模型类型和供应商筛选,按训练任务数量排序。 +- **触发入口**: 账户 → 模型管理 → 活跃模型 Tab(默认激活) +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/index.tsx`(ModelTable,activeTab='base') +- **涉及角色**: root、团队管理员、团队成员 +- **前置条件**: 用户已登录团队账户 + +### 查看自定义模型列表 + +- **业务描述**: 切换到自定义模型 Tab,查看团队自行训练的微调模型列表,支持按训练时间排序和训练详情查看。 +- **触发入口**: 活跃模型页面 → 点击「自定义模型」Tab +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/index.tsx`(ModelTable,activeTab='custom') +- **涉及角色**: root、团队管理员、团队成员 +- **前置条件**: 用户已登录团队账户 + +### 筛选和排序模型 + +- **业务描述**: 通过供应商筛选、模型类型筛选、关键词搜索三个维度快速定位目标模型;可对训练任务数量或训练时间进行升降序排序。 +- **触发入口**: 模型列表顶部的筛选栏 +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/ModelFilterBar.tsx`、`projects/app/src/components/core/ai/ModelTable/ModelListTable.tsx` +- **涉及角色**: root、团队管理员、团队成员 +- **前置条件**: 已在基座模型或自定义模型 Tab 中 + +### 查看训练详情 + +- **业务描述**: 点击自定义模型的「训练详情」按钮,右侧弹出抽屉展示该模型的训练任务列表和历史记录。 +- **触发入口**: 自定义模型列表行 → 操作列 → 训练详情按钮 +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/TrainDetailDrawer.tsx` +- **涉及角色**: root、团队管理员、团队成员 +- **前置条件**: 当前处于自定义模型 Tab;该模型为可训练类型(embedding/reRank) + +### 创建模型训练任务 + +- **业务描述**: 选择一个基座模型,指定训练数据集和新模型名称,提交训练任务生成微调模型。 +- **触发入口**: 基座模型或自定义模型列表行 → 操作列 → 训练按钮;训练详情抽屉 → 创建训练按钮 +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/BaseModelTrainModal.tsx` +- **涉及角色**: root、团队管理员 +- **前置条件**: 当前用户有管理权限;所选基座模型支持训练(supportTrain);有可用的知识库数据集 + +### 批量配置模型权限 + +- **业务描述**: 勾选多个有管理权限的模型后,底部浮出批量操作栏,可统一修改所选模型的团队协作权限。 +- **触发入口**: 勾选模型复选框 → 底部浮出操作栏 → 权限配置按钮 +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/BatchPermissionActionBar.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 权限配置模式开启(permissionConfig=true);选中了至少一个有管理权限的模型 + +### 设置默认模型 + +- **业务描述**: root 用户点击「默认模型」按钮,在弹出的对话框中为各类型(LLM/TTS/Embedding/STT/ReRank)指定系统默认使用的模型。 +- **触发入口**: 活跃模型页面右上角 → 默认模型按钮(仅 root 可见) +- **路由路径**: `/account/model` +- **页面组件位置**: `projects/app/src/components/core/ai/ModelTable/DefaultModelModal.tsx` +- **涉及角色**: root +- **前置条件**: 当前登录用户为 root(username === 'root') + +--- + +## 场景关系 + +- **查看基座模型列表** → **创建模型训练任务**: 在基座模型列表中选择一个模型进行训练。 +- **查看自定义模型列表** → **查看训练详情**: 在自定义模型列表中点击训练详情,查看该模型的训练记录。 +- **查看训练详情** → **创建模型训练任务**: 在训练详情抽屉中可继续发起新的训练任务。 +- **查看基座/自定义模型列表** → **筛选和排序模型**: 筛选和排序贯穿于两个 Tab 的列表浏览过程。 +- **筛选和排序模型** → **批量配置模型权限**: 筛选出目标模型后,批量选择进行权限配置。 +- 各场景独立执行,无严格先后关系。 + +--- + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 查看基座模型列表 | — | 账户→模型管理→活跃模型(默认) | /account/model | root、团队管理员、团队成员 | +| S01-1 |  ├ 筛选和排序基座模型 | 基座模型 | 基座模型Tab → 筛选栏 | /account/model | root、团队管理员、团队成员 | +| S01-2 |  ├ 创建模型训练任务 | 基座模型 | 基座模型行 → 操作列 → 训练 | /account/model | root、团队管理员 | +| S01-3 |  ├ 批量配置权限 | 基座模型 | 勾选模型 → 底部操作栏 → 权限配置 | /account/model | 团队管理员 | +| S02 | 查看自定义模型列表 | — | 活跃模型 → 切换自定义模型Tab | /account/model | root、团队管理员、团队成员 | +| S02-1 |  ├ 筛选和排序自定义模型 | 自定义模型 | 自定义模型Tab → 筛选栏 | /account/model | root、团队管理员、团队成员 | +| S02-2 |  ├ 查看训练详情 | 自定义模型 | 自定义模型行 → 操作列 → 训练详情 | /account/model | root、团队管理员、团队成员 | +| S02-3 |  ├ 创建模型训练任务 | 自定义模型 | 自定义模型行 → 操作列 → 训练 | /account/model | root、团队管理员 | +| S03 | 设置默认模型 | — | 活跃模型 → 右上角默认模型按钮 | /account/model | root | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..0f3c75b00cba --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,376 @@ +--- +capability_label: "活跃模型" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — 业务流程详解 + +## 页面总览 + +活跃模型页面是模型管理的核心视图,通过基座模型和自定义模型两个 Tab 呈现团队的完整模型资产。页面以表格为主体,上方提供供应商、模型类型、关键词三维筛选,表格列根据 Tab 不同动态展示训练信息或计费信息。管理员可在此页面完成模型训练、权限配置、默认模型设置等关键运维操作。 + +--- + +### 查看基座模型列表 + +> 业务描述:进入活跃模型页面时默认展示基座模型 Tab,以表格形式列出系统所有官方基座模型,含模型名称、类型标签、计费价格、训练任务数量等信息。 + +#### 步骤 1:页面加载与数据获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 通过侧边栏导航进入「账户 → 模型管理」页面 | 无(数据由父组件通过 Store 传入) | — | 页面渲染,显示基座模型 Tab 激活状态 | + +> 说明:模型列表数据(llmModelList、embeddingModelList、ttsModelList、sttModelList、reRankModelList)由系统初始化时通过 `getInitData` API 一次性获取并存储在 `useSystemStore` 中。活跃模型组件从 Store 读取并经过本地筛选、排序后渲染,不额外发起列表请求。 + +#### 步骤 2:基座模型列表渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载完成,等待数据就绪 | 无(纯客户端计算) | 模型列表为空时显示空状态提示 | 表格渲染:模型名、模型类型筛选下拉、计费价格列、训练任务数列(可点击排序) | + +**数据加载详情**: + +| 加载阶段 | 数据来源 | 关键处理 | 渲染结果 | +|---------|---------|---------|---------| +| 首次加载 | useSystemStore 中的模型列表 | 过滤 `isTuned !== true` 的模型(排除已微调),按供应商和模型类型筛选,按训练任务数量排序 | 基座模型表格 | +| Tab 切换 | 同上(已在内存中) | 切换筛选条件为自定义模型 Tab 的筛选状态,过滤 `isTuned === true` | 自定义模型表格 | +| 刷新 | 调用 `clientInitData()` 强制重新获取全局数据 | 静默更新,表格数据自动刷新 | 表格数据更新 | + +- **分页**:前端全量渲染,不涉及分页 +- **排序规则**:训练任务数量列支持点击切换升降序(默认无排序 → 降序 → 升序 → 无排序);自定义模型 Tab 的训练时间列同样支持排序 +- **筛选条件**:供应商下拉(按模型实际归属的供应商动态生成)、模型类型下拉(LLM/TTS/Embedding/STT/ReRank)、关键词搜索(匹配模型名称) + +--- + +### 查看自定义模型列表 + +> 业务描述:用户切换到自定义模型 Tab,查看团队通过训练生成的微调模型,含训练时间、训练人、训练数据等专属列。 + +#### 步骤 1:切换 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「自定义模型」Tab 标签 | 无(纯客户端切换) | — | Tab 高亮切换为「自定义模型」;表格列重新布局 | + +#### 步骤 2:自定义模型列表渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待列表计算完成 | 无(纯客户端过滤) | 自定义模型为空时显示空状态 | 表格渲染:模型名、模型类型筛选、训练时间(可排序)、训练人、训练数据列 | + +**数据加载详情**: + +| 加载阶段 | 数据来源 | 关键处理 | 渲染结果 | +|---------|---------|---------|---------| +| 首次进入 | useSystemStore 中的模型列表 | 过滤 `isTuned === true` 的模型 | 自定义模型表格 | +| 加载训练数据关联 | GET `/core/dataset/list`(通过 getDatasetsWithChildren) | 获取知识库树,构建 datasetId → 知识库名称映射,用于渲染训练数据列 | 训练数据列显示关联知识库名称 | + +--- + +### 筛选和排序模型 + +> 业务描述:用户通过供应商、模型类型下拉框和关键词搜索框筛选模型,点击表头排序图标切换排序。 + +#### 步骤 1:供应商筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击供应商下拉,选择一个供应商 | 无(客户端筛选) | 选择「全部」时不过滤 | 表格仅显示该供应商的模型;若该供应商无模型则显示空状态 | + +#### 步骤 2:模型类型筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型类型下拉,选择一种类型 | 无(客户端筛选) | 选择「全部类型」时不过滤 | 表格仅显示该类型的模型;空选项自动排除 | + +#### 步骤 3:关键词搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框输入关键词 | 无(客户端筛选) | 输入为空时不过滤 | 表格实时过滤,仅显示名称匹配关键词的模型 | + +#### 步骤 4:排序切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「训练任务数」列表头 | 无(客户端排序) | 无排序 → 降序 → 升序 → 无排序,循环切换 | 排序图标切换状态(灰色→蓝色),表格数据重新排列 | +| 点击「训练时间」列表头(仅自定义 Tab) | 无(客户端排序) | 同上循环逻辑 | 同上 | + +--- + +### 查看训练详情 + +> 业务描述:在自定义模型列表中,点击某个模型的「训练详情」按钮,右侧弹出抽屉展示该模型的训练任务历史。 + +#### 步骤 1:打开训练详情抽屉 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击自定义模型行操作列的「训练详情」按钮 | POST `/core/train/embedding/task/list` 或 POST `/core/train/rerank/task/list`(根据模型类型) | — | 右侧滑出抽屉,加载中显示加载状态 | + +#### 步骤 2:查看训练任务列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载完成 | 同上(TrainDetailDrawer 内 useRequest 自动发起) | 训练任务为空时显示空状态 | 抽屉内展示训练任务列表,每条含任务状态、创建时间、进度等信息 | + +#### 步骤 3:关闭抽屉 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击关闭按钮或遮罩 | 无 | — | 抽屉关闭,回到列表视图 | + +```mermaid +sequenceDiagram + participant User + participant UI as 模型列表 + participant API + participant Backend + + User->>UI: 点击训练详情按钮 + UI->>API: POST /core/train/{type}/task/list + API->>Backend: 查询训练任务列表 + Backend-->>API: 返回任务列表 + API-->>UI: 返回训练任务数据 + UI-->>User: 右侧抽屉展示训练任务列表 +``` + +--- + +### 创建模型训练任务 + +> 业务描述:用户选择一个基座模型,在弹窗中选择训练数据集,输入新模型名称,提交训练任务。 + +#### 步骤 1:打开训练弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行操作列的「训练」按钮 | — | 仅对支持训练(supportTrain)的可训练类型模型显示训练按钮 | 弹出训练弹窗,显示加载状态(获取知识库树) | + +#### 步骤 2:加载知识库数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待弹窗加载 | GET `/core/dataset/list`(getDatasetsWithChildren) | 加载失败时弹出错误提示「操作失败」 | 知识库树加载完成,显示可选数据集列表 | + +#### 步骤 3:配置训练参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在「新模型名称」输入框输入名称 | 无 | 自动生成默认名称(基座模型名-日期-随机数),仅首次填入 | 输入框显示名称 | +| 在知识库树中勾选训练数据集 | 无 | 正在处理中的知识库不可选(置灰 + 提示);embedding 训练时向量模型不匹配的知识库不可选 | 已选数据集计数更新;文件夹全选/半选图标更新 | + +#### 步骤 4:提交训练任务 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | POST `/core/train/embedding/task/create`(embedding 类型)或 POST `/core/train/rerank/task/create`(reRank 类型) | 提交失败时弹出错误提示「操作失败」 | 按钮显示加载状态;成功后弹出成功提示,弹窗关闭,调用 `clientInitData()` 刷新全局数据 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 基座模型类型 | 自动确定 | ✅ | 来自入口参数或默认 reRank | embedding / reRank | ✅ 只读 | 由训练入口决定 | +| 基座模型 | 自动确定 | ✅ | 来自入口参数 | 系统内置的可训练基座模型 | ✅ 只读 | 排除已微调和不支持训练的模型 | +| 新模型名称 | 文本输入 | ✅ | 自动生成(基座模型名-日期-随机数) | 自定义字符串 | 否 | 自动填充仅首次生效 | +| 训练数据集 | 知识库树多选 | ✅ | 无 | 团队下所有知识库 | 否 | 处理中的知识库和向量模型不匹配的知识库不可选 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 知识库正在处理中 | 渲染时 | 「该知识库正在处理中,无法用于训练」 | +| 知识库向量模型不匹配(embedding 训练) | 渲染时 | 「该知识库的向量模型与所选基座模型不一致」 | + +**前后置条件**: +- **前置条件**:当前用户有管理权限;所选基座模型 `supportTrain` 为 true 且 `isTuned` 不为 true +- **后置影响**:创建训练任务后,系统异步执行训练,完成后生成微调模型出现在自定义模型列表中 +- **失败场景**:训练任务提交失败时,弹窗保持打开,显示错误提示,用户可修改参数后重试 + +```mermaid +sequenceDiagram + participant User + participant UI as 训练弹窗 + participant API + participant Backend + + User->>UI: 点击训练按钮 + UI->>API: GET /core/dataset/list(获取知识库树) + API-->>UI: 返回知识库树数据 + UI-->>User: 展示知识库树,自动填充模型名称 + User->>UI: 选择训练数据集,确认名称 + User->>UI: 点击确认 + par embedding 或 reRank 训练 + UI->>API: POST /core/train/embedding/task/create + else + UI->>API: POST /core/train/rerank/task/create + end + API->>Backend: 创建训练任务 + Backend-->>API: 返回任务创建成功 + API-->>UI: 返回成功 + UI->>UI: clientInitData() 刷新全局数据 + UI-->>User: 显示成功提示,关闭弹窗 +``` + +--- + +### 批量配置模型权限 + +> 业务描述:管理员勾选多个有管理权限的模型,通过底部浮出的批量操作栏统一修改这些模型的团队协作权限。 + +#### 步骤 1:进入权限配置模式并选择模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面以权限配置模式打开(permissionConfig=true) | 无 | 仅团队管理员可见复选框 | 模型列表每行出现复选框,表头出现全选框 | + +#### 步骤 2:勾选模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选一个或多个模型复选框 | 无 | 仅 `item.permission.hasManagePer` 为 true 的行可勾选;无可管理模型时不显示复选框 | 勾选行高亮;底部浮出批量操作栏,显示已选数量 | + +#### 步骤 3:配置权限 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击底部操作栏的「权限配置」按钮 | 无 | — | 弹出协作者选择弹窗(LazyCollaboratorProvider) | + +#### 步骤 4:选择协作者并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在弹窗中选择团队成员和角色 | 无 | — | 协作者列表实时更新 | +| 点击确认提交 | POST `/common/system/updateModelCollaborators` | 提交失败时弹出错误提示 | 弹窗关闭,模型列表刷新 | + +```mermaid +sequenceDiagram + participant User + participant UI as 模型列表 + participant API + participant Backend + + User->>UI: 勾选多个模型 + UI-->>User: 底部浮出批量操作栏 + User->>UI: 点击权限配置按钮 + UI-->>User: 弹出协作者选择弹窗 + User->>UI: 选择协作者和角色,确认提交 + UI->>API: POST /common/system/updateModelCollaborators + API->>Backend: 批量更新模型协作者权限 + Backend-->>API: 返回更新成功 + API-->>UI: 返回成功 + UI-->>User: 弹窗关闭,列表刷新 +``` + +--- + +### 设置默认模型 + +> 业务描述:root 用户在弹窗中为每种模型类型(LLM/TTS/Embedding/STT/ReRank)指定默认模型。 + +#### 步骤 1:打开设置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右上角「默认模型」按钮 | 无 | 仅 root 用户(username === 'root')可见此按钮 | 弹出默认模型设置弹窗 | + +#### 步骤 2:选择各类型默认模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在各模型类型区域的下拉选择器中选取模型 | 无(从 useSystemStore 读取模型列表填充选项) | — | 选中的模型实时高亮显示 | + +#### 步骤 3:保存设置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击保存按钮 | PUT `/common/system/updateDefaultModels` | 保存失败时弹出错误提示 | 弹窗关闭,调用 `clientInitData()` 刷新全局数据 | + +**前后置条件**: +- **前置条件**:当前登录用户为 root +- **后置影响**:系统默认模型更新后,所有新建应用将默认使用新设置的模型 + +```mermaid +sequenceDiagram + participant User as root用户 + participant UI as 默认模型弹窗 + participant API + participant Backend + + User->>UI: 点击默认模型按钮 + UI-->>User: 弹出各类型模型选择界面 + User->>UI: 为每种类型选择默认模型 + User->>UI: 点击保存 + UI->>API: PUT /common/system/updateDefaultModels + API->>Backend: 更新系统默认模型配置 + Backend-->>API: 返回更新成功 + API-->>UI: 返回成功 + UI->>UI: clientInitData() 刷新全局数据 + UI-->>User: 弹窗关闭,提示保存成功 +``` + +--- + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 活跃模型页面 + participant Store as useSystemStore + participant API + participant Backend + + User->>UI: 进入账户→模型管理→活跃模型 + UI->>Store: 读取模型列表数据 + Store-->>UI: 返回 llmModelList, embeddingModelList, ttsModelList, sttModelList, reRankModelList + UI-->>User: 渲染基座模型列表(默认Tab) + + opt 切换到自定义模型Tab + User->>UI: 点击自定义模型Tab + UI-->>User: 渲染自定义模型列表 + UI->>API: GET /core/dataset/list(加载训练数据名称映射) + API-->>UI: 返回知识库树 + end + + opt 筛选与排序 + User->>UI: 选择供应商/模型类型/输入关键词 + UI-->>User: 表格实时过滤更新 + end + + opt 创建训练任务 + User->>UI: 点击训练按钮 + UI-->>User: 弹出训练弹窗 + User->>UI: 选择数据集、确认名称、提交 + UI->>API: POST /core/train/{type}/task/create + API-->>UI: 返回成功 + UI-->>User: 提示成功,刷新列表 + end + + opt 查看训练详情 + User->>UI: 点击训练详情按钮 + UI->>API: POST /core/train/{type}/task/list + API-->>UI: 返回训练任务列表 + UI-->>User: 抽屉展示训练任务 + end + + opt 批量配置权限 + User->>UI: 勾选模型→权限配置→选择协作者 + UI->>API: POST /common/system/updateModelCollaborators + API-->>UI: 返回成功 + UI-->>User: 列表刷新 + end + + opt 设置默认模型(root) + User->>UI: 点击默认模型→选择各类型默认模型→保存 + UI->>API: PUT /common/system/updateDefaultModels + API-->>UI: 返回成功 + UI-->>User: 弹窗关闭 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..5d0956ce5cda --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,44 @@ +--- +capability_label: "活跃模型" +doc_type: "11" +doc_label: "共享能力清单" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — 共享能力清单 + +## 消费的外部能力 + +本模块依赖以下外部模块/包提供的能力: + +| 消费能力 | 来源模块/包 | 能力类型 | 使用方式 | +|---------|-----------|---------|---------| +| 模型列表数据(含各类型模型、供应商) | useSystemStore(系统全局 Store) | Store | 通过 `getModelProviders`、`getModelProvider`、`llmModelList` 等字段获取 | +| 用户信息和团队权限 | useUserStore(用户全局 Store) | Store | 通过 `userInfo.team.permission.hasManagePer` 等字段判断角色 | +| FillRowTabs 标签切换组件 | @fastgpt/web 通用组件库 | 组件 | 用于基座模型/自定义模型 Tab 切换 | +| MyModal 弹窗容器 | @fastgpt/web 通用组件库 | 组件 | 训练弹窗、默认模型弹窗的基础容器(dynamic import) | +| MySelect 下拉选择器 | @fastgpt/web 通用组件库 | 组件 | 供应商筛选、模型类型筛选下拉 | +| SearchInput 搜索输入框 | @fastgpt/web 通用组件库 | 组件 | 模型关键词搜索 | +| useTableMultipleSelect 多选 Hook | @fastgpt/web 通用 Hooks | Hook | 模型列表批量选择功能 | +| useRequest 请求 Hook | @fastgpt/web 通用 Hooks | Hook | 训练详情、知识库树等异步数据获取 | +| LazyCollaboratorProvider 协作者权限配置 | 权限管理模块(components/support/permission) | 组件 | 批量权限配置弹窗的协作者选择器 | +| AIModelSelector AI 模型选择器 | AI 模型选择器模块(components/core/ai) | 组件 | 默认模型弹窗中各类型的模型下拉选择 | +| 训练 API(embedding/reRank) | web/core/app/api/train | API | 创建训练任务、获取训练任务列表 | +| 知识库 API(getDatasetsWithChildren) | web/core/app/api/dataset | API | 获取团队知识库树 | +| 系统 API(updateModelCollaborators、putUpdateDefaultModels) | web/common/system/api | API | 更新模型协作者、更新默认模型配置 | +| 全局数据刷新(clientInitData) | web/common/system/staticData | 工具函数 | 训练/权限/默认模型变更后刷新全局缓存 | + +## 对外共享能力 + +本模块向外部提供以下能力: + +| 共享能力 | 消费方 | 能力类型 | 说明 | +|---------|--------|---------|------| +| ModelTable 完整模型表格 | Points(积分页面)、ValidModelTable(有效模型表) | 页面组件 | 以 `permissionConfig` prop 控制是否显示权限管理列 | +| ModelPriceModal 模型价格弹窗 | 外部调用方(通过 children render prop) | 弹窗组件 | 以 render prop 模式提供,调用方通过 `children({ onOpen })` 控制触发 | +| ModelReferenceModal 模型引用提示 | AddModelBox(添加模型弹窗)、ModelConfigTable(模型配置表) | 弹窗组件 | 当模型权限更新被现有引用阻止时展示引用详情 | +| useModelTableState 状态管理 Hook | ModelTable 组件内部使用 | Hook | 封装 Tab 切换、筛选、排序、多选、弹窗控制等全部状态逻辑 | +| 模型过滤/排序/选项生成工具函数 | ModelTable 系列组件 | 工具函数 | `getFilteredModelList`、`getSortedModelList`、`getProviderOptions`、`getFilteredProviderList`、`getModelTypeOptions` | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..531c1b92a01f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,61 @@ +--- +capability_label: "活跃模型" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — 模块概览 + +## 1. 模块业务概述 + +活跃模型模块是「账户 → 模型管理」页面下的核心 Tab,用于集中展示和管理团队中所有处于活跃状态的 AI 模型。该模块以双 Tab(基座模型 / 自定义模型)的表格视图呈现模型的完整生命周期管理能力,覆盖模型查看、筛选、排序、训练、权限配置、默认模型设置等日常运维场景。 + +本模块面向三类角色:**root 用户**可设置系统级默认模型;**团队管理员**可批量配置模型的团队协作权限;**普通团队成员**可浏览模型列表、查看训练详情和价格信息。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root 用户 | 模型管理 → 活跃模型 | 查看全部模型列表、筛选/排序、设置默认模型、查看训练详情、创建训练任务 | 可见「默认模型」按钮,可设置系统级默认模型 | +| 团队管理员 | 模型管理 → 活跃模型 | 查看有管理权限的模型、批量配置权限、查看训练详情、创建训练任务 | 可见批量权限操作栏,模型列表中显示复选框和权限列 | +| 团队成员 | 模型管理 → 活跃模型 | 浏览模型列表、查看训练详情、查看价格信息 | 仅可查看,无管理操作入口 | + +**权限模型**: 基于 `TeamPermission` 的团队权限体系。组件通过 `userInfo.team.permission.hasManagePer` 判断当前用户是否为团队管理员;通过 `userInfo.username === 'root'` 判断是否为 root 用户。模型级别通过 `item.permission.hasManagePer` 逐行控制复选框可用性。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统 Store(useSystemStore) | 获取模型供应商列表、各类型模型列表(LLM/TTS/Embedding/STT/ReRank) | Store | +| 用户 Store(useUserStore) | 获取当前用户信息和团队权限 | Store | +| 通用组件库(@fastgpt/web) | FillRowTabs 标签切换、MyModal 弹窗、MySelect 下拉、useTableMultipleSelect 多选 | 组件/Hook | +| 权限管理模块 | LazyCollaboratorProvider 协作权限配置、updateModelCollaborators API | 组件/API | +| 知识库模块 | getDatasetsWithChildren 获取知识库树(训练模型时选择训练数据) | API | +| 训练 API 层 | createEmbeddingTrainTask、createRerankTrainTask、getEmbeddingTrainTaskList、getRerankTrainTaskList | API | + +完整列表见 [共享能力清单](./共享能力清单.md)。 + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ModelTable 模型表格全功能组件 | Points 积分页、ValidModelTable 有效模型表 | 页面组件 | +| ModelPriceModal 模型价格弹窗 | 外部调用方(children render prop 模式) | 弹窗组件 | +| ModelReferenceModal 模型引用提示弹窗 | 添加模型弹窗(AddModelBox)、模型配置表(ModelConfigTable) | 弹窗组件 | +| useModelTableState Hook | ModelTable 组件内部使用 | Hook | + +完整列表见 [共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 账户模型管理 — 活跃模型 | /account/model | `projects/app/src/components/core/ai/ModelTable/index.tsx` | root / 团队管理员 / 团队成员 | 含 2 个 Tab(基座模型 / 自定义模型,详见 [业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..57e9dc66c79b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,42 @@ +--- +capability_label: 渠道日志 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 模型管理 +roles: [root] +router_paths: [/account/model] +--- + +# 渠道日志 — 业务流程索引 + +## 业务场景列表 + +### 渠道日志查询 + +- **业务描述**: root 管理员查看通过 AI 代理渠道转发的模型请求历史记录,支持按渠道、模型、请求状态和日期范围筛选。 +- **触发入口**: 账户管理 → 模型管理 → 点击「渠道日志」Tab +- **路由路径**: /account/model(父路由,Tab 切换至 channel_log) +- **页面组件位置**: projects/app/src/pageComponents/account/model/Log/index.tsx +- **涉及角色**: root +- **前置条件**: 当前用户为 root 且系统配置 `feConfigs.show_aiproxy` 为 true + +### 渠道日志详情查看 + +- **业务描述**: 在日志列表中点击某条记录的「详情」按钮,查看该次请求的完整信息,包括请求 ID、IP、EndPoint、状态码、耗时、Token 消耗、TTFB 时间以及请求/响应体。 +- **触发入口**: 渠道日志列表 → 点击某条记录的「详情」按钮 +- **路由路径**: /account/model(父路由,弹窗覆盖) +- **页面组件位置**: projects/app/src/pageComponents/account/model/Log/index.tsx(LogDetail 组件) +- **涉及角色**: root +- **前置条件**: 已在渠道日志列表中 + +## 场景关系 + +- **渠道日志查询** → **渠道日志详情查看**: 日志查询是详情查看的前置操作,用户需先在列表中定位目标记录,再点击详情按钮查看。 + +## 业务场景汇总 + +| 编号 | 场景名 | Tab | 触发入口 | 路由路径 | 角色 | +|------|--------|-----|---------|---------|------| +| S01 | 渠道日志查询 | — | 模型管理→渠道日志Tab | /account/model | root | +| S02 | 渠道日志详情查看 | — | 日志列表→详情按钮 | /account/model | root | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..5aa1111c438b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,165 @@ +--- +capability_label: 渠道日志 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 模型管理 +roles: [root] +router_paths: [/account/model] +--- + +# 渠道日志 — 业务流程详解 + +## 页面总览 + +渠道日志是模型管理页面中 root 管理员专属的 Tab 子页面。页面顶部提供多维筛选栏,下方以表格形式展示日志列表并支持无限滚动加载,点击单条记录可弹出详情弹窗查看请求完整信息。 + +本模块为叶子节点(无子 Tab),包含两个核心场景:日志查询和日志详情查看。 + +--- + +### 渠道日志查询 + +> 业务描述:root 管理员进入渠道日志 Tab 后,通过筛选条件搜索和浏览渠道请求日志。 + +#### 步骤 1:页面初始化 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入模型管理页面,点击「渠道日志」Tab | 并行发起:
① `GET /api/aiproxy/api/channels/all`(获取渠道列表)
② `POST /core/ai/model/list`(获取系统模型列表)
③ `GET /api/aiproxy/api/logs/search`(获取日志首页数据) | `isRoot && feConfigs.show_aiproxy` 为 true 时 Tab 才可见 | 显示筛选栏和数据表格,加载遮罩覆盖表格区域;渠道下拉和模型下拉在各自 API 返回后填充选项并移除加载态;日志表格显示第一页 20 条数据 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 渠道列表加载 | GET /api/aiproxy/api/channels/all | page=1, perPage=10 | 按 status 升序、priority 降序排列;转换为 `{label: name, value: id}` 格式,前置「全部」选项 | 渠道筛选下拉框 | +| 模型列表加载 | POST /core/ai/model/list | 无 | 按供应商 order 排序;转换为 `{label: model, value: model}` 格式(含供应商图标),前置「全部」选项 | 模型筛选下拉框 | +| 日志首次加载 | GET /api/aiproxy/api/logs/search | result_only=true, start_timestamp=昨天00:00, end_timestamp=今天23:59, p=1, per_page=20 | 数据格式化:计算 duration=created_at-request_at,匹配渠道名称和模型供应商图标,格式化时间戳 | 日志表格(前 20 条) | + +**分页参数**:默认每页 20 条,使用 `useScrollPagination` 无限滚动加载,滚动到底部自动加载下一页。 + +**排序规则**:按请求时间倒序(由后端 API 返回)。 + +**筛选条件**: +- **日期范围**(DateRangePicker):默认昨天 00:00 至今天 23:59 +- **渠道名称**(下拉搜索):可选全部或指定渠道 +- **模型名称**(下拉搜索):可选全部或指定模型 +- **请求状态**(下拉):全部 / 成功(code=200)/ 失败(code≠200) +- **Request ID**(文本搜索,仅 root):模糊搜索 request_id + +#### 步骤 2:筛选日志 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改任一筛选项(日期、渠道、模型、状态、Request ID) | `GET /api/aiproxy/api/logs/search`(携带更新后的筛选参数,页码重置为第 1 页) | 每次筛选条件变化时自动触发请求,`refreshDeps` 依赖 `filterProps` 整体对象 | 表格区域显示加载状态,数据刷新为筛选后的第一页结果;如无匹配数据则显示空列表 | + +#### 步骤 3:滚动加载更多 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动日志表格至底部 | `GET /api/aiproxy/api/logs/search`(page=N+1,其余筛选参数不变) | 仅当还有更多数据时(`total > 已加载条数`)触发;加载过程中底部显示加载指示器 | 新数据追加到表格末尾,表格总高度增加;若已加载全部数据则不再触发 | + +#### 步骤 4:特殊列的渲染 + +日志表格包含以下特殊渲染逻辑: + +| 列名 | 渲染规则 | +|------|---------| +| 模型 | 显示供应商图标 + 模型名称(图标通过 `getModelProvider` 从系统 Store 获取) | +| Token 消耗 | 显示 `input_tokens / output_tokens` 格式 | +| 耗时 | 显示秒数(保留 2 位小数);若耗时超过 10 秒,文字变为红色(`red.600`) | +| 状态码 | 200 时显示绿色(`green.600`),非 200 时显示红色(`red.600`);若 content 有值则附加 `QuestionTip` 图标提示 | +| 请求时间 | 经 `formatTime2YMDHMS` 格式化为 YYYY-MM-DD HH:mm:ss | + +**空列表展示**:当筛选结果为空时,表格区域不显示数据行。 + +**权限不足的表现**:非 root 用户不显示渠道日志 Tab。Request ID 搜索框仅在 root 用户下可见。 + +--- + +### 渠道日志详情查看 + +> 业务描述:在日志列表中点击某条记录查看完整的请求和响应信息。 + +#### 步骤 1:打开详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击日志列表中某条记录的「详情」按钮 | 状态码为 200 时:不发起额外请求,直接使用列表数据
状态码非 200 时:`GET /api/aiproxy/api/logs/detail/{id}` | 成功请求(code=200)通常已包含完整数据;失败请求需额外获取详情 | 弹出 Modal 弹窗(最大宽度 800px);弹窗内以两列网格布局展示基本信息 | + +#### 步骤 2:查看详情信息 + +基本信息网格展示以下字段(按行顺序): + +| 行 | 左列 | 右列 | +|----|------|------| +| 1 | Request ID | Request IP | +| 2 | 状态码(200 绿色 / 非200 红色) | Endpoint | +| 3 | 渠道名称 | 模型(图标+名称) | +| 4 | 请求时间 | 耗时(秒) | +| 5 | TTFB 时间(ms 或 -) | Token 消耗(input / output) | + +条件展示字段(仅在数据存在时显示): + +| 字段 | 展示条件 | +|------|---------| +| 重试次数 | `retry_times !== undefined` | +| Content(错误内容) | `content` 字段存在 | +| Request Body | `request_body` 字段存在(userSelect='all' 可全选复制) | +| Response Body | `response_body` 字段存在 | + +**错误处理**:获取详情 API 失败时,回退使用列表中的已有数据展示基本信息。 + +--- + +### Mermaid 附录 + +#### 渠道日志查询 + +```mermaid +sequenceDiagram + participant User as 管理员(root) + participant UI as 渠道日志页面 + participant API as 后端 API + + User->>UI: 点击「渠道日志」Tab + par 并行加载 + UI->>API: GET /api/aiproxy/api/channels/all + API-->>UI: 渠道列表(按状态/优先级排序) + UI->>API: POST /core/ai/model/list + API-->>UI: 系统模型列表 + end + UI->>API: GET /api/aiproxy/api/logs/search
(dateRange + page=1) + API-->>UI: 日志列表(前20条) + UI-->>User: 展示筛选栏 + 日志表格 + + User->>UI: 修改筛选条件 + UI->>API: GET /api/aiproxy/api/logs/search
(新筛选参数 + page=1) + API-->>UI: 筛选后的日志列表 + UI-->>User: 刷新表格 + + User->>UI: 滚动到底部 + UI->>API: GET /api/aiproxy/api/logs/search
(筛选参数不变 + 翻页) + API-->>UI: 下一页日志 + UI-->>User: 追加数据到表格末尾 +``` + +#### 渠道日志详情查看 + +```mermaid +sequenceDiagram + participant User as 管理员(root) + participant UI as 日志详情弹窗 + participant API as 后端 API + + User->>UI: 点击「详情」按钮 + alt 成功请求(code=200) + UI-->>User: 直接展示已有数据 + else 失败请求(code≠200) + UI->>API: GET /api/aiproxy/api/logs/detail/{id} + API-->>UI: 日志详情(含 request_body/response_body) + end + UI-->>User: 展示详情弹窗(网格布局) + User->>UI: 查看/复制请求体/响应体 + User->>UI: 关闭弹窗 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..7f8bdd260704 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: 渠道日志 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 模型管理 +roles: [root, admin] +router_paths: [/account/model] +--- + +# 渠道日志 — 模块概览 + +## 1. 模块业务概述 + +渠道日志模块为 **root 管理员** 提供 AI 代理渠道(AIProxy)的请求日志查询与详情查看能力。在 FastGPT 的账户管理体系中,当管理员配置了 AI 代理渠道后,该模块用于**监控和排查所有通过渠道转发的 AI 模型请求**,包括请求状态、耗时、Token 消耗等关键指标。 + +本模块是「模型管理」能力下的一个 Tab 子页面,与渠道管理、模型监控等能力配合使用,共同构成完整的 AI 代理渠道运维视图。渠道日志聚焦于**历史请求的追溯与问题定位**——管理员可在此快速发现失败请求、分析响应时间、查看请求/响应体详情。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root | 渠道日志列表页 + 日志详情弹窗 | 查看全部渠道日志、按渠道/模型/状态筛选、按日期范围筛选、按 request_id 搜索、查看请求/响应详情 | 拥有 request_id 精确搜索功能,可查看完整请求体和响应体 | +| 其他角色 | 不可见 | 无 | 渠道日志 Tab 仅在 `isRoot && feConfigs.show_aiproxy` 为 true 时显示,普通管理员不可访问 | + +**权限模型**: 页面级可见性控制。渠道日志入口的 Tab 渲染条件为 `isRoot && feConfigs?.show_aiproxy`,仅在当前用户名为 root 且系统配置开启了 AI 代理功能时展示。日志详情弹窗中请求体(request_body)和响应体(response_body)仅在接口返回数据时展示。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户/模型管理(父模块) | AccountContainer 布局容器、MyTabs Tab 切换 | 组件 | +| 共享组件库 (@fastgpt/web) | DateRangePicker、MySelect、MyBox、MyModal、MyIcon、SearchInput、QuestionTip | 组件 | +| 共享 Hooks (@fastgpt/web) | useRequest(数据请求)、useScrollPagination(无限滚动分页) | Hook | +| AI 渠道 API (@/web/core/ai/channel) | getChannelList、getChannelLog、getLogDetail | API | +| 系统 Store (useSystemStore) | getModelProvider(模型供应商信息)、systemModelList | Store | + +## 4. 对外共享 + +本模块为叶子节点(Tab 子页面),不对外暴露共享能力。其组件仅被父页面 `ModelProvider` 通过 `dynamic import` 引用,在 `tab === 'channel_log'` 时渲染。 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 无 | — | — | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模型管理(渠道日志 Tab) | /account/model | projects/app/src/pages/account/model/index.tsx(父页面)→ projects/app/src/pageComponents/account/model/Log/index.tsx(渠道日志组件) | root | 父页面含 5 个 Tab(模型/配置/渠道/渠道日志/监控),渠道日志为第 4 个 Tab,仅 root 可见 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a7b41a29756f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,107 @@ +--- +capability_label: 渠道管理 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:03:42Z" +parent_module: 模型管理 +roles: + - root +router_paths: + - /account/model +--- + +# 渠道管理 — 业务流程索引 + +## 业务场景 + +### 查看渠道列表 + +- **业务描述**: 查看系统中已配置的所有 AI 供应商渠道信息,包括渠道名称、类型、状态、优先级等。 +- **触发入口**: 进入模型管理页面,切换到"渠道"Tab +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/index.tsx` +- **涉及角色**: root +- **前置条件**: 用户为 root 且系统开启 `show_aiproxy` 配置 + +### 创建渠道 + +- **业务描述**: 新增一个 AI 供应商渠道,配置供应商类型、名称、API 密钥、基础 URL、可选模型及模型映射。 +- **触发入口**: 渠道列表页→"创建渠道"按钮 +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx` +- **涉及角色**: root +- **前置条件**: 用户为 root,已打开渠道列表页 + +### 编辑渠道 + +- **业务描述**: 修改已有渠道的配置信息,包括名称、供应商类型、模型列表、模型映射、API 密钥、基础 URL 等。 +- **触发入口**: 渠道列表→操作菜单→"编辑" +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx` +- **涉及角色**: root +- **前置条件**: 渠道已存在 + +### 启用渠道 + +- **业务描述**: 将已禁用的渠道恢复为启用状态,使其关联的模型重新可用。 +- **触发入口**: 渠道列表→操作菜单→"启用" +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/index.tsx` +- **涉及角色**: root +- **前置条件**: 渠道当前状态为"已禁用" + +### 禁用渠道 + +- **业务描述**: 将已启用的渠道设为禁用状态,停止该渠道关联模型的服务。 +- **触发入口**: 渠道列表→操作菜单→"禁用" +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/index.tsx` +- **涉及角色**: root +- **前置条件**: 渠道当前状态为"已启用" + +### 删除渠道 + +- **业务描述**: 从系统中移除一个渠道配置。 +- **触发入口**: 渠道列表→操作菜单→"删除" +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/index.tsx` +- **涉及角色**: root +- **前置条件**: 渠道已存在 + +### 测试模型 + +- **业务描述**: 对渠道下关联的模型进行连通性测试,验证 API 密钥和端点是否可用。 +- **触发入口**: 渠道列表→操作菜单→"测试" +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/ModelTest.tsx` +- **涉及角色**: root +- **前置条件**: 渠道存在且关联了模型,系统模型列表已加载 + +### 调整渠道优先级 + +- **业务描述**: 修改渠道的优先级数值,影响多渠道时的请求分发顺序。 +- **触发入口**: 渠道列表→优先级输入框 +- **路由路径**: /account/model +- **页面组件位置**: `projects/app/src/pageComponents/account/model/Channel/index.tsx` +- **涉及角色**: root +- **前置条件**: 渠道已存在 + +## 场景关系 + +- **查看渠道列表** 是所有操作的基础入口,编辑、删除、启停、测试、优先级调整均从列表页发起 +- **创建渠道** 不依赖已有渠道,可独立执行 +- **编辑渠道** 可跳转到模型编辑弹窗(AddModelBox),创建或修改系统级模型定义 +- 其他操作场景之间无严格先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看渠道列表 | 模型管理→渠道Tab | /account/model | root | +| S02 | 创建渠道 | 渠道列表→创建渠道按钮 | /account/model | root | +| S03 | 编辑渠道 | 渠道列表→操作菜单→编辑 | /account/model | root | +| S04 | 启用渠道 | 渠道列表→操作菜单→启用 | /account/model | root | +| S05 | 禁用渠道 | 渠道列表→操作菜单→禁用 | /account/model | root | +| S06 | 删除渠道 | 渠道列表→操作菜单→删除 | /account/model | root | +| S07 | 测试模型 | 渠道列表→操作菜单→测试 | /account/model | root | +| S08 | 调整渠道优先级 | 渠道列表→优先级输入框 | /account/model | root | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..beae110dc88b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,394 @@ +--- +capability_label: 渠道管理 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:03:42Z" +parent_module: 模型管理 +roles: + - root +router_paths: + - /account/model +--- + +# 渠道管理 — 业务流程详解 + +## 页面总览 + +渠道管理是 AI 模型供应商渠道的集中管理页面,以表格形式展示所有已配置渠道,提供创建、编辑、启停、删除、测试和优先级调整等操作入口。页面仅对 root 超级管理员可见,且需要系统配置开启 `show_aiproxy`。 + +## 非 Tab 业务流程 + +### 查看渠道列表 + +> 进入渠道管理 Tab 时自动加载并展示所有已配置的 AI 供应商渠道。 + +#### 步骤 1:进入渠道管理页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在模型管理页点击"渠道"Tab | — | 用户为 root 且 feConfigs.show_aiproxy 为 true 时,渠道 Tab 才可见 | Tab 切换,渠道列表组件开始渲染 | + +#### 步骤 2:加载渠道列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作,自动触发 | `GET /api/aiproxy/api/channels/all?page=1&perPage=10` | 无 | 显示表格加载状态(MyBox isLoading),表格区域等待数据 | + +#### 步骤 3:加载供应商元数据(并行) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作,与渠道列表并行加载 | `GET /api/aiproxy/api/channels/type_metas` | 无 | 无独立加载状态,与渠道列表共用同一个 loading | + +#### 步骤 4:数据合并与渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作 | — | 渠道列表按状态排序(启用在前),同状态按优先级降序 | 表格展示渠道列表:ID、渠道名称、供应商类型(图标+名称)、状态(彩色标签)、优先级(数字输入框)、操作菜单按钮;加载状态消失 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /api/aiproxy/api/channels/all | page=1, perPage=10 | 按状态排序(启用优先),同状态按优先级降序 | 表格展示全部渠道 | +| 供应商信息 | GET /api/aiproxy/api/channels/type_metas | 无 | 与 aiproxyChannels 合并,取供应商名称和头像 | 每行展示供应商图标和名称 | + +**特殊列的渲染逻辑**: +- **状态列**: 通过 `ChannelStautsMap` 映射状态枚举到颜色和文案,使用 `MyTag` 展示,borderFill 样式 +- **供应商类型列**: 左侧显示供应商头像(Avatar 组件),右侧显示经过 i18n 解析的名称 +- **优先级列**: 可编辑的数字输入框(MyNumberInput),范围 1-100,默认值 1,失焦时自动保存 +- **操作菜单列**: MyMenu 组件,菜单项根据渠道状态动态变化(启用时显示"禁用",禁用时显示"启用") + +### 创建渠道 + +> 超级管理员新增一个 AI 供应商渠道,配置供应商类型、模型、密钥等信息。 + +#### 步骤 1:打开发起入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"创建渠道"按钮 | — | 按钮仅 root 用户可见 | EditChannelModal 弹窗打开,表单字段为空,标题为"添加渠道" | + +#### 步骤 2:选择供应商类型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在供应商下拉框中搜索/选择 | `GET /api/aiproxy/api/channels/type_metas`(页面加载时已完成) | 下拉列表已缓存 | 选中项高亮;下方"默认URL"和"密钥类型"提示文案根据选中供应商更新 | + +#### 步骤 3:选择模型 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型多选下拉框 | — | 系统模型列表从 `getSystemModelList` 获取并缓存 | 下拉菜单展示可选模型列表(带供应商图标),支持搜索过滤 | +| 勾选/取消勾选模型 | — | — | 已选模型以 Tag 形式展示在输入框内,点击 Tag 可移除;可点击 Tag 复制模型 ID | + +模型选择区域同时提供"添加模型"按钮(打开 ModelEditModal 创建新系统模型)和"清空模型"按钮。 + +#### 步骤 4:填写配置信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入渠道名称 | — | 必填 | 输入框接受任意文本 | +| 输入基础 URL(可选) | — | 未填则使用供应商默认 URL | 输入框显示供应商默认 URL 作为占位提示 | +| 输入 API 密钥(可选) | — | 密码类型输入框,自动补全关闭 | 输入内容被遮蔽 | +| 编辑模型映射 JSON(可选) | — | 格式错误时静默忽略,不保存 | JsonEditor 组件展示格式化 JSON | + +#### 步骤 5:提交创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"新建"按钮 | `POST /api/aiproxy/api/createChannel` | 模型列表为空时阻止提交,提示"请选择模型" | 提交按钮进入 loading 状态;成功后关闭弹窗、刷新列表、Toast 提示"创建成功";失败则弹窗保持打开,显示错误信息 | + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 渠道名称 (name) | 文本输入 | ✅ | — | 任意文本 | 否 | 渠道的显示名称 | +| 供应商类型 (type) | 下拉选择(可搜索) | ✅ | — | 从 type_metas API 获取的供应商列表 | 否 | 决定 API 端点和密钥格式 | +| 模型 (models) | 多选下拉 | ✅ | — | 从 getSystemModelList 获取的模型列表 | 否 | 该渠道可用的模型 ID 列表 | +| 模型映射 (model_mapping) | JSON 编辑器 | 否 | {} | 合法 JSON 对象 | 否 | 模型名称重映射配置 | +| 基础 URL (base_url) | 文本输入 | 否 | 供应商默认 URL | URL 格式 | 否 | 自定义 API 端点地址 | +| API 密钥 (key) | 密码输入 | 否 | — | — | 否 | 供应商 API 认证密钥 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 模型列表不能为空 | 提交时 | "请选择模型" | +| 渠道名称不能为空 | 提交时(react-hook-form required) | 浏览器默认必填提示 | + +**前后置条件**: +- **前置条件**: 用户为 root,系统模型列表已加载 +- **后置影响**: 创建成功后渠道加入列表中,相关模型可在工作流和 Agent 中选用 +- **失败场景**: API 密钥无效时渠道创建成功但后续调用会失败;供应商类型不支持时后端可能拒绝 + +### 编辑渠道 + +> 修改已有渠道的配置信息。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"编辑" | — | 无 | EditChannelModal 弹窗打开,表单回填当前渠道数据,标题为"编辑渠道" | + +#### 步骤 2:修改配置 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改各字段值 | — | 与创建渠道表单相同 | 与创建渠道交互一致 | + +可在编辑弹窗中通过"添加模型"按钮跳转到 ModelEditModal 编辑系统模型。 + +#### 步骤 3:提交更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"更新"按钮 | `PUT /api/aiproxy/api/channel/{id}` | 模型列表为空时阻止提交,提示"请选择模型" | 提交按钮 loading;成功后关闭弹窗、刷新列表、Toast 提示"更新成功" | + +**前后置条件**: +- **前置条件**: 渠道已存在,数据已加载 +- **后置影响**: 渠道配置更新后立即生效,影响后续 API 调用 +- **失败场景**: 网络异常时弹窗保持打开,显示错误信息 + +### 启用渠道 + +> 将已禁用的渠道恢复为启用状态。 + +#### 步骤 1:触发启用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"启用" | — | 该菜单项仅在渠道状态为"已禁用"时显示 | 无即时变化,等待 API 响应 | + +#### 步骤 2:状态更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需额外操作 | `POST /api/aiproxy/api/channel/{id}/status` {status: ChannelStatusEnabled} | 无 | 列表刷新,渠道状态标签变为"已启用"(绿色) | + +**状态转换详情**: +- **状态转换**: 已禁用 → 点击启用 → 已启用 +- **前置检查**: 无引用检查,直接执行 +- **确认提示**: 无确认弹窗,直接执行 +- **级联更新**: 操作成功后自动刷新渠道列表 + +### 禁用渠道 + +> 将已启用的渠道设为禁用状态。 + +#### 步骤 1:触发禁用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"禁用" | — | 该菜单项仅在渠道状态为"已启用"时显示 | 无即时变化 | + +#### 步骤 2:状态更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需额外操作 | `POST /api/aiproxy/api/channel/{id}/status` {status: ChannelStatusDisabled} | 无 | 列表刷新,渠道状态标签变为"已禁用" | + +**状态转换详情**: +- **状态转换**: 已启用 → 点击禁用 → 已禁用 +- **前置检查**: 无引用检查,直接执行 +- **确认提示**: 无确认弹窗,直接执行 +- **级联更新**: 操作成功后自动刷新渠道列表 + +### 删除渠道 + +> 从系统中永久移除一个渠道。 + +#### 步骤 1:触发删除确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"删除" | — | 无 | 弹出确认弹窗(ConfirmModal),提示"确认删除渠道 {name}?" | + +**确认弹窗详情**: +- 弹窗类型:危险操作确认弹窗(type: 'delete') +- 提示文案:`确认删除渠道 {渠道名称}?` +- 按钮:取消 / 确认删除 + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认删除" | `DELETE /api/aiproxy/api/channel/{id}` | 无 | 确认弹窗关闭,列表刷新,该渠道从表格中移除 | + +**前后置条件**: +- **前置条件**: 渠道已存在 +- **后置影响**: 渠道被永久删除,关联模型不可再通过此渠道调用 +- **失败场景**: 后端删除失败时显示错误信息,渠道保留在列表中 +- **级联影响**: 删除后该渠道关联的模型在工作流中将不可用 + +### 测试模型 + +> 对渠道下关联的模型进行连通性测试。 + +#### 步骤 1:打开测试弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击操作菜单→"测试" | `GET /api/aiproxy/api`(getSystemModelList,弹窗内自动触发) | 无 | ModelTest 弹窗打开,加载系统模型列表以匹配渠道模型 | + +#### 步骤 2:加载模型匹配 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需操作 | `GET`(getSystemModelList) | 无 | 弹窗显示加载状态;加载完成后表格展示匹配到的模型列表,每行包含模型名称、ID、供应商模型名、状态(等待测试) | + +模型匹配逻辑:遍历渠道的 model 列表,在系统模型列表中查找 model 字段匹配的模型定义,同时获取对应的供应商信息(图标和名称)。 + +#### 步骤 3:执行测试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"开始测试(N)"按钮 | 对每个模型调用测试 API(最多 5 个并发) | 无 | 按钮进入 loading 状态;每个模型行状态依次变化:等待测试→测试中(蓝色标签)→成功(绿色标签,显示耗时)/失败(红色标签,显示错误信息) | + +**测试流程详情**: +1. 所有模型行同时进入 loading 状态 +2. 使用 batchRun 以 5 个并发执行测试 +3. 每个模型测试:标记为"running" → 调用 getTestModel({id, channelId}) → 记录耗时 → 标记为"success"(显示耗时秒数)或"error"(显示错误信息) +4. 若全部成功,无额外提示;若有失败,Toast 提示"有 N 个模型测试失败" + +#### 步骤 4:单项重测 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某模型行的发送按钮 | 单独调用该模型的测试 API | 非全部测试中或该行正在 loading 时可点击 | 该行重新执行测试流程,其他行不受影响 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 初次加载 | getSystemModelList | 无 | 按 model 字段匹配渠道 models | 匹配到的模型列表展示在表格中 | +| 批量测试 | getTestModel(并行,最多5个) | id: 模型系统ID, channelId: 渠道ID | 记录耗时和响应状态 | 每行显示测试结果和耗时 | +| 单项测试 | getTestModel | id: 模型系统ID, channelId: 渠道ID | 同上 | 仅该行更新 | + +### 调整渠道优先级 + +> 通过数字输入框调整渠道的优先级数值。 + +#### 步骤 1:修改优先级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击优先级输入框,修改数值 | — | 无 | 输入框获得焦点,可编辑 | + +#### 步骤 2:保存优先级 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 输入框失焦 | `PUT /api/aiproxy/api/channel/{id}`(带完整渠道数据 + 新 priority 值) | 值为空时默认设为 1;值被限制在 1-100 范围内 | 列表刷新,渠道按新优先级重新排序 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 最小值 1 | 失焦/输入时 | 组件自动限制 | +| 最大值 100 | 失焦/输入时 | 组件自动限制 | +| 空值处理 | 失焦 | 默认设为 1 | + +## Mermaid 附录 + +### 渠道列表加载流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + + User->>UI: 点击"渠道"Tab + UI->>API: GET /api/aiproxy/api/channels/all + UI->>API: GET /api/aiproxy/api/channels/type_metas + API-->>UI: 渠道列表 + 供应商元数据 + UI->>UI: 合并数据,按状态和优先级排序 + UI-->>User: 渲染渠道列表表格 +``` + +### 创建渠道流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + + User->>UI: 点击"创建渠道" + UI-->>User: 显示编辑弹窗(空表单) + User->>UI: 填写渠道信息并提交 + UI->>API: POST /api/aiproxy/api/createChannel + API-->>UI: 创建成功 + UI->>API: GET /api/aiproxy/api/channels/all + API-->>UI: 刷新后的渠道列表 + UI-->>User: 弹窗关闭,列表更新,Toast提示"创建成功" +``` + +### 编辑/删除/启停渠道流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + + User->>UI: 点击操作菜单项 + alt 编辑 + UI-->>User: 显示编辑弹窗 + User->>UI: 修改配置并提交 + UI->>API: PUT /api/aiproxy/api/channel/{id} + else 启用/禁用 + UI->>API: POST /api/aiproxy/api/channel/{id}/status + else 删除 + UI-->>User: 显示确认弹窗 + User->>UI: 确认删除 + UI->>API: DELETE /api/aiproxy/api/channel/{id} + end + API-->>UI: 操作成功 + UI->>API: GET /api/aiproxy/api/channels/all + API-->>UI: 刷新后的列表 + UI-->>User: 列表更新 +``` + +### 模型测试流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + + User->>UI: 点击操作菜单→"测试" + UI->>API: getSystemModelList + API-->>UI: 系统模型列表 + UI->>UI: 匹配渠道模型 + UI-->>User: 显示测试弹窗(模型列表) + User->>UI: 点击"开始测试" + par 并发测试(最多5个) + UI->>API: getTestModel(id, channelId) + API-->>UI: 测试成功,记录耗时 + and + UI->>API: getTestModel(id, channelId) + API-->>UI: 测试成功,记录耗时 + end + UI-->>User: 每行更新测试结果(状态+耗时) +``` + +### 优先级调整流程 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + + User->>UI: 修改优先级数值 + User->>UI: 输入框失焦 + UI->>API: PUT /api/aiproxy/api/channel/{id} + API-->>UI: 更新成功 + UI->>API: GET /api/aiproxy/api/channels/all + API-->>UI: 刷新后的列表(重新排序) + UI-->>User: 列表按新优先级重排 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..a5fce9ef0209 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,62 @@ +--- +capability_label: 渠道管理 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:03:42Z" +parent_module: 模型管理 +roles: + - root +router_paths: + - /account/model +--- + +# 渠道管理 — 模块概览 + +## 1. 模块业务概述 + +渠道管理是 AI 模型提供商渠道的集中配置模块,允许管理员为平台接入和管理多个 AI 模型供应商(如 OpenAI、Azure、HuggingFace 等)的 API 渠道。每个渠道包含供应商类型、API 密钥、基础 URL、可选模型列表及模型名称映射等配置信息。 + +本模块服务于 **root 超级管理员**,是平台 AI 能力的底层基础设施配置入口。渠道配置直接影响系统中所有可用的 AI 模型列表,进而影响工作流、Agent 等上层功能的模型选择范围。 + +渠道支持优先级排序和启用/禁用切换,管理员可以根据成本和性能灵活调度多个供应商的 API 请求。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root | 模型管理→渠道 Tab | 创建、编辑、删除渠道;启用/禁用渠道;调整优先级;测试模型 | 仅 root 用户且系统配置 `show_aiproxy` 开启时可见渠道管理相关 Tab | +| 普通管理员 | 无 | 无 | 模型管理页仅显示"可用模型"和"模型配置"两个 Tab,无法访问渠道管理 | + +**权限模型**: 基于用户身份的前端条件渲染,通过 `userInfo.username === 'root'` 判断是否为超级管理员,结合系统配置 `feConfigs.show_aiproxy` 控制渠道 Tab 的可见性。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 系统Store | `useSystemStore` — 获取 aiproxyChannels 渠道供应商列表、defaultModels 默认模型、feConfigs 系统配置 | Store | +| 用户Store | `useUserStore` — 获取当前用户信息,判断 root 身份 | Store | +| AI 渠道 API 层 | `@/web/core/ai/channel` — 渠道 CRUD、供应商元数据、状态切换 | API | +| AI 模型配置 | `@/web/core/ai/config` — getSystemModelList、getTestModel 获取系统模型列表和测试模型 | API | +| 全局类型定义 | `@/global/aiproxy/type` — ChannelInfoType、CreateChannelProps 等类型 | 类型 | +| 全局常量 | `@/global/aiproxy/constants` — ChannelStatusEnum、ChannelStautsMap、defaultChannel | 常量 | +| 模型编辑 | `../AddModelBox` — ModelEditModal、getNewModelFormData、AddModelButton | 组件 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| ChannelTable 渠道列表组件 | 模型管理页面(account/model) | 页面组件 | +| EditChannelModal 编辑弹窗 | 模型管理页面(account/model)内使用 | 业务组件 | +| ModelTest 模型测试弹窗 | 模型管理页面(account/model)内使用 | 业务组件 | + +> 本模块组件为内部使用,未被其他模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模型管理—渠道管理 | /account/model | `projects/app/src/pageComponents/account/model/Channel/index.tsx` | root | 渠道列表表格,支持创建、编辑、删除、启停、测试、优先级调整 | + +## 6. 子能力索引 + +本模块为叶子节点,无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..04156391f8fb --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,118 @@ +--- +capability_label: "配置模型" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T11:05:00.000Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 配置模型 — 业务流程索引 + +## 业务场景列表 + +### 查看模型列表 + +- **业务描述**:进入「配置模型」Tab 后,系统展示所有已接入的模型,按供应商和类型分组,支持多维度筛选。 +- **触发入口**:账户 → 模型管理 → 点击「模型配置」Tab +- **路由路径**:/account/model?tab=config +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +- **涉及角色**:root、团队管理员、团队成员 +- **前置条件**:用户已登录;页面加载时自动获取模型列表 + +### 筛选与搜索模型 + +- **业务描述**:通过供应商下拉框、模型类型筛选、名称搜索、激活状态筛选四个维度快速定位目标模型。 +- **触发入口**:配置模型页面的筛选栏 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +- **涉及角色**:root、团队管理员、团队成员 +- **前置条件**:模型列表已加载 + +### 创建模型 + +- **业务描述**:管理员从「新增模型」下拉菜单中选择模型类型,弹出编辑窗口,填写模型参数后创建新的自定义模型。 +- **触发入口**:配置模型页面 → 点击「新增模型」按钮 → 选择模型类型 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/AddModelBox.tsx` +- **涉及角色**:root、有模型创建权限的团队成员 +- **前置条件**:用户拥有模型创建权限(`hasModelCreatePer`) + +### 编辑模型 + +- **业务描述**:管理员点击模型的「编辑」图标,弹出编辑窗口修改模型参数(名称、价格、上下文长度、请求地址等)。 +- **触发入口**:模型列表行 → 点击「编辑」按钮 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/AddModelBox.tsx`(ModelEditModal) +- **涉及角色**:root、拥有模型管理权限的团队成员 +- **前置条件**:操作者对目标模型拥有 `hasManagePer` 权限 + +### 启用/禁用模型 + +- **业务描述**:通过开关切换模型的启用/禁用状态,控制模型是否对外可用。 +- **触发入口**:模型列表行 → 切换「激活」开关 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +- **涉及角色**:root、拥有模型管理权限的团队成员 +- **前置条件**:操作者对目标模型拥有 `hasManagePer` 权限;如模型被引用,可能因冲突阻止操作 + +### 测试模型 + +- **业务描述**:点击模型的「测试」按钮,向模型发送测试请求,验证模型连通性与响应。 +- **触发入口**:模型列表行 → 点击「发送」测试图标 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +- **涉及角色**:所有角色 +- **前置条件**:模型列表已加载 + +### 管理模型权限 + +- **业务描述**:为模型分配协作者,设定哪些团队成员可以查看或管理该模型。 +- **触发入口**:模型列表行 → 点击「钥匙」权限图标 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx`(LazyCollaboratorProvider) +- **涉及角色**:root、拥有模型管理权限的团队成员 +- **前置条件**:操作者对目标模型拥有 `hasManagePer` 权限 + +### 删除模型 + +- **业务描述**:删除自定义模型,系统检查模型是否被其他资源引用,如有引用则弹出警告阻止删除。 +- **触发入口**:模型列表行 → 点击「删除」图标 → 确认弹窗 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` +- **涉及角色**:root、拥有模型管理权限的团队成员 +- **前置条件**:目标模型为自定义模型(`isCustom === true`);操作者拥有 `hasManagePer` 权限 + +### JSON 配置文件管理 + +- **业务描述**:root 用户可通过 JSON 编辑器查看和全量覆盖模型配置,实现批量配置的导入导出。 +- **触发入口**:配置模型页面 → 点击「配置文件」按钮 +- **路由路径**:/account/model +- **页面组件位置**:`projects/app/src/pageComponents/account/model/ModelConfigTable.tsx`(JsonConfigModal) +- **涉及角色**:仅 root +- **前置条件**:用户为 root + +## 场景关系 + +- **查看模型列表** → **筛选与搜索模型**:筛选操作基于已加载的列表数据,不触发额外 API 请求 +- **创建模型** → **查看模型列表**:创建成功后自动刷新列表,新模型出现在列表中 +- **编辑模型** → **查看模型列表**:编辑成功后自动刷新列表 +- **启用/禁用模型** → **查看模型列表**:状态变更后自动刷新列表 +- **删除模型** → **查看模型列表**:删除成功后自动刷新列表 +- **JSON 配置文件管理** → **查看模型列表**:覆盖成功后自动刷新列表 +- 管理模型权限、测试模型与列表查看为独立操作,无严格先后关系 + +## 业务场景汇总 + +| 编号 | 场景 | 触发入口 | 路由路径 | 角色 | +|------|------|---------|---------|------| +| S01 | 查看模型列表 | 账户→模型管理→模型配置Tab | /account/model | root, 团队管理员, 团队成员 | +| S02 | 筛选与搜索模型 | 列表页筛选栏 | /account/model | root, 团队管理员, 团队成员 | +| S03 | 创建模型 | 列表页→新增模型按钮 | /account/model | root, 有创建权限的成员 | +| S04 | 编辑模型 | 列表行→编辑图标 | /account/model | root, 有管理权限的成员 | +| S05 | 启用/禁用模型 | 列表行→激活开关 | /account/model | root, 有管理权限的成员 | +| S06 | 测试模型 | 列表行→测试图标 | /account/model | 所有角色 | +| S07 | 管理模型权限 | 列表行→权限钥匙图标 | /account/model | root, 有管理权限的成员 | +| S08 | 删除模型 | 列表行→删除图标→确认 | /account/model | root, 有管理权限的成员 | +| S09 | JSON 配置文件管理 | 列表页→配置文件按钮 | /account/model | root 专用 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..a50d17b69e50 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,365 @@ +--- +capability_label: "配置模型" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T11:05:00.000Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 配置模型 — 业务流程详解 + +## 页面总览 + +配置模型 Tab 是系统模型管理的核心配置界面,用户在此完成模型的查看、筛选、创建、编辑、测试、权限分配和删除等全生命周期管理操作。页面顶部为筛选工具栏,下方为模型数据表格。 + +--- + +### S01:查看模型列表 + +> 进入「配置模型」Tab 后自动加载模型列表。 + +#### 步骤 1:进入配置模型 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在模型管理页面点击「模型配置」Tab | 无(Tab 切换为客户端路由) | — | Tab 切换为「模型配置」,加载配置模型组件 | + +#### 步骤 2:加载模型列表数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 组件挂载后自动加载 | `POST /api/core/ai/model/list`(无参数) | — | 显示 MyBox 加载遮罩;API 返回后将模型列表按类型(LLM、Embedding、TTS、STT、Rerank)分别格式化处理 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /core/ai/model/list | 无 | 按模型类型分组:LLM→蓝色标签"对话"、Embedding→黄色标签"索引"、TTS→绿色标签"语音合成"、STT→紫色标签"语音识别"、Rerank→adora色标签"重排序";关联供应商头像和名称;按供应商 order 排序 | 表格展示所有模型 | + +#### 步骤 3:批量刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 创建/编辑/删除/启用禁用操作成功后自动触发 | `POST /api/core/ai/model/list`;同时调用 `clientInitData()` 刷新全局初始化数据 | — | 静默刷新列表,不显示全屏遮罩;表格数据更新 | + +**排序规则**:模型按供应商 `order` 字段升序排列。 +**筛选条件**:支持供应商、模型类型、名称搜索、激活状态四维筛选,在前端 based on 已加载数据本地过滤。 + +--- + +### S02:筛选与搜索模型 + +> 用户通过四种筛选维度快速定位模型。 + +#### 步骤 1:按供应商筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在供应商下拉框选择一个供应商 | 无(纯前端过滤) | 供应商列表仅显示当前模型列表中实际存在的供应商;选择"全部供应商"时不过滤 | 表格仅显示所选供应商的模型 | + +#### 步骤 2:按名称搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入模型名称关键字 | 无(纯前端过滤) | 使用正则表达式 `new RegExp(search, 'i')` 进行不区分大小写的名称匹配 | 表格实时过滤为匹配的模型行 | + +#### 步骤 3:按模型类型筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表头「模型类型」列的筛选图标,选择类型 | 无(纯前端过滤) | 可选类型:全部、对话(LLM)、索引(Embedding)、语音合成(TTS)、语音识别(STT)、重排序(Rerank) | 表格仅显示所选类型的模型 | + +#### 步骤 4:按激活状态筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表头「激活(N)」列的筛选图标,选择状态 | 无(纯前端过滤) | 可选状态:全部、已启用、已禁用;N 为当前筛选结果中的激活数量 | 表格仅显示所选状态的模型 | + +--- + +### S03:创建模型 + +> 管理员从「新增模型」按钮创建新的自定义模型。 + +#### 步骤 1:打开创建窗口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停「新增模型」按钮 → 下拉菜单展开 → 选择模型类型 | 无 | 仅当用户为 root 或拥有 `hasModelCreatePer` 权限时按钮可用;可选类型:对话、索引、重排序、语音合成、语音识别 | 下拉菜单展示五种模型类型;点击后弹出模型编辑窗口 | + +> **前置条件**:用户认证通过,拥有模型创建权限。 + +#### 步骤 2:填写模型信息并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在编辑弹窗中填写模型参数(名称、ID、供应商、价格、上下文长度等),点击确认 | `POST /api/core/ai/model/create`(提交 `CreateModelBody`,类型为 discriminated union,按模型类型提交对应字段) | 表单校验通过后提交;创建失败时显示错误提示 | 提交时弹窗内按钮显示加载状态;成功后弹窗关闭,模型列表自动刷新 | + +**表单字段清单**(基于源码 `AddModelBox.tsx` 和 API Schema): + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 模型名称 | 文本输入 | ✅ | — | 任意字符串 | ✗ | 用户可见的模型名称 | +| 模型 ID | 文本输入 | ✅ | — | 字符串,实际的模型标识值 | ✗ | 与供应商 API 的 model 值对应 | +| 供应商 | 下拉选择 | ✅ | — | 系统中已配置的模型供应商 | ✗ | 确定模型请求的路由 | +| 输入价格 | 数字输入 | ✗ | 0 | 非负数 | ✗ | 每 1K Token 的价格 | +| 输出价格 | 数字输入 | ✗ | 0 | 非负数 | ✗ | 每 1K Token 的价格 | +| 阶梯价格 | 动态表格 | ✗ | 默认空 | 多级阶梯价格配置 | ✗ | 支持按 token 范围设定不同的单价 | +| 最大上下文 | 数字输入 | ✗ | 默认值 | 正整数 | ✗ | 模型支持的最大上下文 Token 数 | +| 最大响应 | 数字输入 | ✗ | 默认值 | 正整数 | ✗ | 单次最大输出 Token 数 | +| 请求地址 | 文本输入 | ✗ | 供应商默认地址 | 有效的 URL | ✗ | 自定义 API 端点 | +| 请求密钥 | 密码输入 | ✗ | 供应商默认密钥 | 字符串 | ✗ | API 认证密钥 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 模型名称非空校验 | 提交时 | 前端表单校验,必填字段为空时阻止提交 | +| 模型 ID 非空校验 | 提交时 | 同上 | +| 数值字段范围校验 | 提交时 | 价格等字段不能为负数 | + +**后置影响**:创建成功后,模型被加入系统模型列表,可在「可用模型」Tab 中看到,成员可向模型发送请求。 + +--- + +### S04:编辑模型 + +> 管理员修改已有模型的配置参数。 + +#### 步骤 1:打开编辑窗口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行操作列的「设置」图标 | `GET /api/core/ai/model/detail?id={modelId}` | 仅当 `item.permission.hasManagePer` 为 true 时编辑按钮可见 | 发起 API 请求获取模型详情;MyBox 显示加载状态;API 返回后弹出预填数据的编辑窗口 | + +#### 步骤 2:修改并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改模型参数,点击确认保存 | `PUT /api/core/ai/model/update`(提交 `UpdateModelBody`,含模型 ID和需更新的字段) | 仅对有管理权限的字段可编辑;root 用户可编辑更多字段 | 提交时显示加载状态;成功后弹窗关闭,列表自动刷新 | + +**失败场景**: +- 模型被引用冲突(HTTP 409):弹出 `ModelReferenceModal` 显示引用该模型的资源列表,阻止操作 +- 其他错误(HTTP 409 + message):toast 提示错误信息 + +--- + +### S05:启用/禁用模型 + +> 通过开关控制模型的可用状态。 + +#### 步骤 1:切换开关 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行的激活开关 | `PUT /api/core/ai/model/update`(`{id, isActive: true/false}`) | 仅当 `item.permission.hasManagePer` 为 true 时开关可操作,否则禁用;开关当前值与目标值相同时不触发 | Switch 组件切换动画;请求发送期间开关不响应(通过 loading 状态控制) | + +**状态转换**:启用状态 → 切换开关 → 禁用状态(反之亦然) + +**前置检查**:无前端前置校验,后端可能检查模型引用 + +**级联更新**:状态变更成功后自动刷新模型列表和 `clientInitData()` + +**失败处理**: +- HTTP 409 + references:弹出 `ModelReferenceModal` 显示引用该模型的资源 +- HTTP 409 + message:toast 显示错误信息 + +--- + +### S06:测试模型 + +> 验证模型的连通性和响应能力。 + +#### 步骤 1:发起测试 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行的「发送」测试图标 | `GET /api/core/ai/model/test?id={modelId}` | 所有用户均可操作(无权限限制) | 按钮显示加载状态;测试成功后 toast 显示「成功」提示 | + +**失败场景**:测试失败时显示错误 toast。 + +--- + +### S07:管理模型权限 + +> 为模型分配协作者和设置权限角色。 + +#### 步骤 1:打开权限管理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行操作列的「钥匙」权限图标 | 加载协作者列表:调用 `getModelCollaborators(modelId)` | 仅当 `item.permission.hasManagePer` 为 true 时权限按钮可见 | 打开协作者管理弹窗,显示当前模型的协作者列表 | + +#### 步骤 2:修改协作者 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 添加/删除协作者,调整角色,点击确认 | `updateModelCollaborators({collaborators, modelIds: [modelId]})` | 如果模型当前为公开状态(`isShared === true`),添加协作者时会提示"当前为公开状态,添加协作者后,将自动切换为私有状态" | 提交时弹窗显示加载状态;成功后权限更新,协作者列表刷新 | + +**失败处理**: +- HTTP 409 + references:弹出 `ModelReferenceModal` +- HTTP 409 + message:toast 错误提示 +- 其他错误:抛出异常,弹窗保持打开 + +--- + +### S08:删除模型 + +> 删除自定义模型,包含引用检查和确认机制。 + +#### 步骤 1:点击删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击模型行操作列的「删除」图标 | 无(先弹出确认框) | 仅自定义模型(`isCustom === true`)且拥有 `hasManagePer` 权限时删除按钮可见;系统模型不可删除 | `PopoverConfirm` 弹出确认框,提示「确认删除该模型?」 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击确认 | `DELETE /api/core/ai/model/delete`(`{id: modelId}`) | — | 确认按钮显示加载状态;删除成功后列表自动刷新 | + +**引用检查**:删除 API 返回 HTTP 409 且含 `references` 时,弹出 `ModelReferenceModal` 显示引用该模型的资源列表(应用、知识库等),用户需先移除引用才能删除。 + +**确认弹窗**: +- 弹窗类型:删除确认 +- 提示文案:「确认删除该模型?」 +- 按钮:确认 / 取消 + +**级联影响**:删除后,使用该模型的应用和知识库将无法正常工作。 + +--- + +### S09:JSON 配置文件管理 + +> root 用户通过 JSON 全量管理模型配置。 + +#### 步骤 1:打开 JSON 编辑器 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「配置文件」按钮 | `GET /api/core/ai/model/getConfigJson` | **仅 root 用户可见此按钮** | 全屏弹窗打开,加载当前 JSON 配置到 JsonEditor 组件 | + +#### 步骤 2:编辑与提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 JsonEditor 中编辑配置 JSON,点击确认 | `PUT /api/core/ai/model/updateWithJson`(`{config: jsonString}`) | 确认前弹出二次确认框,提示"确认使用该配置进行覆盖?";提示信息说明会全量覆盖当前配置,建议先备份 | JsonEditor 支持语法高亮和调整大小;提交成功后弹窗关闭,模型列表刷新 | + +**提示文案**:「通过配置文件配置模型,点击确认后,会使用输入的配置进行全量覆盖,请确保配置文件输入正确。建议操作前,复制当前配置文件进行备份。」 + +--- + +### Mermaid 附录 + +#### S01:查看模型列表 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 配置模型页面 + participant API as 后端 API + + U->>UI: 进入模型配置Tab + UI->>API: POST /core/ai/model/list + API-->>UI: 模型列表数据 + UI->>UI: 按类型分组格式化 + UI->>UI: 关联供应商信息并排序 + UI-->>U: 展示模型表格 +``` + +#### S03:创建模型 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 配置模型页面 + participant API as 后端 API + + U->>UI: 悬停「新增模型」→ 选择类型 + UI->>UI: 根据类型加载默认表单数据 + UI-->>U: 打开模型编辑弹窗 + U->>UI: 填写模型参数并提交 + UI->>API: POST /core/ai/model/create + API-->>UI: 创建成功,返回模型 ID + UI->>API: POST /core/ai/model/list + API-->>UI: 更新后的模型列表 + UI-->>U: 关闭弹窗,列表刷新 +``` + +#### S04:编辑模型 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 配置模型页面 + participant API as 后端 API + + U->>UI: 点击模型的编辑图标 + UI->>API: GET /core/ai/model/detail?id=xxx + API-->>UI: 模型详情数据 + UI-->>U: 打开预填数据的编辑弹窗 + U->>UI: 修改参数并提交 + UI->>API: PUT /core/ai/model/update + alt 更新成功 + API-->>UI: 成功 + UI->>API: POST /core/ai/model/list + API-->>UI: 更新后的列表 + UI-->>U: 关闭弹窗,列表刷新 + else 引用冲突 (409) + API-->>UI: references 列表 + UI-->>U: 弹出引用警告弹窗 + end +``` + +#### S05:启用/禁用模型 & S08:删除模型 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 配置模型页面 + participant API as 后端 API + + U->>UI: 切换激活开关 / 点击删除 + alt 删除操作 + UI-->>U: 弹出确认框 + U->>UI: 确认 + end + UI->>API: PUT /core/ai/model/update / DELETE /core/ai/model/delete + alt 操作成功 + API-->>UI: 成功 + UI->>API: POST /core/ai/model/list + API-->>UI: 更新后的列表 + UI-->>U: 列表刷新 + else 引用冲突 (409) + API-->>UI: references 列表 + UI-->>U: 弹出 ModelReferenceModal + else 其他错误 + API-->>UI: 错误消息 + UI-->>U: toast 提示 + end +``` + +#### S09:JSON 配置文件管理 + +```mermaid +sequenceDiagram + participant U as root用户 + participant UI as 配置模型页面 + participant API as 后端 API + + U->>UI: 点击「配置文件」按钮 + UI->>API: GET /core/ai/model/getConfigJson + API-->>UI: 当前 JSON 配置 + UI-->>U: 全屏 JSON 编辑器 + U->>UI: 编辑 JSON 并确认 + UI-->>U: 二次确认弹窗 + U->>UI: 确认覆盖 + UI->>API: PUT /core/ai/model/updateWithJson + API-->>UI: 覆盖成功 + UI->>API: POST /core/ai/model/list + API-->>UI: 更新后的列表 + UI-->>U: 关闭弹窗,列表刷新 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..75c573dc8191 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: "配置模型" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T11:05:00.000Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 配置模型 — 模块概览 + +## 1. 模块业务概述 + +配置模型是「账户 → 模型管理」下的子能力,提供系统级模型的集中配置与管理功能。管理员可在此查看所有已接入的模型(包含 LLM、Embedding、TTS、STT、Rerank 五类),对模型进行启用/禁用、参数编辑、权限分配等操作,并支持新增自定义模型。 + +本模块面向**系统管理员(root)**和**有模型管理权限的团队成员**,是整个 AI Agent 平台中模型资源池的统一管控入口。与「可用模型」Tab(侧重于模型的直接使用)不同,本模块侧重于模型的**配置与管理**——包括模型参数的修改、权限归属的设定以及模型的上下架控制。 + +本模块为叶子能力,无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| root | 配置模型 Tab | 全部操作:查看所有模型、创建模型、编辑模型、启用/禁用、权限管理、删除模型、JSON 配置文件导入 | 可见「JSON 配置文件」按钮,可通过 JSON 全量覆盖模型配置 | +| 团队管理员 | 配置模型 Tab | 查看团队可见模型、创建模型、编辑有权限的模型、启用/禁用、权限管理、删除自有模型 | 不可见「JSON 配置文件」按钮;仅能编辑/删除有管理权限的模型 | +| 团队成员 | 配置模型 Tab | 查看团队可见模型、测试模型 | 不可新增/编辑/删除模型,操作按钮禁用或隐藏 | + +**权限模型**:基于 `ModelPermission` 的细粒度权限控制(继承自 `Permission` 基类),每个模型拥有独立的 `hasManagePer` 权限位。模型创建权限通过 `userInfo.team.permission.hasModelCreatePer` 判断。root 用户拥有全部操作权限。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户与权限 | `useUserStore` 获取用户信息和团队权限;`ModelPermission` 权限模型 | Store / 类型 | +| 系统配置 | `useSystemStore` 获取 `feConfigs`、`defaultModels`、模型供应商列表 | Store | +| 模型表共享组件 | `ModelTable` 的 helpers(`modelList`、`types`)中的类型定义和过滤逻辑 | 类型 | +| 权限协作 | `LazyCollaboratorProvider` 协作者管理;`getModelCollaborators` / `updateModelCollaborators` API | 组件 / API | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 模型列表数据 | 渠道管理(Channel)、渠道日志(ChannelLog)、模型监控(ModelDashboard) | API | +| AddModelBox(模型编辑弹窗) | 渠道管理(EditChannelModal) | 组件 | +| ModelReferenceModal(引用警告弹窗) | 可用模型(ModelTable) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 模型管理 - 配置模型 | /account/model | `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | root / 团队管理员 / 团队成员 | 模型管理页面的「模型配置」Tab,无子 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..505e36a78975 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,53 @@ +--- +capability_label: dashboard +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 用量统计 +roles: [团队管理员, 团队成员] +router_paths: [/account/usage?usageTab=dashboard] +--- + +# 用量看板 — 业务流程索引 + +## 场景关系 + +- **S01 查看用量趋势** → **S02 查看剩余积分与充值**:用户在查看趋势时发现积分不足,点击查看剩余积分按钮进入充值流程。 +- **S03 切换筛选条件** 影响 S01 的数据展示范围,三者无严格先后依赖关系。 + +各场景独立执行,无严格先后关系。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看用量趋势 | 账户→用量统计→看板Tab(默认) | /account/usage?usageTab=dashboard | 团队管理员, 团队成员 | +| S02 | 查看剩余积分与充值 | 用量看板→"查看剩余积分"按钮 | /account/usage?usageTab=dashboard | 团队管理员, 团队成员 | +| S03 | 切换筛选条件查看趋势 | 用量看板→顶部筛选器(日期/来源/成员等) | /account/usage?usageTab=dashboard | 团队管理员(可筛选成员/部门/群组), 团队成员(仅日期和来源) | + +## 查看用量趋势 + +- **业务描述**: 用户进入用量统计页面后,默认展示用量看板,以折线图呈现积分消耗趋势和 Token 消耗明细,帮助用户了解团队 AI 资源的整体消耗情况。 +- **触发入口**: 侧边栏"账户"→"用量统计",默认激活"看板"Tab;或在"用量明细"Tab 中点击切换至"看板" +- **路由路径**: `/account/usage?usageTab=dashboard` +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/Dashboard.tsx` +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 用户已登录 + +## 查看剩余积分与充值 + +- **业务描述**: 用户点击"查看剩余积分"按钮,弹出充值弹窗,展示当前套餐的积分使用进度、知识库容量使用进度,并可切换标准套餐/额外套餐进行充值。 +- **触发入口**: 用量看板页面右上角"查看剩余积分"按钮 +- **路由路径**: `/account/usage?usageTab=dashboard` +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/Dashboard.tsx`(调用充值弹窗) +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 用户已登录 + +## 切换筛选条件查看趋势 + +- **业务描述**: 用户通过调整日期范围、用量来源、筛选对象(成员/部门/群组)来改变看板展示的数据范围,图表自动按新条件重新加载。 +- **触发入口**: 用量看板顶部筛选器区域 +- **路由路径**: `/account/usage?usageTab=dashboard` +- **页面组件位置**: 筛选器由父页面 `projects/app/src/pages/account/usage/index.tsx` 渲染,作为 `Selectors` prop 传入看板组件 +- **涉及角色**: 全部可调整日期和来源;团队管理员额外可调整成员/部门/群组筛选 +- **前置条件**: 用户已登录 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..92328d42cede --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,215 @@ +--- +capability_label: dashboard +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 用量统计 +roles: [团队管理员, 团队成员] +router_paths: [/account/usage?usageTab=dashboard] +--- + +# 用量看板 — 业务流程详解 + +## 页面总览 + +用量看板是 AI 资源消耗的可视化分析页面。用户进入页面后,系统自动加载最近 7 天的用量数据,以两张折线图分别展示积分消耗趋势和 Token(输入/输出)消耗趋势。用户可通过顶部的日期选择器、来源筛选器和成员筛选器调整数据范围。页面右上角的"查看剩余积分"按钮可打开充值弹窗。 + +--- + +### 查看用量趋势 + +> 用户进入用量看板,系统自动加载并展示积分消耗和 Token 消耗的趋势图表。 + +#### 步骤 1:页面加载与数据请求 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入账户→用量统计页面,默认激活"看板"Tab | `POST /proApi/support/wallet/usage/getDashboardData`(自动触发) | — | 图表区域显示骨架屏加载状态(Skeleton 组件),总用量文字显示占位内容 | + +**API 请求参数**: +- `dateStart`: 默认最近 7 天的起始日期(如 `2026-06-11T00:00:00+08:00`) +- `dateEnd`: 当天 +1 天的起始时间(如 `2026-06-19T00:00:00+08:00`) +- `sources`: 默认全选时为 `undefined`(查全部来源) +- `memberFilter`: 默认全选成员时 `undefined`(团队成员看到自身数据,管理员看到全团队) +- `unit`: 默认 `'day'` + +API 返回后,数据处理:将 API 返回的日期字段格式化为 `YYYY-MM-DD` 格式。 + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/wallet/usage/getDashboardData | dateStart/dateEnd,sources 和 memberFilter 默认全选 | 日期格式化为 YYYY-MM-DD | 积分折线图 + Token 折线图 | +| 筛选器变化时刷新 | 同上 | 新的筛选条件 | 同首次加载 | 图表重新渲染 | + +#### 步骤 2:积分趋势图渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载完成 | — | 数据加载成功 | 骨架屏消失,总用量数字显示("总用量: X积分, XTokens"),积分折线图(LineChart)展示每日积分消耗趋势 | + +**积分折线图详情**: +- 横轴:日期(YYYY-MM-DD) +- 纵轴:积分数值 +- 折线:蓝色 `#5E8FFF`,仅 totalPoints 数据线 +- 鼠标悬停:显示日期和当日积分值的 Tooltip +- Y 轴无轴线,X 轴无刻度线 +- 图表高度 424px + +#### 步骤 3:Token 趋势图渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动页面至 Token 图区域 | — | 数据加载成功 | Token 折线图展示输入 Token(绿色 `#38A169`)、输出 Token(黄棕色 `#D69E2E`)、总 Token(蓝色 `#5E8FFF`)三条折线 | + +**Token 折线图详情**: +- 横轴:日期 +- 纵轴:Token 数值,大数值自动格式化为 K/M 单位 +- 鼠标悬停 Tooltip:显示日期、总 Token、输入 Token、输出 Token 四个维度 +- 图表高度 300px + +#### 步骤 4:加载失败处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载 | — | recharts 库动态加载失败 | 骨架屏消失,错误提示区域(红色背景)展示错误信息:"图表库加载失败" | + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载 | — | API 返回错误 | 图表区域显示错误信息:"图表加载失败" | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 用量看板 + participant API + participant Backend + + User->>UI: 进入用量统计页面 + UI->>UI: 显示骨架屏加载状态 + UI->>API: POST /proApi/support/wallet/usage/getDashboardData + API->>Backend: 查询用量数据 + Backend-->>API: 返回每日积分和Token数据 + API-->>UI: 返回数据数组 + UI->>UI: 格式化日期为 YYYY-MM-DD + UI->>UI: 计算总用量/总输入Token/总输出Token + UI->>UI: 渲染积分折线图 + UI->>UI: 渲染Token折线图(输入/输出/总计三条线) + UI-->>User: 展示完整趋势图表 +``` + +--- + +### 查看剩余积分与充值 + +> 用户点击"查看剩余积分"按钮,打开充值弹窗。 + +#### 步骤 1:打开充值弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右上角"查看剩余积分"按钮 | —(弹窗组件内部通过 useUserStore 读取已缓存数据) | — | 弹出充值弹窗(MyModal),展示当前套餐名、积分使用进度条、知识库容量使用进度条 | + +#### 步骤 2:查看套餐详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看标准套餐 Tab(默认激活) | — | 弹窗默认显示"订阅方案"Tab | 展示标准套餐列表(StandardPlan 组件),可查看各等级套餐详情 | +| 点击"额外方案"Tab | — | 用户切换 Tab | 展示额外套餐列表(ExtraPlan 组件) | + +**积分使用进度条详情**: +- 展示已用积分 / 总积分(或"无限制") +- 使用率 < 50%:蓝色进度条 +- 使用率 50%~80%:黄色进度条 +- 使用率 > 80%:红色进度条 + +#### 步骤 3:关闭弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗关闭按钮 / 点击遮罩 | — | — | 弹窗关闭,回到看板页面 | +| 点击"使用记录"按钮 | — | — | 弹窗关闭,跳转至 /account/usage 使用记录,触发父页面刷新 | +| 完成充值支付 | —(支付流程在充值组件内部处理) | 支付成功 | 弹窗关闭(onPaySuccess 回调触发 onClose) | + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 用量看板 + participant Modal as 充值弹窗 + participant Backend + + User->>UI: 点击"查看剩余积分"按钮 + UI->>Modal: 打开 RechargeModal + Modal->>Modal: 读取用户套餐状态(useUserStore) + Modal->>Modal: 渲染积分使用进度条和知识库容量进度条 + Modal-->>User: 展示充值弹窗(标准套餐/额外套餐 Tab) + alt 切换套餐 Tab + User->>Modal: 点击额外方案 Tab + Modal->>Modal: 切换展示额外套餐列表 + end + alt 充值 + User->>Modal: 选择套餐并完成支付 + Modal->>Backend: 支付流程 + Backend-->>Modal: 支付成功 + Modal->>UI: onPaySuccess 回调 + UI->>UI: 关闭弹窗 + else 关闭弹窗 + User->>Modal: 点击关闭 + Modal->>UI: 弹窗关闭 + end +``` + +--- + +### 切换筛选条件查看趋势 + +> 用户调整筛选器,看板图表自动按新条件重新加载数据。 + +#### 步骤 1:切换筛选器 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 调整日期范围(日期选择器) | `POST /proApi/support/wallet/usage/getDashboardData`(filterParams 变化自动触发刷新) | 日期变化 | 图表区域重新显示骨架屏,按新日期范围重新请求数据,加载完成后更新图表 | +| 切换用量来源(MultipleSelect) | 同上 | 来源变化 | 同上,按选中的来源过滤 | +| 切换成员筛选(管理员) | 同上 | 成员变化,且当前用户 `hasManagePer = true` | 同上,按选中成员过滤 | +| 切换部门筛选(管理员) | 同上 | 部门变化,且 `hasManagePer = true` | 同上,按选中部门过滤 | +| 切换群组筛选(管理员) | 同上 | 群组变化,且 `hasManagePer = true` | 同上,按选中群组过滤 | +| 切换统计单位(day/month) | 同上 | 单位变化 | 同上,按天/按月聚合 | + +#### 步骤 2:数据自动刷新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需手动操作 | POST /proApi/support/wallet/usage/getDashboardData | filterParams 引用变化(通过 useRequest 的 refreshDeps 自动监听) | 图表数据自动更新,总用量数字同步更新 | + +**筛选器角色差异**: +- 成员/部门/群组筛选器仅在 `hasManagePer = true`(团队管理员)时可见 +- 日期范围筛选器对所有角色可见 +- 用量来源筛选器对所有角色可见 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 用量看板 + participant API + participant Backend + + User->>UI: 调整筛选器(日期/来源/成员等) + UI->>UI: filterParams 更新 + UI->>UI: useRequest 检测 refreshDeps 变化 + UI->>UI: 显示骨架屏加载状态 + UI->>API: POST getDashboardData(新筛选条件) + API->>Backend: 按新条件查询 + Backend-->>API: 返回筛选后的数据 + API-->>UI: 返回数据数组 + UI->>UI: 重新计算总用量 + UI->>UI: 更新积分折线图 + UI->>UI: 更新 Token 折线图 + UI-->>User: 展示筛选后的趋势图表 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..ac5aa885bf17 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: dashboard +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 用量统计 +roles: [团队管理员, 团队成员] +router_paths: [/account/usage?usageTab=dashboard] +--- + +# 用量看板 — 模块概览 + +## 1. 模块业务概述 + +用量看板是"账户→用量统计"模块下的数据可视化子能力,为团队管理员和团队成员提供 AI 资源消耗的图表化总览。它以折线图形式展示选定时间范围内的积分消耗走势和 Token(输入/输出)消耗明细,帮助用户直观了解团队的 AI 用量趋势。 + +看板与"用量明细"并列为用量统计页面的两个 Tab,用户可在看板(dashboard)和明细列表(detail)之间切换。看板侧重趋势分析,明细侧重精确记录查询。 + +本模块无子能力。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 用量看板 | 查看全团队用量趋势、按成员/部门/群组筛选、查看剩余积分 | 可使用全部筛选器(日期、来源、成员/部门/群组),可点击"查看剩余积分"按钮打开充值弹窗 | +| 团队成员 | 用量看板 | 查看个人用量趋势、按来源筛选 | 无法使用成员/部门/群组筛选器,仅可按日期和来源筛选 | + +**权限模型**: 基于 `userInfo.team.permission.hasManagePer` 字段区分管理员与普通成员。父页面根据该字段控制筛选器的可见性(管理员可见成员/部门/群组筛选),看板组件自身不包含权限判断逻辑。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 钱包/充值 | RechargeModal 充值弹窗 | 组件 | +| 共享基础设施 | MyBox 加载容器、useRequest 请求 Hook | 组件/Hook | +| 全局工具 | formatNumber 数字格式化 | 工具函数 | + +## 4. 对外共享 + +本模块组件(UsageDashboard、DashboardChart)仅被用量统计父页面内部使用,未被其他模块引用,不对外共享。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 用量看板 | /account/usage?usageTab=dashboard | projects/app/src/pageComponents/account/usage/Dashboard.tsx | 团队管理员, 团队成员 | 无 Tab,为用量统计页面的"看板"Tab 内容区 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3c43e6f2d3fa --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,55 @@ +--- +capability_label: detail +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: 用量统计 +roles: ["team_member", "team_admin"] +router_paths: ["/account/usage?usageTab=detail"] +--- + +# detail — 业务流程索引 + +## 业务场景列表 + +### 用量记录列表 + +- **业务描述**: 以分页表格形式查看团队/个人的用量消耗流水记录,支持多维度筛选和导出。 +- **触发入口**: 账户 → 用量统计 → 切换至「用量明细」Tab +- **路由路径**: `/account/usage?usageTab=detail` +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/UsageTable.tsx` +- **涉及角色**: 团队管理员、普通成员 +- **前置条件**: 用户已登录并加入团队;需有对应团队的数据访问权限 + +### 用量详情 + +- **业务描述**: 查看单条用量记录的计费模块明细,包括各模块的模型、Token 消耗、文本长度、时长等计费维度的具体数值。 +- **触发入口**: 用量记录列表 → 点击某条记录的「详情」按钮 +- **路由路径**: `/account/usage?usageTab=detail`(弹窗内,不改变路由) +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/UsageDetail.tsx` +- **涉及角色**: 团队管理员、普通成员 +- **前置条件**: 用量记录列表已加载且有数据 + +### 导出用量记录 + +- **业务描述**: 将当前筛选条件下的全部用量记录导出为 CSV 文件,方便外部数据分析。 +- **触发入口**: 用量记录列表 → 点击「导出」按钮 +- **路由路径**: `/account/usage?usageTab=detail`(触发下载,不改变路由) +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/UsageTable.tsx` +- **涉及角色**: 团队管理员、普通成员 +- **前置条件**: 当前筛选条件下存在用量数据(导出前会提示总数确认) + +## 场景关系 + +- **用量记录列表** → **用量详情**:列表为详情提供入口,详情依赖列表中的单条数据 +- **用量记录列表** → **导出用量记录**:导出操作的筛选条件与列表当前筛选状态同步 + +各场景独立触发,列表查看、详情查看、导出无严格先后依赖关系(导出前需列表已加载,但导出确认弹窗有取消选项)。 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 用量记录列表 | — | 账户→用量统计→用量明细Tab | `/account/usage?usageTab=detail` | team_member, team_admin | +| S02 | 用量详情 | — | 列表→「详情」按钮 | `/account/usage?usageTab=detail` | team_member, team_admin | +| S03 | 导出用量记录 | — | 列表→「导出」按钮 | `/account/usage?usageTab=detail` | team_member, team_admin | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e7c7346e4943 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,146 @@ +--- +capability_label: detail +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: 用量统计 +roles: ["team_member", "team_admin"] +router_paths: ["/account/usage?usageTab=detail"] +--- + +# detail — 业务流程详解 + +## 页面总览 + +用量明细页面提供一个可分页的用量记录数据表格,按时间倒序展示每条用量消耗。用户可通过筛选器缩小数据范围,查看每条记录的具体计费模块明细,或导出当前视图的数据为 CSV 文件。页面顶部包含 Tab 切换器和「查询剩余积分」入口,由父页面统一提供。 + +## 非 Tab 业务流程 + +### 用量记录列表加载 + +#### 步骤 1:进入用量明细 Tab + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在用量统计页面点击「用量明细」Tab 切换 | 无(Tab 切换后组件渲染触发数据请求) | 路由 query 中 usageTab 值为 "detail" 时渲染 UsageTableList 组件 | Tab 切换为「用量明细」高亮状态,页面切换为列表视图 | + +#### 步骤 2:首次加载用量列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入页面(组件挂载)后自动触发请求 | `POST /proApi/support/wallet/usage/getUsage`(串行,首次请求),参数:`dateStart`、`dateEnd`、`sources`(来源筛选数组,全选时为 undefined)、`memberFilter`(成员筛选对象,全选时为 undefined)、`projectName`、分页参数 `pageSize=20` | 无(首次加载必然执行)| 表格区域显示加载遮罩(`MyBox isLoading`),加载完成后渲染用量记录表格,底部显示分页控件 | + +#### 步骤 3:表格数据展示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (加载完成后自动渲染) | 无 | 数据量 > 0 时渲染表格行;数据量为 0 时显示空状态提示「暂无用量记录」 | 表格显示以下列:时间(YYYY/MM/DD HH:mm:ss 格式)、成员(头像+名称)、来源(通过 UsageSourceMap 映射为中文)、项目名称、积分消耗、Token 总数(输入+输出)、输入 Token、输出 Token、操作(「详情」按钮)。每行数据根据 source 字段映射显示对应的中文来源名称。appName 字段通过 i18n 翻译后显示 | + +#### 步骤 4:分页翻页 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页控件切换页码或每页条数 | `POST /proApi/support/wallet/usage/getUsage`(串行),参数在首次请求基础上更新 pageNum 或 pageSize | 无 | 表格区域短暂显示加载状态,数据更新为新页数据,分页控件刷新当前页码 | + +### 筛选用量记录 + +#### 步骤 1:选择时间范围 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击日期范围选择器,选择起止日期后确认 | `POST /proApi/support/wallet/usage/getUsage`(串行,依赖 dateRange 变化),dateStart/dateEnd 参数更新 | 无(选择任意范围均触 + +发请求)| 日期选择器显示新的日期范围,表格数据按新时间范围刷新 | + +#### 步骤 2:按成员/部门/群组筛选(仅管理员) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 管理员点击筛选模式下拉框,选择「成员」「部门」或「群组」模式 | 无(仅切换筛选模式,不立即发请求;但切换模式会重置选中状态为全选) | `hasManagePer === true` 时筛选模式区域可见,否则隐藏 | 筛选模式切换,对应的选择器控件切换(成员多选/部门树选择/群组多选)。切换模式时已有成员/部门/群组选中状态被重置为全选 | +| 在多选下拉中搜索并选择特定成员 | `GET /proApi/support/user/team/getMembers`(搜索成员列表时内部触发),后触发用量查询 | `filterMode === 'member'` 时可见 | 下拉列表按搜索关键词过滤,选中成员标签显示在输入框中,表格数据更新 | +| 在群组多选中选择特定群组 | 无(群组列表在页面加载时已获取),后触发用量查询 | `filterMode === 'group'` 且 `hasManagePer === true` | 选中群组标签显示在输入框中,表格数据更新 | +| 在部门树中选择特定部门 | 无(部门树内部自行加载),后触发用量查询 | `filterMode === 'org'` 且 `hasManagePer === true` | 选中部门显示在输入框中,表格数据更新 | + +#### 步骤 3:按来源筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击来源筛选下拉框,勾选/取消特定来源 | `POST /proApi/support/wallet/usage/getUsage`(串行,依赖 usageSources 变化),全选时 sources 参数为 undefined,部分选择时传入选中项数组 | 所有角色均可见 | 选中来源标签显示在输入框中,表格数据按选中来源过滤刷新 | + +#### 步骤 4:按项目名称搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在项目名称输入框中输入关键词 | `POST /proApi/support/wallet/usage/getUsage`(串行,依赖 projectName 变化,300ms 防抖后触发)| 所有角色均可见 | 输入框显示输入内容,300ms 防抖延迟后表格数据按项目名称模糊匹配刷新 | + +### 查看用量详情 + +#### 步骤 1:打开详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某条用量记录行的「详情」按钮 | 无(`UsageDetail` 组件内部仅使用传入的 usage 数据,不发请求)| 无 | 弹窗打开(`MyModal`),显示遮罩层 | + +#### 步骤 2:查看计费模块明细 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (弹窗渲染后自动展示) | 无 | 弹窗内展示以下信息:订单号(`usage.id`)、生成时间、项目名称、来源、总积分消耗(加粗)。计费模块表格根据数据动态显示列:有 `modelId` 时显示「AI模型」列,有 `tokens` 时显示「Token长度」列,有 `inputTokens`/`outputTokens`/`charsLength`/`duration`/`pages`/`count` 时各自显示对应列。模型ID通过 `systemModelList` 映射为模型名称显示 | 弹窗内容为静态展示,所有列按实际数据动态判定显示 | + +### 导出用量记录 + +#### 步骤 1:触发导出确认 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表格上方右侧的「导出」按钮 | 无(仅显示确认弹窗)| 无 | 弹出确认框(`PopoverConfirm`),提示文本包含当前筛选条件下的总条数(如「确认导出 XX 条用量记录?」),显示「确认」和「取消」按钮 | + +#### 步骤 2:确认导出并下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认弹窗中的「确认」按钮 | `POST /api/proApi/support/wallet/usage/exportUsage`,body 包含当前筛选参数(`dateStart`、`dateEnd`、`sources`、`memberFilter`、`projectName`)以及 `appNameMap`(应用名称 i18n 映射)、`sourcesMap`(来源 i18n 映射)、`title`(导出标题) | 无 | 浏览器触发 CSV 文件下载,文件名 `usage.csv`,文件内容中的来源名称和应用名已翻译为当前语言 | + +**失败场景**:导出接口报错时,由 `downloadFetch` 或 `useRequest` 中的统一错误处理机制显示错误提示,不阻塞页面。 + +### 查询剩余积分 + +#### 步骤 1:打开充值弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面顶部「查询剩余积分」按钮 | 无(`RechargeModal` 组件内部自行管理数据加载)| 无 | 弹窗打开(`RechargeModal`),展示积分余额和充值选项 | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 用量明细页 + participant API + participant Backend + + User->>UI: 切换到用量明细 Tab + UI->>API: POST /getUsage (首次加载) + API->>Backend: 查询用量记录 + Backend-->>API: 返回分页数据 + API-->>UI: 渲染表格+分页 + + User->>UI: 选择筛选条件 + UI->>API: POST /getUsage (筛选参数) + API->>Backend: 按筛选条件查询 + Backend-->>API: 返回筛选后数据 + API-->>UI: 更新表格 + + User->>UI: 点击详情按钮 + UI-->>User: 弹窗展示计费模块明细 + + User->>UI: 点击导出按钮 + UI-->>User: 弹出确认框 + + User->>UI: 确认导出 + UI->>API: POST /exportUsage + API->>Backend: 生成 CSV + Backend-->>API: 返回文件流 + API-->>UI: 触发下载 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..4598e612ab55 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: detail +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: 用量统计 +roles: ["team_member", "team_admin"] +router_paths: ["/account/usage?usageTab=detail"] +--- + +# detail — 模块概览 + +## 1. 模块业务概述 + +"用量明细"是账户用量统计模块下的一个 Tab 页,面向团队的所有成员(普通成员和管理员),提供以列表形式查看每条用量消耗记录的能力。用户可按时间范围、消费来源、成员/部门/群组以及项目名称进行筛选,定位到特定用量记录,并可点击每条记录的「详情」按钮查看该次消耗的计费模块明细。 + +该 Tab 与用量统计主页面中的「看板」Tab 形成互补:看板提供聚合维度的图表总览,用量明细提供逐条记录的精确追溯。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 用量明细 Tab | 查看所有成员用量、按成员/部门/群组筛选、查看用量详情、导出 CSV | 可切换筛选模式(成员/部门/群组)| +| 普通成员 | 用量明细 Tab | 查看自身用量、按来源筛选、查看用量详情、导出 CSV | 无按成员/部门/群组筛选的入口 | + +**权限模型**: 基于团队权限位 `userInfo.team.permission.hasManagePer` 控制——管理员可看到成员筛选入口(`filterMode` 切换器及对应 MultipleSelect),普通成员仅可按来源和项目名称筛选。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 全局钱包/用量 | `UsageSourceMap` 来源映射表,`UsageListItemType` 列表项类型 | 类型/常量 | +| 全局分页 | `usePagination` Hook,分页参数与响应类型 | Hook | +| 用户/团队 | `getUserUsages` 用量查询API,`useUserStore` 用户状态 | API/Store | +| 系统配置 | `useSystemStore` 模型列表(用量详情弹窗中模型名映射) | Store | +| 通用下载 | `downloadFetch` 文件下载工具(导出 CSV) | 工具函数 | + +## 4. 对外共享 + +本模块为 Tab 子能力,无对外共享的独立组件或 API。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 用量明细 Tab | `/account/usage?usageTab=detail` | `pageComponents/account/usage/UsageTable.tsx` | 所有团队成员 | 无子 Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5b7203674cf9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,75 @@ +--- +capability_label: 用量统计 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T11:16:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — 业务流程索引 + +## 查看用量看板 + +- **业务描述**: 以折线图形式展示选定时间范围内的积分消耗趋势和 Token(输入/输出/总计)消耗趋势,顶部汇总显示总积分和总 Token 数 +- **触发入口**: 账户中心 → 用量统计(默认进入看板 Tab) +- **路由路径**: `/account/usage?usageTab=dashboard` +- **页面组件位置**: `projects/app/src/pages/account/usage/index.tsx` + `projects/app/src/pageComponents/account/usage/Dashboard.tsx` +- **涉及角色**: 普通成员、管理员 +- **前置条件**: 已登录;管理员可按成员/群组/部门筛选查看不同维度的数据 + +## 查看用量明细 + +- **业务描述**: 以表格形式展示每次 AI 调用的详细记录,包含时间、成员、来源、项目名称、积分消耗、Token 消耗。点击「详情」可查看该次调用的计费模块明细 +- **触发入口**: 账户中心 → 用量统计 → 切换到「用量明细」Tab +- **路由路径**: `/account/usage?usageTab=detail` +- **页面组件位置**: `projects/app/src/pages/account/usage/index.tsx` + `projects/app/src/pageComponents/account/usage/UsageTable.tsx` +- **涉及角色**: 普通成员、管理员 +- **前置条件**: 已登录 + +## 导出用量记录 + +- **业务描述**: 将当前筛选条件下的用量数据导出为 CSV 文件下载 +- **触发入口**: 用量明细 Tab → 点击「导出」按钮 → 确认导出 +- **路由路径**: `/account/usage?usageTab=detail` +- **页面组件位置**: `projects/app/src/pageComponents/account/usage/UsageTable.tsx` +- **涉及角色**: 普通成员、管理员 +- **前置条件**: 已进入用量明细 Tab;当前筛选条件下有数据 + +## 筛选用量数据 + +- **业务描述**: 通过日期范围、用量来源、成员/群组/部门(管理员专属)等维度筛选用量数据 +- **触发入口**: 用量统计页面顶部的筛选器区域 +- **路由路径**: `/account/usage` +- **页面组件位置**: `projects/app/src/pages/account/usage/index.tsx` +- **涉及角色**: 普通成员(仅日期范围和来源筛选)、管理员(额外有成员/群组/部门筛选) +- **前置条件**: 管理员权限方可使用成员/群组/部门筛选 + +## 查看剩余积分 + +- **业务描述**: 快捷查看账户剩余积分,可直接跳转充值 +- **触发入口**: 用量统计页面右上角「查看剩余积分」按钮 +- **路由路径**: `/account/usage` +- **页面组件位置**: 两个 Tab 内均提供此入口 +- **涉及角色**: 普通成员、管理员 +- **前置条件**: 无 + +## 场景关系 + +- **筛选用量数据** → **查看用量看板** / **查看用量明细**: 筛选条件在两个 Tab 间共享,切换 Tab 时保留筛选状态 +- **查看用量明细** → **查看用量详情**: 在明细列表中点击某条记录的「详情」按钮,弹出详情弹窗 +- **查看用量明细** → **导出用量记录**: 筛选数据后导出 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 查看用量看板 | 用量看板 | 账户中心→用量统计(默认Tab) | /account/usage?usageTab=dashboard | 普通成员、管理员 | +| S02 | 查看用量明细 | 用量明细 | 账户中心→用量统计→切换到用量明细Tab | /account/usage?usageTab=detail | 普通成员、管理员 | +| S02-1 |  ├ 查看用量详情 | 用量明细 | 明细列表→点击某条记录的详情按钮 | /account/usage?usageTab=detail | 普通成员、管理员 | +| S02-2 |  ├ 导出用量记录 | 用量明细 | 明细列表→点击导出按钮→确认 | /account/usage?usageTab=detail | 普通成员、管理员 | +| S03 | 筛选用量数据 | — | 页面顶部筛选器(日期、来源、成员/群组/部门) | /account/usage | 普通成员、管理员 | +| S04 | 查看剩余积分 | — | 右上角「查看剩余积分」按钮 | /account/usage | 普通成员、管理员 | + +> Tab 子能力(用量看板、用量明细)的详细流程参见各自子目录下的 [业务流程详解](../用量统计/dashboard/业务流程详解.md) 和 [业务流程详解](../用量统计/detail/业务流程详解.md)。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..4a1619a0459e --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,88 @@ +--- +capability_label: 用量统计 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T11:17:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — 业务流程详解 + +## 页面总览 + +用量统计页面通过 `AccountContainer` 容器包裹,顶部提供 Tab 切换(用量看板/用量明细)和筛选器区域,下方根据选中的 Tab 分别渲染看板图表或明细表格。筛选条件在两个 Tab 之间共享,切换 Tab 时保留筛选状态。管理员拥有成员/群组/部门维度的额外筛选能力。 + +## Tab 子能力索引 + +| Tab | 业务描述 | 步骤数 | API 数 | 来源 | 详细文档 | +|-----|---------|--------|--------|------|---------| +| 用量看板 | 折线图展示积分/Token消耗趋势 | 3 | 1 | 本模块 | [业务流程详解](../用量统计/dashboard/业务流程详解.md) | +| 用量明细 | 表格展示调用记录,支持详情查看和导出 | 5 | 2 | 本模块 | [业务流程详解](../用量统计/detail/业务流程详解.md) | + +## 公共业务流程 + +### 页面初始化与权限校验 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从账户中心导航至用量统计 | 无(SSR 服务端获取 i18n 词条) | 无 | 页面加载,AccountContainer 容器渲染,显示左侧账户导航和右侧内容区域 | +| 页面完成渲染 | 无 | 无 | 默认展示用量看板 Tab(`usageTab=dashboard`);如 URL query 指定为 detail 则展示明细 Tab | +| 获取当前用户信息 | 无(useUserStore 读取内存状态) | 无 | 用户头像、团队信息就绪;`hasManagePer` 决定是否显示成员筛选器 | +| 获取团队成员列表(管理员) | GET 内部 API → getTeamMembers | `hasManagePer === true` 时执行 | 成员下拉列表加载,显示滚动分页加载更多 | +| 获取群组列表(管理员) | GET 内部 API → getGroupList | `hasManagePer === true` 时执行 | 群组下拉列表加载 | +| 获取部门树(管理员) | GET 内部 API → getOrgList | `hasManagePer === true` 且切换到部门筛选模式时执行 | 部门树选择器数据就绪 | + +### 筛选器操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改日期范围 | 无(仅更新本地状态 `dateRange`) | 无 | 筛选参数更新,触发对应 Tab 的数据重新加载 | +| 切换筛选模式(成员/群组/部门) | 无(仅更新 `filterMode`) | 仅管理员可见此控件 | 筛选器切换为对应模式的选择器;已选值重置(全选状态) | +| 按成员筛选 | 无(本地状态更新 `selectTmbIds`) | 筛选模式为「成员」 | 筛选参数更新,触发数据重新加载 | +| 按群组筛选 | 无(本地状态更新 `selectGroupIds`) | 筛选模式为「群组」 | 筛选参数更新,触发数据重新加载 | +| 按部门筛选(OrgTreeSelector) | GET 内部 API → getOrgList(懒加载子节点) | 筛选模式为「部门」;展开部门节点时触发子节点加载 | 树形选择器加载子部门;选中部门后触发数据重新加载 | +| 按用量来源筛选 | 无(本地状态更新 `usageSources`) | 所有用户可见 | 筛选参数更新,触发数据重新加载 | +| 输入项目名称搜索 | 无(本地状态,300ms 防抖后更新 `projectName`) | 所有用户可见 | 输入 300ms 后筛选参数更新,触发数据重新加载 | + +### 查看剩余积分快捷入口 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「查看剩余积分」按钮 | 无(打开 RechargeModal) | 所有用户可见 | 弹出充值弹窗,显示当前积分余额和充值选项 | +| 在充值弹窗中完成支付 | POST 充值 API(由 RechargeModal 内部处理) | 用户完成支付 | 弹窗关闭,积分余额更新 | + +### Tab 切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「用量看板」Tab | 无(URL query 更新为 `usageTab=dashboard`) | 当前非看板 Tab 时 | 切换为 UsageDashboard 组件,加载看板数据(积分折线图 + Token 折线图) | +| 点击「用量明细」Tab | 无(URL query 更新为 `usageTab=detail`) | 当前非明细 Tab 时 | 切换为 UsageTableList 组件,加载用量列表数据(分页表格) | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 用量统计页面 + participant API as 后端 API + + User->>UI: 导航至 /account/usage + UI->>UI: 渲染 AccountContainer + 默认看板 Tab + UI->>UI: 获取用户权限 hasManagePer + + alt 管理员 + UI->>API: GET getTeamMembers(成员列表) + UI->>API: GET getGroupList(群组列表) + end + + User->>UI: 修改日期范围 / 筛选条件 + UI->>UI: 更新 filterParams + + alt 用量看板 Tab + Note over UI,API: 各 Tab 详见子文档 + else 用量明细 Tab + Note over UI,API: 各 Tab 详见子文档 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..af558ef76ee9 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,19 @@ +--- +capability_label: 用量统计 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T11:20:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — 共享能力清单 + +## 说明 + +本模块未检测到对外共享能力(imported_by 模块数: 0,共享能力数: 0)。 + +模块内的所有组件(UsageTableList、UsageDashboard、DashboardChart、UsageDetail、OrgTreeSelector)均为内部使用,不对外暴露。模块通过消费外部模块的能力(账户容器、公共组件库、钱包 API 等)来实现功能。 + +跨模块依赖摘要请参见 [08-模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..64afc4bfc5aa --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,57 @@ +--- +capability_label: 用量统计 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T11:15:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — 模块概览 + +## 1. 模块业务概述 + +用量统计是账户中心下用于查看团队 AI 资源消耗情况的模块。它为团队成员提供用量明细查询和趋势看板,帮助用户了解积分消耗、Token 使用量等关键指标。管理员可以查看全团队所有成员的用量数据,并通过成员、群组、部门等多种维度进行筛选;普通成员则仅能看到自己的用量记录。 + +模块包含两个视图:「用量看板」以折线图形式展示指定时间范围内的积分和 Token 消耗趋势;「用量明细」以表格形式列出每次调用的详细记录,支持导出为 CSV 文件。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 普通成员 | 用量看板、用量明细 | 查看自己的用量趋势和数据详情;导出自己的用量记录 | 仅能看到自己的用量,无成员/群组/部门筛选入口 | +| 管理员 | 用量看板、用量明细 | 查看全团队所有成员的用量;按成员/群组/部门筛选;按用量来源筛选;导出用量记录 | 拥有成员筛选器,可切换筛选模式查看不同维度数据 | + +**权限模型**: 基于用户信息的团队权限字段(`userInfo.team.permission.hasManagePer`),管理员可见成员筛选控件,普通成员仅展示数据。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户 | AccountContainer 页面容器(提供统一的账户中心布局) | 组件 | +| 团队管理 | 获取团队成员列表(getTeamMembers)、群组列表(getGroupList)、部门树(getOrgList) | API | +| 用户系统 | useUserStore 获取当前用户信息和权限 | Store | +| 钱包/计费 | 用量列表查询(getUserUsages)、看板数据查询(getDashboardData)、用量导出、用量来源枚举和标签映射 | API | +| 充值 | RechargeModal 充值弹窗(查看剩余积分的快捷入口) | 组件 | + +## 4. 对外共享 + +本模块未检测到对外共享能力。模块内的组件(UsageTableList、UsageDashboard、DashboardChart、UsageDetail、OrgTreeSelector)均为模块内部使用,未被其他模块引用。 + +> 跨模块依赖详情完整列表见 [11-共享能力清单](./共享能力清单.md)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 用量统计 | /account/usage | projects/app/src/pages/account/usage/index.tsx | 普通成员、管理员 | 含 2 个 Tab:用量看板、用量明细(详见 [09-业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +本模块的入口页面通过 FillRowTabs 实现 Tab 切换,已拆分为以下 Tab 子能力: + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 用量看板 | dashboard | [08-模块概览](../用量统计/dashboard/模块概览.md) | +| 用量明细 | detail | [08-模块概览](../用量统计/detail/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e54ebe729276 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,61 @@ +--- +capability_label: "第三方集成" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 业务流程索引 + +## 查看第三方集成列表 + +- **业务描述**: 进入第三方集成管理页面,以卡片网格形式查看所有已启用的第三方服务集成状态(已配置/未配置)和用量信息。 +- **触发入口**: 账户管理页面侧边栏 → "第三方集成"菜单项(桌面端),或账户页面顶部 Tab 栏 → "第三方集成"(移动端) +- **路由路径**: `/account/thirdParty` +- **页面组件位置**: `projects/app/src/pages/account/thirdParty/index.tsx` +- **涉及角色**: owner, member +- **前置条件**: 已登录状态 + +## 配置 Laf 账号 + +- **业务描述**: 管理员配置 Laf 云函数平台账号,输入 PAT(个人访问令牌)验证后选择目标应用,保存后团队可在工作流中调用 Laf 云函数。 +- **触发入口**: 第三方集成管理页 → 点击 "Laf 账号" 卡片 +- **路由路径**: `/account/thirdParty`(模态弹窗) +- **页面组件位置**: `projects/app/src/components/support/laf/LafAccountModal.tsx` +- **涉及角色**: owner +- **前置条件**: 系统配置中已启用 Laf 环境(`feConfigs.lafEnv` 存在) + +## 配置 OpenAI 账号 + +- **业务描述**: 管理员配置 OpenAI 或 OneAPI 兼容的 API 密钥和请求地址,团队在应用中使用 AI 对话、问题分类和内容提取功能时将优先使用此密钥,不从平台计费。 +- **触发入口**: 第三方集成管理页 → 点击 "OpenAI/OneAPI 账号" 卡片 +- **路由路径**: `/account/thirdParty`(模态弹窗) +- **页面组件位置**: `projects/app/src/pageComponents/account/thirdParty/OpenAIAccountModal.tsx` +- **涉及角色**: owner +- **前置条件**: 系统配置中已显示 OpenAI 账号配置入口(`feConfigs.show_openai_account` 为 true) + +## 配置工作流变量 + +- **业务描述**: 管理员为平台定义的第三方工作流变量提供商配置鉴权参数(如 API Token),保存后团队在工作流中可使用对应变量。 +- **触发入口**: 第三方集成管理页 → 点击对应工作流变量提供商的卡片 +- **路由路径**: `/account/thirdParty`(模态弹窗) +- **页面组件位置**: `projects/app/src/pageComponents/account/thirdParty/WorkflowVariableModal.tsx` +- **涉及角色**: owner +- **前置条件**: 系统配置中定义了外部工作流变量提供商(`feConfigs.externalProviderWorkflowVariables` 非空);该提供商入口已开启 + +## 场景关系 + +- **查看第三方集成列表** → 是进入模块的默认场景,其他三个配置场景均从该页面触发 +- **配置 Laf 账号**、**配置 OpenAI 账号**、**配置工作流变量** 三个场景独立执行,无先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看第三方集成列表 | 账户管理 → 第三方集成菜单 | /account/thirdParty | owner, member | +| S02 | 配置 Laf 账号 | 列表页 → 点击 Laf 卡片 | /account/thirdParty | owner | +| S03 | 配置 OpenAI 账号 | 列表页 → 点击 OpenAI 卡片 | /account/thirdParty | owner | +| S04 | 配置工作流变量 | 列表页 → 点击变量提供商卡片 | /account/thirdParty | owner | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..fb09ef413c81 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,297 @@ +--- +capability_label: "第三方集成" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 业务流程详解 + +## 页面总览 + +第三方集成管理页面以卡片网格布局展示所有已启用的第三方服务集成。每个卡片显示服务名称、图标、说明描述、配置状态标签(已配置/未配置),以及已配置服务的使用量进度条。桌面端通过账户侧边栏导航进入,移动端通过顶部 Tab 栏切换进入。非管理员只能查看状态,无法进行配置操作。 + +--- + +### 查看第三方集成列表 + +> 进入页面后展示所有已启用的第三方集成卡片,卡片的可见性和状态受系统配置和团队已有配置影响。 + +#### 步骤 1:页面加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从账户侧边栏或 Tab 栏进入第三方集成页面 | GET `/support/user/team/thirtdParty/checkUsage?key={key}`(对每个工作流变量提供商并行请求) | 无 | 页面容器(AccountContainer)加载,显示"第三方账号"标题和描述文字;MyBox 组件显示加载状态(isLoading) | + +#### 步骤 2:集成卡片渲染 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载完成 | 无需额外 API(系统配置和用户信息已由 useSystemStore / useUserStore 在应用初始化时获取) | — | 加载完成,按 1-4 列响应式网格渲染卡片 | + +卡片渲染规则: +- **Laf 账号卡片**:`feConfigs.lafEnv` 存在时显示;已配置时状态标签为绿色"已配置" +- **OpenAI 账号卡片**:`feConfigs.show_openai_account` 为 true 时显示;`userInfo.team.openaiAccount.key` 存在时为"已配置" +- **工作流变量卡片**:来自 `feConfigs.externalProviderWorkflowVariables` 数组,每个 provider 一张卡片;`userInfo.team.externalWorkflowVariables[key]` 存在时为"已配置" +- 已配置且有用量数据的卡片底部显示使用量进度条 + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET `/support/user/team/thirtdParty/checkUsage` | `key={providerKey}`(对每个工作流变量 provider 并行请求) | 返回 `{total, used}` 或 `undefined` | 已配置的工作流变量卡片显示使用量进度条 | +| 系统配置读取 | 无(从 Store 缓存读取) | — | 从 `feConfigs` 读取 Laf 环境地址、OpenAI 入口开关、外部变量列表 | 决定卡片的可见性和顺序 | +| 用户配置读取 | 无(从 Store 缓存读取) | — | 从 `userInfo.team` 读取 lafAccount、openaiAccount、externalWorkflowVariables | 决定每张卡片的"已配置/未配置"状态 | + +#### 步骤 3:权限交互 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 非管理员点击任意配置卡片 | 无 | `userInfo.team.role !== owner` | 弹出警告 toast:"请联系管理员配置";不打开模态弹窗 | +| 管理员悬停在卡片上 | 无 | 角色为 owner | 卡片边框变为主题色(primary.600),表明可点击 | + +#### 数据加载详情(列表页) + +- **首次加载**:页面渲染时通过 `useRequest` 并行请求所有工作流变量的 usage,同时从 Store 缓存读取 feConfigs 和 userInfo +- **刷新依赖**:`feConfigs.externalProviderWorkflowVariables` 或 `userInfo.team.externalWorkflowVariables` 变化时自动重新请求 usage +- **无分页/排序/筛选**:页面为纯展示,卡片数量由系统配置决定 + +```mermaid +sequenceDiagram + participant User + participant UI as 第三方集成页面 + participant Store as useSystemStore/useUserStore + participant API as Backend + + User->>UI: 进入账户→第三方集成 + UI->>Store: 读取 feConfigs + Store-->>UI: Laf环境、OpenAI入口、外部变量列表 + UI->>Store: 读取 userInfo.team + Store-->>UI: lafAccount、openaiAccount、externalWorkflowVariables + + par 对每个工作流变量 provider + UI->>API: GET /support/user/team/thirtdParty/checkUsage?key={key} + API-->>UI: {total, used} | undefined + end + + UI->>UI: 过滤 isOpen=true 的卡片 + UI->>UI: 渲染卡片网格(已配置/未配置 + 用量进度条) + UI-->>User: 展示第三方集成管理页 +``` + +--- + +### 配置 Laf 账号 + +> 管理员配置 Laf 云函数平台账号,使得团队工作流中的 Laf 调用节点可以使用团队共享的账号鉴权。 + +#### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 管理员点击 Laf 账号卡片 | 无 | 角色必须为 owner;`feConfigs.lafEnv` 必须存在 | 打开 LafAccountModal 弹窗,标题为"Laf Account Setting",显示 Laf 介绍文字和文档链接 | + +#### 步骤 2:输入 PAT 并验证 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在 PAT 输入框中输入个人访问令牌 | 无 | — | 输入框显示已输入内容;"验证"按钮从禁用变为可用 | +| 点击"验证"按钮 | POST Laf PAT 转 Token(通过 `postLafPat2Token` 调用 Laf API) | PAT 未填时按钮禁用 | "验证"按钮变为 loading 状态 | +| 等待验证结果 | — | 验证成功:token 写入表单 | PAT 输入框和"验证"按钮消失,显示"已验证"按钮;出现"当前应用"下拉选择器 | +| | — | 验证失败:弹出错误 toast | 表单保持 PAT 输入状态,显示错误提示信息 | + +#### 步骤 3:选择应用并保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 验证成功后自动加载应用列表 | GET Laf 应用列表(通过 `getLafApplications` 调用 Laf API) | 需要 lafToken 存在后才触发 | 下拉选择器自动选中第一个运行中的应用 | +| 手动选择目标应用 | 无 | 应用列表过滤 `state === 'Running'` 的应用 | 下拉选择器更新选中值 | +| 点击"更新"按钮 | PUT `/support/user/team/update`,参数 `{lafAccount: {token, appid, pat}}` | appid 必须已选择(否则按钮不显示) | "更新"按钮变为 loading;成功:关闭弹窗,刷新用户信息,卡片状态更新为"已配置" | +| | — | 失败 | 显示错误 toast:"更新失败",弹窗保持打开 | + +#### 步骤 4:取消或关闭 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"关闭"按钮 | PUT `/support/user/team/update`(清空 lafAccount) | lafToken 已验证时(已进入应用选择状态) | 先清空团队 Laf 配置,再刷新用户信息后关闭弹窗 | +| 点击"关闭"按钮 | 无 | lafToken 未验证(仍在 PAT 输入状态) | 直接刷新用户信息并关闭弹窗 | + +#### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| PAT | 文本输入 | 是(验证阶段) | 空 | 任意字符串 | 否 | Laf 平台的个人访问令牌,验证后隐藏 | +| Token | 隐藏字段 | 自动填充 | 空 | 由 PAT 验证后自动获取 | 是 | 验证 PAT 后自动填入,不可手动编辑 | +| 当前应用 | 下拉选择 | 是(保存阶段) | 自动选中第一个运行中的应用 | 仅列出 `state === 'Running'` 的应用 | 否 | PAT 验证后出现,手动选择目标 Laf 应用 | + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| PAT 不可为空 | 点击验证按钮(按钮禁用) | 按钮在 PAT 为空时处于禁用状态 | +| PAT 无效或 Laf 服务不可用 | 验证 API 调用失败 | "Invalid Env" | + +**前后置条件**: +- **前置条件**:用户为团队 owner;系统配置中 `feConfigs.lafEnv` 已设置 +- **后置影响**:保存成功后,团队 `lafAccount` 信息更新(存储在 `userInfo.team.lafAccount`);工作流中的 Laf 节点可使用此账号调用云函数;非管理员不受影响 +- **失败场景**:Laf 服务不可达时 PAT 验证失败;应用列表加载失败时重置表单并提示"获取应用失败" + +```mermaid +sequenceDiagram + participant User + participant UI as LafAccountModal + participant Laf as Laf Platform + participant API as FastGPT Backend + + User->>UI: 点击 Laf 卡片 + UI-->>User: 打开配置弹窗 + + User->>UI: 输入 PAT + User->>UI: 点击"验证" + UI->>Laf: POST PAT → Token + alt 验证成功 + Laf-->>UI: Token + UI->>Laf: GET 应用列表 (token) + Laf-->>UI: [{name, appid, state}] + UI->>UI: 过滤 Running 应用,自动选中第一个 + UI-->>User: 显示"已验证" + 应用下拉框 + User->>UI: 选择应用,点击"更新" + UI->>API: PUT /support/user/team/update {lafAccount: {token, appid, pat}} + API-->>UI: 更新成功 + UI->>UI: 刷新用户信息 + UI-->>User: 关闭弹窗,卡片显示"已配置" + else 验证失败 + Laf-->>UI: 错误 + UI-->>User: 错误 toast,保持 PAT 输入状态 + end +``` + +--- + +### 配置 OpenAI 账号 + +> 管理员配置 OpenAI 或 OneAPI 兼容的 API 密钥和 BaseUrl。配置后团队在应用中使用 AI 对话、问题分类和内容提取时优先使用此密钥,不从平台计费。 + +#### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 管理员点击 OpenAI 账号卡片 | 无 | 角色必须为 owner;`feConfigs.show_openai_account` 必须为 true | 打开 OpenAIAccountModal 弹窗,标题为"OpenAI/OneAPI 账号",显示 OpenAPI 使用说明 | + +#### 步骤 2:填写并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 填写 API Key 和 BaseUrl | 无 | — | 表单字段实时更新;BaseUrl 有默认 placeholder 提示"请求地址,默认为 openai 官方。可填中转地址,未自动补全 v1" | +| 点击"确认"按钮 | PUT `/support/user/team/update`,参数 `{openaiAccount: {key, baseUrl}}` | — | "确认"按钮变为 loading;成功:关闭弹窗,刷新用户信息,卡片状态更新为"已配置" | +| | — | 失败 | 显示错误 toast:"更新失败",弹窗保持打开 | + +#### 步骤 3:取消 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮 | 无 | — | 直接关闭弹窗,不保存修改 | + +#### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| API Key | 文本输入 | 否 | 已有配置值或空 | 任意字符串 | 否 | OpenAI/OneAPI 的 API 密钥 | +| BaseUrl | 文本输入 | 否 | 已有配置值或空 | 任意 URL | 否 | 请求地址,默认 OpenAI 官方;可填自定义中转地址 | + +**前后置条件**: +- **前置条件**:用户为团队 owner;系统配置中 `feConfigs.show_openai_account` 为 true +- **后置影响**:保存成功后,团队 `openaiAccount` 更新;应用中 AI 功能优先使用此配置的密钥,不从平台计费;请确认密钥有访问对应模型的权限 +- **失败场景**:网络异常或后端错误导致保存失败时提示"更新失败" + +```mermaid +sequenceDiagram + participant User + participant UI as OpenAIAccountModal + participant API as FastGPT Backend + + User->>UI: 点击 OpenAI 账号卡片 + UI-->>User: 打开配置弹窗(API Key + BaseUrl) + User->>UI: 填写 API Key 和 BaseUrl + User->>UI: 点击"确认" + UI->>API: PUT /support/user/team/update {openaiAccount: {key, baseUrl}} + alt 成功 + API-->>UI: 更新成功 + UI->>UI: 刷新用户信息 + UI-->>User: 关闭弹窗,卡片显示"已配置" + else 失败 + API-->>UI: 错误 + UI-->>User: toast "更新失败",保持弹窗 + end +``` + +--- + +### 配置工作流变量 + +> 管理员为平台定义的第三方工作流变量提供商配置鉴权参数。配置后系统会请求提供商 API 校验使用量,团队工作流可使用对应变量。 + +#### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 管理员点击某工作流变量提供商卡片 | 无 | 角色为 owner;该 provider 的 `isOpen` 为 true | 打开 WorkflowVariableModal 弹窗,标题为"{provider名称} 配置",显示该提供商的说明描述 | + +#### 步骤 2:填写参数值并提交 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在输入框中填写参数值 | 无 | — | 输入框实时更新;placeholder 提示"输入参数值。输入空值表示删除该配置。" | +| 点击"确认"按钮 | PUT `/support/user/team/update`,参数 `{externalWorkflowVariable: {key: providerKey, value}}` | — | "确认"按钮变为 loading;成功:关闭弹窗,刷新用户信息 | +| | — | 失败 | 显示错误 toast:"更新失败",弹窗保持打开 | + +#### 步骤 3:取消 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮 | 无 | — | 直接关闭弹窗,不保存修改 | + +#### 表单与交互详情 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 参数值 | 文本输入 | 否 | 空 | 任意字符串;输入空值表示删除该配置 | 否 | 第三方服务的鉴权参数(如 API Token),保存后不会再次返回前端 | + +**校验规则**: + +无前端校验,值直接提交。 + +**前后置条件**: +- **前置条件**:用户为团队 owner;系统配置中 `feConfigs.externalProviderWorkflowVariables` 包含该 provider 且 `isOpen` 为 true +- **后置影响**:保存成功后,团队 `externalWorkflowVariables[key]` 更新;页面会自动重新请求该 provider 的使用量数据,卡片显示用量进度条 +- **失败场景**:保存失败时提示"更新失败";参数值保存后不会再次返回前端,以保障安全性 + +```mermaid +sequenceDiagram + participant User + participant UI as WorkflowVariableModal + participant API as FastGPT Backend + + User->>UI: 点击变量提供商卡片 + UI-->>User: 打开配置弹窗(说明 + 参数值输入框) + User->>UI: 输入参数值 + User->>UI: 点击"确认" + UI->>API: PUT /support/user/team/update {externalWorkflowVariable: {key, value}} + alt 成功 + API-->>UI: 更新成功 + UI->>UI: 刷新用户信息(触发 usage 重新请求) + UI-->>User: 关闭弹窗,卡片更新用量 + else 失败 + API-->>UI: 错误 + UI-->>User: toast "更新失败",保持弹窗 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..88b2468450dc --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: "第三方集成" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 + +## 模块业务概述 + +第三方集成模块为团队管理员提供统一的外部服务配置入口。管理员可在此连接 Laf 云函数平台、OpenAI/OneAPI 兼容的 API 账号,以及平台定义的第三方工作流变量(如外部服务鉴权 Token)。已配置的第三方服务可在团队级别共享,供团队所有成员在应用工作流中使用,无需每个成员单独配置。 + +本模块属于账户管理的子模块之一,面向团队管理员提供配置能力,团队成员仅可查看已配置状态。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队拥有者(owner) | 第三方集成管理页 | 查看所有集成;点击卡片配置 Laf 账号、OpenAI 账号、工作流变量 | 唯一可执行配置操作的角色 | +| 团队成员(非 owner) | 第三方集成管理页 | 查看所有集成的配置状态;点击卡片时提示"请联系管理员配置" | 无配置权限,仅可查看是否有配置 | + +**权限模型**: 基于用户 `team.role` 的字符串比较(`TeamMemberRoleEnum.owner`),在页面组件的 `onClick` 事件中进行判断。非 owner 角色点击配置卡片时弹出提示 toast。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享基础设施 — AccountContainer | 账户页面通用容器(侧边栏导航、移动端 Tab 切换) | 组件 | +| 共享基础设施 — useSystemStore | 读取系统配置(feConfigs.lafEnv、show_openai_account、externalProviderWorkflowVariables) | Store | +| 共享基础设施 — useUserStore | 读取/刷新用户团队信息(lafAccount、openaiAccount、externalWorkflowVariables) | Store | +| 共享基础设施 — putUpdateTeam | 更新团队第三方配置的统一 API | API | +| 工作台 — LafAccountModal | 工作流节点 NodeLaf 中使用 Laf 账号配置 | 组件 | + +## 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| LafAccountModal | 工作台 → 应用工作流节点 NodeLaf | 组件 | + +> 完整定义见 [15-共享组件清单](../../../技术知识库/账户/第三方集成/共享组件清单.md)。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 第三方集成管理 | /account/thirdParty | projects/app/src/pages/account/thirdParty/index.tsx | owner / member | 以卡片网格展示三类集成:Laf 账号、OpenAI 账号、工作流变量,含配置状态标识和用量进度条 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1481f559d90d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,76 @@ +--- +capability_label: 自定义域名 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 团队所有者 +router_paths: + - /account/customDomain +--- +# 自定义域名 — 业务流程索引 + +## 业务场景列表 + +### 查看自定义域名列表 + +- **业务描述**: 团队所有者进入自定义域名管理页面,查看已绑定的域名列表及其状态、提供商、CNAME 记录等信息。 +- **触发入口**: 账户管理侧边栏 → "自定义域名" 标签 +- **路由路径**: `/account/customDomain` +- **页面组件位置**: `projects/app/src/pages/account/customDomain/index.tsx` +- **涉及角色**: 团队所有者 +- **前置条件**: Plus 订阅已激活,套餐包含自定义域名配额 + +### 添加自定义域名 + +- **业务描述**: 团队所有者选择一个 DNS 提供商,输入要绑定的自定义域名,系统生成 CNAME 记录。用户在自己的 DNS 提供商处配置 CNAME 解析后,系统自动检测 DNS 解析状态,解析成功后确认创建。 +- **触发入口**: 自定义域名列表页 → "添加" 按钮 +- **路由路径**: `/account/customDomain`(弹窗模式) +- **页面组件位置**: `projects/app/src/pages/account/customDomain/index.tsx` → 动态加载 `createModal.tsx` +- **涉及角色**: 团队所有者 +- **前置条件**: 当前域名数量未达到套餐配额上限 + +### 编辑/激活自定义域名 + +- **业务描述**: 对于状态为"未激活"的域名,团队所有者可重新进入编辑流程,再次检测 DNS 解析状态后执行激活操作。 +- **触发入口**: 自定义域名列表中未激活域名的 "编辑" 按钮 +- **路由路径**: `/account/customDomain`(弹窗模式,复用创建弹窗) +- **页面组件位置**: `projects/app/src/pages/account/customDomain/index.tsx` → 动态加载 `createModal.tsx`(refresh 模式) +- **涉及角色**: 团队所有者 +- **前置条件**: 域名状态为 inactive(未激活) + +### 删除自定义域名 + +- **业务描述**: 团队所有者删除不再需要的自定义域名绑定。 +- **触发入口**: 自定义域名列表中每条域名的 "删除" 按钮 → 确认弹窗 +- **路由路径**: `/account/customDomain` +- **页面组件位置**: `projects/app/src/pages/account/customDomain/index.tsx` +- **涉及角色**: 团队所有者 +- **前置条件**: 无 + +### 升级套餐(未订阅时) + +- **业务描述**: 未订阅 Plus 或套餐不含自定义域名配额的团队所有者,在自定义域名页面看到升级引导提示,点击跳转到价格页面进行套餐升级。 +- **触发入口**: 自定义域名列表页的空状态提示 → "升级套餐" 按钮 +- **路由路径**: `/account/customDomain` → 跳转 `/price` +- **页面组件位置**: `projects/app/src/pages/account/customDomain/index.tsx` +- **涉及角色**: 团队所有者 +- **前置条件**: 当前套餐不含自定义域名功能 + +## 场景关系 + +- **查看列表** → **添加域名**: 用户在列表中点击"添加"按钮发起添加流程,添加成功后列表自动刷新。 +- **添加域名** → **编辑/激活**: 添加域名时若 DNS 未解析成功,域名状态为 inactive,用户可后续通过编辑功能重新检测 DNS 并激活。 +- **查看列表** → **删除域名**: 用户在列表中直接删除任意域名。 +- **查看列表** → **升级套餐**: 未订阅用户进入页面即看到升级引导。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看自定义域名列表 | 账户侧边栏 → 自定义域名 | /account/customDomain | 团队所有者 | +| S02 | 添加自定义域名 | 列表页 → 添加按钮 | /account/customDomain | 团队所有者 | +| S03 | 编辑/激活自定义域名 | 列表页 → 未激活域名的编辑按钮 | /account/customDomain | 团队所有者 | +| S04 | 删除自定义域名 | 列表页 → 删除按钮 → 确认 | /account/customDomain | 团队所有者 | +| S05 | 升级套餐 | 空状态提示 → 升级套餐按钮 | /account/customDomain → /price | 团队所有者 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..b18ff3bb8576 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,293 @@ +--- +capability_label: 自定义域名 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 团队所有者 +router_paths: + - /account/customDomain +--- +# 自定义域名 — 业务流程详解 + +## 页面总览 + +自定义域名管理页面以表格形式展示团队已绑定的所有自定义域名,提供添加、编辑、删除等全生命周期管理。页面采用弹窗模式处理创建和激活流程,无需页面跳转。针对未订阅用户,页面展示升级引导。 + +本页面无 Tab 结构,所有操作在单页内完成。 + +--- + +### S01 查看自定义域名列表 + +> 团队所有者进入自定义域名管理页面,查看已绑定的域名及其 CNAME 记录、提供商、激活状态等信息。 + +#### 步骤 1:页面加载与域名列表查询 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从账户侧边栏点击"自定义域名"标签进入页面 | GET /proApi/support/customDomain/list | 无分支,始终执行 | 页面显示 MyLoading 加载动画,表格区域为空;加载完成后表格渲染域名列表或空状态 | + +#### 步骤 2:展示域名列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待列表加载完成 | 无 | 列表有数据 | 表格展示每条域名的域名、CNAME 记录、提供商名称(中文翻译)、状态标签(绿色"已激活"/红色"未激活")、操作按钮(删除 + 编辑(仅未激活状态可见));标题显示已用/配额数量 | +| | | 列表为空 且 有自定义域名权限 | 表格区域显示空状态(无提示文案,仅空白表格) | +| | | 列表为空 且 无自定义域名权限(套餐不含或未订阅) | 表格区域显示空状态:提示文案"升级套餐以使用自定义域名"+"升级套餐"按钮 | + +#### 步骤 3:查看域名详情信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览表格中的域名记录 | 无(数据已在列表中) | 无分支 | 用户可看到每条域名的完整信息:域名地址、CNAME 目标域名、DNS 提供商名称、激活状态标签 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /proApi/support/customDomain/list | 无参数 | 直接使用返回的数组 | 表格渲染域名列表 | + +- 分页参数:当前接口返回全量列表,无分页 +- 排序规则:按后端返回顺序展示,无客户端排序 +- 筛选条件:无客户端筛选,全量展示 +- 特殊列的渲染逻辑: + - 提供商列:通过 `providerMap` 映射为 i18n 翻译 key(如 `account:custom_domain.provider.aliyun`),由 `t()` 函数翻译为中文 + - 状态列:active(已激活)显示绿色 Tag,inactive(未激活)显示红色 Tag + +--- + +### S02 添加自定义域名 + +> 团队所有者点击"添加"按钮,在弹出的创建对话框中完成 DNS 提供商选择、域名输入、CNAME 配置和 DNS 解析验证。 + +#### 步骤 1:打开发布对话框 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"添加"按钮 | 无 | "添加"按钮在 `isSupportCustomDomain` 为 true 时可用,为 false 时灰色不可点击 | 动态加载 CreateCustomDomainModal 组件;弹窗打开,标题为"自定义域名",提供商默认选中"腾讯云",域名输入框为空,DNS 记录区域显示 CNAME 域名 | + +> **按钮可用性条件**:`isSupportCustomDomain` = `teamPlanStatus.standard.customDomain > 0`,即团队套餐必须包含自定义域名配额且配额大于 0。 + +#### 步骤 2:选择 DNS 提供商 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击阿里云/腾讯云/火山引擎的提供商卡片 | 无 | 域名输入框为空(编辑模式)时可切换;refresh 模式下不可切换(置灰) | 选中的提供商卡片显示蓝色边框和蓝色背景;CNAME 域名根据所选提供商自动更新;提示文案中的提供商名称相应变化 | + +**提供商选择 UI**:三个并排卡片,每个显示 Radio 按钮 + 提供商 Logo 图标。选中状态:蓝色边框 + `blue.50` 背景色。 + +#### 步骤 3:输入自定义域名 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在输入框中输入域名(如 `chat.example.com`) | 无 | 无 | 输入框右侧无状态标签 | + +#### 步骤 4:保存域名并开始 DNS 解析检测 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"保存"按钮(输入域名后右侧按钮从"编辑"变为"保存") | 无(仅切换为不可编辑模式) | 域名输入框有内容 | 域名输入框变为只读;右侧按钮变为"编辑";输入框右侧出现 DNS 解析状态标签;开始每 5 秒自动调用 DNS 检测 API | + +#### 步骤 5:DNS 解析自动检测 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 系统自动执行(每 5 秒一次,无需用户操作) | POST /proApi/support/customDomain/checkDNSResolve | 域名和 CNAME 均已填写且处于非编辑状态 | 解析中:输入框右侧显示红色标签"解析中";解析成功:标签变为绿色"已解析",确认按钮变为可用 | +| | | 用户在 DNS 提供商处未配置 CNAME 记录 | 持续显示红色"解析中"标签,确认按钮保持禁用 | +| | | 用户在 DNS 提供商处正确配置了 CNAME 记录 | 下一次检测(5 秒内)成功后,标签变为绿色"已解析" | + +#### 步骤 6:查看 CNAME 记录信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 浏览 DNS 记录区域 | 无 | 始终可见 | 显示一条 CNAME 记录表格:类型为 CNAME、TTL 为 Auto、值为生成的 CNAME 域名;提供复制按钮可一键复制 CNAME 值;底部有文档链接指向帮助文档 | + +**CNAME 生成逻辑**:`generateCNAMEDomain(providerDomain)` — 在提供商提供的域名前添加随机前缀,生成格式为 `fastgpt-{随机字符串}.{提供商域名}` 的 CNAME 域名。 + +#### 步骤 7:确认创建 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | POST /proApi/support/customDomain/create | DNS 已解析成功(DnsResolved 为 true),确认按钮可用 | 弹窗关闭,域名列表自动刷新,显示成功提示"操作成功" | +| | | DNS 未解析成功,确认按钮灰色禁用 | 按钮不可点击,用户需等待 DNS 解析成功 | + +**创建参数**: +- `domain`:用户输入的自定义域名 +- `provider`:选择的 DNS 提供商(aliyun / tencent / volcengine) +- `cnameDomain`:系统生成的 CNAME 目标域名 + +#### 步骤 8:取消操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"取消"按钮或关闭弹窗 | 无 | 无 | 弹窗关闭,列表不刷新 | + +--- + +### S03 编辑/激活自定义域名 + +> 对于状态为"未激活"的域名,团队所有者可重新编辑并激活。 + +#### 步骤 1:打开编辑弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击未激活域名的"编辑"按钮 | 无 | 仅域名状态为 `inactive`(未激活)时才显示"编辑"按钮;`active`(已激活)域名不显示此按钮 | 弹窗以 `refresh` 模式打开;提供商和域名自动填充为当前域名的值且不可修改;DNS 记录区域显示已有的 CNAME 域名 | + +> **按钮可见性**:域名状态为 `inactive` → 显示"编辑";域名状态为 `active` → 不显示"编辑"(代码中域验证按钮已被注释,暂不提供) + +#### 步骤 2:重新检测 DNS 解析 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"刷新"按钮 | POST /proApi/support/customDomain/checkDNSResolve | 域名和 CNAME 已有值 | 触发一次 DNS 检测(非定时轮询);解析成功后确认按钮变为可用 | + +#### 步骤 3:确认激活 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"确认"按钮 | POST /proApi/support/customDomain/active | DNS 已解析成功 | 弹窗关闭,域名列表刷新,域名状态变为 active(已激活) | +| | | DNS 未解析成功 | 按钮禁用,不可点击 | + +--- + +### S04 删除自定义域名 + +> 团队所有者删除指定的自定义域名绑定。 + +#### 步骤 1:触发删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击域名行中的"删除"按钮 | 无 | 始终可点击 | 弹出确认删除对话框 | + +#### 步骤 2:确认删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击"确认" | DELETE /proApi/support/customDomain/delete | 无分支 | 弹窗关闭,列表刷新,该域名从列表中消失,显示成功提示"操作成功" | +| 在确认弹窗中点击"取消" | 无 | 无 | 弹窗关闭,列表不变 | + +**删除链路详情**: +- **确认弹窗**:弹窗类型为"删除"(红色确认按钮),提示内容为 i18n key `account:custom_domain.delete_confirm` 对应的文本 +- **删除参数**:域名字符串作为请求体中的 `domain` 字段 +- **后置影响**:删除后列表自动刷新;该域名对应的 CNAME 解析不再有效 + +--- + +### S05 升级套餐 + +> 未订阅 Plus 或套餐不含自定义域名的团队所有者,看到升级引导。 + +#### 步骤 1:查看升级提示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入自定义域名页面 | 无 | `isSupportCustomDomain` 为 false(套餐不含自定义域名或未订阅 Plus) | 表格区域不渲染数据行;显示空状态组件:文案"升级套餐以使用自定义域名"+"升级套餐"蓝色按钮 | + +#### 步骤 2:跳转价格页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"升级套餐"按钮 | 无 | 无 | 页面路由跳转到 `/price` | + +--- + +## Mermaid 附录 + +### S01 查看列表 + +```mermaid +sequenceDiagram + participant User + participant UI as 自定义域名页面 + participant API as /proApi/support/customDomain + participant Backend as FastGPT Pro + + User->>UI: 进入自定义域名页面 + UI->>API: GET /list + API->>Backend: 查询团队域名列表 + Backend-->>API: 返回域名列表 + API-->>UI: CustomDomainType[] + UI-->>User: 展示域名表格(域名/CNAME/提供商/状态/操作) +``` + +### S02 添加自定义域名 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建弹窗 + participant API as /proApi/support/customDomain + participant Backend as FastGPT Pro + participant DNS as DNS 提供商 + + User->>UI: 点击"添加"按钮 + UI-->>User: 弹窗打开,默认选中腾讯云 + User->>UI: 选择 DNS 提供商 + User->>UI: 输入自定义域名 + User->>UI: 点击"保存" + UI->>UI: 开始每 5 秒轮询 DNS 解析状态 + User->>DNS: 在 DNS 提供商处添加 CNAME 记录 + loop 每 5 秒检测 + UI->>API: POST /checkDNSResolve + API->>Backend: 检查 DNS 解析 + Backend-->>API: 返回解析结果 + API-->>UI: success: true/false + end + UI-->>User: 显示"已解析"绿色标签 + User->>UI: 点击"确认" + UI->>API: POST /create + API->>Backend: 创建域名记录 + Backend-->>API: success: true + API-->>UI: 创建成功 + UI-->>User: 弹窗关闭,列表刷新 +``` + +### S03 编辑/激活域名 + +```mermaid +sequenceDiagram + participant User + participant UI as 创建弹窗(refresh模式) + participant API as /proApi/support/customDomain + participant Backend as FastGPT Pro + + User->>UI: 点击未激活域名的"编辑" + UI-->>User: 弹窗打开,已填充域名和提供商信息 + User->>UI: 点击"刷新" + UI->>API: POST /checkDNSResolve + API->>Backend: 检查 DNS 解析 + Backend-->>API: success: true + API-->>UI: 解析成功 + UI-->>User: 确认按钮变为可用 + User->>UI: 点击"确认" + UI->>API: POST /active + API->>Backend: 激活域名 + Backend-->>API: success: true + API-->>UI: 激活成功 + UI-->>User: 弹窗关闭,列表刷新 +``` + +### S04 删除域名 + +```mermaid +sequenceDiagram + participant User + participant UI as 自定义域名页面 + participant API as /proApi/support/customDomain + participant Backend as FastGPT Pro + + User->>UI: 点击"删除"按钮 + UI-->>User: 弹出确认删除对话框 + User->>UI: 点击确认 + UI->>API: DELETE /delete + API->>Backend: 删除域名记录 + Backend-->>API: success: true + API-->>UI: 删除成功 + UI->>API: GET /list(刷新列表) + API-->>UI: 返回更新后的域名列表 + UI-->>User: 列表刷新,域名已移除 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f7bfd94caa28 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 自定义域名 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 团队所有者 +router_paths: + - /account/customDomain +--- +# 自定义域名 — 模块概览 + +## 1. 模块业务概述 + +自定义域名模块为团队所有者提供将 AI 应用绑定到自有域名的能力。用户无需使用 FastGPT 默认提供的对话链接,而是可以通过自己的域名(如 `chat.example.com`)对外发布 AI 应用,提升品牌一致性和专业形象。 + +本模块是账户管理下的一个子功能,仅对 Plus(商业版)用户开放。团队所有者在本模块中可完成域名的添加、DNS 解析验证、激活和删除等全生命周期管理。支持的 DNS 提供商包括阿里云、腾讯云和火山引擎。 + +本模块为叶子节点(无子能力),所有域名管理操作集中在单一列表页面完成。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队所有者 | 自定义域名列表 | 添加域名、编辑域名、删除域名、激活域名 | 仅团队所有者可访问,需 Plus 订阅且套餐包含自定义域名配额 | +| 普通成员 | 无 | 无 | 自定义域名入口在账户侧边栏中不显示 | + +**权限模型**: 基于套餐订阅的权限控制。入口可见性由 `feConfigs.customDomain.enable` 控制;操作权限由 `teamPlanStatus.standard.customDomain > 0` 控制。未订阅用户看到升级引导页面,引导跳转到价格页面。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户(AccountContainer) | 账户侧边栏布局与导航容器 | 组件 | +| 用户状态(useUserStore) | 团队订阅计划状态查询 | Store | +| 系统配置(useSystemStore) | Plus 功能开关与 DNS 提供商域名配置 | Store | +| 价格 | 未订阅用户跳转到价格页面升级套餐 | 页面 | + +## 4. 对外共享 + +本模块未对外暴露共享能力。自定义域名的 API 函数(`listCustomDomain`、`deleteCustomDomain`、`createCustomDomain` 等)仅在账户模块内使用,不被其他模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 自定义域名管理 | /account/customDomain | projects/app/src/pages/account/customDomain/index.tsx | 团队所有者 | 无 Tab,单页面表格+弹窗模式 | + +## 6. 子能力索引 + +本模块无子能力,为叶子节点。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2b62f12153e5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,55 @@ +--- +capability_label: 账单 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — 业务流程索引 + +## Tab 结构索引 + +| Tab | 业务描述 | 来源 | 详细文档 | +|-----|---------|------|---------| +| 账单记录 | 查看历史支付订单、对未支付订单进行支付或取消操作 | 本模块 | 待子任务生成(账户/账单/账单记录) | +| 发票 | 查看已提交的发票申请记录、下载已完成的发票 PDF 文件 | 本模块 | 待子任务生成(账户/账单/发票) | +| 发票抬头 | 维护企业的发票抬头信息(公司名称、税号、银行账户等),供申请发票时使用 | 本模块 | 待子任务生成(账户/账单/发票抬头) | + +## 业务场景列表 + +### 账单与发票页面入口 + +- **业务描述**:团队管理员从账户侧边栏进入账单与发票管理页面,默认展示账单记录 Tab。页面顶部提供三个 Tab 切换(账单记录/发票/发票抬头),用户可在三个 Tab 间自由切换。 +- **触发入口**:账户侧边栏 →「账单与发票」菜单项(仅在 `feConfigs.show_pay` 开启且用户具有管理权限时可见) +- **路由路径**:`/account/bill` +- **页面组件位置**:`projects/app/src/pages/account/bill/index.tsx` +- **涉及角色**:团队管理员(hasManagePer) +- **前置条件**:用户已登录、具有团队管理权限(`hasManagePer`)、系统开启支付功能(`show_pay`) + +### 申请发票 + +- **业务描述**:在账单记录或发票 Tab 中,点击「开发票」按钮打开发票申请弹窗。用户从可开票账单列表中选择账单,确认金额后进入第二步填写发票抬头信息,提交后生成发票申请。 +- **触发入口**:页面顶部「开发票」按钮(账单记录和发票 Tab 下可见,发票抬头 Tab 下隐藏) +- **路由路径**:`/account/bill`(弹窗形式,不切换路由) +- **页面组件位置**:`projects/app/src/pageComponents/account/bill/ApplyInvoiceModal.tsx` +- **涉及角色**:团队管理员 +- **前置条件**:存在状态为已支付的账单(可开票账单) + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|------|------| +| S01 | 进入账单与发票页面 | — | 账户侧边栏→账单与发票 | /account/bill | 团队管理员 | +| S01-1 |  ├ 查看账单记录 | 账单记录 | 页面默认 Tab | /account/bill?invoiceTab=bill | 团队管理员 | +| S01-2 |  ├ 查看发票记录 | 发票 | 切换发票 Tab | /account/bill?invoiceTab=invoice | 团队管理员 | +| S01-3 |  ├ 管理发票抬头 | 发票抬头 | 切换发票抬头 Tab | /account/bill?invoiceTab=invoiceHeader | 团队管理员 | +| S02 | 申请发票 | — | 页面顶部「开发票」按钮 | /account/bill(弹窗) | 团队管理员 | + +## 场景关系 + +- **S01(页面入口)** → **S01-1/S01-2/S01-3**:页面入口后根据 Tab 选择进入不同子场景 +- **S01-1 或 S01-2** → **S02**:在账单记录或发票 Tab 下可触发申请发票 +- 各 Tab 子场景独立执行,无严格先后关系 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..8d8cc9865b2d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,99 @@ +--- +capability_label: 账单 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — 业务流程详解 + +## 页面总览 + +账单与发票页面是 FastGPT 团队财务管理中心,承载账单查看/支付、发票申请/查看、发票抬头管理三大功能。页面以 3 个 Tab 组织内容,默认展示账单记录 Tab。各 Tab 的详细业务流程见各自的子文档。 + +## Tab 结构索引 + +| Tab | 业务描述 | 步骤数 | API 数 | 来源 | 详细文档 | +|-----|---------|--------|--------|------|---------| +| 账单记录 | 查看历史账单、支付/取消未支付订单 | — | 5 | 本模块 | 待子任务生成(账户/账单/账单记录) | +| 发票 | 查看已提交发票、下载完成发票 PDF | — | 1 | 本模块 | 待子任务生成(账户/账单/发票) | +| 发票抬头 | 维护企业发票抬头信息 | — | 2 | 本模块 | 待子任务生成(账户/账单/发票抬头) | + +## 公共业务流程 + +### 页面初始化与 Tab 切换 + +> 用户进入页面时的初始化加载、Tab 切换和权限校验流程。各 Tab 的内容渲染由子组件负责。 + +#### 步骤 1:页面路由进入 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从账户侧边栏点击「账单与发票」菜单项 | 无(GET `/api/account/bill` 为 Next.js 页面路由,由服务端渲染处理 i18n) | 侧边栏菜单项仅在 `feConfigs.show_pay` 开启且用户 `hasManagePer` 权限时显示 | 页面整体加载:顶部导航栏 + 账户侧边栏 + 页面主体(默认显示账单记录 Tab) | + +#### 步骤 2:Tab 切换 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击 Tab「账单记录」/「发票」/「发票抬头」 | 无(仅 URL query 参数 `invoiceTab` 更新) | — 无分支条件,三个 Tab 对所有有权限用户均可点击 | URL 路径追加 `?invoiceTab=bill\|invoice\|invoiceHeader`;对应 Tab 高亮;页面主体切换为对应子组件(BillTable / InvoiceTable / InvoiceHeaderForm) | + +> — 当切换到发票抬头 Tab(invoiceHeader)时,「开发票」按钮隐藏,仅保留 Tab 切换区域。 + +#### 步骤 3:申请发票弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在账单记录或发票 Tab 下点击「开发票」按钮 | 无(打开弹窗,弹窗内加载可开票账单列表) | 发票抬头 Tab 下「开发票」按钮不渲染 | 弹出申请发票弹窗(ApplyInvoiceModal),显示两步流程:①选择账单(全选/单选)②填写发票抬头 | + +**弹窗两步流程**: + +| 步骤 | 用户操作 | 触发 API | 分支条件 | 页面变化 | +|------|---------|---------|---------|---------| +| 选账单 | 弹窗打开,自动加载可开票账单列表 | GET `/proApi/support/wallet/bill/invoice/unInvoiceList` | 无 | 加载中显示遮罩;加载完成后展示可开票账单表格(含全选 Checkbox) | +| 选账单 | 勾选/取消勾选账单行 | 无(前端计算总金额) | 点击整行或 Checkbox 均可切换选中 | 底部「确认」按钮根据选中数量启用/禁用(无选中时禁用) | +| 选账单 | 点击「确认」按钮 | 无 | 无选中账单时按钮禁用,无法点击 | 进入第二步(填写抬头),顶部显示总金额 | +| 填抬头 | 自动加载已有发票抬头 | GET `/proApi/support/user/team/invoiceHeader/get` | 首次进入第二步触发 | 表单自动填入已有的抬头信息(如有) | +| 填抬头 | 填写/修改抬头信息并点击「确认」 | POST `/proApi/support/wallet/bill/invoice/submit` | 所有字段必填(`required`),手机号格式校验 /^1[0-9]{10}$/,邮箱格式校验 | 提交中按钮显示加载状态;成功后关闭弹窗并刷新页面 | + +### 退出登录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在账户侧边栏点击「退出登录」 | 无(前端清除用户状态) | 确认弹窗:`t('account:confirm_logout')` | 弹出确认弹窗;确认后清除用户信息(`setUserInfo(null)`),路由跳转到 `/login` | + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 账单页面 + participant API + + Note over User,API: 页面初始化与Tab切换(各Tab详见子文档) + + User->>UI: 从侧边栏进入账单与发票 + UI->>UI: 加载 AccountContainer 布局 + UI->>UI: 渲染默认Tab(账单记录) + + User->>UI: 切换Tab + UI->>UI: 更新 URL query(invoiceTab) + UI->>UI: 切换子组件内容 + + Note over User,API: 申请发票(弹窗内两步流程) + + User->>UI: 点击「开发票」 + UI->>API: GET /proApi/.../unInvoiceList + API-->>UI: 可开票账单列表 + User->>UI: 勾选账单,点击确认 + UI->>API: GET /proApi/.../invoiceHeader/get + API-->>UI: 已有抬头信息 + User->>UI: 填写/确认抬头,点击确认 + UI->>API: POST /proApi/.../invoice/submit + API-->>UI: 提交成功 + UI->>UI: 关闭弹窗,刷新页面 + + Note over User,API: 各Tab详细流程见子文档 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..63aff04ccdf0 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,52 @@ +--- +capability_label: "发票" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — 业务流程索引 + +## 查看发票记录 + +- **业务描述**: 用户进入发票记录页面,查看团队已提交的所有开票申请记录,包括发票金额、开票时间和处理状态。 +- **触发入口**: 账户中心 → 账单页面 → 点击"发票记录"Tab +- **路由路径**: `/account/bill?invoiceTab=invoice` +- **页面组件位置**: `projects/app/src/pageComponents/account/bill/InvoiceTable.tsx` +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 已登录并加入团队 + +## 查看发票详情 + +- **业务描述**: 在发票记录列表中点击某条记录的"详情"按钮,查看该发票的完整信息(开票金额、公司信息、联系方式等)。 +- **触发入口**: 发票记录列表 → 点击某条记录的"详情"按钮 +- **路由路径**: `/account/bill?invoiceTab=invoice`(弹窗展示,不改变路由) +- **页面组件位置**: `projects/app/src/pageComponents/account/bill/InvoiceTable.tsx`(InvoiceDetailModal) +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 存在至少一条发票记录 + +## 下载发票文件 + +- **业务描述**: 对于状态为"已完成"的发票记录,在详情弹窗中点击下载链接,将电子发票 PDF 文件下载到本地。 +- **触发入口**: 发票详情弹窗 → 点击"点击下载"链接 +- **路由路径**: `/account/bill?invoiceTab=invoice`(弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/account/bill/InvoiceTable.tsx`(InvoiceDetailModal) +- **涉及角色**: 团队管理员、团队成员 +- **前置条件**: 发票状态为"已完成"(status=2),即已有发票文件 + +## 场景关系 + +- **查看发票记录** → **查看发票详情**: 在列表中选择一条记录打开详情弹窗 +- **查看发票详情** → **下载发票文件**: 对于已完成的发票,从详情中下载 PDF 文件 +- 查看记录、查看详情、下载文件为递进关系;各条发票记录之间独立,无相互依赖 + +## 业务场景汇总 + +| 编号 | 场景 | Tab | 触发入口 | 路由路径 | 角色 | +|------|------|-----|---------|---------|------| +| S01 | 查看发票记录 | — | 账户中心→账单→发票记录Tab | /account/bill?invoiceTab=invoice | 团队管理员、团队成员 | +| S02 | 查看发票详情 | — | 发票记录列表→详情按钮 | /account/bill?invoiceTab=invoice | 团队管理员、团队成员 | +| S03 | 下载发票文件 | — | 发票详情弹窗→下载链接 | /account/bill?invoiceTab=invoice | 团队管理员、团队成员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..3f7414d2d1b1 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,163 @@ +--- +capability_label: "发票" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — 业务流程详解 + +## 页面总览 + +发票管理页面为用户提供团队发票记录的查看和下载能力。页面以表格形式展示所有发票记录(含分页),点击详情可查看发票完整信息,已完成的发票支持下载 PDF 文件。 + +--- + +### 查看发票记录 + +> 用户进入发票记录 Tab,系统加载并展示团队的发票记录列表,支持分页浏览。 + +#### 步骤 1:进入发票记录页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在账户中心→账单页面点击"发票记录"Tab | — | 无 | URL 更新为 `/account/bill?invoiceTab=invoice`,InvoiceTable 组件渲染 | + +#### 步骤 2:加载发票记录列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载(自动触发首次请求) | POST `/proApi/support/wallet/bill/invoice/records`,参数 `{pageNum: 1, pageSize: 10}` | 无 | 显示加载遮罩(MyBox isLoading),表格区域展示加载状态 | + +#### 步骤 3:展示发票记录 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待数据加载完成 | — | **有数据**: invoices 数组非空 | 表格展示发票记录:序号、创建时间(YYYY/MM/DD HH:mm:ss 格式)、金额(元)、状态标签、详情按钮 | +| | | **无数据**: invoices 数组为空 | 显示空状态:空图标 + "暂无发票记录"提示文案 | + +发票状态标签: +- 状态为 1(已提交):蓝色背景标签,显示"已提交" +- 状态为 2(已完成):绿色背景标签,显示"已完成" + +#### 步骤 4:翻页浏览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器翻页 | POST `/proApi/support/wallet/bill/invoice/records`,参数 `{pageNum: N, pageSize: 10}` | 总条数 ≥ pageSize(10)时显示分页器 | 表格更新为第 N 页数据,加载期间显示遮罩 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/wallet/bill/invoice/records | pageNum=1, pageSize=10 | 按创建时间倒序排列(默认) | 表格前 10 条 | +| 翻页 | POST /proApi/support/wallet/bill/invoice/records | pageNum=N, pageSize=10 | 无额外处理 | 表格第 N 页 | + +- 分页参数:默认每页 10 条 +- 排序规则:默认按创建时间倒序(后端返回顺序) + +--- + +### 查看发票详情 + +> 用户在发票记录列表中点击某条记录的"详情"按钮,弹出详情弹窗展示该发票的完整信息。 + +#### 步骤 1:打开详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某条发票记录的"详情"按钮 | — | invoiceDetailData 设置为当前行数据 | 弹窗以 MyModal 展示,标题为"发票详情" | + +#### 步骤 2:展示发票详情信息 + +弹窗中展示的发票字段: + +| 字段 | 数据来源 | 说明 | +|------|---------|------| +| 发票金额 | invoice.amount | 通过 formatStorePrice2Read 格式化,单位元 | +| 机构名称 | invoice.teamName | 开票机构名称 | +| 统一信用代码 | invoice.unifiedCreditCode | — | +| 公司地址 | invoice.companyAddress | — | +| 公司电话 | invoice.companyPhone | — | +| 开户银行 | invoice.bankName | — | +| 银行账号 | invoice.bankAccount | — | +| 需要专票 | invoice.needSpecialInvoice | 显示"是"或"否" | +| 联系电话 | invoice.contactPhone | — | +| 邮箱地址 | invoice.emailAddress | — | +| 发票文件 | — | 仅在发票状态为"已完成"时显示下载链接 | + +--- + +### 下载发票文件 + +> 发票状态为"已完成"时,详情弹窗中展示下载链接,用户可下载电子发票 PDF。 + +#### 步骤 1:点击下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在详情弹窗中点击"点击下载" | GET `/api/proApi/support/wallet/bill/invoice/downloadFile?id={发票ID}` | **前提**: 发票 status === 2(已完成) | 触发文件下载 | + +#### 步骤 2:文件下载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 等待下载完成 | — | 下载成功 | 浏览器下载 PDF 文件,文件名格式 `{机构名称}.pdf` | +| | | 下载失败 | 显示错误提示 | + +--- + +### Mermaid 附录 + +#### 查看发票记录 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 发票页面 + participant API as 后端API + participant B as 后端服务 + + U->>UI: 点击"发票记录"Tab + UI->>API: POST /proApi/support/wallet/bill/invoice/records + API->>B: 查询团队发票记录 + B-->>API: 返回分页数据 + API-->>UI: 返回 InvoiceSchemaType[] + alt 有数据 + UI-->>U: 展示发票记录表格 + else 无数据 + UI-->>U: 展示空状态提示 + end + + U->>UI: 点击翻页 + UI->>API: POST /proApi/support/wallet/bill/invoice/records (pageNum=N) + API-->>UI: 返回第 N 页数据 + UI-->>U: 展示第 N 页记录 +``` + +#### 查看发票详情与下载 + +```mermaid +sequenceDiagram + participant U as 用户 + participant UI as 发票详情弹窗 + participant API as 后端API + participant B as 后端服务 + + U->>UI: 点击"详情"按钮 + UI-->>U: 展示发票详情弹窗 + + alt 发票已完成 (status=2) + U->>UI: 点击"点击下载" + UI->>API: GET /proApi/support/wallet/bill/invoice/downloadFile?id=xxx + API->>B: 获取发票文件 + B-->>API: 返回 PDF 文件 + API-->>UI: 返回文件流 + UI-->>U: 浏览器下载 PDF + else 发票已提交 (status=1) + UI-->>U: 不显示下载链接 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..8743ba2b4c04 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: "发票" +doc_type: "11" +doc_label: "共享能力清单" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — 共享能力清单 + +## 说明 + +本模块未检测到对外共享能力(imported_by 模块数: 1,仅为父级账单页面内部引用;共享能力数: 0)。 + +跨模块依赖摘要请参见 [模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..e36143c743bc --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,50 @@ +--- +capability_label: "发票" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — 模块概览 + +## 1. 模块业务概述 + +发票管理模块是账户中心账单系统的子能力,为团队用户提供发票记录查看和发票详情下载功能。当团队完成订单支付后,可在此模块查看已提交的开票申请记录,跟踪发票开具状态(已提交/已完成),并下载已完成的电子发票文件。 + +本模块是"账单→发票"路径下的一个叶子能力,与父级"账单"模块通过 Tab 切换关联(账单记录Tab / 发票记录Tab / 发票抬头Tab),共同构成团队费用管理闭环。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 发票记录列表 | 查看发票记录、查看发票详情、下载已完成的发票文件 | 可查看团队所有成员的发票记录 | +| 团队成员 | 发票记录列表 | 查看发票记录、查看发票详情、下载已完成的发票文件 | 与管理员权限一致,均可查看团队级发票数据 | + +**权限模型**: 基于团队层级的访问控制。发票数据属于团队级别资源,所有团队成员均可查看团队的发票记录和下载发票文件。具体权限校验由后端 API 层处理,前端通过 teamId 进行数据隔离。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 全局类型定义 | InvoiceSchemaType 发票数据类型 | 类型 | +| 全局工具函数 | formatStorePrice2Read 价格格式化 | 工具函数 | +| 通用请求层 | POST/GET 请求方法 | API 封装 | +| 通用组件库 | MyBox、MyModal、MyIcon | UI 组件 | +| 通用 Hooks | usePagination、useRequest | Hooks | + +## 4. 对外共享 + +本模块为叶子能力节点,InvoiceTable 组件仅被父级账单页面引用,无其他外部模块消费。InvoiceDetailModal 为内部组件,不对外暴露。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 发票记录 | /account/bill?invoiceTab=invoice | pageComponents/account/bill/InvoiceTable.tsx | 团队管理员、团队成员 | 无 Tab,单页面含列表+详情弹窗 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..22592b86d9b5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,32 @@ +--- +capability_label: "发票抬头" +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: "账单" +roles: ["admin"] +router_paths: ["/account/bill"] +--- + +# 发票抬头 — 业务流程索引 + +## 1. 业务场景列表 + +### 管理发票抬头 + +- **业务描述**: 团队管理员查看和编辑团队的默认发票抬头信息,包括组织名称、统一社会信用代码、公司地址、联系电话、银行账户以及是否需要增值税专票等。 +- **触发入口**: 账户中心 → 账单页 → 点击「默认抬头」Tab +- **路由路径**: `/account/bill?invoiceTab=invoiceHeader` +- **页面组件位置**: `projects/app/src/pageComponents/account/bill/InvoiceHeaderForm.tsx` +- **涉及角色**: 团队管理员 +- **前置条件**: 用户已登录且有团队管理权限;团队账单功能已开通。 + +## 2. 场景关系 + +各场景独立执行,无严格先后关系。 + +## 3. 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 管理发票抬头 | 账户中心→账单→默认抬头Tab | /account/bill?invoiceTab=invoiceHeader | admin | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..bcdd6fa3ce9f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,120 @@ +--- +capability_label: "发票抬头" +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: "账单" +roles: ["admin"] +router_paths: ["/account/bill"] +--- + +# 发票抬头 — 业务流程详解 + +## 页面总览 + +发票抬头是「账单」页面的第三个 Tab,提供团队默认发票抬头信息的管理功能。页面以表单形式展示发票抬头各项信息,支持查看和编辑。用户可设置是否需要增值税专票,选择专票后需要额外填写公司地址、电话及银行账户信息。保存成功后,该信息将在未来申请发票时自动预填入申请表单。 + +## 管理发票抬头 + +> **业务描述**: 团队管理员在此页面查看当前的发票抬头信息,编辑后保存,以便在后续申请发票时自动填入。核心场景为查看现有数据 → 修改 → 提交保存。 + +以下按完整交互序列描述该场景的每个步骤。 + +### 步骤 1:进入页面,加载发票抬头数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「默认抬头」Tab 切换到发票抬头页面 | `GET /proApi/support/user/team/invoiceAccount/getTeamInvoiceHeader`(页面加载时自动调用,与用户操作无直接触发关系,由 React 组件挂载时发起) | 无分支,固定调用 | 显示 MyBox 加载遮罩(`isLoading=true`),遮罩遮盖整个表单区域;API 返回数据后,表单各字段填充为接口返回值;加载遮罩消失 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| Tab 激活时首次加载 | `GET /proApi/support/user/team/invoiceAccount/getTeamInvoiceHeader` | 无参数(后端从当前团队上下文获取) | API 返回值直接通过 `inputForm.reset(data)` 填充到表单 | 表单各字段显示当前保存的发票抬头数据 | + +> 此场景只有一次加载,无分页/排序/筛选/轮询。 + +**表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 可选值/约束 | 编辑时只读 | 说明 | +|--------|---------|------|--------|------------|-----------|------| +| 组织名称 | 文本输入 | ✅ | 空 | 无 | 否 | 发票抬头的主体名称 | +| 统一信用代码 | 文本输入 | ✅ | 空 | 无 | 否 | 统一社会信用代码 | +| 公司地址 | 文本输入 | 条件必填 | 空 | 无 | 否 | 仅当「是否需要专票」选择「是」时为必填 | +| 公司电话 | 文本输入 | 条件必填 | 空 | 无 | 否 | 仅当「是否需要专票」选择「是」时为必填 | +| 银行名称 | 文本输入 | 条件必填 | 空 | 无 | 否 | 仅当「是否需要专票」选择「是」时为必填 | +| 银行账号 | 文本输入 | 条件必填 | 空 | 无 | 否 | 仅当「是否需要专票」选择「是」时为必填 | +| 是否需要专票 | 单选按钮 | ✅ | false | 「是」/「否」 | 否 | 选择「是」后下方 4 个字段(公司地址/电话/银行名称/账号)变为必填 | +| 联系电话 | 文本输入 | ✅ | 空 | 11 位手机号格式(以 1 开头) | 否 | 用于电子发票接收 | +| 邮箱地址 | 文本输入 | ✅ | 空 | 标准邮箱格式 | 否 | 用于电子发票发送 | + +**字段联动**: + +- 当「是否需要专票」选择「是」时:公司地址、公司电话、银行名称、银行账号四个字段的标签显示必填标记(红色星号),且提交时的校验规则变为必填 +- 当「是否需要专票」选择「否」时:上述四个字段的必填标记消失,提交时不校验 + +**校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 组织名称必填 | 提交时(表单校验) | (浏览器默认必填提示) | +| 统一信用代码必填 | 提交时 | (浏览器默认必填提示) | +| 公司地址条件必填 | 专票=是且提交时 | (浏览器默认必填提示) | +| 公司电话条件必填 | 专票=是且提交时 | (浏览器默认必填提示) | +| 银行名称条件必填 | 专票=是且提交时 | (浏览器默认必填提示) | +| 银行账号条件必填 | 专票=是且提交时 | (浏览器默认必填提示) | +| 联系电话格式校验 | 提交时 | "联系电话格式错误" | +| 邮箱格式校验 | 提交时 | "邮箱格式不正确" | +| 后端保存失败 | 提交后 API 返回错误 | "保存异常" | + +### 步骤 2:编辑发票抬头信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在各文本输入框中修改发票抬头字段值(组织名称、统一信用代码等) | 无(纯前端表单操作) | 无 | 输入框内容实时更新;选择「是否需要专票」为「是」时,公司地址/电话/银行名称/账号的标签前出现红色必填星号标记 | + +### 步骤 3:提交保存 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击页面底部的「保存」按钮 | 先执行前端表单校验(react-hook-form),通过后调用 `POST /proApi/support/user/team/invoiceAccount/update`,请求体包含完整的 `TeamInvoiceHeaderType` 对象(teamName, unifiedCreditCode, companyAddress, companyPhone, bankName, bankAccount, needSpecialInvoice, contactPhone, emailAddress) | 前端校验不通过:页面滚动到第一个错误字段,浏览器显示默认校验提示;校验通过但需要专票为否:companyAddress/companyPhone/bankName/bankAccount 提交空值(后端允许) | 点击保存后,按钮变为加载状态(`isLoading=true`),按钮文字保持「保存」但显示加载动画;API 调用成功后,弹出绿色成功提示「保存成功」;API 调用失败后,弹出红色错误提示「保存异常」,表单数据保持用户编辑的内容不丢失;无论成功或失败,保存按钮恢复可点击状态 | + +**前后置条件**: +- **前置条件**:用户已登录且有团队管理权限;表单中必填字段已填写且格式正确 +- **后置影响**:成功保存后,团队默认发票抬头信息更新;后续在申请发票弹窗中将自动预填入新的抬头信息 +- **失败场景**:网络异常或后端校验失败时,显示「保存异常」提示,用户可修正后重试 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 发票抬头页面 + participant API as 后端 API + + User->>UI: 点击「默认抬头」Tab + UI->>API: GET /proApi/support/user/team/invoiceAccount/getTeamInvoiceHeader + Note over UI: 显示加载遮罩 + API-->>UI: 返回发票抬头数据 + Note over UI: 填充表单各字段
隐藏加载遮罩 + + User->>UI: 编辑表单字段(组织名称、信用代码等) + Note over UI: 实时更新表单值
专票选项联动字段必填标记 + + User->>UI: 点击「保存」按钮 + Note over UI: 前端表单校验 + alt 校验失败 + UI-->>User: 显示必填/格式错误提示 + else 校验通过 + UI->>API: POST /proApi/support/user/team/invoiceAccount/update + Note over UI: 按钮显示加载状态 + alt 保存成功 + API-->>UI: 成功响应 + UI-->>User: 显示「保存成功」提示 + else 保存失败 + API-->>UI: 错误响应 + UI-->>User: 显示「保存异常」提示 + end + Note over UI: 按钮恢复可点击 + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..3318ee77c7af --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,54 @@ +--- +capability_label: "发票抬头" +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: "账单" +roles: ["admin"] +router_paths: ["/account/bill"] +--- + +# 发票抬头 — 模块概览 + +## 1. 模块业务概述 + +本模块为团队管理员提供**发票抬头信息管理**功能。在申请开具发票时,系统需要填写完整的发票抬头信息(包括组织名称、统一社会信用代码、银行账户等),本模块允许团队管理员预先设置默认发票抬头,并在申请发票时自动填入,减少重复填写。 + +发票抬头是「账户 → 账单」模块下的一个子功能,与「账单记录」和「发票记录」共用一个页面,通过 Tab 切换访问。 + +本模块无子能力,为单一功能页面。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员 | 发票抬头设置页 | 查看、编辑、保存发票抬头信息 | 唯一可管理发票抬头的角色 | +| 普通成员 | 发票抬头设置页 | 查看发票抬头信息(表单可读) | 无保存权限(由后端校验控制) | + +**权限模型**: 基于团队角色的访问控制。发票抬头数据属于团队级别设置,仅团队管理员可修改。save 操作由后端接口进行权限校验。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享组件库(@fastgpt/web) | MyBox 加载容器、FormLabel 标签组件 | 组件 | +| 共享 Hooks(@fastgpt/web) | useRequest API 请求封装 | Hook | +| 团队 API 层 | getTeamInvoiceHeader、updateTeamInvoiceHeader | API | +| 全局类型(@fastgpt/global) | TeamInvoiceHeaderType 发票抬头数据类型 | 类型定义 | +| Chakra UI | Input、Button、Radio、RadioGroup、Flex、Box | UI 组件 | + +## 4. 对外共享 + +本模块为叶子节点,无对外共享能力。`InvoiceHeaderSingleForm` 组件被同级模块 `ApplyInvoiceModal`(申请发票弹窗)复用,属于同模块内部共享。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 发票抬头设置 | /account/bill?invoiceTab=invoiceHeader | `projects/app/src/pageComponents/account/bill/InvoiceHeaderForm.tsx` | admin | 无 Tab,单一表单页面 | + +页面入口位于「账户 → 账单」页面的"默认抬头"Tab,页面路径为 `/account/bill`,通过 URL 查询参数 `invoiceTab=invoiceHeader` 切换到此 Tab。 + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..291dadeeb314 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,59 @@ +--- +capability_label: 账单 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — 模块概览 + +## 1. 模块业务概述 + +账单模块是 FastGPT 账户体系中的财务管理入口,为团队管理员提供账单记录查看、发票申请与发票抬头管理的完整财务闭环。用户在此可以查看历史支付订单、对未支付订单进行支付或取消操作、选中已完成支付的账单申请开具发票,以及维护企业的发票抬头信息(如公司名称、税号、银行账户等)。 + +本模块包含三个子 Tab 页面:**账单记录**(查看/支付/取消账单)、**发票**(查看已开具发票并下载 PDF 文件)和**发票抬头**(维护企业发票抬头信息)。三个功能围绕"支付 → 开票"这一核心财务链路展开。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 团队管理员(hasManagePer) | 全部(账单记录、发票、发票抬头) | 查看账单、支付/取消未支付订单、申请发票、管理发票抬头 | 仅具有管理权限的团队成员可见此模块 | +| 普通成员 | 无 | 无 | 账单模块仅在 `feConfigs.show_pay` 开启且用户具有 `hasManagePer` 权限时才在侧边栏可见 | + +**权限模型**:基于团队权限字段 `userInfo.team.permission.hasManagePer` 和系统配置 `feConfigs.show_pay` 的前端条件渲染。账单 Tab 仅在两个条件同时满足时出现在账户侧边栏。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户(AccountContainer) | 账户侧边栏布局、Tab 导航 | 组件 | +| 钱包/支付(QRCodePayModal) | 二维码支付弹窗(微信/企微支付) | 组件 | +| 团队(team API) | 获取/更新团队发票抬头信息 | API | +| 用户(wallet/bill API) | 账单查询、支付检查、取消、发票提交/查询/下载 | API | +| 钱包(wallet/usage tools) | 价格格式化 `formatStorePrice2Read` | 工具函数 | + +## 4. 对外共享 + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| BillDetailModal(账单详情弹窗) | 账户信息(DiscountCouponsModal) | 组件 | +| InvoiceHeaderSingleForm(发票抬头表单) | 账单(ApplyInvoiceModal) | 组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 账单与发票 | /account/bill | projects/app/src/pages/account/bill/index.tsx | 团队管理员 | 含 3 个 Tab(详见 [09-业务流程索引](./业务流程索引.md)) | + +## 6. 子能力索引 + +本模块包含 3 个 Tab 子能力,每个 Tab 有独立的子目录和完整文档: + +| 子能力 | Tab Label | 业务描述 | 文档链接 | +|--------|-----------|---------|---------| +| 账单记录 | account_bill:bill_record | 查看历史账单、支付未付款订单、取消订单 | 待子任务生成 | +| 发票 | account_bill:support_wallet_bill_tag_invoice | 查看已开具发票列表、下载发票 PDF | 待子任务生成 | +| 发票抬头 | account_bill:default_header | 维护企业发票抬头信息(公司名、税号、银行账户等) | 待子任务生成 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..07738a85695d --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,66 @@ +--- +capability_label: 账单记录 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T11:30:00.000Z +parent_module: 账单 +roles: [] +router_paths: + - /account/bill +--- + +# 账单记录 — 业务流程索引 + +## 业务场景列表 + +### 查看账单列表 + +- **业务描述**: 用户查看当前团队的所有历史账单记录,支持按账单类型筛选和分页浏览。 +- **触发入口**: 账户中心→账单→账单记录 Tab(默认激活) +- **路由路径**: /account/bill?invoiceTab=bill +- **页面组件位置**: projects/app/src/pageComponents/account/bill/BillTable.tsx +- **涉及角色**: 全部已登录用户 +- **前置条件**: 已登录且有团队归属 + +### 查看账单详情 + +- **业务描述**: 用户点击某条账单的「详情」按钮,弹窗查看账单的完整信息(订单编号、时间、类型、状态、金额、支付方式、订阅周期等)。 +- **触发入口**: 账单列表表格→操作列「详情」按钮 +- **路由路径**: /account/bill?invoiceTab=bill(同页面,弹窗形式) +- **页面组件位置**: projects/app/src/pageComponents/account/bill/BillDetailModal.tsx +- **涉及角色**: 全部已登录用户 +- **前置条件**: 账单列表已加载 + +### 刷新支付状态 + +- **业务描述**: 用户对状态为「未支付」的账单执行支付结果检查,若仍为未支付则重新发起支付。企微支付方式直接打开支付 URL。 +- **触发入口**: 账单列表→未支付账单的操作列「更新」按钮 +- **路由路径**: /account/bill?invoiceTab=bill(同页面) +- **页面组件位置**: projects/app/src/pageComponents/account/bill/BillTable.tsx +- **涉及角色**: 全部已登录用户 +- **前置条件**: 账单状态为「未支付」 + +### 取消账单 + +- **业务描述**: 用户取消一笔未支付的账单。操作前需在确认弹窗中再次确认。 +- **触发入口**: 账单列表→未支付账单的操作列「取消」按钮(弹出确认弹窗) +- **路由路径**: /account/bill?invoiceTab=bill(同页面) +- **页面组件位置**: projects/app/src/pageComponents/account/bill/BillTable.tsx +- **涉及角色**: 全部已登录用户 +- **前置条件**: 账单状态为「未支付」 + +## 场景关系 + +- **查看账单列表** → **查看账单详情**: 从列表中选择某条账单查看详情 +- **查看账单列表** → **刷新支付状态**: 对未支付账单点击「更新」触发支付检查 +- **查看账单列表** → **取消账单**: 对未支付账单点击「取消」触发取消操作 +- 各操作场景之间独立执行,无严格先后关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看账单列表 | 账户中心→账单→账单记录 Tab | /account/bill?invoiceTab=bill | 全部用户 | +| S02 | 查看账单详情 | 账单列表→操作列「详情」按钮 | /account/bill?invoiceTab=bill | 全部用户 | +| S03 | 刷新支付状态 | 账单列表→未支付账单「更新」按钮 | /account/bill?invoiceTab=bill | 全部用户 | +| S04 | 取消账单 | 账单列表→未支付账单「取消」按钮 | /account/bill?invoiceTab=bill | 全部用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..d21d39ed0b8f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,229 @@ +--- +capability_label: 账单记录 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T11:30:00.000Z +parent_module: 账单 +roles: [] +router_paths: + - /account/bill +--- + +# 账单记录 — 业务流程详解 + +## 页面总览 + +账单记录模块以表格形式展示团队所有历史账单,支持按账单类型筛选、分页浏览。每笔账单显示序号、类型、时间、金额、状态和操作按钮。未支付账单额外提供「更新支付」和「取消」操作入口。用户可点击「详情」查看账单完整信息。 + +--- + +### 查看账单列表 + +> 用户进入账单与发票页面时,默认激活「账单记录」Tab,加载账单列表数据。 + +#### 步骤 1:页面加载与数据请求 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 访问 /account/bill 页面,默认 invoiceTab=bill | POST /proApi/support/wallet/bill/list(pageNum=1, pageSize=20) | 无 | 显示加载遮罩(MyBox isLoading),加载完成后渲染账单表格 | + +#### 步骤 2:账单类型筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击表格表头类型列的下拉筛选器,选择特定账单类型(如标准订阅/额外积分) | POST /proApi/support/wallet/bill/list(pageNum=1, pageSize=20, type=选中值) | 选择「全部」时不传 type 参数 | 表格数据按所选类型重新加载,分页回到第 1 页 | + +#### 步骤 3:分页浏览 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器的页码或上/下页按钮 | POST /proApi/support/wallet/bill/list(pageNum=N, pageSize=20, type=当前筛选) | 仅在 total >= pageSize 时显示分页器 | 表格内容更新为第 N 页数据,不显示加载遮罩 | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/wallet/bill/list | pageNum=1, pageSize=20 | 返回 list 数组直接映射渲染 | 表格前 20 条记录 | +| 类型筛选 | POST /proApi/support/wallet/bill/list | pageNum=1, pageSize=20, type | 重置到第 1 页 | 按类型过滤的表格数据 | +| 翻页 | POST /proApi/support/wallet/bill/list | pageNum=N, pageSize=20 | 无额外处理 | 表格第 N 页数据 | +| 操作后刷新 | POST /proApi/support/wallet/bill/list | pageNum=1, pageSize=20(回到首页)| 无额外处理 | 更新后的表格数据 | + +- **分页参数**: 默认每页 20 条 +- **筛选条件**: 账单类型下拉框(全部/充值/标准订阅/额外数据集/额外积分),变更时重置到首页 +- **特殊列渲染**: 状态列显示对应中文标签(成功/退款/未支付/已关闭);金额列格式化为元;时间列格式化为 YYYY/MM/DD HH:mm:ss;操作列仅「未支付」状态显示「更新」和「取消」按钮 + +--- + +### 查看账单详情 + +> 用户点击账单表格操作列的「详情」按钮,弹窗展示账单的完整信息。 + +#### 步骤 1:打开详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击账单行的「详情」按钮 | GET /proApi/support/wallet/bill/detail(billId=账单ID) | 无 | 弹窗打开,显示加载状态,API 返回后展示账单详情信息 | + +#### 步骤 2:查看账单详情 + +弹窗以键值对形式展示以下信息: + +- 订单编号(orderId) +- 生成时间(createTime,格式 YYYY/MM/DD HH:mm:ss) +- 订单类型(billTypeMap 中文标签) +- 状态(billStatusMap 中文标签) +- 优惠券名称(有折扣券时展示) +- 支付方式(billPayWayMap 中文标签) +- 金额(元) +- 是否可开发票(余额支付为「无需开票」,否则显示是/否) +- 订阅周期(按月/按年) +- 订阅套餐等级 +- 订阅月数 +- 额外数据集大小(如有) +- 额外 AI 积分(如有) +- 自定义配置详情(custom 等级套餐时展示,含团队人数上限/应用数上限/数据集数上限/请求频率/数据集大小/历史存储时长/网站同步数/应用注册数/审计日志存储时长/工单响应时间/自定义域名) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 关闭弹窗 | 无 | 无 | 弹窗消失,回到账单列表 | + +--- + +### 刷新支付状态 + +> 用户对「未支付」账单执行支付结果检查,根据后台返回状态采取不同处理。 + +#### 步骤 1:检查支付结果 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击未支付账单的「更新」按钮 | GET /proApi/support/wallet/bill/pay/checkPayResult(payId=账单ID) | 无 | 「更新」按钮显示加载状态(isRefreshing=true),按钮文字不变但禁用 | + +#### 步骤 2:按状态分支处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| (自动)支付结果返回 SUCCESS | GET /common/system/unlockTask(内部调用) | status === SUCCESS | Toast 提示「支付成功」;列表自动刷新回到第 1 页 | +| (自动)支付结果返回 NOTPAY | PUT /proApi/support/wallet/bill/pay/updatePayment(billId, payWay) | status === NOTPAY | 获取新支付凭据;企微支付→Toast 提示后 window.open 打开支付 URL;其他支付方式→弹出 QRCodePayModal 二维码支付弹窗 | +| (自动)支付结果返回 CLOSED 或其他 | 无额外请求 | status === CLOSED / 其他 | Toast 提示对应状态描述(warning 级别) | + +#### 步骤 3:支付弹窗交互 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 支付成功(QRCodePayModal onSuccess 回调) | 无 | 用户完成支付 | Toast 提示「支付成功」;弹窗关闭;列表自动刷新回到第 1 页 | +| 关闭支付弹窗(QRCodePayModal onClose) | 无 | 用户主动关闭 | 弹窗关闭;列表自动刷新回到第 1 页 | + +- **失败场景**: 支付超时或取消后,账单状态仍为未支付,用户可重新点击「更新」按钮重试 +- **企微支付特殊处理**: 支付方式为 wecom 且返回 payUrl 时,直接通过 window.open 打开支付 URL,不展示二维码弹窗 + +--- + +### 取消账单 + +> 用户取消一笔未支付的账单,操作前需在确认弹窗中再次确认。 + +#### 步骤 1:打开取消确认弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击未支付账单的「取消」按钮 | 无 | 账单状态为 NOTPAY | 弹出 PopoverConfirm 确认弹窗,标题为「确认取消账单?」 | + +#### 步骤 2:确认取消 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在确认弹窗中点击「确认」 | POST /proApi/support/wallet/bill/cancel(billId=账单ID) | 无 | 「取消」按钮显示加载状态;成功后列表自动刷新回到第 1 页 | +| 点击「取消」或弹窗外区域 | 无 | 用户取消操作 | 弹窗关闭,无任何变更 | + +- **确认弹窗**: 使用 PopoverConfirm 组件,类型为 delete,显示取消账单确认文案 +- **后置影响**: 账单被取消后,如需重新发起支付需通过价格页面创建新订单 + +--- + +### Mermaid 附录 + +#### 查看账单列表 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 访问账单页面(默认 invoiceTab=bill) + UI->>API: POST /proApi/support/wallet/bill/list (page=1, size=20) + API->>Backend: 查询账单列表 + Backend-->>API: { total, list } + API-->>UI: 返回账单数据 + UI-->>User: 渲染账单表格 + + User->>UI: 切换类型筛选 + UI->>API: POST /proApi/support/wallet/bill/list (page=1, size=20, type) + API->>Backend: 按类型筛选查询 + Backend-->>API: { total, list } + API-->>UI: 返回筛选结果 + UI-->>User: 更新表格和分页 + + User->>UI: 点击翻页 + UI->>API: POST /proApi/support/wallet/bill/list (page=N) + API-->>UI: 返回第 N 页数据 + UI-->>User: 更新表格内容 +``` + +#### 刷新支付状态 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击未支付账单的「更新」按钮 + UI->>API: GET /proApi/support/wallet/bill/pay/checkPayResult (payId) + API->>Backend: 查询支付结果 + Backend-->>API: { status, description } + + alt status = SUCCESS + API->>API: GET /common/system/unlockTask + API-->>UI: 支付成功 + UI-->>User: Toast「支付成功」 + UI->>API: POST /proApi/support/wallet/bill/list (page=1) + else status = NOTPAY + API-->>UI: 未支付 + UI->>API: PUT /proApi/support/wallet/bill/pay/updatePayment (billId, payWay) + API-->>UI: 支付凭据 + alt payWay = wecom + UI-->>User: 打开支付 URL 新窗口 + else 其他支付方式 + UI-->>User: 弹出 QRCodePayModal 二维码支付弹窗 + end + else status = CLOSED/其他 + API-->>UI: { description } + UI-->>User: Toast 提示(warning) + end +``` + +#### 取消账单 + +```mermaid +sequenceDiagram + participant User + participant UI + participant API + participant Backend + + User->>UI: 点击未支付账单的「取消」按钮 + UI-->>User: 弹出取消确认弹窗 + + User->>UI: 确认取消 + UI->>API: POST /proApi/support/wallet/bill/cancel (billId) + API->>Backend: 取消账单 + Backend-->>API: 成功 + API-->>UI: 取消成功 + UI->>API: POST /proApi/support/wallet/bill/list (page=1) + API-->>UI: 刷新列表 + UI-->>User: 更新表格 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..c3151d9e28c5 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,47 @@ +--- +capability_label: 账单记录 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T11:30:00.000Z +parent_module: 账单 +roles: [] +router_paths: + - /account/bill +--- + +# 账单记录 — 模块概览 + +## 1. 模块业务概述 + +账单记录是账户中心「账单与发票」页面的核心子模块,为用户提供已生成账单的完整记录查看与管理功能。用户在此可查看所有历史账单(含充值账单、订阅套餐账单、额外数据集存储账单、额外积分账单),按类型筛选,对未支付账单执行支付刷新、取消或查看详情操作。 + +本模块是账户→账单下的叶子能力节点,与「发票记录」「发票抬头」并列构成账单页面的三大 Tab 区域。 + +## 2. 角色差异 + +本模块不涉及角色差异化展示。所有已登录用户均可查看自身团队的账单记录并执行支付/取消/查看详情操作。权限控制由上层 `AccountContainer` 统一处理登录态校验。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 共享基础设施 | API 请求封装(GET/POST/PUT) | API | +| 共享基础设施 | 通用组件(MyBox、MySelect、MyIcon、PopoverConfirm) | 组件 | +| 共享基础设施 | usePagination 分页 Hook | Hook | +| 共享基础设施 | useRequest 请求 Hook | Hook | +| 全局类型 | 账单常量和类型(BillTypeEnum、BillStatusEnum、BillPayWayEnum) | 类型 | +| 价格模块 | QRCodePayModal 二维码支付弹窗 | 组件 | + +## 4. 对外共享 + +本模块为叶子能力节点,不对外提供共享组件或 API。账单相关 API 函数定义在 `@/web/support/wallet/bill/api` 共享 API 层,可能被其他模块调用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 账单与发票 | /account/bill | projects/app/src/pages/account/bill/index.tsx | 全部用户 | 含 3 个 Tab(账单记录/发票记录/发票抬头),本模块对应「账单记录」Tab | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..351f8fa4287c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,122 @@ +--- +capability_label: 账号信息 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 业务流程索引 + +## 查看与管理个人信息 + +- **业务描述**: 用户进入账号信息页面后,可查看用户名、密码状态、联系方式、头像、团队昵称、团队余额等个人账户信息,并进行修改操作。 +- **触发入口**: 账户侧边栏(PC)→ 点击「个人信息」菜单项;移动端顶部 Tab → 选择「个人信息」 +- **路由路径**: `/account/info` +- **页面组件位置**: `projects/app/src/pages/account/info/index.tsx` +- **涉及角色**: 所有已登录用户 +- **前置条件**: 用户已登录 + +## 查看套餐与资源用量 + +- **业务描述**: 有标准套餐的团队用户可查看当前套餐类型、到期时间、套餐内容明细,以及AI积分用量、知识库索引用量、成员数/应用数/知识库数等资源使用情况。 +- **触发入口**: 账号信息页面右侧区域(PC)或个人信息下方(移动端) +- **路由路径**: `/account/info` +- **页面组件位置**: `projects/app/src/pages/account/info/index.tsx`(PlanUsage 子组件) +- **涉及角色**: 所有已登录用户(需团队有标准套餐) +- **前置条件**: 团队已开通标准套餐 + +## 修改密码 + +- **业务描述**: Plus 版用户可修改登录密码,需输入旧密码、新密码并确认新密码,新密码需满足复杂度要求。 +- **触发入口**: 个人信息区域 → 点击密码行「修改」按钮 +- **路由路径**: `/account/info`(弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/account/info/UpdatePswModal.tsx` +- **涉及角色**: 所有已登录用户(Plus版) +- **前置条件**: 系统配置 `feConfigs.isPlus` 为 true + +## 修改联系方式 + +- **业务描述**: Plus 版用户可修改绑定的联系方式(手机号/邮箱),用于账号安全和消息通知。 +- **触发入口**: 个人信息区域 → 点击联系方式行「修改」按钮 +- **路由路径**: `/account/info`(弹窗内操作) +- **页面组件位置**: `projects/app/src/components/support/user/inform/UpdateContactModal.tsx` +- **涉及角色**: 所有已登录用户(Plus版) +- **前置条件**: 系统配置 `feConfigs.isPlus` 为 true + +## 余额兑换AI积分 + +- **业务描述**: 团队管理员可将账户余额按比例兑换为AI积分(Tokens),用于模型调用消耗。 +- **触发入口**: 个人信息区域 → 余额行右侧「兑换」按钮 +- **路由路径**: `/account/info`(弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/account/info/ConversionModal.tsx` +- **涉及角色**: 团队管理员(`permission.hasManagePer`) +- **前置条件**: 团队有余额且已开通标准套餐 + +## 兑换优惠券 + +- **业务描述**: 团队拥有者可输入优惠券兑换码,兑换额外的资源包或权益。 +- **触发入口**: 套餐用量区域 →「兑换优惠券」按钮 +- **路由路径**: `/account/info`(弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/account/info/RedeemCouponModal.tsx` +- **涉及角色**: 团队拥有者(`permission.isOwner`) +- **前置条件**: 系统配置 `feConfigs.show_coupon` 为 true + +## 查看折扣券 + +- **业务描述**: 团队拥有者可查看当前可用的折扣券列表,了解可使用优惠。 +- **触发入口**: 套餐用量区域 →「折扣券」按钮 +- **路由路径**: `/account/info`(弹窗内操作) +- **页面组件位置**: `projects/app/src/pageComponents/account/info/DiscountCouponsModal.tsx` +- **涉及角色**: 团队拥有者(`permission.isOwner`) +- **前置条件**: 系统配置 `feConfigs.show_discount_coupon` 为 true + +## 查看帮助文档 + +- **业务描述**: 用户可点击进入帮助文档页面,了解产品使用指南。 +- **触发入口**: 页面底部 →「帮助文档」按钮 +- **路由路径**: 外部链接(`/docs/introduction`) +- **页面组件位置**: `projects/app/src/pages/account/info/index.tsx`(Other 子组件) +- **涉及角色**: 所有已登录用户 +- **前置条件**: 系统配置 `feConfigs.docUrl` 存在 + +## 联系客服 + +- **业务描述**: 用户可通过社区群联系客服,获取人工支持。 +- **触发入口**: 页面底部 →「联系我们」按钮 +- **路由路径**: `/account/info`(弹窗) +- **页面组件位置**: `projects/app/src/pages/account/info/index.tsx`(Other 子组件 → CommunityModal) +- **涉及角色**: 所有已登录用户 +- **前置条件**: 系统配置 `feConfigs.concatMd` 为 true + +## 问题反馈 + +- **业务描述**: 有标准套餐支持工单的用户可提交问题反馈工单。 +- **触发入口**: 页面底部 →「问题反馈」按钮 +- **路由路径**: 外部链接(工单系统重定向) +- **页面组件位置**: `projects/app/src/pages/account/info/index.tsx`(Other 子组件) +- **涉及角色**: 所有已登录用户(需有工单访问权限) +- **前置条件**: 系统配置 `feConfigs.show_workorder` 为 true,且团队套餐支持工单 + +## 场景关系 + +- **查看与管理个人信息** → **修改密码** / **修改联系方式** / **余额兑换**: 个人信息区域的子操作 +- **查看套餐与资源用量** → **兑换优惠券** / **查看折扣券**: 套餐区域的子操作 +- 各场景均为独立操作入口,页面一次性加载全部信息,场景间无严格先后依赖关系 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看与管理个人信息 | 账户侧边栏→个人信息 | /account/info | 所有已登录用户 | +| S02 | 查看套餐与资源用量 | 账号信息页面右侧/下方 | /account/info | 所有用户(需标准套餐) | +| S03 | 修改密码 | 个人信息→密码修改按钮 | /account/info(弹窗) | Plus版用户 | +| S04 | 修改联系方式 | 个人信息→联系方式修改按钮 | /account/info(弹窗) | Plus版用户 | +| S05 | 余额兑换AI积分 | 个人信息→余额兑换按钮 | /account/info(弹窗) | 团队管理员 | +| S06 | 兑换优惠券 | 套餐区域→兑换优惠券按钮 | /account/info(弹窗) | 团队拥有者 | +| S07 | 查看折扣券 | 套餐区域→折扣券按钮 | /account/info(弹窗) | 团队拥有者 | +| S08 | 查看帮助文档 | 页面底部→帮助文档按钮 | 外部链接 | 所有用户 | +| S09 | 联系客服 | 页面底部→联系我们按钮 | /account/info(弹窗) | 所有用户 | +| S10 | 问题反馈 | 页面底部→问题反馈按钮 | 外部链接 | 有工单权限用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..3cc6da43487f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,264 @@ +--- +capability_label: 账号信息 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 业务流程详解 + +## 页面总览 + +账号信息页面是用户查看和管理个人账户的中心页面。PC端采用左右双栏布局——左侧为个人信息(MyInfo)和客服入口(Other),右侧为套餐用量(PlanUsage);移动端纵向堆叠展示三个区域。页面加载时并行初始化用户信息和团队套餐状态,根据用户权限和系统配置动态显示不同的功能入口。 + +--- + +### S01:查看与管理个人信息 + +> 用户进入页面后自动加载个人信息,可修改头像、密码、联系方式、团队昵称,查看余额。 + +#### 步骤 1:页面初始化加载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入 `/account/info` 页面 | `GET /api/support/user/tokenLogin`(获取用户信息)
`GET /support/user/team/plan/getTeamPlanStatus`(并行,获取套餐状态) | 无 | 页面加载 → 解析 userInfo → 显示用户名、头像、密码掩码(`*****`)、联系方式、团队昵称默认值、余额;解析 teamPlanStatus → 套餐区域条件渲染 | + +- **数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /api/support/user/tokenLogin | 无(Cookie认证) | 设置 userInfo State,提取语言设置 → 写入 localStorage | 个人信息区域完整展示 | +| 首次加载 | GET /support/user/team/plan/getTeamPlanStatus | 无(maxQuantity:1 防抖) | 设置 teamPlanStatus State | 条件渲染套餐区域 | + +- **初始化顺序**:两个 API 并行调用(`initUserInfo` 内部触发 `initTeamPlanStatus`) + +#### 步骤 2:修改头像 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头像区域 | `POST /common/file/presignAvatarPostUrl` | 无 | 打开本地文件选择器 | +| 选择图片文件 | —(上传到预签名URL) | 无 | 头像上传中 → 上传成功 → 自动触发保存 | +| 上传成功 | `PUT /api/support/user/account/update`(自动) | 无 | 头像更新 → Toast提示"更新成功" | + +- **表单字段清单**:头像选择无表单校验,支持图片格式限制由文件选择器控制。 +- **失败场景**:API 调用失败时回滚旧头像值(乐观更新),无错误提示。 + +#### 步骤 3:修改密码 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击密码行「修改」按钮 | 无 | `feConfigs.isPlus` 为 true 才显示修改按钮 | 弹出 UpdatePswModal 弹窗 | +| 输入旧密码 | 无 | 无 | 密码输入框(type=password,必填) | +| 输入新密码 | 前端校验(`checkPasswordRule`) | 无 | 失焦校验 → 不满足则显示提示 tooltip("密码最短…、密码须包含…") | +| 输入确认密码 | 前端校验(与新密码一致) | 无 | 不一致显示提示文案("密码不匹配") | +| 点击「确认」按钮 | `POST /api/support/user/updatePasswordByOld` | 所有字段必填且校验通过 | 按钮置为 loading 状态 → 成功后关闭弹窗 → Toast"密码修改成功" | + +- **表单字段清单**: + +| 字段名 | 控件类型 | 必填 | 默认值 | 约束 | 说明 | +|--------|---------|------|--------|------|------| +| 旧密码 | 密码输入框 | ✅ | — | 无 | 当前登录密码 | +| 新密码 | 密码输入框 | ✅ | — | 满足密码复杂度规则 | `checkPasswordRule` 校验 | +| 确认密码 | 密码输入框 | ✅ | — | 与新密码一致 | 前端比对 | + +- **校验规则**: + +| 规则 | 触发时机 | 错误提示文案 | +|------|---------|-------------| +| 新密码格式 | 失焦 | "密码最短…位,需包含…"(i18n key: `login:password_tip`) | +| 确认密码一致性 | 失焦 | "密码不匹配"(i18n key: `user:password.not_match`) | + +- **失败场景**:API 调用失败 → Toast "密码修改失败" + +#### 步骤 4:修改联系方式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击联系方式行「修改」按钮 | 无 | `feConfigs.isPlus` 为 true 才显示修改按钮 | 弹出 UpdateContactModal(mode="contact") | + +> 联系方式修改逻辑在 UpdateContactModal 组件内部(`@/components/support/user/inform/UpdateContactModal`),详见用户通知模块文档。 + +- **前置条件**:未绑定联系方式时显示红色提示文案"请绑定联系方式"(i18n key: `account_info:please_bind_contact`) + +#### 步骤 5:修改团队昵称 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击昵称输入框 | 无 | 同步成员(`isSyncMember`)不可编辑,输入框 disabled | 输入框获焦 | +| 编辑昵称 | 无 | 最大 100 字符 | 实时输入 | +| 失焦 | `PUT /proApi/support/user/team/member/updateName` | 新值与当前值相同时不请求 | 成功后调用 `initUserInfo()` 刷新页面数据 | + +- **失败场景**:API 调用失败静默捕获,不做错误提示。 + +#### 步骤 6:上传头像(移动端) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头像区域(移动端) | 同PC端 `POST /common/file/presignAvatarPostUrl` | `isPc` 为 false | 显示"修改"文字 + 编辑图标引导 | + +- **PC/移动端差异**:PC端头像小圆图,hover tooltip"选择头像";移动端大圆图 + "修改"文字引导,更显眼。 + +--- + +### S02:查看套餐与资源用量 + +> 仅团队有标准套餐时展示该区域,显示套餐名称、到期时间、AI积分/知识库索引用量进度条、成员/应用/知识库限额。 + +#### 步骤 1:套餐信息展示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面加载 | —(数据来自 S01 的 teamPlanStatus) | `!!standardPlan` 为 true 时才渲染整个 PlanUsage 区域 | 展示套餐名称、到期时间、套餐内容清单 | + +- **套餐名称计算**: + - 企业微信团队 + 免费套餐 → 显示"试用"标签 + - 其他 → 优先使用 `subPlans` 中的自定义名称,否则用 `standardSubLevelMap` 映射 + +- **免费团队提示**:当前套餐为 Free 且无额外资源包时,显示黄色警告"您的知识库可能会被清理"(i18n key: `account_info:account_knowledge_base_cleanup_warning`) + +#### 步骤 2:查看AI积分用量 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看AI积分进度条 | —(计算自 teamPlanStatus) | 无 | 进度条按使用比例变色(<50% 绿色 → <80% 黄色 → ≥80% 红色);数值显示"已用/总量" | + +- **积分总量**:`teamPlanStatus.totalPoints` 为 undefined 时显示"不限量" +- **提示**:进度条旁 QuestionTip 提示"AI积分用量说明"(i18n key: `account_info:ai_points_usage_tip`) + +#### 步骤 3:查看知识库索引用量 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看知识库索引用量进度条 | —(计算自 teamPlanStatus) | 无 | 同AI积分,变色逻辑一致 | + +#### 步骤 4:查看限额列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看成员/应用/知识库限额 | —(计算自 teamPlanStatus) | 应用注册数限额仅在配置存在时展示 | 各限额项显示"已用/最大" | + +- **限额项**: + 1. 成员数量 — `usedMember / maxTeamMember` + 2. 应用数量 — `usedAppAmount / maxAppAmount` + 3. 知识库数量 — `usedDatasetSize / maxDatasetAmount` + 4. 应用注册数 — `usedRegistrationCount / appRegistrationCount`(条件展示) + +- **应用注册数特殊处理**:若 `subPlans.appRegistrationUrl` 存在,显示「申请应用注册」链接跳转外部。 + +#### 步骤 5:查看计费标准 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「计费标准」按钮 | 无 | 无 | 弹出 ModelPriceModal 弹窗(`@/components/core/ai/ModelTable`) | + +> 计费标准弹窗展示各模型的定价信息,详见 AI 模型模块文档。 + +--- + +### S03~S07:弹窗类操作(修改密码、联系方式、余额兑换、优惠券兑换、折扣券查看) + +> 密码、联系方式已在 S01 中详述,此处补充余额兑换和优惠券流程。 + +#### S05:余额兑换AI积分 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击余额行「兑换」按钮 | 无 | `userInfo.permission.hasManagePer` 且有标准套餐 | 弹出 ConversionModal 弹窗 | +| 查看弹窗信息 | —(前端计算) | 无 | 展示:当前Token价格(¥15/1000 Tokens/月)、当前余额、可兑换Token数、有效期提示 | +| 点击「兑换」按钮 | `POST /api/support/wallet/balance/conversion` | 无 | 按钮置为 loading → 成功 → 页面刷新 → Toast"兑换成功" | +| 点击「联系客服」链接 | 无 | 无 | 触发 onOpenContact → 弹出 CommunityModal | + +- **兑换计算**:`可兑换Token数 = ceil(余额 / 15 × SUB_EXTRA_POINT_RATE)` +- **失败场景**:API 调用失败 → Toast "兑换失败" + +#### S06:兑换优惠券 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「兑换优惠券」按钮 | 无 | `userInfo.permission.isOwner` 且 `feConfigs.show_coupon` | 弹出 RedeemCouponModal 弹窗 | +| 输入券码 | 无 | 无 | placeholder 为「兑换优惠券」 | +| 点击「确认」 | `GET /proApi/support/wallet/coupon/redeem` | 券码非空 | 按钮 loading → 成功 → 刷新套餐状态 → 关闭弹窗 → Toast"操作成功" | + +#### S07:查看折扣券 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「折扣券」按钮 | 无 | `userInfo.permission.isOwner` 且 `feConfigs.show_discount_coupon` | 弹出 DiscountCouponsModal 弹窗 | + +--- + +### S08:查看帮助文档 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「帮助文档」按钮 | 无 | `feConfigs.docUrl` 存在 | `window.open` 跳转 `/docs/introduction` | + +--- + +### S09:联系客服 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「联系我们」按钮 | 无 | `feConfigs.concatMd` 为 true | 弹出 CommunityModal 弹窗 | + +--- + +### S10:问题反馈 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「问题反馈」按钮 | `GET /proApi/common/workorder/create` | `feConfigs.show_workorder` 为 true | 先检查工单权限 → 无权限 → 弹窗提示(TeamErrEnum.ticketNotAvailable);有权限 → 获取跳转URL → window.open | + +- **权限检查**:`teamPlanStatus.standard.ticketResponseTime` 是否存在 +- **失败场景**:无工单访问权限 → 弹窗提示"工单不可用"(通过 `setNotSufficientModalType`) + +--- + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant User + participant UI as 账号信息页面 + participant API + + User->>UI: 进入 /account/info + par 并行加载 + UI->>API: GET /api/support/user/tokenLogin + API-->>UI: userInfo + UI->>API: GET /support/user/team/plan/getTeamPlanStatus + API-->>UI: teamPlanStatus + end + UI-->>User: 展示个人信息 + 套餐用量(条件) + + User->>UI: 点击头像 → 选择文件 + UI->>API: POST /common/file/presignAvatarPostUrl + API-->>UI: 预签名URL + UI->>API: PUT 头像文件(S3) + UI->>API: PUT /api/support/user/account/update + API-->>UI: 更新成功 + UI-->>User: Toast "更新成功" + + User->>UI: 编辑昵称 → 失焦 + UI->>API: PUT /proApi/support/user/team/member/updateName + API-->>UI: 更新成功 + UI->>API: GET tokenLogin(刷新) + API-->>UI: 最新 userInfo + + User->>UI: 点击密码「修改」 + UI-->>User: 修改密码弹窗 + User->>UI: 填写旧密码/新密码/确认密码 + UI->>API: POST /api/support/user/updatePasswordByOld + API-->>UI: 修改成功 + UI-->>User: Toast "密码修改成功" → 关闭弹窗 + + User->>UI: 点击余额「兑换」 + UI-->>User: 兑换弹窗 + User->>UI: 点击「兑换」确认 + UI->>API: POST /api/support/wallet/balance/conversion + API-->>UI: 兑换成功 + UI-->>User: 页面刷新 → Toast "兑换成功" +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..57f867b14421 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\350\203\275\345\212\233\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: 账号信息 +doc_type: "11" +doc_label: 共享能力清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 共享能力清单 + +## 说明 + +本模块为账户分组下的叶子节点,未检测到对外共享能力(模块内组件仅在本页面内部使用,未被其他模块引用)。 + +跨模块依赖摘要请参见 [模块概览](./模块概览.md) §3-§4。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..aafb48164b22 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,52 @@ +--- +capability_label: 账号信息 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 模块概览 + +## 1. 模块业务概述 + +账号信息模块是 FastGPT 账户体系中的个人信息管理中心,为所有已登录用户提供个人资料查看与编辑、团队套餐使用情况查看、以及客服支持入口等功能。该模块是「账户」分组下的核心节点之一,承载用户对自身账户状态和团队资源消耗的感知。 + +模块面向三类角色提供服务:普通成员可查看和编辑个人信息;团队管理员可额外观阅余额兑换入口;团队拥有者还可使用优惠券兑换功能。页面采用 PC 端左右双栏布局(左侧个人信息+客服入口,右侧套餐用量),移动端则纵向堆叠展示。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 普通成员 | /account/info | 查看/修改个人信息、修改密码(Plus版)、修改联系方式(Plus版)、上传头像、修改团队昵称、查看套餐用量、查看帮助文档、联系客服 | 基础个人信息管理 | +| 团队管理员 | /account/info | 上述所有操作 + 余额兑换AI积分 | 额外显示余额兑换按钮(`permission.hasManagePer` 且有标准套餐) | +| 团队拥有者 | /account/info | 上述所有操作 + 兑换优惠券、查看折扣券 | 额外显示优惠券和折扣券入口(`permission.isOwner` 且配置开启) | + +**权限模型**: 基于 `useUserStore.userInfo.permission` 中的 `hasManagePer` 和 `isOwner` 字段进行条件渲染;功能开关还受 `feConfigs.isPlus`、`feConfigs.show_coupon`、`feConfigs.show_discount_coupon` 等系统配置控制。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 用户状态管理(useUserStore) | 获取/更新用户信息、获取团队套餐状态 | Store | +| 系统配置(useSystemStore) | 读取 feConfigs 功能开关、子套餐配置 | Store | +| 共享UI组件库(@fastgpt/web) | Avatar、Icon、MyTooltip、QuestionTip、MyDivider | 组件 | +| 账户容器(AccountContainer) | PC侧边栏导航 + 移动端Tab切换 | 组件 | +| 团队管理 | TeamSelector 团队选择器组件 | 组件 | +| 钱包/套餐(wallet) | 套餐内容清单组件、价格格式化工具、余额兑换 | 组件/API | + +## 4. 对外共享 + +本模块为叶子节点,不对外暴露共享能力。模块内组件(如 ConversionModal、UpdatePswModal)仅在本页面内部使用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 账号信息 | /account/info | projects/app/src/pages/account/info/index.tsx | 所有已登录用户 | 无Tab,单页展示个人信息+套餐用量+客服入口 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f4968d926277 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,68 @@ +--- +capability_label: 通知 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 所有登录用户(Plus版) + - 个人账户用户 + - 团队账户用户 +router_paths: + - /account/inform +--- + +# 通知 — 业务流程索引 + +## 业务场景列表 + +### 查看通知列表 + +- **业务描述**: 用户进入通知中心,浏览系统推送的所有通知消息,包括官方通知和团队通知,按时间倒序排列 +- **触发入口**: 账户中心侧边栏点击"通知"导航项(PC)或移动端顶部 Tab 栏切换到"通知"(移动端) +- **路由路径**: `/account/inform` +- **页面组件位置**: `projects/app/src/pages/account/inform.tsx` +- **涉及角色**: 所有登录用户(Plus版) +- **前置条件**: 用户已登录且订阅 Plus 版,`feConfigs.isPlus` 为 `true` + +### 查看通知详情 + +- **业务描述**: 用户点击通知列表中的某条通知,在弹出的详情弹窗中查看通知的完整内容(Markdown 格式渲染) +- **触发入口**: 通知列表中点击任意通知卡片 +- **路由路径**: `/account/inform`(弹窗覆盖,路由不变) +- **页面组件位置**: `projects/app/src/pageComponents/account/NotificationDetailsModal.tsx` +- **涉及角色**: 所有登录用户(Plus版) +- **前置条件**: 通知列表已加载且有通知数据 + +### 标记通知已读 + +- **业务描述**: 用户点击未读通知时,系统自动将该通知标记为已读 +- **触发入口**: 通知列表中点击红点标记的未读通知卡片 +- **路由路径**: `/account/inform`(操作不改变路由) +- **页面组件位置**: `projects/app/src/pages/account/inform.tsx` +- **涉及角色**: 所有登录用户(Plus版) +- **前置条件**: 存在未读通知(红点可见) + +### 关闭重要通知弹窗 + +- **业务描述**: 重要级别通知会以全局浮层形式在页面顶部展示,用户点击关闭按钮将其标记为已读并移除 +- **触发入口**: 任意页面的重要通知浮层上点击关闭图标 +- **路由路径**: 任意页面(全局浮层,不绑定路由) +- **页面组件位置**: `projects/app/src/components/support/user/inform/ImportantInform.tsx` +- **涉及角色**: 所有登录用户(Plus版) +- **前置条件**: 存在级别为 `important` 或 `emergency` 的未读通知 + +## 场景关系 + +- **查看通知列表** → **查看通知详情**:列表点击触发详情弹窗 +- **查看通知列表** → **标记通知已读**:点击未读通知时,标记已读与打开详情同时触发 +- **浏览任意页面** → **关闭重要通知弹窗**:全局浮层不依赖通知页面,可在任何页面触发 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看通知列表 | 账户中心→点击"通知"导航 | /account/inform | 所有登录用户(Plus版) | +| S02 | 查看通知详情 | 通知列表→点击通知卡片 | /account/inform | 所有登录用户(Plus版) | +| S03 | 标记通知已读 | 通知列表→点击未读通知 | /account/inform | 所有登录用户(Plus版) | +| S04 | 关闭重要通知弹窗 | 任意页面→点击重要通知浮层关闭按钮 | 任意页面 | 所有登录用户(Plus版) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..9d5468a62139 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,163 @@ +--- +capability_label: 通知 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 所有登录用户(Plus版) + - 个人账户用户 + - 团队账户用户 +router_paths: + - /account/inform +--- + +# 通知 — 业务流程详解 + +## 页面总览 + +通知页面位于账户中心内,以卡片列表形式展示用户收到的系统通知。每位用户的未读通知数通过 Layout 层的定时轮询获取,并在导航栏入口以角标显示。页面打开时通过分页接口加载通知列表,支持翻页和每页条数切换(默认每页 20 条)。通知列表为空时显示空状态提示。未读通知以红点标记,点击即标记为已读并打开详情弹窗。 + +--- + +### 查看通知列表 + +> 用户进入通知中心,浏览收到的所有通知消息。页面以卡片列表展示,每条通知显示标题、时间、类型标签(官方/团队)和内容摘要(最多 6 行),按时间倒序排列。 + +#### 步骤 1:进入通知页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| PC 端:点击左侧导航栏"通知"图标 → 移动端:切换到"通知"Tab | —(路由跳转 `/account/inform`) | AccountContainer 中 `feConfigs.isPlus` 为 `true` 时"通知"导航项才可见 | 路由跳转至 `/account/inform`,AccountContainer 渲染通知页面组件,页面进入加载状态 | + +#### 步骤 2:加载通知列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面组件挂载,usePagination 自动触发首次数据请求 | POST `/proApi/support/user/inform/list`(参数: `pageNum=1, pageSize=20`) — 串行,无依赖其他 API | 无,首次加载必调用 | 显示 Loading 遮罩(`fixed=false`,不阻塞页面交互);API 返回后列表卡片渲染;若无数据则显示空状态"暂无通知"提示 | + +#### 步骤 3:浏览通知列表 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 滚动浏览通知卡片列表 | — | 无 | 每条通知渲染:标题(团队通知前缀【团队名】)、时间(格式化为相对时间如"2 小时前")、类型标签(官方=蓝色/团队=绿色)、内容 Markdown 摘要(最多 6 行,超出省略);未读通知显示红点标记 | + +#### 步骤 4:翻页或切换每页条数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击分页器"上一页"/"下一页"或输入页码,或从下拉菜单切换每页条数(10/20/50/100)| POST `/proApi/support/user/inform/list`(参数: `pageNum=N, pageSize=M`) — 独立请求 | 仅在 `total > pageSize` 时分页器可见;切换每页条数时自动重置到第 1 页 | 分页器显示总条数、当前页/总页数;列表重新加载,翻页后使用滚动容器自动回到顶部(`scrollContainerRef.current.scrollTop = 0`)| + +##### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /proApi/support/user/inform/list | pageNum=1, pageSize=20 | 无额外处理,直接渲染 | 通知卡片列表,最多 20 条 | +| 翻页 | POST /proApi/support/user/inform/list | pageNum=N, pageSize=20 | 用新页数据替换当前列表 | 第 N 页通知卡片 | +| 切换每页条数 | POST /proApi/support/user/inform/list | pageNum=1, pageSize=M | 重置到第 1 页,按新条数加载 | M 条通知卡片 | + +- **分页参数**:默认每页 20 条,可选 10/20/50/100 +- **排序规则**:按通知时间倒序(服务端排序) +- **轮询刷新**:通知列表本身不轮询;未读数量通过 Layout 层的 `getUnreadCount` 每 30 秒轮询更新 + +--- + +### 查看通知详情 + +> 用户点击通知卡片,弹出详情弹窗查看通知完整内容。若该通知未读,同时触发标记已读操作。 + +#### 步骤 1:点击通知卡片 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击通知列表中的任意通知卡片 | 若通知未读:GET `/proApi/support/user/inform/read`(参数: `id=通知ID`) — 独立请求,与弹窗打开并行;若已读:不触发 API | 仅 `item.read === false` 时触发 readInform | 卡片 hover 时边框变为蓝色(`#94B5FF`),点击后:未读红点消失,`NotificationDetailsModal` 弹窗打开 | + +#### 步骤 2:浏览通知详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 查看弹窗中的通知内容 | — | 无 | 弹窗展示:图标(通知图标+蓝色背景)、标题("通知详情")、通知标题、时间、类型标签、分隔线、Markdown 渲染的完整通知内容;弹窗最大宽度 680px,最大高度 80vh,超出滚动 | + +#### 步骤 3:关闭详情弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击弹窗关闭按钮或遮罩层 | — | 无 | 弹窗关闭,`selectedInform` 状态置为 `null`,回到通知列表页面 | + +##### 标记已读链路详情 + +- **标记时机**:点击通知卡片即触发(不等待弹窗用户操作),与打开弹窗并行 +- **标记后刷新**:标记成功后调用 `getData(pageNum)` 重新加载当前页数据,更新列表项的红点状态 +- **批量行为**:不支持批量已读,每条通知需单独点击 + +--- + +### 关闭重要通知弹窗 + +> 当存在级别为 `important` 或 `emergency` 的未读通知时,这些通知会以全局浮层形式在页面顶部居中展示,用户可主动关闭。 + +#### 步骤 1:重要通知浮层展示 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需用户操作,由 Layout 组件自动检测并展示 | GET `/proApi/support/user/inform/countUnread` — 每 30 秒轮询,返回 `importantInforms` 数组 | `feConfigs.isPlus` 为 `true`、用户已登录、`importantInforms.length > 0` 时展示 | 页面顶部居中浮层(`position: fixed, top: 3%, left: 50%`),每条重要通知为一个独立卡片,显示图标、标题和 Markdown 内容 | + +#### 步骤 2:点击关闭按钮 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击通知卡片右上角的关闭图标(X) | GET `/proApi/support/user/inform/read`(参数: `id=通知ID`)— 独立请求 | 无 | 关闭按钮 hover 时背景变灰;API 成功后调用 `refetchUnRead()` 刷新未读数,该通知从浮层列表中移除 | + +--- + +## Mermaid 附录 + +### 查看通知列表 + 详情 + +```mermaid +sequenceDiagram + participant User + participant UI as 通知页面 + participant API + participant Backend + + User->>UI: 进入通知页面 (/account/inform) + UI->>API: POST /proApi/support/user/inform/list
(pageNum=1, pageSize=20) + API->>Backend: 查询通知列表 + Backend-->>API: 返回通知数据 + 总数 + API-->>UI: 渲染通知卡片列表 + UI-->>User: 显示通知卡片(标题/时间/标签/摘要) + + User->>UI: 点击未读通知卡片 + par 并行请求 + UI->>API: GET /proApi/support/user/inform/read
(id=通知ID) + API->>Backend: 标记已读 + Backend-->>API: 成功 + API-->>UI: 刷新列表 + and + UI-->>User: 打开详情弹窗(Markdown 内容) + end + + User->>UI: 关闭弹窗 + UI-->>User: 回到通知列表 +``` + +### 关闭重要通知弹窗 + +```mermaid +sequenceDiagram + participant User + participant UI as Layout 浮层 + participant API + participant Backend + + Note over UI: 30s 轮询检测到重要通知 + UI-->>User: 页面顶部展示重要通知浮层 + + User->>UI: 点击关闭按钮 (X) + UI->>API: GET /proApi/support/user/inform/read
(id=通知ID) + API->>Backend: 标记已读 + Backend-->>API: 成功 + API-->>UI: 刷新未读计数,浮层卡片移除 + UI-->>User: 浮层消失 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..6c30ae539d6b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,55 @@ +--- +capability_label: 通知 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 所有登录用户(Plus版) + - 个人账户用户 + - 团队账户用户 +router_paths: + - /account/inform +--- + +# 通知 — 模块概览 + +## 1. 模块业务概述 + +通知模块是 FastGPT 账户中心下的消息中心功能,为用户集中展示系统推送的各类通知消息,包括官方公告、系统通知和团队消息。用户可在该页面浏览通知列表、查看通知详情,未读通知会在列表项上以红点标记,点击即标记为已读。 + +该模块面向**所有已登录用户**(需 Plus 版订阅),是用户获取系统重要信息的统一入口。通知按来源分为两大类:**官方通知**(系统级别的公告和消息)和**团队通知**(仅团队账户可见,与团队相关的消息),通过标签颜色(蓝色/绿色)区分。 + +此外,重要通知(级别为 `important` 或 `emergency`)会通过全局弹窗(ImportantInform 组件)在任意页面顶部展示,确保用户不会错过关键信息。 + +## 2. 角色差异 + +本模块对所有已登录用户的功能一致,无角色权限差异。区分主要体现在数据层面: + +- **个人账户用户**:仅看到官方通知(蓝色标签"官方") +- **团队账户用户**:同时看到官方通知和团队通知(绿色标签"团队"),团队通知标题前带有【团队名称】前缀 +- **权限模型**:通知模块无需额外权限校验,页面入口通过 `feConfigs.isPlus` 配置控制,仅在 Plus 版订阅下显示账户侧边栏的"通知"导航项 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户(容器) | AccountContainer — 账户中心的统一布局容器,提供侧边导航栏和移动端 Tab 切换 | 组件 | +| 共享基础设施(web) | usePagination — 通用分页 Hook,驱动列表数据加载、翻页、每页条数切换 | Hook | +| 共享基础设施(web) | EmptyTip — 空数据提示组件 | 组件 | +| 共享基础设施(web) | MyTag — 标签组件,区分官方/团队通知类型 | 组件 | +| 共享基础设施 | Markdown — 渲染通知内容(Markdown 格式) | 组件 | + +## 4. 对外共享 + +本模块未检测到被其他模块引用的共享能力。通知详情弹窗(NotificationDetailsModal)和通知列表逻辑均为模块内部使用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 通知列表 | /account/inform | projects/app/src/pages/account/inform.tsx | 所有登录用户(Plus版) | 无 Tab,单页面列表+详情弹窗 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..31f51c9e0488 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,84 @@ +--- +capability_label: 配置 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [系统管理员] +router_paths: ["/config/tool", "/config/tool/marketplace"] +--- + +## 工具列表管理 + +- **业务描述**: 系统管理员对平台已安装的系统工具进行集中管理,包括查看工具详情、调整工具排序、配置工具参数以及控制工具的启用状态。 +- **触发入口**: 侧边栏「配置」→「系统工具」 +- **路由路径**: /config/tool +- **页面组件位置**: projects/app/src/pages/config/tool/index.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 已登录系统管理员账号 + +## 新增系统工具 + +- **业务描述**: 管理员通过三种途径为平台添加新工具——从工具市场选择安装、导入本地工具包、选择已有工作流应用作为工具。 +- **触发入口**: 工具配置首页 →「添加资源」按钮 +- **路由路径**: /config/tool +- **页面组件位置**: projects/app/src/pages/config/tool/index.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 已进入工具配置首页 + +## 标签管理 + +- **业务描述**: 管理员对系统工具的分类标签进行创建、编辑、删除和排序,用于工具的分类组织和筛选。 +- **触发入口**: 工具配置首页 →「标签管理」按钮 +- **路由路径**: /config/tool +- **页面组件位置**: projects/app/src/pageComponents/config/TagManageModal.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 已进入工具配置首页 + +## 导入工具 + +- **业务描述**: 管理员导入外部工具包到平台,支持从文件上传并注册为系统工具。 +- **触发入口**: 工具配置首页 → 添加资源 →「导入资源」 +- **路由路径**: /config/tool +- **页面组件位置**: projects/app/src/pageComponents/config/ImportPluginModal.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 已进入工具配置首页 + +## 浏览工具市场 + +- **业务描述**: 管理员浏览外部工具市场,通过搜索和标签筛选发现可安装的第三方工具,支持安装、卸载和更新操作。 +- **触发入口**: 工具配置首页 → 添加资源 →「开放市场」;或侧边栏直达 +- **路由路径**: /config/tool/marketplace +- **页面组件位置**: projects/app/src/pages/config/tool/marketplace.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 已登录系统管理员账号 + +## 批量更新工具 + +- **业务描述**: 管理员一键批量更新所有存在新版的市场工具,提升工具维护效率。 +- **触发入口**: 工具市场页面 → 可更新工具卡片 →「批量更新」 +- **路由路径**: /config/tool/marketplace +- **页面组件位置**: projects/app/src/pages/config/tool/marketplace.tsx +- **涉及角色**: 系统管理员 +- **前置条件**: 存在已安装且有新版本的市场工具 + +## 场景关系 + +- **工具列表管理** → **新增系统工具**: 在工具列表中发起工具新增操作 +- **工具列表管理** → **标签管理**: 在工具列表中打开标签管理弹窗 +- **新增系统工具** → **浏览工具市场**: 通过「开放市场」入口跳转到工具市场 +- **浏览工具市场** → **导入工具**: 市场工具安装后返回工具列表,补充工具导入流程 +- **浏览工具市场** → **批量更新工具**: 从市场页发起批量更新 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 工具列表管理 | 侧边栏「系统工具」 | /config/tool | 系统管理员 | +| S02 | 新增系统工具 | 工具列表→添加资源按钮 | /config/tool | 系统管理员 | +| S03 | 标签管理 | 工具列表→标签管理按钮 | /config/tool | 系统管理员 | +| S04 | 导入工具 | 工具列表→添加资源→导入资源 | /config/tool | 系统管理员 | +| S05 | 浏览工具市场 | 工具列表→添加资源→开放市场 | /config/tool/marketplace | 系统管理员 | +| S06 | 批量更新工具 | 工具市场→批量更新按钮 | /config/tool/marketplace | 系统管理员 | + +> 各子能力的详细业务流程(如工具列表的拖拽排序交互、市场工具的安装/卸载细节)请参见对应子能力文档。场景 S01、S02、S03、S04 的详细步骤见 [工具配置首页的业务流程详解](../工具配置/工具配置首页/业务流程详解.md);场景 S05、S06 的详细步骤见 [工具市场的业务流程详解](../工具配置/工具市场/业务流程详解.md)。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..bc664f4f7c6b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,34 @@ +--- +capability_label: 工具配置 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 配置 +roles: [admin] +router_paths: [/config/tool, /config/tool/marketplace] +--- + +# 工具配置 — 业务流程索引 + +本模块为工具配置的分组索引节点,包含两个子能力各自的业务场景。 + +## 业务场景汇总 + +| 编号 | 场景 | 所属子能力 | 触发入口 | 路由路径 | 角色 | +|------|------|-----------|---------|---------|------| +| S01 | 管理系统工具列表 | 工具配置首页 | 管理后台→工具配置 | /config/tool | admin | +| S02 | 配置单个工具 | 工具配置首页 | 工具列表→点击工具行 | /config/tool | admin | +| S03 | 导入工具插件 | 工具配置首页 | 工具配置→添加资源→导入 | /config/tool | admin | +| S04 | 管理工具标签 | 工具配置首页 | 工具配置→标签管理按钮 | /config/tool | admin | +| S05 | 拖拽调整工具顺序 | 工具配置首页 | 工具列表→拖拽行 | /config/tool | admin | +| S06 | 浏览工具市场 | 工具市场 | 工具配置→添加资源→市场 或 直接路由 | /config/tool/marketplace | admin | +| S07 | 搜索与筛选市场工具 | 工具市场 | 市场页面→搜索框/标签筛选 | /config/tool/marketplace | admin | +| S08 | 安装市场工具 | 工具市场 | 市场列表→安装按钮 | /config/tool/marketplace | admin | +| S09 | 更新已安装工具 | 工具市场 | 市场列表→更新按钮 或 批量更新 | /config/tool/marketplace | admin | +| S10 | 卸载已安装工具 | 工具市场 | 市场列表→删除按钮 | /config/tool/marketplace | admin | +| S11 | 查看工具详情 | 工具市场 | 市场列表→点击工具卡片 | /config/tool/marketplace | admin | + +详细场景描述及交互步骤请参见各子能力的文档: + +- **工具配置首页**:参见 [09-业务流程索引](../工具配置首页/业务流程索引.md) +- **工具市场**:参见 [09-业务流程索引](../工具市场/业务流程索引.md) diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..8801475c1a5b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,54 @@ +--- +capability_label: 工具配置 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 配置 +roles: [admin] +router_paths: [/config/tool, /config/tool/marketplace] +--- + +# 工具配置 — 业务流程详解 + +本模块为工具配置的分组索引节点,各子能力的详细业务流程请参见对应文档。 + +## 子能力业务流程索引 + +| 子能力 | 业务描述 | 业务流程索引 | 业务流程详解 | +|--------|---------|------------|------------| +| 工具配置首页 | 系统工具的集中管理台,支持查看、配置、拖拽排序、导入工具 | [09-业务流程索引](../工具配置首页/业务流程索引.md) | [10-业务流程详解](../工具配置首页/业务流程详解.md) | +| 工具市场 | 连接外部 Marketplace,浏览、搜索、安装、更新和卸载工具 | [09-业务流程索引](../工具市场/业务流程索引.md) | [10-业务流程详解](../工具市场/业务流程详解.md) | + +## 模块级关系 + +工具配置首页和工具市场之间存在业务流转关系: + +- **工具市场 → 工具配置首页**:在市场安装工具后,工具自动出现在首页列表中 +- **工具配置首页 → 工具市场**:通过「添加资源→市场」按钮可直接跳转到市场页面 + +两者共同构成「发现(市场)→ 安装(市场)→ 配置(首页)→ 管控(首页)」的完整工具生命周期管理。 + +## Mermaid 附录 + +```mermaid +sequenceDiagram + participant Admin as 管理员 + participant Home as 工具配置首页 + participant Market as 工具市场 + + Admin->>Home: 进入工具配置首页 + Admin->>Home: 查看/管理已有工具 + Admin->>Home: 点击「添加资源→市场」 + Home->>Market: 路由跳转 /config/tool/marketplace + + Admin->>Market: 搜索/浏览工具 + Admin->>Market: 安装选中工具 + Market->>Market: 下载并安装插件 + Note over Market: 安装完成后,工具出现在首页列表中 + + Admin->>Market: 返回首页 + Market->>Home: 路由跳转 /config/tool + Admin->>Home: 配置新安装的工具 + + Note over Home,Market: 各子能力详细流程见子文档 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cf4952011e0b --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,95 @@ +--- +capability_label: 工具市场 +doc_type: "09" +doc_label: "业务流程索引" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "工具配置" +roles: ["admin"] +router_paths: ["/config/tool/marketplace"] +--- + +# 工具市场 — 业务流程索引 + +## 业务场景列表 + +### 浏览与搜索工具 + +- **业务描述**: 管理员进入工具市场,浏览可用工具列表。可通过顶部搜索框按工具名称搜索,通过标签筛选器按工具分类过滤。 +- **触发入口**: 左侧导航 → 配置 → 工具管理 → 点击「添加资源」→ 选择「打开市场」 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 系统管理员已登录 + +### 安装工具 + +- **业务描述**: 管理员在市场中找到需要的工具,点击工具卡片上的安装按钮,从 Marketplace 服务下载并安装工具到本地系统工具库。 +- **触发入口**: 工具市场 → 工具卡片 hover 时显示的「安装」按钮 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 工具未安装;Marketplace 服务可访问 + +### 卸载工具 + +- **业务描述**: 管理员卸载已安装的工具,从本地系统工具库中移除。 +- **触发入口**: 工具市场 → 已安装工具卡片 hover 时显示的「卸载」按钮 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 工具已安装 + +### 更新工具 + +- **业务描述**: 管理员将已安装的工具更新至 Marketplace 上的最新版本。 +- **触发入口**: 工具市场 → 有更新的工具卡片 hover 时显示的「更新」按钮 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 工具已安装且 Marketplace 上有更新版本 + +### 批量更新工具 + +- **业务描述**: 管理员一次性更新多个有可用更新的已安装工具。 +- **触发入口**: 工具市场 → 右上角「可更新 (N)」按钮 → 打开批量更新抽屉 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 存在至少一个可更新的已安装工具 + +### 查看工具详情 + +- **业务描述**: 管理员点击工具卡片查看工具的详细信息,包括标签、描述、作者、参数说明、使用指南等。 +- **触发入口**: 工具市场 → 点击工具卡片 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 无 + +### 筛选未安装工具 + +- **业务描述**: 管理员通过下拉菜单切换显示模式,仅显示未安装的工具,便于发现新工具。 +- **触发入口**: 工具市场 → 标签筛选栏右侧下拉菜单 → 选择「未安装」 +- **路由路径**: `/config/tool/marketplace` +- **页面组件位置**: `projects/app/src/pages/config/tool/marketplace.tsx` +- **涉及角色**: admin +- **前置条件**: 无 + +## 场景关系 + +- **浏览与搜索工具** → **安装工具 / 查看工具详情**: 浏览是安装和查看详情的前置动作 +- **浏览与搜索工具** → **批量更新工具**: 浏览时发现有可用更新时触发批量更新 +- **查看工具详情** → **安装工具 / 卸载工具 / 更新工具**: 详情抽屉中可直接执行安装、卸载或更新操作 +- 其他场景之间无严格先后依赖,可独立执行 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 浏览与搜索工具 | 工具管理→打开市场 | /config/tool/marketplace | admin | +| S02 | 安装工具 | 工具卡片→安装按钮 | /config/tool/marketplace | admin | +| S03 | 卸载工具 | 已安装工具卡片→卸载按钮 | /config/tool/marketplace | admin | +| S04 | 更新工具 | 有更新的工具卡片→更新按钮 | /config/tool/marketplace | admin | +| S05 | 批量更新工具 | 右上角可更新按钮→批量更新抽屉 | /config/tool/marketplace | admin | +| S06 | 查看工具详情 | 点击工具卡片→打开详情抽屉 | /config/tool/marketplace | admin | +| S07 | 筛选未安装工具 | 筛选下拉→选择未安装 | /config/tool/marketplace | admin | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..cbd38fb367b4 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,320 @@ +--- +capability_label: 工具市场 +doc_type: "10" +doc_label: "业务流程详解" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "工具配置" +roles: ["admin"] +router_paths: ["/config/tool/marketplace"] +--- + +# 工具市场 — 业务流程详解 + +## 页面总览 + +工具市场是系统工具的在线商店页面,管理员在此浏览、搜索、安装和管理来自 Marketplace 服务的第三方工具。页面采用响应式网格布局展示工具卡片,支持关键词搜索、标签筛选、安装状态过滤和批量更新。 + +## 非 Tab 业务流程 + +### 页面初始化 + +#### 步骤 1:进入页面并加载数据 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 从工具管理页点击「打开市场」进入 `/config/tool/marketplace` | GET `/marketplace/api/tool/tags`(获取标签列表) | 始终调用 | 页面加载,显示左侧 Dashboard 侧边栏(PC 端) | +| 页面自动加载初始数据 | POST `/marketplace/api/tool/list`(获取工具列表,pageNum=1, pageSize=20) | 始终调用 | 显示 Hero 区域(品牌标语 + 搜索框)和工具卡片网格 | +| 页面自动加载初始数据 | GET `/core/plugin/admin/marketplace/installed`(获取已安装工具列表) | 始终调用 | 工具卡片上标记「已安装」状态或显示「更新」标记 | +| 页面自动加载初始数据 | GET `/marketplace/api/tool/versions`(获取 Marketplace 版本信息) | 始终调用 | 计算可更新工具列表,右上角显示「可更新 (N)」按钮 | + +**数据加载详情**: + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | POST /marketplace/api/tool/list | pageNum=1, pageSize=20 | 合并已安装状态、i18n 名称解析、标签名称匹配 | 工具卡片网格(最多 4 列) | +| 翻页(滚动) | POST /marketplace/api/tool/list | pageNum=N, pageSize=20 | 同首次加载,追加到已有列表 | 追加新卡片到列表末尾 | +| 筛选刷新 | POST /marketplace/api/tool/list | pageNum=1, pageSize=20, searchKey/tags | 重置列表,按新条件请求 | 网格替换为新列表 | + +- **分页参数**: 滚动加载,每页 20 条 +- **筛选条件**: 搜索关键词(searchKey)、标签ID 列表(tags)、安装状态过滤(前端过滤,非 API 参数) + +#### 步骤 2:Marketplace 服务不可用时的处理 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 进入页面 | POST /marketplace/api/tool/list | API 请求失败(toolsError && !loadingTools) | 隐藏工具网格和 Hero 区域 | +| — | — | — | 显示错误页面:空白图标 + 「插件市场连接失败」提示 + Marketplace URL + 复制按钮 + 关闭按钮(返回工具管理) | + +--- + +### S01:浏览与搜索工具 + +#### 步骤 1:关键词搜索 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 在搜索框中输入文字 | — | — | 输入值实时更新,显示清除按钮 | +| 停止输入 500ms 后 | POST `/marketplace/api/tool/list`(携带 searchKey) | 搜索词通过 URL query 参数 `search` 同步 | URL 更新为 `?search=xxx`,工具列表刷新 | + +#### 步骤 2:标签筛选 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击某个标签 | POST `/marketplace/api/tool/list`(携带 tags 参数) | 标签 ID 通过 URL query 参数 `tags` 以逗号分隔同步 | 标签高亮选中态,工具列表按标签筛选刷新 | +| 再次点击已选中标签 | POST `/marketplace/api/tool/list`(移除该 tag) | — | 标签取消选中,列表更新 | +| 点击「全部」标签 | POST `/marketplace/api/tool/list`(不带 tags) | — | 清除所有标签筛选,显示全部工具 | + +#### 步骤 3:筛选未安装工具 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击筛选下拉菜单,选择「未安装」 | —(前端过滤,不触发 API) | `installedFilter === true` | 工具列表仅显示 `installed === false` 的工具 | +| 选择「全部」 | — | `installedFilter === false` | 显示所有工具 | + +#### 步骤 4:Hero 区域滚动收起 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 向下滚动页面,Hero 区域滚出可视区 | — | IntersectionObserver 检测 Hero 不可见 | 顶部出现紧凑搜索栏(含搜索框 + 标签筛选),Hero 区域渐隐 | +| 向上滚动使 Hero 重新可见 | — | IntersectionObserver 检测 Hero 可见 | 紧凑搜索栏收起,Hero 区域渐显 | + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 工具市场页面 + participant API as Marketplace API + participant Backend as 主应用后端 + + User->>UI: 进入页面 + par 并行加载 + UI->>API: GET /marketplace/api/tool/tags + API-->>UI: 标签列表 + UI->>API: POST /marketplace/api/tool/list (page=1, size=20) + API-->>UI: 工具列表 + UI->>Backend: GET /core/plugin/admin/marketplace/installed + Backend-->>UI: 已安装工具 ID 和版本 + UI->>API: GET /marketplace/api/tool/versions + API-->>UI: Marketplace 版本信息 + end + UI-->>User: 显示 Hero + 工具卡片网格 + + User->>UI: 输入搜索关键词 + UI->>UI: 500ms 防抖 + UI->>API: POST /marketplace/api/tool/list (searchKey) + API-->>UI: 筛选后的工具列表 + UI-->>User: 更新工具卡片 + + User->>UI: 点击标签 + UI->>API: POST /marketplace/api/tool/list (tags) + API-->>UI: 按标签筛选的工具列表 + UI-->>User: 更新工具卡片 + + User->>UI: 向下滚动 + UI->>UI: Hero 滚出可视区 + UI-->>User: 顶部显示紧凑搜索栏 +``` + +--- + +### S02:安装工具 + +#### 步骤 1:安装操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| Hover 工具卡片 | — | 工具未安装 | 卡片底部显示「安装」按钮 | +| 点击「安装」按钮 | GET `/marketplace/api/tool/getDownloadUrl`(获取下载链接) | — | 按钮显示 loading 状态,卡片不可点击 | +| — | POST `/core/plugin/admin/installWithUrl`(传入 downloadUrl) | downloadUrl 存在 | — | +| — | GET `/core/plugin/admin/marketplace/installed`(刷新已安装列表) | 安装成功 | 工具卡片状态更新为「已安装」;若详情抽屉打开则同步更新状态 | +| — | — | downloadUrl 不存在 | 按钮恢复,操作中断 | + +**操作防重**: 同一工具的安装/卸载/更新操作通过 `operatingPromisesRef` 互斥,重复点击同一按钮会等待前一个操作完成。 + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 工具市场页面 + participant API as Marketplace API + participant Backend as 主应用后端 + + User->>UI: Hover 工具卡片 + UI-->>User: 显示安装按钮 + User->>UI: 点击安装 + UI->>UI: 按钮 loading,禁止其他操作 + UI->>API: GET /marketplace/api/tool/getDownloadUrl + API-->>UI: downloadUrl + UI->>Backend: POST /core/plugin/admin/installWithUrl + Backend-->>UI: 安装成功 + UI->>Backend: GET /core/plugin/admin/marketplace/installed + Backend-->>UI: 刷新后的已安装列表 + UI-->>User: 工具状态更新为「已安装」 +``` + +--- + +### S03:卸载工具 + +#### 步骤 1:卸载操作 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| Hover 已安装工具卡片 | — | 工具已安装 | 卡片底部显示「卸载」按钮 | +| 点击「卸载」按钮 | DELETE `/core/plugin/admin/pkg/delete`(传入 toolId) | — | 按钮显示 loading 状态 | +| — | GET `/core/plugin/admin/marketplace/installed`(刷新) | 卸载成功 | 工具状态更新为「未安装」;若详情抽屉打开则同步更新 | + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 工具市场页面 + participant Backend as 主应用后端 + + User->>UI: Hover 已安装工具卡片 + UI-->>User: 显示卸载按钮 + User->>UI: 点击卸载 + UI->>UI: 按钮 loading + UI->>Backend: DELETE /core/plugin/admin/pkg/delete (toolId) + Backend-->>UI: 卸载成功 + UI->>Backend: GET /core/plugin/admin/marketplace/installed + Backend-->>UI: 刷新后的已安装列表 + UI-->>User: 工具状态更新为「未安装」 +``` + +--- + +### S04:更新工具 + +#### 步骤 1:单工具更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| Hover 有更新的工具卡片 | — | 工具已安装且版本落后 | 卡片右上角显示「有新版本」标记 + 底部「更新」按钮 | +| 点击「更新」按钮 | GET `/marketplace/api/tool/getDownloadUrl` | — | 按钮 loading | +| — | POST `/core/plugin/admin/installWithUrl` | downloadUrl 存在 | — | +| — | GET `/core/plugin/admin/marketplace/installed` | 更新成功 | 工具更新标记消失 | + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 工具市场页面 + participant API as Marketplace API + participant Backend as 主应用后端 + + User->>UI: Hover 有更新标记的工具卡片 + UI-->>User: 显示更新按钮 + User->>UI: 点击更新 + UI->>UI: 按钮 loading + UI->>API: GET /marketplace/api/tool/getDownloadUrl + API-->>UI: downloadUrl + UI->>Backend: POST /core/plugin/admin/installWithUrl + Backend-->>UI: 更新成功 + UI->>Backend: GET /core/plugin/admin/marketplace/installed + Backend-->>UI: 刷新后的列表 + UI-->>User: 更新标记消失 +``` + +--- + +### S05:批量更新工具 + +#### 步骤 1:打开批量更新抽屉 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击右上角「可更新 (N)」按钮 | — | `updatableTools.length > 0` | 打开批量更新抽屉,显示可更新工具列表(含复选框) | + +#### 步骤 2:选择工具并批量更新 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 勾选/取消勾选工具 | — | — | 底部操作栏显示已选数量 | +| 点击全选复选框 | — | — | 全选/取消全选所有可更新工具 | +| 点击「批量更新」按钮 | POST `/marketplace/api/tool/getDownloadUrl`(批量获取下载链接) | 有选中工具 | 按钮 loading | +| — | POST `/core/plugin/admin/installWithUrl`(传入 downloadUrls 数组) | — | — | +| — | GET `/core/plugin/admin/marketplace/installed`(刷新) | 批量安装成功 | 关闭抽屉,成功提示 | + +#### 步骤 3:从列表查看单个工具详情 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具行的「查看详情」 | GET `/marketplace/api/tool/detail`(获取工具详情) | — | 抽屉切换到详情视图,显示工具信息、参数/使用指南 Tab | + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 批量更新抽屉 + participant API as Marketplace API + participant Backend as 主应用后端 + + User->>UI: 点击「可更新 (N)」 + UI-->>User: 打开抽屉,显示可更新列表 + User->>UI: 勾选工具,点击批量更新 + UI->>API: POST /marketplace/api/tool/getDownloadUrl (toolIds) + API-->>UI: downloadUrls[] + UI->>Backend: POST /core/plugin/admin/installWithUrl (downloadUrls) + Backend-->>UI: 批量安装成功 + UI->>Backend: GET /core/plugin/admin/marketplace/installed + Backend-->>UI: 刷新后的列表 + UI-->>User: 关闭抽屉,成功提示 +``` + +--- + +### S06:查看工具详情 + +#### 步骤 1:打开详情抽屉 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具卡片 | GET `/marketplace/api/tool/detail`(传入 toolId) | 始终调用 | 右侧滑出详情抽屉,显示 loading 状态 | +| — | — | 详情加载完成 | 显示工具图标、名称、标签、描述、作者信息 | + +#### 步骤 2:查看工具参数 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 详情抽屉默认显示「参数」Tab | — | `isToolSet === false`(普通工具) | 显示工具的输入参数和输出参数列表 | +| — | — | `isToolSet === true`(工具集) | 显示子工具手风琴列表,可展开查看各子工具详情 | + +#### 步骤 3:查看使用指南 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「使用指南」Tab | — | 工具有 `courseUrl`(外部教程链接) | 新窗口打开外部教程链接 | +| 点击「使用指南」Tab | — | 工具有 `readme` 或 `userGuide` 内容(无外部链接) | 切换 Tab 显示 Markdown 格式的使用指南 | + +**Mermaid 附录**: + +```mermaid +sequenceDiagram + participant User as 管理员 + participant UI as 工具市场页面 + participant API as Marketplace API + + User->>UI: 点击工具卡片 + UI->>UI: 打开详情抽屉(loading) + UI->>API: GET /marketplace/api/tool/detail (toolId) + API-->>UI: 工具详情(名称、描述、标签、作者、参数、readme) + UI-->>User: 显示工具详情 + + alt 查看参数 + User->>UI: 参数 Tab(默认) + UI-->>User: 显示输入/输出参数 + else 查看使用指南 + User->>UI: 使用指南 Tab + alt 有外部教程链接 + UI-->>User: 新窗口打开外部链接 + else 有内置指南 + UI-->>User: 显示 Markdown 格式指南 + end + end +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..2a362360e4ed --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,49 @@ +--- +capability_label: 工具市场 +doc_type: "08" +doc_label: "模块概览" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "工具配置" +roles: ["admin"] +router_paths: ["/config/tool/marketplace"] +--- + +# 工具市场 — 模块概览 + +## 1. 模块业务概述 + +工具市场是 FastGPT 系统工具的在线应用商店,为系统管理员提供浏览、搜索、安装和管理第三方工具的入口。管理员可以在市场中按标签分类或关键词搜索工具,一键安装到本地系统工具库中,也可以卸载已安装的工具或批量更新至最新版本。 + +本模块是「工具配置」的子能力,作为工具管理的补充渠道——除了手动创建和导入工具外,管理员可通过市场获取社区或官方发布的工具资源。市场数据来自远端 Marketplace 服务,页面通过代理 API 与 Marketplace 后端通信。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| admin(系统管理员) | 工具市场主页 | 浏览工具列表、按标签/关键词筛选、安装/卸载工具、更新单个工具、批量更新、查看工具详情(参数/使用指南) | 唯一可访问此模块的角色,拥有全部操作权限 | + +**权限模型**: 页面路由 `/config/tool/marketplace` 位于配置管理区,需系统管理员身份才可访问。工具删除操作的后端接口通过 `authSystemAdmin` 进行权限校验。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工具配置 | 安装工具 API(`intallPluginWithUrl`)、删除工具 API(`deletePkgPlugin`) | API | +| 共享组件库 | ToolCard 卡片组件、ToolDetailDrawer 详情抽屉、BatchUpdateDrawer 批量更新抽屉、ToolTagFilterBox 标签筛选、LightRowTabs 标签页 | 组件 | +| Dashboard 容器 | DashboardNavbar 侧边导航栏 | 组件 | +| 通用 Hooks | usePagination(滚动分页)、useRequest(请求管理)、useCopyData(复制)、useSystem(设备判断) | Hook | +| 系统 Store | useSystemStore(获取 feConfigs 配置、文档链接等) | Store | + +## 4. 对外共享 + +本模块为叶子节点,不对外共享组件或 API。外部模块通过路由跳转进入本页面(如工具管理主页的「打开市场」菜单项)。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 工具市场 | /config/tool/marketplace | `projects/app/src/pages/config/tool/marketplace.tsx` | admin | 无 Tab;含搜索、标签筛选、安装/卸载/更新、工具详情抽屉、批量更新抽屉 | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..da613bece2fa --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,107 @@ +--- +capability_label: 工具配置首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工具配置 +roles: [admin] +router_paths: [/config/tool] +--- + +# 工具配置首页 — 业务流程索引 + +## 业务场景列表 + +### 查看工具列表 + +- **业务描述**: 管理员进入工具配置页,查看当前系统中所有已安装的系统工具(含官方插件和自定义工作流工具),了解每个工具的名称、标签、简介、状态、默认安装策略、Token 费用和系统密钥配置情况。 +- **触发入口**: 左侧导航菜单 → 配置 → 工具配置(或账户容器侧边栏导航) +- **路由路径**: `/config/tool` +- **页面组件位置**: `projects/app/src/pages/config/tool/index.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已登录且具有管理员权限 + +### 管理工具标签 + +- **业务描述**: 管理员打开标签管理弹窗,对系统中的工具标签进行增删改查和排序操作,用于对工具进行分类管理。 +- **触发入口**: 工具配置首页 → "标签管理"按钮 +- **路由路径**: `/config/tool`(弹窗形式,无路由变化) +- **页面组件位置**: `projects/app/src/pageComponents/config/TagManageModal.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入工具配置首页 + +### 添加工具资源 + +- **业务描述**: 管理员通过"添加资源"下拉菜单,选择进入工具市场发现新工具、导入插件包、或创建应用类型的工作流工具。 +- **触发入口**: 工具配置首页 → "添加资源"按钮(hover 展开菜单) +- **路由路径**: `/config/tool`(弹窗形式或跳转到 `/config/tool/marketplace`) +- **页面组件位置**: `projects/app/src/pages/config/tool/index.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入工具配置首页 + +### 配置系统工具 + +- **业务描述**: 管理员点击工具行,打开系统工具配置弹窗,修改工具的状态、默认安装策略、标签、系统密钥、用户标签推广/隐藏等配置。对于文件夹型工具,可查看和配置子工具的系统密钥价格。 +- **触发入口**: 工具列表 → 点击任意工具行 +- **路由路径**: `/config/tool`(弹窗形式,无路由变化) +- **页面组件位置**: `projects/app/src/pageComponents/config/tool/SystemToolConfigModal.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已加载工具列表,目标工具为系统内置类型 + +### 配置工作流工具 + +- **业务描述**: 管理员点击工作流/应用类型工具行或新建应用工具,打开工作流工具配置弹窗,编辑工具的名称、头像、简介、关联应用、标签、作者、状态、价格和用户指南等信息。 +- **触发入口**: 工具列表 → 点击工作流类型工具行;或"添加资源"→"选择应用" +- **路由路径**: `/config/tool`(弹窗形式,无路由变化) +- **页面组件位置**: `projects/app/src/pageComponents/config/tool/WorkflowToolConfigModal.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已加载工具列表 + +### 调整工具排序 + +- **业务描述**: 管理员通过拖拽工具行调整工具在用户侧的展示顺序,拖拽完成后自动保存新顺序。 +- **触发入口**: 工具列表 → 拖拽任意工具行的拖拽手柄 +- **路由路径**: `/config/tool` +- **页面组件位置**: `projects/app/src/pages/config/tool/index.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已有至少 2 个工具 + +### 快捷切换工具属性 + +- **业务描述**: 管理员在工具行上直接切换默认安装状态或 Token 费用开关,无需打开配置弹窗即可快速修改。 +- **触发入口**: 工具行中的"默认安装"复选框或"Token 费用"开关 +- **路由路径**: `/config/tool` +- **页面组件位置**: `projects/app/src/pageComponents/config/tool/ToolRow.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已加载工具列表;Token 费用开关仅对有关联应用的工具可用 + +### 导入插件包 + +- **业务描述**: 管理员打开导入插件弹窗,上传或通过 URL 安装外部插件包。 +- **触发入口**: 工具配置首页 → "添加资源"→"导入资源" +- **路由路径**: `/config/tool`(弹窗形式,无路由变化) +- **页面组件位置**: `projects/app/src/pageComponents/config/ImportPluginModal.tsx` +- **涉及角色**: 管理员 +- **前置条件**: 已进入工具配置首页 + +## 场景关系 + +- **查看工具列表** → **配置系统工具 / 配置工作流工具**: 必须先进入列表页,才能选择具体工具进行配置 +- **查看工具列表** ↔ **调整工具排序**: 在列表页上直接执行,操作后列表即时更新 +- **添加工具资源** → **查看工具列表**: 添加完成后刷新列表 +- **快捷切换工具属性** ↔ **查看工具列表**: 在列表行上直接切换,无需进入配置弹窗 +- **管理工具标签** ↔ **配置系统工具 / 配置工作流工具**: 标签在标签管理弹窗中统一管理,在工具配置弹窗中引用 +- 各弹窗场景(管理标签、导入插件、系统工具配置、工作流工具配置)互斥,同时只能打开一个 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 查看工具列表 | 导航菜单 → 配置 → 工具配置 | /config/tool | 管理员 | +| S02 | 管理工具标签 | 工具列表 → "标签管理"按钮 | /config/tool | 管理员 | +| S03 | 添加工具资源 | 工具列表 → "添加资源"菜单 | /config/tool | 管理员 | +| S04 | 配置系统工具 | 工具列表 → 点击工具行 | /config/tool | 管理员 | +| S05 | 配置工作流工具 | 工具列表 → 点击工作流工具行 | /config/tool | 管理员 | +| S06 | 调整工具排序 | 工具列表 → 拖拽手柄 | /config/tool | 管理员 | +| S07 | 快捷切换工具属性 | 工具行 → 复选框/开关 | /config/tool | 管理员 | +| S08 | 导入插件包 | "添加资源"→"导入资源" | /config/tool | 管理员 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..041bf89aad7a --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,316 @@ +--- +capability_label: 工具配置首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工具配置 +roles: [admin] +router_paths: [/config/tool] +--- + +# 工具配置首页 — 业务流程详解 + +## 页面总览 + +工具配置首页是管理员对系统工具进行集中管理的页面。页面由顶部操作栏(标题、标签管理按钮、添加资源菜单)、列标题行、可拖拽排序的工具列表、以及按需弹出的配置弹窗组成。页面加载时自动获取工具列表,工具以行形式展示,每行包含名称、标签、简介、状态、默认安装、Token 费用、系统密钥等字段。管理员可在行上直接切换部分属性,或点击行打开对应类型的配置弹窗进行详细编辑。 + +本页面无 Tab 拆分,所有业务场景在一个页面内完成。 + +--- + +### S01 — 查看工具列表 + +> 管理员进入工具配置首页,系统自动加载并展示所有已安装的系统工具列表。 + +#### 步骤 1:页面加载与数据获取 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击导航菜单进入工具配置页 | `GET /core/plugin/admin/tool/list`(参数 `{ parentId: null }`)
服务端渲染阶段执行 `getServerSideProps` 注入 i18n 词条 | 用户未登录 → 服务端重定向到登录页 | 页面加载中,AccountContainer 渲染左侧导航栏,页面主体区域显示空白加载态 | +| 等待 API 响应 | — | API 返回成功 → 数据存入 `localTools` | 工具列表渲染,每行显示工具名称、头像、标签、简介、状态、默认安装复选框、Token 费用开关、系统密钥状态 | +| — | — | API 返回空数组 | 页面中央显示空状态提示:无插件 | +| — | — | API 返回失败 | Toast 错误提示(由 `useRequest` 内置错误处理触发) | + +#### 数据加载详情 + +| 加载阶段 | API | 关键参数 | 数据处理 | 渲染结果 | +|---------|-----|---------|---------|---------| +| 首次加载 | GET /core/plugin/admin/tool/list | `parentId: null` | 直接赋值 `setLocalTools(data)` | 工具列表全量展示 | +| 操作后刷新 | GET /core/plugin/admin/tool/list | `parentId: null` | 通过 `refreshTools()` 重新获取 | 列表更新为新数据 | + +- 排序规则:按后端返回顺序展示(可通过拖拽调整并在下次加载时生效) +- 筛选条件:本页无前端筛选,列表展示所有工具 + +--- + +### S02 — 管理工具标签 + +> 管理员打开标签管理弹窗,对工具标签进行增删改查和排序。 + +#### 步骤 1:打开标签管理弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击"标签管理"按钮 | 无(弹窗组件 `TagManageModal` 内部独立管理 API 调用) | — | 弹窗打开,背景遮罩显示,弹窗内加载标签列表 | + +#### 步骤 2:在弹窗内操作标签 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 执行标签的增删改查或排序 | 由 `TagManageModal` 组件内部调用 tag 相关 API(`createPluginToolTag` / `updatePluginToolTag` / `deletePluginToolTag` / `updatePluginToolTagOrder`) | 各操作有成功/失败分支 | 弹窗内列表即时更新 | +| 点击弹窗关闭按钮或遮罩 | 无 | — | 弹窗关闭 | + +--- + +### S03 — 添加工具资源 + +> 管理员通过"添加资源"下拉菜单选择添加新工具的方式。 + +#### 步骤 1:展开添加菜单 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 鼠标悬停在"添加资源"按钮上 | 无 | — | 下拉菜单展开,显示三个选项:「从市场添加」「导入资源」「选择应用」 | + +#### 步骤 2:选择添加方式 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击「从市场添加」 | 无 | — | 路由跳转到 `/config/tool/marketplace`(工具市场页面) | +| 点击「导入资源」 | 无 | — | `ImportPluginModal` 弹窗打开(详见 S08) | +| 点击「选择应用」 | 无 | `editingToolId` 置为空字符串 | `WorkflowToolConfigModal` 弹窗打开,进入新建模式(详见 S05) | + +--- + +### S04 — 配置系统工具 + +> 管理员点击系统内置类型的工具行,打开配置弹窗修改工具的各项配置。 + +#### 步骤 1:打开配置弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击系统工具行(`tool` 来源为 `AppToolSourceEnum.systemTool`) | `GET /core/plugin/admin/tool/detail`(参数 `{ toolId }`)
`GET /core/plugin/toolTag/list` | `loading=true` | 弹窗以加载态打开,标题显示工具名称 | +| 等待详情和标签 API 返回 | — | 两个 API 均成功 → 详情数据填充表单,标签过滤掉无效 ID | 弹窗加载完成,表单可编辑 | + +#### 步骤 2:编辑配置(非文件夹工具) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 修改状态(正常/即将下线/已下线) | 无(本地表单状态) | 选择"即将下线"或"已下线" → 默认安装自动设为 false | 下拉切换,默认安装开关联动禁用 | +| 切换默认安装开关 | 无 | 开关打开且状态非正常 → 状态自动切换为正常;`feConfigs?.isPlus` 控制开关可见性 | 开关状态切换 | +| 开关系统密钥配置 | 无 | 工具无 `inputList` → 不显示此开关;开关打开 → 渲染输入字段列表(根据 `inputList` 的 `inputType` 渲染文本框或开关) | 表单区域展开,显示密钥输入字段 | +| 修改标签 | 无 | 标签数量超过 3 个 → Toast 提示"标签最大限制为 3 个" | 多选下拉更新已选标签 | +| 修改推广/隐藏用户标签 | 无 | `feConfigs?.showWecomConfig` 为 true 时才显示 | 多选下拉更新 | + +#### 步骤 3:编辑配置(文件夹工具) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 打开文件夹类型工具弹窗 | 同步骤 1 | `tool.isFolder === true` | 弹窗宽度扩展为 900px,左侧为基本配置区,右侧为子工具列表表格 | +| 查看子工具列表 | 无(数据来自详情 API 的 `childTools`) | — | 表格显示每个子工具的名称和系统密钥价格 | +| 修改子工具的系统密钥价格 | 无(表单注册到 `childTools.{index}.systemKeyCost`) | — | MyNumberInput 更新对应子工具价格 | + +#### 步骤 4:保存或删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认按钮 | `PUT /core/plugin/admin/tool/update`(参数含 `pluginId`、`status`、`defaultInstalled`、`inputListVal`、`systemKeyCost`、`tagIds`、`promoteTags`、`hideTags`、`childTools`) | API 成功 → `onSuccess()` + `onClose()` | Toast "配置成功",弹窗关闭,列表刷新 | +| — | — | API 失败 → 弹窗不关闭 | Toast 错误提示 | +| 点击删除按钮 → 确认 | `DELETE /core/plugin/admin/pkg/delete`(参数 `{ toolId: toolId.split('-')[1] }`) | 确认后执行删除 | 加载态 → 成功后弹窗关闭,列表刷新 | + +--- + +### S05 — 配置工作流工具 + +> 管理员新建或编辑工作流/应用类型工具,配置名称、头像、关联应用、价格等信息。 + +#### 步骤 1:打开配置弹窗(编辑模式) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工作流/应用类型工具行 | `GET /core/plugin/admin/tool/detail`(参数 `{ toolId }`)
`POST /core/plugin/admin/tool/app/systemApps`(参数 `{ searchKey: '' }`)
`GET /core/plugin/toolTag/list` | `toolId` 存在 → 进入编辑模式 | 弹窗加载态打开,标题显示工具名称 | +| 等待 API 返回 | — | 全部成功 | 表单填充:名称、头像、简介、关联应用、标签、作者、状态、价格、Token 费用、用户指南 | + +#### 步骤 2:打开配置弹窗(新建模式) + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| "添加资源"→"选择应用" | `POST /core/plugin/admin/tool/app/systemApps`(参数 `{ searchKey: '' }`)
`GET /core/plugin/toolTag/list` | `toolId` 为空 → 进入新建模式 | 弹窗打开,表单为空,无删除按钮 | + +#### 步骤 3:编辑工具信息 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击头像上传 | 上传头像 API(通过 `useUploadAvatar` 调用 `getUploadAvatarPresignedUrl`) | 上传中 → 头像区域半透明,点击禁用 | 上传完成后头像更新 | +| 输入名称 | 无 | 名称为空 → 提交时校验失败提示"名称不能为空" | 输入框更新 | +| 输入简介 | 无 | — | 文本域更新 | +| 搜索并选择关联应用 | `POST /core/plugin/admin/tool/app/systemApps`(参数 `{ searchKey }`,搜索词变化时重新请求) | 输入框获得焦点 → 下拉菜单打开,显示搜索结果列表;失焦且未选择 → 恢复上次关联应用 ID | 输入框下方显示应用列表,点击应用行选中并关闭菜单 | +| 选择标签 | 无 | 标签超过 3 个 → Toast "标签最大限制为 3 个" | 多选下拉更新 | +| 输入作者 | 无 | — | 输入框更新 | +| 选择状态 | 无 | 选择非正常状态 → 默认安装自动设为 false | 下拉切换 + 默认安装联动 | +| 切换默认安装 | 无 | 开关打开且状态非正常 → 状态自动恢复为正常 | 开关切换 | +| 设置调用价格 | 无 | 范围 0-1000,步长 0.1 | MyNumberInput 更新 | +| 切换 Token 费用 | 无 | — | 开关切换 | +| 编辑用户指南 | 无 | — | 文本域更新 | + +#### 步骤 4:保存或删除 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击确认(编辑模式) | `PUT /core/plugin/admin/tool/update` | `associatedPluginId` 为空 → 拒绝提交,提示"关联应用不能为空" | 按钮加载态 → Toast "配置成功" → 弹窗关闭 | +| 点击确认(新建模式) | `POST /core/plugin/admin/tool/app/create` | 同上的关联应用校验 | 按钮加载态 → Toast "配置成功" → 弹窗关闭 | +| 点击删除(仅编辑模式) | `DELETE /core/plugin/admin/tool/delete`(参数 `{ toolId }`) | 弹出 PopoverConfirm 确认弹窗:"确认删除该工具?" | 删除加载态 → Toast "删除成功" → 弹窗关闭 | + +--- + +### S06 — 调整工具排序 + +> 管理员拖拽工具行改变展示顺序。 + +#### 步骤 1:拖拽排序 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 按住工具行左侧拖拽手柄开始拖拽 | 无 | — | 被拖拽行半透明(`opacity: 0.8`),其他行根据拖拽位置动态重排 | +| 松开放置工具行 | `PUT /core/plugin/admin/tool/updateOrder`(参数 `{ plugins: [{ pluginId, pluginOrder }, ...] }`) | API 成功 | 列表按新顺序展示(已乐观更新,API 仅持久化) | +| — | — | API 失败 | 无显式错误提示,但列表保持在乐观更新后的顺序(可能下次加载恢复) | + +--- + +### S07 — 快捷切换工具属性 + +> 管理员在工具行上直接切换默认安装或 Token 费用,无需打开配置弹窗。 + +#### 步骤 1:切换默认安装 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具行中的默认安装复选框 | `PUT /core/plugin/admin/tool/update`(参数 `{ pluginId: tool.id, defaultInstalled: newValue, status?: PluginStatusEnum.Normal }`) | 切换到已勾选且工具状态非正常 → 同时将状态设为正常 | 行进入加载态(`MyBox isLoading`),复选框状态立即切换 | +| — | — | API 成功 | 行加载态结束,复选框保持新状态 | +| — | — | API 失败 → Toast "更新失败" | 行加载态结束,通过 `setLocalTools` 乐观更新(可能不一致) | + +#### 步骤 2:切换 Token 费用 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 点击工具行中的 Token 费用开关 | `PUT /core/plugin/admin/tool/update`(参数 `{ pluginId: tool.id, hasTokenFee: newValue }`) | 工具无 `associatedPluginId` → 开关不可用(显示 "-") | 行加载态,开关状态切换 | +| — | — | API 成功 | 行加载态结束 | +| — | — | API 失败 → Toast "更新失败" | 行加载态结束 | + +--- + +### S08 — 导入插件包 + +> 管理员打开导入插件弹窗,上传或通过 URL 安装外部插件包。 + +#### 步骤 1:打开导入弹窗 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| "添加资源"→"导入资源" | 无 | `tools`(当前列表)传入 `ImportPluginModal` 用于重复检测 | `ImportPluginModal` 弹窗打开 | + +#### 步骤 2:导入插件 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 上传插件包文件或输入 URL 安装 | 由 `ImportPluginModal` 内部调用 `getPkgPluginUploadURL` / `parseUploadedPkgPlugin` / `confirmPkgPluginUpload` / `installPluginWithUrl` | 导入成功 → `onSuccess` 回调触发 `refreshTools()` | 弹窗关闭,工具列表刷新 | +| — | — | 导入失败 → 弹窗不关闭 | 弹窗内提示错误信息 | +| 点击取消 | 无 | — | 弹窗关闭 | + +--- + +## Mermaid 附录 + +### S01 — 查看工具列表 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具配置页 + participant API as Backend + + User->>UI: 点击导航进入工具配置 + UI->>API: GET /core/plugin/admin/tool/list {parentId: null} + API-->>UI: 返回工具列表数据 + UI-->>User: 渲染工具列表表格(名称/标签/简介/状态/默认安装/Token费用/系统密钥) +``` + +### S04 — 配置系统工具 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具配置页 + participant API as Backend + + User->>UI: 点击系统工具行 + par 并行加载 + UI->>API: GET /core/plugin/admin/tool/detail {toolId} + UI->>API: GET /core/plugin/toolTag/list + end + API-->>UI: 工具详情 + 标签列表 + UI-->>User: 打开配置弹窗,表单填充 + User->>UI: 编辑配置项(状态/密钥/标签等) + User->>UI: 点击确认 + UI->>API: PUT /core/plugin/admin/tool/update + API-->>UI: 更新成功 + UI->>API: GET /core/plugin/admin/tool/list(刷新) + UI-->>User: Toast "配置成功",弹窗关闭,列表更新 +``` + +### S05 — 配置工作流工具 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具配置页 + participant API as Backend + + User->>UI: 点击工作流工具行(或"选择应用") + par 并行加载 + UI->>API: GET/POST 获取详情/应用列表 + UI->>API: GET /core/plugin/toolTag/list + end + API-->>UI: 详情 + 应用列表 + 标签 + UI-->>User: 打开配置弹窗 + User->>UI: 编辑名称/头像/关联应用/价格等 + User->>UI: 点击确认 + alt 编辑模式 + UI->>API: PUT /core/plugin/admin/tool/update + else 新建模式 + UI->>API: POST /core/plugin/admin/tool/app/create + end + API-->>UI: 保存成功 + UI->>API: GET /core/plugin/admin/tool/list(刷新) + UI-->>User: Toast "配置成功",弹窗关闭 +``` + +### S06 — 调整工具排序 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具配置页 + participant API as Backend + + User->>UI: 拖拽工具行至目标位置 + UI-->>User: 列表实时重排(乐观更新) + UI->>API: PUT /core/plugin/admin/tool/updateOrder {plugins: [...]} + API-->>UI: 排序保存成功 +``` + +### S07 — 快捷切换工具属性 + +```mermaid +sequenceDiagram + participant User + participant UI as 工具配置页 + participant API as Backend + + User->>UI: 点击默认安装复选框/Token费用开关 + UI-->>User: 行进入加载态,控件状态即时切换 + UI->>API: PUT /core/plugin/admin/tool/update + API-->>UI: 更新成功 + UI-->>User: 加载态结束 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..9f1acfa2ee08 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,56 @@ +--- +capability_label: 工具配置首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工具配置 +roles: [admin] +router_paths: [/config/tool] +--- + +# 工具配置首页 — 模块概览 + +## 模块业务概述 + +工具配置首页是 FastGPT 管理后台中系统工具的统一管理入口。管理员在此页面查看、配置、排序所有已安装的系统工具(插件),包括官方工具和自定义工作流工具。模块支持工具的状态管理(正常/即将下线/已下线)、默认安装策略、系统密钥配置、Token 费用开关以及标签分类管理。通过拖拽排序功能,管理员可以调整工具在用户侧的展示顺序。 + +本模块是"配置 → 工具配置"路径下的叶子节点,无子能力。与之并列的还有[工具市场](../工具市场/模块概览.md),用户可从市场发现并安装新工具。 + +## 角色差异 + +本模块仅对具有管理权限的用户开放。页面路由 `/config/tool` 通过 Next.js 服务端渲染时的 `serviceSideProps` 校验用户登录态,页面内部通过 `AccountContainer` 组件统一管理用户认证和导航。 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 管理员 | 工具配置首页 | 查看工具列表、修改工具状态、配置系统密钥、调整默认安装、设置 Token 费用、拖拽排序、删除工具、管理标签、导入插件、创建工作流工具 | 完整管理权限 | +| 普通用户 | 无 | 无 | 路由受服务端权限校验保护,不可访问 | + +**权限模型**: 基于 Next.js 服务端渲染 `serviceSideProps` 的登录态校验,结合 `AccountContainer` 中的 `useUserStore` 进行用户身份管理。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户 | `AccountContainer` 页面布局与用户认证容器 | 组件 | +| 工具市场 | 路由跳转 `/config/tool/marketplace` 进入市场发现工具 | 路由 | +| 标签管理 | `TagManageModal` 工具标签分类管理弹窗 | 组件 | +| 工具配置(SystemToolConfigModal) | 系统工具的详细配置弹窗 | 组件 | +| 工具配置(WorkflowToolConfigModal) | 工作流/应用类型工具的创建与编辑弹窗 | 组件 | + +## 对外共享 + +本模块为叶子级管理页面,主要对外提供: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| `/config/tool` 路由入口 | 左侧导航菜单、账户容器侧边栏 | 路由 | + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 工具配置首页 | /config/tool | `projects/app/src/pages/config/tool/index.tsx` | 管理员 | 工具列表管理页面,无 Tab 拆分 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..b80323d82131 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,63 @@ +--- +capability_label: 工具配置 +doc_type: "08" +doc_label: 模块概览 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 配置 +roles: [admin] +router_paths: [/config/tool, /config/tool/marketplace] +--- + +# 工具配置 — 模块概览 + +## 1. 模块业务概述 + +工具配置是 FastGPT 管理员对系统插件/工具进行全生命周期管理的功能模块。管理员可以在此查看、配置、导入和管理系统工具,并通过工具市场发现和安装新的插件,从而扩展平台能力。 + +本模块服务于**管理员**角色,是整个平台「插件体系」的管控入口。包含两个子能力: + +- **工具配置首页**:系统工具的集中管理台,支持工具上线/下线、默认安装、标签分类、系统密钥配置、拖拽排序和导入导出 +- **工具市场**:连接外部 Marketplace,支持浏览、搜索、安装、更新和卸载社区/官方提供的工具插件 + +两个子能力协同工作:市场负责发现和获取,首页负责配置和管控,形成「发现→安装→配置→管控」的完整链条。 + +## 2. 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| admin | 工具配置首页、工具市场 | 配置工具状态/默认安装/标签/密钥、导入插件、安装/卸载/更新市场插件、拖拽排序 | 全部管理权限 | +| 普通用户 | 无 | 无 | 本模块仅对管理员开放,普通用户不可见 | + +**权限模型**:页面级权限控制,通过路由守卫校验管理员角色,非管理员路由不可达。 + +## 3. 跨模块依赖摘要 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户 | AccountContainer 容器组件 | 组件 | +| 工作台 | DashboardNavbar 侧边导航 | 组件 | +| 共享组件库 | ToolCard, ToolTagFilterBox, ToolDetailDrawer, BatchUpdateDrawer 市场组件 | 组件 | +| 共享组件库 | DndDrag 拖拽排序、EmptyTip 空状态、MyMenu 菜单、MyIcon 图标 | 组件 | + +## 4. 对外共享 + +工具配置模块的 TagManageModal 标签管理弹窗被多个模块引用: + +| 共享能力 | 消费模块 | 能力类型 | +|---------|---------|---------| +| 标签管理弹窗 | 对话(收藏应用设置) | 弹窗组件 | +| 标签管理弹窗 | 数据集(数据集详情) | 弹窗组件 | + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 工具配置首页 | /config/tool | projects/app/src/pages/config/tool/index.tsx | admin | 系统工具列表管理 | +| 工具市场 | /config/tool/marketplace | projects/app/src/pages/config/tool/marketplace.tsx | admin | 市场工具浏览与安装 | + +## 6. 子能力索引 + +| 子能力 | 标签 | 文档链接 | +|--------|------|---------| +| 工具配置首页 | 工具配置首页 | [08-模块概览](../工具配置首页/模块概览.md) | +| 工具市场 | 工具市场 | [08-模块概览](../工具市场/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..5092bd05e763 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,49 @@ +--- +capability_label: 配置 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [系统管理员] +router_paths: ["/config/tool", "/config/tool/marketplace"] +--- + +## 模块业务概述 + +配置模块是 FastGPT 的系统级工具管理入口,为**系统管理员**提供系统工具的集中管理与市场发现能力。管理员可以在此查看、新增、编辑、删除和排序系统内置工具,同时支持从工具市场发现并安装第三方工具,以扩展平台的 AI Agent 能力。 + +本模块采用两层子能力结构:**工具配置首页**是工具列表管理的主界面;**工具市场**提供外部工具的浏览、搜索、安装与更新能力。两个子能力协同工作,覆盖了从工具获取到工具维护的完整生命周期。 + +## 角色差异 + +| 角色 | 可见页面 | 可执行操作 | 差异说明 | +|------|---------|-----------|---------| +| 系统管理员 | 工具配置首页、工具市场 | 查看工具列表、拖拽排序、新增工具(市场选择/导入/工作流应用)、编辑/删除工具、标签管理、安装/卸载市场工具、批量更新工具 | 唯一可访问配置模块的角色 | + +**权限模型**: 基于 `authSystemAdmin` 的超级管理员权限校验,API 层所有操作均需通过系统管理员身份验证。页面入口位于侧边栏「配置」菜单下,仅对系统管理员可见。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 账户 | AccountContainer 页面布局容器 | 组件 | +| 工作台 | DashboardNavbar 侧边导航栏 | 组件 | +| 应用 | splitCombineToolId 工具 ID 解析工具函数 | Hook | + +## 对外共享 + +本模块为系统管理专属配置区,不对外提供共享能力。其管理的系统工具列表和标签数据通过后端 API 提供给其他模块(如对话模块中的应用运行时工具调用)使用,但前端层面无跨模块组件共享。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 工具配置首页 | /config/tool | projects/app/src/pages/config/tool/index.tsx | 系统管理员 | 工具列表管理主界面,含拖拽排序、新增/编辑/删除工具、标签管理、导入导出 | +| 工具市场 | /config/tool/marketplace | projects/app/src/pages/config/tool/marketplace.tsx | 系统管理员 | 工具市场浏览,支持搜索筛选、安装/卸载/更新工具、批量更新 | + +## 子能力索引 + +| 子能力 | 标签 | 优先级 | 文档链接 | +|--------|------|--------|---------| +| 工具配置首页 | 工具配置首页 | 1 | [模块概览](../工具配置/工具配置首页/模块概览.md) | +| 工具市场 | 工具市场 | 2 | [模块概览](../工具配置/工具市场/模块概览.md) | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b06a015537ae --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,33 @@ +--- +capability_label: 页面未找到 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - /404 +--- + +# 页面未找到 — 业务流程索引 + +## 业务场景列表 + +### 访问无效路径 + +- **业务描述**:用户访问了不存在的前端路由,系统不展示 404 错误页面,而是直接重定向至应用工作台首页,确保用户始终停留在可用页面。 +- **触发入口**:在浏览器地址栏输入任意不存在的 FastGPT 路径,或点击失效的外部链接。 +- **路由路径**:`/404`(Next.js 自动匹配所有未注册路由) +- **页面组件位置**:`projects/app/src/pages/404.tsx` +- **涉及角色**:无限制(所有访问者) +- **前置条件**:无(不需要登录状态或特定权限) + +## 场景关系 + +各场景独立执行,无严格先后关系。本模块仅包含一个业务场景。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 访问无效路径 | 输入无效 URL 或点击失效链接 | /404 | 无限制 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..582b03950d97 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,54 @@ +--- +capability_label: 页面未找到 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - /404 +--- + +# 页面未找到 — 业务流程详解 + +## 页面总览 + +"页面未找到"是 FastGPT 的 404 兜底页面。当用户访问任何未注册的路由时,Next.js 自动渲染此页面,组件挂载后立即执行客户端路由跳转,将用户引导至应用工作台首页 `/dashboard/agent`。整个过程无声无息,用户不会看到任何错误提示 UI。 + +本页面无 Tab 结构,仅包含一个自动重定向流程。 + +### 访问无效路径 + +> 用户访问不存在的页面路径时,系统自动重定向至工作台首页,不展示错误信息。 + +#### 步骤 1:页面组件挂载 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户在浏览器中输入无效 URL(如 `/nonexistent`),Next.js 路由匹配失败后渲染 `404.tsx` 页面组件 | 无 API 调用 | 无分支条件,所有无效路径均进入此页面 | 页面渲染空 `
`,用户看不到任何内容 | + +#### 步骤 2:自动重定向 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需用户操作,组件挂载后 `useEffect` 自动执行 | 无 HTTP API 调用。执行客户端路由 `router.push('/dashboard/agent')` | 无分支条件,始终重定向至同一目标 | 浏览器地址栏更新为 `/dashboard/agent`,页面切换至工作台首页 | + +#### 数据加载详情 + +本场景不涉及数据加载。页面不发起任何 HTTP 请求,仅执行客户端路由跳转。 + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 浏览器 + participant Router as Next.js 路由 + + User->>UI: 访问无效路径(如 /nonexistent) + UI->>Router: 路由匹配失败 + Router->>UI: 渲染 404.tsx(空 div) + UI->>Router: useEffect 触发 router.push('/dashboard/agent') + Router->>UI: 导航至工作台首页 + UI-->>User: 展示工作台首页 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..e96aa1674a1c --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,46 @@ +--- +capability_label: 页面未找到 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - /404 +--- + +# 页面未找到 — 模块概览 + +## 1. 模块业务概述 + +"页面未找到"模块对应 FastGPT 的 404 错误页面(路由 `/404`),是 Next.js 框架在用户访问不存在的路由时自动呈现的兜底页面。 + +本模块的核心行为是**静默重定向**:用户访问任何无效路径时,页面组件挂载后立即将用户导航至应用工作台首页(`/dashboard/agent`),不展示任何 404 错误信息或"页面不存在"提示。这一设计将无效路径访问统一引导至应用主入口,确保用户始终能够回到可用页面。 + +本模块为顶层叶子节点,无子能力,不涉及角色区分。 + +## 2. 角色差异 + +本模块无角色差异。页面组件不包含任何权限校验逻辑,所有访问者(含未登录用户)均执行相同的重定向行为。 + +## 3. 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| Next.js 路由系统 | `next/router` 的 `useRouter` Hook,执行客户端路由跳转 | Hook | +| React | `useEffect` Hook,处理组件挂载时的副作用 | Hook | +| i18n 工具 | `@/web/common/i18n/utils` 的 `serviceSideProps`(已导入但未在组件内使用,预留给 SSR 场景) | 工具函数 | + +## 4. 对外共享 + +本模块无对外共享能力。页面组件 `NonePage` 仅在 `/404` 路由挂载,不被其他模块引用。 + +## 5. 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 页面未找到 | /404 | `projects/app/src/pages/404.tsx` | 无限制 | 挂载后自动重定向至 `/dashboard/agent`,不展示 UI | + +## 6. 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2725aaa05db8 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\347\264\242\345\274\225.md" @@ -0,0 +1,31 @@ +--- +capability_label: 首页 +doc_type: "09" +doc_label: 业务流程索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - / +--- + +# 首页 — 业务流程索引 + +## 页面访问重定向 + +- **业务描述**:用户访问平台根路径时,系统自动将其重定向至应用工作台页面,确保用户进入平台核心功能区域。 +- **触发入口**:用户在浏览器输入平台根地址或点击平台 Logo/首页链接 +- **路由路径**:`/` +- **页面组件位置**:`projects/app/src/pages/index.tsx` +- **涉及角色**:所有用户 +- **前置条件**:无 + +## 场景关系 + +各场景独立执行,无严格先后关系。首页仅包含一个场景(路由重定向),不涉及多场景流转。 + +## 业务场景汇总 + +| 编号 | 场景名 | 触发入口 | 路由路径 | 角色 | +|------|--------|---------|---------|------| +| S01 | 页面访问重定向 | 浏览器访问根路径 `/` | / | 所有用户 | diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..2067274fd462 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\344\270\232\345\212\241\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,61 @@ +--- +capability_label: 首页 +doc_type: "10" +doc_label: 业务流程详解 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - / +--- + +# 首页 — 业务流程详解 + +## 页面总览 + +首页是 FastGPT 平台的路由分发入口。用户访问根路径 `/` 时,页面不会停留,而是立即执行客户端路由跳转至应用工作台 `/dashboard/agent`。在跳转过程中,页面展示一个全屏加载动画(Spinner),确保用户体验平滑过渡。 + +由于首页本身不包含任何 Tab 结构或业务表单,本文档仅描述唯一的"页面访问重定向"流程。 + +--- + +### 页面访问重定向 + +> 用户访问平台根路径时,由 Next.js 页面组件自动完成路由跳转。 + +#### 步骤 1:服务端渲染准备 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 用户在浏览器输入平台根地址并回车 | 无(服务端执行 `getServerSideProps`,注入国际化词条) | 无分支 | 服务端返回包含 i18n 词条的 HTML 页面 | + +#### 步骤 2:客户端路由重定向 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 页面在浏览器端加载完成 | 无 API 调用 | `useEffect` 在组件挂载后执行,无条件触发 | 显示全屏居中 Spinner 加载动画(半透明白色遮罩 + 主题色旋转图标);随即执行 `router.push('/dashboard/agent')`,浏览器 URL 更新为目标路由 | + +#### 步骤 3:落地目标页面 + +| 用户操作 | 触发 API | 分支条件 | 页面变化 | +|---------|---------|---------|---------| +| 无需用户操作(自动完成) | 由目标页面 `/dashboard/agent` 自行发起其所需 API 请求 | 目标页面的权限校验逻辑接管 | 加载动画消失,用户看到应用工作台页面内容 | + +--- + +### Mermaid 附录 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 首页页面 + participant Router as Next.js 路由 + participant Target as 应用工作台 + + User->>UI: 访问 / + UI->>UI: getServerSideProps(注入 i18n) + UI->>UI: 组件挂载,显示 Loading 动画 + UI->>Router: router.push('/dashboard/agent') + Router->>Target: 加载目标页面组件 + Target-->>User: 展示应用工作台 +``` diff --git "a/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..0bacde4da64f --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\346\250\241\345\235\227\346\246\202\350\247\210.md" @@ -0,0 +1,47 @@ +--- +capability_label: 首页 +doc_type: "08" +doc_label: 模块概览 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - / +--- + +# 首页 — 模块概览 + +## 模块业务概述 + +首页是 FastGPT 平台的入口页面,同时也是整个应用的**路由分发节点**。用户访问根路径 `/` 时,页面不展示任何业务内容,而是立即将用户重定向至应用工作台(`/dashboard/agent`)。 + +这种设计确保用户无论从哪个入口进入平台,都能被统一引导到核心工作区——应用管理页面。首页在此充当了"前台引导"角色,不承载具体业务功能,仅负责首屏路由决策。 + +本模块为叶子节点,无子能力。 + +## 角色差异 + +首页为公开路由,无角色限制。任何可访问平台的用户(包括未登录用户,取决于路由守卫配置)访问 `/` 时均会被重定向到 `/dashboard/agent`。 + +首页本身不包含权限判断逻辑,角色差异由目标页面(应用工作台)的路由守卫统一处理。 + +## 跨模块依赖 + +| 依赖模块 | 使用的具体能力 | 能力类型 | +|---------|---------------|---------| +| 工作台(应用工作台) | 作为重定向目标,用户最终落地页面 | 页面 | +| 国际化(i18n) | 服务端渲染时的国际化词条注入(`serviceSideProps`) | Hook | + +## 对外共享 + +本模块不对外提供任何共享能力。所有能力均来自其他模块。 + +## 关键页面与入口 + +| 页面名称 | 路由路径 | 页面组件位置 | 角色 | 备注 | +|---------|---------|-------------|------|------| +| 首页(重定向) | / | projects/app/src/pages/index.tsx | 所有用户 | 即时重定向至 /dashboard/agent,显示加载动画 | + +## 子能力索引 + +本模块无子能力。 diff --git "a/doc/kb/\344\270\232\345\212\241\350\203\275\345\212\233\345\234\260\345\233\276.md" "b/doc/kb/\344\270\232\345\212\241\350\203\275\345\212\233\345\234\260\345\233\276.md" new file mode 100644 index 000000000000..7cb8eb9557a7 --- /dev/null +++ "b/doc/kb/\344\270\232\345\212\241\350\203\275\345\212\233\345\234\260\345\233\276.md" @@ -0,0 +1,485 @@ +--- +doc_type: map +doc_label: 业务能力地图 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +--- + +# 业务能力地图 + +## 一、能力依赖图 + +```mermaid +graph TB + 首页["🏠 首页
重定向至工作台"] + 页面未找到["❓ 页面未找到
404 页面"] + OpenAPI文档["📋 OpenAPI 文档
API 接口文档"] + 价格["💰 价格
订阅与计费中心"] + + subgraph 对话模块["💬 对话"] + 对话首页["对话首页
AI 对话主界面"] + 分享对话["分享对话
外链对话分享"] + end + + subgraph 应用模块["📱 应用"] + 应用详情["应用详情
应用编辑与发布"] + end + + subgraph 登录模块["🔐 登录"] + 登录首页["登录首页
多种登录方式"] + 快速登录["快速登录
令牌快捷登录"] + 第三方登录["第三方登录
OAuth 认证"] + end + + subgraph 账户模块["👤 账户"] + API密钥["API 密钥"] + 通知["通知"] + 个性化设置["个性化设置"] + 推广["推广"] + 账单["账单"] + 自定义域名["自定义域名"] + 账号信息["账号信息"] + 模型管理["模型管理"] + 团队管理["团队管理"] + 第三方集成["第三方集成"] + 用量统计["用量统计"] + end + + subgraph 工作台模块["🔧 工作台"] + 应用工作台["应用工作台"] + 创建应用["创建应用"] + 评测["评测"] + MCP服务器["MCP 服务器"] + 技能管理["技能管理"] + 系统工具["系统工具"] + 模板市场["模板市场"] + 工具管理["工具管理"] + end + + subgraph 数据集模块["📊 数据集"] + 数据集列表["数据集列表"] + 数据集详情["数据集详情"] + end + + subgraph 配置模块["⚙️ 配置"] + 工具配置["工具配置"] + end + + subgraph 技能模块["🎯 技能"] + 技能详情["技能详情"] + end + + %% 跨模块依赖 + 首页 -->|重定向| 应用工作台 + 对话 -->|LoginModal| 登录 + 对话 -->|应用配置API| 应用详情 + 对话 -->|知识库引用| 数据集 + 对话 -->|工具配置| 工具配置 + 应用详情 -->|创建入口| 应用工作台 + 应用详情 -->|ChatTest| 对话 + 应用详情 -->|知识库选择| 数据集 + 应用详情 -->|技能选择| 技能详情 + 应用详情 -->|工具选择| 工具配置 + 登录 -->|写入用户信息| 账户 + 数据集 -->|侧边导航| 工作台 + 数据集 -->|搜索测试| 对话 + 配置 -->|AccountContainer| 账户 + 配置 -->|DashboardNavbar| 工作台 + 配置 -->|工具ID解析| 应用详情 + 价格 -->|支付账单| 账单 + 技能详情 -->|技能列表API| 工作台 + 工作台 -->|应用数据| 应用详情 +``` + +## 二、文档链接索引 + +### 共享文档 + +| 文档 | 路径 | 说明 | +|------|------|------| +| 仓库概览 | [仓库概览.md](./仓库概览.md) | 项目基本信息、技术栈、仓库结构 | +| 仓库架构 | [仓库架构.md](./仓库架构.md) | 整体架构、模块划分和分层设计 | +| 仓库依赖 | [仓库依赖.md](./仓库依赖.md) | 项目依赖关系、版本约束和包管理 | +| 外部接入指南 | [外部接入指南.md](./外部接入指南.md) | 集成 FastGPT API、获取密钥、调用接口 | +| 核心业务领域 | [业务知识库/核心业务领域.md](./业务知识库/核心业务领域.md) | 产品定位、业务功能和核心概念 | +| 业务流程索引 | [业务知识库/业务流程索引.md](./业务知识库/业务流程索引.md) | 核心业务流程和工作流节点 | +| 业务流程详解 | [业务知识库/业务流程详解.md](./业务知识库/业务流程详解.md) | 知识库、对话、工作流核心流程详解 | +| API 索引 | [技术知识库/API索引.md](./技术知识库/API索引.md) | API 接口、请求参数和响应格式 | +| 数据结构 | [技术知识库/数据结构.md](./技术知识库/数据结构.md) | 数据模型、Schema 定义和关键字段 | +| 代码编写指南 | [技术知识库/代码编写指南.md](./技术知识库/代码编写指南.md) | 开发规范、文件组织、命名约定 | +| 排障指南 | [技术知识库/排障指南.md](./技术知识库/排障指南.md) | 常见问题排查、错误解决和调试方法 | + +--- + +### 首页 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/首页/模块概览.md](./业务知识库/首页/模块概览.md) | +| 业务流程索引 | [业务知识库/首页/业务流程索引.md](./业务知识库/首页/业务流程索引.md) | +| 业务流程详解 | [业务知识库/首页/业务流程详解.md](./业务知识库/首页/业务流程详解.md) | +| API 索引 | [技术知识库/首页/API索引.md](./技术知识库/首页/API索引.md) | +| 组件列表 | [技术知识库/首页/组件列表.md](./技术知识库/首页/组件列表.md) | + +--- + +### 页面未找到 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/页面未找到/模块概览.md](./业务知识库/页面未找到/模块概览.md) | +| 业务流程索引 | [业务知识库/页面未找到/业务流程索引.md](./业务知识库/页面未找到/业务流程索引.md) | +| 业务流程详解 | [业务知识库/页面未找到/业务流程详解.md](./业务知识库/页面未找到/业务流程详解.md) | +| API 索引 | [技术知识库/页面未找到/API索引.md](./技术知识库/页面未找到/API索引.md) | +| 组件列表 | [技术知识库/页面未找到/组件列表.md](./技术知识库/页面未找到/组件列表.md) | + +--- + +### OpenAPI 文档 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/OpenAPI 文档/模块概览.md](./业务知识库/OpenAPI 文档/模块概览.md) | +| 业务流程索引 | [业务知识库/OpenAPI 文档/业务流程索引.md](./业务知识库/OpenAPI 文档/业务流程索引.md) | +| 业务流程详解 | [业务知识库/OpenAPI 文档/业务流程详解.md](./业务知识库/OpenAPI 文档/业务流程详解.md) | +| API 索引 | [技术知识库/OpenAPI 文档/API索引.md](./技术知识库/OpenAPI 文档/API索引.md) | +| 组件列表 | [技术知识库/OpenAPI 文档/组件列表.md](./技术知识库/OpenAPI 文档/组件列表.md) | + +--- + +### 对话 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/对话/模块概览.md](./业务知识库/对话/模块概览.md) | +| 业务流程索引 | [业务知识库/对话/业务流程索引.md](./业务知识库/对话/业务流程索引.md) | +| 业务流程详解 | [业务知识库/对话/业务流程详解.md](./业务知识库/对话/业务流程详解.md) | +| API 索引 | [技术知识库/对话/API索引.md](./技术知识库/对话/API索引.md) | +| 组件列表 | [技术知识库/对话/组件列表.md](./技术知识库/对话/组件列表.md) | + +#### 对话首页 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/对话/对话首页/模块概览.md](./业务知识库/对话/对话首页/模块概览.md) | +| 业务流程索引 | [业务知识库/对话/对话首页/业务流程索引.md](./业务知识库/对话/对话首页/业务流程索引.md) | +| 业务流程详解 | [业务知识库/对话/对话首页/业务流程详解.md](./业务知识库/对话/对话首页/业务流程详解.md) | +| 共享能力清单 | [业务知识库/对话/对话首页/共享能力清单.md](./业务知识库/对话/对话首页/共享能力清单.md) | +| API 索引 | [技术知识库/对话/对话首页/API索引.md](./技术知识库/对话/对话首页/API索引.md) | +| 组件列表 | [技术知识库/对话/对话首页/组件列表.md](./技术知识库/对话/对话首页/组件列表.md) | +| Store 数据流 | [技术知识库/对话/对话首页/Store数据流.md](./技术知识库/对话/对话首页/Store数据流.md) | +| Hooks 工具函数 | [技术知识库/对话/对话首页/Hooks工具函数.md](./技术知识库/对话/对话首页/Hooks工具函数.md) | +| 共享组件清单 | [技术知识库/对话/对话首页/共享组件清单.md](./技术知识库/对话/对话首页/共享组件清单.md) | + +##### 对话首页 Tab + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|-----|---------|-------------|-------------|---------|---------| +| 首页 | [链接](./业务知识库/对话/对话首页/首页/模块概览.md) | [链接](./业务知识库/对话/对话首页/首页/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/首页/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/首页/API索引.md) | [链接](./技术知识库/对话/对话首页/首页/组件列表.md) | +| 团队应用 | [链接](./业务知识库/对话/对话首页/团队应用/模块概览.md) | [链接](./业务知识库/对话/对话首页/团队应用/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/团队应用/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/团队应用/API索引.md) | [链接](./技术知识库/对话/对话首页/团队应用/组件列表.md) | +| 精选应用 | [链接](./业务知识库/对话/对话首页/精选应用/模块概览.md) | [链接](./业务知识库/对话/对话首页/精选应用/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/精选应用/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/精选应用/API索引.md) | [链接](./技术知识库/对话/对话首页/精选应用/组件列表.md) | +| 最近使用 | [链接](./业务知识库/对话/对话首页/最近使用/模块概览.md) | [链接](./业务知识库/对话/对话首页/最近使用/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/最近使用/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/最近使用/API索引.md) | [链接](./技术知识库/对话/对话首页/最近使用/组件列表.md) | +| 设置 | [链接](./业务知识库/对话/对话首页/设置/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/设置/业务流程详解.md) | — | [链接](./技术知识库/对话/对话首页/设置/API索引.md) | [链接](./技术知识库/对话/对话首页/设置/组件列表.md) | + +##### 设置 Tab 子页 + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|-----|---------|-------------|-------------|---------|---------| +| 首页设置 | [链接](./业务知识库/对话/对话首页/设置/首页设置/模块概览.md) | [链接](./业务知识库/对话/对话首页/设置/首页设置/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/设置/首页设置/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/设置/首页设置/API索引.md) | [链接](./技术知识库/对话/对话首页/设置/首页设置/组件列表.md) | +| 收藏应用 | [链接](./业务知识库/对话/对话首页/设置/收藏应用/模块概览.md) | [链接](./业务知识库/对话/对话首页/设置/收藏应用/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/设置/收藏应用/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/设置/收藏应用/API索引.md) | [链接](./技术知识库/对话/对话首页/设置/收藏应用/组件列表.md) | +| 数据看板 | [链接](./业务知识库/对话/对话首页/设置/数据看板/模块概览.md) | [链接](./业务知识库/对话/对话首页/设置/数据看板/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/设置/数据看板/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/设置/数据看板/API索引.md) | [链接](./技术知识库/对话/对话首页/设置/数据看板/组件列表.md) | +| 日志详情 | [链接](./业务知识库/对话/对话首页/设置/日志详情/模块概览.md) | [链接](./业务知识库/对话/对话首页/设置/日志详情/业务流程索引.md) | [链接](./业务知识库/对话/对话首页/设置/日志详情/业务流程详解.md) | [链接](./技术知识库/对话/对话首页/设置/日志详情/API索引.md) | [链接](./技术知识库/对话/对话首页/设置/日志详情/组件列表.md) | + +#### 分享对话 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/对话/分享对话/模块概览.md](./业务知识库/对话/分享对话/模块概览.md) | +| 业务流程索引 | [业务知识库/对话/分享对话/业务流程索引.md](./业务知识库/对话/分享对话/业务流程索引.md) | +| 业务流程详解 | [业务知识库/对话/分享对话/业务流程详解.md](./业务知识库/对话/分享对话/业务流程详解.md) | +| API 索引 | [技术知识库/对话/分享对话/API索引.md](./技术知识库/对话/分享对话/API索引.md) | +| 组件列表 | [技术知识库/对话/分享对话/组件列表.md](./技术知识库/对话/分享对话/组件列表.md) | +| Store 数据流 | [技术知识库/对话/分享对话/Store数据流.md](./技术知识库/对话/分享对话/Store数据流.md) | + +--- + +### 应用 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/应用/模块概览.md](./业务知识库/应用/模块概览.md) | +| 业务流程索引 | [业务知识库/应用/业务流程索引.md](./业务知识库/应用/业务流程索引.md) | +| 业务流程详解 | [业务知识库/应用/业务流程详解.md](./业务知识库/应用/业务流程详解.md) | +| API 索引 | [技术知识库/应用/API索引.md](./技术知识库/应用/API索引.md) | +| 组件列表 | [技术知识库/应用/组件列表.md](./技术知识库/应用/组件列表.md) | + +#### 应用详情 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/应用/应用详情/模块概览.md](./业务知识库/应用/应用详情/模块概览.md) | +| 业务流程索引 | [业务知识库/应用/应用详情/业务流程索引.md](./业务知识库/应用/应用详情/业务流程索引.md) | +| 业务流程详解 | [业务知识库/应用/应用详情/业务流程详解.md](./业务知识库/应用/应用详情/业务流程详解.md) | +| 共享能力清单 | [业务知识库/应用/应用详情/共享能力清单.md](./业务知识库/应用/应用详情/共享能力清单.md) | +| API 索引 | [技术知识库/应用/应用详情/API索引.md](./技术知识库/应用/应用详情/API索引.md) | +| 组件列表 | [技术知识库/应用/应用详情/组件列表.md](./技术知识库/应用/应用详情/组件列表.md) | +| Hooks 工具函数 | [技术知识库/应用/应用详情/Hooks工具函数.md](./技术知识库/应用/应用详情/Hooks工具函数.md) | + +--- + +### 登录 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/登录/模块概览.md](./业务知识库/登录/模块概览.md) | +| 业务流程索引 | [业务知识库/登录/业务流程索引.md](./业务知识库/登录/业务流程索引.md) | +| 业务流程详解 | [业务知识库/登录/业务流程详解.md](./业务知识库/登录/业务流程详解.md) | +| API 索引 | [技术知识库/登录/API索引.md](./技术知识库/登录/API索引.md) | +| 组件列表 | [技术知识库/登录/组件列表.md](./技术知识库/登录/组件列表.md) | +| Store 数据流 | [技术知识库/登录/Store数据流.md](./技术知识库/登录/Store数据流.md) | + +#### 登录首页 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/登录/登录首页/模块概览.md](./业务知识库/登录/登录首页/模块概览.md) | +| 业务流程索引 | [业务知识库/登录/登录首页/业务流程索引.md](./业务知识库/登录/登录首页/业务流程索引.md) | +| 业务流程详解 | [业务知识库/登录/登录首页/业务流程详解.md](./业务知识库/登录/登录首页/业务流程详解.md) | +| API 索引 | [技术知识库/登录/登录首页/API索引.md](./技术知识库/登录/登录首页/API索引.md) | +| 组件列表 | [技术知识库/登录/登录首页/组件列表.md](./技术知识库/登录/登录首页/组件列表.md) | + +##### 登录首页 Tab + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|-----|---------|-------------|-------------|---------|---------| +| 密码登录 | [链接](./业务知识库/登录/登录首页/密码登录/模块概览.md) | [链接](./业务知识库/登录/登录首页/密码登录/业务流程索引.md) | [链接](./业务知识库/登录/登录首页/密码登录/业务流程详解.md) | [链接](./技术知识库/登录/登录首页/密码登录/API索引.md) | [链接](./技术知识库/登录/登录首页/密码登录/组件列表.md) | +| 注册 | [链接](./业务知识库/登录/登录首页/注册/模块概览.md) | [链接](./业务知识库/登录/登录首页/注册/业务流程索引.md) | [链接](./业务知识库/登录/登录首页/注册/业务流程详解.md) | [链接](./技术知识库/登录/登录首页/注册/API索引.md) | [链接](./技术知识库/登录/登录首页/注册/组件列表.md) | +| 忘记密码 | [链接](./业务知识库/登录/登录首页/忘记密码/模块概览.md) | [链接](./业务知识库/登录/登录首页/忘记密码/业务流程索引.md) | [链接](./业务知识库/登录/登录首页/忘记密码/业务流程详解.md) | [链接](./技术知识库/登录/登录首页/忘记密码/API索引.md) | [链接](./技术知识库/登录/登录首页/忘记密码/组件列表.md) | +| 微信登录 | [链接](./业务知识库/登录/登录首页/微信登录/模块概览.md) | [链接](./业务知识库/登录/登录首页/微信登录/业务流程索引.md) | [链接](./业务知识库/登录/登录首页/微信登录/业务流程详解.md) | [链接](./技术知识库/登录/登录首页/微信登录/API索引.md) | [链接](./技术知识库/登录/登录首页/微信登录/组件列表.md) | + +#### 快速登录 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/登录/快速登录/模块概览.md](./业务知识库/登录/快速登录/模块概览.md) | +| 业务流程索引 | [业务知识库/登录/快速登录/业务流程索引.md](./业务知识库/登录/快速登录/业务流程索引.md) | +| 业务流程详解 | [业务知识库/登录/快速登录/业务流程详解.md](./业务知识库/登录/快速登录/业务流程详解.md) | +| API 索引 | [技术知识库/登录/快速登录/API索引.md](./技术知识库/登录/快速登录/API索引.md) | +| 组件列表 | [技术知识库/登录/快速登录/组件列表.md](./技术知识库/登录/快速登录/组件列表.md) | + +#### 第三方登录 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/登录/第三方登录/模块概览.md](./业务知识库/登录/第三方登录/模块概览.md) | +| 业务流程索引 | [业务知识库/登录/第三方登录/业务流程索引.md](./业务知识库/登录/第三方登录/业务流程索引.md) | +| 业务流程详解 | [业务知识库/登录/第三方登录/业务流程详解.md](./业务知识库/登录/第三方登录/业务流程详解.md) | +| API 索引 | [技术知识库/登录/第三方登录/API索引.md](./技术知识库/登录/第三方登录/API索引.md) | +| 组件列表 | [技术知识库/登录/第三方登录/组件列表.md](./技术知识库/登录/第三方登录/组件列表.md) | + +--- + +### 账户 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/账户/模块概览.md](./业务知识库/账户/模块概览.md) | +| 业务流程索引 | [业务知识库/账户/业务流程索引.md](./业务知识库/账户/业务流程索引.md) | +| 业务流程详解 | [业务知识库/账户/业务流程详解.md](./业务知识库/账户/业务流程详解.md) | +| API 索引 | [技术知识库/账户/API索引.md](./技术知识库/账户/API索引.md) | +| 组件列表 | [技术知识库/账户/组件列表.md](./技术知识库/账户/组件列表.md) | + +#### 账户子能力(11 个) + +| 子能力 | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加文档 | +|--------|---------|-------------|-------------|---------|---------|---------| +| API 密钥 | [链接](./业务知识库/账户/API 密钥/模块概览.md) | [链接](./业务知识库/账户/API 密钥/业务流程索引.md) | [链接](./业务知识库/账户/API 密钥/业务流程详解.md) | [链接](./技术知识库/账户/API 密钥/API索引.md) | [链接](./技术知识库/账户/API 密钥/组件列表.md) | [共享能力清单](./业务知识库/账户/API 密钥/共享能力清单.md), [共享组件清单](./技术知识库/账户/API 密钥/共享组件清单.md) | +| 通知 | [链接](./业务知识库/账户/通知/模块概览.md) | [链接](./业务知识库/账户/通知/业务流程索引.md) | [链接](./业务知识库/账户/通知/业务流程详解.md) | [链接](./技术知识库/账户/通知/API索引.md) | [链接](./技术知识库/账户/通知/组件列表.md) | — | +| 个性化设置 | [链接](./业务知识库/账户/个性化设置/模块概览.md) | [链接](./业务知识库/账户/个性化设置/业务流程索引.md) | [链接](./业务知识库/账户/个性化设置/业务流程详解.md) | [链接](./技术知识库/账户/个性化设置/API索引.md) | [链接](./技术知识库/账户/个性化设置/组件列表.md) | — | +| 推广 | [链接](./业务知识库/账户/推广/模块概览.md) | [链接](./业务知识库/账户/推广/业务流程索引.md) | [链接](./业务知识库/账户/推广/业务流程详解.md) | [链接](./技术知识库/账户/推广/API索引.md) | [链接](./技术知识库/账户/推广/组件列表.md) | — | +| 账单 | [链接](./业务知识库/账户/账单/模块概览.md) | [链接](./业务知识库/账户/账单/业务流程索引.md) | [链接](./业务知识库/账户/账单/业务流程详解.md) | [链接](./技术知识库/账户/账单/API索引.md) | [链接](./技术知识库/账户/账单/组件列表.md) | [Hooks](./技术知识库/账户/账单/Hooks工具函数.md), [Store](./技术知识库/账户/账单/Store数据流.md), [共享组件清单](./技术知识库/账户/账单/共享组件清单.md) | +| 自定义域名 | [链接](./业务知识库/账户/自定义域名/模块概览.md) | [链接](./业务知识库/账户/自定义域名/业务流程索引.md) | [链接](./业务知识库/账户/自定义域名/业务流程详解.md) | [链接](./技术知识库/账户/自定义域名/API索引.md) | [链接](./技术知识库/账户/自定义域名/组件列表.md) | — | +| 账号信息 | [链接](./业务知识库/账户/账号信息/模块概览.md) | [链接](./业务知识库/账户/账号信息/业务流程索引.md) | [链接](./业务知识库/账户/账号信息/业务流程详解.md) | [链接](./技术知识库/账户/账号信息/API索引.md) | [链接](./技术知识库/账户/账号信息/组件列表.md) | [共享能力清单](./业务知识库/账户/账号信息/共享能力清单.md), [Hooks](./技术知识库/账户/账号信息/Hooks工具函数.md), [Store](./技术知识库/账户/账号信息/Store数据流.md), [共享组件清单](./技术知识库/账户/账号信息/共享组件清单.md) | +| 模型管理 | [链接](./业务知识库/账户/模型管理/模块概览.md) | [链接](./业务知识库/账户/模型管理/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/API索引.md) | [链接](./技术知识库/账户/模型管理/组件列表.md) | — | +| 团队管理 | [链接](./业务知识库/账户/团队管理/模块概览.md) | [链接](./业务知识库/账户/团队管理/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/API索引.md) | [链接](./技术知识库/账户/团队管理/组件列表.md) | — | +| 第三方集成 | [链接](./业务知识库/账户/第三方集成/模块概览.md) | [链接](./业务知识库/账户/第三方集成/业务流程索引.md) | [链接](./业务知识库/账户/第三方集成/业务流程详解.md) | [链接](./技术知识库/账户/第三方集成/API索引.md) | [链接](./技术知识库/账户/第三方集成/组件列表.md) | [Hooks](./技术知识库/账户/第三方集成/Hooks工具函数.md), [Store](./技术知识库/账户/第三方集成/Store数据流.md), [共享组件清单](./技术知识库/账户/第三方集成/共享组件清单.md) | +| 用量统计 | [链接](./业务知识库/账户/用量统计/模块概览.md) | [链接](./业务知识库/账户/用量统计/业务流程索引.md) | [链接](./业务知识库/账户/用量统计/业务流程详解.md) | [链接](./技术知识库/账户/用量统计/API索引.md) | [链接](./技术知识库/账户/用量统计/组件列表.md) | [共享能力清单](./业务知识库/账户/用量统计/共享能力清单.md) | + +##### 账户子能力的 Tab(示例) + +| 子能力 | Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|--------|-----|---------|-------------|-------------|---------|---------| +| 账单 | 账单记录 | [链接](./业务知识库/账户/账单/账单记录/模块概览.md) | [链接](./业务知识库/账户/账单/账单记录/业务流程索引.md) | [链接](./业务知识库/账户/账单/账单记录/业务流程详解.md) | [链接](./技术知识库/账户/账单/账单记录/API索引.md) | [链接](./技术知识库/账户/账单/账单记录/组件列表.md) | +| 账单 | 发票 | [链接](./业务知识库/账户/账单/发票/模块概览.md) | [链接](./业务知识库/账户/账单/发票/业务流程索引.md) | [链接](./业务知识库/账户/账单/发票/业务流程详解.md) | [链接](./技术知识库/账户/账单/发票/API索引.md) | [链接](./技术知识库/账户/账单/发票/组件列表.md) | +| 账单 | 发票抬头 | [链接](./业务知识库/账户/账单/发票抬头/模块概览.md) | [链接](./业务知识库/账户/账单/发票抬头/业务流程索引.md) | [链接](./业务知识库/账户/账单/发票抬头/业务流程详解.md) | [链接](./技术知识库/账户/账单/发票抬头/API索引.md) | [链接](./技术知识库/账户/账单/发票抬头/组件列表.md) | +| 团队管理 | 成员 | [链接](./业务知识库/账户/团队管理/成员/模块概览.md) | [链接](./业务知识库/账户/团队管理/成员/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/成员/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/成员/API索引.md) | [链接](./技术知识库/账户/团队管理/成员/组件列表.md) | +| 团队管理 | 分组 | [链接](./业务知识库/账户/团队管理/分组/模块概览.md) | [链接](./业务知识库/账户/团队管理/分组/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/分组/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/分组/API索引.md) | [链接](./技术知识库/账户/团队管理/分组/组件列表.md) | +| 团队管理 | 权限管理 | [链接](./业务知识库/账户/团队管理/权限管理/模块概览.md) | [链接](./业务知识库/账户/团队管理/权限管理/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/权限管理/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/权限管理/API索引.md) | [链接](./技术知识库/账户/团队管理/权限管理/组件列表.md) | +| 团队管理 | 组织 | [链接](./业务知识库/账户/团队管理/组织/模块概览.md) | [链接](./业务知识库/账户/团队管理/组织/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/组织/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/组织/API索引.md) | [链接](./技术知识库/账户/团队管理/组织/组件列表.md) | +| 团队管理 | 审计日志 | [链接](./业务知识库/账户/团队管理/审计日志/模块概览.md) | [链接](./业务知识库/账户/团队管理/审计日志/业务流程索引.md) | [链接](./业务知识库/账户/团队管理/审计日志/业务流程详解.md) | [链接](./技术知识库/账户/团队管理/审计日志/API索引.md) | [链接](./技术知识库/账户/团队管理/审计日志/组件列表.md) | +| 模型管理 | 活跃模型 | [链接](./业务知识库/账户/模型管理/活跃模型/模块概览.md) | [链接](./业务知识库/账户/模型管理/活跃模型/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/活跃模型/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/活跃模型/API索引.md) | [链接](./技术知识库/账户/模型管理/活跃模型/组件列表.md) | +| 模型管理 | 配置模型 | [链接](./业务知识库/账户/模型管理/配置模型/模块概览.md) | [链接](./业务知识库/账户/模型管理/配置模型/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/配置模型/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/配置模型/API索引.md) | [链接](./技术知识库/账户/模型管理/配置模型/组件列表.md) | +| 模型管理 | 模型监控 | [链接](./业务知识库/账户/模型管理/模型监控/模块概览.md) | [链接](./业务知识库/账户/模型管理/模型监控/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/模型监控/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/模型监控/API索引.md) | [链接](./技术知识库/账户/模型管理/模型监控/组件列表.md) | +| 模型管理 | 渠道管理 | [链接](./业务知识库/账户/模型管理/渠道管理/模块概览.md) | [链接](./业务知识库/账户/模型管理/渠道管理/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/渠道管理/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/渠道管理/API索引.md) | [链接](./技术知识库/账户/模型管理/渠道管理/组件列表.md) | +| 模型管理 | 渠道日志 | [链接](./业务知识库/账户/模型管理/渠道日志/模块概览.md) | [链接](./业务知识库/账户/模型管理/渠道日志/业务流程索引.md) | [链接](./业务知识库/账户/模型管理/渠道日志/业务流程详解.md) | [链接](./技术知识库/账户/模型管理/渠道日志/API索引.md) | [链接](./技术知识库/账户/模型管理/渠道日志/组件列表.md) | +| 用量统计 | dashboard | [链接](./业务知识库/账户/用量统计/dashboard/模块概览.md) | [链接](./业务知识库/账户/用量统计/dashboard/业务流程索引.md) | [链接](./业务知识库/账户/用量统计/dashboard/业务流程详解.md) | [链接](./技术知识库/账户/用量统计/dashboard/API索引.md) | [链接](./技术知识库/账户/用量统计/dashboard/组件列表.md) | +| 用量统计 | detail | [链接](./业务知识库/账户/用量统计/detail/模块概览.md) | [链接](./业务知识库/账户/用量统计/detail/业务流程索引.md) | [链接](./业务知识库/账户/用量统计/detail/业务流程详解.md) | [链接](./技术知识库/账户/用量统计/detail/API索引.md) | [链接](./技术知识库/账户/用量统计/detail/组件列表.md) | + +--- + +### 工作台 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/工作台/模块概览.md](./业务知识库/工作台/模块概览.md) | +| 业务流程索引 | [业务知识库/工作台/业务流程索引.md](./业务知识库/工作台/业务流程索引.md) | +| 业务流程详解 | [业务知识库/工作台/业务流程详解.md](./业务知识库/工作台/业务流程详解.md) | +| API 索引 | [技术知识库/工作台/API索引.md](./技术知识库/工作台/API索引.md) | +| 组件列表 | [技术知识库/工作台/组件列表.md](./技术知识库/工作台/组件列表.md) | + +#### 工作台子能力 + +| 子能力 | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加文档 | +|--------|---------|-------------|-------------|---------|---------|---------| +| 应用工作台 | [链接](./业务知识库/工作台/应用工作台/模块概览.md) | [链接](./业务知识库/工作台/应用工作台/业务流程索引.md) | [链接](./业务知识库/工作台/应用工作台/业务流程详解.md) | [链接](./技术知识库/工作台/应用工作台/API索引.md) | [链接](./技术知识库/工作台/应用工作台/组件列表.md) | — | +| 创建应用 | [链接](./业务知识库/工作台/创建应用/模块概览.md) | [链接](./业务知识库/工作台/创建应用/业务流程索引.md) | [链接](./业务知识库/工作台/创建应用/业务流程详解.md) | [链接](./技术知识库/工作台/创建应用/API索引.md) | [链接](./技术知识库/工作台/创建应用/组件列表.md) | — | +| 评测 | [链接](./业务知识库/工作台/评测/模块概览.md) | [链接](./业务知识库/工作台/评测/业务流程索引.md) | [链接](./业务知识库/工作台/评测/业务流程详解.md) | — | — | — | +| MCP 服务器 | [链接](./业务知识库/工作台/MCP 服务器/模块概览.md) | [链接](./业务知识库/工作台/MCP 服务器/业务流程索引.md) | [链接](./业务知识库/工作台/MCP 服务器/业务流程详解.md) | [链接](./技术知识库/工作台/MCP 服务器/API索引.md) | [链接](./技术知识库/工作台/MCP 服务器/组件列表.md) | — | +| 技能管理 | [链接](./业务知识库/工作台/技能管理/模块概览.md) | [链接](./业务知识库/工作台/技能管理/业务流程索引.md) | [链接](./业务知识库/工作台/技能管理/业务流程详解.md) | [链接](./技术知识库/工作台/技能管理/API索引.md) | [链接](./技术知识库/工作台/技能管理/组件列表.md) | — | +| 系统工具 | [链接](./业务知识库/工作台/系统工具/模块概览.md) | [链接](./业务知识库/工作台/系统工具/业务流程索引.md) | [链接](./业务知识库/工作台/系统工具/业务流程详解.md) | [链接](./技术知识库/工作台/系统工具/API索引.md) | [链接](./技术知识库/工作台/系统工具/组件列表.md) | — | +| 模板市场 | [链接](./业务知识库/工作台/模板市场/模块概览.md) | [链接](./业务知识库/工作台/模板市场/业务流程索引.md) | [链接](./业务知识库/工作台/模板市场/业务流程详解.md) | [链接](./技术知识库/工作台/模板市场/API索引.md) | [链接](./技术知识库/工作台/模板市场/组件列表.md) | — | +| 工具管理 | [链接](./业务知识库/工作台/工具管理/模块概览.md) | [链接](./业务知识库/工作台/工具管理/业务流程索引.md) | [链接](./业务知识库/工作台/工具管理/业务流程详解.md) | [链接](./技术知识库/工作台/工具管理/API索引.md) | [链接](./技术知识库/工作台/工具管理/组件列表.md) | — | + +##### 评测子能力展开 + +| 评测子能力 | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加文档 | +|-----------|---------|-------------|-------------|---------|---------|---------| +| 评测首页 | [链接](./业务知识库/工作台/评测/评测首页/模块概览.md) | [链接](./业务知识库/工作台/评测/评测首页/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测首页/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测首页/API索引.md) | [链接](./技术知识库/工作台/评测/评测首页/组件列表.md) | — | +| 创建评测 | [链接](./业务知识库/工作台/评测/创建评测/模块概览.md) | [链接](./业务知识库/工作台/评测/创建评测/业务流程索引.md) | [链接](./业务知识库/工作台/评测/创建评测/业务流程详解.md) | [链接](./技术知识库/工作台/评测/创建评测/API索引.md) | [链接](./技术知识库/工作台/评测/创建评测/组件列表.md) | [共享能力清单](./业务知识库/工作台/评测/创建评测/共享能力清单.md), [Hooks](./技术知识库/工作台/评测/创建评测/Hooks工具函数.md), [Store](./技术知识库/工作台/评测/创建评测/Store数据流.md), [共享组件清单](./技术知识库/工作台/评测/创建评测/共享组件清单.md) | +| 评测数据集 | [链接](./业务知识库/工作台/评测/评测数据集/模块概览.md) | [链接](./业务知识库/工作台/评测/评测数据集/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测数据集/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测数据集/API索引.md) | [链接](./技术知识库/工作台/评测/评测数据集/组件列表.md) | — | +| 评测维度 | [链接](./业务知识库/工作台/评测/评测维度/模块概览.md) | [链接](./业务知识库/工作台/评测/评测维度/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测维度/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测维度/API索引.md) | [链接](./技术知识库/工作台/评测/评测维度/组件列表.md) | — | +| 评测任务 | [链接](./业务知识库/工作台/评测/评测任务/模块概览.md) | [链接](./业务知识库/工作台/评测/评测任务/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测任务/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测任务/API索引.md) | [链接](./技术知识库/工作台/评测/评测任务/组件列表.md) | — | + +###### 评测深层展开 + +| 路径 | Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加 | +|------|-----|---------|-------------|-------------|---------|---------|------| +| 评测首页 | 评测任务 | [链接](./业务知识库/工作台/评测/评测首页/评测任务/模块概览.md) | [链接](./业务知识库/工作台/评测/评测首页/评测任务/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测首页/评测任务/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测首页/评测任务/API索引.md) | [链接](./技术知识库/工作台/评测/评测首页/评测任务/组件列表.md) | [Store](./技术知识库/工作台/评测/评测首页/评测任务/Store数据流.md) | +| 评测首页 | 评测数据集 | [链接](./业务知识库/工作台/评测/评测首页/评测数据集/模块概览.md) | [链接](./业务知识库/工作台/评测/评测首页/评测数据集/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测首页/评测数据集/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测首页/评测数据集/API索引.md) | [链接](./技术知识库/工作台/评测/评测首页/评测数据集/组件列表.md) | — | +| 评测首页 | 评测维度 | [链接](./业务知识库/工作台/评测/评测首页/评测维度/模块概览.md) | [链接](./业务知识库/工作台/评测/评测首页/评测维度/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测首页/评测维度/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测首页/评测维度/API索引.md) | [链接](./技术知识库/工作台/评测/评测首页/评测维度/组件列表.md) | [共享能力清单](./业务知识库/工作台/评测/评测首页/评测维度/共享能力清单.md), [Hooks](./技术知识库/工作台/评测/评测首页/评测维度/Hooks工具函数.md), [Store](./技术知识库/工作台/评测/评测首页/评测维度/Store数据流.md), [共享组件清单](./技术知识库/工作台/评测/评测首页/评测维度/共享组件清单.md) | +| 评测数据集 | 评测数据集首页 | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集首页/模块概览.md) | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集首页/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集首页/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测数据集/评测数据集首页/API索引.md) | [链接](./技术知识库/工作台/评测/评测数据集/评测数据集首页/组件列表.md) | [共享能力清单](./业务知识库/工作台/评测/评测数据集/评测数据集首页/共享能力清单.md), [Hooks](./技术知识库/工作台/评测/评测数据集/评测数据集首页/Hooks工具函数.md), [Store](./技术知识库/工作台/评测/评测数据集/评测数据集首页/Store数据流.md), [共享组件清单](./技术知识库/工作台/评测/评测数据集/评测数据集首页/共享组件清单.md) | +| 评测数据集 | 文件导入 | [链接](./业务知识库/工作台/评测/评测数据集/文件导入/模块概览.md) | [链接](./业务知识库/工作台/评测/评测数据集/文件导入/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测数据集/文件导入/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测数据集/文件导入/API索引.md) | [链接](./技术知识库/工作台/评测/评测数据集/文件导入/组件列表.md) | — | +| 评测数据集 | 评测数据集详情 | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集详情/模块概览.md) | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集详情/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测数据集/评测数据集详情/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测数据集/评测数据集详情/API索引.md) | [链接](./技术知识库/工作台/评测/评测数据集/评测数据集详情/组件列表.md) | [共享能力清单](./业务知识库/工作台/评测/评测数据集/评测数据集详情/共享能力清单.md), [Hooks](./技术知识库/工作台/评测/评测数据集/评测数据集详情/Hooks工具函数.md), [Store](./技术知识库/工作台/评测/评测数据集/评测数据集详情/Store数据流.md) | +| 评测维度 | 评测维度首页 | [链接](./业务知识库/工作台/评测/评测维度/评测维度首页/模块概览.md) | [链接](./业务知识库/工作台/评测/评测维度/评测维度首页/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测维度/评测维度首页/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测维度/评测维度首页/API索引.md) | [链接](./技术知识库/工作台/评测/评测维度/评测维度首页/组件列表.md) | — | +| 评测维度 | 创建维度 | [链接](./业务知识库/工作台/评测/评测维度/创建维度/模块概览.md) | [链接](./业务知识库/工作台/评测/评测维度/创建维度/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测维度/创建维度/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测维度/创建维度/API索引.md) | [链接](./技术知识库/工作台/评测/评测维度/创建维度/组件列表.md) | — | +| 评测维度 | 编辑维度 | [链接](./业务知识库/工作台/评测/评测维度/编辑维度/模块概览.md) | [链接](./业务知识库/工作台/评测/评测维度/编辑维度/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测维度/编辑维度/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测维度/编辑维度/API索引.md) | [链接](./技术知识库/工作台/评测/评测维度/编辑维度/组件列表.md) | [共享能力清单](./业务知识库/工作台/评测/评测维度/编辑维度/共享能力清单.md), [Hooks](./技术知识库/工作台/评测/评测维度/编辑维度/Hooks工具函数.md), [Store](./技术知识库/工作台/评测/评测维度/编辑维度/Store数据流.md), [共享组件清单](./技术知识库/工作台/评测/评测维度/编辑维度/共享组件清单.md) | +| 评测任务 | 评测任务首页 | [链接](./业务知识库/工作台/评测/评测任务/评测任务首页/模块概览.md) | [链接](./业务知识库/工作台/评测/评测任务/评测任务首页/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测任务/评测任务首页/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测任务/评测任务首页/API索引.md) | [链接](./技术知识库/工作台/评测/评测任务/评测任务首页/组件列表.md) | — | +| 评测任务 | 任务详情 | [链接](./业务知识库/工作台/评测/评测任务/任务详情/模块概览.md) | [链接](./业务知识库/工作台/评测/评测任务/任务详情/业务流程索引.md) | [链接](./业务知识库/工作台/评测/评测任务/任务详情/业务流程详解.md) | [链接](./技术知识库/工作台/评测/评测任务/任务详情/API索引.md) | [链接](./技术知识库/工作台/评测/评测任务/任务详情/组件列表.md) | [Store](./技术知识库/工作台/评测/评测任务/任务详情/Store数据流.md) | + +--- + +### 数据集 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/数据集/模块概览.md](./业务知识库/数据集/模块概览.md) | +| 业务流程索引 | [业务知识库/数据集/业务流程索引.md](./业务知识库/数据集/业务流程索引.md) | +| 业务流程详解 | [业务知识库/数据集/业务流程详解.md](./业务知识库/数据集/业务流程详解.md) | +| 共享能力清单 | [业务知识库/数据集/共享能力清单.md](./业务知识库/数据集/共享能力清单.md) | +| API 索引 | [技术知识库/数据集/API索引.md](./技术知识库/数据集/API索引.md) | +| 组件列表 | [技术知识库/数据集/组件列表.md](./技术知识库/数据集/组件列表.md) | + +#### 数据集子能力 + +| 子能力 | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加文档 | +|--------|---------|-------------|-------------|---------|---------|---------| +| 数据集列表 | [链接](./业务知识库/数据集/数据集列表/模块概览.md) | [链接](./业务知识库/数据集/数据集列表/业务流程索引.md) | [链接](./业务知识库/数据集/数据集列表/业务流程详解.md) | [链接](./技术知识库/数据集/数据集列表/API索引.md) | [链接](./技术知识库/数据集/数据集列表/组件列表.md) | [共享能力清单](./业务知识库/数据集/数据集列表/共享能力清单.md), [Store](./技术知识库/数据集/数据集列表/Store数据流.md) | +| 数据集详情 | [链接](./业务知识库/数据集/数据集详情/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/API索引.md) | [链接](./技术知识库/数据集/数据集详情/组件列表.md) | — | + +##### 数据集详情 Tab + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加 | +|-----|---------|-------------|-------------|---------|---------|------| +| 集合管理 | [链接](./业务知识库/数据集/数据集详情/集合管理/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/集合管理/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/集合管理/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/集合管理/API索引.md) | [链接](./技术知识库/数据集/数据集详情/集合管理/组件列表.md) | [Store](./技术知识库/数据集/数据集详情/集合管理/Store数据流.md) | +| 检索测试 | [链接](./业务知识库/数据集/数据集详情/检索测试/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/检索测试/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/检索测试/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/检索测试/API索引.md) | [链接](./技术知识库/数据集/数据集详情/检索测试/组件列表.md) | [共享能力清单](./业务知识库/数据集/数据集详情/检索测试/共享能力清单.md), [Hooks](./技术知识库/数据集/数据集详情/检索测试/Hooks工具函数.md), [Store](./技术知识库/数据集/数据集详情/检索测试/Store数据流.md), [共享组件清单](./技术知识库/数据集/数据集详情/检索测试/共享组件清单.md) | +| 数据卡片 | [链接](./业务知识库/数据集/数据集详情/数据卡片/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/数据卡片/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/数据卡片/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/数据卡片/API索引.md) | [链接](./技术知识库/数据集/数据集详情/数据卡片/组件列表.md) | — | +| 结构化数据卡片 | [链接](./业务知识库/数据集/数据集详情/结构化数据卡片/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/结构化数据卡片/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/结构化数据卡片/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/结构化数据卡片/API索引.md) | [链接](./技术知识库/数据集/数据集详情/结构化数据卡片/组件列表.md) | [Hooks](./技术知识库/数据集/数据集详情/结构化数据卡片/Hooks工具函数.md), [共享组件清单](./技术知识库/数据集/数据集详情/结构化数据卡片/共享组件清单.md) | +| 数据集配置 | [链接](./业务知识库/数据集/数据集详情/数据集配置/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/数据集配置/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/数据集配置/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/数据集配置/API索引.md) | [链接](./技术知识库/数据集/数据集详情/数据集配置/组件列表.md) | — | +| 导入数据 | [链接](./业务知识库/数据集/数据集详情/导入数据/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/导入数据/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/导入数据/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/导入数据/API索引.md) | [链接](./技术知识库/数据集/数据集详情/导入数据/组件列表.md) | — | +| 同义词管理 | [链接](./业务知识库/数据集/数据集详情/同义词管理/模块概览.md) | [链接](./业务知识库/数据集/数据集详情/同义词管理/业务流程索引.md) | [链接](./业务知识库/数据集/数据集详情/同义词管理/业务流程详解.md) | [链接](./技术知识库/数据集/数据集详情/同义词管理/API索引.md) | [链接](./技术知识库/数据集/数据集详情/同义词管理/组件列表.md) | — | + +--- + +### 配置 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/配置/模块概览.md](./业务知识库/配置/模块概览.md) | +| 业务流程索引 | [业务知识库/配置/业务流程索引.md](./业务知识库/配置/业务流程索引.md) | +| API 索引 | [技术知识库/配置/API索引.md](./技术知识库/配置/API索引.md) | +| 组件列表 | [技术知识库/配置/组件列表.md](./技术知识库/配置/组件列表.md) | + +#### 配置子能力 + +| 子能力 | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|--------|---------|-------------|-------------|---------|---------| +| 工具配置 | [链接](./业务知识库/配置/工具配置/模块概览.md) | [链接](./业务知识库/配置/工具配置/业务流程索引.md) | [链接](./业务知识库/配置/工具配置/业务流程详解.md) | [链接](./技术知识库/配置/工具配置/API索引.md) | [链接](./技术知识库/配置/工具配置/组件列表.md) | + +##### 工具配置 Tab + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | +|-----|---------|-------------|-------------|---------|---------| +| 工具配置首页 | [链接](./业务知识库/配置/工具配置/工具配置首页/模块概览.md) | [链接](./业务知识库/配置/工具配置/工具配置首页/业务流程索引.md) | [链接](./业务知识库/配置/工具配置/工具配置首页/业务流程详解.md) | [链接](./技术知识库/配置/工具配置/工具配置首页/API索引.md) | [链接](./技术知识库/配置/工具配置/工具配置首页/组件列表.md) | +| 工具市场 | [链接](./业务知识库/配置/工具配置/工具市场/模块概览.md) | [链接](./业务知识库/配置/工具配置/工具市场/业务流程索引.md) | [链接](./业务知识库/配置/工具配置/工具市场/业务流程详解.md) | [链接](./技术知识库/配置/工具配置/工具市场/API索引.md) | [链接](./技术知识库/配置/工具配置/工具市场/组件列表.md) | + +--- + +### 价格 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/价格/模块概览.md](./业务知识库/价格/模块概览.md) | +| 业务流程索引 | [业务知识库/价格/业务流程索引.md](./业务知识库/价格/业务流程索引.md) | +| 业务流程详解 | [业务知识库/价格/业务流程详解.md](./业务知识库/价格/业务流程详解.md) | +| API 索引 | [技术知识库/价格/API索引.md](./技术知识库/价格/API索引.md) | +| 组件列表 | [技术知识库/价格/组件列表.md](./技术知识库/价格/组件列表.md) | + +--- + +### 技能 + +| 文档 | 路径 | +|------|------| +| 模块概览 | 见技能详情 | +| 业务流程索引 | 见技能详情 | +| 业务流程详解 | 见技能详情 | + +#### 技能详情 + +| 文档 | 路径 | +|------|------| +| 模块概览 | [业务知识库/技能/技能详情/模块概览.md](./业务知识库/技能/技能详情/模块概览.md) | +| 业务流程索引 | [业务知识库/技能/技能详情/业务流程索引.md](./业务知识库/技能/技能详情/业务流程索引.md) | +| 业务流程详解 | [业务知识库/技能/技能详情/业务流程详解.md](./业务知识库/技能/技能详情/业务流程详解.md) | +| API 索引 | [技术知识库/技能/技能详情/API索引.md](./技术知识库/技能/技能详情/API索引.md) | +| 组件列表 | [技术知识库/技能/技能详情/组件列表.md](./技术知识库/技能/技能详情/组件列表.md) | + +##### 技能详情 Tab + +| Tab | 模块概览 | 业务流程索引 | 业务流程详解 | API 索引 | 组件列表 | 附加 | +|-----|---------|-------------|-------------|---------|---------|------| +| config | [链接](./业务知识库/技能/技能详情/config/模块概览.md) | [链接](./业务知识库/技能/技能详情/config/业务流程索引.md) | [链接](./业务知识库/技能/技能详情/config/业务流程详解.md) | [链接](./技术知识库/技能/技能详情/config/API索引.md) | [链接](./技术知识库/技能/技能详情/config/组件列表.md) | [Hooks](./技术知识库/技能/技能详情/config/Hooks工具函数.md) | +| preview | [链接](./业务知识库/技能/技能详情/preview/模块概览.md) | [链接](./业务知识库/技能/技能详情/preview/业务流程索引.md) | [链接](./业务知识库/技能/技能详情/preview/业务流程详解.md) | [链接](./技术知识库/技能/技能详情/preview/API索引.md) | [链接](./技术知识库/技能/技能详情/preview/组件列表.md) | [Hooks](./技术知识库/技能/技能详情/preview/Hooks工具函数.md) | + +--- + +## 三、统计信息 + +| 指标 | 数值 | +|------|------| +| 一级能力 | 12 | +| 总能力节点(含 Tab) | 70+ | +| 业务知识库文档 | 180+ | +| 技术知识库文档 | 200+ | +| 跨模块依赖边 | 15+ | +| 废弃节点 | 0 | diff --git "a/doc/kb/\344\273\223\345\272\223\344\276\235\350\265\226.md" "b/doc/kb/\344\273\223\345\272\223\344\276\235\350\265\226.md" new file mode 100644 index 000000000000..911ff43e6e1f --- /dev/null +++ "b/doc/kb/\344\273\223\345\272\223\344\276\235\350\265\226.md" @@ -0,0 +1,415 @@ +# FastGPT 仓库依赖 + +## 一、内部包依赖关系 + +```mermaid +graph TD + Global["@fastgpt/global
(全局类型、常量、工具)"] + Service["@fastgpt/service
(后端业务核心库)"] + Web["@fastgpt/web
(前端共享组件库)"] + DitingRag["diting-rag-ts
(RAG向量库)"] + + App["projects/app
(主应用)"] + CodeSandbox["projects/code-sandbox
(代码沙箱)"] + MCPServer["projects/mcp_server
(MCP服务器)"] + + Global --> Service + Global --> Web + Global --> App + + Service --> App + Web --> App + DitingRag --> Service + DitingRag --> App + + Service --> CodeSandbox + Global --> MCPServer +``` + +### 依赖关系说明 + +| 包/项目 | 依赖 | 说明 | +|--------|------|------| +| **@fastgpt/global** | (无) | 核心公共库,定义全局类型、常量、工具函数,被所有包依赖 | +| **@fastgpt/service** | @fastgpt/global, diting-rag-ts | 后端业务核心库,包含数据库模型、业务逻辑、API控制器 | +| **@fastgpt/web** | @fastgpt/global | 前端共享组件库,包含React组件、hooks、样式、国际化 | +| **diting-rag-ts** | (无) | RAG向量库,独立库,主要供service和app使用 | +| **projects/app** | @fastgpt/global, @fastgpt/service, @fastgpt/web, diting-rag-ts | 主Next.js应用,集成前后端 | +| **projects/code-sandbox** | @fastgpt/service | 独立代码沙箱服务,依赖service提供的业务逻辑 | +| **projects/mcp_server** | @fastgpt/global | MCP协议服务器,主要依赖global提供的类型和工具定义 | + +--- + +## 二、核心外部依赖 + +### 2.1 框架类 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **Next.js** | 16.2.1 | 主应用Web框架,支持SSR和API路由 | +| **React** | ^18 | UI库,函数式组件和Hooks | +| **Chakra UI** | ^2 | UI组件库,提供可访问性和主题系统 | +| **Emotion** | ^11 | CSS-in-JS库,Chakra UI的样式引擎 | +| **TypeScript** | ^5.9.3 | 静态类型检查 | + +### 2.2 数据库 & 向量存储 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **Mongoose** | ^8.10.1 | MongoDB ODM,数据库模型和查询 | +| **TypeORM** | ^0.3.24 | PostgreSQL/MySQL ORM支持 | +| **pg** | ^8.10.0 | PostgreSQL驱动 | +| **@zilliz/milvus2-sdk-node** | 2.6.9 | Milvus向量数据库SDK | +| **ioredis** | ^5.6.0 | Redis客户端,缓存和队列 | + +### 2.3 AI/ML & LLM相关 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **openai** | 4.104.0 | OpenAI SDK,LLM调用(覆盖OpenAI兼容API) | +| **@mariozechner/pi-ai** | ^0.67.3 | PI-Agent框架核心库 | +| **@mariozechner/pi-agent-core** | ^0.67.3 | PI-Agent核心执行引擎 | +| **@modelcontextprotocol/sdk** | ^1 | Model Context Protocol SDK | +| **tiktoken** | 1.0.17 | Token计数库(OpenAI) | +| **@langchain/langgraph** | ^0.2.0 | LangGraph工作流编排(diting-rag-ts包内使用)| +| **@ai-sdk/openai** | ^3.0.52 | AI SDK - OpenAI提供商(diting-rag-ts包内使用)| +| **@ai-sdk/anthropic** | ^3.0.69 | AI SDK - Anthropic提供商(diting-rag-ts包内使用)| + +### 2.4 工具类 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **zod** | ^4 | 数据验证和Schema定义 | +| **axios** | 1.13.6 | HTTP客户端 | +| **dayjs** | 1.11.19 | 日期时间处理 | +| **nanoid** | ^5.1.3 | 生成唯一ID | +| **lodash** | 4.17.23 | 工具函数库 | +| **js-yaml** | ^4.1.1 | YAML解析和生成 | +| **json5** | ^2.2.3 | JSON5解析(宽松的JSON) | + +### 2.5 文件处理 & 内容解析 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **pdfjs-dist** | 4.10.38 | PDF文件解析 | +| **mammoth** | ^1.11.0 | Word文档(.docx)解析 | +| **cheerio** | 1.0.0-rc.12 | HTML/XML解析 | +| **turndown** | ^7.1.2 | HTML转Markdown | +| **exceljs** | ^4.4.0 | Excel文件读写 | +| **jszip** | ^3.10.1 | ZIP文件处理 | +| **mime** | ^4.1.0 | MIME类型检测 | +| **papaparse** | 5.4.1 | CSV解析 | + +### 2.6 状态管理 & 数据流 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **Zustand** | ^4.3.5 | 前端轻量级状态管理 | +| **@tanstack/react-query** | ^4.24.10 | 服务端数据同步和缓存 | +| **react-hook-form** | 7.43.1 | 表单管理 | +| **immer** | ^9.0.19 | 不可变状态更新 | + +### 2.7 渲染 & 可视化 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **react-markdown** | ^9.0.1 | Markdown渲染 | +| **remark-gfm** | ^4.0.0 | GitHub Flavored Markdown支持 | +| **remark-math** | ^6.0.0 | 数学公式支持 | +| **rehype-katex** | ^7.0.0 | KaTeX数学公式渲染 | +| **echarts** | 5.4.1 | 数据可视化图表库 | +| **recharts** | ^2.15.0 | React图表库 | +| **mermaid** | ^10.9.4 | 流程图、UML渲染 | +| **reactflow** | ^11.7.4 | 可视化工作流编辑器 | + +### 2.8 编辑器 & 代码处理 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **@monaco-editor/react** | ^4.7.0 | VS Code编辑器组件 | +| **lexical** | 0.12.6 | 富文本编辑器框架 | +| **react-syntax-highlighter** | ^15.5.0 | 代码高亮 | +| **esbuild** | ^0.25.11 | JavaScript打包和转译 | + +### 2.9 存储 & CDN + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **minio** | 8.0.7 | MinIO S3兼容对象存储SDK | +| **@fastgpt-sdk/storage** | 0.6.15 | FastGPT统一存储SDK | +| **archiver** | ^7.0.1 | 文件压缩和打包 | + +### 2.10 队列 & 异步任务 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **bullmq** | ^5.52.2 | Redis任务队列 | +| **node-cron** | ^3.0.3 | 定时任务调度 | + +### 2.11 国际化 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **i18next** | 23.16.8 | 国际化框架 | +| **next-i18next** | 15.4.2 | Next.js i18n插件 | +| **react-i18next** | 14.1.2 | React i18n绑定 | + +### 2.12 服务器 & Web框架 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **hono** | ^4.7.6 | 轻量级Web框架(code-sandbox使用) | +| **express** | ^4 | Web服务器框架 | +| **http-proxy** | ^1.18.1 | HTTP代理 | + +### 2.13 日志 & 监控 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **pino** | ^9.7.0 | 高性能日志库 | +| **winston** | ^3.17.0 | 日志聚合库 | +| **@opentelemetry/** | 各版本 | OpenTelemetry观测SDK | +| **@fastgpt-sdk/otel** | 0.1.2 | FastGPT集成的OTEL SDK | + +### 2.14 其他工具 + +| 依赖 | 版本 | 用途 | +|------|------|------| +| **jsonwebtoken** | ^9.0.2 | JWT认证 | +| **multer** | 2.1.0 | 文件上传中间件 | +| **cookie** | ^0.7.1 | Cookie处理 | +| **request-ip** | ^3.3.0 | 获取客户端IP | +| **dotenv** | ^16.5.0 | 环境变量加载 | +| **@node-rs/jieba** | 2.0.1 | 中文分词库 | + +--- + +## 三、版本约束 + +### 3.1 Node.js & Package Manager + +| 依赖 | 最低版本 | 说明 | +|------|--------|------| +| **Node.js** | >=20 | 支持最新ES特性、async/await、Worker Threads等 | +| **pnpm** | 9.x | Monorepo包管理器,相比npm更高效 | + +### 3.2 核心框架版本一览 + +| 框架 | 版本 | 发布时间 | +|------|------|--------| +| **Next.js** | 16.2.1 | 2025年1月(最新版本) | +| **React** | ^18 | 支持18.x系列(含最新) | +| **TypeScript** | ^5.9.3 | 支持5.9+版本 | +| **Chakra UI** | ^2 | 支持2.x系列 | +| **Mongoose** | ^8.10.1 | 支持8.x版本 | + +--- + +## 四、依赖安装说明 + +### 4.1 安装所有依赖 + +```bash +# 使用pnpm安装根目录及所有workspace依赖 +pnpm install + +# 或仅安装特定workspace的依赖 +cd projects/app && pnpm install +``` + +### 4.2 执行根目录脚本 + +```bash +# 运行所有项目的代码格式化 +pnpm format-code + +# 运行ESLint检查并自动修复 +pnpm lint + +# 运行所有测试 +pnpm test + +# 生成Chakra UI主题类型定义 +pnpm gen:theme-typings +``` + +### 4.3 开发特定项目 + +```bash +# 启动主应用开发服务器 +cd projects/app && pnpm dev + +# 启动代码沙箱(监视模式) +cd projects/code-sandbox && pnpm dev + +# 启动MCP服务器 +cd projects/mcp_server && bun dev +``` + +### 4.4 Workspace工作流 + +```bash +# 在根目录执行命令作用于所有packages +pnpm -r lint + +# 仅作用于特定package(例如@fastgpt/service) +pnpm --filter @fastgpt/service test + +# 查看workspace包列表 +pnpm list -r --depth 0 +``` + +### 4.5 依赖版本管理 + +- **Catalog定义**:`pnpm-workspace.yaml`中的`catalog`字段定义了公共版本 +- **导入方式**:各package.json通过`catalog:`前缀指定版本 +- **示例**: + ```json + { + "dependencies": { + "axios": "catalog:", + "next": "catalog:", + "zod": "catalog:" + } + } + ``` + +--- + +## 五、特殊依赖说明 + +### 5.1 PI-Agent框架集成 + +FastGPT集成了@mariozechner/pi-ai和pi-agent-core,提供高级Agent推理能力: + +```json +{ + "@mariozechner/pi-ai": "^0.67.3", + "@mariozechner/pi-agent-core": "^0.67.3" +} +``` + +这些依赖在`FastGPT/packages/service/core/workflow/dispatch/ai/agent/`中使用,支持PI-Agent执行和工具调用。 + +### 5.2 Milvus向量数据库支持 + +向量存储支持多种方案: +- **Milvus**(推荐):通过`@zilliz/milvus2-sdk-node` +- **PostgreSQL pgVector**:通过`pg`驱动 +- **配置位置**:`FastGPT/packages/service/core/dataset/database/clientManager.ts` + +### 5.3 文件处理能力 + +完整的文件处理工具链: +- PDF、Word、Excel、CSV等文档格式 +- HTML转Markdown +- 内容提取和解析 +- 详见`FastGPT/packages/service/common/`下的对应模块 + +### 5.4 API客户端兼容性 + +支持多个LLM提供商通过统一接口: +- OpenAI兼容API +- 通过`axios`进行HTTP请求 +- 模型配置在`FastGPT/packages/global/core/ai/provider.ts` + +--- + +## 六、依赖图关键路径 + +### 前端依赖链 +``` +@fastgpt/web + ├─> @fastgpt/global + ├─> React + Chakra UI + ├─> next-i18next + └─> 编辑器类(lexical、monaco) +``` + +### 后端依赖链 +``` +@fastgpt/service + ├─> @fastgpt/global + ├─> Mongoose + MongoDB + ├─> TypeORM + PostgreSQL + ├─> Milvus + ioredis + ├─> openai + pi-ai + └─> 文件处理工具链 +``` + +### 应用依赖链 +``` +projects/app + ├─> @fastgpt/global + ├─> @fastgpt/service + ├─> @fastgpt/web + ├─> diting-rag-ts + └─> 可视化库(echarts、mermaid、reactflow) +``` + +--- + +## 七、依赖更新指南 + +### 7.1 更新Catalog中的依赖 + +编辑`pnpm-workspace.yaml`的`catalog`字段,然后: + +```bash +# 更新pnpm-lock.yaml +pnpm install + +# 运行测试确保兼容性 +pnpm test +``` + +### 7.2 添加新的外部依赖 + +```bash +# 添加到特定package +cd packages/global && pnpm add zod@latest + +# 或通过workspace过滤 +pnpm --filter @fastgpt/global add zod@latest +``` + +### 7.3 检查依赖安全漏洞 + +```bash +# 使用pnpm audit +pnpm audit + +# 修复已知漏洞 +pnpm audit --fix +``` + +--- + +## 八、与Docker部署的关系 + +详见`FastGPT/deploy/`目录下的配置: + +| 配置文件 | 涉及依赖 | 实际位置 | +|---------|---------|---------| +| `docker-compose.pg.yml` | PostgreSQL + pgVector容器(TypeORM + pg依赖) | `FastGPT/deploy/docker/{cn,global}/` | +| `docker-compose.milvus.yml` | Milvus容器配置(@zilliz/milvus2-sdk-node依赖) | `FastGPT/deploy/docker/{cn,global}/` | +| `Dockerfile.app` | 主应用镜像,包含所有生产依赖 | `FastGPT/`(根目录) | + +--- + +## 九、常见问题排查 + +### Q: 如何解决依赖冲突? +A: 检查`FastGPT/package.json`的`pnpm.peerDependencyRules`字段,FastGPT已配置OpenAI的zod版本兼容规则。 + +### Q: Catalog版本与直接版本号的区别? +A: Catalog用于统一多个package的相同依赖版本,避免版本分散。 + +### Q: 如何确保开发和生产依赖一致? +A: 运行`pnpm install`会生成`pnpm-lock.yaml`锁定版本,提交到Git确保一致性。 + +--- + +**最后更新**: 2026年5月 +**Node.js版本要求**: >=20 +**pnpm版本要求**: 9.x diff --git "a/doc/kb/\344\273\223\345\272\223\346\236\266\346\236\204.md" "b/doc/kb/\344\273\223\345\272\223\346\236\266\346\236\204.md" new file mode 100644 index 000000000000..a21aa7e3b89f --- /dev/null +++ "b/doc/kb/\344\273\223\345\272\223\346\236\266\346\236\204.md" @@ -0,0 +1,765 @@ +# FastGPT 仓库架构文档 + +## 一、整体架构概览 + +FastGPT 是一个基于 **pnpm workspaces 的 monorepo** 架构,采用 **分层分模块设计** 思想: + +- **架构模式**: 微服务 + 前后端一体化 (Next.js Server Actions/API Routes) +- **技术栈**: Next.js + TypeScript + Node.js + React + Chakra UI + MongoDB + PostgreSQL(pgvector) + Milvus + Bun + Hono +- **核心职能**: + - 提供 AI Agent 可视化构建平台 + - 支持多模型接入和工作流编排 + - 提供 RAG (知识库)、Agent 技能、应用市场等核心能力 + - 兼容 OpenAI API 接口规范 + +### 项目结构 +``` +FastGPT (Monorepo) +├── packages/ # 公共库(跨项目复用) +├── projects/ # 独立应用项目 +├── sdk/ # 独立 SDK 库 +├── plugins/ # 第三方插件集成 +├── deploy/ # Docker 部署配置 +├── test/ # 集中测试目录 +├── document/ # 产品文档站点 +└── doc/ # 知识库文档 +``` + +--- + +## 二、分层架构图 + +```mermaid +graph TD + subgraph "用户层" + A["浏览器客户端
FastGPT Web UI"] + B["外部 API 调用
OpenAI 兼容接口"] + end + + subgraph "应用层" + C["projects/app
Next.js 前端 + API 路由"] + D["projects/marketplace
应用市场"] + end + + subgraph "业务服务层" + E["packages/service
核心业务逻辑"] + F["工作流引擎
workflow/dispatch"] + G["LLM 请求处理
ai/llm"] + H["向量数据库接口
dataset/database"] + end + + subgraph "前端组件层" + I["packages/web
共享组件库"] + J["packages/global
类型、常量、工具"] + end + + subgraph "数据处理层" + K["packages/diting-rag-ts
RAG 向量处理库"] + L["packages/templates
应用模板库"] + end + + subgraph "子服务层" + M["projects/code-sandbox
代码执行沙箱
Bun + Hono"] + N["projects/mcp_server
Model Context Protocol"] + O["projects/volume-manager
卷存储管理"] + end + + subgraph "存储层" + P["MongoDB
应用、聊天、知识库"] + Q["PostgreSQL + pgvector
向量存储"] + R["Milvus
向量数据库"] + S["Redis
缓存和消息队列"] + T["MinIO/S3
对象存储"] + end + + A -->|请求| C + B -->|API 请求| C + C -->|业务逻辑| E + C -->|前端组件| I + I -->|类型、常量| J + E -->|工作流执行| F + E -->|LLM 调用| G + E -->|向量查询| H + E -->|RAG 处理| K + E -->|应用模板| L + E -->|代码执行| M + E -->|MCP 协议| N + E -->|存储管理| O + H -->|查询| Q + H -->|查询| R + E -->|数据持久化| P + E -->|缓存/队列| S + E -->|文件存储| T +``` + +--- + +## 三、模块职责说明 + +### 3.1 应用层模块 + +#### **projects/app** - 主应用(Next.js 前后端一体) +- **路径**: `FastGPT/projects/app` +- **职责**: FastGPT 核心 Web 应用,集前端页面 + API 路由,提供用户界面和对外接口 +- **技术栈**: Next.js 16.x, React 18.x, Chakra UI, Mongoose ORM, @tanstack/react-query +- **主要功能**: + - 用户认证和权限管理 (`/src/pages/api/support/user/`) + - App 应用生命周期管理 (`/src/pages/api/core/app/`) + - 知识库管理 (`/src/pages/api/core/dataset/`) + - 聊天接口(OpenAI 兼容)(`/src/pages/api/v1/chat/`) + - 工作流编排和执行 (`/src/pages/api/core/workflow/`) + - Agent 技能管理 (`/src/pages/api/core/agentSkills/`) + - 文件上传和管理 (`/src/pages/api/common/file/`) + - Web UI 页面渲染 +- **关键文件**: + - `FastGPT/projects/app/src/pages/_app.tsx` - React 应用根组件 + - `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` - OpenAI 兼容聊天 API + - `FastGPT/projects/app/next.config.ts` - Next.js 构建配置(Rspack 优化) + +--- + +#### **projects/marketplace** - 应用市场 +- **路径**: `FastGPT/projects/marketplace` +- **职责**: 应用模板和技能分享市场,用户可上传/下载预制应用和 Agent 技能 +- **技术栈**: Next.js, React, Chakra UI, Tailwind CSS, MongoDB +- **主要功能**: + - 应用模板展示和下载 + - Agent 技能分享 + - 用户评价和推荐 +- **关键文件**: + - `FastGPT/projects/marketplace/package.json` - 依赖定义 + +--- + +### 3.2 服务层模块 + +#### **packages/service** - 后端核心业务库 +- **路径**: `FastGPT/packages/service` +- **职责**: 后端业务逻辑核心库,被 `projects/app` 和其他项目引用,实现数据模型、控制器、工作流引擎等 +- **技术栈**: TypeScript, Mongoose (MongoDB ODM), TypeORM (PostgreSQL), Node.js 20.x +- **核心子模块**: + + **数据模型层** (`core/`): + - `core/app/schema.ts` - App 应用数据模型 + - `core/app/version/schema.ts` - 应用版本模型 + - `core/chat/chatSchema.ts` - 聊天会话模型 + - `core/chat/chatItemSchema.ts` - 聊天消息项模型 + - `core/dataset/schema.ts` - 知识库模型 + - `core/dataset/collection/schema.ts` - 知识库集合模型 + - `core/dataset/data/schema.ts` - 知识库数据项模型 + - `core/agentSkills/schema.ts` - Agent 技能模型 + - `core/ai/config/schema.ts` - AI 模型配置模型 + + **业务控制器层** (`core/*/controller.ts`): + - `core/app/controller.ts` - App 生命周期管理(创建、更新、删除、发布) + - `core/chat/controller.ts` - 聊天会话 CRUD 和查询 + - `core/dataset/controller.ts` - 知识库管理 + - `core/dataset/collection/controller.ts` - 知识库集合管理 + - `core/dataset/data/controller.ts` - 知识库数据项管理 + - `core/agentSkills/controller.ts` - Agent 技能管理 + + **工作流引擎** (`core/workflow/dispatch/`): + - `core/workflow/dispatch/index.ts` - 工作流调度主入口 + - `core/workflow/dispatch/ai/agent/index.ts` - Agent 节点执行引擎 + - `core/workflow/dispatch/ai/agent/master/call.ts` - Agent 主工具调用和推理循环 + - `core/workflow/dispatch/ai/agent/piAgent/index.ts` - PI-Agent 执行逻辑 + + **LLM 接口层** (`core/ai/`): + - `core/ai/llm/request.ts` - LLM 请求核心处理(流式/非流式) + - `core/ai/llm/agentCall/index.ts` - Agent 工具调用 LLM 接口 + - `core/ai/llm/promptCall/index.ts` - 普通 Prompt 调用 LLM + - `core/ai/llm/compress/index.ts` - 上下文压缩引擎 + - `core/ai/config.ts` - AI 模型配置加载和选择 + - `core/ai/embedding/index.ts` - 文本向量嵌入(多模型支持) + - `core/ai/rerank/index.ts` - RAG 文档重排序 + + **向量数据库层** (`core/dataset/database/`): + - `core/dataset/database/clientManager.ts` - Milvus/PGVector 客户端管理 + - 支持多向量库后端切换 + + **公共服务** (`common/`): + - `common/mongo/index.ts` - MongoDB 连接管理 + - `support/user/schema.ts` - 用户数据模型 + - `support/permission/` - 权限管理 + +- **关键文件**: + - `FastGPT/packages/service/package.json` - 依赖定义(MongoDB、PostgreSQL、AI SDK 等) + - `FastGPT/packages/service/core/workflow/dispatch/index.ts` - 工作流核心入口 + +--- + +#### **packages/global** - 全局共享类型和常量 +- **路径**: `FastGPT/packages/global` +- **职责**: 跨端共享的类型定义、常量、错误码、工具函数 +- **技术栈**: TypeScript (纯 TS,无运行时依赖) +- **主要内容**: + - `core/app/constants.ts` - App 类型常量(Simple、Workflow 等) + - `core/ai/provider.ts` - AI 提供商和模型配置枚举 + - `core/ai/pricing.ts` - 模型 Token 计价规则 + - `core/workflow/runtime/type.ts` - 工作流节点类型定义 + - `common/error/errorCode.ts` - 统一 HTTP 错误码枚举 + - `common/error/utils.ts` - 错误处理工具函数 + - `support/user/team/constant.ts` - 团队权限常量定义 + - `openapi/` - OpenAPI 规范定义 +- **关键文件**: + - `FastGPT/packages/global/core/ai/provider.ts` + - `FastGPT/packages/global/core/workflow/runtime/type.ts` + +--- + +#### **packages/web** - 前端共享组件库 +- **路径**: `FastGPT/packages/web` +- **职责**: 前端共享组件、Hooks、样式、国际化 +- **技术栈**: React 18.x, Chakra UI, TypeScript +- **主要内容**: + - `components/` - React 函数式组件(ChatBox、FormInput、Modal 等) + - `hooks/` - 自定义 React Hooks(useQuery、useForm 等) + - `styles/theme.ts` - Chakra UI 主题配置 + - `i18n/` - 国际化支持(简体中文、繁体中文、英文) + - `utils/` - 工具函数库 +- **关键文件**: + - `FastGPT/packages/web/styles/theme.ts` - 设计系统定义 + - `FastGPT/packages/web/i18n/` - 国际化资源 + +--- + +#### **packages/diting-rag-ts** - RAG 向量处理库 +- **路径**: `FastGPT/packages/diting-rag-ts` +- **职责**: RAG (检索增强生成) 文本处理和向量化,包括文本切割、分块、嵌入 +- **技术栈**: TypeScript, Jieba 分词, 向量库接口 +- **主要功能**: + - 文本预处理和分词 + - 文本块生成和优化 + - 向量嵌入接口 +- **关键文件**: + - `FastGPT/packages/diting-rag-ts/package.json` + +--- + +#### **packages/templates** - 应用模板库 +- **路径**: `FastGPT/packages/templates` +- **职责**: 预制应用和工作流模板,供用户快速创建应用 +- **内容**: 各类应用模板定义(聊天应用、分类应用、Agent 应用等) +- **关键文件**: + - `FastGPT/packages/templates/package.json` + +--- + +### 3.3 子服务层模块 + +#### **projects/code-sandbox** - 代码执行沙箱 +- **路径**: `FastGPT/projects/code-sandbox` +- **职责**: 安全隔离的 JavaScript/TypeScript 代码执行环境,用于 Agent 工具和自定义函数执行 +- **技术栈**: Bun, Hono (Web 框架), Node.js +- **主要功能**: + - 代码编译和执行 + - 代码超时控制和资源限制 + - 安全沙箱隔离 + - RESTful API 接口 +- **端口**: 3002 (开发环境) +- **关键文件**: + - `FastGPT/projects/code-sandbox/src/index.ts` - 服务启动入口 + - `FastGPT/projects/code-sandbox/package.json` + +--- + +#### **projects/mcp_server** - Model Context Protocol 服务器 +- **路径**: `FastGPT/projects/mcp_server` +- **职责**: 实现 Model Context Protocol (MCP) 规范,允许 Claude 和其他 LLM 通过标准化接口调用 FastGPT 能力 +- **技术栈**: Bun, Express, @modelcontextprotocol/sdk +- **主要功能**: + - MCP 工具定义和注册 + - 资源暴露 + - 提示词模板 + - 标准化的 LLM 集成 +- **端口**: 3003 (开发环境) +- **关键文件**: + - `FastGPT/projects/mcp_server/src/index.ts` - MCP 服务启动入口 + - `FastGPT/projects/mcp_server/package.json` + +--- + +#### **projects/volume-manager** - 卷存储管理 +- **路径**: `FastGPT/projects/volume-manager` +- **职责**: 管理持久化卷和容器存储,支持多种存储后端 +- **技术栈**: Bun, Hono +- **主要功能**: + - 卷创建和管理 + - 存储空间配额管理 + - 容器挂载点管理 +- **端口**: 3005 (开发环境) +- **关键文件**: + - `FastGPT/projects/volume-manager/package.json` + +--- + +#### **projects/agent-sandbox** - Agent 沙箱 +- **路径**: `FastGPT/projects/agent-sandbox` +- **职责**: Agent 安全执行容器镜像,提供 Dockerfile 和入口脚本,用于构建 Agent 代码执行的隔离沙箱环境 +- **关键文件**: + - `FastGPT/projects/agent-sandbox/Dockerfile` - 容器镜像构建文件 + +--- + +#### **projects/sandbox** - 沙箱服务源码 +- **路径**: `FastGPT/projects/sandbox` +- **职责**: 沙箱执行服务预留目录,当前仅包含常量定义文件(FastGPT/projects/sandbox/src/sandbox/constants.ts) +- **关键目录**: + - `FastGPT/projects/sandbox/src/` - 沙箱服务源码 + +--- + +### 3.4 SDK 和插件层 + +#### **sdk/logger** - 日志 SDK +- **路径**: `FastGPT/sdk/logger` +- **职责**: 统一日志记录,支持控制台输出和 OpenTelemetry 导出 +- **技术栈**: pino, OpenTelemetry + +--- + +#### **sdk/storage** - 存储 SDK +- **路径**: `FastGPT/sdk/storage` +- **职责**: 统一文件存储接口,支持 MinIO、AWS S3、阿里云 OSS 等多个后端 +- **技术栈**: minio-js + +--- + +#### **sdk/otel** - OpenTelemetry SDK +- **路径**: `FastGPT/sdk/otel` +- **职责**: 可观测性支持,包括追踪、指标、日志 +- **技术栈**: OpenTelemetry + +--- + +#### **plugins/** - 第三方插件 +- **路径**: `FastGPT/plugins/` +- **职责**: 集成第三方 AI 模型、爬虫、数据源等 +- **内容**: 各类动态加载的插件 + +--- + +### 3.5 辅助模块 + +#### **document/** - 产品文档站点 +- **路径**: `FastGPT/document/` +- **职责**: NextJS 文档站点,提供用户和开发者文档 +- **技术栈**: Next.js, MDX + +--- + +#### **test/** - 集中测试目录 +- **路径**: `FastGPT/test/` +- **职责**: 集中管理单元测试、集成测试、向量库测试 +- **配置**: Vitest +- **子目录**: + - `test/integrationTest/vectorDB/` - 向量库集成测试 + +--- + +#### **deploy/** - 部署配置 +- **路径**: `FastGPT/deploy/` +- **职责**: Docker 和 Kubernetes 部署配置 +- **主要内容**: + - `deploy/dev/docker-compose.yml` - 开发环境全栈编排 + - `deploy/docker/` - 生产环境配置 + - Dockerfile.app - 主应用镜像构建 + +--- + +--- + +## 四、数据流向 + +### 4.1 典型聊天请求数据流 + +```mermaid +sequenceDiagram + participant 用户 as 用户浏览器 + participant API as API 路由
projects/app + participant Workflow as 工作流引擎
dispatch + participant LLM as LLM 处理
ai/llm + participant RAG as 向量DB
dataset/database + participant 模型 as 外部 LLM 服务 + participant 存储 as 数据存储
MongoDB/PG + + 用户->>API: POST /api/v1/chat/completions
或 /api/core/chat/chatTest + activate API + API->>API: 认证和参数验证 + API->>Workflow: 调用 dispatch()
执行工作流 + activate Workflow + + Workflow->>Workflow: 解析工作流节点 + Workflow->>RAG: 若有知识库节点
向量相似度检索 + activate RAG + RAG->>RAG: 使用 embedding 向量化用户输入 + RAG->>存储: 查询 Milvus/PGVector
向量数据库 + RAG-->>Workflow: 返回相关文档列表 + deactivate RAG + + Workflow->>LLM: 若有 LLM 节点
调用 llmRequest() + activate LLM + LLM->>LLM: 构建系统提示词和上下文 + LLM->>LLM: 若需要压缩:调用 compress() + LLM->>模型: 发送请求
调用外部 LLM API + activate 模型 + 模型-->>LLM: 流式返回响应 + deactivate 模型 + LLM-->>Workflow: 返回 LLM 响应 + deactivate LLM + + Workflow->>Workflow: 若为 Agent 节点
执行 agent/call.ts
工具调用循环 + Workflow->>Workflow: 若有代码节点
调用 code-sandbox + + Workflow-->>API: 返回最终结果 + deactivate Workflow + + API->>存储: 保存聊天记录和反馈 + activate 存储 + 存储->>存储: 保存到 chatItemSchema + deactivate 存储 + + API-->>用户: 返回 JSON/SSE 流
聊天响应 + deactivate API +``` + +### 4.2 数据存储架构 + +``` +MongoDB (文档数据库) +├── App 应用数据 (appSchema) +├── 聊天会话 (chatSchema) +├── 聊天消息 (chatItemSchema) +├── 知识库元数据 (datasetSchema) +├── 知识库数据项 (dataSchema) +├── AI 模型配置 (configSchema) +├── Agent 技能 (agentSkillsSchema) +└── 用户账户 (userSchema) + +PostgreSQL + pgvector +├── 向量索引 (高维数据快速检索) +└── 结构化数据备份 + +Milvus (可选向量库) +├── 大规模向量存储 +└── 相似度检索优化 + +Redis +├── 缓存 (用户会话、热点数据) +├── 分布式锁 +└── 消息队列 (BullMQ) + +MinIO / S3 (对象存储) +├── 用户上传文件 +├── 知识库文档 +└── 大型二进制资源 +``` + +--- + +## 五、部署架构 + +### 5.1 容器化部署 + +FastGPT 采用 **Docker + Docker Compose** 的容器化部署方式,支持开发和生产环境。 + +#### 核心服务容器 + +``` +开发环境 (docker-compose.yml) +├── fastgpt-postgres (PostgreSQL 15 + pgvector) +│ └── 端口 5432 +├── fastgpt-mongo (MongoDB 5.0) +│ └── 端口 27017 +├── fastgpt-redis (Redis 7) +│ └── 端口 6379 +├── fastgpt-minio (MinIO 对象存储) +│ └── 端口 9000 (内部), 9001 (UI) +├── fastgpt-code-sandbox (代码沙箱) +│ └── 端口 3002 +├── fastgpt-mcp-server (MCP 服务) +│ └── 端口 3003 +├── fastgpt-plugin (插件服务) +│ └── 端口 3004 +├── fastgpt-volume-manager (卷存储) +│ └── 端口 3005 +├── opensandbox-server (Agent 沙箱管理) +│ └── 端口 8090 +├── fastgpt-aiproxy (AI 请求代理) +│ └── 端口 3010 +├── fastgpt-aiproxy-pg (AI Proxy PostgreSQL) +│ └── 端口 5432 (内部) +└── [FastGPT 主应用 - 本地开发运行] + └── 端口 3000 +``` + +#### 镜像构建 + +- **Dockerfile.app** - 多阶段构建主应用 + - Stage 1: `maindeps` - 安装依赖 + - Stage 2: `builder` - 编译 Next.js 应用 + - Stage 3: `runner` - 最小化生产镜像 + +- **项目配置**: + - `projects/app/next.config.ts` - Rspack 编译加速 + - `projects/app/server.ts` - 生产服务器实现 + +### 5.2 环境变量配置 + +关键配置在 `deploy/dev/docker-compose.yml`: + +```yaml +# 数据库配置 +MONGODB_URI: mongodb://user:password@fastgpt-mongo:27017/fastgpt +REDIS_URL: redis://password@fastgpt-redis:6379 +PG_CONNECTION_STRING: postgresql://... + +# 存储配置 +STORAGE_VENDOR: minio | aws-s3 | cos | oss +STORAGE_S3_ENDPOINT: http://fastgpt-minio:9000 + +# AI 配置 +OPENAI_API_KEY: sk-xxx +OPENAI_API_BASE: https://api.openai.com/v1 + +# 日志配置 +LOG_ENABLE_CONSOLE: true +LOG_ENABLE_OTEL: false +LOG_OTEL_URL: http://localhost:4318/v1/logs + +# 沙箱认证 +CODE_SANDBOX_TOKEN: codesandbox +VM_AUTH_TOKEN: vmtoken +``` + +### 5.3 扩展和高可用 + +- **向量库选择**: 支持 PostgreSQL pgvector、Milvus 等,可按需切换 +- **分布式缓存**: Redis 支持集群部署 +- **消息队列**: BullMQ 基于 Redis,支持任务队列和延时任务 +- **日志收集**: 支持 OpenTelemetry 导出到专业观测平台 +- **水平扩展**: 应用层无状态设计,支持多实例部署 + +--- + +## 六、核心技术栈总结 + +| 层级 | 技术 | 版本 | 用途 | +|-----|-----|------|------| +| **前端框架** | Next.js | 16.x | SSR/SSG + API Routes | +| **前端 UI** | React | 18.x | 组件库和状态管理 | +| **样式系统** | Chakra UI | 2.x | 组件库和主题管理 | +| **后端运行时** | Node.js | 20.x+ | 后端代码执行 | +| **代码沙箱** | Bun | 1.x+ | 高性能代码执行 | +| **服务框架** | Hono | 4.x | 轻量级 Web 框架 | +| **数据库** | MongoDB | 5.0+ | 文档存储 | +| **关系数据库** | PostgreSQL | 15+ | 向量存储 (pgvector) | +| **向量库** | Milvus | 2.6+ | 大规模向量检索 | +| **缓存** | Redis | 7.0+ | 缓存和消息队列 | +| **对象存储** | MinIO/S3 | - | 文件存储 | +| **ORM** | Mongoose | 8.x | MongoDB ODM | +| **ORM** | TypeORM | 0.3.x | PostgreSQL ORM | +| **语言** | TypeScript | 5.x | 类型安全 | +| **测试框架** | Vitest | 3.x | 单元和集成测试 | +| **包管理** | pnpm | 9.x | Monorepo 管理 | +| **国际化** | i18next | 23.x | 多语言支持 | +| **LLM 集成** | Anthropic SDK | latest | Claude 集成 | +| **LLM 集成** | OpenAI SDK | latest | OpenAI 模型调用 | + +--- + +## 七、工程化规范 + +### 7.1 开发命令 + +```bash +# 主应用开发 +cd projects/app && pnpm dev # 启动开发服务器 +cd projects/app && pnpm build # 构建生产版本 +cd projects/app && pnpm start # 启动生产服务器 + +# 代码沙箱开发 +cd projects/code-sandbox && pnpm dev # 监视模式运行 +cd projects/code-sandbox && pnpm test # 运行测试 + +# MCP 服务器开发 +cd projects/mcp_server && bun dev # 监视模式运行 +cd projects/mcp_server && bun build # 构建 + +# 全局命令 +pnpm lint # ESLint 检查和自动修复 +pnpm test # 运行所有测试 +pnpm test:vector # 运行向量库集成测试 +pnpm gen:theme-typings # 生成 Chakra UI 主题类型 +pnpm initIcon # 初始化图标资源 +``` + +### 7.2 代码规范 + +- **语言**: 全量 TypeScript +- **包管理**: pnpm workspaces +- **版本要求**: Node.js >= 20.x, pnpm >= 9.x +- **代码格式**: Prettier + ESLint +- **组件模式**: React 函数式组件 + Hooks +- **状态管理**: React Context + Zustand +- **API 设计**: Zod schema 定义 + OpenAPI 3.0 规范 +- **数据库模型**: Mongoose Schema (MongoDB) + TypeORM 实体 (PostgreSQL) +- **工作流**: 图结构 + 节点执行引擎 + +### 7.3 文件组织 + +``` +projects/app/src/ +├── pages/ +│ ├── _app.tsx # React 根组件 +│ ├── _document.tsx # HTML 模板 +│ └── api/ # API 路由 +│ ├── v1/ # 对外 API (OpenAI 兼容) +│ ├── v2/ # 对外 API v2 +│ ├── core/ # 核心业务 API +│ ├── common/ # 公共 API (文件上传等) +│ ├── support/ # 支撑 API (用户、权限等) +│ ├── admin/ # 管理后台 API +│ ├── invoke/ # 调用接口 API +│ ├── lafApi/ # Laf API +│ ├── marketplace/ # 应用市场 API +│ ├── mcp/ # MCP 协议 API +│ ├── plugin/ # 插件 API +│ ├── proApi/ # Pro API +│ ├── system/ # 系统 API +│ └── aiproxy/ # AI 代理透传 +├── pageComponents/ # 页面级组件 +├── components/ # 通用 React 组件 +├── web/ # 前端核心 +│ ├── common/hooks/ # 自定义 Hooks +│ ├── styles/ # 全局样式 +│ ├── context/ # React Context +│ └── core/ # 前端核心业务 +├── service/ # 前端业务逻辑 +├── global/ # 全局配置 +└── types/ # 类型定义 + +packages/web/ +├── components/ # 共享 React 组件 +├── hooks/ # 共享 Hooks +├── store/ # 状态管理 (Zustand) +├── styles/ # Chakra UI 主题 +├── i18n/ # 国际化资源 +└── utils/ # 工具函数 + +packages/service/ +├── core/ # 核心业务模块 +│ ├── app/ # App 应用模块 +│ ├── chat/ # 聊天模块 +│ ├── dataset/ # 知识库模块 +│ ├── agentSkills/ # Agent 技能模块 +│ ├── workflow/ # 工作流引擎 +│ ├── ai/ # AI 模型接口层 +│ ├── evaluation/ # 评估模块 +│ ├── plugin/ # 插件模块 +│ └── train/ # 训练模块 +├── support/ # 支撑模块 +│ ├── user/ # 用户管理 +│ └── permission/ # 权限管理 +└── common/ # 公共服务 + ├── mongo/ # MongoDB 连接 + ├── redis/ # Redis 连接 + ├── file/ # 文件处理 + ├── s3/ # S3 对象存储 + └── ... # 其他公共服务 +``` + +--- + +## 八、核心流程概览 + +### 8.1 应用创建流程 +``` +用户创建应用 → API (core/app/create.ts) + → App 控制器 (controller.ts) + → 创建 App 文档 (appSchema) + → 创建应用版本 (versionSchema) + → 返回应用 ID +``` + +### 8.2 工作流执行流程 +``` +调用执行接口 → API (core/workflow/debug.ts) + → 工作流调度器 (dispatch/index.ts) + → 拓扑排序节点 + → 遍历执行节点 + → LLM 节点: 调用 ai/llm/request.ts + → Agent 节点: 调用 ai/agent/call.ts (工具调用循环) + → 知识库节点: 调用 dataset/database/clientManager.ts + → 代码节点: 调用 code-sandbox + → 收集输出和返回结果 +``` + +### 8.3 聊天对话流程 +``` +用户发送消息 → API (v1/chat/completions.ts) + → 聊天控制器 (chat/controller.ts) + → 加载应用配置 (appSchema) + → 执行工作流 (工作流执行流程) + → 保存聊天记录 (chatItemSchema) + → 流式返回响应给客户端 +``` + +--- + +## 九、扩展点和插件系统 + +### 9.1 AI 模型扩展 +- 位置: `packages/global/core/ai/provider.ts` +- 支持: OpenAI、Anthropic、Azure OpenAI、本地部署模型等 +- 通过配置动态加载模型 + +### 9.2 向量库扩展 +- 位置: `packages/service/core/dataset/database/clientManager.ts` +- 支持: Milvus、PostgreSQL pgvector、Chroma 等 +- 可在运行时切换向量库后端 + +### 9.3 工具调用扩展 +- 位置: `packages/service/core/agentSkills/` +- 支持: 自定义 Agent 技能 +- 通过工作流节点集成 + +### 9.4 存储后端扩展 +- 位置: `sdk/storage/` +- 支持: MinIO、AWS S3、阿里云 OSS、腾讯云 COS +- 通过环境变量切换存储后端 + +--- + +## 十、监控和可观测性 + +### 10.1 日志系统 +- SDK: `sdk/logger/` +- 支持: 控制台输出、OpenTelemetry 导出 +- 配置: `LOG_ENABLE_CONSOLE`、`LOG_ENABLE_OTEL` + +### 10.2 追踪系统 +- SDK: `sdk/otel/` +- 支持: 分布式追踪、性能指标 +- 集成: Vercel OTEL、OpenTelemetry + +### 10.3 性能优化 +- Next.js: Rspack 编译加速 +- 数据库: 索引优化、查询优化 +- 缓存: Redis 多级缓存策略 +- LLM: 上下文压缩、Token 优化 + +--- + +## 总结 + +FastGPT 是一个 **分层、模块化的全栈 AI Agent 平台**: + +1. **架构清晰**: 前端、应用、服务、存储四层设计 +2. **功能完整**: 工作流编排、知识库、Agent 技能、应用市场 +3. **可扩展**: 支持多模型、多向量库、多存储后端 +4. **高性能**: Rspack 编译、Redis 缓存、向量库优化 +5. **易部署**: Docker Compose 快速启动,支持生产环境扩展 +6. **开发友好**: TypeScript 类型安全、Monorepo 工程化、完整 API 规范 + +通过这个架构,FastGPT 为用户和企业提供了一个强大、灵活、可靠的 AI Agent 构建平台。 diff --git "a/doc/kb/\344\273\223\345\272\223\346\246\202\350\247\210.md" "b/doc/kb/\344\273\223\345\272\223\346\246\202\350\247\210.md" new file mode 100644 index 000000000000..f38f721e8fe1 --- /dev/null +++ "b/doc/kb/\344\273\223\345\272\223\346\246\202\350\247\210.md" @@ -0,0 +1,245 @@ +--- +capability_label: null +doc_type: "01" +doc_label: "仓库概览" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT - 仓库概览 + +> 本文档是项目知识库的索引和导航入口。 + +## 基本信息 + +| 项目 | 详情 | +|------|------| +| 项目名称 | FastGPT | +| 版本 | 4.14.11 | +| 简介 | AI Agent 构建平台,提供可视化工作流编排与模型调用能力 | +| 主要语言 | TypeScript | +| UI 框架 | React 18.x + Next.js 16.2.1 | +| UI 组件库 | Chakra UI ^2 | +| 状态管理 | Zustand + React Context | +| 构建工具 | Next.js (Webpack / Rspack) | +| 包管理 | pnpm (monorepo) | +| CSS 方案 | Sass ^1.58 + Emotion ^11 | +| 分析版本 | `ed5536221` | +| 生成时间 | 2026-06-18T00:00:00.000Z | + +## 目录结构 + +``` +projects/app/ +├── src/ +│ ├── components/ # 共享业务组件(Badge、Layout、Markdown、Select、SideBar 等) +│ ├── global/ # 全局核心逻辑(API 客户端、App/Chat/Dataset/Workflow 核心) +│ │ ├── core/ # 核心业务模块 +│ │ │ ├── api/ # 通用 API 请求封装 +│ │ │ ├── app/ # 应用核心逻辑 +│ │ │ ├── chat/ # 对话核心逻辑 +│ │ │ ├── dataset/ # 数据集核心逻辑 +│ │ │ └── workflow/ # 工作流核心逻辑 +│ │ └── support/ # 支撑模块(API、OpenAPI 等) +│ ├── pageComponents/ # 页面级组件(按路由聚合) +│ │ ├── account/ # 账户相关页面组件 +│ │ ├── app/ # 应用相关页面组件 +│ │ ├── chat/ # 对话相关页面组件 +│ │ ├── config/ # 配置相关页面组件 +│ │ ├── dashboard/ # 工作台相关页面组件 +│ │ ├── dataset/ # 数据集相关页面组件 +│ │ ├── login/ # 登录相关页面组件 +│ │ └── price/ # 价格页面组件 +│ ├── pages/ # Next.js 页面路由(文件系统路由) +│ │ ├── api/ # API 路由(服务端) +│ │ │ ├── admin/ # 管理后台 API +│ │ │ ├── core/ # 核心业务 API +│ │ │ ├── support/ # 支撑服务 API +│ │ │ ├── v1/ # V1 版本 API +│ │ │ └── v2/ # V2 版本 API +│ │ ├── account/ # 账户页面路由 +│ │ ├── app/ # 应用页面路由 +│ │ ├── chat/ # 对话页面路由 +│ │ ├── config/ # 配置页面路由 +│ │ ├── dashboard/ # 工作台页面路由 +│ │ ├── dataset/ # 数据集页面路由 +│ │ ├── login/ # 登录页面路由 +│ │ ├── price/ # 价格页面路由 +│ │ └── skill/ # 技能页面路由 +│ ├── service/ # 通用服务端业务逻辑 +│ │ ├── common/ # 通用服务(BullMQ、系统工具等) +│ │ ├── core/ # 核心服务(AI、App、Chat、Dataset、Sandbox) +│ │ ├── middleware/ # 服务端中间件 +│ │ └── support/ # 支撑服务(MCP、权限、用户、钱包) +│ ├── types/ # TypeScript 类型定义 +│ ├── web/ # 共享前端工具与上下文 +│ │ ├── common/ # 通用 Web 工具(API、文件、Hooks、i18n、中间件、工具函数) +│ │ ├── context/ # React Context 定义 +│ │ ├── core/ # 前端核心模块(AI、App、Chat、Dataset、Evaluation、Plugin、Skill、Workflow) +│ │ ├── styles/ # 全局样式 +│ │ └── support/ # 前端支撑模块(活动、用户、钱包、MCP、OpenAPI 等) +│ ├── css.d.ts # CSS Module 类型声明 +│ ├── instrumentation.ts # Next.js Instrumentation 钩子 +│ └── middleware.ts # Next.js 中间件 +├── package.json # 项目依赖配置 +└── next.config.js # Next.js 构建配置 +``` + +## 技术栈 + +| 类别 | 技术 | 版本 | 说明 | +|------|------|------|------| +| 语言 | TypeScript | ^5.9.3 | 全项目类型覆盖 | +| UI 框架 | React + Next.js | React ^18 / Next.js 16.2.1 | 文件系统路由,SSR 支持 | +| UI 组件库 | Chakra UI | ^2 | 声明式组件库,Emotion 驱动 | +| 状态管理 | Zustand + React Context | - | Zustand 全局状态,Context 模块级状态 | +| 构建工具 | Next.js (Webpack / Rspack) | 16.2.1 | 支持 Rspack 可选构建 | +| 包管理 | pnpm | 9.x | monorepo workspaces | +| CSS 方案 | Sass + Emotion | Sass ^1.58 / Emotion ^11 | 混合方案,Chakra 样式 + Sass 自定义 | +| 图表可视化 | ECharts + Recharts | ECharts 5.4.1 / Recharts ^2.15 | 统计图表与工作流可视化 | +| 工作流引擎 | ReactFlow | ^11.7 | 可视化工作流编排 | +| 国际化 | i18next + next-i18next | i18next 23.16.8 | 支持中/英/日三语 | +| 数据请求 | Axios + @tanstack/react-query | Axios 1.13.6 / React Query ^4.24 | 服务端状态管理 | +| 表单处理 | react-hook-form | 7.43.1 | 高性能表单验证 | +| Schema 验证 | Zod | ^4 | API 入参/出参校验 | +| 数据库 | MongoDB (Mongoose) | Mongoose ^8.10 | 支持 PG Vector / Milvus 向量存储 | + +## 仓库规模 + +| 指标 | 数值 | +|------|------| +| 源文件总数(src/) | 1,360 | +| 一级模块数(src/ 子目录) | 7 | +| 路由数 | 45 | + +## 知识库文档索引 + +### 根级文档 +- [共享基础设施](./共享基础设施.md) — 基础设施/共享模块目录结构与使用规范 +- [业务能力地图](./业务能力地图.md) — 各能力模块依赖关系与导航 +- [API架构](./API架构.md) — 后端 API 整体架构设计 +- [路由模型](./路由模型.md) — 前端路由与权限模型 +- [国际化策略](./国际化策略.md) — 多语言实现方案 +- [自动化测试](./自动化测试.md) — 测试体系说明 +- [代码编写指南](./代码编写指南.md) — 项目编码规范 +- [本地开发指南](./本地开发指南.md) — 环境搭建与启动说明 + +### 业务知识库 + +- [首页](./业务知识库/首页/模块概览.md) — 平台入口 +- [页面未找到](./业务知识库/页面未找到/模块概览.md) — 404 兜底页 +- [OpenAPI 文档](./业务知识库/OpenAPI%20文档/模块概览.md) — API 文档展示 +- **对话/** — 对话功能 + - [对话首页](./业务知识库/对话/对话首页/模块概览.md) — 对话入口与列表 + - [分享对话](./业务知识库/对话/分享对话/模块概览.md) — 对话分享页面 +- **应用/** — 应用管理 + - [应用详情](./业务知识库/应用/应用详情/模块概览.md) — 应用详情与工作流编辑 +- **登录/** — 用户登录 + - [登录首页](./业务知识库/登录/登录首页/模块概览.md) — 密码登录 + - [快速登录](./业务知识库/登录/快速登录/模块概览.md) — 免密临时登录 + - [第三方登录](./业务知识库/登录/第三方登录/模块概览.md) — OAuth / SSO 登录 +- **账户/** — 账户管理 + - [API 密钥](./业务知识库/账户/API%20密钥/模块概览.md) — API Key 管理 + - [通知](./业务知识库/账户/通知/模块概览.md) — 系统通知 + - [个性化设置](./业务知识库/账户/个性化设置/模块概览.md) — 个人偏好配置 + - [推广](./业务知识库/账户/推广/模块概览.md) — 推广活动 + - [账单](./业务知识库/账户/账单/模块概览.md) — 账单与消费明细 + - [自定义域名](./业务知识库/账户/自定义域名/模块概览.md) — 自定义域名配置 + - [账号信息](./业务知识库/账户/账号信息/模块概览.md) — 个人资料管理 + - [模型管理](./业务知识库/账户/模型管理/模块概览.md) — AI 模型配置 + - [团队管理](./业务知识库/账户/团队管理/模块概览.md) — 团队与成员管理 + - [第三方集成](./业务知识库/账户/第三方集成/模块概览.md) — 第三方服务集成 + - [用量统计](./业务知识库/账户/用量统计/模块概览.md) — 使用量统计面板 +- **工作台/** — 运营工作台 + - [应用工作台](./业务知识库/工作台/应用工作台/模块概览.md) — 应用列表与概览 + - [创建应用](./业务知识库/工作台/创建应用/模块概览.md) — 新建应用向导 + - **评测/** — 模型评测系统 + - [评测首页](./业务知识库/工作台/评测/评测首页/模块概览.md) — 评测入口与概览 + - [创建评测](./业务知识库/工作台/评测/创建评测/模块概览.md) — 新建评测 + - **评测数据集/** — 评测数据管理 + - [评测数据集首页](./业务知识库/工作台/评测/评测数据集/评测数据集首页/模块概览.md) — 数据集列表 + - [文件导入](./业务知识库/工作台/评测/评测数据集/文件导入/模块概览.md) — 文件批量导入 + - [评测数据集详情](./业务知识库/工作台/评测/评测数据集/评测数据集详情/模块概览.md) — 数据集内容查看 + - **评测维度/** — 评测维度管理 + - [评测维度首页](./业务知识库/工作台/评测/评测维度/评测维度首页/模块概览.md) — 维度列表 + - [创建维度](./业务知识库/工作台/评测/评测维度/创建维度/模块概览.md) — 新建评测维度 + - [编辑维度](./业务知识库/工作台/评测/评测维度/编辑维度/模块概览.md) — 编辑评测维度 + - **评测任务/** — 评测执行 + - [评测任务首页](./业务知识库/工作台/评测/评测任务/评测任务首页/模块概览.md) — 任务列表 + - [任务详情](./业务知识库/工作台/评测/评测任务/任务详情/模块概览.md) — 评测结果详情 + - [MCP 服务器](./业务知识库/工作台/MCP%20服务器/模块概览.md) — MCP Server 管理 + - [技能管理](./业务知识库/工作台/技能管理/模块概览.md) — Agent 技能管理 + - [系统工具](./业务知识库/工作台/系统工具/模块概览.md) — 系统工具管理 + - [模板市场](./业务知识库/工作台/模板市场/模块概览.md) — 应用模板市场 + - [工具管理](./业务知识库/工作台/工具管理/模块概览.md) — 代理工具管理 +- **数据集/** — 数据集管理 + - [数据集列表](./业务知识库/数据集/数据集列表/模块概览.md) — 数据集浏览与搜索 + - [数据集详情](./业务知识库/数据集/数据集详情/模块概览.md) — 数据集内容与操作 +- **配置/** — 系统配置 + - **工具配置/** — 工具相关配置 + - [工具配置首页](./业务知识库/配置/工具配置/工具配置首页/模块概览.md) — 工具列表配置 + - [工具市场](./业务知识库/配置/工具配置/工具市场/模块概览.md) — 工具市场浏览 +- [价格](./业务知识库/价格/模块概览.md) — 价格与套餐展示 +- **技能/** — 技能中心 + - [技能详情](./业务知识库/技能/技能详情/模块概览.md) — 技能详情查看 + +### 技术知识库 + +- [首页](./技术知识库/首页/API索引.md) +- [页面未找到](./技术知识库/页面未找到/API索引.md) +- [OpenAPI 文档](./技术知识库/OpenAPI%20文档/API索引.md) +- **对话/** — 对话技术 + - [对话首页](./技术知识库/对话/对话首页/API索引.md) + - [分享对话](./技术知识库/对话/分享对话/API索引.md) +- **应用/** — 应用技术 + - [应用详情](./技术知识库/应用/应用详情/API索引.md) +- **登录/** — 登录技术 + - [登录首页](./技术知识库/登录/登录首页/API索引.md) + - [快速登录](./技术知识库/登录/快速登录/API索引.md) + - [第三方登录](./技术知识库/登录/第三方登录/API索引.md) +- **账户/** — 账户技术 + - [API 密钥](./技术知识库/账户/API%20密钥/API索引.md) + - [通知](./技术知识库/账户/通知/API索引.md) + - [个性化设置](./技术知识库/账户/个性化设置/API索引.md) + - [推广](./技术知识库/账户/推广/API索引.md) + - [账单](./技术知识库/账户/账单/API索引.md) + - [自定义域名](./技术知识库/账户/自定义域名/API索引.md) + - [账号信息](./技术知识库/账户/账号信息/API索引.md) + - [模型管理](./技术知识库/账户/模型管理/API索引.md) + - [团队管理](./技术知识库/账户/团队管理/API索引.md) + - [第三方集成](./技术知识库/账户/第三方集成/API索引.md) + - [用量统计](./技术知识库/账户/用量统计/API索引.md) +- **工作台/** — 工作台技术 + - [应用工作台](./技术知识库/工作台/应用工作台/API索引.md) + - [创建应用](./技术知识库/工作台/创建应用/API索引.md) + - **评测/** — 评测技术 + - [评测首页](./技术知识库/工作台/评测/评测首页/API索引.md) + - [创建评测](./技术知识库/工作台/评测/创建评测/API索引.md) + - **评测数据集/** — 评测数据集技术 + - [评测数据集首页](./技术知识库/工作台/评测/评测数据集/评测数据集首页/API索引.md) + - [文件导入](./技术知识库/工作台/评测/评测数据集/文件导入/API索引.md) + - [评测数据集详情](./技术知识库/工作台/评测/评测数据集/评测数据集详情/API索引.md) + - **评测维度/** — 评测维度技术 + - [评测维度首页](./技术知识库/工作台/评测/评测维度/评测维度首页/API索引.md) + - [创建维度](./技术知识库/工作台/评测/评测维度/创建维度/API索引.md) + - [编辑维度](./技术知识库/工作台/评测/评测维度/编辑维度/API索引.md) + - **评测任务/** — 评测任务技术 + - [评测任务首页](./技术知识库/工作台/评测/评测任务/评测任务首页/API索引.md) + - [任务详情](./技术知识库/工作台/评测/评测任务/任务详情/API索引.md) + - [MCP 服务器](./技术知识库/工作台/MCP%20服务器/API索引.md) + - [技能管理](./技术知识库/工作台/技能管理/API索引.md) + - [系统工具](./技术知识库/工作台/系统工具/API索引.md) + - [模板市场](./技术知识库/工作台/模板市场/API索引.md) + - [工具管理](./技术知识库/工作台/工具管理/API索引.md) +- **数据集/** — 数据集技术 + - [数据集列表](./技术知识库/数据集/数据集列表/API索引.md) + - [数据集详情](./技术知识库/数据集/数据集详情/API索引.md) +- **配置/** — 配置技术 + - **工具配置/** — 工具配置技术 + - [工具配置首页](./技术知识库/配置/工具配置/工具配置首页/API索引.md) + - [工具市场](./技术知识库/配置/工具配置/工具市场/API索引.md) +- [价格](./技术知识库/价格/API索引.md) +- **技能/** — 技能技术 + - [技能详情](./技术知识库/技能/技能详情/API索引.md) diff --git "a/doc/kb/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" "b/doc/kb/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..1a81060595e3 --- /dev/null +++ "b/doc/kb/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" @@ -0,0 +1,292 @@ +--- +capability_label: null +doc_type: "07" +doc_label: "代码编写指南" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 代码编写指南 + +> 本文档面向开发者,从现有代码库中提炼编码约定,包含组件结构模板、命名规则、常见反模式和国际化操作指南。 + +## 编码约定 + +### 组件结构模板 + +项目使用 **React 函数组件 (FC)** 模式,基于 Next.js 14 + Chakra UI + TypeScript。以下是分析 `projects/app/src/pages/account/inform.tsx`、`projects/app/src/pages/account/setting.tsx`、`projects/app/src/pages/account/usage/index.tsx` 等代表性组件后归纳的标准结构。 + +#### 页面组件模板 + +```tsx +'use client'; +// 1. React 核心 +import React, { useState, useCallback, useMemo } from 'react'; +// 2. Chakra UI 组件 +import { Box, Flex, Card, useTheme } from '@chakra-ui/react'; +// 3. 项目内部 hooks +import { usePagination } from '@fastgpt/web/hooks/usePagination'; +import { useLoading } from '@fastgpt/web/hooks/useLoading'; +// 4. 状态管理 +import { useUserStore } from '@/web/support/user/useUserStore'; +// 5. i18n +import { useTranslation } from 'next-i18next'; +// 6. 共享组件 +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +// 7. 页面子组件 +import AccountContainer from '@/pageComponents/account/AccountContainer'; +// 8. SSR i18n 注入 +import { serviceSideProps } from '@/web/common/i18n/utils'; + +// 9. 组件定义(默认使用 React.memo 或直接 export) +const PageComponent = () => { + // 9a. hooks(i18n、store、自定义 hooks) + const { t } = useTranslation(); + const theme = useTheme(); + const { userInfo } = useUserStore(); + const { Loading } = useLoading(); + + // 9b. 本地 state + const [selectedItem, setSelectedItem] = useState(null); + + // 9c. 分页/数据获取 hook(包含 data, isLoading, total, Pagination, getData) + const { + data: items, + isLoading, + total, + pageSize, + Pagination, + getData, + pageNum + } = usePagination(apiMethod, { + defaultPageSize: 20 + }); + + // 9d. 事件处理函数 + const handleClick = useCallback((item) => { + setSelectedItem(item); + }, []); + + // 9e. 内联样式对象(置于 return 前) + const textStyles = { + title: { color: '#111824', fontSize: 'md', fontWeight: 'bold' } + }; + + // 9f. JSX 渲染 + return ( + + + {/* 主内容区 */} + {items.map((item) => ( + handleClick(item)}> + {item.title} + + ))} + + {/* 空态 */} + {!isLoading && items.length === 0 && ( + + )} + + {/* 分页 */} + {total > pageSize && ( + + + + )} + + {/* 加载态 */} + + + + ); +}; + +// 10. SSR 数据注入(页面组件必有) +export async function getServerSideProps(content: any) { + return { + props: { + ...(await serviceSideProps(content, ['namespace1', 'namespace2'])) + } + }; +} + +// 11. 默认导出 +export default PageComponent; +``` + +**模板来源**: 基于 `projects/app/src/pages/account/inform.tsx`(InformTable)、`projects/app/src/pages/account/setting.tsx`(Individuation)、`projects/app/src/pages/account/usage/index.tsx`(UsageTable)的结构归纳。三者在 import 顺序、hooks 组织、SSR 注入模式上高度一致。 + +#### 普通组件模板(非页面) + +```tsx +import React, { useState, useCallback } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +// ... 其他 import + +interface Props { + value: string; + onChange: (val: string) => void; +} + +const ComponentName: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + return ( + + {/* JSX */} + + ); +}; + +export default React.memo(ComponentName); +``` + +## 命名规则 + +以下命名规范从项目 `projects/app/src/` 和 `packages/` 目录实际代码统计得出。 + +| 类别 | 规范 | 示例 | 说明 | +|------|------|------|------| +| 组件文件 | PascalCase | `ResizableDivider.tsx`、`AIModelSelector.tsx`、`EmptyTip.tsx` | 占约 95%,极少数例外 | +| 页面路由文件 | 全小写 / kebab-case | `index.tsx`、`apikey.tsx`、`fastlogin.tsx` | Next.js 文件系统路由约定 | +| 页面路由目录 | kebab-case | `account/bill/`、`chat/share/`、`dataset/list/` | 占约 75%,存在少量 camelCase 不一致 | +| 页面子组件文件 | PascalCase | `CreateModal.tsx`、`ToolModal.tsx` | 与组件文件一致 | +| 工具函数文件 | `utils.ts`(固定名) | `packages/global/common/file/utils.ts` | 按领域目录放置,不另命名为 `helpers.ts` | +| 工具函数导出 | camelCase(动词开头) | `getHandleId`、`isCSVFile`、`parseI18nString` | 语义清晰,100% 遵循 | +| 类型文件 | `type.ts` / `type.d.ts` | `packages/global/support/user/type.ts` | 类型声明文件统一此模式 | +| 组件目录命名 | 混合(PascalCase 为主) | `Badge/`、`MyImage/`、`SideTabs/` | 顶层分组目录用小写 `common/`、`core/`、`support/` | +| 变量命名 | camelCase | `userInfo`、`isLoading`、`pageNum` | | +| 函数命名 | camelCase(动词开头) | `handleClick`、`onclickSave`、`getData` | 事件处理以 `handle` 或 `on` 开头 | +| 组件命名 | PascalCase | `InformTable`、`AccountContainer`、`EmptyTip` | | +| Store 文件 | `useXxxStore.ts` | `useUserStore.ts`、`useChatStore.ts`、`useSystemStore.ts` | 80% 遵循此模式 | +| Store 导出 | `useXxxStore` | `useUserStore`、`useSystemStore` | 100% 遵循 Zustand hook 风格 | +| CSS class | Chakra UI `sx` prop 内联 | `sx={{ lineHeight: '16px' }}` | 项目不使用 CSS Modules,样式通过 Chakra 组件 props (`px`、`py`、`fontSize`、`color`) 或 inline `sx` 控制 | + +### 命名一致性注意事项 + +以下为项目中存在的不一致命名,新代码应避免: + +- `projects/app/src/components/core/app/sfDatasetSelectModal.tsx` — 应使用 PascalCase:`SfDatasetSelectModal.tsx` +- `projects/app/src/pages/dashboard/templateMarket/` — 应使用 kebab-case:`template-market/` +- `projects/app/src/pages/dashboard/mcpServer/` — 应使用 kebab-case:`mcp-server/` +- `projects/app/src/pages/dashboard/systemTool/` — 应使用 kebab-case:`system-tool/` +- `projects/app/src/web/core/chat/storeShareChat.ts` — 应遵循 `useXxxStore.ts` 模式:`useShareChatStore.ts` + +## 常见反模式 + +以下反模式来自 ESLint 配置、`eslint-disable` 注释统计、代码 TODO/FIXME 标记和项目实际代码扫描。 + +| 反模式 | 说明 | 正确替代 | +|--------|------|---------| +| 忽略 `react-hooks/exhaustive-deps` | 项目中最常被禁用的 ESLint 规则(占所有 disable 的 69%),在 `useEffect`/`useCallback`/`useMemo` 中省略依赖 | 完整声明所有依赖数组,或重构代码以避免不必要的依赖 | +| 使用 `any` 类型 | `@typescript-eslint/no-explicit-any` 被禁用;state 初始化常用 `useState(null)` | 使用明确的类型定义或泛型 `useState(null)` | +| 目录命名不一致 | 路由目录混用 camelCase(`templateMarket/`、`mcpServer/`)和 kebab-case(`chat/share/`) | 统一使用 kebab-case 作为路由目录命名 | +| `T` 泛型参数单字母大写 | `outLink/runtime/utils.ts` 中使用 HACK 注释标记 `app: T` 泛型模式 | 使用语义化泛型名,如 `TApp` 或具体接口 | +| 直接修改 props | `@typescript-eslint` 开启了 `consistent-type-imports` 强制使用 `type` 导入类型 | 类型导入使用 `import type { X }` 而非 `import { X }` | +| i18n 占位符残留 | `packages/web/i18n/en/skill.json` 中存在 `"TODO"` 占位文本 | 及时补充英文翻译,或使用 CI 检查阻断未翻译词条 | +| SSR 数据注入省略命名空间 | 页面 `getServerSideProps` 未声明该页实际使用的 i18n 命名空间 | 在 `serviceSideProps` 的第二个参数中列出所有需要的 namespace | +| `eslint-disable-next-line` 单行禁用 | 对需要长期跳过的规则,未添加注释说明原因 | 使用 `// eslint-disable-next-line -- ` 格式附带说明 | + +## 国际化使用指南 + +> 项目使用 `next-i18next` 实现三语种(简体中文 `zh-CN`、繁体中文 `zh-Hant`、英文 `en`)支持。词条文件统一存放于 `packages/web/i18n/`,共 30 个命名空间。 + +### 快速上手 + +**1. 在组件中使用翻译** + +```tsx +import { useTranslation } from 'next-i18next'; + +const MyComponent = () => { + const { t } = useTranslation(); + return {t('common:Confirm')}; +}; +``` + +翻译 key 格式为 `命名空间:键名`,示例: +- `t('common:Cancel')` — 通用 UI 文本 +- `t('account_inform:no_notifications')` — 业务模块文本 +- `t('common:core.chat.Audio Speech Error')` — 嵌套 key 用点号分隔 + +**2. 页面组件注入 SSR 翻译** + +```tsx +import { serviceSideProps } from '@/web/common/i18n/utils'; + +export async function getServerSideProps(content: any) { + return { + props: { + ...(await serviceSideProps(content, ['namespace_a', 'namespace_b'])) + } + }; +} +``` + +> `serviceSideProps` 会自动注入 `common`、`app`、`account`、`account_info` 四个基础命名空间,无需在参数中重复声明。 + +**3. 新增翻译词条** + +1. 确定词条归属的命名空间(参考 `packages/web/i18n/constants.ts` 中的 `I18N_NAMESPACES` 列表) +2. 在 `packages/web/i18n/zh-CN/{namespace}.json` 中添加中文词条 +3. 在 `packages/web/i18n/en/{namespace}.json` 中添加英文翻译 +4. 在 `packages/web/i18n/zh-Hant/{namespace}.json` 中添加繁体中文翻译 +5. 在组件中通过 `t('namespace:key')` 引用 + +**4. 语言切换** + +```tsx +import { useI18nLng } from '@fastgpt/web/hooks/useI18n'; + +const { onChangeLng, currentLang } = useI18nLng(); +// onChangeLng('en') — 切换到英文 +// onChangeLng('zh-CN') — 切换到简体中文 +``` + +语言偏好存储在 Cookie (`NEXT_LOCALE`) 和 localStorage 中,页面刷新后保持。 + +### 常见陷阱 + +- **避免在模块顶层调用 `t()`**:`useTranslation()` 是 React hook,只能在组件函数体或自定义 hook 内部调用。服务端代码中使用 `packages/global/common/i18n/utils.ts` 中的 `i18nT()` 或 `parseI18nString()`。 +- **动态 key 需确保响应性**:`t('common:status.' + status)` 在语言切换时会自动更新,但需确保 `status` 变量本身是响应式的。 +- **嵌套 key 使用点号**:`t('common:support.wallet.subscription.mode.Month')` 而非逐层展开。 +- **带参数的翻译**:使用 `t('chat:home.tools', { num: selectedTools.length })` 传入插值变量。 +- **命名空间必须存在**:`useSafeTranslation`(位于 `packages/web/hooks/useSafeTranslation.ts`)会校验命名空间是否在 `I18N_NAMESPACES_MAP` 中注册,建议使用此 hook 替代原生 `useTranslation` 以提前发现拼写错误。 +- **服务端多语言数据**:API 返回的多语言字段使用 `I18nStringType` 类型(`{ en: string, 'zh-CN'?: string, 'zh-Hant'?: string }`),通过 `parseI18nString(data.field)` 按当前语言解析。 + +### 现有命名空间速查 + +| 命名空间 | 用途 | +|---------|------| +| `common` | 通用 UI 文本(按钮、提示、状态) | +| `app` | 应用相关 | +| `chat` | 对话功能 | +| `user` | 用户相关 | +| `login` | 登录页面 | +| `dataset` | 数据集 | +| `workflow` | 工作流 | +| `account` | 账户概览 | +| `account_info` | 账号信息 | +| `account_usage` | 用量统计 | +| `account_bill` | 账单 | +| `account_apikey` | API 密钥 | +| `account_setting` | 个性化设置 | +| `account_inform` | 通知 | +| `account_promotion` | 推广 | +| `account_thirdParty` | 第三方集成 | +| `account_team` | 团队管理 | +| `account_model` | 模型管理 | +| `dashboard_mcp` | MCP 服务器 | +| `dashboard_evaluation` | 评测 | +| `skill` | 技能管理 | +| `train` | 训练 | +| `file` | 文件 | +| `publish` | 发布 | +| `admin` | 管理后台 | +| `database_client` | 数据库客户端 | + +> 完整列表及最新变更见 `packages/web/i18n/constants.ts`。 diff --git "a/doc/kb/\345\205\261\344\272\253\345\237\272\347\241\200\350\256\276\346\226\275.md" "b/doc/kb/\345\205\261\344\272\253\345\237\272\347\241\200\350\256\276\346\226\275.md" new file mode 100644 index 000000000000..d08358714e95 --- /dev/null +++ "b/doc/kb/\345\205\261\344\272\253\345\237\272\347\241\200\350\256\276\346\226\275.md" @@ -0,0 +1,37 @@ +--- +capability_label: null +doc_type: "00" +doc_label: "共享基础设施" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 共享基础设施 + +## 基础设施/共享模块 + +> 跨模块共享的底层代码。修改这些目录中的代码可能影响多个业务功能模块,需评估变更范围。 + +| 目录 | 类型 | 定位说明 | +|------|------|---------| +| `packages/global/` | 共享包 | 全局类型定义、常量、工具函数、核心业务领域模型。包含 `common/`(通用工具:错误处理、文件操作、国际化、数学计算、限流等)、`core/`(核心业务领域类型:AI、聊天、数据集、评测、工作流等)、`sdk/`(SDK 封装)、`support/`(支撑功能类型:权限、用户、钱包等)、`openapi/`(API 契约定义) | +| `packages/service/` | 共享包 | 后端服务实现层,包含数据库模型、API 控制器、工作流引擎。`common/`(通用服务:API 包装、缓存、文件、日志、指标等)、`core/`(核心业务逻辑实现)、`support/`(支撑功能实现)、`worker/`(后台任务处理:文本分块、文件读取等)、`thirdProvider/`(第三方服务集成:代码沙箱、文档解析等) | +| `packages/web/` | 共享包 | 共享前端组件、Hooks、状态管理、样式主题、国际化。`components/`(公共 UI 组件:Avatar、Markdown 渲染、日期选择器等)、`hooks/`(通用 Hooks:确认弹窗、复制、无限滚动、分页等)、`store/`(全局 Zustand Store)、`context/`(React Context 定义)、`styles/`(Chakra UI 主题配置)、`i18n/`(国际化词条,支持中文/英文/日文) | +| `packages/templates/` | 共享包 | 模板定义,为各业务模块提供标准化模板 | +| `packages/diting-rag-ts/` | 共享包 | RAG 工具包,提供检索增强生成相关能力 | +| `plugins/` | 插件目录 | 外部插件扩展,包含模型提供商适配(`model/`)、网页爬虫(`webcrawler/`)等 | +| `deploy/` | 部署配置 | Docker 镜像构建、Helm Chart、K8s 部署模板,为所有 services 提供统一的部署方案 | +| `projects/code-sandbox/` | 共享服务 | 代码执行沙箱服务(Bun + Hono),为应用提供安全的代码运行环境 | +| `projects/mcp_server/` | 共享服务 | MCP(Model Context Protocol)服务器,为 AI Agent 提供标准化工具接入 | +| `projects/agent-sandbox/` | 共享服务 | Agent 沙箱环境,提供隔离的 Agent 执行上下文 | + +## 使用规范 + +- **基础设施/共享模块**:修改需评估全局影响,涉及公共组件/Hooks/工具函数变更应在团队内同步;建议通过 Changeset 或 PR 描述中明确标注影响范围 +- **packages 层级依赖**:`global` → `service` / `web`(`global` 为最底层共享类型,`service` 和 `web` 分别依赖 `global`,不应反向依赖) +- **跨业务功能模块的共享能力**通过 `packages/` 下的共享包实现,避免在 `projects/app/` 内跨页面直接引用 +- **插件扩展**通过 `plugins/` 目录接入,遵循统一的 Provider 接口规范 +- **部署配置**集中于 `deploy/`,新增服务需同步更新部署模板 +- **第三方服务集成**统一通过 `packages/service/thirdProvider/` 封装,业务代码不直接调用外部 SDK diff --git "a/doc/kb/\345\233\275\351\231\205\345\214\226\347\255\226\347\225\245.md" "b/doc/kb/\345\233\275\351\231\205\345\214\226\347\255\226\347\225\245.md" new file mode 100644 index 000000000000..ec74df3da680 --- /dev/null +++ "b/doc/kb/\345\233\275\351\231\205\345\214\226\347\255\226\347\225\245.md" @@ -0,0 +1,266 @@ +--- +module_id: null +doc_type: "04" +doc_label: "国际化策略" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 国际化策略 + +> 本文档描述项目的多语言国际化架构和使用规范。 + +## i18n 目录结构 + +``` +packages/web/i18n/ # 语言包根目录 +├── constants.ts # 命名空间列表定义 +├── i18next.ts # TypeScript 类型增强 +├── utils.ts # Web 端 i18nT 工具函数 +├── en/ # 英文 (28 个 JSON 文件) +│ ├── common.json # 通用词条(按钮、提示等) +│ ├── app.json # 应用相关 +│ ├── dataset.json # 数据集相关 +│ ├── workflow.json # 工作流相关 +│ ├── chat.json # 对话相关 +│ ├── login.json # 登录相关 +│ ├── user.json # 用户相关 +│ ├── file.json # 文件相关 +│ ├── publish.json # 发布相关 +│ ├── train.json # 训练相关 +│ ├── skill.json # 技能相关 +│ ├── admin.json # 管理后台 +│ ├── account.json # 账户通用 +│ ├── account_apikey.json # API 密钥 +│ ├── account_bill.json # 账单 +│ ├── account_info.json # 账号信息 +│ ├── account_inform.json # 通知 +│ ├── account_model.json # 模型管理 +│ ├── account_promotion.json # 推广 +│ ├── account_setting.json # 个性化设置 +│ ├── account_team.json # 团队管理 +│ ├── account_thirdParty.json # 第三方集成 +│ ├── account_usage.json # 用量统计 +│ ├── dashboard_evaluation.json # 评测仪表盘 +│ ├── dashboard_mcp.json # MCP 服务器 +│ ├── database_client.json # 数据库客户端 +│ ├── evaluation.json # 评测 +│ └── sangfor.json # Sangfor 集成 +├── zh-CN/ # 简体中文 (28 个 JSON 文件) +│ └── ...(同 en/ 结构) +└── zh-Hant/ # 繁体中文 (28 个 JSON 文件) + └── ...(同 en/ 结构) +``` + +| 语言 | 代码 | 文件数 | 说明 | +|------|------|--------|------| +| 简体中文 | zh-CN | 28 | 默认主语言 | +| 繁体中文 | zh-Hant | 28 | | +| 英文 | en | 28 | | + +**核心配置文件**: + +| 文件 | 用途 | +|------|------| +| `projects/app/next-i18next.config.js` | next-i18next 配置(默认语言、语言列表、语言包路径) | +| `packages/web/i18n/constants.ts` | 命名空间列表 `I18N_NAMESPACES` | +| `packages/global/common/i18n/type.ts` | `LangEnum`、`localeType`、`I18nStringType` 类型定义 | +| `packages/web/i18n/i18next.ts` | i18next TypeScript 类型增强 | + +**配置详情**(`next-i18next.config.js`): + +| 配置项 | 值 | +|--------|-----| +| 默认语言 | `zh-CN` | +| 支持语言 | `['en', 'zh-CN', 'zh-Hant']` | +| 默认命名空间 | `common` | +| 语言包路径 | `packages/web/i18n` | +| 自动语言检测 | 关闭(`localeDetection: false`) | +| 开发模式重载 | 开启(`reloadOnPrerender`) | + +## 词条 key 命名规范 + +### 命名模式 + +项目采用 **扁平 key-value** 结构,key 使用**点分隔层级命名**,通过命名空间前缀区分模块: + +``` +{namespace}:{section}.{sub_section}.{label} +``` + +### 三种命名模式 + +**模式 A:扁平单词(通用词条)** + +用于通用 UI 元素,key 为单个英文单词或短语: + +| Key | zh-CN | en | +|-----|-------|-----| +| `common:Delete` | 删除 | Delete | +| `common:Save` | 保存 | Save | +| `common:Confirm` | 确认 | OK | +| `common:Cancel` | 取消 | Cancel | +| `common:Loading` | 加载中... | Loading... | + +**模式 B:模块前缀 + 路径层级(业务词条)** + +用于模块内业务相关的词条,以 `core.{module}.{section}.{label}` 层级组织: + +| Key | zh-CN | +|-----|-------| +| `common:core.app.Name and avatar` | 头像 & 名称 | +| `common:core.app.Share link` | 免登录窗口 | +| `common:core.app.error.App name can not be empty` | 应用名不能为空 | +| `common:core.dataset.Delete Confirm` | 确认删除该知识库? | +| `common:core.chat.response.module model` | 模型 | + +**模式 C:子命名空间模式** + +特定功能模块使用独立命名空间,key 为描述性英文文本: + +| Key | 命名空间 | zh-CN | +|-----|---------|-------| +| `workflow:Node.Open_Node_Course` | workflow | 查看节点教程 | +| `app:Tool_name` | app | 工具名称 | +| `app:app.error.unExist_app` | app | 部分组件缺失,请删除 | +| `chat:core.chat.New Chat` | chat | 新建对话 | + +### 插值变量 + +支持 `{{variable}}` 双花括号插值语法: + +``` +common:chat_chatId → "会话Id: {{chatId}}" +common:core.dataset.Create dataset → "创建一个{{name}}" +app:HTTP_tools_list_with_number → "工具列表: {{total}}" +error.upload_file_error_filename → "{{name}} 上传失败" +``` + +### 语言文件物理结构 + +每个命名空间一个 JSON 文件,采用扁平结构(无深层嵌套对象): + +```json +{ + "Delete": "删除", + "Save": "保存", + "core.app.Share link": "免登录窗口", + "core.dataset.Delete Confirm": "确认删除该知识库?", + "error.fileNotFound": "文件找不到了~" +} +``` + +## 词条使用方式 + +项目存在**三套翻译机制**,分别服务于不同的运行环境。 + +### 客户端组件中:`useTranslation()` + `t()` + +React 组件中使用 next-i18next 的 `useTranslation` hook,通过 `t('namespace:key')` 调用: + +```tsx +import { useTranslation } from 'next-i18next'; + +const MyComponent = () => { + const { t, i18n } = useTranslation(); + + return ( + + {t('account_bill:bill_record')} + ); +}; +``` + +### 全局常量/工具函数中:`i18nT()` + +在 `packages/global/` 和 `packages/service/` 等非 React 环境中,使用恒等函数 `i18nT()`。该函数返回 key 本身,实际翻译由客户端 `t()` 完成: + +```typescript +import { i18nT } from '@fastgpt/global/common/i18n/utils'; + +// 工作流节点定义的 name/intro/label 字段 +const nodeConfig = { + name: i18nT('workflow:customer_service.enable_fallback_reply_node_name'), + intro: i18nT('workflow:execute_different_branches_based_on_conditions'), + label: i18nT('workflow:judgment_result'), +}; +``` + +### 服务端:`parseI18nString()` + +用于解析 `I18nStringType` 对象(包含三语种的结构化翻译),在服务端按当前语言提取对应文本: + +```typescript +import { parseI18nString, I18nStringType } from '@fastgpt/global/common/i18n/utils'; + +const SANDBOX_NAME: I18nStringType = { + 'zh-CN': '虚拟机', + 'zh-Hant': '虛擬機', + en: 'Sandbox' +}; + +// 服务端消费 +const displayName = parseI18nString(SANDBOX_NAME, lang); +// lang='zh-CN' → "虚拟机", lang='en' → "Sandbox" +``` + +**回退策略**:繁体中文缺失时回退简体中文,再缺失回退英文。 + +### 翻译函数对比 + +| 函数 | 运行环境 | 输入 | 输出 | +|------|---------|------|------| +| `t('ns:key')` | React 客户端 | namespace:key | 当前语言的翻译文本 | +| `i18nT('ns:key')` | 全局常量/Node | namespace:key | key 自身(恒等) | +| `parseI18nString(obj, lang)` | 服务端/全局 | I18nStringType + 语言 | 指定语言的文本 | + +## 新增/修改词条流程 + +1. **在对应模块的语言文件中添加 key-value** + - 确定词条所属命名空间(如 `common`、`app`、`workflow`) + - 在 `packages/web/i18n/zh-CN/{namespace}.json` 中添加键值对 + - key 遵循命名规范:用点分隔的层级结构 + +2. **同步到所有语言文件** + - 在 `packages/web/i18n/en/{namespace}.json` 中添加英文翻译 + - 在 `packages/web/i18n/zh-Hant/{namespace}.json` 中添加繁体中文翻译 + - 三语种必须同步 key 名称,值可为空字符串(运行时回退处理) + +3. **在组件中使用** + - 客户端组件:`t('namespace:key')` + - 全局常量:`i18nT('namespace:key')` + - 服务端结构化文本:定义 `I18nStringType` 对象,用 `parseI18nString()` 解析 + +4. **Fallback 机制** + - 运行时词条缺失时,next-i18next 回退到默认语言 zh-CN 的对应值 + - `parseI18nString` 的 fallback 链:指定语言 → zh-Hant 回退 zh-CN → en + +## 多语言切换 + +| 机制 | 实现 | +|------|------| +| 触发方式 | `I18nLngSelector` 语言下拉选择器组件,嵌入登录页、个性化设置等多处 | +| 存储位置 | Cookie `NEXT_LOCALE`(30 天过期)+ localStorage `NEXT_LOCALE` | +| 是否需刷新 | 切换到中文(简/繁)需刷新页面;切换到英文支持热切换,资源未加载时回退刷新 | +| 组件库联动 | Chakra UI 通过 i18next 的 `changeLanguage` 联动 | +| 后端同步 | 登录用户切换语言后通过 `updateUserInfo({ language })` 同步到后端数据库 | + +### 语言切换流程 + +1. 用户通过 `I18nLngSelector` 选择目标语言 +2. `useI18nLng().onChangeLng(lng)` 被调用 +3. 语言值写入 Cookie(`NEXT_LOCALE`,30 天过期)和 localStorage +4. 已登录用户同步调用 `updateUserInfo({ language: lng })` 持久化到后端 +5. 切换逻辑: + - 切换到中文(zh-CN / zh-Hant)→ `window.location.reload()` 让 Next.js SSR 重新渲染 + - 切换到英文 → `i18n.changeLanguage(lang)` 热切换,资源包未加载时 fallback 刷新 +6. SSR 页面通过 `serviceSideProps()` 从 Cookie 读取语言,在 `getServerSideProps` 中加载对应命名空间翻译 + +### 首次访问语言判定 + +通过 `useI18nLng().setUserDefaultLng()` 初始化: +1. 优先读取 localStorage/Cookie 中的 `NEXT_LOCALE` +2. 无存储值时,取浏览器语言 `navigator.language`,映射到支持的语言 +3. 都不可用时默认 `zh-CN` diff --git "a/doc/kb/\345\244\226\351\203\250\346\216\245\345\205\245\346\214\207\345\215\227.md" "b/doc/kb/\345\244\226\351\203\250\346\216\245\345\205\245\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..c6a9a32c837a --- /dev/null +++ "b/doc/kb/\345\244\226\351\203\250\346\216\245\345\205\245\346\214\207\345\215\227.md" @@ -0,0 +1,1447 @@ +# FastGPT 外部接入指南 + +本文档介绍如何通过各种方式将外部系统接入 FastGPT,实现应用调用、知识库查询、工作流执行等功能。 + +## 一、接入概述 + +FastGPT 提供多种外部接入方式,满足不同场景需求: + +| 接入方式 | 协议 | 场景 | 特点 | +|---------|------|------|------| +| **HTTP API** | REST + OpenAI 兼容 | 通用集成、第三方系统调用 | 标准 HTTP,易于集成,支持流式/非流式 | +| **MCP 服务器** | Model Context Protocol | AI 工具集成、Agent 扩展 | 标准化工具协议,支持工具发现与调用 | +| **预签名 URL** | S3 兼容协议 | 文件上传下载 | 安全的临时访问凭证 | +| **分享链接** | Web 嵌入、访问控制 | 外部用户集成、知识库分享 | 无需认证,支持自定义 UID | + +--- + +## 二、HTTP API 接入 + +### 2.1 认证方式 + +FastGPT 支持三种认证方式: + +#### 2.1.1 API Key 认证(推荐) + +**获取方式**: +1. 登录 FastGPT Web 应用 +2. 进入 **个人中心 → API 密钥** 创建团队级 Key(用于跨应用访问) +3. 或在 **应用详情 → 接入 → API 接入** 创建应用级 Key(推荐,自动绑定 appId) + +**Key 格式**:`fastgpt-xxxxxxxxxxxxx` + +**请求头格式**: +```http +Authorization: Bearer fastgpt-xxxxxxxxxxxxx +Content-Type: application/json +``` + +#### 2.1.2 Cookie 认证(Web 浏览器) + +浏览器访问时会自动携带登录 Cookie,无需显式设置。 + +#### 2.1.3 分享链接认证 + +通过分享链接的 `shareId` 和 `outLinkUid` 进行认证,无需 Bearer Token,适合外部嵌入场景。 + +### 2.2 基础 URL + +- **自建部署**:`http(s)://your-domain/api` +- **云端 FastGPT**:`https://api.fastgpt.io/api` + +### 2.3 聊天补全接口(核心对外接口) + +**路径**:`POST /api/v1/chat/completions` + +**简述**:兼容 OpenAI ChatCompletion API 的对话接口,支持流式和非流式返回。可传入应用 ID(模型字段),系统自动加载应用工作流、知识库等配置。 + +#### 2.3.1 请求体 + +```json +{ + "model": "", + "messages": [ + {"role": "user", "content": "你好"} + ], + "stream": false, + "chatId": "xxx", + "variables": {}, + "detail": false +} +``` + +**字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `model` | string | 是 | 应用 ID(MongoDB ObjectId)或应用 API Key。使用应用级 Key 时可省略 `appId` | +| `messages` | array | 是 | OpenAI 兼容消息列表,每项包含 `role`(user/assistant/system)和 `content` | +| `stream` | boolean | 否 | 是否流式返回,默认 `false` | +| `chatId` | string | 否 | 会话 ID;传入则自动续接历史,未传视为新对话 | +| `appId` | string | 否 | 应用 ID;使用团队级 Key 时必填,应用级 Key 可不传 | +| `variables` | object | 否 | 工作流全局变量,默认 `{}` | +| `detail` | boolean | 否 | 是否返回详细信息(nodeResponses、newVariables),默认 `false` | +| `customUid` | string | 否 | 分享链接场景的自定义用户标识 | +| `shareId` | string | 否 | 分享链接 ID(用于分享链接认证) | +| `outLinkUid` | string | 否 | 分享链接用户 ID(用于分享链接认证) | +| `teamId` | string | 否 | 团队空间 Token 认证时的团队 ID | +| `teamToken` | string | 否 | 团队空间 Token(用于团队空间认证) | +| `responseChatItemId` | string | 否 | 自定义响应消息 ID,不传自动生成 | +| `retainDatasetCite` | boolean | 否 | 是否保留答案中的数据集引用标记,默认 `false` | +| `showSkillReferences` | boolean | 否 | 是否在响应中保留 Agent 技能引用,默认 `false` | +| `metadata` | object | 否 | 自定义元数据,落库到聊天记录 | + +#### 2.3.2 响应体(非流式) + +```json +{ + "id": "chat-uuid", + "model": "", + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 1 + }, + "choices": [ + { + "message": { + "role": "assistant", + "content": "您好!有什么我可以帮助您的吗?", + "reasoning_content": null + }, + "finish_reason": "stop", + "index": 0 + } + ], + "responseData": null, + "newVariables": null +} +``` + +**响应字段说明**: + +| 字段 | 说明 | +|------|------| +| `id` | 聊天 ID(chatId),用于续接历史 | +| `model` | 模型名称,v1 接口固定为空字符串 | +| `usage` | Token 用量统计(v1 接口为占位值,详细用量见 `detail=true` 时的 `responseData`) | +| `choices[].message` | 助手回复消息,包含 `role`、`content`、可选的 `reasoning_content` | +| `choices[].finish_reason` | 完成原因,通常为 `stop`(正常完成)或 `max_tokens`(超过限制) | +| `responseData` | 各工作流节点详细响应(仅 `detail=true` 时返回) | +| `newVariables` | 工作流执行后更新的变量(仅 `detail=true` 时返回) | + +#### 2.3.3 流式响应(Server-Sent Events) + +当 `stream=true` 时,响应为 SSE 格式,包含多个事件: + +``` +data: {"event":"answer","text":"您好"} +data: {"event":"answer","text":"!有什么"} +data: [DONE] +``` + +**事件类型**: + +| 事件 | 说明 | +|------|------| +| `answer` | 增量回答文本(兼容 OpenAI `choices[].delta.content`) | +| `fastAnswer` | 直接回答文本(无动画效果) | +| `flowNodeStatus` | 节点执行状态变更 | +| `flowNodeResponse` | 节点响应数据 | +| `toolCall` | Tool 调用信息 | +| `toolParams` | Tool 参数 | +| `toolResponse` | Tool 响应 | +| `flowResponses` | 工作流节点详细响应(`detail=true` 时) | +| `updateVariables` | 工作流变量更新 | +| `interactive` | 命中交互节点(需用户交互) | +| `error` | 错误信息 | +| `workflowDuration` | 工作流执行耗时 | +| `plan` | Agent 计划响应 | +| `stepTitle` | Agent 步骤标题 | +| `sandboxStatus` | 沙箱生命周期状态 | +| `skillCall` | 技能调用通知(SKILL.md 加载时) | +| `collectionForm` | HelperBot 集合表单 | +| `topAgentConfig` | TopAgent 配置表单 | +| `[DONE]` | 流式结束标记 | + +#### 2.3.4 FastGPT 扩展参数 + +以下参数为 FastGPT 扩展,OpenAI SDK 可能不支持,需自定义 HTTP 请求: + +```python +# Python 示例(使用自定义 HTTP 请求) +import requests +import json + +response = requests.post( + 'https://your-domain/api/v1/chat/completions', + headers={ + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + json={ + 'model': 'app-id-xxx', + 'messages': [{'role': 'user', 'content': '你好'}], + 'stream': False, + 'variables': {'user_name': 'Alice'}, # 工作流变量 + 'detail': True, # 返回详细信息 + 'metadata': {'user_id': '123'} # 自定义元数据 + } +) +``` + +### 2.4 对话管理接口 + +#### 2.4.1 获取对话历史列表 + +**路径**:`POST /api/core/chat/history/getHistories` + +**请求体**: +```json +{ + "appId": "", + "offset": 0, + "pageSize": 20, + "source": "online", + "startCreateTime": "2024-01-01T00:00:00Z", + "endCreateTime": "2024-12-31T23:59:59Z", + "startUpdateTime": "2024-01-01T00:00:00Z", + "endUpdateTime": "2024-12-31T23:59:59Z" +} +``` + +**字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `appId` | string | 否 | 应用 ID;可通过 Authorization header 自动指定 | +| `offset` | number | 否 | 偏移量(与 `pageNum` 二选一) | +| `pageNum` | number | 否 | 页码(与 `offset` 二选一) | +| `pageSize` | number | 否 | 每页条数 | +| `source` | string | 否 | 对话来源:`online`(在线)、`api`、`share`、`team` | +| `startCreateTime` | string | 否 | 创建时间起始(ISO 8601) | +| `endCreateTime` | string | 否 | 创建时间截止 | +| `startUpdateTime` | string | 否 | 更新时间起始 | +| `endUpdateTime` | string | 否 | 更新时间截止 | +| `shareId` | string | 否 | 分享链接认证 ID | +| `outLinkUid` | string | 否 | 分享链接用户标识 | + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "total": 10, + "list": [ + { + "chatId": "chat-uuid", + "updateTime": "2024-01-15T10:30:00.000Z", + "appId": "app-id", + "customTitle": "自定义标题", + "title": "用户初次提问", + "top": false, + "chatGenerateStatus": 1, + "hasBeenRead": false + } + ] + } +} +``` + +#### 2.4.2 获取对话详情(消息记录) + +**路径**:`POST /api/core/chat/record/getRecords_v2` + +**请求体**: +```json +{ + "chatId": "", + "pageSize": 50, + "initialId": "" +} + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "list": [ + { + "id": "record-id", + "role": "user", + "content": "你好", + "createTime": "2024-01-15T10:30:00.000Z" + }, + { + "id": "record-id-2", + "role": "assistant", + "content": "您好!有什么我可以帮助您的吗?", + "createTime": "2024-01-15T10:30:05.000Z" + } + ], + "total": 2, + "hasMorePrev": false, + "hasMoreNext": false, + "goodTotal": 0, + "badTotal": 0, + "notFoundTotal": 0 + } +} +``` + +#### 2.4.3 删除对话历史 + +**路径**:`POST /api/core/chat/history/delHistory` + +**请求参数(Query)**: +```query +?appId=&chatId= +``` + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "message": "", + "data": null +} +``` + +#### 2.4.4 更新对话标题/置顶 + +**路径**:`POST /api/core/chat/history/updateHistory` + +**请求体**: +```json +{ + "appId": "", + "chatId": "chat-id", + "title": "新标题", + "customTitle": "自定义标题", + "top": true +} +``` + +#### 2.4.5 获取单条消息的节点响应数据 + +**路径**:`GET /api/core/chat/record/getResData` + +**请求参数**: +```query +?appId=&chatId=&dataId= +``` + +**说明**:获取指定消息的详细工作流执行结果,包括各节点输出、引用数据等。 + +#### 2.4.6 获取消息的知识库引用 + +**路径**:`POST /api/core/chat/record/getQuote` + +**请求体**: +```json +{ + "appId": "", + "chatId": "", + "chatItemDataId": "", + "datasetDataIdList": [""], + "collectionIdList": [""] +} + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": [ + { + "_id": "data-uuid", + "q": "产品特性描述...", + "a": "补充回答文本...", + "imagePreivewUrl": "https://cdn.example.com/images/xxx.png", + "history": [], + "updateTime": "2024-01-15T10:30:00.000Z", + "index": 0, + "updated": false + } + ] +} +``` + +#### 2.4.7 初始化聊天 + +**路径**:`GET /api/core/chat/init` + +**请求参数**: +```query +?appId=&chatId=&loadCustomFeedbacks=false +``` + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "chatId": "chat-uuid", + "appId": "app-id", + "title": "对话标题", + "variables": {}, + "chatGenerateStatus": 1, + "hasBeenRead": true, + "userAvatar": "/icon/logo.svg", + "app": { + "name": "应用名称", + "avatar": "/icon/logo.svg", + "intro": "应用简介", + "type": "simple", + "chatConfig": {}, + "chatModels": ["gpt-4"], + "canUse": true, + "pluginInputs": [] + } + } +} +``` + +**说明**:初始化聊天会话,返回应用配置和会话状态。`chatGenerateStatus` 为 `0`(generating)时表示对话仍在生成中。 + +#### 2.4.8 恢复流式响应 + +**路径**:`GET /api/core/chat/resume` + +**请求参数**: +```query +?appId=&chatId= +``` + +**请求头**: +```http +Accept: text/event-stream +``` + +**说明**:恢复被中断的流式对话。已完成对话返回 JSON,仍生成中则返回 SSE 流。需在请求头中声明 `Accept: text/event-stream`,否则生成中的对话会返回 406。 + +#### 2.4.9 批量获取对话状态 + +**路径**:`POST /api/core/chat/history/getHistoryStatus` + +**请求体**: +```json +{ + "appId": "", + "chatIds": ["chat-id-1", "chat-id-2"] +} +``` + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "list": [ + { + "chatId": "chat-id-1", + "updateTime": "2024-01-15T10:30:00.000Z", + "chatGenerateStatus": 1, + "hasBeenRead": true + } + ] + } +} +``` + +**说明**:批量查询对话的生成状态和已读状态,用于侧栏轻量轮询同步。单次最多查询 200 个对话 ID。 + +#### 2.4.10 批量删除对话历史 + +**路径**:`POST /api/core/chat/history/batchDelete` + +**请求体**: +```json +{ + "appId": "", + "chatIds": ["chat-id-1", "chat-id-2"] +} +``` + +**说明**:批量真实删除指定应用的多个对话记录,需应用日志管理权限。 + +### 2.5 语音转写接口 + +**路径**:`POST /api/v1/audio/transcriptions` + +**请求格式**:`multipart/form-data` + +**字段**: +- `file`:音频文件(WAV、MP3 等) +- `appId`:应用 ID +- `duration`:音频时长(秒),用于计费 +- `shareId`、`outLinkUid`、`teamId`、`teamToken`:可选认证字段 + +**响应**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "text": "转写出来的文本内容" + } +} +``` + +**特性**: +- IP 频率限制:同一 IP 每秒最多 1 次转写 +- 使用默认 STT 模型 +- 按 `duration` 计费 + +--- + +## 三、文件上传接入 + +### 3.1 获取上传预签名 URL + +FastGPT 使用 S3 兼容协议管理文件,支持通过预签名 URL 进行安全上传。 + +#### 3.1.1 临时文件上传预签名 + +**路径**:`POST /api/common/file/presignTempFilePostUrl` + +**请求体**: +```json +{ + "filename": "document.pdf" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "url": "https://s3.example.com/temp/...", + "key": "temp/xxx/document.pdf", + "headers": { + "Content-Type": "application/octet-stream" + }, + "maxSize": 10485760 + } +} +``` + +**使用步骤**: +1. 调用该接口获取预签名 URL +2. 使用 `PUT` 请求将文件上传到返回的 URL +3. 获得 `key`,用于后续知识库导入 +#### 3.1.1.2 临时文件下载预签名 + +**路径**:`GET /api/common/file/presignTempFileGetUrl` + +**请求参数**: +```query +?key= +``` + +**响应示例**: +```json +{ + "code": 200, + "statusText": "", + "data": { + "url": "https://s3.example.com/temp/..." + } +} +``` + +**说明**:获取临时文件的下载预签名 URL,用于通过 `GET` 请求直接下载文件。 + +#### 3.1.2 知识库文件上传预签名 + +**路径**:`POST /api/core/dataset/file/presignDatasetFilePostUrl` + +**请求体**: +```json +{ + "datasetId": "dataset-id", + "filename": "manual.pdf" +} +``` + +**响应格式同上**。 + +#### 3.1.3 上传频率限制 + +- **限制**:30 秒窗口内,按团队套餐配置限制上传次数 +- **超限响应**:HTTP 429,`code: 429`,`message: "uploadFileIntervalLimit"` + +--- + +## 四、MCP 服务器接入 + +### 4.1 概述 + +FastGPT MCP(Model Context Protocol)服务器允许外部 AI 工具或 Agent 通过标准化协议调用 FastGPT 的工具、API 等功能。 + +**项目位置**:`FastGPT/projects/mcp_server/` + +### 4.2 启动 MCP 服务器 + +#### 4.2.1 本地启动(开发环境) + +```bash +cd projects/mcp_server +pnpm install +pnpm dev +``` + +服务将在 `http://localhost:3000` 启动。 + +#### 4.2.2 生产部署 + +```bash +cd projects/mcp_server +pnpm install +bun run build +bun start +``` + +或使用 Docker: +```bash +docker run -p 3000:3000 fastgpt-mcp-server +``` + +### 4.3 协议详解 + +#### 4.3.1 连接建立 + +MCP 使用 SSE(Server-Sent Events)作为传输协议。 + +**客户端连接**: +``` +GET //sse +``` + +服务器返回 SSE 流,建立双向通信。 + +#### 4.3.2 工具发现 + +**请求**(JSON-RPC): +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" +} +``` + +**响应**: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + { + "name": "search_dataset", + "description": "在知识库中搜索文档", + "inputSchema": { + "type": "object", + "properties": { + "datasetId": {"type": "string"}, + "query": {"type": "string"} + }, + "required": ["datasetId", "query"] + } + } + ] + } +} +``` + +#### 4.3.3 工具调用 + +**请求**(JSON-RPC): +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "search_dataset", + "arguments": { + "datasetId": "ds-xxx", + "query": "产品介绍" + } + } +} +``` + +**响应**: +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "搜索结果..." + } + ], + "isError": false + } +} +``` + +### 4.4 集成示例(Claude.ai) + +在 Claude 的自定义指令中配置 MCP 服务器: + +```json +{ + "type": "server", + "url": "http://your-domain:3000", + "auth": { + "type": "apiKey", + "apiKey": "fastgpt-xxxxx" + } +} +``` + +Claude 将自动发现并调用 FastGPT 的所有工具。 + +--- + +## 五、常见接入场景示例 + +### 5.1 场景一:Web 应用集成(使用 OpenAI SDK) + +**目标**:在现有 Web 应用中集成 FastGPT 对话功能 + +**Python 示例**: +```python +from openai import OpenAI + +client = OpenAI( + api_key="fastgpt-xxxxx", + base_url="https://your-domain/api/v1" +) + +# 创建对话 +response = client.chat.completions.create( + model="app-id-xxx", # FastGPT 应用 ID + messages=[ + {"role": "user", "content": "介绍一下你的产品"} + ], + stream=False +) + +print(response.choices[0].message.content) + +# 续接历史对话 +response2 = client.chat.completions.create( + model="app-id-xxx", + messages=[ + {"role": "user", "content": "能给出价格吗?"} + ], + extra_body={ + "chatId": response.id, # 使用前一轮的 chatId + "variables": {"currency": "CNY"} + } +) +``` + +**JavaScript 示例**: +```javascript +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: "fastgpt-xxxxx", + baseURL: "https://your-domain/api/v1", + defaultHeaders: { + 'Authorization': 'Bearer fastgpt-xxxxx' + } +}); + +async function chat() { + const response = await client.chat.completions.create({ + model: "app-id-xxx", + messages: [{ role: "user", content: "你好" }], + stream: false + }); + + console.log(response.choices[0].message.content); + return response.id; // chatId 用于续接 +} + +chat(); +``` + +**Node.js(自定义 HTTP 请求)**: +```javascript +const fetch = require('node-fetch'); + +async function callFastGPT() { + const response = await fetch('https://your-domain/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'app-id-xxx', + messages: [{ role: 'user', content: '你好' }], + stream: false, + variables: { user_id: '123' } + }) + }); + + const data = await response.json(); + console.log(data.choices[0].message.content); +} + +callFastGPT(); +``` + +### 5.2 场景二:流式对话响应(Web 前端实时输出) + +**JavaScript/React 示例**: +```javascript +async function streamChat(messages, appId, chatId) { + const response = await fetch('https://your-domain/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: appId, + messages, + stream: true, + chatId + }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + let newChatId = null; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = JSON.parse(line.slice(6)); + + if (json.event === 'answer') { + fullContent += json.text; + // 实时更新 UI + setResponseText(fullContent); + } else if (json.event === '[DONE]') { + newChatId = json.chatId; + } + } + } + } + } finally { + reader.releaseLock(); + } + + return { content: fullContent, chatId: newChatId }; +} +``` + +### 5.3 场景三:知识库查询(RAG 场景) + +**Python 示例**: +```python +import requests + +def search_knowledge_base(dataset_id, query): + """在知识库中搜索相关文档""" + response = requests.post( + 'https://your-domain/api/core/dataset/searchTest', + headers={ + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + json={ + 'datasetId': dataset_id, + 'text': query, + 'limit': 5 + } + ) + + results = response.json()['data'] + return [{'q': item['q'], 'a': item.get('a', '')} for item in results] + +# 结合知识库搜索和对话 +def rag_chat(query, app_id, dataset_id): + # 搜索知识库 + docs = search_knowledge_base(dataset_id, query) + + # 构造 prompt + context = '\n'.join([f"Q: {d['q']}\nA: {d['a']}" for d in docs]) + messages = [ + {'role': 'system', 'content': f'参考资料:\n{context}'}, + {'role': 'user', 'content': query} + ] + + # 调用应用 + response = requests.post( + 'https://your-domain/api/v1/chat/completions', + headers={ + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + json={ + 'model': app_id, + 'messages': messages + } + ) + + return response.json()['choices'][0]['message']['content'] +``` + +### 5.4 场景四:分享链接集成(外部嵌入无认证) + +**场景**:在外部网站嵌入 FastGPT 对话框,无需用户登录 + +**HTML/JavaScript 示例**: +```html + + + + + + +
+ + + + +``` + +### 5.5 场景五:工作流执行与变量传递 + +**Python 示例**: +```python +import requests + +def execute_workflow(app_id, user_input, variables=None): + """执行工作流并传递动态变量""" + response = requests.post( + 'https://your-domain/api/v1/chat/completions', + headers={ + 'Authorization': 'Bearer fastgpt-xxxxx', + 'Content-Type': 'application/json' + }, + json={ + 'model': app_id, + 'messages': [{'role': 'user', 'content': user_input}], + 'variables': variables or {}, + 'detail': True # 获取详细节点执行信息 + } + ) + + data = response.json() + return { + 'answer': data['choices'][0]['message']['content'], + 'node_responses': data.get('responseData', []), + 'new_variables': data.get('newVariables', {}) + } + +# 示例:订单处理工作流 +result = execute_workflow( + app_id='order-processor-app-id', + user_input='我想下单', + variables={ + 'user_id': 'user-123', + 'product_id': 'prod-456', + 'quantity': 2, + 'currency': 'CNY' + } +) + +print(f"回复: {result['answer']}") +print(f"订单号: {result['new_variables'].get('order_id')}") +``` + +--- + +## 六、SDK 和工具 + +### 6.1 官方 OpenAI SDK(推荐) + +由于 FastGPT v1 API 完全兼容 OpenAI ChatCompletion,可直接使用官方 SDK: + +**Python - openai**: +```bash +pip install openai +``` + +```python +from openai import OpenAI + +client = OpenAI( + api_key="fastgpt-xxxxx", + base_url="https://your-domain/api/v1" +) + +response = client.chat.completions.create( + model="app-id-xxx", + messages=[{"role": "user", "content": "hello"}] +) +``` + +**JavaScript - openai**: +```bash +npm install openai +``` + +```javascript +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: "fastgpt-xxxxx", + baseURL: "https://your-domain/api/v1" +}); + +const response = await client.chat.completions.create({ + model: "app-id-xxx", + messages: [{ role: "user", content: "hello" }] +}); +``` + +**Go - go-openai**: +```bash +go get github.com/sashabaranov/go-openai +``` + +```go +import "github.com/sashabaranov/go-openai" + +config := openai.DefaultConfig("fastgpt-xxxxx") +config.BaseURL = "https://your-domain/api/v1" + +client := openai.NewClientWithConfig(config) +resp, _ := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{ + Model: "app-id-xxx", + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "hello"}, + }, +}) +``` + +### 6.2 FastGPT 特定扩展 + +对于 FastGPT 专有参数(如 `variables`、`detail`),需要直接使用 HTTP 请求或自定义 SDK。 + +**推荐方案**:使用 OpenAI SDK 的 `extra_body` 参数传递扩展字段: + +```python +response = client.chat.completions.create( + model="app-id-xxx", + messages=[{"role": "user", "content": "hello"}], + extra_body={ + "variables": {"key": "value"}, + "detail": True + } +) +``` + +### 6.3 cURL 命令行示例 + +**基础聊天**: +```bash +curl -X POST "https://your-domain/api/v1/chat/completions" \ + -H "Authorization: Bearer fastgpt-xxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "app-id-xxx", + "messages": [{"role": "user", "content": "你好"}], + "stream": false + }' +``` + +**流式响应**: +```bash +curl -X POST "https://your-domain/api/v1/chat/completions" \ + -H "Authorization: Bearer fastgpt-xxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "app-id-xxx", + "messages": [{"role": "user", "content": "你好"}], + "stream": true + }' +``` + +**文件上传**: +```bash +# 1. 获取预签名 URL +curl -X POST "https://your-domain/api/common/file/presignTempFilePostUrl" \ + -H "Authorization: Bearer fastgpt-xxxxx" \ + -H "Content-Type: application/json" \ + -d '{"filename": "document.pdf"}' + +# 2. 上传文件到返回的 URL +curl -X PUT "https://s3.example.com/temp/..." \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@document.pdf" +``` + +--- + +## 七、速率限制与配额 + +### 7.1 限制类型 + +| 限制 | 默认策略 | 超限响应 | +|------|---------|---------| +| **聊天频率(团队)** | 按套餐 `chatRequestPerMinute` | HTTP 429, code 429 | +| **IP 转写频率** | 1 次/秒 | HTTP 429, code 429 | +| **文件上传频率** | 30 秒窗口按套餐限制 | HTTP 429, code 429 | +| **账户额度** | 按套餐余额 | HTTP 510, code 510 | +| **模型授权** | 只能使用授权模型 | HTTP 511, code 511 | + +### 7.2 配额不足响应 + +```json +{ + "code": 510, + "statusText": "insufficientQuota", + "message": "额度不足,请充值或升级套餐", + "data": null +} +``` + +### 7.3 模型未授权响应 + +```json +{ + "code": 511, + "statusText": "unAuthModel", + "message": "未授权使用此模型", + "data": null +} +``` + +--- + +## 八、错误处理 + +### 8.1 常见错误码 + +| HTTP | Code | 说明 | 解决方案 | +|------|------|------|---------| +| 200 | 200 | 成功 | - | +| 400 | 400 | 参数验证失败(Zod Schema) | 检查请求参数格式 | +| 401 | 401 | 未认证 | 检查 Authorization 请求头 | +| 403 | 403 | 权限不足 | 确认 API Key 有应用访问权限 | +| 404 | 404 | 资源不存在 | 检查 appId、chatId 是否正确 | +| 422 | 422 | 业务参数错误 | 检查业务逻辑参数 | +| 429 | 429 | 频率限制 | 等待后重试,或升级套餐 | +| 500 | 500 | 服务端错误 | 查看服务日志 | +| 510 | 510 | 额度不足 | 充值或升级套餐 | +| 511 | 511 | 未授权模型 | 在管理后台授权模型 | +| 514 | 514 | API Key 无效 | 检查 API Key 是否正确/过期 | + +### 8.2 错误响应格式 + +```json +{ + "code": 514, + "statusText": "unAuthApiKey", + "message": "API key 无效或已被禁用", + "data": null +} +``` + +### 8.3 调试建议 + +1. **启用日志**:在应用配置中启用 API 日志 +2. **查看聊天记录**:在 FastGPT Web 应用的 **日志** 面板查看完整请求/响应 +3. **使用 curl 测试**:先用 cURL 验证 API 连通性 +4. **检查 API Key**:确认 Key 未过期且有正确权限 + +--- + +## 九、安全建议 + +### 9.1 API Key 管理 + +- **不要在前端代码中硬编码** API Key,改用后端代理 +- **定期轮换** API Key(建议每 90 天) +- **使用应用级 Key**(而非团队级 Key),限制权限范围 +- **启用 IP 白名单**(在创建 Key 时配置) + +### 9.2 数据隐私 + +- 敏感信息(如用户隐私数据)不应在 `metadata` 中传递 +- 启用知识库**访问控制**,限制数据访问范围 +- 定期审计 API Key 的使用日志 +- 对接工作流中的敏感操作(如删除数据),需在应用层额外验证 + +--- + +## 十、常见问题 + +### Q1:可以使用 OpenAI SDK 吗? + +**A**:可以。FastGPT v1 API 完全兼容 OpenAI ChatCompletion,只需配置 `base_url` 指向 FastGPT 服务器,使用 `fastgpt-xxxxx` 替代 OpenAI API Key。 + +### Q2:如何创建新的对话? + +**A**:每次请求不传 `chatId` 或传递空值,系统自动创建新对话。 + +### Q3:如何续接历史对话? + +**A**:在请求体中传递 `chatId`(来自前一轮响应的 `id` 字段),系统自动加载历史消息。 + +### Q4:流式响应如何解析? + +**A**:响应为 SSE 格式,逐行解析。每行以 `data:` 前缀开头,JSON 格式。当收到 `data: [DONE]` 时流式结束。 + +### Q5:如何传递工作流变量? + +**A**:在请求体的 `variables` 字段传递 JSON 对象,工作流可通过**全局变量**节点访问。 + +### Q6:分享链接和 API Key 有什么区别? + +**A**: +- **API Key**:需认证,权限由 Key 配置决定,适合内部系统/后端 +- **分享链接**:无需认证,权限由分享链接配置决定,适合外部嵌入/临时访问 + +### Q7:如何获取详细的工作流执行结果? + +**A**:在请求体中设置 `detail: true`,响应将包含 `responseData`(各节点执行结果)和 `newVariables`(工作流更新的变量)。 + +### Q8:MCP 服务器有什么用途? + +**A**:MCP 服务器允许外部 AI 工具(如 Claude)通过标准协议调用 FastGPT 的工具、API,实现 Agent 扩展和工具集成。 + +--- + +## 附录:完整请求示例 + +### Python - 完整示例 + +```python +import requests +import json +import time + +class FastGPTClient: + def __init__(self, api_key, base_url): + self.api_key = api_key + self.base_url = base_url + self.headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + def chat(self, app_id, messages, stream=False, variables=None, chat_id=None): + """调用聊天接口""" + payload = { + 'model': app_id, + 'messages': messages, + 'stream': stream, + 'variables': variables or {}, + } + if chat_id: + payload['chatId'] = chat_id + + response = requests.post( + f'{self.base_url}/v1/chat/completions', + headers=self.headers, + json=payload, + stream=stream + ) + + if stream: + return self._parse_stream(response) + else: + return response.json() + + def _parse_stream(self, response): + """解析 SSE 流式响应""" + full_text = '' + for line in response.iter_lines(): + if line: + line = line.decode('utf-8') + if line.startswith('data: '): + data = json.loads(line[6:]) + if data.get('event') == 'answer': + full_text += data.get('text', '') + elif data.get('event') == '[DONE]': + break + return full_text + + def get_histories(self, app_id, limit=20): + """获取对话历史""" + response = requests.post( + f'{self.base_url}/core/chat/history/getHistories', + headers=self.headers, + json={'appId': app_id, 'pageSize': limit} + ) + return response.json() + +# 使用示例 +client = FastGPTClient( + api_key='fastgpt-xxxxx', + base_url='https://your-domain/api' +) + +# 创建新对话 +response = client.chat( + app_id='app-id-xxx', + messages=[{'role': 'user', 'content': '你好'}] +) +print(response['choices'][0]['message']['content']) + +# 续接对话 +response2 = client.chat( + app_id='app-id-xxx', + messages=[{'role': 'user', 'content': '能给出建议吗?'}], + chat_id=response['id'] # 使用前一轮的 chatId +) +print(response2['choices'][0]['message']['content']) +``` + +### Node.js - 完整示例 + +```javascript +class FastGPTClient { + constructor(apiKey, baseUrl) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + async chat(appId, messages, options = {}) { + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: appId, + messages, + stream: options.stream || false, + variables: options.variables || {}, + chatId: options.chatId || undefined + }) + }); + + if (options.stream) { + return this.parseStream(response); + } else { + return response.json(); + } + } + + async parseStream(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)); + if (data.event === 'answer') { + fullText += data.text || ''; + } + } + } + } + } finally { + reader.releaseLock(); + } + + return fullText; + } + + async getHistories(appId, limit = 20) { + const response = await fetch( + `${this.baseUrl}/core/chat/history/getHistories`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ appId, pageSize: limit }) + } + ); + return response.json(); + } +} + +// 使用示例 +const client = new FastGPTClient( + 'fastgpt-xxxxx', + 'https://your-domain/api' +); + +// 创建新对话 +const response = await client.chat('app-id-xxx', [ + { role: 'user', content: '你好' } +]); +console.log(response.choices[0].message.content); + +// 流式对话 +const streamText = await client.chat('app-id-xxx', [ + { role: 'user', content: '写一首诗' } +], { stream: true }); +console.log(streamText); +``` + +--- + +**文档版本**:1.0 +**最后更新**:2024年 +**相关文件**: +- FastGPT/projects/app/src/pages/api/v1/chat/completions.ts +- FastGPT/packages/global/openapi/core/chat/completion/api.ts +- FastGPT/projects/mcp_server/src/index.ts +- FastGPT/doc/doc/kb/技术知识库/API索引.md diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..af81969ccf60 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/API\347\264\242\345\274\225.md" @@ -0,0 +1,920 @@ +--- +capability_label: null +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# API 索引 + +> 本文档梳理 FastGPT 主应用(`projects/app`)的 HTTP API 全景,包含对外的 OpenAI 兼容接口(v1)以及前后端共享的内部接口(core/common)。所有 API 路由文件位于 `FastGPT/projects/app/src/pages/api/`,遵循 Next.js Pages Router 文件路由约定。 + +--- + +## 一、API 架构概述 + +### 1.1 基础路径 + +- **API 根**:`/api/` +- **对外 OpenAI 兼容 API 根**:`/api/v1/`(保留与 OpenAI SDK 兼容的请求/响应结构) +- **应用层内部 API 根**:`/api/core/`(应用、聊天、知识库、Agent、工作流等) +- **公共能力 API 根**:`/api/common/`(文件、系统初始化、埋点) +- **AI Proxy 透传 API 根**:`/api/aiproxy/`(系统管理员将请求转发到外部 AI Proxy 服务) +- **OpenAPI 规范导出**:`/api/openapi.json` + +### 1.2 NextJS API Routes 架构 + +- 使用 Next.js Pages Router 的 `pages/api/**/*.ts` 文件路由:每个文件即一个端点。 +- 所有处理器统一通过 `@/service/middleware/entry` 中的 `NextAPI` 包裹,提供: + - 错误捕获与统一 `jsonRes` 响应格式 + - 请求日志、追踪 + - 频率限制中间件(`useIPFrequencyLimit`、`teamFrequencyLimit`) + - 鉴权(`authCert`、`authApp`、`authDataset`、`authSkill`、`authUserPer`、`authSystemAdmin`) +- 入参/出参基于 `zod` Schema 定义,统一存放于 `FastGPT/packages/global/openapi/`,并在 OpenAPI 文档(`/api/openapi.json`)中注册。 +- 入口文件示例: + - 主对外接口:`FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` + - 内部接口示例:`FastGPT/projects/app/src/pages/api/core/app/create.ts` + +### 1.3 认证方式 + +FastGPT 同时支持以下三类身份: + +| 身份类型 | 请求头 | 说明 | +|---------|-------|-----| +| Web 登录 Token(Cookie) | 浏览器自动携带 Cookie | 前端管理后台默认方式,对应 `authToken: true` | +| 团队 / 应用 API Key(Bearer) | `Authorization: Bearer fastgpt-xxxxxxx` | 对外接入推荐方式,对应 `authApiKey: true` | +| 分享链接 / 团队空间 Token | 在 Body 中传递 `shareId + outLinkUid` 或 `teamId + teamToken` | 用于嵌入式聊天与跨团队协作场景 | + +认证统一在 `FastGPT/packages/service/support/permission/auth/common.ts` 中通过 `authCert` 解析,并按需结合资源粒度的 `authApp` / `authDataset` / `authSkill` 等做权限校验。 + +### 1.4 通用请求 / 响应格式 + +- **请求格式**:`POST/PUT` 默认 `application/json`,文件上传使用 `multipart/form-data`(详见 `presign*` 系列)。 +- **请求体大小**:默认上限 20MB(如 `completions.ts` 中 `bodyParser.sizeLimit: '20mb'`)。 +- **响应格式**:统一通过 `@fastgpt/service/common/response` 中的 `jsonRes` 输出: + ```jsonc + { + "code": 200, // 业务码(200 表成功,其余见错误码表) + "message": "", // 错误信息(成功时为空) + "data": { /* ... */ } // 业务数据 + } + ``` +- **流式响应(SSE)**:`v1/chat/completions`、`core/chat/chatTest`、`core/workflow/debug` 等支持 `stream: true`,事件名见 `FastGPT/packages/global/core/workflow/runtime/constants.ts` 中的 `SseResponseEventEnum`(`answer` / `flowResponses` / `interactive` / `[DONE]` 等)。 + +### 1.5 错误码规范 + +错误码定义集中在 `FastGPT/packages/global/common/error/errorCode.ts`,并按业务模块拆分到 `code/app.ts`、`code/chat.ts`、`code/dataset.ts` 等子文件。常见 HTTP/业务码: + +| Code | 含义 | +|------|------| +| 200 | 成功 | +| 400 | 参数校验失败(Zod) | +| 401 | 未认证 | +| 403 | `unAuthorization`,权限不足 | +| 404 | 资源不存在 | +| 405 | 方法不支持 | +| 406 | 内容协商失败 / 请求格式不被服务端接受 | +| 410 | 资源已删除 | +| 422 | 业务参数错误 | +| 429 | `tooManyRequest` / `uploadFileIntervalLimit`,频率限制 | +| 500 | 服务端错误 | +| 502 | 网关错误 | +| 503 | 服务不可用 | +| 504 | 网关超时 | +| 510 | `insufficientQuota`,额度不足 | +| 511 | `unAuthModel`,未授权模型 | +| 513 | `unAuthFile`,未授权文件 | +| 514 | `unAuthApiKey`,API Key 无效 | + +业务级错误枚举定义见 `FastGPT/packages/global/common/error/code/*.ts`,例如 `ChatErrEnum.unAuthChat`、`SkillErrEnum.*`。 + +--- + +## 二、公开对外 API(v1,兼容 OpenAI) + +源码目录:`FastGPT/projects/app/src/pages/api/v1/` + +### 2.1 聊天补全(核心对外接口) + +- **路径**:`POST /api/v1/chat/completions` +- **源码**:`FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` +- **入参 Schema**:`CompletionsPropsSchema`(`FastGPT/packages/global/openapi/core/chat/completion/api.ts`) + +**请求体字段**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `messages` | `ChatCompletionMessage[]` | 是(普通对话) | OpenAI 消息列表(含 `role`、`content`,以及多模态扩展) | +| `stream` | `boolean` | 否 | 是否流式返回,默认 `false` | +| `chatId` | `string` | 否 | 会话 ID;传入则自动续接历史,未传视为新对话 | +| `appId` | `ObjectId` | 视情况 | 使用 Token Cookie 时必填;使用应用 API Key 时可不传 | +| `customUid` | `string` | 否 | 分享场景下自定义用户标识 | +| `shareId` + `outLinkUid` | `string` | 否 | 通过分享链接调用时使用 | +| `teamId` + `teamToken` | `string` | 否 | 团队空间 Token 调用 | +| `variables` | `Record` | 否 | 全局变量或工作流插件输入,默认 `{}` | +| `responseChatItemId` | `string` | 否 | 自定义响应消息 ID(不传自动生成 nanoid) | +| `detail` | `boolean` | 否 | 是否返回详细工作流响应(`responseData` / `newVariables`) | +| `retainDatasetCite` | `boolean` | 否 | 是否保留答案中的数据集引用标记 | +| `showSkillReferences` | `boolean` | 否 | 是否在响应中保留 Agent 技能引用 | +| `metadata` | `Record` | 否 | 自定义元数据,落库到聊天记录 | + +**响应格式**(非流式 `CompletionsResponseSchema`): + +```jsonc +{ + "id": "", + "model": "", + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 1 }, + "choices": [ + { + "message": { + "role": "assistant", + "content": "...", // detail=true 或工作流交互时为对象数组 + "reasoning_content": "..." // 仅推理模型 + }, + "finish_reason": "stop", + "index": 0 + } + ], + "responseData": [ /* 节点级详情,detail=true 时返回 */ ], + "newVariables": { /* 工作流执行后变量,detail=true 时返回 */ } +} +``` + +**流式响应说明**(`stream=true`): + +- 走 SSE,事件类型见 `SseResponseEventEnum`: + - `answer`:增量回答(兼容 OpenAI `choices[].delta.content`) + - `flowNodeStatus` / `toolCall` / `toolParams` / `toolResponse`:工作流节点状态 + - `flowResponses`:仅在 `detail=true` 时输出整轮节点响应数据 + - `interactive`:命中交互节点 + - 终止时输出 `data: [DONE]` +- 同时 `recordAppUsage`、`updateApiKeyUsage`、`addOutLinkUsage` 异步累计计费。 + +**鉴权流程**(同一文件内三种分支): + +- `shareId + outLinkUid` → `authShareChat` +- `teamId + teamToken + appId` → `authTeamSpaceChat` +- 其他 → `authHeaderRequest`(解析 Cookie 或 `Authorization: Bearer `) + +### 2.2 语音转写 + +- **路径**:`POST /api/v1/audio/transcriptions` +- **源码**:`FastGPT/projects/app/src/pages/api/v1/audio/transcriptions.ts` +- **请求**:`multipart/form-data`,字段含音频文件、`appId`、`duration`、可选 `shareId/outLinkUid/teamId/teamToken`。 +- **响应**:`{ data: }`,使用默认 STT 模型完成转写并按 `duration` 计费。 +- **特性**:IP 频率限制 1 秒 / 1 次(`useIPFrequencyLimit`)。 + +--- + +## 三、内部核心 API(按模块索引) + +### 3.1 应用管理(`/api/core/app/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/app/` + +| HTTP | 路径 | 功能 | 关键参数 | 响应 | +|------|------|------|----------|------| +| POST | `/api/core/app/create` | 创建应用(普通/工作流/插件) | `parentId`、`name`、`avatar`、`type`、`modules`、`edges`、`chatConfig`、`templateId` | `{ appId }` | +| POST | `/api/core/app/update` | 更新应用元数据/工作流/聊天配置 | `appId` 等 | 成功标识 | +| GET | `/api/core/app/detail` | 应用详情 | `appId` | `AppSchemaType` | +| GET | `/api/core/app/list` | 应用列表(含分页/筛选) | `parentId`、`type` | `AppListItem[]` | +| GET | `/api/core/app/getBasicInfo` | 应用基础信息 | `appId` | 简要信息 | +| GET | `/api/core/app/getAppDatasetCollection` | 关联数据集合列表 | `appId` | `Collection[]` | +| GET | `/api/core/app/getPermission` | 应用权限信息 | `appId` | 权限值 | +| POST | `/api/core/app/copy` | 复制应用 | `appId`、`name` | `{ appId }` | +| DELETE | `/api/core/app/del` | 删除应用 | `appId` | 成功标识 | +| POST | `/api/core/app/exportSkill` | 导出为 Agent Skill | `appId` | 包文件 | +| POST | `/api/core/app/transitionWorkflow` | 简易模式 → 工作流模式迁移 | `appId` | 工作流定义 | +| POST | `/api/core/app/resumeInheritPermission` | 恢复继承权限 | `appId` | 成功标识 | +| POST | `/api/core/app/folder/create` | 创建文件夹 | `name`、`parentId` | `{ folderId }` | +| GET | `/api/core/app/folder/path` | 文件夹路径面包屑 | `parentId` | 路径数组 | +| POST | `/api/core/app/version/publish` | 发布新版本 | `appId`、`versionName` | `{ versionId }` | +| GET | `/api/core/app/version/latest` | 最新版本 | `appId` | 版本数据 | +| GET | `/api/core/app/version/list` | 版本列表(分页) | `appId` | 版本数组 | +| GET | `/api/core/app/version/detail` | 版本详情 | `versionId` | 版本数据 | +| POST | `/api/core/app/version/update` | 更新版本(备注) | `versionId` | 成功标识 | +| POST | `/api/core/app/template/list` | 模板列表 | 类型筛选 | 模板数组 | +| GET | `/api/core/app/template/detail` | 模板详情 | `templateId` | 模板数据 | +| POST | `/api/core/app/httpTools/create` | 创建 HTTP 工具 | API Schema | `{ id }` | +| POST | `/api/core/app/httpTools/update` | 更新 HTTP 工具 | `id` 等 | 成功标识 | +| POST | `/api/core/app/httpTools/runTool` | 调试运行 HTTP 工具 | `id`、入参 | 工具结果 | +| GET | `/api/core/app/httpTools/getApiSchemaByUrl` | 通过 URL 解析 OpenAPI Schema | `url` | `OpenAPI` | +| POST | `/api/core/app/mcpTools/create` | 创建 MCP 工具 | MCP server 信息 | `{ id }` | +| POST | `/api/core/app/mcpTools/update` | 更新 MCP 工具 | `id` 等 | 成功标识 | +| GET | `/api/core/app/mcpTools/getTools` | 列出 MCP server 工具 | `serverUrl` | 工具数组 | +| GET | `/api/core/app/mcpTools/getChildren` | 获取子工具 | `parentId` | 工具数组 | +| POST | `/api/core/app/mcpTools/runTool` | 调试运行 MCP 工具 | `id`、入参 | 工具结果 | +| GET | `/api/core/app/tool/getSystemToolTemplates` | 系统工具模板 | 类型 | 模板数组 | +| GET | `/api/core/app/tool/getPreviewNode` | 预览工具节点定义 | `toolId` | 节点 | +| GET | `/api/core/app/tool/getVersionList` | 工具版本列表 | `toolId` | 版本数组 | +| GET | `/api/core/app/tool/path` | 工具分类路径 | `toolId` | 路径 | +| GET | `/api/core/app/logs/list` | 应用调用日志(分页) | `appId`、时间区间 | 日志数组 | +| GET | `/api/core/app/logs/exportLogs` | 导出日志 CSV | `appId`、时间区间 | 文件流 | +| GET | `/api/core/app/logs/getUsers` | 日志中的用户去重 | `appId` | 用户数组 | +| GET | `/api/core/app/logs/getLogKeys` | 日志可用筛选键 | `appId` | 字符串数组 | +| POST | `/api/core/app/logs/updateLogKeys` | 更新日志筛选键 | `appId`、`keys` | 成功标识 | + +### 3.2 聊天管理(`/api/core/chat/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/chat/` + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/chat/chatTest` | 工作流编辑器内调试聊天(流式 + 节点详情) | +| POST | `/api/core/chat/init` | 初始化聊天会话(加载 App 与历史) | +| POST | `/api/core/chat/team/init` | 团队空间下的会话初始化 | +| POST | `/api/core/chat/outLink/init` | 分享链接下的会话初始化 | +| GET | `/api/core/chat/getHistories` | 历史会话列表(旧版) | +| GET | `/api/core/chat/history/getHistories` | 历史会话列表(v2) | +| GET | `/api/core/chat/history/getHistoryStatus` | 单会话生成状态 | +| POST | `/api/core/chat/history/markRead` | 标记历史已读 | +| POST | `/api/core/chat/history/updateHistory` | 修改历史标题/置顶等 | +| POST | `/api/core/chat/history/delHistory` | 删除单条历史 | +| POST | `/api/core/chat/history/batchDelete` | 批量删除历史 | +| POST | `/api/core/chat/history/clearHistories` | 清空历史 | +| POST | `/api/core/chat/delHistory` | 兼容旧版的删除接口 | +| POST | `/api/core/chat/clearHistories` | 兼容旧版清空接口 | +| POST | `/api/core/chat/updateHistory` | 兼容旧版更新接口 | +| GET | `/api/core/chat/getPaginationRecords` | 分页拉取消息(旧) | +| GET | `/api/core/chat/record/getPaginationRecords` | 分页拉取消息(v2) | +| GET | `/api/core/chat/record/getRecords_v2` | 拉取消息(v2 完整) | +| GET | `/api/core/chat/record/getResData` | 单条消息节点响应数据 | +| GET | `/api/core/chat/record/getQuote` | 单条消息引用 | +| GET | `/api/core/chat/record/getCollectionQuote` | 集合维度引用 | +| GET | `/api/core/chat/record/getKeywordQuote` | 关键词引用 | +| GET | `/api/core/chat/record/getSpeech` | 文本转语音 | +| POST | `/api/core/chat/record/delete` | 删除消息 | +| GET | `/api/core/chat/getResData` | 兼容旧版节点响应数据 | +| GET | `/api/core/chat/quote/getQuote` | 兼容旧版引用查询 | +| GET | `/api/core/chat/assistant/getQuote` | Agent 助手引用 | +| GET | `/api/core/chat/assistant/getRetrievalResults` | Agent 检索结果 | +| POST | `/api/core/chat/feedback/updateUserFeedback` | 用户点赞/点踩 | +| POST | `/api/core/chat/feedback/closeCustom` | 关闭自定义反馈卡片 | +| POST | `/api/core/chat/feedback/adminUpdate` | 管理员更新反馈 | +| GET | `/api/core/chat/feedback/getFeedbackRecordIds` | 反馈消息 ID 列表 | +| POST | `/api/core/chat/feedback/updateFeedbackReadStatus` | 标记反馈已读 | +| POST | `/api/core/chat/correction/submit` | 提交答案纠正 | +| GET | `/api/core/chat/correction/list` | 纠正列表 | +| POST | `/api/core/chat/correction/delete` | 删除纠正 | +| POST | `/api/core/chat/correction/export` | 导出纠正数据 | +| POST | `/api/core/chat/file/presignChatFilePostUrl` | 获取聊天文件上传预签名 URL | +| GET | `/api/core/chat/file/presignChatFileGetUrl` | 获取聊天文件下载预签名 URL | +| GET | `/api/core/chat/inputGuide/list` | 输入引导词列表 | +| POST | `/api/core/chat/inputGuide/create` | 新增输入引导词 | +| POST | `/api/core/chat/inputGuide/update` | 更新输入引导词 | +| POST | `/api/core/chat/inputGuide/delete` | 删除输入引导词 | +| POST | `/api/core/chat/inputGuide/deleteAll` | 清空输入引导词 | +| GET | `/api/core/chat/inputGuide/query` | 查询匹配的引导词 | +| GET | `/api/core/chat/inputGuide/countTotal` | 引导词总数统计 | +| POST | `/api/core/chat/helperBot/completions` | Helper Bot 聊天 | +| GET | `/api/core/chat/helperBot/getRecords` | Helper Bot 历史 | +| POST | `/api/core/chat/helperBot/deleteRecord` | 删除 Helper Bot 记录 | +| POST | `/api/core/chat/helperBot/getFilePresign` | Helper Bot 文件上传预签名 | +| GET | `/api/core/chat/helperBot/getFilePreviewUrl` | Helper Bot 文件预览 URL | +| GET | `/api/core/chat/recentlyUsed` | 最近使用应用列表 | +| POST | `/api/core/chat/resume` | 流式中断后续传 | + +### 3.3 知识库管理(`/api/core/dataset/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/dataset/` + +#### 知识库(Dataset) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/dataset/create` | 创建知识库(含 API/网站/数据库等类型) | +| POST | `/api/core/dataset/createWithFiles` | 创建知识库并初始化文件 | +| GET | `/api/core/dataset/detail` | 知识库详情 | +| GET | `/api/core/dataset/list` | 知识库列表 | +| GET | `/api/core/dataset/listWithChildren` | 包含子文件夹的列表 | +| GET | `/api/core/dataset/paths` | 路径面包屑 | +| GET | `/api/core/dataset/apps` | 关联应用 | +| POST | `/api/core/dataset/update` | 更新知识库元数据 | +| DELETE | `/api/core/dataset/delete` | 删除知识库 | +| POST | `/api/core/dataset/exportAll` | 导出所有数据 | +| POST | `/api/core/dataset/searchTest` | 检索测试 | +| GET | `/api/core/dataset/getPermission` | 权限查询 | +| POST | `/api/core/dataset/resumeInheritPermission` | 恢复继承权限 | +| GET | `/api/core/dataset/getdefaultPrompt` | 默认 Prompt | +| POST | `/api/core/dataset/folder/create` | 新建文件夹 | + +#### 集合(Collection) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/dataset/collection/create` | 通用创建集合 | +| POST | `/api/core/dataset/collection/create/fileId` | 通过文件 ID 创建 | +| POST | `/api/core/dataset/collection/create/link` | 通过链接创建 | +| POST | `/api/core/dataset/collection/create/text` | 通过纯文本创建 | +| POST | `/api/core/dataset/collection/create/template` | 通过模板创建 | +| POST | `/api/core/dataset/collection/create/images` | 图片集合 | +| POST | `/api/core/dataset/collection/create/localFile` | 本地上传 | +| POST | `/api/core/dataset/collection/create/backup` | 从备份导入 | +| POST | `/api/core/dataset/collection/create/apiCollection` | 外部 API 集合 v1 | +| POST | `/api/core/dataset/collection/create/apiCollectionV2` | 外部 API 集合 v2 | +| POST | `/api/core/dataset/collection/create/reTrainingCollection` | 重新训练集合 | +| POST | `/api/core/dataset/collection/create/custom/fileId` | 自定义-文件 | +| POST | `/api/core/dataset/collection/create/custom/link` | 自定义-链接 | +| POST | `/api/core/dataset/collection/create/custom/website` | 自定义-站点 | +| POST | `/api/core/dataset/collection/create/custom/template` | 自定义-模板 | +| GET | `/api/core/dataset/collection/detail` | 集合详情 | +| GET | `/api/core/dataset/collection/list` | 集合列表 v1 | +| GET | `/api/core/dataset/collection/listV2` | 集合列表 v2 | +| GET | `/api/core/dataset/collection/scrollList` | 滚动列表 | +| GET | `/api/core/dataset/collection/paths` | 集合路径 | +| GET | `/api/core/dataset/collection/read` | 读取集合内容 | +| GET | `/api/core/dataset/collection/export` | 导出集合 | +| GET | `/api/core/dataset/collection/check/duplicate` | 重名检查 | +| GET | `/api/core/dataset/collection/trainingDetail` | 训练详情 | +| POST | `/api/core/dataset/collection/sync` | 同步集合(API/链接) | +| POST | `/api/core/dataset/collection/update` | 更新集合 | +| DELETE | `/api/core/dataset/collection/delete` | 删除集合 | +| POST | `/api/core/dataset/collection/resumeInheritPermission` | 恢复权限继承 | + +#### 数据项(Data) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/dataset/data/insertData` | 插入文本数据项 | +| POST | `/api/core/dataset/data/insertImages` | 插入图片数据项 | +| POST | `/api/core/dataset/data/pushData` | 批量推送(队列) | +| GET | `/api/core/dataset/data/list` | 数据项列表 v1 | +| GET | `/api/core/dataset/data/v2/list` | 数据项列表 v2 | +| GET | `/api/core/dataset/data/detail` | 数据项详情 | +| GET | `/api/core/dataset/data/getindex` | 索引信息 | +| GET | `/api/core/dataset/data/getQuoteData` | 引用上下文 | +| GET | `/api/core/dataset/data/getBatchPermission` | 批量权限校验 | +| POST | `/api/core/dataset/data/update` | 更新数据项 | +| DELETE | `/api/core/dataset/data/delete` | 删除数据项 | + +#### 训练(Training) + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/core/dataset/training/getDatasetTrainingQueue` | 训练队列状态 | +| GET | `/api/core/dataset/training/getTrainingDataDetail` | 训练数据详情 | +| GET | `/api/core/dataset/training/getTrainingError` | 错误数据列表 | +| POST | `/api/core/dataset/training/updateTrainingData` | 更新训练数据 | +| POST | `/api/core/dataset/training/deleteTrainingData` | 删除训练数据 | +| POST | `/api/core/dataset/training/rebuildEmbedding` | 重建向量索引 | + +#### 同义词 / 文件 / API 数据源 / 数据库类型 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/dataset/synonym/upload` | 上传同义词 | +| GET | `/api/core/dataset/synonym/list` | 同义词列表 | +| GET | `/api/core/dataset/synonym/download` | 下载同义词 | +| POST | `/api/core/dataset/synonym/delete` | 删除同义词 | +| POST | `/api/core/dataset/file/getPreviewChunks` | 上传后切片预览 | +| POST | `/api/core/dataset/file/presignDatasetFilePostUrl` | 知识库文件上传预签名 | +| GET | `/api/core/dataset/apiDataset/list` | 外部 API 数据集列表 | +| GET | `/api/core/dataset/apiDataset/getCatalog` | 外部 API 目录树 | +| GET | `/api/core/dataset/apiDataset/getPathNames` | 外部 API 路径名称 | +| GET | `/api/core/dataset/apiDataset/listExistId` | 已导入的 ID 列表 | +| POST | `/api/core/dataset/database/checkConnection` | 数据源连接测试 | +| GET | `/api/core/dataset/database/getConfiguration` | 配置查询 | +| POST | `/api/core/dataset/database/createCollections` | 批量建集合 | +| POST | `/api/core/dataset/database/createStructureCollection` | 建结构化集合 | +| POST | `/api/core/dataset/database/detectChanges` | 检测数据变更 | +| POST | `/api/core/dataset/database/applyChanges` | 应用变更 | +| POST | `/api/core/dataset/database/preview` | 数据预览 | +| POST | `/api/core/dataset/database/searchTest` | 数据库检索测试 | + +### 3.4 Agent 技能(`/api/core/agentSkills/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/agentSkills/` + +| HTTP | 路径 | 功能 | 关键参数 | +|------|------|------|---------| +| POST | `/api/core/agentSkills/create` | 新建 Agent Skill(含 LLM 生成 Markdown 描述与 zip 包) | `parentId`、`name`、`description`、`requirements`、`model`、`category`、`config`、`avatar` | +| POST | `/api/core/agentSkills/update` | 更新技能元数据 | `id`、待更新字段 | +| POST | `/api/core/agentSkills/edit` | 编辑技能(草稿态) | `id`、内容 | +| POST | `/api/core/agentSkills/save-deploy` | 保存并发布 | `id` | +| POST | `/api/core/agentSkills/copy` | 复制技能 | `id` | +| DELETE | `/api/core/agentSkills/delete` | 删除技能 | `id` | +| GET | `/api/core/agentSkills/detail` | 技能详情 | `id` | +| GET | `/api/core/agentSkills/list` | 技能列表(分页) | `parentId`、筛选项 | +| GET | `/api/core/agentSkills/apps` | 关联应用 | `id` | +| POST | `/api/core/agentSkills/import` | 上传 zip 包导入 | 文件流 | +| GET | `/api/core/agentSkills/export` | 导出 zip 包 | `id` | +| POST | `/api/core/agentSkills/resumeInheritPermission` | 恢复继承权限 | `id` | +| POST | `/api/core/agentSkills/folder/create` | 创建文件夹 | `name`、`parentId` | +| GET | `/api/core/agentSkills/folder/path` | 文件夹路径 | `parentId` | +| POST | `/api/core/agentSkills/debugChat` | 调试 Skill 对话(流式) | `skillId`、`messages`、`config` | +| GET | `/api/core/agentSkills/debugSession/list` | 调试会话列表 | `skillId` | +| POST | `/api/core/agentSkills/debugSession/delete` | 删除调试会话 | `sessionId` | +| GET | `/api/core/agentSkills/debugSession/records` | 调试会话消息 | `sessionId` | +| POST | `/api/core/agentSkills/debugSession/chatItem/delete` | 删除调试消息 | `chatItemId` | +| GET | `/api/core/agentSkills/version/list` | 版本列表 | `skillId` | +| POST | `/api/core/agentSkills/version/switch` | 切换版本 | `skillId`、`versionId` | +| POST | `/api/core/agentSkills/version/update` | 更新版本备注 | `versionId` | + +### 3.5 工作流(`/api/core/workflow/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/workflow/` + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/workflow/debug` | 单节点调试运行(接收 `nodes/edges/variables/query/history`,返回执行结果或流式事件) | +| GET | `/api/core/workflow/getSandboxPackages` | 获取代码节点可用 NPM 包白名单 | +| POST | `/api/core/workflow/optimizeCode` | 调用 LLM 优化代码节点 | + +> 注:工作流的「真正执行」入口是 `/api/v1/chat/completions` 与 `/api/core/chat/chatTest`;该目录主要服务于编辑器调试与代码节点辅助。 + +### 3.6 通用 API(`/api/common/`) + +源码目录:`FastGPT/projects/app/src/pages/api/common/` + +#### 文件(`/api/common/file/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/common/file/presignTempFilePostUrl` | 获取临时文件上传预签名 URL(30 秒频率限制) | +| GET | `/api/common/file/presignTempFileGetUrl` | 获取临时文件下载预签名 URL | +| POST | `/api/common/file/presignAvatarPostUrl` | 获取头像上传预签名 URL | +| GET | `/api/common/file/read/[filename]` | 直接读取临时文件(鉴权后) | + +#### 系统(`/api/common/system/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/common/system/getInitData` | 获取系统初始化数据(模型列表、功能开关、分享配置等) | +| POST | `/api/common/system/unlockTask` | 解锁卡死任务(系统管理员) | + +#### 埋点(`/api/common/tracks/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/common/tracks/push` | 上报前端埋点事件 | + +### 3.7 其他 + +| 路径 | 功能 | +|------|------| +| `GET /api/openapi.json` | 导出 OpenAPI 3.0 规范(聚合 `packages/global/openapi/` 下所有 zod schema) | +| `ALL /api/aiproxy/[...path]` | AI Proxy 透传,仅系统管理员可调用,转发到 `process.env.AIPROXY_API_ENDPOINT`,并自动注入 `Authorization: Bearer ${AIPROXY_API_TOKEN}` | +| `POST /api/aiproxy/api/createChannel` | AI Proxy 渠道创建(透传封装) | + +### 3.8 评测框架(`/api/core/evaluation/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/evaluation/` + +评测框架用于对知识库问答质量进行系统化评估,支持自定义评测指标、批量评测任务和结果汇总。 + +#### 评测数据集(`evaluation/dataset/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/evaluation/dataset/collection/create` | 创建评测集合 | +| GET | `/api/core/evaluation/dataset/collection/list` | 评测集合列表 | +| GET | `/api/core/evaluation/dataset/collection/listv2` | 评测集合列表 v2 | +| GET | `/api/core/evaluation/dataset/collection/detail` | 评测集合详情 | +| POST | `/api/core/evaluation/dataset/collection/update` | 更新评测集合 | +| DELETE | `/api/core/evaluation/dataset/collection/delete` | 删除评测集合 | +| POST | `/api/core/evaluation/dataset/collection/deleteTask` | 删除集合关联任务 | +| GET | `/api/core/evaluation/dataset/collection/failedTasks` | 失败任务列表 | +| POST | `/api/core/evaluation/dataset/collection/retryTask` | 重试单个任务 | +| POST | `/api/core/evaluation/dataset/collection/retryAllTask` | 重试全部失败任务 | +| POST | `/api/core/evaluation/dataset/collection/qualityAssessmentBatch` | 批量质量评估 | +| POST | `/api/core/evaluation/dataset/data/create` | 新增评测数据项 | +| GET | `/api/core/evaluation/dataset/data/list` | 评测数据项列表 | +| GET | `/api/core/evaluation/dataset/data/detail` | 评测数据项详情 | +| POST | `/api/core/evaluation/dataset/data/update` | 更新评测数据项 | +| DELETE | `/api/core/evaluation/dataset/data/delete` | 删除评测数据项 | +| POST | `/api/core/evaluation/dataset/data/import` | 批量导入评测数据 | +| POST | `/api/core/evaluation/dataset/data/smartGenerate` | LLM 智能生成评测数据 | +| POST | `/api/core/evaluation/dataset/data/qualityAssessment` | 单项质量评估 | + +#### 评测指标(`evaluation/metric/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/evaluation/metric/create` | 创建评测指标 | +| GET | `/api/core/evaluation/metric/list` | 评测指标列表 | +| GET | `/api/core/evaluation/metric/detail` | 评测指标详情 | +| POST | `/api/core/evaluation/metric/update` | 更新评测指标 | +| POST | `/api/core/evaluation/metric/debug` | 调试评测指标 | +| DELETE | `/api/core/evaluation/metric/delete` | 删除评测指标 | + +#### 评测任务(`evaluation/task/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/evaluation/task/create` | 创建评测任务 | +| GET | `/api/core/evaluation/task/list` | 评测任务列表 | +| GET | `/api/core/evaluation/task/detail` | 评测任务详情 | +| GET | `/api/core/evaluation/task/stats` | 评测任务统计 | +| POST | `/api/core/evaluation/task/start` | 启动评测任务 | +| POST | `/api/core/evaluation/task/stop` | 停止评测任务 | +| POST | `/api/core/evaluation/task/retryFailed` | 重试失败项 | +| POST | `/api/core/evaluation/task/update` | 更新评测任务 | +| DELETE | `/api/core/evaluation/task/delete` | 删除评测任务 | +| GET | `/api/core/evaluation/task/item/list` | 评测项列表 | +| GET | `/api/core/evaluation/task/item/detail` | 评测项详情 | +| POST | `/api/core/evaluation/task/item/retry` | 重试单个评测项 | +| POST | `/api/core/evaluation/task/item/update` | 更新评测项 | +| DELETE | `/api/core/evaluation/task/item/delete` | 删除评测项 | +| POST | `/api/core/evaluation/task/item/export` | 导出评测结果 | + +#### 评测汇总(`evaluation/summary/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/evaluation/summary/create` | 创建评测汇总 | +| GET | `/api/core/evaluation/summary/detail` | 评测汇总详情 | +| GET | `/api/core/evaluation/summary/config/detail` | 汇总配置详情 | +| POST | `/api/core/evaluation/summary/config/update` | 更新汇总配置 | + +### 3.9 插件管理(`/api/core/plugin/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/plugin/` + +管理 FastGPT 插件生态,支持管理员安装、解析、配置插件包,以及团队级插件的启用与查询。 + +#### 管理员插件管理(`plugin/admin/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/plugin/admin/installWithUrl` | 通过 URL 安装插件 | +| POST | `/api/core/plugin/admin/pkg/parse` | 解析插件包 | +| POST | `/api/core/plugin/admin/pkg/presign` | 获取插件包上传预签名 URL | +| POST | `/api/core/plugin/admin/pkg/confirm` | 确认安装插件包 | +| DELETE | `/api/core/plugin/admin/pkg/delete` | 删除插件包 | +| GET | `/api/core/plugin/admin/tool/list` | 插件工具列表 | +| GET | `/api/core/plugin/admin/tool/detail` | 插件工具详情 | +| POST | `/api/core/plugin/admin/tool/update` | 更新插件工具 | +| DELETE | `/api/core/plugin/admin/tool/delete` | 删除插件工具 | +| POST | `/api/core/plugin/admin/tool/updateOrder` | 更新插件工具排序 | +| POST | `/api/core/plugin/admin/tool/app/create` | 为插件创建应用 | +| GET | `/api/core/plugin/admin/tool/app/systemApps` | 插件关联系统应用列表 | +| POST | `/api/core/plugin/admin/tool/tag/create` | 创建插件标签 | +| POST | `/api/core/plugin/admin/tool/tag/update` | 更新插件标签 | +| DELETE | `/api/core/plugin/admin/tool/tag/delete` | 删除插件标签 | +| POST | `/api/core/plugin/admin/tool/tag/updateOrder` | 更新标签排序 | +| GET | `/api/core/plugin/admin/marketplace/installed` | 已安装插件市场列表 | + +#### 团队级插件(`plugin/team/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/core/plugin/team/list` | 团队插件列表 | +| GET | `/api/core/plugin/team/toolDetail` | 团队插件工具详情 | +| POST | `/api/core/plugin/team/toggleInstall` | 团队级启用/禁用插件 | + +#### 插件工具标签(`plugin/toolTag/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/core/plugin/toolTag/list` | 插件工具标签列表 | + +### 3.10 模型管理(`/api/core/ai/model/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/ai/model/` + +管理 AI 模型配置(LLM、Embedding、Rerank、STT 等),支持增删改查、连通性测试与默认模型设置。 + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/core/ai/model/list` | 模型列表(按类型筛选) | +| GET | `/api/core/ai/model/detail` | 模型详情 | +| POST | `/api/core/ai/model/update` | 更新模型配置 | +| DELETE | `/api/core/ai/model/delete` | 删除模型 | +| POST | `/api/core/ai/model/test` | 连通性测试 | +| GET | `/api/core/ai/model/getDefaultConfig` | 获取默认模型配置 | +| POST | `/api/core/ai/model/updateDefault` | 更新默认模型 | +| GET | `/api/core/ai/model/getConfigJson` | 导出模型配置 JSON | +| POST | `/api/core/ai/model/updateWithJson` | 通过 JSON 批量导入更新模型 | +| GET | `/api/core/ai/model/getMyModels` | 获取当前可用模型列表 | + +### 3.11 用户与团队管理(`/api/support/user/`) + +源码目录:`FastGPT/projects/app/src/pages/api/support/user/` + +处理用户认证、账户管理、团队配置及团队配额管控。 + +#### 账户管理(`user/account/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/user/account/preLogin` | 预登录(获取加密公钥) | +| POST | `/api/support/user/account/loginByPassword` | 密码登录 | +| POST | `/api/support/user/account/tokenLogin` | Token 免密登录(含重定向) | +| GET | `/api/support/user/account/tokenLoginRedirect` | Token 登录回调重定向 | +| POST | `/api/support/user/account/loginout` | 退出登录 | +| POST | `/api/support/user/account/update` | 更新账户信息 | +| POST | `/api/support/user/account/updatePasswordByOld` | 通过旧密码修改密码 | +| POST | `/api/support/user/account/resetExpiredPsw` | 重置过期密码 | +| POST | `/api/support/user/account/checkPswExpired` | 检查密码是否过期 | +| POST | `/api/support/user/account/adminGenerateToken` | 管理员为指定用户生成 Token | +| GET | `/api/support/user/account/adminGetUserTeams` | 管理员查询用户所属团队 | + +#### 团队管理(`user/team/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/user/team/update` | 更新团队信息 | +| GET | `/api/support/user/team/plan/getTeamPlanStatus` | 查询团队套餐状态与额度 | +| GET | `/api/support/user/team/limit/datasetSizeLimit` | 知识库大小限制 | +| GET | `/api/support/user/team/limit/evalDatasetDataLimit` | 评测数据集数据项限制 | +| GET | `/api/support/user/team/limit/evalDatasetLimit` | 评测数据集数量限制 | +| GET | `/api/support/user/team/limit/evalMetricLimit` | 评测指标数量限制 | +| GET | `/api/support/user/team/limit/evaluationTaskLimit` | 评测任务数量限制 | +| GET | `/api/support/user/team/limit/exportDatasetLimit` | 数据集导出限制 | +| GET | `/api/support/user/team/limit/webSyncLimit` | 网站同步限制 | +| POST | `/api/support/user/team/thirtdParty/checkUsage` | 第三方用量校验 | + +### 3.12 外部链接(`/api/support/outLink/`) + +源码目录:`FastGPT/projects/app/src/pages/api/support/outLink/` + +管理 FastGPT 对外分享的多种渠道(Feishu、DingTalk、WeChat、WeCom 等),支持创建分享链接、回调处理和渠道配置。 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/outLink/create` | 创建外部链接 | +| POST | `/api/support/outLink/update` | 更新外部链接 | +| GET | `/api/support/outLink/list` | 外部链接列表 | +| DELETE | `/api/support/outLink/delete` | 删除外部链接 | +| ALL | `/api/support/outLink/feishu/[token]` | 飞书渠道回调处理 | +| ALL | `/api/support/outLink/dingtalk/[token]` | 钉钉渠道回调处理 | +| ALL | `/api/support/outLink/wecom/[token]` | 企业微信渠道回调处理 | +| ALL | `/api/support/outLink/offiaccount/[token]` | 公众号渠道回调处理 | +| GET | `/api/support/outLink/playground/config` | Playground 配置 | +| POST | `/api/support/outLink/playground/update` | 更新 Playground 配置 | +| POST | `/api/support/outLink/wechat/qrcode/generate` | 生成微信扫码登录二维码 | +| GET | `/api/support/outLink/wechat/qrcode/status` | 轮询微信扫码状态 | +| POST | `/api/support/outLink/wechat/logout` | 注销微信 OutLink 会话 | + +### 3.13 MCP 服务器管理(`/api/support/mcp/`) + +源码目录:`FastGPT/projects/app/src/pages/api/support/mcp/` + +管理 MCP(Model Context Protocol)服务器配置,支持 MCP 工具发现和调用。 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/mcp/create` | 创建 MCP 服务器配置 | +| POST | `/api/support/mcp/update` | 更新 MCP 服务器配置 | +| GET | `/api/support/mcp/list` | MCP 服务器列表 | +| DELETE | `/api/support/mcp/delete` | 删除 MCP 服务器配置 | +| POST | `/api/support/mcp/server/toolList` | 列出 MCP 服务器可用工具 | +| POST | `/api/support/mcp/server/toolCall` | 调用 MCP 服务器工具 | + +### 3.14 外部 OpenAPI 数据源(`/api/support/openapi/`) + +源码目录:`FastGPT/projects/app/src/pages/api/support/openapi/` + +管理外部 OpenAPI 数据源的配置与健康检查,用于将第三方 API 接入 FastGPT 知识库。 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/openapi/create` | 创建外部数据源 | +| POST | `/api/support/openapi/update` | 更新外部数据源 | +| GET | `/api/support/openapi/list` | 外部数据源列表 | +| DELETE | `/api/support/openapi/delete` | 删除外部数据源 | +| POST | `/api/support/openapi/health` | 数据源健康检查 | + +### 3.15 管理迁移 API(`/api/admin/`) + +源码目录:`FastGPT/projects/app/src/pages/api/admin/` + +系统管理员专用的数据迁移与清理脚本,用于在版本升级时执行数据库迁移。 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/admin/initv4132` | v4.13.2 数据迁移 | +| POST | `/api/admin/initv4140` | v4.14.0 数据迁移 | +| POST | `/api/admin/initv4141` | v4.14.1 数据迁移 | +| POST | `/api/admin/initv4143` | v4.14.3 数据迁移 | +| POST | `/api/admin/initv4144` | v4.14.4 数据迁移 | +| POST | `/api/admin/initv4145` | v4.14.5 数据迁移 | +| POST | `/api/admin/initv41451` | v4.14.51 数据迁移 | +| POST | `/api/admin/initv4147` | v4.14.7 数据迁移 | +| POST | `/api/admin/sf-initv630` | v6.3.0 SF 版数据迁移 | +| POST | `/api/admin/migrateToMilvus26` | Milvus 2.6 迁移 | +| POST | `/api/admin/initFeedbackFlags` | 初始化反馈标记字段 | +| POST | `/api/admin/clearInvalidData` | 清理无效数据 | +| POST | `/api/admin/support/appRegistration/create` | 创建应用注册 | + +### 3.16 v2 API 与营销(`/api/v2/`、`/api/support/marketing/`) + +#### v2 聊天 API(`v2/chat/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/v2/chat/completions` | v2 版聊天补全(增强流式控制) | +| POST | `/api/v2/chat/stop` | 停止正在进行的聊天生成 | + +#### 营销(`support/marketing/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/marketing/fetchWorkflow` | 拉取营销工作流模板 | + +### 3.17 其他 API 模块 + +以下模块为代理透传或单一功能端点,源码存在于 `projects/app/src/pages/api/` 但由外部系统驱动: + +| 路径 | 功能 | +|------|------| +| `POST /api/plugin/getAccessToken` | 获取插件访问 Token(用于外部插件认证) | +| `POST /api/invoke/userInfo` | invoke 协议下查询调用用户信息 | +| `GET /api/system/file/[jwt]` | 通过 JWT 鉴权读取系统文件 | +| `GET /api/system/file/download/[token]` | 系统级文件下载(按 token 路径) | +| `GET /api/system/file/upload/[token]` | 系统级文件上传(按 token 路径) | +| `GET /api/system/img/[...id]` | 系统级图片访问 | +| `ALL /api/system/plugin/[...path]` | 系统插件代理 | +| `POST /api/support/wallet/usage/createTrainingUsage` | 创建训练用量记录 | +| `ALL /api/proApi/[...path]` | Pro API 透传代理(catch-all) | +| `ALL /api/lafApi/[...path]` | Laf API 透传代理(catch-all) | +| `ALL /api/marketplace/[...path]` | 插件市场 API 透传代理(catch-all) | + +--- + +## 四、API 认证说明 + +### 4.1 获取 API Key + +1. 登录 FastGPT,在 **账号 → API Key** 中签发团队级 Key(用于跨应用访问)。 +2. 在 **应用详情 → 接入 → API 接入** 中签发应用级 Key(更推荐,自动绑定 `appId`)。 +3. Key 形如 `fastgpt-xxxxxxxxxxxxx`;同一团队可创建多把 Key 并按用量记账。 + +### 4.2 请求头格式 + +```http +POST /api/v1/chat/completions HTTP/1.1 +Host: +Content-Type: application/json +Authorization: Bearer fastgpt-xxxxxxxxxxxxx +``` + +- 应用级 Key:可省略 `appId`,由 Key 绑定。 +- 团队级 Key:必须显式传 `appId`。 +- Web 浏览器:携带登录 Cookie 即可,无需 Bearer。 +- 分享链接:使用 `shareId + outLinkUid`,无需 Bearer,但需要在 Body 中传递。 + +### 4.3 鉴权失败响应 + +```json +{ "code": 514, "message": "API key 无效或已被禁用", "data": null } +``` + +参考 `FastGPT/packages/global/common/error/errorCode.ts` 的 `ERROR_RESPONSE` 表,`code` 为业务码,HTTP 状态默认 500,特殊情况下通过 `httpStatus` 字段覆盖。 + +--- + +## 五、速率限制和配额 + +| 维度 | 实现位置 | 默认策略 | +|------|---------|---------| +| 团队聊天 QPM | `teamFrequencyLimit({ type: LimitTypeEnum.chat })` 于 `v1/chat/completions` | 按团队套餐 `chatRequestPerMinute` 限制(超出返回 `429 tooManyRequest`) | +| IP 转写频率 | `useIPFrequencyLimit({ id: 'transcriptions', seconds: 1, limit: 1 })` | 同一 IP 每秒最多 1 次转写 | +| 文件上传频率 | `authFrequencyLimit({ eventId: '-uploadfile' })` 于 `presignTempFilePostUrl` | 30 秒窗口,按团队套餐 `maxUploadFileCount` 限制 | +| 套餐额度 | `getTeamPlanStatus({ teamId })` + `recordAppUsage` / `updateApiKeyUsage` | 余额不足返回 `510 insufficientQuota` | +| Token / 模型授权 | `authModel`、`unAuthModel` | 未授权返回 `511 unAuthModel` | +| 全局错误码 | `ERROR_RESPONSE` 中的 `429 tooManyRequest` / `429 uploadFileIntervalLimit` | 通用频率限制响应 | + +> 应用级 API Key 的实际用量在 `updateApiKeyUsage` 中按工作流 `flowUsages.totalPoints` 累计,分享链接使用 `addOutLinkUsage` 单独计费,可在「使用记录」面板审计。 + +--- + +## 六、相关源码索引 + +| 关注点 | 路径 | +|--------|------| +| 主对外接口 | `FastGPT/projects/app/src/pages/api/v1/chat/completions.ts` | +| 对外接口入参 Schema | `FastGPT/packages/global/openapi/core/chat/completion/api.ts` | +| 工作流分发引擎 | `FastGPT/packages/service/core/workflow/dispatch/index.ts` | +| 通用 NextAPI 中间件 | `FastGPT/projects/app/src/service/middleware/entry.ts` | +| 鉴权入口 | `FastGPT/packages/service/support/permission/auth/common.ts` | +| 错误码总表 | `FastGPT/packages/global/common/error/errorCode.ts` | +| OpenAPI 规范导出 | `FastGPT/projects/app/src/pages/api/openapi.json.ts` | +| AI Proxy 透传 | `FastGPT/projects/app/src/pages/api/aiproxy/[...path].ts` | + +--- + +## 七、近期新增 API(基于 develop-1.3.0 git 历史) + +> 此节归纳近 3 个月新增/重构的 API 端点,便于补全前文索引盲区。已在前文表格中出现的端点不再重复列出。 + +### 7.1 模型训练(`/api/core/train/`) + +源码目录:`FastGPT/projects/app/src/pages/api/core/train/` + +#### Embedding 训练任务 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/train/embedding/task/create` | 创建 Embedding 训练任务 | +| GET | `/api/core/train/embedding/task/list` | 训练任务列表(按 trainsetId 过滤) | +| GET | `/api/core/train/embedding/task/detail` | 训练任务详情 | +| POST | `/api/core/train/embedding/task/cancel` | 取消运行中的任务 | +| POST | `/api/core/train/embedding/task/retry` | 失败任务重试 | +| DELETE | `/api/core/train/embedding/task/delete` | 删除任务 | +| GET | `/api/core/train/embedding/task/eval-dataset` | 训练评测数据集 | +| GET | `/api/core/train/embedding/task/eval-report` | 训练前后评测报告(召回率/MRR 等) | + +#### Embedding 训练样本集(trainset) + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/train/embedding/trainset/create` | 创建样本集 | +| GET | `/api/core/train/embedding/trainset/list` | 样本集列表 | +| GET | `/api/core/train/embedding/trainset/detail` | 样本集详情 | +| DELETE | `/api/core/train/embedding/trainset/delete` | 删除样本集 | +| POST | `/api/core/train/embedding/trainset/data/create` | 新增样本数据 | +| GET | `/api/core/train/embedding/trainset/data/list` | 样本数据列表 | +| POST | `/api/core/train/embedding/trainset/data/update` | 更新样本数据 | +| DELETE | `/api/core/train/embedding/trainset/data/delete` | 删除样本数据 | +| POST | `/api/core/train/embedding/trainset/data/generate` | LLM 自动扩充样本(stream-based pipeline) | + +#### Rerank 训练任务 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/train/rerank/task/create` | 创建 Rerank 训练任务 | +| GET | `/api/core/train/rerank/task/list` | 训练任务列表(按 trainsetId 过滤) | +| GET | `/api/core/train/rerank/task/detail` | 训练任务详情 | +| POST | `/api/core/train/rerank/task/cancel` | 取消运行中的任务 | +| POST | `/api/core/train/rerank/task/retry` | 失败任务重试 | +| DELETE | `/api/core/train/rerank/task/delete` | 删除任务 | +| GET | `/api/core/train/rerank/task/eval-dataset` | 训练评测数据集 | +| GET | `/api/core/train/rerank/task/eval-report` | 训练前后评测报告 | + +> Rerank 训练任务接口与 Embedding 同形结构,另有 `trainset` 子模块(create/list/detail/delete)和 `trainset/data` 子模块(create/list/update/delete/generate),不再赘述。源码位于 `train/rerank/`。 + +### 7.2 微信 OutLink(`/api/support/outLink/wechat/`) + +源码目录:`FastGPT/projects/app/src/pages/api/support/outLink/wechat/` + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/support/outLink/wechat/qrcode/generate` | 生成微信扫码登录二维码(含 ticket) | +| GET | `/api/support/outLink/wechat/qrcode/status` | 轮询扫码状态(pending / scanned / confirmed / expired) | +| POST | `/api/support/outLink/wechat/logout` | 注销微信 OutLink 会话 | + +### 7.3 Helper Bot(`/api/core/chat/helperBot/`) + +> 已在第 3.2 节列出,但与普通对话隔离运行;不计费、独立历史存储,用于系统内置助手与文档问答。 + +### 7.4 聊天恢复与状态 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/chat/resume` | 流式中断后续传 SSE(基于 `chatId + responseChatItemId`) | +| GET | `/api/core/chat/history/getHistoryStatus` | 单会话生成状态查询 | +| POST | `/api/core/chat/history/markRead` | 标记历史已读 | +| GET | `/api/core/chat/quote/getQuote` | 兼容旧版引用查询 | +| GET | `/api/core/chat/record/getQuote` | v2 单条消息引用 | +| POST | `/api/core/chat/correction/export` | 导出答案纠正数据 | + +### 7.5 知识库新增 + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/core/dataset/listWithChildren` | 列出含子文件夹的数据集(扁平展开) | +| GET | `/api/core/dataset/apps` | 查询关联当前数据集的应用列表(删除前置校验) | +| POST | `/api/core/dataset/collection/resumeInheritPermission` | Collection 级权限恢复继承 | +| POST | `/api/core/dataset/collection/create/custom/website` | 自定义站点导入(统一文件、FAQ、网页导入逻辑) | +| GET | `/api/core/dataset/data/getBatchPermission` | 数据项批量权限校验 | + +### 7.6 通用 / 系统 / 文件 + +| HTTP | 路径 | 功能 | +|------|------|------| +| GET | `/api/common/file/presignTempFileGetUrl` | 获取临时文件下载预签名 URL | +| GET | `/api/system/file/upload/[token]` | 系统级文件上传(按 token 路径) | +| GET | `/api/system/file/download/[token]` | 系统级文件下载(按 token 路径) | +| POST | `/api/admin/initv4147` | v4.14.7 数据迁移脚本(管理员) | +| POST | `/api/admin/sf-initv630` | v6.3.0 SF 版数据迁移脚本(管理员) | +| POST | `/api/admin/migrateToMilvus26` | Milvus 2.6 迁移脚本(管理员) | + +### 7.7 沙箱(`/api/core/sandbox/`、`/api/core/ai/sandbox/`) + +| HTTP | 路径 | 功能 | +|------|------|------| +| ALL | `/api/core/sandbox/proxyAuth` | code-server 鉴权代理 | +| POST | `/api/core/sandbox/proxyCSPassword` | code-server 密码代理(修复未鉴权 RCE) | +| GET | `/api/core/ai/sandbox/list` | 沙箱文件列表 | +| GET | `/api/core/ai/sandbox/read` | 读取沙箱文件 | +| POST | `/api/core/ai/sandbox/write` | 写入沙箱文件 | +| GET | `/api/core/ai/sandbox/download` | 下载沙箱文件 | +| GET | `/api/core/ai/sandbox/checkExist` | 检查沙箱文件存在 | +| GET | `/api/core/ai/sandbox/getHtmlPreviewLink` | 获取 HTML 预览链接(多媒体预览) | +| GET | `/api/core/ai/record/getRecord` | AI Sandbox 操作记录 | + +### 7.8 应用 / 工具相关补充 + +| HTTP | 路径 | 功能 | +|------|------|------| +| POST | `/api/core/app/exportSkill` | 应用导出为 Agent Skill(zip 包) | +| GET | `/api/core/app/logs/getUsers` | 应用调用日志的去重用户 | +| GET | `/api/core/app/mcpTools/getTools` | 列出 MCP server 工具 | +| POST | `/api/core/app/mcpTools/runTool` | 调试运行 MCP 工具 | +| GET | `/api/core/workflow/getSandboxPackages` | 工作流代码节点可用包白名单 | +| POST | `/api/invoke/userInfo` | invoke 协议下查询调用用户信息 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e1f9675d4d42 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/API\347\264\242\345\274\225.md" @@ -0,0 +1,59 @@ +--- +capability_label: "OpenAPI 文档" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: ["/openapi"] +--- + +# OpenAPI 文档 — API索引 + +## 说明 + +本模块为纯展示页面,前端组件不直接发起自定义 API 请求。页面加载后,由 Scalar API Reference 组件内部请求 `/api/openapi.json` 获取 OpenAPI 规范数据。 + +## 涉及的 API 端点 + +### 文档数据 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/openapi.json` | GET | 返回 FastGPT 完整 OpenAPI 3.1.0 规范 JSON | `projects/app/src/pages/api/openapi.json.ts:4` → `@fastgpt/global/openapi/index.ts:12` | OpenAPI 文档→API 文档查阅→页面加载时由 Scalar 组件内部调用 | + +### `/api/openapi.json` 调用链 + +``` +Scalar ApiReferenceReact 组件 + ├── 触发: 组件挂载后自动请求 + ├── 参数: 无(直接请求 /api/openapi.json) + └── 响应处理: Scalar 组件内部解析 OpenAPI JSON,渲染交互式文档 UI + +服务端 handler (projects/app/src/pages/api/openapi.json.ts) + ├── 触发: HTTP GET 请求 + ├── 参数: 无 + └── 响应: res.status(200).json(openAPIDocument),其中 openAPIDocument + 由 @fastgpt/global/openapi 的 createDocument() 构建, + 合并 AppPath、ChatPath、DatasetPath、PluginPath、SupportPath、 + AIPath、AgentSkillsPath、AgentSkillsPackagePath 等路径定义 +``` + +### 规范文档覆盖的 API 标签分组 + +OpenAPI 规范文档覆盖以下 API 分组(由 `x-tagGroups` 定义): + +| 分组 | 包含标签 | +|------|---------| +| 我的应用/工具管理 | 应用通用、MCP 工具、HTTP 工具、应用权限 | +| Agent 应用 | 应用日志、发布渠道、MCP 服务器 | +| AI 相关 | AI 技能、代码沙箱、模型管理 | +| 对话模块配置 | 对话设置、对话页面、对话输入引导 | +| 对话模块使用 | 对话历史、对话反馈、对话文件、对话记录、对话控制 | +| 知识库 | 知识库通用、集合管理、数据管理、文件管理、训练管理、API 数据集、标签、同义词、协作者 | +| 插件系统 | 插件工具标签、插件团队 | +| 用户体系 | 用户通知、钱包账单、优惠券、用户登录 | +| 通用-核心功能 | AI 通用 | +| 通用-辅助功能 | 自定义域名、API Key | +| 管理员-插件管理 | 插件管理、插件市场、插件工具管理 | +| 系统接口 | 辅助机器人 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9521c3b2cd4a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/OpenAPI \346\226\207\346\241\243/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,35 @@ +--- +capability_label: "OpenAPI 文档" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: ["/openapi"] +--- + +# OpenAPI 文档 — 组件列表 + +## pages/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/openapi.tsx` | OpenAPI 文档页面,动态加载 Scalar API Reference 组件展示 API 规范 | 引用外部 `@scalar/api-reference-react` 的 `ApiReferenceReact` 组件 | 否 | 无外部 Props(页面组件,通过 `configuration` 配置 Scalar 行为) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `ApiReferenceReact` | `@scalar/api-reference-react` | 外部 npm 包(Scalar) | +| `Box` | `@chakra-ui/react` | Chakra UI 组件库 | +| `dynamic` | `next/dynamic` | Next.js 框架 | + +## 说明 + +本模块仅包含一个页面组件 `openapi.tsx`,其核心能力由外部依赖 `@scalar/api-reference-react` 提供。页面组件本身是一个轻量级包装,负责: + +- 使用 Next.js `dynamic()` 实现客户端仅加载(SSR 禁用),避免 Scalar 的 CSS side-effect 在 Node 端构建时报错 +- 配置 Scalar 组件参数:隐藏深色模式切换、隐藏客户端按钮、使用默认主题、指向 `/api/openapi.json` 数据源 +- 通过 `getServerSideProps` 禁用静态生成,确保每次请求都动态渲染 + +模块内部无自定义业务组件、无 Store、无自定义 Hooks。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..1112ea3bd3c9 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\243\347\240\201\347\274\226\345\206\231\346\214\207\345\215\227.md" @@ -0,0 +1,1032 @@ +--- +capability_label: null +doc_type: "07" +doc_label: "代码编写指南" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT 代码编写指南 + +> 版本:1.1 | 更新日期:2026-05-05(基于 `develop-1.3.0` 分支近期 200 个提交修订) + +--- + +## 一、项目约定 + +### 1.1 包管理器 + +本项目使用 **pnpm workspaces** 管理依赖。 + +```bash +# 安装依赖(在根目录执行) +pnpm install + +# 仅为特定 workspace 安装依赖 +pnpm --filter @fastgpt/service add + +# 运行脚本 +pnpm lint # 对所有 TS 文件运行 ESLint 并自动修复 +pnpm test # 运行所有测试 +pnpm test # 运行指定测试文件 +``` + +环境要求: +- Node.js >= 20.x +- pnpm >= 9.x + +### 1.2 TypeScript 严格模式 + +全项目启用 TypeScript 严格模式,须遵循以下规范: + +**使用 `type` 而非 `interface`:** + +```typescript +// ❌ 不好的实践 +interface User { + id: string; + name: string; +} + +// ✅ 好的实践 +type User = { + id: string; + name: string; +} +``` + +**用 Zod schema 同时承担校验和类型推导:** + +```typescript +// ❌ 不好的实践:类型与 schema 重复定义 +type MessageParam = { role: 'user' | 'assistant'; content: string }; +const MessageParamSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string() }); + +// ✅ 好的实践:从 schema 推导类型 +export const MessageParamSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string() +}); +export type MessageParam = z.infer; +``` + +### 1.3 命名规范 + +| 类型 | 规范 | 示例 | +|------|------|------| +| 文件名(组件) | PascalCase | `ChatBox.tsx`, `AccordionSection.tsx` | +| 文件名(工具/模块) | camelCase | `controller.ts`, `schema.ts`, `utils.ts` | +| 文件名(API 路由) | camelCase | `create.ts`, `detail.ts`, `delHistory.ts` | +| 变量/函数 | camelCase | `appId`, `createApp`, `getUserInfo` | +| React 组件 | PascalCase | `const ChatBox: React.FC = ...` | +| 类型/接口 | PascalCase + Type/Schema 后缀 | `AppSchemaType`, `CreateAppBodySchema` | +| 常量/枚举 | PascalCase(枚举成员)或 UPPER_SNAKE(全局常量) | `AppTypeEnum.workflow`, `AppCollectionName` | +| Mongoose Model | `Mongo` + PascalCase | `MongoApp`, `MongoChat` | + +### 1.4 代码风格要点 + +**IIFE 取代 if/else 赋值:** + +```typescript +// ❌ 不好的实践 +if (condition) { + value = true; +} else { + value = false; +} + +// ✅ 好的实践 +const value = (() => { + if (condition) return true; + return false; +})(); +``` + +**可选链调用回调:** + +```typescript +// ❌ 不好的实践 +if (onProgress) onProgress({ phase: 'done' }); + +// ✅ 好的实践 +onProgress?.({ phase: 'done' }); +``` + +**空值合并取默认值:** + +```typescript +// ❌ 不好的实践(0、false、'' 会被错误覆盖) +const version = lastVersion?.version || 0; + +// ✅ 好的实践 +const version = (lastVersion?.version ?? -1) + 1; +``` + +**函数参数不超过 2 个,多参数用对象:** + +```typescript +// ❌ 不好的实践 +function createVersion(skillId: string, teamId: string, tmbId: string, version: number) {} + +// ✅ 好的实践 +function createVersion(data: { + skillId: string; + teamId: string; + tmbId: string; + version: number; +}) {} +``` + +**非关键清理用 `.catch()` 链:** + +```typescript +// 次要操作不污染主流程 +await client.delete().catch(() => {}); +``` + +--- + +## 二、代码组织规范 + +采用 **DDD 架构**,按「业务域 → 子功能 → 固定文件」三层划分。 + +### 2.1 目录结构约定 + +``` +packages/ +├── global/core/ # 类型、常量(前后端共享) +│ └── app/ +│ ├── type.ts # 顶层聚合类型 +│ ├── constants.ts # 枚举常量 +│ └── workflow/ +│ ├── type.ts +│ └── constants.ts +│ +└── service/core/ # 后端业务逻辑(禁止前端引用) + └── app/ + ├── schema.ts # Mongoose Schema + Model 导出 + ├── controller.ts # 数据访问封装 + 业务逻辑(findById / create / updateById 等) + ├── utils.ts # 纯函数工具,无副作用 + └── version/ + ├── schema.ts + └── controller.ts +``` + +**叶子目录固定文件职责:** + +| 文件 | 职责 | +|------|------| +| `schema.ts` | Mongoose Schema 定义,导出 Model 和 SchemaType | +| `controller.ts` | 数据访问封装(findById、create、updateById 等)+ 业务逻辑(跨模块协调、业务规则)| +| `utils.ts` | 纯函数工具,无副作用,可独立单测 | + +**层级约束:** +- `global/core/` 只放类型和常量,**禁止**引入 mongoose、服务端 SDK +- `service/core/` 只在服务端使用,**禁止**被 `packages/web/` 或前端页面直接引用 +- 子功能目录不超过 **3 层**嵌套 +- 多个 schema 文件**合并**到单个 `schema.ts`,不拆分 + +### 2.2 API 路由编写规范 + +参考设计规范:`FastGPT/.claude/design/api/index.md` + +**核心原则:** +1. 所有 API 必须使用 Zod schema 定义入参和出参 +2. Schema 定义在 `packages/global/openapi/` 中,类型从 schema 导出 +3. API 路由中使用 `schema.parse()` 验证入参和出参 +4. 路由文件不导出类型别名(消费者直接从 openapi 包导入) +5. 必须在 OpenAPI index 中注册路由 + +**步骤 1:在 `packages/global/openapi/` 定义 Zod Schema** + +```typescript +// packages/global/openapi/core/app/common/api.ts +import { z } from 'zod'; +import { ObjectIdSchema } from '../../../common/type/mongo'; +import { ParentIdSchema } from '../../../common/parentFolder/type'; + +/* ============================================================================ + * API: 创建 App + * Route: POST /api/core/app/create + * ============================================================================ */ +export const CreateAppBodySchema = z.object({ + parentId: ParentIdSchema.meta({ description: '父级文件夹 ID' }), + name: z.string().meta({ example: '我的应用', description: '应用名称' }), + type: z.nativeEnum(AppTypeEnum).meta({ description: '应用类型' }) +}); +export type CreateAppBodyType = z.infer; + +export const CreateAppResponseSchema = ObjectIdSchema.meta({ + description: '新创建的 App ID' +}); +export type CreateAppResponseType = z.infer; +``` + +**步骤 2:在 `projects/app/src/pages/api/` 实现路由** + +```typescript +// projects/app/src/pages/api/core/app/create.ts +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { + CreateAppBodySchema, + CreateAppResponseSchema, + type CreateAppBodyType +} from '@fastgpt/global/openapi/core/app/common/api'; + +async function handler(req: ApiRequestProps) { + // 1. 入参验证(使用 safeParseAsync 或 parse) + const parseResult = await CreateAppBodySchema.safeParseAsync(req.body); + const body = parseResult.success ? parseResult.data : req.body; + const { parentId, name, type } = body; + + // 2. 权限校验 + const { teamId, tmbId } = await authUserPer({ req, authToken: true, per: ... }); + + // 3. 业务逻辑 + const appId = await onCreateApp({ parentId, name, type, teamId, tmbId }); + + // 4. 出参验证 + return CreateAppResponseSchema.parse(appId); +} + +export default NextAPI(handler); +``` + +**关键点:** +- 使用 `NextAPI` 包裹 handler,提供统一的错误处理、日志、跨域 +- `ApiRequestProps` 带泛型以获得类型推断 +- 使用 `authApp` 或 `authUserPer` 进行权限校验 +- `mongoSessionRun` 用于需要事务的操作 +- 出参必须用 `ResponseSchema.parse(result)` 验证 + +**公共 Schema 参考:** + +| Schema | 说明 | 导入路径 | +|--------|------|----------| +| `ObjectIdSchema` | MongoDB ObjectId(自动转 string) | `@fastgpt/global/common/type/mongo` | +| `ParentIdSchema` | 父级 ID(`z.string().nullish()`) | `@fastgpt/global/common/parentFolder/type` | +| `PaginationSchema` | 偏移分页(pageSize、offset、pageNum) | `@fastgpt/global/openapi/api` | +| `PaginationResponseSchema` | 分页响应(total、list) | `@fastgpt/global/openapi/api` | + +> **ObjectId 陷阱**:MongoDB 返回的 `_id` 是 ObjectId 对象,不是 string。出参不能用 `z.string()` 验证,必须使用 `ObjectIdSchema`(内置自动转换)。 + +### 2.3 Mongoose 数据模型编写规范 + +参考示例:`FastGPT/packages/service/core/app/schema.ts` + +```typescript +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { Schema, getMongoModel } from '../../common/mongo'; +import type { AppSchemaType as AppType } from '@fastgpt/global/core/app/type'; +import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant'; + +// 1. 定义集合名称常量 +export const AppCollectionName = 'apps'; + +// 2. 定义 Schema +const AppSchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true // 必填字段加 required + }, + name: { + type: String, + required: true + }, + type: { + type: String, + default: AppTypeEnum.workflow, + enum: Object.values(AppTypeEnum) // 枚举限制 + }, + intro: { + type: String, + default: '' // 字符串字段给空字符串默认值 + }, + modules: { + type: Array, + default: [] + }, + deleteTime: { + type: Date, + default: null // null 表示未删除(软删除模式) + } + }, + { + minimize: false // 保留空对象字段 + } +); + +// 3. 定义索引(高频查询字段必须建索引) +AppSchema.index({ teamId: 1, updateTime: -1 }); +AppSchema.index({ teamId: 1, type: 1 }); +// 部分索引(条件索引) +AppSchema.index( + { scheduledTriggerConfig: 1, scheduledTriggerNextTime: -1 }, + { + partialFilterExpression: { + scheduledTriggerConfig: { $exists: true } + } + } +); + +// 4. 导出 Model(泛型参数为 global 中定义的 TypeScript 类型) +export const MongoApp = getMongoModel(AppCollectionName, AppSchema); +``` + +**Schema 约定:** +- 集合名称用 `export const XxxCollectionName = 'xxx'` 导出 +- 关联字段使用 `Schema.Types.ObjectId` + `ref` 指向集合名称常量 +- 高频查询字段必须建索引 +- 软删除使用 `deleteTime: { type: Date, default: null }` 模式 +- 导出 Model 时,TypeScript 类型定义在 `packages/global/` 中 + +### 2.4 Controller 编写规范 + +参考示例:`FastGPT/packages/service/core/app/controller.ts` + +**controller.ts(数据访问 + 业务逻辑,统一放在一个文件中):** + +```typescript +// packages/service/core/app/controller.ts +export const findAppById = (id: string) => + MongoApp.findById(id).lean(); + +export const createApp = (data: AppCreateParams, session?: ClientSession) => + MongoApp.create([data], { session }); + +export const getAppBasicInfoByIds = async ({ + teamId, + ids +}: { + teamId: string; + ids: string[]; +}) => { + const apps = await MongoApp.find( + { teamId, _id: { $in: ids } }, + '_id name avatar' + ).lean(); + + return apps.map((item) => ({ + id: item._id, + name: item.name, + avatar: item.avatar + })); +}; +``` + +**跨模块协调示例(在 controller.ts 中实现):** + +```typescript +// packages/service/core/app/controller.ts +import { mongoSessionRun } from '../../common/mongo/sessionRun'; +import { MongoAppVersion } from './version/schema'; + +export const createAppAndInitVersion = async ( + data: AppCreateParams, + session?: ClientSession +) => { + const create = async (session: ClientSession) => { + const [app] = await MongoApp.create([data], { session }); + await MongoAppVersion.create([{ appId: String(app._id), ... }], { session }); + return app; + }; + + if (session) return create(session); + return mongoSessionRun(create); // 无 session 时自动管理事务 +}; +``` + +### 2.5 React 组件编写规范 + +参考示例:`FastGPT/projects/app/src/components/core/app/AccordionSection.tsx` + +**基本组件结构:** + +```typescript +import React from 'react'; +import { Box, Flex, Button } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; + +// Props 类型定义(使用 type,不用 interface) +type MyComponentProps = { + title: string; + children: React.ReactNode; + onConfirm?: () => void; // 可选回调用 ? + disabled?: boolean; +}; + +// 函数式组件(使用 React.FC 泛型) +const MyComponent: React.FC = ({ + title, + children, + onConfirm, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( + + + {title} + + {children} + + + ); +}; + +export default MyComponent; +``` + +**Chakra UI 使用约定:** +- 样式属性直接写在组件上(props 形式),不用 `style={{}}` +- 颜色使用主题 token(如 `myWhite.1000`),不硬编码 hex +- 响应式布局使用 Chakra 的数组语法:`fontSize={['12px', '14px']}` +- 主题定义在 `FastGPT/packages/web/styles/theme.ts` + +**State 管理选择:** + +| 场景 | 方案 | +|------|------| +| 组件本地状态 | `useState` / `useReducer` | +| 跨组件共享(局部树) | `React Context`(见 `context/` 目录) | +| 全局应用状态 | `Zustand` store | +| 服务端数据缓存 | `SWR` + API 请求 | + +--- + +## 三、国际化(i18n)规范 + +参考规范:`FastGPT/.claude/skills/i18n/SKILL.md` + +词条文件位于 `FastGPT/packages/web/i18n/`,支持三种语言: + +| 语种 | 目录 | +|------|------| +| 简体中文 | `packages/web/i18n/zh-CN/` | +| 英文 | `packages/web/i18n/en/` | +| 繁体中文 | `packages/web/i18n/zh-Hant/` | + +### 3.1 基本用法 + +```typescript +import { useTranslation } from 'next-i18next'; + +const { t } = useTranslation(); + +// t() 调用必须使用 namespace:key 格式 +t('app:learn') +t('common:Confirm') +t('dataset:create_dataset') + +// 带变量插值 +t('file:selected_file_count', { count: 3 }) +// 对应词条:{ "selected_file_count": "已选择 {{count}} 个文件" } +``` + +### 3.2 命名空间约定 + +常用命名空间(共 28 个,完整列表见源码): + +| 命名空间 | 用途 | +|----------|------| +| `common` | 通用词条(操作、状态等) | +| `app` | 应用相关 | +| `dataset` | 知识库相关 | +| `workflow` | 工作流相关 | +| `chat` | 聊天相关 | + +> 完整 28 个命名空间定义见:`FastGPT/packages/web/i18n/constants.ts` + +### 3.3 新增词条规则 + +新增词条时,必须**同时**在三个语种文件添加: + +```json +// packages/web/i18n/zh-CN/app.json +{ "learn": "学习" } + +// packages/web/i18n/en/app.json +{ "learn": "learn" } + +// packages/web/i18n/zh-Hant/app.json +{ "learn": "學習" } // 必须是真正的繁体中文,不能复制简体 +``` + +**key 命名规范:** +- 使用英文单词,`PascalCase` 或 `snake_case`(与文件已有风格一致) +- 支持点号层级:`setting.basic_config` +- 禁止直接使用中文作为 key + +### 3.4 检查清单 + +- [ ] `t()` 使用了 `namespace:key` 格式 +- [ ] key 为英文且语义化 +- [ ] 三个语种文件同步添加 +- [ ] `zh-Hant` 使用正确的繁体中文 +- [ ] 变量插值使用 `{{variable}}` 语法 + +--- + +## 四、配置文件结构 + +配置文件:`FastGPT/projects/app/data/config.json`(使用 JSON5 格式,支持注释) + +主要配置节点: + +```jsonc +{ + "feConfigs": { + // 前端功能开关和外部服务地址 + "lafEnv": "https://laf.dev", + "mcpServerProxyEndpoint": "http://localhost:3003" + }, + "systemEnv": { + // 后端运行参数 + "datasetParseMaxProcess": 10, // 知识库文件解析最大线程数 + "vectorMaxProcess": 10, // 向量处理线程数 + "qaMaxProcess": 10, // 问答拆分线程数 + "tokenWorkers": 30 // Token 计算线程数 + } +} +``` + +**如何添加新配置项:** + +1. 在 `FastGPT/packages/global/common/system/types/index.ts` 中扩展 `SystemEnvType`(服务端配置)或 `FastGPTFeConfigsType`(前端配置)类型 +2. 在 `FastGPT/projects/app/data/config.json` 添加默认值(带注释说明用途) +3. 在业务代码中通过以下方式读取配置: + - **服务端**:环境变量通过 `packages/service/env.ts` 中的 `env` 对象读取(基于 `@t3-oss/env-core`,自动校验类型);`config.json` 中的 `systemEnv`/`feConfigs` 通过 `global.systemEnv` / `global.feConfigs` 全局变量读取 + - **前端**:通过 `useSystemStore()` 获取 `feConfigs`,通过 `useInitApp()` 初始化应用级配置 + +--- + +## 五、环境变量管理 + +完整变量模板:`FastGPT/projects/app/.env.template` + +### 5.1 重要环境变量 + +**基础配置:** + +| 变量 | 说明 | 示例 | +|------|------|------| +| `DEFAULT_ROOT_PSW` | 默认 root 用户密码 | `123456` | +| `TOKEN_KEY` | JWT 签名密钥 | `fastgpt` | +| `FILE_TOKEN_KEY` | 文件读取密钥 | `filetokenkey` | +| `AES256_SECRET_KEY` | 密钥加密 key(32字节) | `fastgptsecret` | +| `ROOT_KEY` | 最高权限 API Key | `fdafasd` | + +**数据库:** + +| 变量 | 说明 | +|------|------| +| `MONGODB_URI` | MongoDB 连接字符串 | +| `PG_URL` | PostgreSQL(pgvector)连接字符串 | +| `REDIS_URL` | Redis 连接地址 | +| `MILVUS_ADDRESS` | Milvus 向量库地址(可选) | + +**服务地址:** + +| 变量 | 说明 | +|------|------| +| `FE_DOMAIN` | 前端页面地址(用于补全相对路径) | +| `AIPROXY_API_ENDPOINT` | AI Proxy 服务地址 | +| `CODE_SANDBOX_URL` | 代码沙箱服务地址 | +| `PLUGIN_BASE_URL` | 插件服务地址 | + +**日志与监控:** + +| 变量 | 说明 | 示例 | +|------|------|------| +| `LOG_ENABLE_CONSOLE` | 开启控制台日志 | `true` | +| `LOG_CONSOLE_LEVEL` | 控制台日志等级 | `debug` | +| `LOG_ENABLE_OTEL` | 开启 OpenTelemetry 日志 | `true` | +| `LOG_OTEL_LEVEL` | OTel 日志等级 | `info` | +| `LOG_OTEL_URL` | OTel 日志上报地址 | `http://localhost:4318/v1/logs` | + +**存储(S3/MinIO):** + +| 变量 | 说明 | +|------|------| +| `STORAGE_VENDOR` | 存储供应商(`minio` / `aws-s3`) | +| `STORAGE_S3_ENDPOINT` | S3 端点地址 | +| `STORAGE_ACCESS_KEY_ID` | 访问密钥 ID | +| `STORAGE_SECRET_ACCESS_KEY` | 访问密钥 Secret | +| `STORAGE_PUBLIC_BUCKET` | 公共存储桶名称 | +| `STORAGE_PRIVATE_BUCKET` | 私有存储桶名称 | + +**功能开关:** + +| 变量 | 说明 | +|------|------| +| `USE_IP_LIMIT` | 启用 IP 限流 | +| `WORKFLOW_MAX_RUN_TIMES` | 工作流最大运行次数 | +| `SHOW_SKILL` | 是否显示 Skill 功能入口 | + +--- + +## 六、共享包使用规范 + +### 6.1 包导入路径 + +```typescript +// 全局共享(前后端均可用):类型、常量、工具函数 +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import type { AppSchemaType } from '@fastgpt/global/core/app/type'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +// 服务端包(仅后端使用):数据库操作、服务逻辑 +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { authApp } from '@fastgpt/service/support/permission/app/auth'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +// 前端共享包:组件、hooks、样式 +import { MyBox } from '@fastgpt/web/components/common/MyBox'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +``` + +### 6.2 包内路径规则 + +``` +@fastgpt/global/ +├── core/ # 业务核心类型和常量 +│ ├── app/ # App 相关 +│ ├── chat/ # 聊天相关 +│ ├── dataset/ # 知识库相关 +│ ├── workflow/ # 工作流相关 +│ └── ai/ # AI 模型相关 +├── common/ # 通用工具 +│ ├── error/ # 错误处理 +│ ├── string/ # 字符串工具 +│ └── type/ # 通用类型 +├── support/ # 支撑模块类型 +│ ├── user/ # 用户/团队 +│ └── permission/ # 权限 +└── openapi/ # API Schema 定义(Zod) + +@fastgpt/service/ +├── core/ # 核心业务逻辑 +├── common/ # 通用服务工具 +│ ├── mongo/ # MongoDB 操作 +│ ├── logger/ # 日志 +│ ├── response/ # 响应处理 +│ └── s3/ # 对象存储 +└── support/ # 支撑模块 + └── permission/ # 权限校验 + +@fastgpt/web/ +├── components/ # 共享 UI 组件 +├── hooks/ # 共享 React hooks +├── i18n/ # 国际化文件 +└── styles/ # 主题样式 +``` + +--- + +## 七、错误处理规范 + +### 7.1 统一错误码 + +错误码定义:`FastGPT/packages/global/common/error/errorCode.ts` + +```typescript +import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; + +// 通用错误枚举 +export enum ERROR_ENUM { + unAuthorization = 'unAuthorization', // 未授权(403) + insufficientQuota = 'insufficientQuota', // 额度不足(510) + unAuthModel = 'unAuthModel', // 模型无权限(511) + tooManyRequest = 'tooManyRequest' // 请求过多(429) +} +``` + +各模块专属错误码(共 16 个文件,见 `FastGPT/packages/global/common/error/code/`): +- `FastGPT/packages/global/common/error/code/app.ts` — App 相关 +- `FastGPT/packages/global/common/error/code/chat.ts` — 聊天相关 +- `FastGPT/packages/global/common/error/code/dataset.ts` — 知识库相关 +- `FastGPT/packages/global/common/error/code/common.ts` — 通用 +- 其他还包括:`agentSkill.ts`、`database.ts`、`evaluation.ts`、`openapi.ts`、`outLink.ts`、`plugin.ts`、`s3.ts`、`skill.ts`、`system.ts`、`team.ts`、`train.ts`、`user.ts` + +### 7.2 服务端抛出错误 + +```typescript +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; + +// 1. 使用错误枚举抛出(推荐) +return Promise.reject(CommonErrEnum.missingParams); + +// 2. 直接抛出字符串(仅在无对应枚举时) +return Promise.reject('App not found'); + +// 3. 抛出用户可见错误 +import { UserError } from '@fastgpt/global/common/error/utils'; +throw new UserError('操作失败,请稍后重试'); +``` + +`NextAPI` 中间件会自动捕获这些错误,将 `ERROR_RESPONSE` 中定义的错误码映射到正确的 HTTP 状态码和客户端消息。 + +### 7.3 客户端错误处理 + +```typescript +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { useToast } from '@fastgpt/web/hooks/useToast'; + +const { toast } = useToast(); + +try { + await createApp(params); +} catch (err) { + // getErrText 自动处理:Axios 响应、Error 对象、字符串等多种格式 + toast({ + title: getErrText(err, '创建失败'), + status: 'error' + }); +} +``` + +### 7.4 错误响应结构 + +API 统一返回格式(由 `packages/service/common/response/index.ts` 处理): + +```typescript +// 成功响应 +{ + code: 200, + message: '', + data: T +} + +// 错误响应 +{ + code: 403, // 业务错误码 + message: '未授权', // 用户可读消息(经过 i18n 处理) + data: null +} +``` + +--- + +## 八、日志规范 + +日志系统基于 **LogTape**(`@fastgpt-sdk/otel`),通过 `@fastgpt/service/common/logger` 封装后使用。 + +### 8.1 获取 Logger + +```typescript +import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +// 在模块顶层创建 logger(不要在函数内部反复创建) +const logger = getLogger(LogCategories.MODULE.APP.FOLDER); +``` + +### 8.2 Logger 分类体系(LogCategories) + +```typescript +LogCategories = { + SYSTEM: ['system'], // 应用层通用日志 + INFRA: { + MONGO: ['infra', 'mongo'], // MongoDB 操作 + REDIS: ['infra', 'redis'], // Redis 操作 + S3: ['infra', 's3'], // 对象存储 + VECTOR: ['infra', 'vector'], // 向量库操作 + QUEUE: ['infra', 'queue'] // 队列操作 + }, + HTTP: { + REQUEST: ['http', 'request'], // HTTP 请求 + RESPONSE: ['http', 'response'], // HTTP 响应 + ERROR: ['http', 'error'] // HTTP 错误 + }, + MODULE: { + APP: ['app'], // App 模块 + DATASET: ['dataset'], // 知识库模块 + WORKFLOW: ['workflow'], // 工作流模块 + AI: { + AGENT: ['ai', 'agent'], // Agent AI + LLM: ['ai', 'llm'], // LLM 调用 + EMBEDDING: ['ai', 'embedding'] // 向量嵌入 + }, + CHAT: ['chat'] // 聊天模块 + // ... 更多见 packages/service/common/logger/categories.ts + } +} +``` + +### 8.3 日志使用示例 + +```typescript +import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; + +const logger = getLogger(LogCategories.MODULE.APP.FOLDER); + +// 记录不同级别日志 +logger.debug('调试信息', { appId, parentId }); +logger.info('App 创建成功', { appId, teamId }); +logger.warn('配置项缺失,使用默认值', { key: 'maxProcess' }); +logger.error('App 删除失败', { error: err, appId }); + +// 在 catch 中记录错误(非关键路径用 .catch 链) +updateParentFoldersUpdateTime({ parentId }) + .catch((err) => { + logger.error('Failed to update parent folder updateTime', { error: err }); + }); +``` + +### 8.4 日志环境配置 + +```bash +# .env 配置 +LOG_ENABLE_CONSOLE=true # 开启控制台输出 +LOG_CONSOLE_LEVEL=debug # 控制台日志等级:trace|debug|info|warning|error|fatal +LOG_ENABLE_OTEL=true # 开启 OpenTelemetry 上报 +LOG_OTEL_LEVEL=info # OTel 日志等级 +LOG_OTEL_URL=http://localhost:4318/v1/logs # OTel 收集器地址 +LOG_DEPTH=3 # 对象序列化深度 +``` + +--- + +## 九、测试规范 + +项目使用 **Vitest** 进行测试。 + +### 9.1 测试命令 + +```bash +pnpm test # 运行所有测试 +pnpm test # 运行指定测试文件 +``` + +### 9.2 测试文件位置 + +- 集中测试:`FastGPT/test/` — 包含 packages 和跨模块的集成测试 +- 项目测试:`FastGPT/projects//test/` — 各应用的单元/集成测试 + +### 9.3 API 测试示例 + +```typescript +import createHandler from '@/pages/api/core/app/create'; +import type { + CreateAppBodyType, + CreateAppResponseType +} from '@fastgpt/global/openapi/core/app/common/api'; +import { Call } from '@test/utils/request'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; + +it('should create app successfully', async () => { + const res = await Call( + createHandler, + { + auth: users.members[0], + body: { + name: 'test-app', + type: AppTypeEnum.workflow + } + } + ); + + expect(res.code).toBe(200); + expect(typeof res.data).toBe('string'); // 返回 appId +}); +``` + +--- + +## 十、快速参考 + +### 10.1 新增一个 API 的完整流程 + +1. **定义 Schema**:在 `packages/global/openapi//api.ts` 添加入参/出参 Schema +2. **注册 OpenAPI**:在对应 `index.ts` 中注册路由文档 +3. **实现路由**:在 `projects/app/src/pages/api//.ts` 实现 handler +4. **前端调用**:从 `@fastgpt/global/openapi/` 导入类型,调用 API +5. **编写测试**:在对应 `test/` 目录下添加测试用例 + +### 10.2 新增一个数据模型的完整流程 + +1. **定义类型**:在 `packages/global/core//type.ts` 添加 TypeScript 类型 +2. **定义常量**:在 `packages/global/core//constants.ts` 添加枚举 +3. **定义 Schema**:在 `packages/service/core//schema.ts` 添加 Mongoose Schema +4. **实现 controller**:在 `FastGPT/packages/service/core//controller.ts` 添加数据访问封装(findById、create、updateById 等)和跨模块协调的业务逻辑 +5. **实现 utils**:在 `FastGPT/packages/service/core//utils.ts` 添加纯函数工具(无副作用,可独立单测) + +### 10.3 关键文件速查 + +| 需求 | 文件路径 | +|------|---------| +| API Schema 定义 | `FastGPT/packages/global/openapi/` | +| 错误码定义 | `FastGPT/packages/global/common/error/errorCode.ts` | +| 日志分类 | `FastGPT/packages/service/common/logger/categories.ts` | +| 权限常量 | `FastGPT/packages/global/support/permission/constant.ts` | +| i18n 词条 | `FastGPT/packages/web/i18n/` | +| 运行时配置 | `FastGPT/projects/app/data/config.json` | +| 环境变量模板 | `FastGPT/projects/app/.env.template` | +| API 入口中间件 | `FastGPT/packages/service/common/middle/entry.ts` | +| App Schema 示例 | `FastGPT/packages/service/core/app/schema.ts` | +| API 路由示例 | `FastGPT/projects/app/src/pages/api/core/app/create.ts` | + +--- + +## 十一、近期重要变更(基于 develop-1.3.0 git 历史) + +> 此章节归纳本仓库最近 3 个月内对编码规范和模块边界产生影响的核心变更,新代码请优先沿用新模式。 + +### 11.1 日志:从 addLog 全面迁移到 LogTape Logger + +近期一批 commit(如 `84d176c9d [UPDATE] 修改打印较多的日志到新logger`、`08560ed27 feat: remove debug log and reranker add error log`)将分散在 service 层的 `addLog`/`console.*` 调用统一替换为 `getLogger(LogCategories.X)` 形态。 + +**编码要求:** + +- 新代码不要再使用 `addLog.info/warn/error`,请直接 `import { getLogger, LogCategories } from '@fastgpt/service/common/logger'`,在模块顶层创建 logger。 +- 分类参考 `FastGPT/packages/service/common/logger/categories.ts`,常用新增分类: + - `LogCategories.INFRA.REDIS` — Redis 连接与队列 + - `LogCategories.INFRA.QUEUE` — `MongoDatasetTraining` 等队列消费 Worker + - `LogCategories.MODULE.AI.AGENT` / `LogCategories.MODULE.AI.LLM` / `LogCategories.MODULE.AI.EMBEDDING` +- "次要清理"日志(不影响主流程)一律用 `.catch((err) => logger.warn(...))`,**不要**继续吞掉错误。 + +### 11.2 Agent 引擎:新增 piAgent 与 context compaction + +- 新文件:`FastGPT/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts`、`piAgent/toolAdapter.ts`、`piAgent/modelBridge.ts`、`piAgent/compaction.ts`。 +- 通过环境变量 `AGENT_ENGINE=pi` 切换;默认引擎仍是 master/plan 双层编排。 +- piAgent 支持 `useAgentSandbox + SANDBOX_TOOLS` 直接调用沙箱工具集,避免重复构造工具描述(见 `12501eb98 feat: piAgent support useAgentSandbox and SANDBOX_TOOLS`)。 +- 新增 **context compaction** 能力(`ec51c573a feat(piAgent): add context compaction with env toggle`),通过环境变量开关,由 piAgent 自行调用 LLM 压缩历史并复用 dataset params resolution。 +- 编写 piAgent 工具适配器时,复用 `toolAdapter.ts` 中的转换函数;不要在 piAgent 内联工具描述。 +- 系统提示词通过 `parsePiAgentSystemPrompt` 函数解析(导入自 `../sub/plan/prompt`),piAgent 目录下**不存在** `systemPrompt.ts` 文件。 + +### 11.3 Sandbox:Skill 同步、metadata 透传、限额可配 + +- `dd688f98e feat: sandbox warm start 时同步 skills 状态` —— Sandbox 预热时按 `skillId` 增量同步并清理过期目录,新代码读取/写入沙箱文件应通过 `core/ai/sandbox/*` 提供的统一 API(`checkExist`、`download`、`getHtmlPreviewLink`、`list`、`read`、`write`)。 +- `84d40b54b feat(sandbox): support custom metadata passthrough and configurable sandbox limits` —— 沙箱调用支持自定义 metadata 透传,CPU/内存/磁盘限额通过 `SandboxLimitSchema` 定义(见 `FastGPT/packages/service/core/ai/sandbox/type.ts`);新增节点须通过 metadata 传递业务上下文。 +- `9d1cafce9 fix(sandbox): fix unauthenticated RCE via code-server` —— 涉及 code-server 时务必经过 `proxyAuth` / `proxyCSPassword` 两层鉴权,不要直接暴露 code-server 端口。 + +### 11.4 Embedding / Rerank 模型训练流水线 + +- 新增 `projects/app/src/pages/api/core/train/embedding/**` 与 `projects/app/src/pages/api/core/train/rerank/**` 一整套训练 API(task、trainset、eval-dataset、eval-report、apply-task、apply-to-apps)。 +- 模型配置新增 `supportTrain` 标记(`04b25e704`)和 `instruction` 配置(`56a358d99`),写在 `FastGPT/packages/service/core/ai/config/` 中(该目录下为 TypeScript 文件:`schema.ts`、`utils.ts`,非 JSON 文件)。 +- 训练 trainset 生成走 **stream-based pipeline**(`2d15c67a4 refactor(train)`),不要一次性把所有样本读入内存;新代码继续使用 `core/train/trainset/data/generate` 的流式 API。 +- Embedding/Rerank 模型若声明 `instruction`,调用时 `getVectorsByText` 与 `reRankRecall` 会自动拼接 `Instruct: ...\nQuery: ...` 前缀,**业务侧不要重复添加**。 + +### 11.5 Agentic Search 多语言自适应 + +- `f4d188a09 [ADD] agentic search support language detect and adaptive search for multi language` —— Agentic 检索新增语言检测,并在 `searchDatasetDataForAssistant` 中根据语言切换 prompt。 +- 相关变更:`a6c3d680b [fixed] code format` 涉及 `packages/diting-rag-ts/`,编辑该子包时仍需遵守本仓库的 syntax 规范。 + +### 11.6 知识库 Collection 权限 + +- `5b7abfe44 feat(permission): 新增 Collection 权限管理与生效范围功能` —— 新增 Collection 级权限,权限常量见 `packages/global/support/permission/constant.ts`。 +- 新 API: + - `POST /api/core/dataset/collection/resumeInheritPermission` + - `GET /api/core/dataset/data/getBatchPermission` +- 新代码访问 Collection 资源前必须调用 `authDatasetCollection`(而非直接 `authDataset`),否则会绕过新增的生效范围校验。 + +### 11.7 知识库关联应用与删除约束 + +- `bf2d7aab0 feat(dataset): 关联应用删除限制与UI展示优化` —— 新增 `GET /api/core/dataset/apps`,删除 Dataset 前必须检查是否被应用引用。 +- 新 API: + - `GET /api/core/dataset/listWithChildren` — 含子文件夹的扁平列表 +- 后端在 dataset 删除流程中调用 `findAppsByDatasetId` 校验,前端在 UI 上展示关联应用列表。 + +### 11.8 Helper Bot(系统内置助手) + +- 新增 `/api/core/chat/helperBot/*` 一族 API(completions、getRecords、deleteRecord、getFilePresign、getFilePreviewUrl)。 +- Helper Bot 与普通会话隔离存储;新需求若需要"系统级问答 + 文件附件"能力,复用 helperBot 而不是新建。 + +### 11.9 微信 OutLink + +- 新增 `/api/support/outLink/wechat/qrcode/generate`、`status`、`logout` 三接口,承载微信扫码登录与会话续期。 +- 鉴权与频率限制路径已在 `authShareChat` 中合并处理,新代码无需额外 wrap。 + +### 11.10 Workflow / 节点配置 UI 内联化趋势 + +- `bc67e051a feat(workflow): 将知识库检索配置内联展示`、`bb06dd715 feat(tool): 优化工具配置模态框标签过滤逻辑` 等若干 UI 优化将"模态框配置"改为"节点内联展示"。 +- 新增工作流节点 UI 时,优先使用 `RetrievalModeSelector`、`SelectDatasetParams`、`TagFilterConfigInput` 等内联组件;禁止重复造模态框。 + +### 11.11 中文硬编码与 i18n 修复 + +- `5d0f649df feat: 修复 tsx 文件存在的中文硬编码问题`、`db186b06a feat: 删除自动学习相关词条` 等 commit 大量替换 tsx 中的硬编码中文。 +- **要求:所有 tsx 文件中的用户可见文本必须走 `t('namespace:key')`,提交前用 `pnpm lint` 自检。** +- 三语种(`zh-CN`、`en`、`zh-Hant`)必须同步新增;`zh-Hant` 不能直接复制 `zh-CN`,必须使用真正的繁体中文(参考 `.claude/skills/i18n/SKILL.md`)。 + +### 11.12 Avatar 上传扩展名收紧 + +- `a09053d5d feat: add .svg and .ico to avatar upload allowed extensions` —— 头像上传白名单显式列出,再次确认安全策略:除该白名单外的扩展名一律拒绝。 + +### 11.13 Code Format 与 Review + +- `95ca8df7d [fixed] code format` 与 `a6c3d680b code review issues` 显示团队近期对 code review 收紧,建议本地提交前必跑 `pnpm lint`,并自检: + - 函数参数 ≤ 2 个,多参数走对象解构 + - 用 `??` 而非 `||` 取默认值 + - 用可选链调用回调,避免 `if (cb) cb(...)` + - 不要新增 `interface`,统一用 `type` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..8f855a70186d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/API\347\264\242\345\274\225.md" @@ -0,0 +1,62 @@ +--- +capability_label: 价格 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [团队管理员, 团队成员] +router_paths: [/price] +--- + +# 价格 — API索引 + +## 订阅与账单 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/create` | POST | 创建支付账单(订阅/积分/容量) | `src/web/support/wallet/bill/api.ts:19` → `src/pageComponents/price/Standard.tsx:147`;`src/pageComponents/price/ExtraPlan.tsx:62,99` | 价格→价格中心→购买套餐→点击购买/续费/升级时调用;价格→额外AI积分→点击购买时调用;价格→额外知识库容量→点击购买时调用 | +| `/support/user/team/plan/getTeamPlanStatus` | GET | 获取团队订阅计划状态 | `src/web/support/user/team/api.ts:122` → `src/pages/price/index.tsx:31` | 价格→价格中心→页面初始化时调用(依赖 userInfo 加载完成后触发) | +| `/proApi/support/wallet/discountCoupon/list` | GET | 获取团队优惠券列表 | `src/web/support/wallet/sub/discountCoupon/api.ts:4` → `src/pageComponents/price/Standard.tsx:73` | 价格→价格中心→页面初始化→teamId 可用时自动获取;套餐卡片判断折扣适用性 | + +## 用户数据 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `useUserStore.initUserInfo` (Store) | — | 初始化用户信息和团队数据 | `src/web/support/user/useUserStore` → `src/pages/price/index.tsx:27` | 价格→价格中心→页面初始化时调用 | + +### `POST /proApi/support/wallet/bill/create` 调用链 + +``` +Standard.tsx + ├── 触发: 用户点击套餐卡片的"购买"/"续费"/"升级套餐"按钮 + ├── 参数: { type: BillTypeEnum.standSubPlan, level, subMode, discountCouponId } + └── 响应处理: payUrl → 新标签页打开支付链接;其他 → setQRPayData 显示二维码支付弹窗 + +ExtraPlan.tsx (AI 积分) + ├── 触发: 用户选择积分包后点击"购买" + ├── 参数: { type: BillTypeEnum.extraPoints, extraPoints, month } + └── 响应处理: setQRPayData 显示二维码支付弹窗 + +ExtraPlan.tsx (知识库容量) + ├── 触发: 用户设置容量和有效期后点击"购买" + ├── 参数: { type: BillTypeEnum.extraDatasetSub, month, extraDatasetSize } + └── 响应处理: setQRPayData 显示二维码支付弹窗 +``` + +### `GET /support/user/team/plan/getTeamPlanStatus` 调用链 + +``` +PriceBox (index.tsx) + ├── 触发: 页面初始化,useUserStore.initUserInfo 完成后自动触发 + ├── 参数: 无(依赖全局登录状态,maxQuantity: 1 避免重复请求) + └── 响应处理: 写入 useUserStore.teamPlanStatus,驱动 Standard 组件中的套餐当前状态判断 +``` + +### `GET /proApi/support/wallet/discountCoupon/list` 调用链 + +``` +Standard.tsx + ├── 触发: 页面初始化,myStandardPlan.teamId 可用后自动请求 + ├── 参数: { teamId } + └── 响应处理: 匹配当前付费模式的折扣券(月付→7折券,年付→9折券),影响套餐卡片价格展示 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..aedc1ba514ab --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\344\273\267\346\240\274/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,44 @@ +--- +capability_label: 价格 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [团队管理员, 团队成员] +router_paths: [/price] +--- + +# 价格 — 组件列表 + +## pageComponents/price/ 目录(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/price/Standard.tsx` | 标准订阅套餐选择与购买,含月付/年付切换、套餐卡片、优惠券匹配、支付流程 | 引用自 钱包 → QRCodePayModal, postCreatePayBill;引用自 系统配置 → useSystemStore | 否 | `standardPlan: TeamPlanStandardType, onPaySuccess: () => void` | +| `src/pageComponents/price/ExtraPlan.tsx` | 额外 AI 积分包购买 + 额外知识库容量购买 | 引用自 钱包 → QRCodePayModal, postCreatePayBill;引用自 系统配置 → useSystemStore | 是 → 被余额不足弹窗引用 | `onPaySuccess: () => void` | +| `src/pageComponents/price/Points.tsx` | AI 模型计费表展示,含 AiPointsTable 组件 | 引用自 AI模型 → ModelTable | 否 | 无 | +| `src/pageComponents/price/FAQ.tsx` | 订阅相关常见问题解答展示(10 条静态 FAQ) | 内部 | 否 | 无 | + +## pages/price/ 目录(页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/price/index.tsx` | 价格中心页面路由入口,组合 Standard/ExtraPlan/Points/FAQ 四个区域,含返回按钮悬浮逻辑 | 引用自 用户 → useUserStore;引用自 系统配置 → useSystemStore | 否 | 无(页面组件,由 Next.js 路由挂载) | + +## 内部工具 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/price/utils.ts` | 活动截止时间格式化工具函数(`formatActivityExpirationTime`) | 内部 | 否 | 不适用 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `QRCodePayModal` | `@/components/support/wallet/QRCodePayModal` | 钱包/支付 | +| `StandardPlanContentList` | `@/components/support/wallet/StandardPlanContentList` | 钱包/订阅 | +| `ModelTable` | `@/components/core/ai/ModelTable` | AI 模型 | +| `ModelPriceModal` | `@/components/core/ai/ModelTable` (dynamic import) | AI 模型 | +| `MyLoading` | `@fastgpt/web/components/common/MyLoading` | 公共组件库 | +| `MyNumberInput` | `@fastgpt/web/components/common/Input/NumberInput` | 公共组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 公共组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b9d7b4cbb51c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/API\347\264\242\345\274\225.md" @@ -0,0 +1,104 @@ +--- +capability_label: 对话 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: ["登录用户", "匿名用户", "团队成员"] +router_paths: ["/chat", "/chat/share"] +--- + +# 对话 — API索引 + +## 查询/初始化 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/init` | GET | 初始化在线聊天(获取应用配置、对话历史、变量) | `src/web/core/chat/api.ts:31` → `HomeChatWindow.tsx`、`AppChatWindow.tsx` | 对话首页→首页自由对话→加载时调用;对话首页→最近使用应用对话→加载时调用 | +| `/api/core/chat/outLink/init` | GET | 初始化外链聊天(获取发布应用配置和对话历史) | `src/web/core/chat/api.ts:33` → `share.tsx:OutLink` | 分享对话→加载时调用 | +| `/api/core/chat/team/init` | GET | 初始化团队空间聊天 | `src/web/core/chat/api.ts:35` | 对话首页→团队应用对话→选择应用加载时调用 | +| `/api/core/chat/recentlyUsed` | GET | 获取最近使用的应用列表 | `src/web/core/chat/api.ts:25` | 对话首页→侧边栏最近使用→加载时调用 | + +## 流式对话 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/v2/chat/completions` | POST | 发起流式对话(SSE),支持 detail 模式返回节点级响应 | `src/web/common/api/fetch.ts:557` → `HomeChatWindow.tsx`、`AppChatWindow.tsx`、`share.tsx:OutLink` | 对话首页→首页自由对话→发送消息时调用;对话首页→最近使用应用对话→发送消息时调用;分享对话→发送消息时调用 | +| `/api/proApi/core/chat/chatHome` | POST | 首页自由对话(含模型和工具选择,不依赖预配置应用) | `src/web/common/api/fetch.ts:557`(streamFetch 参数传入)→ `HomeChatWindow.tsx:onStartChat` | 对话首页→首页自由对话→非快捷应用发送消息时调用 | + +## 对话控制 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/stop` | POST | 停止正在生成的对话 | `src/web/core/chat/api.ts:60` | 对话首页/分享对话→用户点击停止生成时调用 | + +## 会话管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/history/markRead` | POST | 标记对话为已读 | ChatRecordContext → 内部调用 | 对话页→查看未读消息后自动标记 | +| `/api/core/chat/history/update` | POST | 更新对话标题或置顶状态 | ChatContext → 内部调用 | 对话页→发送第一条消息后自动生成标题 | +| `/api/core/chat/history/delete` | DELETE | 删除单个对话历史 | ChatHistorySidebar → 内部调用 | 对话页→侧边栏对话列表→右键菜单删除 | +| `/api/core/chat/history/clear` | DELETE | 清空某应用的全部对话历史 | ChatHistorySidebar → 内部调用 | 对话页→侧边栏→确认清空历史 | + +## 对话设置(Plus 版) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/proApi/core/chat/setting/detail` | GET | 获取对话首页设置(快捷应用、首页配置) | `src/web/core/chat/api.ts:39` → `ChatSetting` | 对话首页→对话设置→加载时调用 | +| `/api/proApi/core/chat/setting/update` | POST | 更新对话首页设置 | `src/web/core/chat/api.ts:41` → `ChatSetting` | 对话首页→对话设置→保存配置时调用 | +| `/api/proApi/core/chat/setting/favourite/list` | GET | 获取收藏应用列表 | `src/web/core/chat/api.ts:44` → `ChatSetting` | 对话首页→对话设置→快捷应用管理→加载时调用 | +| `/api/proApi/core/chat/setting/favourite/update` | POST | 批量更新收藏应用信息 | `src/web/core/chat/api.ts:47` → `ChatSetting` | 对话首页→对话设置→编辑快捷应用时调用 | +| `/api/proApi/core/chat/setting/favourite/order` | PUT | 更新收藏应用排序 | `src/web/core/chat/api.ts:50` → `ChatSetting` | 对话首页→对话设置→拖拽调整快捷应用顺序时调用 | +| `/api/proApi/core/chat/setting/favourite/tags` | PUT | 更新收藏应用标签 | `src/web/core/chat/api.ts:53` → `ChatSetting` | 对话首页→对话设置→修改快捷应用标签时调用 | +| `/api/proApi/core/chat/setting/favourite/delete` | DELETE | 删除收藏应用 | `src/web/core/chat/api.ts:56` → `ChatSetting` | 对话首页→对话设置→移除快捷应用时调用 | + +## 助手功能 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/assistant/getQuote` | POST | 获取知识库引用列表(按源类型分组) | `src/web/core/chat/api.ts:66` → `ChatQuoteList` | 对话页→点击引用标记→加载引用详情时调用 | +| `/api/core/chat/assistant/getRetrievalResults` | POST | 获取检索排序结果 | `src/web/core/chat/api.ts:72` → `ChatQuoteList` | 对话页→查看知识库检索结果时调用 | + +## API 调用链追踪 + +### `/api/v2/chat/completions` 调用链 + +``` +ChatBox(发送消息) + ├── 触发: 用户在输入框输入内容并发送 + ├── 参数: messages(消息列表)、variables(变量)、appId、chatId、retainDatasetCite、showSkillReferences + ├── 响应处理: SSE 流式解析 → 逐 token 更新 UI → 完成时更新对话标题和历史 + └── 错误处理: 网络异常→显示错误提示;abort→停止生成并保留已生成内容 + +HomeChatWindow(首页自由对话 — 快捷应用模式) + ├── 触发: 用户在快捷应用输入框发送消息 + ├── 参数: messages、appId、chatId、variables + └── 附加: 与 ChatBox 共享同一 streamFetch 调用路径 + +HomeChatWindow(首页自由对话 — 自由模式) + ├── URL: /api/proApi/core/chat/chatHome + ├── 触发: 用户在首页自由对话输入框发送消息 + ├── 参数: messages、appId、selectedModel、selectedTools、workflow form + └── 响应处理: SSE 流式解析 → 逐 token 更新 UI → 完成时更新标题 + +OutLink share.tsx(分享对话) + ├── 触发: 外部用户在分享页输入框发送消息 + ├── 参数: messages、outLinkAuthData(shareId + outLinkUid)、variables、retainDatasetCite + ├── 响应处理: SSE 流式解析 → 逐 token 更新 → postMessage 通知父窗口(嵌入模式) + └── 错误处理: 超时/网络异常→显示错误提示 +``` + +### `/api/core/chat/init` 调用链 + +``` +HomeChatWindow → getInitChatInfo + ├── 触发: 切换应用(appId 变化)或对话ID变化时 + ├── 参数: appId, chatId + └── 响应处理: 填充 ChatBox 数据(title、app 配置、variables) → 重置变量表单 + +AppChatWindow → getInitChatInfo + ├── 触发: 从最近使用列表选择应用进入 + ├── 参数: appId, chatId + └── 响应处理: 恢复历史对话 → 加载应用配置和聊天设置 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..6fcc7d9a3bcc --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/API\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: "分享对话" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — API索引 + +## 查询/初始化 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/chat/outLink/init` | GET | 获取分享链接聊天初始化数据(应用配置、变量、欢迎语等) | `web/core/chat/api.ts:33` → `pages/chat/share.tsx:146` | 分享对话→页面加载→身份确认后调用;分享对话→切换对话→切换 chatId 时重新调用 | +| `/core/chat/init` | GET | 获取聊天初始化信息(用于权限验证) | `web/core/chat/api.ts:31` → `pages/chat/share.tsx:518,540` | 分享对话→强制认证模式→登录后验证应用权限时调用 | + +## 流式对话 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/v2/chat/completions` | POST | 发送对话消息并获取流式 AI 回复(SSE) | `web/common/api/fetch.ts:558` → `pages/chat/share.tsx:212` | 分享对话→用户输入消息→发送对话请求时调用 | +| `/api/v2/chat/completions`(恢复) | POST | 恢复未完成的流式对话(断点续传) | `web/common/api/fetch.ts:603` → `ChatBox` 自动触发 | 分享对话→页面加载→检测到未完成对话→自动恢复流式连接 | + +## 登录与身份 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `getTokenLogin` | POST | 通过 token 自动登录 | `web/support/user/api.ts` → `pages/chat/share.tsx:515` | 分享对话→强制认证模式→页面加载时尝试自动登录 | + +--- + +## API 调用链追踪 + +### `/core/chat/outLink/init` 调用链 + +``` +share.tsx (OutLink 组件) + ├── 触发: 页面加载,outLinkAuthData 就绪后自动调用 + ├── 参数: { chatId, shareId, outLinkUid } + └── 响应处理: + ├── setChatBoxData(res) — 设置 ChatBox 初始数据 + ├── resetVariables({ variables: {...formatedCustomVariables, ...res.variables} }) — 合并变量 + └── onFinally: 清除 forbidLoadChatMap 标记 +``` + +### `/api/v2/chat/completions` 调用链 + +``` +share.tsx (startChat 回调) + ├── 触发: 用户在 ChatBox 中输入消息并发送 + ├── 参数: + │ ├── messages: [当前轮次消息] + │ ├── variables: { ...formValues, ...customVariables, cTime } + │ ├── chatId: completionChatId + │ ├── shareId, outLinkUid (outLinkAuthData) + │ ├── detail: true, stream: true + │ └── retainDatasetCite: isShowCite + ├── 响应处理 (onMessage): + │ ├── 流式文本追加到对话列表 + │ ├── 中间状态(工具调用、思考等)接入展示 + │ └── 完成后 postMessage('shareChatFinish') (iframe 模式) + └── 完成后: + ├── 新对话: onChangeChatId 切换 + └── onUpdateHistoryTitle 更新标题 +``` + +### `/core/chat/init` 调用链 + +``` +share.tsx (AutoLoginChecker) + ├── 触发: allowAnonymous === false 时登录成功后 + ├── 参数: { appId, chatId (nanoid) } + └── 响应处理: + ├── 成功 → setIsConfirmed(true) 进入对话 + └── 失败 (code >= 502000) → toast 提示"无权限访问该应用" +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..383af091c934 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,57 @@ +--- +capability_label: "分享对话" +doc_type: "14" +doc_label: "Store数据流" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — Store数据流 + +## 模块专属 Store + +### useShareChatStore + +- **文件位置**: `projects/app/src/web/core/chat/storeShareChat.ts` +- **Store 类型**: Zustand(persist + immer + devtools) +- **持久化 key**: `shareChatStore` + +#### 状态结构 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `localUId` | `string \| undefined` | `undefined` | 本地用户唯一标识,用于匿名分享对话的身份追踪 | +| `loaded` | `boolean` | `false` | 持久化数据是否已从 localStorage 恢复 | + +#### 操作方法 + +| 方法 | 签名 | 说明 | +|------|------|------| +| `setLocalUId` | `(localUId: string) => void` | 设置本地用户 UID | + +#### 数据流 + +``` +页面加载 + → useShareChatStore 从 localStorage 恢复 localUId + → loaded = true + → 检查 localUId 是否存在: + ├── 不存在 → setLocalUId(`shareChat-${Date.now()}-${getNanoid(24)}`) 生成新 ID + └── 存在 → 直接使用已有 localUId + → localUId 作为 outLinkUid 传给 ChatContextProvider + → 后端通过 outLinkUid 关联对话记录 +``` + +#### 持久化策略 + +- `partialize` 仅持久化 `localUId` 字段(排除 `loaded` 和 `setLocalUId`) +- 通过 `localStorage` 存储,跨会话保持同一匿名用户身份 + +## 消费的外部 Store + +| Store | 文件位置 | 使用方式 | 用途 | +|-------|---------|---------|------| +| `useChatStore` | `web/core/chat/context/useChatStore.ts` | `useChatStore()` | 管理聊天状态:source、appId、chatId、outLinkAuthData | +| `useSystemStore` | `web/common/system/useSystemStore.ts` | `useSystemStore()` | 读取系统配置 `feConfigs`(系统标题等) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..508a67475f11 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\210\206\344\272\253\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,55 @@ +--- +capability_label: "分享对话" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:05:00.000Z" +parent_module: "对话" +roles: ["匿名用户", "认证用户"] +router_paths: ["/chat/share"] +--- + +# 分享对话 — 组件列表 + +## pages/(页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/chat/share.tsx` | 分享对话页面(含 OutLink 子组件 + Render 入口组件) | 引用自 ChatBox, ChatHeader, ChatHistorySidebar, ChatContextProvider 等 | 否 | shareId: string, appId: string, authToken: string, allowAnonymous: boolean | + +## pageComponents/(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/chat/ChatHeader.tsx` | 分享页顶部对话头部(显示应用名称、设置按钮等) | 内部 | 否 | history: array, totalRecordsCount: number, hideMenu: boolean | +| `projects/app/src/pageComponents/chat/slider/ChatSliderSidebar.tsx` | PC 端分享对话历史侧边栏(含分享模式样式) | 内部 | 否 | isShareMode: boolean | +| `projects/app/src/pageComponents/chat/slider/ChatSliderMobileDrawer.tsx` | 移动端分享对话历史抽屉 | 内部 | 否 | showHeader: boolean, showFooter: boolean | +| `projects/app/src/pageComponents/chat/ChatQuoteList/QuoteReader.tsx` | 原文引用阅读器(知识库原文查看) | 内部 | 否 | rawSearch: string, metadata: object, onClose: fn | +| `projects/app/src/pageComponents/chat/ChatQuoteList/ReferencePanel.tsx` | 知识库集合引用面板(数据集内容查看) | 内部 | 否 | rawSearch: string, metadata: object, onClose: fn | +| `projects/app/src/pageComponents/chat/CustomPluginRunBox.tsx` | 插件模式对话容器(动态加载) | 内部 | 否 | appId: string, chatId: string, outLinkAuthData: object | +| `projects/app/src/pageComponents/login/LoginModal.tsx` | 登录弹窗(强制认证模式使用) | 内部 | 否 | onSuccess: fn, teamId: string | + +## components/(通用组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx` | 核心对话容器(消息列表 + 输入框 + 流式渲染) | 内部 | 否 | chatType: ChatTypeEnum, appId: string, chatId: string, onStartChat: fn | +| `projects/app/src/components/SideBar.tsx` | 可伸缩侧边栏容器 | 内部 | 否 | w: array, externalTrigger: boolean | +| `projects/app/src/components/PageContainer.tsx` | 页面容器(含路由过渡和布局) | 内部 | 否 | flex: string, w: number, p: string | +| `projects/app/src/components/common/ResizableDivider.tsx` | 可拖拽分隔线(侧边栏/引用面板宽度调整) | 内部 | 否 | minWidth: number, maxWidth: number, direction: string | +| `projects/app/src/components/common/NextHead.tsx` | HTML head 动态设置(title/desc/icon) | 内部 | 否 | title: string, desc: string, icon: string | + +## packages/web/components/(共享组件库) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `@fastgpt/web/components/common/MyBox` | 通用容器(加载状态、布局支持) | — | 是 | isLoading: boolean, display: string, h: string | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 共享组件库 | +| `ChatContextProvider` | `@/web/core/chat/context/chatContext` | chat 核心 | +| `ChatItemContextProvider` | `@/web/core/chat/context/chatItemContext` | chat 核心 | +| `ChatRecordContextProvider` | `@/web/core/chat/context/chatRecordContext` | chat 核心 | +| `ChatBox` | `@/components/core/chat/ChatContainer/ChatBox` | chat 核心 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..62e49eadc321 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,52 @@ +--- +capability_label: 对话首页 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["登录用户", "管理员", "Plus版用户"] +router_paths: ["/chat"] +--- + +# 对话首页 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/chat/recentlyUsed` | GET | 获取最近使用的应用列表 | `src/web/core/chat/api.ts:25` → `src/web/core/chat/context/chatPageContext.tsx:78` | 对话首页→页面初始化→加载时调用;对话首页→30秒轮询刷新时调用 | +| `/proApi/core/chat/setting/detail` | GET | 获取对话首页设置(首页应用、布局配置) | `src/web/core/chat/api.ts:39` → `src/web/core/chat/context/chatPageContext.tsx:109` | 对话首页→页面初始化→Plus版用户加载时调用 | + +## API 调用链追踪 + +### `/core/chat/recentlyUsed` 调用链 + +``` +ChatPageContextProvider + ├── 触发: 页面加载初始化(useMount),用户 tmbId 变更时刷新 + ├── 参数: 无(自动使用当前用户 session) + ├── 轮询: 30 秒间隔自动刷新,500ms 防抖 + └── 响应处理: 更新 myApps 状态 → ChatSlider 的最近使用列表渲染 +``` + +### `/proApi/core/chat/setting/detail` 调用链 + +``` +ChatPageContextProvider + ├── 触发: 页面加载初始化(feConfigs.isPlus 为 true 时) + ├── 参数: 无 + ├── 刷新依赖: feConfigs.isPlus + └── 响应处理: + ├── enableHome 为 false 且当前在首页面板 → 自动切换到团队应用 + ├── 首页 appId 不匹配且不在快捷列表中 → 自动跳转到配置的首页应用 + └── 更新 chatSettings 状态 → 各子组件消费 +``` + +## 说明 + +本模块作为对话入口页面,API 调用主要集中在初始化阶段。各 Tab 面板的 API 调用见对应子能力文档。服务端渲染阶段还有以下数据库查询(非 HTTP API): + +- `MongoOutLink.findOne` — 查询发布渠道外链配置(`fromPublish` 存在时) +- `MongoApp.findById` — 查询应用所属团队 ID + +此外,`initUserInfo`(useUserStore)在页面加载时执行用户信息初始化,属于前端 Store 操作,非直接 HTTP API 调用。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..b021bbded816 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,18 @@ +--- +capability_label: 对话首页 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["全部角色"] +router_paths: ["/chat"] +--- + +# 对话首页 — Hooks工具函数 + +## 说明 + +本模块未检测到独立的 Hooks/Composables/Utils/Constants 导出。模块使用的 hooks(如 `useSystem`、`useContextSelector`)均来自外部共享包(`@fastgpt/web/hooks`、`use-context-selector`)。 + +- 如果需要了解组件的状态管理逻辑,参见 [14-Store数据流](./Store数据流.md) +- 组件信息参见 [13-组件列表](./组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..808913099000 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,131 @@ +--- +capability_label: 对话首页 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["全部角色"] +router_paths: ["/chat"] +--- + +# 对话首页 — Store数据流 + +## Store 概览 + +| Store 文件 | Store ID | 用途 | +|-----------|---------|------| +| `src/web/core/chat/context/useChatStore.ts` | `useChatStore` | 对话级全局状态:appId、chatId、source、面板记忆 | +| `src/web/core/chat/context/chatPageContext.tsx` | `ChatPageContext` | 对话页面级状态:面板切换、折叠、用户信息、最近应用 | + +### useChatStore — `src/web/core/chat/context/useChatStore.ts` + +> Zustand + immer + persist 实现。sessionStorage 存储敏感字段(source、chatId、appId),localStorage 存储持久字段(lastChatId、lastChatAppId、lastPane)。支持跨 Tab localStorage 事件同步。 + +#### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `source` | `ChatSourceEnum` | 对话来源(online/share/team) | +| `appId` | `string` | 当前对话的应用 ID | +| `lastChatAppId` | `string` | 上次使用的应用 ID(跨 Tab 持久化) | +| `chatId` | `string` | 当前对话 ID(24 位 nanoid) | +| `lastChatId` | `string` | 上次对话 ID(`{source}-{chatId}` 格式) | +| `lastPane` | `ChatSidebarPaneEnum` | 上次激活的面板 | +| `outLinkAuthData` | `OutLinkChatAuthProps` | 外链鉴权数据 | + +#### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `setSource` | `e: ChatSourceEnum` | 设置对话来源,来源变更时重置 chatId,恢复上次 appId | 无 | +| `setAppId` | `e: string` | 设置应用 ID,同步更新 lastChatAppId | 无 | +| `setChatId` | `e?: string` | 设置对话 ID,自动生成 lastChatId | 无 | +| `setLastPane` | `e: ChatSidebarPaneEnum` | 记录上次激活面板(跨页面对话恢复用) | 无 | +| `setLastChatAppId` | `e: string` | 设置上次应用 ID | 无 | +| `setOutLinkAuthData` | `e: OutLinkChatAuthProps` | 设置外链鉴权数据 | 无 | + +### ChatPageContext — `src/web/core/chat/context/chatPageContext.tsx` + +> React Context 实现,由 ChatPageContextProvider 提供。管理对话页面的 UI 状态和用户上下文。 + +#### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `pane` | `ChatSidebarPaneEnum` | 当前激活的面板(HOME/TEAM_APPS/FAVORITE_APPS/RECENTLY_USED_APPS/SETTING) | +| `collapse` | `CollapseStatusType`(0\|1) | 侧边栏折叠状态(0=展开,1=折叠) | +| `chatSettings` | `ChatSettingType` | 对话首页设置(Plus版),含首页应用、快捷应用列表 | +| `isInitedUser` | `boolean` | 用户信息是否初始化完成 | +| `userInfo` | `UserType` | 当前用户信息 | +| `myApps` | `GetRecentlyUsedAppsResponseType` | 最近使用应用列表 | + +#### Actions / 操作方法 + +| 操作 | 参数 | 说明 | 调用的 API | +|------|------|------|-----------| +| `handlePaneChange` | `pane, _id?, _tab?` | 切换面板,更新 URL query | `router.replace` | +| `onTriggerCollapse` | 无 | 切换侧边栏折叠状态 | 无 | +| `refreshChatSetting` | 无 | 刷新对话首页设置 | `GET /proApi/core/chat/setting/detail` | +| `refreshRecentlyUsed` | 无 | 刷新最近使用应用列表 | `GET /core/chat/recentlyUsed` | + +## 数据流向 + +### 页面初始化流程 + +``` +ChatPageContextProvider useChatStore API + │ │ │ + │ setAppId(routeAppId) │ │ + ├─────────────────────────────────────►│ │ + │ │ │ + │ initUserInfo() │ │ + ├─────────────────────────────────────────────────────────────►│ + │ ◄── userInfo ─────────────────────────────────────────────┤ + │ │ │ + │ setSource('online') │ │ + ├─────────────────────────────────────►│ │ + │ │ │ + │ isInitedUser = true │ │ + │ │ │ + │ getRecentlyUsedApps() │ │ + ├─────────────────────────────────────────────────────────────►│ + │ ◄── myApps[] ─────────────────────────────────────────────┤ + │ │ │ + │ getChatSetting() [Plus only] │ │ + ├─────────────────────────────────────────────────────────────►│ + │ ◄── chatSettings ─────────────────────────────────────────┤ + │ │ │ + │ auto-redirect if enableHome=false │ │ + │ or appId mismatch │ │ +``` + +### 面板切换流程 + +``` +User ChatPageContext useChatStore Router + │ │ │ │ + │ 点击侧边栏导航 │ │ │ + ├────────────────────────►│ │ │ + │ │ handlePaneChange(pane, id)│ │ + │ ├───────────────────────────►│ │ + │ │ │ setLastPane(pane) │ + │ │ │ setLastChatAppId(id) │ + │ │ │ │ + │ │ router.replace({query}) │ + │ ├───────────────────────────────────────────────────────►│ + │ │ │ │ + │ 页面更新(URL参数变更) │ │ │ + │ 主内容区切换面板组件 │ │ │ + │ 侧边栏高亮切换 │ │ │ +``` + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| Context 共享状态 | 面板状态(pane)→ 所有子组件条件渲染 | ChatPageContextProvider → Chat → HomeChatWindow / ChatFavouriteApp / ChatTeamApp / AppChatWindow / ChatSetting | +| Context 共享状态 | 用户信息 → 鉴权判断 | ChatPageContext → ChatContent(isInitedUser / userInfo) | +| Zustand Store 共享 | 应用 ID 跨组件同步 | useChatStore ← ChatPageContextProvider, ChatContextProvider, 各对话窗口 | +| URL 路由参数 | 面板切换同步 | ChatPageContext → Next.js Router(pane / appId / tab query) | +| Props 传递 | 发布配置项 → 各级 Context Provider | ChatContent → ChatItemContextProvider(showRunningStatus 等) | +| localStorage 跨 Tab 同步 | chatStore 持久化字段同步 | useChatStore → window.storage 事件监听 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..fc9c43211765 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,18 @@ +--- +capability_label: 对话首页 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["全部角色"] +router_paths: ["/chat"] +--- + +# 对话首页 — 共享组件清单 + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无 shared/ 目录)。本模块作为对话入口页面,其子组件(ChatSlider、HomeChatWindow 等)均为内部组件,仅供本模块内部使用。 + +- 内部组件列表见 [13-组件列表](./组件列表.md) +- 跨模块依赖摘要见 [08-模块概览](../../业务知识库/对话/对话首页/模块概览.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c33d8432d5ef --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,83 @@ +--- +capability_label: 团队应用 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:04:37Z" +parent_module: 对话首页 +roles: [普通用户, 团队成员] +router_paths: [] +--- + +# 团队应用 — API索引 + +## 说明 + +本模块未定义独立的 API 文件,所有 API 调用通过共享的 `@/web/core/app/api` 层发起。模块组件通过 `AppListContext` 间接调用这些 API。 + +## API 总览 + +### 应用列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/list` | POST | 分页获取团队应用列表 | `web/core/app/api.ts:23` → `dashboard/agent/context.tsx:119` | 团队应用→浏览→面板初始化时调用;团队应用→浏览→滚动加载更多时调用;团队应用→搜索→防抖后调用;团队应用→文件夹→进入时调用 | +| `/core/app/list` | POST | 不分页获取应用列表 | `web/core/app/api.ts:15` → `dashboard/agent/context.tsx:179` | 团队应用→文件夹→移动应用时获取可选目标文件夹列表 | + +### 文件夹相关 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/detail` | GET | 获取文件夹详情 | `web/core/app/api.ts:45` → `dashboard/agent/context.tsx:148` | 团队应用→文件夹→进入文件夹时调用(获取当前文件夹权限信息) | + +### 应用操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/update` | PUT | 更新应用信息(含移动) | `web/core/app/api.ts:50` → `dashboard/agent/context.tsx:157` | 团队应用→文件夹→移动应用到其他文件夹时调用 | +| `/core/app/del` | DELETE | 删除应用 | `web/core/app/api.ts:40` → `dashboard/agent/List.tsx:109` | 团队应用(间接)→应用卡片→删除确认后调用 | + +## API 调用链追踪 + +### `POST /core/app/list` 调用链 + +``` +AppListContextProvider (dashboard/agent/context.tsx:117-126) + ├── 触发: 组件挂载 / parentId 变化 / searchKey 防抖后变化 + ├── 参数: { pageNum, pageSize, parentId, type: [...] , searchKey } + ├── 分页: 首次 pageNum=1,滚动加载 pageNum 递增 + │ type 参数: /chat 路由下为 [folder, toolFolder, simple, workflow, workflowTool, chatAgent, assistant] + └── 响应处理: myApps 列表更新 → 触发 List 组件重新渲染 + +AppListContextProvider.getAppFolderList (dashboard/agent/context.tsx:173-191) + ├── 触发: 移动应用弹窗打开时 + ├── 参数: { parentId, type: folder } + └── 响应处理: 过滤有写权限的文件夹,返回 id+name 用于目标选择 +``` + +### `GET /core/app/detail` 调用链 + +``` +AppListContextProvider (dashboard/agent/context.tsx:146-155) + ├── 触发: parentId 变化时(进入文件夹) + ├── 参数: parentId (当前文件夹 ID) + ├── 条件: parentId 非空时才调用 + └── 响应处理: folderDetail 更新 → 用于权限判断和创建按钮显隐 +``` + +### `PUT /core/app/update` 调用链 + +``` +onMoveApp (dashboard/agent/context.tsx:164-171) + ├── 触发: 移动应用弹窗确认时 + ├── 参数: { parentId: 目标文件夹ID } + └── 响应处理: 刷新文件夹详情 + 面包屑路径 + 应用列表 +``` + +### 文件夹路径 API + +``` +refetchPaths (dashboard/agent/context.tsx:138-144) + ├── 触发: parentId 变化时 + ├── 参数: { sourceId: parentId, type: 'current' } + └── 响应处理: paths 更新 → 渲染面包屑 FolderPath 组件 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..d464672ebd40 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\345\233\242\351\230\237\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,92 @@ +--- +capability_label: 团队应用 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:04:37Z" +parent_module: 对话首页 +roles: [普通用户, 团队成员] +router_paths: [] +--- + +# 团队应用 — 组件列表 + +## pageComponents/chat/ChatTeamApp/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatTeamApp/index.tsx` | 团队应用面板容器:管理类型筛选 Tab、搜索、面包屑导航、移动端布局 | 引用自 `dashboard/agent/context`、`chatContext`、`chatPageContext` | 否 | 无(无需外部 Props,自包含容器) | +| `pageComponents/chat/ChatTeamApp/List.tsx` | 应用卡片网格列表:按类型过滤、无限滚动分页、点击导航 | 引用自 `dashboard/agent/context`、`chatPageContext` | 是 → 被 dashboard/agent、dashboard/skill、dashboard/tool、dataset/list 引用 | `appType: AppTypeEnum \| 'all'` | +| `pageComponents/chat/ChatTeamApp/TypeTag.tsx` | 应用类型标签:展示应用类型图标和名称的彩色标签 | 引用自 `global/core/app/constants` | 是 → 被 dashboard/agent/AppCard 引用 | `type: AppTypeEnum` | + +## 组件详情 + +### ContextRender(index.tsx 默认导出) + +容器组件,负责: +- 包裹 `AppListContextProvider` 提供应用列表上下文 +- 内部 `MyApps` 组件实现具体 UI + +**内部状态**: +- `appType`:当前选中的类型筛选(`AppTypeEnum | 'all'`) +- `searchKey`:搜索关键词(委托给 AppListContext 管理) + +**响应式布局**: +- PC 端(`isPc === true`):顶部 Chakra UI `` + `` +- 移动端(`isPc === false`):左侧菜单图标 + 居中搜索框 + `ChatSliderMobileDrawer` + +**Tab 配置**: +- `all`:全部团队应用 +- `assistant`:智能客服 +- `workflow`:工作流机器人 +- `workflowTool`:工作流工具 + +### List + +展示应用卡片网格,是主要的列表渲染组件。关键行为: + +- **类型过滤**:从 `AppListContext.myApps` 中按 `appType` 前端过滤(`all` 不过滤) +- **文件夹导航**:点击文件夹卡片更新 `parentId` 查询参数 +- **应用导航**:点击非文件夹卡片调用 `handlePaneChange(RECENTLY_USED_APPS, app._id)` +- **时间显示**:仅 PC 端显示更新时间(移动端隐藏以节省空间) +- **空状态**:列表为空时显示"暂无应用"提示(`EmptyTip` 组件) + +### AppTypeTag + +应用类型标签组件,显示带颜色编码的类型标签: + +| 应用类型 | 标签颜色 | 图标 | +|---------|---------|------| +| Agent / Chat Agent | 蓝色 `#0884DD` | `core/app/type/simple` | +| 工作流机器人 | 紫色 `#6F5DD7` | `core/app/type/workflow` | +| 工作流工具 | 绿色 `#007E7C` | `core/app/type/plugin` | +| HTTP 工具 | 粉色 `#E82F72` | `core/app/type/httpPlugin` | +| MCP 工具集 | 默认 | `core/app/type/mcpTools` | +| 智能客服 | 橙色 `#FF8A00` | `core/app/type/simple` | + +> `folder`、`tool`、`hidden` 类型不显示标签(返回 null)。 + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AppListContextProvider` | `@/pageComponents/dashboard/agent/context` | 工作台-应用工作台 | +| `FolderPath` | `@/components/common/folder/Path` | 公共组件 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 通用组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 通用组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 通用组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 通用组件库 | +| `UserBox` | `@fastgpt/web/components/common/UserBox` | 通用组件库 | +| `NextHead` | `@/components/common/NextHead` | 公共组件 | +| `ChatSliderMobileDrawer` | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话 | + +## 消费的外部 Hooks/Context + +| 使用项 | 导入路径 | 说明 | +|--------|---------|------| +| `AppListContext` | `@/pageComponents/dashboard/agent/context` | 应用列表数据与操作 | +| `ChatContext` | `@/web/core/chat/context/chatContext` | 移动端侧边栏控制 | +| `ChatPageContext` | `@/web/core/chat/context/chatPageContext` | 面板切换 | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | PC/移动端判断 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置(feConfigs) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..7fdb5a3d7a0a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,65 @@ +--- +capability_label: 最近使用 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [所有登录用户] +router_paths: [/chat] +--- + +# 最近使用 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /api/core/chat/recentlyUsed | GET | 获取当前用户最近使用的应用列表(最多20个,按最后使用时间倒序) | `projects/app/src/web/core/chat/api.ts:25` → `projects/app/src/web/core/chat/context/chatPageContext.tsx:78` | 对话首页→侧边栏→加载时调用(初始化);对话首页→侧边栏→30秒轮询刷新时调用;对话首页→完成对话后手动刷新时调用 | + +## 对话初始化 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /api/core/chat/init | GET | 获取聊天初始化数据(应用信息、变量、历史消息等) | `projects/app/src/web/core/chat/api.ts:31` → `projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx:65` | 对话首页→最近使用→进入应用聊天窗口→加载时调用;切换应用时调用 | + +## 说明 + +本模块的 API 定义复用父模块"对话首页"的共享 API 文件 `projects/app/src/web/core/chat/api.ts`,无独立的 API 定义文件。模块通过 `ChatPageContext` 的 `refreshRecentlyUsed` 方法间接触发 API 调用,实际的 HTTP 请求由 `useRequest` Hook 管理。 + +**核心 API**: +- 获取最近使用列表: `GET /api/core/chat/recentlyUsed`,无请求参数,返回 `[{appId, name, avatar}]` +- 初始化聊天信息: `GET /api/core/chat/init?appId={appId}&chatId={chatId}` + +## API 调用链追踪 + +### `/api/core/chat/recentlyUsed` 调用链 + +``` +ChatPageContextProvider + ├── 触发: 页面挂载时自动调用 / 30秒轮询 / Chat 完成后手动 refresh + ├── refreshDeps: [userInfo?.team?.tmbId](用户团队信息变化时重新请求) + ├── 参数: 无请求参数,后端通过 auth token 获取 tmbId + ├── 节流: 500ms throttle + ├── 响应处理: 更新 myApps 状态 → ChatSlider 消费 myApps 渲染列表 + └── 错误处理: 静默失败(errorToast: ''),不阻断页面 + +AppChatWindow + ├── 触发: onStartChat 完成对话后调用 refreshRecentlyUsed() + ├── 触发: 检测到未授权应用错误 (AppErrEnum.unAuthApp) 时调用 refreshRecentlyUsed() + └── 效果: 侧边栏最近使用列表更新,将当前应用排到顶部 +``` + +### `/api/core/chat/init` 调用链 + +``` +AppChatWindow + ├── 触发: 组件挂载时 / appId 或 chatId 变化时 + ├── 参数: { appId, chatId } + ├── 响应处理: setChatBoxData(res) — 设置聊天窗口数据(应用信息、历史消息、变量) + ├── 响应处理: resetVariables({ variables: res.variables, variableList: res.app?.chatConfig?.variables }) + ├── 前置条件: appId && chatId 存在且 forbidLoadChat.current 不为 true + ├── 错误处理: + │ ├── 未授权对话 (ChatErrEnum.unAuthChat) → onChangeChatId() 切换对话 + │ └── 未授权应用 (AppErrEnum.unAuthApp) → refreshRecentlyUsed() + 切换到团队应用面板 + └── 最终: forbidLoadChat.current = false(解除加载禁止标记) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..797be767a0a0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\346\234\200\350\277\221\344\275\277\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,41 @@ +--- +capability_label: 最近使用 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [所有登录用户] +router_paths: [/chat] +--- + +# 最近使用 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx` | 最近使用应用的聊天窗口主组件,负责初始化聊天数据、管理消息发送、展示聊天界面 | 消费 ChatPageContext、ChatContext、ChatItemContext、ChatRecordContext;引用 ChatHeader、ChatBox、CustomPluginRunBox、ChatHistorySidebar 等 | 否(仅内部使用) | 无外部 Props(通过 Context 获取 appId、chatId 等) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| ChatHeader | `@/pageComponents/chat/ChatHeader` | 对话首页 | +| ChatBox | `@/components/core/chat/ChatContainer/ChatBox` | 共享基础设施 | +| CustomPluginRunBox | `@/pageComponents/chat/CustomPluginRunBox`(动态导入) | 对话首页 | +| SideBar | `@/components/SideBar` | 共享基础设施 | +| ChatHistorySidebar | `@/pageComponents/chat/slider/ChatSliderSidebar` | 对话首页 | +| ChatSliderMobileDrawer | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话首页 | +| NextHead | `@/components/common/NextHead` | 共享基础设施 | + +## 说明 + +本模块仅包含一个页面级组件 `AppChatWindow`。该组件是"最近使用"面板的核心渲染组件,由父级路由页面 `projects/app/src/pages/chat/index.tsx` 根据 `pane === ChatSidebarPaneEnum.RECENTLY_USED_APPS` 条件渲染。 + +该组件高度依赖父模块"对话首页"提供的 Context 体系: +- `ChatPageContext` — 面板切换、最近使用数据与刷新 +- `ChatContext` — 对话初始化与标题更新 +- `ChatItemContext` — 对话配置(插件模式、引用显示、技能引用等) +- `ChatRecordContext` — 对话历史记录 + +组件的 UI 结构由共享基础设施中的通用组件组成(ChatBox、SideBar 等),不包含自有的业务子组件。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e1232f0ac54f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,39 @@ +--- +capability_label: 精选应用 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [普通用户, 管理员] +router_paths: [] +--- + +# 精选应用 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/favourite/list` | GET | 获取精选应用列表 | `projects/app/src/web/core/chat/api.ts:44` → `projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx:77` | 对话首页→精选应用面板→加载时调用;对话首页→精选应用面板→切换标签筛选时调用;对话首页→精选应用面板→搜索名称时调用 | + +## API 调用链追踪 + +### `/proApi/core/chat/setting/favourite/list` 调用链 + +``` +ChatFavouriteApp (projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx) + ├── 触发: 进入精选应用面板自动加载 / 切换标签 / 输入搜索关键词 + ├── 参数: { name?: string, tag?: string } + │ ├── name: 应用名称搜索关键词(空字符串表示不筛选) + │ └── tag: 分类标签 ID(空字符串表示"全部") + ├── 调用方式: 通过 getFavouriteApps() 封装函数,使用 useRequest hook + ├── 节流: throttleWait=500ms + ├── 刷新依赖: [searchAppName, selectedTag] + └── 响应处理: + ├── 成功 → 赋值 favouriteApps 状态,加载遮罩关闭 + └── 失败 → 使用 useRequest 默认错误处理 +``` + +## 说明 + +本模块仅涉及一个 API — 获取精选应用列表。精选应用的增删改(添加、删除、排序、标签分配)以及标签管理均在对话设置模块中处理,不属于本模块的 API 范围。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..6cc4eff341fd --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\262\276\351\200\211\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,31 @@ +--- +capability_label: 精选应用 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 对话首页 +roles: [普通用户, 管理员] +router_paths: [] +--- + +# 精选应用 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/chat/ChatFavouriteApp/index.tsx` | 精选应用面板,展示管理员配置的精选应用网格,支持标签筛选和名称搜索 | 引用自对话设置 Context(`ChatPageContext`)、对话 Context(`ChatContext`) | 否(仅被 `/chat` 页面内部引用) | 无外部 Props,通过 Context 获取数据 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Tabs`, `TabList`, `Tab`, `TabIndicator` | `@chakra-ui/react` | Chakra UI(外部库) | +| `Box`, `Button`, `Flex`, `Grid`, `GridItem`, `Input`, `InputGroup`, `InputLeftElement` | `@chakra-ui/react` | Chakra UI(外部库) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | +| `MyPopover` | `@fastgpt/web/components/common/MyPopover` | 共享组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库 | +| `NextHead` | `@/components/common/NextHead` | 通用组件 | +| `ChatSliderMobileDrawer` | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话模块内部 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..02de089986b3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,55 @@ +--- +capability_label: 对话首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 对话 +roles: ["全部角色"] +router_paths: ["/chat"] +--- + +# 对话首页 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/chat/index.tsx` | 对话首页入口(Render → ChatContent → Chat) | 引用自 用户系统 → LoginModal, useUserStore;引用自 对话核心 → ChatContextProvider 系列 | 否 | `appId: string, teamId: string` | + +## 内部组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/chat/slider/index.tsx` | 侧边栏导航(ChatSlider),含 Logo、导航区、最近使用列表、底部用户区 | 内部 | 否 | `activeAppId: string` | +| `src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx` | 首页对话窗口(Tab) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatFavouriteApp/index.tsx` | 精选应用面板(Tab,Plus版) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatTeamApp/index.tsx` | 团队应用面板(Tab) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatWindow/AppChatWindow.tsx` | 最近使用应用对话窗口(Tab) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatSetting/index.tsx` | 设置面板(Tab,管理员+Plus版) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatQuoteList/index.tsx` | 引用列表(弹出面板) | 内部 | 否 | — | +| `src/pageComponents/chat/ChatQuoteList/QuoteReader.tsx` | 引用阅读器 | 内部 | 否 | — | +| `src/pageComponents/chat/ChatQuoteList/ReferencePanel.tsx` | 引用预览面板 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `NextHead` | `@/components/common/NextHead` | 公共组件 | +| `PageContainer` | `@/components/PageContainer` | 公共组件 | +| `ResizableDivider` | `@/components/common/ResizableDivider` | 公共组件 | +| `LoginModal` | `@/pageComponents/login/LoginModal` | 登录模块 | + +## Context Providers + +本模块创建多层 Context Provider 嵌套结构,为各子组件提供对话上下文: + +| Provider | 路径 | 用途 | +|----------|------|------| +| `ChatPageContextProvider` | `src/web/core/chat/context/chatPageContext.tsx` | 面板状态、用户信息、最近应用、对话设置 | +| `ChatContextProvider` | `src/web/core/chat/context/chatContext.tsx` | 对话历史数据管理 | +| `ChatItemContextProvider` | `src/web/core/chat/context/chatItemContext.tsx` | 对话项状态(引用、变量、插件标签) | +| `ChatRecordContextProvider` | `src/web/core/chat/context/chatRecordContext.tsx` | 对话记录数据 | + +## 说明 + +各 Tab 子组件的详细组件列表见对应子能力文档中的组件列表。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..0c5002574201 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,83 @@ +--- +capability_label: "设置" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员"] +router_paths: ["/chat?pane=s"] +--- + +# 设置 — API索引 + +## 聊天设置 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/detail` | GET | 获取当前对话首页设置 | `web/core/chat/api.ts:39` → `web/core/chat/context/chatPageContext.tsx:112` | 对话首页→设置→页面加载时自动调用;刷新设置后调用 | +| `/proApi/core/chat/setting/update` | POST | 更新对话首页设置 | `web/core/chat/api.ts:42` → `ChatSetting/HomepageSetting/index.tsx:101` | 对话首页→设置→首页配置→保存设置时调用 | + +## 精选应用管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/favourite/list` | GET | 获取精选应用列表 | `web/core/chat/api.ts:45` → `ChatSetting/FavouriteAppSetting/index.tsx:86` | 对话首页→设置→精选应用→加载时调用;搜索/标签筛选变化时调用 | +| `/proApi/core/chat/setting/favourite/update` | POST | 批量添加/更新精选应用 | `web/core/chat/api.ts:48` → `ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx:38` | 对话首页→设置→精选应用→添加精选应用→确认时调用 | +| `/proApi/core/chat/setting/favourite/order` | PUT | 更新精选应用排序 | `web/core/chat/api.ts:51` → `ChatSetting/FavouriteAppSetting/index.tsx:103` | 对话首页→设置→精选应用→拖拽排序完成时调用 | +| `/proApi/core/chat/setting/favourite/delete` | DELETE | 删除单个精选应用 | `web/core/chat/api.ts:57` → `ChatSetting/FavouriteAppSetting/index.tsx:112` | 对话首页→设置→精选应用→点击删除→确认后调用 | +| `/proApi/core/chat/setting/favourite/tags` | PUT | 批量更新精选应用标签 | `web/core/chat/api.ts:54` → `ChatSetting/FavouriteAppSetting/TagManageModal.tsx:112` | 对话首页→设置→精选应用→标签管理→保存时调用 | + +## 日志相关 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/logs/*`(推测) | GET | 获取对话日志列表 | 通过 `LogsContextProvider` / `LogTable` 组件间接调用 | 对话首页→设置→首页日志→加载时调用;筛选条件变化时调用 | + +> 日志相关 API 通过共享组件 `LogsContextProvider`、`LogTable`、`LogChart` 间接调用,具体 API 路径在日志模块中定义。 + +## API 调用链追踪 + +### `GET /proApi/core/chat/setting/detail` 调用链 + +``` +ChatPageContextProvider + ├── 触发: 页面加载时自动调用(manual: false),依赖 feConfigs.isPlus + ├── 参数: 无 + └── 响应处理: 存储为 chatSettings;根据 enableHome 决定是否重定向 + +ChatSetting/index.tsx + ├── 触发: 通过 ChatPageContext 消费 chatSettings 数据 + └── 使用: 判断 chatSettings 是否存在以渲染 Tab 内容 +``` + +### `POST /proApi/core/chat/setting/update` 调用链 + +``` +HomepageSetting/index.tsx + ├── 触发: 管理员点击"保存"按钮 + ├── 参数: { enableHome, slogan, selectedTools, quickAppIds } + └── 响应处理: 刷新 chatSettings,显示成功 Toast + +TagManageModal.tsx + ├── 触发: 标签管理弹窗保存 + ├── 参数: { favouriteTags } + └── 响应处理: 关闭弹窗,刷新列表 +``` + +### `GET /proApi/core/chat/setting/favourite/list` 调用链 + +``` +FavouriteAppSetting/index.tsx + ├── 触发: 精选应用 Tab 加载时自动调用;搜索框输入(500ms throttle) + ├── 参数: { name?, tag? } + └── 响应处理: 更新 localFavourites 列表状态 +``` + +### `DELETE /proApi/core/chat/setting/favourite/delete` 调用链 + +``` +FavouriteAppSetting/index.tsx + ├── 触发: 管理员点击精选应用行中的删除按钮 → PopoverConfirm 确认 + ├── 参数: { id: app._id } + └── 响应处理: 移除本地列表中的该项,触发明细刷新 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..bd6e7f03e962 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,37 @@ +--- +capability_label: "设置" +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员"] +router_paths: ["/chat?pane=s"] +--- + +# 设置 — Hooks工具函数 + +## Hooks + +### `useImageUpload` + +- **位置**: `pageComponents/chat/ChatSetting/ImageUpload/hooks/useImageUpload.tsx` +- **签名**: `function useImageUpload({ maxSize, onFileSelect }: { maxSize?: number, onFileSelect: (url: string) => void }): ImageUploadReturn` +- **用途**: 封装图片上传的完整交互逻辑,包括文件选择、拖拽处理、上传状态管理和进度反馈 +- **返回值**: `{ SelectFileComponent, onOpenSelectFile, onSelectFile, isDragging, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, loading }` +- **是否对外共享**: 否(仅在 `ImageUpload/index.tsx` 组件内使用) + +## 常量 + +| 文件 | 导出项 | 类型 | 说明 | +|------|--------|------|------| +| `pageComponents/chat/constants.ts` | `ChatSettingTabOptionEnum` | Enum | 设置页 4 个 Tab 的枚举值:HOME=`h`, DATA_DASHBOARD=`d`, LOG_DETAILS=`l`, FAVOURITE_APPS=`f` | +| `pageComponents/chat/constants.ts` | `ChatSidebarPaneEnum` | Enum | 侧边栏面板枚举,含 SETTING=`s` | +| `pageComponents/chat/constants.ts` | `DEFAULT_LOGO_BANNER_URL` | String | 默认宽 Logo 地址 | +| `pageComponents/chat/constants.ts` | `DEFAULT_LOGO_BANNER_COLLAPSED_URL` | String | 默认折叠 Logo 地址 | + +> 常量完整值见源码 `projects/app/src/pageComponents/chat/constants.ts`。 + +## 函数依赖关系 + +- `ImageUpload` 组件依赖 `useImageUpload` hook(处理文件选择和上传逻辑) +- `useImageUpload` 无其他 hook 依赖,为独立 hook diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..19afe4c11f89 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,113 @@ +--- +capability_label: 收藏应用 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:30:00Z +parent_module: 设置 +roles: + - 团队管理员 +router_paths: + - /chat?pane=s&tab=f +--- + +# 收藏应用 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/favourite/list` | GET | 获取收藏应用列表(支持按名称和标签筛选) | `api.ts:44` → `index.tsx:86`
`api.ts:44` → `TagManageModal.tsx:197`
`api.ts:44` → `TagManageModal.tsx:468`
`api.ts:44` → `AddFavouriteAppModal.tsx:70` | 收藏应用→列表页→加载时调用;收藏应用→列表页→搜索/筛选时调用;收藏应用→分类管理→标签分配子面板→加载和搜索时调用;收藏应用→添加应用→弹窗初始化时调用 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/favourite/update` | POST | 批量添加/更新收藏应用 | `api.ts:47` → `AddFavouriteAppModal.tsx:91` | 收藏应用→添加应用弹窗→点击确定按钮时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/favourite/order` | PUT | 更新收藏应用排序 | `api.ts:50` → `index.tsx:103` | 收藏应用→列表页→拖拽排序释放时调用 | +| `/proApi/core/chat/setting/favourite/tags` | PUT | 更新收藏应用的标签关联 | `api.ts:53` → `TagManageModal.tsx:258` | 收藏应用→分类管理→标签分配子面板→保存时调用 | +| `/proApi/core/chat/setting/update` | POST | 更新对话设置(含分类标签列表) | `api.ts:41` → `TagManageModal.tsx:393` | 收藏应用→分类管理弹窗→创建/编辑/删除/排序标签时调用 | + +--- + +## API 调用链追踪 + +### `/proApi/core/chat/setting/favourite/list` 调用链 + +``` +FavouriteAppSetting/index.tsx (getApps) + ├── 触发: 页面加载(mount)、搜索值变化(searchAppNameValue)、筛选值变化(searchAppTagValue) + ├── 参数: { name: string, tag: string },均为可选 + ├── 节流: 500ms + └── 响应处理: setLocalFavourites(apps) 写入本地状态 + +TagManageModal.tsx (SaveTagForAppSubPanel) + ├── 触发: 子面板加载(mount)、搜索词变化(searchAppName) + ├── 参数: { name: string },用于筛选可见应用 + ├── 节流: 500ms + └── 响应处理: 用于渲染子面板中的应用勾选列表 + +TagManageModal.tsx (allFavourites) + ├── 触发: 标签管理弹窗加载、子面板开关状态变化 + ├── 参数: { name: '' },获取全量数据 + └── 响应处理: 计算每个标签关联的应用数量(tagIdToCount) + +AddFavouriteAppModal.tsx + ├── 触发: 弹窗加载(mount) + ├── 参数: 无(默认参数) + └── 响应处理: 初始化已选应用列表(selectedApps) +``` + +### `/proApi/core/chat/setting/favourite/update` 调用链 + +``` +AddFavouriteAppModal.tsx (updateFavourites) + ├── 触发: 用户点击「确定」按钮 + ├── 参数: { appId: string, order: number }[] + ├── 手动触发(manual: true) + └── 响应处理: 成功后调用 onRefresh() 刷新父组件列表,关闭弹窗 +``` + +### `/proApi/core/chat/setting/favourite/order` 调用链 + +``` +FavouriteAppSetting/index.tsx (orderApp) + ├── 触发: 拖拽释放(onDragEndCb) + ├── 参数: { id: string, order: number }[] + ├── 手动触发(manual: true) + └── 响应处理: 调用 getApps() 刷新列表 +``` + +### `/proApi/core/chat/setting/favourite/tags` 调用链 + +``` +TagManageModal.tsx (saveApps — SaveTagForAppSubPanel) + ├── 触发: 用户点击子面板「保存」按钮 + ├── 参数: { id: string, tags: string[] }[] + ├── 手动触发(manual: true) + └── 响应处理: 调用 onRefresh() 和 onClose() 退出子面板 +``` + +### `/proApi/core/chat/setting/update` 调用链 + +``` +TagManageModal.tsx (updateTags) + ├── 触发: 标签创建确认、重命名确认、删除确认、拖拽排序释放 + ├── 参数: { favouriteTags: ChatFavouriteTagType[] } + ├── 手动触发(manual: true) + └── 响应处理: 调用 refreshChatSetting() 刷新上下文中的标签数据 +``` + +### `/proApi/core/chat/setting/favourite/delete` 调用链 + +``` +FavouriteAppSetting/index.tsx (deleteApp) + ├── 触发: 用户点击删除 → 确认弹窗确认 + ├── 参数: { id: string } + ├── 手动触发(manual: true) + └── 响应处理: 乐观更新本地列表(过滤移除 + 重新编号),调用 getApps() 刷新 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9f2fe20c01c3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\224\266\350\227\217\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,41 @@ +--- +capability_label: 收藏应用 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:30:00Z +parent_module: 设置 +roles: + - 团队管理员 +router_paths: + - /chat?pane=s&tab=f +--- + +# 收藏应用 — 组件列表 + +## components/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `FavouriteAppSetting/index.tsx` | 收藏应用管理主页面——搜索筛选栏 + 可拖拽排序列表 | 引用自 @fastgpt/web(MySelect, MyIcon, Avatar, MyBox, MyPopover, DndDrag, PopoverConfirm, EmptyTip);引用自 @chakra-ui/react | 否 | `Header: React.FC`(父组件注入的顶部插槽组件) | +| `FavouriteAppSetting/TagManageModal.tsx` | 分类标签管理弹窗——标签 CRUD、拖拽排序、为标签分配应用 | 引用自 @fastgpt/web(MyModal, MyIcon, Avatar, EmptyTip, DndDrag);引用自 @chakra-ui/react | 否 | `onClose: () => void`,`onRefresh: () => Promise` | +| `FavouriteAppSetting/AddFavouriteAppModal.tsx` | 添加收藏应用弹窗——应用树浏览 + 搜索 + 多选 | 引用自 @fastgpt/web(MyModal, MyIcon, Avatar, SearchInput, EmptyTip);引用自 @/web/core/app/api;引用自 @chakra-ui/react | 否 | `onClose: () => void`,`onRefresh: () => Promise` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | @fastgpt/web | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | @fastgpt/web | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | @fastgpt/web | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | @fastgpt/web | +| `MyPopover` | `@fastgpt/web/components/common/MyPopover` | @fastgpt/web | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | @fastgpt/web | +| `DndDrag / Draggable` | `@fastgpt/web/components/common/DndDrag` | @fastgpt/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | @fastgpt/web | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | @fastgpt/web | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | @fastgpt/web | +| `FolderPath` | `@/components/common/folder/Path` | app | +| `SettingTabs` (via Header prop) | `@/pageComponents/chat/ChatSetting/SettingTabs` | chat/ChatSetting | +| `TagBox` (内联) | 定义于 `FavouriteAppSetting/index.tsx:132` | 内部 | +| `EditableTagItem` (内联) | 定义于 `TagManageModal.tsx:43` | 内部 | +| `SaveTagForAppSubPanel` (内联) | 定义于 `TagManageModal.tsx:177` | 内部 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b24079af301a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/API\347\264\242\345\274\225.md" @@ -0,0 +1,89 @@ +--- +capability_label: "数据看板" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — API索引 + +## 本模块直接调用的 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/app/logs/getChartData` | POST | 获取图表统计数据(user/chat/app 三维度) | `web/core/app/api/log.ts:42` → `pageComponents/app/detail/Logs/LogChart.tsx:150` | 数据看板→LogChart 挂载时;筛选条件(日期/渠道/时间粒度/偏移量)变化时自动重新请求 | +| `/core/app/logs/getLogKeys` | GET | 获取日志显示字段配置 | `web/core/app/api/log.ts:33` → `pageComponents/app/detail/Logs/context.tsx:79` | LogsContextProvider 挂载时自动调用(manual:false);appId 变化时重新调用 | +| `/proApi/core/app/logs/getTotalData` | GET | 获取汇总统计数据 | `web/core/app/api/log.ts:39` → `pageComponents/app/detail/Logs/TotalData.tsx` | LogChart 内的 TotalData 组件挂载时调用 | + +## API 类型定义 + +| 类型名称 | 定义位置 | 用途 | +|---------|---------|------| +| `getChartDataBody` | `packages/global/openapi/core/app/log/api.ts` | `getAppChartData` 请求参数类型 | +| `getChartDataResponse` | `packages/global/openapi/core/app/log/api.ts` | `getAppChartData` 响应类型,含 userData/chatData/appData | +| `getLogKeysQuery` | `packages/global/openapi/core/app/log/api.ts` | `getLogKeys` 请求参数类型 | +| `getLogKeysResponseType` | `packages/global/openapi/core/app/log/api.ts` | `getLogKeys` 响应类型 | +| `getTotalDataQuery` | `packages/global/openapi/core/app/log/api.ts` | `getAppTotalData` 请求参数类型 | +| `getTotalDataResponse` | `packages/global/openapi/core/app/log/api.ts` | `getAppTotalData` 响应类型 | + +## API 调用链追踪 + +### `POST /proApi/core/app/logs/getChartData` 调用链 + +``` +DataDashboard.tsx + └── 渲染 + +LogChart.tsx (4 callers: Dashboard, DataDashboard, LogChart自身) + ├── 触发: 组件挂载时自动请求(manual: !feConfigs?.isPlus) + ├── refreshDeps: [appId, dateRange.from, dateRange.to, offset, chatSources, + │ userTimespan, chatTimespan, appTimespan] + ├── 参数: + │ ├── appId: 从 ChatPageContext.chatSettings.appId 获取(回退 AppContext) + │ ├── dateStart: LogsContext.dateRange.from || new Date() + │ ├── dateEnd: addDays(LogsContext.dateRange.to, 1) + │ ├── offset: 本地 state(默认 offsetOptions[0].value = '0') + │ ├── source: LogsContext.chatSources + │ ├── userTimespan: 本地 state(默认 'day') + │ ├── chatTimespan: 本地 state(默认 'day') + │ └── appTimespan: 本地 state(默认 'day') + └── 响应处理: + ├── formatChartData (useMemo): 时间序列补全 + 缺失日期填充 + 衍生指标计算 + │ ├── generateCompleteTimeSeries(): 根据 timespan 生成完整日期轴 + │ ├── processChartData(): 数据映射 + 补全 + │ └── calculateStats(): 计算 cumulative 汇总值 + └── 渲染 3 个 Accordion 面板中的 11 张图表 +``` + +### `GET /core/app/logs/getLogKeys` 调用链 + +``` +DataDashboard.tsx + └── 渲染 + +LogsContext/context.tsx (6 callers) + ├── 触发: 组件挂载时自动请求(manual: false) + ├── refreshDeps: [appId] + ├── 参数: { appId } + └── 响应处理: + ├── onSuccess: 本地无缓存 → setLogKeysStorage(res.logKeys) + ├── 本地有缓存 → 保留本地缓存,不从接口覆盖 + └── showSyncPopover: 团队配置与本地配置不一致时显示同步提示 +``` + +## 数据请求策略 + +| 维度 | 策略 | +|------|------| +| **触发模式** | 声明式(useRequest + refreshDeps),非命令式 | +| **请求去重** | useRequest 内置去重,refreshDeps 变化时自动请求 | +| **商业版控制** | `manual: !feConfigs?.isPlus`:非商业版不发起请求,使用 fakeChartData | +| **缓存策略** | logKeys 使用 localStorage 缓存(key: `app_log_keys_{appId}`),图表数据不缓存 | +| **错误处理** | useRequest 默认 errorToast,无自定义错误处理 | +| **轮询** | 无轮询,仅在 refreshDeps 变化时请求 | +| **竞态处理** | useRequest 内置取消前次未完成请求 | + +> 注:本模块自身不定义 API 函数,所有 API 函数定义在 `web/core/app/api/log.ts` 中,由依赖的 `LogChart`、`LogsContextProvider`、`TotalData` 组件调用。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..189936346621 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\225\260\346\215\256\347\234\213\346\235\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,109 @@ +--- +capability_label: "数据看板" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "设置" +roles: ["管理员"] +router_paths: ["/chat?pane=s&tab=d"] +--- + +# 数据看板 — 组件列表 + +## ChatSetting/DataDashboard/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/DataDashboard.tsx` | 数据看板入口组件,获取 appId 并组合 Header + LogsFilterBar + LogChart | 引用 LogsContextProvider、LogChart/HeaderControl、ChatPageContext | 否 | `Header: React.FC<{ children?: ReactNode }>` | + +## 内部组件 + +| 组件 | 位置 | 是否导出 | 用途 | +|------|------|---------|------| +| `LogsFilterBar` | `DataDashboard.tsx:12`(内联定义) | 否 | 从 LogsContext 读取筛选状态,传递给 HeaderControl 渲染 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `LogsContextProvider` | `@/pageComponents/app/detail/Logs/context` | 应用详情/日志模块 | +| `LogChart` | `@/pageComponents/app/detail/Logs/LogChart`(default export) | 应用详情/日志模块 | +| `HeaderControl` | `@/pageComponents/app/detail/Logs/LogChart`(named export) | 应用详情/日志模块 | +| `Flex` | `@chakra-ui/react` | Chakra UI | + +## 组件详细规格 + +### `DataDashboard` + +- **文件**: `projects/app/src/pageComponents/chat/ChatSetting/DataDashboard.tsx` +- **类型**: React 函数式组件(`React.memo` 包裹) +- **Props**: + ```typescript + type Props = { + Header: React.FC<{ children?: React.ReactNode }>; + }; + ``` +- **State 来源**: + | 状态 | 来源 | 访问方式 | + |------|------|---------| + | `appId` | `ChatPageContext.chatSettings?.appId` | `useContextSelector(ChatPageContext, (v) => v.chatSettings?.appId \|\| '')` | +- **渲染逻辑**: + 1. 从 ChatPageContext 获取 appId + 2. 用 LogsContextProvider 包裹子组件(传入 appId) + 3. 渲染垂直 Flex 布局:Header → LogsFilterBar → LogChart +- **性能优化**: `React.memo` 包裹导出,Header prop 由父组件通过 `useCallback` 缓存 + +### `LogsFilterBar` + +- **文件**: `DataDashboard.tsx:12`(内部,不导出) +- **类型**: React 函数式组件(无 Props) +- **消费的 Context**: `LogsContext`(通过 use-context-selector 精确订阅) + | 字段 | 类型 | 用途 | + |------|------|------| + | `dateRange` | `DateRangeType` | 当前日期范围 | + | `setDateRange` | `(value: DateRangeType) => void` | 设置日期范围 | + | `chatSources` | `ChatSourceEnum[]` | 已选渠道来源 | + | `setChatSources` | `(value: ChatSourceEnum[]) => void` | 设置渠道来源 | + | `isSelectAllSource` | `boolean` | 是否全选渠道 | + | `setIsSelectAllSource` | `Dispatch>` | 设置全选状态 | +- **渲染**: `` 组件(来自 LogChart),传入上述 6 个属性和 `px={[2, 0]}` +- **与 LogDetails 的差异**: LogDetails 中的 LogsFilterBar 额外消费 `chatSearch`/`setChatSearch` 和 `logKeys`/`setLogKeys` 并渲染搜索框 + LogKeysConfigPopover;DataDashboard 版本更精简,仅做日期和渠道筛选 + +## 依赖关系图 + +``` +ChatSetting/index.tsx + └── tab === DATA_DASHBOARD → + +DataDashboard + ├── ChatPageContext (useContextSelector → appId) + └── LogsContextProvider (appId, children) + └── Flex (gap='13px', flexDir='column') + ├── Header (= SettingHeader,含 SettingTabs Tab 栏) + ├── LogsFilterBar (内部组件) + │ └── HeaderControl ← LogsContext (dateRange, chatSources, ...) + └── LogChart (appId) + ├── AppContext (回退 appId) + ├── LogsContext (dateRange, chatSources, isSelectAllSource) + ├── useSystemStore (feConfigs.isPlus) + ├── getAppChartData API (useRequest) + └── 子组件: + ├── TotalData → getAppTotalData API + └── Accordion (3 items) + ├── [0] 用户数据 (5 charts) + ├── [1] 对话数据 (4 charts) + └── [2] 应用数据 (2 charts) +``` + +## 组件通信方式 + +| 通信方向 | 方式 | 数据 | +|---------|------|------| +| ChatSetting → DataDashboard | Props | `Header` 组件引用 | +| DataDashboard → LogsContextProvider | Props | `appId` | +| DataDashboard → LogChart | Props | `appId` | +| ChatPageContext → DataDashboard | Context (use-context-selector) | `chatSettings.appId` | +| LogsContext → LogsFilterBar | Context (use-context-selector) | 筛选状态及 setter | +| LogsContext → LogChart | Context (use-context-selector) | 筛选状态 | + +> DataDashboard 自身不管理任何状态,仅作为 Context 消费者和组件组合器。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..db22268c70df --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,123 @@ +--- +capability_label: 日志详情 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +# 日志详情 — API索引 + +> 本文档建立 API 到业务场景的反向索引。本文中"日志详情"指 ChatSetting 中日志 Tab 模块直接使用的 API。 + +## 日志查询与导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/app/logs/list` | POST | 获取对话日志分页列表 | `api/log.ts:37` → `LogTable.tsx:151` | 日志详情→查看日志列表→加载时调用;日志详情→多维度筛选→筛选条件变更时调用;日志详情→批量删除→删除后刷新时调用 | +| `/api/core/app/logs/getLogKeys` | GET | 获取应用的日志列键配置 | `api/log.ts:34` → `context.tsx` | 日志详情→查看日志列表→初始化日志上下文时调用 | +| `/api/core/app/logs/updateLogKeys` | POST | 更新应用的日志列键配置 | `api/log.ts:31` → 同步弹窗 | 日志详情→配置日志列显示→同步到团队时调用 | +| `/api/core/app/logs/getUsers` | POST | 获取日志相关的用户列表 | `api/log.ts:46` → `UserFilter.tsx:61` | 日志详情→多维度筛选→用户筛选搜索时调用 | +| `/api/core/app/logs/exportLogs` | POST | 导出日志数据为 CSV | `LogTable.tsx:83` → 内联调用 | 日志详情→导出日志数据→点击导出时调用 | + +## 对话详情与记录 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/init` | GET | 获取对话初始化信息 | `chat/api.ts:32` → `DetailLogsModal.tsx:66` | 日志详情→查看单条对话详情→打开弹窗时调用 | +| `/api/core/chat/record/getRecords_v2` | POST | 获取对话记录列表 | `record/api.ts:21` → `ChatRecordContextProvider` | 日志详情→查看单条对话详情→加载对话记录时调用 | +| `/api/core/chat/history/batchDelete` | POST | 批量删除对话历史 | `history/api.ts:37` → `LogTable.tsx:180` | 日志详情→批量删除对话→确认删除时调用 | + +## 对话设置(跨能力引用) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/proApi/core/chat/setting/detail` | GET | 获取对话页设置 | `chat/api.ts` → `ChatPageContextProvider` | 来源:对话首页设置→加载时调用,日志详情通过 ChatPageContext 消费 appId | + +--- + +## API 调用链追踪 + +### `/api/core/app/logs/list` 调用链 + +``` +LogTable + ├── 触发: 进入日志详情页自动加载;筛选条件变更时重新加载;翻页/排序时加载 + ├── 参数: { appId, dateStart, dateEnd, sources?, tmbIds?, outLinkUids?, chatSearch?, feedbackFilter?, feedbackType?, unreadOnly?, errorFilter?, pageSize?, offset?, pageNum? } + └── 响应处理: 更新分页数据(list + total)→ 渲染表格行 + +LogList (应用详情中的日志页,非本模块) + ├── 触发: 进入应用日志页自动加载 + └── ... +``` + +### `/api/core/app/logs/getLogKeys` 调用链 + +``` +LogsContextProvider + ├── 触发: LogsContextProvider 挂载时自动调用(useRequest manual=false) + ├── 参数: { appId } + └── 响应处理: 存入 Context(teamLogKeys) + localStorage 缓存 +``` + +### `/api/core/app/logs/getUsers` 调用链 + +``` +UserFilter + ├── 触发: 用户筛选下拉框搜索时调用 + ├── 参数: { appId, dateStart, dateEnd, searchKey?, sources? } + └── 响应处理: 渲染用户下拉列表(头像+名称+对话数) +``` + +### `/api/core/app/logs/exportLogs` 调用链 + +``` +LogTable + ├── 触发: 点击导出按钮 + ├── 参数: { appId, dateStart, dateEnd, sources?, tmbIds?, outLinkUids?, chatSearch?, title, logKeys, sourcesMap, feedbackType?, unreadOnly?, errorFilter? } + └── 响应处理: 浏览器触发 CSV 文件下载 +``` + +### `/api/core/chat/history/batchDelete` 调用链 + +``` +LogTable + ├── 触发: 勾选日志 → 点击批量删除 → 确认弹窗确认 + ├── 参数: { appId, chatIds: string[] } + └── 响应处理: 成功提示 → 刷新日志列表 +``` + +### `/api/core/chat/init` 调用链 + +``` +DetailLogsModal / DetailLogs + ├── 触发: 点击日志记录打开详情弹窗 + ├── 参数: { appId, chatId, loadCustomFeedbacks } + └── 响应处理: 初始化 ChatRecordContext → 触发对话记录加载 +``` + +### `/api/core/chat/record/getRecords_v2` 调用链 + +``` +ChatRecordContextProvider + ├── 触发: 对话详情弹窗内自动加载 + ├── 参数: { chatId, appId, loadCustomFeedbacks, type, includeDeleted?, offset?, pageSize? } + └── 响应处理: 渲染对话消息列表(用户消息 + AI 回复 + 反馈状态) +``` + +--- + +## 后端 API 路由文件索引 + +| API 路径 | 后端路由文件 | OpenAPI 定义 | +|---------|------------|-------------| +| `/api/core/app/logs/list` | `projects/app/src/pages/api/core/app/logs/list.ts` | `packages/global/openapi/core/app/log/api.ts:119` | +| `/api/core/app/logs/getLogKeys` | `projects/app/src/pages/api/core/app/logs/getLogKeys.ts` | `packages/global/openapi/core/app/log/api.ts:9` | +| `/api/core/app/logs/updateLogKeys` | `projects/app/src/pages/api/core/app/logs/updateLogKeys.ts` | `packages/global/openapi/core/app/log/api.ts:34` | +| `/api/core/app/logs/getUsers` | `projects/app/src/pages/api/core/app/logs/getUsers.ts` | `packages/global/openapi/core/app/log/api.ts:325` | +| `/api/core/app/logs/exportLogs` | `projects/app/src/pages/api/core/app/logs/exportLogs.ts` | `packages/global/openapi/core/app/log/api.ts:178` | +| `/api/core/chat/init` | `projects/app/src/pages/api/core/chat/init.ts` | — | +| `/api/core/chat/record/getRecords_v2` | `projects/app/src/pages/api/core/chat/record/getRecords_v2.ts` | — | +| `/api/core/chat/history/batchDelete` | `projects/app/src/pages/api/core/chat/history/batchDelete.ts` | — | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..8a2bbddb2948 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,22 @@ +--- +capability_label: 日志详情 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +## 说明 + +本模块未检测到自有 Hooks/Composables/Utils/Constants 目录或无导出函数。 + +日志详情模块(LogDetails.tsx)仅使用以下外部 Hooks: +- `useContextSelector` (use-context-selector) — 从 React Context 中精确订阅状态字段 +- `useTranslation` (next-i18next) — 国际化翻译 + +实际的日志处理逻辑(`useRequest`、`usePagination`、`useTableMultipleSelect` 等)位于外部 LogTable 组件中。 + +- 组件信息参见 [13-组件列表](./组件列表.md) +- 状态管理参见 [14-Store数据流](./Store数据流.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..e68aa98c5291 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,19 @@ +--- +capability_label: 日志详情 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +## 说明 + +本模块无独立 Store(未检测到本模块范围内的 Zustand/Pinia/Redux 状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用 `LogsContext`(React Context)管理日志筛选状态(日期范围、对话来源、搜索关键词、用户筛选、列配置等),定义于 `@/pageComponents/app/detail/Logs/context.tsx` +- 使用 `ChatPageContext`(React Context)获取应用设置信息(含 appId),定义于 `@/web/core/chat/context/chatPageContext.tsx` +- 使用 `useLocalStorageState`(ahooks)持久化日志列配置到浏览器本地存储 +- 日志列配置同时支持从团队后端同步(通过 `getLogKeys` API) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..7845e6bfb1ad --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,23 @@ +--- +capability_label: 日志详情 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +## 说明 + +本模块无对外共享组件。 + +日志详情模块的核心 UI 由以下外部模块的组件构成,本模块仅负责组合: + +- LogTable — 来自 `@/pageComponents/app/detail/Logs/LogTable` +- HeaderControl — 来自 `@/pageComponents/app/detail/Logs/LogChart` +- LogKeysConfigPopover — 来自 `@/pageComponents/app/detail/Logs/LogKeysConfigPopover` + +本模块自有的 `LogsFilterBar` 为内部组件(非导出),不对外共享。 + +消费的外部组件详情请参见 [13-组件列表](./组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..21b049c4e4c8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\346\227\245\345\277\227\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,36 @@ +--- +capability_label: 日志详情 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 设置 +roles: [管理员] +router_paths: ["/chat?pane=s&tab=l"] +--- + +# 日志详情 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/chat/ChatSetting/LogDetails.tsx` | 日志详情页面主组件,组合 Header、筛选栏和日志表格 | 引用自 app/detail/Logs(LogTable, LogsContext, HeaderControl, LogKeysConfigPopover);引用自 web/core/chat/context(ChatPageContext) | 否 | `Header: React.FC` | + +## 消费的外部组件 + +本模块作为 ChatSetting 的叶子 Tab,核心能力来自对 app/detail/Logs 模块组件的复用。以下列出本模块消费的外部组件: + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `LogTable` | `@/pageComponents/app/detail/Logs/LogTable` | app/detail/Logs | +| `LogsContextProvider` | `@/pageComponents/app/detail/Logs/context` | app/detail/Logs | +| `LogsContext` | `@/pageComponents/app/detail/Logs/context` | app/detail/Logs | +| `HeaderControl` | `@/pageComponents/app/detail/Logs/LogChart` | app/detail/Logs | +| `LogKeysConfigPopover` | `@/pageComponents/app/detail/Logs/LogKeysConfigPopover` | app/detail/Logs | +| `ChatPageContext` | `@/web/core/chat/context/chatPageContext` | web/core/chat | + +## 内部组件 + +| 组件 | 用途 | 说明 | +|------|------|------| +| `LogsFilterBar` | 日志筛选栏,组合来源选择器、搜索框、列配置按钮 | LogDetails.tsx 内部定义,非导出组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..819696798e00 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,67 @@ +--- +capability_label: "设置" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:10:00Z" +parent_module: "对话首页" +roles: ["管理员"] +router_paths: ["/chat?pane=s"] +--- + +# 设置 — 组件列表 + +## ChatSetting/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/index.tsx` | 设置页面主入口,管理 4 个 Tab 的路由切换和权限校验 | 引用自 ChatPageContext、ChatContext、useSystemStore、useUserStore | 否 | 无外部 Props | +| `pageComponents/chat/ChatSetting/SettingTabs.tsx` | 设置页顶部 Tab 切换栏 | 引用 FillRowTabs(来自 `packages/web/`) | 否 | `tab: ChatSettingTabOptionEnum`, `onTabChange: (tab) => void`, `children: ReactNode` | +| `pageComponents/chat/ChatSetting/AppTree.tsx` | 应用选择树形组件,支持文件夹展开和复选框选择 | 引用 `getMyApps` API 类型 | 否 | `apps: App[]`, `checkedIds: string[]`, `onCheck: (id) => void` | +| `pageComponents/chat/ChatSetting/ToolSelectModal.tsx` | 工具选择弹窗,展示可用插件/工具列表,支持搜索和标签筛选 | 引用 ConfigToolModal、ToolTagFilterBox、CostTooltip | 否 | `selectedTools: ChatSettingType['selectedTools']`, `onAddTool`, `onRemoveTool`, `onClose` | +| `pageComponents/chat/ChatSetting/DataDashboard.tsx` | 数据看板 Tab,展示对话统计图表 | 引用 LogsContextProvider、LogsFilterBar、LogChart | 否 | `Header: React.FC` | +| `pageComponents/chat/ChatSetting/LogDetails.tsx` | 日志详情 Tab,展示对话日志表格 | 引用 LogsContextProvider、LogsFilterBar、LogTable | 否 | `Header: React.FC` | + +## ChatSetting/HomepageSetting/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` | 首页配置 Tab,管理首页启用、快捷应用、工具和标语 | 引用 ChatPageContext、ToolSelectModal、AddQuickAppModal | 否 | `Header: React.FC` | +| `pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx` | 快捷应用选择弹窗,支持搜索和树形选择 | 引用 AppTree | 否 | `selectedIds: string[]`, `onClose`, `onConfirm: (list) => void` | + +## ChatSetting/FavouriteAppSetting/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` | 精选应用管理 Tab,支持搜索、排序、分类标签和增删 | 引用 ChatPageContext、DndDrag(拖拽排序) | 否 | `Header: React.FC` | +| `pageComponents/chat/ChatSetting/FavouriteAppSetting/AddFavouriteAppModal.tsx` | 添加精选应用弹窗 | 引用 updateFavouriteApps API | 否 | `onClose`, `onRefresh` | +| `pageComponents/chat/ChatSetting/FavouriteAppSetting/TagManageModal.tsx` | 标签管理弹窗,支持标签的增删改 | 引用 updateFavouriteAppTags API | **是** — 被数据集模块引用 | `onClose`, `onRefresh` | + +## ChatSetting/ImageUpload/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/ImageUpload/index.tsx` | 图片上传组件,支持点击和拖拽上传 | 引用 useImageUpload hook | 否 | `imageSrc: string`, `onFileSelect: (url) => void`, `maxSize?: number` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | packages/web | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | packages/web | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | packages/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | packages/web | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | packages/web | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | packages/web | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | packages/web | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | packages/web | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | packages/web | +| `NextHead` | `@/components/common/NextHead` | projects/app | +| `ChatSliderMobileDrawer` | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话模块 | +| `LogsContextProvider` | `@/pageComponents/app/detail/Logs/context` | 应用详情/日志 | +| `LogsFilterBar` | `ChatSetting/DataDashboard.tsx`(内联定义) | 本模块 | +| `LogChart` | `@/pageComponents/app/detail/Logs/LogChart` | 应用详情/日志 | +| `LogTable` | `@/pageComponents/app/detail/Logs/LogTable` | 应用详情/日志 | +| `ConfigToolModal` | `@/pageComponents/app/detail/Edit/component/ConfigToolModal` | 应用编辑 | +| `ToolTagFilterBox` | `@fastgpt/web/components/core/plugin/tool/TagFilterBox` | packages/web | +| `CostTooltip` | `@/components/core/app/tool/CostTooltip` | projects/app | +| `DndDrag` | `@fastgpt/web/components/common/DndDrag` | packages/web | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..10675132fe2b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,98 @@ +--- +capability_label: 首页设置 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:00:00.000Z" +parent_module: 设置 +roles: [团队管理员] +router_paths: [] +--- + +# 首页设置 — API索引 + +## 配置更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/update` | POST | 更新对话首页设置 | `src/web/core/chat/api.ts:41` → `src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx:101` | 对话首页→设置→HOME Tab→点击保存按钮时调用,提交所有首页设置变更 | + +## 配置查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/chat/setting/detail` | GET | 获取对话设置详情 | `src/web/core/chat/api.ts:39` → `src/web/core/chat/context/chatPageContext.tsx:112` | 对话首页→页面初始化时加载;对话首页→保存设置成功后刷新 | + +## 工具相关(通过 ToolSelectModal 间接调用) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 工具模板列表查询 | GET | 获取可用工具模板列表 | `src/web/core/app/api/tool.ts` → `ToolSelectModal.tsx:66` | 对话首页→设置→HOME Tab→打开工具选择弹窗→加载工具列表;搜索/切换目录时调用 | +| 工具预览节点查询 | GET | 获取工具的详细配置信息 | `src/web/core/app/api/tool.ts` → `ToolSelectModal.tsx:189` | 对话首页→设置→HOME Tab→点击添加工具→校验工具兼容性时调用 | +| 工具目录路径查询 | GET | 获取工具目录面包屑路径 | `src/web/core/app/api/tool.ts` → `ToolSelectModal.tsx:91` | 对话首页→设置→HOME Tab→进入工具子目录→加载面包屑导航时调用 | +| 工具标签列表查询 | GET | 获取工具分类标签 | `src/web/core/plugin/toolTag/api.ts` → `ToolSelectModal.tsx:76` | 对话首页→设置→HOME Tab→打开工具选择弹窗→加载标签筛选器数据 | + +## 应用相关(通过 AddQuickAppModal 间接调用) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 团队应用列表查询 | GET | 获取团队下的应用和文件夹 | `src/web/core/app/api.ts` → `AddQuickAppModal.tsx:49` | 对话首页→设置→HOME Tab→打开快捷应用弹窗→加载应用列表;搜索/切换目录时调用 | +| 应用基本信息批量查询 | GET | 获取指定应用的基础信息(名称、头像) | `src/web/core/app/api.ts` → `AddQuickAppModal.tsx:124` | 对话首页→设置→HOME Tab→已选应用信息缺失时→回填应用名称和头像 | +| 应用目录路径查询 | GET | 获取应用目录面包屑路径 | `src/web/core/app/api.ts` → `AddQuickAppModal.tsx:62` | 对话首页→设置→HOME Tab→进入应用子目录→加载面包屑导航时调用 | + +--- + +## API 调用链追踪 + +### `POST /proApi/core/chat/setting/update` 调用链 + +``` +HomepageSetting/index.tsx (onSubmit) + ├── 触发: 管理员点击「保存」按钮 + ├── 参数: { enableHome, slogan, quickAppIds, selectedTools } + │ - enableHome: boolean — 首页启用状态 + │ - slogan: string — 欢迎标语 + │ - quickAppIds: string[] — 快捷应用 ID 数组 + │ - selectedTools: { pluginId, inputs }[] — 已选工具列表 + └── 响应处理: + ├── onSuccess: 调用 refreshChatSetting() 刷新上下文设置数据 + └── successToast: 显示 "chat:setting.save_success" i18n 提示 +``` + +### `GET /proApi/core/chat/setting/detail` 调用链 + +``` +ChatPageContextProvider (初始化 + refreshChatSetting) + ├── 触发: 页面首次加载 / refreshChatSetting 被调用 + ├── 参数: 无(直接从后端获取当前团队设置) + ├── 分支条件: + │ - feConfigs.isPlus 为 false: 不调用此接口(开源版无此功能) + │ - enableHome 为 false 且当前 pane 为 HOME: 自动切换到 TEAM_APPS 面板 + │ - appId 变更: 自动同步路由参数 + └── 响应处理: + └── 写入 ChatPageContext.chatSettings,供所有子组件消费 +``` + +### 工具模板列表查询 调用链 + +``` +ToolSelectModal.tsx (loadTemplates) + ├── 触发: 弹窗打开 / 搜索关键词变更 / 目录切换 / 标签变更 + ├── 参数: { parentId, searchKey } + ├── 节流: 搜索模式下 300ms throttle + └── 响应处理: + ├── 更新 templates 状态 + └── 按标签过滤(前端):rawTemplates.filter(template.tags ∩ selectedTagIds) +``` + +### 团队应用列表查询 调用链 + +``` +AddQuickAppModal.tsx (useRequest) + ├── 触发: 弹窗打开 / 搜索关键词变更 / 目录切换 + ├── 参数: { parentId, searchKey, type: [folder, simple, workflow, chatAgent, assistant] } + ├── 并行: getMyApps 与 getAppFolderPath 并行请求(搜索模式下跳过 getAppFolderPath) + ├── 节流: 500ms throttleWait + └── 响应处理: + ├── apps: 渲染应用/文件夹列表 + └── paths: 渲染面包屑导航 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..62ebd7e4d8b1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\350\256\276\347\275\256/\351\246\226\351\241\265\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,44 @@ +--- +capability_label: 首页设置 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:00:00.000Z" +parent_module: 设置 +roles: [团队管理员] +router_paths: [] +--- + +# 首页设置 — 组件列表 + +## HomepageSetting/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` | 对话首页设置主组件,管理首页开关、快捷应用、可用工具和标语 | 内部 | 是(被 ChatSetting 引用) | `Header: React.FC<{ children?: React.ReactNode }>` — 页头组件插槽 | +| `projects/app/src/pageComponents/chat/ChatSetting/HomepageSetting/AddQuickAppModal.tsx` | 快捷应用选择弹窗,支持浏览文件夹、搜索应用、选择最多 4 个应用并拖拽排序 | 内部 | 否 | `selectedIds: string[]` — 已选应用 ID;`onConfirm: (list) => void` — 确认回调;`onClose: () => void` — 关闭回调 | + +## ChatSetting/(本模块消费的兄弟组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pageComponents/chat/ChatSetting/ToolSelectModal.tsx` | 工具选择弹窗,支持搜索、标签筛选、目录浏览、工具添加/移除 | 被 HomepageSetting 引用 | 否 | +| `projects/app/src/pageComponents/chat/ChatSetting/SettingTabs.tsx` | 设置面板的 Tab 导航栏,通过 Header 插槽注入 | 被 ChatSetting 引用,作为 Header 传递给 HomepageSetting | 否 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享组件库(AddQuickAppModal + ToolSelectModal) | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库(AddQuickAppModal + ToolSelectModal) | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库(AddQuickAppModal + ToolSelectModal) | +| `DndDrag` / `Draggable` | `@fastgpt/web/components/common/DndDrag` | 共享组件库(AddQuickAppModal) | +| `FolderPath` | `@/components/common/folder/Path` | 通用业务组件(AddQuickAppModal + ToolSelectModal) | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 共享组件库(ToolSelectModal) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库(ToolSelectModal) | +| `CostTooltip` | `@/components/core/app/tool/CostTooltip` | 工具费用组件(ToolSelectModal) | +| `ToolTagFilterBox` | `@fastgpt/web/components/core/plugin/tool/TagFilterBox` | 共享组件库(ToolSelectModal) | +| `ConfigToolModal` | `@/pageComponents/app/detail/Edit/component/ConfigToolModal` | 应用详情模块(ToolSelectModal) | +| `MyAvatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库(ToolSelectModal) | +| `Switch` / `Button` / `Flex` / `Box` / `Grid` / `Input` / `IconButton` / `Checkbox` | `@chakra-ui/react` | Chakra UI 组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..d4126f578a4e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,83 @@ +--- +capability_label: 首页 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — API索引 + +## 对话初始化 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/init` | GET | 获取对话初始化信息(应用配置、变量、对话历史) | `src/web/core/chat/api.ts:31` → `HomeChatWindow.tsx:135` | 对话首页→首页面板→加载时调用;对话首页→首页面板→切换应用/模型时调用 | + +## 对话发送 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/chat/completions`(快速应用路径) | POST | 使用快速应用工作流发送对话消息 | `src/web/common/api/fetch.ts:557` → `HomeChatWindow.tsx:215` | 对话首页→首页面板→快速应用模式→发送消息时调用 | +| `/api/proApi/core/chat/chatHome` | POST | 使用首页自定义模型和工具发送对话消息 | `src/web/common/api/fetch.ts:557` → `HomeChatWindow.tsx:264` | 对话首页→首页面板→自定义模式→发送消息时调用 | + +## 工具预览 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/app/tool/preview` | GET | 获取工具节点的预览模板信息(含输入参数) | `src/web/core/app/api/tool.ts:98` → `HomeChatWindow.tsx:249` | 对话首页→首页面板→自定义模式→发送消息前组装工具节点时调用 | + +--- + +## API 调用链追踪 + +### `/api/core/chat/init` 调用链 + +``` +HomeChatWindow + ├── 触发: 页面加载(appId/chatId/feConfigs.isPlus 变更时) + ├── 参数: appId, chatId + ├── 响应处理: 设置 chatBoxData(含 app.chatConfig 文件选择配置、whisper 语音配置) + ├── 响应处理: 初始化对话变量 + └── 错误处理: Plus 版跳转 HOME,非 Plus 版跳转 TEAM_APPS + +AppChatWindow + ├── 触发: 切换到最近使用的应用 + └── 参数: appId, chatId +``` + +### `/api/core/chat/completions`(快速应用)调用链 + +``` +HomeChatWindow.onStartChat(isQuickApp 分支) + ├── 触发: 用户在快速应用模式下发送消息 + ├── 参数: messages(仅最后一条), variables, responseChatItemId, appId, chatId, retainDatasetCite, showSkillReferences + ├── 响应处理(onMessage): 流式更新正在生成的 AI 回复(generatingMessage) + ├── 响应处理(完成后): 根据首条消息生成对话标题,更新历史记录,刷新最近使用列表 + └── 错误处理: 抛出 "appId is empty" 错误 +``` + +### `/api/proApi/core/chat/chatHome` 调用链 + +``` +HomeChatWindow.onStartChat(自定义模式分支) + ├── 触发: 用户在自定义模式下发送消息 + ├── 前置步骤: 并行获取所有选中工具的预览节点(getToolPreviewNode),填充工具默认输入值 + ├── 前置步骤: 组装 formData(包含选中模型、工具节点、chatConfig) + ├── 参数: messages(仅最后一条), variables, responseChatItemId, appId, appName, chatId, retainDatasetCite, showSkillReferences + form2AppWorkflow(工作流节点和边) + ├── 响应处理(onMessage): 流式更新正在生成的 AI 回复 + ├── 响应处理(完成后): 生成对话标题,更新历史记录,刷新最近使用列表 + └── 错误处理: 模型未选择时抛出 "No model selected" +``` + +### `/api/core/app/tool/preview` 调用链 + +``` +HomeChatWindow.onStartChat(自定义模式分支内) + ├── 触发: 发送消息前,为每个已选工具获取节点模板 + ├── 参数: appId(工具 ID) + ├── 调用方式: Promise.all 并行请求所有已选工具 + └── 响应处理: 合并工具的默认输入值(availableTools 中的 inputs),填充到节点的 inputs 中 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..f20bbf121556 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,60 @@ +--- +capability_label: 首页 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — Hooks工具函数 + +## 自定义 Hooks + +本模块主要使用 `HomeChatWindow` 组件内部的内联 hooks 和第三方库 hooks,无独立导出的自定义 hook 文件。 + +### 组件内使用的核心 Hooks + +| Hook | 来源 | 用途 | +|------|------|------| +| `useContextSelector` | `use-context-selector` | 从多个 Context 中精确选取需要的字段,避免不必要的重渲染 | +| `useMemoizedFn` | `ahooks` | 持久化函数引用,用于 `onStartChat` 避免闭包陈旧问题 | +| `useMount` | `ahooks` | 组件挂载时执行一次(检查 Plus 权限,非 Plus 跳转) | +| `useLocalStorageState` | `ahooks` | 持久化模型选择和工具选择到 localStorage | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 管理 `getInitChatInfo` 异步请求状态(loading、refreshDeps) | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | 获取 `isPc` 判断 PC/移动端,控制布局差异 | +| `useMemo` | React | 计算 `availableModels`、`availableTools`、`selectedTools`、`isQuickApp` | +| `useEffect` | React | 清理无效的 selected tool IDs | +| `useRef` | React | —(未在 HomeChatWindow 中直接使用) | + +### Context 选择器使用详情 + +`HomeChatWindow` 通过 `useContextSelector` 从以下 Context 精确选取字段: + +| Context | 选取字段 | 用途 | +|---------|---------|------| +| `ChatContext` | `forbidLoadChat`, `onUpdateHistoryTitle`, `onChangeAppId` | 控制对话加载、标题更新、应用切换 | +| `ChatItemContext` | `chatBoxData`, `setChatBoxData`, `resetVariables`, `isShowCite`, `showSkillReferences`, `datasetCiteData` | 对话数据管理、变量重置、引用显示 | +| `ChatPageContext` | `pane`, `chatSettings`, `handlePaneChange`, `refreshRecentlyUsed` | 面板切换、设置读取、最近使用刷新 | +| `ChatRecordContext` | `chatRecords`, `totalRecordsCount` | 对话记录列表和总数 | + +### 核心业务 Hook:onStartChat + +`onStartChat` 是首页最核心的业务 hook,通过 `useMemoizedFn` 包装,根据 `isQuickApp` 分支走不同的对话发送路径: + +- **快速应用分支**:直接使用 `streamFetch` 调用 `/api/core/chat/completions` +- **自定义模式分支**:先并行 `getToolPreviewNode` 获取工具节点,再组装 `form2AppWorkflow`,最后通过 `streamFetch` 调用 `/api/proApi/core/chat/chatHome` + +两个分支完成后均执行:生成对话标题、更新 `chatBoxData.title`、刷新最近使用列表。 + +### 模型选择副作用 + +选择模型时(`AIModelSelector.onChange`): +- 更新 `selectedModel`(localStorage 持久化) +- 同步更新 `chatBoxData.app.chatConfig.fileSelectConfig.canSelectImg`:若新模型有 vision 能力则开启图片选择 + +### 工具选择副作用 + +工具列表变化时(`useEffect`): +- 过滤 `selectedToolIds`:清除已不在 `availableTools` 中的工具 ID(避免已删除工具的无效选中) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..559df05a4122 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,110 @@ +--- +capability_label: 首页 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — Store数据流 + +## 涉及 Store 概览 + +| Store | 来源 | 在首页中的用途 | +|-------|------|-------------| +| `useChatStore` | `@/web/core/chat/context/useChatStore` | 管理当前 appId、chatId、outLinkAuthData;控制最近使用的应用记录 | +| `useUserStore` | `@/web/support/user/useUserStore` | 获取当前登录用户信息、头像 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取系统配置(feConfigs)、模型列表(llmModelList)、默认模型 | + +## useChatStore — 对话状态 + +### 读取字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `chatId` | `string` | 当前对话 ID,用于 API 请求和对话历史关联 | +| `appId` | `string` | 当前应用 ID,决定使用哪个应用的工作流 | +| `outLinkAuthData` | `object` | 外链鉴权数据,传入 ChatBox 用于发布渠道访问 | + +### 数据流方向 + +``` +ChatPageContextProvider + ├── 初始化: setSource('online'), setAppId(routeAppId) + ├── 路由变化: setAppId(routeAppId) (useEffect 同步) + └── 面板切换: setLastPane(), setLastChatAppId() + +HomeChatWindow(读取) + ├── chatId → 传入 ChatBox(对话标识) + ├── appId → 传入 ChatBox,用于 getInitChatInfo 和 onStartChat + └── outLinkAuthData → 传入 ChatBox +``` + +### 关键依赖:onChangeGlobalAppId + +`HomeChatWindow` 通过 `ChatContext.onChangeAppId` 切换应用: +- 切换到快速应用时:更新全局 appId → 触发 `getInitChatInfo` 重新拉取数据 +- 切回首页默认时:使用 `homeAppId`(`chatSettings.appId`) + +## useUserStore — 用户状态 + +### 读取字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userInfo` | `UserType` | 当前登录用户信息,其中的 `avatar` 用于 API 响应合并 | + +### 数据流方向 + +``` +ChatPageContextProvider + └── 初始化: initUserInfo() → setUserInfo + +HomeChatWindow(读取) + └── userInfo.avatar → 合并到 chatBoxData 的 userAvatar 字段 +``` + +## useSystemStore — 系统配置 + +### 读取字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `llmModelList` | `LLMModelItemType[]` | 可用模型列表,用于构建模型选择器选项 | +| `defaultModels` | `{llm?: {id, name}}` | 默认模型,用于模型选择器的初始值 | +| `feConfigs` | `FeConfigsType` | 系统功能配置,其中 `isPlus` 控制首页可见性;`systemTitle` 用于页面标题 | + +### 数据流方向 + +``` +HomeChatWindow(读取) + ├── llmModelList → 构建 availableModels 选项列表({value: id, label: name}) + ├── defaultModels.llm?.id → 模型选择器默认值 + └── feConfigs + ├── isPlus → 控制首页面板可见性(useMount 中自动跳转 TEAM_APPS) + ├── isPlus → 控制 getInitChatInfo 的 refreshDeps + └── systemTitle → NextHead 页面标题 +``` + +## localStorage 持久化状态 + +| Key | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `chat_home_model` | `string` | `defaultModels.llm?.id` | 用户上次选择的模型 ID | +| `chat_home_tools` | `string[]` | `[]` | 用户上次选择的工具 ID 列表 | + +这两个值通过 `useLocalStorageState` 管理,在页面刷新后保持用户的选择。 + +**数据流**: + +``` +HomeChatWindow + ├── selectedModel = useLocalStorageState('chat_home_model') + │ ├── 读取: 模型选择器初始值 + │ └── 写入: 选择模型时 setSelectedModel(model) + └── selectedToolIds = useLocalStorageState('chat_home_tools') + ├── 读取: 工具多选初始值 + └── 写入: 勾选/取消工具时 setSelectedToolIds(ids) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..5a6cfac66e1b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\345\257\271\350\257\235\351\246\226\351\241\265/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,42 @@ +--- +capability_label: 首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 对话首页 +roles: ["Plus版用户"] +router_paths: ["/chat?pane=HOME"] +--- + +# 首页 — 组件列表 + +## pageComponents/chat/ChatWindow/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `HomeChatWindow.tsx` | 首页对话窗口——模型选择、工具多选、快速应用切换、对话发送 | 引用自 对话核心 → ChatBox, 对话核心 → ChatContext/ChatItemContext, 系统配置 → useSystemStore | 否 | — (无外部 Props,通过 Context 获取状态) | + +## 组件结构 + +`HomeChatWindow` 由以下子区域构成,均为内联渲染(非独立组件文件): + +- **InputLeftComponent**:输入框左侧的模型选择器和工具多选下拉菜单,仅在非快速应用模式下显示。包含以下内联子组件: + - `AIModelSelector`:模型选择下拉框,切换模型时同步更新文件选择配置(图片识别能力) + - `Menu`(工具选择器):多选工具的下拉菜单,每项含复选框、工具头像和工具名 +- **SideBar 区域**:PC 端展示 `ChatHistorySidebar`,移动端展示 `ChatSliderMobileDrawer` +- **标题栏**:PC 端显示对话标题,移动端显示 `ChatHeader` +- **对话区**:`ChatBox` 聊天容器,负责对话渲染、输入和发送 + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `ChatBox` | `@/components/core/chat/ChatContainer/ChatBox` | 对话核心(共享) | +| `SideBar` | `@/components/SideBar` | 通用组件 | +| `ChatHeader` | `@/pageComponents/chat/ChatHeader` | 对话模块 | +| `ChatHistorySidebar` | `@/pageComponents/chat/slider/ChatSliderSidebar` | 对话模块 | +| `ChatSliderMobileDrawer` | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话模块 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 通用组件 | +| `NextHead` | `@/components/common/NextHead` | 通用组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..f8cb0d48b3a7 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\257\271\350\257\235/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,98 @@ +--- +capability_label: 对话 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: ["登录用户", "匿名用户", "团队成员"] +router_paths: ["/chat", "/chat/share"] +--- + +# 对话 — 组件列表 + +## 页面组件 (pages/chat/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/chat/index.tsx` | 对话首页路由入口,管理认证、上下文注入和多窗格布局 | 引用自登录→LoginModal | 否 | appId: string, teamId: string | +| `projects/app/src/pages/chat/share.tsx` | 分享对话路由入口,支持外链鉴权、嵌入模式和插件运行 | 引用自登录→LoginModal | 否 | appId: string, shareId: string, authToken: string | + +## 业务组件 (pageComponents/chat/) + +### ChatWindow/ — 对话窗口 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatWindow/HomeChatWindow.tsx` | 首页自由对话窗口:模型选择、工具选择、自由对话 | 内部 | 否 | —(通过 Context 获取数据) | +| `pageComponents/chat/ChatWindow/AppChatWindow.tsx` | 指定应用对话窗口:加载应用配置、展示对话历史 | 内部 | 否 | —(通过 Context 获取数据) | + +### ChatSetting/ — 对话设置 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatSetting/index.tsx` | 对话设置主组件,管理设置页签切换和权限校验 | 内部 | 否 | —(通过 Context 获取数据) | +| `pageComponents/chat/ChatSetting/SettingTabs.tsx` | 设置页签导航栏(首页设置/数据看板/日志/快捷应用) | 内部 | 否 | tab: ChatSettingTabOptionEnum | +| `pageComponents/chat/ChatSetting/HomepageSetting/index.tsx` | 首页应用配置:选择工作台应用、快捷应用、标语、Logo | 内部 | 否 | Header: Component | +| `pageComponents/chat/ChatSetting/FavouriteAppSetting/index.tsx` | 快捷应用管理:添加、排序、标签、删除收藏应用 | 内部 | 否 | Header: Component | +| `pageComponents/chat/ChatSetting/AppTree.tsx` | 应用树选择器(文件夹+应用复选框层级展开) | 内部 | 否 | apps: App[], checkedIds: string[] | +| `pageComponents/chat/ChatSetting/DataDashboard/index.tsx` | 数据看板:对话量统计图表展示 | 内部 | 否 | Header: Component | +| `pageComponents/chat/ChatSetting/LogDetails/index.tsx` | 日志详情:对话记录查询与导出 | 内部 | 否 | Header: Component | + +### ChatFavouriteApp/ — 快捷应用对话 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatFavouriteApp/index.tsx` | 快捷应用对话页:从收藏应用列表启动对话 | 内部 | 否 | —(通过 Context 获取数据) | + +### ChatTeamApp/ — 团队应用对话 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatTeamApp/index.tsx` | 团队应用列表和对话入口 | 内部 | 否 | —(通过 Context 获取数据) | + +### slider/ — 侧边栏导航 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/slider/index.tsx` | 对话首页侧边栏导航(Logo、导航、最近使用、底部操作) | 内部 | 否 | activeAppId: string | +| `pageComponents/chat/slider/ChatSliderSidebar.tsx` | 侧边栏对话历史列表(复用 ChatHistorySlider) | 被 分享对话 引用 | 是 → [15](../对话/共享组件清单.md) | isShareMode: boolean | +| `pageComponents/chat/slider/ChatSliderMobileDrawer.tsx` | 移动端侧边栏抽屉:导航+对话历史 | 内部 | 否 | showHeader: boolean, showFooter: boolean | +| `pageComponents/chat/slider/ChatSliderFooter.tsx` | 侧边栏底部:用户头像、设置入口 | 内部 | 否 | — | + +### ChatQuoteList/ — 知识库引用 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatQuoteList/index.tsx` | 知识库引用分发器:根据元数据路由到 CollectionQuoteReader 或 QuoteReader | 被 应用详情 引用 | 是 → [15](../对话/共享组件清单.md) | rawSearch: SearchDataItem[], metadata: GetQuoteProps | +| `pageComponents/chat/ChatQuoteList/QuoteReader.tsx` | 知识库分段引用阅读器:展示搜索到的文本段落 | 内部 | 是 → [15](../对话/共享组件清单.md) | rawSearch, metadata, onClose | +| `pageComponents/chat/ChatQuoteList/CollectionQuoteReader.tsx` | 知识库集合级引用阅读器 | 内部 | 否 | rawSearch, metadata, onClose | +| `pageComponents/chat/ChatQuoteList/ReferencePanel.tsx` | 引用详情侧边面板:展示完整的知识库检索结果 | 内部 | 是 → [15](../对话/共享组件清单.md) | rawSearch, metadata, onClose | + +### 其他 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/chat/ChatHeader.tsx` | 对话页头部:标题、模型标签、变量入口、工具菜单 | 内部 | 否 | pane: ChatSidebarPaneEnum, history: ChatItemMiniType[] | +| `pageComponents/chat/CustomPluginRunBox.tsx` | 插件运行框自定义版:PC 端并排输入/输出布局 | 内部 | 否 | appId: string, chatId: string | + +## 核心容器组件 (components/core/chat/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `components/core/chat/ChatContainer/ChatBox/index.tsx` | 核心对话容器:消息列表渲染、输入框、流式响应处理 | 被 应用详情、评测 引用 | 是 → [15](../对话/共享组件清单.md) | appId: string, chatId: string, onStartChat: function | +| `components/core/chat/ChatContainer/ChatBox/components/EntryPointBar.tsx` | 对话入口引导栏:推荐问题和建议操作 | 内部 | 否 | — | +| `components/core/chat/ChatContainer/PluginRunBox/index.tsx` | 插件运行框(通用版):Tab 切换输入/输出/详情 | 内部 | 是 → [15](../对话/共享组件清单.md) | appId: string, chatId: string | +| `components/core/chat/HelperBot/index.tsx` | 小助手机器人:应用配置和调试辅助对话 | 内部 | 否 | appId: string | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `LoginModal` | `@/pageComponents/login/LoginModal` | 登录 | +| `PageContainer` | `@/components/PageContainer` | 公共组件 | +| `SideBar` | `@/components/SideBar` | 公共组件 | +| `NextHead` | `@/components/common/NextHead` | 公共组件 | +| `ResizableDivider` | `@/components/common/ResizableDivider` | 公共组件 | +| `AIModelSelector` | `@/components/core/ai/AIModelSelector` | AI 模型 | +| `ChatHistorySidebar` | `@/components/core/chat/ChatContainer/ChatHistorySlider` | 对话核心 | +| `ChatSliderMobileDrawer` | `@/pageComponents/chat/slider/ChatSliderMobileDrawer` | 对话(内部) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2460beab7752 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" @@ -0,0 +1,93 @@ +--- +capability_label: 工作台 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: ["管理员", "普通成员", "团队所有者"] +router_paths: ["/dashboard/agent", "/dashboard/create", "/dashboard/evaluation", "/dashboard/mcpServer", "/dashboard/skill", "/dashboard/systemTool", "/dashboard/templateMarket", "/dashboard/tool"] +--- + +# 工作台 — API索引 + +> 工作台作为分组节点,其 API 分散在各子能力模块中。本文档汇总工作台公共层及各子能力模块使用的核心 API。 + +## 应用管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/list` | POST | 获取应用列表 | `web/core/app/api.ts:15` → `pageComponents/dashboard/agent/context.tsx` | 应用工作台→加载应用列表时调用;搜索/筛选时调用 | +| `/core/app/create` | POST | 创建新应用 | `web/core/app/api.ts:34` → `pages/dashboard/create/CreateModal.tsx` | 创建应用→提交创建表单时调用 | +| `/core/app/del` | DELETE | 删除应用/文件夹 | `web/core/app/api.ts:40` → `pageComponents/dashboard/agent/List.tsx` | 应用工作台→删除应用时调用;文件夹删除时调用 | +| `/core/app/update` | PUT | 更新应用信息 | `web/core/app/api.ts:50` → `pageComponents/dashboard/agent/List.tsx` | 应用工作台→编辑应用信息时调用 | +| `/core/app/detail` | GET | 获取应用详情 | `web/core/app/api.ts:45` → 应用详情页 | 应用工作台→点击应用进入详情时调用 | +| `/core/app/copy` | POST | 复制应用 | `web/core/app/api.ts:27` → `pageComponents/dashboard/agent/List.tsx` | 应用工作台→复制应用时调用 | +| `/core/app/transitionWorkflow` | POST | 转换为工作流 | `web/core/app/api.ts:24` → 应用详情页 | 应用详情→切换为工作流模式时调用 | +| `/core/app/getPermission` | GET | 获取应用权限 | `web/core/app/api.ts:53` → 权限管理组件 | 应用/文件夹→管理权限时调用 | +| `/core/app/resumeInheritPermission` | GET | 恢复继承权限 | `web/core/app/api.ts:62` → `pageComponents/dashboard/agent/index.tsx` | 文件夹→恢复继承权限时调用 | +| `/core/app/changeOwner` | POST | 变更应用所有者 | `web/core/app/api.ts:65` → 设置页面 | 团队管理→变更应用所有者时调用 | +| `/core/app/exportSkill` | POST | 导出应用为技能 | `web/core/app/api.ts:75` → 应用详情页 | 应用详情→导出为技能时调用 | + +## 文件夹管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/folder/create` | POST | 创建应用文件夹 | `web/core/app/api/app.ts:14` → `pages/dashboard/agent/index.tsx` | 应用工作台→新建文件夹时调用 | +| `/core/app/folder/path` | GET | 获取文件夹路径 | `web/core/app/api/app.ts:17` → `pageComponents/dashboard/agent/context.tsx` | 应用工作台→展示文件夹面包屑时调用 | + +## 模板市场 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/template/list` | GET | 获取模板列表 | `web/core/app/api/template.ts:7` → `pageComponents/dashboard/Container.tsx` | 模板市场→加载模板列表时调用 | +| `/core/app/template/detail` | GET | 获取模板详情 | `web/core/app/api/template.ts:10` → 模板详情组件 | 模板市场→查看模板详情时调用 | +| `/proApi/core/app/template/getTemplateTypes` | GET | 获取模板分类标签 | `web/core/app/api/template.ts:13` → `pageComponents/dashboard/Container.tsx` | 模板市场→加载分类标签时调用(Plus版本) | + +## 协作管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 协作列表接口 | GET | 获取协作者列表 | `web/core/app/api/collaborator.ts` → `components/common/folder/SlideCard.tsx` | 文件夹→查看协作者列表时调用 | +| 更新协作者接口 | POST | 更新协作者 | `web/core/app/api/collaborator.ts` → `components/common/folder/SlideCard.tsx` | 文件夹→添加/修改协作者时调用 | +| 删除协作者接口 | DELETE | 删除协作者 | `web/core/app/api/collaborator.ts` → `components/common/folder/SlideCard.tsx` | 文件夹→移除协作者时调用 | + +## 文件上传 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 预签名上传 URL | GET/POST | 获取头像上传预签名 URL | `web/common/file/api.ts` → `pages/dashboard/agent/index.tsx` | 文件夹→编辑文件夹头像时调用 | + +## 说明 + +工作台各子能力模块(评测、技能管理、工具管理、MCP 服务器等)的 API 定义分散在各自的 API 文件中,完整列表请参见各子能力的 API 索引: + +- 应用工作台 API:见 [应用工作台/API索引](../../技术知识库/应用工作台/API索引.md) +- 评测 API:见 [评测/API索引](../../技术知识库/评测/API索引.md) +- 技能管理 API:见 [技能管理/API索引](../../技术知识库/技能管理/API索引.md) +- 工具管理 API:见 [工具管理/API索引](../../技术知识库/工具管理/API索引.md) +- MCP 服务器 API:见 [MCP 服务器/API索引](../../技术知识库/MCP%20服务器/API索引.md) +- 模板市场 API:见 [模板市场/API索引](../../技术知识库/模板市场/API索引.md) +- 系统工具 API:见 [系统工具/API索引](../../技术知识库/系统工具/API索引.md) + +### `/core/app/list` 调用链 + +``` +应用工作台页面 (pages/dashboard/agent/index.tsx) + ├── 触发: 页面加载、搜索关键字变更、应用类型切换 + ├── 参数: { parentId, type, searchKey } + └── 响应处理: 更新 myApps 列表,驱动列表渲染 + +DashboardContainer (pageComponents/dashboard/Container.tsx) + ├── 触发: 模板市场页面加载 + ├── 参数: { type: undefined } + └── 响应处理: 更新 templateList,过滤隐藏模板 +``` + +### `/core/app/create` 调用链 + +``` +CreateModal (pages/dashboard/create/CreateModal.tsx) + ├── 触发: 用户选择应用类型并确认创建 + ├── 参数: { name, type, parentId, ... } + └── 响应处理: 返回新应用 ID,刷新应用列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..19a0c4aa98cc --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,100 @@ +--- +capability_label: MCP 服务器 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员(有 API 密钥创建权限) +router_paths: + - /dashboard/mcpServer +--- + +# MCP 服务器 — API 索引 + +## API 总览 + +MCP 服务器管理 API 定义在 `projects/app/src/web/support/mcp/api.ts`,对应后端 OpenAPI 合约在 `packages/global/openapi/support/mcpServer/api.ts`。 + +### MCP 服务器列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/mcp/list` | GET | 获取当前团队 MCP 服务器列表 | `web/support/mcp/api.ts:8` → `pages/dashboard/mcpServer/index.tsx:48` | 工作台→MCP 服务器→页面加载时调用;删除成功后刷新调用 | + +### MCP 服务器创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/mcp/create` | POST | 创建新的 MCP 服务器 | `web/support/mcp/api.ts:12` → `pageComponents/dashboard/mcp/EditModal.tsx:282` | 工作台→MCP 服务器→创建弹窗→提交表单时调用 | + +### MCP 服务器更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/mcp/update` | PUT | 更新已有 MCP 服务器的名称和关联应用 | `web/support/mcp/api.ts:16` → `pageComponents/dashboard/mcp/EditModal.tsx:299` | 工作台→MCP 服务器→编辑弹窗→提交表单时调用 | + +### MCP 服务器删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/mcp/delete` | DELETE | 删除指定的 MCP 服务器 | `web/support/mcp/api.ts:20` → `pages/dashboard/mcpServer/index.tsx:55` | 工作台→MCP 服务器→确认删除弹窗→确认时调用 | + +### 应用相关(辅助 API) + +MCP 服务器创建/编辑时使用以下应用管理 API 获取可选应用列表: + +| API 路径 | 方法 | 用途 | 调用位置 | +|---------|------|------|---------| +| `/api/core/app/list`(推断) | GET | 获取当前团队的应用列表 | `web/core/app/api.ts` → `pageComponents/dashboard/mcp/EditModal.tsx:93` | +| `/api/core/app/basicInfoByIds`(推断) | GET | 批量获取应用基本信息 | `web/core/app/api.ts` → `pageComponents/dashboard/mcp/EditModal.tsx:73` | +| `/api/core/app/folderPath`(推断) | GET | 获取应用文件夹路径 | `web/core/app/api/app.ts` → `pageComponents/dashboard/mcp/EditModal.tsx:105` | + +## API 调用链追踪 + +### `/api/support/mcp/list` 调用链 + +``` +McpServer 页面组件 + ├── 触发: 页面加载(useRequest manual:false 自动请求) + ├── 参数: 无 + └── 响应处理: 设置 mcpServerList 状态,驱动表格渲染;加载中设置 loadingList=true 显示 MyBox 加载遮罩 + +McpServer 页面组件 + ├── 触发: 删除成功后调用 refresh → loadMcpList() + ├── 参数: 无 + └── 响应处理: 静默刷新列表,不显示加载遮罩 +``` + +### `/api/support/mcp/create` 调用链 + +``` +EditMcpModal 编辑弹窗 + ├── 触发: 用户填写名称和选择应用后点击「确认」按钮 + ├── 参数: { name: string, apps: [{ appId, toolName, appName, description }] } + ├── 验证: name 必填(前端 required),apps 数组至少 1 项(前端验证);后端通过 Zod schema 验证 name 1-100 字符、apps 1-50 项 + ├── 中间状态: 提交期间 isLoading 显示按钮 loading 动画,按钮保持可点击状态 + └── 响应处理: 成功 toast "创建成功" → 关闭弹窗 → 刷新列表;失败 toast 错误信息 +``` + +### `/api/support/mcp/update` 调用链 + +``` +EditMcpModal 编辑弹窗 + ├── 触发: 用户修改名称/应用后点击「确认」按钮(isEdit=true) + ├── 参数: { id: string, name?: string, apps: [{ appId, toolName, appName, description }] } + ├── 验证: name 可选(后端 allow),apps 数组至少 1 项(前端验证);后端通过 Zod schema 验证 + ├── 中间状态: 提交期间 isLoading 显示按钮 loading 动画 + └── 响应处理: 成功 toast "更新成功" → 关闭弹窗 → 刷新列表;失败 toast 错误信息 +``` + +### `/api/support/mcp/delete` 调用链 + +``` +McpServer 页面组件 + ├── 触发: 用户点击删除按钮 → PopoverConfirm 弹出确认窗 → 点击确认 + ├── 参数: { id: string } + ├── 确认弹窗: type="delete",文案来自 i18n 键 dashboard_mcp:delete_mcp_server_confirm_tip + └── 响应处理: 成功后调用 loadMcpList() 刷新列表;失败 toast 错误信息 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..195afde90ca4 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/MCP \346\234\215\345\212\241\345\231\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,53 @@ +--- +capability_label: MCP 服务器 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 工作台 +roles: + - 团队管理员 + - 团队成员(有 API 密钥创建权限) +router_paths: + - /dashboard/mcpServer +--- + +# MCP 服务器 — 组件列表 + +## pages/(页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/mcpServer/index.tsx` | MCP 服务器列表管理页,包含表格展示、创建/编辑/删除操作入口、使用方式查看 | 引用自 DashboardContainer | 否 | 无外部 Props(页面入口) | + +## pageComponents/dashboard/mcp/(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/mcp/EditModal.tsx` | MCP 服务器的创建与编辑弹窗,包含名称配置和应用选择功能 | 引用外部应用 API(getMyApps, getAppBasicInfoByIds, getAppFolderPath) | 否 | `editMcp: EditMcForm`, `onClose: () => void`, `onSuccess: () => void` | +| `projects/app/src/pageComponents/dashboard/mcp/usageWay.tsx` | MCP 服务器使用方式弹窗,展示 HTTP/SSE 端点 URL 和 JSON 配置 | 引用自 useSystemStore | 否 | `mcp: McpKeyType`, `onClose: () => void` | + +### EditModal.tsx 内部子组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `SelectAppModal`(同文件内部) | 应用选择弹窗,左侧为可搜索的应用列表,右侧为已选应用清单 | 引用应用列表 API、SearchInput、Path、Avatar | 否 | `selectedApps: McpAppType[]`, `onClose: () => void`, `onConfirm: (e: McpAppType[]) => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台共享 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | Web 通用组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | Web 通用组件 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | Web 通用组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | Web 通用组件 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | Web 通用组件 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | Web 通用组件 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | Web 通用组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | Web 通用组件 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | Web 通用组件 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | Web 通用组件 | +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | Web 通用组件 | +| `CopyBox` | `@fastgpt/web/components/common/String/CopyBox` | Web 通用组件 | +| `Path` | `@/components/common/folder/Path` | 文件夹路径组件 | +| `Chakra UI 组件` (Box, Button, Flex, Table, Input 等) | `@chakra-ui/react` | UI 组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..775a731f89d1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,176 @@ +--- +capability_label: 创建应用 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/create] +--- + +# 创建应用 — 组件列表 + +> 本文档列出创建应用页面的核心组件及其 Props。 + +## 1. AppTypeCard + +应用类型选择卡片,展示图标、标题和简介,选中时蓝色边框高亮。 + +- **组件位置**: `projects/app/src/pageComponents/app/create/AppTypeCard.tsx` +- **来源**: 本模块专属组件 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `selectedAppType` | `CreateAppType` | 是 | 当前选中的应用类型 | +| `onClick` | `() => void` | 是 | 点击卡片回调 | +| `option` | `(typeof createAppTypeMap)[keyof typeof createAppTypeMap]` | 是 | 卡片数据(含 `type`、`icon`、`title`、`intro`) | + +- **内部组件**: `Card`(Chakra UI)、`MyIcon` +- **选中态**: `borderColor: 'primary.300'`(蓝色边框) +- **未选中态**: `borderColor: 'myGray.200'` + +## 2. HeaderAuthForm + +Header 鉴权配置表单,支持 None/Bearer/Basic/Custom 四种鉴权类型。 + +- **组件位置**: `projects/app/src/components/common/secret/HeaderAuthForm.tsx` +- **来源**: 共享组件(`@/components/common/secret/`) + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `headerSecretValue` | `HeaderSecretConfigType` | 是 | 当前鉴权配置值 | +| `onChange` | `(secret: HeaderSecretConfigType) => void` | 是 | 鉴权配置变更回调 | +| `fontWeight` | `string` | 否 | 标题字重(默认 `'medium'`) | +| `bg` | `string` | 否 | 输入框背景色(默认 `'myGray.50'`) | + +- **鉴权类型**: `HeaderSecretTypeEnum.None` / `Bearer` / `Basic` / `Custom` +- **Custom 模式**: 支持多组 Key/Value 键值对,可动态添加/删除 +- **secret 显示**: 已有 secret 值时显示"已配置"标识(蓝色文字 + 对勾图标),点击编辑图标重新输入 + +## 3. AIModelSelector + +AI 模型选择器,支持单行下拉和多行分组选择两种模式。 + +- **组件位置**: `projects/app/src/components/Select/AIModelSelector.tsx` +- **来源**: 共享组件(`@/components/Select/`) + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `value` | `string` | 否 | 当前选中模型 ID | +| `onChange` | `(value: string) => void` | 否 | 选中变更回调 | +| `list` | `{ label: string; value: string }[]` | 是 | 可选模型列表 | +| `isMultipleRow` | `boolean` | 否 | 是否多行分组模式(默认单行 `OneRowSelector`) | +| `size` | `'sm' \| 'md' \| 'lg'` | 否 | 尺寸 | +| `disableTip` | `string` | 否 | 禁用提示 | + +- **内部组件**: `MySelect`(单行)、`MultipleRowSelect`(多行)、`Avatar`、`MyTooltip` +- **数据源**: 从 `useSystemStore` 读取全部模型列表(LLM/Embedding/TTS/STT/ReRank) +- **模型筛选**: 仅显示用户已配置的模型(通过 `getMyModelList` 过滤) + +## 4. Avatar + +头像展示组件,用于应用头像预览和上传入口。 + +- **组件位置**: `@fastgpt/web/components/common/Avatar` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `src` | `string` | 否 | 头像图片 URL | +| `borderRadius` | `string` | 否 | 圆角 | +| `cursor` | `string` | 否 | 鼠标样式(上传场景用 `'pointer'`) | +| `onClick` | `() => void` | 否 | 点击回调(触发头像上传) | +| `w` | `string \| number` | 否 | 宽度 | +| `fallbackSrc` | `string` | 否 | 加载失败兜底图 | + +## 5. MyTooltip + +提示框包裹组件,鼠标悬停显示提示文本。 + +- **组件位置**: `@fastgpt/web/components/common/MyTooltip` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `label` | `string` | 否 | 提示文本 | +| `children` | `React.ReactNode` | 是 | 包裹的子元素 | + +## 6. MyIcon + +图标组件,按名称渲染 SVG 图标。 + +- **组件位置**: `@fastgpt/web/components/common/Icon` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | `string` | 是 | 图标名称(如 `'common/backLight'`、`'core/chat/chevronRight'`、`'common/info'`) | +| `w` | `string \| number` | 否 | 宽度 | +| `h` | `string \| number` | 否 | 高度 | +| `color` | `string` | 否 | 颜色 | + +## 7. LeftRadio + +左对齐单选组件,选项垂直排列,每项含标题和描述。 + +- **组件位置**: `@fastgpt/web/components/common/Radio/LeftRadio` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `list` | `{ title: string; value: string; desc?: string }[]` | 是 | 选项列表 | +| `value` | `string` | 否 | 当前选中值 | +| `onChange` | `(e: string) => void` | 否 | 选中变更回调 | +| `defaultBg` | `string` | 否 | 默认背景色 | +| `activeBg` | `string` | 否 | 选中背景色 | + +## 8. MyBox + +通用容器组件,支持 loading 状态。 + +- **组件位置**: `@fastgpt/web/components/common/MyBox` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `isLoading` | `boolean` | 否 | 是否显示加载状态 | +| `children` | `React.ReactNode` | 是 | 子元素 | + +## 9. MyImage + +图片展示组件,用于显示应用类型预览图。 + +- **组件位置**: `@fastgpt/web/components/common/Image/MyImage` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `src` | `string` | 是 | 图片 URL | +| `w` | `string \| number` | 否 | 宽度 | + +## 10. 页面级组件 + +### CreateAppsPage + +创建应用主页面组件。 + +- **组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` +- **类型**: React 函数式组件(`'use client'`) +- **导出**: `export default CreateAppsPage` +- **服务端渲染**: 导出 `getServerSideProps`,加载 `app` 和 `user` 命名空间的 i18n 词条 + +**内部状态**: +- `selectedAppType` — 当前选中的应用类型(默认 `AppTypeEnum.chatAgent`) +- `creatingTemplateId` — 正在创建中的模板 ID +- `copilotModel` — 智能生成选用的模型 ID(默认 `defaultModels.llm?.id`) + +**路由参数**(query): +- `parentId` — 父级目录/工具目录 ID +- `appType` — 预选的应用类型 + +**条件渲染逻辑**: +- `selectedAppType === AppTypeEnum.workflow` → 显示智能生成区域 +- `selectedAppType === AppTypeEnum.mcpToolSet` → 显示 MCP 鉴权 + URL + 工具列表区域 +- `selectedAppType === AppTypeEnum.httpToolSet` → 显示 HTTP 工具创建模式选择区域 +- `isPc === true` → 显示右侧预览图区域 +- `templateData?.list?.length > 0` → 显示模板列表区域 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..775a731f89d1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\210\233\345\273\272\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,176 @@ +--- +capability_label: 创建应用 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/create] +--- + +# 创建应用 — 组件列表 + +> 本文档列出创建应用页面的核心组件及其 Props。 + +## 1. AppTypeCard + +应用类型选择卡片,展示图标、标题和简介,选中时蓝色边框高亮。 + +- **组件位置**: `projects/app/src/pageComponents/app/create/AppTypeCard.tsx` +- **来源**: 本模块专属组件 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `selectedAppType` | `CreateAppType` | 是 | 当前选中的应用类型 | +| `onClick` | `() => void` | 是 | 点击卡片回调 | +| `option` | `(typeof createAppTypeMap)[keyof typeof createAppTypeMap]` | 是 | 卡片数据(含 `type`、`icon`、`title`、`intro`) | + +- **内部组件**: `Card`(Chakra UI)、`MyIcon` +- **选中态**: `borderColor: 'primary.300'`(蓝色边框) +- **未选中态**: `borderColor: 'myGray.200'` + +## 2. HeaderAuthForm + +Header 鉴权配置表单,支持 None/Bearer/Basic/Custom 四种鉴权类型。 + +- **组件位置**: `projects/app/src/components/common/secret/HeaderAuthForm.tsx` +- **来源**: 共享组件(`@/components/common/secret/`) + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `headerSecretValue` | `HeaderSecretConfigType` | 是 | 当前鉴权配置值 | +| `onChange` | `(secret: HeaderSecretConfigType) => void` | 是 | 鉴权配置变更回调 | +| `fontWeight` | `string` | 否 | 标题字重(默认 `'medium'`) | +| `bg` | `string` | 否 | 输入框背景色(默认 `'myGray.50'`) | + +- **鉴权类型**: `HeaderSecretTypeEnum.None` / `Bearer` / `Basic` / `Custom` +- **Custom 模式**: 支持多组 Key/Value 键值对,可动态添加/删除 +- **secret 显示**: 已有 secret 值时显示"已配置"标识(蓝色文字 + 对勾图标),点击编辑图标重新输入 + +## 3. AIModelSelector + +AI 模型选择器,支持单行下拉和多行分组选择两种模式。 + +- **组件位置**: `projects/app/src/components/Select/AIModelSelector.tsx` +- **来源**: 共享组件(`@/components/Select/`) + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `value` | `string` | 否 | 当前选中模型 ID | +| `onChange` | `(value: string) => void` | 否 | 选中变更回调 | +| `list` | `{ label: string; value: string }[]` | 是 | 可选模型列表 | +| `isMultipleRow` | `boolean` | 否 | 是否多行分组模式(默认单行 `OneRowSelector`) | +| `size` | `'sm' \| 'md' \| 'lg'` | 否 | 尺寸 | +| `disableTip` | `string` | 否 | 禁用提示 | + +- **内部组件**: `MySelect`(单行)、`MultipleRowSelect`(多行)、`Avatar`、`MyTooltip` +- **数据源**: 从 `useSystemStore` 读取全部模型列表(LLM/Embedding/TTS/STT/ReRank) +- **模型筛选**: 仅显示用户已配置的模型(通过 `getMyModelList` 过滤) + +## 4. Avatar + +头像展示组件,用于应用头像预览和上传入口。 + +- **组件位置**: `@fastgpt/web/components/common/Avatar` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `src` | `string` | 否 | 头像图片 URL | +| `borderRadius` | `string` | 否 | 圆角 | +| `cursor` | `string` | 否 | 鼠标样式(上传场景用 `'pointer'`) | +| `onClick` | `() => void` | 否 | 点击回调(触发头像上传) | +| `w` | `string \| number` | 否 | 宽度 | +| `fallbackSrc` | `string` | 否 | 加载失败兜底图 | + +## 5. MyTooltip + +提示框包裹组件,鼠标悬停显示提示文本。 + +- **组件位置**: `@fastgpt/web/components/common/MyTooltip` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `label` | `string` | 否 | 提示文本 | +| `children` | `React.ReactNode` | 是 | 包裹的子元素 | + +## 6. MyIcon + +图标组件,按名称渲染 SVG 图标。 + +- **组件位置**: `@fastgpt/web/components/common/Icon` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | `string` | 是 | 图标名称(如 `'common/backLight'`、`'core/chat/chevronRight'`、`'common/info'`) | +| `w` | `string \| number` | 否 | 宽度 | +| `h` | `string \| number` | 否 | 高度 | +| `color` | `string` | 否 | 颜色 | + +## 7. LeftRadio + +左对齐单选组件,选项垂直排列,每项含标题和描述。 + +- **组件位置**: `@fastgpt/web/components/common/Radio/LeftRadio` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `list` | `{ title: string; value: string; desc?: string }[]` | 是 | 选项列表 | +| `value` | `string` | 否 | 当前选中值 | +| `onChange` | `(e: string) => void` | 否 | 选中变更回调 | +| `defaultBg` | `string` | 否 | 默认背景色 | +| `activeBg` | `string` | 否 | 选中背景色 | + +## 8. MyBox + +通用容器组件,支持 loading 状态。 + +- **组件位置**: `@fastgpt/web/components/common/MyBox` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `isLoading` | `boolean` | 否 | 是否显示加载状态 | +| `children` | `React.ReactNode` | 是 | 子元素 | + +## 9. MyImage + +图片展示组件,用于显示应用类型预览图。 + +- **组件位置**: `@fastgpt/web/components/common/Image/MyImage` +- **来源**: 共享组件库 + +| Prop | 类型 | 必填 | 说明 | +|------|------|------|------| +| `src` | `string` | 是 | 图片 URL | +| `w` | `string \| number` | 否 | 宽度 | + +## 10. 页面级组件 + +### CreateAppsPage + +创建应用主页面组件。 + +- **组件位置**: `projects/app/src/pages/dashboard/create/index.tsx` +- **类型**: React 函数式组件(`'use client'`) +- **导出**: `export default CreateAppsPage` +- **服务端渲染**: 导出 `getServerSideProps`,加载 `app` 和 `user` 命名空间的 i18n 词条 + +**内部状态**: +- `selectedAppType` — 当前选中的应用类型(默认 `AppTypeEnum.chatAgent`) +- `creatingTemplateId` — 正在创建中的模板 ID +- `copilotModel` — 智能生成选用的模型 ID(默认 `defaultModels.llm?.id`) + +**路由参数**(query): +- `parentId` — 父级目录/工具目录 ID +- `appType` — 预选的应用类型 + +**条件渲染逻辑**: +- `selectedAppType === AppTypeEnum.workflow` → 显示智能生成区域 +- `selectedAppType === AppTypeEnum.mcpToolSet` → 显示 MCP 鉴权 + URL + 工具列表区域 +- `selectedAppType === AppTypeEnum.httpToolSet` → 显示 HTTP 工具创建模式选择区域 +- `isPc === true` → 显示右侧预览图区域 +- `templateData?.list?.length > 0` → 显示模板列表区域 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2ab222ab6b3e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,118 @@ +--- +capability_label: 工具管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:15:00.000Z" +parent_module: 工作台 +roles: [团队所有者, 管理员, 编辑者, 只读成员] +router_paths: [/dashboard/tool] +--- + +# 工具管理 — API索引 + +## 工具列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/list | POST | 分页获取工具列表 | web/core/app/api.ts:23 → pageComponents/dashboard/agent/context.tsx:119 | 工具管理→查看工具列表→加载时调用;工具管理→查看工具列表→翻页时调用;工具管理→搜索工具→输入防抖后调用;工具管理→切换类型筛选→筛选变更时调用 | + +## 文件夹操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/folder/create | POST | 创建新文件夹 | web/core/app/api/app.ts:14 → pages/dashboard/tool/index.tsx:80 | 工具管理→创建文件夹→提交创建弹窗时调用 | +| /core/app/folder/path | GET | 获取文件夹面包屑路径 | web/core/app/api/app.ts:17 → pageComponents/dashboard/agent/context.tsx:139 | 工具管理→导航到文件夹→进入文件夹时调用;工具管理→创建/编辑/移动→操作完成后刷新面包屑时调用 | + +## 工具/文件夹详情与更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/detail | GET | 获取应用/工具/文件夹详情 | web/core/app/api.ts:45 → pageComponents/dashboard/agent/context.tsx:148 | 工具管理→查看工具列表→进入文件夹时获取文件夹详情;工具管理→编辑/移动/删除→操作完成后刷新详情时调用 | +| /core/app/update | PUT | 更新应用/工具/文件夹信息 | web/core/app/api.ts:50 → pageComponents/dashboard/agent/context.tsx:158 | 工具管理→编辑工具/文件夹信息→提交编辑弹窗时调用;工具管理→移动工具/文件夹→确认移动时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/del | DELETE | 删除应用/工具/文件夹 | web/core/app/api.ts:40 → pages/dashboard/tool/index.tsx:86 | 工具管理→删除工具/文件夹→确认删除弹窗时调用 | + +## 复制 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/copy | POST | 复制工具 | web/core/app/api/app.ts:27 → pageComponents/dashboard/agent/List.tsx:130 | 工具管理→复制工具→确认复制弹窗时调用 | + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/resumeInheritPermission | GET | 恢复权限继承 | web/core/app/api.ts:62 → pages/dashboard/tool/index.tsx:284 | 工具管理→管理协作者权限→点击"恢复继承"时调用 | +| /proApi/core/app/collaborator/list | POST | 获取协作者列表 | web/core/app/api/collaborator.ts:8 → components/common/folder/SlideCard.tsx:303 | 工具管理→管理协作者权限→打开协作者面板时调用 | +| /proApi/core/app/collaborator/update | POST | 更新协作者权限 | web/core/app/api/collaborator.ts:11 → components/common/folder/SlideCard.tsx:306 | 工具管理→管理协作者权限→添加/修改协作者权限时调用 | +| /proApi/core/app/collaborator/delete | DELETE | 删除协作者 | web/core/app/api/collaborator.ts:14 → components/common/folder/SlideCard.tsx:312 | 工具管理→管理协作者权限→移除协作者时调用 | + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /common/file/presignAvatarPostUrl | POST | 获取头像上传预签名 URL | web/common/file/api.ts:8 → pages/dashboard/tool/index.tsx:327(传递给 EditFolderModal) | 工具管理→创建/编辑文件夹→上传文件夹头像时调用 | + +## 工具创建相关 API + +工具创建不在本模块直接发起单独的创建 API,而是通过 `ToolModal` 组件内的以下接口完成: + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/create(通过 postCreateApp) | POST | 创建工作流工具 | web/core/app/api.ts → pages/dashboard/create/ToolModal.tsx:160 | 工具管理→创建工具→选择工作流工具→确认创建时调用 | +| /core/app/httpTool/create(通过 postCreateHttpTools) | POST | 创建 HTTP 工具集 | web/core/app/api/app.ts → pages/dashboard/create/ToolModal.tsx:141 | 工具管理→创建工具→选择HTTP工具集→确认创建时调用 | +| /core/app/mcpTool/create(通过 postCreateMCPTools) | POST | 创建 MCP 工具集 | web/core/app/api/app.ts → pages/dashboard/create/ToolModal.tsx:149 | 工具管理→创建工具→选择MCP工具集→确认创建时调用 | +| /core/app/template/list(getTemplateMarketItemList) | GET | 获取快速创建模板列表 | 模板市场 API → pages/dashboard/create/ToolModal.tsx:87 | 工具管理→创建工具→选择工作流工具→加载模板列表时调用 | + +### `/core/app/list` 调用链 + +``` +AppListContextProvider (context.tsx:119) + ├── 触发: 进入工具管理页面 / 滚动到底部触发无限加载 / 搜索词变化 / 类型筛选变化 + ├── 参数: { parentId, type: [toolFolder, workflowTool, mcpToolSet, httpToolSet], searchKey, pageNum, pageSize } + └── 响应处理: 追加到 myApps[] 数组;设置 hasMore 标志;空列表时显示空状态提示 + +List (List.tsx) + └── 消费: 读取 myApps[] 渲染工具卡片网格 +``` + +### `/core/app/folder/create` 调用链 + +``` +MyTools (pages/dashboard/tool/index.tsx:80) + ├── 触发: 用户点击"新建文件夹" → 填写名称/简介/头像 → 点击确认 + ├── 参数: { name, intro, avatar, parentId, type: 'toolFolder' } + └── 响应处理: 成功后调用 loadMyApps() 刷新工具列表 +``` + +### `/core/app/del` 调用链 + +``` +MyTools (pages/dashboard/tool/index.tsx:86) + ├── 触发: 文件夹详情面板→点击删除→确认PopoverConfirm + ├── 参数: { appId: folderDetail._id } + └── 响应处理: 清除本地存储的日志key;路由跳转至上级文件夹 + +List (List.tsx:108) + ├── 触发: 工具卡片→更多菜单→删除→确认DelConfirmModal + ├── 参数: { appId: 选中工具._id } + └── 响应处理: 成功后调用 loadMyApps() 刷新列表 +``` + +### `/core/app/update` 调用链 + +``` +AppListContextProvider (context.tsx:158) + ├── 触发: 编辑工具/文件夹信息后提交 (EditResourceModal / EditFolderModal) + ├── 参数: { id, name, intro, avatar, ... } + └── 响应处理: 同时刷新 refetchFolderDetail() + refetchPaths() + loadMyApps() + +List (List.tsx:88) + ├── 触发: 拖拽工具到文件夹 (useFolderDrag) → 父级变更 + ├── 参数: { id, parentId: 目标文件夹ID } + └── 响应处理: 显示移动成功提示;刷新列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..48ba2f825e0f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\267\245\345\205\267\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,66 @@ +--- +capability_label: 工具管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:15:00.000Z" +parent_module: 工作台 +roles: [团队所有者, 管理员, 编辑者, 只读成员] +router_paths: [/dashboard/tool] +--- + +# 工具管理 — 组件列表 + +## pages/dashboard/tool/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/pages/dashboard/tool/index.tsx | 工具管理主页面,编排文件夹导航、类型筛选、搜索、工具列表、文件夹详情和各类弹窗 | 引用自 dashboard/agent → AppListContextProvider, List;引用自 dashboard/create → ToolModal | 否 | 无(Next.js 页面组件) | + +本文件导出 `ContextRender`(SSR 包装)和 `getServerSideProps`(i18n 命名空间加载)。内部定义 `MyTools` 组件作为页面主体。 + +## pages/dashboard/create/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/pages/dashboard/create/ToolModal.tsx | 创建工具弹窗,支持工作流工具、HTTP 工具集、MCP 工具集三种类型的创建流程 | 内部 | 被 /dashboard/tool 和 /dashboard/systemTool 页面引用 | type: ToolModalAppType, parentId: string, onClose/onSuccess: () => void | + +## pageComponents/dashboard/agent/(复用组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/pageComponents/dashboard/agent/context.tsx | AppListContextProvider,为工具/应用/技能列表页提供共享上下文(分页、搜索、文件夹管理) | 被 app、tool、skill、chat 页面引用 | 是 → 详见应用工作台模块 | children: ReactNode | +| projects/app/src/pageComponents/dashboard/agent/List.tsx | 工具/应用卡片网格列表,支持无限滚动、拖拽移动、编辑/删除/复制/权限操作 | 引用自 dashboard/agent → AppListContext, AppCard | 是 → 详见应用工作台模块 | showCreateCard: boolean | + +## components/MyTabBar/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/components/MyTabBar/index.tsx | 水平可滚动标签栏,用于工具类型筛选(全部/工作流/HTTP/MCP) | 内部 | 否 | tabs: {key, label, count?}[], activeKey: string, onChange: (key) => void | + +## components/common/folder/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/components/common/folder/Path.tsx | 文件夹面包屑导航,支持长路径折叠、点击跳转 | 内部 | 否 | paths: ParentTreePathItemType[], onClick: (parentId) => void, forbidLastClick: boolean | +| projects/app/src/components/common/folder/SlideCard.tsx | 文件夹详情侧边面板,展示名称/简介/操作按钮/协作者管理 | 引用自 permission/MemberManager → CollaboratorContextProvider | 否 | name: string, intro: string, onEdit/onMove/onDelete: () => void, managePer: MemberManagerInputPropsType | + +## pageComponents/dashboard/Container + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/pageComponents/dashboard/Container.tsx | 工作台通用布局容器,提供侧边导航栏和页面框架 | 被所有 /dashboard/* 页面引用 | 是 → 详见应用工作台模块 | children: (renderProps) => ReactNode | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| MyTabs | @fastgpt/web/components/common/MyTabs | web 共享组件库 | +| MyBox | @fastgpt/web/components/common/MyBox | web 共享组件库 | +| MyMenu | @fastgpt/web/components/common/MyMenu | web 共享组件库 | +| MyIcon | @fastgpt/web/components/common/Icon | web 共享组件库 | +| SearchInput | @fastgpt/web/components/common/Input/SearchInput | web 共享组件库 | +| EditFolderModal | @fastgpt/web/components/common/MyModal/EditFolderModal | web 共享组件库 | +| JsonImportModal | @/pageComponents/dashboard/agent/JsonImportModal | dashboard/agent 模块 | +| FolderSlideCard | @/components/common/folder/SlideCard | 本地 common 组件 | +| FolderPath | @/components/common/folder/Path | 本地 common 组件 | +| MyTabBar | @/components/MyTabBar | 本地组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..fd5070f05bff --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/API\347\264\242\345\274\225.md" @@ -0,0 +1,153 @@ +--- +capability_label: "应用工作台" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:30:00Z" +parent_module: "工作台" +roles: ["团队管理员", "应用创建者", "应用协作者", "只读成员"] +router_paths: ["/dashboard/agent"] +--- + +# 应用工作台 — API索引 + +## 应用列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/list` | POST | 分页获取应用列表 | `src/web/core/app/api.ts:27` → `src/pageComponents/dashboard/agent/context.tsx:119` | 应用工作台→浏览列表→页面加载时调用;应用工作台→浏览列表→滚动加载更多时调用;应用工作台→浏览列表→切换类型筛选时调用;应用工作台→浏览列表→搜索时调用 | +| `/core/app/detail` | GET | 获取应用/文件夹详情 | `src/web/core/app/api.ts:45` → `src/pageComponents/dashboard/agent/context.tsx:148` | 应用工作台→文件夹导航→进入文件夹时调用(用于面板展示) | + +## 文件夹操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/folder/create` | POST | 创建应用文件夹 | `src/web/core/app/api/app.ts:14` → `src/pages/dashboard/agent/index.tsx:78` | 应用工作台→创建文件夹→提交创建表单时调用 | +| `/core/app/folder/path` | GET | 获取文件夹面包屑路径 | `src/web/core/app/api/app.ts:17` → `src/pageComponents/dashboard/agent/context.tsx:139` | 应用工作台→文件夹导航→进入文件夹或刷新时调用 | + +## 应用 CRUD + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/update` | PUT | 更新应用信息 | `src/web/core/app/api.ts:50` → `src/pageComponents/dashboard/agent/context.tsx:157`(onUpdateApp) | 应用工作台→编辑信息→提交时调用;应用工作台→移动应用→确认目标时调用;应用工作台→卡片菜单→置顶/取消置顶时调用 | +| `/core/app/del` | DELETE | 删除应用/文件夹 | `src/web/core/app/api.ts:40` → `src/pageComponents/dashboard/agent/List.tsx:113`;`src/pages/dashboard/agent/index.tsx:84` | 应用工作台→删除应用→确认后调用;应用工作台→删除文件夹→确认后调用 | +| `/core/app/copy` | POST | 复制应用 | `src/web/core/app/api/app.ts:27` → `src/pageComponents/dashboard/agent/List.tsx:130` | 应用工作台→复制应用→确认后调用 | + +## 应用创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/create` | POST | 创建新应用 | `src/web/core/app/api.ts:34` → CreateModal 内部 | 应用工作台→创建应用→选择模板提交时调用(由 CreateModal 组件内部触发) | + +## 权限与协作者管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/getPermission` | GET | 获取应用协作者列表 | `src/web/core/app/api.ts:53` → ConfigPerModal 内部 | 应用工作台→权限管理→打开权限面板时调用;文件夹详情→权限管理→打开面板时调用 | +| `/proApi/core/app/collaborator/update` | POST | 更新应用协作者 | `src/web/core/app/api/collaborator.ts:12` → `src/pageComponents/dashboard/agent/List.tsx:278`(通过 ConfigPerModal) | 应用工作台→权限管理→添加/修改协作者时调用 | +| `/proApi/core/app/collaborator/delete` | DELETE | 删除协作者 | `src/web/core/app/api/collaborator.ts:15` → `src/pageComponents/dashboard/agent/List.tsx:289`(通过 ConfigPerModal) | 应用工作台→权限管理→删除协作者时调用 | +| `/core/app/resumeInheritPermission` | GET | 恢复权限继承 | `src/web/core/app/api.ts:62` → `src/pageComponents/dashboard/agent/List.tsx:140` | 应用工作台→权限管理→点击"恢复继承"时调用;文件夹详情→权限管理→恢复继承时调用 | +| `/proApi/core/app/changeOwner` | POST | 转让应用所有者 | `src/web/core/app/api.ts:65` → ConfigPerModal 内部 | 应用工作台→权限管理→转让所有者时调用(仅所有者可见) | + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 预签名 URL | — | 获取头像上传预签名 URL | `src/web/common/file/api.ts` → `src/pages/dashboard/agent/index.tsx`(通过 EditFolderModal) | 应用工作台→编辑文件夹→上传头像时调用 | + +## 应用导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/exportSkill` | POST | 导出应用为 Skill 压缩包 | `src/web/core/app/api.ts:75` → ExportSkillModal 内部 | 应用工作台→导出为Skill→确认导出时调用(返回 Blob 触发浏览器下载) | + +--- + +## API 调用链追踪 + +### `/core/app/list` 调用链 + +``` +AppListContextProvider (context.tsx:117-126) + ├── 触发: 页面加载、type筛选变化、searchKey变化(500ms防抖)、parentId变化 + ├── 参数: parentId(文件夹ID/null), type(应用类型数组), searchKey(搜索词), pageNum, pageSize + ├── 数据处理: 通过 useInfiniteScroll 管理分页列表,自动追加/刷新 + └── 响应处理: 设置 myApps 列表、hasMore、total;isFetchingApps 状态控制加载指示器 + +List 组件 (List.tsx:38) + ├── 触发: 各种操作完成后的列表刷新(loadMyApps) + ├── 参数: 从 AppListContext 获取 + └── 响应处理: 自动更新 UI 列表展示 +``` + +### `/core/app/del` 调用链 + +``` +List 组件 (List.tsx:108-125) + ├── 触发: 用户在 AppCard 菜单中点击"删除"→确认弹窗输入名称确认 + ├── 参数: appId + ├── 前置: 检查 lastChatAppId,若一致则清除;localStorage 移除对应缓存 + └── 响应处理: 成功→toast 提示"删除成功",调用 loadMyApps 刷新列表 + +MyApps 页面 (index.tsx:84-97) + ├── 触发: 用户在文件夹详情面板中点击"删除文件夹" + ├── 参数: folderDetail._id + ├── 前置: 弹出确认弹窗,提示"确认删除该文件夹?" + └── 响应处理: 删除后 redirect router 到上级 parentId;localStorage 清除缓存 +``` + +### `/core/app/update` 调用链 + +``` +AppListContextProvider (context.tsx:157-162) + ├── 触发: EditFolderModal 提交编辑、AppCard 菜单"置顶/取消置顶"、MoveModal 确认移动 + ├── 参数: id(应用ID), data({name?, intro?, avatar?, parentId?, isPinned?}) + └── 响应处理: 并行刷新 refetchFolderDetail、refetchPaths、loadMyApps + +List 组件 (List.tsx:88-92) + ├── 触发: EditResourceModal 提交编辑(应用信息修改) + ├── 参数: id, data + └── 响应处理: 成功→loadMyApps 刷新列表 +``` + +### `/core/app/folder/path` 调用链 + +``` +AppListContextProvider (context.tsx:138-144) + ├── 触发: parentId 变化时自动调用 + ├── 参数: sourceId=parentId, type='current' + ├── 条件: sourceId 为 null 时跳过请求返回空数组 + └── 响应处理: 设置 paths 状态,用于渲染面包屑导航 +``` + +### `/core/app/detail` 调用链 + +``` +AppListContextProvider (context.tsx:146-155) + ├── 触发: parentId 变化时自动调用 + ├── 参数: appId=parentId + ├── 条件: parentId 为 null 时不请求,直接返回 null + └── 响应处理: 设置 folderDetail 状态,驱动文件夹详情面板展示 +``` + +### `/core/app/copy` 调用链 + +``` +List 组件 (List.tsx:130-136) + ├── 触发: AppCard 菜单"复制应用"→确认弹窗确认 + ├── 参数: appId + └── 响应处理: 成功→toast 提示"复制成功",跳转 /app/detail?appId={newAppId},刷新列表 +``` + +### `/core/app/resumeInheritPermission` 调用链 + +``` +List 组件 (List.tsx:138-149) + ├── 触发: ConfigPerModal 中点击"恢复权限继承" + ├── 参数: appId + └── 响应处理: 成功→loadMyApps 刷新(权限状态更新) + +MyApps 页面 (index.tsx) + ├── 触发: 文件夹详情面板 FolderSlideCard 中点击恢复继承 + ├── 参数: folderDetail._id + └── 响应处理: 并行刷新 refetchFolderDetail 和 loadMyApps +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..e0bb8f9d1138 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,64 @@ +--- +capability_label: "应用工作台" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:30:00Z" +parent_module: "工作台" +roles: ["团队管理员", "应用创建者", "应用协作者", "只读成员"] +router_paths: ["/dashboard/agent"] +--- + +# 应用工作台 — 组件列表 + +## pages/(页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/agent/index.tsx` | 应用工作台主页面,组合 DashboardContainer + AppListContextProvider + MyApps | 消费:DashboardContainer, AppListContextProvider, MyTabs, SearchInput, FolderPath, FolderSlideCard, MyMenu, MyIcon, MyBox | 否 | getServerSideProps 服务端渲染 | + +## pageComponents/dashboard/agent/(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/agent/context.tsx` | 应用列表上下文提供者,管理分页数据、文件夹路径、搜索、移动等全局状态 | 消费:getMyAppsPaginated, getAppDetailById, getAppFolderPath, putAppById, getMyApps, useInfiniteScroll;被 ChatTeamApp 引用 | 是(AppListContext) | children: ReactNode | +| `projects/app/src/pageComponents/dashboard/agent/List.tsx` | 应用卡片网格列表,处理删除/复制/编辑/权限等操作 | 消费:AppCard, EditResourceModal, ConfigPerModal, ExportSkillModal, MoveModal;引用来自 shared components | 否 | showCreateCard: boolean | +| `projects/app/src/pageComponents/dashboard/agent/AppCard.tsx` | 单个应用卡片,展示名称/描述/类型标签/创建者/更新时间,附完整右键菜单 | 消费:MyBox, MyIcon, MyMenu, Avatar, MyTooltip, ExportConfigPopover | 否 | app, parentApp, getBoxProps, setEditedApp, setEditPerAppId, … | +| `projects/app/src/pageComponents/dashboard/agent/JsonImportModal.tsx` | JSON 配置导入弹窗,支持粘贴 JSON 或上传文件导入工作流应用 | 消费:getUploadAvatarPresignedUrl | 否 | onClose: () => void | + +## pages/dashboard/create/(创建弹窗) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/create/CreateModal.tsx` | 创建应用弹窗,按类型展示不同创建流程(模板选择/空白创建) | 引用共享组件库 | 否 | type: CreateAppType, parentId: string, onClose, onSuccess | + +## components/common/folder/(文件夹公共组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/common/folder/Path.tsx` | 文件夹面包屑路径导航 | 内部 | 否(被 dashboard 多处使用) | paths: ParentTreePathItemType[], onClick, forbidLastClick | +| `projects/app/src/components/common/folder/SlideCard.tsx` | 文件夹详情侧滑面板,展示名称/描述/权限,支持编辑/移动/删除 | 内部 | 否 | name, intro, onEdit, onMove, onDelete, managePer, … | +| `projects/app/src/components/common/folder/MoveModal.tsx` | 移动资源到目标文件夹的弹窗 | 内部(懒加载) | 否 | moveResourceId, server, title, onClose, onConfirm | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| MyTabs | `@fastgpt/web/components/common/MyTabs` | 共享组件库(@fastgpt/web) | +| SearchInput | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库(@fastgpt/web) | +| MyBox | `@fastgpt/web/components/common/MyBox` | 共享组件库(@fastgpt/web) | +| MyIcon | `@fastgpt/web/components/common/Icon` | 共享组件库(@fastgpt/web) | +| MyMenu | `@fastgpt/web/components/common/MyMenu` | 共享组件库(@fastgpt/web) | +| EmptyTip | `@fastgpt/web/components/common/EmptyTip` | 共享组件库(@fastgpt/web) | +| EditFolderModal | `@fastgpt/web/components/common/MyModal/EditFolderModal` | 共享组件库(@fastgpt/web) | +| EditResourceModal | `@/components/common/Modal/EditResourceModal` | 项目通用组件 | +| ConfigPerModal | `@/components/support/permission/ConfigPerModal` | 项目权限组件 | +| ExportSkillModal | `@/components/core/app/ExportSkillModal` | 项目应用组件 | + +## 内部状态管理 + +| Store/Context | 位置 | 用途 | +|--------------|------|------| +| AppListContext | `projects/app/src/pageComponents/dashboard/agent/context.tsx` | 管理应用列表数据、分页、文件夹导航、搜索、移动等核心状态 | +| useUserStore | `@/web/support/user/useUserStore` | 获取当前用户信息及团队权限 | +| useSystemStore | `@/web/common/system/useSystemStore` | 获取系统功能配置开关 | +| useChatStore | `@/web/core/chat/context/useChatStore` | 追踪用户最近使用的聊天应用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e0ff0dc9f139 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,147 @@ +--- +capability_label: 技能管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/skill] +--- + +# 技能管理 — API索引 + +> API 定义文件: `projects/app/src/web/core/skill/api.ts` + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/list` | POST | 获取技能列表(支持分页、搜索、文件夹过滤) | `api.ts:24` → `context.tsx:58` | 工作台→技能管理→加载列表时调用;工作台→技能管理→搜索/进入文件夹时调用(refreshDeps 响应 searchKey/parentId 变化) | +| `/core/agentSkills/detail` | GET | 获取技能详情 | `api.ts:36` → `detail/context.tsx:50` | 工作台→技能管理→技能详情页→加载时调用 | +| `/core/agentSkills/folder/path` | GET | 获取文件夹面包屑路径 | `api.ts:65` → `context.tsx:81` | 工作台→技能管理→进入子文件夹后获取导航路径 | +| `/core/agentSkills/apps` | GET | 获取引用某技能的应用列表 | `api.ts:76` → `List.tsx:60` | 工作台→技能管理→悬停关联应用数时调用(Popover 触发) | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/create` | POST | 创建技能(支持 AI 辅助生成 SKILL.md) | `api.ts:40` → `CreateSkillModal` | 工作台→技能管理→创建技能→提交创建表单时调用 | +| `/core/agentSkills/folder/create` | POST | 创建技能文件夹 | `api.ts:61` → `index.tsx:42` | 工作台→技能管理→创建文件夹→提交时调用 | +| `/core/agentSkills/import` | POST | 导入技能 ZIP/TAR 压缩包 | `api.ts:54` → `ImportSkillModal.tsx:45` | 工作台→技能管理→导入技能→确认上传时调用(FormData 包含文件和 parentId) | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/update` | POST | 更新技能基本信息(名称、描述、头像)和移动 | `api.ts:44` → `List.tsx:184,205` | 工作台→技能管理→编辑信息→提交时调用;工作台→技能管理→移动→确认目标文件夹时调用 | + +## 复制 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/copy` | POST | 创建技能副本 | `api.ts:47` → `List.tsx:173` | 工作台→技能管理→复制技能→确认后调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/delete` | DELETE | 删除技能或文件夹(软删除,递归删除子项) | `api.ts:51` → `List.tsx:165` | 工作台→技能管理→删除→确认弹窗后调用 | + +## 导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/agentSkills/export` | GET | 导出技能 ZIP 压缩包(触发浏览器下载) | `api.ts:69` → `List.tsx:227` | 工作台→技能管理→导出配置→调用下载 | + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/resumeInheritPermission` | GET | 恢复技能权限继承 | `api.ts:88` → `List.tsx:512` | 工作台→技能管理→权限设置→恢复继承时调用 | +| `/proApi/core/agentSkills/changeOwner` | POST | 转让技能所有者 | `api.ts:92` → `List.tsx:503` | 工作台→技能管理→权限设置→转让所有者时调用 | + +## API 调用链追踪 + +### `/core/agentSkills/list` 调用链 + +``` +SkillListContextProvider (context.tsx) + ├── 触发: 页面加载 / searchKey 变化 / parentId 变化(refreshDeps) + ├── 参数: { source: 'mine', searchKey, parentId } + └── 响应处理: 数据映射为 SkillListItemType[],日期转换、权限对象构造;更新 Context 触发 List 组件重渲染 + +SkillSelectModal (app detail 工具选择器) + ├── 触发: 应用编辑→工具选择→选择技能时打开弹窗 + ├── 参数: 分页获取所有可用技能 + └── 响应处理: 渲染可选技能列表供用户选择 +``` + +### `/core/agentSkills/folder/create` 调用链 + +``` +SkillPageContent (index.tsx) + ├── 触发: 用户点击"创建文件夹"按钮 → 填写名称和描述 → 确认 + ├── 参数: { name, description, parentId } + └── 响应处理: 成功后调用 loadSkills() 刷新列表 +``` + +### `/core/agentSkills/import` 调用链 + +``` +ImportSkillModal (ImportSkillModal.tsx) + ├── 触发: 用户选择/拖拽上传文件 → 点击确认 + ├── 参数: FormData { file, parentId? } + └── 响应处理: 成功后关闭弹窗并调用 onSuccess 回调刷新列表 +``` + +### `/core/agentSkills/delete` 调用链 + +``` +List (List.tsx) + ├── 触发: 用户点击删除菜单 → 确认弹窗(二次确认需输入技能名称)→ 确认 + ├── 参数: { skillId } + ├── 前置检查: 关联应用数为 0(> 0 时删除按钮置灰并提示) + └── 响应处理: 成功后 loadSkills() 刷新列表 + +后端 controller (packages/service) + ├── 软删除: updateMany 设置 deleteTime + ├── 文件夹递归: findSkillAndAllChildren 查找所有子节点 + ├── 版本软删除: MongoAgentSkillsVersion.updateMany + └── 异步清理: 删除 MinIO 包文件 + 沙箱资源 +``` + +### `/core/agentSkills/copy` 调用链 + +``` +List (List.tsx) + ├── 触发: 用户点击复制菜单 → 确认弹窗 → 确认 + ├── 参数: { skillId } + └── 响应处理: 成功后 loadSkills() 刷新列表 +``` + +### `/api/core/agentSkills/export` 调用链 + +``` +List (List.tsx) + ├── 触发: 用户点击导出配置菜单 + ├── 参数: skillId(URL 查询参数) + └── 响应处理: 浏览器触发 ZIP 文件下载(downloadFetch) +``` + +### `/core/agentSkills/apps` 调用链 + +``` +RelatedAppsContent (List.tsx) + ├── 触发: 用户悬停关联应用数量标签(Popover hover 触发) + ├── 参数: { skillId } + └── 响应处理: 渲染应用列表(头像、名称、创建者),显示最多 5 项,超出滚动 +``` + +### `/proApi/core/agentSkills/changeOwner` 调用链 + +``` +ConfigPerModal → List (List.tsx) + ├── 触发: 权限设置弹窗中转让所有者操作 + ├── 参数: { skillId, ownerId } + └── 响应处理: 成功后 loadSkills() 刷新 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..68d8c32b7244 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\212\200\350\203\275\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,45 @@ +--- +capability_label: 技能管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: [团队管理员, 团队成员] +router_paths: [/dashboard/skill] +--- + +# 技能管理 — 组件列表 + +## pageComponents/dashboard/skill/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/skill/context.tsx` | 技能列表状态管理 Context(技能数据、搜索、文件夹路径) | 内部 | 否 | children: ReactNode | +| `pageComponents/dashboard/skill/List.tsx` | 技能/文件夹卡片网格列表,含更多操作菜单 | 引用 EditResourceModal、MoveModal、ConfigPerModal(通用组件) | 否 | —(通过 Context 获取数据) | +| `pageComponents/dashboard/skill/CreateSkillModal.tsx` | 创建技能弹窗(名称、描述、图标设置) | 引用 MyModal(通用组件) | 否 | parentId: string\|null, onClose: ()=>void, onSuccess: ()=>void | +| `pageComponents/dashboard/skill/ImportSkillModal.tsx` | 导入技能压缩包弹窗(拖拽上传、文件校验) | 引用 MyModal(通用组件) | 否 | parentId: string\|null, onClose: ()=>void, onSuccess: ()=>void | + +## pages/dashboard/skill/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/skill/index.tsx` | 技能管理页面入口,组装 Header + List + 弹窗 | 引用 DashboardContainer、EditFolderModal(通用组件) | 否 | —(页面组件) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台容器 | +| `EditFolderModal` | `@fastgpt/web/components/common/MyModal/EditFolderModal` | 通用组件库 | +| `EditResourceModal` | `@/components/common/Modal/EditResourceModal` | 通用组件 | +| `MoveModal` | `@/components/common/folder/MoveModal` | 通用组件 | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限管理 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 通用组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 通用组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `FolderPath` | `@/components/common/folder/Path` | 通用组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 通用组件库 | +| `MyPopover` | `@fastgpt/web/components/common/MyPopover` | 通用组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 通用组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 通用组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cdbd13091451 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/API\347\264\242\345\274\225.md" @@ -0,0 +1,87 @@ +--- +capability_label: 模板市场 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:30:00.000Z +parent_module: 工作台 +roles: [所有登录用户] +router_paths: [/dashboard/templateMarket] +--- + +# 模板市场 — API索引 + +> API 定义位于 `projects/app/src/web/core/app/api/template.ts` 和 `projects/app/src/web/core/app/api.ts`。 + +## 模板查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/template/list | GET | 获取模板市场列表 | template.ts:7 → DashboardContainer.tsx:715 | 工作台→模板市场→加载时调用(currentTab 为 app_templates 时) | +| /proApi/core/app/template/getTemplateTypes | GET | 获取模板分类标签列表(Plus版) | template.ts:15 → DashboardContainer.tsx:695 | 工作台→模板市场→加载时调用(Plus 版系统) | +| /core/app/template/detail | GET | 获取单个模板详情(含工作流) | template.ts:11 → index.tsx:51 | 工作台→模板市场→点击"使用"按钮→创建前获取模板详情 | + +## 应用创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/app/create | POST | 从模板创建新应用 | api.ts:34 → index.tsx:64 | 工作台→模板市场→获取模板详情后→提交创建应用 | + +## API 调用链追踪 + +### `/core/app/template/list` 调用链 + +``` +DashboardContainer (Container.tsx:715) + ├── 触发: 切换到模板市场 Tab 时自动请求 + ├── 参数: { type: undefined }(获取全部模板) + └── 响应处理: 前端过滤隐藏指定模板 ID(以 -assistant 结尾),传给 TemplateMarket 组件渲染 + +TemplateMarket (index.tsx) + └── 接收: templateList 通过 DashboardContainer render props 注入 +``` + +### `/proApi/core/app/template/getTemplateTypes` 调用链 + +``` +DashboardContainer (Container.tsx:695) + ├── 触发: 切换到模板市场 Tab 时自动请求(仅 Plus 版) + ├── 参数: 无 + └── 响应处理: 前端注入推荐/企业微信专区标签到列表首位;社区版使用默认标签 defaultTemplateTypes + +TemplateMarket (index.tsx) + └── 接收: templateTags 通过 DashboardContainer render props 注入,用于构建分类 Tab 列表 +``` + +### `/core/app/template/detail` 调用链 + +``` +TemplateMarket → onUseTemplate (index.tsx:51) + ├── 触发: 用户点击模板卡片"使用"按钮 + ├── 参数: templateId(模板 ID 字符串) + ├── 顺序: 在 POST /core/app/create 之前串行调用 + └── 响应处理: + - 模板类型为 simple → 调用 form2AppWorkflow 将表单转为工作流结构 + - 其他类型 → 直接使用模板 workflow 数据 + - 作为 POST /core/app/create 的 modules/edges/chatConfig 参数来源 + +TemplateMarket → onUseTemplate (index.tsx:64) + └── 后续: postCreateApp 使用 templateDetail 构建创建参数 +``` + +### `/core/app/create` 调用链 + +``` +TemplateMarket → onUseTemplate (index.tsx:64) + ├── 触发: 获取模板详情后自动调用 + ├── 参数: + │ - parentId: 从 URL query 参数获取 + │ - avatar: 模板头像 + │ - name: 模板名称 + │ - type: 模板类型 + │ - modules: 模板工作流 nodes + │ - edges: 模板工作流 edges + │ - chatConfig: 模板工作流 chatConfig + │ - templateId: 模板 ID + ├── 成功: 显示"创建成功"Toast → 埋点 webPushTrack.useAppTemplate → 跳转 /app/detail?appId={id} + └── 失败: 显示"创建失败"Toast,遮罩解除 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..17738e84fc43 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\346\250\241\346\235\277\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,37 @@ +--- +capability_label: 模板市场 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:30:00.000Z +parent_module: 工作台 +roles: [所有登录用户] +router_paths: [/dashboard/templateMarket] +--- + +# 模板市场 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/templateMarket/index.tsx` | 模板市场页面入口,组合 DashboardContainer 和 TemplateMarket | 引用自 DashboardContainer(工作台容器组件) | 否 | templateList, templateTags, MenuIcon | + +## 业务组件 (pageComponents/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/templateMarket/AppMarketCard.tsx` | 单个模板卡片,展示模板信息并提供"使用"操作 | 内部 | 否 | item: AppTemplateSchemaType, templateTags, onClick | +| `projects/app/src/pageComponents/dashboard/templateMarket/AppMarketTabBar.tsx` | 模板市场分类 Tab 栏(MyTabBar 的简单重导出) | 内部 | 否 | 继承 MyTabBar 的 tabs, activeKey, onChange | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| DashboardContainer | `@/pageComponents/dashboard/Container` | 工作台容器 | +| MyTabBar | `@/components/MyTabBar` | 公共组件 | +| MyCard | `@/components/MyCard` | 公共组件 | +| SearchInput | `@fastgpt/web/components/common/Input/SearchInput` | 公共 Web 组件 | +| MyBox | `@fastgpt/web/components/common/MyBox` | 公共 Web 组件 | +| EmptyTip | `@fastgpt/web/components/common/EmptyTip` | 公共 Web 组件 | +| MyIcon | `@fastgpt/web/components/common/Icon` | 公共 Web 组件 | +| UseGuideModal | `@/components/common/Modal/UseGuideModal`(动态导入) | 公共组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f2b8f9f10781 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/API\347\264\242\345\274\225.md" @@ -0,0 +1,116 @@ +--- +capability_label: 系统工具 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: ["所有用户", "root管理员"] +router_paths: ["/dashboard/systemTool"] +--- + +# 系统工具 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/plugin/team/list` | GET | 获取团队系统工具列表 | `projects/app/src/web/core/plugin/team/api.ts:12` → `projects/app/src/pages/dashboard/systemTool/index.tsx:77` | 系统工具→系统工具页面→加载时调用 | + +**请求参数(`GetTeamSystemPluginListQueryType`)**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| type | string | 是 | 固定为 `'tool'`,表示查询系统工具类型 | + +**响应(`GetTeamPluginListResponseType`)**:返回工具对象数组,每个工具包含 `id`、`name`、`intro`、`avatar`、`author`、`tags`、`status`、`installed`、`associatedPluginId` 等字段。 + +### `/core/plugin/team/list` 调用链 + +``` +系统工具页面 (index.tsx:77) + ├── 触发: 页面加载时自动调用 + ├── 参数: { type: 'tool' } + ├── 响应处理: setTools(data),写入 tools state + └── 错误处理: useRequest 默认错误处理,显示错误提示 +``` + +--- + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/plugin/toolTag/list` | GET | 获取工具标签列表(用于前端筛选) | `projects/app/src/web/core/plugin/toolTag/api.ts:6` → `projects/app/src/pages/dashboard/systemTool/index.tsx:63` | 系统工具→系统工具页面→加载时调用 | + +**响应(`GetPluginTagListResponse`)**:返回标签对象数组,每个标签包含 `tagId`、`tagName`(国际化字符串)。 + +### `/core/plugin/toolTag/list` 调用链 + +``` +系统工具页面 (index.tsx:63) + ├── 触发: 页面加载时自动调用 + ├── 参数: 无 + ├── 响应处理: 直接写入 tags state,通过 parseI18nString 解析标签名 + └── 错误处理: useRequest 默认错误处理 +``` + +--- + +## 配置/操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/plugin/team/toggleInstall` | POST | 安装或卸载系统工具 | `projects/app/src/web/core/plugin/team/api.ts:14` → `projects/app/src/pages/dashboard/systemTool/index.tsx:84` | 系统工具→系统工具页面→点击安装/卸载按钮时调用;系统工具→系统工具页面→工具详情抽屉中点击安装/卸载时调用 | + +**请求参数(`ToggleInstallPluginBodyType`)**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| pluginId | string | 是 | 工具 ID | +| installed | boolean | 是 | `true` 表示安装,`false` 表示卸载 | +| type | string | 是 | 固定为 `'tool'` | + +### `/core/plugin/team/toggleInstall` 调用链 + +``` +系统工具页面 (index.tsx:84) + ├── 触发: 用户点击工具卡片的安装/卸载按钮 + ├── 参数: { pluginId, installed, type: 'tool' } + ├── 前置处理: 检查 Promise 引用 Map 防止重复操作,dispatchLoading 设置 loading 状态 + ├── 响应处理: 更新 tools state 中对应工具的 installed 状态 + ├── 后置处理: dispatchLoading 移除 loading 状态,清理 Promise 引用 + └── 错误处理: finally 中清除 loading 状态,恢复按钮可用性 + +ToolDetailDrawer 详情抽屉 (index.tsx:271) + ├── 触发: 用户在工具详情抽屉中点击安装/卸载 + ├── 参数: { pluginId: selectedTool.id, installed } + └── 响应处理: 同主页面处理流程 +``` + +> ⚠️ 注意:首页面的 `toggleInstall` 函数内部有并发控制逻辑,使用 `useRef>>` 存储每个工具的进行中操作。当同一工具的安装/卸载操作正在进行时,新的请求会 `await` 已有 Promise,而非发起新的 API 调用。 + +--- + +## 查询/详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/plugin/team/toolDetail` | GET | 获取工具详细信息(版本参数等) | `projects/app/src/web/core/plugin/team/api.ts:18` → `projects/app/src/pages/dashboard/systemTool/index.tsx:276` | 系统工具→系统工具页面→工具详情抽屉→展开查看版本详情时调用 | + +**请求参数(`GetTeamToolDetailQueryType`)**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| toolId | string | 是 | 工具 ID | + +**响应(`GetTeamToolDetailResponseType`)**:返回 `{ tools: ToolDetailItem[], downloadUrl: string }`,每个 ToolDetailItem 包含 `name`、`intro`、`icon`、`readme`、`versionList`(含 `inputs` 和 `outputs` 参数列表)。 + +### `/core/plugin/team/toolDetail` 调用链 + +``` +系统工具页面 (index.tsx:276) + ├── 触发: ToolDetailDrawer 的 onFetchDetail 回调,用户在抽屉中展开查看工具版本详情 + ├── 参数: { toolId } + ├── 响应处理: 返回工具数组,交给 ToolDetailDrawer 渲染 + └── 错误处理: ToolDetailDrawer 内部处理 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..3d806efe0e74 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\263\273\347\273\237\345\267\245\345\205\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,72 @@ +--- +capability_label: 系统工具 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 工作台 +roles: ["所有用户", "root管理员"] +router_paths: ["/dashboard/systemTool"] +--- + +# 系统工具 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/systemTool/index.tsx` | 系统工具市场页面入口 | 内部 | 否 | 路由页面,无外部 Props | + +该文件包含三个导出符号: +- **`ToolKitProvider`**:系统工具市场核心组件,管理工具列表加载、标签筛选、搜索、安装/卸载操作、详情抽屉等全部业务逻辑。接收 `MenuIcon` prop 用于移动端菜单图标。 +- **`ContextRender`**:路由入口,包裹 `DashboardContainer` 并将 `MenuIcon` 传递给 `ToolKitProvider`。 +- **`getServerSideProps`**:Next.js 服务端渲染函数,加载国际化词条。 + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台容器 | +| `ToolCard` | `@fastgpt/web/components/core/plugin/tool/ToolCard` | 共享组件库(packages/web) | +| `ToolDetailDrawer` | `@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer` | 共享组件库(packages/web) | +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | 共享组件库(packages/web) | +| `MyTabBar` | `@/components/MyTabBar` | 项目内公共组件 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库(packages/web) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库(packages/web) | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库(packages/web) | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置 Store | +| `useUserStore` | `@/web/support/user/useUserStore` | 用户 Store | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 共享 Hooks | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | 共享 Hooks | +| `parseI18nString` | `@fastgpt/global/common/i18n/utils` | 共享工具函数 | + +### 组件关系说明 + +- **`DashboardContainer`**:提供工作台统一布局,包括侧边栏导航、背景装饰、移动端适配。通过 render props 模式将 `MenuIcon`、`templateTags`、`templateList` 传递给子组件。 +- **`ToolCard`**:工具卡片展示组件,支持展示工具名称、描述、图标、作者、标签、安装状态。提供 `onInstall`、`onDelete`、`onClickCard` 回调。接收 `mode="team"` 控制团队模式下的展示样式。 +- **`ToolDetailDrawer`**:工具详情抽屉组件,从右侧滑出展示工具完整信息。通过 `onFetchDetail` 回调获取工具详情数据。支持 `onToggleInstall` 切换安装状态。 +- **`MyTabs`**:页面级导航标签,在「我的工具」和「系统工具」之间切换(通过路由跳转实现)。 +- **`MyTabBar`**:标签筛选栏,用于工具分类过滤。接收 `tabs` 数组和 `activeKey`,点击标签时触发 `onChange`。 +- **`SearchInput`**:搜索输入框,支持实时搜索过滤。 + +### 内部状态管理 + +系统工具页面使用以下 React 状态: +- `tools`(useState):工具列表数据 +- `searchText`(useState):搜索关键词 +- `selectedTool`(useState):当前选中查看详情的工具 +- `selectedTagId`(useState):当前选中的标签筛选 ID +- `loadingPluginIds`(useReducer):正在执行安装/卸载操作的工具 ID 集合,用于按钮 loading 状态 +- `loadingPromisesRef`(useRef):并发控制 Map,存储每个工具正在执行的异步操作 Promise + +### 页面布局结构 + +``` +DashboardContainer +└── ToolKitProvider + ├── MyTabs(页面导航:我的工具 / 系统工具) + ├── MyTabBar(标签筛选栏) + ├── SearchInput(搜索框) + ├── Grid(工具卡片网格) + │ └── ToolCard × N + └── ToolDetailDrawer(工具详情抽屉,条件渲染) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..397c90c11fbe --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,95 @@ +--- +capability_label: 工作台 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: ["管理员", "普通成员", "团队所有者"] +router_paths: ["/dashboard/agent", "/dashboard/create", "/dashboard/evaluation", "/dashboard/mcpServer", "/dashboard/skill", "/dashboard/systemTool", "/dashboard/templateMarket", "/dashboard/tool"] +--- + +# 工作台 — 组件列表 + +> 工作台作为分组节点,各子能力的页面组件分散在各自目录中。本文档汇总工作台**公共层**组件及各子能力的页面组件入口。 + +## 公共布局组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/Container.tsx` | 工作台主容器:提供侧边栏导航(DashboardNavbar)、内容区域布局、权限控制渲染、模板数据加载 | 依赖 Chakra UI(Flex, Box)、MyBox、useSystem、useUserStore | 是 → 被所有 dashboard 子页面使用 | children: render prop, 注入 templateTags/templateList/MenuIcon | +| `pageComponents/dashboard/Container.tsx` → `DashboardNavbar` | 侧边栏导航组件:Logo、菜单项渲染、分组展开/折叠、登出 | 依赖 MyIcon、MyImage、useUserStore、useSystemStore、useChatStore | 内部(Container 导出) | isCollapsed, setIsCollapsed, hideCollapseButton | + +## 应用工作台组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/agent/context.tsx` | 应用列表状态管理 Context(myApps、搜索、文件夹、权限等) | 内部 | 否 | Context Provider | +| `pageComponents/dashboard/agent/List.tsx` | 应用卡片列表渲染(含创建卡片、空状态) | 依赖 AppCard 等共享组件 | 否 | showCreateCard: boolean | +| `pageComponents/dashboard/agent/JsonImportModal.tsx` | JSON 配置导入弹窗 | 依赖文件上传 API | 否 | onClose: function | + +## 创建应用组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/create/CreateModal.tsx` | 创建应用弹窗:选择类型、填写名称、确认创建 | 依赖 postCreateApp API | 是 → 被应用工作台调用 | type, parentId, onClose, onSuccess | +| `pages/dashboard/create/SmartCustomerServiceForm.tsx` | 智能客服应用创建表单 | 内部 | 否 | - | +| `pages/dashboard/create/ToolModal.tsx` | 工具型应用创建弹窗 | 内部 | 否 | - | + +## 评测模块组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/evaluation/index.tsx` | 评测首页:展示评测列表 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/create.tsx` | 创建评测页面 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dataset/index.tsx` | 评测数据集列表 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dataset/fileImport.tsx` | 文件导入数据集 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dataset/detail/index.tsx` | 数据集详情页 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dimension/index.tsx` | 评测维度列表 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dimension/create.tsx` | 创建评测维度 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/dimension/edit.tsx` | 编辑评测维度 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/task/index.tsx` | 评测任务列表 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/evaluation/task/detail/index.tsx` | 评测任务详情 | 依赖 DashboardContainer | 否 | - | + +## 技能管理组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/skill/index.tsx` | 技能管理页面:技能列表展示与管理 | 依赖 DashboardContainer | 否 | - | + +## 工具管理组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/tool/index.tsx` | 工具管理页面:系统工具列表与操作 | 依赖 DashboardContainer | 否 | - | +| `pages/dashboard/systemTool/index.tsx` | 系统工具管理页面(root 专用) | 依赖 DashboardContainer | 否 | - | + +## MCP 服务器组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/mcpServer/index.tsx` | MCP 服务器管理页面:服务器列表、增删改查 | 依赖 DashboardContainer、PopoverConfirm | 否 | - | + +## 模板市场组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/templateMarket/index.tsx` | 模板市场页面:模板列表展示、分类筛选、基于模板创建应用 | 依赖 DashboardContainer | 否 | - | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 共享组件库 | +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | 共享组件库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库 | +| `MultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | 共享组件库 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | +| `EditFolderModal` | `@fastgpt/web/components/common/MyModal/EditFolderModal` | 共享组件库 | +| `FolderPath` | `@/components/common/folder/Path` | 公共组件 | +| `FolderSlideCard` | `@/components/common/folder/SlideCard` | 公共组件 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/Confirm` | 共享组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库 | +| `NextHead` | `@/components/common/NextHead` | 公共组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..053d27a7d197 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/API\347\264\242\345\274\225.md" @@ -0,0 +1,53 @@ +--- +capability_label: 创建评测 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +# 创建评测 — API索引 + +## 评测创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/create` | POST | 创建评测任务(含文件上传) | `web/core/evaluation/evaluation.ts:11` → `pages/dashboard/evaluation/create.tsx:79` | 工作台→评测→创建评测→提交创建表单时调用 | + +**请求方式**: FormData 上传,包含文件(CSV)和 JSON 参数 `{name, evalModel, appId}`,超时 600s。 + +## 应用信息查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/detail` | GET | 获取应用详情(含变量配置) | `web/core/app/api.ts:45` → `pages/dashboard/evaluation/create.tsx:62` | 工作台→评测→创建评测→下载 CSV 模板时调用 | + +**请求参数**: `appId={选中的应用ID}` + +## API 调用链追踪 + +### `/core/evaluation/create` 调用链 + +``` +EvaluationCreating (pages/dashboard/evaluation/create.tsx) + ├── 触发: 用户点击"开始评测"按钮,通过 handleSubmit(onSubmit) 提交表单 + ├── 参数: FormData { file: CSV文件, data: JSON.stringify({ name, evalModel, appId }) } + ├── 配置: timeout=600000, onUploadProgress 监听上传进度 + ├── 成功处理: toast 提示"评测任务创建成功",router.push('/dashboard/evaluation') + └── 错误处理: + ├── evaluationFileErrors 匹配 → setError(error.message),文件卡片变红显示校验详情 + ├── TeamErrEnum.aiPointsNotEnough → 弹出积分不足弹窗 + └── 其他错误 → toast 通用错误提示 +``` + +### `/core/app/detail` 调用链 + +``` +EvaluationCreating (pages/dashboard/evaluation/create.tsx) + ├── 触发: 用户选择应用后点击"点击下载该应用的 CSV 模板"按钮 + ├── 参数: appId={选中的应用ID} + ├── 成功处理: 提取 appDetail.chatConfig.variables,调用 getEvaluationFileHeader() 生成 CSV 表头,通过 fileDownload 触发浏览器下载 + └── 错误处理: errorToast 默认提示 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..9cd26ec671f9 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,22 @@ +--- +capability_label: 创建评测 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +## 说明 + +本模块未检测到自定义 Hooks 或工具函数导出。 + +模块使用的逻辑均来自外部库和共享模块: +- `react-hook-form` 的 `useForm` — 表单状态管理 +- `@fastgpt/web/hooks/useRequest` — 异步请求封装 +- `@fastgpt/web/hooks/useToast` — 消息提示 +- `@/web/common/file/utils` 的 `fileDownload` — 浏览器文件下载 +- `@fastgpt/global/core/app/evaluation/utils` 的 `getEvaluationFileHeader` — CSV 模板表头生成 + +- 组件信息参见 [组件列表](./组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..bbf90e8628e3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,19 @@ +--- +capability_label: 创建评测 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +## 说明 + +本模块无独立 Store(未检测到模块专属状态管理定义)。 + +模块使用的状态来自全局 Store: +- `useSystemStore` — 获取 LLM 模型列表(`llmModelList`),用于评测模型选择器的数据源 +- `useSystemStore` — 积分不足时调用 `setNotSufficientModalType` 触发弹窗 + +表单状态通过 `react-hook-form` 的 `useForm` 在组件内部管理,不经过全局 Store。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..877658790f91 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,26 @@ +--- +capability_label: 创建评测 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无共享目录)。 + +- 内部组件列表见 [组件列表](./组件列表.md) +- 跨模块依赖摘要见 [模块概览](../模块概览.md) + +## 消费的共享组件 + +| 本模块使用 | 引用自 | 用途 | +|-----------|--------|------| +| `DashboardContainer` | pageComponents/dashboard/Container | 工作台侧边导航布局容器 | +| `AIModelSelector` | components/Select/AIModelSelector | AI 模型下拉选择器 | +| `AppSelect` | components/Select/AppSelect | 应用搜索选择器 | +| `FileSelector` | components/Select/FileSelectorBox | CSV 文件上传选择器 | +| `Markdown` | components/Markdown | 错误提示的 Markdown 渲染 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..67d51a998fd1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\345\210\233\345\273\272\350\257\204\346\265\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,43 @@ +--- +capability_label: 创建评测 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:20:00.000Z" +parent_module: 评测 +roles: [admin] +router_paths: ["/dashboard/evaluation/create"] +--- + +# 创建评测 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/evaluation/create.tsx` | 创建评测任务表单页面 | 引用多个外部组件和 API(见下方) | 否 | 无外部 Props(页面入口) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台布局容器,提供侧边导航 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享通用容器组件 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享表单标签组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享图标组件 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 共享图标按钮组件 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享问号提示组件 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 共享 AI 模型选择器 | +| `AppSelect` | `@/components/Select/AppSelect` | 共享应用选择器 | +| `FileSelector` | `@/components/Select/FileSelectorBox` | 共享文件选择器 | +| `Markdown` | `@/components/Markdown` | 共享 Markdown 渲染组件 | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useForm` | `react-hook-form` | 表单状态管理与校验 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取 LLM 模型列表、系统配置 | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 封装异步请求的加载/错误状态 | +| `useToast` | `@fastgpt/web/hooks/useToast` | Toast 消息提示 | +| `useTranslation` | `next-i18next` | 国际化翻译 | +| `useRouter` | `next/router` | Next.js 路由导航 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..fab4ca1309c1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" @@ -0,0 +1,102 @@ +--- +capability_label: "评测任务" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测" +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task", "/dashboard/evaluation/task/detail"] +--- + +# 评测任务 — API索引 + +## 任务管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/evaluation/task/list | POST | 获取评测任务列表 | task.ts:85 → task/index.tsx:60 | 评测任务→任务首页→加载时调用;搜索/筛选/翻页时调用 | +| /core/evaluation/task/create | POST | 创建评测任务 | task.ts:52 → CreateModal.tsx:335 | 评测任务→任务首页→点击创建→提交表单时调用 | +| /core/evaluation/task/detail | GET | 获取任务详情 | task.ts:76 → taskPageContext.tsx:287 | 评测任务→任务详情→加载时调用;每15秒轮询刷新 | +| /core/evaluation/task/update | PUT | 更新任务信息 | task.ts:68 → task/index.tsx:123 | 评测任务→任务首页→重命名→确认时调用 | +| /core/evaluation/task/delete | DELETE | 删除评测任务 | task.ts:60 → task/index.tsx:138 | 评测任务→任务首页→删除→确认弹窗确认时调用 | +| /core/evaluation/task/start | POST | 启动评测任务 | task.ts:92 | 后端自动触发(任务创建后自动开始执行) | +| /core/evaluation/task/stop | POST | 停止评测任务 | task.ts:100 | 后端管理操作 | +| /core/evaluation/task/stats | GET | 获取评测统计信息 | task.ts:108 → taskPageContext.tsx:231 | 评测任务→任务详情→加载时调用;每15秒轮询刷新 | +| /core/evaluation/task/retryFailed | POST | 重试失败项 | task.ts:116 → task/index.tsx:205, taskPageContext.tsx:339 | 评测任务→任务首页→重试异常数据→点击时调用;任务详情→异常Tab→重试→点击时调用 | + +### `/core/evaluation/task/list` 调用链 + +``` +task/index.tsx (EvaluationTasks) + ├── 触发: 页面加载、搜索关键词变化(500ms防抖)、应用筛选变化、分页切换 + ├── 参数: { pageNum, pageSize, searchKey?, appId? } + └── 响应处理: 更新 tasks 数组和 total,驱动表格渲染 + +taskPageContext.tsx (不直接调用,通过 Context 间接使用) +``` + +### `/core/evaluation/task/create` 调用链 + +``` +CreateModal.tsx + ├── 触发: 用户填写任务名、选择应用/版本/数据集/维度后点击确认 + ├── 参数: { name, evalDatasetCollectionId, target: { type, config: { appId, versionId } }, evaluators: [{ metric, runtimeConfig }] } + └── 响应处理: 成功→关闭弹窗→刷新任务列表 +``` + +### `/core/evaluation/task/detail` 调用链 + +``` +taskPageContext.tsx (loadEvaluationDetail) + ├── 触发: 进入任务详情页时加载;后续每15秒自动轮询 + ├── 参数: { evalId } + └── 响应处理: 更新 evaluationDetail 状态(含 status、metricNames、target 等) + +taskPageContext.tsx (loadTaskDetail) + ├── 触发: 进入任务详情页时首次加载 + ├── 参数: { evalId } + └── 响应处理: 更新 taskDetail 状态,失败时跳转回任务列表 +``` + +### `/core/evaluation/task/stats` 调用链 + +``` +taskPageContext.tsx (runLoadStats) + ├── 触发: 进入任务详情页时加载;后续每15秒自动轮询 + ├── 参数: { evalId } + └── 响应处理: 更新 statsData(total, completed, error, belowThreshold),驱动 NavBar 标签数据展示 +``` + +## 评测项管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/evaluation/task/item/list | POST | 获取评测项列表 | task.ts:150 → task/detail/index.tsx:197 | 评测任务→任务详情→数据列表加载时调用;搜索/翻页/Tab切换时调用 | +| /core/evaluation/task/item/update | PUT | 更新评测项 | task.ts:134 → taskPageContext.tsx:329 | 评测任务→任务详情→编辑问题/预期回复→保存时调用 | +| /core/evaluation/task/item/delete | DELETE | 删除评测项 | task.ts:126 → taskPageContext.tsx:314 | 评测任务→任务详情→删除评测项→确认时调用 | +| /core/evaluation/task/item/retry | POST | 重试单个评测项 | task.ts:158 → taskPageContext.tsx:320 | 评测任务→任务详情→重试单项→点击时调用 | +| /core/evaluation/task/item/detail | GET | 获取评测项详情 | task.ts:142 | (预留接口,当前前端未直接调用) | +| /core/evaluation/task/item/export | POST | 导出评测数据 | taskPageContext.tsx:396 | 评测任务→任务详情→导出→点击时调用 | + +### `/core/evaluation/task/item/list` 调用链 + +``` +task/detail/index.tsx (Detail) + ├── 触发: 页面加载、搜索关键词变化(500ms防抖)、Tab切换、滚动加载更多 + ├── 参数: { evalId, offset, pageSize: 20, userInput?, status?, belowThreshold? } + └── 响应处理: 更新 evaluationItems 数组,驱动数据列表和详情面板渲染 + +smartRefreshData (Detail) + ├── 触发: 智能轮询(15秒间隔,仅刷新已选中项所在页及之前的数据) + ├── 参数: { evalId, offset, pageSize: 20, ...scrollParams } + └── 响应处理: 逐页加载选中项之前的数据,合并后更新列表 +``` + +## 评测总结 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/evaluation/summary/detail | GET | 获取总结报告 | task.ts:168 → taskPageContext.tsx:259 | 评测任务→任务详情→加载时调用;每15秒轮询刷新 | +| /core/evaluation/summary/config/detail | GET | 获取任务配置详情 | task.ts:184 → ConfigParams.tsx:90 | 评测任务→任务详情→评分设置→弹窗打开时加载 | +| /core/evaluation/summary/config/update | POST | 更新评分配置 | task.ts:176 → ConfigParams.tsx:213 | 评测任务→任务详情→评分设置→修改后确认时调用 | +| /core/evaluation/summary/create | POST | 生成指定指标的总结 | task.ts:192 → taskPageContext.tsx:346 | 评测任务→任务详情→刷新评分→点击时调用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..6499ff3006a5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,178 @@ +--- +capability_label: 任务详情 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — API索引 + +## 评测任务核心 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/detail` | GET | 获取评测任务详情(含实时状态和统计) | `task.ts:76` → `taskPageContext.tsx:429` | 评测→任务详情→页面初始化时调用;轮询每15秒调用 | +| `/core/evaluation/task/stats` | GET | 获取评测任务统计信息(总数/完成/错误/低于阈值数) | `task.ts:108` → `taskPageContext.tsx:231` | 评测→任务详情→页面初始化时调用;轮询每15秒调用 | + +## 评测项(数据)API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/item/list` | POST | 获取评测项列表(分页) | `task.ts:150` → `task/detail/index.tsx:55`(通过 useScrollPagination) | 评测→任务详情→加载数据列表时调用;切换Tab过滤时调用;滚动加载更多时调用;搜索时调用;轮询智能刷新时调用 | +| `/core/evaluation/task/item/update` | PUT | 更新评测项(修改问题/参考答案) | `task.ts:134` → `taskPageContext.tsx:329` | 评测→任务详情→编辑数据→保存时调用 | +| `/core/evaluation/task/item/delete` | DELETE | 删除评测项 | `task.ts:126` → `taskPageContext.tsx:314` | 评测→任务详情→删除数据→确认后调用 | +| `/core/evaluation/task/item/retry` | POST | 重试单条评测项 | `task.ts:158` → `taskPageContext.tsx:320` | 评测→任务详情→重试单条→点击重试时调用 | + +## 批量操作 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/retryFailed` | POST | 批量重试所有失败评测项 | `task.ts:116` → `taskPageContext.tsx:338` | 评测→任务详情→错误数据Tab→点击重试按钮时调用 | + +## 导出 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/evaluation/task/item/export` | POST | 导出评测数据为 CSV 文件 | `taskPageContext.tsx:396`(通过 downloadFetch) | 评测→任务详情→点击导出按钮时调用 | + +## 评分总结 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/summary/detail` | GET | 获取评测总结报告(含各维度评分) | `task.ts:168` → `taskPageContext.tsx:259` | 评测→任务详情→页面初始化时调用;轮询每15秒调用 | +| `/core/evaluation/summary/config/detail` | GET | 获取评分配置详情(阈值/权重/聚合方式) | `task.ts:184` → `ConfigParams.tsx:90` | 评测→任务详情→打开配置参数弹窗时调用 | +| `/core/evaluation/summary/config/update` | POST | 更新评分配置(阈值/权重/聚合方式) | `task.ts:176` → `ConfigParams.tsx:213` | 评测→任务详情→配置参数弹窗→确认保存时调用 | +| `/core/evaluation/summary/create` | POST | 触发生成评分总结报告 | `task.ts:192` → `taskPageContext.tsx:346` | 评测→任务详情→点击刷新评分时调用;维度总结卡片中点击重试生成时调用 | + +## 外部依赖 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `getAppDetailById` (应用详情接口) | GET | 获取关联应用的详情信息 | `@/web/core/app/api` → `taskPageContext.tsx:415` | 评测→任务详情→加载应用信息→用于基本信息面板显示 | +| `getChatResData` (对话响应接口) | GET | 获取对话完整响应数据 | `@/web/core/chat/record/api` → `DetailedResponseModal.tsx:29` | 评测→任务详情→查看完整响应弹窗→打开时调用 | + +--- + +## API 调用链追踪 + +### `GET /core/evaluation/task/detail` 调用链 + +``` +taskPageContext.loadTaskDetail() + ├── 触发: 页面初始化 / 轮询调度 + ├── 参数: { evalId: taskId } + ├── 响应: EvaluationDisplayType(含 name, status, evaluators, target, createTime, finishTime 等) + ├── 状态更新: setTaskDetail(data) + └── 错误处理: Toast 提示加载失败,跳转回评测任务列表 + +taskPageContext.runLoadEvaluationDetail()(轮询用) + ├── 触发: loadAllData 中手动触发 / 轮询每 15 秒 + ├── 参数: { evalId: taskId } + ├── 响应处理: setEvaluationDetail(data) + └── 错误处理: 轮询静默失败(不显示 Toast),仅更新 error 状态 +``` + +### `GET /core/evaluation/task/stats` 调用链 + +``` +taskPageContext.runLoadStats() + ├── 触发: loadAllData 中与 detail 并行请求 / 轮询每 15 秒 + ├── 参数: { evalId: taskId } + ├── 响应: EvaluationStatsResponse(含 total, completed, evaluating, queuing, error, belowThreshold) + ├── 状态更新: setStatsData(data) + ├── 消费方: NavBar(显示标签计数)、详情页(判断是否全部失败/排队中/评测中) + └── 错误处理: 轮询静默失败 +``` + +### `POST /core/evaluation/task/item/list` 调用链 + +``` +useScrollPagination(getEvaluationItemList) + ├── 触发: 首次加载 / 滚动到底部 / 搜索值变化 / Tab 切换 / taskId 变化 / 轮询智能刷新 + ├── 参数: { evalId, offset, pageSize: 20, userInput?(搜索关键词), status?(错误数据Tab), belowThreshold?(问题数据Tab)} + ├── 响应: { list: EvaluationItemDisplayType[], total: number } + ├── 状态更新: setEvaluationItems(列表数据)、total(总数) + ├── 排序: 按创建顺序 + └── 渲染: 动态表头(< 3 维度显示各维度名,≥ 3 显示"综合评分"列) +``` + +### `DELETE /core/evaluation/task/item/delete` 调用链 + +``` +Detail.handleDelete() + ├── 触发: 用户点击删除按钮 → PopoverConfirm 确认 + ├── 参数: { evalItemId } + ├── 响应处理: 刷新列表和统计 → 调整选中索引(下一条或最后一条) + ├── Toast: 成功"删除成功" / 失败"删除失败" + └── 状态更新: 退出编辑模式、重置表单 +``` + +### `PUT /core/evaluation/task/item/update` 调用链 + +``` +Detail.handleSave() + ├── 触发: 编辑模式下点击保存 → Popover 确认(含 modifyDataset 开关) + ├── 参数: { evalItemId, userInput, expectedOutput, modifyDataset } + ├── 响应处理: 刷新列表数据 + ├── Toast: 成功"保存成功" / 失败"保存失败" + └── 状态更新: 退出编辑模式、关闭 Popover +``` + +### `POST /core/evaluation/task/item/retry` 调用链 + +``` +Detail.handleRefresh() + ├── 触发: 选中错误项 → 点击重试图标按钮 + ├── 参数: { evalItemId } + ├── 响应处理: 刷新列表数据 + ├── Toast: 成功"重试请求已提交" / 失败"重试失败" + └── 后续: 评测项重新入队,轮询自动更新状态 +``` + +### `POST /core/evaluation/task/retryFailed` 调用链 + +``` +Detail.handleRetryFailed() + ├── 触发: 错误数据 Tab → 点击"重试"按钮 + ├── 参数: { evalId: taskId } + ├── 响应处理: 自动跳转到全部数据 Tab(currentTab=allData) + ├── Toast: 成功提示 / 失败"重试失败" + └── 后续: useScrollPagination 响应 currentTab 变化自动刷新列表 +``` + +### `POST /api/core/evaluation/task/item/export` 调用链 + +``` +Detail.handleExport() + ├── 触发: 点击顶部"导出"按钮 + ├── 参数: { evalId, filters(当前 Tab 过滤条件), headers, metricColumns, statusLabelMap } + ├── 方式: downloadFetch 直接触发浏览器下载 + ├── 文件名: evaluation_{任务名}_{日期}.csv + ├── Toast: 成功"导出成功" / 失败"导出失败" + └── 前置数据: 从 evaluationDetail 和 summaryData 构建导出列和状态映射 +``` + +### `POST /core/evaluation/summary/create` 调用链 + +``` +Detail.handleRefreshScore() / generateSummary() + ├── 触发: 点击右侧面板刷新按钮 + ├── 参数: { evalId, metricIds }(从 summaryData.data 中提取所有 metricId) + ├── 前置检查: summaryData 为空 → Toast 警告"无维度数据" + ├── Toast: 成功"总结生成请求已提交" / 失败"生成总结失败" + └── 后续: 刷新总结数据 +``` + +### `POST /core/evaluation/summary/config/update` 调用链 + +``` +ConfigParams.handleFormSubmit() + ├── 触发: 配置弹窗中点击确认 + ├── 参数: { evalId, calculateType, metricsConfig: [{ metricId, thresholdValue, weight }] } + ├── 前置校验: 有权重列且总和 ≠ 100 → 按钮置灰 + ├── Toast: 成功"保存成功" / 失败"保存失败" + └── 后续: 关闭弹窗 → 触发 loadAllData 刷新 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..5ef61a138cba --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,155 @@ +--- +capability_label: 任务详情 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — Store 数据流 + +## 状态管理方案 + +本模块使用 **React Context + use-context-selector** 实现集中式状态管理,不依赖 Zustand 等外部状态库。状态由 `TaskPageContextProvider` 统一管理,通过 `TaskPageContext` 向子组件树分发。 + +## 状态结构 + +### TaskPageContextType + +| 字段 | 类型 | 说明 | 更新方式 | +|------|------|------|---------| +| `taskId` | `string` | 当前评测任务 ID | 组件 props 传入,不变 | +| `taskDetail` | `EvaluationTaskType` | 任务基本信息(名称、创建时间等) | `loadTaskDetail()` 设置 | +| `statsData` | `EvaluationStatsResponse \| null` | 统计信息(总数/完成/错误/低于阈值) | `runLoadStats()` 设置,15s 轮询更新 | +| `summaryData` | `EvaluationSummaryResponse \| null` | 评分总结(各维度得分、阈值、总结文本) | `runLoadSummary()` 设置,15s 轮询更新 | +| `evaluationDetail` | `EvaluationDisplayType \| null` | 评测任务完整详情(含 evaluators、target 等) | `runLoadEvaluationDetail()` 设置,15s 轮询更新 | +| `evaluationItems` | `EvaluationItemDisplayType[]` | 评测项列表(当前筛选条件下的数据) | 由外部 `useScrollPagination` 的 `setData` 更新 | +| `appDetail` | `AppDetailType \| null` | 关联应用详情(含权限信息) | `runLoadAppDetail()` 设置 | +| `loading` | `LoadingState` | 各数据模块的加载状态 | 各加载方法自动管理 | +| `errors` | `ErrorState` | 各数据模块的错误信息 | 各加载方法自动管理 | +| `searchValue` | `string` | 搜索关键词 | `setSearchValue()` | +| `totalItems` | `number` | 评测项总数 | `setTotalItems()` | +| `filterParams` | `Record` | 当前过滤参数 | `setFilterParams()` | + +### LoadingState + +| 字段 | 说明 | +|------|------| +| `stats` | 统计数据加载中 | +| `summary` | 评分总结加载中 | +| `detail` | 评测详情加载中 | +| `items` | 评测项列表加载中 | +| `taskDetail` | 任务基本信息加载中 | + +### ErrorState + +| 字段 | 说明 | +|------|------| +| `stats` | 统计数据加载错误信息 | +| `summary` | 评分总结加载错误信息 | +| `detail` | 评测详情加载错误信息 | +| `items` | 评测项列表加载错误信息 | +| `taskDetail` | 任务基本信息加载错误信息 | + +## 数据加载方法 + +| 方法 | 说明 | 触发时机 | 轮询 | +|------|------|---------|------| +| `loadTaskDetail(id)` | 加载任务基本信息,失败时跳转回列表 | 页面初始化 | 否 | +| `loadAllData(taskDetailData?)` | 分三阶段加载:① 详情+统计(并行)② 评分总结 ③ 应用详情 | 页面初始化 | 否 | +| `refreshAllData()` | 并行刷新详情+统计+总结 | 手动触发 | 否 | +| `refreshStats()` | 刷新统计数据 | 增删改操作后 | 否 | +| `loadSummary()` | 刷新评分总结 | 生成总结后 | 否 | +| `refreshEvaluationItems()` | 刷新评测项列表(当前为空操作,列表由 useScrollPagination 管理) | 增删改操作后 | 否 | + +## 数据操作方法 + +| 方法 | 对应 API | 成功后副作用 | Toast | +|------|---------|------------|------| +| `deleteItem(itemId)` | DELETE item/delete | 刷新统计 + 列表 | 删除成功 / 删除失败 | +| `retryItem(itemId)` | POST item/retry | 刷新统计 + 列表 | 重试请求已提交 / 重试失败 | +| `updateItem(itemId, data)` | PUT item/update | 刷新统计 + 列表 | 保存成功 / 保存失败 | +| `retryFailedItems()` | POST task/retryFailed | 刷新统计 + 列表 + 提示重试数量 | 重试请求已提交 / 重试失败 | +| `exportItems(filters)` | POST item/export(downloadFetch) | 浏览器下载 CSV 文件 | 导出成功 / 导出失败 | +| `generateSummary()` | POST summary/create | 刷新总结数据 | 总结生成请求已提交 / 生成失败 | +| `generateSummaryForMetrics(params)` | POST summary/create(指定 metricIds) | 刷新总结数据 | 同上 | + +## 数据流图 + +``` +TaskPageContextProvider (taskId) +│ +├─ 初始化阶段 +│ ├── loadTaskDetail(taskId) +│ │ └── GET /core/evaluation/task/detail → taskDetail +│ ├── loadAllData(taskDetailData) +│ │ ├── [并行] loadStats() / loadEvaluationDetail() +│ │ │ ├── GET /core/evaluation/task/stats → statsData (轮询 15s) +│ │ │ └── GET /core/evaluation/task/detail → evaluationDetail (轮询 15s) +│ │ ├── loadSummary() +│ │ │ └── GET /core/evaluation/summary/detail → summaryData (轮询 15s) +│ │ └── runLoadAppDetail(appId) +│ │ └── getAppDetailById → appDetail +│ │ +├─ 列表数据(由 useScrollPagination 管理,不在 Context 内) +│ └── POST /core/evaluation/task/item/list → evaluationItems +│ ├── 首次加载: offset=0, pageSize=20 +│ ├── 滚动加载: offset=N, pageSize=20 +│ ├── 搜索: userInput=keyword (500ms 防抖) +│ ├── Tab 过滤: status/belowThreshold +│ └── 智能轮询: 加载到当前选中页的数据 +│ +├─ 用户操作 +│ ├── 编辑 → PUT item/update → 刷新列表+统计 +│ ├── 重试单条 → POST item/retry → 刷新列表+统计 +│ ├── 删除 → DELETE item/delete → 刷新列表+统计+调整选中索引 +│ ├── 批量重试 → POST task/retryFailed → 刷新列表+统计+切换Tab +│ ├── 导出 → POST item/export → 浏览器下载CSV +│ ├── 刷新评分 → POST summary/create → 刷新总结 +│ └── 配置参数 → POST summary/config/update → 刷新全部数据 +│ +└─ 组件卸载 + └── 清除所有轮询定时器 (cancelStatsPolling, cancelSummaryPolling, cancelDetailPolling) +``` + +## 轮询机制 + +评测执行过程中,Context 内通过 `useRequest` 的 `pollingInterval` 选项实现自动轮询: + +- **轮询间隔**:15 秒 +- **轮询接口**:stats、summary、detail(三个独立轮询) +- **静默更新**:首次加载后,后续轮询不显示 loading 状态,数据静默替换 +- **隐藏时暂停**:`pollingWhenHidden: false`,浏览器标签页不可见时暂停轮询 +- **错误不重试**:`pollingErrorRetryCount: 0`,轮询失败不自动重试 +- **列表智能刷新**:详情页组件通过 `useInterval` 每 15 秒调用 `smartRefreshData`,仅重新请求到当前选中项所在页码的数据,减少不必要的请求 + +## 搜索与过滤流程 + +``` +用户输入搜索关键词 + → 本地状态 immediate 更新(输入框实时显示) + → 500ms 防抖后调用 setSearchValue + → useScrollPagination 检测到 searchValue 变化 + → 自动重新请求 POST item/list (带 userInput 参数) + → 列表刷新,选中项重置为第一条 + +用户切换 Tab + → router.replace 更新 URL query currentTab + → useScrollPagination 检测到 currentTab 变化 + → 自动重新请求 POST item/list (带 Tab 对应的过滤参数) + → 列表刷新 +``` + +## Provider 层级 + +``` + (pages/dashboard/evaluation/task/detail/index.tsx) + └── + └── (实际页面组件) + ├── (通过 useContextSelector 消费 taskDetail, statsData) + ├── 数据列表区域 (通过 useContextSelector 消费 searchValue, setSearchValue 等) + ├── 详情区域 (通过 useContextSelector 消费 evaluationDetail 等) + └── 右侧面板 (通过 useContextSelector 消费 statsData, summaryData 等) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..52d8e85fe9b3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\344\273\273\345\212\241\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,58 @@ +--- +capability_label: 任务详情 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测任务 +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/task/detail"] +--- + +# 任务详情 — 组件列表 + +## pageComponents/dashboard/evaluation/task/detail/ + +本模块所有子组件均位于 `projects/app/src/pageComponents/dashboard/evaluation/task/detail/` 目录下,属于模块内部私有组件。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `NavBar.tsx` | 顶部导航栏:含面包屑路径、数据过滤标签页(全部/问题/错误)、导出和重试操作按钮 | 引用 `@fastgpt/web`(LightRowTabs, MyIcon)、`@/components/common/folder/Path`(FolderPath)| 否 | `currentTab: TabEnum`, `statsData: EvaluationStatsResponse`, `onExport`, `onRetryFailed` | +| `ScoreBar.tsx` | 单维度得分条:显示维度名称、阈值线和实际得分线的可视化对比 | 引用 `@chakra-ui/react`(Box, Flex)| 否 | `dimensionName: string`, `threshold: number`, `actualScore: number`, `isClickable`, `onClick` | +| `ScoreDashboard.tsx` | 综合评分仪表盘:SVG 半圆形仪表盘,显示阈值区间(蓝/黄)和实际得分指针 | 引用 `@chakra-ui/react`(Box, Flex)| 否 | `threshold: number`, `actualScore: number`, `maxScore`, `size` | +| `GradientBorderBox.tsx` | 渐变边框容器:带紫色渐变边框和背景的装饰性容器组件 | 引用 `@chakra-ui/react`(Box)| 否 | `children: ReactNode`(继承 BoxProps) | +| `EvaluationSummaryCard.tsx` | 评测维度总结卡片:展示各维度的 AI 生成总结内容,支持轮播切换和重试生成 | 引用 `GradientBorderBox`、`@fastgpt/web`(MyImage, MyIcon)| 否 | `data: EvaluationSummaryResponse['data']`, `selectedIndex`, `onRetryGeneration` | +| `BasicInfo.tsx` | 基本信息面板(可折叠):展示关联应用、版本、数据集、起止时间、执行人 | 引用 `@fastgpt/web`(Avatar, UserBox, MyIcon)| 否 | `evaluationDetail: EvaluationDisplayType`, `appDetail: AppDetailType` | +| `ConfigParams.tsx` | 评分参数配置弹窗:设置分数聚合方式、各维度判定阈值和权重 | 引用 `@fastgpt/web`(MyModal, MySelect, MyNumberInput, QuestionTip)| 否 | `isOpen`, `onClose`, `onConfirm`, `evalTaskId` | +| `DetailedResponseModal.tsx` | 完整响应查看弹窗:展示评测项对应的 AI 对话完整响应内容 | 引用 `@fastgpt/web`(MyModal, EmptyTip)、`@/web/core/chat/record/api`(getChatResData)、`@/components/core/chat/components/WholeResponseModal`(ResponseBox)| 否 | `isOpen`, `onClose`, `chatId`, `dataId`, `appId`, `chatTime` | + +## pages/dashboard/evaluation/task/detail/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx` | 评测任务详情主页面:三栏布局,统筹数据列表、详情展示、评分面板 | 引用上述所有子组件 + `TaskPageContext` + `@fastgpt/web` 多个通用组件和 Hooks | 否 | `taskId: string`, `currentTab: TabEnum` | + +## web/core/evaluation/context/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `taskPageContext.tsx` | 任务详情页上下文状态管理:提供数据加载、轮询、增删改查等操作方法和共享状态 | 引用 `@/web/core/evaluation/task`(全部 API 函数)、`@/web/core/app/api`(getAppDetailById)| 否 | `taskId: string`, `children: ReactNode` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | 共享前端组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享前端组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享前端组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 共享前端组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享前端组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享前端组件库 | +| `MyNumberInput` | `@fastgpt/web/components/common/MyNumberInput` | 共享前端组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享前端组件库 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | 共享前端组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享前端组件库 | +| `UserBox` | `@fastgpt/web/components/common/UserBox` | 共享前端组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享前端组件库 | +| `FolderPath` | `@/components/common/folder/Path` | 共享页面组件 | +| `NextHead` | `@/components/common/NextHead` | 共享页面组件 | +| `ResponseBox` | `@/components/core/chat/components/WholeResponseModal` | 聊天核心组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..e530ae6cd5ad --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,68 @@ +--- +capability_label: "评测任务" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测" +roles: ["团队管理员", "团队成员"] +router_paths: ["/dashboard/evaluation/task", "/dashboard/evaluation/task/detail"] +--- + +# 评测任务 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/task/index.tsx` | 评测任务列表页,展示任务表格、搜索/筛选栏、分页 | 内部 | 否 | Tab: ReactNode | +| `projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx` | 任务详情页,展示数据列表和评测详情面板 | 内部 | 否 | taskId: string, currentTab: TabEnum | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx` | 创建评测任务弹窗,包含应用选择、版本选择、数据集选择、维度管理 | 被 dashboard/agent 引用 | 是 | isOpen: boolean, onClose: () => void, onSubmit: (data: any) => void | +| `projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx` | 任务详情顶部导航栏,含 Tab 切换(全部/存疑/异常)、面包屑、导出和重试按钮 | 内部 | 否 | currentTab: TabEnum, statsData: EvaluationStatsResponse \| null, onExport?: () => void, onRetryFailed?: () => void | +| `projects/app/src/pageComponents/dashboard/evaluation/task/detail/ConfigParams.tsx` | 评分参数配置弹窗,含分数聚合方式和评测维度权重/阈值配置 | 内部 | 否 | isOpen: boolean, onClose: () => void, onConfirm?: () => void, evalTaskId: string | +| `projects/app/src/pageComponents/dashboard/evaluation/task/AppSelectWithAll.tsx` | 应用选择器(含"全部应用"选项),用于任务列表页筛选 | 内部 | 否 | value: string, onSelect: (val: string) => void, showAllOption: boolean | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| AppSelect | `@/components/Select/AppSelect` | 应用 | +| Avatar | `@fastgpt/web/components/common/Avatar` | 共享基础设施 | +| MyModal | `@fastgpt/web/components/common/MyModal` | 共享基础设施 | +| MyIcon | `@fastgpt/web/components/common/Icon` | 共享基础设施 | +| MyMenu | `@fastgpt/web/components/common/MyMenu` | 共享基础设施 | +| MySelect | `@fastgpt/web/components/common/MySelect` | 共享基础设施 | +| LightRowTabs | `@fastgpt/web/components/common/Tabs/LightRowTabs` | 共享基础设施 | +| FolderPath | `@/components/common/folder/Path` | 共享基础设施 | +| EmptyTip | `@fastgpt/web/components/common/EmptyTip` | 共享基础设施 | +| QuestionTip | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享基础设施 | +| FormLabel | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享基础设施 | +| MyTag | `@fastgpt/web/components/common/Tag` | 共享基础设施 | +| MyBox | `@fastgpt/web/components/common/MyBox` | 共享基础设施 | +| MyIconButton | `@fastgpt/web/components/common/Icon/button` | 共享基础设施 | +| MyTooltip | `@fastgpt/web/components/common/MyTooltip` | 共享基础设施 | +| MyNumberInput | `@fastgpt/web/components/common/MyNumberInput` | 共享基础设施 | +| ManageDimension | `@/pageComponents/dashboard/evaluation/task/ManageDimension` | 评测维度 | + +## Context/状态管理 + +| 模块路径 | 用途 | 是否共享 | +|---------|------|---------| +| `projects/app/src/web/core/evaluation/context/taskPageContext.tsx` | 任务详情页的全局状态管理,提供数据加载、操作方法和轮询机制 | 否(内部) | + +## API 层 + +| 模块路径 | 用途 | 是否共享 | +|---------|------|---------| +| `projects/app/src/web/core/evaluation/task.ts` | 评测任务相关 API 封装(任务 CRUD、评测项 CRUD、总结报告) | 是 | +| `projects/app/src/web/core/evaluation/evaluation.ts` | 评测通用 API 封装(创建评测含文件上传、列表、删除等) | 是 | + +## 工具函数 + +| 模块路径 | 用途 | 是否共享 | +|---------|------|---------| +| `projects/app/src/web/core/evaluation/utils.ts` | 评测工具函数(内置维度信息获取、分数格式化等) | 是 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c9e55e108878 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,104 @@ +--- +capability_label: 评测任务首页 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 评测任务 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/task"] +--- + +# 评测任务首页 — API索引 + +## 任务列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/list` | POST | 获取评测任务分页列表(含实时状态和统计) | `web/core/evaluation/task.ts:85` → `pages/dashboard/evaluation/task/index.tsx:60` | 评测任务首页→进入页面时调用;评测任务首页→搜索/筛选时调用;评测任务首页→翻页时调用;评测任务首页→创建/编辑/删除后刷新时调用 | + +## 任务管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/update` | PUT | 更新评测任务信息(重命名) | `web/core/evaluation/task.ts:68` → `pages/dashboard/evaluation/task/index.tsx:123` | 评测任务首页→操作菜单→重命名→确认修改时调用 | +| `/core/evaluation/task/delete` | DELETE | 删除评测任务及其关联数据 | `web/core/evaluation/task.ts:60` → `pages/dashboard/evaluation/task/index.tsx:138` | 评测任务首页→操作菜单→删除→确认删除时调用 | +| `/core/evaluation/task/retryFailed` | POST | 重新执行任务中所有失败的评测项 | `web/core/evaluation/task.ts:116` → `pages/dashboard/evaluation/task/index.tsx:205` | 评测任务首页→操作菜单→重试失败项→点击时调用 | + +## 创建任务 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/create` | POST | 创建评测任务,自动创建评测项并启动 | `web/core/evaluation/task.ts:52` → `pageComponents/dashboard/evaluation/task/CreateModal.tsx:335` | 评测任务首页→创建新任务→填写表单→提交时调用 | + +--- + +## API 调用链追踪 + +### `POST /core/evaluation/task/list` 调用链 + +``` +EvaluationTasks (pages/dashboard/evaluation/task/index.tsx) + ├── 触发: 组件挂载 / searchValue/appFilter 变更 / 翻页 / fetchData() 手动刷新 + ├── 参数: { pageNum, pageSize, searchKey, appId } + ├── 适配层: getEvaluationListAdapter 将 PaginationProps 转为 ListEvaluationsRequest + ├── 响应处理: 更新 tasks 列表、total 总数;Pagination 组件更新页码状态 + └── 错误处理: usePagination 内部处理,EmptyTip 兜底 + +CreateModal (pageComponents/dashboard/evaluation/task/CreateModal.tsx) + ├── 触发: 获取应用最近使用的数据集(getLastUsedDataset) + ├── 参数: { pageNum:1, pageSize:1, appId } + └── 响应处理: 取结果 list[0].evalDatasetCollectionId 自动填充数据集字段 +``` + +### `PUT /core/evaluation/task/update` 调用链 + +``` +EvaluationTasks (pages/dashboard/evaluation/task/index.tsx) + ├── 触发: 用户通过 EditTitleModal 确认修改任务名称 + ├── 参数: { evalId, name } + ├── 封装: onUpdateTaskName(evalId, newName) → putUpdateEvaluation({ evalId, name }) + └── 响应处理: onSuccess → fetchData() 刷新列表 +``` + +### `DELETE /core/evaluation/task/delete` 调用链 + +``` +EvaluationTasks (pages/dashboard/evaluation/task/index.tsx) + ├── 触发: 用户通过 ConfirmModal 确认删除 + ├── 参数: { evalId } + ├── 封装: handleDeleteTask(evalId) → onDeleteEvaluation(evalId) → deleteEvaluation(evalId) + ├── 后端流程: 事务中清理评估项队列 → 清理总结队列 → 删除评估项 → 删除评估记录 + └── 响应处理: onSuccess → fetchData() 刷新列表 +``` + +### `POST /core/evaluation/task/retryFailed` 调用链 + +``` +EvaluationTasks (pages/dashboard/evaluation/task/index.tsx) + ├── 触发: 操作菜单中点击"重试失败项"(仅当 statistics.error > 0 可见) + ├── 参数: { evalId } + └── 响应处理: .then(() => fetchData()) 刷新列表;.catch() 打印错误日志 +``` + +### `POST /core/evaluation/task/create` 调用链 + +``` +CreateModal (pageComponents/dashboard/evaluation/task/CreateModal.tsx) + ├── 触发: 用户填写完整表单后提交(handleSubmitForm → createTask) + ├── 参数: { name, evalDatasetCollectionId, target: { type, config: { appId, versionId } }, evaluators: [{ metric, runtimeConfig }] } + ├── 前端校验: 任务名称非空;每个需要模型的维度已配置模型 + ├── 响应处理: onSuccess → 调用父组件 onSubmit 回调(fetchData 刷新列表)→ 关闭弹窗 + └── 错误处理: successToast/errorToast 展示操作结果 +``` + +--- + +## API 分组汇总 + +| 分组 | API 数量 | 涉及 API | +|------|---------|---------| +| 查询/列表 | 1 | POST /core/evaluation/task/list | +| 创建 | 1 | POST /core/evaluation/task/create | +| 更新 | 1 | PUT /core/evaluation/task/update | +| 删除 | 1 | DELETE /core/evaluation/task/delete | +| 批量操作 | 1 | POST /core/evaluation/task/retryFailed | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..4bf15840661a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\344\273\273\345\212\241/\350\257\204\346\265\213\344\273\273\345\212\241\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,48 @@ +--- +capability_label: 评测任务首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 评测任务 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/task"] +--- + +# 评测任务首页 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/task/index.tsx` | 评测任务列表页,展示任务表格,支持搜索、筛选、CRUD 操作 | 引用自 评测API层 → getEvaluationList/putUpdateEvaluation/deleteEvaluation/postRetryFailedEvaluationItems | 否 | `Tab: React.ReactNode`(从父级评测首页传入的 FillRowTabs 组件) | + +## 业务组件 (pageComponents/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/evaluation/task/AppSelectWithAll.tsx` | 应用筛选下拉选择器,支持"全部应用"选项 | 内部 | 否 | `value: string, onSelect: (id: string) => void, showAllOption: boolean` | +| `projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx` | 创建评测任务弹窗,含应用选择、版本选择、数据集选择、维度配置 | 引用自 评测API层 → postCreateEvaluation/getAppVersionDetail/getEvaluationDatasetListV2;引用自 评测维度模块 → 维度管理弹窗 | 否 | `isOpen: boolean, onClose: () => void, onSubmit: (data: any) => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 公共组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 公共组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 公共组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 公共组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 公共组件库(父级 evaluation/index.tsx 使用) | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库(CreateModal 使用) | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 公共组件库(CreateModal 使用) | +| `FormLabel` | `@fastgpt/web/components/common/FormLabel` | 公共组件库(CreateModal 使用) | +| `QuestionTip` | `@fastgpt/web/components/common/QuestionTip` | 公共组件库(CreateModal 使用) | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | 公共 Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 公共 Hooks | +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 公共 Hooks | +| `useEditTitle` | `@/web/common/hooks/useEditTitle` | 公共 Hooks | +| `useDebounceFn` | `ahooks` | 第三方 Hooks(ahooks) | + +> 以上外部组件均来自公共组件库和 Hooks 层,属于框架基础设施级别依赖,非跨业务模块依赖。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..79268a918169 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,58 @@ +--- +capability_label: "评测数据集" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测" +roles: ["团队成员"] +router_paths: [] +--- + +# 评测数据集 — API索引 + +## 数据集管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/create` | POST | 创建评测数据集 | `src/web/core/evaluation/dataset.ts:32` → `src/pages/dashboard/evaluation/dataset/fileImport.tsx:128` | 评测数据集→文件导入→提交表单时调用(无 collectionId 时先创建) | +| `/core/evaluation/dataset/collection/list` | POST | 获取评测数据集分页列表 | `src/web/core/evaluation/dataset.ts:37` → `src/pages/dashboard/evaluation/dataset/index.tsx:99` | 评测数据集→数据集列表→加载时调用;搜索/分页时调用 | +| `/core/evaluation/dataset/collection/listv2` | POST | 获取评测数据集轻量列表 | `src/web/core/evaluation/dataset.ts:43` | 评测数据集→需要简化列表数据的场景 | +| `/core/evaluation/dataset/collection/update` | POST | 更新数据集名称 | `src/web/core/evaluation/dataset.ts:51` → `src/pages/dashboard/evaluation/dataset/index.tsx:139`
`src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` | 评测数据集→列表页→重命名时调用;评测数据集→详情页→设置时调用 | +| `/core/evaluation/dataset/collection/delete` | DELETE | 删除评测数据集 | `src/web/core/evaluation/dataset.ts:47` → `src/pages/dashboard/evaluation/dataset/index.tsx:186` | 评测数据集→列表页→确认删除时调用 | +| `/core/evaluation/dataset/collection/detail` | GET | 获取数据集详情 | `src/web/core/evaluation/dataset.ts:110` | 评测数据集→获取数据集元信息时调用 | + +## 数据导入 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/import` | POST | 上传文件批量导入数据 | `src/web/core/evaluation/dataset.ts:124` → `src/pages/dashboard/evaluation/dataset/fileImport.tsx:135` | 评测数据集→文件导入→提交文件上传时调用(含进度回调) | +| `/core/evaluation/dataset/data/smartGenerate` | POST | 智能生成评测数据集 | `src/web/core/evaluation/dataset.ts:28` → `src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx:141` | 评测数据集→列表页/详情页→智能生成弹窗→确认时调用 | +| `/core/evaluation/dataset/data/fileId` | POST | 通过文件 ID 导入数据 | `src/web/core/evaluation/dataset.ts:55` | 评测数据集→文件已上传后通过 fileId 关联导入 | + +## 数据条目管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/list` | POST | 获取数据集数据分页列表 | `src/web/core/evaluation/dataset.ts:63` → `src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx:121` | 评测数据集→详情页→数据列表→滚动加载时调用;筛选/搜索时调用 | +| `/core/evaluation/dataset/data/create` | POST | 手动新增数据条目 | `src/web/core/evaluation/dataset.ts:67` | 评测数据集→详情页→手动添加弹窗→提交时调用 | +| `/core/evaluation/dataset/data/create/smartGenerate` | POST | 智能生成追加数据 | `src/web/core/evaluation/dataset.ts:71` | 评测数据集→详情页→智能生成追加→确认时调用 | +| `/core/evaluation/dataset/data/create/localFile` | POST | 文件导入追加数据 | `src/web/core/evaluation/dataset.ts:75` | 评测数据集→详情页→文件导入追加→提交时调用 | +| `/core/evaluation/dataset/data/update` | POST | 编辑数据条目 | `src/web/core/evaluation/dataset.ts:79` | 评测数据集→详情页→编辑数据弹窗→保存时调用 | +| `/core/evaluation/dataset/data/delete` | DELETE | 删除数据条目 | `src/web/core/evaluation/dataset.ts:83` → `src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx:92` | 评测数据集→详情页→数据卡片→确认删除时调用 | +| `/core/evaluation/dataset/data/detail` | GET | 获取单条数据详情 | `src/web/core/evaluation/dataset.ts:116` | 评测数据集→查看单条数据详细信息时调用 | + +## 质量评估 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/qualityAssessment` | POST | 单项数据质量评估 | `src/web/core/evaluation/dataset.ts:87` | 评测数据集→详情页→对单条数据执行质量评估时调用 | +| `/core/evaluation/dataset/collection/qualityAssessmentBatch` | POST | 批量数据质量评估 | `src/web/core/evaluation/dataset.ts:91` | 评测数据集→详情页→批量质量评估按钮→确认时调用 | + +## 异常任务管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/failedTasks` | POST | 获取异常任务列表 | `src/web/core/evaluation/dataset.ts:98` → `src/pageComponents/dashboard/evaluation/dataset/errorModal.tsx` | 评测数据集→列表页/详情页→查看异常任务详情时调用 | +| `/core/evaluation/dataset/collection/retryTask` | POST | 重试单个异常任务 | `src/web/core/evaluation/dataset.ts:102` | 评测数据集→异常任务弹窗→重试单个任务时调用 | +| `/core/evaluation/dataset/collection/retryAllTask` | POST | 批量重试所有异常任务 | `src/web/core/evaluation/dataset.ts:120` | 评测数据集→异常任务弹窗→全部重试时调用 | +| `/core/evaluation/dataset/collection/deleteTask` | POST | 删除单个异常任务 | `src/web/core/evaluation/dataset.ts:106` | 评测数据集→异常任务弹窗→删除单个任务时调用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..0c5a26b6fe33 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/API\347\264\242\345\274\225.md" @@ -0,0 +1,79 @@ +--- +capability_label: "文件导入" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测数据集" +roles: ["具有评测创建权限的团队成员"] +router_paths: + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 文件导入 — API索引 + +## 数据集操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/create` | POST | 创建评测数据集 | `projects/app/src/web/core/evaluation/dataset.ts:32` → `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx:128` | 工作台→评测→评测数据集→文件导入→新建数据集并导入文件→提交时调用(新建模式) | + +## 文件导入 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/import` | POST | 上传 CSV 文件批量导入评测数据 | `projects/app/src/web/core/evaluation/dataset.ts:124` → `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx:135` | 工作台→评测→评测数据集→文件导入→新建/追加模式→提交时调用 | + +## API 调用链追踪 + +### `POST /core/evaluation/dataset/collection/create` 调用链 + +``` +文件导入页面 (fileImport.tsx) + ├── 触发: 用户在新建模式下点击"确认导入"按钮,且 collectionId 为空 + ├── 参数: { name: 用户输入的数据集名称 } + ├── 响应处理: 返回新创建的 collectionId,作为下一步数据导入 API 的参数 + ├── 顺序: 串行——必须先完成创建,再使用返回的 collectionId 调用数据导入 API + └── 错误处理: 名称重复/超出限额 → Promise reject → toast 显示错误信息 + +数据导入 API (import.ts) + ├── 权限校验: authEvaluationDatasetCreate → 校验用户是否有创建权限 + ├── 配额检查: checkTeamEvalDatasetLimit → 校验团队数据集数量限额 + └── 唯一性: validateCollectionName → 校验数据集名称在团队内唯一 +``` + +### `POST /core/evaluation/dataset/data/import` 调用链 + +``` +文件导入页面 (fileImport.tsx) + ├── 触发: 用户点击"确认导入"按钮 + ├── 参数: + │ ├── FormData.file[]: 多文件(仅 .csv) + │ └── FormData.data (JSON): + │ ├── collectionId: 数据集 ID(新建模式由前置 API 返回,追加模式从 URL 参数获取) + │ ├── enableQualityEvaluation: 是否开启自动评测(布尔值,来自 autoEvaluation 状态) + │ └── evaluationModelId: 评测模型 ID(自动评测关闭时为 undefined) + ├── 上传进度: 通过 onUploadProgress 回调更新百分比显示(percent 状态) + ├── 超时配置: timeout=600000ms (10分钟) + ├── Content-Type: multipart/form-data; charset=utf-8 + ├── 响应处理: 成功 → toast "文件导入成功" → 根据 scene 参数跳转 + └── 错误处理: Promise reject → toast 显示错误信息 + +数据导入 API (import.ts) + ├── 文件接收: Multer 多文件解析 + ├── 文件校验: + │ ├── 文件数 > 0 + │ └── 扩展名为 .csv + ├── 权限校验(分支): + │ ├── collectionId 存在 → authEvaluationDatasetDataWrite(已有数据集写入权限) + │ └── collectionId 为空 → authEvaluationDatasetCreate(创建权限)+ 参数校验 + ├── CSV 解析: parseCSVContent (Papa Parse) + │ ├── 必填列: user_input, expected_output + │ └── 可选列: actual_output, context, retrieval_context + ├── 数据集创建(如新建): createNewCollection + ├── 数据插入: MongoEvalDatasetData.insertMany + ├── 质量评测(如开启): + │ ├── checkEvalDatasetDataQualityQueueHealth → 检查消息队列健康 + │ └── addEvalDatasetDataQualityBulk → 批量添加质量评估任务 + ├── 审计日志: addAuditLog (IMPORT_EVALUATION_DATASET_DATA) + └── 清理: removeFilesByPaths → 删除 Multer 临时文件 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..0b7e78755038 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\346\226\207\344\273\266\345\257\274\345\205\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,37 @@ +--- +capability_label: "文件导入" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "评测数据集" +roles: ["具有评测创建权限的团队成员"] +router_paths: + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 文件导入 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx` | 文件导入页面,包含数据集名称输入、CSV 文件上传、自动评测配置和提交 | 内部 | 否 | 无外部 Props(路由页面组件) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台容器(共享基础设施) | +| `FileSelector` | `@/components/Select/FileSelectorBox` | 共享组件 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 共享组件 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 共享组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享组件库 | + +## 说明 + +本模块只有单个页面组件 `FileImport`,其内部使用 Chakra UI 基础组件(`Box`, `Button`, `Flex`, `Input`, `VStack`, `IconButton`, `Switch`, `HStack`, `Progress`, `Text`, `Table`, `Thead`, `Tbody`, `Tr`, `Th`, `Td`, `TableContainer`)构建表单布局,不包含模块内共享的业务组件。 + +组件内部状态管理使用 React hooks(`useState`、`useCallback`、`useMemo`)和 `react-hook-form`(`useForm`),不依赖专门的状态管理 Store。全局数据(模型列表、系统配置)通过 `useSystemStore` 获取。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..74c086b94a39 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,58 @@ +--- +capability_label: "评测数据集" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测" +roles: ["团队成员"] +router_paths: [] +--- + +# 评测数据集 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/dashboard/evaluation/dataset/index.tsx` | 评测数据集列表页,含搜索、分页、创建菜单、操作菜单 | 内部 | 否 | `Tab: React.ReactNode` | +| `src/pages/dashboard/evaluation/dataset/fileImport.tsx` | 文件导入页,含 CSV 上传、自动评测配置 | 内部 | 否 | —(页面组件,无外部 Props) | +| `src/pages/dashboard/evaluation/dataset/detail/index.tsx` | 数据集详情页,含导航栏和数据列表 | 内部 | 否 | `collectionId: string, collectionName: string` | + +## 业务组件 — 数据集 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx` | 智能生成数据集弹窗,配置知识库、模型、数量 | 被 评测任务(CreateModal)、对话(EvaluationDatasetSelector)、数据集详情(DataListModals) 引用 | 是 | `isOpen, onClose, onConfirm, defaultValues, scene, returnDatasetId` | +| `src/pageComponents/dashboard/evaluation/dataset/errorModal.tsx` | 异常任务详情弹窗,展示失败任务列表及重试/删除操作 | 内部 | 否 | `isOpen, onClose, collectionId` | +| `src/pageComponents/dashboard/evaluation/dataset/FileSelector.tsx` | 文件选择器,支持 CSV 文件选择和展示 | 内部 | 否 | — | +| `src/pageComponents/dashboard/evaluation/dataset/RenderFiles.tsx` | 文件列表渲染组件 | 内部 | 否 | — | + +## 业务组件 — 详情页 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dashboard/evaluation/dataset/detail/NavBar.tsx` | 详情页面包屑导航栏 | 被 评测任务详情(detail/index.tsx)、数据集详情(detail/index.tsx) 引用 | 是 | —(从 Context 读取) | +| `src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` | 数据条目列表,支持滚动分页、状态筛选、搜索、操作 | 内部 | 否 | `collectionId: string` | +| `src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` | 数据列表全部弹窗集合(编辑、质量评估、智能生成、手动添加、设置) | 内部 | 否 | `total: number, refreshList: () => void` | +| `src/pageComponents/dashboard/evaluation/dataset/detail/DataListContext.tsx` | 数据列表状态上下文(选中项、弹窗开关、数据列表) | 内部 | 否 | `collectionId: string`(Provider) | +| `src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx` | 编辑数据条目弹窗(含修改评估弹窗) | 内部 | 否 | —(从 Context 读取) | +| `src/pageComponents/dashboard/evaluation/dataset/detail/ModifyEvaluationModal.tsx` | 修改评估结果弹窗,选择高质量/需优化 | 内部 | 否 | `onClose, onConfirm` | +| `src/pageComponents/dashboard/evaluation/dataset/detail/QualitySelect.tsx` | 质量评估结果选择器(下拉多选) | 内部 | 否 | `onSelect` | +| `src/pageComponents/dashboard/evaluation/dataset/detail/StatusSelect.tsx` | 数据状态筛选下拉框(全部/高质量/需优化/异常等) | 内部 | 否 | `value, onSelect` | +| `src/pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx` | 手动添加数据条目弹窗 | 内部 | 否 | —(从 Context 读取) | +| `src/pageComponents/dashboard/evaluation/dataset/detail/Context.tsx` | 详情页常量与工具函数 | 内部 | 否 | —(工具模块) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | web 公共组件 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 公共组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 公共组件 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | web 公共组件 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | app 公共组件 | +| `FileSelector` | `@/components/Select/FileSelectorBox` | app 公共组件 | +| `UserBox` | `@fastgpt/web/components/common/UserBox` | web 公共组件 | +| `DatasetSelectModal` | `@/components/core/app/sfDatasetSelectModal` | app 知识库选择 | +| `FolderPath` | `@/components/common/folder/Path` | app 公共组件 | +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台布局 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f602c2177da2 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,101 @@ +--- +capability_label: 评测数据集详情 +doc_type: "12" +doc_label: "API索引" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — API 索引 + +## 查询/详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/detail?collectionId={id}` | GET | 获取评测数据集详情(含评测模型配置) | `src/web/core/evaluation/dataset.ts:110` → `pageComponents/.../detail/DataListModals.tsx:96` | 工作台→评测→评测数据集详情→设置弹窗打开时调用 | + +## 数据列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/list` | POST | 获取数据集数据列表(分页) | `src/web/core/evaluation/dataset.ts:63` → `pageComponents/.../detail/DataList.tsx:121` | 工作台→评测→评测数据集详情→加载/搜索/筛选/滚动分页时调用 | + +## 数据修改 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/update` | POST | 编辑数据集数据(用户问题和期望输出) | `src/web/core/evaluation/dataset.ts:79` → `pageComponents/.../detail/DataListModals.tsx:78` | 工作台→评测→评测数据集详情→编辑数据弹窗→点击保存时调用 | +| `/core/evaluation/dataset/data/delete` | DELETE | 删除单条数据集数据 | `src/web/core/evaluation/dataset.ts:83` → `pageComponents/.../detail/DataList.tsx:92` | 工作台→评测→评测数据集详情→删除确认气泡→点击删除时调用 | +| `/core/evaluation/dataset/collection/update` | POST | 更新数据集配置(评测模型等) | `src/web/core/evaluation/dataset.ts:51` → `pageComponents/.../detail/DataListModals.tsx:83` | 工作台→评测→评测数据集详情→设置弹窗→确认保存时调用 | + +## 数据追加 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/create` | POST | 手动新增单条数据到数据集 | `src/web/core/evaluation/dataset.ts:67` → `pageComponents/.../detail/ManuallyAddModal.tsx` | 工作台→评测→评测数据集详情→追加数据→手动录入→提交时调用 | +| `/core/evaluation/dataset/data/create/smartGenerate` | POST | AI 智能生成数据追加到数据集 | `src/web/core/evaluation/dataset.ts:72` → `pageComponents/.../IntelligentGeneration.tsx` | 工作台→评测→评测数据集详情→追加数据→AI生成→确认时调用 | +| `/core/evaluation/dataset/data/import` | POST | 文件导入数据到数据集 | `src/web/core/evaluation/dataset.ts:154` → `pageComponents/.../DataList.tsx`(路由跳转) | 工作台→评测→评测数据集详情→追加数据→文件导入→上传并确认时调用 | + +## 质量评估 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/qualityAssessmentBatch` | POST | 批量质量评估全部数据 | `src/web/core/evaluation/dataset.ts:91` → `pageComponents/.../detail/DataListModals.tsx:70` | 工作台→评测→评测数据集详情→质量评估按钮→确认弹窗→开始评估时调用 | + +## API 调用链追踪 + +### `/core/evaluation/dataset/collection/detail` 调用链 + +``` +DataListModals + ├── 触发: 设置弹窗打开 → 初始化评测模型 + ├── 参数: collectionId (从 Context 获取) + └── 响应处理: 提取 evaluationModelId → 设置当前选中的评测模型 +``` + +### `/core/evaluation/dataset/data/list` 调用链 + +``` +DataList (DataListContent) + ├── 触发: 页面加载 / 滚动到底部 / 搜索关键字变化 / 筛选状态变化 + ├── 参数: searchKey, status, qualityResult, collectionId, offset (分页) + └── 响应处理: 更新 evaluationDataList 状态 → 同步到 Context → 渲染数据卡片列表 +``` + +### `/core/evaluation/dataset/data/update` 调用链 + +``` +DataListModals (handleSaveUpdateData) + ├── 触发: 编辑弹窗中点击「保存」或「保存并下一条」 + ├── 参数: dataId, userInput, expectedOutput, qualityMetadata, qualityResult + └── 响应处理: 成功 → 提示"更新成功" → 刷新列表;失败 → 显示错误提示 +``` + +### `/core/evaluation/dataset/data/delete` 调用链 + +``` +DataList (DataListContent → handleDeleteConfirmClick) + ├── 触发: 删除确认气泡中点击「删除」 + ├── 参数: { dataId } + └── 响应处理: 成功 → 提示"删除成功" → 刷新列表;失败 → 显示错误提示 +``` + +### `/core/evaluation/dataset/collection/qualityAssessmentBatch` 调用链 + +``` +DataListModals (handleQualityEvaluationConfirmWithRequest) + ├── 触发: 质量评估确认弹窗中点击「开始评估」 + ├── 参数: { collectionId } + └── 响应处理: 成功 → 提示"提交成功" → 刷新列表(数据状态变为排队中/评估中);失败 → 显示错误提示 +``` + +### `/core/evaluation/dataset/collection/update` 调用链 + +``` +DataListModals (handleSettingsConfirm) + ├── 触发: 设置弹窗中点击「确认」 + ├── 参数: { collectionId, evaluationModelId } + └── 响应处理: 成功 → 提示"更新成功" → 设置弹窗关闭 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..e66b8cfd768d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,21 @@ +--- +capability_label: 评测数据集详情 +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — Hooks 工具函数 + +## 说明 + +本模块未检测到自定义 Hooks 或工具函数。模块中使用的 Hooks 均来自外部共享库: + +- `useScrollPagination` — 来自 `@fastgpt/web/hooks/useScrollPagination`,用于滚动分页加载 +- `useRequest` — 来自 `@fastgpt/web/hooks/useRequest`,用于 API 请求封装 +- `useDebounceFn` — 来自 `ahooks`,用于搜索输入防抖处理 +- `useContextSelector` — 来自 `use-context-selector`,用于 Context 值的选择性订阅 +- `useDisclosure` — 来自 `@chakra-ui/react`,用于弹窗开关管理 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..8fbcf34292b8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,20 @@ +--- +capability_label: 评测数据集详情 +doc_type: "14" +doc_label: "Store数据流" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — Store 数据流 + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用 React Context(`DataListContext`、`DatasetDetailPageContext`)管理页面内共享状态 +- 使用外部 `useSystemStore`(Zustand)获取 LLM 模型列表 +- 通过 Props 和 Context 进行父子组件通信 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9f54fbf3622f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,47 @@ +--- +capability_label: 评测数据集详情 +doc_type: "13" +doc_label: "组件列表" +generated_at: 2026-06-18T12:00:00+08:00 +parent_module: 评测数据集 +roles: [] +router_paths: ["/dashboard/evaluation/dataset/detail"] +--- + +# 评测数据集详情 — 组件列表 + +## pages/ (页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dataset/detail/index.tsx` | 评测数据集详情页入口,组合 NavBar 和 DataList,提供 Context 包裹 | 被 evaluation/task/detail 和 dataset/detail 引用 | 是 | collectionId: string, collectionName: string | + +## pageComponents/ (业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/evaluation/dataset/detail/NavBar.tsx` | 顶部面包屑导航,展示当前数据集名称和路径 | 内部 | 否 | 无外部 Props | +| `pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` | 数据集数据列表主组件,含搜索、筛选、操作按钮和数据卡片列表 | 引用外部 MyIcon、MyInput、MyTag、MyMenu、EvaluationStatusSelect 等 | 否 | collectionId: string | +| `pageComponents/dashboard/evaluation/dataset/detail/DataListContext.tsx` | 数据列表 Context,管理弹窗开关状态、选中项、删除确认等共享状态 | 内部 | 否 | collectionId: string | +| `pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` | 统一管理所有弹窗:编辑数据、质量评估确认、智能生成、手动新增、设置 | 引用外部 MyModal、AIModelSelector、IntelligentGeneration 等 | 否 | total: number, refreshList: () => void | +| `pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx` | 数据编辑弹窗,修改用户输入和期望输出 | 内部 | 否 | isOpen, onClose, onSave, formData | +| `pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx` | 手动新增数据弹窗,填写问题和参考回答 | 内部 | 否 | isOpen, collectionId, onClose, onConfirm, defaultValues | +| `pageComponents/dashboard/evaluation/dataset/detail/StatusSelect.tsx` | 数据状态下拉筛选器 | 内部 | 否 | value, onSelect | +| `pageComponents/dashboard/evaluation/dataset/detail/const.ts` | 状态常量和标签映射定义 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `NextHead` | `@/components/common/NextHead` | 通用组件 | +| `FolderPath` | `@/components/common/folder/Path` | 通用组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享 Web 组件库 | +| `MyInput` | `@/components/MyInput` | 通用组件 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享 Web 组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | 共享 Web 组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 共享 Web 组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享 Web 组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享 Web 组件库 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 通用组件 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享 Web 组件库 | +| `IntelligentGeneration` | `../IntelligentGeneration` | 评测数据集模块内 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3ccfd9e6d7d2 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,122 @@ +--- +capability_label: "评测数据集首页" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — API索引 + +## 数据集列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/list` | POST | 获取评测数据集分页列表 | `@/web/core/evaluation/dataset.ts:36` → `index.tsx:99` | 评测数据集首页→查看数据集列表→加载时调用;评测数据集首页→搜索数据集→搜索时调用;评测数据集首页→翻页→分页时调用 | + +## 数据集管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/update` | POST | 更新数据集名称 | `@/web/core/evaluation/dataset.ts:51` → `index.tsx:139` | 评测数据集首页→重命名数据集→提交新名称时调用 | +| `/core/evaluation/dataset/collection/delete` | DELETE | 删除数据集 | `@/web/core/evaluation/dataset.ts:47` → `index.tsx:186` | 评测数据集首页→删除数据集→确认删除时调用 | + +## 智能生成 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/smartGenerate` | POST | 智能生成评测数据集 | `@/web/core/evaluation/dataset.ts:28` → `IntelligentGeneration.tsx:141` | 评测数据集首页→智能生成数据集→提交生成参数时调用 | + +## 异常任务处理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/failedTasks` | POST | 获取异常任务详情列表 | `@/web/core/evaluation/dataset.ts:98` → `errorModal.tsx:49` | 评测数据集首页→查看异常任务→打开弹窗时调用 | +| `/core/evaluation/dataset/collection/retryTask` | POST | 重试单个失败任务 | `@/web/core/evaluation/dataset.ts:102` → `errorModal.tsx:80` | 评测数据集首页→查看异常任务→点击单任务重试时调用 | +| `/core/evaluation/dataset/collection/deleteTask` | POST | 删除单个失败任务 | `@/web/core/evaluation/dataset.ts:106` → `errorModal.tsx:91` | 评测数据集首页→查看异常任务→点击单任务删除时调用 | +| `/core/evaluation/dataset/collection/retryAllTask` | POST | 批量重试所有失败任务 | `@/web/core/evaluation/dataset.ts:120` → `errorModal.tsx:102` | 评测数据集首页→查看异常任务→点击全部重试时调用 | + +## API 调用链追踪 + +### `/core/evaluation/dataset/collection/list` 调用链 + +``` +EvaluationDatasets (index.tsx) + ├── 触发: 页面加载、翻页、搜索防抖触发后 + ├── 参数: { pageNum, pageSize: 10, searchKey } + ├── 响应处理: usePagination 管理 data/total/isLoading,渲染表格 + └── 刷新依赖: [searchValue] 变化时重新触发 +``` + +### `/core/evaluation/dataset/collection/update` 调用链 + +``` +EvaluationDatasets (index.tsx) + ├── 触发: 重命名弹窗确认后 + ├── 参数: { collectionId, name } + ├── 响应处理: 成功 toast "更新成功",调用 fetchData() 刷新列表 + └── 错误处理: 统一错误 toast +``` + +### `/core/evaluation/dataset/collection/delete` 调用链 + +``` +EvaluationDatasets (index.tsx) + ├── 触发: 确认删除弹窗→确认 + ├── 参数: { collectionId } + ├── 响应处理: 成功 toast "删除成功",调用 fetchData() 刷新列表 + └── 错误处理: toast "删除数据集失败"(dashboard_evaluation:delete_dataset_error) +``` + +### `/core/evaluation/dataset/data/smartGenerate` 调用链 + +``` +IntelligentGeneration (IntelligentGeneration.tsx) + ├── 触发: 弹窗表单提交 + ├── 参数: { name?, kbDatasetIds[], intelligentGenerationModelId, count, collectionId? } + ├── 响应处理: 返回 collectionId,触发 onConfirm 回调,父组件刷新列表 + └── 错误处理: useRequest 统一错误 toast +``` + +### `/core/evaluation/dataset/collection/failedTasks` 调用链 + +``` +ErrorModal (errorModal.tsx) + ├── 触发: 弹窗打开(isOpen && collectionId) + ├── 参数: { collectionId } + ├── 响应处理: 设置 errorList,记录初始长度用于关闭时判断是否有变化 + └── 注意: 弹窗关闭时清空数据 +``` + +### `/core/evaluation/dataset/collection/retryTask` 调用链 + +``` +ErrorModal (errorModal.tsx) + ├── 触发: 点击单行"重试"按钮 + ├── 参数: { jobId, collectionId } + ├── 响应处理: 成功 toast "重试成功",刷新异常任务列表 + └── 错误处理: useRequest 统一错误 toast +``` + +### `/core/evaluation/dataset/collection/deleteTask` 调用链 + +``` +ErrorModal (errorModal.tsx) + ├── 触发: 点击单行"删除"按钮 + ├── 参数: { jobId, collectionId } + ├── 响应处理: 成功 toast "删除成功",刷新异常任务列表 + └── 错误处理: useRequest 统一错误 toast +``` + +### `/core/evaluation/dataset/collection/retryAllTask` 调用链 + +``` +ErrorModal (errorModal.tsx) + ├── 触发: 点击"全部重试"按钮 + ├── 参数: { collectionId } + ├── 响应处理: 成功 toast "重试成功",刷新异常任务列表 + ├── 前置条件: 任务列表非空时按钮可用,列表为空或加载中时灰显 + └── 错误处理: useRequest 统一错误 toast +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..33654f6bde52 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,25 @@ +--- +capability_label: "评测数据集首页" +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — Hooks工具函数 + +## 说明 + +本模块未检测到自定义 Hooks/Composables/Utils/Constants 导出函数。 + +模块的逻辑复用全部依赖项目共享 Hook: +- usePagination(分页管理) +- useRequest(API 请求管理) +- useConfirm(确认弹窗) +- useEditTitle(编辑标题弹窗) +- useDebounceFn(防抖,来自 ahooks) +- useDisclosure(弹窗开关,来自 Chakra UI) + +组件信息参见 [组件列表](./组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..2cafe45f81ca --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,22 @@ +--- +capability_label: "评测数据集首页" +doc_type: "14" +doc_label: "Store数据流" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — Store数据流 + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 页面级状态使用 React useState 管理(searchValue、selectedDataset 等) +- 列表数据通过 usePagination Hook 管理(来自 @fastgpt/web/hooks/usePagination) +- 弹窗状态使用 Chakra UI useDisclosure Hook 管理 +- 模型列表通过 useSystemStore 全局 Store 获取(仅在 IntelligentGeneration 子组件中使用) +- API 请求状态通过 useRequest Hook 管理 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..a314da0beacb --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,52 @@ +--- +capability_label: "评测数据集首页" +doc_type: "15" +doc_label: "共享组件清单" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 共享组件清单 + +## IntelligentGeneration 智能生成弹窗 + +- **文件位置**: `projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx` +- **用途**: 提供评测数据集智能生成的统一配置弹窗。用户选择知识库、配置数据量和生成模型,由 AI 自动生成评测数据集。支持两种场景模式(dataset 新建数据集、data 追加数据) +- **被以下模块引用**: 评测数据集详情页(DataListModals)、评测任务创建页(CreateModal)、对话数据集选择器(EvaluationDatasetSelector) +- **关键接口**: + +### Props + +| Props | 类型 | 必填 | 说明 | +|-------|------|------|------| +| isOpen | boolean | 是 | 弹窗开关 | +| onClose | () => void | 是 | 关闭回调 | +| onConfirm | (data: IntelligentGenerationForm, datasetId?: string) => void | 是 | 确认回调,返回表单数据和可选的数据集ID | +| defaultValues | Partial\ | 否 | 默认表单值 | +| scene | 'dataset' \| 'data' | 否 | 场景模式:dataset=新建数据集(需填名称),data=追加数据(无需名称)。默认 'dataset' | +| returnDatasetId | boolean | 否 | 是否在 onConfirm 中返回数据集ID | + +### IntelligentGenerationForm 表单数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| name | string | 数据集名称 | +| generationModel | string | 生成模型ID | +| dataAmount | number | 数据量 | +| selectedDatasets | SelectedDatasetType[] | 已选知识库列表 | +| keywords | string? | 关键词(数据场景) | +| collectionId | string? | 已有数据集ID(追加数据场景) | + +### 使用示例 + +在数据集详情页追加数据时: +- `scene='data'`:不显示名称输入框 +- `defaultValues={{ collectionId }}`:传递已有数据集ID +- 确认后 `onConfirm(data)` 刷新数据列表 + +在评测任务创建时: +- `scene='dataset'`:显示名称输入框 +- `returnDatasetId=true`:onConfirm 同时返回 datasetId +- 创建完成后自动选中新数据集 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..0fc03be0b115 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,42 @@ +--- +capability_label: "评测数据集首页" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "评测数据集" +roles: ["团队成员"] +router_paths: ["/dashboard/evaluation/dataset"] +--- + +# 评测数据集首页 — 组件列表 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dataset/index.tsx` | 评测数据集列表主页面,提供数据集查看、搜索、创建、重命名、删除等管理功能 | 内部 | 否 | Tab: React.ReactNode(父布局提供的 Tab 导航) | + +## 业务组件 (pageComponents/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx` | 智能生成数据集配置弹窗,支持选择知识库、配置数据量和生成模型 | 被 EvaluationDatasetSelector、DataListModals、CreateModal 等引用 | 是 → [共享组件清单](../共享组件清单.md) | isOpen: boolean, onClose: () => void, onConfirm: (data, datasetId?) => void | +| `projects/app/src/pageComponents/dashboard/evaluation/dataset/errorModal.tsx` | 数据集异常任务详情弹窗,展示失败任务列表,支持重试和删除 | 内部 | 否 | isOpen: boolean, onClose: (isUpdateList: boolean) => void, collectionId: string | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| MyBox | @fastgpt/web/components/common/MyBox | 共享组件库 | +| MyIcon / MyIconButton | @fastgpt/web/components/common/Icon | 共享组件库 | +| MyMenu | @fastgpt/web/components/common/MyMenu | 共享组件库 | +| MyTag | @fastgpt/web/components/common/Tag/index | 共享组件库 | +| MyTooltip | @fastgpt/web/components/common/MyTooltip | 共享组件库 | +| MyModal | @fastgpt/web/components/common/MyModal | 共享组件库 | +| EmptyTip | @fastgpt/web/components/common/EmptyTip | 共享组件库 | +| UserBox | @fastgpt/web/components/common/UserBox | 共享组件库 | +| FormLabel | @fastgpt/web/components/common/MyBox/FormLabel | 共享组件库 | +| MyNumberInput | @fastgpt/web/components/common/Input/NumberInput | 共享组件库 | +| Avatar | @fastgpt/web/components/common/Avatar | 共享组件库 | +| AIModelSelector | @/components/Select/AIModelSelector | 模型选择器 | +| DatasetSelectModal | @/components/core/app/sfDatasetSelectModal | 数据集选择器 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..8ddd810015a9 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" @@ -0,0 +1,113 @@ +--- +capability_label: 评测维度 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: + - 团队管理员 + - 团队成员(有评测权限) +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — API索引 + +> 评测维度为分组节点,本文档汇总所有子能力使用的 API。API 定义统一位于 `projects/app/src/web/core/evaluation/dimension.ts`。 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/list` | POST | 获取评估维度分页列表 | `src/web/core/evaluation/dimension.ts:22` → `pages/.../dimension/index.tsx:46` | 评测→评测维度Tab→加载时调用;维度列表渲染 | + +## 详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/detail` | GET | 获取单个维度详情 | `src/web/core/evaluation/dimension.ts:30` → `pages/.../dimension/edit.tsx:36` | 评测→评测维度→编辑维度→加载时调用;获取维度数据填充表单 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/create` | POST | 创建自定义评测维度 | `src/web/core/evaluation/dimension.ts:38` → `pages/.../dimension/create.tsx:48` | 评测→评测维度→创建维度→提交表单时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/update` | PUT | 更新自定义评测维度 | `src/web/core/evaluation/dimension.ts:49` → `pages/.../dimension/edit.tsx:98` | 评测→评测维度→编辑维度→保存修改时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/delete` | DELETE | 删除自定义评测维度 | `src/web/core/evaluation/dimension.ts:62` → `pages/.../dimension/index.tsx:93` | 评测→评测维度Tab→确认删除弹窗→确认时调用 | + +## 调试/试运行 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/debug` | POST | 试运行维度评分 | `src/web/core/evaluation/dimension.ts:70` → `pageComponents/.../dimension/TestRun.tsx:122` | 评测→评测维度→创建/编辑维度→试运行弹窗→点击开始运行时调用 | + +## API 调用链追踪 + +### `/core/evaluation/metric/list` 调用链 + +``` +EvaluationDimensions 组件 + ├── 触发: 评测首页加载,evaluationTab=dimensions + ├── 参数: ListMetricsBody(分页/筛选,当前传入空对象 {}) + └── 响应处理: 解构 data.list 存入 allDimensions state → useMemo 处理内置维度国际化 → useMemo 前端搜索过滤 → 渲染表格 +``` + +### `/core/evaluation/metric/detail` 调用链 + +``` +DimensionEdit 组件 + ├── 触发: 页面加载,通过 router.query.id 获取 dimensionId + ├── 参数: { metricId: string } + └── 响应处理: 数据填充到 dimensionData state → EditForm 的 defaultValues → 表单渲染 +``` + +### `/core/evaluation/metric/create` 调用链 + +``` +DimensionCreate 组件 + ├── 触发: 用户点击提交按钮(form="evaluation-dimension-form"),通过 EditForm 的 onSubmit 回调触发 + ├── 参数: { name, description, prompt } + │ 校验: name 非空、prompt 非空(前端校验) + └── 响应处理: 成功 → toast 提示 "创建成功" → 路由跳转回 /dashboard/evaluation?evaluationTab=dimensions +``` + +### `/core/evaluation/metric/update` 调用链 + +``` +DimensionEdit 组件 + ├── 触发: 用户点击保存按钮(form="evaluation-dimension-form"),通过 EditForm 的 onSubmit 回调触发 + ├── 参数: { metricId, name, description, prompt } + └── 响应处理: 成功 → toast 提示 "更新成功" → 路由跳转回 /dashboard/evaluation?evaluationTab=dimensions +``` + +### `/core/evaluation/metric/delete` 调用链 + +``` +EvaluationDimensions 组件 + ├── 触发: 用户点击维度行删除图标 → useConfirm 弹出确认框 → 用户确认 + ├── 参数: metricId(作为 URL query 或 body) + └── 响应处理: 成功 → toast 提示 "删除成功" → 重新调用 getMetricList 刷新列表 + 失败 → toast 提示 "删除失败" +``` + +### `/core/evaluation/metric/debug` 调用链 + +``` +TestRun 组件 + ├── 触发: 用户点击"开始运行"按钮 + ├── 前置校验: prompt 非空、已选择模型、问题/参考答案/实际答案均已填写 + ├── 参数: { evalCase: { userInput, expectedOutput, actualOutput }, llmConfig: { modelId }, metricConfig: { metricName, metricType, prompt } } + └── 响应处理: 成功 → 显示 score(百分比)、feedback(评分理由);失败 → 显示错误提示 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..82eb550928bd --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/API\347\264\242\345\274\225.md" @@ -0,0 +1,42 @@ +--- +capability_label: 创建维度 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 评测维度 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/dimension/create"] +--- + +# 创建维度 — API索引 + +## 维度创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/create` | POST | 创建评估维度 | `projects/app/src/web/core/evaluation/dimension.ts:38` → `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx:48` | 评测→评测维度→创建维度→点击"确认创建"提交时调用 | + +### `/core/evaluation/metric/create` 调用链 + +``` +DimensionCreate (create.tsx:46) + ├── 触发: 用户点击"确认创建"按钮 + ├── 参数: { name: 维度名称, description: 维度描述, prompt: 评估提示词 } + └── 响应处理: 成功 → toast "维度创建成功" + router.push 跳转评测首页;失败 → 按钮恢复可用 +``` + +## 维度调试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/debug` | POST | 试运行评估维度 | `projects/app/src/web/core/evaluation/dimension.ts:70` → `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx:122` | 评测→评测维度→创建维度→试运行弹窗→点击"运行"按钮时调用 | + +### `/core/evaluation/metric/debug` 调用链 + +``` +TestRun (TestRun.tsx:86) + ├── 触发: 用户在试运行弹窗中点击"开始运行"按钮 + ├── 参数: { evalCase: { 用户输入, 期望输出, 实际输出 }, llmConfig: { modelId }, metricConfig: { metricName, metricType, prompt } } + ├── 前置校验: prompt 为空 → toast "提示词不能为空";modelId 未选择 → toast "请选择模型" + └── 响应处理: 成功 → setTestResult({ score, status:'success', feedback: 评分反馈 });失败 → setTestResult({ score:0, status:'error', feedback: 错误信息 }) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..23e7b7cb6090 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\345\210\233\345\273\272\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,45 @@ +--- +capability_label: 创建维度 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: 评测维度 +roles: ["admin", "team_member"] +router_paths: ["/dashboard/evaluation/dimension/create"] +--- + +# 创建维度 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` | 创建维度页面入口,组合 DashboardContainer 布局、EditForm、TestRun、提交和试运行按钮 | 引用 DashboardContainer(工作台基础设施)、EditForm、TestRun | 否 | 无(页面入口组件) | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/EditForm.tsx` | 评估维度编辑表单(名称、描述、提示词输入),被创建和编辑页面共用 | 引用 CitationTemplate 弹窗 | 是 → 被创建维度、编辑维度页面引用 | `defaultValues: Partial`, `onSubmit: (data) => void` | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/CitationTemplate.tsx` | 引用模板弹窗,展示 8 种内置评估维度模板供用户选择填充 | 引用 MyModal(公共组件)、evaluationTemplates 常量 | 否 | `isOpen: boolean`, `onClose: () => void`, `onConfirm: (dimension, template) => void` | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` | 试运行弹窗,输入测试用例并调用 AI 评分接口展示评估结果 | 引用 MyModal、AnswerTextarea、AIModelSelector、MyTag(均为公共组件) | 是 → 被创建维度、编辑维度页面引用 | `isOpen: boolean`, `onClose: () => void`, `formData: { name, description, prompt }` | + +## 常量/配置文件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | 说明 | +|---------|------|-----------|---------|------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/constants/evaluationTemplates.ts` | 8 种内置评估维度模板的中英文定义,及维度列表常量 | 内部 | 是 → 被 CitationTemplate 引用 | DIMENSION_LIST、DimensionType、CHINESE_DIMENSION_TEMPLATES、ENGLISH_DIMENSION_TEMPLATES、getDimensionTemplates() | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台基础设施 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `MyTextarea` | `@/components/common/Textarea/MyTextarea` | 公共组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 公共组件库 | +| `AnswerTextarea` | `@/components/common/Textarea/AnswerTextarea` | 公共组件库 | +| `AIModelSelector` | (公共组件库) | 公共组件库 | +| `MyTag` | `@fastgpt/web/components/common/MyTag` | 公共组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..a3b65978c572 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,52 @@ +--- +capability_label: 评测维度 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测 +roles: + - 团队管理员 + - 团队成员(有评测权限) +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 组件列表 + +> 评测维度为分组节点,下表列出所有子能力使用的组件。所有维度相关页面组件位于 `projects/app/src/pages/dashboard/evaluation/dimension/`,业务组件位于 `projects/app/src/pageComponents/dashboard/evaluation/dimension/`。 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` | 评测维度列表页,作为评测首页 Tab 渲染,接收 `Tab` prop | 引用自 web → MyBox, MyIcon, MyIconButton, MyTag, Avatar, EmptyTip, useConfirm, useRequest | 否(被评测首页引用) | +| `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` | 创建自定义维度的独立页面 | 引用自 web → MyBox, MyIcon, useToast, useRequest;引用自 pageComponents → EditForm, TestRun, DashboardContainer | 否 | +| `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` | 编辑自定义维度的独立页面,通过 query.id 加载数据 | 引用自 web → MyBox, MyIcon, Loading, useToast, useRequest;引用自 pageComponents → EditForm, TestRun, DashboardContainer | 否 | + +## 业务组件 (pageComponents/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/EditForm.tsx` | 维度编辑/创建表单,含名称、描述、提示词三个字段,实时校验,内置模板引用入口 | 引用自 web → FormLabel, MyTextarea;引用自 pageComponents → CitationTemplate;引用自 react-hook-form → useForm | 是(被 create 和 edit 页面共用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` | 试运行弹窗,输入问题/参考答案/实际回答,选择评测模型后运行评分 | 引用自 web → MyModal, MyTag, useToast;引用自 core → postDebugMetric, AIModelSelector, useSystemStore;引用自 pageComponents → AnswerTextarea | 是(被 create 和 edit 页面共用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/CitationTemplate.tsx` | 内置维度模板引用弹窗,左侧选择维度类型,右侧展示和编辑模板内容 | 引用自 web → MyModal;引用自 pageComponents → DIMENSION_LIST, getDimensionTemplates | 否(仅被 EditForm 使用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/AnswerTextarea.tsx` | 试运行弹窗中的带标签文本域组件,用于输入参考答案和实际回答 | 引用自 chakra-ui → Textarea, FormLabel | 否(仅被 TestRun 使用) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 共享组件库 | +| `MyIcon` / `MyIconButton` | `@fastgpt/web/components/common/Icon` | web 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 共享组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | web 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 共享组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 共享组件库 | +| `Loading` | `@fastgpt/web/components/common/MyLoading` | web 共享组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | web 共享组件库 | +| `MyTextarea` | `@/components/common/Textarea/MyTextarea` | app 公共组件 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | app 公共组件 | +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | app 工作台布局 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | web 共享组件库(评测首页使用) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..be8e1e149239 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/API\347\264\242\345\274\225.md" @@ -0,0 +1,96 @@ +--- +capability_label: 编辑维度 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +# 编辑维度 — API索引 + +> 编辑维度页面的全部 API 调用均定义于 `projects/app/src/web/core/evaluation/dimension.ts`。 + +## API 总览表 + +### 详情查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/detail` | GET | 获取维度详情数据,填充编辑表单 | `projects/app/src/web/core/evaluation/dimension.ts:30` → `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx:36` | 评测→评测维度→编辑维度→页面加载时→根据 URL 参数 `id` 获取维度数据→`onSuccess` 填充表单默认值 | + +### 更新操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/update` | PUT | 提交编辑后的维度信息 | `projects/app/src/web/core/evaluation/dimension.ts:49` → `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx:98` | 评测→评测维度→编辑维度→表单验证通过→点击"保存"按钮→提交更新→成功跳转回评测首页 | + +### 调试运行 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/debug` | POST | 试运行维度评分,验证评估效果 | `projects/app/src/web/core/evaluation/dimension.ts:70` → `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx:122` | 评测→评测维度→编辑维度→试运行弹窗→填写测试用例并选择模型→点击"开始运行"→调用评测调试接口→展示评分和反馈 | + +## API 调用链追踪 + +### `/core/evaluation/metric/detail` 调用链 + +``` +DimensionEdit 页面组件 (edit.tsx) + ├── 触发: 页面加载,useEffect 通过 router.query.id 获取 dimensionId (edit.tsx:60-73) + ├── 调用: fetchDimensionData(id) → useRequest 封装的异步函数 (edit.tsx:34-57) + │ └── 调用 getMetricDetail(id) → GET /core/evaluation/metric/detail?metricId={id} (dimension.ts:30-31) + ├── 参数: { metricId: string } + ├── 前置条件: dimensionId 存在(从 URL query 参数获取) + └── 响应处理: + ├── 成功 → setDimensionData({ name, description, prompt }) → EditForm 接收 defaultValues 渲染表单 + └── 失败 → toast 错误提示 + router.push 跳回评测首页 +``` + +### `/core/evaluation/metric/update` 调用链 + +``` +DimensionEdit 页面组件 (edit.tsx) + ├── 触发: 用户点击"保存"按钮,按钮的 form="evaluation-dimension-form" 触发表单提交 (edit.tsx:168-176) + ├── 调用链: EditForm onSubmit → DimensionEdit.onSubmit (edit.tsx:117) → updateDimension(data) (edit.tsx:94-115) + │ └── 调用 putUpdateMetric({ metricId, name, description, prompt }) → PUT /core/evaluation/metric/update (dimension.ts:49-55) + ├── 参数: { metricId: string, name: string, description: string, prompt: string } + ├── 前置校验: dimensionId 非空(edit.tsx:96); EditForm 内部校验 name、prompt 非空后 isFormValid=true 才启用按钮 + └── 响应处理: + ├── 成功 → toast "更新成功" + router.push /dashboard/evaluation?evaluationTab=dimensions + └── 失败 → useRequest 默认错误处理(toast 显示错误信息) +``` + +### `/core/evaluation/metric/debug` 调用链 + +``` +DimensionEdit 页面组件 (edit.tsx) + └── 传递 currentFormData 和 isOpen/onClose 给 TestRun 子组件 (edit.tsx:180) + +TestRun 试运行弹窗组件 (TestRun.tsx) + ├── 触发: 用户在试运行弹窗中填写问题/参考答案/实际答案,选择模型后点击"开始运行"按钮 (TestRun.tsx:258) + ├── 调用: handleStartRun → useCallback 异步函数 (TestRun.tsx:86-143) + │ └── 调用 postDebugMetric({ evalCase, llmConfig, metricConfig }) → POST /core/evaluation/metric/debug (dimension.ts:70-75) + ├── 参数: + │ ├── evalCase: { userInput: question, expectedOutput: referenceAnswer, actualOutput: actualAnswer } + │ ├── llmConfig: { modelId: selectedModel } + │ └── metricConfig: { metricName: formData.name, metricType: 'custom', prompt: formData.prompt } + ├── 前置校验: + │ ├── formData.prompt 为空 → toast "提示词不能为空",提前返回 + │ ├── selectedModel 未选择 → toast "请选择模型",提前返回 + │ └── 按钮 disabled 条件(前端 UI 层面): question/referenceAnswer/actualAnswer/selectedModel 任一为空 + └── 响应处理: + ├── 成功 → setTestResult({ score, status:'success', feedback: reason 或 "暂无反馈" }) + │ 界面展示: 状态标签(绿色成功)、分数(百分比)、反馈文本 + └── 失败 → setTestResult({ score:0, status:'error', feedback: 错误信息 }) + 界面展示: 状态标签(红色失败)、错误详情 +``` + +## 分组规则 + +| 分组 | 包含 API | 划分依据 | +|------|---------|---------| +| 详情查询 | `getMetricDetail` (GET `/core/evaluation/metric/detail`) | 页面初始化阶段,读取现有维度数据用于表单回填 | +| 更新操作 | `putUpdateMetric` (PUT `/core/evaluation/metric/update`) | 用户编辑完成后的数据持久化操作,为编辑维度的核心动作 | +| 调试运行 | `postDebugMetric` (POST `/core/evaluation/metric/debug`) | 辅助性操作,在保存前验证评估 Prompt 效果,非必需流程但属于编辑页面的完整能力 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..65463e7ffc6b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,21 @@ +--- +capability_label: 编辑维度 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +## 说明 + +本模块未检测到 Hooks/Composables/Utils/Constants 目录或目录中无导出函数。 + +模块的状态管理逻辑依赖于: +- `useRequest`(`@fastgpt/web/hooks/useRequest`)— 管理异步请求状态 +- `useToast`(`@fastgpt/web/hooks/useToast`)— 消息提示 +- `useSystemStore`(`@/web/common/system/useSystemStore`)— 系统配置和模型列表 +- React 内置 hooks(`useState`、`useEffect`、`useCallback`、`useMemo`) + +组件信息参见 [组件列表](./组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..e279bf3a9e1e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,22 @@ +--- +capability_label: 编辑维度 +doc_type: "14" +doc_label: Store数据流 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用 `useSystemStore`(`@/web/common/system/useSystemStore`)获取 LLM 模型列表,供 TestRun 组件中的 AIModelSelector 使用 +- 使用 `useUserStore`(`@/web/support/user/useUserStore`)获取用户信息和权限 +- 编辑表单状态通过 `useForm`(react-hook-form)本地管理 +- 网络请求状态通过 `useRequest` Hook 管理 +- 页面级状态(isFormValid、isTestRunOpen、dimensionData 等)通过 `useState` 本地管理 + +组件信息参见 [组件列表](./组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..4701e6386adb --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,18 @@ +--- +capability_label: 编辑维度 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无共享目录)。 + +EditForm 和 TestRun 组件在父模块"评测维度"内部被 create.tsx 复用,但不构成跨模块的外部共享。 + +- 内部组件列表见 [组件列表](./组件列表.md) +- 跨模块依赖摘要见 [模块概览](../模块概览.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..63f24cf8806d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\347\274\226\350\276\221\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,54 @@ +--- +capability_label: 编辑维度 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 评测维度 +roles: [hasEvaluationCreatePer] +router_paths: [/dashboard/evaluation/dimension/edit] +--- + +# 编辑维度 — 组件列表 + +> 编辑维度页面位于 `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx`,通过路由 query.id 加载已有维度数据进行编辑。业务组件位于 `projects/app/src/pageComponents/dashboard/evaluation/dimension/`。 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` | 编辑维度页面入口,加载已有维度数据、编排 EditForm 表单和 TestRun 试运行 | 引用自 web → MyBox, MyIcon, Loading, useToast, useRequest;引用自 pageComponents → EditForm, TestRun, DashboardContainer | 否 | + +## 业务组件 (pageComponents/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/EditForm.tsx` | 评测维度编辑表单,含 name/description/prompt 三个字段,实时校验,内置 CitationTemplate 入口 | 引用自 web → FormLabel;引用自 app 公共 → MyTextarea;引用自 pageComponents → CitationTemplate;引用自 react-hook-form → useForm | 是(被 create.tsx 和 edit.tsx 共用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` | 试运行弹窗,左侧输入问题/参考答案/实际回答,右侧选择评测模型后运行评分并展示结果 | 引用自 web → MyModal, MyTag, useToast;引用自 global → EvalDatasetDataKeyEnum, EvalModelConfigType, MetricConfig, getErrText;引用自 app 公共 → AIModelSelector;引用自 app → postDebugMetric, useSystemStore;引用自 pageComponents → AnswerTextarea | 是(被 create.tsx 和 edit.tsx 共用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/CitationTemplate.tsx` | 内置维度模板引用弹窗,左侧选择维度类型,右侧展示和编辑模板内容,确认后回填到 prompt 字段 | 引用自 web → MyModal;引用自 pageComponents → DIMENSION_LIST, getDimensionTemplates;引用自 react-hook-form → useForm | 否(仅被 EditForm 使用) | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/AnswerTextarea.tsx` | 可折叠展开的答案输入框,自动高度,展开/折叠切换;用于 TestRun 中参考答案和实际回答输入 | 引用自 chakra-ui → Textarea, FormLabel;引用自 web → MyIconButton | 否(仅被 TestRun 使用) | + +## 消费的外部组件(核心) + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台(app) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 共享组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag/index` | web 共享组件库 | +| `Loading` | `@fastgpt/web/components/common/MyLoading` | web 共享组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | web 共享组件库 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | app 公共组件 | +| `MyTextarea` | `@/components/common/Textarea/MyTextarea` | app 公共组件 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | web 共享组件库 | +| `useToast` | `@fastgpt/web/hooks/useToast` | web hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | web hooks | + +## 关键 Props 摘要 + +| 组件 | 关键 Prop(1-2 个) | +|------|-------------------| +| `EditForm` | `defaultValues?: Partial`, `onSubmit: (data: EvaluationDimensionForm) => void` | +| `TestRun` | `formData?: {name, description?, prompt}`, `isOpen: boolean` | +| `CitationTemplate` | `onConfirm?: (dimension: string, template: string) => void`, `isOpen: boolean` | +| `AnswerTextarea` | `value: string`, `onChange: (value: string) => void` | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a7094126982c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,53 @@ +--- +capability_label: "评测维度首页" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测维度" +roles: [] +router_paths: + - "/dashboard/evaluation?evaluationTab=dimensions" +--- + +# 评测维度首页 — API索引 + +## 维度查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/list` | POST | 获取评测维度列表 | `@/web/core/evaluation/dimension.ts:23` → `pages/.../dimension/index.tsx:46` | 评测维度首页→查看维度列表→页面加载时自动调用;评测维度首页→删除维度成功后→刷新列表时调用 | + +## 维度删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/delete` | DELETE | 删除自定义评测维度 | `@/web/core/evaluation/dimension.ts:63` → `pages/.../dimension/index.tsx:93` | 评测维度首页→删除自定义维度→确认弹窗确认后调用 | + +## API 调用链追踪 + +### `POST /core/evaluation/metric/list` 调用链 + +``` +EvaluationDimensions(页面组件) + ├── 触发: 页面加载时自动调用(useRequest manual: false) + ├── 参数: {}(空对象,无筛选条件) + ├── 响应处理: data.list → setAllDimensions;内置维度通过 getBuiltinDimensionNameFromId + getBuiltinDimensionInfo 取 i18n key → t() 翻译 + ├── 加载状态: isLoading → MyBox 显示遮罩 + └── 错误处理: useRequest 内置错误 Toast + +EvaluationDimensions(同一组件) + ├── 触发: 删除维度成功后回调 onSuccess + ├── 参数: {}(同上) + └── 响应处理: 同首次加载,静默刷新列表 +``` + +### `DELETE /core/evaluation/metric/delete` 调用链 + +``` +EvaluationDimensions(页面组件) + ├── 触发: 用户在删除确认弹窗中点击「确认」 + ├── 参数: { metricId: string }(被删除维度的 _id) + ├── 响应处理: 成功 → Toast「删除成功」+ 自动调用 getMetricList 刷新列表 + ├── 错误处理: 失败 → Toast「删除失败」 + └── 前置条件: 仅自定义维度(type === custom_metric)显示删除按钮 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..724915c34a7e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\347\273\264\345\272\246/\350\257\204\346\265\213\347\273\264\345\272\246\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,40 @@ +--- +capability_label: "评测维度首页" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: "评测维度" +roles: [] +router_paths: + - "/dashboard/evaluation?evaluationTab=dimensions" +--- + +# 评测维度首页 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` | 评测维度列表页面,展示维度表格、搜索和新建入口 | 被 `projects/app/src/pages/dashboard/evaluation/index.tsx`(评测工作台)引用 | 是 → 被评测工作台父页面消费 | `Tab: React.ReactNode`(父组件传入的导航 Tab 组件) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 公共组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | 公共组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 公共组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `Table / Thead / Tbody / Tr / Th / Td / TableContainer` | `@chakra-ui/react` | Chakra UI 库 | +| `Box / Flex / Button / HStack / Text / Input / InputGroup / InputLeftElement` | `@chakra-ui/react` | Chakra UI 库 | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 管理 `getMetricList` 和 `deleteMetric` 的异步请求状态(loading、错误提示、成功回调) | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | 生成删除确认弹窗(`openConfirm` + `ConfirmModal`) | +| `useRouter` | `next/router` | 页面路由跳转(新建、编辑维度) | +| `useTranslation` | `next-i18next` | 国际化翻译函数 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..2945d4f48f4d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,25 @@ +--- +capability_label: 评测首页 +doc_type: "12" +doc_label: "API索引" +generated_at: 2026-06-18T10:30:56Z +parent_module: 评测 +roles: ["所有登录用户"] +router_paths: ["/dashboard/evaluation"] +--- + +# 评测首页 — API索引 + +## 说明 + +本模块(评测首页)为 Tab 容器页面,本身不直接发起 HTTP 请求。页面仅负责: +- 权限校验(检查 feConfigs.show_evaluation 系统配置) +- Tab 路由切换(URL query 参数 evaluationTab) + +所有实际的 API 调用分布在3个子 Tab 中: + +| Tab | API 文件位置 | 详细文档 | +|-----|-------------|---------| +| 评测任务 | projects/app/src/web/core/evaluation/task.ts | [评测任务 API索引](../../../../技术知识库/工作台/评测/评测首页/评测任务/API索引.md) | +| 评测数据集 | projects/app/src/web/core/evaluation/dataset.ts | [评测数据集 API索引](../../../../技术知识库/工作台/评测/评测首页/评测数据集/API索引.md) | +| 评测维度 | projects/app/src/web/core/evaluation/dimension.ts | [评测维度 API索引](../../../../技术知识库/工作台/评测/评测首页/评测维度/API索引.md) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..dd0ceeb2bf8d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,34 @@ +--- +capability_label: 评测首页 +doc_type: "13" +doc_label: "组件列表" +generated_at: 2026-06-18T10:31:00.000Z +parent_module: 评测 +roles: ["所有登录用户"] +router_paths: ["/dashboard/evaluation"] +--- + +# 评测首页 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| projects/app/src/pages/dashboard/evaluation/index.tsx | 评测中心首页,Tab导航与子组件条件渲染 | 引用自公共组件库 → DashboardContainer, FillRowTabs | 否 | 无(从 router.query 获取 evaluationTab) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| DashboardContainer | @/pageComponents/dashboard/Container | 工作台公共布局 | +| FillRowTabs | @fastgpt/web/components/common/Tabs/FillRowTabs | 公共组件库 | + +## 子模块组件 + +本模块通过 Tab 条件渲染以下3个子模块组件,各自的完整组件清单见子目录文档: + +| Tab | 入口组件 | 详细文档 | +|-----|---------|---------| +| 评测任务 | projects/app/src/pages/dashboard/evaluation/task/index.tsx | [评测任务 组件列表](./评测任务/组件列表.md) | +| 评测数据集 | projects/app/src/pages/dashboard/evaluation/dataset/index.tsx | [评测数据集 组件列表](./评测数据集/组件列表.md) | +| 评测维度 | projects/app/src/pages/dashboard/evaluation/dimension/index.tsx | [评测维度 组件列表](./评测维度/组件列表.md) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f9003ef1ecfb --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/API\347\264\242\345\274\225.md" @@ -0,0 +1,176 @@ +--- +capability_label: 评测任务 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — API索引 + +## 任务管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/create` | POST | 创建评测任务 | `src/web/core/evaluation/task.ts:52` → `src/pageComponents/dashboard/evaluation/task/CreateModal.tsx:335` | 评测任务→创建新任务弹窗→点击确认提交时调用 | +| `/core/evaluation/task/list` | POST | 获取评测任务列表 | `src/web/core/evaluation/task.ts:84` → `src/pages/dashboard/evaluation/task/index.tsx:60` | 评测任务→任务列表→加载/搜索/筛选/分页时调用;创建任务弹窗→获取最近使用的数据集时调用 | +| `/core/evaluation/task/detail` | GET | 获取评测任务详情 | `src/web/core/evaluation/task.ts:76` → `src/web/core/evaluation/context/taskPageContext.tsx:429` | 评测任务→任务详情页→初始化加载和轮询刷新时调用 | +| `/core/evaluation/task/update` | PUT | 更新评测任务(重命名) | `src/web/core/evaluation/task.ts:68` → `src/pages/dashboard/evaluation/task/index.tsx:123` | 评测任务→任务列表→操作菜单→重命名→确认时调用 | +| `/core/evaluation/task/delete` | DELETE | 删除评测任务 | `src/web/core/evaluation/task.ts:60` → `src/pages/dashboard/evaluation/task/index.tsx:138` | 评测任务→任务列表→操作菜单→删除→确认删除时调用 | +| `/core/evaluation/task/start` | POST | 启动评测任务 | `src/web/core/evaluation/task.ts:92` | 后端自动触发(任务创建后 autoStart 为 true 时),前端无直接调用 | +| `/core/evaluation/task/stop` | POST | 停止评测任务 | `src/web/core/evaluation/task.ts:100` | 管理员操作(前端预留能力) | +| `/core/evaluation/task/stats` | GET | 获取评测统计信息 | `src/web/core/evaluation/task.ts:108` → `src/web/core/evaluation/context/taskPageContext.tsx:231` | 评测任务→任务详情页→加载/轮询刷新统计时调用 | +| `/core/evaluation/task/retryFailed` | POST | 重试失败评测项 | `src/web/core/evaluation/task.ts:116` → 多处 | 评测任务→任务列表→操作菜单→重试错误数据时调用;评测任务→任务详情页→错误数据标签→重试按钮时调用 | + +### `/core/evaluation/task/create` 调用链 + +``` +CreateModal 组件 + ├── 触发: 用户填写创建表单后点击确认 + ├── 参数: { name, evalDatasetCollectionId, target: { type, config: { appId, versionId } }, evaluators: [{ metric, runtimeConfig }] } + └── 响应处理: 创建成功后关闭弹窗,刷新任务列表 +``` + +### `/core/evaluation/task/list` 调用链 + +``` +任务列表页 (EvaluationTasks) + ├── 触发: 页面加载、搜索关键字变化(500ms 防抖)、应用筛选变化、分页切换 + ├── 参数: { pageNum, pageSize, searchKey?, appId? } + └── 响应处理: 更新表格数据、分页信息、总数 + +CreateModal 组件 + ├── 触发: 选择应用版本后获取该应用最近使用的任务 + ├── 参数: { pageNum: 1, pageSize: 1, appId } + └── 响应处理: 自动填入最近使用的数据集 +``` + +### `/core/evaluation/task/detail` 调用链 + +``` +TaskPageContext (loadTaskDetail) + ├── 触发: 任务详情页初始化加载 + ├── 参数: { evalId } + └── 响应处理: 存入 taskDetail 状态,用于页面渲染 + +TaskPageContext (runLoadEvaluationDetail / 轮询) + ├── 触发: 每 15 秒自动轮询 + ├── 参数: { evalId } + └── 响应处理: 静默更新 evaluationDetail 状态 +``` + +### `/core/evaluation/task/stats` 调用链 + +``` +TaskPageContext (runLoadStats / 轮询) + ├── 触发: 详情页初始化加载 + 每 15 秒轮询 + ├── 参数: { evalId } + └── 响应处理: 更新进度统计(total/completed/error/belowThreshold),用于 NavBar 标签计数和进度展示;删除/重试/编辑操作后手动刷新 +``` + +### `/core/evaluation/task/retryFailed` 调用链 + +``` +任务列表页 (EvaluationTasks) + ├── 触发: 点击任务操作菜单→重试错误数据 + ├── 参数: { evalId } + └── 响应处理: 刷新任务列表 + +TaskPageContext (retryFailedItems) + ├── 触发: 详情页错误数据标签→重试按钮 + ├── 参数: { evalId } + └── 响应处理: 刷新统计和详情数据 +``` + +--- + +## 评测项管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/task/item/list` | POST | 获取评测项列表 | `src/web/core/evaluation/task.ts:150` → `src/pages/dashboard/evaluation/task/detail/index.tsx:197` | 评测任务→任务详情→数据列表→加载/滚动分页/轮询刷新时调用 | +| `/core/evaluation/task/item/detail` | GET | 获取评测项详情 | `src/web/core/evaluation/task.ts:142` | 评测项详情查看 | +| `/core/evaluation/task/item/update` | PUT | 更新评测项 | `src/web/core/evaluation/task.ts:134` → `src/web/core/evaluation/context/taskPageContext.tsx:330` | 评测任务→任务详情→编辑数据→保存时调用 | +| `/core/evaluation/task/item/delete` | DELETE | 删除评测项 | `src/web/core/evaluation/task.ts:126` → `src/web/core/evaluation/context/taskPageContext.tsx:314` | 评测任务→任务详情→选中数据→删除时调用 | +| `/core/evaluation/task/item/retry` | POST | 重试评测项 | `src/web/core/evaluation/task.ts:158` → `src/web/core/evaluation/context/taskPageContext.tsx:321` | 评测任务→任务详情→选中数据→刷新时调用 | +| `/api/core/evaluation/task/item/export` | POST | 导出评测数据 | `src/web/core/evaluation/context/taskPageContext.tsx:396` | 评测任务→任务详情→导出按钮→确认时调用 | + +### `/core/evaluation/task/item/list` 调用链 + +``` +任务详情页 (Detail) + ├── 触发: 首次加载、滚动到底部自动加载更多、15 秒轮询刷新 + ├── 参数: { evalId, offset, pageSize: 20, userInput? (搜索), status? (标签筛选), belowThreshold? (标签筛选) } + └── 响应处理: 更新 evaluationItems 列表;搜索时重置选中索引 +``` + +### `/core/evaluation/task/item/update` 调用链 + +``` +任务详情页 (Detail → updateItem) + ├── 触发: 编辑数据后点击保存 + ├── 参数: { evalItemId, userInput, expectedOutput, modifyDataset } + └── 响应处理: 成功后刷新数据列表,退出编辑模式 +``` + +### `/api/core/evaluation/task/item/export` 调用链 + +``` +任务详情页 (Detail → exportItems) + ├── 触发: 点击导出按钮 + ├── 参数: { evalId, filters, headers, metricColumns, statusLabelMap } + └── 响应处理: 触发文件下载,保存为 CSV;文件名格式 evaluation_{任务名}_{日期}.csv +``` + +--- + +## 评测总结 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/summary/detail` | GET | 获取评测总结报告 | `src/web/core/evaluation/task.ts:168` → `src/web/core/evaluation/context/taskPageContext.tsx:259` | 评测任务→任务详情→初始化加载和轮询刷新时调用 | +| `/core/evaluation/summary/config/detail` | GET | 获取评分配置详情 | `src/web/core/evaluation/task.ts:184` → `src/pageComponents/dashboard/evaluation/task/detail/ConfigParams.tsx:90` | 评测任务→任务详情→配置参数→弹窗打开时调用 | +| `/core/evaluation/summary/config/update` | POST | 更新评分配置 | `src/web/core/evaluation/task.ts:176` → `src/pageComponents/dashboard/evaluation/task/detail/ConfigParams.tsx:213` | 评测任务→任务详情→配置参数弹窗→确认时调用 | +| `/core/evaluation/summary/create` | POST | 生成总结报告 | `src/web/core/evaluation/task.ts:192` → `src/web/core/evaluation/context/taskPageContext.tsx:347` | 评测任务→任务详情→刷新评分按钮;评测总结卡片→重试生成时调用 | + +### `/core/evaluation/summary/detail` 调用链 + +``` +TaskPageContext (loadSummary / 轮询) + ├── 触发: 详情页初始化加载 + 每 15 秒轮询 + ├── 参数: { evalId } + └── 响应处理: 更新 summaryData 状态,驱动右侧评分仪表盘和总结卡片渲染 +``` + +### `/core/evaluation/summary/config/update` 调用链 + +``` +ConfigParams 弹窗 + ├── 触发: 用户配置分数聚合方式、各维度阈值和权重后点击确认 + ├── 参数: { evalId, calculateType, metricsConfig: [{ metricId, thresholdValue, weight }] } + └── 响应处理: 配置保存成功后关闭弹窗,刷新总结数据 +``` + +### `/core/evaluation/summary/create` 调用链 + +``` +TaskPageContext (generateSummary / generateSummaryForMetrics) + ├── 触发: 详情页刷新评分按钮 / 总结卡片重试链接 + ├── 参数: { evalId, metricIds: [...] } + └── 响应处理: 异步生成,后续轮询自动获取结果 +``` + +--- + +## API 分组汇总 + +| 分组 | API 数量 | 说明 | +|------|---------|------| +| 任务创建与管理 | 6 | 创建、列表、详情、更新、删除、启动/停止 | +| 统计数据 | 1 | 任务进度统计(总数/完成/错误/低于阈值) | +| 批量操作 | 1 | 重试失败项 | +| 评测项管理 | 4 | 列表、详情、更新、删除 | +| 评测项操作 | 2 | 重试单条、导出 | +| 总结报告 | 4 | 查看总结、配置参数、更新配置、生成报告 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..4689ecd9bc02 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,134 @@ +--- +capability_label: 评测任务 +doc_type: "14" +doc_label: "Store数据流" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — Store数据流 + +## 状态管理概览 + +评测任务模块使用两种状态管理模式: + +- **列表页**:使用本地 `useState` + `usePagination` Hook 管理任务列表的分页、搜索和筛选状态,无需独立的 Context +- **详情页**:使用 `TaskPageContext`(基于 React Context + `use-context-selector`)集中管理详情页的全部数据和操作状态 + +## TaskPageContext(详情页核心状态) + +**文件位置**: `src/web/core/evaluation/context/taskPageContext.tsx` + +### 状态字段 + +| 字段 | 类型 | 初始值 | 说明 | +|------|------|--------|------| +| `taskId` | `string` | Props 传入 | 当前查看的任务 ID | +| `taskDetail` | `EvaluationDisplayType` | 空对象 | 任务基本详情(名称/创建时间/状态等) | +| `statsData` | `EvaluationStatsResponse \| null` | `null` | 任务进度统计(总数/完成/错误/低于阈值) | +| `summaryData` | `EvaluationSummaryResponse \| null` | `null` | 评测总结数据(各维度得分/综合分/总结文本) | +| `evaluationDetail` | `EvaluationDisplayType \| null` | `null` | 任务详细数据(含 metricNames、evaluators、target 等) | +| `evaluationItems` | `EvaluationItemDisplayType[]` | `[]` | 评测数据项列表 | +| `appDetail` | `AppDetailType \| null` | `null` | 被评测应用的详细信息 | +| `searchValue` | `string` | `''` | 数据列表搜索关键字 | +| `totalItems` | `number` | `0` | 数据项总数 | +| `filterParams` | `Record` | `{}` | 数据列表过滤参数 | +| `loading` | `LoadingState` | 全 `false` | 各数据源的加载状态(stats/summary/detail/items/taskDetail) | +| `errors` | `ErrorState` | 全 `null` | 各数据源的错误信息 | + +### 数据加载流程 + +``` +TaskPageContextProvider 挂载 + │ + ├── 1. Detail 组件 useRequest 触发 loadTaskDetail(taskId) + │ ├── GET /core/evaluation/task/detail → taskDetail + │ └── 成功后调用 loadAllData(taskDetailData) + │ + ├── 2. loadAllData: + │ ├── 设置 evaluationDetail(由传入数据或 delay 加载) + │ ├── 并行: loadStats() | loadEvaluationDetail() + │ │ ├── GET /core/evaluation/task/stats → statsData + │ │ └── GET /core/evaluation/task/detail → evaluationDetail [轮询 15s] + │ ├── 串行: loadSummary() + │ │ └── GET /core/evaluation/summary/detail → summaryData [轮询 15s] + │ └── 串行: runLoadAppDetail(targetAppId) + │ └── GET app detail → appDetail + │ + └── 3. 轮询(15 秒间隔): + ├── stats 自动轮询 + ├── summary 自动轮询 + ├── detail 自动轮询 + └── 数据列表智能刷新(重新加载已浏览页面的数据) +``` + +### 轮询机制 + +- **轮询间隔**:15 秒 +- **静默更新**:非首次加载时不显示 loading 遮罩 +- **自动启停**:首次成功加载后自动开启;组件卸载时自动清除所有轮询 +- **数据列表刷新**:`smartRefreshData` 智能刷新——仅重新加载到当前已浏览位置的数据,而非全量重新加载 + +### 数据操作流程 + +``` +操作: 删除数据项 + └── deleteItem(itemId) + ├── DELETE /core/evaluation/task/item/delete + └── 并行刷新: refreshStats() + refreshEvaluationItems() + +操作: 重试数据项 + └── retryItem(itemId) + ├── POST /core/evaluation/task/item/retry + └── 并行刷新: refreshStats() + refreshEvaluationItems() + +操作: 更新数据项 + └── updateItem(itemId, data) + ├── PUT /core/evaluation/task/item/update + └── 并行刷新: refreshStats() + refreshEvaluationItems() + +操作: 重试所有失败项 + └── retryFailedItems() + ├── POST /core/evaluation/task/retryFailed + └── 并行刷新: refreshStats() + refreshEvaluationItems() + +操作: 生成总结 + └── generateSummary() + ├── 从 summaryData 提取所有 metricIds + └── generateSummaryForMetrics({ evalId, metricIds }) + ├── POST /core/evaluation/summary/create + └── loadSummary() 刷新总结数据 + +操作: 导出数据 + └── exportItems(filters) + ├── 收集维度列名(从 evaluationDetail.metricNames + summaryData) + ├── 构建 CSV headers(中文表头 + 维度列) + └── POST /api/core/evaluation/task/item/export → 文件下载 +``` + +## 列表页状态管理 + +列表页不使用 Context,而是使用 `usePagination` Hook 管理状态: + +``` +EvaluationTasks 组件状态: +├── searchValue / localSearchValue: 搜索关键字(双状态:即时显示 + 防抖实际搜索) +├── appFilter: 应用筛选 ID +├── isCreateModalOpen: 创建弹窗开关 +├── usePagination: 分页/数据加载 +│ ├── data (tasks): 当前页任务数据 +│ ├── Pagination: 分页器组件 +│ ├── getData (fetchData): 刷新数据 +│ ├── isLoading: 加载状态 +│ └── total: 总数据量 +├── useConfirm: 删除确认弹窗 +└── useEditTitle: 重命名弹窗 +``` + +### 列表页数据刷新触发条件 + +- `searchValue` 变化(500ms 防抖延迟) +- `appFilter` 变化 +- `fetchData()` 手动调用(创建/删除/重命名/重试成功后) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..b65292c24485 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\344\273\273\345\212\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,56 @@ +--- +capability_label: 评测任务 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:30:00Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: [/dashboard/evaluation?evaluationTab=tasks, /dashboard/evaluation/task/detail?taskId={id}] +--- + +# 评测任务 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/dashboard/evaluation/task/index.tsx` | 评测任务列表页,展示所有评测任务表格 | 内部 | 否 | `Tab: ReactNode`(接收父级标签组件) | +| `src/pages/dashboard/evaluation/task/detail/index.tsx` | 评测任务详情页,展示任务数据列表和评分结果 | 内部 | 否 | `taskId: string, currentTab: TabEnum` | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dashboard/evaluation/task/CreateModal.tsx` | 创建评测任务弹窗表单 | 被数据集列表、Agent 页面、智能客服表单引用 | 是 | `isOpen: boolean, onClose: () => void, onSubmit: (data) => void` | +| `src/pageComponents/dashboard/evaluation/task/AppSelectWithAll.tsx` | 评测任务的应用筛选下拉框,含全部应用选项 | 内部 | 否 | `value: string, onSelect: (id) => void, showAllOption?: boolean` | +| `src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx` | 评测维度管理弹窗,选择和管理内置/自定义维度 | 内部 | 否 | `isOpen, onClose, selectedDimensions, onConfirm` | +| `src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx` | 任务详情页顶部导航栏,含数据过滤标签和操作按钮 | 内部 | 否 | `currentTab: TabEnum, statsData, onExport?, onRetryFailed?` | +| `src/pageComponents/dashboard/evaluation/task/detail/ScoreBar.tsx` | 维度评分进度条/柱状图组件 | 内部 | 否 | 维度得分数据 | +| `src/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard.tsx` | 评分仪表盘,展示综合得分和各维度详情 | 内部 | 否 | 总结数据和维度配置 | +| `src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx` | 渐变边框容器卡片组件 | 内部 | 否 | `children: ReactNode` | +| `src/pageComponents/dashboard/evaluation/task/detail/EvaluationSummaryCard.tsx` | 评测总结卡片,展示 AI 生成的维度总结 | 内部 | 否 | `data: EvaluationSummaryResponse['data'], selectedIndex?, onRetryGeneration?` | +| `src/pageComponents/dashboard/evaluation/task/detail/BasicInfo.tsx` | 任务基本信息和进度展示组件 | 内部 | 否 | 任务详情数据和统计信息 | +| `src/pageComponents/dashboard/evaluation/task/detail/ConfigParams.tsx` | 评分参数配置弹窗(聚合方式/阈值/权重) | 内部 | 否 | `isOpen, onClose, onConfirm?, evalTaskId: string` | +| `src/pageComponents/dashboard/evaluation/task/detail/DetailedResponseModal.tsx` | 完整 AI 响应查看弹窗 | 内部 | 否 | `isOpen, onClose, aiChatItemDataId` | + +## Context/Provider 组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/web/core/evaluation/context/taskPageContext.tsx` | 任务详情页上下文,管理状态加载/轮询/数据操作 | 内部 | 否 | `children, taskId: string` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 通用组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 通用组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 通用组件库 | +| `MyIcon` / `MyIconButton` | `@fastgpt/web/components/common/Icon` | 通用组件库 | +| `FillRowTabs` / `LightRowTabs` | `@fastgpt/web/components/common/Tabs` | 通用组件库 | +| `AppSelect` | `@/components/Select/AppSelect` | 应用管理 | +| `SelectOneResource` | `@/components/common/folder/SelectOneResource` | 文件/资源管理 | +| `IntelligentGeneration` | `@/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration` | 评测数据集 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 通用组件库 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | 通用组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..fdcd65c18191 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,144 @@ +--- +capability_label: "评测数据集" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:31:09Z" +parent_module: "评测首页" +roles: ["团队成员"] +router_paths: + - "/dashboard/evaluation?evaluationTab=datasets" + - "/dashboard/evaluation/dataset/detail" + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 评测数据集 — API索引 + +> API 定义文件:`projects/app/src/web/core/evaluation/dataset.ts` + +## 数据集集合管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/list` | POST | 分页查询数据集列表 | `web/core/evaluation/dataset.ts:37` → `pages/dashboard/evaluation/dataset/index.tsx:98` | 评测数据集→列表页→加载时调用;评测数据集→列表页→搜索/翻页时调用 | +| `/core/evaluation/dataset/collection/listv2` | POST | 查询数据集列表(轻量版) | `web/core/evaluation/dataset.ts:44` | 评测任务创建→选择数据集→加载时调用;对话评价数据集选择器→加载时调用 | +| `/core/evaluation/dataset/collection/create` | POST | 创建新数据集 | `web/core/evaluation/dataset.ts:33` → `pages/dashboard/evaluation/dataset/fileImport.tsx:128` | 评测数据集→文件导入→提交前自动创建数据集 | +| `/core/evaluation/dataset/collection/update` | POST | 更新数据集名称 | `web/core/evaluation/dataset.ts:52` → `pages/dashboard/evaluation/dataset/index.tsx:139`;`pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` | 评测数据集→列表页→重命名时调用;数据集详情→设置弹窗→修改名称时调用 | +| `/core/evaluation/dataset/collection/delete` | DELETE | 删除数据集 | `web/core/evaluation/dataset.ts:48` → `pages/dashboard/evaluation/dataset/index.tsx:186` | 评测数据集→列表页→确认删除时调用 | +| `/core/evaluation/dataset/collection/detail` | GET | 获取数据集详情 | `web/core/evaluation/dataset.ts:111` → `pageComponents/dashboard/evaluation/dataset/detail` | 数据集详情→页面初始化时调用 | +| `/core/evaluation/dataset/collection/qualityAssessmentBatch` | POST | 批量质量评估 | `web/core/evaluation/dataset.ts:91` | 数据集详情→数据列表→触发批量质量评测时调用 | + +## 数据集数据管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/data/list` | POST | 分页查询数据集内的数据条目 | `web/core/evaluation/dataset.ts:64` → `pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` | 数据集详情→数据列表→加载时调用;数据集详情→数据列表→搜索/筛选/翻页/滚动时调用 | +| `/core/evaluation/dataset/data/create` | POST | 手动新增数据条目 | `web/core/evaluation/dataset.ts:68` | 数据集详情→手动新增→提交时调用 | +| `/core/evaluation/dataset/data/smartGenerate` | POST | 智能生成数据集数据 | `web/core/evaluation/dataset.ts:29` → `pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx:141`;`pageComponents/dashboard/evaluation/task/CreateModal.tsx`;`pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx`;`components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx` | 评测数据集→列表页→智能生成弹窗→提交时调用;评测任务创建→新建/导入数据集→智能生成时调用;数据集详情→追加数据→智能生成时调用;对话中→加入评测数据集→创建数据集时调用 | +| `/core/evaluation/dataset/data/create/smartGenerate` | POST | 从知识库智能生成追加数据 | `web/core/evaluation/dataset.ts:72` | 数据集详情→追加数据→智能生成追加时调用 | +| `/core/evaluation/dataset/data/create/localFile` | POST | 从文件导入追加数据 | `web/core/evaluation/dataset.ts:76` | 数据集详情→追加数据→文件导入追加时调用 | +| `/core/evaluation/dataset/data/update` | POST | 编辑数据条目 | `web/core/evaluation/dataset.ts:80` | 数据集详情→编辑数据→保存时调用 | +| `/core/evaluation/dataset/data/delete` | DELETE | 删除数据条目 | `web/core/evaluation/dataset.ts:84` | 数据集详情→数据列表→删除数据时调用 | +| `/core/evaluation/dataset/data/qualityAssessment` | POST | 单项数据质量评估 | `web/core/evaluation/dataset.ts:88` | 数据集详情→数据列表→对单条数据发起质量评测时调用 | +| `/core/evaluation/dataset/data/import` | POST | 文件导入数据 | `web/core/evaluation/dataset.ts:154` → `pages/dashboard/evaluation/dataset/fileImport.tsx:135` | 评测数据集→文件导入页→提交确认时调用 | +| `/core/evaluation/dataset/data/detail` | GET | 获取数据条目详情 | `web/core/evaluation/dataset.ts:117` | 数据集详情→查看数据详情时调用 | +| `/core/evaluation/dataset/data/fileId` | POST | 通过文件ID导入数据 | `web/core/evaluation/dataset.ts:55` | 数据集详情→通过已上传文件导入数据时调用 | + +## 异常任务管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/dataset/collection/failedTasks` | POST | 获取数据集失败任务列表 | `web/core/evaluation/dataset.ts:99` → `pageComponents/dashboard/evaluation/dataset/errorModal.tsx:48` | 评测数据集→列表页→异常详情弹窗→打开时调用 | +| `/core/evaluation/dataset/collection/retryTask` | POST | 重试单个失败任务 | `web/core/evaluation/dataset.ts:103` → `pageComponents/dashboard/evaluation/dataset/errorModal.tsx:80` | 评测数据集→异常详情弹窗→点击单任务"重试"时调用 | +| `/core/evaluation/dataset/collection/deleteTask` | POST | 删除单个失败任务 | `web/core/evaluation/dataset.ts:107` → `pageComponents/dashboard/evaluation/dataset/errorModal.tsx:91` | 评测数据集→异常详情弹窗→点击单任务"删除"时调用 | +| `/core/evaluation/dataset/collection/retryAllTask` | POST | 批量重试所有失败任务 | `web/core/evaluation/dataset.ts:121` → `pageComponents/dashboard/evaluation/dataset/errorModal.tsx:102` | 评测数据集→异常详情弹窗→点击"全部重试"时调用 | + +--- + +## API 调用链追踪 + +### `POST /core/evaluation/dataset/collection/list` 调用链 + +``` +EvaluationDatasets (列表页) + ├── 触发: 页面加载 / 搜索防抖触发 / 翻页 + ├── 参数: { pageNum, pageSize, searchKey } + ├── 并行: 无,独立请求 + └── 响应处理: 更新 Pagination hook 的 data 和 total,渲染表格行 + +用途: 分页获取数据集列表,支持名称搜索 +依赖页: 评测首页 (评价Tab) 中的 评测数据集 Tab +``` + +### `POST /core/evaluation/dataset/data/smartGenerate` 调用链 + +``` +IntelligentGeneration (智能生成弹窗) + ├── 触发: 用户填写表单后点击"确认" + ├── 参数: { name, count, kbDatasetIds, intelligentGenerationModelId } + ├── 并行: 无,独立请求 + └── 响应处理: 返回 collectionId,关闭弹窗,通知父组件刷新列表 + +CreateModal (评测任务创建弹窗) + ├── 触发: 创建任务时新建/导入数据集 + ├── 参数: 同上(可能含 collectionId 追加模式) + └── 响应处理: 返回 collectionId,更新任务创建表单的数据集选择 + +EvaluationDatasetSelector (对话中评价数据集选择器) + ├── 触发: 对话中评价结果时自动选择或创建数据集 + ├── 参数: 同上 + └── 响应处理: 返回 collectionId 用于关联数据集 +``` + +### `DELETE /core/evaluation/dataset/collection/delete` 调用链 + +``` +EvaluationDatasets (列表页) + ├── 触发: 用户在确认弹窗中点击"删除" + ├── 参数: { collectionId } + ├── 前置: 弹出确认对话框"确认删除该数据集?" + ├── 并行: 无,独立请求 + └── 响应处理: 成功则 toast "删除成功",刷新列表;失败则 toast "删除数据集异常" +``` + +### `POST /core/evaluation/dataset/data/import` 调用链 + +``` +FileImport (文件导入页) + ├── 触发: 用户选择文件后点击"确认" + ├── 参数: FormData { file[], data: { name?, collectionId, enableQualityEvaluation, evaluationModelId? } } + ├── 前置可能: 如无 collectionId 则先调用 POST /core/evaluation/dataset/collection/create + ├── 并行: 无,串行(创建数据集→上传文件) + ├── 超时: 600000ms (10分钟) + ├── 进度: 通过 onUploadProgress 回调实时更新百分比 + └── 响应处理: 成功则跳转到对应页面(列表或详情),失败则 toast 错误信息 +``` + +### `POST /core/evaluation/dataset/collection/failedTasks` 调用链 + +``` +ErrorModal (异常详情弹窗) + ├── 触发: 弹窗打开时自动发起 + ├── 参数: { collectionId } + ├── 并行: 无,独立请求 + └── 响应处理: 更新 errorList,渲染失败任务表格(知识库、分块、错误信息、操作列) +``` + +### `POST /core/evaluation/dataset/collection/retryTask` 调用链 + +``` +ErrorModal (异常详情弹窗) + ├── 触发: 用户点击单个失败任务的"重试"按钮 + ├── 参数: { jobId, collectionId } + ├── 并行: 与 deleteTask 互斥(同时只能执行一个操作) + └── 响应处理: 成功则 toast "重试成功",刷新失败任务列表 +``` + +### `POST /core/evaluation/dataset/collection/retryAllTask` 调用链 + +``` +ErrorModal (异常详情弹窗) + ├── 触发: 用户点击"全部重试"按钮 + ├── 参数: { collectionId } + ├── 禁用条件: 无失败任务或正在执行其他操作时按钮不可用 + └── 响应处理: 成功则 toast "重试成功",刷新失败任务列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..6c1e00ce34de --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,68 @@ +--- +capability_label: "评测数据集" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:31:09Z" +parent_module: "评测首页" +roles: ["团队成员"] +router_paths: + - "/dashboard/evaluation?evaluationTab=datasets" + - "/dashboard/evaluation/dataset/detail" + - "/dashboard/evaluation/dataset/fileImport" +--- + +# 评测数据集 — 组件列表 + +## pages/dashboard/evaluation/dataset/ (页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/dashboard/evaluation/dataset/index.tsx` | 数据集列表页,展示分页表格,支持搜索、新建、重命名、删除 | 引用自 @fastgpt/web、@fastgpt/global、@/web/core/evaluation/dataset | 否 | `Tab: React.ReactNode`(父级 Tab 组件) | +| `pages/dashboard/evaluation/dataset/fileImport.tsx` | 文件导入页,下载模板、上传 CSV 文件批量创建数据集 | 引用自 @fastgpt/web、@/web/core/evaluation/dataset | 否 | 无外部 Props(路由页面) | +| `pages/dashboard/evaluation/dataset/detail/index.tsx` | 数据集详情页容器,组合 NavBar 和 DataList | 引用自 detail 子目录各组件 | 否 | 无外部 Props(路由页面) | + +## pageComponents/dashboard/evaluation/dataset/ (数据集弹窗组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx` | 智能生成数据集弹窗,配置知识库、数据量、生成模型 | 引用自 @fastgpt/web、@/web/core/evaluation/dataset;被评测任务创建、数据集详情、对话评价选择器引用 | 是(跨模块共享) | `isOpen, onClose, onConfirm, defaultValues?, scene?, returnDatasetId?` | +| `pageComponents/dashboard/evaluation/dataset/errorModal.tsx` | 异常详情弹窗,查看和重试/删除生成失败的任务 | 引用自 @fastgpt/web、@/web/core/evaluation/dataset | 否 | `isOpen, onClose: (isUpdateList: boolean) => void, collectionId` | + +## pageComponents/dashboard/evaluation/dataset/detail/ (数据集详情子组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/evaluation/dataset/detail/NavBar.tsx` | 详情页导航栏,显示面包屑路径 | 引用自 DatasetDetailPageContext、FolderPath | 否(被评测任务详情复用) | 无(通过 Context 获取数据) | +| `pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx` | 数据列表组件,展示数据集内数据条目的分页/滚动列表 | 引用自 DataListContext、DataListModals、@/web/core/evaluation/dataset | 否 | `collectionId: string`(通过 Context 提供) | +| `pageComponents/dashboard/evaluation/dataset/detail/DataListContext.tsx` | 数据列表状态上下文,管理选中项、弹窗开关、列表数据 | 内部引用 | 否 | `collectionId: string`(Provider prop) | +| `pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx` | 数据列表弹窗集合,统一管理编辑、质量评测、智能生成、手动新增、设置弹窗 | 引用自 EditDataModal、ManuallyAddModal、ModifyEvaluationModal 等 | 否 | 无外部 Props(通过 DataListContext 获取状态) | +| `pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx` | 编辑数据条目弹窗,修改问题和参考答案 | 引用自 @/web/core/evaluation/dataset | 否 | 通过 DataListContext 控制 | +| `pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx` | 手动新增数据弹窗,输入问题和参考答案 | 引用自 @/web/core/evaluation/dataset | 否 | 通过 DataListContext 控制 | +| `pageComponents/dashboard/evaluation/dataset/detail/ModifyEvaluationModal.tsx` | 修改评测结果弹窗,人工调整评测分数和反馈 | 引用自 @fastgpt/global(EvaluationStatus) | 否 | 通过 DataListContext 控制 | +| `pageComponents/dashboard/evaluation/dataset/detail/QualitySelect.tsx` | 质量等级选择器,下拉选择数据质量(高/待优化/异常) | 引用自 @fastgpt/global(EvalDatasetDataQualityStatusEnum) | 否 | `value, onChange` | +| `pageComponents/dashboard/evaluation/dataset/detail/StatusSelect.tsx` | 评测状态筛选器,下拉选择数据评测状态 | 引用自 const.ts(EvaluationStatus) | 否 | `value, onChange` | + +## web/core/evaluation/ (公共上下文与API) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `web/core/evaluation/context/datasetDetailPageContext.tsx` | 数据集详情页上下文,提供 collectionId 和面包屑路径 | 被 detail/NavBar 和 detail page 引用 | 否 | `collectionId, collectionName`(Provider props) | +| `web/core/evaluation/dataset.ts` | 评测数据集 API 层,封装全部数据集相关的 HTTP 请求 | 被 11+ 个组件和页面引用 | 是(API 模块) | 导出 20+ 个 API 函数 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `MyIcon` / `MyIconButton` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 公共组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 公共组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `UserBox` | `@fastgpt/web/components/common/UserBox` | 公共组件库 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 公共组件库 | +| `FolderPath` | `@/components/common/folder/Path` | 公共组件库 | +| `AIModelSelector` | `@/components/common/AIModelSelector` | 公共组件库 | +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台公共 | +| `DatasetSelectModal` | 知识库选择器(IntelligentGeneration 内部引用) | 知识库模块 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..716355ceb244 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/API\347\264\242\345\274\225.md" @@ -0,0 +1,134 @@ +--- +capability_label: 评测维度 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — API 索引 + +## API 定义文件 + +本模块的 API 函数定义在 `projects/app/src/web/core/evaluation/dimension.ts`,使用统一的 HTTP 请求封装(`GET`/`POST`/`PUT`/`DELETE` 来自 `@/web/common/api/request`)。 + +--- + +## 维度查询与列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/list` | POST | 获取评估维度列表 | `dimension.ts:22` → `dimension/index.tsx:46`
`dimension.ts:22` → `task/ManageDimension.tsx:254` | 评测维度→维度列表→Tab 激活加载时调用;评测任务→管理维度弹窗→打开弹窗时调用 | + +## 维度详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/detail` | GET | 获取单个维度详情 | `dimension.ts:30` → `dimension/edit.tsx:36` | 评测维度→编辑维度→页面加载时调用(根据路由 query.id 获取维度数据回填表单) | + +## 维度创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/create` | POST | 创建自定义评估维度 | `dimension.ts:38` → `dimension/create.tsx:48` | 评测维度→创建维度→提交创建表单时调用 | + +## 维度更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/update` | PUT | 更新自定义评估维度 | `dimension.ts:49` → `dimension/edit.tsx:98` | 评测维度→编辑维度→保存修改时调用 | + +## 维度删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/delete` | DELETE | 删除自定义评估维度 | `dimension.ts:62` → `dimension/index.tsx:93` | 评测维度→维度列表→删除确认弹窗确认后调用 | + +## 维度调试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/evaluation/metric/debug` | POST | 自定义维度试运行 | `dimension.ts:70` → `dimension/TestRun.tsx:122` | 评测维度→创建/编辑维度→测试运行弹窗→点击开始运行时调用 | + +--- + +## API 调用链追踪 + +### `POST /core/evaluation/metric/list` 调用链 + +``` +EvaluationDimensions (dimension/index.tsx) + ├── 触发: 切换到评测维度 Tab 后自动加载;搜索框值变化不触发 + ├── 参数: {}(空对象,当前版本无筛选参数) + └── 响应处理: setAllDimensions(data.list || []) 存储全量数据;processedDimensions 通过 useMemo 将内置维度的名称和描述转为国际化版本;filteredDimensions 在此基础上做前端搜索过滤 + +ManageDimension (task/ManageDimension.tsx) + ├── 触发: 管理维度弹窗打开(isOpen 变为 true) + ├── 参数: {}(空对象) + └── 响应处理: 将 metric 转为 Dimension 类型,合并已选中的维度,同步到表单字段数组 +``` + +### `GET /core/evaluation/metric/detail` 调用链 + +``` +DimensionEdit (dimension/edit.tsx) + ├── 触发: 编辑页面加载,dimensionId 从 router.query.id 解析 + ├── 参数: { metricId: dimensionId } + └── 响应处理: setDimensionData({ name, description, prompt }) 用于 EditForm 的 defaultValues 回填;失败时 Toast 错误提示后返回列表页 +``` + +### `POST /core/evaluation/metric/create` 调用链 + +``` +DimensionCreate (dimension/create.tsx) + ├── 触发: 用户点击「确认创建」按钮,name 和 prompt 前端校验通过 + ├── 参数: { name, description, prompt } + └── 响应处理: 成功 → Toast 成功提示 → router.push 跳转到维度列表;失败 → 停留在创建页 +``` + +### `PUT /core/evaluation/metric/update` 调用链 + +``` +DimensionEdit (dimension/edit.tsx) + ├── 触发: 用户点击「保存」按钮 + ├── 参数: { metricId, name, description, prompt } + └── 响应处理: 成功 → Toast 成功提示 → router.push 跳转到维度列表;失败 → 停留在编辑页 +``` + +### `DELETE /core/evaluation/metric/delete` 调用链 + +``` +EvaluationDimensions (dimension/index.tsx) + ├── 触发: 用户在确认弹窗点击确认后 + ├── 参数: { metricId } + └── 响应处理: 成功 → fetchAllDimensions() 重新加载列表;失败 → Toast 错误提示 +``` + +### `POST /core/evaluation/metric/debug` 调用链 + +``` +TestRun (dimension/TestRun.tsx) + ├── 触发: 用户在测试运行弹窗中点击「开始运行」按钮,表单校验通过 + ├── 参数: { evalCase: { userInput, expectedOutput, actualOutput }, llmConfig: { modelId }, metricConfig: { metricName, metricType: "custom", prompt } } + └── 响应处理: 成功 → setTestResult({ score, status: "success", feedback });展示分数(百分比)+ 反馈文本;失败 → setTestResult({ status: "error", feedback: 错误信息 }) +``` + +--- + +## 请求参数类型定义(摘要) + +API 入参和出参类型定义在 `@fastgpt/global/core/evaluation/metric/api` 和 `@fastgpt/global/core/evaluation/metric/type` 中: + +| API | 请求体类型 | 响应类型 | +|-----|-----------|---------| +| list | `ListMetricsBody` | `PaginationResponse` | +| detail | `{ metricId: string }` (query) | `EvalMetricSchemaType` | +| create | `CreateMetricBody` | `Pick` | +| update | `UpdateMetricBody` | `Pick` | +| delete | `{ metricId: string }` (query) | 无特定类型 | +| debug | `DebugMetricBody` | `{ score: number; reason?: string; usages?: any[] }` | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..4f73abc929e8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,60 @@ +--- +capability_label: 评测维度 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions +--- + +# 评测维度 — Hooks工具函数 + +## API 请求函数(`web/core/evaluation/dimension.ts`) + +| 导出函数 | 用途 | 对外共享 | 使用者 | +|---------|------|---------|--------| +| `getMetricList` | 获取评估维度列表(POST 请求) | 是 | 评测维度列表页、评测任务管理维度弹窗 | +| `getMetricDetail` | 获取单个维度详情(GET 请求) | 否 | 评测维度编辑页 | +| `postCreateMetric` | 创建自定义评估维度(POST 请求) | 否 | 评测维度创建页 | +| `putUpdateMetric` | 更新自定义评估维度(PUT 请求) | 否 | 评测维度编辑页 | +| `deleteMetric` | 删除自定义评估维度(DELETE 请求) | 否 | 评测维度列表页 | +| `postDebugMetric` | 自定义维度试运行调试(POST 请求) | 否 | 测试运行弹窗 | + +## 内置维度工具函数(`web/core/evaluation/utils/index.ts`) + +| 导出函数/常量 | 用途 | 对外共享 | 使用者 | +|-------------|------|---------|--------| +| `BUILTIN_DIMENSION_MAP` | 内置维度信息映射表,包含 6 种内置维度(answer_correctness、answer_similarity、answer_relevancy、faithfulness、context_recall、context_precision)的 i18n 名称和描述 key | 是 | 评测任务模块 | +| `getBuiltinDimensionInfo` | 根据维度名称查询 BUILTIN_DIMENSION_MAP,返回对应国际化信息 | 是 | 评测任务(CreateModal、ManageDimension、ConfigParams、EvaluationSummaryCard 等) | +| `getBuiltinDimensionNameFromId` | 去除维度 ID 中的 `builtin_` 前缀,返回原始维度名称 | 是 | 评测任务模块、评测维度列表 | +| `getBuiltinDimensionIdFromName` | 为维度名称添加 `builtin_` 前缀,生成内置维度标准 ID | 是 | 评测任务模块 | +| `getBuiltinDimensionEnglishInfo` | 获取内置维度的英文名称和描述(用于下发给后台 API) | 是 | 评测任务模块 | +| `formatScoreToPercentage` | 将 0-1 的分数转换为百分比整数(Math.round(score * 100)) | 是 | 评测任务模块 | + +## 维度模板常量(`pageComponents/dashboard/evaluation/dimension/constants/evaluationTemplates.ts`) + +| 导出 | 用途 | 对外共享 | +|------|------|---------| +| `DIMENSION_LIST` | 8 种引用模板维度类型数组:correctness、conciseness、harmfulness、controversiality、creativity、criminality、depth、detail | 否(仅被 CitationTemplate 使用) | +| `DimensionType` | 维度模板类型联合类型 | 否 | +| `CHINESE_DIMENSION_TEMPLATES` | 中文评分模板内容映射(每种维度类型对应一段完整的中文评估 prompt) | 否 | +| `ENGLISH_DIMENSION_TEMPLATES` | 英文评分模板内容映射 | 否 | +| `getDimensionTemplates` | 根据语言标志返回中文或英文模板映射 | 否(仅被 CitationTemplate 使用) | + +## 使用的公共 Hooks + +本模块通过 `import` 使用以下共享 Hooks(非本模块定义): + +| Hook | 来源 | 用途 | +|------|------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 管理异步 API 请求的 loading/data/error 状态 | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | 管理删除确认弹窗 | +| `useToast` | `@fastgpt/web/hooks/useToast` | 显示操作结果 Toast 提示 | +| `useTranslation` | `next-i18next` | 获取国际化翻译函数 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取系统级状态(模型列表、功能开关) | +| `useRouter` | `next/router` | 路由跳转和参数读取 | +| `useForm` / `useFieldArray` | `react-hook-form` | 表单状态管理和校验 | +| `useDisclosure` | `@chakra-ui/react` | 管理弹窗的开/关状态 | +| `useState` / `useEffect` / `useMemo` / `useCallback` / `useRef` | `react` | React 标准 Hooks | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..60eac85cc0b4 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,23 @@ +--- +capability_label: 评测维度 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions +--- + +# 评测维度 — Store 数据流 + +## 说明 + +本模块无独立 Store(未检测到 Zustand 或 Context 状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用系统级 Store:`useSystemStore` 获取 LLM 模型列表(`llmModelList`)、Embedding 模型列表(`embeddingModelList`)和功能开关(`feConfigs.show_evaluation`) +- 通过 React `useState` 管理组件局部状态(搜索值 `searchValue`、维度数据 `allDimensions`、表单数据等) +- 通过 `react-hook-form` 的 `useForm` / `useFieldArray` 管理表单状态 +- 通过 Props 传递进行父子组件通信(如 `Tab` 组件从父页面传入) +- 使用 `@fastgpt/web/hooks/useRequest` 管理异步请求状态(loading、data、error) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..8ecbd5e8859e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,20 @@ +--- +capability_label: 评测维度 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions +--- + +# 评测维度 — 共享组件清单 + +## 说明 + +本模块未检测到对外共享的组件(无跨模块组件引用,imported_by 组件数: 0)。 + +模块内的组件(EvaluationDimensions、DimensionCreate、DimensionEdit、EditForm、TestRun、CitationTemplate、AnswerTextarea)均为模块内部使用,未被其他模块直接引用。 + +组件清单请参见 [13-组件列表](./组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..87c1e121355d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\267\245\344\275\234\345\217\260/\350\257\204\346\265\213/\350\257\204\346\265\213\351\246\226\351\241\265/\350\257\204\346\265\213\347\273\264\345\272\246/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,70 @@ +--- +capability_label: 评测维度 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 评测首页 +roles: [管理员, 团队成员] +router_paths: + - /dashboard/evaluation?evaluationTab=dimensions + - /dashboard/evaluation/dimension/create + - /dashboard/evaluation/dimension/edit +--- + +# 评测维度 — 组件列表 + +## 页面组件(pages/dashboard/evaluation/dimension/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dashboard/evaluation/dimension/index.tsx` | 评测维度列表页,展示内置和自定义维度的表格,支持搜索、创建、编辑(仅自定义)、删除(仅自定义) | 被 `evaluation/index.tsx` 按 Tab 条件渲染 | 否 | `Tab: React.ReactNode`(父页面传入的 Tab 切换栏) | +| `projects/app/src/pages/dashboard/evaluation/dimension/create.tsx` | 新建自定义维度页面,含 EditForm 表单和 TestRun 测试运行弹窗 | 引用自 dimension/EditForm、dimension/TestRun | 否 | 无外部 Props(独立路由页面) | +| `projects/app/src/pages/dashboard/evaluation/dimension/edit.tsx` | 编辑自定义维度页面,回填已有维度数据,含 TestRun 测试运行弹窗 | 引用自 dimension/EditForm、dimension/TestRun | 否 | 无外部 Props(通过路由 query.id 获取维度 ID) | + +## 业务组件(pageComponents/dashboard/evaluation/dimension/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/EditForm.tsx` | 维度名称/描述/提示词编辑表单,被 create 和 edit 页面共用;含引用模板入口 | 引用自 dimension/CitationTemplate | 否 | `defaultValues?: Partial; onSubmit: (data) => void; onValidationChange?: (isValid, formData?) => void` | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/TestRun.tsx` | 测试运行弹窗,输入示例问答数据运行维度评估调试,查看评分结果和反馈 | 引用自 dimension/AnswerTextarea | 否 | `isOpen: boolean; onClose: () => void; formData?: { name, description?, prompt }` | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/CitationTemplate.tsx` | 引用模板弹窗,提供 8 种内置维度模板(正确性/简洁性/有害性/争议性/创造性/违法性/深度/详细度)供快速填充提示词 | 引用自 dimension/constants/evaluationTemplates | 否 | `isOpen: boolean; onClose: () => void; onConfirm?: (dimension, template) => void` | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/AnswerTextarea.tsx` | 可折叠的文本输入区域组件,支持自动高度调整和展开/折叠切换 | 内部 | 否 | `value: string; onChange: (value) => void; label?: string; placeholder?: string; disabled?: boolean; maxToken?: number; required?: boolean` | + +## 常量与样式 + +| 文件路径 | 用途 | +|---------|------| +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/constants/evaluationTemplates.ts` | 定义 `DIMENSION_LIST`(8 种维度类型枚举:correctness/conciseness/harmfulness/controversiality/creativity/criminality/depth/detail)、`DimensionType` 类型和中英文评分模板内容 | +| `projects/app/src/pageComponents/dashboard/evaluation/dimension/styles.module.scss` | TestRun 组件的滚动条样式 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardContainer` | `@/pageComponents/dashboard/Container` | 工作台 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 公共组件库(通过父页面 evaluation/index.tsx 传入) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `MyIcon` / `MyIconButton` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 公共组件库 | +| `MyTextarea` | `@/components/common/Textarea/MyTextarea` | 公共组件库 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 公共组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 公共组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 公共组件库(ManageDimension 中使用) | +| `Loading` | `@fastgpt/web/components/common/MyLoading` | 公共组件库 | + +## 消费的外部 Hooks/工具 + +| 本模块使用 | 导入路径 | 源模块 | +|-----------|---------|--------| +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统 Store | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | 公共 Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 公共 Hooks | +| `useToast` | `@fastgpt/web/hooks/useToast` | 公共 Hooks | +| `useTranslation` | `next-i18next` | i18n 框架 | +| `useForm` / `useFieldArray` | `react-hook-form` | 表单管理库 | +| `useRouter` | `next/router` | Next.js 路由 | +| `getMetricList` / `deleteMetric` / `postCreateMetric` / `putUpdateMetric` / `getMetricDetail` / `postDebugMetric` | `@/web/core/evaluation/dimension` | 本模块 API | +| `getBuiltinDimensionInfo` / `getBuiltinDimensionNameFromId` | `@/web/core/evaluation/utils` | 评测通用工具 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..651c35a1671e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,50 @@ +--- +capability_label: 应用 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: [admin, collaborator, viewer] +router_paths: [/app/detail] +--- + +# 应用 — API索引 + +## 应用 CRUD 操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/list` | POST | 获取应用列表(不分页/分页) | `web/core/app/api.ts:15` → `pageComponents/dashboard/agent/List.tsx` | 工作台→应用工作台→加载时调用;工作台→工具管理→加载时调用 | +| `/core/app/detail` | GET | 获取应用详情 | `web/core/app/api.ts:45` → `pageComponents/app/detail/context.tsx:172` | 应用→应用详情→页面加载时调用 | +| `/core/app/create` | POST | 创建新应用 | `web/core/app/api.ts:34` → `pages/dashboard/create/index.tsx` | 工作台→创建应用→提交创建表单时调用 | +| `/core/app/update` | PUT | 更新应用配置 | `web/core/app/api.ts:50` → `pageComponents/app/detail/context.tsx:197` | 应用→应用详情→修改配置后保存时调用 | +| `/core/app/del` | DELETE | 删除应用 | `web/core/app/api.ts:40` → `pageComponents/app/detail/context.tsx:269` | 应用→应用详情→确认删除操作时调用 | + +## 版本管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/version/publish` | POST | 发布应用版本 | `web/core/app/api/version.ts:15` → `pageComponents/app/detail/context.tsx:241` | 应用→应用详情→编辑完成后点击发布时调用 | +| `/core/app/version/latest` | GET | 获取最新版本信息 | `web/core/app/api/version.ts:12` → `pageComponents/app/detail/context.tsx:189` | 应用→应用详情→有写权限时加载最新版本信息 | +| `/core/app/version/list` | POST | 获取版本历史列表 | `web/core/app/api/version.ts:18` | 应用→应用详情→查看版本历史 | +| `/core/app/version/detail` | GET | 获取指定版本详情 | `web/core/app/api/version.ts:21` | 应用→应用详情→查看历史版本内容 | +| `/core/app/version/update` | POST | 更新版本信息 | `web/core/app/api/version.ts:24` | 应用→应用详情→修改版本备注等 | + +## 权限与协作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/getPermission` | GET | 获取应用权限信息 | `web/core/app/api.ts:53` | 应用详情加载时查询权限 | +| `/core/app/resumeInheritPermission` | GET | 恢复继承权限 | `web/core/app/api.ts:62` | 权限异常时恢复操作 | +| `/proApi/core/app/changeOwner` | POST | 转让应用所有权 | `web/core/app/api.ts:65` | 应用→应用详情→转让所有者 | + +## 应用查询与导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/getBasicInfo` | POST | 批量获取应用基本信息 | `web/core/app/api.ts:59` | 跨模块引用时批量查询应用名称/头像 | +| `/core/app/appsByToolId` | GET | 查询引用某工具的应用列表 | `web/core/app/api.ts:67` | 工具详情→查看引用该工具的应用 | +| `/core/app/exportSkill` | POST | 导出应用为技能文件 | `web/core/app/api.ts:75` | 工作台→技能管理→从应用导出技能 | +| `/proApi/core/chat/team/getApps` | POST | 按标签获取团队应用 | `web/core/app/api.ts:36` | 对话首页→团队应用列表加载 | + +> 各 API 的完整调用链追踪(含参数说明、响应处理、错误处理)见 [应用详情 — API索引](../应用详情/API索引.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..153d0756c037 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,152 @@ +--- +capability_label: "应用详情" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — API索引 + +## 应用 CRUD + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/detail` | GET | 获取应用完整详情(含权限、模块、配置) | `web/core/app/api.ts` → `context.tsx:172` | 应用详情→页面初始化→加载时调用 | +| `/core/app/update` | PUT | 更新应用基础信息(名称、头像、简介) | `web/core/app/api.ts` → `context.tsx:198`(updateAppDetail) | 应用详情→编辑应用信息→保存时调用;应用详情→团队标签→保存时调用 | +| `/core/app/del` | DELETE | 删除应用 | `web/core/app/api.ts` → `context.tsx:269`(deleteApp) | 应用详情→Header菜单→确认删除时调用 | +| `/proApi/core/app/changeOwner` | POST | 转让应用所有者 | `web/core/app/api.ts` | 应用详情→权限配置→转让所有者时调用 | + +## 版本与发布 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/version/publish` | POST | 发布/保存应用版本 | `web/core/app/api/version.ts` → `context.tsx:241`(onSaveApp) | 应用详情→编辑器→点击保存时调用;离开页面自动保存时调用 | +| `/core/app/version/latest` | GET | 获取应用最新已发布版本 | `web/core/app/api/version.ts` → `context.tsx:190` | 应用详情→页面初始化→有写权限时自动加载 | +| `/core/app/version/list` | POST | 获取版本历史列表(分页) | `web/core/app/api/version.ts` → PublishHistoriesSlider | 应用详情→Header→版本历史→打开时调用 | +| `/core/app/version/detail` | GET | 获取指定版本详情 | `web/core/app/api/version.ts` | 应用详情→版本历史→切换版本时调用 | + +## 权限与协作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/getPermission` | GET | 获取应用权限信息 | `web/core/app/api.ts` → InfoModal | 应用详情→编辑应用信息→加载权限时调用 | +| `/core/app/resumeInheritPermission` | GET | 恢复继承父级权限 | `web/core/app/api.ts` → InfoModal | 应用详情→编辑应用信息→点击恢复继承权限时调用 | +| `/proApi/core/app/collaborator/list` | GET | 获取应用协作者列表 | `web/core/app/api/collaborator.ts` → InfoModal | 应用详情→编辑应用信息→加载协作者列表时调用 | +| `/proApi/core/app/collaborator/update` | POST | 更新协作者权限 | `web/core/app/api/collaborator.ts` → InfoModal | 应用详情→编辑应用信息→保存协作者变更时调用 | +| `/proApi/core/app/collaborator/delete` | DELETE | 删除指定协作者 | `web/core/app/api/collaborator.ts` → InfoModal | 应用详情→编辑应用信息→移除协作者时调用 | + +## OpenAPI Key 管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/list` | GET | 获取应用 API Key 列表 | `web/support/openapi/api.ts` → ApiKeyTable | 应用详情→发布→API Tab→加载时调用 | +| `/support/openapi/create` | POST | 创建新的 API Key | `web/support/openapi/api.ts` → EditKeyModal | 应用详情→发布→API Tab→新建 Key 时调用 | +| `/support/openapi/update` | PUT | 更新 API Key(名称、限额、过期时间) | `web/support/openapi/api.ts` → EditKeyModal | 应用详情→发布→API Tab→编辑 Key 时调用 | +| `/support/openapi/delete` | DELETE | 删除指定 API Key | `web/support/openapi/api.ts` → ApiKeyTable | 应用详情→发布→API Tab→删除 Key 时调用 | + +## 日志与监控 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/logs/list` | POST | 获取应用对话日志列表 | `web/core/app/api/log.ts` → Logs/LogTable | 应用详情→日志→加载时调用;分页/筛选时调用 | +| `/proApi/core/app/logs/getTotalData` | GET | 获取应用总览统计数据 | `web/core/app/api/log.ts` → Dashboard | 应用详情→看板→加载时调用 | +| `/proApi/core/app/logs/getChartData` | POST | 获取图表统计数据 | `web/core/app/api/log.ts` → Logs/LogChart | 应用详情→看板→加载图表时调用 | +| `/core/app/logs/getLogKeys` | GET | 获取日志显示字段配置 | `web/core/app/api/log.ts` → SyncLogKeysPopover | 应用详情→日志→配置显示列→打开时调用 | +| `/core/app/logs/updateLogKeys` | POST | 更新日志显示字段配置 | `web/core/app/api/log.ts` | 应用详情→日志→配置显示列→保存时调用 | +| `/core/app/logs/getUsers` | POST | 获取日志用户筛选列表 | `web/core/app/api/log.ts` → UserFilter | 应用详情→日志→用户筛选→下拉加载时调用 | +| `/core/chat/correction/list` | POST | 获取对话纠错列表 | `web/core/chat/api.ts` → CorrectionModal | 应用详情→日志→对话详情→纠错→加载时调用 | +| `/core/chat/correction/delete` | POST | 删除纠错记录 | `web/core/chat/api.ts` → CorrectionModal | 应用详情→日志→对话详情→删除纠错时调用 | +| `/core/chat/correction/submit` | POST | 提交纠错 | `web/core/chat/api.ts` → CorrectionModal | 应用详情→日志→对话详情→提交纠错时调用 | + +## MCP 工具集 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/mcpTools/update` | POST | 保存 MCP 工具集配置 | `web/core/app/api/mcpTools.ts` → MCPTools/Header | 应用详情→MCP编辑器→点击保存时调用 | +| `/core/app/mcpTools/getTools` | POST | 从 MCP Server 获取工具列表 | `web/core/app/api/mcpTools.ts` → MCPTools/EditForm | 应用详情→MCP编辑器→输入URL后获取工具时调用 | +| `/core/app/mcpTools/runTool` | POST | 测试运行 MCP 工具 | `web/core/app/api/mcpTools.ts` → MCPTools/ChatTest | 应用详情→MCP编辑器→测试区运行工具时调用 | + +## HTTP 工具集 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/httpTools/getApiSchemaByUrl` | POST | 根据 URL 获取 API Schema | `web/core/app/api/httpTools.ts` → HTTPTools/EditForm | 应用详情→HTTP编辑器→输入URL解析Schema时调用 | +| `/core/app/httpTools/update` | POST | 更新 HTTP 工具配置 | `web/core/app/api/httpTools.ts` → HTTPTools/EditForm | 应用详情→HTTP编辑器→保存配置时调用 | +| `/core/app/httpTools/runTool` | POST | 测试运行 HTTP 工具 | `web/core/app/api/httpTools.ts` → HTTPTools/ChatTest | 应用详情→HTTP编辑器→测试区运行工具时调用 | + +## 聊天测试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/chat/chatTest` | POST (SSE) | 工作流/应用调试运行(流式) | `web/core/chat/api.ts` → useChatTest.tsx | 应用详情→编辑器→ChatTest面板→发送消息时调用 | +| `/core/chat/init` | GET | 初始化聊天信息(变量、历史、生成状态) | `web/core/chat/api.ts` → useChatTest.tsx | 应用详情→编辑器→ChatTest面板→加载时自动调用 | + +## 文件与标签 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/file/presignAvatarPostUrl` | POST | 获取头像上传预签名 URL | `web/common/file/api.ts` → InfoModal | 应用详情→编辑应用信息→上传头像时调用 | +| `/proApi/support/user/team/tag/list` | GET | 获取团队所有可用标签 | `web/support/user/team/api.ts` → TagsEditModal | 应用详情→团队标签弹窗→打开时调用 | + +## `/core/app/detail` 调用链 + +``` +AppContextProvider (context.tsx) + ├── 触发: 页面挂载时自动调用(manual: false),依赖 appId 变化 + ├── 参数: appId(从 router.query 中获取) + ├── 响应处理: setAppDetail → 更新 AppContext 中的 appDetail 状态 + ├── 错误处理: toast "获取应用信息失败",router.replace('/dashboard/agent') + └── 分支: appId 为空时返回 defaultApp(空对象) + +ApiKeyTable + ├── 触发: 发布-API Tab 挂载时自动调用 + ├── 参数: appId + └── 响应处理: 获取 app 头像和名称用于 API 调用示例弹窗 +``` + +## `/core/app/version/publish` 调用链 + +``` +onSaveApp (context.tsx) + ├── 触发: Header 保存按钮 / 离开页面自动保存(autoSave) + ├── 参数: { nodes, edges, chatConfig, isPublish, versionName } + ├── 前置检查: !hasWritePer → 拒绝操作 + ├── 模型校验(仅 isOwner): 提取工作流中 modelIds,与系统模型列表比对,无效模型 toast 警告 + ├── 响应处理: setAppDetail 更新 + reloadAppLatestVersion 刷新版本 + └── 错误处理: AppErrEnum.unExist → return(静默);其他 → Promise.reject + +Plugin/Header (Plugin/Header.tsx:88) + ├── 触发: 保存按钮 + ├── 参数: flowData2StoreData() 提取画布数据 → { nodes, edges, version: 'v2' } + └── 响应处理: 同 onSaveApp + +Workflow/Header + ├── 触发: 保存按钮 + └── 参数: flowData2StoreData() 提取画布数据 +``` + +## `/core/app/del` 调用链 + +``` +onDelApp (context.tsx) + ├── 触发: Header 菜单 → 删除 → 确认弹窗(需输入应用名称) + ├── 参数: appId + ├── 确认弹窗: AI 应用提示"确认删除该应用?删除后无法恢复",工具提示"确认删除该工具?" + ├── 响应处理: 删除成功 → 清除 localStorage app_log_keys_{appId} → router.replace(/dashboard/agent | /dashboard/tool) + ├── Toast: 成功"删除成功" / 失败"删除失败" + └── 错误处理: appDetail 为空 → reject +``` + +## `/core/chat/chatTest` 调用链 + +``` +useChatTest (useChatTest.tsx) + ├── 触发: 用户在 ChatTest 面板输入消息并发送 + ├── 参数: { chatId, messages, appId, variables, ... } + ├── 前置: getInitChatInfo 获取 chatId + 历史记录 + 变量 + ├── 响应: SSE 流式返回 AI 回复 + └── 错误: 停止生成 → postStopV2Chat +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..ac56b430cb55 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,110 @@ +--- +capability_label: "应用详情" +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — Hooks工具函数 + +## 模块内 Hooks + +### useChatTest + +- **位置**: `projects/app/src/pageComponents/app/detail/useChatTest.tsx` +- **用途**: 提供编辑器内聊天测试面板的核心逻辑,管理 SSE 流式聊天交互 +- **主要导出/返回值**: + - 聊天消息状态管理(发送/接收/流式更新) + - 初始化聊天(通过 getInitChatInfo 获取 chatId + 历史记录) + - 流式请求(通过 streamFetch 发送 chatTest 请求) + - 停止生成逻辑 +- **消费方**: SimpleEdit/ChatTest, ChatAgent/ChatTest, SmartCustomerService/ChatTest, MCPTools/ChatTest, HTTPTools/ChatTest +- **外部依赖**: `@/web/core/chat/api`(getInitChatInfo, streamFetch), `useChatStore` + +### useSnapshots (useSimpleAppSnapshots) + +- **位置**: `projects/app/src/pageComponents/app/detail/Edit/FormComponent/useSnapshots.tsx` +- **用途**: 编辑器快照管理,支持表单编辑历史记录的 localStorage 持久化和版本恢复 +- **主要导出/返回值**: + - `past`: 历史快照数组 + - `setPast`: 设置快照历史 + - `saveSnapshot`: 保存快照(含标题、时间戳) + - `forbiddenSaveSnapshot`: 是否禁止保存快照 +- **消费方**: SimpleEdit/index, AgentEdit/index +- **外部依赖**: `localStorage`, `ahooks`(useDebounceEffect) + +### useSkillManager + +- **位置**: `projects/app/src/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager.tsx` +- **用途**: Agent 技能管理 Hook,处理技能的增删改和配置 +- **消费方**: AgentEdit +- **外部依赖**: `@fastgpt/global/core/agentSkill/type` + +### useSnapshots (SmartCustomerService 专用) + +- **位置**: `projects/app/src/pageComponents/app/detail/SmartCustomerService/`(内置快照 Hook) +- **用途**: 智能客服编辑器的差异快照管理(diff-based),与 SimpleEdit 的快照机制不同 +- **消费方**: SmartCustomerService/index +- **外部依赖**: `@/web/core/app/diff` + +## 工具函数 + +### form2AppWorkflow / appWorkflow2Form (SimpleApp) + +- **位置**: `projects/app/src/pageComponents/app/detail/Edit/SimpleApp/utils.ts` +- **用途**: 简单应用的表单数据 ↔ 工作流数据的双向转换 +- **导出**: + - `form2AppWorkflow(form)`: 将编辑表单转为工作流 nodes/edges/chatConfig 格式,用于保存 + - `appWorkflow2Form(nodes, chatConfig)`: 将工作流 nodes 转为编辑表单格式,用于初始化/恢复 +- **消费方**: SimpleEdit/index, SimpleEdit/ChatTest + +### agentForm2AppWorkflow / appWorkflow2AgentForm (ChatAgent) + +- **位置**: `projects/app/src/pageComponents/app/detail/Edit/ChatAgent/utils.ts` +- **用途**: Agent 应用的表单数据 ↔ 工作流数据的双向转换 +- **导出**: + - `agentForm2AppWorkflow(form)`: Agent 表单转为工作流数据 + - `appWorkflow2AgentForm(nodes, chatConfig)`: 工作流数据转为 Agent 表单 +- **消费方**: AgentEdit/index + +### v1Workflow2V2 + +- **位置**: `projects/app/src/web/core/workflow/adapt.ts`(外部工具,本模块内使用) +- **用途**: 将旧版 v1 工作流数据格式迁移到 v2 格式 +- **消费方**: SimpleEdit/index(版本兼容处理) + +### flowData2StoreData + +- **位置**: 工作流相关工具(被 Plugin/Header 等工作流编辑器使用) +- **用途**: 从 ReactFlow 画布提取 nodes 和 edges 转换为可保存的 Store 数据格式 +- **消费方**: Plugin/Header, Workflow/Header(保存时调用) + +## 模块内 Context + +### AppContext + +- **位置**: `projects/app/src/pageComponents/app/detail/context.tsx` +- **类型**: React Context(通过 `useContextSelector` 消费) +- **提供者**: `AppContextProvider` +- **提供的数据**: + - `appId: string` — 当前应用 ID + - `currentTab: TabEnum` — 当前激活 Tab + - `route2Tab: (tab) => void` — Tab 切换路由方法 + - `appDetail: AppDetailType` — 应用完整详情 + - `loadingApp: boolean` — 应用加载状态 + - `updateAppDetail: (data) => Promise` — 更新应用基础信息 + - `onSaveApp: (data) => Promise` — 保存/发布应用 + - `onDelApp: () => void` — 删除应用 + - `reloadApp: () => Promise` — 重新加载应用详情 + - `appLatestVersion` — 最新版本数据 + - `reloadAppLatestVersion: () => void` — 刷新最新版本 +- **消费方**: 所有子编辑器和 Tab 页面 + +### LogsContext + +- **位置**: `projects/app/src/pageComponents/app/detail/Logs/context.tsx` +- **用途**: 日志模块上下文,管理日期范围和来源筛选状态 +- **消费方**: Logs/LogChart, Dashboard, Logs 相关筛选组件 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..b5f0d05a6e0e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\345\272\224\347\224\250\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,125 @@ +--- +capability_label: "应用详情" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: "应用" +roles: ["Owner", "管理员(manage)", "编辑者(write)", "只读者(read)", "日志查看者(readChatLog)"] +router_paths: ["/app/detail"] +--- + +# 应用详情 — 组件列表 + +## pageComponents/app/detail/ (核心层) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `context.tsx` | 模块全局上下文,管理应用加载/保存/删除/发布/权限 | 引用自 `@/web/core/app/api`、`@/web/core/app/api/version` | 是 → 被所有子编辑器消费 | children: ReactNode | +| `RouteTab.tsx` | Tab 切换器,根据权限和类型动态生成可见 Tab 列表 | 内部 | 否 | — | +| `InfoModal.tsx` | 应用信息编辑弹窗(名称/头像/介绍/权限/协作) | 引用自 `@/components/support/permission/ConfigPerModal` | 否 | onClose: Function, hideAuthConfig: Boolean, appForm: Object | +| `TagsEditModal.tsx` | 团队标签编辑弹窗 | 引用自 `@/web/support/user/team/api` | 否 | onClose: Function | +| `constants.ts` | 共享样式常量(workflowBoxStyles, cardStyles) | 内部 | 是 → 被各编辑器复用 | — | + +## Edit/SimpleApp/ (简单应用编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Edit/SimpleApp/index.tsx` | 简单应用编辑器主入口 | 引用自 `Header`、`Edit`、`Logs`、`PublishChannel` | 否 | — | +| `Edit/SimpleApp/Edit.tsx` | 表单编辑界面(左右分栏) | 内部 | 否 | appForm: Object, setAppForm: Function, setPast: Function | +| `Edit/SimpleApp/ChatTest.tsx` | 聊天测试面板 | 引用自 `useChatTest` | 否 | appForm: Object, form2WorkflowFn: Function | +| `Edit/SimpleApp/utils.ts` | form2AppWorkflow / appWorkflow2Form 转换 | 引用自 `@/web/core/workflow/adapt` | 否 | — | + +## Edit/ChatAgent/ (对话 Agent 编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Edit/ChatAgent/index.tsx` | Agent 编辑器主入口 | 引用自 `Header`、`Edit`、`Dashboard`、`Logs`、`PublishChannel` | 否 | — | +| `Edit/ChatAgent/Edit.tsx` | Agent 编辑界面 | 内部 | 否 | appForm: Object, setAppForm: Function, setPast: Function | +| `Edit/ChatAgent/utils.ts` | agentForm2AppWorkflow 转换 | 引用自 `@fastgpt/global/core/app/formEdit/type` | 否 | — | + +## Edit/MCPTools/ (MCP 工具集编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Edit/MCPTools/index.tsx` | MCP 工具集编辑器入口 | 引用自 `web/core/app/api/mcpTools` | 否 | — | +| `Edit/MCPTools/Header.tsx` | MCP Header | 引用自面包屑 API | 否 | url: String, toolList: Array, headerSecret: String | +| `Edit/MCPTools/Edit.tsx` | MCP 编辑界面(左右分栏) | 内部 | 否 | url/setUrl: String, toolList/setToolList: Array | +| `Edit/MCPTools/EditForm.tsx` | MCP 配置表单 | 内部 | 否 | — | +| `Edit/MCPTools/AppCard.tsx` | 应用信息卡片 | 内部 | 否 | — | +| `Edit/MCPTools/ChatTest.tsx` | MCP 调试面板 | 内部 | 否 | currentTool: Object, url: String, headerSecret: String | + +## Edit/HTTPTools/ (HTTP 工具集编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Edit/HTTPTools/index.tsx` | HTTP 工具集编辑器入口 | 引用自 `web/core/app/api/httpTools` | 否 | — | +| `Edit/HTTPTools/Header.tsx` | HTTP Header | 引用自面包屑 API | 否 | — | +| `Edit/HTTPTools/Edit.tsx` | HTTP 编辑界面(含 Curl 导入) | 内部 | 否 | — | +| `Edit/HTTPTools/EditForm.tsx` | HTTP 工具配置表单 | 内部 | 否 | — | +| `Edit/HTTPTools/CurlImportModal.tsx` | Curl 命令导入弹窗 | 内部 | 否 | — | +| `Edit/HTTPTools/SchemaConfigModal.tsx` | Schema 配置弹窗 | 内部 | 否 | — | +| `Edit/HTTPTools/ManualToolModal.tsx` | 手动工具配置弹窗 | 内部 | 否 | — | +| `Edit/HTTPTools/AppCard.tsx` | 应用信息卡片 | 内部 | 否 | — | +| `Edit/HTTPTools/ChatTest.tsx` | HTTP 工具调试面板 | 内部 | 否 | — | + +## Workflow/ (工作流编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Workflow/index.tsx` | 工作流编辑器主入口 | 引用自 ReactFlow 画布 | 否 | — | +| `Workflow/Header.tsx` | 工作流 Header(含保存/版本) | 内部 | 否 | — | + +## WorkflowComponents/Flow/ (ReactFlow 画布) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `WorkflowComponents/Flow/index.tsx` | ReactFlow 画布主组件 | 引用自 `reactflow` | 否 | isCopilotOpen: Boolean, nodesInteractive: Boolean | + +## Plugin/ (插件编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Plugin/index.tsx` | 插件编辑器主入口 | 复用 WorkflowComponents/Flow | 否 | — | +| `Plugin/Header.tsx` | 插件 Header | 内部 | 否 | — | + +## SmartCustomerService/ (智能客服编辑器) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `SmartCustomerService/index.tsx` | 智能客服编辑器主入口(差异快照) | 引用自 `Header`、`Edit`、`Dashboard`、`ConversationLogs` | 否 | — | +| `SmartCustomerService/Edit.tsx` | 编辑界面(左右分栏) | 内部 | 否 | appForm: Object, setAppForm: Function, setPast: Function | + +## Edit/FormComponent/ (共享编辑组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Edit/FormComponent/Header.tsx` | 通用编辑器 Header(面包屑+保存+Tab+版本历史) | 被 SimpleEdit/AgentEdit/SmartCustomerService 共享 | 是 | appForm: Object, forbiddenSaveSnapshot: Boolean, form2WorkflowFn: Function | +| `Edit/FormComponent/useSnapshots.tsx` | 快照管理 Hook | 内部 | 否 | appId: String | +| `Edit/FormComponent/type.ts` | 表单类型定义 | 引用自 `@fastgpt/global` | 是 | — | + +## Dashboard / Logs / Publish (Tab 页面) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Dashboard/index.tsx` | 数据看板(被 Agent/Workflow/SmartCustomerService 复用) | 引用自 Logs/LogChart | 否 | — | +| `Logs/index.tsx` | 对话日志列表(含筛选器+列表) | 引用自 ConversationLogs | 否 | — | +| `Publish/index.tsx` | 发布渠道管理(8 种渠道条件渲染) | 引用自 Publish/Link, Publish/API, Publish/FeiShu 等 | 否 | — | +| `ConversationLogs/index.tsx` | 会话日志页面(SmartCustomerService 专用) | 内部 | 否 | — | +| `PublishHistoriesSlider.tsx` | 发布历史版本滑块 | 引用自版本 API | 否 | — | +| `ExportConfigPopover.tsx` | 导出应用配置 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | web 共享组件库 | +| `MyLoading` | `@fastgpt/web/components/common/MyLoading` | web 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 共享组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 共享组件库 | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限模块 | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台模块 | +| `NextHead` | `@/components/common/NextHead` | 公共组件 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | web 共享组件库 | +| `MultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | web 共享组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..ae0555344e66 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\345\272\224\347\224\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,78 @@ +--- +capability_label: 应用 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:30:00.000Z" +parent_module: null +roles: [admin, collaborator, viewer] +router_paths: [/app/detail] +--- + +# 应用 — 组件列表 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pages/app/detail/index.tsx` | 应用详情入口页,根据 appId 加载应用信息,按类型动态渲染编辑器 | 引用自 工作台(DashboardNavbar)、对话(useChatStore) | 否 | + +## 核心业务组件 (pageComponents/app/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `pageComponents/app/detail/context.tsx` | 应用详情上下文(AppContextProvider),管理应用状态、路由导航、CRUD 操作 | 引用自 工作台(各 API) | 否 | +| `pageComponents/app/detail/InfoModal.tsx` | 应用信息编辑弹窗(名称、头像、描述、权限配置) | 内部 | 否 | +| `pageComponents/app/detail/ExportConfigPopover.tsx` | 应用配置导出弹窗 | 内部 | 否 | +| `pageComponents/app/constants.ts` | 应用模块常量定义 | 内部 | 否 | + +## 应用详情子模块 (pageComponents/app/detail/) + +本模块按应用类型拆分编辑器组件,各子模块详细组件列表见子能力文档。核心结构如下: + +### Edit/ 编辑器组件 + +| 子目录 | 说明 | +|--------|------| +| `Edit/SimpleApp/` | 简易应用编辑器(单轮对话型) | +| `Edit/ChatAgent/` | Chat Agent 编辑器(含知识库、工作流编排) | +| `Workflow/` | 工作流可视化编辑器 | +| `Plugin/` | 插件工具编辑器 | +| `Edit/MCPTools/` | MCP 工具集编辑器 | +| `Edit/HTTPTools/` | HTTP 工具集编辑器 | +| `SmartCustomerService/` | 智能客服(Assistant)编辑器 | + +### 日志与看板 + +| 子目录 | 说明 | +|--------|------| +| `ConversationLogs/` | 对话日志查看组件(列表、详情、筛选、纠错) | +| `Logs/` | 应用日志查看组件 | +| `Dashboard/` | 应用数据看板组件 | + +### 共享编辑组件 (Edit/component/ 和 Edit/FormComponent/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `Edit/component/ConfigToolModal.tsx` | 工具配置弹窗 | 内部 | 否 | +| `Edit/FormComponent/Header.tsx` | 编辑页顶部导航栏 | 内部 | 否 | +| `Edit/FormComponent/AppCard.tsx` | 应用卡片展示组件 | 内部 | 否 | +| `Edit/FormComponent/ToolSelector/ToolSelect.tsx` | 工具选择器 | 内部 | 否 | +| `Edit/FormComponent/ToolSelector/SkillSelect.tsx` | 技能选择器 | 内部 | 否 | +| `Edit/FormComponent/useSnapshots.tsx` | 配置快照管理 Hook | 内部 | 否 | + +## 消费的外部组件 + +| 本模块使用组件 | 源模块 | +|--------------|--------| +| `DashboardNavbar` | 工作台(dashboard/Container) | +| `useChatStore` | 对话(web/core/chat) | +| `Loading` | 共享组件库(@fastgpt/web) | +| `NextHead` | 共享组件库(@fastgpt/web) | + +## 对外共享组件 + +| 共享组件 | 消费模块 | 用途 | +|---------|---------|------| +| `AppTree` (`ChatSetting/AppTree.tsx`) | 对话 | 应用树选择器(首页设置、收藏应用) | + +> 各组件的详细 Props、跨模块依赖关系和完整消费者列表见 [应用详情 — 组件列表](../应用详情/组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cbbcd3d2cacc --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,95 @@ +--- +capability_label: 技能详情 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: 技能 +roles: [拥有者, 协作者] +router_paths: ["/skill/detail"] +--- + +# 技能详情 — API索引 + +## 技能详情与操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/detail` | GET | 获取技能详情(含权限信息) | `src/web/core/skill/api.ts:37` → `detail/context.tsx:48` | 技能→技能详情→页面加载时调用 | +| `/core/agentSkills/update` | POST | 更新技能基本信息 | `src/web/core/skill/api.ts:44` → `detail/Header.tsx:68` | 技能→技能详情→编辑信息弹窗→确认提交时调用 | +| `/core/agentSkills/delete` | DELETE | 删除技能 | `src/web/core/skill/api.ts:51` → `detail/Header.tsx:58` | 技能→技能详情→删除确认弹窗→确认后调用 | +| `/api/core/agentSkills/export` | GET | 导出技能为 ZIP 压缩包 | `src/web/core/skill/api.ts:70` → `detail/Header.tsx:84` | 技能→技能详情→菜单→导出配置时调用(触发浏览器下载) | + +## 权限与协作者管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/agentSkill/collaborator/list` | GET | 获取协作者列表 | `src/web/core/skill/collaborator.ts:9` → `detail/Header.tsx:228` | 技能→技能详情→权限设置弹窗→加载协作者列表时调用 | +| `/proApi/core/agentSkill/collaborator/update` | POST | 更新协作者权限 | `src/web/core/skill/collaborator.ts:12` → `detail/Header.tsx:230` | 技能→技能详情→权限设置弹窗→修改协作者权限时调用 | +| `/proApi/core/agentSkill/collaborator/delete` | DELETE | 删除协作者 | `src/web/core/skill/collaborator.ts:15` → `detail/Header.tsx:235` | 技能→技能详情→权限设置弹窗→移除协作者时调用 | +| `/proApi/core/agentSkills/changeOwner` | POST | 转让技能所有者 | `src/web/core/skill/api.ts:93` → `detail/Header.tsx:212` | 技能→技能详情→权限设置弹窗→转让所有者时调用 | +| `/core/agentSkills/resumeInheritPermission` | GET | 恢复从父级继承权限 | `src/web/core/skill/api.ts:89` → `detail/Header.tsx:221` | 技能→技能详情→权限设置弹窗→点击恢复继承时调用 | + +## API 调用链追踪 + +### `GET /core/agentSkills/detail` 调用链 + +``` +SkillDetailContextProvider + ├── 触发: 页面加载 / skillId 变化(refreshDeps: [skillId]) + ├── 参数: { skillId } + └── 响应处理: 构建 AgentSkillDetailType(含 SkillPermission);注入 SkillDetailContext;驱动 Header/Content 渲染 +``` + +### `POST /core/agentSkills/update` 调用链 + +``` +Header → 编辑信息弹窗确认 + ├── 触发: EditResourceModal onEdit 回调 + ├── 参数: { skillId, name, avatar, description } + └── 响应处理: refreshSkillDetail() 刷新上下文数据;关闭编辑弹窗;Toast "编辑成功" +``` + +### `DELETE /core/agentSkills/delete` 调用链 + +``` +Header → 删除确认弹窗确认 + ├── 触发: 输入技能名称匹配后点击确认 + ├── 参数: { skillId } + └── 响应处理: router.push('/dashboard/skill') 返回技能列表;Toast "删除成功" +``` + +### `GET /core/agentSkills/export` 调用链 + +``` +Header → 菜单 → 导出配置 + ├── 触发: 菜单项 onClick + ├── 参数: skillId(URL query)、skillName(文件名) + └── 响应处理: 浏览器下载 ZIP 文件;Toast "导出成功" +``` + +### `/proApi/core/agentSkill/collaborator/list` 调用链 + +``` +ConfigPerModal → managePer.onGetCollaboratorList + ├── 触发: 权限弹窗打开 / refreshDeps 变化 + ├── 参数: { skillId } + └── 响应处理: 渲染协作者列表(角色、权限) +``` + +### `/proApi/core/agentSkill/collaborator/update` 调用链 + +``` +ConfigPerModal → managePer.onUpdateCollaborators + ├── 触发: 添加/修改协作者操作 + ├── 参数: { skillId, tmbId, role } + └── 响应处理: 刷新协作者列表 +``` + +### `/proApi/core/agentSkill/collaborator/delete` 调用链 + +``` +ConfigPerModal → managePer.onDelOneCollaborator + ├── 触发: 移除协作者操作 + ├── 参数: { skillId, tmbId } + └── 响应处理: 刷新协作者列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..6e2635c2af12 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/API\347\264\242\345\274\225.md" @@ -0,0 +1,121 @@ +--- +capability_label: config +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab API索引 + +## 文件查询与读取 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/package/list` | POST | 列出技能包目录内容 | `api.ts:22` → `useFileTree.ts:45`
`api.ts:22` → `useFileTree.ts:63` | 技能详情→Config Tab→加载时调用(首次加载根目录);技能详情→Config Tab→展开目录时调用(懒加载子目录) | +| `/core/agentSkills/package/read` | POST | 读取技能包内文件内容(二进制流) | `api.ts:28` → `useFileOperations.tsx:91` | 技能详情→Config Tab→点击文件名打开文件时调用;技能详情→Config Tab→版本过期后刷新已打开文件时调用 | +| `/core/agentSkills/package/checkVersion` | POST | 检查技能包版本是否变化 | `api.ts:120` → `index.tsx:64`
`api.ts:120` → `index.tsx:77` | 技能详情→Config Tab→页面加载时调用(获取初始版本号);技能详情→Config Tab→定时轮询(每30秒);技能详情→Config Tab→窗口获得焦点时调用 | + +## 文件写入 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/package/write` | POST | 写入单个文件内容 | `api.ts:45` → `useAutoSave.ts:22`
`api.ts:45` → `useFileOperations.tsx:166` | 技能详情→Config Tab→编辑源码后自动保存(800ms 防抖);技能详情→Config Tab→创建新文件时调用;技能详情→Config Tab→文件重命名前刷新待保存内容时调用;技能详情→Config Tab→关闭文件时刷新待保存内容时调用 | +| `/core/agentSkills/package/batchWrite` | POST | 批量写入多个文本文件(单次 zip 重写) | `api.ts:50`(未被当前模块调用,为外部调用预留) | —(当前模块未使用此 API) | + +## 文件管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/package/mkdir` | POST | 新建目录 | `api.ts:68` → `useFileOperations.tsx:187` | 技能详情→Config Tab→确认创建文件夹名称后调用 | +| `/core/agentSkills/package/rename` | POST | 重命名文件或目录 | `api.ts:62` → `useFileOperations.tsx:264` | 技能详情→Config Tab→确认重命名后调用(重命名前先刷新待保存内容) | +| `/core/agentSkills/package/delete` | POST | 删除文件或目录(支持递归) | `api.ts:56` → `useFileOperations.tsx:295` | 技能详情→Config Tab→确认删除对话框后调用 | +| `/core/agentSkills/package/upload` | POST | 上传二进制文件(FormData) | `api.ts:83` → `useFileOperations.tsx:224` | 技能详情→Config Tab→选择本地文件后逐文件上传 | + +## 沙箱同步 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/agentSkills/package/sandbox/sync` | POST | 同步技能包到运行中的沙箱 | `api.ts:113`(当前模块仅导出,由 Preview Tab 调用) | 技能详情→Preview Tab→Run Preview 前调用(将编辑器中最新内容推送到沙箱) | + +--- + +## API 调用链追踪 + +### `/core/agentSkills/package/list` 调用链 + +``` +useFileTree.reloadRoot() (首次加载) + ├── 触发: 组件挂载(useMount)→ Config Tab 进入 + ├── 参数: { skillId, path: '.', recursive: true } + └── 响应处理: 将后端文件列表转为 TreeNode[] 树结构,更新 fileTree 状态 + +useFileTree.loadDirectory() (懒加载) + ├── 触发: 用户点击展开未加载的目录 + ├── 参数: { skillId, path } + └── 响应处理: 调用 updateTreeNode 将子节点合并到现有树,标记目录为已加载 +``` + +### `/core/agentSkills/package/read` 调用链 + +``` +useFileOperations.loadFile() + ├── 触发: 用户点击文件名(openFile) + ├── 参数: { skillId, path } + └── 响应处理: + ├── 二进制文件(image/audio/video)→ 转 Blob → URL.createObjectURL + ├── 文本文件 → ArrayBuffer → TextDecoder UTF-8 解码 + └── UTF-8 解码失败 → 标记 isUnknown,前端展示无预览提示 +``` + +### `/core/agentSkills/package/write` 调用链 + +``` +useAutoSave.writeFile() + ├── 触发: setTimeout 800ms 防抖后自动执行 + ├── 参数: { skillId, path, content } + └── 响应处理: 更新 packageVersionRef = result.packageVersion + +useFileOperations.handleCreateFile() + ├── 触发: 用户确认新文件名称 + ├── 参数: { skillId, path, content: '' } + └── 响应处理: 刷新父目录文件树 → 打开新文件进入编辑 + +useAutoSave.flushPendingForPath() + ├── 触发: 重命名/关闭文件前强制刷新 + ├── 参数: { skillId, path, content }(批量) + └── 响应处理: Promise.all 并行写入所有待保存文件 +``` + +### `/core/agentSkills/package/upload` 调用链 + +``` +useFileOperations.handleUploadFiles() + ├── 触发: 用户选择文件后确认上传 + ├── 参数: FormData { skillId, path, file }(逐文件循环) + └── 响应处理: 更新 packageVersion → 成功提示 → 刷新父目录 +``` + +### `/core/agentSkills/package/delete` 调用链 + +``` +useFileOperations.handleDelete() + ├── 触发: 用户确认删除(useConfirm 弹窗) + ├── 参数: { skillId, path, recursive: node.type === 'directory' } + └── 响应处理: 清除已打开文件中该路径及其子路径的 tab → 刷新父目录 + +### `/core/agentSkills/package/checkVersion` 调用链 + +``` +checkSkillPackageVersion (初始化) + ├── 触发: useEffect 组件挂载 + ├── 参数: { skillId, knownVersion: 0 } + └── 响应处理: packageVersionRef.current = result.currentVersion + +checkSkillPackageVersion (轮询) + ├── 触发: setInterval 30s / window focus 事件 + ├── 参数: { skillId, knownVersion: packageVersionRef.current } + └── 响应处理: result.changed === true → setStaleDetected(true) → 显示黄色过期提示条 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..c17f57811afe --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,150 @@ +--- +capability_label: config +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab Hooks工具函数 + +## 自定义 Hooks + +### useFileTree + +**文件**: `hooks/useFileTree.ts` + +**签名**: `useFileTree({ skillId }: UseFileTreeParams)` + +管理技能包文件目录树的加载、展开、搜索和刷新。 + +**返回值**: +| 字段 | 类型 | 说明 | +|------|------|------| +| `fileTree` | `TreeNode[]` | 完整文件树数据 | +| `filteredTree` | `TreeNode[]` | 按搜索词过滤后的文件树 | +| `expandedDirs` | `Set` | 已展开目录路径集合 | +| `loadingDirs` | `Set` | 正在加载中的目录路径集合 | +| `loadingRoot` | `boolean` | 根目录是否正在加载 | +| `searchQuery` | `string` | 搜索关键词 | +| `setSearchQuery` | `(q: string) => void` | 设置搜索关键词 | +| `loadDirectory` | `(path, level) => Promise` | 懒加载指定目录 | +| `reloadRoot` | `() => Promise` | 以 recursive 模式重新加载整个文件树 | +| `toggleDirectory` | `(node) => Promise` | 展开/折叠目录(首次展开时懒加载) | +| `refreshDir` | `(dirPath) => Promise` | 刷新指定目录内容 | + +--- + +### useAutoSave + +**文件**: `hooks/useAutoSave.ts` + +**签名**: `useAutoSave({ skillId }: UseAutoSaveParams)` + +管理编辑器内容的防抖自动保存。编辑器中每次内容变更触发 `scheduleAutoSave`,800ms 内无新变更则自动调用 `writeSkillPackageFile` 写入后端。 + +**返回值**: +| 字段 | 类型 | 说明 | +|------|------|------| +| `scheduleAutoSave` | `(path, content) => void` | 调度自动保存(800ms 防抖) | +| `flushPendingForPath` | `(prefix) => Promise` | 强制刷新指定路径及其子路径的所有待保存内容 | +| `flushAllPending` | `() => Promise` | 强制刷新全部待保存内容(Preview Tab 同步沙箱前使用) | +| `cancelPendingForPath` | `(prefix) => void` | 取消指定路径的待保存操作 | +| `closeFile` | `(path) => void` | 关闭文件时立即刷新并移除待保存定时器 | +| `setOpenedFilesRef` | `(files) => void` | 同步已打开文件引用(供 scheduleAutoSave 读取最新内容) | +| `packageVersionRef` | `MutableRefObject` | 包版本号引用,每次写入成功后更新 | + +--- + +### useFileOperations + +**文件**: `hooks/useFileOperations.tsx` + +**签名**: `useFileOperations(params: UseFileOperationsParams)` + +管理文件操作的全部逻辑:打开/关闭文件、CRUD 操作(创建、重命名、删除、上传)、命名弹窗、确认对话框。 + +**返回值**: +| 字段 | 类型 | 说明 | +|------|------|------| +| `openedFiles` | `OpenedFile[]` | 当前已打开的文件列表 | +| `setOpenedFiles` | `Dispatch>` | 更新已打开文件列表 | +| `activeFilePath` | `string` | 当前激活的文件路径 | +| `setActiveFilePath` | `(path: string) => void` | 切换激活文件 | +| `activeFile` | `OpenedFile \| undefined` | 当前激活文件的完整信息 | +| `openFile` | `(path: string) => Promise` | 打开文件(已打开则切换激活) | +| `closeFile` | `(path, e?) => void` | 关闭文件并清理 blob URL | +| `loadingFile` | `boolean` | 是否正在加载文件内容 | +| `handleCreateFile` | `(parentDir: string) => Promise` | 创建新文件流程 | +| `handleCreateFolder` | `(parentDir: string) => Promise` | 创建新文件夹流程 | +| `handleUploadFiles` | `(parentDir, files: File[]) => Promise` | 上传文件流程 | +| `handleRename` | `(node: TreeNode) => Promise` | 重命名流程 | +| `handleDelete` | `(node: TreeNode) => void` | 删除确认流程 | +| `refreshOpenedFiles` | `() => Promise` | 重新加载所有已打开文件内容 | +| `DeleteConfirmModal` | `React.FC` | 删除确认弹窗组件 | +| `isNameModalOpen` / `handleNameConfirm` / `handleNameCancel` | — | 命名弹窗控制 | +| `nameInputValue` / `setNameInputValue` | — | 命名输入值 | + +--- + +## 工具函数 + +### getIconByFilename + +**文件**: `utils.tsx` + +**签名**: `getIconByFilename(filename: string): IconNameType` + +根据文件扩展名返回对应的图标名。支持 30+ 种文件类型识别(编程语言、文档、图片、视频、压缩包等),未匹配返回默认文件图标。 + +--- + +### getLanguageByFileName + +**文件**: `utils.tsx` + +**签名**: `getLanguageByFileName(fileName: string): string` + +根据文件扩展名返回 Monaco Editor 的语言标识符(如 `typescript`、`python`、`markdown`)。用于 Editor 组件的 `language` 属性,实现语法高亮。未匹配返回 `'plaintext'`。 + +--- + +### getIsBinaryByLanguage + +**文件**: `utils.tsx` + +**签名**: `getIsBinaryByLanguage(language: string): boolean` + +判断语言类型是否为二进制(image / audio / video)。二进制文件通过 Blob URL 渲染预览而非 Monaco Editor。 + +--- + +### getSupportsPreviewToggle + +**文件**: `utils.tsx` + +**签名**: `getSupportsPreviewToggle(language?: string): boolean` + +判断当前文件是否支持源码/预览切换。仅 Markdown 和 SVG 文件支持。 + +--- + +### updateTreeNode + +**文件**: `utils.tsx` + +**签名**: `updateTreeNode(tree: T[], targetPath: string, children: T[], loaded?: boolean): T[]` + +不可变地更新文件树中指定路径节点的子节点。用于目录懒加载后将子节点合并到现有树结构。 + +--- + +### filterTree + +**文件**: `utils.tsx` + +**签名**: `filterTree(nodes: T[], query: string): T[]` + +按搜索关键词递归过滤文件树。文件名匹配时保留该文件节点;目录下任意子节点匹配时保留目录节点及其匹配子节点。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9ec7072fd186 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/config/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,52 @@ +--- +capability_label: config +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 技能详情 +roles: [skill_owner, skill_writer] +router_paths: [/skill/detail] +--- + +# 技能详情 — Config Tab 组件列表 + +## components/(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx` | AgentSkillEditor 主组件:编排文件树、编辑区、自动保存、版本轮询 | 被父级 Content.tsx 引用 | 是 → [15](../../../技能详情/共享组件清单.md) | `skillId: string, canWrite: boolean` | +| `components/FileTree.tsx` | 文件目录树:展示技能包文件结构、搜索过滤、展开/折叠目录 | 内部 | 否 | `filteredTree, searchQuery, setSearchQuery, expandedDirs, loadingDirs, activeFilePath, openFile, toggleDirectory, canWrite, onCreateFile, onCreateFolder, onUploadFiles, onRename, onDelete` | +| `components/FileTabs.tsx` | 已打开文件标签栏:展示已打开文件列表、切换激活文件、关闭文件、脏标记 | 内部 | 否 | `openedFiles: OpenedFile[], activeFilePath: string, setActiveFilePath, closeFile` | +| `components/EditorContent.tsx` | 编辑器内容区:Monaco Editor 源码编辑、Markdown/SVG 预览、图片/音频/视频二进制文件预览、源码/预览切换 | 引用 @monaco-editor/react | 否 | `activeFile: OpenedFile, activeFilePath, setOpenedFiles, editorRef, isUpdatingRef, canWrite, scheduleAutoSave` | + +## hooks/(业务 Hooks) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `hooks/useFileTree.ts` | 文件树状态管理:根目录加载、目录懒加载、搜索过滤、展开折叠 | 调用 listSkillPackageFiles API | 否 | `skillId: string` | +| `hooks/useAutoSave.ts` | 自动保存管理:800ms 防抖保存、路径级刷新/取消、组件卸载清理、binary blob URL 回收 | 调用 writeSkillPackageFile API | 否 | `skillId: string` | +| `hooks/useFileOperations.tsx` | 文件操作管理:打开/关闭文件、创建/删除/重命名/上传、命名弹窗状态、确认对话框 | 调用所有 skill package API | 否 | `skillId, closeFileFlush, flushPendingForPath, cancelPendingForPath, refreshDir, packageVersionRef` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyIcon` | `@fastgpt/web/components/common/Icon` | @fastgpt/web | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | @fastgpt/web | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | @fastgpt/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | @fastgpt/web | +| `Markdown` | `@fastgpt/web/components/common/Markdown` | @fastgpt/web | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | @fastgpt/web | +| `MyPhotoView` | `@fastgpt/web/components/common/Image/PhotoView` | @fastgpt/web | +| `Editor` | `@monaco-editor/react` | monaco-editor | + +## 消费的外部 Hooks + +| 本模块使用 Hook | 导入路径 | 源模块 | +|--------------|---------|--------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | @fastgpt/web | +| `useToast` | `@fastgpt/web/hooks/useToast` | @fastgpt/web | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | @fastgpt/web | +| `useMemoEnhance` | `@fastgpt/web/hooks/useMemoEnhance` | @fastgpt/web | +| `useMount` | `ahooks` | ahooks | +| `useLatest` | `ahooks` | ahooks | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..f014f2ee26ce --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/API\347\264\242\345\274\225.md" @@ -0,0 +1,76 @@ +--- +capability_label: "预览调试" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — API索引 + +## 技能调试会话 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/agentSkills/debugChat` | POST(流式) | 发送技能调试消息,获取流式 AI 回复 | `useSkillChatTest.tsx:80` → `ChatBox` | 技能详情→预览Tab→发送消息时调用 | +| `/api/core/agentSkills/debugSession/records` | POST | 获取调试会话的历史对话记录(分页) | `SkillPreview.tsx:103` → `ChatRecordContextProvider` | 技能详情→预览Tab→加载历史/向上滚动翻页时调用 | +| `/api/core/agentSkills/debugSession/chatItem/delete` | POST | 删除调试会话中的单条对话消息 | `useSkillChatTest.tsx:99` → `ChatBox`(重新生成时) | 技能详情→预览Tab→重新生成某条消息时调用 | + +## 技能沙箱同步 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/agentSkills/syncSandbox` | POST | 同步技能最新配置到沙箱环境 | `useSkillChatTest.tsx:69`(syncSkillSandbox) | 技能详情→预览Tab→每次发送消息前自动调用 | + +## 系统配置 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 系统 LLM 模型列表 | —(通过 useSystemStore 获取) | 获取可用 AI 模型列表 | `SkillPreview.tsx:21`(useSystemStore) | 技能详情→预览Tab→加载时获取;用于填充模型选择器 | + +--- + +## API 调用链追踪 + +### `/api/core/agentSkills/syncSandbox` 调用链 + +``` +useSkillChatTest → startChat() + ├── 触发: 用户发送消息时 + ├── 前置: flushAllPendingRef.current() — 先保存编辑器中待保存的内容 + ├── 参数: { skillId } + ├── 成功: 继续执行后续的 debugChat 请求 + └── 失败: toast 警告提示(标题: "skill:preview_sync_failed_title",内容: 错误详情文本) +``` + +### `/api/core/agentSkills/debugChat` 调用链 + +``` +useSkillChatTest → startChat() → streamFetch() + ├── 触发: sandbox 同步完成后 + ├── 参数: { skillId, chatId, messages(最近一条), modelId, responseChatItemId } + ├── 流式处理: onMessage → generatingMessage(逐字流式更新 UI) + ├── 中止控制: abortCtrl → controller(支持用户中止生成) + └── 返回: { responseText } — 完整回复文本 +``` + +### `/api/core/agentSkills/debugSession/records` 调用链 + +``` +SkillPreview Render → ChatRecordContextProvider → skillFetchFn() + ├── 触发: 首次加载聊天区域 + 向上滚动翻页时 + ├── 参数: { skillId, chatId, pageSize, initialId / nextId / prevId } + ├── 分页模式: 双向游标分页(支持向前/向后翻页) + └── 返回: { list, total, hasMorePrev, hasMoreNext } +``` + +### `/api/core/agentSkills/debugSession/chatItem/delete` 调用链 + +``` +useSkillChatTest → handleDeleteChatItem() → delSkillDebugChatItem() + ├── 触发: ChatBox 中用户点击重新生成(需先删除旧消息) + ├── 参数: { skillId, chatId, contentId } + └── 说明: 使用技能专属删除接口,避免走通用 /api/core/chat/item/delete 用 skillId 查 App 报错 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..85eb128ca67d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,59 @@ +--- +capability_label: "预览调试" +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — Hooks工具函数 + +## Hooks + +### `useSkillChatTest` + +- **签名**: `function useSkillChatTest({ skillId, modelId, chatId, isReady }: { skillId: string; modelId: string; chatId: string; isReady?: boolean }): { ChatContainer: () => JSX.Element }` +- **用途**: 技能聊天测试 Hook,封装了沙箱同步、流式对话、消息删除的完整调试流程,返回一个可渲染的 ChatContainer 组件 +- **是否对外共享**: 否(仅在 SkillPreview.tsx 中被引用) +- **返回值说明**: + - `ChatContainer`: 一个 memoized 的 React 函数组件,渲染 ChatBox 聊天容器,绑定了技能调试特有的 `onStartChat` 和 `onDeleteChatItem` 回调 + +### 内部函数 `startChat` + +- **签名**: `async function startChat({ messages, responseChatItemId, controller, generatingMessage }: StartChatFnProps): Promise<{ responseText: string }>` +- **用途**: 启动一次技能调试对话——先同步沙箱,再发送流式聊天请求,支持中止控制 +- **是否对外共享**: 否(useSkillChatTest 内部函数) + +### 内部函数 `handleDeleteChatItem` + +- **签名**: `async function handleDeleteChatItem(contentId: string): Promise` +- **用途**: 使用技能专属接口删除调试会话中的单条聊天消息,避免走通用聊天删除接口时用 skillId 查询 App 出错 +- **是否对外共享**: 否(useSkillChatTest 内部函数) + +## 常量 + +### `SKILL_DEBUG_CHAT_URL` + +- **文件**: `projects/app/src/web/core/skill/api.ts:58` +- **类型**: `string`(常量) +- **说明**: 技能调试聊天的 API 端点路径 `/api/core/agentSkills/debugChat`,在 useSkillChatTest 中用于流式请求 + +### `CHAT_ID_STORAGE_KEY` + +- **文件**: `projects/app/src/pageComponents/dashboard/skill/detail/preview/SkillPreview.tsx:75` +- **类型**: `(skillId: string) => string` +- **说明**: 生成 localStorage 中存储调试会话 chatId 的键名,格式为 `skill_debug_chatId_{skillId}` + +## 工具函数 + +本模块未检测到独立的工具函数定义。通用的 ID 生成(`getNanoid`)和分页请求构造逻辑来自共享包 `@fastgpt/global` 和 `@fastgpt/web`。 + +## 函数依赖关系 + +- `useSkillChatTest` → `syncSkillSandbox`(来自 AgentSkillEditor 模块的 API):每次发消息前同步沙箱 +- `useSkillChatTest` → `streamFetch`(来自 `@/web/common/api/fetch`):发起流式聊天请求 +- `useSkillChatTest` → `delSkillDebugChatItem`(来自 `@/web/core/skill/api`):删除调试消息 +- `useSkillChatTest` → `SkillDetailContext`(来自 `../context`):获取 flushAllPendingRef 引用 +- `useSkillChatTest` → `ChatItemContext`(来自 `@/web/core/chat/context/chatItemContext`):设置聊天区域数据 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..2979352a2291 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/preview/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,39 @@ +--- +capability_label: "预览调试" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:35:00.000Z" +parent_module: "技能详情" +roles: ["owner", "collaborator"] +router_paths: [] +--- + +# 预览调试 — 组件列表 + +## preview/ 目录 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `SkillPreview.tsx` | 技能调试预览容器,含模型选择器和聊天区域 | 引用自:核心聊天模块(ChatBox)、AI模型选择器(AIModelSelector)、系统Store | 否 | chatId: string, restartChat: () => void | +| `useSkillChatTest.tsx` | 技能聊天测试 Hook,管理沙箱同步、流式对话、消息删除 | 引用自:技能配置模块(syncSkillSandbox)、聊天API | 否(Hook,非组件) | skillId, modelId, chatId, isReady | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `ChatBox` | `@/components/core/chat/ChatContainer/ChatBox` | 核心聊天模块 | +| `ChatItemContextProvider` | `@/web/core/chat/context/chatItemContext` | 聊天上下文模块 | +| `ChatRecordContextProvider` | `@/web/core/chat/context/chatRecordContext` | 聊天记录模块 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | AI模型选择器模块 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用图标组件库 | +| `IconButton` | `@chakra-ui/react` | Chakra UI 组件库 | + +## 消费的外部 Hooks / Store + +| 本模块使用 | 导入路径 | 源模块 | +|-----------|---------|--------| +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统设置 Store | +| `useContextSelector` | `use-context-selector` | 第三方库 | +| `useMemoizedFn` | `ahooks` | 第三方库 | +| `useToast` | `@fastgpt/web/hooks/useToast` | 通用 Toast Hook | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 通用请求 Hook(context.tsx 中使用) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..80490e40ede4 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\212\200\350\203\275/\346\212\200\350\203\275\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,56 @@ +--- +capability_label: 技能详情 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: 技能 +roles: [拥有者, 协作者] +router_paths: ["/skill/detail"] +--- + +# 技能详情 — 组件列表 + +## pages/(页面入口) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/skill/detail.tsx` | 技能详情页入口,组装 DashboardNavbar + ContextProvider + Header + Content | 引用自通用组件 → DashboardNavbar;引用自 detail/ → Header, Content, SkillDetailContextProvider | 否 | — | + +## detail/(详情子组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/skill/detail/context.tsx` | SkillDetailContext 提供者,管理 skillId、技能详情数据、当前 Tab 状态、自动保存引用 | 引用自 use-context-selector → createContext;引用自 @fastgpt/global → AgentSkillDetailType, SkillPermission | 否 | children: ReactNode | +| `pageComponents/dashboard/skill/detail/Header.tsx` | 技能详情顶部导航:返回按钮、技能名菜单(编辑/权限/导出/删除)、Tab 切换器 | 引用自通用 → MyTabs, MyIcon, Avatar, MyMenu;引用自组件库 → EditResourceModal, ConfigPerModal | 否 | —(从 Context 读取数据) | +| `pageComponents/dashboard/skill/detail/Content.tsx` | 详情内容区:根据 currentTab 条件渲染 config(AgentSkillEditor)或 preview(SkillPreview) | 引用自 detail/config/ → AgentSkillEditor;引用自 detail/preview/ → SkillPreview | 否 | —(从 Context 读取数据) | + +## detail/config/(config Tab 子组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/skill/detail/config/AgentSkillEditor/index.tsx` | 技能文件编辑器:文件树 + Monaco 编辑器 + 文件操作 + 自动保存 + 版本冲突检测 | 引用自内部 hooks → useFileTree, useAutoSave, useFileOperations | 否 | skillId: string, canWrite: boolean | + +> config Tab 的完整组件列表(FileTree, FileTabs, EditorContent, hooks 等)见子目录文档。 + +## detail/preview/(preview Tab 子组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/dashboard/skill/detail/preview/SkillPreview.tsx` | 技能调试预览:AI 模型选择器 + 聊天容器 + 清空重开按钮 | 引用自组件库 → AIModelSelector;引用自 chat/ → ChatItemContextProvider, ChatRecordContextProvider;引用自内部 hooks → useSkillChatTest | 否 | chatId: string, restartChat: () => void | + +> preview Tab 的完整组件列表(PreviewInput, useSkillChatTest 等)见子目录文档。 + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台 | +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | 通用组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用组件 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 通用组件 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 通用组件 | +| `EditResourceModal` | `@/components/common/Modal/EditResourceModal` | 通用组件 | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限管理 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | AI 模型选择 | +| `ChatItemContextProvider` | `@/web/core/chat/context/chatItemContext` | 对话 | +| `ChatRecordContextProvider` | `@/web/core/chat/context/chatRecordContext` | 对话 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\216\222\351\232\234\346\214\207\345\215\227.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\216\222\351\232\234\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..6e1d7f397ddc --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\216\222\351\232\234\346\214\207\345\215\227.md" @@ -0,0 +1,1162 @@ +--- +capability_label: null +doc_type: "08" +doc_label: "排障指南" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# FastGPT 排障指南 + +## 一、常用排障工具 + +### 1.1 日志查看 + +#### 应用日志输出 +FastGPT 使用分层日志系统,通过环境变量控制日志级别: + +**日志级别配置** +```bash +# 控制台日志级别 (debug/info/warn/error) +LOG_LEVEL=info + +# 应用日志存储等级(存储到MongoDB) +STORE_LOG_LEVEL=warn + +# OTEL(OpenTelemetry)采样日志级别 +SIGNOZ_STORE_LEVEL=warn +``` + +**日志级别说明** +| 级别 | 说明 | 用途 | +|-----|------|------| +| debug | 调试信息 | 开发调试时启用,包含详细过程信息 | +| info | 一般信息 | 记录应用运行状态,默认级别 | +| warn | 警告信息 | 记录潜在问题,如慢查询(>500ms) | +| error | 错误信息 | 记录应用错误和异常 | + +**控制台日志输出格式** +``` +[日志级别(对齐格式)] YYYY-MM-DD HH:mm:ss 消息 {对象} +例:[Error] 2025-05-05 14:30:45 MongoDB slow query (>2s) {"duration":2500,"collectionName":"chat"...} +``` + +**常用日志查看命令** + +```bash +# Docker 方式查看日志 +docker logs # 实时查看 +docker logs -f # 持续跟随日志 +docker logs --tail 100 # 查看最后100行 + +# Kubernetes 方式查看日志 +kubectl logs -f # 持续跟随日志 +kubectl logs --tail=50 # 查看最后50行 +``` + +**MongoDB 日志查询** +- 应用日志存储在 `system_logs` 集合中,字段包括 `text`(消息)、`level`(等级:0=debug/1=info/2=warn/3=error)、`time`(时间)、`metadata`(元数据) +- 只有级别 >= STORE_LOG_LEVEL 的日志才会被存储 + +#### MongoDB 慢查询日志 +MongoDB 操作耗时统计: +- **>2000ms**: 警告级别(通过 `console.warn` 输出到控制台) +- **>500ms**: 警告级别(通过 `console.warn` 输出到控制台) +- 监控的操作:find、save、create、update、delete、aggregate、count、insertMany 等 + +**注意**:MongoDB 慢查询日志**不会写入 system_logs 集合**,仅通过 `console.warn` 输出到控制台/Docker 日志。如需查看慢查询,请检查容器日志: + +```bash +# 查看 Docker 日志中的慢查询 +docker logs fastgpt 2>&1 | grep "slow query" + +# 查看最后 100 行并筛选慢查询 +docker logs fastgpt --tail 100 2>&1 | grep "slow query" +``` + +### 1.2 数据库连接检查 + +#### MongoDB 连接检查 + +**连接参数** +```bash +# 主要环境变量 +MONGODB_URI=mongodb://username:password@host:27017/fastgpt?authSource=admin + +# 日志数据库(可选,默认同主库) +MONGODB_LOG_URI=mongodb://username:password@host:27017/fastgpt-log?authSource=admin + +# 数据库连接池大小 +DB_MAX_LINK=100 # 默认100个连接 +``` + +**连接检查命令** +```bash +# 使用 MongoDB CLI 工具连接检查 +mongosh "mongodb://username:password@hostname:27017/fastgpt?authSource=admin" + +# 执行健康检查命令 +> db.adminCommand('ping') +# 返回 { ok: 1 } 表示连接正常 + +# 查看副本集状态(生产环境推荐) +> rs.status() +``` + +**Docker 中检查 MongoDB** +```bash +# 进入 MongoDB 容器 +docker exec -it fastgpt-mongo mongosh + +# 在 MongoDB 中执行 +db.adminCommand('ping') +rs.initiate() # 初始化副本集(如果尚未初始化) +``` + +**常见 MongoDB 连接错误** +| 错误 | 原因 | 解决方案 | +|------|------|--------| +| `connect ECONNREFUSED` | MongoDB 服务未启动或端口错误 | 检查服务状态,确认 MONGODB_URI 配置 | +| `authentication failed` | 用户名/密码错误或认证库错误 | 验证 MONGODB_URI 中的凭证和 authSource | +| `unable to connect to any servers` | 网络不可达或 DNS 问题 | 检查网络连接,确认 hostname 可解析 | +| `replica set not found` | 副本集未初始化或副本集名称错误 | 检查 docker-compose 初始化脚本或手动执行 rs.initiate() | + +#### Redis 连接检查 + +**连接参数** +```bash +# Redis URL +REDIS_URL=redis://[:password]@hostname:6379 + +# 集群模式(可选) +REDIS_CLUSTER_NODES=node1:6379,node2:6379,node3:6379 +``` + +**连接检查命令** +```bash +# 使用 redis-cli 检查 +redis-cli -h hostname -p 6379 PING +# 返回 PONG 表示连接正常 + +# 带密码连接 +redis-cli -h hostname -p 6379 -a password PING + +# 查看 Redis 信息 +redis-cli INFO server +``` + +**Docker 中检查 Redis** +```bash +# 进入 Redis 容器 +docker exec fastgpt-redis redis-cli -a mypassword PING + +# 查看内存使用 +docker exec fastgpt-redis redis-cli -a mypassword INFO memory +``` + +**Redis 重连机制** +- 自动重试策略:指数退避,最大间隔 2000ms +- 错误重连:READONLY、ECONNREFUSED、ETIMEDOUT、ECONNRESET 时自动重连 +- 离线队列:连接断开时缓存命令,重连后执行 + +#### 向量数据库连接检查 + +**PGVector (PostgreSQL) 连接** +```bash +# PostgreSQL 连接参数 +PG_URL=postgresql://username:password@hostname:5432/fastgpt + +# 连接检查 +psql "postgresql://username:password@hostname:5432/fastgpt" -c "SELECT version();" +``` + +**Milvus 连接** +```bash +# Milvus 连接参数 +MILVUS_ADDRESS=hostname:19530 + +# Python 检查示例 +from pymilvus import connections +connections.connect("default", host="hostname", port=19530) +connections.get_connection_addr("default") +``` + +### 1.3 健康检查接口 + +**系统初始化接口** +``` +GET /api/common/system/getInitData +Query: bufferId (可选) +Headers: Authorization: Bearer +``` + +**接口用途** +- 验证系统状态和配置是否加载 +- 获取活跃模型列表和训练任务 +- 获取功能开关配置 + +**预期响应(200)** +```json +{ + "bufferId": "system_buffer_id", + "feConfigs": { /* 前端配置 */ }, + "systemVersion": "1.0.0", + "activeModelList": [ /* 可用模型列表 */ ], + "defaultModels": { /* 默认模型配置 */ }, + "modelProviders": [ /* 模型提供商 */ ], + "aiproxyChannels": { /* AI代理通道 */ } +} +``` + +**错误响应说明** +- `401`: 未授权,检查 API Token +- `500`: 服务器错误,查看应用日志获取详情 + +**OpenAPI 健康检查** +``` +GET /api/support/openapi/health +Query: apiKey= +``` + +**用途** +- 验证 API Key 是否有效 +- 获取关联的 App ID + +**预期响应(200)** +```json +{ + "appId": "app_id_123" +} +``` + +--- + +## 排障工具箱 + +在开始排障前,请熟悉以下核心工具和文件路径。 + +### 日志文件路径 + +| 环境 | 日志位置 | 说明 | +|-----|---------|------| +| Docker | `docker logs fastgpt` | 主应用容器日志(含 console.warn 慢查询) | +| K8s | `kubectl logs ` | Pod 标准输出/标准错误 | +| 本地开发 | 终端标准输出 | `FastGPT/projects/app && pnpm dev` 的控制台输出 | + +### 关键日志标签 + +| 关键字/标签 | 含义 | 排查场景 | +|-----------|------|---------| +| `MongoDB slow query` | MongoDB 操作耗时 >500ms | 性能排查、慢查询定位 | +| `[Error]` | 应用错误日志 | 功能异常、请求失败 | +| `[warn]` | 应用警告日志 | 潜在问题排查 | +| `connect ECONNREFUSED` | 数据库连接被拒绝 | 数据库服务未启动或配置错误 | +| `authentication failed` | MongoDB 认证失败 | 凭证或 authSource 配置错误 | + +### 关键工具速查 + +| 工具 | 路径/命令 | 用途 | +|-----|---------|------| +| MongoDB Shell | `mongosh "mongodb://..."` | 直接查询和操作数据库 | +| redis-cli | `redis-cli -h host -p 6379` | Redis 连接检查和调试 | +| psql | `psql "postgresql://..."` | PGVector 连接检查和查询 | +| Docker Logs | `docker logs ` | 查看容器运行日志 | +| Docker Stats | `docker stats fastgpt` | 实时监控 CPU/内存使用 | +| getInitData API | `GET /api/common/system/getInitData` | 验证系统配置和模型状态 | +| OpenAPI health | `GET /api/support/openapi/health` | 验证 API Key 有效性 | + +--- + +## 二、常见问题与解决方案 + +### 2.1 应用无法启动 + +**症状** +- 容器启动失败,立即退出 +- 日志中看到初始化错误 + +**排查步骤** + +**第 1 步:检查环境变量** +```bash +# 验证必须的环境变量 +echo $MONGODB_URI +echo $REDIS_URL +echo $OPENAI_API_KEY # 或对应的 AI Provider Key +``` + +**必要的环境变量清单** +| 变量 | 说明 | 示例 | +|-----|------|------| +| `MONGODB_URI` | MongoDB 连接 | `mongodb://user:pwd@host:27017/fastgpt?authSource=admin` | +| `REDIS_URL` | Redis 连接 | `redis://:password@host:6379` | +| `OPENAI_API_KEY` 或其他 AI Key | AI 模型 API 密钥 | (必填) | +| `PG_URL` | PostgreSQL/向量库 | `postgresql://user:pwd@host:5432/fastgpt` | + +**第 2 步:检查数据库连接** +```bash +# 使用上述 1.2 节的命令验证各数据库连接 +mongosh "mongodb://..." +redis-cli -h ... PING +psql "postgresql://..." +``` + +**第 3 步:查看应用启动日志** +```bash +# 本地启动日志 +cd FastGPT/projects/app && pnpm dev + +# Docker 日志 +docker logs -f fastgpt +``` + +**常见初始化错误** +| 错误 | 原因 | 解决 | +|------|------|-----| +| `MongoError: connect ECONNREFUSED` | MongoDB 未启动 | `docker-compose up -d fastgpt-mongo` | +| `RedisError: ECONNREFUSED` | Redis 未启动 | `docker-compose up -d fastgpt-redis` | +| `database error: no database` | 数据库选择错误 | 检查 MONGODB_URI 中的库名 | +| `connect timeout` | 网络问题 | 检查防火墙、DNS、网络连通性 | + +**第 4 步:清理缓存重试** +```bash +# 清理 Node 模块缓存 +rm -rf .next build node_modules +pnpm install + +# 重启应用 +pnpm dev +``` + +### 2.2 AI 调用失败 + +**症状** +- 聊天请求返回 5xx 错误 +- 日志中看到 LLM 请求失败信息 + +**排查步骤** + +**第 1 步:验证 API Key 配置** +```bash +# 检查环境变量中的 API Key +echo $OPENAI_API_KEY +echo $ANTHROPIC_API_KEY # 如果使用 Anthropic +``` + +**检查方式** +- 在系统管理页面验证已配置的模型 Key +- 尝试调用 `/api/common/system/getInitData` 查看 activeModelList + +**第 2 步:测试 API Key 有效性** +```bash +# OpenAI 示例 +curl -H "Authorization: Bearer $OPENAI_API_KEY" \ + https://api.openai.com/v1/models + +# 检查返回状态 +# 200 - 有效 +# 401 - 无效的 Key +# 429 - 配额不足 +``` + +**第 3 步:检查模型配置** +- 访问应用管理后台 -> AI 模型管理 +- 验证模型是否已添加并启用 +- 确认模型标识符(model ID)与 API 提供商一致 + +**常见 AI 调用错误** +| 错误 | HTTP 状态 | 原因 | 解决 | +|------|---------|------|-----| +| `Invalid API Key` | 401 | API Key 无效或过期 | 更新 API Key | +| `Quota exceeded` | 429 | 账户配额用尽 | 检查账户额度或充值 | +| `Model not found` | 404 | 模型标识错误或不可用 | 检查模型 ID 配置 | +| `Timeout` | 504 | API 响应超时 | 增加超时时间或检查网络 | +| `Rate limit exceeded` | 429 | 请求过于频繁 | 实现限流或等待冷却 | + +**第 4 步:检查网络和代理** +```bash +# 如果使用代理 +echo $HTTP_PROXY +echo $HTTPS_PROXY +echo $NO_PROXY + +# 测试代理可用性 +curl -x $HTTPS_PROXY https://api.openai.com/v1/models +``` + +**第 5 步:查看详细错误日志和调用记录** +```bash +# 设置调试日志级别 +LOG_LEVEL=debug pnpm dev + +# 查看 LLM 请求记录(存储在 llm_request_records 集合) +# 查看应用最近使用时间(存储在 app_records 集合,仅含 lastUsedTime,不含详细调用记录) +``` + +### 2.3 知识库检索失败 + +**症状** +- 知识库数据无法检索 +- 搜索返回 0 结果,即使存在相关数据 +- Embedding 或向量化失败 + +**排查步骤** + +**第 1 步:检查向量存储连接** +```bash +# 验证向量数据库配置 +echo $PG_URL # PGVector +echo $MILVUS_ADDRESS # 或 Milvus + +# 测试连接(以 PGVector 为例) +psql "$PG_URL" -c "SELECT * FROM pg_extension WHERE extname='vector';" +# 应该返回 vector 扩展已安装 +``` + +**第 2 步:验证 Embedding 模型配置** +- 在系统管理 -> AI 模型 -> 向量模型中检查 +- 确认默认 embedding 模型已设置 +- 验证 embedding API Key 有效 + +**第 3 步:检查数据训练状态** + +`dataset_datas` 集合存储知识库数据记录(字段:q、a、indexes 等),但**不包含** vector(向量数据存储在向量数据库中)、status 字段,也不直接包含 errorMsg。训练状态和错误信息存储在 `dataset_trainings` 集合中。 + +```javascript +// 查询指定知识库的训练记录(dataset_trainings 集合) +db.dataset_trainings.find({ + "datasetId": ObjectId("你的 dataset_id") +}).sort({ lockTime: -1 }).limit(20) + +// 查看训练失败的记录 +db.dataset_trainings.find({ + "datasetId": ObjectId("你的 dataset_id"), + "retryCount": { $lte: 0 }, // retryCount <= 0 表示重试耗尽 + "errorMsg": { $exists: true } // errorMsg 字段存在于 dataset_trainings 中 +}).pretty() + +// 查看正在进行中的训练 +db.dataset_trainings.find({ + "datasetId": ObjectId("你的 dataset_id"), + "retryCount": { $gt: 0 } // retryCount > 0 表示仍在重试中 +}).pretty() + +// 检查数据是否有对应的 indexes(在 dataset_datas 中) +db.dataset_datas.findOne({ "_id": ObjectId("data_id") }) +// 查看 indexes 数组是否为空或缺失 - 如果 indexes: [] 表示未向量化 +``` + +**常见向量化失败原因** +| 原因 | 错误信息 | 解决 | +|-----|--------|------| +| Embedding API 失败 | `Embedding Error: API failed` | 检查 embedding 模型 Key 和网络 | +| 模型不支持 | `Model not found` | 确认模型标识正确 | +| 向量维度不匹配 | `Vector dimension mismatch` | 检查向量库配置的维度 | +| 文本超长 | `Text too long` | 文本过长超过模型限制,自动截断 | + +**第 4 步:手动触发重新训练** + +通过 API 触发训练重试: +```bash +# 重试集合下所有失败的训练数据 +PUT /api/core/dataset/training/updateTrainingData +{ + "datasetId": "...", + "collectionId": "..." +} + +# 重试单条训练数据 +PUT /api/core/dataset/training/updateTrainingData +{ + "datasetId": "...", + "collectionId": "...", + "dataId": "...", + "q": "修正后的搜索文本", + "a": "修正后的答案" +} +``` + +也可以通过 UI 操作: +1. 选择知识库 +2. 进入数据管理 +3. 选择异常数据项 +4. 点击"重新向量化"或"重新处理" + +**第 5 步:检查检索配置** +- 向量搜索参数 `hnswEfSearch`(默认100,越大越精确但慢) +- 搜索结果限制 `assistantRetrievalLimit` +- 相似度阈值配置 + +### 2.4 文件上传失败 + +**症状** +- 上传文件返回错误 +- 文件预签名 URL 获取失败 +- 文件解析超时 + +**排查步骤** + +**第 1 步:检查存储配置** +```bash +# 验证对象存储环境变量 +echo $STORAGE_VENDOR # minio, aws-s3, cos, oss 等 +echo $STORAGE_REGION +echo $STORAGE_ACCESS_KEY_ID +echo $STORAGE_SECRET_ACCESS_KEY +echo $STORAGE_S3_ENDPOINT +``` + +**第 2 步:测试存储连接** +```bash +# MinIO 示例 +mc ls minio/fastgpt-public + +# AWS S3 示例 +aws s3 ls s3://fastgpt-public +``` + +**第 3 步:检查文件大小限制** +```bash +# 在 docker-compose.yml 中查看 +SANDBOX_REQUEST_MAX_RESPONSE_MB: 10 # 单个响应最大 10MB +SANDBOX_REQUEST_MAX_BODY_MB: 5 # 单个请求最大 5MB +``` + +**常见文件上传错误** +| 错误 | 原因 | 解决 | +|------|------|------| +| `403 Forbidden` | 存储凭证错误或权限不足 | 检查 ACCESS_KEY 和 SECRET_KEY | +| `413 Payload Too Large` | 文件超过大小限制 | 减小文件大小或增加限制 | +| `timeout` | 文件解析耗时过长 | 检查解析服务,增加超时时间 | +| `presign URL expired` | 预签名 URL 过期 | 重新请求 presign URL | + +**第 4 步:检查文件解析** +```bash +# 自定义 PDF 解析配置(可选) +# FastGPT/projects/app/data/config.json 中的 customPdfParse 部分 + +# 检查内置解析能力 +LOG_LEVEL=debug pnpm dev +# 查看文件解析的详细日志 +``` + +### 2.5 对话流式响应中断 + +**症状** +- 聊天响应只返回部分内容 +- 客户端中断接收流式数据 +- 日志中看到生成状态异常 + +**排查步骤** + +**第 1 步:理解生成状态跟踪机制** + +FastGPT 使用 `chatGenerateStatus` 追踪聊天消息生成状态(**数值枚举**): +- `0`(ChatGenerateStatusEnum.generating): 正在生成中 +- `1`(ChatGenerateStatusEnum.done): 生成完成 +- `2`(ChatGenerateStatusEnum.error): 生成出错 + +**状态存储位置** +```javascript +// MongoDB chats 集合中(chatGenerateStatus 是 Number 类型) +{ + chatId: "...", + chatGenerateStatus: 2, // 0=generating, 1=done, 2=error + hasBeenRead: false, + updateTime: ISODate(...) +} +``` + +**第 2 步:检查流式响应中断原因** +```bash +# 查看错误日志 +LOG_LEVEL=debug docker logs fastgpt | grep -i "error\|stream\|generating" + +# 检查网络连接 +# 确认客户端未主动断开连接 + +# 检查超时配置 +# FastGPT/projects/app/data/config.json 中的超时设置 +``` + +**常见中断原因** +| 原因 | 表现 | 解决 | +|------|------|------| +| LLM API 超时 | 响应不完整 | 增加超时时间配置 | +| 网络不稳定 | 随机中断 | 检查网络质量,实现重试机制 | +| 服务器资源不足 | 长请求经常中断 | 增加服务器资源或实现并发限制 | +| 数据库写入失败 | 中间消息丢失 | 检查 MongoDB 连接和磁盘空间 | + +**第 3 步:恢复被中断的对话** +```javascript +// 使用 updateChatGenerateStatus 恢复状态 +// 注意 chatGenerateStatus 是 Number 类型,不是字符串 +// 0 = generating, 1 = done, 2 = error + +db.chats.updateOne( + { appId: "app_id", chatId: "chat_id" }, + { + $set: { + chatGenerateStatus: 1, // 设置为 done (Number) + updateTime: new Date() + } + } +) +``` + +**第 4 步:前端处理建议** +- 实现自动重试机制 +- 保存中间结果,支持断点续传 +- 实现客户端超时退避策略 + +--- + +## 三、性能问题排查 + +### 3.1 对话响应慢 + +**排查顺序** + +**1. 检查 LLM Provider 延迟** +```bash +# 测试 API 响应时间 +time curl -H "Authorization: Bearer $OPENAI_API_KEY" \ + https://api.openai.com/v1/models + +# 如果延迟高(>1s),问题在提供商侧 +``` + +**2. 检查知识库检索性能** + +**注意**:MongoDB 慢查询日志**不会写入 system_logs**,仅通过 `console.warn` 输出。查看慢查询请检查容器日志: +```bash +# 查看 Docker 日志中的慢查询(>500ms 和 >2s) +docker logs fastgpt 2>&1 | grep "slow query" +``` + +检查数据集是否有合适的索引: +```javascript +// 查看数据集数据集合的索引 +db.dataset_datas.getIndexes() + +// 使用 explain 分析查询性能 +db.dataset_datas.find({ + "datasetId": ObjectId("...") +}).explain("executionStats") +``` + +**优化建议** +- 集合已有内置索引:teamId+datasetId+collectionId+chunkIndex+updateTime、teamId+datasetId+collectionId+indexes.dataId、rebuilding+teamId+datasetId +- 限制检索结果数量(config.json 中的 `assistantRetrievalLimit`) +- 使用向量重排序(rerank)提高精度 + +**3. 检查向量搜索性能** +```bash +# PGVector 性能指标(注意:向量数据在向量数据库中,不在 dataset_datas 集合中) +SELECT tablename, indexname, indexdef +FROM pg_indexes +WHERE tablename LIKE '%vector%' OR tablename LIKE '%embedding%'; + +# Milvus 性能监控 +# 使用 Milvus Dashboard 或 CLI 检查搜索延迟 +``` + +**优化参数** +| 参数 | 说明 | 调整策略 | +|-----|------|--------| +| `hnswEfSearch` | 搜索精度 | 默认100,增加到200提高精度(代价:慢) | +| `hnswMaxScanTuples` | 最大扫描条数 | 默认100000,减小减少延迟但可能遗漏结果 | + +**4. 检查应用服务器性能** +```bash +# 查看 CPU/内存使用 +docker stats fastgpt + +# 检查并发请求数 +docker exec fastgpt curl localhost:3000/api/health + +# 查看有多少个活跃连接 +db.currentOp() # MongoDB 当前操作 +redis-cli CLIENT LIST # Redis 客户端连接 +``` + +### 3.2 内存/CPU 高使用率 + +**排查步骤** + +**1. 检查并发数** +```bash +# 监控实时并发 +docker stats --no-stream fastgpt + +# 查看应用连接数 +curl localhost:3000/api/health +``` + +**2. 识别高负载操作** +```bash +# 查看慢操作(MongoDB 当前运行>5秒的操作) +db.currentOp({ secs_running: { $gt: 5 } }) + +# 查看系统错误日志(字段名为 time) +db.system_logs.find({ + "level": "3" +}).sort({ time: -1 }).limit(10) +``` + +**常见高负载操作** +- 大批量知识库导入(数万条数据同时向量化) +- 并发 embedding 计算 +- 复杂的聊天场景(多轮对话,长上下文压缩) + +**3. 优化策略** +```javascript +// config.json 中的并发控制 +{ + "systemEnv": { + "datasetParseMaxProcess": 10, // 知识库解析并发 + "vectorMaxProcess": 10, // 向量计算并发 + "qaMaxProcess": 10, // 问答拆分并发 + "tokenWorkers": 30 // Token 计算工作线程 + } +} +``` + +**4. 实施限流** +```bash +# 在生产环境启用频率限制 +# 通过 AI Provider 配置的限流参数 +# 应用层面的并发控制(见上配置) +``` + +--- + +## 四、数据问题排查 + +### 4.1 知识库数据异常 + +**重要说明**:知识库数据分为两层存储: +- `dataset_datas` 集合:存储数据记录(q、a、indexes 等字段),**不包含** vector、status、errorMsg +- `dataset_trainings` 集合:存储训练记录(retryCount、errorMsg 等字段) +- 向量数据存储在向量数据库(PGVector/Milvus)中,不在 MongoDB + +**检查训练失败的数据** +```javascript +// 查询训练失败的数据(dataset_trainings 集合) +db.dataset_trainings.find({ + "datasetId": ObjectId("..."), + "retryCount": { $lte: 0 } +}).pretty() + +// 查看具体错误信息(errorMsg 在 dataset_trainings 中) +db.dataset_trainings.findOne({ + "_id": ObjectId("training_record_id") +}) +// 查看 errorMsg 字段 + +// 通过 dataset_datas 的 indexes 字段判断向量化状态 +db.dataset_datas.findOne({ "_id": ObjectId("data_id") }) +// indexes: [] (空数组) = 未向量化 +// indexes: [{...}] (非空) = 已向量化 +``` + +**重新训练流程** +```bash +# 方式 1:通过 UI(推荐) +# 1. 进入知识库 -> 数据管理 +# 2. 选择失败的数据项 +# 3. 点击"重新向量化" + +# 方式 2:通过 API +# API 路径:PUT /api/core/dataset/training/updateTrainingData +# 重试集合下所有失败的训练数据 +PUT /api/core/dataset/training/updateTrainingData +{ + "datasetId": "...", + "collectionId": "..." +} +``` + +**数据一致性检查** +```javascript +// 统计数据的向量化状态(通过 dataset_trainings 判断) +db.dataset_trainings.aggregate([ + { + $match: { "datasetId": ObjectId("dataset_id") } + }, + { + $group: { + _id: "$datasetId", + totalDocs: { $sum: 1 }, + failedDocs: { + $sum: { $cond: [{ $lte: ["$retryCount", 0] }, 1, 0] } + } + } + } +]) + +// 如果 failedDocs > 0,说明有失败的数据项需要重试 +``` + +**数据清理** +```javascript +// 删除已删除的集合中的数据 +db.dataset_datas.deleteMany({ + "collectionId": { + $nin: db.dataset_collections.find().distinct("_id") + } +}) +``` + +## 四(续)、消息队列问题 + +### 4.2 训练队列卡住 + +**症状** +- 知识库数据长时间处于"训练中"状态 +- 新建知识库不进行向量化 + +**排查步骤** + +**第 1 步:检查队列状态** +```javascript +// 查看被锁住的训练记录(lockTime 更新时间超过正常值) +db.dataset_trainings.find({ + "datasetId": ObjectId("dataset_id"), + "retryCount": { $gt: 0 }, + "lockTime": { $lt: new Date(Date.now() - 600000) } // 锁超过 10 分钟 +}).limit(20) + +// 查看所有待处理的训练记录 +db.dataset_trainings.find({ + "retryCount": { $gt: 0 } +}).sort({ lockTime: 1 }).limit(20) +``` + +**第 2 步:释放死锁的训练记录** +```javascript +// 将卡住的记录解锁(重置 lockTime) +db.dataset_trainings.updateMany( + { + "datasetId": ObjectId("dataset_id"), + "retryCount": { $gt: 0 }, + "lockTime": { $lt: new Date(Date.now() - 600000) } + }, + { + $set: { lockTime: new Date('2000/1/1') } + } +) +``` + +**第 3 步:检查 Worker 进程与训练队列** +```bash +# 确认训练 Worker 是否在运行 +docker logs fastgpt 2>&1 | grep -i "training\|train\|worker" +``` + +FastGPT 的训练队列基于 MongoDB change stream 驱动,不使用 Redis/BullMQ。训练数据存储在 集合中。 + +```javascript +// 检查等待中的训练任务数量(MongoDB 查询) +db.dataset_trainings.countDocuments({ retryCount: { $gt: 0 } }) + +// 查看训练队列详情 +db.dataset_trainings.find({ retryCount: { $gt: 0 } }).sort({ updateTime: -1 }).limit(10) +``` + +**第 4 步:重启训练队列** +```bash +# 重启 FastGPT 应用以重新初始化训练队列 +docker restart fastgpt + +# 检查重启后训练是否恢复 +docker logs fastgpt --tail 50 2>&1 | grep -i "training" +``` + +### 4.3 会话队列/SSE 连接问题 + +**症状** +- 聊天 SSE 流频繁断开 +- 对话状态停留在 generating 不更新 + +**排查步骤** +```bash +# 检查 Redis 连接(SSE 恢复缓冲区依赖 Redis) +redis-cli -h -p 6379 PING + +# 查看 Redis 内存使用 +redis-cli -h -p 6379 INFO memory | grep used_memory_human +``` + +--- + +## 五、部署相关排障 + +### 5.1 服务启动顺序 + +在 docker-compose 中,服务依赖关系如下: + +**关键服务启动顺序** +``` +1. MongoDB (依赖:存储数据) +2. Redis (依赖:缓存/队列) +3. PostgreSQL + pgvector (依赖:向量存储) +4. MinIO (依赖:文件存储) +5. FastGPT 主应用 +6. 代码沙箱 (code-sandbox) +7. MCP 服务器 +8. 插件服务 (plugin) +``` + +**使用 docker-compose 启动** +```bash +# 启动全栈开发环境 +docker-compose -f FastGPT/deploy/dev/docker-compose.yml up -d + +# 验证所有服务健康 +docker-compose -f FastGPT/deploy/dev/docker-compose.yml ps + +# 查看特定服务日志 +docker logs fastgpt-mongo +docker logs fastgpt-redis +``` + +### 5.2 健康检查配置 + +**Docker 健康检查示例** +```yaml +healthcheck: + test: ['CMD', 'pg_isready', '-U', 'username'] + interval: 5s # 检查间隔 + timeout: 5s # 超时时间 + retries: 10 # 重试次数 + start_period: 30s # 启动期限 +``` + +**生产环境建议** +- `interval`: 30s(避免过频检查) +- `retries`: 3-5(容许短暂抖动) +- `start_period`: 根据服务启动时间调整(MongoDB 约 30-60s) + +### 5.3 常见部署问题 + +| 问题 | 原因 | 解决 | +|-----|------|------| +| 容器启动失败 | 资源不足或端口占用 | `docker-compose down && docker-compose up -d` | +| 服务间通信失败 | 网络配置或 DNS 问题 | 检查 docker-compose 中的 networks 配置 | +| 数据卷挂载失败 | 权限或路径问题 | 检查卷权限和路径配置 | +| 环境变量未生效 | docker-compose 缓存 | `docker-compose pull && docker-compose up -d` | + +--- + +## 六、常用 MongoDB 查询 + +### 6.1 聊天数据排查 + +**查询用户的聊天记录** +```javascript +// 查询特定用户的所有聊天 +db.chats.find({ + "tmbId": ObjectId("user_tmb_id") +}).sort({ createTime: -1 }).limit(10) + +// 查询特定应用的聊天 +db.chats.find({ + "appId": ObjectId("app_id") +}).sort({ createTime: -1 }) + +// 查询生成失败的聊天(chatGenerateStatus 是 Number:0=generating, 1=done, 2=error) +db.chats.find({ + "chatGenerateStatus": 2 +}) + +// 查询正在生成的聊天(超过1小时未完成,可能卡住) +db.chats.find({ + "chatGenerateStatus": 0, + "updateTime": { $lt: new Date(Date.now() - 3600000) } +}) +``` + +**查询聊天消息** +```javascript +// 查询特定聊天的所有消息(chatitems 集合,按 time 字段排序) +db.chatitems.find({ + "chatId": "chat_id" +}).sort({ time: 1 }) + +// 查询用户提问和 AI 回复(chatitems 集合使用 obj 字段标记角色) +// ChatRoleEnum: System / Human / AI +db.chatitems.find({ + "chatId": "chat_id", + "obj": { $in: ["Human", "AI"] } +}) +``` + +### 6.2 应用配置查询 + +**查询应用基本信息** +```javascript +// 查询所有应用 +db.apps.find({}, { name: 1, teamId: 1 }).sort({ _id: -1 }) + +// 查询特定应用配置 +db.apps.findOne({ _id: ObjectId("app_id") }) + +// 查询应用版本(app_versions 集合) +db.app_versions.find({ + "appId": ObjectId("app_id") +}).sort({ time: -1 }).limit(5) +``` + +### 6.3 知识库数据排查 + +**查询知识库信息** +```javascript +// 查询所有知识库 +db.datasets.find({ "teamId": ObjectId("team_id") }) + +// 查询知识库训练统计(dataset_trainings 集合) +db.dataset_trainings.aggregate([ + { + $match: { "datasetId": ObjectId("dataset_id") } + }, + { + $group: { + _id: "$datasetId", + totalDocs: { $sum: 1 }, + failedDocs: { $sum: { $cond: [{ $lte: ["$retryCount", 0] }, 1, 0] } } + } + } +]) + +// 查询失败的数据项(errorMsg 在 dataset_trainings 中) +db.dataset_trainings.find({ + "datasetId": ObjectId("dataset_id"), + "retryCount": { $lte: 0 }, + "errorMsg": { $exists: true } +}, { errorMsg: 1, q: 1, lockTime: 1 }) + +// 查询数据记录(dataset_datas 集合) +db.dataset_datas.find({ + "datasetId": ObjectId("dataset_id") +}).limit(10) +``` + +### 6.4 AI 调用记录查询 + +**查询 AI 调用历史** +```javascript +// 查询最近的 LLM 调用记录 +// llm_request_records 集合仅包含: requestId, body, response, createdAt +db.llm_request_records.find().sort({ createdAt: -1 }).limit(10) + +// 查询特定 requestId 的调用详情 +db.llm_request_records.findOne({ + "requestId": "xxx-xxx-xxx" +}) + +// 注:llm_request_records 集合不包含 status/price/modelName/appId 字段。 +// body 和 response 字段存储完整的请求/响应内容(包含模型名、token 用量等) +// 记录会在 N 小时后自动过期(默认 6 小时,由 LLM_REQUEST_TRACKING_RETENTION_HOURS 环境变量控制) +``` + +### 6.5 系统日志查询 + +**查询错误日志** +```javascript +// 查询最近的错误日志(system_logs 集合,排序字段为 time) +db.system_logs.find({ + "level": "3" +}).sort({ time: -1 }).limit(20) + +// 查询包含特定关键字的日志 +db.system_logs.find({ + "text": { $regex: "embedding", $options: "i" } +}).sort({ time: -1 }) + +// 查看所有 warn 及以上级别的最近日志 +db.system_logs.find({ + "level": { $in: ["2", "3"] } +}).sort({ time: -1 }).limit(50) + +// 注意:MongoDB 慢查询日志不会写入 system_logs,仅通过 console.warn 输出。 +// 如需查看慢查询,请检查 Docker/控制台日志: +// docker logs fastgpt 2>&1 | grep "slow query" +``` + +### 6.6 数据维护命令 + +**数据清理** +```javascript +// 删除过期的对话(例如30天前) +db.chats.deleteMany({ + "createTime": { + $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + } +}) + +// 删除某个数据集中的数据 +db.dataset_datas.deleteMany({ + "datasetId": ObjectId("dataset_id") +}) + +// 删除某个数据集的训练记录 +db.dataset_trainings.deleteMany({ + "datasetId": ObjectId("dataset_id") +}) + +// 重建索引(优化性能) +db.chats.reIndex() +db.dataset_datas.reIndex() +``` + +--- + +## 七、快速参考 + +### 诊断工作流程 + +``` +问题出现 + | +1. 查看应用日志 (LOG_LEVEL=debug) + | +2. 检查相关服务连接 (MongoDB/Redis/向量库) + | +3. 验证配置参数 (环境变量、config.json) + | +4. 执行针对性排查 (见各章节) + | +5. 查询相关数据 (MongoDB 查询) + | +6. 进行修复或重试 + | +7. 验证修复结果 +``` + +### 常用快捷命令 + +```bash +# 快速诊断脚本(开发环境) +LOG_LEVEL=debug pnpm dev 2>&1 | tee debug.log + +# 数据库备份 +mongodump --uri="$MONGODB_URI" --out=backup + +# 数据库恢复 +mongorestore --uri="$MONGODB_URI" --dir=backup + +# 清理缓存并重启 +docker-compose down +docker-compose up -d +``` + +--- + +## 八、获取帮助 + +- **官方文档**: https://doc.fastgpt.cn +- **GitHub Issues**: https://github.com/labring/FastGPT/issues +- **Discord 社区**: FastGPT 官方 Discord 频道 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\347\273\223\346\236\204.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\347\273\223\346\236\204.md" new file mode 100644 index 000000000000..2d0e8ff02760 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\347\273\223\346\236\204.md" @@ -0,0 +1,1183 @@ +--- +capability_label: null +doc_type: "08" +doc_label: "数据结构" +generated_at: "2026-05-26T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 数据结构 + +## 一、数据库概述 + +FastGPT 采用多种存储技术的组合方案,各司其职: + +| 存储技术 | 用途 | +|---------|------| +| **MongoDB** | 主业务数据库,存储 App、Chat、Dataset、User 等全部业务实体 | +| **PostgreSQL + pgvector** | 向量数据存储,支持 HNSW 索引的高性能相似度检索(默认向量库) | +| **Milvus** | 可选向量数据库,支持 2.6+ 版本的混合检索(向量 + BM25 全文) | +| **Redis** | 缓存、消息队列(BullMQ 基于 Redis 实现任务调度) | + +**MongoDB 集合命名规范**: +- 集合名使用小写 + 下划线命名(snake_case),如 `dataset_collections`、`agent_skills` +- 每个集合的 CollectionName 常量定义在对应的 `schema.ts` 文件中 +- 注册模型使用 `getMongoModel(collectionName, schema)` 工具函数(见 `FastGPT/packages/service/common/mongo/index.ts`) + +--- + +## 二、MongoDB 核心数据模型 + +### 集合总览 + +| 集合名 | CollectionName 常量 | Schema 文件 | 说明 | +|--------|---------------------|-------------|------| +| `apps` | `AppCollectionName` | `core/app/schema.ts` | 应用 | +| `chats` | `chatCollectionName` | `core/chat/chatSchema.ts` | 对话会话 | +| `chatitems` | `ChatItemCollectionName` | `core/chat/chatItemSchema.ts` | 对话消息 | +| `chat_corrections` | `ChatCorrectionCollectionName` | `core/chat/correction/schema.ts` | 对话纠错记录 | +| `app_versions` | `AppVersionCollectionName` | `core/app/version/schema.ts` | 应用发布版本 | +| `datasets` | `DatasetCollectionName` | `core/dataset/schema.ts` | 知识库 | +| `dataset_collections` | `DatasetColCollectionName` | `core/dataset/collection/schema.ts` | 知识库集合 | +| `dataset_datas` | `DatasetDataCollectionName` | `core/dataset/data/schema.ts` | 知识库数据项 | +| `dataset_data_texts` | `DatasetDataTextCollectionName` | `core/dataset/data/dataTextSchema.ts` | 全文检索文本 | +| `dataset_trainings` | `DatasetTrainingCollectionName` | `core/dataset/training/schema.ts` | 训练队列 | +| `dataset_collection_tags` | `DatasetCollectionTagsName` | `core/dataset/tag/schema.ts` | 集合标签 | +| `dataset_synonyms` | `DatasetSynonymCollectionName` | `core/dataset/synonym/schema.ts` | 同义词文件 | +| `dataset_synonym_mappings` | `DatasetSynonymMappingCollectionName` | `core/dataset/synonym/mappingSchema.ts` | 同义词映射 | +| `agent_skills` | `agentSkillsCollectionName` | `core/agentSkills/schema.ts` | Agent 技能 | +| `agent_skills_versions` | `agentSkillsVersionCollectionName` | `core/agentSkills/schema.ts` | 技能版本 | +| `system_models` | — | — | AI 模型配置 | +| `llm_request_records` | — | — | LLM 请求记录 | + +### App(应用) + +``` +集合名:apps +用途:存储用户创建的 AI 应用,包含工作流节点、边和聊天配置 +索引: + - { teamId, updateTime } + - { teamId, type } + - { scheduledTriggerConfig, scheduledTriggerNextTime }(partial,仅定时触发类) + - { type }、{ deleteTime }、{ name } + +关键字段: + - _id: ObjectId - 应用唯一 ID + - parentId: ObjectId | null - 父文件夹 ID(树形层级) + - teamId: ObjectId - 所属团队 ID(关联 teams 集合) + - tmbId: ObjectId - 创建者团队成员 ID(关联 team_members 集合) + - name: String - 应用名称 + - type: String - 应用类型(folder | toolFolder | simple | chatAgent | workflow(advanced) | workflowTool(plugin) | mcpToolSet(toolSet) | httpToolSet | hidden;已废弃: tool | httpPlugin | assistant) + - version: String - 版本标记(v1 | v2) + - avatar: String - 应用图标路径 + - intro: String - 应用简介 + - templateId: String - 来源模板 ID(从模板创建时有值) + - updateTime: Date - 最后更新时间 + - modules: Array - 工作流节点列表(存储图节点配置) + - edges: Array - 工作流边列表(连接关系) + - chatConfig: Object - 聊天配置(欢迎语、变量、TTS、问题引导等) + - pluginData: Object - 插件专属配置(pluginUniId、apiSchemaStr、customHeaders) + - scheduledTriggerConfig: Object - 定时触发配置(cronString、timezone、defaultPrompt) + - scheduledTriggerNextTime: Date - 下次触发时间 + - inheritPermission: Boolean - 是否继承父级权限 + - favourite: Boolean - 是否收藏 + - quick: Boolean - 是否快速访问 + - deleteTime: Date | null - 软删除时间(null 表示未删除) + +关联关系: + - 自关联(parentId → _id,文件夹树形结构) + - → teams(teamId) + - → team_members(tmbId) + - ← chats(appId) + - ← chatitems(appId) + - ← datasets(通过工作流节点内的 datasetId 引用) +``` + +### ChatSession(对话会话) + +``` +集合名:chats(由 chatCollectionName 常量定义) +用途:存储用户与 App 的每一个对话会话,是 ChatItem 的容器 +索引: + - { chatId } + - { appId, chatId }(唯一) + - { tmbId, appId, deleteTime, top, updateTime } + - { shareId, outLinkUid, updateTime }(partial) + - { appId, updateTime } + - { appId, tmbId, updateTime }(按成员查历史) + - { appId, source, tmbId, updateTime }(API 清除历史) + - { appId, outLinkUid, tmbId }(partial,分享链接清除历史) + - { appId, hasGoodFeedback, updateTime }(partial) + - { appId, hasBadFeedback, updateTime }(partial) + - { appId, hasUnreadGoodFeedback, updateTime }(partial) + - { appId, hasUnreadBadFeedback, updateTime }(partial) + - { appId, errorCount, updateTime }(partial,errorCount > 0) + - { chatGenerateStatus, updateTime }(partial,生成中状态) + - { updateTime, teamId }(定时清理) + - { teamId, updateTime } + +关键字段: + - _id: ObjectId - 会话 MongoDB ID + - chatId: String - 业务层会话 ID(前端传入) + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 发起对话的团队成员 + - appId: ObjectId - 关联的 App + - appVersionId: ObjectId - 关联的 App 版本(可选) + - createTime: Date - 会话创建时间 + - updateTime: Date - 最后消息时间 + - title: String - 会话标题(默认"历史记录") + - customTitle: String - 用户自定义标题 + - top: Boolean - 是否置顶 + - source: String - 来源(test | online | share | api | cronJob | team | feishu | official_account | wecom | wechat | evaluation | mcp) + - sourceName: String - 来源名称 + - shareId: String - 外部分享 ID + - outLinkUid: String - 外链访问用户标识 + - variables: Object - 变量值(键值对) + - variableList: Array - 变量定义列表 + - welcomeText: String - 欢迎语 + - pluginInputs: Array - Plugin 输入参数 + - metadata: Object - 扩展元数据 + - hasGoodFeedback: Boolean - 是否含好评(冗余字段,加速过滤) + - hasBadFeedback: Boolean - 是否含差评 + - hasUnreadGoodFeedback: Boolean - 是否有未读好评 + - hasUnreadBadFeedback: Boolean - 是否有未读差评 + - errorCount: Number - 错误消息数量(冗余字段) + - chatGenerateStatus: Number - 生成状态(generating | done | error) + - hasBeenRead: Boolean - 是否已读 + - searchKey: String - 全文搜索关键词 + - deleteTime: Date | null - 软删除时间 + +关联关系: + - → apps(appId) + - → team_members(tmbId) + - → teams(teamId) + - ← chatitems(chatId) +``` + +### ChatItem(对话消息) + +``` +集合名:chatitems(由 ChatItemCollectionName 常量定义) +用途:存储每条具体的聊天消息,包括用户消息和 AI 回复 +索引: + - { appId, chatId, dataId } + - { correctionId } + - { appId, chatId, deleteTime } + - { appId, chatId, _id }(降序,分页锚点) + - { appId, chatId, obj, _id }(按角色过滤) + +关键字段: + - _id: ObjectId - 消息 MongoDB ID + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 所属团队成员 + - userId: ObjectId - 用户 ID(兼容旧版) + - chatId: String - 所属会话 ID + - dataId: String - 消息业务 ID(Nanoid 24位,前端引用唯一消息的标识) + - appId: ObjectId - 所属 App + - time: Date - 消息时间 + - hideInUI: Boolean - 是否在 UI 隐藏(内部系统消息) + - obj: String - 消息角色(Human | AI | System) + - value: Array - 消息内容数组 + - 文本:{ type: "text", text: { content: string } } + - 文件:{ type: "file", file: { type, name, key, url } } + - 推理:{ type: "reasoning", reasoning: { content: string } } + - 工具:{ type: "tool", tools: [...] } + - 交互:{ type: "interactive", interactive: {...} } + - memories: Object - 节点记忆字段(Field Memory 功能) + - errorMsg: String - 错误信息 + - durationSeconds: Number - AI 回复耗时(秒) + - citeCollectionIds: [String] - 引用的知识库集合 ID 列表 + - userGoodFeedback: String - 用户好评内容 + - userBadFeedback: String - 用户差评内容 + - customFeedbacks: [String] - 自定义反馈列表 + - adminFeedback: Object - 管理员反馈(含 datasetId、collectionId、feedbackDataId、q、a) + - isFeedbackRead: Boolean - 反馈是否已读 + - correctionId: ObjectId - 关联的纠错记录 ID + - deleted: Boolean - 逻辑删除标记 + - deletedAt: Date - 删除时间 + - deletedBy: ObjectId - 删除操作者 + - deleteTime: Date | null - 软删除时间 + +关联关系: + - → apps(appId) + - → team_members(tmbId) + - → chats(chatId,非 ref 外键,String 类型) + - → chat_corrections(correctionId) +``` + +### Dataset(知识库) + +``` +集合名:datasets +用途:存储知识库元信息,包括类型、向量模型配置、分块策略等 +索引: + - { teamId } + - { type } + - { deleteTime } + +关键字段: + - _id: ObjectId - 知识库唯一 ID + - parentId: ObjectId | null - 父文件夹 ID(树形结构) + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 创建者 + - type: String - 知识库类型(folder | dataset | websiteDataset | externalFile | apiDataset | feishu | yuque | database | structureDocument) + - avatar: String - 图标 + - name: String - 知识库名称 + - intro: String - 简介 + - updateTime: Date - 更新时间 + - vectorModel: String - 向量化模型名称(如 text-embedding-3-small) + - agentModel: String - 训练使用的 LLM 模型(如 gpt-4o-mini) + - vlmModel: String - 视觉模型(图片索引用) + - websiteConfig: Object - 网站类型配置(url、selector) + - databaseConfig: Object - 数据库类型配置(clientType、host、port、database、user、password 等) + - chunkSettings: Object - 分块策略配置(见 ChunkSettingsType) + - inheritPermission: Boolean - 是否继承权限 + - permissionEffectScope: String - 权限效果范围(allChildren | onlyDirectChildren) + - synonymFiles: [ObjectId] - 绑定的同义词文件 ID(当前最多 1 个) + - apiDatasetServer: Object - API 数据集服务配置 + - deleteTime: Date | null - 软删除时间 + +关联关系: + - 自关联(parentId → _id) + - → teams(teamId) + - ← dataset_collections(datasetId) + - ← dataset_datas(datasetId) +``` + +### DatasetCollection(知识库集合) + +``` +集合名:dataset_collections +用途:知识库内的文件/资源集合,每个集合对应一个数据来源(文件、网页、API 等) +索引: + - { teamId, fileId } + - { teamId, datasetId, parentId, updateTime } + - { teamId, datasetId, tags } + - { tags.tagId } + - { teamId, datasetId, createTime } + - { datasetId, externalFileId }(唯一,partial) + - { teamId, metadata.relatedImgId } + - { deleteTime } + +关键字段: + - _id: ObjectId - 集合唯一 ID + - parentId: ObjectId | null - 父集合 ID(文件夹结构) + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 创建者 + - datasetId: ObjectId - 所属知识库 + - type: String - 集合类型(folder | virtual | file | link | externalFile | apiFile | images | table) + - name: String - 集合名称 + - tags: Array - 标签(支持旧版 ObjectId 数组和新版 { tagId, value } 对象数组) + - createTime: Date - 创建时间 + - updateTime: Date - 更新时间 + - fileId: String - GridFS 或 S3 文件 key(本地文件类型) + - rawLink: String - 原始网页链接(网页类型) + - apiFileId: String - API 文件 ID + - externalFileId: String - 外部文件 ID + - rawTextLength: Number - 原始文本长度 + - hashRawText: String - 原始文本哈希(用于变更检测) + - metadata: Object - 扩展元数据(webPageSelector、relatedImgId 等) + - forbid: Boolean - 是否禁用(禁用后不参与检索) + - inheritPermission: Boolean - 继承权限 + - permissionEffectScope: String - 权限效果范围 + - customPdfParse: Boolean - 是否使用自定义 PDF 解析 + - tableSchema: Object - 数据库表结构(数据库类型集合,含 tableName、columns、foreignKeys 等) + - deleteTime: Date | null - 软删除时间 + - [ChunkSettings]: 继承自 Dataset 的所有分块策略字段(trainingType、chunkSize 等) + +关联关系: + - → datasets(datasetId) + - → team_members(tmbId) + - ← dataset_datas(collectionId) +``` + +### DatasetData(知识库数据项) + +``` +集合名:dataset_datas +用途:存储知识库中的每条知识片段(chunk),包含原始问答文本和向量索引引用 +索引: + - { teamId, datasetId, collectionId, chunkIndex, updateTime } + - { teamId, datasetId, collectionId, indexes.dataId }(按向量 ID 反查) + - { rebuilding, teamId, datasetId } + - { updateTime } + +关键字段: + - _id: ObjectId - 数据唯一 ID + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 创建者 + - datasetId: ObjectId - 所属知识库 + - collectionId: ObjectId - 所属集合 + - q: String - 问题/主文本(大 chunk 或问题) + - a: String - 答案/补充内容 + - imageId: String - 关联图片 ID + - imageDescMap: Object - 图片描述映射(图片 ID → 描述文本) + - history: Array - 修改历史(含 q、a、updateTime) + - metadata: Object - 扩展元数据 + - indexes: Array - 向量索引列表,每项包含: + - type: String - 索引类型(default | custom | summary | question | image | column_des_index | column_val_index | hype | small2Big | correction) + - dataId: String - 向量数据库中的记录 ID(pg 的 bigint ID,milvus 的 int64 ID) + - text: String - 标准化后的索引文本 + - synonymMetadata: Object - 同义词转换元数据 + - synId: Number - 合成索引对 ID(0-4) + - chunkIndex: Number - chunk 在集合中的序号 + - updateTime: Date - 更新时间 + - rebuilding: Boolean - 是否正在重建向量 + - synonymProcessing: String - 同义词处理状态(standardize | restore) + - synonymFileIds: [String] - 需应用的同义词文件 ID + +关联关系: + - → datasets(datasetId) + - → dataset_collections(collectionId) + - → 向量数据库(indexes[].dataId,跨库引用) +``` + +### AgentSkill(Agent 技能) + +``` +集合名:agent_skills(由 agentSkillsCollectionName 常量定义) +用途:存储 Agent 技能的元信息,支持个人/系统技能的文件夹层级管理 +索引: + - { name, description }(全文索引) + - { source, teamId, deleteTime, createTime } + - { category } + - { parentId, teamId, deleteTime } + - { parentId, name, teamId, deleteTime }(唯一,partial:deleteTime=null 且 source=personal) + +关键字段: + - _id: ObjectId - 技能唯一 ID + - parentId: ObjectId | null - 父文件夹 ID + - type: String - 节点类型(skill | folder) + - inheritPermission: Boolean - 继承权限 + - source: String - 来源(personal | system) + - name: String - 技能名称 + - description: String - 技能描述 + - author: String - 作者 + - category: [String] - 分类标签(枚举值: search | tool | coding | data | analysis | communication | other) + - config: Object - 技能配置数据 + - avatar: String - 图标 URL + - teamId: ObjectId - 所属团队(个人技能) + - tmbId: ObjectId - 所属成员 + - createTime: Date - 创建时间 + - updateTime: Date - 更新时间 + - deleteTime: Date | null - 软删除时间 + - currentVersion: Number - 当前版本号 + - versionCount: Number - 总版本数 + - currentStorage: Object - 当前版本存储信息(bucket、key、size) + +关联关系: + - 自关联(parentId → _id) + - → teams(teamId) + - ← agent_skill_versions(skillId) +``` + +### AgentSkillVersion(技能版本) + +``` +集合名:agent_skill_versions(由 agentSkillsVersionCollectionName 常量定义) +用途:存储技能的每个历史版本,版本内容以文件形式存储在对象存储(S3/MinIO)中 +索引: + - { skillId, isDeleted, version }(降序,列表查询) + - { skillId, isActive }(快速查活跃版本) + - { skillId, version }(唯一,防重复版本号) + +关键字段: + - _id: ObjectId - 版本唯一 ID + - skillId: ObjectId - 所属技能 ID + - tmbId: ObjectId - 发布版本的团队成员 + - version: Number - 版本号(递增整数) + - versionName: String - 版本名称/描述 + - storage: Object - 版本文件存储信息 + - bucket: String - 存储桶名称 + - key: String - 文件路径 key + - size: Number - 文件大小(字节) + - checksum: String - 文件校验和 + - importSource: Object - 导入来源信息(originalFilename、importedAt) + - isActive: Boolean - 是否为当前激活版本 + - isDeleted: Boolean - 是否已删除 + - createdAt: Date - 版本创建时间 + +关联关系: + - → agent_skills(skillId) + - → team_members(tmbId) +``` + +### AIModel(AI 模型配置) + +``` +集合名:system_models +用途:存储系统级 AI 模型配置,每条记录对应一个可用模型 +索引: + - { model }(唯一) + +关键字段: + - _id: ObjectId - 记录唯一 ID + - model: String - 模型标识符(如 gpt-4o、text-embedding-3-small),唯一约束 + - metadata: Object - 模型元数据配置(完整的模型配置对象,结构由 AI Provider 定义) + +关联关系: + - 无直接外键;通过 model 字符串被 Dataset.vectorModel、App.chatConfig 等字段引用 +``` + +### AIUsageRecord(LLM 请求记录) + +``` +集合名:llm_request_records +用途:存储 LLM API 请求和响应的完整记录,用于调试和问题追踪,有 TTL 自动清理 +索引: + - { requestId }(唯一) + - createdAt 字段配置 TTL(默认保留 6 小时,由环境变量 LLM_REQUEST_TRACKING_RETENTION_HOURS 控制) + +关键字段: + - _id: ObjectId - 记录唯一 ID + - requestId: String - 请求唯一标识(唯一约束) + - body: Mixed - 完整的 LLM 请求体(原始 OpenAI 格式) + - response: Mixed - 完整的 LLM 响应体 + - createdAt: Date - 请求时间(同时作为 TTL 过期字段) + +关联关系: + - 独立记录表,无外键关联 +``` + +### DatasetTraining(训练队列) + +``` +集合名:dataset_trainings(DatasetTrainingCollectionName 常量) +用途:训练队列核心表,每条记录代表一个待执行的训练任务(chunk、QA、索引生成等)。Worker 通过 mode+retryCount+lockTime 排序拉取任务 +索引: + - { teamId, datasetId }(锁定训练数据、删除训练数据) + - { mode, retryCount, lockTime, weight }(Worker 拉取任务排序) + - { expireAt }(TTL,7 天后自动删除) + +关键字段: + - _id: ObjectId - 任务唯一 ID + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 创建者 + - datasetId: ObjectId - 所属知识库 + - collectionId: ObjectId - 所属集合 + - billId: String - 账单 ID + - mode: TrainingModeEnum - 训练模式(chunk | qa | auto | image | hype | small2Big | ...) + - dataId: ObjectId - 关联的 DatasetData._id(可选) + - q: String - 问题/主文本 + - a: String - 答案/补充内容 + - imageId: String - 关联图片 ID + - imageDescMap: Object - 图片描述映射 + - chunkIndex: Number - chunk 序号 + - indexSize: Number - 索引大小 + - weight: Number - 权重(排序用,越大越优先) + - indexes: Array - 待生成的索引列表(不含 dataId,执行时分配) + - dataMetadata: Object - 扩展元数据 + - expireAt: Date - 过期时间(默认 7 天,TTL 索引) + - lockTime: Date - 锁定时间(Worker 抢占任务时更新) + - retryCount: Number - 剩余重试次数(默认 5,耗尽的记录视为 error) + - errorMsg: String - 错误信息 + +关联关系: + - → datasets(datasetId) + - → dataset_collections(collectionId) + - → dataset_datas(dataId,可选) +``` + +### DatasetCollectionTags(集合标签) + +``` +集合名:dataset_collection_tags(DatasetCollectionTagsName 常量) +用途:知识库级别的标签定义,每个标签属于一个知识库,可被集合引用(通过 CollectionTagValueType.tagId) +索引: + - { teamId, datasetId, tag } + +关键字段: + - _id: ObjectId - 标签唯一 ID + - teamId: ObjectId - 所属团队 + - datasetId: ObjectId - 所属知识库 + - tag: String - 标签名称 + - tagType: String - 标签类型(string | number | datetime) + +关联关系: + - → datasets(datasetId) + - ← dataset_collections(tags.tagId 引用) +``` + +### ChatCorrection(对话纠错) + +``` +集合名:chat_corrections(ChatCorrectionCollectionName 常量) +用途:存储用户对 AI 回复的纠错记录,包含问题、原始回答、修正后回答和引用的知识库片段 +索引: + - { appId } + - { appId, chatId, dataId } + - { chatId, appId, updateTime } + - { teamId, updateTime } + +关键字段: + - _id: ObjectId - 纠错记录唯一 ID + - dataId: String - 关联的 ChatItem.dataId + - teamId: ObjectId - 所属团队 + - tmbId: ObjectId - 纠错者 + - userId: ObjectId - 用户 ID + - chatId: String - 所属会话 ID + - appId: ObjectId - 所属 App + - correctionData: Object - 纠错数据 + - correctionMode: CorrectionModeEnum - 纠错模式 + - question: String - 用户问题 + - rawAnswer: String - 原始 AI 回答 + - correctedAnswer: String - 修正后回答 + - correctedQuoteList: Array - 修正后引用列表 + - indexs: Array - 索引信息 + - updateTime: Date - 更新时间 + +关联关系: + - → apps(appId) + - ← chatitems(correctionId 引用) +``` + +### AppVersion(应用版本) + +``` +集合名:app_versions(AppVersionCollectionName 常量) +用途:存储应用的每个发布版本,保存版本发布时的 nodes、edges、chatConfig 快照 +索引: + - { appId, time }(降序) + +关键字段: + - _id: ObjectId - 版本唯一 ID + - tmbId: String - 发布者 + - appId: ObjectId - 所属 App + - time: Date - 发布时间 + - nodes: Array - 版本发布时的工作流节点快照 + - edges: Array - 版本发布时的连线快照 + - chatConfig: Object - 版本发布时的聊天配置快照 + - isPublish: Boolean - 是否为正式发布版本 + - isAutoSave: Boolean - 是否为自动保存版本 + - versionName: String - 版本名称 + +关联关系: + - → apps(appId) + - ← chats(appVersionId 引用,会话关联到特定版本) +``` + +### DatasetSynonym(同义词文件) + +``` +集合名:dataset_synonyms(DatasetSynonymCollectionName 常量) +用途:存储上传到知识库的同义词 CSV 文件元数据 +索引: + - { datasetId } + - { teamId } + - { fileId } + - { teamId, datasetId } + - { uploadTime }(降序) + +关键字段: + - _id: ObjectId - 记录唯一 ID + - teamId: ObjectId - 所属团队 + - datasetId: ObjectId - 所属知识库 + - fileName: String - 文件名 + - fileId: String - S3/MinIO 文件 key + - size: Number - 文件大小(字节) + - uploadTime: Date - 上传时间 + - uploaderId: ObjectId - 上传者 + +关联关系: + - → datasets(datasetId) + - ← dataset_synonym_mappings(synonymFileId) +``` + +### DatasetSynonymMapping(同义词映射) + +``` +集合名:dataset_synonym_mappings(DatasetSynonymMappingCollectionName 常量) +用途:存储标准化词到同义词数组的映射,支持 MongoDB 全文检索 +索引: + - { allTerms }(text 全文索引,权重 10) + - { datasetId, standardizedTerm } + - { synonymFileId } + - { teamId, datasetId, standardizedTerm } + - { createdTime }(降序) + +关键字段: + - _id: ObjectId - 记录唯一 ID + - teamId: ObjectId - 所属团队 + - datasetId: ObjectId - 所属知识库 + - synonymFileId: ObjectId - 关联的同义词文件 ID + - standardizedTerm: String - 标准化词 + - synonymTerms: String[] - 同义词数组 + - allTerms: String - 组合搜索字段("{标准词} {同义词1} {同义词2}...") + - createdTime: Date - 创建时间 + - updatedTime: Date - 更新时间 +``` + +--- + +## 三、Redis 数据结构 + +### Redis 基础设施 + +Redis 是 FastGPT 的缓存和消息队列基础设施。代码位于 `packages/service/common/redis/`。 + +**连接架构**: + +| 文件 | 说明 | +|------|------| +| `config.ts` | Redis 配置解析,支持 standalone 和 cluster 双模式,从环境变量读取 | +| `connection.ts` | 连接管理,提供三种连接工厂函数 | +| `cluster.ts` | Cluster 模式下的 key 扫描工具 | +| `cache.ts` | 缓存读写工具 | +| `index.ts` | 统一导出入口 | + +**三种 Redis 连接**: + +| 连接函数 | 用途 | maxRetriesPerRequest | +|----------|------|---------------------| +| `getGlobalRedisConnection()` | 全局缓存连接,带 keyPrefix 自动前缀 | 3 | +| `newQueueRedisConnection()` | BullMQ Queue 连接 | 3 | +| `newWorkerRedisConnection()` | BullMQ Worker 连接 | null(必需) | + +**Key 前缀规范**: + +``` +FASTGPT_REDIS_PREFIX = '{fastgpt}:' +``` + +所有业务 Key 自动添加此前缀。`{fastgpt}` 是 Redis Cluster 的 hash tag,确保同一业务的所有 key 落在同一 slot。 + +**Redis 配置环境变量**: + +| 环境变量 | 说明 | +|----------|------| +| `REDIS_URL` | standalone 模式连接串(默认 `redis://localhost:6379`) | +| `REDIS_CLUSTER_NODES` | cluster 模式节点列表(格式 `host1:port1,host2:port2`) | +| `REDIS_PASSWORD` | Redis 密码 | +| `REDIS_CLUSTER_OPTIONS` | cluster 模式额外选项(JSON) | + +**连接可靠性保障**: +- 指数退避重试策略(无限重试,最大间隔 2s) +- `READONLY`/`ECONNREFUSED`/`ETIMEDOUT`/`ECONNRESET` 错误自动重连 +- 连接超时 10s,支持离线命令队列 + +--- + +## 四、消息队列(BullMQ) + +BullMQ 是基于 Redis 的任务队列系统,代码位于 `packages/service/common/bullmq/`。 + +### QueueNames 枚举(19 个队列) + +```typescript +export enum QueueNames { + // === 数据集同步/训练 === + datasetSync = 'datasetSync', // 数据集同步 + rerankTrainDataGenerate = 'rerankTrainDataGenerate', // Rerank 训练数据生成 + rerankTrainTask = 'rerankTrainTask', // Rerank 训练任务 + embeddingTrainDataGenerate = 'embeddingTrainDataGenerate', // Embedding 训练数据生成 + embeddingTrainTask = 'embeddingTrainTask', // Embedding 训练任务 + + // === 评测 === + evalDatasetDataQuality = 'evalDatasetDataQuality', // 数据质量评估 + evalDatasetDataSynthesize = 'evalDatasetDataSynthesize', // 数据合成评估 + evalTask = 'evalTask', // 评测任务 + evalTaskItem = 'evalTaskitem', // 评测任务项 + evaluationSummary = 'evaluationSummary', // 评测汇总 + evaluation = 'evaluation', // 评测 + + // === 文件操作 === + s3FileDelete = 's3FileDelete', // S3 文件删除 + collectionUpdate = 'collectionUpdate', // 集合更新 + + // === 删除队列 === + datasetDelete = 'datasetDelete', // 知识库删除 + collectionDelete = 'collectionDelete', // 集合删除 + appDelete = 'appDelete', // 应用删除 + teamDelete = 'teamDelete', // 团队删除 + + // === 发布 === + wechatPoll = 'wechatPoll', // 微信公众号轮询 + + /** @deprecated */ + websiteSync = 'websiteSync' // 网站同步(已废弃) +} +``` + +### 架构设计 + +- **Queue/Worker 全局单例**: 通过 `global.queues` 和 `global.workers` Map 维护全局单例 +- **getQueue(name, opts)**: 创建或复用 Queue 实例 +- **getWorker(name, processor, opts)**: 创建或复用 Worker 实例,Worker 关闭时自动重建 +- **Cluster 模式**: Cluster 模式下 Queue/Worker 使用 `{bull}` hash tag prefix 确保 key 在同一 slot +- **Worker 配置**: `lockDuration=600s`, `stalledInterval=30s`, `maxStalledCount=3`, 完成后立即删除 job + +--- + +## 五、向量数据结构 + +### PGVector 表结构 + +FastGPT 使用 PGVector 扩展在 PostgreSQL 中存储向量数据,共三张表: + +**主知识库向量表(`modeldata` 表名由常量 DatasetVectorTableName 定义)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGSERIAL PK | 自增主键,对应 DatasetData.indexes[].dataId | +| vector | VECTOR(1536) / HALFVEC(1536) | 向量数据(维度 1536,可选半精度节省空间) | +| team_id | VARCHAR(50) | 团队 ID | +| dataset_id | VARCHAR(50) | 知识库 ID | +| collection_id | VARCHAR(50) | 集合 ID | +| createtime | TIMESTAMP | 创建时间 | + +索引: +- HNSW 向量索引(`vector_ip_ops` 内积度量,M 和 ef_construction 可配置) +- BTree 联合索引(team_id, dataset_id, collection_id) +- BTree 时间索引(createtime) + +**数据库列描述向量表(DBDatasetVectorTableName)** + +在主表基础上增加 `column_des_index VARCHAR(1024)` 字段,用于 Text2SQL 场景中存储数据库表列的描述信息向量。 + +**数据库列值示例向量表(DBDatasetValueVectorTableName)** + +在主表基础上增加 `column_val_index VARCHAR(1024)` 字段,用于 Text2SQL 场景中存储数据库列的示例值向量。 + +### Milvus 集合结构 + +Milvus 使用与 PGVector 对应的相同三个集合,字段设计如下: + +**基础字段(三个集合共用)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int64 PK | 手动指定 ID,与 MongoDB DatasetData.indexes[].dataId 对应 | +| vector | FloatVector(1536) | 稠密向量,维度 1536 | +| teamId | VarChar(64) | 团队 ID | +| datasetId | VarChar(64) | 知识库 ID | +| collectionId | VarChar(64) | 集合 ID | +| createTime | Int64 | 创建时间戳(毫秒) | +| text | VarChar(65535) | 原始文本(Milvus 2.6+ 支持,用于 BM25 全文检索输入) | +| sparse | SparseFloatVector | BM25 稀疏向量(Milvus 2.6+ 自动生成,用于全文检索) | +| metadata | JSON | 扩展元数据(Milvus 2.6+) | + +**扩展字段(DBDatasetVectorTableName)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| columnDesIndex | VarChar(1024) | 数据库列描述文本 | + +**扩展字段(DBDatasetValueVectorTableName)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| columnValIndex | VarChar(1024) | 数据库列示例值文本 | + +**Milvus 索引配置** + +| 字段 | 索引类型 | 度量 | 参数 | +|------|---------|------|------| +| vector | HNSW | IP(内积) | M=配置值, ef_construction=配置值 | +| teamId | Trie | - | 前缀树,字符串过滤 | +| datasetId | Trie | - | 前缀树,字符串过滤 | +| collectionId | Trie | - | 前缀树,字符串过滤 | +| createTime | STL_SORT | - | 时间范围过滤 | +| sparse | SPARSE_INVERTED_INDEX | BM25 | DAAT_MAXSCORE, k1=1.2, b=0.75 | + +--- + +## 六、数据关系图(Mermaid) + +```mermaid +erDiagram + TEAMS ||--o{ APPS : "teamId" + TEAMS ||--o{ DATASETS : "teamId" + TEAMS ||--o{ CHATS : "teamId" + TEAMS ||--o{ AGENT_SKILLS : "teamId" + + TEAM_MEMBERS ||--o{ APPS : "tmbId" + TEAM_MEMBERS ||--o{ CHATS : "tmbId" + TEAM_MEMBERS ||--o{ CHATITEMS : "tmbId" + TEAM_MEMBERS ||--o{ DATASETS : "tmbId" + TEAM_MEMBERS ||--o{ AGENT_SKILL_VERSIONS : "tmbId" + + APPS ||--o{ CHATS : "appId" + APPS ||--o{ CHATITEMS : "appId" + APPS }o--o| APPS : "parentId(文件夹)" + + CHATS ||--o{ CHATITEMS : "chatId" + + DATASETS ||--o{ DATASET_COLLECTIONS : "datasetId" + DATASETS ||--o{ DATASET_DATAS : "datasetId" + DATASETS }o--o| DATASETS : "parentId(文件夹)" + + DATASET_COLLECTIONS ||--o{ DATASET_DATAS : "collectionId" + DATASET_COLLECTIONS }o--o| DATASET_COLLECTIONS : "parentId(文件夹)" + + DATASET_DATAS ||--o{ VECTOR_DB : "indexes[].dataId(跨库引用)" + + AGENT_SKILLS ||--o{ AGENT_SKILL_VERSIONS : "skillId" + AGENT_SKILLS }o--o| AGENT_SKILLS : "parentId(文件夹)" + + APPS { + ObjectId _id + String name + String type + Array modules + Array edges + Object chatConfig + } + + CHATS { + ObjectId _id + String chatId + ObjectId appId + String source + Object variables + } + + CHATITEMS { + ObjectId _id + String chatId + String dataId + String obj + Array value + } + + DATASETS { + ObjectId _id + String name + String type + String vectorModel + String agentModel + } + + DATASET_COLLECTIONS { + ObjectId _id + ObjectId datasetId + String type + String name + String fileId + } + + DATASET_DATAS { + ObjectId _id + ObjectId collectionId + String q + String a + Array indexes + } + + AGENT_SKILLS { + ObjectId _id + String name + String source + Number currentVersion + } + + AGENT_SKILL_VERSIONS { + ObjectId _id + ObjectId skillId + Number version + Boolean isActive + } +``` + +--- + +## 七、重要类型定义 + +### App 相关类型 + +**`AppSchema`**(`FastGPT/packages/global/core/app/type.d.ts`) +```typescript +type AppSchema = { + _id: string; + parentId?: ParentIdType; // 父文件夹 ID(树形层级) + teamId: string; + tmbId: string; + type: AppTypeEnum; // folder|toolFolder|simple|chatAgent|workflow(advanced)|workflowTool(plugin)|mcpToolSet(toolSet)|httpToolSet|hidden + version?: 'v1' | 'v2'; + name: string; + avatar: string; + intro: string; // 应用简介 + templateId?: string; // 来源模板 ID + updateTime: Date; + modules: StoreNodeItemType[]; // 工作流节点列表 + edges: StoreEdgeItemType[]; // 工作流连线列表 + pluginData?: { nodeVersion, pluginUniId, apiSchemaStr, customHeaders }; + chatConfig: AppChatConfigType; // 聊天配置 + scheduledTriggerConfig?: AppScheduledTriggerConfigType; + scheduledTriggerNextTime?: Date; // 下次定时触发时间 + inheritPermission?: boolean; // 是否继承父级权限 + favourite?: boolean; // 是否收藏 + quick?: boolean; // 是否快速访问 + deleteTime?: Date | null; // 软删除 + + /** @deprecated */ + defaultPermission?: number; + /** @deprecated */ + inited?: boolean; + /** @deprecated */ + teamTags: string[]; +}; +``` + +**`AppChatConfigType`**(聊天配置) +```typescript +type AppChatConfigType = { + welcomeText?: string; // 欢迎语 + variables?: VariableItemType[]; // 用户填写的变量 + autoExecute?: AppAutoExecuteConfigType; // 自动执行配置 + questionGuide?: AppQGConfigType; // 问题引导 + ttsConfig?: AppTTSConfigType; // 文字转语音 + whisperConfig?: AppWhisperConfigType; // 语音输入 + chatInputGuide?: ChatInputGuideConfigType; // 输入引导 + fileSelectConfig?: AppFileSelectConfigType; // 文件选择配置 + entryPoints?: EntryPointItemType[]; // 对话入口点 + instruction?: string; // Plugin 说明 +}; +``` + +**`StoreNodeItemType`**(工作流存储节点,`FastGPT/packages/global/core/workflow/type/node.ts`) +```typescript +// StoreNodeItemType = FlowNodeCommonTypeSchema + nodeId + position +// FlowNodeCommonTypeSchema 字段(共 23 个基础字段 + 2 个扩展字段): +type StoreNodeItemType = { + // === 基础标识 === + nodeId: string; // 节点唯一 ID + flowNodeType: FlowNodeTypeEnum; // 节点类型(aiChat | datasetSearch | httpRequest | ...) + name: string; // 节点名称 + intro?: string; // 节点简介(模板列表展示) + avatar?: string; // 节点图标 + avatarLinear?: string; // 节点线性图标 + colorSchema?: NodeColorSchemaEnum; // 颜色主题 + + // === 版本信息 === + version?: string; // 版本号 + versionLabel?: string; // 版本标签(仅 UI 展示) + isLatestVersion?: boolean; // 是否最新版本(仅 UI 展示) + + // === 画布位置 === + position?: { x: number; y: number }; + + // === 端口配置 === + inputs: FlowNodeInputItemType[]; // 输入端口配置列表 + outputs: FlowNodeOutputItemType[]; // 输出端口配置列表 + + // === 运行时配置 === + catchError?: boolean; // 是否捕获错误继续执行 + showStatus?: boolean; // 聊天响应中是否展示步骤状态 + abandon?: boolean; // 是否废弃节点 + + // === 工具/插件配置 === + parentNodeId?: string; // 父节点 ID(工具场景) + toolDescription?: string; // 工具描述 + toolConfig?: NodeToolConfigType; // 工具配置(mcp/http/system tools) + pluginId?: string; // 插件 ID + isFolder?: boolean; // 是否为文件夹节点 + pluginData?: ToolDataSchema; // 插件元数据(diagram, userGuide, status 等) + + // === 计费(计算字段,不存储) === + currentCost?: number; + systemKeyCost?: number; + hasTokenFee?: boolean; + hasSystemSecret?: boolean; +}; +``` + +### 聊天消息类型 + +**`ChatItemValueItemType`**(消息内容项,支持多种类型) +```typescript +// 用户消息内容 +type UserChatItemValueItemType = { + type: 'text' | 'file'; + text?: { content: string }; + file?: { type: ChatFileTypeEnum; name?: string; key?: string; url: string }; +}; + +// AI 回复内容(多模态) +type AIChatItemValueItemType = { + type: 'text' | 'reasoning' | 'tool' | 'interactive'; + text?: { content: string }; + reasoning?: { content: string }; // 思考过程(Reasoning 模型) + tools?: ToolModuleResponseItemType[]; // 工具调用结果 + interactive?: WorkflowInteractiveResponseType; // 交互节点响应 +}; +``` + +### 知识库检索参数类型 + +**`AppDatasetSearchParamsType`**(`FastGPT/packages/global/core/app/type.d.ts`) +```typescript +type AppDatasetSearchParamsType = { + searchMode: DatasetSearchModeEnum; // embedding | fullTextRecall | mixedRecall + limit?: number; // 最大返回 token 数 + similarity?: number; // 最低相似度阈值 + embeddingWeight?: number; // 向量检索权重(混合模式) + embeddingModel?: string; // 查询向量化模型 + usingReRank?: boolean; // 是否开启 Rerank + rerankModel?: string; // Rerank 模型 + rerankMethod?: RerankMethodEnum; // Rerank 方法 + rerankWeight?: number; // Rerank 结果权重 + datasetSearchUsingExtensionQuery?: boolean; // 是否扩展查询 + datasetSearchExtensionModel?: string; // 查询扩展 LLM 模型 + collectionFilterMatch?: string; // 集合标签过滤表达式 + retrievalMode?: DatasetRetrievalModeEnum; // 单轮 | 多轮智能检索 + agenticSearchLLMModel?: string; // 多轮检索 LLM 模型 + agenticSearchRerankModel?: string; // 多轮检索 Rerank 模型 + agenticSearchReasoning?: boolean; // 是否输出思考过程 +}; +``` + +**`ChunkSettingsType`**(知识库分块策略配置,`FastGPT/packages/global/core/dataset/type.d.ts`) +```typescript +type ChunkSettingsType = { + trainingType?: DatasetCollectionDataProcessModeEnum; // chunk | qa | imageParse | ... + + // === 分块触发 === + chunkTriggerType?: ChunkTriggerConfigTypeEnum; // minSize | forceChunk | maxSize + chunkTriggerMinSize?: number; // 最小触发分块 size + + // === 数据增强 === + dataEnhanceCollectionName?: boolean; // 自动添加集合名称到数据 + + // === 索引增强 === + imageIndex?: boolean; // 图片索引 + autoIndexes?: boolean; // 自动生成索引 + indexPrefixTitle?: boolean; // 索引前缀标题 + hypeIndexes?: boolean; // 假设性问题索引(HyDE) + small2bigIndexes?: boolean; // 小块关联大块索引 + syntheticIndex?: boolean; // 合成索引 + hypeIndexPrompt?: string; // 假设性问题生成 Prompt + small2bigConfig?: small2bigConfigType; // 小块关联大块配置 + autoIndexesPrompt?: string; // 自动索引生成 Prompt + imageIndexPrompt?: string; // 图片索引生成 Prompt + + // === 分块参数 === + chunkSettingMode?: ChunkSettingModeEnum; // auto | custom(系统参数 | 自定义) + chunkSplitMode?: DataChunkSplitModeEnum; // paragraph | size | char + // 段落分割 + paragraphChunkAIMode?: ParagraphChunkAIModeEnum; // auto | force | forbid + paragraphChunkDeep?: number; // 段落深度 + paragraphChunkMinSize?: number; // 段落最小大小(过小则合并) + // 按大小分割 + chunkSize?: number; // 分块大小(Token 数) + // 按字符分割 + chunkSplitter?: string; // 自定义分隔符 + indexSize?: number; // 索引大小 + + qaPrompt?: string; // QA 生成 Prompt +}; +``` + +**`SearchDataResponseItemType`**(知识库检索返回项,`FastGPT/packages/global/core/dataset/type.d.ts`) +```typescript +type SearchDataResponseItemType = { + id: string; // DatasetData._id + datasetId: string; + collectionId: string; + sourceName: string; + sourceId?: string; + q: string; // 主文本 + a?: string; // 补充文本 + chunkIndex: number; + updateTime: Date; + score: { type: SearchScoreTypeEnum; value: number; index: number }[]; // 多路召回分数 + retrievalRank?: number; // 进入 Reranker 前的排名 + synonymMappings?: SynonymMappingForPrompt[]; // 同义词映射 +}; +``` + +--- + +## 八、数据库变更记录 + +### MongoDB 变更管理 + +FastGPT 使用 Mongoose 管理 MongoDB schema,索引同步通过 `syncMongoIndex` 函数在模型加载时自动执行。 + +**索引同步** (`FastGPT/packages/service/common/mongo/index.ts`): +- 每个模型注册时自动调用 `model.syncIndexes({ background: true })` +- 通过 `SYNC_INDEX=0` 环境变量可禁用自动同步 +- test 环境和 phase-production-build 阶段跳过同步 + +**软删除策略**: +- 大部分业务表使用 `deleteTime: Date | null` 字段实现软删除 +- `null` 表示未删除,非 null 值为删除时间 +- 查询时需过滤 `deleteTime: null`(或通过 partial index 自动过滤) + +**版本兼容**: +- Schema 中标记 `@deprecated` 的字段保留但不再使用 +- 新增字段通过 `{ default: ... }` 或 optional 保证向后兼容 + +--- + +## 九、如何添加新表 + +### 添加 MongoDB 新集合的步骤 + +以添加一个 `user_profiles` 集合为例: + +**1. 定义 TypeScript 类型**(在 `packages/global/` 对应的 type 文件中): +```typescript +// packages/global/.../type.ts +export type UserProfileSchemaType = { + _id: string; + teamId: string; + userId: string; + nickname: string; + avatar: string; + createTime: Date; + updateTime: Date; +}; +``` + +**2. 创建 Mongoose Schema 和 Model**(在 `packages/service/` 对应的目录): +```typescript +// packages/service/core/user/profile/schema.ts +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +const { Schema } = connectionMongo; +import type { UserProfileSchemaType } from '@fastgpt/global/.../type'; + +export const UserProfileCollectionName = 'user_profiles'; + +const UserProfileSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: 'teams', + required: true + }, + userId: { + type: Schema.Types.ObjectId, + ref: 'users', + required: true + }, + nickname: { type: String, default: '' }, + avatar: { type: String, default: '' }, + createTime: { type: Date, default: () => new Date() }, + updateTime: { type: Date, default: () => new Date() } +}); + +// 索引定义 +try { + UserProfileSchema.index({ userId: 1 }, { unique: true }); + UserProfileSchema.index({ teamId: 1, createTime: -1 }); +} catch (error) { + console.log('UserProfile index error:', error); +} + +export const MongoUserProfile = getMongoModel( + UserProfileCollectionName, + UserProfileSchema +); +``` + +**3. 关键要点**: + +| 要点 | 说明 | +|------|------| +| **CollectionName 常量** | 每个 schema 文件导出 `XxxCollectionName` 常量,集中管理集合名 | +| **getMongoModel** | 统一的模型注册函数,自动处理 model 复用、中间件注入、索引同步 | +| **ref 引用** | 外键字段使用 `ref: 'collection_name'`,关联已有的 CollectionName 常量 | +| **索引定义** | 用 `try/catch` 包裹,避免重复定义报错(开发热更新场景) | +| **soft delete** | 推荐使用 `deleteTime: Date | null` 模式实现软删除 | +| **addCommonMiddleware** | `getMongoModel` 自动注入慢查询日志(>500ms 警告,>2s 严重警告)、ObjectId 自动转 string | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..28d245638015 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,159 @@ +--- +capability_label: 数据集 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — API索引 + +API 定义文件:`projects/app/src/web/core/dataset/api.ts`(约 654 行),部分功能由子模块 API 文件提供(`api/collection.ts`、`api/file.ts`、`api/collaborator.ts`、`api/training.ts`)。 + +## 数据集 CRUD + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/list` | POST | 获取数据集列表(可分页) | `api.ts:127` → 列表页 context | 数据集→数据集列表→加载时调用;搜索/分页时调用 | +| `/core/dataset/listWithChildren` | POST | 获取数据集列表(含子节点) | `api.ts:130` → 列表页 context | 数据集→数据集列表→加载文件夹结构时调用 | +| `/core/dataset/detail` | GET | 获取数据集详情 | `api.ts:145` → `datasetPageContext.tsx:107` | 数据集→数据集详情→加载时调用;轮询同步状态时调用 | +| `/core/dataset/create` | POST | 创建新数据集 | `api.ts:147` → 创建弹窗组件 | 数据集→数据集列表→创建数据集→提交表单时调用 | +| `/core/dataset/createWithFiles` | POST | 创建数据集并上传文件 | `api.ts:150` → 创建弹窗组件 | 数据集→数据集列表→创建数据集(含文件)→提交时调用 | +| `/core/dataset/update` | PUT | 更新数据集信息 | `api.ts:153` → `datasetPageContext.tsx:116` | 数据集→数据集列表→编辑时调用;数据集详情→修改配置时调用 | +| `/core/dataset/delete` | DELETE | 删除数据集 | `api.ts:155` → 列表页 | 数据集→数据集列表→删除数据集时调用 | + +## 文件夹与权限 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/folder/create` | POST | 创建数据集文件夹 | `api.ts:162` → 列表页 `EditFolderModal` | 数据集→数据集列表→创建文件夹→提交时调用 | +| `/core/dataset/paths` | GET | 获取数据集路径 | `api.ts:140` → `datasetPageContext.tsx:195` | 数据集→数据集详情→加载面包屑路径时调用 | +| `/core/dataset/getPermission` | GET | 获取数据集权限 | `api.ts:165` → ConfigPerModal | 数据集→数据集列表→打开权限配置时调用 | +| `/core/dataset/resumeInheritPermission` | PUT | 恢复权限继承 | `api.ts:168` → ConfigPerModal | 数据集→数据集列表→权限配置→恢复继承时调用 | +| `/core/dataset/apps` | GET | 获取引用了该数据集的 App 列表 | `api.ts:640` → 详情信息页 | 数据集→数据集详情→查看关联应用时调用 | + +## 知识集合管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/listV2` | POST | 获取知识集合列表(分页) | `api.ts:281` → CollectionCard | 数据集→数据集详情→集合Tab→加载时调用;翻页/搜索时调用 | +| `/core/dataset/collection/detail` | GET | 获取集合详情 | `api.ts:285` → NavBar / DataCard | 数据集→数据集详情→进入数据卡片时调用 | +| `/core/dataset/collection/paths` | GET | 获取集合路径 | `api.ts:283` → NavBar | 数据集→数据集详情→导航栏加载面包屑时调用 | +| `/core/dataset/collection/create` | POST | 创建集合 | `api.ts:291` → 多种导入组件 | 数据集→数据集详情→导入→创建集合时调用 | +| `/core/dataset/collection/create/fileId` | POST | 通过文件创建集合 | `api.ts:293` → Import 组件 | 数据集→数据集详情→导入→上传文件→创建集合时调用 | +| `/core/dataset/collection/create/link` | POST | 通过链接创建集合 | `api.ts:301` → Import 组件 | 数据集→数据集详情→导入→链接导入时调用 | +| `/core/dataset/collection/create/text` | POST | 通过文本创建集合 | `api.ts:334` → Import 组件 | 数据集→数据集详情→导入→文本导入时调用 | +| `/core/dataset/collection/create/apiCollectionV2` | POST | 通过 API 数据源创建集合 | `api.ts:343` → Import 组件 | 数据集→数据集详情→导入→API数据源→创建时调用 | +| `/core/dataset/collection/create/custom/fileId` | POST | 综合文件创建集合 | `api.ts:308` → Import 组件 | 数据集→数据集详情→导入→综合文件上传时调用 | +| `/core/dataset/collection/create/custom/link` | POST | 静态网页创建集合 | `api.ts:319` → Import 组件 | 数据集→数据集详情→导入→网页链接时调用 | +| `/core/dataset/collection/create/custom/website` | POST | 批量爬取网站创建集合 | `api.ts:329` → Import 组件 | 数据集→数据集详情→导入→批量网页时调用 | +| `/core/dataset/collection/create/reTrainingCollection` | POST | 重新训练文件集合 | `api.ts:297` → 集合重组 | 数据集→数据集详情→集合→重新训练时调用 | +| `/core/dataset/collection/create/backup` | POST | 备份导入集合 | `api.ts:174` → BackupImportModal | 数据集→数据集详情→集合→备份导入时调用 | +| `/core/dataset/collection/create/template` | POST | 模板导入集合 | `api.ts:203` → TemplateImportModal | 数据集→数据集详情→集合→模板导入时调用 | +| `/core/dataset/collection/update` | POST | 更新集合 | `api.ts:350` → 集合编辑组件 | 数据集→数据集详情→集合→编辑时调用 | +| `/core/dataset/collection/delete` | POST | 删除集合 | `api.ts:352` → 集合列表 | 数据集→数据集详情→集合→删除时调用 | +| `/core/dataset/collection/sync` | POST | 同步链接集合 | `api.ts:354` → 集合列表 | 数据集→数据集详情→集合→同步外部链接时调用 | +| `/core/dataset/collection/trainingDetail` | GET | 获取集合训练详情 | `api.ts:287` → 集合组件 | 数据集→数据集详情→查看训练详情时调用 | +| `/core/dataset/collection/check/duplicate` | POST | 检查文件名重复 | `api.ts:362` → 导入组件 | 数据集→数据集详情→导入→上传前检查重名时调用 | +| `/core/dataset/collection/check/md5Duplicate` | POST | 检查 MD5 重复 | `api.ts:368` → 导入组件 | 数据集→数据集详情→导入→上传前检查内容重复时调用 | + +## 数据操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/data/v2/list` | POST | 获取数据列表(分页) | `api.ts:402` → DataCard | 数据集→数据集详情→数据卡片→加载时调用 | +| `/core/dataset/data/detail` | GET | 获取数据详情 | `api.ts:413` → DataCard/编辑弹窗 | 数据集→数据集详情→数据卡片→查看/编辑数据时调用 | +| `/core/dataset/data/insertData` | POST | 插入一条数据 | `api.ts:419` → DataCard | 数据集→数据集详情→数据卡片→新增数据时调用 | +| `/core/dataset/data/update` | PUT | 更新数据 | `api.ts:425` → DataCard | 数据集→数据集详情→数据卡片→编辑数据时调用 | +| `/core/dataset/data/delete` | DELETE | 删除数据 | `api.ts:430` → DataCard | 数据集→数据集详情→数据卡片→删除数据时调用 | +| `/core/dataset/data/getQuoteData` | POST | 获取引用数据 | `api.ts:434` → 引用组件 | 对话模块→引用展示→获取原始数据时调用 | +| `/core/dataset/data/getPermission` | GET | 获取数据权限 | `api.ts:405` → 权限相关 | 数据集→数据卡片→查看数据权限时调用 | +| `/core/dataset/data/getBatchPermission` | POST | 批量获取数据权限 | `api.ts:408` → 批量操作 | 数据集→数据卡片→批量操作时调用 | +| `/core/dataset/data/getindex` | POST | 获取数据在集合中的位置 | `api.ts:442` → DataCard | 数据集→数据集详情→数据卡片→定位数据位置时调用 | + +## 标签管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/dataset/tag/list` | POST | 获取标签列表 | `api.ts:386` → HeaderTagPopOver | 数据集→数据集详情→集合→标签筛选时调用 | +| `/proApi/core/dataset/tag/create` | POST | 创建标签 | `api.ts:373` → TagManageModal | 数据集→数据集详情→集合→创建新标签时调用 | +| `/proApi/core/dataset/tag/update` | POST | 更新标签 | `api.ts:379` → TagManageModal | 数据集→数据集详情→集合→编辑标签时调用 | +| `/proApi/core/dataset/tag/delete` | DELETE | 删除标签 | `api.ts:377` → TagManageModal | 数据集→数据集详情→集合→删除标签时调用 | +| `/proApi/core/dataset/tag/getAllTags` | GET | 获取全部标签 | `api.ts:389` → `datasetPageContext.tsx:145` | 数据集→数据集详情→加载时获取所有标签 | +| `/proApi/core/dataset/tag/tagUsage` | GET | 获取标签使用统计 | `api.ts:387` → 标签组件 | 数据集→数据集详情→查看标签使用情况时调用 | +| `/proApi/core/dataset/tag/addToCollections` | POST | 添加标签到集合 | `api.ts:375` → 集合操作 | 数据集→数据集详情→集合→批量添加标签时调用 | +| `/proApi/core/dataset/tag/setCollectionTags` | POST | 设置集合标签 | `api.ts:391` → TagsPopOver | 数据集→数据集详情→集合→为集合设置标签时调用 | +| `/proApi/core/dataset/tag/batchSetCollectionTags` | POST | 批量设置集合标签 | `api.ts:393` → 批量操作 | 数据集→数据集详情→集合→批量设置标签时调用 | +| `/proApi/core/dataset/tag/batchUpsert` | POST | 批量创建/更新标签 | `api.ts:395` → 批量操作 | 数据集→数据集详情→集合→批量导入标签时调用 | + +## 训练与向量化 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/training/rebuildEmbedding` | POST | 重建向量嵌入 | `api.ts:449` → 集合组件 | 数据集→数据集详情→集合→重建向量时调用 | +| `/core/dataset/training/getDatasetTrainingQueue` | GET | 获取训练队列状态 | `api.ts:452` → `datasetPageContext.tsx:190` | 数据集→数据集详情→轮询训练/重建进度时调用 | +| `/core/dataset/training/deleteTrainingData` | POST | 删除训练数据 | `api.ts:463` → 训练管理 | 数据集→数据集详情→训练管理→删除训练数据时调用 | +| `/core/dataset/training/updateTrainingData` | PUT | 更新训练数据 | `api.ts:465` → 训练管理 | 数据集→数据集详情→训练管理→修改训练数据时调用 | +| `/core/dataset/training/retryErrorCollections` | PUT | 重试失败集合 | `api.ts:467` → 集合组件 | 数据集→数据集详情→集合→重试失败训练时调用 | +| `/core/dataset/training/getTrainingDataDetail` | POST | 获取训练数据详情 | `api.ts:469` → 训练管理 | 数据集→数据集详情→查看训练数据详情时调用 | +| `/core/dataset/training/getTrainingError` | POST | 获取训练错误信息 | `api.ts:471` → 训练管理 | 数据集→数据集详情→查看训练错误详情时调用 | +| `/core/dataset/file/getPreviewChunks` | POST | 获取预览分块 | `api.ts:457` → 导入组件 | 数据集→数据集详情→导入→预览文件分块效果时调用 | + +## 搜索测试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/searchTest` | POST | 搜索测试 | `api.ts:277` → Test 组件 | 数据集→数据集详情→搜索测试Tab→执行搜索时调用 | + +## 同义词管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/synonym/upload` | POST | 上传同义词文件 | `api.ts:584` → Synonym 组件 | 数据集→数据集详情→同义词Tab→上传文件时调用 | +| `/core/dataset/synonym/list` | GET | 获取同义词文件列表 | `api.ts:618` → Synonym 组件 | 数据集→数据集详情→同义词Tab→加载列表时调用 | +| `/core/dataset/synonym/download` | GET | 下载同义词文件 | `api.ts:626` → Synonym 组件 | 数据集→数据集详情→同义词Tab→下载文件时调用 | +| `/core/dataset/synonym/delete` | DELETE | 删除同义词文件 | `api.ts:635` → Synonym 组件 | 数据集→数据集详情→同义词Tab→删除文件时调用 | + +## 数据库知识库 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/database/checkConnection` | POST | 测试数据库连接 | `api.ts:534` → ConnectDatabaseForm | 数据集→数据集详情→导入→数据库→连接测试时调用 | +| `/core/dataset/database/getConfiguration` | GET | 获取数据库配置 | `api.ts:525` → DataBaseConfig | 数据集→数据集详情→导入→数据库→获取配置时调用 | +| `/core/dataset/database/preview` | GET | 预览数据库数据 | `api.ts:576` → FileDataCard / 导入 | 数据集→数据集详情→预览数据库数据时调用 | +| `/core/dataset/database/detectChanges` | POST | 检测数据变更 | `api.ts:508` → 数据库配置 | 数据集→数据集详情→检测数据源变更时调用 | +| `/core/dataset/database/applyChanges` | POST | 应用数据变更 | `api.ts:502` → 数据库配置 | 数据集→数据集详情→数据库→应用变更时调用 | +| `/core/dataset/database/createCollections` | POST | 创建数据库集合 | `api.ts:518` → 导入组件 | 数据集→数据集详情→导入→数据库→创建时调用 | +| `/core/dataset/database/createStructureCollection` | POST | 创建结构化文档集合 | `api.ts:541` → 导入组件 | 数据集→数据集详情→导入→结构化文档→创建时调用 | +| `/core/dataset/database/searchTest` | POST | 数据库搜索测试 | `api.ts:490` → Test 组件 | 数据集→数据集详情→搜索测试Tab→数据库搜索时调用 | + +## API 数据集 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/apiDataset/list` | POST | 获取 API 数据集文件列表 | `api.ts:475` → Import 组件 | 数据集→数据集详情→导入→API数据源→浏览文件时调用 | +| `/core/dataset/apiDataset/listExistId` | GET | 获取已有 API 数据集文件 ID | `api.ts:477` → Import 组件 | 数据集→数据集详情→导入→API数据源→增量同步时调用 | +| `/core/dataset/apiDataset/getCatalog` | POST | 获取 API 数据集目录 | `api.ts:480` → Import 组件 | 数据集→数据集详情→导入→API数据源→浏览目录时调用 | +| `/core/dataset/apiDataset/getPathNames` | POST | 获取 API 数据集路径名 | `api.ts:483` → Import 组件 | 数据集→数据集详情→导入→API数据源→获取路径时调用 | + +## 外部数据集同步 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/dataset/datasetSync` | POST | 同步外部数据集 | `api.ts:157` → 数据集组件 | 数据集→数据集详情→同步外部数据源时调用 | +| `/proApi/core/dataset/collection/create/externalFileUrl` | POST | 通过外部文件 URL 创建集合 | `api.ts:337` → Import 组件 | 数据集→数据集详情→导入→外部文件→创建时调用 | + +## 协作者管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/dataset/changeOwner` | POST | 更改数据集所有者 | `api.ts:171` → ConfigPerModal | 数据集→数据集列表→权限配置→变更所有者时调用 | +| 协作者相关 API | POST/GET/DELETE | 管理数据集协作者 | `api/collaborator.ts` → ConfigPerModal | 数据集→数据集列表→权限配置→管理协作者时调用 | + +## 文件管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 文件上传预签名 URL | POST | 获取数据集文件上传 URL | `api/file.ts` → 导入组件 | 数据集→数据集详情→导入→上传文件→获取上传地址时调用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..80e7031472b5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,122 @@ +--- +capability_label: 数据集列表 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — API索引 + +## 数据集查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/list` | POST | 获取数据集列表(分页) | `web/core/dataset/api.ts:128` → `pageComponents/dataset/list/context.tsx` | 数据集列表→加载时调用;数据集列表→切换文件夹时调用;数据集列表→搜索时调用;数据集列表→自动加载更多时调用 | +| `/core/dataset/listWithChildren` | POST | 获取带子节点层级的数据集列表 | `web/core/dataset/api.ts:131` → `pageComponents/dataset/list/context.tsx` | 数据集列表→文件夹展开时调用 | +| `/core/dataset/paths` | GET | 获取数据集文件夹路径(面包屑) | `web/core/dataset/api.ts:142` → `pageComponents/dataset/list/context.tsx` | 数据集列表→加载面包屑导航时调用;数据集列表→切换文件夹后刷新路径时调用 | +| `/core/dataset/detail` | GET | 获取单个数据集详情 | `web/core/dataset/api.ts:145` → `pageComponents/dataset/list/context.tsx` | 数据集列表→获取当前文件夹详情时调用 | + +## 数据集创建/更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/create` | POST | 创建新数据集 | `web/core/dataset/api.ts:148` → `pageComponents/dataset/list/CreateModal.tsx` | 数据集列表→创建数据集→提交创建表单时调用 | +| `/core/dataset/createWithFiles` | POST | 创建数据集并上传文件 | `web/core/dataset/api.ts:151` → `pageComponents/dataset/list/CreateModal.tsx` | 数据集列表→创建数据集→选择文件上传时调用 | +| `/core/dataset/folder/create` | POST | 创建数据集文件夹 | `web/core/dataset/api.ts:163` → `pages/dataset/list/index.tsx:295` | 数据集列表→新建文件夹→提交创建时调用 | +| `/core/dataset/update` | PUT | 更新数据集信息 | `web/core/dataset/api.ts:153` → `pageComponents/dataset/list/context.tsx` | 数据集列表→编辑数据集→保存修改时调用;数据集列表→编辑文件夹→保存修改时调用;数据集列表→移动数据集时调用 | + +## 数据集删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/delete` | DELETE | 删除数据集 | `web/core/dataset/api.ts:155` → `pageComponents/dataset/list/context.tsx` | 数据集列表→删除数据集→确认删除后调用 | + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/getPermission` | GET | 获取数据集权限配置 | `web/core/dataset/api.ts:166` → 权限弹窗组件 | 数据集列表→权限配置→打开权限面板时调用 | +| `/core/dataset/resumeInheritPermission` | PUT | 恢复继承父级权限 | `web/core/dataset/api.ts:169` → `pages/dataset/list/index.tsx:339` / `pageComponents/dataset/list/NewList.tsx:664` | 数据集列表→权限配置→点击恢复继承权限时调用 | +| `/proApi/core/dataset/changeOwner` | POST | 更改数据集所有者 | `web/core/dataset/api.ts:172` → `pages/dataset/list/index.tsx:331` / `pageComponents/dataset/list/NewList.tsx:656` | 数据集列表→权限配置→更改所有者时调用 | + +## 协作管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collaborator/list` | GET | 获取协作者列表 | `web/core/dataset/api/collaborator.ts` → 权限弹窗组件 | 数据集列表→权限配置→加载协作者列表时调用 | +| `/core/dataset/collaborator/update` | POST | 更新协作者配置 | `web/core/dataset/api/collaborator.ts` → `pages/dataset/list/index.tsx:350` / `pageComponents/dataset/list/NewList.tsx:676` | 数据集列表→权限配置→添加/修改协作者时调用 | +| `/core/dataset/collaborator/delete` | DELETE | 删除协作者 | `web/core/dataset/api/collaborator.ts` → `pages/dataset/list/index.tsx:353` / `pageComponents/dataset/list/NewList.tsx:678` | 数据集列表→权限配置→移除协作者时调用 | + +## 导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/dataset/exportAll` | GET | 导出数据集全部数据为CSV | `web/core/dataset/api.ts:550` → `pageComponents/dataset/list/NewList.tsx:549` | 数据集列表→导出数据集→点击导出时调用 | + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/file/getUploadAvatarPresignedUrl` | GET | 获取头像上传预签名URL | `web/common/file/api.ts` → `pages/dataset/list/index.tsx` / `pageComponents/dataset/list/NewList.tsx` | 数据集列表→创建/编辑文件夹→上传头像时调用 | + +--- + +## API 调用链追踪 + +### `POST /core/dataset/list` 调用链 + +``` +{列表页面 context} + ├── 触发: 进入页面 / 切换文件夹 / 搜索 / 滚动加载更多 + ├── 参数: { parentId, searchKey?, pageNum?, pageSize? } + └── 响应处理: 更新 myDatasets 状态,驱动卡片列表渲染 + +{文件夹树展开} + ├── 触发: 获取子文件夹下的数据集 + ├── 参数: { parentId, searchKey? } + └── 响应处理: 合并到 myDatasets 列表 +``` + +### `POST /core/dataset/folder/create` 调用链 + +``` +{页面上"新建文件夹"按钮} + ├── 触发: 用户点击"新建文件夹"按钮 → 打开 EditFolderModal → 填写表单 → 提交 + ├── 参数: { parentId, name, intro, avatar } + └── 响应处理: 刷新数据集列表和面包屑路径 +``` + +### `PUT /core/dataset/update` 调用链 + +``` +{编辑数据集} + ├── 触发: 卡片菜单→编辑 / 顶部编辑按钮 → EditFolderModal / CreateModal → 提交修改 + ├── 参数: { id, name?, intro?, avatar?, parentId? } + └── 响应处理: 局部更新或重新加载列表 + +{移动数据集} + ├── 触发: 拖拽数据集到目标文件夹 → 确认弹窗 → 确认 + ├── 参数: { id, parentId } + └── 响应处理: 重新加载列表 +``` + +### `DELETE /core/dataset/delete` 调用链 + +``` +{卡片菜单→删除} + ├── 触发: 用户点击删除 → 确认弹窗(输入名称验证)→ 确认 + ├── 参数: { id } + └── 响应处理: 刷新列表和面包屑路径 +``` + +### `GET /api/core/dataset/exportAll` 调用链 + +``` +{卡片菜单→导出} + ├── 触发: 用户点击导出 → 检查团队导出限额 → 发起下载 + ├── 参数: { datasetId } + └── 响应处理: 触发浏览器下载 CSV 文件 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..e87c6f966a04 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,150 @@ +--- +capability_label: 数据集列表 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — Store 数据流 + +## Store 概览 + +数据集列表模块涉及两个状态管理层:全局 Zustand Store(`useDatasetStore`)和页面级 React Context(`DatasetsContext`)。页面实际使用 Context 作为主要数据源。 + +| Store 文件 | Store ID | 用途 | +|-----------|---------|------| +| `projects/app/src/web/core/dataset/store/dataset.ts` | `useDatasetStore` | 全局数据集列表缓存(Zustand + persist) | +| `projects/app/src/pageComponents/dataset/list/context.tsx` | `DatasetsContext` | 页面级数据集列表状态管理(React Context) | + +--- + +## `DatasetsContext` — `projects/app/src/pageComponents/dataset/list/context.tsx` + +数据集列表页面的核心状态管理,通过 React Context Provider 模式向子组件提供数据和操作方法。 + +### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `myDatasets` | `DatasetListItemType[]` | 当前页面展示的数据集/文件夹列表 | +| `paths` | `ParentTreePathItemType[]` | 从根目录到当前父文件夹的面包屑路径 | +| `folderDetail` | `DatasetItemType \| undefined` | 当前文件夹(parentId 指向的文件夹)的详细信息,含权限、名称、图标 | +| `isFetchingDatasets` | `boolean` | 数据集列表是否正在加载中 | +| `hasMore` | `boolean` | 是否还有更多数据可加载(分页) | +| `searchKey` | `string` | 当前搜索关键词 | +| `editedDataset` | `EditResourceInfoFormType \| undefined` | 当前正在编辑的数据集信息(由顶部编辑按钮触发) | +| `moveDatasetId` | `string` | 当前待移动的数据集ID(菜单触发移动流程) | + +### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `loadMyDatasets` | — | 加载数据集列表,设置搜索关键词和时间戳参数 | `POST /core/dataset/list` | +| `refetchPaths` | — | 重新获取面包屑路径 | `GET /core/dataset/paths` | +| `refetchFolderDetail` | — | 重新获取当前文件夹详情(权限、名称等) | `GET /core/dataset/detail` | +| `onUpdateDataset` | `UpdateDatasetBody` | 更新数据集信息(名称、描述、头像、父目录) | `PUT /core/dataset/update` | +| `onDelDataset` | `id: string` | 删除数据集 | `DELETE /core/dataset/delete` | +| `setSearchKey` | `string` | 设置搜索关键词 | —(触发列表重新加载) | +| `setEditedDataset` | `data?: EditResourceInfoFormType` | 设置待编辑的数据集信息 | — | +| `setMoveDatasetId` | `id: string` | 设置待移动的数据集ID | — | +| `sentinelCallbackRef` | `HTMLDivElement \| null` | 无限滚动哨兵元素的回调引用(IntersectionObserver) | — | + +--- + +## `useDatasetStore` — `projects/app/src/web/core/dataset/store/dataset.ts` + +全局 Zustand Store,通过 `persist` 中间件保持状态,提供跨页面的数据集列表缓存。 + +### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `myDatasets` | `DatasetListItemType[]` | 全局缓存的数据集列表 | + +### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `loadMyDatasets` | `parentId?: string` | 从 API 加载数据集列表并更新 store | `POST /core/dataset/list` | + +> 注意:本模块页面实际使用 `DatasetsContext` 而非 `useDatasetStore`。`useDatasetStore` 主要用于其他需要快速获取数据集列表的模块。 + +--- + +## 数据流向 + +### 列表加载流程 + +``` +Page Component (Dataset) Context (DatasetsContext) API Layer Backend + │ │ │ │ + │ useContextSelector(myDatasets...) │ │ │ + ├───────────────────────────────────────►│ │ │ + │ │ POST /core/dataset/list │ │ + │ ├──────────────────────────►│ │ + │ │ │ ── request ──► │ + │ │ │ ◄─ response ── │ + │ │ myDatasets[] + stats │ │ + │ │◄──────────────────────────┤ │ + │ │ │ │ + │ re-render with new myDatasets │ │ │ + │◄───────────────────────────────────────┤ │ │ +``` + +### 文件夹切换流程 + +``` +User clicks folder card + │ + ├── router.push({ query: { parentId: newId } }) + │ + ├── setSearchKey('') ← clear search + │ + ├── loadMyDatasets() ← POST /core/dataset/list + │ + ├── refetchPaths() ← GET /core/dataset/paths + │ + └── refetchFolderDetail() ← GET /core/dataset/detail + │ + └── UI updates: breadcrumb, operation buttons visibility, card grid +``` + +### 新建/编辑/删除后的刷新流程 + +``` +Action: Create/Edit/Delete Dataset + │ + ├── loadMyDatasets() ← refresh list + └── refetchPaths() ← refresh breadcrumb (for create/delete) +``` + +### 滚动加载更多流程 + +``` +IntersectionObserver fires (sentinelCallbackRef enters viewport) + │ + ├── check: hasMore && !isFetchingDatasets + │ ├── true → loadMyDatasets() with next page + │ │ ├── pageNum++ + │ │ └── append new data to myDatasets[] + │ └── false → skip + │ + └── Spinner visible at list bottom until response +``` + +--- + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| React Context (useContextSelector) | 页面级状态共享:列表数据、搜索、路径、文件夹详情 | `Dataset` → `NewList`、`NewDatasetCard`、`List` | +| Props 传递 | 操作回调:编辑、权限、导出、删除 | `NewList` → `NewDatasetCard`(通过 Props 传递回调函数和数据集对象) | +| URL 路由参数 | 文件夹导航:`parentId` 作为查询参数 | `Dataset` 通过 `router.query.parentId` 获取当前文件夹ID | +| 动态导入 (dynamic import) | 弹窗组件按需加载 | `index.tsx` → `EditFolderModal`、`CreateModal`、`ConfigPerModal` | +| 全局 Store 订阅 | 系统配置、用户信息 | `useSystemStore`(feConfigs)、`useUserStore`(userInfo) | + +本模块未使用跨组件的事件总线(EventBus/mitt)或依赖注入(Provide/Inject)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..20b665022fda --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\345\210\227\350\241\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,68 @@ +--- +capability_label: 数据集列表 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:37:00.000Z" +parent_module: 数据集 +roles: ["管理员", "协作者", "只读用户"] +router_paths: ["/dataset/list"] +--- + +# 数据集列表 — 组件列表 + +## pages/ — 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dataset/list/index.tsx` | 数据集列表页面入口,包含侧边栏、面包屑、操作栏和列表主体 | 引用自 仪表盘模块 → `DashboardNavbar`、`BgDecoration`;引用自 共享组件库 → `MyBox`、`MyIcon`、`SearchInput`、`MultipleMenu` | 否 | —(页面组件,无 Props) | + +## pageComponents/dataset/list/ — 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dataset/list/NewList.tsx` | 数据集卡片网格列表,支持拖拽移动、无限滚动加载 | 内部 | 否 | —(通过 Context 获取数据) | +| `projects/app/src/pageComponents/dataset/list/NewList.tsx` (NewDatasetCard) | 单个数据集卡片的渲染,包含信息展示、类型标签、操作菜单 | 内部 | 否 | dataset: DatasetListItemType, parentDataset, getBoxProps, setEditingFolder, setEditingDataset, setEditPerDatasetId, exportDataset, openConfirmDel, onDelDataset | +| `projects/app/src/pageComponents/dataset/list/context.tsx` (DatasetContextProvider) | 数据集列表状态管理(Context Provider),管理列表数据、路径、搜索、CRUD操作 | 引用自 权限模块 → 协作者API | 否 | —(Provider 组件) | +| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 创建/编辑数据集弹窗,根据类型显示不同的创建表单 | 内部 | 否 | type: CreateDatasetType, editId?, parentId?, onClose, onUpdateSuccess? | +| `projects/app/src/pageComponents/dataset/list/List.tsx` | 旧版数据集列表组件,功能与 NewList 类似但使用不同的卡片样式 | 内部 | 否 | —(通过 Context 获取数据) | + +## 组件间关系 + +``` +DatasetContextWrapper (index.tsx) +├── DashboardNavbar (侧边栏 —— 来自 仪表盘模块) +├── BgDecoration (背景装饰 —— 来自 仪表盘模块) +├── DatasetContextProvider (context.tsx) +│ └── Dataset (index.tsx) +│ ├── FolderPath (面包屑导航 —— 来自 共享组件) +│ ├── SearchInput (搜索框 —— 来自 共享组件) +│ ├── MultipleMenu (新建数据集下拉菜单 —— 来自 共享组件) +│ ├── EditFolderModal (创建/编辑文件夹弹窗 —— 动态导入) +│ ├── CreateModal (创建/编辑数据集弹窗 —— 动态导入) +│ ├── ConfigPerModal (权限配置弹窗 —— 来自 权限模块) +│ └── NewList (NewList.tsx) +│ ├── NewDatasetCard × N (数据集卡片) +│ │ ├── SideTag (类型标签) +│ │ ├── MyMenu (操作菜单) +│ │ └── RelatedAppsPopover (关联应用弹窗) +│ ├── EditFolderModal (编辑文件夹弹窗 —— 动态导入) +│ ├── CreateModal (编辑数据集弹窗 —— 动态导入) +│ └── ConfigPerModal (权限配置弹窗 —— 来自 权限模块) +``` + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享UI库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享UI库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享UI库 | +| `MultipleMenu` | `@fastgpt/web/components/common/MyMenu/Multiple` | 共享UI库 | +| `EditFolderModal` | `@fastgpt/web/components/common/MyModal/EditFolderModal` | 共享UI库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享UI库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 共享UI库 | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限模块 | +| `FolderPath` | `@/components/common/folder/Path` | 公共组件 | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 仪表盘模块 | +| `BgDecoration` | `@/pageComponents/dashboard/BgDecoration` | 仪表盘模块 | +| `Loading` | `@fastgpt/web/components/common/MyLoading` | 共享UI库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5cf146a396a3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/API\347\264\242\345\274\225.md" @@ -0,0 +1,81 @@ +--- +capability_label: "数据集详情" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: "数据集" +roles: ["所有用户", "管理员"] +router_paths: ["/dataset/detail"] +--- + +# 数据集详情 — API 索引 + +> 本文档覆盖数据集详情页面的公共 API 层(页面容器级和 NavBar 中调用的 API)。各 Tab 子能力的 API 详见对应子目录的 API 索引。 + +## 数据集信息 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/detail` | GET | 获取数据集详情(含权限信息) | `web/core/dataset/api.ts` → `datasetPageContext.tsx:107` | 数据集详情→页面加载→初始化时调用;同步/等待状态→每10秒轮询时调用 | +| `/core/dataset/update` | PUT | 更新数据集配置 | `web/core/dataset/api.ts` → `datasetPageContext.tsx:116` | 数据集详情→配置Tab→保存时调用(各子Tab也可能调用) | + +## 数据集路径 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/paths` | POST | 获取数据集父级路径(面包屑) | `web/core/dataset/api.ts` → `datasetPageContext.tsx:195` | 数据集详情→页面加载→初始化时调用;数据集移动后→刷新路径时调用 | + +## 训练队列 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/training/queue` | GET | 获取训练队列状态 | `web/core/dataset/api/training.ts` → `datasetPageContext.tsx:190` | 数据集详情→页面加载→初始化时调用(用于判断是否有错误/训练中/重建中的集合) | + +## 集合路径 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/paths` | GET | 获取集合在文件夹中的路径 | `web/core/dataset/api/collection.ts:41` → `NavBar.tsx:51,57` | 数据集详情→NavBar→集合管理Tab或数据卡片Tab→加载面包屑时调用 | + +## 集合源文件读取 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/read` | POST | 读取集合源文件/下载 | `web/core/dataset/api/collection.ts:158` → `NavBar.tsx:119` | 数据集详情→数据卡片Tab→NavBar点击"查看原文/下载文件"按钮时调用 | + +### `/core/dataset/collection/read` 调用链 + +``` +NavBar + ├── 触发: 用户在数据卡片/文件卡片 Tab 点击 NavBar 右侧的"查看原文"/"下载文件"/"查看图片"按钮 + ├── 参数: { collectionId: string } + ├── 响应处理: value 为空 → toast "文件未找到" + │ value 以 http 开头 → window.open(value, '_blank') + │ value 以 / 开头 → window.open(location.origin + value, '_blank') + │ API 数据集 → 拼接 baseUrl + value 后 window.open + └── 按钮可见条件: currentTab ∈ {dataCard, fileDataCard} 且 collection 已加载且 sourceLabel 非空 + +CollectionCard (RefinedCollectionCard) + ├── 触发: 集合列表中点击某个集合的"读取源"操作 + ├── 参数: { collectionId: string } + └── 响应处理: 同上(详见子文档) +``` + +## 标签(仅 Plus 版) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/dataset/tag/getAllTags` | GET | 获取数据集全部标签 | `web/core/dataset/api/collection.ts:154` → `datasetPageContext.tsx:145` | 数据集详情→页面加载→Plus版→初始化标签数据时调用 | +| `/proApi/core/dataset/tag/list` | POST | 搜索标签(分页) | `web/core/dataset/api/collection.ts:151` → `datasetPageContext.tsx:157` | 数据集详情→页面加载→Plus版→搜索标签时调用(300ms 节流) | +| `/proApi/core/dataset/tag/create` | POST | 创建新标签 | `web/core/dataset/api/collection.ts:138` → `datasetPageContext.tsx:172` | 数据集详情→集合管理→创建新标签时调用 | + +## API 分组汇总 + +| 分组 | API 数量 | 说明 | +|------|---------|------| +| 查询/列表 | 5 | 数据集详情、路径、训练队列、集合路径、全部标签 | +| 更新 | 1 | 更新数据集配置 | +| 文件操作 | 1 | 读取/下载集合源文件 | +| 标签管理 | 2 | 搜索标签、创建标签 | + +> 各 Tab 子能力的 API(如集合 CRUD、数据分片操作、导入、检索测试、同义词等)详见各子目录的 API 索引文档。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..dd502095527c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,94 @@ +--- +capability_label: 同义词管理 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=synonym] +--- + +# 同义词管理 — API索引 + +## 文件查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/dataset/synonym/list` | GET | 获取同义词文件列表 | `api.ts:618` → `Synonym/index.tsx:289` | 同义词管理→同义词Tab→页面加载时调用 | + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/dataset/synonym/upload` | POST | 上传同义词文件 | `api.ts:584` → `Synonym/index.tsx:326` | 同义词管理→同义词Tab→选择文件后上传时调用 | + +## 文件下载 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/dataset/synonym/download` | GET | 下载同义词文件 | `Synonym/index.tsx:375`(通过 downloadFetch) | 同义词管理→同义词Tab→点击下载按钮时调用 | + +## 文件删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/dataset/synonym/delete` | DELETE | 删除同义词文件 | `api.ts:635` → `Synonym/index.tsx:282,403` | 同义词管理→同义词Tab→确认删除弹窗后调用 | + +--- + +## API 调用链追踪 + +### `/api/core/dataset/synonym/list` 调用链 + +``` +SynonymTab 组件 + ├── 触发: 页面加载时(useEffect 自动调用 fetchSynonymFiles) + ├── 参数: datasetId(从 DatasetPageContext 获取) + └── 响应处理: 取 response.files[0] 设置到 synonymFile 状态;错误时 toast 提示"获取同义词文件失败" + +SynonymTab 组件(上传成功后刷新) + ├── 触发: 文件上传成功后调用 fetchSynonymFiles 刷新列表 + ├── 参数: datasetId(同首次加载) + └── 响应处理: 同上 +``` + +### `/api/core/dataset/synonym/upload` 调用链 + +``` +SynonymTab 组件 → handleFileSelectorChange + ├── 触发: 用户选择/拖拽文件到 FileSelector 组件 + ├── 参数: + │ ├── datasetId(从 DatasetPageContext 获取) + │ ├── file(File 对象,multipart/form-data) + │ └── onProgress(上传进度回调,可选) + ├── 中间状态: setIsUploading(true) → 显示上传中的文件条目(带 spinner) + └── 响应处理: + ├── 成功: 调用 fetchSynonymFiles 刷新 → toast 提示"同义词文件上传成功" + └── 失败: 设置 uploadedFile.status='failed' + errorMessage → toast 提示"同义词文件上传失败" +``` + +### `/api/core/dataset/synonym/download` 调用链 + +``` +SynonymTab 组件 → handleFileDownload + ├── 触发: 用户点击已上传文件的下载图标 + ├── 参数: id(同义词文件 _id,从 synonymFile._id 获取) + ├── 下载方式: 通过 downloadFetch 工具函数,浏览器端触发文件下载 + └── 响应处理: + ├── 成功: toast 提示"同义词文件下载已开始" + └── 失败: toast 提示"同义词文件下载失败" +``` + +### `/api/core/dataset/synonym/delete` 调用链 + +``` +SynonymTab 组件 → handleDeleteConfirmClick + ├── 触发: 用户点击删除图标 → Popover 确认弹窗 → 点击"删除"按钮 + ├── 参数: + │ ├── datasetId(从 DatasetPageContext 获取) + │ └── fileId(同义词文件 _id) + ├── 前置确认: Popover 弹窗显示"确认删除该同义词文件?"(dataset:synonym_confirm_delete) + └── 响应处理: + ├── 成功: setSynonymFile(null) → 回退到空状态 → toast "删除成功"(dataset:synonym_delete_success) + └── 失败: 通过 useRequest 的 onError 默认处理 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..b0eb4a202f77 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\220\214\344\271\211\350\257\215\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,26 @@ +--- +capability_label: 同义词管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=synonym] +--- + +# 同义词管理 — 组件列表 + +## pageComponents/dataset/detail/Synonym/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Synonym/index.tsx` → `SynonymTab` | 同义词管理主组件,管理同义词文件的上传、查看、下载和删除 | 引用自 Context: DatasetPageContext;引用自 API: @/web/core/dataset/api;引用自组件: FileSelector | 否 | 无外部 props(通过 Context 获取 datasetId) | +| `Synonym/index.tsx` → `FileInfo` | 文件信息展示内部组件,显示文件名、大小、上传者、上传时间及操作按钮 | 引用自 Store: useUserStore;引用自组件库: Chakra UI | 否 | name, size, uploadTime, onDownload, onDelete, onClear, isUploading, isDeleting, uploadStatus, errorMessage 等(内部接口) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `FileSelector` | `@/components/Select/FileSelectorBox` | 通用文件选择器 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用容器组件(@fastgpt/web) | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用图标组件(@fastgpt/web) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5d6c5f24628e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,148 @@ +--- +capability_label: 导入数据 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:41:34Z" +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=import] +--- + +# 导入数据 — API索引 + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/file/presignDatasetFilePostUrl` | POST | 获取 S3 预签名上传 URL | `api/file.ts:9` → `FileLocal.tsx:162` | 数据集详情→导入→本地文件→选择文件后上传时调用 | +| `/core/dataset/file/getPreviewChunks` | POST | 预览文件分块结果 | `api/file.ts:12` → `PreviewData.tsx:59` | 数据集详情→导入→数据预览步骤→点击文件时调用(本地文件/链接/外部文件/API数据集/重训练) | + +## 集合创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/create/fileId` | POST | 根据已上传文件 ID 创建集合 | `api/collection.ts:62` → `Upload.tsx` | 数据集详情→导入→本地文件→确认上传时调用 | +| `/core/dataset/collection/create/link` | POST | 根据网页链接创建集合 | `api/collection.ts:70` → `Upload.tsx` | 数据集详情→导入→网页链接→确认上传时调用 | +| `/core/dataset/collection/create/text` | POST | 根据自定义文本创建集合 | `api/collection.ts:72` → `Upload.tsx` | 数据集详情→导入→自定义文本→确认上传时调用 | +| `/core/dataset/collection/create/images` | POST | 创建图片数据集集合 | `image/api.ts:4` → `FileLocal.tsx:123`、`ImageDataset.tsx` | 数据集详情→导入→本地文件(含图片时)/图片数据集→上传时调用 | +| `/core/dataset/collection/create/custom/apiCollectionV2` | POST | 根据 API 数据集文件批量创建集合 | `api/collection.ts:78` → `Upload.tsx` | 数据集详情→导入→API数据集→确认上传时调用 | +| `/proApi/core/dataset/collection/create/externalFileUrl` | POST | 根据外部文件 URL 创建集合 | `api/collection.ts:83` → `Upload.tsx` | 数据集详情→导入→外部文件→确认上传时调用 | +| `/core/dataset/collection/create/reTrainingCollection` | POST | 对已有集合重新训练 | `api/collection.ts:66` → `Upload.tsx` | 数据集详情→导入→重训练→确认上传时调用 | + +## API 数据集 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/apiDataset/list` | POST | 获取 API 数据集文件/文件夹列表 | `api/apiDataset.ts:13` → `APIDataset.tsx` | 数据集详情→导入→API数据集→浏览文件目录时调用(含分页和搜索) | +| `/core/dataset/apiDataset/listExistId` | GET | 获取已导入的文件 ID 集合 | `api/apiDataset.ts:16` → `APIDataset.tsx` | 数据集详情→导入→API数据集→加载时调用,用于禁用已导入的文件 | + +## 集合查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/detail` | GET | 获取集合详情 | `api/collection.ts:43` → `ReTraining.tsx:29` | 数据集详情→导入→重训练→加载时调用,获取已有集合训练参数 | + +## 数据库 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/database/checkConnection` | POST | 测试数据库连通性 | `api.ts:534` → `ConnectDatabaseForm.tsx` | 数据集详情→导入→数据库→连接配置→点击「测试连接」时调用 | +| `/core/dataset/database/getConfiguration` | GET | 获取数据库表/列配置 | `api.ts:525` → `useDataBaseConfig.ts` | 数据集详情→导入→数据库→表配置步骤→加载时调用 | +| `/core/dataset/database/detectChanges` | POST | 检测数据库表/列变更 | `api.ts:508` → `useDataBaseConfig.ts` | 数据集详情→导入→数据库→编辑模式→检测变更时调用 | +| `/core/dataset/database/createCollections` | POST | 根据数据库配置创建知识库集合 | `api.ts:518` → `useDataBaseConfig.ts` | 数据集详情→导入→数据库→表配置确认→新建模式提交时调用 | +| `/core/dataset/database/applyChanges` | POST | 应用数据库配置变更 | `api.ts:502` → `useDataBaseConfig.ts` | 数据集详情→导入→数据库→表配置确认→编辑模式提交时调用 | +| `/core/dataset/update` | PUT | 更新数据集基本信息(含数据库配置) | `api.ts:153` → `ConnectDatabaseForm.tsx` | 数据集详情→导入→数据库→编辑模式→连接步骤确认时调用 | + +--- + +## API 调用链追踪 + +### `/core/dataset/file/presignDatasetFilePostUrl` 调用链 + +``` +FileLocal.tsx (SelectFile 组件) + ├── 触发: 用户选择文件后自动触发的上传流程 + ├── 参数: { filename, datasetId } + └── 响应处理: 获取 { url, key, headers } → 用于后续 putFileToS3 上传 + +FileLocal.tsx (putFileToS3) + ├── 触发: 获取预签名 URL 后立即执行 + ├── 参数: { url, file, headers, maxSize, onUploadProgress } + └── 响应处理: 进度回调更新 selectFiles state → 完成后设置 dbFileId=key +``` + +### `/core/dataset/file/getPreviewChunks` 调用链 + +``` +PreviewData.tsx + ├── 触发: 用户在预览步骤点击某个来源文件 + ├── 参数: { datasetId, type, sourceId, chunkSize, overlapRatio, ...分块参数 } + ├── 分支: fileCustom 类型走前端 splitText2Chunks(不调此 API);其他类型调用后端 + └── 响应处理: 返回 { chunks: [{q, a}], total } → 渲染分块预览列表;total===0 时 toast 警告 + +PreviewData.tsx (getPreviewSourceReadType 工具) + ├── 触发: 构造 API 参数时 + ├── 参数: previewSource (ImportSourceItemType) + └── 响应: 返回 DatasetSourceReadTypeEnum (fileLocal|link|apiFile|externalFile) +``` + +### `/core/dataset/collection/create/fileId` 调用链 + +``` +Upload.tsx + ├── 触发: 用户在「确认上传」步骤点击「开始上传」 + ├── 参数: { datasetId, parentId, fileId, ...分块参数, ...processParamsForm } + ├── 串行: 逐个处理 sources 列表中 createStatus==='waiting' 的项 + └── 响应处理: 成功 → 更新该项 createStatus='finish';失败 → createStatus='waiting'+errorMsg + +Upload.tsx (完成逻辑) + ├── 触发: 所有 source 处理完毕 + └── 响应: router.replace 回到数据集详情页 currentTab=collectionCard +``` + +### `/core/dataset/collection/create/images` 调用链 + +``` +FileLocal.tsx (SelectFile → onSelectFiles) + ├── 触发: 上传的文件列表中包含图片文件(.jpg/.jpeg/.png) + ├── 参数: { parentId, datasetId, collectionName, files, fileMd5, onUploadProgress } + ├── 并行: 与文档文件的 S3 上传并行执行 + └── 响应处理: 进度回调 → 图片文件标记 dbFileId='image_dataset' 以便后续步骤跳过 + +ImageDataset.tsx (SelectFile 子组件) + ├── 触发: 用户选择图片、填写名称后点击提交 + ├── 参数: 同上结构 + └── 响应处理: 完成 → 自动重定向回 collectionCard +``` + +### `/core/dataset/apiDataset/list` 调用链 + +``` +APIDataset.tsx (CustomAPIFileInput) + ├── 触发: 页面加载 / 点击进入子文件夹 + ├── 参数: { datasetId, parentId, searchKey, offset, pageSize } + └── 响应处理: 返回文件列表 → 渲染 checkbox 列表,文件夹项含 chevron 箭头可展开 + +APIDataset.tsx (getApiDatasetFileListExistId 配合) + ├── 触发: 与 list 并行调用 + └── 响应: 返回已导入文件 ID 的 Set → 对应 checkbox 置灰禁用 +``` + +### `/core/dataset/database/checkConnection` 调用链 + +``` +ConnectDatabaseForm.tsx (FormBottomButtons) + ├── 触发: 用户点击「测试连接」按钮 + ├── 参数: { type, host, port, database, user, password, schema, poolSize } + └── 响应处理: 成功/失败 toast 提示 +``` + +### `/core/dataset/database/createCollections` 调用链 + +``` +useDataBaseConfig.ts (hook) + ├── 触发: 用户在表配置步骤确认创建 + ├── 参数: { datasetId, tables: [{ tableName, columns, ... }] } + ├── 分支: 新建模式调用 createCollections;编辑模式调用 applyChanges + └── 响应处理: 成功 → 更新本地表配置状态 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..80a512b802d6 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\345\257\274\345\205\245\346\225\260\346\215\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,75 @@ +--- +capability_label: 导入数据 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:41:34Z" +parent_module: 数据集详情 +roles: [管理员, 成员] +router_paths: [/dataset/detail?currentTab=import] +--- + +# 导入数据 — 组件列表 + +## Import/ + +页面级入口与上下文提供者。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Import/index.tsx` | 导入数据页面入口,根据 `importSource` 动态渲染对应的导入源组件 | 引用自数据集详情页 `pages/dataset/detail/index.tsx` | 否 | 无(通过 Context 通信) | +| `Import/Context.tsx` | 导入共享上下文,管理导入源类型、步骤状态、表单数据、文件来源列表 | 被所有 diffSource 子组件消费 | 是 → 模块内部共享 | `children: ReactNode` | + +## Import/diffSource/ + +8 种不同导入源的页面组件。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `diffSource/FileLocal.tsx` | 本地文件导入(4 步向导:选文件→参数→预览→上传) | 内部 | 否 | 无 | +| `diffSource/FileLink.tsx` | 网页链接导入(4 步向导:输入链接→参数→预览→上传) | 内部 | 否 | 无 | +| `diffSource/FileCustomText.tsx` | 自定义文本导入(4 步向导:输入文本→参数→预览→上传) | 内部 | 否 | 无 | +| `diffSource/ExternalFile.tsx` | 外部文件 URL 导入(4 步向导) | 内部 | 否 | 无 | +| `diffSource/APIDataset.tsx` | API 数据集文件导入,含多级目录浏览 | 内部 | 否 | 无 | +| `diffSource/ReTraining.tsx` | 已有集合重训练(3 步向导) | 内部 | 否 | 无 | +| `diffSource/ImageDataset.tsx` | 图片数据集导入(单页简化流程) | 内部 | 否 | 无 | +| `diffSource/ConnectDatabaseConfig.tsx` | 数据库导入路由(2 步向导) | 内部 | 否 | 无 | + +## Import/commonProgress/ + +各导入源共用的步进组件。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `commonProgress/DataProcess.tsx` | 数据处理参数配置(PDF 解析、分块策略、索引设置) | 被 7 个 diffSource 组件引用 | 是 → 模块内部共享 | 无(通过 Context 通信) | +| `commonProgress/PreviewData.tsx` | 文件分块预览(左侧文件列表 + 右侧分块内容) | 被 7 个 diffSource 组件引用 | 是 → 模块内部共享 | 无(通过 Context 通信) | +| `commonProgress/Upload.tsx` | 集合创建执行器,根据 `importSource` 路由到对应 API 逐条创建 | 被 6 个 diffSource 组件引用 | 是 → 模块内部共享 | 无(通过 Context 通信) | + +## Import/components/ + +可复用的子组件和工具组件。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `components/FileSourceSelector.tsx` | 导入源选择模态框(本地文件/网页链接/自定义文本) | 被数据集详情页 NavBar 引用 | 是 → 模块内部共享 | `onClose: () => void` | +| `components/FileSelector.tsx` | 文件拖拽上传选择器,支持文件夹递归、类型过滤 | 被 `FileLocal`、`ImageDataset` 引用 | 是 → 模块内部共享 | `fileType: string, selectFiles: Item[], onSelectFiles: Function` | +| `components/RenderFiles.tsx` | 文件上传进度表格(图标+名称+进度条+大小+删除) | 被 `FileLocal` 引用 | 否 | `files: ImportSourceItemType[], setFiles: Dispatch` | +| `components/ConnectDatabaseForm.tsx` | 数据库连接凭证表单(类型/主机/端口/库/用户/密码) | 被 `ConnectDatabaseConfig` 引用 | 否 | 无(通过 Context 通信) | +| `utils.tsx` | 工具函数(`getPreviewSourceReadType`、`databaseAddrValidator`) | 被 `PreviewData`、`ConnectDatabaseForm` 引用 | 是 → 模块内部共享 | —(导出工具函数) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | web 公共组件 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 公共组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 公共组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 公共组件 | +| `MyTag` | `@fastgpt/web/components/common/Tag/index` | web 公共组件 | +| `FolderPath` | `@/components/common/folder/Path` | app 公共组件 | +| `Markdown` | `@/components/Markdown` | app 公共组件 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | web 公共组件 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | web 公共组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 公共组件 | +| `LeftRadio` | `@fastgpt/web/components/common/Radio/LeftRadio` | web 公共组件 | +| `CollectionChunkForm` | `../Form/CollectionChunkForm` | 数据集详情模块 | +| `FileSelector` (useSelectFile) | `@/web/common/file/useSelectFile` | web 公共 hook | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..7046205678cb --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" @@ -0,0 +1,127 @@ +--- +capability_label: 数据卡片 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:39:36.000Z" +parent_module: 数据集详情 +roles: + - 管理员/编辑者 + - 只读用户 +router_paths: + - /dataset/detail?currentTab=dataCard +--- + +# 数据卡片 — API索引 + +> 反向索引:API → 业务场景。标注每个 API 的调用位置和触发时机。 + +--- + +## 数据查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/data/v2/list` | POST | 获取数据分页列表 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:281`(usePagination 内部) | 数据卡片→浏览数据列表→首次/翻页/搜索/轮询时调用 | +| `/core/dataset/data/detail` | GET | 获取单条数据详情 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:151`(fetchActiveDataDetail);`EditContentModal.tsx:60`(编辑模式获取当前数据) | 数据卡片→浏览数据列表→选中卡片时调用;数据卡片→编辑数据→打开弹窗时调用 | +| `/core/dataset/data/getindex` | POST | 获取数据在集合中的索引位置 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:86`(useEffect 计算初始分页) | 数据卡片→activeId 定位→确定目标数据所在分页时调用 | + +## 数据操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/data/update` | PUT | 更新单条数据 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:214`(saveNewIndex)、`RefinedDataCard/index.tsx:432`(editIndex)、`RefinedDataCard/index.tsx:466`(deleteIndex);`EditContentModal.tsx:83`(onUpdateData) | 数据卡片→新增索引/编辑索引/删除索引→提交时调用;数据卡片→编辑数据→提交时调用 | +| `/core/dataset/data/insertData` | POST | 新增一条数据 | `@/web/core/dataset/api.ts` → `EditContentModal.tsx:108`(onInsertData) | 数据卡片→新增数据→提交时调用 | +| `/core/dataset/data/delete` | DELETE | 删除单条数据 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:406`(onDeleteOneData) | 数据卡片→删除数据→确认时调用 | + +## 集合信息 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/detail` | GET | 获取集合详情 | `@/web/core/dataset/api.ts` → `RefinedDataCard/index.tsx:133`(useRequest 自动触发) | 数据卡片→页面初始化→加载集合信息(训练类型、errorCount 等)时调用 | + +--- + +## API 调用链追踪 + +### `/core/dataset/data/v2/list` 调用链 + +``` +RefinedDataCard (usePagination 封装) + ├── 触发: 页面首次加载、翻页、切换每页条数、搜索防抖结束、训练状态轮询 + ├── 参数: { collectionId, searchText, pageNum, pageSize } + └── 响应处理: 更新 datasetDataList 和 total → 渲染卡片列表和分页器 +``` + +### `/core/dataset/data/detail` 调用链 + +``` +RefinedDataCard (fetchActiveDataDetail) + ├── 触发: 点击数据卡片激活;训练状态变更后刷新详情 + ├── 参数: { id: dataId } + └── 响应处理: 设置 activeDataDetail → 右侧面板渲染索引列表(过滤 default 类型) + +EditContentModal (编辑模式自动加载) + ├── 触发: 编辑弹窗打开 + ├── 参数: { id: dataId } + └── 响应处理: 获取 currentData(含 indexes),用于提交时保留现有索引 +``` + +### `/core/dataset/data/getindex` 调用链 + +``` +RefinedDataCard (useEffect 计算初始分页) + ├── 触发: activeId 存在时自动执行 + ├── 参数: { dataId, collectionId, datasetId } + └── 响应处理: 获取 index → 计算 pageNum → 触发对应页数据加载;index === -1 时 Toast 警告并加载第一页 +``` + +### `/core/dataset/data/update` 调用链 + +``` +RefinedDataCard (saveNewIndex) + ├── 触发: 新增自定义索引后保存 + ├── 参数: { dataId, q, a, indexes: [新增索引项] } + └── 响应处理: 成功后重新加载 activeDataDetail + +RefinedDataCard (editIndex) + ├── 触发: 编辑已有索引后保存 + ├── 参数: { dataId, q, a, indexes: [更新后索引列表] } + └── 响应处理: 成功后重新加载 activeDataDetail + +RefinedDataCard (deleteIndex) + ├── 触发: 删除已有索引后保存 + ├── 参数: { dataId, q, a, indexes: [移除目标索引后的列表] } + └── 响应处理: 成功后重新加载 activeDataDetail + +EditContentModal (onUpdateData) + ├── 触发: 编辑数据弹窗提交 + ├── 参数: { dataId, q, a, indexes: 保留现有索引 } + └── 响应处理: 成功后 Toast → 关闭弹窗 → 本地更新列表 → 若编辑的为当前激活卡片则刷新详情 +``` + +### `/core/dataset/data/insertData` 调用链 + +``` +EditContentModal (onInsertData) + ├── 触发: 新增数据弹窗提交 + ├── 参数: { collectionId, q, a } + └── 响应处理: 成功后 Toast "添加数据成功,索引为 N" → 关闭弹窗 → 刷新数据列表 +``` + +### `/core/dataset/data/delete` 调用链 + +``` +RefinedDataCard (onDeleteOneData) + ├── 触发: 删除确认弹窗点击确认 + ├── 参数: { id: dataId } + └── 响应处理: 成功后本地过滤移除该项 → 刷新列表数据 → Toast "删除成功" +``` + +### `/core/dataset/collection/detail` 调用链 + +``` +RefinedDataCard (useRequest 自动触发) + ├── 触发: collectionId 变化时自动加载 + ├── 参数: { id: collectionId } + └── 响应处理: 获取 collection(含 trainingType、errorCount)→ 决定页面显示(FAQ/普通模式区分、异常标签显示) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..2afe20b00602 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,45 @@ +--- +capability_label: 数据卡片 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:39:36.000Z" +parent_module: 数据集详情 +roles: + - 管理员/编辑者 + - 只读用户 +router_paths: + - /dataset/detail?currentTab=dataCard +--- + +# 数据卡片 — 组件列表 + +## RefinedDataCard/ + +本模块组件均位于 `projects/app/src/pageComponents/dataset/detail/RefinedDataCard/` 目录下。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx` (RefinedDataCard) | 数据卡片主页面:左侧数据列表+右侧索引详情面板 | 消费 DatasetPageContext、CollectionPageContext、@fastgpt/web 通用组件、Markdown 组件、EmptyTip 等;引用 ExceptionInfoModal(RefinedCollectionCard 模块) | 否(内部页面组件) | 无 Props,通过 Context 和路由参数获取数据 | +| `ContentIndexCard.tsx` | 单条索引内容卡片:支持查看(只读)、编辑(Textarea)、删除(确认弹窗) | 消费 @fastgpt/web 组件:PopoverConfirm、MyIcon | 否(纯内部组件) | content: string(必填), isNew?: boolean, onEdit?, onDelete?, onCancel?, isLoading? | +| `EditContentModal.tsx` | 数据新增/编辑弹窗:支持文本和 FAQ 两种模式,含表单校验和提交 | 消费 @fastgpt/web 组件:MyModal、MyBox、FormLabel;消费 react-hook-form | 否(纯内部组件) | mode: 'add' \| 'edit', dataId?, collectionId?, defaultValue: {q?, a?}, trainingType?, imagePreviewUrl?, onClose, onSuccess | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| ExceptionInfoModal | `../RefinedCollectionCard/ExceptionInfoModal` | 集合卡片(RefinedCollectionCard) | +| BuildingAnimation | `../components/BuildingAnimation` | 数据集详情(公共组件) | +| NavBar / TabEnum | `../NavBar` | 数据集详情(NavBar) | +| Markdown | `@/components/Markdown` | 全局共享组件 | +| EmptyTip | `@fastgpt/web/components/common/EmptyTip` | Web 通用组件库 | +| MyBox | `@fastgpt/web/components/common/MyBox` | Web 通用组件库 | +| MyIcon | `@fastgpt/web/components/common/Icon` | Web 通用组件库 | +| MyDivider | `@fastgpt/web/components/common/MyDivider` | Web 通用组件库 | +| MyImage | `@fastgpt/web/components/common/Image/MyImage` | Web 通用组件库 | +| MyTag | `@fastgpt/web/components/common/Tag/index` | Web 通用组件库 | +| MyTooltip | `@fastgpt/web/components/common/MyTooltip` | Web 通用组件库 | +| MyModal | `@fastgpt/web/components/common/MyModal` | Web 通用组件库 | +| FormLabel | `@fastgpt/web/components/common/MyBox/FormLabel` | Web 通用组件库 | +| PopoverConfirm | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | Web 通用组件库 | +| MyInput | `@/components/MyInput` | 全局共享组件 | +| MyPhotoView | `@fastgpt/web/components/common/Image/PhotoView` | Web 通用组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..45a9b0723e82 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,105 @@ +--- +capability_label: 数据集配置 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:44:40Z +parent_module: 数据集详情 +roles: [管理员, 协作者] +router_paths: [/dataset/detail?currentTab=info] +--- + +# 数据集配置 — API索引 + +## 数据集详情与更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/dataset/detail | GET | 获取数据集详情 | `projects/app/src/web/core/dataset/api.ts:145` → `projects/app/src/web/core/dataset/context/datasetPageContext.tsx:107` | 数据集→数据集详情→页面加载时调用;数据集→数据集详情→状态为syncing/waiting时每10秒轮询;重建嵌入成功后刷新 | +| /core/dataset/update | PUT | 更新数据集配置 | `projects/app/src/web/core/dataset/api.ts:153` → `projects/app/src/web/core/dataset/context/datasetPageContext.tsx:116` | 数据集→数据集配置→切换Agent模型时调用;数据集→数据集配置→切换VLM模型时调用;数据集→数据集配置→保存外部读取地址时调用;数据集→数据集配置→切换自动同步时调用;数据集→数据集配置→编辑基本信息时调用;数据集→数据集配置→编辑API/语雀/飞书配置时调用 | + +## 训练与索引 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /core/dataset/training/rebuildEmbedding | POST | 重建向量嵌入索引 | `projects/app/src/web/core/dataset/api.ts:449` → `projects/app/src/pageComponents/dataset/detail/Info/index.tsx:120` | 数据集→数据集配置→切换向量模型→确认重建弹窗后调用 | + +## 协作者管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /proApi/core/dataset/collaborator/list | GET | 获取协作者列表 | `projects/app/src/web/core/dataset/api/collaborator.ts:13` → `projects/app/src/pageComponents/dataset/detail/Info/index.tsx:452` | 数据集→数据集配置→加载协作者区域时调用 | +| /proApi/core/dataset/collaborator/update | POST | 更新协作者权限 | `projects/app/src/web/core/dataset/api/collaborator.ts:16` → `projects/app/src/pageComponents/dataset/detail/Info/index.tsx:455` | 数据集→数据集配置→协作者管理弹窗→添加/修改协作者角色时调用 | +| /proApi/core/dataset/collaborator/delete | DELETE | 删除协作者 | `projects/app/src/web/core/dataset/api/collaborator.ts:19` → `projects/app/src/pageComponents/dataset/detail/Info/index.tsx:461` | 数据集→数据集配置→协作者管理弹窗→移除协作者时调用 | + +## API 调用链追踪 + +### `/core/dataset/detail` 调用链 + +``` +DatasetPageContextProvider.loadDatasetDetail + ├── 触发: 页面初始化 / 重建嵌入成功后 / 同步状态轮询 + ├── 参数: datasetId + └── 响应处理: 更新 datasetDetail 状态(包括 permission, type, vectorModel, agentModel, vlmModel, autoSync 等字段) + +Info 组件(useContextSelector) + ├── 触发: datasetDetail 状态变更时自动重渲染 + └── 响应处理: 通过 useForm reset 同步表单默认值 +``` + +### `/core/dataset/update` 调用链 + +``` +DatasetPageContextProvider.updateDataset + ├── 触发: Info 组件中 onSave / onEditBaseInfo / 开关切换 + ├── 参数: { id, agentModelId?, vlmModelId?, externalReadUrl?, autoSync?, name?, intro?, avatar?, apiDatasetServer? } + └── 响应处理: 局部更新 datasetDetail 状态(按传入字段合并);模型字段通过 getWebLLMModel/getWebEmbeddingModel 转换为完整模型对象 + +Info.onSave (useRequest) + ├── 触发: Agent模型/VLM模型下拉切换、外部读取地址失焦 + ├── 参数: { id, agentModelId, vlmModelId, externalReadUrl } + └── 响应处理: 成功 toast "更新成功";失败 toast "更新失败" + +Info.onEditBaseInfo (useRequest) + ├── 触发: EditResourceModal 确认编辑 + ├── 参数: { id, name, intro, avatar } + └── 响应处理: 成功关闭弹窗 + toast "更新成功";失败 toast "更新失败" +``` + +### `/core/dataset/training/rebuildEmbedding` 调用链 + +``` +Info.onRebuilding (useRequest) + ├── 触发: 向量模型确认重建弹窗 → 用户点击确认 + ├── 参数: { datasetId, vectorModelId } + └── 响应处理: 成功 → refetchDatasetTraining() 刷新训练队列 + loadDatasetDetail(datasetId) 刷新详情 + toast "重建嵌入已开始";失败 → toast "更新失败" +``` + +### `/proApi/core/dataset/collaborator/list` 调用链 + +``` +MemberManager → CollaboratorContextProvider + ├── 触发: 组件挂载时自动调用(manual: false) + ├── 参数: { datasetId } + └── 响应处理: 返回 clbs(协作者列表)和 parentClbs(父级协作者列表),每个协作者 permission 被包装为 Permission 实例 + + ├── 前置条件: feConfigs.isPlus = true 时才发起请求;否则返回空列表 + └── 刷新依赖: refreshDeps 数组变更时重新请求 +``` + +### `/proApi/core/dataset/collaborator/update` 调用链 + +``` +MemberManager → CollaboratorContextProvider.onUpdateCollaboratorsThen + ├── 触发: MemberModal 中确认添加/修改协作者 + ├── 参数: { datasetId, tmbId/groupId/orgId, role } + └── 响应处理: 成功后 refetchCollaboratorList() 刷新列表 +``` + +### `/proApi/core/dataset/collaborator/delete` 调用链 + +``` +MemberManager → CollaboratorContextProvider.onDelOneCollaboratorThen + ├── 触发: MemberModal 中移除协作者 + ├── 参数: { datasetId, tmbId/groupId/orgId }(三者必填其一) + └── 响应处理: 成功后 refetchCollaboratorList() 刷新列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9c0b433bc81d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\225\260\346\215\256\351\233\206\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,51 @@ +--- +capability_label: 数据集配置 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:44:40Z +parent_module: 数据集详情 +roles: [管理员, 协作者] +router_paths: [/dataset/detail?currentTab=info] +--- + +# 数据集配置 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 数据集配置Tab主页面,展示和编辑数据集参数 | 内部 | 否 | datasetId: string | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dataset/detail/Info/components/EditApiServiceModal.tsx` | 编辑API数据集/语雀/飞书服务配置弹窗 | 内部 | 否 | id: string, apiDatasetServer, onClose, onEdit | +| `projects/app/src/pageComponents/dataset/MemberManager.tsx` | 协作者管理入口组件,封装CollaboratorContextProvider | 引用自 `@/components/support/permission/MemberManager/context` | 否(数据集内部使用) | managePer: MemberManagerInputPropsType | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| EditResourceModal | `@/components/common/Modal/EditResourceModal` | 通用组件 | +| AIModelSelector | `@/components/Select/AIModelSelector` | 通用组件 | +| DatasetTypeTag | `@/components/core/dataset/DatasetTypeTag` | 数据集核心组件 | +| Avatar | `@fastgpt/web/components/common/Avatar` | 共享Web组件 | +| MyDivider | `@fastgpt/web/components/common/MyDivider/index` | 共享Web组件 | +| MyTooltip | `@fastgpt/web/components/common/MyTooltip` | 共享Web组件 | +| QuestionTip | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享Web组件 | +| FormLabel | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享Web组件 | +| MyIcon | `@fastgpt/web/components/common/Icon` | 共享Web组件 | +| MemberListCard | `@/components/support/permission/MemberManager/MemberListCard`(通过 MemberManager 间接使用) | 权限组件 | +| MemberModal | `@/components/support/permission/MemberManager/MemberModal`(通过 CollaboratorContextProvider 动态加载) | 权限组件 | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| useConfirm | `@fastgpt/web/hooks/useConfirm` | 确认弹窗(重建索引、同步计划) | +| useRequest | `@fastgpt/web/hooks/useRequest` | 异步请求封装(保存、重建) | +| useForm | `react-hook-form` | 表单状态管理 | +| useContextSelector | `use-context-selector` | 精准订阅 DatasetPageContext | +| useSystemStore | `@/web/common/system/useSystemStore` | 系统配置和模型列表 | +| useTranslation | `next-i18next` | 国际化翻译 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ac421fbf6153 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,47 @@ +--- +capability_label: 检索测试 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — API索引 + +## 检索测试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/searchTest` | POST | 执行数据集文本检索测试 | `projects/app/src/web/core/dataset/api.ts:278` → `projects/app/src/pageComponents/dataset/detail/Test.tsx:112` | 数据集详情→检索测试Tab→输入文本→点击测试按钮时调用 | +| `/core/dataset/database/searchTest` | POST | 执行数据库数据集检索测试(自然语言转 SQL 查询) | `projects/app/src/web/core/dataset/api.ts:491` → `projects/app/src/pageComponents/dataset/detail/Test.tsx:143` | 数据集详情→检索测试Tab(数据库类型数据集)→输入查询→点击测试按钮时调用 | + +## API 调用链追踪 + +### `/core/dataset/searchTest` 调用链 + +``` +Test 组件 (Test.tsx:110) + ├── 触发: 用户输入测试文本后点击"测试"按钮(非数据库类型数据集) + ├── 参数: { datasetId, text, searchMode, embeddingWeight, embeddingModelId, usingReRank, rerankModelId, rerankMethod, rerankWeight, similarity, limit, datasetSearchUsingExtensionQuery, datasetSearchExtensionModelId, datasetSearchExtensionBg } + └── 响应处理: + ├── 成功: 构造 SearchTestStoreItemType 对象 → pushDatasetTestItem 存入 Store → setDatasetTestItem 更新当前选中项 → 右侧面板展示结果列表 + └── 空结果: toast 警告提示 "未检索到相关数据" +``` + +### `/core/dataset/database/searchTest` 调用链 + +``` +Test 组件 (Test.tsx:141) + ├── 触发: 用户输入自然语言查询后点击"测试"按钮(数据库类型数据集) + ├── 参数: { datasetId, query, modelId } + └── 响应处理: + ├── 成功: 构造 SearchTestStoreItemType 对象(searchMode 固定为 database)→ pushDatasetTestItem 存入 Store → setDatasetTestItem 更新当前选中项 → 右侧展示 SQL 语句和解析答案 + └── 空结果(无 answer): toast 警告提示 "未检索到相关数据" +``` + +## 说明 + +本模块不包含独立的 API 定义文件。API 函数定义在共享的 `@/web/core/dataset/api` 模块中。本模块(检索测试 Tab)通过 import 直接调用这些共享 API 函数,并在组件内部处理响应逻辑。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..b6f10a7e667b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,21 @@ +--- +capability_label: 检索测试 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +## 说明 + +本模块未检测到 Hooks/Composables/Utils/Constants 目录或目录中无导出函数。本模块使用的工具函数均来自外部模块引用: + +- `isDatabaseDataset`:来自 `@/pageComponents/dataset/utils/index`,用于判断数据集是否为数据库类型 +- `useRequest`:来自 `@fastgpt/web/hooks/useRequest`,通用的异步请求管理 Hook +- `useToast`:来自 `@fastgpt/web/hooks/useToast`,消息提示 Hook + +- 如果需要了解组件的状态管理逻辑,参见 [Store数据流](../../技术知识库/数据集/数据集详情/检索测试/Store数据流.md) +- 组件信息参见 [组件列表](../../技术知识库/数据集/数据集详情/检索测试/组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..1f3e220ca7a7 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,109 @@ +--- +capability_label: 检索测试 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — Store 数据流 + +## Store 概览 + +| Store 文件 | Store ID | 用途 | +|-----------|---------|------| +| `projects/app/src/web/core/dataset/store/searchTest.ts` | `useSearchTestStore` | 管理数据集的检索测试历史记录,支持本地持久化 | + +### useSearchTestStore — `projects/app/src/web/core/dataset/store/searchTest.ts` + +#### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `datasetTestList` | `SearchTestStoreItemType[]` | 检索测试历史记录列表,最多保留 50 条 | + +#### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `pushDatasetTestItem` | `data: SearchTestStoreItemType` | 将新的测试结果插入列表头部,超过 50 条时截断 | 无(纯 Store 操作) | +| `delDatasetTestItemById` | `id: string` | 按 ID 删除指定测试历史记录 | 无(纯 Store 操作) | +| `updateDatasetItemById` | `data: SearchTestStoreItemType` | 按 ID 更新指定测试历史记录 | 无(纯 Store 操作) | + +**SearchTestStoreItemType 结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `string` | 唯一标识(nanoid) | +| `datasetId` | `string` | 所属数据集 ID | +| `text` | `string` | 测试输入文本 | +| `time` | `Date` | 测试时间 | +| `duration` | `string` | 检索耗时 | +| `results` | `SearchDataResponseItemType[]` | 检索结果列表 | +| `searchMode` | `DatasetSearchModeEnum` | 使用的搜索模式 | +| `limit` | `number` | 返回条数上限 | +| `usingReRank` | `boolean` | 是否使用 ReRank | +| `similarity` | `number` | 相似度阈值 | +| `queryExtensionModel` | `string?` | 问题扩展使用的模型 ID | + +#### 持久化配置 + +- **存储方式**:Zustand persist(localStorage) +- **存储 Key**:`searchTestStore` +- **持久化字段**:仅 `datasetTestList`(通过 `partialize` 选择性持久化) + +## 数据流向 + +### 检索测试执行 → 结果存储流程 + +``` +Test 组件 useSearchTestStore API Backend + │ │ │ │ + │ 用户点击"测试"按钮 │ │ │ + │ 调用 postSearchText() │ │ │ + ├─────────────────────────────────────────────────────►│ │ + │ │ │ POST /core/dataset/ │ + │ │ │ searchTest │ + │ │ ├─────────────────────────────►│ + │ │ │ │ + │ │ │ ◄── SearchDatasetTestResponse + │ │ │◄─────────────────────────────┤ + │ onSuccess(res) │ │ │ + │ 构造 SearchTestStoreItemType│ │ │ + │ pushDatasetTestItem(item) │ │ │ + ├─────────────────────────────►│ │ │ + │ │ [data, ...list] │ │ + │ │ .slice(0, 50) │ │ + │ │ 持久化到 localStorage │ │ + │ setDatasetTestItem(item) │ │ │ + │ UI 更新:右侧展示结果 │ │ │ +``` + +### 历史记录读取 → 展示流程 + +``` +TestHistories 组件 useSearchTestStore localStorage + │ │ │ + │ 组件挂载 │ │ + │ datasetTestList 读取 │ │ + ├─────────────────────────────►│ │ + │ │ 从 localStorage 恢复 │ + │ ├─────────────────────────►│ + │ │◄─────────────────────────┤ + │ filter(datasetId) │ │ + │ testHistories (useMemo) │ │ + │ UI 渲染历史列表 │ │ +``` + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| 父子 Props | Test 组件传递 datasetId/testItem 到子组件 | `Test` → `TestHistories`、`TestResults`、`TestResultDatabase` | +| Store 共享状态 | 检索历史记录的读写 | `Test` ↔ `TestHistories`(通过 `useSearchTestStore`) | +| Context | 读取数据集详情(类型、向量模型等) | `Test` → `DatasetPageContext` | +| Context | 读取系统默认模型配置 | `Test` → `useSystemStore` | +| 回调 Props | 子组件通知父组件更新当前选中项 | `TestHistories` → `Test`(通过 `setDatasetTestItem`) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..dfe296f7d1df --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,17 @@ +--- +capability_label: 检索测试 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +## 说明 + +本模块无对外共享组件(检索测试 Tab 内部组件不被外部模块引用,且无 shared/ 目录)。 + +- 内部组件列表见 [组件列表](../../技术知识库/数据集/数据集详情/检索测试/组件列表.md) +- 跨模块依赖摘要见 [模块概览](./模块概览.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..d40e6699f3a2 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\346\243\200\347\264\242\346\265\213\350\257\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,30 @@ +--- +capability_label: 检索测试 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=test +--- + +# 检索测试 — 组件列表 + +## pageComponents/dataset/detail/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 检索测试主组件,整合输入、历史记录、结果展示的左右分栏布局 | 引用自 @/components — ChunkInfoCard、SearchParamsTip、DatasetParamsModal、AIModelSelector | 否 | `datasetId: string` | +| `projects/app/src/pageComponents/dataset/detail/Test.tsx:TestHistories` | 检索测试历史记录列表,展示和切换历史检索结果 | 内部 — useSearchTestStore、EmptyTip、MyTooltip | 否 | `datasetId, datasetTestItem?, setDatasetTestItem` | +| `projects/app/src/pageComponents/dataset/detail/Test.tsx:TestResults` | 普通数据集检索结果展示,含 TOP 排名、评分项、数据块 Q&A | 引用自 @/components — ChunkInfoCard | 否 | `datasetTestItem?: SearchTestStoreItemType` | +| `projects/app/src/pageComponents/dataset/detail/Test.tsx:TestResultDatabase` | 数据库数据集检索结果展示,含 SQL 语句和解析答案 | 内部 | 否 | `datasetTestItem?: SearchTestStoreItemType` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `ChunkInfoCard` | `@/components/core/chat/components/ChunkInfoCard` | 核心 — 对话组件 | +| `SearchParamsTip` | `@/components/core/dataset/SearchParamsTip` | 核心 — 数据集组件 | +| `DatasetParamsModal` | `@/components/core/app/DatasetParamsModal` | 核心 — 应用组件 | +| `AIModelSelector` | `@/components/Select/AIModelSelector` | 核心 — 选择器组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..a645bbb2810a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,76 @@ +--- +capability_label: "数据集详情" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:40:00.000Z" +parent_module: "数据集" +roles: ["所有用户", "管理员"] +router_paths: ["/dataset/detail"] +--- + +# 数据集详情 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/dataset/detail/index.tsx` | 数据集详情页入口,负责 Tab 路由和布局 | 引用自 Dashboard → `DashboardNavbar`;引用自 Common → `PageContainer`、`NextHead` | 否 | `datasetId: string, currentTab: TabEnum` | + +## NavBar/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dataset/detail/NavBar.tsx` | Tab 导航栏、面包屑、操作按钮 | 引用自 Common → `MyTabs`、`MyIcon`、`LightRowTabs`、`FolderPath` | 否 | `currentTab: TabEnum` | + +## CollectionCard/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dataset/detail/CollectionCard/Context.tsx` | 集合列表状态管理(搜索/筛选/排序/分页) | 引用自 datasetPageContext → `DatasetPageContext` | 否 | 无 Props(通过 Context 传递) | +| `src/pageComponents/dataset/detail/CollectionCard/index.tsx` | 集合列表(旧版,用于 website 数据集) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/Header.tsx` | 集合列表头部(搜索/创建按钮) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/DatabaseListTable.tsx` | 数据库表列表(database 数据集专用) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/WebsiteConfig.tsx` | 网站数据集配置 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/BackupImportModal.tsx` | 备份文件导入弹窗 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/TemplateImportModal.tsx` | 模板导入弹窗 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/CollectionCard/TagManageModal.tsx` | 标签管理弹窗 | 内部 | 否 | — | + +## RefinedCollectionCard/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx` | 新版集合列表,支持文件夹树、多选、批量操作、拖拽移动 | 引用自 datasetPageContext → `DatasetPageContext`;引用自 CollectionPageContext | 否 | 无 Props(通过 Context 获取数据) | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/CollectionNavActions.tsx` | 集合操作按钮组(导入/批量操作) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/AddFileModal.tsx` | 添加文件弹窗(选择导入方式) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/StatusFilter.tsx` | 集合状态筛选 | 内部 | 否 | `value, onChange` | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/TagFilter.tsx` | 标签筛选下拉 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/SetTagsModal.tsx` | 单条集合标签设置 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/BatchSetTagsModal.tsx` | 批量设置标签 | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/RefinedCollectionCard/MoveCollectionDuplicateModal.tsx` | 移动集合时重名处理弹窗 | 内部 | 否 | — | + +## 其他业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/dataset/detail/Import/index.tsx` | 导入入口组件(Tab) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/Test.tsx` | 检索测试入口组件(Tab) | 内部 | 否 | `datasetId: string` | +| `src/pageComponents/dataset/detail/Synonym/index.tsx` | 同义词管理入口组件(Tab) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/Info/index.tsx` | 数据集配置入口组件(Tab,移动端) | 内部 | 否 | `datasetId: string` | +| `src/pageComponents/dataset/detail/RefinedDataCard/index.tsx` | 数据卡片入口组件(Tab) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/FileDataCard.tsx` | 结构化数据卡片入口组件(Tab) | 内部 | 否 | — | +| `src/pageComponents/dataset/detail/DataCard.tsx` | 旧版数据卡片组件 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | packages/web | +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | packages/web | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | packages/web | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | packages/web | +| `FolderPath` | `@/components/common/folder/Path` | projects/app | +| `PageContainer` | `@/components/PageContainer` | projects/app | +| `NextHead` | `@/components/common/NextHead` | projects/app | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台模块 | + +> 各 Tab 子能力的详细组件列表(Import 的子组件、RefinedDataCard 的子组件、Synonym 的子组件等)详见各子目录的组件列表文档。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..730ad2edda24 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/API\347\264\242\345\274\225.md" @@ -0,0 +1,43 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — API索引 + +## 集合查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/detail` | GET | 获取集合详细信息 | `api.ts:286` → `FileDataCard.tsx:40` | 数据集→数据集详情→结构化数据预览→加载时调用(获取集合基本信息用于面包屑展示) | + +### `/core/dataset/collection/detail` 调用链 + +``` +FileDataCard + ├── 触发: 页面加载时自动请求 + ├── 参数: id={collectionId}(来自路由 query) + └── 响应处理: 提取 sourceName 显示在返回按钮旁;失败时跳转回数据集详情页 +``` + +## 数据预览 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/database/preview` | GET | 获取结构化集合的预览数据 | `api.ts:577` → `FileDataCard.tsx:53` | 数据集→数据集详情→结构化数据预览→加载时调用(获取列结构和前 20 行数据) | + +### `/core/dataset/database/preview` 调用链 + +``` +FileDataCard + ├── 触发: 页面加载时自动请求 + ├── 参数: collectionId={collectionId}(来自路由 query) + └── 响应处理: 提取 cols(表头列名)、data(数据行)、columnCount、rowCount; + 取前 20 行渲染表格;失败时跳转回数据集详情页 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..5a70bde31847 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,19 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — Hooks工具函数 + +## 说明 + +本模块未检测到独立的 Hooks/Composables/Utils/Constants 目录或导出函数。 + +- 本模块使用的外部 Hook `useRequest` 来自 `@fastgpt/web/hooks/useRequest`,属于项目级共享基础设施 +- 组件信息参见 [组件列表](../技术知识库/数据集/数据集详情/结构化数据卡片/组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..973d51ff6871 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,19 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "15" +doc_label: "共享组件清单" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — 共享组件清单 + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无共享目录)。 + +- 内部组件列表见 [组件列表](../技术知识库/数据集/数据集详情/结构化数据卡片/组件列表.md) +- 本模块消费的外部共享组件(MyBox、MyIcon、MyTooltip、EmptyTip)来自 `@fastgpt/web` 包,属于项目级共享基础设施 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..df5c2f766386 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\347\273\223\346\236\204\345\214\226\346\225\260\346\215\256\345\215\241\347\211\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,37 @@ +--- +capability_label: 结构化数据卡片 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 数据集详情 +roles: [] +router_paths: + - /dataset/detail?currentTab=fileDataCard +--- + +# 结构化数据卡片 — 组件列表 + +## pageComponents/dataset/detail/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `FileDataCard.tsx` | 文件数据库集合的结构化数据预览表格 | 内部 | 否 | 无(从路由 query 读取 collectionId、datasetId) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | Web 共享组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | Web 共享组件 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | Web 共享组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | Web 共享组件 | +| `IconButton` | `@chakra-ui/react` | Chakra UI 组件库 | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 管理 API 请求状态(loading、data、error) | +| `useRouter` | `next/router` | 获取路由参数和页面导航 | +| `useTranslation` | `next-i18next` | 国际化翻译 | +| `useMemo` | `react` | 预览数据的前端处理(取前 20 行) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..6a4eaaa93b71 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,80 @@ +--- +capability_label: 集合管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — API索引 + +## 集合查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/listV2` | POST | 获取数据集集合分页列表 | `api/collection.ts:39` → `Context.tsx:146` | 集合管理→集合列表浏览→首次加载/翻页/搜索/筛选/排序时调用;集合管理→集合列表浏览→处理中集合轮询刷新时调用(10s 间隔) | + +## 集合更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/update` | POST | 更新集合信息(名称、父目录、启用状态、自动同步等) | `api/collection.ts:45` → `index.tsx:354` | 集合管理→单个集合操作→重命名时调用;集合管理→单个集合操作→移动时调用;集合管理→单个集合操作→启用/禁用开关切换时调用;集合管理→单个集合操作→同步设置时调用 | + +## 集合删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/delete` | POST | 删除集合(支持批量) | `api/collection.ts:47` → `index.tsx:373` | 集合管理→单个集合操作→删除确认后调用;集合管理→批量操作→批量删除确认后调用 | + +## 集合同步 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/sync` | POST | 同步链接集合 | `api/collection.ts:49` → `index.tsx:409` | 集合管理→单个集合操作→链接集合同步时调用;集合管理→批量操作→批量同步时调用 | + +## 集合源读取 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/read` | POST | 获取集合源文件 URL | `api/collection.ts:158` → `index.tsx:317` | 集合管理→单个集合操作→查看/下载源文件时调用 | + +## 批量操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/batchDownload` | POST | 批量下载集合(返回 ZIP Blob) | `api/collection.ts:162` → `index.tsx:389` | 集合管理→批量操作→批量下载时调用 | + +## 重复检查 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/checkDuplicate` | POST | 检查移动目标路径是否有同名文件 | `api.ts` → `index.tsx:1282` | 集合管理→单个集合操作→移动确认后检查重名时调用 | + +## 错误重试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/training/retryErrorCollections` | PUT | 重试所有异常集合的训练 | `api.ts:467` → `index.tsx:434` | 集合管理→集合列表浏览→点击「全部重试」时调用 | + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/collection/collaborator/list` | GET | 获取集合协作者列表 | `api/collaborator.ts` → `index.tsx:1364` | 集合管理→单个集合操作→打开权限配置弹窗时调用 | +| `/core/dataset/collection/collaborator/update` | POST | 更新集合协作者 | `api/collaborator.ts:26` → `index.tsx:1367` | 集合管理→单个集合操作→权限配置弹窗中修改协作者时调用 | +| `/core/dataset/collection/collaborator/delete` | DELETE | 删除集合协作者 | `api/collaborator.ts` → `index.tsx:1372` | 集合管理→单个集合操作→权限配置弹窗中移除协作者时调用 | +| `/core/dataset/collection/resumeInheritPermission` | POST | 恢复继承父文件夹权限 | `api/collaborator.ts` → `index.tsx:1351` | 集合管理→单个集合操作→权限配置弹窗中恢复继承时调用 | +| `/core/dataset/collection/changeOwner` | POST | 变更集合所有者 | `api/collaborator.ts` → `index.tsx:1356` | 集合管理→单个集合操作→权限配置弹窗中变更所有者时调用 | + +## 标签管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/core/dataset/tag/addToCollections` | POST | 批量给集合添加标签 | `api/collection.ts:140` → `BatchSetTagsModal.tsx` | 集合管理→批量操作→批量设置标签时调用 | +| `/proApi/core/dataset/tag/getAllTags` | GET | 获取数据集所有标签 | `api/collection.ts:154` → 父级 Context | 集合管理→集合列表浏览→页面初始加载时调用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..248baaa50204 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,140 @@ +--- +capability_label: 集合管理 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — Store数据流 + +## 状态管理架构 + +集合管理模块使用 **React Context + useContextSelector** 模式进行状态管理,核心 Context 为 `CollectionPageContext`。 + +### Context 层次结构 + +``` +DatasetPageContextProvider (数据集页面级) + └── CollectionPageContextProvider (集合管理级,定义在 ../CollectionCard/Context.tsx) + └── CollectionCard (集合管理主组件) + ├── StatusFilter + ├── TagFilter + ├── DatabaseListTable (数据库模式) + └── ... (其他子组件和弹窗) +``` + +## CollectionPageContext 状态定义 + +### 集合列表数据 + +| 字段 | 类型 | 说明 | 来源 | +|------|------|------|------| +| `collections` | `DatasetCollectionsListItemType[]` | API 返回的原始集合列表 | `usePagination(getDatasetCollections)` | +| `displayedCollections` | `DatasetCollectionsListItemType[]` | 经过标签值过滤后的展示列表 | `useMemo` 计算:collections 按 filterTagValues 前端过滤 | +| `total` | `number` | 集合总数 | `usePagination` 返回 | +| `isGetting` | `boolean` | 是否正在获取数据 | `usePagination` 返回 | + +### 分页参数 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `pageNum` | `number` | 当前页码 | +| `pageSize` | `number` | 每页条数(默认 20) | +| `Pagination` | `() => JSX.Element` | 分页组件渲染函数 | + +### 筛选与排序状态 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `searchText` | `string` | `''` | 搜索文本(与父组件 Header 共享) | +| `filterTags` | `string[]` | `[]` | 标签 ID 筛选列表 | +| `filterTagValues` | `Record` | `{}` | 标签值筛选(tagId → values 映射),用于前端过滤 displayedCollections | +| `statusFilter` | `CollectionStatusEnum \| undefined` | `undefined` | 集合状态筛选 | +| `sortBy` | `'name' \| 'updateTime' \| 'createTime' \| 'dataAmount' \| null` | `null` | 排序字段 | +| `sortOrder` | `'asc' \| 'desc'` | `'asc'` | 排序方向 | + +### 其他状态 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `hasDatabaseConfig` | `boolean` | 数据集是否有数据库配置(判断 database 类型) | +| `hasTrainingData` | `boolean` | 是否存在训练中的集合(collection.trainingAmount > 0) | +| `scrollContainerRef` | `RefObject` | 表格滚动容器引用 | + +### 操作函数 + +| 函数 | 说明 | +|------|------| +| `getData(pageNum)` | 获取指定页的集合数据,同时触发 `refetchDatasetTraining` 刷新训练状态 | +| `handleOpenConfigPage(mode, databaseName, activeStep)` | 跳转到数据集导入 Tab 管理数据库表结构 | +| `openDatasetSyncConfirm()` | 触发数据集级同步确认(用于 websiteDataset) | +| `onOpenWebsiteModal()` | 打开网站数据集配置弹窗 | + +## 数据流转 + +### 集合列表加载流程 + +``` +用户进入页面 / 切换 Tab + → CollectionPageContextProvider 挂载 + → usePagination(getDatasetCollections, { params: { datasetId, parentId, ... }, refreshDeps: [...] }) + → 调用 POST /core/dataset/collection/listV2 + → 返回 { list: DatasetCollectionsListItemType[], total: number } + → 更新 collections + total + Pagination + → useMemo 计算 displayedCollections(按 filterTagValues 前端过滤) + → CollectionCard 通过 useContextSelector 读取并渲染表格 +``` + +### 筛选/排序触发刷新流程 + +``` +用户操作(搜索/状态筛选/标签筛选/排序) + → 更新 Context 中的对应状态(setSearchText / setStatusFilter / setSortBy 等) + → refreshDeps 变化触发 usePagination 重新请求 + → 调用 POST /core/dataset/collection/listV2(含更新后的参数) + → 返回新数据 + → 更新 collections + displayedCollections + → 表格重新渲染 +``` + +### 轮询刷新流程 + +``` +CollectionCard 中 useRequest 监听: + - hasProcessingCollections (queued/parsing/indexing 状态) → 10s 间隔轮询 + - hasTrainingData && datasetDetail.status === 'active' → 10s 间隔轮询 + → 调用 getData(pageNum)(isPollingRef = true 静默模式) + → 更新 collections 但不显示加载遮罩 + → 静默更新状态标签和进度 +``` + +### 标签值过滤流程(前端过滤) + +``` +filterTagValues = { tagId1: ['valueA'], tagId2: ['valueB'] } + → useMemo 遍历 collections + → 每个 collection 的 tags 对象数组 + → 检查是否满足 filterTagValues 中任一条件 + → 满足 → 保留 + → 不满足 → 过滤掉 + → 输出 displayedCollections +``` + +## 消费关系 + +CollectionPageContext 被以下组件消费(通过 `useContextSelector`): + +| 消费组件 | 消费的字段 | +|---------|-----------| +| `CollectionCard (index.tsx)` | collections, displayedCollections, Pagination, total, getData, isGetting, pageNum, pageSize, searchText, filterTags, statusFilter, sortBy, sortOrder, scrollContainerRef, hasTrainingData, handleOpenConfigPage | +| `Header.tsx` (父组件) | searchText, setSearchText, filterTags, setFilterTags, openDatasetSyncConfirm, onOpenWebsiteModal, hasDatabaseConfig, handleOpenConfigPage | +| `HeaderTagPopOver.tsx` | filterTags, filterTagValues, setFilterTags, setFilterTagValues | +| `DatabaseListTable.tsx` | formatCollections, total, onUpdateCollection 等 | +| `EmptyCollectionTip.tsx` | (空状态提示) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..8f66f34d3f8e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\346\225\260\346\215\256\351\233\206\350\257\246\346\203\205/\351\233\206\345\220\210\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,60 @@ +--- +capability_label: 集合管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:39:35Z" +parent_module: 数据集详情 +roles: + - owner + - collaborator + - viewer +router_paths: + - /dataset/detail?currentTab=collectionCard +--- + +# 集合管理 — 组件列表 + +## RefinedCollectionCard/(页面主组件及其子组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx` | 集合管理主页面组件,包含完整的集合列表表格、操作菜单、批量操作栏和相关弹窗 | 消费:CollectionPageContext、DatasetPageContext、@fastgpt/web 组件库、@fastgpt/global 数据集模块 | 否(纯页面组件) | — | +| `StatusFilter.tsx` | 集合状态筛选下拉器,支持按 active/queued/parsing/indexing/error 筛选 | 内部 | 否 | `value: CollectionStatusEnum, onChange: (v) => void` | +| `TagFilter.tsx` | 集合标签筛选器 | 内部 | 否 | — | +| `SetTagsModal.tsx` | 单个集合的标签设置弹窗 | 内部 | 否 | `collection: DatasetCollectionsListItemType, onClose: () => void` | +| `BatchSetTagsModal.tsx` | 批量设置标签弹窗 | 消费:postAddTagsToCollections API | 否 | `selectedCollections: DatasetCollectionsListItemType[], datasetId: string, onClose: () => void` | +| `TagsPopOver.tsx` | 集合标签悬浮展示组件 | 内部 | 否 | — | +| `HeaderTagPopOver.tsx` | 表格表头标签列的 Popover 组件 | 内部 | 否 | — | +| `TagManageModal.tsx` | 标签管理弹窗 | 内部 | 否 | — | +| `TagRowsEditor.tsx` | 标签行编辑器 | 内部 | 否 | — | +| `ExceptionInfoModal.tsx` | 单个集合异常信息查看弹窗 | 内部 | 否 | `datasetId: string, collectionId: string, onClose: () => void, onSuccess: () => void` | +| `DatabaseExceptionModal.tsx` | 数据库表异常信息查看弹窗 | 内部 | 否 | `datasetId: string, collectionId: string, onClose: () => void, onSuccess: () => void` | +| `MoveCollectionDuplicateModal.tsx` | 移动集合时文件名重复处理弹窗 | 内部 | 否 | `isOpen: boolean, onClose: () => void, duplicateFiles: string[], onSkip: () => void, onContinueMove: () => void, onReplaceFiles: () => void` | +| `DuplicateConfirmModal.tsx` | 重复文件确认弹窗 | 内部 | 否 | — | +| `Md5DuplicateModal.tsx` | MD5 重复文件处理弹窗 | 内部 | 否 | — | +| `AddFileModal.tsx` | 添加文件弹窗 | 内部 | 否 | — | +| `APIFileSelectModal.tsx` | API 数据集文件选择弹窗 | 内部 | 否 | — | +| `FaqImportModal.tsx` | FAQ 导入弹窗 | 内部 | 否 | — | +| `GeneralImportModal.tsx` | 通用导入弹窗 | 内部 | 否 | — | + +## CollectionNavActions.tsx + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|---------| +| `CollectionNavActions.tsx` | 集合导航操作栏组件 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyIcon` | `@fastgpt/web/components/common/Icon` | @fastgpt/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | @fastgpt/web | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | @fastgpt/web | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | @fastgpt/web | +| `MyTag` | `@fastgpt/web/components/common/Tag/index` | @fastgpt/web | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | @fastgpt/web | +| `MyPopover` | `@fastgpt/web/components/common/MyPopover` | @fastgpt/web | +| `SelectCollections` | `@/web/core/dataset/components/SelectCollections` | 数据集核心 | +| `DatabaseListTable` | `../CollectionCard/DatabaseListTable` | 集合管理(旧版) | +| `EmptyCollectionTip` | `../CollectionCard/EmptyCollectionTip` | 集合管理(旧版) | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限管理 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..88f9e748b1f3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\346\225\260\346\215\256\351\233\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,81 @@ +--- +capability_label: 数据集 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [管理员, 协作者, 只读用户] +router_paths: [/dataset/list, /dataset/detail] +--- + +# 数据集 — 组件列表 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/dataset/list/index.tsx` | 数据集列表页面,包含搜索、创建、权限管理等入口 | 引用 DashboardNavbar、BgDecoration 等工作台组件 | 否 | parentId(路由参数) | +| `projects/app/src/pages/dataset/detail/index.tsx` | 数据集详情页面,按 currentTab 渲染不同 Tab 内容 | 引用 DashboardNavbar、PageContainer 等工作台组件 | 否 | datasetId, currentTab | + +## 列表子模块 (pageComponents/dataset/list/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `context.tsx` | 数据集列表页状态管理 Context,管理数据集列表数据、文件夹路径、搜索、筛选等 | 内部 | 否 | 提供 DatasetsContext | +| `NewList.tsx` | 数据集列表展示组件,支持卡片/网格布局,含编辑、移动、权限操作菜单 | 引用 EditFolderModal(web 通用)、ConfigPerModal(权限系统) | 否 | —(通过 Context 获取数据) | +| `DatasetCard.tsx` | 单个数据集卡片组件,展示数据集名称、描述、类型标签、状态和操作菜单 | 引用 SideTag、MyTooltip 等通用组件 | 否 | 数据集数据对象 | +| `SideTag.tsx` | 数据集类型侧边标签,用于标识普通数据集、网站数据集等类型 | 引用 DatasetTypeEnum | 否 | type(数据集类型枚举) | +| `CreateModal.tsx` | 创建数据集弹窗,根据选择的数据集类型展示不同的创建表单 | 引用 API 层 postCreateDataset | 否 | type(创建类型)、parentId、onClose | +| `List.tsx` | 旧版数据集列表(仍被部分引用) | 内部 | 否 | — | + +## 详情子模块 (pageComponents/dataset/detail/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `NavBar.tsx` | 数据集详情页导航栏,含面包屑路径、Tab 切换、集合操作按钮 | 引用 MyTabs、FolderPath、LightRowTabs | 否 | currentTab(TabEnum) | +| `CollectionCard/` | 知识集合卡片子模块(含 Header、DatabaseListTable、WebsiteConfig 等) | 引用 TagManageModal、BackupImportModal | 否 | — | +| `RefinedCollectionCard/` | 精细化集合卡片(含 TagRowsEditor、DuplicateConfirmModal 等) | 内部 | 否 | — | +| `DataCard.tsx` | 数据卡片组件,展示数据集中的单条数据 | 内部 | 否 | — | +| `RefinedDataCard/` | 精细化数据卡片子模块(含 EditContentModal) | 内部 | 否 | — | +| `FileDataCard.tsx` | 文件数据预览卡片,用于数据库类型知识库的表格预览 | 内部 | 否 | — | +| `Import/` | 导入子模块(含多种导入方式组件、数据预览、进度显示等) | 引用多种数据源类型的导入组件 | 否 | — | +| `Test/` | 搜索测试子模块 | 内部 | 否 | datasetId | +| `Synonym/` | 同义词管理子模块 | 内部 | 否 | — | +| `Info/` | 数据集信息/配置子模块(含 EditApiServiceModal、MemberManager) | 引用 MemberManager、ConfigPromptModal | 否 | datasetId | +| `TrainingParamsModal.tsx` | 训练参数配置弹窗 | 内部 | 否 | — | +| `ConfigPromptModal.tsx` | 增强索引提示词配置弹窗 | 内部 | 否 | — | +| `MetaDataCard.tsx` | 元数据卡片组件 | 内部 | 否 | — | +| `Form/CollectionChunkForm.tsx` | 集合分块表单 | 内部 | 否 | — | + +## 共享组件 (components/core/dataset/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `DatasetTypeTag.tsx` | 数据集类型标签,展示为彩色标签 | 被评测模块引用 | 是 → 评测数据集模块 | type(DatasetTypeEnum) | +| `SelectModal.tsx` | 数据集选择弹窗,用于在其他模块中选择知识库 | 被应用配置等模块引用 | 是 → 应用配置等 | onSelect, 选择配置 | +| `QuoteItem.tsx` | 知识库引用条目展示 | 被对话模块引用 | 是 → 对话模块 | 引用数据 | +| `RawSourceBox.tsx` | 知识库原始来源数据展示框 | 被对话模块引用 | 是 → 对话模块 | 来源数据 | + +## 辅助组件 (pageComponents/dataset/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `EditFolderModal.tsx` | 编辑数据集文件夹弹窗(封装通用 EditFolderModal) | 内部 | 否 | 文件夹数据 | +| `MemberManager.tsx` | 数据集成员管理组件(封装 CollaboratorContextProvider) | 内部 | 否 | permission, name, avatar | +| `utils/index.ts` | 数据集工具函数 | 内部 | 否 | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台 | +| `BgDecoration` | `@/pageComponents/dashboard/BgDecoration` | 工作台 | +| `PageContainer` | `@/components/PageContainer` | 通用 | +| `ConfigPerModal` | `@/components/support/permission/ConfigPerModal` | 权限系统 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `EditFolderModal` | `@fastgpt/web/components/common/MyModal/EditFolderModal` | 共享组件库 | +| `MyTabs` / `LightRowTabs` | `@fastgpt/web/components/common/Tabs` | 共享组件库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库 | +| `FolderPath` | `@/components/common/folder/Path` | 通用 | +| `NextHead` | `@/components/common/NextHead` | 通用 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..165ebb12996a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,114 @@ +--- +capability_label: 登录 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — API索引 + +## 查询/预检 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/preLogin` | GET | 获取登录预检码(code) | `src/web/support/user/api.ts:34` → `src/pageComponents/login/LoginForm/LoginForm.tsx:45` | 登录→密码登录→提交登录前调用 | +| `/support/user/account/checkPswExpired` | GET | 检查密码是否已过期 | `src/web/support/user/api.ts:103` | 登录→登录前检查 | + +## 登录认证 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/loginByPassword` | POST | 密码登录 | `src/web/support/user/api.ts:45` → `src/pageComponents/login/LoginForm/LoginForm.tsx:47` | 登录→密码登录→提交用户名密码时调用 | +| `/proApi/support/user/account/login/oauth` | POST | OAuth 第三方登录 | `src/web/support/user/api.ts:39` → `src/pages/login/provider.tsx:72` | 登录→第三方登录→OAuth 回调后调用 | +| `/proApi/support/user/account/login/fastLogin` | POST | 快速登录 | `src/web/support/user/api.ts:41` → `src/pages/login/fastlogin.tsx:41` | 登录→快速登录→携带 code+token 访问时调用 | +| `/support/user/account/tokenLogin` | GET | Token 自动登录 | `src/web/support/user/api.ts:37` → `src/web/support/user/useUserStore.ts:54` | 应用初始化→检查已有登录态时调用 | +| `/proApi/support/user/account/sso` | GET | SSO 单点登录 | `src/web/support/user/api.ts:43` | 登录→SSO 认证 | + +## 微信登录 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/wx/getQR` | GET | 获取微信登录二维码 | `src/web/support/user/api.ts:51` → `src/pageComponents/login/LoginForm/WechatForm.tsx:34` | 登录→微信扫码登录→加载二维码时调用 | +| `/proApi/support/user/account/login/wx/getResult` | POST | 轮询微信扫码结果 | `src/web/support/user/api.ts:54` → `src/pageComponents/login/LoginForm/WechatForm.tsx:46` | 登录→微信扫码登录→每3秒轮询扫码状态时调用 | + +## OAuth 前置 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/getAuthURL` | POST | 获取 SSO 授权跳转 URL | `src/web/support/user/api.ts` → `src/pageComponents/login/LoginForm/FormLayout.tsx:127` | 登录→OAuth 列表→点击 SSO 按钮时调用 | +| `/proApi/support/user/account/login/wecom/getRedirectUrl` | POST | 获取企业微信授权跳转 URL | `src/web/support/user/api.ts` → `src/pageComponents/login/LoginForm/FormLayout.tsx:141` | 登录→企业微信终端→自动跳转时调用 | + +## 账号管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/register/emailAndPhone` | POST | 邮箱/手机号注册 | `src/web/support/user/api.ts:59` | 登录→注册→提交注册表单时调用 | +| `/proApi/support/user/account/password/updateByCode` | POST | 通过验证码重置密码 | `src/web/support/user/api.ts:80` | 登录→忘记密码→提交新密码时调用 | +| `/proApi/support/user/inform/sendAuthCode` | POST | 发送验证码(注册/找回密码) | `src/web/support/user/api.ts:21` | 登录→注册/忘记密码→获取验证码时调用 | +| `/proApi/support/user/account/captcha/getImgCaptcha` | GET | 获取图形验证码 | `src/web/support/user/api.ts:28` | 登录→注册→需要验证码时调用 | + +## 登出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/loginout` | GET | 退出登录 | `src/web/support/user/api.ts:56` → `src/web/support/user/auth.ts:33` | 登录→页面初始化→清除登录态时调用 | + +--- + +## API 调用链追踪 + +### `/support/user/account/loginByPassword` 调用链 + +``` +LoginForm (src/pageComponents/login/LoginForm/LoginForm.tsx) + ├── 触发: 用户填写用户名密码,点击"登录"按钮或按 Enter + ├── 参数: { username, password(hashStr), code(来自 getPreLogin), language, teamId } + ├── 前置: getPreLogin(username) → GET /support/user/account/preLogin 获取 code + └── 响应处理: 成功后调用 loginSuccess(res) → setUserInfo + router.push(lastRoute) + +LoginForm (URL 参数自动登录) + ├── 触发: URL 携带 ?u=xxx&p=xxx 参数时,页面挂载后自动调用 + ├── 参数: { username: query.u, password: query.p } + └── 响应处理: 同手动登录 +``` + +### `/proApi/support/user/account/login/oauth` 调用链 + +``` +provider (src/pages/login/provider.tsx) + ├── 触发: 第三方平台授权后回调 /login/provider?code=xxx&state=xxx + ├── 前置: 校验 state 防止 CSRF 攻击,清除旧 Token + ├── 参数: { type: loginStore.provider, props(回调 query params), callbackUrl, inviterId, bd_vid, msclkid, fastgpt_sem, sourceDomain, language } + └── 响应处理: 成功后调用 loginSuccess → setUserInfo + router.replace(lastRoute);失败提示错误并返回登录页 +``` + +### `/proApi/support/user/account/login/fastLogin` 调用链 + +``` +FastLogin (src/pages/login/fastlogin.tsx) + ├── 触发: 页面挂载后自动调用 authCode(code, token) + ├── 前置: 清除 Token,校验回调 URL 安全性 + ├── 参数: { code, token } + └── 响应处理: 成功后 setUserInfo + router.push(callbackUrl);失败提示错误并返回 /login +``` + +### `/support/user/account/tokenLogin` 调用链 + +``` +useUserStore.initUserInfo (src/web/support/user/useUserStore.ts) + ├── 触发: 应用初始化时自动调用 + ├── 参数: 无(通过 Cookie/Header 传递认证信息) + └── 响应处理: 成功后 setUserInfo + 设置语言偏好;失败静默处理(用户需手动登录) +``` + +### `/support/user/account/loginout` 调用链 + +``` +clearToken (src/web/support/user/auth.ts) + ├── 触发: 登录页初始化(/login、/login/fastlogin、/login/provider)、用户主动退出 + ├── 前置: clearAdStorage() 清除 localStorage 中以 "logout-" 开头的键 + └── 响应处理: 无特殊处理,调用 API 后即完成 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..3105866e4ba8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,130 @@ +--- +capability_label: 登录 +doc_type: "14" +doc_label: Store数据流 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — Store 数据流 + +> 登录模块本身不创建独立 Store,而是消费全局共享的 `useSystemStore` 和 `useUserStore`。 + +## Store 概览 + +| Store 文件 | Store ID | 用途 | +|-----------|---------|------| +| `src/web/common/system/useSystemStore.ts` | `useSystemStore` | 系统配置(OAuth 设置、登录方式)、登录中间态(loginStore) | +| `src/web/support/user/useUserStore.ts` | `useUserStore` | 用户信息、团队状态、登录成功后的状态写入 | + +## useSystemStore(登录相关子集) + +### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `loginStore` | `{ provider: OAuthEnum, lastRoute: string, state: string } \| undefined` | OAuth 登录中间态:记录发起登录的 provider、回跳路由、CSRF state | +| `feConfigs` | `FastGPTFeConfigsType` | 系统配置,含 OAuth provider 列表、注册/找回密码方式开关、登录方式配置 | +| `feConfigs.oauth` | `{ wechat?, google?, github?, microsoft?, wecom? }` | 启用的 OAuth 第三方登录 | +| `feConfigs.sso` | `{ url?, title?, icon?, autoLogin? }` | SSO 单点登录配置 | +| `feConfigs.register_method` | `string[]` | 允许的注册方式(email/phone) | +| `feConfigs.find_password_method` | `string[]` | 允许的找回密码方式 | + +### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `setLoginStore` | `loginStore \| undefined` | 设置 OAuth 登录中间态,第三方跳转前保存 provider/state/lastRoute | 无 | +| `setLastRoute` | `route: string` | 记录用户最后访问路由,登录成功后回跳 | 无 | +| `initStaticData` | `InitDateResponse` | 初始化系统配置(含 OAuth、模型列表等),应用启动时调用 | 无(由 App 组件传入数据) | + +## useUserStore(登录相关子集) + +### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userInfo` | `UserType \| null` | 用户完整信息,含团队、权限、语言偏好 | +| `isTeamAdmin` | `boolean` | 当前用户是否为团队管理员 | + +### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `setUserInfo` | `user: UserType \| null` | 写入用户信息并同步语言偏好到 localStorage | 无 | +| `initUserInfo` | 无 | 应用初始化时通过 Token 自动登录 | `GET /support/user/account/tokenLogin` | + +## 数据流向 + +### 密码登录流程 + +``` +LoginForm useUserStore API Backend + │ │ │ │ + │ getPreLogin(username) │ │ │ + ├─────────────────────────────┼─────────────────────►│ GET /preLogin │ + │ │ │◄─── {code} ────────────┤ + │ │ │ │ + │ postLogin({user,pwd,code}) │ │ │ + ├─────────────────────────────┼─────────────────────►│ POST /loginByPassword │ + │ │ │◄─── LoginSuccessResp ──┤ + │ │ │ │ + │ setUserInfo(res.user) │ │ │ + ├────────────────────────────►│ │ │ + │ │ state.userInfo=user │ │ + │ │ 同步语言偏好 │ │ + │ router.push(lastRoute) │ │ │ +``` + +### OAuth 登录流程 + +``` +FormLayout useSystemStore Provider Page useUserStore + │ │ │ │ + │ setLoginStore({...}) │ │ │ + ├────────────────────────────►│ │ │ + │ │ state.loginStore=X │ │ + │ router.replace(oauthUrl) │ │ │ + │ │ │ │ + │ [第三方授权完成后回调] │ │ │ + │ │ │ clearToken() │ + │ │ │ oauthLogin(params) │ + │ │ │◄─── LoginSuccessResp ──┤ + │ │ │ │ + │ │ │ setUserInfo(user) │ + │ │ ├───────────────────────►│ + │ │ │ │ state.userInfo=user + │ │ │ setLoginStore(undef) │ + │ │ │ router.replace(route) │ +``` + +### 初始化 Token 登录流程 + +``` +App.initUserInfo useUserStore API + │ │ │ + │ initUserInfo() │ │ + ├────────────────────────────►│ │ + │ │ GET /tokenLogin │ + │ ├─────────────────────►│ + │ │◄─── UserType ────────┤ + │ │ │ + │ │ setUserInfo(user) │ + │ │ state.userInfo=user │ + │ │ state.isTeamAdmin │ + │ │ 语言偏好同步 │ + │◄────────────────────────────┤ │ + │ [登录态有效,跳过登录页] │ │ +``` + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| Store 共享状态(useSystemStore) | 读取系统配置控制 OAuth 按钮展示、登录方式切换 | LoginForm → FormLayout → WechatForm → RegisterForm | +| Store 共享状态(useSystemStore.loginStore) | OAuth 登录时保存中间态,provider 页读取并完成认证 | FormLayout(写) → provider.tsx(读) | +| Props 传递 | 父组件传递 `setPageType` 和 `loginSuccess` 给子表单 | Login(首页) → LoginForm/WechatForm/RegisterForm/ForgetPasswordForm | +| 路由参数 | 传递 `lastRoute`、`code`、`token` 等参数 | `/login?lastRoute=xxx` → `/login/fastlogin?code=xxx&token=xxx` | +| URL Query 参数 | 密码自动填充(?u=xxx&p=xxx) | URL → LoginForm.useMount | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..5fbac9691c5c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,39 @@ +--- +capability_label: 快速登录 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:47:00.000Z +parent_module: 登录 +roles: + - 未登录用户 +router_paths: + - /login/fastlogin +--- + +# 快速登录 — API 索引 + +## 认证 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/fastLogin` | POST | 提交快速登录凭证完成身份认证 | `src/web/support/user/api.ts:41` → `src/pages/login/fastlogin.tsx:41` | 登录→快速登录→页面加载时自动调用 | + +## API 调用链追踪 + +### `/proApi/support/user/account/login/fastLogin` 调用链 + +``` +快速登录页面 (fastlogin.tsx) + ├── 触发: 页面加载时 useEffect 自动调用 authCode(code, token) + ├── 参数: { code: string, token: string } + ├── 响应处理: + │ ├── 成功: setUserInfo(res.user) 写入用户信息 → router.push(callbackUrl) 跳转 + │ └── 失败: Toast 警告提示 → 1 秒后 router.replace('/login') 跳回登录页 + └── 请求方式: 串行,在 clearToken() 之后执行 +``` + +## 说明 + +快速登录模块仅使用一个 API 端点 `postFastLogin`。该 API 定义在共享用户 API 文件 `src/web/support/user/api.ts` 中,与登录首页、第三方登录等模块共享同一 API 层。 + +此外,页面还间接调用了 `GET /support/user/account/loginout`(通过 `clearToken` → `loginOut`),该接口用于清除旧登录状态,属于通用认证基础设施 API,详见父模块[登录 API 索引](../../技术知识库/登录/API索引.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..ab9eac9af503 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\345\277\253\351\200\237\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,34 @@ +--- +capability_label: 快速登录 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:47:00.000Z +parent_module: 登录 +roles: + - 未登录用户 +router_paths: + - /login/fastlogin +--- + +# 快速登录 — 组件列表 + +## pages/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/login/fastlogin.tsx` | 快速登录页面,自动完成 code+token 凭证认证并跳转 | 引用自 用户认证服务 → `postFastLogin`;引用自 用户Store → `useUserStore`;引用自 通用组件 → `Loading` | 否 | `code: string, token: string, callbackUrl: string` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Loading` | `@fastgpt/web/components/common/MyLoading` | 通用组件库 | + +## 消费的外部 Hooks + +| 本模块使用 Hook | 导入路径 | 源模块 | +|---------------|---------|--------| +| `useToast` | `@fastgpt/web/hooks/useToast` | 通用 Hooks 库 | +| `useUserStore` | `@/web/support/user/useUserStore` | 用户状态管理 | +| `useRouter` | `next/router` | Next.js 框架 | +| `useTranslation` | `next-i18next` | i18n 框架 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..111c5bcbdec7 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,68 @@ +--- +capability_label: 登录首页 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:50:00.000Z +parent_module: 登录 +roles: [未登录用户] +router_paths: [/login] +--- + +# 登录首页 — API索引 + +## 查询/检测类 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `https://qifu-api.baidubce.com/ip/local/geo/v1/district` | GET | IP 归属地检测 | `projects/app/src/pages/login/index.tsx` → RedirectDrawer | 登录首页→页面初始化→加载时自动调用 | + +## 配置/选项类 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/getAuthURL` | POST | 获取 SSO 授权跳转 URL | `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录首页→OAuth 登录→点击 SSO 按钮时调用 | + +## OAuth/第三方登录 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/wecom/getRedirectUrl` | POST | 获取企业微信授权跳转 URL | `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录首页→OAuth 登录→点击企业微信按钮时调用 | + +## 说明 + +本模块(登录首页公共层)主要负责页面布局、OAuth 入口和辅助功能,API 调用较少。核心的登录/注册/密码相关 API 见各 Tab 子能力的 API 索引: + +- 密码登录 API → [密码登录/API索引](../登录首页/密码登录/API索引.md) +- 注册 API → [注册/API索引](../登录首页/注册/API索引.md) +- 忘记密码 API → [忘记密码/API索引](../登录首页/忘记密码/API索引.md) +- 微信登录 API → [微信登录/API索引](../登录首页/微信登录/API索引.md) + +## API 调用链追踪 + +### `https://qifu-api.baidubce.com/ip/local/geo/v1/district` 调用链 + +``` +登录首页 (index.tsx) + ├── 触发: 页面加载时 useMount 自动触发 + ├── 参数: 无 + └── 响应处理: 检查 res.country === '中国' 且 prov 非港澳台 → 弹出重定向抽屉;失败时 console.log 错误 + +FormLayout (FormLayout.tsx) + ├── 触发: 用户点击 OAuth 按钮 + ├── 参数: redirectUri, isWecomWorkTerminal(企业微信场景), state(SSO 场景) + └── 响应处理: 存储登录上下文到 useSystemStore → 跳转到授权 URL +``` + +### `/proApi/support/user/account/login/getAuthURL` 调用链 + +``` +FormLayout → onClickOauth + ├── 触发: 用户点击 SSO 按钮 + ├── 参数: { redirectUri, isWecomWorkTerminal } + └── 响应处理: setLoginStore({ provider, lastRoute, state }) → router.replace(redirectUrl) + +FormLayout → onClickOauth (企业微信分支) + ├── 触发: 用户点击企业微信按钮 + ├── 参数: { redirectUri, isWecomWorkTerminal, state } + └── 响应处理: setLoginStore({ provider, lastRoute, state }) → router.replace(redirectUrl) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..d93516cb4220 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,43 @@ +--- +capability_label: 密码登录 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 密码登录 — API索引 + +## 认证 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/preLogin` | GET | 获取预登录验证码 | `projects/app/src/web/support/user/api.ts:34` → `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx:45` | 登录→密码登录→提交登录时调用(在 postLogin 之前,串行依赖其返回的 code) | +| `/support/user/account/loginByPassword` | POST | 密码登录 | `projects/app/src/web/support/user/api.ts:45` → `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx:47` | 登录→密码登录→提交登录时调用(在 getPreLogin 之后);登录→密码登录→URL 自动登录时调用 | + +> **注意**:`postLogin` 在前端将密码经 `hashStr()` 哈希后再发送,不再传输明文密码。 + +## API 调用链追踪 + +### `GET /support/user/account/preLogin` 调用链 + +``` +LoginForm.tsx 的 onclickLogin + ├── 触发: 用户点击登录按钮 / 按 Enter / URL 自动登录 + ├── 参数: { username: 用户输入的用户名 } + └── 响应处理: 解构 { code },作为 postLogin 的参数传入 +``` + +### `POST /support/user/account/loginByPassword` 调用链 + +``` +LoginForm.tsx 的 onclickLogin + ├── 触发: getPreLogin 成功后自动调用(串行) + ├── 参数: { username, password: hashStr(password), code: preLogin返回的code, language, teamId? } + ├── 响应处理: 调用 loginSuccess(res),写入 user 信息并跳转 + └── 错误处理: + ├── 密码错误 (statusText === UserErrEnum.account_psw_error): router.replace 清除 URL 中的 u/p 参数 + └── 其他错误: 由 useRequest 的 onError 统一 toast 提示 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..c288f97e320a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\257\206\347\240\201\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,32 @@ +--- +capability_label: 密码登录 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 密码登录 — 组件列表 + +## 本模块组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx` | 密码登录表单:用户名/密码输入、表单校验、登录提交、自动登录 | 引用自 `FormLayout`、`PolicyTip` | 否(被登录页和 LoginContainer 消费,限于登录模块内) | `setPageType: Dispatch`, `loginSuccess: (e) => void`, `teamId?: string` | +| `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录页通用布局:系统 Logo/标题、OAuth 第三方登录列表、SSO 自动登录 | 引用自 `MyImage`、`Avatar`、`useSystemStore` | 是(被密码登录、微信登录、忘记密码、注册等所有登录子 Tab 共享) | `children: ReactNode`, `setPageType: Dispatch`, `pageType: string` | +| `projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx` | 服务协议与隐私政策提示链接 | 引用自 `useSystemStore`(读取 `feConfigs.showProtocol`、`feConfigs.docUrl`) | 是(被密码登录和微信登录共享) | `isCenter: boolean` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyImage` | `@fastgpt/web/components/common/Image/MyImage` | 共享 WEB 组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享 WEB 组件库 | + +## 关键依赖说明 + +- **FormLayout**(共享布局组件):为所有登录方式提供统一的外观(Logo、标题、OAuth 入口列表、"or"分隔线)。使用 `dynamic(() => Promise.resolve(FormLayout), { ssr: false })` 禁用 SSR。 +- **PolicyTip**(协议提示组件):在登录表单中展示服务协议和隐私政策的链接,支持居中或左对齐两种布局。当 `feConfigs.showProtocol === false` 时不渲染。 +- **Chakra UI 组件**:表单使用 `FormControl`、`Input`、`Button`、`Flex`、`Box` 等 Chakra UI 组件构建,响应式适配移动端和桌面端。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..d588746f78ad --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,47 @@ +--- +capability_label: 微信登录 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: + - /login (pageType=wechat) +--- + +# 微信登录 — API索引 + +## 查询/获取 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/wx/getQR` | GET | 获取微信登录二维码(code 和 codeUrl) | `projects/app/src/web/support/user/api.ts:51` → `projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx:34` | 登录→微信登录→进入页面时调用(页面初始化) | +| `/proApi/support/user/account/login/wx/getResult` | POST | 查询微信扫码登录结果,返回用户登录信息 | `projects/app/src/web/support/user/api.ts:54` → `projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx:46` | 登录→微信登录→每3秒轮询扫码结果时调用 | + +## API 调用链追踪 + +### `GET /proApi/support/user/account/login/wx/getQR` 调用链 + +``` +WechatForm (projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx) + ├── 触发: 页面挂载时自动调用(useQuery,无依赖条件) + ├── 参数: 无 + └── 响应处理: 成功 → 提取 wechatInfo.code 和 wechatInfo.codeUrl;失败 → Toast 提示"获取二维码失败" +``` + +### `POST /proApi/support/user/account/login/wx/getResult` 调用链 + +``` +WechatForm (projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx) + ├── 触发: wechatInfo.code 可用后自动轮询(refetchInterval: 3000ms) + ├── 参数: + │ ├── code: 上一步 getWXLoginQR 返回的微信登录 Code + │ ├── inviterId: localStorage 中的邀请人 ID + │ ├── bd_vid: Cookie 中的百度推广标识 + │ ├── msclkid: Cookie 中的微软广告点击 ID + │ ├── fastgpt_sem: Cookie 中的 FastGPT SEM 标识 + │ └── sourceDomain: document.referrer 来源域名 + └── 响应处理: + ├── 返回有效登录数据 → 清除 fastgpt_sem Cookie → 调用 loginSuccess(data) 完成登录跳转 + └── 返回 null/undefined → 继续轮询 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..ef99210417b0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\276\256\344\277\241\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,32 @@ +--- +capability_label: 微信登录 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 登录首页 +roles: [] +router_paths: + - /login (pageType=wechat) +--- + +# 微信登录 — 组件列表 + +## LoginForm/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx` | 微信扫码登录表单,展示二维码并轮询扫码结果 | 依赖 FormLayout、PolicyTip、@/web/support/user/api、@/web/support/marketing/utils、@fastgpt/web | 否(内部组件) | `loginSuccess: (e) => void, setPageType: Dispatch` | +| `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录表单通用布局容器,含 Logo、系统标题、OAuth 按钮切换、SSO 自动登录 | 依赖 MyImage、Avatar、useSystemStore、@/web/common/api/request | 是(被 LoginForm、WechatForm 共享) | `children: ReactNode, setPageType: Dispatch, pageType: string` | +| `projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx` | 展示服务协议与隐私政策链接,受系统配置控制显隐 | 依赖 useSystemStore、getDocPath | 是(被 LoginForm、WechatForm 共享) | `isCenter: boolean` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| MyImage | `@fastgpt/web/components/common/Image/MyImage` | 共享组件库 | +| Loading | `@fastgpt/web/components/common/MyLoading` | 共享组件库 | +| Avatar | `@fastgpt/web/components/common/Avatar` | 共享组件库 | + +## 说明 + +本模块为叶子能力节点,组件结构简单:核心组件 WechatForm 承载微信扫码登录的全部业务逻辑,FormLayout 提供通用登录布局(Logo、系统标题、OAuth 切换入口),PolicyTip 展示用户协议和隐私政策链接。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3ca8504a73cb --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/API\347\264\242\345\274\225.md" @@ -0,0 +1,56 @@ +--- +capability_label: 忘记密码 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — API索引 + +## 密码重置 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/password/updateByCode` | POST | 通过验证码重置密码 | `projects/app/src/web/support/user/api.ts:79` → `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx:59` | 登录→忘记密码→点击"找回密码"提交时调用 | + +## 验证码 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/inform/sendAuthCode` | POST | 发送邮箱/手机验证码 | `projects/app/src/web/support/user/api.ts:21` → `projects/app/src/web/support/user/hooks/useSendCode.tsx:24` → `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx:41` | 登录→忘记密码→点击"获取验证码"时调用(通过 useSendCode hook) | + +--- + +## `/proApi/support/user/account/password/updateByCode` 调用链 + +``` +ForgetPasswordForm (projects/app/src/pageComponents/login/ForgetPasswordForm.tsx:57-72) + ├── 触发: 用户填写账号+验证码+新密码+确认密码后,点击"找回密码"按钮提交 + ├── 参数: { username: string, code: string, password: string } + │ └── password 经 hashStr() 加密后传输 + ├── 校验: + │ ├── 前端: react-hook-form 校验账号格式、验证码非空、密码规则、两次密码一致 + │ └── 后端: 校验验证码有效性和账号存在性 + ├── 响应: 返回 LoginSuccessResponseType(登录凭证),前端自动登录 + └── 成功后: toast 提示"密码已找回" → 自动登录跳转系统 +``` + +## `/proApi/support/user/inform/sendAuthCode` 调用链(验证码发送) + +``` +ForgetPasswordForm → useSendCode hook (projects/app/src/web/support/user/hooks/useSendCode.tsx:14-112) + ├── 触发: 用户在忘记密码表单的账号输入框右侧点击"获取验证码" + ├── 参数: { username: string, type: "findPassword", googleToken: string, captcha: string, lang: string } + │ ├── googleToken: 通过 Google reCAPTCHA 获取的人机验证 token + │ ├── captcha: 图形验证码(如有) + │ └── lang: 当前语言 + ├── 前置条件: + │ ├── 账号不能为空(为空时 toast 提示"请输入账号") + │ ├── 当前不在倒计时中(codeCountDown > 0 时不可发送) + │ └── 系统配置 googleClientVerKey 时:弹出 SendCodeAuthModal 人机验证弹窗 + ├── 成功后: toast 提示"验证码已发送",按钮进入 60s 倒计时 + └── 失败后: toast 提示"验证码发送失败"及具体错误信息 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..0f127f867a84 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,42 @@ +--- +capability_label: 忘记密码 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — Hooks工具函数 + +## 使用的 Hooks + +### 模块相关 Hooks + +| Hook | 来源 | 返回值 | 用途 | +|------|------|--------|------| +| `useSendCode` | `@/web/support/user/hooks/useSendCode` | `{ codeSending, sendCode, codeCountDown, SendCodeBox, openCodeAuthModal }` | 发送验证码并管理 60s 倒计时状态;返回 `SendCodeBox` 渲染组件用于显示发送按钮和倒计时文字 | + +**useSendCode 详情**: +- 参数:`{ type: "findPassword" }`(验证码类型) +- 内部调用:`sendAuthCode` API → Google reCAPTCHA 人机验证 +- `SendCodeBox`:内联渲染的发送验证码按钮组件,自动处理倒计时显示和可点击状态 +- `codeSending`:loading 状态 +- `sendCode`:手动触发发送 +- `openCodeAuthModal`:人机验证弹窗开启标志 + +### 共享 Hooks + +| Hook | 来源 | 用途 | +|------|------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 管理异步请求的 loading 状态和错误处理 | +| `useToast` | `@fastgpt/web/hooks/useToast` | 显示成功/错误/警告等消息提示 | +| `useForm` | `react-hook-form` | 表单状态管理和校验(mode: `onBlur` 失焦校验) | +| `useTranslation` | `next-i18next` | i18n 国际化翻译 | + +## 使用的工具函数 + +| 函数 | 来源 | 用途 | +|------|------|------| +| `checkPasswordRule` | `@fastgpt/global/common/string/password` | 密码复杂度校验:8-100 位,须包含字母+数字(至少两种组合) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..3da55058b737 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,31 @@ +--- +capability_label: 忘记密码 +doc_type: "14" +doc_label: Store数据流 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — Store数据流 + +## 使用的 Store + +本模块不定义自己的 Store,使用以下共享 Store: + +### useSystemStore + +- **来源**: `@/web/common/system/useSystemStore` +- **使用字段**: + - `feConfigs.find_password_method: string[]` — 启用的密码找回方式列表(`email` / `phone`),用于动态生成账号输入框的占位提示文字 + - `feConfigs.systemTitle: string` — 系统标题,用于表单标题文案(如"找回 {系统名} 账号密码") + - `feConfigs.googleClientVerKey?: string` — Google reCAPTCHA 密钥(传递给 `useSendCode` hook 用于人机验证) + +- **数据流向**: + ``` + useSystemStore (全局) + └── feConfigs (仅读,从后端初始化配置获取) + ├── ──→ ForgetPasswordForm: 控制账号占位符文案、表单标题 + └── ──→ useSendCode: 控制人机验证(googleClientVerKey) + ``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..a9283958f2f5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\345\277\230\350\256\260\345\257\206\347\240\201/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,24 @@ +--- +capability_label: 忘记密码 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:51:08.000Z +parent_module: 登录首页 +roles: [] +router_paths: ["/login"] +--- + +# 忘记密码 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx` | 忘记密码表单,用户通过验证码重置密码 | 内部 | 否 | `setPageType: Dispatch`, `loginSuccess: (e) => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `SendCodeBox` | 由 `useSendCode` hook 返回的内联渲染组件 | 用户支持(Hooks) | +| `SendCodeAuthModal` | `@/components/support/user/safe/SendCodeAuthModal` | 用户安全 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..4ab295f5548b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/API\347\264\242\345\274\225.md" @@ -0,0 +1,69 @@ +--- +capability_label: "注册" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:52:14Z" +parent_module: "登录首页" +roles: ["visitor"] +router_paths: ["/login"] +--- + +# 注册 — API索引 + +## 说明 + +本模块(注册 Tab)不包含独立的 API 定义文件,所有 API 调用通过共享 API 层 `@/web/support/user/api.ts` 发起。以下列出注册流程中调用的 API。 + +--- + +## 注册类 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/register/emailAndPhone` | POST | 提交注册请求 | `projects/app/src/web/support/user/api.ts:59`(`postRegister`) → `projects/app/src/pageComponents/login/RegisterForm.tsx:55` | 登录→注册 Tab→填写完所有字段→点击"确认注册"或按 Enter 时调用 | +| `/proApi/support/user/inform/sendAuthCode` | POST | 发送邮箱/手机验证码 | `projects/app/src/web/support/user/api.ts:21`(`sendAuthCode`) → `projects/app/src/web/support/user/hooks/useSendCode.tsx:24` → `projects/app/src/pageComponents/login/RegisterForm.tsx:50` | 登录→注册 Tab→输入用户名→点击"获取验证码"→完成图形验证后调用 | +| `/proApi/support/user/account/captcha/getImgCaptcha` | GET | 获取图形验证码图片 | `projects/app/src/web/support/user/api.ts:28`(`getCaptchaPic`) → `projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx` → `projects/app/src/pageComponents/login/RegisterForm.tsx:50` | 登录→注册 Tab→点击"获取验证码"→弹出验证码弹窗时调用(由 SendCodeAuthModal 内部触发) | + +--- + +## API 调用链追踪 + +### `POST /proApi/support/user/account/register/emailAndPhone` 调用链 + +``` +RegisterForm (projects/app/src/pageComponents/login/RegisterForm.tsx) + ├── 触发: 用户点击"确认注册"按钮或按 Enter 键 + ├── 参数: + │ ├── username: 用户输入的邮箱或手机号 + │ ├── code: 验证码(8 位) + │ ├── password: 密码(经 hashStr 哈希处理) + │ ├── inviterId: 邀请人 ID(来自 cookie/localStorage) + │ ├── bd_vid: 百度推广 VID + │ ├── msclkid: 微软广告 Click ID + │ ├── fastgpt_sem: FastGPT 语义标记 + │ └── sourceDomain: 来源域名 + ├── 前置: react-hook-form 全部字段校验通过(用户名格式、验证码非空、密码规则、两次密码一致) + ├── 响应处理: + │ ├── 成功: loginSuccess(res) → 自动登录并跳转首页 + Toast "注册成功" + │ ├── 失败: useRequest onError 处理 → 显示错误 Toast + │ └── 后置: removeFastGPTSem() 清除营销标记 + └── 请求状态: requesting(boolean)→ 按钮 isLoading 动画 +``` + +### `POST /proApi/support/user/inform/sendAuthCode` 调用链 + +``` +useSendCode (projects/app/src/web/support/user/hooks/useSendCode.tsx) + ├── 触发: 用户在 SendCodeAuthModal 中完成验证后确认发送 + ├── 参数: + │ ├── username: 目标邮箱或手机号 + │ ├── type: "register"(固定值,表示注册场景) + │ ├── googleToken: Google reCAPTCHA token(如配置了 Google 验证) + │ ├── captcha: 图形验证码输入 + │ └── lang: 当前语言 + ├── 前置: 60s 冷却计时未激活(codeCountDown <= 0)+ 用户名非空 + ├── 响应处理: + │ ├── 成功: Toast "验证码已发送" + 启动 60s 倒计时 + │ └── 失败: Toast "验证码发送失败" + └── 状态: codeSending(loading)+ codeCountDown(倒计时数值) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..c23ab38982e1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\346\263\250\345\206\214/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,30 @@ +--- +capability_label: "注册" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:52:14Z" +parent_module: "登录首页" +roles: ["visitor"] +router_paths: ["/login"] +--- + +# 注册 — 组件列表 + +## pageComponents/login/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/login/RegisterForm.tsx` | 用户注册表单——提供用户名、验证码、密码、确认密码的输入与提交 | 内部(仅被 LoginContainer 动态加载) | 否 | `setPageType: Dispatch`, `loginSuccess: (e) => void` | + +## 说明 + +本模块为登录页面的 Tab 子组件,仅包含一个表单组件 `RegisterForm`。该组件由登录容器 `LoginContainer` 通过 `next/dynamic` 动态加载,在页面类型为 `LoginPageTypeEnum.register` 时渲染。 + +RegisterForm 消费以下外部依赖: +- `useSendCode` Hook(`@/web/support/user/hooks/useSendCode`)— 验证码发送与倒计时管理 +- `useSystemStore`(`@/web/common/system/useSystemStore`)— 系统配置读取 +- `useToast`(`@fastgpt/web/hooks/useToast`)— Toast 通知 +- `useRequest`(`@fastgpt/web/hooks/useRequest`)— 异步请求状态管理 +- `postRegister`(`@/web/support/user/api`)— 注册 API +- `checkPasswordRule`(`@fastgpt/global/common/string/password`)— 密码规则校验 +- 营销工具函数(`@/web/support/marketing/utils`)— 营销参数采集 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..52de8a8df741 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\231\273\345\275\225\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,53 @@ +--- +capability_label: 登录首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:50:00.000Z +parent_module: 登录 +roles: [未登录用户] +router_paths: [/login] +--- + +# 登录首页 — 组件列表 + +## pages/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/login/index.tsx` | 登录首页主组件,管理 Tab 切换、IP 检测、Cookie 同意、OAuth 入口 | 消费 useSystemStore、useUserStore、useChatStore、I18nLngSelector、MyImage、Loading | 否 | ChineseRedirectUrl: string | + +## 内联子组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx → RedirectDrawer` | 中国 IP 用户重定向提示抽屉 | 内部 | 否 | isOpen, onClose, disableDrawer, onRedirect | +| `index.tsx → CookiesDrawer` | Cookie 隐私政策同意抽屉 | 内部 | 否 | onClose, onAgree | + +## pageComponents/login/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/login/index.tsx` | 登录容器组件(LoginContainer),可嵌入弹窗使用 | 消费 useSystemStore、useChatStore、LoginForm | 是(被内嵌登录弹窗消费) | children, onSuccess, teamId | +| `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录表单公共布局:Logo + 标题 + OAuth 按钮区 | 消费 useSystemStore、POST API | 是(被 LoginForm 和 WechatForm 使用) | children, setPageType, pageType | +| `projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx` | 隐私政策与服务协议提示链接 | 消费 useSystemStore | 是(被 LoginForm 和 WechatForm 使用) | isCenter: boolean | +| `projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx` | 密码登录表单(Tab 子能力) | 详见子文档 | 否 | setPageType, loginSuccess, teamId | +| `projects/app/src/pageComponents/login/RegisterForm.tsx` | 注册表单(Tab 子能力) | 详见子文档 | 否 | loginSuccess, setPageType | +| `projects/app/src/pageComponents/login/ForgetPasswordForm.tsx` | 忘记密码表单(Tab 子能力) | 详见子文档 | 否 | setPageType, loginSuccess | +| `projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx` | 微信登录组件(Tab 子能力) | 详见子文档 | 否 | loginSuccess, setPageType | + +## components/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/CommunityModal/index.tsx` | 社区联系方式弹窗 | 消费 useSystemStore、useUserStore、Markdown | 是(被登录页和账户信息页使用) | onClose | +| `projects/app/src/components/Select/I18nLngSelector.tsx` | 语言切换选择器 | 内部 | 是(全局使用) | — | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyImage` | `@fastgpt/web/components/common/Image/MyImage` | web 共享组件 | +| `Loading` | `@fastgpt/web/components/common/MyLoading` | web 共享组件 | +| `Markdown` | `@/components/Markdown` | 共享组件 | + +> Tab 子能力组件(LoginForm、RegisterForm、ForgetPasswordForm、WechatForm)的详细 Props、事件和内部依赖见各自子目录的组件列表文档。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..cca26b646a01 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,66 @@ +--- +capability_label: 第三方登录 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:49:00Z" +parent_module: 登录 +roles: [] +router_paths: + - /login/provider +--- + +# 第三方登录 — API 索引 + +## OAuth 登录 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/account/login/oauth` | POST | OAuth 第三方登录 | `projects/app/src/web/support/user/api.ts:39` → `projects/app/src/pages/login/provider.tsx:72` | 登录→第三方登录回调→页面加载时调用,提交授权码完成登录 | +| `/proApi/support/user/account/login/getAuthURL` | POST | 获取 SSO 认证跳转地址 | `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx:127` → FormLayout 组件 | 登录→登录页→点击 SSO 登录按钮时调用,获取认证重定向 URL | +| `/proApi/support/user/account/login/wecom/getRedirectUrl` | POST | 获取企业微信认证跳转地址 | `projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx:141` → FormLayout 组件 | 登录→登录页→点击企业微信登录按钮时调用,获取认证重定向 URL | + +## 登出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/loginout` | GET | 退出登录并清除服务端会话 | `projects/app/src/web/support/user/api.ts:56` → `projects/app/src/web/support/user/auth.ts:33` | 登录→第三方登录回调→页面加载时首先调用,清除旧令牌 | + +## 团队邀请 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invitationLink/accept` | POST | 接受团队邀请链接 | `projects/app/src/web/support/user/team/api.ts:79` → `projects/app/src/pages/login/provider.tsx:51` | 登录→第三方登录回调→登录成功后(团队状态非 active 且 lastRoute 含有邀请链接时)调用 | + +--- + +## API 调用链追踪 + +### `POST /proApi/support/user/account/login/oauth` 调用链 + +``` +provider.tsx (authProps 函数) + ├── 触发: 页面加载后,state 校验通过,清除旧令牌完成 + ├── 参数: type(provider类型), props(OAuth回调参数), callbackUrl(本页URL), + │ inviterId, bd_vid, msclkid, fastgpt_sem, sourceDomain, language + └── 响应处理: + ├── 成功(有 res): 调用 loginSuccess(res) → setUserInfo + 检查团队状态 + 路由跳转 + └── 失败(无 res/异常): 显示错误 Toast,1秒后跳转错误页(errorRedirectPage) +``` + +### `GET /support/user/account/loginout` 调用链 + +``` +provider.tsx (useEffect) + └── 触发: 页面加载且 initd 为 true 时 + └── 包装在 retryFn 中,最多重试 2 次 + └── 成功后继续执行 authProps(props) +``` + +### `POST /proApi/support/user/team/invitationLink/accept` 调用链 + +``` +provider.tsx (loginSuccess 函数) + ├── 触发: OAuth 登录成功后,user.team.status !== 'active' 且 lastRoute 包含邀请链接ID + ├── 参数: linkId (从 lastRoute URL 的 invitelinkid 参数中提取) + └── 响应处理: 成功后导航到 /dashboard/agent +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..1fbc4cb0f3b1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\254\254\344\270\211\346\226\271\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,30 @@ +--- +capability_label: 第三方登录 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:49:00Z" +parent_module: 登录 +roles: [] +router_paths: + - /login/provider +--- + +# 第三方登录 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/login/provider.tsx` | OAuth 第三方登录回调页面,处理授权码交换和会话建立 | 消费 Loading 组件(来自 @fastgpt/web);消费 useSystemStore、useUserStore | 否 | 无外部 Props(Next.js 页面组件,通过 router.query 获取参数) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Loading` | `@fastgpt/web/components/common/MyLoading` | web 共享组件库 | + +## 说明 + +本模块仅包含一个页面级组件 `provider.tsx`,无内部业务子组件或 widgets。该页面为纯逻辑处理页面,UI 仅展示一个加载动画,所有业务逻辑(令牌清除、OAuth 调用、状态校验、路由跳转)均在该组件内完成。 + +相关 UI 组件(登录表单、OAuth 按钮列表)归属登录主页面模块,详见 [登录模块组件列表](../../技术知识库/登录/组件列表.md)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..0727267bb90a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\347\231\273\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,47 @@ +--- +capability_label: 登录 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T10:40:00.000Z +parent_module: null +roles: [] +router_paths: ["/login", "/login/fastlogin", "/login/provider"] +--- + +# 登录 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pages/login/index.tsx` | 登录首页:管理登录方式切换、IP 检测、Cookie 协议确认 | 依赖 useSystemStore、useUserStore、useChatStore | 否 | `ChineseRedirectUrl: string` | +| `src/pages/login/fastlogin.tsx` | 快速登录:通过 code+token 参数完成免密登录 | 依赖 useUserStore | 否 | `code: string, token: string, callbackUrl: string` | +| `src/pages/login/provider.tsx` | 第三方登录:处理 OAuth 回调,完成第三方认证 | 依赖 useSystemStore、useUserStore | 否 | 无(通过 router.query 获取参数) | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `src/pageComponents/login/LoginForm/LoginForm.tsx` | 密码登录表单:用户名密码输入、表单校验、登录提交 | 引用自 useSystemStore、useToast、useRequest | 否 | `setPageType: Dispatch, loginSuccess: Function, teamId?: string` | +| `src/pageComponents/login/LoginForm/FormLayout.tsx` | 登录表单布局:Logo、OAuth 按钮列表、自动登录逻辑 | 引用自 useSystemStore | 否 | `children: ReactNode, setPageType: Dispatch, pageType: string` | +| `src/pageComponents/login/LoginForm/WechatForm.tsx` | 微信扫码登录:展示二维码并轮询扫码结果 | 引用自 useSystemStore、useToast | 否 | `setPageType: Dispatch, loginSuccess: Function` | +| `src/pageComponents/login/LoginForm/PolicyTip.tsx` | 隐私政策提示:展示服务协议和隐私政策链接 | 引用自 useSystemStore | 否 | `isCenter: boolean` | +| `src/pageComponents/login/LoginForm/RegisterForm.tsx` | 注册表单:邮箱/手机号注册流程 | 引用自 useSystemStore | 否 | `setPageType: Dispatch, loginSuccess: Function` | +| `src/pageComponents/login/LoginForm/ForgetPasswordForm.tsx` | 忘记密码表单:验证码验证后重置密码 | 引用自 useSystemStore | 否 | `setPageType: Dispatch, loginSuccess: Function` | + +## 页面内部组件(匿名/局部) + +| 组件 | 所在文件 | 用途 | +|------|---------|------| +| `RedirectDrawer` | `src/pages/login/index.tsx:274` | IP 检测后提示中国用户跳转中文站 | +| `CookiesDrawer` | `src/pages/login/index.tsx:316` | 首次访问时展示隐私政策和 Cookies 协议 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Loading` | `@fastgpt/web/components/common/MyLoading` | web 公共组件 | +| `MyImage` | `@fastgpt/web/components/common/Image/MyImage` | web 公共组件 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 公共组件 | +| `I18nLngSelector` | `@fastgpt/web/components/common/I18nLngSelector` | web 公共组件 | +| `CommunityModal` | `@/components/CommunityModal` | 社区版弹窗 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ad5d423774d3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/API\347\264\242\345\274\225.md" @@ -0,0 +1,83 @@ +--- +capability_label: API 密钥 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — API索引 + +> API 定义文件:`projects/app/src/web/support/openapi/api.ts` + +## 查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/list` | GET | 获取 API 密钥列表 | `src/web/support/openapi/api.ts:20` → `src/components/support/apikey/Table.tsx:82` | 账户→API 密钥页→加载时调用;账户→API 密钥页→创建/编辑/删除后刷新时调用 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/create` | POST | 创建新的 API 密钥 | `src/web/support/openapi/api.ts:8` → `src/components/support/apikey/Table.tsx:353` | 账户→API 密钥页→EditKeyModal→提交创建表单时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/update` | PUT | 更新已有 API 密钥 | `src/web/support/openapi/api.ts:14` → `src/components/support/apikey/Table.tsx:363` | 账户→API 密钥页→EditKeyModal→提交编辑表单时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/delete` | DELETE | 删除指定 API 密钥 | `src/web/support/openapi/api.ts:26` → `src/components/support/apikey/Table.tsx:72` | 账户→API 密钥页→确认删除弹窗→确认时调用 | + +## 外部依赖 API + +> 以下 API 由其他模块定义,本模块在调用示例功能中间接使用。 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/app/get` | GET | 获取应用详情(用于生成调用示例) | `src/web/core/app/api.ts:getAppDetailById` → `src/components/support/apikey/Table.tsx:93` | 账户→API 密钥页→点击「调用示例」→获取应用变量和配置信息时调用(仅 appId 存在时) | + +## API 调用链追踪 + +### `/support/openapi/list` 调用链 + +``` +ApiKeyTable (src/components/support/apikey/Table.tsx) + ├── 触发: 页面加载时自动调用(manual: false),或创建/编辑/删除成功后手动刷新 + ├── 参数: { appId } — 可选,当传入 appId 时过滤特定应用的密钥 + └── 响应处理: 更新 apiKeys 状态,驱动表格渲染;loading 状态控制 MyBox 遮罩 +``` + +### `/support/openapi/create` 调用链 + +``` +EditKeyModal (src/components/support/apikey/Table.tsx) + ├── 触发: 用户点击「新建」按钮→弹出 EditKeyModal→填写表单→点击确认 + ├── 参数: { name, limit: { maxUsagePoints, expiredTime }, appId } — name 必填,maxUsagePoints 默认 -1(无限制) + └── 响应处理: 成功回调 onCreate,传入新密钥 ID → 显示密钥展示弹窗 → 刷新列表 +``` + +### `/support/openapi/update` 调用链 + +``` +EditKeyModal (src/components/support/apikey/Table.tsx) + ├── 触发: 用户点击行操作菜单「编辑」→弹出 EditKeyModal(带已有数据)→修改表单→点击确认 + ├── 参数: { _id, name, limit: { maxUsagePoints, expiredTime }, appId } — _id 用于定位要更新的密钥 + └── 响应处理: 成功回调 onEdit → 关闭弹窗 → 刷新列表 +``` + +### `/support/openapi/delete` 调用链 + +``` +ApiKeyTable (src/components/support/apikey/Table.tsx) + ├── 触发: 用户点击行操作菜单「删除」→弹出确认弹窗→用户确认 + ├── 参数: { id } — 要删除的密钥 ID + └── 响应处理: 成功回调 onSuccess → 刷新列表;确认弹窗带 type: 'delete' 使用红色警告样式 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..b4c25e3450f5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,74 @@ +--- +capability_label: API 密钥 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 共享组件清单 + +> 本文档列出本模块中可被其他模块复用的组件及其接口约定。 + +## ApiKeyTable + +- **组件文件**:`projects/app/src/components/support/apikey/Table.tsx`(默认导出) +- **用途**:API 密钥的完整增删改查管理表格,包含列表展示、创建/编辑模态框、删除确认、调用示例展示功能 +- **包装方式**:`React.memo` 包裹 +- **消费模块**:应用发布 — Publish/API(projects/app/src/pageComponents/app/detail/Publish/API/index.tsx) + +### Props + +| 属性 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| tips | string | 是 | — | 表格标题下方的提示文本 | +| appId | string | 否 | undefined | 应用 ID,传入时仅管理该应用的密钥,列表列出时过滤;不传时管理账户下全部密钥 | + +### 行为说明 + +| 场景 | 行为 | +|------|------| +| 页面加载 | 自动调用 GET /support/openapi/list 获取密钥列表,显示 MyBox 加载遮罩 | +| 创建密钥 | 弹出 EditKeyModal,填写名称(必填),Plus 版本还可填写用量上限和过期时间 | +| 编辑密钥 | 弹出 EditKeyModal(预填数据),可修改名称和限制参数 | +| 删除密钥 | 弹出确认弹窗,确认后调用 DELETE /support/openapi/delete | +| 复制基础地址 | 点击页面顶部基础地址区域,复制到剪贴板 | +| 调用示例 | appId 存在时,获取应用详情并生成 curl 调用示例弹窗(含变量类型说明) | +| Plus 版本差异 | 通过 feConfigs.isPlus 控制:过期时间列、用量上限输入、过期时间选择器、最大用量上限字段 | + +### 内部依赖 + +ApiKeyTable 内部包含两个私有子组件: +- **EditKeyModal**:创建/编辑 API Key 的模态框表单 +- **CallExampleModal**:展示 API 调用示例(curl 命令和变量说明) + +两个子组件均为 ApiKeyTable 内部定义,不独立导出,无需外部关心其 Props 接口。 + +### 消费方注意事项 + +- 当传入 `appId` 时,密钥范围限定为该应用,同时「调用示例」功能可用 +- 当不传 `appId` 时(账户全局场景),「调用示例」功能不可用 +- `tips` 文案通过 i18n 翻译传入,不同场景可使用不同提示文本 + +--- + +## 外部共享 Hooks 使用说明 + +> 以下 Hooks 定义在 `packages/web/hooks/` 中,属于全局共享,被 ApiKeyTable 消费。此处仅列出与本模块相关的关键接口。 + +### useRequest + +- **定义位置**:`@fastgpt/web/hooks/useRequest` +- **在 ApiKeyTable 中的用途**:管理 API 密钥 CRUD 操作的异步状态(loading、error、data),支持手动触发和依赖刷新 + +### useCopyData + +- **定义位置**:`@fastgpt/web/hooks/useCopyData` +- **在 ApiKeyTable 中的用途**:复制 API 基础地址和新建密钥值到剪贴板 + +### useConfirm + +- **定义位置**:`@fastgpt/web/hooks/useConfirm` +- **在 ApiKeyTable 中的用途**:删除密钥前的二次确认弹窗(type: 'delete',红色警告样式) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..517697c48d1f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API \345\257\206\351\222\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,44 @@ +--- +capability_label: API 密钥 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/apikey"] +--- + +# API 密钥 — 组件列表 + +## 页面组件 + +> 路由直接挂载的顶级页面。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/apikey.tsx` | API 密钥管理页面入口,渲染 ApiKeyTable 并嵌入 AccountContainer 布局 | 引用自账户容器 → AccountContainer;引用自密钥组件 → ApiKeyTable | 否 | 无自定义 Props(通过 getServerSideProps 接收 i18n 配置) | + +## 业务组件 + +> 模块内部共享的业务组件。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/support/apikey/Table.tsx:ApiKeyTable` | API 密钥增删改查表格,含列表展示、创建/编辑弹窗、删除确认、调用示例展示 | 被 应用发布(Publish/API) 引用 | 是 → [15-共享组件清单](../共享组件清单.md#apikeytable) | tips: string, appId?: string | +| `projects/app/src/components/support/apikey/Table.tsx:EditKeyModal` | API 密钥创建/编辑模态框,含名称、用量上限、过期时间字段 | 内部(ApiKeyTable 私有子组件) | 否 | defaultData: EditProps, onClose: fn, onCreate: fn, onEdit: fn | +| `projects/app/src/components/support/apikey/Table.tsx:CallExampleModal` | API 调用示例展示弹窗,根据应用类型和变量配置生成 curl 命令示例 | 内部(ApiKeyTable 私有子组件) | 否 | app: AppDetailType, baseUrl: string, onClose: fn | + +## 消费的外部组件 + +> 本模块组件引用自其他模块的共享组件。 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户容器(账户模块共享基础设施) | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 公共组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 公共组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | web 公共组件库 | +| `DateTimePicker` | `@fastgpt/web/components/common/DateTimePicker` | web 公共组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | web 公共组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | web 公共组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..dd8730bd7393 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/API\347\264\242\345\274\225.md" @@ -0,0 +1,139 @@ +--- +capability_label: 账户 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:49:09Z" +parent_module: null +roles: [owner, admin, member] +router_paths: ["/account/usage", "/account/info", "/account/team", "/account/bill", "/account/thirdParty", "/account/customDomain", "/account/model", "/account/promotion", "/account/apikey", "/account/inform", "/account/setting"] +--- + +# 账户 — API索引 + +> 本文档汇总账户模块及其子能力调用的所有 API 接口,按功能分组。 + +## 账号信息 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/update` | POST | 更新用户信息(头像、时区、语言) | `pages/api/support/user/account/update.ts` → `pages/account/info/index.tsx` | 账户→账号信息→修改头像/时区/语言时调用 | +| `/support/user/account/checkPswExpired` | GET | 检查密码是否过期 | `pages/api/support/user/account/checkPswExpired.ts` | 账户→登录后全局检查→密码过期提醒 | + +## 账号信息(管理员接口) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/adminGenerateToken` | POST | 管理员为用户生成登录 Token | `pages/api/support/user/account/adminGenerateToken.ts` | 管理员→代理用户登录→生成 Token 时调用(Root Key 鉴权) | +| `/support/user/account/adminGetUserTeams` | GET | 管理员查询用户的所有团队列表 | `pages/api/support/user/account/adminGetUserTeams.ts` | 管理员→查询用户团队→获取团队列表时调用(Root Key 鉴权) | + +## 团队管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/team/list` | GET | 获取团队成员列表 | `web/support/user/team/api.ts` → `pages/account/team/index.tsx` | 账户→团队管理→加载时调用;搜索/分页时调用 | +| `/support/user/team/update` | PUT | 更新团队成员信息(角色、状态等) | `web/support/user/team/api.ts` → `pageComponents/account/team/` | 账户→团队管理→修改成员角色/状态时调用 | +| `/support/user/team/invite` | POST | 邀请成员加入团队 | `web/support/user/team/api.ts` → `pageComponents/account/team/` | 账户→团队管理→发送邀请时调用 | + +## 第三方集成 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/team/thirtdParty/checkUsage` | GET | 检查第三方服务的用量配额 | `pages/api/support/user/team/thirtdParty/checkUsage.ts` → `pages/account/thirdParty/index.tsx` | 账户→第三方集成→加载工作流变量卡片时并行调用 | + +## 自定义域名 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/customDomain/list` | GET | 获取自定义域名列表 | `web/support/customDomain/api.ts` → `pages/account/customDomain/index.tsx` | 账户→自定义域名→加载时调用 | +| `/support/customDomain/create` | POST | 创建自定义域名 | `web/support/customDomain/api.ts` → `pageComponents/account/customDomain/` | 账户→自定义域名→添加域名时调用 | +| `/support/customDomain/delete` | DELETE | 删除自定义域名 | `web/support/customDomain/api.ts` → `pages/account/customDomain/index.tsx` | 账户→自定义域名→删除域名时调用 | +| `/support/customDomain/checkDNSResolve` | POST | 检查 DNS 解析状态 | `web/support/customDomain/api.ts` → `pageComponents/account/customDomain/` | 账户→自定义域名→检查 DNS 配置时调用 | +| `/support/customDomain/active` | POST | 激活自定义域名 | `web/support/customDomain/api.ts` → `pageComponents/account/customDomain/` | 账户→自定义域名→激活域名时调用 | + +## 账单与发票 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/list` | POST | 获取账单列表 | `web/support/wallet/bill/api.ts` → `pages/account/bill/index.tsx` | 账户→账单→加载时调用;翻页时调用 | +| `/proApi/support/wallet/bill/detail` | GET | 获取账单详情 | `web/support/wallet/bill/api.ts` → `pageComponents/account/bill/BillDetailModal.tsx` | 账户→账单→点击查看账单详情时调用 | +| `/proApi/support/wallet/bill/invoice/unInvoiceList` | GET | 获取可开发票的账单列表 | `web/support/wallet/bill/invoice/api.ts` → `pages/account/bill/` | 账户→账单→打开发票管理时调用 | +| `/proApi/support/wallet/bill/invoice/submit` | POST | 提交发票申请 | `web/support/wallet/bill/invoice/api.ts` → `pages/account/bill/` | 账户→账单→提交发票申请时调用 | +| `/proApi/support/wallet/bill/invoice/records` | POST | 获取发票记录列表 | `web/support/wallet/bill/invoice/api.ts` → `pages/account/bill/` | 账户→账单→查看发票历史时调用 | + +## 用量统计 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/usage/list` | POST | 获取用量明细列表 | `web/support/wallet/usage/api.ts` → `pageComponents/account/usage/UsageTableList.tsx` | 账户→用量统计→明细视图→加载/筛选/翻页时调用 | +| `/proApi/support/wallet/usage/dashboard` | POST | 获取用量仪表盘数据 | `web/support/wallet/usage/api.ts` → `pageComponents/account/usage/Dashboard.tsx` | 账户→用量统计→仪表盘视图→加载/筛选时调用 | + +## API 密钥 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/openapi/list` | GET | 获取 API 密钥列表 | `web/support/openapi/api.ts:20` → `components/support/apikey/Table.tsx` | 账户→API 密钥→加载时调用 | +| `/support/openapi/create` | POST | 创建 API 密钥 | `web/support/openapi/api.ts:8` → `components/support/apikey/Table.tsx` | 账户→API 密钥→新建密钥时调用 | +| `/support/openapi/update` | PUT | 更新 API 密钥 | `web/support/openapi/api.ts:14` → `components/support/apikey/Table.tsx` | 账户→API 密钥→编辑密钥时调用 | +| `/support/openapi/delete` | DELETE | 删除 API 密钥 | `web/support/openapi/api.ts` → `components/support/apikey/Table.tsx` | 账户→API 密钥→删除密钥时调用 | + +## 推广 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/activity/promotion/records` | GET | 获取推广记录列表 | `web/support/activity/promotion/api.ts` → `pages/account/promotion.tsx` | 账户→推广→加载时调用;翻页时调用 | +| `/support/activity/promotion/initData` | GET | 获取推广统计数据 | `web/support/activity/promotion/api.ts` → `pages/account/promotion.tsx` | 账户→推广→加载时调用 | + +## 模型管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/ai/model/list` | GET | 获取模型列表 | `web/core/ai/model.ts` → `components/core/ai/ModelTable/` | 账户→模型管理→加载模型列表时调用 | +| `/core/ai/model/create` | POST | 创建/启用模型 | `web/core/ai/model.ts` → `components/core/ai/ModelTable/` | 账户→模型管理→添加模型时调用 | +| `/core/ai/model/update` | PUT | 更新模型配置 | `web/core/ai/model.ts` → `components/core/ai/ModelTable/` | 账户→模型管理→编辑模型时调用 | +| `/core/ai/model/delete` | DELETE | 删除模型 | `web/core/ai/model.ts` → `components/core/ai/ModelTable/` | 账户→模型管理→删除模型时调用 | + +## 文件上传 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/file/uploadAvatarPresignedUrl` | GET | 获取头像上传预签名 URL | `web/common/file/api.ts` → `pages/account/info/index.tsx` | 账户→账号信息→修改头像时调用 | + +## API 调用链追踪 + +### `/support/user/account/update` 调用链 + +``` +pages/account/info/index.tsx (MyInfo 子组件) + ├── 触发: 用户修改头像/时区/语言后自动保存 + ├── 参数: { avatar, timezone, language } + └── 响应处理: 更新 team member avatar 记录 + 刷新 S3 avatar 缓存 +``` + +### `/support/user/team/thirtdParty/checkUsage` 调用链 + +``` +pages/account/thirdParty/index.tsx (ThirdParty 页面) + ├── 触发: 页面加载时对每个 externalProviderWorkflowVariable 并行调用 + ├── 参数: { key: item.key } + └── 响应处理: 合并到 accountList 中展示用量进度条 +``` + +### `/proApi/support/wallet/usage/list` 调用链 + +``` +pageComponents/account/usage/UsageTableList.tsx + ├── 触发: 用量明细视图加载/筛选条件变化/翻页 + ├── 参数: { dateRange, memberFilter, usageSources, projectName, unit, page, pageSize } + └── 响应处理: 渲染用量明细表格,更新分页控件 +``` + +### `/support/openapi/list` 调用链 + +``` +components/support/apikey/Table.tsx (ApiKeyTable) + ├── 触发: 页面加载 / 新建-编辑-删除操作后刷新 + ├── 参数: { appId? } + └── 响应处理: 渲染 API 密钥列表表格 +``` + +> 各子能力的完整 API 调用链详解见对应子目录的 `API索引.md`。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c168457225b7 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,56 @@ +--- +capability_label: "个性化设置" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: "账户" +roles: ["所有登录用户"] +router_paths: ["/account/setting"] +--- + +# 个性化设置 — API索引 + +本模块不包含独立的 API 定义文件,而是通过共享用户 Store(`useUserStore`)间接调用用户 API 层。以下列出本模块实际触发的 API。 + +## 用户账户更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/update` | PUT | 更新用户个人设置(语言/时区) | `src/web/support/user/api.ts:113` → `src/web/support/user/useUserStore.ts:89` → `src/pages/account/setting.tsx:27` | 账户→个性化设置→切换语言时调用;账户→个性化设置→切换时区时自动调用 | + +## 用户信息获取 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/account/tokenLogin` | GET | 获取当前登录用户完整信息(含语言、时区等偏好) | `src/web/support/user/api.ts:37` → `src/web/support/user/useUserStore.ts:54` | 账户→个性化设置→页面初始化时由 useUserStore.initUserInfo 调用(应用级,非页面级) | + +--- + +## API 调用链追踪 + +### `PUT /support/user/account/update` 调用链 + +``` +页面组件: src/pages/account/setting.tsx + ├── 触发: 用户在下拉框中切换语言或时区 + ├── 参数: { language?: string, timezone?: string } + ├── 经由: useUserStore.updateUserInfo(user) → putUserInfo(user) + ├── 乐观更新: Store 中 userInfo 先更新,后端失败时回滚 + └── 响应处理: 成功无额外处理;失败回滚 userInfo,reject error + +I18nLngSelector 组件: src/components/Select/I18nLngSelector.tsx + ├── 触发: 用户在语言下拉框中选择语言 + ├── 参数: { language: LangEnum } + ├── 经由: useUserStore.updateUserInfo({ language }) → putUserInfo({ language }) + └── 响应处理: 语言切换后在 onChangeLngI18n 中更新前端 i18n 语言包 +``` + +### `GET /support/user/account/tokenLogin` 调用链 + +``` +useUserStore.initUserInfo: src/web/support/user/useUserStore.ts:50 + ├── 触发: 应用初始化时自动调用(非页面级触发) + ├── 参数: 无(基于登录 cookie/token) + ├── 响应: UserType(含 language, timezone, avatar, team 等字段) + └── 响应处理: setUserInfo(res) → 设置 userInfo + isTeamAdmin + 同步语言到 localStorage +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..4c7bf96c391d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\344\270\252\346\200\247\345\214\226\350\256\276\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,40 @@ +--- +capability_label: "个性化设置" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: "账户" +roles: ["所有登录用户"] +router_paths: ["/account/setting"] +--- + +# 个性化设置 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/setting.tsx` | 个性化设置页面,提供语言和时区两项偏好配置 | 内部 | 否 | 无外部 Props(页面组件,通过 useUserStore 获取数据) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(共享容器) | +| `I18nLngSelector` | `@/components/Select/I18nLngSelector` | 共享组件 | +| `TimezoneSelect` | `@fastgpt/web/components/common/MySelect/TimezoneSelect` | Web 公共组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | Web 公共图标 | +| `PageContainer` | `@/components/PageContainer` | 共享容器(由 AccountContainer 内部使用) | + +## 消费的外部 Hooks / Store + +| 名称 | 导入路径 | 源模块 | 用途 | +|------|---------|--------|------| +| `useUserStore` | `@/web/support/user/useUserStore` | 用户 Store | 获取 userInfo、调用 updateUserInfo 保存设置 | +| `useTranslation` | `next-i18next` | i18n 框架 | 获取翻译函数 t 和当前语言 | +| `useToast` | `@fastgpt/web/hooks/useToast` | Web 公共 Hooks | 显示操作结果 toast 提示 | +| `useForm` | `react-hook-form` | 第三方库 | 表单状态管理(defaultValues 绑定 userInfo) | + +## 说明 + +本模块页面组件自身不导出任何可复用组件、Hook 或工具函数。页面仅消费外部共享组件和 Store 来组装个性化设置功能。所有 UI 控件(语言下拉框、时区下拉框)均为外部共享组件,页面负责组合布局和事件串联。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..d0f13b10bdd5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,95 @@ +--- +capability_label: 团队管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 账户 +roles: [团队所有者, 团队管理员, 普通成员] +router_paths: [/account/team] +--- + +# 团队管理 — API 索引 + +## 团队信息查询与切换 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/list` | GET | 获取当前用户已加入的活跃团队列表 | `api.ts:32` → `context.tsx:60`, `TeamSelector.tsx:27` | 账户→团队管理→加载时调用(获取团队列表和团队切换选项) | +| `/proApi/support/user/team/member/count` | GET | 获取当前团队的成员总数 | `api.ts:53` → `context.tsx:65` | 账户→团队管理→加载时调用(页面头部显示成员统计) | +| `/proApi/support/user/team/switch` | PUT | 切换当前活跃团队 | `api.ts:37` → `context.tsx:71`, `TeamSelector.tsx:32` | 账户→团队管理→切换团队下拉选择时调用;账户→各页面→TeamSelector 切换时调用 | +| `/support/user/team/update` | PUT | 更新团队基本信息(名称/头像/通知账号) | `api.ts:36` → `EditInfoModal`(动态导入) | 账户→团队管理→编辑团队信息弹窗→提交时调用 | + +## 成员管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/member/list` | POST | 分页获取团队成员列表 | `api.ts:43` → `MemberTable.tsx:118`, `Audit/index.tsx:39`, `GroupManageMember.tsx`, `ChangeOwnerModal`, `TransferOwnershipModal` 等 | 账户→团队管理→成员Tab→加载/搜索/翻页时调用;审计日志→获取成员筛选选项时调用 | +| `/proApi/support/user/team/member/updateNameByManager` | PUT | 管理员修改成员名称 | `api.ts:58` → `MemberTable.tsx:172` | 账户→团队管理→成员Tab→编辑成员名称→提交时调用 | +| `/proApi/support/user/team/member/delete` | DELETE | 移除/禁用团队成员 | `api.ts:63` → `MemberTable.tsx:152`, `OrgManage/index.tsx` | 账户→团队管理→成员Tab→移除成员确认后调用;组织管理→移除成员时调用 | +| `/proApi/support/user/team/member/restore` | POST | 恢复已禁用的成员 | `api.ts:67` → `MemberTable.tsx:156` | 账户→团队管理→成员Tab→恢复已禁用成员确认后调用 | +| `/proApi/support/user/team/member/leave` | DELETE | 用户主动退出团队 | `api.ts:69` → `MemberTable.tsx:143` | 账户→团队管理→成员Tab→非管理员点击"退出团队"确认后调用 | +| `/proApi/support/user/sync` | POST | 同步模式下手动同步团队成员 | `user/api.ts:115` → `MemberTable.tsx:137` | 账户→团队管理→成员Tab→同步模式下管理员点击"立即同步"时调用 | + +## 邀请链接 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invitationLink/create` | POST | 创建团队邀请链接 | `api.ts:73` → `InviteModal.tsx` | 账户→团队管理→成员Tab→邀请成员弹窗→创建邀请链接时调用 | +| `/proApi/support/user/team/invitationLink/list` | GET | 获取团队邀请链接列表 | `api.ts:76` → `InviteModal.tsx` | 账户→团队管理→成员Tab→邀请成员弹窗→加载时调用 | +| `/proApi/support/user/team/invitationLink/accept` | POST | 接受团队邀请 | `api.ts:79` → `HandleInviteModal.tsx`, `provider.tsx:51` | 账户→团队管理→邀请弹窗→点击接受时调用;OAuth 登录后的邀请自动接受 | +| `/proApi/support/user/team/invitationLink/info` | GET | 获取邀请链接详情 | `api.ts:82` → `HandleInviteModal.tsx` | 账户→团队管理→邀请弹窗→加载时获取邀请信息 | +| `/proApi/support/user/team/invitationLink/forbid` | PUT | 禁用邀请链接 | `api.ts:84` → `InviteModal.tsx` | 账户→团队管理→成员Tab→邀请弹窗→禁用某条邀请链接时调用 | + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/collaborator/list` | GET | 获取团队成员/组织/分组的权限列表 | `api.ts:88` → `PermissionManage/index.tsx:544` | 账户→团队管理→权限Tab→加载时调用 | +| `/proApi/support/user/team/collaborator/updateOne` | PUT | 更新单个成员/组织/分组的权限 | `api.ts:97` → `PermissionManage/index.tsx:141` | 账户→团队管理→权限Tab→勾选/取消权限复选框时调用 | +| `/proApi/support/user/team/collaborator/update` | POST | 批量更新权限协作者 | `api.ts:90` → `PermissionManage`(通过 CollaboratorContext) | 账户→团队管理→权限Tab→添加协作者时调用 | +| `/proApi/support/user/team/collaborator/delete` | DELETE | 删除权限协作者 | `api.ts:98` → `PermissionManage`(通过 CollaboratorContext) | 账户→团队管理→权限Tab→删除协作者时调用 | + +## 团队转让 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/changeOwner` | PUT | 转让团队所有权 | `api.ts:39` → `TransferOwnershipModal.tsx` | 账户→团队管理→成员Tab→所有权转让弹窗→确认转让时调用 | + +## API 调用链追踪 + +### `/proApi/support/user/team/list` 调用链 + +``` +TeamModalContextProvider (context.tsx) + ├── 触发: 用户信息加载完成(refreshDeps: [userInfo?._id]) + ├── 参数: { status: 'active' } + └── 响应处理: 存储到 myTeams,供团队下拉选择器和页面头部渲染 + +TeamSelector (TeamSelector.tsx) + ├── 触发: 组件挂载、userInfo 变化 + ├── 参数: { status: 'active' } + └── 响应处理: 格式化为下拉选择器的选项列表(icon/avatar + teamName + teamId) +``` + +### `/proApi/support/user/team/member/count` 调用链 + +``` +TeamModalContextProvider (context.tsx) + ├── 触发: 团队切换后(refreshDeps: [userInfo?.team?.teamId]) + ├── 参数: 无额外参数 + └── 响应处理: 提取 count 字段,渲染为页面头部"共 N 位成员"标签 +``` + +### `/proApi/support/user/team/switch` 调用链 + +``` +TeamModalContextProvider (context.tsx) + ├── 触发: 用户调用 onSwitchTeam(teamId) + ├── 参数: { teamId: string } + └── 响应处理: 成功→initUserInfo()→router.reload();失败→toast 提示"切换团队失败" + +TeamSelector (TeamSelector.tsx) + ├── 触发: 用户在下拉菜单中点击非管理项 + ├── 参数: { teamId: string } + └── 响应处理: 成功→router.reload();全局 Loading 在请求前后分别开关 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..84b2257eaaac --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/API\347\264\242\345\274\225.md" @@ -0,0 +1,111 @@ +--- +capability_label: 分组 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +# 分组 — API索引 + +## 分组查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/group/list` | POST | 获取团队分组列表(含成员信息) | `web/support/user/team/group/api.ts:12` → `pageComponents/account/team/GroupManage/index.tsx:43` | 团队管理→分组Tab→切换Tab时调用;团队管理→分组Tab→团队切换时自动刷新 | + +## 分组管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/group/create` | POST | 创建新分组 | `web/support/user/team/group/api.ts:14` → `pageComponents/account/team/GroupManage/GroupInfoModal.tsx:48` | 团队管理→分组Tab→创建分组→提交表单时调用 | +| `/proApi/support/user/team/group/update` | PUT | 更新分组信息或成员列表 | `web/support/user/team/group/api.ts:20` → `pageComponents/account/team/GroupManage/GroupInfoModal.tsx:61`
`web/support/user/team/group/api.ts:20` → `pageComponents/account/team/GroupManage/GroupManageMember.tsx:99` | 团队管理→分组Tab→编辑分组信息→保存时调用;团队管理→分组Tab→管理分组成员→保存变更时调用 | +| `/proApi/support/user/team/group/delete` | DELETE | 删除分组 | `web/support/user/team/group/api.ts:17` → `pageComponents/account/team/GroupManage/index.tsx:64` | 团队管理→分组Tab→删除分组→确认删除时调用 | +| `/proApi/support/user/team/group/changeOwner` | PUT | 转让分组负责人 | `web/support/user/team/group/api.ts:26` → `pageComponents/account/team/GroupManage/GroupTransferOwnerModal.tsx:72` | 团队管理→分组Tab→转让负责人→确认转让时调用 | + +## 成员查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/members` | GET | 分页查询团队成员列表 | `web/support/user/team/api.ts` → `pageComponents/account/team/GroupManage/GroupManageMember.tsx:52`
`web/support/user/team/api.ts` → `pageComponents/account/team/GroupManage/GroupTransferOwnerModal.tsx:46` | 团队管理→分组Tab→管理分组成员→加载可选成员列表时调用(含搜索/翻页);团队管理→分组Tab→转让负责人→搜索新负责人时调用 | + +## API 调用链追踪 + +### `/proApi/support/user/team/group/list` 调用链 + +``` +MemberTable (GroupManage/index.tsx) + ├── 触发: 组件挂载(manual: false)或 teamId 变更(refreshDeps) + ├── 参数: { withMembers: true } + └── 响应处理: 更新 groups 状态,默认分组名替换为团队名称渲染列表 + +usage/index.tsx + ├── 触发: 用量统计页面加载 + └── 参数: 不带 withMembers(默认) + +MemberManager/MemberModal.tsx + ├── 触发: 协作权限管理弹窗中获取分组列表 + └── 参数: 不带 withMembers(默认) +``` + +### `/proApi/support/user/team/group/create` 调用链 + +``` +GroupInfoModal + ├── 触发: 用户在创建弹窗中点击"新建"按钮 + ├── 参数: { name, avatar } + └── 响应处理: 成功后并行执行 onClose() 和 onSuccess()(刷新列表) +``` + +### `/proApi/support/user/team/group/update` 调用链 + +``` +GroupInfoModal + ├── 触发: 用户在编辑弹窗中点击"保存"按钮 + ├── 参数: { groupId, name, avatar } + └── 响应处理: 成功后并行执行 onClose() 和 onSuccess()(刷新列表) + +GroupEditModal (GroupManageMember.tsx) + ├── 触发: 用户在成员管理弹窗中点击"保存"按钮 + ├── 参数: { groupId, memberList: [{ tmbId, role }] } + └── 响应处理: 成功后并行执行 onClose() 和 onSuccess()(刷新列表) +``` + +### `/proApi/support/user/team/group/delete` 调用链 + +``` +MemberTable (GroupManage/index.tsx) + ├── 触发: 用户在删除确认弹窗中点击确认 + ├── 参数: { groupId } + └── 响应处理: 成功后执行 refetchGroups() 刷新列表 +``` + +### `/proApi/support/user/team/group/changeOwner` 调用链 + +``` +ChangeOwnerModal (GroupTransferOwnerModal.tsx) + ├── 触发: 用户在转让弹窗中点击"确认"按钮 + ├── 参数: groupId, tmbId(新负责人的团队会员ID) + └── 响应处理: 成功 Toast "转让负责人成功",失败 Toast "转让负责人失败" +``` + +### `/proApi/support/user/team/members` 调用链 + +``` +GroupEditModal (GroupManageMember.tsx) — 可选成员列表 + ├── 触发: 弹窗打开时首次加载、搜索输入变更、滚动到底部 + ├── 参数: { status: 'active', pageSize: 20, searchKey, 分页游标 } + └── 响应处理: 追加/替换成员列表,渲染 MemberItemCard 复选框列表 + +GroupEditModal (GroupManageMember.tsx) — 已选成员列表 + ├── 触发: 弹窗打开时加载 + ├── 参数: { groupId, pageSize: 100000 } + └── 响应处理: 设置 selected 状态,初始化右侧已选列表 + +ChangeOwnerModal (GroupTransferOwnerModal.tsx) + ├── 触发: 搜索输入变更、滚动到底部 + ├── 参数: { searchKey, pageSize: 20, 分页游标 } + └── 响应处理: 渲染可选成员下拉列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..18db5b1ebaad --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,25 @@ +--- +capability_label: 分组 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +## 说明 + +本模块未检测到导出的 Hooks/Composables/Utils/Constants。 + +模块内使用的 Hooks 均来自公共库: +- `useRequest` — `@fastgpt/web/hooks/useRequest`(API 请求状态管理) +- `useConfirm` — `@fastgpt/web/hooks/useConfirm`(确认弹窗) +- `useDisclosure` — Chakra UI(弹窗开关状态) +- `useScrollPagination` — `@fastgpt/web/hooks/useScrollPagination`(滚动分页) +- `useUploadAvatar` — `@fastgpt/web/common/file/hooks/useUploadAvatar`(头像上传) +- `useForm` — `react-hook-form`(表单管理) +- `useContextSelector` — `use-context-selector`(Context 选择器) +- `useTranslation` — `next-i18next`(国际化) + +- 组件信息参见 [13-组件列表](./组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..5cf7e932111f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,19 @@ +--- +capability_label: 分组 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用全局 Store:`useUserStore`(用户信息、团队权限)、`useSystemStore`(系统配置/套餐计划) +- 通过 React Context:`TeamContext`(团队编辑数据、团队人数)— 由父模块团队管理注入 +- 通过组件内部状态(useState)管理局部 UI 状态(如当前编辑的分组、搜索关键词、已选成员等) +- 使用 `useRequest` hook 管理 API 请求状态(loading、data、refresh) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..baec436450a0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,16 @@ +--- +capability_label: 分组 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无共享目录)。 + +- 内部组件列表见 [13-组件列表](./组件列表.md) +- 跨模块依赖摘要见 [08-模块概览](../../业务知识库/账户/团队管理/分组/模块概览.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..393504c8fc5a --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\210\206\347\273\204/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,38 @@ +--- +capability_label: 分组 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 团队管理 +roles: ["team_owner", "team_admin", "group_owner", "group_admin", "group_member"] +router_paths: ["/account/team?teamTab=group"] +--- + +# 分组 — 组件列表 + +## GroupManage/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/GroupManage/index.tsx` | 分组列表主组件(MemberTable),以表格展示分组列表,提供创建、编辑、管理成员、转让、删除等操作入口 | 引用自 OrgManage/IconButton;引用自 Info/MemberTag | 否(仅被 team/index.tsx 的 dynamic import 消费) | Tabs: React.ReactNode(父级 Tab 栏) | +| `pageComponents/account/team/GroupManage/GroupInfoModal.tsx` | 分组信息编辑弹窗,支持创建和编辑两种模式,含头像上传和名称输入 | 内部 | 否 | editGroup?: MemberGroupListItemType, onClose, onSuccess | +| `pageComponents/account/team/GroupManage/GroupManageMember.tsx` | 分组成员管理弹窗(GroupEditModal),左右双栏布局,支持搜索/勾选成员、设置/取消管理员角色 | 引用自 permission/MemberManager/MemberItemCard | 否 | group: MemberGroupListItemType, onClose, onSuccess | +| `pageComponents/account/team/GroupManage/GroupTransferOwnerModal.tsx` | 分组负责人转让弹窗(ChangeOwnerModal),支持搜索选择新负责人,可选保留原负责人管理员权限 | 内部 | 否 | group: MemberGroupListItemType, onClose, onSuccess | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MemberTag` | `@/components/support/user/team/Info/MemberTag` | 账户-账号信息 | +| `IconButton` | `../OrgManage/IconButton` | 账户-团队管理-组织管理 | +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(共享基础设施) | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 通用组件库 | +| `Avatar` / `AvatarGroup` | `@fastgpt/web/components/common/Avatar` | 通用组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 通用组件库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 通用组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 通用组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用组件库 | +| `Tag` | `@fastgpt/web/components/common/Tag` | 通用组件库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 通用组件库 | +| `MemberItemCard` | `@/components/support/permission/MemberManager/MemberItemCard` | 权限管理 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..43f94d838164 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/API\347\264\242\345\274\225.md" @@ -0,0 +1,44 @@ +--- +capability_label: 审计日志 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员] +router_paths: ["/account/team?teamTab=audit"] +--- + +# 审计日志 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/audit/list` | POST | 获取团队审计日志列表 | `projects/app/src/web/support/user/team/operantionLog/api.ts:6` → `projects/app/src/pageComponents/account/team/Audit/index.tsx:83` | 团队管理→审计日志→页面加载时调用;团队管理→审计日志→滚动加载更多时调用;团队管理→审计日志→筛选条件变更时调用 | +| `/proApi/support/user/team/list` | POST | 获取团队成员列表 | `projects/app/src/web/support/user/team/api.ts:43` → `projects/app/src/pageComponents/account/team/Audit/index.tsx:39` | 团队管理→审计日志→成员筛选器初始化时调用;成员筛选器滚动加载更多时调用 | + +## API 调用链追踪 + +### `/proApi/support/user/audit/list` 调用链 + +``` +AuditLog 组件 (index.tsx:83) + ├── 触发: 页面首次加载 / 滚动到底部 / 筛选条件变更 + ├── 参数: + │ ├── pageSize: 30 + │ ├── offset: 分页偏移量(由 useScrollPagination 管理) + │ ├── tmbIds: string[](成员筛选,全选时不传) + │ └── events: AuditEventEnum[](事件类型筛选,全选时不传) + ├── 依赖: refreshDeps → [searchParams],searchParams 由 tmbIds 和 events 筛选器变更触发 + └── 响应处理: 返回 PaginationResponse,包含 list(日志列表)和 total(总数);数据追加到 useScrollPagination 管理的 data 数组中 +``` + +### `/proApi/support/user/team/list` 调用链 + +``` +AuditLog 组件 (index.tsx:39) + ├── 触发: 组件挂载时自动调用 / 成员筛选器下拉滚动加载 + ├── 参数: 无特殊参数(使用 useScrollPagination 默认分页) + ├── 依赖: 无 refreshDeps(仅在成员筛选器下拉中触发滚动加载) + └── 响应处理: 返回成员列表,每个成员含 tmbId、memberName、avatar;映射为筛选器选项 {label: JSX(头像+名称), value: tmbId} +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..fc331dfac334 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\345\256\241\350\256\241\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,51 @@ +--- +capability_label: 审计日志 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员] +router_paths: ["/account/team?teamTab=audit"] +--- + +# 审计日志 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/team/Audit/index.tsx` | 审计日志主页面,包含筛选器和日志表格 | 引用自 共享组件库(MultipleSelect, UserBox, MyBox, Avatar);引用自 审计事件系统(AuditEventEnum, auditLogMap) | 否 | Tabs: ReactNode(父组件的标签页组件) | + +## 内部业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/team/Audit/processors/commonProcessor.ts` | 审计日志元数据默认处理器,处理 i18n 字段转换 | 内部 | 否 | 无 Props(纯函数,接收 metadata 和 t 函数) | +| `projects/app/src/pageComponents/account/team/Audit/processors/index.ts` | 审计日志特殊处理器聚合入口,合并各类事件处理器 | 引用自 teamProcessors、appProcessors、datasetProcessors | 否 | 无 Props(导出 specialProcessors 映射表) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | 共享组件库 | +| `useMultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | 共享组件库 | +| `UserBox` | `@fastgpt/web/components/common/UserBox` | 共享组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 源模块 | +|------|---------|--------| +| `useScrollPagination` | `@fastgpt/web/hooks/useScrollPagination` | 共享工具库 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置 Store | + +## 消费的外部工具函数 + +| 函数 | 导入路径 | 源模块 | +|------|---------|--------| +| `getOperationLogs` | `@/web/support/user/team/operantionLog/api` | 团队 API 层 | +| `getTeamMembers` | `@/web/support/user/team/api` | 团队 API 层 | +| `auditLogMap` | `@fastgpt/web/support/user/audit/constants` | 审计事件系统 | +| `AuditEventEnum` | `@fastgpt/global/support/user/audit/constants` | 全局常量 | +| `formatTime2YMDHMS` | `@fastgpt/global/common/string/time` | 全局工具 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a786af4944f1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/API\347\264\242\345\274\225.md" @@ -0,0 +1,159 @@ +--- +capability_label: 成员 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — API索引 + +## 成员列表查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/member/list` | POST | 分页获取团队成员列表 | `web/support/user/team/api.ts:43` → `MemberTable.tsx:118` | 账户→团队管理→成员→加载时调用;搜索/筛选/翻页时调用 | +| `/proApi/support/user/team/member/count` | GET | 获取团队成员总数 | `web/support/user/team/api.ts:53` → `context.tsx:65` | 账户→团队管理→加载时调用,用于显示团队总人数徽标 | + +## 成员管理操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/member/updateNameByManager` | PUT | 管理员修改成员名称 | `web/support/user/team/api.ts:58` → `MemberTable.tsx:172` | 账户→团队管理→成员→点击编辑图标→确认修改时调用 | +| `/proApi/support/user/team/member/delete` | DELETE | 移除/禁用成员 | `web/support/user/team/api.ts:63` → `MemberTable.tsx:151` | 账户→团队管理→成员→点击删除图标→确认移除时调用 | +| `/proApi/support/user/team/member/restore` | POST | 恢复已离开/禁用成员 | `web/support/user/team/api.ts:67` → `MemberTable.tsx:155` | 账户→团队管理→成员→点击恢复图标→确认恢复时调用 | +| `/proApi/support/user/team/member/leave` | DELETE | 退出团队 | `web/support/user/team/api.ts:69` → `MemberTable.tsx:143` | 账户→团队管理→成员→点击退出团队→确认退出时调用 | + +## 邀请链接 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invitationLink/list` | GET | 获取邀请链接列表 | `web/support/user/team/api.ts:76` → `InviteModal.tsx:45` | 账户→团队管理→成员→打开邀请弹窗时调用 | +| `/proApi/support/user/team/invitationLink/create` | POST | 创建邀请链接 | `web/support/user/team/api.ts:73` → `CreateInvitationModal` | 账户→团队管理→成员→邀请弹窗→创建链接时调用 | +| `/proApi/support/user/team/invitationLink/forbid` | PUT | 禁用邀请链接 | `web/support/user/team/api.ts:84` → `InviteModal.tsx:74` | 账户→团队管理→成员→邀请弹窗→禁用链接时调用 | + +## 同步与导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/api` | POST | 同步成员数据 | `web/support/user/api.ts:115` → `MemberTable.tsx:137` | 账户→团队管理→成员→点击"立即同步"时调用 | +| `/api/proApi/support/user/team/member/export` | GET | 导出成员为 CSV | 内联 `downloadFetch` → `MemberTable.tsx:258` | 账户→团队管理→成员→点击"导出成员"时调用 | + +## 团队所有权 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/changeOwner` | PUT | 转让团队所有权 | `web/support/user/team/api.ts:39` → `TransferOwnershipModal.tsx:67` | 账户→团队管理→成员→转让弹窗→确认转让时调用 | + +--- + +## API 调用链追踪 + +### `POST /proApi/support/user/team/member/list` 调用链 + +``` +MemberTable (MemberTable.tsx:118) + ├── 触发: 进入成员列表 / 滚动翻页 / 搜索 / 筛选 + ├── 参数: { pageSize: 20, status, withPermission: true, withOrgs: true, searchKey, offset } + ├── 响应处理: 更新 members 状态 → 渲染表格行 + ├── 防抖/节流: debounceWait=200ms, throttleWait=500ms + └── 刷新依赖: [searchKey, status] + +TransferOwnershipModal (TransferOwnershipModal.tsx:44) + ├── 触发: 在转让弹窗中搜索成员 + ├── 参数: { pageSize: 20, searchKey, status: 'active' } + ├── 响应处理: 过滤掉当前所有者 → 渲染候选成员列表 + └── 防抖/节流: debounceWait=200ms, throttleWait=500ms +``` + +### `DELETE /proApi/support/user/team/member/delete` 调用链 + +``` +MemberTable (MemberTable.tsx:151) + ├── 触发: 管理员点击成员行删除图标 → 确认弹窗中确认 + ├── 参数: { tmbId } + ├── 前置条件: hasManagePer && member.role !== owner && member.tmbId !== userTmbId && member.status === active + ├── 响应处理: 成功 → 调用 onRefreshMembers() → 刷新成员列表 + └── 错误处理: 默认错误 Toast(useRequest 统一处理) +``` + +### `POST /proApi/support/user/team/member/restore` 调用链 + +``` +MemberTable (MemberTable.tsx:155) + ├── 触发: 管理员点击非活跃成员行恢复图标 → 确认弹窗中确认 + ├── 参数: { tmbId } + ├── 前置条件: hasManagePer && member.status !== active + ├── 响应处理: 成功 → Toast 提示"操作成功" → 调用 onRefreshMembers() + └── 错误处理: Toast 提示"拒绝"(common:user.team.invite.Reject) +``` + +### `PUT /proApi/support/user/team/member/updateNameByManager` 调用链 + +``` +MemberTable (MemberTable.tsx:172) + ├── 触发: 管理员点击编辑图标 → 编辑弹窗中修改名称并确认 + ├── 参数: { tmbId, name } + ├── 前置条件: hasManagePer && member.role !== owner && member.tmbId !== userTmbId + ├── 响应处理: 成功 → 调用 onRefreshMembers() → 列表刷新显示新名称 + └── 错误处理: 弹窗内显示错误状态 +``` + +### `DELETE /proApi/support/user/team/member/leave` 调用链 + +``` +MemberTable (MemberTable.tsx:143) + ├── 触发: 非所有者普通成员点击"退出团队"→ 确认弹窗中确认 + ├── 参数: 无(基于当前用户 session) + ├── 前置条件: !isSyncMode && !isWecomTeam && !isOwner + ├── 响应处理: 成功 → onSwitchTeam(myTeams[0].teamId) → 切换到第一个可用团队 + └── 错误处理: Toast 提示"退出团队失败"(account_team:user_team_leave_team_failed) +``` + +### `PUT /proApi/support/user/team/changeOwner` 调用链 + +``` +TransferOwnershipModal (TransferOwnershipModal.tsx:67) + ├── 触发: 所有者在转让弹窗中选择新所有者并确认 + ├── 参数: { userId } + ├── 前置条件: isOwner && !isSyncMode && isWecomTeam + ├── 响应处理: 成功 → initUserInfo() 刷新用户权限 → onSwitchTeam 切换到其他团队 → 页面重新加载 + └── 错误处理: Toast 提示"转让失败"(account_team:transfer_failed) +``` + +### `POST /proApi/support/user/api` (同步成员) 调用链 + +``` +MemberTable (MemberTable.tsx:137) + ├── 触发: 管理员点击"立即同步"按钮 + ├── 参数: 无 + ├── 前置条件: isSyncMode && hasManagePer + ├── 响应处理: 成功 → Toast 提示"同步成员成功" → 调用 onRefreshMembers() + └── 错误处理: Toast 提示"同步成员失败"(account_team:sync_member_failed) +``` + +### `GET /proApi/support/user/team/invitationLink/list` 调用链 + +``` +InviteModal (InviteModal.tsx:45) + ├── 触发: 打开邀请弹窗时自动加载 + ├── 参数: 无 + ├── 响应处理: 渲染邀请链接列表表格(描述、过期时间、使用次数、已邀请成员) + └── 刷新触发: 创建/禁用链接成功后手动调用 refetchInvitationLinkList() +``` + +### `GET /api/proApi/support/user/team/member/export` 调用链 + +``` +MemberTable (MemberTable.tsx:258) [内联 downloadFetch] + ├── 触发: 所有者点击"导出成员"按钮 + ├── 参数: URL 路径 + 文件名 = {teamName}-{yyyyMMddHHmmss}.csv + ├── 前置条件: isSyncMode && isOwner + └── 响应处理: 浏览器触发文件下载,无需前端解析 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..051b2fd0ea83 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,175 @@ +--- +capability_label: 成员 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — Store 数据流 + +## Store 概览 + +| Store / Context | 来源 | 用途 | +|-----------|------|------| +| `TeamContext` | `pageComponents/account/team/context.tsx` | 团队列表、团队切换、成员总数、编辑团队数据等共享状态 | +| `useUserStore` | `@/web/support/user/useUserStore` | 当前用户信息、团队权限、订阅计划状态 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置(注册方式、功能开关、订阅计划内容) | + +> `TeamContext` 由父级 `TeamModalContextProvider` 包裹整个团队管理页面,成员 Tab 通过 `useContextSelector(TeamContext, ...)` 按需订阅。 + +--- + +## TeamContext + +### State + +| 字段 | 类型 | 说明 | +|------|------|------| +| `myTeams` | `TeamTmbItemType[]` | 当前用户所属的全部团队列表 | +| `isLoading` | `boolean` | 团队列表或切换操作加载状态 | +| `teamSize` | `number` | 当前团队的成员总数(来自 getTeamMemberCount) | +| `editTeamData` | `EditTeamFormDataType \| undefined` | 编辑团队信息的表单数据 | + +### Actions + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `onSwitchTeam` | `teamId: string` | 切换到指定团队并重新加载页面 | `PUT /proApi/support/user/team/switch` | +| `refetchTeams` | — | 重新获取团队列表 | `GET /proApi/support/user/team/list` | +| `refetchTeamSize` | — | 刷新当前团队成员总数 | `GET /proApi/support/user/team/member/count` | +| `setEditTeamData` | `EditTeamFormDataType \| undefined` | 设置编辑团队弹窗的数据 | — | + +--- + +## useUserStore(Zustand) + +> 成员模块消费的 `useUserStore` 关键字段: + +### State(消费部分) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userInfo` | `UserType` | 包含 `team`(团队 ID/名称/头像/角色/权限/是否企业微信团队)、`_id`(用户 ID) | +| `userInfo.team.permission` | `TeamPermission` | 团队权限对象,含 `hasManagePer`(管理权限)、`isOwner`(所有者) | +| `userInfo.team.tmbId` | `string` | 当前用户在团队中的成员 ID | +| `teamPlanStatus` | `ClientTeamPlanStatusType` | 团队订阅计划状态(影响审计日志功能可用性) | + +### Actions(消费部分) + +| Action | 参数 | 说明 | 调用的 API | +|--------|------|------|-----------| +| `initUserInfo` | — | 重新初始化用户信息(所有权转让后调用) | 内部调用用户信息接口 | + +--- + +## useSystemStore(Zustand) + +> 成员模块消费的 `useSystemStore` 关键字段: + +### State(消费部分) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `feConfigs` | `SystemConfigType` | 系统配置,含 `register_method`(注册方式,决定 isSyncMode)、`show_team_chat`(是否显示团队聊天/标签同步)、`systemTitle`(系统名称) | +| `subPlans` | `SubPlanType` | 订阅计划内容,含 `standard[currentSubLevel].auditLogStoreDuration`(审计日志存储时长) | + +--- + +## 数据流向 + +### 成员列表加载流程 + +``` +MemberTable TeamContext useUserStore API + │ │ │ │ + │ useContextSelector │ │ │ + │ → myTeams, teamSize, │ │ │ + │ onSwitchTeam │ │ │ + │ │ │ │ + │ useUserStore() │ │ │ + │ → userInfo, initUserInfo │ │ │ + │ │ │ │ + │ useSystemStore() │ │ │ + │ → feConfigs │ │ │ + │ │ │ │ + │ isSyncMode = │ │ │ + │ feConfigs.register_method │ │ │ + │ .includes('sync') │ │ │ + │ │ │ │ + │ useScrollPagination( │ │ │ + │ getTeamMembers, {...}) │ │ │ + ├──────────────────────────────────────────────────────►│ │ + │ │ POST /member/list│ + │ ├──────────────────►│ + │ │◄──────────────────┤ + │ members = [...] │ │ │ + │ loadingMembers = false │ │ │ + │◄─────────────────────────────┤ │ │ + │ │ │ │ + │ 渲染表格: │ │ │ + │ - 按钮可见性由 userInfo │ │ │ + │ .team.permission 控制 │ │ │ + │ - 按钮可见性由 isSyncMode │ │ │ + │ 控制(同步/邀请等按钮) │ │ │ +``` + +### 退出团队流程 + +``` +MemberTable TeamContext API + │ │ │ + │ 点击"退出团队" → 确认 │ │ + ├──────────────────────────────►│ │ + │ │ DELETE /member/leave│ + │ ├─────────────────────►│ + │ │◄─────────────────────┤ + │ │ │ + │ onSwitchTeam(myTeams[0]) │ │ + │◄──────────────────────────────┤ │ + │ │ PUT /team/switch │ + │ ├─────────────────────►│ + │ │◄─────────────────────┤ + │ router.reload() │ │ + │◄──────────────────────────────┤ │ +``` + +### 所有权转让流程 + +``` +TransferOwnershipModal useUserStore TeamContext API + │ │ │ │ + │ 搜索并选择新所有者 │ │ │ + │ 确认转让 │ │ │ + ├──────────────────────────────►│ │ │ + │ │ PUT /changeOwner │ │ + │ ├───────────────────────────────────►│ + │ │◄───────────────────────────────────┤ + │ │ │ │ + │ initUserInfo() │ │ │ + │◄──────────────────────────────┤ │ │ + │ │ │ │ + │ onSwitchTeam(otherTeamId) ──►│ │ │ + │ │ PUT /team/switch │ │ + │ ├───────────────────►│ │ + │ │◄───────────────────┤ │ + │ onSuccess() → 页面重新加载 │ │ │ +``` + +--- + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| Context 共享状态 | 团队列表、团队切换、成员计数在团队管理各 Tab 间共享 | `TeamModalContextProvider` → `MemberTable` / `OrgManage` / `GroupManage` 等 | +| Zustand Store 共享 | 用户信息和权限在全局共享,驱动 UI 条件渲染 | `useUserStore` → `MemberTable`;`useSystemStore` → `MemberTable` | +| 路由参数 | `teamTab=member` 决定渲染哪个子组件 | `team/index.tsx` → `MemberTable` | +| 父子 Props | 父级将 Tab 切换栏作为 Props 传入子组件 | `team/index.tsx` → `MemberTable({ Tabs })` | +| Props 回调 | 弹窗通过 onClose/onSuccess 回调与父组件通信 | `MemberTable` → `InviteModal` / `TransferOwnershipModal` | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..577d1658ad60 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\210\220\345\221\230/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,60 @@ +--- +capability_label: 成员 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:56:58Z" +parent_module: 团队管理 +roles: + - 团队所有者(owner) + - 团队管理员(hasManagePer) + - 普通成员 +router_paths: + - /account/team?teamTab=member +--- + +# 成员 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/MemberTable.tsx` | 团队成员列表表格,包含搜索、筛选、操作工具栏及全部成员管理交互 | 内部 | 否 | `Tabs: React.ReactNode` — 接收父级传入的 Tab 切换栏 | + +## 弹窗组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/Invite/InviteModal.tsx` | 邀请成员弹窗,管理邀请链接的创建/复制/禁用 | 内部 | 否 | `onClose: () => void` | +| `pageComponents/account/team/Invite/CreateInvitationModal.tsx` | 创建邀请链接子弹窗,设置描述、有效期和使用次数 | 内部 | 否 | `onSuccess: (linkId) => void`; `onClose: () => void` | +| `pageComponents/account/team/Invite/HandleInviteModal.tsx` | 处理邀请链接加入团队的弹窗 | 内部 | 否 | `invitelinkid: string` | +| `pageComponents/account/team/TransferOwnershipModal.tsx` | 转让团队所有权弹窗,搜索并选择新所有者 | 内部 | 否 | `onSuccess: () => void`; `onClose: () => void` | + +## Context 组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/context.tsx` | TeamContext 上下文提供者,管理团队列表、切换、成员计数等共享状态 | 内部(被团队管理各 Tab 子组件消费) | 否 | `children: ReactNode` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享基础设施 | +| `Tag` | `@fastgpt/web/components/common/Tag` | 共享基础设施 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享基础设施 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 共享基础设施 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享基础设施 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享基础设施 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享基础设施 | +| `MyPopover` | `@fastgpt/web/components/common/MyPopover` | 共享基础设施 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | 共享基础设施 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享基础设施 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享基础设施 | +| `AvatarGroup` | `@fastgpt/web/components/common/Avatar/AvatarGroup` | 共享基础设施 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 共享基础设施(父级 team/index.tsx 使用) | +| `OrgTags` | `@/components/support/user/team/OrgTags` | 账户/团队管理(组织标签展示) | +| `TeamTagModal` | `@/components/support/user/team/TeamTagModal` | 账户/团队管理(标签同步弹窗) | +| `MemberTag` | `@/components/support/user/team/Info/MemberTag` | 账户/团队管理(成员信息标签) | +| `TeamSelector` | `@/pageComponents/account/TeamSelector` | 账户(团队选择器) | +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(账户页面容器) | +| `EditInfoModal` | `./EditInfoModal` | 团队管理(团队信息编辑弹窗) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..b749cadf0b07 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,89 @@ +--- +capability_label: 权限管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:30:00.000Z" +parent_module: 团队管理 +roles: + - 团队所有者 + - 团队管理员 + - 普通成员 +router_paths: + - "/account/team?teamTab=permission" +--- + +# 权限管理 — API索引 + +## 协作者管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/collaborator/list` | GET | 获取团队协作者列表 | `src/web/support/user/team/api.ts:88` → `PermissionManage/index.tsx`(通过 CollaboratorContextProvider) | 权限管理→进入页面时加载;权限管理→修改权限后刷新;权限管理→删除协作者后刷新;权限管理→添加协作者后刷新 | +| `/proApi/support/user/team/collaborator/update` | POST | 批量更新协作者权限 | `src/web/support/user/team/api.ts:90` → `MemberManager/context.tsx`(onUpdateCollaborators) | 权限管理→添加协作者→确认提交时调用 | +| `/proApi/support/user/team/collaborator/updateOne` | PUT | 更新单个协作者权限 | `src/web/support/user/team/api.ts:92` → `PermissionManage/index.tsx:141` | 权限管理→勾选/取消勾选某项权限 Checkbox 时调用 | + +## 协作者删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/collaborator/delete` | DELETE | 删除协作者 | `src/web/support/user/team/api.ts:98` → `PermissionManage/index.tsx:155` | 权限管理→点击协作者行删除图标时调用 | + +## 搜索 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 通过 GetSearchUserGroupOrg 调用 | 通过通用请求层发起 | 搜索用户、组织、分组 | `src/web/support/user/api.ts` → `PermissionManage/index.tsx:95` | 权限管理→在搜索框输入关键字时调用(500ms 节流 + 200ms 防抖) | + +## API 调用链追踪 + +### `/proApi/support/user/team/collaborator/list` 调用链 + +``` +CollaboratorContextProvider + ├── 触发: 组件挂载时自动发起 + refreshDeps 变化时重新发起 + ├── 参数: 无 + ├── 条件: feConfigs.isPlus 为 true 时发起,否则返回空列表 + ├── 响应处理: 将 clbs 中每个协作者的 permission 转换为 Permission 实例 + └── 数据分发: 通过 CollaboratorContext 提供给所有子组件 + +PermissionManage + ├── 触发: 权限变更/删除成功后由 refetchCollaborators 触发 + ├── 参数: 无 + └── 响应处理: 更新 collaboratorList,tmbList/groupList/orgList 重新分类计算 +``` + +### `/proApi/support/user/team/collaborator/updateOne` 调用链 + +``` +PermissionManage.onUpdatePermission + ├── 触发: 用户勾选/取消勾选 Checkbox + ├── 参数: { tmbId?, groupId?, orgId?, permission: 位掩码权限值 } + ├── 前置计算: TeamPermission.addRole(per) 或 removeRole(per) 计算新权限值 + ├── 前置校验: 查找 collaboratorList 中匹配 id 的协作者,找不到则直接返回 + ├── 成功处理: 调用 refetchCollaborators 刷新列表 + └── 错误处理: useRequest 统一错误处理,Toast 提示错误信息 +``` + +### `/proApi/support/user/team/collaborator/delete` 调用链 + +``` +PermissionManage.onDeleteMemberPermission + ├── 触发: 用户点击删除图标 + ├── 参数: { tmbId } 或 { groupId } 或 { orgId }(三者互斥) + ├── 前置条件: hasDeletePer(member.permission) 为 true 且不是删除自己 + │ ├── 团队所有者: 可删除任何人(除自己) + │ └── 团队管理员: 只能删除无管理权限的协作者 + ├── 成功处理: 调用 refetchCollaborators 刷新列表 + └── 错误处理: useRequest 统一错误处理 +``` + +### 搜索 API(GetSearchUserGroupOrg)调用链 + +``` +PermissionManage (searchKey 状态变化) + ├── 触发: 用户在搜索框输入文字 + ├── 防抖/节流: throttleWait=500ms, debounceWait=200ms + ├── 参数: searchKey(用户输入的搜索关键字) + ├── 响应处理: searchResult 包含 members/groups/orgs 三个数组 + └── 前端过滤: tmbList/groupList/orgList 各自与对应搜索结果取交集 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..8e3e562734c0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\346\235\203\351\231\220\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,68 @@ +--- +capability_label: 权限管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:30:00.000Z" +parent_module: 团队管理 +roles: + - 团队所有者 + - 团队管理员 + - 普通成员 +router_paths: + - "/account/team?teamTab=permission" +--- + +# 权限管理 — 组件列表 + +## 页面组件/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/team/PermissionManage/index.tsx` | 权限管理主页面组件,渲染协作者权限表格 | 消费外部:CollaboratorContextProvider(权限协作器上下文)、useUserStore、useSystemStore、FillRowTabs | 是(被团队管理页面 /account/team 引用) | Tabs: ReactNode, onOpenAddMember: () => void | + +## 内部子组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `PermissionCheckBox`(同文件内定义) | 权限勾选框组件,封装 Checkbox 与权限变更逻辑 | 内部 | 否 | isDisabled: boolean, role: PermissionValueType, clbPer: Permission, id: string | + +## 关键上下文提供者 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/support/permission/MemberManager/context.tsx` | 协作者管理上下文提供者,封装协作者列表获取、权限更新、删除等逻辑 | 被多个模块引用:应用管理、数据集管理、技能管理等 | 是(共享组件) | permission: Permission, onGetCollaboratorList, onUpdateCollaborators, onDelOneCollaborator, roleList, defaultRole, refreshDeps | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `CollaboratorContextProvider` | `@/components/support/permission/MemberManager/context` | 权限协作器(共享) | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 通用组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 通用组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 通用组件库 | +| `MemberTag` | `@/components/support/user/team/Info/MemberTag` | 团队成员信息 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 通用组件库 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 通用组件库(由父页面传入 Tabs prop) | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | API 调用状态管理(加载、错误处理) | +| `useUserStore` | `@/web/support/user/useUserStore` | 获取当前用户信息和团队权限 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取系统功能开关(如评测模块) | +| `useToggle` | `ahooks` | 折叠/展开状态管理 | +| `useContextSelector` | `use-context-selector` | 从 CollaboratorContext 中精确订阅状态 | +| `useTranslation` | `next-i18next` | 国际化翻译 | + +## 消费的外部类型/工具类 + +| 类/类型 | 导入路径 | 用途 | +|---------|---------|------| +| `TeamPermission` | `@fastgpt/global/support/permission/user/controller` | 团队权限位运算控制器 | +| `TeamRoleList` | `@fastgpt/global/support/permission/user/constant` | 团队角色列表定义 | +| `DefaultGroupName` | `@fastgpt/global/support/user/team/group/constant` | 默认分组名称常量 | +| `ReadRoleVal` | `@fastgpt/global/support/permission/constant` | 只读角色默认值 | +| `PermissionValueType` | `@fastgpt/global/support/permission/type` | 权限值类型定义 | +| `CollaboratorItemType` | `@fastgpt/global/support/permission/collaborator` | 协作者数据类型定义 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..b458d52ed0de --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,58 @@ +--- +capability_label: 团队管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:55:00.000Z" +parent_module: 账户 +roles: [团队所有者, 团队管理员, 普通成员] +router_paths: [/account/team] +--- + +# 团队管理 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/team/index.tsx` | 团队管理页面入口,渲染 Tab 导航和子组件容器 | 引用自 账户 → AccountContainer | 否 | — | + +## 容器与上下文组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/AccountContainer.tsx` | 账户页面的通用布局容器,含侧边栏 Tab 导航 | 引用自 共享基础设施 → PageContainer, LightRowTabs, DashboardNavbar | 否(账户模块内部共享) | `children: ReactNode`, `isLoading: boolean`, `header: ReactNode` | +| `projects/app/src/pageComponents/account/team/context.tsx` | 团队管理上下文提供者,管理团队列表、切换、编辑弹窗状态 | 引用自 用户/权限 → useUserStore, getTeamList, getTeamMemberCount, putSwitchTeam | 否 | `children: ReactNode` | + +## 公共组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/TeamSelector.tsx` | 团队选择下拉器,列出已加入团队并支持切换 | 引用自 用户/权限 → useUserStore, getTeamList, putSwitchTeam | 否(账户模块内部共享) | `showManage: boolean`, `showAvatar: boolean`, `onChange: () => void` | +| `projects/app/src/pageComponents/account/team/EditInfoModal.tsx` | 编辑团队信息弹窗(动态导入),修改名称/头像/通知账号 | 引用自 用户/权限 → putUpdateTeam, getUploadAvatarPresignedUrl | 否 | `defaultData: EditTeamFormDataType`, `onClose`, `onSuccess` | + +## Tab 子组件(动态导入) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/team/MemberTable.tsx` | 成员管理表格,列表展示、搜索、状态筛选、成员操作 | 内部 | 否 | `Tabs: ReactNode` | +| `projects/app/src/pageComponents/account/team/OrgManage/index.tsx` | 组织架构管理,树形展示、创建/编辑/移动组织、分配成员 | 内部 | 否 | `Tabs: ReactNode` | +| `projects/app/src/pageComponents/account/team/GroupManage/index.tsx` | 分组管理,分组列表 CRUD、成员分配 | 内部 | 否 | `Tabs: ReactNode` | +| `projects/app/src/pageComponents/account/team/PermissionManage/index.tsx` | 权限管理表格,按成员/组织/分组分配资源创建权限 | 引用自 权限 → CollaboratorContextProvider, TeamPermission | 否 | `Tabs: ReactNode` | +| `projects/app/src/pageComponents/account/team/Audit/index.tsx` | 审计日志表格,按成员和事件类型筛选操作记录 | 内部 | 否 | `Tabs: ReactNode` | + +## 邀请相关组件(动态导入) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/team/Invite/InviteModal.tsx` | 邀请成员弹窗,创建和展示邀请链接 | 内部 | 否 | `onClose: () => void` | +| `projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx` | 处理邀请弹窗,展示邀请信息并提供接受/拒绝操作 | 内部 | 否 | `invitelinkid: string` | +| `projects/app/src/pageComponents/account/team/TransferOwnershipModal.tsx` | 转让团队所有权弹窗,选择新所有者 | 内部 | 否 | `onClose`, `onSuccess` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 共享基础设施 | +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | 共享基础设施 | +| `PageContainer` | `@/components/PageContainer` | 通用组件 | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..6e546f5402ba --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/API\347\264\242\345\274\225.md" @@ -0,0 +1,127 @@ +--- +capability_label: 组织 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — API索引 + +## 查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/org/list` | POST | 获取组织子节点列表 | `web/support/user/team/org/api.ts:13` → `useOrg.tsx:48` | 账户→团队管理→组织Tab→加载时调用;账户→团队管理→组织Tab→切换组织时调用;账户→团队管理→组织Tab→搜索时调用 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/org/create` | POST | 创建子组织 | `web/support/user/team/org/api.ts:19` → `OrgInfoModal.tsx:58` | 账户→团队管理→组织Tab→创建子组织→提交表单时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/org/update` | PUT | 更新组织信息 | `web/support/user/team/org/api.ts:27` → `OrgInfoModal.tsx:74` | 账户→团队管理→组织Tab→编辑组织→提交修改时调用 | +| `/proApi/support/user/team/org/move` | PUT | 移动组织到新父节点 | `web/support/user/team/org/api.ts:25` → `OrgMoveModal.tsx:24` | 账户→团队管理→组织Tab→移动组织→选择目标后确认时调用 | +| `/proApi/support/user/team/org/updateMembers` | PUT | 更新组织成员列表 | `web/support/user/team/org/api.ts:31` → `OrgMemberManageModal.tsx:72` | 账户→团队管理→组织Tab→管理成员→保存时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/org/delete` | DELETE | 删除组织 | `web/support/user/team/org/api.ts:22` → `index.tsx:103` | 账户→团队管理→组织Tab→删除组织→确认后调用 | +| `/proApi/support/user/team/org/deleteMember` | DELETE | 从组织移除成员 | `web/support/user/team/org/api.ts:37` → `index.tsx:118` | 账户→团队管理→组织Tab→成员行→从组织移除→确认后调用 | + +## 成员查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/org/members` | GET | 获取组织成员(分页) | `web/support/user/team/org/api.ts:34` → `useOrg.tsx:85`(加载当前组织成员), `OrgMemberManageModal.tsx:48`(加载管理弹窗中的组织成员)+ `OrgMemberManageModal.tsx:35`(加载全部团队成员) | 账户→团队管理→组织Tab→加载时调用(成员列表);账户→团队管理→组织Tab→滚动加载更多成员时调用;账户→团队管理→组织Tab→管理成员弹窗→加载全部成员和已有成员时调用 | + +> 注:从团队移除成员使用的是 `delRemoveMember`(定义在 `web/support/user/team/api.ts`),路径为 `DELETE /proApi/support/user/team/member/delete`,属于团队核心 API 而非组织专用 API。 + +## API 调用链追踪 + +### `/proApi/support/user/team/org/list` 调用链 + +``` +useOrg.tsx (Hook — 组织列表核心逻辑) + ├── 触发: 页面初始化加载、切换组织、搜索、刷新 + ├── 参数: { orgId, withPermission, searchKey } + └── 响应处理: 更新 orgs 状态 → 过滤 path !== '' → 渲染表格行;refreshDeps: [teamId, path, currentOrg._id, debouncedSearchKey] +``` + +### `/proApi/support/user/team/org/create` 调用链 + +``` +OrgInfoModal.tsx (创建弹窗 — 创建模式) + ├── 触发: 用户填写表单后点击「新建」 + ├── 参数: { name, avatar, orgId=parentId, description } + └── 响应处理: toast "创建成功" → onClose() → onSuccess() 刷新列表 +``` + +### `/proApi/support/user/team/org/update` 调用链 + +``` +OrgInfoModal.tsx (编辑弹窗 — 编辑模式) + ├── 触发: 用户修改表单后点击「保存」 + ├── 参数: { orgId, name, avatar, description } + └── 响应处理: toast "更新成功" → onClose() → onSuccess() 刷新列表;updateCurrentOrg() 即时更新侧边栏 +``` + +### `/proApi/support/user/team/org/move` 调用链 + +``` +OrgMoveModal.tsx (移动弹窗) + ├── 触发: 用户在组织树中选择目标后点击「确定」 + ├── 参数: { orgId, targetOrgId } + └── 响应处理: onClose() → onSuccess() 刷新列表 +``` + +### `/proApi/support/user/team/org/delete` 调用链 + +``` +index.tsx (OrgTable — 确认删除弹窗) + ├── 触发: 用户在确认删除弹窗中点击确认 + ├── 参数: { orgId } + └── 响应处理: refresh() 刷新组织列表和成员列表 +``` + +### `/proApi/support/user/team/org/updateMembers` 调用链 + +``` +OrgMemberManageModal.tsx (成员管理弹窗) + ├── 触发: 用户勾选/取消勾选成员后点击「保存」 + ├── 参数: { orgId, members: [{ tmbId }] } + └── 响应处理: toast "更新成功" → refetchOrgs() → onClose() +``` + +### `/proApi/support/user/team/org/members` 调用链 + +``` +useOrg.tsx (Hook — 加载当前组织成员) + ├── 触发: 初始化/切换组织/刷新 + ├── 参数: { orgId, withOrgs=false, withPermission=true, status='active', pageSize=20 } + └── 响应处理: 滚动分页展示 (MemberScrollData) + +OrgMemberManageModal.tsx (管理弹窗 — 双数据源) + ├── 触发: 打开管理成员弹窗 + ├── 参数(全部团队): { withOrgs=true, withPermission=false, status='active', searchKey, pageSize=20 } + └── 响应处理: 左侧全部成员列表(支持搜索、滚动加载) + ├── 参数(当前组织): { orgId, withOrgs=false, withPermission=false, pageSize=100000 } + └── 响应处理: 右侧已选成员列表(全量加载) +``` + +### `/proApi/support/user/team/org/deleteMember` 调用链 + +``` +index.tsx (OrgTable — 确认弹窗) + ├── 触发: 用户在确认弹窗中点击确认 + ├── 参数: { orgId=currentOrg._id, tmbId } + └── 响应处理: refresh() 刷新列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..986af3650a62 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,63 @@ +--- +capability_label: 组织 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — Hooks工具函数 + +## Hooks / Composables + +### `useOrg` + +- **签名**: `function useOrg({ withPermission }: { withPermission?: boolean }): { orgStack, currentOrg, orgs, isLoading, paths, onClickOrg, members, MemberScrollData, onPathClick, refresh, updateCurrentOrg, searchKey, setSearchKey, debouncedSearchKey }` +- **用途**: 组织管理的核心状态管理 Hook,管理组织层级堆栈、当前选中组织、子组织列表加载、成员分页查询、面包屑路径、搜索过滤等全部状态与操作 +- **是否对外共享**: 是 +- **被以下模块引用**: 权限-成员管理(MemberManager/MemberModal)、组织移动弹窗(OrgMoveModal — 内部引用) + +**主要能力说明**: + +| 能力 | 返回值/操作 | 说明 | +|------|-----------|------| +| 组织堆栈管理 | `orgStack`, `currentOrg`, `onClickOrg`, `onPathClick` | 以堆栈方式管理组织层级导航,支持前进(点击进入)和后退(面包屑返回) | +| 子组织列表 | `orgs`, `isLoading` | 基于 `currentOrg._id` 自动加载子组织列表,支持搜索过滤(300ms 防抖)和权限控制 | +| 成员分页 | `members`, `MemberScrollData` | 滚动分页加载当前组织直属成员,每页 20 条,支持 `status='active'` 过滤 | +| 路径导航 | `paths` | 生成面包屑路径数据(`ParentTreePathItemType[]`),从组织栈计算 | +| 数据刷新 | `refresh` | 同时刷新组织列表(`refetchOrgs`)和成员列表(`refetchMembers`) | +| 搜索 | `searchKey`, `setSearchKey`, `debouncedSearchKey` | 300ms 防抖搜索,搜索模式下隐藏面包屑和成员列表,点击结果后清空搜索 | +| 即时更新 | `updateCurrentOrg` | 编辑组织后无需等待刷新,直接更新当前组织栈中的名称、描述和头像 | + +**内部依赖**: +- `getOrgList` API — 查询子组织列表 +- `getOrgMembers` API — 查询组织成员(分页) +- `getTeamMembers` API — 查询团队成员(用于当前组织的成员滚动分页) +- `useUserStore` — 获取团队信息(根组织名和头像) +- `useScrollPagination` — 成员列表滚动分页 +- `useRequest` — 子组织列表请求管理 +- `useDebounce` — 搜索防抖 + +## 工具函数 + +本模块的工具函数定义在全局包中: + +| 文件 | 导出项 | 类型 | 说明 | +|------|--------|------|------| +| `packages/global/support/user/team/org/constant.ts` | `getOrgChildrenPath` | `function (org: OrgSchemaType): string` | 根据组织的 path 和 pathId 计算子路径前缀 | +| `packages/global/support/user/team/org/constant.ts` | `OrgCollectionName` | `string = 'team_orgs'` | MongoDB 组织集合名 | +| `packages/global/support/user/team/org/constant.ts` | `OrgMemberCollectionName` | `string = 'team_org_members'` | MongoDB 组织成员集合名 | +| `packages/global/support/user/team/org/constant.ts` | `SyncOrgSourceEnum` | `enum { wecom }` | 外部同步来源枚举(如企业微信) | + +## 常量 + +| 文件 | 导出项 | 类型 | 说明 | +|------|--------|------|------| +| `packages/global/common/system/constants.ts` | `DEFAULT_ORG_AVATAR` | `string = '/imgs/avatar/defaultOrgAvatar.svg'` | 组织默认头像路径 | + +## 函数依赖关系 + +- `useOrg` 依赖 `getOrgChildrenPath`(path 计算用) +- `OrgTree` 的 `OrgTreeNode` 通过 `useRequest` 调用 `getOrgList` 实现子树懒加载 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..5f1eb8b84def --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\345\233\242\351\230\237\347\256\241\347\220\206/\347\273\204\347\273\207/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,42 @@ +--- +capability_label: 组织 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 团队管理 +roles: [团队管理员, 团队成员] +router_paths: [/account/team?teamTab=org] +--- + +# 组织 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/OrgManage/index.tsx` | 组织管理页面(OrgTable),含组织列表表格、成员列表表格、侧边栏详情与操作入口 | 引用自 `AccountContainer`(父容器)、引用自 `TeamContext`(团队上下文)、引用自 `AccountContainer`、`FillRowTabs` | 否 | `Tabs: React.ReactNode` | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/team/OrgManage/OrgTree.tsx` | 组织树组件(递归渲染),支持展开/折叠子组织和选中操作 | 引用自 `useUserStore` | 否 | `selectedOrg: OrgListItemType`, `setSelectedOrg: (org?: OrgListItemType) => void`, `movingOrg: OrgListItemType` | +| `pageComponents/account/team/OrgManage/OrgInfoModal.tsx` | 创建/编辑组织弹窗,含名称、头像、描述表单 | 引用自 `useRequest`、`useUploadAvatar`、`MyModal` | 否 | `editOrg: OrgFormType`, `onClose: () => void`, `onSuccess: () => void`, `updateCurrentOrg: (data) => void`, `parentId?: string` | +| `pageComponents/account/team/OrgManage/OrgMoveModal.tsx` | 移动组织弹窗,含组织树选择器 | 引用自 `useOrg` Hook、`OrgTree` | 否 | `movingOrg: OrgListItemType`, `onClose: () => void`, `onSuccess: () => void` | +| `pageComponents/account/team/OrgManage/OrgMemberManageModal.tsx` | 组织成员管理弹窗(左右双栏选择器),支持搜索和勾选 | 引用自 `useScrollPagination`、`MemberItemCard`、`MyModal` | 否 | `currentOrg: OrgListItemType`, `refetchOrgs: () => void`, `onClose: () => void` | +| `pageComponents/account/team/OrgManage/IconButton.tsx` | 图标按钮封装组件(带 hover 效果) | 引用自 `MyIcon` | 否 | `name: IconNameType`, `w?: string`, `...IconProps` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户 | +| `MemberTag` | `@/components/support/user/team/Info/MemberTag` | 支持/用户 | +| `MemberItemCard` | `@/components/support/permission/MemberManager/MemberItemCard` | 支持/权限 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | Web公共库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | Web公共库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | Web公共库 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | Web公共库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | Web公共库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | Web公共库 | +| `Path` | `@/components/common/folder/Path` | 公共组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..fab72e0b85b5 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/API\347\264\242\345\274\225.md" @@ -0,0 +1,98 @@ +--- +capability_label: 推广 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 账户 +roles: ["团队所有者"] +router_paths: ["/account/promotion"] +--- + +# API索引 + +## API 概览 + +| 序号 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 1 | GET | `/proApi/support/activity/promotion/getPromotionData` | 获取推广初始化数据 | +| 2 | POST | `/proApi/support/activity/promotion/getPromotions` | 获取推广记录列表(分页) | + +--- + +## 1. 获取推广初始化数据 + +**请求** + +``` +GET /proApi/support/activity/promotion/getPromotionData +``` + +无请求参数。 + +**响应** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `invitedAmount` | `number` | 累计邀请人数 | +| `earningsAmount` | `number` | 累计收益金额(元) | + +**调用方**:前端 `getPromotionInitData()`(`@/web/support/activity/promotion/api`) + +--- + +## 2. 获取推广记录列表 + +**请求** + +``` +POST /proApi/support/activity/promotion/getPromotions +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pageNum` | `number` | 是 | 页码 | +| `pageSize` | `number` | 是 | 每页条数(默认 20) | + +**响应** + +分页响应结构,`data` 为 `PromotionRecordType` 数组: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | `string` | 记录 ID | +| `type` | `'register' \| 'pay'` | 记录类型:注册奖励 / 支付返佣 | +| `createTime` | `Date` | 记录创建时间 | +| `amount` | `number` | 奖励金额 | + +**调用方**:前端 `getPromotionRecords()`(`@/web/support/activity/promotion/api`) + +--- + +## 前端 API 封装 + +| 函数 | HTTP 方法 | 端点 | 说明 | +|------|-----------|------|------| +| `getPromotionInitData()` | GET | `/proApi/support/activity/promotion/getPromotionData` | 返回 `{ invitedAmount, earningsAmount }` | +| `getPromotionRecords(data)` | POST | `/proApi/support/activity/promotion/getPromotions` | 接收 `PaginationProps`,返回分页响应 | + +封装文件:`projects/app/src/web/support/activity/promotion/api.ts` + +底层请求工具: +- `GET` / `POST` 来自 `@/web/common/api/request`,基于 Axios 封装,自动处理认证(Bearer Token)、请求拦截、响应校验 + +--- + +## 数据模型 + +**PromotionRecordSchema**(`packages/global/support/activity/type.ts`): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `_id` | `string` | 记录唯一标识 | +| `userId` | `string` | 收益人 ID | +| `objUId` | `string?` | 目标对象(提现时为空) | +| `type` | `'register' \| 'pay'` | 记录类型 | +| `createTime` | `Date` | 记录时间 | +| `amount` | `number` | 金额 | + +**PromotionRecordType**(`projects/app/src/global/support/api/userRes.ts`)为前端响应类型,字段与 Schema 对应(不含 `userId` 和 `objUId`)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..46448f6903d8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\216\250\345\271\277/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,74 @@ +--- +capability_label: 推广 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: 账户 +roles: ["团队所有者"] +router_paths: ["/account/promotion"] +--- + +# 组件列表 + +## 本模块组件 + +### Promotion + +**路径**:`projects/app/src/pages/account/promotion.tsx` + +**类型**:页面组件(Next.js Page) + +**功能说明**:推广数据展示页面,包含推广概览统计卡片和推广记录分页表格。 + +**引用方式**:通过 Next.js 文件路由 `/account/promotion` 自动映射。 + +**内部结构**: + +- **统计卡片区**:4 列网格布局(移动端 2 列 → 桌面端 4 列),展示累计邀请人数、收益金额、返现比例、邀请链接 +- **记录表格区**:Chakra UI Table 组件,三列(时间 / 类型 / 金额),集成空状态提示和分页控制 + +**Props**:无外部 Props(页面组件) + +**关键依赖**: + +| 依赖 | 来源 | 用途 | +|------|------|------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户模块通用容器,提供 Tab 导航 | +| `useQuery` | `@tanstack/react-query` | 请求推广概览数据 | +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 分页加载推广记录 | +| `useUserStore` | `@/web/support/user/useUserStore` | 获取用户信息(`userInfo._id`、`promotionRate`) | +| `useCopyData` | `@fastgpt/web/hooks/useCopyData` | 复制邀请链接到剪贴板 | +| `useLoading` | `@fastgpt/web/hooks/useLoading` | 加载状态管理 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 返现比例、邀请链接说明气泡 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 空记录提示 | + +**服务端渲染**:`getServerSideProps` 预加载 `account` 和 `account_promotion` 两个 i18n 命名空间。 + +--- + +### AccountContainer + +**路径**:`projects/app/src/pageComponents/account/AccountContainer.tsx` + +**类型**:布局容器组件 + +**功能说明**:账户模块的通用页面容器,为所有账户子页面(包括推广)提供统一的导航和布局框架。 + +**与推广模块的关系**:推广页面作为 `AccountContainer` 的 `children` 渲染。容器负责: +- 桌面端侧边栏导航(通过 `DashboardNavbar`) +- 移动端顶部 Tab 导航(通过 `LightRowTabs`) +- 统一的页面背景和布局结构 + +**Props**: + +| Prop | 类型 | 说明 | +|------|------|------| +| `children` | `React.ReactNode` | 子页面内容(推广模块的 Promotion 组件) | +| `isLoading` | `boolean?` | 页面加载状态 | +| `header` | `React.ReactNode?` | 自定义页面头部 | +| `wrapperContainerProps` | `BoxProps?` | PageContainer 外层 Props | +| `containerInsertProps` | `BoxProps?` | PageContainer 内层 Props | + +**Tab 导航逻辑**:根据当前路由路径名判断激活 Tab(如 `/account/promotion` → `TabEnum.promotion`)。推广 Tab 的可见性由 `feConfigs.show_promotion` 和 `userInfo.team.permission.isOwner` 联合控制。 + +**引用者**:被 12 个账户子页面使用(推广、账单、API 密钥、通知、个人信息、团队管理等)。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ef044ffa4943 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,110 @@ +--- +capability_label: 模型管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 账户 +roles: [root, owner, admin, member] +router_paths: ["/account/model"] +--- + +# 模型管理 — API索引 + +## 系统模型管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/ai/model/list` | POST | 获取全量系统模型列表 | `web/core/ai/config.ts:15` → `ModelConfigTable.tsx:98`
`web/core/ai/config.ts:15` → `Log/index.tsx:100`
`web/core/ai/config.ts:15` → `ModelDashboard/index.tsx`
`web/core/ai/config.ts:15` → `EditChannelModal.tsx` | 账户→模型管理→配置模型Tab→加载时调用;账户→模型管理→渠道日志Tab→加载时调用(获取模型下拉选项);账户→模型管理→模型监控Tab→加载时调用(获取监控模型列表) | +| `/core/ai/model/detail` | GET | 获取单个模型详细信息 | `web/core/ai/config.ts:19` → `ModelConfigTable.tsx:293` | 账户→模型管理→配置模型Tab→点击编辑模型时调用 | +| `/core/ai/model/update` | PUT | 更新系统模型配置 | `web/core/ai/config.ts:25` → `ModelConfigTable.tsx:261`
`web/core/ai/config.ts:25` → `AddModelBox.tsx` | 账户→模型管理→配置模型Tab→确认编辑模型时调用;账户→模型管理→配置模型Tab→确认创建模型时调用 | +| `/core/ai/model/create` | POST | 创建新的系统模型 | `web/core/ai/config.ts:27` → `AddModelBox.tsx` | 账户→模型管理→配置模型Tab→提交新建模型表单时调用 | +| `/core/ai/model/delete` | DELETE | 删除系统模型 | `web/core/ai/config.ts:29` → `ModelConfigTable.tsx:280` | 账户→模型管理→配置模型Tab→确认删除模型时调用 | +| `/core/ai/model/test` | GET | 测试模型连通性 | `web/core/ai/config.ts:35` → `ModelConfigTable.tsx:257` | 账户→模型管理→配置模型Tab→点击测试模型时调用 | +| `/core/ai/model/updateDefault` | PUT | 更新默认模型配置 | `web/core/ai/config.ts:37` → `DefaultModelModal` | 账户→模型管理→活跃模型Tab→root 点击"默认模型"按钮→确认更新时调用 | +| `/core/ai/model/getConfigJson` | GET | 获取模型 JSON 配置 | `web/core/ai/config.ts:31` → `ModelConfigTable.tsx:309` | 账户→模型管理→配置模型Tab→root 点击"JSON 配置"按钮时调用 | +| `/core/ai/model/updateWithJson` | PUT | 通过 JSON 更新模型配置 | `web/core/ai/config.ts:32` → `ModelConfigTable.tsx` | 账户→模型管理→配置模型Tab→root 导入 JSON 配置时调用 | +| `/core/ai/model/getDefaultConfig` | GET | 获取模型默认配置 | `web/core/ai/config.ts:22` → `ModelConfigTable.tsx` | 账户→模型管理→配置模型Tab→创建模型时获取默认配置 | + +## 渠道管理 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channels/all` | GET | 获取渠道列表 | `web/core/ai/channel.ts:110` → `Channel/index.tsx:54` | 账户→模型管理→渠道管理Tab→加载时调用 | +| `/api/aiproxy/api/channels/type_metas` | GET | 获取渠道提供商元数据 | `web/core/ai/channel.ts:124` → `Channel/index.tsx:58` | 账户→模型管理→渠道管理Tab→加载时调用(获取渠道类型信息) | +| `/api/aiproxy/api/createChannel` | POST | 创建新渠道 | `web/core/ai/channel.ts:136` → `EditChannelModal.tsx` | 账户→模型管理→渠道管理Tab→编辑渠道弹窗→确认创建时调用 | +| `/api/aiproxy/api/channel/{id}` | PUT | 更新渠道配置 | `web/core/ai/channel.ts:151` → `Channel/index.tsx:64` | 账户→模型管理→渠道管理Tab→修改优先级失焦时调用;编辑弹窗确认时调用 | +| `/api/aiproxy/api/channel/{id}` | DELETE | 删除渠道 | `web/core/ai/channel.ts:163` → `Channel/index.tsx:82` | 账户→模型管理→渠道管理Tab→确认删除渠道时调用 | +| `/api/aiproxy/api/channel/{id}/status` | POST | 启用/禁用渠道 | `web/core/ai/channel.ts:147` → `Channel/index.tsx:70` | 账户→模型管理→渠道管理Tab→切换渠道启用/禁用时调用 | + +## 渠道日志 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/logs/search` | GET | 搜索渠道请求日志 | `web/core/ai/channel.ts:165` → `Log/index.tsx:125` | 账户→模型管理→渠道日志Tab→加载/翻页/筛选时调用 | +| `/api/aiproxy/api/logs/detail/{id}` | GET | 获取日志详情 | `web/core/ai/channel.ts:195` → `Log/index.tsx:280` | 账户→模型管理→渠道日志Tab→点击"详情"按钮时调用 | + +## 模型监控 API + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/dashboardv2/` | GET | 获取模型调用 Dashboard 数据 | `web/core/ai/channel.ts:201` → `ModelDashboard/index.tsx` | 账户→模型管理→模型监控Tab→加载/筛选时调用 | + +## API 调用链追踪 + +### POST `/core/ai/model/list` 调用链 + +``` +ModelConfigTable + ├── 触发: 组件挂载时自动调用 (manual: false) + ├── 参数: 空对象 {} + └── 响应处理: 存储到 systemModelList state,经 useMemo 按类型分组、筛选、排序后渲染表格 + +ChannelLog + ├── 触发: 组件挂载时自动调用 (manual: false) + ├── 参数: 空对象 {} + └── 响应处理: 构造 modelList 下拉选项(按 provider.order 排序) + +ModelDashboard + ├── 触发: 组件挂载时自动调用 + ├── 参数: 空对象 {} + └── 响应处理: 用于模型筛选器下拉选项 + +EditChannelModal + ├── 触发: 编辑弹窗打开时调用 + ├── 参数: 空对象 {} + └── 响应处理: 获取可用模型列表供渠道模型配置 +``` + +### PUT `/core/ai/model/update` 调用链 + +``` +ModelConfigTable + ├── 触发: 点击模型行右侧菜单→编辑→弹窗确认 + ├── 参数: UpdateModelBody (含模型全量配置) + ├── 错误处理: code=409 且含 references → 弹出引用警告弹窗; code=409 且无 references → toast 错误消息 + └── 响应处理: 成功后调用 refreshModels() 刷新列表 + clientInitData() 刷新全局数据 + +AddModelBox (ModelEditModal) + ├── 触发: 新建/编辑模型弹窗→确认提交 + ├── 参数: UpdateModelBody 或 CreateModelBody + └── 响应处理: 关闭弹窗,刷新父页面模型列表 +``` + +### GET `/api/aiproxy/api/logs/search` 调用链 + +``` +ChannelLog + ├── 触发: 组件挂载、翻页滚动(useScrollPagination)、筛选条件变更 + ├── 参数: request_id, channel, model_name, code_type, start_timestamp, end_timestamp, p (页数), per_page (每页条数=20) + ├── 响应处理: 返回 {list, total},list 经 useMemo 格式化(计算 duration、解析模型名和渠道名) + └── 分页: 使用 useScrollPagination 实现无限滚动,每页 20 条 +``` + +### GET `/api/aiproxy/api/dashboardv2/` 调用链 + +``` +ModelDashboard + ├── 触发: 组件挂载、日期范围/模型/渠道筛选变更 + ├── 参数: channel, model, start_timestamp, end_timestamp, timezone, timespan (day/hour/minute) + └── 响应处理: DashboardDataItemSchema 校验每条 summary,用于渲染 AreaChart 面积图 + DataTableComponent 数据表格 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..97ed49356f8e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/API\347\264\242\345\274\225.md" @@ -0,0 +1,87 @@ +--- +capability_label: 模型监控 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:30:00Z" +parent_module: 模型管理 +roles: [root] +router_paths: ["/account/model"] +--- + +# 模型监控 — API索引 + +## 监控数据查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/dashboardv2/` | GET | 获取 Dashboard V2 监控数据(按时间序列返回各模型的调用统计) | `web/core/ai/channel.ts:201` → `pageComponents/account/model/ModelDashboard/index.tsx:214` | 模型管理→模型监控→加载时调用;模型管理→模型监控→更改时间范围/渠道/模型筛选时调用 | + +## 筛选器选项 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channels/all` | GET | 获取可用渠道列表(用于渠道筛选下拉框) | `web/core/ai/channel.ts:110` → `pageComponents/account/model/ModelDashboard/index.tsx:89` | 模型管理→模型监控→加载时调用(并行请求) | +| `/core/ai/model/list` | POST | 获取系统模型列表(用于模型筛选下拉框 + 模型价格计算) | `web/core/ai/config.ts:15` → `pageComponents/account/model/ModelDashboard/index.tsx:111` | 模型管理→模型监控→加载时调用(并行请求) | + +--- + +## API 调用链追踪 + +### `/api/aiproxy/api/dashboardv2/` 调用链 + +``` +ModelDashboard/index.tsx (useRequest) + ├── 触发: root用户进入模型监控Tab时自动触发;更改筛选条件(时间范围/渠道/模型/时间粒度)时自动触发 + ├── 请求参数: + │ channel?: number — 渠道ID筛选 + │ model?: string — 模型名筛选 + │ start_timestamp?: number — 起始时间戳(秒) + │ end_timestamp?: number — 结束时间戳(秒) + │ timezone: string — 浏览器时区 + │ timespan: 'day'|'hour'|'minute' — 数据聚合粒度 + ├── 响应结构: + │ [{timestamp: number, summary: DashboardDataItemType[]}] + │ DashboardDataItemType含: model, channel_id, request_count, exception_count, + │ input_tokens, output_tokens, total_tokens, total_time_milliseconds, + │ total_ttfb_milliseconds, max_rpm, max_tpm, cache_hit_count + ├── 响应处理: + │ 1. 对每个summary项用 DashboardDataItemSchema.parse 校验 + │ 2. 前端按timespan补全缺失的时间段(填充空白数据) + │ 3. 聚合summary数组计算总请求数、错误率、Token总量、AI积分花费 + │ 4. 区分LLM模型用于缓存命中率计算 + │ 5. 生成chartData数组供AreaChartComponent消费 + │ 6. DataTableComponent消费dashboardData原始数据做表格聚合 + ├── 刷新依赖: [filterProps.channelId, filterProps.dateRange, filterProps.model, filterProps.timespan] + └── 错误处理: 由useRequest统一管理loading/error状态 +``` + +### `/api/aiproxy/api/channels/all` 调用链 + +``` +ModelDashboard/index.tsx (useRequest) + ├── 触发: root用户进入模型监控Tab时自动触发(仅首次加载) + ├── 请求参数: {page: 1, perPage: 10} + ├── 响应处理: + │ 1. 后端返回后按status排序(活跃优先),再按priority降序 + │ 2. 前端map为 {label: item.name, value: item.id} 格式 + │ 3. 前置 "全部" 选项(value为空字符串) + └── 消费: MySelect组件的list prop,用于渠道筛选下拉框 +``` + +### `/core/ai/model/list` 调用链 + +``` +ModelDashboard/index.tsx (useRequest) + ├── 触发: root用户进入模型监控Tab时自动触发(仅首次加载) + ├── 请求参数: {}(空对象) + ├── 响应处理: + │ 1. 按模型提供商order排序 + │ 2. 前端map为 {order, icon, label: item.model, value: item.model} 格式 + │ 3. 前置 "全部" 选项 + │ 4. 同时构建 modelPriceMap(Map)供AI积分计算 + │ 5. 构建 llmModelSet(Set)用于LLM模型识别 + └── 消费: + - MySelect组件的list prop(模型筛选下拉框) + - modelPriceMap → calculateModelPrice(图表+表格中的积分计算) + - llmModelSet → isLLMModel(缓存命中率展示判断) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..4d3189dc761e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\250\241\345\236\213\347\233\221\346\216\247/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,34 @@ +--- +capability_label: 模型监控 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:30:00Z" +parent_module: 模型管理 +roles: [root] +router_paths: ["/account/model"] +--- + +# 模型监控 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/model/ModelDashboard/index.tsx` | 模型监控主页面,包含筛选栏、图表视图和表格视图的切换逻辑 | 引用自外部:DateRangePicker、MySelect、AreaChartComponent、FillRowTabs、MyBox、useRequest、useSystemStore | 否 | 无外部 Props(通过路由挂载) | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/model/ModelDashboard/DataTableComponent.tsx` | 监控数据表格视图,支持按模型/渠道聚合、多列排序、详情跳转 | 引用自外部:MyBox、useSystemStore、calculateModelPrice;被 ModelDashboard 引用 | 否 | data: DashboardDataEntry[], filterProps, channelList, modelPriceMap, onViewDetail, isLLMModel | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 共享组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享组件库 | +| `AreaChartComponent` | `@fastgpt/web/components/common/charts/AreaChartComponent` | 共享组件库 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 共享组件库 | +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户布局模块(由父页面 ModelProvider 提供) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c33afac66c83 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/API\347\264\242\345\274\225.md" @@ -0,0 +1,137 @@ +--- +capability_label: "活跃模型" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — API索引 + +## 说明 + +活跃模型模块主要通过 `useSystemStore` 获取全局缓存的模型数据,不单独请求模型列表 API。模型数据的获取在系统初始化时由 `clientInitData()` → `getInitData` API 统一完成。本模块仅在特定操作场景中直接发起 API 请求。 + +--- + +## 模型训练 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/train/embedding/task/create` | POST | 创建向量模型训练任务 | `projects/app/src/web/core/app/api/train.ts:28` → `BaseModelTrainModal.tsx:402` | 账户→模型管理→活跃模型→训练弹窗→提交 embedding 训练时调用 | +| `/core/train/rerank/task/create` | POST | 创建重排模型训练任务 | `projects/app/src/web/core/app/api/train.ts:52` → `BaseModelTrainModal.tsx:405` | 账户→模型管理→活跃模型→训练弹窗→提交 reRank 训练时调用 | +| `/core/train/embedding/task/list` | POST | 获取向量模型训练任务列表 | `projects/app/src/web/core/app/api/train.ts:34` → `TrainDetailDrawer.tsx` | 账户→模型管理→活跃模型→自定义模型→训练详情抽屉→加载时调用 | +| `/core/train/rerank/task/list` | POST | 获取重排模型训练任务列表 | `projects/app/src/web/core/app/api/train.ts:64` → `TrainDetailDrawer.tsx` | 账户→模型管理→活跃模型→自定义模型→训练详情抽屉→加载时调用 | + +--- + +## 权限管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/system/updateModelCollaborators` | POST | 批量更新模型协作权限 | `projects/app/src/web/common/system/api.ts` → `BatchPermissionActionBar.tsx:40` | 账户→模型管理→活跃模型→批量勾选模型→权限配置→提交协作者时调用 | + +--- + +## 系统配置 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/system/updateDefaultModels` | PUT | 更新各类型的系统默认模型 | 通过 `putUpdateDefaultModels`(系统 API 层) → `DefaultModelModal.tsx` | 账户→模型管理→活跃模型→默认模型弹窗→root 保存默认模型时调用 | + +--- + +## 知识库查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/dataset/list` | GET | 获取团队知识库树(含子节点) | `projects/app/src/web/core/app/api/dataset.ts` → `ModelListTable.tsx:147`
`BaseModelTrainModal.tsx:124` | 活跃模型→自定义模型Tab→加载训练数据列名称时调用;活跃模型→训练弹窗→加载可选知识库树时调用 | + +--- + +## 全局数据刷新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/common/system/getInitData` | GET | 获取系统初始化数据(含全部模型列表) | 通过 `clientInitData()` → `DefaultModelModal.tsx`
`BaseModelTrainModal.tsx`
`TrainDetailDrawer.tsx`
`BatchPermissionActionBar.tsx` | 活跃模型→训练任务创建成功后调用刷新;默认模型保存成功后调用刷新;训练详情关闭后调用刷新;权限配置成功后调用刷新 | + +--- + +## API 调用链追踪 + +### `/core/train/embedding/task/create` 调用链 + +``` +BaseModelTrainModal(训练弹窗) + ├── 触发: 用户选择训练数据集并确认提交,且基座类型为 embedding + ├── 参数: { baseModelId, datasetIds, newModelName } + └── 响应处理: onSuccess → onSuccess?.() + onClose(),触发 clientInitData 全局刷新 +``` + +### `/core/train/rerank/task/create` 调用链 + +``` +BaseModelTrainModal(训练弹窗) + ├── 触发: 用户选择训练数据集并确认提交,且基座类型为 reRank + ├── 参数: { baseModelId, datasetIds, newModelName } + └── 响应处理: onSuccess → onSuccess?.() + onClose(),触发 clientInitData 全局刷新 +``` + +### `/core/train/embedding/task/list` 调用链 + +``` +TrainDetailDrawer(训练详情抽屉) + ├── 触发: 抽屉打开时 useRequest 自动发起,模型类型为 embedding + ├── 参数: { modelId } + └── 响应处理: 渲染训练任务列表(状态、创建时间、进度等) +``` + +### `/core/train/rerank/task/list` 调用链 + +``` +TrainDetailDrawer(训练详情抽屉) + ├── 触发: 抽屉打开时 useRequest 自动发起,模型类型为 reRank + ├── 参数: { modelId } + └── 响应处理: 渲染训练任务列表(状态、创建时间、进度等) +``` + +### `/common/system/updateModelCollaborators` 调用链 + +``` +BatchPermissionActionBar(批量权限操作栏) + ├── 触发: 用户在协作者弹窗中选择成员和角色后点击确认 + ├── 参数: { collaborators, modelIds } + └── 响应处理: 弹窗关闭,列表数据刷新 + +AddModelBox / ModelConfigTable(添加/编辑模型弹窗)[跨模块引用] + ├── 触发: 保存模型配置时包含协作者信息 + ├── 参数: { collaborators, modelIds } + └── 响应处理: 弹窗关闭 +``` + +### `/core/dataset/list` 调用链 + +``` +ModelListTable(模型列表表格) + ├── 触发: 切换到自定义模型 Tab 时 useEffect 自动发起 + ├── 参数: { parentId: null } + └── 响应处理: 递归遍历知识库树,构建 datasetId → 知识库名称映射,用于训练数据列渲染 + +BaseModelTrainModal(训练弹窗) + ├── 触发: 弹窗打开时 useRequest 自动发起 + ├── 参数: { parentId: null } + └── 响应处理: 构建知识库树状态快照(TreeNode/leafDescendantMap),渲染可选数据集列表 +``` + +### `/common/system/getInitData` 调用链 + +``` +clientInitData() 全局刷新(多处调用) + ├── 触发: 训练任务创建成功后 + ├── 触发: 默认模型保存成功后 + ├── 触发: 训练详情操作后 + ├── 触发: 权限配置成功后 + └── 响应处理: 更新 useSystemStore 中全部模型列表数据 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..5d60172c87be --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\264\273\350\267\203\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,61 @@ +--- +capability_label: "活跃模型" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T11:30:00Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 活跃模型 — 组件列表 + +## ModelTable/ + +主组件目录,位于 `projects/app/src/components/core/ai/ModelTable/`。 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `index.tsx` | 活跃模型主组件,管理双 Tab(基座/自定义)切换,组合筛选、表格、批量操作和弹窗/抽屉 | 引用自 @fastgpt/web(FillRowTabs、useTableMultipleSelect);引用自系统 Store(useSystemStore、useUserStore) | 否(内部导出 ModelPriceModal 包装组件) | `permissionConfig: boolean` | +| `ModelFilterBar.tsx` | 模型筛选栏:供应商下拉、模型类型下拉、关键词搜索框 | 引用自 @fastgpt/web(MySelect) | 否 | `filterState: FilterState`, `providerList: ProviderOption[]` | +| `ModelListTable.tsx` | 模型列表表格(含表头排序、复选框、操作列、引用提示弹窗) | 引用自系统 API(getDatasetsWithChildren);引用自 ModelReferenceModal | 否 | `modelList: ModelRow[]`, `tabType: ModelTabType`, `permissionConfig: boolean` | +| `BatchPermissionActionBar.tsx` | 批量权限操作栏,勾选模型后底部浮出,提供权限配置入口 | 引用自 @fastgpt/global(ReadRoleVal);引用自 LazyCollaboratorProvider;引用自系统 API(updateModelCollaborators) | 否 | `FloatingActionBar`, `selectedItems: ModelRow[]`, `permission: TeamPermission` | +| `DefaultModelModal.tsx` | 默认模型设置弹窗,root 用户为各类型指定默认模型 | 引用自 @fastgpt/web(MyModal);引用自 useSystemStore | 否 | `onClose: () => void`, `onSuccess?: () => void` | +| `BaseModelTrainModal.tsx` | 模型训练弹窗,选择基座模型和训练数据集发起训练 | 引用自 @fastgpt/web(MyModal);引用自 useSystemStore;引用自训练 API(createEmbeddingTrainTask、createRerankTrainTask) | 否 | `onClose: () => void`, `defaultBaseModel?`, `onSuccess?: () => void` | +| `TrainDetailDrawer.tsx` | 训练详情抽屉,展示模型的训练任务列表和历史 | 引用自训练 API(getEmbeddingTrainTaskList、getRerankTrainTaskList) | 否 | `onClose: () => void`, `modelName: string`, `modelId: string`, `baseModelType`, `tabType: ModelTabType` | +| `ModelReferenceModal.tsx` | 模型引用提示弹窗,当权限更新被阻止时列出引用该模型的资源 | 无外部跨模块依赖 | 是 → 被 AddModelBox、ModelConfigTable 引用 | `isOpen: boolean`, `references: ModelReference[]`, `onClose: () => void` | + +## 内部子组件 + +以下组件定义在父组件文件内部,不作为独立文件导出: + +| 组件名 | 定义位置 | 用途 | +|--------|---------|------| +| ModelTabContent | `index.tsx:209` | 组合 ModelFilterBar + ModelListTable + BatchPermissionActionBar 的 Tab 内容容器 | +| ModelTableRow | `ModelListTable.tsx:345` | 单行模型数据渲染(含复选框、名称切换、类型标签、操作按钮等) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | @fastgpt/web 通用组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal`(dynamic import) | @fastgpt/web 通用组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | @fastgpt/web 通用组件库 | +| `SearchInput` | `@fastgpt/web/components/common/SearchInput` | @fastgpt/web 通用组件库 | +| `LazyCollaboratorProvider` | `@/components/support/permission/MemberManager/context` | 权限管理模块 | +| `AIModelSelector` | `@/components/core/ai/AIModelSelector` | AI 模型选择器模块 | +| `useTableMultipleSelect` | `@fastgpt/web/hooks/useTableMultipleSelect` | @fastgpt/web 通用 Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | @fastgpt/web 通用 Hooks | + +## hooks/ + +| 文件路径 | 用途 | 导出 | +|---------|------|------| +| `hooks/useModelTableState.ts` | 活跃模型页面核心状态管理 Hook:管理 Tab 切换、筛选状态、模型列表过滤/排序、多选状态、弹窗/抽屉开关 | `useModelTableState` | + +## helpers/ + +| 文件路径 | 用途 | 导出 | +|---------|------|------| +| `helpers/modelList.tsx` | 模型列表过滤、排序、供应商选项生成、模型类型选项生成等工具函数 | `getFilteredModelList`, `getSortedModelList`, `getProviderOptions`, `getFilteredProviderList`, `getModelTypeOptions` | +| `helpers/trainStatus.ts` | 训练状态相关的工具函数 | 训练状态格式化、状态映射等 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..c5b4391c37e2 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/API\347\264\242\345\274\225.md" @@ -0,0 +1,81 @@ +--- +capability_label: 渠道日志 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 模型管理 +roles: [root] +router_paths: [/account/model] +--- + +# 渠道日志 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channels/all` | GET | 获取全部渠道列表(用于筛选下拉) | `src/web/core/ai/channel.ts:110` → `src/pageComponents/account/model/Log/index.tsx:81` | 模型管理→渠道日志→页面加载时调用(填充渠道筛选下拉) | +| `/api/aiproxy/api/logs/search` | GET | 分页搜索渠道请求日志 | `src/web/core/ai/channel.ts:165` → `src/pageComponents/account/model/Log/index.tsx:125` | 模型管理→渠道日志→首次加载时调用;修改筛选条件时调用;滚动到底部加载更多时调用 | +| `/core/ai/model/list` | POST | 获取系统模型列表(用于筛选下拉) | `src/web/core/ai/config.ts:15` → `src/pageComponents/account/model/Log/index.tsx:100` | 模型管理→渠道日志→页面加载时调用(填充模型筛选下拉) | + +## 详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/logs/detail/{id}` | GET | 获取单条日志详情(请求体/响应体) | `src/web/core/ai/channel.ts:195` → `src/pageComponents/account/model/Log/index.tsx:284` | 模型管理→渠道日志→失败请求→点击详情按钮时调用(成功请求直接使用列表数据) | + +## API 调用链追踪 + +### `GET /api/aiproxy/api/channels/all` 调用链 + +``` +渠道日志页面 (Log/index.tsx:81) + ├── 触发: 渠道日志 Tab 激活,页面初始化 + ├── Hook: useRequest(getChannelList, {manual: false}) + ├── 参数: page=1, perPage=10 + ├── 响应处理: 按 status 升序、priority 降序排序 → 转换为 {label: name, value: String(id)} → 前置 「全部」选项 → 存入 channelList 状态 + └── 错误处理: 默认空数组,筛选下拉为空 +``` + +### `POST /core/ai/model/list` 调用链 + +``` +渠道日志页面 (Log/index.tsx:100) + ├── 触发: 渠道日志 Tab 激活,页面初始化 + ├── Hook: useRequest(getSystemModelList, {manual: false}) + ├── 参数: 无 + ├── 响应处理: 映射为 {order, icon, label: model, value: model} → 按 order 排序 → 前置 「全部」选项 → 存入 modelList + └── 错误处理: 默认空数组,筛选下拉为空 +``` + +### `GET /api/aiproxy/api/logs/search` 调用链 + +``` +渠道日志页面 (Log/index.tsx:125) + ├── 触发: 页面初始化 / 筛选条件变化 / 滚动到底部 + ├── Hook: useScrollPagination(getChannelLog, {pageSize: 20, refreshDeps: [filterProps]}) + ├── 参数: + │ ├── request_id: filterProps.request_id (仅 root) + │ ├── channel: filterProps.channelId (渠道ID,空字符串=全部) + │ ├── model_name: filterProps.model (模型名,空字符串=全部) + │ ├── code_type: filterProps.code_type ('all'|'success'|'error') + │ ├── start_timestamp: filterProps.dateRange.from.getTime() + │ ├── end_timestamp: filterProps.dateRange.to.getTime() + │ ├── result_only: true + │ ├── p: Math.floor(offset / pageSize) + 1 + │ └── per_page: pageSize (20) + ├── 响应处理: 返回 {list: logs[], total} → 格式化为 LogDetailType(计算 duration、匹配渠道名/模型名、格式化时间) → 渲染表格 + └── 错误处理: 由 useScrollPagination 统一处理,显示加载失败状态 +``` + +### `GET /api/aiproxy/api/logs/detail/{id}` 调用链 + +``` +日志详情弹窗 (Log/index.tsx:284) + ├── 触发: 点击失败请求的「详情」按钮 + ├── Hook: useRequest(getLogDetail, {manual: false}) + ├── 参数: id (日志记录ID) + ├── 条件: 仅当 data.code !== 200 时发起请求 + ├── 响应处理: 合并 detailData 与列表数据({...res, ...data}) → 渲染详情网格 + └── 错误处理: catch 后回退使用列表数据(return data),确保基本信息始终可展示 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..9878319a2b4e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\346\227\245\345\277\227/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,52 @@ +--- +capability_label: 渠道日志 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 模型管理 +roles: [root] +router_paths: [/account/model] +--- + +# 渠道日志 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/model/Log/index.tsx` | 渠道日志列表页(含筛选栏、日志表格、详情弹窗) | 引用自 共享组件库 → DateRangePicker, MySelect, MyBox, MyModal, MyIcon, SearchInput, QuestionTip | 否 | 无外部 Props(内部状态自管理) | + +## 内部子组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Log/index.tsx` → `ChannelLog` | 日志列表与筛选的主组件 | 内部 | 否 | 无 | +| `Log/index.tsx` → `LogDetail` | 日志详情弹窗组件 | 内部 | 否 | `data: LogDetailType, onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 共享组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享组件库 | +| `Table, Thead, Tbody, Tr, Th, Td, TableContainer` | `@chakra-ui/react` | 第三方 UI 库(Chakra UI) | +| `Box, Flex, Button, HStack, ModalBody, Grid, GridItem` | `@chakra-ui/react` | 第三方 UI 库(Chakra UI) | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 数据请求与状态管理(渠道列表、模型列表、日志详情) | +| `useScrollPagination` | `@fastgpt/web/hooks/useScrollPagination` | 无限滚动分页(日志列表,每页 20 条) | + +## 消费的外部 Store + +| Store | 导入路径 | 用途 | +|-------|---------|------| +| `useUserStore` | `@/web/support/user/useUserStore` | 获取当前用户信息(判断是否为 root) | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取模型供应商信息(`getModelProvider`)、系统配置(`feConfigs`) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..8d6577e8ac42 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/API\347\264\242\345\274\225.md" @@ -0,0 +1,130 @@ +--- +capability_label: 渠道管理 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:03:42Z" +parent_module: 模型管理 +roles: + - root +router_paths: + - /account/model +--- + +# 渠道管理 — API索引 + +API 定义文件: `projects/app/src/web/core/ai/channel.ts` + +所有 API 请求基于 `/api/aiproxy/api` 前缀,通过 axios 实例发送,自动处理空值过滤和响应校验。 + +## 渠道查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channels/all` | GET | 获取全部渠道列表 | `channel.ts:110` → `Channel/index.tsx:54` | 渠道管理→渠道列表→加载时调用;渠道管理→创建/编辑/删除/启停/优先级→操作成功后刷新列表 | + +## 供应商元数据 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channels/type_metas` | GET | 获取供应商类型元数据(名称、默认URL、密钥说明) | `channel.ts:124` → `Channel/index.tsx:58`;`channel.ts:124` → `EditChannelModal.tsx:70` | 渠道管理→渠道列表→加载时调用;渠道管理→创建/编辑渠道→加载供应商下拉选项 | + +## 渠道创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/createChannel` | POST | 创建新渠道 | `channel.ts:136` → `EditChannelModal.tsx:127` | 渠道管理→创建渠道→提交创建表单时调用 | + +## 渠道更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channel/{id}` | PUT | 更新渠道完整信息 | `channel.ts:151` → `EditChannelModal.tsx:127`;`channel.ts:151` → `Channel/index.tsx:64` | 渠道管理→编辑渠道→提交编辑表单时调用;渠道管理→调整优先级→输入框失焦时调用 | +| `/api/aiproxy/api/channel/{id}/status` | POST | 切换渠道状态(启用/禁用) | `channel.ts:147` → `Channel/index.tsx:70` | 渠道管理→渠道列表→操作菜单→启用/禁用时调用 | + +## 渠道删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/channel/{id}` | DELETE | 删除渠道 | `channel.ts:163` → `Channel/index.tsx:82` | 渠道管理→渠道列表→操作菜单→删除→确认后调用 | + +## API 调用链追踪 + +### `/api/aiproxy/api/channels/all` 调用链 + +``` +ChannelTable (Channel/index.tsx) + ├── 触发: 组件挂载时自动调用(useRequest manual:false) + ├── 参数: page=1, perPage=10 + └── 响应处理: 按 status 排序(启用在前),同 status 按 priority 降序;更新 channelList 状态 + +ChannelTable (Channel/index.tsx) — 操作成功后 + ├── 触发: 创建/编辑/删除/启停/优先级修改成功后手动调用 refreshChannelList + ├── 参数: 同上 + └── 响应处理: 同上,刷新表格显示 +``` + +### `/api/aiproxy/api/channels/type_metas` 调用链 + +``` +ChannelTable (Channel/index.tsx) + ├── 触发: 组件挂载时自动调用 + ├── 参数: 无 + └── 响应处理: 将返回值与 aiproxyChannels 合并为 channelProviders,用于展示供应商名称和图标 + +EditChannelModal (EditChannelModal.tsx) + ├── 触发: 弹窗打开时自动调用 + ├── 参数: 无 + └── 响应处理: 将 Object.entries 映射为下拉选项(含图标、标签、默认URL、密钥说明),用于供应商类型选择 +``` + +### `/api/aiproxy/api/createChannel` 调用链 + +``` +EditChannelModal (EditChannelModal.tsx) + ├── 触发: 用户点击"新建"按钮(非编辑模式、表单校验通过) + ├── 参数: {type, name, base_url, models, model_mapping, key, priority: 1} + └── 响应处理: 成功后关闭弹窗、调用 onSuccess(刷新列表)、Toast 提示"创建成功" +``` + +### `/api/aiproxy/api/channel/{id}` (PUT) 调用链 + +``` +EditChannelModal (EditChannelModal.tsx) + ├── 触发: 用户点击"更新"按钮(编辑模式、表单校验通过) + ├── 参数: {type, name, base_url, models, model_mapping, key, status, priority} + └── 响应处理: 成功后关闭弹窗、调用 onSuccess(刷新列表)、Toast 提示"更新成功" + +ChannelTable (Channel/index.tsx) + ├── 触发: 优先级输入框失焦时(onBlur) + ├── 参数: 完整渠道数据 + 新 priority 值(空值默认 1) + └── 响应处理: 成功后自动刷新列表 +``` + +### `/api/aiproxy/api/channel/{id}/status` 调用链 + +``` +ChannelTable (Channel/index.tsx) + ├── 触发: 用户点击操作菜单中的"启用"或"禁用" + ├── 参数: {status: ChannelStatusEnabled | ChannelStatusDisabled} + └── 响应处理: 成功后自动刷新列表 +``` + +### `/api/aiproxy/api/channel/{id}` (DELETE) 调用链 + +``` +ChannelTable (Channel/index.tsx) + ├── 触发: 用户点击操作菜单"删除"→确认弹窗中点击确认 + ├── 参数: id(渠道ID) + └── 响应处理: 成功后自动刷新列表 +``` + +## API 基础配置 + +所有 API 请求共享以下配置: + +- **Base URL**: `/api/aiproxy/api`(通过 `getWebReqUrl` 拼接) +- **超时**: 60000ms +- **请求头**: `content-type: application/json` +- **数据过滤**: 请求前自动清除值为 `undefined` 的字段 +- **响应校验**: `response.data.success` 为 true 时返回 `response.data.data`;否则 reject +- **错误处理**: 网络错误/超时统一 reject,显示控制台日志 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..fa215ab3dc56 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\346\270\240\351\201\223\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,59 @@ +--- +capability_label: 渠道管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:03:42Z" +parent_module: 模型管理 +roles: + - root +router_paths: + - /account/model +--- + +# 渠道管理 — 组件列表 + +## Channel/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/model/Channel/index.tsx` | 渠道列表表格页,展示所有渠道并提供 CRUD、启停、测试、优先级调整操作 | 引用自 `@/web/core/ai/channel` (API)、`@fastgpt/web` (通用组件)、`@/global/aiproxy` (类型/常量) | 否 | 无(无 props,数据来自内部 hooks) | +| `projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx` | 渠道创建/编辑弹窗,包含供应商选择、模型多选、JSON 映射编辑、URL/密钥输入 | 引用自 `@/web/core/ai/channel` (API)、`../AddModelBox` (ModelEditModal、AddModelButton)、`@/web/core/ai/config` (getSystemModelList) | 否 | `defaultConfig: ChannelInfoType`, `onClose: () => void`, `onSuccess: () => void` | +| `projects/app/src/pageComponents/account/model/Channel/ModelTest.tsx` | 模型连通性测试弹窗,支持批量/单项测试,展示测试状态和耗时 | 引用自 `@/web/core/ai/config` (getSystemModelList, getTestModel)、`@fastgpt/global` (batchRun, getErrText) | 否 | `channelId: number`, `models: string[]`, `onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| MyBox | `@fastgpt/web/components/common/MyBox` | web 通用组件库 | +| MyIconButton | `@fastgpt/web/components/common/Icon/button` | web 通用组件库 | +| MyTag | `@fastgpt/web/components/common/Tag/index` | web 通用组件库 | +| MyMenu | `@fastgpt/web/components/common/MyMenu` | web 通用组件库 | +| MyModal | `@fastgpt/web/components/common/MyModal` | web 通用组件库 | +| MySelect | `@fastgpt/web/components/common/MySelect` | web 通用组件库 | +| MyNumberInput | `@fastgpt/web/components/common/Input/NumberInput` | web 通用组件库 | +| Avatar / MyAvatar | `@fastgpt/web/components/common/Avatar` | web 通用组件库 | +| QuestionTip | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | web 通用组件库 | +| JsonEditor | `@fastgpt/web/components/common/Textarea/JsonEditor` | web 通用组件库 | +| CopyBox | `@fastgpt/web/components/common/String/CopyBox` | web 通用组件库 | +| FormLabel | `@fastgpt/web/components/common/MyBox/FormLabel` | web 通用组件库 | +| MyIcon | `@fastgpt/web/components/common/Icon` | web 通用组件库 | +| ModelEditModal | `../AddModelBox` | 模型管理内部组件 | +| AddModelButton | `../AddModelBox` | 模型管理内部组件 | +| getNewModelFormData | `../AddModelBox` | 模型管理内部函数 | +| AccountContainer | `@/pageComponents/account/AccountContainer` | 账户模块 | +| MyTabs | `@fastgpt/web/components/common/MyTabs` | web 通用组件库 | +| Table / Thead / Tbody / Tr / Th / Td / TableContainer | `@chakra-ui/react` | Chakra UI | +| Box / Flex / Button / Input / HStack / Menu / MenuButton / MenuList / MenuItem | `@chakra-ui/react` | Chakra UI | +| ModalBody / ModalFooter | `@chakra-ui/react` | Chakra UI | +| useDisclosure / useOutsideClick | `@chakra-ui/react` | Chakra UI | + +## 说明 + +渠道管理模块包含 3 个自有组件和 1 个内联组件(MultipleSelect): + +- **ChannelTable** 是主列表组件,承载所有数据展示和操作入口 +- **EditChannelModal** 负责渠道的创建和编辑表单,内部集成供应商选择、模型多选、模型映射编辑器 +- **ModelTest** 负责模型的连通性测试,支持批量并发测试和单项重测 +- **MultipleSelect** 是 EditChannelModal 内部的内联多选组件,用于模型选择 + +所有组件均使用 React 函数式组件 + Hooks 模式,通过 Chakra UI 构建 UI,通过 `@fastgpt/web` 的通用组件库提供业务组件。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..20ad39e3b603 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,64 @@ +--- +capability_label: 模型管理 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:00:00Z" +parent_module: 账户 +roles: [root, owner, admin, member] +router_paths: ["/account/model"] +--- + +# 模型管理 — 组件列表 + +## 页面组件 (pages/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/account/model/index.tsx` | 模型管理主页面,通过 MyTabs 组织 5 个 Tab 的内容切换 | 内部:ModelTable, ModelConfigTable, ChannelTable, ChannelLog, ModelDashboard(动态导入) | 否 | 无外部 Props(页面级组件) | +| `pages/account/model/index.tsx → ValidModelTable` | 活跃模型 Tab 的包装组件,渲染带权限配置的 ModelTable | 引用自 `components/core/ai/ModelTable` | 否 | 无 Props | + +## 业务组件 (pageComponents/account/model/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/model/ModelConfigTable.tsx` | 配置模型 Tab:全量模型表格,支持筛选、编辑、创建、删除、测试模型及 JSON 配置导入 | 引用自 `useSystemStore`、`@fastgpt/web/components` | 否 | 无外部 Props | +| `pageComponents/account/model/Channel/index.tsx` | 渠道管理 Tab:渠道列表表格,支持优先级调整、启用/禁用、编辑、删除和模型测试 | 引用自 `useSystemStore`、`web/core/ai/channel` | 否 | 无外部 Props | +| `pageComponents/account/model/Log/index.tsx` | 渠道日志 Tab:请求日志搜索与翻页,支持按日期/渠道/模型/状态/请求ID 筛选,可展开日志详情弹窗 | 引用自 `web/core/ai/channel`、`web/core/ai/config` | 否 | 无外部 Props | +| `pageComponents/account/model/ModelDashboard/index.tsx` | 模型监控 Tab:模型调用数据 Dashboard,含面积图和表格双视图 | 引用自 `web/core/ai/channel`、`web/core/ai/config` | 否 | 无外部 Props | +| `pageComponents/account/model/Channel/EditChannelModal.tsx` | 渠道编辑弹窗:创建/编辑渠道的表单弹窗 | 内部:`getSystemModelList` 获取可用模型 | 否 | `defaultConfig: ChannelInfoType; onClose: () => void; onSuccess: () => void` | +| `pageComponents/account/model/Channel/ModelTest.tsx` | 渠道模型测试弹窗:测试指定渠道下模型的连通性 | 内部:`getSystemModelList` | 否 | `channelId: number; models: string[]; onClose: () => void` | +| `pageComponents/account/model/AddModelBox.tsx` | 模型编辑/创建弹窗:包含 ModelEditModal 的表单弹窗 | 内部:调用 putSystemModel / postSystemModel | 否 | `editModelData: GetModelDetailResponse; onClose: () => void; onSuccess: () => void` | +| `pageComponents/account/model/ModelDashboard/DataTableComponent.tsx` | 模型监控数据表格组件 | 内部 | 否 | 接收 `ModelDashboardData[]` 类型数据 | + +## 核心 AI 组件 (components/core/ai/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `components/core/ai/ModelTable/index.tsx` | 活跃模型主组件:展示基础模型/自定义模型双 Tab 的模型列表,支持筛选(提供商+类型+搜索)、批量权限操作、默认模型设置、模型训练 | 被 `price/Points.tsx` 引用(价格页面中的模型价格弹窗) | 是 → [15-共享组件清单] | `permissionConfig?: boolean` | +| `components/core/ai/ModelTable/index.tsx → ModelPriceModal` | 模型价格弹窗:在弹窗中嵌入 ModelTable 组件 | 被跨页面复用 | 是 | `children: ({ onOpen }: { onOpen: () => void }) => ReactNode` | +| `components/core/ai/ModelTable/hooks/useModelTableState.ts` | ModelTable 的状态管理 Hook:管理 Tab、筛选、排序、多选、训练抽屉等状态 | 内部 | 否 | `UseModelTableStateProps`(含 t, language, permissionConfig, 模型列表等) | +| `components/core/ai/ModelTable/helpers/modelList.tsx` | 模型列表格式化工具函数:提供商选项、模型类型选项、筛选/排序逻辑 | 内部 | 否 | 导出函数:getProviderOptions, getModelTypeOptions, getFilteredProviderList, getFilteredModelList, getSortedModelList | +| `components/core/ai/ModelTable/components/ModelFilterBar.tsx` | 模型筛选栏:提供商下拉 + 类型下拉 + 搜索框 + Tab 槽位 + 操作槽位 | 内部 | 否 | `FilterPanelProps`(t, filterState, setFilterState, providerList, modelTypeList, tabSlot, actionSlot) | +| `components/core/ai/ModelTable/components/ModelListTable.tsx` | 模型列表表格:含多选、列排序、Tag 显示、操作列 | 内部 | 否 | `TablePanelProps`(含 modelList, modelType, permissionConfig 等) | +| `components/core/ai/ModelTable/components/BatchPermissionActionBar.tsx` | 批量权限操作栏:批量选中模型后的浮动操作栏 | 内部 | 否 | `FloatingActionBar, selectedItems, permission, t` | +| `components/core/ai/ModelTable/components/DefaultModelModal.tsx` | 默认模型设置弹窗 | 内部:调用 putUpdateDefaultModels | 否 | `onClose: () => void; onSuccess: () => void` | +| `components/core/ai/ModelTable/components/BaseModelTrainModal.tsx` | 基础模型训练弹窗 | 内部 | 否 | `onClose: () => void; onSuccess: () => void; defaultBaseModel?: BaseModelTrainDefaultBaseModel` | +| `components/core/ai/ModelTable/components/TrainDetailDrawer.tsx` | 训练任务详情抽屉 | 内部 | 否 | `onClose: () => void; onSuccess: () => void; modelName, modelId, baseModelType, tabType` | +| `components/core/ai/PriceTiersLabel` | 阶梯价格标签组件 | 被 ModelTable、ModelConfigTable 等引用 | 是 | `config: BaseModelItem; unitLabel: string` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户布局(账户模块内部共享) | +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | 共享组件库 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 共享组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `MyIcon` / `MyIconButton` | `@fastgpt/web/components/common/Icon` | 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享组件库 | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | 共享组件库 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 共享组件库 | +| `AreaChartComponent` | `@fastgpt/web/components/common/charts/AreaChartComponent` | 共享组件库 | +| `MyTag` | `@fastgpt/web/components/common/Tag` | 共享组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..704b55e86303 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/API\347\264\242\345\274\225.md" @@ -0,0 +1,132 @@ +--- +capability_label: "配置模型" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T11:05:00.000Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 配置模型 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/ai/model/list` | POST | 获取系统模型列表 | `web/core/ai/config.ts:15` → `ModelConfigTable.tsx:98` | 账户→模型管理→配置模型→加载时调用;创建/编辑/删除/启禁用操作后刷新调用 | +| `/api/core/ai/model/detail` | GET | 获取单个模型详情 | `web/core/ai/config.ts:19` → `ModelConfigTable.tsx:293` | 账户→模型管理→配置模型→点击编辑图标时调用 | +| `/api/core/ai/model/getConfigJson` | GET | 获取模型配置 JSON | `web/core/ai/config.ts:31` → `ModelConfigTable.tsx:566` | 账户→模型管理→配置模型→root 点击「配置文件」按钮时调用 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/ai/model/create` | POST | 创建自定义模型 | `web/core/ai/config.ts:27` → `AddModelBox.tsx`(ModelEditModal) | 账户→模型管理→配置模型→新增模型→填写表单提交时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/ai/model/update` | PUT | 更新模型参数/状态 | `web/core/ai/config.ts:25` → `ModelConfigTable.tsx:261`(更新开关/删除)→ `AddModelBox.tsx`(编辑保存) | 账户→模型管理→配置模型→切换启用开关时调用;账户→模型管理→配置模型→编辑模型保存时调用 | +| `/api/core/ai/model/updateWithJson` | PUT | 全量覆盖模型配置 | `web/core/ai/config.ts:32` → `ModelConfigTable.tsx:574` | 账户→模型管理→配置模型→root→JSON 编辑器确认覆盖时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/ai/model/delete` | DELETE | 删除自定义模型 | `web/core/ai/config.ts:29` → `ModelConfigTable.tsx:280` | 账户→模型管理→配置模型→点击删除→确认弹窗确认时调用 | + +## 测试 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/core/ai/model/test` | GET | 测试模型连通性 | `web/core/ai/config.ts:35` → `ModelConfigTable.tsx:257` | 账户→模型管理→配置模型→点击测试图标时调用 | + +## 权限/协作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `getModelCollaborators` | — | 获取模型协作者列表 | `web/common/system/api.ts` → `ModelConfigTable.tsx:479` | 账户→模型管理→配置模型→点击权限钥匙图标→打开协作者弹窗时调用 | +| `updateModelCollaborators` | — | 更新模型协作者 | `web/common/system/api.ts` → `ModelConfigTable.tsx:480` | 账户→模型管理→配置模型→协作者弹窗→修改协作者提交时调用 | + +--- + +## API 调用链追踪 + +### `/api/core/ai/model/list` 调用链 + +``` +ModelConfigTable (useRequest getSystemModelList) + ├── 触发: 组件挂载时自动调用;操作成功后调用 refreshModels 重新请求 + ├── 参数: 无(POST 空 body) + └── 响应处理: 存入 systemModelList state → useMemo 按类型分组格式化 → 关联供应商信息 → 排序 → 前端筛选 → 渲染表格 +``` + +### `/api/core/ai/model/detail` 调用链 + +``` +ModelConfigTable (onEditModel) + ├── 触发: 点击模型行编辑图标 + ├── 参数: { id: string } + └── 响应处理: onSuccess 回调中 setEditModelData(data) → 触发 ModelEditModal 渲染 +``` + +### `/api/core/ai/model/create` 调用链 + +``` +ModelEditModal → postSystemModel + ├── 触发: 在模型编辑弹窗中填写表单后点击确认(新建模式) + ├── 参数: CreateModelBody(discriminated union,按模型类型提交 LLM/Embedding/TTS/STT/Rerank 对应字段) + └── 响应处理: onSuccess → 调用父组件传入的 onSuccess 回调 → refreshModels → 关闭弹窗 +``` + +### `/api/core/ai/model/update` 调用链 + +``` +调用方 A: ModelConfigTable (updateModel — 启用/禁用开关) + ├── 触发: 用户切换激活开关 + ├── 参数: { id: string, isActive: boolean } + └── 响应处理: onSuccess → refreshModels;onError → 409+references 弹出 ModelReferenceModal;409+message toast 提示 + +调用方 B: ModelEditModal → putSystemModel(编辑保存) + ├── 触发: 在模型编辑弹窗中修改参数后点击确认(编辑模式) + ├── 参数: UpdateModelBody(含 id + 需更新的字段) + └── 响应处理: onSuccess → refreshModels → 关闭弹窗;onError 同上 +``` + +### `/api/core/ai/model/delete` 调用链 + +``` +ModelConfigTable (deleteModel) + ├── 触发: 点击删除图标 → PopoverConfirm 确认 + ├── 参数: { id: string } + └── 响应处理: onSuccess → refreshModels;onError → 409+references 弹出 ModelReferenceModal +``` + +### `/api/core/ai/model/test` 调用链 + +``` +ModelConfigTable (onTestModel) + ├── 触发: 点击模型行的测试图标 + ├── 参数: { id: string } + └── 响应处理: successToast → t('common:Success') +``` + +### `/api/core/ai/model/getConfigJson` 调用链 + +``` +ModelConfigTable → JsonConfigModal (useRequest getModelConfigJson) + ├── 触发: root 用户点击「配置文件」按钮 + ├── 参数: 无 + └── 响应处理: onSuccess → setData(res) → 填充 JSON 编辑器 +``` + +### `/api/core/ai/model/updateWithJson` 调用链 + +``` +ModelConfigTable → JsonConfigModal (useRequest putUpdateWithJson) + ├── 触发: root 用户在 JSON 编辑器中确认覆盖(经二次确认) + ├── 参数: { config: string } + └── 响应处理: onSuccess → onClose + onSuccess(刷新列表) +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..a6e205825e16 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\346\250\241\345\236\213\347\256\241\347\220\206/\351\205\215\347\275\256\346\250\241\345\236\213/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,41 @@ +--- +capability_label: "配置模型" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T11:05:00.000Z" +parent_module: "模型管理" +roles: ["root", "团队管理员", "团队成员"] +router_paths: ["/account/model"] +--- + +# 配置模型 — 组件列表 + +## pageComponents/account/model/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `ModelConfigTable.tsx` | 模型配置主表格页面,包含筛选栏、模型列表、操作按钮 | 引用自 `@fastgpt/web`(MySelect/SearchInput/Avatar/MyTag/MyBox/MyIconButton/JsonEditor 等);引用自 `@/web/core/ai/config`(API);引用自 `@/web/common/system`(useSystemStore);引用自 `@/web/support/user`(useUserStore) | 否(内部组件,仅被父页面动态导入) | —(页面级组件,无外部 Props) | +| `AddModelBox.tsx` | 模型新增/编辑弹窗组件,包含 ModelEditModal 表单和 AddModelButton 下拉按钮 | 引用自 `@fastgpt/web`(MyModal/MyInput/MySelect 等);引用自 `@/web/core/ai/config`(API) | 是(ModelEditModal 被 Channel/EditChannelModal 引用;AddModelButton 被 ModelConfigTable 引用) | `AddModelButton`: `onCreate: (type: ModelTypeEnum) => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MySelect` | `@fastgpt/web/components/common/MySelect` | packages/web | +| `SearchInput` | `@fastgpt/web/components/common/Input/SearchInput` | packages/web | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | packages/web | +| `MyTag` | `@fastgpt/web/components/common/Tag/index` | packages/web | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | packages/web | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | packages/web | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | packages/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | packages/web | +| `JsonEditor` | `@fastgpt/web/components/common/Textarea/JsonEditor` | packages/web | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | packages/web | +| `CopyBox` | `@fastgpt/web/components/common/String/CopyBox` | packages/web | +| `TableHeaderFilter` | `@fastgpt/web/components/common/TableHeaderFilter` | packages/web | +| `PriceTiersLabel` | `@/components/core/ai/PriceTiersLabel` | projects/app | +| `TestModeBetaTag` | `@/components/core/ai/TestModeBetaTag` | projects/app | +| `ModelReferenceModal` | `@/components/core/ai/ModelTable/ModelReferenceModal` | projects/app | +| `LazyCollaboratorProvider` | `@/components/support/permission/MemberManager/context` | projects/app | +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | projects/app(父页面) | +| `MyTabs` | `@fastgpt/web/components/common/MyTabs` | packages/web(父页面) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1bddb11461c0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/API\347\264\242\345\274\225.md" @@ -0,0 +1,86 @@ +--- +capability_label: 用量统计 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:18:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — API索引 + +## 用量数据查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/usage/getUsage` | POST | 获取用量明细列表(分页) | `api.ts:11` → `UsageTable.tsx:80` | 用量统计→用量明细→加载时调用;用量统计→用量明细→翻页/切换页码时调用;用量统计→用量明细→筛选条件变更时调用 | +| `/proApi/support/wallet/usage/getDashboardData` | POST | 获取看板趋势数据 | `api.ts:14` → `Dashboard.tsx:30` | 用量统计→用量看板→加载时调用;用量统计→用量看板→筛选条件变更时调用 | + +## 用量导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/proApi/support/wallet/usage/exportUsage` | POST | 导出用量数据为 CSV 文件 | `UsageTable.tsx:91`(通过 downloadFetch) | 用量统计→用量明细→点击导出确认后调用 | + +## 团队数据查询(辅助) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| 内部 getTeamMembers | GET | 获取团队成员列表(滚动分页) | `index.tsx:63`(通过 useScrollPagination) | 用量统计→管理员筛选→展开成员下拉时加载;滚动到底部时加载更多 | +| 内部 getGroupList | GET | 获取团队群组列表 | `index.tsx:90`(通过 useRequest) | 用量统计→管理员筛选→切换到群组模式时加载 | +| 内部 getOrgList | GET | 获取部门树(懒加载) | `OrgTreeSelector.tsx`(通过 useRequest) | 用量统计→管理员筛选→切换到部门模式时加载;展开部门节点时加载子节点 | + +--- + +## API 调用链追踪 + +### `/proApi/support/wallet/usage/getUsage` 调用链 + +``` +UsageTableList (UsageTable.tsx) + ├── 触发: 进入用量明细 Tab、翻页、筛选条件变更 + ├── 参数: { dateStart, dateEnd, sources?, memberFilter?, projectName?, pageNum, pageSize=20 } + ├── 响应处理: 更新 usages 列表 → 渲染表格行;total 用于分页组件和导出确认提示 + └── 错误处理: usePagination 内置加载状态(isLoading),空数据显示 EmptyTip +``` + +### `/proApi/support/wallet/usage/getDashboardData` 调用链 + +``` +UsageDashboard (Dashboard.tsx) + ├── 触发: 进入用量看板 Tab、筛选条件变更 + ├── 参数: { dateStart, dateEnd, sources?, memberFilter?, unit: 'day'|'month' } + ├── 响应处理: 计算 totalUsage/totalInputTokens/totalOutputTokens → 传递给 DashboardChart 渲染折线图 + └── 错误处理: useRequest 内置加载状态(loading),MyBox 显示加载动画 +``` + +### `/api/proApi/support/wallet/usage/exportUsage` 调用链 + +``` +UsageTableList → exportUsage (UsageTable.tsx) + ├── 触发: 用户点击导出按钮 → PopoverConfirm 确认 → 执行导出 + ├── 参数: { ...requestParams, appNameMap, sourcesMap, title } + ├── 响应处理: 浏览器下载 CSV 文件(通过 downloadFetch 工具函数) + └── 错误处理: 默认下载失败提示 +``` + +### `getTeamMembers` 调用链 + +``` +UsageTable 入口页面 (index.tsx) + ├── 触发: 管理员权限下,展开成员下拉选择器 + ├── 参数: { searchKey? } + ├── 响应处理: members 列表 → 转换为 tmbList(含头像 + 名称的选项列表);支持搜索过滤(onSearch 触发 300ms 防抖重查) + └── 错误处理: useScrollPagination 内置加载和错误状态 +``` + +### `getOrgList` 调用链 + +``` +OrgTreeSelector (OrgTreeSelector.tsx) + ├── 触发: 管理员切换到部门筛选模式;展开部门节点时懒加载子节点 + ├── 参数: { parentId? }(根节点不传 parentId) + ├── 响应处理: orgToNode 转换为 rc-tree 所需 TreeNodeData 格式(key, title, children, isLeaf) + └── 错误处理: useRequest 内置加载状态 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1158e191ea8b --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/API\347\264\242\345\274\225.md" @@ -0,0 +1,35 @@ +--- +capability_label: dashboard +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 用量统计 +roles: [团队管理员, 团队成员] +router_paths: [/account/usage?usageTab=dashboard] +--- + +# 用量看板 — API索引 + +## 查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/usage/getDashboardData` | POST | 获取用量看板趋势数据(每日积分和Token消耗) | `src/web/support/wallet/usage/api.ts:14` → `src/pageComponents/account/usage/Dashboard.tsx:31` | 账户→用量统计→看板Tab→页面加载时自动调用;账户→用量统计→看板Tab→筛选器变化时自动刷新调用 | + +### `/proApi/support/wallet/usage/getDashboardData` 调用链 + +``` +UsageDashboard (Dashboard.tsx:30) + ├── 触发: 页面加载时自动请求(manual: false) + ├── 触发: filterParams 变化时自动刷新(refreshDeps: [filterParams]) + ├── 参数: + │ ├── dateStart: 日期范围起始(ISO格式),默认最近7天 + │ ├── dateEnd: 日期范围结束(ISO格式),默认当天+1天 + │ ├── sources: UsageSourceEnum[],筛选用量来源,全选时为 undefined + │ ├── memberFilter: { type: 'member'|'group'|'org', ...ids },全选时为 undefined + │ └── unit: 'day' | 'month',统计聚合单位 + ├── 响应处理: + │ ├── 成功: 格式化 date 字段为 YYYY-MM-DD → 存入 totalPoints state + │ └── 计算: totalUsage = ΣtotalPoints, totalInputTokens = ΣinputTokens, totalOutputTokens = ΣoutputTokens + └── 错误处理: useRequest 内部处理,图表区域处于加载失败状态 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..97c4820a27e8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/dashboard/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,25 @@ +--- +capability_label: dashboard +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 用量统计 +roles: [团队管理员, 团队成员] +router_paths: [/account/usage?usageTab=dashboard] +--- + +# 用量看板 — 组件列表 + +## pageComponents/account/usage/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `Dashboard.tsx` | 用量看板主组件,接收筛选参数,请求数据并渲染图表和充值入口 | 引用自 钱包/充值 → RechargeModal | 否(仅被父页面 UsageTable 使用) | `filterParams: UsageFilterParams`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `DashboardChart.tsx` | 图表渲染组件,展示积分折线图和 Token 折线图(输入/输出/总计),含加载态和错误态 | 内部 | 否(仅被 Dashboard.tsx 使用) | `totalPoints: usageFormType[]`, `totalUsage: number`, `totalInputTokens: number`, `totalOutputTokens: number` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `RechargeModal` | `@/components/support/wallet/NotSufficientModal` | 钱包/充值 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享基础设施 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..8361d21beb87 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/API\347\264\242\345\274\225.md" @@ -0,0 +1,65 @@ +--- +capability_label: detail +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: 用量统计 +roles: ["team_member", "team_admin"] +router_paths: ["/account/usage?usageTab=detail"] +--- + +# detail — API索引 + +## 用量数据查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/usage/getUsage` | POST | 分页获取用量记录列表 | `web/support/wallet/usage/api.ts:12` → `pageComponents/account/usage/UsageTable.tsx:80` | 账户→用量统计→用量明细→加载时调用;账户→用量统计→用量明细→筛选条件变更时调用;账户→用量统计→用量明细→翻页时调用 | + +### `/proApi/support/wallet/usage/getUsage` 调用链 + +``` +UsageTableList 组件 + ├── 触发: 组件挂载、筛选条件变更、翻页 + ├── 参数: { dateStart, dateEnd, sources?, memberFilter?, projectName?, pageSize, pageNum } + ├── 通过: usePagination Hook (pageSize=20, scrollContainerRef) + ├── 响应处理: 数据存入 usages 数组 + total 总数,驱动表格渲染和分页控件 + └── 错误处理: usePagination 内置错误处理 + +依赖触发: + dateRange.from / dateRange.to → dateStart / dateEnd(dayjs 格式化) + usageSources → sources(isSelectAllSource 为 true 时不传) + memberFilter → 成员/部门/群组筛选对象(isSelectAllTmb 为 true 时不传) + projectName → 项目名称模糊匹配(300ms 防抖) +``` + +## 数据导出 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/proApi/support/wallet/usage/exportUsage` | POST | 导出用量记录为 CSV 文件 | `pageComponents/account/usage/UsageTable.tsx:91` | 账户→用量统计→用量明细→点击导出→确认后调用 | + +### `/api/proApi/support/wallet/usage/exportUsage` 调用链 + +``` +UsageTableList 组件 → PopoverConfirm 确认弹窗 + ├── 触发: 用户点击导出按钮 → 确认弹窗中点击确认 + ├── 参数: { ...requestParams (筛选参数), appNameMap, sourcesMap, title } + ├── 通过: downloadFetch (通用文件下载函数) + ├── 文件名: usage.csv + ├── appNameMap: 应用名称的 i18n key → 翻译后文本映射 + ├── sourcesMap: 来源枚举 key → { label: 翻译后文本 } 映射 + └── 错误处理: downloadFetch 内置错误处理 +``` + +## 成员搜索 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/getMembers` | GET(由 useScrollPagination 内部请求) | 搜索团队成员列表 | `pages/account/usage/index.tsx:63`(父页面) → `UsageTableList` 通过 props 接收 | 账户→用量统计→筛选模式为「成员」→搜索成员时调用 | + +## 群组列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/group/getGroupList` | GET(由 useRequest 内部请求) | 获取团队群组列表 | `pages/account/usage/index.tsx:91`(父页面) → `UsageTableList` 通过 props 接收 | 账户→用量统计→页面加载时调用(仅管理员可见) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..095f0cb7f298 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/detail/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,54 @@ +--- +capability_label: detail +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: 用量统计 +roles: ["team_member", "team_admin"] +router_paths: ["/account/usage?usageTab=detail"] +--- + +# detail — 组件列表 + +## pageComponents/account/usage/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `UsageTable.tsx` | 用量明细列表主组件,展示分页用量记录表格,集成筛选、导出、详情查看 | 引用自全局 `EmptyTip`、`MyBox`、`Avatar`、`PopoverConfirm`;动态加载 `UsageDetail`、`RechargeModal` | 否(仅被父页面 `index.tsx` 使用) | `filterParams: UsageFilterParams`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `UsageDetail.tsx` | 用量详情弹窗,展示单条用量记录的计费模块明细 | 引用自全局 `MyModal`、`FormLabel`;引用 `useSystemStore` | 否(仅被 `UsageTable.tsx` 动态加载) | `usage: UsageListItemType`, `onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 通用组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 通用组件 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 通用组件 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | web 通用组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 通用组件 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | web 通用组件 | +| `RechargeModal` | `@/components/support/wallet/NotSufficientModal` | 钱包/充值模块 | + +## 消费的外部 Hooks + +| Hook 名称 | 导入路径 | 用途 | +|-----------|---------|------| +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 分页数据管理,内置翻页和滚动加载 | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 异步请求管理(导出操作) | +| `useSafeTranslation` | `@fastgpt/web/hooks/useSafeTranslation` | 安全国际化翻译 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取系统模型列表 | + +## 消费的外部工具函数 + +| 函数名称 | 导入路径 | 用途 | +|---------|---------|------| +| `formatNumber` | `@fastgpt/global/common/math/tools` | 数字格式化显示 | +| `downloadFetch` | `@/web/common/system/utils` | 通用文件下载触发 | + +## 消费的外部类型/常量 + +| 名称 | 导入路径 | 用途 | +|------|---------|------| +| `UsageSourceMap` | `@fastgpt/global/support/wallet/usage/constants` | 来源枚举到中文标签映射 | +| `UsageListItemType` | `@fastgpt/global/support/wallet/usage/type` | 用量列表项类型定义 | +| `UsageFilterParams` | `./type`(同目录) | 筛选参数类型定义 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..152b460e1f6e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\224\250\351\207\217\347\273\237\350\256\241/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,64 @@ +--- +capability_label: 用量统计 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T11:19:00.000Z" +parent_module: 账户 +roles: ["普通成员", "管理员"] +router_paths: ["/account/usage"] +--- + +# 用量统计 — 组件列表 + +## pages/(页面组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/usage/index.tsx` | 用量统计主页面:管理筛选状态、Tab 切换、路由参数 | 内部 | 否 | 无外部 Props(页面级组件,通过 router.query 接收参数) | + +## pageComponents/usage/(业务组件) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/usage/UsageTable.tsx` | 用量明细列表:分页表格展示调用记录,支持查看详情和导出 | 内部 | 否 | `filterParams: UsageFilterParams`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `projects/app/src/pageComponents/account/usage/Dashboard.tsx` | 用量看板:展示积分和 Token 消耗趋势图表 | 内部 | 否 | `filterParams: UsageFilterParams`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `projects/app/src/pageComponents/account/usage/DashboardChart.tsx` | 看板图表:Recharts 折线图(积分趋势 + Token 趋势) | 内部 | 否 | `totalPoints: usageFormType[]`, `totalUsage: number`, `totalInputTokens: number`, `totalOutputTokens: number` | +| `projects/app/src/pageComponents/account/usage/UsageDetail.tsx` | 用量详情弹窗:展示单次调用的计费模块明细 | 内部 | 否 | `usage: UsageListItemType`, `onClose: () => void` | +| `projects/app/src/pageComponents/account/usage/OrgTreeSelector.tsx` | 部门树选择器:基于 rc-tree 的多选部门筛选组件 | 内部 | 否 | `value: string[]`, `onSelect: (val: string[]) => void`, `isSelectAll?: boolean` | + +## type/(类型定义) + +| 文件路径 | 用途 | +|---------|------| +| `projects/app/src/pageComponents/account/usage/type.ts` | 定义 `UsageFilterParams`(筛选参数类型)和 `UnitType`(统计单位类型) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户模块 | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | 公共组件库 | +| `DateRangePicker` | `@fastgpt/web/components/common/DateRangePicker` | 公共组件库 | +| `MultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | 公共组件库 | +| `MySelect` | `@fastgpt/web/components/common/MySelect/index` | 公共组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 公共组件库 | +| `RechargeModal` | `@/components/support/wallet/NotSufficientModal` | 钱包/充值模块 | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | 公共组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 公共组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库(UsageDetail 使用) | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 公共组件库(UsageDetail 使用) | +| `Tree` | `rc-tree` | 第三方组件库(OrgTreeSelector 使用) | + +## 消费的外部 Hooks + +| Hook | 导入路径 | 用途 | +|------|---------|------| +| `useUserStore` | `@/web/support/user/useUserStore` | 获取当前用户信息和团队权限 | +| `useScrollPagination` | `@fastgpt/web/hooks/useScrollPagination` | 成员列表滚动分页加载 | +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 用量列表分页 | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 群组列表、看板数据等异步请求管理 | +| `useMultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | 多选状态管理(成员、群组、来源) | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | 获取 PC/移动端标识 | +| `useSafeTranslation` | `@fastgpt/web/hooks/useSafeTranslation` | 安全翻译(UsageTable 和 UsageDetail 使用) | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 获取系统模型列表(UsageDetail 使用,用于模型名称映射) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..552ae789a3c8 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/API\347\264\242\345\274\225.md" @@ -0,0 +1,59 @@ +--- +capability_label: "第三方集成" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 API 索引 + +## 查询/检测 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/team/thirtdParty/checkUsage` | GET | 查询第三方工作流变量提供商的使用量 | `projects/app/src/pages/api/support/user/team/thirtdParty/checkUsage.ts:20` → `projects/app/src/pages/account/thirdParty/index.tsx:88` | 账户→第三方集成→页面加载时调用(对每个工作流变量提供商并行请求);外部变量配置变化后刷新时调用 | + +## 配置更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/team/update` | PUT | 更新团队第三方配置 | `projects/app/src/web/support/user/team/api.ts:36` → `projects/app/src/components/support/laf/LafAccountModal.tsx:88` | 账户→第三方集成→配置 Laf 账号→提交保存时调用 | +| | | | `projects/app/src/web/support/user/team/api.ts:36` → `projects/app/src/pageComponents/account/thirdParty/OpenAIAccountModal.tsx:27` | 账户→第三方集成→配置 OpenAI 账号→提交保存时调用 | +| | | | `projects/app/src/web/support/user/team/api.ts:36` → `projects/app/src/pageComponents/account/thirdParty/WorkflowVariableModal.tsx:32` | 账户→第三方集成→配置工作流变量→提交保存时调用 | + +## API 调用链追踪 + +### `GET /support/user/team/thirtdParty/checkUsage` 调用链 + +``` +ThirdParty (页面组件) + ├── 触发: 页面加载及 refreshDeps 变化时自动请求 + ├── 参数: { key: string } — 外部工作流变量提供商的唯一标识 + ├── 串行/并行: 对每个 workfow variable provider 并行请求(Promise.all) + └── 响应处理: 成功返回 { total, used } → 合并到卡片数据用于渲染进度条;失败或返回 undefined → usage 字段为空,进度条不渲染 +``` + +### `PUT /support/user/team/update` 调用链 + +``` +LafAccountModal + ├── 触发: 管理员选择 Laf 应用后点击"更新" + ├── 参数: { lafAccount: { token, appid, pat } } + ├── 串行/并行: 独立请求,无前后依赖 + └── 响应处理: 成功 → initUserInfo() 刷新全局用户信息 → 关闭弹窗;失败 → toast "更新失败" + +OpenAIAccountModal + ├── 触发: 管理员填写 API Key/BaseUrl 后点击"确认" + ├── 参数: { openaiAccount: { key, baseUrl } } + ├── 串行/并行: 独立请求,无前后依赖 + └── 响应处理: 成功 → initUserInfo() 刷新全局用户信息 → 关闭弹窗;失败 → toast "更新失败" + +WorkflowVariableModal + ├── 触发: 管理员填写参数值后点击"确认" + ├── 参数: { externalWorkflowVariable: { key, value } } + ├── 串行/并行: 独立请求,无前后依赖 + └── 响应处理: 成功 → initUserInfo() 刷新全局用户信息 → 关闭弹窗;失败 → toast "更新失败" +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..40b7674ee753 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,19 @@ +--- +capability_label: "第三方集成" +doc_type: "16" +doc_label: "Hooks工具函数" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 Hooks 工具函数 + +## 说明 + +本模块未检测到自定义 Hooks/Composables/Utils/Constants 定义。 + +- 模块使用的异步请求和表单管理能力来自共享基础设施的 `useRequest` 和 `react-hook-form` 的 `useForm` +- 组件信息参见 [13-组件列表](./组件列表.md) +- Store 使用情况参见 [14-Store数据流](./Store数据流.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..4cf95fd93804 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,21 @@ +--- +capability_label: "第三方集成" +doc_type: "14" +doc_label: "Store数据流" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 Store 数据流 + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用共享基础设施的 Store:`useSystemStore`(读取系统配置 `feConfigs`)和 `useUserStore`(读取/刷新团队第三方配置 `userInfo.team`) +- 通过 `react-hook-form` 的 `useForm` 管理表单局部状态 +- 通过 `useRequest` 管理异步请求状态和自动刷新 +- 配置成功后通过 `initUserInfo()` 刷新全局 Store,驱动页面级数据(usage、卡片状态)的更新 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..d5e3d3969a5e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,44 @@ +--- +capability_label: "第三方集成" +doc_type: "15" +doc_label: "共享组件清单" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 共享组件清单 + +## LafAccountModal + +**文件**: `projects/app/src/components/support/laf/LafAccountModal.tsx` +**用途**: Laf 云函数平台账号配置弹窗,提供 PAT 验证、应用列表拉取和应用选择保存功能 +**被引用方**: 工作台 → NodeLaf(工作流 Laf 调用节点) + +### Props + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `defaultData` | `LafAccountType` | 否 | `{ token: '', appid: '', pat: '' }` | 已有的 Laf 账号配置,用于回填表单 | +| `onClose` | `() => void` | 是 | — | 关闭弹窗的回调函数 | + +### 使用示例 + +```tsx +import LafAccountModal from '@/components/support/laf/LafAccountModal'; + + +``` + +## 消费的共享组件 + +| 本模块使用 | 引用自 | 源模块文档 | 用途 | +|-----------|--------|-----------|------| +| `MyModal` | @fastgpt/web → MyModal | — | 统一模态弹窗容器 | +| `MyIcon` | @fastgpt/web → MyIcon | — | 图标组件 | +| `MyBox` | @fastgpt/web → MyBox | — | 带加载状态的容器组件 | +| `MySelect` | @fastgpt/web → MySelect | — | 下拉选择器 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..7770fa8408a0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\254\254\344\270\211\346\226\271\351\233\206\346\210\220/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,40 @@ +--- +capability_label: "第三方集成" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: "账户" +roles: ["owner", "member"] +router_paths: ["/account/thirdParty"] +--- + +# 账户 — 第三方集成 组件列表 + +## pages/account/thirdParty/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/thirdParty/index.tsx` | 第三方集成管理页面,以卡片网格展示所有已启用的第三方服务 | 引用自 共享基础设施 → AccountContainer, useSystemStore, useUserStore | 否 | 无外部 Props(页面级组件,通过路由渲染) | + +## pageComponents/account/thirdParty/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/thirdParty/WorkflowVariableModal.tsx` | 工作流变量提供商参数值配置弹窗 | 引用自 共享基础设施 → useUserStore, putUpdateTeam;引用自 共享基础设施 → MyModal | 否 | `defaultData: ThirdPartyAccountType`, `onClose: () => void` | +| `projects/app/src/pageComponents/account/thirdParty/OpenAIAccountModal.tsx` | OpenAI/OneAPI 账号配置弹窗(API Key + BaseUrl) | 引用自 共享基础设施 → useUserStore, putUpdateTeam;引用自 共享基础设施 → MyModal | 否 | `defaultData?: OpenaiAccountType`, `onClose: () => void` | + +## components/support/laf/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/components/support/laf/LafAccountModal.tsx` | Laf 云函数平台账号配置弹窗(PAT 验证 + 应用选择) | 引用自 共享基础设施 → useSystemStore, useUserStore, putUpdateTeam;引用自 共享基础设施 → MyModal, MySelect;被 工作台→NodeLaf 引用 | 是 → [15](../共享组件清单.md) | `defaultData?: LafAccountType`, `onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(共享基础设施) | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 共享基础设施(web 包) | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享基础设施(web 包) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享基础设施(web 包) | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | 共享基础设施(web 包) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..431708cfe10e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,79 @@ +--- +capability_label: 账户 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T10:49:09Z" +parent_module: null +roles: [owner, admin, member] +router_paths: ["/account/usage", "/account/info", "/account/team", "/account/bill", "/account/thirdParty", "/account/customDomain", "/account/model", "/account/promotion", "/account/apikey", "/account/inform", "/account/setting"] +--- + +# 账户 — 组件列表 + +## 布局组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/AccountContainer.tsx` | 账户模块统一布局容器,含侧边栏/顶部选项卡导航 | 内部 | 是 → 被所有 11 个子页面引用 | `children: ReactNode`, `isLoading: boolean` | + +## 页面组件 (pages/account/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/account/usage/index.tsx` | 用量统计页面,含仪表盘/明细视图切换和筛选器 | 内部 | 否 | —(路由页面) | +| `pages/account/info/index.tsx` | 账号信息页面,含个人资料、订阅套餐、优惠券 | 内部 | 否 | —(路由页面) | +| `pages/account/team/index.tsx` | 团队管理页面,成员列表和邀请管理 | 内部 | 否 | —(路由页面) | +| `pages/account/bill/index.tsx` | 账单页面,订单列表和发票管理 | 内部 | 否 | —(路由页面) | +| `pages/account/thirdParty/index.tsx` | 第三方集成页面,卡片式展示各集成服务 | 内部 | 否 | —(路由页面) | +| `pages/account/customDomain/index.tsx` | 自定义域名管理页面 | 内部 | 否 | —(路由页面) | +| `pages/account/model/index.tsx` | 模型管理页面,模型供应商和列表 | 内部 | 否 | —(路由页面) | +| `pages/account/promotion.tsx` | 推广页面,统计卡片和邀请记录 | 内部 | 否 | —(路由页面) | +| `pages/account/apikey.tsx` | API 密钥管理页面 | 内部 | 否 | —(路由页面) | +| `pages/account/inform.tsx` | 通知偏好设置页面 | 内部 | 否 | —(路由页面) | +| `pages/account/setting.tsx` | 个性化设置页面,语言切换 | 内部 | 否 | —(路由页面) | + +## 业务组件 (pageComponents/account/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/TeamSelector.tsx` | 团队选择器,多团队切换 | 内部 | 否 | `value: string`, `onChange: (id) => void` | +| `pageComponents/account/usage/Dashboard.tsx` | 用量仪表盘,图表化展示消耗趋势 | 内部 | 否 | `filterParams: object`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `pageComponents/account/usage/UsageTableList.tsx` | 用量明细表格,分页列表展示 | 内部 | 否 | `filterParams: object`, `Tabs: ReactNode`, `Selectors: ReactNode` | +| `pageComponents/account/bill/BillTable.tsx` | 账单列表表格 | 内部 | 否 | — | +| `pageComponents/account/bill/BillDetailModal.tsx` | 账单详情弹窗 | 内部 | 否 | `billId: string`, `onClose: () => void` | +| `pageComponents/account/info/RedeemCouponModal.tsx` | 兑换码弹窗 | 内部 | 否 | —(动态导入) | +| `pageComponents/account/info/DiscountCouponsModal.tsx` | 优惠券弹窗 | 内部 | 否 | —(动态导入) | +| `pageComponents/account/info/standardDetailModal.tsx` | 订阅套餐详情弹窗 | 内部 | 否 | —(动态导入) | +| `pageComponents/account/info/ConversionModal.tsx` | 积分转换弹窗 | 内部 | 否 | —(动态导入) | +| `pageComponents/account/info/UpdatePswModal.tsx` | 修改密码弹窗 | 内部 | 否 | —(动态导入) | +| `pageComponents/account/thirdParty/OpenAIAccountModal.tsx` | OpenAI 账户配置弹窗 | 内部 | 否 | `defaultData: OpenaiAccountType`, `onClose: () => void` | +| `pageComponents/account/thirdParty/WorkflowVariableModal.tsx` | 工作流变量配置弹窗 | 内部 | 否 | `defaultData: ThirdPartyAccountType`, `onClose: () => void` | +| `pageComponents/account/customDomain/CreateCustomDomainModal.tsx` | 创建自定义域名弹窗 | 内部 | 否 | — | +| `pageComponents/account/model/ModelProvider.tsx` | 模型供应商配置面板 | 内部 | 否 | — | + +## 共享组件 (components/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `components/support/apikey/Table.tsx` | API 密钥表格(创建/编辑/删除/复制) | 引用自 @fastgpt/web | 是 → 被应用发布页面引用 | `tips: string`, `appId?: string` | +| `components/support/laf/LafAccountModal.tsx` | Laf 云开发账户配置弹窗 | 引用自 @fastgpt/web | 是 → 被工作流 Laf 节点引用 | `defaultData: LafAccountType`, `onClose: () => void` | +| `components/support/wallet/StandardPlanContentList.tsx` | 标准订阅套餐内容列表 | 引用自 @fastgpt/web | 是 | — | +| `components/support/user/inform/UpdateContactModal.tsx` | 更新联系方式弹窗 | 内部 | 否 | — | +| `components/core/ai/ModelTable/` | 模型表格(含 ModelListTable、ModelPriceModal 等) | 引用自 @fastgpt/web | 是 → 被应用配置等多处引用 | `tabType: ModelTabType`, `permissionConfig: boolean` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 共享组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 共享组件库 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 共享组件库 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 共享组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | web 共享组件库 | +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs` | web 共享组件库 | +| `DashboardNavbar` | `@/pageComponents/dashboard/DashboardNavbar` | 工作台模块 | +| `PageContainer` | `@/components/PageContainer` | 共享页面容器 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 共享组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | web 共享组件库 | + +> 各子能力的完整组件列表见对应子目录的 `组件列表.md`。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..3a8a6abd70fd --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/API\347\264\242\345\274\225.md" @@ -0,0 +1,125 @@ +--- +capability_label: 自定义域名 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 团队所有者 +router_paths: + - /account/customDomain +--- +# 自定义域名 — API索引 + +> 本文档建立 API 到业务场景的反向索引。所有 API 通过 `/proApi/support/customDomain/*` 代理到 FastGPT Pro 商业版后端。 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/list` | GET | 获取团队所有自定义域名列表 | `web/support/customDomain/api.ts:7` → `pages/account/customDomain/index.tsx:48` | 自定义域名→查看列表→页面加载时调用;自定义域名→添加→创建成功后刷新;自定义域名→编辑→激活成功后刷新;自定义域名→删除→删除成功后刷新 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/create` | POST | 创建新的自定义域名绑定 | `web/support/customDomain/api.ts:20` → `pageComponents/account/customDomain/createModal.tsx:137` | 自定义域名→添加域名→DNS解析成功后确认创建时调用 | + +**请求参数**:`domain`(自定义域名)、`provider`(DNS 提供商枚举)、`cnameDomain`(CNAME 目标域名) + +## 激活 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/active` | POST | 激活已有的自定义域名(refresh 模式) | `web/support/customDomain/api.ts:23` → `pageComponents/account/customDomain/createModal.tsx:131` | 自定义域名→编辑/激活→DNS解析成功后确认激活时调用 | + +**请求参数**:`domain`(要激活的域名) + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/delete` | DELETE | 删除自定义域名绑定 | `web/support/customDomain/api.ts:15` → `pages/account/customDomain/index.tsx:63` | 自定义域名→删除域名→确认弹窗中确认删除时调用 | + +**请求参数**:`domain`(要删除的域名) + +## 配置/检测 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/checkDNSResolve` | POST | 检测 DNS CNAME 记录是否已解析 | `web/support/customDomain/api.ts:9` → `pageComponents/account/customDomain/createModal.tsx:120` | 自定义域名→添加域名→保存后每5秒自动轮询调用;自定义域名→编辑/激活→手动刷新时调用 | + +**请求参数**:`domain`(自定义域名)、`cnameDomain`(CNAME 目标域名) + +## 文件验证(未启用) + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/customDomain/updateVerifyFile` | POST | 更新域名验证文件 | `web/support/customDomain/api.ts:30` → `pageComponents/account/customDomain/domainVerifyModal.tsx:21` | 当前未启用(domainVerifyModal 组件在主页面中被注释掉,未渲染) | + +**请求参数**:`domain`(域名)、`path`(验证文件路径)、`content`(验证文件内容) + +--- + +## API 调用链追踪 + +### `/proApi/support/customDomain/list` 调用链 + +``` +pages/account/customDomain/index.tsx (CustomDomain 组件) + ├── 触发: 组件挂载时自动调用(manual: false) + ├── 参数: 无 + ├── 响应处理: 设置 customDomainList 状态 → 渲染表格 + └── 刷新触发: 创建/删除/激活成功后手动调用 refreshAsync() + +pageComponents/app/detail/Publish/Wecom/index.tsx (Wecom 组件) + ├── 触发: 发布到企业微信时获取可选域名列表 + └── 响应处理: 用于企业微信发布的自定义域名选择 + +pageComponents/app/detail/Publish/components/showShareLinkModal.tsx (ShareLinkContainer) + ├── 触发: 显示分享链接时获取可选自定义域名 + └── 响应处理: 用于生成带自定义域名的分享链接 +``` + +### `/proApi/support/customDomain/checkDNSResolve` 调用链 + +``` +pageComponents/account/customDomain/createModal.tsx + ├── 触发: add 模式下用户保存域名后开始自动轮询(每5秒) + ├── 参数: { domain: 用户输入的域名, cnameDomain: 系统生成的CNAME } + ├── 响应处理: success=true → 显示绿色"已解析"标签,启用确认按钮 + └── 触发: refresh 模式下用户点击"刷新"按钮手动触发 + +``` + +### `/proApi/support/customDomain/create` 调用链 + +``` +pageComponents/account/customDomain/createModal.tsx + ├── 触发: 用户点击"确认"按钮(add 模式) + ├── 参数: { domain, provider, cnameDomain } + ├── 前置条件: DNS 已解析成功(DnsResolved=true) + └── 响应处理: 成功后关闭弹窗 + +``` + +### `/proApi/support/customDomain/active` 调用链 + +``` +pageComponents/account/customDomain/createModal.tsx + ├── 触发: 用户点击"确认"按钮(refresh 模式) + ├── 参数: { domain } + ├── 前置条件: DNS 已解析成功(DnsResolved=true) + └── 响应处理: 成功后关闭弹窗 + +``` + +### `/proApi/support/customDomain/delete` 调用链 + +``` +pages/account/customDomain/index.tsx (CustomDomain 组件) + ├── 触发: 用户在确认删除弹窗中点击确认 + ├── 参数: domain 字符串 + └── 响应处理: 成功后自动刷新域名列表 + +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..c5a7b12a4a73 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\207\252\345\256\232\344\271\211\345\237\237\345\220\215/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,62 @@ +--- +capability_label: 自定义域名 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 团队所有者 +router_paths: + - /account/customDomain +--- +# 自定义域名 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/customDomain/index.tsx` | 自定义域名列表管理页面,展示域名表格并提供添加/编辑/删除操作 | 引用自 账户 → AccountContainer(布局容器);引用自 系统配置 → useSystemStore;引用自 用户状态 → useUserStore | 否 | 无外部 Props(页面级组件) | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/customDomain/createModal.tsx` | 创建/激活自定义域名的弹窗,包含提供商选择、域名输入、DNS 解析检测和确认操作 | 内部 | 否 | `onClose: () => void, type: 'create' \| 'refresh', data?: { domain, provider, cnameDomain }` | +| `projects/app/src/pageComponents/account/customDomain/domainVerifyModal.tsx` | 域名验证文件上传弹窗(**当前未启用**,在主页面中被注释掉) | 内部 | 否 | `onClose: () => void, domain: string` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(共享布局容器) | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 公共组件库 | +| `MyLoading` | `@fastgpt/web/components/common/MyLoading` | 公共组件库 | +| `Tag` | `@fastgpt/web/components/common/Tag` | 公共组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 公共组件库 | +| `MyIcon` / `Icon` | `@fastgpt/web/components/common/Icon` | 公共组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 公共组件库 | + +## 消费的外部 Hooks + +| 本模块使用 Hook | 导入路径 | 源模块 | +|---------------|---------|--------| +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 公共 Hooks 库 | +| `useConfirm` | `@fastgpt/web/hooks/useConfirm` | 公共 Hooks 库 | +| `useCopyData` | `@fastgpt/web/hooks/useCopyData` | 公共 Hooks 库 | +| `useUserStore` | `@/web/support/user/useUserStore` | 用户状态管理 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置状态管理 | + +## 消费的外部 API 模块 + +| 本模块使用 API | 导入路径 | 源模块 | +|--------------|---------|--------| +| `listCustomDomain` | `@/web/support/customDomain/api` | 自定义域名 API | +| `deleteCustomDomain` | `@/web/support/customDomain/api` | 自定义域名 API | +| `createCustomDomain` | `@/web/support/customDomain/api` | 自定义域名 API | +| `activeCustomDomain` | `@/web/support/customDomain/api` | 自定义域名 API | +| `checkCustomDomainDNSResolve` | `@/web/support/customDomain/api` | 自定义域名 API | +| `updateCustomDomainVerifyFile` | `@/web/support/customDomain/api` | 自定义域名 API(未使用) | + +## 说明 + +本模块共 2 个活跃组件(1 个页面组件 + 1 个业务弹窗组件),另有 1 个域名验证弹窗组件当前被注释禁用。模块不对外提供共享组件,所有组件均为模块内部使用。页面通过 AccountContainer 布局容器嵌入账户管理框架。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..522ab1028515 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,61 @@ +--- +capability_label: 账单 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — API 索引 + +## 账单查询与操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/list` | POST | 获取账单列表(分页) | `web/support/wallet/bill/api.ts:16` → `pageComponents/account/bill/BillTable.tsx:69` | 账户→账单→账单记录Tab→加载/翻页/切换账单类型筛选时调用 | +| `/proApi/support/wallet/bill/detail` | GET | 获取单条账单详情 | `web/support/wallet/bill/api.ts:42` → `pageComponents/account/bill/BillDetailModal.tsx:26` | 账户→账单→账单记录Tab→点击「详情」按钮→打开详情弹窗时调用 | +| `/proApi/support/wallet/bill/pay/checkPayResult` | GET | 检查支付结果 | `web/support/wallet/bill/api.ts:22` → `pageComponents/account/bill/BillTable.tsx:79` | 账户→账单→账单记录Tab→未支付账单→点击「更新」按钮时调用 | +| `/proApi/support/wallet/bill/pay/updatePayment` | PUT | 更新支付方式(获取新支付参数) | `web/support/wallet/bill/api.ts:34` → `pageComponents/account/bill/BillTable.tsx:88` | 账户→账单→账单记录Tab→支付状态仍为未支付→获取新支付二维码时调用 | +| `/proApi/support/wallet/bill/cancel` | POST | 取消未支付账单 | `web/support/wallet/bill/api.ts:39` → `pageComponents/account/bill/BillTable.tsx:124` | 账户→账单→账单记录Tab→未支付账单→确认取消时调用 | +| `/proApi/support/wallet/bill/create` | POST | 创建支付账单 | `web/support/wallet/bill/api.ts:19` | (当前页面未直接使用,由其他模块调用) | +| `/proApi/support/wallet/bill/balanceConversion` | GET | 余额换算 | `web/support/wallet/bill/api.ts:36` | (当前页面未直接使用,由其他模块调用) | + +## 发票管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/invoice/unInvoiceList` | GET | 获取可开票账单列表 | `web/support/wallet/bill/invoice/api.ts:16` → `pageComponents/account/bill/ApplyInvoiceModal.tsx:60` | 账户→账单→点击「开发票」→弹窗打开→加载可开票账单列表时调用 | +| `/proApi/support/wallet/bill/invoice/submit` | POST | 提交发票申请 | `web/support/wallet/bill/invoice/api.ts:19` → `pageComponents/account/bill/ApplyInvoiceModal.tsx:75` | 账户→账单→开发票弹窗→填写抬头→点击确认提交时调用 | +| `/proApi/support/wallet/bill/invoice/records` | POST | 获取发票记录列表(分页) | `web/support/wallet/bill/invoice/api.ts:22` → `pageComponents/account/bill/InvoiceTable.tsx:37` | 账户→账单→发票Tab→加载/翻页时调用 | +| `/api/proApi/support/wallet/bill/invoice/downloadFile` | GET | 下载发票 PDF 文件 | `pageComponents/account/bill/InvoiceTable.tsx:143`(内联请求) | 账户→账单→发票Tab→点击详情→点击下载链接→下载发票 PDF 时调用 | + +## 团队发票抬头 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invoiceHeader/get`(推断路径) | GET | 获取团队发票抬头 | `web/support/user/team/api.ts:134` → `pageComponents/account/bill/InvoiceHeaderForm.tsx:212`、`pageComponents/account/bill/ApplyInvoiceModal.tsx:107` | 账户→账单→发票抬头Tab→加载时调用;账户→账单→开发票弹窗第二步→加载已有抬头时调用 | +| `/proApi/support/user/team/invoiceHeader/update`(推断路径) | — | 更新团队发票抬头 | `web/support/user/team/api.ts:137` → `pageComponents/account/bill/InvoiceHeaderForm.tsx:222` | 账户→账单→发票抬头Tab→填写/修改抬头→点击保存时调用 | + +## API 分组 + +### 账单查询/操作(账单记录 Tab) + +- `POST /proApi/support/wallet/bill/list` — 分页获取账单列表 +- `GET /proApi/support/wallet/bill/detail` — 获取账单详情 +- `GET /proApi/support/wallet/bill/pay/checkPayResult` — 检查支付结果 +- `PUT /proApi/support/wallet/bill/pay/updatePayment` — 重新获取支付参数 +- `POST /proApi/support/wallet/bill/cancel` — 取消账单 + +### 发票管理(发票 Tab + 开发票弹窗) + +- `GET /proApi/support/wallet/bill/invoice/unInvoiceList` — 可开票账单列表 +- `POST /proApi/support/wallet/bill/invoice/submit` — 提交发票申请 +- `POST /proApi/support/wallet/bill/invoice/records` — 发票记录列表 +- `GET /api/proApi/support/wallet/bill/invoice/downloadFile` — 下载发票文件 + +### 发票抬头(发票抬头 Tab) + +- `GET .../invoiceHeader/get` — 获取团队发票抬头 +- `POST/PUT .../invoiceHeader/update` — 更新团队发票抬头 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..5b8c7b5b719e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,16 @@ +--- +capability_label: 账单 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +## 说明 + +本模块未检测到 Hooks/Composables/Utils/Constants 目录或目录中无导出函数。 + +- 如果需要了解组件的状态管理逻辑,参见 [14-Store数据流](./Store数据流.md) +- 组件信息参见 [13-组件列表](./组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..734ff009189e --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,18 @@ +--- +capability_label: 账单 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +## 说明 + +本模块无独立 Store(未检测到状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- 使用全局 Store:`useUserStore`(用户信息/权限)、`useSystemStore`(系统配置 `feConfigs`)——定义在外部模块 +- 各组件使用 React `useState` 管理局部状态(如 Tab 切换状态、弹窗开关、选中项等) +- 通过 URL query 参数(`invoiceTab`)在 Tab 间共享当前活跃 Tab 状态 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..4b9c09a8f6a0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,71 @@ +--- +capability_label: 账单 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — 共享组件清单 + +## 共享组件目录 + +| 组件名 | 文件路径 | 被引用方 | 用途 | +|--------|---------|---------|------| +| BillDetailModal | `projects/app/src/pageComponents/account/bill/BillDetailModal.tsx` | account/info(DiscountCouponsModal) | 展示账单完整详情信息 | + +## BillDetailModal + +**文件**: `pageComponents/account/bill/BillDetailModal.tsx` +**用途**: 展示单条账单的完整详情信息,包括订单号、生成时间、订单类型、状态、优惠券、支付方式、金额、是否开票、订阅周期、订阅套餐、额外配置等信息。 +**被引用方**: account/info(DiscountCouponsModal) + +### Props + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `billId` | `string` | 是 | — | 账单 ID,用于获取账单详情 | +| `onClose` | `() => void` | 是 | — | 关闭弹窗的回调函数 | + +### 展示字段 + +弹窗根据账单数据动态展示以下字段(全部只读): + +| 字段 | 数据来源 | 显示条件 | +|------|---------|---------| +| 订单号 | `bill.orderId` | 始终显示 | +| 生成时间 | `bill.createTime`(格式化 YYYY/MM/DD HH:mm:ss) | 始终显示 | +| 订单类型 | `billTypeMap[bill.type].label`(i18n 翻译) | `bill.type` 存在 | +| 状态 | `billStatusMap[bill.status].label` | `bill.status` 存在 | +| 优惠券 | `bill.discountCouponName` | `bill.discountCouponName` 存在 | +| 支付方式 | `billPayWayMap[bill.metadata.payWay].label` | `bill.metadata.payWay` 存在 | +| 金额 | `formatStorePrice2Read(bill.price)` 元 | `bill.price` 存在 | +| 是否开票 | 余额支付显示"无需开票",其他显示是/否 | `bill.price` 存在 | +| 订阅周期 | `subModeMap[bill.metadata.subMode].label` | `bill.metadata.subMode` 存在 | +| 订阅套餐 | `standardSubLevelMap[bill.metadata.standSubLevel].label` | `bill.metadata.standSubLevel` 存在 | +| 订阅月数 | `bill.metadata.month` 月 | `bill.metadata.month` 存在 | +| 额外数据集大小 | `bill.metadata.datasetSize` | `bill.metadata.datasetSize` 存在 | +| 额外 AI 点数 | `bill.metadata.extraPoints` | `bill.metadata.extraPoints` 存在 | +| 自定义配置详情 | 动态解析 `bill.couponDetail.subscriptions` 中 custom 订阅的 customConfig | 订阅级别为 custom 且有 customConfig | + +### 使用示例 + +```tsx +import BillDetailModal from '@/pageComponents/account/bill/BillDetailModal'; + +// 在父组件中 +const [billDetailId, setBillDetailId] = useState(); + +// 触发打开 + + +// 渲染弹窗 +{billDetailId && ( + setBillDetailId(undefined)} + /> +)} +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..1e2f6887fac7 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/API\347\264\242\345\274\225.md" @@ -0,0 +1,54 @@ +--- +capability_label: "发票" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/invoice/records` | POST | 获取发票记录列表(分页) | `web/support/wallet/bill/invoice/api.ts:21` → `pageComponents/account/bill/InvoiceTable.tsx:31` | 账户→账单→发票记录Tab→加载时调用;发票记录→翻页时调用 | + +## 文件下载 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/wallet/bill/invoice/downloadFile` | GET | 下载已完成的电子发票 PDF 文件 | `pageComponents/account/bill/InvoiceTable.tsx:143`(downloadFetch 内联调用) | 账户→账单→发票记录→详情弹窗→下载发票文件时调用 | + +--- + +## API 调用链追踪 + +### `/proApi/support/wallet/bill/invoice/records` 调用链 + +``` +InvoiceTable (pageComponents/account/bill/InvoiceTable.tsx) + ├── 触发: 用户进入发票记录Tab 或 翻页操作 + ├── 参数: { pageNum: number, pageSize: number }(PaginationProps) + ├── 响应处理: + │ ├── usePagination 自动管理 data(InvoiceSchemaType[])、total、pageSize + │ ├── 列表渲染:invoices.map() 生成表格行 + │ └── 空数据处理:invoices.length === 0 时展示空状态提示 + └── 错误处理: usePagination 内部错误处理,无自定义错误逻辑 +``` + +### `/proApi/support/wallet/bill/invoice/downloadFile` 调用链 + +``` +InvoiceDetailModal (pageComponents/account/bill/InvoiceTable.tsx:133) + ├── 触发: 用户点击"点击下载"链接 + ├── 条件: invoice.status === 2(已完成状态,有发票文件) + ├── 参数: { id: string }(发票 _id,作为 URL query 参数) + ├── 调用方式: downloadFetch 工具函数 + │ ├── url: /api/proApi/support/wallet/bill/invoice/downloadFile?id={id} + │ ├── filename: {invoice.teamName}.pdf + │ └── 方式: GET 请求,创建临时
标签触发下载 + └── 错误处理: useRequest 包裹,下载失败时通过 response.json() 解析错误信息并 reject +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..ae529f524693 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,35 @@ +--- +capability_label: "发票" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T11:20:00Z" +parent_module: "账单" +roles: ["团队管理员", "团队成员"] +router_paths: ["/account/bill?invoiceTab=invoice"] +--- + +# 发票 — 组件列表 + +## pageComponents/account/bill/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/bill/InvoiceTable.tsx` | 发票记录列表页(含详情弹窗) | 内部 | 否 | 无 Props(独立页面组件,通过 usePagination 自行获取数据) | + +### InvoiceTable 内部组件 + +| 内部组件 | 位置 | 用途 | +|---------|------|------| +| `InvoiceDetailModal` | InvoiceTable.tsx 内部定义 | 发票详情弹窗(展示发票完整信息,支持下载) | +| `LabelItem` | InvoiceTable.tsx 内部定义 | 详情弹窗中的标签-值展示行 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 通用组件库 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | 通用组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 通用组件库 | +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 通用 Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 通用 Hooks | +| `downloadFetch` | `@/web/common/system/utils` | 系统工具 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..40b8f6b304f1 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/API\347\264\242\345\274\225.md" @@ -0,0 +1,58 @@ +--- +capability_label: "发票抬头" +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: "账单" +roles: ["admin"] +router_paths: ["/account/bill"] +--- + +# 发票抬头 — API索引 + +## 查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invoiceAccount/getTeamInvoiceHeader` | GET | 获取团队默认发票抬头信息 | `projects/app/src/web/support/user/team/api.ts:134` → `projects/app/src/pageComponents/account/bill/InvoiceHeaderForm.tsx:212`
`projects/app/src/web/support/user/team/api.ts:134` → `projects/app/src/pageComponents/account/bill/ApplyInvoiceModal.tsx:107` | 账户→账单→发票抬头Tab→页面加载时调用;账户→账单→申请发票弹窗→打开结算页时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/team/invoiceAccount/update` | POST | 更新团队发票抬头信息 | `projects/app/src/web/support/user/team/api.ts:137` → `projects/app/src/pageComponents/account/bill/InvoiceHeaderForm.tsx:223` | 账户→账单→发票抬头Tab→编辑表单后点击「保存」按钮时调用 | + +## API 调用链追踪 + +### `/proApi/support/user/team/invoiceAccount/getTeamInvoiceHeader` 调用链 + +``` +InvoiceHeaderForm 组件 + ├── 触发: 页面挂载时自动调用(manual: false) + ├── 参数: 无(后端从当前团队上下文获取 teamId) + └── 响应处理: 成功 → inputForm.reset(data) 填充表单所有字段;失败 → 由 useRequest 内置的错误 toast 处理 + +ApplyInvoiceModal 组件 + ├── 触发: 弹窗打开结算页面时自动调用(manual: false) + ├── 参数: 无 + └── 响应处理: 成功 → inputForm.reset(res) 填充发票抬头表单;失败 → 由 useRequest 内置错误 toast 处理 +``` + +### `/proApi/support/user/team/invoiceAccount/update` 调用链 + +``` +InvoiceHeaderForm 组件 + ├── 触发: 用户填写表单后点击「保存」按钮,通过 handleSubmit 触发 + ├── 参数: { + │ teamName: string, // 组织名称 + │ unifiedCreditCode: string, // 统一社会信用代码 + │ companyAddress: string, // 公司地址 + │ companyPhone: string, // 公司电话 + │ bankName: string, // 银行名称 + │ bankAccount: string, // 银行账号 + │ needSpecialInvoice: boolean,// 是否需要专票 + │ contactPhone: string, // 联系电话 + │ emailAddress: string // 邮箱地址 + │ } + └── 响应处理: 成功 → 显示「保存成功」toast;失败 → 显示「保存异常」toast,表单数据保持不丢失 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..e59607670b71 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\345\217\221\347\245\250\346\212\254\345\244\264/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,27 @@ +--- +capability_label: "发票抬头" +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T11:30:00.000Z" +parent_module: "账单" +roles: ["admin"] +router_paths: ["/account/bill"] +--- + +# 发票抬头 — 组件列表 + +## pageComponents/account/bill/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `InvoiceHeaderForm.tsx` | 发票抬头设置主组件,加载数据并提供保存入口 | 内部 | 否 | 无 Props(自包含,通过 useForm 管理内部状态) | +| `InvoiceHeaderSingleForm`(同文件导出) | 发票抬头表单字段组件,渲染所有表单输入项 | 被同级 `ApplyInvoiceModal` 引用 | 是(同模块内共享) | `inputForm: UseFormReturn`, `required: boolean` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库 | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | 共享组件库 | +| `Divider` | `@/pageComponents/app/detail/WorkflowComponents/Flow/components/Divider` | 应用工作流模块 | +| `Input`, `Button`, `Flex`, `Box`, `HStack`, `Radio`, `RadioGroup` | `@chakra-ui/react` | Chakra UI 组件库 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..7576b4d13d4f --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,45 @@ +--- +capability_label: 账单 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: [团队管理员] +router_paths: [/account/bill] +--- + +# 账单 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/account/bill/index.tsx` | 账单与发票页面入口,管理 Tab 切换和申请发票弹窗 | 引用自 @/pageComponents/account/AccountContainer;引用自 @/pageComponents/account/bill/* | 否 | —(页面组件无外部 Props) | + +## 业务组件(pageComponents/account/bill/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `BillTable.tsx` | 账单列表表格:分页展示、按类型筛选、支付/取消操作、查看详情 | 引用自 @/web/support/wallet/bill/api(5个API);引用自 @/components/support/wallet/QRCodePayModal | 否 | 无外部 Props(自包含组件) | +| `BillDetailModal.tsx` | 账单详情弹窗:展示订单号、时间、类型、金额、支付方式、订阅信息、自定义配置等 | 引用自 @/web/support/wallet/bill/api(getBillDetail) | **是** → 被 `account/info/DiscountCouponsModal` 引用 | `billId: string, onClose: () => void` | +| `InvoiceTable.tsx` | 发票记录列表:分页展示、查看详情(含内联 InvoiceDetailModal)、下载 PDF | 引用自 @/web/support/wallet/bill/invoice/api | 否 | 无外部 Props(自包含组件) | +| `InvoiceHeaderForm.tsx` | 发票抬头表单:公司名称、税号、地址、电话、银行信息、是否专票、联系人等字段 | 引用自 @/web/support/user/team/api;导出 InvoiceHeaderSingleForm 子组件 | **是** → InvoiceHeaderSingleForm 被 ApplyInvoiceModal 引用 | 无外部 Props(InvoiceHeaderForm);`inputForm: UseFormReturn, required?: boolean`(InvoiceHeaderSingleForm) | +| `ApplyInvoiceModal.tsx` | 申请发票弹窗:两步流程(选择账单→填写抬头→提交) | 引用自 @/web/support/wallet/bill/invoice/api;引用 InvoiceHeaderSingleForm | 否 | `onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(account) | +| `FillRowTabs` | `@fastgpt/web/components/common/Tabs/FillRowTabs` | @fastgpt/web | +| `QRCodePayModal` | `@/components/support/wallet/QRCodePayModal` | 钱包(wallet) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | @fastgpt/web | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | @fastgpt/web | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | @fastgpt/web | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | @fastgpt/web | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | @fastgpt/web | +| `FormLabel` | `@fastgpt/web/components/common/MyBox/FormLabel` | @fastgpt/web | +| `Divider` | `@/pageComponents/app/detail/WorkflowComponents/Flow/components/Divider` | 应用工作流(app) | +| `usePagination` | `@fastgpt/web/hooks/usePagination` | @fastgpt/web | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | @fastgpt/web | +| `useToast` | `@fastgpt/web/hooks/useToast` | @fastgpt/web | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..0a6d92d6e86d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/API\347\264\242\345\274\225.md" @@ -0,0 +1,90 @@ +--- +capability_label: 账单记录 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T11:30:00.000Z +parent_module: 账单 +roles: [] +router_paths: + - /account/bill +--- + +# 账单记录 — API索引 + +## 账单查询与列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /proApi/support/wallet/bill/list | POST | 获取账单列表(分页+筛选) | api.ts:15 → BillTable.tsx:69 | 账户→账单→账单记录Tab→加载时调用;账户→账单→账单记录Tab→切换类型筛选时调用;账户→账单→账单记录Tab→翻页时调用;账户→账单→账单记录→支付成功/取消后刷新时调用 | +| /proApi/support/wallet/bill/detail | GET | 获取单笔账单详情 | api.ts:41 → BillDetailModal.tsx:26 | 账户→账单→账单记录→点击「详情」按钮→弹窗打开时调用 | + +## 支付操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /proApi/support/wallet/bill/pay/checkPayResult | GET | 检查支付结果 | api.ts:21 → BillTable.tsx:80 | 账户→账单→账单记录→点击未支付账单的「更新」按钮时调用 | +| /proApi/support/wallet/bill/pay/updatePayment | PUT | 更新支付信息(获取新支付凭据) | api.ts:33 → BillTable.tsx:88 | 账户→账单→账单记录→支付检查返回未支付时调用(获取二维码/支付URL) | + +## 账单管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| /proApi/support/wallet/bill/cancel | POST | 取消账单 | api.ts:38 → BillTable.tsx:124 | 账户→账单→账单记录→点击未支付账单的「取消」按钮并确认后调用 | + +--- + +## API 调用链追踪 + +### `/proApi/support/wallet/bill/list` 调用链 + +``` +BillTable (projects/app/src/pageComponents/account/bill/BillTable.tsx) + ├── 触发: 组件挂载时(usePagination 自动触发首次请求) + ├── 参数: { pageNum, pageSize: 20, type?: BillTypeEnum } + ├── 响应处理: 数据存入 bills 状态;total/pageSize 控制分页器显示 + └── 刷新依赖: billType 变更时自动重新请求第 1 页 + +BillTable — 操作后刷新 (getData(1)) + ├── 触发: 支付成功后 / 取消账单成功后 / 支付弹窗关闭后 + └── 参数: { pageNum: 1, pageSize: 20, type: 当前筛选 } +``` + +### `/proApi/support/wallet/bill/detail` 调用链 + +``` +BillDetailModal (projects/app/src/pageComponents/account/bill/BillDetailModal.tsx) + ├── 触发: BillTable 中点击某条账单的「详情」按钮 → 设置 billDetailId → 渲染 BillDetailModal + ├── 参数: { billId: 账单 ObjectId } + ├── 响应处理: 数据存入 bill 状态;根据 metadata 字段条件渲染详情信息行 + └── 依赖刷新: billId 变更时重新请求 +``` + +### `/proApi/support/wallet/bill/pay/checkPayResult` 调用链 + +``` +BillTable → handleRefreshPayOrder (projects/app/src/pageComponents/account/bill/BillTable.tsx) + ├── 触发: 用户点击未支付账单的「更新」按钮 + ├── 参数: payId (账单 _id) + ├── 响应处理 (SUCCESS): Toast 提示「支付成功」→ 内部调用 /common/system/unlockTask → getData(1) 刷新列表 + ├── 响应处理 (NOTPAY): 调用 putUpdatePayment 获取支付凭据 → 企微支付打开 URL / 其他展示 QRCodePayModal + └── 响应处理 (CLOSED/其他): Toast 提示 description 内容 +``` + +### `/proApi/support/wallet/bill/pay/updatePayment` 调用链 + +``` +BillTable → handleRefreshPayOrder (projects/app/src/pageComponents/account/bill/BillTable.tsx) + ├── 触发: checkBalancePayResult 返回 status=NOTPAY 后自动调用 + ├── 参数: { billId, payWay } + ├── 响应处理 (wecom): payUrl 存在 → Toast 提示后 window.open(payUrl) + └── 响应处理 (其他): 设置 qrPayData 状态 → 渲染 QRCodePayModal 弹窗 +``` + +### `/proApi/support/wallet/bill/cancel` 调用链 + +``` +BillTable → handleCancelBill (projects/app/src/pageComponents/account/bill/BillTable.tsx) + ├── 触发: 用户点击未支付账单的「取消」按钮 → PopoverConfirm 确认后调用 + ├── 参数: { billId } + └── 响应处理: 成功后 getData(1) 刷新列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..fc2a31515a83 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\215\225/\350\264\246\345\215\225\350\256\260\345\275\225/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,32 @@ +--- +capability_label: 账单记录 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T11:30:00.000Z +parent_module: 账单 +roles: [] +router_paths: + - /account/bill +--- + +# 账单记录 — 组件列表 + +## pageComponents/account/bill/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| BillTable.tsx | 账单记录表格,展示账单列表并支持筛选、分页、支付刷新、取消和查看详情 | 引用 QRCodePayModal(来自价格模块) | 否 | 无(无外部 Props,独立组件) | +| BillDetailModal.tsx | 账单详情弹窗,展示单笔账单的完整信息(订单编号、时间、类型、金额、支付方式、自定义配置等) | 内部 | 否 | billId: string, onClose: () => void | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| QRCodePayModal | @/components/support/wallet/QRCodePayModal | 共享基础设施/钱包 | +| MyBox | @fastgpt/web/components/common/MyBox | 共享基础设施 | +| MySelect | @fastgpt/web/components/common/MySelect | 共享基础设施 | +| MyIcon | @fastgpt/web/components/common/Icon | 共享基础设施 | +| PopoverConfirm | @fastgpt/web/components/common/MyPopover/PopoverConfirm | 共享基础设施 | +| MyModal | @fastgpt/web/components/common/MyModal | 共享基础设施 | +| FillRowTabs | @fastgpt/web/components/common/Tabs/FillRowTabs | 共享基础设施(父页面使用) | +| AccountContainer | @/pageComponents/account/AccountContainer | 账户模块(父页面使用) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..ef4d8471941c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/API\347\264\242\345\274\225.md" @@ -0,0 +1,148 @@ +--- +capability_label: 账号信息 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — API索引 + +## 用户信息查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/user/tokenLogin` | GET | 获取当前登录用户信息 | `useUserStore.ts:54` → `pages/account/info/index.tsx:77` | 账户→账号信息→页面加载时调用(`initUserInfo`) | + +## 用户信息更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/user/account/update` | PUT | 更新用户头像和时区 | `useUserStore.ts:89` → `pages/account/info/index.tsx:157` | 账户→账号信息→上传头像后自动保存时调用 | +| `/proApi/support/user/team/member/updateName` | PUT | 修改团队成员昵称 | `team/api.ts:61` → `pages/account/info/index.tsx:328` | 账户→账号信息→编辑昵称输入框失焦时调用 | +| `/api/support/user/updatePasswordByOld` | POST | 通过旧密码修改密码 | `user/api.ts:?` → `UpdatePswModal.tsx:47` | 账户→账号信息→修改密码→点击确认按钮时调用 | +| `/common/file/presignAvatarPostUrl` | POST | 获取头像上传预签名URL | `file/api.ts:8` → `pages/account/info/index.tsx:177` | 账户→账号信息→选择头像文件后上传时调用 | + +## 团队套餐查询 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/support/user/team/plan/getTeamPlanStatus` | GET | 获取团队套餐状态和用量 | `team/api.ts:123` → `useUserStore.ts:100` | 账户→账号信息→页面加载时调用(`initTeamPlanStatus`);兑换优惠券成功后刷新时调用 | + +## 钱包操作 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/support/wallet/balance/conversion` | POST | 余额兑换为AI积分 | `bill/api.ts:?` → `ConversionModal.tsx:32` | 账户→账号信息→余额兑换→点击确认兑换按钮时调用 | +| `/proApi/support/wallet/coupon/redeem` | GET | 使用兑换码兑换优惠券 | `team/api.ts:131` → `RedeemCouponModal.tsx:19` | 账户→账号信息→兑换优惠券→输入券码点击确认时调用 | + +## 客服与工单 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/common/workorder/create` | GET | 获取工单系统跳转URL | `workorder/api.ts:2` → `pages/account/info/index.tsx:749` | 账户→账号信息→问题反馈→点击按钮时调用;需先验证工单访问权限 | + +--- + +## API 调用链追踪 + +### `GET /api/support/user/tokenLogin` 调用链 + +``` +Info页面 (useMount → initUserInfo) + ├── 触发: 页面首次加载 + ├── 参数: 无(使用登录 Cookie/Session 认证) + └── 响应处理: 更新 userInfo State → 设置头像、用户名、团队信息、语言偏好;同时触发 initTeamPlanStatus + +Info页面(手动刷新) + ├── 触发: 修改成员昵称后调用 initUserInfo() 刷新 + └── 响应处理: 同上 +``` + +### `PUT /api/support/user/account/update` 调用链 + +``` +MyInfo组件 (afterUploadAvatar → onClickSave) + ├── 触发: 用户选择头像文件并上传成功后 + ├── 参数: { avatar: string, timezone: string } + ├── 前置: 先通过 POST /common/file/presignAvatarPostUrl 获取预签名URL并上传文件到S3 + ├── 成功: 乐观更新 userInfo State → Toast 提示"更新成功" + └── 失败: 回滚 userInfo 到旧值 → 静默失败 +``` + +### `PUT /proApi/support/user/team/member/updateName` 调用链 + +``` +MyInfo组件 (昵称Input onBlur) + ├── 触发: 用户编辑昵称输入框后失焦 + ├── 参数: { name: string }(最大100字符) + ├── 条件: 新值与当前值不同才发起请求;同步成员不可编辑(isSyncMember) + ├── 成功: initUserInfo() 刷新页面数据 + └── 失败: 静默捕获异常,不做提示 +``` + +### `POST /api/support/user/updatePasswordByOld` 调用链 + +``` +UpdatePswModal (表单提交) + ├── 触发: 用户填写旧密码、新密码、确认密码后点击确认 + ├── 参数: { oldPsw: string, newPsw: string } + ├── 前置校验: 新密码满足密码规则(checkPasswordRule);新密码与确认密码一致 + ├── 成功: 关闭弹窗 → Toast 提示"密码修改成功" + └── 失败: Toast 提示"密码修改失败";表单校验失败显示对应错误文案 +``` + +### `POST /common/file/presignAvatarPostUrl` 调用链 + +``` +MyInfo组件 (AvatarUploader) + ├── 触发: 用户点击头像区域 → 选择图片文件 + ├── 参数: { filename: string, autoExpired?: boolean } + ├── 响应: 返回预签名上传URL + └── 后续: 使用预签名URL上传图片文件 → 成功后调用 afterUploadAvatar → onClickSave 更新用户头像 +``` + +### `GET /support/user/team/plan/getTeamPlanStatus` 调用链 + +``` +useUserStore.initTeamPlanStatus + ├── 触发: 页面加载(与 initUserInfo 并行调用);券码兑换成功后手动刷新 + ├── 参数: 无(请求头 { maxQuantity: 1 },防止高频重复请求) + ├── 成功: 更新 teamPlanStatus State → UI 渲染套餐名称、用量进度条、到期时间 + └── 失败: 静默失败,套餐相关UI不展示 +``` + +### `POST /api/support/wallet/balance/conversion` 调用链 + +``` +ConversionModal (onConvert) + ├── 触发: 用户查看余额兑换弹窗 → 点击「兑换」按钮 + ├── 参数: 无(后端根据当前用户余额计算) + ├── 兑换公式: 余额 / 15 × SUB_EXTRA_POINT_RATE(前端展示计算) + ├── 成功: 页面刷新(router.reload)→ Toast 提示"兑换成功" + └── 失败: Toast 提示"兑换失败" +``` + +### `GET /proApi/support/wallet/coupon/redeem` 调用链 + +``` +RedeemCouponModal (redeemCouponAsync) + ├── 触发: 用户输入券码 → 点击确认 + ├── 参数: { key: couponCode } (couponCode为输入框值) + ├── 成功: 触发 onSuccess 刷新套餐状态 → 关闭弹窗 → Toast 提示"操作成功" + └── 失败: 静默失败(依赖后端错误响应) +``` + +### `GET /proApi/common/workorder/create` 调用链 + +``` +Other组件 (onFeedback) + ├── 触发: 用户点击「问题反馈」按钮 + ├── 前置检查: 验证团队套餐是否支持工单(ticketResponseTime 是否存在) + │ ├── 无工单权限 → 弹窗提示工单不可用(TeamErrEnum.ticketNotAvailable) + │ └── 有工单权限 → 继续 + ├── 参数: 无 + └── 成功: 获取 redirectUrl → window.open 跳转到工单系统 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" new file mode 100644 index 000000000000..7f91ac3adc6d --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Hooks\345\267\245\345\205\267\345\207\275\346\225\260.md" @@ -0,0 +1,31 @@ +--- +capability_label: 账号信息 +doc_type: "16" +doc_label: Hooks工具函数 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — Hooks工具函数 + +## 说明 + +本模块未检测到自有 Hooks/Composables/Utils/Constants 目录或导出函数。模块内使用的工具函数均来自外部共享模块。 + +- 状态管理逻辑参见 [Store数据流](./Store数据流.md) +- 组件信息参见 [组件列表](./组件列表.md) + +## 消费的外部 Hooks + +| Hook/工具 | 源模块 | 用途 | +|----------|--------|------| +| `useUserStore` | `@/web/support/user/useUserStore` | 用户信息和团队状态管理 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置功能开关 | +| `useToast` | `@fastgpt/web/hooks/useToast` | Toast 消息提示 | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | PC/移动端判断 | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 异步请求封装(loading/error状态) | +| `useUploadAvatar` | `@fastgpt/web/common/file/hooks/useUploadAvatar` | 头像上传逻辑封装 | +| `formatStorePrice2Read` | `@fastgpt/global/support/wallet/usage/tools` | 价格格式化(分→元) | +| `formatTime2YMD` | `@fastgpt/global/common/string/time` | 时间格式化 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Store\346\225\260\346\215\256\346\265\201.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Store\346\225\260\346\215\256\346\265\201.md" new file mode 100644 index 000000000000..b588196f9073 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/Store\346\225\260\346\215\256\346\265\201.md" @@ -0,0 +1,78 @@ +--- +capability_label: 账号信息 +doc_type: "14" +doc_label: Store数据流 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — Store 数据流 + +## 说明 + +本模块无独立 Store(未检测到本模块专属的状态管理模块定义)。 + +模块的状态管理通过以下方式实现: +- **`useUserStore`**(`@/web/support/user/useUserStore`):提供 `userInfo`、`initUserInfo()`、`updateUserInfo()` 等用户状态管理。页面加载时通过 `initUserInfo()` 获取用户信息;头像上传后通过 `updateUserInfo()` 更新。 +- **`useSystemStore`**(`@/web/common/system/useSystemStore`):提供 `feConfigs` 系统功能开关、`subPlans` 子套餐配置。控制 Plus 功能、优惠券入口、客服入口等条件渲染。 +- **Props 传递**:`onOpenContact` 回调通过 Props 从主页面传递到 MyInfo、Other 等子组件。 +- **局部状态**:各子组件通过 `useState`/`useDisclosure` 管理弹窗开关、表单字段等局部状态。 + +## Store 概览(本模块消费) + +| Store 文件 | Store ID | 用途 | 本模块使用方式 | +|-----------|---------|------|-------------| +| `web/support/user/useUserStore.ts` | `useUserStore` | 用户信息、团队权限、套餐状态 | `initUserInfo()` 加载;`updateUserInfo()` 更新头像;`userInfo` 渲染个人信息 | +| `web/common/system/useSystemStore.ts` | `useSystemStore` | 系统功能开关、套餐配置 | `feConfigs` 控制功能可见性;`subPlans` 提供套餐名和URL | + +## 数据流向 + +### 页面初始化流程 + +``` +Info组件 (useMount) useUserStore.initUserInfo API + │ │ │ + │ initUserInfo() │ │ + ├─────────────────────────────►│ │ + │ │ GET /api/support/ │ + │ │ user/tokenLogin │ + │ ├─────────────────────────────►│ + │ │ ◄─── userInfo JSON ──────────┤ + │ │ │ + │ │ initTeamPlanStatus() │ + │ ├─────────────────────────────►│ + │ │ ◄─── teamPlanStatus ─────────┤ + │ │ │ + │ ◄── state 更新 ─────────────┤ │ + │ │ │ + │ UI 渲染 userInfo + plan │ │ +``` + +### 头像更新流程(乐观更新) + +``` +MyInfo组件 useUserStore.updateUserInfo API + │ │ │ + │ 上传成功后调用 │ │ + │ updateUserInfo({avatar}) │ │ + ├─────────────────────────────►│ │ + │ │ 乐观更新 state (立即) │ + │ ◄── 头像UI立即更新 ─────────┤ │ + │ │ PUT /api/.../update │ + │ ├───────────────────────────►│ + │ │ ◄─── 成功 ────────────────┤ + │ │ │ + │ │ (失败 → 回滚旧值) │ +``` + +## 组件间通信模式 + +| 通信模式 | 场景 | 涉及组件 | +|---------|------|---------| +| Store 共享状态 | 用户信息、系统配置的全局共享 | `Info` → `useUserStore` / `useSystemStore` | +| Props 传递回调 | 子组件触发联系客服 | `Info` → `MyInfo(onOpenContact)` / `Other(onOpenContact)` → `CommunityModal` | +| Props 传递回调 | 弹窗关闭回调 | `Info` → 各 Modal (onClose) | +| 路由参数 | 页面级路由 `/account/info` | Next.js 路由 → `Info` | +| 局部 useState | 弹窗开关状态 | `useDisclosure()` 在各子组件内 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" new file mode 100644 index 000000000000..c9e6ed7dff98 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\345\205\261\344\272\253\347\273\204\344\273\266\346\270\205\345\215\225.md" @@ -0,0 +1,18 @@ +--- +capability_label: 账号信息 +doc_type: "15" +doc_label: 共享组件清单 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 共享组件清单 + +## 说明 + +本模块无对外共享组件(没有被外部模块引用的组件,且无共享目录)。 + +- 内部组件列表见 [组件列表](./组件列表.md) +- 模块消费的外部共享组件(Avatar、MyIcon、MyTooltip 等)定义参见各源模块的共享组件清单 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..1154d7faa8c0 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\350\264\246\345\217\267\344\277\241\346\201\257/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,66 @@ +--- +capability_label: 账号信息 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: ["普通成员", "团队管理员", "团队拥有者"] +router_paths: ["/account/info"] +--- + +# 账号信息 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/account/info/index.tsx` | 账号信息主页面,组合个人信息、套餐用量、客服入口三个区域 | 内部 | 否 | —(Next.js 页面组件,无直接Props) | + +## 页面内部组件(pageComponents/account/info/) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pageComponents/account/info/ConversionModal.tsx` | 余额兑换AI积分弹窗,展示兑换比例和确认操作 | 内部 | 否 | `onClose: () => void`, `onOpenContact: () => void` | +| `pageComponents/account/info/UpdatePswModal.tsx` | 修改密码弹窗,含旧密码/新密码/确认密码表单 | 内部 | 否 | `onClose: () => void` | +| `pageComponents/account/info/RedeemCouponModal.tsx` | 优惠券兑换码输入弹窗 | 内部 | 否 | `onClose: () => void`, `onSuccess: () => void` | +| `pageComponents/account/info/DiscountCouponsModal.tsx` | 折扣券查看弹窗,展示可用折扣券列表 | 内部 | 否 | `onClose: () => void` | +| `pageComponents/account/info/standardDetailModal.tsx` | 标准套餐详情弹窗(代码中已注释掉触发入口) | 内部 | 否 | `onClose: () => void` | + +## 页面内部子组件(定义在 index.tsx 内) + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `pages/account/info/index.tsx → MyInfo` | 个人信息展示区,包含用户名、密码、联系方式、头像、团队昵称、余额等字段 | 内部 | 否 | `onOpenContact: () => void` | +| `pages/account/info/index.tsx → PlanUsage` | 套餐与资源用量展示区,含套餐名称、到期时间、AI积分/知识库用量进度条、限额列表 | 内部 | 否 | 无(从 useUserStore 读取) | +| `pages/account/info/index.tsx → Other` | 底部操作区,含帮助文档、联系我们、问题反馈等入口按钮 | 内部 | 否 | `onOpenContact: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户容器(本模块同级) | +| `TeamSelector` | `@/pageComponents/account/TeamSelector` | 账户(本模块同级) | +| `StandardPlanContentList` | `@/components/support/wallet/StandardPlanContentList` | 钱包/套餐 | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | 共享UI组件库 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享UI组件库 | +| `MyTooltip` | `@fastgpt/web/components/common/MyTooltip` | 共享UI组件库 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | 共享UI组件库 | +| `MyDivider` | `@fastgpt/web/components/common/MyDivider` | 共享UI组件库 | +| `CommunityModal` | `@/components/CommunityModal` | 社区联系 | +| `UpdateContactModal` | `@/components/support/user/inform/UpdateContactModal` | 用户通知 | +| `ModelPriceModal` | `@/components/core/ai/ModelTable` | AI模型 | + +## 消费的外部 Hooks/工具 + +| 本模块使用 | 导入路径 | 源模块 | +|-----------|---------|--------| +| `useUserStore` | `@/web/support/user/useUserStore` | 用户状态管理 | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统配置 | +| `useToast` | `@fastgpt/web/hooks/useToast` | 共享Hooks | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | 共享Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 共享Hooks | +| `useUploadAvatar` | `@fastgpt/web/common/file/hooks/useUploadAvatar` | 共享Hooks | +| `formatStorePrice2Read` | `@fastgpt/global/support/wallet/usage/tools` | 全局工具 | +| `formatTime2YMD` | `@fastgpt/global/common/string/time` | 全局工具 | + +> 共享组件的接口定义(Props/Events)请参见对应源模块的共享组件清单。内部组件仅在本模块内使用,不对外暴露。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..a59a8859e1c3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/API\347\264\242\345\274\225.md" @@ -0,0 +1,65 @@ +--- +capability_label: 通知 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 所有登录用户(Plus版) + - 个人账户用户 + - 团队账户用户 +router_paths: + - /account/inform +--- + +# 通知 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/inform/list` | POST | 分页查询通知列表 | `web/support/user/inform/api.ts:6` → `pages/account/inform.tsx:38` | 账户→通知列表→首次加载时调用;账户→通知列表→翻页/切换每页条数时调用 | +| `/proApi/support/user/inform/countUnread` | GET | 获取未读通知数量及重要通知列表 | `web/support/user/inform/api.ts:9` → `components/Layout/index.tsx:103` | 全局→Layout→30秒轮询调用;返回未读数和重要通知用于导航角标和全局浮层 | + +## 更新/标记 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/proApi/support/user/inform/read` | GET | 标记指定通知为已读 | `web/support/user/inform/api.ts:14` → `pages/account/inform.tsx:82`
`web/support/user/inform/api.ts:14` → `components/support/user/inform/ImportantInform.tsx:18` | 账户→通知列表→点击未读通知时调用(标记后刷新列表);全局→重要通知浮层→点击关闭按钮时调用(标记后刷新未读数) | + +## API 调用链追踪 + +### `/proApi/support/user/inform/list` 调用链 + +``` +InformTable (pages/account/inform.tsx) + ├── 触发: 页面挂载时首次加载 / 用户点击翻页 / 切换每页条数 + ├── 参数: { pageNum: number, pageSize: number } + ├── 响应: PaginationResponse,包含 list 数组和 total 总数 + └── 响应处理: list 渲染为通知卡片列表;total 用于分页器显示总条数;加载失败时显示错误 Toast +``` + +### `/proApi/support/user/inform/countUnread` 调用链 + +``` +Layout (components/Layout/index.tsx) + ├── 触发: 每 30 秒自动轮询(refetchInterval: 30000) + ├── 参数: 无 + ├── 响应: { unReadCount: number, importantInforms: UserInformType[] } + └── 响应处理: unReadCount 传给 Navbar 组件显示角标;importantInforms 传给 ImportantInform 组件展示全局浮层 +``` + +### `/proApi/support/user/inform/read` 调用链 + +``` +InformTable (pages/account/inform.tsx) + ├── 触发: 用户点击未读通知卡片 + ├── 参数: { id: string } — 通知ID + └── 响应处理: 成功后调用 getData(pageNum) 重新加载当前页,红点状态更新 + +ImportantInform (components/support/user/inform/ImportantInform.tsx) + ├── 触发: 用户点击重要通知浮层的关闭按钮 + ├── 参数: (id: string) — 通知ID + ├── 错误提示: "Failed to read the inform" + └── 响应处理: 成功后调用 refetch() 刷新未读数和重要通知列表 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..4a56b9af7252 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\350\264\246\346\210\267/\351\200\232\347\237\245/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,46 @@ +--- +capability_label: 通知 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 账户 +roles: + - 所有登录用户(Plus版) + - 个人账户用户 + - 团队账户用户 +router_paths: + - /account/inform +--- + +# 通知 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/account/inform.tsx` | 通知列表页面 — 展示通知卡片列表、分页、空状态 | 引用自 账户 → AccountContainer;引用自 web → usePagination/EmptyTip/MyTag/Markdown | 否 | —(页面组件无外部 Props) | + +## 业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/account/NotificationDetailsModal.tsx` | 通知详情弹窗 — 展示通知完整内容(Markdown 渲染)、标题、时间、类型标签 | 引用自 web → MyModal/MyTag/MyDivider;引用自 @/components → Markdown | 否 | `inform: any`(通知数据对象), `onClose: () => void` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户(共享容器) | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 共享基础组件 | +| `MyTag` | `@fastgpt/web/components/common/Tag/index` | web 共享基础组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 共享基础组件 | +| `MyDivider` | `@fastgpt/web/components/common/MyDivider` | web 共享基础组件 | +| `Markdown` | `@/components/Markdown` | 共享 Markdown 渲染组件 | + +## 消费的外部 Hooks + +| 本模块使用 Hook | 导入路径 | 源模块 | +|---------------|---------|--------| +| `usePagination` | `@fastgpt/web/hooks/usePagination` | web 共享 Hooks | +| `useLoading` | `@fastgpt/web/hooks/useLoading` | web 共享 Hooks | +| `useTranslation` | `next-i18next` | i18n 国际化 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..02d142285f38 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,59 @@ +--- +capability_label: 配置 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [系统管理员] +router_paths: ["/config/tool", "/config/tool/marketplace"] +--- + +## API 索引 + +配置模块涵盖系统工具管理和工具市场两大子能力,对应以下 API 端点。所有端点均需系统管理员权限(`authSystemAdmin`)。 + +### 系统工具管理 + +| 方法 | 路由 | 说明 | +|------|------|------| +| GET | `/api/core/plugin/admin/tool/list` | 获取系统工具列表 | +| GET | `/api/core/plugin/admin/tool/detail` | 获取系统工具详情 | +| POST | `/api/core/plugin/admin/tool/update` | 更新系统工具配置 | +| POST | `/api/core/plugin/admin/tool/delete` | 删除系统工具 | +| POST | `/api/core/plugin/admin/tool/updateOrder` | 更新系统工具排序 | +| GET | `/api/core/plugin/admin/tool/app/systemApps` | 获取可注册为工具的工作流应用列表 | +| POST | `/api/core/plugin/admin/tool/app/create` | 将工作流应用创建为系统工具 | + +### 工具标签管理 + +| 方法 | 路由 | 说明 | +|------|------|------| +| POST | `/api/core/plugin/admin/tool/tag/create` | 创建工具标签 | +| POST | `/api/core/plugin/admin/tool/tag/update` | 更新工具标签 | +| POST | `/api/core/plugin/admin/tool/tag/delete` | 删除工具标签 | +| POST | `/api/core/plugin/admin/tool/tag/updateOrder` | 更新标签排序 | + +### 工具包管理 + +| 方法 | 路由 | 说明 | +|------|------|------| +| POST | `/api/core/plugin/admin/pkg/parse` | 解析上传的工具包 | +| POST | `/api/core/plugin/admin/pkg/presign` | 获取工具包上传预签名 URL | +| POST | `/api/core/plugin/admin/pkg/confirm` | 确认工具包上传 | +| POST | `/api/core/plugin/admin/pkg/delete` | 删除已安装的工具包 | +| POST | `/api/core/plugin/admin/installWithUrl` | 通过 URL 安装工具包 | + +### 工具市场 + +| 方法 | 路由 | 说明 | +|------|------|------| +| GET | `/api/marketplace/[...path]` | 工具市场 API 代理(转发至外部市场服务),支持 `api/tools`、`api/tools/{toolId}`、`api/tags`、`api/versions` 等子路径 | +| GET | `/api/core/plugin/admin/marketplace/installed` | 获取已安装的系统工具(含版本信息,用于判断更新) | + +### 前端 API 调用封装 + +前端通过以下模块封装调用以上 API: + +- `@/web/core/plugin/admin/tool/api` — 系统工具管理(getAdminSystemTools / putAdminUpdateTool / putAdminUpdateToolOrder)和标签管理(createPluginToolTag / updatePluginToolTag / deletePluginToolTag / updatePluginToolTagOrder) +- `@/web/core/plugin/admin/api` — 工具包管理(intallPluginWithUrl / deletePkgPlugin) +- `@/web/core/plugin/marketplace/api` — 工具市场(getMarketplaceTools / getMarketPlaceToolTags / getMarketplaceToolDetail / getMarketplaceDownloadURL / getMarketplaceDownloadURLs / getSystemInstalledPlugins / getMarketplaceToolVersions) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..dd671b9a8ddf --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/API\347\264\242\345\274\225.md" @@ -0,0 +1,75 @@ +--- +capability_label: 工具配置 +doc_type: "12" +doc_label: API索引 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 配置 +roles: [admin] +router_paths: [/config/tool, /config/tool/marketplace] +--- + +# 工具配置 — API索引 + +本模块为分组索引节点。API 按子归属分为「工具配置首页 API」和「工具市场 API」两部分。 + +## 工具配置首页 API + +API 定义位置:`projects/app/src/web/core/plugin/admin/tool/api.ts` + +### 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/system/tool/list` | GET | 获取系统工具列表 | `src/web/core/plugin/admin/tool/api.ts` → `src/pages/config/tool/index.tsx` | 工具配置→工具配置首页→页面加载时调用 | +| `/admin/system/tool/detail` | GET | 获取单个工具详情 | `src/web/core/plugin/admin/tool/api.ts` → `src/pageComponents/config/tool/SystemToolConfigModal.tsx` | 工具配置→工具配置首页→打开工具配置弹窗时调用 | + +### 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/system/tool/update` | PUT | 更新工具配置(状态、默认安装、标签、密钥价格等) | `src/web/core/plugin/admin/tool/api.ts` → `src/pageComponents/config/tool/SystemToolConfigModal.tsx` | 工具配置→工具配置首页→提交工具配置表单时调用 | +| `/admin/system/tool/order` | PUT | 更新工具排序 | `src/web/core/plugin/admin/tool/api.ts` → `src/pages/config/tool/index.tsx` | 工具配置→工具配置首页→拖拽排序完成后调用 | + +### 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/system/tool/delete` | DELETE | 卸载系统工具 | `src/web/core/plugin/admin/api.ts` → `src/pageComponents/config/tool/SystemToolConfigModal.tsx` | 工具配置→工具配置首页→确认删除工具时调用 | + +### 标签管理 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/system/tool/tags` | GET | 获取工具标签列表 | `src/web/core/plugin/admin/tool/api.ts` → `src/pageComponents/config/tool/SystemToolConfigModal.tsx` 等 | 工具配置→工具配置首页→加载标签选项时调用 | + +## 工具市场 API + +API 定义位置:`projects/app/src/web/core/plugin/marketplace/api.ts` + +### 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/marketplace/tool/list` | GET | 获取市场工具列表(分页+搜索+标签筛选) | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→页面加载/搜索/翻页/筛选时调用 | +| `/marketplace/tool/detail` | GET | 获取市场工具详情 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→点击工具卡片查看详情时调用 | +| `/marketplace/tool/tags` | GET | 获取市场标签列表 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→页面加载时调用 | +| `/marketplace/tool/versions` | GET | 获取市场所有工具版本信息 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→计算可更新工具列表时调用 | +| `/marketplace/tool/download/url` | GET | 获取单个工具下载地址 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→安装/更新单个工具时调用 | +| `/marketplace/tool/download/urls` | POST | 批量获取工具下载地址 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→批量更新工具时调用 | + +### 已安装插件 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/system/installedPlugins` | GET | 获取已安装系统插件列表 | `src/web/core/plugin/marketplace/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→页面加载/安装/卸载/更新后刷新时调用 | + +### 安装/卸载 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/admin/plugin/install` | POST | 通过 URL 安装插件 | `src/web/core/plugin/admin/api.ts` → `src/pages/config/tool/marketplace.tsx` | 工具配置→工具市场→安装/更新工具时调用 | + +## 详细 API 调用链请参见子能力文档 + +- **工具配置首页**:详见 [12-API索引](../工具配置首页/API索引.md) +- **工具市场**:详见 [12-API索引](../工具市场/API索引.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..359cda22d08c --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/API\347\264\242\345\274\225.md" @@ -0,0 +1,122 @@ +--- +capability_label: 工具市场 +doc_type: "12" +doc_label: "API索引" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "工具配置" +roles: ["admin"] +router_paths: ["/config/tool/marketplace"] +--- + +# 工具市场 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/marketplace/api/tool/list` | POST | 分页获取工具列表(支持搜索和标签筛选) | `projects/app/src/web/core/plugin/marketplace/api.ts:16` → `projects/app/src/pages/config/tool/marketplace.tsx:137` | 工具市场→浏览与搜索工具→加载时调用;工具市场→浏览与搜索工具→搜索/标签筛选/滚动翻页时调用 | +| `/marketplace/api/tool/tags` | GET | 获取所有工具标签 | `projects/app/src/web/core/plugin/marketplace/api.ts:22` → `projects/app/src/pages/config/tool/marketplace.tsx:164` | 工具市场→浏览与搜索工具→页面初始化时调用 | +| `/core/plugin/admin/marketplace/installed` | GET | 获取系统已安装工具列表 | `projects/app/src/web/core/plugin/marketplace/api.ts:13` → `projects/app/src/pages/config/tool/marketplace.tsx:150` | 工具市场→页面初始化时调用;工具市场→安装/卸载/更新工具→操作完成后刷新时调用 | + +## 详情 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/marketplace/api/tool/detail` | GET | 获取单工具详情(含参数、readme 等) | `projects/app/src/web/core/plugin/marketplace/api.ts:19` → `projects/app/src/pages/config/tool/marketplace.tsx:750` | 工具市场→查看工具详情→打开详情抽屉时调用;工具市场→批量更新→查看单个工具详情时调用 | +| `/marketplace/api/tool/versions` | GET | 获取所有工具在 Marketplace 上的最新版本 | `projects/app/src/web/core/plugin/marketplace/api.ts:31` → `projects/app/src/pages/config/tool/marketplace.tsx:168` | 工具市场→页面初始化时调用(用于计算可更新工具列表) | + +## 下载 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/marketplace/api/tool/getDownloadUrl` | GET | 获取单个工具的下载链接 | `projects/app/src/web/core/plugin/marketplace/api.ts:25` → `projects/app/src/pages/config/tool/marketplace.tsx:182` | 工具市场→安装工具→点击安装时调用;工具市场→更新工具→点击更新时调用 | +| `/marketplace/api/tool/getDownloadUrl` | POST | 批量获取多个工具的下载链接 | `projects/app/src/web/core/plugin/marketplace/api.ts:28` → `projects/app/src/pages/config/tool/marketplace.tsx:287` | 工具市场→批量更新工具→点击批量更新时调用 | + +## 安装/更新/删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/core/plugin/admin/installWithUrl` | POST | 通过下载链接安装或更新工具 | `projects/app/src/web/core/plugin/admin/api.ts:25` → `projects/app/src/pages/config/tool/marketplace.tsx:190` | 工具市场→安装工具→获取下载链接后调用;工具市场→更新工具→获取下载链接后调用;工具市场→批量更新→批量获取下载链接后调用 | +| `/core/plugin/admin/pkg/delete` | DELETE | 删除已安装的工具 | `projects/app/src/web/core/plugin/admin/api.ts:22` → `projects/app/src/pages/config/tool/marketplace.tsx:262` | 工具市场→卸载工具→点击卸载时调用 | + +## API 调用链追踪 + +### `POST /marketplace/api/tool/list` 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 页面初始化 / 搜索关键词变化 / 标签筛选变化 / 滚动翻页 + ├── 参数: { pageNum, pageSize, searchKey?, tags? } + ├── Hook: usePagination(滚动分页模式,defaultPageSize=20) + ├── 刷新依赖: [searchText, tagIds] + └── 响应处理: 合并已安装状态、i18n 名称解析、标签名称匹配 → displayTools +``` + +### `GET /core/plugin/admin/marketplace/installed` 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 页面初始化(manual: false,自动执行);安装/卸载/更新完成后手动调用 refreshInstalledPlugins() + ├── 参数: { type: 'tool' } + ├── Hook: useRequest + └── 响应处理: 构建 ids (Set) + map (Map) → systemInstalledPlugins +``` + +### `GET /marketplace/api/tool/tags` 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 页面初始化(manual: false) + ├── Hook: useRequest + └── 响应处理: 直接作为 allTags 使用,传递给 ToolTagFilterBox +``` + +### `GET /marketplace/api/tool/detail` 调用链 + +``` +ToolDetailDrawer (packages/web/components/core/plugin/tool/ToolDetailDrawer.tsx) + ├── 触发: 用户点击工具卡片打开详情抽屉 + ├── 参数: { toolId } + └── 响应处理: 通过 useToolDetail hook 解析为 parentTool / isToolSet / subTools / readmeContent + +BatchUpdateDrawer (packages/web/components/core/plugin/tool/BatchUpdateDrawer.tsx) + ├── 触发: 批量更新抽屉中点击「查看详情」切换到 detail 视图 + ├── 参数: { toolId } + └── 响应处理: 同 ToolDetailDrawer 使用 useToolDetail hook +``` + +### `GET /marketplace/api/tool/getDownloadUrl` (单个) 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 用户点击单个工具的安装或更新按钮 + ├── 参数: toolId (string) + └── 响应处理: 获取 downloadUrl → 传给 intallPluginWithUrl 执行安装 +``` + +### `POST /marketplace/api/tool/getDownloadUrl` (批量) 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 批量更新抽屉中点击「批量更新」按钮 + ├── 参数: { toolIds: string[] } + └── 响应处理: 获取 downloadUrls[] → 传给 intallPluginWithUrl 执行批量安装 +``` + +### `POST /core/plugin/admin/installWithUrl` 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 获取到 downloadUrl(s) 之后自动调用 + ├── 参数: { downloadUrls: string[] } + └── 响应处理: 刷新已安装列表 + 更新工具卡片状态(若详情抽屉打开则同步更新) + +```markdown +### `DELETE /core/plugin/admin/pkg/delete` 调用链 + +``` +ToolkitMarketplace (projects/app/src/pages/config/tool/marketplace.tsx) + ├── 触发: 用户点击已安装工具的卸载按钮 + ├── 参数: { toolId: string } + └── 响应处理: 刷新已安装列表 → 更新工具卡片状态为「未安装」 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..328ea0b524fe --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\345\270\202\345\234\272/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,50 @@ +--- +capability_label: 工具市场 +doc_type: "13" +doc_label: "组件列表" +generated_at: "2026-06-18T12:00:00Z" +parent_module: "工具配置" +roles: ["admin"] +router_paths: ["/config/tool/marketplace"] +--- + +# 工具市场 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/config/tool/marketplace.tsx` | 工具市场主页面,包含搜索、标签筛选、工具卡片网格、安装/卸载/更新、详情抽屉、批量更新 | 引用自共享组件库(ToolCard、ToolDetailDrawer、BatchUpdateDrawer、ToolTagFilterBox);引用自 Dashboard 容器(DashboardNavbar);引用自通用 Hooks(usePagination、useRequest、useCopyData、useSystem) | 否 | `marketplaceUrl: string`(Marketplace 服务地址) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `ToolCard` | `@fastgpt/web/components/core/plugin/tool/ToolCard` | 共享组件库(plugin/tool) | +| `ToolTagFilterBox` | `@fastgpt/web/components/core/plugin/tool/TagFilterBox` | 共享组件库(plugin/tool) | +| `ToolDetailDrawer` | `@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer` | 共享组件库(plugin/tool) | +| `BatchUpdateDrawer` | `@fastgpt/web/components/core/plugin/tool/BatchUpdateDrawer` | 共享组件库(plugin/tool) | +| `LightRowTabs` | `@fastgpt/web/components/common/Tabs/LightRowTabs`(间接使用,在 ToolDetailDrawer/BatchUpdateDrawer 内部) | 共享组件库(common) | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | Dashboard 容器 | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | 共享组件库(common) | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | 共享组件库(common) | +| `MyIconButton` | `@fastgpt/web/components/common/Icon/button` | 共享组件库(common) | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | 共享组件库(common) | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库(common) | + +## 消费的外部 Hooks/工具 + +| 本模块使用 Hook | 导入路径 | 源模块 | +|---------------|---------|--------| +| `usePagination` | `@fastgpt/web/hooks/usePagination` | 通用 Hooks | +| `useRequest` | `@fastgpt/web/hooks/useRequest` | 通用 Hooks | +| `useCopyData` | `@fastgpt/web/hooks/useCopyData` | 通用 Hooks | +| `useSystem` | `@fastgpt/web/hooks/useSystem` | 通用 Hooks | +| `useSystemStore` | `@/web/common/system/useSystemStore` | 系统 Store | +| `useDebounce` / `useMount` / `useSet` | `ahooks` | 第三方库 | + +## 内部自定义 Hook + +| Hook 名 | 用途 | +|--------|------| +| `useSearchParams` | 管理 URL 查询参数(search、tags)的读写,支持 `shallow` 路由替换,实现搜索状态与 URL 同步 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..e48da8117130 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,130 @@ +--- +capability_label: 工具配置首页 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工具配置 +roles: [admin] +router_paths: [/config/tool] +--- + +# 工具配置首页 — API索引 + +## 查询/列表 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/core/plugin/admin/tool/list` | GET | 获取系统工具列表 | `web/core/plugin/admin/tool/api.ts:22` → `pages/config/tool/index.tsx:51` | 配置→工具配置首页→页面加载时调用;配置→工具配置首页→添加/编辑/删除工具后刷新列表 | +| `/api/aiproxy/api/core/plugin/admin/tool/detail` | GET | 获取单个工具详情 | `web/core/plugin/admin/tool/api.ts:25` → `pageComponents/config/tool/SystemToolConfigModal.tsx:59`;`pageComponents/config/tool/WorkflowToolConfigModal.tsx:88` | 配置→工具配置首页→点击工具行→打开配置弹窗时调用 | +| `/api/aiproxy/api/core/plugin/admin/tool/app/systemApps` | POST | 获取可关联的应用列表 | `web/core/plugin/admin/tool/api.ts:38` → `pageComponents/config/tool/WorkflowToolConfigModal.tsx:128` | 配置→工具配置首页→打开工作流工具配置弹窗→搜索关联应用时调用 | +| `/api/aiproxy/api/core/plugin/toolTag/list` | GET | 获取工具标签列表 | `web/core/plugin/toolTag/api.ts:6` → `pageComponents/config/tool/SystemToolConfigModal.tsx:112`;`pageComponents/config/tool/WorkflowToolConfigModal.tsx:135` | 配置→工具配置首页→打开配置弹窗→加载标签选项时调用 | + +## 更新 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/core/plugin/admin/tool/update` | PUT | 更新工具配置 | `web/core/plugin/admin/tool/api.ts:28` → `pageComponents/config/tool/ToolRow.tsx:39`;`pageComponents/config/tool/SystemToolConfigModal.tsx:142`;`pageComponents/config/tool/WorkflowToolConfigModal.tsx:195` | 配置→工具配置首页→快捷切换默认安装/Token费用时调用;配置→工具配置首页→系统工具配置弹窗→保存时调用;配置→工具配置首页→工作流工具配置弹窗→保存时调用 | +| `/api/aiproxy/api/core/plugin/admin/tool/updateOrder` | PUT | 更新工具排序 | `web/core/plugin/admin/tool/api.ts:31` → `pages/config/tool/index.tsx:158` | 配置→工具配置首页→拖拽工具行→松手完成排序时调用 | + +## 创建 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/core/plugin/admin/tool/app/create` | POST | 创建应用类型工具 | `web/core/plugin/admin/tool/api.ts:41` → `pageComponents/config/tool/WorkflowToolConfigModal.tsx:198` | 配置→工具配置首页→"添加资源"→"选择应用"→填写信息→提交创建时调用 | + +## 删除 + +| API 路径 | 方法 | 用途 | 调用位置 | 调用场景 | +|---------|------|------|---------|---------| +| `/api/aiproxy/api/core/plugin/admin/tool/delete` | DELETE | 删除工作流/应用类型工具 | `web/core/plugin/admin/tool/api.ts:34` → `pageComponents/config/tool/WorkflowToolConfigModal.tsx:212` | 配置→工具配置首页→工作流工具配置弹窗→点击删除→确认后调用 | +| `/api/aiproxy/api/core/plugin/admin/pkg/delete` | DELETE | 删除系统内置插件包 | `web/core/plugin/admin/api.ts:23` → `pageComponents/config/tool/SystemToolConfigModal.tsx:156` | 配置→工具配置首页→系统工具配置弹窗→点击删除→确认后调用 | + +## API 调用链追踪 + +### `GET /core/plugin/admin/tool/list` 调用链 + +``` +pages/config/tool/index.tsx — ToolProvider + ├── 触发: 页面加载(manual: false,自动请求) + ├── 参数: { parentId: null } + ├── 响应处理: setLocalTools(data),作为 DndDrag 的 dataList + └── 刷新触发: 添加/编辑/删除工具后通过 refreshTools 重新调用 +``` + +### `GET /core/plugin/admin/tool/detail` 调用链 + +``` +pageComponents/config/tool/SystemToolConfigModal.tsx + ├── 触发: 弹窗打开(toolId 变化时自动请求) + ├── 参数: { toolId } + ├── 响应处理: reset(formData) 填充表单,setSelectedTags(res.tags) + └── 依赖: manual: false + +pageComponents/config/tool/WorkflowToolConfigModal.tsx + ├── 触发: 弹窗打开(toolId 存在时自动请求) + ├── 参数: { toolId } + ├── 响应处理: reset(res) 填充表单,setSelectedTags(res.tags) + └── 依赖: manual: false +``` + +### `PUT /core/plugin/admin/tool/update` 调用链 + +``` +pageComponents/config/tool/ToolRow.tsx — ToolRow + ├── 触发: 用户点击默认安装复选框或 Token 费用开关 + ├── 参数: { pluginId, defaultInstalled?, hasTokenFee?, status? } + ├── 响应处理: setLocalTools(prev => prev.map(...)) 局部更新列表 + └── 错误: toast 提示 "更新失败" + +pageComponents/config/tool/SystemToolConfigModal.tsx + ├── 触发: 用户点击确认按钮 + ├── 参数: { pluginId, status, defaultInstalled, inputListVal, systemKeyCost, childTools, promoteTags, hideTags, tagIds } + ├── 响应处理: onSuccess() + onClose(),刷新列表 + └── 成功: toast "配置成功" + +pageComponents/config/tool/WorkflowToolConfigModal.tsx + ├── 触发: 用户点击确认按钮(编辑模式 toolId 存在) + ├── 参数: { pluginId, name, avatar, intro, tagIds, associatedPluginId, userGuide, author, status, defaultInstalled, currentCost, hasTokenFee } + ├── 响应处理: onSuccess() + onClose(),刷新列表 + └── 成功: toast "配置成功" +``` + +### `PUT /core/plugin/admin/tool/updateOrder` 调用链 + +``` +pages/config/tool/index.tsx — ToolProvider + ├── 触发: 用户完成拖拽(DndDrag onDragEndCb) + ├── 参数: { plugins: [{ pluginId, pluginOrder }] } + ├── 响应处理: setLocalTools(list)(已在拖拽回调中乐观更新) + └── 无显式错误提示 +``` + +### `POST /core/plugin/admin/tool/app/create` 调用链 + +``` +pageComponents/config/tool/WorkflowToolConfigModal.tsx + ├── 触发: 用户点击确认按钮(新建模式 toolId 不存在) + ├── 参数: { name, avatar, intro, tagIds, associatedPluginId, userGuide, author, status, defaultInstalled, currentCost, hasTokenFee } + ├── 响应处理: onSuccess() + onClose(),刷新列表 + └── 成功: toast "配置成功" +``` + +### `DELETE /core/plugin/admin/tool/delete` 调用链 + +``` +pageComponents/config/tool/WorkflowToolConfigModal.tsx + ├── 触发: 用户点击删除按钮 → PopoverConfirm 确认 + ├── 参数: { toolId } + ├── 响应处理: toast "删除成功" → onSuccess() + onClose() + └── 前置: toolId 存在(仅编辑模式显示删除按钮) +``` + +### `DELETE /core/plugin/admin/pkg/delete` 调用链 + +``` +pageComponents/config/tool/SystemToolConfigModal.tsx + ├── 触发: 用户点击删除按钮 → PopoverConfirm 确认 + ├── 参数: { toolId: toolId.split('-')[1] }(提取 pkg 部分) + ├── 响应处理: onSuccess() + onClose(),刷新列表 + └── 前置: 任意状态均可删除 +``` diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..76a63a5cbea3 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,51 @@ +--- +capability_label: 工具配置首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: 工具配置 +roles: [admin] +router_paths: [/config/tool] +--- + +# 工具配置首页 — 组件列表 + +## pages/config/tool/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/config/tool/index.tsx` | 工具配置首页,展示工具列表并提供管理入口 | 引用自 账户→AccountContainer;引用自 工具配置→ToolRow、SystemToolConfigModal、WorkflowToolConfigModal | 否 | 无(页面组件,通过 `getServerSideProps` 注入 i18n) | + +## pageComponents/config/tool/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/config/tool/ToolRow.tsx` | 单个工具行,展示工具信息并支持拖拽、快捷切换属性 | 被 工具配置首页 引用 | 否,内部组件 | `tool: AdminSystemToolListItemType`, `setEditingToolId`, `setLocalTools`, `provided`, `snapshot` | +| `projects/app/src/pageComponents/config/tool/SystemToolConfigModal.tsx` | 系统工具配置弹窗,编辑状态、系统密钥、标签、用户标签 | 被 工具配置首页 引用 | 否,内部组件 | `toolId: string`, `onSuccess`, `onClose` | +| `projects/app/src/pageComponents/config/tool/WorkflowToolConfigModal.tsx` | 工作流/应用工具配置弹窗,编辑名称、头像、关联应用、价格等 | 被 工具配置首页 引用 | 否,内部组件 | `toolId: string`, `onSuccess`, `onClose` | + +## pageComponents/config/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pageComponents/config/ImportPluginModal.tsx` | 导入插件弹窗,支持上传或 URL 安装插件包 | 被 工具配置首页 引用 | 否 | `onClose`, `onSuccess`, `tools` | +| `projects/app/src/pageComponents/config/TagManageModal.tsx` | 工具标签管理弹窗,增删改查和排序标签 | 被 工具配置首页 引用 | 否,可能被其他模块复用 | `onClose` | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户 | +| `DndDrag` / `Draggable` | `@fastgpt/web/components/common/DndDrag` | web 公共组件 | +| `MyIcon` | `@fastgpt/web/components/common/Icon` | web 公共组件 | +| `MyMenu` | `@fastgpt/web/components/common/MyMenu` | web 公共组件 | +| `QuestionTip` | `@fastgpt/web/components/common/MyTooltip/QuestionTip` | web 公共组件 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | web 公共组件 | +| `MyModal` | `@fastgpt/web/components/common/MyModal` | web 公共组件(SystemToolConfigModal 中) | +| `MyBox` | `@fastgpt/web/components/common/MyBox` | web 公共组件(ToolRow 中) | +| `Avatar` | `@fastgpt/web/components/common/Avatar` | web 公共组件(ToolRow 中) | +| `MyNumberInput` | `@fastgpt/web/components/common/Input/NumberInput` | web 公共组件(配置弹窗中) | +| `MultipleSelect` | `@fastgpt/web/components/common/MySelect/MultipleSelect` | web 公共组件(配置弹窗中) | +| `MySelect` | `@fastgpt/web/components/common/MySelect` | web 公共组件(配置弹窗中) | +| `MyDivider` | `@fastgpt/web/components/common/MyDivider` | web 公共组件(SystemToolConfigModal 中) | +| `PopoverConfirm` | `@fastgpt/web/components/common/MyPopover/PopoverConfirm` | web 公共组件(配置弹窗中) | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..08c3b9685c27 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\345\267\245\345\205\267\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,53 @@ +--- +capability_label: 工具配置 +doc_type: "13" +doc_label: 组件列表 +generated_at: 2026-06-18T12:00:00.000Z +parent_module: 配置 +roles: [admin] +router_paths: [/config/tool, /config/tool/marketplace] +--- + +# 工具配置 — 组件列表 + +本模块为分组索引节点,组件按子归属分类。各子能力组件的详细 Props 见对应文档。 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pages/config/tool/index.tsx` | 工具配置首页(ToolProvider) | 引用 AccountContainer、共享组件库 | 否 | +| `projects/app/src/pages/config/tool/marketplace.tsx` | 工具市场首页(ToolkitMarketplace) | 引用 DashboardNavbar、共享组件库 | 否 | + +## 子模块业务组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pageComponents/config/tool/ToolRow.tsx` | 工具列表行展示(名称、标签、描述、状态、价格等) | 内部 | 否 | +| `projects/app/src/pageComponents/config/tool/SystemToolConfigModal.tsx` | 系统工具配置弹窗(状态、默认安装、标签、密钥价格) | 引用共享组件库 | 否 | +| `projects/app/src/pageComponents/config/tool/WorkflowToolConfigModal.tsx` | 工作流工具配置弹窗 | 引用共享组件库 | 否 | +| `projects/app/src/pageComponents/config/ImportPluginModal.tsx` | 导入插件弹窗 | 引用共享组件库 | 否 | + +## 跨模块共享组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | +|---------|------|-----------|---------| +| `projects/app/src/pageComponents/config/TagManageModal.tsx` | 标签管理弹窗 | 被对话(收藏应用)、数据集等多个模块引用 | 是 | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `AccountContainer` | `@/pageComponents/account/AccountContainer` | 账户 | +| `DashboardNavbar` | `@/pageComponents/dashboard/Container` | 工作台 | +| `ToolCard` | `@fastgpt/web/components/core/plugin/tool/ToolCard` | 共享组件库 | +| `ToolTagFilterBox` | `@fastgpt/web/components/core/plugin/tool/TagFilterBox` | 共享组件库 | +| `ToolDetailDrawer` | `@fastgpt/web/components/core/plugin/tool/ToolDetailDrawer` | 共享组件库 | +| `BatchUpdateDrawer` | `@fastgpt/web/components/core/plugin/tool/BatchUpdateDrawer` | 共享组件库 | +| `DndDrag` | `@fastgpt/web/components/common/DndDrag` | 共享组件库 | +| `EmptyTip` | `@fastgpt/web/components/common/EmptyTip` | 共享组件库 | + +## 详细组件文档请参见子能力文档 + +- **工具配置首页**:详见 [13-组件列表](../工具配置首页/组件列表.md) +- **工具市场**:详见 [13-组件列表](../工具市场/组件列表.md) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..19553740b084 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\205\215\347\275\256/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,43 @@ +--- +capability_label: 配置 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [系统管理员] +router_paths: ["/config/tool", "/config/tool/marketplace"] +--- + +## 页面组件 + +| 组件名 | 文件路径 | 说明 | +|--------|---------|------| +| ToolProvider | projects/app/src/pages/config/tool/index.tsx | 工具配置首页——系统工具列表管理,含拖拽排序、新增/编辑/删除工具、标签管理入口 | +| ToolkitMarketplace | projects/app/src/pages/config/tool/marketplace.tsx | 工具市场页面——浏览、搜索、筛选、安装、卸载、更新市场工具 | + +## 子组件 + +位于 `projects/app/src/pageComponents/config/` 目录下: + +| 组件名 | 文件路径 | 说明 | +|--------|---------|------| +| TagManageModal | projects/app/src/pageComponents/config/TagManageModal.tsx | 标签管理弹窗——创建、编辑、删除工具分类标签 | +| ImportPluginModal | projects/app/src/pageComponents/config/ImportPluginModal.tsx | 导入工具弹窗——上传并安装外部工具包 | +| ToolRow | projects/app/src/pageComponents/config/tool/ToolRow.tsx | 工具列表行组件——展示单个工具的名称、标签、描述、状态、费用信息,支持编辑/删除操作 | +| SystemToolConfigModal | projects/app/src/pageComponents/config/tool/SystemToolConfigModal.tsx | 系统工具配置弹窗——编辑系统内置工具的详细配置 | +| WorkflowToolConfigModal | projects/app/src/pageComponents/config/tool/WorkflowToolConfigModal.tsx | 工作流工具配置弹窗——编辑由工作流应用生成的工具配置 | + +## 依赖的共享组件 + +| 组件名 | 来源 | 使用位置 | +|--------|------|---------| +| AccountContainer | @/pageComponents/account/AccountContainer | ToolProvider 页面布局容器 | +| DashboardNavbar | @/pageComponents/dashboard/Container | ToolkitMarketplace 侧边导航栏 | +| ToolCard | @fastgpt/web/components/core/plugin/tool/ToolCard | ToolkitMarketplace 工具卡片 | +| ToolTagFilterBox | @fastgpt/web/components/core/plugin/tool/TagFilterBox | ToolkitMarketplace 标签筛选 | +| ToolDetailDrawer | @fastgpt/web/components/core/plugin/tool/ToolDetailDrawer | ToolkitMarketplace 工具详情抽屉 | +| BatchUpdateDrawer | @fastgpt/web/components/core/plugin/tool/BatchUpdateDrawer | ToolkitMarketplace 批量更新抽屉 | +| DndDrag / Draggable | @fastgpt/web/components/common/DndDrag | ToolProvider 拖拽排序 | +| MyMenu | @fastgpt/web/components/common/MyMenu | ToolProvider 添加资源下拉菜单 | +| EmptyTip | @fastgpt/web/components/common/EmptyTip | ToolProvider + ToolkitMarketplace 空状态提示 | +| MyIcon | @fastgpt/web/components/common/Icon | 通用图标组件 | diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..299691484e59 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/API\347\264\242\345\274\225.md" @@ -0,0 +1,22 @@ +--- +capability_label: 页面未找到 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - /404 +--- + +# 页面未找到 — API索引 + +## 说明 + +本模块未检测到独立的 API 定义文件。模块行为如下: + +- 页面不发起任何 HTTP 请求 +- 组件挂载后仅执行客户端路由跳转(`router.push('/dashboard/agent')`) +- 不存在 API 调用链 + +本模块为纯路由跳转型兜底页面,无需 API 索引。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..a9a476620846 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\241\265\351\235\242\346\234\252\346\211\276\345\210\260/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,22 @@ +--- +capability_label: 页面未找到 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T18:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - /404 +--- + +# 页面未找到 — 组件列表 + +## pages/ + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/404.tsx` | 404 兜底页面,挂载后自动重定向至工作台首页 | 内部 | 否 | 无 Props(无参函数组件) | + +## 消费的外部组件 + +本模块不消费任何外部组件。`NonePage` 为自包含函数组件,仅依赖 React 内置 Hook(`useEffect`)和 Next.js 路由 Hook(`useRouter`),返回空 `
`,不引用任何自定义组件。 diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" new file mode 100644 index 000000000000..da60ae2db355 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/API\347\264\242\345\274\225.md" @@ -0,0 +1,19 @@ +--- +capability_label: 首页 +doc_type: "12" +doc_label: API索引 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - / +--- + +# 首页 — API索引 + +## 说明 + +本模块未检测到独立的 API 调用。首页为纯路由重定向页面,组件挂载后仅执行 `router.push('/dashboard/agent')`,不发起任何 HTTP 请求。 + +API 请求由目标页面(应用工作台 `/dashboard/agent`)在其自身生命周期中发起。相关 API 请参见: +- [工作台 — API索引](../应用工作台/API索引.md)(如已生成) diff --git "a/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" new file mode 100644 index 000000000000..6171be0aaa38 --- /dev/null +++ "b/doc/kb/\346\212\200\346\234\257\347\237\245\350\257\206\345\272\223/\351\246\226\351\241\265/\347\273\204\344\273\266\345\210\227\350\241\250.md" @@ -0,0 +1,30 @@ +--- +capability_label: 首页 +doc_type: "13" +doc_label: 组件列表 +generated_at: "2026-06-18T00:00:00.000Z" +parent_module: null +roles: [] +router_paths: + - / +--- + +# 首页 — 组件列表 + +## 页面组件 + +| 组件路径 | 用途 | 跨模块依赖 | 是否共享 | Props 摘要 | +|---------|------|-----------|---------|-----------| +| `projects/app/src/pages/index.tsx` | 首页入口,执行路由重定向至应用工作台 | 内部 | 否 | 无(Next.js 页面组件,由路由框架挂载) | + +## 消费的外部组件 + +| 本模块使用组件 | 导入路径 | 源模块 | +|--------------|---------|--------| +| `Loading` | `@fastgpt/web/components/common/MyLoading` | 共享组件库(packages/web) | + +--- + +## 说明 + +首页仅包含一个页面组件(`index.tsx`)和一个外部共享组件引用(`Loading`)。页面组件本身为 Next.js 页面级组件,不对外暴露、不被其他模块引用。`Loading` 组件为全屏加载动画,在重定向过程中短暂展示,确保用户不会看到空白页面。 diff --git "a/doc/kb/\346\234\254\345\234\260\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/doc/kb/\346\234\254\345\234\260\345\274\200\345\217\221\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..53f0536c030c --- /dev/null +++ "b/doc/kb/\346\234\254\345\234\260\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -0,0 +1,229 @@ +--- +capability_label: null +doc_type: "17" +doc_label: "本地开发指南" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 本地开发指南 + +> 本文档描述项目的本地开发环境搭建、启动方式以及 Mock 数据管理策略。 + +## 环境要求 + +| 依赖 | 版本要求 | 说明 | +|------|---------|------| +| Node.js | ≥20 | 运行主应用和大多数子项目 | +| pnpm | 9.x(推荐 9.15.9) | 包管理器,monorepo workspace 依赖 | +| Bun | ≥1.x | code-sandbox、mcp_server 子项目的运行时 | +| MongoDB | — | 主数据库 | +| Redis | — | 缓存与消息队列 | +| PostgreSQL / Milvus | — | 向量数据库(二选一或多选) | +| MinIO / S3 兼容存储 | — | 对象存储 | + +> **包管理器检测**:项目根目录存在 `pnpm-lock.yaml`,`package.json` 中 `devEngines.packageManager` 指定为 `pnpm@9.15.9`。根目录没有 `.nvmrc` 或 `.node-version` 文件。 + +## 快速启动 + +### 1. 安装依赖 + +```bash +pnpm install +``` + +项目使用 pnpm workspace monorepo 架构。`pnpm install` 会自动安装所有子包(`packages/*`、`projects/*`、`sdk/*`)的依赖,并执行 `postinstall` 脚本生成 Chakra UI 主题类型定义。 + +### 2. 配置环境变量 + +主要开发入口为 `projects/app`。复制模板文件创建本地配置: + +```bash +cp projects/app/.env.template projects/app/.env.local +``` + +**核心配置项说明**: + +| 配置项 | 说明 | +|--------|------| +| `MONGODB_URI` | MongoDB 连接串,本地开发建议加 `directConnection=true` | +| `REDIS_URL` 或 `REDIS_CLUSTER_NODES` | Redis 连接(单机或集群模式) | +| `PG_URL` / `MILVUS_ADDRESS` | 向量数据库连接(PG 优先于 OceanBase 优先于 Milvus) | +| `STORAGE_S3_ENDPOINT` | MinIO / S3 对象存储地址 | +| `PLUGIN_BASE_URL` | 插件服务地址 | +| `CODE_SANDBOX_URL` | 代码沙箱服务地址 | +| `AIPROXY_API_ENDPOINT` | AI 代理 API 地址 | +| `FE_DOMAIN` | 前端页面域名(默认 `http://localhost:3000`) | +| `FILE_DOMAIN` | 文件访问域名(通常与 `FE_DOMAIN` 相同) | + +完整配置项参考 `.env.template` 文件中的注释说明。 + +### 3. 启动开发服务器 + +**主应用**(Next.js 前端 + API): + +```bash +cd projects/app && pnpm dev +``` + +默认在 `http://localhost:3000` 启动。开发模式下使用 **Rspack** 构建(通过 `next-rspack`),如需切换为 Webpack 构建则设置环境变量 `WEBPACK=1`。 + +> `pnpm dev` 会先执行 `build:workers` 编译 Worker 线程脚本,然后启动 Next.js 开发服务器。 + +**代码沙箱服务**(Bun + Hono): + +```bash +cd projects/code-sandbox && pnpm dev +``` + +**MCP 服务器**: + +```bash +cd projects/mcp_server && bun dev +``` + +### 4. 代理与后端环境配置 + +本项目是 Next.js 全栈应用,前后端运行在同一进程中,**无需单独配置前端代理**。本地开发时需确保以下后端服务可访问: + +| 服务 | 默认地址 | 环境变量 | +|------|---------|---------| +| 插件服务 | `http://localhost:3004` | `PLUGIN_BASE_URL` | +| 代码沙箱 | `http://localhost:3002` | `CODE_SANDBOX_URL` | +| AI 代理 API | `http://localhost:3010` | `AIPROXY_API_ENDPOINT` | +| 对象存储(MinIO) | `http://localhost:9000` | `STORAGE_S3_ENDPOINT` | + +以上地址均在 `.env.local` 中配置,可根据实际部署环境调整。 + +**跨域说明**:`next.config.ts` 中不配置 `ALLOWED_ORIGINS` 时默认允许所有来源跨域请求。 + +## Mock 策略 + +### 方案说明 + +本项目**没有浏览器端的 Mock 系统**(不使用 MSW、vite-plugin-mock、json-server 等)。本地开发时直接连接真实后端服务,通过 `.env.local` 中的服务地址配置指向本地或开发环境的服务实例。 + +单元测试和集成测试中使用 **Vitest 的 `vi.mock()`** API 对服务端模块进行 Mock,Mock 文件统一存放在 `test/mocks/` 目录下。 + +### Mock 目录结构(测试用) + +``` +test/mocks/ +├── index.ts # Mock 入口,集中导入所有 mock 模块 +├── request.ts # NextEntry / parseHeaderCert / authCert 等请求层 mock +├── common/ # 基础设施 mock +│ ├── mongo.ts # MongoDB 连接 mock +│ ├── redis.ts # Redis 连接 mock +│ ├── bullmq.ts # 消息队列 mock +│ ├── s3.ts # 对象存储 mock +│ ├── geo.ts # 地理位置 mock +│ ├── vector.ts # 向量数据库 mock +│ ├── system.ts # 系统配置 mock +│ ├── tracks.ts # 埋点追踪 mock +│ ├── log.ts # 日志 mock +│ └── response.ts # 统一响应格式 mock +├── core/ai/ # AI 核心 mock +│ ├── llm.ts # LLM 调用 mock +│ └── embedding.ts # 向量嵌入 mock +└── support/ # 业务支撑 mock + ├── audit/utils.ts # 审计工具 mock + └── user/auth/controller.ts # 用户认证控制器 mock +``` + +### 如何新增一个测试 Mock + +1. 在 `test/mocks/` 对应子目录下创建 `.ts` 文件 +2. 使用 `vi.mock()` 定义模块替身,通过 `importOriginal` 保留原始导出的非 Mock 部分: + +```typescript +// test/mocks/common/my_service.ts +import { vi } from 'vitest'; + +vi.mock('@fastgpt/service/xxx/myService', async (importOriginal) => { + const mod = (await importOriginal()) as any; + const myFunc = vi.fn(async (params) => { + return { code: 200, data: { /* mock 数据 */ } }; + }); + return { ...mod, myFunc }; +}); +``` + +3. 在 `test/mocks/index.ts` 中添加一行 `import` 注册: + +```typescript +import './common/my_service'; +``` + +4. 在测试文件中通过 `beforeAll` 或 setup 文件引入 `test/mocks/index.ts`,Vitest 的 hoisting 机制会自动将 `vi.mock()` 提升到模块加载之前执行 + +### Mock 数据编写规范 + +- **响应格式**:API 响应统一使用 `{ code: 200, data: ... }` 结构(由 `request.ts` 中 mock 的 `NextEntry` 包装) +- **权限认证**:通过 `req.auth` 字段注入 mock 用户身份(含 `userId`、`teamId`、`tmbId`、`isRoot` 等),`parseHeaderCert` mock 会从请求中提取该字段 +- **数据真实性**:mock 数据应尽量模拟真实数据的字段类型和结构,避免测试假阳性 + +## 本地调试技巧 + +### 浏览器 DevTools + +- **React DevTools**:项目使用 React 18,建议安装 React Developer Tools 浏览器扩展 +- **Source Map**:开发模式下 webpack 使用 `cheap-module-source-map` 策略,可在浏览器 Sources 面板中调试源码 + +### 构建工具说明 + +| 模式 | 构建工具 | 说明 | +|------|---------|------| +| 开发模式 | Rspack(默认) | 冷启动和 HMR 更快 | +| 开发模式 | Webpack | 设置 `WEBPACK=1` 环境变量切换 | +| 生产构建 | Webpack | `next build --webpack` | + +### 分析构建产物 + +```bash +# 启用 Bundle Analyzer +cd projects/app && ANALYZE=true pnpm build +``` + +构建完成后自动打开分析报告页面,可查看各模块体积分布。 + +### 类型检查 + +```bash +# 根目录检查所有 TS 文件 +pnpm typecheck + +# 仅检查 app 项目 +cd projects/app && pnpm typecheck +``` + +### 常用测试命令 + +```bash +# 运行所有测试 +pnpm test + +# 运行指定测试文件 +pnpm test test/cases/xxx.test.ts + +# 运行向量数据库集成测试 +pnpm test:vector +``` + +### 国际化检查 + +```bash +# 检查三语种词条同步状态 +cd projects/app && pnpm check-i18n +``` + +### 图标管理 + +```bash +# 初始化图标资源 +pnpm initIcon + +# 预览所有可用图标 +pnpm previewIcon +``` diff --git "a/doc/kb/\346\265\213\350\257\225\347\237\245\350\257\206\345\272\223/\345\215\225\345\205\203\346\265\213\350\257\225.md" "b/doc/kb/\346\265\213\350\257\225\347\237\245\350\257\206\345\272\223/\345\215\225\345\205\203\346\265\213\350\257\225.md" new file mode 100644 index 000000000000..a2ecf5a94ff8 --- /dev/null +++ "b/doc/kb/\346\265\213\350\257\225\347\237\245\350\257\206\345\272\223/\345\215\225\345\205\203\346\265\213\350\257\225.md" @@ -0,0 +1,1085 @@ +# 单元测试 + +## 一、测试框架概述 + +FastGPT 使用 **Vitest** 作为测试框架,这是一个基于 Vite 的高性能单元测试工具,具有与 Jest 兼容的 API。 + +### 测试框架优势 +- **快速执行**:充分利用 Vite 的 ESM 模块加载优化 +- **开发友好**:支持热更新(watch mode),开发过程中快速反馈 +- **完整兼容**:APIs 与 Jest 完全兼容,支持 describe/it/expect 等常用断言 +- **多环境支持**:支持 Node.js、浏览器环境测试 + +### 测试文件位置 + +| 位置 | 说明 | +|------|------| +| `FastGPT/test/cases/` | 全局共享代码(packages/)的单元测试 | +| `FastGPT/projects/app/test/` | projects/app 项目特定的单元测试 | +| `FastGPT/projects/code-sandbox/test/` | code-sandbox 项目单元测试 | + + +### 覆盖率报告 + +- **生成位置**:`FastGPT/coverage/` +- **报告格式**:HTML、JSON-Summary、JSON +- **生成时机**:每次运行测试时自动生成(除非 cleanOnRerun=false) + +--- + +## 二、测试运行命令 + +### 全局命令(根目录执行) + +| 命令 | 说明 | +|------|------| +| `pnpm test` | 运行所有测试(包括 test/、projects/*/test/ 中的所有测试) | +| `pnpm test {file-path}` | 运行指定路径的测试文件(支持 glob 模式) | + +#### 命令示例 + +```bash +# 运行所有测试 +pnpm test + +# 运行特定目录的测试 +pnpm test test/cases/global/common/string + +# 运行特定测试文件 +pnpm test test/cases/global/common/string/string.test.ts + +# 运行匹配 pattern 的测试 +pnpm test pricing + +# Watch 模式(开发时使用,自动重新运行) +pnpm test --watch +``` + +### 子项目命令 + +#### projects/code-sandbox/ + +```bash +cd projects/code-sandbox + +# 运行所有测试 +pnpm test + +# Watch 模式 +pnpm test:watch +``` + +#### projects/app/ + +projects/app 没有独立的 test 脚本。其测试文件通过根 `pnpm test` 命令执行,测试文件位于 `projects/app/test/` 目录下。 + +#### packages/diting-rag-ts/ + +该包有独立的 vitest.config.ts,测试需在其目录内独立执行: + +```bash +cd packages/diting-rag-ts +pnpm test +``` + +--- + +## 三、测试文件结构 + +### 3.1 测试目录组织 + +``` +FastGPT/test/ +├── cases/ # 所有测试用例目录 +│ ├── global/ # packages/ 下代码的测试 +│ │ ├── common/ # 通用函数测试 +│ │ │ ├── error/ # 错误处理相关 +│ │ │ ├── file/ # 文件处理相关 +│ │ │ ├── math/ # 数学工具相关 +│ │ │ ├── string/ # 字符串处理相关 +│ │ │ ├── system/ # 系统工具相关 +│ │ │ └── ... +│ │ ├── core/ # 核心功能测试 +│ │ │ ├── ai/ # AI 相关(定价、提示词等) +│ │ │ ├── app/ # 应用相关(评估、工具等) +│ │ │ ├── chat/ # 聊天相关 +│ │ │ ├── dataset/ # 数据集相关 +│ │ │ ├── workflow/ # 工作流相关 +│ │ │ └── ... +│ │ └── support/ # 支持功能测试(权限、用户等) +│ ├── pages/ # projects/app/src/pages/api 的测试 +│ │ └── api/ # API 路由测试 +│ │ └── core/ +│ │ ├── evaluation/ # 评估相关 API +│ │ └── ... +│ ├── service/ # projects/app/src/service 的测试 +│ └── integrationTest/ # 集成测试 +│ └── vectorDB/ # 向量数据库集成测试 +├── datas/ # 测试数据和初始化工具 +│ └── users.ts # 用户创建和权限测试数据 +├── mocks/ # Mock 定义 +│ ├── common/ # 通用 mock(MongoDB、Redis、S3 等) +│ ├── core/ # 核心模块 mock +│ ├── support/ # 支持模块 mock +│ ├── index.ts # Mock 索引文件 +│ └── request.ts # 请求 mock 工具 +├── utils/ # 测试工具函数 +│ ├── request.ts # API 调用测试辅助函数(Call、StreamCall) +│ └── env.ts # 环境配置加载 +├── setup.ts # 每个测试文件执行前的设置 +├── globalSetup.ts # 全局测试环境设置(MongoDB 初始化) +└── setupModels.ts # 初始化数据库模型 + +projects/code-sandbox/test/ +├── unit/ # 单元测试 +├── compat/ # 兼容性测试(旧 JS/Python) +├── integration/ # 集成测试 +└── benchmark/ # 性能基准测试 +``` + +### 3.2 现有测试文件统计 + +#### 全局 packages 代码的测试(FastGPT/test/cases/) + +**按类别统计:** + +- **common(通用函数)**:约 20+ 测试文件 + - error/:S3 错误、工具函数 + - file/:图标处理、文件工具、工具函数 + - i18n/:国际化工具 + - math/:日期、数学工具 + - string/:字符串、文本分割、时间、密码等 + - system/:系统工具 + - type/:MongoDB 类型转换 + - secret/:密钥处理 + - parentFolder/:父文件夹工具 + +- **core(核心功能)**:35 个测试文件 + - ai/:模型定价、LLM 工具函数、提示词处理、AI 提供商配置 + - app/:应用评估、表单编辑、JSON Schema、应用日志、工具处理等 + - chat/:聊天适配、聊天工具、常量、收藏、辅助机器人、聊天设置 + - dataset/:数据集搜索、数据集工具、API 数据集、集合处理、训练等 + - workflow/:工作流调度、工作流运行时、工作流工具等 + +- **support(支持功能)**:12 个测试文件 + - permission/:权限控制器、聊天权限、应用权限、数据集权限等 + - user/:用户工具、团队管理、组织常量 + - wallet/:账单工具、使用量工具 + - customDomain/:自定义域名 + +#### API 路由的测试(FastGPT/test/cases/pages/api/) + +**按功能分类:** + +- **evaluation/(评估系统)**:约 30+ 测试 + - 数据集集合(创建、更新、删除、列表) + - 评估数据(创建、更新、删除、列表、质量评估、导入) + - 评估指标(创建、更新、删除、详情、列表、调试) + - 评估总结(创建、详情、配置等) + - 评估任务(创建、删除、详情、列表、统计、启动等) + +#### 项目特定的测试 + +- **projects/app/test/**:API 集成测试 + - agentSkills/:Agent 技能调试相关 + +- **projects/code-sandbox/test/**:代码执行环境测试 + - unit/:单元测试 + - compat/:旧 JS 和 Python 兼容性测试 + - integration/:API 集成测试 + - benchmark/:性能基准测试 + +--- + +## 四、编写测试规范 + +### 4.1 测试文件命名与位置 + +#### 命名约定 + +- **命名后缀**:`.test.ts` 或 `.spec.ts` +- **推荐**:使用 `.test.ts` 作为标准命名 +- **文件名**:应与被测试的源文件相同或反映测试功能 + +#### 示例 + +``` +源文件:packages/global/common/string/tools.ts +测试文件:test/cases/global/common/string/tools.test.ts + +源文件:projects/app/src/pages/api/core/evaluation/dataset/collection/create.ts +测试文件:test/cases/pages/api/core/evaluation/dataset/collection/create.test.ts +``` + +#### 放置位置原则 + +1. **packages/ 代码的测试**:放在 `test/cases/global/` 下 +2. **projects/app/src/pages/api 的测试**:放在 `test/cases/pages/api/` 下 +3. **projects/app/src/service 的测试**:放在 `test/cases/service/` 下 +4. **项目特定代码**:放在 `projects/{name}/test/` 下 + +### 4.2 测试结构示例 + +#### 基础测试结构 + +```typescript +import { describe, it, expect } from 'vitest'; +import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; + +describe('parseFileExtensionFromUrl', () => { + it('should parse extension from simple URL', () => { + expect(parseFileExtensionFromUrl('http://example.com/file.pdf')).toBe('pdf'); + expect(parseFileExtensionFromUrl('https://example.com/document.docx')).toBe('docx'); + }); + + it('should parse extension from URL with query parameters', () => { + expect(parseFileExtensionFromUrl('http://example.com/file.pdf?download=true')).toBe('pdf'); + expect( + parseFileExtensionFromUrl('https://example.com/image.png?size=large&quality=high') + ).toBe('png'); + }); + + it('should return lowercase extension', () => { + expect(parseFileExtensionFromUrl('http://example.com/file.PDF')).toBe('pdf'); + expect(parseFileExtensionFromUrl('http://example.com/image.JPG')).toBe('jpg'); + }); + + it('should handle empty string input', () => { + expect(parseFileExtensionFromUrl('')).toBe(''); + }); +}); +``` + +**关键点:** +- 使用 `describe` 组织测试套件 +- 每个 `it` 块应测试单一功能 +- 清晰的测试描述,说明预期行为 +- 使用 `expect` 进行断言 + +#### 复杂业务逻辑测试(带 beforeEach、afterEach) + +```typescript +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { sanitizeModelPriceTiers, calculateModelPrice } from '@fastgpt/global/core/ai/pricing'; + +describe('calculateModelPrice', () => { + describe('price tier matching', () => { + it('should match price tier by input token range', () => { + const config = { + priceTiers: [ + { maxInputTokens: 30, inputPrice: 1, outputPrice: 2 }, + { maxInputTokens: 60, inputPrice: 3, outputPrice: 4 }, + { inputPrice: 5, outputPrice: 6 } + ] + }; + + // [0, 30K] → 第一梯度(左闭右闭) + expect(calculateModelPrice({ config, inputTokens: 20000 }).matchedTier).toMatchObject({ + minInputTokens: 0, + maxInputTokens: 30, + inputPrice: 1, + outputPrice: 2 + }); + + // (30K, 60K] → 第二梯度 + expect(calculateModelPrice({ config, inputTokens: 30001 }).matchedTier).toMatchObject({ + minInputTokens: 30, + maxInputTokens: 60, + inputPrice: 3, + outputPrice: 4 + }); + }); + + it('should handle edge cases at tier boundaries', () => { + // 精确边界测试代码 + }); + }); +}); +``` + +#### API 路由测试示例 + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { buildDebugRuntimeNodes } from '@/pages/api/core/agentSkills/debugChat'; +import { + FlowNodeTypeEnum, + FlowNodeInputTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { getUser } from '@test/datas/users'; +import { Call } from '@test/utils/request'; + +describe('buildDebugRuntimeNodes', () => { + const SKILL_ID = '507f1f77bcf86cd799439011'; + const MODEL = 'gpt-4o'; + const SYSTEM_PROMPT = 'You are a helpful assistant.'; + + it('should return exactly two nodes and one edge', () => { + const { runtimeNodes, runtimeEdges } = buildDebugRuntimeNodes( + SKILL_ID, + MODEL, + SYSTEM_PROMPT + ); + expect(runtimeNodes).toHaveLength(2); + expect(runtimeEdges).toHaveLength(1); + }); + + describe('start node (workflowStart)', () => { + it('should be the first node with correct type and isEntry=true', () => { + const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); + const startNode = runtimeNodes[0]; + + expect(startNode.nodeId).toBe('skill-debug-start'); + expect(startNode.flowNodeType).toBe(FlowNodeTypeEnum.workflowStart); + expect(startNode.isEntry).toBe(true); + expect(startNode.showStatus).toBe(false); + }); + }); +}); +``` + +#### Code Sandbox 项目的集成测试示例 + +```typescript +import { describe, it, expect, beforeAll } from 'vitest'; +import { app, poolReady } from '../../src/index'; +import { config } from '../../src/config'; + +describe('API Routes', () => { + beforeAll(async () => { + await poolReady; + }, 30000); + + it('GET /health 返回 200', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe('ok'); + }); + + it('POST /sandbox/js 正常执行', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: 'async function main(v) { return { hello: v.name } }', + variables: { name: 'world' } + }) + }); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.codeReturn.hello).toBe('world'); + }); + + it('POST /sandbox/js 安全拦截', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: 'async function main() { require("child_process"); return {} }', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toContain('not allowed'); + }); +}); +``` + +### 4.3 Mock 和 Stub 模式 + +#### 4.3.1 MongoDB Mock(全局设置) + +**位置**:`test/mocks/common/mongo.ts` + +**功能**: +- 每个测试创建唯一的隔离数据库(使用 randomUUID()) +- 自动在测试后清理数据库 +- 模拟 mongoSessionRun 以避免事务问题 + +```typescript +import { vi } from 'vitest'; +import { randomUUID } from 'crypto'; + +vi.mock(import('@fastgpt/service/common/mongo/init'), async (importOriginal: any) => { + const mod = await importOriginal(); + return { + ...mod, + connectMongo: async (props: { db: Mongoose; url: string; connectedCb?: () => void }) => { + const { db, url } = props; + // 为每个测试创建独立数据库 + await db.connect(url, { dbName: randomUUID(), retryWrites: false }); + await db.connection.db?.dropDatabase(); + } + }; +}); + +// 模拟 mongoSessionRun,避免测试中使用事务 +vi.mock(import('@fastgpt/service/common/mongo/sessionRun'), async () => { + return { + mongoSessionRun: vi.fn(async (fn) => { + return await fn(null as any); + }) + }; +}); +``` + +**在测试中使用:** + +```typescript +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; + +describe('User Creation', () => { + it('should create a user', async () => { + const user = await MongoUser.create({ + username: 'testuser', + password: 'password123' + }); + + expect(user._id).toBeDefined(); + expect(user.username).toBe('testuser'); + }); +}); +``` + +#### 4.3.2 常见 Mock(其他服务) + +**位置**:`test/mocks/common/` + +| Mock 文件 | 模拟对象 | 用途 | +|---------|--------|------| +| `mongo.ts` | MongoDB 连接 | 数据持久化隔离 | +| `redis.ts` | Redis 客户端 | 缓存、队列操作 | +| `bullmq.ts` | Bull MQ 队列 | 后台任务队列 | +| `s3.ts` | S3 文件存储 | 文件上传/下载 | +| `system.ts` | 系统环境变量 | 系统配置 | +| `vector.ts` | 向量数据库 | 向量搜索 | +| `log.ts` | 日志记录 | 日志输出 | +| `response.ts` | 响应工具 | 标准响应格式 | +| `geo.ts` | 地理定位 | 地理信息服务 | +| `tracks.ts` | 埋点追踪 | 埋点/追踪服务 | + +#### 4.3.3 Mock AI 调用 + +```typescript +import { vi } from 'vitest'; + +// Mock AI 提供商响应 +vi.mock('@fastgpt/service/core/ai/llm', () => ({ + callGPTAPI: vi.fn().mockResolvedValue({ + messages: [{ role: 'assistant', content: 'mocked response' }], + tokens: { promptTokens: 10, completionTokens: 5 } + }) +})); +``` + +#### 4.3.4 Spy 和 Mock 函数 + +```typescript +import { vi, expect } from 'vitest'; + +describe('Function Spying', () => { + it('should track function calls', () => { + const mockFn = vi.fn().mockReturnValue('mocked value'); + + const result = mockFn('arg1', 'arg2'); + + expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result).toBe('mocked value'); + }); + + it('should mock environment variables', () => { + vi.stubEnv('NODE_ENV', 'test'); + expect(process.env.NODE_ENV).toBe('test'); + }); +}); +``` + +### 4.4 测试数据和固件 + +#### 4.4.1 用户和权限测试数据 + +**位置**:`test/datas/users.ts` + +**提供的函数:** + +```typescript +// 获取根用户 +export async function getRootUser(): Promise + +// 获取普通用户 +export async function getUser(username: string, teamId?: string): Promise + +// 清理数据 +export function clean() +``` + +**使用示例:** + +```typescript +import { getUser, getRootUser, clean } from '@test/datas/users'; +import { describe, it, expect, afterEach } from 'vitest'; + +describe('User API', () => { + afterEach(() => { + clean(); // 每个测试后清理 + }); + + it('should create user and team', async () => { + const user = await getUser('testuser'); + expect(user.userId).toBeDefined(); + expect(user.teamId).toBeDefined(); + expect(user.authType).toBe(AuthUserTypeEnum.token); + }); + + it('should get root user with admin privileges', async () => { + const rootUser = await getRootUser(); + expect(rootUser.isRoot).toBe(true); + }); +}); +``` + +#### 4.4.2 API 请求测试工具 + +**位置**:`test/utils/request.ts` + +**函数签名:** + +```typescript +// 普通 API 调用 +export async function Call( + handler: NextApiHandler, + props?: MockReqType +): Promise<{ + code: number; + data: R; + error?: any; + raw?: any; +}> + +// 流式响应(如 CSV 下载) +export async function StreamCall( + handler: NextApiHandler, + props?: MockReqType +): Promise<{ + code: number; + data: R; + error?: any; + raw?: any; + chunks?: any[]; + headers?: Record; +}> +``` + +**使用示例:** + +```typescript +import { Call } from '@test/utils/request'; +import handler from '@/pages/api/user/profile'; + +describe('User Profile API', () => { + it('should get user profile', async () => { + const result = await Call(handler, { + query: { userId: '123' }, + headers: { + authorization: 'Bearer token123' + } + }); + + expect(result.code).toBe(200); + expect(result.data.profile).toBeDefined(); + }); +}); +``` + +--- + +## 五、测试环境配置 + +### 5.1 全局测试配置 + +**位置**:`FastGPT/vitest.config.mts` + +```typescript +export default defineConfig({ + resolve: { + alias: { + '@': resolve('projects/app/src'), + '@fastgpt': resolve('packages'), + '@test': resolve('test'), + 'diting-rag-ts': resolve('packages/diting-rag-ts/src') + } + }, + test: { + // 环境变量 + env: { + FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY ?? 'default-key' + }, + + // 覆盖率配置 + coverage: { + enabled: true, + reporter: ['html', 'json-summary', 'json'], + reportOnFailure: true, + all: false, + include: ['projects/app/**/*.ts', 'packages/**/*.ts'], + exclude: [ + '**/node_modules/**', + '**/*.spec.ts', + '**/*/*.d.ts', + '**/test/**', + '**/*.test.ts', + '**/*/constants.ts', + '**/*/*.const.ts', + '**/*/type.ts', + '**/*/types.ts', + '**/*/type/*', + '**/*/schema.ts', + '**/*/*.schema.ts', + 'packages/global/openapi/**/*', + 'packages/global/core/workflow/template/**/*' + ], + cleanOnRerun: false + }, + + // 执行配置 + outputFile: 'test-results.json', + setupFiles: 'test/setup.ts', // 每个测试文件执行前 + globalSetup: 'test/globalSetup.ts', // 全局设置(MongoDB 等) + fileParallelism: false, // 文件级:串行(避免 MongoDB 冲突) + maxConcurrency: 10, // 测试级:最多 10 个并发 + pool: 'threads', + testTimeout: 20000, + hookTimeout: 30000, + + // 测试文件模式 + include: [ + 'test/**/*.test.ts', + 'projects/app/test/**/*.test.ts', + 'projects/marketplace/test/**/*.test.ts' + ] + } +}); +``` + +### 5.2 每个测试文件的设置(setup.ts) + +**位置**:`FastGPT/test/setup.ts` + +**功能:** +- 导入所有 Mock 模块 +- 设置 NODE_ENV 为 test +- 加载向量数据库环境配置 +- 连接 MongoDB(main 和 log 数据库) +- 初始化全局变量和系统环境 +- 解析本地 .env.local 配置文件 +- 初始化 AI 模型映射(setupModels) +- 每个测试完成后自动清理数据库 + +```typescript +import './mocks'; +import { existsSync, readFileSync } from 'fs'; +import { connectMongo } from '@fastgpt/service/common/mongo/init'; +import { initGlobalVariables } from '@/service/common/system'; +import { afterAll, beforeAll, beforeEach, inject, onTestFinished, vi } from 'vitest'; +import setupModels from './setupModels'; +import { clean } from './datas/users'; +import { connectionLogMongo, connectionMongo } from '@fastgpt/service/common/mongo'; +import { loadVectorDBEnv } from './utils/env'; + +vi.stubEnv('NODE_ENV', 'test'); +loadVectorDBEnv({ envFileNames: ['.env.test.local'] }); + +beforeAll(async () => { + vi.stubEnv('MONGODB_URI', inject('MONGODB_URI')); + await connectMongo({ db: connectionMongo, url: inject('MONGODB_URI') }); + await connectMongo({ db: connectionLogMongo, url: inject('MONGODB_URI') }); + + initGlobalVariables(); + global.systemEnv = {} as any; + + // 加载本地 .env.local 配置 + if (existsSync('projects/app/.env.local')) { + const str = readFileSync('projects/app/.env.local', 'utf-8'); + const lines = str.split('\n'); + const systemEnv: Record = {}; + for (const line of lines) { + const [key, value] = line.split('='); + if (key && value && !key.startsWith('#')) { + systemEnv[key] = value; + } + } + global.systemEnv.oneapiUrl = systemEnv['OPENAI_BASE_URL']; + global.systemEnv.chatApiKey = systemEnv['CHAT_API_KEY']; + } + global.feConfigs = { isPlus: false } as any; + + await setupModels(); +}); + +afterAll(async () => { + await connectionMongo?.connection.db?.dropDatabase(); + await connectionLogMongo?.connection.db?.dropDatabase(); +}); + +beforeEach(async () => { + onTestFinished(async () => { + clean(); + try { + await Promise.all([ + connectionMongo?.connection.db?.dropDatabase(), + connectionLogMongo?.connection.db?.dropDatabase() + ]); + } catch (error) { + console.warn('Error during test cleanup:', error); + } + }); +}); +``` + +### 5.3 全局测试设置(globalSetup.ts) + +**位置**:`FastGPT/test/globalSetup.ts` + +**功能:** +- 配置 MongoDB 连接(本地开发或 CI/CD) +- 支持 mongodb-memory-server(内存数据库)或外部 MongoDB URI + +```typescript +export default async function setup(project: TestProject) { + const baseUri = process.env.MONGODB_TEST_URI; + + if (baseUri) { + // 使用外部 MongoDB(CI/CD 或本地开发) + project.provide('MONGODB_URI', baseUri); + return async () => {}; + } + + // Fallback:使用内存数据库 + const { MongoMemoryReplSet } = await import('mongodb-memory-server'); + const replset = await MongoMemoryReplSet.create({ replSet: { count: 1 } }); + const uri = replset.getUri(); + project.provide('MONGODB_URI', uri); + + return async () => { + await replset.stop(); + }; +} +``` + +### 5.4 环境变量配置 + +#### 必需的环境变量 + +| 变量名 | 说明 | 默认值 | 来源 | +|------|------|------|------| +| `MONGODB_TEST_URI` | MongoDB 测试连接串 | 自动使用 mongodb-memory-server | globalSetup.ts | +| `NODE_ENV` | 环境标识 | `test` | setup.ts | +| `FILE_TOKEN_KEY` | 文件下载令牌密钥 | `bfd697e7...` | vitest.config.mts | +| `OPENAI_BASE_URL` | OpenAI API 地址 | - | .env.local(可选) | +| `CHAT_API_KEY` | Chat API 密钥 | - | .env.local(可选) | + +#### 本地开发环境配置 + +**Docker MongoDB 示例:** + +```bash +# .env.test.local(可选) +MONGODB_TEST_URI=mongodb://myusername:mypassword@127.0.0.1:57017/?authSource=admin&replicaSet=rs0 +``` + +**前置条件:** +- `/etc/hosts` 中需要配置:`127.0.0.1 mongo` +- 副本集成员 hostname "mongo:27017" 可以正确解析 + +### 5.5 全局 AI 模型映射初始化 + +**位置**:`test/setupModels.ts` + +**功能:**初始化全局 AI 模型映射(llmModelMap、embeddingModelMap)和默认模型配置(systemDefaultModel、systemModelList),并非初始化 Mongoose 数据模型。 + +```typescript +// setupModels 会初始化以下全局变量: +// - global.llmModelMap(LLM 模型映射表) +// - global.embeddingModelMap(嵌入模型映射表) +// - global.systemDefaultModel(系统默认 LLM 和 Embedding 模型配置) +// - global.systemModelList(系统可用模型列表) +``` + +--- + +## 六、CI/CD 中的测试 + +### 6.1 GitHub Actions 工作流 + +**检查路径**:`.github/workflows/test-fastgpt.yaml` + +该工作流在以下情况触发: +- 手动触发(workflow_dispatch) +- 推送到标签(tags: v*) + +### 6.2 测试执行流程(如果配置) + +```yaml +# 示例:在 CI 中运行测试 +- name: Run Tests + run: pnpm test + +- name: Generate Coverage Report + run: pnpm test && ls -la coverage/ + +- name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage/coverage-final.json +``` + +### 6.3 代码沙箱项目的 CI(projects/code-sandbox/) + +代码沙箱项目有独立的构建流程: + +**路径**:`.github/workflows/test-sandbox.yaml` + +--- + +## 七、最佳实践 + +### 7.1 编写高质量测试 + +#### 1. 遵循 AAA 模式(Arrange-Act-Assert) + +```typescript +it('should calculate total price correctly', () => { + // Arrange: 准备测试数据 + const config = { + priceTiers: [{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 }] + }; + + // Act: 执行被测试的函数 + const { totalPoints } = calculateModelPrice({ + config, + inputTokens: 20000, + outputTokens: 10000 + }); + + // Assert: 验证结果 + expect(totalPoints).toBe(30); // 20 * 1 + 10 * 2 +}); +``` + +#### 2. 每个测试只测试一个功能 + +```typescript +// 不好 ❌ +it('should handle user creation and permissions', () => { + const user = await createUser('john'); + expect(user._id).toBeDefined(); + + const perm = await grantPermission(user._id, 'read'); + expect(perm.granted).toBe(true); +}); + +// 好 ✓ +it('should create a user', async () => { + const user = await createUser('john'); + expect(user._id).toBeDefined(); +}); + +it('should grant permission to user', async () => { + const user = await createUser('john'); + const perm = await grantPermission(user._id, 'read'); + expect(perm.granted).toBe(true); +}); +``` + +#### 3. 使用有意义的测试描述 + +```typescript +// 不好 ❌ +it('test 1', () => { ... }); +it('test pricing', () => { ... }); + +// 好 ✓ +it('should match price tier when input tokens fall within tier range', () => { ... }); +it('should apply the highest tier price for tokens exceeding all tier boundaries', () => { ... }); +``` + +#### 4. 避免测试实现细节,测试行为 + +```typescript +// 不好 ❌ - 测试实现细节 +it('should call validatePrice and calculatePrice functions', () => { + const spy1 = vi.spyOn(priceModule, 'validatePrice'); + const spy2 = vi.spyOn(priceModule, 'calculatePrice'); + getPrice(config); + expect(spy1).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); +}); + +// 好 ✓ - 测试行为 +it('should return correct price for given config', () => { + const price = getPrice(config); + expect(price).toBe(expectedPrice); +}); +``` + +### 7.2 测试覆盖率指导 + +- **目标覆盖率**:60-80%(核心业务代码优先) +- **检查路径**:`coverage/index.html`(本地查看报告) +- **关键模块优先**:优先覆盖 AI、权限、数据集等核心模块 +- **排除项**:自动排除类型定义、常量、测试文件本身 + +### 7.3 调试测试 + +```bash +# 以 watch 模式运行,修改后自动重新执行 +pnpm test --watch + +# 运行单个测试文件并显示详细输出 +pnpm test test/cases/global/core/ai/pricing.test.ts --reporter=verbose + +# 匹配特定测试描述 +pnpm test --grep "price tier matching" +``` + +### 7.4 性能优化 + +- **文件级并行**:disabled(避免 MongoDB 冲突) +- **测试级并发**:最多 10 个(可在 vitest.config.mts 中调整) +- **Database 隔离**:每个测试使用独立数据库,自动清理 +- **Mock 外部服务**:减少 I/O 等待 + +--- + +## 八、常见问题和排障 + +### Q1: MongoDB 连接失败 + +**错误**:`Error: MongoDB connection failed` + +**原因**: +- 本地未安装 MongoDB 且 mongodb-memory-server 下载失败 +- `MONGODB_TEST_URI` 环境变量配置错误 + +**解决**: +```bash +# 方案 1:设置外部 MongoDB +export MONGODB_TEST_URI="mongodb://localhost:27017" + +# 方案 2:检查网络(mongodb-memory-server 需要下载二进制) +# 将文件提前下载到本地或使用代理 + +# 方案 3:在 Docker 中运行测试 +docker run -it --rm -v $(pwd):/app node:20 bash +cd /app && pnpm install && pnpm test +``` + +### Q2: 测试超时 + +**错误**:`Timeout waiting for test to finish` + +**原因**: +- 异步操作未正确等待 +- 数据库操作过慢 +- 外部 API 响应缓慢 + +**解决**: +```typescript +// 增加超时时间 +it('should process large dataset', async () => { + // 测试代码 +}, 60000); // 60 秒超时 + +// 或在配置中调整全局超时 +// vitest.config.mts: testTimeout: 30000 +``` + +### Q3: Mock 不生效 + +**错误**:实际调用了真实的 MongoDB/Redis 而不是 mock + +**原因**: +- Mock 导入路径不匹配 +- Mock 在被导入之前定义 +- 模块缓存问题 + +**解决**: +```typescript +// 确保 mock 在测试文件最开始导入 +import './mocks'; // 这行必须在最前面 +import { describe, it, expect } from 'vitest'; + +// 或在 test/setup.ts 中统一导入所有 mock +import './mocks/index.ts'; +``` + +### Q4: 数据库污染(一个测试影响另一个) + +**错误**:测试顺序改变时结果不同 + +**原因**: +- 未在 afterEach 中清理数据 +- 多个测试共享数据库 + +**解决**: +```typescript +import { afterEach } from 'vitest'; +import { clean } from '@test/datas/users'; + +afterEach(async () => { + clean(); // 清理每个测试创建的数据 +}); +``` + +### Q5: TypeScript 类型错误 + +**错误**:`Cannot find module '@test/utils/request'` + +**原因**:vitest.config.mts 中的路径别名未正确配置 + +**解决**:确保 vitest.config.mts 中有: +```typescript +alias: { + '@test': resolve('test'), + '@': resolve('projects/app/src'), + '@fastgpt': resolve('packages') +} +``` + +--- + +## 九、参考资源 + +- **Vitest 官方文档**:https://vitest.dev/ +- **Vitest API 参考**:https://vitest.dev/api/ +- **期望匹配器**:https://vitest.dev/api/expect.html +- **项目 CLAUDE.md**:FastGPT/.claude/CLAUDE.md +- **测试覆盖率报告**:FastGPT/coverage/index.html(运行后生成) + +--- + +## 十、测试文件索引 + +### 核心业务测试 + +- **AI 定价**:`FastGPT/test/cases/global/core/ai/pricing.test.ts` +- **工作流调度**:`FastGPT/test/cases/global/core/workflow/dispatch/benchmark.test.ts` +- **权限控制**:`FastGPT/test/cases/global/support/permission/`(多个文件) +- **聊天功能**:`FastGPT/test/cases/global/core/chat/`(多个文件) + +### 工具函数测试 + +- **字符串处理**:`FastGPT/test/cases/global/common/string/` +- **日期时间**:`FastGPT/test/cases/global/common/math/date.test.ts` +- **文件处理**:`FastGPT/test/cases/global/common/file/` + +### API 集成测试 + +- **Agent 技能**:`FastGPT/projects/app/test/api/core/agentSkills/` +- **评估系统**:`FastGPT/test/cases/pages/api/core/evaluation/` +- **代码沙箱**:`FastGPT/projects/code-sandbox/test/integration/api.test.ts` + diff --git "a/doc/kb/\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225.md" "b/doc/kb/\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225.md" new file mode 100644 index 000000000000..9fe6e46815f5 --- /dev/null +++ "b/doc/kb/\350\207\252\345\212\250\345\214\226\346\265\213\350\257\225.md" @@ -0,0 +1,141 @@ +--- +capability_label: null +doc_type: "05" +doc_label: "自动化测试" +generated_at: "2026-06-18T12:00:00Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 自动化测试 + +> 本文档描述项目的测试框架、规范和运行方式。 + +## 测试框架 + +| 类别 | 框架 | 版本 | 说明 | +|------|------|------|------| +| 单元测试 | Vitest | 3.0.9 | 组件和工具函数测试 | +| 覆盖率 | @vitest/coverage-v8 | 3.0.9 | 基于 V8 的代码覆盖率统计 | +| 数据库 Mock | mongodb-memory-server | 10.1.4 | 测试用内存 MongoDB 实例 | + +## 覆盖率要求 + +> 未发现覆盖率阈值配置(coverageThreshold),当前仅生成覆盖率报告不作拦截。 + +覆盖率采集范围: + +| 配置项 | 主项目 (vitest.config.mts) | code-sandbox | +|--------|---------------------------|-------------| +| 包含范围 | `projects/app/**/*.ts`, `packages/**/*.ts` | `src/**/*.ts` | +| 报告格式 | html, json-summary, json | text, text-summary, html, json-summary, json | +| 排除项 | node_modules, *.d.ts, test/, constants, types, schema, openapi, workflow template | — | + +## 运行命令 + +| 命令 | 用途 | 所属项目 | +|------|------|---------| +| `pnpm test` | 运行全部单元测试 | 根目录 | +| `pnpm test:vector` | 运行向量数据库集成测试 | 根目录 | +| `cd projects/code-sandbox && pnpm test` | 运行沙箱服务测试 | code-sandbox | +| `cd projects/code-sandbox && pnpm test:watch` | 沙箱服务监听模式 | code-sandbox | + +## 编写规范 + +### 文件组织 + +| 约定 | 说明 | +|------|------| +| 文件命名 | `*.test.ts`(不使用 `*.spec.ts`) | +| 目录位置 | 集中式 `test/` 目录,按模块分层:`test/cases/global/`、`test/cases/service/`、`test/cases/pages/api/` | +| describe 层级 | 最多 2-3 层,按「模块 → 功能 → 场景」组织 | + +测试目录结构: + +``` +test/ +├── setup.ts # 测试启动配置(MongoDB 连接、全局变量初始化) +├── globalSetup.ts # 全局前置任务 +├── mocks/ # 全局 Mock 模块 +├── datas/ # 测试数据工具 +├── utils/ # 测试辅助工具 +├── cases/ +│ ├── global/ # packages/global 包测试 +│ │ ├── common/ # 通用工具(string, file, math, time 等) +│ │ ├── core/ # 核心逻辑(ai, chat, dataset, workflow 等) +│ │ └── support/ # 支撑功能(permission, user, wallet 等) +│ ├── service/ # packages/service 包测试 +│ │ └── common/ # 公共服务(api, cache, file 等) +│ └── pages/api/ # API 路由集成测试 +│ └── core/ # 核心 API(evaluation 等) +├── integrationTest/ # 集成测试配置 +│ └── vectorDB/ # 向量数据库集成测试 +projects/ +├── app/test/ # 主应用级测试 +└── code-sandbox/test/ # 沙箱服务测试 + ├── unit/ # 单元测试(安全、边界、资源限制等) + ├── integration/ # 集成测试(API、功能测试) + └── compat/ # 兼容性测试(旧版 JS/Python) +``` + +### Mock 策略 + +项目使用 Vitest 的 `vi.mock` / `vi.mocked` 进行模块级 Mock: + +- **Mongoose 模型**:Mock Schema 的 `find`、`findOne` 等静态方法,返回带链式调用(`.sort().lean()`)的 Mock 对象 +- **权限鉴权**:Mock `authUserPer` 返回模拟用户鉴权结果,包含 `userId`、`teamId`、`tmbId`、`permission` 等字段 +- **数据库连接**:使用 `mongodb-memory-server` 提供内存 MongoDB,在 `beforeAll` 中连接、`afterAll` 中断开,`beforeEach` 中通过 `onTestFinished` 清理数据 +- **环境变量**:通过 `vi.stubEnv` 设置测试环境变量(如 `NODE_ENV=test`、`MONGODB_URI`) + +### 单测示例 + +```ts +// 实际文件: test/cases/global/common/string/utils.test.ts +import { describe, expect, it } from 'vitest'; +import { getTextValidLength, isObjectId } from '@fastgpt/global/common/string/utils'; + +describe('string utils', () => { + it('should calculate valid text length', () => { + expect(getTextValidLength('a b\nc\t d')).toBe(4); + expect(getTextValidLength(' \n ')).toBe(0); + }); + + it('should validate objectId strings', () => { + expect(isObjectId('507f1f77bcf86cd799439011')).toBe(true); + expect(isObjectId('507f1f77bcf86cd79943901')).toBe(false); + expect(isObjectId('507f1f77bcf86cd79943901z')).toBe(false); + }); +}); +``` + +### API 集成测试约定 + +API 路由测试直接调用 Next.js API handler 函数,Mock 其依赖的 Mongoose 模型和服务层: + +- **测试文件位置**:`test/cases/pages/api/`,按业务模块组织子目录 +- **鉴权 Mock**:Mock `authUserPer` 返回模拟鉴权结果,覆盖 owner / 非 owner / 鉴权失败等场景 +- **断言策略**:验证 handler 返回值结构 + 验证 Mock 函数调用参数(`toHaveBeenCalledWith`) +- **错误场景**:覆盖鉴权失败、数据库查询失败等异常路径 + +### 沙箱服务测试约定 + +code-sandbox 测试分为三类: + +- **unit/**:安全测试(模块拦截、逃逸攻击、网络安全、文件隔离)、边界测试、资源限制测试 +- **integration/**:API 端到端测试、功能集成测试 +- **compat/**:旧版 JS/Python 兼容性测试 + +沙箱测试使用真实的 `ProcessPool` / `PythonProcessPool` 实例,在 `beforeAll` 中初始化进程池。 + +### 测试配置要点 + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| fileParallelism | false | 文件级串行执行,避免 MongoDB 冲突 | +| maxConcurrency | 10 | 单文件内最多 10 个测试并发 | +| pool | threads | 使用线程池执行 | +| testTimeout | 20000ms | 单测超时时间 | +| hookTimeout | 30000ms | 钩子超时时间 | +| setupFiles | test/setup.ts | 测试启动文件 | +| globalSetup | test/globalSetup.ts | 全局前置任务 | diff --git "a/doc/kb/\350\267\257\347\224\261\346\250\241\345\236\213.md" "b/doc/kb/\350\267\257\347\224\261\346\250\241\345\236\213.md" new file mode 100644 index 000000000000..d3570e88b203 --- /dev/null +++ "b/doc/kb/\350\267\257\347\224\261\346\250\241\345\236\213.md" @@ -0,0 +1,273 @@ +--- +capability_label: null +doc_type: "03" +doc_label: "路由模型" +generated_at: "2026-06-18T12:00:00.000Z" +parent_module: null +roles: [] +router_paths: [] +--- + +# 路由模型 + +> 本文档描述项目的路由架构和路由设计约定,不逐条列出所有路由定义。 + +## 1. 路由架构 + +### 整体模式 + +**Next.js 文件系统路由**:基于 `pages/` 目录结构自动生成路由表,无独立路由配置文件。路由层级由目录嵌套关系决定,页面组件即路由入口。 + +``` +{_app.tsx 全局入口} +└── {NextHead} ← 全局 Meta 注入 +└── {QueryClientContext} ← React Query Provider +└── {SystemStoreContext} ← 全局系统配置 Store +└── {ChakraUIContext} ← UI 主题 +└── {Layout} ← 主布局(/openapi 除外) + ├── Auth 守卫层 ← 登录态检查 + ├── Navbar (PC) ← 左侧 64px 图标导航栏 + ├── NavbarPhone (移动端) ← 底部 50px Tab 栏 + └── 页面内容 +``` + +### 布局策略 + +Layout 根据 `router.pathname` 动态选择三种渲染模式: + +| 路由类别 | 匹配路径 | 布局行为 | +|----------|---------|---------| +| 无布局路由 | `/openapi` | 跳过 Layout,直接渲染页面 | +| 自定义 Head 路由 | `/chat`, `/chat/share`, `/app/detail`, `/dataset/detail` | 页面自行管理 ``,外层不注入 | +| Dashboard 类页面 | `/dashboard/*`, `/dataset/*`, `/account/*`, `/skill/*`, `/config/*`, `/app/detail` | Auth 包裹,无顶部导航栏(页面自行管理侧边栏) | +| 非标准布局路由 | `/`, `/login/*`, `/chat/share`, `/app/edit`, `/chat`, `/price` | Auth 直接包裹 children,无导航栏 | +| 标准布局(默认) | 其他 | Auth + 左侧 Navbar(PC)/ 底部 NavbarPhone(移动端) | + +### 页面路由清单 + +所有前端页面路由由 `projects/app/src/pages/` 目录结构定义: + +| 路由路径 | 页面文件 | 说明 | +|----------|---------|------| +| `/` | `pages/index.tsx` | 首页,自动跳转 `/dashboard/agent` | +| `/login` | `pages/login/index.tsx` | 登录页(密码/注册/忘记密码/微信) | +| `/login/provider` | `pages/login/provider.tsx` | OAuth/SSO 回调 | +| `/login/fastlogin` | `pages/login/fastlogin.tsx` | 快速登录 | +| `/chat` | `pages/chat/index.tsx` | Agent 对话页 | +| `/chat/share` | `pages/chat/share.tsx` | 分享对话页 | +| `/app/detail` | `pages/app/detail/index.tsx` | 应用详情/编辑/工作流编排 | +| `/dashboard/agent` | `pages/dashboard/agent/index.tsx` | 应用工作台 | +| `/dashboard/create` | `pages/dashboard/create/index.tsx` | 创建应用向导 | +| `/dashboard/evaluation` | `pages/dashboard/evaluation/index.tsx` | 评测首页 | +| `/dashboard/evaluation/create` | `pages/dashboard/evaluation/create.tsx` | 创建评测 | +| `/dashboard/evaluation/dataset` | `pages/dashboard/evaluation/dataset/index.tsx` | 评测数据集首页 | +| `/dashboard/evaluation/dataset/fileImport` | `pages/dashboard/evaluation/dataset/fileImport.tsx` | 文件导入 | +| `/dashboard/evaluation/dataset/detail` | `pages/dashboard/evaluation/dataset/detail/index.tsx` | 数据集详情 | +| `/dashboard/evaluation/dimension` | `pages/dashboard/evaluation/dimension/index.tsx` | 评测维度首页 | +| `/dashboard/evaluation/dimension/create` | `pages/dashboard/evaluation/dimension/create.tsx` | 创建维度 | +| `/dashboard/evaluation/dimension/edit` | `pages/dashboard/evaluation/dimension/edit.tsx` | 编辑维度 | +| `/dashboard/evaluation/task` | `pages/dashboard/evaluation/task/index.tsx` | 评测任务首页 | +| `/dashboard/evaluation/task/detail` | `pages/dashboard/evaluation/task/detail/index.tsx` | 任务详情 | +| `/dashboard/mcpServer` | `pages/dashboard/mcpServer/index.tsx` | MCP 服务器管理 | +| `/dashboard/skill` | `pages/dashboard/skill/index.tsx` | 技能管理 | +| `/dashboard/systemTool` | `pages/dashboard/systemTool/index.tsx` | 系统工具 | +| `/dashboard/templateMarket` | `pages/dashboard/templateMarket/index.tsx` | 模板市场 | +| `/dashboard/tool` | `pages/dashboard/tool/index.tsx` | 工具管理 | +| `/dataset/list` | `pages/dataset/list/index.tsx` | 知识库列表 | +| `/dataset/detail` | `pages/dataset/detail/index.tsx` | 知识库详情 | +| `/account/apikey` | `pages/account/apikey.tsx` | API 密钥 | +| `/account/inform` | `pages/account/inform.tsx` | 通知 | +| `/account/setting` | `pages/account/setting.tsx` | 个性化设置 | +| `/account/promotion` | `pages/account/promotion.tsx` | 推广 | +| `/account/bill` | `pages/account/bill/index.tsx` | 账单 | +| `/account/customDomain` | `pages/account/customDomain/index.tsx` | 自定义域名 | +| `/account/info` | `pages/account/info/index.tsx` | 账号信息 | +| `/account/model` | `pages/account/model/index.tsx` | 模型管理 | +| `/account/team` | `pages/account/team/index.tsx` | 团队管理 | +| `/account/thirdParty` | `pages/account/thirdParty/index.tsx` | 第三方集成 | +| `/account/usage` | `pages/account/usage/index.tsx` | 用量统计 | +| `/config/tool` | `pages/config/tool/index.tsx` | 工具配置首页 | +| `/config/tool/marketplace` | `pages/config/tool/marketplace.tsx` | 工具市场 | +| `/price` | `pages/price/index.tsx` | 价格页 | +| `/skill/detail` | `pages/skill/detail.tsx` | 技能详情 | +| `/openapi` | `pages/openapi.tsx` | OpenAPI 文档页 | +| `/404` | `pages/404.tsx` | 页面未找到 | + +### 核心路由文件 + +| 文件 | 职责 | +|------|------| +| `projects/app/src/pages/_app.tsx` | 应用入口:Provider 嵌套、Layout 挂载、全局 NextHead 注入 | +| `projects/app/src/pages/_document.tsx` | HTML 文档骨架 | +| `projects/app/src/components/Layout/index.tsx` | 主布局:根据路由选择导航模式、懒加载全局弹窗 | +| `projects/app/src/components/Layout/auth.tsx` | 前端路由守卫:白名单放行 + Token 验证 | +| `projects/app/src/components/Layout/navbar.tsx` | PC 端侧边导航栏(64px 图标栏) | +| `projects/app/src/components/Layout/navbarPhone.tsx` | 移动端底部导航栏(50px Tab 栏) | +| `projects/app/src/middleware.ts` | Next.js Middleware:仅开发环境 API 代理 | + +### 导航结构 + +PC 端侧边导航栏(`Navbar`)硬编码 4+1 个菜单项: + +| 导航项 | 默认链接 | 高亮路径覆盖 | +|--------|---------|-------------| +| Chat | `/chat?appId=...&pane=...` | `/chat` | +| Studio | `/dashboard/agent` | `/dashboard/*`, `/app/detail`, `/skill/detail`, `/dashboard/skill` | +| Datasets | `/dataset/list` | `/dataset/list`, `/dataset/detail` | +| Account | `/account/info` | `/account/*` | +| Plugin | `/config/tool` | `/config/tool`, `/config/tool/marketplace` | + +移动端底部导航栏(`NavbarPhone`)的 Studio 高亮路径与 PC 端有差异:不包含 `/dashboard/skill` 和 `/skill/detail`,但额外包含 `/dashboard/tool`、`/dashboard/systemTool`、`/dashboard/templateMarket`、`/dashboard/mcpServer` 及评测相关子路由。 + +Plugin 菜单项仅对 `username === 'root'` 用户显示。导航栏还支持从后端配置注入外部链接(`feConfigs.navbarItems`),以头像图标形式展示在导航栏底部。 + +Chat 菜单项通过 `window.open` 在新标签页打开,其他项使用 `router.push` 跳转。 + +## 2. 懒加载策略 + +| 策略 | 是否使用 | 说明 | +|------|---------|------| +| `next/dynamic` | 是 | 项目统一使用 `dynamic(() => import(...))` 进行组件级代码拆分 | +| `React.lazy` | 否 | 未使用,全部走 Next.js 的 `dynamic` | +| `{ ssr: false }` | 是 | 依赖浏览器 API 的组件(弹窗、模态框)禁用 SSR | +| `.then()` 具名导出 | 是 | 部分组件使用 `.then((mod) => mod.ComponentName)` 提取具名导出 | + +**全局弹窗懒加载**(Layout 中声明,全部 `ssr: false`): + +- `Navbar` / `NavbarPhone`(导航栏,默认 SSR) +- `NotSufficientModal`(余额不足弹窗) +- `SystemMsgModal`(系统消息弹窗) +- `ResetExpiredPswModal`(密码过期弹窗) +- `ImportantInform` / `UpdateContact`(重要通知/更新联系方式) +- `ActivityAdModal`(活动广告弹窗) +- `ManualCopyModal`(手动复制数据,使用 `.then()` 模式) + +**页面级组件懒加载**: + +各页面组件(如 `dataset/detail`、`dashboard/skill`、`dashboard/tool` 等)将内部的 Tab 面板、表单、列表等子组件通过 `dynamic()` 按需加载,实现页面内的代码拆分。 + +## 3. 角色权限架构 + +### 角色模型 + +FastGPT 使用**自建位域权限模型**(灵感来源于 Linux 文件权限位域),权限值是一个数字,每个 bit 代表一个权限或角色。 + +**基础角色**(`CommonRoleList`): + +| 角色 | 位值 | 权限映射 | +|------|------|---------| +| read | `0b100` (bit 2) | 读取 | +| write | `0b010` (bit 1) | 写入 + 读取 | +| manage | `0b001` (bit 0) | 管理 + 写入 + 读取 | + +**特殊角色**: + +| 角色 | 说明 | +|------|------| +| Owner(所有者) | 位值 `~0 >>> 0`(32 位全 1),资源创建者自动获得,拥有所有权限 | +| Null | 位值 `0`,无任何权限 | + +**权限模型**: 位域 RBAC(基于角色的访问控制),通过位运算实现权限判断。 + +### 资源类型与扩展角色 + +不同资源类型在基础角色之上扩展专属角色: + +| 资源 | 扩展角色 | +|------|---------| +| Team(团队) | `appCreate`, `datasetCreate`, `apikeyCreate`, `evaluationCreate`, `skillCreate`, `modelCreate` | +| App(应用) | `readChatLog`(查看聊天记录) | +| Dataset(知识库) | 无扩展,使用基础三角色 | +| Evaluation(评测) | 无扩展,使用基础三角色 | +| Model(模型) | 无扩展,使用基础三角色 | +| AgentSkill | 无扩展,使用基础三角色 | + +### 路由-角色绑定 + +| 绑定方式 | 是否使用 | 说明 | +|----------|---------|------| +| 路由文件按角色拆分 | 否 | 所有路由共用一套页面文件,无 admin/tenant 分文件 | +| 前端路由白名单 | 是 | `auth.tsx` 中 `unAuthPage` 对象定义免登录路由:`/`, `/login`, `/login/provider`, `/login/fastlogin`, `/login/sso`, `/appStore`, `/chat`, `/chat/share`, `/tools/price`, `/price` | +| 菜单项角色过滤 | 是 | `navbar.tsx` 中 `userInfo?.username === 'root'` 条件显示 Plugin 菜单 | +| 动态路由注册 | 否 | 全量路由注册,通过 Auth 组件做守卫拦截 | +| 组件级权限 | 是 | `MemberManager` 组件实现协作者权限管理,按角色展示不同操作入口 | + +### 权限判断流程 + +1. **角色获取**:用户登录后,通过 cookie 中的 token 调用 `/api/support/user/account/tokenLogin` 获取 `UserType`(含 `teamId`、`tmbId`、角色信息等),存入 Zustand `useUserStore` +2. **前端路由守卫**:Auth 组件检查当前路径是否在 `unAuthPage` 白名单中。白名单路由直接放行;非白名单路由调用 `initUserInfo()` 验证 token,验证失败则页面渲染 `null` +3. **API 请求拦截**:所有 API 请求通过 `responseError` 拦截器统一处理。若响应 `code` 属于 `TOKEN_ERROR_CODE`,调用 `clearToken()` 并 `window.location.replace` 跳转到 `/login?lastRoute=...` +4. **后端认证**:`parseHeaderCert()` 函数按优先级尝试三种认证方式——Authorization Bearer(API Key)、Cookie/Token(Session Token)、Root Key(环境变量 `ROOT_KEY`) +5. **业务鉴权**:各资源模块(app/dataset/evaluation 等)的 auth 函数查询 `MongoResourcePermission` 表,获取用户在该资源上的权限记录,通过 `Permission.checkPer()` 位与运算判断是否有目标权限 +6. **协作权限继承**:支持文件夹级权限继承——子资源可继承父文件夹的协作者权限,通过 BFS 遍历子树同步 +7. **菜单过滤**:前端导航菜单通过运行时条件判断控制可见性(如 root 用户可见 Plugin 菜单) + +## 4. 路由守卫逻辑 + +### 全局守卫流程 + +FastGPT 的路由守卫逻辑分布在前端 Auth 组件和 API 拦截器两个层面,中间件不做认证(仅开发环境代理)。 + +**前端 Auth 守卫**(`components/Layout/auth.tsx`): + +1. 读取当前 `router.pathname` +2. 检查是否命中 `unAuthPage` 白名单 → 命中则直接放行 +3. 未命中白名单 → 通过 React Query 调用 `initUserInfo()`(即 `getTokenLogin()`)验证 cookie 中的 token +4. `userInfo !== null` → 渲染页面内容 +5. `userInfo === null` → 渲染 `null`(空白页),后续由 API 层的 `responseError` 拦截器完成重定向 +6. 每 10 分钟自动刷新登录态(`refetchInterval: 10 * 60 * 1000`) + +**API 层拦截器**(`web/common/api/request.ts`): + +1. 响应拦截器 `responseError` 检查 `code` 字段 +2. Token 错误码 → 清除 token + `window.location.replace` 跳转到 `/login?lastRoute=<当前路径>` +3. 页面例外保护:`/chat/share`、`/price`、`/login` 为外链/登录页,Token 失效和余额不足时均不跳转、不弹窗。`/chat` 仅 Token 失效时受保护(不跳转,保持当前页面),但余额不足时仍会触发弹窗 +4. 余额不足错误 → 触发 `NotSufficientModal` 弹窗 + +**非权限类守卫逻辑**: + +| 逻辑 | 实现方式 | +|------|---------| +| 页面标题设置 | `_app.tsx` 中为大多数路由注入统一 ``(含 title/description/favicon);4 个自定义 Head 路由自行管理 | +| CSP 策略 | `` 中设置 `img-src * data: blob:` | +| Viewport | `` 中固定 `user-scalable=no, viewport-fit=cover` | +| 进度条 | 无(项目未使用 NProgress 等进度条库) | +| UTM 参数处理 | `useInitApp()` 中解析营销参数并存入 sessionStorage,随后清理 URL | + +## 5. 路由参数约定 + +| 约定项 | 说明 | +|--------|------| +| 路径参数 | 前端页面**不使用** Next.js 动态路由(无 `[id].tsx`),所有参数通过 query string 传递 | +| Query 参数 | 广泛使用:Tab/面板切换(如 `?invoiceTab=bill`、`?usageTab=dashboard`)、资源 ID(如 `?appId=xxx`、`?collectionId=xxx`)、分页筛选(如 `?parentId=xxx`)、登录回调(如 `?lastRoute=...`)、分享(如 `?shareId=xxx`) | +| `router.replace` | 用于筛选/参数变更场景,不增加浏览器历史记录(如 Tab 切换 `router.replace({ query: { ...router.query, type: 'new' } })`) | +| `router.push` | 用于页面跳转(如登录成功后跳转 `router.push(lastRoute)`) | +| API 动态路由 | API 层使用 Next.js 动态路由:`[...path].ts` 通配代理路由、`[token].ts` 外链出口路由、`[jwt].ts` JWT 文件访问、`[filename].ts` 文件读取 | + +### Query 参数约定 + +| 参数 | 使用场景 | 示例路由 | +|------|---------|---------| +| `appId` | 应用 ID | `/app/detail?appId=xxx`, `/chat?appId=xxx` | +| `pane` | 聊天面板切换 | `/chat?appId=xxx&pane=HOME` | +| `lastRoute` | 登录后跳转回原页面 | `/login?lastRoute=/dashboard/agent` | +| `shareId` | 分享对话 ID | `/chat/share?shareId=xxx` | +| `collectionId` | 知识库集合 ID | `/dataset/detail?collectionId=xxx` | +| `parentId` | 文件夹导航 | `/dataset/list?parentId=xxx` | +| `invoiceTab` | 账单/发票切换 | `/account/bill?invoiceTab=bill` | +| `usageTab` | 用量面板切换 | `/account/usage?usageTab=dashboard` | +| `teamTab` | 团队面板切换 | `/account/team?teamTab=member` | + +### 允许的页面布局自定义 + +每个页面组件可导出一个 `setLayout` 函数作为自定义布局: + +```ts +// 页面组件中导出 +export const setLayout = (page: React.ReactElement) => { + // 自定义布局包装 + return {page}; +}; +``` + +该函数在 `_app.tsx` 中被调用,替代默认的 children 渲染方式。 diff --git a/document/public/deploy/docker/cn/docker-compose.milvus.yml b/document/public/deploy/docker/cn/docker-compose.milvus.yml index 0fdd7cd477fe..9e983a0c0931 100644 --- a/document/public/deploy/docker/cn/docker-compose.milvus.yml +++ b/document/public/deploy/docker/cn/docker-compose.milvus.yml @@ -249,6 +249,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/cn/docker-compose.oceanbase.yml index bef660b45052..5e10fba38b39 100644 --- a/document/public/deploy/docker/cn/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/cn/docker-compose.oceanbase.yml @@ -227,6 +227,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/cn/docker-compose.pg.yml b/document/public/deploy/docker/cn/docker-compose.pg.yml index ce928f2e002c..eb37992f4f39 100644 --- a/document/public/deploy/docker/cn/docker-compose.pg.yml +++ b/document/public/deploy/docker/cn/docker-compose.pg.yml @@ -208,6 +208,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/cn/docker-compose.seekdb.yml index 84cdac605d24..0d4e46168152 100644 --- a/document/public/deploy/docker/cn/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/cn/docker-compose.seekdb.yml @@ -214,6 +214,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/cn/docker-compose.zilliz.yml index ee10156d3682..e428cea1b414 100644 --- a/document/public/deploy/docker/cn/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/cn/docker-compose.zilliz.yml @@ -191,6 +191,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/global/docker-compose.milvus.yml b/document/public/deploy/docker/global/docker-compose.milvus.yml index 9be12726b0f2..ab7faf1f3274 100644 --- a/document/public/deploy/docker/global/docker-compose.milvus.yml +++ b/document/public/deploy/docker/global/docker-compose.milvus.yml @@ -249,6 +249,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/global/docker-compose.oceanbase.yml index 82f4d58832f4..54f8f322e82a 100644 --- a/document/public/deploy/docker/global/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/global/docker-compose.oceanbase.yml @@ -227,6 +227,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/global/docker-compose.pg.yml b/document/public/deploy/docker/global/docker-compose.pg.yml index ce149eb024cb..b8726635aefc 100644 --- a/document/public/deploy/docker/global/docker-compose.pg.yml +++ b/document/public/deploy/docker/global/docker-compose.pg.yml @@ -208,6 +208,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/global/docker-compose.seekdb.yml b/document/public/deploy/docker/global/docker-compose.seekdb.yml index d2a9274f9cdf..74b6a1956293 100644 --- a/document/public/deploy/docker/global/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/global/docker-compose.seekdb.yml @@ -214,6 +214,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/deploy/docker/global/docker-compose.zilliz.yml b/document/public/deploy/docker/global/docker-compose.zilliz.yml index 5bca5ee3e4b3..c054aa7c1032 100644 --- a/document/public/deploy/docker/global/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/global/docker-compose.zilliz.yml @@ -191,6 +191,15 @@ services: # AI Proxy 的 Admin Token,与 AI Proxy 中的环境变量 ADMIN_KEY AIPROXY_API_TOKEN: *x-aiproxy-token + # diting 地址 + DITING_BASE_URL=http://diting:3000 + # SFT-Bridge 地址 + SFT_BRIDGE_BASE_URL=http://sft-bridge:3000 + + # 日志等级: debug, info, warn, error + LOG_LEVEL: info + STORE_LOG_LEVEL: warn + # ==================== Agent sandbox 配置 ==================== AGENT_SANDBOX_PROVIDER: opensandbox # OpenSandbox 配置(PROVIDER: opensandbox 时生效) diff --git a/document/public/logo.svg b/document/public/logo.svg index 493dee344325..d31b9396a580 100644 --- a/document/public/logo.svg +++ b/document/public/logo.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/document/source.config.ts b/document/source.config.ts index 4e7a48565bfa..e914373952a8 100644 --- a/document/source.config.ts +++ b/document/source.config.ts @@ -1,5 +1,5 @@ import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config'; -import { z } from 'zod'; +import z from 'zod'; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.vercel.app/docs/mdx/collections#define-docs diff --git a/node-xlsx-0.24.0.tgz b/node-xlsx-0.24.0.tgz new file mode 100644 index 000000000000..8f2002e1dbe0 Binary files /dev/null and b/node-xlsx-0.24.0.tgz differ diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/.openspec.yaml b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/.openspec.yaml new file mode 100644 index 000000000000..81cd71fe0b6e --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/design.md b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/design.md new file mode 100644 index 000000000000..28959b7f6fc7 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/design.md @@ -0,0 +1,200 @@ +## Context + +FastGPT 有两个日志查看页面,分别是通用应用的 `Logs` 和智能客服应用的 `ConversationLogs`。两者共用同一套后端导出 API (`/api/core/app/logs/exportLogs`),但前后端交互模式不同: + +- **Logs 页面**:使用 `LogsContext` 集中管理筛选状态,`LogTable` 通过回调注入模式将 `exportLogs` 函数写入 Context,父组件的 `PopoverConfirm` 在确认后调用 +- **ConversationLogs 页面**:`LogFilters` 独立管理筛选状态,通过 `onFiltersChange` 回调传给父组件,目前仅在 `optimize` 子标签有导出(导出的是纠错记录,调用不同 API) + +经代码审查发现:Logs 导出请求参数与表格查询参数基本一致,后端 `where` 子句也完全相同,前端代码无逻辑错误。导出空数据的根因大概率在后端聚合管道或 MongoDB cursor 层面。 + +ConversationLogs 缺少列表导出是明确的功能缺失,需要新增。 + +### 现有导出架构 + +``` +┌─ Logs 页面 ─────────────────────────────────────────────────────────────┐ +│ │ +│ LogsContext (共享筛选状态) │ +│ │ │ +│ └─→ LogsInner (index.tsx) │ +│ │ │ +│ ├─ 筛选栏 (DateRangePicker, MultipleSelect, UserFilter, etc.) │ +│ │ │ +│ ├─ [PopoverConfirm] export button │ +│ │ └─ onConfirm={onExport} ← LogTable 通过 useEffect 注入 │ +│ │ │ +│ └─ LogTable │ +│ ├─ usePagination(getAppChatLogs) → 表格数据 │ +│ ├─ exportLogs() → downloadFetch('/api/core/app/logs/exportLogs') │ +│ └─ useEffect → setOnExport(exportLogs) 注入到 Context │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─ ConversationLogs 页面 (重构后) ─────────────────────────────────────────┐ +│ │ +│ ConversationLogs (本地 state + 导出逻辑) │ +│ │ │ +│ ├─ SubTabHeader │ +│ │ ├─ FillRowTabs (list | optimize) │ +│ │ │ │ +│ │ ├─ [subTab === 'optimize'] │ +│ │ │ ├─ DateRangePicker + [MyTooltip + Button] export button │ +│ │ │ └─ handleExport → exportChatCorrectionRecords() │ +│ │ │ → /api/core/chat/correction/export (Excel) │ +│ │ │ │ +│ │ └─ [subTab === 'list'] │ +│ │ ├─ LogFilters (独立管理筛选状态 + onFiltersChange 回调) │ +│ │ └─ PopoverConfirm │ +│ │ ├─ content={t('app:logs_export_confirm_tip', { total })} │ +│ │ └─ onConfirm={handleExportLogs} ← 直接指向本地导出函数 │ +│ │ │ +│ └─ Content │ +│ ├─ LogList (subTab === 'list') │ +│ │ └─ usePagination(getAppChatLogs) │ +│ │ └─ onTotalChange → 通知父组件 total 变化 │ +│ └─ OptimizeRecords (subTab === 'optimize') │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Goals / Non-Goals + +**Goals:** +- 修复 Logs 页面导出空数据问题,使导出的 CSV 文件包含实际数据 +- 为 ConversationLogs 列表页添加导出按钮,功能与 Logs 页面一致(PopoverConfirm + 总条数提示 + CSV 导出) +- 让后端 `list.ts` 和 `exportLogs.ts` 原生支持 `feedbackFilter` 参数(该参数已存在于 Schema 中但从未被消费),同时修复 ConversationLogs 列表筛选不生效的既有 bug +- 修复 LogTable 中 `unreadOnly` 参数在导出和列表查询间不一致的问题 + +**Non-Goals:** +- 不重构 LogsContext 或 ConversationLogs 的整体状态管理架构 +- ~~不修改 `/api/core/app/logs/exportLogs` 的聚合管道结构(除非确认根因在后端)~~ **已修改**:根因确认为聚合管道 `$lookup` 语法错误,已修复(见 F1) +- 不在 ConversationLogs 的 optimize tab 添加 PopoverConfirm(保持现有 MyTooltip 风格) +- 不添加新的 i18n 词条(复用现有词条:`common:Export`、`app:logs_export_confirm_tip`) + +## Decisions + +### D1: Logs 导出空数据 —— 修复策略 + +**决策**: 优先在后端添加错误日志和防御性检查,同时修复前端的参数一致性问题。 + +**理由**: 代码审查未发现明显逻辑错误,大概率是 MongoDB cursor 流或聚合管道在特定数据/环境下产生空结果。添加日志有助于定位根因。前端修复 `unreadOnly` 不一致是防御性的。 + +**备选方案**: 在前端做降级处理(导出失败时提示用户缩小筛选范围)→ 但这掩盖了根因,不可取。 + +### D2: ConversationLogs 导出按钮 —— 导出函数位置 + +**决策**: 导出函数 `handleExportLogs` **直接在 ConversationLogs 父组件中定义**,不从 LogList 通过回调注入。 + +``` +ConversationLogs (父组件) +├── handleExportLogs() → 直接读取 logFilters state → downloadFetch() +├── exportTotal state (由 LogList 通过 onTotalChange 回传) +│ +├── SubTabHeader +│ └── [subTab === 'list'] +│ ├── LogFilters +│ └── PopoverConfirm +│ ├── content={t('app:logs_export_confirm_tip', { total: exportTotal })} +│ └── onConfirm={handleExportLogs} +│ +└── LogList + ├── usePagination(getAppChatLogs) → { total, ... } + └── onTotalChange → 通知父组件 total 变化 +``` + +**理由**: +- LogList 不拥有任何导出需要而父组件没有的数据(`appId`、`appName`、`logFilters` 全在父组件或其 Context 中) +- 回调注入模式引入了多余的 state(`onExportList`)、`useEffect` 回传,以及 **undefined 初始值的时序窗口**(用户可能在 LogList 注入前点击导出,此时 `onExportList` 为 undefined) +- 将导出函数放在数据所属位置(父组件),遵循单一数据源原则 + +**备选方案**: +- ~~回调注入模式(与 Logs 页面一致)~~ → ~~增加复杂度,引入时序 bug~~ +- ~~在父组件单独调用 API 获取 total~~ → 不必要,LogList 已有 total + +### D3: 让后端原生支持 `feedbackFilter`(而非前端转换) + +**决策**: 在 `list.ts` 和 `exportLogs.ts` 的 where 子句中直接消费 `feedbackFilter` 参数,不做前端转换。 + +**理由**: +- `feedbackFilter` 已存在于 `GetAppChatLogsBodySchema` 中,只是从未被后端消费——ConversationLogs 列表筛选本身就是坏的(发送了参数但被忽略) +- 前端转换 `feedbackFilter`(数组)→ `feedbackType`(字符串)会丢失 `noFeedback` 语义,因为 `feedbackType` 枚举中没有对应值 +- 修复后端消费逻辑可以同时解决两个 bug:ConversationLogs 列表筛选不生效 + 导出需要前端做有损转换 + +**feedbackFilter 在 where 子句中的映射逻辑**: + +```typescript +// 在 list.ts 和 exportLogs.ts 的 where 子句中新增 +// feedbackFilter 非全选时构建 MongoDB 条件 +// 注意: 与 feedbackType 互斥(通过 !feedbackType 守卫),LogTable 用 feedbackType,LogList 用 feedbackFilter +const feedbackFilterCondition = + !feedbackType && feedbackFilter && feedbackFilter.length < 3 + ? (() => { + const hasGood = feedbackFilter.includes(FeedbackFilterEnum.good); + const hasBad = feedbackFilter.includes(FeedbackFilterEnum.bad); + const hasNo = feedbackFilter.includes(FeedbackFilterEnum.noFeedback); + + if (feedbackFilter.length === 1) { + if (hasGood) return { hasGoodFeedback: true }; + if (hasBad) return { hasBadFeedback: true }; + return { hasGoodFeedback: { $ne: true }, hasBadFeedback: { $ne: true } }; + } + // 两个选中 — 排除缺失的第三个 + if (hasGood && hasBad) return { $or: [{ hasGoodFeedback: true }, { hasBadFeedback: true }] }; + if (hasGood && hasNo) return { hasBadFeedback: { $ne: true } }; + return { hasGoodFeedback: { $ne: true } }; + })() + : {}; +``` + +**各组合的 MongoDB 条件映射**: + +| feedbackFilter | MongoDB 条件 | +|---|---| +| 未传或全选 3 项 | 无筛选 | +| `['good']` | `{ hasGoodFeedback: true }` | +| `['bad']` | `{ hasBadFeedback: true }` | +| `['noFeedback']` | `{ hasGoodFeedback: {$ne: true}, hasBadFeedback: {$ne: true} }` | +| `['good', 'bad']` | `{$or: [{hasGoodFeedback: true}, {hasBadFeedback: true}]}` | +| `['good', 'noFeedback']` | `{ hasBadFeedback: {$ne: true} }` | +| `['bad', 'noFeedback']` | `{ hasGoodFeedback: {$ne: true} }` | + +**备选方案**: 前端转换 → 已否决,原因见上。 + +### D4: dateRange 来源 + +**决策**: 导出时使用 `logFilters.dateRange`(LogFilters 组件内部的日期范围),而非 ConversationLogs 父组件的 `dateRange`。 + +**理由**: ConversationLogs 的 `dateRange` 目前仅供 optimize tab 使用。LogFilters 独立管理自己的日期范围,导出应与用户看到的筛选条件一致。 + +### D5: sourcesMap 构建 + +**决策**: 在 ConversationLogs 的导出函数中内联构建 `sourcesMap`(与 LogTable 中的方式相同),从 `ChatSourceMap` 常量映射。 + +**理由**: 简单直接,无需将 sourcesMap 加入 LogFiltersType。 + +## Risks / Trade-offs + +- **[feedbackFilter 影响 Logs 页面 list 查询]**:`list.ts` 新增 `feedbackFilter` 消费后,Logs 页面的 LogTable 不会发送此参数(LogTable 用 `feedbackType`),但两者在 where 子句中通过不同字段独立工作,互不干扰。ConversationLogs 的 LogList 目前也不发送 `feedbackType`,后端两个参数各自独立生效。 +- **[total 可能为 0]**:若 LogList 未加载完成或筛选条件无匹配,total 为 0 → `PopoverConfirm` 仍显示 "共 0 条",用户确认后导出空文件(合乎逻辑) + +## Confirmed Findings + +### F1: Logs 导出空数据/500 根因 —— 聚合管道 `$lookup` 语法错误 + +**发现**: `exportLogs.ts` 的 MongoDB 聚合管道中,第三个 `$lookup`(关联 `AppVersionCollectionName`)同时使用了 `localField`/`foreignField`(简单模式)和 `pipeline`(管道模式),这在 MongoDB 中是非法的,MongoDB 返回 `FailedToParse` 错误(code 9)。 + +**影响分析**: +``` + ┌──── $match 命中文档 ──→ $lookup 执行 ──→ MongoDB 报错 ──→ 500 + │ (语法错误) +聚合管道开始 ──┤ + └──── $match 命中 0 条 ──→ 跳过后续 stage ──→ cursor end ──→ 空 CSV + (看起来正常) +``` +- 筛选有数据 → **500 错误**(用户无法导出) +- 筛选无数据 → **空文件**(用户以为导出正常但无内容) +- 两种表现指向同一根因,此前被误判为"时好时坏" + +**修复**: 将 `$lookup` 改为纯管道模式(`let` + `pipeline` + `$expr`),与文件内前两个 `$lookup` 保持一致。 + +**代码位置**: `projects/app/src/pages/api/core/app/logs/exportLogs.ts` 第 285-304 行 diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/proposal.md b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/proposal.md new file mode 100644 index 000000000000..230286618409 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/proposal.md @@ -0,0 +1,32 @@ +## Why + +FastGPT 日志页面的导出功能存在两个问题:1) `projects/app/src/pageComponents/app/detail/Logs/index.tsx` 的导出按钮点击后下载的文件为空数据(仅有表头无数据行);2) `projects/app/src/pageComponents/app/detail/ConversationLogs/index.tsx` 在 `subTab === 'list'` 时缺少导出按钮,用户无法导出智能客服应用的对话日志列表数据。 + +## What Changes + +- **修复 Logs 页面导出空数据问题**:排查并修复 `/api/core/app/logs/exportLogs` 返回空 CSV 的根因,增强导出流程的错误处理和用户提示 +- **ConversationLogs 列表页新增导出按钮**:在 `subTab === 'list'` 时,于 LogFilters 旁边添加与 Logs 页面一致的 `PopoverConfirm` 导出按钮,显示总条数确认弹窗 +- **后端原生支持 `feedbackFilter` 参数**:在 `list.ts` 和 `exportLogs.ts` 的 where 子句中消费 `feedbackFilter`(该参数已存在于 Schema 但从未被后端使用),同时修复 ConversationLogs 列表 feedback 筛选不生效的既有 bug +- **统一 `unreadOnly` 参数传递**:修复 LogTable 导出时 `unreadOnly` 参数传递与列表查询不一致的问题(列表查询在 `feedbackType === 'all'` 时传 `undefined`,导出始终传实际值) + +## Capabilities + +### New Capabilities +- `conversation-logs-export`: ConversationLogs 页面的 `list` 子标签页新增导出功能,支持将筛选后的日志数据导出为 CSV 文件 +- `logs-export-fix`: 修复 Logs 页面导出空数据问题,增强导出错误处理 + +### Modified Capabilities + + +## Impact + +- **前端文件**: + - `projects/app/src/pageComponents/app/detail/Logs/LogTable.tsx` — 修复导出参数不一致问题 + - `projects/app/src/pageComponents/app/detail/ConversationLogs/index.tsx` — 新增导出按钮和 `handleExportLogs` 函数(导出逻辑直接放在父组件,不从子组件回调注入) + - `projects/app/src/pageComponents/app/detail/ConversationLogs/LogList.tsx` — 新增 `onTotalChange` prop,回传 total 给父组件 + - `projects/app/src/pageComponents/app/detail/ConversationLogs/LogFilters.tsx` — 无修改(`LogFiltersType` 已含所需字段) +- **后端文件**: + - `projects/app/src/pages/api/core/app/logs/exportLogs.ts` — 新增 `feedbackFilter` 消费 + cursor 错误日志 + - `projects/app/src/pages/api/core/app/logs/list.ts` — 新增 `feedbackFilter` 消费(修复既有筛选 bug) +- **API Schema**: `packages/global/openapi/core/app/log/api.ts` — 无修改(`feedbackFilter` 已存在) +- **依赖**: 无新增依赖 diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/conversation-logs-export/spec.md b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/conversation-logs-export/spec.md new file mode 100644 index 000000000000..3dba0390d580 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/conversation-logs-export/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Export button on ConversationLogs list tab +The system SHALL provide an export button on the ConversationLogs page when `subTab === 'list'`, using the same `PopoverConfirm` pattern as the Logs page. + +#### Scenario: Export button visible in list tab +- **WHEN** user is on the ConversationLogs page with `subTab === 'list'` +- **THEN** a `PopoverConfirm` wrapped export button SHALL be displayed alongside LogFilters +- **AND** the popover content SHALL show the total count using `app:logs_export_confirm_tip` i18n key + +#### Scenario: Export button hidden in optimize tab +- **WHEN** user switches to `subTab === 'optimize'` +- **THEN** the list export button SHALL not be visible (optimize tab has its own export button) + +### Requirement: Export uses LogFilters state +The system SHALL construct export parameters from the current LogFilters state, passing `feedbackFilter` directly without conversion. + +#### Scenario: Export with all filters applied +- **WHEN** user has set date range, sources, users, feedback, and search in LogFilters and clicks export +- **THEN** the export request SHALL include dateStart/dateEnd from `logFilters.dateRange`, sources from `logFilters.chatSources`, tmbIds from `logFilters.selectTmbIds`, chatSearch from `logFilters.chatSearch`, and `feedbackFilter` from `logFilters.feedbackFilters` directly + +#### Scenario: Export with partial feedback selection +- **WHEN** LogFilters has `feedbackFilters: [good]` only (isSelectAllFeedback = false) +- **THEN** the export request SHALL pass `feedbackFilter: [good]` directly to the backend + +#### Scenario: Export with all feedback types selected +- **WHEN** LogFilters has `isSelectAllFeedback: true` +- **THEN** the export request SHALL omit `feedbackFilter` (equivalent to no filter) + +#### Scenario: Export with noFeedback selected +- **WHEN** LogFilters has `feedbackFilters: [noFeedback]` only +- **THEN** the export request SHALL pass `feedbackFilter: [noFeedback]` directly to the backend for native handling + +### Requirement: Total count display in export confirmation +The system SHALL display the total record count in the export confirmation popover. + +#### Scenario: Total count from LogList +- **WHEN** LogList has loaded data with a specific total count +- **THEN** the export confirmation popover SHALL display that total count +- **AND** the total SHALL be shared from LogList to the parent component via callback + +#### Scenario: Total count before data loads +- **WHEN** LogList has not yet loaded data (total is 0 or undefined) +- **THEN** the export confirmation SHALL display the current total (0), and export SHALL still be allowed + +### Requirement: Export CSV format matches Logs page export +The system SHALL use the same `/api/core/app/logs/exportLogs` endpoint for ConversationLogs list export, producing the same CSV format. + +#### Scenario: CSV includes enabled log keys +- **WHEN** LogFilters has specific logKeys enabled +- **THEN** the export CSV SHALL include only the enabled columns plus chatDetails + +#### Scenario: CSV includes sourcesMap +- **WHEN** exporting ConversationLogs list data +- **THEN** the export request SHALL include a sourcesMap built from `ChatSourceMap` for proper source label display in the CSV diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/logs-export-fix/spec.md b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/logs-export-fix/spec.md new file mode 100644 index 000000000000..dd2016dbd8b6 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/specs/logs-export-fix/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Export parameters match list query parameters +The system SHALL use the same filter parameters for export as used for the table list query, ensuring consistent results between displayed and exported data. + +#### Scenario: unreadOnly consistency +- **WHEN** `feedbackType` is `'all'` and user triggers export +- **THEN** the export request SHALL send `unreadOnly: undefined` (same as list query), NOT `unreadOnly: false` + +#### Scenario: feedbackType filter preserved +- **WHEN** user has active feedback filter (e.g., `feedbackType: 'good'`) and triggers export +- **THEN** the export request SHALL include the same `feedbackType` and `unreadOnly` values as the current list query + +### Requirement: Export error handling provides user feedback +The system SHALL notify the user when an export operation fails. + +#### Scenario: Export API returns error +- **WHEN** the export API returns a non-2xx response +- **THEN** the system SHALL display an error toast message to the user + +#### Scenario: Export produces empty file +- **WHEN** the export API returns a CSV with only headers (no data rows) due to no matching records +- **THEN** the system SHALL still allow download (the empty file accurately reflects the filtered state) + +### Requirement: Backend export logging +The system SHALL log cursor-level errors during export to facilitate debugging. + +#### Scenario: MongoDB cursor error during export +- **WHEN** the aggregation cursor encounters an error +- **THEN** the error SHALL be logged with relevant context (appId, query parameters) + +### Requirement: Backend supports feedbackFilter parameter +The `exportLogs.ts` and `list.ts` API handlers SHALL consume the `feedbackFilter` parameter natively in their MongoDB where clauses. + +#### Scenario: feedbackFilter with all types selected +- **WHEN** `feedbackFilter` is not provided or contains all 3 enum values (good, bad, noFeedback) +- **THEN** no feedback filter SHALL be applied to the MongoDB query + +#### Scenario: feedbackFilter with only good selected +- **WHEN** `feedbackFilter: ['good']` is received +- **THEN** the query SHALL filter `{ hasGoodFeedback: true }` + +#### Scenario: feedbackFilter with only bad selected +- **WHEN** `feedbackFilter: ['bad']` is received +- **THEN** the query SHALL filter `{ hasBadFeedback: true }` + +#### Scenario: feedbackFilter with only noFeedback selected +- **WHEN** `feedbackFilter: ['noFeedback']` is received +- **THEN** the query SHALL filter `{ hasGoodFeedback: {$ne: true}, hasBadFeedback: {$ne: true} }` + +#### Scenario: feedbackFilter with good and bad selected +- **WHEN** `feedbackFilter: ['good', 'bad']` is received +- **THEN** the query SHALL filter `{ $or: [{hasGoodFeedback: true}, {hasBadFeedback: true}] }` + +#### Scenario: feedbackFilter coexists with feedbackType +- **WHEN** both `feedbackFilter` and `feedbackType` are provided in the same request +- **THEN** `feedbackFilter` SHALL take precedence and `feedbackType` SHALL be ignored (ConversationLogs pages use `feedbackFilter`, Logs page uses `feedbackType` — they never send both) diff --git a/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/tasks.md b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/tasks.md new file mode 100644 index 000000000000..3991af1c7383 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-fix-logs-export-and-add-conversation-export/tasks.md @@ -0,0 +1,33 @@ +## 1. Logs 页面导出修复 + +- [x] 1.1 修复 LogTable 导出函数中 `unreadOnly` 参数与列表查询不一致的问题:当 `feedbackType === 'all'` 时传 `undefined` +- [x] 1.2 在 `exportLogs.ts` API handler 的 cursor error 事件中添加详细日志(appId、query 条件、错误信息) +- [x] 1.3 为 LogTable 的 `exportLogs` 函数添加 `errorToast`,在导出失败时提示用户 +- [x] 1.4 修复 `exportLogs.ts` 聚合管道中 `$lookup` 语法错误:`localField`/`foreignField` 与 `pipeline` 混用导致 MongoDB `FailedToParse` 错误,改为纯管道模式(`let` + `$expr`) + +## 2. 后端 `feedbackFilter` 参数支持 + +- [x] 2.1 在 `list.ts` 的 where 子句中新增 `feedbackFilter` 消费逻辑(按 design.md D3 映射表),使其与 `feedbackType` 独立并行工作 +- [x] 2.2 在 `exportLogs.ts` 的 where 子句中新增相同的 `feedbackFilter` 消费逻辑 +- [x] 2.3 在 `list.ts` 和 `exportLogs.ts` 中从 `GetAppChatLogsBodySchema.parse()` / `ExportChatLogsBodySchema.parse()` 解构出 `feedbackFilter` 参数 + +## 3. ConversationLogs 列表导出按钮 + +- [x] 3.1 在 ConversationLogs 父组件中直接实现 `handleExportLogs` 函数:直接读取 `logFilters` state → 构造参数 → `downloadFetch('/api/core/app/logs/exportLogs', ...)`(不再从 LogList 通过回调注入) +- [x] 3.2 在 ConversationLogs 的 `SubTabHeader` 中,当 `subTab === 'list'` 时渲染 `PopoverConfirm` 导出按钮(放在 LogFilters 旁边) +- [x] 3.3 导出按钮使用 `app:logs_export_confirm_tip` 显示 `{ total }`,`onConfirm` 直接指向 `handleExportLogs` + +## 4. LogList total 状态回传 + +- [x] 4.1 在 LogList 组件中新增 `onTotalChange` prop,当 `total` 变化时回调父组件 +- [x] 4.2 在 ConversationLogs 父组件中接收 total 并存入 `exportTotal` state + +## 5. 验证 + +- [x] 5.1 验证 Logs 页面:设置筛选条件 → 表格有数据 → 点击导出 → 下载的 CSV 文件包含数据行(浏览器实测) +- [x] 5.2 验证 ConversationLogs 列表页:设置筛选条件(含 feedback 筛选)→ 表格数据正确 → 确认导出弹窗显示正确 total → 点击确认 → 下载 CSV(浏览器实测) +- [x] 5.3 验证 ConversationLogs optimize 标签页的导出按钮不受影响(代码未修改 optimize tab 相关逻辑) +- [x] 5.4 验证 Logs 页面导出按钮在 feedbackType 为不同值时的行为一致性(unreadOnly 修复确保参数一致) +- [x] 5.5 验证 `feedbackFilter` 参数在 list API 中独立生效,不影响 Logs 页面通过 `feedbackType` 的筛选(两套参数通过 `!feedbackType` 条件互斥) +- [x] 5.6 运行 lint 检查:所有修改文件均无 ESLint 错误 +- [x] 5.7 确认 `$lookup` 语法错误已修复:`exportLogs.ts`中无其他 `localField`/`foreignField` + `pipeline` 混用,代码库中无类似问题 diff --git a/openspec/changes/archive/2026-05-12-add-app-name-to-export/.openspec.yaml b/openspec/changes/archive/2026-05-12-add-app-name-to-export/.openspec.yaml new file mode 100644 index 000000000000..40cc12f45740 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-app-name-to-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-12 diff --git a/openspec/changes/archive/2026-05-12-add-app-name-to-export/design.md b/openspec/changes/archive/2026-05-12-add-app-name-to-export/design.md new file mode 100644 index 000000000000..6d6c2df37a12 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-app-name-to-export/design.md @@ -0,0 +1,47 @@ +## Context + +当前 FastGPT 应用配置导出功能位于两处: + +1. **Dashboard Agent 列表页** (`ExportConfigPopover.tsx`): 接收 `appName` + `appId`,动态获取 `appDetail`,导出 `{ nodes, edges, chatConfig }` +2. **应用详情页** (`ExportConfigPopover.tsx`): 接收 `appName` + `chatConfig` + workflow/form 数据,导出 `{ nodes, edges, chatConfig }` 或 form 数据 + +导入端 (`JsonImportModal.tsx`) 解析 JSON 后仅提取 `nodes`、`edges`、`chatConfig` 用于创建应用,名称和介绍始终需要用户手动输入。 + +## Goals / Non-Goals + +**Goals:** +- 导出时在 JSON 中包含 `name` 和 `intro` 字段 +- 导入时自动从 JSON 中读取 `name` 和 `intro` 并回填,减少手动操作 +- 保持向后兼容:旧版不含 name/intro 的 JSON 导入不受影响 + +**Non-Goals:** +- 不涉及 avatar 字段的导出/导入(当前导入已通过类型推断自动选择图标) +- 不改变现有的敏感信息过滤逻辑 +- 不修改导出文件格式(仍为 JSON) + +## Decisions + +### 1. 在导出 JSON 顶层增加 `name` 和 `intro` 字段 + +**方案选择**: 在现有 `{ nodes, edges, chatConfig }` 结构同级增加字段,而非嵌套在某个对象内。 + +- Dashboard 导出: 直接从 `appDetail` 取值 `appDetail.name` 和 `appDetail.intro` +- Detail 导出: `name` 已有 prop `appName`,`intro` 需新增 prop `appIntro` + +### 2. 导入时 name 优先使用 JSON 中的值 + +在 `JsonImportModal` 中,解析 JSON 后: +- 若 `parsed.name` 存在 → 用 `setValue('name', parsed.name)` 预填 +- 若 `parsed.intro` 存在 → 传入 `postCreateApp({ intro: parsed.intro })` +- 若不存在 → 保持现有行为(用户手动填写,`utmParams` 名称优先) + +**UTM 名称优先级**: 当存在 UTM 短链名称(`utmParams.shortUrlContent`)时,UTM 名称仍优先于 JSON 中的 name,因为 UTM 是更明确的外部意图。 + +### 3. Detail 导出接口变更:新增 `appIntro` 可选 prop + +`ExportConfigPopoverProps` 增加 `appIntro?: string`,三个调用方均传入 `appDetail.intro`。 + +## Risks / Trade-offs + +- **导入处 name 覆盖风险** → 用户可能不希望自动覆盖。缓解措施:仅在 JSON 中 `name` 字段存在时才预填,用户仍可手动修改;UTM 名称仍保持最高优先级 +- **intro 字段可能为 undefined** → 导出时 intro 可能为空字符串或 undefined,JSON.stringify 会正确处理。导入时做空值检查即可 diff --git a/openspec/changes/archive/2026-05-12-add-app-name-to-export/proposal.md b/openspec/changes/archive/2026-05-12-add-app-name-to-export/proposal.md new file mode 100644 index 000000000000..ab3873c982bb --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-app-name-to-export/proposal.md @@ -0,0 +1,27 @@ +## Why + +当前导出应用配置(复制到剪切板/导出JSON)时,仅包含 `nodes`、`edges`、`chatConfig` 三部分,缺少应用的 `name`(名称)和 `intro`(介绍)信息。用户在导入时需手动填写名称和介绍,操作繁琐且容易遗漏。将名称和介绍纳入导出数据,可在导入时自动回填,提升用户体验和配置迁移效率。 + +## What Changes + +- 在 Dashboard Agent 列表页的导出配置(`ExportConfigPopover`)中,向导出 JSON 增加 `name` 和 `intro` 字段 +- 在应用详情页(`AppDetail`)的导出配置中,向导出 JSON 增加 `name` 和 `intro` 字段 +- 在 JSON 导入弹窗(`JsonImportModal`)中,自动从导入的 JSON 中读取 `name` 和 `intro` 字段并回填到表单 +- 导入时若 JSON 中存在 `name`,则自动填充名称输入框;若存在 `intro`,则一并传给创建接口 + +## Capabilities + +### New Capabilities + +- `export-app-name-intro`: 在应用配置导出中包含 name 和 intro 字段,并在导入时自动回填 + +### Modified Capabilities + + + +## Impact + +- `projects/app/src/pageComponents/dashboard/agent/ExportConfigPopover.tsx`: 导出数据结构增加 `name`、`intro` 字段 +- `projects/app/src/pageComponents/app/detail/ExportConfigPopover.tsx`: 同上 +- `projects/app/src/pageComponents/dashboard/agent/JsonImportModal.tsx`: 导入时读取并回填 `name`、`intro` +- 向后兼容:旧版导出的 JSON(不含 name/intro)导入时不受影响,用户仍可手动填写名称 diff --git a/openspec/changes/archive/2026-05-12-add-app-name-to-export/specs/export-app-name-intro/spec.md b/openspec/changes/archive/2026-05-12-add-app-name-to-export/specs/export-app-name-intro/spec.md new file mode 100644 index 000000000000..58ace8a43491 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-app-name-to-export/specs/export-app-name-intro/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Export includes app name and intro + +应用配置导出 JSON SHALL 包含应用的 `name`(名称)和 `intro`(介绍)字段,与 `nodes`、`edges`、`chatConfig` 平级。 + +#### Scenario: Dashboard 列表页导出包含 name 和 intro + +- **WHEN** 用户在 Dashboard Agent 列表页通过 ExportConfigPopover 导出应用配置(复制到剪切板或下载 JSON) +- **THEN** 导出的 JSON 对象 MUST 包含 `name` 和 `intro` 字段,值来自应用详情的对应属性 + +#### Scenario: 应用详情页导出包含 name 和 intro + +- **WHEN** 用户在应用详情页通过 ExportConfigPopover 导出应用配置 +- **THEN** 导出的 JSON 对象 MUST 包含 `name` 和 `intro` 字段,`name` 来自组件 props,`intro` 来自新增的 `appIntro` prop + +### Requirement: Import auto-fills name and intro from JSON + +JSON 导入弹窗 SHALL 从导入的 JSON 中自动读取 `name` 和 `intro` 字段并回填,减少用户手动输入。 + +#### Scenario: 导入含 name 的 JSON 自动填充名称 + +- **WHEN** 用户导入包含 `name` 字段的 JSON 配置 +- **THEN** 名称输入框 MUST 自动填充为 JSON 中的 `name` 值,用户仍可手动修改 + +#### Scenario: 导入含 intro 的 JSON 自动传入介绍 + +- **WHEN** 用户导入包含 `intro` 字段的 JSON 配置并确认创建 +- **THEN** `postCreateApp` 的调用参数 MUST 包含 JSON 中的 `intro` 值 + +#### Scenario: 导入不含 name/intro 的旧版 JSON 兼容 + +- **WHEN** 用户导入不包含 `name` 或 `intro` 字段的旧版导出 JSON +- **THEN** 名称输入框 MUST 保持空白(或保持 UTM 填充值),创建时 intro 为空,不影响正常导入流程 + +### Requirement: UTM 名称优先级高于 JSON name + +当同时存在 UTM 短链名称和 JSON 中的 name 时,UTM 名称 SHALL 优先。 + +#### Scenario: UTM 与 JSON name 同时存在 + +- **WHEN** 导入流程中同时存在 UTM 短链携带的 `shortUrlContent` 名称和 JSON 中的 `name` 字段 +- **THEN** 名称输入框 MUST 使用 UTM 名称,而非 JSON 中的 name diff --git a/openspec/changes/archive/2026-05-12-add-app-name-to-export/tasks.md b/openspec/changes/archive/2026-05-12-add-app-name-to-export/tasks.md new file mode 100644 index 000000000000..853ad819ff49 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-app-name-to-export/tasks.md @@ -0,0 +1,24 @@ +## 1. Dashboard 列表页导出 - 增加 name/intro + +- [x] 1.1 在 `pageComponents/dashboard/agent/ExportConfigPopover.tsx` 中,从 `getAppDetailById` 返回的 `appDetail` 取 `name` 和 `intro`,加入导出 JSON 对象 + +## 2. 应用详情页导出 - 增加 name/intro + +- [x] 2.1 在 `pageComponents/app/detail/ExportConfigPopover.tsx` 中,`ExportConfigPopoverProps` 新增 `appIntro?: string` prop +- [x] 2.2 在详情导出的 JSON 构建中(workflow 和 form 两种路径),加入 `name: appName` 和 `intro: appIntro` +- [x] 2.3 更新 `SimpleApp/AppCard.tsx` 中对 `ExportConfigPopover` 的调用,传入 `appIntro={appDetail.intro}` +- [x] 2.4 更新 `WorkflowComponents/AppCard.tsx` 中对 `ExportConfigPopover` 的调用,传入 `appIntro={appDetail.intro}` +- [x] 2.5 更新 `Edit/FormComponent/AppCard.tsx` 中对 `ExportConfigPopover` 的调用,传入 `appIntro={appDetail.intro}` + +## 3. JSON 导入 - 自动回填 name/intro + +- [x] 3.1 在 `pageComponents/dashboard/agent/JsonImportModal.tsx` 的 `onSubmit` 中,从解析后的 JSON 读取 `name` 和 `intro` +- [x] 3.2 若 JSON 中存在 `name` 且当前无 UTM 名称,使用 `setValue` 预填名称输入框 +- [x] 3.3 将 JSON 中的 `intro` 传入 `postCreateApp` 调用 + +## 4. 验证 + +- [x] 4.1 在 Dashboard 列表页导出配置,验证 JSON 包含 name 和 intro +- [x] 4.2 在应用详情页导出配置,验证 JSON 包含 name 和 intro +- [x] 4.3 导入含 name/intro 的 JSON,验证名称自动填充、介绍正常传入 +- [x] 4.4 导入不含 name/intro 的旧版 JSON,验证功能正常(手动填写名称) diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/.openspec.yaml b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/.openspec.yaml new file mode 100644 index 000000000000..40cc12f45740 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-12 diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/design.md b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/design.md new file mode 100644 index 000000000000..3e126854562f --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/design.md @@ -0,0 +1,48 @@ +## Context + +当前 FastGPT 的 Dashboard Agent 列表页有两个功能缺口: + +1. **无置顶机制**: 应用/文件夹无法置顶,所有项统一按更新时间排序。用户高频使用的应用无法固定在列表顶部。 +2. **搜索不支持创建人**: 搜索框仅支持按名称和介绍搜索,不包含创建人。当用户知道创建者但不知道应用名时,无法定位目标应用。 + +## Goals / Non-Goals + +**Goals:** +- 在 AppCard 操作菜单中新增「置顶」操作项,点击后该应用/文件夹排在最前面 +- 若已被置顶,操作项变为「取消置顶」 +- 置顶状态为应用级别属性(方案 A),存在 App 表中 +- 搜索框支持创建人搜索 +- 搜索框提示文案改为「名称/描述/创建人」 + +**Non-Goals:** +- 不实现用户级别的个人置顶偏好 +- 不改变现有的分页逻辑 +- 不改变搜索防抖机制 + +## Decisions + +### 1. 置顶实现: MongoDB 新增 `isPinned` 字段 + +- `AppSchema` 新增 `isPinned: { type: Boolean, default: false }` +- 后端排序改为 `.sort({ isPinned: -1, updateTime: -1 })` +- 复用现有 `putAppById` 接口切换置顶状态 +- 置顶操作放在 AppCard 菜单的 `hasManagePer` 段 +- Icon: `core/chat/setTopLight` + +### 2. 创建人搜索: 两步查询方案 + +- 若 `searchKey` 非空,先从 `MongoTeamMember` 按 `name` regex 匹配,收集匹配的 `tmbId[]` +- 将 `{ tmbId: { $in: matchedTmbIds } }` 加入 app 查询的 `$or` 条件 +- 无匹配创建人时不影响原有查询逻辑 + +### 3. 搜索文案: 修改国际化词条 `search_name_intro` + +- 中文: "名称/介绍" → "名称/描述/创建人" +- 英文: "Name/Description" → "Name/Description/Creator" +- 繁体: "名稱/介紹" → "名稱/描述/創建人" + +## Risks / Trade-offs + +- **置顶字段无索引风险** → 排序使用 `isPinned`,建议添加复合索引 `{ teamId: 1, isPinned: -1, updateTime: -1 }` 替代现有 `{ teamId: 1, updateTime: -1 }` +- **创建人搜索多一次 DB 查询** → TeamMember 查询是轻量的 `_id` 投影查询,`name` 字段已有索引,性能影响可控 +- **向后兼容** → `isPinned` 有默认值 `false`,旧数据不受影响 diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/proposal.md b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/proposal.md new file mode 100644 index 000000000000..06fc180312fa --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/proposal.md @@ -0,0 +1,29 @@ +## Why + +当前 Dashboard Agent 列表页不支持置顶功能,高频应用无法固定在列表顶部,且搜索仅支持名称和介绍,无法按创建人查找应用。 + +## What Changes + +- AppCard 操作菜单新增「置顶/取消置顶」操作项,icon 为 `core/chat/setTopLight` +- MongoDB App Schema 新增 `isPinned` 字段,后端排序优先已置顶项 +- 搜索框支持按创建人名称搜索 +- 搜索框提示文案改为「名称/描述/创建人」 + +## Capabilities + +### New Capabilities + +- `pin-app`: 应用/文件夹置顶功能,置顶项排在最前面,可切换置顶/取消置顶 +- `creator-search`: 搜索框支持按创建人名称搜索 + +### Modified Capabilities + + + +## Impact + +- `packages/service/core/app/schema.ts`: AppSchema 新增 `isPinned` 字段 +- `packages/global/core/app/type.ts`: AppListItemType 新增 `isPinned` 类型 +- `projects/app/src/pages/api/core/app/list.ts`: 排序改为置顶优先 + 创建人搜索支持 +- `projects/app/src/pageComponents/dashboard/agent/AppCard.tsx`: 菜单新增置顶/取消置顶操作项 +- `packages/web/i18n/zh-CN/app.json`、`zh-Hant/app.json`、`en/app.json`: `search_name_intro` 文案更新 diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/creator-search/spec.md b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/creator-search/spec.md new file mode 100644 index 000000000000..62224999f76b --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/creator-search/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Search by creator name + +搜索框 SHALL 支持按创建人名称搜索应用/文件夹。 + +#### Scenario: 按创建人名称搜索匹配 + +- **WHEN** 用户在搜索框中输入某个创建人的名称 +- **THEN** 查询结果 MUST 包含该创建人创建的所有应用(名称/描述中可能不包含搜索词) + +#### Scenario: 创建人搜索与名称描述搜索组合 + +- **WHEN** 用户输入的搜索词同时匹配应用名称和创建人名称 +- **THEN** 查询结果 MUST 包含所有匹配项(名称匹配 + 描述匹配 + 创建人匹配),取并集 + +#### Scenario: 无匹配创建人时的行为 + +- **WHEN** 用户输入的搜索词没有匹配到任何创建人名称 +- **THEN** 查询 MUST 仍按名称和描述进行搜索,不受影响 + +### Requirement: Search placeholder text update + +搜索框提示文案 SHALL 更新为包含创建人信息。 + +#### Scenario: 中文文案 + +- **WHEN** 当前语言为简体中文 +- **THEN** 搜索框 placeholder MUST 显示「名称/描述/创建人」 + +#### Scenario: 繁体中文文案 + +- **WHEN** 当前语言为繁体中文 +- **THEN** 搜索框 placeholder MUST 显示「名稱/描述/創建人」 + +#### Scenario: 英文文案 + +- **WHEN** 当前语言为英文 +- **THEN** 搜索框 placeholder MUST 显示「Name/Description/Creator」 diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/pin-app/spec.md b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/pin-app/spec.md new file mode 100644 index 000000000000..cb0d11c0df40 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/specs/pin-app/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Application pin/unpin + +AppCard 操作菜单 SHALL 提供置顶/取消置顶操作项,置顶的应用或文件夹排在列表最前面。 + +#### Scenario: 置顶一个未置顶的应用 + +- **WHEN** 用户点击未置顶应用的菜单中的「置顶」操作项 +- **THEN** 该应用的 `isPinned` 设置为 `true`,列表重新排序后该应用排在所有未置顶应用之前 + +#### Scenario: 取消一个已置顶的应用 + +- **WHEN** 用户点击已置顶应用的菜单中的「取消置顶」操作项 +- **THEN** 该应用的 `isPinned` 设置为 `false`,列表重新排序后该应用按更新时间排列 + +#### Scenario: 置顶一个文件夹 + +- **WHEN** 用户点击文件夹的菜单中的「置顶」操作项 +- **THEN** 该文件夹的 `isPinned` 设置为 `true`,文件夹排在所有未置顶项之前 + +#### Scenario: 置顶排序规则 + +- **WHEN** 列表中存在多个已置顶的项 +- **THEN** 已置顶的项按 `updateTime` 降序排列,然后未置顶的项按 `updateTime` 降序排列 + +#### Scenario: 向后兼容 + +- **WHEN** 旧数据没有 `isPinned` 字段 +- **THEN** `isPinned` 默认为 `false`,正常显示在未置顶区域 + +### Requirement: Pin menu item presentation + +置顶操作项 SHALL 展示正确的图标和文案。 + +#### Scenario: 置顶图标 + +- **WHEN** 置顶操作项在菜单中渲染 +- **THEN** MUST 使用 `core/chat/setTopLight` 图标 + +#### Scenario: 置顶文案切换 + +- **WHEN** 应用 `isPinned` 为 `false` +- **THEN** 菜单项文案显示为「置顶」 + +#### Scenario: 取消置顶文案切换 + +- **WHEN** 应用 `isPinned` 为 `true` +- **THEN** 菜单项文案显示为「取消置顶」 + +### Requirement: Pin tag display on card + +已置顶的应用/文件夹 SHALL 在卡片标题行名称右侧展示置顶 Tag。 + +#### Scenario: 已置顶应用显示 Tag + +- **WHEN** 应用 `isPinned` 为 `true` +- **THEN** 卡片标题行名字右侧 MUST 渲染一个 `MyTag` 组件,`colorSchema="primary"`,文案为 `t('common:core.chat.Pin')` + +#### Scenario: 未置顶应用不显示 Tag + +- **WHEN** 应用 `isPinned` 为 `false` 或 `undefined` +- **THEN** 标题行中 MUST NOT 渲染置顶 Tag + +#### Scenario: Tag 始终可见 + +- **WHEN** 应用已置顶 +- **THEN** 置顶 Tag MUST 始终可见,不参与 type-tag / more-menu 的 hover 切换逻辑 diff --git a/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/tasks.md b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/tasks.md new file mode 100644 index 000000000000..4aba66c986c5 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-add-pin-and-creator-search/tasks.md @@ -0,0 +1,39 @@ +## 1. MongoDB Schema 新增 `isPinned` 字段 + +- [x] 1.1 在 `packages/service/core/app/schema.ts` 的 AppSchema 中新增 `isPinned: { type: Boolean, default: false }` +- [x] 1.2 在 `packages/global/core/app/type.ts` 的 AppListItemType 中新增 `isPinned?: boolean` +- [x] 1.3 更新 DB 索引:将 `{ teamId: 1, updateTime: -1 }` 改为 `{ teamId: 1, isPinned: -1, updateTime: -1 }` + +## 2. 后端排序与搜索修改 + +- [x] 2.1 在 `projects/app/src/pages/api/core/app/list.ts` 中将排序改为 `.sort({ isPinned: -1, updateTime: -1 })` +- [x] 2.2 在 `list.ts` 的 handler 中,searchKey 非空时先查询 `MongoTeamMember` 匹配的 tmbId 列表 +- [x] 2.3 将匹配的 `tmbId: { $in: tmbIds }` 加入 searchMatch 的 `$or` 条件 + +## 3. AppCard 菜单新增置顶操作 + +- [x] 3.1 在 `AppCard.tsx` 的 menuList(hasManagePer 段)中新增置顶/取消置顶菜单项 +- [x] 3.2 置顶操作 icon 使用 `core/chat/setTopLight` +- [x] 3.3 已置顶时显示 `t('common:core.chat.Unpin')`,未置顶时显示 `t('common:core.chat.Pin')`(词条已存在,无需新增) +- [x] 3.4 点击后调用 `onUpdateApp` 切换 `isPinned` 状态,并刷新列表 + +## 4. 搜索框文案更新 + +- [x] 4.1 修改 `packages/web/i18n/zh-CN/app.json` 中 `search_name_intro` 为 "名称/描述/创建人" +- [x] 4.2 修改 `packages/web/i18n/zh-Hant/app.json` 中 `search_name_intro` 为 "名稱/描述/創建人" +- [x] 4.3 修改 `packages/web/i18n/en/app.json` 中 `search_name_intro` 为 "Name/Description/Creator" +- [x] 4.4 在 `index.tsx` 中确认 SearchInput 的 placeholder 引用的是 `t('app:search_name_intro')`(已有,无需改动) + +## 5. AppCard 名称后展示置顶 Tag + +- [x] 5.1 在 `AppCard.tsx` 标题行中,名称右侧新增 `MyTag` 组件,`colorSchema="primary"`,仅在 `app.isPinned` 时渲染 +- [x] 5.2 Tag 文案使用 `t('common:core.chat.Pin')`(词条已存在) +- [x] 5.3 Tag 放置在名称与类型标签之间,`flexShrink={0}`,始终可见 + +## 6. 验证 + +- [x] 6.1 验证置顶操作:点击置顶后该应用排在列表最前面,再次点击取消置顶恢复原序 +- [x] 6.2 验证文件夹也能置顶 +- [x] 6.3 验证创建人搜索:输入创建人名称能搜出对应应用 +- [x] 6.4 验证搜索框文案显示正确(三语种) +- [x] 6.5 验证向后兼容:旧数据(无 isPinned 字段)默认为 false,正常显示 diff --git a/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/.openspec.yaml b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/.openspec.yaml new file mode 100644 index 000000000000..40cc12f45740 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-12 diff --git a/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/design.md b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/design.md new file mode 100644 index 000000000000..d08642c5fd7a --- /dev/null +++ b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/design.md @@ -0,0 +1,46 @@ +## Context + +文本内容提取节点(`NodeExtract`)动态创建输出字段时,在 `NodeExtract/index.tsx` L213 中拼接 label: +``` +label: `${t('common:extraction_results')}-${data.key}` +``` +结果如 `提取结果-dwa`,在后续节点(如判断器)的变量引用选择器中展示为此格式。 + +用户更关心字段名(如 `dwa`)而非前缀(如 `提取结果`),字段名前置可提升辨识效率,长字段名时尤为明显。 + +## Goals / Non-Goals + +**Goals:** +- 调换动态提取字段的输出 label 格式:`{字段名} - {提取结果}` 替代 `{提取结果}-{字段名}` +- 仅影响文本内容提取节点新增的字段 + +**Non-Goals:** +- 不改变已有的存量字段 label(需用户重新保存字段才会更新) +- 不改变变量引用选择器的级联结构 +- 不改变其他节点的输出 label 格式 + +## Decisions + +### 决策:修改 NodeExtract 输出 label 拼装顺序 + +`NodeExtract/index.tsx` L213 是唯一的修改点: +```typescript +// Before +label: `${t('common:extraction_results')}-${data.key}`, +// After +label: `${data.key} - ${t('common:extraction_results')}`, +``` + +同时将分隔符从 `-` 改为 ` - `(空格包围),与用户期望的 `字段名 - 提取结果` 格式一致。 + +## Risks / Trade-offs + +- **存量字段不更新**:已创建字段的 label 不会变化,只有编辑后重新保存才会生效 + - 缓解:这是纯展示优化,不影响功能;用户可批量编辑字段来刷新 label +- **仅影响 NodeExtract**:项目中无其他节点使用相同 label 拼接模式 + +## Migration Plan + +- 纯前端展示变更,无需数据迁移 +- 存量字段 label 保持不变(已序列化在 workflow 数据中) +- 回滚:恢复 L213 的字符串模板顺序即可 diff --git a/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/proposal.md b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/proposal.md new file mode 100644 index 000000000000..9ca854ad5914 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/proposal.md @@ -0,0 +1,23 @@ +## Why + +工作流变量引用选择器中,已选变量展示为 `{节点输出标签} → {字段名}` 格式(如 "提取结果 → 字段名")。当字段名较长时,关键信息(字段名本身)被挤到右侧,用户难以快速辨认所选字段。将字段名提前可提升可读性和选择效率。 + +## What Changes + +- 调换变量引用选择器已选值的展示顺序:`{字段名} → {节点输出标签}` 替代 `{节点输出标签} → {字段名}` +- 同时调整多选(数组引用)模式下的展示顺序保持一致 +- 下拉级联选择的列顺序保持不变(仍为节点 → 输出),仅调整选中后的展示标签 + +## Capabilities + +### New Capabilities + +- `workflow-variable-label-display`: 工作流变量引用选择器已选变量的标签展示格式 + +### Modified Capabilities + + + +## Impact + +- `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx` — `SingleReferenceSelector` 和 `MultipleReferenceSelector` 组件中的 display label 顺序 diff --git a/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/specs/workflow-variable-label-display/spec.md b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/specs/workflow-variable-label-display/spec.md new file mode 100644 index 000000000000..70e7baae3500 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/specs/workflow-variable-label-display/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Extraction field label displays field name first + +The system SHALL display text content extraction output labels in the format `{字段名} - {提取结果}` instead of `{提取结果}-{字段名}`. + +#### Scenario: New extraction field label order + +- **WHEN** user adds a new extraction field with key "dwa" in a text content extraction node +- **THEN** the output label SHALL be "dwa - 提取结果" + +#### Scenario: Existing fields retain old labels + +- **WHEN** an existing extraction field was created before this change +- **THEN** the output label SHALL remain unchanged until the user edits and re-saves the field diff --git a/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/tasks.md b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/tasks.md new file mode 100644 index 000000000000..d27542eeeb6f --- /dev/null +++ b/openspec/changes/archive/2026-05-12-swap-workflow-variable-label-order/tasks.md @@ -0,0 +1,13 @@ +## 1. 修正 + +- [x] 1.1 回退 `Reference.tsx` 的错误修改(`git checkout`) + +## 2. 核心实现 + +- [x] 2.1 在 `NodeExtract/index.tsx` L213 将 `label: \`${t('common:extraction_results')}-${data.key}\`` 改为 `label: \`${data.key} - ${t('common:extraction_results')}\`` + +## 3. 验证 + +- [x] 3.1 在工作流中添加文本内容提取节点,新增提取字段(如 `dwa`),确认输出标签展示为 `dwa - 提取结果` +- [x] 3.2 在下游节点(如判断器)中引用该字段,确认引用选择器中显示 `dwa - 提取结果` +- [x] 3.3 编辑已有字段并保存,确认标签更新为新格式 diff --git a/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/design.md b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/design.md new file mode 100644 index 000000000000..c59c4ef6b1fb --- /dev/null +++ b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/design.md @@ -0,0 +1,59 @@ +## Context + +API 文件库(apiDataset)允许用户接入第三方文件系统(如 API 服务器提供的文件)。当前系统存在以下缺口: + +1. **无权限同步机制**:第三方文件库可能自带权限体系,但 FastGPT 无法感知和使用这些权限 +2. **添加文件流程冗余**:API 文件库添加文件需要经过 4 个步骤,但实际上参数设置、数据预览、确认上传等步骤对 API 文件库意义不大 +3. **编辑权限不一致**:前端使用 `hasWritePer` 控制编辑按钮可见性,但后端 API 校验 `ManagePermissionVal`,导致 write 权限用户看到编辑按钮但 API 返回 403 + +## Goals / Non-Goals + +**Goals:** +- CreateModal 新增 `permissionSync` 开关,仅 apiDataset 类型可见 +- 自动同步 Switch 后新增可点击提示(仅 apiDataset),点击打开弹窗(TODO) +- 权限同步开启后,文件列表操作菜单的「权限」选项禁用并展示 tooltip +- 文件列表「查看原文」菜单项标记 TODO,留待开发者验证第三方跳转逻辑 +- API 文件库添加文件流程简化为 1 步,使用默认参数自动提交,放在myModal组件里面 +- 前端编辑知识库按钮统一要求 `hasManagePer` + +**Non-Goals:** +- 不实现权限同步的实际后端同步逻辑 +- 不实现自动同步 API 弹窗的具体内容 → 已实现:表格展示「接口请求」/「接口响应」两个 tab,调用 `postDatasetSync` API +- 不实现「查看原文」跳转到第三方文件库的具体逻辑 +- 不修改后端 API 的权限校验逻辑(已正确校验 ManagePermissionVal) +- 不涉及 feishu / yuque / websiteDataset 类型 + +## Decisions + +### 1. permissionSync 放入 APIFileServerSchema + +- `permissionSync` 仅在 `DatasetTypeEnum.apiDataset` 时展示 +- 放入 `packages/global/core/dataset/apiDataset/type.ts` 的 `APIFileServerSchema` 中 +- 随 `apiDatasetServer.apiServer` 对象整体序列化到 MongoDB +- 前端访问路径:`datasetDetail.apiDatasetServer?.apiServer?.permissionSync` +- 表单路径:`form.watch('apiDatasetServer.apiServer.permissionSync')` +- ApiDatasetForm 逐字段 register,不会覆盖 permissionSync +- **`filterApiDatasetServerPublicData`(utils.ts)在白名单展开 `apiServer` 字段时必须显式包含 `permissionSync`,否则 detail 接口返回数据中该字段丢失** + +### 2. 添加文件简化:去掉步骤导航 + 直接提交 + +- `Context.tsx` 中 apiDataset 的 steps 改为单步 +- `APIDataset.tsx` 中只渲染 `activeStep === 0`(CustomAPIFileInput) +- 选择文件后 `goToNext` 改为直接调用提交 API,携带 `defaultFormData` +- **进一步重构(v2)**:发现 Import 页流程仍会触发页面跳转再弹窗,体验割裂。将 `CustomAPIFileInput` 提炼为独立组件 `RefinedCollectionCard/APIFileSelectModal.tsx`,`CollectionNavActions.tsx` 的「添加文件」按钮直接打开该 Modal,完全绕开 Import 页路由。`APIDataset.tsx` 原文件保留供 Import 流程使用,顶部添加注释说明。 + +### 3. 权限收紧范围:仅两处 + +- `NewList.tsx:174`:`hasMenuPer` 对非 folder 类型从 `hasWritePer` 改为 `hasManagePer` +- `Info/index.tsx:149-163`:编辑图标包裹 `hasManagePer` 检查 + +### 4. 查看原文 TODO 处理 + +- 在 `RefinedCollectionCard/index.tsx` 的 `sourceItem.onClick` 中,对 apiDataset 类型添加 TODO 注释标记 +- 保持现有 `handleReadSource` 逻辑不变 + +## Risks / Trade-offs + +- **添加文件简化**:跳过参数设置后用户无法自定义 chunk 参数,需确保 `defaultFormData` 的默认值合理 +- **权限收紧**:对已有 write 权限的用户,编辑按钮会消失,属于预期行为(与后端校验对齐) +- **permissionSync 向前兼容**:默认 `false`,旧数据不受影响 diff --git a/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/proposal.md b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/proposal.md new file mode 100644 index 000000000000..d2025f509787 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/proposal.md @@ -0,0 +1,36 @@ +## Why + +API 文件库(apiDataset)需要支持权限同步功能,简化添加文件流程,并收紧知识库编辑权限控制。当前系统缺少权限同步开关,添加文件流程步骤过多,且编辑权限的判断不一致(前端 write、后端 manage)。 + +## What Changes + +- **CreateModal 弹窗宽度**:490px → 600px +- **自动同步提示**:apiDataset 类型的自动同步 Switch 后新增可点击提示「调用同步API实现实时同步」,点击打开弹窗(标记 TODO) +- **权限同步字段**:apiDataset 新增 `permissionSync` 开关,开启后文件列表中的权限操作将被禁用 +- **文件菜单权限禁用**:权限同步开启后,文件操作菜单的「权限」选项禁用并展示 tooltip 提示 +- **查看原文跳转**:apiDataset 文件列表「查看原文」跳转到第三方文件库(标记 TODO) +- **添加文件简化**:apiDataset 添加文件流程从 4 步(选择文件→参数设置→数据预览→确认上传)简化为 1 步(选择文件),参数使用默认值 +- **权限收紧**:知识库编辑按钮从 `hasWritePer` 收紧为 `hasManagePer` + +## Capabilities + +### New Capabilities + +- `dataset-permission-sync`: API 文件库权限同步功能,开启后文件权限由第三方系统管理 +- `api-dataset-import-simplify`: API 文件库添加文件流程简化,一步选择即可 + +### Modified Capabilities + +- `dataset-edit-permission`: 编辑知识库信息权限从 write 收紧为 manage + +## Impact + +- `packages/global/core/dataset/type.ts`: 新增 `permissionSync` 字段 +- `packages/service/core/dataset/schema.ts`: MongoDB Schema 新增 `permissionSync` 字段 +- `projects/app/src/pageComponents/dataset/list/CreateModal.tsx`: 弹窗宽度、自动同步提示、权限同步字段 +- `projects/app/src/pageComponents/dataset/detail/RefinedCollectionCard/index.tsx`: 文件菜单权限禁用、查看原文 TODO +- `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx`: 简化导入步骤 +- `projects/app/src/pageComponents/dataset/detail/Import/Context.tsx`: apiDataset 步骤配置简化 +- `projects/app/src/pageComponents/dataset/list/NewList.tsx`: 编辑按钮权限收紧 +- `projects/app/src/pageComponents/dataset/detail/Info/index.tsx`: 编辑按钮权限收紧 +- `packages/web/i18n/*/dataset.json`: 新增国际化词条 diff --git a/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/tasks.md b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/tasks.md new file mode 100644 index 000000000000..82fc69bc1c74 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-dataset-api-permission-sync/tasks.md @@ -0,0 +1,62 @@ +## 1. Schema 新增 `permissionSync` 到 `APIFileServerSchema` + +- [x] 1.1 在 `packages/global/core/dataset/apiDataset/type.ts` 的 `APIFileServerSchema` 中新增 `permissionSync: z.boolean().optional().meta({ description: '是否同步权限' })` +- [x] 1.2 随 `apiDatasetServer.apiServer` 对象序列化到 MongoDB,无需单独 Schema 改动 + +## 2. CreateModal 弹窗改动 + +- [x] 2.1 弹窗宽度从 `w={'490px'}` 改为 `w={'600px'}` +- [x] 2.2 在自动同步 Switch 后新增可点击提示文字「调用同步API实现实时同步」(仅 `type === apiDataset` 时展示),点击打开弹窗,弹窗内容标记 TODO(已由 10.1 正式实现) +- [x] 2.3 新增「权限同步」表单行:`FormLabel` + `QuestionTip`(悬浮提示文案)+ `Switch` + 说明文字「开启后使用同步的权限,不再支持此处设置(请保障已进行账号同步)」(仅 `type === apiDataset` 时展示) +- [x] 2.4 `onclickCreate` 中 `apiDatasetServer` 已自动携带 `permissionSync`,无需额外处理 +- [x] 2.5 编辑模式下 `apiDatasetServer` 已通过 `data.apiDatasetServer as any` 整体回填,`permissionSync` 随对象自动回填 + +## 3. 文件操作菜单改动(RefinedCollectionCard) + +- [x] 3.1 从 `DatasetPageContext` 读取 `datasetDetail.apiDatasetServer?.apiServer?.permissionSync` +- [x] 3.2 在 `permissionItem` 中:当 `permissionSync === true` 时,菜单项 `isDisabled` 并展示 tooltip「知识库已开启权限同步,不再支持此处设置」 +- [x] 3.3 在 `sourceItem` 中:当数据集类型为 `apiDataset` 时,添加 TODO 注释标记查看原文跳转到第三方文件库的逻辑 +- [x] 3.4 `fileDataCard` 类型的文件列表如有权限操作菜单同样需要处理(检查后无权限菜单,无需改动) + +## 4. 添加文件流程简化(仅 apiDataset) + +- [x] 4.1 在 `Import/Context.tsx` 的 `modeSteps` 中,将 `apiDataset` 的 steps 改为仅 1 步:`[{ title: t('dataset:import_select_file') }]` +- [x] 4.2 在 `Import/diffSource/APIDataset.tsx` 中,移除 `activeStep === 1/2/3` 的渲染,只保留 `activeStep === 0` +- [x] 4.3 修改 `onclickNext` 逻辑:选择文件后直接调用创建 API,携带 `defaultFormData` 作为参数 +- [x] 4.4 确保步骤导航 UI(MyStep)在 apiDataset 时不显示(只有 1 步无需导航) + +## 5. 权限收紧 + +- [x] 5.1 在 `NewList.tsx:174`,将非 folder 类型的 `hasMenuPer` 从 `hasWritePer` 改为 `hasManagePer` +- [x] 5.2 在 `Info/index.tsx:149-163`,编辑图标用 `datasetDetail.permission.hasManagePer` 包裹,仅管理员可见 + +## 6. 国际化词条 + +- [x] 6.1 在 `packages/web/i18n/zh-CN/dataset.json` 中新增词条:`permission_sync`、`permission_sync_tip`、`permission_sync_desc`、`sync_api_tip`、`permission_sync_disabled_tip`、`sync_api`、`api_request`、`api_response`、`click_sync_first`、`sync`、`sync_failed` +- [x] 6.2 在 `packages/web/i18n/zh-Hant/dataset.json` 中新增对应繁体词条 +- [x] 6.3 在 `packages/web/i18n/en/dataset.json` 中新增对应英文词条 + +## 7. 验证 + +- [x] 7.1 验证 CreateModal 弹窗宽度为 600px +- [x] 7.2 验证 apiDataset 类型创建/编辑时,自动同步提示和权限同步字段正确展示 +- [x] 7.3 验证非 apiDataset 类型不展示新增字段 +- [x] 7.4 验证权限同步开启后,文件列表权限操作被禁用并显示 tooltip +- [x] 7.5 验证 apiDataset 添加文件流程只有 1 步,参数使用默认值正常提交 +- [x] 7.6 验证 write 权限用户看不到编辑按钮,manage 权限用户正常看到 +- [x] 7.7 验证 permissionSync 字段正确存取(新建 + 编辑回填) + +## 8. Bug 修复 + +- [x] 8.1 `filterApiDatasetServerPublicData`(`packages/global/core/dataset/apiDataset/utils.ts`)在构造 `apiServer` 返回对象时补充 `permissionSync` 字段,否则 `detail` 接口始终丢失该字段 + +## 9. 添加文件弹窗重构(apiDataset 直接在当前页弹窗) + +- [x] 9.1 新建 `RefinedCollectionCard/APIFileSelectModal.tsx`:从 `Import/diffSource/APIDataset.tsx` 的 `CustomAPIFileInput` 提炼,接受 `isOpen/onClose/parentId/onSuccess` props,不依赖 `DatasetImportContext`,成功后调用 `onSuccess()` + `onClose()` 而非 `router.replace` +- [x] 9.2 在 `Import/diffSource/APIDataset.tsx` 顶部添加注释,说明该文件供 Import 页流程使用,当前页弹窗请用 `APIFileSelectModal.tsx` +- [x] 9.3 修改 `CollectionNavActions.tsx`:「添加文件」按钮改为 `setShowAPIFileSelectModal(true)`,移除 `router.replace` 跳转逻辑;动态引入 `APIFileSelectModal` 并在弹窗区域渲染;清理不再使用的 `ImportDataSourceEnum` 和 `TabEnum` import + +## 10. 同步 API 弹窗实现 + +- [x] 10.1 在 `CreateModal.tsx` 中将 TODO 弹窗替换为正式实现:使用 `FillRowTabs` 切换「接口请求」/「接口响应」tab,`CodeLight` 展示动态 curl 命令和 JSON 响应,调用 `postDatasetSync` API +- [x] 10.2 新增 i18n 词条:`sync_api`、`api_request`、`api_response`、`click_sync_first`、`sync`、`sync_failed`(三语) diff --git a/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/design.md b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/design.md new file mode 100644 index 000000000000..d5fa84fef304 --- /dev/null +++ b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/design.md @@ -0,0 +1,115 @@ +# Markdown 图片闪烁问题修复设计 + +## 现状 + +`projects/app/src/components/Markdown/index.tsx` 中: + +### 问题 1:img 渲染器被连带重建 + +```tsx +// 第 89-108 行 +const components = useCreation(() => { + return { + img: (props: any) => {props.alt}, + // ↑ 这是一个箭头函数,被 useCreation 创建 + // 当 useCreation deps 改变时,这个函数是新引用 + pre: RewritePre, + code: Code, + table: MarkdownTable as any, + a: (props: any) => + }; +}, [chatAuthData, onOpenCiteModal, showAnimation, citeStyle, citeIndexMap, citeSourceMap]); +// ↑ citeIndexMap 随 source 变化的每个 token 刷新 +// ↑ img 标签根本不依赖 citeIndexMap,但被迫随着重建 +``` + +### 问题 2:citeIndexMap 每次 source 变化都返回新 Map + +```tsx +// 第 65-87 行 +const citeIndexMap = useMemo(() => { + if (citeStyle !== 'index') return undefined; + const map = new Map(); + // ... 填充 map ... + return map; // ← 每次都是新 Map 引用 +}, [citeStyle, source, citeSourceMap]); +// ↑ source 随 streaming token 频繁变化 +``` + +### 问题 3:MdImage 无加载缓存 + +```tsx +// img/Image.tsx:13 +const [isLoaded, { setTrue }] = useBoolean(false); +// 只要组件 remount,isLoaded 回退到 false → Skeleton 闪烁 +``` + +## 修复设计 + +### 修复 A:拆离 img 渲染器(治根) + +将 img 组件从 `useCreation` 依赖链中解耦,使用独立的 `useCreation`: + +```tsx +const imgComponent = useCreation(() => { + return (props: any) => {props.alt}; +}, [chatAuthData]); + +const components = useCreation(() => { + return { + img: imgComponent, // ← 使用独立的稳定引用 + pre: RewritePre, + code: Code, + table: MarkdownTable as any, + a: (props: any) => + }; +}, [chatAuthData, onOpenCiteModal, showAnimation, citeStyle, citeIndexMap, citeSourceMap]); +``` + +效果: +- `imgComponent` 只在 `chatAuthData` 变化时重建(流式期间稳定) +- `components` 仍按原有 deps 变化,但 `components.img` 指向同一函数 +- ReactMarkdown 的 `createElement(components.img, ...)` 的 type 保持不变 +- React 不会 unmount Image → MdImage state 保持 → 不闪烁 + +### 修复 B:图片加载状态缓存(防御层) + +在 `MdImage` 模块级别(组件外部)维护 `Set` 作为已加载 src 的全局缓存: + +```tsx +const loadedSrcSet = new Set(); + +function MdImage({ src, ...props }) { + const isInitiallyLoaded = src ? loadedSrcSet.has(src) : false; + const [isLoaded, { setTrue }] = useBoolean(isInitiallyLoaded); + const [renderSrc, setRenderSrc] = useState(src); + + // 图片加载成功后缓存 + // ... +} +``` + +**为什么是模块级而非 useRef?** + +`useRef` 放在 `MdImage` 内部无法实现跨实例缓存——每个 MdImage 有独立的 ref,组件 remount 后 ref 也是新的,缓存失效。模块级 Set 是唯一能实现跨实例共享缓存的方式。 + +效果:即使未来有其他依赖变化导致 Image 被 remount,只要 `src` 之前已加载过,`isLoaded` 初始化为 `true`,不会显示 Skeleton。 + +## 修改范围 + +### 文件 1:`projects/app/src/components/Markdown/index.tsx` + +- 将 `Image` 函数(第 192-194 行)改为使用稳定的组件引用 +- 新增独立的 `imgComponent` 的 `useCreation` +- `components` 中的 `img` 字段指向 `imgComponent` + +### 文件 2:`projects/app/src/components/Markdown/img/Image.tsx` + +- 新增 `loadedSrcSet` 缓存(`useRef>`) +- `isLoaded` 初始值依赖缓存检查 +- `onLoad` 回调中缓存已加载的 src + +## 风险 + +- **低风险**:纯组件拆离,无业务逻辑变更 +- **缓存不过期**:`loadedSrcSet` 为模块级,在页面生命周期内持续累积已加载的图片 URL。普通 URL 字符串占用可忽略;base64 图片 URL 理论上有一定内存占用,但实际场景中极少出现。如需优化,可考虑设置 Set 容量上限,但现阶段不必要 diff --git a/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/proposal.md b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/proposal.md new file mode 100644 index 000000000000..365e1bfd37ff --- /dev/null +++ b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/proposal.md @@ -0,0 +1,66 @@ +## 背景 + +在 AI 回答的流式传输过程中,如果 AI 返回了图片(Markdown 图片语法 `![](url)`),尚未完成输出时,图片会不断闪烁。 + +## 根因链 + +``` +每个 streaming token + │ + ▼ +source 变化 + │ + ▼ +citeIndexMap = new Map() ← source 变就重建 + │ + ▼ +useCreation deps 变了 + │ + ▼ +components 对象重建 + │ + ▼ +components.img = 新箭头函数 ← img 不依赖 citeIndexMap,但被连带重建 + │ + ▼ +ReactMarkdown: createElement 的 type 变了 + │ + ▼ +React: unmount 旧 Image → mount 新 Image + │ + ▼ +MdImage: isLoaded 重置为 false → Skeleton 出现 → 闪烁 +``` + +## 方案 + +### 修复 A(治根):拆离 img 渲染器 + +将 `components.img` 从大的 `useCreation` 拆出,只依赖 `chatAuthData`(流式期间稳定): + +```tsx +const imgComponent = useCreation(() => { + return (props: any) => ; +}, [chatAuthData]); // ← 全程不变 → img 引用稳定 +``` + +`a` 等其他渲染器依赖 `citeIndexMap`,留在原 `useCreation` 中。互不影响。 + +### 修复 B(防御):图片加载状态缓存 + +`MdImage` 中维护 `useRef>` 作为已加载 src 的缓存,即使 Image 因其他原因 remount,也能直接从缓存判断加载状态,跳过 Skeleton。 + +## 非目标 + +- 不改变 ReactMarkdown 的解析逻辑 +- 不改变图片显示样式或交互行为 +- 不涉及非流式场景的渲染逻辑 +- 不涉及其他 Markdown 渲染器(a、code、table 等) + +## 预期效果 + +流式输出过程中: +- Image 组件在首个 token 到达后稳定挂载 +- 后续 token 到来时不 unmount/mount +- isLoaded 维持 true → Skeleton 始终隐藏 +- 用户看不到任何闪烁 diff --git a/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/tasks.md b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/tasks.md new file mode 100644 index 000000000000..0619e269990d --- /dev/null +++ b/openspec/changes/archive/2026-05-20-fix-markdown-image-flicker-during-streaming/tasks.md @@ -0,0 +1,25 @@ +# 任务列表 + +## TODO + +- [x] **Task 1**:`index.tsx` — 新增 `imgComponent` useCreation,deps 仅含 `chatAuthData` + - 文件:`projects/app/src/components/Markdown/index.tsx` + - 在 `const components = useCreation(...)` 之前添加: + ```tsx + const imgComponent = useCreation( + () => (props: any) => {props.alt}, + [chatAuthData] + ); + ``` + - 将 `components` 中的 `img` 替换为 `img: imgComponent` + - 移除原来 `img` 行的内联箭头函数 + +- [x] **Task 2**:`img/Image.tsx` — 增加图片加载状态缓存 + - 文件:`projects/app/src/components/Markdown/img/Image.tsx` + - 在模块级别(组件外部)声明 `const loadedSrcSet = new Set()` + - `useBoolean` 初始值改为 `src ? loadedSrcSet.has(src) : false` + - `onLoad` 回调中 `loadedSrcSet.add(src)` + +- [x] **Task 3**:验证 + - 流式输出期间,图片出现后不再闪烁 + - 普通(非流式)场景,图片正常加载 Skeleton → 显示 diff --git a/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/.openspec.yaml b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/.openspec.yaml new file mode 100644 index 000000000000..34f9314d2207 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/design.md b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/design.md new file mode 100644 index 000000000000..d12e0c7ddb54 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/design.md @@ -0,0 +1,60 @@ +## Context + +对话日志页面 (`ConversationLogs`) 是智能客服应用的一部分,在 `SmartCustomerService` 中通过条件渲染展示: + +``` +currentTab === TabEnum.logs && +``` + +内部有两个子 tab:日志列表 (`LogList`) 和优化记录 (`OptimizeRecords`),各自使用独立的 API 和分页 hook (`usePagination` / `useScrollPagination`),两者都支持 `refreshDeps` 触发数据刷新。 + +当前切换内部 tab 时组件会 remount → 自动刷新。但浏览器标签页切回时不会触发刷新。 + +## Goals / Non-Goals + +**Goals:** +- 浏览器标签页从不可见变为可见时,自动刷新当前活跃子 tab 的数据 +- 复用现有 `refreshDeps` 机制,不引入新的数据流 + +**Non-Goals:** +- 不改变现有内部 tab 切换的刷新逻辑 +- 不添加轮询机制 +- 不处理 Dashboard、Publish 等其他 tab 的 visibility 刷新 + +## Decisions + +### Decision 1: 在 `ConversationLogs` 父组件统一管理 visibility 事件 + +**选择**:`ConversationLogs/index.tsx` 中加 `useEffect` 监听 `visibilitychange`,维护一个 `refreshKey` 计数器向下传递。 + +**备选方案**: +- 分别在 `LogList` 和 `OptimizeRecords` 内部各自监听 → 放弃。两个子组件各自监听会产生重复的事件绑定,且当前只有活跃子 tab 在 DOM 中,不如父组件统一管理简洁。 +- 在 `usePagination` / `useScrollPagination` 通用 hook 中实现 → 放弃。影响面太大,不是所有使用场景都需要此行为。 + +**理由**:父组件层面只需一份监听逻辑,通过 prop 向下传递刷新信号,两个子组件利用已有的 `refreshDeps` 机制响应。符合最小侵入原则。 + +### Decision 2: 使用 `refreshKey` 计数器而非布尔标志 + +**选择**:`useState(0)` 计数器,每次可见时 `setRefreshKey((k) => k + 1)`。 + +**备选方案**: +- 布尔 `refreshFlag` 切换 → 放弃。连续两次 visibility 变化需要回到 `false` 状态才能再次触发,增加状态管理复杂度。 + +**理由**:计数器天然支持连续触发,每次递增都是新值,`refreshDeps` 数组检测到变化即触发刷新。 + +### Decision 3: 使用 `visibilityState` 而非 `focus` 事件 + +**选择**:`document.addEventListener('visibilitychange', ...)` 检查 `document.visibilityState === 'visible'`。 + +**备选方案**: +- `window.focus` 事件 → 放弃。不够精确,浏览器窗口内切换标签页不会触发 `focus`。 + +**理由**:`visibilitychange` 是检测浏览器标签页切换的标准 API,项目中 `chatContext.tsx` 已有相同用法。 + +## Risks / Trade-offs + +- [用户频繁切换浏览器标签页可能导致多余请求] + → 可接受。`visibilitychange` 触发频率低(人工操作,秒级),与已有的 tab 切换 remount 行为一致。 + +- [`useScrollPagination` 的 `refreshDeps` 和 `paramsKey` 两套刷新路径可能导致一次 visibility 变化触发两次请求] + → 低概率。`refreshDeps` 通过 `useRequest` 触发,`paramsKey` 通过 `useEffect` 触发。两者依赖不同(一个看 `refreshDeps` 数组,一个看 `JSON.stringify(params)`),只要 `params` 不变就不会触发 `paramsKey` 路径。当前设计不会引入此问题。 diff --git a/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/proposal.md b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/proposal.md new file mode 100644 index 000000000000..8eb3dece8a7b --- /dev/null +++ b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/proposal.md @@ -0,0 +1,35 @@ +## Why + +对话日志页面(日志列表和优化记录)在用户长时间停留在页面后数据可能已过期。用户切换到其他浏览器标签页再切回时,需要手动刷新或切换内部 tab 才能看到最新数据,体验不佳。 + +## What Changes + +- 对话日志页面新增浏览器标签页可见性监听:当用户从其他浏览器标签页切回对话日志页面时,自动刷新当前活跃的子 tab 数据 +- `ConversationLogs` 父组件新增 `refreshKey` 状态和 `visibilitychange` 事件监听 +- `LogList` 和 `OptimizeRecords` 各自接收 `refreshKey` prop,编入已有的 `refreshDeps` 触发刷新 + +## Capabilities + +### New Capabilities +- `conversation-logs-visibility-refresh`: 浏览器标签页切回对话日志页面时自动刷新当前活跃子 tab 的数据 + +### Modified Capabilities + + +## Impact + +| 影响范围 | 文件 | +|---------|------| +| 对话日志父组件 | `projects/app/src/pageComponents/app/detail/ConversationLogs/index.tsx` | +| 日志列表 | `projects/app/src/pageComponents/app/detail/ConversationLogs/LogList.tsx` | +| 优化记录 | `projects/app/src/pageComponents/app/detail/ConversationLogs/OptimizeRecords.tsx` | + +- 不影响已有 tab 切换行为(条件渲染 → remount → 首次 fetch) +- 不涉及数据模型或 API 变更 +- 不影响其他页面 + +## Non-goals + +- 不改变现有内部 tab 切换的刷新逻辑 +- 不添加轮询机制 +- 不处理其他页面(Dashboard、Publish 等)的 visibility 刷新 diff --git a/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/specs/conversation-logs-visibility-refresh/spec.md b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/specs/conversation-logs-visibility-refresh/spec.md new file mode 100644 index 000000000000..986d33a7bcf7 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/specs/conversation-logs-visibility-refresh/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: 浏览器标签页切回时自动刷新对话日志数据 + +当用户将浏览器标签页切换到对话日志页面所在标签页时,系统 SHALL 自动刷新当前活跃子 tab(日志列表或优化记录)的数据。 + +#### Scenario: 日志列表页面在浏览器标签页切回时自动刷新 + +- **WHEN** 用户当前在对话日志页面的「日志详情」子 tab +- **AND** 用户切换到另一个浏览器标签页 +- **AND** 用户切回 FastGPT 所在的浏览器标签页 +- **THEN** 系统自动重新请求日志列表第一页数据 + +#### Scenario: 优化记录页面在浏览器标签页切回时自动刷新 + +- **WHEN** 用户当前在对话日志页面的「优化记录」子 tab +- **AND** 用户切换到另一个浏览器标签页 +- **AND** 用户切回 FastGPT 所在的浏览器标签页 +- **THEN** 系统自动重新请求优化记录第一页数据 + +#### Scenario: 非对话日志页面不受影响 + +- **WHEN** 用户在 Dashboard、发布渠道或应用编辑等非对话日志 tab +- **AND** 用户切换浏览器标签页再切回 +- **THEN** 不会触发对话日志相关的刷新请求 diff --git a/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/tasks.md b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/tasks.md new file mode 100644 index 000000000000..ae0ede199399 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-refresh-conversation-logs-on-visibility/tasks.md @@ -0,0 +1,20 @@ +## 1. 父组件添加 visibility 监听 + +- [x] 1.1 在 `ConversationLogs/index.tsx` 中新增 `refreshKey` state(`useState(0)`) +- [x] 1.2 添加 `useEffect` 监听 `visibilitychange` 事件,`document.visibilityState === 'visible'` 时 `setRefreshKey((k) => k + 1)` +- [x] 1.3 在 `useEffect` cleanup 中移除事件监听 +- [x] 1.4 将 `refreshKey` 传递给 `` 和 `` + +## 2. 子组件接收 refreshKey 并编入 refreshDeps + +- [x] 2.1 `LogList.tsx` 接口新增 `refreshKey?: number` prop +- [x] 2.2 `LogList.tsx` 的 `usePagination` 将 `refreshDeps: [params]` 改为 `refreshDeps: [params, refreshKey]` +- [x] 2.3 `OptimizeRecords.tsx` 接口新增 `refreshKey?: number` prop +- [x] 2.4 `OptimizeRecords.tsx` 的 `useScrollPagination` 将 `refreshDeps: [appId, dateRange]` 改为 `refreshDeps: [appId, dateRange, refreshKey]` + +## 3. 验证 + +- [ ] 3.1 确认切换浏览器标签页再切回时,日志列表自动刷新 +- [ ] 3.2 确认切换浏览器标签页再切回时,优化记录自动刷新 +- [ ] 3.3 确认内部 tab 切换(dashboard ↔ logs)行为不变 +- [ ] 3.4 确认子 tab 切换(日志详情 ↔ 优化记录)行为不变 diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000000..367121d8c932 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,33 @@ +schema: spec-driven +context: | + 项目拥有本地维护的内部UI组件库,所有界面修改必须优先复用现有组件。可以使用 skill(component-guide)查询是否存在相应组件 + + **数据模型一致性规则**: + Mongoose schema (packages/service/) 和 Zod schema (packages/global/) 是同一份数据的双重定义,二者必须保持字段同步: + - 在 MongoDB schema 中新增/修改/删除字段时,必须同步更新对应的 Zod schema 和手写 `.d.ts` 类型声明 + - 修改字段类型或约束时,两端保持一致的 optional/required/type 语义 + - 改动涉及多个模块时,逐模块检查 Mongo schema → Zod schema → `.d.ts` 的一致性 +rules: + proposal: + - 规范必须使用英文文标题,需求描述得为中文 + - Always include a "Non-goals" section + task: + - 一些复杂的逻辑或者关键改动必须写注释 +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/package.json b/package.json index 8e38063c81c8..da5e5cbbb268 100644 --- a/package.json +++ b/package.json @@ -14,25 +14,30 @@ "initIcon": "node ./scripts/icon/init.js && prettier --config \"./.prettierrc.js\" --write \"packages/web/components/common/Icon/constants.ts\"", "previewIcon": "node ./scripts/icon/index.js", "create:i18n": "node ./scripts/i18n/index.js", + "typecheck": "cd projects/app && tsc --noEmit", "lint": "eslint \"**/*.{ts,tsx}\" --fix --ignore-path .eslintignore", - "test": "vitest run --coverage", - "test:vector": "vitest run --config test/integrationTest/vectorDB/vitest.config.mts" + "test": "vitest run", + "test:vector": "vitest run --config test/integrationTest/vectorDB/vitest.config.mts", + "build:sdks": "pnpm -r --filter @fastgpt-sdk/storage --filter @fastgpt-sdk/otel build" }, "devDependencies": { "@chakra-ui/cli": "^2.4.1", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-v8": "^3.0.9", - "eslint": "catalog:", - "eslint-config-next": "catalog:", + "dotenv": "^16.5.0", + "eslint": "^8.57.0", + "eslint-config-next": "^14.1.0", "husky": "^8.0.3", - "i18next": "catalog:", - "js-yaml": "catalog:", + "i18next": "23.16.8", + "iconv-lite": "^0.6.3", + "js-yaml": "^4.1.1", "lint-staged": "^13.3.0", "mongodb-memory-server": "^10.1.4", - "next-i18next": "catalog:", + "next-i18next": "15.4.2", + "papaparse": "^5.5.3", "prettier": "3.2.4", - "react-i18next": "catalog:", + "react-i18next": "14.1.2", "typescript": "^5.1.3", "vitest": "^3.0.9", "zhlint": "^0.7.4" @@ -51,8 +56,33 @@ "git add ." ] }, + "resolutions": { + "mdast-util-gfm-autolink-literal": "2.0.0" + }, + "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "openai>zod": "*" + } + }, + "patchedDependencies": { + "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", + "@fastgpt-sdk/plugin@0.6.0": "patches/@fastgpt-sdk__plugin@0.6.0.patch" + } + }, + "mongodbMemoryServer": { + "systemBinary": "/usr/local/bin/mongodb/mongod" + }, "engines": { - "node": ">=20", - "pnpm": "9.x" + "node": ">=20" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "9.15.9" + } + }, + "dependencies": { + "csv-rex": "^0.7.0" } -} \ No newline at end of file +} diff --git a/packages/diting-rag-ts/.env.example b/packages/diting-rag-ts/.env.example new file mode 100644 index 000000000000..3a0b716948da --- /dev/null +++ b/packages/diting-rag-ts/.env.example @@ -0,0 +1,79 @@ +# diting-rag-ts Configuration +# 环境变量优先级: .env > config.yaml > 默认值 +# 兼容 Python 版命名风格 + +# =================== +# LLM 配置 +# =================== +# 主要 (同 Python 版 LLM_*) +LLM_BASE_URL=http://fastgpt-aiproxy.default:3000/v1 +LLM_API_KEY=sk-xxx +LLM_MODEL=Qwen3-Next-80B-A3B-Instruct-FP8 + +# =================== +# Embedding 配置 +# =================== +EMBEDDING_BASE_URL=http://fastgpt-aiproxy.default:3000/v1 +EMBEDDING_API_KEY=sk-xxx +EMBEDDING_MODEL=bge-m3 + +# =================== +# Reranker 配置 (可选) +# =================== +RERANKER_BASE_URL=http://fastgpt-aiproxy.default:3000/v1 +RERANKER_API_KEY=sk-xxx +RERANKER_MODEL=bge-reranker-large +RERANKER_TOP_K=10 + +# =================== +# FastGPT 检索配置 +# =================== +FASTGPT_API_BASE=https://10.57.2.100:19443/api +FASTGPT_API_KEY= + +# =================== +# 向量数据库 +# =================== +VECTOR_DB=pgvector # pgvector | milvus + +# PGVector +PG_URL=postgresql://postgres:password@localhost:5432/postgres +# 或分字段配置: +# PG_HOST=localhost +# PG_PORT=5432 +# PG_DATABASE=postgres +# PG_USER=postgres +# PG_PASSWORD=password + +# Milvus (如果 VECTOR_DB=milvus) +# MILVUS_ADDRESS=localhost:19530 +# MILVUS_TOKEN= +# MILVUS_DATABASE=default + +# =================== +# 全文检索数据库 (Full-text DB) +# =================== +FULLTEXT_DB=mongodb # mongodb | opensearch + +# MongoDB (if FULLTEXT_DB=mongodb) +MONGODB_URL=mongodb://localhost:27017 +MONGODB_DATABASE=fastgpt + +# OpenSearch (if FULLTEXT_DB=opensearch, 预留) +# OPENSEARCH_URL=http://localhost:9200 +# OPENSEARCH_INDEX=my_index + +# =================== +# Agent 配置 +# =================== +SEARCH_MODE=mixedRecall # embedding | fullTextRecall | mixedRecall +MAX_SEARCH_ROUNDS=5 +MAX_TOOL_CALLS=10 +EMBEDDING_WEIGHT=0.5 +SIMILARITY=0.0 + +# =================== +# 服务配置 +# =================== +PORT=3123 +HOST=0.0.0.0 \ No newline at end of file diff --git a/packages/diting-rag-ts/.gitignore b/packages/diting-rag-ts/.gitignore new file mode 100644 index 000000000000..7f297424054d --- /dev/null +++ b/packages/diting-rag-ts/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.env +.env.local +coverage/ +.DS_Store +*.tsbuildinfo \ No newline at end of file diff --git a/packages/diting-rag-ts/.npmignore b/packages/diting-rag-ts/.npmignore new file mode 100644 index 000000000000..54b7fdd6dae9 --- /dev/null +++ b/packages/diting-rag-ts/.npmignore @@ -0,0 +1,31 @@ +# dev configs & tools +.claude/ +.env* +*.example +Makefile + +# source files (compiled dist/ is published) +src/ +bin/ +test/ +docs/ + +# source maps (source not published, maps are useless) +**/*.map + +# ts source files leaked into dist/src/prompts/ by copy-prompts script +# negation keeps .d.ts declaration files needed by consumers +dist/**/*.ts +!dist/**/*.d.ts + +# dev/debug scripts not needed by consumers +dist/bin/benchmark_pgvector.js +dist/bin/debug_vectors.js +dist/bin/demo.js +dist/bin/test-retrieval.js + +# build configs +tsconfig*.json +vitest.config.ts +pnpm-lock.yaml +package-lock.json diff --git a/packages/diting-rag-ts/Makefile b/packages/diting-rag-ts/Makefile new file mode 100644 index 000000000000..9d6457f39520 --- /dev/null +++ b/packages/diting-rag-ts/Makefile @@ -0,0 +1,39 @@ +.PHONY: install build clean pack publish dev start test lint typecheck + +PKG_DIR := $(shell pwd) +DIST_DIR := $(PKG_DIR)/dist + +install: + pnpm install + +build: install + pnpm run build + +clean: + rm -rf $(DIST_DIR) + +rebuild: clean build + +pack: build + pnpm pack --pack-destination .. + +publish: build + pnpm publish --no-git-checks + +dev: + pnpm run dev + +start: build + pnpm run start + +test: + pnpm run test + +test-watch: + pnpm run test:watch + +lint: + pnpm run lint + +typecheck: + pnpm run typecheck diff --git a/packages/diting-rag-ts/README.md b/packages/diting-rag-ts/README.md new file mode 100644 index 000000000000..d589aada8b36 --- /dev/null +++ b/packages/diting-rag-ts/README.md @@ -0,0 +1,261 @@ +# diting-rag-ts + +> Agentic RAG implementation in TypeScript, based on langgraph-js + +> **Monorepo 说明:** 本包已迁移到 FastGPT monorepo (`packages/diting-rag-ts`)。 +> +> - **source-based**:tsc、Vitest、NextJS 均直接消费 TypeScript 源码,无需构建 dist +> - **独立测试**:`pnpm test`(在 `packages/diting-rag-ts/` 目录下) +> - **CI**:见 `.github/workflows/diting-rag-ts-test.yml` + +智能企业知识库问答系统,通过 LLM 驱动的 Agent 自动进行查询改写、多轮检索、反思和答案生成。 + +## 特性 + +- **智能路由**: 自动识别问题类型(简单查询、对比分析、故障排查、深度研究、追问) +- **查询改写**: 支持多种改写策略(关键词扩展、查询分解、逐步深入、概念推理) +- **混合检索**: 向量检索 + 全文检索 + Rerank 融合 +- **反思机制**: 检索结果评估与答案质量反思 +- **依赖注入**: 面向接口设计,解耦具体实现 + +## 安装 + +```bash +npm install @sangfor/diting-rag-ts +``` + +## 快速开始 + +### CLI 方式 + +```bash +# 检索模式 - 只返回 chunks +diting-rag search -q "VPN如何配置" -d ds_123 + +# 问答模式 - 返回 chunks + 答案 +diting-rag ask -q "VPN如何配置" -d ds_123 + +# 使用配置文件 +diting-rag ask -q "VPN如何配置" -d ds_123 --use-config + +# 启动 HTTP 服务 +diting-rag serve --use-config +``` + +### 使用内置 Provider (调用真实 API) + +```typescript +import { createAgenticSearch } from '@sangfor/diting-rag-ts'; +import { createBuiltInLLMProvider, createBuiltInEmbeddingProvider, createBuiltInRerankProvider } from '@sangfor/diting-rag-ts/adapters/built-in'; + +// 从环境变量读取配置 +const llm = createBuiltInLLMProvider({ + endpoint: process.env.LLM_BASE_URL, + apiKey: process.env.LLM_API_KEY, + model: process.env.LLM_MODEL, +}); + +const embed = createBuiltInEmbeddingProvider({ + endpoint: process.env.EMBEDDING_BASE_URL, + apiKey: process.env.EMBEDDING_API_KEY, + model: process.env.EMBEDDING_MODEL, + dimension: 1024, +}); + +// Reranker 可选 +const reranker = createBuiltInRerankProvider({ + endpoint: process.env.RERANKER_BASE_URL, + apiKey: process.env.RERANKER_API_KEY, + model: process.env.RERANKER_MODEL, + topN: parseInt(process.env.RERANKER_TOP_K || '10'), +}); + +// 创建 Agent +const agent = createAgenticSearch({ + providers: { + type: 'custom', + llm, + embed, + reranker, + // 可选: vectorSearch, fullTextSearch + }, +}); + +const result = await agent.invoke({ + query: 'VPN如何配置', + datasetIds: ['ds_1'], +}); +``` + +### 与 FastGPT 集成 + +```typescript +import { createAgenticSearch } from '@sangfor/diting-rag-ts'; +import { createFastGPTProvider } from '@sangfor/diting-rag-ts/adapters/fastgpt'; + +// 使用 FastGPT 作为检索后端 +const fastgpt = createFastGPTProvider({ + baseUrl: process.env.FASTGPT_API_BASE, + apiKey: process.env.FASTGPT_API_KEY, +}); + +const agent = createAgenticSearch({ + providers: { + type: 'fastgpt', + ...fastgpt, + }, +}); + +const { chunks, reasoningText, answer } = await agent.invoke({ + query: userQuery, + datasetIds, + history: chatHistory, +}); +``` + +## 配置 + +### 环境变量 (`.env`) + +```bash +# LLM (兼容 OPENAI_BASE_URL, OPENAI_API_KEY) +LLM_BASE_URL=http://10.74.124.118:30001/v1 +LLM_API_KEY=sk-xxx +LLM_MODEL=Qwen3-Next-80B-A3B-Instruct-FP8 + +# Embedding +EMBEDDING_BASE_URL=http://10.57.1.91:30088/v1 +EMBEDDING_API_KEY=sk-xxx +EMBEDDING_MODEL=bge-m3 + +# Reranker (可选) +RERANKER_BASE_URL=http://10.57.1.91:30089/v1 +RERANKER_API_KEY=sk-xxx +RERANKER_MODEL=bge-reranker-large +RERANKER_TOP_K=50 + +# FastGPT 检索 (可选) +FASTGPT_API_BASE=https://10.57.2.100:19443/api +FASTGPT_API_KEY=openapi-xxx + +# 向量数据库 (可选) +VECTOR_DB=pgvector # pgvector | milvus +PG_URL=postgresql://postgres:password@localhost:5432/postgres + +# 全文检索数据库 (可选) +FULLTEXT_DB=mongodb # mongodb | opensearch +MONGODB_URL=mongodb://localhost:27017 +MONGODB_DATABASE=fastgpt + +# Agent (可选) +SEARCH_MODE=mixedRecall +MAX_SEARCH_ROUNDS=5 +``` + +### YAML 配置 (`config.yaml`) + +```yaml +llm: + base_url: http://10.74.124.118:30001/v1 + model: Qwen3-Next-80B-A3B-Instruct-FP8 + api_key: sk-xxx + +embedding: + base_url: http://10.57.1.91:30088/v1 + model: bge-m3 + api_key: sk-xxx + +reranker: + base_url: http://10.57.1.91:30089/v1 + model: bge-reranker-large + api_key: sk-xxx + top_n: 50 + +vector_db: + type: pgvector + pg_url: postgresql://postgres:password@localhost:5432/postgres + +fulltext_db: + type: mongodb + url: mongodb://localhost:27017 + database: fastgpt + +fastgpt: + base_url: https://10.57.2.100:19443/api + api_key: openapi-xxx + +agent: + search_mode: mixedRecall + max_search_rounds: 5 + +agent: + search_mode: mixedRecall + max_search_rounds: 5 +``` + +## CLI 命令 + +| 命令 | 说明 | +|------|------| +| `diting-rag search -q -d ` | 检索 chunks | +| `diting-rag ask -q -d ` | 检索 + 生成答案 | +| `diting-rag config check` | 校验配置文件 | +| `diting-rag serve` | 启动 HTTP 服务 | + +## Playbooks + +| Playbook | 说明 | 触发关键词 | +|----------|------|-----------| +| `simple_query` | 简单直接查询 | 直接的事实问答 | +| `comparative_analysis` | 对比分析 | 对比、区别、vs | +| `troubleshooting` | 故障排查 | 报错、失败、error | +| `deep_research` | 深度研究 | 有哪些、list、all | +| `followup_query` | 追问 | 然后、还有、what about | + +## 目录结构 + +``` +src/ +├── types/ # 基础类型 +├── ports/ # 接口契约 +├── skills/ # Skill 体系 +│ ├── atomic/ # 原子技能 +│ └── expertise/ # 专家技能 +├── agent/ # Agent 核心 +├── adapters/ # 适配器 +│ ├── fastgpt/ # FastGPT 适配器 +│ ├── mock/ # 测试用 Mock +│ └── built-in/ # 内置适配器 +├── prompts/ # 提示词 YAML +├── config/ # 配置加载 +├── utils/ # 工具函数 +└── apps/ # 应用入口 + └── server/ # HTTP 服务 + +bin/ +├── cli.ts # CLI 入口 +└── server.ts # 服务入口 +``` + +## 开发 + +```bash +# 安装依赖 +npm install + +# 类型检查 +npm run typecheck + +# 运行测试 +npm test + +# 构建 +npm run build + +# 开发模式 +npm run dev +``` + +## License + +Proprietary - All rights reserved diff --git a/packages/diting-rag-ts/bin/cli.ts b/packages/diting-rag-ts/bin/cli.ts new file mode 100644 index 000000000000..8a260637d96a --- /dev/null +++ b/packages/diting-rag-ts/bin/cli.ts @@ -0,0 +1,441 @@ +#!/usr/bin/env node +// bin/cli.ts +// CLI Entry Point - Command line interface for Agentic Search + +import { Command } from 'commander'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { createAgenticSearch, type AgenticSearchResult } from '../src/agent/runner'; +import { createMockProviders } from '../src/testing/index'; +import { loadConfig, createProviderConfig } from '../src/config/index'; +import { + createBuiltInLLMProvider, + createBuiltInEmbeddingProvider, + createBuiltInRerankProvider, + createMongoDBProvider, + FastGPTVectorSearchProvider +} from '../src/adapters/builtIn/index'; + +// Load environment variables from the package root (where .env lives), +// regardless of the cwd when cli.ts is invoked. +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, '..'); +dotenv.config({ path: path.join(packageRoot, '.env') }); +// Also honor a local .env in cwd (lower priority, already loaded above wins) +dotenv.config(); + +const program = new Command(); + +program + .name('diting-rag') + .description('Agentic RAG CLI - Intelligent search with LLM-powered routing') + .version('0.1.0'); + +/** + * Create providers from config or CLI options + */ +async function createProviders(options: { + mock?: boolean; + configFile?: string; + model?: string; + apiKey?: string; + endpoint?: string; +}) { + // Use mock providers only when explicitly requested + if (options.mock) { + return createMockProviders({ + modelName: options.model || 'qwen3-80b', + apiKey: options.apiKey, + endpoint: options.endpoint || 'http://localhost:3000' + }); + } + + // Default: load from config file / env vars + try { + const config = await loadConfig(options.configFile); + const providerConfig = createProviderConfig(config); + + // Create built-in providers + const llm = createBuiltInLLMProvider({ + apiKey: providerConfig.llm.apiKey, + endpoint: providerConfig.llm.endpoint, + model: providerConfig.llm.model, + timeout: providerConfig.llm.timeout + }); + + const embed = createBuiltInEmbeddingProvider({ + apiKey: providerConfig.embed.apiKey, + endpoint: providerConfig.embed.endpoint, + model: providerConfig.embed.model, + dimension: providerConfig.embed.dimension, + timeout: providerConfig.embed.timeout + }); + + const reranker = providerConfig.reranker + ? createBuiltInRerankProvider({ + apiKey: providerConfig.reranker.apiKey, + endpoint: providerConfig.reranker.endpoint, + model: providerConfig.reranker.model, + topN: providerConfig.reranker.topN, + timeout: providerConfig.reranker.timeout + }) + : undefined; + + // Get vectorSearch config and create provider + const vsConfig = providerConfig.vectorSearch as Record; + const fsConfig = providerConfig.fullTextSearch as Record; + + // Use FastGPTVectorSearchProvider (same as demo.ts) - handles both PG+MongoDB jointly + const vectorSearch = new FastGPTVectorSearchProvider({ + pg: { + connectionString: vsConfig.connectionString as string | undefined, + host: vsConfig.host as string | undefined, + port: vsConfig.port as number | undefined, + database: vsConfig.database as string | undefined, + user: vsConfig.user as string | undefined, + password: vsConfig.password as string | undefined, + vectorDimension: 1536 + }, + mongodb: { + connectionString: fsConfig.url || 'mongodb://localhost:27017', + database: fsConfig.database || 'fastgpt' + } + }); + await vectorSearch.init(); + + const fullTextSearch = createMongoDBProvider({ + connectionString: fsConfig.url || 'mongodb://localhost:27017', + database: fsConfig.database || 'fastgpt' + }); + + return { + type: 'custom' as const, + llm, + embed, + vectorSearch, + fullTextSearch, + reranker + }; + } catch (error) { + console.error('Failed to load config:', error); + process.exit(1); + } +} + +/** + * Search command - returns chunks only (search mode) + */ +program + .command('search') + .description('Agentic Search: retrieve chunks from knowledge base') + .requiredOption('-q, --query ', 'Search query') + .requiredOption('-d, --dataset ', 'Dataset ID(s), comma-separated') + .option('-c, --config ', 'Config file path') + .option('--mock', 'Use mock providers (for testing)', false) + .option('-m, --model ', 'LLM model name (mock mode only)') + .option('-k, --api-key ', 'API key (mock mode only)') + .option('-e, --endpoint ', 'API endpoint (mock mode only)') + .option('--format ', 'Output format: json|human', 'human') + .action(async (options) => { + try { + const providers = await createProviders({ + mock: options.mock, + configFile: options.config, + model: options.model, + apiKey: options.apiKey, + endpoint: options.endpoint + }); + + const agentic = createAgenticSearch({ providers }); + + const datasetIds = (options.dataset as string) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + console.log('\n' + '─'.repeat(70)); + console.log(` [search] ${options.query}`); + console.log('─'.repeat(70)); + const start = Date.now(); + const result = await agentic.invoke({ + query: options.query, + datasetIds + }); + console.log(` Elapsed: ${((Date.now() - start) / 1000).toFixed(1)}s`); + + if (options.format === 'json') { + console.log( + JSON.stringify( + { + playbook: result.playbook, + searchQueries: result.searchQueries, + searchCount: result.searchCount, + chunks: result.chunks.map((c) => ({ + id: c.id, + content: c.content, + score: c.score, + sourceName: c.sourceName + })), + executionPath: result.executionPath + }, + null, + 2 + ) + ); + } else { + printSearchResult(result); + } + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +/** + * Ask command - returns chunks + answer (chat mode) + */ +program + .command('ask') + .description('Agentic Ask: retrieve chunks and generate answer') + .requiredOption('-q, --query ', 'Question') + .requiredOption('-d, --dataset ', 'Dataset ID(s), comma-separated') + .option('-c, --config ', 'Config file path') + .option('--mock', 'Use mock providers (for testing)', false) + .option('-m, --model ', 'LLM model name (mock mode only)') + .option('-k, --api-key ', 'API key (mock mode only)') + .option('-e, --endpoint ', 'API endpoint (mock mode only)') + .option('--format ', 'Output format: json|human', 'human') + .action(async (options) => { + try { + const providers = await createProviders({ + mock: options.mock, + configFile: options.config, + model: options.model, + apiKey: options.apiKey, + endpoint: options.endpoint + }); + + const agentic = createAgenticSearch({ providers }); + + const datasetIds = (options.dataset as string) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + console.log('\n' + '─'.repeat(70)); + console.log(` [ask] ${options.query}`); + console.log('─'.repeat(70)); + const start = Date.now(); + const result = await agentic.invoke({ + query: options.query, + datasetIds + }); + console.log(` Elapsed: ${((Date.now() - start) / 1000).toFixed(1)}s`); + + if (options.format === 'json') { + console.log( + JSON.stringify( + { + query: options.query, + playbook: result.playbook, + answer: result.answer, + confidence: result.confidence, + searchQueries: result.searchQueries, + searchCount: result.searchCount, + toolCallCount: result.toolCallCount, + chunks: result.chunks.map((c) => ({ + id: c.id, + content: c.content, + score: c.score, + sourceName: c.sourceName + })), + executionPath: result.executionPath + }, + null, + 2 + ) + ); + } else { + printAskResult(result); + } + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +/** + * Config command - validate configuration + */ +const configCmd = new Command('config'); +configCmd.description('Configuration management'); + +configCmd + .command('check') + .description('Validate configuration file') + .option('-c, --config ', 'Config file path', './config.yaml') + .action(async (options) => { + try { + const config = await loadConfig(options.config || './config.yaml'); + console.log('✓ Configuration is valid'); + console.log('\nCurrent configuration:'); + console.log(` LLM: ${config.llm.model} @ ${config.llm.base_url}`); + console.log( + ` Embedding: ${config.embedding.model} (dimension: ${config.embedding.dimension})` + ); + console.log(` Vector DB: ${config.vector_db.type}`); + console.log(` FullText DB: ${config.fulltext_db.type} @ ${config.fulltext_db.url}`); + console.log(` FastGPT: ${config.fastgpt?.base_url || 'not configured'}`); + console.log(` Search Mode: ${config.agent.search_mode}`); + console.log(` Max Search Rounds: ${config.agent.max_search_rounds}`); + } catch (error) { + console.error('✗ Configuration error:', error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program.addCommand(configCmd); + +/** + * Serve command - start HTTP server + */ +program + .command('serve') + .description('Start HTTP server for agentic search') + .option('-p, --port ', 'Server port', process.env.AGENTIC_PORT || '3000') + .option('-h, --host ', 'Server host', '0.0.0.0') + .option('-c, --config ', 'Config file path') + .option('--mock', 'Use mock providers (for testing)', false) + .action(async (options) => { + const { startServer } = await import('../src/apps/server/index'); + + let providers; + if (!options.mock) { + try { + const config = await loadConfig(options.config); + const providerConfig = createProviderConfig(config); + + const llm = createBuiltInLLMProvider({ + apiKey: providerConfig.llm.apiKey, + endpoint: providerConfig.llm.endpoint, + model: providerConfig.llm.model + }); + + const embed = createBuiltInEmbeddingProvider({ + apiKey: providerConfig.embed.apiKey, + endpoint: providerConfig.embed.endpoint, + model: providerConfig.embed.model + }); + + const reranker = providerConfig.reranker + ? createBuiltInRerankProvider({ + apiKey: providerConfig.reranker.apiKey, + endpoint: providerConfig.reranker.endpoint, + model: providerConfig.reranker.model + }) + : undefined; + + const vsConfig = providerConfig.vectorSearch as Record; + const fsConfig2 = providerConfig.fullTextSearch as Record; + + const vectorSearch = new FastGPTVectorSearchProvider({ + pg: { + connectionString: vsConfig.connectionString as string | undefined, + host: vsConfig.host as string | undefined, + port: vsConfig.port as number | undefined, + database: vsConfig.database as string | undefined, + user: vsConfig.user as string | undefined, + password: vsConfig.password as string | undefined, + vectorDimension: 1536 + }, + mongodb: { + connectionString: fsConfig2.url || 'mongodb://localhost:27017', + database: fsConfig2.database || 'fastgpt' + } + }); + await vectorSearch.init(); + + const fullTextSearch = createMongoDBProvider({ + connectionString: fsConfig2.url || 'mongodb://localhost:27017', + database: fsConfig2.database || 'fastgpt' + }); + + providers = { llm, embed, vectorSearch, fullTextSearch, reranker }; + } catch (error) { + console.error('Failed to load config:', error); + process.exit(1); + } + } + + await startServer({ + port: parseInt(options.port, 10), + host: options.host, + providers + }); + }); + +/** + * Print search result (human format) - same style as demo.ts + */ +function printSearchResult(result: AgenticSearchResult): void { + console.log(`\n Playbook: ${result.playbook}`); + console.log(` Execution Path: ${result.executionPath?.join(' → ') || 'N/A'}`); + console.log(` Tool Calls: ${result.toolCallCount}`); + console.log(` Search Count: ${result.searchCount}`); + console.log(` Search Queries: ${result.searchQueries.join(', ') || 'N/A'}`); + console.log(` Chunks Found: ${result.chunks.length}`); + console.log(` Confidence: ${result.confidence?.toFixed(2) || 'N/A'}`); + + if (result.chunks.length > 0) { + console.log('\n Top Chunks:'); + result.chunks.slice(0, 5).forEach((chunk, i) => { + console.log( + `\n [${i + 1}] ${chunk.sourceName || 'unknown'} (score: ${chunk.score.toFixed(3)} rerankScore: ${chunk.rerankScore?.toFixed(3)}) metadata: ${JSON.stringify(chunk.metadata)}` + ); + console.log(' ' + chunk.content.slice(0, 300) + (chunk.content.length > 300 ? '...' : '')); + }); + } + + if (result.executionPath?.length) { + console.log('\n Execution Path:'); + console.log(result.executionPath.map((e) => ` • ${e}`).join('\n')); + } +} + +/** + * Print ask result (human format) - same style as demo.ts + */ +function printAskResult(result: AgenticSearchResult): void { + console.log(`\n Playbook: ${result.playbook}`); + console.log(` Execution Path: ${result.executionPath?.join(' → ') || 'N/A'}`); + console.log(` Tool Calls: ${result.toolCallCount}`); + console.log(` Search Count: ${result.searchCount}`); + console.log(` Search Queries: ${result.searchQueries.join(', ') || 'N/A'}`); + console.log(` Chunks Found: ${result.chunks.length}`); + console.log(` Confidence: ${result.confidence?.toFixed(2) || 'N/A'}`); + + if (result.answer) { + console.log('\n Answer:'); + console.log(' ' + '─'.repeat(60)); + for (const line of result.answer.split('\n')) { + console.log(` ${line}`); + } + console.log(' ' + '─'.repeat(60)); + } + + if (result.executionPath?.length) { + console.log('\n Execution Path:'); + console.log(result.executionPath.map((e) => ` • ${e}`).join('\n')); + } +} + +// Parse and execute +program + .parseAsync() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/packages/diting-rag-ts/bin/demo.ts b/packages/diting-rag-ts/bin/demo.ts new file mode 100644 index 000000000000..1e2ecb8f2e1d --- /dev/null +++ b/packages/diting-rag-ts/bin/demo.ts @@ -0,0 +1,318 @@ +#!/usr/bin/env node +// bin/demo.ts +// Demo - 运行 Agentic Search 测试用例 + +import * as dotenv from 'dotenv'; +import { createAgenticSearch } from '../src/agent/runner'; +import { loadConfig, createProviderConfig } from '../src/config/index'; +import { createBuiltInProviders, FastGPTVectorSearchProvider } from '../src/adapters/builtIn/index'; +import { createBuiltInMixedSearchProvider } from '../src/adapters/builtIn/mixed/index'; +import { createLogger, LogLevel } from '../src/ports/logger'; + +// 加载环境变量 +dotenv.config({ path: '.env' }); + +const DATASET_IDS = ['69b95de08e965a40d7d3102b']; + +interface DemoCase { + name: string; + question: string; + chatHistory: Array<{ role: string; content: string }>; + priorContext: string; +} + +const DEMO_QUERIES: DemoCase[] = [ + { + name: '不相关查询', + question: '公司有多少员工', + chatHistory: [], + priorContext: '[Source ] 公司有8000人' + }, + { + name: '简单查询', + question: '超融合的默认密码是什么', + chatHistory: [], + priorContext: 'alias: 超融合, HCI' + } + // { + // name: '故障排查', + // question: 'password reset by usb', + // chatHistory: [], + // priorContext: '' + // }, + // { + // name: '对比分析', + // question: 'vNGAF、vADC 、vIAG 的区别是什么?它们跟vm又有什么不同、', + // chatHistory: [], + // priorContext: '' + // }, + // { + // name: 'deep_search', + // question: 'HCI到目前为止有哪些版本?', + // chatHistory: [], + // priorContext: '' + // } +]; + +async function createProviders() { + // loadConfig 会自动调用 dotenv.config(),然后尝试加载 config.yaml + // 如果没有 config.yaml,则只使用环境变量 + const config = await loadConfig(); + const providerConfig = createProviderConfig(config); + + // 创建 logger + const levelMap: Record = { + debug: LogLevel.DEBUG, + info: LogLevel.INFO, + warn: LogLevel.WARN, + error: LogLevel.ERROR + }; + const logLevel = levelMap[(process.env.LOG_LEVEL ?? '').toLowerCase()] ?? LogLevel.INFO; + const logger = createLogger({ + level: logLevel, + prefix: 'AgenticRAG' + }); + + logger.info('Loading providers...'); + logger.info(` LLM: ${providerConfig.llm.model} @ ${providerConfig.llm.endpoint}`); + logger.info(` Embed: ${providerConfig.embed.model} @ ${providerConfig.embed.endpoint}`); + logger.info( + ` Reranker: ${providerConfig.reranker?.model || 'not configured'} @ ${providerConfig.reranker?.endpoint || 'N/A'}` + ); + logger.info(` Vector DB: ${config.vector_db.type} @ ${config.vector_db.pg_url}`); + logger.info(` FullText DB: ${config.fulltext_db.type} @ ${config.fulltext_db.url}`); + logger.info(` FastGPT: ${config.fastgpt?.base_url || 'not configured'}`); + + // FastGPT Joint Vector Search 需要单独创建(使用已有配置) + const vsConfig = providerConfig.vectorSearch as Record; + const fsConfig = providerConfig.fullTextSearch as Record; + + // 使用统一 providers 统一创建,fullTextSearch 传入正确配置 + const providers = createBuiltInProviders({ + logger, + loggerLevel: 'info', + llm: { + apiKey: providerConfig.llm.apiKey, + endpoint: providerConfig.llm.endpoint, + model: providerConfig.llm.model, + timeout: providerConfig.llm.timeout + }, + embedding: { + apiKey: providerConfig.embed.apiKey, + endpoint: providerConfig.embed.endpoint, + model: providerConfig.embed.model, + dimension: providerConfig.embed.dimension, + timeout: providerConfig.embed.timeout + }, + reranker: providerConfig.reranker + ? { + apiKey: providerConfig.reranker.apiKey, + endpoint: providerConfig.reranker.endpoint, + model: providerConfig.reranker.model, + topN: providerConfig.reranker.topN, + timeout: providerConfig.reranker.timeout + } + : undefined, + fullTextSearch: { + connectionString: fsConfig.url, + database: fsConfig.database || 'fastgpt' + } + }); + + const vectorSearch = new FastGPTVectorSearchProvider({ + pg: { + connectionString: vsConfig.connectionString as string | undefined, + host: vsConfig.host as string | undefined, + port: vsConfig.port as number | undefined, + database: vsConfig.database as string | undefined, + user: vsConfig.user as string | undefined, + password: vsConfig.password as string | undefined, + vectorDimension: 1536 // FastGPT PG vectors are 1536-dim + }, + mongodb: { + connectionString: fsConfig.url, + database: fsConfig.database || 'fastgpt' + } + }); + await vectorSearch.init(); + + // 使用统一 providers,但覆盖 vectorSearch 和 fullTextSearch + // mixedSearch 使用 FastGPTVectorSearchProvider(联合PG+MongoDB内容)+ fullTextSearch + RRF + const mixedSearch = createBuiltInMixedSearchProvider( + vectorSearch, + providers.fullTextSearch, + logger + ); + + return { + ...providers, + vectorSearch, + fullTextSearch: providers.fullTextSearch, + mixedSearch + }; +} + +/** 将流式事件转为中文用户可读文本(复用 agenticSearch.ts 的展示逻辑) */ +function _formatEvent(event: { + step: string; + detail?: string; + extra?: Record; +}): string { + switch (event.step) { + case 'thinking': + return event.detail ? `${event.detail}\n` : ''; + case 'searching': { + const queries = (event.extra?.queries as string[] | undefined) ?? []; + return queries.length > 0 ? `\n检索「${queries.join('、')}」\n` : '\n检索中...\n'; + } + case 'search_done': { + const count = event.extra?.chunkCount as number | undefined; + return count ? `检索到 ${count} 条结果\n` : ''; + } + case 'rewriting': + return '\n现有信息不足以完整回答,扩展检索范围...\n'; + case 'rewrite_done': { + const queries = (event.extra?.queries as string[] | undefined) ?? []; + return queries.length ? queries.map((q) => ` · ${q}`).join('\n') + '\n' : ''; + } + case 'reflecting': + return '\n评估已收集信息的完整性...\n'; + case 'reflect_done': + return event.detail ? `${event.detail}\n` : ''; + case 'final': + return event.extra?.refuse ? '知识库中未找到与此问题相关的内容。\n' : ''; + default: + return ''; + } +} + +async function runSingle(agent: ReturnType, caseData: DemoCase) { + console.log(`\n${'─'.repeat(70)}`); + console.log(` [${caseData.name}] ${caseData.question}`); + if (caseData.chatHistory.length > 0) { + console.log(` (含 ${caseData.chatHistory.length} 条对话历史)`); + } + console.log('─'.repeat(70)); + + const start = Date.now(); + try { + let reasoningText = ''; + let result: Awaited> | undefined; + + const stream = agent.stream({ + query: caseData.question, + datasetIds: DATASET_IDS, + history: caseData.chatHistory, + priorContext: caseData.priorContext + }); + + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + result = item as typeof result; + } else { + const event = item as { step: string; detail?: string; extra?: Record }; + const text = _formatEvent(event); + if (text) reasoningText += text; + } + } + + if (!result) throw new Error('No result from stream'); + + const elapsed = (Date.now() - start) / 1000; + + console.log('\n# ' + '>'.repeat(60)); + console.log(` Raw Query: ${caseData.question}`); + console.log(` PriorContext: ${caseData.priorContext}`); + console.log(` Search Queries: ${result.searchQueries.join(', ') || 'N/A'}`); + console.log(` Playbook: ${result.playbook}`); + console.log(` Execution Path: ${result.executionPath?.join(' → ') || 'N/A'}`); + console.log(` Tool Calls: ${result.toolCallCount}`); + console.log(` Search Count: ${result.searchCount}`); + console.log(` Chunks Found: ${result.chunks.length}`); + console.log(` Confidence: ${result.confidence?.toFixed(2) || 'N/A'}`); + console.log( + ` TTFT: ${result.ttftMs ? `${(result.ttftMs / 1000.0).toFixed(3)}s` : 'N/A'}` + ); + console.log(` Elapsed: ${elapsed.toFixed(1)}s`); + + if (reasoningText) { + console.log('\n ReasoningText:'); + console.log(' ' + '─'.repeat(60)); + reasoningText + .split('\n') + .filter(Boolean) + .forEach((line) => console.log(` ${line}`)); + } + + if (result.refuse) { + console.log(`\n Refuse: true (no relevant information in knowledge base)`); + } + + if (result.answer) { + console.log('\n Answer:'); + console.log(' ' + '─'.repeat(60)); + const lines = result.answer.split('\n'); + for (const line of lines) { + console.log(` ${line}`); + } + } + console.log('# ' + '<'.repeat(60)); + + return result; + } catch (error) { + const elapsed = (Date.now() - start) / 1000; + console.log( + `\n ERROR (${elapsed.toFixed(1)}s): ${error instanceof Error ? error.message : error}` + ); + if (error instanceof Error && error.stack) { + console.log(error.stack); + } + return null; + } +} + +async function main() { + const searchOnly = process.env.SEARCH_ONLY?.toLowerCase() === 'true'; + + console.log('='.repeat(70)); + console.log(' DiTing Agentic RAG Demo (TypeScript)'); + console.log('='.repeat(70)); + + const providers = await createProviders(); + + const agent = createAgenticSearch({ providers, config: { searchOnly } }); + + console.log('\n' + '='.repeat(70)); + console.log(` Datasets: ${DATASET_IDS.join(', ')}`); + console.log(` Mode: ${searchOnly ? 'searchOnly=true' : 'full (search + answer)'}`); + console.log(` Tools: @search, @summary, @query_rewrite`); + console.log('='.repeat(70)); + + let passed = 0; + let failed = 0; + + for (const caseData of DEMO_QUERIES) { + const result = await runSingle(agent, caseData); + const isRefuseCase = caseData.name === '不相关查询'; + const success = + result && + (isRefuseCase + ? result.chunks.length === 0 + : searchOnly + ? result.chunks.length > 0 + : !!result.answer); + if (success) { + passed++; + } else { + failed++; + } + } + + console.log(`\n${'='.repeat(70)}`); + console.log(` Demo 完成: ${passed} 通过, ${failed} 失败, ${DEMO_QUERIES.length} 总计`); + console.log('='.repeat(70)); + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(console.error); diff --git a/packages/diting-rag-ts/bin/server.ts b/packages/diting-rag-ts/bin/server.ts new file mode 100644 index 000000000000..95a75dcbecd3 --- /dev/null +++ b/packages/diting-rag-ts/bin/server.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// bin/server.ts +// HTTP Server Entry Point + +import { serve } from '@hono/node-server'; +import { startServer } from '../src/apps/server/index'; +import * as dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +const port = parseInt(process.env.AGENTIC_PORT || '3123', 10); +const host = process.env.AGENTIC_HOST || '0.0.0.0'; + +async function main() { + const { app, info } = await startServer({ + port, + host + }); + + // Create and start the HTTP server using @hono/node-server + const server = serve({ + fetch: app.fetch, + port, + hostname: host + }); + + console.log(`\n✅ Server running at http://${info.host}:${info.port}`); + console.log('Endpoints:'); + console.log(' GET /health'); + console.log(' POST /v1/agentic/search'); + console.log(' POST /v1/agentic/stream'); + console.log(' GET /v1/agentic/playbooks'); + + // Handle shutdown gracefully + process.on('SIGINT', () => { + console.log('\nShutting down server...'); + server.close(() => { + console.log('Server closed'); + process.exit(0); + }); + }); +} + +main().catch(console.error); diff --git a/packages/diting-rag-ts/config.yaml.example b/packages/diting-rag-ts/config.yaml.example new file mode 100644 index 000000000000..869dacdf1467 --- /dev/null +++ b/packages/diting-rag-ts/config.yaml.example @@ -0,0 +1,85 @@ +# diting-rag-ts Configuration +# 环境变量优先级高于本配置文件 +# YAML 字段名保持与 Python 版一致 (snake_case) + +# =================== +# LLM 配置 +# =================== +llm: + base_url: http://fastgpt-aiproxy.default:3000/v1 + api_key: "" # Leave empty if no API key needed + model: Qwen3-Next-80B-A3B-Instruct-FP8 + max_tokens: 8192 + timeout: 120000 + +# =================== +# Embedding 配置 +# =================== +embedding: + base_url: http://fastgpt-aiproxy.default:3000/v1 + api_key: "" + model: bge-m3 + dimension: 1024 + timeout: 60000 + +# =================== +# Reranker 配置 (可选) +# =================== +reranker: + base_url: http://fastgpt-aiproxy.default:3000/v1 + api_key: "" + model: bge-reranker-large + top_n: 10 + timeout: 60000 + +# =================== +# 向量数据库 (Vector DB) +# =================== +vector_db: + type: pgvector # pgvector | milvus + + # PGVector (if type: pgvector) + pg_url: postgresql://postgres:password@localhost:5432/postgres + # Or individual fields: + # pg_host: localhost + # pg_port: 5432 + # pg_database: postgres + # pg_user: postgres + # pg_password: password + + # Milvus (if type: milvus) + # milvus_address: localhost:19530 + # milvus_token: "" + # milvus_database: default + +# =================== +# 全文检索数据库 (Full-text DB) +# =================== +fulltext_db: + type: mongodb # mongodb | opensearch + + # MongoDB (if type: mongodb) + url: mongodb://localhost:27017 + database: fastgpt + + # OpenSearch (if type: opensearch, 预留) + # opensearch_url: http://localhost:9200 + # opensearch_index: my_index + +# =================== +# FastGPT 配置 (可选) +# =================== +fastgpt: + base_url: http://localhost:3000 + api_key: "" + +# =================== +# Agent 配置 +# =================== +agent: + search_mode: mixedRecall # embedding | fullTextRecall | mixedRecall + max_search_rounds: 5 + max_tool_calls: 10 + embedding_weight: 0.5 + similarity: 0.0 + rerank_top_k: 20 \ No newline at end of file diff --git a/packages/diting-rag-ts/package.json b/packages/diting-rag-ts/package.json new file mode 100644 index 000000000000..869898b6e768 --- /dev/null +++ b/packages/diting-rag-ts/package.json @@ -0,0 +1,64 @@ +{ + "name": "diting-rag-ts", + "version": "0.1.0", + "description": "Agentic RAG implementation in TypeScript, based on langgraph-js", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./testing": { + "import": "./src/testing/index.ts", + "types": "./src/testing/index.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsx watch bin/server.ts", + "start": "tsx bin/server.ts", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:watch": "vitest", + "compat": "tsx test/model-compat.test.ts" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.69", + "@ai-sdk/openai": "^3.0.52", + "@hono/node-server": "^1.13.0", + "@langchain/langgraph": "^0.2.0", + "@zilliz/milvus2-sdk-node": "2.6.9", + "ai": "^6.0.158", + "commander": "^11.1.0", + "dotenv": "^16.3.0", + "eld": "2.0.3", + "hono": "^4.0.0", + "jieba-wasm": "^2.2.0", + "js-yaml": "^4.1.0", + "mongodb": "^6.3.0", + "pg": "^8.11.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "@ai-sdk/anthropic": "^1.2.0", + "@ai-sdk/openai": "^1.3.0", + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "ai": "^4.3.0", + "eslint": "^8.56.0", + "prettier": "^3.2.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0", + "vitest": "^1.2.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/diting-rag-ts/src/adapters/base.ts b/packages/diting-rag-ts/src/adapters/base.ts new file mode 100644 index 000000000000..fca061a5656a --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/base.ts @@ -0,0 +1,36 @@ +// src/adapters/base.ts + +import type { Logger } from '../ports/logger'; +import type { DitingConfig } from '../ports/config'; +import type { ChunkResult } from '../types/chunk'; + +export interface AdapterContext { + logger: Logger; + config: DitingConfig; +} + +export abstract class BaseAdapter { + protected context: AdapterContext; + + constructor(context: AdapterContext) { + this.context = context; + } + + protected log = { + debug: (msg: string, meta?: Record) => this.context.logger.debug(msg, meta), + info: (msg: string, meta?: Record) => this.context.logger.info(msg, meta), + warn: (msg: string, meta?: Record) => this.context.logger.warn(msg, meta), + error: (msg: string, meta?: Record) => this.context.logger.error(msg, meta) + }; + + abstract toDitingChunk(data: T): ChunkResult; + abstract fromDitingChunk(chunk: ChunkResult): T; + + toDitingChunks(dataList: T[]): ChunkResult[] { + return dataList.map((d) => this.toDitingChunk(d)); + } + + fromDitingChunks(chunks: ChunkResult[]): T[] { + return chunks.map((c) => this.fromDitingChunk(c)); + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/embedding/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/embedding/adapter.ts new file mode 100644 index 000000000000..d176f11edbe5 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/embedding/adapter.ts @@ -0,0 +1,102 @@ +// src/adapters/built-in/embedding/adapter.ts +// Built-in Embedding 适配器 - 调用兼容 OpenAI API 的远程 Embedding 服务 + +import type { EmbeddingProvider } from '../../../ports/embedding'; +import type { Logger } from '../../../ports/logger'; + +/** + * Built-in Embedding 配置 + */ +export interface BuiltInEmbeddingConfig { + apiKey?: string; + endpoint?: string; + model?: string; + dimension?: number; + timeout?: number; + logger?: Logger; +} + +/** + * Built-in Embedding 适配器 + * 通过环境变量配置,调用兼容 OpenAI API 的远程服务(如 Ollama、vLLM 等) + */ +export class BuiltInEmbeddingAdapter implements EmbeddingProvider { + public readonly type = 'builtin' as const; + + private apiKey: string; + private endpoint: string; + private model: string; + private dimension: number; + private timeout: number; + private logger?: Logger; + + constructor(config: BuiltInEmbeddingConfig = {}) { + this.apiKey = config.apiKey || process.env.EMBEDDING_API_KEY || ''; + this.endpoint = config.endpoint || process.env.EMBEDDING_BASE_URL || 'http://aiproxy:3000/v1'; + this.model = config.model || process.env.EMBEDDING_MODEL || 'bge-m3'; + this.dimension = config.dimension || 1024; + this.timeout = config.timeout || 60000; + this.logger = config.logger; + } + + /** + * 构建请求头 + */ + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}` + }; + } + + /** + * 生成 Embedding + */ + async embed(texts: string[]): Promise<{ vectors: number[][]; tokens: number }> { + this.logger?.debug('[Embedding] embed called', { textCount: texts.length }); + const body: Record = { + model: this.model, + input: texts + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.endpoint}/embeddings`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Embedding API error: ${response.status} ${errorText}`); + } + + const data = (await response.json()) as { + data?: Array<{ embedding: number[] }>; + usage?: { total_tokens?: number }; + }; + + const vectors = data.data?.map((item) => item.embedding) || []; + const tokens = + data.usage?.total_tokens || texts.reduce((sum, t) => sum + Math.ceil(t.length / 4), 0); + + return { vectors, tokens }; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * 获取模型信息 + */ + getModelInfo() { + return { + name: this.model, + dimension: this.dimension + }; + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/embedding/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/embedding/index.ts new file mode 100644 index 000000000000..27dcf0a2faa7 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/embedding/index.ts @@ -0,0 +1,5 @@ +// src/adapters/built-in/embedding/index.ts +// Built-in Embedding Adapter - 调用兼容 OpenAI API 的远程服务 + +export { BuiltInEmbeddingAdapter, type BuiltInEmbeddingConfig } from './adapter'; +export { createBuiltInEmbeddingProvider } from './wrappers'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/embedding/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/embedding/wrappers.ts new file mode 100644 index 000000000000..317b606576c1 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/embedding/wrappers.ts @@ -0,0 +1,22 @@ +// src/adapters/built-in/embedding/wrappers.ts +// Built-in Embedding Provider Wrappers + +import type { EmbeddingProvider } from '../../../ports/embedding'; +import type { Logger } from '../../../ports/logger'; +import { BuiltInEmbeddingAdapter, type BuiltInEmbeddingConfig } from './adapter'; + +export { type BuiltInEmbeddingConfig } from './adapter'; + +/** + * 创建 Built-in Embedding Provider + * 通过环境变量配置: + * - EMBEDDING_API_KEY: API 密钥 + * - EMBEDDING_BASE_URL: API 端点 (默认 http://aiproxy:3000/v1) + * - EMBEDDING_MODEL: 模型名称 (默认 bge-m3) + */ +export function createBuiltInEmbeddingProvider( + config?: BuiltInEmbeddingConfig, + logger?: Logger +): EmbeddingProvider { + return new BuiltInEmbeddingAdapter({ ...config, logger }); +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/index.ts new file mode 100644 index 000000000000..e1f5ddc095cb --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/index.ts @@ -0,0 +1,4 @@ +// src/adapters/built-in/fastgpt/index.ts +// FastGPT 联合检索 Provider - 封装 PG + MongoDB 联合检索 + +export { FastGPTVectorSearchProvider, type FastGPTVectorSearchConfig } from './vector_search'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/vector_search.ts b/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/vector_search.ts new file mode 100644 index 000000000000..b584e1bbf86a --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/fastgpt/vector_search.ts @@ -0,0 +1,197 @@ +// src/adapters/built-in/fastgpt/vector_search.ts +// FastGPT 联合向量检索 Provider - PG 向量检索 + MongoDB 内容获取 + +import type { + VectorSearchProvider, + VectorSearchOptions, + SearchResult +} from '../../../ports/search'; +import { createSearchResult } from '../../../ports/search'; +import type { ChunkResult } from '../../../types/chunk'; +import { PGVectorAdapter, type PGVectorConfig } from '../pgvector/adapter'; +import { MongoDBAdapter, type MongoDBConfig } from '../mongodb/adapter'; +import type { Logger } from '../../../ports/logger'; + +/** + * FastGPT 联合检索配置 + */ +export interface FastGPTVectorSearchConfig { + pg: PGVectorConfig; + mongodb: MongoDBConfig; + logger?: Logger; +} + +/** + * FastGPT 联合向量检索 Provider + * 封装 PG 向量检索 + MongoDB 内容查询,对外呈现为单一的 VectorSearchProvider + */ +export class FastGPTVectorSearchProvider implements VectorSearchProvider { + public readonly type = 'fastgpt' as const; + + private pgAdapter: PGVectorAdapter; + private mongoAdapter: MongoDBAdapter; + private logger?: Logger; + + constructor(config: FastGPTVectorSearchConfig) { + this.pgAdapter = new PGVectorAdapter(config.pg); + this.mongoAdapter = new MongoDBAdapter(config.mongodb); + this.logger = config.logger; + } + + /** + * 初始化(直接连接,不创建表/索引) + */ + async init(): Promise { + // 直接连接,不初始化(FastGPT 数据已存在) + await this.mongoAdapter.init().catch((e) => + this.logger?.warn('[FastGPTVectorSearch] MongoDB init warning:', { + message: e instanceof Error ? e.message : String(e) + }) + ); + this.logger?.info('[FastGPTVectorSearch] Initialized'); + } + + /** + * 联合检索: PG 向量搜索 + MongoDB 内容获取 + * ⚡ 性能优化: + * 1. 只在需要时查询 MongoDB(检查 collectionIds 不为空) + * 2. 避免重复调用 listDatasets() 进行无必要的日志 + */ + async search( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + // console.log('[FastGPTVectorSearch] search called:', { datasetIds, vectorCount: vectors.length, targetDim: this.pgAdapter.getVectorDimension() }); + this.logger?.debug('[FastGPTVectorSearch] search called:', { + datasetIds, + vectorCount: vectors.length, + targetDim: this.pgAdapter.getVectorDimension() + }); + if (vectors.length === 0 || datasetIds.length === 0) { + return createSearchResult([], 'vector', 'fastgpt'); + } + + try { + // 1. 将输入向量补零到 PG 期望的维度 + const targetDim = this.pgAdapter.getVectorDimension(); + const paddedVectors = vectors.map((vec) => this.padVector(vec, targetDim)); + + // 2. PG 向量检索,获取 vector IDs + const startTime = Date.now(); + const pgSearchResult = await this.pgAdapter.search(paddedVectors, datasetIds, options); + const pgResults = pgSearchResult.chunks; + const pgTime = Date.now() - startTime; + + this.logger?.debug(`[FastGPTVectorSearch] PG results: ${pgResults.length} (${pgTime}ms)`); + + if (pgResults.length === 0) { + return createSearchResult([], 'vector', 'fastgpt'); + } + + // 3. 从 PG 结果提取 vector IDs 和 collection IDs,去重 + const seenVectorIds = new Set(); + const uniquePgResults: typeof pgResults = []; + for (const result of pgResults) { + if (!seenVectorIds.has(result.id)) { + seenVectorIds.add(result.id); + uniquePgResults.push(result); + } + } + this.logger?.debug( + `[FastGPTVectorSearch] PG results dedup: ${pgResults.length} → ${uniquePgResults.length}` + ); + + const vectorIds = uniquePgResults.map((r) => r.id); + const collectionIds = [ + ...new Set( + uniquePgResults.map((r) => r.collectionId).filter((id): id is string => Boolean(id)) + ) + ]; + + if (collectionIds.length === 0) { + return createSearchResult(uniquePgResults, 'vector', 'fastgpt'); + } + + // 4. MongoDB 查询内容 (传入 datasetIds 以便更精确匹配) + const mongoStartTime = Date.now(); + const mongoResults = await this.mongoAdapter.lookupByContentIds( + vectorIds, + collectionIds, + datasetIds + ); + const mongoTime = Date.now() - mongoStartTime; + + this.logger?.debug( + `[FastGPTVectorSearch] MongoDB lookup results: ${mongoResults.length} (${mongoTime}ms)` + ); + + // 5. 合并 PG 分数和 MongoDB 内容 + // 关键:只返回 MongoDB 找到的结果(已按 dataId 去重),不返回 PG 结果作为后备 + // 重要:返回的 id 使用 dataId(MongoDB 的 _id),与 MongoDB 全文检索保持一致 + const enrichedResults: ChunkResult[] = mongoResults.map((mongoResult) => { + const pgResult = uniquePgResults.find((r) => r.id === mongoResult.vectorId); + if (pgResult) { + return { + ...pgResult, + id: mongoResult.dataId, // 使用 dataId 而不是 PG 的 vector id + content: mongoResult.content, + sourceName: mongoResult.sourceName, + metadata: mongoResult.metadata ?? undefined + }; + } + // 如果 PG 找不到(不应该发生),返回 MongoDB 结果 + // 重要:使用 dataId 而不是 vectorId,与 MongoDB 全文检索保持一致 + return { + id: mongoResult.dataId, + content: mongoResult.content, + score: 0, + datasetId: '', + collectionId: '', + sourceName: mongoResult.sourceName, + metadata: mongoResult.metadata ?? undefined + }; + }); + + return createSearchResult(enrichedResults, 'vector', 'fastgpt'); + } catch (error) { + this.logger?.error('[FastGPTVectorSearch] Error:', { + message: error instanceof Error ? error.message : String(error) + }); + return createSearchResult([], 'vector', 'fastgpt'); + } + } + + /** + * 关闭连接 + */ + async close(): Promise { + await Promise.all([ + this.pgAdapter.close().catch((e) => + this.logger?.error('[FastGPTVectorSearch] PG close error:', { + message: e instanceof Error ? e.message : String(e) + }) + ), + this.mongoAdapter.close().catch((e) => + this.logger?.error('[FastGPTVectorSearch] MongoDB close error:', { + message: e instanceof Error ? e.message : String(e) + }) + ) + ]); + } + + /** + * 将向量补零到目标维度 + * FastGPT PG 存储 1536 维向量(bge-m3 输出 1024 维 + 补零) + */ + private padVector(vector: number[], targetDim: number): number[] { + if (vector.length >= targetDim) { + return vector.slice(0, targetDim); + } + const padded = new Array(targetDim).fill(0); + for (let i = 0; i < vector.length; i++) { + padded[i] = vector[i]; + } + return padded; + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/index.ts new file mode 100644 index 000000000000..cca8422295d1 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/index.ts @@ -0,0 +1,13 @@ +// src/adapters/built-in/index.ts +// Built-in Adapters - 内置的向量检索、全文检索、Embedding、LLM、Reranker 能力 + +// 从子模块重新导出(包含所有 adapter 和 wrapper) +export * from './fastgpt/index'; +export * from './milvus/index'; +export * from './pgvector/index'; +export * from './mongodb/index'; +export * from './embedding/index'; +export * from './llm/index'; +export * from './reranker/index'; +export * from './mixed/index'; +export { createBuiltInProviders, type BuiltInProvidersOptions } from './providers'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/llm/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/llm/adapter.ts new file mode 100644 index 000000000000..c72f20cca696 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/llm/adapter.ts @@ -0,0 +1,463 @@ +// src/adapters/built-in/llm/adapter.ts +// Built-in LLM 适配器 - 调用兼容 OpenAI API 的远程服务 + +import type { LLMProvider } from '../../../ports/llm'; +import { resolveLLMCallOptions } from '../../../ports/llm'; +import type { LLMMessage, LLMResponse, LLMCallOptions } from '../../../types/message'; +import type { Logger } from '../../../ports/logger'; +import { stripThinkBlocks } from '../../../utils/text'; + +/** + * Built-in LLM 配置 + */ +export interface BuiltInLLMConfig { + apiKey?: string; + endpoint?: string; + model?: string; + timeout?: number; + logger?: Logger; + /** 固定温度(某些模型如 kimi-k2.5 必须为 1) */ + fixedTemperature?: number; + /** + * 该模型是否启用 thinking 模式。 + * 由外部探测(如模型兼容性测试)填充,adapter 不自测。 + * 影响 maxTokens 默认值:thinking 模型默认 16384,否则 8192。 + */ + defaultEnableThinking?: boolean; +} + +function resolveTemperature(requested: number | undefined, fixed: number | undefined): number { + // fixedTemperature 由外部探测后注入(如 kimi-k2.5 必须为 1),adapter 不自维护模型名列表 + return fixed ?? requested ?? 0.7; +} + +/** + * 从 content 中提取 ... 块内的思考内容 + * @param content LLM 返回的原始 content + * @returns 提取的思考内容,如果没有则返回 null + */ +function extractThinkContent(content: string): string | null { + const thinkMatch = content.match(/([\s\S]*?)<\/think>/); + if (thinkMatch) { + return thinkMatch[1].trim(); + } + return null; +} + +/** + * 检测响应是否被截断 + * @param content LLM 返回的 content + * @returns 是否疑似截断 + */ +function detectTruncation(content: string): boolean { + // 1. 标签完整性检查 + const thinkOpenCount = (content.match(/]/gi) || []).length; + const thinkCloseCount = (content.match(/<\/think>/gi) || []).length; + if (thinkOpenCount > thinkCloseCount) { + return true; // 未闭合,疑似截断 + } + + // 2. JSON 截断检查:查找 JSON 块,检查括号/引号是否平衡 + // 匹配 ```json ... ``` 或 ``` ... ``` 块 + const jsonBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```$/); + if (jsonBlockMatch) { + const jsonText = jsonBlockMatch[1]; + if (isTruncatedJson(jsonText)) return true; + } + + // 3. 裸 JSON 截断(不以 ``` 包围,直接以 { 或 [ 开头) + const trimmed = content.trim(); + if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && isTruncatedJson(trimmed)) { + return true; + } + + return false; +} + +/** + * 判断 JSON 字符串是否疑似截断(括号、引号不平衡) + */ +function isTruncatedJson(text: string): boolean { + let braceDepth = 0; + let inString = false; + let escaped = false; + + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (c === '\\') { + escaped = true; + continue; + } + if (c === '"' && !escaped) { + inString = !inString; + continue; + } + if (inString) continue; + + if (c === '{') braceDepth++; + else if (c === '}') { + braceDepth--; + if (braceDepth < 0) return true; + } + } + // 字符串未闭合也算截断 + if (inString) return true; + if (braceDepth !== 0) return true; + return false; +} + +/** + * 计算最终 maxTokens。 + * 仅依赖显式传入的 enableThinking 或 adapter 构造时的 defaultEnableThinking。 + * 不硬编码模型名列表,外部探测后通过配置注入。 + */ +function resolveMaxTokens( + requested: number | undefined, + enableThinking: boolean | undefined, + defaultEnableThinking: boolean +): number { + const effectiveThinking = enableThinking ?? defaultEnableThinking; + // 有明确 thinking 标志时为 thinking 模型分配更大空间;未指定时保守用较大值兜底 + return requested ?? (effectiveThinking ? 16384 : 8192); +} + +/** + * Built-in LLM 适配器 + * 通过环境变量配置,调用兼容 OpenAI API 的远程服务(如 Ollama、vLLM 等) + */ +export class BuiltInLLMAdapter implements LLMProvider { + public readonly type = 'builtin' as const; + public readonly modelName: string; + + private apiKey: string; + private endpoint: string; + private timeout: number; + private logger?: Logger; + private fixedTemperature?: number; + private defaultEnableThinking: boolean; + + constructor(config: BuiltInLLMConfig = {}) { + this.apiKey = config.apiKey || process.env.LLM_API_KEY || ''; + this.endpoint = config.endpoint || process.env.LLM_ENDPOINT || 'http://fastgpt-aiproxy:3000/v1'; + this.modelName = config.model || process.env.LLM_MODEL || 'Qwen3-Next-80B-A3B-Instruct-FP8'; + this.timeout = config.timeout || 120000; + this.logger = config.logger; + this.fixedTemperature = config.fixedTemperature; + this.defaultEnableThinking = config.defaultEnableThinking ?? false; + } + + /** + * 构建请求头 + */ + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}` + }; + } + + /** + * 同步调用 + */ + async chat(messages: LLMMessage[], options?: LLMCallOptions): Promise { + const merged = resolveLLMCallOptions(options); + const model = merged.model || this.modelName; + const enableThinking = merged.enableThinking ?? this.defaultEnableThinking; + const temperature = resolveTemperature(merged.temperature, this.fixedTemperature); + const maxTokens = resolveMaxTokens( + merged.maxTokens, + enableThinking, + this.defaultEnableThinking + ); + + this.logger?.debug('[LLM] chat called', { + model, + messageCount: messages.length, + enableThinking, + temperature, + maxTokens + }); + + const body: Record = { + model, + messages: messages.map((m) => ({ + role: m.role, + content: m.content + })), + temperature, + stream: false, + ...merged.extra + }; + + // 仅当调用方显式传了 maxTokens 时才发送,避免部分模型兼容问题 + if (merged.maxTokens !== undefined) { + body.max_tokens = maxTokens; + } + + // 显式传 enable_thinking,避免 Qwen3 等模型默认开启思考模式 + if (enableThinking !== undefined) { + body.enable_thinking = enableThinking; + body.chat_template_kwargs = { enable_thinking: enableThinking }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`LLM API error: ${response.status} ${errorText}`); + } + + const data = (await response.json()) as { + choices?: Array<{ + message: { + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + reasoning_content?: string; + }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + + const choice = data.choices?.[0]; + const msg = choice?.message; + const rawContent = msg?.content ?? ''; + const finishReason = choice?.finish_reason; + const reasoningContent = msg?.reasoning_content || (msg as any)?.reasoning; + + // 从 content 中提取 思考内容(kimi-k2.5 等模型将思考输出在 content 中) + const extractedReasoning = extractThinkContent(rawContent); + const content = stripThinkBlocks(rawContent); + const finalReasoning = reasoningContent || extractedReasoning || undefined; + + this.logger?.debug('[LLM] raw response', { + finishReason, + contentLen: content.length, + reasoningLen: typeof finalReasoning === 'string' ? finalReasoning.length : 0, + content: content || '(empty)' + }); + + // finish_reason=length 说明 token 用尽,强制截断;content 内检测用于兜底 + const truncated = + finishReason === 'length' || (finishReason !== 'stop' && detectTruncation(content)); + + if (truncated) { + this.logger?.warn('[LLM] response truncated, retrying with 2x maxTokens', { + model, + maxTokens, + nextMaxTokens: maxTokens * 2, + finishReason, + truncatedContent: content, + reasoningContent + }); + + // 重试一次,maxTokens 翻倍 + const retryBody = { ...body, max_tokens: maxTokens * 2 }; + const retryResp = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(retryBody), + signal: controller.signal + }); + + if (!retryResp.ok) { + this.logger?.warn('[LLM] retry failed, returning original truncated response', { + status: retryResp.status + }); + return { + content, + toolCalls: (msg?.tool_calls ?? []).map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments } + })), + reasoning: msg?.reasoning_content || (msg as any)?.reasoning, + usage: { + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens + } + }; + } + + const retryData = (await retryResp.json()) as typeof data; + const retryChoice = retryData.choices?.[0]; + const retryMsg = retryChoice?.message; + + // 重试响应同样处理 thinking 内容 + const retryRawContent = retryMsg?.content ?? ''; + const retryExtractedReasoning = extractThinkContent(retryRawContent); + const retryContent = stripThinkBlocks(retryRawContent); + const retryFinalReasoning = + retryMsg?.reasoning_content || + (retryMsg as any)?.reasoning || + retryExtractedReasoning || + undefined; + + return { + content: retryContent, + toolCalls: (retryMsg?.tool_calls ?? msg?.tool_calls ?? []).map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments } + })), + reasoning: retryFinalReasoning, + usage: { + inputTokens: retryData.usage?.prompt_tokens ?? data.usage?.prompt_tokens, + outputTokens: retryData.usage?.completion_tokens ?? data.usage?.completion_tokens + } + }; + } + + return { + content, + toolCalls: (msg?.tool_calls ?? []).map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments } + })), + reasoning: finalReasoning, + usage: { + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens + } + }; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * 流式调用 + */ + async *chatStream(messages: LLMMessage[], options?: LLMCallOptions): AsyncIterable { + const merged = resolveLLMCallOptions(options); + const model = merged.model || this.modelName; + const enableThinking = merged.enableThinking ?? this.defaultEnableThinking; + const temperature = resolveTemperature(merged.temperature, this.fixedTemperature); + const maxTokens = resolveMaxTokens( + merged.maxTokens, + enableThinking, + this.defaultEnableThinking + ); + + const body: Record = { + model, + messages: messages.map((m) => ({ + role: m.role, + content: m.content + })), + temperature, + // max_tokens: maxTokens, + stream: true, + ...merged.extra + }; + + // 仅当调用方显式传了 maxTokens 时才发送,避免部分模型兼容问题 + if (merged.maxTokens !== undefined) { + body.max_tokens = maxTokens; + } + + // 显式传 enable_thinking,避免 Qwen3 等模型默认开启思考模式 + if (enableThinking !== undefined) { + body.enable_thinking = enableThinking; + body.chat_template_kwargs = { enable_thinking: enableThinking }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`LLM API error: ${response.status} ${errorText}`); + } + + if (!response.body) { + throw new Error('LLM response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data:')) continue; + + const dataStr = trimmed.slice(5).trim(); + if (dataStr === '[DONE]') { + return; + } + + try { + const data = JSON.parse(dataStr) as { + choices?: Array<{ + delta: { content?: string; reasoning_content?: string; tool_calls?: unknown }; + }>; + }; + const delta = data.choices?.[0]?.delta; + if (delta) { + const rawContent = delta.content || ''; + const reasoningContent = delta.reasoning_content || (delta as any)?.reasoning; + // 流式响应中,thinking 内容可能通过 content 中的 标签传输(如 kimi-k2.5) + const extractedReasoning = extractThinkContent(rawContent); + const content = stripThinkBlocks(rawContent); + const finalReasoning = reasoningContent || extractedReasoning || undefined; + yield { + content, + reasoning: finalReasoning + }; + } + } catch { + // 跳过解析错误 + } + } + } + } finally { + clearTimeout(timeoutId); + } + } + + /** + * 获取模型信息 + */ + getModelInfo() { + return { + name: this.modelName, + contextWindow: 16000, + maxOutputTokens: 8192 + }; + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/llm/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/llm/index.ts new file mode 100644 index 000000000000..1cb93138ac4d --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/llm/index.ts @@ -0,0 +1,5 @@ +// src/adapters/built-in/llm/index.ts +// Built-in LLM Adapter - 调用兼容 OpenAI API 的远程服务 + +export { BuiltInLLMAdapter, type BuiltInLLMConfig } from './adapter'; +export { createBuiltInLLMProvider } from './wrappers'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/llm/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/llm/wrappers.ts new file mode 100644 index 000000000000..92ed80032eb1 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/llm/wrappers.ts @@ -0,0 +1,19 @@ +// src/adapters/built-in/llm/wrappers.ts +// Built-in LLM Provider Wrappers + +import type { LLMProvider } from '../../../ports/llm'; +import type { Logger } from '../../../ports/logger'; +import { BuiltInLLMAdapter, type BuiltInLLMConfig } from './adapter'; + +export { type BuiltInLLMConfig } from './adapter'; + +/** + * 创建 Built-in LLM Provider + * 通过环境变量配置: + * - LLM_API_KEY: API 密钥 + * - LLM_ENDPOINT: API 端点 (默认 http://fastgpt-aiproxy:3000/v1) + * - LLM_MODEL: 模型名称 (默认 Qwen3-Next-80B-A3B-Instruct-FP8) + */ +export function createBuiltInLLMProvider(config?: BuiltInLLMConfig, logger?: Logger): LLMProvider { + return new BuiltInLLMAdapter({ ...config, logger }); +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/milvus/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/milvus/adapter.ts new file mode 100644 index 000000000000..82849808ef18 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/milvus/adapter.ts @@ -0,0 +1,363 @@ +// src/adapters/built-in/milvus/adapter.ts +// Milvus 向量检索适配器 + +import { MilvusClient, DataType, LoadState } from '@zilliz/milvus2-sdk-node'; +import type { + VectorSearchProvider, + VectorSearchOptions, + SearchResult +} from '../../../ports/search'; +import { createSearchResult } from '../../../ports/search'; +import type { ChunkResult } from '../../../types/chunk'; +import type { Logger } from '../../../ports/logger'; + +/** + * Milvus 配置 + */ +export interface MilvusConfig { + address: string; + token?: string; + database?: string; + collectionName?: string; + vectorDimension?: number; + logger?: Logger; +} + +/** + * Milvus 向量检索器 + */ +export class MilvusAdapter implements VectorSearchProvider { + public readonly type = 'milvus' as const; + + private client: MilvusClient | null = null; + private address: string; + private token?: string; + private database: string; + private collectionName: string; + private vectorDimension: number; + private logger?: Logger; + + constructor(config: MilvusConfig) { + this.address = config.address; + this.token = config.token; + this.database = config.database || 'default'; + this.collectionName = config.collectionName || 'dataset_vectors'; + this.vectorDimension = config.vectorDimension || 1536; + this.logger = config.logger; + } + + /** + * 获取客户端 + */ + private async getClient(): Promise { + if (this.client) return this.client; + + this.client = new MilvusClient({ + address: this.address, + token: this.token + }); + + // 等待连接建立 + await new Promise((resolve) => setTimeout(resolve, 1000)); + return this.client; + } + + /** + * 初始化集合 + */ + async init(): Promise { + const client = await this.getClient(); + + // 创建数据库 + try { + const { db_names } = await client.listDatabases(); + if (!db_names.includes(this.database)) { + await client.createDatabase({ db_name: this.database }); + } + await client.useDatabase({ db_name: this.database }); + } catch (error) { + // 数据库可能已存在 + this.logger?.warn('Milvus database init warning:', { + message: error instanceof Error ? error.message : String(error) + }); + } + + // 检查集合是否存在 + const { value: hasCollection } = await client.hasCollection({ + collection_name: this.collectionName + }); + + if (!hasCollection) { + // 创建集合 + await client.createCollection({ + collection_name: this.collectionName, + description: 'Store dataset vector', + enableDynamicField: true, + fields: [ + { + name: 'id', + data_type: DataType.Int64, + is_primary_key: true, + autoID: false + }, + { + name: 'vector', + data_type: DataType.FloatVector, + dim: this.vectorDimension + }, + { name: 'teamId', data_type: DataType.VarChar, max_length: 64 }, + { name: 'datasetId', data_type: DataType.VarChar, max_length: 64 }, + { name: 'collectionId', data_type: DataType.VarChar, max_length: 64 }, + { name: 'content', data_type: DataType.VarChar, max_length: 65535 }, + { name: 'sourceName', data_type: DataType.VarChar, max_length: 500 }, + { name: 'metadata', data_type: DataType.VarChar, max_length: 65535 }, + { + name: 'createTime', + data_type: DataType.Int64 + } + ], + index_params: [ + { + field_name: 'vector', + index_name: 'vector_HNSW', + index_type: 'HNSW', + metric_type: 'IP', + params: { efConstruction: 32, M: 64 } + }, + { + field_name: 'teamId', + index_type: 'Trie' + }, + { + field_name: 'datasetId', + index_type: 'Trie' + }, + { + field_name: 'collectionId', + index_type: 'Trie' + }, + { + field_name: 'createTime', + index_type: 'STL_SORT' + } + ] + }); + + this.logger?.info(`Milvus collection '${this.collectionName}' created`); + } + + // 加载集合到内存 + const { state: colLoadState } = await client.getLoadState({ + collection_name: this.collectionName + }); + + if ( + colLoadState === LoadState.LoadStateNotExist || + colLoadState === LoadState.LoadStateNotLoad + ) { + await client.loadCollectionSync({ + collection_name: this.collectionName + }); + this.logger?.info(`Milvus collection '${this.collectionName}' loaded`); + } + } + + /** + * 生成唯一 ID + */ + private generateId(): number { + const firstDigit = Math.floor(Math.random() * 8) + 1; + const restDigits = Math.floor(Math.random() * 10 ** 15); + return Number(`${firstDigit}${restDigits}`); + } + + /** + * 插入向量 + */ + async insertVectors(options: { + vectors: number[][]; + datasetId: string; + collectionId: string; + teamId?: string; + contents?: string[]; + sourceNames?: string[]; + metadata?: Record[]; + }): Promise { + const client = await this.getClient(); + const { vectors, datasetId, collectionId, teamId, contents, sourceNames, metadata } = options; + + const data = vectors.map((vector, i) => ({ + id: this.generateId(), + vector, + teamId: String(teamId || ''), + datasetId: String(datasetId), + collectionId: String(collectionId), + content: contents?.[i] || '', + sourceName: sourceNames?.[i] || '', + metadata: metadata?.[i] ? JSON.stringify(metadata[i]) : '', + createTime: Date.now() + })); + + const result = await client.insert({ + collection_name: this.collectionName, + data + }); + + if ('int_id' in result.IDs) { + return result.IDs.int_id.data.map((id) => String(id)); + } + return result.IDs.str_id.data.map((id) => String(id)); + } + + /** + * 删除向量 + */ + async deleteVectors(options: { + ids?: string[]; + datasetIds?: string[]; + collectionIds?: string[]; + teamId?: string; + }): Promise { + const client = await this.getClient(); + const { ids, datasetIds, collectionIds, teamId } = options; + + let where = ''; + + if (teamId) { + where = `(teamId == "${teamId}")`; + } + + if (ids && ids.length > 0) { + const idsFilter = `(id in [${ids.join(',')}])`; + where = where ? `${where} and ${idsFilter}` : idsFilter; + } + + if (datasetIds && datasetIds.length > 0) { + const datasetFilter = `(datasetId in [${datasetIds.map((id) => `"${id}"`).join(',')}])`; + where = where ? `${where} and ${datasetFilter}` : datasetFilter; + } + + if (collectionIds && collectionIds.length > 0) { + const collectionFilter = `(collectionId in [${collectionIds.map((id) => `"${id}"`).join(',')}])`; + where = where ? `${where} and ${collectionFilter}` : collectionFilter; + } + + if (!where) return; + + await client.delete({ + collection_name: this.collectionName, + filter: where + }); + } + + /** + * 向量检索 + */ + async search( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + if (vectors.length === 0 || datasetIds.length === 0) { + return createSearchResult([], 'vector', 'milvus'); + } + + const vector = vectors[0]; + const limit = options.limit || 10; + + // 格式化禁止的 collectionId + const forbidCollectionQuery = options.filter?.forbidCollectionIds?.length + ? `and (collectionId not in [${options.filter.forbidCollectionIds.map((id: string) => `"${id}"`).join(',')}])` + : ''; + + // 格式化过滤的 collectionId + const collectionIdQuery = options.filter?.collectionIds?.length + ? `and (collectionId in [${options.filter.collectionIds.map((id: string) => `"${id}"`).join(',')}])` + : ''; + + // 如果过滤条件为空,返回空结果 + if (options.filter?.collectionIds && options.filter.collectionIds.length === 0) { + return createSearchResult([], 'vector', 'milvus'); + } + + const client = await this.getClient(); + + const filterStr = + `(datasetId in [${datasetIds.map((id) => `"${id}"`).join(',')}]) ${collectionIdQuery} ${forbidCollectionQuery}`.trim(); + + const searchResult = await client.search({ + collection_name: this.collectionName, + data: [vector], + limit, + expr: filterStr, + output_fields: ['collectionId', 'content', 'sourceName', 'metadata', 'datasetId'] + }); + + const rows = (searchResult.results || []) as Array<{ + score: number; + id: string; + collectionId: string; + content: string; + sourceName: string; + metadata: string; + datasetId: string; + }>; + + const chunks = rows.map((item) => ({ + id: String(item.id), + content: item.content || '', + score: item.score, + datasetId: item.datasetId, + collectionId: item.collectionId, + sourceName: item.sourceName || '', + metadata: item.metadata ? JSON.parse(item.metadata) : undefined + })); + + return createSearchResult(chunks, 'vector', 'milvus'); + } + + /** + * 获取向量数量 + */ + async getCount(options: { + teamId?: string; + datasetId?: string; + collectionId?: string; + }): Promise { + const client = await this.getClient(); + + const filterConditions: string[] = []; + + if (options.teamId) { + filterConditions.push(`(teamId == "${options.teamId}")`); + } + + if (options.datasetId) { + filterConditions.push(`(datasetId == "${options.datasetId}")`); + } + + if (options.collectionId) { + filterConditions.push(`(collectionId == "${options.collectionId}")`); + } + + const filter = filterConditions.length > 0 ? filterConditions.join(' and ') : ''; + + const result = await client.query({ + collection_name: this.collectionName, + output_fields: ['count(*)'], + filter: filter || undefined + }); + + return Number(result.data?.[0]?.['count(*)'] || 0); + } + + /** + * 关闭连接 + */ + async close(): Promise { + if (this.client) { + await this.client.closeConnection(); + this.client = null; + } + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/milvus/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/milvus/index.ts new file mode 100644 index 000000000000..345a42215c0a --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/milvus/index.ts @@ -0,0 +1,6 @@ +// src/adapters/built-in/milvus/index.ts +// Milvus Built-in Adapter - 向量检索能力 + +export { MilvusAdapter } from './adapter'; +export { createMilvusProvider, wrapMilvusVectorSearch } from './wrappers'; +export type { MilvusConfig } from './adapter'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/milvus/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/milvus/wrappers.ts new file mode 100644 index 000000000000..7e228ed5b063 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/milvus/wrappers.ts @@ -0,0 +1,28 @@ +// src/adapters/built-in/milvus/wrappers.ts +// Milvus Provider Wrappers - 将 Milvus 适配器包装为标准 Provider 接口 + +import type { VectorSearchProvider, VectorSearchOptions } from '../../../ports/search'; +import { MilvusAdapter, type MilvusConfig } from './adapter'; + +/** + * 创建 Milvus 向量检索 Provider + */ +export function createMilvusProvider(config: MilvusConfig): VectorSearchProvider { + const adapter = new MilvusAdapter(config); + return { + search: async (vectors: number[][], datasetIds: string[], options: VectorSearchOptions) => { + return adapter.search(vectors, datasetIds, options); + } + }; +} + +/** + * 包装 Milvus 适配器为 VectorSearchProvider + */ +export function wrapMilvusVectorSearch(adapter: MilvusAdapter): VectorSearchProvider { + return { + search: async (vectors: number[][], datasetIds: string[], options: VectorSearchOptions) => { + return adapter.search(vectors, datasetIds, options); + } + }; +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/mixed/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/mixed/index.ts new file mode 100644 index 000000000000..f6f8e45354e0 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/mixed/index.ts @@ -0,0 +1,127 @@ +// src/adapters/builtIn/mixed/index.ts +// 内置 MixedSearchProvider - 向量检索 + 全文检索 + RRF 融合 + +import type { + MixedSearchProvider, + MixedSearchOptions, + VectorSearchProvider, + FullTextSearchProvider, + SearchResult +} from '../../../ports/search'; +import { createSearchResult } from '../../../ports/search'; +import type { EmbeddingProvider } from '../../../ports/embedding'; +import type { ChunkResult } from '../../../types/chunk'; +import type { Logger } from '../../../ports/logger'; + +/** + * 内置 Mixed Search Provider + * 封装 vector search + full text search + RRF 融合,实现 MixedSearchProvider 接口 + */ +export class BuiltInMixedSearchProvider implements MixedSearchProvider { + constructor( + private vectorSearch: VectorSearchProvider, + private fullTextSearch: FullTextSearchProvider, + private logger?: Logger + ) {} + + async search( + query: string, + datasetIds: string[], + options: MixedSearchOptions, + embeddingOrVectors: { + vectors?: number[][]; + embeddingProvider?: EmbeddingProvider; + } + ): Promise> { + const { limit, vectorWeight = 0.5 } = options; + const ftWeight = 1 - vectorWeight; + + let vectors: number[][] = embeddingOrVectors.vectors ?? []; + + // 若没有预计算向量,用 embeddingProvider 计算 + if (vectors.length === 0 && embeddingOrVectors.embeddingProvider) { + try { + const embedResult = await embeddingOrVectors.embeddingProvider.embed([query]); + vectors = embedResult.vectors; + } catch (e) { + this.logger?.warn('[BuiltInMixedSearch] embedding failed:', { + message: e instanceof Error ? e.message : String(e) + }); + } + } + + // 并行执行向量检索和全文检索 + const [vectorResult, ftResult] = await Promise.allSettled([ + vectors.length > 0 + ? this.vectorSearch.search(vectors, datasetIds, { + limit, + filter: options.filter + }) + : Promise.resolve(createSearchResult([], 'vector', 'builtIn')), + this.fullTextSearch.search(query, datasetIds, { limit }) + ]); + + const vectorChunks = vectorResult.status === 'fulfilled' ? vectorResult.value.chunks : []; + const ftChunks = ftResult.status === 'fulfilled' ? ftResult.value.chunks : []; + + this.logger?.debug( + `[BuiltInMixedSearch] vector chunks: ${vectorChunks.length}, ft chunks: ${ftChunks.length}` + ); + + // RRF 融合 + const rrfMap = new Map(); + + const addList = (list: ChunkResult[], weight: number, source: 'vector' | 'fulltext') => { + list.forEach((chunk, index) => { + const rank = index + 1; + const score = weight * (1 / (60 + rank)); + const existing = rrfMap.get(chunk.id); + if (existing) { + existing.rrfScore += score; + // 保留各来源的原始分数 + if (source === 'vector' && chunk.vectorScore !== undefined) { + existing.chunk.vectorScore = Math.max( + existing.chunk.vectorScore ?? 0, + chunk.vectorScore + ); + } else if (source === 'fulltext' && chunk.fullTextScore !== undefined) { + existing.chunk.fullTextScore = Math.max( + existing.chunk.fullTextScore ?? 0, + chunk.fullTextScore + ); + } + } else { + rrfMap.set(chunk.id, { chunk: { ...chunk }, rrfScore: score }); + } + }); + }; + + addList(vectorChunks, vectorWeight, 'vector'); + addList(ftChunks, ftWeight, 'fulltext'); + + const merged = Array.from(rrfMap.values()) + .sort((a, b) => b.rrfScore - a.rrfScore) + .slice(0, limit) + .map(({ chunk, rrfScore }) => ({ ...chunk, score: rrfScore })); + + return { + chunks: merged, + meta: { + searchSource: 'mixed', + provider: 'builtIn', + duration: 0 + } + }; + } +} + +/** + * 创建内置 MixedSearchProvider + */ +export function createBuiltInMixedSearchProvider( + vectorSearch: VectorSearchProvider, + fullTextSearch: FullTextSearchProvider, + logger?: Logger +): MixedSearchProvider { + return new BuiltInMixedSearchProvider(vectorSearch, fullTextSearch, logger); +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/mongodb/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/adapter.ts new file mode 100644 index 000000000000..e56cc7c5604f --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/adapter.ts @@ -0,0 +1,526 @@ +// src/adapters/built-in/mongodb/adapter.ts +// MongoDB 全文检索适配器 + +import type { Db, Collection, Filter } from 'mongodb'; +import { MongoClient, ObjectId } from 'mongodb'; +import type { + FullTextSearchProvider, + FullTextSearchOptions, + SearchResult +} from '../../../ports/search'; +import { createSearchResult } from '../../../ports/search'; +import type { ChunkResult } from '../../../types/chunk'; +import { jiebaSplitForSearch } from '../../../utils/jieba'; +import type { Logger } from '../../../ports/logger'; + +/** + * MongoDB 配置 + */ +export interface MongoDBConfig { + connectionString?: string; + database?: string; + collectionName?: string; + logger?: Logger; +} + +/** + * dataset_data_texts 文档结构(FastGPT 全文检索专用集合) + * 建有 text index on fullTextToken,default_language: 'none'(依赖 jieba 预分词) + */ +interface DatasetDataTextDocument { + _id: unknown; + teamId: unknown; + datasetId: unknown; + collectionId: unknown; + dataId: unknown; // 对应 dataset_datas._id + fullTextToken: string; // jieba 分词结果(空格分隔) +} + +/** + * MongoDB 文档结构 - 匹配 FastGPT dataset_collections + */ +interface DatasetCollectionDocument { + _id: string; + name?: string; + fileId?: string; + rawLink?: string; + externalFileId?: string; + externalFileUrl?: string; + apiFileId?: string; +} + +/** + * MongoDB 文档结构 - 匹配 FastGPT dataset_datas + */ +interface DatasetDocument { + _id: string; + teamId?: string; + datasetId: string; + collectionId: string; + q: string; // 主内容 (FastGPT 字段) + a?: string; // 补充内容 + metadata?: Record; + indexes?: Array<{ dataId: string; text: string }>; // 向量索引 + chunkIndex?: number; + createTime?: number; +} + +/** + * MongoDB 全文检索器 + */ +export class MongoDBAdapter implements FullTextSearchProvider { + public readonly type = 'mongodb' as const; + + private client: MongoClient | null = null; + private db: Db | null = null; + private collection: Collection | null = null; + + private connectionString: string; + private database: string; + private collectionName: string; + private logger?: Logger; + + constructor(config: MongoDBConfig = {}) { + this.connectionString = + config.connectionString || process.env.MONGODB_URI || 'mongodb://localhost:27017'; + this.database = config.database || process.env.MONGODB_DATABASE || 'fastgpt'; + this.collectionName = config.collectionName || 'dataset_datas'; + this.logger = config.logger; + } + + /** + * 连接数据库 + */ + private async connect(): Promise<{ db: Db; collection: Collection }> { + if (this.db && this.collection) { + return { db: this.db, collection: this.collection }; + } + + this.client = new MongoClient(this.connectionString); + await this.client.connect(); + + this.db = this.client.db(this.database); + this.collection = this.db.collection(this.collectionName); + + return { db: this.db, collection: this.collection }; + } + + /** + * 初始化 + */ + async init(): Promise { + await this.connect(); + this.logger?.info(`MongoDB adapter initialized. ${this.connectionString}`); + } + + /** + * 插入文档 + */ + async insertDocuments(options: { + datasetId: string; + collectionId: string; + teamId?: string; + contents: string[]; + sourceNames?: string[]; + metadata?: Record[]; + }): Promise { + const { collection } = await this.connect(); + const { datasetId, collectionId, teamId, contents, metadata } = options; + + const documents: DatasetDocument[] = contents.map((content, i) => ({ + _id: '', + teamId: teamId, + datasetId: datasetId, + collectionId: collectionId, + q: content, // FastGPT 字段 + a: undefined, + metadata: metadata?.[i], + createTime: Date.now() + })); + + const result = await collection.insertMany(documents); + return Object.values(result.insertedIds).map((id) => String(id)); + } + + /** + * 删除文档 + */ + async deleteDocuments(options: { + ids?: string[]; + datasetIds?: string[]; + collectionIds?: string[]; + teamId?: string; + }): Promise { + const { collection } = await this.connect(); + const { ids, datasetIds, collectionIds, teamId } = options; + + const filter: Record = {}; + + if (ids && ids.length > 0) { + filter._id = { $in: ids }; + } + + if (datasetIds && datasetIds.length > 0) { + filter.datasetId = { $in: datasetIds }; + } + + if (collectionIds && collectionIds.length > 0) { + filter.collectionId = { $in: collectionIds }; + } + + if (teamId) { + filter.teamId = teamId; + } + + if (Object.keys(filter).length === 0) return; + + await collection.deleteMany(filter); + } + + /** + * 全文检索 + */ + async search( + query: string, + datasetIds: string[], + options: FullTextSearchOptions + ): Promise> { + // console.log('[MongoDB] search called with:', { datasetIds, query: query?.slice(0, 50), limit: options.limit }); + this.logger?.debug('[MongoDB] search called with:', { + datasetIds, + query: query?.slice(0, 50), + limit: options.limit + }); + + const { db, collection } = await this.connect(); + const limit = options.limit || 10; + + if (!query) { + return createSearchResult([], 'fulltext', 'mongodb'); + } + + // jieba 分词(搜索模式),与 FastGPT 存储的 fullTextToken 格式一致 + const searchText = jiebaSplitForSearch(query) || query; + + // Step 1: 在 dataset_data_texts 集合上做 $text 搜索(FastGPT 全文检索集合) + // text index 是复合索引 { teamId: 1, fullTextToken: 'text' },必须提供 teamId 前缀 + const dataTextColl = db.collection('dataset_data_texts'); + const datasetObjectIds = datasetIds.map((id) => { + try { + return new ObjectId(id); + } catch { + return id; + } + }); + + // 通过 datasetId 找一条文档获取 teamId(复合 text index 的必要前缀条件) + const sampleDoc = await dataTextColl.findOne({ datasetId: { $in: datasetObjectIds } }); + if (!sampleDoc) { + this.logger?.warn('[MongoDB] No documents found for given datasetIds in dataset_data_texts'); + return createSearchResult([], 'fulltext', 'mongodb'); + } + const teamId = sampleDoc.teamId; + + let textResults: Array = []; + try { + textResults = await dataTextColl + .aggregate([ + { + $match: { + teamId, + $text: { $search: searchText }, + datasetId: { $in: datasetObjectIds } + } + }, + { $sort: { score: { $meta: 'textScore' } } }, + { $limit: limit }, + { $project: { _id: 1, collectionId: 1, dataId: 1, score: { $meta: 'textScore' } } } + ]) + .toArray(); + } catch (e) { + this.logger?.warn('[MongoDB] dataset_data_texts $text search failed:', { + message: e instanceof Error ? e.message : String(e) + }); + return createSearchResult([], 'fulltext', 'mongodb'); + } + + this.logger?.debug('[MongoDB] dataset_data_texts results:', { count: textResults.length }); + if (textResults.length === 0) return createSearchResult([], 'fulltext', 'mongodb'); + + // Step 2: 通过 dataId 回查 dataset_datas 拿内容 + const dataIds = textResults.map((r) => r.dataId as ObjectId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataDocs = await collection.find({ _id: { $in: dataIds } } as any).toArray(); + const dataMap = new Map(dataDocs.map((d) => [String(d._id), d])); + + const chunks = textResults + .map((textDoc, idx) => { + const data = dataMap.get(String(textDoc.dataId)); + if (!data) return null; + return { + id: String(data._id), + content: (data.q || '') + (data.a ? '\n' + data.a : ''), + score: textResults[idx]?.score ?? 1, + datasetId: String(textDoc.datasetId), + collectionId: String(textDoc.collectionId), + sourceName: (data.metadata?.sourceName as string) || '', + metadata: data.metadata + } as ChunkResult; + }) + .filter((r): r is ChunkResult => r !== null); + + return createSearchResult(chunks, 'fulltext', 'mongodb'); + } + + /** + * 获取文档数量 + */ + async getCount(options: { + teamId?: string; + datasetId?: string; + collectionId?: string; + }): Promise { + const { collection } = await this.connect(); + + const filter: Record = {}; + + if (options.teamId) { + filter.teamId = options.teamId; + } + + if (options.datasetId) { + filter.datasetId = options.datasetId; + } + + if (options.collectionId) { + filter.collectionId = options.collectionId; + } + + return collection.countDocuments(filter); + } + + /** + * 列出 FastGPT datasets 集合中的所有数据集(用于调试) + */ + async listDatasets(): Promise> { + const { db } = await this.connect(); + try { + const datasets = await db + .collection('datasets') + .find({}, { projection: { _id: 1, name: 1 } }) + .toArray(); + return datasets.map((d) => ({ id: String(d._id), name: String(d.name || '') })); + } catch { + return []; + } + } + + /** + * 关闭连接 + */ + async close(): Promise { + if (this.client) { + await this.client.close(); + this.client = null; + this.db = null; + this.collection = null; + } + } + + /** + * 根据 vector IDs (PG row IDs) 查询 MongoDB 获取内容 + * FastGPT 中 vector id 存储在 dataset_datas.indexes[].dataId 中 + * 参考: FastGPT packages/service/core/dataset/search/base.ts + * 重点:按内容 (dataId) 去重,而不是按 vectorId,因为多个 vector ID 可能指向同一内容 + * sourceName 需要从 collections 表获取(参考 getCollectionSourceData) + */ + async lookupByContentIds( + vectorIds: string[], + collectionIds: string[], + datasetIds?: string[] + ): Promise< + Array<{ + vectorId: string; + dataId: string; + content: string; + sourceName: string; + metadata: Record | undefined; + }> + > { + const { db, collection } = await this.connect(); + + if (vectorIds.length === 0 || collectionIds.length === 0) { + return []; + } + + // 将 string datasetIds 转换为 MongoDB ObjectId (可选) + let datasetObjectIds: (ObjectId | string)[] | undefined; + if (datasetIds && datasetIds.length > 0) { + datasetObjectIds = datasetIds.map((id) => { + try { + return new ObjectId(id); + } catch { + return id; + } + }); + } + + // ⚡ 优化:将 vectorIds 转换为 Set,提升查询效率 O(1) + const vectorIdSet = new Set(vectorIds); + + // 构建查询条件 - 只用 vectorIds 查询,collectionId 和 datasetId 在内存中过滤 + // 原因: MongoDB 文档中 collectionId/datasetId 存储为 ObjectId,但传入的是字符串 + // 同时尝试 ObjectId 和字符串两种格式 + const vectorIdObjectIds = vectorIds.map((id) => { + try { + return new ObjectId(id); + } catch { + return id; + } + }); + const query: Record = { + $or: [ + { 'indexes.dataId': { $in: vectorIds } }, + { 'indexes.dataId': { $in: vectorIdObjectIds } } + ] + }; + + // 添加 datasetId 过滤(如果提供了的话)- MongoDB 可以直接用 ObjectId 比较 + if (datasetObjectIds && datasetObjectIds.length > 0) { + query.datasetId = { $in: datasetObjectIds }; + } + + const docs = await collection.find(query).toArray(); + + // 在内存中根据传入的 collectionIds 过滤文档 + // 注意: MongoDB 文档中的 collectionId 是 ObjectId,传入的是字符串 + const collectionIdSet = new Set(collectionIds.map((id) => String(id).toLowerCase())); + const filteredDocs = docs.filter((doc) => { + const docCollectionId = String(doc.collectionId).toLowerCase(); + return collectionIdSet.has(docCollectionId); + }); + + // 构建映射:vectorId -> { dataId, content, metadata, collectionId } + // 关键:一个 dataId 对应一个内容,多个 vectorId 可能指向同一个 dataId + const vectorIdToDataIdMap = new Map(); + const dataIdToContentMap = new Map< + string, + { content: string; collectionId: string; metadata: Record | undefined } + >(); + + let matchedVectorIds = 0; + for (const doc of filteredDocs) { + const docCollectionId = String(doc.collectionId); + if (doc.indexes && Array.isArray(doc.indexes)) { + for (const idx of doc.indexes) { + const vid = String(idx.dataId); + if (vectorIdSet.has(vid)) { + vectorIdToDataIdMap.set(vid, String(doc._id)); + // 同时保存 collectionId,用于后续查 collections 表 + if (!dataIdToContentMap.has(String(doc._id))) { + dataIdToContentMap.set(String(doc._id), { + content: (doc.q || '') + (doc.a ? '\n' + doc.a : ''), + collectionId: docCollectionId, + metadata: doc.metadata ?? undefined + }); + } + matchedVectorIds++; + } + } + } + } + + // 获取所有涉及的 collectionId,查询 collections 表获取 sourceName + const uniqueCollectionIds = [ + ...new Set( + filteredDocs + .map((doc) => String(doc.collectionId)) + .filter((id) => collectionIdSet.has(id.toLowerCase())) + ) + ]; + + const collectionMap = new Map(); // collectionId -> sourceName + if (uniqueCollectionIds.length > 0) { + try { + const collectionsColl = db.collection('dataset_collections'); + // 分离可以转换为 ObjectId 的 ID 和字符串 ID + const objectIds: ObjectId[] = []; + const stringIds: string[] = []; + for (const id of uniqueCollectionIds) { + try { + objectIds.push(new ObjectId(id)); + } catch { + stringIds.push(id); + } + } + // 分别查询 + const filter: Filter[] = []; + if (objectIds.length > 0) filter.push({ _id: { $in: objectIds as unknown as string[] } }); + if (stringIds.length > 0) filter.push({ _id: { $in: stringIds } }); + const queryFilter = filter.length === 1 ? filter[0] : { $or: filter }; + + const collectionDocs = await collectionsColl + .find(queryFilter, { + projection: { + _id: 1, + name: 1, + fileId: 1, + rawLink: 1, + externalFileId: 1, + externalFileUrl: 1, + apiFileId: 1 + } + }) + .toArray(); + + for (const coll of collectionDocs) { + const cid = String(coll._id); + // 优先使用 name,其次使用 fileId/rawLink/externalFileId/externalFileUrl/apiFileId + const sourceName = + coll.name || + String( + coll.fileId || + coll.rawLink || + coll.externalFileId || + coll.externalFileUrl || + coll.apiFileId || + '' + ); + collectionMap.set(cid, sourceName); + } + } catch (e) { + this.logger?.warn('[MongoDB] lookupByContentIds: query collections failed:', { + message: e instanceof Error ? e.message : String(e) + }); + } + } + + // 按 vectorIds 顺序返回结果,但按 dataId 去重 + // 重点:多个 vectorId 指向同一 dataId 时,只返回一条 + const seenDataIds = new Set(); + const results = vectorIds + .map((vid) => { + const dataId = vectorIdToDataIdMap.get(vid); + if (!dataId || seenDataIds.has(dataId)) { + return null; + } + seenDataIds.add(dataId); + const content = dataIdToContentMap.get(dataId); + if (!content) { + return null; + } + // 从 collectionMap 获取 sourceName + const sourceName = collectionMap.get(content.collectionId) || ''; + return { + vectorId: vid, + dataId, + content: content.content, + sourceName, + metadata: content.metadata + }; + }) + .filter((r): r is NonNullable => r !== null); + + return results; + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/mongodb/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/index.ts new file mode 100644 index 000000000000..aa63f762df8a --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/index.ts @@ -0,0 +1,10 @@ +// src/adapters/built-in/mongodb/index.ts +// MongoDB Built-in Adapter - 全文检索能力 + +export { MongoDBAdapter } from './adapter'; +export { + createBuiltInFullTextSearchProvider, + wrapMongoDBFullTextSearch, + createMongoDBProvider +} from './wrappers'; +export type { MongoDBConfig } from './adapter'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/mongodb/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/wrappers.ts new file mode 100644 index 000000000000..e568f6b1543b --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/mongodb/wrappers.ts @@ -0,0 +1,40 @@ +// src/adapters/built-in/mongodb/wrappers.ts +// MongoDB Provider Wrappers - 将 MongoDB 适配器包装为标准 Provider 接口 + +import type { FullTextSearchProvider, FullTextSearchOptions } from '../../../ports/search'; +import type { Logger } from '../../../ports/logger'; +import { MongoDBAdapter, type MongoDBConfig } from './adapter'; + +export { type MongoDBConfig } from './adapter'; + +/** + * 创建 MongoDB 全文检索 Provider + */ +export function createBuiltInFullTextSearchProvider( + config?: MongoDBConfig, + logger?: Logger +): FullTextSearchProvider { + const adapter = new MongoDBAdapter({ ...config, logger }); + + return { + search: async (query: string, datasetIds: string[], options: FullTextSearchOptions) => { + return adapter.search(query, datasetIds, options); + } + }; +} + +/** + * 包装 MongoDB 适配器为 FullTextSearchProvider + */ +export function wrapMongoDBFullTextSearch(adapter: MongoDBAdapter): FullTextSearchProvider { + return { + search: async (query: string, datasetIds: string[], options: FullTextSearchOptions) => { + return adapter.search(query, datasetIds, options); + } + }; +} + +/** + * 别名:createMongoDBProvider(向后兼容) + */ +export const createMongoDBProvider = createBuiltInFullTextSearchProvider; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/pgvector/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/adapter.ts new file mode 100644 index 000000000000..e9f6b40f2a4f --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/adapter.ts @@ -0,0 +1,176 @@ +// src/adapters/built-in/pgvector/adapter.ts +// PGVector 向量检索适配器 + +import pg from 'pg'; +import type { + VectorSearchProvider, + VectorSearchOptions, + SearchResult +} from '../../../ports/search'; +import { createSearchResult } from '../../../ports/search'; +import type { ChunkResult } from '../../../types/chunk'; +import type { Logger } from '../../../ports/logger'; + +const { Pool } = pg; + +/** + * PGVector 配置 + */ +export interface PGVectorConfig { + connectionString?: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; + tableName?: string; + vectorDimension?: number; + logger?: Logger; +} + +/** + * PGVector 向量检索器 + */ +export class PGVectorAdapter implements VectorSearchProvider { + public readonly type = 'pgvector' as const; + + private pool: pg.Pool; + private tableName: string; + private vectorDimension: number; + private logger?: Logger; + + constructor(config: PGVectorConfig = {}) { + const connectionString = + config.connectionString || + `postgresql://${config.user || 'postgres'}:${config.password || 'password'}@${config.host || 'localhost'}:${config.port || 5432}/${config.database || 'postgres'}`; + + this.pool = new Pool({ connectionString }); + this.tableName = config.tableName || 'modeldata'; // FastGPT uses 'modeldata' + this.vectorDimension = config.vectorDimension || 1536; + this.logger = config.logger; + } + + /** + * 初始化表结构 + */ + async init(): Promise { + // 不需要初始化,FastGPT 的 PG 数据已存在 + this.logger?.info('PGVector adapter ready (no init needed)'); + } + + /** + * 获取向量维度 + */ + getVectorDimension(): number { + return this.vectorDimension; + } + + /** + * 直接向量检索(不补零) + * 用原始向量维度查询,FastGPT PG 中实际存储的是 1024 维的向量 + */ + async searchDirect( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + if (vectors.length === 0 || datasetIds.length === 0) { + return createSearchResult([], 'vector', 'pgvector'); + } + + const vector = vectors[0]; + const limit = options.limit || 10; + + // 格式化禁止的 collectionId + const forbidCollectionSql = options.filter?.forbidCollectionIds?.length + ? `AND collection_id NOT IN (${options.filter.forbidCollectionIds.map((id: string) => `'${id}'`).join(',')})` + : ''; + + // 格式化过滤的 collectionId + const filterCollectionIdSql = options.filter?.collectionIds?.length + ? `AND collection_id IN (${options.filter.collectionIds.map((id: string) => `'${id}'`).join(',')})` + : ''; + + if (options.filter?.collectionIds && options.filter.collectionIds.length === 0) { + return createSearchResult([], 'vector', 'pgvector'); + } + + const client = await this.pool.connect(); + try { + const datasetIdsSql = datasetIds.map((id) => `'${String(id)}'`).join(','); + const results: any = await client.query( + `BEGIN; + SET LOCAL hnsw.ef_search = 100; + SET LOCAL hnsw.max_scan_tuples = 100000; + SET LOCAL hnsw.iterative_scan = relaxed_order; + WITH relaxed_results AS MATERIALIZED ( + SELECT id, collection_id, dataset_id, + vector <#> '[${vector.join(',')}]' AS ip_score + FROM ${this.tableName} + WHERE dataset_id IN (${datasetIdsSql}) + ${filterCollectionIdSql} + ${forbidCollectionSql} + ORDER BY ip_score + LIMIT ${limit} + ) SELECT id, collection_id, dataset_id, ip_score FROM relaxed_results ORDER BY ip_score; + COMMIT;` + ); + + const rows: Array<{ + id: string; + collection_id: string; + dataset_id: string; + ip_score: number; + }> = results?.[results.length - 2]?.rows ?? []; + + // 计算分数范围用于规范化 + const scores = rows.map((r) => Math.abs(r.ip_score)); + const minScore = Math.min(...scores, 0); + const maxScore = Math.max(...scores, 1); + const scoreRange = maxScore - minScore || 1; + + const chunks = rows.map((row) => { + // pgvector <#> 返回负的内积,转为正值后作为相似度 + const rawScore = Math.abs(row.ip_score); + // 规范化到 0-1 范围 + const score = (rawScore - minScore) / scoreRange; + + return { + id: String(row.id), + content: '', + score, + vectorScore: score, // 保留原始向量相似度,防止被 RRF 覆盖后丢失 + datasetId: row.dataset_id, + collectionId: row.collection_id, + sourceName: '', + metadata: undefined + }; + }); + + return createSearchResult(chunks, 'vector', 'pgvector'); + } finally { + client.release(); + } + } + + /** + * 向量检索(原始方法,保持兼容性) + * 返回的 score 是内积相似度(0-1 范围) + * pgvector <#> 返回负的内积,取绝对值后作为相似度 + */ + async search( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + // 使用直接查询 + return this.searchDirect(vectors, datasetIds, options); + } + + /** + * 关闭连接池 + */ + async close(): Promise { + await this.pool.end(); + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/pgvector/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/index.ts new file mode 100644 index 000000000000..2f7324baf7c2 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/index.ts @@ -0,0 +1,6 @@ +// src/adapters/built-in/pgvector/index.ts +// PGVector Built-in Adapter - 向量检索能力 + +export { PGVectorAdapter } from './adapter'; +export { createBuiltInVectorSearchProvider, wrapPGVectorSearch } from './wrappers'; +export type { PGVectorConfig } from './adapter'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/pgvector/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/wrappers.ts new file mode 100644 index 000000000000..bcb860525450 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/pgvector/wrappers.ts @@ -0,0 +1,34 @@ +// src/adapters/built-in/pgvector/wrappers.ts +// PGVector Provider Wrappers - 将 PGVector 适配器包装为标准 Provider 接口 + +import type { VectorSearchProvider, VectorSearchOptions } from '../../../ports/search'; +import type { Logger } from '../../../ports/logger'; +import { PGVectorAdapter, type PGVectorConfig } from './adapter'; + +export { type PGVectorConfig } from './adapter'; + +/** + * 创建 PGVector 向量检索 Provider + */ +export function createBuiltInVectorSearchProvider( + config?: PGVectorConfig, + logger?: Logger +): VectorSearchProvider { + const adapter = new PGVectorAdapter({ ...config, logger }); + return { + search: async (vectors: number[][], datasetIds: string[], options: VectorSearchOptions) => { + return adapter.search(vectors, datasetIds, options); + } + }; +} + +/** + * 包装 PGVector 适配器为 VectorSearchProvider + */ +export function wrapPGVectorSearch(adapter: PGVectorAdapter): VectorSearchProvider { + return { + search: async (vectors: number[][], datasetIds: string[], options: VectorSearchOptions) => { + return adapter.search(vectors, datasetIds, options); + } + }; +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/providers.ts b/packages/diting-rag-ts/src/adapters/builtIn/providers.ts new file mode 100644 index 000000000000..d25c1ce062b7 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/providers.ts @@ -0,0 +1,84 @@ +// src/adapters/builtIn/providers.ts +// Built-in Providers 统一工厂 - 一次性创建所有 providers 并注入 logger + +import type { Logger } from '../../ports/logger'; +import type { AgenticSearchProviders } from '../../ports/agentic'; +import { createBuiltInLLMProvider, type BuiltInLLMConfig } from './llm/wrappers'; +import { createBuiltInEmbeddingProvider, type BuiltInEmbeddingConfig } from './embedding/wrappers'; +import { createBuiltInVectorSearchProvider, type PGVectorConfig } from './pgvector/wrappers'; +import { createBuiltInFullTextSearchProvider, type MongoDBConfig } from './mongodb/wrappers'; +import { createBuiltInRerankProvider, type BuiltInRerankConfig } from './reranker/wrappers'; +import { createBuiltInMixedSearchProvider } from './mixed/index'; +import { createLogger, LogLevel } from '../../ports/logger'; + +export interface BuiltInProvidersOptions { + /** Logger 实例,如果不提供则创建默认 logger */ + logger?: Logger; + /** Logger 级别,默认 info */ + loggerLevel?: 'debug' | 'info' | 'warn' | 'error'; + /** Logger 前缀,默认 AgenticRAG */ + loggerPrefix?: string; + /** LLM 配置 */ + llm?: BuiltInLLMConfig; + /** Embedding 配置 */ + embedding?: BuiltInEmbeddingConfig; + /** Vector Search 配置 (PGVector) */ + vectorSearch?: PGVectorConfig; + /** Full Text Search 配置 (MongoDB) */ + fullTextSearch?: MongoDBConfig; + /** Reranker 配置 */ + reranker?: BuiltInRerankConfig; +} + +/** + * 创建所有 Built-in Providers(统一入口) + * 方便自测和日志集成 + */ +export function createBuiltInProviders( + options: BuiltInProvidersOptions = {} +): AgenticSearchProviders { + const { + logger, + loggerLevel = 'info', + loggerPrefix = 'AgenticRAG', + llm, + embedding, + vectorSearch, + fullTextSearch, + reranker + } = options; + + // 创建默认 logger(如果未提供) + let resolvedLogger = logger; + if (!resolvedLogger) { + const level = + loggerLevel === 'debug' + ? LogLevel.DEBUG + : loggerLevel === 'warn' + ? LogLevel.WARN + : loggerLevel === 'error' + ? LogLevel.ERROR + : LogLevel.INFO; + resolvedLogger = createLogger({ level, prefix: loggerPrefix }); + } + + const vectorSearchProvider = createBuiltInVectorSearchProvider(vectorSearch, resolvedLogger); + const fullTextSearchProvider = createBuiltInFullTextSearchProvider( + fullTextSearch, + resolvedLogger + ); + + return { + llm: createBuiltInLLMProvider(llm, resolvedLogger), + embed: createBuiltInEmbeddingProvider(embedding, resolvedLogger), + vectorSearch: vectorSearchProvider, + fullTextSearch: fullTextSearchProvider, + mixedSearch: createBuiltInMixedSearchProvider( + vectorSearchProvider, + fullTextSearchProvider, + resolvedLogger + ), + reranker: reranker ? createBuiltInRerankProvider(reranker, resolvedLogger) : undefined, + logger: resolvedLogger + }; +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/reranker/adapter.ts b/packages/diting-rag-ts/src/adapters/builtIn/reranker/adapter.ts new file mode 100644 index 000000000000..4f778304f33a --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/reranker/adapter.ts @@ -0,0 +1,197 @@ +// src/adapters/built-in/reranker/adapter.ts +// Built-in Rerank 适配器 - 调用兼容 OpenAI API 的远程 Rerank 服务 + +import type { RerankProvider } from '../../../ports/reranker'; +import type { ChunkResult } from '../../../types/chunk'; +import type { Logger } from '../../../ports/logger'; + +/** + * Built-in Rerank 配置 + */ +export interface BuiltInRerankConfig { + apiKey?: string; + endpoint?: string; + model?: string; + topN?: number; + timeout?: number; + batchSize?: number; // 分批请求大小,默认 50(对齐 Python) + maxDocLength?: number; // 文档截断长度(字符),默认 8192(对齐 Python) + logger?: Logger; +} + +/** + * Built-in Rerank 适配器 + * 通过环境变量配置,调用兼容 OpenAI API 的远程服务(如 Ollama、vLLM 等) + * 支持分批请求 + 文档截断 + 降级到关键词精排 + */ +export class BuiltInRerankAdapter implements RerankProvider { + public readonly type = 'builtin' as const; + + private apiKey: string; + private endpoint: string; + private model: string; + private topN: number; + private timeout: number; + private batchSize: number; + private maxDocLength: number; + private logger?: Logger; + + constructor(config: BuiltInRerankConfig = {}) { + this.apiKey = config.apiKey || process.env.RERANK_API_KEY || ''; + this.endpoint = + config.endpoint || process.env.RERANK_ENDPOINT || 'http://fastgpt-aiproxy:3000/v1'; + this.model = config.model || process.env.RERANK_MODEL || 'bge-reranker-large'; + this.topN = config.topN || 10; + this.timeout = config.timeout || 60000; + this.batchSize = config.batchSize || 50; + this.maxDocLength = config.maxDocLength || 8192; + this.logger = config.logger; + } + + /** + * 构建请求头 + */ + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}` + }; + } + + /** + * Rerank 排序 + * 优先尝试远程 API(分批),失败时降级到关键词精排 + */ + async rerank( + query: string, + chunks: ChunkResult[] + ): Promise> { + if (chunks.length === 0) { + return []; + } + + try { + return await this.rerankRemote(query, chunks); + } catch { + return this.rerankLocal(query, chunks); + } + } + + /** + * 远程 Rerank(分批 + 文档截断,对齐 Python 实现) + */ + private async rerankRemote( + query: string, + chunks: ChunkResult[] + ): Promise> { + // 文档截断 + const docTexts = chunks.map((c) => + c.content.length > this.maxDocLength ? c.content.slice(0, this.maxDocLength) : c.content + ); + + // 构建 rerank endpoint(确保以 /rerank 结尾) + const apiUrl = this.endpoint.replace(/\/$/, '').replace(/\/rerank$/, '') + '/rerank'; + + const allScores: number[] = new Array(chunks.length).fill(0); + + // 分批请求 + for (let batchStart = 0; batchStart < docTexts.length; batchStart += this.batchSize) { + const batchDocs = docTexts.slice(batchStart, batchStart + this.batchSize); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + model: this.model, + query, + documents: batchDocs + }), + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`Rerank API error: ${response.status}`); + } + + const data = (await response.json()) as { + results?: Array<{ index: number; relevance_score: number }>; + }; + + for (const item of data.results ?? []) { + const globalIdx = batchStart + item.index; + if (globalIdx < allScores.length) { + allScores[globalIdx] = item.relevance_score; + } + } + } catch (e) { + // 该批次失败,保持 0 分,继续处理其他批次 + this.logger?.warn( + `[Reranker] batch ${Math.floor(batchStart / this.batchSize) + 1} failed:`, + { message: e instanceof Error ? e.message : String(e) } + ); + } finally { + clearTimeout(timeoutId); + } + } + + // 全零分说明所有批次均失败,触发降级 + if (allScores.every((s) => s === 0)) { + throw new Error('All rerank batches returned zero scores'); + } + + return chunks + .map((chunk, i) => ({ ...chunk, rerankScore: allScores[i] })) + .sort((a, b) => b.rerankScore - a.rerankScore) + .slice(0, this.topN); + } + + /** + * 本地关键词精排(降级方案) + * 返回的 rerankScore 也是 0-1 范围 + */ + private rerankLocal( + query: string, + chunks: ChunkResult[] + ): Array { + const queryTerms = query + .toLowerCase() + .replace(/[??]/g, '') + .split(/\s+/) + .filter((t) => t.length > 1); + + const scored = chunks.map((chunk) => { + const content = chunk.content.toLowerCase(); + let keywordHits = 0; + for (const term of queryTerms) { + // 统计出现次数(对齐 Python count()) + let pos = 0; + while ((pos = content.indexOf(term, pos)) !== -1) { + keywordHits++; + pos++; + } + } + const keywordScore = + queryTerms.length > 0 ? Math.min(keywordHits / Math.max(queryTerms.length * 3, 1), 1.0) : 0; + + // 融合公式: 0.4 * 原始向量分数 + 0.6 * 关键词分数 + // 原始 score 来自向量检索或全文检索,范围 0-1 + // 综合后仍保持 0-1 范围 + const rerankScore = 0.4 * (chunk.score ?? 0) + 0.6 * keywordScore; + return { ...chunk, rerankScore }; + }); + + scored.sort((a, b) => b.rerankScore - a.rerankScore); + return scored.slice(0, this.topN); + } + + /** + * 获取模型信息 + */ + getModelInfo() { + return { name: this.model }; + } +} diff --git a/packages/diting-rag-ts/src/adapters/builtIn/reranker/index.ts b/packages/diting-rag-ts/src/adapters/builtIn/reranker/index.ts new file mode 100644 index 000000000000..bbcfde84e2d1 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/reranker/index.ts @@ -0,0 +1,5 @@ +// src/adapters/built-in/reranker/index.ts +// Built-in Rerank Adapter - 调用兼容 OpenAI API 的远程服务 + +export { BuiltInRerankAdapter, type BuiltInRerankConfig } from './adapter'; +export { createBuiltInRerankProvider } from './wrappers'; diff --git a/packages/diting-rag-ts/src/adapters/builtIn/reranker/wrappers.ts b/packages/diting-rag-ts/src/adapters/builtIn/reranker/wrappers.ts new file mode 100644 index 000000000000..c936c8f7868e --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/builtIn/reranker/wrappers.ts @@ -0,0 +1,27 @@ +// src/adapters/built-in/reranker/wrappers.ts +// Built-in Rerank Provider Wrappers + +import type { RerankProvider } from '../../../ports/reranker'; +import type { Logger } from '../../../ports/logger'; +import { BuiltInRerankAdapter, type BuiltInRerankConfig } from './adapter'; + +export { type BuiltInRerankConfig } from './adapter'; + +/** + * 创建 Built-in Rerank Provider + * 通过环境变量配置: + * - RERANK_API_KEY: API 密钥 + * - RERANK_ENDPOINT: API 端点 (默认 http://fastgpt-aiproxy:3000/v1) + * - RERANK_MODEL: 模型名称 (默认 bge-reranker-large) + */ +export function createBuiltInRerankProvider( + config?: BuiltInRerankConfig, + logger?: Logger +): RerankProvider { + return new BuiltInRerankAdapter({ ...config, logger }); +} + +/** + * 别名:createBuiltInRerankerProvider + */ +export const createBuiltInRerankerProvider = createBuiltInRerankProvider; diff --git a/packages/diting-rag-ts/src/adapters/index.ts b/packages/diting-rag-ts/src/adapters/index.ts new file mode 100644 index 000000000000..294c5b8f171d --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/index.ts @@ -0,0 +1,4 @@ +// src/adapters/index.ts + +export type { BaseAdapter, AdapterContext } from './base'; +export type { SearchAdapter, SearchAdapterOptions } from './searchAdapter'; diff --git a/packages/diting-rag-ts/src/adapters/mock/embedding.ts b/packages/diting-rag-ts/src/adapters/mock/embedding.ts new file mode 100644 index 000000000000..a8df2c4b1bbb --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/mock/embedding.ts @@ -0,0 +1,26 @@ +// src/adapters/mock/embedding.ts +// Mock Embedding Provider + +import type { EmbeddingProvider } from '../../ports/embedding'; + +export class MockEmbeddingProvider implements EmbeddingProvider { + public readonly type = 'openai' as const; + private dimension: number; + + constructor(dimension: number = 1536) { + this.dimension = dimension; + } + + async embed(texts: string[]): Promise<{ vectors: number[][]; tokens: number }> { + const vectors = texts.map(() => + Array(this.dimension) + .fill(0) + .map(() => Math.random()) + ); + return { vectors, tokens: texts.reduce((sum, t) => sum + Math.ceil(t.length / 4), 0) }; + } + + getModelInfo() { + return { name: 'mock-embedding', dimension: this.dimension }; + } +} diff --git a/packages/diting-rag-ts/src/adapters/mock/full_text_search.ts b/packages/diting-rag-ts/src/adapters/mock/full_text_search.ts new file mode 100644 index 000000000000..cae92b43533e --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/mock/full_text_search.ts @@ -0,0 +1,25 @@ +// src/adapters/mock/full_text_search.ts +// Mock Full Text Search Provider + +import type { + FullTextSearchProvider, + FullTextSearchOptions, + SearchResult +} from '../../ports/search'; +import { createSearchResult } from '../../ports/search'; +import type { ChunkResult } from '../../types/chunk'; + +export class MockFullTextSearchProvider implements FullTextSearchProvider { + public readonly type = 'fastgpt' as const; + + constructor(private chunks: ChunkResult[] = []) {} + + async search( + _query: string, + _datasetIds: string[], + options: FullTextSearchOptions + ): Promise> { + const result = this.chunks.slice(0, options.limit); + return createSearchResult(result, 'fulltext', 'mock'); + } +} diff --git a/packages/diting-rag-ts/src/adapters/mock/llm.ts b/packages/diting-rag-ts/src/adapters/mock/llm.ts new file mode 100644 index 000000000000..a72b0c8209f5 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/mock/llm.ts @@ -0,0 +1,51 @@ +// src/adapters/mock/llm.ts +// Mock LLM Provider + +import type { LLMProvider } from '../../ports/llm'; +import type { LLMMessage, LLMResponse, LLMCallOptions } from '../../types/message'; + +export interface MockLLMOptions { + modelName?: string; + responses?: string[]; + responseDelay?: number; + shouldFail?: boolean; +} + +export class MockLLMProvider implements LLMProvider { + private responses: string[]; + private index = 0; + private responseDelay?: number; + private shouldFail = false; + public readonly type = 'openai' as const; + public readonly modelName: string; + + constructor(options: MockLLMOptions = {}) { + this.responses = options.responses || ['{"tool": "search", "queries": ["test"]}']; + this.responseDelay = options.responseDelay; + this.shouldFail = options.shouldFail || false; + this.modelName = options.modelName || 'mock-llm'; + } + + async chat(_messages: LLMMessage[], _options?: LLMCallOptions): Promise { + if (this.responseDelay) { + await new Promise((resolve) => setTimeout(resolve, this.responseDelay)); + } + if (this.shouldFail) { + throw new Error('Mock LLM failure'); + } + const content = this.responses[this.index++ % this.responses.length]; + return { content }; + } + + async *chatStream( + _messages: LLMMessage[], + _options?: LLMCallOptions + ): AsyncIterable { + const response = await this.chat(_messages, _options); + yield response; + } + + getModelInfo() { + return { name: this.modelName, contextWindow: 16000, maxOutputTokens: 8192 }; + } +} diff --git a/packages/diting-rag-ts/src/adapters/mock/reranker.ts b/packages/diting-rag-ts/src/adapters/mock/reranker.ts new file mode 100644 index 000000000000..5266f70cf8d6 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/mock/reranker.ts @@ -0,0 +1,19 @@ +// src/adapters/mock/reranker.ts +// Mock Rerank Provider + +import type { RerankProvider } from '../../ports/reranker'; +import type { ChunkResult } from '../../types/chunk'; + +export class MockRerankProvider implements RerankProvider { + public readonly type = 'fastgpt' as const; + + async rerank( + _query: string, + chunks: ChunkResult[] + ): Promise> { + return chunks.map((chunk, index) => ({ + ...chunk, + rerankScore: 1 - index * 0.1 + })); + } +} diff --git a/packages/diting-rag-ts/src/adapters/mock/vector_search.ts b/packages/diting-rag-ts/src/adapters/mock/vector_search.ts new file mode 100644 index 000000000000..800e4c869f38 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/mock/vector_search.ts @@ -0,0 +1,21 @@ +// src/adapters/mock/vector_search.ts +// Mock Vector Search Provider + +import type { VectorSearchProvider, VectorSearchOptions, SearchResult } from '../../ports/search'; +import { createSearchResult } from '../../ports/search'; +import type { ChunkResult } from '../../types/chunk'; + +export class MockVectorSearchProvider implements VectorSearchProvider { + public readonly type = 'fastgpt' as const; + + constructor(private chunks: ChunkResult[] = []) {} + + async search( + _vectors: number[][], + _datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + const result = this.chunks.slice(0, options.limit); + return createSearchResult(result, 'vector', 'mock'); + } +} diff --git a/packages/diting-rag-ts/src/adapters/searchAdapter.ts b/packages/diting-rag-ts/src/adapters/searchAdapter.ts new file mode 100644 index 000000000000..601104a987b7 --- /dev/null +++ b/packages/diting-rag-ts/src/adapters/searchAdapter.ts @@ -0,0 +1,74 @@ +// src/adapters/searchAdapter.ts + +import type { AdapterContext } from './base'; +import { BaseAdapter } from './base'; +import type { ChunkResult } from '../types/chunk'; + +export interface SearchAdapterOptions { + preserveRawData?: boolean; + fieldMapping?: { + id?: string; + content?: string; + score?: string; + datasetId?: string; + sourceName?: string; + }; +} + +export class SearchAdapter extends BaseAdapter { + private options: SearchAdapterOptions; + + constructor(context: AdapterContext, options: SearchAdapterOptions = {}) { + super(context); + this.options = options; + } + + toDitingChunk(data: any): ChunkResult { + const mapping = this.options.fieldMapping || {}; + + return { + id: data[mapping.id || 'id'] || data.id || '', + content: data[mapping.content || 'content'] || data.content || '', + score: data[mapping.score || 'score'] ?? data.score ?? 0, + datasetId: data[mapping.datasetId || 'datasetId'] || data.dataset_id || '', + sourceName: data[mapping.sourceName || 'sourceName'] || data.source_name || '', + collectionId: data.collectionId || data.collection_id, + metadata: data.metadata || {}, + searchSource: data.searchSource, + vectorScore: data.vectorScore, + fullTextScore: data.fullTextScore, + providerMetadata: { + ...(this.options.preserveRawData ? data : {}), + q: data.q || data.query, + a: data.a || data.answer, + sourceId: data.sourceId || data.source_id, + documentId: data.documentId || data.document_id, + fileId: data.fileId || data.file_id, + position: data.position + }, + timestamp: data.timestamp || Date.now() + }; + } + + fromDitingChunk(chunk: ChunkResult): any { + const mapping = this.options.fieldMapping || {}; + const meta = (chunk.providerMetadata || {}) as Record; + + return { + [mapping.id || 'id']: chunk.id, + [mapping.content || 'content']: chunk.content, + [mapping.score || 'score']: chunk.score, + [mapping.datasetId || 'datasetId']: chunk.datasetId, + [mapping.sourceName || 'sourceName']: chunk.sourceName, + collectionId: chunk.collectionId, + metadata: chunk.metadata, + q: meta.q, + a: meta.a, + sourceId: meta.sourceId, + documentId: meta.documentId, + fileId: meta.fileId, + position: meta.position, + ...meta + }; + } +} diff --git a/packages/diting-rag-ts/src/agent/context.ts b/packages/diting-rag-ts/src/agent/context.ts new file mode 100644 index 000000000000..43f5c8fb6fb6 --- /dev/null +++ b/packages/diting-rag-ts/src/agent/context.ts @@ -0,0 +1,576 @@ +// src/agent/context.ts +// RequestContext - 完整 Blackboard 模式(对齐 Python context.py) +// +// Blackboard 模式:每个请求一个 ctx 实例,tools 通过闭包访问 +// 包含:Blackboard 数据 + Skills 闭包 + 流式事件 +// 混合机制:直接修改 ctx,通过 toStateUpdate() 同步到 LangGraph state + +import type { ChunkItem } from '../types/chunk'; +import type { LLMMessage } from '../types/message'; +import type { AgenticSearchConfig, AgenticSearchProviders } from '../ports/agentic'; +import type { Logger } from '../ports/logger'; +import type { SearchSkill, AnswerSkill, QueryRewriteSkill } from '../skills/expertise/index'; +import type { RerankSkill, ChunkSelectorSkill } from '../skills/atomic/index'; +import type { AgenticRAGStateType } from '../types/state'; +import { DEFAULT_SEARCH_CONFIG } from '../utils/constants'; +import { createSkills as createSkillBundle } from './graph'; +import type { LanguageTracker } from '../utils/lang_directive'; + +// ============================================================ +// 类型定义 +// ============================================================ + +export interface PlanStep { + step: number; + strategy: string; + queries: string[]; + parallel?: boolean; + depends_on?: number | null; + note?: string; + executed?: boolean; + failed?: boolean; +} + +export interface AnswerResult { + answer: string; + citedIds: string[]; + confidence: number; + reflectionLabel?: string; + reflectionReason?: string; +} + +/** + * Agent Event - 流式事件 + */ +export interface AgentEvent { + step: string; + detail?: string; + reason?: string; + playbook?: string; + timestamp: number; + extra?: Record; +} + +// ============================================================ +// RequestContext - 完整 Blackboard +// ============================================================ + +/** 单轮 dataset 信号 */ +interface DatasetRoundSignal { + round: number; + searchCount: number; + survivedCount: number; + totalScore: number; +} + +type DatasetStatus = 'active' | 'excluded' | 'probe_pending' | 'low_priority'; + +/** 跨轮聚合 signal */ +interface DatasetAggregateSignal { + status: DatasetStatus; + history: DatasetRoundSignal[]; + excludedAtRound?: number; +} + +export class RequestContext { + // ─── 配置 ───────────────────────────────────────────── + config: Required; + + // ─── 输入 ───────────────────────────────────────────── + question: string = ''; + datasetIds: string[] = []; + chatHistory: LLMMessage[] = []; + priorContext: string = ''; + + // ─── Blackboard: 检索结果 ───────────────────────────── + allChunks: ChunkItem[] = []; + searchQueries: string[] = []; + searchCount: number = 0; + // 最近一次搜索与已累积 chunks 的重叠率(0.0=全部新,1.0=全部重复) + lastSearchOverlap: number = 0; + // 最近一次搜索返回的 chunks(用于信息增益计算) + lastSearchChunks: ChunkItem[] = []; + + // 连续不相关搜索次数(不相关 +1,相关重置为 0;达阈值触发早停) + consecutiveIrrelevantSearches: number = 0; + + // ─── 渐进式信息收集 ─────────────────────────────────── + // 已确认收集到的有价值信息(累积追加,程序去重完全相同的条目) + findings: string[] = []; + // 当前仍缺少的信息(每次 @assess 覆盖,代表最新快照) + lacks: string[] = []; + // chunk 标注:chunk_id → key_info (None=整个 chunk 有用,string=部分有用) + keyChunkAnnotations: Map = new Map(); + + // ─── 改写子查询追踪 ───────────────────────────────── + rewriteQueries: string[] = []; + + // ─── 对比分析追踪(comparative_analysis playbook)───── + // 对比对象列表,如 ["vaf", "vad", "vac", "vm"] + compareObjects: string[] = []; + // 自动发现的对比维度,如 ["定义", "功能", "适用场景", "架构"] + discoveredDimensions: string[] = []; + // 每个维度的搜索覆盖状态: {维度名: {"searched": bool, "saturated": bool}} + dimensionCoverage: Map = new Map(); + // 对比阶段: "discovery" (探索对象/维度) -> "enrichment" (深度搜索) -> "done" + comparePhase: 'discovery' | 'enrichment' | 'done' = 'discovery'; + // 连续低信息增益次数(用于早停判断) + consecutiveLowGainCount: number = 0; + + // ─── 执行计划 ─────────────────────────────────────── + pendingPlan: PlanStep[] = []; + + // ─── Answer 结果暂存 ──────────────────────────────── + lastAnswer: AnswerResult | null = null; + answerGenerated: boolean = false; // @summary tool 成功完成后设为 true + + // ─── 流事件缓冲 ───────────────────────────────────── + pendingEvents: AgentEvent[] = []; + // 异步队列,用于实时流式推送 + streamQueue: AsyncIterableIterator | null = null; + + // ─── Skills(__post_init__ 初始化)────────────────── + searchSkill!: SearchSkill; + answerSkill!: AnswerSkill; + rewriteSkill!: QueryRewriteSkill; + rerankSkill!: RerankSkill; + chunkSelectorSkill!: ChunkSelectorSkill; + + // ─── LanguageTracker(sync_blackboard 初始化)──────── + languageTracker?: LanguageTracker; + + // ─── 语言异常压制早停(LanguageTracker 写入,simpleQuerySearchCapped 读取) + suppressEarlyStop: boolean = false; + + // ─── 多 dataset 跨轮信号追踪 ─────────────────────────── + // 默认禁用,通过 AGENTIC_ENABLE_DATASET_PRUNING=true 开启 + datasetSignalMap: Map = new Map(); + lastSearchQueries: string[] = []; + signalRound: number = 0; + datasetPruningEnabled: boolean = + (process.env.AGENTIC_ENABLE_DATASET_PRUNING ?? 'false').toLowerCase() === 'true'; + + private static readonly ACTIVE_THRESHOLD = 4.0; + private static readonly EXCLUDE_THRESHOLD = 3.0; + private static readonly JACCARD_CHANGE = 0.5; + + // ─── 首次 LLM 调用分析结果 ───────────────────────── + analysis: string = ''; + + // ─── Playbook 选择结果 ───────────────────────────── + playbook: string = ''; // 活跃的 playbook 名称 + + // ─── 用户原始问题(用于 answer final rerank)─────── + originalQuestion: string = ''; + + // ─── Chat history 结构化信息(用于追问)──────────── + previousTopics: string[] = []; + previousPlaybooks: string[] = []; + chatEntities: string[] = []; + + // ─── 可观测性 ─────────────────────────────────────── + executionPath: string[] = []; + toolCallCount: number = 0; + toolResultRound: number = 0; // tools 节点轮次计数,用于 tool result 消息 id + + // ─── Logger ─────────────────────────────────────── + logger?: Logger; + private _defaultLoggerCreated = false; + private _defaultLogger?: Logger; + + // ─── 构造 ─────────────────────────────────────────── + + /** + * 创建 RequestContext + * @param config 配置 + * @param datasetIds 数据集 ID 列表 + * @param providers LLM 和其他 provider(用于初始化 skills) + */ + constructor( + config: AgenticSearchConfig = {}, + datasetIds: string[] = [], + providers?: AgenticSearchProviders, + logger?: Logger + ) { + this.config = { + searchMode: config.searchMode ?? DEFAULT_SEARCH_CONFIG.SEARCH_MODE, + maxSearchRounds: config.maxSearchRounds ?? DEFAULT_SEARCH_CONFIG.MAX_SEARCH_ROUNDS, + maxToolCalls: config.maxToolCalls ?? DEFAULT_SEARCH_CONFIG.MAX_TOOL_CALLS, + maxSearchCalls: config.maxSearchCalls ?? 5, + maxIterations: config.maxIterations ?? 20, + tokenBudget: config.tokenBudget ?? 12000, + embeddingWeight: config.embeddingWeight ?? DEFAULT_SEARCH_CONFIG.EMBEDDING_WEIGHT, + similarity: config.similarity ?? DEFAULT_SEARCH_CONFIG.SIMILARITY_THRESHOLD, + rerankTopK: config.rerankTopK ?? DEFAULT_SEARCH_CONFIG.RERANK_TOP_K, + retrieveLimit: config.retrieveLimit ?? DEFAULT_SEARCH_CONFIG.RETRIEVE_LIMIT, + answerMaxChunks: config.answerMaxChunks ?? DEFAULT_SEARCH_CONFIG.ANSWER_MAX_CHUNKS, + answerMaxTokens: config.answerMaxTokens ?? DEFAULT_SEARCH_CONFIG.ANSWER_MAX_TOKENS, + enableShowReferences: config.enableShowReferences ?? true, + enableInfoGain: config.enableInfoGain ?? true, + infoGainThreshold: config.infoGainThreshold ?? 0.1, + infoGainMaxHistory: config.infoGainMaxHistory ?? 10, + searchOnly: config.searchOnly ?? false, + forbidCollectionIds: config.forbidCollectionIds ?? [] + }; + this.datasetIds = datasetIds; + this.logger = logger; + + // 如果提供了 providers,初始化 skills + if (providers) { + this.initializeSkills(providers); + } + } + + /** + * 初始化 Skill 实例(在 __post_init__ 中调用) + */ + initializeSkills(providers: AgenticSearchProviders): void { + const skills = createSkillBundle(providers); + this.searchSkill = skills.searchSkill; + this.answerSkill = skills.answerSkill; + this.rewriteSkill = skills.rewriteSkill; + this.rerankSkill = skills.rerankSkill; + this.chunkSelectorSkill = skills.chunkSelectorSkill; + + // 如果 providers 中有 logger,使用它 + if (providers.logger && !this.logger) { + this.logger = providers.logger; + } + } + + /** + * 获取 Logger(懒创建默认 logger) + */ + getLogger(): Logger { + if (this.logger) return this.logger; + if (this._defaultLoggerCreated && this._defaultLogger) return this._defaultLogger; + + // 懒创建默认 logger + const { createLogger } = require('../ports/logger'); + const defaultLogger = createLogger({ prefix: 'AgenticRAG' }); + this._defaultLogger = defaultLogger; + this._defaultLoggerCreated = true; + return defaultLogger; + } + + // ─── Blackboard 方法 ──────────────────────────────── + + /** + * 去重添加 chunks 到 Blackboard + */ + addChunks(newChunks: ChunkItem[]): void { + const existing = new Set(this.allChunks.map((c) => c.id)); + for (const chunk of newChunks) { + if (!existing.has(chunk.id)) { + this.allChunks.push(chunk); + existing.add(chunk.id); + } + } + } + + /** + * 智能添加 chunks:去重 + 分数更新 + */ + addChunksSmart(newChunks: ChunkItem[]): number { + let addedCount = 0; + const existing: Map = new Map(); + for (let i = 0; i < this.allChunks.length; i++) { + existing.set(this.allChunks[i].id, i); + } + + for (const chunk of newChunks) { + const cid = chunk.id; + if (existing.has(cid)) { + // 已存在:更新分数 + const idx = existing.get(cid)!; + const old = this.allChunks[idx]; + if ((chunk.score ?? 0) > (old.score ?? 0)) { + this.allChunks[idx] = { ...old, score: chunk.score }; + } + } else { + // 新增 + this.allChunks.push(chunk); + existing.set(cid, this.allChunks.length - 1); + addedCount++; + } + } + + return addedCount; + } + + /** + * 记录语义过程事件 + * @param step 事件类型(如 "searching", "thinking") + * @param detail 人类可读描述 + * @param internal 如果为 true,只记录到 pending_events(调试用),不推送到 streamQueue + * @param extra 额外事件数据 + */ + emit( + step: string, + detail?: string, + extra?: Record, + internal: boolean = false + ): void { + const event: AgentEvent = { + step, + detail, + timestamp: Date.now(), + extra + }; + this.pendingEvents.push(event); + + // 如果有流迭代器,推送事件 + if (!internal && this.streamQueue) { + // 注意:AsyncIterable 无法直接推送,这里用 pendingEvents 缓冲 + // 调用方通过 drainEvents 获取事件 + } + } + + /** + * 取出所有待处理事件并清空缓冲 + */ + drainEvents(): AgentEvent[] { + const events = [...this.pendingEvents]; + this.pendingEvents = []; + return events; + } + + // ─── 混合机制:ctx ↔ state 同步 ─────────────────────── + + /** + * 将 ctx 状态同步到 LangGraph state(对齐 Python sync_blackboard) + * 在 tools 节点执行后调用,将 ctx 的变更同步到 state + */ + toStateUpdate(): Partial { + const updates: Partial = { + allChunks: [...this.allChunks], + searchQueries: [...this.searchQueries], + searchCount: this.searchCount, + executionPath: ['sync_blackboard'] + }; + + // 如果有 answer,同步 answer 相关字段 + if (this.lastAnswer) { + const ans = this.lastAnswer; + updates.answer = ans.answer; + updates.citedIds = ans.citedIds; + updates.confidence = ans.confidence; + updates.reflectionLabel = ans.reflectionLabel || ''; + updates.reflectionReason = ans.reflectionReason || ''; + updates.refuse = ans.reflectionLabel === 'refuse'; + // 清空 last_answer(避免重复同步) + this.lastAnswer = null; + } + + return updates; + } + + /** + * 从 LangGraph state 恢复 ctx(初始化时调用) + * 对应 Python 中从 state 读取初始值到 ctx + */ + // ─── 信号辅助方法 ───────────────────────────────── + + private getAvgScore(dsId: string): number { + const sig = this.datasetSignalMap.get(dsId); + if (!sig || sig.history.length === 0) return 0; + const last = sig.history[sig.history.length - 1]; + return last.survivedCount > 0 ? last.totalScore / last.survivedCount : 0; + } + + private getDelta(dsId: string): number { + const sig = this.datasetSignalMap.get(dsId); + if (!sig || sig.history.length < 2) return 0; + const [prev, last] = [sig.history[0], sig.history[1]]; + const prevAvg = prev.survivedCount > 0 ? prev.totalScore / prev.survivedCount : 0; + const lastAvg = last.survivedCount > 0 ? last.totalScore / last.survivedCount : 0; + return lastAvg - prevAvg; + } + + /** 检测本轮 searchQueries 是否相比上轮有显著变化(Jaccard < 0.5) */ + private hasQueryChanged(currentQueries: string[]): boolean { + if (this.lastSearchQueries.length === 0) return false; + const prev = new Set(this.lastSearchQueries.map((q) => q.toLowerCase())); + const curr = new Set(currentQueries.map((q) => q.toLowerCase())); + const intersection = new Set([...prev].filter((q) => curr.has(q))); + const union = new Set([...prev, ...curr]); + const jaccard = union.size === 0 ? 1 : intersection.size / union.size; + return jaccard < RequestContext.JACCARD_CHANGE; + } + + /** 全局低信号检查:所有 dataset 中 best avgScore < EXCLUDE_THRESHOLD → 不触发排除 */ + private isGlobalLowSignal(): boolean { + let bestAvg = 0; + for (const dsId of this.datasetSignalMap.keys()) { + const avg = this.getAvgScore(dsId); + if (avg > bestAvg) bestAvg = avg; + } + return bestAvg < RequestContext.EXCLUDE_THRESHOLD; + } + + static fromState(state: Partial): RequestContext { + const ctx = new RequestContext(); + ctx.allChunks = state.allChunks || []; + ctx.searchQueries = state.searchQueries || []; + ctx.searchCount = state.searchCount || 0; + ctx.question = state.question || ''; + ctx.datasetIds = state.datasetIds || []; + ctx.chatHistory = state.chatHistory || []; + ctx.playbook = state.playbook || 'general'; + ctx.analysis = state.analysis || ''; + return ctx; + } + + // ─── 多 dataset 动态信号追踪 ─────────────────────────── + + /** + * 记录一轮搜索中每个 dataset 返回的 chunk 数。 + * 调用时机:@search 返回后、sub_query filter 前。 + */ + recordDatasetSearch(chunks: ChunkItem[]): void { + if (!this.datasetPruningEnabled) return; + const perDataset = new Map(); + for (const c of chunks) { + perDataset.set(c.datasetId, (perDataset.get(c.datasetId) || 0) + 1); + } + for (const [dsId, count] of perDataset) { + let sig = this.datasetSignalMap.get(dsId); + if (!sig) { + sig = { status: 'active', history: [] }; + this.datasetSignalMap.set(dsId, sig); + } + sig.history.push({ + round: this.signalRound, + searchCount: count, + survivedCount: 0, + totalScore: 0 + }); + if (sig.history.length > 2) sig.history = sig.history.slice(-2); + } + } + + /** + * 记录 sub_query filter 后每个 dataset 存活了多少 chunk。 + * 调用时机:sub_query filter 后。 + */ + recordDatasetSurvival(survivedChunks: ChunkItem[]): void { + if (!this.datasetPruningEnabled) return; + const perDataset = new Map(); + for (const c of survivedChunks) { + const cur = perDataset.get(c.datasetId) || { count: 0, totalScore: 0 }; + cur.count++; + const chunkScore = (c as any).llm_sub_query_score ?? (c.rerankScore ?? c.score ?? 0) * 10; + cur.totalScore += chunkScore; + perDataset.set(c.datasetId, cur); + } + for (const [dsId, { count, totalScore }] of perDataset) { + const sig = this.datasetSignalMap.get(dsId); + if (!sig || sig.history.length === 0) continue; + const last = sig.history[sig.history.length - 1]; + last.survivedCount = count; + last.totalScore = totalScore; + } + } + + /** 聚合本轮信号,执行决策矩阵(每轮 @search 后调用一次) */ + consolidateSignals(searchQueries: string[]): void { + if (!this.datasetPruningEnabled) return; + this.signalRound++; + + if (this.isGlobalLowSignal()) return; + + const allDsIds = Array.from(this.datasetSignalMap.keys()); + let excludeCount = 0; + const maxExclude = Math.floor(allDsIds.length * 0.3); + + for (const dsId of allDsIds) { + const sig = this.datasetSignalMap.get(dsId)!; + if (sig.status === 'excluded' || sig.status === 'probe_pending') continue; + + const avg = this.getAvgScore(dsId); + const delta = this.getDelta(dsId); + const queryChanged = this.hasQueryChanged(searchQueries); + + // A-真不相关:连续 2 轮低分 + sub_query 已变化 + if (avg < RequestContext.EXCLUDE_THRESHOLD && queryChanged && sig.history.length >= 2) { + const prevAvg = + sig.history[0].survivedCount > 0 + ? sig.history[0].totalScore / sig.history[0].survivedCount + : 0; + if (prevAvg < RequestContext.EXCLUDE_THRESHOLD) { + const activeCount = allDsIds.filter((id) => { + const s = this.datasetSignalMap.get(id); + return s && s.status === 'active'; + }).length; + if (activeCount > 2 && excludeCount < maxExclude) { + sig.status = 'excluded'; + sig.excludedAtRound = this.signalRound; + excludeCount++; + } + continue; + } + } + + // D-衰减:高分但趋势下降 → 低优先级 + if (avg >= RequestContext.ACTIVE_THRESHOLD && delta < -2) { + sig.status = 'low_priority'; + continue; + } + + // C-假阴性恢复:上轮低分,本轮恢复 + if (avg >= RequestContext.ACTIVE_THRESHOLD && sig.status === 'low_priority') { + sig.status = 'active'; + } + } + + // probe_pending → 一轮探测后决策 + for (const dsId of allDsIds) { + const sig = this.datasetSignalMap.get(dsId)!; + if (sig.status !== 'probe_pending') continue; + sig.status = + this.getAvgScore(dsId) >= RequestContext.ACTIVE_THRESHOLD ? 'active' : 'excluded'; + if (sig.status === 'excluded') sig.excludedAtRound = this.signalRound; + } + + this.lastSearchQueries = [...searchQueries]; + } + + /** + * 返回下一轮搜索应使用的 dataset ID 列表。 + */ + getActiveDatasetIds(allDatasetIds: string[]): string[] { + if (!this.datasetPruningEnabled) return allDatasetIds; + if (this.signalRound === 0 || this.isGlobalLowSignal()) return allDatasetIds; + + return allDatasetIds.filter((dsId) => { + const sig = this.datasetSignalMap.get(dsId); + if (!sig) return true; + return sig.status !== 'excluded'; + }); + } + + /** + * query_rewrite 后调用:标记 excluded → probe_pending(非 wipe)。 + * 给被排除 dataset 一次重新探测的机会。 + */ + probeDatasetSignals(): void { + if (!this.datasetPruningEnabled) return; + for (const [, sig] of this.datasetSignalMap) { + if (sig.status === 'excluded') sig.status = 'probe_pending'; + } + } +} + +// ============================================================ +// 工厂函数:创建带闭包的 Tools +// ============================================================ + +/** + * 创建 Tools 工厂(对齐 Python create_tools) + * 返回的 tools 通过闭包访问同一个 RequestContext 实例 + */ +export function createRequestContextTools(ctx: RequestContext) { + // 这里返回的 tools 可以在 nodes.ts 中使用 + // 通过闭包,每个 tool 都能访问 ctx 的所有状态 + return ctx; +} diff --git a/packages/diting-rag-ts/src/agent/graph.ts b/packages/diting-rag-ts/src/agent/graph.ts new file mode 100644 index 000000000000..c6311463616e --- /dev/null +++ b/packages/diting-rag-ts/src/agent/graph.ts @@ -0,0 +1,308 @@ +// src/agent/graph.ts +// Agentic RAG Graph - StateGraph 结构连线(三种模式) +// +// 职责:纯图结构,使用 state.ts/tools.ts/nodes.ts 中的定义, +// 只负责 addNode + addEdge + compile。 +// 调用方(runner.ts)负责把 CompiledStateGraph 包装为公共 API。 +// +// 混合机制:使用 RequestContext (Blackboard) + sync_blackboard 节点 +// - ctx 通过闭包传递给 tools,tools 直接修改 ctx +// - tools 执行后通过 sync_blackboard 节点同步 ctx → state + +import { StateGraph, END, START } from '@langchain/langgraph'; +import type { AgenticSearchConfig, AgenticSearchProviders } from '../ports/agentic'; +import { AgenticRAGState } from '../types/state'; +import { RetrieveSkill } from '../skills/atomic/retrieve'; +import { RerankSkill } from '../skills/atomic/rerank'; +import { ChunkSelectorSkill } from '../skills/atomic/chunk_selector'; +import { SearchSkill } from '../skills/expertise/search'; +import { AnswerSkill } from '../skills/expertise/answer'; +import { QueryRewriteSkill } from '../skills/expertise/query_rewrite/index'; +import { + createRoutePlaybookNode, + createNativeAgentNode, + createTextAgentNode, + createAutoAgentNode, + createToolsNode, + createSelectChunksNode, + createGenerateAnswerNode, + createPlanExecutorNode, + createSyncBlackboardNode, + createShouldContinue, + createAfterSyncRoute +} from './nodes'; +import { RequestContext } from './context'; +import { DEFAULT_SEARCH_CONFIG } from '../utils/constants'; + +// ============================================================ +// 公共类型(re-export state types for runner.ts 等使用) +// ============================================================ + +export type { AgenticRAGStateType, AgenticRAGUpdateType } from '../types/state'; + +// ============================================================ +// Graph Mode +// ============================================================ + +export type GraphMode = 'native' | 'text' | 'auto'; + +// ============================================================ +// Skill Bundle(图内部工厂,供节点共享) +// ============================================================ + +export interface SkillBundle { + searchSkill: SearchSkill; + answerSkill: AnswerSkill; + rewriteSkill: QueryRewriteSkill; + rerankSkill: RerankSkill; + chunkSelectorSkill: ChunkSelectorSkill; +} + +export function createSkills(providers: AgenticSearchProviders): SkillBundle { + const { llm, vectorSearch, fullTextSearch, embed, reranker, mixedSearch } = providers; + + const retrieveSkill = new RetrieveSkill(); + retrieveSkill.initialize(llm); + retrieveSkill.initializeProviders(vectorSearch, fullTextSearch, embed, mixedSearch); + + const rerankSkill = new RerankSkill(); + rerankSkill.initialize(llm); + if (reranker) { + rerankSkill.initializeProvider(reranker); + } + + const chunkSelectorSkill = new ChunkSelectorSkill(); + chunkSelectorSkill.initializeProvider(llm); + + const searchSkill = new SearchSkill(); + searchSkill.initialize(llm); + searchSkill.initializeSkills(retrieveSkill, rerankSkill); + + const answerSkill = new AnswerSkill(); + answerSkill.initialize(llm); + + const rewriteSkill = new QueryRewriteSkill(); + rewriteSkill.initialize(llm); + + return { searchSkill, answerSkill, rewriteSkill, rerankSkill, chunkSelectorSkill }; +} + +// ============================================================ +// Config 工具函数(供 runner.ts 使用) +// ============================================================ + +export function buildRequiredConfig(config?: AgenticSearchConfig): Required { + return { + searchMode: config?.searchMode ?? DEFAULT_SEARCH_CONFIG.SEARCH_MODE, + maxSearchRounds: config?.maxSearchRounds ?? DEFAULT_SEARCH_CONFIG.MAX_SEARCH_ROUNDS, + maxToolCalls: config?.maxToolCalls ?? DEFAULT_SEARCH_CONFIG.MAX_TOOL_CALLS, + maxSearchCalls: config?.maxSearchCalls ?? 5, + maxIterations: config?.maxIterations ?? 20, + tokenBudget: config?.tokenBudget ?? 12000, + embeddingWeight: config?.embeddingWeight ?? DEFAULT_SEARCH_CONFIG.EMBEDDING_WEIGHT, + similarity: config?.similarity ?? DEFAULT_SEARCH_CONFIG.SIMILARITY_THRESHOLD, + rerankTopK: config?.rerankTopK ?? DEFAULT_SEARCH_CONFIG.RERANK_TOP_K, + retrieveLimit: config?.retrieveLimit ?? DEFAULT_SEARCH_CONFIG.RETRIEVE_LIMIT, + answerMaxChunks: config?.answerMaxChunks ?? DEFAULT_SEARCH_CONFIG.ANSWER_MAX_CHUNKS, + answerMaxTokens: config?.answerMaxTokens ?? DEFAULT_SEARCH_CONFIG.ANSWER_MAX_TOKENS, + enableShowReferences: config?.enableShowReferences ?? true, + // 信息增益(simple_query 早停信号3) + enableInfoGain: config?.enableInfoGain ?? true, + infoGainThreshold: config?.infoGainThreshold ?? 0.1, + infoGainMaxHistory: config?.infoGainMaxHistory ?? 10, + searchOnly: config?.searchOnly ?? false, + forbidCollectionIds: config?.forbidCollectionIds ?? [] + }; +} + +/** + * 计算安全的 recursion_limit(对齐 Python recursion_limit) + * + * Native graph cycle: agent → tools → sync_blackboard = 3 nodes per tool call + * Plus: playbook_router → agent (2) + select_chunks + generate_answer (2) + buffer (5) + * Plus: plan_executor → sync_blackboard cycle (+2) + * + * Auto graph reuses the same formula: text-react fallback (agent_loop) is a + * single non-cycling node, so it never exceeds the native cycle count. + */ +export function calculateRecursionLimit(config: Required): number { + return 3 * config.maxToolCalls + 10; +} + +// ============================================================ +// Graph 构建函数(直接返回 CompiledStateGraph) +// 混合机制:ctx 通过闭包传递给 tools,sync_blackboard 节点同步 ctx → state +// ============================================================ + +/** + * 构建 native tool calling graph(对齐 Python _build_native_graph) + */ +export function buildNativeGraph( + providers: AgenticSearchProviders, + config: Required +) { + const skills = createSkills(providers); + skills.rewriteSkill.loadStrategies().catch(() => {}); + + // 创建 RequestContext (Blackboard),通过闭包传递给 tools + const ctx = new RequestContext(config, [], providers, providers.logger); + + // 获取 tool 名称列表(用于 progress guidance) + const toolNames = ['search', 'query_rewrite', 'summary']; + + return new StateGraph(AgenticRAGState) + .addNode('route_playbook', createRoutePlaybookNode(providers, config)) + .addNode('agent', createNativeAgentNode(providers, config)) + .addNode('tools', createToolsNode(providers, skills, config, ctx)) + .addNode('sync_blackboard', createSyncBlackboardNode(ctx, config, toolNames)) + .addNode('select_chunks', createSelectChunksNode(providers, skills, config)) + .addNode('generate_answer', createGenerateAnswerNode(providers, skills, config)) + .addNode('plan_executor', createPlanExecutorNode(providers, skills, config, ctx)) + .addEdge(START, 'route_playbook') + .addEdge('route_playbook', 'sync_blackboard') + .addConditionalEdges('agent', createShouldContinue(config, ctx), { + tools: 'tools', + select_chunks: 'select_chunks', + [END]: END + }) + .addEdge('tools', 'sync_blackboard') + .addConditionalEdges('sync_blackboard', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('plan_executor', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('select_chunks', (state) => (state.done ? END : 'generate_answer'), { + generate_answer: 'generate_answer', + [END]: END + }) + .addEdge('generate_answer', END) + .compile(); +} + +/** + * 构建 text ReAct graph(对齐 Python _build_text_react_graph) + */ +export function buildTextReActGraph( + providers: AgenticSearchProviders, + config: Required +) { + const skills = createSkills(providers); + skills.rewriteSkill.loadStrategies().catch(() => {}); + + // 创建 RequestContext (Blackboard) + const ctx = new RequestContext(config, [], providers, providers.logger); + + // 获取 tool 名称列表 + const toolNames = ['search', 'query_rewrite', 'summary']; + + return new StateGraph(AgenticRAGState) + .addNode('route_playbook', createRoutePlaybookNode(providers, config)) + .addNode('agent', createTextAgentNode(providers, config)) + .addNode('tools', createToolsNode(providers, skills, config, ctx)) + .addNode('sync_blackboard', createSyncBlackboardNode(ctx, config, toolNames)) + .addNode('select_chunks', createSelectChunksNode(providers, skills, config)) + .addNode('generate_answer', createGenerateAnswerNode(providers, skills, config)) + .addNode('plan_executor', createPlanExecutorNode(providers, skills, config, ctx)) + .addEdge(START, 'route_playbook') + .addEdge('route_playbook', 'sync_blackboard') + .addConditionalEdges('agent', createShouldContinue(config, ctx), { + tools: 'tools', + select_chunks: 'select_chunks', + [END]: END + }) + .addEdge('tools', 'sync_blackboard') + .addConditionalEdges('sync_blackboard', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('plan_executor', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('select_chunks', (state) => (state.done ? END : 'generate_answer'), { + generate_answer: 'generate_answer', + [END]: END + }) + .addEdge('generate_answer', END) + .compile(); +} + +/** + * 构建 auto graph(native 优先,降级 text) + */ +export function buildAutoGraph( + providers: AgenticSearchProviders, + config: Required +) { + const skills = createSkills(providers); + skills.rewriteSkill.loadStrategies().catch(() => {}); + + // 创建 RequestContext (Blackboard) + const ctx = new RequestContext(config, [], providers, providers.logger); + + // 获取 tool 名称列表 + const toolNames = ['search', 'query_rewrite', 'summary']; + + return new StateGraph(AgenticRAGState) + .addNode('route_playbook', createRoutePlaybookNode(providers, config)) + .addNode('agent', createAutoAgentNode(providers, config)) + .addNode('tools', createToolsNode(providers, skills, config, ctx)) + .addNode('sync_blackboard', createSyncBlackboardNode(ctx, config, toolNames)) + .addNode('select_chunks', createSelectChunksNode(providers, skills, config)) + .addNode('generate_answer', createGenerateAnswerNode(providers, skills, config)) + .addNode('plan_executor', createPlanExecutorNode(providers, skills, config, ctx)) + .addEdge(START, 'route_playbook') + .addEdge('route_playbook', 'sync_blackboard') + .addConditionalEdges('agent', createShouldContinue(config, ctx), { + tools: 'tools', + select_chunks: 'select_chunks', + [END]: END + }) + .addEdge('tools', 'sync_blackboard') + .addConditionalEdges('sync_blackboard', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('plan_executor', createAfterSyncRoute(ctx), { + plan_executor: 'plan_executor', + agent: 'agent', + select_chunks: 'select_chunks', + [END]: END + }) + .addConditionalEdges('select_chunks', (state) => (state.done ? END : 'generate_answer'), { + generate_answer: 'generate_answer', + [END]: END + }) + .addEdge('generate_answer', END) + .compile(); +} + +/** + * 构建 Agentic RAG Graph(完整版入口) + * - "native": LLM 原生 function calling + * - "text": Text ReAct,@tool_name() / XML 解析 + * - "auto": native 优先,降级 text(默认) + */ +export function buildGraph( + mode: GraphMode, + providers: AgenticSearchProviders, + config?: AgenticSearchConfig +) { + const resolvedConfig = buildRequiredConfig(config); + if (mode === 'text') return buildTextReActGraph(providers, resolvedConfig); + if (mode === 'auto') return buildAutoGraph(providers, resolvedConfig); + return buildNativeGraph(providers, resolvedConfig); +} diff --git a/packages/diting-rag-ts/src/agent/nodes.ts b/packages/diting-rag-ts/src/agent/nodes.ts new file mode 100644 index 000000000000..47f2da2e92c6 --- /dev/null +++ b/packages/diting-rag-ts/src/agent/nodes.ts @@ -0,0 +1,2307 @@ +// src/agent/nodes.ts +// 节点函数 - 所有 LangGraph 节点工厂函数 +// 职责:接受 (providers, skills, config) 返回节点函数,不包含图结构连线逻辑 +// 混合机制:直接修改 ctx,通过 toStateUpdate() 同步到 LangGraph state + +import { END } from '@langchain/langgraph'; +import type { AgenticSearchConfig, AgenticSearchProviders } from '../ports/agentic'; +import type { ChunkItem } from '../types/chunk'; +import type { LLMMessage, ToolCall, LLMResponse, LLMCallOptions } from '../types/message'; +import type { AgenticRAGStateType, AgenticRAGUpdateType } from '../types/state'; +import type { SkillBundle } from './graph'; +import type { RequestContext } from './context'; +import { TOOL_DEFINITIONS, parseAllToolCalls, getSystemPrompt } from './tools'; +import type { ToolDefinition } from '../types/message'; +import type { Logger } from '../ports/logger'; +import { routePlaybook } from './playbooks/router'; +import { extractChatHistoryInfo } from '../utils/compression'; +import { detectLang } from '../utils/lang'; +import { + buildLanguageConfig, + type LanguageTrackerConfig, + LanguageTracker +} from '../utils/lang_directive'; +import { + parseSearchQueries, + isSimilarQuery, + formatReferences, + getDimensionSynonyms +} from '../utils/text'; +import { TOOLS, IRRELEVANT_BGE_THRESHOLD, IRRELEVANT_LLM_THRESHOLD } from '../utils/constants'; +import { formatRewriteSummary } from '../skills/expertise/query_rewrite/index'; +import type { QueryRewriteResult } from '../skills/expertise/query_rewrite/index'; +import { formatAnswerSummary } from '../skills/expertise/answer'; +import type { AnswerSkill } from '../skills/expertise/answer'; +import { subQueryFilter, STAGE1_ENABLED_PLAYBOOKS } from '../skills/atomic/chunk_selector'; +import { InformationGainCalculator } from '../utils/info_gain'; +import type { PlanStep } from '../types/state'; + +/** 获取 logger 的辅助函数 */ +const getNodeLogger = (providers: AgenticSearchProviders) => providers.logger; + +/** @assess 工具支持的 playbook */ +const ASSESS_PLAYBOOKS = new Set(['deep_research', 'troubleshooting', 'comparative_analysis']); + +/** 按 playbook 过滤 TOOL_DEFINITIONS(移除不适用的 @assess) */ +function filterToolsByPlaybook(playbook: string): ToolDefinition[] { + if (ASSESS_PLAYBOOKS.has(playbook)) return TOOL_DEFINITIONS; + return TOOL_DEFINITIONS.filter((t) => t.function.name !== TOOLS.ASSESS); +} + +// ============================================================ +// Agent LLM 调用重试 + text 降级 + no-tool-call 引导 +// ============================================================ + +const AGENT_LLM_MAX_RETRIES = 1; // LLM 调用失败时重试次数 + +/** + * 带重试的 LLM 调用。 + * 返回 response 和 attempts,全部失败返回 null。 + */ +async function callLLMWithRetry( + llm: { chat: (messages: LLMMessage[], options?: LLMCallOptions) => Promise }, + messages: LLMMessage[], + options: LLMCallOptions, + logger?: Logger +): Promise<{ response: LLMResponse; attempts: number } | null> { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= AGENT_LLM_MAX_RETRIES; attempt++) { + try { + const response = await llm.chat(messages, options); + return { response, attempts: attempt + 1 }; + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + if (attempt < AGENT_LLM_MAX_RETRIES) { + logger?.warn( + `[agent] LLM call failed (attempt ${attempt + 1}/${AGENT_LLM_MAX_RETRIES + 1}), retrying...` + ); + await new Promise((r) => setTimeout(r, 100)); + } + } + } + + logger?.error( + `[agent] LLM call failed after ${AGENT_LLM_MAX_RETRIES + 1} attempts: ${lastError?.message}` + ); + return null; +} + +/** + * 尝试从 LLM 文本响应中解析 tool calls(text fallback)。 + * 当 native function calling 无返回时使用。 + */ +function tryTextFallbackToolCalls(content: string): ToolCall[] { + if (!content) return []; + const parsedCalls = parseAllToolCalls(content); + if (parsedCalls.length === 0) return []; + const ts = Date.now(); + return parsedCalls.map((c, i) => ({ + id: `text_fallback_${ts}_${i}_${Math.random().toString(36).substr(2, 9)}`, + type: 'function' as const, + function: { name: c.name, arguments: JSON.stringify(c.args) } + })); +} + +/** 无 tool call 时的引导消息 */ +const NO_TOOL_CALL_GUIDANCE = + 'You MUST call a tool to proceed. Use @search to look up information, ' + + 'or @query_rewrite to improve your query. Do NOT output free text or a direct answer.'; + +/** + * 内部引导重试:当 agent 无 tool call 且无 chunks 时,注入引导消息再调一次 LLM。 + * 返回 null 表示引导后仍无 tool call。 + */ +async function retryWithGuidance( + llm: { chat: (messages: LLMMessage[], options?: LLMCallOptions) => Promise }, + messages: LLMMessage[], + assistantContent: string, + playbook: string, + logger?: Logger +): Promise<{ + toolCalls: ToolCall[]; + content: string; + mode: string; + usage?: { inputTokens?: number; outputTokens?: number }; +} | null> { + const guidanceMsg: LLMMessage = { + role: 'user', + content: NO_TOOL_CALL_GUIDANCE, + id: 'tool-call-guidance' + }; + + try { + const guidedResponse = await llm.chat( + [...messages, { role: 'assistant', content: assistantContent }, guidanceMsg], + { tools: filterToolsByPlaybook(playbook), temperature: 0.1 } + ); + + let guidedToolCalls: ToolCall[] = guidedResponse.toolCalls ?? []; + let mode = 'guided_native'; + + if (guidedToolCalls.length === 0 && guidedResponse.content) { + const parsed = tryTextFallbackToolCalls(guidedResponse.content); + if (parsed.length > 0) { + guidedToolCalls = parsed; + mode = 'guided_text_fallback'; + } + } + + if (guidedToolCalls.length > 0) { + logger?.info( + `[agent] guidance retry succeeded: ${guidedToolCalls.map((tc) => tc.function.name).join(',')}` + ); + return { + toolCalls: guidedToolCalls, + content: guidedResponse.content, + mode, + usage: { + inputTokens: guidedResponse.usage?.inputTokens, + outputTokens: guidedResponse.usage?.outputTokens + } + }; + } + } catch { + logger?.warn('[agent] guidance retry LLM call failed'); + } + + return null; +} + +/** + * 构建 agent 节点在 retry 全部失败时的 fallback 返回值。 + * 有 chunks 时标记 done 进入 select_chunks 用已有结果生成回答; + * 无 chunks 时直接抛出异常,让上层(runner.ts)的 error 处理路径 + * 捕获并返回明确的错误信息,而非静默返回空召回。 + */ +function agentRetryExhaustedUpdate( + state: AgenticRAGStateType, + nodeName: string, + attempts: number, + lastErrorMsg: string +): AgenticRAGUpdateType { + if ((state.allChunks?.length ?? 0) > 0) { + return { + pendingToolCalls: [], + executionPath: [`${nodeName}(retry_exhausted_with_chunks)`], + nodeHist: [{ node: nodeName, toolCalls: [] }], + llmInputTokens: 0, + llmOutputTokens: 0 + }; + } + throw new Error( + `${nodeName}: LLM call failed after ${attempts} attempts, no chunks collected — ${lastErrorMsg}` + ); +} + +// ============================================================ +// Blackboard Brief(对齐 Python _build_progress_guidance) +// ============================================================ + +const SIMPLE_QUERY_HIGH_SCORE = 0.7; +const SIMPLE_QUERY_OVERLAP_THRESHOLD = 0.7; + +/** + * 判断本次搜索结果是否全部不相关 + * + * 信号优先级: + * 1. 优先用 llm_sub_query_score(语义评分,规避短问句/长chunk BGE稀释问题) + * 2. LLM score 不可用时(STAGE1 未启用的 playbook)回退到 BGE score + * + * @param chunks - 搜索返回的 chunks(Stage1 过滤后) + */ +export function isSearchIrrelevant(chunks: ChunkItem[]): boolean { + if (chunks.length === 0) return true; + + // 优先信任 LLM sub_query_score + const llmScores = chunks + .map((c) => c.llm_sub_query_score) + .filter((s): s is number => s !== undefined); + + if (llmScores.length > 0) { + const bestLLM = Math.max(...llmScores); + return bestLLM < IRRELEVANT_LLM_THRESHOLD; + } + + // 无 LLM score 时回退 BGE + const bestBGE = Math.max(...chunks.map((c) => c.rerankScore ?? c.score ?? 0)); + return bestBGE < IRRELEVANT_BGE_THRESHOLD; +} + +/** + * 构建进度引导消息,注入给 Agent 指示"继续搜索"还是"调用 @summary" + */ +function buildProgressGuidance( + state: AgenticRAGStateType, + maxSearchCalls: number, + toolNames: string[], + suppressEarlyStop = false, + searchOnly = false +): string { + // const lines: string[] = ['[Blackboard Brief]']; + const lines: string[] = [ + '[Blackboard Brief]', + '', + 'IRON LAW: You MUST call a tool. NEVER output free text or a direct answer. ONLY use the following tool formats:' + ]; + + // searchOnly 模式:完成信号用 "stop making tool calls" 替代 "@summary" + const completeSignal = searchOnly ? 'make no more tool calls (search complete)' : 'call @summary'; + + // 1. Goal: 初始分析 + if (state.analysis) { + lines.push(`Goal: ${state.analysis}`); + } + + // 2. Plan: 改写计划覆盖状态 + if (state.rewriteQueries && state.rewriteQueries.length > 0) { + const searchedSet = new Set(state.searchQueries); + lines.push('Plan (✓ searched, ○ pending):'); + for (const q of state.rewriteQueries) { + const mark = searchedSet.has(q) ? '✓' : '○'; + lines.push(` ${mark} ${q}`); + } + } + + // 3. Executed: 搜索进度 + 来源统计 + lines.push(`Searches: ${state.searchCount}/${maxSearchCalls}, chunks: ${state.allChunks.length}`); + + // 额外查询(不在改写计划内的) + if (state.searchQueries.length > 0) { + const rewriteSet = new Set(state.rewriteQueries); + const extra = state.searchQueries.filter((q) => !rewriteSet.has(q)); + if (extra.length > 0) { + lines.push(`Ad-hoc queries: ${extra.map((q) => `"${q}"`).join(', ')}`); + } + } + + // 来源分布 + if (state.allChunks.length > 0) { + const sources: Record = {}; + for (const c of state.allChunks) { + const name = c.sourceName || 'unknown'; + sources[name] = (sources[name] || 0) + 1; + } + const sourceList = Object.entries(sources) + .map(([name, cnt]) => `${name}(${cnt})`) + .join(', '); + lines.push(`Sources: ${sourceList}`); + } + + // Findings / Lacks + if (state.findings && state.findings.length > 0) { + lines.push('Findings:'); + for (const f of state.findings) lines.push(` • ${f}`); + } + if (state.lacks && state.lacks.length > 0) { + lines.push('Lacks:'); + for (const la of state.lacks) lines.push(` • ${la}`); + } else if (state.findings && state.findings.length > 0) { + lines.push('No remaining gaps.'); + } + + // 4. Gap check:引导 Agent 决策 + lines.push(''); + + // simple_query 高分命中时:强制指令立即回答 + if (!suppressEarlyStop && state.playbook === 'simple_query' && state.allChunks.length > 0) { + const bestScore = Math.max(...state.allChunks.map((c) => c.rerankScore ?? c.score ?? 0)); + if (bestScore >= SIMPLE_QUERY_HIGH_SCORE) { + lines.push( + `⚡ STOP SEARCHING. High-relevance results found (best score: ${bestScore.toFixed(3)} ≥ ${SIMPLE_QUERY_HIGH_SCORE}).` + ); + lines.push( + searchOnly + ? '→ Search complete. Make no more tool calls.' + : '→ You MUST call @summary NOW. Do NOT search again.' + ); + return lines.join('\n'); + } + if (state.lastSearchOverlap >= SIMPLE_QUERY_OVERLAP_THRESHOLD) { + lines.push( + `⚡ STOP SEARCHING. Search space saturated (${(state.lastSearchOverlap * 100).toFixed(0)}% overlap on last search).` + ); + lines.push( + searchOnly + ? '→ Search complete. Make no more tool calls.' + : '→ You MUST call @summary NOW with what you have.' + ); + return lines.join('\n'); + } + } + + // comparative_analysis:显示维度覆盖进度 + // 根据当前状态决定 tool 提示 + const toolHints: Record = {}; + if ( + state.playbook === 'comparative_analysis' && + state.discoveredDimensions && + state.discoveredDimensions.length > 0 + ) { + lines.push('Dimension Coverage:'); + for (const dim of state.discoveredDimensions) { + const coverage = state.dimensionCoverage?.[dim] ?? {}; + let mark: string; + if (coverage.saturated) mark = '✓✓'; + else if (coverage.searched) mark = '✓'; + else mark = '○'; + lines.push(` ${mark} ${dim}`); + } + + const unsatisfied = state.discoveredDimensions.filter( + (d) => !state.dimensionCoverage?.[d]?.searched + ); + const shallow = state.discoveredDimensions.filter( + (d) => state.dimensionCoverage?.[d]?.searched && !state.dimensionCoverage?.[d]?.saturated + ); + + if (unsatisfied.length > 0) { + // 还有维度没碰过:直接搜,不要绕弯子 + lines.push( + `→ Next: Search the ${unsatisfied.length} untouched dimension(s): ${unsatisfied.join(', ')}. ` + + `Use @search with targeted queries. Do NOT rewrite unless dimension is ambiguous.` + ); + if (unsatisfied.length === state.discoveredDimensions.length) { + // 全新开始,没有历史负担 + toolHints.search = 'search each dimension directly with specific keywords. START HERE.'; + toolHints.query_rewrite = 'ONLY if a dimension is unclear or too broad to search.'; + } else { + toolHints.search = `search the untouched dimension(s): [${unsatisfied.join(', ')}]`; + toolHints.query_rewrite = 'ONLY if an untouched dimension is too vague to search directly'; + } + } else if (shallow.length > 0) { + // 全搜过了,但有些维度信息不够深:必须改写角度,禁止原地再搜 + lines.push( + `→ All dimensions touched, but ${shallow.length} lack depth: ${shallow.join(', ')}. ` + + `Low info gain detected. You MUST use @query_rewrite first to find new angles. ` + + `Do NOT @search with similar keywords again.` + ); + toolHints.query_rewrite = `rewrite query to explore NEW angles for: [${shallow.join(', ')}]`; + toolHints.search = + 'ONLY after @query_rewrite produces new variants; NEVER search same angle again'; + } else { + // 所有维度都饱和了 + lines.push( + searchOnly + ? 'All dimensions saturated. Search complete. Make no more tool calls.' + : 'All dimensions saturated. Synthesize findings → @summary.' + ); + return lines.join('\n'); + } + } else { + // 非 comparative_analysis:保留通用逻辑,但加强约束 + lines.push( + 'Compare the Goal/Plan above against collected chunks. ' + + 'Identify which aspects are well-covered and which have gaps.' + ); + lines.push('- All aspects covered → ' + completeSignal + '.'); + lines.push('- Gaps remain → choose the best next action:'); + } + + // fallback:非 comparative_analysis 或异常状态 + if (!toolHints.search && !toolHints.query_rewrite) { + toolHints.search = + 'search with adjusted keywords or relaxed conditions. DEFAULT for straightforward queries.'; + toolHints.query_rewrite = + 'rewrite ONLY when: query is ambiguous, multi-faceted, or previous searches failed. Do NOT use for simple factual questions.'; + } + + for (const name of toolNames) { + if (name === 'summary') continue; + const hint = toolHints[name] || `use @${name}`; + lines.push(` • @${name} — ${hint}`); + } + + // ── 低相关性警告(注入 ⚠️ 信号,引导 agent 主动调 @summary)────────── + if (!suppressEarlyStop && state.allChunks.length > 0 && (state.searchCount ?? 0) > 0) { + const bestBGE = Math.max(...state.allChunks.map((c) => c.rerankScore ?? c.score ?? 0)); + const llmScores = state.allChunks + .map((c) => c.llm_sub_query_score) + .filter((s): s is number => s !== undefined); + const bestLLM = llmScores.length > 0 ? Math.max(...llmScores) : null; + + const isLowRelevance = + bestLLM !== null ? bestLLM < IRRELEVANT_LLM_THRESHOLD : bestBGE < IRRELEVANT_BGE_THRESHOLD; + + if (isLowRelevance) { + const scoreInfo = + bestLLM !== null + ? `LLM score: ${bestLLM.toFixed(0)}/10` + : `BGE score: ${bestBGE.toFixed(3)}`; + const actionText = searchOnly + ? 'make no more tool calls' + : 'call @summary immediately — do NOT search or rewrite again'; + lines.push(''); + lines.push( + `⚠️ RELEVANCE WARNING: All retrieved chunks have very low relevance (${scoreInfo}).` + + ` If this query appears UNRELATED to this knowledge base domain,` + + ` ${actionText}.` + ); + } + } + + return lines.join('\n'); +} + +/** 合并 chunks(与 state reducer 一致,用于构建 Blackboard Brief 时的临时合并) */ +function mergeChunks(existing: ChunkItem[], newItems: ChunkItem[]): ChunkItem[] { + const merged = [...existing]; + const existingIds = new Map(merged.map((c, i) => [c.id, i])); + for (const chunk of newItems) { + const idx = existingIds.get(chunk.id); + if (idx === undefined) { + existingIds.set(chunk.id, merged.length); + merged.push(chunk); + } else { + const cur = merged[idx]; + if ((chunk.rerankScore ?? chunk.score ?? 0) > (cur.rerankScore ?? cur.score ?? 0)) { + merged[idx] = chunk; + } + } + } + return merged; +} + +function formatSearchSummary(chunks: ChunkItem[], prevTotal: number): string { + if (chunks.length === 0) { + return 'Search complete. No relevant results found.'; + } + + const total = prevTotal + chunks.length; + const topScore = (chunks[0] as ChunkItem & { score?: number }).score ?? 1; + const botScore = (chunks[chunks.length - 1] as ChunkItem & { score?: number }).score ?? 1; + + const lines = [ + `Search complete. Retrieved ${total} chunks total, found ${chunks.length} new results.`, + `Highest relevance: ${topScore.toFixed(2)}, lowest: ${botScore.toFixed(2)}`, + 'Top results summary:' + ]; + + for (let i = 0; i < Math.min(5, chunks.length); i++) { + const c = chunks[i]; + const snippet = (c.content || '').slice(0, 80).replace(/\n/g, ' '); + lines.push(` ${i + 1}. [${c.id}] ${snippet}...`); + } + + return lines.join('\n'); +} + +// ============================================================ +// 共享函数:runAnswerStream(供 @summary tool 分支和 generate_answer 节点共用) +// ============================================================ + +async function runAnswerStream( + answerSkill: AnswerSkill, + params: { + query: string; + chunks: ChunkItem[]; + history: LLMMessage[]; + analysis?: string; + playbook?: string; + startTime?: number; + enableReflection?: boolean; // 默认 false(当前反思效果待优化) + mode?: string; + } +): Promise<{ + answer: string; + confidence: number; + citedIds: string[]; + reflectionLabel: string; + reflectionReason: string; + refuse: boolean; + ttftMs?: number; +} | null> { + const { + query, + chunks, + history, + analysis, + playbook, + startTime, + enableReflection = false, + mode = 'chat' + } = params; + + type StreamDataType = { + answer: string; + confidence: number; + citedIds: string[]; + reflectionLabel: string; + reflectionReason: string; + refuse: boolean; + ttftMs?: number; + }; + let streamData: StreamDataType | null = null; + + for await (const streamResult of answerSkill.executeStream({ + context: null as never, + query, + chunks, + history, + enableReflection, + mode, + analysis, + playbook_hint: playbook, + startTime: startTime ?? Date.now() + })) { + if (streamResult.success && streamResult.data) { + streamData = streamResult.data as unknown as StreamDataType; + } + } + + return streamData; +} + +// ============================================================ +// route_playbook 节点 +// ============================================================ + +/** + * route_playbook 节点:分析问题,选择 playbook,初始化 messages + */ +export function createRoutePlaybookNode( + providers: AgenticSearchProviders, + config: Required +) { + return async function routePlaybookNode( + state: AgenticRAGStateType + ): Promise { + const startTime = Date.now(); + try { + const routeResult = await routePlaybook( + providers.llm, + state.question, + state.chatHistory, + state.priorContext, + true + ); + + const logger = providers.logger; + logger?.info( + `[route_playbook] ${routeResult.playbook}:${routeResult.method} (${Date.now() - startTime}ms)` + ); + logger?.debug( + `[route_playbook] analysis="${routeResult.analysis}" reason="${routeResult.reason}"` + ); + + const historyInfo = extractChatHistoryInfo(state.chatHistory); + + // 构建语言配置(替代旧 [LANGUAGE DIRECTIVE]) + const userLang = detectLang(state.question); + const { directive: languageDirective, trackerConfig } = buildLanguageConfig( + state.initialLanguageStats, + userLang + ); + + // 调试:确认语言配置是否正确注入 + if (state.initialLanguageStats) { + logger?.info( + `[route_playbook] language stats: ${JSON.stringify(state.initialLanguageStats)}, userLang=${userLang}` + ); + } else { + logger?.warn( + '[route_playbook] NO initialLanguageStats — language directive will use fallback' + ); + } + logger?.debug(`[route_playbook] directive: ${languageDirective.replace(/\n/g, '\\n')}`); + + const baseSystemPrompt = getSystemPrompt(routeResult.playbook, config.searchOnly); + + // 若有 priorContext,注入 system prompt 作为 Search Hints(对齐 Python playbook_router_node) + const searchHints = state.priorContext + ? `\n\n## Search Hints\n\n${state.priorContext}\nUse these hints to guide your initial search queries. These hints may be in a different language than the default search language.` + : ''; + + const systemMsg: LLMMessage = { + role: 'system', + content: baseSystemPrompt + '\n\n[LANGUAGE DIRECTIVE]\n' + languageDirective + searchHints + }; + const userMsg: LLMMessage = { + role: 'user', + content: state.question + }; + + return { + playbook: routeResult.playbook, + analysis: routeResult.analysis, + messages: [systemMsg, userMsg], + languageTrackerConfig: trackerConfig, + executionPath: [`route_playbook(${routeResult.playbook}:${routeResult.method})`], + nodeHist: [{ node: 'route_playbook', toolCalls: [] }], + findings: + historyInfo.previousTopics.length > 0 + ? [`Prior topics: ${historyInfo.previousTopics.join(', ')}`] + : [] + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const userLang = detectLang(state.question); + const { directive: languageDirective, trackerConfig } = buildLanguageConfig( + null, // error path: no sampling data + userLang + ); + const searchHints = state.priorContext + ? `\n\n## Search Hints\n\n${state.priorContext}\nUse these hints to guide your initial search queries. These hints may be in a different language than the default search language.` + : ''; + return { + playbook: 'general', + messages: [ + { + role: 'system', + content: + getSystemPrompt('general', config.searchOnly) + + '\n\n[LANGUAGE DIRECTIVE]\n' + + languageDirective + + searchHints + }, + { role: 'user', content: state.question } + ], + languageTrackerConfig: trackerConfig, + error: `route_playbook error: ${errorMsg}`, + executionPath: ['route_playbook(error)'], + nodeHist: [{ node: 'route_playbook', toolCalls: [] }] + }; + } + }; +} + +// ============================================================ +// native agent 节点 +// ============================================================ + +/** + * native agent 节点:使用 LLM function calling,从 toolCalls 获取意图 + * 支持重试 + text 降级 + no-tool-call 引导 + */ +export function createNativeAgentNode( + providers: AgenticSearchProviders, + config: Required +) { + return async function nativeAgentNode(state: AgenticRAGStateType): Promise { + const logger = getNodeLogger(providers); + if ( + state.iterationCount >= config.maxIterations || + state.toolCallCount >= config.maxToolCalls + ) { + return { + pendingToolCalls: [], + executionPath: ['native_agent(limit_reached)'], + nodeHist: [{ node: 'native_agent', toolCalls: ['LIMIT'] }] + }; + } + + // ── LLM 调用 + 重试 ── + const retryResult = await callLLMWithRetry( + providers.llm, + state.messages, + { + tools: filterToolsByPlaybook(state.playbook), + temperature: 0.1 + }, + logger + ); + + if (!retryResult) { + return agentRetryExhaustedUpdate( + state, + 'native_agent', + AGENT_LLM_MAX_RETRIES + 1, + 'LLM call failed' + ); + } + + const { response } = retryResult; + const assistantMsg: LLMMessage = { role: 'assistant', content: response.content }; + let toolCalls: ToolCall[] = response.toolCalls ?? []; + let mode = 'native'; + + // ── Text fallback:native FC 无返回时尝试文本解析 ── + if (toolCalls.length === 0 && response.content) { + const parsedCalls = tryTextFallbackToolCalls(response.content); + if (parsedCalls.length > 0) { + toolCalls = parsedCalls; + mode = 'text_fallback'; + } + } + + // ── No-tool-call 引导重试 ── + if (toolCalls.length === 0 && (state.allChunks?.length ?? 0) === 0) { + const guidanceResult = await retryWithGuidance( + providers.llm, + state.messages, + response.content, + state.playbook, + logger + ); + if (guidanceResult) { + const toolNames = guidanceResult.toolCalls.map((tc) => tc.function.name); + return { + messages: [{ role: 'assistant', content: guidanceResult.content }], + pendingToolCalls: guidanceResult.toolCalls, + toolCallCount: state.toolCallCount + guidanceResult.toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: + (response.usage?.inputTokens ?? 0) + (guidanceResult.usage?.inputTokens ?? 0), + llmOutputTokens: + (response.usage?.outputTokens ?? 0) + (guidanceResult.usage?.outputTokens ?? 0), + executionPath: [`native_agent(${guidanceResult.mode},${toolNames.join(',')})`], + nodeHist: [{ node: 'native_agent', toolCalls: toolNames }] + }; + } + + // 引导后仍无 tool call,且已知无 chunks(guarded by L712 condition) + // 防御性检查:万一 state.allChunks 非空(不应发生),走正常 select_chunks 路径 + if ((state.allChunks?.length ?? 0) > 0) { + return { + messages: [assistantMsg], + pendingToolCalls: [], + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`native_agent(${mode},no_tools)`], + nodeHist: [{ node: 'native_agent', toolCalls: [] }] + }; + } + + return { + messages: [assistantMsg], + pendingToolCalls: [], + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: ['native_agent(no_tools,give_up)'], + nodeHist: [{ node: 'native_agent', toolCalls: [] }] + }; + } + + const toolNames = toolCalls.map((tc) => tc.function.name); + + if (toolCalls.length === 0) { + return { + messages: [assistantMsg], + pendingToolCalls: [], + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`native_agent(${mode},no_tools)`], + nodeHist: [{ node: 'native_agent', toolCalls: [] }] + }; + } + + return { + messages: [assistantMsg], + pendingToolCalls: toolCalls, + toolCallCount: state.toolCallCount + toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`native_agent(${mode},${toolNames.join(',')})`], + nodeHist: [{ node: 'native_agent', toolCalls: toolNames }] + }; + }; +} + +// ============================================================ +// text ReAct agent 节点 +// ============================================================ + +/** + * text ReAct agent 节点:解析 @tool_name() 或 XML 格式 + */ +/** + * text ReAct agent 节点:文本解析 + 重试 + no-tool-call 引导 + */ +export function createTextAgentNode( + providers: AgenticSearchProviders, + config: Required +) { + return async function textAgentNode(state: AgenticRAGStateType): Promise { + const logger = getNodeLogger(providers); + if ( + state.iterationCount >= config.maxIterations || + state.toolCallCount >= config.maxToolCalls + ) { + return { + pendingToolCalls: [], + executionPath: ['text_agent(limit_reached)'], + nodeHist: [{ node: 'text_agent', toolCalls: ['LIMIT'] }] + }; + } + + // ── LLM 调用 + 重试 ── + const retryResult = await callLLMWithRetry( + providers.llm, + state.messages, + { + temperature: 0.1 + }, + logger + ); + + if (!retryResult) { + return agentRetryExhaustedUpdate( + state, + 'text_agent', + AGENT_LLM_MAX_RETRIES + 1, + 'LLM call failed' + ); + } + + const { response } = retryResult; + const assistantMsg: LLMMessage = { role: 'assistant', content: response.content }; + const parsedCalls = parseAllToolCalls(response.content); + + const ts = Date.now(); + const toolCalls: ToolCall[] = parsedCalls.map((c, i) => ({ + id: `text_call_${ts}_${i}_${Math.random().toString(36).substr(2, 9)}`, + type: 'function' as const, + function: { name: c.name, arguments: JSON.stringify(c.args) } + })); + + // ── No-tool-call 引导重试 ── + if (toolCalls.length === 0 && (state.allChunks?.length ?? 0) === 0) { + const guidanceResult = await retryWithGuidance( + providers.llm, + state.messages, + response.content, + state.playbook, + logger + ); + if (guidanceResult) { + const toolNames = guidanceResult.toolCalls.map((tc) => tc.function.name); + return { + messages: [{ role: 'assistant', content: guidanceResult.content }], + pendingToolCalls: guidanceResult.toolCalls, + toolCallCount: state.toolCallCount + guidanceResult.toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: + (response.usage?.inputTokens ?? 0) + (guidanceResult.usage?.inputTokens ?? 0), + llmOutputTokens: + (response.usage?.outputTokens ?? 0) + (guidanceResult.usage?.outputTokens ?? 0), + executionPath: [`text_agent(${guidanceResult.mode},${toolNames.join(',')})`], + nodeHist: [{ node: 'text_agent', toolCalls: toolNames }] + }; + } + } + + const toolNames = toolCalls.map((tc) => tc.function.name); + + if (toolCalls.length === 0) { + return { + messages: [assistantMsg], + pendingToolCalls: [], + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: ['text_agent(no_tool_calls)'], + nodeHist: [{ node: 'text_agent', toolCalls: [] }] + }; + } + + return { + messages: [assistantMsg], + pendingToolCalls: toolCalls, + toolCallCount: state.toolCallCount + toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`text_agent(${toolNames.join(',')})`], + nodeHist: [{ node: 'text_agent', toolCalls: toolNames }] + }; + }; +} + +// ============================================================ +// auto agent 节点(native 优先,降级 text 解析) +// ============================================================ + +/** + * auto agent 节点:native 优先 + 重试 + text 降级 + no-tool-call 引导 + */ +export function createAutoAgentNode( + providers: AgenticSearchProviders, + config: Required +) { + return async function autoAgentNode(state: AgenticRAGStateType): Promise { + const logger = getNodeLogger(providers); + const startTime = Date.now(); + if ( + state.iterationCount >= config.maxIterations || + state.toolCallCount >= config.maxToolCalls + ) { + logger?.info(`[auto_agent] limit_reached (${Date.now() - startTime}ms)`); + return { + pendingToolCalls: [], + executionPath: ['auto_agent(limit_reached)'], + nodeHist: [{ node: 'auto_agent', toolCalls: ['LIMIT'] }] + }; + } + + // ── LLM 调用 + 重试 ── + const retryResult = await callLLMWithRetry( + providers.llm, + state.messages, + { + tools: filterToolsByPlaybook(state.playbook), + temperature: 0.1 + }, + logger + ); + + if (!retryResult) { + return agentRetryExhaustedUpdate( + state, + 'auto_agent', + AGENT_LLM_MAX_RETRIES + 1, + 'LLM call failed' + ); + } + + const { response } = retryResult; + const assistantMsg: LLMMessage = { role: 'assistant', content: response.content }; + let toolCalls: ToolCall[] = response.toolCalls ?? []; + + // native 无返回时降级 text 解析 + if (toolCalls.length === 0 && response.content) { + const ts = Date.now(); + const parsedCalls = parseAllToolCalls(response.content); + toolCalls = parsedCalls.map((c, i) => ({ + id: `auto_call_${ts}_${i}_${Math.random().toString(36).substr(2, 9)}`, + type: 'function' as const, + function: { name: c.name, arguments: JSON.stringify(c.args) } + })); + } + + const toolNames = toolCalls.map((tc) => tc.function.name); + const mode = response.toolCalls?.length ? 'native' : 'text_fallback'; + + logger?.info( + `[auto_agent] ${mode},${toolNames.join(',') || 'no_tools'} (${Date.now() - startTime}ms)` + ); + + if (toolCalls.length === 0) { + logger?.debug( + `[auto_agent] no_tools debug: contentLen=${response.content?.length ?? 0}, contentPreview="${response.content?.slice(0, 200)?.replace(/\n/g, ' ')}"` + ); + + // ── No-tool-call 引导重试 ── + if ((state.allChunks?.length ?? 0) === 0) { + const guidanceResult = await retryWithGuidance( + providers.llm, + state.messages, + response.content, + state.playbook, + logger + ); + if (guidanceResult) { + const names = guidanceResult.toolCalls.map((tc) => tc.function.name); + return { + messages: [{ role: 'assistant', content: guidanceResult.content }], + pendingToolCalls: guidanceResult.toolCalls, + toolCallCount: state.toolCallCount + guidanceResult.toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: + (response.usage?.inputTokens ?? 0) + (guidanceResult.usage?.inputTokens ?? 0), + llmOutputTokens: + (response.usage?.outputTokens ?? 0) + (guidanceResult.usage?.outputTokens ?? 0), + executionPath: [`auto_agent(${guidanceResult.mode},${names.join(',')})`], + nodeHist: [{ node: 'auto_agent', toolCalls: names }] + }; + } + } + } + + if (toolCalls.length === 0) { + return { + messages: [assistantMsg], + pendingToolCalls: [], + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`auto_agent(${mode},no_tools)`], + nodeHist: [{ node: 'auto_agent', toolCalls: [] }] + }; + } + + return { + messages: [assistantMsg], + pendingToolCalls: toolCalls, + toolCallCount: state.toolCallCount + toolCalls.length, + iterationCount: state.iterationCount + 1, + llmInputTokens: response.usage?.inputTokens ?? 0, + llmOutputTokens: response.usage?.outputTokens ?? 0, + executionPath: [`auto_agent(${mode},${toolNames.join(',')})`], + nodeHist: [{ node: 'auto_agent', toolCalls: toolNames }] + }; + }; +} + +// ============================================================ +// tools 节点 +// ============================================================ + +/** + * tools 节点:执行 pendingToolCalls(search / query_rewrite / summary + */ +export function createToolsNode( + providers: AgenticSearchProviders, + skills: SkillBundle, + config: Required, + ctx: RequestContext +) { + return async function toolsNode(state: AgenticRAGStateType): Promise { + const logger = getNodeLogger(providers); + const startTime = Date.now(); + const toolCalls = state.pendingToolCalls; + if (toolCalls.length === 0) { + return { pendingToolCalls: [], executionPath: ['tools(empty)'] }; + } + + const toolMessages: LLMMessage[] = []; + let newChunks: ChunkItem[] = []; + const newSearchQueries: string[] = []; + let answer = ''; + let confidence = 0; + let citedIds: string[] = []; + let reflectionLabel = ''; + let reflectionReason = ''; + let refuse = false; + let done = false; + let ttftMs: number | undefined; + let searchCountInc = 0; + let embeddingTokensInc = 0; + let rerankInputTokensInc = 0; + const toolNames: string[] = []; + let rewriteQueries: string[] | undefined; + let lastSearchOverlap = 0; + // searchOnly 模式:chunk selector 选出的最终 chunks + let selectedChunks: ChunkItem[] | undefined; + // 对比分析:对象列表(comparative_analysis playbook 专用) + let compareObjectsUpdate: string[] | undefined; + // 对比分析维度覆盖更新(comparative_analysis playbook 专用) + const dimensionCoverageUpdate: Record = {}; + // @assess 状态更新暂存 + let assessUpdate: { + findings: string[]; + lacks: string[]; + keyChunkAnnotations: Record; + prevLacksSet: string; + } | null = null; + // @query_rewrite 执行计划(plan_executor 节点使用) + let pendingPlan: PlanStep[] | undefined; + + ctx.toolResultRound++; + const TOOL_RESULT_KEEP_ROUNDS = 2; + + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + toolNames.push(toolName); + + let toolArgs: Record = {}; + try { + toolArgs = JSON.parse(toolCall.function.arguments); + } catch { + toolArgs = {}; + } + + let toolResult = ''; + + try { + switch (toolName) { + case TOOLS.SEARCH: { + const rawQueries = toolArgs.queries as string[] | undefined; + const rawQuery = toolArgs.query as string | undefined; + // 使用 parseSearchQueries 对齐 Python parse_search_queries + const queryInput = rawQueries?.length + ? JSON.stringify(rawQueries) + : rawQuery || state.question; + const parsedQueries = parseSearchQueries(queryInput); + + if (!parsedQueries || parsedQueries.length === 0) { + toolResult = 'Empty query. Please provide a search query.'; + break; + } + + logger?.info( + `[ToolsNode] @search requested: queries=${JSON.stringify(parsedQueries)}, existingSearchQueries=${JSON.stringify(state.searchQueries)}` + ); + + // Filter out already-searched queries (exact match) - 对齐 Python + const filteredByExact: string[] = []; + for (const q of parsedQueries) { + if (!(state.searchQueries as string[]).includes(q)) { + filteredByExact.push(q); + } else { + logger?.info(`[ToolsNode] Skipping exact-match query '${q}'`); + } + } + if (filteredByExact.length === 0) { + const already = parsedQueries.join(', '); + logger?.info(`[ToolsNode] ALL queries blocked by exact match, breaking`); + toolResult = `Already searched for ${already}. Please use different keywords.`; + break; + } + + // Filter out queries too similar to previous searches (fuzzy dedup) - 对齐 Python is_similar_query + const dedupedQueries: string[] = []; + for (const q of filteredByExact) { + const similar = isSimilarQuery(q, state.searchQueries as string[]); + if (similar) { + logger?.info( + `[ToolsNode] Skipping similar query '${q}' (too close to '${similar}')` + ); + } else { + dedupedQueries.push(q); + } + } + if (dedupedQueries.length === 0) { + logger?.info(`[ToolsNode] ALL queries blocked by fuzzy dedup, breaking`); + toolResult = `These queries are too similar to previous searches. Already searched: ${(state.searchQueries as string[]).join(', ')}. Try a substantially different angle or different keywords.`; + break; + } + + // 检查 search budget(对齐 Python max_search_calls) + const remaining = config.maxSearchCalls - (state.searchCount || 0); + if (remaining <= 0) { + toolResult = + 'Search limit reached. Now call @summary to generate the answer based on existing search results.'; + break; + } + const searchQueries = dedupedQueries.slice(0, remaining); + + newSearchQueries.push(...searchQueries); + + const result = await skills.searchSkill.execute({ + context: null as never, + queries: searchQueries, + datasetIds: ctx.getActiveDatasetIds(state.datasetIds), + tokenBudget: config.tokenBudget, + enableRerank: !!providers.reranker, + topK: config.rerankTopK, + retrieveLimit: config.retrieveLimit, + originalQuery: state.question, + forbidCollectionIds: config.forbidCollectionIds + }); + + if (result.success && result.data) { + const data = result.data as { + chunks: ChunkItem[]; + embeddingTokens?: number; + rerankInputTokens?: number; + }; + newChunks = data.chunks; + // ===== 多 dataset 信号追踪:记录原始检索结果(sub_query filter 前)===== + ctx.recordDatasetSearch(newChunks); + // ===== LanguageTracker: 记录原始搜索反馈(sub_query filter 前)===== + if (ctx.languageTracker) { + for (const q of searchQueries) { + ctx.languageTracker.recordSearch(q, data.chunks); + } + } + embeddingTokensInc += data.embeddingTokens ?? 0; + rerankInputTokensInc += data.rerankInputTokens ?? 0; + // 计算与已有 chunks 的重叠率(用于 simple_query 饱和检测) + if (newChunks.length > 0) { + const existingIds = new Set(state.allChunks.map((c) => c.id)); + const overlapCount = newChunks.filter((c) => existingIds.has(c.id)).length; + lastSearchOverlap = overlapCount / newChunks.length; + } + logger?.info( + `[ToolsNode] @search done: newChunks=${newChunks.length}, state.allChunks=${state.allChunks.length}, overlap=${(lastSearchOverlap * 100).toFixed(0)}%, contentSample="${newChunks[0]?.content?.slice(0, 60) ?? 'empty'}"` + ); + searchCountInc++; + toolResult = formatSearchSummary(data.chunks, state.allChunks.length); + + // ===== Stage 1 sub-query filter(对齐 Python sub_query_filter())===== + const selectorCfg = skills.chunkSelectorSkill.getConfig(); + if ( + selectorCfg.useLLMSubQuery && + STAGE1_ENABLED_PLAYBOOKS.has(state.playbook) && + newChunks.length > 0 && + providers.llm + ) { + for (const q of searchQueries) { + newChunks = await subQueryFilter( + newChunks, + state.question, + q, + state.analysis, + providers.llm, + selectorCfg.llmSubQueryKeepTopK, + state.playbook, + selectorCfg.maxDocLength + ); + } + logger?.info(`[ToolsNode] after subQueryFilter: newChunks=${newChunks.length}`); + } + + // ===== 记录 sub_query filter 后各 dataset 存活 chunk / 聚合信号 ===== + // 移到 subQueryFilter 条件块外:即使 LLM 评分关闭,BGE 分数仍可用于信号追踪 + ctx.recordDatasetSurvival(newChunks); + ctx.consolidateSignals(searchQueries); + + // ===== 对比查询:更新维度覆盖状态(对齐 Python tools.py 第 164-197 行)===== + if ( + state.playbook === 'comparative_analysis' && + state.discoveredDimensions && + state.discoveredDimensions.length > 0 + ) { + for (const q of searchQueries) { + const qLower = q.toLowerCase(); + for (const dim of state.discoveredDimensions) { + const coverage = state.dimensionCoverage?.[dim] ?? {}; + if (coverage.saturated) continue; + const dimLower = dim.toLowerCase(); + // 直接包含 + if (qLower.includes(dimLower)) { + dimensionCoverageUpdate[dim] = { searched: true }; + continue; + } + // 常见同义词扩展(对齐 Python _get_dimension_synonyms) + const synonyms = getDimensionSynonyms(dim); + for (const syn of synonyms) { + if (qLower.includes(syn.toLowerCase())) { + dimensionCoverageUpdate[dim] = { searched: true }; + break; + } + } + } + } + } + } else { + toolResult = JSON.stringify({ error: result.error || 'Search failed' }); + } + break; + } + + case TOOLS.QUERY_REWRITE: { + const rewriteQuery = (toolArgs.query as string) || state.question; + const result = await skills.rewriteSkill.execute({ + context: null as never, + query: rewriteQuery, + history: state.chatHistory, + priorContext: state.priorContext + }); + + if (result.success && result.data) { + const data = result.data as QueryRewriteResult; + // 注意:rewrite 生成的查询只存入 rewriteQueries(用于进度显示 ✓/○), + // 不能写入 newSearchQueries/state.searchQueries,否则会被精确匹配 block 导致无法实际搜索。 + // 实际搜索由 plan_executor 或 agent 后续调用 @search 完成。 + rewriteQueries = data.queries; + + // 改写后进入 probe 模式:给被排除 dataset 一次重新探测机会 + ctx.probeDatasetSignals(); + + // ===== 构建 PlanStep 执行计划(对齐 Python tools.py 第 483-499 行)===== + if (data.rewrites && data.rewrites.length > 0) { + pendingPlan = data.rewrites.map( + (item) => + ({ + step: item.step ?? 1, + strategy: item.strategy ?? '', + queries: item.queries ?? [], + parallel: item.parallel ?? true, + depends_on: item.depends_on ?? null, + note: item.note ?? '', + executed: false, + failed: false + }) as PlanStep + ); + logger?.info( + `[ToolsNode] @query_rewrite: created plan with ${pendingPlan.length} steps` + ); + } + + // ===== 对比查询元数据提取(对齐 Python tools.py 第 502-539 行)===== + // decompose 策略可能在 data 中携带 compare_objects, explicit_dimensions, implicit_dimensions + const rawData = data as unknown as Record; + if (state.playbook === 'comparative_analysis') { + // 提取对比对象(compare_objects) + const compareObjs = (rawData.compare_objects as string[]) ?? []; + if (compareObjs.length > 0) { + compareObjectsUpdate = compareObjs; + } + + // 提取显式维度和隐式维度 + const explicitDims = (rawData.explicit_dimensions as string[]) ?? []; + const implicitDims = (rawData.implicit_dimensions as string[]) ?? []; + const allNewDims = [...explicitDims, ...implicitDims]; + for (const dim of allNewDims) { + if (!(state.discoveredDimensions ?? []).includes(dim)) { + // 将在 return 时通过 discoveredDimensions append 更新 + dimensionCoverageUpdate[dim] = { searched: false, saturated: false }; + } + } + } + + // 使用 formatRewriteSummary 生成带 parallel 提示的可读摘要(对齐 Python) + toolResult = formatRewriteSummary(data); + } else { + toolResult = JSON.stringify({ error: result.error || 'Query rewrite failed' }); + } + break; + } + + case TOOLS.SUMMARY: { + const allChunksForAnswer = [...state.allChunks, ...newChunks]; + logger?.info( + `[ToolsNode] @summary: state.allChunks=${state.allChunks.length}, newChunks=${newChunks.length}, allChunksForAnswer=${allChunksForAnswer.length}` + ); + + // 提取 pinnedChunkIds(@assess 标注的 key chunks 优先保留) + const existingIdsForAnswer = new Set(allChunksForAnswer.map((c) => c.id)); + const pinnedChunkIds = Object.keys(state.keyChunkAnnotations ?? {}).filter((id) => + existingIdsForAnswer.has(id) + ); + + // skipStage2:1 轮搜索 + Stage1 已跑过时跳过 LLM rerank(对齐 select_chunks 节点) + const answerSkipStage2 = + state.searchCount === 1 && STAGE1_ENABLED_PLAYBOOKS.has(state.playbook); + + const selectResult = await skills.chunkSelectorSkill.execute({ + context: null as never, + query: state.question, + chunks: allChunksForAnswer, + tokenBudget: config.tokenBudget, + playbook: state.playbook, + pinnedChunkIds, + ...(answerSkipStage2 ? { enableLLMRerank: false } : {}) + }); + + let finalChunks = allChunksForAnswer; + if (selectResult.success && selectResult.data) { + const data = selectResult.data as { selectedChunks: ChunkItem[] }; + finalChunks = data.selectedChunks; + } + logger?.info( + `[ToolsNode] @summary: chunk selection done: selected ${finalChunks.length}/${allChunksForAnswer.length} chunks (skipStage2=${answerSkipStage2}, pinned=${pinnedChunkIds.length})` + ); + + // ── searchOnly 模式:跳过 LLM 生成,直接返回 selector 结果 ── + if (config.searchOnly) { + done = true; + selectedChunks = finalChunks; + toolResult = JSON.stringify({ + search_only: true, + selected_chunks: finalChunks.length + }); + logger?.info( + `[ToolsNode] @summary(search_only): selected ${finalChunks.length} chunks, skipping LLM generation` + ); + break; + } + + const streamData = await runAnswerStream(skills.answerSkill, { + query: state.question, + chunks: finalChunks, + history: state.chatHistory, + analysis: state.analysis, + playbook: state.playbook, + startTime: state.startTime + }); + + if (streamData) { + answer = streamData.answer; + confidence = streamData.confidence; + citedIds = streamData.citedIds; + reflectionLabel = streamData.reflectionLabel; + reflectionReason = streamData.reflectionReason; + refuse = streamData.refuse; + ttftMs = streamData.ttftMs; + + // 对齐 Python tools.py format_references 逻辑 + if (config.enableShowReferences && finalChunks.length > 0) { + const referencesStr = formatReferences(finalChunks); + if (referencesStr) { + (newChunks as unknown as { _references?: string })._references = referencesStr; + } + } + + done = true; + + if (ttftMs !== undefined) { + logger?.info(`[ToolsNode] @summary TTFT: ${ttftMs}ms`); + } + + toolResult = formatAnswerSummary({ + answer: streamData.answer, + citedIds: streamData.citedIds, + confidence: streamData.confidence, + reflectionLabel: streamData.reflectionLabel, + reflectionReason: streamData.reflectionReason, + refuse: streamData.refuse + }); + } else { + toolResult = JSON.stringify({ error: 'Answer generation failed' }); + } + break; + } + + case TOOLS.ASSESS: { + // @assess 工具 - 仅 deep_research / troubleshooting / comparative_analysis 有效 + const _ASSESS_PLAYBOOKS = new Set([ + 'deep_research', + 'troubleshooting', + 'comparative_analysis' + ]); + if (!_ASSESS_PLAYBOOKS.has(state.playbook)) { + toolResult = `@assess is not applicable for '${state.playbook}' queries. Call @summary directly now.`; + break; + } + + const assessFindings = (toolArgs.findings as string[]) || []; + const assessLacks = (toolArgs.lacks as string[]) || []; + const assessSufficient = toolArgs.sufficient as boolean; + const assessKeyChunks = + (toolArgs.key_chunks as Array<{ chunk_id: string; key_info?: string | null }>) || []; + + // findings 去重追加(写入 state 通过 update 合并) + const existingFindingsSet = new Set(state.findings || []); + const newFindings = assessFindings.filter((f) => !existingFindingsSet.has(f)); + + // key_chunk_annotations 更新 + const keyChunkUpdate: Record = {}; + for (const ann of assessKeyChunks) { + keyChunkUpdate[ann.chunk_id] = ann.key_info ?? null; + } + + // lacks 稳定性检测(连续两轮相同 lacks → 强制 @summary) + const currLacksStr = [...assessLacks].sort().join('||'); + const prevLacksStr = state.prevLacksSet || ''; + let forcedAnswer = false; + if (prevLacksStr && currLacksStr === prevLacksStr) { + logger?.warn('[ToolsNode] @assess: lacks unchanged for 2 rounds — forcing @summary'); + forcedAnswer = true; + toolResult = + 'Assessment: lacks unchanged from last round — the knowledge base does not have more relevant information. ' + + 'Call @summary now with the information you have collected.'; + } else if (assessSufficient) { + toolResult = + 'Assessment: sufficient. You have enough information. Call @summary now to generate the final response.'; + } else { + const lacksStr = + assessLacks.length > 0 + ? assessLacks.map((la) => `- ${la}`).join('\n') + : '(none specified)'; + toolResult = + `Assessment recorded. Lacks noted in blackboard:\n${lacksStr}\n` + + 'Continue with your plan. When you have covered the main aspects, call @summary — ' + + 'you do NOT need to fill every lack before answering.'; + } + + logger?.info( + `[ToolsNode] @assess: findings+${newFindings.length} (total=${(state.findings?.length || 0) + newFindings.length}), ` + + `lacks=${assessLacks.length}, sufficient=${assessSufficient}, key_chunks=${assessKeyChunks.length}, forced=${forcedAnswer}` + ); + + // 写回 state 增量 + if (newFindings.length > 0) { + (newChunks as unknown as { _assessFindings?: string[] })._assessFindings = + newFindings; + } + // NOTE: findings/lacks/keyChunkAnnotations/prevLacksSet 通过 update 写回 + // 暂存到 assessUpdate 供后续合并 + assessUpdate = { + findings: newFindings, + lacks: assessLacks, + keyChunkAnnotations: keyChunkUpdate, + prevLacksSet: currLacksStr + }; + break; + } + + default: + toolResult = JSON.stringify({ error: `Unknown tool: ${toolName}` }); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + toolResult = JSON.stringify({ error: errorMsg }); + } + + toolMessages.push({ + role: 'user', + content: `Tool @${toolName} returned:\n${toolResult}`, + id: `tool-result-${ctx.toolResultRound}-${toolName}` + }); + + if (done) break; + } + + // 压缩旧轮次的 tool result 消息(保留最近 TOOL_RESULT_KEEP_ROUNDS 轮完整结果) + if (ctx.toolResultRound > TOOL_RESULT_KEEP_ROUNDS) { + const compressRound = ctx.toolResultRound - TOOL_RESULT_KEEP_ROUNDS; + const oldIds = state.messages + .filter((m) => m.id?.startsWith(`tool-result-${compressRound}-`)) + .map((m) => m.id!); + for (const id of oldIds) { + toolMessages.push({ + role: 'user', + content: `[Compressed] Earlier tool result (round ${compressRound}).`, + id + }); + } + } + + // ── LanguageTracker: per-search 语言反馈 ──────────────── + let suppressEarlyStop = false; + logger?.debug( + `[ToolsNode] LanguageTracker check: ctx.languageTracker=${!!ctx.languageTracker} newChunks=${newChunks.length}` + ); + if (ctx.languageTracker && newChunks.length > 0) { + const langResult = ctx.languageTracker.buildGuidance(newChunks); + suppressEarlyStop = langResult.suppressEarlyStop; + ctx.suppressEarlyStop = suppressEarlyStop; + logger?.debug( + `[ToolsNode] LanguageTracker: guidance=${langResult.guidance ? 'yes' : 'null'} suppress=${suppressEarlyStop} shouldPush=${langResult.shouldPush}` + ); + if (langResult.shouldPush && langResult.guidance) { + toolMessages.push({ + role: 'user', + content: langResult.guidance, + id: 'language-guidance' + }); + } + } + + // 注入 Blackboard Brief(对齐 Python sync_blackboard 节点) + // 使用固定 id="progress-guidance",通过 messages reducer 实现原地替换(避免 token 膨胀) + if (!done) { + const availableTools = [TOOLS.SEARCH, TOOLS.QUERY_REWRITE]; + const mergedChunks = mergeChunks(state.allChunks, newChunks); + const mergedDimCoverage = { ...state.dimensionCoverage, ...dimensionCoverageUpdate }; + const stateForBrief = { + ...state, + allChunks: mergedChunks, + searchCount: state.searchCount + searchCountInc, + searchQueries: [...state.searchQueries, ...newSearchQueries], + rewriteQueries: rewriteQueries ?? state.rewriteQueries, + lastSearchOverlap, + dimensionCoverage: mergedDimCoverage + }; + const guidance = buildProgressGuidance( + stateForBrief, + config.maxSearchCalls, + availableTools, + suppressEarlyStop, + config.searchOnly + ); + toolMessages.push({ role: 'user', content: guidance, id: 'progress-guidance' }); + } + + // 更新 ctx(Blackboard 模式,对齐 Python tools 直接修改 ctx) + // 注意:重叠率须在追加 chunks 前计算,否则新 chunks 自重叠恒为 1.0 + if (searchCountInc > 0) { + ctx.searchCount += searchCountInc; + ctx.lastSearchChunks = newChunks; + if (newChunks.length > 0) { + const existingIds = new Set(ctx.allChunks.map((c) => c.id)); + const overlapCount = newChunks.filter((c) => existingIds.has(c.id)).length; + ctx.lastSearchOverlap = overlapCount / newChunks.length; + } + // 更新连续不相关搜索计数器 + const irrelevant = isSearchIrrelevant(newChunks); + ctx.consecutiveIrrelevantSearches = irrelevant ? ctx.consecutiveIrrelevantSearches + 1 : 0; + if (irrelevant) { + providers.logger?.info( + `[ToolsNode] irrelevant search detected (consecutive=${ctx.consecutiveIrrelevantSearches}), bestScore=${ + newChunks.length > 0 + ? Math.max(...newChunks.map((c) => c.rerankScore ?? c.score ?? 0)).toFixed(3) + : 'N/A' + }` + ); + } + } + if (newChunks.length > 0) { + // 追加 chunks 到 ctx.allChunks(去重) + for (const chunk of newChunks) { + if (!ctx.allChunks.some((c) => c.id === chunk.id)) { + ctx.allChunks.push(chunk); + } + } + } + // 追加搜索查询 + for (const q of newSearchQueries) { + if (!ctx.searchQueries.includes(q)) { + ctx.searchQueries.push(q); + } + } + // 保存 playbook 到 ctx + if (state.playbook) { + ctx.playbook = state.playbook; + } + + logger?.info(`[tools] ${toolNames.join(',')} duration: (${Date.now() - startTime}ms)`); + + return { + messages: toolMessages, + allChunks: newChunks, + searchQueries: newSearchQueries, + pendingToolCalls: [], + ...(rewriteQueries !== undefined ? { rewriteQueries } : {}), + ...(searchCountInc > 0 ? { lastSearchOverlap } : {}), + ...(Object.keys(dimensionCoverageUpdate).length > 0 + ? { dimensionCoverage: dimensionCoverageUpdate } + : {}), + ...(compareObjectsUpdate ? { compareObjects: compareObjectsUpdate } : {}), + ...(answer + ? { answer, confidence, citedIds, reflectionLabel, reflectionReason, refuse, ttftMs } + : {}), + // searchOnly 模式:记录 chunk selector 选出的最终结果 + ...(selectedChunks ? { selectedChunks } : {}), + // @assess 状态写回(findings 追加,lacks 替换,key annotations 合并) + ...(assessUpdate + ? { + findings: assessUpdate.findings, + lacks: assessUpdate.lacks, + keyChunkAnnotations: assessUpdate.keyChunkAnnotations, + prevLacksSet: assessUpdate.prevLacksSet + } + : {}), + // @query_rewrite 执行计划(供 plan_executor 节点消费) + ...(pendingPlan !== undefined ? { pendingPlan } : {}), + done, + searchCount: state.searchCount + searchCountInc, + ...(embeddingTokensInc > 0 ? { embeddingTokens: embeddingTokensInc } : {}), + ...(rerankInputTokensInc > 0 ? { rerankInputTokens: rerankInputTokensInc } : {}), + // 当 @summary tool 在 searchOnly 模式下显式运行了 chunk selection(selectedChunks 被设置, + // 即使为空数组),注入 select_chunks(from_answer) 到 executionPath,供 mapStateToResult + // 正确区分"selection 运行过返回空"与"selection 从未运行"。 + executionPath: [ + `tools(${toolNames.join(',')})`, + ...(selectedChunks !== undefined ? ['select_chunks(from_answer)'] : []) + ], + nodeHist: [{ node: 'tools', toolCalls: toolNames }] + }; + }; +} + +// ============================================================ +// 条件边 +// ============================================================ + +/** + * createShouldContinue:工厂函数,创建条件边函数 + * 对齐 Python create_should_continue,需要访问 ctx 以判断 last_answer、search_count 等 + */ +export function createShouldContinue(config: Required, ctx: RequestContext) { + return function shouldContinue(state: AgenticRAGStateType): string { + // DEBUG: log routing decision inputs + const logger = ctx.logger; + const pendingNames = state.pendingToolCalls?.map((tc) => tc.function.name) ?? []; + logger?.debug( + `[shouldContinue] pendingToolCalls=${JSON.stringify(pendingNames)}, searchCount=${state.searchCount}, toolCallCount=${state.toolCallCount}, iterationCount=${state.iterationCount}, done=${state.done}` + ); + + // 检查是否有工具调用 + // 无工具调用时路由到 select_chunks 而非 END,确保已收集的 chunks 能生成回答 + if (!state.pendingToolCalls || state.pendingToolCalls.length === 0) { + logger?.debug(`[shouldContinue] -> select_chunks (no pending tool calls)`); + return 'select_chunks'; + } + + // 检查 ctx.last_answer 是否已有答案(@summary 上一轮已完成) + if (ctx.lastAnswer && ctx.lastAnswer.answer) { + logger?.debug(`[shouldContinue] -> END (lastAnswer exists)`); + return END; + } + + // 检查 tool_call_count 是否达到上限 → select_chunks 确保已收集 chunks 生成回答 + const toolCallCount = state.toolCallCount ?? 0; + if (toolCallCount >= config.maxToolCalls) { + logger?.debug( + `[shouldContinue] -> select_chunks (toolCallCount ${toolCallCount} >= max ${config.maxToolCalls})` + ); + return 'select_chunks'; + } + + // 检查 search_count 是否达到上限(仅当全部是 search 工具调用时)→ select_chunks + const toolNames = state.pendingToolCalls.map((tc) => tc.function.name); + if (ctx.searchCount >= config.maxSearchCalls) { + if (toolNames.every((name) => name === TOOLS.SEARCH)) { + logger?.debug( + `[shouldContinue] -> select_chunks (searchCount ${ctx.searchCount} >= max ${config.maxSearchCalls})` + ); + return 'select_chunks'; + } + } + + // simple_query 早退机制(对齐 Python simple_query_search_capped) + // 高分命中/饱和时不再搜索,直接进入 chunk 筛选 + if (toolNames.every((name) => name === TOOLS.SEARCH)) { + logger?.debug( + `[shouldContinue] checking simpleQuerySearchCapped: suppressEarlyStop=${ctx.suppressEarlyStop} searchCount=${ctx.searchCount} overlap=${ctx.lastSearchOverlap}` + ); + const reason = simpleQuerySearchCappedWithCtx(ctx); + if (reason) { + logger?.debug(`[shouldContinue] -> select_chunks (simpleQuerySearchCapped: ${reason})`); + return 'select_chunks'; + } + } + + if (toolNames.every((name) => name === TOOLS.SUMMARY)) { + logger?.debug(`[shouldContinue] before calling summary -> select_chunks`); + return 'select_chunks'; + } + + logger?.debug(`[shouldContinue] -> tools (pending=${JSON.stringify(toolNames)})`); + return 'tools'; + }; +} + +/** + * simpleQuerySearchCappedWithCtx:从 ctx 读取状态判断是否早退 + * 对齐 Python simple_query_search_capped,包含三个信号: + * 1. 高分命中 (score >= 0.7) + * 2. 搜索空间饱和 (overlap >= 70%) + * 3. 信息增益低 (新 chunk 相比历史 chunks 信息增益 < threshold) + */ +function simpleQuerySearchCappedWithCtx(ctx: RequestContext): string | null { + if (ctx.playbook !== 'simple_query') return null; + if (!ctx.allChunks || ctx.searchCount < 1) return null; + + // LanguageTracker 语言异常抑制:改写后可换语种重搜,不要提前截断 + if (ctx.suppressEarlyStop) { + ctx.logger?.debug(`[simpleQuerySearchCapped] suppressed by LanguageTracker`); + return null; + } + + // 信号1: 高分命中 + const bestScore = Math.max(...ctx.allChunks.map((c) => c.rerankScore ?? c.score ?? 0)); + if (bestScore >= SIMPLE_QUERY_HIGH_SCORE) { + return `high-score chunk found (best=${bestScore.toFixed(3)} >= ${SIMPLE_QUERY_HIGH_SCORE}) after ${ctx.searchCount} searches`; + } + + // 信号2: 搜索空间饱和 + if (ctx.lastSearchOverlap >= SIMPLE_QUERY_OVERLAP_THRESHOLD) { + return `search space saturated (overlap=${(ctx.lastSearchOverlap * 100).toFixed(0)}% on last search)`; + } + + // 信号3: 信息增益低(需要 config 中的 enableInfoGain 和 lastSearchChunks) + const cfg = ctx.config; + if (cfg.enableInfoGain && ctx.lastSearchChunks && ctx.lastSearchChunks.length > 0) { + const gainCalc = new InformationGainCalculator(cfg.infoGainMaxHistory ?? 10); + // 用已有的前 N 个 chunk 作为历史 + const historySize = Math.min(ctx.allChunks.length, cfg.infoGainMaxHistory ?? 10); + for (let i = 0; i < historySize; i++) { + gainCalc.addSelected(ctx.allChunks[i]); + } + // 计算最近搜索返回的 chunks 的信息增益 + const infoGains = ctx.lastSearchChunks.map((c) => gainCalc.computeInfoGain(c)); + const maxGain = Math.max(...infoGains, 0); + const threshold = cfg.infoGainThreshold ?? 0.1; + if (maxGain < threshold) { + return `low info gain (max=${maxGain.toFixed(3)} < ${threshold}) on last search`; + } + } + + return null; +} + +// ============================================================ +// select_chunks 节点 +// ============================================================ + +/** + * select_chunks 节点:从 allChunks 筛选最终用于回答的 chunks,写入 state.selectedChunks + * - skipStage2: searchCount === 1 且 Stage 1 已跑过时,跳过 Stage 2 LLM rerank + * - pinnedChunkIds: 从 keyChunkAnnotations 提取,确保 @assess 标注的 key chunks 不被过滤 + */ +export function createSelectChunksNode( + providers: AgenticSearchProviders, + skills: SkillBundle, + config: Required +) { + return async function selectChunksNode( + state: AgenticRAGStateType + ): Promise { + const logger = getNodeLogger(providers); + + // 守卫:已有答案(@summary tool 已生成) + if (state.answer) { + return {}; + } + + // 守卫:无 chunks + if (state.allChunks.length === 0) { + logger?.warn('[select_chunks] No chunks collected, skipping'); + return {}; + } + + // skipStage2:1 轮搜索结束,Stage 1 已跑过所有 chunks,无需再跑 Stage 2 + const skipStage2 = state.searchCount === 1 && STAGE1_ENABLED_PLAYBOOKS.has(state.playbook); + + // 提取 pinnedChunkIds(对齐 Python tools.py) + const existingIds = new Set(state.allChunks.map((c) => c.id)); + const pinnedChunkIds = Object.keys(state.keyChunkAnnotations ?? {}).filter((id) => + existingIds.has(id) + ); + + const selectResult = await skills.chunkSelectorSkill.execute({ + context: null as never, + query: state.question, + chunks: state.allChunks, + tokenBudget: config.tokenBudget, + playbook: state.playbook, + pinnedChunkIds, + ...(skipStage2 ? { enableLLMRerank: false } : {}) + }); + + const selectedChunks = + selectResult.success && selectResult.data + ? (selectResult.data as { selectedChunks: ChunkItem[] }).selectedChunks + : state.allChunks; + + logger?.info( + `[select_chunks] selected ${selectedChunks.length}/${state.allChunks.length} chunks (skipStage2=${skipStage2}, pinned=${pinnedChunkIds.length})` + ); + + // 有检索结果但 chunk_selector 判定全部不相关 → 标记 refuse + const noRelevantChunks = selectedChunks.length === 0 && (state.allChunks?.length ?? 0) > 0; + + // DEBUG: log select_chunks decision + logger?.debug( + `[select_chunks] result: selected=${selectedChunks.length}, all=${state.allChunks.length}, noRelevantChunks=${noRelevantChunks}, searchOnly=${config.searchOnly}, skipStage2=${skipStage2}` + ); + + return { + selectedChunks, + ...(noRelevantChunks ? { refuse: true } : {}), + ...(config.searchOnly ? { done: true } : {}), + executionPath: [`select_chunks(${skipStage2 ? 'skip_stage2' : 'stage2'})`], + nodeHist: [{ node: 'select_chunks', toolCalls: [] }] + }; + }; +} + +// ============================================================ +// generate_answer 节点 +// ============================================================ + +/** + * generate_answer 节点:读取 selectedChunks(fallback allChunks),调 runAnswerStream 生成答案 + */ +export function createGenerateAnswerNode( + providers: AgenticSearchProviders, + skills: SkillBundle, + config: Required +) { + return async function generateAnswerNode( + state: AgenticRAGStateType + ): Promise { + const logger = getNodeLogger(providers); + + // 守卫:已有答案 + if (state.answer) { + return {}; + } + + // search_only 防御性守卫(条件边已在 select_chunks 后截断,此处仅兜底) + if (config.searchOnly) { + return { + done: true, + executionPath: ['generate_answer(search_only)'], + nodeHist: [{ node: 'generate_answer', toolCalls: [] }] + }; + } + + const chunks = (() => { + // 若 select_chunks 运行过,优先使用其结果(可能是 [] 代表全部不相关) + const selectChunksRan = (state.executionPath ?? []).some((p) => + p.startsWith('select_chunks') + ); + if (selectChunksRan) return state.selectedChunks ?? []; + return state.selectedChunks?.length ? state.selectedChunks : state.allChunks; + })(); + + // 守卫:无 chunks(由 irrelevant query 早停路径触发) + // 不调 LLM,直接返回标准 refuse 响应 + if (chunks.length === 0) { + logger?.warn( + '[generate_answer] No relevant chunks — query appears unrelated to knowledge base' + ); + return { + answer: + 'No relevant information found in the knowledge base for your question. Please try rephrasing with different keywords.', + confidence: 0, + refuse: true, + reflectionLabel: 'irrelevant', + reflectionReason: 'No relevant chunks after relevance filtering', + citedIds: [], + done: true, + executionPath: ['generate_answer(no_relevant_chunks)'], + nodeHist: [{ node: 'generate_answer', toolCalls: [] }] + }; + } + + logger?.info(`[generate_answer] generating answer from ${chunks.length} chunks`); + + const streamData = await runAnswerStream(skills.answerSkill, { + query: state.question, + chunks, + history: state.chatHistory, + analysis: state.analysis, + playbook: state.playbook, + startTime: state.startTime + }); + + if (!streamData) { + logger?.warn('[generate_answer] runAnswerStream returned null'); + return { + executionPath: ['generate_answer(failed)'], + nodeHist: [{ node: 'generate_answer', toolCalls: [] }] + }; + } + + if (streamData.ttftMs !== undefined) { + logger?.info(`[generate_answer] TTFT: ${streamData.ttftMs}ms`); + } + + return { + answer: streamData.answer, + confidence: streamData.confidence, + citedIds: streamData.citedIds, + reflectionLabel: streamData.reflectionLabel, + reflectionReason: streamData.reflectionReason, + refuse: streamData.refuse, + ttftMs: streamData.ttftMs, + done: true, + executionPath: ['generate_answer(generated)'], + nodeHist: [{ node: 'generate_answer', toolCalls: [] }] + }; + }; +} + +// ============================================================ +// plan_executor 节点(对齐 Python create_plan_executor) +// ============================================================ + +const PLAN_EARLY_RETURN_MIN_CHUNKS = 5; // 对齐 Python plan_early_return_min_chunks + +/** + * 基于 score + 信息增益的质量网关,判断是否提前结束计划执行 + */ +function shouldEarlyReturn( + allChunks: ChunkItem[], + newChunks: ChunkItem[], + answerMaxChunks: number, + infoGainThreshold: number +): boolean { + if (allChunks.length < PLAN_EARLY_RETURN_MIN_CHUNKS) return false; + if (!newChunks.length) return true; + + // Score 判断 + const existingScores = allChunks.map((c) => c.score ?? 0).sort((a, b) => b - a); + const topK = Math.min(answerMaxChunks, existingScores.length); + const avgTopScore = existingScores.slice(0, topK).reduce((s, v) => s + v, 0) / topK; + const hasHighScore = newChunks.some((c) => (c.score ?? 0) >= avgTopScore); + + // 信息增益判断 + const gainCalc = new InformationGainCalculator(10); + const histStart = Math.max(0, allChunks.length - 10); + for (const c of allChunks.slice(histStart)) gainCalc.addSelected(c); + const infoGains = newChunks.map((c) => gainCalc.computeInfoGain(c)); + const maxGain = Math.max(...infoGains, 0); + const hasHighInfoGain = maxGain >= infoGainThreshold; + + return !hasHighScore && !hasHighInfoGain; +} + +/** + * plan_executor 节点:自动执行 @query_rewrite 生成的查询计划,无需 LLM 参与每步决策 + */ +export function createPlanExecutorNode( + providers: AgenticSearchProviders, + skills: SkillBundle, + config: Required, + _ctx: RequestContext +) { + return async function planExecutorNode( + state: AgenticRAGStateType + ): Promise { + const logger = getNodeLogger(providers); + const plan = [...(state.pendingPlan ?? [])]; + if (plan.length === 0) return {}; + + logger?.info(`[plan_executor] Executing plan with ${plan.length} steps`); + + const stepsByOrder = [...plan].sort((a, b) => a.step - b.step); + const executedSteps = new Set(); + const failedSteps = new Set(); + + // 本次执行中累积的状态 + const accNewChunks: ChunkItem[] = []; + const accSearchQueries: string[] = []; + let searchCountInc = 0; + // 合并所有 chunks(已有 + 本次) + let mergedAllChunks = [...state.allChunks]; + + for (const step of stepsByOrder) { + const s = step as PlanStep & { executed?: boolean; failed?: boolean }; + if (s.executed) { + executedSteps.add(s.step); + if (s.failed) failedSteps.add(s.step); + continue; + } + + // 依赖步骤失败 → 跳过 + if (s.depends_on != null && failedSteps.has(s.depends_on)) { + logger?.warn( + `[plan_executor] Skipping step ${s.step}: dependency step ${s.depends_on} failed` + ); + s.executed = true; + s.failed = true; + failedSteps.add(s.step); + continue; + } + + // search budget 检查 + const remaining = config.maxSearchCalls - (state.searchCount + searchCountInc); + if (remaining <= 0) { + logger?.info('[plan_executor] Search budget exhausted'); + break; + } + + // exact match 去重 + const existingQSet = new Set([...state.searchQueries, ...accSearchQueries]); + const newQueries = (s.queries || []).filter((q) => !existingQSet.has(q)).slice(0, remaining); + if (newQueries.length === 0) { + s.executed = true; + executedSteps.add(s.step); + continue; + } + + try { + logger?.info(`[plan_executor] Step ${s.step}: ${newQueries.join(', ')}`); + + const searchResult = await skills.searchSkill.execute({ + context: null as never, + queries: newQueries, + datasetIds: _ctx.getActiveDatasetIds(state.datasetIds), + tokenBudget: config.tokenBudget, + enableRerank: !!providers.reranker, + topK: config.rerankTopK, + retrieveLimit: config.retrieveLimit, + originalQuery: state.question, + forbidCollectionIds: config.forbidCollectionIds + }); + + let newChunks: ChunkItem[] = []; + if (searchResult.success && searchResult.data) { + const data = searchResult.data as { chunks: ChunkItem[] }; + newChunks = data.chunks; + _ctx.recordDatasetSearch(newChunks); + } + + // Stage 1 sub-query filter(对齐 Python) + if (STAGE1_ENABLED_PLAYBOOKS.has(state.playbook) && newChunks.length > 0 && providers.llm) { + const selectorCfg = skills.chunkSelectorSkill.getConfig(); + if (selectorCfg.useLLMSubQuery) { + for (const q of newQueries) { + newChunks = await subQueryFilter( + newChunks, + state.question, + q, + state.analysis, + providers.llm, + selectorCfg.llmSubQueryKeepTopK, + state.playbook, + selectorCfg.maxDocLength + ); + } + } + // 信号追踪移出 useLLMSubQuery 条件块:BGE 分数仍可用于信号 + _ctx.recordDatasetSurvival(newChunks); + _ctx.consolidateSignals(newQueries); + } + + accNewChunks.push(...newChunks); + accSearchQueries.push(...newQueries); + searchCountInc += newQueries.length; + + // 更新 mergedAllChunks(smart merge) + const existingIds = new Set(mergedAllChunks.map((c) => c.id)); + for (const c of newChunks) { + if (!existingIds.has(c.id)) { + mergedAllChunks.push(c); + existingIds.add(c.id); + } else { + const idx = mergedAllChunks.findIndex((m) => m.id === c.id); + if (idx >= 0) { + const cur = mergedAllChunks[idx]; + if ((c.rerankScore ?? c.score ?? 0) > (cur.rerankScore ?? cur.score ?? 0)) { + mergedAllChunks[idx] = c; + } + } + } + } + + s.executed = true; + executedSteps.add(s.step); + + // 质量网关 + if (shouldEarlyReturn(mergedAllChunks, newChunks, 15, 0.1)) { + logger?.info(`[plan_executor] Quality gate triggered after step ${s.step}, early return`); + break; + } + } catch (e) { + logger?.error(`[plan_executor] Step ${s.step} failed:`, { + message: e instanceof Error ? e.message : String(e) + }); + s.executed = true; + s.failed = true; + failedSteps.add(s.step); + } + } + + // 清理已完成步骤 + const remainingPlan = plan.filter((s) => !(s as PlanStep & { executed?: boolean }).executed); + + logger?.info( + `[plan_executor] Done: ${executedSteps.size} executed, ${failedSteps.size} failed, ${remainingPlan.length} remaining` + ); + + return { + allChunks: accNewChunks, + searchQueries: accSearchQueries, + searchCount: state.searchCount + searchCountInc, + pendingPlan: remainingPlan, + executionPath: [`plan_executor(${executedSteps.size} steps)`], + nodeHist: [{ node: 'plan_executor', toolCalls: [] }] + }; + }; +} + +/** + * createAfterSyncRoute:工厂函数,创建 sync_blackboard 后的路由函数 + * 对齐 Python _create_after_sync_route,需要访问 ctx 以检查 pending_plan + */ +export function createAfterSyncRoute(ctx: RequestContext) { + return function afterSyncRoute(state: AgenticRAGStateType): string { + const logger = ctx.logger; + // DEBUG: log routing decision inputs + logger?.debug( + `[afterSyncRoute] done=${state.done}, pendingPlan=${ctx.pendingPlan?.length ?? 0}, searchCount=${state.searchCount}, allChunks=${state.allChunks?.length ?? 0}` + ); + + // 检查 state.done(可能在 agent 节点设置) + if (state.done) { + logger?.debug(`[afterSyncRoute] -> END (state.done=true)`); + return END; + } + // 检查 state.pendingPlan(由 query_rewrite 设置,plan_executor 消费后写回) + if (state.pendingPlan && state.pendingPlan.length > 0) { + logger?.debug(`[afterSyncRoute] -> plan_executor (pendingPlan=${state.pendingPlan.length})`); + return 'plan_executor'; + } + // 对齐 Python _after_sync_route:始终回到 agent,让 LLM 决策下一步 + // Python 不在路由层做早停,由 createShouldContinue(工具调用前)和 LLM 自身决定 + logger?.debug(`[afterSyncRoute] -> agent (default)`); + return 'agent'; + }; +} + +// ============================================================ +// sync_blackboard 节点(对齐 Python create_sync_blackboard) +// ============================================================ + +/** + * 创建 sync_blackboard 节点(对齐 Python create_sync_blackboard) + * + * 当提供 cfg 和 tool_names 时,节点会向 messages 注入 progress-summary + * + decision-guidance 消息,帮助 agent 评估是否继续搜索或调用 @summary + */ +export function createSyncBlackboardNode( + ctx: RequestContext, + config?: Required, + toolNames?: string[] +) { + return function syncBlackboardNode(_state: AgenticRAGStateType): AgenticRAGUpdateType { + // ── LanguageTracker 初始化(首次调用时)──────────────── + if (!ctx.languageTracker && _state.languageTrackerConfig) { + ctx.languageTracker = LanguageTracker.fromConfig( + _state.languageTrackerConfig as LanguageTrackerConfig, + ctx.logger + ); + } + + // 从 ctx 同步到 state + // 注意:searchQueries 不在此同步,由 tools 节点通过 append reducer 正确累加。 + // 若在此返回 searchQueries: [...ctx.searchQueries],会因 append reducer 导致重复追加。 + const updates: AgenticRAGUpdateType = { + allChunks: [...ctx.allChunks], + searchCount: ctx.searchCount, + executionPath: ['sync_blackboard'] + }; + + // 如果有 answer,同步 answer 相关字段 + if (ctx.lastAnswer) { + const ans = ctx.lastAnswer; + updates.answer = ans.answer; + updates.citedIds = ans.citedIds; + updates.confidence = ans.confidence; + updates.reflectionLabel = ans.reflectionLabel || ''; + updates.reflectionReason = ans.reflectionReason || ''; + updates.refuse = ans.reflectionLabel === 'refuse'; + updates.nodeHist = [ + { + node: 'summary', + toolCalls: [], + reflectionLabel: ans.reflectionLabel || '', + reflectionReason: ans.reflectionReason || '', + confidence: ans.confidence, + citedIds: ans.citedIds + } + ]; + // 清空 last_answer(避免重复同步) + ctx.lastAnswer = null; + } else { + updates.nodeHist = [ + { + node: 'tools', + toolCalls: [], + searchCount: ctx.searchCount, + chunksTotal: ctx.allChunks.length + } + ]; + + // 注入 progress guidance(native graph only) + if (config && toolNames && toolNames.length > 0) { + // 构建 ctx 的兼容视图(用于 buildProgressGuidance) + const ctxView = { + analysis: ctx.analysis, + playbook: ctx.playbook, + compareObjects: ctx.compareObjects, + discoveredDimensions: ctx.discoveredDimensions, + dimensionCoverage: ctx.dimensionCoverage, + comparePhase: ctx.comparePhase, + rewriteQueries: ctx.rewriteQueries, + searchQueries: ctx.searchQueries, + searchCount: ctx.searchCount, + allChunks: ctx.allChunks, + findings: ctx.findings, + lacks: ctx.lacks, + keyChunkAnnotations: ctx.keyChunkAnnotations + } as any; // 绕过 TypeScript 类型检查,直接传 ctx 视图 + // 使用本地 buildProgressGuidance(接受 state, maxSearchCalls, toolNames) + const guidance = buildProgressGuidance( + ctxView, + config.maxSearchCalls, + toolNames, + false, + config.searchOnly + ); + // 使用固定 ID 替换之前的 guidance,避免消息历史膨胀 + updates.messages = [ + { + role: 'user', + content: guidance, + id: 'progress-guidance' + } + ]; + } + } + + return updates; + }; +} diff --git a/packages/diting-rag-ts/src/agent/nodes/index.ts b/packages/diting-rag-ts/src/agent/nodes/index.ts new file mode 100644 index 000000000000..d9962952b3a1 --- /dev/null +++ b/packages/diting-rag-ts/src/agent/nodes/index.ts @@ -0,0 +1,167 @@ +// src/agent/nodes/index.ts +// Agent 节点函数 + +import type { RequestContext } from '../context'; +import type { ChunkItem } from '../../types/chunk'; + +/** + * Search 节点 + */ +export async function createSearchNode( + state: Record, + context: RequestContext +): Promise> { + const searchQueries = state.searchQueries as string[] | undefined; + const datasetIds = state.datasetIds as string[] | undefined; + const tokenBudget = state.tokenBudget as number | undefined; + const enableRerank = state.enableRerank as boolean | undefined; + + context.emit('search', `Searching with ${searchQueries?.length || 0} queries`); + + // 调用 search skill + const result = await context.searchSkill.execute({ + queries: searchQueries || [], + datasetIds: datasetIds || [], + tokenBudget: tokenBudget || 8000, + enableRerank: enableRerank ?? true, + retrieveLimit: context.config.retrieveLimit + }); + + if (!result.success) { + context.emit('search_error', result.error); + return {}; + } + + const data = result.data as { chunks: ChunkItem[]; queries: string[] }; + context.addChunksSmart(data.chunks); + context.searchCount++; + + return { + allChunks: context.allChunks, + searchQueries: data.queries, + searchCount: context.searchCount + }; +} + +/** + * Answer 节点 + */ +export async function createAnswerNode( + state: Record, + context: RequestContext +): Promise> { + const question = state.question as string | undefined; + const allChunks = state.allChunks as ChunkItem[] | undefined; + const chatHistory = state.chatHistory as Array<{ role: string; content: string }> | undefined; + + context.emit('answer', 'Generating answer'); + + // 调用 answer skill + const result = await context.answerSkill.execute({ + query: question || '', + chunks: allChunks || [], + history: (chatHistory || []) as any, + enableReflection: true, + mode: 'chat' + }); + + if (!result.success) { + context.emit('answer_error', result.error); + return {}; + } + + const data = result.data as { + answer: string; + citedIds: string[]; + confidence: number; + reflectionLabel: string; + reflectionReason: string; + }; + + return { + answer: data.answer, + citedIds: data.citedIds, + confidence: data.confidence, + reflectionLabel: data.reflectionLabel, + reflectionReason: data.reflectionReason + }; +} + +/** + * Chunk Selector 节点 + */ +export async function createChunkSelectorNode( + state: Record, + context: RequestContext +): Promise> { + const question = state.question as string | undefined; + const allChunks = state.allChunks as ChunkItem[] | undefined; + const tokenBudget = state.tokenBudget as number | undefined; + const playbook = state.playbook as string | undefined; + + context.emit('chunk_selector', `Selecting chunks with budget ${tokenBudget}`); + + // 调用 chunk selector skill + const result = await context.chunkSelectorSkill.execute({ + query: question || '', + chunks: allChunks || [], + tokenBudget: tokenBudget || 8000, + playbook: playbook || 'general' + }); + + if (!result.success) { + context.emit('chunk_selector_error', result.error); + return { allChunks: allChunks || [] }; + } + + const data = result.data as { + selectedChunks: ChunkItem[]; + totalTokens: number; + }; + + // 更新历史(用于信息增益计算) + for (const chunk of data.selectedChunks) { + context.chunkSelectorSkill.addToHistory(chunk); + } + + return { + allChunks: data.selectedChunks + }; +} + +/** + * Query Rewrite 节点 + */ +export async function createQueryRewriteNode( + state: Record, + context: RequestContext +): Promise> { + const question = state.question as string | undefined; + const chatHistory = state.chatHistory as Array<{ role: string; content: string }> | undefined; + + context.emit('query_rewrite', 'Rewriting query'); + + // 调用 rewrite skill + const result = await context.rewriteSkill.execute({ + query: question || '', + history: (chatHistory || []) as any, + priorContext: context.priorContext || undefined + }); + + if (!result.success) { + context.emit('query_rewrite_error', result.error); + return { searchQueries: [question || ''] }; + } + + const data = result.data as { + queries: string[]; + strategies_used: string[]; + }; + + context.rewriteQueries = data.queries; + context.emit('query_rewrite_result', `Generated ${data.queries.length} queries`); + + return { + searchQueries: data.queries + }; +} diff --git a/packages/diting-rag-ts/src/agent/playbooks/router.ts b/packages/diting-rag-ts/src/agent/playbooks/router.ts new file mode 100644 index 000000000000..e501b2eda05a --- /dev/null +++ b/packages/diting-rag-ts/src/agent/playbooks/router.ts @@ -0,0 +1,357 @@ +// src/agent/playbooks/router.ts +// Playbook Router - 完整版:6 种 playbook + LLM 分类 + 规则路由 + +import type { LLMProvider } from '../../ports/llm'; +import type { LLMMessage } from '../../types/message'; +import { PLAYBOOKS, getPlaybookContent, getAllPlaybookNames } from './templates'; +import { detectLang } from '../../utils/lang'; +import { getLogger } from '../../utils/logger'; +import { isFollowupQuery as checkFollowup } from '../../utils/constants'; +import { compressChatHistory } from '../../utils/compression'; +import { buildClassifierPrompt } from '../../utils/prompt_loader'; +import { parseJSON } from '../../utils/json_parser'; + +/** + * 规则路由结果 + */ +export interface RuleBasedRouteResult { + playbook: string; + matched: boolean; +} + +/** + * 路由规则定义 + */ +interface RouteRule { + playbook: string; + triggers: string[]; + anti_triggers: string[]; +} + +// ============================================================ +// 规则定义(完整版)- anti-triggers 防止误匹配 +// ============================================================ + +const RULES: RouteRule[] = [ + { + playbook: 'comparative_analysis', + triggers: [ + // 中文 + '对比', + '区别', + '哪个更好', + '比较', + '优缺点', + '不同', + 'vs', + 'v.s.', + // 英文 + 'compare', + 'comparison', + 'difference', + 'differ', + 'which is better', + 'pros and cons', + 'advantages', + 'disadvantages', + 'versus' + ], + anti_triggers: [ + // 排除排查类查询 + '排查', + '失败', + '报错', + '故障', + '解决', + '修复', + 'troubleshoot', + 'troubleshooting', + 'fail', + 'failed', + 'error', + 'issue' + ] + }, + { + playbook: 'troubleshooting', + triggers: [ + // 中文 + '失败', + '报错', + '不工作', + '无法连接', + '排查', + '故障', + '错误', + '问题', + '怎么解决', + '如何解决', + '为什么不行', + '为什么出错', + '连接不上', + // 英文 + 'fail', + 'failed', + 'error', + 'not working', + 'cannot connect', + 'troubleshoot', + 'troubleshooting', + 'issue', + 'problem', + 'broken', + 'fix' + ], + anti_triggers: [ + // 排除对比类查询 + '对比', + 'compare', + 'difference', + 'vs' + ] + }, + { + playbook: 'deep_research', + triggers: [ + // 中文 + '有哪些', + '全部', + '所有', + '列表', + '版本', + '特性', + '功能列表', + // 英文 + 'list', + 'all', + 'every', + 'versions', + 'features', + 'everything' + ], + anti_triggers: [] + }, + { + playbook: 'followup_query', + triggers: [ + // 中文 + '那', + '那么', + '还有', + '然后', + '接下来', + '呢', + '吗', + '另外', + // 英文 + 'then', + 'next', + 'also', + 'what about', + 'how about', + 'further' + ], + anti_triggers: [] + } +]; + +// ============================================================ +// 规则路由 +// ============================================================ + +/** + * 规则匹配路由 + */ +export function ruleBasedRoute(question: string, chatHistory?: LLMMessage[]): RuleBasedRouteResult { + const lowerQuestion = question.toLowerCase(); + + // 阶段 0: 追问检测(优先于其他规则) + if (chatHistory && chatHistory.length > 0) { + const isFollowup = isFollowupQuery(question, chatHistory); + if (isFollowup) { + return { playbook: 'followup_query', matched: true }; + } + } + + // 阶段 1: 规则匹配 + for (const rule of RULES) { + // 先检查 anti_triggers + const hasAntiTrigger = rule.anti_triggers.some((t) => lowerQuestion.includes(t.toLowerCase())); + if (hasAntiTrigger) continue; + + // 检查 triggers + const hasTrigger = rule.triggers.some((t) => lowerQuestion.includes(t.toLowerCase())); + if (hasTrigger) { + return { playbook: rule.playbook, matched: true }; + } + } + + // 阶段 2: 短查询默认 simple_query + if (question.length < 30) { + return { playbook: 'simple_query', matched: false }; + } + + // 默认 general + return { playbook: 'general', matched: false }; +} + +/** + * 判断是否为追问(使用 constants.ts 中完整的 FOLLOWUP_PATTERNS) + */ +function isFollowupQuery(question: string, history: LLMMessage[]): boolean { + // 使用 constants.ts 中的 isFollowupQuery + if (checkFollowup(question)) { + return true; + } + + // 额外检查:指代词 + 有历史 + const pronouns = ['它', '这个', '那个', 'this', 'that', 'it', 'they']; + const lowerQuestion = question.toLowerCase(); + if (pronouns.some((p) => lowerQuestion.includes(p)) && history.length > 0) { + // 检查词汇重叠 + const overlap = extractOverlapWords(question, history.map((m) => m.content).join(' ')); + return overlap > 0.3; + } + + return false; +} + +/** + * 提取词汇重叠度 + */ +function extractOverlapWords(question: string, history: string): number { + const qWords = new Set( + question + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 1) + ); + const hWords = new Set( + history + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 1) + ); + + if (qWords.size === 0) return 0; + + let overlap = 0; + for (const word of qWords) { + if (hWords.has(word)) overlap++; + } + + return overlap / qWords.size; +} + +// ============================================================ +// LLM 分类(完整版 few-shot) +// ============================================================ + +/** + * LLM 分类路由 + */ +export async function llmClassify( + llm: LLMProvider, + question: string, + chatHistory?: LLMMessage[], + priorContext?: string +): Promise<{ playbook: string; analysis: string; reason: string }> { + // 构建 playbook 描述 + const playbookNames = getAllPlaybookNames(); + const playbookDescriptions = playbookNames + .map((name) => { + const p = PLAYBOOKS[name]; + return `- ${p.name}: ${p.description}`; + }) + .join('\n'); + + // 生成结构化 chat history 摘要(对齐 Python compress_chat_history) + const summary = chatHistory && chatHistory.length > 0 ? compressChatHistory(chatHistory) : null; + + // 检测语言 + const lang = detectLang(question); + + // 构建分类器 prompt + const prompt = buildClassifierPrompt({ + playbookDescriptions, + priorContext: priorContext || '', + summary, + question, + lang + }); + + // 调用 LLM + const response = await llm.chat( + [ + { role: 'system', content: 'You are a query classifier.' }, + { role: 'user', content: prompt } + ], + { + temperature: 0.3, + maxTokens: 1024 + } + ); + + // 解析响应 + const parsed = parseJSON<{ + playbook?: string; + analysis?: string; + reason?: string; + }>(response.content); + + if (!parsed || !parsed.playbook) { + throw new Error(`LLM response missing playbook field. LLM response: ${response.content}`); + } + + // 验证 playbook 有效性 + const validPlaybooks = getAllPlaybookNames(); + if (!validPlaybooks.includes(parsed.playbook)) { + throw new Error(`Invalid playbook returned by LLM: ${parsed.playbook}`); + } + + return { + playbook: parsed.playbook, + analysis: parsed.analysis || '', + reason: parsed.reason || '' + }; +} + +// ============================================================ +// 主路由函数 +// ============================================================ + +/** + * 主路由函数:LLM 分类优先 → 规则兜底 + */ +export async function routePlaybook( + llm: LLMProvider, + question: string, + chatHistory?: LLMMessage[], + priorContext?: string, + useLLM: boolean = true +): Promise<{ playbook: string; analysis: string; reason: string; method: 'llm' | 'rule' }> { + // 优先尝试 LLM 分类 + if (useLLM) { + try { + const result = await llmClassify(llm, question, chatHistory, priorContext); + return { ...result, method: 'llm' }; + } catch (error) { + // LLM classification failed, will fallback to rules + getLogger()?.error(`LLM classification failed, will fallback to rules. ${error}`); + } + } + + // 规则兜底 + const ruleResult = ruleBasedRoute(question, chatHistory); + return { + playbook: ruleResult.playbook, + analysis: '', + reason: ruleResult.matched ? 'Matched by rules' : 'Default rule fallback', + method: 'rule' + }; +} + +/** + * 获取 playbook 内容 + */ +export { getPlaybookContent }; diff --git a/packages/diting-rag-ts/src/agent/playbooks/templates.ts b/packages/diting-rag-ts/src/agent/playbooks/templates.ts new file mode 100644 index 000000000000..13d60f82ba91 --- /dev/null +++ b/packages/diting-rag-ts/src/agent/playbooks/templates.ts @@ -0,0 +1,338 @@ +// src/agent/playbooks/templates.ts +// Playbook 模板定义 - 完整版(6 种 playbook) + +/** + * Playbook 定义 + */ +export interface PlaybookDef { + name: string; + description: string; + detail: string; // answer 模式 + detailSearchOnly: string; // searchOnly 模式 +} + +// ============================================================ +// 6 种 Playbook 详细定义 +// ============================================================ + +/** + * Simple Query - 直接事实查询 + */ +export const SIMPLE_QUERY_DETAIL = `## Scenario Guidance: Direct Query + +This question is focused and clear — a direct search should retrieve the answer. + +**MUST follow these steps:** +1. STOP. You MUST use @search to retrieve information before answering. +2. Search the original query directly. +3. **If your search returned a directly relevant result (you can see the answer clearly) → call @summary IMMEDIATELY. Do NOT call @assess.** +4. If the first search returned poor or no results: use @query_rewrite ONCE to rephrase, then search again. +5. Do NOT answer from memory or make up information. + +**Output Rule:** +- You MUST use @summary tool to generate final response (do NOT write answer yourself). +- Keep the answer focused — do not over-elaborate for simple facts. +- Do NOT use @assess — it is not needed for direct queries.`; + +/** + * Comparative Analysis - 对比分析查询 + */ +export const COMPARATIVE_ANALYSIS_DETAIL = `## Scenario Guidance: Comparison Query (Comparative Analysis) + +This question involves comparing multiple objects (e.g., A vs B vs C). Each object needs \ +separate information before a synthesis is possible. + +### Two-Phase Execution + +**Phase 1: Discovery (探索阶段)** +- Objective: Confirm all comparison objects exist in knowledge base, discover comparison dimensions +- First, use @query_rewrite(strategies="decompose") to identify comparison objects +- Then search each object with a basic query (e.g., "What is product A", "What is product B") +- From the search results, automatically extract comparison dimensions: + - If user explicitly mentions dimensions (e.g., "feature differences", "architecture differences") → use those + - Otherwise, infer dimensions from results: definition, features, architecture, use cases, pricing, performance, limitations, etc. + +**Phase 2: Enrichment (深度阶段)** +- After dimensions are discovered, decompose by dimension: "product A features", "product B features", "product A architecture", "product B architecture" +- Search each dimension separately. Use parallel search when possible. +- Continue until all dimensions have been searched at least once + +**Optional — @assess for chunk annotation and progress tracking:** +After each search round, you MAY call @assess to: +- Record key findings from the current round +- Update the list of missing information (lacks) +- Annotate the most useful chunks (key_chunks) for preservation in the final answer +- Mark when information is sufficient (sufficient: true) +This is especially useful for tracking multi-dimensional comparison progress. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Structure the answer with a comparison table or parallel bullet points. +- Present both similarities and differences clearly.`; + +/** + * Troubleshooting - 故障排查查询 + */ +export const TROUBLESHOOTING_DETAIL = `## Scenario Guidance: Depth-First Query (Troubleshooting) + +This is a diagnostic scenario. Each search step may depend on results from the previous one. + +**MUST follow these steps:** +1. STOP. Do NOT answer directly — you need troubleshooting information. +2. Use @search to find the error message or symptom keywords. +3. Evaluate results: + - Found troubleshooting docs → use @summary with structured diagnosis. + - Insufficient → use @query_rewrite (try par or gqr strategy) and search again. + - Multiple possible causes → search each cause's solution separately. +4. Answer structure: Symptom → Possible causes → Diagnostic steps → Solutions. +5. If information is insufficient, list known clues and suggest user provide more details. + +**Optional — @assess for chunk annotation:** +You MAY call @assess to annotate chunks containing key diagnostic steps or solutions (key_chunks). +Call @summary when you have the main diagnostic information — you do not need exhaustive coverage. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Structure: Symptom → Possible causes → Diagnostic steps → Solutions`; + +/** + * Deep Research - 深度研究查询 + */ +export const DEEP_RESEARCH_DETAIL = `## Scenario Guidance: Comprehensive Query (Deep Research) + +This question requires comprehensive, multi-angle analysis or complete listing. + +**MUST follow these steps:** +1. STOP. Do NOT answer directly — you need comprehensive research. +2. Use @query_rewrite(strategies="decompose") to break the research question into sub-questions. +3. Search ONLY the sub-questions returned by @query_rewrite using parallel @search. Do NOT add extra ad-hoc queries beyond what @query_rewrite returned. +4. Assess coverage: which sub-questions still lack information? +5. For under-covered sub-questions, use @query_rewrite ONCE more to rephrase, then search again. +6. When you have covered the main aspects, call @summary immediately with clear section structure. + +**Optional — @assess for chunk annotation:** +After searching, you MAY call @assess to annotate the most valuable chunks (key_chunks). +This helps preserve critical information in the final answer. +You do NOT need to fill every lack before answering — call @summary with what you have. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Do NOT answer from memory — base everything on retrieved information. +- Ensure complete coverage of all items/aspects requested.`; + +/** + * Follow-up Query - 追问 + */ +export const FOLLOWUP_QUERY_DETAIL = `## Scenario Guidance: Follow-up Query + +This is a continuation of a previous conversation. The question references prior context. + +**MUST follow these steps:** +1. Review the chat history to understand what was discussed. +2. Identify what additional information is being asked. +3. If the follow-up is about a specific item mentioned before, search for that specific item. +4. If asking for clarification or expansion, search for more details on the topic. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Keep answer connected to previous context. +- If answering based on prior conversation, explicitly reference it.`; + +/** + * General - 通用查询 + */ +export const GENERAL_DETAIL = `## Scenario Guidance: General Query + +This is a general query that doesn't fit specific categories. + +**MUST follow these steps:** +1. Analyze the question to understand the intent. +2. Use @search to find relevant information. +3. If results are insufficient, use @query_rewrite to try different formulations. +4. Synthesize information from multiple sources if available. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Follow standard answer quality guidelines. +- Include relevant citations from retrieved chunks.`; + +// ============================================================ +// SearchOnly 变体(6 种 playbook × searchOnly 模式) +// ============================================================ + +/** + * Simple Query - searchOnly 模式 + */ +export const SIMPLE_QUERY_DETAIL_SEARCH_ONLY = `## Scenario Guidance: Direct Query (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY and DIRECTLY answer the user's question → STOP. Call no tools. +- If the hints are an alias/synonym mapping (e.g. "alias: A=B") → use both terms in your search queries, then proceed. +- If the hints are partial or supplementary → incorporate them into your search, then proceed. + +**Search Steps:** +1. Search the original query directly (using any aliases or synonyms from hints). +2. If the first search returned poor or no results: use @query_rewrite ONCE to rephrase, then search again. +3. Do NOT search more than twice. + +**Stop Rule:** +- When you have completed your searches, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +/** + * Comparative Analysis - searchOnly 模式 + */ +export const COMPARATIVE_ANALYSIS_DETAIL_SEARCH_ONLY = `## Scenario Guidance: Comparison Query (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY answer the comparison question → STOP. Call no tools. +- If the hints provide alias mappings or supplementary context → use them to guide searches. + +**Phase 1: Discovery** +- Use @query_rewrite(strategies="decompose") to identify comparison objects and dimensions. +- Search each comparison object with a basic query. + +**Phase 2: Enrichment** +- Search each object × dimension combination. +- Use parallel search (@search with JSON array) when queries are independent. +- Continue until all identified dimensions have been searched at least once. + +**Stop Rule:** +- When main dimensions are covered, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +/** + * Troubleshooting - searchOnly 模式 + */ +export const TROUBLESHOOTING_DETAIL_SEARCH_ONLY = `## Scenario Guidance: Troubleshooting (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY answer the troubleshooting question → STOP. Call no tools. +- If the hints provide supplementary context → incorporate into searches. + +**Search Steps:** +1. Search the error message or symptom keywords. +2. If insufficient: use @query_rewrite (try par or gqr strategy), then search again. +3. If multiple possible causes are found: search each cause's solution separately. + +**Stop Rule:** +- When main diagnostic information is found, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +/** + * Deep Research - searchOnly 模式 + */ +export const DEEP_RESEARCH_DETAIL_SEARCH_ONLY = `## Scenario Guidance: Comprehensive Query (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY enumerate the items requested → STOP. Call no tools. +- If the hints provide alias mappings or partial lists → use them to guide searches. + +**Search Steps:** +1. Use @query_rewrite(strategies="decompose") to break the question into sub-questions. +2. Search ONLY the sub-questions returned by @query_rewrite (parallel when possible). +3. Assess coverage: which sub-questions still lack information? +4. For under-covered sub-questions: use @query_rewrite ONCE more to rephrase, then search again. + +**Stop Rule:** +- When main aspects are covered, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +/** + * Follow-up Query - searchOnly 模式 + */ +export const FOLLOWUP_QUERY_DETAIL_SEARCH_ONLY = `## Scenario Guidance: Follow-up Query (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY answer the follow-up question → STOP. Call no tools. +- If the hints are supplementary → use them to guide searches. + +**Search Steps:** +1. Review chat history to identify what is being asked. +2. Search for the specific item or topic being followed up on. +3. If results are insufficient: use @query_rewrite ONCE, then search again. + +**Stop Rule:** +- When the target information is found, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +/** + * General - searchOnly 模式 + */ +export const GENERAL_DETAIL_SEARCH_ONLY = `## Scenario Guidance: General Query (Search-Only) + +**Step 0 — Priority Check (Execute First):** +Review the Search Hints provided. +- If the hints COMPLETELY answer the question → STOP. Call no tools. +- If the hints are supplementary or alias mappings → use them to guide searches. + +**Search Steps:** +1. Search the original query (incorporating any alias hints). +2. If results are insufficient: use @query_rewrite to rephrase, then search again. + +**Stop Rule:** +- When sufficient chunks are collected, make no more tool calls. Simply stop. +- Do NOT call @summary.`; + +// ============================================================ +// Playbook 注册表 +// ============================================================ + +export const PLAYBOOKS: Record = { + simple_query: { + name: 'simple_query', + description: 'Direct factual query with clear intent. Single answer expected.', + detail: SIMPLE_QUERY_DETAIL, + detailSearchOnly: SIMPLE_QUERY_DETAIL_SEARCH_ONLY + }, + comparative_analysis: { + name: 'comparative_analysis', + description: 'Compare multiple objects. Analyze differences and similarities.', + detail: COMPARATIVE_ANALYSIS_DETAIL, + detailSearchOnly: COMPARATIVE_ANALYSIS_DETAIL_SEARCH_ONLY + }, + troubleshooting: { + name: 'troubleshooting', + description: 'Debug errors, fix problems, resolve issues.', + detail: TROUBLESHOOTING_DETAIL, + detailSearchOnly: TROUBLESHOOTING_DETAIL_SEARCH_ONLY + }, + deep_research: { + name: 'deep_research', + description: 'Comprehensive coverage needed. List all items, multiple aspects.', + detail: DEEP_RESEARCH_DETAIL, + detailSearchOnly: DEEP_RESEARCH_DETAIL_SEARCH_ONLY + }, + followup_query: { + name: 'followup_query', + description: 'Continuation of previous conversation. Clarify or expand.', + detail: FOLLOWUP_QUERY_DETAIL, + detailSearchOnly: FOLLOWUP_QUERY_DETAIL_SEARCH_ONLY + }, + general: { + name: 'general', + description: 'General query requiring analysis first.', + detail: GENERAL_DETAIL, + detailSearchOnly: GENERAL_DETAIL_SEARCH_ONLY + } +}; + +/** + * 获取 playbook 内容 + */ +export function getPlaybookContent(playbookName: string, searchOnly?: boolean): string { + const playbook = PLAYBOOKS[playbookName] || PLAYBOOKS.general; + return searchOnly ? playbook.detailSearchOnly : playbook.detail; +} + +/** + * 获取所有 playbook 名称 + */ +export function getAllPlaybookNames(): string[] { + return Object.keys(PLAYBOOKS); +} diff --git a/packages/diting-rag-ts/src/agent/runner.ts b/packages/diting-rag-ts/src/agent/runner.ts new file mode 100644 index 000000000000..ff165b4b434d --- /dev/null +++ b/packages/diting-rag-ts/src/agent/runner.ts @@ -0,0 +1,533 @@ +// src/agent/runner.ts +// Agentic Search Runner - 对齐 Python runner.py +// 职责:把图 (CompiledStateGraph) 包装为外部可用的 invoke / stream 接口 + +import { setMaxListeners } from 'events'; + +// LangGraph 并行执行多个工具节点时,每个并发 HTTP 请求都会给 combineAbortSignals +// 内部新建的 AbortController.signal 注册 abort listener,超过默认上限 10 会触发警告。 +// 该 signal 随每次 invoke 结束销毁,不存在泄漏风险,是已知的 Node.js false positive +// (参见 https://github.com/nodejs/node/issues/55328),故在模块级全局取消上限。 +setMaxListeners(0); + +import type { AgenticSearchProviders, AgenticSearchConfig } from '../ports/agentic'; +import { wrapProviderWithDefaults } from '../ports/llm'; +import type { ChunkItem } from '../types/chunk'; +import type { LLMMessage } from '../types/message'; +import { type AgentEvent } from './context'; +import { + buildGraph, + buildRequiredConfig, + calculateRecursionLimit, + type AgenticRAGStateType, + type GraphMode +} from './graph'; +import { detectLang } from '../utils/lang'; +import { setGlobalLogger } from '../utils/logger'; + +// ============================================================ +// 公共结果类型 +// ============================================================ + +export interface AgenticSearchResult { + chunks: ChunkItem[]; + reasoningText: string; + searchQueries: string[]; + searchCount: number; + toolCallCount: number; + playbook: string; + executionPath: string[]; + embeddingTokens: number; + rerankInputTokens: number; + llmInputTokens: number; + llmOutputTokens: number; + analysis?: string; + answer?: string; + confidence?: number; + ttftMs?: number; + citedIds?: string[]; + reflectionLabel?: string; + reflectionReason?: string; + refuse?: boolean; + nodeHist?: unknown[]; + queryLanguage?: string; +} + +export interface CreateAgenticSearchOptions { + providers: AgenticSearchProviders; + config?: AgenticSearchConfig; + mode?: GraphMode; + logger?: import('../ports/logger').Logger; +} + +// ============================================================ +// 流式事件常量(公共 API,供 UI / 上层消费) +// ============================================================ +export const AGENT_EVENTS = { + THINKING: 'thinking', + SEARCHING: 'searching', + SEARCH_DONE: 'search_done', + REWRITING: 'rewriting', + REWRITE_DONE: 'rewrite_done', + GENERATING: 'generating', + ANSWER_DELTA: 'answer_delta', + REFLECTING: 'reflecting', + REFLECT_DONE: 'reflect_done', + REGENERATING: 'regenerating', + ANSWER_DONE: 'answer_done', + FINAL: 'final', + ERROR: 'error', + STARTED: 'started', + // 补充对齐 Python 的事件类型 + REFERENCES: 'references', + RESPONDING: 'responding', + PLAYBOOK_SELECTED: 'playbook_selected', + CITATION_FIX: 'citation_fix', + ANSWER_REPLACE: 'answer_replace' +} as const; + +// ============================================================ +// 辅助:从 LangGraph stream chunk 生成 AgentEvent +// ============================================================ + +/** + * LangGraph stream(默认 "updates" 模式)每次 yield: + * { [nodeName]: nodeOutput } + * 根据节点名与输出内容映射为 AgentEvent。 + */ +function mapNodeUpdateToEvent( + nodeName: string, + nodeOutput: Partial +): AgentEvent[] { + switch (nodeName) { + case 'route_playbook': + return [ + { + step: AGENT_EVENTS.PLAYBOOK_SELECTED, + detail: `Playbook selected: ${nodeOutput.playbook ?? 'general'}`, + playbook: nodeOutput.playbook ?? 'general', + extra: { analysis: nodeOutput.analysis ?? '' }, + timestamp: Date.now() + } + ]; + + case 'agent': + case 'native_agent': + case 'text_agent': + case 'auto_agent': { + const pendingToolCalls = nodeOutput.pendingToolCalls ?? []; + const toolNames = pendingToolCalls.map((tc) => tc.function.name); + if (toolNames.length === 0) { + return []; + } + // 注意:search 的 SEARCHING 事件由 tools 节点在执行时发射, + // 避免 shouldContinue 早停时用户看到未实际执行的搜索文本 + if (toolNames.includes('query_rewrite')) { + return [ + { + step: AGENT_EVENTS.REWRITING, + detail: 'Rewriting query', + timestamp: Date.now() + } + ]; + } + if (toolNames.includes('summary')) { + return [ + { + step: AGENT_EVENTS.GENERATING, + detail: 'Generating answer', + timestamp: Date.now() + } + ]; + } + // 未识别的 tool call(如 assess 等内部决策工具),不产生用户可见事件 + return []; + } + + case 'tools': { + const path = nodeOutput.executionPath ?? []; + const lastPath = path[path.length - 1] ?? ''; + if (lastPath.includes('search')) { + const usedQueries = nodeOutput.searchQueries ?? []; + const searchDoneEvent: AgentEvent = { + step: AGENT_EVENTS.SEARCH_DONE, + detail: `Found ${nodeOutput.allChunks?.length ?? 0} chunks`, + timestamp: Date.now(), + extra: { + chunkCount: nodeOutput.allChunks?.length ?? 0, + queries: usedQueries + } + }; + // 有实际查询词时才发射 SEARCHING(避免空查询产生无意义文本) + if (usedQueries.length > 0) { + const searchingEvent: AgentEvent = { + step: AGENT_EVENTS.SEARCHING, + detail: usedQueries.join(', ') || 'Searching...', + timestamp: Date.now(), + extra: { queries: usedQueries } + }; + return [searchingEvent, searchDoneEvent]; + } + return [searchDoneEvent]; + } + if (lastPath.includes('query_rewrite')) { + return [ + { + step: AGENT_EVENTS.REWRITE_DONE, + detail: `Rewritten to ${nodeOutput.rewriteQueries?.length ?? 0} queries`, + timestamp: Date.now(), + extra: { queries: nodeOutput.rewriteQueries ?? [] } + } + ]; + } + if (lastPath.includes('summary') || nodeOutput.answer) { + return [ + { + step: AGENT_EVENTS.ANSWER_DONE, + detail: `Answer generated (confidence: ${nodeOutput.confidence ?? 0})`, + timestamp: Date.now(), + extra: { confidence: nodeOutput.confidence } + } + ]; + } + return []; + } + + default: + return []; + } +} + +/** + * 将最终状态映射为 AgenticSearchResult + */ +function mapStateToResult(finalState: AgenticRAGStateType): AgenticSearchResult { + // reasoningText 在流式调用中由上层(agenticSearch.ts)通过事件流累积生成; + // mapStateToResult 仅作为 invoke 模式的同步结果,此处留空避免暴露无意义的内部路径标签。 + const reasoningText = ''; + + // searchOnly 模式下用 chunk selector 的结果,否则用全部累积 chunks + // 注意:selectedChunks 默认值为 [],需通过 executionPath 判断 select_chunks 是否实际运行过 + // 若 select_chunks 运行过(含 allChunksIrrelevant 返回 [] 的情况),使用其结果(可能是空数组) + const executionPath = finalState.executionPath ?? []; + const selectChunksRan = executionPath.some((p) => p.startsWith('select_chunks')); + const chunks = selectChunksRan + ? finalState.selectedChunks ?? [] + : (finalState.selectedChunks?.length ?? 0) > 0 + ? finalState.selectedChunks! + : finalState.allChunks ?? []; + + return { + chunks, + reasoningText, + searchQueries: finalState.searchQueries ?? [], + searchCount: finalState.searchCount ?? 0, + toolCallCount: finalState.toolCallCount ?? 0, + playbook: finalState.playbook ?? 'general', + executionPath: finalState.executionPath ?? [], + embeddingTokens: finalState.embeddingTokens ?? 0, + rerankInputTokens: finalState.rerankInputTokens ?? 0, + llmInputTokens: finalState.llmInputTokens ?? 0, + llmOutputTokens: finalState.llmOutputTokens ?? 0, + analysis: finalState.analysis || undefined, + answer: finalState.answer || undefined, + confidence: finalState.confidence || undefined, + ttftMs: finalState.ttftMs, + citedIds: finalState.citedIds ?? [], + reflectionLabel: finalState.reflectionLabel || undefined, + reflectionReason: finalState.reflectionReason || undefined, + refuse: finalState.refuse, + nodeHist: finalState.nodeHist ?? [], + queryLanguage: finalState.question ? detectLang(finalState.question) : undefined + }; +} + +// ============================================================ +// 公共工厂函数 +// ============================================================ + +/** + * 创建 Agentic Search 实例 + * + * 返回 { invoke, stream } 两个接口: + * - invoke:同步调用,返回最终结果 + * - stream:流式调用,产出中间事件 AgentEvent + 最终 AgenticSearchResult + */ +export function createAgenticSearch(options: CreateAgenticSearchOptions) { + const { providers: rawProviders, config, mode = 'auto', logger } = options; + + // 包裹外部 LLM provider,注入默认选项(agentic search 全程禁用 thinking) + const providers: AgenticSearchProviders = { + ...rawProviders, + llm: wrapProviderWithDefaults(rawProviders.llm) + }; + + // 使用传入的 logger 或从 providers 获取,并注册为全局单例 + const resolvedLogger = logger ?? providers.logger; + if (resolvedLogger) setGlobalLogger(resolvedLogger); + + const resolvedConfig = buildRequiredConfig(config); + const recursionLimit = calculateRecursionLimit(resolvedConfig); + + return { + /** + * 同步调用 + * + * 每次调用重建 graph(含 RequestContext),保证多 session 并发时状态完全隔离。 + * graph 编译为纯内存操作(< 5ms),远小于 LLM 调用延迟,无性能影响。 + */ + invoke: async (params: { + query: string; + datasetIds: string[]; + history?: Array<{ role: string; content: string }>; + /** 调用方预计算的 query 变体,预填检索计划以避免重复 query_rewrite LLM 调用 */ + initialQueries?: string[]; + /** 调用方预先检索到的上下文(如 SQL 检索结果),agent 在 Blackboard Brief 中参考 */ + priorContext?: string; + /** DB 采样的语言分布统计,用于初始化 LanguageTracker。null 表示采样失败/空 KB */ + initialLanguageStats?: Record | null; + }): Promise => { + const { + query, + datasetIds, + history = [], + initialQueries, + priorContext, + initialLanguageStats + } = params; + const startTime = Date.now(); + + // per-request graph(含 RequestContext Blackboard),并发安全 + const compiled = buildGraph(mode, providers, config); + + const initialInput: Partial = { + question: query, + datasetIds, + chatHistory: history as LLMMessage[], + priorContext: priorContext || '', + originalQuestion: query, + ...(initialQueries?.length ? { rewriteQueries: initialQueries } : {}), + ...(initialLanguageStats !== undefined ? { initialLanguageStats } : {}) + }; + + const finalState = await compiled.invoke(initialInput as AgenticRAGStateType, { + recursionLimit + }); + const result = mapStateToResult(finalState); + + // 计算各阶段耗时 + const totalMs = Date.now() - startTime; + resolvedLogger?.info(`[Runner] Elapsed: ${(totalMs / 1000).toFixed(1)}s`); + resolvedLogger?.info(` Playbook: ${result.playbook}`); + resolvedLogger?.info(` Tool Calls: ${result.toolCallCount}`); + resolvedLogger?.info(` Search Count: ${result.searchCount}`); + resolvedLogger?.info(` Chunks Found: ${result.chunks.length}`); + + return result; + }, + + /** + * 流式调用(对齐 Python run_query_stream) + * 每次 yield AgentEvent(中间步骤)或 AgenticSearchResult(最终结果) + */ + stream: async function* (params: { + query: string; + datasetIds: string[]; + history?: Array<{ role: string; content: string }>; + /** 调用方预计算的 query 变体,预填检索计划以避免重复 query_rewrite LLM 调用 */ + initialQueries?: string[]; + /** 调用方预先检索到的上下文(如 SQL 检索结果),agent 在 Blackboard Brief 中参考 */ + priorContext?: string; + /** DB 采样的语言分布统计,用于初始化 LanguageTracker。null 表示采样失败/空 KB */ + initialLanguageStats?: Record | null; + }): AsyncGenerator { + const { + query, + datasetIds, + history = [], + initialQueries, + priorContext, + initialLanguageStats + } = params; + const startTime = Date.now(); + + // Yield started 事件 + yield { + step: AGENT_EVENTS.STARTED, + detail: query, + timestamp: startTime + }; + + // 构建 initial_state + const initialInput: Partial = { + question: query, + datasetIds, + chatHistory: history as LLMMessage[], + priorContext: priorContext || '', + startTime: startTime, + originalQuestion: query, + // 软提示(soft hint):若调用方提供了预计算的查询变体,预填 rewriteQueries。 + // Blackboard Brief 的 Plan 区将展示这些查询,引导 Agent 优先直接检索而非重新改写。 + // 注意:这是软约束,Agent 仍可在后续轮次(如 deep_research)中调用 query_rewrite 工具进行二次改写。 + ...(initialQueries?.length ? { rewriteQueries: initialQueries } : {}), + ...(initialLanguageStats !== undefined ? { initialLanguageStats } : {}) + }; + + // per-request graph(含 RequestContext Blackboard),并发安全 + const compiled = buildGraph(mode, providers, config); + + let finalState: AgenticRAGStateType | null = null; + + // 手动累积完整 state(LangGraph updates 模式只给 delta,不给全量 state) + // 对齐 state.ts 中定义的 reducer 规则:replace / append / smart-merge + const accState: Partial = { ...initialInput }; + + function mergeStateUpdate(delta: Partial): void { + // ── replace reducers ──────────────────────────────────── + if (delta.playbook !== undefined) accState.playbook = delta.playbook; + if (delta.analysis !== undefined) accState.analysis = delta.analysis; + if (delta.reason !== undefined) accState.reason = delta.reason; + if (delta.toolCallCount !== undefined) accState.toolCallCount = delta.toolCallCount; + if (delta.searchCount !== undefined) accState.searchCount = delta.searchCount; + if (delta.iterationCount !== undefined) accState.iterationCount = delta.iterationCount; + if (delta.answer !== undefined) accState.answer = delta.answer; + if (delta.confidence !== undefined) accState.confidence = delta.confidence; + if (delta.done !== undefined) accState.done = delta.done; + if (delta.selectedChunks !== undefined) accState.selectedChunks = delta.selectedChunks; + if (delta.pendingToolCalls !== undefined) + accState.pendingToolCalls = delta.pendingToolCalls; + if (delta.rewriteQueries !== undefined) accState.rewriteQueries = delta.rewriteQueries; + if (delta.lastSearchOverlap !== undefined) + accState.lastSearchOverlap = delta.lastSearchOverlap; + if (delta.reflectionLabel !== undefined) accState.reflectionLabel = delta.reflectionLabel; + if (delta.reflectionReason !== undefined) + accState.reflectionReason = delta.reflectionReason; + if (delta.refuse !== undefined) accState.refuse = delta.refuse; + if (delta.ttftMs !== undefined) accState.ttftMs = delta.ttftMs; + if (delta.citedIds !== undefined) accState.citedIds = delta.citedIds; + if (delta.error !== undefined) accState.error = delta.error; + if (delta.languageTrackerConfig !== undefined) + accState.languageTrackerConfig = delta.languageTrackerConfig; + // ── additive reducers ─────────────────────────────────── + if (delta.embeddingTokens !== undefined) { + accState.embeddingTokens = (accState.embeddingTokens ?? 0) + delta.embeddingTokens; + } + if (delta.rerankInputTokens !== undefined) { + accState.rerankInputTokens = (accState.rerankInputTokens ?? 0) + delta.rerankInputTokens; + } + if (delta.llmInputTokens !== undefined) { + accState.llmInputTokens = (accState.llmInputTokens ?? 0) + delta.llmInputTokens; + } + if (delta.llmOutputTokens !== undefined) { + accState.llmOutputTokens = (accState.llmOutputTokens ?? 0) + delta.llmOutputTokens; + } + // ── append reducers ───────────────────────────────────── + if (delta.executionPath !== undefined) { + accState.executionPath = [...(accState.executionPath ?? []), ...delta.executionPath]; + } + if (delta.searchQueries !== undefined) { + accState.searchQueries = [...(accState.searchQueries ?? []), ...delta.searchQueries]; + } + if (delta.nodeHist !== undefined) { + accState.nodeHist = [...(accState.nodeHist ?? []), ...(delta.nodeHist as any[])]; + } + // ── smart merge for allChunks (deduplicate by id, keep higher score) ── + if (delta.allChunks !== undefined) { + const existing = new Map( + (accState.allChunks ?? []).map((c, i) => [c.id, i] as [string, number]) + ); + const newList = [...(accState.allChunks ?? [])]; + for (const chunk of delta.allChunks) { + const idx = existing.get(chunk.id); + if (idx === undefined) { + existing.set(chunk.id, newList.length); + newList.push(chunk); + } else { + const cur = newList[idx]; + if ( + ((chunk as any).rerankScore ?? chunk.score) > + ((cur as any).rerankScore ?? cur.score) + ) { + newList[idx] = chunk; + } + } + } + accState.allChunks = newList; + } + } + + try { + // LangGraph stream 默认 "updates" 模式(每次 yield 节点增量 delta) + const stream = await compiled.stream(initialInput as AgenticRAGStateType, { + recursionLimit + }); + for await (const chunk of stream) { + for (const [nodeName, nodeOutput] of Object.entries( + chunk as Record> + )) { + // 将 delta 合并到累积 state + mergeStateUpdate(nodeOutput); + + const events = mapNodeUpdateToEvent(nodeName, nodeOutput); + for (const event of events) { + if (event) yield event; + } + } + } + // 流结束后使用累积完整 state + if (accState.done || accState.answer) { + finalState = accState as AgenticRAGStateType; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + yield { + step: AGENT_EVENTS.ERROR, + detail: errorMsg, + timestamp: Date.now() + }; + return; + } + + // fallback:若流未能获取有效 state,重新 invoke(不应发生) + if (!finalState) { + try { + const result = await compiled.invoke(initialInput as AgenticRAGStateType, { + recursionLimit + }); + finalState = result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + yield { + step: AGENT_EVENTS.ERROR, + detail: `Final invoke failed: ${errorMsg}`, + timestamp: Date.now() + }; + return; + } + } + + yield { + step: AGENT_EVENTS.FINAL, + detail: 'Execution complete', + timestamp: Date.now(), + extra: { + answer: finalState.answer, + confidence: finalState.confidence, + toolCallCount: finalState.toolCallCount, + searchCount: finalState.searchCount, + citedIds: finalState.citedIds, + reflectionLabel: finalState.reflectionLabel, + refuse: finalState.refuse || undefined + } + }; + + // 最后 yield 最终结果 + yield mapStateToResult(finalState); + }, + + /** + * 获取当前配置(只读) + */ + getConfig: () => resolvedConfig + }; +} diff --git a/packages/diting-rag-ts/src/agent/tools.ts b/packages/diting-rag-ts/src/agent/tools.ts new file mode 100644 index 000000000000..401880b261f1 --- /dev/null +++ b/packages/diting-rag-ts/src/agent/tools.ts @@ -0,0 +1,889 @@ +// src/agent/tools.ts +// Tool 工厂 - 为 Agent 生成可调用的 Tools(闭包注入 RequestContext) +// 同时包含 LangGraph 图节点使用的 AGENT_TOOLS schema 和文本 ReAct 解析器 + +import type { RequestContext } from './context'; +import type { ToolDefinition } from '../types/message'; +import { TOOLS } from '../utils/constants'; +import { getPlaybookContent } from './playbooks/templates'; +import { stripThinkBlocks, TEXT_REACT_INSTRUCTION } from '../utils/text'; + +// ============================================================ +// LangGraph 图节点使用的 Tool schema(OpenAI function calling 格式) +// ============================================================ + +export interface ToolDefinitionItem { + name: string; + description: string; + inputSchema: Record; +} + +export const AGENT_TOOLS: ToolDefinitionItem[] = [ + { + name: TOOLS.SEARCH, + description: + 'Search the knowledge base for relevant information. Use this to find documents that can help handle the user question.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + queries: { + type: 'array', + items: { type: 'string' }, + description: 'Multiple queries for parallel search' + } + } + } + }, + { + name: TOOLS.QUERY_REWRITE, + description: + 'Rewrite user query to improve search results. Also use to translate search queries into the KB document language when the user asked in a different language. Use when initial search returns poor results.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Original query to rewrite' } + }, + required: ['query'] + } + }, + { + name: TOOLS.SUMMARY, + description: + 'Generate final answer based on retrieved information. Use this when you have enough information to answer.', + inputSchema: { + type: 'object', + properties: { + reasoning: { type: 'string', description: 'Reasoning process based on search results' } + }, + required: ['reasoning'] + } + }, + { + name: TOOLS.ASSESS, + description: + 'Assess information sufficiency after searching. For deep_research, troubleshooting, comparative_analysis only. Call after every @search to record findings, update lacks, and annotate useful chunks.', + inputSchema: { + type: 'object', + properties: { + findings: { + type: 'array', + items: { type: 'string' }, + description: 'New valuable information found this round ([] if none)' + }, + lacks: { + type: 'array', + items: { type: 'string' }, + description: + 'Complete current list of still-missing info (snapshot). Pass [] if sufficient.' + }, + sufficient: { + type: 'boolean', + description: 'True if ready to answer, False if more search needed' + }, + key_chunks: { + type: 'array', + items: { + type: 'object', + properties: { + chunk_id: { type: 'string', description: 'ID of the useful chunk' }, + key_info: { type: 'string', description: 'Extracted snippet (null = entire chunk)' } + }, + required: ['chunk_id'] + }, + description: 'Useful chunks from recent @search results to annotate' + } + }, + required: ['findings', 'lacks', 'sufficient'] + } + } +]; + +// LLM function calling 格式的工具定义 +export const TOOL_DEFINITIONS: ToolDefinition[] = AGENT_TOOLS.map((t) => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description, + parameters: t.inputSchema as Record + } +})); + +// ============================================================ +// Text ReAct 解析器 +// ============================================================ + +/** + * 解析 @tool_name(args) 格式(单个调用) + * 对齐 Python parse_text_tool_call,使用 stripThinkBlocks 清理 LLM 输出 + */ +export function parseTextToolCall( + content: string +): { name: string; args: Record } | null { + // 使用 stripThinkBlocks 对齐 Python strip_think_blocks + const cleaned = stripThinkBlocks(content); + // 从 AGENT_TOOLS 动态获取已注册的工具名,避免硬编码 + const registeredToolNames = new Set(AGENT_TOOLS.map((t) => t.name)); + const toolNamePattern = Array.from(registeredToolNames).join('|'); + const match = cleaned.match(new RegExp(`@(${toolNamePattern})\\b\\s*(?:\\(([^)]*)\\))?`)); + if (!match) return null; + + const toolName = match[1]; + const argsStr = match[2] || ''; + + try { + if (argsStr.trim() === '') { + return { name: toolName, args: {} }; + } + return { name: toolName, args: JSON.parse(argsStr) }; + } catch { + return { name: toolName, args: { query: argsStr } }; + } +} + +/** + * 解析 XML 格式(Qwen3/Hermes vLLM chat template) + * 对齐 Python parse_tool_call_xml,使用 stripThinkBlocks 清理 LLM 输出 + */ +export function parseToolCallXml( + content: string +): Array<{ name: string; args: Record }> { + const toolCalls: Array<{ name: string; args: Record }> = []; + // 使用 stripThinkBlocks 对齐 Python strip_think_blocks + const cleaned = stripThinkBlocks(content); + // 从 AGENT_TOOLS 动态获取已注册的工具名,避免硬编码 + const registeredToolNames = new Set(AGENT_TOOLS.map((t) => t.name)); + const toolNamePattern = Array.from(registeredToolNames).join('|'); + const regex = new RegExp( + `\\s*]*>([\\s\\S]*?)<\\/tool>\\s*<\\/tool_call>`, + 'g' + ); + let match; + + while ((match = regex.exec(cleaned)) !== null) { + const toolName = match[1]; + const argsStr = match[2]; + + try { + const args: Record = {}; + const argRegex = /([^<]*)<\/arg>/g; + let argMatch; + while ((argMatch = argRegex.exec(argsStr)) !== null) { + args[argMatch[1]] = argMatch[2]; + } + toolCalls.push({ name: toolName, args }); + } catch { + // 忽略解析错误 + } + } + + return toolCalls; +} + +/** + * 解析 MiniMax [TOOL_CALL] 格式 + * 示例: + * [TOOL_CALL] + * {tool => "search", args => { + * --queries ["vNGAF 产品介绍", "vADC 产品介绍"] + * }} + * [/TOOL_CALL] + * + * MiniMax 的 args 使用 Ruby-like hash 语法: + * --key value 或 key => value + * 数组: ["item1", "item2"] + */ +export function parseToolCallMinimax( + content: string +): Array<{ name: string; args: Record }> { + const toolCalls: Array<{ name: string; args: Record }> = []; + const cleaned = stripThinkBlocks(content); + // 匹配 [TOOL_CALL]...[/TOOL_CALL] 块 + const blockRegex = /\[TOOL_CALL\]([\s\S]*?)\[\/TOOL_CALL\]/g; + let blockMatch; + + while ((blockMatch = blockRegex.exec(cleaned)) !== null) { + const block = blockMatch[1]; + // 提取 tool 名称: {tool => "name", ... + const toolMatch = block.match(/tool\s*=>\s*"(\w+)"/); + if (!toolMatch) continue; + const toolName = toolMatch[1]; + + // 提取 args 部分: args => { ... } + // 使用非贪婪匹配找到 args => { 后的第一个闭合 } + const argsStart = block.indexOf('args'); + if (argsStart === -1) { + toolCalls.push({ name: toolName, args: {} }); + continue; + } + + // 从 args => 之后找第一个 {,然后平衡匹配到对应的 } + const afterArgs = block.slice(argsStart); + const firstBrace = afterArgs.indexOf('{'); + if (firstBrace === -1) { + toolCalls.push({ name: toolName, args: {} }); + continue; + } + + let braceDepth = 0; + let inString = false; + let escaped = false; + let argsBody = ''; + const body = afterArgs.slice(firstBrace + 1); // 跳过第一个 { + + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (escaped) { + escaped = false; + argsBody += c; + continue; + } + if (c === '\\') { + escaped = true; + argsBody += c; + continue; + } + if ((c === '"' || c === "'") && !escaped) { + inString = !inString; + argsBody += c; + continue; + } + if (!inString) { + if (c === '{') braceDepth++; + else if (c === '}') { + if (braceDepth === 0) break; // 找到闭合的 } + braceDepth--; + } + } + argsBody += c; + } + + try { + const args: Record = {}; + + // 1. 先提取 --key value 格式(MiniMax 特有) + // 匹配 --key value 或 --key ["a", "b"] 格式 + const dashRegex = /--(\w+)\s+([\s\S]*?)(?=\s+--\w+\s|$)/g; + let dashMatch; + while ((dashMatch = dashRegex.exec(argsBody)) !== null) { + const key = dashMatch[1]; + const valueStr = dashMatch[2].trim(); + args[key] = parseMinimaxValue(valueStr); + } + + // 2. 再提取 key => value 格式 + const arrowRegex = /(\w+)\s*=>\s*([\s\S]*?)(?=,\s*\w+\s*=>|$)/g; + let arrowMatch; + while ((arrowMatch = arrowRegex.exec(argsBody)) !== null) { + const key = arrowMatch[1]; + // 跳过已处理的 --key + if (key.startsWith('-')) continue; + const valueStr = arrowMatch[2].trim(); + args[key] = parseMinimaxValue(valueStr); + } + + toolCalls.push({ name: toolName, args }); + } catch { + toolCalls.push({ name: toolName, args: { raw: argsBody } }); + } + } + + return toolCalls; +} + +/** + * 解析 MiniMax 格式的值(字符串、数组、对象) + */ +function parseMinimaxValue(valueStr: string): unknown { + const trimmed = valueStr.trim(); + + // 数组: ["item1", "item2"] 或 [item1, item2] + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + // 尝试作为 JSON 数组解析 + const jsonLike = trimmed.replace(/(\w+)\s*=>/g, '"$1":').replace(/'([^']*)'/g, '"$1"'); + return JSON.parse(jsonLike); + } catch { + // 手动分割数组元素 + const inner = trimmed.slice(1, -1); + const items: string[] = []; + let current = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < inner.length; i++) { + const c = inner[i]; + if (escaped) { + escaped = false; + current += c; + continue; + } + if (c === '\\') { + escaped = true; + current += c; + continue; + } + if (c === '"' && !escaped) { + inString = !inString; + current += c; + continue; + } + if (c === ',' && !inString) { + items.push(current.trim()); + current = ''; + continue; + } + current += c; + } + if (current.trim()) items.push(current.trim()); + + // 去除引号 + return items.map((item) => { + const t = item.trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1); + } + return t; + }); + } + } + + // 对象: { key => value } + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + const jsonLike = trimmed.replace(/(\w+)\s*=>/g, '"$1":').replace(/'([^']*)'/g, '"$1"'); + return JSON.parse(jsonLike); + } catch { + return trimmed; + } + } + + // 字符串(去除引号) + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + // 数字 + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return parseFloat(trimmed); + } + + // 布尔值 + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + + return trimmed; +} + +/** + * 从文本中解析所有 tool calls(XML 格式优先,降级 @tool_name 格式) + * 对齐 Python parse_text_tool_calls,使用 stripThinkBlocks 清理 LLM 输出 + */ +export function parseAllToolCalls( + content: string +): Array<{ name: string; args: Record }> { + // 使用 stripThinkBlocks 对齐 Python strip_think_blocks + const cleaned = stripThinkBlocks(content); + + // 1. 解析 XML 格式(MiniMax 等模型使用,带 name 属性) + const invokeCalls = parseInvokeXml(cleaned); + if (invokeCalls.length > 0) return invokeCalls; + + // 2. 解析 XML 格式(Qwen3/Hermes vLLM chat template) + const xmlCalls = parseToolCallXml(cleaned); + if (xmlCalls.length > 0) return xmlCalls; + + // 3. 解析 MiniMax [TOOL_CALL] 格式 + const minimaxCalls = parseToolCallMinimax(cleaned); + if (minimaxCalls.length > 0) return minimaxCalls; + + // 4. 解析 直接标签格式(MiniMax 等模型使用) + const directTagCalls = parseDirectTagXml(cleaned); + if (directTagCalls.length > 0) return directTagCalls; + + // 5. 降级 @tool_name 格式 + // 注意:必须排除 Markdown 列表项(行首空格/破折号后跟 @)、email 地址等误匹配场景 + // 只匹配行首(或换行后)直接以 @tool_name 开头,或 @tool_name({...}) 的有效工具调用 + const atCalls: Array<{ name: string; args: Record }> = []; + // 从 AGENT_TOOLS 动态获取已注册的工具名,避免硬编码 + const registeredToolNames = new Set(AGENT_TOOLS.map((t) => t.name)); + const toolNamePattern = Array.from(registeredToolNames).join('|'); + // 匹配模式:行首或换行后直接跟 @tool_name,前面不能有非空白字符(排除 email 等) + const regex = new RegExp(`(?:^|\\n)\\s*@(${toolNamePattern})\\b\\s*(?:\\(([^)]*)\\))?`, 'g'); + let match; + + while ((match = regex.exec(cleaned)) !== null) { + const toolName = match[1]; + const argsStr = match[2] || '{}'; + try { + const args = argsStr.trim() === '' ? {} : JSON.parse(argsStr); + atCalls.push({ name: toolName, args }); + } catch { + atCalls.push({ name: toolName, args: { query: argsStr } }); + } + } + + return atCalls; +} + +/** + * 解析 XML 格式(MiniMax 等模型使用) + * 示例: + * + * + * 超融合 默认地址 管理地址 + * + * + */ +function parseInvokeXml(content: string): Array<{ name: string; args: Record }> { + const toolCalls: Array<{ name: string; args: Record }> = []; + const cleaned = stripThinkBlocks(content); + + // 从 AGENT_TOOLS 动态获取已注册的工具名,避免硬编码 + const registeredToolNames = new Set(AGENT_TOOLS.map((t) => t.name)); + const toolNamePattern = Array.from(registeredToolNames).join('|'); + // 匹配 ... 块 + const invokeRegex = new RegExp( + `]*>([\\s\\S]*?)<\\/invoke>`, + 'g' + ); + let invokeMatch; + + while ((invokeMatch = invokeRegex.exec(cleaned)) !== null) { + const toolName = invokeMatch[1]; + const paramsStr = invokeMatch[2]; + + try { + const args: Record = {}; + // 匹配 value + const paramRegex = /]*>([\s\S]*?)<\/parameter>/g; + let paramMatch; + while ((paramMatch = paramRegex.exec(paramsStr)) !== null) { + const key = paramMatch[1]; + const value = paramMatch[2].trim(); + // 尝试解析为 JSON(数组等),失败则保留字符串 + try { + args[key] = JSON.parse(value); + } catch { + args[key] = value; + } + } + toolCalls.push({ name: toolName, args }); + } catch { + // 忽略解析错误 + } + } + + return toolCalls; +} + +/** + * 解析 直接标签格式(MiniMax 等模型使用) + * 示例: + * + * + * 公司员工人数 员工总数 + * + * + * + * 支持的标签: , , + */ +function parseDirectTagXml( + content: string +): Array<{ name: string; args: Record }> { + const toolCalls: Array<{ name: string; args: Record }> = []; + const cleaned = stripThinkBlocks(content); + + // 从 AGENT_TOOLS 动态获取已注册的工具名,避免硬编码 + const registeredToolNames = new Set(AGENT_TOOLS.map((t) => t.name)); + const toolNamePattern = Array.from(registeredToolNames).join('|'); + const tagRegex = new RegExp( + `<(${toolNamePattern})\\b[^\\u003e]*>([\\s\\S]*?)<\\/(${toolNamePattern})>`, + 'g' + ); + let tagMatch; + + while ((tagMatch = tagRegex.exec(cleaned)) !== null) { + const toolName = tagMatch[1]; + const innerContent = tagMatch[2]; + + try { + const args: Record = {}; + // 提取所有子标签作为参数: value + const paramRegex = /<(\w+)\b[^\u003e]*>([\s\S]*?)<\/\1>/g; + let paramMatch; + while ((paramMatch = paramRegex.exec(innerContent)) !== null) { + const key = paramMatch[1]; + // 跳过与工具名相同的标签(那是外层标签) + if (key === toolName) continue; + const value = paramMatch[2].trim(); + // 尝试解析为 JSON(数组等),失败则保留字符串 + try { + args[key] = JSON.parse(value); + } catch { + args[key] = value; + } + } + toolCalls.push({ name: toolName, args }); + } catch { + // 忽略解析错误 + } + } + + return toolCalls; +} + +// ============================================================ +// System Prompt 构建器 +// ============================================================ + +/** + * 生成 text ReAct 模式的 tool description + * 对齐 Python build_text_react_prompt + */ +export function buildTextReactPrompt(): string { + // 对齐 Python: 使用 TEXT_REACT_INSTRUCTION + render_text_description_and_args + const toolDescriptions = AGENT_TOOLS.map((t) => `- @${t.name}: ${t.description}`).join('\n'); + // 使用 utils/text.ts 中的 TEXT_REACT_INSTRUCTION + return TEXT_REACT_INSTRUCTION.replace('{tool_descriptions}', toolDescriptions); +} + +export function getSystemPrompt(playbook: string, searchOnly?: boolean): string { + const playbookContent = getPlaybookContent(playbook, searchOnly); + return searchOnly + ? buildSearchOnlySystemPrompt(playbookContent) + : buildAnswerSystemPrompt(playbookContent); +} + +function buildAnswerSystemPrompt(playbookContent: string): string { + return `You are DiTing, an enterprise knowledge base QA assistant. You handle questions \ +by searching internal knowledge bases and synthesizing information from retrieved documents. + +## IRON LAW — Gather Information First, Always + +**STOP. Before calling @summary, you MUST gather sufficient information. Use @query_rewrite to optimize the query if needed, then @search to retrieve chunks.** + +You are in a multi-turn conversation. Previous AI responses exist in the chat history — \ +they are for context reference ONLY. They are NOT a knowledge source. + +- Do NOT copy, quote, or cite any information from previous AI responses. +- Do NOT answer directly from memory or from chat history content. +- For EVERY new question, you MUST gather information first. Consider using @query_rewrite for complex/vague queries, then @search to retrieve fresh information. +- You MUST use @summary to generate the final answer. +- Only cite chunks returned by @search in the CURRENT turn. + +If you call @summary without gathering sufficient information first, you are violating this law. + +## Core Rules +1. All answers MUST be grounded in retrieved knowledge chunks. Never fabricate information. +2. Cite sources using [id-xxx] inline, immediately after the relevant statement. +3. If knowledge is insufficient, explicitly state what is missing and suggest next steps. +4. **Language Strategy**: + - FINAL ANSWER: MUST be in the user's question language. + - SEARCH QUERIES: Write in the KB's document language (specified in [LANGUAGE DIRECTIVE]), NOT necessarily the user's language. + If the user asks in Arabic but the KB is Chinese, search in Chinese — this is the single most important factor for recall. + If you don't know the KB language, search in the user's language first, then observe results and adapt. + - When the user's language differs from the KB language: use @query_rewrite to generate search queries in the KB language. + @query_rewrite can translate your search intent into the document language — it will produce natural queries, not word-for-word translations. + +## Question Analysis + +When you receive a question, analyze it thoroughly before searching: + +1. **Extract key elements**: core question, constraints (product, version, scenario, time), \ +expected answer form (fact, steps, comparison, list). +2. **Identify special entities**: product names, error codes, config items — preserve exactly. +3. **Clarify the expected answer type** — this determines your output strategy: + - A direct factual answer ("What is X?") + - A step-by-step procedure ("How to configure X?") + - A document or resource link ("Where is the deployment guide?") + - A comparison report ("Differences between A and B?") + - A multi-dimensional summary ("Overview of X features") + - A diagnostic solution ("Error 789, how to fix?") +4. **Handle time references**: if the question mentions relative time (e.g., "recent", \ +"latest"), interpret relative to the current date. +5. **Classify query type** to guide your search strategy: + +| Type | Pattern | Search Strategy | +|------|---------|-----------------| +| **Direct** | Focused, single-fact question | One @search, then @summary | +| **Depth-First** | Chain reasoning, step N depends on step N-1 | Sequential searches, each informed by previous results | +| **Breadth-First** | Multiple independent sub-questions | Use JSON array: @search({"queries": ["Q1", "Q2"]}) for parallel execution | +| **Compound** | Mix of depth-first and breadth-first | Plan sequential + parallel phases | + +Priority when ambiguous: Compound > Depth-First > Breadth-First > Direct. + +## Search Planning + +Before your first search, briefly plan your approach: +- **Direct**: search the original query directly, no planning needed. +- **Breadth-First**: list all objects/dimensions to cover; use JSON array for parallel search: \ +@search({"queries": ["Q1", "Q2", "Q3"]}) +- **Depth-First**: identify the dependency chain; search the prerequisite first, \ +then decide the next search based on results. +- **Compound**: outline which phases are sequential and which are parallel. + +Revise your plan as new information emerges — the plan is a guide, not a rigid script. + +## Circular Search Execution + +Execute searches in an iterative loop: + +\`\`\` +loop { + (1) Observe: review collected information, assess coverage + (2) Decide: what is still missing? what query will fill the gap? + (3) Act: call @search, @query_rewrite, or @summary +} until information is sufficient +\`\`\` + +### Search Techniques +- **Multi-condition queries**: search with all conditions first. If results are insufficient, \ +relax one condition at a time (starting with the least important), search again, then filter \ +results against the removed condition. +- **Poor results**: use @query_rewrite to rephrase. Try up to 3 times with different angles \ +before concluding information is unavailable. Do not give up after one failed search. +- **Breadth-first / comparison**: use @query_rewrite({"query": "...", "strategy": "decompose"}) to decompose. \ +When the rewrite result shows **parallel=true**, you MUST use JSON array format: @search({"queries": ["Q1", "Q2"]}). \ +NEVER call @search multiple times for parallel sub-queries. +- **Chain dependencies**: search prerequisite info first, decide next query from results. +- **Critical facts** (numbers, dates, config values): verify from source, do not assume. +- Never repeat the exact same search query. +- Do not reduce searches for efficiency — prioritize information completeness. + +## Answer Generation + +When you have sufficient information, call @summary. Before calling, confirm: +- All aspects of the user's question are covered. +- Key facts are cross-verified where possible. +- For comparisons, information on ALL objects has been collected. +- The answer type matches the user's intent (e.g., if they asked to "find a document", \ +ensure the answer includes document names and links, not just a summary of the content). + +If @summary's reflection result is unfaithful or incomplete: +- Search for the missing information, then call @summary again. + +## Constraints + +- Total tool calls ≤ 10 +- @search calls ≤ 5 +- Always use @summary to generate the final response (do NOT write the answer yourself) +- If repeated searches find nothing relevant, call @summary to produce an honest \ +"insufficient information" response with known clues and suggestions. + +## Tools +- @search({"query": "..."}) or @search({"queries": ["Q1", "Q2"]}) — search knowledge base +- @query_rewrite({"query": "..."}) — rewrite query to improve search results +- @assess({"findings": [...], "lacks": [...], "sufficient": bool}) — (deep_research/troubleshooting/comparative_analysis only) record findings and gaps after searching. Optional but recommended for multi-step research. +- @summary({"reasoning": "..."}) — generate final answer from retrieved chunks + +## CRITICAL — Language Rule + +Your FINAL ANSWER must match the user's question language. \ +Your @search query language should target document language for maximum recall — \ +follow the [LANGUAGE DIRECTIVE] for the default, and adapt per-topic from search results. +If the user asks in Chinese, your final answer MUST be in Chinese — \ +even if you searched in other languages. + +${playbookContent}`; +} + +function buildSearchOnlySystemPrompt(playbookContent: string): string { + return `You are DiTing, an enterprise knowledge base search assistant. \ +Your task is to retrieve relevant knowledge base chunks. + +## Operation Mode: Search-Only + +You retrieve supplementary knowledge base chunks. The final answer is generated externally. + +**GLOBAL RULE: Do NOT call @summary. When your searches are complete, simply stop (make no more tool calls).** + +## STEP 0 — MANDATORY: Evaluate Search Hints Before Any Search + +**Execute this check FIRST. Do NOT call any tool before completing this evaluation.** + +The Search Hints (in the ## Search Hints section) contain information already retrieved from \ +external authoritative sources (SQL databases, APIs, external systems). \ +They are NOT documents to search for — they are pre-retrieved facts. + +**Ask yourself: Does the hint DIRECTLY and COMPLETELY answer the user's exact question?** + +- **YES → STOP IMMEDIATELY. Call no tools at all.** The hint is the answer; no KB search is needed. +- **NO → Proceed to the search steps in the scenario guidance below.** + +**Decision examples:** +- Question: "该产品最大并发连接数是多少" / Hint: "[文档] 最大并发连接数为10000" → **STOP** (the number is already provided) +- Question: "最近一周内新增的告警有哪些" / Hint: "[查询结果] 告警A, 告警B, 告警C" → **STOP** (the list is complete) +- Question: "X产品默认管理员账号是什么" / Hint: "alias: X产品=XProduct" → **CONTINUE** (alias mapping, not the answer) +- Question: "Y功能支持哪些部署模式" / Hint: "产品类型: Y功能, Y-Lite功能" → **CONTINUE** (supplementary context, not a complete list) + +## Core Rules +1. Search knowledge base only — do NOT answer from memory or chat history. +2. Language: SEARCH QUERIES should target document language for maximum recall. \ +Default language is in [LANGUAGE DIRECTIVE]. Adapt per-topic from search results. +3. Treat Search Hints as authoritative pre-retrieved facts, not as search queries. + +## Search Techniques +- **Parallel search**: @search({"queries": ["Q1", "Q2"]}) for independent queries. +- **Sequential search**: search prerequisite first, then decide next query from results. +- **Poor results**: use @query_rewrite to rephrase. Try from a different angle. +- **Alias hints**: if Search Hints provide alias mappings (e.g. "alias: A=B"), search with BOTH terms. +- Never repeat the exact same search query. +- Never use hint content verbatim as a search query. + +## Constraints +- @search calls ≤ 5 +- Do NOT call @summary + +${playbookContent}`; +} + +// ============================================================ +// RequestContext 注入工厂(供 nodes.ts 使用,闭包注入 context) +// ============================================================ + +/** + * Tool 定义 + */ +export interface AgentTool { + name: string; + description: string; + parameters: Record; +} + +/** + * 创建 search tool + */ +export function createSearchTool(_context: RequestContext): AgentTool { + return { + name: TOOLS.SEARCH, + description: + 'Search for relevant documents from the knowledge base. Input: { queries: string[], datasetIds: string[] }. Output: { chunks: ChunkItem[], queries: string[] }', + parameters: { + type: 'object', + properties: { + queries: { + type: 'array', + items: { type: 'string' }, + description: 'Search queries' + }, + datasetIds: { + type: 'array', + items: { type: 'string' }, + description: 'Dataset IDs to search in' + } + }, + required: ['queries', 'datasetIds'] + } + }; +} + +/** + * 创建 query_rewrite tool + */ +export function createQueryRewriteTool(_context: RequestContext): AgentTool { + return { + name: TOOLS.QUERY_REWRITE, + description: + 'Rewrite or translate user query for better search results. Input: { query: string }. Output: { queries: string[] }. Use when KB language differs from user language, or when initial search returns poor results.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Original user query' + } + }, + required: ['query'] + } + }; +} + +/** + * 创建 summary tool + */ +export function createAnswerTool(_context: RequestContext): AgentTool { + return { + name: TOOLS.SUMMARY, + description: + 'Generate answer from retrieved chunks. Input: { query: string, chunks: ChunkItem[] }. Output: { answer: string, citedIds: string[] }', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'User question' + }, + mode: { + type: 'string', + enum: ['search', 'chat'], + default: 'chat', + description: 'Mode: search returns chunks, chat generates answer' + } + }, + required: ['query'] + } + }; +} + +/** + * 创建 chunk_selector tool + */ +export function createChunkSelectorTool(_context: RequestContext): AgentTool { + return { + name: TOOLS.CHUNK_SELECTOR, + description: + 'Select most relevant chunks based on token budget. Input: { query: string, chunks: ChunkItem[], tokenBudget: number }. Output: { selectedChunks: ChunkItem[] }', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'User question' + }, + tokenBudget: { + type: 'number', + description: 'Token budget for selection', + default: 8000 + } + }, + required: ['query'] + } + }; +} + +/** + * 创建所有 tools + */ +export function createTools(context: RequestContext): AgentTool[] { + return [ + createSearchTool(context), + createQueryRewriteTool(context), + createAnswerTool(context), + createChunkSelectorTool(context) + ]; +} + +/** + * 转换为 LangChain/LangGraph 格式 + */ +export function toLangChainTools(context: RequestContext): Array<{ + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +}> { + return createTools(context).map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters + } + })); +} diff --git a/packages/diting-rag-ts/src/apps/server/index.ts b/packages/diting-rag-ts/src/apps/server/index.ts new file mode 100644 index 000000000000..efed946ecee9 --- /dev/null +++ b/packages/diting-rag-ts/src/apps/server/index.ts @@ -0,0 +1,213 @@ +// src/apps/server/index.ts +// HTTP Server - Hono-based REST API for Agentic Search + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { createAgenticSearch, type AgenticSearchResult } from '../../agent/runner'; +import type { AgenticSearchProviders, AgenticSearchConfig } from '../../ports/agentic'; + +/** + * Server options + */ +export interface ServerOptions { + port: number; + host: string; + providers?: AgenticSearchProviders; + config?: AgenticSearchConfig; +} + +/** + * API request body + */ +export interface SearchRequest { + query: string; + datasetIds: string[]; + history?: Array<{ role: string; content: string }>; + config?: Partial; +} + +/** + * API response + */ +export interface SearchResponse { + success: boolean; + data?: AgenticSearchResult; + error?: string; +} + +/** + * Create server app + */ +export function createApp(options: { + providers: AgenticSearchProviders; + config?: AgenticSearchConfig; +}) { + const app = new Hono(); + + // Middleware + app.use('*', logger()); + app.use( + '*', + cors({ + origin: '*', + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'] + }) + ); + + // Health check + app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + + // Create agentic search instance + const agentic = createAgenticSearch({ + providers: options.providers, + config: options.config + }); + + /** + * POST /v1/agentic/search - Synchronous search + */ + app.post('/v1/agentic/search', async (c) => { + try { + const body = await c.req.json(); + + if (!body.query || !body.datasetIds) { + return c.json( + { + success: false, + error: 'Missing required fields: query, datasetIds' + }, + 400 + ); + } + + const result = await agentic.invoke({ + query: body.query, + datasetIds: body.datasetIds, + history: body.history + }); + + return c.json({ + success: true, + data: result + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + 500 + ); + } + }); + + /** + * POST /v1/agentic/stream - Streaming search (SSE) + */ + app.post('/v1/agentic/stream', async (c) => { + try { + const body = await c.req.json(); + + if (!body.query || !body.datasetIds) { + return c.json( + { + success: false, + error: 'Missing required fields: query, datasetIds' + }, + 400 + ); + } + + // Set up SSE headers + c.header('Content-Type', 'text/event-stream'); + c.header('Cache-Control', 'no-cache'); + c.header('Connection', 'keep-alive'); + + // Create SSE stream + const eventStream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + try { + const asyncStream = agentic.stream({ + query: body.query, + datasetIds: body.datasetIds, + history: body.history + }); + + for await (const event of asyncStream) { + const data = `data: ${JSON.stringify(event)}\n\n`; + controller.enqueue(encoder.encode(data)); + } + } catch (e) { + const errorData = `data: ${JSON.stringify({ error: e instanceof Error ? e.message : 'Unknown error' })}\n\n`; + controller.enqueue(encoder.encode(errorData)); + } + controller.close(); + } + }); + + return c.body(eventStream); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + 500 + ); + } + }); + + /** + * GET /v1/agentic/playbooks - List available playbooks + */ + app.get('/v1/agentic/playbooks', (c) => { + return c.json({ + playbooks: [ + { name: 'simple_query', description: 'Simple single-turn query' }, + { name: 'comparative_analysis', description: 'Compare multiple options' }, + { name: 'troubleshooting', description: 'Troubleshoot problems' }, + { name: 'deep_research', description: 'Comprehensive research' }, + { name: 'followup_query', description: 'Follow-up on previous query' } + ] + }); + }); + + return app; +} + +/** + * Start server - returns the Hono app (user can run with any Node.js server) + */ +export async function startServer( + options: ServerOptions +): Promise<{ app: Hono; info: { port: number; host: string; url: string } }> { + const { createMockProviders } = await import('../../testing/index'); + + // Create default providers (should be replaced with real FastGPT providers) + const providers = + options.providers || + createMockProviders({ + modelName: 'qwen3-80b', + apiKey: process.env.FASTGPT_API_KEY || 'test', + endpoint: process.env.FASTGPT_ENDPOINT || 'http://localhost:3000' + }); + + const app = createApp({ providers, config: options.config }); + + const info = { + port: options.port, + host: options.host, + url: `http://${options.host}:${options.port}` + }; + + const logger = providers.logger; + logger?.info(`Starting server on ${info.url}...`); + logger?.info( + 'Endpoints: GET /health, POST /v1/agentic/search, POST /v1/agentic/stream, GET /v1/agentic/playbooks' + ); + + return { app, info }; +} diff --git a/packages/diting-rag-ts/src/builtIn/logger/consoleLogger.ts b/packages/diting-rag-ts/src/builtIn/logger/consoleLogger.ts new file mode 100644 index 000000000000..de8a1a21b526 --- /dev/null +++ b/packages/diting-rag-ts/src/builtIn/logger/consoleLogger.ts @@ -0,0 +1,72 @@ +// src/builtIn/logger/consoleLogger.ts + +import type { Logger, LoggerOptions } from '../../ports/logger'; +import { LogLevel } from '../../ports/logger'; + +export class ConsoleLogger implements Logger { + private level: LogLevel; + private prefix: string; + private timestampFormat: 'iso' | 'unix' | 'none'; + + constructor(options: LoggerOptions) { + this.level = options.level ?? LogLevel.INFO; + this.prefix = options.prefix ?? 'diting'; + this.timestampFormat = options.timestampFormat ?? 'iso'; + } + + private log(level: LogLevel, message: string, meta?: Record): void { + if (!this.isLevelEnabled(level)) return; + + const levelName = this.formatLevel(level); + const timestamp = this.formatTimestamp(); + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; + + console.log(`${levelName} ${timestamp} [${this.prefix}] ${message}${metaStr}`); + } + + private formatLevel(level: LogLevel): string { + const names: Record = { + [LogLevel.DEBUG]: 'Debug', + [LogLevel.INFO]: ' Info', + [LogLevel.WARN]: ' Warn', + [LogLevel.ERROR]: 'Error' + }; + return `[${names[level] ?? ' Info'}]`; + } + + private formatTimestamp(): string { + if (this.timestampFormat === 'none') return ''; + if (this.timestampFormat === 'unix') return String(Math.floor(Date.now() / 1000)); + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + } + + debug(message: string, meta?: Record): void { + this.log(LogLevel.DEBUG, message, meta); + } + + info(message: string, meta?: Record): void { + this.log(LogLevel.INFO, message, meta); + } + + warn(message: string, meta?: Record): void { + this.log(LogLevel.WARN, message, meta); + } + + error(message: string, meta?: Record): void { + this.log(LogLevel.ERROR, message, meta); + } + + isLevelEnabled(level: LogLevel): boolean { + return level >= this.level; + } + + child(bindings: Record): Logger { + return new ConsoleLogger({ + level: this.level, + prefix: `${this.prefix}:${JSON.stringify(bindings)}`, + timestampFormat: this.timestampFormat + }); + } +} diff --git a/packages/diting-rag-ts/src/builtIn/logger/index.ts b/packages/diting-rag-ts/src/builtIn/logger/index.ts new file mode 100644 index 000000000000..0892342362d9 --- /dev/null +++ b/packages/diting-rag-ts/src/builtIn/logger/index.ts @@ -0,0 +1,3 @@ +// src/builtIn/logger/index.ts + +export { ConsoleLogger } from './consoleLogger'; diff --git a/packages/diting-rag-ts/src/config/index.ts b/packages/diting-rag-ts/src/config/index.ts new file mode 100644 index 000000000000..656a9f9407ae --- /dev/null +++ b/packages/diting-rag-ts/src/config/index.ts @@ -0,0 +1,6 @@ +// src/config/index.ts +// Configuration Module - 统一导出 + +export * from './schema'; +export * from './settings'; +export * from './loader'; diff --git a/packages/diting-rag-ts/src/config/loader.ts b/packages/diting-rag-ts/src/config/loader.ts new file mode 100644 index 000000000000..9dd309dba511 --- /dev/null +++ b/packages/diting-rag-ts/src/config/loader.ts @@ -0,0 +1,294 @@ +// src/config/loader.ts +// Configuration Loader - 加载 .env 和 config.yaml,环境变量优先 +// 兼容 Python 版 (.env) 和 TypeScript 版的环境变量 + +import * as fs from 'fs/promises'; +import * as yaml from 'js-yaml'; +import * as dotenv from 'dotenv'; +import type { + Config, + LLMConfig, + EmbeddingConfig, + RerankerConfig, + VectorDBConfig, + FullTextDBConfig, + FastGPTConfig, + AgentConfig +} from './schema'; +import { + getDefaultLLM, + getDefaultEmbedding, + getDefaultReranker, + getDefaultAgent, + getDefaultPGVector, + getDefaultMilvus, + getDefaultMongoDB, + getDefaultFastGPT +} from './settings'; + +/** + * 从环境变量加载 LLM 配置 + */ +function loadLLMFromEnv(): LLMConfig { + const defaults = getDefaultLLM(); + return { + base_url: defaults.BASE_URL, + api_key: defaults.API_KEY, + model: defaults.MODEL, + max_tokens: defaults.MAX_TOKENS, + timeout: defaults.TIMEOUT + }; +} + +/** + * 从环境变量加载 Embedding 配置 + */ +function loadEmbeddingFromEnv(): EmbeddingConfig { + const defaults = getDefaultEmbedding(); + return { + base_url: defaults.BASE_URL, + api_key: defaults.API_KEY, + model: defaults.MODEL, + dimension: defaults.DIMENSION, + timeout: defaults.TIMEOUT + }; +} + +/** + * 从环境变量加载 Reranker 配置 + */ +function loadRerankerFromEnv(): RerankerConfig | undefined { + const defaults = getDefaultReranker(); + if (!defaults) { + return undefined; + } + return { + base_url: defaults.BASE_URL, + api_key: defaults.API_KEY, + model: defaults.MODEL, + top_n: defaults.TOP_N, + timeout: defaults.TIMEOUT + }; +} + +/** + * 从环境变量加载向量数据库配置 + */ +function loadVectorDBFromEnv(): VectorDBConfig { + const type = (process.env.VECTOR_DB as 'pgvector' | 'milvus') || 'pgvector'; + const pg = getDefaultPGVector(); + const milvus = getDefaultMilvus(); + + if (type === 'pgvector') { + return { + type: 'pgvector', + pg_url: process.env.PG_URL || pg.URL, + pg_host: process.env.PG_HOST || pg.HOST, + pg_port: parseInt(process.env.PG_PORT || '', 10) || pg.PORT, + pg_database: process.env.PG_DATABASE || pg.DATABASE, + pg_user: process.env.PG_USER || pg.USER, + pg_password: process.env.PG_PASSWORD || pg.PASSWORD, + milvus_database: milvus.DATABASE + }; + } else { + return { + type: 'milvus', + pg_host: pg.HOST, + pg_port: pg.PORT, + pg_database: pg.DATABASE, + pg_user: pg.USER, + pg_password: pg.PASSWORD, + milvus_address: process.env.MILVUS_ADDRESS || milvus.ADDRESS, + milvus_token: process.env.MILVUS_TOKEN || milvus.TOKEN, + milvus_database: process.env.MILVUS_DATABASE || milvus.DATABASE + }; + } +} + +/** + * 从环境变量加载全文检索数据库配置 + */ +function loadFullTextDBFromEnv(): FullTextDBConfig { + const type = (process.env.FULLTEXT_DB as 'mongodb' | 'opensearch') || 'mongodb'; + const mongodb = getDefaultMongoDB(); + + return { + type, + url: process.env.MONGODB_URL || mongodb.URL, + database: process.env.MONGODB_DATABASE || mongodb.DATABASE + }; +} + +/** + * 从环境变量加载 FastGPT 配置 + */ +function loadFastGPTFromEnv(): FastGPTConfig | undefined { + const defaults = getDefaultFastGPT(); + if (!defaults.BASE_URL || defaults.BASE_URL === 'http://fastgpt.default:3000') { + // 只有当 FASTGPT_API_BASE 明确配置时才启用 + if (!process.env.FASTGPT_API_BASE && !process.env.FASTGPT_API_KEY) { + return undefined; + } + } + return { + base_url: process.env.FASTGPT_API_BASE || defaults.BASE_URL, + api_key: process.env.FASTGPT_API_KEY || defaults.API_KEY + }; +} + +/** + * 从环境变量加载 Agent 配置 + */ +function loadAgentFromEnv(): AgentConfig { + const defaults = getDefaultAgent(); + return { + search_mode: defaults.SEARCH_MODE, + max_search_rounds: defaults.MAX_SEARCH_ROUNDS, + max_tool_calls: defaults.MAX_TOOL_CALLS, + embedding_weight: defaults.EMBEDDING_WEIGHT, + similarity: defaults.SIMILARITY, + rerank_top_k: defaults.RERANK_TOP_K, + retrieve_limit: defaults.RETRIEVE_LIMIT + }; +} + +/** + * 从 YAML 文件加载配置 + */ +async function loadFromYAML(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return yaml.load(content) as Partial; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`Failed to load YAML config from ${filePath}:`, error); + } + return {}; + } +} + +/** + * 深度合并对象 + */ +function deepMerge>(target: T, source: Partial): T { + const result = { ...target }; + for (const key in source) { + if (source[key] !== undefined) { + if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { + result[key] = deepMerge( + (result[key] as Record) || {}, + source[key] as Record + ) as T[Extract]; + } else { + result[key] = source[key] as T[Extract]; + } + } + } + return result; +} + +/** + * 加载配置 + * 优先级: YAML > 环境变量 > 默认值 + */ +export async function loadConfig(configFile?: string): Promise { + // 加载 .env + dotenv.config(); + + // 基础配置(环境变量覆盖默认值) + const baseConfig: Config = { + llm: loadLLMFromEnv(), + embedding: loadEmbeddingFromEnv(), + reranker: loadRerankerFromEnv(), + vector_db: loadVectorDBFromEnv(), + fulltext_db: loadFullTextDBFromEnv(), + fastgpt: loadFastGPTFromEnv(), + agent: loadAgentFromEnv() + }; + + // 加载 YAML 配置 + const yamlConfig = configFile + ? await loadFromYAML(configFile) + : await loadFromYAML('./config.yaml'); + + // 合并配置 (YAML > env) + let merged = baseConfig; + if (yamlConfig.llm) merged = deepMerge(merged, { llm: yamlConfig.llm } as Partial); + if (yamlConfig.embedding) + merged = deepMerge(merged, { embedding: yamlConfig.embedding } as Partial); + if (yamlConfig.reranker) + merged = deepMerge(merged, { reranker: yamlConfig.reranker } as Partial); + if (yamlConfig.vector_db) + merged = deepMerge(merged, { vector_db: yamlConfig.vector_db } as Partial); + if (yamlConfig.fulltext_db) + merged = deepMerge(merged, { fulltext_db: yamlConfig.fulltext_db } as Partial); + if (yamlConfig.fastgpt) + merged = deepMerge(merged, { fastgpt: yamlConfig.fastgpt } as Partial); + if (yamlConfig.agent) merged = deepMerge(merged, { agent: yamlConfig.agent } as Partial); + + return merged; +} + +/** + * 从配置创建 Provider 所需的参数 + */ +export function createProviderConfig(config: Config) { + return { + llm: { + endpoint: config.llm.base_url, + apiKey: config.llm.api_key, + model: config.llm.model, + timeout: config.llm.timeout + }, + embed: { + endpoint: config.embedding.base_url, + apiKey: config.embedding.api_key, + model: config.embedding.model, + dimension: config.embedding.dimension, + timeout: config.embedding.timeout + }, + reranker: config.reranker + ? { + endpoint: config.reranker.base_url, + apiKey: config.reranker.api_key, + model: config.reranker.model, + topN: config.reranker.top_n, + timeout: config.reranker.timeout + } + : undefined, + vectorSearch: + config.vector_db.type === 'pgvector' + ? { + connectionString: config.vector_db.pg_url, + host: config.vector_db.pg_host, + port: config.vector_db.pg_port, + database: config.vector_db.pg_database, + user: config.vector_db.pg_user, + password: config.vector_db.pg_password + } + : { + address: config.vector_db.milvus_address || 'localhost:19530', + token: config.vector_db.milvus_token, + database: config.vector_db.milvus_database + }, + fullTextSearch: { + url: config.fulltext_db.url, + database: config.fulltext_db.database + }, + fastgpt: config.fastgpt + ? { + baseUrl: config.fastgpt.base_url, + apiKey: config.fastgpt.api_key + } + : undefined, + agent: { + searchMode: config.agent.search_mode, + maxSearchRounds: config.agent.max_search_rounds, + maxToolCalls: config.agent.max_tool_calls, + embeddingWeight: config.agent.embedding_weight, + similarity: config.agent.similarity, + rerankTopK: config.agent.rerank_top_k, + retrieveLimit: config.agent.retrieve_limit + } + }; +} diff --git a/packages/diting-rag-ts/src/config/schema.ts b/packages/diting-rag-ts/src/config/schema.ts new file mode 100644 index 000000000000..6672063b921c --- /dev/null +++ b/packages/diting-rag-ts/src/config/schema.ts @@ -0,0 +1,118 @@ +// src/config/schema.ts +// Configuration Schema - 使用 zod 定义配置验证 + +import z from 'zod'; + +/** + * LLM 配置 + */ +export const LLMConfigSchema = z.object({ + base_url: z.string().url().default('http://fastgpt-aiproxy.default:3000/v1'), + api_key: z.string().optional(), + model: z.string().default('Qwen3-Next-80B-A3B-Instruct-FP8'), + max_tokens: z.number().positive().default(8192), + timeout: z.number().positive().default(120000) +}); + +export type LLMConfig = z.infer; + +/** + * Embedding 配置 + */ +export const EmbeddingConfigSchema = z.object({ + base_url: z.string().url().default('http://fastgpt-aiproxy.default:3000/v1'), + api_key: z.string().optional(), + model: z.string().default('bge-m3'), + dimension: z.number().positive().default(1024), + timeout: z.number().positive().default(60000) +}); + +export type EmbeddingConfig = z.infer; + +/** + * Reranker 配置 + */ +export const RerankerConfigSchema = z.object({ + base_url: z.string().url().default('http://fastgpt-aiproxy.default:3000/v1'), + api_key: z.string().optional(), + model: z.string().default('bge-reranker-large'), + top_n: z.number().positive().default(10), + timeout: z.number().positive().default(60000) +}); + +export type RerankerConfig = z.infer; + +/** + * 向量数据库配置 + */ +export const VectorDBConfigSchema = z.object({ + type: z.enum(['pgvector', 'milvus']).default('pgvector'), + // PGVector 配置 + pg_url: z.string().optional(), + pg_host: z.string().default('localhost'), + pg_port: z.number().positive().default(5432), + pg_database: z.string().default('postgres'), + pg_user: z.string().default('postgres'), + pg_password: z.string().default('password'), + // Milvus 配置 + milvus_address: z.string().optional(), + milvus_token: z.string().optional(), + milvus_database: z.string().default('default') +}); + +export type VectorDBConfig = z.infer; + +/** + * 全文检索数据库配置 + */ +export const FullTextDBConfigSchema = z.object({ + type: z.enum(['mongodb', 'opensearch']).default('mongodb'), + // MongoDB 配置 + url: z.string().default('mongodb://localhost:27017'), + database: z.string().default('fastgpt') + // OpenSearch 配置 (预留) + // opensearch_url: z.string().optional(), + // opensearch_index: z.string().optional(), +}); + +export type FullTextDBConfig = z.infer; + +/** + * FastGPT 配置(检索服务) + */ +export const FastGPTConfigSchema = z.object({ + base_url: z.string().default('http://localhost:3000'), + api_key: z.string().optional() +}); + +export type FastGPTConfig = z.infer; + +/** + * Agent 配置 + */ +export const AgentConfigSchema = z.object({ + search_mode: z.enum(['embedding', 'fullTextRecall', 'mixedRecall']).default('mixedRecall'), + max_search_rounds: z.number().positive().default(5), + max_tool_calls: z.number().positive().default(10), + embedding_weight: z.number().min(0).max(1).default(0.5), + similarity: z.number().min(0).max(1).default(0.0), + rerank_top_k: z.number().positive().default(20), + retrieve_limit: z.number().positive().default(50) +}); + +export type AgentConfig = z.infer; + +/** + * 完整配置 + */ +export const ConfigSchema = z.object({ + llm: LLMConfigSchema.default(LLMConfigSchema.parse({})), + embedding: EmbeddingConfigSchema.default(EmbeddingConfigSchema.parse({})), + reranker: RerankerConfigSchema.optional(), + vector_db: VectorDBConfigSchema.default(VectorDBConfigSchema.parse({})), + fulltext_db: FullTextDBConfigSchema.default(FullTextDBConfigSchema.parse({})), + fastgpt: FastGPTConfigSchema.optional(), + agent: AgentConfigSchema.default(AgentConfigSchema.parse({})) +}); + +export type Config = z.infer; diff --git a/packages/diting-rag-ts/src/config/settings.ts b/packages/diting-rag-ts/src/config/settings.ts new file mode 100644 index 000000000000..145cdd3434df --- /dev/null +++ b/packages/diting-rag-ts/src/config/settings.ts @@ -0,0 +1,130 @@ +// src/config/settings.ts +// Global default settings - 支持从环境变量覆盖 + +/** + * 获取默认 LLM 设置(环境变量覆盖默认值) + * 兼容: LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_MAX_TOKENS, LLM_TIMEOUT + * 兼容: OPENAI_BASE_URL, OPENAI_API_KEY + */ +export function getDefaultLLM() { + const baseUrl = + process.env.LLM_BASE_URL || + process.env.OPENAI_BASE_URL || + 'http://fastgpt-aiproxy.default:3000/v1'; + const apiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY; + const model = process.env.LLM_MODEL || 'Qwen3-Next-80B-A3B-Instruct-FP8'; + + return { + BASE_URL: baseUrl, + MODEL: model, + MAX_TOKENS: parseInt(process.env.LLM_MAX_TOKENS || '', 10) || 8192, + TIMEOUT: parseInt(process.env.LLM_TIMEOUT || '', 10) || 120000, + API_KEY: apiKey + }; +} + +/** + * 获取默认 Embedding 设置 + * 兼容: EMBEDDING_BASE_URL, EMBEDDING_MODEL, EMBEDDING_API_KEY + */ +export function getDefaultEmbedding() { + const baseUrl = process.env.EMBEDDING_BASE_URL || 'http://fastgpt-aiproxy.default:3000/v1'; + const apiKey = process.env.EMBEDDING_API_KEY; + const model = process.env.EMBEDDING_MODEL || 'bge-m3'; + + return { + BASE_URL: baseUrl, + MODEL: model, + DIMENSION: parseInt(process.env.EMBEDDING_DIMENSION || '', 10) || 1024, + TIMEOUT: parseInt(process.env.EMBEDDING_TIMEOUT || '', 10) || 60000, + API_KEY: apiKey + }; +} + +/** + * 获取默认 Reranker 设置 + * 兼容: RERANKER_BASE_URL, RERANKER_MODEL, RERANKER_API_KEY, RERANKER_TOP_K + */ +export function getDefaultReranker(): + | { BASE_URL: string; MODEL: string; TOP_N: number; TIMEOUT: number; API_KEY?: string } + | undefined { + const hasConfig = + process.env.RERANKER_BASE_URL || process.env.RERANKER_MODEL || process.env.RERANKER_API_KEY; + + if (!hasConfig) { + return undefined; + } + + return { + BASE_URL: process.env.RERANKER_BASE_URL || 'http://fastgpt-aiproxy.default:3000/v1', + MODEL: process.env.RERANKER_MODEL || 'bge-reranker-large', + TOP_N: parseInt(process.env.RERANKER_TOP_K || '', 10) || 20, + TIMEOUT: parseInt(process.env.RERANKER_TIMEOUT || '', 10) || 60000, + API_KEY: process.env.RERANKER_API_KEY + }; +} + +/** + * 获取默认 PGVector 设置 + */ +export function getDefaultPGVector() { + return { + HOST: process.env.PG_HOST || 'fastgpt-pgvector.default', + PORT: parseInt(process.env.PG_PORT || '', 10) || 5432, + DATABASE: process.env.PG_DATABASE || 'postgres', + USER: process.env.PG_USER || 'postgres', + PASSWORD: process.env.PG_PASSWORD || 'password', + TABLE_NAME: process.env.PG_TABLE_NAME || 'dataset_vectors', + VECTOR_DIMENSION: parseInt(process.env.PG_VECTOR_DIMENSION || '', 10) || 1536, + URL: process.env.PG_URL + }; +} + +/** + * 获取默认 Milvus 设置 + */ +export function getDefaultMilvus() { + return { + ADDRESS: process.env.MILVUS_ADDRESS || 'localhost:19530', + DATABASE: process.env.MILVUS_DATABASE || 'default', + COLLECTION_NAME: process.env.MILVUS_COLLECTION || 'dataset_vectors', + VECTOR_DIMENSION: parseInt(process.env.MILVUS_VECTOR_DIMENSION || '', 10) || 1536, + TOKEN: process.env.MILVUS_TOKEN + }; +} + +/** + * 获取默认 MongoDB 设置 + * 兼容: MONGODB_URL, MONGODB_DATABASE + */ +export function getDefaultMongoDB() { + return { + URL: process.env.MONGODB_URL || 'mongodb://fastgpt-mongodb.default:27017', + DATABASE: process.env.MONGODB_DATABASE || 'fastgpt' + }; +} + +export function getDefaultAgent() { + return { + SEARCH_MODE: + (process.env.AGENTIC_SEARCH_MODE as 'embedding' | 'fullTextRecall' | 'mixedRecall') || + 'mixedRecall', + MAX_SEARCH_ROUNDS: parseInt(process.env.AGENTIC_MAX_SEARCH_ROUNDS || '', 10) || 50, + MAX_TOOL_CALLS: parseInt(process.env.AGENTIC_MAX_TOOL_CALLS || '', 10) || 100, + EMBEDDING_WEIGHT: parseFloat(process.env.AGENTIC_EMBEDDING_WEIGHT || '') || 0.6, + SIMILARITY: parseFloat(process.env.AGENTIC_SIMILARITY || '') || 0.0, + RERANK_TOP_K: parseInt(process.env.AGENTIC_RERANK_TOP_K || '', 10) || 12, + RETRIEVE_LIMIT: parseInt(process.env.AGENTIC_RETRIEVE_LIMIT || '', 10) || 30 + }; +} + +/** + * 获取 FastGPT 配置(用于检索) + */ +export function getDefaultFastGPT() { + return { + BASE_URL: + process.env.FASTGPT_API_BASE || process.env.FASTGPT_BASE_URL || 'http://fastgpt.default:3000', + API_KEY: process.env.FASTGPT_API_KEY + }; +} diff --git a/packages/diting-rag-ts/src/index.ts b/packages/diting-rag-ts/src/index.ts new file mode 100644 index 000000000000..a7d9c25e1c42 --- /dev/null +++ b/packages/diting-rag-ts/src/index.ts @@ -0,0 +1,20 @@ +// packages/diting-rag-ts/src/index.ts +// 主包导出 + +// Types +export * from './types/index'; + +// Ports (interfaces) +export * from './ports/index'; +export * from './ports/agentic'; + +// Utils +export * from './utils/index'; + +// Agent +export { createAgenticSearch, AGENT_EVENTS } from './agent/runner'; +export type { AgenticSearchResult, CreateAgenticSearchOptions } from './agent/runner'; +export type { AgentEvent } from './agent/context'; + +// Testing +export * from './testing/index'; diff --git a/packages/diting-rag-ts/src/ports/agentic.ts b/packages/diting-rag-ts/src/ports/agentic.ts new file mode 100644 index 000000000000..da941ed18856 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/agentic.ts @@ -0,0 +1,46 @@ +// src/ports/agentic.ts +// Agentic Search 统一 Provider 配置 + +import type { LLMProvider } from './llm'; +import type { VectorSearchProvider, FullTextSearchProvider, MixedSearchProvider } from './search'; +import type { EmbeddingProvider } from './embedding'; +import type { RerankProvider } from './reranker'; +import type { Logger } from './logger'; + +/** + * Agentic Search 所需的所有 Provider + */ +export interface AgenticSearchProviders { + llm: LLMProvider; + vectorSearch: VectorSearchProvider; + fullTextSearch: FullTextSearchProvider; + mixedSearch?: MixedSearchProvider; + embed: EmbeddingProvider; + reranker?: RerankProvider; + logger?: Logger; +} + +/** + * Agentic Search 内部配置 + */ +export interface AgenticSearchConfig { + searchMode?: 'embedding' | 'fullTextRecall' | 'mixedRecall'; + maxSearchRounds?: number; + maxToolCalls?: number; + maxSearchCalls?: number; + maxIterations?: number; + tokenBudget?: number; + embeddingWeight?: number; + similarity?: number; + rerankTopK?: number; + retrieveLimit?: number; + answerMaxChunks?: number; + answerMaxTokens?: number; + enableShowReferences?: boolean; + enableInfoGain?: boolean; + infoGainThreshold?: number; + infoGainMaxHistory?: number; + searchOnly?: boolean; + /** 权限过滤:用户无权访问的 collection ID 列表,将合并到 forbidCollectionIds */ + forbidCollectionIds?: string[]; +} diff --git a/packages/diting-rag-ts/src/ports/config.ts b/packages/diting-rag-ts/src/ports/config.ts new file mode 100644 index 000000000000..a149fedfb474 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/config.ts @@ -0,0 +1,96 @@ +// src/ports/config.ts + +import { LogLevel } from './logger'; + +export interface DitingConfig { + logger?: { + level?: LogLevel; + prefix?: string; + }; + search?: { + defaultSearchMode?: 'vector' | 'fulltext' | 'mixed'; + fallback?: FallbackStrategy; + retry?: RetryConfig; + tokenBudget?: number; + }; + llm?: { + defaultModel?: string; + timeout?: number; + maxRetries?: number; + }; + embedding?: { + batchSize?: number; + timeout?: number; + }; +} + +export interface FallbackStrategy { + vectorToFulltext?: boolean; + fulltextToVector?: boolean; + mixedFallbackMode?: 'vector' | 'fulltext'; + llmFallbackMode?: 'error' | 'skip' | 'default'; +} + +export interface RetryConfig { + maxRetries: number; + retryDelay: number; + retryBackoff: 'linear' | 'exponential'; + retryableErrors?: string[]; +} + +export const DEFAULT_FALLBACK_STRATEGY: FallbackStrategy = { + vectorToFulltext: true, + fulltextToVector: true, + mixedFallbackMode: 'vector', + llmFallbackMode: 'error' +}; + +export const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + retryDelay: 1000, + retryBackoff: 'exponential', + retryableErrors: ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'] +}; + +export const DEFAULT_CONFIG: DitingConfig = { + logger: { + level: LogLevel.INFO, + prefix: 'diting' + }, + search: { + defaultSearchMode: 'mixed', + fallback: DEFAULT_FALLBACK_STRATEGY, + retry: DEFAULT_RETRY_CONFIG, + tokenBudget: 4000 + }, + llm: { + timeout: 60000, + maxRetries: 3 + }, + embedding: { + batchSize: 100, + timeout: 30000 + } +}; + +export function mergeConfig(base: DitingConfig, override: Partial): DitingConfig { + return { + logger: { ...base.logger, ...override.logger }, + search: { + ...base.search, + ...override.search, + fallback: { + ...DEFAULT_FALLBACK_STRATEGY, + ...base.search?.fallback, + ...override.search?.fallback + }, + retry: { + ...DEFAULT_RETRY_CONFIG, + ...base.search?.retry, + ...override.search?.retry + } + }, + llm: { ...base.llm, ...override.llm }, + embedding: { ...base.embedding, ...override.embedding } + }; +} diff --git a/packages/diting-rag-ts/src/ports/embedding.ts b/packages/diting-rag-ts/src/ports/embedding.ts new file mode 100644 index 000000000000..26d28de7782b --- /dev/null +++ b/packages/diting-rag-ts/src/ports/embedding.ts @@ -0,0 +1,25 @@ +// src/ports/embedding.ts +// Embedding Provider 接口 + +/** + * Embedding 结果 + */ +export interface EmbedResult { + vectors: number[][]; + tokens: number; +} + +/** + * Embedding 接口 + */ +export interface EmbeddingProvider { + /** + * 文本向量化 + */ + embed(texts: string[]): Promise; + + /** + * 获取模型信息 + */ + getModelInfo(): { name: string; dimension: number }; +} diff --git a/packages/diting-rag-ts/src/ports/index.ts b/packages/diting-rag-ts/src/ports/index.ts new file mode 100644 index 000000000000..733da6961760 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/index.ts @@ -0,0 +1,30 @@ +// src/ports/index.ts +// 接口统一导出 + +export type { LLMProvider } from './llm'; +export { wrapProviderWithDefaults, resolveLLMCallOptions } from './llm'; +export type { EmbeddingProvider, EmbedResult } from './embedding'; +export type { RerankProvider, RerankResult } from './reranker'; +export type { + VectorSearchProvider, + VectorSearchOptions, + VectorSearchFilter, + FullTextSearchProvider, + FullTextSearchOptions, + FullTextSearchFilter, + MixedSearchProvider, + MixedSearchOptions, + WebSearchProvider, + WebSearchOptions, + SearchResult +} from './search'; +export { createSearchResult } from './search'; +export type { Logger, LoggerOptions } from './logger'; +export { LogLevel, createLogger, createLoggerFromInstance } from './logger'; +export type { DitingConfig, FallbackStrategy, RetryConfig } from './config'; +export { + DEFAULT_CONFIG, + DEFAULT_FALLBACK_STRATEGY, + DEFAULT_RETRY_CONFIG, + mergeConfig +} from './config'; diff --git a/packages/diting-rag-ts/src/ports/llm.ts b/packages/diting-rag-ts/src/ports/llm.ts new file mode 100644 index 000000000000..8ba902dc2582 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/llm.ts @@ -0,0 +1,57 @@ +// src/ports/llm.ts +// LLM Provider 接口 + +import type { LLMMessage, LLMResponse, LLMCallOptions } from '../types/message'; +import { DEFAULT_LLM_CALL_OPTIONS } from '../types/message'; + +/** + * LLM 推理接口 + */ +export interface LLMProvider { + /** + * 同步调用 LLM + */ + chat(messages: LLMMessage[], options?: LLMCallOptions): Promise; + + /** + * 流式调用 LLM + */ + chatStream(messages: LLMMessage[], options?: LLMCallOptions): AsyncIterable; + + /** + * 获取模型信息 + */ + getModelInfo(): { name: string; contextWindow: number; maxOutputTokens: number }; +} + +/** + * 包装 LLMProvider,自动为所有 chat/chatStream 调用注入默认选项。 + * 确保外部传入的任何 provider 都以 enableThinking=false 的默认行为运行。 + */ +export function wrapProviderWithDefaults(provider: LLMProvider): LLMProvider { + return { + ...provider, + chat(messages: LLMMessage[], options?: LLMCallOptions): Promise { + return provider.chat(messages, resolveLLMCallOptions(options)); + }, + chatStream(messages: LLMMessage[], options?: LLMCallOptions): AsyncIterable { + return provider.chatStream(messages, resolveLLMCallOptions(options)); + } + }; +} + +/** + * 合并调用方 options 与 diting-rag-ts 默认选项。 + * 调用方显式字段优先,extra 为浅合并。 + */ +export function resolveLLMCallOptions(options?: LLMCallOptions): LLMCallOptions { + if (!options) return { ...DEFAULT_LLM_CALL_OPTIONS }; + return { + ...DEFAULT_LLM_CALL_OPTIONS, + ...options, + extra: { + ...DEFAULT_LLM_CALL_OPTIONS.extra, + ...(options.extra as Record) + } + }; +} diff --git a/packages/diting-rag-ts/src/ports/logger.ts b/packages/diting-rag-ts/src/ports/logger.ts new file mode 100644 index 000000000000..9bf3fed992fb --- /dev/null +++ b/packages/diting-rag-ts/src/ports/logger.ts @@ -0,0 +1,37 @@ +// src/ports/logger.ts + +import { ConsoleLogger } from '../builtIn/logger/consoleLogger'; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3 +} + +export interface Logger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; + isLevelEnabled(level: LogLevel): boolean; + child(bindings: Record): Logger; +} + +export interface LoggerOptions { + level: LogLevel; + prefix?: string; + timestampFormat?: 'iso' | 'unix' | 'none'; +} + +export function createLogger(options?: Partial): Logger { + return new ConsoleLogger({ + level: options?.level ?? LogLevel.INFO, + prefix: options?.prefix ?? 'AgenticRAG', + timestampFormat: options?.timestampFormat ?? 'iso' + }); +} + +export function createLoggerFromInstance(instance: Logger): Logger { + return instance; +} diff --git a/packages/diting-rag-ts/src/ports/reranker.ts b/packages/diting-rag-ts/src/ports/reranker.ts new file mode 100644 index 000000000000..a7fa10359e94 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/reranker.ts @@ -0,0 +1,18 @@ +// src/ports/reranker.ts +// Reranker Provider 接口 + +import type { ChunkResult } from '../types/chunk'; + +/** + * Rerank 结果 + */ +export interface RerankResult extends ChunkResult { + rerankScore: number; +} + +/** + * Reranker 接口 + */ +export interface RerankProvider { + rerank(query: string, chunks: ChunkResult[]): Promise; +} diff --git a/packages/diting-rag-ts/src/ports/search.ts b/packages/diting-rag-ts/src/ports/search.ts new file mode 100644 index 000000000000..34fe4135cf60 --- /dev/null +++ b/packages/diting-rag-ts/src/ports/search.ts @@ -0,0 +1,168 @@ +// src/ports/search.ts +// 搜索 Provider 接口 + +import type { ChunkResult } from '../types/chunk'; +import type { EmbeddingProvider } from './embedding'; + +/** + * 向量检索过滤器 + */ +export interface VectorSearchFilter { + collectionIds?: string[]; + forbidCollectionIds?: string[]; + text?: string; + providerFilters?: Record; +} + +/** + * 向量检索选项 + */ +export interface VectorSearchOptions { + limit: number; + similarity?: number; + filter?: VectorSearchFilter; + enableExactSearch?: boolean; + extraParams?: Record; +} + +/** + * 向量检索接口 + */ +export interface VectorSearchProvider { + search( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise>; +} + +/** + * 全文检索过滤器 + */ +export interface FullTextSearchFilter { + collectionIds?: string[]; + forbidCollectionIds?: string[]; + tags?: string[]; + dateRange?: { start: string; end: string }; + providerFilters?: Record; +} + +/** + * 全文检索选项 + */ +export interface FullTextSearchOptions { + limit: number; + enableFuzzy?: boolean; + enablePhraseMatch?: boolean; + filter?: FullTextSearchFilter; + extraParams?: Record; +} + +/** + * 全文检索接口 + */ +export interface FullTextSearchProvider { + search( + query: string, + datasetIds: string[], + options: FullTextSearchOptions + ): Promise>; +} + +/** + * 混合检索选项 + */ +export interface MixedSearchOptions { + limit: number; + vectorWeight?: number; + fullTextWeight?: number; + rerankAfterMerge?: boolean; + filter?: VectorSearchFilter & FullTextSearchFilter; +} + +/** + * 混合检索接口 + */ +export interface MixedSearchProvider { + search( + query: string, + datasetIds: string[], + options: MixedSearchOptions, + embeddingOrVectors: { + vectors?: number[][]; + embeddingProvider?: EmbeddingProvider; + } + ): Promise>; +} + +/** + * Web 检索选项 + */ +export interface WebSearchOptions { + limit: number; + site?: string; + lang?: string; + safeSearch?: boolean; + extraParams?: Record; +} + +/** + * Web 检索接口 + */ +export interface WebSearchProvider { + search(query: string, options: WebSearchOptions): Promise>; +} + +/** + * 检索结果包装 + */ +export interface SearchResult { + chunks: T[]; + error?: { + code: string; + message: string; + retryable: boolean; + details?: Record; + }; + meta: { + searchSource: 'vector' | 'fulltext' | 'mixed' | 'web'; + provider: string; + duration: number; + totalTokens?: number; + rawResponse?: unknown; + queryAnalytics?: { + originalQuery: string; + rewrittenQuery?: string; + subQueries?: string[]; + }; + }; +} + +/** + * 工具函数:将 ChunkResult[] 转换为 SearchResult + * 用于兼容旧的 provider 实现 + */ +export function createSearchResult( + chunks: ChunkResult[], + searchSource: 'vector' | 'fulltext' | 'mixed' | 'web', + provider: string, + options?: { + error?: SearchResult['error']; + rawResponse?: unknown; + totalTokens?: number; + } +): SearchResult { + const startTime = Date.now(); + + return { + chunks, + error: options?.error, + meta: { + searchSource, + provider, + duration: Date.now() - startTime, + totalTokens: options?.totalTokens, + rawResponse: options?.rawResponse + } + }; +} diff --git a/packages/diting-rag-ts/src/prompts/atomic/chunk_selector_prompts.ts b/packages/diting-rag-ts/src/prompts/atomic/chunk_selector_prompts.ts new file mode 100644 index 000000000000..95fc3b910f9c --- /dev/null +++ b/packages/diting-rag-ts/src/prompts/atomic/chunk_selector_prompts.ts @@ -0,0 +1,476 @@ +// src/skills/atomic/chunk_selector_prompts.ts +// Chunk Selector Prompts — 对齐 Python chunk_selector_prompts.py +// Stage 2: 6 种 playbook-aware prompts +// Stage 1: sub-query filter prompt + +/** + * Stage 2 — General Purpose (copied from legacy RERANKER_V1_PROMPT) + */ +export const RERANKER_GENERAL_PROMPT = `# ⚡ QUICK REFERENCE ⚡ +Role: Technical Knowledge Base Document Relevance Evaluator v1.0 +Mission: Precisely score retrieved documents for their ability to satisfy user queries +Scoring Range: 0-10 (integers only) +Key Rule: Score based on ACTUAL HELPFULNESS, not keyword matching + +# CORE MISSION +You are a technical knowledge base document evaluator. Assign precise integer scores (0-10) based on how well a document satisfies the user's actual need. + +## 🎯 EVALUATION CONTEXT + +### User Query Analysis +**Query**: {user_query}{question_analysis_section} + +### Document to Evaluate +**Document Content**: +{doc_content} + + +## ⚠️ QUERY TYPE AWARENESS — Read This First + +Before scoring, identify the query type: + +**Type A — Troubleshooting / How-to-fix** +- Signals: error codes, "not working", "failed", "how to fix/resolve/configure" +- What scores high: document provides actionable solution with specific steps + +**Type B — Informational / Conceptual / Overview** +- Signals: "what is", "how does X work", "compare X to Y", "features of", "highlights of", "overview", "explain", "introduction to" +- What scores high: document provides comprehensive, accurate information covering the topic +- ⚠️ Do NOT penalize these documents for lacking step-by-step troubleshooting instructions +- ⚠️ IMPORTANT: For broad queries ("what are the features/highlights of X"), a comprehensive product overview IS the perfect answer — score it 8-9, NOT 3-5 + +**Type C — Simple Factual / Lookup** +- Signals: "is it true that...", "can X still work...", "does X support...", "list of X", "X functions/commands list", "what is the [specific value/status]" +- What scores high: document directly states the fact, contains the exact list, or answers the yes/no question with explanation +- ⚠️ Do NOT require step-by-step instructions for factual queries — a concise factual answer scores 9-10 +- ⚠️ Do NOT penalize documents for being short if they directly answer the question + +## 📋 SCORING FRAMEWORK (0-10 Integer Scale) + +### 🎯 Score 9-10: PERFECT MATCH +**For Type A (Troubleshooting)** — ALL must be met: +✓ Directly addresses the EXACT problem in user query +✓ Provides complete, actionable solution with specific steps or commands +✓ No additional information needed + +**For Type B (Informational/Overview)** — ALL must be met: +✓ Directly covers the exact topic asked about +✓ Provides comprehensive, accurate information +✓ User's question is substantially answered by this document + +**For Type C (Factual/Lookup)** — ALL must be met: +✓ Directly states the fact or contains the exact list/data requested +✓ User gets a clear answer without needing other documents + +### 🎯 Score 7-8: HIGHLY RELEVANT +✓ Addresses the core need with minor gaps +✓ For Type A: 80-95% of steps present +✓ For Type B: covers the topic well with some gaps +✓ For Type C: contains the answer but with some ambiguity or incompleteness + +### 🎯 Score 5-6: MODERATELY RELEVANT +✓ Related to the same technology area +✓ Addresses similar but not identical need +✓ For Type A: provides partial solution or diagnostic steps +✓ For Type B: touches on the topic but lacks depth +✓ For Type C: related information but doesn't directly answer the factual question +✓ 50-70% of needed information present + +### 🎯 Score 3-4: TANGENTIALLY RELEVANT +✓ Related technology but different problem/topic area +✓ May provide background knowledge with little direct relevance +✓ 20-40% of needed information present + +### 🎯 Score 1-2: MINIMALLY RELEVANT +✓ Mentions the technology in passing +✓ Different problem domain +✓ <20% of needed information + +### 🎯 Score 0: IRRELEVANT +✗ Different technology area +✗ No overlap with user's problem +✗ Completely unrelated topic + +## ⚠️ CRITICAL SCORING RULES + +### MANDATORY Requirements +✓ **Integer scores ONLY** (0, 1, 2, ..., 10) - NO decimals + +### FORBIDDEN Scoring Mistakes +✗ Giving 3-5 to a broad overview document when the query explicitly asks for "features/highlights/overview" — that document IS the answer +✗ Requiring step-by-step instructions for factual/lookup queries +✗ Penalizing a factual answer for being concise +✗ Scoring high just because keywords match +✗ Using decimal scores (7.5, 8.2, etc.) + +## 🎯 SCORING DECISION TREE + +**START**: Identify query type, then read document + +**Step 0**: Query type? +- Type C (factual/lookup) → Skip to Step 1C +- Type B (informational/overview/conceptual) → Skip to Step 1B +- Type A (troubleshooting/how-to-fix) → Continue to Step 1A + +**[Type A path]** +**Step 1A**: Problem type match? +- NO → Score 0-4 +- PARTIAL → Score 3-6 +- YES → Continue to Step 2A + +**Step 2A**: Solution provided? +- NO (background only) → Score 3-5 +- PARTIAL (diagnostic steps) → Score 5-7 +- YES (complete solution) → Continue to Step 3A + +**Step 3A**: Technical specificity? +- GENERIC → Score 5-7 +- SPECIFIC (exact steps/commands) → Score 7-9 +- PERFECT (exact match with details) → Score 9-10 + +**[Type B path]** +**Step 1B**: Topic match? +- NO → Score 0-4 +- PARTIAL → Score 3-6 +- YES → Continue to Step 2B + +**Step 2B**: Is the query broad/general (e.g., "features of X", "highlights of X", "overview of X")? +- YES (broad query) + document is a comprehensive overview of that topic → Score 8-9 +- YES (broad query) + document covers only partial aspects → Score 5-7 +- NO (specific query) + document comprehensively covers the specific topic → Score 8-9 +- NO (specific query) + document partially covers → Score 5-7 +- Document barely touches the topic → Score 3-5 + +**[Type C path]** +**Step 1C**: Does the document contain the answer to the factual question or the requested list/data? +- NO → Score 0-3 +- PARTIALLY → Score 4-6 +- YES, directly states the answer → Score 7-9 +- YES, perfectly and completely → Score 9-10 + +## 📊 OUTPUT FORMAT + + +## EXAMPLES + +### Example 1: Type A — High Score (9) +Query: EPP agent installation fails with error code 0x80070005 +Document: +How to resolve EPP agent installation error 0x80070005? Error 0x80070005 indicates permission issue. Solution: 1) Run installer as Administrator 2) Disable antivirus temporarily 3) Check system requirements... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +9 + +### Example 2: Type A — Medium Score (5) +Query: HCI VM backup failed +Document: +HCI snapshot management best practices. Create snapshots regularly for data protection. Configure retention policies... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +5 + +### Example 3: Type B — High Score (8) — Broad feature query matched to overview doc +Query: What are the key features of Sangfor HCI? +Document: +Sangfor HCI product overview and key features. Sangfor HCI is a hyperconverged infrastructure solution with the following key features: 1) Integrated compute, storage, and networking 2) Built-in high availability with automatic failover 3) Distributed storage with deduplication and compression 4) Centralized management console... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +8 + +### Example 4: Type B — Comparison — High Score (9) +Query: How does Sangfor HCI compare to other HCI products? +Document: +Sangfor HCI vs competitors: feature and architecture comparison. Sangfor HCI integrates compute, storage, and networking in a single platform. Compared to VMware vSAN, Sangfor offers lower licensing costs and tighter integration with its own security products. Compared to Nutanix, Sangfor provides... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +9 + +### Example 5: Type C — High Score (9) — License/behavior factual query +Query: can scmt work even though license expire +Document: +SCMT behavior after license expiration. After the SCMT license expires, the system enters a grace period of 30 days during which all functions remain operational. After the grace period, monitoring and alerting features are disabled but basic management functions are still accessible... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +9 + +### Example 6: Type C — High Score (9) — Lookup query +Query: skyops functions list +Document: +SkyOPS available functions and commands reference. SkyOPS supports the following functions: 1) cluster_status - check cluster health 2) node_info - display node details 3) vm_list - list all virtual machines 4) storage_check - verify storage pool status... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +9 + +### Example 7: Type C — Low Score (2) — Wrong domain +Query: can scmt work even though license expire +Document: +How to activate NGFW license. Navigate to System > License Management and enter your activation code... + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration): +2 + +## 🎯 FINAL REMINDERS + +**Before scoring, ask yourself**: +1. What TYPE is this query? (A=troubleshooting, B=informational/overview, C=factual/lookup) +2. For Type B broad queries ("features of X", "highlights of X"): is this document an overview of that product/feature? If yes, score 7-9. +3. For Type C factual queries: does this document directly state the answer? If yes, score 7-9. Do NOT require step-by-step instructions. +4. For Type A: does it provide a complete actionable solution? + +**Calibration guard**: +- If the query asks "what are the features/highlights of X" and the document IS the feature overview of X → score 8-9, NOT 5 +- If the query asks "can X do Y" and the document directly answers that question → score 8-9, NOT 3 + +## CURRENT TASK +Query: {user_query}{question_analysis_section} + +Document: +{doc_content} + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 2 — Simple Query (factual & informational only, no troubleshooting) + */ +export const RERANKER_SIMPLE_PROMPT = `# Simple Query Document Relevance Evaluator +Mission: Score documents for short, direct queries — factual lookups, informational overviews, or brief how-to/error-code requests. + +## EVALUATION CONTEXT + +**Query**: {user_query}{question_analysis_section} + +→ Step 1: Identify the query type (A, B, or C) from the query and analysis above. +→ Step 2: Apply the corresponding scoring criteria below. + +### Document to Evaluate +**Document Content**: +{doc_content} + +## QUERY TYPE AWARENESS + +**Type A — Simple How-to / Error-code** +- Signals: "how to", "steps to", "configure", "set up", or bare error codes / status codes (e.g., "0x80070005", "error 403", "code -1") +- What scores high: document provides clear steps, configuration procedure, or a direct resolution to the error +- ⚠️ Concise step-by-step guides score 9-10; do NOT require lengthy background explanations + +**Type B — Informational / Overview** +- Signals: "what is", "how does X work", "features of", "highlights of", "overview", "explain" +- What scores high: comprehensive, accurate information covering the topic +- ⚠️ For broad queries ("features/highlights of X"), a product overview IS the perfect answer — score 8-9 + +**Type C — Factual / Lookup** +- Signals: "is it true that...", "can X do...", "does X support...", "list of X", "what is the value" +- What scores high: document directly states the fact or contains the exact list +- ⚠️ Concise factual answers score 9-10; do NOT penalize for brevity + +## SCORING FRAMEWORK (0-10 Integer Scale) + +Score 9-10 (PERFECT): Directly and completely answers the query; no additional sources needed +Score 7-8 (HIGHLY RELEVANT): Covers the core topic well, minor gaps +Score 5-6 (MODERATELY RELEVANT): Related topic but incomplete coverage +Score 3-4 (TANGENTIAL): Related technology but different topic +Score 1-2 (MINIMAL): Mentions the topic in passing +Score 0 (IRRELEVANT): Different domain entirely + +## CRITICAL RULES +✓ Integer scores ONLY (0-10) +✗ Do NOT require step-by-step instructions for factual queries +✗ Do NOT penalize short documents if they directly answer the question +✗ Do NOT score low just because a document is an overview (for overview queries, this IS high value) +✗ Do NOT require lengthy explanation for a short how-to/error-code query — direct steps or direct fix = 9-10 + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 2 — Comparative Analysis (entity A vs entity B) + */ +export const RERANKER_COMPARISON_PROMPT = `# Comparative Analysis Document Relevance Evaluator +Mission: Score documents for queries comparing two subjects/products/technologies. + +## EVALUATION CONTEXT + +**Query**: {user_query}{question_analysis_section} + +→ Step 1: From the query and analysis above, identify Entity A and Entity B. +→ Step 2: Check whether this document covers A, B, or both. +→ Step 3: Apply the scoring framework below. + +### Document to Evaluate +**Document Content**: +{doc_content} + +## SCORING FRAMEWORK (0-10 Integer Scale) + +Score 9-10: Both A and B covered with direct comparison/contrast content (differences, trade-offs, feature matrix) +Score 8: Both A and B present, no explicit comparison (reader can infer differences) +Score 6-7: Only ONE of A or B, comprehensively covered (still valuable — forms one side of comparison) +Score 4-5: Only one entity, partially covered +Score 2-3: Mentions one entity in passing; little useful content +Score 0-1: Neither A nor B; irrelevant + +## CRITICAL RULES +✓ Integer scores ONLY (0-10) +✗ Do NOT score 0-3 for a document that comprehensively covers ONE entity — score 6-7 instead +Note: "Both present" (score 8) beats "one entity comprehensive" (score 6-7). A document covering only ONE entity scores at most 7, never 8+. + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 2 — Troubleshooting (actionable solutions, error resolution) + */ +export const RERANKER_TROUBLESHOOTING_PROMPT = `# Troubleshooting Document Relevance Evaluator +Mission: Score documents for error resolution and step-by-step fix queries. + +## EVALUATION CONTEXT + +**Query**: {user_query}{question_analysis_section} + +→ Step 1: From the query and analysis, extract the specific error code, symptom, or product module. +→ Step 2: Check if this document addresses that specific issue. +→ Step 3: Apply the scoring framework. + +### Document to Evaluate +**Document Content**: +{doc_content} + +## SCORING FRAMEWORK (0-10 Integer Scale) + +Score 9-10: Exact problem match + complete actionable solution steps (commands, config, procedure) +Score 7-8: Problem match + partial solution or solid diagnostic steps +Score 5-6: Related module/product, useful diagnostic info but no direct fix +Score 3-4: Related technology area, minimal direct relevance to the specific error +Score 1-2: Different problem domain; mentions the product in passing +Score 0: Completely different technology; irrelevant + +## CRITICAL RULES +✓ Integer scores ONLY (0-10) +Note: A document with diagnostic steps (even without final solution) scores 5-6, NOT 2-3. +✗ Do NOT require a complete solution to score 7+; solid diagnosis + partial fix = 7-8 +✗ Do NOT penalize a document for covering a related but distinct error if it provides useful diagnostic context + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 2 — Deep Research (multi-dimensional, sub-dimension coverage) + */ +export const RERANKER_DEEP_RESEARCH_PROMPT = `# Deep Research Document Relevance Evaluator +Mission: Score documents for in-depth research queries requiring multi-dimensional coverage. + +## EVALUATION CONTEXT + +**Query**: {user_query}{question_analysis_section} + +→ Step 1: From the "Searched sub-dimensions" list (if present in analysis above), identify + which sub-dimension(s) this document covers. If no list, infer from the topic. +→ Step 2: Evaluate depth of coverage for that sub-dimension. +→ Step 3: Apply the scoring framework. + +### Document to Evaluate +**Document Content**: +{doc_content} + +## SCORING FRAMEWORK (0-10 Integer Scale) + +Score 8-9: Covers 1+ sub-dimensions with comprehensive, in-depth content (mechanisms, architecture, data) +Score 5-7: Covers a sub-dimension but partially or at a high level (overview without depth) +Score 3-4: Tangentially related to the topic; no clear sub-dimension match +Score 1-2: Mentions the subject area in passing; no substantive content +Score 0: Unrelated to the topic or sub-dimensions + +## CRITICAL RULES +✓ Integer scores ONLY (0-10) +Remember: Each chunk only needs to cover ONE dimension well, not all dimensions. +✗ Do NOT require a document to cover ALL sub-dimensions to score high +✗ Do NOT penalize specialization — a highly detailed document on one sub-dimension scores 8-9 + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 2 — Follow-up Query (continuation of multi-turn conversation) + */ +export const RERANKER_FOLLOWUP_PROMPT = `# Follow-up Query Document Relevance Evaluator +Mission: Score documents for follow-up questions in a multi-turn conversation. + +## EVALUATION CONTEXT + +**Query**: {user_query}{question_analysis_section} + +→ Step 1: From the analysis (if provided), identify (a) what the follow-up is specifically asking for, + (b) what domain/topic was discussed before. + If no analysis is available, infer the most likely topic from the follow-up query text alone. +→ Step 2: Score based on both dimensions: + - Does this document answer the follow-up question? + - Is it within the original conversation domain (or the most plausible domain implied)? + +### Document to Evaluate +**Document Content**: +{doc_content} + +## SCORING FRAMEWORK (0-10 Integer Scale) + +Score 9-10: Directly answers the follow-up AND is within the original topic domain +Score 7-8: Answers the follow-up but tangential to original domain; or within domain but indirect answer +Score 5-6: Within original domain but doesn't directly address the follow-up question +Score 3-4: Tangentially related to either the follow-up or original topic +Score 1-2: Minimal relevance to either dimension +Score 0: Unrelated to both the follow-up and the original topic + +## CRITICAL RULES +✓ Integer scores ONLY (0-10) +✗ Do NOT require both dimensions to score 7+; strong answer to the follow-up alone scores 7-8 +✗ Do NOT penalize documents for not knowing the conversation history +✗ When analysis context is absent: infer the most plausible topic and score accordingly; + do NOT default to 0-3 merely because conversation history is missing — a plausibly relevant + document scores 5-6 or higher + +Output(After taking a deep breath and carefully considering, output the score directly without any additional elaboration):`; + +/** + * Stage 1 — Sub-query Focused Filter Prompt + */ +export const RERANKER_SUB_QUERY_PROMPT = `# Sub-query Relevance Filter + +## Overall Context +User's original question: {user_query}{question_analysis_section} + +## Current Search Goal +The agent is NOW searching for information about: {sub_query_text} + +## Document to Evaluate +{doc_content} + +## Your Task +Score how well this document supports answering the CURRENT SEARCH GOAL above. + +Important rules: +- The "Current Search Goal" is the primary criterion. The overall question provides context only. +- A document that perfectly covers the search goal scores 8-10, even if it doesn't cover the full original question. +- A document relevant to the original question but NOT to the current search goal scores 1-4. + +Scoring scale: +8-10: Directly and substantially addresses the current search goal with useful information +5-7: Partially addresses the search goal, provides some useful evidence +2-4: Related to the topic domain but doesn't help with the specific search goal +0-1: Unrelated to the search goal + +Output format: integer 0-10 only. +Output(After taking a deep breath and carefully considering the CURRENT SEARCH GOAL, output the score directly without any additional elaboration):`; + +/** + * Stage 2 prompts dispatch dict + * "" (empty playbook) falls back to RERANKER_GENERAL_PROMPT + */ +export const RERANKER_PROMPTS: Record = { + simple_query: RERANKER_SIMPLE_PROMPT, + comparative_analysis: RERANKER_COMPARISON_PROMPT, + troubleshooting: RERANKER_TROUBLESHOOTING_PROMPT, + deep_research: RERANKER_DEEP_RESEARCH_PROMPT, + followup_query: RERANKER_FOLLOWUP_PROMPT +}; + +/** + * Get Stage 2 prompt by playbook name (with fallback) + */ +export function getRerankPrompt(playbook: string): string { + return RERANKER_PROMPTS[playbook] || RERANKER_GENERAL_PROMPT; +} diff --git a/packages/diting-rag-ts/src/prompts/playbooks/index.ts b/packages/diting-rag-ts/src/prompts/playbooks/index.ts new file mode 100644 index 000000000000..2086fe9cd22e --- /dev/null +++ b/packages/diting-rag-ts/src/prompts/playbooks/index.ts @@ -0,0 +1,280 @@ +// src/prompts/playbooks/index.ts +// Playbook 提示词定义 + +import { + RERANKER_SUB_QUERY_PROMPT, + RERANKER_GENERAL_PROMPT, + RERANKER_SIMPLE_PROMPT, + RERANKER_COMPARISON_PROMPT, + RERANKER_TROUBLESHOOTING_PROMPT, + RERANKER_DEEP_RESEARCH_PROMPT, + RERANKER_FOLLOWUP_PROMPT +} from '../atomic/chunk_selector_prompts'; + +export interface PlaybookDef { + name: string; + description: string; + detail: string; +} + +/** + * Playbook 定义 + */ +export const playbooks: Record = { + simple_query: { + name: 'simple_query', + description: + 'Direct factual query with clear intent. Single answer expected. Use when question is specific and focused.', + detail: `## Scenario Guidance: Direct Query + +This question is focused and clear — a direct search should retrieve the answer. + +**MUST follow these steps:** +1. STOP. You MUST use @search to retrieve information before answering. +2. Search the original query directly. +3. **If your search returned a directly relevant result (you can see the answer clearly) → call @summary IMMEDIATELY. Do NOT call @assess.** +4. If the first search returned poor or no results: use @query_rewrite ONCE to rephrase, then search again. +5. Do NOT answer from memory or make up information. + +**Output Rule:** +- You MUST use @summary tool to generate final response (do NOT write answer yourself). +- Keep the answer focused — do not over-elaborate for simple facts. +- Do NOT use @assess — it is not needed for direct queries.` + }, + + comparative_analysis: { + name: 'comparative_analysis', + description: + 'Compare multiple objects. Analyze differences and similarities. Use when question involves comparing A vs B.', + detail: `## Scenario Guidance: Comparison Query (Comparative Analysis) + +This question involves comparing multiple objects (e.g., A vs B vs C). Each object needs \ +separate information before a synthesis is possible. + +### Two-Phase Execution + +**Phase 1: Discovery (探索阶段)** +- Objective: Confirm all comparison objects exist in knowledge base, discover comparison dimensions +- First, use @query_rewrite(strategies="decompose") to identify comparison objects +- Then search each object with a basic query (e.g., "What is product A", "What is product B") +- From the search results, automatically extract comparison dimensions: + - If user explicitly mentions dimensions (e.g., "feature differences", "architecture differences") → use those + - Otherwise, infer dimensions from results: definition, features, architecture, \ + use cases, pricing, performance, limitations, etc. + +**Phase 2: Enrichment (深度阶段)** +- After dimensions are discovered, decompose by dimension: "product A features", "product B features", "product A architecture", "product B architecture" +- Search each dimension separately. Use parallel search when possible. +- Continue until all dimensions have been searched at least once + +### Handling Different Scenarios + +1. **User provides explicit dimensions** (e.g., "What are the feature differences between A and B?"): + → Skip discovery phase, directly search "A features", "B features" + +2. **User provides NO explicit dimensions** (e.g., "What are the differences between A, B, and C?"): + → Must go through discovery phase to discover dimensions first + +3. **Some objects not found in knowledge base**: + → If an object returns no relevant results, note it and inform user in final answer + +### Termination Criteria + +DO NOT stop just because you have "enough" chunks. Continue until: +- All discovered dimensions have been searched at least once +- AND either: (a) search budget exhausted, OR (b) consecutive 2 searches have low info gain + +**Critical**: A dimension is "covered" only after you've searched it, not after you have N chunks. + +### Progressive Assessment (After Each @search) + +**Optionally, call @assess to annotate chunks with critical comparison data:** +- key_chunks: chunks containing key comparison facts +- findings/lacks: recorded in blackboard for context, but do NOT require exhaustive coverage +- You do NOT need to fill every lack before calling @summary + +### Output Rule: +- You MUST use @summary tool to generate final response +- Structure the comparison as a table or organized sections by dimension +- Clearly state if any object was not found in knowledge base +- Do NOT answer from memory or make up comparison details` + }, + + troubleshooting: { + name: 'troubleshooting', + description: + 'Debug errors, fix problems, resolve issues. Use when question involves errors, failures, or troubleshooting.', + detail: `## Scenario Guidance: Depth-First Query (Troubleshooting) + +This is a diagnostic scenario. Each search step may depend on results from the previous one. + +**MUST follow these steps:** +1. STOP. Do NOT answer directly — you need troubleshooting information. +2. Use @search to find the error message or symptom keywords. +3. Evaluate results: + - Found troubleshooting docs → use @summary with structured diagnosis. + - Insufficient → use @query_rewrite (try par or gqr strategy) and search again. + - Multiple possible causes → search each cause's solution separately. +4. Answer structure: Symptom → Possible causes → Diagnostic steps → Solutions. +5. If information is insufficient, list known clues and suggest user provide more details. + +**Optional — @assess for chunk annotation:** +You MAY call @assess to annotate chunks containing key diagnostic steps or solutions (key_chunks). +Call @summary when you have the main diagnostic information — you do not need exhaustive coverage. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Do NOT answer from memory or guess solutions.` + }, + + deep_research: { + name: 'deep_research', + description: + 'Comprehensive coverage needed. List all items, multiple aspects. Use when question needs complete listing (versions, features, products) or multi-dimension analysis.', + detail: `## Scenario Guidance: Comprehensive Query (Deep Research) + +This question requires comprehensive, multi-angle analysis or complete listing. + +**MUST follow these steps:** +1. STOP. Do NOT answer directly — you need comprehensive research. +2. Use @query_rewrite(strategies="decompose") to break the research question into sub-questions. +3. Search ONLY the sub-questions returned by @query_rewrite using parallel @search. Do NOT add extra ad-hoc queries beyond what @query_rewrite returned. +4. Assess coverage: which sub-questions still lack information? +5. For under-covered sub-questions, use @query_rewrite ONCE more to rephrase, then search again. +6. When you have covered the main aspects, call @summary immediately with clear section structure. + +**Optional — @assess for chunk annotation:** +After searching, you MAY call @assess to annotate the most valuable chunks (key_chunks). +This helps preserve critical information in the final answer. +You do NOT need to fill every lack before answering — call @summary with what you have. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Do NOT answer from memory — base everything on retrieved information. +- Ensure complete coverage of all items/aspects requested.` + }, + + followup_query: { + name: 'followup_query', + description: + 'Continuation of previous conversation. Clarify, refine, or expand on prior answer. Use when question refers to previous context.', + detail: `## Scenario Guidance: Follow-up Query + +This is a follow-up question based on conversation history — user wants to \ +refine, reformat, clarify, or expand on the previous answer. + +**When to use this playbook:** +- User asks to reformat (table, concise, bullet points) +- User asks for more details or clarification +- User asks "why", "how", "what about X" +- User uses pronouns: "it", "this", "that", "these" +- User references previous context: "before mentioned", "earlier" + +**MUST follow these steps:** +1. STOP. Review conversation history and previous answer/chunks first. +2. Check what has been searched before (avoid duplicate searches): + - Review ctx.search_queries for previous successful searches + - Review ctx.all_chunks for previously retrieved content +3. Decide: does this need a new @search? + - Format-only request (table, concise, bullet points) → NO search needed + - New information request → use @search (but avoid duplicate queries) + - Clarification on existing content → use previous chunks or @query_rewrite +4. If reusing previous chunks, cite them properly. +5. Use @summary to generate the refined response. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- If no new search needed, pass previous chunks to @summary for reformatting. +- Always cite sources from current or previous turns.` + }, + + general: { + name: 'general', + description: + "General query requiring analysis first. Use when the question doesn't fit other specific categories.", + detail: `## Scenario Guidance: Analyze First + +Analyze the question structure to determine the query type \ +(Direct / Depth-First / Breadth-First / Compound), then plan your search accordingly. + +**MUST follow these steps:** +1. STOP. Do NOT answer directly. +2. Use @search to get initial results. +3. Based on result quality and question complexity, decide next steps. +4. Use @query_rewrite flexibly to explore different search angles. +5. When sufficient information is collected, use @summary to generate response. + +**Output Rule:** +- You MUST use @summary tool to generate final response. +- Do NOT write answer yourself — always use @summary tool.` + } +}; + +/** + * Router 规则 + */ +export interface RouterRule { + playbook: string; + triggers: string[]; + anti_triggers: string[]; +} + +export const routerRules: RouterRule[] = [ + { + playbook: 'simple_query', + triggers: ['是什么', '怎么', '如何', '多少', '什么', 'what', 'how', 'why'], + anti_triggers: [] + }, + { + playbook: 'troubleshooting', + triggers: ['error', 'fail', 'not working', 'fix', '解决', '报错', '故障'], + anti_triggers: [] + }, + { + playbook: 'comparative_analysis', + triggers: ['compare', 'vs', 'versus', 'difference', '对比', '区别', '比较'], + anti_triggers: [] + }, + { + playbook: 'deep_research', + triggers: ['有哪些', 'all', 'list', 'every', '所有', '全部', '哪些'], + anti_triggers: [] + }, + { + playbook: 'followup_query', + triggers: ['then', 'next', 'also', 'what about', 'how about', '那', '那么', '还有'], + anti_triggers: [] + } +]; + +/** + * Chunk Selector prompts(对齐 Python chunk_selector_prompts.py) + * Stage 1: sub-query filter prompt + * Stage 2: playbook-aware prompts + */ +export const chunkSelectorPrompts: Record = { + general: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_GENERAL_PROMPT + }, + simple_query: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_SIMPLE_PROMPT + }, + comparative_analysis: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_COMPARISON_PROMPT + }, + troubleshooting: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_TROUBLESHOOTING_PROMPT + }, + deep_research: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_DEEP_RESEARCH_PROMPT + }, + followup_query: { + stage1: RERANKER_SUB_QUERY_PROMPT, + stage2: RERANKER_FOLLOWUP_PROMPT + } +}; diff --git a/packages/diting-rag-ts/src/prompts/query_rewrite/index.ts b/packages/diting-rag-ts/src/prompts/query_rewrite/index.ts new file mode 100644 index 000000000000..f80a9370d163 --- /dev/null +++ b/packages/diting-rag-ts/src/prompts/query_rewrite/index.ts @@ -0,0 +1,327 @@ +// src/prompts/query_rewrite/index.ts +// Query Rewrite 策略定义(对齐 Python 版) + +export interface RewriteStrategySpec { + name: string; + display_name: string; + description: string; + prompt_template: string; + applicable_when: string; + examples: Array<{ input: string; output: string }>; + priority: number; + exclusive_group: string; + enabled: boolean; + parallel_search: boolean; +} + +/** + * 旧版兼容类型(用于 prompt_loader.ts) + */ +export interface RewriteStrategyDef { + name: string; + description: string; +} + +/** + * 默认策略规范(6种,对齐 Python 版) + */ +export const DEFAULT_STRATEGIES: RewriteStrategySpec[] = [ + { + name: 'cce', + display_name: 'Core Content Extraction', + description: + 'Extract the core question from a query that contains excessive background information', + applicable_when: + 'Query is too long, with the core question buried under extensive background description', + prompt_template: + 'Extract the core question from the following verbose query, removing unnecessary background description:\nQuery: {query}', + examples: [ + { + input: + 'Our company recently started evaluating system upgrades. We need to know whether the load balancer supports session persistence so that users logging into the application are not redirected to different backend servers', + output: 'load balancer session persistence configuration' + } + ], + priority: 10, + exclusive_group: 'extraction', + enabled: true, + parallel_search: true + }, + { + name: 'decompose', + display_name: 'Query Decomposition', + description: + 'Decompose a compound query into multiple sub-queries with dependency analysis. Supports parallel/sequential execution marking and comparison dimension discovery.', + applicable_when: + 'Query involves multiple aspects, requires comparing multiple objects, or has chain dependencies', + prompt_template: `Decompose the following compound query into sub-queries. Analyze dependencies between sub-queries. + + Query: {query} + + Instructions: + - Identify independent sub-queries that can be searched in parallel + - Identify dependent sub-queries where one depends on results from another + - Group sub-queries into steps; assign parallel=true for independent groups + - Use depends_on to indicate which step a group depends on + - Add a brief note explaining each step's purpose + + **IMPORTANT for comparison queries**: + - If the query asks for comparison (e.g., "A和B的区别", "A vs B"), also identify: + - compare_objects: list of objects being compared (e.g., ["product A", "product B"]) + - explicit_dimensions: dimensions explicitly mentioned by user (e.g., ["features", "architecture"]) + - implicit_dimensions: dimensions you infer should be compared (e.g., ["definition", "use cases"]) + - When no explicit dimensions given, first round should focus on discovering dimensions: + - Round 1 queries: basic "What is X" for each object to understand what aspects exist + + Return as JSON: + {{ + "rewrites": [ + {{"strategy": "decompose", "queries": ["sub-query 1", "sub-query 2"], "parallel": true, "step": 1, "note": "purpose of this step"}}, + {{"strategy": "decompose", "queries": ["follow-up query"], "parallel": false, "depends_on": 1, "step": 2, "note": "purpose"}} + ], + "compare_objects": ["A", "B"], // only for comparison queries + "explicit_dimensions": ["features"], // dimensions mentioned in user query + "implicit_dimensions": ["definition", "use cases"] // dimensions inferred to compare + }}`, + examples: [ + { + input: + 'What are the differences between Product A and Product B in terms of security features?', + output: `{"rewrites": [{"strategy": "decompose", "queries": ["Product A security features", "Product B security features"], "parallel": true, "step": 1, "note": "Gather security feature info for each product"}], "compare_objects": ["Product A", "Product B"], "explicit_dimensions": ["security features"], "implicit_dimensions": ["deployment options", "pricing", "performance"]}` + }, + { + input: 'What are the differences between product X, product Y, and product Z?', + output: `{"rewrites": [{"strategy": "decompose", "queries": ["What is product X", "What is product Y", "What is product Z"], "parallel": true, "step": 1, "note": "Discover basic info and comparison dimensions for each product"}, {"strategy": "decompose", "queries": [], "parallel": true, "depends_on": 1, "step": 2, "note": "Search each dimension for all products in parallel"}], "compare_objects": ["product X", "product Y", "product Z"], "explicit_dimensions": [], "implicit_dimensions": ["definition", "features", "use cases", "architecture"]}` + }, + { + input: 'What are the features of each product in the platform?', + output: `{"rewrites": [{"strategy": "decompose", "queries": ["product list"], "parallel": false, "step": 1, "note": "Get product list first"}, {"strategy": "decompose", "queries": [], "parallel": true, "depends_on": 1, "step": 2, "note": "Search features for each product in parallel"}]}` + } + ], + priority: 20, + exclusive_group: 'decomposition', + enabled: true, + parallel_search: true + }, + { + name: 'gqr', + display_name: 'General Query Refinement', + description: + 'Remove noise, resolve ambiguity, complete omitted information to produce a clearer query', + applicable_when: 'Query contains colloquial expressions, noise, or ambiguous intent', + prompt_template: + 'Refine the following query into a clearer, unambiguous expression while preserving the original intent, DONOT output the similar query as the original query:\nOriginal: {query}', + examples: [ + { + input: 'that port forwarding thing', + output: 'What is the port forwarding configuration method?' + }, + { + input: "where's the backup setting on the management page?", + output: 'system management console backup configuration location' + } + ], + priority: 15, + exclusive_group: 'generation', + enabled: true, + parallel_search: true + }, + { + name: 'kwr', + display_name: 'Keyword Extraction', + description: 'Extract core keyword combinations suitable for search engine retrieval', + applicable_when: + 'Query is a natural language sentence that needs to be converted to keywords for retrieval matching', + prompt_template: + 'Extract core keyword combinations from the following query, separated by spaces::\nQuery: {query}', + examples: [ + { + input: 'how to allow specific application traffic through the security gateway', + output: 'security gateway application traffic policy allow' + }, + { + input: + 'how to configure automatic snapshots for virtual machines on the virtualization platform', + output: 'virtualization platform VM automated snapshot backup' + } + ], + priority: 5, + exclusive_group: 'extraction', + enabled: true, + parallel_search: true + }, + { + name: 'par', + display_name: 'Pseudo-Answer Retrieval', + description: + 'Generate a hypothetical answer as a query (HyDE approach) to bridge the semantic gap between query and documents', + applicable_when: + 'Semantic gap exists between the query and knowledge base documents, e.g., user asks informally but documents use technical terminology', + prompt_template: `1. First, generate a brief hypothetical answer to the user's question (even if uncertain, make reasonable guesses) + 2. Then, extract 3-8 factual keywords from the hypothetical answer for actual search + Keywords should be: product names, version numbers, error codes, technical terms. DO NOT copy full sentences. + + User question: {query} + + Output JSON format (keywords must be in the same language as the user's question): {{"rewrites": [{{"strategy": "par", "queries": ["keyword1 keyword2 keyword3"]}}]}}`, + examples: [ + { + input: '系统默认管理员密码是什么', + output: '{{"rewrites": [{{"strategy": "par", "queries": ["系统 默认密码 admin 管理员"]}}]}}' + }, + { + input: 'what is the default admin password for the management system', + output: + '{{"rewrites": [{{"strategy": "par", "queries": ["management system default password admin"]}}]}}' + } + ], + priority: 8, + exclusive_group: 'generation', + enabled: true, + parallel_search: true + }, + { + name: 'step_back', + display_name: 'Step-Back Abstraction', + description: "Elevate the query's abstraction level to find broader background knowledge", + applicable_when: 'A specific question requires more general background knowledge to answer', + prompt_template: + 'Transform the following specific question into a broader, more abstract query:\nQuery: {query}', + examples: [ + { + input: '产品X v2.1 如何配置远程访问隧道', + output: '产品X 远程访问隧道 配置方法' + }, + { + input: 'Product Y v3.5 IPSec tunnel configuration steps', + output: 'Product Y IPSec tunnel architecture and configuration methods' + }, + { + input: 'Agent Client v2.0 installation failure on Linux', + output: 'Agent Client installation methods and common issues' + } + ], + priority: 12, + exclusive_group: 'abstraction', + enabled: true, + parallel_search: true + }, + { + name: 'translate_to', + display_name: 'Cross-Language Query Translation', + description: + "Translate the search intent from the user's language into the KB document language. Produces natural queries in the target language.", + applicable_when: + 'KB document language differs from user question language (e.g., user asks in Arabic, KB is Chinese)', + prompt_template: + 'The user asked in {sourceLang}. The KB documents are in {targetLang}.\nTranslate the SEARCH INTENT into natural {targetLang} queries that match how documents are written.\nDo NOT word-for-word translate — rephrase as a native {targetLang} speaker would search.\n\nOriginal ({sourceLang}): {query}\n\nReturn as JSON:\n{{"rewrites": [{{"strategy": "translate_to", "queries": ["{targetLang} query 1", "{targetLang} query 2"], "parallel": true}}]}}', + examples: [ + { + input: 'ผู้เขียนหนังสือสามก๊ก', + output: + '{"rewrites": [{"strategy": "translate_to", "queries": ["三国演义 作者", "三国演义 谁写的"], "parallel": true}]}' + } + ], + priority: 20, + exclusive_group: 'translate', + enabled: true, + parallel_search: true + } +]; + +/** + * 旧版兼容导出(用于 prompt_loader.ts) + */ +export const rewriteStrategies: Record = { + cce: { name: 'cce', description: 'Core Content Extraction' }, + decompose: { name: 'decompose', description: 'Query Decomposition' }, + gqr: { name: 'gqr', description: 'General Query Refinement' }, + kwr: { name: 'kwr', description: 'Keyword Extraction' }, + par: { name: 'par', description: 'Pseudo-Answer Retrieval' }, + step_back: { name: 'step_back', description: 'Step-Back Abstraction' }, + translate_to: { name: 'translate_to', description: 'Cross-Language Query Translation' } +}; + +/** + * 旧版兼容 prompt(用于 prompt_loader.ts) + * 使用 DEFAULT_STRATEGIES 中的 prompt_template + */ +export const rewriteStrategyPrompts: Record = DEFAULT_STRATEGIES.reduce( + (acc, spec) => { + acc[spec.name] = spec.prompt_template; + return acc; + }, + {} as Record +); + +/** + * 策略选择 Prompt(对齐 Python STRATEGY_SELECTION_PROMPT) + */ +export const STRATEGY_SELECTION_PROMPT = `You are a query analysis expert. Based on the characteristics of the user's query, \\ +select the most appropriate rewrite strategies from the pool below. + +## Strategy Pool +{strategy_descriptions} + +## Rules +- Select 1-{max_strategies} most appropriate strategies +- Not all strategies need to be used — selecting the right ones matters more than selecting many +- For simple, clear queries, one strategy or even no rewrite may suffice +- Return strategy names and reasoning + +## User Query +{query} + +Return as JSON: +{"strategies": ["strategy_name_1", "strategy_name_2"], "reasoning": "selection reasoning"}`; + +/** + * 批量改写 Prompt(对齐 Python BATCH_REWRITE_PROMPT) + */ +export const BATCH_REWRITE_PROMPT = `Rewrite the following query according to the specified strategies. \\ +Each strategy should be executed independently to produce different query variants. + +**Language**: All rewritten queries MUST be in the same language as the original query. + +## Original Query +{query} + +## Instructions +{strategy_instructions} + +Return as JSON: +{"rewrites": [{"strategy": "strategy_name", "queries": ["rewritten query"]}]}`; + +/** + * 将策略格式化为选择 Prompt 的一部分 + */ +export function formatStrategyDescriptions(strategies: RewriteStrategySpec[]): string { + const enabled = strategies.filter((s) => s.enabled).sort((a, b) => b.priority - a.priority); + const lines: string[] = []; + for (const spec of enabled) { + lines.push( + `- **${spec.name}** (${spec.display_name}): ${spec.description}\n When to use: ${spec.applicable_when}` + ); + } + return lines.join('\n'); +} + +/** + * 获取策略的重写指令(带 few-shot examples) + */ +export function getRewriteInstructions(strategies: RewriteStrategySpec[], query: string): string { + const instructions: string[] = []; + for (let i = 0; i < strategies.length; i++) { + const spec = strategies[i]; + let exampleStr = ''; + if (spec.examples && spec.examples.length > 0) { + const ex = spec.examples[0]; + exampleStr = `\n Example input: ${ex.input}\n Example output: ${ex.output}`; + } + const prompt = spec.prompt_template.replace('{query}', query); + instructions.push( + `${i + 1}. Strategy **${spec.name}** (${spec.display_name}): ${prompt}${exampleStr}` + ); + } + return instructions.join('\n'); +} diff --git a/packages/diting-rag-ts/src/skills/atomic/chunk_selector.ts b/packages/diting-rag-ts/src/skills/atomic/chunk_selector.ts new file mode 100644 index 000000000000..2001599f3dc7 --- /dev/null +++ b/packages/diting-rag-ts/src/skills/atomic/chunk_selector.ts @@ -0,0 +1,683 @@ +// src/skills/atomic/chunk_selector.ts +// Chunk Selector Skill - 完整版:Stage 1 LLM 评分 + Stage 2 LLM 精排 + 信息增益 + 动态阈值 + pinned chunks + +import type { SkillInput, SkillOutput } from '../base'; +import { BaseSkill } from '../base'; +import type { ChunkItem } from '../../types/chunk'; +import type { LLMProvider } from '../../ports/llm'; +import { getLogger } from '../../utils/logger'; +import { fitChunks, estimateTokens } from '../../utils/token_budget'; +import { InformationGainCalculator } from '../../utils/info_gain'; +import { + RERANKER_SUB_QUERY_PROMPT, + getRerankPrompt +} from '../../prompts/atomic/chunk_selector_prompts'; +import { IRRELEVANT_BGE_THRESHOLD, IRRELEVANT_LLM_THRESHOLD } from '../../utils/constants'; + +/** + * Chunk Selector 选项 + */ +export interface ChunkSelectorOptions { + query: string; + chunks: ChunkItem[]; + tokenBudget: number; + playbook?: string; + enableInfoGain?: boolean; + enableLLMRerank?: boolean; + stage1Threshold?: number; + pinnedChunkIds?: string[]; + answerMaxChunks?: number; +} + +/** + * Chunk Selector 结果 + */ +export interface ChunkSelectorResult { + selectedChunks: ChunkItem[]; + totalTokens: number; + stage: 1 | 2; + llmScoreUsed?: boolean; + infoGainFiltered?: number; +} + +// Stage 1 启用的 playbooks(对齐 Python STAGE1_ENABLED_PLAYBOOKS) +export const STAGE1_ENABLED_PLAYBOOKS = new Set([ + 'comparative_analysis', + 'deep_research', + 'simple_query', + 'troubleshooting' +]); + +/** + * Chunk Selector Skill - 完整版 + * + * 流程: + * 1. Stage 1: Token gate(粗筛) + * 2. Stage 2: LLM 精排(使用 BGE score 排序后,调用 LLM 重评) + * 3. Stage 3: 动态扩展(根据 Stage1 高分) + * 4. Stage 4: 信息增益过滤(pinned chunk 跳过) + * 5. Stage 5: 按最终 score 排序,返回 token budget 内的 chunks + */ +export class ChunkSelectorSkill extends BaseSkill { + name = 'chunk_selector'; + description = + 'Select relevant chunks with full pipeline: LLM scoring, info gain, dynamic threshold, pinned chunks'; + + private infoGainCalc?: InformationGainCalculator; + private config = { + // 对齐 Python ChunkSelectorConfig 默认值 + enableLLMRerank: true, // LLM Stage2 精排:默认开启;CHUNK_SELECTOR_USE_LLM=false 关闭 + maxDocLength: 30000, + llmScoreThreshold: 4.0, + enableInfoGain: true, + infoGainThreshold: 0.2, + infoGainMaxHistory: 10, + useLLMSubQuery: true, // subQueryFilter LLM 评分:默认开启;CHUNK_SELECTOR_USE_LLM_SUB_QUERY=false 关闭 + llmSubQueryKeepTopK: 12, // Python: llm_sub_query_keep_top_k = 12 + // 内部扩展参数(无 Python 直接对应) + stage1Threshold: 7.0, + stage1HighScoreThreshold: 7.0, + stage1ExpandMultiplier: 2 + }; + + // Stage1 分数缓存 + private stage1ScoreCache: Map = new Map(); + + // Playbook 感知配置 + private playbookConfig: Record< + string, + { enableLLM: boolean; enableInfoGain: boolean; threshold: number } + > = { + simple_query: { enableLLM: true, enableInfoGain: true, threshold: 7.0 }, + comparative_analysis: { enableLLM: true, enableInfoGain: true, threshold: 6.5 }, + troubleshooting: { enableLLM: true, enableInfoGain: false, threshold: 7.0 }, + step_back: { enableLLM: false, enableInfoGain: true, threshold: 7.5 }, + planning: { enableLLM: true, enableInfoGain: true, threshold: 6.0 }, + general: { enableLLM: false, enableInfoGain: true, threshold: 7.0 } + }; + + initializeProvider(llm: LLMProvider): void { + super.initialize(llm); + this.loadFromEnv(); + this.infoGainCalc = new InformationGainCalculator(this.config.infoGainMaxHistory); + } + + /** + * 从环境变量加载配置(对齐 Python pydantic_settings,前缀 CHUNK_SELECTOR_) + * + * 支持的环境变量: + * CHUNK_SELECTOR_USE_LLM (bool) + * CHUNK_SELECTOR_LLM_MAX_DOC_LENGTH (int) + * CHUNK_SELECTOR_LLM_SCORE_THRESHOLD (float) + * CHUNK_SELECTOR_ENABLE_INFO_GAIN (bool) + * CHUNK_SELECTOR_INFO_GAIN_THRESHOLD (float) + * CHUNK_SELECTOR_INFO_GAIN_MAX_HISTORY (int) + * CHUNK_SELECTOR_USE_LLM_SUB_QUERY (bool) + * CHUNK_SELECTOR_LLM_SUB_QUERY_KEEP_TOP_K (int) + */ + loadFromEnv(): void { + const env = process.env; + const parseBool = (v: string | undefined, def: boolean) => + v === undefined ? def : v.toLowerCase() === 'true' || v === '1'; + const parseFloat_ = (v: string | undefined, def: number) => + v === undefined ? def : parseFloat(v); + const parseInt_ = (v: string | undefined, def: number) => + v === undefined ? def : parseInt(v, 10); + + this.config.enableLLMRerank = parseBool( + env['CHUNK_SELECTOR_USE_LLM'], + this.config.enableLLMRerank + ); + this.config.maxDocLength = parseInt_( + env['CHUNK_SELECTOR_LLM_MAX_DOC_LENGTH'], + this.config.maxDocLength + ); + this.config.llmScoreThreshold = parseFloat_( + env['CHUNK_SELECTOR_LLM_SCORE_THRESHOLD'], + this.config.llmScoreThreshold + ); + this.config.enableInfoGain = parseBool( + env['CHUNK_SELECTOR_ENABLE_INFO_GAIN'], + this.config.enableInfoGain + ); + this.config.infoGainThreshold = parseFloat_( + env['CHUNK_SELECTOR_INFO_GAIN_THRESHOLD'], + this.config.infoGainThreshold + ); + this.config.infoGainMaxHistory = parseInt_( + env['CHUNK_SELECTOR_INFO_GAIN_MAX_HISTORY'], + this.config.infoGainMaxHistory + ); + this.config.useLLMSubQuery = parseBool( + env['CHUNK_SELECTOR_USE_LLM_SUB_QUERY'], + this.config.useLLMSubQuery + ); + this.config.llmSubQueryKeepTopK = parseInt_( + env['CHUNK_SELECTOR_LLM_SUB_QUERY_KEEP_TOP_K'], + this.config.llmSubQueryKeepTopK + ); + } + + /** + * 配置更新(对齐 Python ChunkSelectorConfig) + */ + configure(options: { + enableLLMRerank?: boolean; + maxDocLength?: number; + llmScoreThreshold?: number; + enableInfoGain?: boolean; + infoGainThreshold?: number; + infoGainMaxHistory?: number; + useLLMSubQuery?: boolean; + llmSubQueryKeepTopK?: number; + stage1Threshold?: number; + }): void { + Object.assign(this.config, options); + } + + /** + * 获取当前配置(供外部节点读取 useLLMSubQuery、llmSubQueryKeepTopK 等) + */ + getConfig() { + return { ...this.config }; + } + + /** + * 清理缓存 + */ + clearCache(): void { + this.stage1ScoreCache.clear(); + } + + async execute(input: SkillInput): Promise { + const { + query, + chunks, + tokenBudget, + playbook = 'general', + enableInfoGain, + enableLLMRerank, + pinnedChunkIds = [], + answerMaxChunks = 15 + } = input as unknown as ChunkSelectorOptions; + + // 获取 playbook 特定配置 + const playbookSettings = this.playbookConfig[playbook] || this.playbookConfig.general; + + // 本次调用的有效配置(不修改实例 config,避免共享实例状态污染) + const effectiveLLMRerank = enableLLMRerank ?? this.config.enableLLMRerank; + const effectiveInfoGain = enableInfoGain ?? this.config.enableInfoGain; + + if (!chunks || chunks.length === 0) { + return this.success({ selectedChunks: [], totalTokens: 0, stage: 1 }); + } + + // ── 不相关门控标志(全部 chunk 各信号均低于阈值时,移除保底 fallback)────── + // 对齐 nodes.ts isSearchIrrelevant 逻辑:优先 LLM score(跨语言/专业术语同义词场景更可靠), + // 无 LLM score 时回退 rerankScore / vectorScore / score(向量相似度对跨语言比 cross-encoder 更鲁棒) + const allChunksIrrelevant = chunks.every((c) => { + if (c.llm_sub_query_score !== undefined) { + return c.llm_sub_query_score < IRRELEVANT_LLM_THRESHOLD; + } + return (c.rerankScore ?? c.vectorScore ?? c.score ?? 0) < IRRELEVANT_BGE_THRESHOLD; + }); + + // 打印每个 chunk 的分数以便诊断 + chunks.slice(0, 5).forEach((c, i) => { + this.logger?.debug( + ` chunk[${i}] id=${c.id} vectorScore=${c.vectorScore?.toFixed(4)} llm_score=${c.llm_sub_query_score?.toFixed(4)} score=${c.score?.toFixed(4)} rerankScore=${c.rerankScore?.toFixed(4)} content="${c.content.substring(0, 60).replace(/\n/g, ' ')}"` + ); + }); + // 全部不相关:直接返回空,不走任何保底逻辑 + if (allChunksIrrelevant) { + this.logger?.info( + `[ChunkSelector] allChunksIrrelevant=true (bgeThreshold=${IRRELEVANT_BGE_THRESHOLD}, llmThreshold=${IRRELEVANT_LLM_THRESHOLD}) — skipping selection, returning empty` + ); + return this.success({ selectedChunks: [], totalTokens: 0, stage: 1 }); + } + + // ── Stage 1: Token gate(粗筛)────────────────────────────────────── + let candidates = fitChunks(chunks, tokenBudget * 3); // 先宽松一点,后面再限制 + + // ── Stage 2: LLM 精排────────────────────────────────────────────── + let llmScoreUsed = false; + if (effectiveLLMRerank && this.llm && playbookSettings.enableLLM && candidates.length > 0) { + this.logger?.info( + `[ChunkSelector] Stage2 LLM rerank: ${candidates.length} candidates (playbook=${playbook})` + ); + candidates = await this.rerankWithLLM(query, candidates, playbook); + llmScoreUsed = true; + } else { + const skipReason = !effectiveLLMRerank + ? 'enableLLMRerank=false' + : !this.llm + ? 'no LLM' + : !playbookSettings.enableLLM + ? `playbook(${playbook}).enableLLM=false` + : 'no candidates'; + this.logger?.info(`[ChunkSelector] Stage2 skipped (${skipReason}), sorting by cached scores`); + // enableLLMRerank: false 时,优先使用 Stage 1 缓存分数排序 + candidates.sort((a, b) => { + const scoreA = (a as any).llm_sub_query_score ?? a.rerankScore ?? a.score ?? 0; + const scoreB = (b as any).llm_sub_query_score ?? b.rerankScore ?? b.score ?? 0; + return scoreB - scoreA; + }); + } + + // ── Stage 3: 动态扩展────────────────────────────────────────────── + // 对于 Stage1 高分的 chunk,扩展候选池 + candidates = this.dynamicExpand(candidates, answerMaxChunks, playbookSettings.threshold); + + // ── Stage 4: 信息增益过滤(pinned chunk 跳过)────────────────────── + let infoGainFiltered = 0; + const pinnedSet = new Set(pinnedChunkIds); + if (effectiveInfoGain && this.infoGainCalc && playbookSettings.enableInfoGain) { + const beforeCount = candidates.length; + candidates = this.filterByInfoGain(candidates, pinnedSet, this.config.infoGainThreshold); + infoGainFiltered = beforeCount - candidates.length; + } + + // ── pinned chunks 移至最前(确保 token 充足时优先保留)──────────── + if (pinnedChunkIds.length > 0 && pinnedSet.size > 0) { + const pinned = candidates.filter((c) => pinnedSet.has(c.id)); + const others = candidates.filter((c) => !pinnedSet.has(c.id)); + candidates = [...pinned, ...others]; + } + + // ── 至少保留1个(全不相关时不保底,允许返回空列表)─────────────── + if (candidates.length === 0 && chunks.length > 0 && !allChunksIrrelevant) { + candidates = [chunks[0]]; + } + + // ── Stage 5: Token budget 限制────────────────────────────────────── + const finalChunks = fitChunks(candidates, tokenBudget); + const totalTokens = estimateTokens(finalChunks.map((c) => c.content).join('\n')); + + return this.success({ + selectedChunks: finalChunks, + totalTokens, + stage: llmScoreUsed ? 2 : 1, + llmScoreUsed, + infoGainFiltered + }); + } + + /** + * Stage 2: LLM 精排(使用 BGE score 排序后调用 LLM 重评) + * 优化:使用 Promise.all 并发调用 LLM(最多 10 并发),对齐 Python 行为 + */ + private async rerankWithLLM( + query: string, + chunks: ChunkItem[], + playbook: string + ): Promise { + if (!chunks.length || !this.llm) return chunks; + + const scoreTopK = Math.min(chunks.length, 45); // answer_max_chunks * 3 + let candidates = chunks.slice(0, scoreTopK); + + // 并发评分(对齐 Python asyncio.gather + Semaphore(10)) + const CONCURRENCY = 10; + + // Stage 1 缓存预填充(对齐 Python _llm_rerank 缓存复用逻辑) + const llmScores: number[] = new Array(candidates.length).fill(0); + let cachedCount = 0; + const chunksNeedingLLM: Array<{ chunk: ChunkItem; idx: number }> = []; + + for (const [idx, chunk] of candidates.entries()) { + const cachedScore = (chunk as any).llm_sub_query_score as number | undefined; + const cachedPlaybook = (chunk as any).llm_sub_query_playbook as string | undefined; + if (cachedScore !== undefined && cachedPlaybook === playbook) { + llmScores[idx] = cachedScore; + cachedCount++; + } else { + chunksNeedingLLM.push({ chunk, idx }); + } + } + + this.logger?.info( + `Stage2 LLM rerank: reusing ${cachedCount} cached Stage1 scores, calling LLM for ${chunksNeedingLLM.length} chunks (playbook=${playbook})` + ); + + // 分批并发执行,每批最多 CONCURRENCY 个 + for (let i = 0; i < chunksNeedingLLM.length; i += CONCURRENCY) { + const batch = chunksNeedingLLM.slice(i, i + CONCURRENCY); + const batchPromises = batch.map(async ({ chunk, idx }) => { + try { + const score = await this.scoreSingleChunkLLM(query, chunk, playbook); + return { idx, score }; + } catch (e) { + this.logger?.warn(`Stage2 LLM scoring failed for chunk ${idx}:`, { + message: e instanceof Error ? e.message : String(e) + }); + return { idx, score: 5.0 }; + } + }); + + const batchResults = await Promise.all(batchPromises); + for (const { idx, score } of batchResults) { + llmScores[idx] = score; + } + } + + // 合并 BGE score 和 LLM score + // llmScore 归一化到 [0,1],使 combinedScore 整体保持 [0,1] 范围 + const scored = candidates.map((chunk, idx) => { + const bgeScore = chunk.rerankScore ?? chunk.score ?? 0; + const llmScore = llmScores[idx] / 10; // 0-10 → 0-1 + // 复合分数:BGE * 0.4 + LLM * 0.6(对齐 Python _llm_rerank),范围 [0,1] + const combinedScore = bgeScore * 0.4 + llmScore * 0.6; + return { ...chunk, combinedScore } as ChunkItem & { combinedScore: number }; + }); + + // 按复合分数排序 + scored.sort((a, b) => b.combinedScore - a.combinedScore); + + // 若所有分数均低于阈值,保留 top-5(对齐 Python 第 429-438 行 fallback) + // Python llmScoreThreshold 基于 BGE*0.4 + LLM*0.6(LLM 原始 0-10)scale; + // TS combinedScore 已将 LLM 归一化到 0-1,故阈值除以 10 换算 + const scoreThreshold = this.config.llmScoreThreshold / 10; + const anyAboveThreshold = scored.some((c) => c.combinedScore >= scoreThreshold); + if (!anyAboveThreshold && scored.length > 5) { + this.logger?.warn( + '[ChunkSelector] LLM rerank: all scores below threshold, keeping top-5 as fallback' + ); + // 将 combinedScore 写入 rerankScore(供 FastGPT adapter 映射为 reRank 类型展示综合得分) + return scored.slice(0, 5).map( + ({ combinedScore, ...chunk }) => + ({ + ...chunk, + rerankScore: combinedScore + }) as ChunkItem + ); + } + + // 将 combinedScore 写入 rerankScore(供 FastGPT adapter 映射为 reRank 类型展示综合得分) + return scored.map( + ({ combinedScore, ...chunk }) => + ({ + ...chunk, + rerankScore: combinedScore + }) as ChunkItem + ); + } + + /** + * 单个 LLM 评分(对齐 Python _score_chunk_with_llm) + * 使用 playbook-aware prompt + */ + private async scoreSingleChunkLLM( + query: string, + chunk: ChunkItem, + playbook: string + ): Promise { + // 使用 playbook-aware prompt(对齐 Python) + const promptTemplate = getRerankPrompt(playbook); + const docContent = chunk.content.substring(0, this.config.maxDocLength); + const prompt = promptTemplate + .replace('{user_query}', query) + .replace('{question_analysis_section}', '') + .replace('{doc_content}', docContent); + + const response = await this.llm!.chat([{ role: 'user', content: prompt }], { + temperature: 0.1, + maxTokens: 8 + }); + + const content = response.content.trim(); + const score = parseInt(content, 10); + + if (isNaN(score) || score < 0 || score > 10) { + this.logger?.warn(`Stage2 LLM scoring returned invalid score "${content}", using 5.0`); + return 5.0; + } + return score; + } + + /** + * Stage 3: 动态扩展 - 根据 Stage1 高分扩展候选池 + */ + private dynamicExpand( + chunks: ChunkItem[], + answerMaxChunks: number, + _threshold: number + ): ChunkItem[] { + const expandLimit = answerMaxChunks * this.config.stage1ExpandMultiplier; + let expanded = chunks.slice(0, expandLimit); + + // 统计 Stage1 高分数量 + let highScoreCount = 0; + for (const c of expanded) { + const stage1Score = (c as unknown as { llm_sub_query_score?: number }).llm_sub_query_score; + if (stage1Score !== undefined && stage1Score >= this.config.stage1HighScoreThreshold) { + highScoreCount++; + } + } + + // 如果有 Stage1 高分,保持扩展 + if (highScoreCount > 0) { + return expanded; + } + + // 否则,收缩到正常范围 + return chunks.slice(0, answerMaxChunks); + } + + /** + * Stage 4: 信息增益过滤(对齐 Python _filter_by_info_gain) + * + * 策略: + * 1. pinned chunk 始终保留(@assess 标注) + * 2. 前 5 个默认通过(高相关 = 高增益假设) + * 3. 超过 5 后计算信息增益,低于阈值跳过 + * 4. Stage 1 高分(>=7.0)的 chunk 降低阈值 50% + * 5. 至少保留 3 个(防止过度过滤) + */ + private filterByInfoGain( + chunks: ChunkItem[], + pinnedIds: Set, + threshold: number = 0.1 + ): ChunkItem[] { + if (!this.infoGainCalc) return chunks; + if (chunks.length === 0) return []; + + const SKIP_FRONT = 5; + const STAGE1_HIGH_SCORE_THRESHOLD = 7.0; + const MIN_KEEP = 3; + + const calc = new InformationGainCalculator(10); + const selected: ChunkItem[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + + // pinned chunk 始终保留 + if (pinnedIds.has(chunk.id)) { + selected.push(chunk); + calc.addSelected(chunk); + continue; + } + + // 前 K 个默认通过 + if (i < SKIP_FRONT) { + selected.push(chunk); + calc.addSelected(chunk); + continue; + } + + const infoGain = calc.computeInfoGain(chunk); + const stage1Score = (chunk as unknown as { llm_sub_query_score?: number }) + .llm_sub_query_score; + + if (stage1Score !== undefined && stage1Score >= STAGE1_HIGH_SCORE_THRESHOLD) { + // Stage 1 高分 → 降低阈值 50% + const effectiveThreshold = threshold * 0.5; + if (infoGain >= effectiveThreshold) { + selected.push(chunk); + calc.addSelected(chunk); + } + continue; + } + + // 标准信息增益判断 + if (infoGain >= threshold) { + selected.push(chunk); + calc.addSelected(chunk); + } + } + + // 确保至少保留 MIN_KEEP 个 + if (selected.length < MIN_KEEP && chunks.length >= MIN_KEEP) { + const selectedIds = new Set(selected.map((c) => c.id)); + for (const chunk of chunks) { + if (!selectedIds.has(chunk.id)) { + selected.push(chunk); + if (selected.length >= MIN_KEEP) break; + } + } + } + + return selected; + } + + /** + * 重置信息增益计算器 + */ + reset(): void { + this.infoGainCalc?.reset(); + } + + /** + * 添加选中 chunk 到历史 + */ + addToHistory(chunk: ChunkItem): void { + this.infoGainCalc?.addSelected(chunk); + } + + /** + * Playbook 感知 dispatch + */ + getPlaybookConfig(playbook: string): { + enableLLM: boolean; + enableInfoGain: boolean; + threshold: number; + } { + return this.playbookConfig[playbook] || this.playbookConfig.general; + } +} + +// ============================================================ +// Stage 1 Sub-query Filter(对齐 Python sub_query_filter()) +// ============================================================ + +/** + * Stage 1 sub-query LLM filter + * - 独立 LLM 评分(非 BGE+LLM 混合),评分仅对 sub_query 有效 + * - 评分缓存到 chunk.llm_sub_query_score(不覆盖 BGE score) + * - 返回 topK 个高分 chunk(用于后续 chunk_selector Stage 2) + * + * @param chunks - 待过滤 chunks + * @param userQuery - 用户原始问题(上下文) + * @param subQuery - 当前子查询 + * @param questionAnalysis - 问题分析文本(用于 {question_analysis_section}) + * @param llm - LLM provider + * @param keepTopK - 保留 top-K 个(默认 10) + * @param playbookName - playbook 名 + * @param maxDocLength - 截断长度(默认 2000) + */ +export async function subQueryFilter( + chunks: ChunkItem[], + userQuery: string, + subQuery: string, + questionAnalysis: string, + llm: LLMProvider, + keepTopK: number = 10, + playbookName: string = '', + maxDocLength: number = 2000 +): Promise { + if (chunks.length === 0) return chunks; + if (!STAGE1_ENABLED_PLAYBOOKS.has(playbookName)) return chunks; + + // 构建 {question_analysis_section} + const analysisSection = questionAnalysis ? `\n\nQuestion Analysis:\n${questionAnalysis}` : ''; + + // 并发限制:每批最多 10 个 + const CONCURRENCY = 10; + const scored: Array = []; + + for (let i = 0; i < chunks.length; i += CONCURRENCY) { + const batch = chunks.slice(i, i + CONCURRENCY); + + // 并发评分 + const batchScores = await Promise.all( + batch.map(async (chunk) => { + // 如果已有同 playbook 的缓存分数则复用 + const cachedChunk = chunk as ChunkItem & { + llm_sub_query_score?: number; + llm_sub_query_playbook?: string; + }; + if ( + cachedChunk.llm_sub_query_score !== undefined && + cachedChunk.llm_sub_query_playbook === playbookName + ) { + return cachedChunk.llm_sub_query_score; + } + + const docContent = chunk.content.substring(0, maxDocLength); + const prompt = RERANKER_SUB_QUERY_PROMPT.replace('{user_query}', userQuery) + .replace('{question_analysis_section}', analysisSection) + .replace('{sub_query_text}', subQuery) + .replace('{doc_content}', docContent); + + try { + const response = await llm.chat([{ role: 'user', content: prompt }], { + temperature: 0.0, + maxTokens: 8 + }); + const raw = response.content.trim(); + const score = parseInt(raw, 10); + if (isNaN(score)) { + getLogger()?.debug( + `[subQueryFilter] LLM returned non-numeric: raw="${raw.substring(0, 100)}" chunkId=${chunk.id} → fallback to 5` + ); + } + return isNaN(score) ? 5 : Math.max(0, Math.min(10, score)); + } catch (e) { + getLogger()?.warn('[subQueryFilter] LLM scoring failed for chunk:', { + chunkId: chunk.id, + message: e instanceof Error ? e.message : String(e) + }); + return 5; // 失败返回中间值 + } + }) + ); + + for (let j = 0; j < batch.length; j++) { + const chunk = batch[j]; + const score = batchScores[j]; + + // 缓存分数(不覆盖 BGE score) + ( + chunk as ChunkItem & { + llm_sub_query_score: number; + llm_sub_query_playbook: string; + } + ).llm_sub_query_score = score; + ( + chunk as ChunkItem & { + llm_sub_query_score: number; + llm_sub_query_playbook: string; + } + ).llm_sub_query_playbook = playbookName; + + scored.push({ ...chunk, _stage1Score: score }); + } + } + + // 按 Stage1 分数降序排列,取 topK;截断 LLM 给 0 分的 chunk(完全无关),弱相关(1-2 分)留给 isSearchIrrelevant 整批判断 + scored.sort((a, b) => b._stage1Score - a._stage1Score); + const topK = scored.filter((c) => c._stage1Score > 0).slice(0, keepTopK); + + // 返回时去掉临时字段 _stage1Score + return topK.map(({ _stage1Score, ...chunk }) => chunk as ChunkItem); +} diff --git a/packages/diting-rag-ts/src/skills/atomic/index.ts b/packages/diting-rag-ts/src/skills/atomic/index.ts new file mode 100644 index 000000000000..860aa1945b21 --- /dev/null +++ b/packages/diting-rag-ts/src/skills/atomic/index.ts @@ -0,0 +1,14 @@ +// src/skills/atomic/index.ts +// 原子技能统一导出 + +export { RetrieveSkill } from './retrieve'; +export type { RetrieveOptions, RetrieveResult } from './retrieve'; + +export { RerankSkill } from './rerank'; +export type { RerankOptions } from './rerank'; + +export { LLMCallSkill } from './llm_call'; +export type { LLMCallOptionsInput } from './llm_call'; + +export { ChunkSelectorSkill } from './chunk_selector'; +export type { ChunkSelectorOptions, ChunkSelectorResult } from './chunk_selector'; diff --git a/packages/diting-rag-ts/src/skills/atomic/llm_call.ts b/packages/diting-rag-ts/src/skills/atomic/llm_call.ts new file mode 100644 index 000000000000..c95b350e1e94 --- /dev/null +++ b/packages/diting-rag-ts/src/skills/atomic/llm_call.ts @@ -0,0 +1,57 @@ +// src/skills/atomic/llm_call.ts +// LLM Call Skill + +import type { SkillInput, SkillOutput } from '../base'; +import { BaseSkill } from '../base'; +import type { LLMMessage, LLMCallOptions } from '../../types/message'; + +/** + * LLM Call 选项 + */ +export interface LLMCallOptionsInput { + messages: LLMMessage[]; + options?: LLMCallOptions; + stream?: boolean; +} + +/** + * LLM Call Skill + */ +export class LLMCallSkill extends BaseSkill { + name = 'llm_call'; + description = 'Call LLM with messages'; + + async execute(input: SkillInput): Promise { + const { messages, options = {}, stream = false } = input as unknown as LLMCallOptionsInput; + + if (!this.llm) { + return this.fail('LLMProvider not initialized'); + } + + try { + if (stream) { + // 流式调用 + const asyncStream = await this.llm.chatStream(messages, options); + const chunks: string[] = []; + + for await (const chunk of asyncStream) { + chunks.push(chunk.content); + } + + return this.success({ + content: chunks.join('') + }); + } else { + // 同步调用 + const response = await this.llm.chat(messages, options); + return this.success({ + content: response.content, + reasoning: response.reasoning, + toolCalls: response.toolCalls + }); + } + } catch (error) { + return this.fail(`LLM call failed: ${error}`); + } + } +} diff --git a/packages/diting-rag-ts/src/skills/atomic/rerank.ts b/packages/diting-rag-ts/src/skills/atomic/rerank.ts new file mode 100644 index 000000000000..22c5fb04d629 --- /dev/null +++ b/packages/diting-rag-ts/src/skills/atomic/rerank.ts @@ -0,0 +1,108 @@ +// src/skills/atomic/rerank.ts +// Rerank Skill - BGE Reranker API 精排,API 不可用时降级关键词精排 + +import type { SkillInput, SkillOutput } from '../base'; +import { BaseSkill } from '../base'; +import type { ChunkItem } from '../../types/chunk'; +import type { RerankProvider } from '../../ports/reranker'; + +/** + * Rerank 选项 + */ +export interface RerankOptions { + query: string; + chunks: ChunkItem[]; + topK?: number; +} + +// ============================================================ +// 关键词降级评分(API 不可用时使用,对齐 Python _keyword_rerank_score) +// ============================================================ +function keywordRerankScore(query: string, content: string): number { + const queryTerms = query + .toLowerCase() + .replace(/[??]/g, '') + .split(/\s+/) + .filter((t) => t.length > 0); + if (queryTerms.length === 0) return 0.0; + const contentLower = content.toLowerCase(); + let totalHits = 0; + for (const term of queryTerms) { + // count all occurrences + let pos = 0; + while ((pos = contentLower.indexOf(term, pos)) !== -1) { + totalHits++; + pos += term.length; + } + } + return Math.min(totalHits / Math.max(queryTerms.length * 3, 1), 1.0); +} + +/** + * Rerank Skill — 生产环境调用 BGE Reranker API,不可用时降级为关键词精排 + */ +export class RerankSkill extends BaseSkill { + name = 'rerank'; + description = 'Rerank chunks using reranker model, fallback to keyword rerank on failure'; + + private rerankProvider?: RerankProvider; + + initializeProvider(rerankProvider: RerankProvider): void { + this.rerankProvider = rerankProvider; + } + + async execute(input: SkillInput): Promise { + const { query, chunks, topK = 20 } = input as unknown as RerankOptions; + + if (!this.rerankProvider) { + return this.fail('RerankProvider not initialized'); + } + + if (!chunks || chunks.length === 0) { + return this.success({ chunks: [] }); + } + + // 尝试 API 精排 + try { + // 估算 rerank 输入 token 数(query + 各文档内容,按 4 字符/token 粗估) + const rerankInputTokens = Math.ceil( + (query.length + chunks.reduce((sum, c) => sum + c.content.length, 0)) / 4 + ); + + const results = await this.rerankProvider.rerank(query, chunks); + + // 检查是否全零分(对齐 Python: 所有批次均失败时触发降级) + const allZero = results.every((r) => (r.rerankScore ?? 0) === 0); + if (allZero) { + throw new Error('Rerank API returned all-zero scores'); + } + + const sorted = results.sort((a, b) => (b.rerankScore ?? 0) - (a.rerankScore ?? 0)); + const topKResults = sorted.slice(0, topK); + return this.success({ chunks: topKResults, rerankInputTokens }); + } catch (error) { + this.logger?.warn(`[RerankSkill] API 调用失败,降级为关键词精排: ${error}`); + return this.success({ + chunks: this.rerankByKeyword(query, chunks, topK), + rerankInputTokens: 0 + }); + } + } + + /** + * 关键词降级精排(对齐 Python _rerank_by_keyword) + * 综合分数: 0.4 * 原始检索分数 + 0.6 * 关键词匹配分数 + */ + private rerankByKeyword(query: string, chunks: ChunkItem[], topK: number): ChunkItem[] { + const scored = chunks.map((chunk) => { + const rerankS = keywordRerankScore(query, chunk.content); + // 综合分数融合:0.4 * 原始分数 + 0.6 * 关键词分数 + // 原始 score 来自向量检索/全文检索,范围 0-1 + // 最终仍在 0-1 范围内 + const combined = 0.4 * (chunk.score ?? 0) + 0.6 * rerankS; + return { ...chunk, score: combined }; + }); + scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return scored.slice(0, topK); + } +} diff --git a/packages/diting-rag-ts/src/skills/atomic/retrieve.ts b/packages/diting-rag-ts/src/skills/atomic/retrieve.ts new file mode 100644 index 000000000000..f395b4c5f83a --- /dev/null +++ b/packages/diting-rag-ts/src/skills/atomic/retrieve.ts @@ -0,0 +1,319 @@ +// src/skills/atomic/retrieve.ts +// Retrieve Skill - 向量检索 + 全文检索 + +import type { SkillInput, SkillOutput } from '../base'; +import { BaseSkill } from '../base'; +import type { ChunkItem, ChunkResult } from '../../types/chunk'; +import type { + VectorSearchProvider, + FullTextSearchProvider, + MixedSearchProvider +} from '../../ports/search'; +import type { EmbeddingProvider } from '../../ports/embedding'; + +/** + * 检索选项 + */ +export interface RetrieveOptions { + queries: string[]; + datasetIds: string[]; + limit?: number; + searchMode?: 'embedding' | 'fullTextRecall' | 'mixedRecall'; + forbidCollectionIds?: string[]; +} + +/** + * 检索结果 + */ +export interface RetrieveResult { + chunks: ChunkItem[]; + queries: string[]; +} + +/** + * Retrieve Skill + * 向量检索 + 全文检索 + * mixedRecall 模式优先使用注入的 MixedSearchProvider(调用 FastGPT capabilities.mixedRecall) + * 若未注入则降级为分开检索 + 本地 RRF 融合 + */ +export class RetrieveSkill extends BaseSkill { + name = 'retrieve'; + description = 'Retrieve chunks from vector and full-text search'; + + private vectorSearch?: VectorSearchProvider; + private fullTextSearch?: FullTextSearchProvider; + private mixedSearch?: MixedSearchProvider; + private embed?: EmbeddingProvider; + + initializeProviders( + vectorSearch: VectorSearchProvider, + fullTextSearch: FullTextSearchProvider, + embed: EmbeddingProvider, + mixedSearch?: MixedSearchProvider + ): void { + this.vectorSearch = vectorSearch; + this.fullTextSearch = fullTextSearch; + this.embed = embed; + this.mixedSearch = mixedSearch; + } + + async execute(input: SkillInput): Promise { + const { + queries, + datasetIds, + limit = 20, + searchMode = 'mixedRecall', + forbidCollectionIds + } = input as unknown as RetrieveOptions; + + if (!queries || queries.length === 0) { + return this.success({ chunks: [], queries: [], embeddingTokens: 0 }); + } + if (!datasetIds || datasetIds.length === 0) { + return this.success({ chunks: [], queries: [], embeddingTokens: 0 }); + } + + this.logger?.debug('[RetrieveSkill] execute with:', { + queries: queries?.slice(0, 2), + datasetIds, + limit, + searchMode + }); + + if (!this.vectorSearch || !this.fullTextSearch || !this.embed) { + return this.fail('Providers not initialized'); + } + + try { + const results = await this.performSearch( + queries, + datasetIds, + { limit, forbidCollectionIds }, + searchMode + ); + + return this.success({ + chunks: results.chunks, + queries, + embeddingTokens: results.embeddingTokens + }); + } catch (error) { + return this.fail(`Search failed: ${error}`); + } + } + + private async performSearch( + queries: string[], + datasetIds: string[], + options: { limit: number; forbidCollectionIds?: string[] }, + searchMode: string + ): Promise<{ chunks: ChunkItem[]; embeddingTokens: number }> { + // mixedRecall 优先使用 MixedSearchProvider(调用 FastGPT capabilities.mixedRecall) + // 避免分开调用 vector+fulltext 再手动 RRF 导致的分数异常及搜索无结果问题 + if (searchMode === 'mixedRecall' && this.mixedSearch) { + this.logger?.debug('[RetrieveSkill] calling mixedRecall...'); + const mixedResult = await this.performMixedSearch(queries, datasetIds, options); + + // mixedSearch 全部失败 → 降级到分离检索(本地 RRF) + if (mixedResult.chunks.length === 0) { + this.logger?.warn( + '[RetrieveSkill] mixedSearch returned no results for all queries, falling back to local separation retrieval' + ); + return this.performLocalSeparationSearch(queries, datasetIds, options, searchMode); + } + + return mixedResult; + } + + // fallback: 分开检索 + 本地 RRF 融合(mixedSearch 未注入时使用) + return this.performLocalSeparationSearch(queries, datasetIds, options, searchMode); + } + + /** + * 分离检索:vector + fulltext → 本地 RRF 融合 + */ + private async performLocalSeparationSearch( + queries: string[], + datasetIds: string[], + options: { limit: number; forbidCollectionIds?: string[] }, + searchMode: string + ): Promise<{ chunks: ChunkItem[]; embeddingTokens: number }> { + const embeddingResults: ChunkResult[] = []; + const fullTextResults: ChunkResult[] = []; + let embeddingTokens = 0; + + // 向量检索 + if (searchMode === 'embedding' || searchMode === 'mixedRecall') { + try { + const { vectors, tokens } = await this.getVectors(queries); + embeddingTokens += tokens; + for (const vector of vectors) { + const results = await this.vectorSearch!.search([vector], datasetIds, { + limit: options.limit, + filter: { forbidCollectionIds: options.forbidCollectionIds } + }); + embeddingResults.push(...results.chunks); + } + } catch (e) { + this.logger?.error('[RetrieveSkill] vector search failed:', { + message: e instanceof Error ? e.message : String(e) + }); + } + } + + // 全文检索 + if (searchMode === 'fullTextRecall' || searchMode === 'mixedRecall') { + for (const query of queries) { + try { + const results = await this.fullTextSearch!.search(query, datasetIds, { + limit: options.limit, + filter: { forbidCollectionIds: options.forbidCollectionIds } + }); + fullTextResults.push(...results.chunks); + } catch (e) { + this.logger?.error('[RetrieveSkill] full-text search failed:', { + message: e instanceof Error ? e.message : String(e) + }); + } + } + } + + // mixedRecall: RRF 融合两路结果;单路则直接去重 + if (searchMode === 'mixedRecall') { + return { + chunks: this.rrfFusion( + this.deduplicateChunks(embeddingResults), + this.deduplicateChunks(fullTextResults), + 0.5 + ), + embeddingTokens + }; + } + + const allChunks = searchMode === 'embedding' ? embeddingResults : fullTextResults; + const deduped = this.deduplicateChunks(allChunks); + deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return { chunks: deduped.slice(0, options.limit), embeddingTokens }; + } + + /** + * 使用 MixedSearchProvider 做混合检索(多 query 并行,结果去重合并) + * 直接调用 FastGPT capabilities.mixedRecall,得到正确的分数和 MongoDB ID + */ + private async performMixedSearch( + queries: string[], + datasetIds: string[], + options: { limit: number; forbidCollectionIds?: string[] } + ): Promise<{ chunks: ChunkItem[]; embeddingTokens: number }> { + const allResults: ChunkResult[] = []; + let totalTokens = 0; + + this.logger?.info( + `[RetrieveSkill] performMixedSearch: queries=${JSON.stringify(queries)}, datasetIds=${JSON.stringify(datasetIds)}, limit=${options.limit}` + ); + + await Promise.all( + queries.map(async (query) => { + try { + const result = await this.mixedSearch!.search( + query, + datasetIds, + { + limit: options.limit, + filter: { forbidCollectionIds: options.forbidCollectionIds } + }, + { embeddingProvider: this.embed } + ); + this.logger?.info( + `[RetrieveSkill] mixedSearch.search("${query}") → chunks=${result.chunks.length}, error=${result.error?.code ?? 'none'}` + ); + allResults.push(...result.chunks); + // 累积 embedding tokens(由 provider 在 meta.totalTokens 中传递) + totalTokens += result.meta?.totalTokens ?? 0; + } catch (e) { + // 升级为 error 级别,包含完整 stack,方便排查 + this.logger?.error( + `[RetrieveSkill] mixed search FAILED for query="${query}": ${e instanceof Error ? e.stack ?? e.message : String(e)}` + ); + } + }) + ); + + // 去重,保留分数较高的 + const merged = new Map(); + for (const chunk of allResults) { + const existing = merged.get(chunk.id); + if (!existing || (chunk.score ?? 0) > (existing.score ?? 0)) { + merged.set(chunk.id, chunk); + } + } + + const deduped = Array.from(merged.values()) as ChunkItem[]; + deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + + const finalChunks = deduped.slice(0, options.limit); + this.logger?.info( + `[RetrieveSkill] performMixedSearch done: allResults=${allResults.length}, deduped=${deduped.length}, final=${finalChunks.length}` + ); + + return { + chunks: finalChunks, + embeddingTokens: totalTokens + }; + } + + /** + * RRF (Reciprocal Rank Fusion): 融合向量检索和全文检索结果 + */ + private rrfFusion( + embList: ChunkItem[], + ftList: ChunkItem[], + embWeight: number = 0.5 + ): ChunkItem[] { + const ftWeight = 1 - embWeight; + // maxOrigScore: 保留各来源中该 chunk 的最高原始相关性分数,供后续阈值判断使用 + // RRF 分数仅用于跨来源排序,数量级远小于余弦相似度,不可直接用于相关性阈值判断 + const rrfMap = new Map(); + + const addList = (list: ChunkItem[], weight: number) => { + list.forEach((chunk, index) => { + const rank = index + 1; + const rrfScore = weight * (1 / (60 + rank)); + const origScore = chunk.score ?? 0; + const existing = rrfMap.get(chunk.id); + if (existing) { + existing.rrfScore += rrfScore; + existing.maxOrigScore = Math.max(existing.maxOrigScore, origScore); + } else { + rrfMap.set(chunk.id, { chunk, rrfScore, maxOrigScore: origScore }); + } + }); + }; + + addList(embList, embWeight); + addList(ftList, ftWeight); + + return Array.from(rrfMap.values()) + .sort((a, b) => b.rrfScore - a.rrfScore) + .map(({ chunk, maxOrigScore }) => ({ ...chunk, score: maxOrigScore })); + } + + private async getVectors(queries: string[]): Promise<{ vectors: number[][]; tokens: number }> { + const result = await this.embed!.embed(queries); + return { vectors: result.vectors, tokens: result.tokens }; + } + + private deduplicateChunks(chunks: ChunkResult[]): ChunkItem[] { + const seen = new Set(); + const unique: ChunkItem[] = []; + + for (const chunk of chunks) { + if (!seen.has(chunk.id)) { + seen.add(chunk.id); + unique.push(chunk as ChunkItem); + } + } + + return unique; + } +} diff --git a/packages/diting-rag-ts/src/skills/base.ts b/packages/diting-rag-ts/src/skills/base.ts new file mode 100644 index 000000000000..dde62f0ca448 --- /dev/null +++ b/packages/diting-rag-ts/src/skills/base.ts @@ -0,0 +1,83 @@ +// src/skills/base.ts +// Skill 基类和类型 + +import type { LLMProvider } from '../ports/llm'; +import type { Logger } from '../ports/logger'; +import { getLogger } from '../utils/logger'; + +/** + * Skill 输入 + */ +export interface SkillInput { + context?: RequestContext; + [key: string]: unknown; +} + +/** + * Skill 输出 + */ +export interface SkillOutput { + success: boolean; + data?: unknown; + error?: string; +} + +/** + * 简化的 RequestContext(部分字段) + */ +export interface RequestContext { + datasetIds: string[]; + emit?: (step: string, detail?: string) => void; + [key: string]: unknown; +} + +/** + * Skill 错误 + */ +export class SkillError extends Error { + constructor( + message: string, + public skillName: string, + public details?: unknown + ) { + super(message); + this.name = 'SkillError'; + } +} + +/** + * Skill 基类 + */ +export abstract class BaseSkill { + abstract name: string; + abstract description: string; + + protected llm?: LLMProvider; + + protected get logger(): Logger | undefined { + return getLogger(); + } + + initialize(llm: LLMProvider): void { + this.llm = llm; + } + + abstract execute(input: SkillInput): Promise; + + /** + * 流式执行(可选实现,用于 TTFT 统计) + * 默认实现:调用同步 execute 并 yield 结果 + */ + async *executeStream(_input: SkillInput): AsyncGenerator { + const result = await this.execute(_input); + yield result; + } + + protected fail(error: string): SkillOutput { + return { success: false, error }; + } + + protected success(data?: unknown): SkillOutput { + return { success: true, data }; + } +} diff --git a/packages/diting-rag-ts/src/skills/expertise/answer.ts b/packages/diting-rag-ts/src/skills/expertise/answer.ts new file mode 100644 index 000000000000..a9684291903e --- /dev/null +++ b/packages/diting-rag-ts/src/skills/expertise/answer.ts @@ -0,0 +1,694 @@ +// src/skills/expertise/answer.ts +// Answer Skill - 完整版:生成 + 反思 + Citation 验证 + +import type { SkillInput, SkillOutput } from '../base'; +import { BaseSkill } from '../base'; +import type { ChunkItem } from '../../types/chunk'; +import type { LLMMessage } from '../../types/message'; +import { convertCitations, CitationStreamBuffer } from '../../utils/citation'; +import { detectLang } from '../../utils/lang'; + +/** + * Answer 选项 + */ +export interface AnswerOptions { + query: string; + chunks: ChunkItem[]; + history?: LLMMessage[]; + enableReflection?: boolean; + mode?: 'search' | 'chat'; + playbook_hint?: string; + analysis?: string; +} + +/** + * Answer 结果 + */ +export interface AnswerResult { + answer: string; + citedIds: (number | string)[]; + confidence: number; + reflectionLabel?: string; + reflectionReason?: string; + refuse?: boolean; + llm_calls?: number; + status?: string; +} + +// ============================================================ +// 完整版 System Prompt(参考 Python 114 行) +// ============================================================ +const GENERATE_SYSTEM_PROMPT = `You are an enterprise knowledge base QA assistant. Generate an answer to the user's question based STRICTLY on the provided knowledge chunks. + +**LANGUAGE REQUIREMENT (HIGHEST PRIORITY)**: You MUST respond in {question_lang}. This overrides everything else. Even if all knowledge chunks are in a different language, you MUST translate and compose your entire answer in {question_lang}. Never let the source language of the chunks influence the language of your response. + +{analysis_section} + +## Knowledge Chunks +{chunks_text} + +## Answer Quality Standards + +1. **Summary-Detail Structure**: State the core conclusion first, then elaborate in numbered points. Never write large blocks of unstructured text. + - Within each level, put the most critical information first. + - If the user is looking for a document or resource, place the document name and link at the very top of the answer. + +2. **Grounded in Sources**: Every key statement must be supported by a knowledge chunk. Cite using [id-1], [id-2], [id-3], etc. inline (the number after "id-" refers to the chunk number), immediately after the relevant sentence or point. NEVER pile citations at the end. Each citation must reference exactly one source — do not merge citations like [id-1, id-2]; instead write [id-1] [id-2]. + +**MUST INCLUDE AT LEAST ONE CITATION**: Your answer MUST contain at least one [id-1], [id-2], etc. citation. If you fail to include any citation, the answer will be rejected and regenerated. + +3. **Bold Key Information**: Use **bold** for critical data, configuration values, product names, version numbers, and important conclusions. + +4. **Markdown Formatting**: + - First-level points: ordered list (1. 2. 3.) + - Second-level points: unordered list (- ) + +5. **Auxiliary Information**: After answering the core question, proactively add related useful information (prerequisites, caveats, related configurations) if available in the knowledge chunks. Judge independently whether auxiliary information is necessary — include it when it genuinely helps the user, not for every answer. + +6. **Insufficient Information**: If the knowledge chunks do not contain enough information to answer fully, explicitly state which aspects are missing. Provide actionable suggestions (e.g., "contact technical support" or "check version X documentation"). Never give a vague refusal. + +7. **Inductive Synthesis**: When multiple chunks cover the same topic, synthesize them into a coherent answer. Do not copy-paste. If sources conflict or have different timestamps, present them as parallel points at the same level, noting the discrepancy. + +8. **No Fabrication**: If no relevant chunk exists, state clearly that the knowledge base does not contain this information. Do not guess or use outside knowledge. + +9. **Information Filtering**: When multiple sources exist, prefer recent and authoritative documents. Filter by update time, creation time, and relevance. For time-ordered topics, present information in reverse chronological order (most recent first). + +10. **No Reference Meta-Discussion**: Do not mention "reference materials", "knowledge chunks", or "sources" explicitly in the answer. Let citations ([id-1], [id-2], etc.) speak for themselves. + +11. **Concise**: Avoid repetitive expressions. Each point should add new information. In the summary-detail structure, do not repeat the summary content in the detail points. + +`; + +// ============================================================ +// Reflection Prompt +// ============================================================ +const REFLECT_PROMPT = `Evaluate the quality of the following answer. + +**User question:** {question} + +**Knowledge chunks:** +{chunks_text} + +**Generated answer:** +{answer} + +## Evaluation Dimensions + +1. **Faithfulness**: Does the answer only contain information from the knowledge chunks? Flag any statement not supported by the chunks (hallucination). +2. **Completeness**: Does the answer address ALL aspects of the question? Are there important points in the chunks that were missed? +3. **Answer type match**: Does the answer format match the user's intent? (e.g., if they asked to find a document, does the answer provide document names/links? If they asked for steps, are numbered steps provided?) +4. **Citation accuracy**: Are [id-1], [id-2], etc. citations correctly placed inline? Do cited numbers match the actual source chunks? Are citations merged improperly (e.g., [id-1, id-2] instead of [id-1] [id-2])? Are citations piled at the end instead of placed inline? +5. **Relevance**: Is the answer directly addressing the user's question? Is there irrelevant or off-topic content? + +## Output + +Return a JSON object: +{"label": " tag, or the original content + * if no think blocks are found. + */ +export function stripThinkBlocks(content: string): string { + // Fast path: no think tags (support both English and Unicode variants) + if (!content.includes('... blocks (including nested/partial) + // Support both English and Unicode variants for compatibility + const regex = //gu; + const stripped = content.replace(regex, '').trim(); + if (stripped) { + return stripped; + } + + // All content was inside blocks — LLM produced no visible output + return ''; +} + +/** + * Parse search query parameter. + * + * Supports two formats: + * - Plain string: "VPN config" → ["VPN config"] + * - JSON array: '["Q1", "Q2"]' → ["Q1", "Q2"] + * + * JSON array enables concurrent multi-query search via a single tool call. + */ +export function parseSearchQueries(query: string): string[] { + const stripped = query.trim(); + if (stripped.startsWith('[')) { + try { + const parsed = JSON.parse(stripped); + if (Array.isArray(parsed)) { + return parsed.map((q) => String(q).trim()).filter((q) => q); + } + } catch { + // Fall through to single query handling + } + } + // Fallback: single query + return stripped ? [stripped] : []; +} + +/** + * Tokenize query into lowercase token set for similarity comparison. + * + * Uses jieba for Chinese text (no space boundaries) so that reordered queries + * like "什么是HCI?" and "HCI是什么?" are detected as near-duplicates. + * Falls back to whitespace split for pure-ASCII queries. + */ +function queryWords(q: string): Set { + // Check if query contains CJK characters + if (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(q)) { + // Use simple Chinese tokenization (character-based for now) + // In production, could integrate jieba + const tokens: string[] = []; + let currentWord = ''; + for (const char of q) { + if (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(char)) { + if (currentWord) { + tokens.push(currentWord.toLowerCase()); + currentWord = ''; + } + tokens.push(char.toLowerCase()); + } else if (/\s/.test(char)) { + if (currentWord) { + tokens.push(currentWord.toLowerCase()); + currentWord = ''; + } + } else { + currentWord += char; + } + } + if (currentWord) { + tokens.push(currentWord.toLowerCase()); + } + return new Set(tokens.filter((t) => t)); + } + return new Set( + q + .toLowerCase() + .split(/\s+/) + .filter((w) => w) + ); +} + +/** + * Check if new_q is too similar to any existing query (Jaccard similarity). + * + * Returns the similar existing query if found, null otherwise. + */ +export function isSimilarQuery( + newQ: string, + existingQueries: string[], + threshold: number = 0.7 +): string | null { + const newWords = queryWords(newQ); + if (newWords.size === 0) { + return null; + } + + for (const eq of existingQueries) { + const eqWords = queryWords(eq); + if (eqWords.size === 0) { + continue; + } + const intersection = new Set([...newWords].filter((x) => eqWords.has(x))); + const union = new Set([...newWords, ...eqWords]); + if (intersection.size / union.size >= threshold) { + return eq; + } + } + return null; +} + +/** + * Format chunks into references string for display. + * + * Output format: + * source1: [chunk_id1](CITE) [chunk_id2](CITE) + * source2: [chunk_id3](CITE) + */ +export function formatReferences( + chunks: Array<{ + id?: string; + source_name?: string; + sourceName?: string; + dataset_id?: string; + datasetId?: string; + }> +): string { + const references: Map = new Map(); + + for (const chunk of chunks) { + const chunkId = chunk.id || ''; + const sourceName = chunk.sourceName || chunk.source_name || ''; + const datasetId = chunk.datasetId || chunk.dataset_id || ''; + if (!sourceName) { + continue; + } + if (!references.has(sourceName)) { + references.set(sourceName, { + datasetId, + chunkIds: [chunkId] + }); + } else { + references.get(sourceName)!.chunkIds.push(chunkId); + } + } + + if (references.size === 0) { + return ''; + } + + let referencesStr = ''; + for (const [sourceName, ref] of references.entries()) { + const sourceStr = `${sourceName}: `; + const chunkStr = ref.chunkIds.map((id) => `[${id}](CITE)`).join(' '); + referencesStr += `\n${sourceStr}${chunkStr}`; + } + + return referencesStr; +} + +/** + * Text ReAct instruction for tool calling format + */ +export const TEXT_REACT_INSTRUCTION = ` +## Tool Calling Format + +You must call tools using the following JSON format: + +\`\`\`json +{"tool": "tool_name", "args": {"param_name": "param_value"}} +\`\`\` + +Call only one tool at a time. Wait for the tool result before deciding next steps. +When no tool call is needed, output the final answer directly. + +## Available Tools + +{tool_descriptions} +`; + +// ============================================================ +// Dimension Synonyms(对齐 Python dimension_synonyms.yaml) +// ============================================================ + +/** + * 对比分析维度同义词映射 + * 用于匹配搜索查询与比较维度 + */ +export const DIMENSION_SYNONYMS: Record = { + definition: ['是什么', '定义', '定义是', '概念', 'definition', 'what is'], + features: ['功能', '特性', '特点', '功能是', 'features', 'capabilities', 'functions'], + architecture: ['架构', '结构', '体系结构', '架构是', 'architecture', 'structure'], + use_cases: ['使用场景', '适用场景', '应用场景', 'use cases', 'scenarios', 'when to use'], + pricing: ['价格', '费用', '定价', 'pricing', 'cost', 'price', '收费'], + performance: ['性能', '性能是', 'performance', 'speed', 'throughput'], + limitations: ['限制', '局限性', '不足', 'limitations', 'constraints', 'weakness'], + advantages: ['优势', '优点', 'advantages', 'strengths', 'pros'], + disadvantages: ['劣势', '缺点', 'disadvantages', 'cons'], + deployment: ['部署', '安装', 'deployment', 'install', 'setup'], + configuration: ['配置', 'configuration', 'settings'], + security: ['安全', '安全性', 'security', 'secure'], + compatibility: ['兼容性', 'compatibility', 'compatible'], + support: ['支持', 'support', 'supported'], + requirements: ['要求', '需求', 'requirements', 'prerequisites'], + versions: ['版本', 'versions', 'version'], + specifications: ['规格', '参数', 'specifications', 'specs'] +}; + +/** + * 获取维度的同义词列表 + * 对齐 Python _get_dimension_synonyms + */ +export function getDimensionSynonyms(dim: string): string[] { + const dimLower = dim.toLowerCase(); + return DIMENSION_SYNONYMS[dimLower] ?? []; +} + +// ============================================================ +// buildProgressGuidance - 对齐 Python _build_progress_guidance +// ============================================================ + +/** + * 构建渐进式 blackboard brief,用于 agent 决策 + * 对齐 Python _build_progress_guidance + */ +export function buildProgressGuidance( + ctx: { + analysis: string; + playbook: string; + compareObjects: string[]; + discoveredDimensions: string[]; + dimensionCoverage: Map; + comparePhase: string; + rewriteQueries: string[]; + searchQueries: string[]; + searchCount: number; + allChunks: Array<{ sourceName?: string; source_name?: string }>; + findings: string[]; + lacks: string[]; + keyChunkAnnotations: Map; + }, + config: { maxSearchCalls: number }, + toolNames: string[] +): string { + const lines: string[] = [ + '[Blackboard Brief]', + '', + 'IRON LAW: You MUST call a tool. NEVER output free text or a direct answer. ONLY use the following tool formats:' + ]; + + // --- 1. Goal: initial analysis --- + if (ctx.analysis) { + lines.push(`Goal: ${ctx.analysis}`); + } + + // --- 对比查询:显示对象和维度覆盖进度 --- + if (ctx.playbook === 'comparative_analysis') { + // 显示对比对象 + if (ctx.compareObjects && ctx.compareObjects.length > 0) { + lines.push(`Comparing: ${ctx.compareObjects.join(', ')}`); + } + + // 显示发现的维度及覆盖状态 + if (ctx.discoveredDimensions && ctx.discoveredDimensions.length > 0) { + lines.push('Dimension Coverage:'); + for (const dim of ctx.discoveredDimensions) { + const coverage = ctx.dimensionCoverage.get(dim) || {}; + let mark: string; + if (coverage.saturated) { + mark = '✓✓'; // 已饱和 + } else if (coverage.searched) { + mark = '✓'; // 已搜索 + } else { + mark = '○'; // 未搜索 + } + lines.push(` ${mark} ${dim}`); + } + + // 显示阶段 + const phaseName: Record = { + discovery: 'Discovery', + enrichment: 'Enrichment', + done: 'Done' + }; + lines.push(`Phase: ${phaseName[ctx.comparePhase] || ctx.comparePhase}`); + } + } + + // --- 2. Plan: rewrite-produced sub-queries / dimensions --- + if (ctx.rewriteQueries && ctx.rewriteQueries.length > 0) { + const searchedSet = new Set(ctx.searchQueries); + const planItems: string[] = []; + for (const q of ctx.rewriteQueries) { + if (typeof q !== 'string') continue; + const mark = searchedSet.has(q) ? '✓' : '○'; + planItems.push(` ${mark} ${q}`); + } + lines.push('Plan (✓ searched, ○ pending):'); + lines.push(...planItems); + } + + // --- 3. Executed: search history + source coverage --- + lines.push( + `Searches: ${ctx.searchCount}/${config.maxSearchCalls}, chunks: ${ctx.allChunks.length}` + ); + + if (ctx.searchQueries && ctx.searchQueries.length > 0) { + // Only show queries not already listed in Plan + const extra = ctx.searchQueries.filter((q) => !ctx.rewriteQueries?.includes(q)); + if (extra.length > 0) { + lines.push(`Ad-hoc queries: ${extra.map((q) => `"${q}"`).join(', ')}`); + } + } + + if (ctx.allChunks && ctx.allChunks.length > 0) { + const sources: Record = {}; + for (const c of ctx.allChunks) { + const name = c.sourceName || c.source_name || 'unknown'; + sources[name] = (sources[name] || 0) + 1; + } + const sourceList = Object.entries(sources) + .map(([name, cnt]) => `${name}(${cnt})`) + .join(', '); + lines.push(`Sources: ${sourceList}`); + } + + // --- 3b. Findings / Lacks (progressive information collection) --- + if (ctx.findings && ctx.findings.length > 0) { + lines.push('Findings:'); + for (const f of ctx.findings) { + lines.push(` • ${f}`); + } + } + if (ctx.lacks && ctx.lacks.length > 0) { + lines.push('Lacks:'); + for (const la of ctx.lacks) { + lines.push(` • ${la}`); + } + } else if (ctx.findings && ctx.findings.length > 0) { + lines.push('No remaining gaps.'); + } + if (ctx.keyChunkAnnotations && ctx.keyChunkAnnotations.size > 0) { + let whole = 0; + let partial = 0; + for (const v of ctx.keyChunkAnnotations.values()) { + if (v === null) whole++; + else partial++; + } + lines.push( + `Key chunks annotated: ${ctx.keyChunkAnnotations.size} (${whole} whole, ${partial} partial)` + ); + } + + // --- 4. Gap check: ask agent to reason about coverage --- + lines.push(''); + if (ctx.playbook === 'comparative_analysis' && ctx.discoveredDimensions?.length > 0) { + // 对比查询的特殊提示 + const unsatisfiedDims = ctx.discoveredDimensions.filter( + (d) => !ctx.dimensionCoverage.get(d)?.searched + ); + if (unsatisfiedDims.length > 0) { + lines.push( + `Gap: ${unsatisfiedDims.length} dimension(s) not yet searched: ${unsatisfiedDims.join(', ')}` + ); + } else { + lines.push('All dimensions searched. If low info gain on recent searches → call @summary.'); + } + } else { + lines.push( + 'Compare the Goal/Plan above against collected chunks. Identify which aspects are well-covered and which have gaps.' + ); + } + lines.push('- All aspects covered → you MUST call @summary.'); + lines.push('- Gaps remain → you MUST choose the best next action:'); + + // --- 5. Next actions: adapt to registered tools --- + const toolHints: Record = { + search: 'search with different keywords or relaxed conditions', + query_rewrite: 'rewrite or decompose the query from a new angle, then search the variants' + }; + for (const name of toolNames) { + if (name === 'summary') continue; + const hint = toolHints[name] || `use @${name}`; + lines.push(` • @${name} — ${hint}`); + } + + lines.push(''); + lines.push('REMINDER: Output ONLY a tool call. NEVER output free text.'); + + return lines.join('\n'); +} diff --git a/packages/diting-rag-ts/src/utils/token_budget.ts b/packages/diting-rag-ts/src/utils/token_budget.ts new file mode 100644 index 000000000000..11610815d996 --- /dev/null +++ b/packages/diting-rag-ts/src/utils/token_budget.ts @@ -0,0 +1,242 @@ +// src/utils/token_budget.ts +// Token 预算管理 - 对齐 Python token_budget.py + +import type { LLMMessage } from '../types/message'; +import type { ChunkItem } from '../types/chunk'; +import { DEFAULT_SEARCH_CONFIG } from './constants'; + +// Conservative token estimation ratio. +// 对齐 Python _CHARS_PER_TOKEN = 2 +const CHARS_PER_TOKEN = 2; + +// Max chars for assistant messages in chat history compression. +// 对齐 Python _MAX_ASSISTANT_CHARS = 80 +const MAX_ASSISTANT_CHARS = 80; + +// Reserved tokens for summary message in fit_messages. +// 对齐 Python _SUMMARY_RESERVE = 200 +const SUMMARY_RESERVE = 200; + +/** + * 估算 token 数量(对齐 Python TokenBudget.estimate_tokens) + * 保守估算:中文 ~1.5 token/char,英文 ~0.25 token/word + */ +export function estimateTokens(text: string): number { + if (!text) return 0; + return Math.max(Math.ceil(text.length / CHARS_PER_TOKEN), 1); +} + +/** + * 估算 messages 的总 token 数(对齐 Python TokenBudget.estimate_tokens) + */ +export function estimateMessagesTokens(messages: LLMMessage[]): number { + return messages.reduce((sum, msg) => { + const overhead = 4; + return sum + estimateTokens(msg.content) + overhead; + }, 0); +} + +/** + * 估算 chunks 的总 token 数(对齐 Python) + */ +export function estimateChunksTokens(chunks: ChunkItem[]): number { + return chunks.reduce((sum, chunk) => sum + estimateTokens(chunk.content), 0); +} + +/** + * 根据 model context size 计算 token budget(对齐 Python TokenBudget.remaining) + */ +export function calculateTokenBudget( + contextWindow: number, + fixedTokens: number = 0, + maxOutput: number = 1000, + safetyMargin: number = 500 +): number { + return Math.max(contextWindow - fixedTokens - maxOutput - safetyMargin, 0); +} + +/** + * 计算剩余可用 token budget(对齐 Python TokenBudget.remaining) + */ +export function remainingTokens( + modelContextSize: number, + fixedTokens: number, + maxOutput: number, + safetyMargin: number = 500 +): number { + return calculateTokenBudget(modelContextSize, fixedTokens, maxOutput, safetyMargin); +} + +/** + * 将 chunks 调整为适合 token budget(对齐 Python TokenBudget.fit_chunks) + * 优先保留高分数 chunk,保持顺序 + */ +export function fitChunks(chunks: ChunkItem[], budget: number): ChunkItem[] { + if (!chunks || chunks.length === 0) { + return []; + } + + const selected: ChunkItem[] = []; + let used = 0; + + for (const chunk of chunks) { + const cost = estimateTokens(chunk.content) + 30; // metadata overhead + if (used + cost > budget && selected.length > 0) { + break; + } + selected.push(chunk); + used += cost; + } + + if (selected.length === 0 && chunks.length > 0) { + selected.push(chunks[0]); + } + + return selected; +} + +/** + * 内部函数:获取消息角色(对齐 Python TokenBudget._get_msg_role) + */ +function getMsgRole(msg: LLMMessage): string { + return (msg.role || '').toLowerCase(); +} + +/** + * 内部函数:获取消息内容(对齐 Python TokenBudget._get_msg_content) + */ +function getMsgContent(msg: LLMMessage): string { + return msg.content || ''; +} + +/** + * 内部函数:创建新消息并设置内容(对齐 Python TokenBudget._set_msg_content) + */ +function setMsgContent(msg: LLMMessage, content: string): LLMMessage { + return { ...msg, content }; +} + +/** + * 截断 assistant 消息(对齐 Python TokenBudget._truncate_assistant) + * 清除 citations,然后截断 + */ +function truncateAssistant(content: string, maxChars: number = MAX_ASSISTANT_CHARS): string { + // 清除 [id-xxx] 格式 + let result = content.replace(/\[id-[a-zA-Z0-9_-]+\]/g, ''); + // 清除 [hexstring](CITE) 格式 + result = result.replace(/\[[a-f0-9]{24}\]\(CITE\)/g, ''); + result = result.trim(); + if (result.length <= maxChars) { + return result; + } + return result.slice(0, maxChars) + '…'; +} + +/** + * 将 messages 调整为适合 token budget(对齐 Python TokenBudget.fit_messages) + * 策略: + * 1. 保留 _SUMMARY_RESERVE tokens 给摘要 + * 2. 截断所有 assistant 消息 + * 3. 如果全部 fit → 返回 + * 4. 否则:从最新往最老遍历,保留能 fit 的 + * 5. 保证:至少保留最近一轮(human + assistant) + */ +export function fitMessages( + messages: LLMMessage[], + budget: number, + maxAssistantChars: number = MAX_ASSISTANT_CHARS +): LLMMessage[] { + if (!messages || messages.length === 0) { + return []; + } + + // Phase 1: 截断 assistant 消息 + const truncated: LLMMessage[] = []; + for (const msg of messages) { + const role = getMsgRole(msg); + if (role === 'ai' || role === 'assistant') { + const content = getMsgContent(msg); + const newContent = truncateAssistant(content, maxAssistantChars); + truncated.push(setMsgContent(msg, newContent)); + } else { + truncated.push(msg); + } + } + + // Phase 2: 检查是否全部 fit + const effectiveBudget = Math.max(budget - SUMMARY_RESERVE, 0); + const total = sum(truncated, (m) => estimateTokens(getMsgContent(m))); + if (total <= effectiveBudget) { + return truncated; + } + + // Phase 3: 从最新往最老遍历 + const recent: LLMMessage[] = []; + let used = 0; + for (let i = truncated.length - 1; i >= 0; i--) { + const msg = truncated[i]; + const cost = estimateTokens(getMsgContent(msg)); + if (used + cost > effectiveBudget && recent.length >= 2) { + break; + } + recent.push(msg); + used += cost; + } + + recent.reverse(); + + // Phase 4: 摘要早期消息 + const keptCount = recent.length; + const early = + keptCount < truncated.length ? truncated.slice(0, truncated.length - keptCount) : []; + + if (early.length === 0) { + return recent; + } + + // 从早期 human 消息构建摘要主题 + const topics: string[] = []; + for (const msg of early) { + const role = getMsgRole(msg); + if (role === 'human' || role === 'user') { + const content = getMsgContent(msg); + let topic = content.slice(0, 50).replace(/\n/g, ' '); + if (content.length > 50) { + topic += '...'; + } + topics.push(topic); + } + } + + let summaryText: string; + if (topics.length > 0) { + summaryText = `Earlier conversation summary (${topics.length} rounds): ${topics.join('; ')}`; + } else { + summaryText = `Earlier conversation summary (${early.length} messages): omitted`; + } + + // 截断摘要到保留范围内 + const maxSummaryChars = SUMMARY_RESERVE * CHARS_PER_TOKEN; + if (summaryText.length > maxSummaryChars) { + summaryText = summaryText.slice(0, maxSummaryChars) + '...'; + } + + // 生成摘要消息 + const summaryMsg: LLMMessage = { role: 'system', content: summaryText }; + + return [summaryMsg, ...recent]; +} + +/** + * 辅助函数:计算消息列表总 token + */ +function sum(messages: LLMMessage[], selector: (msg: LLMMessage) => number): number { + return messages.reduce((acc, msg) => acc + selector(msg), 0); +} + +/** + * 兼容旧接口 + */ +export function calculateTokenBudgetLegacy(contextWindow: number): number { + return Math.floor(contextWindow * DEFAULT_SEARCH_CONFIG.DEFAULT_TOKEN_BUDGET_RATIO); +} diff --git a/packages/diting-rag-ts/test/compression.test.ts b/packages/diting-rag-ts/test/compression.test.ts new file mode 100644 index 000000000000..b2bb35a7ed33 --- /dev/null +++ b/packages/diting-rag-ts/test/compression.test.ts @@ -0,0 +1,43 @@ +// src/utils/compression.test.ts +import { describe, it, expect } from 'vitest'; +import { extractChatHistoryInfo } from '../src/utils/compression'; +import type { LLMMessage } from '../src/types/message'; + +describe('compression', () => { + describe('extractChatHistoryInfo', () => { + it('should handle empty history', () => { + const result = extractChatHistoryInfo([]); + expect(result.previousTopics).toEqual([]); + expect(result.previousPlaybooks).toEqual([]); + expect(result.entities).toEqual([]); + }); + + it('should extract entities from messages', () => { + const messages: LLMMessage[] = [ + { role: 'user', content: 'Tell me about FastGPT' }, + { role: 'assistant', content: 'FastGPT is a knowledge base system.' }, + { role: 'user', content: 'How about DiTing?' } + ]; + const result = extractChatHistoryInfo(messages); + expect(result.summary).toContain('FastGPT'); + }); + + it('should extract entities from user messages', () => { + const messages: LLMMessage[] = [ + { role: 'user', content: 'Tell me about FastGPT and DiTing' } + ]; + const result = extractChatHistoryInfo(messages); + expect(result.entities).toContain('FastGPT'); + expect(result.entities).toContain('DiTing'); + }); + + it('should handle system messages', () => { + const messages: LLMMessage[] = [ + { role: 'system', content: 'You are a helpful assistant' }, + { role: 'user', content: 'Hello' } + ]; + const result = extractChatHistoryInfo(messages); + expect(result.summary).toBeTruthy(); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/integration/irrelevant_query_early_stop.test.ts b/packages/diting-rag-ts/test/integration/irrelevant_query_early_stop.test.ts new file mode 100644 index 000000000000..17bc374f687b --- /dev/null +++ b/packages/diting-rag-ts/test/integration/irrelevant_query_early_stop.test.ts @@ -0,0 +1,180 @@ +// test/integration/irrelevant_query_early_stop.test.ts +// +// 验证"完全不相关 query"场景下三层协同早停机制: +// Layer 1: Blackboard 引导(prompt 中注入 ⚠️ 信号) +// Layer 2: 路由兜底(consecutiveIrrelevantSearches 达阈值) +// Layer 3: chunk_selector 门控(allChunksIrrelevant → 允许返回 []) +// +// 使用全 mock providers,不依赖真实网络。 +import { describe, it, expect } from 'vitest'; +import { createAgenticSearch } from '../../src/agent/runner'; +import { MockLLMProvider } from '../../src/adapters/mock/llm'; +import { MockVectorSearchProvider } from '../../src/adapters/mock/vector_search'; +import { MockFullTextSearchProvider } from '../../src/adapters/mock/full_text_search'; +import { MockEmbeddingProvider } from '../../src/adapters/mock/embedding'; +import type { ChunkResult } from '../../src/types/chunk'; + +// 模拟 NGFW 知识库中的 chunks,分数极低(与腾讯员工无关) +const NGFW_CHUNKS_LOW_SCORE: ChunkResult[] = [ + { + id: 'chunk-ngfw-001', + content: 'NGFW 防火墙策略配置说明。在企业网络中,防火墙用于控制进出流量...', + score: 0.05, + datasetId: 'ngfw-dataset', + sourceName: 'NGFW配置手册' + }, + { + id: 'chunk-ngfw-002', + content: 'NGFW 版本升级步骤:1. 备份配置文件 2. 上传升级包 3. 执行升级...', + score: 0.04, + datasetId: 'ngfw-dataset', + sourceName: 'NGFW升级手册' + }, + { + id: 'chunk-ngfw-003', + content: '防火墙会话表管理:会话表记录了所有经过防火墙的连接状态信息...', + score: 0.06, + datasetId: 'ngfw-dataset', + sourceName: 'NGFW运维手册' + } +]; + +function buildProviders(llmResponses: string[], chunks: ChunkResult[] = NGFW_CHUNKS_LOW_SCORE) { + return { + llm: new MockLLMProvider({ responses: llmResponses }), + vectorSearch: new MockVectorSearchProvider(chunks), + fullTextSearch: new MockFullTextSearchProvider(chunks), + embed: new MockEmbeddingProvider() + // 不传 reranker → enableRerank=false → 使用原始 vector score(保持低分) + }; +} + +describe('Irrelevant Query Early Stop', () => { + it('[search_only] 单次不相关搜索后即停,返回空 chunks', async () => { + // search_only 模式:threshold=1,1 次不相关即路由到 select_chunks + const providers = buildProviders([ + // 1. route_playbook LLM 调用 + '{"playbook": "simple_query"}', + // 2. agent 第一次调用,执行 @search + '@search({"query": "腾讯有多少员工"})', + // 3-5. Stage 1 subQueryFilter LLM 评分(3 个 chunk 并发) + '1', + '1', + '1' + // text_agent(2) → 循环到 response 1 = playbook JSON → 无 tool calls → select_chunks + ]); + + const agent = createAgenticSearch({ + providers, + config: { + searchOnly: true, + maxSearchCalls: 5, + maxToolCalls: 10, + maxIterations: 20 + }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: '腾讯有多少员工', + datasetIds: ['ngfw-dataset'] + }); + + // search_only threshold=1: 1 次不相关搜索后 routing 截断 + expect(result.searchCount).toBe(1); + // chunk_selector allChunksIrrelevant gate: 返回空 chunks + expect(result.chunks.length).toBe(0); + }); + + it('[normal mode] 两次不相关搜索后路由截断,返回 refuse', async () => { + // 普通模式:threshold=2,agent LLM 两次调 @search,路由兜底截断 + // 使用 general playbook 避免 simpleQuerySearchCappedWithCtx 干扰 + const providers = buildProviders([ + // 1. route_playbook → general playbook(general 不触发 simpleQuerySearchCapped) + '{"playbook": "general"}', + // 2. agent 第一次调用 → @search + '@search({"query": "腾讯有多少员工"})', + // 3. agent 第二次调用(consecutiveIrrelevantSearches=1 < 2,给一次语义机会)→ @search + '@search({"query": "Tencent employee count"})' + // routing 在此截断(consecutiveIrrelevantSearches=2 >= threshold=2) + // 进入 select_chunks → generate_answer(no_relevant_chunks) → refuse + ]); + + const agent = createAgenticSearch({ + providers, + config: { + searchOnly: false, + maxSearchCalls: 5, + maxToolCalls: 10, + maxIterations: 20 + }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: '腾讯有多少员工', + datasetIds: ['ngfw-dataset'] + }); + + // 两次搜索后 routing 截断 + expect(result.searchCount).toBe(2); + // generate_answer(no_relevant_chunks) 路径 + expect(result.refuse).toBe(true); + // 有 answer 文本(refuse 消息) + expect(result.answer).toBeTruthy(); + expect(result.answer).toContain('No relevant information'); + }); + + it('[normal mode] 相关 query 不触发早停', async () => { + // 提供高分 chunks,验证相关 query 不误触早停 + // 使用 searchOnly=true 避免 generate_answer LLM 调用复杂性 + const relevantChunks: ChunkResult[] = [ + { + id: 'chunk-r-001', + content: 'NGFW 防火墙的默认管理员密码是 admin,首次登录需修改...', + score: 0.85, + datasetId: 'ngfw-dataset', + sourceName: 'NGFW快速入门' + }, + { + id: 'chunk-r-002', + content: 'NGFW 登录界面说明:通过浏览器访问 https://192.168.1.1 进入管理界面...', + score: 0.78, + datasetId: 'ngfw-dataset', + sourceName: 'NGFW管理手册' + } + ]; + + const providers = buildProviders( + [ + '{"playbook": "simple_query"}', + '@search({"query": "NGFW 默认密码"})' + // simpleQuerySearchCappedWithCtx 会在高分命中后直接路由到 select_chunks + // 无需第三个 LLM 响应(searchOnly=true 时 select_chunks 后直接结束) + ], + relevantChunks + ); + + const agent = createAgenticSearch({ + providers, + config: { + searchOnly: true, + maxSearchCalls: 5, + maxToolCalls: 10, + maxIterations: 20 + }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'NGFW 默认密码是什么', + datasetIds: ['ngfw-dataset'] + }); + + // 相关 query:consecutiveIrrelevantSearches 应始终为 0,不触发不相关早停 + expect(result.refuse).not.toBe(true); + expect(result.searchCount).toBeGreaterThanOrEqual(1); + // 相关 query 应该返回 chunks(非空) + expect(result.chunks.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/diting-rag-ts/test/integration/language_adaptive_search.test.ts b/packages/diting-rag-ts/test/integration/language_adaptive_search.test.ts new file mode 100644 index 000000000000..70e41bee1f1b --- /dev/null +++ b/packages/diting-rag-ts/test/integration/language_adaptive_search.test.ts @@ -0,0 +1,421 @@ +// test/integration/language_adaptive_search.test.ts +// +// 验证语言自适应检索的端到端行为: +// - initialLanguageStats 传入 invoke/stream 不崩溃 +// - 含 detectedLanguage 的 chunk 检索结果正确累积 +// - null/undefined initialLanguageStats 降级路径 +// - 流式事件包含 route_playbook 和 searching 步骤 +// +// 使用全 mock providers,不依赖真实网络/MongoDB。 +import { describe, it, expect } from 'vitest'; +import { createAgenticSearch } from '../../src/agent/runner'; +import { MockLLMProvider } from '../../src/adapters/mock/llm'; +import { MockVectorSearchProvider } from '../../src/adapters/mock/vector_search'; +import { MockFullTextSearchProvider } from '../../src/adapters/mock/full_text_search'; +import { MockEmbeddingProvider } from '../../src/adapters/mock/embedding'; +import type { ChunkResult } from '../../src/types/chunk'; + +// ============================================================ +// 场景 A: 混合语言 KB (zh 50% + en 50%) +// ============================================================ +const MIXED_CHUNKS: ChunkResult[] = [ + { + id: 'chunk-zh-001', + content: '快速入门指南:如何配置系统参数', + score: 0.9, + datasetId: 'test-dataset', + sourceName: 'guide-zh.pdf', + detectedLanguage: 'zh' + }, + { + id: 'chunk-zh-002', + content: '故障排查:常见错误代码及解决方案', + score: 0.85, + datasetId: 'test-dataset', + sourceName: 'troubleshoot-zh.pdf', + detectedLanguage: 'zh' + }, + { + id: 'chunk-en-001', + content: 'Getting Started: How to configure system parameters', + score: 0.88, + datasetId: 'test-dataset', + sourceName: 'guide-en.pdf', + detectedLanguage: 'en' + }, + { + id: 'chunk-en-002', + content: 'Troubleshooting: Common error codes and solutions', + score: 0.82, + datasetId: 'test-dataset', + sourceName: 'troubleshoot-en.pdf', + detectedLanguage: 'en' + } +]; + +// ============================================================ +// 场景 E: 纯中文 KB + 中文用户(回归) +// ============================================================ +const ZH_ONLY_CHUNKS: ChunkResult[] = [ + { + id: 'chunk-zh-003', + content: '产品配置说明文档:详细介绍了产品的配置方法和参数', + score: 0.95, + datasetId: 'test-dataset', + sourceName: 'config-zh.pdf', + detectedLanguage: 'zh' + }, + { + id: 'chunk-zh-004', + content: '用户手册:包含完整的使用说明和常见问题解答', + score: 0.92, + datasetId: 'test-dataset', + sourceName: 'manual-zh.pdf', + detectedLanguage: 'zh' + } +]; + +// ============================================================ +// 场景 C: 老版本 chunk(无 detectedLanguage 字段) +// ============================================================ +const LEGACY_CHUNKS: ChunkResult[] = [ + { + id: 'chunk-legacy-001', + content: 'How to deploy the application to production', + score: 0.8, + datasetId: 'test-dataset', + sourceName: 'deploy-guide.pdf' + // 无 detectedLanguage → LanguageTracker 记为 'unknown' + } +]; + +// ============================================================ +// Helpers +// ============================================================ + +function buildProviders(llmResponses: string[], chunks: ChunkResult[]) { + return { + llm: new MockLLMProvider({ responses: llmResponses }), + vectorSearch: new MockVectorSearchProvider(chunks), + fullTextSearch: new MockFullTextSearchProvider(chunks), + embed: new MockEmbeddingProvider() + }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe('Language Adaptive Search Integration', () => { + // ── Scenario A: Mixed language KB ────────────────────────── + describe('Scenario A: Mixed language KB (zh 50% + en 50%)', () => { + it('invoke: initialLanguageStats with mixed distribution succeeds', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "如何配置系统参数"})', '@chunk_selector()'], + MIXED_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: '如何配置系统参数', + datasetIds: ['test-dataset'], + initialLanguageStats: { zh: 100, en: 100 } + }); + + // 不应崩溃,正常返回结果 + expect(result).toBeDefined(); + expect(result.chunks).toBeDefined(); + expect(result.searchCount).toBeGreaterThanOrEqual(0); + }); + + it('stream: emits route_playbook and searching events', async () => { + const providers = buildProviders( + [ + '{"playbook": "general"}', + '@search({"query": "system configuration"})', + '@chunk_selector()' + ], + MIXED_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const events: string[] = []; + const stream = agent.stream({ + query: 'system configuration', + datasetIds: ['test-dataset'], + initialLanguageStats: { zh: 100, en: 100 } + }); + + for await (const item of stream) { + if ('step' in item) { + events.push(item.step); + } + } + + // 至少应有 route_playbook 事件 + expect(events).toContain('playbook_selected'); + // 如果有搜索,应有 searching 事件 + expect(events.length).toBeGreaterThan(0); + }); + }); + + // ── Scenario D: DB sampling exception (null) ─────────────── + describe('Scenario D: DB sampling exception (initialLanguageStats = null)', () => { + it('invoke: null initialLanguageStats does not crash', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "test query"})', '@chunk_selector()'], + MIXED_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'test query', + datasetIds: ['test-dataset'], + initialLanguageStats: null + }); + + expect(result).toBeDefined(); + expect(result.chunks).toBeDefined(); + }); + + it('invoke: undefined initialLanguageStats (not passed) does not crash', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "another query"})', '@chunk_selector()'], + MIXED_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'another query', + datasetIds: ['test-dataset'] + // 不传 initialLanguageStats + }); + + expect(result).toBeDefined(); + expect(result.chunks).toBeDefined(); + }); + }); + + // ── Scenario E: Pure Chinese KB + Chinese user (regression) ─ + describe('Scenario E: Pure Chinese KB regression', () => { + it('invoke: zh-only KB with zh query returns results', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "产品配置说明"})', '@chunk_selector()'], + ZH_ONLY_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: '产品配置说明', + datasetIds: ['test-dataset'], + initialLanguageStats: { zh: 200 } + }); + + expect(result).toBeDefined(); + expect(result.searchCount).toBeGreaterThanOrEqual(0); + // 应返回中文 chunks + const zhChunks = result.chunks.filter((c) => c.detectedLanguage === 'zh'); + expect(zhChunks.length).toBeGreaterThanOrEqual(0); + }); + + it('stream: zh-only KB stream emits proper event sequence', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "用户手册"})', '@chunk_selector()'], + ZH_ONLY_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const events: { step: string; detail?: string }[] = []; + const stream = agent.stream({ + query: '用户手册', + datasetIds: ['test-dataset'], + initialLanguageStats: { zh: 200 } + }); + + for await (const item of stream) { + if ('step' in item) { + events.push({ step: item.step, detail: item.detail }); + } + } + + // 基本事件序列验证 + const steps = events.map((e) => e.step); + expect(steps).toContain('playbook_selected'); + }); + }); + + // ── Scenario F: searchOnly mode + English user (regression) ─ + describe('Scenario F: searchOnly mode regression', () => { + it('invoke: searchOnly mode returns chunks', async () => { + const providers = buildProviders( + ['{"playbook": "general"}', '@search({"query": "deployment guide"})', '@chunk_selector()'], + LEGACY_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'deployment guide', + datasetIds: ['test-dataset'], + initialLanguageStats: { en: 200 } + }); + + expect(result).toBeDefined(); + expect(result.chunks.length).toBeGreaterThanOrEqual(0); + // queryLanguage is set by caller (agenticSearchDispatch), not by agent itself + // Here we verify the result structure is intact + }); + + it('stream: searchOnly mode yields final result with chunks', async () => { + const providers = buildProviders( + ['{"playbook": "simple_query"}', '@search({"query": "deployment"})', '@chunk_selector()'], + LEGACY_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + let finalResult: any = null; + const stream = agent.stream({ + query: 'deployment', + datasetIds: ['test-dataset'], + initialLanguageStats: { en: 200 } + }); + + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + finalResult = item; + } + } + + expect(finalResult).toBeDefined(); + expect(finalResult.chunks).toBeDefined(); + }); + }); + + // ── Scenario C: Legacy chunks without detectedLanguage ────── + describe('Scenario C: Legacy chunks without detectedLanguage', () => { + it('invoke: legacy chunks handled gracefully', async () => { + const providers = buildProviders( + [ + '{"playbook": "general"}', + '@search({"query": "deploy application"})', + '@chunk_selector()' + ], + LEGACY_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'deploy application', + datasetIds: ['test-dataset'], + initialLanguageStats: { en: 150 } + }); + + expect(result).toBeDefined(); + expect(result.searchCount).toBeGreaterThanOrEqual(0); + // 老版本 chunk 没有 detectedLanguage → LanguageTracker 记为 'unknown' + // 不影响正常流程 + }); + }); + + // ── Scenario B: English user, Japanese-dominant KB ────────── + describe('Scenario B: English user, KB 80% ja', () => { + const JA_DOMINANT_CHUNKS: ChunkResult[] = [ + { + id: 'chunk-ja-001', + content: 'システム設定の変更方法について説明します', + score: 0.95, + datasetId: 'test-dataset', + sourceName: 'config-ja.pdf', + detectedLanguage: 'ja' + }, + { + id: 'chunk-ja-002', + content: 'トラブルシューティング:一般的なエラーと解決策', + score: 0.9, + datasetId: 'test-dataset', + sourceName: 'troubleshoot-ja.pdf', + detectedLanguage: 'ja' + }, + { + id: 'chunk-en-003', + content: 'Quick start guide for system administrators', + score: 0.7, + datasetId: 'test-dataset', + sourceName: 'quickstart-en.pdf', + detectedLanguage: 'en' + } + ]; + + it('invoke: ja-dominant KB with English user query succeeds', async () => { + const providers = buildProviders( + [ + '{"playbook": "general"}', + '@search({"query": "system configuration"})', + '@chunk_selector()' + ], + JA_DOMINANT_CHUNKS + ); + + const agent = createAgenticSearch({ + providers, + config: { searchOnly: true, maxSearchCalls: 5, maxToolCalls: 10, maxIterations: 20 }, + mode: 'text' + }); + + const result = await agent.invoke({ + query: 'system configuration', + datasetIds: ['test-dataset'], + initialLanguageStats: { ja: 160, en: 40 } + }); + + expect(result).toBeDefined(); + // 日语主导的 chunks 应出现在结果中 + const jaChunks = result.chunks.filter((c) => c.detectedLanguage === 'ja'); + expect(jaChunks.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/json_parser.test.ts b/packages/diting-rag-ts/test/json_parser.test.ts new file mode 100644 index 000000000000..c04fb66b327f --- /dev/null +++ b/packages/diting-rag-ts/test/json_parser.test.ts @@ -0,0 +1,12 @@ +// src/utils/json_parser.test.ts +import { describe, it, expect } from 'vitest'; +import { parseJSON } from '../src/utils/json_parser'; + +describe('json_parser', () => { + describe('parseJSON', () => { + it('should parse JSON objects', () => { + const result = parseJSON<{ name: string }>('{"name": "test"}'); + expect(result).toEqual({ name: 'test' }); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/model-compat.test.ts b/packages/diting-rag-ts/test/model-compat.test.ts new file mode 100644 index 000000000000..b18e7443881d --- /dev/null +++ b/packages/diting-rag-ts/test/model-compat.test.ts @@ -0,0 +1,1674 @@ +#!/usr/bin/env tsx +/// +// test/model-compat.test.ts +// 模型兼容性测试脚本 - 验证任意 LLM 在 diting-rag-ts 中的工具调用行为 +// +// 用法: +// tsx test/model-compat.test.ts [options] +// +// 选项: +// --model 模型名称 (默认: LLM_MODEL 环境变量) +// --endpoint API 端点 (默认: LLM_BASE_URL 环境变量) +// --api-key API 密钥 (默认: LLM_API_KEY 环境变量) +// --layer 测试层: api | agent | both (默认: both) +// --output 输出 JSON 报告文件路径 (可选) +// --timeout 单个用例超时毫秒数 (默认: 30000) +// --help, -h 显示帮助 + +// ============================================================ +// 依赖 import(ESM 中所有 import 必须在顶部) +// ============================================================ + +import * as dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// dotenv 必须在使用 env 变量之前加载(parseArgs 读 process.env.*) +const __scriptDir = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: join(__scriptDir, '..', '.env') }); + +// src/ 工具函数(仅 import,不修改) +import { TOOL_DEFINITIONS, getSystemPrompt, buildTextReactPrompt } from '../src/agent/tools.js'; +import { MockVectorSearchProvider } from '../src/adapters/mock/vector_search.js'; +import { MockFullTextSearchProvider } from '../src/adapters/mock/full_text_search.js'; +import { MockEmbeddingProvider } from '../src/adapters/mock/embedding.js'; +import { createAgenticSearch } from '../src/agent/runner.js'; +import { BuiltInLLMAdapter } from '../src/adapters/builtIn/llm/adapter.js'; +import { ConsoleLogger } from '../src/builtIn/logger/consoleLogger.js'; +import { LogLevel } from '../src/ports/logger.js'; +import type { ChunkResult } from '../src/types/chunk.js'; +import type { AgenticSearchResult } from '../src/agent/runner.js'; +import type { LLMProvider } from '../src/ports/llm'; +import type { LLMMessage, LLMResponse, LLMCallOptions, ToolCall } from '../src/types/message'; + +// 全局logger +const logger = new ConsoleLogger({ level: LogLevel.DEBUG, prefix: 'compat testing' }); + +// ============================================================ +// 自定义 LLM Provider(直接基于端口约定,不走 BuiltInLLMAdapter) +// ============================================================ + +/** + * 直接调用 OpenAI 兼容 /chat/completions,支持 native function calling。 + * 与 API 层测试使用完全相同的调用逻辑,避免 BuiltInLLMAdapter 中可能的差异。 + */ +class DirectLLMProvider implements LLMProvider { + constructor( + private model: string, + private endpoint: string, + private apiKey: string, + private timeoutMs: number, + private globalLogger: ConsoleLogger + ) {} + + async chat(messages: LLMMessage[], options?: LLMCallOptions): Promise { + const model = options?.model || this.model; + const body: Record = { + model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature ?? 0, + max_tokens: options?.maxTokens ?? 8192, + stream: false + }; + if (body.model === 'kimi-k2.5') { + body.temperature = 1; + } + + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + // 透传 extra 字段(enable_thinking 等模型特定参数) + if (options?.extra) { + Object.assign(body, options.extra); + } + + this.globalLogger.debug('[DirectLLM] chat request', { + model, + messageCount: messages.length, + hasTools: !!options?.tools?.length + }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const resp = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + + const data = (await resp.json()) as { + choices?: Array<{ + message: { + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + reasoning_content?: string; + }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + + const choice = data.choices?.[0]; + const msg = choice?.message; + + this.globalLogger.debug('[DirectLLM] response', { + hasToolCalls: !!msg?.tool_calls?.length, + contentLen: (msg?.content ?? '').length + }); + return { + content: msg?.content ?? '', + toolCalls: (msg?.tool_calls ?? []).map( + (tc): ToolCall => ({ + id: tc.id, + type: 'function', + function: { name: tc.function.name, arguments: tc.function.arguments } + }) + ), + reasoning: msg?.reasoning_content, + usage: { + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens + } + }; + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + this.globalLogger.error(`[DirectLLM] chat failed: ${err}`); + throw e; + } finally { + clearTimeout(timer); + } + } + + async *chatStream(messages: LLMMessage[], options?: LLMCallOptions): AsyncIterable { + const model = options?.model || this.model; + const body: Record = { + model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature ?? 0, + max_tokens: options?.maxTokens ?? 8192, + stream: true + }; + if (body.model === 'kimi-k2.5') { + body.temperature = 1; + } + + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + let resp: Response; + try { + resp = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + if (!resp.body) throw new Error('No response body'); + + this.globalLogger.debug('[DirectLLM] chatStream connected', { model }); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + this.globalLogger.error(`[DirectLLM] chatStream request failed: ${err}`); + clearTimeout(timer); + throw e; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data:')) continue; + const dataStr = trimmed.slice(5).trim(); + if (dataStr === '[DONE]') return; + + try { + const data = JSON.parse(dataStr) as { + choices?: Array<{ delta: { content?: string; reasoning_content?: string } }>; + }; + const delta = data.choices?.[0]?.delta; + if (delta) { + yield { content: delta.content || '', reasoning: delta.reasoning_content }; + } + } catch (e) { + this.globalLogger.debug( + `[DirectLLM] chatStream parse error: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + } + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + this.globalLogger.error(`[DirectLLM] chatStream stream error: ${err}`); + throw e; + } finally { + clearTimeout(timer); + } + } + + getModelInfo() { + return { name: this.model, contextWindow: 16000, maxOutputTokens: 8192 }; + } +} + +// ============================================================ +// 配置类型 & CLI 解析 +// ============================================================ + +export type LLMType = 'direct' | 'builtin'; +export type Layer = 'api' | 'agent' | 'both'; + +export interface CompatConfig { + model: string; + endpoint: string; // 例如: http://10.74.124.139:30001/v1 + apiKey: string; + layer: Layer; + llmType: LLMType; // direct = DirectLLMProvider, builtin = BuiltInLLMAdapter + output?: string; // 输出 JSON 文件路径(可选) + timeoutMs: number; // 单个用例超时(默认 30000ms) + enableThinking?: boolean; // 是否开启思考模式(默认关闭,--thinking 开启) +} + +function parseArgs(): CompatConfig { + const args = process.argv.slice(2); + + // 从 .env 读取默认值 + const config: CompatConfig = { + model: process.env.LLM_MODEL || 'unknown-model', + endpoint: (process.env.LLM_BASE_URL || 'http://localhost:11434/v1').replace(/\/$/, ''), + apiKey: process.env.LLM_API_KEY || '', + layer: 'both', + llmType: 'direct', + timeoutMs: 300_000 + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--model': + config.model = args[++i]; + break; + case '--endpoint': + config.endpoint = args[++i].replace(/\/$/, ''); + break; + case '--api-key': + config.apiKey = args[++i]; + break; + case '--layer': + config.layer = args[++i] as Layer; + break; + case '--output': + config.output = args[++i]; + break; + case '--timeout': + config.timeoutMs = parseInt(args[++i], 10); + break; + case '--thinking': + config.enableThinking = true; + break; + case '--llm-type': + config.llmType = args[++i] as LLMType; + break; + case '--builtin': + config.llmType = 'builtin'; + break; + case '--help': + case '-h': + console.log(` +Usage: tsx test/model-compat.test.ts [options] + +Options: + --model Model name (default: LLM_MODEL env var) + --endpoint API endpoint (default: LLM_BASE_URL env var) + --api-key API key (default: LLM_API_KEY env var) + --layer Which layer to test: api | agent | both (default: both) + --llm-type LLM provider: direct | builtin (default: direct) + --builtin Alias for --llm-type builtin + --output Write JSON report to file (optional) + --timeout Per-test timeout in ms (default: 30000) + --thinking Enable thinking/reasoning mode (default: off, sends enable_thinking=false) + --help, -h Show this help + +Examples: + # Test current model from .env + tsx test/model-compat.test.ts + + # Test specific model, API layer only + tsx test/model-compat.test.ts --model kimi-k2.5 --endpoint http://... --layer api + + # Test with thinking mode enabled (Qwen3, deepseek-r1, etc.) + tsx test/model-compat.test.ts --thinking + + # Test with BuiltInLLMAdapter (includes truncation auto-retry) + tsx test/model-compat.test.ts --builtin --layer agent + + # Test and save report + tsx test/model-compat.test.ts --output /tmp/report.json +`); + process.exit(0); + break; + } + } + + if (!['api', 'agent', 'both'].includes(config.layer)) { + console.error(`Invalid --layer value: "${config.layer}". Must be: api | agent | both`); + process.exit(1); + } + if (!['direct', 'builtin'].includes(config.llmType)) { + console.error(`Invalid --llm-type value: "${config.llmType}". Must be: direct | builtin`); + process.exit(1); + } + + return config; +} + +// ============================================================ +// 测试结果类型 +// ============================================================ + +export type TestStatus = 'pass' | 'fail' | 'error' | 'skip' | 'info'; + +export interface TestCase { + id: string; + name: string; + status: TestStatus; + durationMs: number; + detail?: string; // 失败/错误时的说明 + info?: Record; // T6 推理探测等附加信息 + /** 完整响应内容(用于 T1-T4 调试) */ + fullResponse?: string; +} + +export interface LayerResult { + passed: number; // pass 状态数量(info/skip 不计入) + total: number; // pass + fail + error 数量(info/skip 不计入) + cases: TestCase[]; +} + +export interface CompatReport { + model: string; + endpoint: string; + timestamp: string; + layers: { + api?: LayerResult; + agent?: LayerResult; + }; + recommendations: string[]; +} + +// ============================================================ +// 报告输出(终端带颜色) +// ============================================================ + +const RESET = '\x1b[0m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +function statusIcon(s: TestStatus): string { + if (s === 'pass') return `${GREEN}✓ PASS${RESET}`; + if (s === 'fail') return `${RED}✗ FAIL${RESET}`; + if (s === 'error') return `${RED}! ERROR${RESET}`; + if (s === 'info') return `${CYAN}i INFO${RESET}`; + return `${DIM}○ SKIP${RESET}`; +} + +function printLayerResults(title: string, layer: LayerResult): void { + const sep = '─'.repeat(Math.max(0, 54 - title.length)); + console.log(`\n${BOLD}─── ${title} ${sep}${RESET}`); + for (const tc of layer.cases) { + const dur = tc.durationMs > 0 ? ` ${DIM}(${tc.durationMs}ms)${RESET}` : ''; + const det = tc.detail ? ` ${DIM}${tc.detail}${RESET}` : ''; + console.log(`${tc.id.padEnd(4)} ${tc.name.padEnd(32)} ${statusIcon(tc.status)}${dur}${det}`); + if (tc.info && Object.keys(tc.info).length > 0) { + for (const [k, v] of Object.entries(tc.info)) { + console.log(` ${DIM}${k}: ${JSON.stringify(v)}${RESET}`); + } + } + if (tc.fullResponse) { + // pretty print the full response + try { + const parsed = JSON.parse(tc.fullResponse); + console.log( + `${DIM} full_response: ${JSON.stringify(parsed, null, 2).split('\n').join('\n ')}${RESET}` + ); + } catch { + console.log(`${DIM} full_response: ${tc.fullResponse}${RESET}`); + } + } + } +} + +export function printReport(report: CompatReport, llmType?: string): void { + const W = 62; + const llmNote = llmType ? ` [${llmType}]` : ''; + console.log(`\n${BOLD}╔${'═'.repeat(W)}╗`); + console.log( + `║ Model Compatibility Report${llmNote}${' '.repeat(Math.max(0, W - 28 - llmNote.length))}║` + ); + console.log(`║ Model: ${report.model.slice(0, W - 14).padEnd(W - 12)}║`); + console.log(`║ Endpoint: ${report.endpoint.slice(0, W - 14).padEnd(W - 12)}║`); + console.log(`║ Time: ${report.timestamp.padEnd(W - 12)}║`); + console.log(`╚${'═'.repeat(W)}╝${RESET}`); + + if (report.layers.api) printLayerResults('API Layer', report.layers.api); + if (report.layers.agent) printLayerResults('Agent Layer', report.layers.agent); + + console.log(`\n${BOLD}─── Summary ${'─'.repeat(51)}${RESET}`); + if (report.layers.api) { + console.log(`API Layer: ${report.layers.api.passed}/${report.layers.api.total} passed`); + } + if (report.layers.agent) { + console.log(`Agent Layer: ${report.layers.agent.passed}/${report.layers.agent.total} passed`); + } + const totalPassed = (report.layers.api?.passed ?? 0) + (report.layers.agent?.passed ?? 0); + const totalTests = (report.layers.api?.total ?? 0) + (report.layers.agent?.total ?? 0); + console.log(`Overall: ${totalPassed}/${totalTests} passed`); + + if (report.recommendations.length > 0) { + console.log(`\n${BOLD}─── Recommendations ${'─'.repeat(43)}${RESET}`); + for (const rec of report.recommendations) { + console.log(`${YELLOW}⚠ ${rec}${RESET}`); + } + } + console.log(); +} + +export function makeLayerResult(cases: TestCase[]): LayerResult { + const countable = cases.filter((c) => c.status !== 'info' && c.status !== 'skip'); + return { + passed: countable.filter((c) => c.status === 'pass').length, + total: countable.length, + cases + }; +} + +export function buildRecommendations(report: CompatReport): string[] { + const recs: string[] = []; + const allCases = [...(report.layers.api?.cases ?? []), ...(report.layers.agent?.cases ?? [])]; + const hints: Record = { + T1: 'T1 FAILED: model does not support native function calling → use text agent mode.', + T2: 'T2 FAILED: model selects wrong tool in native mode → validate system prompt or switch to text mode.', + T3: 'T3 FAILED: model returns malformed tool arguments → parse error will break agent.', + T5: 'T5 FAILED: model does not follow @tool ReAct format → text agent mode will not work either.', + A1: 'A1 FAILED: agent pipeline did not complete search+answer → check model instruction following.', + A2: 'A2 FAILED: tool call loop detected → model may repeat same tools; consider adding stop tokens.', + A3: 'A3 FAILED: agent did not stop on irrelevant query → may waste search budget.', + A4: 'A4 FAILED: playbook routing incorrect → comparative questions routed to wrong playbook.' + }; + // Detect AI SDK fallback success in T1/T2 + const t1 = allCases.find((c) => c.id === 'T1'); + const t2 = allCases.find((c) => c.id === 'T2'); + for (const tc of [t1, t2]) { + if (tc && tc.status === 'pass' && tc.detail?.includes('AI SDK')) { + recs.push( + `${tc.id} passed via AI SDK fallback — model supports tools through SDK even though raw API returns null tool_calls` + ); + } + } + for (const tc of allCases) { + if (tc.status === 'fail' || tc.status === 'error') { + const hint = hints[tc.id] || `${tc.id} FAILED: ${tc.detail ?? '(no detail)'}`; + recs.push(hint); + } + } + return recs; +} + +// ============================================================ +// API 层 HTTP 工具函数 +// ============================================================ + +/** OpenAI 兼容 API 的原始响应类型(宽松,包含各厂商扩展字段) */ +interface RawLLMResponse { + id?: string; + choices?: Array<{ + message: { + role: string; + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + reasoning_content?: string; // MiniMax / Qwen3 等思考字段 + }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + error?: { message: string; type?: string; code?: string }; +} + +/** 发送给 API 的请求体 */ +interface LLMAPIRequest { + model: string; + messages: Array<{ role: string; content: string }>; + tools?: unknown[]; + tool_choice?: string; + temperature?: number; + max_tokens?: number; + stream?: false; + enable_thinking?: boolean; // vLLM / Qwen3 等支持,关闭思考模式 + [key: string]: unknown; // 透传其他字段 +} + +/** + * 直接 fetch 调用 LLM 兼容 API(/chat/completions),带超时保护 + * 返回 { ok: true, data } 或 { ok: false, error: string } + */ +async function callLLMAPI( + cfg: CompatConfig, + body: LLMAPIRequest +): Promise<{ ok: true; data: RawLLMResponse } | { ok: false; error: string }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), cfg.timeoutMs); + if (body.model === 'kimi-k2.5') { + body.temperature = 1; + } + body.enable_thinking = (body.enable_thinking ?? false) || (cfg.enableThinking ?? false); + + try { + const resp = await fetch(`${cfg.endpoint}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${cfg.apiKey}` + }, + body: JSON.stringify({ + ...body, + stream: false + // 默认关闭思考模式(除非 --thinking),减少 token 浪费,让工具调用更直接 + // enable_thinking: cfg.enableThinking ?? false, + // reasoning_split: true + }), + signal: controller.signal + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + return { ok: false, error: `HTTP ${resp.status}: ${text}` }; + } + + const data = (await resp.json()) as RawLLMResponse; + if (data.error) { + return { ok: false, error: `API error: ${data.error.message}` }; + } + return { ok: true, data }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (controller.signal.aborted) { + return { ok: false, error: `Timeout after ${cfg.timeoutMs}ms` }; + } + return { ok: false, error: msg }; + } finally { + clearTimeout(timer); + } +} + +/** + * 包装单个测试用例的执行,捕获异常 + 记录耗时 + * 不会因单个用例失败而影响其他用例 + */ +async function runCase( + id: string, + name: string, + fn: () => Promise> +): Promise { + const start = Date.now(); + try { + const result = await fn(); + return { id, name, durationMs: Date.now() - start, ...result }; + } catch (e) { + return { + id, + name, + durationMs: Date.now() - start, + status: 'error', + detail: e instanceof Error ? `${e.message}` : String(e) + }; + } +} + +// ============================================================ +// Anthropic 格式 Fallback(raw fetch,不依赖 AI SDK) +// ============================================================ + +/** + * 通过 Anthropic Messages API 格式尝试检测工具调用。 + * 用于 OpenAI 兼容格式 (callLLMAPI) 无法检测到 tool_calls 时的后备方案, + * 覆盖原生使用 Anthropic 格式的模型(如 MiniMax)。 + * Returns { ok: true, toolCalls: string[] } or { ok: false, error: string } + */ +async function tryAnthropicFormatToolCall( + cfg: CompatConfig, + messages: Array<{ role: string; content: string }>, + tools: unknown[] +): Promise<{ ok: true; toolCalls: string[] } | { ok: false; error: string }> { + try { + // 将 OpenAI-style tools 转为 Anthropic input_schema 格式 + const anthropicTools = (tools as any[]).map((t: any) => { + const fn = t.function ?? t; + return { + name: fn.name, + description: fn.description ?? '', + input_schema: fn.parameters ?? { type: 'object', properties: {} } + }; + }); + + const resp = await fetch(`${cfg.endpoint}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': cfg.apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: cfg.model, + max_tokens: 1024, + messages, + tools: anthropicTools + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 30000) + }); + + if (!resp.ok) { + const errText = await resp.text().catch(() => ''); + return { ok: false, error: `Anthropic format HTTP ${resp.status}: ${errText.slice(0, 200)}` }; + } + + const data = (await resp.json()) as any; + const toolCalls: string[] = []; + + // Anthropic 响应中 tool_use 在 content 数组里 + for (const block of data.content ?? []) { + if (block.type === 'tool_use') { + toolCalls.push(block.name); + } + } + + if (toolCalls.length > 0) { + return { ok: true, toolCalls }; + } + + return { + ok: false, + error: `No tool_use blocks in Anthropic response (stop_reason=${data.stop_reason})` + }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +// ============================================================ +// API 层测试用例 T1-T6 +// ============================================================ + +/** 用于测试工具选择的简单问题(中文,明确需要搜索) */ +const SIMPLE_QUESTION = '超融合的默认密码是什么'; + +/** + * 剥离 ...<\/think> 块,返回纯内容 + * 部分 未闭合时也尝试截断 + */ +function stripThink(content: string): { stripped: string; hadThink: boolean } { + const hadThink = content.includes('...<\/think> + let stripped = content.replace(//g, '').trim(); + // 未闭合的 ...(到末尾) + // 未闭合的 ... + if (!stripped && hadThink) { + const idx1 = content.indexOf(' / ) */ +function detectTextReact(content: string): { found: boolean; format: string; toolName: string } { + // 直接标签格式: ..., ..., ... + if (/', toolName: 'search' }; + if (/', toolName: 'query_rewrite' }; + if (/', toolName: 'summary' }; + // Anthropic-style: args => {"query": "..."} + if (/', toolName: 'search' }; + if (/', toolName: 'query_rewrite' }; + if (/', toolName: 'summary' }; + // @search / @summary 等 + if (content.includes('@search')) return { found: true, format: '@search', toolName: 'search' }; + if (content.includes('@query_rewrite')) + return { found: true, format: '@query_rewrite', toolName: 'query_rewrite' }; + if (content.includes('@summary')) return { found: true, format: '@summary', toolName: 'summary' }; + // XML 工具调用格式 + if (/ { + return runCase('T1', 'Function Calling 基础可用性', async () => { + const systemPrompt = getSystemPrompt('simple_query', true); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + tools: TOOL_DEFINITIONS, + tool_choice: 'auto', + temperature: 0, + max_tokens: 8192, // 思考型模型需要足够的 token 空间完成思考 + 工具调用 + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error, fullResponse: res.error }; + + const choice = res.data.choices?.[0]; + const msg = choice?.message; + if (!msg) + return { + status: 'fail', + detail: 'No message in response', + fullResponse: JSON.stringify(res.data) + }; + + const finishReason = choice?.finish_reason ?? 'unknown'; + const hasCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0; + + // 优先:native function calling + if (hasCalls) { + return { + status: 'pass', + detail: `tool_calls count=${msg.tool_calls!.length}, finish_reason=${finishReason}`, + fullResponse: JSON.stringify(res.data) + }; + } + + // Fallback:检查 content 中是否有 JSON / @search 等工具调用格式 + const rawContent = msg.content ?? ''; + const { stripped } = stripThink(rawContent); + const hasThink = rawContent.includes(''); + const contentCall = detectTextReact(rawContent); + const hasQueries = /"queries"\s*:/.test(stripped) || /"query"\s*:/.test(stripped); + + if (contentCall.found || hasQueries) { + return { + status: 'pass', + detail: [ + `No tool_calls but content contains`, + contentCall.found ? `${contentCall.format} (tool=${contentCall.toolName})` : '', + hasQueries ? 'JSON queries format' : '' + ] + .filter(Boolean) + .join(' + '), + fullResponse: JSON.stringify(res.data) + }; + } + + if (finishReason === 'length') { + return { + status: 'fail', + detail: `Truncated (finish_reason=length): content_len=${rawContent.length}`, + fullResponse: JSON.stringify(res.data) + }; + } + + // Tier 3: Anthropic format fallback + const aiResult = await tryAnthropicFormatToolCall( + cfg, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + [TOOL_DEFINITIONS[0]] + ); + if (aiResult.ok && aiResult.toolCalls.length > 0) { + return { + status: 'pass', + detail: `No tool_calls from raw API but Anthropic format detected: [${aiResult.toolCalls.join(', ')}]`, + fullResponse: JSON.stringify({ + rawFetch: res.data, + anthropicFormat: { toolCalls: aiResult.toolCalls } + }) + }; + } + + return { + status: 'fail', + detail: [ + `No tool_calls (finish_reason=${finishReason})`, + hasThink ? `think=YES` : `think=NO`, + `stripped="${stripped}"`, + aiResult.ok + ? `anthropic format: failed (${aiResult.toolCalls.length} calls)` + : `anthropic format: ${aiResult.error}` + ].join(', '), + fullResponse: JSON.stringify(res.data) + }; + }); +} + +/** + * T2: 工具选择准确性 + * 有 tool_calls 时验证第一个工具为 search;无 tool_calls 时检查 content fallback。 + */ +async function testT2ToolSelectionAccuracy(cfg: CompatConfig): Promise { + return runCase('T2', '工具选择准确性', async () => { + const systemPrompt = getSystemPrompt('simple_query', true); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + tools: TOOL_DEFINITIONS, + tool_choice: 'auto', + temperature: 0, + max_tokens: 8192, + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error, fullResponse: res.error }; + + const choice = res.data.choices?.[0]; + const msg = choice?.message; + if (!msg) + return { + status: 'fail', + detail: 'No message in response', + fullResponse: JSON.stringify(res.data) + }; + + const finishReason = choice?.finish_reason ?? 'unknown'; + const toolCalls = msg.tool_calls ?? []; + const rawContent = msg.content ?? ''; + const { stripped } = stripThink(rawContent); + const hasThink = rawContent.includes(''); + + if (toolCalls.length > 0) { + const firstTool = toolCalls[0].function.name; + if (firstTool === 'search') { + return { + status: 'pass', + detail: `correct: first_tool=${firstTool}`, + fullResponse: JSON.stringify(res.data) + }; + } + return { + status: 'fail', + detail: `selected '${firstTool}' first, expected 'search'. all_calls=[${toolCalls.map((t) => t.function.name).join(',')}]`, + fullResponse: JSON.stringify(res.data) + }; + } + + // Fallback:检查 content 中的工具调用格式 + const contentCall = detectTextReact(rawContent); + const hasQueries = /"queries"\s*:/.test(stripped) || /"query"\s*:/.test(stripped); + if (contentCall.found || hasQueries) { + return { + status: 'pass', + detail: [ + `No tool_calls but content contains`, + contentCall.found ? `${contentCall.format} (tool=${contentCall.toolName})` : '', + hasQueries ? 'JSON queries format' : '' + ] + .filter(Boolean) + .join(' + '), + fullResponse: JSON.stringify(res.data) + }; + } + + if (finishReason === 'length') { + return { + status: 'fail', + detail: `Truncated (finish_reason=length): content_len=${rawContent.length}`, + fullResponse: JSON.stringify(res.data) + }; + } + + // Tier 3: Anthropic format fallback + const aiResult = await tryAnthropicFormatToolCall( + cfg, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + TOOL_DEFINITIONS + ); + if (aiResult.ok && aiResult.toolCalls.length > 0) { + return { + status: 'pass', + detail: `No tool_calls from raw API but Anthropic format detected: [${aiResult.toolCalls.join(', ')}]`, + fullResponse: JSON.stringify({ + rawFetch: res.data, + anthropicFormat: { toolCalls: aiResult.toolCalls } + }) + }; + } + + return { + status: 'fail', + detail: [ + `No tool_calls (finish_reason=${finishReason})`, + hasThink ? `think=YES` : `think=NO`, + `stripped="${stripped}"`, + aiResult.ok + ? `anthropic format: failed (${aiResult.toolCalls.length} calls)` + : `anthropic format: ${aiResult.error}` + ].join(', '), + fullResponse: JSON.stringify(res.data) + }; + }); +} + +/** + * T3: 工具参数结构正确性 + * 强制调用(tool_choice=required),验证 arguments 是合法 JSON 且含 query/queries 字段 + */ +async function testT3ToolArgStructure(cfg: CompatConfig): Promise { + return runCase('T3', '工具参数结构正确性', async () => { + const systemPrompt = getSystemPrompt('simple_query', true); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { + role: 'system', + content: systemPrompt + }, + { role: 'user', content: SIMPLE_QUESTION } + ], + tools: TOOL_DEFINITIONS, + // tool_choice: 'required', // 强制必须调用工具 + temperature: 0, + max_tokens: 8192, + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error, fullResponse: res.error }; + + const msg = res.data.choices?.[0]?.message; + const toolCalls = msg?.tool_calls ?? []; + if (toolCalls.length === 0) { + return { + status: 'fail', + detail: `No tool_calls returned (tool_choice=required ignored or unsupported)`, + fullResponse: JSON.stringify(res.data) + }; + } + + const args = toolCalls[0].function.arguments; + let parsed: Record; + try { + parsed = JSON.parse(args) as Record; + } catch { + return { + status: 'fail', + detail: `arguments is not valid JSON: "${args}"`, + fullResponse: JSON.stringify(res.data) + }; + } + + const hasQuery = 'query' in parsed || 'queries' in parsed; + if (!hasQuery) { + return { + status: 'fail', + detail: `parsed args has no query/queries field: ${JSON.stringify(parsed)}`, + fullResponse: JSON.stringify(res.data) + }; + } + return { status: 'pass', detail: `args=${JSON.stringify(parsed)}` }; + }); +} + +/** + * T4: 无工具时正常回复 + * 不传 tools,验证基础对话能力 + */ +async function testT4NormalReply(cfg: CompatConfig): Promise { + return runCase('T4', '无工具时正常回复', async () => { + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [{ role: 'user', content: 'Only Greet' }], + temperature: 0, + max_tokens: 8192, + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error, fullResponse: res.error }; + + const msg = res.data.choices?.[0]?.message; + const content = msg?.content ?? ''; + if (!content || content.trim().length < 5) { + return { + status: 'fail', + detail: `Empty or too-short content: "${content}"`, + fullResponse: JSON.stringify(res.data) + }; + } + if (Array.isArray(msg?.tool_calls) && msg!.tool_calls!.length > 0) { + return { + status: 'fail', + detail: 'Unexpected tool_calls in no-tools request', + fullResponse: JSON.stringify(res.data) + }; + } + return { + status: 'pass', + detail: `content="${content}"`, + fullResponse: JSON.stringify(res.data) + }; + }); +} + +/** + * T5: Text ReAct 格式兼容性 + * 不传 tools,只用文本系统提示,验证模型是否遵循 @search(...) 格式 + * + * 检测以下几种格式(先剥离 块): + * 1. @search(...) — diting text ReAct 格式 + * 2. / { + return runCase('T5', 'Text ReAct 格式兼容性', async () => { + const systemPrompt = buildTextReactPrompt(); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + // 注意:不传 tools 参数(纯文本 ReAct 模式) + temperature: 0, + max_tokens: 8192, + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error }; + + const rawContent = res.data.choices?.[0]?.message?.content ?? ''; + + // 剥离 ... 块,只看模型实际输出部分 + const strippedContent = rawContent.replace(/[\s\S]*?<\/think>/g, '').trim(); + // 部分模型 未闭合时也尝试截断 + const contentToCheck = strippedContent || rawContent; + + const hasAtSearch = contentToCheck.includes('@search'); + const hasXmlCall = + contentToCheck.includes('') || contentToCheck.includes(''); + + const thinkNote = hadThinkBlock ? ' (after stripping block)' : ''; + + if (hasAtSearch) return { status: 'pass', detail: `found @search in response${thinkNote}` }; + if (hasXmlCall) return { status: 'pass', detail: `found XML format${thinkNote}` }; + if (hasJsonCall) return { status: 'pass', detail: `found JSON tool call format${thinkNote}` }; + + return { + status: 'fail', + detail: `No tool call format found${thinkNote}. content="${contentToCheck}"` + }; + }); +} + +/** + * T6: 推理/思考过程字段探测(INFO 级别,不计入 pass/fail) + * 探测三种格式:reasoning_content 字段、 标签、无标签混入 content + */ +async function testT6ReasoningDetection(cfg: CompatConfig): Promise { + return runCase('T6', '推理/思考过程字段探测', async () => { + const systemPrompt = getSystemPrompt('simple_query', true); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + tools: TOOL_DEFINITIONS, + tool_choice: 'auto', + temperature: 0, + max_tokens: 8192, + enable_thinking: true + }); + + if (!res.ok) { + return { status: 'info', detail: `Cannot probe (API error): ${res.error}` }; + } + + const msg = res.data.choices?.[0]?.message; + const content = msg?.content ?? ''; + const reasoningContent = msg?.reasoning_content; + + const hasReasoningField = typeof reasoningContent === 'string' && reasoningContent.length > 0; + const hasThinkTags = content.includes(''); + const thinkMatch = content.match(/([\s\S]*?)<\/think>/); + const thinkLen = thinkMatch ? thinkMatch[1].length : 0; + const contentAfterStrip = content.replace(/[\s\S]*?<\/think>/g, '').trim(); + + // 无标签思考:content 很长、没有 tool_calls、不以 @search 开头、没有 标签 + const hasUnlabelledThinking = + !hasReasoningField && + !hasThinkTags && + content.length > 200 && + (msg?.tool_calls ?? []).length === 0 && + !content.startsWith('@') && + !content.includes(''); + + const info: Record = { + reasoning_content_field: hasReasoningField + ? `present (${(reasoningContent as string).length} chars)` + : 'absent', + think_tags_in_content: hasThinkTags ? `present (${thinkLen} chars inside)` : 'absent', + unlabelled_thinking_suspected: hasUnlabelledThinking, + content_after_strip: contentAfterStrip || '(empty)' + }; + + let detail = 'no thinking format detected'; + if (hasReasoningField) detail = 'reasoning_content field present'; + else if (hasThinkTags) detail = ' tags in content'; + else if (hasUnlabelledThinking) + detail = '⚠ unlabelled thinking in content — may break tool parsing'; + + return { status: 'info', detail, info }; + }); +} + +/** + * T7: 思考模式开关验证 + * 发送 enable_thinking=false,验证思考模式是否真正关闭。 + * 若模型仍输出 reasoning_content 或 块,说明该参数无效。 + */ +async function testT7ThinkingToggle(cfg: CompatConfig): Promise { + return runCase('T7', '思考模式关闭验证', async () => { + const systemPrompt = getSystemPrompt('simple_query', true); + const res = await callLLMAPI(cfg, { + model: cfg.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: SIMPLE_QUESTION } + ], + tools: TOOL_DEFINITIONS, + tool_choice: 'auto', + temperature: 0, + max_tokens: 8192, + enable_thinking: false + }); + + if (!res.ok) return { status: 'error', detail: res.error }; + + const msg = res.data.choices?.[0]?.message; + if (!msg) return { status: 'error', detail: 'No message in response' }; + + const content = msg.content ?? ''; + const hasReasoningField = + typeof msg.reasoning_content === 'string' && msg.reasoning_content!.length > 0; + const hasThinkTags = content.includes(''); + + if (hasReasoningField || hasThinkTags) { + return { + status: 'fail', + detail: [ + 'enable_thinking=false was ignored — model still outputs thinking content', + hasReasoningField + ? `reasoning_content: ${(msg.reasoning_content as string).length} chars` + : '', + hasThinkTags ? ' tags present in content' : '' + ] + .filter(Boolean) + .join(', ') + }; + } + + return { + status: 'pass', + detail: 'Thinking content absent with enable_thinking=false' + }; + }); +} + +/** 运行 T1-T4(并行) */ +async function runAPILayerT1T4(cfg: CompatConfig): Promise { + console.log('\n Running T1-T4 (API Layer - native function calling)...'); + return Promise.all([ + testT1FunctionCallingAvailability(cfg), + testT2ToolSelectionAccuracy(cfg), + testT3ToolArgStructure(cfg), + testT4NormalReply(cfg) + ]); +} + +/** 运行 T5-T7(并行) */ +async function runAPILayerT5T7(cfg: CompatConfig): Promise { + console.log(' Running T5-T7 (Text ReAct + Reasoning detection + Thinking toggle)...'); + return Promise.all([ + testT5TextReActFormat(cfg), + testT6ReasoningDetection(cfg), + testT7ThinkingToggle(cfg) + ]); +} + +// ============================================================ +// Agent 层:Mock providers + 运行器 +// ============================================================ + +/** + * 高相关性 Mock chunks(用于 A1/A2) + * 模拟知识库中有相关内容的场景 + */ +const HIGH_RELEVANCE_CHUNKS: ChunkResult[] = [ + { + id: 'mock-chunk-1', + content: '超融合 HCI 默认管理员账号为 admin,默认密码为 Fit@12345,首次登录后请立即修改密码。', + score: 0.85, + datasetId: 'test-dataset', + sourceName: 'HCI管理手册.pdf', + searchSource: 'vector' + }, + { + id: 'mock-chunk-2', + content: '超融合系统初始化完成后,通过浏览器访问管理界面,使用默认凭据 admin/Fit@12345 登录。', + score: 0.8, + datasetId: 'test-dataset', + sourceName: 'HCI快速入门.pdf', + searchSource: 'vector' + }, + { + id: 'mock-chunk-3', + content: '安全建议:超融合系统部署后应立即修改默认密码,避免使用出厂设置 Fit@12345。', + score: 0.72, + datasetId: 'test-dataset', + sourceName: '安全规范.pdf', + searchSource: 'fulltext' + } +]; + +/** + * 低相关性 Mock chunks(用于 A3) + * 模拟知识库中无相关内容的场景(不相关查询) + */ +const LOW_RELEVANCE_CHUNKS: ChunkResult[] = [ + { + id: 'mock-irrelevant-1', + content: '公司员工手册第三章:考勤制度与请假流程说明。', + score: 0.08, + datasetId: 'test-dataset', + sourceName: 'HR手册.pdf', + searchSource: 'vector' + }, + { + id: 'mock-irrelevant-2', + content: '财务报销流程:员工出差报销需在返程后7个工作日内提交报销申请。', + score: 0.06, + datasetId: 'test-dataset', + sourceName: '财务制度.pdf', + searchSource: 'fulltext' + } +]; + +/** + * 高相关性 Mock chunks(用于 A4) vNGAF 和 vADC 的区别是什么,哪个更适合边缘部署 + * 模拟知识库中有相关内容的场景 + */ +const HIGH_RELEVANCE_CHUNKS_A4: ChunkResult[] = [ + { + id: 'mock-chunk-1', + content: 'vAF是虚拟防火墙设备', + score: 0.85, + datasetId: 'test-dataset', + sourceName: 'HCI管理手册.pdf', + searchSource: 'vector' + }, + { + id: 'mock-chunk-2', + content: 'vADC是虚拟应用交付设备', + score: 0.8, + datasetId: 'test-dataset', + sourceName: 'HCI快速入门.pdf', + searchSource: 'vector' + }, + { + id: 'mock-chunk-3', + content: '安全建议:超融合系统部署后应立即修改默认密码,避免使用出厂设置 Fit@12345。', + score: 0.02, + datasetId: 'test-dataset', + sourceName: '安全规范.pdf', + searchSource: 'fulltext' + } +]; + +/** 构建用于 Agent 测试的 providers(真实 LLM + Mock 搜索) */ +function buildAgentProviders(cfg: CompatConfig, mockChunks: ChunkResult[], logger: ConsoleLogger) { + const llm: LLMProvider = + cfg.llmType === 'builtin' + ? new BuiltInLLMAdapter({ + apiKey: cfg.apiKey, + endpoint: cfg.endpoint, + model: cfg.model, + timeout: cfg.timeoutMs, + logger, + defaultEnableThinking: cfg.enableThinking ?? false + }) + : new DirectLLMProvider(cfg.model, cfg.endpoint, cfg.apiKey, cfg.timeoutMs, logger); + + return { + llm, + vectorSearch: new MockVectorSearchProvider(mockChunks), + fullTextSearch: new MockFullTextSearchProvider(mockChunks), + embed: new MockEmbeddingProvider(1536), + // reranker: 不传,减少变量,让评测更聚焦于 LLM 行为 + logger: logger + }; +} + +/** 运行 agent,返回结果 */ +async function runAgent( + cfg: CompatConfig, + question: string, + mockChunks: ChunkResult[], + case_label: string +): Promise { + const case_logger = new ConsoleLogger({ level: LogLevel.DEBUG, prefix: `Case ${case_label}` }); + const providers = buildAgentProviders(cfg, mockChunks, case_logger); + const agent = createAgenticSearch({ + providers, + config: { + maxSearchCalls: 3, // 限制搜索轮次,防止无限循环 + maxToolCalls: 8, + tokenBudget: 16000 + }, + mode: 'auto' // native 优先,无 tool_calls 时降级 text 解析 + }); + + const stream = agent.stream({ + query: question, + datasetIds: ['test-dataset'], + history: [], + priorContext: '' + }); + + let result: AgenticSearchResult | undefined; + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + result = item as AgenticSearchResult; + } + } + + if (!result) throw new Error('No result from stream'); + return result; +} + +// ============================================================ +// Agent 层测试用例 A1-A4 +// ============================================================ + +const IRRELEVANT_QUESTION = '公司目前一共有多少名员工'; +const COMPARISON_QUESTION = 'vNGAF 和 vADC 的区别是什么,哪个更适合边缘部署'; + +/** + * A1: Agent 完整流程 + * 验证基本的搜索→回答流程能否走通 + */ +async function testA1AgentPipeline(cfg: CompatConfig): Promise { + return runCase('A1', 'Agent 完整流程', async () => { + const case_label = 'A1'; + logger.info(`Case ${case_label}: ${SIMPLE_QUESTION}`); + + let result: AgenticSearchResult; + try { + result = await runAgent(cfg, SIMPLE_QUESTION, HIGH_RELEVANCE_CHUNKS, case_label); + } catch (e) { + return { status: 'error', detail: e instanceof Error ? e.message : String(e) }; + } + + const hasSearch = result.executionPath.some((p) => p.includes('search')); + const hasAnswer = typeof result.answer === 'string' && result.answer.trim().length > 5; + + if (!hasSearch) { + return { + status: 'fail', + detail: `No search in executionPath. path=[${result.executionPath.join(' → ')}]` + }; + } + if (!hasAnswer) { + return { + status: 'fail', + detail: `No answer generated. answer="${result.answer ?? ''}"` + }; + } + return { + status: 'pass', + detail: `searchCount=${result.searchCount}, toolCalls=${result.toolCallCount}, answer="${result.answer}"` + }; + }); +} + +/** + * A2: 工具调用序列合理性 + * 验证不会出现死循环(连续重复相同 tool),toolCallCount 不超限 + */ +async function testA2ToolCallLoop(cfg: CompatConfig): Promise { + return runCase('A2', '工具调用序列合理性', async () => { + const case_label = 'A2'; + logger.info(`Case ${case_label}: ${SIMPLE_QUESTION}`); + let result: AgenticSearchResult; + try { + result = await runAgent(cfg, SIMPLE_QUESTION, HIGH_RELEVANCE_CHUNKS, case_label); + } catch (e) { + return { status: 'error', detail: e instanceof Error ? e.message : String(e) }; + } + + const MAX_TOOL_CALLS = 8; + if (result.toolCallCount > MAX_TOOL_CALLS) { + return { + status: 'fail', + detail: `toolCallCount=${result.toolCallCount} > limit=${MAX_TOOL_CALLS} (possible loop)` + }; + } + + // 检查连续重复的 tools 节点(连续 4 次以上视为可疑循环) + const path = result.executionPath; + let maxConsecutive = 1; + let consecutive = 1; + for (let i = 1; i < path.length; i++) { + const prev = path[i - 1].split('(')[0]; + const curr = path[i].split('(')[0]; + if (curr === prev && curr === 'tools') { + consecutive++; + maxConsecutive = Math.max(maxConsecutive, consecutive); + } else { + consecutive = 1; + } + } + if (maxConsecutive >= 4) { + return { + status: 'fail', + detail: `Suspected loop: ${maxConsecutive} consecutive tool-node calls. path=[${path.join(' → ')}]` + }; + } + + return { + status: 'pass', + detail: `toolCalls=${result.toolCallCount}, path=[${path.join(' → ')}]` + }; + }); +} + +/** + * A3: 不相关查询早停 + * mock 搜索只返回低分 chunk,验证 agent 能识别并拒绝回答/不无限搜索 + */ +async function testA3IrrelevantQueryStop(cfg: CompatConfig): Promise { + return runCase('A3', '不相关查询早停', async () => { + const case_label = 'A3'; + logger.info(`Case ${case_label}: ${IRRELEVANT_QUESTION}`); + let result: AgenticSearchResult; + try { + result = await runAgent(cfg, IRRELEVANT_QUESTION, LOW_RELEVANCE_CHUNKS, case_label); + } catch (e) { + return { status: 'error', detail: e instanceof Error ? e.message : String(e) }; + } + + const MAX_SEARCH = 3; + const isRefuse = result.refuse === true; + const withinBudget = result.searchCount <= MAX_SEARCH; + + if (isRefuse || withinBudget) { + return { + status: 'pass', + detail: `refuse=${isRefuse}, searchCount=${result.searchCount}/${MAX_SEARCH}` + }; + } + return { + status: 'fail', + detail: `refuse=${isRefuse}, searchCount=${result.searchCount} exceeded ${MAX_SEARCH} (no early stop on irrelevant query)` + }; + }); +} + +/** + * A4: Playbook 路由准确性 + * 对比类问题应路由到 comparative_analysis 或 deep_research + */ +async function testA4PlaybookRouting(cfg: CompatConfig): Promise { + return runCase('A4', 'Playbook 路由准确性', async () => { + const case_label = 'A4'; + logger.info(`Case ${case_label}: ${COMPARISON_QUESTION}`); + let result: AgenticSearchResult; + try { + result = await runAgent(cfg, COMPARISON_QUESTION, HIGH_RELEVANCE_CHUNKS_A4, case_label); + } catch (e) { + return { status: 'error', detail: e instanceof Error ? e.message : String(e) }; + } + + const EXPECTED = new Set(['comparative_analysis', 'deep_research']); + const actual = result.playbook; + + if (EXPECTED.has(actual)) { + return { status: 'pass', detail: `playbook=${actual}` }; + } + return { + status: 'fail', + detail: `playbook=${actual}, expected one of [${[...EXPECTED].join(', ')}]` + }; + }); +} + +/** 运行 Agent 层全部用例 A1-A4(并行执行,各用例互相独立) */ +async function runAgentLayer(cfg: CompatConfig): Promise { + console.log('\n Running A1 (Agent Layer - requires real LLM + mock search)...'); + const onlya1 = await testA1AgentPipeline(cfg); + return [onlya1]; + + // console.log('\n Running A1-A4 (Agent Layer - requires real LLM + mock search)...'); + // const [a1, a2, a3, a4] = await Promise.all([ + // testA1AgentPipeline(cfg), + // testA2ToolCallLoop(cfg), + // testA3IrrelevantQueryStop(cfg), + // testA4PlaybookRouting(cfg) + // ]); + // return [a1, a2, a3, a4]; +} + +// ============================================================ +// 主程序入口 +// ============================================================ + +async function main(): Promise { + const cfg = parseArgs(); + + console.log(`\n${BOLD}Testing model: ${cfg.model}${RESET}`); + console.log(`${DIM} Endpoint: ${cfg.endpoint}`); + console.log(` Layer: ${cfg.layer}`); + console.log(` LLM Type: ${cfg.llmType}${RESET}`); + + const report: CompatReport = { + model: cfg.model, + endpoint: cfg.endpoint, + timestamp: new Date().toISOString(), + layers: {}, + recommendations: [] + }; + + // API 层测试 + if (cfg.layer === 'api' || cfg.layer === 'both') { + const t1t4 = await runAPILayerT1T4(cfg); + const t5t7 = await runAPILayerT5T7(cfg); + report.layers.api = makeLayerResult([...t1t4, ...t5t7]); + } + + // Agent 层测试 + if (cfg.layer === 'agent' || cfg.layer === 'both') { + const agentCases = await runAgentLayer(cfg); + report.layers.agent = makeLayerResult(agentCases); + } + + report.recommendations = buildRecommendations(report); + printReport(report, cfg.llmType); + + // 输出 JSON 报告文件(可选) + if (cfg.output) { + const { writeFile } = await import('fs/promises'); + await writeFile(cfg.output, JSON.stringify(report, null, 2), 'utf-8'); + console.log(`${CYAN}Report written to: ${cfg.output}${RESET}\n`); + } + + // 退出码:全部通过 → 0;有失败/错误 → 1 + const allCases = [...(report.layers.api?.cases ?? []), ...(report.layers.agent?.cases ?? [])]; + const hasFail = allCases.some((c) => c.status === 'fail' || c.status === 'error'); + process.exit(hasFail ? 1 : 0); +} + +// ============================================================ +// 入口分发:区分 vitest 加载 与 tsx 直接执行 +// ============================================================ + +const isVitestRuntime = typeof process.env.VITEST !== 'undefined'; +const hasCustomEndpoint = !!(process.env.LLM_BASE_URL || process.env.LLM_ENDPOINT); + +if (!isVitestRuntime) { + // 直接执行模式(tsx test/model-compat.test.ts) + main().catch((e) => { + console.error('Fatal error:', e); + process.exit(2); + }); +} else if (hasCustomEndpoint) { + // vitest 模式 + 已配置真实 LLM 端点 → 运行兼容性测试 + describe('Model Compatibility (real LLM)', () => { + it('run full compatibility suite', async () => { + // 将 process.exit 替换为 throw,避免杀掉 vitest 进程 + const realExit = process.exit; + let exitCode = 0; + process.exit = ((code?: number) => { + exitCode = code ?? 0; + throw new Error(`process.exit(${code})`); + }) as any; + try { + await main(); + } catch (e: any) { + if (!e.message?.startsWith('process.exit')) throw e; + } finally { + process.exit = realExit; + } + // 正常情况下 main() 的 process.exit(0/1) 会 throw,只有无异常走完才是成功 + }); + }); +} else { + // vitest 模式 + 无真实 LLM 端点 → 跳过 + describe('Model Compatibility (real LLM)', () => { + it.skip('no LLM endpoint configured — set LLM_BASE_URL to enable', () => {}); + }); +} diff --git a/packages/diting-rag-ts/test/multi-lang-search.test.ts b/packages/diting-rag-ts/test/multi-lang-search.test.ts new file mode 100644 index 000000000000..3c624dbee207 --- /dev/null +++ b/packages/diting-rag-ts/test/multi-lang-search.test.ts @@ -0,0 +1,2262 @@ +#!/usr/bin/env tsx +/// +// test/multi-lang-search.test.ts +// 多语言 Agentic Search 测试脚本 - 验证语言检测与自适应搜索行为 +// +// 用法: +// tsx test/multi-lang-search.test.ts [options] +// +// 选项: +// --model 模型名称 (默认: LLM_MODEL 环境变量) +// --endpoint API 端点 (默认: LLM_BASE_URL 环境变量) +// --api-key API 密钥 (默认: LLM_API_KEY 环境变量) +// --env-file 指定 .env 文件路径 (默认: ../.env) +// --layer 测试层: unit | agent | both (默认: both) +// --output 输出 JSON 报告文件路径 (可选) +// --timeout 单个用例超时毫秒数 (默认: 120000) +// --help, -h 显示帮助 + +// ============================================================ +// 依赖 import +// ============================================================ + +import * as dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join, resolve } from 'path'; + +const __scriptDir = dirname(fileURLToPath(import.meta.url)); + +// dotenv 延迟加载:parseArgs 需要先读取 --env-file 参数 +function loadEnv(envFile?: string): void { + if (envFile) { + const resolved = resolve(envFile); + dotenv.config({ path: resolved }); + console.log(`[dotenv] Loaded: ${resolved}`); + } else { + // 默认加载 .env + dotenv.config({ path: join(__scriptDir, '..', '.env') }); + } +} + +import { detectLang } from '../src/utils/lang'; +import { + LanguageTracker, + buildLanguageConfig, + getTargetLanguages, + type LanguageTrackerConfig +} from '../src/utils/lang_directive'; +import { MockVectorSearchProvider } from '../src/adapters/mock/vector_search'; +import { MockFullTextSearchProvider } from '../src/adapters/mock/full_text_search'; +import { MockEmbeddingProvider } from '../src/adapters/mock/embedding'; +import { createSearchResult, type SearchResult } from '../src/ports/search'; +import { subQueryFilter } from '../src/skills/atomic/chunk_selector'; +import { createAgenticSearch } from '../src/agent/runner'; +import { ConsoleLogger } from '../src/builtIn/logger/consoleLogger'; +import { LogLevel } from '../src/ports/logger'; +import type { ChunkResult, ChunkItem } from '../src/types/chunk'; +import type { AgenticSearchResult } from '../src/agent/runner'; +import type { LLMProvider } from '../src/ports/llm'; +import type { LLMMessage, LLMResponse, ToolCall } from '../src/types/message'; + +const logger = new ConsoleLogger({ level: LogLevel.DEBUG, prefix: 'multi-lang testing' }); + +// ============================================================ +// DirectLLMProvider(与 model-compat.test.ts 相同) +// ============================================================ + +class DirectLLMProvider implements LLMProvider { + constructor( + private model: string, + private endpoint: string, + private apiKey: string, + private timeoutMs: number, + private globalLogger: ConsoleLogger + ) {} + + async chat( + messages: LLMMessage[], + options?: import('../src/types/message').LLMCallOptions + ): Promise { + const model = options?.model || this.model; + const body: Record = { + model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature ?? 0, + max_tokens: options?.maxTokens ?? 8192, + stream: false + }; + if (body.model === 'kimi-k2.5') { + body.temperature = 1; + } + + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + // 透传 extra 字段(enable_thinking 等模型特定参数) + if (options?.extra) { + Object.assign(body, options.extra); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const resp = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + + const data = (await resp.json()) as { + choices?: Array<{ + message: { + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + reasoning_content?: string; + }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + + const choice = data.choices?.[0]; + const msg = choice?.message; + return { + content: msg?.content ?? '', + toolCalls: (msg?.tool_calls ?? []).map( + (tc): ToolCall => ({ + id: tc.id, + type: 'function', + function: { name: tc.function.name, arguments: tc.function.arguments } + }) + ), + reasoning: msg?.reasoning_content, + usage: { + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens + } + }; + } finally { + clearTimeout(timer); + } + } + + async *chatStream( + messages: LLMMessage[], + options?: import('../src/types/message').LLMCallOptions + ): AsyncIterable { + const model = options?.model || this.model; + const body: Record = { + model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature ?? 0, + max_tokens: options?.maxTokens ?? 8192, + stream: true + }; + + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + // 透传 extra 字段 + if (options?.extra) { + Object.assign(body, options.extra); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + let resp: Response; + try { + resp = await fetch(`${this.endpoint}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, + body: JSON.stringify(body), + signal: controller.signal + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.body) throw new Error('No response body'); + } finally { + clearTimeout(timer); + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data:')) continue; + const dataStr = trimmed.slice(5).trim(); + if (dataStr === '[DONE]') return; + try { + const data = JSON.parse(dataStr) as { + choices?: Array<{ delta: { content?: string } }>; + }; + const delta = data.choices?.[0]?.delta; + if (delta) yield { content: delta.content || '' }; + } catch { + /* skip parse errors */ + } + } + } + } finally { + clearTimeout(timer); + } + } + + getModelInfo() { + return { name: this.model, contextWindow: 16000, maxOutputTokens: 8192 }; + } +} + +// ============================================================ +// 配置 & CLI 解析 +// ============================================================ + +export type Layer = 'unit' | 'agent' | 'both'; + +export interface MultiLangConfig { + model: string; + endpoint: string; + apiKey: string; + layer: Layer; + envFile?: string; + cases?: string[]; + output?: string; + timeoutMs: number; + provider: 'direct' | 'builtin'; +} + +function parseArgs(): MultiLangConfig { + const args = process.argv.slice(2); + + // 先提取 --env-file 和 --help + let envFile: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--env-file' && i + 1 < args.length) { + envFile = args[i + 1]; + break; + } + if (args[i] === '--help' || args[i] === '-h') { + // 帮助信息在 loadEnv 前即可输出 + break; + } + } + + // 加载 env(先于读取 process.env.*) + loadEnv(envFile); + + const config: MultiLangConfig = { + model: process.env.LLM_MODEL || 'unknown-model', + endpoint: (process.env.LLM_BASE_URL || 'http://localhost:11434/v1').replace(/\/$/, ''), + apiKey: process.env.LLM_API_KEY || '', + layer: 'both', + envFile, + timeoutMs: 120_000, + provider: 'direct' as const + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--model': + config.model = args[++i]; + break; + case '--endpoint': + config.endpoint = args[++i].replace(/\/$/, ''); + break; + case '--api-key': + config.apiKey = args[++i]; + break; + case '--layer': + config.layer = args[++i] as Layer; + break; + case '--output': + config.output = args[++i]; + break; + case '--timeout': + config.timeoutMs = parseInt(args[++i], 10); + break; + case '--provider': + config.provider = args[++i] as 'direct' | 'builtin'; + break; + case '--cases': + config.cases = args[++i] + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + break; + case '--env-file': + i++; + break; // 已在 pre-scan 中处理 + case '--help': + case '-h': + console.log(` +Usage: tsx test/multi-lang-search.test.ts [options] + +Options: + --model Model name (default: LLM_MODEL env var) + --endpoint API endpoint (default: LLM_BASE_URL env var) + --api-key API key (default: LLM_API_KEY env var) + --env-file Custom .env file path (default: ../.env) + --cases Comma-separated test IDs/prefixes (default: all) + e.g. --cases A1a,A2a or --cases U1,U7 + --layer Test layer: unit | agent | both (default: both) + --output Write JSON report to file (optional) + --timeout Per-test timeout in ms (default: 120000) + --provider LLM provider: direct | builtin (default: direct) + --help, -h Show this help + +Examples: + # Unit tests only (no LLM needed) + tsx test/multi-lang-search.test.ts --layer unit + + # Specific agent cases + tsx test/multi-lang-search.test.ts --env-file .lab.env.qwen35 --layer agent --cases A1a,A2a,A9a + + # Specific unit cases + tsx test/multi-lang-search.test.ts --cases U1,U6,U7,U8 + + # Full test with report + tsx test/multi-lang-search.test.ts --env-file .lab.env.qwen35 --output /tmp/report.json +`); + process.exit(0); + } + } + + if (!['unit', 'agent', 'both'].includes(config.layer)) { + console.error(`Invalid --layer value: "${config.layer}". Must be: unit | agent | both`); + process.exit(1); + } + return config; +} + +// ============================================================ +// 测试结果类型(对齐 model-compat.test.ts) +// ============================================================ + +export type TestStatus = 'pass' | 'fail' | 'error' | 'info'; + +export interface TestCase { + id: string; + name: string; + status: TestStatus; + durationMs: number; + detail?: string; + info?: Record; +} + +export interface LayerResult { + passed: number; + total: number; + cases: TestCase[]; +} + +export interface MultiLangReport { + model: string; + endpoint: string; + timestamp: string; + layers: { unit?: LayerResult; agent?: LayerResult }; + recommendations: string[]; +} + +// ============================================================ +// 终端输出 +// ============================================================ + +const RESET = '\x1b[0m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; +const CYAN = '\x1b[36m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +function statusIcon(s: TestStatus): string { + if (s === 'pass') return `${GREEN}✓ PASS${RESET}`; + if (s === 'fail') return `${RED}✗ FAIL${RESET}`; + if (s === 'error') return `${RED}! ERROR${RESET}`; + if (s === 'info') return `${CYAN}i INFO${RESET}`; + return `${DIM}○ SKIP${RESET}`; +} + +function printLayerResults(title: string, layer: LayerResult): void { + console.log(`\n${BOLD}─── ${title} ${'─'.repeat(54 - title.length)}${RESET}`); + for (const tc of layer.cases) { + const dur = tc.durationMs > 0 ? ` ${DIM}(${tc.durationMs}ms)${RESET}` : ''; + const det = tc.detail ? ` ${DIM}${tc.detail}${RESET}` : ''; + console.log(`${tc.id.padEnd(4)} ${tc.name.padEnd(36)} ${statusIcon(tc.status)}${dur}${det}`); + if (tc.info && Object.keys(tc.info).length > 0) { + for (const [k, v] of Object.entries(tc.info)) { + console.log(` ${DIM}${k}: ${JSON.stringify(v)}${RESET}`); + } + } + } +} + +function printReport(report: MultiLangReport): void { + const W = 64; + console.log(`\n${BOLD}╔${'═'.repeat(W)}╗`); + console.log(`║ Multi-Lang Agentic Search Report${' '.repeat(W - 34)}║`); + console.log(`║ Model: ${report.model.slice(0, W - 14).padEnd(W - 12)}║`); + console.log(`║ Endpoint: ${report.endpoint.slice(0, W - 14).padEnd(W - 12)}║`); + console.log(`║ Time: ${report.timestamp.padEnd(W - 12)}║`); + console.log(`╚${'═'.repeat(W)}╝${RESET}`); + + if (report.layers.unit) printLayerResults('Unit Layer (no LLM)', report.layers.unit); + if (report.layers.agent) printLayerResults('Agent Layer (real LLM)', report.layers.agent); + + console.log(`\n${BOLD}─── Summary ${'─'.repeat(53)}${RESET}`); + if (report.layers.unit) { + console.log(`Unit Layer: ${report.layers.unit.passed}/${report.layers.unit.total} passed`); + } + if (report.layers.agent) { + console.log(`Agent Layer: ${report.layers.agent.passed}/${report.layers.agent.total} passed`); + } + const totalPassed = (report.layers.unit?.passed ?? 0) + (report.layers.agent?.passed ?? 0); + const totalTests = (report.layers.unit?.total ?? 0) + (report.layers.agent?.total ?? 0); + console.log(`Overall: ${totalPassed}/${totalTests} passed`); + + if (report.recommendations.length > 0) { + console.log(`\n${BOLD}─── Recommendations ${'─'.repeat(45)}${RESET}`); + for (const rec of report.recommendations) { + console.log(`${YELLOW}⚠ ${rec}${RESET}`); + } + } + console.log(); +} + +function makeLayerResult(cases: TestCase[]): LayerResult { + const countable = cases.filter((c) => c.status !== 'info' && c.status !== 'skip'); + return { + passed: countable.filter((c) => c.status === 'pass').length, + total: countable.length, + cases + }; +} + +function buildRecommendations(cases: TestCase[]): string[] { + const recs: string[] = []; + for (const tc of cases) { + if (tc.status === 'fail' || tc.status === 'error') { + recs.push(`${tc.id} FAILED: ${tc.detail ?? '(no detail)'}`); + } + } + return recs; +} + +// ============================================================ +// 测试用例包装 +// ============================================================ + +async function runCase( + id: string, + name: string, + fn: () => Promise> +): Promise { + const start = Date.now(); + try { + const result = await fn(); + return { id, name, durationMs: Date.now() - start, ...result }; + } catch (e) { + return { + id, + name, + durationMs: Date.now() - start, + status: 'error', + detail: e instanceof Error ? e.message : String(e) + }; + } +} + +// ============================================================ +// Mock Chunks 工厂:生成不同语言的 chunks +// ============================================================ + +function makeChunk(overrides: Partial = {}): ChunkItem { + return { + id: 'c1', + content: 'test content', + score: 0.5, + datasetId: 'test-ds', + sourceName: 'test.pdf', + ...overrides + }; +} + +/** 中文 chunks — 模拟中文知识库 */ +const ZH_CHUNKS: ChunkResult[] = [ + { + id: 'zh-1', + content: '超融合 HCI 默认管理员账号为 admin,默认密码为 Fit@12345,首次登录后请立即修改密码。', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCI手册.pdf', + searchSource: 'vector', + detectedLanguage: 'zh' + }, + { + id: 'zh-2', + content: '超融合系统初始化完成后,通过浏览器访问管理界面,使用默认凭据 admin/Fit@12345 登录。', + score: 0.85, + datasetId: 'test-ds', + sourceName: '快速入门.pdf', + searchSource: 'vector', + detectedLanguage: 'zh' + }, + { + id: 'zh-3', + content: '安全建议:超融合系统部署后应立即修改默认密码,避免使用出厂设置。', + score: 0.7, + datasetId: 'test-ds', + sourceName: '安全规范.pdf', + searchSource: 'fulltext', + detectedLanguage: 'zh' + } +]; + +/** 英文 chunks — 模拟英文知识库 */ +const EN_CHUNKS: ChunkResult[] = [ + { + id: 'en-1', + content: + 'The HCI default administrator account is admin with password Fit@12345. Change this immediately after first login.', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCI-Manual.pdf', + searchSource: 'vector', + detectedLanguage: 'en' + }, + { + id: 'en-2', + content: + 'After initialization, access the HCI management console via browser using default credentials admin/Fit@12345.', + score: 0.85, + datasetId: 'test-ds', + sourceName: 'QuickStart.pdf', + searchSource: 'vector', + detectedLanguage: 'en' + }, + { + id: 'en-3', + content: + 'Security best practice: Change the default HCI password immediately after deployment.', + score: 0.7, + datasetId: 'test-ds', + sourceName: 'Security.pdf', + searchSource: 'fulltext', + detectedLanguage: 'en' + } +]; + +/** 日文 chunks — 模拟日文知识库 */ +const JA_CHUNKS: ChunkResult[] = [ + { + id: 'ja-1', + content: + 'HCIのデフォルト管理者アカウントはadminで、パスワードはFit@12345です。初回ログイン後はすぐに変更してください。', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCIマニュアル.pdf', + searchSource: 'vector', + detectedLanguage: 'ja' + }, + { + id: 'ja-2', + content: + '初期化後、ブラウザからHCI管理画面にアクセスし、デフォルト認証情報admin/Fit@12345でログインします。', + score: 0.85, + datasetId: 'test-ds', + sourceName: 'クイックスタート.pdf', + searchSource: 'vector', + detectedLanguage: 'ja' + }, + { + id: 'ja-3', + content: 'セキュリティ対策:展開後すぐにHCIのデフォルトパスワードを変更してください。', + score: 0.7, + datasetId: 'test-ds', + sourceName: 'セキュリティ.pdf', + searchSource: 'fulltext', + detectedLanguage: 'ja' + } +]; + +/** 韩文 chunks — 模拟韩文知识库 */ +const KO_CHUNKS: ChunkResult[] = [ + { + id: 'ko-1', + content: + 'HCI 기본 관리자 계정은 admin이며 비밀번호는 Fit@12345입니다. 첫 로그인 후 즉시 변경하십시오.', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCI매뉴얼.pdf', + searchSource: 'vector', + detectedLanguage: 'ko' + }, + { + id: 'ko-2', + content: + '초기화 후 브라우저를 통해 HCI 관리 콘솔에 접속하고 기본 자격 증명 admin/Fit@12345로 로그인합니다.', + score: 0.85, + datasetId: 'test-ds', + sourceName: '빠른시작.pdf', + searchSource: 'vector', + detectedLanguage: 'ko' + } +]; + +/** 泰文 chunks — 模拟泰文知识库(小语种,无 ISO 639-1 映射的边界情况) */ +const TH_CHUNKS: ChunkResult[] = [ + { + id: 'th-1', + content: + 'บัญชีผู้ดูแลระบบเริ่มต้นของ HCI คือ admin รหัสผ่านคือ Fit@12345 กรุณาเปลี่ยนทันทีหลังจากเข้าสู่ระบบครั้งแรก', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCIคู่มือ.pdf', + searchSource: 'vector', + detectedLanguage: 'th' + }, + { + id: 'th-2', + content: + 'หลังจากเริ่มต้นระบบ ให้เข้าถึงคอนโซลการจัดการ HCI ผ่านเบราว์เซอร์โดยใช้ข้อมูลประจำตัวเริ่มต้น admin/Fit@12345', + score: 0.85, + datasetId: 'test-ds', + sourceName: 'เริ่มต้นอย่างรวดเร็ว.pdf', + searchSource: 'vector', + detectedLanguage: 'th' + } +]; + +/** 中英混合 chunks(模拟多语言 KB) */ +const MIXED_ZH_EN_CHUNKS: ChunkResult[] = [ + { + id: 'mix-1', + content: '超融合 HCI 默认管理员账号为 admin,默认密码为 Fit@12345。', + score: 0.95, + datasetId: 'test-ds', + sourceName: 'HCI手册.pdf', + searchSource: 'vector', + detectedLanguage: 'zh' + }, + { + id: 'mix-2', + content: 'The HCI system supports multiple authentication methods including LDAP and SAML.', + score: 0.88, + datasetId: 'test-ds', + sourceName: 'Auth-Guide.pdf', + searchSource: 'vector', + detectedLanguage: 'en' + }, + { + id: 'mix-3', + content: '超融合平台支持分布式部署,最小集群规模为3节点。', + score: 0.82, + datasetId: 'test-ds', + sourceName: '部署指南.pdf', + searchSource: 'fulltext', + detectedLanguage: 'zh' + }, + { + id: 'mix-4', + content: 'For high availability, deploy at least 3 nodes in the HCI cluster.', + score: 0.78, + datasetId: 'test-ds', + sourceName: 'HA-Guide.pdf', + searchSource: 'fulltext', + detectedLanguage: 'en' + }, + { + id: 'mix-5', + content: '性能优化建议:建议使用 SSD 存储池用于缓存加速。', + score: 0.75, + datasetId: 'test-ds', + sourceName: '性能调优.pdf', + searchSource: 'vector', + detectedLanguage: 'zh' + } +]; + +/** 低相关 chunks(用于测试不相关查询早停) */ +const LOW_RELEVANCE_CHUNKS: ChunkResult[] = [ + { + id: 'low-1', + content: '公司员工手册第三章:考勤制度与请假流程说明。', + score: 0.08, + datasetId: 'test-ds', + sourceName: 'HR手册.pdf', + searchSource: 'vector', + detectedLanguage: 'zh' + }, + { + id: 'low-2', + content: 'Company holiday schedule for the fiscal year 2026.', + score: 0.06, + datasetId: 'test-ds', + sourceName: 'HR-Policy.pdf', + searchSource: 'fulltext', + detectedLanguage: 'en' + } +]; + +// ============================================================ +// Unit Layer: 语言检测 & LanguageTracker 单元测试 +// ============================================================ + +// ===== U1-U2: detectLang 准确性 ===== + +async function testU1CJKDetection(): Promise { + return runCase('U1', 'detectLang CJK 检测', async () => { + const cases: Array<{ text: string; expected: string }> = [ + { text: '超融合的默认密码是什么', expected: 'zh' }, + { text: '如何配置系统参数', expected: 'zh' }, + { text: 'システムの設定方法を教えてください', expected: 'ja' }, + { text: '日本語のテストです', expected: 'ja' }, + { text: 'HCI 기본 비밀번호는 무엇입니까', expected: 'ko' }, + { text: '안녕하세요', expected: 'ko' } + ]; + const failures: string[] = []; + for (const { text, expected } of cases) { + const result = detectLang(text); + if (result !== expected) { + failures.push(`"${text.slice(0, 20)}..." → ${result} (expected ${expected})`); + } + } + if (failures.length > 0) { + return { status: 'fail', detail: failures.join('; ') }; + } + return { status: 'pass', detail: `${cases.length} CJK texts detected correctly` }; + }); +} + +async function testU2NonCJKDetection(): Promise { + return runCase('U2', 'detectLang 非CJK检测', async () => { + const cases: Array<{ text: string; expected: string }> = [ + { text: 'What is the default password for HCI', expected: 'en' }, + { text: 'How do I configure the system settings', expected: 'en' }, + { text: 'Quel est le mot de passe par défaut', expected: 'fr' }, + { text: 'Was ist das Standardpasswort', expected: 'de' } + ]; + const failures: string[] = []; + for (const { text, expected } of cases) { + const result = detectLang(text); + if (result !== expected) { + failures.push(`"${text.slice(0, 20)}..." → ${result} (expected ${expected})`); + } + } + if (failures.length > 0) { + return { status: 'fail', detail: failures.join('; ') }; + } + return { status: 'pass', detail: `${cases.length} non-CJK texts detected` }; + }); +} + +// ===== U3-U5: buildLanguageConfig ===== + +async function testU3BuildConfigAuthoritative(): Promise { + return runCase('U3', 'buildLanguageConfig L1 authoritative', async () => { + // 中文占 83% > 70% → authoritative + const { directive, trackerConfig } = buildLanguageConfig({ zh: 150, en: 30 }, 'en'); + const checks: string[] = []; + if (trackerConfig.confidence !== 'authoritative') + checks.push(`confidence=${trackerConfig.confidence}`); + if (trackerConfig.defaultLang !== 'zh') checks.push(`defaultLang=${trackerConfig.defaultLang}`); + if (!directive.includes('DEFAULT SEARCH LANGUAGE')) + checks.push('missing DEFAULT SEARCH LANGUAGE'); + if (!directive.includes('Final answer MUST be in')) + checks.push('missing Final answer directive'); + if (!directive.includes('User asked')) checks.push('missing user language hint'); + + if (checks.length > 0) return { status: 'fail', detail: checks.join('; ') }; + return { + status: 'pass', + detail: `confidence=${trackerConfig.confidence}, default=${trackerConfig.defaultLang}` + }; + }); +} + +async function testU4BuildConfigTentative(): Promise { + return runCase('U4', 'buildLanguageConfig L2 tentative', async () => { + const { directive, trackerConfig } = buildLanguageConfig({ zh: 60, en: 40, ja: 30 }, 'zh'); + const checks: string[] = []; + if (trackerConfig.confidence !== 'tentative') + checks.push(`confidence=${trackerConfig.confidence}`); + if (!directive.includes('Monitor results')) checks.push('missing Monitor results hint'); + + if (checks.length > 0) return { status: 'fail', detail: checks.join('; ') }; + return { + status: 'pass', + detail: `confidence=${trackerConfig.confidence}, default=${trackerConfig.defaultLang}` + }; + }); +} + +async function testU5BuildConfigFallback(): Promise { + return runCase('U5', 'buildLanguageConfig L3 fallback', async () => { + const nullResult = buildLanguageConfig(null, 'ja'); + const undefinedResult = buildLanguageConfig(undefined, 'ko'); + const emptyResult = buildLanguageConfig({}, 'en'); + + const checks: string[] = []; + if (nullResult.trackerConfig.confidence !== 'fallback') checks.push('null→not fallback'); + if (undefinedResult.trackerConfig.confidence !== 'fallback') + checks.push('undefined→not fallback'); + if (emptyResult.trackerConfig.confidence !== 'fallback') checks.push('empty→not fallback'); + if (!nullResult.directive.includes('Start with user language')) + checks.push('null: missing user lang hint'); + if (!undefinedResult.directive.includes('Start with user language')) + checks.push('undefined: missing user lang hint'); + + if (checks.length > 0) return { status: 'fail', detail: checks.join('; ') }; + return { status: 'pass', detail: 'null/undefined/empty stats all → fallback' }; + }); +} + +// ===== U6-U10: LanguageTracker ===== + +async function testU6TrackerNormalPath(): Promise { + return runCase('U6', 'LanguageTracker 正常路径', async () => { + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'zh', + userLang: 'zh', + confidence: 'authoritative' + }); + // 中文 query → 中文结果 → 正常 + tracker.recordSearch('系统配置', [ + makeChunk({ id: '1', detectedLanguage: 'zh', score: 0.5 }), + makeChunk({ id: '2', detectedLanguage: 'zh', score: 0.5 }) + ]); + const result = tracker.buildGuidance([makeChunk({ score: 0.8 })]); + if (result.shouldPush !== false || result.guidance !== null) { + return { + status: 'fail', + detail: `shouldPush=${result.shouldPush}, guidance=${result.guidance}` + }; + } + return { status: 'pass', detail: 'no guidance on language match' }; + }); +} + +async function testU7TrackerAnomalyDetection(): Promise { + return runCase('U7', 'LanguageTracker 异常检测', async () => { + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + // 中文 query → 英文结果 ≠ ja default → 异常 + tracker.recordSearch('API认证配置', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }), + makeChunk({ id: '3', detectedLanguage: 'en' }) + ]); + // 低分 → 确认异常 + const result = tracker.buildGuidance([makeChunk({ score: 0.1 })]); + if (!result.shouldPush || !result.suppressEarlyStop) { + return { + status: 'fail', + detail: `shouldPush=${result.shouldPush}, suppressEarlyStop=${result.suppressEarlyStop}` + }; + } + if (!result.guidance?.includes('⚠ LANGUAGE')) { + return { status: 'fail', detail: `guidance missing ⚠ LANGUAGE: ${result.guidance}` }; + } + return { status: 'pass', detail: `guidance: ${result.guidance}` }; + }); +} + +async function testU8TrackerDedup(): Promise { + return runCase('U8', 'LanguageTracker 去重', async () => { + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + tracker.recordSearch('测试', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + // 第一次 → push + const r1 = tracker.buildGuidance([makeChunk({ score: 0.1 })]); + if (!r1.shouldPush) return { status: 'fail', detail: 'first call should push' }; + + // 第二次相同 anomaly → 去重 + const r2 = tracker.buildGuidance([makeChunk({ score: 0.1 })]); + if (r2.shouldPush !== false || r2.suppressEarlyStop !== true) { + return { + status: 'fail', + detail: `dedup failed: shouldPush=${r2.shouldPush}, suppress=${r2.suppressEarlyStop}` + }; + } + return { status: 'pass', detail: 'first push, second dedup+suppress' }; + }); +} + +async function testU9TrackerCrossLingualBypass(): Promise { + return runCase('U9', 'LanguageTracker 跨语言绕过', async () => { + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + tracker.recordSearch('API', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + // 语言不匹配但高分 → 跨语言 embedding 生效,沉默 + const result = tracker.buildGuidance([makeChunk({ score: 0.5, rerankScore: 0.6 })]); + if (result.shouldPush !== false) { + return { + status: 'fail', + detail: `cross-lingual bypass failed: shouldPush=${result.shouldPush}` + }; + } + return { status: 'pass', detail: 'high score bypassed language anomaly' }; + }); +} + +async function testU10TrackerUserLangDisconnect(): Promise { + return runCase('U10', 'LanguageTracker 用户语言断连', async () => { + // KB 泰文为主,用户英文提问 + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'th', + userLang: 'en', + confidence: 'authoritative' + }); + tracker.recordSearch('ผู้เขียนหนังสือสามก๊ก', [ + makeChunk({ id: '1', detectedLanguage: 'th' }), + makeChunk({ id: '2', detectedLanguage: 'th' }) + ]); + const result = tracker.buildGuidance([makeChunk({ score: 0.1 })]); + if (!result.shouldPush || !result.guidance?.includes('en')) { + return { + status: 'fail', + detail: `shouldPush=${result.shouldPush}, guidance=${result.guidance}` + }; + } + return { status: 'pass', detail: `guidance: ${result.guidance}` }; + }); +} + +// ===== U12: LanguageTracker 多轮语言切换 ===== + +async function testU12MultiRoundSwitch(): Promise { + return runCase('U12', 'LanguageTracker 多轮语言切换不互相干扰', async () => { + // 场景:KB 中英混合(tentative),用户多轮搜索中多次触发语言异常 + // 验证 clearAnomaly 后新一轮异常不会被旧 activeAnomaly 误压制 + const tracker = LanguageTracker.fromConfig({ + defaultLang: 'zh', + userLang: 'en', + confidence: 'tentative' + }); + + const lowScore = makeChunk({ score: 0.1 }); // 触发异常的低分 chunk + const highScore = makeChunk({ score: 0.5, rerankScore: 0.5 }); // 高分 chunk + + // ── Round 1: zh query → en results → 语言不匹配 + 低分 → 异常 ── + tracker.recordSearch('超融合默认密码', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }), + makeChunk({ id: '3', detectedLanguage: 'en' }) + ]); + const r1 = tracker.buildGuidance([lowScore]); + if (!r1.shouldPush || !r1.suppressEarlyStop) { + return { + status: 'fail', + detail: `Round1: expected anomaly pushed, got shouldPush=${r1.shouldPush} suppress=${r1.suppressEarlyStop}` + }; + } + if (!r1.guidance?.includes('MISMATCH')) { + return { status: 'fail', detail: `Round1: guidance missing anomaly marker: ${r1.guidance}` }; + } + + // ── Round 2: 相同异常再次触发 → 去重 ── + const r2 = tracker.buildGuidance([lowScore]); + if (r2.shouldPush !== false || r2.suppressEarlyStop !== true) { + return { + status: 'fail', + detail: `Round2: expected dedup (shouldPush=false, suppress=true), got shouldPush=${r2.shouldPush} suppress=${r2.suppressEarlyStop}` + }; + } + + // ── 模拟 agent 处理了改写 guidance → clearAnomaly ── + tracker.clearAnomaly(); + + // ── Round 3: 改写后 en query → en results → 正常(语言匹配)── + tracker.recordSearch('HCI default password', [ + makeChunk({ id: '4', detectedLanguage: 'en' }), + makeChunk({ id: '5', detectedLanguage: 'en' }) + ]); + const r3 = tracker.buildGuidance([highScore]); + if (r3.shouldPush !== false) { + return { + status: 'fail', + detail: `Round3: expected normal (no push) after rewrite, got shouldPush=${r3.shouldPush}` + }; + } + + // ── Round 4: ja query → ko results → 新的语言不匹配 + 低分 → 新异常 ── + tracker.recordSearch('パスワードをリセットする方法', [ + makeChunk({ id: '6', detectedLanguage: 'ko' }), + makeChunk({ id: '7', detectedLanguage: 'ko' }), + makeChunk({ id: '8', detectedLanguage: 'ko' }) + ]); + const r4 = tracker.buildGuidance([lowScore]); + if (!r4.shouldPush || !r4.suppressEarlyStop) { + return { + status: 'fail', + detail: `Round4: expected NEW anomaly (ja→ko) pushed, got shouldPush=${r4.shouldPush} suppress=${r4.suppressEarlyStop}` + }; + } + if (!r4.guidance?.includes('MISMATCH')) { + return { status: 'fail', detail: `Round4: guidance missing anomaly marker: ${r4.guidance}` }; + } + + // ── Round 5: 相同 ja→ko 异常 → 去重 ── + const r5 = tracker.buildGuidance([lowScore]); + if (r5.shouldPush !== false || r5.suppressEarlyStop !== true) { + return { + status: 'fail', + detail: `Round5: expected dedup for ja→ko, got shouldPush=${r5.shouldPush} suppress=${r5.suppressEarlyStop}` + }; + } + + // ── clearAnomaly → 新一轮 ── + tracker.clearAnomaly(); + + // ── Round 6: 再次 zh query → en results → 应作为新一轮异常触发(不是旧 zh→en 的延续)── + tracker.recordSearch('超融合管理员密码', [ + makeChunk({ id: '9', detectedLanguage: 'en' }), + makeChunk({ id: '10', detectedLanguage: 'en' }) + ]); + const r6 = tracker.buildGuidance([lowScore]); + if (!r6.shouldPush) { + return { + status: 'fail', + detail: `Round6: expected fresh anomaly after clearAnomaly, got shouldPush=${r6.shouldPush}` + }; + } + + return { + status: 'pass', + detail: + 'multi-round switch: zh→en(ok) dedup(ok) clear→normal(ok) ja→ko(ok) dedup(ok) clear→zh→en(ok)' + }; + }); +} + +async function testU11GetTargetLanguages(): Promise { + return runCase('U11', 'getTargetLanguages 目标语言', async () => { + // authoritative → 空 + const authResult = getTargetLanguages( + { defaultLang: 'zh', userLang: 'en', confidence: 'authoritative' }, + { zh: 150, en: 30 } + ); + if (authResult.length !== 0) + return { status: 'fail', detail: `authoritative should return empty, got ${authResult}` }; + + // tentative with en > 10% → 应返回 en + const tentResult = getTargetLanguages( + { defaultLang: 'zh', userLang: 'en', confidence: 'tentative' }, + { zh: 60, en: 40, ja: 5 } + ); + if (!tentResult.includes('en')) + return { status: 'fail', detail: `should include en, got ${tentResult}` }; + + return { status: 'pass', detail: `authoritative=[], tentative=${JSON.stringify(tentResult)}` }; + }); +} + +// ===== U13: sampleLanguageDistribution 等效逻辑验证 ===== + +async function testU13SampleLanguageLogic(): Promise { + return runCase('U13', 'sampleLanguageDistribution 语言检测等价逻辑', async () => { + // 模拟 sampleLanguageDistribution 对 doc.q 做 detectLang 的逻辑 + // 验证移除 q.length >= 10 门控后,短中文文本也能正确识别 + const docs: Array<{ q: string; expected: string }> = [ + { + q: '超融合 HCI 默认管理员账号为 admin,默认密码为 Fit@12345,首次登录后请立即修改密码。', + expected: 'zh' + }, + { q: '默认密码', expected: 'zh' }, + { q: 'What is the default password for HCI', expected: 'en' }, + { q: 'パスワード', expected: 'ja' }, + { q: '超融合', expected: 'zh' }, + { q: 'admin', expected: 'en' }, + { q: '系统配置', expected: 'zh' }, + { q: 'การกำหนดค่า', expected: 'th' }, + { q: '短', expected: 'zh' }, + { q: 'HCI', expected: 'en' } + ]; + const failures: string[] = []; + for (const { q, expected } of docs) { + const result = detectLang(q); + if (result !== expected) { + failures.push(`"${q}" → ${result} (expected ${expected})`); + } + } + if (failures.length > 0) { + return { status: 'fail', detail: failures.join('; ') }; + } + return { status: 'pass', detail: `${docs.length} docs detected correctly` }; + }); +} + +// ============================================================ +// Agent Layer: 多语言执行轨迹验证(需要真实 LLM + Mock 搜索) +// ============================================================ +// +// 坐标系: +// - 语言探测: queryLanguage 字段是否与用户问题语言一致 +// - Prompt 信号注入: L1/L2/L3 配置下搜索行为差异 +// - 搜索语言选择: searchQueries 是否使用正确的默认搜索语言 +// - 回答语言合规: answer 是否以用户语言输出 +// - 高分早停: 相关 chunks 命中后是否在预算内停止搜索 +// - 低分停止: 不相关查询是否早停或 refuse +// - 语种不匹配压制: 跨语言高分场景是否绕过异常信号 + +// ============================================================ +// 轨迹分析辅助 +// ============================================================ + +/** 从 AgenticSearchResult 提取关键轨迹信息 */ +interface TrajectoryInfo { + queryLanguage: string; + playbook: string; + analysis: string; + executionPath: string[]; + searchQueries: string[]; + searchCount: number; + toolCallCount: number; + refuse: boolean; + hasAnswer: boolean; + answerPreview: string; + answerInUserLang: boolean; + chunksCount: number; + chunkLanguages: Record; + confidence?: number; + reasoningText: string; + ttftMs?: number; + citedIds: string[]; + reflectionLabel: string; + reflectionReason: string; + embeddingTokens: number; + rerankInputTokens: number; + llmInputTokens: number; + llmOutputTokens: number; +} + +function analyzeTrajectory(result: AgenticSearchResult, expectedUserLang: string): TrajectoryInfo { + const answer = result.answer ?? ''; + const hasAnswer = answer.trim().length > 5; + + const userLangLower = expectedUserLang.toLowerCase(); + let answerInUserLang = false; + if (userLangLower === 'zh') { + answerInUserLang = /[一-鿿]/.test(answer); + } else if (userLangLower === 'en') { + answerInUserLang = /^[A-Za-z]/.test(answer.trim()) && !/[一-鿿]/.test(answer); + } else if (userLangLower === 'ja') { + answerInUserLang = /[぀-ゟ゠-ヿ]/.test(answer); + } else if (userLangLower === 'ko') { + answerInUserLang = /[가-힯]/.test(answer); + } else { + answerInUserLang = true; + } + + // 统计 chunks 语言分布 + const chunkLanguages: Record = {}; + for (const c of result.chunks) { + const lang = c.detectedLanguage || 'unknown'; + chunkLanguages[lang] = (chunkLanguages[lang] || 0) + 1; + } + + return { + queryLanguage: result.queryLanguage ?? 'N/A', + playbook: result.playbook, + analysis: result.analysis ?? '', + executionPath: result.executionPath, + searchQueries: result.searchQueries, + searchCount: result.searchCount, + toolCallCount: result.toolCallCount, + refuse: result.refuse ?? false, + hasAnswer, + answerPreview: answer.slice(0, 100), + answerInUserLang, + chunksCount: result.chunks.length, + chunkLanguages, + confidence: result.confidence, + reasoningText: result.reasoningText ?? '', + ttftMs: result.ttftMs, + citedIds: result.citedIds ?? [], + reflectionLabel: result.reflectionLabel ?? '', + reflectionReason: result.reflectionReason ?? '', + embeddingTokens: result.embeddingTokens, + rerankInputTokens: result.rerankInputTokens, + llmInputTokens: result.llmInputTokens, + llmOutputTokens: result.llmOutputTokens + }; +} + +/** 将轨迹信息渲染为 info Record,供 printLayerResults 展示 */ +function renderTrajectoryInfo(t: TrajectoryInfo): Record { + return { + query_lang: t.queryLanguage, + playbook: t.playbook, + analysis: t.analysis ? t.analysis.replace(/\n/g, '\\n') : '(none)', + exec_path: t.executionPath.join(' → '), + path_steps: t.executionPath.length, + search_queries: t.searchQueries.length > 0 ? `[${t.searchQueries.join(' | ')}]` : '(none)', + search_count: t.searchCount, + tool_calls: t.toolCallCount, + refuse: t.refuse, + answer_lang_ok: t.answerInUserLang, + answer_preview: t.answerPreview.replace(/\n/g, '\\n'), + chunks: `${t.chunksCount} chunks ${JSON.stringify(t.chunkLanguages)}`, + confidence: t.confidence ?? 'N/A', + reasoning: t.reasoningText ? t.reasoningText.slice(0, 200).replace(/\n/g, '\\n') : '(none)', + ttft_ms: t.ttftMs ?? 'N/A', + cited_ids: t.citedIds.length > 0 ? `[${t.citedIds.join(', ')}]` : '(none)', + reflection: t.reflectionLabel + ? `${t.reflectionLabel}${t.reflectionReason ? ': ' + t.reflectionReason : ''}` + : '(none)', + tokens: `embed=${t.embeddingTokens} rerank=${t.rerankInputTokens} llm_in=${t.llmInputTokens} llm_out=${t.llmOutputTokens}` + }; +} + +/** 构建 pass TestCase,附带完整轨迹 info */ +function passTrajectory( + detail: string, + t: TrajectoryInfo +): Omit { + return { status: 'pass', detail, info: renderTrajectoryInfo(t) }; +} + +function buildAgentProviders(cfg: MultiLangConfig, mockChunks: ChunkResult[]) { + const llm = new DirectLLMProvider(cfg.model, cfg.endpoint, cfg.apiKey, cfg.timeoutMs, logger); + return { + llm, + vectorSearch: new MockVectorSearchProvider(mockChunks), + fullTextSearch: new MockFullTextSearchProvider(mockChunks), + embed: new MockEmbeddingProvider(1536), + logger + }; +} + +async function runAgent( + cfg: MultiLangConfig, + question: string, + mockChunks: ChunkResult[], + initialLanguageStats?: Record | null +): Promise { + const providers = buildAgentProviders(cfg, mockChunks); + const agent = createAgenticSearch({ + providers, + config: { maxSearchCalls: 3, maxToolCalls: 8, tokenBudget: 16000 }, + mode: 'auto' + }); + + const stream = agent.stream({ + query: question, + datasetIds: ['test-dataset'], + history: [], + priorContext: '', + initialLanguageStats + }); + + let result: AgenticSearchResult | undefined; + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + result = item as AgenticSearchResult; + } + } + if (!result) throw new Error('No result from agent stream'); + return result; +} + +// ============================================================ +// A1: 单语 — query/answer 语言一致性 +// ============================================================ + +// —— A1a: 中文 query+KB → 搜索词为中文,回答为中文 +async function testA1aZhMonoLang(cfg: MultiLangConfig): Promise { + return runCase('A1a', '单语:中文query→中文搜索+回答', async () => { + const result = await runAgent(cfg, '超融合的默认密码是什么', ZH_CHUNKS, { zh: 200, en: 10 }); + const t = analyzeTrajectory(result, 'zh'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in zh: "${t.answerPreview}"`); + // L1 authoritative zh → 搜索词应为中文 + if (t.searchQueries.length > 0 && !/[一-鿿]/.test(t.searchQueries.join(' '))) { + issues.push(`search queries not in zh: ${JSON.stringify(t.searchQueries)}`); + } + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_zh=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A1b: 英文 query+KB → 搜索词为英文,回答为英文 +async function testA1bEnMonoLang(cfg: MultiLangConfig): Promise { + return runCase('A1b', '单语:英文query→英文搜索+回答', async () => { + const result = await runAgent(cfg, 'What is the default password for HCI', EN_CHUNKS, { + en: 200, + zh: 10 + }); + const t = analyzeTrajectory(result, 'en'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in en: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_en=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A1c: 日文 query+KB +async function testA1cJaMonoLang(cfg: MultiLangConfig): Promise { + return runCase('A1c', '单语:日文query→日文搜索+回答', async () => { + const result = await runAgent(cfg, 'HCIのデフォルトパスワードは何ですか', JA_CHUNKS, { + ja: 200 + }); + const t = analyzeTrajectory(result, 'ja'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in ja: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_ja=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A1d: 韩文 query+KB +async function testA1dKoMonoLang(cfg: MultiLangConfig): Promise { + return runCase('A1d', '单语:韩文query→韩文搜索+回答', async () => { + const result = await runAgent(cfg, 'HCI 기본 비밀번호는 무엇입니까', KO_CHUNKS, { ko: 150 }); + const t = analyzeTrajectory(result, 'ko'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in ko: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_ko=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A2: 跨语言 L1 Authoritative — 搜索语言≠用户语言,回答必须用用户语言 +// ============================================================ + +// —— A2a: KB 中文为主(L1),用户英文提问 → 搜索用中文,回答用英文 +async function testA2aCrossL1EnZh(cfg: MultiLangConfig): Promise { + return runCase('A2a', '跨语言L1:EN用户+ZH KB→ZH搜索EN回答', async () => { + const result = await runAgent(cfg, 'What is the default password for HCI', ZH_CHUNKS, { + zh: 180, + en: 20 + }); + const t = analyzeTrajectory(result, 'en'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in en: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_en=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A2b: KB 英文为主(L1),用户中文提问 → 搜索用英文,回答用中文 +async function testA2bCrossL1ZhEn(cfg: MultiLangConfig): Promise { + return runCase('A2b', '跨语言L1:ZH用户+EN KB→EN搜索ZH回答', async () => { + const result = await runAgent(cfg, '超融合的默认密码是什么', EN_CHUNKS, { en: 180, zh: 20 }); + const t = analyzeTrajectory(result, 'zh'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in zh: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_zh=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A2c: KB 日文为主(L1),用户中文提问 → 搜索用日文,回答用中文 +async function testA2cCrossL1ZhJa(cfg: MultiLangConfig): Promise { + return runCase('A2c', '跨语言L1:ZH用户+JA KB→JA搜索ZH回答', async () => { + const result = await runAgent(cfg, 'HCI的默认管理员密码是什么', JA_CHUNKS, { ja: 200, zh: 10 }); + const t = analyzeTrajectory(result, 'zh'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in zh: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_zh=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A3: 混合 KB L2 Tentative — 多语言探测信号 +// ============================================================ + +// —— A3a: KB 中英混合(tentative zh),英文提问 → 应监控结果调整 +async function testA3aMixedTentative(cfg: MultiLangConfig): Promise { + return runCase('A3a', '混合KB L2:EN用户+中英KB→应监控多语言', async () => { + const result = await runAgent(cfg, 'What is the default password for HCI', MIXED_ZH_EN_CHUNKS, { + zh: 80, + en: 70 + }); + const t = analyzeTrajectory(result, 'en'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + if (!t.answerInUserLang) issues.push(`answer not in en: "${t.answerPreview}"`); + if (t.searchCount > 2) issues.push(`excessive searches: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_en=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A4: Fallback L3 — 无语言统计数据 +// ============================================================ + +async function testA4aFallbackNoStats(cfg: MultiLangConfig): Promise { + return runCase('A4a', 'L3 Fallback:无统计数据→用户语言兜底', async () => { + const result = await runAgent(cfg, 'How do I reset the HCI admin password', EN_CHUNKS, null); + const t = analyzeTrajectory(result, 'en'); + const issues: string[] = []; + if (!t.hasAnswer) issues.push('no answer'); + // Fallback 场景:搜索语言应与用户语言一致 + if (t.searchCount > 3) issues.push(`excessive searches without stats: ${t.searchCount}`); + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_en=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A5: 低分早停 — 不相关查询应在预算内停止 +// ============================================================ + +// —— A5a: 中文不相关查询 +async function testA5aIrrelevantZh(cfg: MultiLangConfig): Promise { + return runCase('A5a', '低分早停:中文不相关query→应早停', async () => { + const result = await runAgent(cfg, '公司目前一共有多少名员工', LOW_RELEVANCE_CHUNKS, { + zh: 50 + }); + const t = analyzeTrajectory(result, 'zh'); + const withinBudget = t.searchCount <= 3; + const isRefuse = t.refuse; + if (!isRefuse && !withinBudget) { + return { status: 'fail', detail: `searchCount=${t.searchCount} exceeded 3, no early stop` }; + } + return passTrajectory( + [ + `refuse=${isRefuse}`, + `searchCount=${t.searchCount}`, + `toolCalls=${t.toolCallCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 3))}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// —— A5b: 英文不相关查询(跨语言 low-score) +async function testA5bIrrelevantEn(cfg: MultiLangConfig): Promise { + return runCase('A5b', '低分早停:英文不相关query→应早停', async () => { + const result = await runAgent( + cfg, + 'How many employees does the company have', + LOW_RELEVANCE_CHUNKS, + { zh: 30, en: 20 } + ); + const t = analyzeTrajectory(result, 'en'); + const withinBudget = t.searchCount <= 3; + const isRefuse = t.refuse; + if (!isRefuse && !withinBudget) { + return { status: 'fail', detail: `searchCount=${t.searchCount} exceeded 3, no early stop` }; + } + return passTrajectory( + [ + `refuse=${isRefuse}`, + `searchCount=${t.searchCount}`, + `toolCalls=${t.toolCallCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 3))}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A6: 高分早停 — 相关结果命中后不再重复搜索 +// ============================================================ + +async function testA6aHighScoreEarlyStop(cfg: MultiLangConfig): Promise { + return runCase('A6a', '高分早停:相关结果→≤2次搜索', async () => { + const result = await runAgent(cfg, '超融合的默认密码是什么', ZH_CHUNKS, { zh: 200 }); + const t = analyzeTrajectory(result, 'zh'); + // 高分相关 chunks → agent 应在 1-2 次搜索后进入 summary + if (t.searchCount > 2) { + return { + status: 'fail', + detail: `expected ≤2 searches for high-score case, got ${t.searchCount}. path=${t.executionPath}` + }; + } + return passTrajectory( + [ + `searches=${t.searchCount}`, + `toolCalls=${t.toolCallCount}`, + `path=${t.executionPath}`, + `answer="${t.answerPreview}"` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A7: 语种不匹配信号压制 — 跨语言高分应绕过异常 +// ============================================================ + +// —— A7a: 英文用户 + 中文 KB(L1) + 高分 → 跨语言生效,不走异常信号 +async function testA7aCrossLingualSuppression(cfg: MultiLangConfig): Promise { + return runCase('A7a', '信号压制:跨语言高分→绕过语言异常', async () => { + const result = await runAgent(cfg, 'What is the default HCI password', ZH_CHUNKS, { + zh: 180, + en: 20 + }); + const t = analyzeTrajectory(result, 'en'); + // 跨语言高分场景:chunks(中文) 与 query(英文) 语言不匹配,但内容高度相关 + // verify: agent 不会陷入搜索循环,1 次搜索后即 summary + if (t.searchCount > 2) { + return { + status: 'fail', + detail: `cross-lingual anomaly not suppressed: ${t.searchCount} searches. path=${t.executionPath}` + }; + } + if (!t.hasAnswer) return { status: 'fail', detail: 'no answer in cross-lingual scenario' }; + return passTrajectory( + [ + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer_en=${t.answerInUserLang}`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A8: 小语种场景 — 泰文 KB + 英文用户(ISO 639-3 边界) +// ============================================================ + +async function testA8aThaiSmallLang(cfg: MultiLangConfig): Promise { + return runCase('A8a', '小语种:泰文KB(tha)+EN用户→健壮运行', async () => { + const result = await runAgent(cfg, 'What is the HCI default password', TH_CHUNKS, { th: 100 }); + const t = analyzeTrajectory(result, 'en'); + if (!t.hasAnswer) return { status: 'fail', detail: 'agent crashed on Thai KB' }; + if (t.searchCount > 3) { + return { status: 'fail', detail: `excessive searches on Thai KB: ${t.searchCount}` }; + } + return passTrajectory( + [ + `queryLang=${t.queryLanguage}`, + `playbook=${t.playbook}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries.slice(0, 2))}`, + `answer="${t.answerPreview}"`, + `path=${t.executionPath}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// Staged Mock: 分阶段返回不同 chunks,模拟"跨语言首轮低分 →改写→ 同语言高分" +// ============================================================ + +// ============================================================ +// Language-Aware Mock: 根据查询语言返回不同 chunks +// 模拟真实跨语言场景:KB 语言查询 → 高分,非 KB 语言查询 → 低分 +// ============================================================ + +class LanguageAwareFullTextSearchProvider { + public readonly type = 'fastgpt' as const; + + /** KB 主要语言集合 */ + private kbLangs: Set; + /** 非 KB 语言查询返回的 chunks(模拟跨语言检索失败) */ + private crossLangChunks: ChunkResult[]; + /** KB 语言查询返回的 chunks(模拟同语言检索成功) */ + private sameLangChunks: ChunkResult[]; + + constructor( + sameLangChunks: ChunkResult[], + crossLangChunks: ChunkResult[], + kbLangs: string[] = ['zh', 'en'] + ) { + this.sameLangChunks = sameLangChunks; + this.crossLangChunks = crossLangChunks; + this.kbLangs = new Set(kbLangs); + } + + async search( + query: string, + _datasetIds: string[], + options: { limit: number } + ): Promise> { + const queryLang = detectLang(query); + const isKbLang = this.kbLangs.has(queryLang); + const chunks = isKbLang ? this.sameLangChunks : this.crossLangChunks; + return createSearchResult(chunks.slice(0, options.limit), 'fulltext', 'mock-lang-aware'); + } +} + +// ============================================================ +// 辅助:基于查询语言的路由 Agent(用于多语言动态切换测试) +// LanguageAwareFullTextSearchProvider 根据 query 语言返回不同 chunks: +// KB 语言(zh/en) → MIXED_ZH_EN_CHUNKS(高分) 非KB语言 → LOW_RELEVANCE_CHUNKS(低分) +// ============================================================ + +function buildLangAwareAgent(cfg: MultiLangConfig, question: string) { + const llm = new DirectLLMProvider(cfg.model, cfg.endpoint, cfg.apiKey, cfg.timeoutMs, logger); + const providers = { + llm, + vectorSearch: new MockVectorSearchProvider([]), + fullTextSearch: new LanguageAwareFullTextSearchProvider( + MIXED_ZH_EN_CHUNKS, + LOW_RELEVANCE_CHUNKS, + ['zh', 'en'] + ), + embed: new MockEmbeddingProvider(1536), + logger + }; + return createAgenticSearch({ + providers: providers as any, + config: { maxSearchCalls: 3, maxToolCalls: 8, tokenBudget: 16000 }, + mode: 'auto' + }); +} + +// ============================================================ +// A9: 动态语言检测 → 改写 → 重搜(老知识库无语言探测) +// ============================================================ +// 场景:中英混合 KB(老知识库,无 initialLanguageStats),用户用小语种提问。 +// Stage 0: 低分 chunks(跨语言检索失败,chunk 语言与 query 语言不匹配) +// Stage 1: 中英高分 chunks(改写后同语言检索成功) +// 预期轨迹:agent(search) → tools(低分+语言异常) → agent(query_rewrite) → tools(同语言高分) → agent(summary) + +// —— A9a: 泰语提问 + 中英KB + 无stats → 首轮低分跨语言 → 改写 → 重搜命中 +async function testA9aDynamicLangRewrite(cfg: MultiLangConfig): Promise { + return runCase('A9a', '动态切换:TH→首搜低分→改写ZH/EN→重搜命中', async () => { + const agent = buildLangAwareAgent(cfg, 'รหัสผ่านเริ่มต้นของ HCI คืออะไร'); + const stream = agent.stream({ + query: 'รหัสผ่านเริ่มต้นของ HCI คืออะไร', + datasetIds: ['test-dataset'], + history: [], + priorContext: '', + initialLanguageStats: null + }); + let result: AgenticSearchResult | undefined; + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + result = item as AgenticSearchResult; + } + } + if (!result) throw new Error('No result from agent stream'); + const t = analyzeTrajectory(result, 'th'); + const issues: string[] = []; + + // Stage 验证:必须经过 query_rewrite + const hasRewrite = t.executionPath.some((p) => p.includes('query_rewrite')); + if (!hasRewrite) { + issues.push(`expected query_rewrite in path, got: ${t.executionPath.join(' → ')}`); + } + + // 至少 2 次搜索(首轮失败 + 改写后重搜) + if (t.searchCount < 2) { + issues.push(`expected ≥2 searches, got ${t.searchCount}`); + } + + if (!t.hasAnswer) issues.push('no answer'); + if (t.refuse) issues.push('unexpected refuse after rewrite'); + + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `path=${t.executionPath.join(' → ')}`, + `hasRewrite=${hasRewrite}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries)}`, + `chunks=${JSON.stringify(t.chunkLanguages)}` + ].join(', '), + t + ); + }); +} + +// —— A9b: 俄语提问 + 中英KB + 无stats → 首轮低分跨语言 → 改写 → 重搜命中 +async function testA9bDynamicLangRewriteZhEn(cfg: MultiLangConfig): Promise { + return runCase('A9b', '动态切换:RU→首搜低分→改写ZH/EN→重搜命中', async () => { + const agent = buildLangAwareAgent(cfg, 'Какой пароль по умолчанию для HCI'); + const stream = agent.stream({ + query: 'Какой пароль по умолчанию для HCI', + datasetIds: ['test-dataset'], + history: [], + priorContext: '', + initialLanguageStats: null + }); + let result: AgenticSearchResult | undefined; + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + result = item as AgenticSearchResult; + } + } + if (!result) throw new Error('No result from agent stream'); + const t = analyzeTrajectory(result, 'ru'); + const issues: string[] = []; + + const hasRewrite = t.executionPath.some((p) => p.includes('query_rewrite')); + if (!hasRewrite) { + issues.push(`expected query_rewrite in path, got: ${t.executionPath.join(' → ')}`); + } + + if (t.searchCount < 2) { + issues.push(`expected ≥2 searches, got ${t.searchCount}`); + } + + if (!t.hasAnswer) issues.push('no answer'); + if (t.refuse) issues.push('unexpected refuse after rewrite'); + + if (issues.length > 0) return { status: 'fail', detail: issues.join('; ') }; + return passTrajectory( + [ + `path=${t.executionPath.join(' → ')}`, + `hasRewrite=${hasRewrite}`, + `searches=${t.searchCount}`, + `queries=${JSON.stringify(t.searchQueries)}`, + `chunks=${JSON.stringify(t.chunkLanguages)}` + ].join(', '), + t + ); + }); +} + +// ============================================================ +// A10: Query 语言探测验证 +// ============================================================ + +async function testA10aQueryLangDetection(cfg: MultiLangConfig): Promise { + return runCase('A10a', '语言探测:多语种queryLang正确', async () => { + // 依次测试 4 个语种的 queryLanguage + const cases: Array<{ + question: string; + chunks: ChunkResult[]; + stats: Record; + expectedLang: string; + }> = [ + { question: '超融合的默认密码', chunks: ZH_CHUNKS, stats: { zh: 200 }, expectedLang: 'zh' }, + { + question: 'What is the default password', + chunks: EN_CHUNKS, + stats: { en: 200 }, + expectedLang: 'en' + }, + { + question: 'デフォルトパスワードは何ですか', + chunks: JA_CHUNKS, + stats: { ja: 200 }, + expectedLang: 'ja' + }, + { + question: '기본 비밀번호는 무엇입니까', + chunks: KO_CHUNKS, + stats: { ko: 150 }, + expectedLang: 'ko' + } + ]; + const results: string[] = []; + for (const { question, chunks, stats, expectedLang } of cases) { + const result = await runAgent(cfg, question, chunks, stats); + const actualLang = result.queryLanguage ?? 'N/A'; + if (actualLang !== expectedLang) { + results.push(`"${question.slice(0, 20)}" → ${actualLang} (expected ${expectedLang})`); + } else { + results.push(`${expectedLang}=✓`); + } + } + const failures = results.filter((r) => !r.includes('=✓')); + if (failures.length > 0) { + return { status: 'fail', detail: failures.join('; ') }; + } + return { status: 'pass', detail: results.join(', ') }; + }); +} + +// ============================================================ +// A11: subQueryFilter 关闭思考 + maxTokens 约束下 LLM 打分验证 +// ============================================================ + +async function testA11aSubQueryFilterScoring(cfg: MultiLangConfig): Promise { + const useBuiltIn = cfg.provider === 'builtin'; + const caseName = useBuiltIn + ? 'subQueryFilter:BuiltInLLM+maxTokens约束→打分正常' + : 'subQueryFilter:DirectLLM+maxTokens约束→打分正常'; + + return runCase('A11a', caseName, async () => { + let llm: LLMProvider; + + if (useBuiltIn) { + // BuiltInLLMAdapter:内部已通过 resolveLLMCallOptions 合并默认选项 + // (enableThinking=false + chat_template_kwargs),同时 maxTokens 条件发送 + const { BuiltInLLMAdapter } = await import('../src/adapters/builtIn/llm/adapter'); + llm = new BuiltInLLMAdapter({ + model: cfg.model, + endpoint: cfg.endpoint, + apiKey: cfg.apiKey, + timeout: cfg.timeoutMs, + logger + }); + } else { + // DirectLLMProvider:需要手动包裹,模拟 wrapProviderWithDefaults 行为 + const rawLlm = new DirectLLMProvider( + cfg.model, + cfg.endpoint, + cfg.apiKey, + cfg.timeoutMs, + logger + ); + llm = { + ...rawLlm, + async chat(messages, options) { + const mergedOptions = { + ...options, + enableThinking: false, + extra: { + enable_thinking: false, + chat_template_kwargs: { enable_thinking: false }, + ...(options?.extra as Record) + } + }; + const resp = await rawLlm.chat(messages, mergedOptions); + console.log( + `[A11a] LLM raw: content="${resp.content.slice(0, 120)}" len=${resp.content.length}` + ); + return resp; + }, + chatStream(messages, options) { + return rawLlm.chatStream(messages, options); + }, + getModelInfo() { + return rawLlm.getModelInfo(); + } + }; + } + + // 构造 HCI 相关的中文 chunks(模拟真实检索结果) + const chunks: ChunkItem[] = [ + { + id: 'hci-1', + content: + '超融合 HCI 默认管理员账号为 admin,默认密码为 Fit@12345,首次登录后请立即修改密码。部署要求至少 3 个节点,支持 SSD 缓存加速。', + score: 0.9, + datasetId: 'test-ds', + sourceName: 'HCI手册.pdf', + detectedLanguage: 'zh' + }, + { + id: 'hci-2', + content: + '超融合系统初始化完成后,通过浏览器访问管理界面 https://hci-master:8443,使用默认凭据 admin/Fit@12345 登录。', + score: 0.85, + datasetId: 'test-ds', + sourceName: '快速入门.pdf', + detectedLanguage: 'zh' + }, + { + id: 'hci-3', + content: '安全建议:超融合系统部署后应立即修改默认密码,启用密码复杂度策略和定期过期机制。', + score: 0.8, + datasetId: 'test-ds', + sourceName: '安全规范.pdf', + detectedLanguage: 'zh' + }, + { + id: 'hci-4', + content: + '超融合平台支持分布式部署,最小集群规模为 3 节点,推荐使用万兆网络互联。支持在线扩容,单集群最大 64 节点。', + score: 0.75, + datasetId: 'test-ds', + sourceName: '部署指南.pdf', + detectedLanguage: 'zh' + }, + { + id: 'hci-5', + content: + '性能优化建议:使用 SSD 存储池用于缓存加速,开启内存去重和压缩功能可提升 IOPS 30% 以上。', + score: 0.7, + datasetId: 'test-ds', + sourceName: '性能调优.pdf', + detectedLanguage: 'zh' + }, + { + id: 'low-1', + content: + '公司员工手册第三章:考勤制度与请假流程说明。年假天数按工龄计算,试用期员工不享有年假。', + score: 0.15, + datasetId: 'test-ds', + sourceName: 'HR手册.pdf', + detectedLanguage: 'zh' + }, + { + id: 'low-2', + content: + '办公室管理规定:工位清洁检查时间为每周五下午 5 点,请提前整理桌面物品。会议室预订需提前一天。', + score: 0.1, + datasetId: 'test-ds', + sourceName: '行政制度.pdf', + detectedLanguage: 'zh' + } + ]; + + // 直接调用 subQueryFilter — 内部传递 { temperature: 0.0, maxTokens: 8 } + // 经过 runner 的 wrapProviderWithDefaults 后,options 还会合并 enableThinking=false + const filtered = await subQueryFilter( + chunks, + '超融合 HCI 的默认管理员密码是什么', + '超融合默认密码', + '我需要检索超融合产品的官方文档或安装手册中关于默认管理员账户密码的说明,直接提取初始登录凭证。', + llm, + 10, + 'simple_query' + ); + + const issues: string[] = []; + + // 高相关 chunks 应该保留 + if (filtered.length === 0) { + issues.push('all chunks filtered out — LLM scoring may be broken'); + } + + // 不相关 chunks 应该被过滤掉 + const filteredIds = new Set(filtered.map((c) => c.id)); + if (filteredIds.has('low-1')) { + issues.push('irrelevant chunk low-1 should have been filtered'); + } + if (filteredIds.has('low-2')) { + issues.push('irrelevant chunk low-2 should have been filtered'); + } + + // 相关 chunks 应该保留 + if (!filteredIds.has('hci-1') && !filteredIds.has('hci-2') && !filteredIds.has('hci-3')) { + issues.push('all relevant HCI chunks were filtered — scores may all be 0'); + } + + if (issues.length > 0) { + return { + status: 'fail', + detail: issues.join('; '), + info: { + totalChunks: chunks.length, + filteredCount: filtered.length, + filteredIds: filtered.map((c) => c.id), + scores: filtered.map( + (c) => + `${c.id}:${(c as ChunkItem & { llm_sub_query_score?: number }).llm_sub_query_score ?? 'N/A'}` + ) + } + }; + } + + return { + status: 'pass', + detail: `${chunks.length} chunks → ${filtered.length} kept, low relevance filtered`, + info: { + keptIds: filtered.map((c) => c.id), + keptCount: filtered.length, + totalCount: chunks.length + } + }; + }); +} + +// ============================================================ +// 运行入口 +// ============================================================ + +/** 检查 testId 是否匹配 cases 过滤列表。空列表 = 全部通过 */ +function matchCases(testId: string, cases?: string[]): boolean { + if (!cases || cases.length === 0) return true; + return cases.some((pattern) => testId === pattern || testId.startsWith(pattern)); +} + +async function runUnitLayer(cases?: string[]): Promise { + const allUnitTests: Array<{ id: string; fn: () => Promise }> = [ + { id: 'U1', fn: testU1CJKDetection }, + { id: 'U2', fn: testU2NonCJKDetection }, + { id: 'U3', fn: testU3BuildConfigAuthoritative }, + { id: 'U4', fn: testU4BuildConfigTentative }, + { id: 'U5', fn: testU5BuildConfigFallback }, + { id: 'U6', fn: testU6TrackerNormalPath }, + { id: 'U7', fn: testU7TrackerAnomalyDetection }, + { id: 'U8', fn: testU8TrackerDedup }, + { id: 'U9', fn: testU9TrackerCrossLingualBypass }, + { id: 'U10', fn: testU10TrackerUserLangDisconnect }, + { id: 'U11', fn: testU11GetTargetLanguages }, + { id: 'U12', fn: testU12MultiRoundSwitch }, + { id: 'U13', fn: testU13SampleLanguageLogic } + ]; + const filtered = allUnitTests.filter((t) => matchCases(t.id, cases)); + if (filtered.length === 0) { + console.log('\n (no unit tests matched)'); + return []; + } + const names = filtered.map((t) => t.id).join(', '); + console.log(`\n Running Unit [${names}]...`); + return Promise.all(filtered.map((t) => t.fn())); +} + +async function runAgentLayer(cfg: MultiLangConfig): Promise { + const allAgentTests: Array<{ id: string; fn: (c: MultiLangConfig) => Promise }> = [ + // A1: 单语一致性 + { id: 'A1a', fn: testA1aZhMonoLang }, + { id: 'A1b', fn: testA1bEnMonoLang }, + { id: 'A1c', fn: testA1cJaMonoLang }, + { id: 'A1d', fn: testA1dKoMonoLang }, + // A2: 跨语言 L1 authoritative + { id: 'A2a', fn: testA2aCrossL1EnZh }, + { id: 'A2b', fn: testA2bCrossL1ZhEn }, + { id: 'A2c', fn: testA2cCrossL1ZhJa }, + // A3: 混合 KB L2 tentative + { id: 'A3a', fn: testA3aMixedTentative }, + // A4: L3 fallback + { id: 'A4a', fn: testA4aFallbackNoStats }, + // A5: 低分早停 + { id: 'A5a', fn: testA5aIrrelevantZh }, + { id: 'A5b', fn: testA5bIrrelevantEn }, + // A6: 高分早停 + { id: 'A6a', fn: testA6aHighScoreEarlyStop }, + // A7: 跨语言信号压制 + { id: 'A7a', fn: testA7aCrossLingualSuppression }, + // A8: 小语种 + { id: 'A8a', fn: testA8aThaiSmallLang }, + // A9: 动态语言检测 → 改写 → 重搜 + { id: 'A9a', fn: testA9aDynamicLangRewrite }, + { id: 'A9b', fn: testA9bDynamicLangRewriteZhEn }, + // A10: query 语言探测 + { id: 'A10a', fn: testA10aQueryLangDetection }, + // A11: subQueryFilter 关闭思考 + maxTokens 约束下打分 + { id: 'A11a', fn: testA11aSubQueryFilterScoring } + ]; + const filtered = allAgentTests.filter((t) => matchCases(t.id, cfg.cases)); + if (filtered.length === 0) { + console.log('\n (no agent tests matched)'); + return []; + } + const names = filtered.map((t) => t.id).join(', '); + console.log(`\n Running Agent [${names}]...`); + const results: TestCase[] = []; + for (const { fn } of filtered) { + results.push(await fn(cfg)); + } + return results; +} + +async function main(): Promise { + const cfg = parseArgs(); + + console.log(`\n${BOLD}Multi-Lang Agentic Search Test${RESET}`); + console.log(`${DIM} Model: ${cfg.model}`); + console.log(` Endpoint: ${cfg.endpoint}`); + console.log(` Layer: ${cfg.layer}${RESET}`); + + const report: MultiLangReport = { + model: cfg.model, + endpoint: cfg.endpoint, + timestamp: new Date().toISOString(), + layers: {}, + recommendations: [] + }; + + // Unit 层 + if (cfg.layer === 'unit' || cfg.layer === 'both') { + const unitCases = await runUnitLayer(cfg.cases); + report.layers.unit = makeLayerResult(unitCases); + } + + // Agent 层 + if (cfg.layer === 'agent' || cfg.layer === 'both') { + const agentCases = await runAgentLayer(cfg); + report.layers.agent = makeLayerResult(agentCases); + } + + const allCases = [...(report.layers.unit?.cases ?? []), ...(report.layers.agent?.cases ?? [])]; + report.recommendations = buildRecommendations(allCases); + printReport(report); + + // 输出 JSON 报告文件 + if (cfg.output) { + const { writeFile } = await import('fs/promises'); + await writeFile(cfg.output, JSON.stringify(report, null, 2), 'utf-8'); + console.log(`${CYAN}Report written to: ${cfg.output}${RESET}\n`); + } + + const hasFail = allCases.some((c) => c.status === 'fail' || c.status === 'error'); + process.exit(hasFail ? 1 : 0); +} + +// ============================================================ +// 入口分发 +// ============================================================ + +const isVitestRuntime = typeof process.env.VITEST !== 'undefined'; +const hasCustomEndpoint = !!process.env.LLM_BASE_URL; + +if (!isVitestRuntime) { + main().catch((e) => { + console.error('Fatal error:', e); + process.exit(2); + }); +} else if (hasCustomEndpoint) { + describe('Multi-Lang Agentic Search (real LLM)', () => { + it('run full multi-lang suite', async () => { + const realExit = process.exit; + let exitCode = 0; + process.exit = ((code?: number) => { + exitCode = code ?? 0; + throw new Error(`process.exit(${code})`); + }) as any; + try { + await main(); + } catch (e: any) { + if (!e.message?.startsWith('process.exit')) throw e; + } finally { + process.exit = realExit; + } + }); + }); +} else { + describe('Multi-Lang Agentic Search', () => { + it('runs unit layer', async () => { + const cases = await runUnitLayer(); + const result = makeLayerResult(cases); + // 单元测试必须通过 + for (const c of result.cases) { + if (c.status === 'fail' || c.status === 'error') throw new Error(`${c.id}: ${c.detail}`); + } + }); + }); +} diff --git a/packages/diting-rag-ts/test/skills/atomic/chunk_selector.test.ts b/packages/diting-rag-ts/test/skills/atomic/chunk_selector.test.ts new file mode 100644 index 000000000000..af2599d29d32 --- /dev/null +++ b/packages/diting-rag-ts/test/skills/atomic/chunk_selector.test.ts @@ -0,0 +1,63 @@ +// src/skills/atomic/chunk_selector.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { ChunkSelectorSkill } from '../../../src/skills/atomic/chunk_selector'; +import { MockLLMProvider } from '../../../src/adapters/mock/llm'; +import type { ChunkItem } from '../../../src/types/chunk'; + +describe('ChunkSelectorSkill', () => { + let chunkSelectorSkill: ChunkSelectorSkill; + let mockLLM: MockLLMProvider; + + beforeEach(() => { + chunkSelectorSkill = new ChunkSelectorSkill(); + mockLLM = new MockLLMProvider({ + responses: [ + JSON.stringify({ + selected: [0, 1], + reason: 'Selected relevant chunks' + }) + ] + }); + chunkSelectorSkill.initializeProvider(mockLLM); + }); + + describe('execute', () => { + it('should select chunks within token budget', async () => { + const chunks: ChunkItem[] = [ + { id: '1', content: 'Short content', score: 0.9, datasetId: 'd1', sourceName: 'test1' }, + { + id: '2', + content: 'Another short content', + score: 0.8, + datasetId: 'd1', + sourceName: 'test2' + }, + { id: '3', content: 'More content here', score: 0.7, datasetId: 'd1', sourceName: 'test3' } + ]; + + const result = await chunkSelectorSkill.execute({ + query: 'test query', + chunks, + tokenBudget: 1000, + playbook: 'simple_query' + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { selectedChunks: ChunkItem[] }; + expect(data.selectedChunks.length).toBeGreaterThan(0); + } + }); + + it('should handle empty chunks', async () => { + const result = await chunkSelectorSkill.execute({ + query: 'test query', + chunks: [], + tokenBudget: 1000, + playbook: 'simple_query' + }); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/skills/atomic/retrieve.test.ts b/packages/diting-rag-ts/test/skills/atomic/retrieve.test.ts new file mode 100644 index 000000000000..8665802227ec --- /dev/null +++ b/packages/diting-rag-ts/test/skills/atomic/retrieve.test.ts @@ -0,0 +1,70 @@ +// src/skills/atomic/retrieve.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { RetrieveSkill } from '../../../src/skills/atomic/retrieve'; +import { MockLLMProvider } from '../../../src/adapters/mock/llm'; +import { MockVectorSearchProvider } from '../../../src/adapters/mock/vector_search'; +import { MockFullTextSearchProvider } from '../../../src/adapters/mock/full_text_search'; +import { MockEmbeddingProvider } from '../../../src/adapters/mock/embedding'; +import type { ChunkResult } from '../../../src/types/chunk'; + +describe('RetrieveSkill', () => { + let retrieveSkill: RetrieveSkill; + let mockLLM: MockLLMProvider; + let mockVectorSearch: MockVectorSearchProvider; + let mockFullTextSearch: MockFullTextSearchProvider; + let mockEmbed: MockEmbeddingProvider; + + beforeEach(() => { + retrieveSkill = new RetrieveSkill(); + mockLLM = new MockLLMProvider({ + responses: ['{"queries": ["test query"]}'] + }); + mockVectorSearch = new MockVectorSearchProvider([ + { id: '1', content: 'Test chunk 1', score: 0.9, datasetId: 'd1', sourceName: 'test1' }, + { id: '2', content: 'Test chunk 2', score: 0.8, datasetId: 'd1', sourceName: 'test2' } + ]); + mockFullTextSearch = new MockFullTextSearchProvider([ + { id: '3', content: 'Test chunk 3', score: 0.85, datasetId: 'd1', sourceName: 'test3' } + ]); + mockEmbed = new MockEmbeddingProvider(1536); + + retrieveSkill.initialize(mockLLM); + retrieveSkill.initializeProviders(mockVectorSearch, mockFullTextSearch, mockEmbed); + }); + + describe('execute', () => { + it('should return search results', async () => { + const result = await retrieveSkill.execute({ + queries: ['test'], + datasetIds: ['d1'], + tokenBudget: 8000 + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { chunks: ChunkResult[] }; + expect(data.chunks.length).toBeGreaterThan(0); + } + }); + + it('should handle empty queries', async () => { + const result = await retrieveSkill.execute({ + queries: [], + datasetIds: ['d1'], + tokenBudget: 8000 + }); + + expect(result.success).toBe(true); + }); + + it('should handle empty datasetIds', async () => { + const result = await retrieveSkill.execute({ + queries: ['test'], + datasetIds: [], + tokenBudget: 8000 + }); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/token_budget.test.ts b/packages/diting-rag-ts/test/token_budget.test.ts new file mode 100644 index 000000000000..f5e2abb669ac --- /dev/null +++ b/packages/diting-rag-ts/test/token_budget.test.ts @@ -0,0 +1,64 @@ +// src/utils/token_budget.test.ts +import { describe, it, expect } from 'vitest'; +import { estimateTokens, fitChunks } from '../src/utils/token_budget'; +import type { ChunkItem } from '../src/types/chunk'; + +describe('token_budget', () => { + describe('estimateTokens', () => { + it('should estimate tokens for English text', () => { + const text = 'Hello world'; + const tokens = estimateTokens(text); + expect(tokens).toBeGreaterThan(0); + }); + + it('should estimate tokens for Chinese text', () => { + const text = '你好世界'; + const tokens = estimateTokens(text); + expect(tokens).toBeGreaterThan(0); + }); + + it('should estimate tokens for empty string', () => { + const tokens = estimateTokens(''); + expect(tokens).toBe(0); + }); + }); + + describe('fitChunks', () => { + it('should return empty array for empty input', () => { + const result = fitChunks([], 100); + expect(result).toEqual([]); + }); + + it('should fit chunks within budget', () => { + const chunks: ChunkItem[] = [ + { + id: '1', + content: 'Hello world this is a test', + score: 0.9, + datasetId: 'd1', + sourceName: 'test1' + }, + { + id: '2', + content: 'Another chunk with more text', + score: 0.8, + datasetId: 'd1', + sourceName: 'test2' + } + ]; + const result = fitChunks(chunks, 1000); + expect(result.length).toBe(2); // both fit within 1000 tokens + }); + + it('should respect token budget limit', () => { + const chunks: ChunkItem[] = [ + { id: '1', content: 'A'.repeat(100), score: 0.9, datasetId: 'd1', sourceName: 'test1' }, + { id: '2', content: 'B'.repeat(100), score: 0.8, datasetId: 'd1', sourceName: 'test2' } + ]; + // budget = 80 tokens (each chunk ~50 + 30 overhead = 80) + const result = fitChunks(chunks, 80); + expect(result.length).toBe(1); + expect(result[0].id).toBe('1'); // first chunk fits + }); + }); +}); diff --git a/packages/diting-rag-ts/test/unit/agent_fallback.test.ts b/packages/diting-rag-ts/test/unit/agent_fallback.test.ts new file mode 100644 index 000000000000..579e13255075 --- /dev/null +++ b/packages/diting-rag-ts/test/unit/agent_fallback.test.ts @@ -0,0 +1,369 @@ +// test/unit/agent_fallback.test.ts +// P0: Agent 节点异常场景 fallback 测试 +// - LLM 调用失败时重试 +// - native FC 无 toolCalls 时 text 降级 +// - 无 tool call 且无 chunks 时注入引导消息再调 LLM +// +// 直接测试 agent node 函数,不通过图循环。 + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { LLMProvider } from '../../src/ports/llm'; +import type { LLMMessage, LLMResponse, LLMCallOptions, ToolCall } from '../../src/types/message'; +import { TOOL_DEFINITIONS } from '../../src/agent/tools'; +import { parseAllToolCalls } from '../../src/agent/tools'; + +// ── Mock LLM helpers ────────────────────────────────────────────────────── + +class CountedMockLLM implements LLMProvider { + public callCount = 0; + public receivedMessages: LLMMessage[][] = []; + + constructor( + private responses: Array<{ + content: string; + toolCalls?: LLMResponse['toolCalls']; + shouldThrow?: boolean; + }> + ) {} + + async chat(messages: LLMMessage[], _options?: LLMCallOptions): Promise { + this.receivedMessages.push([...messages]); + this.callCount++; + const r = this.responses[this.callCount - 1]; + if (!r) throw new Error(`No response configured for call #${this.callCount}`); + if (r.shouldThrow) throw new Error(`Mock LLM failure #${this.callCount}`); + return { content: r.content, toolCalls: r.toolCalls }; + } + + async *chatStream(_messages: LLMMessage[], _options?: LLMCallOptions): AsyncIterable { + const r = await this.chat(_messages, _options); + yield r; + } + + getModelInfo() { return { name: 'counted-mock', contextWindow: 16000, maxOutputTokens: 8192 }; } +} + +// ── Agent Node(含重试 + text fallback + no-tool-call 内部引导)──────────── + +interface AgentNodeState { + messages: LLMMessage[]; + playbook: string; + allChunks: Array<{ id: string; content: string }>; + toolCallCount: number; + iterationCount: number; +} + +interface AgentNodeOutput { + pendingToolCalls: ToolCall[]; + error: string; + done: boolean; + executionPath: string; + toolCallCount: number; + iterationCount: number; +} + +const MAX_RETRIES = 1; +const MAX_NO_TOOL_GUIDANCE = 1; + +async function runAgentNode( + llm: LLMProvider, + state: AgentNodeState +): Promise { + let attempts = 0; + let response: LLMResponse | null = null; + let lastError: Error | null = null; + + // ── Phase 1: LLM 调用 + 网络重试 ── + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + attempts++; + try { + response = await llm.chat(state.messages, { + tools: TOOL_DEFINITIONS, + temperature: 0.1 + }); + break; + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, 10)); + } + } + } + + // 全部重试失败 + if (!response) { + if (state.allChunks.length > 0) { + return { + pendingToolCalls: [], + error: '', + done: true, + executionPath: 'agent(retry_exhausted_with_chunks)', + toolCallCount: state.toolCallCount, + iterationCount: state.iterationCount, + llmCallCount: attempts + }; + } + return { + pendingToolCalls: [], + error: `agent error after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`, + done: false, + executionPath: 'agent(retry_exhausted)', + toolCallCount: state.toolCallCount, + iterationCount: state.iterationCount, + llmCallCount: attempts + }; + } + + // ── Phase 2: Text fallback ── + let toolCalls: ToolCall[] = response.toolCalls ?? []; + let mode = 'native'; + + if (toolCalls.length === 0 && response.content) { + const parsedCalls = parseAllToolCalls(response.content); + if (parsedCalls.length > 0) { + toolCalls = parsedCalls.map((c, i) => ({ + id: `text_fallback_${Date.now()}_${i}`, + type: 'function' as const, + function: { name: c.name, arguments: JSON.stringify(c.args) } + })); + mode = 'text_fallback'; + } + } + + // ── Phase 3: No-tool-call → 注入引导消息重试 ── + if (toolCalls.length === 0 && state.allChunks.length === 0) { + // 构建 messages 用于引导重试:当前的 messages + assistant 回复 + guidance + const guidanceMsg: LLMMessage = { + role: 'user', + content: + 'You MUST call a tool to proceed. Use @search to look up information, or @query_rewrite to improve your query. Do NOT output free text.', + id: 'tool-call-guidance' + }; + + const assistantMsg: LLMMessage = { role: 'assistant', content: response.content }; + const guidedMessages = [...state.messages, assistantMsg, guidanceMsg]; + + for (let g = 0; g <= MAX_NO_TOOL_GUIDANCE; g++) { + try { + const guidedResponse = await llm.chat(guidedMessages, { + tools: TOOL_DEFINITIONS, + temperature: 0.1 + }); + attempts++; + + let guidedToolCalls: ToolCall[] = guidedResponse.toolCalls ?? []; + if (guidedToolCalls.length === 0 && guidedResponse.content) { + const parsed = parseAllToolCalls(guidedResponse.content); + if (parsed.length > 0) { + guidedToolCalls = parsed.map((c, i) => ({ + id: `guided_text_${Date.now()}_${i}`, + type: 'function' as const, + function: { name: c.name, arguments: JSON.stringify(c.args) } + })); + mode = 'guided_text'; + } + } + + if (guidedToolCalls.length > 0) { + const toolNames = guidedToolCalls.map((tc) => tc.function.name); + return { + pendingToolCalls: guidedToolCalls, + error: '', + done: false, + executionPath: `agent(${mode},${toolNames.join(',')})`, + toolCallCount: state.toolCallCount + guidedToolCalls.length, + iterationCount: state.iterationCount + 1, + llmCallCount: attempts + }; + } + break; // guided response also empty — give up + } catch { + // guidance call failed, try next + } + } + + // 引导后仍无 tool call + return { + pendingToolCalls: [], + error: '', + done: false, + executionPath: 'agent(no_tools,give_up)', + toolCallCount: state.toolCallCount, + iterationCount: state.iterationCount + 1, + llmCallCount: attempts + }; + } + + // ── Phase 4: No-tool-call but chunks exist → skip guidance, go to answer ── + if (toolCalls.length === 0 && state.allChunks.length > 0) { + return { + pendingToolCalls: [], + error: '', + done: true, + executionPath: 'agent(no_tools,stop_with_chunks)', + toolCallCount: state.toolCallCount, + iterationCount: state.iterationCount + 1, + llmCallCount: attempts + }; + } + + // ── Normal: has tool calls ── + const toolNames = toolCalls.map((tc) => tc.function.name); + return { + pendingToolCalls: toolCalls, + error: '', + done: false, + executionPath: `agent(${mode},${toolNames.join(',')})`, + toolCallCount: state.toolCallCount + toolCalls.length, + iterationCount: state.iterationCount + 1, + llmCallCount: attempts + }; +} + +function baseState(overrides: Partial = {}): AgentNodeState { + return { + messages: [{ role: 'user', content: 'test query' }], + playbook: 'general', + allChunks: [], + toolCallCount: 0, + iterationCount: 0, + ...overrides + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('Agent Node Fallback (P0)', () => { + describe('LLM call retry', () => { + it('retries once on LLM failure, then succeeds with tool call', async () => { + const llm = new CountedMockLLM([ + { content: '', shouldThrow: true }, // call 1: fails + { content: 'ok', toolCalls: [{ id: 'c1', type: 'function', function: { name: 'search', arguments: '{"query":"t"}' } }] } // call 2: success + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(llm.callCount).toBe(2); + expect(result.pendingToolCalls.length).toBe(1); + expect(result.pendingToolCalls[0].function.name).toBe('search'); + expect(result.executionPath).toBe('agent(native,search)'); + }); + + it('returns error after max retries exhausted with no chunks', async () => { + const llm = new CountedMockLLM([ + { content: '', shouldThrow: true }, + { content: '', shouldThrow: true } + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(llm.callCount).toBe(2); + expect(result.pendingToolCalls.length).toBe(0); + expect(result.error).toContain('agent error after 2 attempts'); + expect(result.executionPath).toBe('agent(retry_exhausted)'); + }); + + it('marks done when retries exhausted but chunks exist', async () => { + const llm = new CountedMockLLM([ + { content: '', shouldThrow: true }, + { content: '', shouldThrow: true } + ]); + + const result = await runAgentNode(llm, baseState({ + allChunks: [{ id: 'c1', content: 'data' }], + toolCallCount: 1 + })); + + expect(result.done).toBe(true); + expect(result.executionPath).toBe('agent(retry_exhausted_with_chunks)'); + }); + }); + + describe('Text fallback', () => { + it('parses @search from content when native FC returns no toolCalls', async () => { + const llm = new CountedMockLLM([ + { content: '@search({"query": "test search"})' } // no toolCalls in response + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(result.pendingToolCalls.length).toBe(1); + expect(result.pendingToolCalls[0].function.name).toBe('search'); + expect(result.executionPath).toContain('text_fallback'); + }); + + it('parses XML when native FC returns no toolCalls', async () => { + const llm = new CountedMockLLM([ + { + content: + 'xml search', + toolCalls: undefined + } + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(result.pendingToolCalls.length).toBe(1); + expect(result.pendingToolCalls[0].function.name).toBe('search'); + expect(result.executionPath).toContain('text_fallback'); + }); + }); + + describe('No-tool-call guidance retry', () => { + it('injects guidance and retries LLM when first call has no tool call', async () => { + // call 1: empty text, no tool calls → triggers guidance injection + // call 2: guidance retry succeeds with @search in text + const llm = new CountedMockLLM([ + { content: '' }, // call 1: no tool call + { content: '@search({"query": "guided search"})' } // call 2: guidance retry + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(result.pendingToolCalls.length).toBe(1); + expect(result.pendingToolCalls[0].function.name).toBe('search'); + expect(llm.callCount).toBe(2); + expect(llm.receivedMessages[1]!.some((m) => m.id === 'tool-call-guidance')).toBe(true); + }); + + it('gives up when guidance retry also produces no tool call', async () => { + const llm = new CountedMockLLM([ + { content: '' }, // call 1: no tool call + { content: '' } // call 2: guidance retry also empty + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(result.pendingToolCalls.length).toBe(0); + expect(result.executionPath).toBe('agent(no_tools,give_up)'); + expect(llm.callCount).toBe(2); + }); + + it('skips guidance retry when chunks already collected', async () => { + const llm = new CountedMockLLM([ + { content: '' } // only one call expected + ]); + + const result = await runAgentNode(llm, baseState({ + allChunks: [{ id: 'c1', content: 'data' }], + toolCallCount: 1 + })); + + expect(result.done).toBe(true); + expect(result.executionPath).toBe('agent(no_tools,stop_with_chunks)'); + expect(llm.callCount).toBe(1); + }); + + it('handles guidance LLM call failure gracefully', async () => { + const llm = new CountedMockLLM([ + { content: '' }, // call 1: no tool call + { content: '', shouldThrow: true } // call 2: guidance retry fails + ]); + + const result = await runAgentNode(llm, baseState()); + + expect(result.pendingToolCalls.length).toBe(0); + expect(result.executionPath).toBe('agent(no_tools,give_up)'); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/unit/chunk_selector_irrelevant.test.ts b/packages/diting-rag-ts/test/unit/chunk_selector_irrelevant.test.ts new file mode 100644 index 000000000000..80dcfa341f7c --- /dev/null +++ b/packages/diting-rag-ts/test/unit/chunk_selector_irrelevant.test.ts @@ -0,0 +1,90 @@ +// test/unit/chunk_selector_irrelevant.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { ChunkSelectorSkill } from '../../src/skills/atomic/chunk_selector'; +import { MockLLMProvider } from '../../src/adapters/mock/llm'; +import type { ChunkItem } from '../../src/types/chunk'; + +function makeChunk(id: string, score: number): ChunkItem { + return { + id, + content: `Content for chunk ${id}. `.repeat(20), + score, + datasetId: 'd1', + sourceName: 'test' + }; +} + +describe('ChunkSelectorSkill — irrelevant gate', () => { + let skill: ChunkSelectorSkill; + + beforeEach(() => { + skill = new ChunkSelectorSkill(); + skill.initializeProvider(new MockLLMProvider({ responses: ['5'] })); + }); + + it('returns empty array when all chunks have BGE score < 0.2', async () => { + const chunks: ChunkItem[] = [ + makeChunk('c1', 0.05), + makeChunk('c2', 0.08), + makeChunk('c3', 0.12), + makeChunk('c4', 0.1), + makeChunk('c5', 0.07) + ]; + + const result = await skill.execute({ + query: '腾讯有多少员工', + chunks, + tokenBudget: 10000, + playbook: 'simple_query', + enableLLMRerank: false + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { selectedChunks: ChunkItem[] }; + expect(data.selectedChunks.length).toBe(0); + } + }); + + it('does NOT activate gate when at least one chunk has score >= 0.2', async () => { + const chunks: ChunkItem[] = [ + makeChunk('c1', 0.05), + makeChunk('c2', 0.25), + makeChunk('c3', 0.08) + ]; + + const result = await skill.execute({ + query: 'NGFW 配置', + chunks, + tokenBudget: 10000, + playbook: 'simple_query', + enableLLMRerank: false + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { selectedChunks: ChunkItem[] }; + expect(data.selectedChunks.length).toBeGreaterThan(0); + } + }); + + it('forces MIN_KEEP when chunks are relevant (existing behavior unchanged)', async () => { + const chunks: ChunkItem[] = Array.from({ length: 8 }, (_, i) => + makeChunk(`c${i}`, 0.9 - i * 0.05) + ); + + const result = await skill.execute({ + query: 'NGFW 防火墙策略', + chunks, + tokenBudget: 10000, + playbook: 'simple_query', + enableLLMRerank: false + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { selectedChunks: ChunkItem[] }; + expect(data.selectedChunks.length).toBeGreaterThanOrEqual(3); + } + }); +}); diff --git a/packages/diting-rag-ts/test/unit/irrelevant_detection.test.ts b/packages/diting-rag-ts/test/unit/irrelevant_detection.test.ts new file mode 100644 index 000000000000..a84248f7e27b --- /dev/null +++ b/packages/diting-rag-ts/test/unit/irrelevant_detection.test.ts @@ -0,0 +1,69 @@ +// test/unit/irrelevant_detection.test.ts +import { describe, it, expect } from 'vitest'; +import { isSearchIrrelevant } from '../../src/agent/nodes'; +import type { ChunkItem } from '../../src/types/chunk'; + +function makeChunk(overrides: Partial = {}): ChunkItem { + return { + id: 'c1', + content: 'test content', + score: 0.5, + datasetId: 'd1', + sourceName: 'test', + ...overrides + }; +} + +describe('isSearchIrrelevant', () => { + it('returns true for empty chunks', () => { + expect(isSearchIrrelevant([])).toBe(true); + }); + + it('returns true when all BGE scores below 0.2 (no LLM score)', () => { + const chunks = [ + makeChunk({ id: 'c1', score: 0.05 }), + makeChunk({ id: 'c2', score: 0.1 }), + makeChunk({ id: 'c3', score: 0.15 }) + ]; + expect(isSearchIrrelevant(chunks)).toBe(true); + }); + + it('returns false when at least one BGE score >= 0.2 (no LLM score)', () => { + const chunks = [makeChunk({ id: 'c1', score: 0.05 }), makeChunk({ id: 'c2', score: 0.25 })]; + expect(isSearchIrrelevant(chunks)).toBe(false); + }); + + it('returns true when all LLM scores < 3 (even if BGE is high)', () => { + const chunks = [ + makeChunk({ id: 'c1', score: 0.95, llm_sub_query_score: 1 }), + makeChunk({ id: 'c2', score: 0.88, llm_sub_query_score: 2 }) + ]; + // LLM优先:bestLLM=2 < 3 → irrelevant + expect(isSearchIrrelevant(chunks)).toBe(true); + }); + + it('returns false when at least one LLM score >= 3 (even if BGE is low)', () => { + const chunks = [ + makeChunk({ id: 'c1', score: 0.05, llm_sub_query_score: 1 }), + makeChunk({ id: 'c2', score: 0.08, llm_sub_query_score: 5 }) + ]; + // LLM优先:bestLLM=5 >= 3 → relevant + expect(isSearchIrrelevant(chunks)).toBe(false); + }); + + it('falls back to BGE when only some chunks have LLM scores (edge case: mixed)', () => { + // 若只有部分 chunks 有 LLM score,仍基于有 LLM score 的那部分判断 + const chunks = [ + makeChunk({ id: 'c1', score: 0.05, llm_sub_query_score: 2 }), + makeChunk({ id: 'c2', score: 0.9 }) // 无 LLM score + ]; + // llmScores=[2], bestLLM=2 < 3 → irrelevant (LLM wins even though BGE of c2 is high) + expect(isSearchIrrelevant(chunks)).toBe(true); + }); + + it('returns false when rerankScore is used instead of score', () => { + const chunks = [makeChunk({ id: 'c1', score: 0.0, rerankScore: 0.75 })]; + // 无 LLM score,使用 rerankScore ?? score = 0.75 >= 0.2 → relevant + expect(isSearchIrrelevant(chunks)).toBe(false); + }); +}); diff --git a/packages/diting-rag-ts/test/unit/lang.test.ts b/packages/diting-rag-ts/test/unit/lang.test.ts new file mode 100644 index 000000000000..8618b3acebcd --- /dev/null +++ b/packages/diting-rag-ts/test/unit/lang.test.ts @@ -0,0 +1,59 @@ +// test/unit/lang.test.ts +import { describe, it, expect } from 'vitest'; +import { detectLang } from '../../src/utils/lang'; + +describe('detectLang', () => { + it('returns zh for pure Chinese text', () => { + expect(detectLang('今天天气很好')).toBe('zh'); + expect(detectLang('请帮我查询一下订单状态')).toBe('zh'); + }); + + it('returns en for pure English text', () => { + // franc on short English text may misdetect, but our CJK gate + // ensures non-CJK short text defaults to franc's best guess. + // Long English text is reliably detected. + const result = detectLang( + 'What is the weather today? I would like to know the forecast for this week.' + ); + expect(result).toBe('en'); + }); + + it('returns zh for mixed Chinese-English text (CJK present)', () => { + expect(detectLang('reset-password.txt 是什么文件')).toBe('zh'); + expect(detectLang('API接口文档')).toBe('zh'); + }); + + it('returns ja for Japanese text (hiragana present)', () => { + expect(detectLang('今日は良い天気です')).toBe('ja'); + expect(detectLang('ドキュメントを検索する')).toBe('ja'); + }); + + it('returns ko for Korean text (hangul present)', () => { + expect(detectLang('안녕하세요')).toBe('ko'); + }); + + it('returns en for empty or whitespace-only string', () => { + expect(detectLang('')).toBe('en'); + expect(detectLang(' ')).toBe('en'); + }); + + it('handles text with special characters (no CJK)', () => { + // Pure ASCII — franc will attempt detection + // For short ASCII, franc is unreliable; we just check it does not throw + const result = detectLang('HTTP/1.1 200 OK'); + expect(typeof result).toBe('string'); + }); + + it('returns en for very short text without CJK', () => { + // eld returns isReliable()=false for very short text → fallback to 'en' + expect(detectLang('OK')).toBe('en'); + expect(detectLang('a')).toBe('en'); + }); + + it('returns ISO 639-1 code for long French text', () => { + const result = detectLang( + "Bonjour, je voudrais savoir comment configurer le système d'authentification." + ); + expect(result).toBe('fr'); + }); +}); diff --git a/packages/diting-rag-ts/test/unit/language_tracker.test.ts b/packages/diting-rag-ts/test/unit/language_tracker.test.ts new file mode 100644 index 000000000000..ce4d580c21f3 --- /dev/null +++ b/packages/diting-rag-ts/test/unit/language_tracker.test.ts @@ -0,0 +1,353 @@ +// test/unit/language_tracker.test.ts +// v2 API: buildLanguageConfig + LanguageTracker with anomaly detection +import { describe, it, expect } from 'vitest'; +import { + LanguageTracker, + buildLanguageConfig, + type LanguageTrackerConfig +} from '../../src/utils/lang_directive'; +import type { ChunkItem } from '../../src/types/chunk'; + +function makeChunk(overrides: Partial = {}): ChunkItem { + return { + id: 'c1', + content: 'test', + score: 0.5, + datasetId: 'd1', + sourceName: 'test', + ...overrides + }; +} + +// ============================================================ +// buildLanguageConfig +// ============================================================ + +describe('buildLanguageConfig', () => { + it('L1 authoritative: dominant >70%', () => { + const { directive, trackerConfig } = buildLanguageConfig({ zh: 150, en: 30 }, 'en'); + expect(trackerConfig.confidence).toBe('authoritative'); + expect(trackerConfig.defaultLang).toBe('zh'); + expect(trackerConfig.userLang).toBe('en'); + expect(directive).toContain('DEFAULT SEARCH LANGUAGE: Chinese (中文)'); + expect(directive).toContain('83% zh'); + expect(directive).toContain('Final answer MUST be in: English'); + }); + + it('L2 tentative: no language >70%', () => { + const { directive, trackerConfig } = buildLanguageConfig({ zh: 60, en: 40, ja: 30 }, 'zh'); + expect(trackerConfig.confidence).toBe('tentative'); + expect(trackerConfig.defaultLang).toBe('zh'); + expect(directive).toContain('Monitor results'); + }); + + it('L3 fallback: null stats', () => { + const { directive, trackerConfig } = buildLanguageConfig(null, 'ja'); + expect(trackerConfig.confidence).toBe('fallback'); + expect(trackerConfig.defaultLang).toBe('ja'); + expect(directive).toContain('No KB language data'); + expect(directive).toContain('Start with user language Japanese (日本語)'); + }); + + it('L3 fallback: undefined stats', () => { + const { trackerConfig } = buildLanguageConfig(undefined, 'ko'); + expect(trackerConfig.confidence).toBe('fallback'); + }); + + it('L3 fallback: empty stats', () => { + const { trackerConfig } = buildLanguageConfig({}, 'en'); + expect(trackerConfig.confidence).toBe('fallback'); + }); + + it('handles single language (100% authoritative)', () => { + const { trackerConfig, directive } = buildLanguageConfig({ en: 200 }, 'zh'); + expect(trackerConfig.confidence).toBe('authoritative'); + expect(trackerConfig.defaultLang).toBe('en'); + expect(directive).toContain('DEFAULT SEARCH LANGUAGE: English'); + }); + + it('exact 70% threshold is tentative (not authoritative)', () => { + const { trackerConfig } = buildLanguageConfig({ zh: 70, en: 30 }, 'en'); + expect(trackerConfig.confidence).toBe('tentative'); + }); + + it('70.1% is authoritative', () => { + const { trackerConfig } = buildLanguageConfig({ zh: 71, en: 29 }, 'en'); + expect(trackerConfig.confidence).toBe('authoritative'); + }); + + it('L1 includes userHint when dominantLang differs from userLang', () => { + const { directive } = buildLanguageConfig({ th: 150, en: 30 }, 'en'); + expect(directive).toContain('User asked in English'); + expect(directive).toContain('try English queries'); + }); + + it('L1 omits userHint when dominantLang equals userLang', () => { + const { directive } = buildLanguageConfig({ en: 150, zh: 30 }, 'en'); + expect(directive).not.toContain('User asked in'); + }); + + it('L2 includes userHint when dominantLang differs from userLang', () => { + const { directive } = buildLanguageConfig({ zh: 60, en: 40, ja: 30 }, 'en'); + expect(directive).toContain('User asked in English'); + expect(directive).toContain('try English queries'); + }); + + it('L2 omits userHint when dominantLang equals userLang', () => { + const { directive } = buildLanguageConfig({ zh: 60, en: 40, ja: 30 }, 'zh'); + expect(directive).not.toContain('User asked in'); + }); +}); + +// ============================================================ +// LanguageTracker +// ============================================================ + +describe('LanguageTracker', () => { + const config: LanguageTrackerConfig = { + defaultLang: 'zh', + userLang: 'en', + confidence: 'tentative' + }; + + describe('fromConfig', () => { + it('creates tracker from LanguageTrackerConfig', () => { + const t = LanguageTracker.fromConfig(config); + expect(t.defaultLang).toBe('zh'); + expect(t.userLang).toBe('en'); + expect(t.confidence).toBe('tentative'); + }); + }); + + describe('recordSearch', () => { + it('records query language and result language distribution', () => { + const t = LanguageTracker.fromConfig(config); + const chunks = [ + makeChunk({ id: '1', detectedLanguage: 'zh' }), + makeChunk({ id: '2', detectedLanguage: 'zh' }), + makeChunk({ id: '3', detectedLanguage: 'en' }) + ]; + t.recordSearch('产品配置', chunks); + expect(t.getLastRecord()).toBeDefined(); + }); + + it('treats chunks without detectedLanguage as unknown', () => { + const t = LanguageTracker.fromConfig(config); + const chunks = [makeChunk({ id: '1' })]; + t.recordSearch('test', chunks); + const rec = t.getLastRecord()!; + expect(rec.resultStats['unknown']).toBe(0.5); + }); + }); + + describe('buildGuidance — normal (silence)', () => { + it('returns null guidance when no records', () => { + const t = LanguageTracker.fromConfig(config); + const result = t.buildGuidance([]); + expect(result.shouldPush).toBe(false); + expect(result.guidance).toBeNull(); + }); + + it('returns null when queryLang matches result dominant', () => { + const t = LanguageTracker.fromConfig(config); + t.recordSearch('系统配置', [ + makeChunk({ id: '1', detectedLanguage: 'zh' }), + makeChunk({ id: '2', detectedLanguage: 'zh' }), + makeChunk({ id: '3', detectedLanguage: 'en' }) + ]); + // zh query → zh dominant → 正常 + const result = t.buildGuidance([ + makeChunk({ id: '1', score: 0.5 }) // 低相关,但语言匹配,不异常 + ]); + expect(result.shouldPush).toBe(false); + }); + + it('returns null when result dominant matches defaultLang', () => { + const t = LanguageTracker.fromConfig(config); + t.recordSearch('how do I configure the API settings for this application', [ + makeChunk({ id: '1', detectedLanguage: 'zh' }), + makeChunk({ id: '2', detectedLanguage: 'zh' }) + ]); + // en query → zh dominant = defaultLang → 正常(用户用自己的语言搜索,结果是默认语言) + const result = t.buildGuidance([]); + expect(result.shouldPush).toBe(false); + }); + }); + + describe('buildGuidance — anomaly detection', () => { + it('detects language mismatch with low relevance', () => { + const jaConfig: LanguageTrackerConfig = { + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }; + const t = LanguageTracker.fromConfig(jaConfig); + t.recordSearch('API认证配置', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }), + makeChunk({ id: '3', detectedLanguage: 'en' }) + ]); + // zh query → en dominant ≠ ja default → 异常 + // filtered chunks have low score → 确认异常 + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(result.shouldPush).toBe(true); + expect(result.suppressEarlyStop).toBe(true); + expect(result.guidance).toContain('⚠ LANGUAGE'); + expect(result.guidance).toContain('en'); + }); + + it('suppresses anomaly when cross-lingual embedding gives high scores', () => { + const jaConfig: LanguageTrackerConfig = { + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }; + const t = LanguageTracker.fromConfig(jaConfig); + t.recordSearch('API', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + // zh query → en dominant ≠ ja default + // BUT filtered chunks have high BGE → 跨语言生效,沉默 + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.4, rerankScore: 0.5 })]); + expect(result.shouldPush).toBe(false); + expect(result.suppressEarlyStop).toBe(false); + }); + + it('deduplicates same anomaly', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + t.recordSearch('测试', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + // First call → push + const r1 = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(r1.shouldPush).toBe(true); + + // Second call with same anomaly → dedup + const r2 = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(r2.shouldPush).toBe(false); + expect(r2.suppressEarlyStop).toBe(true); // still suppress + }); + + it('clears anomaly after clearAnomaly()', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + t.recordSearch('测试', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); // push + + t.clearAnomaly(); + const r2 = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(r2.shouldPush).toBe(true); // re-pushes after clear + }); + }); + + describe('buildGuidance — LLM score cross-lingual bypass', () => { + it('bypasses when LLM sub_query_score is high', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'ja', + userLang: 'zh', + confidence: 'authoritative' + }); + t.recordSearch('测试', [ + makeChunk({ id: '1', detectedLanguage: 'en' }), + makeChunk({ id: '2', detectedLanguage: 'en' }) + ]); + // High LLM score → cross-lingual effective + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.1, llm_sub_query_score: 8 })]); + expect(result.shouldPush).toBe(false); + }); + }); + + describe('buildGuidance — user language disconnect', () => { + it('pushes guidance when searching in non-user language with weak results', () => { + // 模拟用户用英文提问,但 KB 以泰文为主,Agent 一直在搜索泰文 + const t = LanguageTracker.fromConfig({ + defaultLang: 'th', + userLang: 'en', + confidence: 'authoritative' + }); + // Agent 用泰文搜索,返回泰文结果 + t.recordSearch('ผู้เขียนหนังสือสามก๊ก', [ + makeChunk({ id: '1', detectedLanguage: 'th' }), + makeChunk({ id: '2', detectedLanguage: 'th' }) + ]); + // 结果相关性低 + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(result.shouldPush).toBe(true); + expect(result.guidance).toContain('⚠ LANGUAGE'); + expect(result.guidance).toContain('en'); + expect(result.guidance).toContain('th'); + expect(result.suppressEarlyStop).toBe(false); + }); + + it('silent when searching in non-user language but results are strong', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'th', + userLang: 'en', + confidence: 'authoritative' + }); + t.recordSearch('ผู้เขียนหนังสือสามก๊ก', [ + makeChunk({ id: '1', detectedLanguage: 'th' }), + makeChunk({ id: '2', detectedLanguage: 'th' }) + ]); + // 高分结果 → 不推送,让 Agent 继续 + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.8 })]); + expect(result.shouldPush).toBe(false); + }); + + it('silent when queryLang matches userLang (user language is being used)', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'th', + userLang: 'en', + confidence: 'authoritative' + }); + // 用户用英文搜索 + t.recordSearch('author of Three Kingdoms', [ + makeChunk({ id: '1', detectedLanguage: 'th' }), + makeChunk({ id: '2', detectedLanguage: 'th' }) + ]); + // queryLang='en' === userLang='en' → 已在尝试用户语言 + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(result.shouldPush).toBe(false); + }); + + it('deduplicates user-lang guidance', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'th', + userLang: 'en', + confidence: 'authoritative' + }); + t.recordSearch('ผู้เขียนหนังสือสามก๊ก', [makeChunk({ id: '1', detectedLanguage: 'th' })]); + // 第一次 → push + const r1 = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(r1.shouldPush).toBe(true); + + // 第二次相同情况 → dedup + t.recordSearch('ประวัติสามก๊ก', [makeChunk({ id: '2', detectedLanguage: 'th' })]); + const r2 = t.buildGuidance([makeChunk({ id: '2', score: 0.1 })]); + expect(r2.shouldPush).toBe(false); + }); + + it('silent when defaultLang equals userLang (no disconnect possible)', () => { + const t = LanguageTracker.fromConfig({ + defaultLang: 'zh', + userLang: 'zh', + confidence: 'authoritative' + }); + t.recordSearch('系统配置', [makeChunk({ id: '1', detectedLanguage: 'zh' })]); + const result = t.buildGuidance([makeChunk({ id: '1', score: 0.1 })]); + expect(result.shouldPush).toBe(false); + }); + }); +}); diff --git a/packages/diting-rag-ts/test/unit/query_rewrite_fallback.test.ts b/packages/diting-rag-ts/test/unit/query_rewrite_fallback.test.ts new file mode 100644 index 000000000000..abe8f9e69032 --- /dev/null +++ b/packages/diting-rag-ts/test/unit/query_rewrite_fallback.test.ts @@ -0,0 +1,243 @@ +// test/unit/query_rewrite_fallback.test.ts +// P1: query_rewrite 降级测试 — RED phase +// +// 新功能: batchRewrite LLM 调用失败时,fallback 到原始 query 而非直接 fail +// - batchRewrite 失败 → 返回 `queries: [originalQuery]`,让 agent 能继续搜索 +// - selectStrategies 失败 → 已存在的 gqr 默认策略兜底 +// - 整体 try/catch → 不再直接 fail,改成带 original query 的 success + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { LLMProvider } from '../../src/ports/llm'; +import type { LLMMessage, LLMResponse, LLMCallOptions } from '../../src/types/message'; +import { QueryRewriteSkill } from '../../src/skills/expertise/query_rewrite'; + +// ── Controllable Mock LLM ────────────────────────────────────────────────── +class PhaseMockLLM implements LLMProvider { + public callCount = 0; + public receivedMessages: LLMMessage[][] = []; + + constructor( + private responses: Array<{ content: string; shouldThrow?: boolean }> + ) {} + + async chat(messages: LLMMessage[], _options?: LLMCallOptions): Promise { + this.receivedMessages.push([...messages]); + this.callCount++; + const r = this.responses[this.callCount - 1]; + if (!r) throw new Error(`No response configured for call #${this.callCount}`); + if (r.shouldThrow) throw new Error(`LLM failure #${this.callCount}`); + return { content: r.content }; + } + + async *chatStream(_messages: LLMMessage[], _options?: LLMCallOptions): AsyncIterable { + const r = await this.chat(_messages, _options); + yield r; + } + + getModelInfo() { return { name: 'phase-mock', contextWindow: 16000, maxOutputTokens: 8192 }; } +} + +// ── Direct test of the fallback logic ────────────────────────────────────── +// +// 直接测试改写逻辑函数,验证 batchRewrite 失败时的降级行为。 +// 测试一个精简版的 execute 流程(strategy selection + batch rewrite + fallback)。 + +import type { SkillInput, SkillOutput } from '../../src/skills/base'; + +/** + * 带 fallback 的 execute 逻辑(测试目标实现) + */ +async function executeWithFallback( + skill: QueryRewriteSkill, + input: SkillInput +): Promise { + const { query, priorContext } = input as unknown as { query: string; priorContext?: string }; + + if (!(skill as any).llm) return { success: false, error: 'LLMProvider not initialized' }; + + try { + // Step 1: 策略选择 + let selected: string[] = []; + let reasoning = ''; + + try { + const result = await (skill as any).selectStrategies(query); + if (result) { + selected = result.strategies || []; + reasoning = result.reasoning || ''; + } + } catch { + // selectStrategies 失败 → 默认使用 gqr + selected = ['gqr']; + } + + if (selected.length === 0) { + return { + success: true, + data: { rewrites: [], strategies_used: [], selection_reasoning: 'No rewrite needed', queries: [] } + }; + } + + // Step 2: 批量改写(带降级) + let rewrites: Array<{ strategy: string; queries: string[] }> = []; + try { + const batchResult = await (skill as any).batchRewrite( + query, + selected, + priorContext + ); + if (batchResult?.rewrites?.length > 0) { + rewrites = batchResult.rewrites; + } + } catch { + // ★ NEW FALLBACK: batchRewrite 失败时,用原始 query 兜底 + } + + // ★ 如果改写完全失败 or 没有生成 queries,用原始 query 兜底 + const queries = rewrites.length > 0 + ? rewrites.flatMap((r: { queries: string[] }) => r.queries).filter((q: string) => q !== query) + : []; + + if (queries.length === 0 && rewrites.length === 0) { + // 改写失败,fallback 到原始 query + return { + success: true, + data: { + rewrites: [{ strategy: 'fallback', queries: [query] }], + strategies_used: ['fallback'], + selection_reasoning: 'Batch rewrite failed; using original query as fallback', + queries: [query] + } + }; + } + + return { + success: true, + data: { + rewrites, + strategies_used: selected, + selection_reasoning: reasoning, + queries + } + }; + } catch (error) { + // ★ 整体失败:不返回 fail,而是返回带原始 query 的 success + return { + success: true, + data: { + rewrites: [{ strategy: 'fallback', queries: [query] }], + strategies_used: ['fallback'], + selection_reasoning: `Rewrite failed: ${error}; using original query as fallback`, + queries: [query] + } + }; + } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('QueryRewriteSkill — 降级到原始查询 (P1)', () => { + let skill: QueryRewriteSkill; + + beforeEach(() => { + skill = new QueryRewriteSkill(); + }); + + it('falls back to original query when batchRewrite LLM fails', async () => { + // call 1: selectStrategies succeeds → strategies = ["gqr", "cce"] + // call 2: batchRewrite fails + const llm = new PhaseMockLLM([ + { content: '{"strategies": ["gqr", "cce"], "reasoning": "general query rewrite"}' }, + { content: '', shouldThrow: true } + ]); + + (skill as any).llm = llm; + + const result = await executeWithFallback(skill, { + context: null, + query: '什么是超融合架构', + priorContext: '' + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { queries: string[]; strategies_used: string[] }; + // 应该 fallback 到原始 query + expect(data.queries).toContain('什么是超融合架构'); + expect(data.strategies_used).toContain('fallback'); + } + }); + + it('falls back to original query when selectStrategies also fails', async () => { + // 两次 LLM 调用都失败 + const llm = new PhaseMockLLM([ + { content: '', shouldThrow: true }, + { content: '', shouldThrow: true } + ]); + + (skill as any).llm = llm; + + const result = await executeWithFallback(skill, { + context: null, + query: '如何配置防火墙规则', + priorContext: '' + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { queries: string[] }; + expect(data.queries).toContain('如何配置防火墙规则'); + } + }); + + it('returns original query as only result when batchRewrite returns empty rewrites', async () => { + // selectStrategies 返回策略,但 batchRewrite 返回空 rewrites + const llm = new PhaseMockLLM([ + { content: '{"strategies": ["kwr"], "reasoning": "keyword rewrite"}' }, + { content: '{"rewrites": []}' } // 空 rewrites + ]); + + (skill as any).llm = llm; + + const result = await executeWithFallback(skill, { + context: null, + query: 'VPN连接失败怎么办', + priorContext: '' + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { queries: string[] }; + expect(data.queries.length).toBeGreaterThan(0); + expect(data.queries).toContain('VPN连接失败怎么办'); + } + }); + + it('still uses batchRewrite results when LLM succeeds', async () => { + // 正常的改写流程 + const llm = new PhaseMockLLM([ + { content: '{"strategies": ["gqr"], "reasoning": "standard rewrite"}' }, + { + content: JSON.stringify({ + rewrites: [{ strategy: 'gqr', queries: ['超融合架构 定义', '超融合 原理'] }] + }) + } + ]); + + (skill as any).llm = llm; + + const result = await executeWithFallback(skill, { + context: null, + query: '什么是超融合架构', + priorContext: '' + }); + + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { queries: string[]; strategies_used: string[] }; + expect(data.queries).toContain('超融合架构 定义'); + expect(data.queries).toContain('超融合 原理'); + expect(data.strategies_used).not.toContain('fallback'); + } + }); +}); diff --git a/packages/diting-rag-ts/test/unit/retrieve_fallback.test.ts b/packages/diting-rag-ts/test/unit/retrieve_fallback.test.ts new file mode 100644 index 000000000000..486b52e7277b --- /dev/null +++ b/packages/diting-rag-ts/test/unit/retrieve_fallback.test.ts @@ -0,0 +1,208 @@ +// test/unit/retrieve_fallback.test.ts +// P1: 检索降级链测试 — RED phase: 测试尚未实现的新 fallback 行为 +// +// 新功能 1: mixedSearch 全部失败 → 降级到分离检索(本地 RRF) +// 新功能 2: EMB 连续失败 → 自动切换 searchMode + +import { describe, it, expect, beforeEach } from 'vitest'; +import { RetrieveSkill } from '../../src/skills/atomic/retrieve'; +import { MockLLMProvider } from '../../src/adapters/mock/llm'; +import type { LLMProvider } from '../../src/ports/llm'; +import type { + VectorSearchProvider, + FullTextSearchProvider, + MixedSearchProvider, + SearchResult, + VectorSearchOptions, + FullTextSearchOptions, + MixedSearchOptions +} from '../../src/ports/search'; +import type { EmbeddingProvider, EmbedResult } from '../../src/ports/embedding'; +import type { ChunkResult } from '../../src/types/chunk'; + +function makeChunk(id: string, content: string, score: number, datasetId = 'd1'): ChunkResult { + return { id, content, score, datasetId, sourceName: 'test', timestamp: Date.now() }; +} + +// ── Always-failing mixedSearch ──────────────────────────────────────────── +class AlwaysFailingMixedSearch implements MixedSearchProvider { + async search( + _query: string, _datasetIds: string[], _options: MixedSearchOptions, + _emb: { embeddingProvider?: EmbeddingProvider } + ): Promise> { + throw new Error('mixedSearch unavailable'); + } +} + +// ── Always-failing EMB ──────────────────────────────────────────────────── +class AlwaysFailingEmbedding implements EmbeddingProvider { + public callCount = 0; + async embed(_texts: string[]): Promise { + this.callCount++; + throw new Error('EMB service down'); + } + getModelInfo() { return { name: 'broken-emb', dimension: 1536 }; } +} + +// ── Working providers (for fallback verification) ───────────────────────── +const workingVectorSearch: VectorSearchProvider = { + async search(_vectors: number[][], _datasetIds: string[], _options: VectorSearchOptions): Promise> { + return { + chunks: [makeChunk('vs-1', 'vector result', 0.9)], + error: undefined, + meta: { searchSource: 'vector', provider: 'mock', duration: 5, totalTokens: 100 } + }; + } +}; + +const workingFullTextSearch: FullTextSearchProvider = { + async search(_query: string, _datasetIds: string[], _options: FullTextSearchOptions): Promise> { + return { + chunks: [makeChunk('ft-1', 'fulltext result', 0.7)], + error: undefined, + meta: { searchSource: 'fulltext', provider: 'mock', duration: 5, totalTokens: 50 } + }; + } +}; + +const workingEmbedding: EmbeddingProvider = { + async embed(texts: string[]): Promise { + return { vectors: texts.map(() => Array(1536).fill(0).map(() => Math.random())), tokens: 100 }; + }, + getModelInfo() { return { name: 'ok-emb', dimension: 1536 }; } +}; + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('RetrieveSkill — mixedSearch 全失败降级到分离检索 (P1, 新功能)', () => { + let skill: RetrieveSkill; + let llm: LLMProvider; + + beforeEach(() => { + skill = new RetrieveSkill(); + llm = new MockLLMProvider(); + skill.initialize(llm); + }); + + it('falls back to local separation retrieval when mixedSearch fails for all queries', async () => { + // Scenario: mixedSearch 每次都抛异常 + const mixedSearch = new AlwaysFailingMixedSearch(); + + skill.initializeProviders(workingVectorSearch, workingFullTextSearch, workingEmbedding, mixedSearch); + + const result = await skill.execute({ + context: null, + queries: ['q1', 'q2'], + datasetIds: ['d1'], + limit: 20, + searchMode: 'mixedRecall' + }); + + // Expected (NEW behavior): mixedSearch 全部失败后自动降级到分离检索 + // performSearch 检测到所有 mixedSearch query 都失败 → 降级到本地 RRF + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { chunks: ChunkResult[] }; + // 应该从分离检索拿到结果(vector + fulltext → RRF) + expect(data.chunks.length).toBeGreaterThan(0); + } + }); + + it('falls back to local separation when mixedSearch is not injected', async () => { + // No mixedSearch provider at all + skill.initializeProviders(workingVectorSearch, workingFullTextSearch, workingEmbedding); + + const result = await skill.execute({ + context: null, + queries: ['test'], + datasetIds: ['d1'], + limit: 20, + searchMode: 'mixedRecall' + }); + + // 当前已有的 fallback: 无 mixedSearch → 直接走分离检索 + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { chunks: ChunkResult[] }; + expect(data.chunks.length).toBeGreaterThan(0); + } + }); +}); + +describe('RetrieveSkill — EMB 连续失败自动切换 searchMode (P1, 新功能)', () => { + let skill: RetrieveSkill; + let llm: LLMProvider; + + beforeEach(() => { + skill = new RetrieveSkill(); + llm = new MockLLMProvider(); + skill.initialize(llm); + }); + + it('switches to fullTextRecall after consecutive EMB failures', async () => { + const emb = new AlwaysFailingEmbedding(); + const fulltextSearch = workingFullTextSearch; + const vectorSearch: VectorSearchProvider = { + async search(_vectors: number[][], _datasetIds: string[], _options: VectorSearchOptions): Promise> { + return { + chunks: [makeChunk('vs-1', 'vector result', 0.9)], + error: undefined, + meta: { searchSource: 'vector', provider: 'mock', duration: 5, totalTokens: 100 } + }; + } + }; + + skill.initializeProviders(vectorSearch, fulltextSearch, emb); + + // 第一次调用:EMB 失败 → RRF 模式(EMB 被 catch,仅 FullText 有结果) + const result1 = await skill.execute({ + context: null, + queries: ['q1'], + datasetIds: ['d1'], + limit: 20, + searchMode: 'mixedRecall' + }); + + expect(result1.success).toBe(true); + if (result1.success && result1.data) { + const data1 = result1.data as { chunks: ChunkResult[] }; + // 应该从 fulltext 拿到结果(EMB 失败被跳过) + expect(data1.chunks.length).toBeGreaterThan(0); + } + + // 追踪 EMB 失败次数 + 自动切换模式后,后续调用应使用 fullTextRecall + // 注意:此测试验证 EMB 失败不会导致整个搜索崩溃 + // 自动切换 searchMode 的行为需要实现后才可验证 + }); + + it('returns empty result when all providers fail', async () => { + const emb = new AlwaysFailingEmbedding(); + const failingFullText: FullTextSearchProvider = { + async search(_q: string, _ds: string[], _opts: FullTextSearchOptions): Promise> { + throw new Error('fulltext down'); + } + }; + const failingVector: VectorSearchProvider = { + async search(_v: number[][], _ds: string[], _opts: VectorSearchOptions): Promise> { + throw new Error('vector down'); + } + }; + + skill.initializeProviders(failingVector, failingFullText, emb); + + const result = await skill.execute({ + context: null, + queries: ['q1'], + datasetIds: ['d1'], + limit: 20, + searchMode: 'mixedRecall' + }); + + // 所有 provider 都失败 → 返回空结果(而非崩溃) + expect(result.success).toBe(true); + if (result.success && result.data) { + const data = result.data as { chunks: ChunkResult[] }; + expect(data.chunks.length).toBe(0); + } + }); +}); diff --git a/packages/diting-rag-ts/tsconfig.bin.json b/packages/diting-rag-ts/tsconfig.bin.json new file mode 100644 index 000000000000..79a4017ab4d0 --- /dev/null +++ b/packages/diting-rag-ts/tsconfig.bin.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "noEmit": false, + "declaration": false, + "declarationMap": false + }, + "include": ["bin/**/*"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/packages/diting-rag-ts/tsconfig.build.json b/packages/diting-rag-ts/tsconfig.build.json new file mode 100644 index 000000000000..577a657ec5d0 --- /dev/null +++ b/packages/diting-rag-ts/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "rootDir": ".", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules", "dist", "bin"] +} \ No newline at end of file diff --git a/packages/diting-rag-ts/tsconfig.json b/packages/diting-rag-ts/tsconfig.json new file mode 100644 index 000000000000..a45bf09f90ac --- /dev/null +++ b/packages/diting-rag-ts/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "composite": false, + "removeComments": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*", "bin/**/*"], + "exclude": ["node_modules", "dist", "test"] +} \ No newline at end of file diff --git a/packages/diting-rag-ts/vitest.config.ts b/packages/diting-rag-ts/vitest.config.ts new file mode 100644 index 000000000000..40bb18acf4ef --- /dev/null +++ b/packages/diting-rag-ts/vitest.config.ts @@ -0,0 +1,14 @@ +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'] + } + } +}); diff --git a/packages/global/common/error/code/agentSkill.ts b/packages/global/common/error/code/agentSkill.ts index 24348327c804..ffde32391b54 100644 --- a/packages/global/common/error/code/agentSkill.ts +++ b/packages/global/common/error/code/agentSkill.ts @@ -1,5 +1,5 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* agentSkill: 509000 */ export enum SkillErrEnum { unExist = 'skillUnExist', diff --git a/packages/global/common/error/code/app.ts b/packages/global/common/error/code/app.ts index 45b2e3cc3129..4b085030feb9 100644 --- a/packages/global/common/error/code/app.ts +++ b/packages/global/common/error/code/app.ts @@ -1,12 +1,13 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 502000 */ export enum AppErrEnum { unExist = 'appUnExist', unAuthApp = 'unAuthApp', invalidOwner = 'invalidOwner', invalidAppType = 'invalidAppType', - canNotEditAdminPermission = 'canNotEditAdminPermission' + canNotEditAdminPermission = 'canNotEditAdminPermission', + exportSkillFailed = 'exportSkillFailed' } const appErrList = [ { @@ -28,6 +29,10 @@ const appErrList = [ { statusText: AppErrEnum.canNotEditAdminPermission, message: i18nT('common:code_error.app_error.can_not_edit_admin_permission') + }, + { + statusText: AppErrEnum.exportSkillFailed, + message: i18nT('skill:export_skill_failed') } ]; export default appErrList.reduce((acc, cur, index) => { diff --git a/packages/global/common/error/code/chat.ts b/packages/global/common/error/code/chat.ts index b9c5b64cba76..38af4e575844 100644 --- a/packages/global/common/error/code/chat.ts +++ b/packages/global/common/error/code/chat.ts @@ -1,8 +1,9 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 504000 */ export enum ChatErrEnum { unAuthChat = 'unAuthChat', + modelNameRequired = 'modelNameRequired', chatIsGenerating = 'chatIsGenerating' } const errList = [ @@ -10,6 +11,10 @@ const errList = [ statusText: ChatErrEnum.unAuthChat, message: i18nT('common:code_error.chat_error.un_auth') }, + { + statusText: ChatErrEnum.modelNameRequired, + message: i18nT('common:code_error.chat_error.model_name_required') + }, { statusText: ChatErrEnum.chatIsGenerating, message: i18nT('common:code_error.chat_error.chat_generating') @@ -26,3 +31,4 @@ export default errList.reduce((acc, cur, index) => { } }; }, {} as ErrType<`${ChatErrEnum}`>); + diff --git a/packages/global/common/error/code/common.ts b/packages/global/common/error/code/common.ts index 2e82e94a58cb..402bcce4beca 100644 --- a/packages/global/common/error/code/common.ts +++ b/packages/global/common/error/code/common.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; import { type ErrType } from '../errorCode'; /* dataset: 507000 */ @@ -9,7 +9,14 @@ export enum CommonErrEnum { fileNotFound = 'fileNotFound', unAuthFile = 'unAuthFile', missingParams = 'missingParams', - inheritPermissionError = 'inheritPermissionError' + inheritPermissionError = 'inheritPermissionError', + ownerRequired = 'ownerRequired', + pdfEncrypted = 'pdfEncrypted', + pptxParseFailed = 'pptxParseFailed', + fileTypeRequiresExternalParse = 'fileTypeRequiresExternalParse', + unsupportedFileType = 'unsupportedFileType', + customParseMissingKey = 'customParseMissingKey', + duplicateTag = 'duplicateTag' } const datasetErr = [ { @@ -22,19 +29,47 @@ const datasetErr = [ }, { statusText: CommonErrEnum.fileNotFound, - message: 'error.fileNotFound' + message: i18nT('common:error.fileNotFound') }, { statusText: CommonErrEnum.unAuthFile, - message: 'error.unAuthFile' + message: i18nT('common:error.unAuthFile') }, { statusText: CommonErrEnum.missingParams, - message: 'error.missingParams' + message: i18nT('common:error.missingParams') }, { statusText: CommonErrEnum.inheritPermissionError, - message: 'error.inheritPermissionError' + message: i18nT('common:error.inheritPermissionError') + }, + { + statusText: CommonErrEnum.ownerRequired, + message: i18nT('common:error.owner_required') + }, + { + statusText: CommonErrEnum.pdfEncrypted, + message: i18nT('file:pdf_encrypted_error') + }, + { + statusText: CommonErrEnum.pptxParseFailed, + message: i18nT('file:pptx_parse_failed') + }, + { + statusText: CommonErrEnum.fileTypeRequiresExternalParse, + message: i18nT('file:file_type_requires_external_parse') + }, + { + statusText: CommonErrEnum.unsupportedFileType, + message: i18nT('file:unsupported_file_type') + }, + { + statusText: CommonErrEnum.customParseMissingKey, + message: i18nT('file:custom_parse_missing_key') + }, + { + statusText: CommonErrEnum.duplicateTag, + message: i18nT('common:error.duplicate_tag') } ]; export default datasetErr.reduce((acc, cur, index) => { diff --git a/packages/global/common/error/code/database.ts b/packages/global/common/error/code/database.ts new file mode 100644 index 000000000000..341581531a3a --- /dev/null +++ b/packages/global/common/error/code/database.ts @@ -0,0 +1,139 @@ +import { i18nT } from '../../i18n/utils'; +import { type ErrType } from '../errorCode'; + +/* database: 509000 */ +export enum DatabaseErrEnum { + // 知识库相关 + datasetParamsError = 'datasetParamsError', + // 客户端创建和管理错误 + clientCreateError = 'databaseClientCreateError', + clientUpdateError = 'databaseClientUpdateError', + clientDestroyError = 'databaseClientDestroyError', + clientAlreadyExists = 'databaseClientAlreadyExists', + clientNotFound = 'databaseClientNotFound', + + // 连接相关错误 + authError = 'databaseAuthError', + databaseNameError = 'databaseNameError', + databasePortError = 'databasePortError', + hostError = 'databaseHostError', + checkError = 'databaseCheckError', + econnRefused = 'connectionRefused', + connectionFailed = 'databaseConnectionFailed', + connectionTimeout = 'databaseConnectionTimeout', + connectionLost = 'databaseConnectionLost', + + // 数据库类型和支持错误 + notSupportType = 'databaseNotSupportType', + notImplemented = 'databaseNotImplemented', + + // API 请求和验证错误 + requestValidationError = 'databaseRequestValidationError', + invalidTableName = 'databaseInvalidTableName', + fetchInfoError = 'databaseFetchInfoError', + dbConfigNotFound = 'databaseConfigNotFound', + opUnknownDatabaseError = 'opUnknownDatabaseError', + dativeServiceError = 'dativeServiceError' +} + +const databaseErr = [ + // 客户端管理错误 + { + statusText: DatabaseErrEnum.clientCreateError, + message: 'core.database.error.client_create_failed' + }, + { + statusText: DatabaseErrEnum.clientUpdateError, + message: 'core.database.error.client_update_failed' + }, + { + statusText: DatabaseErrEnum.clientDestroyError, + message: i18nT('database_client:client_destory_error') + }, + { + statusText: DatabaseErrEnum.clientNotFound, + message: i18nT('database_client:client_not_found') + }, + + // 连接错误 + { + statusText: DatabaseErrEnum.authError, + message: i18nT('database_client:authentication_failed') + }, + { + statusText: DatabaseErrEnum.databaseNameError, + message: i18nT('database_client:database_not_exist') + }, + { + statusText: DatabaseErrEnum.databasePortError, + message: i18nT('database_client:database_port_error') + }, + { + statusText: DatabaseErrEnum.hostError, + message: i18nT('database_client:host_error') + }, + { + statusText: DatabaseErrEnum.econnRefused, + message: i18nT('database_client:connection_refused') + }, + { + statusText: DatabaseErrEnum.checkError, + message: i18nT('database_client:connection_check_error') + }, + { + statusText: DatabaseErrEnum.connectionLost, + message: i18nT('database_client:connection_lost') + }, + { + statusText: DatabaseErrEnum.connectionFailed, + message: i18nT('database_client:connection_failed') + }, + { + statusText: DatabaseErrEnum.connectionTimeout, + message: i18nT('database_client:connection_timeout') + }, + + // 类型支持错误 + { + statusText: DatabaseErrEnum.notSupportType, + message: i18nT('database_client:not_support_databaseType') + }, + { + statusText: DatabaseErrEnum.notImplemented, + message: i18nT('database_client:not_implemented_databaseType') + }, + + // 请求验证错误 + { + statusText: DatabaseErrEnum.invalidTableName, + message: i18nT('database_client:invalid_table_name') + }, + { + statusText: DatabaseErrEnum.fetchInfoError, + message: i18nT('database_client:fetch_info_error') + }, + { + statusText: DatabaseErrEnum.dbConfigNotFound, + message: i18nT('database_client:database_config_not_found') + }, + { + statusText: DatabaseErrEnum.opUnknownDatabaseError, + message: i18nT('database_client:op_unknown_database_error') + }, + { + statusText: DatabaseErrEnum.dativeServiceError, + message: i18nT('database_client:dative_service_error') + } +]; + +export default databaseErr.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 509000 + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; +}, {} as ErrType<`${DatabaseErrEnum}`>); diff --git a/packages/global/common/error/code/dataset.ts b/packages/global/common/error/code/dataset.ts index a3591fca5cf5..e0ea344da954 100644 --- a/packages/global/common/error/code/dataset.ts +++ b/packages/global/common/error/code/dataset.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; import { type ErrType } from '../errorCode'; /* dataset: 501000 */ @@ -15,7 +15,35 @@ export enum DatasetErrEnum { notSupportSync = 'notSupportSync', sameApiCollection = 'sameApiCollection', noApiServer = 'noApiServer', - canNotEditAdminPermission = 'canNotEditAdminPermission' + canNotEditAdminPermission = 'canNotEditAdminPermission', + // Data validation errors + datasetIdMismatch = 'datasetIdMismatch', + // Synonym errors + synonymFileNotExist = 'synonymFileNotExist', + synonymFileEmpty = 'synonymFileEmpty', + synonymFileInvalidFormat = 'synonymFileInvalidFormat', + synonymFileNoValidData = 'synonymFileNoValidData', + synonymFileUnsupportedFormat = 'synonymFileUnsupportedFormat', + synonymFileParseFailed = 'synonymFileParseFailed', + synonymTermDuplicate = 'synonymTermDuplicate', + datasetTrainingInProgress = 'datasetTrainingInProgress', + // Quota errors + insufficientQuota = 'insufficientQuota', + // Collection name validation errors + collectionNameDuplicate = 'collectionNameDuplicate', + collectionNameMissingExtension = 'collectionNameMissingExtension', + collectionNameExtensionMismatch = 'collectionNameExtensionMismatch', + // Template import errors + templateImportModeNotFound = 'templateImportModeNotFound', + // File upload errors + fileContentDuplicate = 'fileContentDuplicate', + // Image dataset errors + imageDatasetRequiresVlmModel = 'imageDatasetRequiresVlmModel', + imageDatasetRequiresVlmModelOrCustomParse = 'imageDatasetRequiresVlmModelOrCustomParse', + // Collaborator errors + cannotChangeOwner = 'cannotChangeOwner', + collaboratorNotInTeam = 'collaboratorNotInTeam', + permissionSyncEnabled = 'permissionSyncEnabled' } const datasetErr = [ { @@ -36,35 +64,107 @@ const datasetErr = [ }, { statusText: DatasetErrEnum.unAuthDataset, - message: 'core.dataset.error.unAuthDataset' + message: i18nT('common:core.dataset.error.unAuthDataset') }, { statusText: DatasetErrEnum.unAuthDatasetCollection, - message: 'core.dataset.error.unAuthDatasetCollection' + message: i18nT('common:core.dataset.error.unAuthDatasetCollection') }, { statusText: DatasetErrEnum.unAuthDatasetData, - message: 'core.dataset.error.unAuthDatasetData' + message: i18nT('common:core.dataset.error.unAuthDatasetData') }, { statusText: DatasetErrEnum.unAuthDatasetFile, - message: 'core.dataset.error.unAuthDatasetFile' + message: i18nT('common:core.dataset.error.unAuthDatasetFile') }, { statusText: DatasetErrEnum.unCreateCollection, - message: 'core.dataset.error.unCreateCollection' + message: i18nT('common:core.dataset.error.unCreateCollection') }, { statusText: DatasetErrEnum.unLinkCollection, - message: 'core.dataset.error.unLinkCollection' + message: i18nT('common:core.dataset.error.unLinkCollection') }, { statusText: DatasetErrEnum.invalidVectorModelOrQAModel, - message: 'core.dataset.error.invalidVectorModelOrQAModel' + message: i18nT('common:core.dataset.error.invalidVectorModelOrQAModel') }, { statusText: DatasetErrEnum.canNotEditAdminPermission, - message: 'core.dataset.error.canNotEditAdminPermission' + message: i18nT('common:core.dataset.error.canNotEditAdminPermission') + }, + { + statusText: DatasetErrEnum.datasetIdMismatch, + message: i18nT('common:core.dataset.error.datasetIdMismatch') + }, + { + statusText: DatasetErrEnum.synonymFileNotExist, + message: i18nT('common:core.dataset.error.synonymFileNotExist') + }, + { + statusText: DatasetErrEnum.synonymFileEmpty, + message: i18nT('common:core.dataset.error.synonymFileEmpty') + }, + { + statusText: DatasetErrEnum.synonymFileInvalidFormat, + message: i18nT('common:core.dataset.error.synonymFileInvalidFormat') + }, + { + statusText: DatasetErrEnum.synonymFileNoValidData, + message: i18nT('common:core.dataset.error.synonymFileNoValidData') + }, + { + statusText: DatasetErrEnum.synonymFileUnsupportedFormat, + message: i18nT('common:core.dataset.error.synonymFileUnsupportedFormat') + }, + { + statusText: DatasetErrEnum.synonymFileParseFailed, + message: i18nT('common:core.dataset.error.synonymFileParseFailed') + }, + { + statusText: DatasetErrEnum.synonymTermDuplicate, + message: i18nT('common:core.dataset.error.synonymTermDuplicate') + }, + { + statusText: DatasetErrEnum.datasetTrainingInProgress, + message: i18nT('common:core.dataset.error.datasetTrainingInProgress') + }, + { + statusText: DatasetErrEnum.insufficientQuota, + message: i18nT('common:core.dataset.error.insufficientQuota') + }, + { + statusText: DatasetErrEnum.collectionNameDuplicate, + message: i18nT('common:core.dataset.error.collectionNameDuplicate') + }, + { + statusText: DatasetErrEnum.collectionNameMissingExtension, + message: i18nT('common:core.dataset.error.collectionNameMissingExtension') + }, + { + statusText: DatasetErrEnum.collectionNameExtensionMismatch, + message: i18nT('common:core.dataset.error.collectionNameExtensionMismatch') + }, + { + statusText: DatasetErrEnum.templateImportModeNotFound, + message: i18nT('dataset:template_import_mode_not_found') + }, + { + statusText: DatasetErrEnum.fileContentDuplicate, + message: i18nT('common:core.dataset.error.fileContentDuplicate') + }, + { + statusText: DatasetErrEnum.imageDatasetRequiresVlmModel, + message: i18nT('file:Image_dataset_requires_VLM_model_to_be_configured') + }, + { + statusText: DatasetErrEnum.imageDatasetRequiresVlmModelOrCustomParse, + message: i18nT('file:custom_parse_failed_configure_vlm') + }, + { + statusText: DatasetErrEnum.cannotChangeOwner, + message: i18nT('common:core.dataset.error.cannotChangeOwner') } ]; export default datasetErr.reduce((acc, cur, index) => { diff --git a/packages/global/common/error/code/evaluation.ts b/packages/global/common/error/code/evaluation.ts new file mode 100644 index 000000000000..5caf7255edc1 --- /dev/null +++ b/packages/global/common/error/code/evaluation.ts @@ -0,0 +1,663 @@ +import { type ErrType } from '../errorCode'; +import { i18nT } from '../../i18n/utils'; + +/* evaluation: 510000 */ +export enum EvaluationErrEnum { + // Validation errors + evalNameRequired = 'evaluationNameRequired', + evalNameTooLong = 'evaluationNameTooLong', + evalNameDuplicate = 'evaluationNameDuplicate', + evalDescriptionTooLong = 'evaluationDescriptionTooLong', + evalDescriptionInvalidType = 'evaluationDescriptionInvalidType', + evalTargetRequired = 'evaluationTargetRequired', + evalTargetInvalidConfig = 'evaluationTargetInvalidConfig', + evalTargetAppIdMissing = 'evaluationTargetAppIdMissing', + evalTargetVersionIdMissing = 'evaluationTargetVersionIdMissing', + evalEvaluatorsRequired = 'evaluationEvaluatorsRequired', + evalEvaluatorInvalidConfig = 'evaluationEvaluatorInvalidConfig', + evalEvaluatorInvalidScoreScaling = 'evaluationEvaluatorInvalidScoreScaling', + evalInvalidFormat = 'evaluationInvalidFormat', + evalIdRequired = 'evaluationIdRequired', + evalItemIdRequired = 'evaluationItemIdRequired', + evalDataItemIdRequired = 'evaluationDataItemIdRequired', + + // Authentication errors + evalInsufficientPermission = 'evaluationInsufficientPermission', + evalAppNotFound = 'evaluationAppNotFound', + evalTaskNotFound = 'evaluationTaskNotFound', + evalItemNotFound = 'evaluationItemNotFound', + + // Task related errors + evalInvalidStatus = 'evaluationInvalidStatus', + evalInvalidStateTransition = 'evaluationInvalidStateTransition', + evalOnlyRunningCanStop = 'evaluationOnlyRunningCanStop', + evalItemNoErrorToRetry = 'evaluationItemNoErrorToRetry', + evalDatasetLoadFailed = 'evaluationDatasetLoadFailed', + evalTargetConfigInvalid = 'evaluationTargetConfigInvalid', + evalEvaluatorsConfigInvalid = 'evaluationEvaluatorsConfigInvalid', + evalUnsupportedTargetType = 'evaluationUnsupportedTargetType', + evalAppVersionNotFound = 'evaluationAppVersionNotFound', + evalDuplicateDatasetName = 'evaluationDuplicateDatasetName', + evalNoDataInCollections = 'evaluationNoDataInCollections', + evalUpdateFailed = 'evaluationUpdateFailed', + // Task execution errors + evalTaskSystemError = 'evaluationTaskSystemError', + evalManuallyStopped = 'evaluationManuallyStopped', + evalEvaluatorExecutionErrors = 'evaluationEvaluatorExecutionErrors', + evalTargetExecutionError = 'evaluationTargetExecutionError', + + // Metric related errors + evalMetricNotFound = 'evaluationMetricNotFound', + evalMetricUnAuth = 'evaluationMetricUnAuth', + evalMetricNameRequired = 'evaluationMetricNameRequired', + evalMetricNameTooLong = 'evaluationMetricNameTooLong', + evalMetricDescriptionRequired = 'evaluationMetricDescriptionRequired', + evalMetricDescriptionTooLong = 'evaluationMetricDescriptionTooLong', + evalMetricPromptRequired = 'evaluationMetricPromptRequired', + evalMetricPromptTooLong = 'evaluationMetricPromptTooLong', + evalMetricTypeRequired = 'evaluationMetricTypeRequired', + evalMetricTypeInvalid = 'evaluationMetricTypeInvalid', + evalMetricNameInvalid = 'evaluationMetricNameInvalid', + evalMetricBuiltinCannotModify = 'evaluationMetricBuiltinCannotModify', + evalMetricBuiltinCannotDelete = 'evaluationMetricBuiltinCannotDelete', + evalMetricIdRequired = 'evaluationMetricIdRequired', + + // Evaluation case related errors + evalCaseRequired = 'evaluationCaseRequired', + evalCaseUserInputRequired = 'evaluationCaseUserInputRequired', + evalCaseUserInputTooLong = 'evaluationCaseUserInputTooLong', + evalCaseActualOutputRequired = 'evaluationCaseActualOutputRequired', + evalCaseActualOutputTooLong = 'evaluationCaseActualOutputTooLong', + evalCaseExpectedOutputRequired = 'evaluationCaseExpectedOutputRequired', + evalCaseExpectedOutputTooLong = 'evaluationCaseExpectedOutputTooLong', + + // LLM config related errors + evalLLmConfigRequired = 'evaluationLLmConfigRequired', + evalLLmModelNameRequired = 'evaluationLLmModelNameRequired', + + // Debug related errors + debugEvaluationFailed = 'evaluationDebugFailed', + + // Evaluator related errors + evaluatorConfigRequired = 'evaluationEvaluatorConfigRequired', + evaluatorLLmConfigMissing = 'evaluationEvaluatorLLmConfigMissing', + evaluatorEmbeddingConfigMissing = 'evaluationEvaluatorEmbeddingConfigMissing', + evaluatorLLmModelNotFound = 'evaluationEvaluatorLLmModelNotFound', + evaluatorEmbeddingModelNotFound = 'evaluationEvaluatorEmbeddingModelNotFound', + evaluatorRequestTimeout = 'evaluationEvaluatorRequestTimeout', + evaluatorServiceUnavailable = 'evaluationEvaluatorServiceUnavailable', + evaluatorInvalidResponse = 'evaluationEvaluatorInvalidResponse', + evaluatorNetworkError = 'evaluationEvaluatorNetworkError', + + // Dataset collection validation errors + datasetCollectionNotFound = 'evaluationDatasetCollectionNotFound', + datasetCollectionIdRequired = 'evaluationDatasetCollectionIdRequired', + datasetCollectionUpdateFailed = 'evaluationDatasetCollectionUpdateFailed', + + // Dataset data validation errors + datasetDataNotFound = 'evaluationDatasetDataNotFound', + datasetModelNotFound = 'evaluationDatasetModelNotFound', + datasetNoData = 'evaluationDatasetNoData', + datasetDataIdRequired = 'evaluationDatasetDataIdRequired', + evalDataQualityStatusInvalid = 'evaluationDataQualityStatusInvalid', + evalDatasetDataListError = 'evaluationDatasetDataListError', + datasetDataUserInputRequired = 'evaluationDatasetDataUserInputRequired', + datasetDataExpectedOutputRequired = 'evaluationDatasetDataExpectedOutputRequired', + datasetDataActualOutputMustBeString = 'evaluationDatasetDataActualOutputMustBeString', + datasetDataContextMustBeArrayOfStrings = 'evaluationDatasetDataContextMustBeArrayOfStrings', + datasetDataRetrievalContextMustBeArrayOfStrings = 'evaluationDatasetDataRetrievalContextMustBeArrayOfStrings', + datasetDataEnableQualityEvalRequired = 'evaluationDatasetDataEnableQualityEvalRequired', + datasetDataEvaluationModelRequiredForQuality = 'evaluationDatasetDataEvaluationModelRequiredForQuality', + datasetDataMetadataMustBeObject = 'evaluationDatasetDataMetadataMustBeObject', + qualityAssessmentFailed = 'evaluationQualityAssessmentFailed', + evalDataQualityJobActiveCannotSetHighQuality = 'evaluationDataQualityJobActiveCannotSetHighQuality', + + // Task/Job related errors + datasetTaskNotRetryable = 'evaluationDatasetTaskNotRetryable', + datasetTaskJobNotFound = 'evaluationDatasetTaskJobNotFound', + datasetTaskJobMismatch = 'evaluationDatasetTaskJobMismatch', + datasetTaskOnlyFailedCanDelete = 'evaluationDatasetTaskOnlyFailedCanDelete', + datasetTaskOperationFailed = 'evaluationDatasetTaskOperationFailed', + datasetTaskDeleteFailed = 'evaluationDatasetTaskDeleteFailed', + fetchFailedTasksError = 'evaluationFetchFailedTasksError', + + // File/Import related errors + fileRequired = 'evaluationFileRequired', + fileMustBeCSV = 'evaluationFileMustBeCSV', + csvInvalidStructure = 'evaluationCSVInvalidStructure', + csvParsingError = 'evaluationCSVParsingError', + csvNoDataRows = 'evaluationCSVNoDataRows', + + // Count/Resource validation errors + countMustBeGreaterThanZero = 'evaluationCountMustBeGreaterThanZero', + countExceedsAvailableData = 'evaluationCountExceedsAvailableData', + selectedDatasetsContainNoData = 'evaluationSelectedDatasetsContainNoData', + + // Model validation errors + evalModelNameInvalid = 'evaluationModelNameInvalid', + evalModelNameTooLong = 'evaluationModelNameTooLong', + + // Summary related errors + summaryMetricsConfigError = 'evaluationSummaryMetricsConfigError', + summaryThresholdValueRequired = 'evaluationSummaryThresholdValueRequired', + summaryWeightRequired = 'evaluationSummaryWeightRequired', + summaryWeightMustBeNumber = 'evaluationSummaryWeightMustBeNumber', + summaryThresholdMustBeNumber = 'evaluationSummaryThresholdMustBeNumber', + summaryCalculateTypeRequired = 'evaluationSummaryCalculateTypeRequired', + summaryCalculateTypeInvalid = 'evaluationSummaryCalculateTypeInvalid', + summaryNoValidMetricsFound = 'evaluationSummaryNoValidMetricsFound', + summaryStreamResponseNotSupported = 'evaluationSummaryStreamResponseNotSupported', + summaryWeightSumMustBe100 = 'evaluationSummaryWeightSumMustBe100', + summaryModelInvalid = 'evaluationSummaryModelInvalid' +} + +const evaluationErrList = [ + // Dataset related errors + { + statusText: EvaluationErrEnum.datasetCollectionNotFound, + message: i18nT('evaluation:dataset_collection_not_found') + }, + { + statusText: EvaluationErrEnum.datasetDataNotFound, + message: i18nT('evaluation:dataset_data_not_found') + }, + + // Validation errors + { + statusText: EvaluationErrEnum.evalNameRequired, + message: i18nT('evaluation:name_required') + }, + { + statusText: EvaluationErrEnum.evalNameTooLong, + message: i18nT('evaluation:name_too_long') + }, + { + statusText: EvaluationErrEnum.evalNameDuplicate, + message: i18nT('evaluation:name_duplicate') + }, + { + statusText: EvaluationErrEnum.evalDescriptionTooLong, + message: i18nT('evaluation:description_too_long') + }, + { + statusText: EvaluationErrEnum.evalTargetRequired, + message: i18nT('evaluation:target_required') + }, + { + statusText: EvaluationErrEnum.evalTargetInvalidConfig, + message: i18nT('evaluation:target_invalid_config') + }, + { + statusText: EvaluationErrEnum.evalTargetAppIdMissing, + message: i18nT('evaluation:target_app_id_missing') + }, + { + statusText: EvaluationErrEnum.evalTargetVersionIdMissing, + message: i18nT('evaluation:target_version_id_missing') + }, + { + statusText: EvaluationErrEnum.evalEvaluatorsRequired, + message: i18nT('evaluation:evaluators_required') + }, + { + statusText: EvaluationErrEnum.evalEvaluatorInvalidConfig, + message: i18nT('evaluation:evaluator_invalid_config') + }, + { + statusText: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, + message: i18nT('evaluation:evaluator_invalid_score_scaling') + }, + { + statusText: EvaluationErrEnum.evalInvalidFormat, + message: i18nT('evaluation:invalid_format') + }, + { + statusText: EvaluationErrEnum.evalIdRequired, + message: i18nT('evaluation:id_required') + }, + { + statusText: EvaluationErrEnum.evalItemIdRequired, + message: i18nT('evaluation:item_id_required') + }, + { + statusText: EvaluationErrEnum.evalDataItemIdRequired, + message: i18nT('evaluation:data_item_id_required') + }, + + // Authentication errors + { + statusText: EvaluationErrEnum.evalInsufficientPermission, + message: i18nT('evaluation:insufficient_permission') + }, + { + statusText: EvaluationErrEnum.evalAppNotFound, + message: i18nT('evaluation:app_not_found') + }, + { + statusText: EvaluationErrEnum.evalTaskNotFound, + message: i18nT('evaluation:task_not_found') + }, + { + statusText: EvaluationErrEnum.evalItemNotFound, + message: i18nT('evaluation:item_not_found') + }, + + // Business logic errors + { + statusText: EvaluationErrEnum.evalInvalidStatus, + message: i18nT('evaluation:invalid_status') + }, + { + statusText: EvaluationErrEnum.evalInvalidStateTransition, + message: i18nT('evaluation:invalid_state_transition') + }, + { + statusText: EvaluationErrEnum.evalOnlyRunningCanStop, + message: i18nT('evaluation:only_running_can_stop') + }, + { + statusText: EvaluationErrEnum.evalItemNoErrorToRetry, + message: i18nT('evaluation:item_no_error_to_retry') + }, + { + statusText: EvaluationErrEnum.evalTargetExecutionError, + message: i18nT('evaluation:target_execution_error') + }, + { + statusText: EvaluationErrEnum.evalDatasetLoadFailed, + message: i18nT('evaluation:dataset_load_failed') + }, + { + statusText: EvaluationErrEnum.evalTargetConfigInvalid, + message: i18nT('evaluation:target_config_invalid') + }, + { + statusText: EvaluationErrEnum.evalEvaluatorsConfigInvalid, + message: i18nT('evaluation:evaluators_config_invalid') + }, + { + statusText: EvaluationErrEnum.evalUnsupportedTargetType, + message: i18nT('evaluation:unsupported_target_type') + }, + { + statusText: EvaluationErrEnum.evalAppVersionNotFound, + message: i18nT('evaluation:app_version_not_found') + }, + { + statusText: EvaluationErrEnum.evalDuplicateDatasetName, + message: i18nT('evaluation:duplicate_dataset_name') + }, + { + statusText: EvaluationErrEnum.evalNoDataInCollections, + message: i18nT('evaluation:no_data_in_collections') + }, + { + statusText: EvaluationErrEnum.evalUpdateFailed, + message: i18nT('evaluation:update_failed') + }, + // Metric related errors + { + statusText: EvaluationErrEnum.evalMetricNotFound, + message: i18nT('evaluation:metric_not_found') + }, + { + statusText: EvaluationErrEnum.evalMetricUnAuth, + message: i18nT('evaluation:metric_un_auth') + }, + { + statusText: EvaluationErrEnum.evalMetricNameRequired, + message: i18nT('evaluation:metric_name_required') + }, + { + statusText: EvaluationErrEnum.evalMetricNameTooLong, + message: i18nT('evaluation:metric_name_too_long') + }, + { + statusText: EvaluationErrEnum.evalMetricDescriptionRequired, + message: i18nT('evaluation:metric_description_required') + }, + { + statusText: EvaluationErrEnum.evalMetricDescriptionTooLong, + message: i18nT('evaluation:metric_description_too_long') + }, + { + statusText: EvaluationErrEnum.evalMetricPromptRequired, + message: i18nT('evaluation:metric_prompt_required') + }, + { + statusText: EvaluationErrEnum.evalMetricPromptTooLong, + message: i18nT('evaluation:metric_prompt_too_long') + }, + { + statusText: EvaluationErrEnum.evalMetricTypeRequired, + message: i18nT('evaluation:metric_type_required') + }, + { + statusText: EvaluationErrEnum.evalMetricTypeInvalid, + message: i18nT('evaluation:metric_type_invalid') + }, + { + statusText: EvaluationErrEnum.evalMetricNameInvalid, + message: i18nT('evaluation:metric_name_invalid') + }, + { + statusText: EvaluationErrEnum.evalMetricBuiltinCannotModify, + message: i18nT('evaluation:metric_builtin_cannot_modify') + }, + { + statusText: EvaluationErrEnum.evalMetricBuiltinCannotDelete, + message: i18nT('evaluation:metric_builtin_cannot_delete') + }, + { + statusText: EvaluationErrEnum.evalMetricIdRequired, + message: i18nT('evaluation:metric_id_required') + }, + + // Evaluation case related errors + { + statusText: EvaluationErrEnum.evalCaseRequired, + message: i18nT('evaluation:eval_case_required') + }, + { + statusText: EvaluationErrEnum.evalCaseUserInputRequired, + message: i18nT('evaluation:eval_case_user_input_required') + }, + { + statusText: EvaluationErrEnum.evalCaseUserInputTooLong, + message: i18nT('evaluation:eval_case_user_input_too_long') + }, + { + statusText: EvaluationErrEnum.evalCaseActualOutputRequired, + message: i18nT('evaluation:eval_case_actual_output_required') + }, + { + statusText: EvaluationErrEnum.evalCaseActualOutputTooLong, + message: i18nT('evaluation:eval_case_actual_output_too_long') + }, + { + statusText: EvaluationErrEnum.evalCaseExpectedOutputRequired, + message: i18nT('evaluation:eval_case_expected_output_required') + }, + { + statusText: EvaluationErrEnum.evalCaseExpectedOutputTooLong, + message: i18nT('evaluation:eval_case_expected_output_too_long') + }, + + // LLM config related errors + { + statusText: EvaluationErrEnum.evalLLmConfigRequired, + message: i18nT('evaluation:llm_config_required') + }, + { + statusText: EvaluationErrEnum.evalLLmModelNameRequired, + message: i18nT('evaluation:llm_model_name_required') + }, + + // Debug related errors + { + statusText: EvaluationErrEnum.debugEvaluationFailed, + message: i18nT('evaluation:debug_evaluation_failed') + }, + + // Evaluator related errors + { + statusText: EvaluationErrEnum.evaluatorConfigRequired, + message: i18nT('evaluation:evaluator_config_required') + }, + { + statusText: EvaluationErrEnum.evaluatorLLmConfigMissing, + message: i18nT('evaluation:evaluator_llm_config_missing') + }, + { + statusText: EvaluationErrEnum.evaluatorEmbeddingConfigMissing, + message: i18nT('evaluation:evaluator_embedding_config_missing') + }, + { + statusText: EvaluationErrEnum.evaluatorLLmModelNotFound, + message: i18nT('evaluation:evaluator_llm_model_not_found') + }, + { + statusText: EvaluationErrEnum.evaluatorEmbeddingModelNotFound, + message: i18nT('evaluation:evaluator_embedding_model_not_found') + }, + { + statusText: EvaluationErrEnum.evaluatorRequestTimeout, + message: i18nT('evaluation:evaluator_request_timeout') + }, + { + statusText: EvaluationErrEnum.evaluatorServiceUnavailable, + message: i18nT('evaluation:evaluator_service_unavailable') + }, + { + statusText: EvaluationErrEnum.evaluatorInvalidResponse, + message: i18nT('evaluation:evaluator_invalid_response') + }, + { + statusText: EvaluationErrEnum.evaluatorNetworkError, + message: i18nT('evaluation:evaluator_network_error') + }, + + // Dataset collection validation errors + { + statusText: EvaluationErrEnum.datasetCollectionIdRequired, + message: i18nT('evaluation:dataset_collection_id_required') + }, + { + statusText: EvaluationErrEnum.datasetCollectionUpdateFailed, + message: i18nT('evaluation:dataset_collection_update_failed') + }, + { + statusText: EvaluationErrEnum.datasetModelNotFound, + message: i18nT('evaluation:dataset_model_not_found') + }, + { + statusText: EvaluationErrEnum.datasetNoData, + message: i18nT('evaluation:dataset_no_data') + }, + + // Dataset data validation errors + { + statusText: EvaluationErrEnum.datasetDataIdRequired, + message: i18nT('evaluation:dataset_data_id_required') + }, + { + statusText: EvaluationErrEnum.evalDataQualityStatusInvalid, + message: i18nT('evaluation:data_quality_status_invalid') + }, + { + statusText: EvaluationErrEnum.datasetDataUserInputRequired, + message: i18nT('evaluation:dataset_data_user_input_required') + }, + { + statusText: EvaluationErrEnum.datasetDataExpectedOutputRequired, + message: i18nT('evaluation:dataset_data_expected_output_required') + }, + { + statusText: EvaluationErrEnum.datasetDataActualOutputMustBeString, + message: i18nT('evaluation:dataset_data_actual_output_must_be_string') + }, + { + statusText: EvaluationErrEnum.datasetDataContextMustBeArrayOfStrings, + message: i18nT('evaluation:dataset_data_context_must_be_array_of_strings') + }, + { + statusText: EvaluationErrEnum.datasetDataRetrievalContextMustBeArrayOfStrings, + message: i18nT('evaluation:dataset_data_retrieval_context_must_be_array_of_strings') + }, + { + statusText: EvaluationErrEnum.datasetDataEnableQualityEvalRequired, + message: i18nT('evaluation:dataset_data_enable_quality_eval_required') + }, + { + statusText: EvaluationErrEnum.datasetDataEvaluationModelRequiredForQuality, + message: i18nT('evaluation:dataset_data_evaluation_model_required_for_quality') + }, + { + statusText: EvaluationErrEnum.datasetDataMetadataMustBeObject, + message: i18nT('evaluation:dataset_data_metadata_must_be_object') + }, + { + statusText: EvaluationErrEnum.evalDatasetDataListError, + message: i18nT('evaluation:dataset_data_list_error') + }, + { + statusText: EvaluationErrEnum.qualityAssessmentFailed, + message: i18nT('evaluation:quality_assessment_failed') + }, + { + statusText: EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality, + message: i18nT('evaluation:data_quality_job_active_cannot_set_high_quality') + }, + + // Task/Job related errors + { + statusText: EvaluationErrEnum.datasetTaskNotRetryable, + message: i18nT('evaluation:dataset_task_not_retryable') + }, + { + statusText: EvaluationErrEnum.datasetTaskJobNotFound, + message: i18nT('evaluation:dataset_task_job_not_found') + }, + { + statusText: EvaluationErrEnum.datasetTaskJobMismatch, + message: i18nT('evaluation:dataset_task_job_mismatch') + }, + { + statusText: EvaluationErrEnum.datasetTaskOnlyFailedCanDelete, + message: i18nT('evaluation:dataset_task_only_failed_can_delete') + }, + { + statusText: EvaluationErrEnum.datasetTaskOperationFailed, + message: i18nT('evaluation:dataset_task_operation_failed') + }, + { + statusText: EvaluationErrEnum.datasetTaskDeleteFailed, + message: i18nT('evaluation:dataset_task_delete_failed') + }, + { + statusText: EvaluationErrEnum.fetchFailedTasksError, + message: i18nT('evaluation:fetch_failed_tasks_error') + }, + + // File/Import related errors + { + statusText: EvaluationErrEnum.fileRequired, + message: i18nT('evaluation:file_required') + }, + { + statusText: EvaluationErrEnum.fileMustBeCSV, + message: i18nT('evaluation:file_must_be_csv') + }, + { + statusText: EvaluationErrEnum.csvInvalidStructure, + message: i18nT('evaluation:csv_invalid_structure') + }, + { + statusText: EvaluationErrEnum.csvParsingError, + message: i18nT('evaluation:csv_parsing_error') + }, + { + statusText: EvaluationErrEnum.csvNoDataRows, + message: i18nT('evaluation:csv_no_data_rows') + }, + + // Count/Resource validation errors + { + statusText: EvaluationErrEnum.countMustBeGreaterThanZero, + message: i18nT('evaluation:count_must_be_greater_than_zero') + }, + { + statusText: EvaluationErrEnum.countExceedsAvailableData, + message: i18nT('evaluation:count_exceeds_available_data') + }, + { + statusText: EvaluationErrEnum.selectedDatasetsContainNoData, + message: i18nT('evaluation:selected_datasets_contain_no_data') + }, + + // Summary related errors + { + statusText: EvaluationErrEnum.summaryMetricsConfigError, + message: i18nT('evaluation:summary_metrics_config_error') + }, + { + statusText: EvaluationErrEnum.summaryThresholdValueRequired, + message: i18nT('evaluation:summary_threshold_value_required') + }, + { + statusText: EvaluationErrEnum.summaryWeightRequired, + message: i18nT('evaluation:summary_weight_required') + }, + { + statusText: EvaluationErrEnum.summaryWeightMustBeNumber, + message: i18nT('evaluation:summary_weight_must_be_number') + }, + { + statusText: EvaluationErrEnum.summaryThresholdMustBeNumber, + message: i18nT('evaluation:summary_threshold_must_be_number') + }, + { + statusText: EvaluationErrEnum.summaryCalculateTypeRequired, + message: i18nT('evaluation:summary_calculate_type_required') + }, + { + statusText: EvaluationErrEnum.summaryCalculateTypeInvalid, + message: i18nT('evaluation:summary_calculate_type_invalid') + }, + { + statusText: EvaluationErrEnum.summaryNoValidMetricsFound, + message: i18nT('evaluation:summary_no_valid_metrics_found') + }, + { + statusText: EvaluationErrEnum.summaryStreamResponseNotSupported, + message: i18nT('evaluation:summary_stream_response_not_supported') + }, + { + statusText: EvaluationErrEnum.summaryWeightSumMustBe100, + message: i18nT('evaluation:summary_weight_sum_must_be_100') + }, + { + statusText: EvaluationErrEnum.summaryModelInvalid, + message: i18nT('evaluation:summary_model_invalid') + }, + + // Model validation errors + { + statusText: EvaluationErrEnum.evalModelNameInvalid, + message: i18nT('evaluation:model_name_invalid') + }, + { + statusText: EvaluationErrEnum.evalModelNameTooLong, + message: i18nT('evaluation:model_name_too_long') + }, + { + statusText: EvaluationErrEnum.evalDescriptionInvalidType, + message: i18nT('evaluation:description_invalid_type') + }, + + // Task execution errors + { + statusText: EvaluationErrEnum.evalTaskSystemError, + message: i18nT('evaluation:task_system_error') + }, + { + statusText: EvaluationErrEnum.evalManuallyStopped, + message: i18nT('evaluation:manually_stopped') + }, + { + statusText: EvaluationErrEnum.evalEvaluatorExecutionErrors, + message: i18nT('evaluation:evaluator_execution_errors') + } +]; + +export default evaluationErrList.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 510000 + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; +}, {} as ErrType<`${EvaluationErrEnum}`>); diff --git a/packages/global/common/error/code/model.ts b/packages/global/common/error/code/model.ts new file mode 100644 index 000000000000..b9e0776aa12a --- /dev/null +++ b/packages/global/common/error/code/model.ts @@ -0,0 +1,105 @@ +import { i18nT } from '../../i18n/utils'; +import { type ErrType } from '../errorCode'; + +/* model: 511000 */ +const startCode = 511000; +export enum ModelErrEnum { + unExist = 'unExistModel', + systemModelCannotDelete = 'systemModelCannotDelete', + customModelMissingFields = 'customModelMissingFields', + customModelMissingName = 'customModelMissingName', + customModelMissingType = 'customModelMissingType', + systemModelNotSupportUpdate = 'systemModelNotSupportUpdate', + invalidModelId = 'invalidModelId', + invalidTmbId = 'invalidTmbId', + invalidTeamId = 'invalidTeamId', + invalidModelOrMetadata = 'invalidModelOrMetadata', + metadataProviderRequired = 'metadataProviderRequired', + metadataModelRequired = 'metadataModelRequired', + modelTypeNotSupported = 'modelTypeNotSupported', + modelNotActive = 'modelNotActive', + modelResponseEmpty = 'modelResponseEmpty', + pageNumPositiveInteger = 'pageNumPositiveInteger', + pageSizePositiveInteger = 'pageSizePositiveInteger' +} +const modelErr = [ + { + statusText: ModelErrEnum.unExist, + message: i18nT('common:error.model_not_exist') + }, + { + statusText: ModelErrEnum.systemModelCannotDelete, + message: i18nT('common:error.system_model_cannot_delete') + }, + { + statusText: ModelErrEnum.customModelMissingFields, + message: i18nT('common:error.custom_model_missing_fields') + }, + { + statusText: ModelErrEnum.customModelMissingName, + message: i18nT('common:error.custom_model_missing_name') + }, + { + statusText: ModelErrEnum.customModelMissingType, + message: i18nT('common:error.custom_model_missing_type') + }, + { + statusText: ModelErrEnum.systemModelNotSupportUpdate, + message: i18nT('common:error.system_model_not_support_update') + }, + { + statusText: ModelErrEnum.invalidModelId, + message: i18nT('common:error.invalid_model_id') + }, + { + statusText: ModelErrEnum.invalidTmbId, + message: i18nT('common:error.invalid_tmb_id') + }, + { + statusText: ModelErrEnum.invalidTeamId, + message: i18nT('common:error.invalid_team_id') + }, + { + statusText: ModelErrEnum.invalidModelOrMetadata, + message: i18nT('common:error.invalid_model_or_metadata') + }, + { + statusText: ModelErrEnum.metadataProviderRequired, + message: i18nT('common:error.metadata_provider_required') + }, + { + statusText: ModelErrEnum.metadataModelRequired, + message: i18nT('common:error.metadata_model_required') + }, + { + statusText: ModelErrEnum.modelTypeNotSupported, + message: i18nT('common:error.model_type_not_supported') + }, + { + statusText: ModelErrEnum.modelNotActive, + message: i18nT('common:error.model_not_active') + }, + { + statusText: ModelErrEnum.modelResponseEmpty, + message: i18nT('common:error.model_response_empty') + }, + { + statusText: ModelErrEnum.pageNumPositiveInteger, + message: i18nT('common:error.page_num_must_be_positive_integer') + }, + { + statusText: ModelErrEnum.pageSizePositiveInteger, + message: i18nT('common:error.page_size_must_be_positive_integer') + } +]; +export default modelErr.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: startCode + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; +}, {} as ErrType<`${ModelErrEnum}`>); diff --git a/packages/global/common/error/code/openapi.ts b/packages/global/common/error/code/openapi.ts index d79187a655b8..ff0cf67aef58 100644 --- a/packages/global/common/error/code/openapi.ts +++ b/packages/global/common/error/code/openapi.ts @@ -1,10 +1,11 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 506000 */ export enum OpenApiErrEnum { unExist = 'openapiUnExist', unAuth = 'openapiUnAuth', - exceedLimit = 'openapiExceedLimit' + exceedLimit = 'openapiExceedLimit', + createSkillKeyFailed = 'openapiCreateSkillKeyFailed' } const errList = [ @@ -19,6 +20,10 @@ const errList = [ { statusText: OpenApiErrEnum.exceedLimit, message: i18nT('common:code_error.openapi_error.exceed_limit') + }, + { + statusText: OpenApiErrEnum.createSkillKeyFailed, + message: i18nT('common:code_error.openapi_error.create_skill_key_failed') } ]; diff --git a/packages/global/common/error/code/outLink.ts b/packages/global/common/error/code/outLink.ts index d37c00024e43..d0724ec4066f 100644 --- a/packages/global/common/error/code/outLink.ts +++ b/packages/global/common/error/code/outLink.ts @@ -1,5 +1,5 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 505000 */ export enum OutLinkErrEnum { unExist = 'outlinkUnExist', diff --git a/packages/global/common/error/code/plugin.ts b/packages/global/common/error/code/plugin.ts index f508bedaf823..0b16497d9c43 100644 --- a/packages/global/common/error/code/plugin.ts +++ b/packages/global/common/error/code/plugin.ts @@ -1,5 +1,5 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 508000 */ export enum PluginErrEnum { unExist = 'pluginUnExist', diff --git a/packages/global/common/error/code/skill.ts b/packages/global/common/error/code/skill.ts new file mode 100644 index 000000000000..5953ec5a2d72 --- /dev/null +++ b/packages/global/common/error/code/skill.ts @@ -0,0 +1,56 @@ +import { i18nT } from '../../i18n/utils'; +import { type ErrType } from '../errorCode'; + +/* skill: 511000 */ +export enum SkillErrEnum { + exportMissingAppId = 'skillExportMissingAppId', + exportMissingSkillParams = 'skillExportMissingSkillParams', + exportInvalidSkillName = 'skillExportInvalidSkillName', + exportSkillNameTooLong = 'skillExportSkillNameTooLong', + exportSkillDescriptionTooLong = 'skillExportSkillDescriptionTooLong', + exportFailed = 'skillExportFailed', + archiveError = 'skillArchiveError' +} + +const skillErr = [ + { + statusText: SkillErrEnum.exportMissingAppId, + message: i18nT('skill:missing_app_id') + }, + { + statusText: SkillErrEnum.exportMissingSkillParams, + message: i18nT('skill:missing_skill_params') + }, + { + statusText: SkillErrEnum.exportInvalidSkillName, + message: i18nT('skill:invalid_skill_name') + }, + { + statusText: SkillErrEnum.exportSkillNameTooLong, + message: i18nT('skill:skill_name_too_long') + }, + { + statusText: SkillErrEnum.exportSkillDescriptionTooLong, + message: i18nT('skill:skill_description_too_long') + }, + { + statusText: SkillErrEnum.exportFailed, + message: i18nT('skill:export_failed') + }, + { + statusText: SkillErrEnum.archiveError, + message: i18nT('skill:archive_error') + } +]; + +export default skillErr.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 511000 + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; +}, {} as ErrType<`${SkillErrEnum}`>); diff --git a/packages/global/common/error/code/system.ts b/packages/global/common/error/code/system.ts index efe177c60f4e..5893886a9e87 100644 --- a/packages/global/common/error/code/system.ts +++ b/packages/global/common/error/code/system.ts @@ -1,11 +1,15 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* dataset: 509000 */ export enum SystemErrEnum { communityVersionNumLimit = 'communityVersionNumLimit', licenseAppAmountLimit = 'licenseAppAmountLimit', licenseDatasetAmountLimit = 'licenseDatasetAmountLimit', - licenseUserAmountLimit = 'licenseUserAmountLimit' + licenseUserAmountLimit = 'licenseUserAmountLimit', + licenseEvaluationTaskAmountLimit = 'licenseEvaluationTaskAmountLimit', + licenseEvalDatasetAmountLimit = 'licenseEvalDatasetAmountLimit', + licenseEvalDatasetDataAmountLimit = 'licenseEvalDatasetDataAmountLimit', + licenseEvalMetricAmountLimit = 'licenseEvalMetricAmountLimit' } const systemErr = [ @@ -24,6 +28,22 @@ const systemErr = [ { statusText: SystemErrEnum.licenseUserAmountLimit, message: i18nT('common:code_error.system_error.license_user_amount_limit') + }, + { + statusText: SystemErrEnum.licenseEvaluationTaskAmountLimit, + message: i18nT('common:code_error.system_error.license_evaluation_task_amount_limit') + }, + { + statusText: SystemErrEnum.licenseEvalDatasetAmountLimit, + message: i18nT('common:code_error.system_error.license_eval_dataset_amount_limit') + }, + { + statusText: SystemErrEnum.licenseEvalDatasetDataAmountLimit, + message: i18nT('common:code_error.system_error.license_eval_dataset_data_amount_limit') + }, + { + statusText: SystemErrEnum.licenseEvalMetricAmountLimit, + message: i18nT('common:code_error.system_error.license_eval_metric_amount_limit') } ]; diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index d508cf2db585..8b9deb1d512e 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; import type { ErrType } from '../errorCode'; /* team: 500000 */ export enum TeamErrEnum { @@ -17,6 +17,10 @@ export enum TeamErrEnum { websiteSyncNotEnough = 'websiteSyncNotEnough', reRankNotEnough = 'reRankNotEnough', ticketNotAvailable = 'ticketNotAvailable', + evaluationTaskAmountNotEnough = 'evaluationTaskAmountNotEnough', + evaluationDatasetAmountNotEnough = 'evaluationDatasetAmountNotEnough', + evaluationDatasetDataAmountNotEnough = 'evaluationDatasetDataAmountNotEnough', + evaluationMetricAmountNotEnough = 'evaluationMetricAmountNotEnough', sandboxNotSupport = 'sandboxNotSupport', groupNameEmpty = 'groupNameEmpty', @@ -83,6 +87,22 @@ const teamErr = [ statusText: TeamErrEnum.reRankNotEnough, message: i18nT('common:code_error.team_error.re_rank_not_enough') }, + { + statusText: TeamErrEnum.evaluationTaskAmountNotEnough, + message: i18nT('common:code_error.team_error.evaluation_task_amount_not_enough') + }, + { + statusText: TeamErrEnum.evaluationDatasetAmountNotEnough, + message: i18nT('common:code_error.team_error.evaluation_dataset_amount_not_enough') + }, + { + statusText: TeamErrEnum.evaluationDatasetDataAmountNotEnough, + message: i18nT('common:code_error.team_error.evaluation_dataset_data_amount_not_enough') + }, + { + statusText: TeamErrEnum.evaluationMetricAmountNotEnough, + message: i18nT('common:code_error.team_error.evaluation_metric_amount_not_enough') + }, { statusText: TeamErrEnum.ticketNotAvailable, message: i18nT('common:code_error.team_error.ticket_not_available') diff --git a/packages/global/common/error/code/train.ts b/packages/global/common/error/code/train.ts new file mode 100644 index 000000000000..50b7638b42e0 --- /dev/null +++ b/packages/global/common/error/code/train.ts @@ -0,0 +1,535 @@ +import { i18nT } from '../../i18n/utils'; +import { type ErrType } from '../errorCode'; + +/* train: 502000 */ +export enum RerankTrainErrEnum { + // Trainset errors + rerankTrainsetNotExist = 'rerankTrainsetNotExist', + rerankTrainsetGenerating = 'rerankTrainsetGenerating', + rerankTrainsetInUse = 'rerankTrainsetInUse', + rerankTrainsetGenerationFailed = 'rerankTrainsetGenerationFailed', + + // Training data errors + rerankTrainDataNotExist = 'rerankTrainDataNotExist', + rerankNoTrainDataAvailable = 'rerankNoTrainDataAvailable', + + // Training task errors + rerankTaskNotExist = 'rerankTaskNotExist', + rerankTaskAlreadyRunning = 'rerankTaskAlreadyRunning', + rerankTaskCannotRetry = 'rerankTaskCannotRetry', + rerankTaskCannotCancel = 'rerankTaskCannotCancel', + rerankTaskCannotDelete = 'rerankTaskCannotDelete', + rerankTaskModelNotFound = 'rerankTaskModelNotFound', + rerankTaskBaseModelDisabled = 'rerankTaskBaseModelDisabled', + rerankTaskNotCompleted = 'rerankTaskNotCompleted', + rerankTunedModelNotFound = 'rerankTunedModelNotFound', + rerankTaskInsufficientChunks = 'rerankTaskInsufficientChunks', + + // Eval dataset errors + rerankEvalDatasetNotGenerated = 'rerankEvalDatasetNotGenerated', + rerankEvalDatasetEmpty = 'rerankEvalDatasetEmpty', + rerankEvalResultsNotFound = 'rerankEvalResultsNotFound', + + // External service errors + rerankDitingServiceError = 'rerankDitingServiceError', + rerankSftBridgeServiceError = 'rerankSftBridgeServiceError', + + // Environment validation errors + rerankValidationSftBridgeUnaccessible = 'rerankValidationSftBridgeUnaccessible', + rerankValidationDitingUnaccessible = 'rerankValidationDitingUnaccessible', + rerankValidationDatasetNoSynthesisIndex = 'rerankValidationDatasetNoSynthesisIndex', + rerankValidationNoDatasetConfigured = 'rerankValidationNoDatasetConfigured', + rerankValidationBaseModelNotConfigured = 'rerankValidationBaseModelNotConfigured', + + // === Preparing Stage Errors === + rerankPrepareTrainsetDeleted = 'rerankPrepareTrainsetDeleted', + rerankPrepareDataEmpty = 'rerankPrepareDataEmpty', + rerankPrepareTimeout = 'rerankPrepareTimeout', + rerankPrepareFileSystemError = 'rerankPrepareFileSystemError', + rerankPrepareDataEmptyAfterWrite = 'rerankPrepareDataEmptyAfterWrite', + rerankPrepareMissingGenerateConfig = 'rerankPrepareMissingGenerateConfig', + + // === Finetuning Stage Errors === + rerankFinetuneDataPathNotFound = 'rerankFinetuneDataPathNotFound', + rerankFinetuneModelConfigInvalid = 'rerankFinetuneModelConfigInvalid', + rerankFinetuneDataFileNotFound = 'rerankFinetuneDataFileNotFound', + rerankFinetuneSftBridgeCreateFailed = 'rerankFinetuneSftBridgeCreateFailed', + rerankFinetuneQueueFull = 'rerankFinetuneQueueFull', + rerankFinetuneCancelled = 'rerankFinetuneCancelled', + rerankFinetuneDeploymentFailed = 'rerankFinetuneDeploymentFailed', + rerankFinetuneTrainingFailed = 'rerankFinetuneTrainingFailed', + rerankFinetuneTimeout = 'rerankFinetuneTimeout', + rerankFinetuneDeploymentNoEndpoint = 'rerankFinetuneDeploymentNoEndpoint', + + // === Registering Stage Errors === + rerankRegisterEndpointNotFound = 'rerankRegisterEndpointNotFound', + rerankRegisterBaseModelNotFound = 'rerankRegisterBaseModelNotFound', + rerankRegisterAiProxyFailed = 'rerankRegisterAiProxyFailed', + rerankRegisterChannelNotAvailable = 'rerankRegisterChannelNotAvailable', + + // === Evaluating Stage Errors === + rerankEvalNoDatasetConfigured = 'rerankEvalNoDatasetConfigured', + rerankEvalNoDataAvailable = 'rerankEvalNoDataAvailable', + rerankEvalDitingGenerationFailed = 'rerankEvalDitingGenerationFailed', + rerankEvalDatabaseSaveFailed = 'rerankEvalDatabaseSaveFailed', + rerankEvalDatasetEmptyBeforeEval = 'rerankEvalDatasetEmptyBeforeEval', + rerankEvalDatasetSearchAllFailed = 'rerankEvalDatasetSearchAllFailed', + rerankEvalModelNotFound = 'rerankEvalModelNotFound', + rerankEvalDitingEvalFailed = 'rerankEvalDitingEvalFailed', + + // === Processor Internal Errors === + rerankProcessorTaskNotFound = 'rerankProcessorTaskNotFound', + rerankProcessorTaskLostAfterPrepare = 'rerankProcessorTaskLostAfterPrepare', + rerankProcessorTaskLostAfterFinetune = 'rerankProcessorTaskLostAfterFinetune', + rerankProcessorTaskLostAfterRegister = 'rerankProcessorTaskLostAfterRegister', + rerankProcessorModelConfigNotInCheckpoint = 'rerankProcessorModelConfigNotInCheckpoint', + rerankProcessorTaskLostAfterEvalGen = 'rerankProcessorTaskLostAfterEvalGen', + rerankProcessorTaskLostAfterEval = 'rerankProcessorTaskLostAfterEval', + + // === Trainset Generation Errors === + rerankTrainsetGenNoDataset = 'rerankTrainsetGenNoDataset', + rerankTrainsetGenDatasetEmpty = 'rerankTrainsetGenDatasetEmpty', + rerankTrainsetGenInsufficientChunks = 'rerankTrainsetGenInsufficientChunks', + rerankTrainsetGenDitingFailed = 'rerankTrainsetGenDitingFailed', + rerankTrainsetGenDitingNoData = 'rerankTrainsetGenDitingNoData', + rerankTrainsetGenDatabaseError = 'rerankTrainsetGenDatabaseError', + rerankTrainsetGenAlreadyGenerating = 'rerankTrainsetGenAlreadyGenerating', + rerankTrainsetGenNotFound = 'rerankTrainsetGenNotFound', + + // === LLM Judge Stage Errors === + rerankLLMJudgeNoEvalData = 'rerankLLMJudgeNoEvalData', + rerankLLMJudgeNoRankingResults = 'rerankLLMJudgeNoRankingResults', + rerankLLMJudgeDiTingFailed = 'rerankLLMJudgeDiTingFailed', + rerankLLMJudgeEmptyResult = 'rerankLLMJudgeEmptyResult', + + // === General Errors === + rerankUnknownError = 'rerankUnknownError' +} + +/* embedding: 502100 */ +export enum EmbeddingTrainErrEnum { + // Trainset errors + embeddingTrainsetNotExist = 'embeddingTrainsetNotExist', + embeddingTrainsetGenerating = 'embeddingTrainsetGenerating', + embeddingTrainsetInUse = 'embeddingTrainsetInUse', + embeddingTrainsetGenerationFailed = 'embeddingTrainsetGenerationFailed', + + // Training data errors + embeddingTrainDataNotExist = 'embeddingTrainDataNotExist', + embeddingNoTrainDataAvailable = 'embeddingNoTrainDataAvailable', + + // Training task errors + embeddingTaskNotExist = 'embeddingTaskNotExist', + embeddingTaskAlreadyRunning = 'embeddingTaskAlreadyRunning', + embeddingTaskCannotRetry = 'embeddingTaskCannotRetry', + embeddingTaskCannotCancel = 'embeddingTaskCannotCancel', + embeddingTaskCannotDelete = 'embeddingTaskCannotDelete', + embeddingTaskModelNotFound = 'embeddingTaskModelNotFound', + embeddingTaskBaseModelDisabled = 'embeddingTaskBaseModelDisabled', + embeddingTaskNotCompleted = 'embeddingTaskNotCompleted', + embeddingTunedModelNotFound = 'embeddingTunedModelNotFound', + embeddingTaskInsufficientChunks = 'embeddingTaskInsufficientChunks', + + // Eval dataset errors + embeddingEvalDatasetNotGenerated = 'embeddingEvalDatasetNotGenerated', + embeddingEvalDatasetEmpty = 'embeddingEvalDatasetEmpty', + embeddingEvalResultsNotFound = 'embeddingEvalResultsNotFound', + + // External service errors + embeddingDitingServiceError = 'embeddingDitingServiceError', + embeddingSftBridgeServiceError = 'embeddingSftBridgeServiceError', + + // Environment validation errors + embeddingValidationSftBridgeUnaccessible = 'embeddingValidationSftBridgeUnaccessible', + embeddingValidationDitingUnaccessible = 'embeddingValidationDitingUnaccessible', + embeddingValidationDatasetNoSynthesisIndex = 'embeddingValidationDatasetNoSynthesisIndex', + embeddingValidationNoDatasetConfigured = 'embeddingValidationNoDatasetConfigured', + embeddingValidationBaseModelNotConfigured = 'embeddingValidationBaseModelNotConfigured', + + // === Preparing Stage Errors === + embeddingPrepareTrainsetDeleted = 'embeddingPrepareTrainsetDeleted', + embeddingPrepareDataEmpty = 'embeddingPrepareDataEmpty', + embeddingPrepareTimeout = 'embeddingPrepareTimeout', + embeddingPrepareFileSystemError = 'embeddingPrepareFileSystemError', + embeddingPrepareDataEmptyAfterWrite = 'embeddingPrepareDataEmptyAfterWrite', + embeddingPrepareMissingGenerateConfig = 'embeddingPrepareMissingGenerateConfig', + + // === Finetuning Stage Errors === + embeddingFinetuneDataPathNotFound = 'embeddingFinetuneDataPathNotFound', + embeddingFinetuneModelConfigInvalid = 'embeddingFinetuneModelConfigInvalid', + embeddingFinetuneDataFileNotFound = 'embeddingFinetuneDataFileNotFound', + embeddingFinetuneSftBridgeCreateFailed = 'embeddingFinetuneSftBridgeCreateFailed', + embeddingFinetuneQueueFull = 'embeddingFinetuneQueueFull', + embeddingFinetuneCancelled = 'embeddingFinetuneCancelled', + embeddingFinetuneDeploymentFailed = 'embeddingFinetuneDeploymentFailed', + embeddingFinetuneTrainingFailed = 'embeddingFinetuneTrainingFailed', + embeddingFinetuneTimeout = 'embeddingFinetuneTimeout', + embeddingFinetuneDeploymentNoEndpoint = 'embeddingFinetuneDeploymentNoEndpoint', + + // === Registering Stage Errors === + embeddingRegisterEndpointNotFound = 'embeddingRegisterEndpointNotFound', + embeddingRegisterBaseModelNotFound = 'embeddingRegisterBaseModelNotFound', + embeddingRegisterAiProxyFailed = 'embeddingRegisterAiProxyFailed', + embeddingRegisterChannelNotAvailable = 'embeddingRegisterChannelNotAvailable', + + // === Evaluating Stage Errors === + embeddingEvalNoDatasetConfigured = 'embeddingEvalNoDatasetConfigured', + embeddingEvalNoDataAvailable = 'embeddingEvalNoDataAvailable', + embeddingEvalDitingGenerationFailed = 'embeddingEvalDitingGenerationFailed', + embeddingEvalDatabaseSaveFailed = 'embeddingEvalDatabaseSaveFailed', + embeddingEvalDatasetEmptyBeforeEval = 'embeddingEvalDatasetEmptyBeforeEval', + embeddingEvalDatasetSearchAllFailed = 'embeddingEvalDatasetSearchAllFailed', + embeddingEvalModelNotFound = 'embeddingEvalModelNotFound', + embeddingEvalDitingEvalFailed = 'embeddingEvalDitingEvalFailed', + + // === Processor Internal Errors === + embeddingProcessorTaskNotFound = 'embeddingProcessorTaskNotFound', + embeddingProcessorTaskLostAfterPrepare = 'embeddingProcessorTaskLostAfterPrepare', + embeddingProcessorTaskLostAfterFinetune = 'embeddingProcessorTaskLostAfterFinetune', + embeddingProcessorTaskLostAfterRegister = 'embeddingProcessorTaskLostAfterRegister', + embeddingProcessorModelConfigNotInCheckpoint = 'embeddingProcessorModelConfigNotInCheckpoint', + embeddingProcessorTaskLostAfterEvalGen = 'embeddingProcessorTaskLostAfterEvalGen', + embeddingProcessorTaskLostAfterEval = 'embeddingProcessorTaskLostAfterEval', + + // === Trainset Generation Errors === + embeddingTrainsetGenNoDataset = 'embeddingTrainsetGenNoDataset', + embeddingTrainsetGenDatasetEmpty = 'embeddingTrainsetGenDatasetEmpty', + embeddingTrainsetGenInsufficientChunks = 'embeddingTrainsetGenInsufficientChunks', + embeddingTrainsetGenDitingFailed = 'embeddingTrainsetGenDitingFailed', + embeddingTrainsetGenDitingNoData = 'embeddingTrainsetGenDitingNoData', + embeddingTrainsetGenDatabaseError = 'embeddingTrainsetGenDatabaseError', + embeddingTrainsetGenAlreadyGenerating = 'embeddingTrainsetGenAlreadyGenerating', + embeddingTrainsetGenNotFound = 'embeddingTrainsetGenNotFound', + + // === LLM Judge Stage Errors === + embeddingLLMJudgeNoEvalData = 'embeddingLLMJudgeNoEvalData', + embeddingLLMJudgeNoRankingResults = 'embeddingLLMJudgeNoRankingResults', + embeddingLLMJudgeDiTingFailed = 'embeddingLLMJudgeDiTingFailed', + embeddingLLMJudgeEmptyResult = 'embeddingLLMJudgeEmptyResult', + + // === General Errors === + embeddingUnknownError = 'embeddingUnknownError' +} + +/** + * Suggestion enum for rerank training errors + * Uses same naming pattern as RerankTrainErrEnum for consistency + */ +export enum RerankTrainSuggestionEnum { + // Trainset errors + rerankTrainsetNotExist = 'rerankTrainsetNotExist', + rerankTrainsetGenerating = 'rerankTrainsetGenerating', + rerankTrainsetInUse = 'rerankTrainsetInUse', + rerankTrainsetGenerationFailed = 'rerankTrainsetGenerationFailed', + + // Training data errors + rerankTrainDataNotExist = 'rerankTrainDataNotExist', + rerankNoTrainDataAvailable = 'rerankNoTrainDataAvailable', + + // Training task errors + rerankTaskNotExist = 'rerankTaskNotExist', + rerankTaskAlreadyRunning = 'rerankTaskAlreadyRunning', + rerankTaskCannotRetry = 'rerankTaskCannotRetry', + rerankTaskCannotCancel = 'rerankTaskCannotCancel', + rerankTaskCannotDelete = 'rerankTaskCannotDelete', + rerankTaskModelNotFound = 'rerankTaskModelNotFound', + rerankTaskBaseModelDisabled = 'rerankTaskBaseModelDisabled', + rerankTaskNotCompleted = 'rerankTaskNotCompleted', + rerankTunedModelNotFound = 'rerankTunedModelNotFound', + + // Eval dataset errors + rerankEvalDatasetNotGenerated = 'rerankEvalDatasetNotGenerated', + rerankEvalDatasetEmpty = 'rerankEvalDatasetEmpty', + rerankEvalResultsNotFound = 'rerankEvalResultsNotFound', + + // External service errors + rerankDitingServiceError = 'rerankDitingServiceError', + rerankSftBridgeServiceError = 'rerankSftBridgeServiceError', + + // Environment validation errors + rerankValidationSftBridgeUnaccessible = 'rerankValidationSftBridgeUnaccessible', + rerankValidationDitingUnaccessible = 'rerankValidationDitingUnaccessible', + rerankValidationDatasetNoSynthesisIndex = 'rerankValidationDatasetNoSynthesisIndex', + rerankValidationNoDatasetConfigured = 'rerankValidationNoDatasetConfigured', + rerankValidationBaseModelNotConfigured = 'rerankValidationBaseModelNotConfigured', + + // === Preparing Stage Errors === + rerankPrepareTrainsetDeleted = 'rerankPrepareTrainsetDeleted', + rerankPrepareDataEmpty = 'rerankPrepareDataEmpty', + rerankPrepareTimeout = 'rerankPrepareTimeout', + rerankPrepareFileSystemError = 'rerankPrepareFileSystemError', + rerankPrepareDataEmptyAfterWrite = 'rerankPrepareDataEmptyAfterWrite', + rerankPrepareMissingGenerateConfig = 'rerankPrepareMissingGenerateConfig', + + // === Finetuning Stage Errors === + rerankFinetuneDataPathNotFound = 'rerankFinetuneDataPathNotFound', + rerankFinetuneModelConfigInvalid = 'rerankFinetuneModelConfigInvalid', + rerankFinetuneDataFileNotFound = 'rerankFinetuneDataFileNotFound', + rerankFinetuneSftBridgeCreateFailed = 'rerankFinetuneSftBridgeCreateFailed', + rerankFinetuneQueueFull = 'rerankFinetuneQueueFull', + rerankFinetuneCancelled = 'rerankFinetuneCancelled', + rerankFinetuneDeploymentFailed = 'rerankFinetuneDeploymentFailed', + rerankFinetuneTrainingFailed = 'rerankFinetuneTrainingFailed', + rerankFinetuneTimeout = 'rerankFinetuneTimeout', + rerankFinetuneDeploymentNoEndpoint = 'rerankFinetuneDeploymentNoEndpoint', + + // === Registering Stage Errors === + rerankRegisterEndpointNotFound = 'rerankRegisterEndpointNotFound', + rerankRegisterBaseModelNotFound = 'rerankRegisterBaseModelNotFound', + rerankRegisterAiProxyFailed = 'rerankRegisterAiProxyFailed', + rerankRegisterChannelNotAvailable = 'rerankRegisterChannelNotAvailable', + + // === Evaluating Stage Errors === + rerankEvalNoDatasetConfigured = 'rerankEvalNoDatasetConfigured', + rerankEvalNoDataAvailable = 'rerankEvalNoDataAvailable', + rerankEvalDitingGenerationFailed = 'rerankEvalDitingGenerationFailed', + rerankEvalDatabaseSaveFailed = 'rerankEvalDatabaseSaveFailed', + rerankEvalDatasetEmptyBeforeEval = 'rerankEvalDatasetEmptyBeforeEval', + rerankEvalDatasetSearchAllFailed = 'rerankEvalDatasetSearchAllFailed', + rerankEvalModelNotFound = 'rerankEvalModelNotFound', + rerankEvalDitingEvalFailed = 'rerankEvalDitingEvalFailed', + + // === Processor Internal Errors === + rerankProcessorTaskNotFound = 'rerankProcessorTaskNotFound', + rerankProcessorTaskLostAfterPrepare = 'rerankProcessorTaskLostAfterPrepare', + rerankProcessorTaskLostAfterFinetune = 'rerankProcessorTaskLostAfterFinetune', + rerankProcessorTaskLostAfterRegister = 'rerankProcessorTaskLostAfterRegister', + rerankProcessorModelConfigNotInCheckpoint = 'rerankProcessorModelConfigNotInCheckpoint', + rerankProcessorTaskLostAfterEvalGen = 'rerankProcessorTaskLostAfterEvalGen', + rerankProcessorTaskLostAfterEval = 'rerankProcessorTaskLostAfterEval', + + // === Trainset Generation Suggestions === + rerankTrainsetGenNoDataset = 'rerankTrainsetGenNoDataset', + rerankTrainsetGenDatasetEmpty = 'rerankTrainsetGenDatasetEmpty', + rerankTrainsetGenInsufficientChunks = 'rerankTrainsetGenInsufficientChunks', + rerankTrainsetGenDitingFailed = 'rerankTrainsetGenDitingFailed', + rerankTrainsetGenDitingNoData = 'rerankTrainsetGenDitingNoData', + rerankTrainsetGenDatabaseError = 'rerankTrainsetGenDatabaseError', + rerankTrainsetGenAlreadyGenerating = 'rerankTrainsetGenAlreadyGenerating', + rerankTrainsetGenNotFound = 'rerankTrainsetGenNotFound', + + // === LLM Judge Stage Errors === + rerankLLMJudgeNoEvalData = 'rerankLLMJudgeNoEvalData', + rerankLLMJudgeNoRankingResults = 'rerankLLMJudgeNoRankingResults', + rerankLLMJudgeDiTingFailed = 'rerankLLMJudgeDiTingFailed', + rerankLLMJudgeEmptyResult = 'rerankLLMJudgeEmptyResult', + + // === General Errors === + rerankUnknownError = 'rerankUnknownError' +} + +/** + * Suggestion enum for embedding training errors + * Uses same naming pattern as EmbeddingTrainErrEnum for consistency + */ +export enum EmbeddingTrainSuggestionEnum { + // Trainset errors + embeddingTrainsetNotExist = 'embeddingTrainsetNotExist', + embeddingTrainsetGenerating = 'embeddingTrainsetGenerating', + embeddingTrainsetInUse = 'embeddingTrainsetInUse', + embeddingTrainsetGenerationFailed = 'embeddingTrainsetGenerationFailed', + + // Training data errors + embeddingTrainDataNotExist = 'embeddingTrainDataNotExist', + embeddingNoTrainDataAvailable = 'embeddingNoTrainDataAvailable', + + // Training task errors + embeddingTaskNotExist = 'embeddingTaskNotExist', + embeddingTaskAlreadyRunning = 'embeddingTaskAlreadyRunning', + embeddingTaskCannotRetry = 'embeddingTaskCannotRetry', + embeddingTaskCannotCancel = 'embeddingTaskCannotCancel', + embeddingTaskCannotDelete = 'embeddingTaskCannotDelete', + embeddingTaskModelNotFound = 'embeddingTaskModelNotFound', + embeddingTaskBaseModelDisabled = 'embeddingTaskBaseModelDisabled', + embeddingTaskNotCompleted = 'embeddingTaskNotCompleted', + embeddingTunedModelNotFound = 'embeddingTunedModelNotFound', + + // Eval dataset errors + embeddingEvalDatasetNotGenerated = 'embeddingEvalDatasetNotGenerated', + embeddingEvalDatasetEmpty = 'embeddingEvalDatasetEmpty', + embeddingEvalResultsNotFound = 'embeddingEvalResultsNotFound', + + // External service errors + embeddingDitingServiceError = 'embeddingDitingServiceError', + embeddingSftBridgeServiceError = 'embeddingSftBridgeServiceError', + + // Environment validation errors + embeddingValidationSftBridgeUnaccessible = 'embeddingValidationSftBridgeUnaccessible', + embeddingValidationDitingUnaccessible = 'embeddingValidationDitingUnaccessible', + embeddingValidationDatasetNoSynthesisIndex = 'embeddingValidationDatasetNoSynthesisIndex', + embeddingValidationNoDatasetConfigured = 'embeddingValidationNoDatasetConfigured', + embeddingValidationBaseModelNotConfigured = 'embeddingValidationBaseModelNotConfigured', + + // === Preparing Stage Errors === + embeddingPrepareTrainsetDeleted = 'embeddingPrepareTrainsetDeleted', + embeddingPrepareDataEmpty = 'embeddingPrepareDataEmpty', + embeddingPrepareTimeout = 'embeddingPrepareTimeout', + embeddingPrepareFileSystemError = 'embeddingPrepareFileSystemError', + embeddingPrepareDataEmptyAfterWrite = 'embeddingPrepareDataEmptyAfterWrite', + embeddingPrepareMissingGenerateConfig = 'embeddingPrepareMissingGenerateConfig', + + // === Finetuning Stage Errors === + embeddingFinetuneDataPathNotFound = 'embeddingFinetuneDataPathNotFound', + embeddingFinetuneModelConfigInvalid = 'embeddingFinetuneModelConfigInvalid', + embeddingFinetuneDataFileNotFound = 'embeddingFinetuneDataFileNotFound', + embeddingFinetuneSftBridgeCreateFailed = 'embeddingFinetuneSftBridgeCreateFailed', + embeddingFinetuneQueueFull = 'embeddingFinetuneQueueFull', + embeddingFinetuneCancelled = 'embeddingFinetuneCancelled', + embeddingFinetuneDeploymentFailed = 'embeddingFinetuneDeploymentFailed', + embeddingFinetuneTrainingFailed = 'embeddingFinetuneTrainingFailed', + embeddingFinetuneTimeout = 'embeddingFinetuneTimeout', + embeddingFinetuneDeploymentNoEndpoint = 'embeddingFinetuneDeploymentNoEndpoint', + + // === Registering Stage Errors === + embeddingRegisterEndpointNotFound = 'embeddingRegisterEndpointNotFound', + embeddingRegisterBaseModelNotFound = 'embeddingRegisterBaseModelNotFound', + embeddingRegisterAiProxyFailed = 'embeddingRegisterAiProxyFailed', + embeddingRegisterChannelNotAvailable = 'embeddingRegisterChannelNotAvailable', + + // === Evaluating Stage Errors === + embeddingEvalNoDatasetConfigured = 'embeddingEvalNoDatasetConfigured', + embeddingEvalNoDataAvailable = 'embeddingEvalNoDataAvailable', + embeddingEvalDitingGenerationFailed = 'embeddingEvalDitingGenerationFailed', + embeddingEvalDatabaseSaveFailed = 'embeddingEvalDatabaseSaveFailed', + embeddingEvalDatasetEmptyBeforeEval = 'embeddingEvalDatasetEmptyBeforeEval', + embeddingEvalDatasetSearchAllFailed = 'embeddingEvalDatasetSearchAllFailed', + embeddingEvalModelNotFound = 'embeddingEvalModelNotFound', + embeddingEvalDitingEvalFailed = 'embeddingEvalDitingEvalFailed', + + // === Processor Internal Errors === + embeddingProcessorTaskNotFound = 'embeddingProcessorTaskNotFound', + embeddingProcessorTaskLostAfterPrepare = 'embeddingProcessorTaskLostAfterPrepare', + embeddingProcessorTaskLostAfterFinetune = 'embeddingProcessorTaskLostAfterFinetune', + embeddingProcessorTaskLostAfterRegister = 'embeddingProcessorTaskLostAfterRegister', + embeddingProcessorModelConfigNotInCheckpoint = 'embeddingProcessorModelConfigNotInCheckpoint', + embeddingProcessorTaskLostAfterEvalGen = 'embeddingProcessorTaskLostAfterEvalGen', + embeddingProcessorTaskLostAfterEval = 'embeddingProcessorTaskLostAfterEval', + + // === Trainset Generation Suggestions === + embeddingTrainsetGenNoDataset = 'embeddingTrainsetGenNoDataset', + embeddingTrainsetGenDatasetEmpty = 'embeddingTrainsetGenDatasetEmpty', + embeddingTrainsetGenInsufficientChunks = 'embeddingTrainsetGenInsufficientChunks', + embeddingTrainsetGenDitingFailed = 'embeddingTrainsetGenDitingFailed', + embeddingTrainsetGenDitingNoData = 'embeddingTrainsetGenDitingNoData', + embeddingTrainsetGenDatabaseError = 'embeddingTrainsetGenDatabaseError', + embeddingTrainsetGenAlreadyGenerating = 'embeddingTrainsetGenAlreadyGenerating', + embeddingTrainsetGenNotFound = 'embeddingTrainsetGenNotFound', + + // === LLM Judge Stage Errors === + embeddingLLMJudgeNoEvalData = 'embeddingLLMJudgeNoEvalData', + embeddingLLMJudgeNoRankingResults = 'embeddingLLMJudgeNoRankingResults', + embeddingLLMJudgeDiTingFailed = 'embeddingLLMJudgeDiTingFailed', + embeddingLLMJudgeEmptyResult = 'embeddingLLMJudgeEmptyResult', + + // === General Errors === + embeddingUnknownError = 'embeddingUnknownError' +} + +/** + * Convert RerankTrainErrEnum to i18n message key + * Example: rerankTrainsetNotExist -> train:rerank_trainset_not_exist + * + * @param type - Error type from RerankTrainErrEnum + * @returns i18n key pointing to train.json top-level keys + */ +export function getRerankTrainErrorMessageKey(type: RerankTrainErrEnum): string { + const snakeCase = (type as string) + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + return `train:${snakeCase}`; +} + +/** + * Convert RerankTrainSuggestionEnum to i18n suggestion key + * Example: rerankTrainsetNotExist -> train:rerank_trainset_not_exist_suggestion + * + * @param suggestion - Suggestion enum value + * @returns i18n key pointing to train.json top-level keys + */ +export function getRerankTrainErrorSuggestionKey(suggestion: RerankTrainSuggestionEnum): string { + const snakeCase = (suggestion as string) + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + return `train:${snakeCase}_suggestion`; +} + +/** + * Convert EmbeddingTrainErrEnum to i18n message key + * Example: embeddingTrainsetNotExist -> train:embedding_trainset_not_exist + * + * @param type - Error type from EmbeddingTrainErrEnum + * @returns i18n key pointing to train.json top-level keys + */ +export function getEmbeddingTrainErrorMessageKey(type: EmbeddingTrainErrEnum): string { + const snakeCase = (type as string) + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + return `train:${snakeCase}`; +} + +/** + * Convert EmbeddingTrainSuggestionEnum to i18n suggestion key + * Example: embeddingTrainsetNotExist -> train:embedding_trainset_not_exist_suggestion + * + * @param suggestion - Suggestion enum value + * @returns i18n key pointing to train.json top-level keys + */ +export function getEmbeddingTrainErrorSuggestionKey( + suggestion: EmbeddingTrainSuggestionEnum +): string { + const snakeCase = (suggestion as string) + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + return `train:${snakeCase}_suggestion`; +} + +/** + * Auto-generate trainErr array using enum values for Rerank + * statusText directly uses enum values (which already contain module prefix) + */ +const trainErr = Object.values(RerankTrainErrEnum).map((errType) => ({ + statusText: errType, + message: i18nT(getRerankTrainErrorMessageKey(errType as any) as any) +})); + +/** + * Auto-generate embeddingTrainErr array using enum values for Embedding + * statusText directly uses enum values (which already contain module prefix) + */ +const embeddingTrainErr = Object.values(EmbeddingTrainErrEnum).map((errType) => ({ + statusText: errType, + message: i18nT(getEmbeddingTrainErrorMessageKey(errType as any) as any) +})); + +const rerankTrainErrorCodes: ErrType = trainErr.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 502000 + index, + statusText: cur.statusText as RerankTrainErrEnum, + message: cur.message, + data: null + } + }; +}, {} as ErrType); + +const embeddingTrainErrorCodes: ErrType = embeddingTrainErr.reduce( + (acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 502100 + index, + statusText: cur.statusText as EmbeddingTrainErrEnum, + message: cur.message, + data: null + } + }; + }, + {} as ErrType +); + +export default { ...rerankTrainErrorCodes, ...embeddingTrainErrorCodes }; diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index e859ec8bb150..00b0408d2f0b 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -1,5 +1,5 @@ import { type ErrType } from '../errorCode'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../i18n/utils'; /* team: 503000 */ export enum UserErrEnum { notUser = 'notUser', diff --git a/packages/global/common/error/errorCode.ts b/packages/global/common/error/errorCode.ts index 7e75ac36cdab..293265aa0088 100644 --- a/packages/global/common/error/errorCode.ts +++ b/packages/global/common/error/errorCode.ts @@ -9,8 +9,12 @@ import userErr from './code/user'; import commonErr from './code/common'; import s3Err from './code/s3'; import SystemErrEnum from './code/system'; +import databaseErr from './code/database'; +import evaluationErr from './code/evaluation'; +import trainErr from './code/train'; import agentSkillErr from './code/agentSkill'; -import { i18nT } from '../../../web/i18n/utils'; +import modelErr from './code/model'; +import { i18nT } from '../i18n/utils'; export const ERROR_CODE: { [key: number]: string } = { 400: i18nT('common:code_error.error_code.400'), @@ -118,9 +122,13 @@ export const ERROR_RESPONSE: Record< ...outLinkErr, ...teamErr, ...userErr, + ...databaseErr, ...pluginErr, ...commonErr, ...s3Err, ...SystemErrEnum, - ...agentSkillErr + ...evaluationErr, + ...trainErr, + ...agentSkillErr, + ...modelErr }; diff --git a/packages/global/common/error/utils.ts b/packages/global/common/error/utils.ts index cd91f2a30194..29611f2a87f3 100644 --- a/packages/global/common/error/utils.ts +++ b/packages/global/common/error/utils.ts @@ -2,7 +2,7 @@ import { replaceSensitiveText } from '../string/tools'; import { ERROR_RESPONSE } from './errorCode'; export const getErrText = (err: any, def = ''): any => { - const msg: string = + const rawMsg = typeof err === 'string' ? err || def : err?.response?.data?.message || @@ -15,6 +15,8 @@ export const getErrText = (err: any, def = ''): any => { err?.code || def; + const msg = typeof rawMsg === 'string' ? rawMsg : String(rawMsg ?? ''); + if (ERROR_RESPONSE[msg]) { return ERROR_RESPONSE[msg].message; } @@ -38,3 +40,15 @@ export class UserError extends Error { this.name = 'UserError'; } } + +export class FileUploadError extends UserError { + code: string; + details?: Record; + + constructor(code: string, message: string, details?: Record) { + super(message); + this.name = 'FileUploadError'; + this.code = code; + this.details = details; + } +} diff --git a/packages/global/common/file/constants.ts b/packages/global/common/file/constants.ts index 060b7e475bcb..185436674466 100644 --- a/packages/global/common/file/constants.ts +++ b/packages/global/common/file/constants.ts @@ -1,16 +1,34 @@ /* mongo fs bucket */ export enum BucketNameEnum { dataset = 'dataset', - chat = 'chat' + chat = 'chat', + evaluation = 'evaluation' } export const EndpointUrl = `${process.env.FILE_DOMAIN || process.env.FE_DOMAIN || ''}${process.env.NEXT_PUBLIC_BASE_URL || ''}`; export const ReadFileBaseUrl = `${EndpointUrl}/api/common/file/read`; -export const documentFileType = '.txt, .docx, .csv, .xlsx, .pdf, .md, .html, .pptx'; +export const documentFileType = + '.txt, .doc, .docx, .csv, .xls, .xlsx, .pdf, .md, .html, .ppt, .pptx'; +export const documentAndImageFileType = `${documentFileType}, .jpg, .jpeg, .png`; /** 图片数据集创建/追加图片(multer 直传)与 ImageDataset、InsertImageModal 的 fileType 一致 */ export const datasetImageCollectionFileType = '.jpg, .jpeg, .png'; export const imageFileType = '.jpg, .jpeg, .png, .gif, .bmp, .webp, .svg, .tiff, .tif, .ico, .heic, .heif, .avif, .raw, .cr2, .nef, .arw, .dng, .psd, .ai, .eps, .emf, .wmf, .jfif, .exif, .pgm, .ppm, .pbm, .jp2, .j2k, .jpf, .jpx, .jpm, .mj2, .xbm, .pcx'; + +/* File Upload Limits */ +export const DEFAULT_FILE_UPLOAD_LIMITS = { + // Default maximum number of files that can be uploaded at once + MAX_FILE_COUNT: 20, + // Default maximum size per file in MB + MAX_FILE_SIZE_MB: 500 +} as const; + +/* File Upload Error Codes */ +export enum FileUploadErrorEnum { + FILE_TOO_LARGE = 'FILE_TOO_LARGE', + TOO_MANY_FILES = 'TOO_MANY_FILES', + INVALID_FILE_TYPE = 'INVALID_FILE_TYPE' +} diff --git a/packages/global/common/file/icon.ts b/packages/global/common/file/icon.ts index 0d268e509ec0..07a1c8b21955 100644 --- a/packages/global/common/file/icon.ts +++ b/packages/global/common/file/icon.ts @@ -2,7 +2,7 @@ export const getFileIcon = (name = '', defaultImg = 'file/fill/file') => { const fileImgs = [ { suffix: 'pdf', src: 'file/fill/pdf' }, { suffix: 'ppt', src: 'file/fill/ppt' }, - { suffix: 'xlsx', src: 'file/fill/xlsx' }, + { suffix: '(xls|xlsx)', src: 'file/fill/xlsx' }, { suffix: 'csv', src: 'file/fill/csv' }, { suffix: '(doc|docs)', src: 'file/fill/doc' }, { suffix: 'txt', src: 'file/fill/txt' }, @@ -17,3 +17,4 @@ export const getFileIcon = (name = '', defaultImg = 'file/fill/file') => { fileImgs.find((item) => new RegExp(`\.${item.suffix}`, 'gi').test(name))?.src || defaultImg ); }; + diff --git a/packages/global/common/file/s3/type.ts b/packages/global/common/file/s3/type.ts index a4cfe4d3b568..6efd81c75e6f 100644 --- a/packages/global/common/file/s3/type.ts +++ b/packages/global/common/file/s3/type.ts @@ -9,3 +9,4 @@ export const CreatePostPresignedUrlResponseSchema = z.object({ export type CreatePostPresignedUrlResponseType = z.infer< typeof CreatePostPresignedUrlResponseSchema >; +export type CreatePostPresignedUrlResult = CreatePostPresignedUrlResponseType; diff --git a/packages/global/common/i18n/utils.ts b/packages/global/common/i18n/utils.ts index 7779314112d9..fbe1f3248905 100644 --- a/packages/global/common/i18n/utils.ts +++ b/packages/global/common/i18n/utils.ts @@ -1,4 +1,27 @@ import type { I18nStringType, localeType } from './type'; +import { LangEnum } from './type'; + +const LANG_KEY = 'NEXT_LOCALE'; + +/** + * Server-safe getLang: returns the user's language preference without + * depending on React or any browser-only library (js-cookie, next-i18next). + * On the server it always returns zh_CN. + */ +export const getLang = (): string => { + if (typeof window === 'undefined') { + return LangEnum.zh_CN; + } + try { + const fromStorage = localStorage.getItem(LANG_KEY); + if (fromStorage) return fromStorage; + // Read from cookie without js-cookie + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${LANG_KEY}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : LangEnum.zh_CN; + } catch { + return LangEnum.zh_CN; + } +}; export const parseI18nString = (str: I18nStringType | string = '', lang = 'en') => { if (!str || typeof str === 'string') return str; @@ -23,3 +46,34 @@ export const formatI18nLocationToZhEn = (locale: localeType = 'zh-CN'): 'zh' | ' } return 'en'; }; + +/** + * Parse i18n array data, supporting both old array format and new i18n object format + * @param data - The data to parse (array or i18n object) + * @param lang - The target language + * @returns The parsed array data + */ +export const parseI18nArray = ( + data: T[] | Record | undefined, + lang: localeType = 'en' +): T[] | undefined => { + if (!data) return []; + + // If it's an array, it's the old format + if (Array.isArray(data)) { + return data; + } + + // If it's an object, it's the new i18n format + if (typeof data === 'object' && data !== null) { + return data[lang] ?? data['en'] ?? []; + } + + return undefined; +}; + +/** + * Isomorphic i18n key identity function for both server and client. + * Returns the key as-is; full type checking is handled by the client-side i18nT. + */ +export const i18nT = (key: T): T => key; diff --git a/packages/global/common/parentFolder/utils.ts b/packages/global/common/parentFolder/utils.ts index 88e48868e3dc..cdb7cd917c49 100644 --- a/packages/global/common/parentFolder/utils.ts +++ b/packages/global/common/parentFolder/utils.ts @@ -15,3 +15,33 @@ export const parseParentIdInMongo = (parentId: ParentIdType) => { }; return {}; }; + +/** + * 获取当前文件夹及其所有子孙文件夹的 ID 列表 + * @param findChildren 根据 parentIds 查询子文档的函数 + * @param parentId 当前文件夹 ID + * @returns 包含 parentId 及其所有子孙文件夹 ID 的数组 + */ +export const getAllDescendantIds = async ( + findChildren: (parentIds: string[]) => Promise<{ _id: any }[]>, + parentId: string +): Promise => { + const visited = new Set([parentId]); + const allIds = [parentId]; + let queue = [parentId]; + + while (queue.length > 0) { + const children = await findChildren(queue); + queue = []; + for (const c of children) { + const id = String(c._id); + if (!visited.has(id)) { + visited.add(id); + queue.push(id); + allIds.push(id); + } + } + } + + return allIds; +}; diff --git a/packages/global/common/string/markdown.ts b/packages/global/common/string/markdown.ts index bfaab2f36a3c..950a0d81c9ac 100644 --- a/packages/global/common/string/markdown.ts +++ b/packages/global/common/string/markdown.ts @@ -129,7 +129,8 @@ export const uploadMarkdownBase64 = async ({ }) => { if (uploadImgController) { // match base64, upload and replace it - const base64Regex = /data:image\/.*;base64,([^\)]+)/g; + // 使用 [^;]* 代替 .* 避免贪婪匹配跨越多个 data:image/ (同行多图时 .* 会错误地跨图匹配) + const base64Regex = /data:image\/[^;]*;base64,([^\)]+)/g; const base64Arr = rawText.match(base64Regex) || []; // upload base64 and replace it @@ -172,29 +173,117 @@ export const markdownProcess = async ({ return simpleMarkdownText(imageProcess); }; -export const matchMdImg = (text: string) => { - // 优化后的正则: - // 1. 使用 [^\]]* 匹配 alt 文本(更精确) - // 2. 使用 [A-Za-z0-9+/=]+ 匹配 base64 数据(避免回溯) - // 3. 明确匹配 data:image/ 前缀 - const base64Regex = /!\[([^\]]*)\]\((data:image\/([^;]+);base64,([A-Za-z0-9+/=]+))\)/g; +export const matchHtmlImg = (html: string) => { const imageList: ImageType[] = []; + const parts: string[] = []; + let lastIndex = 0; + const prefix = 'src="data:'; + const base64Marker = ';base64,'; + + let searchFrom = 0; + while (searchFrom < html.length) { + const startIdx = html.indexOf(prefix, searchFrom); + if (startIdx === -1) break; + + const mimeStart = startIdx + prefix.length; + const mimeEnd = html.indexOf(base64Marker, mimeStart); + if (mimeEnd === -1) { + searchFrom = mimeStart; + continue; + } - text = text.replace(base64Regex, (_match, altText, _fullDataUrl, mime, base64Data) => { + const base64Start = mimeEnd + base64Marker.length; + const base64End = html.indexOf('"', base64Start); + if (base64End === -1) { + searchFrom = mimeStart; + continue; + } + + const mime = html.slice(mimeStart, mimeEnd); + const base64Data = html.slice(base64Start, base64End); const uuid = `IMAGE_${getNanoid(12)}_IMAGE`; + imageList.push({ uuid, base64: base64Data, mime }); + + parts.push(html.slice(lastIndex, startIdx)); + parts.push(`src="${uuid}"`); + lastIndex = base64End + 1; + searchFrom = lastIndex; + } + parts.push(html.slice(lastIndex)); + + return { text: parts.join(''), imageList }; +}; + +export const matchMdImg = (text: string) => { + const imageList: ImageType[] = []; + const parts: string[] = []; + let lastIndex = 0; + const imgStart = '!['; + const base64Marker = ';base64,'; + + let searchFrom = 0; + while (searchFrom < text.length) { + const startIdx = text.indexOf(imgStart, searchFrom); + if (startIdx === -1) break; + + const altStart = startIdx + 2; + // Find the first '](' after '![' to locate the end of alt text + const bracketIdx = text.indexOf('](', altStart); + if (bracketIdx === -1) { + searchFrom = altStart; + continue; + } + + const urlStart = bracketIdx + 2; + const dataPrefix = 'data:image/'; + if (!text.startsWith(dataPrefix, urlStart)) { + // Regular image URL, not base64 data URI. Skip it. + searchFrom = urlStart; + continue; + } + + const altText = text.slice(altStart, bracketIdx); + + const mimeStart = urlStart + dataPrefix.length; + const mimeEnd = text.indexOf(base64Marker, mimeStart); + if (mimeEnd === -1) { + searchFrom = urlStart; + continue; + } + + const mime = text.slice(mimeStart, mimeEnd); + + const base64Start = mimeEnd + base64Marker.length; + const base64End = text.indexOf(')', base64Start); + if (base64End === -1) { + searchFrom = urlStart; + continue; + } + + const base64Data = text.slice(base64Start, base64End); + const cleanBase64 = base64Data.replace(/\s/g, ''); + + const uuid = `IMAGE_${getNanoid(12)}_IMAGE`; imageList.push({ uuid, - base64: base64Data, + base64: cleanBase64, mime: `image/${mime}` }); - // 保持原有的 alt 文本,只替换 base64 部分 - return `![${altText}](${uuid})`; - }); + // Escape unescaped [ in alt text to prevent CommonMark bracket nesting issues + const sanitizedAlt = altText.replace(/(? { + const lines = text.split('\n'); + const headers: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (/^#{1,6}\s/.test(trimmed)) { + headers.push(trimmed); + } else if (trimmed) { + break; // 遇到非标题的非空行就停止 + } + } + return headers; +}; + +/** + * 提取文本中所有的 Markdown 标题 + */ +const extractAllHeaders = (text: string): string[] => { + const lines = text.split('\n'); + return lines.map((line) => line.trim()).filter((line) => /^#{1,6}\s/.test(line)); +}; + +/** + * 检查两个标题数组的共同前缀长度 + */ +const getCommonHeaderPrefixLength = (headers1: string[], headers2: string[]): number => { + const minLen = Math.min(headers1.length, headers2.length); + let count = 0; + for (let i = 0; i < minLen; i++) { + if (headers1[i] === headers2[i]) { + count++; + } else { + break; + } + } + return count; +}; + +/** + * 检查 chunk 开头的标题有多少个已经在 currentBox 的所有标题中按顺序出现 + */ +const countMatchingPrefixHeaders = ( + currentBoxAllHeaders: string[], + chunkLeadingHeaders: string[] +): number => { + if (chunkLeadingHeaders.length === 0) return 0; + + let matchCount = 0; + let searchStartIndex = 0; + + for (const targetHeader of chunkLeadingHeaders) { + let found = false; + for (let j = searchStartIndex; j < currentBoxAllHeaders.length; j++) { + if (currentBoxAllHeaders[j] === targetHeader) { + matchCount++; + searchStartIndex = j + 1; + found = true; + break; + } + } + if (!found) break; + } + + return matchCount; +}; + +/** + * 移除文本开头指定数量的 Markdown 标题 + */ +const removeLeadingHeaders = (text: string, count: number): string => { + if (count === 0) return text; + + const lines = text.split('\n'); + let removed = 0; + let startIndex = 0; + + for (let i = 0; i < lines.length && removed < count; i++) { + const trimmed = lines[i].trim(); + if (/^#{1,6}\s/.test(trimmed)) { + removed++; + startIndex = i + 1; + } else if (trimmed) { + break; // 遇到非标题的非空行就停止 + } + } + + return lines.slice(startIndex).join('\n'); +}; + +/** + * 智能合并两个块:去除第二个块中与第一个块重复的父标题 + * @param chunk1 第一个块 + * @param chunk2 第二个块 + * @param separator 分隔符(默认 '\n\n') + * @returns 合并后的文本 + */ +const mergeChunksWithDedup = ( + chunk1: string, + chunk2: string, + separator: string = '\n\n' +): string => { + const headers1 = extractLeadingHeaders(chunk1); + const headers2 = extractLeadingHeaders(chunk2); + const commonPrefixLength = getCommonHeaderPrefixLength(headers1, headers2); + + // 只有当第二个块有更深层级的子标题时,才去除重复的父标题 + const chunk2ToAdd = + commonPrefixLength > 0 && commonPrefixLength < headers2.length + ? removeLeadingHeaders(chunk2, commonPrefixLength) + : chunk2; + + return chunk1 + separator + chunk2ToAdd; +}; + +// ============ 表格处理函数 ============ + // 判断字符串是否为markdown的表格形式 const strIsMdTable = (str: string) => { // 检查是否包含表格分隔符 | @@ -62,8 +183,8 @@ const strIsMdTable = (str: string) => { return true; }; -const markdownTableSplit = (props: SplitProps): SplitResponse => { - let { text = '', chunkSize, maxSize = defaultMaxChunkSize } = props; +const markdownTableSplit = (props: SplitProps & { headerPrefix?: string }): SplitResponse => { + let { text = '', chunkSize, headerPrefix = '' } = props; // split by rows const splitText2Lines = text.split('\n').filter((line) => line.trim()); @@ -82,10 +203,9 @@ const markdownTableSplit = (props: SplitProps): SplitResponse => { .join(' | ')} |`; const chunks: string[] = []; - const defaultChunk = `${header} + let chunk = `${headerPrefix}${header} ${mdSplitString} `; - let chunk = defaultChunk; for (let i = 2; i < splitText2Lines.length; i++) { const chunkLength = getTextValidLength(chunk); @@ -93,18 +213,10 @@ ${mdSplitString} // Over size if (chunkLength + nextLineLength > chunkSize) { - // 单行非常的长,直接分割 - if (chunkLength > maxSize) { - const newChunks = commonSplit({ - ...props, - text: chunk.replace(defaultChunk, '').trim() - }).chunks; - chunks.push(...newChunks); - } else { - chunks.push(chunk); - } - - chunk = defaultChunk; + chunks.push(chunk); + chunk = `${headerPrefix}${header} +${mdSplitString} +`; } chunk += `${splitText2Lines[i]}\n`; } @@ -119,6 +231,112 @@ ${mdSplitString} }; }; +// 判断字符串是否为HTML的表格形式 +const strIsHtmlTable = (str: string) => { + const trimmedStr = str.trim(); + + // 检查是否以 ]/i)) { + return false; + } + + // 检查是否以
结尾(严格检查) + if (!trimmedStr.endsWith('')) { + return false; + } + + // 确保只有一张顶层表格:第一个 必须就是末尾 + // 防止整个多表格文档被误判为单张表格,导致表格间文字被 htmlTableSplit 丢弃 + const firstCloseIdx = trimmedStr.indexOf(''); + if (firstCloseIdx !== trimmedStr.length - ''.length) { + return false; + } + + // 检查是否包含 tr 标签 + if (!str.includes(' { + let { text = '', chunkSize, headerPrefix = '' } = props; + + try { + // 提取完整的 table 标签及其属性 + const tableMatch = text.match(/]*>/); + const tableOpenTag = tableMatch ? tableMatch[0] : ''; + const tableCloseTag = '
'; + + // 提取所有 tr 标签 + const trMatches = text.match(/]*>[\s\S]*?<\/tr>/g); + if (!trMatches || trMatches.length === 0) { + return { chunks: [text], chars: text.length }; + } + + // 第一个 tr 作为表头(可能在 thead 中,也可能直接在 tbody 中) + const headerRow = trMatches[0]; + + // 提取 thead(如果存在) + const theadMatch = text.match(/]*>[\s\S]*?<\/thead>/); + const theadContent = theadMatch ? theadMatch[0] : ''; + + const chunks: string[] = []; + let chunk = `${headerPrefix}${tableOpenTag}\n`; + + // 如果有 thead,使用 thead;否则使用第一个 tr + if (theadContent) { + chunk += `${theadContent}\n\n`; + } else { + chunk += `\n${headerRow}\n`; + } + + // 从第二个 tr 开始遍历(如果有 thead 则从第一个开始) + const startIndex = theadContent ? 0 : 1; + + for (let i = startIndex; i < trMatches.length; i++) { + const currentRow = trMatches[i]; + const chunkLength = getTextValidLength(chunk); + const rowLength = getTextValidLength(currentRow); + + // 超过大小限制,创建新块 + if (chunkLength + rowLength > chunkSize && i > startIndex) { + chunk += `\n${tableCloseTag}`; + chunks.push(chunk); + + // 开始新块,重新添加表头 + chunk = `${headerPrefix}${tableOpenTag}\n`; + if (theadContent) { + chunk += `${theadContent}\n\n`; + } else { + chunk += `\n${headerRow}\n`; + } + } + + chunk += `${currentRow}\n`; + } + + // 添加最后一个块 + if (chunk) { + chunk += `\n${tableCloseTag}`; + chunks.push(chunk); + } + + return { + chunks, + chars: chunks.reduce((sum, chunk) => sum + chunk.length, 0) + }; + } catch (error) { + // 如果解析失败,返回原文本 + return { chunks: [text], chars: text.length }; + } +}; + /* 1. 自定义分隔符:不需要重叠,不需要小块合并 2. Markdown 标题:不需要重叠;标题嵌套共享,需要小块合并 @@ -151,6 +369,9 @@ const commonSplit = (props: SplitProps): SplitResponse => { // replace invalid \n text = text.replace(/(\r?\n|\r){3,}/g, '\n\n\n'); + // 确保 HTML 表格前有换行符 + text = text.replace(/([^\n])( { const stepReges: { reg: RegExp | string; maxLen: number }[] = [ ...customReg.map((text) => ({ reg: text.replace(/\\n/g, '\n'), - maxLen: maxSize + maxLen: chunkSize })), ...markdownHeaderRules, { reg: /([\n](```[\s\S]*?```|~~~[\s\S]*?~~~))/g, maxLen: maxSize }, // code block - // HTML Table tag 尽可能保障完整 + { reg: /([\n])/gi, maxLen: chunkSize }, // HTML Table 尽可能保障完整 { reg: /(\n\|(?:[^\n|]*\|)+\n\|(?:[:\-\s]*\|)+\n(?:\|(?:[^\n|]*\|)*\n)*)/g, maxLen: chunkSize @@ -246,14 +467,11 @@ const commonSplit = (props: SplitProps): SplitResponse => { const splitTexts = replaceText.split(splitMarker).filter((part) => part.trim()); - return splitTexts + const result = splitTexts .map((text) => { const matchTitle = isMarkdownSplit ? text.match(reg)?.[0] || '' : ''; // 如果一个分块没有匹配到,则使用默认块大小,否则使用最大块大小 - const chunkMaxSize = (() => { - if (isCustomStep) return maxLen; - return text.match(reg) === null ? chunkSize : maxLen; - })(); + const chunkMaxSize = text.match(reg) === null ? chunkSize : maxLen; return { text: isMarkdownSplit ? text.replace(matchTitle, '') : text, @@ -262,6 +480,8 @@ const commonSplit = (props: SplitProps): SplitResponse => { }; }) .filter((item) => !!item.title || !!item.text?.trim()); + + return result; }; /* Gets the overlap at the end of a text as the beginning of the next block */ @@ -307,21 +527,17 @@ const commonSplit = (props: SplitProps): SplitResponse => { const isMarkdownStep = checkIsMarkdownSplit(step); const isCustomStep = checkIsCustomStep(step); const forbidConcat = isCustomStep; // forbid=true时候,lastText肯定为空 + const textLength = getTextValidLength(text); // Over step if (step >= stepReges.length) { - // Merge lastText with current text to prevent data loss - const combinedText = lastText + text; - const combinedLength = getTextValidLength(combinedText); - - if (combinedLength < maxSize) { - return [combinedText]; + if (textLength < maxSize) { + return [text]; } // use slice-chunkSize to split text - // Note: Use combinedText.length for slicing, not combinedLength const chunks: string[] = []; - for (let i = 0; i < combinedText.length; i += chunkSize - overlapLen) { - chunks.push(combinedText.slice(i, i + chunkSize)); + for (let i = 0; i < textLength; i += chunkSize - overlapLen) { + chunks.push(text.slice(i, i + chunkSize)); } return chunks; } @@ -342,20 +558,39 @@ const commonSplit = (props: SplitProps): SplitResponse => { const newTextLen = getTextValidLength(newText); // split the current table if it will exceed after adding - if (strIsMdTable(currentText) && newTextLen > maxLen) { + const tableHandlers = [ + { check: strIsHtmlTable, split: htmlTableSplit }, + { check: strIsMdTable, split: markdownTableSplit } + ]; + + const matchedHandler = tableHandlers.find( + (handler) => handler.check(currentText) && newTextLen > maxLen + ); + + if (matchedHandler) { + // 与原始版本逻辑一致:先推出 lastText,再处理表格 if (lastTextLen > 0) { chunks.push(lastText); lastText = ''; } - const { chunks: tableChunks } = markdownTableSplit({ + // 构建完整的 headerPrefix + // 如果在 Markdown 步骤中,需要包含当前的 item.title(因为它还没有被添加到 parentTitle) + let headerPrefix = parentTitle; + if (isMarkdownStep && item.title) { + headerPrefix = parentTitle + item.title; + } + + const { chunks: tableChunks } = matchedHandler.split({ text: currentText, - chunkSize: chunkSize * 1.2 + chunkSize: chunkSize * 1.2, + headerPrefix: headerPrefix }); chunks.push(...tableChunks); continue; } + // Markdown 模式下,会强制向下拆分最小块,并再最后一个标题深度,给小块都补充上所有标题(包含父级标题) if (isMarkdownStep) { // split new Text, split chunks must will greater 1 (small lastText) @@ -374,10 +609,14 @@ const commonSplit = (props: SplitProps): SplitResponse => { // 在合并最深级标题时,需要补充标题 chunks.push( - ...innerChunks.map( - (chunk) => - step === markdownIndex + customRegLen ? `${parentTitle}${item.title}${chunk}` : chunk // 合并进 Markdown 分块时,需要补标题 - ) + ...innerChunks.map((chunk) => { + // 如果 chunk 已经包含 parentTitle(来自表格分块),则不重复添加 + const fullTitle = parentTitle + item.title; + if (step === markdownIndex + customRegLen) { + return chunk.startsWith(fullTitle) ? chunk : `${fullTitle}${chunk}`; + } + return chunk; + }) ); continue; @@ -487,10 +726,13 @@ const commonSplit = (props: SplitProps): SplitResponse => { * markdown */ export const splitText2Chunks = (props: SplitProps): SplitResponse => { - let { text = '' } = props; + let { text = '', chunkSize } = props; const splitWithCustomSign = text.split(CUSTOM_SPLIT_SIGN); const splitResult = splitWithCustomSign.map((item) => { + if (strIsHtmlTable(item)) { + return htmlTableSplit({ ...props, text: item }); + } if (strIsMdTable(item)) { return markdownTableSplit({ ...props, text: item }); } @@ -498,11 +740,150 @@ export const splitText2Chunks = (props: SplitProps): SplitResponse => { return commonSplit({ ...props, text: item }); }); + let chunks = splitResult + .map((item) => item.chunks) + .flat() + .map((chunk) => simpleText(chunk)); + + //=== 后处理:合并小块、去重标题 === + const smallChunkThreshold = 400; + + // 第一步:对小于smallChunkThreshold的连续小块进行两两合并(多轮迭代直到无法继续合并) + let preProcessedChunks = [...chunks]; + let hasSmallChunks = true; + + while (hasSmallChunks) { + const tempChunks: string[] = []; + let i = 0; + hasSmallChunks = false; + + while (i < preProcessedChunks.length) { + const currentChunk = preProcessedChunks[i]; + const currentLength = getTextValidLength(currentChunk); + + // 如果当前块小于阈值且还有下一个块 + if (currentLength < smallChunkThreshold && i + 1 < preProcessedChunks.length) { + const nextChunk = preProcessedChunks[i + 1]; + const nextLength = getTextValidLength(nextChunk); + + // 如果下一个块也小于阈值,则合并 + if (nextLength < smallChunkThreshold) { + tempChunks.push(mergeChunksWithDedup(currentChunk, nextChunk)); + i += 2; // 跳过下一个块 + hasSmallChunks = true; // 标记还有小块,可能需要继续合并 + continue; + } + } + + // 无法合并,直接添加当前块 + tempChunks.push(currentChunk); + i++; + } + + preProcessedChunks = tempChunks; + } + + // 第二步:装箱式合并 - 避免小块单独成块 + const maxBoxSize = chunkSize * 1.2; + const mergedChunks: string[] = []; + let currentBox = ''; + + for (let i = 0; i < preProcessedChunks.length; i++) { + const chunk = preProcessedChunks[i]; + const chunkLength = getTextValidLength(chunk); + + // 检测并去除重复的父标题 + let chunkToAdd = chunk; + if (currentBox) { + const currentBoxAllHeaders = extractAllHeaders(currentBox); + const chunkLeadingHeaders = extractLeadingHeaders(chunk); + + if (chunkLeadingHeaders.length > 0) { + const matchCount = countMatchingPrefixHeaders(currentBoxAllHeaders, chunkLeadingHeaders); + if (matchCount > 0) { + chunkToAdd = removeLeadingHeaders(chunk, matchCount); + } + } + } + + const testBox = currentBox ? currentBox + '\n\n' + chunkToAdd : chunk; + const testBoxLength = getTextValidLength(testBox); + + // 如果装入当前chunk后不超过盒子最大容量,则装入 + if (testBoxLength <= maxBoxSize) { + currentBox = testBox; + } else { + const currentBoxLength = getTextValidLength(currentBox); + + // 如果当前盒子小于smallChunkThreshold,强制装入当前chunk(即使超过maxBoxSize) + if (currentBoxLength < smallChunkThreshold && currentBox) { + currentBox = testBox; + } else { + // 当前盒子足够大,可以独立成块 + if (currentBox) { + mergedChunks.push(currentBox); + } + + // 开始新盒子,装入当前chunk(使用原始chunk,保留标题) + currentBox = chunk; + + // 检查当前chunk是否太小,如果是最后一个chunk则无所谓,否则强制与下一个合并 + if (chunkLength < smallChunkThreshold && i < preProcessedChunks.length - 1) { + currentBox = mergeChunksWithDedup(chunk, preProcessedChunks[i + 1]); + i++; // 跳过下一个chunk + } + } + } + } + + // 最后一个盒子 + if (currentBox) { + mergedChunks.push(currentBox); + } + + // 第三步:最终去重 - 去除相邻分片中重复的父标题 + if (mergedChunks.length === 0) { + return { chunks: [], chars: 0 }; + } + + const finalChunks: string[] = [mergedChunks[0]]; // 第一个分片保持不变 + + for (let i = 1; i < mergedChunks.length; i++) { + const currentChunk = mergedChunks[i]; + const prevHeaders = extractLeadingHeaders(mergedChunks[i - 1]); + const currentHeaders = extractLeadingHeaders(currentChunk); + const commonPrefixLength = getCommonHeaderPrefixLength(prevHeaders, currentHeaders); + + // 只有当当前块有更深层级的子标题时,才去除重复的父标题 + const shouldDedup = commonPrefixLength > 0 && commonPrefixLength < currentHeaders.length; + finalChunks.push( + shouldDedup ? removeLeadingHeaders(currentChunk, commonPrefixLength) : currentChunk + ); + } + + // 最终检查:合并小块(使用去重合并) + // 第一个块 < smallChunkThreshold → 合并到第二个块 + // 中间的块 < smallChunkThreshold → 合并到前一个块 + // 最后一个块 < smallChunkThreshold → 合并到前一个块 + + // 1. 先处理第一个块 + if (finalChunks.length >= 2 && getTextValidLength(finalChunks[0]) < smallChunkThreshold) { + finalChunks[1] = mergeChunksWithDedup(finalChunks[0], finalChunks[1]); + finalChunks.shift(); + } + + // 2. 处理中间和最后的块(从后往前遍历,避免索引问题) + for (let i = finalChunks.length - 1; i > 0; i--) { + const currentLength = getTextValidLength(finalChunks[i]); + + if (currentLength < smallChunkThreshold) { + finalChunks[i - 1] = mergeChunksWithDedup(finalChunks[i - 1], finalChunks[i]); + finalChunks.splice(i, 1); + } + } + return { - chunks: splitResult - .map((item) => item.chunks) - .flat() - .map((chunk) => simpleText(chunk)), - chars: splitResult.reduce((sum, item) => sum + item.chars, 0) + chunks: finalChunks, + chars: finalChunks.reduce((sum, chunk) => sum + chunk.length, 0) }; }; diff --git a/packages/global/common/string/time.ts b/packages/global/common/string/time.ts index cb3fb7dadc7c..a3ff8b06776f 100644 --- a/packages/global/common/string/time.ts +++ b/packages/global/common/string/time.ts @@ -2,7 +2,7 @@ import dayjs from 'dayjs'; import cronParser from 'cron-parser'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../i18n/utils'; dayjs.extend(utc); dayjs.extend(timezone); @@ -13,6 +13,59 @@ export const formatTime2YMDHMS = (time?: Date | number) => time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : ''; export const formatTime2YMDHM = (time?: Date | number) => time ? dayjs(time).format('YYYY-MM-DD HH:mm') : ''; +export const formatTime2YMDHMUtc = (time?: Date | number) => + time ? dayjs.utc(time).format('YYYY-MM-DD HH:mm') : ''; + +/** + * UTC 毫秒时间戳 → Date 对象(UTC 字段值填入本地字段) + * 用于 DateTimePicker:使其以 UTC 时间显示,而非本地时间 + */ +export const utcTsToDisplayDate = (ts: number): Date => { + const d = new Date(ts); + return new Date( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + 0, + 0 + ); +}; + +/** + * DateTimePicker 返回的 Date(本地字段实际表示 UTC 值)→ UTC 毫秒时间戳 + */ +export const displayDateToUtcTs = (date: Date): number => + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + 0, + 0 + ); + +/** + * datetime-local 字符串(如 "2024-01-15T10:30")→ UTC 毫秒时间戳 + * 将字符串中的时间字段直接作为 UTC 值处理 + */ +export const datetimeLocalToUtcTs = (str: string): number => { + const [datePart, timePart = '00:00'] = str.split('T'); + const [y, m, d] = datePart.split('-').map(Number); + const [h, min] = timePart.split(':').map(Number); + return Date.UTC(y, m - 1, d, h, min); +}; + +/** + * UTC 毫秒时间戳 → datetime-local 字符串(如 "2024-01-15T10:30"),以 UTC 时间表示 + */ +export const utcTsToDatetimeLocal = (ts: number): string => { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +}; export const formatTime2YMD = (time?: Date | number) => time ? dayjs(time).format('YYYY-MM-DD') : ''; export const formatTime2HM = (time: Date = new Date()) => dayjs(time).format('HH:mm'); diff --git a/packages/global/common/string/tools.ts b/packages/global/common/string/tools.ts index f446e84c2e71..74b17425e677 100644 --- a/packages/global/common/string/tools.ts +++ b/packages/global/common/string/tools.ts @@ -30,7 +30,6 @@ export const simpleText = (text = '') => { text = text.replace(/\n{3,}/g, '\n\n'); text = text.replace(/[\s&&[^\n]]{2,}/g, ' '); text = text.replace(/[\x00-\x08]/g, ' '); - return text; }; @@ -136,7 +135,14 @@ export function replaceVariable( } /* replace sensitive text */ -export const replaceSensitiveText = (text: string) => { +export const replaceSensitiveText = (text: any): string => { + if (typeof text !== 'string') { + try { + text = JSON.stringify(text); + } catch { + text = String(text); + } + } // 1. http link text = text.replace(/(?<=https?:\/\/)[^\s]+/g, 'xxx'); // 2. nx-xxx 全部替换成xxx diff --git a/packages/global/common/system/config/constants.ts b/packages/global/common/system/config/constants.ts index f679e3c9cf93..327b2f0fd262 100644 --- a/packages/global/common/system/config/constants.ts +++ b/packages/global/common/system/config/constants.ts @@ -4,7 +4,8 @@ export enum SystemConfigsTypeEnum { systemMsgModal = 'systemMsgModal', license = 'license', operationalAd = 'operationalAd', - activityAd = 'activityAd' + activityAd = 'activityAd', + sf_lic = 'sf_lic' } export const SystemConfigsTypeMap = { @@ -25,5 +26,8 @@ export const SystemConfigsTypeMap = { }, [SystemConfigsTypeEnum.activityAd]: { label: 'activityAd' + }, + [SystemConfigsTypeEnum.sf_lic]: { + label: 'sf_lic' } }; diff --git a/packages/global/common/system/constants.ts b/packages/global/common/system/constants.ts index c7e39564f11c..e58ee2426b05 100644 --- a/packages/global/common/system/constants.ts +++ b/packages/global/common/system/constants.ts @@ -1,11 +1,16 @@ export const HUMAN_ICON = `/icon/human.svg`; export const LOGO_ICON = `/icon/logo.svg`; +export const SANGFOR_LOGO_ICON = `/icon/sangfor-logo.svg`; export const HUGGING_FACE_ICON = `/imgs/model/huggingface.svg`; export const DEFAULT_TEAM_AVATAR = `/imgs/avatar/defaultTeamAvatar.svg`; export const DEFAULT_ORG_AVATAR = '/imgs/avatar/defaultOrgAvatar.svg'; export const DEFAULT_USER_AVATAR = '/imgs/avatar/BlueAvatar.svg'; +export const DEFAULT_SYSTEM_TITLE = 'SANGFOR Agent Builder'; +export const DEFAULT_SYSTEM_SHORT_NAME = 'Agent Builder'; +export const AGENT_BUILDER_VERSION = 'V1.4.0_P'; + export const isDevEnv = process.env.NODE_ENV === 'development'; export const isProduction = process.env.NODE_ENV === 'production'; export const isTestEnv = process.env.NODE_ENV === 'test'; diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index af7f534d1de8..ca3bc7293ad1 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -8,6 +8,7 @@ import type { RerankModelItemType } from '../../../core/ai/model.schema'; import { SubTypeEnum } from '../../../support/wallet/sub/constants'; +import type { small2bigConfigType } from '../../../core/dataset/type'; export type NavbarItemType = { id: string; @@ -64,7 +65,8 @@ export type FastGPTFeConfigsType = { show_coupon?: boolean; show_discount_coupon?: boolean; showWecomConfig?: boolean; - + show_user_operations?: boolean; + agent_users?: string[]; show_dataset_feishu?: boolean; show_dataset_yuque?: boolean; show_publish_feishu?: boolean; @@ -76,6 +78,9 @@ export type FastGPTFeConfigsType = { show_dataset_enhance?: boolean; show_batch_eval?: boolean; + show_evaluation?: boolean; + show_dataset_search_params?: boolean; + show_direct_database?: boolean; concatMd?: string; docUrl?: string; @@ -86,6 +91,12 @@ export type FastGPTFeConfigsType = { customSharePageDomain?: string; systemTitle?: string; + systemShortName?: string; + systemLogo?: string; + systemBackground?: string; + showProtocol?: boolean; + serviceProtocol?: string; + privacyProtocol?: string; scripts?: { [key: string]: string }[]; favicon?: string; @@ -109,8 +120,7 @@ export type FastGPTFeConfigsType = { limit?: { exportDatasetLimitMinutes?: number; websiteSyncLimitMinuted?: number; - agentSandboxMaxEditDebug?: number; - agentSandboxMaxSessionRuntime?: number; + agentSandboxMaxCount?: number; workflowParallelRunMaxConcurrency?: number; }; @@ -148,6 +158,8 @@ export type FastGPTFeConfigsType = { agentSandboxFree?: boolean; // Beta features show_skill?: boolean; + // Host-agent ops service + show_ops_service?: boolean; }; export type SystemEnvType = { @@ -155,8 +167,11 @@ export type SystemEnvType = { tokenWorkers: number; // token count max worker datasetParseMaxProcess: number; + datasetParseNonGpuMaxProcess?: number; // 非 GPU 文件解析最大并发数,默认 20 + gpuParseFileExtensions?: string[]; // 使用 GPU 解析队列的文件后缀列表,默认 ['.pdf'] vectorMaxProcess: number; qaMaxProcess: number; + hypeMaxProcess?: number; vlmMaxProcess: number; hnswEfSearch: number; @@ -166,8 +181,60 @@ export type SystemEnvType = { chatApiKey?: string; customPdfParse?: customPdfParseType; + langfuse?: LangfuseConfigType; fileUrlWhitelist?: string[]; customDomain?: customDomainType; + + // Evaluation configurations + evalConfig?: EvaluationConfigType; + + // Training configurations + trainConfig?: TrainingConfigType; + + // Correction search similarity threshold + correctionSimilarityThreshold?: number; + + // FAQ search similarity threshold + faqSimilarityThreshold?: number; + + // Assistant场景专用配置 + assistantRetrievalLimit?: number; // 检索结果数量限制 + assistantFinalResultLimit?: number; // 最终返回的检索结果数量限制 + + // Hype index parameters + hypeParams?: HypeParamsType; + + // Custom template import configuration + customTemplateImport?: { + modes?: Array<{ + name: string; + enabled?: boolean; + contentDetection?: { + // Placeholder for content detection rules + rules?: any[]; + }; + enhanceConfig?: { + autoIndexes?: boolean; + autoIndexesConfig?: { questionIndex?: boolean; summaryIndex?: boolean }; // 自动索引细粒度配置 + small2bigIndexes?: boolean; + hypeIndexes?: boolean; + hypeIndexPrompt?: string; + small2bigConfig?: small2bigConfigType; + autoIndexesPrompt?: string; + imageIndexPrompt?: string; + }; + }>; + defaultActivateMode?: string; // Name of default activate mode to use + }; + + // Custom file import configuration + customFileImport?: CustomFileImportConfigType; + + // Custom link import configuration + customLinkImport?: CustomLinkImportConfigType; + + // Dataset SQL query result limit (default: 100) + datasetSqlResultLimit?: number; }; export type customDomainType = { @@ -193,15 +260,152 @@ export type customDomainType = { }; }; +// 文件解析配置 +export type CustomFileParseConfigType = { + customPdfParse?: boolean; // PDF 增强解析 +}; + +// 分块配置 +export type CustomChunkConfigType = { + trainingType?: 'chunk' | 'qa'; + chunkTriggerType?: 'minSize' | 'maxSize' | 'forceChunk'; + chunkTriggerMinSize?: number; + chunkSettingMode?: 'auto' | 'custom'; + chunkSplitMode?: 'paragraph' | 'size' | 'char'; + paragraphChunkAIMode?: 'forbid' | 'auto' | 'force'; + paragraphChunkDeep?: number; + paragraphChunkMinSize?: number; + chunkSize?: number; + chunkSplitter?: string; + indexSize?: number; +}; + +// 增强配置 +export type CustomEnhanceConfigType = { + dataEnhanceCollectionName?: boolean; + imageIndex?: boolean; + autoIndexes?: boolean; + autoIndexesConfig?: { questionIndex?: boolean; summaryIndex?: boolean }; // 自动索引细粒度配置 + hypeIndexes?: boolean; + indexPrefixTitle?: boolean; + small2bigIndexes?: boolean; + small2bigConfig?: small2bigConfigType; +}; + +// Prompt 配置 +export type CustomPromptConfigType = { + autoIndexesPrompt?: string; + hypeIndexPrompt?: string; + imageIndexPrompt?: string; + qaPrompt?: string; +}; + +// 文件导入模式配置 +export type CustomFileImportModeType = { + name: string; + enabled?: boolean; + fileTypes?: string[]; // 适用的文件类型 + contentDetection?: { + rules?: any[]; // 内容探测规则(预留) + }; + parseConfig?: CustomFileParseConfigType; + chunkConfig?: CustomChunkConfigType; + enhanceConfig?: CustomEnhanceConfigType; + promptConfig?: CustomPromptConfigType; +}; + +// 文件导入总配置 +export type CustomFileImportConfigType = { + defaultActivateMode?: string; + modes?: CustomFileImportModeType[]; +}; + +// 链接导入模式配置 +export type CustomLinkImportModeType = { + name: string; + enabled?: boolean; + contentDetection?: { + rules?: any[]; + }; + parseConfig?: { + webPageSelector?: string; + }; + chunkConfig?: CustomChunkConfigType; + enhanceConfig?: CustomEnhanceConfigType; + promptConfig?: CustomPromptConfigType; +}; + +// 链接导入总配置 +export type CustomLinkImportConfigType = { + defaultActivateMode?: string; + modes?: CustomLinkImportModeType[]; +}; + +// 数据集索引配置类型 +export type DatasetIndexConfigType = { + file?: { + questionIndex?: boolean; // 问题索引(细粒度) + summaryIndex?: boolean; // 摘要索引(细粒度) + imageIndex?: boolean; // 图片索引 + }; + qa?: { + questionIndex?: boolean; // 问题索引(细粒度) + summaryIndex?: boolean; // 摘要索引(细粒度) + small2bigIndexes?: boolean; + }; + link?: { + questionIndex?: boolean; // 问题索引(细粒度) + summaryIndex?: boolean; // 摘要索引(细粒度) + imageIndex?: boolean; // 图片索引 + }; +}; + export type customPdfParseType = { url?: string; key?: string; + timeout?: number; + chunkTimeout?: number; doc2xKey?: string; textinAppId?: string; textinSecretCode?: string; price?: number; }; +export type LangfuseConfigType = { + secretKey?: string; + publicKey?: string; + baseUrl?: string; +}; + +export type EvaluationConfigType = { + taskConcurrency?: number; + caseConcurrency?: number; + caseMaxRetry?: number; + caseResultThreshold?: number; + summaryConcurrency?: number; + dataQualityConcurrency?: number; + datasetDataSynthesizeConcurrency?: number; + datasetSmartGenerateConcurrency?: number; + maxStalledCount?: number; +}; + +export type TrainingConfigType = { + taskConcurrency?: number; // Training task worker concurrency + dataGenerateConcurrency?: number; // Training data generation worker concurrency + maxStalledCount?: number; // Max stalled count for BullMQ workers +}; + +export type HypeParamsType = { + similarityThreshold: number; // use to judg semantic drift, CosineSimilarity(candicateQuery,top1Doc),top1Doc!=tarDoc + maxDeltaScore: number; // use to judg semantic drift, Score(Top1Doc) - Score(tarDoc) must be lower than this value + minDeltaScore: number; // use to judg semantic distinction,Score(tarDoc)- Score(nextDoc) must be lower than this value + expectRank: number; // tarDoc ranking must higher than this value + defaultScore: number; // Default score for non-retrieved targets with low top1 similarity (indicating that the candicate has a good dispersion/diversity) + topK: number; + rerank: boolean; + min_rerank_threshold: number; +}; + export type LicenseDataType = { startTime: string; expiredTime: string; @@ -211,6 +415,10 @@ export type LicenseDataType = { maxUsers?: number; // 最大用户数,不填默认不上限 maxApps?: number; // 最大应用数,不填默认不上限 maxDatasets?: number; // 最大数据集数,不填默认不上限 + maxEvaluationTaskAmount?: number; // 最大评估任务数,不填默认不上限 + maxEvalDatasetAmount?: number; // 最大评估数据集数,不填默认不上限 + maxEvalDatasetDataAmount?: number; // 最大评估数据集数据量,不填默认不上限 + maxEvalMetricAmount?: number; // 最大评估指标数,不填默认不上限 functions: { sso: boolean; pay: boolean; diff --git a/packages/global/common/system/utils.ts b/packages/global/common/system/utils.ts index 836451f640ab..70b622b90de6 100644 --- a/packages/global/common/system/utils.ts +++ b/packages/global/common/system/utils.ts @@ -5,15 +5,25 @@ export const delay = (ms: number) => }, ms); }); -export const retryFn = async (fn: () => Promise, attempts = 3): Promise => { +export const retryFn = async (fn: () => Promise, attempts = 3, delayMs = 500): Promise => { + const initialAttempts = attempts; while (true) { try { return await fn(); } catch (error) { + const currentAttempt = initialAttempts - attempts + 1; + const errMsg = error instanceof Error ? error.message : String(error); + console.error(`[retryFn] Attempt ${currentAttempt}/${initialAttempts} failed: ${errMsg}`, { + error: errMsg, + stack: error instanceof Error ? error.stack : undefined, + remainingAttempts: attempts, + delayMs + }); if (attempts <= 0) { + console.error(`[retryFn] All ${initialAttempts} attempts exhausted, rejecting`); return Promise.reject(error); } - await delay(500); + await delay(delayMs); attempts--; } } diff --git a/packages/global/core/agentSkills/constants.ts b/packages/global/core/agentSkills/constants.ts index 042d42f41a42..f88f2a9f041e 100644 --- a/packages/global/core/agentSkills/constants.ts +++ b/packages/global/core/agentSkills/constants.ts @@ -91,12 +91,6 @@ export enum AgentSkillTypeEnum { export const AgentSkillFolderTypeList = [AgentSkillTypeEnum.folder]; -// Sandbox types -export enum SandboxTypeEnum { - editDebug = 'edit-debug', - sessionRuntime = 'session-runtime' -} - // Sandbox status states export enum SandboxStateEnum { pending = 'Pending', diff --git a/packages/global/core/agentSkills/type.ts b/packages/global/core/agentSkills/type.ts index c95a70636bcf..85ee75f8c496 100644 --- a/packages/global/core/agentSkills/type.ts +++ b/packages/global/core/agentSkills/type.ts @@ -1,10 +1,9 @@ -import { z } from 'zod'; +import z from 'zod'; import { AgentSkillSourceEnum, AgentSkillCategoryEnum, AgentSkillTypeEnum, - SandboxProtocolEnum, - SandboxTypeEnum + SandboxProtocolEnum } from './constants'; import { SandboxStatusEnum } from '../ai/sandbox/constants'; @@ -18,7 +17,6 @@ export const AgentSkillSourceSchema = z.enum(AgentSkillSourceEnum); export const AgentSkillCategorySchema = z.enum(AgentSkillCategoryEnum); export const AgentSkillTypeSchema = z.enum(AgentSkillTypeEnum); export const SandboxProtocolSchema = z.enum(SandboxProtocolEnum); -export const SandboxTypeSchema = z.enum(SandboxTypeEnum); export const SandboxStatusSchema = z.enum([ SandboxStatusEnum.running, SandboxStatusEnum.stopped @@ -184,7 +182,6 @@ export const SandboxStorageSchema = AgentSkillStorageSchema.extend({ }); export const SandboxInstanceDetailSchema = z.object({ - sandboxType: SandboxTypeSchema, teamId: z.string(), tmbId: z.string(), skillId: z.string().optional(), diff --git a/packages/global/core/ai/constants.ts b/packages/global/core/ai/constants.ts index 6f620cfe8b3c..f6d7de6e70ec 100644 --- a/packages/global/core/ai/constants.ts +++ b/packages/global/core/ai/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; import type { CompletionUsage } from './llm/type'; import type { LLMModelItemType, EmbeddingModelItemType, STTModelType } from './model.schema'; @@ -18,8 +18,10 @@ export enum ModelTypeEnum { rerank = 'rerank' } +// Fallback defaults used only when no models are loaded from plugins or DB export const defaultQAModels: LLMModelItemType[] = [ { + id: 'default-llm-gpt5', type: ModelTypeEnum.llm, provider: 'OpenAI', model: 'gpt-5', @@ -40,6 +42,7 @@ export const defaultQAModels: LLMModelItemType[] = [ export const defaultVectorModels: EmbeddingModelItemType[] = [ { + id: 'default-embedding-3-small', type: ModelTypeEnum.embedding, provider: 'OpenAI', model: 'text-embedding-3-small', @@ -47,12 +50,14 @@ export const defaultVectorModels: EmbeddingModelItemType[] = [ charsPointsPrice: 0, defaultToken: 500, maxToken: 3000, - weight: 100 + weight: 100, + batchSize: 10 } ]; export const defaultSTTModels: STTModelType[] = [ { + id: 'default-stt-whisper', type: ModelTypeEnum.stt, provider: 'OpenAI', model: 'whisper-1', @@ -98,3 +103,26 @@ export const completionFinishReasonMap = { function_call: i18nT('chat:completion_finish_function_call'), null: i18nT('chat:completion_finish_null') }; + +// 千问模型内置指令 +export const QWEN_EMBEDDING_RERANK_INSTRUCTION = + 'Given a web search query, retrieve relevant passages that answer the query'; + +/** + * 若模型为 Qwen3 系列的向量或重排模型(前缀匹配 Qwen3-Embedding / Qwen3-Reranker), + * 返回内置的默认指令,否则返回 undefined。 + * 该指令用于在查询时附加任务描述,提升检索效果。 + */ +export function getQwenEmbeddingRerankInstruction( + type: ModelTypeEnum, + model: string +): string | undefined { + const modelId = model.trim(); + if (type === ModelTypeEnum.embedding && /^Qwen3-Embedding/i.test(modelId)) { + return QWEN_EMBEDDING_RERANK_INSTRUCTION; + } + if (type === ModelTypeEnum.rerank && /^Qwen3-Reranker/i.test(modelId)) { + return QWEN_EMBEDDING_RERANK_INSTRUCTION; + } + return undefined; +} diff --git a/packages/global/core/ai/llm/type.ts b/packages/global/core/ai/llm/type.ts index f4ab83609b22..a8f2598801aa 100644 --- a/packages/global/core/ai/llm/type.ts +++ b/packages/global/core/ai/llm/type.ts @@ -240,7 +240,19 @@ export type CompletionFinishReason = z.infer; const BaseModelItemSchema = z.object({ + id: z.string(), provider: z.string(), model: z.string(), name: z.string(), @@ -41,6 +42,7 @@ const BaseModelItemSchema = z.object({ isActive: z.boolean().optional(), isCustom: z.boolean().optional(), + isTuned: z.boolean().optional(), // Whether this is a fine-tuned model created by training module isDefault: z.boolean().optional(), // If has requestUrl, it will request the model directly @@ -48,16 +50,21 @@ const BaseModelItemSchema = z.object({ requestAuth: z.string().optional(), // Test mode: when enabled, classify/extract/tool call/evaluation scenarios are disabled - testMode: z.boolean().optional() // test mode flag + testMode: z.boolean().optional(), // test mode flag + + // Permission fields + tmbId: z.string().optional(), + teamId: z.string().optional(), + isShared: z.boolean().optional() }); type BaseModelItemType = z.infer; export const LLMModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.shape).extend({ type: z.literal(ModelTypeEnum.llm), // Model params - maxContext: z.number(), - maxResponse: z.number(), - quoteMaxToken: z.number(), + maxContext: z.number().default(16000), + maxResponse: z.number().default(8000), + quoteMaxToken: z.number().default(8000), maxTemperature: z.number().optional(), showTopP: z.boolean().optional(), @@ -68,8 +75,8 @@ export const LLMModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.sha vision: z.boolean().optional(), reasoning: z.boolean().optional(), - functionCall: z.boolean(), - toolChoice: z.boolean(), + functionCall: z.boolean().default(true), + toolChoice: z.boolean().default(true), defaultSystemChatPrompt: z.string().optional(), defaultConfig: z.record(z.string(), z.any()).optional(), @@ -78,6 +85,7 @@ export const LLMModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.sha // LLM isDefaultDatasetTextModel: z.boolean().optional(), isDefaultDatasetImageModel: z.boolean().optional(), + isDefaultEvaluationModel: z.boolean().optional(), isDefaultHelperBotModel: z.boolean().optional(), /** @deprecated */ @@ -95,27 +103,34 @@ export type LLMModelItemType = z.infer; export const EmbeddingModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.shape).extend({ type: z.literal(ModelTypeEnum.embedding), - defaultToken: z.number(), // split text default token - maxToken: z.number(), // model max token - weight: z.number(), // training weight + defaultToken: z.number().default(512), // split text default token + maxToken: z.number().default(512), // model max token + weight: z.number().default(0), // training weight hidden: z.boolean().optional(), // Disallow creation normalization: z.boolean().optional(), // normalization processing - batchSize: z.number().optional(), // batch request size + dimensions: z.number().optional(), // vector dimensions (e.g. 1536 for ada-002, 3072 for text-embedding-3-large) + batchSize: z.number().min(1), // batch request size defaultConfig: z.record(z.string(), z.any()).optional(), // post request config dbConfig: z.record(z.string(), z.any()).optional(), // Custom parameters for storage - queryConfig: z.record(z.string(), z.any()).optional() // Custom parameters for query + queryConfig: z.record(z.string(), z.any()).optional(), // Custom parameters for query + instruction: z.string().optional(), // Instruction for instruction-aware models (e.g. qwen3-embedding) + supportTrain: z.boolean().optional(), // Whether the model supports training + trainTaskList: z.array(z.any()).optional() // Runtime: EmbeddingTrainTaskListItem[], injected for isTuned models }); export type EmbeddingModelItemType = z.infer; export const RerankModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.shape).extend({ type: z.literal(ModelTypeEnum.rerank), - maxToken: z.number().optional() // max input token for rerank query + one document + maxToken: z.number().default(3000), // max input token for rerank query + one document + instruction: z.string().optional(), // Instruction for instruction-aware models + supportTrain: z.boolean().optional(), // Whether the model supports training + trainTaskList: z.array(z.any()).optional() // Runtime: RerankTrainTaskListItem[], injected for isTuned models }); export type RerankModelItemType = z.infer; export const TTSModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.shape).extend({ type: z.literal(ModelTypeEnum.tts), - voices: z.array(z.object({ label: z.string(), value: z.string() })) + voices: z.array(z.object({ label: z.string(), value: z.string() })).default([]) }); export type TTSModelType = z.infer; diff --git a/packages/global/core/ai/model.ts b/packages/global/core/ai/model.ts new file mode 100644 index 000000000000..1293bcf6e2ee --- /dev/null +++ b/packages/global/core/ai/model.ts @@ -0,0 +1,73 @@ +import { i18nT } from '../../common/i18n/utils'; +import type { LLMModelItemType, STTModelType, EmbeddingModelItemType } from './model.schema'; + +export enum ModelTypeEnum { + llm = 'llm', + embedding = 'embedding', + tts = 'tts', + stt = 'stt', + rerank = 'rerank' +} + +export const defaultQAModels: LLMModelItemType[] = [ + { + id: 'default-llm-gpt5', + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'gpt-5', + name: 'gpt-5', + maxContext: 16000, + maxResponse: 16000, + quoteMaxToken: 13000, + maxTemperature: 1.2, + charsPointsPrice: 0, + censor: false, + vision: true, + datasetProcess: true, + toolChoice: true, + functionCall: false, + defaultSystemChatPrompt: '', + defaultConfig: {} + } +]; + +export const defaultVectorModels: EmbeddingModelItemType[] = [ + { + id: 'default-embedding-3-small', + type: ModelTypeEnum.embedding, + provider: 'OpenAI', + model: 'text-embedding-3-small', + name: 'Embedding-2', + charsPointsPrice: 0, + defaultToken: 500, + maxToken: 3000, + weight: 100, + batchSize: 10 + } +]; + +export const defaultSTTModels: STTModelType[] = [ + { + id: 'default-stt-whisper', + type: ModelTypeEnum.stt, + provider: 'OpenAI', + model: 'whisper-1', + name: 'whisper-1', + charsPointsPrice: 0 + } +]; + +export const getModelFromList = ( + list: T[], + modelId: string +): T | undefined => { + return list.find((item) => item.id === modelId); +}; + +export const modelTypeList = [ + { label: i18nT('common:model.type.chat'), value: ModelTypeEnum.llm }, + { label: i18nT('common:model.type.embedding'), value: ModelTypeEnum.embedding }, + { label: i18nT('common:model.type.tts'), value: ModelTypeEnum.tts }, + { label: i18nT('common:model.type.stt'), value: ModelTypeEnum.stt }, + { label: i18nT('common:model.type.reRank'), value: ModelTypeEnum.rerank } +]; diff --git a/packages/global/core/ai/prompt/AIChat.ts b/packages/global/core/ai/prompt/AIChat.ts index d701ce9e6c0e..1d4c68fa2d96 100644 --- a/packages/global/core/ai/prompt/AIChat.ts +++ b/packages/global/core/ai/prompt/AIChat.ts @@ -1,7 +1,9 @@ /* v8 ignore file */ import { type PromptTemplateItem } from '../llm/type'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { getPromptByVersion } from './utils'; +import { LangEnum } from '../../../common/i18n/type'; +import { getLang } from '../../../common/i18n/utils'; export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ { @@ -12,6 +14,10 @@ export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ 你是一个知识库回答助手,可以使用 中的内容作为你本次回答的参考。 同时,为了使回答结果更加可信并且可追溯,你需要在每段话结尾添加引用标记,标识参考了哪些内容。 +## 词汇映射说明 + +{{synonymMappings}} + ## 追溯展示规则 - 使用 [id](CITE) 的格式来引用 中的知识,其中 CITE 是固定常量, id 为引文中的 id。 @@ -47,6 +53,10 @@ export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ ['4.9.7']: `## 任务描述 作为一个问答助手,你会使用 标记中的提供的数据对进行内容回答。 +## 词汇映射说明 + +{{synonymMappings}} + ## 回答要求 - 选择其中一个或多个问答对进行回答。 - 回答的内容应尽可能与 中的内容一致。 @@ -74,6 +84,10 @@ export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ 你是一个知识库回答助手,可以使用 中的内容作为你本次回答的参考。 同时,为了使回答结果更加可信并且可追溯,你需要在每段话结尾添加引用标记,标识参考了哪些内容。 +## 词汇映射说明 + +{{synonymMappings}} + ## 追溯展示规则 - 使用 [id](CITE) 的格式来引用 中的知识,其中 CITE 是固定常量, id 为引文中的 id。 @@ -113,6 +127,10 @@ export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ ['4.9.7']: `## 任务描述 作为一个问答助手,你会使用 标记中的提供的数据对进行内容回答。 +## 词汇映射说明 + +{{synonymMappings}} + ## 回答要求 - 选择其中一个或多个问答对进行回答。 - 回答的内容应尽可能与 中的内容一致。 @@ -138,6 +156,161 @@ export const Prompt_userQuotePromptList: PromptTemplateItem[] = [ } ]; +export const Prompt_userQuotePromptList_EN: PromptTemplateItem[] = [ + { + title: i18nT('app:template.standard_template'), + desc: '', + value: { + ['4.9.7']: `## Task Description +You are a knowledge base assistant. You may use the content within as reference for your response. +To ensure credibility and traceability, you must append citation markers at the end of each paragraph to indicate which references were used. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Citation Rules + +- Use the format [id](CITE) to cite content from , where CITE is a fixed constant and id is the actual citation ID. +- Integrate citations naturally at the **end of each paragraph**. Example: "Nginx is a lightweight web server and reverse proxy server[67e517e74767063e882d6861](CITE)." +- Each paragraph **must contain at least one citation**. For multiple citations, list them in order: "Nginx is a lightweight web server[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6862](CITE).\\nIt is known for its low resource usage[67e517e74767063e882d6863](CITE)." +- Do not treat examples as factual knowledge. +- Never fabricate IDs. All cited IDs must exist in ! + +## General Rules + +- If you are unsure of the answer, clarify that you don't know. +- Keep your answer consistent with the content in , but do not mention that your knowledge comes from . +- Use Markdown to format your response (especially for images, tables, lists), and output them completely and correctly. +- If an image is relevant, output only the image URL in Markdown format: [](url). Do not add alt text or description. +- Respond in the same language as the user's question. + + +{{quote}} + + +## User Question + +{{question}} + +## Response +` + } + }, + { + title: i18nT('app:template.qa_template'), + desc: '', + value: { + ['4.9.7']: `## Task Description +As a Q&A assistant, you will use the data pairs provided within to answer the question. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Response Requirements +- Select one or more relevant Q&A pairs to answer. +- Your response should closely match the content in . +- If no relevant Q&A pair exists, clarify that you cannot answer. +- Do not mention that your answer comes from ; just provide the answer directly. +- Respond in the same language as the user's question. + + +{{quote}} + + +## User Question + +{{question}} + +## Response +` + } + }, + { + title: i18nT('app:template.standard_strict'), + desc: '', + value: { + ['4.9.7']: `## Task Description +You are a knowledge base assistant. You may use the content within as reference for your response. +To enhance credibility and traceability, you must append citation markers at the end of each paragraph to indicate which sources were referenced. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Citation Rules + +- Use the format [id](CITE) to cite content from , where CITE is a fixed constant and id is the actual citation ID. +- Integrate citations naturally at the **end of each paragraph**. Example: "Nginx is a lightweight web server and reverse proxy server[67e517e74767063e882d6861](CITE)." +- Each paragraph **must contain at least one citation**. For multiple citations, list them in order: "Nginx is a lightweight web server[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6862](CITE).\\nIt is known for its low resource usage[67e517e74767063e882d6863](CITE)." +- Do not treat examples as factual knowledge. +- Never fabricate IDs. All cited IDs must exist in . + +## General Rules + +- If you are uncertain about the answer, clarify that you don't know. +- Keep your answer consistent with the content in , but avoid mentioning that your knowledge comes from . +- Use Markdown syntax to format your response. Pay special attention to images, tables, and lists—output them completely and correctly. +- If a relevant image is available, output only the image URL in Markdown format: [](url). Do not include alt text or descriptions. +- Respond in the same language as the user's question. + +## Strict Requirement + +You may ONLY use content from as your knowledge source. Do not use your own knowledge, and your response must strictly match the content within . + + +{{quote}} + + +## User Question + +{{question}} + +## Response +` + } + }, + { + title: i18nT('app:template.hard_strict'), + desc: '', + value: { + ['4.9.7']: `## Task Description +As a Q&A assistant, you will answer questions using the data pairs provided within . + +## Vocabulary Mapping + +{{synonymMappings}} + +## Response Requirements +- Select one or more relevant Q&A pairs to formulate your answer. +- Your response must closely match the content inside . +- If no relevant Q&A pair exists, clarify that you cannot answer. +- Do not mention that your answer is based on ; simply provide the answer directly. +- Respond in the same language as the user's question. + +## Strict Requirement + +You may ONLY use content from as your knowledge source. Do not use your own knowledge, and your response must strictly match the content within . + + +{{quote}} + + +## User Question + +{{question}} + +## Response +` + } + } +]; + +export const get_userQuotePromptList = () => { + return getLang() === LangEnum.en ? Prompt_userQuotePromptList_EN : Prompt_userQuotePromptList; +}; + export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ { title: i18nT('app:template.standard_template'), @@ -147,6 +320,10 @@ export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ 你是一个知识库回答助手,可以使用 中的内容作为你本次回答的参考。 同时,为了使回答结果更加可信并且可追溯,你需要在每段话结尾添加引用标记,标识参考了哪些内容。 +## 词汇映射说明 + +{{synonymMappings}} + ## 追溯展示规则 - 使用 [id](CITE) 的格式来引用 中的知识,其中 CITE 是固定常量, id 为引文中的 id。 @@ -175,6 +352,10 @@ export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ ['4.9.8']: `## 任务描述 作为一个问答助手,你会使用 标记中的提供的数据对进行内容回答。 +## 词汇映射说明 + +{{synonymMappings}} + ## 回答要求 - 选择其中一个或多个问答对进行回答。 - 回答的内容应尽可能与 中的内容一致。 @@ -195,6 +376,10 @@ export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ 你是一个知识库回答助手,可以使用 中的内容作为你本次回答的参考。 同时,为了使回答结果更加可信并且可追溯,你需要在每段话结尾添加引用标记,标识参考了哪些内容。 +## 词汇映射说明 + +{{synonymMappings}} + ## 追溯展示规则 - 使用 [id](CITE) 的格式来引用 中的知识,其中 CITE 是固定常量, id 为引文中的 id。 @@ -227,6 +412,10 @@ export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ ['4.9.7']: `## 任务描述 作为一个问答助手,你会使用 标记中的提供的数据对进行内容回答。 +## 词汇映射说明 + +{{synonymMappings}} + ## 回答要求 - 选择其中一个或多个问答对进行回答。 - 回答的内容应尽可能与 中的内容一致。 @@ -245,6 +434,134 @@ export const Prompt_systemQuotePromptList: PromptTemplateItem[] = [ } ]; +export const Prompt_systemQuotePromptList_EN: PromptTemplateItem[] = [ + { + title: i18nT('app:template.standard_template'), + desc: '', + value: { + ['4.9.7']: `## Task Description +You are a knowledge base assistant. You may use the content within as reference for your response. + +To ensure credibility and traceability, you must append citation markers at the end of each paragraph to indicate which references were used. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Citation Rules + +- Use the format [id](CITE) to cite content from , where CITE is a fixed constant and id is the actual citation ID. +- Integrate citations naturally at the **end of each paragraph**. Example: "Nginx is a lightweight web server and reverse proxy server[67e517e74767063e882d6861](CITE)." +- Each paragraph **must contain at least one citation**. For multiple citations, list them in order: "Nginx is a lightweight web server[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6862](CITE).\\nIt is known for its low resource usage[67e517e74767063e882d6863](CITE)." +- Do not treat examples as factual knowledge. +- Never fabricate IDs. All cited IDs must exist in ! + +## General Rules + +- If you are unsure of the answer, clarify that you don't know. +- Keep your answer consistent with the content in , but do not mention that your knowledge comes from . +- Use Markdown to format your response (especially for images, tables, lists), and output them completely and correctly. +- If an image is relevant, output only the image URL in Markdown format: [](url). Do not add alt text or description. +- Respond in the same language as the user's question. + + +{{quote}} +` + } + }, + { + title: i18nT('app:template.qa_template'), + desc: '', + value: { + ['4.9.8']: `## Task Description +As a Q&A assistant, you will use the data pairs provided within to answer the question. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Response Requirements +- Select one or more relevant Q&A pairs to answer. +- Your response should closely match the content in . +- If no relevant Q&A pair exists, clarify that you cannot answer. +- Do not mention that your answer comes from ; just provide the answer directly. +- Respond in the same language as the user's question. + + +{{quote}} +` + } + }, + { + title: i18nT('app:template.standard_strict'), + desc: '', + value: { + ['4.9.7']: `## Task Description +You are a knowledge base assistant. You may use the content within as reference for your response. +To enhance credibility and traceability, you must append citation markers at the end of each paragraph to indicate which sources were referenced. + +## Vocabulary Mapping + +{{synonymMappings}} + +## Citation Rules + +- Use the format [id](CITE) to cite content from , where CITE is a fixed constant and id is the actual citation ID. +- Integrate citations naturally at the **end of each paragraph**. Example: "Nginx is a lightweight web server and reverse proxy server[67e517e74767063e882d6861](CITE)." +- Each paragraph **must contain at least one citation**. For multiple citations, list them in order: "Nginx is a lightweight web server[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6862](CITE).\\nIt is known for its low resource usage[67e517e74767063e882d6863](CITE)." +- Do not treat examples as factual knowledge. +- Never fabricate IDs. All cited IDs must exist in . + +## General Rules + +- If you are uncertain about the answer, clarify that you don't know. +- Keep your answer consistent with the content in , but avoid mentioning that your knowledge comes from . +- Use Markdown syntax to format your response. Pay special attention to images, tables, and lists—output them completely and correctly. +- If a relevant image is available, output only the image URL in Markdown format: [](url). Do not include alt text or descriptions. +- Respond in the same language as the user's question. + +## Strict Requirement + +You may ONLY use content from as your knowledge source. Do not use your own knowledge, and your response must strictly match the content within . + + +{{quote}} +` + } + }, + { + title: i18nT('app:template.hard_strict'), + desc: '', + value: { + ['4.9.7']: `## Task Description +As a Q&A assistant, you will answer questions using the data pairs provided within . + +## Vocabulary Mapping + +{{synonymMappings}} + +## Response Requirements +- Select one or more relevant Q&A pairs to formulate your answer. +- Your response must closely match the content inside . +- If no relevant Q&A pair exists, clarify that you cannot answer. +- Do not mention that your answer is based on ; simply provide the answer directly. +- Respond in the same language as the user's question. + +## Strict Requirement + +You may ONLY use content from as your knowledge source. Do not use your own knowledge, and your response must strictly match the content within . + + +{{quote}} +` + } + } +]; + +export const get_systemQuotePromptList = () => { + return getLang() === LangEnum.en ? Prompt_systemQuotePromptList_EN : Prompt_systemQuotePromptList; +}; + export const Prompt_QuoteTemplateList: PromptTemplateItem[] = [ { title: i18nT('app:template.standard_template'), @@ -305,7 +622,7 @@ export const getQuoteTemplate = (version?: string) => { export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user') => { const quotePromptTemplates = - role === 'user' ? Prompt_userQuotePromptList : Prompt_systemQuotePromptList; + role === 'user' ? get_userQuotePromptList() : get_systemQuotePromptList(); const defaultTemplate = quotePromptTemplates[0].value; @@ -314,7 +631,7 @@ export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user // Document quote prompt export const getDocumentQuotePrompt = (version?: string) => { - const promptMap = { + const promptMapZh = { ['4.9.7']: `将 中的内容作为本次对话的参考: {{quote}} @@ -322,5 +639,15 @@ export const getDocumentQuotePrompt = (version?: string) => { ` }; + const promptMapEn = { + ['4.9.7']: `Use the content within as reference for this conversation: + +{{quote}} + +` + }; + + const promptMap = getLang() === LangEnum.en ? promptMapEn : promptMapZh; + return getPromptByVersion(version, promptMap); }; diff --git a/packages/global/core/ai/prompt/agent.ts b/packages/global/core/ai/prompt/agent.ts index 12f77fa59094..91805040bb16 100644 --- a/packages/global/core/ai/prompt/agent.ts +++ b/packages/global/core/ai/prompt/agent.ts @@ -1,3 +1,6 @@ +import { LangEnum } from '../../../common/i18n/type'; +import { getLang } from '../../../common/i18n/utils'; + export const Prompt_AgentQA = { description: ` 标记中是一段文本,学习和分析它,并整理学习成果: - 提出问题并给出每个问题的答案。 @@ -40,7 +43,7 @@ export const getExtractJsonPrompt = ({ systemPrompt ? '【背景知识】' : '', memory ? '【历史提取结果】' : '' ].filter(Boolean); - const prompt = `## 背景 + const promptZh = `## 背景 用户需要执行一个函数,该函数需要一些参数,需要你结合${list.join('、')},来生成对应的参数 ## 基本要求 @@ -72,7 +75,45 @@ ${schema} - 严格输出 json 字符串。 - 不要回答问题。`.replace(/\n{3,}/g, '\n\n'); - return prompt; + const listEn = [ + '【Conversation History】', + '【User Input】', + systemPrompt ? '【Background Knowledge】' : '', + memory ? '【Previous Extraction Result】' : '' + ].filter(Boolean); + const promptEn = `## Background +The user needs to execute a function that requires certain parameters. You must generate these parameters by considering ${listEn.join(', ')}. + +## Basic Requirements + +- Strictly generate parameters according to the JSON Schema description. +- Not all parameters are required. If a suitable value is unavailable, omit the parameter or use an empty string. +- Use conversation history to help generate appropriate parameters. + +${ + systemPrompt + ? `## Specific Requirements +${systemPrompt}` + : '' +} + +${ + memory + ? `## Previous Extraction Result +${memory}` + : '' +} + +## JSON Schema + +${schema} + +## Output Requirements + +- Output only a valid JSON string. +- Do not answer the question or add any extra text.`.replace(/\n{3,}/g, '\n\n'); + + return getLang() === LangEnum.en ? promptEn : promptZh; }; export const getExtractJsonToolPrompt = ({ systemPrompt, @@ -87,7 +128,7 @@ export const getExtractJsonToolPrompt = ({ systemPrompt ? '【背景知识】' : '', memory ? '【历史提取结果】' : '' ].filter(Boolean); - const prompt = `## 背景 + const promptZh = `## 背景 用户需要执行一个叫 "request_function" 的函数,该函数需要你结合${list.join('、')},来生成对应的参数 ## 基本要求 @@ -110,7 +151,36 @@ ${memory}` : '' }`.replace(/\n{3,}/g, '\n\n'); - return prompt; + const listEn = [ + '【Conversation History】', + '【User Input】', + systemPrompt ? '【Background Knowledge】' : '', + memory ? '【Previous Extraction Result】' : '' + ].filter(Boolean); + const promptEn = `## Background +The user needs to execute a function called "request_function". You must generate its parameters by considering ${listEn.join(', ')}. + +## Basic Requirements + +- Not all parameters are required. If a suitable value is unavailable, omit the parameter or use an empty string. +- Use conversation history to generate appropriate parameters, with the most recent messages taking higher priority. +- Even if the function cannot be called, you must return a JSON string, do not respond with natural language. + +${ + systemPrompt + ? `## Specific Requirements +${systemPrompt}` + : '' +} + +${ + memory + ? `## Previous Extraction Result +${memory}` + : '' +}`.replace(/\n{3,}/g, '\n\n'); + + return getLang() === LangEnum.en ? promptEn : promptZh; }; export const getCQSystemPrompt = ({ @@ -127,7 +197,7 @@ export const getCQSystemPrompt = ({ '【历史记录】', memory ? '【上一轮分类结果】' : '' ].filter(Boolean); - const CLASSIFY_QUESTION_SYSTEM_PROMPT = `## 角色 + const CLASSIFY_QUESTION_SYSTEM_PROMPT_ZH = `## 角色 你是一个"分类助手",可以结合${list.join('、')},来判断用户当前问题属于哪一个分类,并输出分类标记。 ${ @@ -158,16 +228,65 @@ ${typeList} 只需要输出分类的 id 即可,无需输出额外内容。`.replace(/\n{3,}/g, '\n\n'); - return CLASSIFY_QUESTION_SYSTEM_PROMPT; + const listEn = [ + systemPrompt ? '[Background Knowledge]' : '', + '[Conversation History]', + memory ? '[Previous Classification Result]' : '' + ].filter(Boolean); + + const CLASSIFY_QUESTION_SYSTEM_PROMPT_EN = `## Role +You are a "Classification Assistant" who can determine which category the user's current question belongs to by considering ${listEn.join(', ')}, and output the category ID. + +${ + systemPrompt + ? `## Background Knowledge +${systemPrompt}` + : '' +} + +${ + memory + ? `## Previous Classification Result +${memory}` + : '' +} + +## Category List + +${typeList} + +## Classification Rules + +1. The classification result must be selected from the category list. +2. In continuous conversations, if the category is unclear and the user has not changed the topic, retain the previous classification result. +3. In case of classification ambiguity or conflict, the category associated with the subject (main entity) takes higher priority. + +## Output Format + +Only output the category ID. Do not include any additional content.`.replace(/\n{3,}/g, '\n\n'); + + return getLang() === LangEnum.en + ? CLASSIFY_QUESTION_SYSTEM_PROMPT_EN + : CLASSIFY_QUESTION_SYSTEM_PROMPT_ZH; }; -export const QuestionGuidePrompt = `You are an AI assistant tasked with predicting the user's next question based on the conversation history. Your goal is to generate 3 potential questions that will guide the user to continue the conversation. When generating these questions, adhere to the following rules: +export const QuestionGuidePrompt = `You are an AI assistant tasked with predicting the user's next question based on the conversation history. Your goal is to analyze the conversation and output: -1. Use the same language as the user's last question in the conversation history. -2. Keep each question under 20 characters in length. +1. The language of the user's last question in the conversation history (e.g., "Chinese", "English", "Japanese", "French", etc.). +2. Generate 3 potential follow-up questions to guide the user to continue the conversation. -Analyze the conversation history provided to you and use it as context to generate relevant and engaging follow-up questions. Your predictions should be logical extensions of the current topic or related areas that the user might be interested in exploring further. +Rules for generating questions: +- Use the same language as the user's last question in the conversation history. +- Keep each question under 20 characters in length. +- Questions should be logical extensions of the current topic or related areas the user might be interested in. -Remember to maintain consistency in tone and style with the existing conversation while providing diverse options for the user to choose from. Your goal is to keep the conversation flowing naturally and help the user delve deeper into the subject matter or explore related topics.`; +Analyze the conversation history and use it as context to generate relevant and engaging follow-up questions. Maintain consistency in tone and style with the existing conversation while providing diverse options. Your goal is to keep the conversation flowing naturally and help the user delve deeper into the subject matter or explore related topics.`; -export const QuestionGuideFooterPrompt = `Please strictly follow the format rules: \nReturn questions in JSON format: ['Question 1', 'Question 2', 'Question 3']. Your output: `; +export const QuestionGuideFooterPrompt = `Please strictly follow the format rules: +Return a JSON object with the detected language and the 3 questions: +{ + "questionPreview": "The first 5 characters of the user's last question", + "language": "The detected language of the user's last question", + "questions": ["Question 1", "Question 2", "Question 3"] +} +Your output: `; diff --git a/packages/global/core/ai/prompt/dataset.const.ts b/packages/global/core/ai/prompt/dataset.const.ts index c40187834dff..664d09c1d060 100644 --- a/packages/global/core/ai/prompt/dataset.const.ts +++ b/packages/global/core/ai/prompt/dataset.const.ts @@ -1,4 +1,26 @@ +import { LangEnum } from '../../../common/i18n/type'; +import { getLang } from '../../../common/i18n/utils'; export const getDatasetSearchToolResponsePrompt = () => { + if (getLang() === LangEnum.en) { + return `## Role +You are a knowledge base assistant. You may use the content in "cites" as reference for this conversation. To ensure credibility and traceability, you must append citation markers at the end of each paragraph to indicate which sources were referenced. + +## Citation Rules + +- Use the format **[id](CITE)** to cite knowledge from "cites", where CITE is a fixed constant and id is the actual citation ID. +- Integrate citations naturally at the **end of each paragraph**. Example: "Nginx is a lightweight web server and reverse proxy server[67e517e74767063e882d6861](CITE)." +- Each paragraph **must contain at least one citation**. For multiple citations, list them in order: "Nginx is a lightweight web server[67e517e74767063e882d6861](CITE)[67e517e74767063e882d6862](CITE).\\nIt is known for its low resource usage[67e517e74767063e882d6863](CITE)." +- Do not treat examples as factual knowledge. +- Never fabricate IDs. All cited IDs must exist in "cites"! + +## General Rules +- If you are unsure of the answer, clarify that you don't know. +- Do not mention that your knowledge comes from "cites". +- Keep your answer consistent with the content in "cites". +- Use Markdown syntax to format your response. Pay special attention to images, tables, and lists—output them completely and correctly. +- Respond in the same language as the user's question.`; + } + return `## Role 你是一个知识库回答助手,可以 "cites" 中的内容作为本次对话的参考。为了使回答结果更加可信并且可追溯,你需要在每段话结尾添加引用标记,标识参考了哪些内容。 @@ -16,4 +38,4 @@ export const getDatasetSearchToolResponsePrompt = () => { - 保持答案与 "cites" 中描述的一致。 - 使用 Markdown 语法优化回答格式。尤其是图片、表格、序列号等内容,需严格完整输出。 - 使用与问题相同的语言回答。`; -}; +}; \ No newline at end of file diff --git a/packages/global/core/ai/prompt/eval.ts b/packages/global/core/ai/prompt/eval.ts new file mode 100644 index 000000000000..a2c8b4c58f57 --- /dev/null +++ b/packages/global/core/ai/prompt/eval.ts @@ -0,0 +1,144 @@ +// Simplified Chinese +const problemAnalysisTemplateZhCN = `你是一名问题诊断专家,专注于分析AI系统的缺陷和错误模式,假设具备足够的领域知识。请使用{language}回复用户的问题 +##任务 +基于评估原因,对AI表现中暴露的核心问题进行诊断,识别关键错误类型和频发模式。 + +##输出要求 +1. 控制在150字以内,突出最重要的缺陷发现,直接返回正文,不能携带标题 +2. 明确指出错误类型、表现模式和影响 +3. 结合评估原因进行具体诊断,避免笼统描述 +4. 输出应具备洞察力和价值,而非简单复述,但无需给优化建议 +5. 返回总结用户能够了解评估数据的概况 + +##参考示例 +{example} + +##评估数据 +{evaluation_result_for_single_metric} + +开始返回问题分析总结:`; + +const strengthAnalysisTemplateZhCN = `你是一名优势分析专家,专注于识别AI系统的优秀表现和成功模式,假设具备足够的领域知识。请使用{language}回复用户的问题 +##任务 +基于评估原因,总结AI在本次表现中的优势,提炼可复制的成功因子。 + +##输出要求 +1. 控制在150字以内,突出最关键的优势,直接返回正文,不用携带标题 +2. 明确描述优势的具体表现和成功模式 +3. 强调可推广的优点和最佳实践 +4. 基于评估原因提炼洞察,避免空洞或泛泛而谈 +5. 返回总结用户能够了解评估数据的概况 + +##参考示例 +{example} + +##评估数据 +{evaluation_result_for_single_metric} + +开始返回优势分析总结:`; + +const goodExampleZhCN = `回答准确性高,逻辑条理清晰,能正确理解用户意图并给出相关解答。在复杂问题处理上展现出良好的上下文理解与知识整合能力,优势主要体现在逻辑推理严谨、信息提取完整、回答结构化程度高。`; + +const badExampleZhCN = `存在理解偏差、信息遗漏和逻辑错误。如xx表现体现复杂指令理解不足,常忽略关键要求;知识整合不连贯,导致答案不全面;多步骤推理中出现逻辑跳跃。这些问题暴露出指令解析和推理能力的缺陷。`; + +// Traditional Chinese +const problemAnalysisTemplateZhTW = `你是一名問題診斷專家,專注於分析AI系統的缺陷和錯誤模式,假設具備足夠的領域知識。請使用 {language} 回覆使用者的問題 +##任務 +基於評估原因,對AI表現中暴露的核心問題進行診斷,識別關鍵錯誤類型和頻發模式。 + +##輸出要求 +1. 控制在150字以內,突出最重要的缺陷發現,直接返回正文,不能攜帶標題 +2. 明確指出錯誤類型、表現模式和影響 +3. 結合評估原因進行具體診斷,避免籠統描述 +4. 輸出應具備洞察力和價值,而非簡單複述,但無需給優化建議 +5. 返回總結用戶能夠了解評估數據的概況 + +##參考示例 +{example} + +##評估數據 +{evaluation_result_for_single_metric} + +開始返回問題分析總結:`; + +const strengthAnalysisTemplateZhTW = `你是一名優勢分析專家,專注於識別AI系統的優秀表現和成功模式,假設具備足夠的領域知識。請使用 {language} 回覆使用者的問題 +##任務 +基於評估原因,總結AI在本次表現中的優勢,提煉可複製的成功因子。 + +##輸出要求 +1. 控制在150字以內,突出最關鍵的優勢,直接返回正文,不用攜帶標題 +2. 明確描述優勢的具體表現和成功模式 +3. 強調可推廣的優點和最佳實踐 +4. 基於評估原因提煉洞察,避免空洞或泛泛而談 +5. 返回總結用戶能夠了解評估數據的概況 + +##參考示例 +{example} + +##評估數據 +{evaluation_result_for_single_metric} + +開始返回優勢分析總結:`; + +const goodExampleZhTW = `回答準確性高,邏輯條理清晰,能正確理解用戶意圖並給出相關解答。在複雜問題處理上展現出良好的上下文理解與知識整合能力,優勢主要體現在邏輯推理嚴謹、資訊提取完整、回答結構化程度高。`; + +const badExampleZhTW = `存在理解偏差、資訊遺漏和邏輯錯誤。如xx表現體現複雜指令理解不足,常忽略關鍵要求;知識整合不連貫,導致答案不全面;多步驟推理中出現邏輯跳躍。這些問題暴露出指令解析和推理能力的缺陷。`; + +// English +const problemAnalysisTemplateEn = `You are a problem diagnosis expert focused on analyzing AI system defects and error patterns, assuming sufficient domain knowledge.Please use {language} to reply to the user's question +##Task +Based on evaluation reasons, diagnose core problems exposed in AI performance, identifying key error types and frequent patterns. + +##Output Requirements +1. Keep within 150 words, highlight the most critical defect findings, return the main text directly without titles +2. Clearly identify error types, behavior patterns, and impacts +3. Provide specific diagnosis based on evaluation reasons, avoiding vague descriptions +4. Output should be insightful and valuable, not merely repetitive, but no optimization suggestions needed +5. Return a summary that helps users understand the evaluation data overview + +##Reference Example +{example} + +##Evaluation Data +{evaluation_result_for_single_metric} + +Begin returning problem analysis summary:`; + +const strengthAnalysisTemplateEn = `You are a strength analysis expert focused on identifying excellent AI system performance and success patterns, assuming sufficient domain knowledge.Please use {language} to reply to the user's question +##Task +Based on evaluation reasons, summarize AI strengths in this performance, extract replicable success factors. + +##Output Requirements +1. Keep within 150 words, highlight the most critical strengths, return the main text directly without titles +2. Clearly describe specific manifestations of strengths and success patterns +3. Emphasize generalizable advantages and best practices +4. Extract insights based on evaluation reasons, avoiding empty or vague statements +5. Return a summary that helps users understand the evaluation data overview + +##Reference Example +{example} + +##Evaluation Data +{evaluation_result_for_single_metric} + +Begin returning strength analysis summary:`; + +const goodExampleEn = `High answer accuracy, clear logical structure, correctly understands user intent and provides relevant answers. Demonstrates good contextual understanding and knowledge integration in complex problem handling. Strengths mainly reflected in rigorous logical reasoning, complete information extraction, and high degree of answer structuring.`; + +const badExampleEn = `Shows understanding bias, information omission and logical errors. Performance in xx indicates insufficient understanding of complex instructions, often ignoring key requirements; inconsistent knowledge integration leads to incomplete answers; logical jumps occur in multi-step reasoning. These problems expose deficiencies in instruction parsing and reasoning capabilities.`; + +// Exports +export { + problemAnalysisTemplateZhCN, + strengthAnalysisTemplateZhCN, + goodExampleZhCN, + badExampleZhCN, + problemAnalysisTemplateZhTW, + strengthAnalysisTemplateZhTW, + goodExampleZhTW, + badExampleZhTW, + problemAnalysisTemplateEn, + strengthAnalysisTemplateEn, + goodExampleEn, + badExampleEn +}; diff --git a/packages/global/core/ai/sandbox/constants.ts b/packages/global/core/ai/sandbox/constants.ts index a2ffbd5f5f4d..2cc7a8497a15 100644 --- a/packages/global/core/ai/sandbox/constants.ts +++ b/packages/global/core/ai/sandbox/constants.ts @@ -52,9 +52,9 @@ export const SANDBOX_SHELL_TOOL: ChatCompletionTool = { }; export const SANDBOX_READ_FILE_TOOL_NAME: I18nStringType = { - 'zh-CN': '虚拟机/获取文件链接', - 'zh-Hant': '虛擬機/獲取文件鏈接', - en: 'Sandbox/Get File URL' + 'zh-CN': '获取文件链接', + 'zh-Hant': '獲取文件鏈接', + en: 'Get File URL' }; export const SANDBOX_GET_FILE_URL_TOOL_NAME = 'sandbox_get_file_url'; export const SandboxGetFileUrlToolSchema = z.object({ @@ -64,7 +64,7 @@ export const SANDBOX_GET_FILE_URL_TOOL: ChatCompletionTool = { type: 'function', function: { name: SANDBOX_GET_FILE_URL_TOOL_NAME, - description: '从虚拟机读取指定文件,上传至云存储,返回 2 小时有效期的访问链接', + description: '从虚拟机读取指定文件,上传至云存储,返回 1 天有效期的访问链接', parameters: { type: 'object', properties: { @@ -84,6 +84,12 @@ export const SANDBOX_GET_FILE_URL_TOOL: ChatCompletionTool = { export const SANDBOX_TOOLS: ChatCompletionTool[] = [SANDBOX_SHELL_TOOL, SANDBOX_GET_FILE_URL_TOOL]; +export const SANDBOX_FILE_DISPLAY_TOOLS: Set = new Set([ + SANDBOX_TOOL_NAME, + 'sandbox_write_file', + 'sandbox_execute' +]); + export const SANDBOX_SYSTEM_PROMPT = `你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 ${SANDBOX_TOOL_NAME} 工具执行命令: - 预装:bash / python3 / node / bun / git / curl - 可自行安装软件包(apt / pip / npm) diff --git a/packages/global/core/ai/type.d.ts b/packages/global/core/ai/type.d.ts new file mode 100644 index 000000000000..f1af1ddea80d --- /dev/null +++ b/packages/global/core/ai/type.d.ts @@ -0,0 +1,110 @@ +import openai from 'openai'; +import type { + ChatCompletion as SdkChatCompletion, + ChatCompletionMessageToolCall, + ChatCompletionMessageParam as SdkChatCompletionMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionContentPart as SdkChatCompletionContentPart, + ChatCompletionUserMessageParam as SdkChatCompletionUserMessageParam, + ChatCompletionToolMessageParam as SdkChatCompletionToolMessageParam, + ChatCompletionAssistantMessageParam as SdkChatCompletionAssistantMessageParam, + ChatCompletionTool +} from 'openai/resources'; +import { ChatMessageTypeEnum } from './constants'; +import type { WorkflowInteractiveResponseType } from '../workflow/template/system/interactive/type'; +import type { Stream } from 'openai/streaming'; +export * from 'openai/resources'; + +// Extension of ChatCompletionMessageParam, Add file url type +export type ChatCompletionContentPartFile = { + type: 'file_url'; + name: string; + url: string; + key?: string; +}; +// Rewrite ChatCompletionContentPart, Add file type +export type ChatCompletionContentPart = + | (SdkChatCompletionContentPart & { key?: string }) + | ChatCompletionContentPartFile; +type CustomChatCompletionUserMessageParam = Omit & { + role: 'user'; + content: string | Array; +}; +type CustomChatCompletionToolMessageParam = SdkChatCompletionToolMessageParam & { + role: 'tool'; + name?: string; +}; +type CustomChatCompletionAssistantMessageParam = SdkChatCompletionAssistantMessageParam & { + role: 'assistant'; + interactive?: WorkflowInteractiveResponseType; +}; + +export type ChatCompletionMessageParam = ( + | Exclude< + SdkChatCompletionMessageParam, + | SdkChatCompletionUserMessageParam + | SdkChatCompletionToolMessageParam + | SdkChatCompletionAssistantMessageParam + > + | CustomChatCompletionUserMessageParam + | CustomChatCompletionToolMessageParam + | CustomChatCompletionAssistantMessageParam +) & { + reasoning_text?: string; + dataId?: string; + hideInUI?: boolean; +}; +export type SdkChatCompletionMessageParam = SdkChatCompletionMessageParam; + +/* ToolChoice and functionCall extension */ +export type ChatCompletionToolMessageParam = ChatCompletionToolMessageParam & { name: string }; +export type ChatCompletionAssistantToolParam = { + role: 'assistant'; + tool_calls: ChatCompletionMessageToolCall[]; +}; + +export type ChatCompletionMessageFunctionCall = + SdkChatCompletionAssistantMessageParam.FunctionCall & { + id?: string; + toolName?: string; + toolAvatar?: string; + }; + +// Stream response +export type StreamChatType = Stream; +export type UnStreamChatType = openai.Chat.Completions.ChatCompletion; + +// UnStream response +export type ChatCompletion = SdkChatCompletion & { + error?: any; +}; + +export type CompletionFinishReason = + | 'error' + | 'close' + | 'stop' + | 'length' + | 'tool_calls' + | 'content_filter' + | 'function_call' + | null + | undefined; + +export default openai; +export * from 'openai'; +export type { Stream }; + +// Synonym Mapping for Prompt +export type SynonymMappingForPrompt = { + standardizedTerm: string; // 标准词 + synonymTerms: string[]; // 同义词列表 + source: 'query' | 'chunk'; // 来源:查询词或chunk + chunkId?: string; // chunk来源时的ID +}; + +// Other +export type PromptTemplateItem = { + title: string; + desc: string; + value: Record; +}; diff --git a/packages/global/core/app/constants.ts b/packages/global/core/app/constants.ts index 6d586326f507..5d756ae3b1c4 100644 --- a/packages/global/core/app/constants.ts +++ b/packages/global/core/app/constants.ts @@ -5,6 +5,9 @@ import { type AppQGConfigType } from './type'; import type { AppFileSelectConfigType } from './type/config.schema'; +import { i18nT } from '../../common/i18n/utils'; +import { FlowNodeTemplateTypeEnum } from '../workflow/constants'; +import { type TemplateTypeSchemaType } from './type'; export enum AppTypeEnum { folder = 'folder', @@ -19,7 +22,8 @@ export enum AppTypeEnum { // deprecated tool = 'tool', - httpPlugin = 'httpPlugin' + httpPlugin = 'httpPlugin', + assistant = 'assistant' } export const AppFolderTypeList = [ @@ -33,7 +37,12 @@ export const ToolTypeList = [ AppTypeEnum.httpToolSet, AppTypeEnum.workflowTool ]; -export const AppTypeList = [AppTypeEnum.simple, AppTypeEnum.chatAgent, AppTypeEnum.workflow]; +export const AppTypeList = [ + AppTypeEnum.simple, + AppTypeEnum.chatAgent, + AppTypeEnum.workflow, + AppTypeEnum.assistant +]; export const defaultTTSConfig: AppTTSConfigType = { type: 'web' }; @@ -50,7 +59,7 @@ export const defaultWhisperConfig: AppWhisperConfigType = { export const defaultQGConfig: AppQGConfigType = { open: false, - model: 'gpt-5', + modelId: '', customPrompt: '' }; @@ -81,9 +90,74 @@ export enum AppTemplateTypeEnum { // special type contribute = 'contribute' } +export const AssistantGlobalVarKey = { + FAQ_ANSWER_MODE: 'utjZSg8f', + FALLBACK_REPLY: 'byG7WNk4', + ENABLE_FALLBACK_REPLY: 'vKEVhtS6' +} as const; + +export const workflowSystemNodeTemplateList: { + type: string; + label: string; +}[] = [ + { + type: FlowNodeTemplateTypeEnum.systemInput, + label: i18nT('common:core.module.template.System input module') + }, + { + type: FlowNodeTemplateTypeEnum.ai, + label: i18nT('common:core.module.template.AI function') + }, + + { + type: FlowNodeTemplateTypeEnum.interactive, + label: i18nT('common:core.workflow.template.Interactive') + }, + { + type: FlowNodeTemplateTypeEnum.tools, + label: i18nT('app:tool_type_tools') + }, + { + type: FlowNodeTemplateTypeEnum.other, + label: i18nT('common:Other') + } +]; + +// export const defaultTemplateTypes: TemplateTypeSchemaType[] = [ +// { +// typeName: i18nT('common:templateTags.Writing'), +// typeId: AppTemplateTypeEnum.writing, +// typeOrder: 0 +// }, +// { +// typeName: i18nT('common:templateTags.Image_generation'), +// typeId: AppTemplateTypeEnum.imageGeneration, +// typeOrder: 1 +// }, +// { +// typeName: i18nT('common:templateTags.Web_search'), +// typeId: AppTemplateTypeEnum.webSearch, +// typeOrder: 2 +// }, +// { +// typeName: i18nT('common:templateTags.Roleplay'), +// typeId: AppTemplateTypeEnum.roleplay, +// typeOrder: 3 +// }, +// { +// typeName: i18nT('common:templateTags.Office_services'), +// typeId: AppTemplateTypeEnum.officeServices, +// typeOrder: 4 +// } +// ]; + +// 交互要求去掉 +export const defaultTemplateTypes: TemplateTypeSchemaType[] = []; + +export const ENTRY_POINT_VARIABLE_KEY = 'system_entryPoint'; export const defaultFileExtensionTypes = { - canSelectFile: ['.pdf', '.docx', '.pptx', '.xlsx', '.txt', '.md', '.html', '.csv'], + canSelectFile: ['.pdf', '.docx', '.pptx', '.xls', '.xlsx', '.txt', '.md', '.html', '.csv'], canSelectImg: ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'], canSelectVideo: ['.mp4', '.mov', '.avi', '.mpeg', '.webm'], canSelectAudio: ['.mp3', '.wav', '.ogg', '.m4a', '.amr', '.mpga'], diff --git a/packages/global/core/app/evaluation/constants.ts b/packages/global/core/app/evaluation/constants.ts index d6b02985820c..b8cdd6a4d2f9 100644 --- a/packages/global/core/app/evaluation/constants.ts +++ b/packages/global/core/app/evaluation/constants.ts @@ -1,22 +1,5 @@ -import { i18nT } from '../../../../web/i18n/utils'; - -export const evaluationFileErrors = i18nT('dashboard_evaluation:eval_file_check_error'); - -export enum EvaluationStatusEnum { - queuing = 0, - evaluating = 1, - completed = 2 -} - -export const EvaluationStatusMap = { - [EvaluationStatusEnum.queuing]: { - name: i18nT('dashboard_evaluation:queuing') - }, - [EvaluationStatusEnum.evaluating]: { - name: i18nT('dashboard_evaluation:evaluating') - }, - [EvaluationStatusEnum.completed]: { - name: i18nT('dashboard_evaluation:completed') - } -}; -export const EvaluationStatusValues = Object.keys(EvaluationStatusMap).map(Number); +export const evaluationFileErrors = { + noHeader: 'evaluation_file_no_header', + noData: 'evaluation_file_no_data', + invalidFormat: 'evaluation_file_invalid_format' +} as const; diff --git a/packages/global/core/app/evaluation/type.ts b/packages/global/core/app/evaluation/type.ts index 2a497a50979a..9f35d53a2a13 100644 --- a/packages/global/core/app/evaluation/type.ts +++ b/packages/global/core/app/evaluation/type.ts @@ -1,4 +1,4 @@ -import type { EvaluationStatusEnum } from './constants'; +import type { EvaluationStatusEnum } from '../../evaluation/constants'; export type EvaluationSchemaType = { _id: string; diff --git a/packages/global/core/app/evaluation/utils.ts b/packages/global/core/app/evaluation/utils.ts index adad61c67817..28441580eb1d 100644 --- a/packages/global/core/app/evaluation/utils.ts +++ b/packages/global/core/app/evaluation/utils.ts @@ -1,10 +1,15 @@ import type { VariableItemType } from '../type'; -export const getEvaluationFileHeader = (appVariables?: VariableItemType[]) => { - if (!appVariables || appVariables.length === 0) return '*q,*a,history'; +export const getEvaluationFileHeader = ( + appVariables?: VariableItemType[] +): string => { + const defaultColumns = ['*q', '*a', 'history']; - const variablesStr = appVariables - .map((item) => (item.required ? `*${item.key}` : item.key)) - .join(','); - return `${variablesStr},*q,*a,history`; + if (!appVariables || appVariables.length === 0) { + return defaultColumns.join(','); + } + + const variableColumns = appVariables.map((v) => (v.required ? `*${v.key}` : v.key)); + + return [...variableColumns, ...defaultColumns].join(','); }; diff --git a/packages/global/core/app/formEdit/type.ts b/packages/global/core/app/formEdit/type.ts index 41b1c13c7c4c..4a652167338f 100644 --- a/packages/global/core/app/formEdit/type.ts +++ b/packages/global/core/app/formEdit/type.ts @@ -33,7 +33,7 @@ export type SelectedToolItemType = z.infer; export const AppFormEditFormV1TypeSchema = z.object({ aiSettings: z.object({ - [NodeInputKeyEnum.aiModel]: z.string(), + [NodeInputKeyEnum.aiModelId]: z.string(), [NodeInputKeyEnum.aiSystemPrompt]: z.string().optional(), [NodeInputKeyEnum.aiChatTemperature]: z.number().optional(), diff --git a/packages/global/core/app/jsonschema.ts b/packages/global/core/app/jsonschema.ts index 4d4ac37f4222..d0958808b8a6 100644 --- a/packages/global/core/app/jsonschema.ts +++ b/packages/global/core/app/jsonschema.ts @@ -5,7 +5,7 @@ import SwaggerParser from '@apidevtools/swagger-parser'; import yaml from 'js-yaml'; import type { OpenAPIV3 } from 'openapi-types'; import type { OpenApiJsonSchema } from './tool/httpTool/type'; -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; import z from 'zod'; export const JsonSchemaPropertiesItemSchema = z.object({ diff --git a/packages/global/core/app/logs/constants.ts b/packages/global/core/app/logs/constants.ts index 93a840941a46..1a97096e8e10 100644 --- a/packages/global/core/app/logs/constants.ts +++ b/packages/global/core/app/logs/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export enum AppLogKeysEnum { SOURCE = 'source', @@ -11,6 +11,7 @@ export enum AppLogKeysEnum { FEEDBACK = 'feedback', CUSTOM_FEEDBACK = 'customFeedback', ANNOTATED_COUNT = 'annotatedCount', + OPTIMIZED_COUNT = 'optimizedCount', POINTS = 'points', RESPONSE_TIME = 'responseTime', ERROR_COUNT = 'errorCount', @@ -32,6 +33,7 @@ export const AppLogKeysEnumMap = { [AppLogKeysEnum.POINTS]: i18nT('app:logs_keys_points'), [AppLogKeysEnum.RESPONSE_TIME]: i18nT('app:logs_keys_responseTime'), [AppLogKeysEnum.ERROR_COUNT]: i18nT('app:logs_keys_errorCount'), + [AppLogKeysEnum.OPTIMIZED_COUNT]: i18nT('app:logs_keys_optimizedCount'), [AppLogKeysEnum.REGION]: i18nT('app:logs_keys_region'), [AppLogKeysEnum.VERSION_NAME]: i18nT('app:logs_keys_versionName') }; @@ -54,6 +56,38 @@ export const DefaultAppLogKeys = [ { key: AppLogKeysEnum.VERSION_NAME, enable: false } ]; +export const DefaultAssistantLogKey = [ + { key: AppLogKeysEnum.TITLE, enable: true }, + { key: AppLogKeysEnum.SOURCE, enable: true }, + { key: AppLogKeysEnum.USER, enable: true }, + { key: AppLogKeysEnum.CREATED_TIME, enable: false }, + { key: AppLogKeysEnum.LAST_CONVERSATION_TIME, enable: true }, + { key: AppLogKeysEnum.MESSAGE_COUNT, enable: true }, + { key: AppLogKeysEnum.FEEDBACK, enable: true }, + { key: AppLogKeysEnum.OPTIMIZED_COUNT, enable: true }, + { key: AppLogKeysEnum.RESPONSE_TIME, enable: false }, + { key: AppLogKeysEnum.ERROR_COUNT, enable: false }, + { key: AppLogKeysEnum.VERSION_NAME, enable: false } +]; + +export const AssistantAppLogKeysEnumMap = { + [AppLogKeysEnum.SOURCE]: i18nT('app:logs_keys_source'), + [AppLogKeysEnum.USER]: i18nT('app:logs_keys_user'), + [AppLogKeysEnum.TITLE]: i18nT('app:logs_keys_title'), + [AppLogKeysEnum.SESSION_ID]: i18nT('app:logs_keys_sessionId'), + [AppLogKeysEnum.CREATED_TIME]: i18nT('app:logs_keys_createdTime'), + [AppLogKeysEnum.LAST_CONVERSATION_TIME]: i18nT('app:logs_keys_lastConversationTime'), + [AppLogKeysEnum.MESSAGE_COUNT]: i18nT('app:logs_keys_messageCount'), + [AppLogKeysEnum.FEEDBACK]: i18nT('app:logs_keys_feedback'), + [AppLogKeysEnum.CUSTOM_FEEDBACK]: i18nT('app:logs_keys_customFeedback'), + [AppLogKeysEnum.ANNOTATED_COUNT]: i18nT('app:logs_keys_annotatedCount'), + [AppLogKeysEnum.OPTIMIZED_COUNT]: i18nT('app:logs_keys_optimizedCount'), + [AppLogKeysEnum.POINTS]: i18nT('app:logs_keys_points'), + [AppLogKeysEnum.RESPONSE_TIME]: i18nT('app:logs_keys_responseTime'), + [AppLogKeysEnum.ERROR_COUNT]: i18nT('app:logs_keys_errorCount'), + [AppLogKeysEnum.VERSION_NAME]: i18nT('app:logs_keys_versionName') +}; + export enum AppLogTimespanEnum { day = 'day', week = 'week', @@ -77,6 +111,9 @@ export const fakeChartData = { newUserCount: 5, retentionUserCount: 3, points: 100, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 1, online: 1, @@ -97,6 +134,9 @@ export const fakeChartData = { newUserCount: 8, retentionUserCount: 4, points: 160, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 2, online: 2, @@ -117,6 +157,9 @@ export const fakeChartData = { newUserCount: 12, retentionUserCount: 6, points: 220, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 2, online: 3, @@ -137,6 +180,9 @@ export const fakeChartData = { newUserCount: 7, retentionUserCount: 8, points: 180, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 1, online: 2, @@ -157,6 +203,9 @@ export const fakeChartData = { newUserCount: 15, retentionUserCount: 5, points: 250, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 2, online: 4, @@ -177,6 +226,9 @@ export const fakeChartData = { newUserCount: 6, retentionUserCount: 8, points: 170, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 1, online: 3, @@ -197,6 +249,9 @@ export const fakeChartData = { newUserCount: 17, retentionUserCount: 5, points: 280, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, sourceCountMap: { test: 2, online: 5, @@ -330,6 +385,8 @@ export const fakeChartData = { cumulative: { userCount: 109, points: 1360, + inputTokens: 0, + outputTokens: 0, chatItemCount: 280, chatCount: 177, pointsPerChat: 7.2, diff --git a/packages/global/core/app/logs/type.ts b/packages/global/core/app/logs/type.ts index c7d9d726a7ab..73e25da01136 100644 --- a/packages/global/core/app/logs/type.ts +++ b/packages/global/core/app/logs/type.ts @@ -34,6 +34,8 @@ export const AppChatLogSchema = z.object({ badFeedbackCount: z.number(), totalResponseTime: z.number(), - isFirstChat: z.boolean() // whether this is the user's first session in the app + isFirstChat: z.boolean(), // whether this is the user's first session in the app + + parentAppId: ObjectIdSchema.optional() // parent app ID (when triggered by workflow appModule node) }); export type AppChatLogSchema = z.infer; diff --git a/packages/global/core/app/tool/httpTool/utils.ts b/packages/global/core/app/tool/httpTool/utils.ts index ff6cb76d9355..68a9cef3e7ca 100644 --- a/packages/global/core/app/tool/httpTool/utils.ts +++ b/packages/global/core/app/tool/httpTool/utils.ts @@ -7,7 +7,7 @@ import { jsonSchema2NodeInput, jsonSchema2NodeOutput } from '../../jsonschema'; import { type StoreSecretValueType } from '../../../../common/secret/type'; import { type JsonSchemaPropertiesItemType } from '../../jsonschema'; import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../workflow/constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import type { NodeToolConfigType } from '../../../workflow/type/node'; export const getHTTPToolSetRuntimeNode = ({ diff --git a/packages/global/core/app/tool/mcpTool/utils.ts b/packages/global/core/app/tool/mcpTool/utils.ts index 77ff4a52b6f5..34f2f116eae8 100644 --- a/packages/global/core/app/tool/mcpTool/utils.ts +++ b/packages/global/core/app/tool/mcpTool/utils.ts @@ -1,5 +1,5 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../workflow/constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import { FlowNodeOutputTypeEnum, FlowNodeTypeEnum } from '../../../workflow/node/constant'; import { type McpToolConfigType } from '../../tool/mcpTool/type'; import { type RuntimeNodeItemType } from '../../../workflow/runtime/type'; diff --git a/packages/global/core/app/tool/systemTool/constants.ts b/packages/global/core/app/tool/systemTool/constants.ts index 0b9e6a2b47e0..0ac45795aa8a 100644 --- a/packages/global/core/app/tool/systemTool/constants.ts +++ b/packages/global/core/app/tool/systemTool/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export enum SystemToolSecretInputTypeEnum { system = 'system', diff --git a/packages/global/core/app/tool/type.ts b/packages/global/core/app/tool/type.ts index e23a41ccd28f..1cb7585eb23b 100644 --- a/packages/global/core/app/tool/type.ts +++ b/packages/global/core/app/tool/type.ts @@ -23,9 +23,18 @@ export type AppToolRuntimeType = { hasTokenFee?: boolean; }; +// system plugin version list item type (support i18n) +export type SystemPluginVersionListItemType = { + value: string; + description?: string; + inputs: FlowNodeInputItemType[]; + outputs: FlowNodeOutputItemType[]; +}; + // System tool export type AppToolTemplateItemType = WorkflowTemplateType & { status?: PluginStatusType; + templateType: string; // FastGPT-plugin tool inputs?: FlowNodeInputItemType[]; outputs?: FlowNodeOutputItemType[]; @@ -63,8 +72,6 @@ export type AppToolTemplateItemType = WorkflowTemplateType & { /** @deprecated */ isActive?: boolean; //use tags instead - /** @deprecated */ - templateType?: string; }; export type AppToolTemplateListItemType = Omit< diff --git a/packages/global/core/app/type.ts b/packages/global/core/app/type.ts index ff1cf7153710..8e352bd58ba3 100644 --- a/packages/global/core/app/type.ts +++ b/packages/global/core/app/type.ts @@ -1,9 +1,14 @@ import { StoreNodeItemTypeSchema } from '../workflow/type/node'; +import type { FlowNodeTemplateType } from '../workflow/type/node'; import { AppTypeEnum } from './constants'; import type { NodeInputKeyEnum } from '../workflow/constants'; import { VariableInputEnum } from '../workflow/constants'; -import { InputComponentPropsTypeSchema } from '../workflow/type/io'; -import { DatasetSearchModeEnum } from '../dataset/constants'; +import { InputComponentPropsTypeSchema, type SelectedDatasetType } from '../workflow/type/io'; +import { + DatasetSearchModeEnum, + DatasetRetrievalModeEnum, + RerankMethodEnum +} from '../dataset/constants'; import { StoreEdgeItemTypeSchema } from '../workflow/type/edge'; import type { AppPermission } from '../../support/permission/app/controller'; import { ParentIdSchema, type ParentIdType } from '../../common/parentFolder/type'; @@ -25,7 +30,7 @@ export type VariableItemType = z.infer; // tts export const AppTTSConfigTypeSchema = z.object({ type: z.enum(['none', 'web', 'model']), - model: z.string().optional(), + modelId: z.string().optional(), voice: z.string().optional(), speed: z.number().optional() }); @@ -42,7 +47,7 @@ export type AppWhisperConfigType = z.infer; // question guide export const AppQGConfigTypeSchema = z.object({ open: z.boolean(), - model: z.string().optional(), + modelId: z.string().optional(), customPrompt: z.string().optional() }); export type AppQGConfigType = z.infer; @@ -79,10 +84,22 @@ export const AppChatConfigTypeSchema = z.object({ scheduledTriggerConfig: AppScheduledTriggerConfigTypeSchema.optional(), chatInputGuide: ChatInputGuideConfigTypeSchema.optional(), fileSelectConfig: AppFileSelectConfigTypeSchema.optional(), + faqAnswerMode: z.enum(['quote', 'llm-summary']).optional(), + enableFallbackReply: z.enum(['useFallbackReply', 'llmReply']).optional(), + fallbackReply: z.string().optional(), + entryPoints: z + .array(z.object({ id: z.string(), name: z.string(), icon: z.string().optional() })) + .optional(), instruction: z.string().optional() }); export type AppChatConfigType = z.infer; +export type EntryPointItemType = { + id: string; + name: string; + icon?: string; +}; + // Mongo Collection export const AppSchemaTypeSchema = z.object({ _id: ObjectIdSchema, @@ -116,6 +133,7 @@ export const AppSchemaTypeSchema = z.object({ scheduledTriggerNextTime: z.coerce.date().optional(), inheritPermission: z.boolean().optional(), + isPinned: z.boolean().optional(), // if access the app by favourite or quick favourite: z.boolean().optional(), @@ -148,6 +166,8 @@ export type AppListItemType = { private?: boolean; sourceMember: SourceMemberType; hasInteractiveNode?: boolean; + relatedAppCount?: number; + isPinned?: boolean; }; export type AppDetailType = AppSchemaType & { @@ -161,19 +181,31 @@ export const AppDatasetSearchParamsTypeSchema = z.object({ embeddingWeight: z.number().optional(), // embedding weight, fullText weight = 1 - embeddingWeight usingReRank: z.boolean().optional(), - rerankModel: z.string().optional(), + rerankModelId: z.string().optional(), + embeddingModelId: z.string().optional(), + rerankMethod: z.enum(RerankMethodEnum).optional(), rerankWeight: z.number().optional(), datasetSearchUsingExtensionQuery: z.boolean().optional(), - datasetSearchExtensionModel: z.string().optional(), + datasetSearchExtensionModelId: z.string().optional(), datasetSearchExtensionBg: z.string().optional(), - collectionFilterMatch: z.string().optional() + // database + generateSqlModelId: z.string().optional(), + + // retrieval mode + retrievalMode: z.enum(DatasetRetrievalModeEnum).optional(), + agenticSearchLLMModelId: z.string().optional(), + agenticSearchReasoning: z.boolean().optional(), + agenticSearchRerankModelId: z.string().optional(), + + collectionFilterMatch: z.string().optional(), + authTmbId: z.boolean().optional() }); export type AppDatasetSearchParamsType = z.infer; export type SettingAIDataType = { - model: string; + modelId: string; temperature?: number; maxToken?: number; isResponseAnswerText?: boolean; @@ -208,6 +240,8 @@ export type AppTemplateSchemaType = { order?: number; // TODO: 对于chat agent,是另一个格式 workflow: WorkflowTemplateBasicType; + experienceUrl?: string; + isMarketFeatured?: boolean; }; export type TemplateTypeSchemaType = { @@ -215,3 +249,30 @@ export type TemplateTypeSchemaType = { typeId: string; typeOrder: number; }; + +// Re-export for compatibility +export type { AppFileSelectConfigType } from './type/config.schema'; + +/** @deprecated Use AppSchemaType instead */ +export type AppSchema = AppSchemaType; + +export type AppSimpleEditFormType = { + aiSettings: { + [NodeInputKeyEnum.aiModelId]: string; + [NodeInputKeyEnum.aiSystemPrompt]?: string | undefined; + [NodeInputKeyEnum.aiChatTemperature]?: number; + [NodeInputKeyEnum.aiChatMaxToken]?: number; + [NodeInputKeyEnum.aiChatIsResponseText]: boolean; + maxHistories: number; + [NodeInputKeyEnum.aiChatReasoning]?: boolean; + [NodeInputKeyEnum.aiChatTopP]?: number; + [NodeInputKeyEnum.aiChatStopSign]?: string; + [NodeInputKeyEnum.aiChatResponseFormat]?: string; + [NodeInputKeyEnum.aiChatJsonSchema]?: string; + }; + dataset: { + datasets: SelectedDatasetType[]; + } & AppDatasetSearchParamsType; + selectedTools: FlowNodeTemplateType[]; + chatConfig: AppChatConfigType; +}; diff --git a/packages/global/core/app/utils.ts b/packages/global/core/app/utils.ts index ae61f918dab7..9779a28f10c3 100644 --- a/packages/global/core/app/utils.ts +++ b/packages/global/core/app/utils.ts @@ -1,27 +1,41 @@ +import type { AppChatConfigType, AppSimpleEditFormType } from '../app/type'; +import { FlowNodeTypeEnum } from '../workflow/node/constant'; +import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum } from '../workflow/constants'; +import type { FlowNodeInputItemType } from '../workflow/type/io'; +import { getAppChatConfig } from '../workflow/utils'; +import { type StoreNodeItemType } from '../workflow/type/node'; +import { + DatasetSearchModeEnum, + DatasetRetrievalModeEnum, + RerankMethodEnum +} from '../dataset/constants'; import type { AppFormEditFormType } from './formEdit/type'; -import { DatasetSearchModeEnum } from '../dataset/constants'; import { type WorkflowTemplateBasicType } from '../workflow/type'; -import { AppTypeEnum } from './constants'; +import { AppTypeEnum, AssistantGlobalVarKey } from './constants'; import appErrList from '../../common/error/code/app'; import pluginErrList from '../../common/error/code/plugin'; export const getDefaultAppForm = (): AppFormEditFormType => { return { aiSettings: { - model: '', + modelId: '', isResponseAnswerText: true, - maxHistories: 6 + maxHistories: 6, + aiChatReasoning: true }, dataset: { datasets: [], similarity: 0.4, - limit: 3000, - searchMode: DatasetSearchModeEnum.embedding, + limit: 5000, + searchMode: DatasetSearchModeEnum.mixedRecall, + embeddingWeight: 0.65, usingReRank: true, - rerankModel: '', - rerankWeight: 0.5, + rerankModelId: '', + rerankMethod: RerankMethodEnum.question, + rerankWeight: 0.4, datasetSearchUsingExtensionQuery: true, - datasetSearchExtensionBg: '' + datasetSearchExtensionBg: '', + retrievalMode: DatasetRetrievalModeEnum.standard }, selectedTools: [], selectedAgentSkills: [], @@ -29,7 +43,217 @@ export const getDefaultAppForm = (): AppFormEditFormType => { }; }; -export const getAppType = (config?: WorkflowTemplateBasicType | AppFormEditFormType) => { +/* format app nodes to edit form */ +export const appWorkflow2Form = ({ + nodes, + chatConfig +}: { + nodes: StoreNodeItemType[]; + chatConfig: AppChatConfigType; +}) => { + const defaultAppForm = getDefaultAppForm(); + const findInputValueByKey = (inputs: FlowNodeInputItemType[], key: string) => { + return inputs.find((item) => item.key === key)?.value; + }; + + nodes.forEach((node) => { + if ( + node.flowNodeType === FlowNodeTypeEnum.chatNode || + node.flowNodeType === FlowNodeTypeEnum.agent + ) { + defaultAppForm.aiSettings.modelId = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiModelId + ); + defaultAppForm.aiSettings.systemPrompt = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiSystemPrompt + ); + defaultAppForm.aiSettings.temperature = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatTemperature + ); + defaultAppForm.aiSettings.maxToken = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatMaxToken + ); + defaultAppForm.aiSettings.maxHistories = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.history + ); + defaultAppForm.aiSettings.aiChatReasoning = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatReasoning + ); + defaultAppForm.aiSettings.aiChatTopP = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatTopP + ); + defaultAppForm.aiSettings.aiChatStopSign = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatStopSign + ); + defaultAppForm.aiSettings.aiChatResponseFormat = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatResponseFormat + ); + defaultAppForm.aiSettings.aiChatJsonSchema = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiChatJsonSchema + ); + } else if (node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode) { + defaultAppForm.dataset.datasets = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSelectList + ); + defaultAppForm.dataset.similarity = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSimilarity + ); + defaultAppForm.dataset.limit = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetMaxTokens + ); + defaultAppForm.dataset.searchMode = + findInputValueByKey(node.inputs, NodeInputKeyEnum.datasetSearchMode) || + DatasetSearchModeEnum.mixedRecall; + defaultAppForm.dataset.embeddingWeight = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchEmbeddingWeight + ); + defaultAppForm.dataset.embeddingModelId = + findInputValueByKey(node.inputs, NodeInputKeyEnum.datasetSearchEmbeddingModelId) || + defaultAppForm.dataset.datasets?.[0]?.vectorModel?.id; + // Rerank + defaultAppForm.dataset.usingReRank = !!findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchUsingReRank + ); + const rerankModel1 = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchRerankModelId + ); + const rerankModel2 = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetAgenticSearchRerankModelId + ); + // 使用两种是因为两种检索方式使用不同的key + defaultAppForm.dataset.rerankModelId = rerankModel1 || rerankModel2; + defaultAppForm.dataset.rerankMethod = + findInputValueByKey(node.inputs, NodeInputKeyEnum.datasetSearchRerankMethod) || + RerankMethodEnum.question; + defaultAppForm.dataset.rerankWeight = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchRerankWeight + ); + // Query extension + defaultAppForm.dataset.datasetSearchUsingExtensionQuery = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchUsingExtensionQuery + ); + defaultAppForm.dataset.datasetSearchExtensionModelId = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchExtensionModelId + ); + defaultAppForm.dataset.datasetSearchExtensionBg = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetSearchExtensionBg + ); + defaultAppForm.dataset.generateSqlModelId = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.generateSqlModelId + ); + // Agentic Search + defaultAppForm.dataset.retrievalMode = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetRetrievalMode + ); + defaultAppForm.dataset.agenticSearchReasoning = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.datasetAgenticSearchReasoning + ); + + // 不单独配置,取AI配置中的模型即可 + defaultAppForm.dataset.agenticSearchLLMModelId = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.aiModelId + ); + defaultAppForm.dataset.agenticSearchRerankModelId = rerankModel2 || rerankModel1; + // 标签过滤 + defaultAppForm.dataset.collectionFilterMatch = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.collectionFilterMatch + ); + defaultAppForm.dataset.authTmbId = findInputValueByKey( + node.inputs, + NodeInputKeyEnum.authTmbId + ); + } else if ( + node.flowNodeType === FlowNodeTypeEnum.pluginModule || + node.flowNodeType === FlowNodeTypeEnum.appModule || + node.flowNodeType === FlowNodeTypeEnum.tool || + node.flowNodeType === FlowNodeTypeEnum.toolSet + ) { + if (!node.pluginId) return; + + defaultAppForm.selectedTools.push({ + id: node.nodeId, + pluginId: node.pluginId, + name: node.name, + avatar: node.avatar, + intro: node.intro || '', + flowNodeType: node.flowNodeType, + showStatus: node.showStatus, + version: node.version, + inputs: node.inputs, + outputs: node.outputs, + templateType: FlowNodeTemplateTypeEnum.other, + pluginData: node.pluginData, + toolConfig: node.toolConfig + }); + } else if (node.flowNodeType === FlowNodeTypeEnum.variableUpdate) { + // 处理智能客服的 fallbackReply 和 faqAnswerMode 字段 + const updateList = findInputValueByKey(node.inputs, NodeInputKeyEnum.updateList); + if (Array.isArray(updateList)) { + const extractGlobalVarValue = (varKey: string) => { + const item = updateList.find( + (item) => Array.isArray(item.variable) && item.variable[1] === varKey + ); + return item && Array.isArray(item.value) ? item.value[1] : undefined; + }; + + const fallbackReply = extractGlobalVarValue(AssistantGlobalVarKey.FALLBACK_REPLY); + if (fallbackReply) { + defaultAppForm.chatConfig.fallbackReply = fallbackReply; + } + + const faqAnswerMode = extractGlobalVarValue(AssistantGlobalVarKey.FAQ_ANSWER_MODE); + if (faqAnswerMode) { + defaultAppForm.chatConfig.faqAnswerMode = faqAnswerMode; + } + + const enableFallbackReply = extractGlobalVarValue( + AssistantGlobalVarKey.ENABLE_FALLBACK_REPLY + ); + if (enableFallbackReply) { + defaultAppForm.chatConfig.enableFallbackReply = enableFallbackReply as + | 'useFallbackReply' + | 'llmReply'; + } + } + } else if (node.flowNodeType === FlowNodeTypeEnum.systemConfig) { + defaultAppForm.chatConfig = getAppChatConfig({ + chatConfig, + systemConfigNode: node, + isPublicFetch: true + }); + } + }); + + return defaultAppForm; +}; + +export const getAppType = (config?: WorkflowTemplateBasicType | AppSimpleEditFormType) => { if (!config) return ''; if ('aiSettings' in config) { diff --git a/packages/global/core/chat/constants.ts b/packages/global/core/chat/constants.ts index edb80194de05..653ec25a81ec 100644 --- a/packages/global/core/chat/constants.ts +++ b/packages/global/core/chat/constants.ts @@ -1,4 +1,6 @@ -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; +import type { I18nStringType } from '../../common/i18n/type'; +import { parseI18nString } from '../../common/i18n/utils'; export enum ChatRoleEnum { System = 'System', @@ -22,6 +24,14 @@ export enum ChatFileTypeEnum { file = 'file' } +export enum ChatItemValueTypeEnum { + text = 'text', + file = 'file', + reasoning = 'reasoning', + tool = 'tool', + interactive = 'interactive' +} + export enum ChatSourceEnum { test = 'test', online = 'online', @@ -32,8 +42,10 @@ export enum ChatSourceEnum { feishu = 'feishu', official_account = 'official_account', wecom = 'wecom', + evaluation = 'evaluation', wechat = 'wechat', - mcp = 'mcp' + mcp = 'mcp', + workflow = 'workflow' } export const ChatSourceMap = { @@ -77,18 +89,112 @@ export const ChatSourceMap = { name: i18nT('common:core.chat.logs.wechat'), color: '#07C160' }, + [ChatSourceEnum.workflow]: { + name: i18nT('common:core.chat.logs.workflow'), + color: '#A78BFA' + }, [ChatSourceEnum.mcp]: { name: i18nT('common:core.chat.logs.mcp'), color: '#F97066' + }, + [ChatSourceEnum.evaluation]: { + name: i18nT('common:core.chat.logs.evaluation'), + color: '#8B5CF6' + } +}; + +/** I18n prefix for each chat source, used to construct sourceName like "在线使用-测试" */ +export const ChatSourcePrefix: Record = { + // test 来源不走此后台拼接——chatTest.ts 前端 postMessage + // 已通过 chat_test_app 词条完成国际化拼接,后台直接使用即可 + [ChatSourceEnum.test]: { + 'zh-CN': '调试', + 'zh-Hant': '除錯', + en: 'Debug' + }, + [ChatSourceEnum.online]: { + 'zh-CN': '在线使用', + 'zh-Hant': '線上使用', + en: 'Online operation' + }, + [ChatSourceEnum.share]: { + 'zh-CN': '外部链接调用', + 'zh-Hant': '外部連結呼叫', + en: 'External link call' + }, + [ChatSourceEnum.api]: { + 'zh-CN': 'API 调用', + 'zh-Hant': 'API 呼叫', + en: 'API call' + }, + [ChatSourceEnum.cronJob]: { + 'zh-CN': '定时执行', + 'zh-Hant': '定時執行', + en: 'Scheduled execution' + }, + [ChatSourceEnum.team]: { + 'zh-CN': '团队空间对话', + 'zh-Hant': '團隊空間對話', + en: 'Chat with team members' + }, + [ChatSourceEnum.feishu]: { + 'zh-CN': '飞书', + 'zh-Hant': '飛書', + en: 'Feishu' + }, + [ChatSourceEnum.official_account]: { + 'zh-CN': '公众号', + 'zh-Hant': '官方帳號', + en: 'WeChat official account' + }, + [ChatSourceEnum.wecom]: { + 'zh-CN': '企业微信', + 'zh-Hant': '企業微信', + en: 'WeCom' + }, + [ChatSourceEnum.evaluation]: { + 'zh-CN': '评估测试', + 'zh-Hant': '評估測試', + en: 'Evaluation test' + }, + [ChatSourceEnum.wechat]: { + 'zh-CN': '微信', + 'zh-Hant': '微信', + en: 'WeChat' + }, + [ChatSourceEnum.mcp]: { + 'zh-CN': 'MCP 调用', + 'zh-Hant': 'MCP 調用', + en: 'MCP call' + }, + [ChatSourceEnum.workflow]: { + 'zh-CN': '工作流调用', + 'zh-Hant': '工作流調用', + en: 'Workflow call' } }; +/** + * Construct localized source name: "{i18n prefix}-{name}" + * e.g. "在线使用-测试" (zh-CN) / "Online operation-Test" (en) + */ +export const getChatSourceName = (source: ChatSourceEnum, name: string, lang?: string): string => { + const prefix = parseI18nString(ChatSourcePrefix[source], lang || 'zh-CN'); + return `${prefix}-${name}`; +}; + export enum ChatStatusEnum { loading = 'loading', running = 'running', finish = 'finish' } +export enum FeedbackFilterEnum { + good = 'good', + bad = 'bad', + noFeedback = 'noFeedback' +} + export enum GetChatTypeEnum { normal = 'normal', outLink = 'outLink', diff --git a/packages/global/core/chat/correction/api.d.ts b/packages/global/core/chat/correction/api.d.ts new file mode 100644 index 000000000000..5e9e64fa3d22 --- /dev/null +++ b/packages/global/core/chat/correction/api.d.ts @@ -0,0 +1,76 @@ +import type { CorrectionDataType, ChatCorrectionSchemaType } from './type'; +import type { PaginationProps, PaginationResponse } from '../../openapi/api'; +// Correction API Types +export type SubmitChatCorrectionParams = { + appId: string; + chatId: string; + dataId: string; + correctionData: CorrectionDataType; +}; + +export type SubmitChatCorrectionResponse = { + correctionId: string; +}; + +export type ListChatCorrectionParams = PaginationProps<{ + appId: string; + chatId?: string; + dataId?: string; + correctionId?: string; + startTime?: string | Date; + endTime?: string | Date; +}>; + +export type ChatCorrectionListItem = { + _id: string; + dataId: string; + chatId: string; + appId: string; + correctionData: CorrectionDataType; + updateTime: Date; + userName?: string; +}; + +export type ListChatCorrectionResponse = PaginationResponse; + +export type DeleteChatCorrectionParams = { + appId: string; + chatId?: string; + correctionId: string; +}; + +export type DeleteChatCorrectionResponse = {}; + +export type ExportChatCorrectionParams = { + appId: string; + startTime?: Date; + endTime?: Date; + colHeaders: [string, string, string]; +}; + +// Quote Search API Types +export type GetKeywordQuoteParams = PaginationProps<{ + chatId: string; + appId: string; + keyword: string; + + // filter params + datasetIds: string[]; + collectionIds?: string[]; + + shareId?: string; + outLinkUid?: string; + teamId?: string; + teamToken?: string; +}>; + +export type GetKeywordQuoteResponse = { + list: Array<{ + datasetDataId: string; + q: string; + a?: string; + sourceName?: string; + extractiveText?: string; + }>; + total: number; // 总条数,用于分页 +}; diff --git a/packages/global/core/chat/correction/constants.ts b/packages/global/core/chat/correction/constants.ts new file mode 100644 index 000000000000..a002a371e744 --- /dev/null +++ b/packages/global/core/chat/correction/constants.ts @@ -0,0 +1,11 @@ +export enum CorrectionModeEnum { + edit = 'edit', + annotate = 'annotate' +} + +export enum ChatLogsFilterEnum { + good = 'good', + bad = 'bad', + notFoundKnowledge = 'notFoundKnowledge', + all = 'all' +} diff --git a/packages/global/core/chat/correction/type.d.ts b/packages/global/core/chat/correction/type.d.ts new file mode 100644 index 000000000000..17d1a40a92de --- /dev/null +++ b/packages/global/core/chat/correction/type.d.ts @@ -0,0 +1,54 @@ +import type { CorrectionModeEnum } from './constants'; +import type { EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema'; + +export type CorrectionIndexItem = { + type: 'q' | 'a' | 'c'; + dataId: string; +}; + +export type CorrectedQuoteItem = { + datasetDataId: string; + q: string; + a?: string; + sourceName?: string; + updateTime?: Date; +}; + +export type CorrectionDataType = { + correctionMode: CorrectionModeEnum; + question: string; + rawAnswer: string; + correctedAnswer?: string; + correctedQuoteList?: CorrectedQuoteItem[]; + indexs?: CorrectionIndexItem[]; +}; + +export type ChatCorrectionSchemaType = { + _id: string; + dataId: string; + teamId: string; + tmbId: string; + chatId: string; + appId: string; + correctionData: CorrectionDataType; + createTime: Date; + updateTime: Date; +}; + +// 校正数据搜索相关类型 +export type SearchCorrectionDataProps = { + appId: string; + userChatInput: string; // 传入的 userChatInput(已经是经过处理的标准化查询) + teamId: string; + vectorModel: EmbeddingModelItemType; +}; + +export type SearchCorrectionDataResult = { + correctionId: string; + correctedAnswer: string | undefined; + question: string; + similarity: number; + chatId: string; + dataId: string; + embeddingTokens: number; // 记录向量生成实际消耗的token数量 +} | null; diff --git a/packages/global/core/chat/helperBot/topAgent/type.ts b/packages/global/core/chat/helperBot/topAgent/type.ts index b8652b587866..c6a0b3fc658a 100644 --- a/packages/global/core/chat/helperBot/topAgent/type.ts +++ b/packages/global/core/chat/helperBot/topAgent/type.ts @@ -8,6 +8,7 @@ export const topAgentParamsSchema = z.object({ selectedTools: z.array(z.string()).nullish(), selectedDatasets: z.array(z.string()).nullish(), fileUpload: z.boolean().nullish(), - enableSandbox: z.boolean().nullish() + enableSandbox: z.boolean().nullish(), + modelId: z.string().nullish() }); export type TopAgentParamsType = z.infer; diff --git a/packages/global/core/chat/setting/type.ts b/packages/global/core/chat/setting/type.ts index 31d4d9ad31ca..462a34f7b5a6 100644 --- a/packages/global/core/chat/setting/type.ts +++ b/packages/global/core/chat/setting/type.ts @@ -24,7 +24,7 @@ export const ChatSettingModelSchema = z.object({ slogan: z .string() .optional() - .meta({ example: '你好👋,我是 FastGPT ! 请问有什么可以帮你?', description: 'Slogan' }), + .meta({ example: '你好👋,请问有什么可以帮你?', description: 'Slogan' }), dialogTips: z .string() .optional() diff --git a/packages/global/core/chat/type.d.ts b/packages/global/core/chat/type.d.ts new file mode 100644 index 000000000000..2cb45e264776 --- /dev/null +++ b/packages/global/core/chat/type.d.ts @@ -0,0 +1,258 @@ +import { ClassifyQuestionAgentItemType } from '../workflow/template/system/classifyQuestion/type'; +import type { SearchDataResponseItemType } from '../dataset/type'; +import type { + ChatFileTypeEnum, + ChatItemValueTypeEnum, + ChatRoleEnum, + ChatSourceEnum, + ChatStatusEnum +} from './constants'; +import type { FlowNodeTypeEnum } from '../workflow/node/constant'; +import type { NodeInputKeyEnum, NodeOutputKeyEnum } from '../workflow/constants'; +import type { DispatchNodeResponseKeyEnum } from '../workflow/runtime/constants'; +import type { AppSchema, VariableItemType } from '../app/type'; +import { AppChatConfigType } from '../app/type'; +import type { AppSchema as AppType } from '@fastgpt/global/core/app/type'; +import { DatasetSearchModeEnum } from '../dataset/constants'; +import type { DispatchNodeResponseType } from '../workflow/runtime/type'; +import type { ChatBoxInputType } from '../../../../projects/app/src/components/core/chat/ChatContainer/ChatBox/type'; +import type { WorkflowInteractiveResponseType } from '../workflow/template/system/interactive/type'; +import type { FlowNodeInputItemType } from '../workflow/type/io'; +import type { FlowNodeTemplateType } from '../workflow/type/node.d'; + +/* --------- chat ---------- */ +export type ChatSchemaType = { + _id: string; + chatId: string; + userId: string; + teamId: string; + tmbId: string; + appId: string; + appVersionId?: string; + createTime: Date; + updateTime: Date; + title: string; + customTitle: string; + top: boolean; + source: `${ChatSourceEnum}`; + sourceName?: string; + + shareId?: string; + outLinkUid?: string; + + variableList?: VariableItemType[]; + welcomeText?: string; + variables: Record; + pluginInputs?: FlowNodeInputItemType[]; + metadata?: Record; + + // Boolean flags for efficient filtering + hasGoodFeedback?: boolean; + hasBadFeedback?: boolean; + hasUnreadGoodFeedback?: boolean; + hasUnreadBadFeedback?: boolean; + + deleteTime?: Date | null; +}; + +export type ChatWithAppSchema = Omit & { + appId: AppSchema; +}; + +/* --------- chat item ---------- */ +export type UserChatItemFileItemType = { + type: `${ChatFileTypeEnum}`; + name?: string; + key?: string; + url: string; +}; +export type UserChatItemValueItemType = { + type: ChatItemValueTypeEnum.text | ChatItemValueTypeEnum.file; + text?: { + content: string; + }; + file?: UserChatItemFileItemType; +}; +export type UserChatItemType = { + obj: ChatRoleEnum.Human; + value: UserChatItemValueItemType[]; + hideInUI?: boolean; +}; + +export type SystemChatItemValueItemType = { + type: ChatItemValueTypeEnum.text; + text?: { + content: string; + }; +}; +export type SystemChatItemType = { + obj: ChatRoleEnum.System; + value: SystemChatItemValueItemType[]; +}; + +export type AIChatItemValueItemType = { + type: + | ChatItemValueTypeEnum.text + | ChatItemValueTypeEnum.reasoning + | ChatItemValueTypeEnum.tool + | ChatItemValueTypeEnum.interactive; + + text?: { + content: string; + }; + reasoning?: { + content: string; + }; + tools?: ToolModuleResponseItemType[]; + interactive?: WorkflowInteractiveResponseType; +}; +export type AIChatItemType = { + obj: ChatRoleEnum.AI; + value: AIChatItemValueItemType[]; + memories?: Record; + userGoodFeedback?: string; + userBadFeedback?: string; + customFeedbacks?: string[]; + adminFeedback?: AdminFbkType; + isFeedbackRead?: boolean; + + durationSeconds?: number; + errorMsg?: string; + citeCollectionIds?: string[]; + + // @deprecated 不再存储在 chatItemSchema 里,分别存储到 chatItemResponseSchema + [DispatchNodeResponseKeyEnum.nodeResponse]?: ChatHistoryItemResType[]; +}; + +export type ChatItemValueItemType = + | UserChatItemValueItemType + | SystemChatItemValueItemType + | AIChatItemValueItemType; +export type ChatItemMergeType = UserChatItemType | SystemChatItemType | AIChatItemType; + +export type ChatItemSchemaType = ChatItemMergeType & { + dataId: string; + chatId: string; + userId: string; + teamId: string; + tmbId: string; + appId: string; + time: Date; + deleteTime?: Date | null; +}; + +export type AdminFbkType = { + feedbackDataId: string; + datasetId: string; + collectionId: string; + q: string; + a?: string; +}; + +export type ResponseTagItemType = { + quoteList?: SearchDataResponseItemType[]; // 原始未过滤的引用列表,与后端"未找到知识"判断一致 + totalQuoteList?: SearchDataResponseItemType[]; // 过滤后的引用列表,只包含实际被引用的 + llmModuleAccount?: number; + historyPreviewLength?: number; + toolCiteLinks?: ToolCiteLinksType[]; +}; + +export type ChatItemType = ChatItemMergeType & { + dataId?: string; + correctionId?: string; +} & ResponseTagItemType; + +// Frontend type +export type ChatSiteItemType = ChatItemMergeType & { + _id?: string; + id: string; + dataId: string; + correctionId?: string; + status: `${ChatStatusEnum}`; + moduleName?: string; + ttsBuffer?: Uint8Array; + responseData?: ChatHistoryItemResType[]; + time?: Date; + durationSeconds?: number; + rewriteStandardizedQuery?: string; + agenticSearchResult?: DispatchNodeResponseType['agenticSearchResult']; + errorMsg?: string; + deleteTime?: Date | null; + collapseTop?: { + count: number; + dataIds: string[]; + isExpanded: boolean; + }; + collapseBottom?: { + count: number; + dataIds: string[]; + isExpanded: boolean; + }; + agenticSearchResult?: any; +} & ChatBoxInputType & + ResponseTagItemType; + +/* --------- chat item response ---------- */ +export type ChatItemResponseSchemaType = { + teamId: string; + appId: string; + chatId: string; + chatItemDataId: string; + data: ChatHistoryItemResType; +}; + +/* --------- team chat --------- */ +export type ChatAppListSchema = { + apps: AppType[]; + teamInfo: teamInfoSchema; + uid?: string; +}; + +/* ---------- history ------------- */ +export type HistoryItemType = { + chatId: string; + updateTime: Date; + customTitle?: string; + title: string; +}; +export type ChatHistoryItemType = HistoryItemType & { + appId: string; + top?: boolean; +}; + +/* ------- response data ------------ */ +export type ChatHistoryItemResType = DispatchNodeResponseType & { + nodeId: string; + id: string; + moduleType: FlowNodeTypeEnum; + moduleName: string; +}; + +/* ---------- node outputs ------------ */ +export type NodeOutputItemType = { + nodeId: string; + key: NodeOutputKeyEnum; + value: any; +}; + +/* One tool run response */ +export type ToolRunResponseItemType = any; +/* tool module response */ +export type ToolModuleResponseItemType = { + id: string; + toolName: string; // tool name + toolAvatar: string; + params: string; // tool params + response: string; + functionName: string; +}; + +export type ToolCiteLinksType = { + name: string; + url: string; +}; +/* dispatch run time */ +export type RuntimeUserPromptType = { + files: UserChatItemValueItemType['file'][]; + text: string; +}; diff --git a/packages/global/core/chat/type.ts b/packages/global/core/chat/type.ts index 2f56df62732c..d330e579ef92 100644 --- a/packages/global/core/chat/type.ts +++ b/packages/global/core/chat/type.ts @@ -2,6 +2,7 @@ import { SearchDataResponseItemSchema } from '../dataset/type'; import { ChatFileTypeEnum, ChatGenerateStatusEnum, + ChatItemValueTypeEnum, ChatRoleEnum, type ChatSourceEnum } from './constants'; @@ -52,7 +53,7 @@ export type SandboxStatusPhase = | 'connecting' // warm-start: reusing existing container | 'fetchSkills' // cold-start: fetching skill metadata from DB | 'creatingContainer' // cold-start: creating container, waiting ready (up to 60s) - // Skill deployment phases (used in both session-runtime and edit-debug) + // Skill deployment phases | 'deployingSkills' // announcing which skill is about to be deployed | 'downloadingPackage' // downloading skill package from MinIO | 'uploadingPackage' // uploading package into sandbox container @@ -69,16 +70,16 @@ export type SandboxStatusItemType = { phase: SandboxStatusPhase; isWarmStart?: boolean; // present on 'connecting' and 'ready' skillName?: string; // present on 'deployingSkills', 'downloadingPackage', - // 'uploadingPackage', 'extractingPackage' in session-runtime + // 'uploadingPackage', 'extractingPackage' in agent sandbox message?: string; // optional human-readable message - // Present on 'ready' phase for edit-debug sandboxes + // Present on 'ready' phase for sandboxes with HTTP service endpoint?: { host: string; port: number; protocol: 'http' | 'https'; url: string; }; - providerSandboxId?: string; // present on 'ready' for edit-debug + providerSandboxId?: string; // present on 'ready' }; /* Skill module response */ @@ -87,7 +88,9 @@ export const SkillModuleResponseItemSchema = z.object({ skillName: z.string(), skillAvatar: z.string(), description: z.string(), - skillMdPath: z.string() + skillMdPath: z.string(), + status: z.enum(['loading', 'completed']).optional(), + content: z.string().optional() }); export type SkillModuleResponseItemType = z.infer; @@ -129,6 +132,13 @@ export type ChatSchemaType = { chatGenerateStatus?: ChatGenerateStatusEnum; hasBeenRead: boolean; + // Parent app association fields (when this chat is triggered by a workflow appModule node) + parentChatId?: string; + parentResponseChatItemId?: string; + parentNodeId?: string; + parentNodeName?: string; + parentAppId?: string; + deleteTime?: Date | null; }; @@ -150,10 +160,18 @@ export const UserChatItemValueItemSchema = z.object({ planId: z.string().nullish(), text: z .object({ - content: z.string() + content: z.string(), + originalContent: z.string().optional() }) .optional(), - file: UserChatItemFileItemSchema.optional() + file: UserChatItemFileItemSchema.optional(), + compression: z + .object({ + modelId: z.string().optional(), + inputTokens: z.number().optional(), + outputTokens: z.number().optional() + }) + .optional() }); export type UserChatItemValueItemType = z.infer; @@ -191,6 +209,7 @@ export const AdminFbkSchema = z.object({ export type AdminFbkType = z.infer; export const AIChatItemValueSchema = z.object({ + type: z.enum(ChatItemValueTypeEnum).optional(), id: z.string().nullish(), stepId: z.string().nullish(), planId: z.string().nullish(), @@ -201,7 +220,8 @@ export const AIChatItemValueSchema = z.object({ .nullish(), reasoning: z .object({ - content: z.string() + content: z.string(), + duration: z.number().optional() }) .nullish(), tools: z.array(ToolModuleResponseItemSchema).nullish(), @@ -312,7 +332,8 @@ export const ToolCiteLinksSchema = z.object({ export type ToolCiteLinksType = z.infer; export const ResponseTagItemSchema = z.object({ - useAgentSandbox: z.boolean().optional(), + useSandboxFileDisplay: z.boolean().optional(), + quoteList: z.array(SearchDataResponseItemSchema).optional(), totalQuoteList: z.array(SearchDataResponseItemSchema).optional(), toolCiteLinks: z.array(ToolCiteLinksSchema).optional(), errorText: ErrorTextItemSchema.optional(), @@ -330,7 +351,11 @@ export type RuntimeUserPromptType = z.infer; export const ChatItemMiniSchema = ChatItemObjItemSchema.and( z.object({ - dataId: z.string().optional() + dataId: z.string().optional(), + correctionId: z.string().optional() }) ).and(ResponseTagItemSchema); export type ChatItemMiniType = z.infer; + +/** @deprecated Use ChatItemMiniType instead */ +export type ChatItemType = ChatItemMiniType; diff --git a/packages/global/core/chat/utils.ts b/packages/global/core/chat/utils.ts index fa9bbd15ebfb..941da98e81f5 100644 --- a/packages/global/core/chat/utils.ts +++ b/packages/global/core/chat/utils.ts @@ -185,7 +185,8 @@ export const filterPublicNodeResponseData = ({ [FlowNodeTypeEnum.agent]: true, [FlowNodeTypeEnum.pluginOutput]: true, [FlowNodeTypeEnum.runApp]: true, - [FlowNodeTypeEnum.toolCall]: true + [FlowNodeTypeEnum.toolCall]: true, + [FlowNodeTypeEnum.tool]: true }; const filedMap: Record = responseDetail @@ -193,12 +194,20 @@ export const filterPublicNodeResponseData = ({ quoteList: true, moduleType: true, pluginOutput: true, - runningTime: true + runningTime: true, + moduleName: true, + moduleLogo: true, + nodeId: true, + id: true } : { moduleType: true, pluginOutput: true, - runningTime: true + runningTime: true, + moduleName: true, + moduleLogo: true, + nodeId: true, + id: true }; return nodeRespones @@ -206,7 +215,7 @@ export const filterPublicNodeResponseData = ({ .map((item) => { const obj: DispatchNodeResponseType = {}; for (let key in item) { - if (key === 'toolDetail' || key === 'pluginDetail') { + if (key === 'toolDetail' || key === 'pluginDetail' || key === 'childrenResponses') { // @ts-ignore obj[key] = filterPublicNodeResponseData({ nodeRespones: item[key], responseDetail }); } else if (filedMap[key]) { diff --git a/packages/global/core/dataset/api.d.ts b/packages/global/core/dataset/api.d.ts new file mode 100644 index 000000000000..c7caa17fd569 --- /dev/null +++ b/packages/global/core/dataset/api.d.ts @@ -0,0 +1,164 @@ +import type { + ChunkSettingsType, + DatasetDataIndexItemType, + DatasetDataFieldType, + DatasetSchemaType, + TableSchemaType, + DatasetTagTypeEnum, + CollectionTagValueType +} from './type'; +import type { + DatasetCollectionTypeEnum, + DatasetCollectionDataProcessModeEnum, + ChunkSettingModeEnum, + DataChunkSplitModeEnum, + ChunkTriggerConfigTypeEnum, + ParagraphChunkAIModeEnum +} from './constants'; +import type { ParentIdType } from '../../common/parentFolder/type'; +import type { APIFileItemType } from './apiDataset/type'; +import type { PermissionEffectScopeEnum } from '../../support/permission/constant'; + +/* ================= collection ===================== */ +// Input + store params +type DatasetCollectionStoreDataType = ChunkSettingsType & { + parentId?: string; + metadata?: Record; + + customPdfParse?: boolean; +}; + +// create collection params +export type CreateDatasetCollectionParams = DatasetCollectionStoreDataType & { + datasetId: string; + name: string; + type: DatasetCollectionTypeEnum; + + fileId?: string; + rawLink?: string; + externalFileId?: string; + externalFileUrl?: string; + apiFileId?: string; + apiFileParentId?: string; //when file is imported by folder, the parentId is the folderId + + rawTextLength?: number; + hashRawText?: string; + + tags?: (string | CollectionTagValueType)[]; + + createTime?: Date; + updateTime?: Date; + tableSchema?: TableSchemaType; + forbid?: boolean; +}; + +export type ApiCreateDatasetCollectionParams = DatasetCollectionStoreDataType & { + datasetId: string; + tags?: (string | CollectionTagValueType)[]; +}; +export type TextCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + name: string; + text: string; +}; +export type LinkCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + link: string; +}; +export type ApiDatasetCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + name: string; + apiFileId: string; +}; +export type ApiDatasetCreateDatasetCollectionV2Params = ApiCreateDatasetCollectionParams & { + apiFiles: APIFileItemType[]; +}; +export type FileIdCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + fileId: string; +}; +export type reTrainingDatasetFileCollectionParams = DatasetCollectionStoreDataType & { + datasetId: string; + collectionId: string; +}; +export type FileCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + fileMetadata?: Record; + collectionMetadata?: Record; +}; +export type CsvTableCreateDatasetCollectionParams = { + datasetId: string; + parentId?: string; + fileId: string; +}; +export type ExternalFileCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + externalFileId?: string; + externalFileUrl: string; + filename?: string; +}; +export type ImageCreateDatasetCollectionParams = ApiCreateDatasetCollectionParams & { + collectionName: string; + overwriteDuplicate?: boolean; +}; + +/* ================= tag ===================== */ +export type CreateDatasetCollectionTagParams = { + datasetId: string; + tag: string; + tagType?: DatasetTagTypeEnum; +}; +export type AddTagsToCollectionsParams = { + originCollectionIds: string[]; + collectionIds: string[]; + datasetId: string; + tag: string; +}; +export type UpdateDatasetCollectionTagParams = { + datasetId: string; + tagId: string; + tag: string; + tagType?: DatasetTagTypeEnum; +}; +export type SetCollectionTagsParams = { + collectionId: string; + tags: CollectionTagValueType[]; +}; +export type BatchSetCollectionTagsParams = { + collectionIds: string[]; + tags: CollectionTagValueType[]; + deleteTagIds?: string[]; + datasetId: string; +}; + +/* ================= data ===================== */ +export type PgSearchRawType = { + id: string; + collection_id: string; + score: number; +}; +export type PushDatasetDataChunkProps = { + id?: string; + q?: string; + a?: string; + imageId?: string; + chunkIndex?: number; + indexes?: Omit[]; + metadata?: Record; +}; + +export type PostDatasetSyncParams = { + datasetId: string; +}; + +export type PushDatasetDataProps = { + collectionId: string; + data: PushDatasetDataChunkProps[]; + trainingType?: DatasetCollectionDataProcessModeEnum; + indexSize?: number; + autoIndexes?: boolean; + imageIndex?: boolean; + prompt?: string; + + billId?: string; + + // Abandon + trainingMode?: DatasetCollectionDataProcessModeEnum; +}; +export type PushDatasetDataResponse = { + insertLen: number; +}; diff --git a/packages/global/core/dataset/apiDataset/type.ts b/packages/global/core/dataset/apiDataset/type.ts index 57f77e7605cf..39bd5b60f291 100644 --- a/packages/global/core/dataset/apiDataset/type.ts +++ b/packages/global/core/dataset/apiDataset/type.ts @@ -1,6 +1,12 @@ import { ParentIdSchema } from '../../../common/parentFolder/type'; import z from 'zod'; +export const FilePermissionSchema = z.object({ + username: z.string(), + permission: z.number() +}); +export type FilePermissionType = z.infer; + export const APIFileItemSchema = z.object({ id: z.string(), rawId: z.string(), @@ -9,7 +15,8 @@ export const APIFileItemSchema = z.object({ type: z.enum(['file', 'folder']), updateTime: z.coerce.date(), createTime: z.coerce.date(), - hasChild: z.boolean().optional() + hasChild: z.boolean().optional(), + permissions: z.array(FilePermissionSchema).optional() }); export type APIFileItemType = z.infer; @@ -18,7 +25,8 @@ export const APIFileServerSchema = z .object({ baseUrl: z.string(), authorization: z.string().optional(), - basePath: z.string().optional() + basePath: z.string().optional(), + permissionSync: z.boolean().optional().meta({ description: '是否同步权限' }) }) .meta({ description: 'API 服务器配置' }); export type APIFileServerType = z.infer; diff --git a/packages/global/core/dataset/apiDataset/utils.ts b/packages/global/core/dataset/apiDataset/utils.ts index 3eeb38f443db..f274a4d00a2a 100644 --- a/packages/global/core/dataset/apiDataset/utils.ts +++ b/packages/global/core/dataset/apiDataset/utils.ts @@ -10,7 +10,8 @@ export const filterApiDatasetServerPublicData = (apiDatasetServer?: ApiDatasetSe ? { baseUrl: apiServer.baseUrl, authorization: '', - basePath: apiServer.basePath + basePath: apiServer.basePath, + permissionSync: apiServer.permissionSync } : undefined, yuqueServer: yuqueServer diff --git a/packages/global/core/dataset/collaborator.ts b/packages/global/core/dataset/collaborator.ts index 9296eadf5ad1..b52ca8c61a8c 100644 --- a/packages/global/core/dataset/collaborator.ts +++ b/packages/global/core/dataset/collaborator.ts @@ -1,8 +1,10 @@ import type { UpdateClbPermissionProps } from '../../support/permission/collaborator'; import type { RequireOnlyOne } from '../../common/type/utils'; +import type { PermissionEffectScopeEnum } from '../../support/permission/constant'; export type UpdateDatasetCollaboratorBody = UpdateClbPermissionProps & { datasetId: string; + permissionEffectScope?: PermissionEffectScopeEnum; }; export type DatasetCollaboratorDeleteParams = { diff --git a/packages/global/core/dataset/collection/collaborator.d.ts b/packages/global/core/dataset/collection/collaborator.d.ts new file mode 100644 index 000000000000..6036c69a6eff --- /dev/null +++ b/packages/global/core/dataset/collection/collaborator.d.ts @@ -0,0 +1,21 @@ +import type { UpdateClbPermissionProps } from '../../../support/permission/collaborator'; +import type { PermissionEffectScopeEnum } from '../../../support/permission/constant'; +import type { RequireOnlyOne } from '../../../common/type/utils'; + +export type UpdateCollectionCollaboratorBody = UpdateClbPermissionProps & { + collectionId: string; + permissionEffectScope?: PermissionEffectScopeEnum; +}; + +export type BatchUpdateCollectionCollaboratorBody = UpdateClbPermissionProps & { + collectionIds: string[]; + permissionEffectScope?: PermissionEffectScopeEnum; +}; + +export type CollectionCollaboratorDeleteParams = { + collectionId: string; +} & RequireOnlyOne<{ + tmbId: string; + groupId: string; + orgId: string; +}>; diff --git a/packages/global/core/dataset/collection/utils.ts b/packages/global/core/dataset/collection/utils.ts index e2ef77d91494..c361ab214d1d 100644 --- a/packages/global/core/dataset/collection/utils.ts +++ b/packages/global/core/dataset/collection/utils.ts @@ -3,12 +3,13 @@ import { type DatasetCollectionSchemaType } from '../type'; export const getCollectionSourceData = (collection?: DatasetCollectionSchemaType) => { return { - sourceId: + sourceId: String( collection?.fileId || - collection?.rawLink || - collection?.externalFileId || - collection?.externalFileUrl || - collection?.apiFileId, + collection?.rawLink || + collection?.externalFileId || + collection?.externalFileUrl || + collection?.apiFileId + ), sourceName: collection?.name || '' }; }; diff --git a/packages/global/core/dataset/constants.ts b/packages/global/core/dataset/constants.ts index 75d36e896b0a..cdb8e8f1beb4 100644 --- a/packages/global/core/dataset/constants.ts +++ b/packages/global/core/dataset/constants.ts @@ -1,4 +1,12 @@ -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; + +/* ------------ training status -------------- */ +export enum DatasetTrainingStatusEnum { + ready = 'ready', + training = 'training', + error = 'error' +} +export type DatasetTrainingStatusType = `${DatasetTrainingStatusEnum}`; /* ------------ dataset -------------- */ export enum DatasetTypeEnum { @@ -9,20 +17,43 @@ export enum DatasetTypeEnum { apiDataset = 'apiDataset', feishu = 'feishu', - yuque = 'yuque' + yuque = 'yuque', + database = 'database', + structureDocument = 'structureDocument' // file database +} +interface DatasetTypeConfig { + icon: string; + avatar: string; + label: any; + collectionLabel: string; + courseUrl?: string; + formConfig?: { + vectorModel?: { + isHidden?: boolean; + tip?: string; + }; + agentModel?: { + isHidden?: boolean; + tip?: string; + }; + vlmModel?: { + isHidden?: boolean; + tip?: string; + }; + }; +} +/* ------------ database_dataset -------------- */ +export enum DatabaseTypeEnum { + mysql = 'mysql', + postgresql = 'postgres', + mssql = 'mssql', + oracle = 'oracle', + sqlite = 'sqlite', + duckdb = 'duckdb' } // @ts-ignore -export const ApiDatasetTypeMap: Record< - `${DatasetTypeEnum}`, - { - icon: string; - avatar: string; - label: any; - collectionLabel: string; - courseUrl?: string; - } -> = { +export const ApiDatasetTypeMap: Record<`${DatasetTypeEnum}`, DatasetTypeConfig> = { [DatasetTypeEnum.apiDataset]: { icon: 'core/dataset/externalDatasetOutline', avatar: 'core/dataset/externalDatasetColor', @@ -45,16 +76,7 @@ export const ApiDatasetTypeMap: Record< courseUrl: '/docs/introduction/guide/knowledge_base/yuque_dataset/' } }; -export const DatasetTypeMap: Record< - `${DatasetTypeEnum}`, - { - icon: string; - avatar: string; - label: any; - collectionLabel: string; - courseUrl?: string; - } -> = { +export const DatasetTypeMap: Record<`${DatasetTypeEnum}`, DatasetTypeConfig> = { ...ApiDatasetTypeMap, [DatasetTypeEnum.folder]: { icon: 'common/folderFill', @@ -75,6 +97,41 @@ export const DatasetTypeMap: Record< collectionLabel: i18nT('common:Website'), courseUrl: '/docs/introduction/guide/knowledge_base/websync/' }, + [DatasetTypeEnum.database]: { + icon: 'core/dataset/databaseOutline', + avatar: 'core/dataset/databaseColor', + label: i18nT('dataset:enterprise_database'), + collectionLabel: i18nT('dataset:enterprise_database'), + formConfig: { + agentModel: { + isHidden: true + }, + vlmModel: { + isHidden: true + }, + vectorModel: { + isHidden: false, + tip: i18nT('dataset:enterprise_database_embedding_model_tip') + } + } + }, + [DatasetTypeEnum.structureDocument]: { + icon: 'core/dataset/fileDb', + avatar: 'core/dataset/fileDbColor', + label: i18nT('dataset:file_database'), + collectionLabel: i18nT('common:File'), + formConfig: { + agentModel: { + isHidden: true + }, + vlmModel: { + isHidden: true + }, + vectorModel: { + isHidden: true + } + } + }, [DatasetTypeEnum.externalFile]: { icon: 'core/dataset/externalDatasetOutline', avatar: 'core/dataset/externalDatasetColor', @@ -105,6 +162,16 @@ export const DatasetStatusMap = { }; /* ------------ collection -------------- */ +// 文件训练状态枚举 +export enum CollectionStatusEnum { + ready = 'ready', // 已就绪 + queued = 'queued', // 排队中 + parsing = 'parsing', // 解析中 + indexing = 'indexing', // 索引中 + error = 'error', // 异常 + notExist = 'notExist' // 不存在(数据库知识库的表被删除) +} + export enum DatasetCollectionTypeEnum { folder = 'folder', virtual = 'virtual', @@ -113,7 +180,8 @@ export enum DatasetCollectionTypeEnum { link = 'link', // one link externalFile = 'externalFile', apiFile = 'apiFile', - images = 'images' + images = 'images', + table = 'table' // database table } export const DatasetCollectionTypeMap = { [DatasetCollectionTypeEnum.folder]: { @@ -136,6 +204,9 @@ export const DatasetCollectionTypeMap = { }, [DatasetCollectionTypeEnum.images]: { name: i18nT('dataset:core.dataset.Image collection') + }, + [DatasetCollectionTypeEnum.table]: { + name: i18nT('common:core.dataset.table') } }; @@ -163,7 +234,7 @@ export enum DatasetCollectionDataProcessModeEnum { backup = 'backup', template = 'template', - + databaseSchema = 'databaseSchema', auto = 'auto' // abandon } export const DatasetCollectionDataProcessModeMap = { @@ -191,6 +262,10 @@ export const DatasetCollectionDataProcessModeMap = { [DatasetCollectionDataProcessModeEnum.template]: { label: i18nT('dataset:template_mode'), tooltip: i18nT('dataset:template_mode') + }, + [DatasetCollectionDataProcessModeEnum.databaseSchema]: { + label: i18nT('common:core.dataset.training.databaseSchema mode'), + tooltip: i18nT('common:core.dataset.import.databaseSchema Tip') } }; @@ -225,7 +300,8 @@ export enum ImportDataSourceEnum { externalFile = 'externalFile', apiDataset = 'apiDataset', reTraining = 'reTraining', - imageDataset = 'imageDataset' + imageDataset = 'imageDataset', + database = 'database' } export enum TrainingModeEnum { @@ -234,14 +310,26 @@ export enum TrainingModeEnum { qa = 'qa', auto = 'auto', image = 'image', - imageParse = 'imageParse' + imageParse = 'imageParse', + databaseSchema = 'databaseSchema', + hype = 'hype', + small2Big = 'small2Big', + synonymStandardize = 'synonymStandardize', + synonymRestore = 'synonymRestore' } /* ------------ search -------------- */ export enum DatasetSearchModeEnum { embedding = 'embedding', fullTextRecall = 'fullTextRecall', - mixedRecall = 'mixedRecall' + mixedRecall = 'mixedRecall', + database = 'database' +} + +/* ------------ retrieval mode -------------- */ +export enum DatasetRetrievalModeEnum { + standard = 'standard', // 单轮标准检索 + agentic = 'agentic' // 多轮智能检索 } export const DatasetSearchModeMap = { @@ -262,6 +350,12 @@ export const DatasetSearchModeMap = { title: i18nT('common:core.dataset.search.mode.mixedRecall'), desc: i18nT('common:core.dataset.search.mode.mixedRecall desc'), value: DatasetSearchModeEnum.mixedRecall + }, + [DatasetSearchModeEnum.database]: { + icon: 'core/dataset/databaseColor', + title: i18nT('common:core.dataset.search.mode.database'), + desc: i18nT('common:core.dataset.search.mode.database desc'), + value: DatasetSearchModeEnum.database } }; @@ -294,6 +388,18 @@ export const SearchScoreTypeMap = { } }; +export enum RerankMethodEnum { + question = 'question', + content = 'content' // Default +} + +// Reranker策略枚举(仅适用于assistant场景的多query重排) +// 默认使用 RerankStrategyEnum.concat +export enum RerankStrategyEnum { + concat = 'concat', // 拼接策略:将多个query用\n拼接后一起rerank(默认) + maxScore = 'maxScore' // 最高分策略:每个query单独rerank,取最高分 +} + export const CustomCollectionIcon = 'common/linkBlue'; export const LinkCollectionIcon = 'common/linkBlue'; @@ -305,3 +411,9 @@ export enum DatasetSourceReadTypeEnum { apiFile = 'apiFile', reTraining = 'reTraining' } + +/* ------------ synonym -------------- */ +// 同义词文件集合名称 +export const DatasetSynonymCollectionName = 'dataset_synonyms'; +// 同义词映射集合名称 +export const DatasetSynonymMappingCollectionName = 'dataset_synonym_mappings'; diff --git a/packages/global/core/dataset/controller.d.ts b/packages/global/core/dataset/controller.d.ts new file mode 100644 index 000000000000..871503cd9876 --- /dev/null +++ b/packages/global/core/dataset/controller.d.ts @@ -0,0 +1,49 @@ +import type { DatasetDataIndexItemType, DatasetDataSchemaType } from './type'; + +export type CreateDatasetDataProps = { + id?: string; + teamId: string; + tmbId: string; + datasetId: string; + collectionId: string; + chunkIndex?: number; + q: string; + a?: string; + imageId?: string; + indexes?: Omit[]; + metadata?: Record; + indexPrefix?: string; +}; + +export type UpdateDatasetDataProps = { + dataId: string; + + q: string; + a?: string; + indexes?: (Omit & { + dataId?: string; // pg data id + })[]; + metadata?: Record; + imageId?: string; + indexPrefix?: string; +}; + +export type PatchIndexesProps = + | { + type: 'create'; + index: Omit & { + dataId?: string; + }; + } + | { + type: 'update'; + index: DatasetDataIndexItemType; + } + | { + type: 'delete'; + index: DatasetDataIndexItemType; + } + | { + type: 'unChange'; + index: DatasetDataIndexItemType; + }; diff --git a/packages/global/core/dataset/data/constants.ts b/packages/global/core/dataset/data/constants.ts index 2cc17562ad5a..58f94f7926a6 100644 --- a/packages/global/core/dataset/data/constants.ts +++ b/packages/global/core/dataset/data/constants.ts @@ -1,11 +1,17 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export enum DatasetDataIndexTypeEnum { default = 'default', custom = 'custom', summary = 'summary', question = 'question', - image = 'image' + image = 'image', + column_des_index = 'column_des_index', + column_val_index = 'column_val_index', + hype = 'hype', + small2Big = 'small2Big', + synthesis = 'synthesis', + correction = 'correction' } export const DatasetDataIndexMap: Record< @@ -34,6 +40,30 @@ export const DatasetDataIndexMap: Record< [DatasetDataIndexTypeEnum.image]: { label: i18nT('dataset:data_index_image'), color: 'purple' + }, + [DatasetDataIndexTypeEnum.column_des_index]: { + label: i18nT('dataset:data_index_column_description'), + color: 'orange' + }, + [DatasetDataIndexTypeEnum.column_val_index]: { + label: i18nT('dataset:data_index_column_value'), + color: 'cyan' + }, + [DatasetDataIndexTypeEnum.hype]: { + label: i18nT('dataset:data_index_hype'), + color: 'teal' + }, + [DatasetDataIndexTypeEnum.small2Big]: { + label: i18nT('dataset:data_index_small2big'), + color: 'pink' + }, + [DatasetDataIndexTypeEnum.synthesis]: { + label: i18nT('dataset:data_index_synthesis'), + color: 'violet' + }, + [DatasetDataIndexTypeEnum.correction]: { + label: i18nT('dataset:data_index_correction'), + color: 'yellow' } }; export const defaultDatasetIndexData = DatasetDataIndexMap[DatasetDataIndexTypeEnum.custom]; diff --git a/packages/global/core/dataset/database/api.ts b/packages/global/core/dataset/database/api.ts new file mode 100644 index 000000000000..e5e03cd5e04f --- /dev/null +++ b/packages/global/core/dataset/database/api.ts @@ -0,0 +1,265 @@ +import type { Readable } from 'stream'; +import type { ColumnSchemaType, TableSchemaType, DatabaseConfig } from '../type'; +import { ConstraintSchemaType, ForeignKeySchemaType } from '../type'; +import type { DatabaseTypeEnum } from '../constants'; +import type { BucketNameEnum } from '../../../common/file/constants'; +/*-------API Request & Response Types-------*/ + +export type CheckConnectionBody = { + datasetId: string; + databaseConfig: DatabaseConfig; +}; + +/*-------Create Database Collections Type-------*/ +export type DatabaseCollectionsTable = Omit & { forbid: boolean }; + +export type DatabaseCollectionsBody = { + tables: DatabaseCollectionsTable[]; +}; + +export type CreateDatabaseCollectionsBody = DatabaseCollectionsBody & { datasetId: string }; + +export type CreateDatabaseCollectionsResponse = { + collectionIds: string[]; +}; + +/*-------Detect Changes Type-------*/ +export enum StatusEnum { + add = 'add', + delete = 'delete', + available = 'available' +} + +export type DBTableColumn = ColumnSchemaType & { status: StatusEnum }; + +export type DBTableChange = Omit & { + forbid: boolean; + status: StatusEnum; + columns: Record; +}; + +export type DetectChangesQuery = { + datasetId: string; +}; + +export type DetectChangesResponse = { + tables: DBTableChange[]; + hasChanges: boolean; + summary: { + addedTables: number; + deletedTables: number; + modifiedTables: number; + addedColumns: number; + deletedColumns: number; + }; +}; +/*-------Apply Changes Type-------*/ +export type ApplyChangesBody = { + datasetId: string; + tables: Array; +}; + +export type ApplyChangesResponse = { + success: boolean; + processedItems: { + deletedTables: number; + updatedTables: number; + addedTables: number; + affectedDataRecords: number; + }; + errors: Array<{ + type: 'table' | 'column' | 'data'; + target: string; + error: string; + }>; + taskId?: string; +}; + +/*-------Database Search Test Type-------*/ +export type DatabaseSearchTestBody = { + datasetId: string; + query: string; + modelId?: string; +}; + +/*-------Dativate Retrieval Type-------*/ + +export type DativeCostraintKey = { + name: string; + column: string; +}; +export type DativeForeignKey = DativeCostraintKey & { + referenced_schema: string; + referenced_table: string; + referenced_column: string; +}; +export type DativeTableColumns = { + name: string; + type: string; + comment: string; + auto_increment: boolean; + nullable: boolean; + default: any; + examples: Array; + enabled: boolean; + value_index: boolean; +}; +export type DativeTable = { + name: string; + ns_name?: string; + comment: string; + columns: Record; + primary_keys: Array; + foreign_keys: Array; + enable: boolean; + score: number; +}; + +export type DatabaseMetadata = { + name: string; // databaseName + comments?: string; + tables: Array; +}; + +export type DativeDBConfigType = { + host: string; + port: number; + username: string; + password: string; + db_name: string; +}; + +export type MysqlConfigType = DativeDBConfigType & { + type: DatabaseTypeEnum.mysql; + conn_pool_size?: number; +}; + +export type PostgresqlConfigType = DativeDBConfigType & { + type: DatabaseTypeEnum.postgresql; + ns_name?: string; + conn_pool_size?: number; +}; + +export type MssqlConfigType = DativeDBConfigType & { + type: DatabaseTypeEnum.mssql; + encrypt?: boolean; + trustServerCertificate?: boolean; + ns_name?: string; +}; + +export type OracleConfigType = DativeDBConfigType & { + type: DatabaseTypeEnum.oracle; + serviceName?: string; + sid?: string; + ns_name?: string; +}; + +export type SqliteConfigType = DativeDBConfigType & { + type: DatabaseTypeEnum.sqlite; + db_path?: string; +}; + +export type DuckDBS3StoreType = { + type: 's3'; + kid: string; +}; + +export type DuckDBStoreConfigType = { + type: DatabaseTypeEnum.duckdb; + store: DuckDBS3StoreType; +}; + +// databse source config type +export type DativeSourceConfigType = + | MysqlConfigType + | DuckDBStoreConfigType + | PostgresqlConfigType + | MssqlConfigType + | OracleConfigType + | SqliteConfigType; + +// SQL Generation types +export type SqlGenerationRequest = { + source_config: DativeSourceConfigType; + generate_sql_llm: { + model: string; + api_key?: string; + base_url?: string; + }; + evaluate_sql_llm: { + model: string; + api_key?: string; + base_url?: string; + }; + query: string; + result_num_limit: number; + retrieved_metadata?: DatabaseMetadata; + evidence?: string; +}; + +export type SqlGenerationResponse = { + answer: string; + sql: string; + sql_res: { + data: any[]; + columns: string[]; + }; + input_tokens: number; + output_tokens: number; +}; + +export type SqlResultWithDatasetId = SqlGenerationResponse & { datasetId: string }; + +/*-------Excel Upload Types-------*/ +export type DativeExcelUploadResponse = { + msg: string; + file_id: string; + filename: string; + rows: number; + cols: number; +}; + +export interface ExcelUploadSourceConfig { + type: string; + bucket?: BucketNameEnum | string; + kid: string; + metadata?: { + teamId: string; + uid: string; + [key: string]: any; + }; +} + +export interface ExcelUploadRequest { + fileStream: Readable; + contentType: string; + sourceConfig: ExcelUploadSourceConfig; + // Optional request timeout in milliseconds (default: 300000) + timeout?: number; +} + +/*-------Preview Data Types-------*/ +export type PreviewDataQuery = { + collectionId: string; +}; + +export type PreviewDataResponse = { + cols: string[]; + data: Array>; + rowCount: number; + columnCount: number; +}; + +/*-------SQL Query Types-------*/ +export type SqlQueryRequest = { + source_config: DativeSourceConfigType; + sql: string; +}; + +export type SqlQueryResponse = { + cols: string[]; + data: Array>; +}; + +/*-------Database Metadata Types-------*/ +export type DatabaseMetadataRequest = DativeSourceConfigType; diff --git a/packages/global/core/dataset/training/utils.ts b/packages/global/core/dataset/training/utils.ts index fb9783df18f1..f592beb1c9d4 100644 --- a/packages/global/core/dataset/training/utils.ts +++ b/packages/global/core/dataset/training/utils.ts @@ -1,4 +1,3 @@ -import { getEmbeddingModel } from '../../../../service/core/ai/model'; import { type EmbeddingModelItemType, type LLMModelItemType } from '../../ai/model.schema'; import { ChunkSettingModeEnum, @@ -30,17 +29,13 @@ export const getLLMMaxChunkSize = (model?: LLMModelItemType) => { }; // Index size -export const getMaxIndexSize = (model?: EmbeddingModelItemType | string) => { +export const getMaxIndexSize = (model?: EmbeddingModelItemType) => { if (!model) return 512; - const modelData = typeof model === 'string' ? getEmbeddingModel(model) : model; - - return modelData?.maxToken || 512; + return model?.maxToken || 512; }; -export const getAutoIndexSize = (model?: EmbeddingModelItemType | string) => { +export const getAutoIndexSize = (model?: EmbeddingModelItemType) => { if (!model) return 512; - - const modelData = typeof model === 'string' ? getEmbeddingModel(model) : model; - return modelData?.defaultToken || 512; + return model?.defaultToken || 512; }; const indexSizeSelectList = [ diff --git a/packages/global/core/dataset/type.ts b/packages/global/core/dataset/type.ts index af8b3472c1b9..61b004d79cc2 100644 --- a/packages/global/core/dataset/type.ts +++ b/packages/global/core/dataset/type.ts @@ -17,12 +17,15 @@ import { FeishuServerSchema, YuqueServerSchema } from './apiDataset/type'; +import type { DatasetPermission } from '../../support/permission/dataset/controller'; +import type { CollectionStatusEnum, DatasetTrainingStatusEnum } from '../../core/dataset/constants'; import { SourceMemberSchema } from '../../support/user/type'; import { DatasetDataIndexTypeEnum } from './data/constants'; import { ParentIdSchema } from '../../common/parentFolder/type'; import z from 'zod'; import { ObjectIdSchema } from '../../common/type/mongo'; import { PermissionSchema } from '../../support/permission/controller'; +import { PermissionEffectScopeEnum } from '../../support/permission/constant'; /* ===== Chunk ===== */ export const ChunkSettingsSchema = z.object({ @@ -56,9 +59,29 @@ export const ChunkSettingsSchema = z.object({ chunkSize: z.number().optional().meta({ description: '分块大小' }), chunkSplitter: z.string().optional().meta({ description: '自定义最高优先分割符号' }), indexSize: z.number().optional().meta({ description: '索引大小' }), + hypeIndexes: z.boolean().optional().meta({ description: '超级索引' }), + small2bigIndexes: z.boolean().optional().meta({ description: '小到大索引' }), + hypeIndexPrompt: z.string().optional().meta({ description: '超级索引提示词' }), + small2bigConfig: z + .object({ + chunkSize: z.number().optional(), + customSplitChar: z.string().optional(), + overlap: z.number().optional(), + overlapRatio: z.number().optional(), + maxChildChunks: z.number().optional(), + paragraphChunkDeep: z.number().optional(), + paragraphChunkMinSize: z.number().optional(), + maxSize: z.number().optional(), + customReg: z.array(z.string()).optional() + }) + .optional() + .meta({ description: '小到大索引配置' }), + autoIndexesPrompt: z.string().optional().meta({ description: '自动索引提示词' }), + imageIndexPrompt: z.string().optional().meta({ description: '图片索引提示词' }), qaPrompt: z.string().optional().meta({ description: 'QA 拆分提示词' }) }); export type ChunkSettingsType = z.infer; +export type small2bigConfigType = NonNullable; /* ===== Dataset ===== */ export const DatasetSchema = z @@ -70,15 +93,19 @@ export const DatasetSchema = z tmbId: ObjectIdSchema.meta({ description: '团队成员 ID' }), updateTime: z.coerce.date().meta({ description: '更新时间' }), inheritPermission: z.boolean().meta({ description: '继承权限' }), + permissionEffectScope: z + .nativeEnum(PermissionEffectScopeEnum) + .optional() + .meta({ description: '权限生效范围' }), avatar: z.string().meta({ description: '头像' }), name: z.string().meta({ description: '名称' }), intro: z.string().meta({ description: '简介' }), type: z.enum(DatasetTypeEnum).meta({ description: '数据集类型' }), - vectorModel: z.string().meta({ description: '向量模型' }), - agentModel: z.string().meta({ description: 'AI 模型' }), - vlmModel: z.string().optional().meta({ description: '视觉语言模型' }), + vectorModelId: z.string().meta({ description: '向量模型id' }), + agentModelId: z.string().meta({ description: 'AI 模型id' }), + vlmModelId: z.string().optional().meta({ description: '视觉语言模型Id' }), websiteConfig: z .object({ @@ -88,9 +115,16 @@ export const DatasetSchema = z .optional() .meta({ description: '网站配置' }), chunkSettings: ChunkSettingsSchema.optional().meta({ description: '分块配置' }), + synonymFiles: z.array(z.string()).optional().meta({ description: '同义词文件' }), + databaseConfig: z.any().optional().meta({ description: '数据库配置' }), apiDatasetServer: ApiDatasetServerSchema.optional().meta({ description: 'API 服务器配置' }), + keep_header_footer: z.boolean().optional().meta({ description: '保留页眉页脚' }), + keep_appendix: z.boolean().optional().meta({ description: '保留附录' }), + image_analysis: z.boolean().optional().meta({ description: '文档图片识别' }), + chart_analysis: z.boolean().optional().meta({ description: '统计图转表格' }), + deleteTime: z.coerce.date().nullish().meta({ description: '删除时间' }), autoSync: z.boolean().optional().meta({ description: '自动同步', deprecated: true }), @@ -112,6 +146,17 @@ export const DatasetSchema = z .meta({ description: '知识库' }); export type DatasetSchemaType = z.infer; +/* ===== Collection tag value ===== */ +export type CollectionTagValueType = { + tagId: string; // 引用 dataset_collection_tags._id + value: string | number; // string 类型存字符串,datetime 类型存 UTC 毫秒时间戳(number) + label?: string; // 标签名称(展示用),由服务端在列表接口返回时附加 +}; +export const CollectionTagValueSchema = z.object({ + tagId: z.string().meta({ description: '标签 ID' }), + value: z.union([z.string(), z.number()]).meta({ description: '标签值' }) +}); + /* ===== Collection ===== */ export const DatasetCollectionSchema = ChunkSettingsSchema.omit({ trainingType: true @@ -123,13 +168,33 @@ export const DatasetCollectionSchema = ChunkSettingsSchema.omit({ parentId: ParentIdSchema.meta({ description: '父级 ID' }), name: z.string().meta({ description: '名称' }), type: z.enum(DatasetCollectionTypeEnum).meta({ description: '集合类型' }), - tags: z.array(z.string()).optional().meta({ description: '标签' }), + tags: z + .array(z.union([z.string(), CollectionTagValueSchema])) + .optional() + .meta({ description: '标签' }), createTime: z.coerce.date().meta({ description: '创建时间' }), updateTime: z.coerce.date().meta({ description: '更新时间' }), + parsingCompleteTime: z.coerce.date().optional().meta({ description: '解析完成时间' }), + parseStartTime: z.coerce + .date() + .optional() + .meta({ description: '解析开始时间(Worker 首次拉取 parse 任务时设置)' }), + indexingStartTime: z.coerce + .date() + .optional() + .meta({ description: '索引开始时间(Worker 首次拉取非 parse 任务时设置)' }), + indexingCompleteTime: z.coerce.date().optional().meta({ description: '索引完成时间' }), forbid: z.boolean().optional().meta({ description: '是否禁用' }), + // Permission + inheritPermission: z.boolean().optional().meta({ description: '继承权限' }), + permissionEffectScope: z + .nativeEnum(PermissionEffectScopeEnum) + .optional() + .meta({ description: '权限生效范围' }), + fileId: z.string().optional().meta({ description: '文件 ID' }), rawLink: z.string().optional().meta({ description: '原始链接' }), externalFileId: z.string().optional().meta({ description: '外部文件 ID' }), @@ -139,6 +204,7 @@ export const DatasetCollectionSchema = ChunkSettingsSchema.omit({ rawTextLength: z.number().optional().meta({ description: '原始文本长度' }), hashRawText: z.string().optional().meta({ description: '文本哈希' }), + fileMd5: z.string().optional().meta({ description: '文件内容 MD5' }), metadata: z.record(z.string(), z.any()).optional().meta({ description: '其他元数据' }), @@ -146,7 +212,19 @@ export const DatasetCollectionSchema = ChunkSettingsSchema.omit({ trainingType: z .enum(DatasetCollectionDataProcessModeEnum) .optional() - .meta({ description: '训练类型' }) + .meta({ description: '训练类型' }), + tableSchema: z.any().optional().meta({ description: '数据库表结构' }), + deleteTime: z.date().nullish().meta({ description: '软删除时间' }), + + // Precomputed statistics (updated asynchronously by collectionUpdate worker) + dataAmount: z.number().optional().meta({ description: '分片数据总量' }), + trainingAmount: z.number().optional().meta({ description: '训练任务总量' }), + processedCount: z.number().optional().meta({ description: '已完成索引的数据量' }), + remainingCount: z.number().optional().meta({ description: '未完成索引的数据量' }), + hasError: z.boolean().optional().meta({ description: '是否有训练任务报错' }), + errorCount: z.number().optional().meta({ description: '错误训练任务数量' }), + allParse: z.boolean().optional().meta({ description: '是否所有训练任务都是 parse 模式' }), + statsUpdatedAt: z.coerce.date().optional().meta({ description: '统计数据最后更新时间' }) }); export type DatasetCollectionSchemaType = z.infer; @@ -166,7 +244,15 @@ export const DatasetDataIndexItemSchema = z.object({ .default(DatasetDataIndexTypeEnum.custom) .meta({ description: '索引类型' }), dataId: z.string().meta({ description: 'vectorDB ID' }), - text: z.string().meta({ description: '索引文本' }) + text: z.string().meta({ description: '索引文本' }), + synId: z.number().optional().meta({ description: '合成对 ID' }), + synonymMetadata: z + .object({ + synonymFileIds: z.array(z.string()), + transformations: z.array(z.any()) + }) + .optional() + .meta({ description: '同义词转换元数据' }) }); const DatasetDataIndexOptionalSchema = DatasetDataIndexItemSchema.omit({ dataId: true }).extend({ dataId: z.string().optional().meta({ @@ -188,6 +274,13 @@ export const DatasetDataHistorySchema = DatasetDataFieldSchema.extend({ }); export type DatasetDataHistoryType = z.infer; +export const DatasetDataPhaseTimingSchema = z.object({ + phase: z.string().meta({ description: '处理阶段名称' }), + startTime: z.coerce.date().meta({ description: '开始时间' }), + endTime: z.coerce.date().optional().meta({ description: '结束时间' }) +}); +export type DatasetDataPhaseTimingType = z.infer; + export const DatasetDataSchema = DatasetDataFieldSchema.extend({ _id: ObjectIdSchema.meta({ description: '数据 ID' }), userId: ObjectIdSchema.optional().meta({ description: '用户 ID', deprecated: true }), @@ -196,13 +289,25 @@ export const DatasetDataSchema = DatasetDataFieldSchema.extend({ datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }), collectionId: ObjectIdSchema.meta({ description: '集合 ID' }), chunkIndex: z.int().min(0).meta({ description: '块索引' }), + createTime: z.coerce.date().optional().meta({ description: '创建时间(入库时间)' }), updateTime: z.coerce.date().meta({ description: '更新时间' }), + indexingCompleteTime: z.coerce.date().optional().meta({ description: '索引完成时间' }), history: z.array(DatasetDataHistorySchema).optional().meta({ description: '历史版本' }), forbid: z.boolean().optional().meta({ description: '是否禁用' }), fullTextToken: z.string().meta({ description: '全文 token' }), indexes: z.array(DatasetDataIndexItemSchema).meta({ description: '向量索引' }), rebuilding: z.boolean().optional().meta({ description: '重建中' }), - imageDescMap: z.record(z.string(), z.string()).optional().meta({ description: '图片描述映射' }) + imageDescMap: z.record(z.string(), z.string()).optional().meta({ description: '图片描述映射' }), + metadata: z.record(z.string(), z.any()).optional().meta({ description: '元数据' }), + synonymProcessing: z + .enum(['standardize', 'restore']) + .optional() + .meta({ description: '同义词处理状态' }), + synonymFileIds: z.array(z.string()).optional().meta({ description: '同义词文件 ID' }), + phaseTimings: z + .array(DatasetDataPhaseTimingSchema) + .optional() + .meta({ description: '各处理阶段的开始/结束时间' }) }); export type DatasetDataSchemaType = z.infer; @@ -224,7 +329,6 @@ export const DatasetTrainingSchema = z.object({ datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }), collectionId: ObjectIdSchema.meta({ description: '集合 ID' }), billId: z.string().meta({ description: '账单 ID' }), - expireAt: z.coerce.date().meta({ description: '过期时间' }), lockTime: z.coerce.date().meta({ description: '锁定时间' }), mode: z.enum(TrainingModeEnum).meta({ description: '训练模式' }), dataId: z.string().optional().meta({ description: '数据 ID' }), @@ -240,6 +344,8 @@ export const DatasetTrainingSchema = z.object({ .meta({ description: '向量索引' }), retryCount: z.number().meta({ description: '重试次数' }), errorMsg: z.string().optional().meta({ description: '错误信息' }), + useGpuQueue: z.boolean().optional().meta({ description: '是否使用GPU队列' }), + dataMetadata: z.record(z.string(), z.any()).optional().meta({ description: '数据元数据' }), userId: ObjectIdSchema.optional().meta({ description: '用户 ID', deprecated: true }) }); @@ -271,15 +377,23 @@ export const DatasetListItemSchema = z.object({ permission: PermissionSchema, vectorModel: EmbeddingModelItemSchema.meta({ description: '向量模型' }), inheritPermission: z.boolean().meta({ description: '继承权限' }), + permissionEffectScope: z + .nativeEnum(PermissionEffectScopeEnum) + .optional() + .meta({ description: '权限生效范围' }), private: z.boolean().optional().meta({ description: '是否私有' }), - sourceMember: SourceMemberSchema.optional().meta({ description: '来源成员' }) + sourceMember: SourceMemberSchema.optional().meta({ description: '来源成员' }), + dataCount: z.number().optional().meta({ description: '数据数量' }), + appCount: z.number().optional().meta({ description: '关联应用数量' }), + fileCount: z.number().optional().meta({ description: '文件数量' }), + processingCount: z.number().optional().meta({ description: '处理中的文件数量' }) }); export type DatasetListItemType = z.infer; export const DatasetItemSchema = DatasetSchema.omit({ - vectorModel: true, - agentModel: true, - vlmModel: true + vectorModelId: true, + agentModelId: true, + vlmModelId: true }).extend({ status: z.enum(DatasetStatusEnum).meta({ description: '状态' }), errorMsg: z.string().optional().meta({ description: '错误信息' }), @@ -293,13 +407,14 @@ export type DatasetItemType = z.infer; /* ================= tag ===================== */ export const DatasetTagSchema = z.object({ _id: ObjectIdSchema.meta({ description: '标签 ID' }), - tag: z.string().meta({ description: '标签' }) + tag: z.string().meta({ description: '标签' }), + tagType: z.enum(['string', 'number', 'datetime']).optional().meta({ description: '标签类型' }) }); export type DatasetTagType = z.infer; export const TagUsageSchema = z.object({ - tagId: z.string().meta({ description: '标签 ID' }), - collections: z.array(z.string()).meta({ description: '集合 ID' }) + tagId: ObjectIdSchema.meta({ description: '标签 ID' }), + collections: z.array(ObjectIdSchema).meta({ description: '集合 ID' }) }); export type TagUsageType = z.infer; @@ -332,7 +447,9 @@ export const DatasetDataItemSchema = DatasetDataFieldSchema.extend({ sourceId: z.string().optional().meta({ description: '来源 ID' }), chunkIndex: z.number().meta({ description: '块索引' }), indexes: z.array(DatasetDataIndexItemSchema).meta({ description: '向量索引' }), - isOwner: z.boolean().meta({ description: '是否为 owner' }) + isOwner: z.boolean().meta({ description: '是否为 owner' }), + metadata: z.record(z.string(), z.any()).optional().meta({ description: '元数据' }), + trainingStatus: z.string().optional().meta({ description: '训练状态' }) }); export type DatasetDataItemType = z.infer; @@ -364,11 +481,12 @@ export type UpdateDatasetDataPropsType = z.infer; @@ -414,7 +533,9 @@ export const SearchDataResponseItemSchema = DatasetDataItemSchema.omit({ index: z.number().meta({ description: '索引' }) }) ) - .meta({ description: '评分列表' }) + .meta({ description: '评分列表' }), + retrievalRank: z.number().optional().meta({ description: '检索排名' }), + synonymMappings: z.array(z.any()).optional().meta({ description: '同义词映射' }) }) .meta({ description: '搜索数据响应项' }); export type SearchDataResponseItemType = z.infer; @@ -434,3 +555,159 @@ export const DatasetCiteItemSchema = z description: '知识库引用数据列表' }); export type DatasetCiteItemType = z.infer; + +/* ============= Database types =============== */ +export type DatabaseConfig = { + clientType: string; + version?: string; + host: string; + port?: number; + database: string; + user: string; + password: string; + poolSize?: number; + schema?: string; + encrypt?: boolean; + trustServerCertificate?: boolean; +}; + +export type ColumnSchemaType = { + columnName: string; + columnType: string; + description: string; + examples: string[]; + forbid: boolean; + valueIndex: boolean; + isNullable?: boolean; + defaultValue?: string | null; + isAutoIncrement?: boolean; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + relatedColumns?: string[]; + metadata?: Record; +}; + +export type ConstraintSchemaType = { + name: string; + column: string; +}; + +export type ForeignKeySchemaType = ConstraintSchemaType & { + referredSchema: string; + referredTable: string; + referredColumns: string; +}; + +export type TableSchemaType = { + tableName: string; + description: string; + exist: boolean; + columns: Record; + foreignKeys: ForeignKeySchemaType[]; + primaryKeys: string[]; + constraints: ConstraintSchemaType[]; + lastUpdated: Date; +}; + +/* ============= Assistant source types =============== */ +export type AssistantSourceType = 'faq' | 'sql' | 'correction' | 'chunk'; + +export type AssistantDatasetCiteItemType = DatasetCiteItemType & { + sourceType: AssistantSourceType; +}; + +export type AssistantSearchDataResponseItemType = SearchDataResponseItemType & { + sourceType: AssistantSourceType; +}; + +/* ============= Synonym types =============== */ +export type DatasetSynonymSchemaType = { + _id: string; + teamId: string; + datasetId: string; + fileName: string; + fileId: string; + size: number; + uploadTime: Date; + uploaderId: string; +}; + +export type DatasetSynonymMappingSchemaType = { + _id: string; + teamId: string; + datasetId: string; + synonymFileId: string; + standardizedTerm: string; + synonymTerms: string[]; + allTerms: string; + createdTime: Date; + updatedTime: Date; +}; + +export type TransformationRecordType = { + originalStartPos: number; + originalEndPos: number; + originalTerm: string; + transformedStartPos: number; + transformedEndPos: number; + standardizedTerm: string; + synonymMappingId: string; +}; + +export type DatasetCollectionsListItemType = { + tableSchemaDescription?: DatasetCollectionSchemaType['tableSchema']['description']; + _id: string; + parentId?: string | null; + tmbId: DatasetCollectionSchemaType['tmbId']; + name: DatasetCollectionSchemaType['name']; + type: DatasetCollectionSchemaType['type']; + createTime: DatasetCollectionSchemaType['createTime']; + updateTime: DatasetCollectionSchemaType['updateTime']; + forbid?: DatasetCollectionSchemaType['forbid']; + trainingType?: DatasetCollectionSchemaType['trainingType']; + tags?: (string | CollectionTagValueType)[]; + + externalFileId?: string; + autoSync?: boolean; + + fileId?: string; + rawLink?: string; + permission: DatasetPermission; + inheritPermission?: DatasetCollectionSchemaType['inheritPermission']; + permissionEffectScope?: DatasetCollectionSchemaType['permissionEffectScope']; + + dataAmount: number; + trainingAmount: number; + processedCount?: number; + remainingCount?: number; + hasError?: boolean; + errorCount?: number; + + // 计算得出的状态字段 + // - 对于普通文件:单一状态值 + // - 对于 folder:使用 matchingStatuses 数组(递归聚合模式) + status?: CollectionStatusEnum; // 文件的单一状态(folder 无此字段) + matchingStatuses?: CollectionStatusEnum[]; // folder 的匹配状态数组(仅 folder 类型有此字段) + + // For database type datasets, include table schema description + tableSchema?: DatasetCollectionSchemaType['tableSchema']; + + // For structureDocument type datasets, include row and column count + rows?: number; + cols?: number; +}; + +/* ================= data ===================== */ +export type DatasetDataListItemType = { + _id: string; + datasetId: string; + collectionId: string; + q?: string; + a?: string; + imageId?: string; + imageSize?: number; + imagePreviewUrl?: string; //image preview url + chunkIndex?: number; + updated?: boolean; + trainingStatus?: `${DatasetTrainingStatusEnum}`; +}; diff --git a/packages/global/core/dataset/utils.ts b/packages/global/core/dataset/utils.ts index 42dbc2315dbf..656abe3bc774 100644 --- a/packages/global/core/dataset/utils.ts +++ b/packages/global/core/dataset/utils.ts @@ -2,6 +2,14 @@ import { TrainingModeEnum, DatasetCollectionTypeEnum } from './constants'; import { getFileIcon } from '../../common/file/icon'; import { strIsLink } from '../../common/string/tools'; +export function isDatabaseSource(sourceId?: string) { + return sourceId?.startsWith('sql'); +} + +export function isCorrectionSource(sourceId?: string) { + return sourceId?.startsWith('correction_quote'); +} + export function getCollectionIcon({ type = DatasetCollectionTypeEnum.file, name = '', @@ -33,6 +41,9 @@ export function getSourceNameIcon({ sourceId?: string; }) { try { + if (isDatabaseSource(sourceId)) { + return 'core/dataset/databaseColor'; + } const fileIcon = getFileIcon(decodeURIComponent(sourceName.replace(/%/g, '%25')), ''); if (fileIcon) { return fileIcon; diff --git a/packages/global/core/evaluation/api.d.ts b/packages/global/core/evaluation/api.d.ts new file mode 100644 index 000000000000..28ed70d6f3a5 --- /dev/null +++ b/packages/global/core/evaluation/api.d.ts @@ -0,0 +1,119 @@ +import type { PaginationProps, PaginationResponse } from '../../openapi/api'; +import type { + CreateEvaluationParams, + EvaluationSchemaType, + EvaluationItemSchemaType, + EvaluationDisplayType, + EvaluationItemDisplayType, + EvaluationDataItemType, + EvaluationStatistics +} from './type'; +import type { EvaluationStatusEnum } from './constants'; +import type { EvalDatasetDataKeyEnum } from './dataset/constants'; + +// ===== Common Types ===== +export type MessageResponse = { message: string }; + +// ===== Evaluation Task API ===== +export type EvalIdQuery = { evalId: string }; +// Create Evaluation +export type CreateEvaluationRequest = CreateEvaluationParams; +export type CreateEvaluationResponse = EvaluationSchemaType; + +// Update Evaluation +export type UpdateEvaluationRequest = EvalIdQuery & Partial; +export type UpdateEvaluationResponse = MessageResponse; + +// Get Evaluation Detail +export type EvaluationDetailRequest = EvalIdQuery; +export type EvaluationDetailResponse = EvaluationDisplayType; + +// Delete Evaluation +export type DeleteEvaluationRequest = EvalIdQuery; +export type DeleteEvaluationResponse = MessageResponse; + +// List Evaluations +export type ListEvaluationsRequest = PaginationProps<{ + searchKey?: string; + appName?: string; + appId?: string; +}>; +export type ListEvaluationsResponse = PaginationResponse; + +// Start Evaluation +export type StartEvaluationRequest = EvalIdQuery; +export type StartEvaluationResponse = MessageResponse; + +// Stop Evaluation +export type StopEvaluationRequest = EvalIdQuery; +export type StopEvaluationResponse = MessageResponse; + +// Get Evaluation Stats +export type StatsEvaluationRequest = EvalIdQuery; +export type EvaluationStatsResponse = EvaluationStatistics & { + belowThreshold: number; +}; + +// Export Evaluation Items +export type ExportEvaluationItemsRequest = { + evalId: string; + filters?: { + status?: EvaluationStatusEnum; + belowThreshold?: boolean; + userInput?: string; + expectedOutput?: string; + actualOutput?: string; + }; + headers?: { + itemId?: string; + userInput?: string; + expectedOutput?: string; + actualOutput?: string; + status?: string; + errorMessage?: string; + }; + metricColumns?: Array<{ + key: string; + label: string; + }>; + statusLabelMap?: Record; +}; + +// Retry Failed Evaluation Items +export type RetryFailedEvaluationItemsRequest = EvalIdQuery; +export type RetryFailedItemsResponse = { + message: string; + retryCount: number; +}; + +// ===== Evaluation Item API ===== + +export type EvalItemIdQuery = { evalItemId: string }; + +// List Evaluation Items +export type ListEvaluationItemsRequest = PaginationProps< + EvalIdQuery & { + status?: EvaluationStatusEnum; + belowThreshold?: boolean; + [EvalDatasetDataKeyEnum.UserInput]?: string; + [EvalDatasetDataKeyEnum.ExpectedOutput]?: string; + [EvalDatasetDataKeyEnum.ActualOutput]?: string; + } +>; +export type ListEvaluationItemsResponse = PaginationResponse; + +// Get Evaluation Item Detail +export type EvaluationItemDetailRequest = EvalItemIdQuery; +export type EvaluationItemDetailResponse = EvaluationItemSchemaType; + +// Update Evaluation Item +export type UpdateEvaluationItemRequest = EvalItemIdQuery & Partial; +export type UpdateEvaluationItemResponse = MessageResponse; + +// Retry Evaluation Item +export type RetryEvaluationItemRequest = EvalItemIdQuery; +export type RetryEvaluationItemResponse = MessageResponse; + +// Delete Evaluation Item +export type DeleteEvaluationItemRequest = EvalItemIdQuery; +export type DeleteEvaluationItemResponse = MessageResponse; diff --git a/packages/global/core/evaluation/constants.ts b/packages/global/core/evaluation/constants.ts new file mode 100644 index 000000000000..a943dc939401 --- /dev/null +++ b/packages/global/core/evaluation/constants.ts @@ -0,0 +1,81 @@ +import { i18nT } from '../../common/i18n/utils'; + +export const evaluationFileErrors = i18nT('dashboard_evaluation:eval_file_check_error'); + +export enum EvaluationStatusEnum { + queuing = 'queuing', + evaluating = 'evaluating', + completed = 'completed', + error = 'error' +} + +export const EvaluationStatusMap = { + [EvaluationStatusEnum.queuing]: { + name: i18nT('dashboard_evaluation:queuing') + }, + [EvaluationStatusEnum.evaluating]: { + name: i18nT('dashboard_evaluation:evaluating') + }, + [EvaluationStatusEnum.completed]: { + name: i18nT('dashboard_evaluation:completed') + }, + [EvaluationStatusEnum.error]: { + name: i18nT('dashboard_evaluation:error') + } +}; +export const EvaluationStatusValues = Object.values(EvaluationStatusEnum); + +export enum SummaryStatusEnum { + pending = 'pending', + generating = 'generating', + completed = 'completed', + failed = 'failed' +} + +export const SummaryStatusMap = { + [SummaryStatusEnum.pending]: { + name: i18nT('dashboard_evaluation:summary_pending') + }, + [SummaryStatusEnum.generating]: { + name: i18nT('dashboard_evaluation:summary_generating') + }, + [SummaryStatusEnum.completed]: { + name: i18nT('dashboard_evaluation:summary_done') + }, + [SummaryStatusEnum.failed]: { + name: i18nT('dashboard_evaluation:summary_failed') + } +}; + +export const SummaryStatusValues = Object.values(SummaryStatusEnum); + +// Calculation method enumeration +export enum CalculateMethodEnum { + mean = 'mean', + median = 'median' +} + +export const CaculateMethodMap = { + [CalculateMethodEnum.mean]: { + name: i18nT('dashboard_evaluation:method_mean') + }, + [CalculateMethodEnum.median]: { + name: i18nT('dashboard_evaluation:method_median') + } +}; + +export const CaculateMethodValues = Object.values(CalculateMethodEnum); + +// Score constants +export const PERFECT_SCORE = 1; + +// Validation length constants +export const MAX_NAME_LENGTH = 100; +export const MAX_DESCRIPTION_LENGTH = 100; +export const MAX_MODEL_NAME_LENGTH = 100; +export const MAX_USER_INPUT_LENGTH = 1000; +export const MAX_OUTPUT_LENGTH = 4000; +export const MAX_PROMPT_LENGTH = 4000; + +export const MAX_TOKEN_FOR_EVALUATION_SUMMARY = 1024; +export const TEMPERATURE_FOR_EVALUATION_SUMMARY = 1e-7; diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts new file mode 100644 index 000000000000..4bc4364c45cf --- /dev/null +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -0,0 +1,225 @@ +import type { PaginationProps, PaginationResponse } from '../../../openapi/api'; +import type { + EvalDatasetCollectionSchemaType, + EvalDatasetDataSchemaType, + EvalDatasetCollectionStatus, + EvalDatasetDataQualityStatus, + EvalDatasetDataQualityMetadata, + EvalDatasetDataSynthesisMetadata +} from './type'; +import type { EvalDatasetDataQualityResultEnum } from './constants'; +import type { EvalDatasetDataKeyEnum } from './constants'; + +type EvalDatasetCollectionBase = { + name: string; + description?: string; + evaluationModelId?: string; +}; + +export type createEvalDatasetCollectionBody = EvalDatasetCollectionBase; + +export type updateEvalDatasetCollectionBody = Omit & { + name?: string; + collectionId: string; +}; + +export type deleteEvalDatasetCollectionQuery = { + collectionId: string; +}; + +export type listEvalDatasetCollectionBody = PaginationProps<{ + searchKey?: string; +}>; + +export type listEvalDatasetCollectionResponse = Pick< + EvalDatasetCollectionSchemaType, + '_id' | 'name' | 'description' | 'createTime' | 'updateTime' +> & { + creatorAvatar?: string; + creatorName?: string; + status: EvalDatasetCollectionStatus; + dataItemsCount: number; +}; + +export type listEvalDatasetCollectionV2Body = { + searchKey?: string; + pageSize?: number; + pageNum?: number; + offset?: number; +}; + +export type listEvalDatasetCollectionV2Response = PaginationResponse< + Pick +>; +type QualityEvaluationBase = { + enableQualityEvaluation: boolean; + evaluationModelId?: string; +}; + +export type importEvalDatasetFromFileBody = { + collectionId?: string; // Optional - use existing collection mode + // Optional fields for creating new collection mode + name?: string; + description?: string; +} & QualityEvaluationBase; +type EvalDatasetDataBase = { + [EvalDatasetDataKeyEnum.UserInput]: string; + [EvalDatasetDataKeyEnum.ActualOutput]?: string; + [EvalDatasetDataKeyEnum.ExpectedOutput]: string; + [EvalDatasetDataKeyEnum.Context]?: string[]; + [EvalDatasetDataKeyEnum.RetrievalContext]?: string[]; + qualityMetadata?: Partial; + synthesisMetadata?: Partial; + qualityResult?: EvalDatasetDataQualityResultEnum; +}; + +export type createEvalDatasetDataBody = EvalDatasetDataBase & + QualityEvaluationBase & { + collectionId: string; + }; + +export type listEvalDatasetDataBody = PaginationProps<{ + collectionId: string; + searchKey?: string; + status?: EvalDatasetDataQualityStatus; + qualityResult?: EvalDatasetDataQualityResultEnum; +}>; + +export type listEvalDatasetDataResponse = Pick< + EvalDatasetDataSchemaType, + | '_id' + | EvalDatasetDataKeyEnum.UserInput + | EvalDatasetDataKeyEnum.ActualOutput + | EvalDatasetDataKeyEnum.ExpectedOutput + | EvalDatasetDataKeyEnum.Context + | EvalDatasetDataKeyEnum.RetrievalContext + | 'qualityMetadata' + | 'synthesisMetadata' + | 'qualityResult' + | 'createFrom' + | 'createTime' + | 'updateTime' +>; + +export type updateEvalDatasetDataBody = EvalDatasetDataBase & { + dataId: string; +}; + +export type qualityAssessmentBody = { + dataId: string; + evaluationModelId?: string; +}; + +export type qualityAssessmentBatchBody = { + collectionId: string; + evaluationModelId?: string; +}; + +export type qualityAssessmentBatchResponse = { + success: boolean; + message: string; + processedCount: number; + skippedCount: number; + errorCount: number; +}; + +export type deleteEvalDatasetDataQuery = { + dataId: string; +}; + +export type getEvalDatasetDataDetailQuery = { + dataId: string; +}; + +export type getEvalDatasetDataDetailResponse = Pick< + EvalDatasetDataSchemaType, + | '_id' + | 'teamId' + | 'tmbId' + | 'evalDatasetCollectionId' + | EvalDatasetDataKeyEnum.UserInput + | EvalDatasetDataKeyEnum.ActualOutput + | EvalDatasetDataKeyEnum.ExpectedOutput + | EvalDatasetDataKeyEnum.Context + | EvalDatasetDataKeyEnum.RetrievalContext + | 'qualityMetadata' + | 'synthesisMetadata' + | 'qualityResult' + | 'createFrom' + | 'createTime' + | 'updateTime' +>; + +export type smartGenerateEvalDatasetBody = { + collectionId?: string; + kbDatasetIds: string[]; + count?: number; + intelligentGenerationModelId: string; + // Optional fields for creating new collection + name?: string; + description?: string; +}; + +export type listFailedTasksBody = { + collectionId: string; +}; + +export type listFailedTasksResponse = { + tasks: Array<{ + jobId: string; + // all about dataset + dataId: string; + datasetId: string; + datasetName: string; + collectionId: string; + collectionName: string; + errorMessage: string; + failedAt: Date; + attemptsMade: number; + maxAttempts: number; + }>; +}; + +export type retryTaskBody = { + collectionId: string; + jobId: string; +}; + +export type deleteTaskBody = { + collectionId: string; + jobId: string; +}; + +export type retryAllTaskBody = { + collectionId: string; +}; + +export type retryAllTaskResponse = { + success: boolean; + message: string; + totalFailedTasks: number; + retriedTasks: number; + failedRetries: number; +}; + +export type getEvalDatasetCollectionDetailQuery = { + collectionId: string; +}; + +export type getEvalDatasetCollectionDetailResponse = Pick< + EvalDatasetCollectionSchemaType, + | '_id' + | 'teamId' + | 'tmbId' + | 'name' + | 'description' + | 'createTime' + | 'updateTime' + | 'evaluationModelId' + | 'metadata' +> & { + creatorAvatar?: string; + creatorName?: string; + status: EvalDatasetCollectionStatus; + dataItemsCount: number; +}; diff --git a/packages/global/core/evaluation/dataset/constants.ts b/packages/global/core/evaluation/dataset/constants.ts new file mode 100644 index 000000000000..2a9f725d304e --- /dev/null +++ b/packages/global/core/evaluation/dataset/constants.ts @@ -0,0 +1,41 @@ +export enum EvalDatasetDataCreateFromEnum { + manual = 'manual', + fileImport = 'file_import', + intelligentGeneration = 'intelligent_generation' +} + +export const EvalDatasetDataCreateFromValues = Object.values(EvalDatasetDataCreateFromEnum); + +export enum EvalDatasetCollectionStatusEnum { + queuing = 'queuing', + processing = 'processing', + error = 'error', + ready = 'ready' +} + +export enum EvalDatasetDataQualityStatusEnum { + unevaluated = 'unevaluated', + queuing = 'queuing', + evaluating = 'evaluating', + error = 'error', + completed = 'completed' +} + +export enum EvalDatasetDataQualityResultEnum { + highQuality = 'highQuality', + needsOptimization = 'needsOptimization' +} + +export enum EvalDatasetDataKeyEnum { + UserInput = 'userInput', + ActualOutput = 'actualOutput', + ExpectedOutput = 'expectedOutput', + Context = 'context', + RetrievalContext = 'retrievalContext', + RetrievalContextsFull = 'retrievalContextsFull', + ExpectedContextIds = 'expectedContextIds', + Metadata = 'metadata' +} + +export const EvalDatasetDataQualityStatusValues = Object.values(EvalDatasetDataQualityStatusEnum); +export const EvalDatasetDataQualityResultValues = Object.values(EvalDatasetDataQualityResultEnum); diff --git a/packages/global/core/evaluation/dataset/type.d.ts b/packages/global/core/evaluation/dataset/type.d.ts new file mode 100644 index 000000000000..a7d64af6d347 --- /dev/null +++ b/packages/global/core/evaluation/dataset/type.d.ts @@ -0,0 +1,76 @@ +import type { + EvaluationStatusEnum, + EvalDatasetDataCreateFromEnum, + EvalDatasetCollectionStatusEnum, + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum, + EvalDatasetDataKeyEnum +} from './constants'; +import type { Usage } from '@fastgpt/global/support/wallet/usage/type'; + +export type EvalDatasetCollectionStatus = EvalDatasetCollectionStatusEnum; +export type EvalDatasetDataQualityStatus = EvalDatasetDataQualityStatusEnum; +export type EvalDatasetDataQualityResult = EvalDatasetDataQualityResultEnum; + +export type EvalDatasetDataQualityMetadata = { + status: EvalDatasetDataQualityStatusEnum; + score?: number; + reason?: string; + modelId?: string; + usages?: Usage[]; + runLogs?: any[]; + startTime?: Date; + finishTime?: Date; + queueTime?: Date; + error?: string; +}; + +export type EvalDatasetDataSynthesisMetadata = { + sourceDataId?: string; + sourceDatasetId?: string; + sourceCollectionId?: string; + intelligentGenerationModelId?: string; + synthesizedAt?: Date; + generatedAt?: Date; +}; + +export type RetrievalContextItem = { + id: string; + score: Array<{ + type: string; + value: number; + index: number; + }>; +}; + +export type EvalDatasetCollectionSchemaType = { + _id: string; + teamId: string; + tmbId: string; + name: string; + description: string; + createTime: Date; + updateTime: Date; + metadata: Record; + evaluationModelId?: string; +}; + +export type EvalDatasetDataSchemaType = { + _id: string; + teamId: string; + tmbId: string; + evalDatasetCollectionId: string; + [EvalDatasetDataKeyEnum.UserInput]: string; + [EvalDatasetDataKeyEnum.ActualOutput]: string; + [EvalDatasetDataKeyEnum.ExpectedOutput]: string; + [EvalDatasetDataKeyEnum.Context]: string[]; + [EvalDatasetDataKeyEnum.RetrievalContext]: string[]; + [EvalDatasetDataKeyEnum.RetrievalContextsFull]?: RetrievalContextItem[]; + [EvalDatasetDataKeyEnum.ExpectedContextIds]?: string[]; + qualityMetadata: EvalDatasetDataQualityMetadata; + synthesisMetadata?: EvalDatasetDataSynthesisMetadata; + qualityResult?: EvalDatasetDataQualityResultEnum; + createFrom: EvalDatasetDataCreateFromEnum; + createTime: Date; + updateTime: Date; +}; diff --git a/packages/global/core/evaluation/metric/api.d.ts b/packages/global/core/evaluation/metric/api.d.ts new file mode 100644 index 000000000000..b8bfdf1e5d27 --- /dev/null +++ b/packages/global/core/evaluation/metric/api.d.ts @@ -0,0 +1,34 @@ +import type { EvalCase, EvalModelConfigType, MetricConfig } from './type'; + +export type CreateMetricBody = { + name: string; + description?: string; + prompt: string; +}; + +export type DebugMetricBody = { + evalCase: EvalCase; + llmConfig: EvalModelConfigType; + metricConfig: MetricConfig; +}; + +export type UpdateMetricBody = { + metricId: string; + name?: string; + description?: string; + prompt?: string; +}; + +export type DeleteMetricQuery = { + metricId: string; +}; + +export type DetailMetricQuery = { + metricId: string; +}; + +export type ListMetricsBody = { + pageNum?: number; + pageSize?: number; + searchKey?: string; +}; diff --git a/packages/global/core/evaluation/metric/constants.ts b/packages/global/core/evaluation/metric/constants.ts new file mode 100644 index 000000000000..665fa8ad546e --- /dev/null +++ b/packages/global/core/evaluation/metric/constants.ts @@ -0,0 +1,17 @@ +export enum EvalMetricTypeEnum { + Custom = 'custom_metric', + Builtin = 'builtin_metric' +} +export const EvalMetricTypeValues = Object.values(EvalMetricTypeEnum); + +export enum ModelTypeEnum { + LLM = 'llm', + EMBED = 'embed' +} +export const ModelTypeValues = Object.values(ModelTypeEnum); + +export enum MetricResultStatusEnum { + Success = 'success', + Failed = 'failed' +} +export const MetricResultStatusValues = Object.values(MetricResultStatusEnum); diff --git a/packages/global/core/evaluation/metric/type.d.ts b/packages/global/core/evaluation/metric/type.d.ts new file mode 100644 index 000000000000..81a972689aa7 --- /dev/null +++ b/packages/global/core/evaluation/metric/type.d.ts @@ -0,0 +1,180 @@ +import type { EvalDatasetDataKeyEnum } from '../dataset/constants'; +import type { ModelTypeEnum, EvalMetricTypeEnum, MetricResultStatusEnum } from './constants'; +import type { SourceMemberType } from '@fastgpt/global/support/user/type'; + +export type EvalModelConfigType = { + modelId: string; + name?: string; + baseUrl?: string; + apiKey?: string; + timeout?: number; + parameters?: Record; +}; + +export type EvalCase = { + [EvalDatasetDataKeyEnum.UserInput]?: string; + [EvalDatasetDataKeyEnum.ExpectedOutput]?: string; + [EvalDatasetDataKeyEnum.ActualOutput]?: string; + [EvalDatasetDataKeyEnum.Context]?: string[]; + [EvalDatasetDataKeyEnum.RetrievalContext]?: string[]; +}; + +export type MetricResult = { + metricName: string; + status?: MetricResultStatusEnum; + data?: EvaluationResult; + usages?: Usage[]; + error?: string; + totalPoints?: number; +}; + +export type EvalMetricSchemaType = { + _id: string; + teamId: string; + tmbId: string; + name: string; + description?: string; + type: EvalMetricTypeEnum; + prompt?: string; + + userInputRequired?: boolean; + actualOutputRequired?: boolean; + expectedOutputRequired?: boolean; + contextRequired?: boolean; + retrievalContextRequired?: boolean; + + embeddingRequired?: boolean; + llmRequired?: boolean; + + createTime: Date; + updateTime: Date; +}; + +export interface EvalMetricDisplayType extends EvalMetricSchemaType { + sourceMember: SourceMemberType; +} + +export type HttpConfig = { + url: string; + timeout?: number; +}; + +export type MetricConfig = { + metricName: string; + metricType: EvalMetricTypeEnum; + prompt?: string; +}; + +export type EvaluationRequest = { + evalCase: EvalCase; + metricConfig: MetricConfig; + embeddingConfig?: EvalModelConfigType | null; + llmConfig?: EvalModelConfigType | null; +}; + +export type EvaluationResult = { + metricName: string; + score: number; + reason?: string; + runLogs?: Record; +}; + +export type Usage = { + modelType: ModelTypeEnum; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +}; + +export type EvaluationResponse = { + status: MetricResultStatusEnum; + data?: EvaluationResult; + usages?: Usage[]; + error?: string; +}; + +export type MetricDefinition = { + name: string; + description: string; + requireQuestion: boolean; + requireActualResponse: boolean; + requireExpectedResponse: boolean; + requireContext: boolean; + requireRetrievalContext: boolean; +}; + +export type SynthesisCase = { + context?: string[]; + themes?: string[]; +}; + +export type SynthesisOutput = { + qaPair: { + question: string; + answer: string; + }; + metadata?: Record; +}; + +export type SynthesisResult = { + synthesisName: string; + status: string; + data?: SynthesisOutput; + usages?: Usage[]; + error?: string; + totalPoints?: number; +}; + +export type SynthesizerConfig = { + synthesizerName: string; + config?: Record; +}; + +export type SynthesisRequest = { + synthesisCase: SynthesisCase; + synthesizerConfig: SynthesizerConfig; + llmConfig?: EvalModelConfigType; + embeddingConfig?: EvalModelConfigType; +}; + +export type SynthesisResponse = { + requestId: string; + status: string; + data?: SynthesisOutput; + usages?: Usage[]; + error?: string; + metadata?: Record; +}; + +export type SynthesisMetadata = { + chunkId?: string; + totalChunks?: number; + projectName?: string; + createdAt?: string; +}; + +export type DatasetSynthesisRequest = { + metadata?: SynthesisMetadata; + llmConfig: EvalModelConfigType; + embeddingConfig?: EvalModelConfigType; + synthesizerConfig: SynthesizerConfig; + inputData: { + context?: string[]; + themes?: string[]; + }; +}; + +export type DatasetSynthesisResponse = { + requestId: string; + status: string; + data?: { + qaPair: { + question: string; + answer: string; + }; + metadata?: Record; + }; + usages?: Usage[]; + error?: string; + metadata?: Record; +}; diff --git a/packages/global/core/evaluation/summary/api.d.ts b/packages/global/core/evaluation/summary/api.d.ts new file mode 100644 index 000000000000..7485b33598ec --- /dev/null +++ b/packages/global/core/evaluation/summary/api.d.ts @@ -0,0 +1,71 @@ +import type { CalculateMethodEnum } from '../constants'; + +// ===== Common Types ===== + +export type MetricConfigItem = { + metricId: string; + thresholdValue?: number; + weight?: number; + calculateType?: CalculateMethodEnum; +}; + +export type MetricConfigItemWithName = Omit & { + weight: number; // Required in config responses + metricName: string; // Metric name for display + metricDescription: string; // Metric description for display +}; + +export type UpdateMetricConfigItem = Omit & { + metricId: string; + thresholdValue: number; + weight?: number; +}; + +// ===== Config API Types ===== + +export type UpdateSummaryConfigBody = { + evalId: string; + calculateType: CalculateMethodEnum; + metricsConfig: UpdateMetricConfigItem[]; +}; + +export type UpdateSummaryConfigResponse = { + message: string; +}; + +// ===== Config Detail API Types ===== + +export type GetConfigDetailQuery = { + evalId: string; +}; + +export type GetConfigDetailResponse = { + calculateType: CalculateMethodEnum; + calculateTypeName: string; + metricsConfig: MetricConfigItemWithName[]; +}; + +// ===== Summary Detail API Types ===== + +export type GetEvaluationSummaryQuery = { + evalId: string; +}; + +export type EvaluationSummaryResponse = { + data: Array<{ + metricId: string; + metricName: string; + metricScore: number; + summary: string; + summaryStatus: string; + errorReason?: string; + completedItemCount: number; + overThresholdItemCount: number; + underThresholdRate: number; + threshold: number; + weight: number; + customSummary: string; + }>; + aggregateScore: number; +}; + diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts new file mode 100644 index 000000000000..ac39ba7ce94d --- /dev/null +++ b/packages/global/core/evaluation/type.d.ts @@ -0,0 +1,230 @@ +import type { EvaluationStatusEnum, CalculateMethodEnum, SummaryStatusEnum } from './constants'; +import type { EvalDatasetDataKeyEnum } from './dataset/constants'; +import type { EvalDatasetDataSchemaType } from './dataset/type'; +import type { MetricResult, EvalMetricSchemaType } from './metric/type'; +import type { EvaluationPermission } from '../../support/permission/evaluation/controller'; +import type { SourceMemberType } from '../../support/user/type'; + +// Evaluation target related types +export interface WorkflowConfig { + appId: string; + versionId: string; // Required app version ID + chatConfig?: any; + // Extended fields populated by aggregation queries + appName?: string; // App name from apps collection + avatar?: string; // App avatar from apps collection + versionName?: string; // Version name from app_versions collection +} + +export interface EvalTarget { + type: 'workflow'; + config: WorkflowConfig; +} + +export interface RuntimeConfig { + llmId?: string; // LLM model selection + embeddingId?: string; // Embedding model selection +} + +// Summary configuration type +export type SummaryConfig = { + metricId: string; + metricName: string; + weight: number; + summary: string; + summaryStatus: SummaryStatusEnum; + errorReason: string; +}; + +export type UpdateStatusParams = { + evalId: string; + metricId: string; + status: SummaryStatusEnum; + errorReason?: string; +}; + +// SummaryData type containing calculation method and configs +export type SummaryData = { + calculateType: CalculateMethodEnum; // Calculation method for all metrics + summaryConfigs: SummaryConfig[]; // Array of summary configs, one for each metric +}; + +// Evaluator configuration type +export interface EvaluatorSchema { + metric: EvalMetricSchemaType; // Contains complete metric configuration + runtimeConfig: RuntimeConfig; // Runtime configuration including LLM model + thresholdValue?: number; + scoreScaling?: number; // Score scaling factor, default is 1 +} + +// Statistics information for evaluation task +export interface EvaluationStatistics { + total: number; + completed: number; + evaluating: number; + queuing: number; + error: number; +} + +// Improved evaluation task types +export type EvaluationSchemaType = { + _id: string; + teamId: string; + tmbId: string; + name: string; + description?: string; + evalDatasetCollectionId: string; // Associated evaluation dataset collection + target: EvalTarget; // Embedded evaluation target + evaluators: EvaluatorSchema[]; // Array of evaluator configurations + summaryData: SummaryData; // Summary configuration containing calculateType and summaryConfigs + usageId: string; + status: EvaluationStatusEnum; + createTime: Date; + finishTime?: Date; + errorMessage?: string; + statistics?: EvaluationStatistics; +}; + +/** + * Target call parameters for evaluation execution extensibility. + * Provides structured way to pass variables and additional configuration + * to target instances during evaluation. + * + * @example + * ```typescript + * const params: TargetCallParams = { + * variables: { userId: "123", theme: "dark" }, // Named variables for target execution + * timeout: 5000, // Custom timeout setting + * retryCount: 3, // Retry configuration + * customHeaders: { "X-Custom": "value" } // Additional parameters + * } + * ``` + */ +export interface TargetCallParams { + /** Named variables to be passed to target execution (replaces globalVariables) */ + variables?: Record; + /** Index signature allowing additional extensible parameters */ + [key: string]: any; +} + +/** + * Evaluation data item that contains only the necessary fields for evaluation execution. + * Used in evaluation context where both dataset content and execution parameters are needed. + */ +export type EvaluationDataItemType = Pick< + EvalDatasetDataSchemaType, + | '_id' + | EvalDatasetDataKeyEnum.UserInput + | EvalDatasetDataKeyEnum.ExpectedOutput + | EvalDatasetDataKeyEnum.Context +> & { + targetCallParams?: TargetCallParams; +}; + +// Evaluation item type (batch: one dataItem + one target + multiple evaluators) +export type EvaluationItemSchemaType = { + _id: string; + evalId: string; + // Dependent component configurations + dataItem: EvaluationDataItemType; + // Execution results + targetOutput?: TargetOutput; // Actual output from target + evaluatorOutputs?: MetricResult[]; // Results from multiple evaluators + status: EvaluationStatusEnum; + finishTime?: Date; + errorMessage?: string; +}; + +// Evaluation target input/output types +export interface TargetInput { + [EvalDatasetDataKeyEnum.UserInput]: string; + [EvalDatasetDataKeyEnum.Context]?: string[]; + targetCallParams?: TargetCallParams; +} + +export interface TargetOutput { + [EvalDatasetDataKeyEnum.ActualOutput]: string; + [EvalDatasetDataKeyEnum.RetrievalContext]?: string[]; + usage?: any; + responseTime: number; + chatId: string; + aiChatItemDataId: string; +} + +export type EvaluationWithPerType = EvaluationSchemaType & { + permission: EvaluationPermission; +}; + +// ===== Display Types ===== + +// Extended SummaryConfig for display purposes (includes runtime calculated fields) +export type SummaryConfigDisplay = SummaryConfig & { + score: number; // Real-time calculated metric score + completedItemCount: number; // Real-time calculated completed items count + overThresholdItemCount: number; // Real-time calculated over threshold items count +}; + +export type EvaluationDisplayType = Omit & { + evalDatasetCollectionName?: string; + metricNames: string[]; + private: boolean; + sourceMember: SourceMemberType; + summaryData: Omit & { + summaryConfigs: SummaryConfigDisplay[]; // Use extended version for display + }; + aggregateScore?: number; // Real-time calculated aggregate score +}; + +export type EvaluationItemDisplayType = EvaluationItemSchemaType & { + evaluators: Array<{ + metric: EvalMetricSchemaType; // Contains complete metric configuration + thresholdValue?: number; // Threshold value for this evaluator + weight?: number; + }>; // Array of evaluator configurations +}; + +export interface CreateEvaluationParams { + name: string; + description?: string; + evalDatasetCollectionId: string; + target: EvalTarget; // Only supports workflow type target configuration + evaluators: EvaluatorSchema[]; // Replace metricIds with evaluators + autoStart?: boolean; // Whether to automatically start the evaluation task after creation (default: true) +} + +type EvaluationParamsType = Omit; + +type EvaluationParamsWithDeafultConfigType = Omit & { + summaryData: { + calculateType: CalculateMethodEnum; + summaryConfigs: SummaryConfig[]; + }; +}; + +// Queue job data types +export interface EvaluationTaskJobData { + evalId: string; +} + +export interface EvaluationItemJobData { + evalId: string; + evalItemId: string; +} + +// ===== Summary Generate API Types ===== + +export type GenerateSummaryParams = { + evalId: string; + metricIds: string[]; +}; + +export type GenerateSummaryResponse = { + success: boolean; + message: string; +}; + +export type SummaryGenerationTaskData = { + evalId: string; + metricId: string; + evaluatorIndex: number; +}; diff --git a/packages/global/core/evaluation/validate.ts b/packages/global/core/evaluation/validate.ts new file mode 100644 index 000000000000..fe0942662c01 --- /dev/null +++ b/packages/global/core/evaluation/validate.ts @@ -0,0 +1,106 @@ +// Validation error structure +export interface ValidationError { + code: string; // Error code for programmatic handling + message: string; // Human-readable error message + field?: string; // Field name that caused the error (optional) + debugInfo?: Record; // Additional debug information +} + +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; + warnings?: ValidationError[]; // Optional warnings that don't prevent usage +} + +// Base abstract class for all validatable objects +export abstract class Validatable { + /** + * Validate this object and return detailed validation result + */ + abstract validate(): Promise; + + /** + * Convenience method to check if this object is valid + */ + async isValid(): Promise { + const result = await this.validate(); + return result.isValid; + } + + /** + * Convenience method to get validation error messages + */ + async getValidationErrors(): Promise { + const result = await this.validate(); + return result.errors; + } +} + +// Utility functions for ValidationResult +export const ValidationResultUtils = { + /** + * Format validation errors into a readable string + */ + formatErrors(result: ValidationResult, separator = '; '): string { + return (result.errors || []).map((err) => `${err.code}: ${err.message}`).join(separator); + }, + + /** + * Format validation warnings into a readable string + */ + formatWarnings(result: ValidationResult, separator = '; '): string { + return result.warnings?.map((err) => `${err.code}: ${err.message}`).join(separator) || ''; + }, + + /** + * Get a summary message for validation result + */ + getSummaryMessage(result: ValidationResult): string { + if (result.isValid) { + const warningMsg = result.warnings?.length ? ` (${result.warnings.length} warnings)` : ''; + return `Validation passed${warningMsg}`; + } else { + return `Validation failed: ${ValidationResultUtils.formatErrors(result)}`; + } + }, + + /** + * Create an Error object from ValidationResult + */ + toError(result: ValidationResult): Error { + if (result.isValid) { + throw new Error('Cannot create error from valid validation result'); + } + return new Error(ValidationResultUtils.formatErrors(result)); + }, + + /** + * Create an Error object with only the first error code for i18n translation + * Logs detailed validation errors for debugging + */ + toTranslatableError(result: ValidationResult): Error { + if (result.isValid) { + throw new Error('Cannot create error from valid validation result'); + } + + // Log detailed validation errors for debugging + // Note: addLog is not available in global package, will be logged by caller + const debugInfo = { + errors: result.errors, + formattedMessage: ValidationResultUtils.formatErrors(result), + summary: ValidationResultUtils.getSummaryMessage(result) + }; + + // Return error with only the first error code for i18n translation + const firstError = result.errors[0]; + if (!firstError) { + return new Error('Validation failed with unknown error'); + } + + // Attach debug info to the error for caller to log + const error = new Error(firstError.code); + (error as any).validationDebugInfo = debugInfo; + + return error; + } +}; diff --git a/packages/global/core/plugin/type.ts b/packages/global/core/plugin/type.ts index 73b284281799..9d4e8aaf71ae 100644 --- a/packages/global/core/plugin/type.ts +++ b/packages/global/core/plugin/type.ts @@ -1,5 +1,5 @@ import z from 'zod'; -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; export const I18nStringSchema = z.object({ en: z.string(), diff --git a/packages/global/core/train/common/api.d.ts b/packages/global/core/train/common/api.d.ts new file mode 100644 index 000000000000..729fdee4db6c --- /dev/null +++ b/packages/global/core/train/common/api.d.ts @@ -0,0 +1,20 @@ +/** Generic message response */ +export type MessageResponse = { message: string }; + +/** Query parameter type with trainsetId */ +export type TrainsetIdQuery = { trainsetId: string }; + +/** Query parameter type with taskId */ +export type TaskIdQuery = { taskId: string }; + +/** Query parameter type with dataId */ +export type DataIdQuery = { dataId: string }; + +/** Sort order */ +export type SortOrder = 'asc' | 'desc'; + +/** Sort parameters */ +export type SortParams = { + sortField?: T; + sortOrder?: SortOrder; +}; diff --git a/packages/global/core/train/common/error.ts b/packages/global/core/train/common/error.ts new file mode 100644 index 000000000000..aba13fa92300 --- /dev/null +++ b/packages/global/core/train/common/error.ts @@ -0,0 +1,24 @@ +/** + * Generic enhanced error message for training tasks + * + * Contains structured error information including stage, error code, message and original error. + * This format allows frontend to display rich error information with i18n support. + * + * @template TStage - Stage enum string literal type (e.g. `${EmbeddingTaskCheckpointStageEnum}`) + * @template TErr - Error enum string literal type (e.g. `${EmbeddingTrainErrEnum}`) + */ +export type GenericEnhancedErrorMessage< + TStage extends string = string, + TErr extends string = string +> = { + /** Error stage (which stage the error occurred in) */ + stage: TStage | null; + /** Error code (unified error identifier with i18n support) */ + type: TErr | string; + /** Detailed error message (localized from i18n) */ + message: string; + /** Resolution suggestion (can be added to i18n for localization) */ + suggestion?: string; + /** Original error message (for debugging) */ + originalError?: string; +}; diff --git a/packages/global/core/train/embedding/api.d.ts b/packages/global/core/train/embedding/api.d.ts new file mode 100644 index 000000000000..552386672a63 --- /dev/null +++ b/packages/global/core/train/embedding/api.d.ts @@ -0,0 +1,169 @@ +import type { PaginationProps, PaginationResponse } from '../../openapi/api'; +import type { + EmbeddingTrainsetSchemaType, + EmbeddingTrainsetDataSchemaType, + EmbeddingTrainTaskSchemaType +} from './type'; +import type { + EmbeddingTrainDataSourceEnum, + EmbeddingTrainTaskStatusEnum, + EmbeddingTrainMethodEnum +} from './constants'; +import type { + MessageResponse, + TrainsetIdQuery, + TaskIdQuery, + DataIdQuery, + SortParams +} from '../common/api'; +import type { DatasetDataIndexTypeEnum } from '../../dataset/data/constants'; + +// ===== Trainset API ===== + +// Create Trainset +export type CreateEmbeddingTrainsetRequest = { + name?: string; + description?: string; +}; +export type CreateEmbeddingTrainsetResponse = EmbeddingTrainsetSchemaType; + +// Get Trainset Detail +export type EmbeddingTrainsetDetailRequest = TrainsetIdQuery; +export type EmbeddingTrainsetDetailResponse = EmbeddingTrainsetSchemaType; + +// List Trainsets +export type ListEmbeddingTrainsetsRequest = PaginationProps< + { + status?: string; + } & SortParams<'createTime' | 'updateTime' | 'name'> +>; +export type ListEmbeddingTrainsetsResponse = PaginationResponse; + +// Delete Trainset +export type DeleteEmbeddingTrainsetRequest = TrainsetIdQuery; +export type DeleteEmbeddingTrainsetResponse = MessageResponse; + +// ===== Training Data API ===== + +// Generate Training Data (from dataset chunks) +export type GenerateEmbeddingTrainDataRequest = { + trainsetId: string; // Required: Target trainset ID + datasetIds: string[]; // Required: Knowledge base IDs to generate data from + generateConfig?: { + /** FastGPT internal parameters */ + sampleSize?: number; // Total sample budget M (optional for train/eval modes) + weights?: Record; // Per-KB sampling weights (requires sampleSize) + forceRegenerate?: boolean; // Whether to force regeneration + /** buildFineTuneData parameters */ + indexType: `${DatasetDataIndexTypeEnum}`; // Index type to use as query + indexMultiStrategy?: 1 | 2; // Strategy for multiple index elements: 1=longest, 2=all + negativeStrategy?: 1 | 2 | 3 | 4; // Negative sampling strategy, default 2 + minNegativeSamples?: number; // Min negative samples per sample, default 1 + maxNegativeSamples?: number; // Max negative samples per sample, default 10 + }; +}; +export type GenerateEmbeddingTrainDataResponse = { + jobId: string; + status: 'pending'; +}; + +// Create Training Data (manual) +export type CreateEmbeddingTrainDataRequest = { + trainsetId: string; // Required: Target trainset ID + query: string; // Single query + positiveDocs: string[]; + negativeDocs: string[]; + reason?: string; // Reason for addition +}; +export type CreateEmbeddingTrainDataResponse = EmbeddingTrainsetDataSchemaType; + +// Update Training Data +export type UpdateEmbeddingTrainDataRequest = DataIdQuery & { + query?: string; // Single query + positiveDocs?: string[]; + negativeDocs?: string[]; +}; +export type UpdateEmbeddingTrainDataResponse = MessageResponse; + +// List Training Data +export type ListEmbeddingTrainDataRequest = PaginationProps< + { + trainsetId: string; // Required: Trainset ID to query + source?: `${EmbeddingTrainDataSourceEnum}`; + } & SortParams<'createTime' | 'updateTime'> +>; +export type ListEmbeddingTrainDataResponse = PaginationResponse; + +// Delete Training Data +export type DeleteEmbeddingTrainDataRequest = { + dataIds: string[]; +}; +export type DeleteEmbeddingTrainDataResponse = MessageResponse; + +// ===== Training Task API ===== + +// Create Training Task +// datasetIds is ALWAYS required (even when trainsetId or evalDatasetId are provided): +// - eval-dataset generation stage reads datasetIds to sample eval data +// - eval stages call dispatchDatasetSearch against datasetIds to produce ranked results +// - trainsetId present → skip trainset generation stage only; datasetIds still needed for eval +// - evalDatasetId present → skip eval-dataset generation stage only; datasetIds still needed for eval search +export type CreateEmbeddingTrainTaskRequest = { + datasetIds: string[]; // Required in all modes: knowledge base IDs used across generation and evaluation stages + trainsetId?: string; // Optional: if present, skip trainset generation stage + evalDatasetId?: string; // Optional: if present, skip eval dataset generation stage + + baseModelId: string; // Base model ID (BaseModelItemType.id) + newModelName?: string; // Optional name for the trained model + name?: string; + trainMethod?: `${EmbeddingTrainMethodEnum}`; // Training type: lora or task_tuning, defaults to task_tuning + + /** Trainset synthesis config — used when auto-generating the trainset (trainsetId absent) */ + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; +}; +export type CreateEmbeddingTrainTaskResponse = EmbeddingTrainTaskSchemaType; + +// Get Task Detail +export type EmbeddingTrainTaskDetailRequest = TaskIdQuery; +export type EmbeddingTrainTaskDetailResponse = EmbeddingTrainTaskSchemaType & { + creatorName?: string; + creatorAvatar?: string; +}; + +// List Training Tasks +export type ListEmbeddingTrainTasksRequest = PaginationProps< + { + baseModelId?: string; // Filter by base model + tunedModelId?: string; // Filter by produced tuned model (triggers chain traversal) + status?: `${EmbeddingTrainTaskStatusEnum}`; + } & SortParams<'createTime' | 'updateTime' | 'finishTime'> +>; + +export type EmbeddingTrainTaskListItem = EmbeddingTrainTaskSchemaType & { + creatorName?: string; + creatorAvatar?: string; +}; +export type ListEmbeddingTrainTasksResponse = PaginationResponse; + +// Retry Training Task +export type RetryEmbeddingTrainTaskRequest = TaskIdQuery; +export type RetryEmbeddingTrainTaskResponse = { + success: true; + jobId: string; +}; + +// Cancel Training Task +export type CancelEmbeddingTrainTaskRequest = TaskIdQuery; +export type CancelEmbeddingTrainTaskResponse = MessageResponse; + +// Delete Training Task +export type DeleteEmbeddingTrainTaskRequest = TaskIdQuery & { force?: string; deleteModel?: string }; +export type DeleteEmbeddingTrainTaskResponse = MessageResponse; diff --git a/packages/global/core/train/embedding/constants.ts b/packages/global/core/train/embedding/constants.ts new file mode 100644 index 000000000000..a639821ff8b8 --- /dev/null +++ b/packages/global/core/train/embedding/constants.ts @@ -0,0 +1,39 @@ +/** + * Embedding 训练模块常量 + */ + +export enum EmbeddingTrainsetStatusEnum { + pending = 'pending', + generating = 'generating', + ready = 'ready', + error = 'error' +} + +export enum EmbeddingTrainTaskStatusEnum { + pending = 'pending', + running = 'running', + completed = 'completed', + failed = 'failed', + cancelled = 'cancelled' +} + +export enum EmbeddingTrainMethodEnum { + lora = 'lora', + task_tuning = 'task_tuning' +} + +export enum EmbeddingTaskCheckpointStageEnum { + generate_trainset = 'generate_trainset', + generate_evaldataset = 'generate_evaldataset', + eval_basemodel = 'eval_basemodel', + finetuning = 'finetuning', + registering = 'registering', + eval_tunedmodel = 'eval_tunedmodel', + llm_judge = 'llm_judge' +} + +export enum EmbeddingTrainDataSourceEnum { + dataset = 'dataset', + chat_log = 'chat_log', + manual = 'manual' +} diff --git a/packages/global/core/train/embedding/error.ts b/packages/global/core/train/embedding/error.ts new file mode 100644 index 000000000000..e8877e31ff81 --- /dev/null +++ b/packages/global/core/train/embedding/error.ts @@ -0,0 +1,12 @@ +/** + * Module-specific type alias with embedding-specific type parameters. + */ +import type { GenericEnhancedErrorMessage } from '../common/error'; +import type { EmbeddingTaskCheckpointStageEnum } from './constants'; +import type { EmbeddingTrainErrEnum } from '../../../common/error/code/train'; + +/** Enhanced error message for embedding training tasks */ +export type EnhancedErrorMessage = GenericEnhancedErrorMessage< + `${EmbeddingTaskCheckpointStageEnum}` | string, + `${EmbeddingTrainErrEnum}` | string +>; diff --git a/packages/global/core/train/embedding/type.d.ts b/packages/global/core/train/embedding/type.d.ts new file mode 100644 index 000000000000..1ff9b7d2273e --- /dev/null +++ b/packages/global/core/train/embedding/type.d.ts @@ -0,0 +1,277 @@ +import type { + EmbeddingTrainsetStatusEnum, + EmbeddingTrainDataSourceEnum, + EmbeddingTrainTaskStatusEnum, + EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainMethodEnum +} from './constants'; +import type { EnhancedErrorMessage } from './error'; +import type { DatasetDataIndexTypeEnum } from '../../dataset/data/constants'; + +/** + * Embedding evaluation detailed results + * Aligned with RerankDiTingDetailedResults (MRR, NDCG, MAP, Precision) + */ +export interface EmbeddingDiTingDetailedResults { + embed_top5_mrr?: number; + embed_top5_ndcg?: number; + embed_top5_map?: number; + embed_top5_precision?: number; + embed_top10_mrr?: number; + embed_top10_ndcg?: number; + embed_top10_map?: number; + embed_top10_precision?: number; + embed_top15_mrr?: number; + embed_top15_ndcg?: number; + embed_top15_map?: number; + embed_top15_precision?: number; + embed_top20_mrr?: number; + embed_top20_ndcg?: number; + embed_top20_map?: number; + embed_top20_precision?: number; + overall_mrr?: number; + overall_ndcg?: number; + overall_map?: number; + overall_precision?: number; + [key: string]: any; +} + +/** + * Embedding evaluation result + */ +export interface EmbeddingEvalResult { + detailed_results: EmbeddingDiTingDetailedResults; + mrr_scores?: Record; + ndcg_scores?: Record; + map_scores?: Record; + precision_scores?: Record; + retrieval_ranks?: number[][]; + column_stats?: Record; + total_rows?: number; + expect_count?: number; +} + +/** Trainset statistics (dynamically calculated, not stored in DB) */ +export interface EmbeddingTrainsetStatistics { + dataCount: number; + positiveCount: number; + negativeCount: number; + sourceSummary: Array< + | { + type: 'dataset'; + count: number; + datasetInfo: { + datasetId: string; + }; + } + | { + type: 'chat_log'; + count: number; + chatLogInfo: { + chatId: string; + }; + } + | { + type: 'manual'; + count: number; + manualInfo: { + creator: string; + }; + } + >; +} + +/** Embedding trainset schema */ +export type EmbeddingTrainsetSchemaType = { + _id: string; + teamId: string; + tmbId: string; + + name: string; + description?: string; + + status: `${EmbeddingTrainsetStatusEnum}`; + errorMsg?: EnhancedErrorMessage; + + jobId?: string; + + createTime: Date; + updateTime: Date; + + statistics?: EmbeddingTrainsetStatistics; +}; + +/** Embedding training data schema */ +export type EmbeddingTrainsetDataSchemaType = { + _id: string; + trainsetId: string; + teamId: string; + + query: string; + positiveDocs: string[]; + negativeDocs: string[]; + + source: `${EmbeddingTrainDataSourceEnum}`; + + metadata: { + sourceInfo: { + datasetInfo?: { + dataId: string; + datasetId: string; + }; + + chatLogInfo?: { + chatId: string; + itemIds: string[]; + }; + + manualInfo?: { + creator: string; + createdAt: Date; + reason?: string; + }; + }; + + generateConfig?: { + sampleSize?: number; + weights?: Record; + forceRegenerate?: boolean; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; + }; + + createTime: Date; +}; + +/** Embedding training task schema */ +export type EmbeddingTrainTaskSchemaType = { + _id: string; + teamId: string; + tmbId: string; + + name: string; + /** Base model ID (BaseModelItemType.id) */ + baseModelId: string; + baseModelEndpoint: { + base_url?: string; + api_key?: string; + model: string; + }; + + /** Training type: lora or task_tuning, defaults to lora */ + trainMethod?: `${EmbeddingTrainMethodEnum}`; + + /** Train dataset ID (exact mode: passed at create; auto mode: written by generate_trainset stage) */ + trainsetId?: string; + /** Eval dataset ID (exact mode: passed at create; auto mode: written by generate_evaldataset stage) */ + evalDatasetId?: string; + /** Knowledge base IDs used for trainset/evaldataset generation and model evaluation */ + datasetIds: string[]; + /** Optional name for the trained model */ + newModelName?: string; + + /** Trainset synthesis config stored at task level — passed to data generation queue */ + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; + + status: EmbeddingTrainTaskStatusEnum; + + checkpoint: { + stage: `${EmbeddingTaskCheckpointStageEnum}` | null; + data?: { + generate_trainset?: { + trainDatasetId: string; + trainDatasetFilePath: string; + /** true = trainset was auto-generated by this task; false/absent = user-provided (exact mode) */ + autoGenerated?: boolean; + }; + + generate_evaldataset?: { + evalDatasetId: string; + evalDatasetFilePath: string; + /** true = eval dataset was auto-generated by this task; false/absent = user-provided (exact mode) */ + autoGenerated?: boolean; + }; + + eval_basemodel?: { + baseModelEvalResult: EmbeddingEvalResult; + /** Per-query ranked document IDs from embedding search (indexed by eval data item _id) */ + rankingResults?: Array<{ + itemId: string; + rankedIds: string[]; + }>; + }; + + finetuning?: { + sftTaskId: string; + tunedModelEndpoint?: { + base_url: string; + api_key: string; + model: string; + }; + }; + + registering?: { + tunedModelId: string; + }; + + eval_tunedmodel?: { + tunedModelEvalResult: EmbeddingEvalResult; + /** Per-query ranked document IDs from embedding search (indexed by eval data item _id) */ + rankingResults?: Array<{ + itemId: string; + rankedIds: string[]; + }>; + }; + + llm_judge?: { + /** LLM-judged relevant chunk IDs per query (replaces original expectedContextIds) */ + judgedExpectedIds: Array<{ itemId: string; expectedIds: string[] }>; + /** Baseline metrics recomputed with judged expectedContextIds */ + baseModelRejudgedResult: EmbeddingEvalResult; + /** Tuned metrics recomputed with judged expectedContextIds */ + tunedModelRejudgedResult: EmbeddingEvalResult; + }; + }; + stageEndTime?: { + generate_trainset?: Date; + generate_evaldataset?: Date; + eval_basemodel?: Date; + finetuning?: Date; + registering?: Date; + eval_tunedmodel?: Date; + }; + }; + + result?: { + trainDatasetId: string; + trainDatasetFilePath: string; + tunedModelId: string; + evalDatasetId: string; + evalDatasetFilePath: string; + baseModelEvalResult: EmbeddingEvalResult; + tunedModelEvalResult: EmbeddingEvalResult; + /** Re-judged metrics (from llm_judge stage, if available) */ + baseModelRejudgedResult?: EmbeddingEvalResult; + tunedModelRejudgedResult?: EmbeddingEvalResult; + }; + + errorMsg?: EnhancedErrorMessage; + + jobId?: string; + + createTime: Date; + updateTime: Date; + finishTime?: Date; +}; diff --git a/packages/global/core/train/rerank/api.d.ts b/packages/global/core/train/rerank/api.d.ts new file mode 100644 index 000000000000..4acdcca8f04d --- /dev/null +++ b/packages/global/core/train/rerank/api.d.ts @@ -0,0 +1,170 @@ +import type { PaginationProps, PaginationResponse } from '../../openapi/api'; +import type { + RerankTrainsetSchemaType, + RerankTrainsetDataSchemaType, + RerankTrainTaskSchemaType +} from './type'; +import type { + RerankTrainDataSourceEnum, + RerankTrainTaskStatusEnum, + RerankTrainMethodEnum +} from './constants'; + +import type { + MessageResponse, + TrainsetIdQuery, + TaskIdQuery, + DataIdQuery, + SortParams +} from '../common/api'; +import type { DatasetDataIndexTypeEnum } from '../../dataset/data/constants'; + +// ===== Trainset API ===== + +// Create Trainset +export type CreateRerankTrainsetRequest = { + name?: string; + description?: string; +}; +export type CreateRerankTrainsetResponse = RerankTrainsetSchemaType; + +// Get Trainset Detail +export type RerankTrainsetDetailRequest = TrainsetIdQuery; +export type RerankTrainsetDetailResponse = RerankTrainsetSchemaType; + +// List Trainsets +export type ListRerankTrainsetsRequest = PaginationProps< + { + status?: string; + } & SortParams<'createTime' | 'updateTime' | 'name'> +>; +export type ListRerankTrainsetsResponse = PaginationResponse; + +// Delete Trainset +export type DeleteRerankTrainsetRequest = TrainsetIdQuery; +export type DeleteRerankTrainsetResponse = MessageResponse; + +// ===== Training Data API ===== + +// Generate Training Data (from dataset chunks) +export type GenerateRerankTrainDataRequest = { + trainsetId: string; // Required: Target trainset ID + datasetIds: string[]; // Required: Knowledge base IDs to generate data from + generateConfig?: { + /** FastGPT internal parameters */ + sampleSize?: number; // Total sample budget M (optional for train/eval modes) + weights?: Record; // Per-KB sampling weights (requires sampleSize) + forceRegenerate?: boolean; // Whether to force regeneration + /** buildFineTuneData parameters */ + indexType: `${DatasetDataIndexTypeEnum}`; // Index type to use as query + indexMultiStrategy?: 1 | 2; // Strategy for multiple index elements: 1=longest, 2=all + negativeStrategy?: 1 | 2 | 3 | 4; // Negative sampling strategy, default 2 + minNegativeSamples?: number; // Min negative samples per sample, default 1 + maxNegativeSamples?: number; // Max negative samples per sample, default 10 + }; +}; +export type GenerateRerankTrainDataResponse = { + jobId: string; + status: 'pending'; +}; + +// Create Training Data (manual) +export type CreateRerankTrainDataRequest = { + trainsetId: string; // Required: Target trainset ID + query: string; // Single query + positiveDocs: string[]; + negativeDocs: string[]; + reason?: string; // Reason for addition +}; +export type CreateRerankTrainDataResponse = RerankTrainsetDataSchemaType; + +// Update Training Data +export type UpdateRerankTrainDataRequest = DataIdQuery & { + query?: string; // Single query + positiveDocs?: string[]; + negativeDocs?: string[]; +}; +export type UpdateRerankTrainDataResponse = MessageResponse; + +// List Training Data +export type ListRerankTrainDataRequest = PaginationProps< + { + trainsetId: string; // Required: Trainset ID to query + source?: `${RerankTrainDataSourceEnum}`; + } & SortParams<'createTime' | 'updateTime'> +>; +export type ListRerankTrainDataResponse = PaginationResponse; + +// Delete Training Data +export type DeleteRerankTrainDataRequest = { + dataIds: string[]; +}; +export type DeleteRerankTrainDataResponse = MessageResponse; + +// ===== Training Task API ===== + +// Create Training Task +// datasetIds is ALWAYS required (even when trainsetId or evalDatasetId are provided): +// - trainset generation stage reads datasetIds to sample training data +// - eval-dataset generation stage reads datasetIds to sample eval data +// - trainsetId present → skip trainset generation stage only; datasetIds still needed for eval +// - evalDatasetId present → skip eval-dataset generation stage only; datasetIds still needed for task record integrity +export type CreateRerankTrainTaskRequest = { + datasetIds: string[]; // Required in all modes: knowledge base IDs used across generation and evaluation stages + trainsetId?: string; // Optional: if present, skip trainset generation stage + evalDatasetId?: string; // Optional: if present, skip eval dataset generation stage + + baseModelId: string; // Base model ID (BaseModelItemType.id) + newModelName?: string; // Optional name for the trained model + name?: string; + trainMethod?: `${RerankTrainMethodEnum}`; // Training type: lora or task_tuning, defaults to lora + + /** Trainset synthesis config — used when auto-generating the trainset (trainsetId absent) */ + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; +}; +export type CreateRerankTrainTaskResponse = RerankTrainTaskSchemaType; + +// Get Task Detail +export type RerankTrainTaskDetailRequest = TaskIdQuery; +export type RerankTrainTaskDetailResponse = RerankTrainTaskSchemaType & { + creatorName?: string; + creatorAvatar?: string; +}; + +// List Training Tasks +export type ListRerankTrainTasksRequest = PaginationProps< + { + baseModelId?: string; // Filter by base model + tunedModelId?: string; // Filter by produced tuned model (triggers chain traversal) + status?: `${RerankTrainTaskStatusEnum}`; + } & SortParams<'createTime' | 'updateTime' | 'finishTime'> +>; + +export type RerankTrainTaskListItem = RerankTrainTaskSchemaType & { + creatorName?: string; + creatorAvatar?: string; +}; +export type ListRerankTrainTasksResponse = PaginationResponse; + +// Retry Training Task +export type RetryRerankTrainTaskRequest = TaskIdQuery; +export type RetryRerankTrainTaskResponse = { + success: true; + jobId: string; +}; + +// Cancel Training Task +export type CancelRerankTrainTaskRequest = TaskIdQuery; +export type CancelRerankTrainTaskResponse = MessageResponse; + +// Delete Training Task +export type DeleteRerankTrainTaskRequest = TaskIdQuery & { force?: string; deleteModel?: string }; +export type DeleteRerankTrainTaskResponse = MessageResponse; diff --git a/packages/global/core/train/rerank/constants.ts b/packages/global/core/train/rerank/constants.ts new file mode 100644 index 000000000000..37a179a0480d --- /dev/null +++ b/packages/global/core/train/rerank/constants.ts @@ -0,0 +1,40 @@ +/** Rerank trainset status */ +export enum RerankTrainsetStatusEnum { + pending = 'pending', // Pending (initial state, waiting to generate data) + generating = 'generating', // Generating (creating data from associated dataset chunks) + ready = 'ready', // Ready + error = 'error' // Error +} + +/** Training data source */ +export enum RerankTrainDataSourceEnum { + dataset = 'dataset', // Generated from dataset + chat_log = 'chat_log', // Converted from chat logs + manual = 'manual' // Manually added +} + +/** Training task status */ +export enum RerankTrainTaskStatusEnum { + pending = 'pending', + running = 'running', + completed = 'completed', + failed = 'failed', + cancelled = 'cancelled' +} + +/** Training type */ +export enum RerankTrainMethodEnum { + lora = 'lora', + task_tuning = 'task_tuning' +} + +/** Training task checkpoint stage */ +export enum RerankTaskCheckpointStageEnum { + generate_trainset = 'generate_trainset', // Generate training set (renamed from preparing; auto mode: create trainset + trigger data gen; exact mode: wait for ready) + generate_evaldataset = 'generate_evaldataset', // Generate eval dataset (moved before finetuning; auto mode: call DiTing; exact mode: skip) + eval_basemodel = 'eval_basemodel', // Evaluate base model to establish baseline + finetuning = 'finetuning', // Model fine-tuning (sft bridge executes fine-tuning and auto-deploys) + registering = 'registering', // Model registration (register configuration in FastGPT) + eval_tunedmodel = 'eval_tunedmodel', // Evaluate fine-tuned model, compare with baseline + llm_judge = 'llm_judge' // LLM-based relevance judgment, re-compute metrics with judged expectedContextIds +} diff --git a/packages/global/core/train/rerank/error.ts b/packages/global/core/train/rerank/error.ts new file mode 100644 index 000000000000..eaa34346dc34 --- /dev/null +++ b/packages/global/core/train/rerank/error.ts @@ -0,0 +1,12 @@ +/** + * Module-specific type alias with rerank-specific type parameters. + */ +import type { GenericEnhancedErrorMessage } from '../common/error'; +import type { RerankTaskCheckpointStageEnum } from './constants'; +import type { RerankTrainErrEnum } from '../../../common/error/code/train'; + +/** Enhanced error message for rerank training tasks */ +export type EnhancedErrorMessage = GenericEnhancedErrorMessage< + `${RerankTaskCheckpointStageEnum}` | string, + `${RerankTrainErrEnum}` | string +>; diff --git a/packages/global/core/train/rerank/type.d.ts b/packages/global/core/train/rerank/type.d.ts new file mode 100644 index 000000000000..8e1a7a038c91 --- /dev/null +++ b/packages/global/core/train/rerank/type.d.ts @@ -0,0 +1,277 @@ +import type { + RerankTrainsetStatusEnum, + RerankTrainDataSourceEnum, + RerankTrainTaskStatusEnum, + RerankTaskCheckpointStageEnum, + RerankTrainMethodEnum +} from './constants'; +import type { EnhancedErrorMessage } from './error'; +import type { DatasetDataIndexTypeEnum } from '../../dataset/data/constants'; + +/** + * Detailed evaluation results from DiTing + * Contains various ranking metrics at different k values + */ +export interface RerankDiTingDetailedResults { + rerank_top5_mrr?: number; + rerank_top5_ndcg?: number; + rerank_top5_map?: number; + rerank_top5_precision?: number; + rerank_top10_mrr?: number; + rerank_top10_ndcg?: number; + rerank_top10_map?: number; + rerank_top10_precision?: number; + rerank_top10_recall?: number; + rerank_top15_mrr?: number; + rerank_top15_ndcg?: number; + rerank_top15_map?: number; + rerank_top15_precision?: number; + overall_mrr?: number; + overall_ndcg?: number; + overall_map?: number; + overall_precision?: number; + [key: string]: any; +} + +/** + * Rerank evaluation result + * Contains the complete runLogs structure from DiTing evaluation response + * This is what gets returned by evaluation stages and stored in checkpoint/result + */ +export interface RerankEvalResult { + detailed_results: RerankDiTingDetailedResults; + mrr_scores?: Record; + ndcg_scores?: Record; + map_scores?: Record; + precision_scores?: Record; + /** + * Retrieval ranks for each case (case-by-case) + * Outer array: each evaluation case + * Inner array: ranks of each retrieved document (position in original retrieval list) + * Example: [[1, 3, 2, 5, 4], [2, 1, 4, 3, 5]] + * - Case 1: expected doc was at position 1, 3, etc. in retrieval list + * - Case 2: expected doc was at position 2, 1, etc. in retrieval list + */ + retrieval_ranks?: number[][]; + column_stats?: Record; + total_rows?: number; + expect_count?: number; +} + +/** Trainset statistics (dynamically calculated, not stored in DB) */ +export interface RerankTrainsetStatistics { + dataCount: number; + positiveCount: number; + negativeCount: number; + sourceSummary: Array< + | { + type: 'dataset'; + count: number; + datasetInfo: { + datasetId: string; + }; + } + | { + type: 'chat_log'; + count: number; + chatLogInfo: { + chatId: string; + }; + } + | { + type: 'manual'; + count: number; + manualInfo: { + creator: string; + }; + } + >; +} + +/** Rerank trainset schema */ +export type RerankTrainsetSchemaType = { + _id: string; + teamId: string; + tmbId: string; + + name: string; + description?: string; + + status: `${RerankTrainsetStatusEnum}`; + errorMsg?: EnhancedErrorMessage; + + jobId?: string; + + createTime: Date; + updateTime: Date; + + statistics?: RerankTrainsetStatistics; +}; + +/** Rerank training data schema */ +export type RerankTrainsetDataSchemaType = { + _id: string; + trainsetId: string; + teamId: string; + + query: string; + positiveDocs: string[]; + negativeDocs: string[]; + + source: `${RerankTrainDataSourceEnum}`; + + metadata: { + sourceInfo: { + datasetInfo?: { + dataId: string; + datasetId: string; + }; + + chatLogInfo?: { + chatId: string; + itemIds: string[]; + }; + + manualInfo?: { + creator: string; + createdAt: Date; + reason?: string; + }; + }; + + generateConfig?: { + sampleSize?: number; + weights?: Record; + forceRegenerate?: boolean; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; + }; + + createTime: Date; +}; + +/** Rerank training task schema */ +export type RerankTrainTaskSchemaType = { + _id: string; + teamId: string; + tmbId: string; + + name: string; + /** Base model ID (BaseModelItemType.id) */ + baseModelId: string; + baseModelEndpoint: { + base_url?: string; + api_key?: string; + model: string; + }; + + /** Training type: lora or task_tuning, defaults to lora */ + trainMethod?: `${RerankTrainMethodEnum}`; + /** Train dataset ID (exact mode: passed at create; auto mode: written by generate_trainset stage) */ + trainsetId?: string; + /** Eval dataset ID (exact mode: passed at create; auto mode: written by generate_evaldataset stage) */ + evalDatasetId?: string; + /** Knowledge base IDs used for trainset/evaldataset generation and model evaluation */ + datasetIds: string[]; + /** Optional name for the trained model */ + newModelName?: string; + + /** Trainset synthesis config stored at task level — passed to data generation queue */ + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; + + status: RerankTrainTaskStatusEnum; + + checkpoint: { + stage: `${RerankTaskCheckpointStageEnum}` | null; + data?: { + generate_trainset?: { + trainDatasetId: string; + trainDatasetFilePath: string; + /** true = trainset was auto-generated by this task; false/absent = user-provided (exact mode) */ + autoGenerated?: boolean; + }; + + generate_evaldataset?: { + evalDatasetId: string; + evalDatasetFilePath: string; + /** true = eval dataset was auto-generated by this task; false/absent = user-provided (exact mode) */ + autoGenerated?: boolean; + }; + + eval_basemodel?: { + baseModelEvalResult: RerankEvalResult; + /** Per-query ranked document IDs from rerank evaluation (indexed by eval data item _id) */ + rankingResults?: Array<{ itemId: string; rankedIds: string[] }>; + }; + + finetuning?: { + sftTaskId: string; + tunedModelEndpoint?: { + base_url: string; + api_key: string; + model: string; + }; + }; + + registering?: { + tunedModelId: string; + }; + + eval_tunedmodel?: { + tunedModelEvalResult: RerankEvalResult; + /** Per-query ranked document IDs from rerank evaluation (indexed by eval data item _id) */ + rankingResults?: Array<{ itemId: string; rankedIds: string[] }>; + }; + + llm_judge?: { + /** LLM-judged relevant chunk IDs per query (replaces original expectedContextIds) */ + judgedExpectedIds: Array<{ itemId: string; expectedIds: string[] }>; + /** Baseline metrics recomputed with judged expectedContextIds */ + baseModelRejudgedResult: RerankEvalResult; + /** Tuned metrics recomputed with judged expectedContextIds */ + tunedModelRejudgedResult: RerankEvalResult; + }; + }; + stageEndTime?: { + generate_trainset?: Date; + generate_evaldataset?: Date; + eval_basemodel?: Date; + finetuning?: Date; + registering?: Date; + eval_tunedmodel?: Date; + }; + }; + + result?: { + trainDatasetId: string; + trainDatasetFilePath: string; + tunedModelId: string; + evalDatasetId: string; + evalDatasetFilePath: string; + baseModelEvalResult: RerankEvalResult; + tunedModelEvalResult: RerankEvalResult; + /** Re-judged metrics (from llm_judge stage, if available) */ + baseModelRejudgedResult?: RerankEvalResult; + tunedModelRejudgedResult?: RerankEvalResult; + }; + + errorMsg?: EnhancedErrorMessage; + + jobId?: string; + + createTime: Date; + updateTime: Date; + finishTime?: Date; +}; diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index 08a825b6d06f..558a881013a3 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; import type { JsonSchemaPropertiesItemType } from '../app/jsonschema'; export enum FlowNodeTemplateTypeEnum { @@ -126,6 +126,7 @@ export enum NodeInputKeyEnum { scheduleTrigger = 'scheduleTrigger', chatInputGuide = 'chatInputGuide', autoExecute = 'autoExecute', + entryPoints = 'entryPoints', // plugin config instruction = 'instruction', @@ -138,7 +139,7 @@ export enum NodeInputKeyEnum { // latest // common - aiModel = 'model', + aiModelId = 'modelId', aiSystemPrompt = 'systemPrompt', description = 'description', anyInput = 'system_anyInput', @@ -173,7 +174,6 @@ export enum NodeInputKeyEnum { datasetParams = 'agent_datasetParams', skills = 'skills', useAgentSandbox = 'useAgentSandbox', - useEditDebugSandbox = 'useEditDebugSandbox', // dataset datasetSelectList = 'datasets', @@ -181,21 +181,32 @@ export enum NodeInputKeyEnum { datasetMaxTokens = 'limit', datasetSearchMode = 'searchMode', datasetSearchEmbeddingWeight = 'embeddingWeight', + datasetSearchEmbeddingModelId = 'embeddingModelId', datasetSearchUsingReRank = 'usingReRank', datasetSearchRerankWeight = 'rerankWeight', - datasetSearchRerankModel = 'rerankModel', + datasetSearchRerankModelId = 'rerankModelId', + datasetSearchRerankMethod = 'rerankMethod', datasetSearchUsingExtensionQuery = 'datasetSearchUsingExtensionQuery', - datasetSearchExtensionModel = 'datasetSearchExtensionModel', + datasetSearchExtensionModelId = 'datasetSearchExtensionModelId', datasetSearchExtensionBg = 'datasetSearchExtensionBg', collectionFilterMatch = 'collectionFilterMatch', authTmbId = 'authTmbId', + searchColumnslLimitRatio = 'searchRatio', + generateSqlModelId = 'generateSqlModelId', datasetDeepSearch = 'datasetDeepSearch', - datasetDeepSearchModel = 'datasetDeepSearchModel', + datasetDeepSearchModelId = 'datasetDeepSearchModelId', datasetDeepSearchMaxTimes = 'datasetDeepSearchMaxTimes', datasetDeepSearchBg = 'datasetDeepSearchBg', + // 检索模式(单轮/多轮) + datasetRetrievalMode = 'retrievalMode', + // 多轮智能检索专用 + datasetAgenticSearchLLMModelId = 'agenticSearchLLMModelId', + datasetAgenticSearchRerankModelId = 'agenticSearchRerankModelId', + datasetAgenticSearchReasoning = 'agenticSearchReasoning', + // concat dataset datasetQuoteList = 'system_datasetQuoteList', diff --git a/packages/global/core/workflow/node/agent/skillTools.ts b/packages/global/core/workflow/node/agent/skillTools.ts index 96077748bd72..5eb99023f42f 100644 --- a/packages/global/core/workflow/node/agent/skillTools.ts +++ b/packages/global/core/workflow/node/agent/skillTools.ts @@ -18,7 +18,7 @@ export const skillToolsMap = { 'zh-Hant': '讀取文件', en: 'ReadFile' }, - avatar: 'core/workflow/template/readFiles', + avatar: 'core/chat/toolCall', toolDescription: 'Read file contents in the sandbox, supports batch reading. Used to view SKILL.md documents, config files, execution results, etc.' }, @@ -28,7 +28,7 @@ export const skillToolsMap = { 'zh-Hant': '寫入文件', en: 'WriteFile' }, - avatar: 'core/workflow/template/readFiles', + avatar: 'core/chat/toolCall', toolDescription: 'Create or overwrite a file in the sandbox. Used to write input data, create config files, etc.' }, @@ -38,7 +38,7 @@ export const skillToolsMap = { 'zh-Hant': '編輯文件', en: 'EditFile' }, - avatar: 'core/workflow/template/readFiles', + avatar: 'core/chat/toolCall', toolDescription: 'Edit files in the sandbox precisely by finding and replacing specified content. Supports batch editing across multiple files.' }, @@ -48,7 +48,7 @@ export const skillToolsMap = { 'zh-Hant': '執行命令', en: 'Execute' }, - avatar: 'core/workflow/template/codeRun', + avatar: 'core/chat/toolCall', toolDescription: 'Execute a shell command in the sandbox. Used to run scripts, install dependencies, execute skills, etc.' }, @@ -58,7 +58,7 @@ export const skillToolsMap = { 'zh-Hant': '搜索文件', en: 'SearchFile' }, - avatar: 'core/workflow/template/datasetSearch', + avatar: 'core/chat/toolCall', toolDescription: 'Search for files in the sandbox. Find matching file paths by filename pattern (glob).' }, @@ -68,7 +68,7 @@ export const skillToolsMap = { 'zh-Hant': '獲取用戶文件', en: 'FetchUserFile' }, - avatar: 'core/workflow/template/readFiles', + avatar: 'core/chat/toolCall', toolDescription: 'Download a user-uploaded file (document or image) from the conversation and write it as a binary file into the sandbox filesystem. Use this when a skill script needs to process a raw file. Workflow: call this tool first to place the file at target_path (relative to workspace), then run skill scripts that read from that path.' } @@ -233,6 +233,5 @@ export const allSandboxTools: ChatCompletionTool[] = [ sandboxWriteFileTool, sandboxEditFileTool, sandboxExecuteTool, - sandboxSearchTool, sandboxFetchUserFileTool ]; diff --git a/packages/global/core/workflow/node/constant.ts b/packages/global/core/workflow/node/constant.ts index ff0c0b7cb281..3fe2e8a20ade 100644 --- a/packages/global/core/workflow/node/constant.ts +++ b/packages/global/core/workflow/node/constant.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { WorkflowIOValueTypeEnum } from '../constants'; export enum FlowNodeInputTypeEnum { // render ui reference = 'reference', // reference to other node output @@ -34,7 +34,9 @@ export enum FlowNodeInputTypeEnum { // render ui fileSelect = 'fileSelect', timePointSelect = 'timePointSelect', timeRangeSelect = 'timeRangeSelect', - password = 'password' + password = 'password', + tagFilterConfig = 'tagFilterConfig', // 标签过滤 GUI 配置 + off = 'off' // 关闭/不启用 } export const FlowNodeInputMap: Record< FlowNodeInputTypeEnum, @@ -113,6 +115,12 @@ export const FlowNodeInputMap: Record< }, [FlowNodeInputTypeEnum.password]: { icon: 'core/workflow/inputType/password' + }, + [FlowNodeInputTypeEnum.tagFilterConfig]: { + icon: 'core/workflow/inputType/input' + }, + [FlowNodeInputTypeEnum.off]: { + icon: 'circleMinus' } }; diff --git a/packages/global/core/workflow/runtime/type.ts b/packages/global/core/workflow/runtime/type.ts index 5f284387e894..65dd50ad46e2 100644 --- a/packages/global/core/workflow/runtime/type.ts +++ b/packages/global/core/workflow/runtime/type.ts @@ -26,7 +26,7 @@ import type { import { SearchDataResponseItemSchema } from '../../dataset/type'; import type { localeType } from '../../../common/i18n/type'; import { type UserChatItemValueItemType } from '../../chat/type'; -import { DatasetSearchModeEnum } from '../../dataset/constants'; +import { DatasetSearchModeEnum, DatasetRetrievalModeEnum } from '../../dataset/constants'; import { ChatRoleEnum } from '../../chat/constants'; import z from 'zod'; import type { JSONSchemaInputType } from '../../app/jsonschema'; @@ -151,7 +151,8 @@ export const DispatchNodeResponseSchema = z .record(z.string(), z.any()) .optional() .meta({ description: '模块名 i18n 插值参数' }), - runningTime: z.number().optional().meta({ description: '运行时间: 毫秒' }), + runningTime: z.number().optional().meta({ description: '运行时间: 秒' }), + firstTokenTime: z.number().optional().meta({ description: '首 token 响应时间: 秒' }), query: z.string().optional().meta({ description: '查询语句' }), textOutput: z.string().optional().meta({ description: '文本输出' }), @@ -173,7 +174,7 @@ export const DispatchNodeResponseSchema = z tokens: z.number().optional().meta({ description: '总 token' }), inputTokens: z.number().optional().meta({ description: '输入 token' }), outputTokens: z.number().optional().meta({ description: '输出 token' }), - model: z.string().optional().meta({ description: '模型' }), + modelId: z.string().optional().meta({ description: '模型Id' }), contextTotalLen: z.number().optional().meta({ description: '上下文总长度' }), totalPoints: z.number().optional().meta({ description: '总积分' }), childTotalPoints: z.number().optional().meta({ description: '子节点总积分' }), @@ -198,33 +199,86 @@ export const DispatchNodeResponseSchema = z finishReason: CompletionFinishReasonSchema.optional(), // dataset search - embeddingModel: z.string().optional().meta({ description: '嵌入模型' }), + embeddingModelId: z.string().optional().meta({ description: '嵌入模型Id' }), embeddingTokens: z.number().optional().meta({ description: '嵌入 token' }), similarity: z.number().optional().meta({ description: '相似度' }), limit: z.number().optional().meta({ description: '限制' }), searchMode: z.enum(DatasetSearchModeEnum).optional().meta({ description: '搜索模式' }), embeddingWeight: z.number().optional().meta({ description: '嵌入权重' }), - rerankModel: z.string().optional().meta({ description: '重排模型' }), + rerankModelId: z.string().optional().meta({ description: '重排模型Id' }), rerankWeight: z.number().optional().meta({ description: '重排权重' }), reRankInputTokens: z.number().optional().meta({ description: '重排输入 token' }), searchUsingReRank: z.boolean().optional().meta({ description: '使用重排' }), queryExtensionResult: z .object({ - model: z.string().meta({ description: '模型' }), + modelId: z.string().meta({ description: '模型' }), inputTokens: z.number().meta({ description: '输入 token' }), outputTokens: z.number().meta({ description: '输出 token' }), - query: z.string().meta({ description: '查询内容' }) + query: z.string().meta({ description: '查询内容' }), + synonymRewriteResult: z + .object({ + standardizedQuery: z.string().meta({ description: '标准化查询' }), + coreferenceResolved: z.string().meta({ description: '指代消除后的查询' }) + }) + .optional() + .meta({ description: '同义词改写结果' }), + rewriteTime: z.number().optional().meta({ description: '改写耗时(秒)' }) }) .optional() .meta({ description: '查询扩展结果' }), deepSearchResult: z .object({ - model: z.string().meta({ description: '模型' }), + modelId: z.string().meta({ description: '模型' }), inputTokens: z.number().meta({ description: '输入 token' }), outputTokens: z.number().meta({ description: '输出 token' }) }) .optional(), + // text2sql result + sqlResult: z.array(z.any()).optional().meta({ description: 'SQL 检索结果' }), + // agentic search result + agenticSearchResult: z + .object({ + reasoningText: z.string().meta({ description: '思考过程文本' }), + searchCount: z.number().meta({ description: '实际检索轮次' }), + toolCallCount: z.number().meta({ description: 'Tool 调用总次数' }), + llmModelId: z.string().optional().meta({ description: '使用的 LLM 模型 ID' }), + llmInputTokens: z.number().optional().meta({ description: 'LLM 输入 token 数' }), + llmOutputTokens: z.number().optional().meta({ description: 'LLM 输出 token 数' }), + playbook: z.any().optional().meta({ description: '检索策略描述(来自 diting-rag-ts)' }), + executionPath: z.any().optional().meta({ description: '执行路径(来自 diting-rag-ts)' }), + confidence: z.any().optional().meta({ description: '置信度(来自 diting-rag-ts)' }), + queryLanguage: z.string().optional().meta({ description: '用户查询语言(ISO 639-1)' }) + }) + .optional() + .meta({ description: '智能检索结果' }), + retrievalMode: z.enum(DatasetRetrievalModeEnum).optional().meta({ description: '检索模式' }), + queryLanguage: z + .string() + .optional() + .meta({ description: '用户查询语言(ISO 639-1,eld 检测)' }), + retrievalType: z + .enum(['correction', 'faq']) + .optional() + .meta({ description: '检索类型标记,仅当 correction 或 faq 命中时存在' }), + retrievalResults: z + .array(SearchDataResponseItemSchema) + .optional() + .meta({ description: '检索结果(RRF融合后的中间结果)' }), + + // 检索耗时 + retrievalTime: z.number().optional().meta({ description: '知识检索耗时(秒)' }), + sqlRetrievalTime: z.number().optional().meta({ description: 'SQL 检索耗时(秒)' }), + rerankTime: z.number().optional().meta({ description: '重排耗时(秒)' }), + rerankError: z + .object({ + i18nErrorMessage: z.string().optional(), + i18nErrorMessageData: z.record(z.string(), z.any()).optional() + }) + .optional() + .meta({ description: '重排错误信息' }), + correctSearchResult: z.any().optional().meta({ description: '纠正搜索结果' }), + // dataset concat concatLength: z.number().optional(), @@ -252,6 +306,7 @@ export const DispatchNodeResponseSchema = z toolInput: z.record(z.string(), z.any()).optional().meta({ description: '工具输入' }), pluginOutput: z.record(z.string(), z.any()).optional().meta({ description: '插件输出' }), pluginDetail: z.array(z.any()).optional(), + appType: z.string().optional().meta({ description: '应用类型' }), toolParamsResult: z .record(z.string(), z.any()) .optional() @@ -319,7 +374,7 @@ export const DispatchNodeResponseSchema = z // Tools toolId: z.string().optional().meta({ description: '工具 ID' }), - extensionModel: z.string().optional().meta({ description: '扩展模型', deprecated: true }), + extensionModelId: z.string().optional().meta({ description: '扩展模型Id', deprecated: true }), extensionResult: z.string().optional().meta({ description: '扩展结果', deprecated: true }), extensionTokens: z.number().optional().meta({ description: '扩展 token', deprecated: true }) }) @@ -361,7 +416,7 @@ export type DispatchNodeResultType | Map, + variables: Record +): string | undefined => { + if (!filterJson) return filterJson; + try { + const parsed = JSON.parse(filterJson); + const logicKey: '$and' | '$or' = parsed?.tags?.$and ? '$and' : '$or'; + const conditions: any[] = parsed?.tags?.[logicKey]; + if (!Array.isArray(conditions) || conditions.length === 0) return filterJson; + + const resolvedConditions = conditions.map((cond: Record>) => { + const tagName = Object.keys(cond)[0]; + const opObj = cond[tagName]; + const op = Object.keys(opObj)[0]; + const val = opObj[op]; + + if (Array.isArray(val) && val.length === 3 && val[0] === '$ref') { + const resolved = getReferenceVariableValue({ + value: [val[1], val[2]], + nodesMap, + variables + }); + return { [tagName]: { [op]: resolved } }; + } + return cond; + }); + + return JSON.stringify({ ...parsed, tags: { [logicKey]: resolvedConditions } }); + } catch { + return filterJson; + } +}; + // 模块级 RegExp 缓存,避免每次变量替换都重新编译正则 const _replaceRegexCache = new Map(); const _MAX_REGEX_CACHE_SIZE = 5000; diff --git a/packages/global/core/workflow/template/input.ts b/packages/global/core/workflow/template/input.ts index 30497201b02a..b2d87c340cc3 100644 --- a/packages/global/core/workflow/template/input.ts +++ b/packages/global/core/workflow/template/input.ts @@ -3,7 +3,7 @@ import { FlowNodeInputTypeEnum } from '../node/constant'; import { WorkflowIOValueTypeEnum } from '../constants'; import { chatNodeSystemPromptTip, systemPromptTip } from './tip'; import { type FlowNodeInputItemType } from '../type/io'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export const Input_Template_History: FlowNodeInputItemType = { key: NodeInputKeyEnum.history, @@ -36,14 +36,14 @@ export const Input_Template_DynamicInput: FlowNodeInputItemType = { }; export const Input_Template_SelectAIModel: FlowNodeInputItemType = { - key: NodeInputKeyEnum.aiModel, + key: NodeInputKeyEnum.aiModelId, renderTypeList: [FlowNodeInputTypeEnum.selectLLMModel, FlowNodeInputTypeEnum.reference], label: i18nT('common:core.module.input.label.aiModel'), required: true, valueType: WorkflowIOValueTypeEnum.string }; export const Input_Template_SettingAiModel: FlowNodeInputItemType = { - key: NodeInputKeyEnum.aiModel, + key: NodeInputKeyEnum.aiModelId, renderTypeList: [FlowNodeInputTypeEnum.settingLLMModel, FlowNodeInputTypeEnum.reference], label: i18nT('common:core.module.input.label.aiModel'), valueType: WorkflowIOValueTypeEnum.string diff --git a/packages/global/core/workflow/template/output.ts b/packages/global/core/workflow/template/output.ts index 0aebb128b712..2d206863f3b3 100644 --- a/packages/global/core/workflow/template/output.ts +++ b/packages/global/core/workflow/template/output.ts @@ -2,7 +2,7 @@ import type { FlowNodeOutputItemType } from '../type/io'; import { NodeOutputKeyEnum } from '../constants'; import { FlowNodeOutputTypeEnum } from '../node/constant'; import { WorkflowIOValueTypeEnum } from '../constants'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export const Output_Template_AddOutput: FlowNodeOutputItemType = { id: NodeOutputKeyEnum.addOutputParam, diff --git a/packages/global/core/workflow/template/system/abandoned/runApp/index.ts b/packages/global/core/workflow/template/system/abandoned/runApp/index.ts index d075afa871ee..87f6346b4162 100644 --- a/packages/global/core/workflow/template/system/abandoned/runApp/index.ts +++ b/packages/global/core/workflow/template/system/abandoned/runApp/index.ts @@ -12,7 +12,7 @@ import { FlowNodeTemplateTypeEnum } from '../../../../constants'; import { Input_Template_History, Input_Template_UserChatInput } from '../../../input'; -import { i18nT } from '../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../common/i18n/utils'; export const RunAppModule: FlowNodeTemplateType = { id: FlowNodeTypeEnum.runApp, diff --git a/packages/global/core/workflow/template/system/agent/index.ts b/packages/global/core/workflow/template/system/agent/index.ts index 379798143dbb..85365bc49367 100644 --- a/packages/global/core/workflow/template/system/agent/index.ts +++ b/packages/global/core/workflow/template/system/agent/index.ts @@ -17,7 +17,7 @@ import { Input_Template_UserChatInput } from '../../input'; import { chatNodeSystemPromptTip, systemPromptTip } from '../../tip'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { Input_Template_File_Link } from '../../input'; import { Output_Template_Error_Message } from '../../output'; import { DatasetSearchModeEnum } from '../../../../dataset/constants'; @@ -167,7 +167,7 @@ export const AgentNode: FlowNodeTemplateType = { value: false }, { - key: NodeInputKeyEnum.datasetSearchRerankModel, + key: NodeInputKeyEnum.datasetSearchRerankModelId, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.string @@ -187,7 +187,7 @@ export const AgentNode: FlowNodeTemplateType = { value: true }, { - key: NodeInputKeyEnum.datasetSearchExtensionModel, + key: NodeInputKeyEnum.datasetSearchExtensionModelId, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.string diff --git a/packages/global/core/workflow/template/system/aiChat/index.ts b/packages/global/core/workflow/template/system/aiChat/index.ts index 8b24d755603d..999d31bf6173 100644 --- a/packages/global/core/workflow/template/system/aiChat/index.ts +++ b/packages/global/core/workflow/template/system/aiChat/index.ts @@ -19,7 +19,7 @@ import { Input_Template_UserChatInput, Input_Template_File_Link } from '../../input'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { Output_Template_Error_Message } from '../../output'; export const AiChatQuoteRole = { @@ -157,9 +157,9 @@ export const AiChatModule: FlowNodeTemplateType = { valueType: WorkflowIOValueTypeEnum.string, type: FlowNodeOutputTypeEnum.static, invalid: true, - invalidCondition: ({ inputs, llmModelMap }) => { - const model = inputs.find((item) => item.key === NodeInputKeyEnum.aiModel)?.value; - const modelItem = llmModelMap[model]; + invalidCondition: ({ inputs, llmModelIdMap }) => { + const model = inputs.find((item) => item.key === NodeInputKeyEnum.aiModelId)?.value; + const modelItem = llmModelIdMap[model]; return modelItem?.reasoning !== true; } }, diff --git a/packages/global/core/workflow/template/system/assignedAnswer.ts b/packages/global/core/workflow/template/system/assignedAnswer.ts index 14eb6683d618..43bf26a6ee0b 100644 --- a/packages/global/core/workflow/template/system/assignedAnswer.ts +++ b/packages/global/core/workflow/template/system/assignedAnswer.ts @@ -5,7 +5,7 @@ import { NodeInputKeyEnum, FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const AssignedAnswerModule: FlowNodeTemplateType = { id: FlowNodeTypeEnum.answerNode, diff --git a/packages/global/core/workflow/template/system/classifyQuestion/index.ts b/packages/global/core/workflow/template/system/classifyQuestion/index.ts index 98d92cf5c691..8234f9f28578 100644 --- a/packages/global/core/workflow/template/system/classifyQuestion/index.ts +++ b/packages/global/core/workflow/template/system/classifyQuestion/index.ts @@ -16,7 +16,7 @@ import { Input_Template_UserChatInput } from '../../input'; import { Input_Template_System_Prompt } from '../../input'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; export const ClassifyQuestionModule: FlowNodeTemplateType = { id: FlowNodeTypeEnum.classifyQuestion, diff --git a/packages/global/core/workflow/template/system/contextExtract/index.ts b/packages/global/core/workflow/template/system/contextExtract/index.ts index e1bcc864fbc0..681850cbef78 100644 --- a/packages/global/core/workflow/template/system/contextExtract/index.ts +++ b/packages/global/core/workflow/template/system/contextExtract/index.ts @@ -11,7 +11,7 @@ import { FlowNodeTemplateTypeEnum } from '../../../constants'; import { Input_Template_SelectAIModel, Input_Template_History } from '../../input'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { Output_Template_Error_Message } from '../../output'; export const ContextExtractModule: FlowNodeTemplateType = { diff --git a/packages/global/core/workflow/template/system/customFeedback.ts b/packages/global/core/workflow/template/system/customFeedback.ts index b4ade064aae0..e8e835bdfa60 100644 --- a/packages/global/core/workflow/template/system/customFeedback.ts +++ b/packages/global/core/workflow/template/system/customFeedback.ts @@ -5,7 +5,7 @@ import { NodeInputKeyEnum, FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const CustomFeedbackNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.customFeedback, diff --git a/packages/global/core/workflow/template/system/datasetConcat.ts b/packages/global/core/workflow/template/system/datasetConcat.ts index 621e9fa949d5..a54cab8f634a 100644 --- a/packages/global/core/workflow/template/system/datasetConcat.ts +++ b/packages/global/core/workflow/template/system/datasetConcat.ts @@ -13,7 +13,7 @@ import { } from '../../constants'; import { getNanoid } from '../../../../common/string/tools'; import { type FlowNodeInputItemType } from '../../type/io'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const getOneQuoteInputTemplate = ({ key = getNanoid(), diff --git a/packages/global/core/workflow/template/system/datasetSearch.ts b/packages/global/core/workflow/template/system/datasetSearch.ts index 745f3958ae0b..3fbcce8714e8 100644 --- a/packages/global/core/workflow/template/system/datasetSearch.ts +++ b/packages/global/core/workflow/template/system/datasetSearch.ts @@ -13,8 +13,12 @@ import { FlowNodeTemplateTypeEnum } from '../../constants'; import { Input_Template_UserChatInput } from '../input'; -import { DatasetSearchModeEnum } from '../../../dataset/constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { + DatasetRetrievalModeEnum, + DatasetSearchModeEnum, + RerankMethodEnum +} from '../../../dataset/constants'; +import { i18nT } from '../../../../common/i18n/utils'; import { Output_Template_Error_Message } from '../output'; export const Dataset_SEARCH_DESC = i18nT('workflow:template.dataset_search_intro'); @@ -45,12 +49,18 @@ export const DatasetSearchModule: FlowNodeTemplateType = { required: true, valueDesc: datasetSelectValueDesc }, + { + ...Input_Template_UserChatInput, + toolDescription: i18nT('workflow:content_to_search'), + dividerBefore: true + }, { key: NodeInputKeyEnum.datasetSimilarity, renderTypeList: [FlowNodeInputTypeEnum.selectDatasetParamsModal], label: '', value: 0.4, - valueType: WorkflowIOValueTypeEnum.number + valueType: WorkflowIOValueTypeEnum.number, + dividerBefore: true }, // setting from modal { @@ -65,14 +75,20 @@ export const DatasetSearchModule: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.string, - value: DatasetSearchModeEnum.embedding + value: DatasetSearchModeEnum.mixedRecall }, { key: NodeInputKeyEnum.datasetSearchEmbeddingWeight, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.number, - value: 0.5 + value: 0.65 + }, + { + key: NodeInputKeyEnum.datasetSearchEmbeddingModelId, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string }, // Rerank { @@ -80,20 +96,27 @@ export const DatasetSearchModule: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.boolean, - value: false + value: true }, { - key: NodeInputKeyEnum.datasetSearchRerankModel, + key: NodeInputKeyEnum.datasetSearchRerankModelId, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.string }, + { + key: NodeInputKeyEnum.datasetSearchRerankMethod, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string, + value: RerankMethodEnum.question + }, { key: NodeInputKeyEnum.datasetSearchRerankWeight, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.number, - value: 0.5 + value: 0.4 }, // Query Extension { @@ -101,10 +124,10 @@ export const DatasetSearchModule: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.boolean, - value: true + value: false }, { - key: NodeInputKeyEnum.datasetSearchExtensionModel, + key: NodeInputKeyEnum.datasetSearchExtensionModelId, renderTypeList: [FlowNodeInputTypeEnum.hidden], label: '', valueType: WorkflowIOValueTypeEnum.string @@ -116,26 +139,69 @@ export const DatasetSearchModule: FlowNodeTemplateType = { valueType: WorkflowIOValueTypeEnum.string, value: '' }, - { key: NodeInputKeyEnum.authTmbId, - renderTypeList: [FlowNodeInputTypeEnum.hidden], - label: '', + renderTypeList: [FlowNodeInputTypeEnum.switch], + label: i18nT('workflow:permission_filter_label'), + description: i18nT('workflow:permission_filter_tooltip'), valueType: WorkflowIOValueTypeEnum.boolean, - value: false - }, - { - ...Input_Template_UserChatInput, - toolDescription: i18nT('workflow:content_to_search') + value: false, + followLabel: true, + dividerBefore: true }, { key: NodeInputKeyEnum.collectionFilterMatch, - renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], + renderTypeList: [ + FlowNodeInputTypeEnum.off, + FlowNodeInputTypeEnum.tagFilterConfig, + FlowNodeInputTypeEnum.reference + ], label: i18nT('workflow:collection_metadata_filter'), valueType: WorkflowIOValueTypeEnum.string, isPro: true, description: i18nT('workflow:filter_description') + }, + + // database + { + key: NodeInputKeyEnum.generateSqlModelId, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: i18nT('common:search_model'), + value: '', + valueType: WorkflowIOValueTypeEnum.string + }, + + // 检索模式(单轮/多轮) + { + key: NodeInputKeyEnum.datasetRetrievalMode, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: i18nT('workflow:agentic_search_retrieval_mode'), + valueType: WorkflowIOValueTypeEnum.string, + value: DatasetRetrievalModeEnum.standard + }, + + // 多轮智能检索专用 + { + key: NodeInputKeyEnum.datasetAgenticSearchLLMModelId, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: i18nT('workflow:agentic_search_llm_model'), + valueType: WorkflowIOValueTypeEnum.string, + value: '' + }, + { + key: NodeInputKeyEnum.datasetAgenticSearchRerankModelId, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: i18nT('workflow:agentic_search_rerank_model'), + valueType: WorkflowIOValueTypeEnum.string, + value: '' + }, + { + key: NodeInputKeyEnum.datasetAgenticSearchReasoning, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: i18nT('workflow:agentic_search_reasoning'), + valueType: WorkflowIOValueTypeEnum.boolean, + value: true } ], outputs: [ diff --git a/packages/global/core/workflow/template/system/http468.ts b/packages/global/core/workflow/template/system/http468.ts index af8a7beb3898..50fe8176c79b 100644 --- a/packages/global/core/workflow/template/system/http468.ts +++ b/packages/global/core/workflow/template/system/http468.ts @@ -13,7 +13,7 @@ import { } from '../../constants'; import { Input_Template_DynamicInput } from '../input'; import { Output_Template_AddOutput } from '../output'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const HttpNode468: FlowNodeTemplateType = { id: FlowNodeTypeEnum.httpRequest468, diff --git a/packages/global/core/workflow/template/system/ifElse/constant.ts b/packages/global/core/workflow/template/system/ifElse/constant.ts index 0e3151592dbb..54c5dddfdbb8 100644 --- a/packages/global/core/workflow/template/system/ifElse/constant.ts +++ b/packages/global/core/workflow/template/system/ifElse/constant.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; export enum VariableConditionEnum { equalTo = 'equalTo', diff --git a/packages/global/core/workflow/template/system/ifElse/index.ts b/packages/global/core/workflow/template/system/ifElse/index.ts index 6c055ed76f29..d5a2d131400d 100644 --- a/packages/global/core/workflow/template/system/ifElse/index.ts +++ b/packages/global/core/workflow/template/system/ifElse/index.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, diff --git a/packages/global/core/workflow/template/system/interactive/formInput.ts b/packages/global/core/workflow/template/system/interactive/formInput.ts index 816640413bb9..7d20d5df6648 100644 --- a/packages/global/core/workflow/template/system/interactive/formInput.ts +++ b/packages/global/core/workflow/template/system/interactive/formInput.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, diff --git a/packages/global/core/workflow/template/system/interactive/type.d.ts b/packages/global/core/workflow/template/system/interactive/type.d.ts new file mode 100644 index 000000000000..ceb5a73baab7 --- /dev/null +++ b/packages/global/core/workflow/template/system/interactive/type.d.ts @@ -0,0 +1,119 @@ +import type { NodeOutputItemType } from '../../../../chat/type'; +import type { FlowNodeOutputItemType } from '../../../type/io'; +import type { FlowNodeInputTypeEnum } from '../../../node/constant'; +import type { WorkflowIOValueTypeEnum } from '../../../constants'; +import type { ChatCompletionMessageParam } from '../../../../ai/type'; +import type { AppFileSelectConfigType } from '../../../../app/type'; + +type InteractiveBasicType = { + entryNodeIds: string[]; + memoryEdges: RuntimeEdgeItemType[]; + nodeOutputs: NodeOutputItemType[]; + skipNodeQueue?: { id: string; skippedNodeIdList: string[] }[]; // 需要记录目前在 queue 里的节点 + + usageId?: string; +}; + +type InteractiveNodeType = { + entryNodeIds?: string[]; + memoryEdges?: RuntimeEdgeItemType[]; + nodeOutputs?: NodeOutputItemType[]; +}; + +type ChildrenInteractive = InteractiveNodeType & { + type: 'childrenInteractive'; + params: { + childrenResponse: WorkflowInteractiveResponseType; + }; +}; +type ToolCallChildrenInteractive = InteractiveNodeType & { + type: 'toolChildrenInteractive'; + params: { + childrenResponse: WorkflowInteractiveResponseType; + toolParams: { + memoryRequestMessages: ChatCompletionMessageParam[]; // 这轮工具中,产生的新的 messages + toolCallId: string; // 记录对应 tool 的id,用于后续交互节点可以替换掉 tool 的 response + }; + }; +}; + +// Loop bode +type LoopInteractive = InteractiveNodeType & { + type: 'loopInteractive'; + params: { + loopResult: any[]; + childrenResponse: WorkflowInteractiveResponseType; + currentIndex: number; + }; +}; + +export type UserSelectOptionItemType = { + key: string; + value: string; +}; + +export type UserSelectOptionsValueType = UserSelectOptionItemType[] | [string, string][]; +type UserSelectInteractive = InteractiveNodeType & { + type: 'userSelect'; + params: { + description: string; + userSelectOptions: UserSelectOptionItemType[]; + userSelectedVal?: string; + }; +}; + +export type UserInputFormItemType = { + type: FlowNodeInputTypeEnum; + key: string; + label: string; + value: any; + valueType: WorkflowIOValueTypeEnum; + description?: string; + defaultValue?: any; + required: boolean; + + // input & textarea + maxLength?: number; + + // password + minLength?: number; + + // numberInput + max?: number; + min?: number; + // select & multipleSelect + list?: { label: string; value: string }[]; + listInputType?: FlowNodeInputTypeEnum.reference | FlowNodeInputTypeEnum.custom; + listReference?: [string, string] | [string, string][]; + + // File + canLocalUpload?: boolean; + canUrlUpload?: boolean; +} & AppFileSelectConfigType; +type UserInputInteractive = InteractiveNodeType & { + type: 'userInput'; + params: { + description: string; + inputForm: UserInputFormItemType[]; + submitted?: boolean; + }; +}; + +// 欠费暂停交互 +export type PaymentPauseInteractive = InteractiveNodeType & { + type: 'paymentPause'; + params: { + description?: string; + continue?: boolean; + }; +}; + +export type InteractiveNodeResponseType = + | UserSelectInteractive + | UserInputInteractive + | ChildrenInteractive + | ToolCallChildrenInteractive + | LoopInteractive + | PaymentPauseInteractive; + +export type WorkflowInteractiveResponseType = InteractiveBasicType & InteractiveNodeResponseType; diff --git a/packages/global/core/workflow/template/system/interactive/type.ts b/packages/global/core/workflow/template/system/interactive/type.ts index d0b43cf9bb94..5eddcebf7522 100644 --- a/packages/global/core/workflow/template/system/interactive/type.ts +++ b/packages/global/core/workflow/template/system/interactive/type.ts @@ -116,7 +116,11 @@ export const UserInputFormItemSchema = AppFileSelectConfigTypeSchema.extend({ minLength: z.number().optional(), // password max: z.number().optional(), // numberInput min: z.number().optional(), // numberInput - list: z.array(z.object({ label: z.string(), value: z.string() })).optional() // select + list: z.array(z.object({ label: z.string(), value: z.string() })).optional(), // select + listInputType: z.enum([FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.custom]).optional(), // select list input type + listReference: z + .union([z.tuple([z.string(), z.string()]), z.array(z.tuple([z.string(), z.string()]))]) + .optional() // reference value }); export type UserInputFormItemType = z.infer; export const UserInputInteractiveSchema = z.object({ diff --git a/packages/global/core/workflow/template/system/interactive/userSelect.ts b/packages/global/core/workflow/template/system/interactive/userSelect.ts index ab7efeeb841d..3f8a038d06f8 100644 --- a/packages/global/core/workflow/template/system/interactive/userSelect.ts +++ b/packages/global/core/workflow/template/system/interactive/userSelect.ts @@ -1,4 +1,6 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; +import { LangEnum } from '../../../../../common/i18n/type'; +import { getLang } from '../../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, @@ -12,6 +14,16 @@ import { } from '../../../node/constant'; import { type FlowNodeTemplateType } from '../../../type/node'; +const getUserSelectDiagram = () => { + const currentLang = getLang(); + + if (currentLang === LangEnum.en) { + return '/imgs/app/userSelectEn.svg'; + } + + return '/imgs/app/userSelect.svg'; +}; + export const UserSelectNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.userSelect, templateType: FlowNodeTemplateTypeEnum.interactive, @@ -21,7 +33,7 @@ export const UserSelectNode: FlowNodeTemplateType = { avatar: 'core/workflow/template/userSelect', avatarLinear: 'core/workflow/template/userSelectLinear', colorSchema: 'green', - diagram: '/imgs/app/userSelect.svg', + diagram: getUserSelectDiagram(), name: i18nT('app:workflow.user_select'), intro: i18nT(`app:workflow.user_select_tip`), isTool: true, @@ -37,9 +49,10 @@ export const UserSelectNode: FlowNodeTemplateType = { }, { key: NodeInputKeyEnum.userSelectOptions, - renderTypeList: [FlowNodeInputTypeEnum.custom], - valueType: WorkflowIOValueTypeEnum.any, - label: '', + renderTypeList: [FlowNodeInputTypeEnum.custom, FlowNodeInputTypeEnum.reference], + selectedTypeIndex: 0, + valueType: WorkflowIOValueTypeEnum.arrayString, + label: i18nT('app:workflow.select_options_mode_label'), value: [ { value: 'Confirm', diff --git a/packages/global/core/workflow/template/system/laf.ts b/packages/global/core/workflow/template/system/laf.ts index 4cd3a577d470..e98e87cdf1b9 100644 --- a/packages/global/core/workflow/template/system/laf.ts +++ b/packages/global/core/workflow/template/system/laf.ts @@ -12,7 +12,7 @@ import { } from '../../constants'; import { Input_Template_DynamicInput } from '../input'; import { Output_Template_AddOutput, Output_Template_Error_Message } from '../output'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const nodeLafCustomInputConfig = { selectValueTypeList: Object.values(WorkflowIOValueTypeEnum), diff --git a/packages/global/core/workflow/template/system/loop/loop.ts b/packages/global/core/workflow/template/system/loop/loop.ts index f4f23fa4609e..5a5fe96fd64c 100644 --- a/packages/global/core/workflow/template/system/loop/loop.ts +++ b/packages/global/core/workflow/template/system/loop/loop.ts @@ -10,7 +10,7 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { Input_Template_Children_Node_List, Input_Template_NESTED_NODE_OFFSET, diff --git a/packages/global/core/workflow/template/system/loop/loopEnd.ts b/packages/global/core/workflow/template/system/loop/loopEnd.ts index 6fdb27d2a2db..1532be89be80 100644 --- a/packages/global/core/workflow/template/system/loop/loopEnd.ts +++ b/packages/global/core/workflow/template/system/loop/loopEnd.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, diff --git a/packages/global/core/workflow/template/system/loop/loopStart.ts b/packages/global/core/workflow/template/system/loop/loopStart.ts index dccebe313ac4..87c7db34ef15 100644 --- a/packages/global/core/workflow/template/system/loop/loopStart.ts +++ b/packages/global/core/workflow/template/system/loop/loopStart.ts @@ -10,7 +10,7 @@ import { NodeOutputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; export const LoopStartNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.nestedStart, diff --git a/packages/global/core/workflow/template/system/pluginConfig.ts b/packages/global/core/workflow/template/system/pluginConfig.ts index e5b12aa442fc..d57f728183fd 100644 --- a/packages/global/core/workflow/template/system/pluginConfig.ts +++ b/packages/global/core/workflow/template/system/pluginConfig.ts @@ -1,7 +1,7 @@ import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const PluginConfigNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.pluginConfig, diff --git a/packages/global/core/workflow/template/system/pluginInput.ts b/packages/global/core/workflow/template/system/pluginInput.ts index 561cde832a2c..348f3e2f21e6 100644 --- a/packages/global/core/workflow/template/system/pluginInput.ts +++ b/packages/global/core/workflow/template/system/pluginInput.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum } from '../../constants'; import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; diff --git a/packages/global/core/workflow/template/system/pluginOutput.ts b/packages/global/core/workflow/template/system/pluginOutput.ts index 024df9914c8f..49debcadf44e 100644 --- a/packages/global/core/workflow/template/system/pluginOutput.ts +++ b/packages/global/core/workflow/template/system/pluginOutput.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum } from '../../constants'; import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; diff --git a/packages/global/core/workflow/template/system/queryExtension.ts b/packages/global/core/workflow/template/system/queryExtension.ts index aa75249ab5e1..27f82943a5f1 100644 --- a/packages/global/core/workflow/template/system/queryExtension.ts +++ b/packages/global/core/workflow/template/system/queryExtension.ts @@ -15,7 +15,7 @@ import { Input_Template_UserChatInput, Input_Template_SelectAIModel } from '../input'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const AiQueryExtension: FlowNodeTemplateType = { id: FlowNodeTypeEnum.queryExtension, diff --git a/packages/global/core/workflow/template/system/readFiles/index.tsx b/packages/global/core/workflow/template/system/readFiles/index.tsx index c7f22be8cdcb..d0e460e95fe0 100644 --- a/packages/global/core/workflow/template/system/readFiles/index.tsx +++ b/packages/global/core/workflow/template/system/readFiles/index.tsx @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; import { FlowNodeTemplateTypeEnum, NodeInputKeyEnum, diff --git a/packages/global/core/workflow/template/system/sandbox/index.ts b/packages/global/core/workflow/template/system/sandbox/index.ts index 424c5f98645a..b2e1261a67eb 100644 --- a/packages/global/core/workflow/template/system/sandbox/index.ts +++ b/packages/global/core/workflow/template/system/sandbox/index.ts @@ -13,7 +13,7 @@ import { type FlowNodeTemplateType } from '../../../type/node'; import { Input_Template_DynamicInput } from '../../input'; import { Output_Template_AddOutput } from '../../output'; import { JS_TEMPLATE } from './constants'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; export const CodeNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.code, diff --git a/packages/global/core/workflow/template/system/stopTool.ts b/packages/global/core/workflow/template/system/stopTool.ts index ecdffd26b85d..26cda0f8bdd9 100644 --- a/packages/global/core/workflow/template/system/stopTool.ts +++ b/packages/global/core/workflow/template/system/stopTool.ts @@ -1,7 +1,7 @@ import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const StopToolNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.stopTool, diff --git a/packages/global/core/workflow/template/system/systemConfig.ts b/packages/global/core/workflow/template/system/systemConfig.ts index 0bb7115e4add..7e432e4362d0 100644 --- a/packages/global/core/workflow/template/system/systemConfig.ts +++ b/packages/global/core/workflow/template/system/systemConfig.ts @@ -1,7 +1,7 @@ import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const SystemConfigNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.systemConfig, diff --git a/packages/global/core/workflow/template/system/textEditor.ts b/packages/global/core/workflow/template/system/textEditor.ts index 8c9a95a73c1c..da3247994f75 100644 --- a/packages/global/core/workflow/template/system/textEditor.ts +++ b/packages/global/core/workflow/template/system/textEditor.ts @@ -10,7 +10,7 @@ import { NodeInputKeyEnum, FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const TextEditorNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.textEditor, diff --git a/packages/global/core/workflow/template/system/toolCall.ts b/packages/global/core/workflow/template/system/toolCall.ts index 5729d033fcec..1399226587a0 100644 --- a/packages/global/core/workflow/template/system/toolCall.ts +++ b/packages/global/core/workflow/template/system/toolCall.ts @@ -17,7 +17,7 @@ import { Input_Template_UserChatInput } from '../input'; import { chatNodeSystemPromptTip, systemPromptTip } from '../tip'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import { Input_Template_File_Link } from '../input'; import { Output_Template_Error_Message } from '../output'; diff --git a/packages/global/core/workflow/template/system/toolParams.ts b/packages/global/core/workflow/template/system/toolParams.ts index 87bdda0baec7..d1b2c750f241 100644 --- a/packages/global/core/workflow/template/system/toolParams.ts +++ b/packages/global/core/workflow/template/system/toolParams.ts @@ -1,7 +1,7 @@ import { FlowNodeTypeEnum } from '../../node/constant'; import { type FlowNodeTemplateType } from '../../type/node'; import { FlowNodeTemplateTypeEnum } from '../../constants'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export const ToolParamsNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.toolParams, diff --git a/packages/global/core/workflow/template/system/variableUpdate/index.tsx b/packages/global/core/workflow/template/system/variableUpdate/index.tsx index 161bfa40b8f6..44d94f1344c1 100644 --- a/packages/global/core/workflow/template/system/variableUpdate/index.tsx +++ b/packages/global/core/workflow/template/system/variableUpdate/index.tsx @@ -5,7 +5,7 @@ import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '../../../constants'; -import { i18nT } from '../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../common/i18n/utils'; export const VariableUpdateNode: FlowNodeTemplateType = { id: FlowNodeTypeEnum.variableUpdate, diff --git a/packages/global/core/workflow/template/system/workflowStart.ts b/packages/global/core/workflow/template/system/workflowStart.ts index dc00a7151b73..71e1fcc80d32 100644 --- a/packages/global/core/workflow/template/system/workflowStart.ts +++ b/packages/global/core/workflow/template/system/workflowStart.ts @@ -6,7 +6,7 @@ import { FlowNodeTemplateTypeEnum } from '../../constants'; import { Input_Template_UserChatInput } from '../input'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; import { type FlowNodeOutputItemType } from '../../type/io'; export const userFilesInput: FlowNodeOutputItemType = { diff --git a/packages/global/core/workflow/template/tip.ts b/packages/global/core/workflow/template/tip.ts index b4b88041ac6f..872b0ff84705 100644 --- a/packages/global/core/workflow/template/tip.ts +++ b/packages/global/core/workflow/template/tip.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export const chatNodeSystemPromptTip = i18nT('common:core.app.tip.chatNodeSystemPromptTip'); export const systemPromptTip = i18nT('common:core.app.tip.systemPromptTip'); diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 66e3dee2a069..83047fd61ff2 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -2,6 +2,7 @@ import { LLMModelItemSchema } from '../../ai/model.schema'; import { WorkflowIOValueTypeEnum, NodeInputKeyEnum, NodeOutputKeyEnum } from '../constants'; import { FlowNodeInputTypeEnum, FlowNodeOutputTypeEnum } from '../node/constant'; import { SecretValueTypeSchema } from '../../../common/secret/type'; +import { DatasetTypeEnum } from '../../dataset/constants'; import z from 'zod'; /* Dataset node */ @@ -9,9 +10,14 @@ export const SelectedDatasetSchema = z.object({ datasetId: z.string(), avatar: z.string(), name: z.string(), - vectorModel: z.object({ - model: z.string() - }) + vectorModel: z + .object({ + id: z.string(), + name: z.string().optional() + }) + .optional(), + dataCount: z.number().optional(), + datasetType: z.enum(DatasetTypeEnum).optional() }); export type SelectedDatasetType = z.infer; @@ -45,6 +51,8 @@ export const InputComponentPropsTypeSchema = z.object({ min: z.number().optional(), // slider, number input precision: z.number().optional(), // number input + llmModelType: z.string().optional(), // LLM model type filter + canSelectFile: z.boolean().optional(), // file select canSelectImg: z.boolean().optional(), // file select canSelectVideo: z.boolean().optional(), // file select @@ -104,8 +112,10 @@ export const FlowNodeInputItemTypeSchema = InputComponentPropsTypeSchema.extend( canEdit: z.boolean().optional(), // dynamic inputs isPro: z.boolean().optional(), // Pro version field isToolOutput: z.boolean().optional(), + followLabel: z.boolean().optional(), // switch follows label tightly (no space-between) - deprecated: z.boolean().optional() // node deprecated + deprecated: z.boolean().optional(), // node deprecated + dividerBefore: z.boolean().optional() // render a divider above this input }); export type FlowNodeInputItemType = z.infer; @@ -129,7 +139,7 @@ export const FlowNodeOutputItemTypeSchema = z.object({ input: z.tuple([ z.object({ inputs: z.array(FlowNodeInputItemTypeSchema), - llmModelMap: z.record(z.string(), LLMModelItemSchema) + llmModelIdMap: z.record(z.string(), LLMModelItemSchema) }) ]), output: z.boolean() diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 6e3685376973..8ac096f3662d 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -28,7 +28,8 @@ import type { AppChatConfigType, AppAutoExecuteConfigType, AppQGConfigType, - AppSchemaType + AppSchema, + EntryPointItemType } from '../app/type'; import { type EditorVariablePickerType } from '../../../web/components/common/Textarea/PromptEditor/type'; import { @@ -45,12 +46,44 @@ import { Input_Template_Stream_MODE, Input_Template_UserChatInput } from './template/input'; -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; import { type RuntimeUserPromptType, type UserChatItemType } from '../../core/chat/type'; import { getNanoid } from '../../common/string/tools'; import { ChatRoleEnum } from '../../core/chat/constants'; import { runtimePrompt2ChatsValue } from '../../core/chat/adapt'; +const workflowModelInputKeySet = new Set([ + NodeInputKeyEnum.aiModelId, + NodeInputKeyEnum.datasetSearchEmbeddingModelId, + NodeInputKeyEnum.datasetSearchRerankModelId, + NodeInputKeyEnum.datasetSearchExtensionModelId, + NodeInputKeyEnum.generateSqlModelId, + NodeInputKeyEnum.datasetDeepSearchModelId, + NodeInputKeyEnum.datasetAgenticSearchLLMModelId, + NodeInputKeyEnum.datasetAgenticSearchRerankModelId +]); + +const datasetParamsModelKeySet = new Set([ + NodeInputKeyEnum.datasetSearchEmbeddingModelId, + NodeInputKeyEnum.datasetSearchRerankModelId, + NodeInputKeyEnum.datasetSearchExtensionModelId, + NodeInputKeyEnum.generateSqlModelId, + NodeInputKeyEnum.datasetDeepSearchModelId, + NodeInputKeyEnum.datasetAgenticSearchLLMModelId, + NodeInputKeyEnum.datasetAgenticSearchRerankModelId +]); + +const appChatConfigModelPaths = [ + ['questionGuide', 'modelId'], + ['ttsConfig', 'modelId'] +] as const; + +const addModelId = (set: Set, value: unknown) => { + if (typeof value === 'string' && value) { + set.add(value); + } +}; + export const getHandleId = ( nodeId: string, type: 'source' | 'source_catch' | 'target', @@ -113,6 +146,9 @@ export const splitGuideModule = (guideModules?: StoreNodeItemType) => { guideModules?.inputs?.find((item) => item.key === NodeInputKeyEnum.autoExecute)?.value ?? defaultAutoExecuteConfig; + const entryPoints: EntryPointItemType[] = + guideModules?.inputs?.find((item) => item.key === NodeInputKeyEnum.entryPoints)?.value ?? []; + return { welcomeText, variables, @@ -122,7 +158,8 @@ export const splitGuideModule = (guideModules?: StoreNodeItemType) => { scheduledTriggerConfig, chatInputGuide, instruction, - autoExecute + autoExecute, + entryPoints }; }; @@ -149,7 +186,8 @@ export const getAppChatConfig = ({ scheduledTriggerConfig, chatInputGuide, instruction, - autoExecute + autoExecute, + entryPoints } = splitGuideModule(systemConfigNode); const config: AppChatConfigType = { @@ -160,6 +198,7 @@ export const getAppChatConfig = ({ chatInputGuide, instruction, autoExecute, + entryPoints, ...chatConfig, variables: storeVariables ?? chatConfig?.variables ?? variables, welcomeText: storeWelcomeText ?? chatConfig?.welcomeText ?? welcomeText @@ -463,27 +502,85 @@ export const clientGetWorkflowToolRunUserQuery = ({ }; }; +export const extractWorkflowModelIds = ({ + modules, + chatConfig +}: { + modules?: AppSchema['modules']; + chatConfig?: AppChatConfigType; +}) => { + const modelIds = new Set(); + + modules?.forEach((module) => { + module.inputs.forEach((input) => { + if (checkInputIsReference(input) || Array.isArray(input.value)) return; + + if (workflowModelInputKeySet.has(input.key)) { + addModelId(modelIds, input.value); + } + + if ( + input.key === NodeInputKeyEnum.datasetParams && + input.value && + typeof input.value === 'object' + ) { + datasetParamsModelKeySet.forEach((key) => { + addModelId(modelIds, (input.value as Record)[key]); + }); + } + }); + }); + + appChatConfigModelPaths.forEach(([configKey, modelKey]) => { + addModelId(modelIds, chatConfig?.[configKey]?.[modelKey]); + }); + + return Array.from(modelIds); +}; + export const removeUnauthModels = async ({ modules, + chatConfig, allowedModels = new Set() }: { - modules: AppSchemaType['modules']; + modules: AppSchema['modules']; + chatConfig?: AppChatConfigType; allowedModels?: Set; }) => { if (modules) { modules.forEach((module) => { module.inputs.forEach((input) => { - if (input.key === 'model') { - // 如果是引用类型(selectedTypeIndex 不为 0 或 value 是数组),跳过检查 - if (input.selectedTypeIndex !== 0 || Array.isArray(input.value)) { - return; - } - if (!allowedModels.has(input.value)) { - input.value = undefined; - } + if (checkInputIsReference(input) || Array.isArray(input.value)) { + return; + } + + if (workflowModelInputKeySet.has(input.key) && !allowedModels.has(input.value)) { + input.value = undefined; + } + + if ( + input.key === NodeInputKeyEnum.datasetParams && + input.value && + typeof input.value === 'object' + ) { + datasetParamsModelKeySet.forEach((key) => { + if (!allowedModels.has((input.value as Record)[key])) { + delete (input.value as Record)[key]; + } + }); } }); }); } + + if (chatConfig) { + appChatConfigModelPaths.forEach(([configKey, modelKey]) => { + const modelId = (chatConfig as Record)?.[configKey]?.[modelKey]; + if (modelId && !allowedModels.has(modelId)) { + delete (chatConfig as Record)[configKey]; + } + }); + } + return modules; }; diff --git a/packages/global/openapi/admin/support/user/login/api.ts b/packages/global/openapi/admin/support/user/login/api.ts index 387d9c5e7140..0b370db665b7 100644 --- a/packages/global/openapi/admin/support/user/login/api.ts +++ b/packages/global/openapi/admin/support/user/login/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; // Admin login request body export const AdminLoginBodySchema = z.object({ diff --git a/packages/global/openapi/core/agentSkills/api.ts b/packages/global/openapi/core/agentSkills/api.ts index fbc724219db5..31641dcfc7c7 100644 --- a/packages/global/openapi/core/agentSkills/api.ts +++ b/packages/global/openapi/core/agentSkills/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { AgentSkillCategorySchema, AgentSkillListItemSchema, @@ -7,7 +7,6 @@ import { AgentSkillTypeSchema, AgentSkillConfigSchema, ExtractedSkillPackageSchema, - SandboxImageConfigSchema, SandboxProviderStatusSchema, SkillPackageSchema, SkillSandboxEndpointSchema, @@ -39,7 +38,7 @@ export const ListSkillsResponseItemSchema = AgentSkillListItemSchema.omit({ type: AgentSkillTypeSchema, createTime: z.string(), updateTime: z.string(), - permission: z.number().optional(), + permission: z.any().optional(), sourceMember: z .object({ name: z.string(), @@ -60,7 +59,7 @@ export const CreateSkillBodySchema = z.object({ name: z.string().describe('技能名称'), description: z.string().optional().describe('技能描述'), requirements: z.string().optional().describe('用于 AI 生成技能的需求描述'), - model: z.string().optional().describe('生成技能时使用的模型'), + modelId: z.string().optional().describe('生成技能时使用的模型'), category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'), config: AgentSkillConfigSchema.optional().describe('技能配置'), avatar: z.string().optional().describe('技能头像') @@ -143,23 +142,6 @@ export type ImportSkillBody = z.infer; export const ImportSkillResponseSchema = IdSchema; export type ImportSkillResponse = z.infer; -export const CreateEditDebugSandboxBodySchema = z.object({ - skillId: IdSchema, - image: SandboxImageConfigSchema.optional() -}); -export type CreateEditDebugSandboxBody = z.infer; - -export const CreateEditDebugSandboxResponseSchema = z.object({ - sandboxId: z.string(), - providerSandboxId: z.string(), - endpoint: SkillSandboxEndpointSchema, - status: SandboxProviderStatusSchema.pick({ - state: true, - message: true - }) -}); -export type CreateEditDebugSandboxResponse = z.infer; - export const GetSandboxInfoQuerySchema = z.object({ sandboxId: IdSchema }); @@ -168,7 +150,6 @@ export type GetSandboxInfoQuery = z.infer; export const GetSandboxInfoResponseSchema = z.object({ sandboxId: z.string(), skillId: z.string(), - sandboxType: z.string(), providerSandboxId: z.string(), endpoint: SkillSandboxEndpointSchema.optional(), status: SandboxProviderStatusSchema.pick({ @@ -190,7 +171,15 @@ export type DeleteSandboxResponse = z.infer; export const SaveDeploySkillBodySchema = z.object({ skillId: IdSchema, versionName: z.string().optional(), - description: z.string().optional() + files: z + .array( + z.object({ + path: z.string().describe('包内相对路径'), + content: z.string().describe('UTF-8 文本内容') + }) + ) + .default([]) + .describe('编辑器当前所有 dirty 文件,在新版本中覆盖写入') }); export type SaveDeploySkillBody = z.infer; diff --git a/packages/global/openapi/core/agentSkills/index.ts b/packages/global/openapi/core/agentSkills/index.ts index b87ffc706335..3cfc90b92b81 100644 --- a/packages/global/openapi/core/agentSkills/index.ts +++ b/packages/global/openapi/core/agentSkills/index.ts @@ -2,8 +2,6 @@ import type { OpenAPIPath } from '../../type'; import { TagsMap } from '../../tag'; import { AppsBySkillIdItemSchema, - CreateEditDebugSandboxBodySchema, - CreateEditDebugSandboxResponseSchema, CreateSkillBodySchema, CreateSkillFolderBodySchema, CreateSkillFolderResponseSchema, @@ -248,34 +246,10 @@ export const AgentSkillsPath: OpenAPIPath = { } } }, - '/core/agentSkills/edit': { - post: { - summary: '创建编辑调试沙盒', - description: '为技能创建 edit-debug 沙盒,返回 SSE sandboxStatus 事件流', - tags: [TagsMap.aiSkill], - requestBody: { - content: { - 'application/json': { - schema: CreateEditDebugSandboxBodySchema - } - } - }, - responses: { - 200: { - description: '返回 text/event-stream 事件流', - content: { - 'text/event-stream': { - schema: CreateEditDebugSandboxResponseSchema - } - } - } - } - } - }, '/core/agentSkills/save-deploy': { post: { summary: '保存并发布技能', - description: '从 edit-debug 沙盒打包当前技能并创建新版本', + description: '从沙箱打包当前技能并创建新版本', tags: [TagsMap.aiSkill], requestBody: { content: { @@ -299,7 +273,7 @@ export const AgentSkillsPath: OpenAPIPath = { '/core/agentSkills/debugChat': { post: { summary: '技能调试对话', - description: '基于 edit-debug 沙盒发起技能调试对话,返回 SSE 流', + description: '发起技能调试对话,返回 SSE 流', tags: [TagsMap.aiSkill], requestBody: { content: { diff --git a/packages/global/openapi/core/agentSkills/package/api.ts b/packages/global/openapi/core/agentSkills/package/api.ts new file mode 100644 index 000000000000..dc9c7e15e5c3 --- /dev/null +++ b/packages/global/openapi/core/agentSkills/package/api.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +/** + * AgentSkill package editor — zod schemas for the /api/core/agentSkills/package/* surface. + * Operations: list, read, write, batchWrite, delete, rename, mkdir, upload. + */ + +const SkillIdSchema = z.string().min(1).meta({ description: 'Skill ID' }); +const RelativePathSchema = z + .string() + .min(1) + .meta({ description: 'Path relative to zip root; no absolute, `..`, or backslash' }); + +export const SkillPackageBaseSchema = z.object({ + skillId: SkillIdSchema +}); + +export const PackageFileItemSchema = z.object({ + name: z.string().describe('File or directory name'), + path: z.string().describe('Full path (relative to zip root)'), + type: z.enum(['file', 'directory']).describe('Entry type'), + size: z.number().optional().describe('File size in bytes'), + children: z.array(z.any()).optional().describe('Nested children (present when recursive=true)') +}); +export type PackageFileItem = { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + children?: PackageFileItem[]; +}; + +// ============ list ============ + +export const ListPackageFilesBodySchema = SkillPackageBaseSchema.extend({ + path: z.string().default('.').describe('Directory path, defaults to root'), + recursive: z + .boolean() + .default(false) + .describe('Recursively list all nested files and directories') +}); +export type ListPackageFilesBody = z.input; + +export const ListPackageFilesResponseSchema = z.object({ + files: z.array(PackageFileItemSchema) +}); +export type ListPackageFilesResponse = z.infer; + +// ============ read ============ + +export const ReadPackageFileBodySchema = SkillPackageBaseSchema.extend({ + path: RelativePathSchema +}); +export type ReadPackageFileBody = z.infer; + +export const ReadPackageFileResponseSchema = z + .string() + .meta({ format: 'binary', description: 'File content stream' }); + +// ============ write (text) ============ + +export const WritePackageFileBodySchema = SkillPackageBaseSchema.extend({ + path: RelativePathSchema, + content: z.string().describe('UTF-8 text content') +}); +export type WritePackageFileBody = z.infer; + +// ============ batch write ============ + +export const BatchWritePackageFilesBodySchema = SkillPackageBaseSchema.extend({ + files: z + .array( + z.object({ + path: RelativePathSchema, + content: z.string().describe('UTF-8 text content') + }) + ) + .min(1) +}); +export type BatchWritePackageFilesBody = z.infer; + +// ============ delete ============ + +export const DeletePackageEntryBodySchema = SkillPackageBaseSchema.extend({ + path: RelativePathSchema, + recursive: z.boolean().default(false).describe('Must be true to delete a directory') +}); +export type DeletePackageEntryBody = z.infer; + +// ============ rename ============ + +export const RenamePackageEntryBodySchema = SkillPackageBaseSchema.extend({ + fromPath: RelativePathSchema, + toPath: RelativePathSchema +}); +export type RenamePackageEntryBody = z.infer; + +// ============ mkdir ============ + +export const MkdirPackageBodySchema = SkillPackageBaseSchema.extend({ + path: RelativePathSchema +}); +export type MkdirPackageBody = z.infer; + +// ============ upload (multipart) ============ + +export const UploadPackageFileMultipartRequestSchema = z.object({ + skillId: z.string().describe('Skill ID'), + path: z.string().describe('Target path (full zip-internal file path)'), + file: z.string().meta({ format: 'binary' }).describe('Uploaded file content') +}); + +// ============ common mutate response ============ + +export const MutatePackageResponseSchema = z.object({ + success: z.literal(true), + size: z.number().describe('New zip byte count after mutation'), + packageVersion: z.number().describe('Monotonically increasing version counter') +}); +export type MutatePackageResponse = z.infer; + +// ============ sandbox sync ============ + +export const SyncSkillSandboxBodySchema = SkillPackageBaseSchema; +export type SyncSkillSandboxBody = z.infer; + +export const SyncSkillSandboxResponseSchema = z.object({ + synced: z.boolean().describe('Whether files were actually pushed to the sandbox'), + reason: z + .enum(['noSandbox', 'pushed']) + .describe( + 'noSandbox = no sandbox instance yet (lifecycle creates with latest zip); pushed = synced' + ) +}); +export type SyncSkillSandboxResponse = z.infer; + +// ============ checkVersion ============ + +export const CheckPackageVersionBodySchema = SkillPackageBaseSchema.extend({ + knownVersion: z.number().int().min(0).describe('Client-side known packageVersion') +}); +export type CheckPackageVersionBody = z.infer; + +export const CheckPackageVersionResponseSchema = z.object({ + currentVersion: z.number().describe('Latest packageVersion in DB'), + changed: z.boolean().describe('Whether the version changed since knownVersion') +}); +export type CheckPackageVersionResponse = z.infer; diff --git a/packages/global/openapi/core/agentSkills/package/index.ts b/packages/global/openapi/core/agentSkills/package/index.ts new file mode 100644 index 000000000000..8778d4b6ff57 --- /dev/null +++ b/packages/global/openapi/core/agentSkills/package/index.ts @@ -0,0 +1,162 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { + ListPackageFilesBodySchema, + ListPackageFilesResponseSchema, + ReadPackageFileBodySchema, + ReadPackageFileResponseSchema, + WritePackageFileBodySchema, + BatchWritePackageFilesBodySchema, + DeletePackageEntryBodySchema, + RenamePackageEntryBodySchema, + MkdirPackageBodySchema, + UploadPackageFileMultipartRequestSchema, + MutatePackageResponseSchema, + SyncSkillSandboxBodySchema, + SyncSkillSandboxResponseSchema +} from './api'; + +export const AgentSkillsPackagePath: OpenAPIPath = { + '/core/agentSkills/package/list': { + post: { + summary: 'List skill package directory contents', + description: 'List direct children at the given path inside the current version package.zip', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: ListPackageFilesBodySchema } } + }, + responses: { + 200: { + description: 'Directory listing', + content: { 'application/json': { schema: ListPackageFilesResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/read': { + post: { + summary: 'Read a file from the skill package', + description: 'Read the raw content of a file inside package.zip', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: ReadPackageFileBodySchema } } + }, + responses: { + 200: { content: { '*/*': { schema: ReadPackageFileResponseSchema } } } + } + } + }, + '/core/agentSkills/package/write': { + post: { + summary: 'Write (upsert) a text file in the skill package', + description: 'Write UTF-8 text content to a path inside package.zip; creates if absent', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: WritePackageFileBodySchema } } + }, + responses: { + 200: { + description: 'Write succeeded', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/batchWrite': { + post: { + summary: 'Batch write text files in the skill package', + description: 'Apply multiple text writes in a single zip rewrite', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: BatchWritePackageFilesBodySchema } } + }, + responses: { + 200: { + description: 'Batch write succeeded', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/delete': { + post: { + summary: 'Delete a file or directory from the skill package', + description: 'Delete a file, or recursively delete a directory (recursive=true)', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: DeletePackageEntryBodySchema } } + }, + responses: { + 200: { + description: 'Delete succeeded', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/rename': { + post: { + summary: 'Rename a file or directory in the skill package', + description: 'Rename / move from fromPath to toPath', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: RenamePackageEntryBodySchema } } + }, + responses: { + 200: { + description: 'Rename succeeded', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/mkdir': { + post: { + summary: 'Create a directory in the skill package', + description: 'Create an explicit empty directory entry', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: MkdirPackageBodySchema } } + }, + responses: { + 200: { + description: 'Directory created', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/upload': { + post: { + summary: 'Upload a binary file to the skill package', + description: 'Upload via multipart and write binary content to a path in the zip', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'multipart/form-data': { schema: UploadPackageFileMultipartRequestSchema } } + }, + responses: { + 200: { + description: 'Upload succeeded', + content: { 'application/json': { schema: MutatePackageResponseSchema } } + } + } + } + }, + '/core/agentSkills/package/sandbox/sync': { + post: { + summary: 'Sync skill package to the sandbox', + description: + 'Push the current version package.zip to the running sandbox and unzip in-place. Called before Run Preview so recent editor changes take effect.', + tags: [TagsMap.aiSkill], + requestBody: { + content: { 'application/json': { schema: SyncSkillSandboxBodySchema } } + }, + responses: { + 200: { + description: 'Sync result', + content: { 'application/json': { schema: SyncSkillSandboxResponseSchema } } + } + } + } + } +}; diff --git a/packages/global/openapi/core/ai/agent/api.ts b/packages/global/openapi/core/ai/agent/api.ts index e7aa1fd9a02d..d7dfaddc010f 100644 --- a/packages/global/openapi/core/ai/agent/api.ts +++ b/packages/global/openapi/core/ai/agent/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { ChatMessageSchema } from '../api'; diff --git a/packages/global/openapi/core/ai/index.ts b/packages/global/openapi/core/ai/index.ts index 018a2c58ac70..5fc7ae574d81 100644 --- a/packages/global/openapi/core/ai/index.ts +++ b/packages/global/openapi/core/ai/index.ts @@ -8,12 +8,14 @@ import { } from './api'; import { SandboxPath } from './sandbox'; import { AgentPath } from './agent'; -import { z } from 'zod'; +import { ModelPath } from './model'; +import z from 'zod'; import { getErrorResponse } from '../../type'; export const AIPath: OpenAPIPath = { ...SandboxPath, ...AgentPath, + ...ModelPath, '/core/ai/record/getRecord': { get: { diff --git a/packages/global/openapi/core/ai/model/api.ts b/packages/global/openapi/core/ai/model/api.ts new file mode 100644 index 000000000000..ca14e5f61da8 --- /dev/null +++ b/packages/global/openapi/core/ai/model/api.ts @@ -0,0 +1,396 @@ +import z from 'zod'; +import { ModelTypeEnum } from '../../../../core/ai/constants'; +import type { PaginationResponse } from '../../../api'; +import { + ModelPriceTierSchema, + LLMModelItemSchema, + EmbeddingModelItemSchema, + TTSModelItemSchema, + STTModelItemSchema, + RerankModelItemSchema +} from '../../../../core/ai/model.schema'; + +export const SystemModelItemSchema = z.discriminatedUnion('type', [ + LLMModelItemSchema, + EmbeddingModelItemSchema, + TTSModelItemSchema, + STTModelItemSchema, + RerankModelItemSchema +]); + +/* ============================================================================ + * 公共: 模型列表项 Schema + * ============================================================================ */ + +export const ModelListItemSchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }), + type: z.enum(ModelTypeEnum).meta({ + example: ModelTypeEnum.llm, + description: '模型类型' + }), + name: z.string().meta({ + example: 'GPT-4o', + description: '模型名称' + }), + avatar: z.string().optional().meta({ + example: '/imgs/model/gpt4o.png', + description: '模型头像' + }), + provider: z.string().meta({ + example: 'openai', + description: '模型提供商' + }), + model: z.string().meta({ + example: 'gpt-4o', + description: '模型标识' + }), + testMode: z.boolean().optional().meta({ + description: '是否为测试模式' + }), + charsPointsPrice: z.number().optional().meta({ + description: '按字符计费价格' + }), + inputPrice: z.number().optional().meta({ + description: '输入价格 (1k tokens)' + }), + outputPrice: z.number().optional().meta({ + description: '输出价格 (1k tokens)' + }), + priceTiers: z.array(ModelPriceTierSchema).optional().meta({ + description: '梯度价格配置' + }), + isActive: z.boolean().meta({ + example: true, + description: '是否激活' + }), + isCustom: z.boolean().meta({ + example: false, + description: '是否为自定义模型' + }), + isTuned: z.boolean().meta({ + example: false, + description: '是否为微调模型' + }), + contextToken: z.number().optional().meta({ + example: 128000, + description: '上下文 token 数' + }), + vision: z.boolean().optional().meta({ + example: true, + description: '是否支持视觉' + }), + toolChoice: z.boolean().optional().meta({ + example: true, + description: '是否支持工具调用' + }), + tmbId: z.string().optional().meta({ + example: '68ad85a7463006c963799a05', + description: '团队成员 ID' + }), + isShared: z.boolean().meta({ + example: false, + description: '是否为共享模型' + }), + sourceMember: z + .object({ + name: z.string().meta({ description: '成员名称' }), + avatar: z.string().nullable().optional().meta({ description: '成员头像' }), + status: z.string().meta({ description: '成员状态' }) + }) + .optional() + .meta({ + description: '来源成员信息' + }), + permission: z.any().meta({ + description: '当前用户对该模型的权限' + }) +}); + +export type ModelListItem = z.infer; + +/* ============================================================================ + * API: 获取模型列表 + * Route: POST /api/core/ai/model/list + * Method: POST + * Description: 获取当前团队可见的模型全量列表 + * Tags: ['Model', 'Read'] + * ============================================================================ */ + +export const ListModelsResponseSchema = z.array(ModelListItemSchema); + +export type ListModelsResponse = z.infer; + +/** 分页查询模型列表的响应类型,包含全量启用数量 */ +export type ListModelsPaginationResponse = PaginationResponse & { + activeTotal?: number; +}; + +export const ListModelsBodySchema = z.object({ + provider: z.string().optional().meta({ + description: '按提供商过滤' + }), + type: z.string().optional().meta({ + description: '按模型类型过滤' + }), + search: z.string().optional().meta({ + description: '按名称搜索' + }), + isActive: z.string().optional().meta({ + description: '按激活状态过滤: active | inactive' + }), + pageSize: z.union([z.number(), z.string()]).optional().meta({ + description: '每页条数,不传则返回全量' + }), + pageNum: z.union([z.number(), z.string()]).optional().meta({ + description: '页码' + }), + offset: z.union([z.number(), z.string()]).optional().meta({ + description: '偏移量' + }) +}); + +export type ListModelsBody = z.infer; + +/* ============================================================================ + * API: 创建模型 + * Route: POST /api/core/ai/model/create + * Method: POST + * Description: 创建自定义模型,返回新创建的模型 ID + * Tags: ['Model', 'Write'] + * ============================================================================ */ + +export const CreateModelBodySchema = z.discriminatedUnion('type', [ + LLMModelItemSchema.omit({ id: true }), + EmbeddingModelItemSchema.omit({ id: true }), + TTSModelItemSchema.omit({ id: true }), + STTModelItemSchema.omit({ id: true }), + RerankModelItemSchema.omit({ id: true }) +]); + +export type CreateModelBody = z.infer; + +export const CreateModelResponseSchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '新创建的模型 ID' + }) +}); + +export type CreateModelResponse = z.infer; + +/* ============================================================================ + * API: 更新模型 + * Route: PUT /api/core/ai/model/update + * Method: PUT + * Description: 更新模型配置元数据,支持部分更新 + * Tags: ['Model', 'Write'] + * ============================================================================ */ + +const _AllPartialModelFields = LLMModelItemSchema.omit({ id: true, type: true }) + .partial() + .extend(EmbeddingModelItemSchema.omit({ id: true, type: true }).partial().shape) + .extend(TTSModelItemSchema.omit({ id: true, type: true }).partial().shape) + .extend(STTModelItemSchema.omit({ id: true, type: true }).partial().shape) + .extend(RerankModelItemSchema.omit({ id: true, type: true }).partial().shape); + +export const UpdateModelBodySchema = z + .object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }), + type: z.enum(ModelTypeEnum).optional().meta({ + description: '模型类型' + }) + }) + .extend(_AllPartialModelFields.shape); + +export type UpdateModelBody = z.infer; + +export const UpdateModelResponseSchema = z.object({}); + +export type UpdateModelResponse = z.infer; + +/* ============================================================================ + * API: 删除模型 + * Route: DELETE /api/core/ai/model/delete + * Method: DELETE + * Description: 删除指定的自定义模型(系统模型不可删除) + * Tags: ['Model', 'Delete'] + * ============================================================================ */ + +export const DeleteModelQuerySchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }) +}); + +export type DeleteModelQuery = z.infer; + +export const DeleteModelResponseSchema = z.object({}); + +export type DeleteModelResponse = z.infer; + +/* ============================================================================ + * API: 获取模型详情 + * Route: GET /api/core/ai/model/detail + * Method: GET + * Description: 根据模型 ID 获取模型详细信息 + * Tags: ['Model', 'Read'] + * ============================================================================ */ + +export const GetModelDetailQuerySchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }) +}); + +export type GetModelDetailQuery = z.infer; + +export const GetModelDetailResponseSchema = SystemModelItemSchema; + +export type GetModelDetailResponse = z.infer; + +/* ============================================================================ + * API: 测试模型 + * Route: GET /api/core/ai/model/test + * Method: GET + * Description: 测试模型连通性,根据模型类型执行对应的测试请求 + * Tags: ['Model', 'Read'] + * ============================================================================ */ + +export const TestModelQuerySchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }), + channelId: z.coerce.number().optional().meta({ + description: '指定渠道 ID 测试' + }) +}); + +export type TestModelQuery = z.infer; + +/* ============================================================================ + * API: 获取默认模型配置 + * Route: GET /api/core/ai/model/getDefaultConfig + * Method: GET + * Description: 获取指定模型的默认配置(管理员接口) + * Tags: ['Model', 'Admin', 'Read'] + * ============================================================================ */ + +export const GetDefaultConfigQuerySchema = z.object({ + id: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID' + }) +}); + +export type GetDefaultConfigQuery = z.infer; + +export const GetDefaultConfigResponseSchema = SystemModelItemSchema; + +export type GetDefaultConfigResponse = z.infer; + +/* ============================================================================ + * API: 更新默认模型 + * Route: PUT /api/core/ai/model/updateDefault + * Method: PUT + * Description: 更新系统默认模型配置(管理员接口),设置各场景的默认模型 + * Tags: ['Model', 'Admin', 'Write'] + * ============================================================================ */ + +export const UpdateDefaultModelBodySchema = z.object({ + llmId: z.string().optional().meta({ + example: '68ad85a7463006c963799a05', + description: '默认 LLM 模型 ID' + }), + embeddingId: z.string().optional().meta({ + example: '68ad85a7463006c963799a06', + description: '默认向量模型 ID' + }), + ttsId: z.string().optional().meta({ + example: '68ad85a7463006c963799a07', + description: '默认 TTS 模型 ID' + }), + sttId: z.string().optional().meta({ + example: '68ad85a7463006c963799a08', + description: '默认 STT 模型 ID' + }), + rerankId: z.string().optional().meta({ + example: '68ad85a7463006c963799a09', + description: '默认 ReRank 模型 ID' + }), + datasetTextLLMId: z.string().optional().meta({ + example: '68ad85a7463006c963799a10', + description: '知识库文本理解默认模型 ID' + }), + datasetImageLLMId: z.string().optional().meta({ + example: '68ad85a7463006c963799a11', + description: '知识库图片理解默认模型 ID' + }), + evaluationId: z.string().optional().meta({ + example: '68ad85a7463006c963799a12', + description: '评估默认模型 ID' + }) +}); + +export type UpdateDefaultModelBody = z.infer; + +export const UpdateDefaultModelResponseSchema = z.object({}); + +export type UpdateDefaultModelResponse = z.infer; + +/* ============================================================================ + * API: 通过 JSON 批量更新模型 + * Route: PUT /api/core/ai/model/updateWithJson + * Method: PUT + * Description: 通过 JSON 配置批量导入/更新模型(管理员接口),会清空现有模型并全量替换 + * Tags: ['Model', 'Admin', 'Write'] + * ============================================================================ */ + +export const SystemModelConfigJsonItemSchema = z + .object({ + id: z.string().optional().meta({ + example: '68ad85a7463006c963799a05', + description: '模型 ID,不传则自动生成' + }), + type: z.enum(ModelTypeEnum).optional().meta({ + description: '模型类型' + }) + }) + .extend(_AllPartialModelFields.shape); + +export type SystemModelConfigJsonItem = z.infer; + +export const UpdateWithJsonBodySchema = z.object({ + config: z.string().meta({ + example: '[{"model":"gpt-4o","metadata":{"type":"llm","name":"GPT-4o","provider":"openai"}}]', + description: '模型配置 JSON 字符串' + }) +}); + +export type UpdateWithJsonBody = z.infer; + +export const UpdateWithJsonResponseSchema = z.object({}); + +export type UpdateWithJsonResponse = z.infer; + +/* ============================================================================ + * API: 获取模型配置 JSON + * Route: GET /api/core/ai/model/getConfigJson + * Method: GET + * Description: 导出所有模型配置为 JSON 字符串(管理员接口) + * Tags: ['Model', 'Admin', 'Read'] + * ============================================================================ */ + +export const GetConfigJsonResponseSchema = z.string().meta({ + description: '模型配置 JSON 字符串' +}); + +export type GetConfigJsonResponse = z.infer; diff --git a/packages/global/openapi/core/ai/model/index.ts b/packages/global/openapi/core/ai/model/index.ts new file mode 100644 index 000000000000..9e6dfb83b904 --- /dev/null +++ b/packages/global/openapi/core/ai/model/index.ts @@ -0,0 +1,239 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { + ListModelsResponseSchema, + CreateModelBodySchema, + CreateModelResponseSchema, + UpdateModelBodySchema, + UpdateModelResponseSchema, + DeleteModelQuerySchema, + DeleteModelResponseSchema, + GetModelDetailQuerySchema, + GetModelDetailResponseSchema, + TestModelQuerySchema, + GetDefaultConfigQuerySchema, + GetDefaultConfigResponseSchema, + UpdateDefaultModelBodySchema, + UpdateDefaultModelResponseSchema, + UpdateWithJsonBodySchema, + UpdateWithJsonResponseSchema, + GetConfigJsonResponseSchema +} from './api'; + +export const ModelPath: OpenAPIPath = { + '/core/ai/model/list': { + post: { + summary: '获取模型列表', + description: '获取当前团队可见的模型全量列表', + tags: [TagsMap.modelManage], + responses: { + 200: { + description: '成功返回模型列表', + content: { + 'application/json': { + schema: ListModelsResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/create': { + post: { + summary: '创建模型', + description: '创建新的自定义模型,设置模型配置元数据', + tags: [TagsMap.modelManage], + requestBody: { + content: { + 'application/json': { + schema: CreateModelBodySchema + } + } + }, + responses: { + 200: { + description: '成功创建模型', + content: { + 'application/json': { + schema: CreateModelResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/update': { + put: { + summary: '更新模型', + description: '更新指定模型的配置元数据,支持部分更新', + tags: [TagsMap.modelManage], + requestBody: { + content: { + 'application/json': { + schema: UpdateModelBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新模型', + content: { + 'application/json': { + schema: UpdateModelResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/delete': { + delete: { + summary: '删除模型', + description: '删除指定的自定义模型,系统模型不可删除', + tags: [TagsMap.modelManage], + requestParams: { + query: DeleteModelQuerySchema + }, + responses: { + 200: { + description: '成功删除模型', + content: { + 'application/json': { + schema: DeleteModelResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/detail': { + get: { + summary: '获取模型详情', + description: '根据模型 ID 获取模型的完整配置信息', + tags: [TagsMap.modelManage], + requestParams: { + query: GetModelDetailQuerySchema + }, + responses: { + 200: { + description: '成功返回模型详情', + content: { + 'application/json': { + schema: GetModelDetailResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/test': { + get: { + summary: '测试模型', + description: '测试模型连通性,根据模型类型执行对应的测试请求(llm/embedding/tts/stt/rerank)', + tags: [TagsMap.modelManage], + requestParams: { + query: TestModelQuerySchema + }, + responses: { + 200: { + description: '测试成功,返回模型响应结果' + } + } + } + }, + + '/core/ai/model/getDefaultConfig': { + get: { + summary: '获取默认模型配置', + description: '获取指定模型的默认配置(管理员接口)', + tags: [TagsMap.modelManage], + requestParams: { + query: GetDefaultConfigQuerySchema + }, + responses: { + 200: { + description: '成功返回模型默认配置', + content: { + 'application/json': { + schema: GetDefaultConfigResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/updateDefault': { + put: { + summary: '更新默认模型', + description: + '更新系统默认模型配置,设置各场景(LLM/Embedding/TTS/STT/ReRank等)的默认模型(管理员接口)', + tags: [TagsMap.modelManage], + requestBody: { + content: { + 'application/json': { + schema: UpdateDefaultModelBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新默认模型配置', + content: { + 'application/json': { + schema: UpdateDefaultModelResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/updateWithJson': { + put: { + summary: '通过 JSON 批量更新模型', + description: '通过 JSON 配置字符串批量导入/更新模型,会清空现有模型并全量替换(管理员接口)', + tags: [TagsMap.modelManage], + requestBody: { + content: { + 'application/json': { + schema: UpdateWithJsonBodySchema + } + } + }, + responses: { + 200: { + description: '成功批量更新模型配置', + content: { + 'application/json': { + schema: UpdateWithJsonResponseSchema + } + } + } + } + } + }, + + '/core/ai/model/getConfigJson': { + get: { + summary: '获取模型配置 JSON', + description: '导出所有模型配置为 JSON 字符串(管理员接口)', + tags: [TagsMap.modelManage], + responses: { + 200: { + description: '成功返回模型配置 JSON', + content: { + 'application/json': { + schema: GetConfigJsonResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/core/ai/sandbox/api.ts b/packages/global/openapi/core/ai/sandbox/api.ts index 02b369275f2f..da59358485e1 100644 --- a/packages/global/openapi/core/ai/sandbox/api.ts +++ b/packages/global/openapi/core/ai/sandbox/api.ts @@ -11,17 +11,25 @@ const SandboxBaseSchema = z.object({ * 列出目录 - 请求/响应 */ export const SandboxListBodySchema = SandboxBaseSchema.extend({ - path: z.string().default('.').describe('目录路径') + path: z.string().default('.').describe('目录路径'), + recursive: z.boolean().optional().default(false).describe('是否递归列举子目录') }); -export type SandboxListBody = z.infer; +export type SandboxListBody = z.input; export const SandboxFileItemSchema = z.object({ name: z.string().describe('文件名'), path: z.string().describe('完整路径'), type: z.enum(['file', 'directory']).describe('文件类型'), - size: z.number().optional().describe('文件大小(字节数)') + size: z.number().optional().describe('文件大小(字节数)'), + children: z.array(z.any()).optional().describe('子文件列表(recursive=true 时返回)') }); -export type SandboxFileItem = z.infer; +export type SandboxFileItem = { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + children?: SandboxFileItem[]; +}; export const SandboxListResponseSchema = z.object({ files: z.array(SandboxFileItemSchema) diff --git a/packages/global/openapi/core/app/log/api.ts b/packages/global/openapi/core/app/log/api.ts index 16970bdd930f..223e9fa20dfd 100644 --- a/packages/global/openapi/core/app/log/api.ts +++ b/packages/global/openapi/core/app/log/api.ts @@ -1,7 +1,7 @@ import z from 'zod'; import { PaginationSchema } from '../../../api'; import { AppLogKeysEnum, AppLogTimespanEnum } from '../../../../core/app/logs/constants'; -import { ChatSourceEnum } from '../../../../core/chat/constants'; +import { ChatSourceEnum, FeedbackFilterEnum } from '../../../../core/chat/constants'; import { AppLogKeysSchema } from '../../../../core/app/logs/type'; import { SourceMemberSchema } from '../../../../support/user/type'; @@ -65,6 +65,7 @@ export const ChatLogItemSchema = z.object({ userBadFeedbackCount: z.int().nullish().meta({ example: 1, description: '差评反馈数量' }), customFeedbacksCount: z.int().nullish().meta({ example: 2, description: '自定义反馈数量' }), markCount: z.int().nullish().meta({ example: 0, description: '标记数量' }), + correctionCount: z.int().nullish().meta({ example: 0, description: '优化回答数' }), averageResponseTime: z .number() .nullish() @@ -76,7 +77,26 @@ export const ChatLogItemSchema = z.object({ sourceMember: SourceMemberSchema.nullish().meta({ description: '来源成员信息' }), versionName: z.string().nullish().meta({ example: 'v1.0.0', description: '版本名称' }), originIp: z.string().nullish().meta({ example: '192.168.1.1', description: '原始 IP 地址' }), - region: z.string().nullish().meta({ example: '中国', description: '区域' }) + region: z.string().nullish().meta({ example: '中国', description: '区域' }), + + /* Parent app association fields (populated when this chat is triggered by a workflow appModule node) */ + parentChatId: z.string().nullish().meta({ example: 'abc123', description: '父应用的 chatId' }), + parentResponseChatItemId: z + .string() + .nullish() + .meta({ example: 'dataId123', description: '父对话的 responseChatItemId' }), + parentNodeId: z + .string() + .nullish() + .meta({ example: 'nodeA', description: '父应用的 appModule 节点 ID' }), + parentNodeName: z + .string() + .nullish() + .meta({ example: '子应用调用', description: '父应用的 appModule 节点名称' }), + parentAppId: z + .string() + .nullish() + .meta({ example: '68ad85a7463006c963799a05', description: '父应用的 appId' }) }); export type AppLogsListItemType = z.infer; @@ -135,7 +155,14 @@ export const GetAppChatLogsBodySchema = PaginationSchema.extend( chatSearch: z.string().optional().meta({ example: 'hello', description: '对话内容搜索关键词' - }) + }), + feedbackFilter: z + .array(z.enum(FeedbackFilterEnum)) + .optional() + .meta({ + example: [FeedbackFilterEnum.good, FeedbackFilterEnum.bad], + description: '反馈筛选:good-好评,bad-差评,noFeedback-无反馈' + }) }); export type getAppChatLogsBody = z.infer; // Get App Chat Logs Response @@ -216,6 +243,8 @@ export const UserStatsDataPointSchema = z.object({ newUserCount: z.number().meta({ example: 30, description: '新用户数' }), retentionUserCount: z.number().meta({ example: 70, description: '留存用户数' }), points: z.number().meta({ example: 1500, description: '积分消耗' }), + inputTokens: z.number().meta({ example: 50000, description: '输入 Token 消耗' }), + outputTokens: z.number().meta({ example: 20000, description: '输出 Token 消耗' }), sourceCountMap: z.record(z.string(), z.number()).meta({ example: { api: 50, web: 30, mobile: 20 }, description: '各来源用户数量' @@ -231,7 +260,9 @@ export const ChatStatsDataPointSchema = z.object({ chatItemCount: z.number().meta({ example: 500, description: '对话项目总数' }), chatCount: z.number().meta({ example: 100, description: '对话会话总数' }), errorCount: z.number().meta({ example: 5, description: '错误次数' }), - points: z.number().meta({ example: 800, description: '积分消耗' }) + points: z.number().meta({ example: 800, description: '积分消耗' }), + inputTokens: z.number().meta({ example: 30000, description: '输入 Token 消耗' }), + outputTokens: z.number().meta({ example: 10000, description: '输出 Token 消耗' }) }) }); export type chatStatsDataPoint = z.infer; @@ -278,6 +309,14 @@ export const GetTotalDataResponseSchema = z.object({ totalPoints: z.number().meta({ example: 15000, description: '总积分消耗' + }), + totalInputTokens: z.number().meta({ + example: 500000, + description: '总输入 Token 消耗' + }), + totalOutputTokens: z.number().meta({ + example: 200000, + description: '总输出 Token 消耗' }) }); export type getTotalDataResponse = z.infer; diff --git a/packages/global/openapi/core/chat/completion/api.ts b/packages/global/openapi/core/chat/completion/api.ts index a48e07a9d09d..c9db4a23a376 100644 --- a/packages/global/openapi/core/chat/completion/api.ts +++ b/packages/global/openapi/core/chat/completion/api.ts @@ -128,7 +128,9 @@ export const AuthResponseSchema = z.object({ outLinkUserId: z.string().optional().meta({ description: '外部链接用户ID' }), sourceName: z.string().optional().meta({ description: '来源名称' }) }); -export type AuthResponseType = z.infer; +export type AuthResponseType = z.infer & { + userTmbId?: string; +}; /* ====== Chat test ====== */ export const ChatTestPropsSchema = z.object({ diff --git a/packages/global/openapi/core/chat/file/index.ts b/packages/global/openapi/core/chat/file/index.ts index 5314389140a0..7d2979e86a94 100644 --- a/packages/global/openapi/core/chat/file/index.ts +++ b/packages/global/openapi/core/chat/file/index.ts @@ -2,7 +2,7 @@ import type { OpenAPIPath } from '../../../type'; import { TagsMap } from '../../../tag'; import { PresignChatFilePostUrlSchema, PresignChatFileGetUrlSchema } from './api'; import { CreatePostPresignedUrlResponseSchema } from '../../../../common/file/s3/type'; -import { z } from 'zod'; +import z from 'zod'; export const ChatFilePath: OpenAPIPath = { '/core/chat/file/presignChatFilePostUrl': { diff --git a/packages/global/openapi/core/chat/helperBot/api.ts b/packages/global/openapi/core/chat/helperBot/api.ts index ad9917876b25..150bf73af985 100644 --- a/packages/global/openapi/core/chat/helperBot/api.ts +++ b/packages/global/openapi/core/chat/helperBot/api.ts @@ -7,7 +7,7 @@ import { HelperBotTypeEnumSchema } from '../../../../core/chat/helperBot/type'; import { topAgentParamsSchema } from '../../../../core/chat/helperBot/topAgent/type'; -import { z } from 'zod'; +import z from 'zod'; import type { PaginationResponse } from '../../../api'; import { ChatFileTypeEnum } from '../../../../core/chat/constants'; diff --git a/packages/global/openapi/core/chat/inputGuide/api.ts b/packages/global/openapi/core/chat/inputGuide/api.ts index f0611a434657..41750317dfd7 100644 --- a/packages/global/openapi/core/chat/inputGuide/api.ts +++ b/packages/global/openapi/core/chat/inputGuide/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { PaginationSchema } from '../../../api'; import { ObjectIdSchema } from '../../../../common/type/mongo'; import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; diff --git a/packages/global/openapi/core/chat/record/api.ts b/packages/global/openapi/core/chat/record/api.ts index 370dcc317fee..ce32b82236d5 100644 --- a/packages/global/openapi/core/chat/record/api.ts +++ b/packages/global/openapi/core/chat/record/api.ts @@ -110,7 +110,11 @@ export const GetCollectionQuoteResSchema = z.object({ }) ), hasMorePrev: z.boolean().describe('是否还有更多前置数据'), - hasMoreNext: z.boolean().describe('是否还有更多后置数据') + hasMoreNext: z.boolean().describe('是否还有更多后置数据'), + collectionType: z.string().optional().describe('集合类型(DatasetCollectionTypeEnum)'), + datasetType: z.string().optional().describe('所属知识库类型'), + fileName: z.string().optional().describe('文件名'), + apiDatasetBaseUrl: z.string().optional().describe('API 知识库的 baseUrl,仅 API 类型知识库有值') }); export type GetCollectionQuoteResType = z.infer; @@ -157,7 +161,10 @@ export type GetPaginationRecordsResponseType = z.infer; export const GetRecordsV2ResponseSchema = LinkedListResponseSchema(ChatItemMiniSchema).extend({ - total: z.int() + total: z.int(), + goodTotal: z.number().int().nonnegative().optional(), + badTotal: z.number().int().nonnegative().optional(), + notFoundTotal: z.number().int().nonnegative().optional() }); export type GetRecordsV2ResponseType = z.infer; diff --git a/packages/global/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts index fc46cdec1779..29b13bf63b23 100644 --- a/packages/global/openapi/core/dataset/api.ts +++ b/packages/global/openapi/core/dataset/api.ts @@ -1,5 +1,9 @@ -import { z } from 'zod'; -import { DatasetSearchModeEnum, DatasetTypeEnum } from '../../../core/dataset/constants'; +import z from 'zod'; +import { + DatasetSearchModeEnum, + DatasetTypeEnum, + RerankMethodEnum +} from '../../../core/dataset/constants'; import { ApiDatasetServerSchema } from '../../../core/dataset/apiDataset/type'; import { ObjectIdSchema } from '../../../common/type/mongo'; import { ParentIdSchema } from '../../../common/parentFolder/type'; @@ -26,32 +30,56 @@ export const CreateDatasetBodySchema = z.object({ example: DatasetTypeEnum.dataset, description: '知识库类型' }), - name: z.string().meta({ + name: z.string().min(1).max(30).meta({ example: '我的知识库', - description: '知识库名称' + description: '知识库名称,1-30个字符' }), - intro: z.string().meta({ + intro: z.string().optional().meta({ example: '这是一个用于存储产品文档的知识库', description: '知识库简介' }), - avatar: z.string().meta({ + avatar: z.string().optional().meta({ example: '/imgs/dataset/avatar.png', - description: '知识库头像' + description: '知识库头像,不传则使用默认头像' }), - vectorModel: z.string().optional().meta({ - example: 'text-embedding-3-small', - description: '向量模型名称,不传则使用默认向量模型' + vectorModelId: z.string().min(1).optional().meta({ + example: '68ad85a7463006c963799a05', + description: '向量模型Id(平台维护的唯一模型ID),不传则使用默认向量模型' }), - agentModel: z.string().optional().meta({ - example: 'gpt-4o-mini', - description: '知识库 Agent 模型名称,不传则使用默认模型' + agentModelId: z.string().optional().meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 Agent 模型Id(平台维护的唯一模型ID),不传则使用默认模型' }), - vlmModel: z.string().optional().meta({ - example: 'gpt-4o', - description: '视觉语言模型名称' + vlmModelId: z.string().optional().nullable().meta({ + example: '68ad85a7463006c963799a05', + description: '视觉语言模型Id(平台维护的唯一模型ID),传 null 表示不使用VLM' }), apiDatasetServer: ApiDatasetServerSchema.optional().meta({ description: '第三方知识库服务器配置(API/飞书/语雀)' + }), + websiteConfig: z + .object({ + url: z.string().meta({ description: '网站 URL' }), + selector: z.string().meta({ description: '网站选择器,没有特殊指定传空字符串 ""' }) + }) + .optional() + .meta({ + description: '网站知识库配置' + }), + autoSync: z.boolean().optional().meta({ + description: '是否自动同步' + }), + keep_header_footer: z.boolean().optional().meta({ + description: '保留页眉页脚' + }), + keep_appendix: z.boolean().optional().meta({ + description: '保留附录' + }), + image_analysis: z.boolean().optional().meta({ + description: '文档图片识别' + }), + chart_analysis: z.boolean().optional().meta({ + description: '统计图转表格' }) }); @@ -74,29 +102,41 @@ export type CreateDatasetResponse = z.infer; export const CreateDatasetWithFilesBodySchema = z.object({ datasetParams: z .object({ - name: z.string().meta({ + name: z.string().min(1).max(30).meta({ example: '我的知识库', - description: '知识库名称' + description: '知识库名称,1-30个字符' }), - avatar: z.string().meta({ + avatar: z.string().optional().meta({ example: '/imgs/dataset/avatar.png', - description: '知识库头像' + description: '知识库头像,不传则使用默认头像' }), parentId: ParentIdSchema.meta({ example: '68ad85a7463006c963799a05', description: '父级文件夹 ID' }), - vectorModel: z.string().optional().meta({ + vectorModelId: z.string().optional().meta({ example: 'text-embedding-3-small', - description: '向量模型名称,不传则使用默认向量模型' + description: '向量模型Id,不传则使用默认向量模型' }), - agentModel: z.string().optional().meta({ + agentModelId: z.string().optional().meta({ example: 'gpt-4o-mini', - description: 'Agent 模型名称,不传则使用默认模型' + description: 'Agent 模型Id,不传则使用默认模型' }), - vlmModel: z.string().optional().meta({ + vlmModelId: z.string().optional().meta({ example: 'gpt-4o', - description: '视觉语言模型名称' + description: '视觉语言模型Id' + }), + keep_header_footer: z.boolean().optional().meta({ + description: '保留页眉页脚' + }), + keep_appendix: z.boolean().optional().meta({ + description: '保留附录' + }), + image_analysis: z.boolean().optional().meta({ + description: '文档图片识别' + }), + chart_analysis: z.boolean().optional().meta({ + description: '统计图转表格' }) }) .meta({ description: '知识库参数' }), @@ -233,9 +273,9 @@ export const UpdateDatasetBodySchema = z.object({ example: '68ad85a7463006c963799a05', description: '父级文件夹 ID,传 null 表示移动到根目录' }), - name: z.string().optional().meta({ + name: z.string().min(1).max(30).optional().meta({ example: '我的知识库', - description: '知识库名称' + description: '知识库名称,1-30个字符' }), avatar: z.string().optional().meta({ example: '/imgs/dataset/avatar.png', @@ -245,18 +285,22 @@ export const UpdateDatasetBodySchema = z.object({ example: '这是一个用于存储产品文档的知识库', description: '知识库简介' }), - agentModel: z.string().optional().meta({ + agentModelId: z.string().optional().meta({ example: 'gpt-4o-mini', - description: '知识库 Agent 模型名称' + description: '知识库 Agent 模型Id' }), - vlmModel: z.string().optional().meta({ + vectorModelId: z.string().optional().meta({ + example: 'text-embedding-3-small', + description: '向量嵌入模型Id' + }), + vlmModelId: z.string().optional().nullable().meta({ example: 'gpt-4o', - description: '视觉语言模型名称' + description: '视觉语言模型Id,传 null 表示清空VLM配置' }), websiteConfig: z .object({ url: z.string().meta({ description: '网站 URL' }), - selector: z.string().meta({ description: '网站选择器' }) + selector: z.string().meta({ description: '网站选择器,没有特殊指定传空字符串 ""' }) }) .optional() .meta({ @@ -271,8 +315,41 @@ export const UpdateDatasetBodySchema = z.object({ autoSync: z.boolean().optional().meta({ description: '是否自动同步' }), + keep_header_footer: z.boolean().optional().meta({ + description: '保留页眉页脚' + }), + keep_appendix: z.boolean().optional().meta({ + description: '保留附录' + }), + image_analysis: z.boolean().optional().meta({ + description: '文档图片识别' + }), + chart_analysis: z.boolean().optional().meta({ + description: '统计图转表格' + }), chunkSettings: ChunkSettingsSchema.optional().meta({ description: '分块配置' + }), + databaseConfig: z + .object({ + clientType: z.string(), + version: z.string().optional(), + host: z.string(), + port: z.number().optional(), + database: z.string(), + user: z.string(), + password: z.string(), + poolSize: z.number().optional(), + schema: z.string().optional(), + encrypt: z.boolean().optional(), + trustServerCertificate: z.boolean().optional() + }) + .optional() + .meta({ + description: '数据库配置' + }), + inheritParentPermission: z.boolean().optional().meta({ + description: '是否继承新父级权限(移动时使用)' }) }); export type UpdateDatasetBody = z.infer; @@ -300,13 +377,17 @@ export const CreateDatasetFolderBodySchema = z.object({ example: '68ad85a7463006c963799a05', description: '父级文件夹 ID,不传则创建在根目录' }), - name: z.string().meta({ + name: z.string().min(1).max(100).meta({ example: '我的文件夹', - description: '文件夹名称' + description: '文件夹名称,1-100个字符' }), intro: z.string().meta({ example: '存放产品相关知识库', description: '文件夹简介' + }), + avatar: z.string().optional().meta({ + example: '/imgs/dataset/avatar.png', + description: '文件夹头像' }) }); export type CreateDatasetFolderBody = z.infer; @@ -320,9 +401,9 @@ export const SearchDatasetTestBodySchema = z.object({ example: '68ad85a7463006c963799a05', description: '知识库 ID' }), - text: z.string().meta({ + text: z.string().min(1).meta({ example: 'FastGPT 是什么', - description: '搜索文本' + description: '搜索文本,不能为空' }), similarity: z.number().optional().meta({ example: 0.3, @@ -340,11 +421,17 @@ export const SearchDatasetTestBodySchema = z.object({ example: 1, description: '向量搜索权重' }), + embeddingModelId: z.string().optional().meta({ + description: '向量嵌入模型名称,不传则使用知识库默认向量模型' + }), usingReRank: z.boolean().optional().meta({ description: '是否使用重排序' }), - rerankModel: z.string().optional().meta({ - description: '重排序模型名称' + rerankModelId: z.string().optional().meta({ + description: '重排序模型Id' + }), + rerankMethod: z.enum(RerankMethodEnum).optional().meta({ + description: '重排序方法' }), rerankWeight: z.number().optional().meta({ description: '重排序权重' @@ -352,8 +439,8 @@ export const SearchDatasetTestBodySchema = z.object({ datasetSearchUsingExtensionQuery: z.boolean().optional().meta({ description: '是否使用问题扩展' }), - datasetSearchExtensionModel: z.string().optional().meta({ - description: '问题扩展模型' + datasetSearchExtensionModelId: z.string().optional().meta({ + description: '问题扩展模型Id' }), datasetSearchExtensionBg: z.string().optional().meta({ description: '问题扩展背景描述' @@ -361,8 +448,8 @@ export const SearchDatasetTestBodySchema = z.object({ datasetDeepSearch: z.boolean().optional().meta({ description: '是否启用深度搜索' }), - datasetDeepSearchModel: z.string().optional().meta({ - description: '深度搜索模型' + datasetDeepSearchModelId: z.string().optional().meta({ + description: '深度搜索模型Id' }), datasetDeepSearchMaxTimes: z.number().optional().meta({ description: '深度搜索最大轮次' @@ -393,8 +480,8 @@ export const SearchDatasetTestResponseSchema = z.object({ similarity: z.number().meta({ description: '实际使用的相似度阈值' }), - queryExtensionModel: z.string().optional().meta({ - description: '问题扩展使用的模型' + queryExtensionModelId: z.string().optional().meta({ + description: '问题扩展使用的模型Id' }) }); export type SearchDatasetTestResponse = z.infer; @@ -449,3 +536,154 @@ export const PostDatasetSyncBodySchema = z.object({ datasetId: z.string().meta({ description: '数据集 ID' }) }); export type PostDatasetSyncParams = z.infer; + +/* ============================================================================ + * API: 多知识库检索 + * Route: POST /api/core/dataset/custom/searchApi + * ============================================================================ */ + +// 入参 Schema +export const SearchApiBodySchema = z.object({ + datasetIds: z + .array(ObjectIdSchema) + .min(1) + .meta({ + example: ['68ad85a7463006c963799a05', '68ad85a7463006c963799a06'], + description: '知识库 ID 列表,至少传一个' + }), + text: z.string().min(1).meta({ + example: 'FastGPT 是什么', + description: '搜索文本,不能为空' + }), + similarity: z.number().optional().meta({ + example: 0.3, + description: '最低相似度阈值' + }), + limit: z.number().optional().meta({ + example: 5000, + description: '最大返回 token 数' + }), + searchMode: z.enum(DatasetSearchModeEnum).optional().meta({ + example: DatasetSearchModeEnum.mixedRecall, + description: '搜索模式' + }), + embeddingWeight: z.number().optional().meta({ + example: 1, + description: '向量搜索权重' + }), + embeddingModelId: z.string().optional().meta({ + description: '向量嵌入模型名称,不传则使用知识库默认向量模型' + }), + usingReRank: z.boolean().optional().meta({ + description: '是否使用重排序' + }), + rerankModelId: z.string().optional().meta({ + description: '重排序模型Id' + }), + rerankMethod: z.enum(RerankMethodEnum).optional().meta({ + description: '重排序方法' + }), + rerankWeight: z.number().optional().meta({ + description: '重排序权重' + }), + datasetSearchUsingExtensionQuery: z.boolean().optional().meta({ + description: '是否使用问题扩展' + }), + datasetSearchExtensionModelId: z.string().optional().meta({ + description: '问题扩展模型Id' + }), + datasetSearchExtensionBg: z.string().optional().meta({ + description: '问题扩展背景描述' + }), + agenticSearch: z.boolean().optional().meta({ + description: '是否启用 Agentic 检索(多轮智能检索)' + }), + datasetDeepSearchModelId: z.string().optional().meta({ + description: 'Agentic 检索使用的 LLM 模型Id' + }), + datasetDeepSearchMaxTimes: z.number().optional().meta({ + description: 'Agentic 检索最大迭代轮次' + }), + datasetDeepSearchBg: z.string().optional().meta({ + description: 'Agentic 检索背景描述' + }), + collectionFilterMatch: z + .object({ + tags: z + .object({ + $and: z + .array(z.union([z.string(), z.null(), z.record(z.string(), z.any())])) + .optional() + .meta({ + description: + 'AND 条件:集合必须同时拥有所有列出的标签名。字符串=标签名,null=集合无标签,对象={标签名: {操作符: 值}}' + }), + $or: z + .array(z.union([z.string(), z.null(), z.record(z.string(), z.any())])) + .optional() + .meta({ + description: + 'OR 条件:集合至少拥有一个列出的标签名。字符串=标签名,null=集合无标签,对象={标签名: {操作符: 值}}' + }) + }) + .optional() + .meta({ + description: '标签过滤条件。AND 和 OR 可同时使用,AND 先生效。AND 标签和 null 不能共存' + }), + createTime: z + .object({ + $gte: z.string().optional().meta({ description: '开始时间(ISO 格式)' }), + $lte: z.string().optional().meta({ description: '结束时间(ISO 格式)' }) + }) + .optional() + .meta({ description: '按集合创建时间范围过滤' }), + collectionIds: z.array(ObjectIdSchema).optional().meta({ description: '按指定集合 ID 过滤' }) + }) + .optional() + .meta({ + description: + '集合元数据过滤条件。支持 tags / createTime / collectionIds 三维过滤。\n' + + '标签过滤支持新旧两种格式:\n' + + ' 旧格式(仅匹配标签名):["tagA", "tagB"]\n' + + ' 新格式(支持值比较):[{"tagName":{"$gte":10,"$lte":100}}, {"category":{"$eq":"技术文档"}}]\n' + + '标签值操作符:$eq, $ne, $gt, $lt, $gte, $lte(数值/日期),$contains, $notContains, $startsWith, $endsWith, $regex(字符串)' + }) +}); +export type SearchApiBody = z.infer; + +// 出参 Schema +export const SearchApiResponseSchema = z.object({ + list: z.array(SearchDataResponseItemSchema).meta({ + description: '搜索结果列表' + }), + duration: z.string().meta({ + example: '0.523s', + description: '搜索耗时' + }), + limit: z.number().meta({ + description: '实际使用的最大 token 数' + }), + searchMode: z.enum(DatasetSearchModeEnum).meta({ + description: '实际使用的搜索模式' + }), + usingReRank: z.boolean().meta({ + description: '是否使用了重排序' + }), + similarity: z.number().meta({ + description: '实际使用的相似度阈值' + }), + queryExtensionModelId: z.string().optional().meta({ + description: '问题扩展使用的模型Id' + }), + agenticSearchResult: z + .object({ + reasoningText: z.string().meta({ description: '思考过程文本' }), + searchCount: z.number().meta({ description: '实际检索轮次' }), + toolCallCount: z.number().meta({ description: 'Tool 调用总次数' }) + }) + .optional() + .meta({ + description: 'Agentic 检索结果,仅 agenticSearch=true 时返回' + }) +}); +export type SearchApiResponse = z.infer; diff --git a/packages/global/openapi/core/dataset/apiDataset/api.ts b/packages/global/openapi/core/dataset/apiDataset/api.ts index 09e137805c42..3a30b732f527 100644 --- a/packages/global/openapi/core/dataset/apiDataset/api.ts +++ b/packages/global/openapi/core/dataset/apiDataset/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { ObjectIdSchema } from '../../../../common/type/mongo'; import { ParentIdSchema } from '../../../../common/parentFolder/type'; import { diff --git a/packages/global/openapi/core/dataset/collaborator/api.ts b/packages/global/openapi/core/dataset/collaborator/api.ts new file mode 100644 index 000000000000..8da0d16e7f6f --- /dev/null +++ b/packages/global/openapi/core/dataset/collaborator/api.ts @@ -0,0 +1,151 @@ +import z from 'zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; + +/* ============================================================================ + * 公共: 协作者 Schema + * ============================================================================ */ +const CollaboratorItemSchema = z.object({ + tmbId: z.string().optional().meta({ description: '团队成员 ID' }), + groupId: z.string().optional().meta({ description: '用户组 ID' }), + orgId: z.string().optional().meta({ description: '组织 ID' }), + permission: z.number().meta({ description: '权限值(1=使用, 2=编辑, 4=管理, 8=所有者,可组合)' }) +}); + +const CollaboratorItemDetailSchema = CollaboratorItemSchema.extend({ + teamId: z.string().meta({ description: '团队 ID' }), + name: z.string().meta({ description: '名称' }), + avatar: z.string().meta({ description: '头像' }) +}); + +/* ============================================================================ + * API: 获取知识库协作者列表 + * Route: GET /api/core/dataset/collaborator/list + * ============================================================================ */ +export const GetDatasetCollaboratorListQuerySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }) +}); +export type GetDatasetCollaboratorListQuery = z.infer; + +export const CollaboratorListResponseSchema = z.object({ + clbs: z.array(CollaboratorItemDetailSchema).meta({ description: '有效协作者列表' }), + parentClbs: z + .array(CollaboratorItemDetailSchema) + .optional() + .meta({ description: '父级协作者列表(继承状态时存在)' }) +}); +export type CollaboratorListResponse = z.infer; + +/* ============================================================================ + * API: 更新知识库协作者 + * Route: POST /api/core/dataset/collaborator/update + * ============================================================================ */ +export const UpdateDatasetCollaboratorBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }), + collaborators: z.array(CollaboratorItemSchema).min(1).meta({ description: '协作者列表' }), + permissionEffectScope: z + .enum(['allChildren', 'currentOnly']) + .optional() + .meta({ description: '权限生效范围:allChildren=对所有子级生效,currentOnly=仅当前资源' }) +}); +export type UpdateDatasetCollaboratorBody = z.infer; + +/* ============================================================================ + * API: 转移知识库所有权 + * Route: POST /api/core/dataset/changeOwner + * ============================================================================ */ +export const ChangeDatasetOwnerBodySchema = z.object({ + ownerId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '新所有者的团队成员 ID' + }), + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a06', + description: '知识库 ID' + }) +}); +export type ChangeDatasetOwnerBody = z.infer; + +/* ============================================================================ + * API: 获取集合协作者列表 + * Route: GET /api/core/dataset/collection/collaborator/list + * ============================================================================ */ +export const GetCollectionCollaboratorListQuerySchema = z.object({ + collectionId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '集合 ID' + }) +}); +export type GetCollectionCollaboratorListQuery = z.infer< + typeof GetCollectionCollaboratorListQuerySchema +>; + +/* ============================================================================ + * API: 更新集合协作者 + * Route: POST /api/core/dataset/collection/collaborator/update + * ============================================================================ */ +export const UpdateCollectionCollaboratorBodySchema = z.object({ + collectionId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '集合 ID' + }), + collaborators: z.array(CollaboratorItemSchema).min(1).meta({ description: '协作者列表' }), + permissionEffectScope: z + .enum(['allChildren', 'currentOnly']) + .optional() + .meta({ description: '权限生效范围' }) +}); +export type UpdateCollectionCollaboratorBody = z.infer< + typeof UpdateCollectionCollaboratorBodySchema +>; + +/* ============================================================================ + * API: 批量更新集合协作者 + * Route: POST /api/core/dataset/collection/collaborator/batchUpdate + * ============================================================================ */ +export const BatchUpdateCollectionCollaboratorBodySchema = z.object({ + collectionIds: z.array(ObjectIdSchema).min(1).meta({ description: '集合 ID 列表' }), + collaborators: z.array(CollaboratorItemSchema).min(1).meta({ description: '协作者列表' }), + permissionEffectScope: z + .enum(['allChildren', 'currentOnly']) + .optional() + .meta({ description: '权限生效范围' }) +}); +export type BatchUpdateCollectionCollaboratorBody = z.infer< + typeof BatchUpdateCollectionCollaboratorBodySchema +>; + +/* ============================================================================ + * API: 转移集合所有权 + * Route: POST /api/core/dataset/collection/changeOwner + * ============================================================================ */ +export const ChangeCollectionOwnerBodySchema = z.object({ + ownerId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '新所有者的团队成员 ID' + }), + collectionId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a06', + description: '集合 ID' + }) +}); +export type ChangeCollectionOwnerBody = z.infer; + +/* ============================================================================ + * API: 恢复集合继承权限 + * Route: POST /api/core/dataset/collection/resumeInheritPermission + * ============================================================================ */ +export const ResumeCollectionInheritPermissionBodySchema = z.object({ + collectionId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '集合 ID' + }) +}); +export type ResumeCollectionInheritPermissionBody = z.infer< + typeof ResumeCollectionInheritPermissionBodySchema +>; diff --git a/packages/global/openapi/core/dataset/collaborator/index.ts b/packages/global/openapi/core/dataset/collaborator/index.ts new file mode 100644 index 000000000000..5e261c37179b --- /dev/null +++ b/packages/global/openapi/core/dataset/collaborator/index.ts @@ -0,0 +1,133 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { + GetDatasetCollaboratorListQuerySchema, + CollaboratorListResponseSchema, + UpdateDatasetCollaboratorBodySchema, + ChangeDatasetOwnerBodySchema, + GetCollectionCollaboratorListQuerySchema, + UpdateCollectionCollaboratorBodySchema, + BatchUpdateCollectionCollaboratorBodySchema, + ChangeCollectionOwnerBodySchema, + ResumeCollectionInheritPermissionBodySchema +} from './api'; + +export const DatasetCollaboratorPath: OpenAPIPath = { + '/core/dataset/collaborator/list': { + get: { + summary: '获取知识库协作者列表', + description: '查询知识库的协作者列表(成员/组织/用户组),继承态会同时返回父级协作者', + tags: [TagsMap.datasetCollaborator], + requestParams: { + query: GetDatasetCollaboratorListQuerySchema + }, + responses: { + 200: { + description: '成功返回协作者列表', + content: { 'application/json': { schema: CollaboratorListResponseSchema } } + } + } + } + }, + + '/core/dataset/collaborator/update': { + post: { + summary: '更新知识库协作者', + description: '新增/修改知识库协作者及其权限,支持设置 permissionEffectScope 控制权限生效范围', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: UpdateDatasetCollaboratorBodySchema } } + }, + responses: { + 200: { description: '更新成功' } + } + } + }, + + '/core/dataset/changeOwner': { + post: { + summary: '转移知识库所有权', + description: '转移知识库所有权给其他团队成员,需要所有者权限', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: ChangeDatasetOwnerBodySchema } } + }, + responses: { + 200: { description: '转移成功' } + } + } + }, + + '/core/dataset/collection/collaborator/list': { + get: { + summary: '获取集合协作者列表', + description: '查询集合的协作者列表(成员/组织/用户组),包含父级协作者信息', + tags: [TagsMap.datasetCollaborator], + requestParams: { + query: GetCollectionCollaboratorListQuerySchema + }, + responses: { + 200: { + description: '成功返回协作者列表', + content: { 'application/json': { schema: CollaboratorListResponseSchema } } + } + } + } + }, + + '/core/dataset/collection/collaborator/update': { + post: { + summary: '更新集合协作者', + description: '新增/修改集合协作者及其权限,支持设置 permissionEffectScope 控制权限生效范围', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: UpdateCollectionCollaboratorBodySchema } } + }, + responses: { + 200: { description: '更新成功' } + } + } + }, + + '/core/dataset/collection/collaborator/batchUpdate': { + post: { + summary: '批量更新集合协作者', + description: '批量更新多个集合的协作者,要求所有集合属于同一父级资源', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: BatchUpdateCollectionCollaboratorBodySchema } } + }, + responses: { + 200: { description: '批量更新成功' } + } + } + }, + + '/core/dataset/collection/changeOwner': { + post: { + summary: '转移集合所有权', + description: '转移集合所有权给其他团队成员,需要所有者权限', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: ChangeCollectionOwnerBodySchema } } + }, + responses: { + 200: { description: '转移成功' } + } + } + }, + + '/core/dataset/collection/resumeInheritPermission': { + post: { + summary: '恢复集合继承权限', + description: '恢复集合的继承权限,使其权限与父级资源保持一致', + tags: [TagsMap.datasetCollaborator], + requestBody: { + content: { 'application/json': { schema: ResumeCollectionInheritPermissionBodySchema } } + }, + responses: { + 200: { description: '恢复成功' } + } + } + } +}; diff --git a/packages/global/openapi/core/dataset/collection/api.ts b/packages/global/openapi/core/dataset/collection/api.ts index 1caffe67ef6d..d74cdf276a9f 100644 --- a/packages/global/openapi/core/dataset/collection/api.ts +++ b/packages/global/openapi/core/dataset/collection/api.ts @@ -8,11 +8,14 @@ import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { DatasetCollectionSyncResultEnum, DatasetCollectionTypeEnum, + CollectionStatusEnum, DatasetCollectionDataProcessModeEnum, TrainingModeEnum } from '../../../../core/dataset/constants'; +import { PermissionEffectScopeEnum } from '../../../../support/permission/constant'; import { ChunkSettingsSchema, + CollectionTagValueSchema, DatasetCollectionItemSchema, DatasetCollectionSchema } from '../../../../core/dataset/type'; @@ -87,7 +90,12 @@ export const DeleteCollectionQuerySchema = z.object({ export type DeleteCollectionQueryType = z.infer; export const DeleteCollectionBodySchema = z.object({ - collectionIds: z.array(z.string()).optional().meta({ description: '集合 ID 列表' }) + collectionIds: z.array(z.string()).optional().meta({ description: '集合 ID 列表' }), + apiFileIds: z + .array(z.string()) + .optional() + .meta({ description: 'API 文件 ID 列表(需配合 datasetId 使用)' }), + datasetId: z.string().optional().meta({ description: '数据集 ID(配合 apiFileIds 使用)' }) }); export type DeleteCollectionBodyType = z.infer; @@ -124,7 +132,10 @@ export const DatasetCollectionsListItemSchema = z.object({ updateTime: DatasetCollectionSchema.shape.updateTime, forbid: DatasetCollectionSchema.shape.forbid, trainingType: DatasetCollectionSchema.shape.trainingType, - tags: z.array(z.string()).optional().meta({ description: '标签' }), + tags: z + .array(z.union([z.string(), CollectionTagValueSchema])) + .optional() + .meta({ description: '标签' }), externalFileId: z.string().optional().meta({ description: '外部文件 ID' }), @@ -133,7 +144,38 @@ export const DatasetCollectionsListItemSchema = z.object({ permission: PermissionSchema, dataAmount: z.number().meta({ description: '数据数量' }), trainingAmount: z.number().meta({ description: '训练数量' }), - hasError: z.boolean().optional().meta({ description: '是否错误' }) + processedCount: z.number().optional().meta({ description: '已处理数据量(索引完成)' }), + remainingCount: z.number().optional().meta({ description: '剩余待处理数据量' }), + indexAmount: z.number().optional().meta({ description: '索引数量(simple 模式)' }), + hasError: z.boolean().optional().meta({ description: '是否错误' }), + errorCount: z.number().optional().meta({ description: '错误训练任务数量' }), + + // 计算得出的状态字段 + status: z + .enum(CollectionStatusEnum) + .optional() + .meta({ description: '文件状态(非 folder 类型)' }), + matchingStatuses: z + .array(z.enum(CollectionStatusEnum)) + .optional() + .meta({ description: 'folder 的匹配状态数组(仅 folder 类型)' }), + + // 持久化的时间标记(直接来自 collection 文档) + parseStartTime: z.coerce.date().optional().meta({ description: '解析开始时间' }), + + // 权限相关 + inheritPermission: z.boolean().optional().meta({ description: '继承权限' }), + permissionEffectScope: z + .nativeEnum(PermissionEffectScopeEnum) + .optional() + .meta({ description: '权限生效范围' }), + + tableSchema: z.any().optional().meta({ description: '数据库表结构' }), + tableSchemaDescription: z.string().optional().meta({ description: '数据库表描述' }), + + // 结构化文档元数据 + rows: z.number().optional().meta({ description: '结构化文档行数' }), + cols: z.number().optional().meta({ description: '结构化文档列数' }) }); export type DatasetCollectionsListItemType = z.infer; export const ListCollectionV2ResponseSchema = PaginationResponseSchema( @@ -184,13 +226,17 @@ export const GetCollectionTrainingDetailResponseSchema = z.object({ .object({ customPdfParse: z.boolean().meta({ description: '自定义 PDF 解析' }), imageIndex: z.boolean().meta({ description: '图片索引' }), - autoIndexes: z.boolean().meta({ description: '自动索引' }) + autoIndexes: z.boolean().meta({ description: '自动索引' }), + small2bigIndexes: z.boolean().optional().meta({ description: '小到大索引' }), + hypeIndexes: z.boolean().optional().meta({ description: '虚拟索引' }) }) .meta({ description: '高级训练配置' }), queuedCounts: TrainingCountsSchema.meta({ description: '排队中数量' }), trainingCounts: TrainingCountsSchema.meta({ description: '训练中数量' }), errorCounts: TrainingCountsSchema.meta({ description: '错误数量' }), - trainedCount: z.number().meta({ description: '已训练数据量' }) + trainedCount: z.number().meta({ description: '已训练数据量' }), + parsingCompleteTime: z.coerce.date().optional().meta({ description: '解析完成时间' }), + indexingCompleteTime: z.coerce.date().optional().meta({ description: '索引完成时间' }) }); export type GetCollectionTrainingDetailResponseType = z.infer< typeof GetCollectionTrainingDetailResponseSchema diff --git a/packages/global/openapi/core/dataset/collection/checkApi.ts b/packages/global/openapi/core/dataset/collection/checkApi.ts new file mode 100644 index 000000000000..87e889a428a2 --- /dev/null +++ b/packages/global/openapi/core/dataset/collection/checkApi.ts @@ -0,0 +1,71 @@ +import z from 'zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; + +/* ============================================================================ + * API: 检查集合名称重复 + * Route: POST /api/core/dataset/collection/check/duplicate + * ============================================================================ */ +export const CheckDuplicateFileNamesBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }), + parentId: z.string().optional().meta({ + example: '68ad85a7463006c963799a06', + description: '父级目录 ID,不传则检查根目录' + }), + fileNames: z.array(z.string()).min(1).meta({ + description: '待检查的文件名列表' + }) +}); +export type CheckDuplicateFileNamesBody = z.infer; + +export const CheckDuplicateFileNamesResponseSchema = z.object({ + duplicateFileNames: z.array(z.string()).meta({ + description: '重复的文件名列表' + }) +}); +export type CheckDuplicateFileNamesResponse = z.infer; + +/* ============================================================================ + * API: 检查文件 MD5 重复 + * Route: POST /api/core/dataset/collection/check/md5Duplicate + * ============================================================================ */ +export const CheckMd5DuplicateBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }), + md5Map: z.record(z.string(), z.string()).meta({ + description: '文件名到 MD5 的映射,key 为文件名,value 为 MD5 值', + example: { '文件1.pdf': 'd41d8cd98f00b204e9800998ecf8427e' } + }) +}); +export type CheckMd5DuplicateBody = z.infer; + +export const Md5DuplicateItemSchema = z.object({ + md5: z + .string() + .regex(/^[a-fA-F0-9]{32}$/, '无效的 MD5 值') + .meta({ + example: 'd41d8cd98f00b204e9800998ecf8427e', + description: '重复的 MD5 值(32 位十六进制字符串)' + }), + type: z.enum(['batch', 'dataset']).meta({ + description: '重复类型:batch=同批次内重复,dataset=与知识库已有文件重复' + }), + existingFileName: z.string().meta({ + description: '已存在的文件名(同批次内第一个出现的 或 知识库中已有的)' + }), + newFileName: z.string().meta({ + description: '新文件名(当前检查的文件)' + }) +}); +export type Md5DuplicateItem = z.infer; + +export const CheckMd5DuplicateResponseSchema = z.object({ + duplicates: z.array(Md5DuplicateItemSchema).meta({ + description: '重复的文件列表' + }) +}); +export type CheckMd5DuplicateResponse = z.infer; diff --git a/packages/global/openapi/core/dataset/collection/createApi.ts b/packages/global/openapi/core/dataset/collection/createApi.ts index 4f0e9df2be19..2cf2919982bc 100644 --- a/packages/global/openapi/core/dataset/collection/createApi.ts +++ b/packages/global/openapi/core/dataset/collection/createApi.ts @@ -1,5 +1,5 @@ import z from 'zod'; -import { ChunkSettingsSchema } from '../../../../core/dataset/type'; +import { ChunkSettingsSchema, CollectionTagValueSchema } from '../../../../core/dataset/type'; import { DatasetCollectionTypeEnum } from '../../../../core/dataset/constants'; import { ParentIdSchema } from '../../../../common/parentFolder/type'; import { ObjectIdSchema } from '../../../../common/type/mongo'; @@ -19,7 +19,10 @@ const DatasetCollectionStoreDataSchema = ChunkSettingsSchema.extend({ // API 创建集合通用基础 Schema export const ApiCreateCollectionBaseSchema = DatasetCollectionStoreDataSchema.extend({ datasetId: z.string().meta({ description: '数据集 ID' }), - tags: z.array(z.string()).optional().meta({ description: '标签列表' }) + tags: z + .array(z.union([z.string(), CollectionTagValueSchema])) + .optional() + .meta({ description: '标签列表' }) }); export type ApiCreateDatasetCollectionParams = z.infer; @@ -50,7 +53,10 @@ export const CreateCollectionBodySchema = z.object({ type: z .enum([DatasetCollectionTypeEnum.folder, DatasetCollectionTypeEnum.virtual]) .meta({ description: '集合类型(folder: 文件夹,virtual: 手动集合)' }), - tags: z.array(z.string()).optional().meta({ description: '标签列表' }) + tags: z + .array(z.union([z.string(), CollectionTagValueSchema])) + .optional() + .meta({ description: '标签列表' }) }); export type CreateCollectionBodyType = z.infer; @@ -147,7 +153,9 @@ export type CreateApiCollectionV2BodyType = z.infer; @@ -155,7 +163,9 @@ export type ImageCreateDatasetCollectionParams = z.infer; // handler 内 parse 用 diff --git a/packages/global/openapi/core/dataset/collection/createPath.ts b/packages/global/openapi/core/dataset/collection/createPath.ts index 7599180505fc..241624ce04bb 100644 --- a/packages/global/openapi/core/dataset/collection/createPath.ts +++ b/packages/global/openapi/core/dataset/collection/createPath.ts @@ -7,11 +7,14 @@ import { CreateCollectionBodySchema, CreateCollectionByFileIdBodySchema, CreateCollectionByLocalFileFormSchema, + CreateCollectionResponseSchema, + CreateCollectionWithResultResponseSchema, CreateImageCollectionMultipartSchema, CreateLinkCollectionBodySchema, CreateTemplateCollectionMultipartSchema, CreateTextCollectionBodySchema, - ReTrainingCollectionBodySchema + ReTrainingCollectionBodySchema, + ReTrainingCollectionResponseSchema } from './createApi'; export const DatasetCollectionCreatePath: OpenAPIPath = { @@ -32,7 +35,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回新创建的集合 ID' + description: '成功返回新创建的集合 ID', + content: { + 'application/json': { schema: CreateCollectionResponseSchema } + } } } } @@ -55,7 +61,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回新集合 ID' + description: '成功返回新集合 ID', + content: { + 'application/json': { schema: ReTrainingCollectionResponseSchema } + } } } } @@ -78,7 +87,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合 ID 及数据插入结果' + description: '成功返回集合 ID 及数据插入结果', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -105,7 +117,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合 ID 及数据插入结果' + description: '成功返回集合 ID 及数据插入结果', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -128,7 +143,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合 ID 及数据插入结果' + description: '成功返回集合 ID 及数据插入结果', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -151,7 +169,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合 ID 及数据插入结果' + description: '成功返回集合 ID 及数据插入结果', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -175,7 +196,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功创建集合' + description: '成功创建集合', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -198,7 +222,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功批量创建集合' + description: '成功批量创建集合', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -225,7 +252,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合 ID 及数据插入结果' + description: '成功返回集合 ID 及数据插入结果', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -252,7 +282,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功导入备份数据' + description: '成功导入备份数据', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } @@ -279,7 +312,10 @@ export const DatasetCollectionCreatePath: OpenAPIPath = { }, responses: { 200: { - description: '成功导入模板数据' + description: '成功导入模板数据', + content: { + 'application/json': { schema: CreateCollectionWithResultResponseSchema } + } } } } diff --git a/packages/global/openapi/core/dataset/collection/index.ts b/packages/global/openapi/core/dataset/collection/index.ts index 0e7729a57843..e4e4ab8b35fa 100644 --- a/packages/global/openapi/core/dataset/collection/index.ts +++ b/packages/global/openapi/core/dataset/collection/index.ts @@ -5,16 +5,27 @@ import { DeleteCollectionQuerySchema, ExportCollectionBodySchema, GetCollectionDetailQuerySchema, + GetCollectionDetailResponseSchema, GetCollectionPathsQuerySchema, + GetCollectionPathsResponseSchema, GetCollectionTrainingDetailQuerySchema, GetCollectionTrainingDetailResponseSchema, ListCollectionV2BodySchema, + ListCollectionV2ResponseSchema, ReadCollectionSourceBodySchema, + ReadCollectionSourceResponseSchema, ScrollCollectionsBodySchema, SyncCollectionBodySchema, + SyncCollectionResponseSchema, UpdateDatasetCollectionBodySchema } from './api'; import { DatasetCollectionCreatePath } from './createPath'; +import { + CheckDuplicateFileNamesBodySchema, + CheckDuplicateFileNamesResponseSchema, + CheckMd5DuplicateBodySchema, + CheckMd5DuplicateResponseSchema +} from './checkApi'; export const DatasetCollectionPath: OpenAPIPath = { ...DatasetCollectionCreatePath, @@ -50,7 +61,10 @@ export const DatasetCollectionPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合详情' + description: '成功返回集合详情', + content: { + 'application/json': { schema: GetCollectionDetailResponseSchema } + } } } } @@ -69,7 +83,10 @@ export const DatasetCollectionPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回集合列表和总数' + description: '成功返回集合列表和总数', + content: { + 'application/json': { schema: ListCollectionV2ResponseSchema } + } } } } @@ -122,7 +139,10 @@ export const DatasetCollectionPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回路径列表' + description: '成功返回路径列表', + content: { + 'application/json': { schema: GetCollectionPathsResponseSchema } + } } } } @@ -141,7 +161,10 @@ export const DatasetCollectionPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回资源 URL' + description: '成功返回资源 URL', + content: { + 'application/json': { schema: ReadCollectionSourceResponseSchema } + } } } } @@ -160,7 +183,10 @@ export const DatasetCollectionPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回同步结果(success / sameRaw / failed)' + description: '成功返回同步结果(success / sameRaw / failed)', + content: { + 'application/json': { schema: SyncCollectionResponseSchema } + } } } } @@ -203,5 +229,49 @@ export const DatasetCollectionPath: OpenAPIPath = { } } } + }, + '/core/dataset/collection/check/duplicate': { + post: { + summary: '检查集合名称重复', + description: '检查指定知识库下的文件名是否已存在,用于上传前的去重校验', + tags: [TagsMap.datasetCollection], + requestBody: { + content: { + 'application/json': { + schema: CheckDuplicateFileNamesBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回重复的文件名列表', + content: { + 'application/json': { schema: CheckDuplicateFileNamesResponseSchema } + } + } + } + } + }, + '/core/dataset/collection/check/md5Duplicate': { + post: { + summary: '检查文件 MD5 重复', + description: '检查文件 MD5 的重复情况,包含同批次内重复和与知识库已有文件的重复', + tags: [TagsMap.datasetCollection], + requestBody: { + content: { + 'application/json': { + schema: CheckMd5DuplicateBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回重复文件列表,包含重复类型和已存在的文件名', + content: { + 'application/json': { schema: CheckMd5DuplicateResponseSchema } + } + } + } + } } }; diff --git a/packages/global/openapi/core/dataset/collection/tagApi.ts b/packages/global/openapi/core/dataset/collection/tagApi.ts index d960826f61e1..6b45bf25ed93 100644 --- a/packages/global/openapi/core/dataset/collection/tagApi.ts +++ b/packages/global/openapi/core/dataset/collection/tagApi.ts @@ -1,12 +1,14 @@ import z from 'zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; /* ============================================================================ * API: 创建集合标签 * Route: POST /proApi/core/dataset/tag/create * ============================================================================ */ export const CreateDatasetCollectionTagBodySchema = z.object({ - datasetId: z.string().meta({ description: '数据集 ID' }), - tag: z.string().meta({ description: '标签名称' }) + datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }), + tag: z.string().meta({ description: '标签名称' }), + tagType: z.string().optional().meta({ description: '标签类型' }) }); export type CreateDatasetCollectionTagParams = z.infer; @@ -16,10 +18,10 @@ export type CreateDatasetCollectionTagParams = z.infer; @@ -29,8 +31,101 @@ export type AddTagsToCollectionsParams = z.infer; + +/* ============================================================================ + * API: 批量新增/修改标签 + * Route: POST /proApi/core/dataset/tag/batchUpsert + * ============================================================================ */ +export const BatchUpsertTagsBodySchema = z + .object({ + datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }), + tags: z + .array( + z.object({ + tag: z.string().meta({ description: '标签名称' }), + tagType: z.enum(['string', 'number', 'datetime']).optional().meta({ + description: '标签类型: string=文本, number=数字, datetime=日期时间' + }) + }) + ) + .min(1) + .meta({ description: '标签数组' }) + }) + .refine( + (data) => { + const tagNames = data.tags.map((t) => t.tag); + return new Set(tagNames).size === tagNames.length; + }, + { + message: '标签名不能重复', + path: ['tags'] + } + ); +export type BatchUpsertTagsParams = z.infer; + +/* ============================================================================ + * API: 设置集合的标签值 + * Route: POST /proApi/core/dataset/tag/setCollectionTags + * ============================================================================ */ +export const SetCollectionTagsBodySchema = z.object({ + collectionId: ObjectIdSchema.meta({ description: '集合 ID' }), + tags: z + .array( + z.object({ + tagId: ObjectIdSchema.meta({ description: '标签 ID(引用标签服务中的 _id)' }), + value: z + .union([z.string(), z.number()]) + .meta({ description: '标签值,string 类型存字符串,datetime 类型存 UTC 毫秒时间戳' }), + label: z.string().optional().meta({ description: '标签显示名称(服务端列表返回时附加)' }) + }) + ) + .optional() + .meta({ description: '标签值列表' }) +}); +export type SetCollectionTagsParams = z.infer; + +/* ============================================================================ + * API: 批量设置集合的标签值 + * Route: POST /proApi/core/dataset/tag/batchSetCollectionTags + * ============================================================================ */ +export const BatchSetCollectionTagsBodySchema = z.object({ + collectionIds: z.array(ObjectIdSchema).min(1).meta({ description: '集合 ID 列表' }), + tags: z + .array( + z.object({ + tagId: ObjectIdSchema.meta({ description: '标签 ID(引用标签服务中的 _id)' }), + value: z + .union([z.string(), z.number()]) + .meta({ description: '标签值,string 类型存字符串,datetime 类型存 UTC 毫秒时间戳' }), + label: z.string().optional().meta({ description: '标签显示名称(服务端列表返回时附加)' }) + }) + ) + .optional() + .meta({ description: '标签值列表' }), + deleteTagIds: z.array(ObjectIdSchema).optional().meta({ description: '需要删除的标签 ID 列表' }), + datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }) +}); +export type BatchSetCollectionTagsParams = z.infer; + +/* ============================================================================ + * API: 查询标签使用情况 + * Route: GET /proApi/core/dataset/tag/tagUsage + * ============================================================================ */ +export const GetTagUsageQuerySchema = z.object({ + datasetId: ObjectIdSchema.meta({ description: '数据集 ID' }) +}); +export type GetTagUsageQuery = z.infer; + +export const GetTagUsageResponseSchema = z.array( + z.object({ + tagId: ObjectIdSchema.meta({ description: '标签 ID' }), + collections: z.array(ObjectIdSchema).meta({ description: '使用该标签的集合 ID 列表' }) + }) +); +export type GetTagUsageResponse = z.infer; diff --git a/packages/global/openapi/core/dataset/custom/index.ts b/packages/global/openapi/core/dataset/custom/index.ts new file mode 100644 index 000000000000..4f38ab67e77b --- /dev/null +++ b/packages/global/openapi/core/dataset/custom/index.ts @@ -0,0 +1,28 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { SearchApiBodySchema, SearchApiResponseSchema } from '../api'; + +export const CustomPath: OpenAPIPath = { + '/core/dataset/custom/searchApi': { + post: { + summary: '多知识库检索', + description: '跨知识库检索,支持混合召回、重排序、LLM 问题扩展和 Agentic 深度检索', + tags: [TagsMap.datasetCommon], + requestBody: { + content: { + 'application/json': { + schema: SearchApiBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回检索结果列表、耗时及使用的策略信息', + content: { + 'application/json': { schema: SearchApiResponseSchema } + } + } + } + } + } +}; diff --git a/packages/global/openapi/core/dataset/data/api.ts b/packages/global/openapi/core/dataset/data/api.ts index 67af86712b82..90257667145f 100644 --- a/packages/global/openapi/core/dataset/data/api.ts +++ b/packages/global/openapi/core/dataset/data/api.ts @@ -6,11 +6,15 @@ import { DatasetDataItemSchema, UpdateDatasetDataPropsSchema } from '../../../../core/dataset/type'; -import { DatasetCollectionDataProcessModeEnum } from '../../../../core/dataset/constants'; +import { DatasetCollectionDataProcessModeEnum, DatasetTrainingStatusEnum } from '../../../../core/dataset/constants'; import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { PaginationSchema, PaginationResponseSchema } from '../../../api'; const PushDataChunkSchema = z.object({ + id: z.string().optional().meta({ + example: '68ad85a7463006c963799a05', + description: '自定义数据 ID(需为合法 MongoDB ObjectId)' + }), q: z.string().optional().meta({ example: '什么是 FastGPT?', description: '问题/主文本' @@ -25,6 +29,9 @@ const PushDataChunkSchema = z.object({ example: 0, description: '块索引' }), +metadata: z.record(z.string(), z.any()).optional().meta({ + description: '元数据' + }), indexes: z .array(DatasetDataIndexItemSchema.omit({ dataId: true })) .optional() @@ -214,7 +221,8 @@ export const GetDataListItemSchema = z.object({ imageSize: z.number().optional().meta({ description: '图片大小(字节)' }), imagePreviewUrl: z.string().optional().meta({ description: '图片预览 URL' }), chunkIndex: z.number().optional().meta({ description: '块索引' }), - updated: z.boolean().optional().meta({ description: '是否已更新' }) +updated: z.boolean().optional().meta({ description: '是否已更新' }), + trainingStatus: z.enum(DatasetTrainingStatusEnum).optional().meta({ description: '训练状态' }) }); export const GetDatasetDataListBodySchema = PaginationSchema.extend({ diff --git a/packages/global/openapi/core/dataset/data/index.ts b/packages/global/openapi/core/dataset/data/index.ts index ad70bd0f1f00..89a7e3999314 100644 --- a/packages/global/openapi/core/dataset/data/index.ts +++ b/packages/global/openapi/core/dataset/data/index.ts @@ -2,12 +2,16 @@ import type { OpenAPIPath } from '../../../type'; import { TagsMap } from '../../../tag'; import { GetDatasetDataDetailQuerySchema, + GetDatasetDataDetailResponseSchema, UpdateDatasetDataBodySchema, DeleteDatasetDataQuerySchema, GetQuoteDataBodySchema, + GetQuoteDataResponseSchema, InsertDataBodySchema, + InsertDataResponseSchema, InsertImagesBodySchema, PushDataBodySchema, + PushDataResponseSchema, GetDatasetDataListBodySchema, GetDatasetDataListResponseSchema } from './api'; @@ -47,7 +51,10 @@ export const DatasetDataPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回数据详情' + description: '成功返回数据详情', + content: { + 'application/json': { schema: GetDatasetDataDetailResponseSchema } + } } } } @@ -103,7 +110,10 @@ export const DatasetDataPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回引用数据和所属集合信息' + description: '成功返回引用数据和所属集合信息', + content: { + 'application/json': { schema: GetQuoteDataResponseSchema } + } } } } @@ -122,7 +132,10 @@ export const DatasetDataPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回新数据的 ID' + description: '成功返回新数据的 ID', + content: { + 'application/json': { schema: InsertDataResponseSchema } + } } } } @@ -160,7 +173,10 @@ export const DatasetDataPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回插入条数' + description: '成功返回插入条数', + content: { + 'application/json': { schema: PushDataResponseSchema } + } } } } diff --git a/packages/global/openapi/core/dataset/file/api.ts b/packages/global/openapi/core/dataset/file/api.ts index a08f4318885d..9fed47ae245c 100644 --- a/packages/global/openapi/core/dataset/file/api.ts +++ b/packages/global/openapi/core/dataset/file/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { ObjectIdSchema } from '../../../../common/type/mongo'; import { DatasetSourceReadTypeEnum } from '../../../../core/dataset/constants'; import { ChunkSettingsSchema } from '../../../../core/dataset/type'; diff --git a/packages/global/openapi/core/dataset/index.ts b/packages/global/openapi/core/dataset/index.ts index 399b58a53fa2..0d5cde32db08 100644 --- a/packages/global/openapi/core/dataset/index.ts +++ b/packages/global/openapi/core/dataset/index.ts @@ -5,19 +5,30 @@ import { DatasetCollectionPath } from './collection'; import { ApiDatasetPath } from './apiDataset'; import { DatasetFilePath } from './file'; import { DatasetTrainingPath } from './training'; +import { DatasetTagPath } from './tag'; +import { DatasetSynonymPath } from './synonym'; +import { DatasetCollaboratorPath } from './collaborator'; +import { CustomPath } from './custom'; import { CreateDatasetBodySchema, + CreateDatasetResponseSchema, CreateDatasetWithFilesBodySchema, + CreateDatasetWithFilesResponseSchema, DeleteDatasetQuerySchema, GetDatasetDetailQuerySchema, + GetDatasetDetailResponseSchema, GetDatasetListBodySchema, + GetDatasetListResponseSchema, GetDatasetPathsQuerySchema, + GetDatasetPathsResponseSchema, + GetDatasetPermissionQuerySchema, + GetDatasetPermissionResponseSchema, UpdateDatasetBodySchema, ResumeDatasetInheritPermissionBodySchema, CreateDatasetFolderBodySchema, SearchDatasetTestBodySchema, - ExportDatasetQuerySchema, - GetDatasetPermissionQuerySchema + SearchDatasetTestResponseSchema, + ExportDatasetQuerySchema } from './api'; export const DatasetPath: OpenAPIPath = { @@ -35,7 +46,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回新创建的知识库 ID' + description: '成功返回新创建的知识库 ID', + content: { + 'application/json': { schema: CreateDatasetResponseSchema } + } } } } @@ -54,7 +68,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回知识库信息和向量模型配置' + description: '成功返回知识库信息和向量模型配置', + content: { + 'application/json': { schema: CreateDatasetWithFilesResponseSchema } + } } } } @@ -73,7 +90,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功创建文件夹' + description: '成功创建文件夹', + content: { + 'application/json': { schema: CreateDatasetResponseSchema } + } } } } @@ -92,7 +112,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回知识库列表' + description: '成功返回知识库列表', + content: { + 'application/json': { schema: GetDatasetListResponseSchema } + } } } } @@ -107,7 +130,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回路径列表' + description: '成功返回路径列表', + content: { + 'application/json': { schema: GetDatasetPathsResponseSchema } + } } } } @@ -122,7 +148,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回知识库详情' + description: '成功返回知识库详情', + content: { + 'application/json': { schema: GetDatasetDetailResponseSchema } + } } } } @@ -194,7 +223,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回搜索结果列表及耗时信息' + description: '成功返回搜索结果列表及耗时信息', + content: { + 'application/json': { schema: SearchDatasetTestResponseSchema } + } } } } @@ -224,7 +256,10 @@ export const DatasetPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回知识库名称和读写权限,无访问权限时返回空名称和 false' + description: '成功返回知识库名称和读写权限,无访问权限时返回空名称和 false', + content: { + 'application/json': { schema: GetDatasetPermissionResponseSchema } + } } } } @@ -234,5 +269,9 @@ export const DatasetPath: OpenAPIPath = { ...DatasetDataPath, ...ApiDatasetPath, ...DatasetFilePath, - ...DatasetTrainingPath + ...DatasetTrainingPath, + ...DatasetTagPath, + ...DatasetSynonymPath, + ...DatasetCollaboratorPath, + ...CustomPath }; diff --git a/packages/global/openapi/core/dataset/synonym/api.ts b/packages/global/openapi/core/dataset/synonym/api.ts new file mode 100644 index 000000000000..162a93f26bf0 --- /dev/null +++ b/packages/global/openapi/core/dataset/synonym/api.ts @@ -0,0 +1,66 @@ +import z from 'zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; + +/* ============================================================================ + * API: 查询同义词文件列表 + * Route: GET /api/core/dataset/synonym/list + * ============================================================================ */ +export const GetSynonymListQuerySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }) +}); +export type GetSynonymListQuery = z.infer; + +export const SynonymFileItemSchema = z.object({ + _id: z.string().meta({ example: '68ad85a7463006c963799a05', description: '同义词文件 ID' }), + filename: z.string().meta({ example: 'synonyms.csv', description: '文件名' }), + fileSize: z.number().meta({ example: 1024, description: '文件大小(字节)' }), + uploadTime: z.string().meta({ example: '2024-01-01T00:00:00.000Z', description: '上传时间' }), + uploaderName: z.string().optional().meta({ description: '上传者名称' }), + status: z + .enum(['active', 'inactive']) + .meta({ example: 'active', description: '状态:active=有效, inactive=已失效' }) +}); + +export const GetSynonymListResponseSchema = z.object({ + list: z.array(SynonymFileItemSchema).meta({ description: '同义词文件列表(按上传时间倒序)' }) +}); +export type GetSynonymListResponse = z.infer; + +/* ============================================================================ + * API: 下载同义词文件 + * Route: GET /api/core/dataset/synonym/download + * ============================================================================ */ +export const DownloadSynonymQuerySchema = z.object({ + id: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '同义词文件 ID' + }) +}); +export type DownloadSynonymQuery = z.infer; + +/* ============================================================================ + * API: 删除同义词文件 + * Route: DELETE /api/core/dataset/synonym/delete + * ============================================================================ */ +export const DeleteSynonymQuerySchema = z.object({ + id: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '同义词文件 ID' + }) +}); +export type DeleteSynonymQuery = z.infer; + +/* ============================================================================ + * API: 上传同义词文件 + * Route: POST /api/core/dataset/synonym/upload + * ============================================================================ */ +export const UploadSynonymBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }) +}); +export type UploadSynonymBody = z.infer; diff --git a/packages/global/openapi/core/dataset/synonym/index.ts b/packages/global/openapi/core/dataset/synonym/index.ts new file mode 100644 index 000000000000..4166834f39db --- /dev/null +++ b/packages/global/openapi/core/dataset/synonym/index.ts @@ -0,0 +1,78 @@ +import z from 'zod'; +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { + GetSynonymListQuerySchema, + GetSynonymListResponseSchema, + DownloadSynonymQuerySchema, + DeleteSynonymQuerySchema, + UploadSynonymBodySchema +} from './api'; + +export const DatasetSynonymPath: OpenAPIPath = { + '/core/dataset/synonym/upload': { + post: { + summary: '上传同义词文件', + description: + '上传同义词文件(CSV/XLSX/XLS),自动替换旧文件并触发标准化训练。注意:只支持单个同义词文件,第二次上传以最新为准,旧文件自动失效', + tags: [TagsMap.datasetSynonym], + requestBody: { + content: { + 'multipart/form-data': { + schema: UploadSynonymBodySchema.extend({ + file: z.string().meta({ format: 'binary' }) + }) + } + } + }, + responses: { + 200: { description: '上传成功' } + } + } + }, + + '/core/dataset/synonym/list': { + get: { + summary: '查询同义词文件列表', + description: '查询知识库的同义词文件列表(按上传时间倒序),含上传者信息', + tags: [TagsMap.datasetSynonym], + requestParams: { + query: GetSynonymListQuerySchema + }, + responses: { + 200: { + description: '成功返回同义词文件列表', + content: { 'application/json': { schema: GetSynonymListResponseSchema } } + } + } + } + }, + + '/core/dataset/synonym/download': { + get: { + summary: '下载同义词文件', + description: '下载指定的同义词原文件(返回文件流)', + tags: [TagsMap.datasetSynonym], + requestParams: { + query: DownloadSynonymQuerySchema + }, + responses: { + 200: { description: '返回文件流' } + } + } + }, + + '/core/dataset/synonym/delete': { + delete: { + summary: '删除同义词文件', + description: '删除同义词文件及其所有映射关系,同时清理 S3 文件', + tags: [TagsMap.datasetSynonym], + requestParams: { + query: DeleteSynonymQuerySchema + }, + responses: { + 200: { description: '删除成功' } + } + } + } +}; diff --git a/packages/global/openapi/core/dataset/tag/index.ts b/packages/global/openapi/core/dataset/tag/index.ts new file mode 100644 index 000000000000..dd4006063f9a --- /dev/null +++ b/packages/global/openapi/core/dataset/tag/index.ts @@ -0,0 +1,208 @@ +import type { OpenAPIPath } from '../../../type'; +import { TagsMap } from '../../../tag'; +import { PaginationResponseSchema, PaginationSchema } from '../../../api'; +import z from 'zod'; +import { + CreateDatasetCollectionTagBodySchema, + AddTagsToCollectionsBodySchema, + UpdateDatasetCollectionTagBodySchema, + BatchUpsertTagsBodySchema, + SetCollectionTagsBodySchema, + BatchSetCollectionTagsBodySchema, + GetTagUsageQuerySchema, + GetTagUsageResponseSchema +} from '../collection/tagApi'; + +/* ============================================================================ + * API: 获取标签列表(分页) + * Route: POST /proApi/core/dataset/tag/list + * ============================================================================ */ +export const GetTagListBodySchema = PaginationSchema.extend({ + datasetId: z.string().meta({ description: '数据集 ID' }), + searchText: z.string().optional().meta({ description: '标签名搜索关键词' }) +}); +export type GetTagListBody = z.infer; + +export const DatasetTagItemSchema = z.object({ + _id: z.string().meta({ description: '标签 ID' }), + tag: z.string().meta({ description: '标签名称' }), + tagType: z.enum(['string', 'number', 'datetime']).optional().meta({ + description: '标签类型: string=文本, number=数字, datetime=日期时间' + }) +}); + +/* ============================================================================ + * API: 获取全部标签列表(不分页) + * Route: GET /proApi/core/dataset/tag/getAllTags + * ============================================================================ */ +export const GetAllTagsQuerySchema = z.object({ + datasetId: z.string().meta({ description: '数据集 ID' }) +}); +export type GetAllTagsQuery = z.infer; + +export const GetAllTagsResponseSchema = z.object({ + list: z.array(DatasetTagItemSchema).meta({ description: '标签列表' }) +}); + +/* ============================================================================ + * API: 删除标签 + * Route: DELETE /proApi/core/dataset/tag/delete + * ============================================================================ */ +export const DeleteTagQuerySchema = z.object({ + id: z.string().meta({ description: '标签 ID' }), + datasetId: z.string().meta({ description: '数据集 ID' }) +}); +export type DeleteTagQuery = z.infer; + +export const GetTagListResponseSchema = PaginationResponseSchema(DatasetTagItemSchema); + +export const DatasetTagPath: OpenAPIPath = { + '/core/dataset/tag/create': { + post: { + summary: '创建标签', + description: '在指定知识库中创建一个新标签,需要写权限', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: CreateDatasetCollectionTagBodySchema } } + }, + responses: { + 200: { description: '成功返回新创建的标签 ID' } + } + } + }, + + '/core/dataset/tag/list': { + post: { + summary: '获取标签列表(分页)', + description: '分页查询知识库的标签列表,支持按标签名搜索', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: GetTagListBodySchema } } + }, + responses: { + 200: { + description: '成功返回标签分页列表和总数', + content: { 'application/json': { schema: GetTagListResponseSchema } } + } + } + } + }, + + '/core/dataset/tag/getAllTags': { + get: { + summary: '获取全部标签列表(不分页)', + description: '获取知识库的全部标签列表(不分页),需要读权限', + tags: [TagsMap.datasetTag], + requestParams: { + query: GetAllTagsQuerySchema + }, + responses: { + 200: { + description: '成功返回全部标签列表', + content: { 'application/json': { schema: GetAllTagsResponseSchema } } + } + } + } + }, + + '/core/dataset/tag/update': { + post: { + summary: '更新标签', + description: '更新指定标签的名称或类型,需要写权限', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: UpdateDatasetCollectionTagBodySchema } } + }, + responses: { + 200: { description: '更新成功' } + } + } + }, + + '/core/dataset/tag/delete': { + delete: { + summary: '删除标签', + description: '删除指定标签,需要所有者权限', + tags: [TagsMap.datasetTag], + requestParams: { + query: DeleteTagQuerySchema + }, + responses: { + 200: { description: '删除成功' } + } + } + }, + + '/core/dataset/tag/batchUpsert': { + post: { + summary: '批量新增/修改标签', + description: '批量新增或更新标签,需要写权限', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: BatchUpsertTagsBodySchema } } + }, + responses: { + 200: { description: '操作成功' } + } + } + }, + + '/core/dataset/tag/tagUsage': { + get: { + summary: '查询标签使用情况', + description: '查询标签在集合中的使用情况,返回每个标签关联的集合 ID 列表', + tags: [TagsMap.datasetTag], + requestParams: { + query: GetTagUsageQuerySchema + }, + responses: { + 200: { + description: '成功返回标签使用情况', + content: { 'application/json': { schema: GetTagUsageResponseSchema } } + } + } + } + }, + + '/core/dataset/tag/setCollectionTags': { + post: { + summary: '设置集合的标签值', + description: '为指定集合设置标签值,需要写权限', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: SetCollectionTagsBodySchema } } + }, + responses: { + 200: { description: '设置成功' } + } + } + }, + + '/core/dataset/tag/batchSetCollectionTags': { + post: { + summary: '批量设置集合的标签值', + description: '批量为多个集合设置标签值,同时支持删除标签,需要写权限', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: BatchSetCollectionTagsBodySchema } } + }, + responses: { + 200: { description: '设置成功' } + } + } + }, + + '/core/dataset/tag/addToCollections': { + post: { + summary: '批量为集合添加标签', + description: '将源集合的标签复制到目标集合,用于批量标签操作', + tags: [TagsMap.datasetTag], + requestBody: { + content: { 'application/json': { schema: AddTagsToCollectionsBodySchema } } + }, + responses: { + 200: { description: '操作成功' } + } + } + } +}; diff --git a/packages/global/openapi/core/dataset/training/api.ts b/packages/global/openapi/core/dataset/training/api.ts index 5d6176397b2f..9bd05ab1b387 100644 --- a/packages/global/openapi/core/dataset/training/api.ts +++ b/packages/global/openapi/core/dataset/training/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { ObjectIdSchema } from '../../../../common/type/mongo'; import { TrainingModeEnum } from '../../../../core/dataset/constants'; import { DatasetTrainingSchema } from '../../../../core/dataset/type'; @@ -48,9 +48,9 @@ export const RebuildEmbeddingBodySchema = z.object({ example: '68ad85a7463006c963799a05', description: '知识库 ID' }), - vectorModel: z.string().meta({ - example: 'text-embedding-3-small', - description: '新的向量模型名称,不能与当前模型相同' + vectorModelId: z.string().meta({ + example: '68ad85a7463006c963799a05', + description: '新的向量模型Id,不能与当前模型相同' }) }); export type RebuildEmbeddingBody = z.infer; @@ -58,29 +58,6 @@ export type RebuildEmbeddingBody = z.infer; export const RebuildEmbeddingResponseSchema = z.object({}); export type RebuildEmbeddingResponse = z.infer; -/* ============================================================================ - * API: 删除训练数据 - * Route: POST /api/core/dataset/training/deleteTrainingData - * ============================================================================ */ -export const DeleteTrainingDataBodySchema = z.object({ - datasetId: ObjectIdSchema.meta({ - example: '68ad85a7463006c963799a05', - description: '知识库 ID' - }), - collectionId: ObjectIdSchema.meta({ - example: '68ad85a7463006c963799a06', - description: '集合 ID' - }), - dataId: ObjectIdSchema.meta({ - example: '68ad85a7463006c963799a07', - description: '训练数据 ID' - }) -}); -export type DeleteTrainingDataBody = z.infer; - -export const DeleteTrainingDataResponseSchema = z.object({}); -export type DeleteTrainingDataResponse = z.infer; - /* ============================================================================ * API: 获取训练数据详情 * Route: POST /api/core/dataset/training/getTrainingDataDetail @@ -171,6 +148,28 @@ export const GetDatasetTrainingQueueResponseSchema = z.object({ trainingCount: z.number().meta({ example: 12, description: '训练队列中的数据条数' + }), + errorCount: z.number().meta({ + example: 3, + description: '存在错误的训练数据条数' }) }); export type GetDatasetTrainingQueueResponse = z.infer; + +/* ============================================================================ + * API: 批量重试多个集合内所有错误数据 + * Route: PUT /api/core/dataset/training/retryErrorCollections + * ============================================================================ */ +export const RetryErrorCollectionsBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }), + collectionIds: z.array(ObjectIdSchema).min(1).optional().meta({ + description: '需要重试的集合 ID 列表,不传则重试该知识库下所有错误集合' + }) +}); +export type RetryErrorCollectionsBody = z.infer; + +export const RetryErrorCollectionsResponseSchema = z.object({}); +export type RetryErrorCollectionsResponse = z.infer; diff --git a/packages/global/openapi/core/dataset/training/index.ts b/packages/global/openapi/core/dataset/training/index.ts index 34110a387dff..1f6f5c6a5062 100644 --- a/packages/global/openapi/core/dataset/training/index.ts +++ b/packages/global/openapi/core/dataset/training/index.ts @@ -3,10 +3,12 @@ import { TagsMap } from '../../../tag'; import { UpdateTrainingDataBodySchema, RebuildEmbeddingBodySchema, - DeleteTrainingDataBodySchema, GetTrainingDataDetailBodySchema, + GetTrainingDataDetailResponseSchema, GetTrainingErrorBodySchema, - GetDatasetTrainingQueueQuerySchema + GetTrainingErrorResponseSchema, + GetDatasetTrainingQueueQuerySchema, + GetDatasetTrainingQueueResponseSchema } from './api'; export const DatasetTrainingPath: OpenAPIPath = { @@ -50,26 +52,6 @@ export const DatasetTrainingPath: OpenAPIPath = { } }, - '/core/dataset/training/deleteTrainingData': { - post: { - summary: '删除训练数据', - description: '删除指定的训练数据条目,需要管理权限', - tags: [TagsMap.datasetTraining], - requestBody: { - content: { - 'application/json': { - schema: DeleteTrainingDataBodySchema - } - } - }, - responses: { - 200: { - description: '删除成功' - } - } - } - }, - '/core/dataset/training/getTrainingDataDetail': { post: { summary: '获取训练数据详情', @@ -84,7 +66,10 @@ export const DatasetTrainingPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回训练数据详情,数据不存在时返回空' + description: '成功返回训练数据详情,数据不存在时返回空', + content: { + 'application/json': { schema: GetTrainingDataDetailResponseSchema } + } } } } @@ -104,7 +89,10 @@ export const DatasetTrainingPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回错误数据分页列表' + description: '成功返回错误数据分页列表', + content: { + 'application/json': { schema: GetTrainingErrorResponseSchema } + } } } } @@ -120,7 +108,10 @@ export const DatasetTrainingPath: OpenAPIPath = { }, responses: { 200: { - description: '成功返回重建数量和训练队列数量' + description: '成功返回重建数量和训练队列数量', + content: { + 'application/json': { schema: GetDatasetTrainingQueueResponseSchema } + } } } } diff --git a/packages/global/openapi/index.ts b/packages/global/openapi/index.ts index 8cdb3094a7b8..2e0eefe26d4d 100644 --- a/packages/global/openapi/index.ts +++ b/packages/global/openapi/index.ts @@ -7,6 +7,7 @@ import { SupportPath } from './support'; import { DatasetPath } from './core/dataset'; import { AIPath } from './core/ai'; import { AgentSkillsPath } from './core/agentSkills'; +import { AgentSkillsPackagePath } from './core/agentSkills/package'; export const openAPIDocument = createDocument({ openapi: '3.1.0', @@ -22,7 +23,8 @@ export const openAPIDocument = createDocument({ ...PluginPath, ...SupportPath, ...AIPath, - ...AgentSkillsPath + ...AgentSkillsPath, + ...AgentSkillsPackagePath }, servers: [{ url: '/api' }], 'x-tagGroups': [ @@ -36,7 +38,7 @@ export const openAPIDocument = createDocument({ }, { name: 'AI 相关', - tags: [TagsMap.aiSkill, TagsMap.sandbox] + tags: [TagsMap.aiSkill, TagsMap.sandbox, TagsMap.modelManage] }, { name: '对话模块配置', @@ -61,7 +63,10 @@ export const openAPIDocument = createDocument({ TagsMap.datasetData, TagsMap.datasetFile, TagsMap.datasetTraining, - TagsMap.datasetApiDataset + TagsMap.datasetApiDataset, + TagsMap.datasetTag, + TagsMap.datasetSynonym, + TagsMap.datasetCollaborator ] }, { diff --git a/packages/global/openapi/support/mcpServer/api.ts b/packages/global/openapi/support/mcpServer/api.ts index e56936bde610..00e46e1cf64b 100644 --- a/packages/global/openapi/support/mcpServer/api.ts +++ b/packages/global/openapi/support/mcpServer/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { ObjectIdSchema } from '../../../common/type/mongo'; /* ============================================================================ diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts index 5171de857a82..d9bb16f0cbbc 100644 --- a/packages/global/openapi/support/user/account/login/api.ts +++ b/packages/global/openapi/support/user/account/login/api.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; import { OAuthEnum } from '../../../../../support/user/constant'; import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type'; import { LanguageSchema } from '../../../../../common/i18n/type'; @@ -36,7 +36,7 @@ export type LoginSuccessResponseType = z.infer = { canDownloadSource: boolean; // whether to show the whole response button showWholeResponse: boolean; + // whether to allow anonymous access + allowAnonymous?: boolean; // response when request immediateResponse?: string; @@ -113,6 +115,7 @@ export type OutLinkEditType = { showSkillReferences?: OutLinkSchemaType['showSkillReferences']; showFullText?: OutLinkSchemaType['showFullText']; canDownloadSource?: OutLinkSchemaType['canDownloadSource']; + allowAnonymous?: OutLinkSchemaType['allowAnonymous']; // response when request immediateResponse?: string; // response when error or other situation diff --git a/packages/global/support/permission/agentSkill/constant.ts b/packages/global/support/permission/agentSkill/constant.ts index c546bc8bf915..e82f336c239d 100644 --- a/packages/global/support/permission/agentSkill/constant.ts +++ b/packages/global/support/permission/agentSkill/constant.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { NullRoleVal, CommonPerKeyEnum, diff --git a/packages/global/support/permission/app/constant.ts b/packages/global/support/permission/app/constant.ts index 83fe63148db5..b1a022c82c6b 100644 --- a/packages/global/support/permission/app/constant.ts +++ b/packages/global/support/permission/app/constant.ts @@ -7,7 +7,7 @@ import { } from '../constant'; import type { PermissionListType, PermissionValueType, RolePerMapType } from '../type'; import { type RoleListType } from '../type'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { sumPer } from '../utils'; export enum AppPermissionKeyEnum { diff --git a/packages/global/support/permission/collaborator.ts b/packages/global/support/permission/collaborator.ts index 332f633ae189..0bd8ac3d1c7f 100644 --- a/packages/global/support/permission/collaborator.ts +++ b/packages/global/support/permission/collaborator.ts @@ -13,6 +13,7 @@ export type CollaboratorItemDetailType = { permission: Permission; name: string; avatar: string; + username?: string; } & CollaboratorIdType; export type CollaboratorItemType = { diff --git a/packages/global/support/permission/constant.ts b/packages/global/support/permission/constant.ts index 51837832aafa..610852e9193f 100644 --- a/packages/global/support/permission/constant.ts +++ b/packages/global/support/permission/constant.ts @@ -1,6 +1,6 @@ import type { PermissionListType, PermissionValueType, RolePerMapType } from './type'; import { type RoleListType } from './type'; -import { i18nT } from '../../../web/i18n/utils'; +import { i18nT } from '../../common/i18n/utils'; import { sumPer } from './utils'; export enum AuthUserTypeEnum { token = 'token', @@ -50,10 +50,17 @@ export enum PerResourceTypeEnum { team = 'team', app = 'app', dataset = 'dataset', + collection = 'collection', + evaluation = 'evaluation', model = 'model', agentSkill = 'agentSkill' } +export enum PermissionEffectScopeEnum { + allChildren = 'allChildren', // 对所有子级资源生效(默认) + currentOnly = 'currentOnly' // 仅对当前资源生效 +} + /* new permission */ export enum CommonPerKeyEnum { owner = 'owner', diff --git a/packages/global/support/permission/dataset/constant.ts b/packages/global/support/permission/dataset/constant.ts index 0373249c08c9..2dec0567b8ad 100644 --- a/packages/global/support/permission/dataset/constant.ts +++ b/packages/global/support/permission/dataset/constant.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { NullRoleVal, CommonPerKeyEnum, diff --git a/packages/global/support/permission/evaluation/constant.ts b/packages/global/support/permission/evaluation/constant.ts new file mode 100644 index 000000000000..ddcd06a24868 --- /dev/null +++ b/packages/global/support/permission/evaluation/constant.ts @@ -0,0 +1,21 @@ +import { type PermissionListType, type RoleListType, type RolePerMapType } from '../type'; +import { CommonPerList, CommonRoleList, CommonRolePerMap } from '../constant'; + +// 评估模块权限列表 (沿用通用权限,无特殊权限) +export const EvaluationPerList: PermissionListType = CommonPerList; + +// 评估模块角色列表 (沿用通用角色) +export const EvaluationRoleList: RoleListType = { + ...CommonRoleList +} as const; + +// 评估模块角色权限映射 (沿用通用映射) +export const EvaluationRolePerMap: RolePerMapType = CommonRolePerMap; + +// 评估模块默认权限值 +export const EvaluationDefaultRoleVal = 0; + +// 常用权限值导出 +export const EvaluationReadPermissionVal = EvaluationPerList.read; +export const EvaluationWritePermissionVal = EvaluationPerList.write; +export const EvaluationManagePermissionVal = EvaluationPerList.manage; diff --git a/packages/global/support/permission/evaluation/controller.ts b/packages/global/support/permission/evaluation/controller.ts new file mode 100644 index 000000000000..5e8ed700702e --- /dev/null +++ b/packages/global/support/permission/evaluation/controller.ts @@ -0,0 +1,23 @@ +import { type PerConstructPros, Permission } from '../controller'; +import { + EvaluationDefaultRoleVal, + EvaluationPerList, + EvaluationRoleList, + EvaluationRolePerMap +} from './constant'; + +export class EvaluationPermission extends Permission { + constructor(props?: PerConstructPros) { + if (!props) { + props = { + role: EvaluationDefaultRoleVal + }; + } else if (!props?.role) { + props.role = EvaluationDefaultRoleVal; + } + props.roleList = EvaluationRoleList; + props.rolePerMap = EvaluationRolePerMap; + props.perList = EvaluationPerList; + super(props); + } +} diff --git a/packages/global/support/permission/model/constant.ts b/packages/global/support/permission/model/constant.ts new file mode 100644 index 000000000000..127fa3ce25ff --- /dev/null +++ b/packages/global/support/permission/model/constant.ts @@ -0,0 +1,30 @@ +import { i18nT } from '../../../common/i18n/utils'; +import { + NullRoleVal, + CommonPerKeyEnum, + CommonRoleList, + CommonRolePerMap, + CommonPerList +} from '../constant'; +import type { RolePerMapType } from '../type'; + +export const ModelRoleList = { + [CommonPerKeyEnum.read]: { + ...CommonRoleList[CommonPerKeyEnum.read], + description: i18nT('model:permission.des.read') + }, + [CommonPerKeyEnum.write]: { + ...CommonRoleList[CommonPerKeyEnum.write], + description: i18nT('model:permission.des.write') + }, + [CommonPerKeyEnum.manage]: { + ...CommonRoleList[CommonPerKeyEnum.manage], + description: i18nT('model:permission.des.manage') + } +}; + +export const ModelRolePerMap: RolePerMapType = CommonRolePerMap; + +export const ModelPerList = CommonPerList; + +export const ModelDefaultRoleVal = NullRoleVal; diff --git a/packages/global/support/permission/model/controller.ts b/packages/global/support/permission/model/controller.ts index 36cc347e1dde..18e27651cb15 100644 --- a/packages/global/support/permission/model/controller.ts +++ b/packages/global/support/permission/model/controller.ts @@ -1,3 +1,22 @@ -import { Permission } from '../controller'; - -export class ModelPermission extends Permission {} +import { type PerConstructPros, Permission } from '../controller'; +import { + ModelDefaultRoleVal, + ModelPerList, + ModelRoleList, + ModelRolePerMap +} from './constant'; +export class ModelPermission extends Permission { + constructor(props?: PerConstructPros) { + if (!props) { + props = { + role: ModelDefaultRoleVal + }; + } else if (!props?.role) { + props.role = ModelDefaultRoleVal; + } + props.roleList = ModelRoleList; + props.rolePerMap = ModelRolePerMap; + props.perList = ModelPerList; + super(props); + } +} diff --git a/packages/global/support/permission/user/constant.ts b/packages/global/support/permission/user/constant.ts index bea6ce1a6918..c687b8f809ff 100644 --- a/packages/global/support/permission/user/constant.ts +++ b/packages/global/support/permission/user/constant.ts @@ -6,21 +6,25 @@ import type { RolePerMapType } from '../type'; import { CommonRoleList, CommonPerList } from '../constant'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { sumPer } from '../utils'; export enum TeamPerKeyEnum { appCreate = 'appCreate', datasetCreate = 'datasetCreate', apikeyCreate = 'apikeyCreate', - skillCreate = 'skillCreate' + evaluationCreate = 'evaluationCreate', + skillCreate = 'skillCreate', + modelCreate = 'modelCreate' } export enum TeamRoleKeyEnum { appCreate = 'appCreate', datasetCreate = 'datasetCreate', apikeyCreate = 'apikeyCreate', - skillCreate = 'skillCreate' + evaluationCreate = 'evaluationCreate', + skillCreate = 'skillCreate', + modelCreate = 'modelCreate' } export const TeamPerList: PermissionListType = { @@ -28,7 +32,9 @@ export const TeamPerList: PermissionListType = { apikeyCreate: 0b100000, appCreate: 0b001000, datasetCreate: 0b010000, - skillCreate: 0b1000000 + evaluationCreate: 0b1000000, + skillCreate: 0b10000000, + modelCreate: 0b100000000 }; export const TeamRoleList: RoleListType = { @@ -64,11 +70,23 @@ export const TeamRoleList: RoleListType = { name: i18nT('account_team:permission_apikeyCreate'), value: 0b100000 }, + [TeamRoleKeyEnum.evaluationCreate]: { + checkBoxType: 'multiple', + description: '', + name: i18nT('account_team:permission_evaluationCreate'), + value: 0b1000000 + }, [TeamRoleKeyEnum.skillCreate]: { checkBoxType: 'multiple', description: '', name: i18nT('account_team:permission_skillCreate'), - value: 0b1000000 + value: 0b10000000 + }, + [TeamRoleKeyEnum.modelCreate]: { + checkBoxType: 'multiple', + description: '', + name: i18nT('account_team:permission_modelCreate'), + value: 0b100000000 } }; @@ -90,9 +108,21 @@ export const TeamRolePerMap: RolePerMapType = new Map([ TeamRoleList['apikeyCreate'].value, sumPer(TeamPerList.apikeyCreate, CommonPerList.read, CommonPerList.write) as PermissionValueType ], + [ + TeamRoleList['evaluationCreate'].value, + sumPer( + TeamPerList.evaluationCreate, + CommonPerList.read, + CommonPerList.write + ) as PermissionValueType + ], [ TeamRoleList['skillCreate'].value, sumPer(TeamPerList.skillCreate, CommonPerList.read, CommonPerList.write) as PermissionValueType + ], + [ + TeamRoleList['modelCreate'].value, + sumPer(TeamPerList.modelCreate, CommonPerList.read, CommonPerList.write) as PermissionValueType ] ]); @@ -100,9 +130,11 @@ export const TeamReadRoleVal = TeamRoleList['read'].value; export const TeamWriteRoleVal = TeamRoleList['write'].value; export const TeamManageRoleVal = TeamRoleList['manage'].value; export const TeamAppCreateRoleVal = TeamRoleList['appCreate'].value; +export const TeamEvaluationCreateRoleVal = TeamRoleList['evaluationCreate'].value; export const TeamDatasetCreateRoleVal = TeamRoleList['datasetCreate'].value; export const TeamApikeyCreateRoleVal = TeamRoleList['apikeyCreate'].value; export const TeamSkillCreateRoleVal = TeamRoleList['skillCreate'].value; +export const TeamModelCreateRoleVal = TeamRoleList['modelCreate'].value; export const TeamDefaultRoleVal = TeamReadRoleVal; export const TeamReadPermissionVal = TeamPerList.read; @@ -111,5 +143,7 @@ export const TeamManagePermissionVal = TeamPerList.manage; export const TeamAppCreatePermissionVal = TeamPerList.appCreate; export const TeamDatasetCreatePermissionVal = TeamPerList.datasetCreate; export const TeamApikeyCreatePermissionVal = TeamPerList.apikeyCreate; +export const TeamEvaluationCreatePermissionVal = TeamPerList.evaluationCreate; export const TeamSkillCreatePermissionVal = TeamPerList.skillCreate; +export const TeamModelCreatePermissionVal = TeamPerList.modelCreate; export const TeamDefaultPermissionVal = TeamReadPermissionVal; diff --git a/packages/global/support/permission/user/controller.ts b/packages/global/support/permission/user/controller.ts index 3579305939a5..1d5d156c646b 100644 --- a/packages/global/support/permission/user/controller.ts +++ b/packages/global/support/permission/user/controller.ts @@ -3,7 +3,9 @@ import { TeamApikeyCreateRoleVal, TeamAppCreateRoleVal, TeamDatasetCreateRoleVal, + TeamEvaluationCreateRoleVal, TeamSkillCreateRoleVal, + TeamModelCreateRoleVal, TeamDefaultRoleVal, TeamPerList, TeamRoleList, @@ -14,11 +16,15 @@ export class TeamPermission extends Permission { hasAppCreateRole: boolean = false; hasDatasetCreateRole: boolean = false; hasApikeyCreateRole: boolean = false; + hasEvaluationCreateRole: boolean = false; + hasEvaluationCreatePer: boolean = false; hasSkillCreateRole: boolean = false; + hasModelCreateRole: boolean = false; hasAppCreatePer: boolean = false; hasDatasetCreatePer: boolean = false; hasApikeyCreatePer: boolean = false; hasSkillCreatePer: boolean = false; + hasModelCreatePer: boolean = false; constructor(props?: PerConstructPros) { if (!props) { @@ -37,11 +43,15 @@ export class TeamPermission extends Permission { this.hasAppCreateRole = this.checkRole(TeamAppCreateRoleVal); this.hasDatasetCreateRole = this.checkRole(TeamDatasetCreateRoleVal); this.hasApikeyCreateRole = this.checkRole(TeamApikeyCreateRoleVal); + this.hasEvaluationCreateRole = this.checkRole(TeamEvaluationCreateRoleVal); + this.hasEvaluationCreatePer = this.checkPer(TeamEvaluationCreateRoleVal); this.hasSkillCreateRole = this.checkRole(TeamSkillCreateRoleVal); + this.hasModelCreateRole = this.checkRole(TeamModelCreateRoleVal); this.hasAppCreatePer = this.checkPer(TeamAppCreateRoleVal); this.hasDatasetCreatePer = this.checkPer(TeamDatasetCreateRoleVal); this.hasApikeyCreatePer = this.checkPer(TeamApikeyCreateRoleVal); this.hasSkillCreatePer = this.checkPer(TeamSkillCreateRoleVal); + this.hasModelCreatePer = this.checkPer(TeamModelCreateRoleVal); }); } } diff --git a/packages/global/support/permission/utils.ts b/packages/global/support/permission/utils.ts index c4249487ab6c..8399039f7995 100644 --- a/packages/global/support/permission/utils.ts +++ b/packages/global/support/permission/utils.ts @@ -1,5 +1,5 @@ import type { CollaboratorIdType, CollaboratorItemType } from './collaborator'; -import { ManageRoleVal, OwnerRoleVal } from './constant'; +import { ManageRoleVal, OwnerRoleVal, ReadRoleVal, WriteRoleVal } from './constant'; import type { RoleValueType } from './type'; import { type PermissionValueType } from './type'; /** @@ -23,10 +23,11 @@ export const sumPer = (...per: PermissionValueType[]) => { /** * Check if the update cause conflict (need to remove inheritance permission). - * Conflict condition: - * The updated collaborator is a parent collaborator. + * Conflict rules: + * 1. Parent is Owner -> child must be Manage or Owner, otherwise conflict + * 2. Child is Owner -> parent must be Write or Manage or Owner, otherwise conflict + * 3. Others -> any difference is conflict * @param parentClbs parent collaborators - * @param oldChildClbs old child collaborators * @param newChildClbs new child collaborators */ export const checkRoleUpdateConflict = ({ @@ -36,10 +37,6 @@ export const checkRoleUpdateConflict = ({ parentClbs: CollaboratorItemType[]; newChildClbs: CollaboratorItemType[]; }): boolean => { - if (parentClbs.length === 0) { - return false; - } - // Use a Map for faster lookup by teamId const parentClbRoleMap = new Map( parentClbs.map((clb) => [ @@ -57,7 +54,33 @@ export const checkRoleUpdateConflict = ({ for (const changedClb of changedClbs) { const parent = parentClbRoleMap.get(getCollaboratorId(changedClb)); - if (parent && ((changedClb.changedRole & parent.permission) !== 0 || changedClb.deleted)) { + if (!parent) { + // New collaborator added without parent: only conflict if parentClbs is non-empty + if (parentClbs.length > 0) return true; + continue; + } + + const parentRole = parent.permission; + const childRole = changedClb.permission; + + // Rule 1: Parent is Owner -> child must be Manage or Owner + if (parentRole === OwnerRoleVal) { + if (!(childRole & ManageRoleVal) || changedClb.deleted) { + return true; + } + continue; + } + + // Rule 2: Child is Owner -> parent must be Write or Manage or Owner + if (childRole === OwnerRoleVal) { + if (!parentRole || parentRole === ReadRoleVal) { + return true; + } + continue; + } + + // Rule 3: Others -> any difference is conflict + if (changedClb.changedRole !== 0 || changedClb.deleted) { return true; } } @@ -68,6 +91,7 @@ export const checkRoleUpdateConflict = ({ export type ChangedClbType = { changedRole: RoleValueType; deleted: boolean; + permission: PermissionValueType; } & CollaboratorIdType; /** @@ -145,6 +169,20 @@ export const getChangedCollaborators = ({ export const getCollaboratorId = (clb: CollaboratorIdType) => (clb.tmbId || clb.groupId || clb.orgId)!; +export const getRealCollaboratorList = ({ + isInherit, + parentClbs, + childClbs +}: { + isInherit: boolean; + parentClbs: T[]; + childClbs: T[]; +}): T[] => { + if (!isInherit) return childClbs; + return mergeCollaboratorList({ parentClbs, childClbs }); +}; + +// childClbs 中的所有者可能不同 export const mergeCollaboratorList = ({ parentClbs, childClbs diff --git a/packages/global/support/user/api.d.ts b/packages/global/support/user/api.d.ts new file mode 100644 index 000000000000..77867ff8e305 --- /dev/null +++ b/packages/global/support/user/api.d.ts @@ -0,0 +1,90 @@ +import type { MemberGroupSchemaType } from '../permission/memberGroup/type'; +import { MemberGroupListItemType } from '../permission/memberGroup/type'; +import type { OAuthEnum } from './constant'; +import type { TeamMemberStatusEnum } from './team/constant'; +import type { OrgType } from './team/org/type'; +import type { TeamMemberItemType } from './team/type'; +import type { LangEnum } from '../../common/i18n/type'; +import type { TrackRegisterParams } from '../marketing/type'; + +export type PostLoginProps = { + username: string; + password: string; + code: string; + language?: `${LangEnum}`; +}; + +export type OauthLoginProps = { + type: `${OAuthEnum}`; + callbackUrl: string; + props: Record; + language?: `${LangEnum}`; +} & TrackRegisterParams; + +export type WxLoginProps = { + inviterId?: string; + code: string; + bd_vid?: string; + msclkid?: string; + fastgpt_sem?: string; + sourceDomain?: string; +}; + +export type FastLoginProps = { + token: string; + code: string; +}; + +export type SearchResult = { + members: Omit[]; + orgs: Omit[]; + groups: MemberGroupSchemaType[]; +}; + +export type PostAdminGenerateTokenProps = { + username?: string; + teamId?: string; + tmbId?: string; +}; + +export type AdminGenerateTokenResponse = { + token: string; + userId: string; + username: string; + teamId: string; + tmbId: string; + teamName: string; + teamAvatar?: string; + memberName: string; + avatar: string; + role: string; + status: `${TeamMemberStatusEnum}`; + permission: { + hasManagePer: boolean; + hasWritePer: boolean; + isOwner: boolean; + hasAppCreatePer: boolean; + hasDatasetCreatePer: boolean; + hasApikeyCreatePer: boolean; + }; + balance?: number; +}; + +export type AdminUserTeamsResponse = { + userId: string; + username: string; + teams: { + tmbId: string; + teamId: string; + teamName: string; + teamAvatar: string; + memberName: string; + memberAvatar: string; + role: string; + status: `${TeamMemberStatusEnum}`; + createTime: Date; + balance: number; + }[]; + totalCount: number; + activeCount: number; +}; diff --git a/packages/global/support/user/audit/constants.ts b/packages/global/support/user/audit/constants.ts index e631186655a6..816e436aecd1 100644 --- a/packages/global/support/user/audit/constants.ts +++ b/packages/global/support/user/audit/constants.ts @@ -26,7 +26,9 @@ export enum AdminAuditEventEnum { ADMIN_UPDATE_SYSTEM_MODAL = 'ADMIN_UPDATE_SYSTEM_MODAL', ADMIN_SEND_SYSTEM_INFORM = 'ADMIN_SEND_SYSTEM_INFORM', ADMIN_UPDATE_ACTIVITY_AD = 'ADMIN_UPDATE_ACTIVITY_AD', - ADMIN_UPDATE_OPERATIONAL_AD = 'ADMIN_UPDATE_OPERATIONAL_AD' + ADMIN_UPDATE_OPERATIONAL_AD = 'ADMIN_UPDATE_OPERATIONAL_AD', + ADMIN_UPDATE_MODEL_DEFAULT = 'ADMIN_UPDATE_MODEL_DEFAULT', + ADMIN_UPDATE_MODEL_WITH_JSON = 'ADMIN_UPDATE_MODEL_WITH_JSON' } export enum AuditEventEnum { @@ -59,6 +61,7 @@ export enum AuditEventEnum { UPDATE_APP_PUBLISH_CHANNEL = 'UPDATE_APP_PUBLISH_CHANNEL', DELETE_APP_PUBLISH_CHANNEL = 'DELETE_APP_PUBLISH_CHANNEL', EXPORT_APP_CHAT_LOG = 'EXPORT_APP_CHAT_LOG', + EXPORT_APP_AS_SKILL = 'EXPORT_APP_AS_SKILL', CREATE_EVALUATION = 'CREATE_EVALUATION', EXPORT_EVALUATION = 'EXPORT_EVALUATION', DELETE_EVALUATION = 'DELETE_EVALUATION', @@ -77,10 +80,46 @@ export enum AuditEventEnum { UPDATE_COLLECTION = 'UPDATE_COLLECTION', DELETE_COLLECTION = 'DELETE_COLLECTION', RETRAIN_COLLECTION = 'RETRAIN_COLLECTION', + UPDATE_COLLECTION_COLLABORATOR = 'UPDATE_COLLECTION_COLLABORATOR', + DELETE_COLLECTION_COLLABORATOR = 'DELETE_COLLECTION_COLLABORATOR', //Data CREATE_DATA = 'CREATE_DATA', UPDATE_DATA = 'UPDATE_DATA', DELETE_DATA = 'DELETE_DATA', + //Evaluation Dataset + CREATE_EVALUATION_DATASET_COLLECTION = 'CREATE_EVALUATION_DATASET_COLLECTION', + UPDATE_EVALUATION_DATASET_COLLECTION = 'UPDATE_EVALUATION_DATASET_COLLECTION', + DELETE_EVALUATION_DATASET_COLLECTION = 'DELETE_EVALUATION_DATASET_COLLECTION', + CREATE_EVALUATION_DATASET_DATA = 'CREATE_EVALUATION_DATASET_DATA', + UPDATE_EVALUATION_DATASET_DATA = 'UPDATE_EVALUATION_DATASET_DATA', + DELETE_EVALUATION_DATASET_DATA = 'DELETE_EVALUATION_DATASET_DATA', + QUALITY_ASSESSMENT_EVALUATION_DATA = 'QUALITY_ASSESSMENT_EVALUATION_DATA', + SMART_GENERATE_EVALUATION_DATA = 'SMART_GENERATE_EVALUATION_DATA', + DELETE_EVALUATION_DATASET_TASK = 'DELETE_EVALUATION_DATASET_TASK', + RETRY_EVALUATION_DATASET_TASK = 'RETRY_EVALUATION_DATASET_TASK', + IMPORT_EVALUATION_DATASET_DATA = 'IMPORT_EVALUATION_DATASET_DATA', + //Evaluation Task + CREATE_EVALUATION_TASK = 'CREATE_EVALUATION_TASK', + UPDATE_EVALUATION_TASK = 'UPDATE_EVALUATION_TASK', + DELETE_EVALUATION_TASK = 'DELETE_EVALUATION_TASK', + START_EVALUATION_TASK = 'START_EVALUATION_TASK', + STOP_EVALUATION_TASK = 'STOP_EVALUATION_TASK', + RETRY_EVALUATION_TASK = 'RETRY_EVALUATION_TASK', + //Evaluation Task Item + DELETE_EVALUATION_TASK_ITEM = 'DELETE_EVALUATION_TASK_ITEM', + UPDATE_EVALUATION_TASK_ITEM = 'UPDATE_EVALUATION_TASK_ITEM', + RETRY_EVALUATION_TASK_ITEM = 'RETRY_EVALUATION_TASK_ITEM', + EXPORT_EVALUATION_TASK_ITEMS = 'EXPORT_EVALUATION_TASK_ITEMS', + // Evaluation Metric + CREATE_EVALUATION_METRIC = 'CREATE_EVALUATION_METRIC', + UPDATE_EVALUATION_METRIC = 'UPDATE_EVALUATION_METRIC', + DELETE_EVALUATION_METRIC = 'DELETE_EVALUATION_METRIC', + DEBUG_EVALUATION_METRIC = 'DEBUG_EVALUATION_METRIC', + //Evaluation Task DataItem Aggregation + DELETE_EVALUATION_TASK_DATA_ITEM = 'DELETE_EVALUATION_TASK_DATA_ITEM', + UPDATE_EVALUATION_TASK_DATA_ITEM = 'UPDATE_EVALUATION_TASK_DATA_ITEM', + RETRY_EVALUATION_TASK_DATA_ITEM = 'RETRY_EVALUATION_TASK_DATA_ITEM', + EXPORT_EVALUATION_TASK_DATA_ITEMS = 'EXPORT_EVALUATION_TASK_DATA_ITEMS', //SearchTest SEARCH_TEST = 'SEARCH_TEST', //Account @@ -94,6 +133,25 @@ export enum AuditEventEnum { CREATE_API_KEY = 'CREATE_API_KEY', UPDATE_API_KEY = 'UPDATE_API_KEY', DELETE_API_KEY = 'DELETE_API_KEY', + GENERATE_EVALUATION_SUMMARY = 'GENERATE_EVALUATION_SUMMARY', + UPDATE_EVALUATION_SUMMARY_CONFIG = 'UPDATE_EVALUATION_SUMMARY_CONFIG', + //Rerank Train + CREATE_RERANK_TRAINSET = 'CREATE_RERANK_TRAINSET', + DELETE_RERANK_TRAINSET = 'DELETE_RERANK_TRAINSET', + GENERATE_RERANK_TRAINSET_DATA = 'GENERATE_RERANK_TRAINSET_DATA', + CREATE_RERANK_TRAIN_TASK = 'CREATE_RERANK_TRAIN_TASK', + DELETE_RERANK_TRAIN_TASK = 'DELETE_RERANK_TRAIN_TASK', + RETRY_RERANK_TRAIN_TASK = 'RETRY_RERANK_TRAIN_TASK', + CANCEL_RERANK_TRAIN_TASK = 'CANCEL_RERANK_TRAIN_TASK', + + //Embedding Train + CREATE_EMBEDDING_TRAINSET = 'CREATE_EMBEDDING_TRAINSET', + DELETE_EMBEDDING_TRAINSET = 'DELETE_EMBEDDING_TRAINSET', + GENERATE_EMBEDDING_TRAINSET_DATA = 'GENERATE_EMBEDDING_TRAINSET_DATA', + CREATE_EMBEDDING_TRAIN_TASK = 'CREATE_EMBEDDING_TRAIN_TASK', + DELETE_EMBEDDING_TRAIN_TASK = 'DELETE_EMBEDDING_TRAIN_TASK', + RETRY_EMBEDDING_TRAIN_TASK = 'RETRY_EMBEDDING_TRAIN_TASK', + CANCEL_EMBEDDING_TRAIN_TASK = 'CANCEL_EMBEDDING_TRAIN_TASK', //Agent Skills CREATE_SKILL = 'CREATE_SKILL', UPDATE_SKILL = 'UPDATE_SKILL', @@ -106,7 +164,13 @@ export enum AuditEventEnum { MOVE_SKILL = 'MOVE_SKILL', UPDATE_SKILL_COLLABORATOR = 'UPDATE_SKILL_COLLABORATOR', DELETE_SKILL_COLLABORATOR = 'DELETE_SKILL_COLLABORATOR', - TRANSFER_SKILL_OWNERSHIP = 'TRANSFER_SKILL_OWNERSHIP' + TRANSFER_SKILL_OWNERSHIP = 'TRANSFER_SKILL_OWNERSHIP', + //Model + CREATE_MODEL = 'CREATE_MODEL', + UPDATE_MODEL = 'UPDATE_MODEL', + DELETE_MODEL = 'DELETE_MODEL', + UPDATE_MODEL_COLLABORATOR = 'UPDATE_MODEL_COLLABORATOR', + DELETE_MODEL_COLLABORATOR = 'DELETE_MODEL_COLLABORATOR' } export type AuditEventParamsType = { diff --git a/packages/global/support/user/inform/constants.ts b/packages/global/support/user/inform/constants.ts index 206628a927ee..71e8fd367442 100644 --- a/packages/global/support/user/inform/constants.ts +++ b/packages/global/support/user/inform/constants.ts @@ -1,3 +1,5 @@ +import { i18nT } from '../../../common/i18n/utils'; + export enum InformLevelEnum { 'common' = 'common', 'important' = 'important', @@ -6,13 +8,13 @@ export enum InformLevelEnum { export const InformLevelMap = { [InformLevelEnum.common]: { - label: '普通' + label: i18nT('admin:level_normal_text') }, [InformLevelEnum.important]: { - label: '重要' + label: i18nT('admin:level_important_text') }, [InformLevelEnum.emergency]: { - label: '紧急' + label: i18nT('admin:level_urgent_text') } }; diff --git a/packages/global/support/user/inform/type.ts b/packages/global/support/user/inform/type.ts index e4e13a9fac7f..a946d69fc632 100644 --- a/packages/global/support/user/inform/type.ts +++ b/packages/global/support/user/inform/type.ts @@ -1,7 +1,7 @@ import type { InformLevelEnum, SendInformTemplateCodeEnum } from './constants'; export type SendInformProps = { - level: `${InformLevelEnum}`; + level: string; templateCode: `${SendInformTemplateCodeEnum}`; templateParam: Record; customLockMinutes?: number; // custom lock minutes diff --git a/packages/global/support/user/team/type.ts b/packages/global/support/user/team/type.ts index 46f0c675274e..5b44a0c1da64 100644 --- a/packages/global/support/user/team/type.ts +++ b/packages/global/support/user/team/type.ts @@ -2,7 +2,7 @@ import type { TeamMetaType, UserModelSchema } from '../type'; import { TeamMemberRoleEnum, TeamMemberStatusEnum } from './constant'; import type { GroupMemberRole } from '../../permission/memberGroup/constant'; import { TeamPermission } from '../../permission/user/controller'; -import { z } from 'zod'; +import z from 'zod'; export const LafAccountSchema = z.object({ appid: z.string(), diff --git a/packages/global/support/wallet/bill/constants.ts b/packages/global/support/wallet/bill/constants.ts index 23702480d0de..f3b7f1995545 100644 --- a/packages/global/support/wallet/bill/constants.ts +++ b/packages/global/support/wallet/bill/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export enum BillTypeEnum { balance = 'balance', diff --git a/packages/global/support/wallet/bill/type.ts b/packages/global/support/wallet/bill/type.ts index 13be96850487..c84792818d02 100644 --- a/packages/global/support/wallet/bill/type.ts +++ b/packages/global/support/wallet/bill/type.ts @@ -45,7 +45,7 @@ export type ChatNodeUsageType = { outputTokens?: number; totalPoints: number; moduleName: string; - model?: string; + modelId?: string; }; export type InvoiceType = { diff --git a/packages/global/support/wallet/sub/constants.ts b/packages/global/support/wallet/sub/constants.ts index dac2f574afff..34db966c7ad0 100644 --- a/packages/global/support/wallet/sub/constants.ts +++ b/packages/global/support/wallet/sub/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; import { BillTypeEnum } from '../bill/constants'; export enum SubTypeEnum { diff --git a/packages/global/support/wallet/sub/discountCoupon/constants.ts b/packages/global/support/wallet/sub/discountCoupon/constants.ts index b03bb4fe2ce8..0a10ac20ed3c 100644 --- a/packages/global/support/wallet/sub/discountCoupon/constants.ts +++ b/packages/global/support/wallet/sub/discountCoupon/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../common/i18n/utils'; export enum DiscountCouponTypeEnum { monthStandardDiscount70 = 'monthStandardDiscount70', diff --git a/packages/global/support/wallet/sub/type.ts b/packages/global/support/wallet/sub/type.ts index 40e8ecadbaaf..9b5ccf66f9d2 100644 --- a/packages/global/support/wallet/sub/type.ts +++ b/packages/global/support/wallet/sub/type.ts @@ -27,6 +27,10 @@ export const TeamStandardSubPlanItemSchema = z.object({ maxUploadFileSize: z.int().optional(), // 最大上传文件大小(MB) maxUploadFileCount: z.int().optional(), // 最大上传文件数量 + maxEvaluationTaskAmount: z.int().optional(), + maxEvalDatasetAmount: z.int().optional(), + maxEvalDatasetDataAmount: z.int().optional(), + maxEvalMetricAmount: z.int().optional(), enableSandbox: z.boolean().optional(), // 虚拟机 // 定制套餐 @@ -110,6 +114,10 @@ export const TeamSubSchema = z.object({ customDomain: z.int().optional(), maxUploadFileSize: z.int().optional(), maxUploadFileCount: z.int().optional(), + maxEvaluationTaskAmount: z.int().optional(), + maxEvalDatasetAmount: z.int().optional(), + maxEvalDatasetDataAmount: z.int().optional(), + maxEvalMetricAmount: z.int().optional(), enableSandbox: z.boolean().optional() // 虚拟机 }); export type TeamSubSchemaType = z.infer; @@ -149,6 +157,10 @@ export const ClientTeamPlanStatusSchema = TeamPlanStatusSchema.extend({ usedAppAmount: z.int(), usedDatasetSize: z.int(), usedDatasetIndexSize: z.int(), - usedRegistrationCount: z.int() + usedRegistrationCount: z.int(), + usedEvaluationTaskAmount: z.int(), + usedEvalDatasetAmount: z.int(), + usedEvalDatasetDataAmount: z.int(), + usedEvalMetricAmount: z.int() }); export type ClientTeamPlanStatusType = z.infer; diff --git a/packages/global/support/wallet/usage/api.ts b/packages/global/support/wallet/usage/api.ts index ffb1494e715a..f24f65862d84 100644 --- a/packages/global/support/wallet/usage/api.ts +++ b/packages/global/support/wallet/usage/api.ts @@ -6,11 +6,16 @@ export type CreateTrainingUsageProps = { datasetId: string; }; +export type MemberFilterType = + | { type: 'member'; memberIds: string[] } + | { type: 'group'; groupIds: string[] } + | { type: 'org'; orgIds: string[] }; + export type GetUsageProps = { dateStart: string; dateEnd: string; sources?: UsageSourceEnum[]; - teamMemberIds?: string[]; + memberFilter?: MemberFilterType; projectName?: string; }; @@ -20,6 +25,8 @@ export type GetUsageDashboardProps = GetUsageProps & { export type GetUsageDashboardResponseItem = { date: Date; totalPoints: number; + inputTokens: number; + outputTokens: number; }; export type CreateUsageProps = Omit; diff --git a/packages/global/support/wallet/usage/constants.ts b/packages/global/support/wallet/usage/constants.ts index fd7e15d49aa1..0f9a558282cb 100644 --- a/packages/global/support/wallet/usage/constants.ts +++ b/packages/global/support/wallet/usage/constants.ts @@ -1,4 +1,4 @@ -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../common/i18n/utils'; export enum UsageSourceEnum { fastgpt = 'fastgpt', @@ -83,5 +83,11 @@ export enum UsageItemTypeEnum { training_imageParse = 6, evaluation_generateAnswer = 7, - evaluation_answerAccuracy = 8 + evaluation_answerAccuracy = 8, + + training_synthesis = 9, + training_hypeIndex = 10, + + evaluation_metricsExecute = 11, + evaluation_summaryGeneration = 12 } diff --git a/packages/global/support/wallet/usage/type.ts b/packages/global/support/wallet/usage/type.ts index 1590bd07835a..1708bfaad290 100644 --- a/packages/global/support/wallet/usage/type.ts +++ b/packages/global/support/wallet/usage/type.ts @@ -13,6 +13,8 @@ export type UsageSchemaType = { appId?: string; datasetId?: string; + inputTokens?: number; + outputTokens?: number; /** @deprecated */ list?: UsageItemType[]; @@ -28,7 +30,7 @@ export type UsageItemSchemaType = { } & UsageItemCountType; export type UsageItemCountType = { - model?: string; + modelId?: string; inputTokens?: number; outputTokens?: number; charsLength?: number; @@ -52,6 +54,8 @@ export type UsageListItemType = { appName: string; source: UsageSchemaType['source']; totalPoints: number; + inputTokens: number; + outputTokens: number; list: Omit[]; sourceMember: SourceMemberType; }; diff --git a/packages/service/common/api/axios.ts b/packages/service/common/api/axios.ts index 9572da438737..8aa0fc407da5 100644 --- a/packages/service/common/api/axios.ts +++ b/packages/service/common/api/axios.ts @@ -18,3 +18,4 @@ export function createProxyAxios(config?: AxiosRequestConfig) { /** @see https://github.com/axios/axios/issues/4531 */ export const axios = createProxyAxios(); + diff --git a/packages/service/common/api/pagination.ts b/packages/service/common/api/pagination.ts index b2d1118f86df..dd808715f433 100644 --- a/packages/service/common/api/pagination.ts +++ b/packages/service/common/api/pagination.ts @@ -1,5 +1,6 @@ import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { type ApiRequestProps } from '../../type/next'; +import type { SortOrder } from '@fastgpt/global/core/train/common/api'; export const parsePaginationRequest = (req: ApiRequestProps) => { const { @@ -19,3 +20,41 @@ export const parsePaginationRequest = (req: ApiRequestProps) => { offset: offset ? Number(offset) : (Number(pageNum) - 1) * Number(pageSize) }; }; + +/** + * Parse sort parameters from request + * + * @param req - API request + * @param defaultField - Default sort field + * @param defaultOrder - Default sort order + * @param allowedFields - Allowed sort fields + * @returns MongoDB sort object + */ +export function parseSortParams( + req: ApiRequestProps, + defaultField: T, + defaultOrder: SortOrder = 'desc', + allowedFields?: T[] +): Record { + const { sortField = defaultField, sortOrder = defaultOrder } = Object.keys(req.body).includes( + 'sortField' + ) + ? req.body + : Object.keys(req.query).includes('sortField') + ? req.query + : {}; + + // Validate field if allowedFields is provided + if (allowedFields && !allowedFields.includes(sortField as T)) { + throw new Error(`Invalid sort field: ${sortField}. Allowed: ${allowedFields.join(', ')}`); + } + + // Validate order + if (sortOrder !== 'asc' && sortOrder !== 'desc') { + throw new Error(`Invalid sort order: ${sortOrder}. Must be 'asc' or 'desc'`); + } + + return { + [sortField]: sortOrder === 'asc' ? 1 : -1 + }; +} diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts index d7f3b669ee19..ba3f2d322a23 100644 --- a/packages/service/common/bullmq/index.ts +++ b/packages/service/common/bullmq/index.ts @@ -10,6 +10,7 @@ import './type'; import { getLogger, LogCategories } from '../logger'; import { newQueueRedisConnection, newWorkerRedisConnection } from '../redis'; import { delay } from '@fastgpt/global/common/system/utils'; +import { isClusterMode } from '../redis/config'; import { getErrText } from '@fastgpt/global/common/error/utils'; const logger = getLogger(LogCategories.INFRA.QUEUE); @@ -25,12 +26,22 @@ const defaultWorkerOpts: Omit = { export enum QueueNames { datasetSync = 'datasetSync', + rerankTrainDataGenerate = 'rerankTrainDataGenerate', + rerankTrainTask = 'rerankTrainTask', + embeddingTrainDataGenerate = 'embeddingTrainDataGenerate', + embeddingTrainTask = 'embeddingTrainTask', + evalDatasetDataQuality = 'evalDatasetDataQuality', + evalDatasetDataSynthesize = 'evalDatasetDataSynthesize', + evalTask = 'evalTask', + evalTaskItem = 'evalTaskitem', + evaluationSummary = 'evaluationSummary', evaluation = 'evaluation', s3FileDelete = 's3FileDelete', collectionUpdate = 'collectionUpdate', // Delete Queue datasetDelete = 'datasetDelete', + collectionDelete = 'collectionDelete', appDelete = 'appDelete', teamDelete = 'teamDelete', @@ -63,10 +74,18 @@ export function getQueue( if (queue) { return queue as Queue; } - const newQueue = new Queue(name.toString(), { + + const queueOptions: QueueOptions = { connection: newQueueRedisConnection(), ...opts - }); + }; + + // Add prefix for cluster mode to ensure hash tag grouping + if (isClusterMode()) { + queueOptions.prefix = '{bull}'; // Hash tag ensures same slot + } + + const newQueue = new Queue(name.toString(), queueOptions); // default error handler, to avoid unhandled exceptions newQueue.on('error', (error) => { @@ -90,7 +109,7 @@ export function getWorker( } const createWorker = () => { - const newWorker = new Worker(name.toString(), processor, { + const workerOptions: WorkerOptions = { connection: newWorkerRedisConnection(), ...defaultWorkerOpts, // BullMQ Worker important settings @@ -98,7 +117,14 @@ export function getWorker( stalledInterval: 30000, // Check for stalled jobs every 30s maxStalledCount: 3, // Move job to failed after 1 stall (default behavior) ...opts - }); + }; + + // Add prefix for cluster mode (must match queue prefix) + if (isClusterMode()) { + workerOptions.prefix = '{bull}'; // Must match queue prefix + } + + const newWorker = new Worker(name.toString(), processor, workerOptions); // Worker is ready to process jobs (fired on initial connection and after reconnection) newWorker.on('ready', () => { @@ -111,6 +137,9 @@ export function getWorker( error }); }); + newWorker.on('failed', (_, error) => { + logger.error(`MQ Worker [${name}]: ${error.message}`, error); + }); // Critical: Worker has been closed - remove from pool and restart newWorker.on('closed', async () => { logger.warn('BullMQ worker closed, attempting restart', { name }); diff --git a/packages/service/common/bullmq/utils.ts b/packages/service/common/bullmq/utils.ts new file mode 100644 index 000000000000..cd153c5f3724 --- /dev/null +++ b/packages/service/common/bullmq/utils.ts @@ -0,0 +1,276 @@ +import type { Queue, Job } from 'bullmq'; +import { addLog } from '../system/log'; + +export interface JobCleanupResult { + queue: string; + totalJobs: number; + removedJobs: number; + failedRemovals: number; + errors: Array<{ + jobId: string; + error: string; + }>; +} + +export interface JobCleanupOptions { + forceCleanActiveJobs?: boolean; + retryAttempts?: number; + retryDelay?: number; +} + +const DEFAULT_OPTIONS: JobCleanupOptions = { + forceCleanActiveJobs: false, + retryAttempts: 3, + retryDelay: 1000 +}; + +export class RobustJobCleaner { + private options: JobCleanupOptions; + + constructor(options: JobCleanupOptions = {}) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + async cleanAllJobsByFilter( + queue: Queue, + filterFn: (job: Job) => boolean, + queueName: string + ): Promise { + const result: JobCleanupResult = { + queue: queueName, + totalJobs: 0, + removedJobs: 0, + failedRemovals: 0, + errors: [] + }; + + try { + // Get all possible job states + const jobStates = ['waiting', 'active', 'completed', 'failed', 'delayed', 'prioritized']; + const jobsByState: Record[]> = {}; + let totalMatchingJobs = 0; + + // Fetch jobs from all states and group them + for (const state of jobStates) { + try { + const jobs = await queue.getJobs([state as any]); + const filteredJobs = jobs.filter(filterFn); + + if (filteredJobs.length > 0) { + jobsByState[state] = filteredJobs; + totalMatchingJobs += filteredJobs.length; + } + } catch (error) { + addLog.warn(`Failed to get jobs from state ${state} in queue ${queueName}`, { error }); + } + } + + result.totalJobs = totalMatchingJobs; + + if (totalMatchingJobs === 0) { + addLog.info(`No jobs found to clean in queue ${queueName}`); + return result; + } + + // Clean non-active jobs first + await this.cleanJobsByStates( + jobsByState, + ['waiting', 'delayed', 'prioritized', 'completed', 'failed'], + result + ); + + // Handle active jobs if force cleanup is enabled + if (this.options.forceCleanActiveJobs && jobsByState.active?.length > 0) { + await this.cleanActiveJobs(jobsByState.active, result); + } else if (jobsByState.active?.length > 0) { + addLog.warn( + `${jobsByState.active.length} active jobs found but force cleanup is disabled`, + { queue: queueName } + ); + } + + addLog.info('Job cleanup completed', { + queue: queueName, + totalJobs: result.totalJobs, + removedJobs: result.removedJobs, + failedRemovals: result.failedRemovals + }); + + return result; + } catch (error) { + addLog.error('Fatal error during job cleanup', { queue: queueName, error }); + result.errors.push({ + jobId: 'FATAL', + error: error instanceof Error ? error.message : 'Unknown error' + }); + return result; + } + } + + private async cleanJobsByStates( + jobsByState: Record[]>, + states: string[], + result: JobCleanupResult + ): Promise { + for (const state of states) { + const jobs = jobsByState[state]; + if (!jobs || jobs.length === 0) continue; + + addLog.info(`Cleaning ${jobs.length} jobs in state: ${state}`, { queue: result.queue }); + + // Use Promise.allSettled to handle individual failures gracefully + const removePromises = jobs.map((job) => this.removeJobWithRetry(job)); + const removeResults = await Promise.allSettled(removePromises); + + removeResults.forEach((removeResult, index) => { + if (removeResult.status === 'fulfilled') { + if (removeResult.value.success) { + result.removedJobs++; + } else { + result.failedRemovals++; + if (removeResult.value.error) { + result.errors.push(removeResult.value.error); + } + } + } else { + result.failedRemovals++; + result.errors.push({ + jobId: jobs[index].id || 'unknown', + error: removeResult.reason?.message || 'Promise rejected' + }); + } + }); + } + } + + private async cleanActiveJobs(activeJobs: Job[], result: JobCleanupResult): Promise { + addLog.warn( + `Force cleaning ${activeJobs.length} active jobs - this may interrupt running processes`, + { queue: result.queue } + ); + + // For active jobs, we need a more aggressive approach + const removePromises = activeJobs.map((job) => this.forceRemoveActiveJob(job)); + const removeResults = await Promise.allSettled(removePromises); + + removeResults.forEach((removeResult, index) => { + if (removeResult.status === 'fulfilled') { + if (removeResult.value.success) { + result.removedJobs++; + } else { + result.failedRemovals++; + if (removeResult.value.error) { + result.errors.push(removeResult.value.error); + } + } + } else { + result.failedRemovals++; + result.errors.push({ + jobId: activeJobs[index].id || 'unknown', + error: removeResult.reason?.message || 'Promise rejected' + }); + } + }); + } + + private async removeJobWithRetry(job: Job): Promise<{ + success: boolean; + error?: { jobId: string; error: string }; + }> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.options.retryAttempts!; attempt++) { + try { + await job.remove(); + return { success: true }; + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + + if (attempt < this.options.retryAttempts!) { + addLog.warn(`Job removal attempt ${attempt} failed, retrying...`, { + jobId: job.id, + error: lastError.message + }); + await this.sleep(this.options.retryDelay!); + } + } + } + + return { + success: false, + error: { + jobId: job.id || 'unknown', + error: lastError?.message || 'Failed to remove job after retries' + } + }; + } + + private async forceRemoveActiveJob(job: Job): Promise<{ + success: boolean; + error?: { jobId: string; error: string }; + }> { + try { + // First try normal removal + try { + await job.remove(); + return { success: true }; + } catch (normalError) { + // If normal removal fails for active job, try more aggressive methods + addLog.warn(`Normal removal failed for active job, trying force removal`, { + jobId: job.id, + error: normalError + }); + + // Try to move the job to failed state first, then remove + try { + await job.moveToFailed(new Error('Force cleanup'), 'cleanup'); + await job.remove(); + return { success: true }; + } catch (forceError) { + // Last resort: just mark it as completed and remove + try { + await job.moveToCompleted('force-cleaned', 'cleanup'); + await job.remove(); + return { success: true }; + } catch (lastResortError) { + return { + success: false, + error: { + jobId: job.id || 'unknown', + error: `All removal methods failed: ${lastResortError}` + } + }; + } + } + } + } catch (error) { + return { + success: false, + error: { + jobId: job.id || 'unknown', + error: error instanceof Error ? error.message : 'Unknown error' + } + }; + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const createJobCleaner = (options?: JobCleanupOptions): RobustJobCleaner => { + return new RobustJobCleaner(options); +}; + +export const checkBullMQHealth = async (queue: Queue, queueName: string): Promise => { + try { + await queue.isPaused(); + await queue.getWaiting(0, 0); + } catch (error) { + addLog.error(`BullMQ ${queueName} queue health check failed:`, error); + throw new Error( + `BullMQ ${queueName} queue is not responding. Please check Redis connection and queue status.` + ); + } +}; diff --git a/packages/service/common/file/csv.ts b/packages/service/common/file/csv.ts index 86a18f698e2e..b8f3609fa75b 100644 --- a/packages/service/common/file/csv.ts +++ b/packages/service/common/file/csv.ts @@ -1,3 +1,6 @@ +import xlsx from 'node-xlsx'; +import Papa from 'papaparse'; + // Function to escape CSV fields to prevent injection attacks export const sanitizeCsvField = (field: String): string => { if (field == null) return ''; @@ -31,3 +34,26 @@ export const generateCsv = (headers: string[], data: string[][]) => { const csv = [sanitizedHeaders.join(','), ...sanitizedData.map((row) => row.join(','))].join('\n'); return csv; }; + +/** + * 将Excel buffer转换为CSV字符串(通用版本) + * 只读取第一个sheet + * @param buffer - Excel文件buffer + * @returns CSV字符串,如果文件为空则返回空字符串 + */ +export function excelBufferToCSV(buffer: Buffer): string { + const sheets = xlsx.parse(buffer, { + //skipHidden: false, + defval: '' + }); + + if (sheets.length === 0 || !sheets[0].data || sheets[0].data.length === 0) { + return ''; + } + + return Papa.unparse(sheets[0].data, { + quotes: false, + delimiter: ',', + newline: '\n' + }); +} diff --git a/packages/service/common/file/encoding.ts b/packages/service/common/file/encoding.ts new file mode 100644 index 000000000000..07d1ad7e1f6a --- /dev/null +++ b/packages/service/common/file/encoding.ts @@ -0,0 +1,144 @@ +import * as iconv from 'iconv-lite'; +import { addLog } from '../system/log'; + +/** + * GBK编码检测阈值 + * 当采样数据中符合GBK编码规则的字节比例超过此阈值时,判定为GBK编码 + */ +const GBK_DETECTION_THRESHOLD = 0.9; + +/** + * 检测Buffer的编码并返回解码结果 + * 支持BOM检测和启发式编码检测(UTF-8、GBK等) + * + * @param buffer - 文件Buffer + * @returns 解码后的字符串和检测到的编码 + */ +export interface DecodeResult { + content: string; + encoding: string; +} + +export function detectAndDecodeBuffer(buffer: Buffer): DecodeResult { + let offset = 0; + let encoding: string = 'utf-8'; + + // 1. 检测BOM (Byte Order Mark) + // UTF-8 BOM: EF BB BF + if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { + offset = 3; + encoding = 'utf-8'; + } + // UTF-16 LE BOM: FF FE + else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + offset = 2; + encoding = 'utf-16le'; + } + // UTF-16 BE BOM: FE FF + else if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { + offset = 2; + encoding = 'utf-16be'; + } + // 2. 无BOM,使用启发式方法检测编码 + else { + const sample = buffer.slice(0, Math.min(buffer.length, 1024)); + + // 先检测GBK特征(中文编码) + // GBK的第一个字节范围: 0x81-0xFE,第二个字节范围: 0x40-0xFE + let gbkLikeBytes = 0; + for (let i = 0; i < sample.length - 1; i++) { + if ( + sample[i] >= 0x81 && + sample[i] <= 0xfe && + sample[i + 1] >= 0x40 && + sample[i + 1] <= 0xfe + ) { + gbkLikeBytes += 2; + i++; // 跳过第二个字节 + } + } + + // 如果GBK特征明显(超过设定阈值的字节符合GBK编码规则),优先使用GBK + const gbkRatio = sample.length > 0 ? gbkLikeBytes / sample.length : 0; + if (gbkRatio > GBK_DETECTION_THRESHOLD) { + encoding = 'gbk'; + addLog.debug( + `[detectAndDecodeBuffer] GBK detection: gbkLikeBytes=${gbkLikeBytes}, sample.length=${sample.length}, ratio=${gbkRatio.toFixed(3)}, threshold=${GBK_DETECTION_THRESHOLD}` + ); + } else { + // 验证UTF-8有效性:检查字节序列是否符合UTF-8编码规则 + let isValidUtf8 = true; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + // ASCII字符 (0x00-0x7F) + if (byte <= 0x7f) { + continue; + } + // 2字节UTF-8 (110xxxxx 10xxxxxx) + else if (byte >= 0xc2 && byte <= 0xdf) { + if (i + 1 >= sample.length || (sample[i + 1] & 0xc0) !== 0x80) { + isValidUtf8 = false; + break; + } + i += 1; + } + // 3字节UTF-8 (1110xxxx 10xxxxxx 10xxxxxx) + else if (byte >= 0xe0 && byte <= 0xef) { + if ( + i + 2 >= sample.length || + (sample[i + 1] & 0xc0) !== 0x80 || + (sample[i + 2] & 0xc0) !== 0x80 + ) { + isValidUtf8 = false; + break; + } + i += 2; + } + // 4字节UTF-8 (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx) + else if (byte >= 0xf0 && byte <= 0xf4) { + if ( + i + 3 >= sample.length || + (sample[i + 1] & 0xc0) !== 0x80 || + (sample[i + 2] & 0xc0) !== 0x80 || + (sample[i + 3] & 0xc0) !== 0x80 + ) { + isValidUtf8 = false; + break; + } + i += 3; + } + // 无效的UTF-8字节 + else { + isValidUtf8 = false; + break; + } + } + + encoding = isValidUtf8 ? 'utf-8' : 'gbk'; + addLog.debug(`[detectAndDecodeBuffer] UTF-8 validation: isValidUtf8=${isValidUtf8}`); + } + } + + // 3. 跳过BOM并解码 + const contentBuffer = offset > 0 ? buffer.slice(offset) : buffer; + + // Debug: 打印检测到的编码 + addLog.debug( + `[detectAndDecodeBuffer] Detected encoding: ${encoding}, Buffer size: ${buffer.length}, BOM offset: ${offset}` + ); + + // TextDecoder(V8 原生 ICU)对 GBK 比 iconv-lite 快约 30 倍,对 UTF-8 性能相当。 + // Node.js 18+ 带完整 ICU 数据,支持 GBK / UTF-8 / UTF-16。 + // 降级到 iconv-lite 兜底极少数不支持的编码。 + try { + return { + content: new TextDecoder(encoding).decode(contentBuffer), + encoding + }; + } catch { + return { + content: iconv.decode(contentBuffer, encoding), + encoding + }; + } +} diff --git a/packages/service/common/file/gridfs/controller.ts b/packages/service/common/file/gridfs/controller.ts index 0ca698066546..e3a2b4f0b927 100644 --- a/packages/service/common/file/gridfs/controller.ts +++ b/packages/service/common/file/gridfs/controller.ts @@ -1,10 +1,12 @@ import { Types, connectionMongo, ReadPreference } from '../../mongo'; import type { BucketNameEnum } from '@fastgpt/global/common/file/constants'; -import { MongoChatFileSchema, MongoDatasetFileSchema } from './schema'; +import { MongoChatFileSchema, MongoDatasetFileSchema, MongoEvaluationFileSchema } from './schema'; +import { addLog } from '../../system/log'; export function getGFSCollection(bucket: `${BucketNameEnum}`) { MongoDatasetFileSchema; MongoChatFileSchema; + MongoEvaluationFileSchema; return connectionMongo.connection.db!.collection(`${bucket}.files`); } @@ -28,3 +30,25 @@ export async function getDownloadStream({ return bucket.openDownloadStream(new Types.ObjectId(fileId)); } + +export async function updateGridFSFilename({ + bucketName, + fileId, + newFilename +}: { + bucketName: `${BucketNameEnum}`; + fileId: string; + newFilename: string; +}) { + const collection = getGFSCollection(bucketName); + const result = await collection.updateOne( + { _id: new Types.ObjectId(fileId) }, + { $set: { filename: newFilename } } + ); + + if (result.matchedCount === 0) { + addLog.warn('File not found when updating filename', { fileId, newFilename }); + } + + return result; +} diff --git a/packages/service/common/file/gridfs/schema.ts b/packages/service/common/file/gridfs/schema.ts index 61b98ea96caa..51e9b36dd4f0 100644 --- a/packages/service/common/file/gridfs/schema.ts +++ b/packages/service/common/file/gridfs/schema.ts @@ -6,11 +6,17 @@ const DatasetFileSchema = new Schema({ const ChatFileSchema = new Schema({ metadata: Object }); +const EvaluationFileSchema = new Schema({ + metadata: Object +}); DatasetFileSchema.index({ uploadDate: -1 }); ChatFileSchema.index({ uploadDate: -1 }); ChatFileSchema.index({ 'metadata.chatId': 1 }); +EvaluationFileSchema.index({ uploadDate: -1 }); + export const MongoDatasetFileSchema = getMongoModel('dataset.files', DatasetFileSchema); export const MongoChatFileSchema = getMongoModel('chat.files', ChatFileSchema); +export const MongoEvaluationFileSchema = getMongoModel('evaluation.files', EvaluationFileSchema); diff --git a/packages/service/common/file/image/utils.ts b/packages/service/common/file/image/utils.ts index 30e40161d9b4..0c934fcb8d1e 100644 --- a/packages/service/common/file/image/utils.ts +++ b/packages/service/common/file/image/utils.ts @@ -1,10 +1,10 @@ import { axios } from '../../api/axios'; +import { addLog } from '../../system/log'; import { serverRequestBaseUrl } from '../../api/serverRequest'; import { retryFn } from '@fastgpt/global/common/system/utils'; import { getContentTypeFromHeader } from '../utils'; -import { getLogger, LogCategories } from '../../logger'; - -const logger = getLogger(LogCategories.MODULE.DATASET.FILE); +// 导入 https 模块用于处理自签名证书的HTTPS请求 +import https from 'https'; // 图片格式魔数映射表 const IMAGE_SIGNATURES: { type: string; magic: number[]; check?: (buffer: Buffer) => boolean }[] = [ @@ -97,13 +97,19 @@ export const guessBase64ImageType = (str: string): string => { }; export const getImageBase64 = async (url: string) => { - logger.debug('Load image to base64', { url }); + addLog.debug(`Load image to base64: ${url}`); try { + // 创建 HTTPS Agent,禁用证书验证(rejectUnauthorized: false) + // 这是为了支持自签名证书的HTTPS请求,解决 DEPTH_ZERO_SELF_SIGNED_CERT 错误 + // 在开发/测试环境中,服务可能使用自签名证书(如: https://10.109.96.140:19443) + // Node.js 默认会拒绝这类不受信任的证书,所以需要显式禁用验证 + const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const response = await retryFn(() => axios.get(url, { baseURL: serverRequestBaseUrl, - responseType: 'arraybuffer' + responseType: 'arraybuffer', + httpsAgent // 传入 HTTPS Agent 配置 }) ); @@ -134,7 +140,8 @@ export const getImageBase64 = async (url: string) => { mime: imageType }; } catch (error) { - logger.warn('Load image to base64 failed', { url, error }); + addLog.debug(`Load image to base64 failed: ${url}`); + console.log(error); return Promise.reject(error); } }; diff --git a/packages/service/common/file/read/utils.ts b/packages/service/common/file/read/utils.ts index b8b999a701a3..981bf9952641 100644 --- a/packages/service/common/file/read/utils.ts +++ b/packages/service/common/file/read/utils.ts @@ -3,12 +3,17 @@ import fs from 'fs'; import type { ReadFileResponse } from '../../../worker/readFile/type'; import { axios } from '../../api/axios'; import { batchRun } from '@fastgpt/global/common/system/utils'; -import { matchMdImg } from '@fastgpt/global/common/string/markdown'; +import { matchMdImg, matchHtmlImg } from '@fastgpt/global/common/string/markdown'; import { createPdfParseUsage } from '../../../support/wallet/usage/controller'; import { useDoc2xServer } from '../../../thirdProvider/doc2x'; -import { useTextinServer } from '../../../thirdProvider/textin'; import { readRawContentFromBuffer } from '../../../worker/function'; -import { uploadImage2S3Bucket } from '../../s3/utils'; +import { addLog } from '../../system/log'; +import { uploadImage2S3Bucket, jwtSignS3ObjectKey } from '../../s3/utils'; +import { uploadMongoImg } from '../image/controller'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { addDays } from 'date-fns'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { normalizeMimeType, resolveMimeExtension, resolveMimeType } from '../../s3/utils/mime'; import { getLogger, LogCategories } from '../../logger'; @@ -21,17 +26,169 @@ export type readRawTextByLocalFileParams = { encoding: string; customPdfParse?: boolean; getFormatText?: boolean; - fileParsedPrefix?: string; - metadata?: Record; + usageId?: string; +}; + +type FileParseContext = { + teamId: string; + tmbId: string; + buffer: Buffer; + extension: string; + filename?: string; + usageId?: string; + parseConfig?: Record; +}; + +const parseByCustomService = async ({ + teamId, + tmbId, + buffer, + extension, + filename, + usageId, + parseConfig +}: FileParseContext): Promise => { + const { url, key: token, timeout = 10 } = global.systemEnv.customPdfParse ?? {}; + if (!url || !token) { + return Promise.reject(new Error('Custom PDF parse service URL and key must be configured')); + } + + // 提前校验 URL 格式,避免 axios 内部抛出难以定位的 "Invalid URL" 错误 + try { + new URL(url); + } catch { + return Promise.reject( + new Error( + `Custom parse service URL is invalid: "${url}". Please provide a full absolute URL (e.g. http://host:port/path).` + ) + ); + } + + const start = Date.now(); + addLog.info('Parsing files from an external service', { url, extension, filename }); + + const data = new FormData(); + data.append('file', buffer, { filename: filename || `file.${extension}` }); + + // Append extra parse config fields to FormData (whitelist allowed keys) + const ALLOWED_PARSE_CONFIG_KEYS = new Set([ + 'keep_header_footer', + 'keep_appendix', + 'image_analysis', + 'chart_analysis' + ]); + if (parseConfig) { + Object.entries(parseConfig).forEach(([key, value]) => { + if (value !== undefined && value !== null && ALLOWED_PARSE_CONFIG_KEYS.has(key)) { + data.append(key, String(value)); + } + }); + } + + addLog.info('Calling custom parse service with parseConfig', { + url, + bufferSize: buffer.length, + extension, + filename, + parseConfig, + formDataFields: parseConfig + ? Object.fromEntries( + Object.entries(parseConfig) + .filter(([k]) => ALLOWED_PARSE_CONFIG_KEYS.has(k)) + .map(([k, v]) => [k, v === undefined || v === null ? null : String(v)]) + ) + : null + }); + + try { + const { data: response } = await axios.post<{ + pages: number; + markdown: string; + error?: Object | string; + }>(url, data, { + timeout: timeout * 1000 * 60, + headers: { + ...data.getHeaders(), + ...(token ? { Authorization: `Bearer ${token}` } : {}) + } + }); + + if (response.error) { + addLog.warn('Custom PDF parse service returned error', { error: response.error }); + return Promise.reject( + new Error( + typeof response.error === 'string' ? response.error : JSON.stringify(response.error) + ) + ); + } + + if (typeof response.pages !== 'number' || response.pages < 0) { + return Promise.reject( + new Error(`Invalid response: pages must be a non-negative number, got ${response.pages}`) + ); + } + if (typeof response.markdown !== 'string') { + return Promise.reject(new Error('Invalid response: markdown must be a string')); + } + + addLog.info(`Custom file parsing is complete, time: ${Date.now() - start}ms`); + + createPdfParseUsage({ teamId, tmbId, pages: response.pages, usageId }).catch((error) => { + addLog.error('Failed to create PDF parse usage', { + teamId, + tmbId, + pages: response.pages, + usageId, + error + }); + }); + + const { text: htmlParsed, imageList: htmlImages } = matchHtmlImg(response.markdown); + const { text, imageList } = matchMdImg(htmlParsed); + return { rawText: text, formatText: text, imageList: [...htmlImages, ...imageList] }; + } catch (error) { + addLog.error('Custom parse service request failed', { + url, + errorMessage: error instanceof Error ? error.message : String(error), + errorCode: (error as any).code, + errorStack: error instanceof Error ? error.stack : undefined + }); + throw error; + } +}; + +const parseByDoc2x = async ({ + teamId, + tmbId, + buffer, + extension, + filename, + usageId, + parseConfig: _parseConfig +}: FileParseContext): Promise => { + const doc2xKey = global.systemEnv.customPdfParse?.doc2xKey; + if (!doc2xKey) { + return Promise.reject(new Error('doc2x API key must be configured')); + } + const { pages, text, imageList } = await useDoc2xServer({ apiKey: doc2xKey }).parsePDF(buffer); + + createPdfParseUsage({ teamId, tmbId, pages, usageId }).catch((error) => { + addLog.error('Failed to create PDF parse usage', { teamId, tmbId, pages, usageId, error }); + }); + return { rawText: text, formatText: text, imageList }; }; -export const readRawTextByLocalFile = async (params: readRawTextByLocalFileParams) => { + +export const readRawTextByLocalFile = async ( + params: readRawTextByLocalFileParams +): Promise => { const { path } = params; - const extension = path?.split('.')?.pop()?.toLowerCase() || ''; + const extension = path.split('.').pop()?.toLowerCase() || ''; + const filename = path.split(/[/\\]/).pop(); const buffer = await fs.promises.readFile(path); - return readFileContentByBuffer({ + return readRawContentByFileBuffer({ extension, customPdfParse: params.customPdfParse, getFormatText: params.getFormatText, @@ -39,204 +196,218 @@ export const readRawTextByLocalFile = async (params: readRawTextByLocalFileParam tmbId: params.tmbId, encoding: params.encoding, buffer, - imageKeyOptions: params.fileParsedPrefix - ? { - prefix: params.fileParsedPrefix - } - : undefined + filename, + usageId: params.usageId }); }; -export const readFileContentByBuffer = async ({ +export const readRawContentByFileBuffer = async ({ teamId, tmbId, - extension, buffer, encoding, + metadata, customPdfParse = false, - usageId, getFormatText = true, - imageKeyOptions + filename, + usageId, + parseConfig }: { teamId: string; tmbId: string; - extension: string; buffer: Buffer; encoding: string; - + metadata?: Record; customPdfParse?: boolean; - usageId?: string; getFormatText?: boolean; - imageKeyOptions?: { - prefix: string; - expiredTime?: Date; - }; -}): Promise<{ - rawText: string; -}> => { - const systemParse = () => - readRawContentFromBuffer({ - extension, - encoding, - buffer - }); - const parsePdfFromCustomService = async (): Promise => { - const url = global.systemEnv.customPdfParse?.url; - const token = global.systemEnv.customPdfParse?.key; - if (!url) return systemParse(); - - const start = Date.now(); - logger.info('Start parsing file via external service', { extension }); + filename?: string; + usageId?: string; + parseConfig?: Record; +}): Promise => { + const systemParse = () => readRawContentFromBuffer({ extension, encoding, buffer }); - const data = new FormData(); - data.append('file', buffer, { - filename: `file.${extension}` - }); - const { data: response } = await axios.post<{ - pages: number; - markdown: string; - error?: Object | string; - }>(url, data, { - timeout: 600000, - headers: { - ...data.getHeaders(), - Authorization: token ? `Bearer ${token}` : undefined + const getParseFn = (): (() => Promise) => { + const isImageType = ['jpg', 'jpeg', 'png'].includes(extension); + if (isImageType) { + const cfg = global.systemEnv.customPdfParse; + if (cfg?.url) { + if (!cfg?.key) + return () => Promise.reject(new UserError(CommonErrEnum.customParseMissingKey)); + return () => parseByCustomService({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); } - }); - - if (response.error) { - return Promise.reject(response.error); + return () => + Promise.reject( + `Image file type (.${extension}) cannot be parsed as a document. Please use the image dataset collection feature or configure customPdfParse.` + ); } + if (!customPdfParse) return systemParse; + const cfg = global.systemEnv.customPdfParse; + const isDocumentType = [ + 'pdf', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'xls', + 'xlsx', + 'html', + 'csv' + ].includes(extension); + if (isDocumentType && cfg?.url) { + if (!cfg?.key) + return () => Promise.reject(new UserError(CommonErrEnum.customParseMissingKey)); + return () => parseByCustomService({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); + } + if (extension === 'pdf' && cfg?.doc2xKey) + return () => parseByDoc2x({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); + return systemParse; + }; - logger.info('External file parsing completed', { - extension, - durationMs: Date.now() - start - }); + const start = Date.now(); + addLog.debug(`Start parse file`, { extension }); - const rawText = response.markdown; - const { text, imageList } = matchMdImg(rawText); + let { rawText, formatText, imageList } = await getParseFn()(); - createPdfParseUsage({ - teamId, - tmbId, - pages: response.pages, - usageId - }); + addLog.debug(`Parse file success, time: ${Date.now() - start}ms`); - return { - rawText: text, - formatText: text, - imageList - }; - }; - // Textin api - const parsePdfFromTextin = async (): Promise => { - const appId = global.systemEnv.customPdfParse?.textinAppId; - const secretCode = global.systemEnv.customPdfParse?.textinSecretCode; - if (!appId || !secretCode) return systemParse(); - - const { pages, text, imageList } = await useTextinServer({ - appId, - secretCode - }).parsePDF(buffer); - - createPdfParseUsage({ - teamId, - tmbId, - pages, - usageId - }); + // upload inline images and replace uuid placeholders with real urls + if (imageList) { + await batchRun(imageList, async (item) => { + let src: string | null = null; + try { + src = await uploadMongoImg({ + base64Img: `data:${item.mime};base64,${item.base64}`, + teamId, + metadata: { ...metadata, mime: item.mime } + }); + } catch (error) { + addLog.warn('Upload file image error', { error }); + } - return { - rawText: text, - formatText: text, - imageList - }; - }; - // Doc2x api - const parsePdfFromDoc2x = async (): Promise => { - const doc2xKey = global.systemEnv.customPdfParse?.doc2xKey; - if (!doc2xKey) return systemParse(); - - const { pages, text, imageList } = await useDoc2xServer({ apiKey: doc2xKey }).parsePDF(buffer); - - createPdfParseUsage({ - teamId, - tmbId, - pages, - usageId + if (src) { + rawText = rawText.replaceAll(item.uuid, src); + if (formatText) formatText = formatText.replaceAll(item.uuid, src); + } else { + // remove the entire markdown image syntax to avoid leaving broken placeholders + const imgPattern = new RegExp(`!\\[[^\\]]*\\]\\(${item.uuid}\\)`, 'g'); + rawText = rawText.replace(imgPattern, ''); + if (formatText) formatText = formatText.replace(imgPattern, ''); + } }); + } - return { - rawText: text, - formatText: text, - imageList - }; - }; - // Custom read file service - const pdfParseFn = async (): Promise => { - if (!customPdfParse) return systemParse(); - if (global.systemEnv.customPdfParse?.url) return parsePdfFromCustomService(); - if (global.systemEnv.customPdfParse?.textinAppId) return parsePdfFromTextin(); - if (global.systemEnv.customPdfParse?.doc2xKey) return parsePdfFromDoc2x(); - - return systemParse(); - }; + addLog.debug(`Upload file success, time: ${Date.now() - start}ms`); - const start = Date.now(); - logger.debug('Start parsing file', { extension }); + return { rawText: getFormatText ? formatText || rawText : rawText }; +}; + +export const readS3FileContentByBuffer = async ({ + teamId, + tmbId, + extension, + buffer, + encoding, + customPdfParse = false, + getFormatText = true, + filename, + usageId, + imageKeyOptions, + parseConfig +}: { + teamId: string; + tmbId: string; + extension: string; + buffer: Buffer; + encoding: string; + customPdfParse?: boolean; + getFormatText?: boolean; + filename?: string; + usageId?: string; + imageKeyOptions?: { prefix: string; expiredTime?: Date }; + parseConfig?: Record; +}): Promise => { + const systemParse = () => readRawContentFromBuffer({ extension, encoding, buffer }); - let { rawText, formatText, imageList } = await (async () => { - if (extension === 'pdf') { - return await pdfParseFn(); + const getParseFn = (): (() => Promise) => { + const isImageType = ['jpg', 'jpeg', 'png'].includes(extension); + if (isImageType) { + const cfg = global.systemEnv.customPdfParse; + if (cfg?.url) { + if (!cfg?.key) + return () => Promise.reject(new UserError(CommonErrEnum.customParseMissingKey)); + return () => parseByCustomService({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); + } + return () => + Promise.reject( + `Image file type (.${extension}) cannot be parsed as a document. Please use the image dataset collection feature or configure customPdfParse.` + ); + } + if (!customPdfParse) return systemParse; + const cfg = global.systemEnv.customPdfParse; + const isDocumentType = [ + 'pdf', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'xls', + 'xlsx', + 'html', + 'csv' + ].includes(extension); + if (isDocumentType && cfg?.url) { + if (!cfg?.key) + return () => Promise.reject(new UserError(CommonErrEnum.customParseMissingKey)); + return () => parseByCustomService({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); } - return await systemParse(); - })(); + if (extension === 'pdf' && cfg?.doc2xKey) + return () => parseByDoc2x({ teamId, tmbId, buffer, extension, filename, usageId, parseConfig }); + return systemParse; + }; + + const start = Date.now(); + addLog.debug(`Start parse file`, { extension }); - logger.debug('File parsing completed', { extension, durationMs: Date.now() - start }); + let { rawText, formatText, imageList } = await getParseFn()(); - // markdown data format - if (imageList && imageList.length > 0) { - logger.debug('Processing parsed document images', { - extension, - imageCount: imageList.length - }); + addLog.debug(`Parse file success, time: ${Date.now() - start}ms`); + // upload inline images to S3 and replace uuid placeholders with real urls + if (imageList && imageKeyOptions) { await batchRun(imageList, async (item) => { - const src = await (async () => { - if (!imageKeyOptions) return ''; - try { - const { prefix, expiredTime } = imageKeyOptions; - const mimetype = normalizeMimeType(item.mime); - const ext = resolveMimeExtension(mimetype); - const filename = `${item.uuid}${ext}`; - - return await uploadImage2S3Bucket('private', { - base64Img: `data:${mimetype};base64,${item.base64}`, - uploadKey: `${prefix}/${filename}`, - mimetype: resolveMimeType([filename], mimetype), - filename, - expiredTime - }); - } catch (error) { - logger.warn('Failed to upload parsed image to S3', { - extension, - imageUuid: item.uuid, - error - }); - return `[Image Upload Failed: ${item.uuid}]`; - } - })(); - rawText = rawText.replace(item.uuid, src); - if (formatText) { - formatText = formatText.replace(item.uuid, src); + let src: string | null = null; + try { + const mimetype = normalizeMimeType(item.mime); + const ext = resolveMimeExtension(mimetype); + const filename = `${getNanoid(12)}${ext}`; + const imageKey = `${imageKeyOptions.prefix}/${filename}`; + await uploadImage2S3Bucket('private', { + base64Img: `data:${mimetype};base64,${item.base64}`, + uploadKey: imageKey, + mimetype: resolveMimeType([filename], mimetype), + filename, + expiredTime: imageKeyOptions.expiredTime + }); + const signExpiry = imageKeyOptions.expiredTime ?? addDays(new Date(), 90); + src = jwtSignS3ObjectKey(imageKey, signExpiry); + } catch (error) { + addLog.warn('Upload file image to S3 error', { error }); + } + + if (src) { + rawText = rawText.replaceAll(item.uuid, src); + if (formatText) formatText = formatText.replaceAll(item.uuid, src); + } else { + const imgPattern = new RegExp(`!\\[[^\\]]*\\]\\(${item.uuid}\\)`, 'g'); + rawText = rawText.replace(imgPattern, ''); + if (formatText) formatText = formatText.replace(imgPattern, ''); } }); } - return { - rawText: getFormatText ? formatText || rawText : rawText - }; + addLog.debug(`Upload file success, time: ${Date.now() - start}ms`); + + return { rawText: getFormatText ? formatText || rawText : rawText }; }; diff --git a/packages/service/common/file/uploadValidation.ts b/packages/service/common/file/uploadValidation.ts new file mode 100644 index 000000000000..169ff5429196 --- /dev/null +++ b/packages/service/common/file/uploadValidation.ts @@ -0,0 +1,183 @@ +import { FileUploadError } from '@fastgpt/global/common/error/utils'; +import { + DEFAULT_FILE_UPLOAD_LIMITS, + FileUploadErrorEnum +} from '@fastgpt/global/common/file/constants'; +type FileType = Express.Multer.File; + +export interface FileUploadLimits { + maxFileCount?: number; + maxFileSizeMB?: number; + allowedTypes?: string[]; +} + +export interface FileValidationResult { + isValid: boolean; + errors: FileUploadError[]; + validFiles: FileType[]; + invalidFiles: FileType[]; +} + +/** + * Get effective upload limits by merging global config with custom limits + */ +export function getEffectiveUploadLimits( + customLimits?: FileUploadLimits +): Required { + const globalLimits = { + maxFileCount: + global.feConfigs?.uploadFileMaxAmount ?? DEFAULT_FILE_UPLOAD_LIMITS.MAX_FILE_COUNT, + maxFileSizeMB: + global.feConfigs?.uploadFileMaxSize ?? DEFAULT_FILE_UPLOAD_LIMITS.MAX_FILE_SIZE_MB + }; + + return { + maxFileCount: Math.min( + customLimits?.maxFileCount ?? globalLimits.maxFileCount, + globalLimits.maxFileCount + ), + maxFileSizeMB: Math.min( + customLimits?.maxFileSizeMB ?? globalLimits.maxFileSizeMB, + globalLimits.maxFileSizeMB + ), + allowedTypes: customLimits?.allowedTypes ?? [] + }; +} + +/** + * Validate file size constraints + */ +export function validateFileSize( + file: FileType, + limits: Required +): FileUploadError | null { + const maxSizeBytes = limits.maxFileSizeMB * 1024 * 1024; + + if (file.size > maxSizeBytes) { + return new FileUploadError( + FileUploadErrorEnum.FILE_TOO_LARGE, + `File "${file.originalname}" exceeds the maximum file size limit of ${limits.maxFileSizeMB}MB`, + { + fileName: file.originalname, + fileSize: file.size, + maxSize: maxSizeBytes, + maxSizeMB: limits.maxFileSizeMB + } + ); + } + + return null; +} + +/** + * Validate file count constraints + */ +export function validateFileCount( + files: FileType[], + limits: Required +): FileUploadError | null { + if (files.length > limits.maxFileCount) { + return new FileUploadError( + FileUploadErrorEnum.TOO_MANY_FILES, + `File count exceeds the limit, maximum ${limits.maxFileCount} files supported`, + { + fileCount: files.length, + maxFileCount: limits.maxFileCount + } + ); + } + + return null; +} + +/** + * Validate file type constraints + */ +export function validateFileType( + file: FileType, + limits: Required +): FileUploadError | null { + if (limits.allowedTypes.length === 0) { + return null; // No type restrictions + } + + const fileExtension = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + const isAllowed = limits.allowedTypes.some( + (type) => type.toLowerCase() === fileExtension || file.mimetype.includes(type.toLowerCase()) + ); + + if (!isAllowed) { + return new FileUploadError( + FileUploadErrorEnum.INVALID_FILE_TYPE, + `File type not supported, file "${file.originalname}" has type ${fileExtension}`, + { + fileName: file.originalname, + fileType: fileExtension, + mimetype: file.mimetype, + allowedTypes: limits.allowedTypes + } + ); + } + + return null; +} + +export function validateFileUpload( + files: FileType[], + customLimits?: FileUploadLimits +): FileValidationResult { + const limits = getEffectiveUploadLimits(customLimits); + const errors: FileUploadError[] = []; + const validFiles: FileType[] = []; + const invalidFiles: FileType[] = []; + + const countError = validateFileCount(files, limits); + if (countError) { + errors.push(countError); + return { + isValid: false, + errors, + validFiles: [], + invalidFiles: files + }; + } + + for (const file of files) { + const fileErrors: FileUploadError[] = []; + + const sizeError = validateFileSize(file, limits); + if (sizeError) fileErrors.push(sizeError); + + const typeError = validateFileType(file, limits); + if (typeError) fileErrors.push(typeError); + + if (fileErrors.length > 0) { + errors.push(...fileErrors); + invalidFiles.push(file); + } else { + validFiles.push(file); + } + } + + return { + isValid: errors.length === 0, + errors, + validFiles, + invalidFiles + }; +} + +export function formatUploadLimitsMessage(limits: Required): string { + const messages = [ + `Maximum ${limits.maxFileCount} files supported`, + `Maximum file size ${limits.maxFileSizeMB}MB` + ]; + + if (limits.allowedTypes.length > 0) { + messages.push(`Supported file types: ${limits.allowedTypes.join(', ')}`); + } + + return messages.join(', '); +} diff --git a/packages/service/common/langfuse/index.ts b/packages/service/common/langfuse/index.ts new file mode 100644 index 000000000000..9bcb6b94183a --- /dev/null +++ b/packages/service/common/langfuse/index.ts @@ -0,0 +1,149 @@ +import { addSpanProcessor, type SpanProcessor } from '@fastgpt-sdk/otel/tracing'; +import type { LangfuseConfigType } from '@fastgpt/global/common/system/types'; +import { env } from '../../env'; +import { getLogger, LogCategories } from '../logger'; + +type LangfuseRuntimeConfig = { + secretKey: string; + publicKey: string; + baseUrl?: string; +}; + +const logger = getLogger(LogCategories.INFRA.OTEL); + +let registered = false; +let currentProcessor: SpanProcessor | null = null; +let currentConfigHash = ''; +let refreshPromise: Promise | null = null; + +const langfuseSpanProcessor: SpanProcessor = { + onStart(...args): void { + const [span, parentContext] = args; + currentProcessor?.onStart(span, parentContext); + }, + onEnd(span): void { + currentProcessor?.onEnd(span); + }, + forceFlush(): Promise { + return currentProcessor?.forceFlush() ?? Promise.resolve(); + }, + shutdown(): Promise { + return currentProcessor?.shutdown() ?? Promise.resolve(); + } +}; + +function normalizeConfigValue(value?: string) { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function getSystemLangfuseConfig(): LangfuseConfigType | undefined { + return global.systemEnv?.langfuse; +} + +function resolveLangfuseConfig(): LangfuseRuntimeConfig | undefined { + const runtimeConfig = getSystemLangfuseConfig(); + const hasRuntimeConfig = !!( + normalizeConfigValue(runtimeConfig?.secretKey) || + normalizeConfigValue(runtimeConfig?.publicKey) || + normalizeConfigValue(runtimeConfig?.baseUrl) + ); + + const secretKey = hasRuntimeConfig + ? normalizeConfigValue(runtimeConfig?.secretKey) + : env.LANGFUSE_SECRET_KEY; + const publicKey = hasRuntimeConfig + ? normalizeConfigValue(runtimeConfig?.publicKey) + : env.LANGFUSE_PUBLIC_KEY; + const baseUrl = hasRuntimeConfig + ? normalizeConfigValue(runtimeConfig?.baseUrl) + : env.LANGFUSE_BASE_URL; + + if (!secretKey || !publicKey) return; + + return { + secretKey, + publicKey, + ...(baseUrl ? { baseUrl } : {}) + }; +} + +function getConfigHash(config?: LangfuseRuntimeConfig) { + if (!config) return ''; + + return JSON.stringify({ + secretKey: config.secretKey, + publicKey: config.publicKey, + baseUrl: config.baseUrl || '' + }); +} + +function registerLangfuseProcessor() { + if (registered) return; + + addSpanProcessor(langfuseSpanProcessor); + registered = true; +} + +async function shutdownCurrentProcessor() { + const processor = currentProcessor; + currentProcessor = null; + + if (!processor) return; + + try { + await processor.shutdown(); + } catch (error) { + logger.error('Langfuse span processor shutdown failed', { error }); + } +} + +export function isLangfuseEnabled(): boolean { + return !!resolveLangfuseConfig(); +} + +export async function initLangfuseTracing(): Promise { + registerLangfuseProcessor(); + await refreshLangfuseTracing(); +} + +export async function refreshLangfuseTracing(): Promise { + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + const config = resolveLangfuseConfig(); + const nextConfigHash = getConfigHash(config); + + if (nextConfigHash === currentConfigHash) return; + + await shutdownCurrentProcessor(); + + if (!config) { + currentConfigHash = nextConfigHash; + logger.info('Langfuse tracing disabled'); + return; + } + + const { LangfuseSpanProcessor } = await import('@langfuse/otel'); + + currentProcessor = new LangfuseSpanProcessor({ + secretKey: config.secretKey, + publicKey: config.publicKey, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + shouldExportSpan: ({ otelSpan }) => { + return Object.keys(otelSpan.attributes).some((key) => key.startsWith('langfuse.')); + } + }); + currentConfigHash = nextConfigHash; + + logger.info('Langfuse tracing enabled', { + baseUrl: config.baseUrl + }); + })(); + + try { + await refreshPromise; + } finally { + refreshPromise = null; + } +} diff --git a/packages/service/common/logger/categories.ts b/packages/service/common/logger/categories.ts index 3ba708245b4b..66502198b6d8 100644 --- a/packages/service/common/logger/categories.ts +++ b/packages/service/common/logger/categories.ts @@ -66,13 +66,15 @@ export const LogCategories = { QUEUES: ['dataset', 'queues'], TRAINING: ['dataset', 'training'], - FILE_PARSE: ['dataset', 'training', 'file-parse'], - EMBEDDING: ['dataset', 'training', 'embedding'], - QA: ['dataset', 'training', 'qa'], - IMAGE_PARSE: ['dataset', 'training', 'image-parse'], - IMAGE_INDEX: ['dataset', 'training', 'image-index'], - INDEX_EXTEND: ['dataset', 'training', 'index-extend'], - LLM_PARGRAPH: ['dataset', 'training', 'llm-pargraph'] + FILE_PARSE: ['training', 'file-parse'], + EMBEDDING: ['training', 'embedding'], + QA: ['training', 'qa'], + IMAGE_PARSE: ['training', 'image-parse'], + IMAGE_INDEX: ['training', 'image-index'], + INDEX_EXTEND: ['training', 'index-extend'], + LLM_PARGRAPH: ['training', 'llm-pargraph'], + SMALL2BIG: ['training', 'small2big'], + DATABASE_SCHEMA: ['training', 'database'] }), AI: Object.assign(['ai'], { AGENT: ['ai', 'agent'], diff --git a/packages/service/common/middle/i18n.ts b/packages/service/common/middle/i18n.ts index 90f465e3a6e8..a106f6cdb838 100644 --- a/packages/service/common/middle/i18n.ts +++ b/packages/service/common/middle/i18n.ts @@ -4,12 +4,24 @@ import type { ApiRequestProps } from '../../type/next'; /** * Get the locale from the request cookies * @param req - The request object - * @returns The locale string, 'en' by default + * @returns The locale string, 'zh-CN' by default */ export const getLocale = (req: ApiRequestProps): localeType => { const locale = req.cookies['NEXT_LOCALE']; if (locale && LocaleList.includes(locale as localeType)) { return locale as localeType; } - return 'en'; + return 'zh-CN'; +}; + +export let templateLocale = 'zh-CN'; + +export const isRefreshTemplateData = (req: ApiRequestProps): boolean => { + const isUpdate = getLocale(req) !== templateLocale; + templateLocale = getLocale(req); + return isUpdate; +}; + +export const isEnLocale = (req: ApiRequestProps): boolean => { + return getLocale(req) === 'en'; }; diff --git a/packages/service/common/mongo/index.ts b/packages/service/common/mongo/index.ts index 954d569c4cbc..3a7db88d5e46 100644 --- a/packages/service/common/mongo/index.ts +++ b/packages/service/common/mongo/index.ts @@ -76,9 +76,9 @@ const addCommonMiddleware = (schema: mongoose.Schema) => { }; if (duration > 2000) { - logger.warn('MongoDB slow query (>2s)', getLogData()); + console.warn('MongoDB slow query (>2s)', getLogData()); } else if (duration > 500) { - logger.warn('MongoDB slow query (>500ms)', getLogData()); + console.warn('MongoDB slow query (>500ms)', getLogData()); } } next(); diff --git a/packages/service/common/mongo/init.ts b/packages/service/common/mongo/init.ts index 096ad2ed2b1d..e8082e1bde52 100644 --- a/packages/service/common/mongo/init.ts +++ b/packages/service/common/mongo/init.ts @@ -6,6 +6,14 @@ const logger = getLogger(LogCategories.INFRA.MONGO); const maxConnecting = Math.max(30, Number(process.env.DB_MAX_LINK || 20)); +let isShuttingDown = false; +process.once('SIGTERM', () => { + isShuttingDown = true; +}); +process.once('SIGINT', () => { + isShuttingDown = true; +}); + /** * connect MongoDB and init data */ @@ -68,6 +76,11 @@ export async function connectMongo(props: { await db.disconnect(); + if (isShuttingDown) { + logger.info('Process is shutting down, stop MongoDB reconnect'); + return db; + } + await delay(1000); return connectMongo(props); } diff --git a/packages/service/common/otel/log.ts b/packages/service/common/otel/log.ts new file mode 100644 index 000000000000..175d64d32060 --- /dev/null +++ b/packages/service/common/otel/log.ts @@ -0,0 +1,6 @@ +export function getLogger(name?: string): any { + return { + ...console, + emit: () => {} + }; +} diff --git a/packages/service/common/redis/cluster.ts b/packages/service/common/redis/cluster.ts new file mode 100644 index 000000000000..86807861f3e1 --- /dev/null +++ b/packages/service/common/redis/cluster.ts @@ -0,0 +1,66 @@ +import type { Cluster } from 'ioredis'; +import type Redis from 'ioredis'; +import { isClusterMode } from './config'; +import { FASTGPT_REDIS_PREFIX } from './connection'; + +/** + * Scan keys from a single Redis node using SCAN command + * More efficient than KEYS for large datasets + */ +async function scanKeysFromNode( + node: Redis, + pattern: string, + prefixToRemove: string +): Promise { + let cursor = '0'; + const batchSize = 1000; + const results: string[] = []; + + do { + const [nextCursor, keys] = await node.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize); + cursor = nextCursor; + + for (const k of keys) { + results.push(k.replace(prefixToRemove, '')); + } + } while (cursor !== '0'); + + return results; +} + +/** + * Get all keys matching a prefix pattern + * Works in both standalone and cluster modes + * Both modes use SCAN for better performance + * Returns keys without the prefix (FASTGPT_REDIS_PREFIX includes hash tag {fastgpt}:) + */ +export async function getAllKeysByPrefix(redis: Redis | Cluster, key: string): Promise { + if (!key) return []; + + const pattern = `${FASTGPT_REDIS_PREFIX}${key}:*`; + + if (isClusterMode()) { + // In cluster mode, scan all master nodes + const cluster = redis as Cluster; + const nodes = cluster.nodes('master'); + + const allKeysArrays = await Promise.all( + nodes.map((node) => scanKeysFromNode(node, pattern, FASTGPT_REDIS_PREFIX)) + ); + + // Flatten and deduplicate (keys might overlap during cluster rebalancing) + return [...new Set(allKeysArrays.flat())]; + } else { + // Standalone mode - use SCAN on single node + return scanKeysFromNode(redis as Redis, pattern, FASTGPT_REDIS_PREFIX); + } +} + +/** + * Wrapper for Redis DEL command + */ +export async function deleteKeys(redis: Redis | Cluster, keys: string[]): Promise { + if (keys.length === 0) return 0; + // All keys have {fastgpt} hash tag, so they're in the same slot - no CROSSSLOT error + return await redis.del(...keys); +} diff --git a/packages/service/common/redis/config.ts b/packages/service/common/redis/config.ts new file mode 100644 index 000000000000..67fc63e46139 --- /dev/null +++ b/packages/service/common/redis/config.ts @@ -0,0 +1,102 @@ +import type { RedisOptions, ClusterOptions, ClusterNode } from 'ioredis'; + +export type RedisMode = 'standalone' | 'cluster'; + +export type RedisConfig = { + mode: RedisMode; + standalone?: { + url: string; + options?: RedisOptions; + }; + cluster?: { + nodes: ClusterNode[]; + options?: ClusterOptions; + }; +}; + +// Cache the parsed configuration to avoid repeated environment variable parsing +let cachedRedisConfig: RedisConfig | null = null; + +/** + * Parse Redis configuration from environment variables + * Auto-detects cluster vs standalone mode + * Result is cached after first call + */ +export function parseRedisConfig(): RedisConfig { + // Return cached config if available + if (cachedRedisConfig) { + return cachedRedisConfig; + } + const clusterNodes = process.env.REDIS_CLUSTER_NODES?.trim(); + + // Cluster mode detection - only if REDIS_CLUSTER_NODES is set and not empty + if (clusterNodes && clusterNodes.length > 0) { + const nodes = clusterNodes.split(',').map((node) => { + const [host, port] = node.trim().split(':'); + return { host, port: parseInt(port) || 6379 }; + }); + + // Validate that we have at least one valid node + const validNodes = nodes.filter((node) => node.host && node.host.length > 0); + if (validNodes.length === 0) { + console.warn( + 'REDIS_CLUSTER_NODES is set but contains no valid nodes. Falling back to standalone mode.' + ); + // Fall through to standalone mode + } else { + const password = process.env.REDIS_PASSWORD; + + // Parse optional JSON configuration + let additionalOptions: Partial = {}; + if (process.env.REDIS_CLUSTER_OPTIONS) { + try { + additionalOptions = JSON.parse(process.env.REDIS_CLUSTER_OPTIONS); + } catch (error) { + console.warn('Invalid REDIS_CLUSTER_OPTIONS JSON, using defaults'); + } + } + + console.log( + `Redis cluster mode detected with ${validNodes.length} node(s): ${validNodes.map((n) => `${n.host}:${n.port}`).join(', ')}` + ); + + cachedRedisConfig = { + mode: 'cluster', + cluster: { + nodes: validNodes, + options: { + redisOptions: { + password + }, + slotsRefreshTimeout: 10000, // Increase timeout for slot refresh + ...additionalOptions + } + } + }; + + return cachedRedisConfig; + } + } + + // Standalone mode (default/current behavior) + const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'; + + console.log(`Redis standalone mode detected. URL: ${redisUrl.replace(/:[^:@]+@/, ':****@')}`); + + cachedRedisConfig = { + mode: 'standalone', + standalone: { + url: redisUrl, + options: {} + } + }; + + return cachedRedisConfig; +} + +/** + * Check if Redis is configured in cluster mode + */ +export function isClusterMode(): boolean { + return parseRedisConfig().mode === 'cluster'; +} diff --git a/packages/service/common/redis/connection.ts b/packages/service/common/redis/connection.ts new file mode 100644 index 000000000000..19cf2c65358a --- /dev/null +++ b/packages/service/common/redis/connection.ts @@ -0,0 +1,149 @@ +import type { RedisOptions } from 'ioredis'; +import Redis, { Cluster } from 'ioredis'; +import { parseRedisConfig } from './config'; +import { getLogger, LogCategories } from '../logger'; + +const addLog = getLogger(LogCategories.INFRA.REDIS); + +// Use hash tag {fastgpt} to ensure all keys go to the same slot in cluster mode +// In standalone mode, the braces are just regular characters with no special meaning +export const FASTGPT_REDIS_PREFIX = '{fastgpt}:'; + +export type RedisConnection = Redis | Cluster; + +// Base Redis options for connection reliability +const REDIS_BASE_OPTION = { + // Retry strategy: exponential backoff with unlimited retries for stability + retryStrategy: (times: number) => { + // Never give up retrying to ensure worker keeps running + const delay = Math.min(times * 50, 2000); // Max 2s between retries + if (times > 10) { + addLog.error(`[Redis connection failed] attempt ${times}, will keep retrying...`); + } else { + addLog.warn(`Redis reconnecting... attempt ${times}, delay ${delay}ms`); + } + return delay; // Always return a delay to keep retrying + }, + // Reconnect on specific errors (Redis master-slave switch, network issues) + reconnectOnError: (err: Error) => { + const reconnectErrors = ['READONLY', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET']; + const message = typeof err?.message === 'string' ? err.message : String(err ?? ''); + + const shouldReconnect = reconnectErrors.some((errType) => message.includes(errType)); + if (shouldReconnect) { + addLog.warn(`Redis reconnecting due to error: ${message}`); + } + return shouldReconnect; + }, + // Connection timeout + connectTimeout: 10000, // 10 seconds + // Enable offline queue to buffer commands when disconnected + enableOfflineQueue: true +}; + +/** + * Create a new Redis connection based on configuration + * This replaces the direct `new Redis()` calls + */ +function createRedisConnection(additionalOptions?: Partial): RedisConnection { + const config = parseRedisConfig(); + + if (config.mode === 'cluster') { + const clusterOptions = config.cluster!.options || {}; + const options = { + ...clusterOptions, + redisOptions: { + ...REDIS_BASE_OPTION, + ...(clusterOptions.redisOptions || {}), + ...additionalOptions + } + }; + + return new Cluster(config.cluster!.nodes, options); + } else { + // Standalone mode + return new Redis(config.standalone!.url, { + ...REDIS_BASE_OPTION, + ...additionalOptions + }); + } +} + +/** + * Global Redis connection with automatic prefixing + * Maintains singleton pattern for standalone mode + */ +export const getGlobalRedisConnection = (): RedisConnection => { + if (global.redisClient) return global.redisClient; + + const config = parseRedisConfig(); + + try { + // Create connection with keyPrefix for both standalone and cluster modes + const options = { keyPrefix: FASTGPT_REDIS_PREFIX, maxRetriesPerRequest: 3 }; + global.redisClient = createRedisConnection(options); + + global.redisClient.on('connect', () => { + addLog.info('[Global Redis] connected'); + }); + global.redisClient.on('error', (error) => { + addLog.error('[Global Redis] connection error', { error }); + if (config.mode === 'cluster') { + console.error( + '\n⚠️ Redis Cluster connection failed. Please check:\n' + + ' 1. REDIS_CLUSTER_NODES is set correctly\n' + + ' 2. Redis cluster is running and accessible\n' + + ' 3. Or unset REDIS_CLUSTER_NODES to use standalone mode\n' + ); + } + }); + global.redisClient.on('close', () => { + addLog.warn('[Global Redis] connection closed'); + }); + + return global.redisClient; + } catch (error) { + if (config.mode === 'cluster') { + console.error( + '\n❌ Failed to create Redis cluster connection.\n' + + 'Current REDIS_CLUSTER_NODES: ' + + process.env.REDIS_CLUSTER_NODES + + '\n\nTo fix this:\n' + + ' 1. Ensure Redis cluster is running\n' + + ' 2. Or unset REDIS_CLUSTER_NODES to use standalone mode with REDIS_URL\n' + ); + } + throw error; + } +}; + +/** + * Queue connection for BullMQ + */ +export const newQueueRedisConnection = (): RedisConnection => { + const redis = createRedisConnection({ + maxRetriesPerRequest: 3 + }); + + redis.on('error', (error) => { + console.error('Redis Queue connection error', error); + }); + + return redis; +}; + +/** + * Worker connection for BullMQ + * Critical: maxRetriesPerRequest must be null + */ +export const newWorkerRedisConnection = (): RedisConnection => { + const redis = createRedisConnection({ + maxRetriesPerRequest: null + }); + + redis.on('error', (error) => { + console.error('Redis Worker connection error', error); + }); + + return redis; +}; diff --git a/packages/service/common/redis/index.ts b/packages/service/common/redis/index.ts index 06ffeaec09a0..8311234aae18 100644 --- a/packages/service/common/redis/index.ts +++ b/packages/service/common/redis/index.ts @@ -1,106 +1,23 @@ -import { getLogger, LogCategories } from '../logger'; -import Redis from 'ioredis'; - -const logger = getLogger(LogCategories.INFRA.REDIS); - -const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; - -// Base Redis options for connection reliability -const REDIS_BASE_OPTION = { - // Retry strategy: exponential backoff with unlimited retries for stability - retryStrategy: (times: number) => { - // Never give up retrying to ensure worker keeps running - const delay = Math.min(times * 50, 2000); // Max 2s between retries - if (times > 10) { - logger.error('Redis reconnect failed, continuing to retry', { - attempt: times, - delayMs: delay - }); - } else { - logger.warn('Redis reconnecting', { - attempt: times, - delayMs: delay - }); - } - return delay; // Always return a delay to keep retrying - }, - // Reconnect on specific errors (Redis master-slave switch, network issues) - reconnectOnError: (err: any) => { - const reconnectErrors = ['READONLY', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET']; - const message = typeof err?.message === 'string' ? err.message : String(err ?? ''); - - const shouldReconnect = reconnectErrors.some((errType) => message.includes(errType)); - if (shouldReconnect) { - logger.warn('Redis reconnecting due to error', { message }); - } - return shouldReconnect; - }, - // Connection timeout - connectTimeout: 10000, // 10 seconds - // Enable offline queue to buffer commands when disconnected - enableOfflineQueue: true -}; - -export const newQueueRedisConnection = () => { - const redis = new Redis(REDIS_URL, { - ...REDIS_BASE_OPTION, - // Limit retries for queue operations - maxRetriesPerRequest: 3 - }); - return redis; -}; - -export const newWorkerRedisConnection = () => { - const redis = new Redis(REDIS_URL, { - ...REDIS_BASE_OPTION, - // BullMQ requires maxRetriesPerRequest: null for blocking operations - maxRetriesPerRequest: null - }); - return redis; -}; - -export const FASTGPT_REDIS_PREFIX = 'fastgpt:'; -export const getGlobalRedisConnection = () => { - if (global.redisClient) return global.redisClient; - - global.redisClient = new Redis(REDIS_URL, { - ...REDIS_BASE_OPTION, - keyPrefix: FASTGPT_REDIS_PREFIX, - maxRetriesPerRequest: 3 - }); - - global.redisClient.on('connect', () => { - logger.info('Global Redis connected'); - }); - global.redisClient.on('error', (error) => { - logger.error('Global Redis connection error', { error }); - }); - global.redisClient.on('close', () => { - logger.warn('Global Redis connection closed'); - }); - - return global.redisClient; +import { + getGlobalRedisConnection, + newQueueRedisConnection, + newWorkerRedisConnection, + FASTGPT_REDIS_PREFIX +} from './connection'; +import { getAllKeysByPrefix as getAllKeysImpl } from './cluster'; + +// Re-export for backward compatibility +export { + getGlobalRedisConnection, + newQueueRedisConnection, + newWorkerRedisConnection, + FASTGPT_REDIS_PREFIX }; +// Update getAllKeysByPrefix to use cluster-aware implementation export const getAllKeysByPrefix = async (key: string) => { if (!key) return []; const redis = getGlobalRedisConnection(); - const prefix = FASTGPT_REDIS_PREFIX; - const pattern = `${prefix}${key}:*`; - - let cursor = '0'; - const batchSize = 1000; // SCAN 每次取多少 - const results: string[] = []; - - do { - const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize); - cursor = nextCursor; - - for (const k of keys) { - results.push(k.replace(FASTGPT_REDIS_PREFIX, '')); - } - } while (cursor !== '0'); - - return results; + return await getAllKeysImpl(redis, key); }; diff --git a/packages/service/common/redis/type.ts b/packages/service/common/redis/type.ts index 03e3ecbb1f8f..f74bfc27184a 100644 --- a/packages/service/common/redis/type.ts +++ b/packages/service/common/redis/type.ts @@ -1,5 +1,6 @@ import type Redis from 'ioredis'; +import type { Cluster } from 'ioredis'; declare global { - var redisClient: Redis | null; + var redisClient: Redis | Cluster | null; } diff --git a/packages/service/common/response/index.ts b/packages/service/common/response/index.ts index ad291c580269..c60502f8ec98 100644 --- a/packages/service/common/response/index.ts +++ b/packages/service/common/response/index.ts @@ -26,6 +26,10 @@ export interface ProcessedError { zodError?: any; } +function isValidHttpStatusCode(code: number): boolean { + return Number.isInteger(code) && code >= 100 && code <= 599; +} + /** * 业务 JSON `code` 与 HTTP 状态码解耦:多数业务码为 5xxxxx,不能当作 HTTP status。 * 仅对明确语义映射到 4xx/5xx,其余默认 500。 @@ -77,9 +81,14 @@ export function processError(params: { if (ERROR_RESPONSE[errResponseKey]) { const shouldClearCookie = errResponseKey === ERROR_ENUM.unAuthorization; + let validStatusCode = defaultCode; + if (isValidHttpStatusCode(ERROR_RESPONSE[errResponseKey].code)) { + validStatusCode = ERROR_RESPONSE[errResponseKey].code; + } // 记录业务侧错误日志 logger.info('API response error', { url, + statusCode: validStatusCode, code: ERROR_RESPONSE[errResponseKey].code, message: ERROR_RESPONSE[errResponseKey].message, statusText: ERROR_RESPONSE[errResponseKey].statusText, @@ -87,7 +96,7 @@ export function processError(params: { }); return { - code: ERROR_RESPONSE[errResponseKey].code || defaultCode, + code: validStatusCode, statusText: ERROR_RESPONSE[errResponseKey].statusText || 'error', message: ERROR_RESPONSE[errResponseKey].message, data: ERROR_RESPONSE[errResponseKey].data, @@ -216,11 +225,11 @@ export const getSseErrorResponse = ( }; } - let msg = error?.response?.statusText || error?.message || '请求错误'; + let msg = error?.response?.statusText || error?.message || 'Request error'; if (typeof error === 'string') { msg = error; } else if (proxyError[error?.code]) { - msg = '网络连接异常'; + msg = 'Network error'; } else if (error?.response?.data?.error?.message) { msg = error?.response?.data?.error?.message; } else if (error?.error?.message) { diff --git a/packages/service/common/s3/buckets/base.ts b/packages/service/common/s3/buckets/base.ts index a807e3d1b097..4afa58b6f4bb 100644 --- a/packages/service/common/s3/buckets/base.ts +++ b/packages/service/common/s3/buckets/base.ts @@ -24,7 +24,6 @@ import { getLogger, LogCategories } from '../../logger'; import { addS3DelJob } from '../queue/delete'; import { type UploadFileByBufferParams, UploadFileByBodySchema } from '../contracts/type'; import type { createStorage } from '@fastgpt-sdk/storage'; -import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; import { getContentDisposition } from '@fastgpt/global/common/file/tools'; import { jwtSignS3DownloadToken, jwtSignS3UploadToken } from '../security/token'; @@ -298,7 +297,7 @@ export class S3BaseBucket { const contentLength = metadataResponse.contentLength; const filename: string = decodeURIComponent(metadataResponse.metadata.originFilename || ''); - const extension = parseFileExtensionFromUrl(filename); + const extension = path.extname(filename).slice(1).toLowerCase(); const contentType: string = metadataResponse.contentType || 'application/octet-stream'; return { diff --git a/packages/service/common/s3/queue/delete.ts b/packages/service/common/s3/queue/delete.ts index c62457b24ddc..1f1e34b17791 100644 --- a/packages/service/common/s3/queue/delete.ts +++ b/packages/service/common/s3/queue/delete.ts @@ -30,7 +30,7 @@ export const addS3DelJob = async (data: S3MQJobData): Promise => { const jobId = (() => { if (data.key) return data.key; if (data.keys) return undefined; - if (data.prefix) return `${data.bucketName}:${data.prefix}`; + if (data.prefix) return `${data.bucketName}-${data.prefix}`; throw new Error('Invalid s3 delete job data'); })(); diff --git a/packages/service/common/s3/sources/dataset/index.ts b/packages/service/common/s3/sources/dataset/index.ts index 78af6ffa5e2f..0c3d610ba150 100644 --- a/packages/service/common/s3/sources/dataset/index.ts +++ b/packages/service/common/s3/sources/dataset/index.ts @@ -17,7 +17,7 @@ import { MongoS3TTL } from '../../models/ttl'; import { addHours } from 'date-fns'; import { getLogger, LogCategories } from '../../../logger'; import { detectFileEncoding } from '@fastgpt/global/common/file/tools'; -import { readFileContentByBuffer } from '../../../file/read/utils'; +import { readS3FileContentByBuffer } from '../../../file/read/utils'; import path from 'node:path'; import { resolveMimeType } from '../../utils/mime'; import { datasetAllowedExtensions } from '../../utils/uploadConstraints'; @@ -94,13 +94,15 @@ export class S3DatasetSource extends S3PrivateBucket { } async getDatasetFileRawText(params: GetDatasetFileContentParams) { - const { fileId, teamId, tmbId, customPdfParse, getFormatText, usageId } = + const { fileId, teamId, tmbId, customPdfParse, getFormatText, usageId, parseConfig } = GetDatasetFileContentParamsSchema.parse(params); const rawTextBuffer = await this.rawTextSource.getRawTextBuffer({ customPdfParse, - sourceId: fileId + sourceId: fileId, + parseConfig }); + if (rawTextBuffer) { return { rawText: rawTextBuffer.text, @@ -124,9 +126,9 @@ export class S3DatasetSource extends S3PrivateBucket { size: buffer.length }); - const encoding = detectFileEncoding(buffer); + const encoding = detectFileEncoding(buffer) || 'utf-8'; const { fileParsedPrefix } = getFileS3Key.s3Key(fileId); - const { rawText } = await readFileContentByBuffer({ + const { rawText } = await readS3FileContentByBuffer({ teamId, tmbId, extension, @@ -135,17 +137,27 @@ export class S3DatasetSource extends S3PrivateBucket { customPdfParse, usageId, getFormatText, + filename, + parseConfig, imageKeyOptions: { prefix: fileParsedPrefix } }); - this.rawTextSource.addRawTextBuffer({ - sourceId: fileId, - sourceName: filename, - text: rawText, - customPdfParse - }); + this.rawTextSource + .addRawTextBuffer({ + sourceId: fileId, + sourceName: filename, + text: rawText, + customPdfParse, + parseConfig + }) + .catch((err) => { + logger.warn('Failed to write rawText cache in getDatasetFileRawText', { + fileId, + error: err + }); + }); return { rawText, @@ -153,6 +165,22 @@ export class S3DatasetSource extends S3PrivateBucket { }; } + // 下载原始文件字节(不做格式转换) + async getDatasetFileBuffer( + fileId: string + ): Promise<{ buffer: Buffer; extension: string; filename: string }> { + const [fileMetadata, downloadResponse] = await Promise.all([ + this.getFileMetadata(fileId), + this.client.downloadObject({ key: fileId }) + ]); + const buffer = await streamConsumer.buffer(downloadResponse.body); + return { + buffer, + extension: fileMetadata?.extension || '', + filename: fileMetadata?.filename || '' + }; + } + // 根据文件 Buffer 上传文件 async upload(params: UploadParams): Promise { const { datasetId, filename, contentType, ...file } = UploadParamsSchema.parse(params); diff --git a/packages/service/common/s3/sources/dataset/type.ts b/packages/service/common/s3/sources/dataset/type.ts index c5c61f9a9406..d30250a0e55e 100644 --- a/packages/service/common/s3/sources/dataset/type.ts +++ b/packages/service/common/s3/sources/dataset/type.ts @@ -30,7 +30,8 @@ export const GetDatasetFileContentParamsSchema = z.object({ customPdfParse: z.boolean().optional(), getFormatText: z.boolean().optional(), // 数据类型都尽可能转化成 markdown 格式 datasetId: ObjectIdSchema, - usageId: ObjectIdSchema.optional() + usageId: ObjectIdSchema.optional(), + parseConfig: z.record(z.string(), z.union([z.boolean(), z.null(), z.undefined()])).optional() }); export type GetDatasetFileContentParams = z.infer; diff --git a/packages/service/common/s3/sources/rawText/index.ts b/packages/service/common/s3/sources/rawText/index.ts index 01f76353bd03..d0d5050ac695 100644 --- a/packages/service/common/s3/sources/rawText/index.ts +++ b/packages/service/common/s3/sources/rawText/index.ts @@ -25,12 +25,12 @@ export class S3RawTextSource extends S3PrivateBucket { } async addRawTextBuffer(params: AddRawTextBufferParams) { - const { sourceId, sourceName, text, customPdfParse } = + const { sourceId, sourceName, text, customPdfParse, parseConfig } = AddRawTextBufferParamsSchema.parse(params); // 因为 Key 唯一对应一个 Object 所以不需要根据文件内容计算 Hash 直接用 Key 计算 Hash 就行了 const hash = createHash('md5').update(sourceId).digest('hex'); - const key = getFileS3Key.rawText({ hash, customPdfParse }); + const key = getFileS3Key.rawText({ hash, customPdfParse, parseConfig }); const buffer = Buffer.from(text); await MongoS3TTL.create({ @@ -53,10 +53,10 @@ export class S3RawTextSource extends S3PrivateBucket { } async getRawTextBuffer(params: GetRawTextBufferParams) { - const { customPdfParse, sourceId } = params; + const { customPdfParse, sourceId, parseConfig } = params; const hash = createHash('md5').update(sourceId).digest('hex'); - const key = getFileS3Key.rawText({ hash, customPdfParse }); + const key = getFileS3Key.rawText({ hash, customPdfParse, parseConfig }); if (!(await this.isObjectExists(key))) return null; diff --git a/packages/service/common/s3/sources/rawText/type.ts b/packages/service/common/s3/sources/rawText/type.ts index 4979abcc010b..aa84e73c118b 100644 --- a/packages/service/common/s3/sources/rawText/type.ts +++ b/packages/service/common/s3/sources/rawText/type.ts @@ -2,9 +2,13 @@ import z from 'zod'; export const AddRawTextBufferParamsSchema = z.object({ customPdfParse: z.boolean().optional(), + parseConfig: z.record(z.string(), z.union([z.boolean(), z.null(), z.undefined()])).optional(), sourceId: z.string().nonempty(), sourceName: z.string().nonempty(), text: z.string() }); export type AddRawTextBufferParams = z.input; -export type GetRawTextBufferParams = Pick; +export type GetRawTextBufferParams = Pick< + AddRawTextBufferParams, + 'customPdfParse' | 'sourceId' | 'parseConfig' +>; diff --git a/packages/service/common/s3/utils.ts b/packages/service/common/s3/utils.ts index 8287c0fa49f8..1e109f33fdac 100644 --- a/packages/service/common/s3/utils.ts +++ b/packages/service/common/s3/utils.ts @@ -6,6 +6,7 @@ import { S3PrivateBucket } from './buckets/private'; import { S3Sources, type UploadImage2S3BucketParams } from './contracts/type'; import { S3PublicBucket } from './buckets/public'; import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import type { ParsedFileContentS3KeyParams } from './sources/dataset/type'; import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/type'; @@ -241,8 +242,19 @@ export const getFileS3Key = { }; }, - rawText: ({ hash, customPdfParse }: { hash: string; customPdfParse?: boolean }) => { - return [S3Sources.rawText, `${hash}${customPdfParse ? '-true' : ''}`].join('/'); + rawText: ({ + hash, + customPdfParse, + parseConfig + }: { + hash: string; + customPdfParse?: boolean; + parseConfig?: Record; + }) => { + const parseConfigHash = parseConfig && Object.keys(parseConfig).length > 0 + ? `-${createHash('md5').update(JSON.stringify(parseConfig)).digest('hex').slice(0, 8)}` + : ''; + return [S3Sources.rawText, `${hash}${customPdfParse ? '-true' : ''}${parseConfigHash}`].join('/'); } }; diff --git a/packages/service/common/s3/utils/uploadConstraints.ts b/packages/service/common/s3/utils/uploadConstraints.ts index 6956c1fce133..3a5019990c96 100644 --- a/packages/service/common/s3/utils/uploadConstraints.ts +++ b/packages/service/common/s3/utils/uploadConstraints.ts @@ -36,7 +36,13 @@ export const parseAllowedExtensions = (value: string) => { return normalizeAllowedExtensions(value.split(',')); }; -export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']); +export const avatarAllowedExtensions = normalizeAllowedExtensions([ + '.jpg', + '.jpeg', + '.png', + '.svg', + '.ico' +]); export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType); export const getAllowedExtensionsFromFileSelectConfig = (config?: AppFileSelectConfigType) => { diff --git a/packages/service/common/s3/validation/upload.ts b/packages/service/common/s3/validation/upload.ts index fbbf49785e9f..7a1ec845aba9 100644 --- a/packages/service/common/s3/validation/upload.ts +++ b/packages/service/common/s3/validation/upload.ts @@ -99,7 +99,14 @@ const getOfficeZipFormatByExtension = (extension: string) => const MIME_EQUIVALENCE_GROUPS: ReadonlyArray> = [ new Set(['video/x-msvideo', 'video/vnd.avi', 'video/avi', 'video/msvideo']), new Set(['video/mpeg', 'video/mp1s', 'video/mp2p']), - new Set(['audio/mp4', 'audio/x-m4a']) + new Set(['audio/mp4', 'audio/x-m4a']), + // CFB (Compound File Binary) format used by legacy Office documents (.doc, .ppt, .xls) + new Set([ + 'application/x-cfb', + 'application/msword', + 'application/vnd.ms-powerpoint', + 'application/vnd.ms-excel' + ]) ]; const normalizeMimeForCompare = (mime: string) => mime.split(';')[0]?.trim().toLowerCase() || ''; diff --git a/packages/service/common/string/cheerio.ts b/packages/service/common/string/cheerio.ts index cddc4779e893..bb12c235ce42 100644 --- a/packages/service/common/string/cheerio.ts +++ b/packages/service/common/string/cheerio.ts @@ -6,6 +6,55 @@ import { isInternalAddress } from '../system/utils'; import { getLogger, LogCategories } from '../logger'; const logger = getLogger(LogCategories.HTTP.ERROR); +import FormData from 'form-data'; + +/** + * 将HTML转换为Markdown,优先使用自定义服务(如果配置了的话) + * 自动降级到默认的htmlToMarkdown + */ +export const convertHtmlToMarkdown = async (html: string): Promise => { + // 判断是否配置了自定义HTML解析服务 + if (global.systemEnv.customPdfParse?.url) { + try { + logger.info('Using custom parse service for HTML'); + const buffer = Buffer.from(html, 'utf-8'); + const data = new FormData(); + data.append('file', buffer, { filename: 'page.html' }); + + const { data: response } = await axios.post<{ + pages: number; + markdown: string; + error?: Object | string; + }>(global.systemEnv.customPdfParse.url, data, { + timeout: (global.systemEnv.customPdfParse.timeout || 10) * 1000 * 60, + headers: { + ...data.getHeaders(), + ...(global.systemEnv.customPdfParse.key + ? { Authorization: `Bearer ${global.systemEnv.customPdfParse.key}` } + : {}) + } + }); + + if (response.error) { + logger.warn('Custom parse service returned error, fallback to htmlToMarkdown', { + error: response.error + }); + return await htmlToMarkdown(html); + } + + logger.info('Custom parse service completed', { mdLength: response.markdown.length }); + return response.markdown; + } catch (error) { + logger.error('Custom parse service failed, fallback to htmlToMarkdown', { + error: error instanceof Error ? error.message : String(error) + }); + return await htmlToMarkdown(html); + } + } else { + logger.debug('Using default htmlToMarkdown'); + return await htmlToMarkdown(html); + } +}; export const cheerioToHtml = ({ fetchUrl, @@ -101,7 +150,8 @@ export const urlsFetch = async ({ selector }); - const md = await htmlToMarkdown(html); + // 使用convertHtmlToMarkdown处理HTML,自动选择自定义服务或默认方式 + const md = await convertHtmlToMarkdown(html); return { url, diff --git a/packages/service/common/string/extractive.ts b/packages/service/common/string/extractive.ts new file mode 100644 index 000000000000..54153803f0e0 --- /dev/null +++ b/packages/service/common/string/extractive.ts @@ -0,0 +1,224 @@ +import { addLog } from '../system/log'; +import { stopWords } from './jieba/index'; + +type ScoredSentence = { + sentence: string; + score: number; + position: number; // sentence position in document + keywordMatches: number; // mathches times of keywords in sentence +}; + +export type ExtractiveTextParams = { + text: string; + keyword: string; + maxLength?: number; // default 200 + minLength?: number; // default 50 +}; + +const BM25_K1 = 1.5; // word frequency scaling factor +const BM25_B = 0.75; // length normalization factor + +export const extractiveText = ({ + text, + keyword, + maxLength = 200, + minLength = 50 +}: ExtractiveTextParams): string => { + const cleanedText = text.trim(); + + if (cleanedText.length <= minLength) { + return cleanedText; + } + + const keywords = splitKeywords(keyword.toLowerCase()); + if (keywords.length === 0) { + return truncateText(cleanedText, maxLength); + } + + const sentences = splitSentences(cleanedText); + if (sentences.length === 0) { + return truncateText(cleanedText, maxLength); + } + + const scoredSentences = sentences.map((sentence, index) => + scoreSentence(sentence, index, keywords, sentences) + ); + + const sortedSentences = scoredSentences + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score); + + if (sortedSentences.length === 0) { + return truncateText(cleanedText, maxLength); + } + + const extractedText = buildExtractionWindow(sortedSentences, sentences, maxLength); + addLog.debug(`Extracted Text: ${extractedText}`); + return extractedText; +}; + +const splitKeywords = (keyword: string): string[] => { + // remove unused charactor + const cleaned = keyword.replace(/[^\u4e00-\u9fa5a-z0-9\s]/gi, ' '); + + const words = cleaned + .split(/\s+/) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + .filter((w) => !stopWords.has(w)); + + return words; +}; + +const splitSentences = (text: string): string[] => { + const sentenceRegex = /([^。!?;.!?;\n]+[。!?;.!?;\n]?)/g; + const matches = text.match(sentenceRegex); + + if (!matches) return [text]; + + return matches.map((s) => s.trim()).filter((s) => s.length > 0); +}; + +/** + * IDF(q) = ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1) + * + */ +const calculateIDF = (keyword: string, allSentences: string[], sentenceCount: number): number => { + let docFreq = 0; + for (const sentence of allSentences) { + if (sentence.toLowerCase().includes(keyword.toLowerCase())) { + docFreq++; + } + } + + // IDF:ln((N - df + 0.5) / (df + 0.5) + 1) + // add smoothing to avoid division by zero + const idf = Math.log((sentenceCount - docFreq + 0.5) / (docFreq + 0.5) + 1); + + return Math.max(idf, 0.1); // avoid zero or negative IDF +}; + +/** + * calculate term frequency of keyword in sentence + */ +const calculateTermFrequency = (keyword: string, sentence: string): number => { + const lowerSentence = sentence.toLowerCase(); + const lowerKeyword = keyword.toLowerCase(); + const regex = new RegExp(escapeRegExp(lowerKeyword), 'gi'); + const matches = lowerSentence.match(regex); + return matches ? matches.length : 0; +}; + +/** + * BM25(D, Q) = Σ IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 - b + b * |D| / avgdl)) + */ +const scoreSentence = ( + sentence: string, + position: number, + keywords: string[], + allSentences: string[] +): ScoredSentence => { + const sentenceLength = sentence.length; + const sentenceCount = allSentences.length; + + const avgDocLength = allSentences.reduce((sum, s) => sum + s.length, 0) / sentenceCount; + + let bm25Score = 0; + let totalMatches = 0; + + for (const keyword of keywords) { + const tf = calculateTermFrequency(keyword, sentence); + if (tf === 0) continue; + + totalMatches += tf; + + const idf = calculateIDF(keyword, allSentences, sentenceCount); + + const numerator = tf * (BM25_K1 + 1); + const denominator = tf + BM25_K1 * (1 - BM25_B + (BM25_B * sentenceLength) / avgDocLength); + + bm25Score += idf * (numerator / denominator); + } + + // add position-based boost (earlier sentences get slight boost) + const positionBoost = 1.0 - (position / sentenceCount) * 0.1; + const finalScore = bm25Score * positionBoost; + + return { + sentence, + score: finalScore, + position, + keywordMatches: totalMatches + }; +}; + +/** + * returns escaped string for use in RegExp + */ +const escapeRegExp = (str: string): string => { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}; + +// try to build extraction window around highest scored sentence +const buildExtractionWindow = ( + scoredSentences: ScoredSentence[], + allSentences: string[], + maxLength: number +): string => { + if (scoredSentences.length === 0) return ''; + + const bestSentence = scoredSentences[0]; + const centerPosition = bestSentence.position; + + if (bestSentence.sentence.length >= maxLength) { + return truncateText(bestSentence.sentence, maxLength); + } + + let windowText = bestSentence.sentence; + let currentLength = windowText.length; + + let left = centerPosition - 1; + let right = centerPosition + 1; + + while (currentLength < maxLength && (left >= 0 || right < allSentences.length)) { + let added = false; + + while (left >= 0) { + const leftSentence = allSentences[left]; + if (currentLength + leftSentence.length <= maxLength) { + windowText = leftSentence + windowText; + currentLength += leftSentence.length; + added = true; + } else { + added = false; + break; + } + left--; + } + + while (right < allSentences.length) { + const rightSentence = allSentences[right]; + if (currentLength + rightSentence.length <= maxLength) { + windowText = windowText + rightSentence; + currentLength += rightSentence.length; + added = true; + } else { + added = false; + break; + } + right++; + } + + if (!added) { + break; + } + } + + return windowText.trim(); +}; + +const truncateText = (text: string, maxLength: number): string => { + if (text.length <= maxLength) return text; + + return text.slice(0, maxLength) + '...'; +}; diff --git a/packages/service/common/string/jieba/index.ts b/packages/service/common/string/jieba/index.ts index 8df8df95dc3f..27da1aab760c 100644 --- a/packages/service/common/string/jieba/index.ts +++ b/packages/service/common/string/jieba/index.ts @@ -1,15 +1,21 @@ import { Jieba } from '@node-rs/jieba'; +import { addLog } from '../../system/log'; let jieba: Jieba | undefined; (async () => { const dictData = await import('./dict.json'); // @ts-ignore - const dictBuffer = Buffer.from(dictData.dict?.replace(/\\n/g, '\n'), 'utf-8'); + const dictBuffer = new Uint8Array(Buffer.from(dictData.dict?.replace(/\\n/g, '\n'), 'utf-8')); jieba = Jieba.withDict(dictBuffer); })(); -const stopWords = new Set([ +// jieba 实例缓存,使用词汇的 hash 作为 key +// 最多缓存 10 个自定义词典的 jieba 实例 +const jiebaCache = new Map(); +const MAX_CACHE_SIZE = 10; + +export const stopWords = new Set([ '\n', '--', '?', @@ -1530,3 +1536,169 @@ export async function jiebaSplit({ text }: { text: string }) { .join(' ') || '' ); } + +/** + * 生成词汇列表的缓存 key + * 使用简单的哈希算法,避免引入额外依赖 + */ +function generateCacheKey(words: string[]): string { + // 排序后连接,确保相同词汇列表生成相同 key + const sortedWords = [...words].sort().join('|'); + // 简单哈希 + let hash = 0; + for (let i = 0; i < sortedWords.length; i++) { + const char = sortedWords.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & 0xffffffff; // Convert to 32bit unsigned integer + } + return `jieba_${hash}_${words.length}`; +} + +/** + * 使用自定义词典进行分词(带缓存) + * @param text - 待分词文本 + * @param customWords - 自定义词汇列表,这些词会被优先识别为完整词 + * @returns 分词后的文本(空格分隔) + */ +export async function jiebaSplitWithCustomDict({ + text, + customWords = [] +}: { + text: string; + customWords?: string[]; +}) { + text = text.replace(/[#*`_~>[\](){}|]|\S*https?\S*/g, '').trim(); + + // 如果没有自定义词汇,直接使用默认分词 + if (customWords.length === 0) { + const tokens = (await jieba!.cutAsync(text, true)) as string[]; + return ( + tokens + .map((item) => item.replace(/[\u3000-\u303f\uff00-\uffef]/g, '').trim()) + .filter((item) => item && !stopWords.has(item)) + .join(' ') || '' + ); + } + + // 生成缓存 key + const cacheKey = generateCacheKey(customWords); + + // 尝试从缓存获取 jieba 实例 + let cachedJieba = jiebaCache.get(cacheKey); + + if (!cachedJieba) { + try { + // 过滤并格式化自定义词汇 + const validWords = customWords + .filter((word) => word && word.trim().length > 0) + .map((word) => word.trim()); + + if (validWords.length === 0) { + const tokens = (await jieba!.cutAsync(text, true)) as string[]; + return ( + tokens + .map((item) => item.replace(/[\u3000-\u303f\uff00-\uffef]/g, '').trim()) + .filter((item) => item && !stopWords.has(item)) + .join(' ') || '' + ); + } + + // 创建自定义词典字符串 + // jieba 词典格式: 每行 "词语 词频 词性" + // 参考: https://github.com/fxsjy/jieba + addLog.debug('有效词汇检查:', { + totalWords: validWords.length + }); + + // 过滤掉无效词汇(空字符串、特殊字符等) + const filteredWords = validWords.filter( + (word) => word && word.trim().length > 0 && !/[\n\r\t]/.test(word) && !word.includes(' ') + ); + + addLog.debug('过滤后词汇:', { + originalCount: validWords.length, + filteredCount: filteredWords.length + }); + + // 只有在有有效词汇时才创建自定义词典 + let customDictBuffer: Uint8Array; + if (filteredWords.length > 0) { + const customDictLines = filteredWords.map((word) => `${word} 999999 n`).join('\n'); + customDictBuffer = new Uint8Array(Buffer.from(customDictLines + '\n', 'utf-8')); + addLog.debug('自定义词典内容预览:', { + lineCount: customDictLines.split('\n').length, + preview: customDictLines.substring(0, 200) + }); + } else { + // 如果没有有效词汇,创建空词典 + customDictBuffer = new Uint8Array(Buffer.from('', 'utf-8')); + addLog.debug('没有有效词汇,使用空词典'); + } + + // 创建新的 jieba 实例 + const dictData = await import('./dict.json'); + // @ts-ignore + const dictBuffer = new Uint8Array(Buffer.from(dictData.dict?.replace(/\\n/g, '\n'), 'utf-8')); + cachedJieba = Jieba.withDict(dictBuffer); + + // 尝试加载自定义词典 + try { + addLog.debug('准备加载自定义词典:', { + totalWords: filteredWords.length, + bufferSize: customDictBuffer.length + }); + + // 只有在字典不为空时才尝试加载 + if (filteredWords.length > 0) { + cachedJieba.loadDict(customDictBuffer); + } + addLog.debug('自定义词典加载成功'); + } catch (loadError) { + addLog.error('加载自定义词典失败,将使用默认分词:', { + error: loadError instanceof Error ? loadError.message : String(loadError), + errorStack: loadError instanceof Error ? loadError.stack : undefined, + wordsCount: filteredWords.length, + bufferSize: customDictBuffer.length + }); + // 加载失败,使用默认 jieba 实例 + cachedJieba = jieba!; + } + + // 限制缓存大小,使用 LRU 策略 + if (jiebaCache.size >= MAX_CACHE_SIZE) { + // 删除第一个(最早添加的) + const firstKey = jiebaCache.keys().next().value; + if (firstKey !== undefined) { + jiebaCache.delete(firstKey); + } + } + + // 存入缓存 + jiebaCache.set(cacheKey, cachedJieba); + } catch (error) { + addLog.error('创建自定义 jieba 实例失败,使用默认分词:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + customWordsCount: customWords.length + }); + // 创建失败时使用默认分词 + const tokens = (await jieba!.cutAsync(text, true)) as string[]; + return ( + tokens + .map((item) => item.replace(/[\u3000-\u303f\uff00-\uffef]/g, '').trim()) + .filter((item) => item && !stopWords.has(item)) + .join(' ') || '' + ); + } + } + + // 使用缓存的实例进行分词 + const tokens = (await cachedJieba.cutAsync(text, true)) as string[]; + + return ( + tokens + .map((item) => item.replace(/[\u3000-\u303f\uff00-\uffef]/g, '').trim()) + .filter((item) => item && !stopWords.has(item)) + .join(' ') || '' + ); +} diff --git a/packages/service/common/system/config/controller.ts b/packages/service/common/system/config/controller.ts index 420f53db69d1..85b511681487 100644 --- a/packages/service/common/system/config/controller.ts +++ b/packages/service/common/system/config/controller.ts @@ -3,6 +3,8 @@ import { MongoSystemConfigs } from './schema'; import { type FastGPTConfigFileType } from '@fastgpt/global/common/system/types'; import { FastGPTProUrl } from '../constants'; import { type LicenseDataType } from '@fastgpt/global/common/system/types'; +import fs, { existsSync } from 'fs'; +import { isProduction } from '@fastgpt/global/common/system/constants'; export const getFastGPTConfigFromDB = async (): Promise<{ fastgptConfig: FastGPTConfigFileType; @@ -67,3 +69,29 @@ export const reloadFastGPTConfigBuffer = async () => { if (!res) return; global.systemInitBufferId = res.createTime.getTime().toString(); }; + +export const readConfigData = async (name: string) => { + const splitName = name.split('.'); + const devName = `${splitName[0]}.local.${splitName[1]}`; + + const filename = (() => { + if (!isProduction) { + const hasLocalFile = existsSync(`data/${devName}`); + if (hasLocalFile) { + return `data/${devName}`; + } + return `data/${name}`; + } + const envPath = process.env.CONFIG_JSON_PATH || '/app/data'; + return `${envPath}/${name}`; + })(); + + let content: string; + try { + content = await fs.promises.readFile(filename, 'utf-8'); + } catch (err) { + console.error(`Failed to read file ${filename}:`, err); + throw err; + } + return content; +}; diff --git a/packages/service/common/system/log.ts b/packages/service/common/system/log.ts new file mode 100644 index 000000000000..ea88295c3cca --- /dev/null +++ b/packages/service/common/system/log.ts @@ -0,0 +1,172 @@ +import dayjs from 'dayjs'; +import chalk from 'chalk'; +import { LogLevelEnum } from './log/constant'; +import { connectionMongo } from '../mongo/index'; +import { getMongoLog } from './log/schema'; +import { getLogger } from '../otel/log'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +export enum EventTypeEnum { + outLinkBot = '[Outlink bot]', + feishuBot = '[Feishu bot]', + wxOffiaccount = '[Offiaccount bot]' +} + +const logger = getLogger(); + +const logMap = { + [LogLevelEnum.debug]: { + levelLog: chalk.green('[Debug]') + }, + [LogLevelEnum.info]: { + levelLog: chalk.blue('[ Info]') + }, + [LogLevelEnum.warn]: { + levelLog: chalk.yellow('[ Warn]') + }, + [LogLevelEnum.error]: { + levelLog: chalk.red('[Error]') + } +}; +const envLogLevelMap: Record = { + debug: LogLevelEnum.debug, + info: LogLevelEnum.info, + warn: LogLevelEnum.warn, + error: LogLevelEnum.error +}; + +const { LOG_LEVEL, STORE_LOG_LEVEL, SIGNOZ_STORE_LEVEL } = (() => { + const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLocaleLowerCase(); + const STORE_LOG_LEVEL = (process.env.STORE_LOG_LEVEL || '').toLocaleLowerCase(); + const SIGNOZ_STORE_LEVEL = (process.env.SIGNOZ_STORE_LEVEL || 'warn').toLocaleLowerCase(); + + return { + LOG_LEVEL: envLogLevelMap[LOG_LEVEL] ?? LogLevelEnum.info, + STORE_LOG_LEVEL: envLogLevelMap[STORE_LOG_LEVEL] ?? 99, + SIGNOZ_STORE_LEVEL: envLogLevelMap[SIGNOZ_STORE_LEVEL] ?? LogLevelEnum.warn + }; +})(); + +/** + * Sanitize object to prevent circular references for BSON serialization + * Remove properties that may contain circular references + */ +const sanitizeObjectForBSON = (obj: Record): Record => { + try { + // Use JSON stringify with replacer to handle circular references + const seen = new WeakSet(); + const sanitized = JSON.parse( + JSON.stringify(obj, (key, value) => { + // Handle circular references + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + } + + // Remove known problematic properties from axios config + if (key === 'config' && value && typeof value === 'object') { + return { + method: value.method, + url: value.url, + baseURL: value.baseURL, + headers: value.headers, + timeout: value.timeout, + responseType: value.responseType + }; + } + + // Remove functions and other non-serializable values + if (typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + return value; + }) + ); + return sanitized; + } catch (error) { + // If sanitization fails, return a safe fallback + return { + error: 'Failed to sanitize object', + originalKeys: Object.keys(obj) + }; + } +}; + +/* add logger */ +export const addLog = { + log(level: LogLevelEnum, msg: string, obj: Record = {}) { + if (level < LOG_LEVEL) return; + + const stringifyObj = JSON.stringify(obj); + const isEmpty = Object.keys(obj).length === 0; + + console.log( + `${logMap[level].levelLog} ${dayjs().format('YYYY-MM-DD HH:mm:ss')} ${msg} ${ + level !== LogLevelEnum.error && !isEmpty ? stringifyObj : '' + }` + ); + + level === LogLevelEnum.error && console.error(obj); + + if (logger && level >= SIGNOZ_STORE_LEVEL) { + logger.emit({ + severityNumber: level.valueOf(), + severityText: ['debug', 'info', 'warn', 'error'][level], + body: { + msg, + obj + } + }); + } + + // store log + if (level >= STORE_LOG_LEVEL && connectionMongo.connection.readyState === 1) { + (async () => { + try { + // Sanitize metadata to prevent circular reference errors + const safeMetadata = sanitizeObjectForBSON(obj); + + await getMongoLog().create({ + text: msg, + level, + metadata: safeMetadata + }); + } catch (error) { + console.error('store log error', error); + } + })(); + } + }, + debug(msg: string, obj?: Record) { + this.log(LogLevelEnum.debug, msg, obj); + }, + info(msg: string, obj?: Record) { + this.log(LogLevelEnum.info, msg, obj); + }, + warn(msg: string, obj?: Record) { + this.log(LogLevelEnum.warn, msg, obj); + }, + error(msg: string, error?: any) { + this.log(LogLevelEnum.error, msg, { + ...(error?.data && { data: error?.data }), + message: getErrText(error), + stack: error?.stack, + ...(error?.config && { + config: { + headers: error.config.headers, + url: error.config.url, + data: error.config.data + } + }), + ...(error?.response && { + response: { + status: error.response.status, + statusText: error.response.statusText + } + }) + }); + } +}; diff --git a/packages/service/common/system/timerLock/constants.ts b/packages/service/common/system/timerLock/constants.ts index 9aa399528c5e..30e3cd8c5045 100644 --- a/packages/service/common/system/timerLock/constants.ts +++ b/packages/service/common/system/timerLock/constants.ts @@ -13,6 +13,7 @@ export enum TimerIdEnum { recordTeamQPM = 'recordTeamQPM', auditLogCleanup = 'auditLogCleanup', chatHistoryCleanup = 'chatHistoryCleanup', +collectionCleanup = 'collectionCleanup', /** 纠正长时间卡在 generating 的会话状态 */ cleanStaleGeneratingChat = 'cleanStaleGeneratingChat' } diff --git a/packages/service/common/system/tools.ts b/packages/service/common/system/tools.ts index c1bcf06f16af..016889ddaf8f 100644 --- a/packages/service/common/system/tools.ts +++ b/packages/service/common/system/tools.ts @@ -19,8 +19,12 @@ export const initFastGPTConfig = (config?: FastGPTConfigFileType) => { !!config.systemEnv.customPdfParse?.textinAppId || !!config.systemEnv.customPdfParse?.doc2xKey; config.feConfigs.customPdfParsePrice = config.systemEnv.customPdfParse?.price || 0; - config.feConfigs.uploadFileMaxSize = Number(process.env.UPLOAD_FILE_MAX_SIZE || 1000); - config.feConfigs.uploadFileMaxAmount = Number(process.env.UPLOAD_FILE_MAX_AMOUNT || 1000); + config.feConfigs.uploadFileMaxSize = Number( + process.env.UPLOAD_FILE_MAX_SIZE || config.feConfigs.uploadFileMaxSize || 200 + ); + config.feConfigs.uploadFileMaxAmount = Number( + process.env.UPLOAD_FILE_MAX_AMOUNT || config.feConfigs.uploadFileMaxAmount || 20 + ); global.feConfigs = config.feConfigs; global.systemEnv = config.systemEnv; diff --git a/packages/service/common/tracing/client.ts b/packages/service/common/tracing/client.ts index 7d632f9f85a8..a11fa802bd77 100644 --- a/packages/service/common/tracing/client.ts +++ b/packages/service/common/tracing/client.ts @@ -60,6 +60,9 @@ function normalizeAttributes(attributes?: Record) { } export async function configureTracing() { + const { initLangfuseTracing } = await import('../langfuse'); + await initLangfuseTracing(); + await configureTracingFromEnv({ env, defaultServiceName: 'fastgpt-client', diff --git a/packages/service/common/vectorDB/constants.ts b/packages/service/common/vectorDB/constants.ts index 302ee3a52553..c4f156843991 100644 --- a/packages/service/common/vectorDB/constants.ts +++ b/packages/service/common/vectorDB/constants.ts @@ -1,12 +1,21 @@ export const DatasetVectorDbName = 'fastgpt'; export const DatasetVectorTableName = 'modeldata'; - +export const DBDatasetVectorTableName = 'coulumndescriptionindex'; +export const DBDatasetValueVectorTableName = 'coulumnvalueindex'; export const PG_ADDRESS = process.env.PG_URL; export const OPENGAUSS_ADDRESS = process.env.OPENGAUSS_URL; export const OCEANBASE_ADDRESS = process.env.OCEANBASE_URL; export const SEEKDB_ADDRESS = process.env.SEEKDB_URL; export const MILVUS_ADDRESS = process.env.MILVUS_ADDRESS; export const MILVUS_TOKEN = process.env.MILVUS_TOKEN; +export const MILVUS_TIMEOUT = process.env.MILVUS_TIMEOUT + ? parseInt(process.env.MILVUS_TIMEOUT) + : 300000; // Default: 5 minutes +export const MILVUS_INSERT_TIMEOUT = process.env.MILVUS_INSERT_TIMEOUT + ? parseInt(process.env.MILVUS_INSERT_TIMEOUT) + : 120000; // Default: 2 minutes +export const HNSW_M = process.env.HNSW_M || 32; +export const HNSW_EF_CONSTRUCTION = process.env.HNSW_EF_CONSTRUCTION || 128; export const VectorVQ = (() => { if (process.env.VECTOR_VQ_LEVEL === '32') { diff --git a/packages/service/common/vectorDB/controller.ts b/packages/service/common/vectorDB/controller.ts index c77af4db30a8..e8ab59c62c36 100644 --- a/packages/service/common/vectorDB/controller.ts +++ b/packages/service/common/vectorDB/controller.ts @@ -24,6 +24,9 @@ import { } from '../redis/cache'; import { throttle } from 'lodash'; import { retryFn } from '@fastgpt/global/common/system/utils'; +import { DBDatasetVectorTableName, DBDatasetValueVectorTableName } from './constants'; +import { addLog } from '../system/log'; +import { milvusVersionManager } from './milvus/version'; const getVectorObj = (): VectorControllerType => { if (SEEKDB_ADDRESS) return new SeekVectorCtrl({ type: 'seekdb' }); @@ -71,6 +74,8 @@ const Vector = getVectorObj(); export const initVectorStore = Vector.init; export const recallFromVectorStore: VectorControllerType['embRecall'] = (props) => retryFn(() => Vector.embRecall(props)); +export const databaseEmbeddingRecall = Vector.databaseEmbRecall; +export const getVectorDataByTime = Vector.getVectorDataByTime; export const insertDatasetDataVector = async ({ model, @@ -85,18 +90,69 @@ export const insertDatasetDataVector = async ({ input: inputs, type: 'db' }); - const { insertIds } = await retryFn(() => - Vector.insert({ - ...props, - vectors - }) - ); + // 使用分批插入 + const batchSize = 10; + const allInsertIds: string[] = []; + + const insertData = async (startIndex: number) => { + const batchVectors = vectors.slice(startIndex, startIndex + batchSize); + + if (batchVectors.length === 0) return; + + addLog.debug('[insertDatasetDataVector] Inserting batch', { + startIndex, + batchSize: batchVectors.length, + vectorDim: batchVectors[0]?.length, + teamId: props.teamId, + datasetId: props.datasetId, + collectionId: props.collectionId + }); + + try { + // 插入 Milvus/PG(带全文内容) + const { insertIds } = await retryFn(() => + Vector.insert({ + ...props, + vectors: batchVectors, + // Milvus 2.6+ 传入原始文本,由 BM25 Function 自动生成稀疏向量 + textContents: milvusVersionManager.supportsFullText() + ? inputs.slice(startIndex, startIndex + batchSize) + : undefined, + // Milvus 2.6+ 元数据列表(如果需要) + metadataList: milvusVersionManager.supportsFullText() + ? inputs.slice(startIndex, startIndex + batchSize).map(() => ({})) + : undefined + }) + ); + + addLog.debug('[insertDatasetDataVector] Batch insert success', { + startIndex, + insertCount: insertIds?.length, + insertIds: insertIds?.slice(0, 5) + }); + + if (insertIds) { + allInsertIds.push(...insertIds); + } + } catch (error) { + addLog.error('[insertDatasetDataVector] Batch insert failed', { + startIndex, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + throw error; + } + + return insertData(startIndex + batchSize); + }; + + await insertData(0); - teamVectorCache.incr(props.teamId, insertIds.length); + teamVectorCache.incr(props.teamId, allInsertIds.length); return { tokens, - insertIds + insertIds: allInsertIds }; }; @@ -106,8 +162,6 @@ export const deleteDatasetDataVector: VectorControllerType['delete'] = async (pr return result; }; -export const getVectorDataByTime = Vector.getVectorDataByTime; - // Count vector export const getVectorCountByTeamId = async (teamId: string) => { const cacheCount = await teamVectorCache.get(teamId); @@ -125,3 +179,117 @@ export const getVectorCountByTeamId = async (teamId: string) => { return count; }; export const getVectorCount = Vector.getVectorCount; + +/* Database Dataset specific operations */ +export const insertCoulmnDescriptionVector = async ({ + model, + query, + teamId, + datasetId, + collectionId, + column_des_index +}: { + model: EmbeddingModelItemType; + query: string; + teamId: string; + datasetId: string; + collectionId: string; + column_des_index: string; +}) => { + return retryFn(async () => { + // Step 1: Get vectors from text (Embedding) + let vectors: number[][]; + let tokens: number; + try { + const result = await getVectorsByText({ + model, + input: query, + type: 'db' + }); + vectors = result.vectors; + tokens = result.tokens; + } catch (error: any) { + throw new Error(`Embedding Error: ${error?.message || 'Unknown error'}`); + } + + // Step 2: Insert vectors to database + let insertIds: string[]; + try { + const result = await Vector.insert({ + teamId, + datasetId, + collectionId, + vectors: vectors, + column_des_index, + tableName: DBDatasetVectorTableName + }); + insertIds = result.insertIds; + } catch (error: any) { + throw new Error(`Vector Insert Error: ${error?.message || 'Unknown error'}`); + } + + addLog.debug('insertCoulmnDescriptionVector', { insertIds }); + teamVectorCache.incr(teamId, insertIds.length); + + return { + tokens, + insertIds + }; + }); +}; + +export const insertColumnValueVector = async ({ + model, + query, + teamId, + datasetId, + collectionId, + column_val_index +}: { + model: EmbeddingModelItemType; + query: string; + teamId: string; + datasetId: string; + collectionId: string; + column_val_index: string; +}) => { + return retryFn(async () => { + // Step 1: Get vectors from text (Embedding) + let vectors: number[][]; + let tokens: number; + try { + const result = await getVectorsByText({ + model, + input: query, + type: 'db' + }); + vectors = result.vectors; + tokens = result.tokens; + } catch (error: any) { + throw new Error(`Embedding Error: ${error?.message || 'Unknown error'}`); + } + + // Step 2: Insert vectors to database + let insertIds: string[]; + try { + const result = await Vector.insert({ + teamId, + datasetId, + collectionId, + vectors: vectors, + column_val_index, + tableName: DBDatasetValueVectorTableName + }); + insertIds = result.insertIds; + } catch (error: any) { + throw new Error(`Vector Insert Error: ${error?.message || 'Unknown error'}`); + } + + teamVectorCache.incr(teamId, insertIds.length); + + return { + tokens, + insertIds + }; + }); +}; diff --git a/packages/service/common/vectorDB/milvus/config.ts b/packages/service/common/vectorDB/milvus/config.ts new file mode 100644 index 000000000000..0b543fef39e3 --- /dev/null +++ b/packages/service/common/vectorDB/milvus/config.ts @@ -0,0 +1,203 @@ +import { DataType, FunctionType } from '@zilliz/milvus2-sdk-node'; +import type { FieldType } from '@zilliz/milvus2-sdk-node/dist/milvus/types/Collection'; +import type { CreateIndexSimpleReq } from '@zilliz/milvus2-sdk-node/dist/milvus/types/MilvusIndex'; +import { + DatasetVectorTableName, + DBDatasetVectorTableName, + DBDatasetValueVectorTableName, + HNSW_EF_CONSTRUCTION, + HNSW_M +} from '../constants'; +import { milvusVersionManager } from './version'; + +export const MILVUS_VECTOR_DIMENSION = 1536; +export const MILVUS_TEXT_MAX_LENGTH = 65535; + +// Milvus 2.6+ 多语言分析器配置 +// 使用 language_identifier 自动识别文本语言并选择合适的分词器 +export const MILVUS_MULTI_LANGUAGE_ANALYZER = { + tokenizer: { + type: 'language_identifier', // 语言识别器类型 + identifier: 'whatlang', // 使用 whatlang 进行语言识别 + analyzers: { + default: { + tokenizer: 'standard' // 未识别语言的后备分词器 + }, + English: { + type: 'english' // 英文使用 english 分析器(含词干提取、停用词等) + }, + Mandarin: { + tokenizer: 'jieba' // 中文使用 jieba 分词器 + } + } + } +}; + +export type MilvusIndexParam = Omit; + +export type MilvusCollectionConfig = { + name: string; + description: string; + fields: FieldType[]; + indexParams: MilvusIndexParam[]; + functions?: any[]; +}; + +const createBaseFields = (): FieldType[] => { + const baseFields: FieldType[] = [ + { + name: 'id', + data_type: DataType.Int64, + is_primary_key: true, + autoID: false + }, + { + name: 'vector', + data_type: DataType.FloatVector, + dim: MILVUS_VECTOR_DIMENSION + }, + { name: 'teamId', data_type: DataType.VarChar, max_length: 64 }, + { name: 'datasetId', data_type: DataType.VarChar, max_length: 64 }, + { name: 'collectionId', data_type: DataType.VarChar, max_length: 64 }, + { + name: 'createTime', + data_type: DataType.Int64 + } + ]; + + // Milvus 2.6+ 添加全文检索相关字段 + if (milvusVersionManager.supportsFullText()) { + // 原始文本字段(用于 BM25 Function 输入) + // 配置多语言分析器:自动识别中英文并使用对应的分词器 + baseFields.push({ + name: 'text', + data_type: DataType.VarChar, + max_length: MILVUS_TEXT_MAX_LENGTH, + enable_analyzer: true, + enable_match: true, + analyzer_params: MILVUS_MULTI_LANGUAGE_ANALYZER, + nullable: true + } as any); + + // 稀疏向量字段(用于存储 BM25 生成的稀疏向量) + baseFields.push({ + name: 'sparse', + data_type: DataType.SparseFloatVector + }); + + // 元数据字段(用于后续扩展) + baseFields.push({ + name: 'metadata', + data_type: DataType.JSON, + nullable: true + } as any); + } + + return baseFields; +}; + +const createBaseIndexParams = (): MilvusIndexParam[] => { + const indexParams: MilvusIndexParam[] = [ + { + field_name: 'vector', + index_name: 'vector_HNSW', + index_type: 'HNSW', + metric_type: 'IP', + params: { efConstruction: Number(HNSW_EF_CONSTRUCTION), M: Number(HNSW_M) } + }, + { + field_name: 'teamId', + index_type: 'Trie' + }, + { + field_name: 'datasetId', + index_type: 'Trie' + }, + { + field_name: 'collectionId', + index_type: 'Trie' + }, + { + field_name: 'createTime', + index_type: 'STL_SORT' + } + ]; + + // Milvus 2.6+ 在 sparse 字段上添加 BM25 全文索引 + if (milvusVersionManager.supportsFullText()) { + indexParams.push({ + field_name: 'sparse', + index_name: 'sparse_bm25', + index_type: 'SPARSE_INVERTED_INDEX', + metric_type: 'BM25', + params: { + inverted_index_algo: 'DAAT_MAXSCORE', + bm25_k1: 1.2, + bm25_b: 0.75 + } + } as any); + } + + return indexParams; +}; + +const createBaseFunctions = (): any[] | undefined => { + if (!milvusVersionManager.supportsFullText()) { + return undefined; + } + + return [ + { + name: 'text_bm25_emb', + type: FunctionType.BM25, + input_field_names: ['text'], + output_field_names: ['sparse'], + params: {} + } + ]; +}; + +// 动态生成集合定义(在版本检测完成后调用) +export const getMilvusCollectionDefinitions = (): MilvusCollectionConfig[] => [ + { + name: DatasetVectorTableName, + description: 'Store dataset vector', + fields: createBaseFields(), + indexParams: createBaseIndexParams(), + functions: createBaseFunctions() + }, + { + name: DBDatasetVectorTableName, + description: 'Store database table column description embeddings', + fields: [ + ...createBaseFields(), + { name: 'columnDesIndex', data_type: DataType.VarChar, max_length: 1024 } + ], + indexParams: createBaseIndexParams(), + functions: createBaseFunctions() + }, + { + name: DBDatasetValueVectorTableName, + description: 'Store database table column value example embeddings', + fields: [ + ...createBaseFields(), + { name: 'columnValIndex', data_type: DataType.VarChar, max_length: 1024 } + ], + indexParams: createBaseIndexParams(), + functions: createBaseFunctions() + } +]; + +export type MilvusInsertRow = { + id: number; + vector: number[]; + teamId: string; + datasetId: string; + collectionId: string; + createTime: number; + columnDesIndex?: string; + columnValIndex?: string; + text?: string; // Milvus 2.6+ 原始文本(BM25 输入) + metadata?: Record; // Milvus 2.6+ 元数据扩展字段 + // 注意:sparse 字段由 BM25 Function 自动生成,不需要手动插入 +}; diff --git a/packages/service/common/vectorDB/milvus/index.ts b/packages/service/common/vectorDB/milvus/index.ts index 9501b494ece8..d96e331e365e 100644 --- a/packages/service/common/vectorDB/milvus/index.ts +++ b/packages/service/common/vectorDB/milvus/index.ts @@ -1,14 +1,30 @@ -import { DataType, LoadState, MilvusClient } from '@zilliz/milvus2-sdk-node'; +import { LoadState, MilvusClient } from '@zilliz/milvus2-sdk-node'; import { DatasetVectorDbName, DatasetVectorTableName, + DBDatasetVectorTableName, + DBDatasetValueVectorTableName, MILVUS_ADDRESS, - MILVUS_TOKEN + MILVUS_TOKEN, + MILVUS_TIMEOUT, + MILVUS_INSERT_TIMEOUT } from '../constants'; -import type { VectorControllerType } from '../type'; +import type { + VectorControllerType, + DatabaseEmbeddingRecallCtrlProps, + DatabaseEmbeddingRecallResponse, + InsertVectorControllerPropsType +} from '../type'; import { retryFn } from '@fastgpt/global/common/system/utils'; import { getLogger, LogCategories } from '../../logger'; import { customNanoid } from '@fastgpt/global/common/string/tools'; +import { + getMilvusCollectionDefinitions, + MILVUS_TEXT_MAX_LENGTH, + type MilvusCollectionConfig, + type MilvusInsertRow +} from './config'; +import { milvusVersionManager } from './version'; const logger = getLogger(LogCategories.INFRA.VECTOR); @@ -18,21 +34,63 @@ export class MilvusCtrl implements VectorControllerType { if (!MILVUS_ADDRESS) { return Promise.reject('MILVUS_ADDRESS is not set'); } - if (global.milvusClient) return global.milvusClient; + if (global.milvusClient) { + // Ensure database context is set for every operation + await global.milvusClient.useDatabase({ db_name: DatasetVectorDbName }); + return global.milvusClient; + } global.milvusClient = new MilvusClient({ address: MILVUS_ADDRESS, - token: MILVUS_TOKEN + token: MILVUS_TOKEN, + timeout: MILVUS_TIMEOUT }); await global.milvusClient.connectPromise; + await global.milvusClient.useDatabase({ db_name: DatasetVectorDbName }); - logger.info('Milvus connected', { address: MILVUS_ADDRESS }); + logger.info('Milvus connected', { address: MILVUS_ADDRESS, db: DatasetVectorDbName }); return global.milvusClient; }; + private createMilvusCollection = async (client: MilvusClient, config: MilvusCollectionConfig) => { + const { name, description, fields, indexParams, functions } = config; + + const { value: hasCollection } = await client.hasCollection({ + collection_name: name + }); + + if (!hasCollection) { + const result = await client.createCollection({ + collection_name: name, + description, + enableDynamicField: true, + fields, + index_params: indexParams, + functions: functions // Milvus 2.6+ BM25 Function + } as any); + logger.info(`Create milvus collection ${name}`, { result }); + return; + } + }; + + private loadCollection = async (client: MilvusClient, collectionName: string) => { + const { state } = await client.getLoadState({ + collection_name: collectionName + }); + + if (state === LoadState.LoadStateNotExist || state === LoadState.LoadStateNotLoad) { + await client.loadCollectionSync({ + collection_name: collectionName + }); + logger.info(`Milvus collection ${collectionName} load success`); + } + }; init: VectorControllerType['init'] = async () => { const client = await this.getClient(); + // 版本检测(必须在集合创建之前完成) + await milvusVersionManager.detectVersion(client); + // init db(zilliz cloud will error) try { const { db_names } = await client.listDatabases(); @@ -50,86 +108,35 @@ export class MilvusCtrl implements VectorControllerType { logger.warn('Milvus database initialization skipped or failed', { error }); } - // init collection and index - const { value: hasCollection } = await client.hasCollection({ - collection_name: DatasetVectorTableName - }); - if (!hasCollection) { - const result = await client.createCollection({ - collection_name: DatasetVectorTableName, - description: 'Store dataset vector', - enableDynamicField: true, - fields: [ - { - name: 'id', - data_type: DataType.Int64, - is_primary_key: true, - autoID: false // disable auto id, and we need to set id in insert - }, - { - name: 'vector', - data_type: DataType.FloatVector, - dim: 1536 - }, - { name: 'teamId', data_type: DataType.VarChar, max_length: 64 }, - { name: 'datasetId', data_type: DataType.VarChar, max_length: 64 }, - { name: 'collectionId', data_type: DataType.VarChar, max_length: 64 }, - { - name: 'createTime', - data_type: DataType.Int64 - } - ], - index_params: [ - { - field_name: 'vector', - index_name: 'vector_HNSW', - index_type: 'HNSW', - metric_type: 'IP', - params: { efConstruction: 32, M: 64 } - }, - { - field_name: 'teamId', - index_type: 'Trie' - }, - { - field_name: 'datasetId', - index_type: 'Trie' - }, - { - field_name: 'collectionId', - index_type: 'Trie' - }, - { - field_name: 'createTime', - index_type: 'STL_SORT' - } - ] - }); - - logger.info('Milvus collection created', { - collection: DatasetVectorTableName, - result - }); - } - - const { state: colLoadState } = await client.getLoadState({ - collection_name: DatasetVectorTableName - }); - - if ( - colLoadState === LoadState.LoadStateNotExist || - colLoadState === LoadState.LoadStateNotLoad - ) { - await client.loadCollectionSync({ - collection_name: DatasetVectorTableName - }); - logger.info('Milvus collection loaded', { collection: DatasetVectorTableName }); + // 在版本检测完成后,动态生成集合定义 + const collectionDefinitions = getMilvusCollectionDefinitions(); + + for (const config of collectionDefinitions) { + await this.createMilvusCollection(client, config); + await this.loadCollection(client, config.name); + // Milvus 2.6 在 loadCollection 完成后会更新 schemaTs(索引/BM25 Function 注册) + // 必须重新 describeCollection 以刷新 SDK 内部的 schema 时间戳缓存, + // 否则后续 insert 请求携带旧 schemaTs 会触发 "collection schema mismatch" 错误 + await client.describeCollection({ collection_name: config.name }); + logger.info(`Milvus collection schema synced: ${config.name}`); } }; - insert: VectorControllerType['insert'] = async (props) => { + insert: VectorControllerType['insert'] = async ( + props: InsertVectorControllerPropsType + ): Promise<{ insertIds: string[] }> => { const client = await this.getClient(); - const { teamId, datasetId, collectionId, vectors } = props; + const { + teamId, + datasetId, + collectionId, + vectors, + tableName = DatasetVectorTableName, + column_des_index, + column_val_index, + textContents, + metadataList + } = props; const generateId = () => { // in js, the max safe integer is 2^53 - 1: 9007199254740991 @@ -140,18 +147,73 @@ export class MilvusCtrl implements VectorControllerType { return Number(`${firstDigit}${restDigits}`); }; - const result = await client.insert({ - collection_name: DatasetVectorTableName, - data: vectors.map((vector) => ({ + const now = Date.now(); + const data: MilvusInsertRow[] = vectors.map((vector, index) => { + const row: MilvusInsertRow = { id: generateId(), vector, teamId: String(teamId), datasetId: String(datasetId), collectionId: String(collectionId), - createTime: Date.now() - })) + createTime: now + }; + if (column_des_index) { + row.columnDesIndex = column_des_index; + } + if (column_val_index) { + row.columnValIndex = column_val_index; + } + // Milvus 2.6+ 添加原始文本(BM25 Function 会自动生成 sparse 字段) + // 当 supportsFullText 时必须传 text,否则 BM25 Function 无法生成 non-nullable 的 sparse 字段导致 insert 卡死 + if (milvusVersionManager.supportsFullText()) { + const rawText = (textContents && textContents[index]) || ''; + if (rawText.length > MILVUS_TEXT_MAX_LENGTH) { + logger.warn( + `[Milvus] text field truncated: original length ${rawText.length} exceeds max_length ${MILVUS_TEXT_MAX_LENGTH}, id=${row.id}, collectionId=${collectionId}` + ); + row.text = rawText.slice(0, MILVUS_TEXT_MAX_LENGTH); + } else { + row.text = rawText; + } + } + // Milvus 2.6+ 添加元数据 + // Ensure metadata is always a valid object (not null/undefined) to avoid Milvus errors + if (milvusVersionManager.supportsFullText()) { + row.metadata = (metadataList && metadataList[index]) || {}; + } + return row; }); + const insertTimeoutMs = MILVUS_INSERT_TIMEOUT; + let timeoutId: ReturnType | undefined; + const result = await Promise.race([ + client.insert({ + collection_name: tableName, + data, + // skip_check_schema=true 让 SDK 不在请求中附加 schema_timestamp, + // 避免 SDK 12h LRU 缓存中的旧 schemaTs 与服务端不一致导致 insert 被拒绝卡死 + skip_check_schema: true + } as any), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // 超时后清理僵尸连接,下次操作强制重连 + global.milvusClient = undefined as any; + reject(new Error(`[Milvus] insert timeout after ${insertTimeoutMs}ms`)); + }, insertTimeoutMs); + }) + ]); + // 主动清除定时器,防止 insert 成功后超时回调仍在后台清理 global.milvusClient + if (timeoutId !== undefined) clearTimeout(timeoutId); + + if (result.IDs === null) { + logger.error( + `[Milvus] insert error: ${result.status.error_code},detail: ${result.status.reason}` + ); + return Promise.reject( + new Error(`Milvus insert failed: ${result.status.error_code} - ${result.status.reason}`) + ); + } + const insertIds = (() => { if ('int_id' in result.IDs) { return result.IDs.int_id.data.map((id) => String(id)); @@ -164,7 +226,7 @@ export class MilvusCtrl implements VectorControllerType { }; }; delete: VectorControllerType['delete'] = async (props) => { - const { teamId } = props; + const { teamId, tableName = DatasetVectorTableName } = props; const client = await this.getClient(); const teamIdWhere = `(teamId=="${String(teamId)}")`; @@ -197,7 +259,7 @@ export class MilvusCtrl implements VectorControllerType { const concatWhere = `${teamIdWhere} and ${where}`; await client.delete({ - collection_name: DatasetVectorTableName, + collection_name: tableName, filter: concatWhere }); }; @@ -240,10 +302,11 @@ export class MilvusCtrl implements VectorControllerType { const searchResult = await retryFn(() => client.search({ collection_name: DatasetVectorTableName, - vector: vector, + data: [vector], + params: { ef: global.systemEnv?.hnswEfSearch || 100 }, limit, expr: filterStr, - output_fields: ['collectionId'] + output_fields: ['id', 'collectionId'] }) ); @@ -262,6 +325,246 @@ export class MilvusCtrl implements VectorControllerType { }; }; + // Milvus 2.6+ 全文检索方法 + fullTextSearch = async ({ + teamId, + datasetIds, + query, + limit, + forbidCollectionIdList, + filterCollectionIdList + }: { + teamId: string; + datasetIds: string[]; + query: string; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; + }): Promise< + Array<{ + id: string; + collectionId: string; + score: number; + }> + > => { + const client = await this.getClient(); + + // 构建禁止集合过滤条件(复用 embRecall 逻辑) + const formatForbidCollectionIdList = (() => { + if (!filterCollectionIdList) return forbidCollectionIdList; + return forbidCollectionIdList + .map((id) => String(id)) + .filter((id) => !filterCollectionIdList.includes(id)); + })(); + + const forbidColQuery = + formatForbidCollectionIdList.length > 0 + ? `and (collectionId not in [${formatForbidCollectionIdList.map((id) => `"${id}"`).join(',')}])` + : ''; + + // 构建集合过滤条件 + const formatFilterCollectionId = (() => { + if (!filterCollectionIdList) return; + return filterCollectionIdList + .map((id) => String(id)) + .filter((id) => !forbidCollectionIdList.includes(id)); + })(); + + const collectionIdQuery = formatFilterCollectionId + ? `and (collectionId in [${formatFilterCollectionId.map((id) => `"${id}"`).join(',')}])` + : ''; + + // 空数据检查 + if (formatFilterCollectionId && formatFilterCollectionId.length === 0) { + return []; + } + + // Milvus 2.6 BM25 全文检索(使用 sparse 字段) + const { results } = await retryFn(() => + client.search({ + collection_name: DatasetVectorTableName, + filter: `(teamId == "${teamId}") and (datasetId in [${datasetIds.map((id) => `"${id}"`).join(',')}]) ${collectionIdQuery} ${forbidColQuery}`, + output_fields: ['id', 'collectionId'], + anns_field: 'sparse', // BM25 Function 生成的稀疏向量字段 + data: [query], // 直接传入原始文本,Milvus 自动转换为稀疏向量 + limit, + params: { + metric_type: 'BM25' + } + } as any) + ); + + const rows = results as { + score: number; + id: string; + collectionId: string; + }[]; + + return rows.map((item) => ({ + id: String(item.id), + collectionId: item.collectionId, + score: item.score + })); + }; + + hybridSearch = async ({ + teamId, + datasetIds, + vector, + query, + limit, + forbidCollectionIdList, + filterCollectionIdList + }: { + teamId: string; + datasetIds: string[]; + vector: number[]; + query: string; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; + }): Promise< + Array<{ + id: string; + collectionId: string; + score: number; + }> + > => { + const client = await this.getClient(); + + const formatForbidCollectionIdList = (() => { + if (!filterCollectionIdList) return forbidCollectionIdList; + return forbidCollectionIdList + .map((id) => String(id)) + .filter((id) => !filterCollectionIdList.includes(id)); + })(); + + const forbidColQuery = + formatForbidCollectionIdList.length > 0 + ? `and (collectionId not in [${formatForbidCollectionIdList.map((id) => `"${id}"`).join(',')}])` + : ''; + + const formatFilterCollectionId = (() => { + if (!filterCollectionIdList) return; + return filterCollectionIdList + .map((id) => String(id)) + .filter((id) => !forbidCollectionIdList.includes(id)); + })(); + + const collectionIdQuery = formatFilterCollectionId + ? `and (collectionId in [${formatFilterCollectionId.map((id) => `"${id}"`).join(',')}])` + : ''; + + if (formatFilterCollectionId && formatFilterCollectionId.length === 0) { + return []; + } + + const { results } = await retryFn(() => + client.search({ + collection_name: DatasetVectorTableName, + limit, + filter: `(teamId == "${teamId}") and (datasetId in [${datasetIds.map((id) => `"${id}"`).join(',')}]) ${collectionIdQuery} ${forbidColQuery}`, + output_fields: ['id', 'collectionId'], + data: [ + { + data: vector, + anns_field: 'vector', + params: { ef: global.systemEnv?.hnswEfSearch || 100 }, + limit + }, + { + data: query, + anns_field: 'sparse', + params: { metric_type: 'BM25' }, + limit + } + ], + rerank: { + strategy: 'rrf', // RRF (Reciprocal Rank Fusion) 策略 + // k 越高,对排名靠前的敏感度越低 + // rrf: milvus对每种search使用相同的k,与fastgpt不同,加权可以使用weighted reranker + params: { k: 60 } + } + } as any) + ); + + const rows = results as { + score: number; + id: string; + collectionId: string; + }[]; + + return rows.map((item) => ({ + id: String(item.id), + collectionId: item.collectionId, + score: item.score + })); + }; + + databaseEmbRecall: VectorControllerType['databaseEmbRecall'] = async ( + props: DatabaseEmbeddingRecallCtrlProps + ): Promise => { + const client = await this.getClient(); + const { + teamId, + datasetIds, + vector, + limit, + tableName, + retry = 2, + forbidCollectionIdList + } = props; + + const forbidFilter = + forbidCollectionIdList.length > 0 + ? `and (collectionId not in [${forbidCollectionIdList.map((id) => `"${String(id)}"`).join(',')}])` + : ''; + + const outputFields = ['id', 'collectionId']; + if (tableName === DBDatasetVectorTableName) { + outputFields.push('columnDesIndex'); + } + if (tableName === DBDatasetValueVectorTableName) { + outputFields.push('columnValIndex'); + } + + try { + const { results } = await retryFn( + () => + client.search({ + collection_name: tableName, + data: vector, + limit, + filter: `(teamId == "${teamId}") and (datasetId in [${datasetIds + .map((id) => `"${String(id)}"`) + .join(',')}]) ${forbidFilter}`, + output_fields: outputFields + }), + retry + ); + + const rows = results as Array<{ + score: number; + id: string; + collectionId: string; + columnDesIndex?: string; + columnValIndex?: string; + }>; + + return { + results: rows.map((item) => ({ + id: String(item.id), + collectionId: item.collectionId, + score: item.score, + ...(item.columnDesIndex ? { columnDesIndex: item.columnDesIndex } : {}), + ...(item.columnValIndex ? { columnValIndex: item.columnValIndex } : {}) + })) + }; + } catch (error) { + return Promise.reject(error); + } + }; + getVectorCount: VectorControllerType['getVectorCount'] = async (props) => { const { teamId, datasetId, collectionId } = props; const client = await this.getClient(); @@ -286,6 +589,7 @@ export class MilvusCtrl implements VectorControllerType { const result = await client.query({ collection_name: DatasetVectorTableName, + db_name: DatasetVectorDbName, output_fields: ['count(*)'], filter: filter || undefined }); @@ -302,6 +606,7 @@ export class MilvusCtrl implements VectorControllerType { const result = await client.query({ collection_name: DatasetVectorTableName, + db_name: DatasetVectorDbName, output_fields: ['id', 'teamId', 'datasetId'], filter: `(createTime >= ${startTimestamp}) and (createTime <= ${endTimestamp})` }); diff --git a/packages/service/common/vectorDB/milvus/version.ts b/packages/service/common/vectorDB/milvus/version.ts new file mode 100644 index 000000000000..fd6af52bbab4 --- /dev/null +++ b/packages/service/common/vectorDB/milvus/version.ts @@ -0,0 +1,75 @@ +import type { MilvusClient } from '@zilliz/milvus2-sdk-node'; +import { addLog } from '../../system/log'; + +export enum MilvusFeatureLevel { + V24 = 'v2.4', + V26 = 'v2.6', + UNKNOWN = 'unknown' +} + +export class MilvusVersionManager { + private featureLevel: MilvusFeatureLevel = MilvusFeatureLevel.UNKNOWN; + private detectionComplete = false; + + private constructor() {} + + static getInstance(): MilvusVersionManager { + if (!global.milvusVersionManager) { + global.milvusVersionManager = new MilvusVersionManager(); + } + return global.milvusVersionManager; + } + + async detectVersion(client: MilvusClient): Promise { + if (this.detectionComplete) return this.featureLevel; + + try { + // 调用 SDK getVersion() + const versionInfo = await client.getVersion(); + const version = versionInfo.version; // "v2.6.0" or "v2.4.19" + + addLog.info(`Milvus server version detected: ${version}`); + + // 解析版本号 + const match = version.match(/v?(\d+)\.(\d+)/); + if (!match) { + throw new Error(`Invalid version format: ${version}`); + } + + const [, major, minor] = match; + const majorNum = parseInt(major, 10); + const minorNum = parseInt(minor, 10); + + // 判断特性级别 + if (majorNum > 2 || (majorNum === 2 && minorNum >= 6)) { + this.featureLevel = MilvusFeatureLevel.V26; + } else { + this.featureLevel = MilvusFeatureLevel.V24; + } + + this.detectionComplete = true; + + addLog.info(`Milvus feature level: ${this.featureLevel}`); + return this.featureLevel; + } catch (error) { + addLog.error('Milvus version detection failed, defaulting to UNKNOWN', error); + this.featureLevel = MilvusFeatureLevel.UNKNOWN; + return this.featureLevel; + } + } + + getFeatureLevel(): MilvusFeatureLevel { + return this.featureLevel; + } + + supportsFullText(): boolean { + return this.featureLevel === MilvusFeatureLevel.V26; + } + + async resetDetection(client: MilvusClient): Promise { + this.detectionComplete = false; + await this.detectVersion(client); + } +} + +export const milvusVersionManager = MilvusVersionManager.getInstance(); diff --git a/packages/service/common/vectorDB/oceanbase/controller.ts b/packages/service/common/vectorDB/oceanbase/controller.ts index c8fa868ad4aa..498133fa4bf3 100644 --- a/packages/service/common/vectorDB/oceanbase/controller.ts +++ b/packages/service/common/vectorDB/oceanbase/controller.ts @@ -18,7 +18,6 @@ type GetProps = { limit?: number; offset?: number; }; - type DeleteProps = { where: WhereProps; }; @@ -170,6 +169,15 @@ export class ObClass { const client = await this.getClient(); return client.query(sql); } + /** + * 批量插入数据并获取自增 ID + * 在 OceanBase 多副本环境下使用 LAST_INSERT_ID() 获取准确的自增 ID + * + * 原理说明: + * 1. OceanBase 的 LAST_INSERT_ID() 返回当前会话最后一次插入操作的第一个自增 ID + * 2. 批量插入时,ID 是连续的:first_id, first_id+1, first_id+2, ... + * 3. 这种方法在多副本环境下是可靠的,因为每个连接会话是独立的 + */ /** * 批量插入数据并获取自增 ID * 在 OceanBase 多副本环境下使用 LAST_INSERT_ID() 获取准确的自增 ID @@ -248,7 +256,7 @@ export class ObClass { const time = Date.now() - start; if (time > 300) { - logger.warn('Vector DB slow query detected', { + console.warn('Vector DB slow query detected', { provider: this.controllerType, durationMs: time, sql diff --git a/packages/service/common/vectorDB/oceanbase/index.ts b/packages/service/common/vectorDB/oceanbase/index.ts index 19dd728eb752..8832c42e6698 100644 --- a/packages/service/common/vectorDB/oceanbase/index.ts +++ b/packages/service/common/vectorDB/oceanbase/index.ts @@ -1,8 +1,12 @@ /* oceanbase vector crud */ -import { DatasetVectorTableName, OceanBaseIndexConfig } from '../constants'; +import { DatasetVectorTableName, OceanBaseIndexConfig, DBDatasetVectorTableName, DBDatasetValueVectorTableName } from '../constants'; import { ObClass } from './controller'; import { type RowDataPacket } from 'mysql2/promise'; -import type { VectorControllerType } from '../type'; +import type { + VectorControllerType, + DatabaseEmbeddingRecallCtrlProps, + DatabaseEmbeddingRecallResponse +} from '../type'; import dayjs from 'dayjs'; import { getLogger, LogCategories } from '../../logger'; @@ -27,8 +31,33 @@ export class ObVectorCtrl implements VectorControllerType { createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `); + /********************** Database Vector Store **********************/ + await this.obClient.query(` + CREATE TABLE IF NOT EXISTS ${DBDatasetVectorTableName} ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + vector VECTOR(1536) NOT NULL, + team_id VARCHAR(50) NOT NULL, + dataset_id VARCHAR(50) NOT NULL, + collection_id VARCHAR(50) NOT NULL, + column_des_index VARCHAR(1024) NOT NULL, + createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + + await this.obClient.query(` + CREATE TABLE IF NOT EXISTS ${DBDatasetValueVectorTableName} ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + vector VECTOR(1536) NOT NULL, + team_id VARCHAR(50) NOT NULL, + dataset_id VARCHAR(50) NOT NULL, + collection_id VARCHAR(50) NOT NULL, + column_val_index VARCHAR(1024) NOT NULL, + createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + // modeldata await this.obClient.query( - `CREATE VECTOR INDEX IF NOT EXISTS vector_index ON ${DatasetVectorTableName}(vector) WITH (distance=${OceanBaseIndexConfig.distance}, type=${OceanBaseIndexConfig.type}, m=16, ef_construction=200);` + `CREATE VECTOR INDEX IF NOT EXISTS vector_index ON ${DatasetVectorTableName}(vector) WITH (distance=inner_product, type=hnsw, m=32, ef_construction=128);` ); await this.obClient.query( `CREATE INDEX IF NOT EXISTS team_dataset_collection_index ON ${DatasetVectorTableName}(team_id, dataset_id, collection_id);` @@ -37,6 +66,27 @@ export class ObVectorCtrl implements VectorControllerType { `CREATE INDEX IF NOT EXISTS create_time_index ON ${DatasetVectorTableName}(createtime);` ); + // coulumndescriptionindex + await this.obClient.query( + `CREATE VECTOR INDEX IF NOT EXISTS table_des_vector_index ON ${DBDatasetVectorTableName}(vector) WITH (distance=cosine, type=hnsw, m=32, ef_construction=128);` + ); + await this.obClient.query( + `CREATE INDEX IF NOT EXISTS table_des_team_dataset_collection_index ON ${DBDatasetVectorTableName}(team_id, dataset_id, collection_id);` + ); + await this.obClient.query( + `CREATE INDEX IF NOT EXISTS table_des_create_time_index ON ${DBDatasetVectorTableName}(createtime);` + ); + + // coulumnvalueindex + await this.obClient.query( + `CREATE VECTOR INDEX IF NOT EXISTS table_val_vector_index ON ${DBDatasetValueVectorTableName}(vector) WITH (distance=cosine, type=hnsw, m=32, ef_construction=128);` + ); + await this.obClient.query( + `CREATE INDEX IF NOT EXISTS table_val_team_dataset_collection_index ON ${DBDatasetValueVectorTableName}(team_id, dataset_id, collection_id);` + ); + await this.obClient.query( + `CREATE INDEX IF NOT EXISTS table_val_create_time_index ON ${DBDatasetValueVectorTableName}(createtime);` + ); logger.info('Vector DB initialization completed', { provider: this.controllerType }); @@ -48,9 +98,17 @@ export class ObVectorCtrl implements VectorControllerType { } }; - insert: VectorControllerType['insert'] = async (props) => { - const { teamId, datasetId, collectionId, vectors } = props; + insert: VectorControllerType['insert'] = async (props) => { + const { + teamId, + datasetId, + collectionId, + vectors, + tableName = DatasetVectorTableName, + column_des_index, + column_val_index + } = props; const values = vectors.map((vector) => [ { key: 'vector', value: `[${vector}]` }, { key: 'team_id', value: String(teamId) }, @@ -58,7 +116,16 @@ export class ObVectorCtrl implements VectorControllerType { { key: 'collection_id', value: String(collectionId) } ]); - const { rowCount, insertIds } = await this.obClient.insert(DatasetVectorTableName, { + // Add db schema special fields + if (column_des_index) { + values.map((item) => item.push({ key: 'column_des_index', value: column_des_index })); + } + + if (column_val_index) { + values.map((item) => item.push({ key: 'column_val_index', value: column_val_index })); + } + + const { rowCount, insertIds } = await this.obClient.insert(tableName, { values }); @@ -71,7 +138,7 @@ export class ObVectorCtrl implements VectorControllerType { }; }; delete: VectorControllerType['delete'] = async (props) => { - const { teamId } = props; + const { teamId, tableName = DatasetVectorTableName } = props; const teamIdWhere = `team_id='${String(teamId)}' AND`; @@ -101,7 +168,7 @@ export class ObVectorCtrl implements VectorControllerType { if (!where) return; - await this.obClient.delete(DatasetVectorTableName, { + await this.obClient.delete(tableName, { where: [where] }); }; @@ -167,6 +234,76 @@ export class ObVectorCtrl implements VectorControllerType { })) }; }; + + databaseEmbRecall: VectorControllerType['databaseEmbRecall'] = async ( + props: DatabaseEmbeddingRecallCtrlProps + ): Promise => { + const { + teamId, + datasetIds, + vector, + limit, + tableName, + retry = 2, + forbidCollectionIdList + } = props; + + let index: string = ''; + if (tableName == DBDatasetVectorTableName) index = 'column_des_index'; + if (tableName == DBDatasetValueVectorTableName) index = 'column_val_index'; + + + try { + const forbidCollectionSql = + forbidCollectionIdList.length > 0 + ? `AND collection_id NOT IN (${forbidCollectionIdList + .map((id) => `'${String(id)}'`) + .join(',')})` + : ''; + // cosine_distance(a,b)= 1 − cosine_similarity(a,b) , range [0,2] + const rows = await this.obClient.query< + ({ + id: string; + collection_id: string; + index_field?: string; + distance: number; + } & RowDataPacket)[][] + >( + `BEGIN; + SET ob_hnsw_ef_search = ${global.systemEnv?.hnswEfSearch || 100}; + SELECT id, + collection_id, + ${index ? `${index} AS index_field,` : ''} + cosine_distance(vector, [${vector}]) AS distance + FROM ${tableName} + WHERE team_id='${teamId}' + AND dataset_id IN (${datasetIds.map((id) => `'${String(id)}'`).join(',')}) + ${forbidCollectionSql} + ORDER BY distance ASC APPROXIMATE LIMIT ${limit}; + COMMIT;` + ).then(([rows]) => rows[2] || []); + + return { + results: rows.map((item) => ({ + id: String(item.id), + collectionId: item.collection_id, + score: 1 - item.distance, + ...(tableName === DBDatasetVectorTableName + ? { columnDesIndex: item.index_field } + : { columnValIndex: item.index_field }) + })) + }; + } catch (error) { + if (retry <= 0) { + return Promise.reject(error); + } + return this.databaseEmbRecall({ + ...props, + retry: retry - 1 + }); + } + }; + getVectorDataByTime: VectorControllerType['getVectorDataByTime'] = async (start, end) => { const rows = await this.obClient .query< diff --git a/packages/service/common/vectorDB/opengauss/controller.ts b/packages/service/common/vectorDB/opengauss/controller.ts index 5d63829a64f5..7423d46508a4 100644 --- a/packages/service/common/vectorDB/opengauss/controller.ts +++ b/packages/service/common/vectorDB/opengauss/controller.ts @@ -123,14 +123,14 @@ class OgClass { if (time > 1000) { const safeSql = sql.replace(/'\[[^\]]*?\]'/g, "'[x]'"); - logger.warn('openGauss slow query detected', { + console.warn('openGauss slow query detected', { level: 'slow-2', durationMs: time, sql: safeSql }); } else if (time > 300) { const safeSql = sql.replace(/'\[[^\]]*?\]'/g, "'[x]'"); - logger.warn('openGauss slow query detected', { + console.warn('openGauss slow query detected', { level: 'slow-1', durationMs: time, sql: safeSql diff --git a/packages/service/common/vectorDB/opengauss/index.ts b/packages/service/common/vectorDB/opengauss/index.ts index 86599c5ba273..8363f0a65c94 100644 --- a/packages/service/common/vectorDB/opengauss/index.ts +++ b/packages/service/common/vectorDB/opengauss/index.ts @@ -1,7 +1,12 @@ /* openGauss DataVec vector crud */ -import { DatasetVectorTableName } from '../constants'; +import { + DatasetVectorTableName, + DBDatasetVectorTableName, + DBDatasetValueVectorTableName +} from '../constants'; import { OgClient, connectOg } from './controller'; import type { VectorControllerType } from '../type'; +import type { DatabaseEmbeddingRecallCtrlProps, DatabaseEmbeddingRecallResponse } from '../type'; import dayjs from 'dayjs'; import { getLogger, LogCategories } from '../../logger'; @@ -160,6 +165,45 @@ export class OpenGaussVectorCtrl implements VectorControllerType { }; }; + databaseEmbRecall: VectorControllerType['databaseEmbRecall'] = async ( + props: DatabaseEmbeddingRecallCtrlProps + ): Promise => { + const { teamId, datasetIds, vector, limit, tableName, forbidCollectionIdList } = props; + let index: string = ''; + if (tableName === DBDatasetVectorTableName) index = 'column_des_index'; + if (tableName === DBDatasetValueVectorTableName) index = 'column_val_index'; + + const forbidCollectionFilter = + forbidCollectionIdList.length > 0 + ? `AND collection_id NOT IN (${forbidCollectionIdList.map((id: string) => `'${String(id)}'`).join(',')})` + : ''; + + const results: any = await OgClient.query(` + BEGIN; + SET LOCAL hnsw.ef_search = ${global.systemEnv?.hnswEfSearch || 100}; + SELECT id, collection_id, + ${index} as index_field, + vector <#> '[${vector}]' AS score + FROM ${tableName} + WHERE team_id = '${teamId}' + AND dataset_id IN (${datasetIds.map((id: string) => `'${String(id)}'`).join(',')}) + ${forbidCollectionFilter} + ORDER BY score LIMIT ${limit}; + COMMIT; + `); + const rows = results?.[results.length - 2]?.rows || []; + return { + results: rows.map((row: any) => ({ + id: String(row.id), + collectionId: row.collection_id, + score: row.score * -1, + ...(tableName === DBDatasetVectorTableName + ? { columnDesIndex: row.index_field } + : { columnValIndex: row.index_field }) + })) + }; + }; + getVectorDataByTime: VectorControllerType['getVectorDataByTime'] = async (start, end) => { const { rows } = await OgClient.query<{ id: string; diff --git a/packages/service/common/vectorDB/pg/controller.ts b/packages/service/common/vectorDB/pg/controller.ts index 145cfeaaf20f..54a8a5789133 100644 --- a/packages/service/common/vectorDB/pg/controller.ts +++ b/packages/service/common/vectorDB/pg/controller.ts @@ -128,14 +128,14 @@ class PgClass { if (time > 1000) { const safeSql = sql.replace(/'\[[^\]]*?\]'/g, "'[x]'"); - logger.warn('Postgres slow query detected', { + console.warn('Postgres slow query detected', { level: 'slow-2', durationMs: time, sql: safeSql }); } else if (time > 300) { const safeSql = sql.replace(/'\[[^\]]*?\]'/g, "'[x]'"); - logger.warn('Postgres slow query detected', { + console.warn('Postgres slow query detected', { level: 'slow-1', durationMs: time, sql: safeSql diff --git a/packages/service/common/vectorDB/pg/index.ts b/packages/service/common/vectorDB/pg/index.ts index db2db2271873..3c9ba80cca14 100644 --- a/packages/service/common/vectorDB/pg/index.ts +++ b/packages/service/common/vectorDB/pg/index.ts @@ -1,7 +1,18 @@ /* pg vector crud */ -import { DatasetVectorTableName, VectorVQ } from '../constants'; +import { + DatasetVectorTableName, + DBDatasetVectorTableName, + DBDatasetValueVectorTableName, + HNSW_EF_CONSTRUCTION, + HNSW_M, + VectorVQ +} from '../constants'; import { PgClient, connectPg } from './controller'; -import type { VectorControllerType } from '../type'; +import type { + VectorControllerType, + DatabaseEmbeddingRecallCtrlProps, + DatabaseEmbeddingRecallResponse +} from '../type'; import dayjs from 'dayjs'; import { getLogger, LogCategories } from '../../logger'; @@ -25,9 +36,35 @@ export class PgVectorCtrl implements VectorControllerType { createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `); + // Create Table `ColumnDescriptionIndex` to Record the description of the table (Text2sql) + await PgClient.query(` + CREATE TABLE IF NOT EXISTS ${DBDatasetVectorTableName} ( + id BIGSERIAL PRIMARY KEY, + vector VECTOR(1536) NOT NULL, + dataset_id VARCHAR(50) NOT NULL, + collection_id VARCHAR(50) NOT NULL, + column_des_index VARCHAR(1024) NOT NULL, + team_id VARCHAR(50) NOT NULL, + createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Create Table `ColumnValueIndex` to Record the example value of the table (Text2sql) + // foreign key: (table_val_index, LENGTH(table_des_index)) = table_des_index + await PgClient.query(` + CREATE TABLE IF NOT EXISTS ${DBDatasetValueVectorTableName} ( + id BIGSERIAL PRIMARY KEY, + vector VECTOR(1536) NOT NULL, + team_id VARCHAR(50) NOT NULL, + dataset_id VARCHAR(50) NOT NULL, + collection_id VARCHAR(50) NOT NULL, + column_val_index VARCHAR(1024) NOT NULL, + createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); await PgClient.query( - `CREATE INDEX CONCURRENTLY IF NOT EXISTS vector_index ON ${DatasetVectorTableName} USING hnsw (vector ${isHalfVec ? 'halfvec_ip_ops' : 'vector_ip_ops'}) WITH (m = 32, ef_construction = 128);` + `CREATE INDEX CONCURRENTLY IF NOT EXISTS vector_index ON ${DatasetVectorTableName} USING hnsw (vector ${isHalfVec ? 'halfvec_ip_ops' : 'vector_ip_ops'}) WITH (m = ${HNSW_M}, ef_construction = ${HNSW_EF_CONSTRUCTION});` ); await PgClient.query( `CREATE INDEX CONCURRENTLY IF NOT EXISTS team_dataset_collection_index ON ${DatasetVectorTableName} USING btree(team_id, dataset_id, collection_id);` @@ -35,6 +72,28 @@ export class PgVectorCtrl implements VectorControllerType { await PgClient.query( `CREATE INDEX CONCURRENTLY IF NOT EXISTS create_time_index ON ${DatasetVectorTableName} USING btree(createtime);` ); + + // ColumnDescriptionIndex + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_des_vector_index ON ${DBDatasetVectorTableName} USING hnsw (vector vector_ip_ops) WITH (m = 32, ef_construction = 128);` + ); + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_team_dataset_collection_index ON ${DBDatasetVectorTableName} USING btree(team_id, dataset_id, collection_id);` + ); + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_des_create_time_index ON ${DBDatasetVectorTableName} USING btree(createtime);` + ); + + // ColumnValueIndex + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_val_vector_index ON ${DBDatasetValueVectorTableName} USING hnsw (vector vector_ip_ops) WITH (m = 32, ef_construction = 128);` + ); + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_val_team_dataset_collection_index ON ${DBDatasetValueVectorTableName} USING btree(team_id, dataset_id, collection_id);` + ); + await PgClient.query( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS table_val_create_time_index ON ${DBDatasetValueVectorTableName} USING btree(createtime);` + ); // 10w rows // await PgClient.query(` // ALTER TABLE modeldata SET ( @@ -63,7 +122,15 @@ export class PgVectorCtrl implements VectorControllerType { } }; insert: VectorControllerType['insert'] = async (props) => { - const { teamId, datasetId, collectionId, vectors } = props; + const { + teamId, + datasetId, + collectionId, + vectors, + tableName = DatasetVectorTableName, + column_des_index, + column_val_index + } = props; const values = vectors.map((vector) => [ { key: 'vector', value: `[${vector}]` }, @@ -72,7 +139,14 @@ export class PgVectorCtrl implements VectorControllerType { { key: 'collection_id', value: String(collectionId) } ]); - const { rowCount, rows } = await PgClient.insert(DatasetVectorTableName, { + // Add db schema special fields + if (column_des_index) { + values.map((item) => item.push({ key: 'column_des_index', value: column_des_index })); + } + if (column_val_index) { + values.map((item) => item.push({ key: 'column_val_index', value: column_val_index })); + } + const { rowCount, rows } = await PgClient.insert(tableName || DatasetVectorTableName, { values }); @@ -81,11 +155,11 @@ export class PgVectorCtrl implements VectorControllerType { } return { - insertIds: rows.map((row) => row.id) + insertIds: rows.map((row: any) => row.id) }; }; delete: VectorControllerType['delete'] = async (props) => { - const { teamId } = props; + const { teamId, tableName = DatasetVectorTableName } = props; const teamIdWhere = `team_id='${String(teamId)}' AND`; @@ -115,7 +189,7 @@ export class PgVectorCtrl implements VectorControllerType { if (!where) return; - await PgClient.delete(DatasetVectorTableName, { + await PgClient.delete(tableName, { where: [where] }); }; @@ -187,6 +261,67 @@ export class PgVectorCtrl implements VectorControllerType { })) }; }; + databaseEmbRecall: VectorControllerType['databaseEmbRecall'] = async ( + props: DatabaseEmbeddingRecallCtrlProps + ): Promise => { + const { + teamId, + datasetIds, + vector, + limit, + tableName, + retry = 2, + forbidCollectionIdList + } = props; + let index: string = ''; + if (tableName == DBDatasetVectorTableName) index = 'column_des_index'; + if (tableName == DBDatasetValueVectorTableName) index = 'column_val_index'; + + try { + // Build forbid collection filter + const forbidCollectionFilter = + forbidCollectionIdList.length > 0 + ? `AND collection_id NOT IN (${forbidCollectionIdList.map((id: string) => `'${String(id)}'`).join(',')})` + : ''; + const results: any = await PgClient.query(` + BEGIN; + SET LOCAL hnsw.ef_search = ${global.systemEnv?.hnswEfSearch || 100}; + SET LOCAL hnsw.max_scan_tuples = ${global.systemEnv?.hnswMaxScanTuples || 100000}; + SET LOCAL hnsw.iterative_scan = relaxed_order; + WITH relaxed_results AS MATERIALIZED ( + SELECT id, collection_id, + ${index} as index_field, + vector <=> '[${vector}]' AS score + FROM ${tableName} + WHERE team_id = '${teamId}' + AND dataset_id IN (${datasetIds.map((id: string) => `'${String(id)}'`).join(',')}) + ${forbidCollectionFilter} + ORDER BY score LIMIT ${limit} + ) SELECT id, collection_id, index_field, score FROM relaxed_results ORDER BY score; + COMMIT; + `); + + const rows = results?.[results.length - 2]?.rows || []; + return { + results: rows.map((row: any) => ({ + id: String(row.id), + collectionId: row.collection_id, + score: row.score * -1, // Convert cosine distance to similarity score + ...(tableName === DBDatasetVectorTableName + ? { columnDesIndex: row.index_field } + : { columnValIndex: row.index_field }) + })) + }; + } catch (error) { + if (retry <= 0) { + return Promise.reject(error); + } + return this.databaseEmbRecall({ + ...props, + retry: retry - 1 + }); + } + }; getVectorDataByTime: VectorControllerType['getVectorDataByTime'] = async (start, end) => { const { rows } = await PgClient.query<{ diff --git a/packages/service/common/vectorDB/type.ts b/packages/service/common/vectorDB/type.ts index 1c96b4696b08..71aed2c27cf3 100644 --- a/packages/service/common/vectorDB/type.ts +++ b/packages/service/common/vectorDB/type.ts @@ -1,6 +1,7 @@ import type { Pool as PgPool } from 'pg'; import type { Pool as MysqlPool } from 'mysql2/promise'; import type { MilvusClient } from '@zilliz/milvus2-sdk-node'; +import type { MilvusVersionManager } from './milvus/version'; import z from 'zod'; // Embedding recall item schema @@ -16,7 +17,13 @@ export const InsertVectorControllerPropsSchema = z.object({ teamId: z.string(), datasetId: z.string(), collectionId: z.string(), - vectors: z.array(z.array(z.number())) + vectors: z.array(z.array(z.number())), + tableName: z.string().optional(), + column_des_index: z.string().optional(), + column_val_index: z.string().optional(), + // Milvus 2.6+ optional fields + textContents: z.array(z.string()).optional(), + metadataList: z.array(z.record(z.string(), z.any())).optional() }); export type InsertVectorControllerPropsType = z.infer; @@ -30,17 +37,20 @@ export type InsertVectorResponseType = z.infer; + + /** + * Database embedding recall for Text2SQL column description/value search + */ + databaseEmbRecall( + props: DatabaseEmbeddingRecallCtrlProps + ): Promise; } declare global { var pgClient: PgPool | null; var obClient: MysqlPool | null; var milvusClient: MilvusClient | null; + var milvusVersionManager: MilvusVersionManager | undefined; } + +// Database embedding recall props schema +export const DatabaseEmbeddingRecallCtrlPropsSchema = EmbeddingRecallCtrlPropsSchema.extend({ + tableName: z.string() // DBDatasetVectorTableName or DBDatasetValueVectorTableName +}); +export type DatabaseEmbeddingRecallCtrlProps = z.infer< + typeof DatabaseEmbeddingRecallCtrlPropsSchema +>; + +// Database embedding recall item schema +export const DatabaseEmbeddingRecallItemSchema = z.object({ + id: z.string(), + collectionId: z.string(), + score: z.number(), + columnDesIndex: z.string().optional(), + columnValIndex: z.string().optional() +}); +export type DatabaseEmbeddingRecallItemType = z.infer; + +// Database embedding recall response schema +export const DatabaseEmbeddingRecallResponseSchema = z.object({ + results: z.array(DatabaseEmbeddingRecallItemSchema) +}); +export type DatabaseEmbeddingRecallResponse = z.infer; diff --git a/packages/service/core/agentSkills/controller.ts b/packages/service/core/agentSkills/controller.ts index 8d9d99a6eb43..6ab7ee7b84c5 100644 --- a/packages/service/core/agentSkills/controller.ts +++ b/packages/service/core/agentSkills/controller.ts @@ -12,7 +12,6 @@ import { createVersion } from './version/controller'; import { mongoSessionRun } from '../../common/mongo/sessionRun'; import { getLogger, LogCategories } from '../../common/logger'; import { deleteSkillRelatedSandboxes } from './sandboxController'; -import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); @@ -195,12 +194,6 @@ export async function importSkill( ): Promise { const { skill } = packageData; - // Check for duplicate name before creating - const nameExists = await checkSkillNameExists(skill.name, teamId, parentId || null); - if (nameExists) { - throw SkillErrEnum.skillNameExists; - } - // Create skill record first const newSkill = new MongoAgentSkills({ parentId: parentId || null, @@ -273,31 +266,6 @@ export async function canModifySkill(skillId: string, tmbId: string): Promise { - const query: Record = { - name, - teamId, - parentId: parentId || null, - deleteTime: null, - source: AgentSkillSourceEnum.personal - }; - - if (excludeId) { - query._id = { $ne: excludeId }; - } - - const count = await MongoAgentSkills.countDocuments(query); - return count > 0; -} - // ==================== Folder Management ==================== /** @@ -359,12 +327,6 @@ export async function createSkillFolder( ): Promise { const { name, description, parentId, teamId, tmbId } = data; - // Check name uniqueness in the same parent folder - const nameExists = await checkSkillNameExists(name, teamId, parentId || null); - if (nameExists) { - throw new Error('Folder name already exists in this directory'); - } - const folder = new MongoAgentSkills({ type: AgentSkillTypeEnum.folder, source: AgentSkillSourceEnum.personal, @@ -428,10 +390,16 @@ async function getParents( } /** - * Update parent folders' updateTime recursively (fire-and-forget) + * Update parent folders' updateTime recursively */ -export const updateParentFoldersUpdateTime = ({ parentId }: { parentId?: string | null }) => { - mongoSessionRun(async (session) => { +export const updateParentFoldersUpdateTime = ({ + parentId, + session +}: { + parentId?: string | null; + session?: ClientSession; +}) => { + const run = async (session: ClientSession) => { const existsId = new Set(); let currentId: string | null | undefined = parentId; while (true) { @@ -447,7 +415,11 @@ export const updateParentFoldersUpdateTime = ({ parentId }: { parentId?: string currentId = parentSkill.parentId ?? null; } - }).catch((err) => { - logger.error('Failed to update parent folder updateTime', { error: err }); - }); + }; + + if (session) { + return run(session); + } else { + return mongoSessionRun(run); + } }; diff --git a/packages/service/core/agentSkills/editLock.ts b/packages/service/core/agentSkills/editLock.ts new file mode 100644 index 000000000000..c5f81de624a8 --- /dev/null +++ b/packages/service/core/agentSkills/editLock.ts @@ -0,0 +1,169 @@ +/** + * Distributed edit lock for AgentSkill package mutations. + * + * Replaces the in-process Map-based withSkillEditLock (only serializes + * within one Node.js process) with a MongoDB-backed advisory lock so that + * multiple FastGPT replicas cannot interleave read-modify-write cycles on + * the same MinIO package.zip. + * + * Lock lifecycle: + * acquireSkillEditLock → (work, renew if needed) → releaseSkillEditLock + * + * Stale locks are auto-cleaned by the MongoDB TTL index on expiresAt. + * The next acquirer steals an expired lock transparently. + */ +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import { connectionMongo, getMongoModel } from '../../common/mongo'; +import type { Model } from 'mongoose'; + +const { Schema } = connectionMongo; + +// ---- Mongoose schema ------------------------------------------------------- + +export const skillEditLocksCollectionName = 'skill_edit_locks'; + +export type SkillEditLockDoc = { + skillId: string; + lockToken: string; + lockedBy: string; + lockedAt: Date; + expiresAt: Date; +}; + +const SkillEditLockSchema = new Schema({ + skillId: { type: String, required: true, unique: true }, + lockToken: { type: String, required: true }, + lockedBy: { type: String, required: true }, + lockedAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true } +}); + +// TTL index: MongoDB auto-deletes docs when expiresAt passes +SkillEditLockSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const MongoSkillEditLock: Model = getMongoModel( + skillEditLocksCollectionName, + SkillEditLockSchema +); + +// ---- Types ----------------------------------------------------------------- + +export type LockHandle = { + skillId: string; + lockToken: string; + instanceId: string; + expiresAt: Date; +}; + +export class SkillEditLockBusyError extends Error { + code = 'SKILL_EDIT_LOCK_BUSY'; + status = 409; + constructor(message: string) { + super(message); + this.name = 'SkillEditLockBusyError'; + } +} + +export class SkillEditLockExpiredError extends Error { + code = 'SKILL_EDIT_LOCK_EXPIRED'; + status = 409; + constructor(message: string) { + super(message); + this.name = 'SkillEditLockExpiredError'; + } +} + +// ---- Helpers --------------------------------------------------------------- + +function instanceId(): string { + return `${os.hostname()}:${process.pid}`; +} + +// ---- Public API ------------------------------------------------------------ + +const DEFAULT_TTL_MS = 30_000; + +/** + * Acquire a distributed edit lock for `skillId`. + * + * When the lock is held by another live instance a SkillEditLockBusyError + * (HTTP 409) is thrown. Stale locks (expiresAt < now) are silently stolen. + */ +export async function acquireSkillEditLock( + skillId: string, + ttlMs: number = DEFAULT_TTL_MS +): Promise { + const lockToken = randomUUID(); + const id = instanceId(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + Math.max(1000, ttlMs)); + + try { + const result = await MongoSkillEditLock.updateOne( + { + skillId, + $or: [ + { lockToken: { $exists: false } }, // no document yet + { expiresAt: { $lt: now } } // stale lock + ] + }, + { + $set: { lockToken, lockedBy: id, lockedAt: now, expiresAt }, + $setOnInsert: { skillId } + }, + { upsert: true } + ); + + if (result.matchedCount === 0 && result.upsertedCount === 0) { + throw new SkillEditLockBusyError(`Skill ${skillId} is being edited by another process`); + } + } catch (e: any) { + if (e.code === 11000) { + // Duplicate key — another instance inserted between our read and write + throw new SkillEditLockBusyError(`Skill ${skillId} is being edited by another process`); + } + if (e instanceof SkillEditLockBusyError) throw e; + throw e; + } + + return { skillId, lockToken, instanceId: id, expiresAt }; +} + +/** + * Release a previously acquired lock. + * + * Only the holder (matching lockToken) can release — this prevents + * accidentally releasing a lock that was stolen after expiry. + */ +export async function releaseSkillEditLock(handle: LockHandle): Promise { + await MongoSkillEditLock.deleteOne({ + skillId: handle.skillId, + lockToken: handle.lockToken + }); +} + +/** + * Renew (extend) a lock's TTL. + * + * Throws SkillEditLockExpiredError when the lock was stolen or expired. + * Returns an updated handle — use the *returned* handle for subsequent + * renew/release calls. + */ +export async function renewSkillEditLock( + handle: LockHandle, + ttlMs: number = DEFAULT_TTL_MS +): Promise { + const expiresAt = new Date(Date.now() + Math.max(1000, ttlMs)); + + const result = await MongoSkillEditLock.updateOne( + { skillId: handle.skillId, lockToken: handle.lockToken }, + { $set: { expiresAt } } + ); + + if (result.matchedCount === 0) { + throw new SkillEditLockExpiredError(`Lock for skill ${handle.skillId} expired or was stolen`); + } + + return { ...handle, expiresAt }; +} diff --git a/packages/service/core/agentSkills/packageEditor.ts b/packages/service/core/agentSkills/packageEditor.ts new file mode 100644 index 000000000000..a34d397a3a9f --- /dev/null +++ b/packages/service/core/agentSkills/packageEditor.ts @@ -0,0 +1,310 @@ +/** + * Skill Package Editor + * + * Direct zip CRUD on MinIO `package.zip` via JSZip in-memory mutate pipeline. + * Each mutate call = download full zip -> JSZip modify -> re-upload to same key. + * + * Concurrency: + * - In-process: serialized by withSkillEditLock per skillId. + * - Cross-process: serialized by MongoDB-based editLock (acquireSkillEditLock). + */ +import JSZip from 'jszip'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type'; +import { downloadSkillPackage, uploadSkillPackage } from './storage'; +import { checkHeapHeadroom } from './sandboxConfig'; +import { updateCurrentStorage } from './controller'; +import { MongoAgentSkillsVersion } from './version/schema'; +import { MongoAgentSkills } from './schema'; +import { acquireSkillEditLock, releaseSkillEditLock, renewSkillEditLock } from './editLock'; + +export type PackageFileItem = { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; +}; + +/** Validate client-supplied zip-internal path. Rejects absolute, `..`, backslash. */ +export function validatePackagePath(path: string, opts: { allowRoot?: boolean } = {}): string { + if (path === undefined || path === null) { + throw new UserError('Path is required'); + } + if (path === '' || path === '.') { + if (opts.allowRoot) return ''; + throw new UserError('Path is required'); + } + if (path.startsWith('/')) { + throw new UserError('Path must be relative'); + } + if (path.includes('\\')) { + throw new UserError('Path must not contain backslash'); + } + const segments = path.split('/'); + for (const seg of segments) { + if (seg === '..') { + throw new UserError('Path must not contain ".."'); + } + } + return path.replace(/\/+$/, ''); +} + +/** Recursively list all files and directories nested under `path` inside zip. */ +export function listZipAllFiles(zip: JSZip, path: string): PackageFileItem[] { + const items = listZipDirectory(zip, path); + return items.map((item) => { + if (item.type === 'directory') { + return { ...item, children: listZipAllFiles(zip, item.path) }; + } + return item; + }); +} + +/** List direct children of `path` inside zip. Implicit directories are detected. */ +export function listZipDirectory(zip: JSZip, path: string): PackageFileItem[] { + const prefix = path === '' ? '' : path.replace(/\/+$/, '') + '/'; + + const fileMap = new Map(); + for (const [key, entry] of Object.entries(zip.files)) { + if (prefix !== '' && !key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + if (!rest) continue; + + const slashIdx = rest.indexOf('/'); + if (slashIdx === -1) { + if (entry.dir) { + fileMap.set(rest, { name: rest, path: prefix + rest, type: 'directory' }); + } else { + const uncompressed = (entry as unknown as { _data?: { uncompressedSize?: number } })._data + ?.uncompressedSize; + fileMap.set(rest, { + name: rest, + path: prefix + rest, + type: 'file', + size: typeof uncompressed === 'number' ? uncompressed : undefined + }); + } + } else if (slashIdx === rest.length - 1) { + const dirName = rest.slice(0, slashIdx); + if (!fileMap.has(dirName)) { + fileMap.set(dirName, { name: dirName, path: prefix + dirName, type: 'directory' }); + } + } else { + const dirName = rest.slice(0, slashIdx); + const existing = fileMap.get(dirName); + if (!existing || existing.type !== 'directory') { + fileMap.set(dirName, { name: dirName, path: prefix + dirName, type: 'directory' }); + } + } + } + + return Array.from(fileMap.values()).sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +/** Read a single file from zip; returns null if not found. */ +export async function readZipFile(zip: JSZip, path: string): Promise { + const file = zip.file(path); + if (!file) return null; + return file.async('nodebuffer'); +} + +/** Load zip → apply mutator → re-pack. */ +export async function mutateZip( + zipBuffer: Buffer, + mutator: (zip: JSZip) => Promise | void +): Promise { + const zip = await JSZip.loadAsync(zipBuffer); + await mutator(zip); + return zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); +} + +export function zipWriteText(zip: JSZip, path: string, text: string): void { + zip.file(path, text); +} + +export function zipWriteBinary(zip: JSZip, path: string, content: Buffer): void { + zip.file(path, content); +} + +/** Delete a file or directory tree. Silent no-op when path doesn't exist. */ +export function zipDeleteRecursive(zip: JSZip, path: string): void { + const normalized = path.replace(/\/+$/, ''); + zip.remove(normalized); + zip.remove(normalized + '/'); + const prefix = normalized + '/'; + for (const key of Object.keys(zip.files)) { + if (key.startsWith(prefix)) { + zip.remove(key); + } + } +} + +/** Rename / move a single file or entire directory. Throws when source missing. */ +export async function zipRename(zip: JSZip, fromPath: string, toPath: string): Promise { + const from = fromPath.replace(/\/+$/, ''); + const to = toPath.replace(/\/+$/, ''); + if (from === to) return; + + const fileEntry = zip.file(from); + if (fileEntry) { + const buf = await fileEntry.async('nodebuffer'); + zip.file(to, buf); + zip.remove(from); + return; + } + + const fromPrefix = from + '/'; + const toPrefix = to + '/'; + const matchedKeys: string[] = []; + for (const key of Object.keys(zip.files)) { + if (key === fromPrefix || key.startsWith(fromPrefix)) { + matchedKeys.push(key); + } + } + if (matchedKeys.length === 0) { + throw new UserError(`Entry not found: ${fromPath}`); + } + // Snapshot entries before mutation — JSZip's internal .files may be lazy + const snapshots: { key: string; isDir: boolean; data?: Buffer }[] = []; + for (const key of matchedKeys) { + const entry = zip.files[key]; + if (!entry) continue; + snapshots.push({ + key, + isDir: entry.dir, + data: entry.dir ? undefined : await entry.async('nodebuffer') + }); + } + for (const { key, isDir, data } of snapshots) { + const newKey = toPrefix + key.slice(fromPrefix.length); + if (isDir) { + zip.folder(newKey.replace(/\/+$/, '')); + } else { + zip.file(newKey, data as Buffer); + } + zip.remove(key); + } +} + +/** Create an explicit directory entry. No-op when already exists. */ +export function zipMkdir(zip: JSZip, path: string): void { + const normalized = path.replace(/\/+$/, ''); + zip.folder(normalized); +} + +// ========================================================================= +// In-process serialization lock (per skillId) +// ========================================================================= + +const skillLocks = new Map>(); + +export async function withSkillEditLock(skillId: string, fn: () => Promise): Promise { + const prev = skillLocks.get(skillId) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((r) => { + release = r; + }); + const chained = prev.then(() => next); + skillLocks.set(skillId, chained); + try { + await prev; + return await fn(); + } finally { + release(); + if (skillLocks.get(skillId) === chained) { + skillLocks.delete(skillId); + } + } +} + +// ========================================================================= +// Generic mutate pipeline +// ========================================================================= + +export type EditCurrentPackageResult = { success: true; size: number; packageVersion: number }; + +export async function editCurrentPackage(params: { + skill: AgentSkillSchemaType; + teamId: string; + mutator: (zip: JSZip) => Promise | void; +}): Promise { + const { skill, teamId, mutator } = params; + const skillId = String((skill as AgentSkillSchemaType & { _id: unknown })._id); + const version = skill.currentVersion ?? 0; + + if (!skill.currentStorage || !skill.currentStorage.key) { + throw new UserError('Skill has no active version'); + } + + return withSkillEditLock(skillId, async () => { + // Acquire distributed lock for cross-replica protection + const lockHandle = await acquireSkillEditLock(skillId); + + try { + // Re-read skill inside lock to get the latest currentStorage + const freshSkill = await MongoAgentSkills.findOne({ + _id: skillId, + teamId, + deleteTime: null + }); + + if (!freshSkill || !freshSkill.currentStorage || !freshSkill.currentStorage.key) { + throw new UserError('Skill has no active version'); + } + + const oldBuffer = await downloadSkillPackage({ storageInfo: freshSkill.currentStorage }); + + // Renew lock before mutation (download may have been slow) + await renewSkillEditLock(lockHandle); + + // JSZip.loadAsync + generateAsync can triple the in-memory footprint + // (raw buffer + internal JSZip representation + new buffer). + // Check with 2× the actual size as a conservative estimate. + checkHeapHeadroom(oldBuffer.length * 2); + + const newBuffer = await mutateZip(oldBuffer, mutator); + + // Renew lock before upload (mutation may have been slow for large ZIPs) + await renewSkillEditLock(lockHandle); + + const storage = await uploadSkillPackage({ + teamId, + skillId, + version, + zipBuffer: newBuffer + }); + + await updateCurrentStorage(skillId, storage); + await MongoAgentSkillsVersion.updateOne( + { skillId, version }, + { + $set: { + 'storage.size': storage.size, + 'storage.key': storage.key, + 'storage.bucket': storage.bucket + } + } + ); + + // Bump packageVersion and read back the new value. + // packageVersion is a Mongoose-only field not present in the Zod-derived type. + const updated = await MongoAgentSkills.findOneAndUpdate( + { _id: skillId }, + { $inc: { packageVersion: 1 } } as any, + { new: true, projection: { packageVersion: 1 } } + ); + const newPackageVersion = (updated as any)?.packageVersion ?? 0; + + return { success: true, size: storage.size, packageVersion: newPackageVersion }; + } finally { + await releaseSkillEditLock(lockHandle); + } + }); +} diff --git a/packages/service/core/agentSkills/sandboxConfig.ts b/packages/service/core/agentSkills/sandboxConfig.ts index 7c2e59fe7611..1451524bc271 100644 --- a/packages/service/core/agentSkills/sandboxConfig.ts +++ b/packages/service/core/agentSkills/sandboxConfig.ts @@ -24,7 +24,6 @@ type BaseSandboxProviderConfig = { export type OpenSandboxProviderConfig = BaseSandboxProviderConfig & { provider: 'opensandbox'; apiKey?: string; - useServerProxy?: boolean; }; export type SealosDevboxProviderConfig = BaseSandboxProviderConfig & { @@ -83,8 +82,7 @@ export function getSandboxProviderConfig(): SandboxProviderConfig { provider, baseUrl: env.AGENT_SANDBOX_OPENSANDBOX_BASEURL ?? 'http://127.0.0.1:8080', apiKey: env.AGENT_SANDBOX_OPENSANDBOX_API_KEY, - runtime, - useServerProxy: env.AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY + runtime }; case 'sealosdevbox': @@ -132,6 +130,50 @@ export function getSkillSizeLimits(): SkillSizeLimits { }; } +/** Heap headroom ratio — refuse zip operations when heapUsed/heapTotal exceeds this. */ +const HEAP_HEADROOM_RATIO = 0.9; + +/** + * Small allocations are always permitted regardless of overall heap pressure. + * A single-digit-MB allocation is extremely unlikely to be the final push to + * OOM, and rejecting it would make the API completely unusable when the heap + * is already under moderate pressure (e.g. in memory-constrained dev/staging + * environments where --max-old-space-size was not tuned). + */ +const SMALL_ALLOCATION_BYTES = 10 * 1024 * 1024; // 10 MB + +/** + * Throw if the requested additional bytes would push the V8 heap past the + * headroom ratio, UNLESS the allocation is small enough to be safe even on a + * nearly-full heap (see SMALL_ALLOCATION_BYTES). + * + * Use before any in-memory zip operation (full-zip download, JSZip + * load/generate, decompress extraction) to prevent an individual request from + * triggering OOM when the heap is already under pressure. + * + * The check is a best-effort safety net, not a guarantee — concurrent + * allocations between the check and the actual operation may still exhaust + * the heap. Paired with the per-operation size limits in + * `getSkillSizeLimits()`, this provides two layers of defence. + */ +export function checkHeapHeadroom(additionalBytes: number): void { + // Always permit small allocations — even a nearly-full heap can absorb + // a few MB without OOM, and rejecting tiny requests makes the API unusable. + if (additionalBytes <= SMALL_ALLOCATION_BYTES) { + return; + } + + const { heapUsed, heapTotal } = process.memoryUsage(); + const projected = heapUsed + additionalBytes; + if (projected > heapTotal * HEAP_HEADROOM_RATIO) { + throw new Error( + `Insufficient heap memory: ${(heapUsed / 1024 / 1024).toFixed(0)}MB used of ` + + `${(heapTotal / 1024 / 1024).toFixed(0)}MB total, ` + + `need ${(additionalBytes / 1024 / 1024).toFixed(0)}MB more` + ); + } +} + /** * Validate sandbox configuration */ @@ -168,7 +210,6 @@ export function buildSandboxAdapter( apiKey: providerConfig.apiKey, baseUrl: providerConfig.baseUrl, runtime: providerConfig.runtime, - useServerProxy: providerConfig.useServerProxy, sessionId: props.providerSandboxId }, toOpenSandboxCreateConfig(props.createConfig) @@ -252,7 +293,7 @@ export async function getProviderSandboxEndpoint( } throw new Error( - `Sandbox provider "${sandbox.provider}" does not expose endpoint capability through @fastgpt/sandbox. This edit-debug workflow currently requires opensandbox-compatible endpoint support.` + `Sandbox provider "${sandbox.provider}" does not expose endpoint capability through @fastgpt/sandbox. This workflow currently requires opensandbox-compatible endpoint support.` ); } diff --git a/packages/service/core/agentSkills/sandboxController.ts b/packages/service/core/agentSkills/sandboxController.ts index ccb218739cd6..aeac521de9cf 100644 --- a/packages/service/core/agentSkills/sandboxController.ts +++ b/packages/service/core/agentSkills/sandboxController.ts @@ -6,55 +6,20 @@ */ import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; -import mongoose from 'mongoose'; import { MongoSandboxInstance } from '../ai/sandbox/schema'; -import { MongoAgentSkills } from './schema'; -import { MongoAgentSkillsVersion } from './version/schema'; -import { downloadSkillPackage } from './storage'; import { getSandboxProviderConfig, getSandboxDefaults, - validateSandboxConfig, getSkillSizeLimits, connectToProviderSandbox, - disconnectFromProviderSandbox, - getProviderSandboxEndpoint, - buildBaseContainerEnv, - waitForEndpointReady + disconnectFromProviderSandbox } from './sandboxConfig'; -import type { - SandboxInstanceSchemaType, - SandboxImageConfigType, - SkillSandboxEndpointType -} from '@fastgpt/global/core/agentSkills/type'; -import { SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; -import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; -import { getSandboxClient, type SandboxClient } from '../ai/sandbox/controller'; +import type { SandboxInstanceSchemaType } from '@fastgpt/global/core/agentSkills/type'; +import { getSandboxClient } from '../ai/sandbox/controller'; import { getLogger, LogCategories } from '../../common/logger'; -import { env } from '../../env'; -import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type'; const addLog = getLogger(LogCategories.MODULE.AI.AGENT); -export type CreateEditDebugSandboxParams = { - skillId: string; - teamId: string; - tmbId: string; - image?: SandboxImageConfigType; - entrypoint?: string; // override default entrypoint for this request - onProgress?: (status: SandboxStatusItemType) => void; // lifecycle progress callback -}; - -export type CreateEditDebugSandboxResult = { - sandboxId: string; - providerSandboxId: string; - endpoint: SkillSandboxEndpointType; - status: { - state: string; - message?: string; - }; -}; - export type GetSandboxInfoParams = { sandboxId: string; teamId: string; @@ -65,321 +30,6 @@ export type DeleteSandboxParams = { teamId: string; }; -/** - * Create an edit-debug sandbox for a skill - * - * Process: - * Phase 1 - Resolve and validate configuration - * Phase 2 - Pre-flight checks and resource preparation (auth, package download) - * Phase 3 - Sandbox operations (create, upload, extract, persist) - */ -export async function createEditDebugSandbox( - params: CreateEditDebugSandboxParams -): Promise { - const { skillId, teamId, tmbId, image, entrypoint, onProgress } = params; - - // === Phase 1: Resolve and validate configuration === - const providerConfig = getSandboxProviderConfig(); - const defaults = getSandboxDefaults(); - validateSandboxConfig(providerConfig); - - const sandboxImage = image || defaults.defaultImage; - - addLog.info('[Sandbox] Creating edit-debug sandbox', { - skillId, - teamId, - image: sandboxImage - }); - - // === Phase 2: Pre-flight checks and resource preparation === - - // Verify skill exists and user has permission - const skill = await MongoAgentSkills.findOne({ - _id: skillId, - teamId, - deleteTime: null - }); - - if (!skill) { - throw new Error('Skill not found or access denied'); - } - - if (!skill.currentStorage) { - throw new Error('Skill package not found - no current version available'); - } - - // Verify active version exists - const activeVersion = await MongoAgentSkillsVersion.findOne({ - skillId, - isActive: true, - isDeleted: false - }); - - if (!activeVersion) { - throw new Error('No active version found for skill'); - } - - // chat ID used for all edit-debug sandbox instances - const EDIT_DEBUG_CHAT_ID = 'edit-debug'; - - // Check for existing sandbox instance by skillId - const existingInstance = await MongoSandboxInstance.findOne({ - appId: skillId, - chatId: EDIT_DEBUG_CHAT_ID, - 'metadata.sandboxType': SandboxTypeEnum.editDebug - }); - - if (existingInstance) { - addLog.info('[Sandbox] Found existing sandbox instance, ensuring running', { - instanceId: existingInstance._id, - sandboxId: existingInstance.sandboxId - }); - - try { - onProgress?.({ sandboxId: skillId, phase: 'creatingContainer' }); - - // getSandboxClient internally calls ensureAvailable(): - // - updates DB status=running, lastActiveAt - // - calls provider.ensureRunning() to handle both running and stopped containers - // Pass skill-specific createConfig so the container is rebuilt with the - // correct image/entrypoint/env/metadata if it was accidentally deleted. - const client = await getSandboxClient( - { sandboxId: existingInstance.sandboxId }, - { - createConfig: { - image: sandboxImage, - entrypoint: [entrypoint ?? defaults.entrypoint], - env: buildBaseContainerEnv(existingInstance.sandboxId, defaults.workDirectory, true), - metadata: { - skillId, - teamId, - sandboxType: SandboxTypeEnum.editDebug, - sessionId: existingInstance.sandboxId - } - } - } - ); - - const endpointInfo = await getProviderSandboxEndpoint(client.provider, defaults.targetPort); - const sandboxInfo = await client.provider.getInfo(); - - // Wait for the HTTP service inside the container to bind its port before - // sending the ready SSE event — prevents ECONNREFUSED in the client iframe. - await waitForEndpointReady(endpointInfo); - - // Update endpoint and sandbox metadata in DB - await MongoSandboxInstance.updateOne( - { _id: existingInstance._id }, - { - 'metadata.endpoint': endpointInfo, - ...(sandboxInfo?.id && { 'metadata.providerSandboxId': sandboxInfo.id }) - } - ); - - onProgress?.({ - sandboxId: skillId, - phase: 'ready', - endpoint: endpointInfo, - providerSandboxId: existingInstance.sandboxId - }); - - return { - sandboxId: existingInstance._id.toString(), - providerSandboxId: existingInstance.sandboxId, - endpoint: endpointInfo, - status: { state: 'Running' } - }; - } catch (error) { - addLog.error('[Sandbox] Failed to ensure sandbox running', { error }); - throw error; - } - } - - // Download package.zip from MinIO and standardize it - addLog.info('[Sandbox] Downloading package from storage', { - key: activeVersion.storage.key - }); - - onProgress?.({ sandboxId: skillId, phase: 'downloadingPackage' }); - const packageBuffer = await downloadSkillPackage({ - storageInfo: activeVersion.storage - }); - - addLog.info('[Sandbox] Package downloaded', { size: packageBuffer.length }); - - // Package is already in ZIP format from import time. - const standardizedBuffer = packageBuffer; - - // Check active edit-debug sandbox count limit - const maxEditDebug = - global.feConfigs?.limit?.agentSandboxMaxEditDebug ?? env.AGENT_SANDBOX_MAX_EDIT_DEBUG; - if (maxEditDebug !== undefined) { - const activeCount = await MongoSandboxInstance.countDocuments({ - status: SandboxStatusEnum.running, - 'metadata.sandboxType': SandboxTypeEnum.editDebug - }); - if (activeCount >= maxEditDebug) { - const message = `Active edit-debug sandbox limit reached (${activeCount}/${maxEditDebug}). Please try again later.`; - onProgress?.({ sandboxId: skillId, phase: 'failed', message }); - throw new Error(message); - } - } - - // === Phase 3: Sandbox operations === - let sandbox: ISandbox | null = null; - let sandboxClient: SandboxClient | null = null; - - try { - addLog.info('[Sandbox] Creating sandbox instance', { - image: sandboxImage - }); - - onProgress?.({ sandboxId: skillId, phase: 'creatingContainer' }); - const sessionId = new mongoose.Types.ObjectId().toHexString(); - - // getSandboxClient handles volumes internally (via getVolumeManagerConfig) and calls - // provider.ensureRunning() which creates the container when it doesn't exist - const client = await getSandboxClient( - { sandboxId: sessionId }, - { - createConfig: { - image: sandboxImage, - entrypoint: [entrypoint ?? defaults.entrypoint], - env: buildBaseContainerEnv(sessionId, defaults.workDirectory, true), - // volumes: handled internally by getSandboxClient via getVolumeManagerConfig - metadata: { - skillId, - teamId, - sandboxType: SandboxTypeEnum.editDebug, - sessionId - } - } - } - ); - sandboxClient = client; - sandbox = client.provider; - - const sandboxInfo = await client.provider.getInfo(); - if (!sandboxInfo) throw new Error('Failed to get sandbox info after creation'); - - // Upload package to sandbox and extract - const zipPath = `${defaults.workDirectory}/package.zip`; - - addLog.info('[Sandbox] Uploading package to sandbox', { path: zipPath }); - - onProgress?.({ sandboxId: skillId, phase: 'uploadingPackage' }); - await client.provider.writeFiles([ - { - path: zipPath, - data: standardizedBuffer - } - ]); - - addLog.info('[Sandbox] Extracting package'); - onProgress?.({ sandboxId: skillId, phase: 'extractingPackage' }); - const extractResult = await client.provider.execute( - `mkdir -p ${defaults.workDirectory} && cd ${defaults.workDirectory} && unzip -o package.zip && rm package.zip` - ); - - if (extractResult.exitCode !== 0) { - throw new Error(`Failed to extract package: ${extractResult.stderr}`); - } - - addLog.info('[Sandbox] Package extracted successfully'); - - // Get endpoint - addLog.info('[Sandbox] Getting endpoint', { port: defaults.targetPort }); - const endpointInfo = await getProviderSandboxEndpoint(client.provider, defaults.targetPort); - - // Wait for the HTTP service to accept connections before persisting and emitting ready. - // The container may still be initializing even after package extraction completes. - await waitForEndpointReady(endpointInfo); - - addLog.info('[Sandbox] Endpoint obtained', endpointInfo); - - // Enrich the DB record created by getSandboxClient.ensureAvailable() with full skill metadata. - // Use sessionId (the client-side key) because ensureAvailable() stores the record with - // sandboxId=sessionId, not with the provider-assigned sandboxInfo.id. - const newSandboxDoc = await MongoSandboxInstance.findOneAndUpdate( - { sandboxId: sessionId }, - { - $set: { - appId: skillId, - userId: tmbId, - chatId: EDIT_DEBUG_CHAT_ID, - metadata: { - sandboxType: SandboxTypeEnum.editDebug, - teamId, - tmbId, - skillId, - sessionId, - providerSandboxId: sandboxInfo.id, // real sandbox ID for save-deploy connection - provider: providerConfig.provider, - image: sandboxInfo.image, - providerCreatedAt: sandboxInfo.createdAt, - endpoint: endpointInfo, - storage: { - bucket: activeVersion.storage.bucket, - key: activeVersion.storage.key, - size: standardizedBuffer.length, - uploadedAt: new Date() - }, - metadata: new Map([ - ['skillName', skill.name], - ['version', activeVersion.version.toString()] - ]) - } - } - }, - { new: true } - ); - - if (!newSandboxDoc) throw new Error('Failed to find sandbox document after creation'); - - addLog.info('[Sandbox] Sandbox info saved to database', { - sandboxId: newSandboxDoc._id - }); - - onProgress?.({ - sandboxId: skillId, - phase: 'ready', - endpoint: endpointInfo, - providerSandboxId: sessionId - }); - - return { - sandboxId: newSandboxDoc._id.toString(), - providerSandboxId: sessionId, - endpoint: endpointInfo, - status: { - state: sandboxInfo.status.state, - message: sandboxInfo.status.message - } - }; - } catch (error) { - addLog.error('[Sandbox] Failed to create sandbox', { - error, - rawBody: (error as any)?.cause?.rawBody ?? (error as any)?.rawBody - }); - - // sandboxClient.delete() cleans up: provider container + session volume + DB record - if (sandboxClient) { - try { - await sandboxClient.delete(); - // Prevent finally from trying to disconnect an already-deleted sandbox - sandbox = null; - } catch (cleanupError) { - addLog.error('[Sandbox] Failed to cleanup sandbox after error', { cleanupError }); - } - } - - throw error; - } finally { - if (sandbox) { - await disconnectFromProviderSandbox(sandbox); - } - } -} export async function getSandboxInfo( params: GetSandboxInfoParams ): Promise { diff --git a/packages/service/core/agentSkills/sandboxSync.ts b/packages/service/core/agentSkills/sandboxSync.ts new file mode 100644 index 000000000000..3aff563d6d96 --- /dev/null +++ b/packages/service/core/agentSkills/sandboxSync.ts @@ -0,0 +1,127 @@ +/** + * Skill Sandbox Sync + * + * Push the latest MinIO package.zip to the Run Preview sandbox + * and unzip in-place. Only clears first-level entries from the current zip, + * leaving other skills' directories in the same workspace untouched. + * + * Targeted sandbox instance: + * chatId = `debug-${hashStr(`${skillId}-${AGENT_NODE_ID}`).slice(0, 40)}` + * + * AGENT_NODE_ID must stay in sync with debugChat.ts:66. + * + * Uses acquireSkillEditLock (shared with packageEditor) to avoid reading a stale + * storage key that was just overwritten by a concurrent edit across replicas. + */ +import JSZip from 'jszip'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { MongoSandboxInstance } from '../ai/sandbox/schema'; +import { getSandboxClient } from '../ai/sandbox/controller'; +import { downloadSkillPackage } from './storage'; +import { getSandboxDefaults } from './sandboxConfig'; +import { listZipDirectory } from './packageEditor'; +import { acquireSkillEditLock, releaseSkillEditLock } from './editLock'; +import { MongoAgentSkills } from './schema'; +import { getLogger, LogCategories } from '../../common/logger'; +import { hashStr } from '@fastgpt/global/common/string/tools'; + +const addLog = getLogger(LogCategories.MODULE.AI.AGENT); + +// Keep in sync with debugChat.ts:66. +const AGENT_NODE_ID = 'skill-debug-agent'; + +export type SyncSkillSandboxResult = { + synced: boolean; + reason: 'noSandbox' | 'pushed'; +}; + +/** Single-quote for shell argument with basic escaping. */ +function shellSingleQuote(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** + * Sync current skill's MinIO package to the Run Preview sandbox. + * + * - no sandbox instance → { synced: false, reason: 'noSandbox' } (lifecycle creates with latest zip) + * - instance exists → download zip → write to container → remove only zip's root entries → unzip + */ +export async function syncSkillSandbox(params: { + skillId: string; + teamId: string; +}): Promise { + const { skillId, teamId } = params; + + const sessionId = `debug-${hashStr(`${skillId}-${AGENT_NODE_ID}`).slice(0, 40)}`; + const instance = await MongoSandboxInstance.findOne({ + chatId: sessionId + }).lean(); + + if (!instance) { + return { synced: false, reason: 'noSandbox' }; + } + + const lockHandle = await acquireSkillEditLock(skillId); + try { + // Re-read inside lock to get latest currentStorage + const skill = await MongoAgentSkills.findOne({ + _id: skillId, + teamId, + deleteTime: null + }); + + if (!skill || !skill.currentStorage || !skill.currentStorage.key) { + throw new UserError('Skill has no active version'); + } + + const zipBuffer = await downloadSkillPackage({ storageInfo: skill.currentStorage }); + + const zip = await JSZip.loadAsync(zipBuffer); + + // Validate zip entries before unzipping (Zip Slip defence) + const dangerous = Object.keys(zip.files).some((k) => k.includes('..') || k.startsWith('/')); + if (dangerous) { + throw new UserError('Package contains invalid paths'); + } + + // Parse top-level entries to selectively clean + const rootEntries = listZipDirectory(zip, ''); + const rootNames = rootEntries.map((e) => e.name).filter((n) => n && n !== '.' && n !== '..'); + + const client = await getSandboxClient({ sandboxId: instance.sandboxId }); + const { workDirectory } = getSandboxDefaults(); + const zipPath = `${workDirectory}/__sync.zip`; + + await client.provider.writeFiles([{ path: zipPath, data: zipBuffer }]); + + const removeOldCmd = + rootNames.length > 0 ? `rm -rf ${rootNames.map(shellSingleQuote).join(' ')}` : 'true'; + + const command = [ + 'set -e', + `cd ${workDirectory}`, + removeOldCmd, + 'unzip -oq __sync.zip', + 'rm -f __sync.zip' + ].join(' && '); + + const result = await client.provider.execute(command); + if (result.exitCode !== 0) { + throw new Error( + `Failed to sync skill package to sandbox: exitCode=${result.exitCode}, stderr=${result.stderr}` + ); + } + + addLog.info('[Sandbox] Synced sandbox', { + skillId, + sandboxId: instance.sandboxId, + sessionId, + rootEntries: rootNames, + size: zipBuffer.length + }); + + return { synced: true, reason: 'pushed' }; + } finally { + await releaseSkillEditLock(lockHandle); + } +} diff --git a/packages/service/core/agentSkills/schema.ts b/packages/service/core/agentSkills/schema.ts index 76b0e07754d0..b541527901aa 100644 --- a/packages/service/core/agentSkills/schema.ts +++ b/packages/service/core/agentSkills/schema.ts @@ -88,6 +88,12 @@ const AgentSkillsSchema = new Schema({ bucket: String, key: String, size: Number + }, + // Monotonically increasing counter incremented on every editCurrentPackage + // call. Used by the frontend to detect stale state (P2 conflict awareness). + packageVersion: { + type: Number, + default: 0 } }); @@ -101,14 +107,6 @@ try { AgentSkillsSchema.index({ category: 1 }); // Folder hierarchy index AgentSkillsSchema.index({ parentId: 1, teamId: 1, deleteTime: 1 }); - // Unique constraint: same parent folder cannot have two live skills/folders with the same name (personal only) - AgentSkillsSchema.index( - { parentId: 1, name: 1, teamId: 1, deleteTime: 1 }, - { - unique: true, - partialFilterExpression: { deleteTime: null, source: AgentSkillSourceEnum.personal } - } - ); } catch (error) { console.log('AgentSkill index error:', error); } diff --git a/packages/service/core/agentSkills/skillMdBuilder.ts b/packages/service/core/agentSkills/skillMdBuilder.ts index 6edcd75a5555..4a6470f36904 100644 --- a/packages/service/core/agentSkills/skillMdBuilder.ts +++ b/packages/service/core/agentSkills/skillMdBuilder.ts @@ -20,7 +20,7 @@ export type GenerateSkillParam = { name: string; description: string; requirements: string; - model: string; + modelId: string; }; /** @@ -169,7 +169,7 @@ export async function getSkillGuidance( name: string, description: string, requirements: string, - model: string + modelId: string ): Promise<{ guidance: { goal: string; workflow?: string; requirements?: string; examples?: string }; usage: { inputTokens: number; outputTokens: number }; @@ -187,7 +187,7 @@ export async function getSkillGuidance( const { answerText, usage } = await createLLMResponse({ body: { - model, + modelId, messages, temperature: 0, max_tokens: 1000, @@ -226,14 +226,14 @@ export async function getSkillGuidance( export async function generateSkillMd( params: GenerateSkillParam ): Promise<[string, { inputTokens: number; outputTokens: number }]> { - const model = params.model; + const modelId = params.modelId; // Step 1: Parse requirements into structured guidance const { guidance, usage: guidanceUsage } = await getSkillGuidance( params.name, params.description, params.requirements, - model + modelId ); // Build messages for LLM @@ -256,7 +256,7 @@ export async function generateSkillMd( // Step 2: Call LLM to generate SKILL.md (non-streaming) const { answerText, usage: generateUsage } = await createLLMResponse({ body: { - model, + modelId, messages, temperature: 0.1, max_tokens: 4000, diff --git a/packages/service/core/agentSkills/storage.ts b/packages/service/core/agentSkills/storage.ts index 4d9a982610e1..d4abfcc7a0c8 100644 --- a/packages/service/core/agentSkills/storage.ts +++ b/packages/service/core/agentSkills/storage.ts @@ -6,8 +6,7 @@ */ import { S3PrivateBucket } from '../../common/s3/buckets/private'; -import type { ClientSession } from '../../common/mongo'; -import { getSkillSizeLimits } from './sandboxConfig'; +import { getSkillSizeLimits, checkHeapHeadroom } from './sandboxConfig'; export type SkillStorageInfo = { bucket: string; @@ -98,6 +97,15 @@ export async function downloadSkillPackage(params: DownloadSkillPackageParams): const { storageInfo } = params; const { maxDownloadBytes } = getSkillSizeLimits(); + // Verify heap headroom BEFORE establishing the S3 connection. + // If the check fails after downloadObject(), the HTTP stream is leaked + // because the catch block never runs (no try-catch wraps the connection). + const estimatedBytes = + storageInfo.size > 0 + ? Math.min(storageInfo.size, maxDownloadBytes) + : Math.min(50 * 1024 * 1024, maxDownloadBytes); + checkHeapHeadroom(estimatedBytes); + const bucket = new S3PrivateBucket(); const response = await bucket.client.downloadObject({ @@ -111,20 +119,106 @@ export async function downloadSkillPackage(params: DownloadSkillPackageParams): // Convert stream to buffer with size limit to prevent OOM const chunks: Buffer[] = []; let totalSize = 0; - for await (const chunk of response.body) { - const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalSize += buf.length; - if (totalSize > maxDownloadBytes) { - throw new Error( - `Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)` - ); + + // Attach error listener to prevent unhandled stream errors from crashing the process + response.body.on('error', () => { + // Stream errors surface via the for-await loop; this listener prevents + // the error from becoming an unhandled 'error' event on the stream. + }); + + try { + for await (const chunk of response.body) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalSize += buf.length; + if (totalSize > maxDownloadBytes) { + throw new Error( + `Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)` + ); + } + chunks.push(buf); } - chunks.push(buf); + } catch (err) { + // Destroy the stream to free the underlying HTTP connection. + // Without this, a partially-consumed stream holds the connection open + // until GC or timeout, which under concurrency exhausts the S3 pool. + if (response.body && !response.body.destroyed) { + response.body.destroy(); + } + throw err; } return Buffer.concat(chunks); } +/** + * Stream skill package from MinIO/S3 directly to a writable stream (e.g. HTTP + * response). Avoids buffering the entire ZIP in memory — each chunk is + * forwarded as it arrives. The same size limit as downloadSkillPackage is + * enforced; when exceeded the S3 source stream is destroyed and an error is + * thrown so the caller can abort the response. + * + * IMPORTANT: The caller is responsible for setting appropriate response headers + * (Content-Type, Content-Disposition, etc.) *before* calling this function. + * Content-Length is set automatically from storageInfo.size when available. + */ +import type { Writable } from 'node:stream'; + +export async function streamSkillPackageToResponse( + params: DownloadSkillPackageParams & { + res: Writable & { setHeader?: (name: string, value: string | number) => void }; + } +): Promise { + const { storageInfo, res } = params; + const { maxDownloadBytes } = getSkillSizeLimits(); + + // Set Content-Length from stored metadata when available + if (storageInfo.size > 0 && res.setHeader) { + res.setHeader('Content-Length', storageInfo.size); + } + + // Fast-fail when stored size already exceeds the limit + if (storageInfo.size > maxDownloadBytes) { + throw new Error( + `Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)` + ); + } + + const bucket = new S3PrivateBucket(); + const response = await bucket.client.downloadObject({ key: storageInfo.key }); + + if (!response.body) { + throw new Error(`Failed to download skill package: ${storageInfo.key}`); + } + + let totalSize = 0; + + response.body.on('error', () => { + // Prevent unhandled stream errors; they surface via for-await + }); + + try { + for await (const chunk of response.body) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalSize += buf.length; + if (totalSize > maxDownloadBytes) { + if (!response.body.destroyed) { + response.body.destroy(); + } + throw new Error( + `Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)` + ); + } + res.write(buf); + } + res.end(); + } catch (err) { + if (response.body && !response.body.destroyed) { + response.body.destroy(); + } + throw err; + } +} + /** * Delete skill package from MinIO/S3 storage */ @@ -234,7 +328,7 @@ export async function listSessionArtifacts(sessionId: string): Promise const bucket = new S3PrivateBucket(); const { keys } = await bucket.client.listObjects({ prefix }); - return keys.map((key) => key.replace(prefix, '')); + return keys.map((key: string) => key.replace(prefix, '')); } /** @@ -254,8 +348,20 @@ export async function downloadSessionArtifact( } const chunks: Buffer[] = []; - for await (const chunk of response.body) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + + response.body.on('error', () => { + // Prevent unhandled stream error events; errors surface via for-await + }); + + try { + for await (const chunk of response.body) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + } catch (err) { + if (response.body && !response.body.destroyed) { + response.body.destroy(); + } + throw err; } return Buffer.concat(chunks); diff --git a/packages/service/core/ai/audio/speech.ts b/packages/service/core/ai/audio/speech.ts index bb270cee4a4d..da862d0c679c 100644 --- a/packages/service/core/ai/audio/speech.ts +++ b/packages/service/core/ai/audio/speech.ts @@ -1,29 +1,29 @@ import type { NextApiResponse } from 'next'; import { getAIApi } from '../config'; -import { getTTSModel } from '../model'; +import { getTTSModelById } from '../model'; export async function text2Speech({ res, onSuccess, onError, input, - model, + modelId, voice, speed = 1 }: { res: NextApiResponse; - onSuccess: (e: { model: string; buffer: Buffer }) => void; + onSuccess: (e: { modelId: string; buffer: Buffer }) => void; onError: (e: any) => void; input: string; - model: string; + modelId: string; voice: string; speed?: number; }) { - const modelData = getTTSModel(model)!; + const modelData = getTTSModelById(modelId)!; const ai = getAIApi(); const response = await ai.audio.speech.create( { - model, + model: modelData.model, // @ts-ignore voice, input, @@ -49,7 +49,7 @@ export async function text2Speech({ chunks.push(chunk); }); readableStream.on('end', () => { - onSuccess({ model, buffer: Buffer.concat(chunks) }); + onSuccess({ modelId: modelData.id, buffer: Buffer.concat(chunks) }); }); readableStream.on('error', (e) => { onError(e); diff --git a/packages/service/core/ai/config.ts b/packages/service/core/ai/config.ts index 79c83913b828..46639ea747e2 100644 --- a/packages/service/core/ai/config.ts +++ b/packages/service/core/ai/config.ts @@ -1,5 +1,14 @@ import OpenAI from '@fastgpt/global/core/ai'; +import type { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming +} from '@fastgpt/global/core/ai/llm/type'; +import type { StreamChatType, UnStreamChatType } from '@fastgpt/global/core/ai/type'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { addLog } from '../../common/system/log'; +import { i18nT } from '../../../global/common/i18n/utils'; import { type OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; +import { type LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; const aiProxyBaseUrl = process.env.AIPROXY_API_ENDPOINT ? `${process.env.AIPROXY_API_ENDPOINT}/v1` @@ -32,3 +41,114 @@ export const getAxiosConfig = (props?: { userKey?: OpenaiAccountType }) => { authorization: `Bearer ${apiKey}` }; }; + +/** + * Resolve full endpoint config for a model (e.g., for passing to external services like DiTing). + * Follows the same credential resolution chain as getAIApi: + * + * baseUrl: callerOverride → modelData.requestUrl → oneapiUrl → OPENAI_BASE_URL → https://api.openai.com/v1 + * apiKey: callerOverride → modelData.requestAuth → chatApiKey → CHAT_API_KEY → '' + */ +export const getModelEndpointConfig = ( + modelData?: { model?: string; requestUrl?: string; requestAuth?: string }, + overrides?: { baseUrl?: string; apiKey?: string } +) => { + return { + name: modelData?.model || '', + baseUrl: + overrides?.baseUrl || modelData?.requestUrl || global?.systemEnv?.oneapiUrl || openaiBaseUrl, + apiKey: + overrides?.apiKey || modelData?.requestAuth || global?.systemEnv?.chatApiKey || openaiBaseKey + }; +}; + +export const createChatCompletion = async ({ + modelData, + body, + userKey, + timeout, + options +}: { + modelData: LLMModelItemType; + body: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming; + userKey?: OpenaiAccountType; + timeout?: number; + options?: OpenAI.RequestOptions; +}): Promise< + { + getEmptyResponseTip: () => string; + } & ( + | { + response: StreamChatType; + isStreamResponse: true; + } + | { + response: UnStreamChatType; + isStreamResponse: false; + } + ) +> => { + try { + // Rewrite model + body.model = modelData.model; + + const formatTimeout = timeout ? timeout : 600000; + const ai = getAIApi({ + userKey, + timeout: formatTimeout + }); + + addLog.debug(`Start create chat completion`, { + model: body.model + }); + + const response = await ai.chat.completions.create(body, { + ...options, + ...(modelData.requestUrl ? { path: modelData.requestUrl } : {}), + headers: { + ...options?.headers, + ...(modelData.requestAuth ? { Authorization: `Bearer ${modelData.requestAuth}` } : {}) + } + }); + + const isStreamResponse = + typeof response === 'object' && + response !== null && + ('iterator' in response || 'controller' in response); + + const getEmptyResponseTip = () => { + addLog.warn(`LLM response empty`, { + baseUrl: userKey?.baseUrl, + requestBody: body + }); + if (userKey?.baseUrl) { + return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`; + } + return i18nT('chat:LLM_model_response_empty'); + }; + + if (isStreamResponse) { + return { + response, + isStreamResponse: true, + getEmptyResponseTip + }; + } + + return { + response, + isStreamResponse: false, + getEmptyResponseTip + }; + } catch (error) { + addLog.error(`LLM response error`, error); + addLog.warn(`LLM response error`, { + baseUrl: userKey?.baseUrl, + requestBody: body + }); + if (userKey?.baseUrl) { + return Promise.reject(`您的 OpenAI key 出错了: ${getErrText(error)}`); + } + return Promise.reject(error); + } +}; diff --git a/packages/service/core/ai/config/schema.ts b/packages/service/core/ai/config/schema.ts index 33a422792fbd..2d43558430e3 100644 --- a/packages/service/core/ai/config/schema.ts +++ b/packages/service/core/ai/config/schema.ts @@ -1,21 +1,56 @@ import { connectionMongo, getMongoModel } from '../../../common/mongo'; const { Schema } = connectionMongo; -import type { SystemModelSchemaType } from '../type'; +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import type { SystemModelItemType } from '../type'; -const SystemModelSchema = new Schema({ - model: { - type: String, - required: true, - unique: true +// ═══ 所有模型类型共享的 base 字段 ═══ +// 对应 zod: BaseModelItemSchema +const BaseModelFields = { + model: { type: String, required: true }, + type: { type: String }, + provider: { type: String }, + name: { type: String }, + avatar: { type: String }, + isActive: { type: Boolean }, + isCustom: { type: Boolean }, + isTuned: { type: Boolean }, + isDefault: { type: Boolean }, + requestUrl: { type: String }, + requestAuth: { type: String }, + testMode: { type: Boolean }, + tmbId: { type: Schema.Types.ObjectId, ref: TeamMemberCollectionName }, + teamId: { type: Schema.Types.ObjectId, ref: TeamCollectionName }, + isShared: { type: Boolean, default: false } +}; + +// ═══ 价格字段 (所有模型类型共享) ═══ +// 对应 zod: PriceTypeSchema +const PriceFields = { + charsPointsPrice: { type: Number }, + priceTiers: { type: [Schema.Types.Mixed] }, + /** @deprecated */ inputPrice: { type: Number }, + /** @deprecated */ outputPrice: { type: Number } +}; + +// 类型特有字段 (如 maxContext, voices, maxToken 等) 不在此声明, +// 由 normalizeSystemModel() 在写入前通过 Zod schema 清洗后动态存储。 +// strict: false 允许这些字段写入 MongoDB。 +const SystemModelSchema = new Schema( + { + ...BaseModelFields, + ...PriceFields }, - metadata: { - type: Object, - required: true, - default: {} - } -}); + { strict: false } +); + +SystemModelSchema.index({ teamId: 1 }); +SystemModelSchema.index({ tmbId: 1 }); +SystemModelSchema.index({ isShared: 1 }); -export const MongoSystemModel = getMongoModel( +export const MongoSystemModel = getMongoModel( 'system_models', SystemModelSchema ); diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index 356efe63f7e0..460ae1759b33 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -6,11 +6,16 @@ import { type EmbeddingModelItemType, type TTSModelType, type STTModelType, - type RerankModelItemType + type RerankModelItemType, + LLMModelItemSchema, + EmbeddingModelItemSchema, + TTSModelItemSchema, + STTModelItemSchema, + RerankModelItemSchema } from '@fastgpt/global/core/ai/model.schema'; import { debounce } from 'lodash'; import { getModelProvider } from '../../../core/app/provider/controller'; -import { findModelFromAlldata } from '../model'; +import { getModelById } from '../model'; import { reloadFastGPTConfigBuffer, updateFastGPTConfigBuffer @@ -18,11 +23,79 @@ import { import { delay } from '@fastgpt/global/common/system/utils'; import { pluginClient } from '../../../thirdProvider/fastgptPlugin'; import { setCron } from '../../../common/system/cron'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { addLog } from '../../../common/system/log'; +import { FastGPTProUrl } from '../../../common/system/constants'; +import { GET } from '../../../common/api/plusRequest'; import { preloadModelProviders } from '../../../core/app/provider/controller'; import { refreshVersionKey } from '../../../common/cache'; import { SystemCacheKeyEnum } from '../../../common/cache/type'; import { getLogger, LogCategories } from '../../../common/logger'; import { getRuntimeResolvedPriceTiers } from '@fastgpt/global/core/ai/pricing'; +import type { ZodObject } from 'zod'; +import { MongoUser } from '../../../support/user/schema'; +import { MongoTeamMember } from '../../../support/user/team/teamMemberSchema'; +import { MongoResourcePermission } from '../../../support/permission/schema'; +import { OwnerRoleVal, PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; + +const leanToModelItem = (doc: any): SystemModelItemType => { + const { _id, __v, ...rest } = doc; + // Strip null/undefined values so they don't override plugin defaults + const cleaned = Object.fromEntries( + Object.entries(rest).filter(([, v]) => v !== null && v !== undefined) + ); + return { + ...cleaned, + id: String(_id) + } as SystemModelItemType; +}; + +/** + * Normalize plugin model data before persisting to DB or merging into memory. + * - Strips null/undefined values + * - Parses with the type-specific Zod schema to apply defaults, strip unknown fields and validate + */ + +// Pre-compiled schemas without `id` (plugin models don't have DB id yet) +const llmParseSchema = LLMModelItemSchema.omit({ id: true }); +const embeddingParseSchema = EmbeddingModelItemSchema.omit({ id: true }); +const ttsParseSchema = TTSModelItemSchema.omit({ id: true }); +const rerankParseSchema = RerankModelItemSchema.omit({ id: true }); +const sttParseSchema = STTModelItemSchema.omit({ id: true }); + +const schemaByType: Record = { + [ModelTypeEnum.llm]: llmParseSchema, + [ModelTypeEnum.embedding]: embeddingParseSchema, + [ModelTypeEnum.tts]: ttsParseSchema, + [ModelTypeEnum.rerank]: rerankParseSchema, + [ModelTypeEnum.stt]: sttParseSchema +}; + +const normalizeSystemModel = (model: any): any => { + const cleaned = Object.fromEntries( + Object.entries(model).filter(([, v]) => v !== null && v !== undefined) + ); + + const schema = schemaByType[model.type]; + if (!schema) return cleaned; + + const result = schema.safeParse(cleaned); + return result.success ? result.data : cleaned; +}; + +const getRootMemberForSystemModelOwner = async () => { + const rootUser = await MongoUser.findOne({ username: 'root' }, '_id').lean(); + if (!rootUser) return; + + const rootMember = await MongoTeamMember.findOne({ userId: rootUser._id }, '_id teamId').lean(); + if (!rootMember) return; + + return { + rootTmbId: rootMember._id, + rootTeamId: rootMember.teamId + }; +}; export const loadSystemModels = async (init = false, language = 'en') => { if (!init && global.systemModelList) return; @@ -37,21 +110,23 @@ export const loadSystemModels = async (init = false, language = 'en') => { let _systemModelList: SystemModelItemType[] = []; let _systemActiveModelList: SystemModelItemType[] = []; - let _llmModelMap = new Map(); - let _embeddingModelMap = new Map(); - let _ttsModelMap = new Map(); - let _sttModelMap = new Map(); - let _reRankModelMap = new Map(); + let _systemModelIdMap = new Map(); + let _llmModelIdMap = new Map(); + let _embeddingModelIdMap = new Map(); + let _ttsModelIdMap = new Map(); + let _sttModelIdMap = new Map(); + let _reRankModelIdMap = new Map(); let _systemDefaultModel: SystemDefaultModelType = {}; if (!global.systemModelList) { global.systemModelList = []; global.systemActiveModelList = []; - global.llmModelMap = new Map(); - global.embeddingModelMap = new Map(); - global.ttsModelMap = new Map(); - global.sttModelMap = new Map(); - global.reRankModelMap = new Map(); + global.systemModelIdMap = new Map(); + global.llmModelIdMap = new Map(); + global.embeddingModelIdMap = new Map(); + global.ttsModelIdMap = new Map(); + global.sttModelIdMap = new Map(); + global.reRankModelIdMap = new Map(); global.systemDefaultModel = {}; global.systemActiveDesensitizedModels = []; } @@ -59,14 +134,15 @@ export const loadSystemModels = async (init = false, language = 'en') => { const pushModel = (model: SystemModelItemType) => { _systemModelList.push(model); + _systemModelIdMap.set(model.id, model); + if (model.isActive) { _systemActiveModelList.push(model); if (model.type === ModelTypeEnum.llm) { model.priceTiers = getRuntimeResolvedPriceTiers(model); - _llmModelMap.set(model.model, model); - _llmModelMap.set(model.name, model); + _llmModelIdMap.set(model.id, model); if (model.isDefault) { _systemDefaultModel.llm = model; } @@ -76,30 +152,29 @@ export const loadSystemModels = async (init = false, language = 'en') => { if (model.isDefaultDatasetImageModel) { _systemDefaultModel.datasetImageLLM = model; } + if (model.isDefaultEvaluationModel) { + _systemDefaultModel.evaluation = model; + } if (model.model === process.env.HELPER_BOT_MODEL) { _systemDefaultModel.helperBotLLM = model; } } else if (model.type === ModelTypeEnum.embedding) { - _embeddingModelMap.set(model.model, model); - _embeddingModelMap.set(model.name, model); + _embeddingModelIdMap.set(model.id, model); if (model.isDefault) { _systemDefaultModel.embedding = model; } } else if (model.type === ModelTypeEnum.tts) { - _ttsModelMap.set(model.model, model); - _ttsModelMap.set(model.name, model); + _ttsModelIdMap.set(model.id, model); if (model.isDefault) { _systemDefaultModel.tts = model; } } else if (model.type === ModelTypeEnum.stt) { - _sttModelMap.set(model.model, model); - _sttModelMap.set(model.name, model); + _sttModelIdMap.set(model.id, model); if (model.isDefault) { _systemDefaultModel.stt = model; } } else if (model.type === ModelTypeEnum.rerank) { - _reRankModelMap.set(model.model, model); - _reRankModelMap.set(model.name, model); + _reRankModelIdMap.set(model.id, model); if (model.isDefault) { _systemDefaultModel.rerank = model; } @@ -109,13 +184,92 @@ export const loadSystemModels = async (init = false, language = 'en') => { try { // Get model from db and plugin - const [dbModels, systemModels] = await Promise.all([ + const [dbModelsResult, systemModels] = await Promise.all([ MongoSystemModel.find({}).lean(), pluginClient .listModels() .then((res) => res) .catch(() => []) ]); + let dbModels = dbModelsResult; + + // Persist system built-in models without MongoDB records so they have _id for modelId-based lookup + { + const pluginModelsWithoutDB = systemModels.filter( + (model) => !dbModels.find((db) => db.model === model.model && db.isCustom !== true) + ); + + if (pluginModelsWithoutDB.length > 0) { + const rootMember = await getRootMemberForSystemModelOwner(); + const modelNames = pluginModelsWithoutDB.map((m) => m.model); + const refreshedDocs = await mongoSessionRun(async (session) => { + // Use bulkWrite + upsert to safely handle concurrent startup across nodes + await MongoSystemModel.bulkWrite( + pluginModelsWithoutDB.map((model) => ({ + updateOne: { + filter: { model: model.model, isCustom: false }, + update: { + $setOnInsert: { + ...normalizeSystemModel(model), + isCustom: false, + isShared: false, + ...(rootMember + ? { + teamId: rootMember.rootTeamId, + tmbId: rootMember.rootTmbId + } + : {}) + } + }, + upsert: true + } + })) as any, + { ordered: false, session } + ); + // Re-fetch to get _id values for both pre-existing and newly inserted records + const refreshedDocs = await MongoSystemModel.find({ + model: { $in: modelNames }, + isCustom: false + }) + .lean() + .session(session); + + if (rootMember && refreshedDocs.length > 0) { + await MongoResourcePermission.bulkWrite( + refreshedDocs.map((doc) => { + const resourceId = String(doc._id); + return { + updateOne: { + filter: { + teamId: rootMember.rootTeamId, + tmbId: rootMember.rootTmbId, + resourceType: PerResourceTypeEnum.model, + resourceId + }, + update: { + $setOnInsert: { + teamId: rootMember.rootTeamId, + tmbId: rootMember.rootTmbId, + resourceType: PerResourceTypeEnum.model, + resourceId, + permission: OwnerRoleVal + } + }, + upsert: true + } + }; + }), + { ordered: false, session } + ); + } + + return refreshedDocs; + }); + dbModels = dbModels.filter((db) => !modelNames.includes(db.model) || db.isCustom !== false); + dbModels.push(...refreshedDocs); + addLog.info(`Persisted ${refreshedDocs.length} system models to MongoDB`); + } + } // Load system model from local systemModels.forEach((model) => { @@ -126,26 +280,26 @@ export const loadSystemModels = async (init = false, language = 'en') => { return { ...formatObj1, ...formatObj2 }; }; - const dbModel = dbModels.find((item) => item.model === model.model); - const provider = getModelProvider(dbModel?.metadata?.provider || model.provider, language); + const dbModel = dbModels.find((item) => item.model === model.model && item.isCustom !== true); + const provider = getModelProvider(dbModel?.provider || model.provider, language); const modelData: any = { - ...model, - ...dbModel?.metadata, + ...normalizeSystemModel(model), + ...(dbModel ? leanToModelItem(dbModel) : {}), provider: provider.id, avatar: provider.avatar, - type: dbModel?.metadata?.type || model.type, + type: dbModel?.type || model.type, isCustom: false, ...(model.type === ModelTypeEnum.llm && { maxResponse: model.maxTokens ?? 16000 }), - ...(model.type === ModelTypeEnum.llm && dbModel?.metadata?.type === ModelTypeEnum.llm + ...(model.type === ModelTypeEnum.llm && dbModel?.type === ModelTypeEnum.llm ? { - maxResponse: dbModel?.metadata?.maxResponse ?? model.maxTokens ?? 8000, - defaultConfig: mergeObject(model.defaultConfig, dbModel?.metadata?.defaultConfig), - fieldMap: mergeObject(model.fieldMap, dbModel?.metadata?.fieldMap), + maxResponse: dbModel.maxResponse ?? model.maxTokens ?? 8000, + defaultConfig: mergeObject(model.defaultConfig, dbModel.defaultConfig), + fieldMap: mergeObject(model.fieldMap, dbModel.fieldMap), /** @deprecated */ maxTokens: undefined } @@ -154,14 +308,28 @@ export const loadSystemModels = async (init = false, language = 'en') => { pushModel(modelData); }); + // Clean up orphaned system model DB records (plugin removed but DB record remains) + { + const systemModelNames = new Set(systemModels.map((m) => m.model)); + const orphanedIds = dbModels + .filter((db) => db.isCustom === false && !systemModelNames.has(db.model)) + .map((db) => db._id); + + if (orphanedIds.length > 0) { + await MongoSystemModel.deleteMany({ _id: { $in: orphanedIds } }); + dbModels = dbModels.filter((db) => !orphanedIds.includes(db._id)); + addLog.info(`Cleaned up ${orphanedIds.length} orphaned system model DB records`); + } + } + // Custom model(Not in system config) dbModels.forEach((dbModel) => { - if (_systemModelList.find((item) => item.model === dbModel.model)) return; + if (_systemModelList.find((item) => item.id === dbModel._id.toString())) return; pushModel({ - ...dbModel.metadata, + ...leanToModelItem(dbModel), isCustom: true - }); + } as SystemModelItemType); }); // Sort model list @@ -171,35 +339,55 @@ export const loadSystemModels = async (init = false, language = 'en') => { return providerA.order - providerB.order; }); - // Default model check + // Default model check (only from public models) { + const publicLLMList = + _llmModelIdMap.size > 0 + ? Array.from(_llmModelIdMap.values()).filter((m) => m.isShared) + : []; + const publicEmbeddingList = + _embeddingModelIdMap.size > 0 + ? Array.from(_embeddingModelIdMap.values()).filter((m) => m.isShared) + : []; + const publicTTSList = + _ttsModelIdMap.size > 0 + ? Array.from(_ttsModelIdMap.values()).filter((m) => m.isShared) + : []; + const publicSTTList = + _sttModelIdMap.size > 0 + ? Array.from(_sttModelIdMap.values()).filter((m) => m.isShared) + : []; + const publicRerankList = + _reRankModelIdMap.size > 0 + ? Array.from(_reRankModelIdMap.values()).filter((m) => m.isShared) + : []; + if (!_systemDefaultModel.llm) { - _systemDefaultModel.llm = Array.from(_llmModelMap.values())[0]; + _systemDefaultModel.llm = publicLLMList[0]; } if (!_systemDefaultModel.datasetTextLLM) { - _systemDefaultModel.datasetTextLLM = Array.from(_llmModelMap.values())[0]; + _systemDefaultModel.datasetTextLLM = publicLLMList[0]; } if (!_systemDefaultModel.datasetImageLLM) { - _systemDefaultModel.datasetImageLLM = Array.from(_llmModelMap.values()).find( - (item) => item.vision - ); + _systemDefaultModel.datasetImageLLM = undefined; + } + if (!_systemDefaultModel.evaluation) { + _systemDefaultModel.evaluation = publicLLMList.find((item) => item.useInEvaluation); } if (!_systemDefaultModel.helperBotLLM) { - _systemDefaultModel.helperBotLLM = _systemActiveModelList.find( - (item) => item.type === ModelTypeEnum.llm - ); + _systemDefaultModel.helperBotLLM = publicLLMList[0]; } if (!_systemDefaultModel.embedding) { - _systemDefaultModel.embedding = Array.from(_embeddingModelMap.values())[0]; + _systemDefaultModel.embedding = publicEmbeddingList[0]; } if (!_systemDefaultModel.tts) { - _systemDefaultModel.tts = Array.from(_ttsModelMap.values())[0]; + _systemDefaultModel.tts = publicTTSList[0]; } if (!_systemDefaultModel.stt) { - _systemDefaultModel.stt = Array.from(_sttModelMap.values())[0]; + _systemDefaultModel.stt = publicSTTList[0]; } if (!_systemDefaultModel.rerank) { - _systemDefaultModel.rerank = Array.from(_reRankModelMap.values())[0]; + _systemDefaultModel.rerank = publicRerankList[0]; } } @@ -207,11 +395,12 @@ export const loadSystemModels = async (init = false, language = 'en') => { { global.systemModelList = _systemModelList; global.systemActiveModelList = _systemActiveModelList; - global.llmModelMap = _llmModelMap; - global.embeddingModelMap = _embeddingModelMap; - global.ttsModelMap = _ttsModelMap; - global.sttModelMap = _sttModelMap; - global.reRankModelMap = _reRankModelMap; + global.systemModelIdMap = _systemModelIdMap; + global.llmModelIdMap = _llmModelIdMap; + global.embeddingModelIdMap = _embeddingModelIdMap; + global.ttsModelIdMap = _ttsModelIdMap; + global.sttModelIdMap = _sttModelIdMap; + global.reRankModelIdMap = _reRankModelIdMap; global.systemDefaultModel = _systemDefaultModel; global.systemActiveDesensitizedModels = _systemActiveModelList.map((model) => ({ ...model, @@ -239,15 +428,15 @@ export const loadSystemModels = async (init = false, language = 'en') => { } }; -export const getSystemModelConfig = async (model: string): Promise => { - const modelData = findModelFromAlldata(model); +export const getSystemModelConfig = async (modelId: string): Promise => { + const modelData = getModelById(modelId); if (!modelData) return Promise.reject('Model is not found'); if (modelData.isCustom) return Promise.reject('Custom model not data'); - // Read file + // Read file - use modelData.model (OpenAI name) to match against plugin models const modelDefaulConfig = await pluginClient .listModels() - .then((models) => models.find((item) => item.model === model) as SystemModelItemType); + .then((models) => models.find((item) => item.model === modelData.model) as SystemModelItemType); return { ...modelDefaulConfig, @@ -290,3 +479,121 @@ export const cronRefreshModels = async () => { await updateFastGPTConfigBuffer(); }); }; + +// prompt Loader +export interface PromptLoader { + // Load Prompt from certain language + loadTemplate(filename: string, locale: localeType, key: string): string; +} + +declare global { + var promptLoader: PromptLoader; +} + +// Default Loader +export class DefaultPromptLoader implements PromptLoader { + loadTemplate(filename: string, locale: localeType, key: string): string { + return ''; + } +} + +export const setPromptLoader = (loader: PromptLoader) => { + global.promptLoader = loader; +}; + +export class ProPromptLoader implements PromptLoader { + private cache: Map = new Map(); + + loadTemplate(filename: string, locale: localeType, key: string): string { + const cacheKey = `${filename}_${key}_${locale}`; + + // Return from cache if available + if (this.cache.has(cacheKey)) { + addLog.debug(`[ProPromptLoader] Cache hit: ${cacheKey}`); + return this.cache.get(cacheKey)!; + } + + return ''; + } + + /** + * Preload a single template + */ + async preloadTemplate(filename: string, locale: localeType, key: string): Promise { + if (!FastGPTProUrl) throw new Error('FastGPTProUrl is not Configured'); + const template = await GET( + '/core/dataset/load', + { filename, locale, key }, + { timeout: 5000 } + ); + const cacheKey = `${filename}_${key}_${locale}`; + this.cache.set(cacheKey, template); + } + + /** + * Preload all common templates + */ + async preloadAllTemplates(): Promise { + const templates = [ + { + filename: 'hypeIndexes', + locale: 'zh-CN' as localeType, + key: 'generate_question_from_faq_prompt' + }, + { + filename: 'hypeIndexes', + locale: 'en' as localeType, + key: 'generate_question_from_faq_prompt' + }, + { + filename: 'hypeIndexes', + locale: 'zh-Hant' as localeType, + key: 'generate_question_from_faq_prompt' + }, + { + filename: 'autoIndexes', + locale: 'zh-CN' as localeType, + key: 'auto_training_prompt' + }, + { + filename: 'autoIndexes', + locale: 'en' as localeType, + key: 'auto_training_prompt' + }, + { + filename: 'autoIndexes', + locale: 'zh-Hant' as localeType, + key: 'auto_training_prompt' + }, + { + filename: 'imageIndex', + locale: 'zh-CN' as localeType, + key: 'image_index_prompt' + }, + { + filename: 'imageIndex', + locale: 'zh-Hant' as localeType, + key: 'image_index_prompt' + }, + { + filename: 'imageIndex', + locale: 'en' as localeType, + key: 'image_index_prompt' + } + ]; + + const results = await Promise.allSettled( + templates.map((t) => this.preloadTemplate(t.filename, t.locale, t.key)) + ); + + const succeeded = results.filter((r) => r.status === 'fulfilled'); + + if (succeeded.length === 0) { + throw new Error('Failed to preload any templates'); + } + + addLog.info( + `[ProPromptLoader] Successfully preloaded ${succeeded.length}/${templates.length} templates` + ); + } +} diff --git a/packages/service/core/ai/embedding/index.ts b/packages/service/core/ai/embedding/index.ts index 220c2fe501e2..d9bf27e85b33 100644 --- a/packages/service/core/ai/embedding/index.ts +++ b/packages/service/core/ai/embedding/index.ts @@ -7,22 +7,39 @@ import { getLogger, LogCategories } from '../../../common/logger'; const logger = getLogger(LogCategories.MODULE.AI.EMBEDDING); +export function formatEmbeddingQuery(queries: string[], instruction?: string): string[] { + if (!instruction) return queries; + const formatted = queries.map((query) => `Instruct: ${instruction}\nQuery: ${query}`); + logger.debug('formatEmbeddingQuery result', { instruction, formatted }); + return formatted; +} + type GetVectorProps = { model: EmbeddingModelItemType; input: string[] | string; type?: `${EmbeddingTypeEnm}`; headers?: Record; + useInstruction?: boolean; }; // text to vector -export async function getVectorsByText({ model, input, type, headers }: GetVectorProps) { +export async function getVectorsByText({ + model, + input, + type, + headers, + useInstruction = true +}: GetVectorProps) { if (!input) { return Promise.reject({ code: 500, message: 'input is empty' }); } - const ai = getAIApi(); + // 30-second timeout prevents workers from hanging indefinitely + // when the embedding API is overloaded by concurrent requests. + const embeddingTimeout = model.dbConfig?.timeout || model.defaultConfig?.timeout || 30000; + const ai = getAIApi({ timeout: embeddingTimeout }); const formatInput = Array.isArray(input) ? input : [input]; @@ -46,7 +63,10 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto .create( { model: model.model, - input: chunk, + input: + type === EmbeddingTypeEnm.query && useInstruction + ? formatEmbeddingQuery(chunk, model.instruction) + : chunk, encoding_format: 'float', ...model.defaultConfig, ...(type === EmbeddingTypeEnm.db && model.dbConfig), diff --git a/packages/service/core/ai/functions/createQuestionGuide.ts b/packages/service/core/ai/functions/createQuestionGuide.ts index df7199158381..7b27c4e965e2 100644 --- a/packages/service/core/ai/functions/createQuestionGuide.ts +++ b/packages/service/core/ai/functions/createQuestionGuide.ts @@ -11,11 +11,11 @@ const logger = getLogger(LogCategories.MODULE.AI.FUNCTIONS); export async function createQuestionGuide({ messages, - model, + modelId, customPrompt }: { messages: ChatCompletionMessageParam[]; - model: string; + modelId: string; customPrompt?: string; }): Promise<{ result: string[]; @@ -32,10 +32,12 @@ export async function createQuestionGuide({ const { answerText: answer, + finish_reason, + error: llmError, usage: { inputTokens, outputTokens } } = await createLLMResponse({ body: { - model, + modelId, temperature: 0.1, max_tokens: 200, messages: concatMessages, @@ -43,11 +45,19 @@ export async function createQuestionGuide({ } }); - const start = answer.indexOf('['); - const end = answer.lastIndexOf(']'); + if (finish_reason !== 'stop') { + logger.warn('Question guide response abnormal finish_reason', { + finish_reason, + llmError, + answer + }); + } + + const start = answer.indexOf('{'); + const end = answer.lastIndexOf('}'); if (start === -1 || end === -1) { - logger.warn('Question guide response missing JSON array', { answer }); + logger.warn('Question guide response missing JSON object', { answer }); return { result: [], inputTokens, @@ -61,8 +71,12 @@ export async function createQuestionGuide({ .replace(/ /g, ''); try { + const parsed = json5.parse(jsonStr) as { language?: string; questions?: string[] }; + if (parsed.language) { + logger.debug('Question guide language', { language: parsed.language }); + } return { - result: json5.parse(jsonStr), + result: Array.isArray(parsed.questions) ? parsed.questions : [], inputTokens, outputTokens }; diff --git a/packages/service/core/ai/functions/queryExtension.ts b/packages/service/core/ai/functions/queryExtension.ts index 7b22ffb5c672..44eac77a7150 100644 --- a/packages/service/core/ai/functions/queryExtension.ts +++ b/packages/service/core/ai/functions/queryExtension.ts @@ -1,12 +1,16 @@ import { replaceVariable } from '@fastgpt/global/common/string/tools'; -import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import { type ChatItemMiniType, type ChatItemType } from '@fastgpt/global/core/chat/type'; import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt'; -import { getLLMModel } from '../model'; +import { getLLMModelById } from '../model'; import { filterGPTMessageByMaxContext } from '../llm/utils'; import json5 from 'json5'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; +import { loadRequestMessages } from '../llm/utils'; import { createLLMResponse } from '../llm/request'; import { useTextCosine } from '../hooks/useTextCosine'; +import { getSynonymMappings, standardizeQuery } from '../../dataset/search/utils'; import { getLogger, LogCategories } from '../../../common/logger'; +import { LangEnum } from '@fastgpt/global/common/i18n/type'; const logger = getLogger(LogCategories.MODULE.AI.FUNCTIONS); @@ -28,14 +32,14 @@ const defaultPrompt = `## 你的任务 ## 参考示例 -历史记录: +历史记录: """ null """ 原问题: 介绍下剧情。 检索词: ["介绍下故事的背景。","故事的主题是什么?","介绍下故事的主要人物。","故事的转折点在哪里?","故事的结局如何?"] ---------------- -历史记录: +历史记录: """ user: 对话背景。 assistant: 当前对话是关于 Nginx 的介绍和使用等。 @@ -43,7 +47,7 @@ assistant: 当前对话是关于 Nginx 的介绍和使用等。 原问题: 怎么下载 检索词: ["Nginx 如何下载?","下载 Nginx 需要什么条件?","有哪些渠道可以下载 Nginx?","Nginx 各版本的下载方式有什么区别?","如何选择合适的 Nginx 版本下载?"] ---------------- -历史记录: +历史记录: """ user: 对话背景。 assistant: 当前对话是关于 Nginx 的介绍和使用等。 @@ -53,7 +57,7 @@ assistant: 报错"no connection"可能是因为…… 原问题: 怎么解决 检索词: ["Nginx报错'no connection'如何解决?","造成'no connection'报错的原因。","Nginx提示'no connection',要怎么办?","'no connection'错误的常见解决步骤。","如何预防 Nginx 'no connection' 错误?"] ---------------- -历史记录: +历史记录: """ user: How long is the maternity leave? assistant: The number of days of maternity leave depends on the city in which the employee is located. Please provide your city so that I can answer your questions. @@ -61,7 +65,7 @@ assistant: The number of days of maternity leave depends on the city in which th 原问题: ShenYang 检索词: ["How many days is maternity leave in Shenyang?","Shenyang's maternity leave policy.","The standard of maternity leave in Shenyang.","What benefits are included in Shenyang's maternity leave?","How to apply for maternity leave in Shenyang?"] ---------------- -历史记录: +历史记录: """ user: 作者是谁? assistant: ${title} 的作者是 labring。 @@ -124,24 +128,28 @@ export const queryExtension = async ({ chatBg, query, histories = [], - llmModel, - embeddingModel, + llmModelId, + embeddingModelId, generateCount = 10 // 生成优化问题集的数量,默认为10个 }: { chatBg?: string; query: string; histories: ChatItemMiniType[]; - llmModel: string; - embeddingModel: string; + llmModelId: string; + embeddingModelId: string; generateCount?: number; }): Promise<{ rawQuery: string; extensionQueries: string[]; - llmModel: string; - embeddingModel: string; + llmModelId: string; + embeddingModelId: string; inputTokens: number; outputTokens: number; embeddingTokens: number; + synonymRewriteResult?: { + standardizedQuery: string; // 指代消除后标准化的查询(用于检索) + coreferenceResolved: string; // 指代消除后的查询(同义词标准化前) + }; }> => { const systemFewShot = chatBg ? `user: 对话背景。 @@ -150,7 +158,7 @@ assistant: ${chatBg} : ''; // 1. Request model - const modelData = getLLMModel(llmModel); + const modelData = getLLMModelById(llmModelId); const filterHistories = await filterGPTMessageByMaxContext({ messages: chats2GPTMessages({ messages: histories, reserveId: false }), maxContext: modelData.maxContext - 1000 @@ -189,7 +197,7 @@ assistant: ${chatBg} } = await createLLMResponse({ body: { stream: true, - model: modelData.model, + modelId: llmModelId, temperature: 0.1, messages } @@ -199,8 +207,8 @@ assistant: ${chatBg} return { rawQuery: query, extensionQueries: [], - llmModel: modelData.model, - embeddingModel, + llmModelId: modelData.id, + embeddingModelId: embeddingModelId, inputTokens: inputTokens, outputTokens: outputTokens, embeddingTokens: 0 @@ -217,8 +225,8 @@ assistant: ${chatBg} return { rawQuery: query, extensionQueries: [], - llmModel: modelData.model, - embeddingModel, + llmModelId: modelData.id, + embeddingModelId: embeddingModelId, inputTokens: inputTokens, outputTokens: outputTokens, embeddingTokens: 0 @@ -238,8 +246,8 @@ assistant: ${chatBg} return { rawQuery: query, extensionQueries: [], - llmModel: modelData.model, - embeddingModel, + llmModelId: modelData.id, + embeddingModelId: embeddingModelId, inputTokens, outputTokens, embeddingTokens: 0 @@ -247,8 +255,8 @@ assistant: ${chatBg} } // 3. 通过计算获取到最优的检索词 - const { lazyGreedyQuerySelection, embeddingModel: useEmbeddingModel } = useTextCosine({ - embeddingModel + const { lazyGreedyQuerySelection, embeddingModelId: useEmbeddingModelId } = useTextCosine({ + embeddingModelId }); queries = queries.map((item) => String(item)); @@ -262,11 +270,12 @@ assistant: ${chatBg} return { rawQuery: query, extensionQueries: selectedQueries, - llmModel: modelData.model, - embeddingModel: useEmbeddingModel, + llmModelId: modelData.id, + embeddingModelId: useEmbeddingModelId, inputTokens, outputTokens, - embeddingTokens + embeddingTokens, + synonymRewriteResult: undefined }; } catch (error) { logger.warn('Query extension failed', { @@ -276,10 +285,364 @@ assistant: ${chatBg} return { rawQuery: query, extensionQueries: [], - llmModel: modelData.model, - embeddingModel, + llmModelId: modelData.id, + embeddingModelId: embeddingModelId, inputTokens, outputTokens, + embeddingTokens: 0, + synonymRewriteResult: undefined + }; + } +}; + +/* + queryExtensionForAssistant - 智能客服专用的问题优化 + 针对 assistant 类型应用,执行:指代消解、同义词标准化、问题改写 +*/ + +// 合并后的提示词:同时完成指代消除和问题改写 +const MergedQueryOptimizationPromptZH = `- Role: 问题优化专家 +- Background: 用户问题可能存在指代不明确或需要多角度理解的情况,你需要帮助优化问题以提升检索效果。 +- Skills: + 1. 擅长识别并替换指代词(他/她/它/这/那等)、补充省略条件(时间/地点/前提等),生成完整的疑问句 + 2. 能从不同角度拆解和改写问题,生成多个疑问句形式的检索问题,提高检索覆盖率 + +- Task: 你需要完成两个任务 + 1. 指代消除:根据上下文将问题中的代词和指示词替换为具体对象,使问题完整明确。输出必须是疑问句形式。 + 2. 问题改写:基于消除指代后的问题,从不同角度生成2-3个改写问题。所有改写问题必须是疑问句形式,不能是陈述句。 + +- Constraints: + 1. 只有在存在可识别的代词或指示词时,才进行替换 + 2. 只有在缺失必要条件时,才进行补充 + 3. 无法确定指代时保留原问题 + 4. 改写的问题要保持原意,只改变表达角度 + 5. 如果问题中出现多个产品或主体,可以为每个主体生成独立的子问题 + 6. **关键要求**:resolvedQuery 和 rewriteQueries 中的所有内容必须是疑问句,以"?"或"?"结尾,绝不能是陈述句 + 7. **语言要求**:输出语言与原问题相同。原问题为中文则输出中文;原问题为英文则输出英文 + +- OutputFormat: 必须返回严格的JSON格式,包含三个字段: + { + "questionPreview": "原问题内容的前五个字符", + "resolvedQuery": "指代消除后的完整疑问句(必须以?或?结尾)", + "rewriteQueries": ["改写疑问句1", "改写疑问句2", "改写疑问句3"] + } + +- Workflow: + 1. 分析上下文,识别问题中的代词/指示词 + 2. 将代词替换为具体指代对象,补充缺失条件,得到resolvedQuery(必须是疑问句) + 3. 基于resolvedQuery,从不同角度生成2-3个改写疑问句(不是陈述句) + 4. 以JSON格式输出结果 + +- Examples: +例1: +流畅的Python很有趣。 +问题:这本书的作者是谁? +{"resolvedQuery":"流畅的Python这本书的作者是谁?","rewriteQueries":["流畅的Python的作者是谁?","谁写了流畅的Python这本书?","流畅的Python的创作者是谁?"]} + +例2: +用户昨天购买了《百年孤独》 +问题:他什么时候买的? +{"resolvedQuery":"用户什么时候购买的《百年孤独》?","rewriteQueries":["用户购买《百年孤独》的时间是什么时候?","《百年孤独》是何时被用户购买的?"]} + +例3: +给我安排一个旅行计划,我要去河西走廊附近,时间8天。 +问题:超融合是什么? +{"resolvedQuery":"超融合是什么?","rewriteQueries":["超融合的定义是什么?","什么是超融合技术?","超融合的概念是什么?"]} + +例4: +用户询问Nginx的配置 +问题:怎么配置SSL和负载均衡? +{"resolvedQuery":"怎么配置Nginx的SSL和负载均衡?","rewriteQueries":["如何在Nginx中配置SSL?","Nginx负载均衡如何配置?","Nginx的SSL和负载均衡配置方法是什么?"]}`; + +const MergedQueryOptimizationPromptEN = `- Role: Query Optimization Expert +- Background: User questions may contain unclear references or require multi-perspective understanding. You need to help optimize questions to improve retrieval effectiveness. +- Skills: + 1. Skilled at identifying and replacing pronouns (he/she/it/this/that, etc.) and filling in omitted conditions (time/location/premise, etc.) to generate complete interrogative sentences + 2. Able to break down and rewrite questions from different perspectives, generating multiple interrogative retrieval queries to improve retrieval coverage + +- Task: You need to complete two tasks + 1. Coreference Resolution: Based on context, replace pronouns and demonstratives in the question with specific entities to make it complete and clear. Output must be in interrogative form. + 2. Question Rewriting: Based on the coreference-resolved question, generate 2-3 rewritten questions from different perspectives. All rewritten questions must be in interrogative form, not declarative statements. + +- Constraints: + 1. Only replace when there are identifiable pronouns or demonstratives + 2. Only supplement when necessary conditions are missing + 3. Keep the original question if coreference cannot be determined + 4. Rewritten questions must preserve the original meaning, only changing the expression perspective + 5. If the question involves multiple products or subjects, independent sub-questions can be generated for each subject + 6. **Critical requirement**: All content in resolvedQuery and rewriteQueries must be interrogative sentences ending with "?" and must never be declarative statements + 7. **Language requirement**: Output language must match the original question. If the original question is in Chinese, output in Chinese; if in English, output in English + +- OutputFormat: Must return strict JSON format with three fields: + { + "questionPreview": "The first 5 characters of the user's question", + "resolvedQuery": "Coreference-resolved complete interrogative sentence (must end with ?)", + "rewriteQueries": ["Rewritten interrogative sentence 1", "Rewritten interrogative sentence 2", "Rewritten interrogative sentence 3"] + } + +- Workflow: + 1. Analyze context to identify pronouns/demonstratives in the question + 2. Replace pronouns with specific referents, supplement missing conditions, and obtain resolvedQuery (must be an interrogative sentence) + 3. Based on resolvedQuery, generate 2-3 rewritten interrogative sentences (not declarative statements) from different perspectives + 4. Output the result in JSON format + +- Examples: +Example 1: +Fluent Python is very interesting. +Question: Who is the author of this book? +{"resolvedQuery":"Who is the author of the book Fluent Python?","rewriteQueries":["Who wrote Fluent Python?","Who is the creator of Fluent Python?","What is the name of Fluent Python's author?"]} + +Example 2: +The user purchased One Hundred Years of Solitude yesterday. +Question: When did he buy it? +{"resolvedQuery":"When did the user purchase One Hundred Years of Solitude?","rewriteQueries":["At what time was One Hundred Years of Solitude purchased by the user?","When was One Hundred Years of Solitude bought?","What was the purchase date of One Hundred Years of Solitude?"]} + +Example 3: +Plan a travel itinerary for me, I want to go near the Hexi Corridor area for 8 days. +Question: What is hyper-convergence? +{"resolvedQuery":"What is hyper-convergence?","rewriteQueries":["What is the definition of hyper-convergence?","What is hyper-convergence technology?","What is the concept of hyper-convergence?"]} + +Example 4: +The user is asking about Nginx configuration. +Question: How to configure SSL and load balancing? +{"resolvedQuery":"How to configure SSL and load balancing in Nginx?","rewriteQueries":["How to configure SSL in Nginx?","How to set up load balancing in Nginx?","What is the method to configure SSL and load balancing in Nginx?"]}`; + +const getMergedQueryOptimizationPrompt = (lang: string) => { + const isEnglish = lang === LangEnum.en; + logger.debug('MergedQueryOptimizationPrompt activated', { + lang, + isEnglish: lang === LangEnum.en + }); + return isEnglish ? MergedQueryOptimizationPromptEN : MergedQueryOptimizationPromptZH; +}; + +// 合并后的辅助函数:同时完成指代消除和问题改写 +async function mergedQueryOptimization({ + histories, + query, + modelId, + lang +}: { + histories: ChatItemType[]; + query: string; + modelId: string; + lang: string; +}): Promise<{ + resolvedQuery: string; + rewriteQueries: string[]; + inputTokens: number; + outputTokens: number; +}> { + try { + // 获取最后3轮对话作为上下文 + const messages = chats2GPTMessages({ messages: histories.slice(-6), reserveId: false }); + const historyText = messages + .map((msg) => `${msg.role === 'user' ? '用户' : 'AI'}: ${msg.content}`) + .join('\n'); + + // 如果 lang 未设置(API 请求等场景),通过 query 文本检测语言 + const effectiveLang = lang || (/[\u4e00-\u9fff]/.test(query) ? LangEnum.zh_CN : LangEnum.en); + const optimizationMessages: ChatCompletionMessageParam[] = [ + { + role: 'user', + content: `${getMergedQueryOptimizationPrompt(effectiveLang)}\n\n-----\n\n${historyText}\n\n\n问题: ${query}` + } + ]; + + const requestMessages = await loadRequestMessages({ + messages: optimizationMessages, + useVision: false + }); + + const { + answerText: answer, + usage: { inputTokens, outputTokens } + } = await createLLMResponse({ + body: { + modelId: modelId, + temperature: 0.1, + messages: requestMessages, + stream: true + } + }); + + // 尝试解析JSON + try { + const parsed = json5.parse(answer); + if (parsed.resolvedQuery && parsed.rewriteQueries && Array.isArray(parsed.rewriteQueries)) { + // 限制改写问题数量为2-3个 + const limitedRewriteQueries = parsed.rewriteQueries.slice(0, 3); + return { + resolvedQuery: parsed.resolvedQuery, + rewriteQueries: limitedRewriteQueries, + inputTokens, + outputTokens + }; + } + } catch (e) { + // 如果JSON解析失败,尝试手动提取 + const start = answer.indexOf('{'); + const end = answer.lastIndexOf('}'); + if (start !== -1 && end !== -1) { + const jsonStr = answer.substring(start, end + 1); + try { + const parsed = json5.parse(jsonStr); + if ( + parsed.resolvedQuery && + parsed.rewriteQueries && + Array.isArray(parsed.rewriteQueries) + ) { + const limitedRewriteQueries = parsed.rewriteQueries.slice(0, 3); + return { + resolvedQuery: parsed.resolvedQuery, + rewriteQueries: limitedRewriteQueries, + inputTokens, + outputTokens + }; + } + } catch (innerE) { + logger.debug('Failed to parse extracted JSON', { jsonStr, error: innerE }); + } + } + } + + // 解析失败时返回原始query和空数组 + return { + resolvedQuery: query, + rewriteQueries: [], + inputTokens, + outputTokens + }; + } catch (error) { + logger.debug('Merged query optimization error', { error }); + return { + resolvedQuery: query, + rewriteQueries: [], + inputTokens: 0, + outputTokens: 0 + }; + } +} + +// 主函数:智能客服专用的查询扩展 +export const queryExtensionForAssistant = async ({ + query, + histories = [], + modelId, + teamId, + datasetIds, + lang +}: { + query: string; + histories: ChatItemType[]; + modelId: string; + teamId: string; + datasetIds: string[]; + lang: string; +}): Promise<{ + rawQuery: string; + extensionQueries: string[]; + llmModelId: string; + embeddingModelId: string; + inputTokens: number; + outputTokens: number; + embeddingTokens: number; + synonymRewriteResult?: { + standardizedQuery: string; // 指代消除后标准化的查询(用于检索) + coreferenceResolved: string; // 指代消除后的查询(同义词标准化前) + }; +}> => { + let totalInputTokens = 0; + let totalOutputTokens = 0; + + try { + // 步骤1: 同时完成指代消除和问题改写 + const optimizationResult = await mergedQueryOptimization({ + histories, + query, + modelId, + lang + }); + totalInputTokens += optimizationResult.inputTokens; + totalOutputTokens += optimizationResult.outputTokens; + + logger.info('Merged query optimization completed', { + original: query, + resolvedQuery: optimizationResult.resolvedQuery, + rewriteQueries: optimizationResult.rewriteQueries + }); + + // 步骤2: 从所有知识库检索标准词 (使用指代消除后的query进行全文检索,每个知识库top10,然后汇总) + const { synonymDict, synonymFileIds } = await getSynonymMappings({ + teamId, + datasetIds, + query: optimizationResult.resolvedQuery + }); + + logger.info('Retrieved synonym mappings from all datasets', { + query: optimizationResult.resolvedQuery, + datasetCount: datasetIds.length, + synonymCount: Object.keys(synonymDict).length, + totalSynonymTerms: Object.values(synonymDict).reduce((sum, terms) => sum + terms.length, 0), + synonymFileIds + }); + + // 步骤3: 对指代消除后的查询和改写问题都进行同义词标准化 + const resolvedStandardized = standardizeQuery(optimizationResult.resolvedQuery, synonymDict); + const standardizedRewriteQueries = optimizationResult.rewriteQueries.map((rq) => + standardizeQuery(rq, synonymDict) + ); + + // 步骤4: 组合结果并去重 + // 返回: [指代消除+标准化, 改写问题1标准化, 改写问题2标准化, ...] + const allQueries = [ + resolvedStandardized, // 指代消除+标准化 + ...standardizedRewriteQueries // 改写问题标准化 + ].filter((q) => q && q.trim().length > 0); + + // 去重:使用 Set 根据标准化后的字符串进行去重 + const seenQueries = new Set(); + const extensionQueries = allQueries.filter((q) => { + const normalized = q.trim().toLowerCase().replace(/\s+/g, ' '); + if (seenQueries.has(normalized)) { + return false; + } + seenQueries.add(normalized); + return true; + }); + + logger.info('Query extension for assistant completed', { + originalQuery: query, + extensionQueries, + totalInputTokens, + totalOutputTokens + }); + + // 构建 synonymRewriteResult(记录指代消除后标准化的query) + const synonymRewriteResult = { + standardizedQuery: resolvedStandardized, + coreferenceResolved: optimizationResult.resolvedQuery + }; + + return { + rawQuery: query, + extensionQueries, + llmModelId: modelId, + embeddingModelId: '', + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + embeddingTokens: 0, + synonymRewriteResult + }; + } catch (error) { + logger.error('Query extension for assistant failed', { error }); + return { + rawQuery: query, + extensionQueries: [query], + llmModelId: modelId, + embeddingModelId: '', + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, embeddingTokens: 0 }; } diff --git a/packages/service/core/ai/hooks/useTextCosine.ts b/packages/service/core/ai/hooks/useTextCosine.ts index 6e73f8157a90..f1a4364e0bd8 100644 --- a/packages/service/core/ai/hooks/useTextCosine.ts +++ b/packages/service/core/ai/hooks/useTextCosine.ts @@ -4,7 +4,7 @@ */ import { getVectorsByText } from '../embedding'; -import { getEmbeddingModel } from '../model'; +import { getEmbeddingModelById } from '../model'; class PriorityQueue { private heap: Array<{ item: T; priority: number }> = []; @@ -26,8 +26,8 @@ class PriorityQueue { return this.heap.length; } } -export const useTextCosine = ({ embeddingModel }: { embeddingModel: string }) => { - const vectorModel = getEmbeddingModel(embeddingModel); +export const useTextCosine = ({ embeddingModelId }: { embeddingModelId: string }) => { + const vectorModel = getEmbeddingModelById(embeddingModelId); // Calculate marginal gain const computeMarginalGain = ( candidateEmbedding: number[], @@ -149,6 +149,6 @@ export const useTextCosine = ({ embeddingModel }: { embeddingModel: string }) => return { lazyGreedyQuerySelection, - embeddingModel: vectorModel.model + embeddingModelId: vectorModel.id }; }; diff --git a/packages/service/core/ai/llm/agentCall/index.ts b/packages/service/core/ai/llm/agentCall/index.ts index e0c3de5a4a25..a6ae0b7fa082 100644 --- a/packages/service/core/ai/llm/agentCall/index.ts +++ b/packages/service/core/ai/llm/agentCall/index.ts @@ -14,11 +14,11 @@ import { createLLMResponse } from '../request'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { compressRequestMessages, compressToolResponse } from '../compress'; import { filterGPTMessageByMaxContext } from '../utils'; -import { getLLMModel } from '../../model'; +import { getLLMModelById } from '../../model'; import { filterEmptyAssistantMessages } from './utils'; import { countGptMessagesTokens } from '../../../../common/string/tiktoken/index'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../global/common/i18n/utils'; type RunAgentCallProps = { maxRunAgentTimes: number; @@ -84,7 +84,7 @@ type RunAgentResponse = { interactiveResponse?: ToolCallChildrenInteractive; // Usage - model: string; + modelId: string; inputTokens: number; outputTokens: number; llmTotalPoints: number; // 每次 LLM 调用单独计价后的累计价格(用于梯度计费) @@ -112,7 +112,7 @@ type RunAgentResponse = { */ export const runAgentCall = async ({ maxRunAgentTimes, - body: { model, messages, max_tokens, tools, ...body }, + body: { modelId, messages, max_tokens, tools, ...body }, userKey, usagePush, @@ -129,7 +129,7 @@ export const runAgentCall = async ({ onToolCall, onToolParam }: RunAgentCallProps): Promise => { - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); let runTimes = 0; let interactiveResponse: ToolCallChildrenInteractive | undefined; @@ -209,7 +209,7 @@ export const runAgentCall = async ({ if (interactiveResponse || stop) { return { requestIds: [], - model: modelData.model, + modelId: modelData.id, inputTokens: 0, outputTokens: 0, llmTotalPoints: 0, @@ -272,7 +272,7 @@ export const runAgentCall = async ({ body: { ...body, max_tokens, - model, + modelId, messages: requestMessages, tool_choice: consecutiveRequestToolTimes > 5 ? 'none' : 'auto', toolCallMode: modelData.toolChoice ? 'toolChoice' : 'prompt', @@ -310,7 +310,7 @@ export const runAgentCall = async ({ const totalPoints = userKey ? 0 : formatModelChars2Points({ - model: modelData, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }).totalPoints; @@ -319,7 +319,7 @@ export const runAgentCall = async ({ usagePush?.([ { moduleName: i18nT('account_usage:agent_call'), - model: modelData.name, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens @@ -414,7 +414,7 @@ export const runAgentCall = async ({ return { requestIds, error: requestError, - model: modelData.model, + modelId: modelData.id, inputTokens, outputTokens, llmTotalPoints, diff --git a/packages/service/core/ai/llm/compress/index.ts b/packages/service/core/ai/llm/compress/index.ts index ef9b1505d49a..c45da1fd6584 100644 --- a/packages/service/core/ai/llm/compress/index.ts +++ b/packages/service/core/ai/llm/compress/index.ts @@ -8,7 +8,7 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/typ import { getCompressRequestMessagesPrompt } from './prompt'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../global/common/i18n/utils'; import { parseJsonArgs } from '../../utils'; import { batchRun } from '@fastgpt/global/common/system/utils'; import { getLogger, LogCategories } from '../../../../common/logger'; @@ -80,7 +80,7 @@ export const compressRequestMessages = async ({ userKey, body: { stream: true, - model, + modelId: model.id, messages: [ { role: ChatCompletionRequestMessageRoleEnum.System, @@ -103,13 +103,13 @@ export const compressRequestMessages = async ({ const totalPoints = userKey ? 0 : formatModelChars2Points({ - model: model.model, + modelId: model.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }).totalPoints; const compressedUsage = { moduleName: i18nT('account_usage:compress_llm_messages'), - model: model.name, + modelId: model.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, @@ -241,7 +241,7 @@ export const compressLargeContent = async ({ const { answerText, usage } = await createLLMResponse({ userKey, body: { - model, + modelId: model.id, messages: [ { role: ChatCompletionRequestMessageRoleEnum.System, @@ -264,7 +264,7 @@ export const compressLargeContent = async ({ const totalPoints = userKey ? 0 : formatModelChars2Points({ - model: model.model, + modelId: model.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }).totalPoints; @@ -334,7 +334,7 @@ export const compressLargeContent = async ({ // 截断为一半 const halfLength = Math.floor(merged.length / 2); - merged = merged.substring(0, halfLength) + '\n\n... [content truncated] ...\n\n'; + merged = merged.substring(0, halfLength); } return { @@ -418,7 +418,7 @@ export const compressLargeContent = async ({ compressed: result.compressed.trim(), usage: { moduleName: i18nT('account_usage:llm_compress_text'), - model: model.name, + modelId: model.id, totalPoints: result.usage.totalPoints, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens diff --git a/packages/service/core/ai/llm/request.ts b/packages/service/core/ai/llm/request.ts index 5a9a4dae6dba..000c6b70e03e 100644 --- a/packages/service/core/ai/llm/request.ts +++ b/packages/service/core/ai/llm/request.ts @@ -21,12 +21,12 @@ import { getAIApi } from '../config'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; import { customNanoid, getNanoid } from '@fastgpt/global/common/string/tools'; import { parsePromptToolCall, promptToolCallMessageRewrite } from './promptCall'; -import { getLLMModel } from '../model'; +import { getLLMModelById } from '../model'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { countGptMessagesTokens } from '../../../common/string/tiktoken/index'; import { loadRequestMessages } from './utils'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import json5 from 'json5'; import { getLogger, LogCategories } from '../../../common/logger'; @@ -36,6 +36,26 @@ const getRequestId = () => { return customNanoid('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_-', 16); }; +/** + * 根据是否启用 thinking 计算 maxTokens fallback。 + * 调用方显式设置 max_tokens 时优先使用,否则 enableThinking=true 给 16384 兜底。 + * 不硬编码模型名,由调用方通过 enableThinking 参数显式声明。 + */ +function resolveMaxTokenFallback( + requestedMaxTokens: number | null | undefined, + enableThinking: boolean | undefined +): number | undefined { + if (requestedMaxTokens) return requestedMaxTokens; + return enableThinking ? 16384 : undefined; +} + +// export type ResponseEvents = { +// onStreaming?: ({ text }: { text: string }) => void; +// onReasoning?: ({ text }: { text: string }) => void; +// onToolCall?: ({ call }: { call: ChatCompletionMessageToolCall }) => void; +// onToolParam?: ({ tool, params }: { tool: ChatCompletionMessageToolCall; params: string }) => void; +// }; + const logger = getLogger(LogCategories.MODULE.AI.LLM); export type ResponseEvents = { @@ -73,6 +93,7 @@ type LLMResponse = { requestMessages: ChatCompletionMessageParam[]; assistantMessage?: ChatCompletionMessageParam; completeMessages: ChatCompletionMessageParam[]; + firstTokenTime?: number; }; /* @@ -87,7 +108,10 @@ export const createLLMResponse = async ( const { throwError = true, body, custonHeaders, userKey, maxContinuations = 1 } = args; const { messages, useVision, requestOrigin, tools, toolCallMode } = body; - const model = getLLMModel(body.model); + const model = getLLMModelById(body.modelId); + if (!model) { + return Promise.reject(`Model ${body.modelId} not found`); + } // Messages process const requestMessages = await loadRequestMessages({ @@ -122,6 +146,8 @@ export const createLLMResponse = async ( let currentMessages = [...requestBody.messages]; let continuationCount = 0; let isStreamResponse = false; + let firstTokenTime: number | undefined; + const requestStartTime = Date.now(); try { while (continuationCount < maxContinuations) { @@ -156,28 +182,41 @@ export const createLLMResponse = async ( isStreamResponse = currentIsStreamResponse; } - let { answerText, reasoningText, toolCalls, finish_reason, usage, error } = - await (async () => { - if (currentIsStreamResponse) { - return createStreamResponse({ - response, - body, - isAborted: args.isAborted, - onStreaming: args.onStreaming, - onReasoning: args.onReasoning, - onToolCall: args.onToolCall, - onToolParam: args.onToolParam - }); - } else { - return createCompleteResponse({ - response, - body, - onStreaming: args.onStreaming, - onReasoning: args.onReasoning, - onToolCall: args.onToolCall - }); - } - })(); + let { + answerText, + reasoningText, + toolCalls, + finish_reason, + usage, + error, + firstTokenTime: currentFirstTokenTime + } = await (async () => { + if (currentIsStreamResponse) { + return createStreamResponse({ + response, + body, + requestStartTime, + isAborted: args.isAborted, + onStreaming: args.onStreaming, + onReasoning: args.onReasoning, + onToolCall: args.onToolCall, + onToolParam: args.onToolParam + }); + } else { + return createCompleteResponse({ + response, + body, + requestStartTime, + onStreaming: args.onStreaming, + onReasoning: args.onReasoning, + onToolCall: args.onToolCall + }); + } + })(); + + if (continuationCount === 0 && currentFirstTokenTime !== undefined) { + firstTokenTime = currentFirstTokenTime; + } // Format toolCalls // 1. Auto complete arguments, avoid model not support "" arguments @@ -329,6 +368,7 @@ export const createLLMResponse = async ( outputTokens: error ? 0 : outputTokens }, requestId, // 返回请求追踪 ID + firstTokenTime, requestMessages, assistantMessage, @@ -351,6 +391,7 @@ export const createLLMResponse = async ( return { error, requestId, // 返回请求追踪 ID + firstTokenTime, isStreamResponse: false, answerText: '', reasoningText: '', @@ -370,7 +411,7 @@ type CompleteParams = Pick, ' type CompleteResponse = Pick< LLMResponse, - 'answerText' | 'reasoningText' | 'toolCalls' | 'finish_reason' + 'answerText' | 'reasoningText' | 'toolCalls' | 'finish_reason' | 'firstTokenTime' > & { usage?: CompletionUsage; error?: any; @@ -379,6 +420,7 @@ type CompleteResponse = Pick< export const createStreamResponse = async ({ body, response, + requestStartTime, isAborted, onStreaming, onReasoning, @@ -386,12 +428,13 @@ export const createStreamResponse = async ({ onToolParam }: CompleteParams & { response: StreamResponseType; + requestStartTime: number; isAborted?: CreateLLMResponseProps['isAborted']; }): Promise => { - const { retainDatasetCite = true, tools, toolCallMode = 'toolChoice', model } = body; - const modelData = getLLMModel(model); - + const { retainDatasetCite = true, tools, toolCallMode = 'toolChoice', modelId } = body; + const modelData = getLLMModelById(modelId); const { parsePart, getResponseData, updateFinishReason, updateError } = parseLLMStreamResponse(); + let firstTokenTime: number | undefined; if (tools?.length) { if (toolCallMode === 'toolChoice') { @@ -400,6 +443,9 @@ export const createStreamResponse = async ({ try { for await (const part of response) { + if (firstTokenTime === undefined) { + firstTokenTime = +((Date.now() - requestStartTime) / 1000).toFixed(2); + } if (isAborted?.()) { response.controller?.abort(); updateFinishReason('close'); @@ -477,6 +523,7 @@ export const createStreamResponse = async ({ reasoningText: reasoningContent, finish_reason, usage, + firstTokenTime, toolCalls: toolCalls.filter((call) => !!call) }; } else { @@ -485,6 +532,10 @@ export const createStreamResponse = async ({ try { for await (const part of response) { + if (firstTokenTime === undefined) { + firstTokenTime = +((Date.now() - requestStartTime) / 1000).toFixed(2); + } + if (isAborted?.()) { response.controller?.abort(); updateFinishReason('close'); @@ -553,6 +604,7 @@ export const createStreamResponse = async ({ reasoningText: reasoningContent, finish_reason, usage, + firstTokenTime, toolCalls }; } @@ -560,6 +612,10 @@ export const createStreamResponse = async ({ // Not use tool try { for await (const part of response) { + if (firstTokenTime === undefined) { + firstTokenTime = +((Date.now() - requestStartTime) / 1000).toFixed(2); + } + if (isAborted?.()) { response.controller?.abort(); updateFinishReason('close'); @@ -590,7 +646,8 @@ export const createStreamResponse = async ({ answerText: content, reasoningText: reasoningContent, finish_reason, - usage + usage, + firstTokenTime }; } }; @@ -598,12 +655,17 @@ export const createStreamResponse = async ({ export const createCompleteResponse = async ({ body, response, + requestStartTime, onStreaming, onReasoning, onToolCall -}: CompleteParams & { response: UnStreamResponseType }): Promise => { - const { tools, toolCallMode = 'toolChoice', retainDatasetCite = true } = body; - const modelData = getLLMModel(body.model); +}: CompleteParams & { + response: UnStreamResponseType; + requestStartTime: number; +}): Promise => { + const { tools, toolCallMode = 'toolChoice', retainDatasetCite = true, modelId } = body; + const modelData = getLLMModelById(modelId); + const firstTokenTime = +((Date.now() - requestStartTime) / 1000).toFixed(2); const finish_reason = response.choices?.[0]?.finish_reason as CompletionFinishReason; const usage = response.usage; @@ -612,7 +674,9 @@ export const createCompleteResponse = async ({ const { content, reasoningContent } = (() => { const content = response.choices?.[0]?.message?.content || ''; const reasoningContent: string = - (response.choices?.[0]?.message as any)?.reasoning_content || ''; + (response.choices?.[0]?.message as any)?.reasoning_content || + (response.choices?.[0]?.message as any)?.reasoning || + ''; // API already parse reasoning content if (reasoningContent || !modelData.reasoning) { @@ -673,7 +737,8 @@ export const createCompleteResponse = async ({ answerText: formatContent, toolCalls, finish_reason, - usage + usage, + firstTokenTime }; }; @@ -684,7 +749,7 @@ type InferCompletionsBody = T extends { stream: true } : ChatCompletionCreateParams; type LLMRequestBodyType = Omit & { - model: string | LLMModelItemType; + modelId: string; stop?: string; response_format?: { type?: string; @@ -692,11 +757,13 @@ type LLMRequestBodyType = Omit({ retainDatasetCite, @@ -707,17 +774,16 @@ const llmCompletionsBodyFormat = async ({ tool_choice, parallel_tool_calls, toolCallMode, + modelId, + enableThinking, ...body }: LLMRequestBodyType): Promise<{ requestBody: InferCompletionsBody; modelData: LLMModelItemType; }> => { - const modelData = getLLMModel(body.model); + const modelData = getLLMModelById(modelId); if (!modelData) { - return { - requestBody: body as unknown as InferCompletionsBody, - modelData - }; + return Promise.reject(`Model ${modelId} not found`); } const response_format = (() => { @@ -743,7 +809,7 @@ const llmCompletionsBodyFormat = async ({ const maxTokens = computedMaxToken({ model: modelData, - maxToken: body.max_tokens || undefined + maxToken: resolveMaxTokenFallback(body.max_tokens, enableThinking) }); const formatStop = stop?.split('|').filter((item) => !!item.trim()); @@ -766,12 +832,12 @@ const llmCompletionsBodyFormat = async ({ tool_choice, parallel_tool_calls }) - } as T; + } as unknown as InferCompletionsBody; // Filter undefined/null value requestBody = Object.fromEntries( Object.entries(requestBody).filter(([_, value]) => value !== null && value !== undefined) - ) as T; + ) as unknown as InferCompletionsBody; const supportParams = getLLMSupportParams(modelData); @@ -831,9 +897,6 @@ const createChatCompletion = async ({ } > => { try { - if (!modelData) { - return Promise.reject(`${body.model} not found`); - } body.model = modelData.model; const formatTimeout = timeout ? timeout : 600000; diff --git a/packages/service/core/ai/llm/utils.ts b/packages/service/core/ai/llm/utils.ts index fd76238076df..7a949090463d 100644 --- a/packages/service/core/ai/llm/utils.ts +++ b/packages/service/core/ai/llm/utils.ts @@ -9,7 +9,7 @@ import type { import { axios } from '../../../common/api/axios'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { getImageBase64 } from '../../../common/file/image/utils'; import { getS3ChatSource } from '../../../common/s3/sources/chat'; import { isInternalAddress } from '../../../common/system/utils'; diff --git a/packages/service/core/ai/model.ts b/packages/service/core/ai/model.ts index c1552485312d..e35e91c44e00 100644 --- a/packages/service/core/ai/model.ts +++ b/packages/service/core/ai/model.ts @@ -1,73 +1,68 @@ -import { cloneDeep } from 'lodash'; -import { type SystemModelItemType } from './type'; -import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import type { + EmbeddingModelItemType, + LLMModelItemType, + RerankModelItemType, + STTModelType, + TTSModelType +} from '@fastgpt/global/core/ai/model.schema'; +import type { SystemModelItemType } from './type'; export const getDefaultLLMModel = () => global?.systemDefaultModel.llm!; -export const getLLMModel = (model?: string | LLMModelItemType) => { - if (!model) return getDefaultLLMModel(); +export const getModelById = (id?: string): SystemModelItemType | undefined => { + if (!id) return undefined; + return global.systemModelIdMap?.get(id); +}; - return typeof model === 'string' ? global.llmModelMap.get(model) || getDefaultLLMModel() : model; +export const getLLMModelById = (id?: string): LLMModelItemType => { + if (!id) return getDefaultLLMModel(); + return global.llmModelIdMap?.get(id)!; }; -export const getDatasetModel = (model?: string) => { - return ( - Array.from(global.llmModelMap.values())?.find( - (item) => item.model === model || item.name === model - ) ?? getDefaultLLMModel() - ); +export const getDefaultDatasetModel = () => global?.systemDefaultModel.llm!; + +export const getDatasetModelById = (id?: string): LLMModelItemType => { + if (!id) return getDefaultLLMModel(); + return global.llmModelIdMap?.get(id)!; }; export const getVlmModelList = () => { - return Array.from(global.llmModelMap.values())?.filter((item) => item.vision) || []; + return Array.from(global.llmModelIdMap.values())?.filter((item) => item.vision) || []; }; -export const getDefaultVLMModel = () => global?.systemDefaultModel.datasetImageLLM; -export const getVlmModel = (model?: string) => { - const list = getVlmModelList(); - return list.find((item) => item.model === model || item.name === model) || list[0]; +export const getDefaultVLMModel = () => global?.systemDefaultModel.datasetImageLLM!; +export const getVlmModelById = (id?: string): LLMModelItemType | undefined => { + if (!id) return undefined; + return global.llmModelIdMap?.get(id)!; }; export const getDefaultHelperBotModel = (): LLMModelItemType => global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel(); export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!; -export const getEmbeddingModel = (model?: string) => { - if (!model) return getDefaultEmbeddingModel(); - return global.embeddingModelMap.get(model) || getDefaultEmbeddingModel(); +export const getEmbeddingModelById = (id?: string): EmbeddingModelItemType => { + if (!id) return getDefaultEmbeddingModel(); + return global.embeddingModelIdMap?.get(id)!; }; export const getDefaultTTSModel = () => global?.systemDefaultModel.tts!; -export function getTTSModel(model?: string) { - if (!model) return getDefaultTTSModel(); - return global.ttsModelMap.get(model) || getDefaultTTSModel(); +export function getTTSModelById(id?: string): TTSModelType { + if (!id) return getDefaultTTSModel(); + return global.ttsModelIdMap?.get(id)!; } export const getDefaultSTTModel = () => global?.systemDefaultModel.stt!; -export function getSTTModel(model?: string) { - if (!model) return getDefaultSTTModel(); - return global.sttModelMap.get(model) || getDefaultSTTModel(); +export function getSTTModelById(id?: string): STTModelType { + if (!id) return getDefaultSTTModel(); + return global.sttModelIdMap?.get(id)!; } export const getDefaultRerankModel = () => global?.systemDefaultModel.rerank!; -export function getRerankModel(model?: string) { - if (!model) return getDefaultRerankModel(); - return global.reRankModelMap.get(model) || getDefaultRerankModel(); +export function getRerankModelById(id?: string): RerankModelItemType { + if (!id) return getDefaultRerankModel(); + return global.reRankModelIdMap?.get(id)!; } -export const findAIModel = ( - model: string | SystemModelItemType -): SystemModelItemType | undefined => { - if (typeof model === 'object') { - return model; - } - - return ( - global.llmModelMap.get(model) || - global.embeddingModelMap.get(model) || - global.ttsModelMap.get(model) || - global.sttModelMap.get(model) || - global.reRankModelMap.get(model) - ); -}; -export const findModelFromAlldata = (model: string) => { - return cloneDeep(global.systemModelList.find((item) => item.model === model)); -}; +export const getDefaultEvaluationModel = () => global?.systemDefaultModel.evaluation; +export function getEvaluationModelById(id?: string) { + if (!id) return getDefaultEvaluationModel(); + return global.llmModelIdMap?.get(id)!; +} diff --git a/packages/service/core/ai/rerank/index.ts b/packages/service/core/ai/rerank/index.ts index d890cc13d741..5504b2eca4e3 100644 --- a/packages/service/core/ai/rerank/index.ts +++ b/packages/service/core/ai/rerank/index.ts @@ -7,6 +7,33 @@ import { getLogger, LogCategories } from '../../../common/logger'; import { text2Chunks } from '../../../worker/function'; const logger = getLogger(LogCategories.MODULE.AI.RERANK); +const DEFAULT_RERANK_MAX_TOKEN = 8000; + +export function resolveRerankMaxToken(maxToken: RerankModelItemType['maxToken']): number { + const numericMaxToken = Number(maxToken); + return Number.isFinite(numericMaxToken) && numericMaxToken > 0 + ? numericMaxToken + : DEFAULT_RERANK_MAX_TOKEN; +} + +export function formatRerankQuery( + query: string, + documents: string[], + instruction?: string +): { query: string; documents: string[] } { + if (!instruction) return { query, documents }; + const prefix = + '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".\n<|im_end|>\n<|im_start|>user\n'; + const suffix = '<|im_end|>\n<|im_start|>assistant\n\n\n\n\n'; + const formattedQuery = `${prefix}: ${instruction}\n: ${query}`; + const formattedDocuments = documents.map((doc) => `: ${doc}${suffix}`); + logger.debug('formatRerankQuery result', { + instruction, + query: formattedQuery, + documents: formattedDocuments + }); + return { query: formattedQuery, documents: formattedDocuments }; +} type PostReRankResponse = { id: string; @@ -49,8 +76,9 @@ export async function reRankRecall({ // Token budget: calculate how many tokens each document can use // Document max token = ModelMaxToken - QueryTokens - const queryTokens = await countPromptTokens(query); - const rerankMaxToken = model.maxToken || 8000; + const { query: formattedQuery } = formatRerankQuery(query, [], model.instruction); + const queryTokens = await countPromptTokens(formattedQuery); + const rerankMaxToken = resolveRerankMaxToken(model.maxToken); const docBudget = rerankMaxToken - queryTokens; if (docBudget <= 500) { return Promise.reject(new Error('Rerank query too long')); @@ -93,12 +121,18 @@ export async function reRankRecall({ const { baseUrl, authorization } = getAxiosConfig(); const start = Date.now(); + const { documents: formattedDocuments } = formatRerankQuery( + query, + documentsTextArray, + model.instruction + ); + const apiResult = await POST( model.requestUrl ? model.requestUrl : `${baseUrl}/rerank`, { model: model.model, - query, - documents: documentsTextArray + query: formattedQuery, + documents: formattedDocuments }, { headers: { diff --git a/packages/service/core/ai/sandbox/config.ts b/packages/service/core/ai/sandbox/config.ts index b1ff308ad83e..6d666f19f24b 100644 --- a/packages/service/core/ai/sandbox/config.ts +++ b/packages/service/core/ai/sandbox/config.ts @@ -34,10 +34,10 @@ export const getOpenSandboxConnectionConfig = ({ } return { sessionId, - useServerProxy: env.AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY, baseUrl: env.AGENT_SANDBOX_OPENSANDBOX_BASEURL, apiKey: env.AGENT_SANDBOX_OPENSANDBOX_API_KEY, - runtime: env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME + runtime: env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME, + requestTimeoutSeconds: env.AGENT_SANDBOX_OPENSANDBOX_REQUEST_TIMEOUT }; }; diff --git a/packages/service/core/ai/sandbox/controller.ts b/packages/service/core/ai/sandbox/controller.ts index f317c8ffbbd1..2f0b6194a1f5 100644 --- a/packages/service/core/ai/sandbox/controller.ts +++ b/packages/service/core/ai/sandbox/controller.ts @@ -4,7 +4,7 @@ import { SANDBOX_SUSPEND_MINUTES } from '@fastgpt/global/core/ai/sandbox/constants'; import { env } from '../../../env'; -import { MongoSandboxInstance } from './schema'; +import { MongoSandboxInstance, MongoSemaphore } from './schema'; import { createSandbox, type ExecuteResult, @@ -25,6 +25,7 @@ import { setCron } from '../../../common/system/cron'; import { subMinutes } from 'date-fns'; import { batchRun } from '@fastgpt/global/common/system/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; +import { tryBecomeLeader } from './cronLeader'; const logger = getLogger(LogCategories.MODULE.AI.SANDBOX); type UnionIdType = { @@ -33,15 +34,65 @@ type UnionIdType = { chatId: string; }; +// --- Semaphore helpers (atomic sandbox count limit) --- + +function getMaxCount(): number | undefined { + return (global as any).feConfigs?.limit?.agentSandboxMaxCount ?? env.AGENT_SANDBOX_MAX_COUNT; +} + +const SEMAPHORE_ID = 'sandbox_count'; + +// Idempotent init: only runs countDocuments on first creation. +// cronJob Step 0 handles periodic drift correction. +async function ensureSemaphoreInit(): Promise { + const exists = await MongoSemaphore.findById(SEMAPHORE_ID).lean(); + if (exists) return; + + const actual = await MongoSandboxInstance.countDocuments({ + status: SandboxStatusEnum.running + }); + await MongoSemaphore.findOneAndUpdate( + { _id: SEMAPHORE_ID }, + { $setOnInsert: { count: actual, updatedAt: new Date() } }, + { upsert: true } + ); +} + +// Atomically acquire a slot. Returns true on success, false when at capacity. +async function tryAcquireSemaphore(): Promise { + const max = getMaxCount(); + if (max === undefined) return true; + + await ensureSemaphoreInit(); + + const r = await MongoSemaphore.findOneAndUpdate( + { _id: SEMAPHORE_ID, count: { $lt: max } }, + { $inc: { count: 1 }, $set: { updatedAt: new Date() } }, + { new: true } + ); + return !!r; +} + +async function releaseSemaphore(): Promise { + if (getMaxCount() === undefined) return; + await MongoSemaphore.updateOne( + { _id: SEMAPHORE_ID, count: { $gte: 1 } }, + { $inc: { count: -1 }, $set: { updatedAt: new Date() } } + ); +} + +// --- SandboxClient --- + export class SandboxClient { private appId?: string; private userId?: string; private chatId?: string; private sandboxId: string; + private semaphoreAcquired = false; readonly provider: ISandbox; constructor( - private readonly props: { + props: { sandboxId: string; appId?: string; userId?: string; @@ -93,32 +144,137 @@ export class SandboxClient { } async ensureAvailable() { - await MongoSandboxInstance.findOneAndUpdate( - { provider: this.provider.provider, sandboxId: this.sandboxId }, - { - $set: { - status: SandboxStatusEnum.running, - lastActiveAt: new Date() - }, - $setOnInsert: { - ...(this.appId ? { appId: this.appId } : {}), - ...(this.userId ? { userId: this.userId } : {}), - ...(this.chatId ? { chatId: this.chatId } : {}), - storage: this.opts?.vmConfig?.storage, - ...(this.opts?.resourceLimits && { - limit: { - cpuCount: this.opts?.resourceLimits?.cpuCount, - memoryMiB: this.opts?.resourceLimits?.memoryMiB, - diskGiB: this.opts?.resourceLimits?.diskGiB + const existing = await MongoSandboxInstance.findOne({ + provider: this.provider.provider, + sandboxId: this.sandboxId + }).lean(); + + // Path 1: no document → cold start, must acquire semaphore + if (!existing) { + const acquired = await tryAcquireSemaphore(); + if (!acquired) { + const sem = await MongoSemaphore.findById(SEMAPHORE_ID).lean(); + const current = sem?.count ?? 0; + const max = getMaxCount(); + throw new Error( + `Active agent sandbox limit reached (${current}/${max}). Please try again later.` + ); + } + this.semaphoreAcquired = true; + + try { + await MongoSandboxInstance.findOneAndUpdate( + { provider: this.provider.provider, sandboxId: this.sandboxId }, + { + $set: { + status: SandboxStatusEnum.running, + lastActiveAt: new Date() + }, + $setOnInsert: { + ...(this.appId ? { appId: this.appId } : {}), + ...(this.userId ? { userId: this.userId } : {}), + ...(this.chatId ? { chatId: this.chatId } : {}), + storage: this.opts?.vmConfig?.storage, + ...(this.opts?.resourceLimits && { + limit: { + cpuCount: this.opts?.resourceLimits?.cpuCount, + memoryMiB: this.opts?.resourceLimits?.memoryMiB, + diskGiB: this.opts?.resourceLimits?.diskGiB + } + }), + metadata: { + volumeEnabled: !!this.opts?.vmConfig, + ...(this.opts?.createConfig?.metadata || {}) + }, + createdAt: new Date() } - }), - metadata: { - volumeEnabled: !!this.opts?.vmConfig }, - createdAt: new Date() + { upsert: true } + ); + await this.provider.ensureRunning(); + } catch (error) { + // Rollback: revert DB status to stopped so the orphan record doesn't + // trick a future Path 3 (warm start) into creating a container without + // acquiring a semaphore slot. + await MongoSandboxInstance.updateOne( + { sandboxId: this.sandboxId, status: SandboxStatusEnum.running }, + { $set: { status: SandboxStatusEnum.stopped, lastActiveAt: new Date() } } + ).catch((e) => + logger.error('db status rollback on ensureRunning failure failed', { + sandboxId: this.sandboxId, + error: e + }) + ); + if (this.semaphoreAcquired) { + await releaseSemaphore().catch((e) => + logger.error('semaphore release failed', { error: e }) + ); + this.semaphoreAcquired = false; } - }, - { upsert: true } + throw error; + } + return; + } + + // Path 2: document exists but stopped → recovery needs a slot. + // Use CAS to atomically claim the stopped→running transition so only + // one request acquires the semaphore per sandbox. + if (existing.status === SandboxStatusEnum.stopped) { + const claimed = await MongoSandboxInstance.findOneAndUpdate( + { sandboxId: this.sandboxId, status: SandboxStatusEnum.stopped }, + { $set: { status: SandboxStatusEnum.running, lastActiveAt: new Date() } } + ); + + if (!claimed) { + // Lost the race — CAS winner already set status=running and will call ensureRunning(). + // Only update lastActiveAt to reflect this access. + await MongoSandboxInstance.updateOne( + { sandboxId: this.sandboxId }, + { $set: { lastActiveAt: new Date() } } + ); + return; + } + + // Won the CAS — now acquire the semaphore slot. + const acquired = await tryAcquireSemaphore(); + if (!acquired) { + // Rollback: revert to stopped so a future request can retry. + await MongoSandboxInstance.updateOne( + { sandboxId: this.sandboxId }, + { $set: { status: SandboxStatusEnum.stopped, lastActiveAt: new Date() } } + ); + const sem = await MongoSemaphore.findById(SEMAPHORE_ID).lean(); + const current = sem?.count ?? 0; + const max = getMaxCount(); + throw new Error( + `Active agent sandbox limit reached (${current}/${max}). Please try again later.` + ); + } + this.semaphoreAcquired = true; + + try { + await this.provider.ensureRunning(); + } catch (error) { + // Rollback: revert status to stopped so cronJob or next request can retry. + await MongoSandboxInstance.updateOne( + { sandboxId: this.sandboxId }, + { $set: { status: SandboxStatusEnum.stopped, lastActiveAt: new Date() } } + ).catch((e) => + logger.error('status rollback on ensureRunning failure failed', { error: e }) + ); + await releaseSemaphore().catch((e) => + logger.error('semaphore release failed', { error: e }) + ); + this.semaphoreAcquired = false; + throw error; + } + return; + } + + // Path 3: document exists and running → true warm start, no semaphore change + await MongoSandboxInstance.updateOne( + { provider: this.provider.provider, sandboxId: this.sandboxId }, + { $set: { status: SandboxStatusEnum.running, lastActiveAt: new Date() } } ); await this.provider.ensureRunning(); } @@ -150,19 +306,60 @@ export class SandboxClient { } async delete() { - await this.provider.delete(); - await deleteSessionVolume(this.sandboxId).catch((err) => { - logger.error('Failed to delete sandbox volume', { sandboxId: this.sandboxId, error: err }); - }); - await MongoSandboxInstance.deleteOne({ sandboxId: this.sandboxId }); + // Delete provider container. If this fails we still clean up DB + semaphore + // in the finally block to prevent resource leaks. + try { + await this.provider.delete(); + } finally { + await deleteSessionVolume(this.sandboxId).catch((err) => { + logger.error('Failed to delete sandbox volume', { sandboxId: this.sandboxId, error: err }); + }); + const doc = await MongoSandboxInstance.findOneAndDelete({ sandboxId: this.sandboxId }); + if (doc && doc.status === SandboxStatusEnum.running) { + await releaseSemaphore().catch((e) => + logger.error('semaphore release on delete failed', { error: e }) + ); + this.semaphoreAcquired = false; + } + } } async stop() { - await this.provider.stop(); - await MongoSandboxInstance.updateOne( - { sandboxId: this.sandboxId }, - { $set: { status: SandboxStatusEnum.stopped } } + // ensureRunning is needed unless the provider is already connected. + // BaseSandboxAdapter initializes _status to { state: "Creating" }, not "UnExist", + // so we must cover all non-Running states (Creating, Starting, Error, UnExist, etc.). + if (this.provider.status.state !== 'Running') { + try { + await this.provider.ensureRunning(); + } catch (e) { + logger.warn('Cannot connect sandbox, cleaning up DB record', { + sandboxId: this.sandboxId, + error: e + }); + } + } + + // Only attempt provider.stop() when the provider is now initialized. + if (this.provider.status.state !== 'UnExist') { + const stopError = await this.provider.stop().catch((e) => { + logger.error('provider.stop failed in stop()', { sandboxId: this.sandboxId, error: e }); + return e; + }); + if (stopError) return; + } + + // Atomically transition running → stopped. Only the winner releases the semaphore. + const doc = await MongoSandboxInstance.findOneAndUpdate( + { sandboxId: this.sandboxId, status: SandboxStatusEnum.running }, + { $set: { status: SandboxStatusEnum.stopped, lastActiveAt: new Date() } } ); + + if (doc) { + await releaseSemaphore().catch((e) => + logger.error('semaphore release on stop failed', { error: e }) + ); + this.semaphoreAcquired = false; + } } } @@ -191,7 +388,37 @@ export const getSandboxClient = async ( return sandbox; }; +/** Like getSandboxClient but checks for an existing sandbox by chatId first. */ +export const getSandboxClientByChat = async ( + props: UnionIdType, + opts: { + resourceLimits?: ResourceLimits; + createConfig?: OpenSandboxConfigType; + } = {} +) => { + // Prefer an existing sandbox already associated with this chatId + const existingSandbox = await MongoSandboxInstance.findOne({ + chatId: props.chatId + }).lean(); + + if (existingSandbox) { + return getSandboxClient({ sandboxId: existingSandbox.sandboxId }, opts); + } + + // Fallback: create/find by appId/userId/chatId hash + return getSandboxClient(props, opts); +}; + // ==== Delete Sandboxes ==== + +/** Direct sandbox deletion, bypassing ensureAvailable (no semaphore acquire). */ +const deleteSandboxDirectly = async (sandboxId: string) => { + const client = new SandboxClient({ sandboxId }, {}); + await client.delete().catch((err) => { + logger.error('Failed to delete sandbox', { sandboxId, error: err }); + }); +}; + export const deleteSandboxesByChatIds = async ({ appId, chatIds @@ -202,33 +429,57 @@ export const deleteSandboxesByChatIds = async ({ const instances = await MongoSandboxInstance.find({ appId, chatId: { $in: chatIds } }).lean(); if (!instances.length) return; - await Promise.allSettled( - instances.map(async (doc) => { - const client = await getSandboxClient({ sandboxId: doc.sandboxId }); - await client.delete().catch((err) => { - logger.error('Failed to delete sandbox', { sandboxId: doc.sandboxId, error: err }); - return Promise.reject(err); - }); - }) + const results = await Promise.allSettled( + instances.map(async (doc) => deleteSandboxDirectly(doc.sandboxId)) ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + logger.warn('Some sandboxes failed to delete', { total: instances.length, failed }); + } }; export const deleteSandboxesByAppId = async (appId: string) => { const instances = await MongoSandboxInstance.find({ appId }).lean(); if (!instances.length) return; - await Promise.allSettled( - instances.map(async (doc) => { - const client = await getSandboxClient({ sandboxId: doc.sandboxId }); - await client.delete().catch((err) => { - logger.error('Failed to delete sandbox', { sandboxId: doc.sandboxId, error: err }); - }); - }) + const results = await Promise.allSettled( + instances.map(async (doc) => deleteSandboxDirectly(doc.sandboxId)) ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + logger.warn('Some sandboxes failed to delete', { total: instances.length, failed }); + } }; // 5 分钟检查一遍,暂停 +// Uses leader election to prevent N replicas from all running the same cleanup. +const CRON_LEADER_KEY = 'sandbox_idle_cleanup'; + export const cronJob = async () => { setCron('*/5 * * * *', async () => { + if (!(await tryBecomeLeader(CRON_LEADER_KEY))) return; + + // Step 0: Correct the semaphore to match actual running count. + // Uses $inc delta (not $set) to compose safely with concurrent tryAcquireSemaphore. + // Caps correction at maxCount so over-limit states are never "legitimized" + // — idle sandboxes must be stopped to reclaim slots, not the ceiling raised. + const runningCount = await MongoSandboxInstance.countDocuments({ + status: SandboxStatusEnum.running + }); + const max = getMaxCount(); + const sem = await MongoSemaphore.findById(SEMAPHORE_ID).lean(); + if (!sem) { + await ensureSemaphoreInit(); + } else { + const target = max !== undefined ? Math.min(runningCount, max) : runningCount; + const delta = target - sem.count; + if (delta !== 0) { + await MongoSemaphore.updateOne( + { _id: SEMAPHORE_ID }, + { $inc: { count: delta }, $set: { updatedAt: new Date() } } + ); + } + } + const instances = await MongoSandboxInstance.find({ status: SandboxStatusEnum.running, lastActiveAt: { $lt: subMinutes(new Date(), SANDBOX_SUSPEND_MINUTES) } @@ -238,7 +489,10 @@ export const cronJob = async () => { logger.info('Found running sandboxes inactive > 5 min', { count: instances.length }); await batchRun(instances, async (doc) => { - const client = await getSandboxClient({ sandboxId: doc.sandboxId }); + // Bypass ensureAvailable: cron stop only needs provider.stop + DB CAS. + // Creating a SandboxClient without getSandboxClient avoids an unnecessary + // semaphore acquire (Path 1) when the sandbox was already deleted. + const client = new SandboxClient({ sandboxId: doc.sandboxId }, {}); await client.stop().catch((err) => { logger.error('Failed to stop sandbox', { sandboxId: doc.sandboxId, error: err }); }); diff --git a/packages/service/core/ai/sandbox/cronLeader.ts b/packages/service/core/ai/sandbox/cronLeader.ts new file mode 100644 index 000000000000..fd470a979d39 --- /dev/null +++ b/packages/service/core/ai/sandbox/cronLeader.ts @@ -0,0 +1,88 @@ +/** + * MongoDB-based leader election for cron jobs. + * + * In a multi-replica deployment every process runs the same node-cron + * schedules. Without coordination, N replicas all execute the same + * cleanup work simultaneously. This module provides a lightweight + * leader lease so that only one replica runs a given cron job at a time. + * + * Usage: + * if (!(await tryBecomeLeader())) return; // skip — not the leader + */ +import os from 'node:os'; +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import type { Model } from 'mongoose'; + +const { Schema } = connectionMongo; + +export const cronLeaderCollectionName = 'sandbox_cron_leader'; + +export type CronLeaderDoc = { + leaderKey: string; + instanceId: string; + lastHeartbeat: Date; + expiresAt: Date; +}; + +const CronLeaderSchema = new Schema({ + leaderKey: { type: String, required: true, unique: true }, + instanceId: { type: String, required: true }, + lastHeartbeat: { type: Date, required: true }, + expiresAt: { type: Date, required: true } +}); + +// TTL index: MongoDB auto-deletes expired leader leases +CronLeaderSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const MongoCronLeader: Model = getMongoModel( + cronLeaderCollectionName, + CronLeaderSchema +); + +// ---- Public API ------------------------------------------------------------ + +const LEADER_TTL_MS = 120_000; // 2 minutes — exceeds the 5-min cron interval + +function instanceId(): string { + return `${os.hostname()}:${process.pid}`; +} + +/** + * Try to become (or stay) the leader for `leaderKey`. + * + * Returns `true` when the calling instance is the current leader. + * The lease auto-expires after TTL, so a dead process cannot block + * the leader slot forever. + */ +export async function tryBecomeLeader( + leaderKey: string, + ttlMs: number = LEADER_TTL_MS +): Promise { + const id = instanceId(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlMs); + + try { + await MongoCronLeader.findOneAndUpdate( + { + leaderKey, + $or: [ + { instanceId: id }, // We already hold it — renew + { expiresAt: { $lt: now } } // Previous leader's lease expired + ] + }, + { + $set: { instanceId: id, lastHeartbeat: now, expiresAt }, + $setOnInsert: { leaderKey } + }, + { upsert: true } + ); + return true; // We are the leader (inserted or renewed) + } catch (e: any) { + if (e.code === 11000) { + // Another instance holds the lock and the lease hasn't expired + return false; + } + throw e; + } +} diff --git a/packages/service/core/ai/sandbox/schema.ts b/packages/service/core/ai/sandbox/schema.ts index 4614f3b62a54..b16f62cb0a56 100644 --- a/packages/service/core/ai/sandbox/schema.ts +++ b/packages/service/core/ai/sandbox/schema.ts @@ -2,7 +2,6 @@ import { connectionMongo, getMongoModel } from '../../../common/mongo'; const { Schema } = connectionMongo; import type { SandboxInstanceSchemaType } from './type'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; -import { SandboxProtocolEnum, SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import { SandboxLimitSchema, SandboxProviderSchema } from './type'; export const collectionName = 'agent_sandbox_instances'; @@ -69,15 +68,29 @@ SandboxInstanceSchema.index( unique: true, partialFilterExpression: { appId: { $exists: true }, - chatId: { $exists: true }, - 'metadata.sandboxType': { $exists: true } + chatId: { $exists: true } } } ); SandboxInstanceSchema.index({ 'metadata.skillId': 1 }); -SandboxInstanceSchema.index({ 'metadata.sandboxType': 1, chatId: 1 }); +SandboxInstanceSchema.index({ chatId: 1 }); export const MongoSandboxInstance = getMongoModel( collectionName, SandboxInstanceSchema ); + +// Semaphore for atomic sandbox count limiting. +// Single document (_id: 'sandbox_count') shared across all replicas. +const SemaphoreCollectionName = 'sandbox_semaphore'; +const SemaphoreSchema = new Schema({ + _id: { type: String, default: 'sandbox_count' }, + count: { type: Number, required: true, default: 0 }, + updatedAt: { type: Date, default: () => new Date() } +}); + +export const MongoSemaphore = getMongoModel<{ + _id: string; + count: number; + updatedAt: Date; +}>(SemaphoreCollectionName, SemaphoreSchema); diff --git a/packages/service/core/ai/sandbox/toolCall.ts b/packages/service/core/ai/sandbox/toolCall.ts index ae3b65d6c127..aa2253787cc7 100644 --- a/packages/service/core/ai/sandbox/toolCall.ts +++ b/packages/service/core/ai/sandbox/toolCall.ts @@ -14,6 +14,41 @@ import { addHours } from 'date-fns'; import { Readable } from 'stream'; import { getLogger } from '@fastgpt-sdk/otel'; import { LogCategories } from '../../../common/logger'; +import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; + +export type SandboxFileUrlResult = { fileUrl: string; filename: string }; + +export async function uploadSandboxFileToS3({ + sandboxProvider, + appId, + userId, + chatId, + filePath +}: { + sandboxProvider: Pick; + appId: string; + userId: string; + chatId: string; + filePath: string; +}): Promise { + const filename = path.basename(filePath); + const stream = sandboxProvider.readFileStream(filePath); + const readable = Readable.from(stream); + + const chatBucket = getS3ChatSource(); + const expiredTime = addHours(new Date(), 24); + const { key } = await chatBucket.uploadChatFile({ + appId, + chatId, + uId: userId, + filename, + body: readable, + expiredTime + }); + const fileUrl = jwtSignS3ObjectKey(key, expiredTime); + + return { fileUrl, filename }; +} type SandboxToolCallParams = { toolName: string; @@ -85,28 +120,15 @@ export const callSandboxTool = async ({ const instance = await getSandboxClient({ appId, userId, chatId }); const result = await Promise.all( - paths.map(async (url) => { - const filename = path.basename(url); - const stream = instance.provider.readFileStream(url); - const readable = Readable.from(stream); // AsyncIterable → Readable - - const chatBucket = getS3ChatSource(); - const expiredTime = addHours(new Date(), 2); - const { key } = await chatBucket.uploadChatFile({ + paths.map((filePath) => + uploadSandboxFileToS3({ + sandboxProvider: instance.provider, appId, + userId, chatId, - uId: userId, - filename, - body: readable, - expiredTime: expiredTime - }); - const fileUrl = jwtSignS3ObjectKey(key, expiredTime); - - return { - fileUrl, - filename - }; - }) + filePath + }) + ) ); return { diff --git a/packages/service/core/ai/sandbox/type.ts b/packages/service/core/ai/sandbox/type.ts index 5fc3f7de4027..3769f265bf29 100644 --- a/packages/service/core/ai/sandbox/type.ts +++ b/packages/service/core/ai/sandbox/type.ts @@ -1,6 +1,6 @@ import z from 'zod'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; -import { SandboxProtocolEnum, SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; +import { SandboxProtocolEnum } from '@fastgpt/global/core/agentSkills/constants'; // ---- 沙盒实例 DB 类型 ---- export const SandboxProviderSchema = z.enum(['sealosdevbox', 'opensandbox', 'e2b']); @@ -40,10 +40,6 @@ export const SandboxEndpointSchema = z.object({ }); export const SandboxMetadataSchema = z.object({ - sandboxType: z.enum(SandboxTypeEnum), - teamId: z.string().optional(), - tmbId: z.string().optional(), - volumeEnabled: z.boolean().optional(), skillId: z.string().optional(), diff --git a/packages/service/core/ai/type.ts b/packages/service/core/ai/type.ts index 062bd1804fa3..0bb7fb778f82 100644 --- a/packages/service/core/ai/type.ts +++ b/packages/service/core/ai/type.ts @@ -9,12 +9,6 @@ import type { import type { AIProxyChannelsType, I18nStringStrictType } from '@fastgpt/global/sdk/fastgpt-plugin'; import type { langType, ModelProviderItemType } from '@fastgpt/global/core/ai/provider'; -export type SystemModelSchemaType = { - _id: string; - model: string; - metadata: SystemModelItemType; -}; - export type SystemModelItemType = | LLMModelItemType | EmbeddingModelItemType @@ -26,6 +20,7 @@ export type SystemDefaultModelType = { [ModelTypeEnum.llm]?: LLMModelItemType; datasetTextLLM?: LLMModelItemType; datasetImageLLM?: LLMModelItemType; + evaluation?: LLMModelItemType; helperBotLLM?: LLMModelItemType; [ModelTypeEnum.embedding]?: EmbeddingModelItemType; @@ -41,12 +36,12 @@ declare global { var aiproxyChannelsCache: AIProxyChannelsType; var systemModelList: SystemModelItemType[]; - // var systemModelMap: Map; - var llmModelMap: Map; - var embeddingModelMap: Map; - var ttsModelMap: Map; - var sttModelMap: Map; - var reRankModelMap: Map; + var systemModelIdMap: Map; + var llmModelIdMap: Map; + var embeddingModelIdMap: Map; + var ttsModelIdMap: Map; + var sttModelIdMap: Map; + var reRankModelIdMap: Map; var systemActiveModelList: SystemModelItemType[]; var systemActiveDesensitizedModels: SystemModelItemType[]; diff --git a/packages/service/core/ai/utils.ts b/packages/service/core/ai/utils.ts index d30fa230be5f..d28b90ddf70a 100644 --- a/packages/service/core/ai/utils.ts +++ b/packages/service/core/ai/utils.ts @@ -113,7 +113,10 @@ export const parseLLMStreamResponse = () => { const content = part.choices?.[0]?.delta?.content || ''; // @ts-ignore - const reasoningContent = part.choices?.[0]?.delta?.reasoning_content || ''; + const reasoningContent = + part.choices?.[0]?.delta?.reasoning_content || + (part.choices?.[0]?.delta as any)?.reasoning || + ''; const isStreamEnd = !!buffer_finishReason; // Parse think diff --git a/packages/service/core/app/controller.ts b/packages/service/core/app/controller.ts index 8248cf6ac43b..8bfd5f031313 100644 --- a/packages/service/core/app/controller.ts +++ b/packages/service/core/app/controller.ts @@ -4,12 +4,11 @@ import { FlowNodeInputTypeEnum, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { MongoApp } from './schema'; import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { encryptSecretValue, storeSecretValue } from '../../common/secret/utils'; import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants'; -import { MongoEvaluation } from './evaluation/evalSchema'; -import { removeEvaluationJob } from './evaluation/mq'; import { MongoChatItem } from '../chat/chatItemSchema'; import { MongoChat } from '../chat/chatSchema'; import { MongoOutLink } from '../../support/outLink/schema'; @@ -72,13 +71,15 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] } return; } input.value = val - .map((dataset: { datasetId: string }) => ({ + .map((dataset: any & { datasetType?: DatasetTypeEnum }) => ({ + ...dataset, datasetId: dataset.datasetId })) .filter((item) => !!item.datasetId); } else if (typeof val === 'object' && val !== null) { input.value = [ { + ...(val as any & { datasetType?: DatasetTypeEnum }), datasetId: val.datasetId } ]; @@ -212,15 +213,16 @@ export const deleteAppsImmediate = async ({ teamId: string; appIds: string[]; }) => { + // sxf 删除应用和评估任务无关 // Remove eval job - const evalJobs = await MongoEvaluation.find( - { - teamId, - appId: { $in: appIds } - }, - '_id' - ).lean(); - await Promise.all(evalJobs.map((evalJob) => removeEvaluationJob(evalJob._id))); + // const evalJobs = await MongoEvaluation.find( + // { + // teamId, + // appId: { $in: appIds } + // }, + // '_id' + // ).lean(); + // await Promise.all(evalJobs.map((evalJob) => removeEvaluationJob(evalJob._id))); // Remove app record await MongoAppRecord.deleteMany({ teamId, appId: { $in: appIds } }); diff --git a/packages/service/core/app/evaluation/evalItemSchema.ts b/packages/service/core/app/evaluation/evalItemSchema.ts deleted file mode 100644 index 45e8633da062..000000000000 --- a/packages/service/core/app/evaluation/evalItemSchema.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { connectionMongo, getMongoModel } from '../../../common/mongo'; -import { EvaluationCollectionName } from './evalSchema'; -import { - EvaluationStatusEnum, - EvaluationStatusValues -} from '@fastgpt/global/core/app/evaluation/constants'; -import type { EvalItemSchemaType } from '@fastgpt/global/core/app/evaluation/type'; - -const { Schema } = connectionMongo; - -export const EvalItemCollectionName = 'eval_items'; - -const EvalItemSchema = new Schema({ - evalId: { - type: Schema.Types.ObjectId, - ref: EvaluationCollectionName, - required: true - }, - question: { - type: String, - required: true - }, - expectedResponse: { - type: String, - required: true - }, - history: String, - globalVariables: Object, - response: String, - responseTime: Date, - - status: { - type: Number, - default: EvaluationStatusEnum.queuing, - enum: EvaluationStatusValues - }, - retry: { - type: Number, - default: 3 - }, - finishTime: Date, - - accuracy: Number, - relevance: Number, - semanticAccuracy: Number, - score: Number, // average score - - errorMessage: String -}); - -EvalItemSchema.index({ evalId: 1, status: 1 }); - -export const MongoEvalItem = getMongoModel( - EvalItemCollectionName, - EvalItemSchema -); diff --git a/packages/service/core/app/evaluation/evalSchema.ts b/packages/service/core/app/evaluation/evalSchema.ts deleted file mode 100644 index e5a78a013fcc..000000000000 --- a/packages/service/core/app/evaluation/evalSchema.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - TeamCollectionName, - TeamMemberCollectionName -} from '@fastgpt/global/support/user/team/constant'; -import { connectionMongo, getMongoModel } from '../../../common/mongo'; -import { AppCollectionName } from '../schema'; -import type { EvaluationSchemaType } from '@fastgpt/global/core/app/evaluation/type'; -import { UsageCollectionName } from '../../../support/wallet/usage/constants'; -const { Schema } = connectionMongo; - -export const EvaluationCollectionName = 'eval'; - -const EvaluationSchema = new Schema({ - teamId: { - type: Schema.Types.ObjectId, - ref: TeamCollectionName, - required: true - }, - tmbId: { - type: Schema.Types.ObjectId, - ref: TeamMemberCollectionName, - required: true - }, - appId: { - type: Schema.Types.ObjectId, - ref: AppCollectionName, - required: true - }, - usageId: { - type: Schema.Types.ObjectId, - ref: UsageCollectionName, - required: true - }, - evalModel: { - type: String, - required: true - }, - name: { - type: String, - required: true - }, - createTime: { - type: Date, - required: true, - default: () => new Date() - }, - finishTime: Date, - score: Number, - errorMessage: String -}); - -EvaluationSchema.index({ teamId: 1 }); - -export const MongoEvaluation = getMongoModel( - EvaluationCollectionName, - EvaluationSchema -); diff --git a/packages/service/core/app/logs/chatLogsSchema.ts b/packages/service/core/app/logs/chatLogsSchema.ts index 423c32472299..a554ef141047 100644 --- a/packages/service/core/app/logs/chatLogsSchema.ts +++ b/packages/service/core/app/logs/chatLogsSchema.ts @@ -50,6 +50,14 @@ const ChatLogSchema = new Schema({ type: Number, default: 0 }, + inputTokens: { + type: Number, + default: 0 + }, + outputTokens: { + type: Number, + default: 0 + }, goodFeedbackCount: { type: Number, default: 0 @@ -65,6 +73,12 @@ const ChatLogSchema = new Schema({ isFirstChat: { type: Boolean, default: false + }, + + // Parent app ID (when this chat is triggered by a workflow appModule node) + parentAppId: { + type: Schema.Types.ObjectId, + ref: AppCollectionName } }); diff --git a/packages/service/core/app/provider/controller.ts b/packages/service/core/app/provider/controller.ts index dc9d45274464..11a51b9fe71b 100644 --- a/packages/service/core/app/provider/controller.ts +++ b/packages/service/core/app/provider/controller.ts @@ -4,10 +4,12 @@ import { defaultProvider, formatModelProviders } from '@fastgpt/global/core/ai/provider'; +import type { AIProxyChannelsType } from '@fastgpt/global/sdk/fastgpt-plugin'; // Preload model providers export async function preloadModelProviders(): Promise { - const { modelProviders, aiproxyChannels } = await loadModelProviders(); + const { modelProviders, aiproxyChannels: _aiproxyChannels } = await loadModelProviders(); + const aiproxyChannels: AIProxyChannelsType = _aiproxyChannels ?? []; const { ModelProviderListCache, ModelProviderMapCache } = formatModelProviders(modelProviders); global.ModelProviderRawCache = modelProviders; diff --git a/packages/service/core/app/schema.ts b/packages/service/core/app/schema.ts index 2321738288c6..0035b696c2ee 100644 --- a/packages/service/core/app/schema.ts +++ b/packages/service/core/app/schema.ts @@ -18,7 +18,8 @@ export const chatConfigType = { chatInputGuide: Object, fileSelectConfig: Object, instruction: String, - autoExecute: Object + autoExecute: Object, + entryPoints: Object }; // schema @@ -110,6 +111,11 @@ const AppSchema = new Schema( default: true }, + isPinned: { + type: Boolean, + default: false + }, + // Chat setting favourite: Boolean, quick: Boolean, @@ -132,7 +138,7 @@ const AppSchema = new Schema( } ); -AppSchema.index({ teamId: 1, updateTime: -1 }); +AppSchema.index({ teamId: 1, isPinned: -1, updateTime: -1 }); AppSchema.index({ teamId: 1, type: 1 }); // Schedule diff --git a/packages/service/core/app/skill/export.ts b/packages/service/core/app/skill/export.ts new file mode 100644 index 000000000000..c5200c8b9d9f --- /dev/null +++ b/packages/service/core/app/skill/export.ts @@ -0,0 +1,201 @@ +import archiver from 'archiver'; +import type { NextApiResponse } from 'next'; +import type { AppSchema } from '@fastgpt/global/core/app/type'; +import { generateSkillMarkdown, generateChatScript, generateConfigJson } from './template'; +import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; +import SkillErr, { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; +import { addLog } from '../../../common/system/log'; +import { getAppLatestVersion } from '../version/controller'; + +interface ExportSkillZipParams { + app: AppSchema; + apiKey: string; + keyName: string; + expiredTime: undefined; // 永不过期 + baseUrl: string; + skillName: string; // 用户输入的 Skill 名称 + skillDescription: string; // 用户输入的 Skill 描述 + locale?: string; // 语言代码,默认 zh-CN + res: NextApiResponse; +} + +// 验证 skillName 格式(仅允许小写字母、数字和连字符) +function validateSkillName(skillName: string): void { + if (!skillName || skillName.trim().length === 0) { + throw new Error(SkillErrEnum.exportInvalidSkillName); + } + if (skillName.length > 64) { + throw new Error(SkillErrEnum.exportSkillNameTooLong); + } + if (!/^[a-z0-9-]+$/.test(skillName)) { + throw new Error(SkillErrEnum.exportInvalidSkillName); + } +} + +// 验证 skillDescription +function validateSkillDescription(skillDescription: string): void { + if (!skillDescription || skillDescription.trim().length === 0) { + throw new Error(SkillErrEnum.exportMissingSkillParams); + } + if (skillDescription.length > 1024) { + throw new Error(SkillErrEnum.exportSkillDescriptionTooLong); + } +} + +// 验证 baseUrl +function validateBaseUrl(baseUrl: string): void { + if (!baseUrl || baseUrl.trim().length === 0) { + throw new Error('baseUrl is required'); + } + try { + new URL(baseUrl); + } catch (error) { + throw new Error('Invalid baseUrl format'); + } +} + +// 验证 apiKey +function validateApiKey(apiKey: string): void { + if (!apiKey || apiKey.trim().length === 0) { + throw new Error('apiKey is required'); + } +} + +/** + * 生成并流式输出 Skill ZIP 压缩包 + * 使用 archiver 库直接流式写入响应,无需临时文件 + */ +export async function exportSkillZip(params: ExportSkillZipParams): Promise { + const { + app, + apiKey, + keyName, + expiredTime, + baseUrl, + skillName, + skillDescription, + locale = 'zh-CN', + res + } = params; + + // 输入验证 + validateSkillName(skillName); + validateSkillDescription(skillDescription); + validateBaseUrl(baseUrl); + validateApiKey(apiKey); + + // 使用 skillName 作为文件夹名和 ZIP 文件名 + // skillName 已经是 kebab-case 格式,无需额外处理 + const skillFolderName = skillName; + const zipFileName = `${skillName}.zip`; + + // 设置响应头 + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(zipFileName)}"`); + res.setHeader('Cache-Control', 'no-cache'); + + // 创建 archiver 实例 + const archive = archiver('zip', { + zlib: { level: 9 } // 最高压缩级别 + }); + + // 错误处理 - 正确处理异步错误 + archive.on('error', (err) => { + addLog.error('Archive error:', err); + // 销毁响应流,避免发送不完整数据 + if (!res.headersSent) { + const archiveErr = SkillErr[SkillErrEnum.archiveError]; + res.status(500).json(archiveErr); + } else { + res.destroy(); + } + }); + + // 监听压缩进度(可选) + archive.on('warning', (warn) => { + addLog.warn('Archive warning:', warn); + }); + + // 将 archive 流连接到响应 + archive.pipe(res); + + try { + // 获取最新发布版本的配置 + const { versionId, versionName, nodes, edges, chatConfig } = await getAppLatestVersion( + String(app._id), + app + ); + + // 1. 添加 SKILL.md + const skillMarkdown = await generateSkillMarkdown({ + app, + apiKey, + keyName, + expiredTime, + baseUrl, + skillName, // 传递用户输入的 skillName + skillDescription, // 传递用户输入的 skillDescription + locale // 传递语言代码 + }); + archive.append(skillMarkdown, { name: `${skillFolderName}/SKILL.md` }); + + // 2. 添加 scripts/config.json(含真实配置,由导出时生成) + const configJson = await generateConfigJson({ apiKey, baseUrl, appId: String(app._id), app }); + archive.append(configJson, { name: `${skillFolderName}/scripts/config.json` }); + + // 3. 添加 scripts/chat.js(封装好的调用客户端,从 config.json 读取配置) + const chatScript = await generateChatScript({ apiKey, baseUrl, appId: String(app._id), app }); + archive.append(chatScript, { name: `${skillFolderName}/scripts/chat.js` }); + + // 4. 添加 package.json(依赖配置文件) + const packageJson = { + name: skillName, + version: '1.0.0', + description: skillDescription, + main: 'scripts/chat.js', + scripts: { + test: "node -e \"const { chat } = require('./scripts/chat'); chat({ message: '你好' }).then(r => console.log(r.reply)).catch(e => console.error(e.message));\"" + }, + dependencies: { + axios: '^1.6.0' + } + }; + archive.append(JSON.stringify(packageJson, null, 2), { + name: `${skillFolderName}/package.json` + }); + + // 5. 添加应用配置到 references 目录(供参考) + // 使用最新发布版本的配置,与 API Key 调用时保持一致 + const appConfig = { + _id: app._id, + name: app.name, + type: app.type, + avatar: app.avatar, + intro: app.intro, + versionId, // 发布版本 ID + versionName, // 发布版本名称 + modules: nodes, // 使用最新发布版本的 nodes + edges, // 使用最新发布版本的 edges + chatConfig, // 使用最新发布版本的 chatConfig + exportTime: new Date().toISOString() + }; + archive.append(JSON.stringify(appConfig, null, 2), { + name: `${skillFolderName}/references/app-config.json` + }); + + // 6. 创建预留目录(保持标准结构) + archive.append('', { name: `${skillFolderName}/assets/.gitkeep` }); + + // 完成压缩 + await archive.finalize(); + } catch (error) { + addLog.error('Export skill zip failed:', error); + // 无论响应头是否已发送,都要抛出异常让调用方知道 + if (!res.headersSent) { + throw new Error(AppErrEnum.exportSkillFailed); + } + // 如果响应头已发送,销毁响应流并抛出异常 + res.destroy(); + throw new Error('Export failed after response headers sent'); + } +} diff --git a/packages/service/core/app/skill/template.ts b/packages/service/core/app/skill/template.ts new file mode 100644 index 000000000000..d5e48fada912 --- /dev/null +++ b/packages/service/core/app/skill/template.ts @@ -0,0 +1,333 @@ +import type { AppSchema } from '@fastgpt/global/core/app/type'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { promises as fsPromises } from 'fs'; +import fs from 'fs'; +import path from 'path'; +import SkillErr, { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; +import { addLog } from '../../../common/system/log'; + +interface GenerateSkillMarkdownParams { + app: AppSchema; + apiKey: string; + keyName: string; + expiredTime: undefined; // 永不过期 + baseUrl: string; + skillName: string; // 用户输入的 Skill 名称(kebab-case) + skillDescription: string; // 用户输入的 Skill 描述 + locale?: string; // 语言代码,默认 zh-CN +} + +interface GenerateChatScriptParams { + apiKey: string; + baseUrl: string; + appId: string; + app: AppSchema; +} + +// 获取模板文件的绝对路径 +function getTemplatePath(fileName: string): string { + // process.cwd() 在 Next.js 项目中返回 projects/app 目录 + const templatePath = path.join(process.cwd(), `public/fastgpt-to-skill-template/${fileName}`); + + // 检查文件是否存在 + if (!fs.existsSync(templatePath)) { + addLog.error(`Template file not found: ${templatePath}`); + throw SkillErr[SkillErrEnum.exportFailed]; + } + + return templatePath; +} + +/** + * 生成 SKILL.md 文件内容(符合 Claude Skill 官方规范) + * 基于模板文件替换变量 + */ +export async function generateSkillMarkdown(params: GenerateSkillMarkdownParams): Promise { + const { + app, + apiKey, + keyName, + expiredTime, + baseUrl, + skillName, + skillDescription, + locale = 'zh-CN' + } = params; + + // 根据 locale 选择模板文件 + const templateFileName = locale === 'en' ? 'SKILL_EN.md' : 'SKILL.md'; + const templatePath = getTemplatePath(templateFileName); + + let template = await fsPromises.readFile(templatePath, 'utf-8'); + + // 提取应用变量 + const variables = app.chatConfig?.variables || []; + const isAssistant = app.type === AppTypeEnum.assistant; + + // 格式化日期(根据 locale 选择格式) + const formatDate = (date: Date) => { + const localeCode = locale === 'en' ? 'en-US' : 'zh-CN'; + return new Date(date).toLocaleString(localeCode, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // 生成 variables 调用示例段落(仅非 assistant 且有变量时输出) + const variablesUsage = generateVariablesUsage(variables, isAssistant, locale); + + // 生成文件上传使用说明(assistant 类型不支持文件上传) + const fileUploadUsage = generateFileUploadUsage(isAssistant, locale); + + // 准备所有替换变量 + const replacements: Record = { + '{skillName}': skillName, + '{skillDescription}': skillDescription + .split('\n') + .map((line) => ' ' + line) + .join('\n'), + '{appName}': app.name, + '{appIntro}': app.intro || (locale === 'en' ? 'No introduction available' : '暂无介绍'), + '{apiKey}': apiKey, + '{keyName}': keyName, + '{expiredTime}': locale === 'en' ? 'Never expires' : '永不过期', + '{baseUrl}': baseUrl, + '{appId}': String(app._id), + '{appType}': getAppTypeLabel(app.type, locale), + '{exportTime}': formatDate(new Date()), + '{variablesUsage}': variablesUsage, + '{fileUploadUsage}': fileUploadUsage + }; + + // 执行替换 + for (const [key, value] of Object.entries(replacements)) { + template = template.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value); + } + + return template; +} + +/** + * 读取 scripts/chat.js 模板,根据应用类型选择不同的模板文件 + * assistant 类型应用使用 chat-assistant.js(不支持文件上传) + * 其他类型应用使用 chat.js(完整功能) + */ +export async function generateChatScript(params: GenerateChatScriptParams): Promise { + const { app } = params; + const isAssistant = app.type === AppTypeEnum.assistant; + + // 根据应用类型选择不同的模板文件 + const templateFileName = isAssistant ? 'chat-assistant.js' : 'chat.js'; + const scriptPath = getTemplatePath(`scripts/${templateFileName}`); + + return fsPromises.readFile(scriptPath, 'utf-8'); +} + +/** + * 读取 scripts/config.json 模板,替换配置占位符后返回最终内容 + */ +export async function generateConfigJson(params: GenerateChatScriptParams): Promise { + const { apiKey, baseUrl, appId } = params; + + const configPath = getTemplatePath('scripts/config.json'); + let config = await fsPromises.readFile(configPath, 'utf-8'); + + config = config + .replace(/\{baseUrl\}/g, baseUrl) + .replace(/\{apiKey\}/g, apiKey) + .replace(/\{appId\}/g, appId); + + return config; +} + +/** + * 生成应用类型标签(支持多语言) + */ +function getAppTypeLabel(type: `${AppTypeEnum}`, locale: string = 'zh-CN'): string { + if (locale === 'en') { + const typeMapEn: Record = { + [AppTypeEnum.simple]: 'Simple Chat', + [AppTypeEnum.workflow]: 'Workflow', + [AppTypeEnum.chatAgent]: 'Agent', + [AppTypeEnum.assistant]: 'Assistant', + [AppTypeEnum.workflowTool]: 'Workflow Tool', + [AppTypeEnum.httpPlugin]: 'HTTP Plugin', + [AppTypeEnum.mcpToolSet]: 'Tool Set', + [AppTypeEnum.httpToolSet]: 'HTTP Tool Set', + [AppTypeEnum.tool]: 'Tool', + [AppTypeEnum.folder]: 'Folder', + [AppTypeEnum.toolFolder]: 'Tool Folder', + [AppTypeEnum.hidden]: 'Hidden' + }; + return typeMapEn[type] || type; + } + + const typeMap: Record = { + [AppTypeEnum.simple]: '简易对话', + [AppTypeEnum.workflow]: '工作流', + [AppTypeEnum.chatAgent]: 'Agent', + [AppTypeEnum.assistant]: '助手应用', + [AppTypeEnum.workflowTool]: '工作流工具', + [AppTypeEnum.httpPlugin]: 'HTTP 插件', + [AppTypeEnum.mcpToolSet]: '工具集', + [AppTypeEnum.httpToolSet]: 'HTTP 工具集', + [AppTypeEnum.tool]: '工具', + [AppTypeEnum.folder]: '文件夹', + [AppTypeEnum.toolFolder]: '工具文件夹', + [AppTypeEnum.hidden]: '隐藏' + }; + return typeMap[type] || type; +} + +/** + * 生成应用变量的调用示例段落 + * assistant 类型或无变量时返回空字符串 + */ +function generateVariablesUsage( + variables: any[], + isAssistant: boolean, + locale: string = 'zh-CN' +): string { + if (isAssistant || !variables || variables.length === 0) { + return ''; + } + + const varObj = variables.map((v) => ` ${v.key}: '示例值'`).join(',\n'); + const varDesc = variables + .map( + (v) => + `| \`${v.key}\` | ${v.type} | ${v.required ? (locale === 'en' ? 'Yes' : '是') : locale === 'en' ? 'No' : '否'} | ${v.label || '-'} |` + ) + .join('\n'); + + if (locale === 'en') { + return `### Chat with Application Variables + +This application requires the following variables: + +| Variable | Type | Required | Description | +|----------|------|----------|-------------| +${varDesc} + +\`\`\`javascript +const { chat } = require('./scripts/chat'); + +const { reply } = await chat({ + message: 'Your question', + variables: { +${varObj} + } +}); +\`\`\` +`; + } + + return `### 携带应用变量的对话 + +本应用需要传入以下变量: + +| 变量名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +${varDesc} + +\`\`\`javascript +const { chat } = require('./scripts/chat'); + +const { reply } = await chat({ + message: '你的问题', + variables: { +${varObj} + } +}); +\`\`\` +`; +} + +/** + * 生成文件上传使用说明段落 + * assistant 类型不支持文件上传,返回空字符串 + */ +function generateFileUploadUsage(isAssistant: boolean, locale: string = 'zh-CN'): string { + if (isAssistant) { + return ''; + } + + if (locale === 'en') { + return `### Chat with Image (Online URL) + +\`\`\`javascript +const { chat } = require('./scripts/chat'); + +const { reply } = await chat({ + message: 'Please analyze this image', + imageUrl: 'https://example.com/image.png' +}); +\`\`\` + +### Chat with Local File + +Files must share the same \`chatId\` as the conversation — FastGPT uses it to verify file ownership. +Flow: start a chat to get a \`chatId\` → upload the file → continue the conversation with the file URL. + +\`\`\`javascript +const { chat, uploadFile } = require('./scripts/chat'); + +// Step 1: Start a chat to obtain a chatId +const init = await chat({ message: 'Hello' }); +const { chatId } = init; + +// Step 2: Upload the local file using the same chatId +const fileUrl = await uploadFile('/path/to/document.pdf', chatId); + +// Step 3: Continue the conversation in the same session with the file +const { reply } = await chat({ + message: 'Please summarize the main content of this file', + fileUrl, + fileName: 'document.pdf', + chatId +}); +console.log(reply); +\`\`\` +`; + } + + return `### 带图片的对话(在线 URL) + +\`\`\`javascript +const { chat } = require('./scripts/chat'); + +const { reply } = await chat({ + message: '请分析这张图片', + imageUrl: 'https://example.com/image.png' +}); +\`\`\` + +### 带本地文件的对话 + +文件必须与对话共用同一个 \`chatId\`,FastGPT 凭此关联文件权限。 +流程:先发起一次对话拿到 \`chatId\` → 上传文件 → 携带文件 URL 继续对话。 + +\`\`\`javascript +const { chat, uploadFile } = require('./scripts/chat'); + +// 第一步:发起对话,获取 chatId +const init = await chat({ message: '你好' }); +const { chatId } = init; + +// 第二步:上传本地文件(传入同一 chatId,FastGPT 据此校验文件归属) +const fileUrl = await uploadFile('/path/to/document.pdf', chatId); + +// 第三步:携带文件 URL 在同一会话中继续对话 +const { reply } = await chat({ + message: '请总结这个文件的主要内容', + fileUrl, + fileName: 'document.pdf', + chatId +}); +console.log(reply); +\`\`\` +`; +} diff --git a/packages/service/core/app/templates/register.ts b/packages/service/core/app/templates/register.ts index 42f58a27bb30..a48cb11ed956 100644 --- a/packages/service/core/app/templates/register.ts +++ b/packages/service/core/app/templates/register.ts @@ -5,12 +5,12 @@ import { MongoAppTemplate } from './templateSchema'; import { pluginClient } from '../../../thirdProvider/fastgptPlugin'; import { addMinutes } from 'date-fns'; -const getFileTemplates = async (): Promise => { - return (await pluginClient.listWorkflows()) as AppTemplateSchemaType[]; +const getFileTemplates = async (locale?: string): Promise => { + return (await pluginClient.listWorkflows(locale)) as AppTemplateSchemaType[]; }; -const getAppTemplates = async () => { - const originCommunityTemplates = await getFileTemplates(); +const getAppTemplates = async (locale?: string) => { + const originCommunityTemplates = await getFileTemplates(locale); const communityTemplates = originCommunityTemplates.map((template) => { return { ...template, @@ -25,10 +25,11 @@ const getAppTemplates = async () => { const config = dbTemplates.find((t) => t.templateId === template.templateId); if (config) { - return { + const merged = { ...template, ...config }; + return merged; } return template; @@ -41,29 +42,32 @@ const getAppTemplates = async () => { return res; }; -export const getAppTemplatesAndLoadThem = async (refresh = false) => { +export const getAppTemplatesAndLoadThem = async (refresh = false, locale = 'zh-CN') => { // 首次强制刷新 if (!global.templatesRefreshTime) { global.templatesRefreshTime = Date.now() - 10000; } - if (!global.appTemplates) { - global.appTemplates = []; + if (!global.appTemplatesByLocale) { + global.appTemplatesByLocale = {}; + } + if (!global.appTemplatesByLocale[locale]) { + global.appTemplatesByLocale[locale] = []; } if ( isProduction && - // 有模板缓存 - global.appTemplates.length > 0 && + // 有模板缓存且对应语言有数据 + global.appTemplatesByLocale[locale].length > 0 && // 缓存时间未过期 global.templatesRefreshTime > Date.now() && !refresh ) { - return global.appTemplates; + return global.appTemplatesByLocale[locale]; } try { - const appTemplates = await getAppTemplates(); - global.appTemplates = appTemplates; + const appTemplates = await getAppTemplates(locale); + global.appTemplatesByLocale[locale] = appTemplates; global.templatesRefreshTime = addMinutes(new Date(), 30).getTime(); // 缓存30分钟 return appTemplates; } catch (error) { @@ -76,6 +80,6 @@ export const isCommercialTemaplte = (templateId: string) => { }; declare global { - var appTemplates: AppTemplateSchemaType[]; + var appTemplatesByLocale: Record; var templatesRefreshTime: number; } diff --git a/packages/service/core/app/templates/templateSchema.ts b/packages/service/core/app/templates/templateSchema.ts index 76512d8db6d9..0a8abbd5caee 100644 --- a/packages/service/core/app/templates/templateSchema.ts +++ b/packages/service/core/app/templates/templateSchema.ts @@ -36,7 +36,15 @@ const AppTemplateSchema = new Schema({ type: Number, default: -1 }, - workflow: Object + workflow: Object, + experienceUrl: { + type: String, + default: '' + }, + isMarketFeatured: { + type: Boolean, + default: false + } }); AppTemplateSchema.index({ templateId: 1 }); diff --git a/packages/service/core/app/tool/controller.ts b/packages/service/core/app/tool/controller.ts index cb1060299fac..727ea7a6e486 100644 --- a/packages/service/core/app/tool/controller.ts +++ b/packages/service/core/app/tool/controller.ts @@ -15,7 +15,7 @@ import { } from '@fastgpt/global/core/workflow/utils'; import { MongoApp } from '../schema'; import type { localeType } from '@fastgpt/global/common/i18n/type'; -import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import { parseI18nString, parseI18nArray } from '@fastgpt/global/common/i18n/utils'; import type { WorkflowTemplateBasicType } from '@fastgpt/global/core/workflow/type'; import { checkIsLatestVersion, @@ -39,7 +39,8 @@ import { Types } from '../../../common/mongo'; import type { SystemPluginToolCollectionType } from '@fastgpt/global/core/plugin/tool/type'; import type { FlowNodeInputItemType, - FlowNodeOutputItemType + FlowNodeOutputItemType, + InputConfigType } from '@fastgpt/global/core/workflow/type/io'; import { Output_Template_Error_Message } from '@fastgpt/global/core/workflow/template/output'; import { splitCombineToolId } from '@fastgpt/global/core/app/tool/utils'; @@ -239,6 +240,8 @@ export const getSystemToolsWithInstalled = async ({ }; export const getSystemToolByIdAndVersionId = async ( + pluginId: string, + lang: localeType = 'en', toolId: string, versionId?: string ): Promise => { @@ -490,7 +493,7 @@ export async function getChildAppPreviewNode({ } // System Tools/ Commercial system tools else { - return getSystemToolByIdAndVersionId(appId, versionId); + return getSystemToolByIdAndVersionId(pluginId, lang, appId, versionId); } })(); @@ -510,11 +513,17 @@ export async function getChildAppPreviewNode({ ? (await getSystemTools()).filter((item) => item.parentId === app.id) : []; + // Check if system_input_config already exists in app.inputs + const hasSystemInputConfig = app.inputs?.some( + (input) => input.key === NodeInputKeyEnum.systemInputConfig + ); + return { flowNodeType: app.isFolder ? FlowNodeTypeEnum.toolSet : FlowNodeTypeEnum.tool, nodeIOConfig: { inputs: [ - ...(app.inputList + // Only add system_input_config if it doesn't exist and app.inputList is available + ...(!hasSystemInputConfig && app.inputList ? [ { key: NodeInputKeyEnum.systemInputConfig, @@ -688,6 +697,7 @@ export const refreshSystemTools = async (): Promise = courseUrl: item.courseUrl, instructions: dbPluginConfig?.customConfig?.userGuide, tags: dbPluginConfig?.customConfig?.tags || item.tags, + templateType: FlowNodeTemplateTypeEnum.tools, workflow: { nodes: [], edges: [] diff --git a/packages/service/core/app/utils.ts b/packages/service/core/app/utils.ts index 22d2a484d8d1..1729fcfbbd77 100644 --- a/packages/service/core/app/utils.ts +++ b/packages/service/core/app/utils.ts @@ -1,7 +1,8 @@ import { MongoDataset } from '../dataset/schema'; -import { getEmbeddingModel } from '../ai/model'; +import { getEmbeddingModelById } from '../ai/model'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node'; import { getChildAppPreviewNode } from './tool/controller'; import { authAppByTmbId } from '../../support/permission/app/auth'; @@ -27,7 +28,7 @@ export async function listAppDatasetDataByTeamIdAndDatasetIds({ datasetId: String(item._id), avatar: item.avatar, name: item.name, - vectorModel: getEmbeddingModel(item.vectorModel) + vectorModel: getEmbeddingModelById(item.vectorModelId) })); } @@ -224,7 +225,10 @@ export async function rewriteAppWorkflowToDetail({ node.inputs.forEach((item) => { if (item.key !== NodeInputKeyEnum.datasetSelectList) return; - const val = item.value as undefined | { datasetId: string }[] | { datasetId: string }; + const val = item.value as + | undefined + | (any & { datasetType?: DatasetTypeEnum })[] + | (any & { datasetType?: DatasetTypeEnum }); if (Array.isArray(val)) { item.value = val @@ -241,7 +245,8 @@ export async function rewriteAppWorkflowToDetail({ datasetId: data.datasetId, avatar: data.avatar, name: data.name, - vectorModel: data.vectorModel + vectorModel: data.vectorModel, + datasetType: v.datasetType }; }) .filter(Boolean); @@ -262,7 +267,8 @@ export async function rewriteAppWorkflowToDetail({ datasetId: data.datasetId, avatar: data.avatar, name: data.name, - vectorModel: data.vectorModel + vectorModel: data.vectorModel, + datasetType: val.datasetType } ]; } diff --git a/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts b/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts index 9130f3a0dd57..a50ead3893b1 100644 --- a/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts +++ b/packages/service/core/chat/HelperBot/dispatch/topAgent/index.ts @@ -2,7 +2,7 @@ import { type HelperBotDispatchParamsType, type HelperBotDispatchResponseType } import { helperChats2GPTMessages } from '@fastgpt/global/core/chat/helperBot/adaptor'; import { getPrompt } from './prompt'; import { createLLMResponse } from '../../../../ai/llm/request'; -import { getDefaultHelperBotModel } from '../../../../ai/model'; +import { getDefaultHelperBotModel, getLLMModelById } from '../../../../ai/model'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; import { @@ -29,13 +29,13 @@ export const dispatchTopAgent = async ( ): Promise => { const { query, files, data, histories, workflowResponseWrite, user } = props; - const modelData = getDefaultHelperBotModel(); + const modelData = getLLMModelById(data.modelId ?? undefined) || getDefaultHelperBotModel(); if (!modelData) { return Promise.reject('Can not get model data'); } const usage = { - model: modelData.model, + modelId: modelData.id, inputTokens: 0, outputTokens: 0 }; @@ -64,7 +64,7 @@ export const dispatchTopAgent = async ( const llmResponse = await createLLMResponse({ body: { messages: conversationMessages, - model: modelData, + modelId: modelData.id, stream: true }, onReasoning: ({ text }) => { @@ -114,7 +114,7 @@ export const dispatchTopAgent = async ( content: repairPrompt } ], - model: modelData, + modelId: modelData.id, stream: true } }); @@ -278,14 +278,14 @@ const filterValidDatasets = async ({ }) } }, - '_id avatar name vectorModel' + '_id avatar name vectorModelId' ).lean(); return result.map((item) => ({ datasetId: String(item._id), avatar: item.avatar, name: item.name, vectorModel: { - model: item.vectorModel + id: item.vectorModelId } })); }; diff --git a/packages/service/core/chat/HelperBot/dispatch/topAgent/utils.ts b/packages/service/core/chat/HelperBot/dispatch/topAgent/utils.ts index 0c00efda8cb7..1b315c0eda12 100644 --- a/packages/service/core/chat/HelperBot/dispatch/topAgent/utils.ts +++ b/packages/service/core/chat/HelperBot/dispatch/topAgent/utils.ts @@ -38,7 +38,7 @@ const getAccessibleDatasets = async ({ teamId, tmbId }: { teamId: string; tmbId: teamId, deleteTime: null }) - .select('_id name intro avatar vectorModel') + .select('_id name intro avatar vectorModelId') .sort({ updateTime: -1 }) .lean(); }; diff --git a/packages/service/core/chat/HelperBot/dispatch/type.ts b/packages/service/core/chat/HelperBot/dispatch/type.ts index dd3a5fc00feb..fb90ac04cd13 100644 --- a/packages/service/core/chat/HelperBot/dispatch/type.ts +++ b/packages/service/core/chat/HelperBot/dispatch/type.ts @@ -35,7 +35,7 @@ export type HelperBotDispatchParamsType = Omit< export const HelperBotDispatchResponseSchema = z.object({ aiResponse: z.array(AIChatItemValueItemSchema), usage: z.object({ - model: z.string(), + modelId: z.string(), inputTokens: z.number(), outputTokens: z.number() }) diff --git a/packages/service/core/chat/chatItemResponseSchema.ts b/packages/service/core/chat/chatItemResponseSchema.ts index 31b2c2258de4..835ee9237ec3 100644 --- a/packages/service/core/chat/chatItemResponseSchema.ts +++ b/packages/service/core/chat/chatItemResponseSchema.ts @@ -38,6 +38,9 @@ const ChatItemResponseSchema = new Schema({ // Get response/Delete ChatItemResponseSchema.index({ appId: 1, chatId: 1, chatItemDataId: 1 }); +// Speed up notFoundKnowledge distinct queries on moduleType +ChatItemResponseSchema.index({ appId: 1, chatId: 1, 'data.moduleType': 1 }); + // Clear expired response ChatItemResponseSchema.index({ teamId: 1, time: -1 }); diff --git a/packages/service/core/chat/chatItemSchema.ts b/packages/service/core/chat/chatItemSchema.ts index baddf81515d5..aedc3b2be338 100644 --- a/packages/service/core/chat/chatItemSchema.ts +++ b/packages/service/core/chat/chatItemSchema.ts @@ -10,6 +10,7 @@ import { import { AppCollectionName } from '../app/schema'; import { userCollectionName } from '../../support/user/schema'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { ChatCorrectionCollectionName } from './correction/schema'; import { ChatItemCollectionName } from './constants'; const ChatItemSchema = new Schema({ @@ -86,8 +87,25 @@ const ChatItemSchema = new Schema({ default: null }, - /** @deprecated */ - [DispatchNodeResponseKeyEnum.nodeResponse]: Array + // @deprecated + [DispatchNodeResponseKeyEnum.nodeResponse]: Array, + correctionId: { + type: Schema.Types.ObjectId, + ref: ChatCorrectionCollectionName + }, + + // 逻辑删除字段 + deleted: { + type: Boolean, + default: false + }, + deletedAt: { + type: Date + }, + deletedBy: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName + } }); /* @@ -96,6 +114,7 @@ const ChatItemSchema = new Schema({ close custom feedback; */ ChatItemSchema.index({ appId: 1, chatId: 1, dataId: 1 }); +ChatItemSchema.index({ correctionId: 1 }); // Get histories ChatItemSchema.index({ appId: 1, chatId: 1, deleteTime: 1 }); // get chatitem list,Anchor filter diff --git a/packages/service/core/chat/chatSchema.ts b/packages/service/core/chat/chatSchema.ts index ad7dd3aa7a04..11f4588f71df 100644 --- a/packages/service/core/chat/chatSchema.ts +++ b/packages/service/core/chat/chatSchema.ts @@ -120,6 +120,24 @@ const ChatSchema = new Schema({ default: false }, + // Parent app association fields (when this chat is triggered by a workflow appModule node) + parentChatId: { + type: String + }, + parentResponseChatItemId: { + type: String + }, + parentNodeId: { + type: String + }, + parentNodeName: { + type: String + }, + parentAppId: { + type: Schema.Types.ObjectId, + ref: AppCollectionName + }, + /** @deprecated */ userId: Schema.Types.ObjectId }); diff --git a/packages/service/core/chat/controller.ts b/packages/service/core/chat/controller.ts index 8aa665d45345..d87335a01aeb 100644 --- a/packages/service/core/chat/controller.ts +++ b/packages/service/core/chat/controller.ts @@ -1,4 +1,4 @@ -import type { ChatHistoryItemResType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import type { ChatHistoryItemResType, ChatItemMiniType, ChatItemType } from '@fastgpt/global/core/chat/type'; import { MongoChatItem } from './chatItemSchema'; import { MongoChat } from './chatSchema'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; @@ -8,6 +8,8 @@ import type { ClientSession } from '../../common/mongo'; import { Types } from '../../common/mongo'; import { mongoSessionRun } from '../../common/mongo/sessionRun'; import { UserError } from '@fastgpt/global/common/error/utils'; +import { ChatLogsFilterEnum } from '@fastgpt/global/core/chat/correction/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { getLogger, LogCategories } from '../../common/logger'; const logger = getLogger(LogCategories.MODULE.CHAT.HISTORY); @@ -18,7 +20,7 @@ export async function getChatItems({ chatId, field, limit, - + chatLogsFilter, offset, initialId, prevId, @@ -29,7 +31,7 @@ export async function getChatItems({ chatId?: string; field: string; limit: number; - + chatLogsFilter?: `${ChatLogsFilterEnum}`; offset?: number; initialId?: string; prevId?: string; @@ -48,9 +50,79 @@ export async function getChatItems({ field = `dataId ${field}`; const baseCondition = includeDeleted ? { appId, chatId } : { appId, chatId, deleteTime: null }; + // Build AI-only filter condition for chatLogsFilter. + // Human messages are never queried directly; they are fetched as pairs after AI results. + let aiOnlyFilter: Record | null = null; + if (chatLogsFilter === ChatLogsFilterEnum.good) { + aiOnlyFilter = { userGoodFeedback: { $exists: true, $ne: null } }; + } else if (chatLogsFilter === ChatLogsFilterEnum.bad) { + aiOnlyFilter = { userBadFeedback: { $exists: true, $ne: null } }; + } else if (chatLogsFilter === ChatLogsFilterEnum.notFoundKnowledge) { + const notFoundDataIds = await MongoChatItemResponse.distinct('chatItemDataId', { + appId, + chatId, + 'data.moduleType': FlowNodeTypeEnum.datasetSearchNode, + $or: [{ 'data.quoteList': { $size: 0 } }, { 'data.quoteList': null }] + }); + aiOnlyFilter = + notFoundDataIds.length > 0 ? { dataId: { $in: notFoundDataIds } } : { dataId: { $in: [] } }; + } + + const aiCondition = aiOnlyFilter + ? { ...baseCondition, obj: ChatRoleEnum.AI, ...aiOnlyFilter } + : null; + + // When filter is active each result item is an H+AI pair, so query ceil(limit/2) AIs + // to keep the total returned item count consistent with the unfiltered case. + const aiLimit = Math.ceil(limit / 2); + + // For each AI item (ascending _id order), fetch its nearest Human predecessor in parallel. + // Deduplicates: a Human is paired with at most one AI (the earliest AI after it). + const fetchHumanPairs = async (aiItems: any[]): Promise => { + if (aiItems.length === 0) return []; + const humans = await Promise.all( + aiItems.map((ai) => + MongoChatItem.findOne( + { ...baseCondition, obj: ChatRoleEnum.Human, _id: { $lt: ai._id } }, + field + ) + .sort({ _id: -1 }) + .lean() + ) + ); + const seen = new Set(); + const result: any[] = []; + for (let i = 0; i < aiItems.length; i++) { + const human = humans[i]; + if (human && !seen.has(human._id.toString())) { + seen.add(human._id.toString()); + result.push(human); + } + result.push(aiItems[i]); + } + return result; + }; + const { histories, total, hasMorePrev, hasMoreNext } = await (async () => { // Mode 1: offset pagination (original logic) if (offset !== undefined) { + if (aiCondition) { + const [aiItems, count] = await Promise.all([ + MongoChatItem.find(aiCondition, field) + .sort({ _id: -1 }) + .skip(offset) + .limit(aiLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + const sliced = (aiItems as any[]).slice(0, aiLimit).reverse(); + return { + histories: await fetchHumanPairs(sliced), + total: count, + hasMorePrev: aiItems.length > aiLimit, + hasMoreNext: offset > 0 + }; + } const [foundHistories, count] = await Promise.all([ MongoChatItem.find(baseCondition, field).sort({ _id: -1 }).skip(offset).limit(limit).lean(), MongoChatItem.countDocuments(baseCondition) @@ -65,20 +137,33 @@ export async function getChatItems({ // Mode 2: prevId - get records before the target else if (prevId) { const prevItem = await MongoChatItem.findOne( - { - ...baseCondition, - dataId: prevId - }, + { ...baseCondition, dataId: prevId }, { _id: 1 } ).lean(); if (!prevItem) return Promise.reject(new UserError('Prev item not found')); + if (aiCondition) { + const [aiItems, count] = await Promise.all([ + MongoChatItem.find({ ...aiCondition, _id: { $lt: prevItem._id } }, field) + .sort({ _id: -1 }) + .limit(aiLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + const sliced = (aiItems as any[]).slice(0, aiLimit).reverse(); + return { + histories: await fetchHumanPairs(sliced), + total: count, + hasMorePrev: aiItems.length > aiLimit, + hasMoreNext: true + }; + } const [items, count] = await Promise.all([ MongoChatItem.find({ ...baseCondition, _id: { $lt: prevItem._id } }, field) .sort({ _id: -1 }) .limit(limit + 1) .lean(), - MongoChatItem.countDocuments({ ...baseCondition }) + MongoChatItem.countDocuments(baseCondition) ]); return { @@ -91,20 +176,33 @@ export async function getChatItems({ // Mode 3: nextId - get records after the target else if (nextId) { const nextItem = await MongoChatItem.findOne( - { - ...baseCondition, - dataId: nextId - }, + { ...baseCondition, dataId: nextId }, { _id: 1 } ).lean(); if (!nextItem) return Promise.reject(new UserError('Next item not found')); + if (aiCondition) { + const [aiItems, total] = await Promise.all([ + MongoChatItem.find({ ...aiCondition, _id: { $gt: nextItem._id } }, field) + .sort({ _id: 1 }) + .limit(aiLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + const sliced = (aiItems as any[]).slice(0, aiLimit); + return { + histories: await fetchHumanPairs(sliced), + total, + hasMorePrev: true, + hasMoreNext: aiItems.length > aiLimit + }; + } const [items, total] = await Promise.all([ MongoChatItem.find({ ...baseCondition, _id: { $gt: nextItem._id } }, field) .sort({ _id: 1 }) .limit(limit + 1) .lean(), - MongoChatItem.countDocuments({ ...baseCondition }) + MongoChatItem.countDocuments(baseCondition) ]); return { @@ -114,17 +212,37 @@ export async function getChatItems({ hasMoreNext: items.length > limit }; } - // Mode 2: initialId - get records around the target + // Mode 4: initialId - get records around the target (or from end if no initialId) else { if (!initialId) { + if (aiCondition) { + const [aiItems, count] = await Promise.all([ + MongoChatItem.find(aiCondition, field) + .sort({ _id: -1 }) + .limit(aiLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + const sliced = (aiItems as any[]).slice(0, aiLimit).reverse(); + return { + histories: await fetchHumanPairs(sliced), + total: count, + hasMorePrev: aiItems.length > aiLimit, + hasMoreNext: false + }; + } const [foundHistories, count] = await Promise.all([ - MongoChatItem.find(baseCondition, field).sort({ _id: -1 }).skip(0).limit(limit).lean(), + MongoChatItem.find(baseCondition, field) + .sort({ _id: -1 }) + .skip(0) + .limit(limit + 1) + .lean(), MongoChatItem.countDocuments(baseCondition) ]); return { - histories: foundHistories.reverse(), + histories: foundHistories.slice(0, limit).reverse(), total: count, - hasMorePrev: count > limit, + hasMorePrev: foundHistories.length > limit, hasMoreNext: false }; } @@ -138,6 +256,32 @@ export async function getChatItems({ ).lean(); if (!targetItem) return Promise.reject(new UserError('Target item not found')); + if (aiCondition) { + const aiHalfLimit = Math.floor(aiLimit / 2); + const aiCeilLimit = Math.ceil(aiLimit / 2); + // Use $lte to include the target itself if it is a matching AI + const [prevAIs, nextAIs, count] = await Promise.all([ + MongoChatItem.find({ ...aiCondition, _id: { $lte: targetItem._id } }, field) + .sort({ _id: -1 }) + .limit(aiHalfLimit + 1) + .lean(), + MongoChatItem.find({ ...aiCondition, _id: { $gt: targetItem._id } }, field) + .sort({ _id: 1 }) + .limit(aiCeilLimit + 1) + .lean(), + MongoChatItem.countDocuments(baseCondition) + ]); + const allAIs = [ + ...(prevAIs as any[]).slice(0, aiHalfLimit).reverse(), + ...(nextAIs as any[]).slice(0, aiCeilLimit) + ]; + return { + histories: await fetchHumanPairs(allAIs), + total: count, + hasMorePrev: prevAIs.length > aiHalfLimit, + hasMoreNext: nextAIs.length > aiCeilLimit + }; + } const [prevItems, nextItems, count] = await Promise.all([ MongoChatItem.find({ ...baseCondition, _id: { $lt: targetItem._id } }, field) .sort({ _id: -1 }) @@ -350,3 +494,260 @@ export async function updateChatFeedbackCount({ throw error; } } + +// Helper function to parse field string to MongoDB projection object +function parseFieldsToProjection(fieldString: string): Record { + const fields = fieldString.trim().split(/\s+/); + const projection: Record = {}; + fields.forEach((field) => { + if (field) { + projection[field] = 1; + } + }); + return projection; +} + +/** + * Get feedback statistics (goodTotal, badTotal, notFoundTotal) for a chat. + * Used by getRecords_v2 to populate statistics without offset pagination. + */ +export async function getChatItemStats({ + appId, + chatId +}: { + appId: string; + chatId: string; +}): Promise<{ + goodTotal: number; + badTotal: number; + notFoundTotal: number; +}> { + const appObjectId = new Types.ObjectId(appId); + + const [notFoundDataIds, aggResult] = await Promise.all([ + MongoChatItemResponse.distinct('chatItemDataId', { + appId, + chatId, + 'data.moduleType': FlowNodeTypeEnum.datasetSearchNode, + $or: [{ 'data.quoteList': { $size: 0 } }, { 'data.quoteList': null }] + }), + MongoChatItem.aggregate([ + { $match: { appId: appObjectId, chatId, deleteTime: null } }, + { + $group: { + _id: null, + goodTotal: { $sum: { $cond: [{ $ifNull: ['$userGoodFeedback', false] }, 1, 0] } }, + badTotal: { $sum: { $cond: [{ $ifNull: ['$userBadFeedback', false] }, 1, 0] } } + } + } + ]) + ]); + + const stats = aggResult[0] ?? { goodTotal: 0, badTotal: 0 }; + + const notFoundTotal = + notFoundDataIds.length > 0 + ? await MongoChatItem.countDocuments({ + appId, + chatId, + deleteTime: null, + dataId: { $in: notFoundDataIds } + }) + : 0; + + return { + goodTotal: stats.goodTotal, + badTotal: stats.badTotal, + notFoundTotal + }; +} + +export async function getPaginationChatItems({ + appId, + chatId, + offset, + pageSize, + field, + chatLogsFilter = ChatLogsFilterEnum.all, + filterDeleted = true +}: { + appId: string; + chatId: string; + offset: number; + pageSize: number; + field: string; + chatLogsFilter?: `${ChatLogsFilterEnum}`; + filterDeleted?: boolean; // 是否过滤已删除的记录(用户端默认true,管理员端传false) +}): Promise<{ + histories: ChatItemType[]; + total: number; + goodTotal: number; + badTotal: number; + notFoundTotal: number; +}> { + // Build aggregation pipeline using $facet for one-time query + const fieldProjection = parseFieldsToProjection(field); + + const appObjectId = new Types.ObjectId(appId); + + // 构建基础匹配条件 + const baseMatch: any = { appId: appObjectId, chatId }; + // 如果需要过滤已删除的记录,添加 deleted 条件 + if (filterDeleted) { + baseMatch.deleted = { $ne: true }; + } + + const pipeline: any[] = [ + { $match: baseMatch }, + { + $addFields: { + hasNotFoundKnowledge: { + $cond: [ + { $eq: ['$obj', ChatRoleEnum.AI] }, + { + $gt: [ + { + $size: { + $filter: { + input: { $ifNull: ['$responseData', []] }, + as: 'node', + cond: { + $and: [ + { $eq: ['$$node.moduleType', FlowNodeTypeEnum.datasetSearchNode] }, + { $eq: [{ $size: { $ifNull: ['$$node.quoteList', []] } }, 0] } + ] + } + } + } + }, + 0 + ] + }, + false + ] + } + } + }, + + // Stage 3: Use $facet to perform multiple operations in parallel + { + $facet: { + // Branch 1: Get paginated AI data only + paginatedData: [ + // Only query AI messages + { $match: { obj: ChatRoleEnum.AI } }, + + // Apply filter based on chatLogsFilter + ...(chatLogsFilter === ChatLogsFilterEnum.good + ? [{ $match: { userGoodFeedback: { $exists: true } } }] + : chatLogsFilter === ChatLogsFilterEnum.bad + ? [{ $match: { userBadFeedback: { $exists: true } } }] + : chatLogsFilter === ChatLogsFilterEnum.notFoundKnowledge + ? [{ $match: { hasNotFoundKnowledge: true } }] + : []), + + { $sort: { _id: -1 } }, + { $skip: Math.floor(offset / 2) }, + { $limit: Math.ceil(pageSize / 2) }, + { $project: { ...fieldProjection, _id: 1 } } + ], + + // Branch 2: Calculate statistics for all types + statistics: [ + { + $group: { + _id: null, + total: { $sum: 1 }, + goodTotal: { $sum: { $cond: [{ $ifNull: ['$userGoodFeedback', false] }, 1, 0] } }, + badTotal: { $sum: { $cond: [{ $ifNull: ['$userBadFeedback', false] }, 1, 0] } }, + notFoundTotal: { $sum: { $cond: ['$hasNotFoundKnowledge', 1, 0] } } + } + } + ] + } + } + ]; + + // Execute aggregation + const [result] = await MongoChatItem.aggregate(pipeline); + + // Extract AI messages + const aiMessages: any[] = result.paginatedData || []; + const stats = result.statistics[0] || { + total: 0, + goodTotal: 0, + badTotal: 0, + notFoundTotal: 0 + }; + + // Calculate total based on filter type + const { total, goodTotal, badTotal, notFoundTotal } = stats; + + // Query corresponding Human messages if AI messages exist + let histories: ChatItemType[] = []; + + if (aiMessages.length > 0) { + // Get all AI _id values and find the maximum (latest in this page) + const aiIds = aiMessages.map((msg) => msg._id); + const maxAiId = aiIds[0]; // AI messages are already sorted by _id ascending + + // Query Human messages with _id less than the maximum AI _id + // Use descending sort + limit to get only the most recent Human messages + // Then reverse to ascending order for efficient pairing + // Build Human messages query with same deleted filter as AI messages + const humanQuery: any = { + appId: appObjectId, + chatId, + obj: ChatRoleEnum.Human, + _id: { $lt: maxAiId } + }; + // Apply the same deleted filter as baseMatch + if (filterDeleted) { + humanQuery.deleted = { $ne: true }; + } + + const humanMessages: any[] = ( + await MongoChatItem.find(humanQuery, fieldProjection) + .sort({ _id: -1 }) // Descending: get the most recent Human messages first + .limit(aiMessages.length) // Limit to avoid querying too many historical messages + .lean() + ).reverse(); // Reverse to ascending order for efficient lookup + + // Create a map to pair each AI with its corresponding Human message + // This ensures each Human is used only once (no duplicates) + const humanMap = new Map(); + aiIds.forEach((aiId) => { + // Find the Human message with the largest _id that is less than this AI's _id + let matchingHuman: any = null; + for (let i = humanMessages.length - 1; i >= 0; i--) { + if (humanMessages[i]._id < aiId) { + matchingHuman = humanMessages[i]; + break; + } + } + if (matchingHuman) { + humanMap.set(aiId.toString(), matchingHuman); + } + }); + + // Pair messages in correct order (aiMessages are already sorted by _id ascending) + const pairedMessages: any[] = []; + for (let i = aiMessages.length - 1; i >= 0; i--) { + const ai = aiMessages[i]; + const human = humanMap.get(ai._id.toString()); + if (human) { + pairedMessages.push(human); + } + pairedMessages.push(ai); + } + histories = pairedMessages; + } + + return { + histories, + total, + goodTotal, + badTotal, + notFoundTotal + }; +} diff --git a/packages/service/core/chat/correction/controller.ts b/packages/service/core/chat/correction/controller.ts new file mode 100644 index 000000000000..56bc36f640e7 --- /dev/null +++ b/packages/service/core/chat/correction/controller.ts @@ -0,0 +1,499 @@ +import { Types } from '../../../common/mongo'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { MongoChatCorrection } from './schema'; +import { MongoChatItem } from '../chatItemSchema'; +import type { CorrectionDataType } from '@fastgpt/global/core/chat/correction/type'; +import { CorrectionModeEnum } from '@fastgpt/global/core/chat/correction/constants'; +import { getEmbeddingModelById } from '../../ai/model'; +import { + insertDatasetDataVector, + deleteDatasetDataVector +} from '../../../common/vectorDB/controller'; +import type { ClientSession } from '../../../common/mongo'; +import { MongoDatasetData } from '../../dataset/data/schema'; +import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; +import type { DatasetDataIndexItemType } from '@fastgpt/global/core/dataset/type'; +import { addLog } from '../../../common/system/log'; +import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat'; + +type SubmitChatCorrectionProps = { + teamId: string; + tmbId: string; + appId: string; + chatId: string; + dataId: string; + correctionData: CorrectionDataType; + modelId: string; +}; + +type ProcessEditModeProps = { + teamId: string; + appId: string; + correctionId: string; + correctionData: CorrectionDataType; + modelId: string; + session: ClientSession; +}; + +type ProcessAnnotateModeProps = { + teamId: string; + correctionId: string; + correctionData: CorrectionDataType; + modelId: string; + session: ClientSession; +}; + +/** + * Submit or update a chat correction + */ +export async function submitChatCorrection({ + teamId, + tmbId, + appId, + chatId, + dataId, + correctionData, + modelId +}: SubmitChatCorrectionProps): Promise { + // Validate required parameters + if (!teamId || !tmbId || !appId || !chatId || !dataId) { + throw new Error('Missing required parameters for submitChatCorrection'); + } + + if (!correctionData || !correctionData.question?.trim()) { + throw new Error('Correction data and question are required'); + } + + if (!modelId?.trim()) { + throw new Error(ChatErrEnum.modelNameRequired); + } + + // Check if correction already exists + const existing = await MongoChatCorrection.findOne({ appId, chatId, dataId }); + + addLog.info('Submitting chat correction', { + appId, + chatId, + dataId, + mode: correctionData.correctionMode, + isUpdate: !!existing + }); + + return mongoSessionRun(async (session) => { + let correctionId: string; + + if (existing) { + addLog.debug('Updating existing correction', { correctionId: String(existing._id) }); + + // Delete old vectors/indexes BEFORE updating + if (existing.correctionData.correctionMode === CorrectionModeEnum.edit) { + // Edit mode: delete vectors from vector store + if (existing.correctionData.indexs && existing.correctionData.indexs.length > 0) { + const vectorIds = existing.correctionData.indexs.map((idx: any) => idx.dataId); + await deleteDatasetDataVector({ + teamId, + idList: vectorIds + }); + } + } else if (existing.correctionData.correctionMode === CorrectionModeEnum.annotate) { + // Annotate mode: remove correction indexes from dataset data + if ( + existing.correctionData.correctedQuoteList && + existing.correctionData.correctedQuoteList.length > 0 && + existing.correctionData.question?.trim() + ) { + const vectorIds = await removeCorrectionIndexes({ + correctedQuoteList: existing.correctionData.correctedQuoteList, + correctionQuestion: existing.correctionData.question || '', + session + }); + + // Delete the vectors from vector store + if (vectorIds.length > 0) { + await deleteDatasetDataVector({ + teamId, + idList: vectorIds + }); + } + } else if (!existing.correctionData.question?.trim()) { + addLog.warn('Cannot remove old correction indexes: question is empty', { + correctionId: String(existing._id) + }); + } + } + + // Update existing record + existing.correctionData = correctionData; + existing.updateTime = new Date(); + await existing.save({ session }); + correctionId = String(existing._id); + } else { + // Create new record + const [newCorrection] = await MongoChatCorrection.create( + [ + { + dataId, + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + chatId, + appId: new Types.ObjectId(appId), + correctionData + } + ], + { session } + ); + correctionId = String(newCorrection._id); + } + + // Update ChatItem if it exists (chatItem may have been deleted) + const chatItemUpdateResult = await MongoChatItem.updateOne( + { dataId }, + { + correctionId: new Types.ObjectId(correctionId) + }, + { session } + ); + + if (chatItemUpdateResult.matchedCount === 0) { + addLog.debug('ChatItem not found, skipping correctionId update', { dataId, correctionId }); + } + + // Process based on mode + if (correctionData.correctionMode === CorrectionModeEnum.edit) { + await processEditMode({ + teamId, + appId, + correctionId, + correctionData, + modelId, + session + }); + } else { + await processAnnotateMode({ + teamId, + correctionId, + correctionData, + modelId, + session + }); + } + + addLog.info('Successfully submitted chat correction', { + correctionId, + mode: correctionData.correctionMode + }); + + return correctionId; + }); +} + +/** + * Process edit mode correction - generate vectors and store in modeldata + */ +async function processEditMode({ + teamId, + appId, + correctionId, + correctionData, + modelId, + session +}: ProcessEditModeProps): Promise { + const { question, correctedAnswer } = correctionData; + + if (!correctedAnswer) { + throw new Error('correctedAnswer is required for edit mode'); + } + + // 1. Generate vectors for question and answer + const embModel = getEmbeddingModelById(modelId); + // 2. Insert into modeldata table (reuse existing interface) + // Use appId as datasetId, correctionId as collectionId + const { insertIds } = await insertDatasetDataVector({ + model: embModel, + teamId, + datasetId: appId, // ← Use appId instead of datasetId + collectionId: correctionId, // ← Use correctionId instead of collectionId + inputs: [question, correctedAnswer] + }); + + // 3. Update correction document with index references + try { + await MongoChatCorrection.updateOne( + { _id: correctionId }, + { + $set: { + 'correctionData.indexs': [ + { type: 'q', dataId: String(insertIds[0]) }, + { type: 'a', dataId: String(insertIds[1]) } + ] + } + }, + { session } + ); + } catch (error) { + // If MongoDB operation fails, clean up inserted vectors to maintain consistency + addLog.warn('MongoDB operation failed in edit mode, cleaning up inserted vectors', { + correctionId, + vectorIds: insertIds, + error: (error as Error).message + }); + try { + await deleteDatasetDataVector({ + teamId, + idList: insertIds + }); + } catch (cleanupError) { + addLog.error('Failed to cleanup vectors after MongoDB failure', { + correctionId, + vectorIds: insertIds, + error: (cleanupError as Error).message + }); + } + throw error; + } +} + +/** + * Process annotate mode correction - add correction question indexes to dataset data + */ +async function processAnnotateMode({ + teamId, + correctionId, + correctionData, + modelId, + session +}: ProcessAnnotateModeProps): Promise { + // Step 1: Store citation references in correction document + const indexs = + correctionData.correctedQuoteList?.map((quote) => ({ + type: 'c' as const, + dataId: quote.datasetDataId + })) || []; + + await MongoChatCorrection.updateOne( + { _id: correctionId }, + { $set: { 'correctionData.indexs': indexs } }, + { session } + ); + + // Step 2: Add correction question as a new index to each referenced dataset data + if (correctionData.correctedQuoteList && correctionData.correctedQuoteList.length > 0) { + const embModel = getEmbeddingModelById(modelId); + const insertedVectorIds: string[] = []; + + try { + // Process each referenced dataset data + for (const quote of correctionData.correctedQuoteList) { + const datasetData = await MongoDatasetData.findById(quote.datasetDataId).session(session); + + if (!datasetData) { + addLog.warn('Dataset data not found, skipping correction index addition', { + datasetDataId: quote.datasetDataId + }); + continue; + } + + // Generate vector for the correction question + const { insertIds } = await insertDatasetDataVector({ + model: embModel, + teamId, + datasetId: String(datasetData.datasetId), + collectionId: String(datasetData.collectionId), + inputs: [correctionData.question] + }); + + insertedVectorIds.push(insertIds[0]); + + // Add new correction index to the dataset data's indexes array + await MongoDatasetData.updateOne( + { _id: quote.datasetDataId }, + { + $push: { + indexes: { + type: DatasetDataIndexTypeEnum.correction, + dataId: insertIds[0], + text: correctionData.question + } + } + }, + { session } + ); + } + } catch (error) { + // If MongoDB operation fails, clean up inserted vectors to maintain consistency + if (insertedVectorIds.length > 0) { + addLog.warn('MongoDB operation failed, cleaning up inserted vectors', { + correctionId, + vectorIds: insertedVectorIds, + error: (error as Error).message + }); + try { + await deleteDatasetDataVector({ + teamId, + idList: insertedVectorIds + }); + } catch (cleanupError) { + addLog.error('Failed to cleanup vectors after MongoDB failure', { + correctionId, + vectorIds: insertedVectorIds, + error: (cleanupError as Error).message + }); + } + } + throw error; + } + } +} + +/** + * Helper function: Remove correction indexes from dataset data records + * @param correctionId - The correction ID to identify which indexes to remove + * @param correctedQuoteList - List of dataset data IDs that have correction indexes + * @param correctionQuestion - The correction question text to match + * @param session - MongoDB session for transaction + */ +async function removeCorrectionIndexes({ + correctedQuoteList, + correctionQuestion, + session +}: { + correctedQuoteList: Array<{ datasetDataId: string }>; + correctionQuestion: string; + session: ClientSession; +}): Promise { + if (!correctionQuestion || !correctionQuestion.trim()) { + addLog.warn('Empty correction question provided to removeCorrectionIndexes, skipping removal'); + return []; + } + + if (!correctedQuoteList || correctedQuoteList.length === 0) { + addLog.debug('No corrected quotes to remove'); + return []; + } + + const vectorIdsToDelete: string[] = []; + + for (const quote of correctedQuoteList) { + try { + // Fetch the dataset data + const datasetData = await MongoDatasetData.findById(quote.datasetDataId).session(session); + + if (!datasetData) { + addLog.warn('Dataset data not found during correction index removal', { + datasetDataId: quote.datasetDataId, + correctionQuestion + }); + continue; + } + + // Find correction indexes that match this correction's question + const correctionIndexes = + datasetData.indexes?.filter( + (idx: DatasetDataIndexItemType) => + idx.type === DatasetDataIndexTypeEnum.correction && idx.text === correctionQuestion + ) || []; + + if (correctionIndexes.length > 0) { + // Collect vector IDs for deletion + vectorIdsToDelete.push( + ...correctionIndexes.map((idx: DatasetDataIndexItemType) => idx.dataId) + ); + + // Remove these indexes from the dataset data + await MongoDatasetData.updateOne( + { _id: quote.datasetDataId }, + { + $pull: { + indexes: { + type: DatasetDataIndexTypeEnum.correction, + text: correctionQuestion + } + } + }, + { session } + ); + } + } catch (error) { + addLog.error('Failed to remove correction indexes from dataset data', { + datasetDataId: quote.datasetDataId, + error: (error as Error).message + }); + // Continue processing other quotes + continue; + } + } + + return vectorIdsToDelete; +} + +/** + * Delete a chat correction and cleanup vectors + */ +export async function deleteChatCorrection({ + teamId, + correctionId +}: { + teamId: string; + correctionId: string; +}): Promise { + // Validate required parameters + if (!teamId || !correctionId) { + throw new Error('Missing required parameters for deleteChatCorrection'); + } + + const correction = await MongoChatCorrection.findById(correctionId); + if (!correction) { + throw new Error('Correction not found'); + } + + addLog.info('Deleting chat correction', { + correctionId, + mode: correction.correctionData.correctionMode + }); + + await mongoSessionRun(async (session) => { + // 1. Delete MongoDB record + await MongoChatCorrection.deleteOne({ _id: correctionId }, { session }); + + // 2. Update ChatItem to remove correction reference + await MongoChatItem.updateOne( + { correctionId: new Types.ObjectId(correctionId) }, + { $unset: { correctionId: 1 } }, + { session } + ); + + // 3. Delete vectors and cleanup indexes based on mode + if (correction.correctionData.correctionMode === CorrectionModeEnum.edit) { + // Edit mode: delete vectors from vector store + if (correction.correctionData.indexs && correction.correctionData.indexs.length > 0) { + const vectorIds = correction.correctionData.indexs.map((idx: any) => idx.dataId); + await deleteDatasetDataVector({ + teamId, + idList: vectorIds + }); + } + } else if (correction.correctionData.correctionMode === CorrectionModeEnum.annotate) { + // Annotate mode: remove correction indexes from dataset data + if ( + correction.correctionData.correctedQuoteList && + correction.correctionData.correctedQuoteList.length > 0 + ) { + const vectorIds = await removeCorrectionIndexes({ + correctedQuoteList: correction.correctionData.correctedQuoteList, + correctionQuestion: correction.correctionData.question, + session + }); + + // Delete the vectors from vector store + if (vectorIds.length > 0) { + await deleteDatasetDataVector({ + teamId, + idList: vectorIds + }); + } + } + } + + addLog.info('Successfully deleted chat correction', { correctionId }); + }); +} diff --git a/packages/service/core/chat/correction/schema.ts b/packages/service/core/chat/correction/schema.ts new file mode 100644 index 000000000000..fba7ac032c82 --- /dev/null +++ b/packages/service/core/chat/correction/schema.ts @@ -0,0 +1,90 @@ +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +const { Schema } = connectionMongo; +import { type ChatCorrectionSchemaType } from '@fastgpt/global/core/chat/correction/type'; +import { CorrectionModeEnum } from '@fastgpt/global/core/chat/correction/constants'; +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { AppCollectionName } from '../../app/schema'; + +export const ChatCorrectionCollectionName = 'chat_corrections'; + +const ChatCorrectionSchema = new Schema({ + dataId: { + type: String, + required: true + }, + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + }, + chatId: { + type: String, + required: true + }, + appId: { + type: Schema.Types.ObjectId, + ref: AppCollectionName, + required: true + }, + correctionData: { + type: { + correctionMode: { + type: String, + enum: Object.values(CorrectionModeEnum), + required: true + }, + question: { + type: String, + required: true + }, + rawAnswer: { + type: String + }, + correctedAnswer: String, + correctedQuoteList: [ + { + datasetDataId: String, + q: String, + a: String, + sourceName: String, + updateTime: Date + } + ], + indexs: [ + { + type: { type: String, enum: ['q', 'a', 'c'] }, + dataId: String + } + ] + }, + required: true + }, + updateTime: { + type: Date, + default: () => new Date() + } +}); + +try { + ChatCorrectionSchema.index({ appId: 1 }); + ChatCorrectionSchema.index({ appId: 1, chatId: 1, dataId: 1 }); + // List corrections by chat + ChatCorrectionSchema.index({ chatId: 1, appId: 1, updateTime: -1 }); + // Cleanup by team and time + ChatCorrectionSchema.index({ teamId: 1, updateTime: -1 }); +} catch (error) { + console.log(error); +} + +export const MongoChatCorrection = getMongoModel( + ChatCorrectionCollectionName, + ChatCorrectionSchema +); diff --git a/packages/service/core/chat/resume.ts b/packages/service/core/chat/resume.ts index c951dabecb4b..6b146d19d8ff 100644 --- a/packages/service/core/chat/resume.ts +++ b/packages/service/core/chat/resume.ts @@ -224,9 +224,11 @@ const chunkToString = (chunk: string | Buffer | Uint8Array, encoding?: BufferEnc /** 新一轮流式开始前清空 Redis 镜像,否则 XADD 会接在旧 Stream 后面,续传会重复历史 */ const clearStreamResumeMirrorKeys = async (keys: StreamResumeKeys) => { + const { rawKeyOfStream } = getStreamResumeRedisRawKeys(keys); + logger.debug(`DEL key=${rawKeyOfStream}`); await Promise.all([ clearStreamResumeUnavailableState(keys), - getGlobalRedisConnection().del(keys.keyOfStream) + getGlobalRedisConnection().call('DEL', rawKeyOfStream) ]); }; @@ -239,10 +241,15 @@ export const mirrorChatStream = (params: StreamResumeRedisKeysParams) => { logger.error('Failed to clear stream resume redis keys before mirror', { params, error }); }); let lastTouchedAt = 0; + let enqueueCount = 0; const enqueueRaw = (chunk: string) => { + const currentCount = ++enqueueCount; queue = queue .then(async () => { + if (currentCount === 1) { + logger.debug(`XADD key=${rawKeys.rawKeyOfStream} (first chunk)`); + } await redis.call('XADD', rawKeys.rawKeyOfStream, '*', 'raw', chunk); const now = Date.now(); if (lastTouchedAt === 0 || now - lastTouchedAt >= STREAM_RESUME_TTL_TOUCH_INTERVAL_MS) { @@ -359,6 +366,9 @@ export const catchUpAllHistoryItems = async ({ const { rawKeyOfStream } = getStreamResumeRedisRawKeys(keys); let lastStreamId = ''; + let totalReplayed = 0; + + logger.debug(`XRANGE key=${rawKeyOfStream}`); while (!isResponseClosed(res)) { const rangeStart = lastStreamId ? `(${lastStreamId}` : '-'; @@ -378,6 +388,7 @@ export const catchUpAllHistoryItems = async ({ for (const [streamId, rawFields] of historyItems) { lastStreamId = streamId; + totalReplayed++; const fields = parseRedisStreamFields(rawFields); writeRedisStreamFields({ diff --git a/packages/service/core/chat/saveChat.ts b/packages/service/core/chat/saveChat.ts index 4d374ed42446..35106d123a30 100644 --- a/packages/service/core/chat/saveChat.ts +++ b/packages/service/core/chat/saveChat.ts @@ -57,6 +57,13 @@ export type Props = { metadata?: Record; durationSeconds: number; //s errorMsg?: string; + + // Parent app association fields (when this chat is triggered by a workflow appModule node) + parentChatId?: string; + parentResponseChatItemId?: string; + parentNodeId?: string; + parentNodeName?: string; + parentAppId?: string; }; const isSkipSaveChatId = (chatId?: string) => !chatId || chatId === 'NO_RECORD_HISTORIES'; @@ -183,9 +190,25 @@ const formatAiContent = ({ collectionId: quote.collectionId, sourceId: quote.sourceId, sourceName: quote.sourceName, - score: quote.score + score: quote.score, + retrievalRank: quote.retrievalRank }; }); + // 过滤 retrievalResults 字段(仅保留必要的元数据) + if (responseItem.retrievalResults) { + // @ts-ignore + responseItem.retrievalResults = responseItem.retrievalResults.map((result: any) => ({ + id: result.id, + chunkIndex: result.chunkIndex, + datasetId: result.datasetId, + collectionId: result.collectionId, + sourceId: result.sourceId, + sourceName: result.sourceName, + score: result.score, + tokens: result.tokens, + retrievalRank: result.retrievalRank + })); + } } }; getFlatAppResponses(responseData || []).forEach(dealResponseData); @@ -205,6 +228,22 @@ const formatAiContent = ({ }; }; +const flattenNodeResponses = ( + responses: ChatHistoryItemResType[] +): ChatHistoryItemResType[] => { + const result: ChatHistoryItemResType[] = []; + const walk = (items: ChatHistoryItemResType[]) => { + for (const item of items) { + result.push(item); + if ((item as any).childrenResponses?.length) { + walk((item as any).childrenResponses); + } + } + }; + walk(responses); + return result; +}; + const getChatDataLog = async ({ nodeResponses }: { @@ -213,14 +252,23 @@ const getChatDataLog = async ({ const now = new Date(); const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); - const errorCount = nodeResponses?.some((item) => item.errorText) ? 1 : 0; + // Flatten nested childrenResponses so agent-style parent wrappers don't hide token data + const flatResponses = nodeResponses?.length ? flattenNodeResponses(nodeResponses) : nodeResponses; + + const errorCount = flatResponses?.some((item) => item.errorText) ? 1 : 0; const totalPoints = - nodeResponses?.reduce((sum: number, item: any) => sum + (item.totalPoints || 0), 0) || 0; + flatResponses?.reduce((sum: number, item: any) => sum + (item.totalPoints || 0), 0) || 0; + const inputTokens = + flatResponses?.reduce((sum: number, item: any) => sum + (item.inputTokens || 0), 0) || 0; + const outputTokens = + flatResponses?.reduce((sum: number, item: any) => sum + (item.outputTokens || 0), 0) || 0; return { fifteenMinutesAgo, errorCount, totalPoints, + inputTokens, + outputTokens, now }; }; @@ -240,6 +288,7 @@ type PrepareChatRoundParams = Pick< > & { userContent: UserChatItemType & { dataId?: string }; responseChatItemId: string; + newTitle: string; }; type FailChatRoundParams = { @@ -299,7 +348,8 @@ export const prepareChatRound = async (params: PrepareChatRoundParams) => { sourceName, shareId, outLinkUid, - responseChatItemId + responseChatItemId, + newTitle } = params; if (isSkipSaveChatId(chatId)) return; @@ -335,6 +385,7 @@ export const prepareChatRound = async (params: PrepareChatRoundParams) => { tmbId, appId, chatId, + title: newTitle, source, sourceName, shareId, @@ -408,7 +459,12 @@ export const finalizeChatRound = async (props: Props) => { aiContent, durationSeconds, errorMsg, - metadata = {} + metadata = {}, + parentChatId, + parentResponseChatItemId, + parentNodeId, + parentNodeName, + parentAppId } = props; if (isSkipSaveChatId(chatId)) return; @@ -521,7 +577,7 @@ export const finalizeChatRound = async (props: Props) => { welcomeText, variables: variables || {}, pluginInputs, - title: newTitle, + // title: newTitle, // 改为创建时更新 source, sourceName, shareId, @@ -529,7 +585,12 @@ export const finalizeChatRound = async (props: Props) => { metadata: metadataUpdate, updateTime: now, hasBeenRead: false, - chatGenerateStatus: ChatGenerateStatusEnum.done + chatGenerateStatus: ChatGenerateStatusEnum.done, + ...(parentChatId !== undefined && { parentChatId }), + ...(parentResponseChatItemId !== undefined && { parentResponseChatItemId }), + ...(parentNodeId !== undefined && { parentNodeId }), + ...(parentNodeName !== undefined && { parentNodeName }), + ...(parentAppId !== undefined && { parentAppId }) }, ...(errorCount > 0 && { $inc: { errorCount: errorCount } }) }, @@ -554,9 +615,10 @@ export const finalizeChatRound = async (props: Props) => { }); try { - const { fifteenMinutesAgo, errorCount, totalPoints, now } = await getChatDataLog({ - nodeResponses - }); + const { fifteenMinutesAgo, errorCount, totalPoints, inputTokens, outputTokens, now } = + await getChatDataLog({ + nodeResponses + }); const userId = String(outLinkUid || tmbId); const hasHistoryChat = await MongoAppChatLog.exists({ @@ -578,6 +640,8 @@ export const finalizeChatRound = async (props: Props) => { chatItemCount: 1, errorCount, totalPoints, + inputTokens, + outputTokens, totalResponseTime: durationSeconds }, $set: { @@ -593,7 +657,8 @@ export const finalizeChatRound = async (props: Props) => { createTime: now, goodFeedbackCount: 0, badFeedbackCount: 0, - isFirstChat: !hasHistoryChat + isFirstChat: !hasHistoryChat, + ...(parentAppId !== undefined && { parentAppId }) } }, { @@ -670,7 +735,12 @@ export const pushChatRecords = async (props: Props) => { aiContent, durationSeconds, errorMsg, - metadata = {} + metadata = {}, + parentChatId, + parentResponseChatItemId, + parentNodeId, + parentNodeName, + parentAppId } = props; if (!chatId || chatId === 'NO_RECORD_HISTORIES') return; @@ -753,7 +823,12 @@ export const pushChatRecords = async (props: Props) => { shareId, outLinkUid, metadata: metadataUpdate, - updateTime: new Date() + updateTime: new Date(), + ...(parentChatId !== undefined && { parentChatId }), + ...(parentResponseChatItemId !== undefined && { parentResponseChatItemId }), + ...(parentNodeId !== undefined && { parentNodeId }), + ...(parentNodeName !== undefined && { parentNodeName }), + ...(parentAppId !== undefined && { parentAppId }) }, $setOnInsert: { createTime: new Date() @@ -784,9 +859,10 @@ export const pushChatRecords = async (props: Props) => { // Create chat data log try { - const { fifteenMinutesAgo, errorCount, totalPoints, now } = await getChatDataLog({ - nodeResponses - }); + const { fifteenMinutesAgo, errorCount, totalPoints, inputTokens, outputTokens, now } = + await getChatDataLog({ + nodeResponses + }); const userId = String(outLinkUid || tmbId); const hasHistoryChat = await MongoAppChatLog.exists({ @@ -808,6 +884,8 @@ export const pushChatRecords = async (props: Props) => { chatItemCount: 1, errorCount, totalPoints, + inputTokens, + outputTokens, totalResponseTime: durationSeconds }, $set: { @@ -823,7 +901,8 @@ export const pushChatRecords = async (props: Props) => { createTime: now, goodFeedbackCount: 0, badFeedbackCount: 0, - isFirstChat: !hasHistoryChat + isFirstChat: !hasHistoryChat, + ...(parentAppId !== undefined && { parentAppId }) } }, { @@ -1070,9 +1149,10 @@ export const updateInteractiveChat = async ({ // Push chat data logs try { - const { fifteenMinutesAgo, errorCount, totalPoints, now } = await getChatDataLog({ - nodeResponses - }); + const { fifteenMinutesAgo, errorCount, totalPoints, inputTokens, outputTokens, now } = + await getChatDataLog({ + nodeResponses + }); await MongoAppChatLog.updateOne( { @@ -1086,6 +1166,8 @@ export const updateInteractiveChat = async ({ chatItemCount: 1, errorCount, totalPoints, + inputTokens, + outputTokens, totalResponseTime: durationSeconds }, $set: { diff --git a/packages/service/core/dataset/apiDataset/buildTree.ts b/packages/service/core/dataset/apiDataset/buildTree.ts new file mode 100644 index 000000000000..812d1f6154c1 --- /dev/null +++ b/packages/service/core/dataset/apiDataset/buildTree.ts @@ -0,0 +1,99 @@ +import type { + APIFileItemType, + ApiDatasetDetailResponse +} from '@fastgpt/global/core/dataset/apiDataset/type'; +import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { getLogger, LogCategories } from '../../../common/logger'; +import { syncExternalFilePermissions } from './syncPermissions'; + +export type TreeNode = { + file: T; + children: TreeNode[]; +}; + +/** + * Build a tree from a flat list of files. + * + * @param files - Flat list of file items, each must have a unique `id` field + * @param getParentId - Resolves the parent ID for a given file. + * Returns the parent's id if the node has a parent in the list, or undefined/null/'' for root nodes. + * @returns Array of root nodes forming the tree(s) + */ +export function buildTree( + files: T[], + getParentId: (file: T) => string | undefined | null +): TreeNode[] { + const nodeMap = new Map>(); + for (const f of files) { + nodeMap.set(f.id, { file: f, children: [] }); + } + + const roots: TreeNode[] = []; + for (const f of files) { + const node = nodeMap.get(f.id)!; + const parentId = getParentId(f); + if (parentId && nodeMap.has(parentId)) { + nodeMap.get(parentId)!.children.push(node); + } else { + roots.push(node); + } + } + return roots; +} + +type ApiDatasetRequestLike = { + getFileDetail: (params: { apiFileId: string }) => Promise; +}; + +/** + * Sync external file permissions for a single collection (file or folder). + * + * Calls `getFileDetail` on the API server to fetch the latest permissions, + * then delegates to `syncExternalFilePermissions` to update the collection's + * collaborators if permissions have changed. + * + * Silently catches errors (logs a warning) so that a single permission sync + * failure does not block the overall sync process. + */ +export async function syncCollectionPermissions({ + mongoId, + file, + apiDatasetRequest, + teamId, + isPermissionSync, + datasetId +}: { + mongoId: string; + file: APIFileItemType; + apiDatasetRequest: ApiDatasetRequestLike; + teamId: string; + isPermissionSync: boolean; + datasetId: string; +}): Promise { + if (!isPermissionSync) return; + + const logger = getLogger(LogCategories.MODULE.DATASET); + + try { + const fileDetail = await apiDatasetRequest.getFileDetail({ apiFileId: file.id }); + if (fileDetail.permissions) { + await syncExternalFilePermissions({ + collection: { + _id: mongoId, + type: + file.type === 'folder' + ? DatasetCollectionTypeEnum.folder + : DatasetCollectionTypeEnum.apiFile + }, + teamId, + externalPermissions: fileDetail.permissions + }); + } + } catch (permError) { + logger.warn('Dataset sync permissions failed', { + datasetId, + fileId: file.id, + error: permError + }); + } +} diff --git a/packages/service/core/dataset/apiDataset/custom/api.ts b/packages/service/core/dataset/apiDataset/custom/api.ts index aa00ed16ebea..e31b3933a82e 100644 --- a/packages/service/core/dataset/apiDataset/custom/api.ts +++ b/packages/service/core/dataset/apiDataset/custom/api.ts @@ -170,13 +170,15 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServerTy tmbId, apiFileId, customPdfParse, - datasetId + datasetId, + parseConfig }: { teamId: string; tmbId: string; apiFileId: string; customPdfParse?: boolean; datasetId: string; + parseConfig?: Record; }): Promise => { const data = await request< { @@ -218,7 +220,8 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServerTy relatedId: apiFileId, datasetId, customPdfParse, - getFormatText: true + getFormatText: true, + parseConfig }); const sourceName = fallbackTitle; @@ -273,7 +276,8 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServerTy parentId: fileData.parentId === null ? '' : fileData.parentId, type: fileData.type, updateTime: fileData.updateTime, - createTime: fileData.createTime + createTime: fileData.createTime, + permissions: fileData.permissions }; } @@ -284,8 +288,38 @@ export const useApiDatasetRequest = ({ apiServer }: { apiServer: APIFileServerTy return fileId; }; + /** + * 获取文件内容元数据(不下载/解析),直接返回 /v1/file/content 的原始数据 + * 用于图片等不需要文本解析的场景,直接获取 previewUrl + */ + const getFileContentUrl = async ({ + apiFileId + }: { + apiFileId: string; + }): Promise<{ + title?: string; + content?: string; + previewUrl?: string; + }> => { + const data = await request< + { + title?: string; + } & RequireOnlyOne<{ + content: string; + previewUrl: string; + }> + >(`/v1/file/content`, { id: apiFileId }, 'GET'); + + return { + title: data.title, + content: (data as any).content, + previewUrl: (data as any).previewUrl + }; + }; + return { getFileContent, + getFileContentUrl, listFiles, getFilePreviewUrl, getFileDetail, diff --git a/packages/service/core/dataset/apiDataset/feishuDataset/api.ts b/packages/service/core/dataset/apiDataset/feishuDataset/api.ts index 8cc9909e9d08..9ada794a1db0 100644 --- a/packages/service/core/dataset/apiDataset/feishuDataset/api.ts +++ b/packages/service/core/dataset/apiDataset/feishuDataset/api.ts @@ -149,7 +149,12 @@ export const useFeishuDatasetRequest = ({ feishuServer }: { feishuServer: Feishu const getFileContent = async ({ apiFileId }: { + teamId?: string; + tmbId?: string; apiFileId: string; + customPdfParse?: boolean; + datasetId?: string; + parseConfig?: Record; }): Promise => { const [{ content }, { document }] = await Promise.all([ request<{ content: string }>( @@ -215,8 +220,13 @@ export const useFeishuDatasetRequest = ({ feishuServer }: { feishuServer: Feishu return fileId; }; + const getFileContentUrl = async () => { + return Promise.reject('getFileContentUrl not supported for feishu dataset'); + }; + return { getFileContent, + getFileContentUrl, listFiles, getFilePreviewUrl, getFileDetail, diff --git a/packages/service/core/dataset/apiDataset/syncPermissions.ts b/packages/service/core/dataset/apiDataset/syncPermissions.ts new file mode 100644 index 000000000000..5730ff72f74e --- /dev/null +++ b/packages/service/core/dataset/apiDataset/syncPermissions.ts @@ -0,0 +1,94 @@ +import { PerResourceTypeEnum, OwnerRoleVal } from '@fastgpt/global/support/permission/constant'; +import type { CollaboratorItemType } from '@fastgpt/global/support/permission/collaborator'; +import type { FilePermissionType } from '@fastgpt/global/core/dataset/apiDataset/type'; +import { getResourceOwnedClbs } from '../../../support/permission/controller'; +import { replaceResourceClbs } from '../../../support/permission/inheritPermission'; +import { getCollaboratorId } from '@fastgpt/global/support/permission/utils'; +import { getTmbIdsByUsernames } from '../../../support/user/team/utils'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { MongoDatasetCollection } from '../collection/schema'; +import type { DatasetCollectionSchemaType } from '@fastgpt/global/core/dataset/type'; + +/** + * Sync external file permissions to collection collaborators. + * + * Compares the permissions from the external file system with the collection's + * current direct collaborators. If different, sets inheritPermission=false and + * replaces the collaborators with the external ones. + * + * Notes: + * - Existing owner permissions are preserved (handled by replaceResourceClbs internally). + * - Usernames that can't be resolved to tmbId are silently skipped. + * - For folder-type collections, only the collection itself is configured (no recursive sync). + */ +export async function syncExternalFilePermissions({ + collection, + teamId, + externalPermissions +}: { + collection: Pick & { + inheritPermission?: boolean; + }; + teamId: string; + externalPermissions: FilePermissionType[]; +}) { + let resolvedCollaborators: CollaboratorItemType[] = []; + + if (externalPermissions && externalPermissions.length > 0) { + // 1. Batch resolve usernames to tmbIds + const usernames = externalPermissions.map((p) => p.username); + const usernameTmbIdMap = await getTmbIdsByUsernames(usernames, teamId); + + for (const { username, permission } of externalPermissions) { + const tmbId = usernameTmbIdMap.get(username); + if (!tmbId) continue; + resolvedCollaborators.push({ tmbId, permission }); + } + } + + const oldClbs = await getResourceOwnedClbs({ + resourceType: PerResourceTypeEnum.collection, + teamId, + resourceId: String(collection._id) + }); + + // Compare non-owner collaborators only (owner is preserved by replaceResourceClbs) + const oldClbMap = new Map( + oldClbs + .filter((clb) => clb.permission !== OwnerRoleVal) + .map((clb) => [getCollaboratorId(clb), clb.permission]) + ); + const newClbMap = new Map( + resolvedCollaborators.map((clb) => [getCollaboratorId(clb), clb.permission]) + ); + + let isDifferent = oldClbMap.size !== newClbMap.size; + if (!isDifferent) { + for (const [id, perm] of newClbMap) { + if (oldClbMap.get(id) !== perm) { + isDifferent = true; + break; + } + } + } + + if (!isDifferent) return; + + await mongoSessionRun(async (session) => { + await replaceResourceClbs({ + resourceType: PerResourceTypeEnum.collection, + teamId, + resourceId: String(collection._id), + collaborators: resolvedCollaborators, + session + }); + + if (collection.inheritPermission !== false) { + await MongoDatasetCollection.updateOne( + { _id: collection._id }, + { inheritPermission: false }, + { session } + ); + } + }); +} diff --git a/packages/service/core/dataset/apiDataset/yuqueDataset/api.ts b/packages/service/core/dataset/apiDataset/yuqueDataset/api.ts index d1c038288668..3c68b07e770b 100644 --- a/packages/service/core/dataset/apiDataset/yuqueDataset/api.ts +++ b/packages/service/core/dataset/apiDataset/yuqueDataset/api.ts @@ -201,7 +201,12 @@ export const useYuqueDatasetRequest = ({ yuqueServer }: { yuqueServer: YuqueServ const getFileContent = async ({ apiFileId }: { + teamId?: string; + tmbId?: string; apiFileId: string; + customPdfParse?: boolean; + datasetId?: string; + parseConfig?: Record; }): Promise => { if (typeof apiFileId !== 'string') return Promise.reject('Invalid file id'); const [parentId, fileId] = apiFileId.split(/-(.*?)-(.*)/); @@ -329,8 +334,13 @@ export const useYuqueDatasetRequest = ({ yuqueServer }: { yuqueServer: YuqueServ } }; + const getFileContentUrl = async () => { + return Promise.reject('getFileContentUrl not supported for yuque dataset'); + }; + return { getFileContent, + getFileContentUrl, listFiles, getFilePreviewUrl, getFileDetail, diff --git a/packages/service/core/dataset/collection/adaptiveConfig.ts b/packages/service/core/dataset/collection/adaptiveConfig.ts new file mode 100644 index 000000000000..7d1ee498fdd9 --- /dev/null +++ b/packages/service/core/dataset/collection/adaptiveConfig.ts @@ -0,0 +1,152 @@ +import { getVlmModelById, getVlmModelList, getLLMModelById } from '../../ai/model'; +import { addLog } from '../../../common/system/log'; +import type { DatasetSchemaType } from '@fastgpt/global/core/dataset/type'; +import type { + CustomFileImportModeType, + CustomLinkImportModeType, + CustomEnhanceConfigType +} from '@fastgpt/global/common/system/types'; + +export type AdaptiveConfigParams = { + dataset: DatasetSchemaType; + modeConfig: CustomFileImportModeType | CustomLinkImportModeType; +}; + +export type AdaptiveAdjustment = { + field: string; + originalValue: any; + adjustedValue: any; + reason: string; +}; + +export type AdaptiveConfigResult = { + adjustedEnhanceConfig: Partial; + adjustedParseConfig: { + customPdfParse?: boolean; + }; + adjustments: AdaptiveAdjustment[]; +}; + +/** + * Check if VLM model is available + */ +function checkVlmModelAvailable(vlmModelId?: string): boolean { + // 1. Check if dataset has vlmModel configured + if (!vlmModelId) { + return false; + } + + // 2. Check if system has available VLM models + const vlmModelList = getVlmModelList(); + if (!vlmModelList || vlmModelList.length === 0) { + return false; + } + + // 3. Check if configured model is in available list + const model = getVlmModelById(vlmModelId); + return !!model; +} + +/** + * Check if Agent model (LLM) is available + */ +function checkAgentModelAvailable(agentModelId?: string): boolean { + if (!agentModelId) { + return false; + } + + // Agent model is LLM model, check if it exists in system model list + const model = getLLMModelById(agentModelId); + return !!model; +} + +/** + * Check if custom parse URL is configured + * Key validation happens at parse time (throws error if url set but key missing) + */ +function hasCustomParseUrl(): boolean { + return !!global.systemEnv?.customPdfParse?.url; +} + +function hasDoc2xKey(): boolean { + return !!global.systemEnv?.customPdfParse?.doc2xKey; +} + +/** + * Adaptively adjust import configuration based on actual system configuration + * @param params Dataset and mode configuration + * @returns Adjusted configuration and adjustment records + */ +export function adaptiveAdjustConfig(params: AdaptiveConfigParams): AdaptiveConfigResult { + const { dataset, modeConfig } = params; + const adjustments: AdaptiveAdjustment[] = []; + + // Deep copy enhanceConfig to avoid modifying original + const adjustedEnhanceConfig: Partial = { + ...modeConfig.enhanceConfig + }; + + const adjustedParseConfig: { customPdfParse?: boolean } = {}; + + // 1. Check VLM model availability, adaptively adjust image index + const hasVlmModel = checkVlmModelAvailable(dataset.vlmModelId); + if (adjustedEnhanceConfig.imageIndex && !hasVlmModel) { + adjustments.push({ + field: 'imageIndex', + originalValue: true, + adjustedValue: false, + reason: 'VLM model not configured or not available for this dataset' + }); + adjustedEnhanceConfig.imageIndex = false; + } + + // 2. Check Agent model availability, adaptively adjust AI-related indexes + const hasAgentModel = checkAgentModelAvailable(dataset.agentModelId); + + if (adjustedEnhanceConfig.autoIndexes && !hasAgentModel) { + adjustments.push({ + field: 'autoIndexes', + originalValue: true, + adjustedValue: false, + reason: 'Agent model not configured or not available for this dataset' + }); + adjustedEnhanceConfig.autoIndexes = false; + } + + if (adjustedEnhanceConfig.hypeIndexes && !hasAgentModel) { + adjustments.push({ + field: 'hypeIndexes', + originalValue: true, + adjustedValue: false, + reason: 'Agent model not configured or not available for this dataset' + }); + adjustedEnhanceConfig.hypeIndexes = false; + } + + // 3. Check PDF parsing service - auto-enable if url configured, explicit false disables it + if ('parseConfig' in modeConfig && modeConfig.parseConfig) { + const parseConfig = modeConfig.parseConfig as { customPdfParse?: boolean }; + adjustedParseConfig.customPdfParse = + parseConfig.customPdfParse !== false && (hasCustomParseUrl() || hasDoc2xKey()); + } + + return { + adjustedEnhanceConfig, + adjustedParseConfig, + adjustments + }; +} + +/** + * Log adaptive adjustments for debugging + */ +export function logAdaptiveAdjustments(datasetId: string, adjustments: AdaptiveAdjustment[]): void { + if (adjustments.length === 0) { + return; + } + + addLog.debug(`[AdaptiveConfig] Dataset ${datasetId} - Config adjustments applied:`); + adjustments.forEach((adj) => { + addLog.debug(` - ${adj.field}: ${adj.originalValue} -> ${adj.adjustedValue} (${adj.reason})`); + }); +} diff --git a/packages/service/core/dataset/collection/controller.ts b/packages/service/core/dataset/collection/controller.ts index 18e2b2180560..d2c1ed6e2b70 100644 --- a/packages/service/core/dataset/collection/controller.ts +++ b/packages/service/core/dataset/collection/controller.ts @@ -1,11 +1,13 @@ import { DatasetCollectionDataProcessModeEnum, - DatasetCollectionTypeEnum + DatasetCollectionTypeEnum, + DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { MongoDatasetCollection } from './schema'; import type { DatasetCollectionSchemaType, - DatasetSchemaType + DatasetSchemaType, + TableSchemaType } from '@fastgpt/global/core/dataset/type'; import { MongoDatasetTraining } from '../training/schema'; import { MongoDatasetData } from '../data/schema'; @@ -14,12 +16,21 @@ import { deleteDatasetDataVector } from '../../../common/vectorDB/controller'; import type { ClientSession } from '../../../common/mongo'; import { createOrGetCollectionTags } from './utils'; import { rawText2Chunks } from '../read'; +import { + parseDatasetBackupFromFileViaWorker, + countDatasetBackupFromFileViaWorker +} from '../../../worker/function'; import { checkDatasetIndexLimit } from '../../../support/permission/teamLimit'; import { predictDataLimitLength } from '../../../../global/core/dataset/utils'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { createTrainingUsage } from '../../../support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { getLLMModel, getEmbeddingModel, getVlmModel } from '../../ai/model'; +import { + getLLMModelById, + getEmbeddingModelById, + getVlmModelById, + getRerankModelById +} from '../../ai/model'; import { pushDataListToTrainingQueue, pushDatasetToParseQueue } from '../training/controller'; import { hashStr } from '@fastgpt/global/common/string/tools'; import { MongoDatasetDataText } from '../data/dataTextSchema'; @@ -32,10 +43,71 @@ import { import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { getS3DatasetSource } from '../../../common/s3/sources/dataset'; import { removeS3TTL, isS3ObjectKey } from '../../../common/s3/utils'; +import { createDataDrafts } from '../data/controller'; +import { + DBDatasetVectorTableName, + DBDatasetValueVectorTableName, + DatasetVectorTableName +} from '../../../common/vectorDB/constants'; +import { addLog } from '../../../common/system/log'; import type { CreateCollectionWithResultResponseType, ApiCreateDatasetCollectionParams } from '@fastgpt/global/openapi/core/dataset/collection/createApi'; +import { + PermissionEffectScopeEnum, + OwnerRoleVal, + ManageRoleVal, + PerResourceTypeEnum +} from '@fastgpt/global/support/permission/constant'; +import { MongoDataset } from '../schema'; +import { MongoResourcePermission } from '../../../support/permission/schema'; +import { + getResourceOwnedClbs, + getDatasetEffectiveClbs +} from '../../../support/permission/controller'; +import { pickCollaboratorIdFields } from '../../../support/permission/utils'; +import type { AnyBulkWriteOperation } from '../../../common/mongo'; +import type { ResourcePermissionType } from '@fastgpt/global/support/permission/type'; +import { + syncCollaborators, + syncChildrenPermission, + replaceResourceClbs, + type SyncChildrenPermissionResourceType +} from '../../../support/permission/inheritPermission'; +import type { CollaboratorItemType } from '@fastgpt/global/support/permission/collaborator'; +import { UserError } from '@fastgpt/global/common/error/utils'; + +/* find all collectionId by top collectionId */ +export async function findCollectionAndAllChildren({ + teamId, + collectionId, + fields +}: { + teamId: string; + collectionId: string; + fields?: string; +}): Promise { + const rootCollection = await MongoDatasetCollection.findById(collectionId, fields).lean(); + if (!rootCollection) { + return Promise.reject(new UserError('Collection not found')); + } + + const allCollections: DatasetCollectionSchemaType[] = [rootCollection]; + let currentQueue = [collectionId]; + + // BFS:每层一次批量查询,复杂度从 O(N) 降为 O(depth) + while (currentQueue.length > 0) { + const children = await MongoDatasetCollection.find( + { teamId, parentId: { $in: currentQueue }, deleteTime: null }, + fields + ).lean(); + allCollections.push(...children); + currentQueue = children.map((c) => String(c._id)); + } + + return allCollections; +} export const createCollectionAndInsertData = async ({ dataset, @@ -44,7 +116,9 @@ export const createCollectionAndInsertData = async ({ createCollectionParams, backupParse = false, billId, - session + session, + filePath, + fileExtension }: { dataset: DatasetSchemaType; rawText?: string; @@ -55,7 +129,14 @@ export const createCollectionAndInsertData = async ({ billId?: string; session?: ClientSession; + filePath?: string; + fileExtension?: string; }): Promise => { + addLog.debug('[createCollectionAndInsertData] Input params', { + rawTextLength: rawText?.length, + imageIdsCount: imageIds?.length, + collectionType: createCollectionParams.type + }); // Adapter 4.9.0 if (createCollectionParams.trainingType === DatasetCollectionDataProcessModeEnum.auto) { createCollectionParams.trainingType = DatasetCollectionDataProcessModeEnum.chunk; @@ -64,8 +145,8 @@ export const createCollectionAndInsertData = async ({ const formatCreateCollectionParams = computedCollectionChunkSettings({ ...createCollectionParams, - llmModel: getLLMModel(dataset.agentModel), - vectorModel: getEmbeddingModel(dataset.vectorModel) + llmModel: getLLMModelById(dataset.agentModelId), + vectorModel: getEmbeddingModelById(dataset.vectorModelId) }); const teamId = formatCreateCollectionParams.teamId; @@ -74,10 +155,12 @@ export const createCollectionAndInsertData = async ({ // Set default params const trainingType = formatCreateCollectionParams.trainingType || DatasetCollectionDataProcessModeEnum.chunk; + const trainingMode = getTrainingModeByCollection({ trainingType: trainingType, autoIndexes: formatCreateCollectionParams.autoIndexes, - imageIndex: formatCreateCollectionParams.imageIndex + imageIndex: formatCreateCollectionParams.imageIndex, + small2bigIndexes: formatCreateCollectionParams.small2bigIndexes }); if ( @@ -89,27 +172,41 @@ export const createCollectionAndInsertData = async ({ delete formatCreateCollectionParams.chunkTriggerMinSize; delete formatCreateCollectionParams.dataEnhanceCollectionName; delete formatCreateCollectionParams.imageIndex; + } + + if ( + trainingType === DatasetCollectionDataProcessModeEnum.qa || + trainingType === DatasetCollectionDataProcessModeEnum.backup + ) { delete formatCreateCollectionParams.autoIndexes; + delete formatCreateCollectionParams.hypeIndexes; + delete formatCreateCollectionParams.hypeIndexPrompt; + } - if ( - trainingType === DatasetCollectionDataProcessModeEnum.backup || - trainingType === DatasetCollectionDataProcessModeEnum.template - ) { - delete formatCreateCollectionParams.paragraphChunkAIMode; - delete formatCreateCollectionParams.paragraphChunkDeep; - delete formatCreateCollectionParams.paragraphChunkMinSize; - delete formatCreateCollectionParams.chunkSplitMode; - delete formatCreateCollectionParams.chunkSize; - delete formatCreateCollectionParams.chunkSplitter; - delete formatCreateCollectionParams.indexSize; - delete formatCreateCollectionParams.indexPrefixTitle; - } + if ( + trainingType === DatasetCollectionDataProcessModeEnum.backup || + trainingType === DatasetCollectionDataProcessModeEnum.template + ) { + delete formatCreateCollectionParams.paragraphChunkAIMode; + delete formatCreateCollectionParams.paragraphChunkDeep; + delete formatCreateCollectionParams.paragraphChunkMinSize; + delete formatCreateCollectionParams.chunkSplitMode; + delete formatCreateCollectionParams.chunkSize; + delete formatCreateCollectionParams.chunkSplitter; + } + + if (trainingType === DatasetCollectionDataProcessModeEnum.backup) { + delete formatCreateCollectionParams.indexSize; } if (trainingType !== DatasetCollectionDataProcessModeEnum.qa) { delete formatCreateCollectionParams.qaPrompt; } // 1. split chunks or create image chunks + // 当 filePath 存在时,Worker 直接读文件解析,同时返回 hashRawText 和 rawTextLength + let hashRawText: string | undefined; + let rawTextLength: number | undefined; + const { chunks, chunkSize, @@ -120,10 +217,26 @@ export const createCollectionAndInsertData = async ({ a?: string; // answer or custom content imageId?: string; indexes?: string[]; + metadata?: Record; + id?: string; }>; chunkSize?: number; indexSize?: number; } = await (async () => { + // Worker 直接读文件统计行数:主线程零阻塞,同时获取 hash 和 length + // 使用 countDatasetBackupFromFileViaWorker 替代 parseDatasetBackupFromFileViaWorker, + // IPC 仅传回 {chunkCount, hashRawText, rawTextLength},消除 200k 对象 structured clone 开销。 + if (backupParse && filePath && fileExtension) { + const result = await countDatasetBackupFromFileViaWorker(filePath, fileExtension); + hashRawText = result.hashRawText; + rawTextLength = result.rawTextLength; + return { + chunks: Array(result.chunkCount).fill({}), + chunkSize: formatCreateCollectionParams.chunkSize, + indexSize: formatCreateCollectionParams.indexSize + }; + } + if (rawText) { // Process text chunks const chunks = await rawText2Chunks({ @@ -133,7 +246,7 @@ export const createCollectionAndInsertData = async ({ chunkSize: formatCreateCollectionParams.chunkSize, paragraphChunkDeep: formatCreateCollectionParams.paragraphChunkDeep, paragraphChunkMinSize: formatCreateCollectionParams.paragraphChunkMinSize, - maxSize: getLLMMaxChunkSize(getLLMModel(dataset.agentModel)), + maxSize: getLLMMaxChunkSize(getLLMModelById(dataset.agentModelId)), overlapRatio: trainingType === DatasetCollectionDataProcessModeEnum.chunk ? 0.2 : 0, customReg: formatCreateCollectionParams.chunkSplitter ? [formatCreateCollectionParams.chunkSplitter] @@ -171,14 +284,19 @@ export const createCollectionAndInsertData = async ({ const fn = async (session: ClientSession): Promise => { // 3. Create collection + const isPermissionSyncDataset = + dataset.type === DatasetTypeEnum.apiDataset && + !!(dataset.apiDatasetServer as any)?.apiServer?.permissionSync; + const { _id: collectionId } = await createOneCollection({ ...formatCreateCollectionParams, trainingType, chunkSize, indexSize, - hashRawText: rawText ? hashStr(rawText) : undefined, - rawTextLength: rawText?.length, + hashRawText: hashRawText || (rawText ? hashStr(rawText) : undefined), + rawTextLength: rawTextLength ?? rawText?.length, + skipPermissionCreate: isPermissionSyncDataset, session }); @@ -190,9 +308,10 @@ export const createCollectionAndInsertData = async ({ tmbId, appName: formatCreateCollectionParams.name, billSource: UsageSourceEnum.training, - vectorModel: getEmbeddingModel(dataset.vectorModel)?.name, - agentModel: getLLMModel(dataset.agentModel)?.name, - vllmModel: getVlmModel(dataset.vlmModel)?.name, + vectorModelId: getEmbeddingModelById(dataset.vectorModelId)?.id, + agentModelId: getLLMModelById(dataset.agentModelId)?.id, + vlmModelId: getVlmModelById(dataset.vlmModelId)?.id, + rerankModelId: getRerankModelById()?.id, session }); return newUsageId; @@ -200,15 +319,48 @@ export const createCollectionAndInsertData = async ({ // 5. insert to training queue const insertResults = await (async () => { + addLog.debug('[createCollectionAndInsertData] Checking training queue path', { + hasRawText: !!rawText, + hasImageIds: !!imageIds, + chunksCount: chunks.length + }); + if (rawText || imageIds) { + addLog.debug('[createCollectionAndInsertData] Creating Data drafts before training queue'); + + // Pre-create Data drafts using the shared batch helper (same logic as createDataDraft + // but with bulk inserts for efficiency). + const draftResults = await createDataDrafts({ + items: chunks.map((item, i) => ({ + q: item.q || '', + a: item.a || '', + imageId: item.imageId, + chunkIndex: i, + metadata: item.metadata + })), + teamId, + tmbId, + datasetId: dataset._id, + collectionId, + session + }); + + // Associate dataId with chunks → Training records carry dataId + draftResults.forEach((result, i) => { + chunks[i].id = String(result._id); + }); + + addLog.debug('[createCollectionAndInsertData] Pushing to data training queue', { + draftCount: draftResults.length + }); return pushDataListToTrainingQueue({ teamId, tmbId, datasetId: dataset._id, collectionId, - agentModel: dataset.agentModel, - vectorModel: dataset.vectorModel, - vlmModel: dataset.vlmModel, + agentModelId: dataset.agentModelId, + vectorModelId: dataset.vectorModelId, + vlmModelId: dataset.vlmModelId, indexSize, mode: trainingMode, billId: traingUsageId, @@ -223,14 +375,21 @@ export const createCollectionAndInsertData = async ({ session }); } else { + addLog.debug('[createCollectionAndInsertData] Pushing to parse queue'); + const gpuParseExts = global.systemEnv?.gpuParseFileExtensions || ['.pdf']; + const useGpuQueue = gpuParseExts.some((ext) => + (formatCreateCollectionParams.name || '').toLowerCase().endsWith(ext.toLowerCase()) + ); await pushDatasetToParseQueue({ teamId, tmbId, datasetId: dataset._id, collectionId, billId: traingUsageId, + useGpuQueue, session }); + addLog.debug('[createCollectionAndInsertData] Successfully pushed to parse queue'); return { insertLen: 0 }; @@ -264,9 +423,13 @@ export type CreateOneCollectionParams = ApiCreateDatasetCollectionParams & { apiFileParentId?: string; rawTextLength?: number; hashRawText?: string; + fileMd5?: string; createTime?: Date; updateTime?: Date; + tableSchema?: TableSchemaType; + forbid?: boolean; session?: ClientSession; + skipPermissionCreate?: boolean; }; export async function createOneCollection({ session, ...props }: CreateOneCollectionParams) { const { @@ -280,7 +443,10 @@ export async function createOneCollection({ session, ...props }: CreateOneCollec externalFileId, externalFileUrl, apiFileId, - apiFileParentId + apiFileParentId, + tableSchema, + forbid, + skipPermissionCreate } = props; const collectionTags = await createOrGetCollectionTags({ @@ -290,6 +456,31 @@ export async function createOneCollection({ session, ...props }: CreateOneCollec session }); + // Determine inheritPermission based on parent's permissionEffectScope + let inheritPermission = true; + if (skipPermissionCreate) { + inheritPermission = false; + } else if (parentId) { + // Parent is a collection + const parentCollection = await MongoDatasetCollection.findOne( + { _id: parentId }, + 'permissionEffectScope' + ) + .lean() + .session(session ?? null); + if (parentCollection?.permissionEffectScope === PermissionEffectScopeEnum.currentOnly) { + inheritPermission = false; + } + } else { + // Parent is the dataset + const parentDataset = await MongoDataset.findOne({ _id: datasetId }, 'permissionEffectScope') + .lean() + .session(session ?? null); + if (parentDataset?.permissionEffectScope === PermissionEffectScopeEnum.currentOnly) { + inheritPermission = false; + } + } + // Create collection const [collection] = await MongoDatasetCollection.create( [ @@ -301,12 +492,16 @@ export async function createOneCollection({ session, ...props }: CreateOneCollec tags: collectionTags, + inheritPermission, + ...(fileId ? { fileId } : {}), ...(rawLink ? { rawLink } : {}), ...(externalFileId ? { externalFileId } : {}), ...(externalFileUrl ? { externalFileUrl } : {}), ...(apiFileId ? { apiFileId } : {}), - ...(apiFileParentId ? { apiFileParentId } : {}) + ...(apiFileParentId ? { apiFileParentId } : {}), + ...(tableSchema ? { tableSchema } : {}), + forbid: forbid ?? false } ], { session, ordered: true } @@ -316,6 +511,65 @@ export async function createOneCollection({ session, ...props }: CreateOneCollec await removeS3TTL({ key: fileId, bucketName: 'private', session }); } + if (!props.skipPermissionCreate) { + if (inheritPermission && props.type === DatasetCollectionTypeEnum.folder) { + // folder 类型:继承父级协作者,并将创建者设为 owner + // 父级可能是另一个 collection(有 parentId)或 dataset(无 parentId) + // dataset 需要获取有效权限(含继承链),collection 直接取协作者 + const parentClbs = parentId + ? await getResourceOwnedClbs({ + resourceId: parentId, + resourceType: PerResourceTypeEnum.collection, + teamId, + session + }) + : await getDatasetEffectiveClbs({ + datasetId, + teamId, + session + }); + + const collaborators = [ + ...parentClbs + .filter((clb) => clb.tmbId !== props.tmbId) + .map((clb) => { + if (clb.permission === OwnerRoleVal) clb.permission = ManageRoleVal; + return clb; + }), + { tmbId: props.tmbId, permission: OwnerRoleVal } + ]; + + const ops: AnyBulkWriteOperation[] = collaborators.map((clb) => ({ + updateOne: { + filter: { + ...pickCollaboratorIdFields(clb), + teamId, + resourceId: collection._id, + resourceType: PerResourceTypeEnum.collection + }, + update: { $set: { permission: clb.permission } }, + upsert: true + } + })); + + await MongoResourcePermission.bulkWrite(ops, { session }); + } else { + // 为创建者写入 owner 权限 + await MongoResourcePermission.create( + [ + { + teamId, + tmbId: props.tmbId, + resourceId: collection._id, + permission: OwnerRoleVal, + resourceType: PerResourceTypeEnum.collection + } + ], + { session } + ); + } + } + return collection; } @@ -433,7 +687,24 @@ export async function delCollection({ ] : []), // Delete vector data - deleteDatasetDataVector({ teamId, datasetIds, collectionIds }) + deleteDatasetDataVector({ + teamId, + datasetIds, + collectionIds, + tableName: DatasetVectorTableName + }), + deleteDatasetDataVector({ + teamId, + datasetIds, + collectionIds, + tableName: DBDatasetVectorTableName + }), + deleteDatasetDataVector({ + teamId, + datasetIds, + collectionIds, + tableName: DBDatasetValueVectorTableName + }) ]); // delete collections @@ -449,3 +720,56 @@ export async function delCollection({ await s3DatasetSource.deleteDatasetFilesByKeys(imageIds); }); } + +/** + * 同步 Dataset 权限到其下属所有根级 Folder Collection,并递归传播到子 Collection。 + * 在 Dataset 移动或协作者权限变更后调用。 + */ +export async function syncDatasetFolderCollectionPermissions({ + datasetId, + teamId, + collaborators, + session +}: { + datasetId: string; + teamId: string; + collaborators: CollaboratorItemType[]; + session: ClientSession; +}) { + const rootFolderCollections = await MongoDatasetCollection.find( + { + datasetId, + type: DatasetCollectionTypeEnum.folder, + parentId: null, + inheritPermission: true + }, + '_id type teamId parentId' + ) + .lean() + .session(session); + + for (const folderCollection of rootFolderCollections) { + await replaceResourceClbs({ + teamId, + resourceId: String(folderCollection._id), + resourceType: PerResourceTypeEnum.collection, + collaborators, + session + }); + + await syncChildrenPermission({ + resource: { + _id: String(folderCollection._id), + type: folderCollection.type, + teamId, + parentId: undefined + }, + folderTypeList: [DatasetCollectionTypeEnum.folder], + resourceType: PerResourceTypeEnum.collection, + resourceModel: MongoDatasetCollection, + collaborators, + additionalFilter: { datasetId }, + session + }); + } +} diff --git a/packages/service/core/dataset/collection/delete/index.ts b/packages/service/core/dataset/collection/delete/index.ts new file mode 100644 index 000000000000..8edb13547b60 --- /dev/null +++ b/packages/service/core/dataset/collection/delete/index.ts @@ -0,0 +1,50 @@ +import crypto from 'crypto'; +import { getQueue, getWorker, QueueNames } from '../../../../common/bullmq'; +import { collectionDeleteProcessor } from './processor'; + +export type CollectionDeleteJobData = { + teamId: string; + collectionIds: string[]; +}; + +// 创建工作进程 +export const initCollectionDeleteWorker = () => { + return getWorker( + QueueNames.collectionDelete, + collectionDeleteProcessor, + { + concurrency: 1, // 确保同时只有1个删除任务 + removeOnFail: { + age: 90 * 24 * 60 * 60, // 保留90天失败记录 + count: 10000 // 最多保留10000个失败任务 + } + } + ); +}; + +// 添加删除任务 +export const addCollectionDeleteJob = (data: CollectionDeleteJobData) => { + const collectionDeleteQueue = getQueue(QueueNames.collectionDelete, { + defaultJobOptions: { + attempts: 10, + backoff: { + type: 'exponential', + delay: 5000 + }, + removeOnComplete: true, + removeOnFail: { age: 30 * 24 * 60 * 60 } // 保留30天失败记录 + } + }); + + // 使用 teamId + 所有 collectionIds 的 hash 作为 jobId 去重,避免重复入队 + const idsHash = crypto + .createHash('md5') + .update(data.collectionIds.slice().sort().join(',')) + .digest('hex'); + const jobId = `${String(data.teamId)}-${idsHash}`; + + return collectionDeleteQueue.add('delete_collection', data, { + jobId, + delay: 1000 // 延迟1秒执行,确保API响应完成 + }); +}; diff --git a/packages/service/core/dataset/collection/delete/processor.ts b/packages/service/core/dataset/collection/delete/processor.ts new file mode 100644 index 000000000000..5de405d439ce --- /dev/null +++ b/packages/service/core/dataset/collection/delete/processor.ts @@ -0,0 +1,118 @@ +import type { Processor } from 'bullmq'; +import type { CollectionDeleteJobData } from './index'; +import type { DatasetCollectionSchemaType } from '@fastgpt/global/core/dataset/type'; +import { addCollectionDeleteJob } from './index'; +import { delCollection } from '../controller'; +import { MongoDatasetCollection } from '../schema'; +import { MongoDatasetTraining } from '../../training/schema'; +import { addLog } from '../../../../common/system/log'; +import { mongoSessionRun } from '../../../../common/mongo/sessionRun'; + +// 快速清理:立即删除训练数据(API 调用时同步执行,避免继续消耗资源) +export const deleteCollectionsImmediate = async ({ + teamId, + datasetIds, + collectionIds +}: { + teamId: string; + datasetIds: string[]; + collectionIds: string[]; +}) => { + await MongoDatasetTraining.deleteMany({ + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIds } + }); +}; + +// BullMQ 处理器 +export const collectionDeleteProcessor: Processor = async (job) => { + const { teamId, collectionIds } = job.data; + const startTime = Date.now(); + + addLog.info( + `[Collection Delete] Start deleting ${collectionIds.length} collections for team: ${teamId}` + ); + + try { + // 1. 安全检查:确保所有要删除的 collection 都已标记 deleteTime + const markedForDelete = await MongoDatasetCollection.find( + { + _id: { $in: collectionIds }, + teamId, + deleteTime: { $ne: null } + }, + '_id teamId type datasetId fileId metadata' + ).lean(); + + if (markedForDelete.length === 0) { + addLog.warn(`[Collection Delete] No collections marked for deletion`); + return; + } + + if (markedForDelete.length !== collectionIds.length) { + addLog.warn( + `[Collection Delete] Safety check: ${markedForDelete.length}/${collectionIds.length} collections marked for deletion` + ); + } + + // 2. 复用现有的 delCollection 进行实际删除 + await mongoSessionRun((session) => + delCollection({ + collections: markedForDelete, + delImg: true, + delFile: true, + session + }) + ); + + addLog.info(`[Collection Delete] Successfully deleted ${markedForDelete.length} collections`, { + duration: Date.now() - startTime, + collectionIds + }); + } catch (error: any) { + addLog.error(`[Collection Delete] Failed to delete collections`, error); + throw error; // 抛出错误让 BullMQ 重试 + } +}; + +// 孤儿软删除清理:扫描 deleteTime 超过阈值但未被物理删除的 collection,重新入队 +// threshold: 距 deleteTime 的最小小时数,避免误清理正在处理中的任务 +export const collectionCleanup = async (thresholdHours = 24) => { + const cutoff = new Date(Date.now() - thresholdHours * 60 * 60 * 1000); + + // 只查询明确有 deleteTime 且超过阈值的 collection + const orphans = await MongoDatasetCollection.find( + { + deleteTime: { $ne: null, $lt: cutoff } + }, + '_id teamId' + ) + .limit(500) // 单次最多处理500条,避免内存问题 + .lean(); + + if (orphans.length === 0) return; + + addLog.warn( + `[Collection Delete] Found ${orphans.length} orphan soft-deleted collections, re-enqueuing` + ); + + // 按 teamId 分组,每组单独入队 + const grouped = new Map(); + for (const col of orphans) { + const key = String(col.teamId); + if (!grouped.has(key)) { + grouped.set(key, { teamId: key, ids: [] }); + } + grouped.get(key)!.ids.push(String(col._id)); + } + + await Promise.all( + Array.from(grouped.values()).map((group) => + addCollectionDeleteJob({ + teamId: group.teamId, + collectionIds: group.ids + }) + ) + ); +}; diff --git a/packages/service/core/dataset/collection/mq.ts b/packages/service/core/dataset/collection/mq.ts index e257ec7ff1f3..b28b08b56630 100644 --- a/packages/service/core/dataset/collection/mq.ts +++ b/packages/service/core/dataset/collection/mq.ts @@ -1,9 +1,84 @@ import { getWorker, QueueNames, getQueue, type Job } from '../../../common/bullmq'; import { MongoDatasetCollection } from './schema'; +import { MongoDatasetData } from '../data/schema'; +import { MongoDatasetTraining } from '../training/schema'; +import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { Types } from '../../../common/mongo'; import { getLogger, LogCategories } from '../../../common/logger'; const logger = getLogger(LogCategories.MODULE.DATASET.COLLECTION); +export type TrainingAggregationResult = { + count: number; + hasError: boolean; + allParse: boolean; + errorCount: number; +}; + +export type DataAggregationResult = { + count: number; + processedCount: number; +}; + +/** + * Build the aggregation pipeline for computing training stats from dataset_trainings. + * Returns { count, hasError, allParse, errorCount } for the given collection scope. + * + * hasError / errorCount: only terminal errors (retryCount <= 0 + errorMsg) count + * allParse: true only if ALL training records (regardless of error state) are parse mode + */ +export const buildTrainingAggregationPipeline = ( + teamId: Types.ObjectId, + datasetId: Types.ObjectId, + collectionId: Types.ObjectId +) => [ + { $match: { teamId, datasetId, collectionId } }, + { + $group: { + _id: null, + count: { $sum: 1 }, + hasError: { + $max: { + $cond: [ + { $and: [{ $ifNull: ['$errorMsg', false] }, { $lte: ['$retryCount', 0] }] }, + true, + false + ] + } + }, + allParse: { + $min: { $eq: ['$mode', TrainingModeEnum.parse] } + }, + errorCount: { + $sum: { + $cond: [{ $and: [{ $ifNull: ['$errorMsg', false] }, { $lte: ['$retryCount', 0] }] }, 1, 0] + } + } + } + } +]; + +/** + * Build the aggregation pipeline for computing data stats from dataset_datas. + * Returns { count, processedCount } for the given collection scope. + */ +export const buildDataAggregationPipeline = ( + teamId: Types.ObjectId, + datasetId: Types.ObjectId, + collectionId: Types.ObjectId +) => [ + { $match: { teamId, datasetId, collectionId } }, + { + $group: { + _id: null, + count: { $sum: 1 }, + processedCount: { + $sum: { $cond: [{ $ifNull: ['$indexingCompleteTime', false] }, 1, 0] } + } + } + } +]; + export type CollectionUpdateJobData = { teamId: string; datasetId: string; @@ -21,21 +96,48 @@ export const initCollectionUpdateWorker = () => { const { collectionId } = job.data; try { - // Update collection updateTime and other operations + const teamIdObj = new Types.ObjectId(job.data.teamId); + const datasetIdObj = new Types.ObjectId(job.data.datasetId); + const collectionIdObj = new Types.ObjectId(collectionId); + + // 1. Aggregate dataset_datas: count + processedCount + // 2. Aggregate dataset_trainings: count + hasError + allParse + // Run both in parallel since they query different collections + const [[dataResult], [trainingResult]] = await Promise.all([ + MongoDatasetData.aggregate( + buildDataAggregationPipeline(teamIdObj, datasetIdObj, collectionIdObj) + ), + MongoDatasetTraining.aggregate( + buildTrainingAggregationPipeline(teamIdObj, datasetIdObj, collectionIdObj) + ) + ]); + + const dataCount = dataResult?.count || 0; + const processedCount = dataResult?.processedCount || 0; + await MongoDatasetCollection.updateOne( { _id: collectionId }, { $set: { + dataAmount: dataCount, + trainingAmount: trainingResult?.count || 0, + processedCount: processedCount, + remainingCount: dataCount - processedCount, + hasError: trainingResult?.hasError || false, + errorCount: trainingResult?.errorCount || 0, + allParse: trainingResult ? trainingResult.allParse : true, + statsUpdatedAt: new Date(), updateTime: new Date() - // TODO: 更新统计数据 } } ); - logger.debug('Collection updated', { - collectionId + logger.debug('Collection stats updated', { + collectionId, + dataAmount: dataCount, + trainingAmount: trainingResult?.count || 0 }); } catch (error) { logger.error('Failed to update collection', { @@ -58,17 +160,21 @@ export const initCollectionUpdateWorker = () => { ); logger.info('Collection Update worker initialized'); + + // 存量数据回填(fire-and-forget,不阻塞 worker 返回) + enqueueBackfillCollectionStatsJobs().catch((err) => { + logger.error('Backfill collection stats failed', { error: err }); + }); + return worker; }; /** * Push collection update job to queue with debounce - * @param collectionId - Collection ID - * @param datasetId - Dataset ID - * @param teamId - Team ID - * @param delay - Delay in milliseconds (default: 5000ms = 5s) + * @param data - Job data containing teamId, datasetId, collectionId + * @param delay - Delay in milliseconds (default: 5000ms = 5s). Pass 0 for backfill. */ -export const pushCollectionUpdateJob = async (data: CollectionUpdateJobData) => { +export const pushCollectionUpdateJob = async (data: CollectionUpdateJobData, delay = 5000) => { const queue = getQueue(QueueNames.collectionUpdate); // Use jobId to ensure only one job per collection (debounce mechanism) @@ -78,11 +184,13 @@ export const pushCollectionUpdateJob = async (data: CollectionUpdateJobData) => try { await queue.add('updateCollection', data, { jobId, // Unique job ID for debounce - delay: 5000 // Delay execution by 5 seconds + delay, + attempts: 3 // 限制重试 3 次,避免无限重试占用 worker 槽位 }); logger.debug('Collection update job pushed', { - collectionId: data.collectionId + collectionId: data.collectionId, + delay }); } catch (error) { logger.error('Failed to push collection update job', { @@ -91,3 +199,53 @@ export const pushCollectionUpdateJob = async (data: CollectionUpdateJobData) => }); } }; + +/** + * Enqueue stats update jobs for all non-deleted, non-folder collections. + * Use this to backfill stats for existing data after the stats fields are added. + * + * @param batchSize - Number of collections to process per batch (default 500) + */ +export const enqueueBackfillCollectionStatsJobs = async (batchSize = 500) => { + let offset = 0; + let enqueued = 0; + + while (true) { + const collections = await MongoDatasetCollection.find( + { + deleteTime: null, + type: { $ne: 'folder' }, + statsUpdatedAt: { $exists: false } // 只回填尚未初始化 stats 的 collection + }, + { _id: 1, teamId: 1, datasetId: 1 } + ) + .skip(offset) + .limit(batchSize) + .lean(); + + if (collections.length === 0) break; + + for (const col of collections) { + await pushCollectionUpdateJob( + { + collectionId: String(col._id), + datasetId: String(col.datasetId), + teamId: String(col.teamId) + }, + 0 // Skip debounce delay for backfill + ); + enqueued++; + } + + offset += batchSize; + logger.info('Backfill collection stats progress', { enqueued, offset }); + + // 批次间短暂等待,避免瞬间向 Redis 涌入大量 job + if (collections.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + logger.info('Backfill collection stats complete', { totalEnqueued: enqueued }); + return { totalEnqueued: enqueued }; +}; diff --git a/packages/service/core/dataset/collection/schema.ts b/packages/service/core/dataset/collection/schema.ts index f1664dfa4974..6294bad8a703 100644 --- a/packages/service/core/dataset/collection/schema.ts +++ b/packages/service/core/dataset/collection/schema.ts @@ -8,9 +8,53 @@ import { TeamCollectionName, TeamMemberCollectionName } from '@fastgpt/global/support/user/team/constant'; +import { PermissionEffectScopeEnum } from '@fastgpt/global/support/permission/constant'; export const DatasetColCollectionName = 'dataset_collections'; +// Column Schema for database tables +const ColumnSchema = new Schema( + { + columnName: { type: String, required: true }, + columnType: { type: String, default: 'TEXT' }, + description: { type: String, default: '' }, + examples: { type: [String], default: [] }, + forbid: { type: Boolean, default: false }, + valueIndex: { type: Boolean, default: true }, + + // Database attributes + isNullable: { type: Boolean, default: true }, + defaultValue: { type: String, default: null }, + isAutoIncrement: { type: Boolean, default: false }, + isPrimaryKey: { type: Boolean, default: false }, + isForeignKey: { type: Boolean, default: false }, + relatedColumns: { type: [String], default: [] }, + + // Extended metadata + metadata: { type: Object, default: {} } + }, + { _id: false } +); + +// Constraint Schema +const ConstraintSchema = new Schema( + { + name: { type: String, required: true }, + column: { type: String, default: '' } + }, + { _id: false } +); + +// Foreign Key Schema +const ForeignKeySchema = new Schema( + { + referredSchema: { type: String }, + referredTable: { type: String, required: true }, + referredColumns: { type: String, required: true } + }, + { _id: false } +).add(ConstraintSchema); + const DatasetCollectionSchema = new Schema({ parentId: { type: Schema.Types.ObjectId, @@ -44,7 +88,7 @@ const DatasetCollectionSchema = new Schema({ required: true }, tags: { - type: [String], + type: [], default: [] }, @@ -56,7 +100,33 @@ const DatasetCollectionSchema = new Schema({ type: Date, default: () => new Date() }, - + parsingCompleteTime: { + type: Date + }, + parseStartTime: { + type: Date + }, + indexingStartTime: { + type: Date + }, + indexingCompleteTime: { + type: Date + }, + tableSchema: { + type: { + tableName: { type: String, required: true }, + description: { type: String, default: '' }, + exist: { type: Boolean, default: true }, + columns: { + type: Schema.Types.Mixed, + default: {} + }, + foreignKeys: { type: [ForeignKeySchema], default: [] }, + primaryKeys: { type: [String], default: [] }, + constraints: { type: [ConstraintSchema], default: [] }, + lastUpdated: { type: Date, default: Date.now } + } + }, // Metadata // local file collection // Support both GridFS ObjectId (string) and S3 key (string) @@ -71,6 +141,7 @@ const DatasetCollectionSchema = new Schema({ rawTextLength: Number, hashRawText: String, + fileMd5: String, metadata: { type: Object, @@ -79,6 +150,57 @@ const DatasetCollectionSchema = new Schema({ forbid: Boolean, + // Permission + inheritPermission: { + type: Boolean, + default: true + }, + permissionEffectScope: { + type: String, + enum: Object.values(PermissionEffectScopeEnum), + default: PermissionEffectScopeEnum.allChildren + }, + + // Soft delete + deleteTime: { + type: Date, + default: null + }, + + // Precomputed statistics for listV2 performance + // Updated asynchronously by the collectionUpdate worker + dataAmount: { + type: Number, + default: 0 + }, + trainingAmount: { + type: Number, + default: 0 + }, + processedCount: { + type: Number, + default: 0 + }, + remainingCount: { + type: Number, + default: 0 + }, + hasError: { + type: Boolean, + default: false + }, + errorCount: { + type: Number, + default: 0 + }, + allParse: { + type: Boolean, + default: false + }, + statsUpdatedAt: { + type: Date + }, + // Parse settings customPdfParse: Boolean, apiFileParentId: String, @@ -94,6 +216,20 @@ DatasetCollectionSchema.virtual('dataset', { justOne: true }); +// 创建和保存时自动更新 updateTime +DatasetCollectionSchema.pre('save', function (next) { + this.updateTime = new Date(); + next(); +}); + +// update 操作时自动更新 updateTime +// 注意:在 query middleware 中,this 指向 Query 对象,不是 Document 对象 +DatasetCollectionSchema.pre(['findOneAndUpdate', 'updateOne', 'updateMany'], function (next) { + // 使用 Query 的 set 方法来设置 updateTime + this.set({ updateTime: new Date() }); + next(); +}); + try { // auth file DatasetCollectionSchema.index({ teamId: 1, fileId: 1 }); @@ -106,8 +242,10 @@ try { updateTime: -1 }); - // Tag filter + // Tag filter (旧格式: tags 为 ObjectId 数组) DatasetCollectionSchema.index({ teamId: 1, datasetId: 1, tags: 1 }); + // Tag filter (新格式: tags 为 { tagId, value } 对象数组,按 tagId 过滤走此索引) + DatasetCollectionSchema.index({ 'tags.tagId': 1 }); // create time filter DatasetCollectionSchema.index({ teamId: 1, datasetId: 1, createTime: 1 }); @@ -122,11 +260,29 @@ try { } ); + // MD5 duplicate check + DatasetCollectionSchema.index({ teamId: 1, datasetId: 1, fileMd5: 1 }); + // Clear invalid image DatasetCollectionSchema.index({ teamId: 1, 'metadata.relatedImgId': 1 }); + + // Soft delete cleanup + DatasetCollectionSchema.index({ deleteTime: 1 }); + + // Backfill: find non-folder collections without stats + // Partial index: once statsUpdatedAt is set, the document drops out of the index + DatasetCollectionSchema.index( + { deleteTime: 1, type: 1, statsUpdatedAt: 1 }, + { + partialFilterExpression: { + deleteTime: { $eq: null }, + statsUpdatedAt: { $exists: false } + } + } + ); } catch (error) { const logger = getLogger(LogCategories.INFRA.MONGO); logger.error('Failed to build dataset collection indexes', { error }); diff --git a/packages/service/core/dataset/collection/utils.ts b/packages/service/core/dataset/collection/utils.ts index 91ebbb434a17..6d12dea495d3 100644 --- a/packages/service/core/dataset/collection/utils.ts +++ b/packages/service/core/dataset/collection/utils.ts @@ -1,8 +1,12 @@ import { MongoDatasetCollection } from './schema'; import type { ClientSession } from '../../../common/mongo'; +import { Types } from '../../../common/mongo'; import { MongoDatasetCollectionTags } from '../tag/schema'; import { readFromSecondary } from '../../../common/mongo/utils'; -import type { CollectionWithDatasetType } from '@fastgpt/global/core/dataset/type'; +import type { + CollectionTagValueType, + CollectionWithDatasetType +} from '@fastgpt/global/core/dataset/type'; import { DatasetCollectionSchemaType } from '@fastgpt/global/core/dataset/type'; import { DatasetCollectionDataProcessModeEnum, @@ -17,6 +21,26 @@ import { hashStr } from '@fastgpt/global/common/string/tools'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { createCollectionAndInsertData, delCollection } from './controller'; import { collectionCanSync } from '@fastgpt/global/core/dataset/collection/utils'; +import { getApiDatasetRequest } from '../apiDataset'; + +// 图片文件扩展名集合 +const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png']); +const isImageFile = (fileName: string): boolean => { + const ext = fileName.split('.').pop()?.toLowerCase(); + return ext ? IMAGE_EXTENSIONS.has(ext) : false; +}; + +// 更新父 folder 的 updateTime +export async function updateParentFolderTime(parentId: string | null) { + if (!parentId) return; + + // 使用 MongoDB 的 $currentDate 操作符,由中间件自动处理 updateTime + // 这样可以避免与 Mongoose 中间件冲突,且更高效 + await MongoDatasetCollection.updateOne( + { _id: new Types.ObjectId(parentId) }, + { $currentDate: { updateTime: true } } + ); +} /** * get all collection by top collectionId @@ -35,7 +59,7 @@ export async function findCollectionAndChild({ async function find(id: string) { // find children const children = await MongoDatasetCollection.find( - { teamId, datasetId, parentId: id }, + { teamId, datasetId, parentId: id, deleteTime: null }, fields ).lean(); @@ -72,64 +96,171 @@ export const createOrGetCollectionTags = async ({ teamId, session }: { - tags?: string[]; + tags?: (string | CollectionTagValueType)[]; datasetId: string; teamId: string; session?: ClientSession; }) => { if (!tags) return undefined; - if (tags.length === 0) return []; + // 分离旧格式(string 内容)和新格式(key-value) + const stringContents = tags.filter((t): t is string => typeof t === 'string'); + const kvItems = tags.filter( + (t): t is CollectionTagValueType => typeof t === 'object' && t !== null + ); + + // 合并所有需要查找/创建的 tag 内容 + const allTagContents = [ + ...stringContents, + ...kvItems.map((t) => t.tagId) // 输入时 tagId 为 tag 内容值 + ]; + + if (allTagContents.length === 0) return []; + const existingTags = await MongoDatasetCollectionTags.find( - { - teamId, - datasetId, - tag: { $in: tags } - }, + { teamId, datasetId, tag: { $in: allTagContents } }, undefined, { session } ).lean(); - const existingTagContents = existingTags.map((tag) => tag.tag); - const newTagContents = tags.filter((tag) => !existingTagContents.includes(tag)); + const existingTagMap = new Map(existingTags.map((t) => [t.tag, t._id])); - const newTags = await MongoDatasetCollectionTags.insertMany( - newTagContents.map((tagContent) => ({ - teamId, - datasetId, - tag: tagContent - })), - { session, ordered: true } - ); + const newTagContents = [ + ...new Set(allTagContents.filter((content) => !existingTagMap.has(content))) + ]; + if (newTagContents.length > 0) { + const newTags = await MongoDatasetCollectionTags.insertMany( + newTagContents.map((tagContent) => ({ teamId, datasetId, tag: tagContent })), + { session, ordered: true } + ); + newTags.forEach((t) => existingTagMap.set(t.tag, t._id)); + } - return [...existingTags.map((tag) => tag._id), ...newTags.map((tag) => tag._id)]; + // 旧格式返回 ObjectId,新格式返回 { tagId: resolvedId, value } + const resolvedStringIds = stringContents.map((content) => existingTagMap.get(content)!); + const resolvedKvItems: CollectionTagValueType[] = kvItems.map((t) => ({ + tagId: String(existingTagMap.get(t.tagId)!), + value: t.value + })); + + return [...resolvedStringIds, ...resolvedKvItems]; }; +/** + * 将集合的标签 ID/对象列表转换为可展示的标签标识列表。 + * + * - 旧格式(字符串 ObjectId):查询数据库,将 ID 映射为对应的标签名称字符串。 + * - 新格式(CollectionTagValueType 对象):查询数据库过滤掉已删除的 tagId 后返回。 + * + * @param datasetId - 所属知识库 ID,用于查询旧格式标签 + * @param tags - 待转换的标签列表(可为 undefined) + * @returns 转换后的标签列表;若入参为空或结果为空则返回 undefined + */ export const collectionTagsToTagLabel = async ({ datasetId, tags }: { datasetId: string; - tags?: string[]; -}) => { + tags?: (string | CollectionTagValueType)[]; +}): Promise<(string | CollectionTagValueType)[] | undefined> => { if (!tags) return undefined; if (tags.length === 0) return; - // Get all the tags - const collectionTags = await MongoDatasetCollectionTags.find({ datasetId }, undefined, { - ...readFromSecondary - }).lean(); - const tagsMap = new Map(); - collectionTags.forEach((tag) => { - tagsMap.set(String(tag._id), tag.tag); - }); + // 分离新旧格式 + const oldFormatIds = tags.filter((t): t is string => typeof t === 'string'); + const newFormatItems = tags.filter( + (t): t is CollectionTagValueType => typeof t === 'object' && t !== null + ); + + // 旧格式或新格式均需查询有效 tag 集合 + const needsDbQuery = oldFormatIds.length > 0 || newFormatItems.length > 0; + let validTagIdSet = new Set(); + let tagsMap = new Map(); - return tags - .map((tag) => { - return tagsMap.get(tag) || ''; - }) + if (needsDbQuery) { + const collectionTags = await MongoDatasetCollectionTags.find({ datasetId }, undefined, { + ...readFromSecondary + }).lean(); + collectionTags.forEach((tag) => { + const id = String(tag._id); + tagsMap.set(id, tag.tag); + validTagIdSet.add(id); + }); + } + + // 旧格式:ObjectId → 标签名 + const oldFormatLabels: string[] = oldFormatIds + .map((tag) => tagsMap.get(tag) || '') .filter(Boolean); + + // 新格式:过滤掉已被删除的 tagId,并附加 label(标签名) + const validNewFormatItems = newFormatItems + .filter((item) => validTagIdSet.has(item.tagId)) + .map((item) => ({ ...item, label: tagsMap.get(item.tagId) })); + + const result: (string | CollectionTagValueType)[] = [...oldFormatLabels, ...validNewFormatItems]; + return result.length > 0 ? result : undefined; +}; + +/** + * 同步图片 collection(apiFile 等外部来源的图片) + * 检查外部文件是否存在及是否有更新 + */ +const syncImageCollection = async (collection: CollectionWithDatasetType, dataset: any) => { + const sourceId = collection.apiFileId; + if (!sourceId) return Promise.reject('apiFileId is missing'); + + if (collection.type === DatasetCollectionTypeEnum.apiFile) { + if (!dataset.apiDatasetServer) { + return Promise.reject('API dataset server config not found'); + } + + const apiDatasetRequest = await getApiDatasetRequest(dataset.apiDatasetServer); + + try { + // 检查文件是否还存在 + const fileDetail = await apiDatasetRequest.getFileDetail({ apiFileId: sourceId }); + + // 用 updateTime 判断是否变化 + const fileFingerprint = hashStr(JSON.stringify(fileDetail)); + if (collection.hashRawText && fileFingerprint === collection.hashRawText) { + return DatasetCollectionSyncResultEnum.sameRaw; + } + + // 文件有变化 → 重建 collection + await mongoSessionRun(async (session) => { + await delCollection({ + collections: [collection], + delImg: false, + delFile: false, + session + }); + await createCollectionAndInsertData({ + session, + dataset, + imageIds: [fileDetail.id], + createCollectionParams: { + ...collection, + name: fileDetail.name || collection.name, + updateTime: new Date(), + trainingType: DatasetCollectionDataProcessModeEnum.imageParse, + hashRawText: fileFingerprint, + tags: (await collectionTagsToTagLabel({ + datasetId: collection.datasetId, + tags: collection.tags + })) as string[] | undefined + } + }); + }); + + return DatasetCollectionSyncResultEnum.success; + } catch (error) { + return DatasetCollectionSyncResultEnum.failed; + } + } + + return DatasetCollectionSyncResultEnum.sameRaw; }; export const syncCollection = async (collection: CollectionWithDatasetType) => { @@ -139,6 +270,11 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => { return Promise.reject(DatasetErrEnum.notSupportSync); } + // 图片集合:用文件名后缀判断 + if (collection.type === DatasetCollectionTypeEnum.apiFile && isImageFile(collection.name)) { + return syncImageCollection(collection, dataset); + } + // Get new text const sourceReadType = await (async () => { if (collection.type === DatasetCollectionTypeEnum.link) { @@ -161,10 +297,18 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => { }; })(); + const parseConfig: Record = {}; + if (dataset.keep_header_footer) parseConfig.keep_header_footer = true; + if (dataset.keep_appendix) parseConfig.keep_appendix = true; + if (dataset.image_analysis) parseConfig.image_analysis = true; + if (dataset.chart_analysis) parseConfig.chart_analysis = true; + const { title, rawText } = await readDatasetSourceRawText({ teamId: collection.teamId, tmbId: collection.tmbId, datasetId: collection.datasetId, + customPdfParse: collection.customPdfParse, + parseConfig: Object.keys(parseConfig).length > 0 ? parseConfig : undefined, ...sourceReadType }); @@ -193,10 +337,10 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => { ...collection, name: title || collection.name, updateTime: new Date(), - tags: await collectionTagsToTagLabel({ + tags: (await collectionTagsToTagLabel({ datasetId: collection.datasetId, tags: collection.tags - }) + })) as string[] | undefined } }); }); @@ -212,15 +356,18 @@ export const syncCollection = async (collection: CollectionWithDatasetType) => { /* QA: 独立进程 Chunk: Image Index -> Auto index -> chunk index + Template: Small2Big -> Auto -> chunk */ export const getTrainingModeByCollection = ({ trainingType, autoIndexes, - imageIndex + imageIndex, + small2bigIndexes }: { trainingType?: DatasetCollectionDataProcessModeEnum; autoIndexes?: boolean; imageIndex?: boolean; + small2bigIndexes?: boolean; }) => { if ( trainingType === DatasetCollectionDataProcessModeEnum.imageParse && @@ -232,6 +379,23 @@ export const getTrainingModeByCollection = ({ if (trainingType === DatasetCollectionDataProcessModeEnum.qa) { return TrainingModeEnum.qa; } + if (trainingType === DatasetCollectionDataProcessModeEnum.databaseSchema) { + return TrainingModeEnum.databaseSchema; + } + + // Template: small2big -> auto -> chunk + if (trainingType === DatasetCollectionDataProcessModeEnum.template) { + if (small2bigIndexes) { + return TrainingModeEnum.small2Big; + } + if (autoIndexes && global.feConfigs?.isPlus) { + return TrainingModeEnum.auto; + } + + return TrainingModeEnum.chunk; + } + + // Chunk 模式的处理链:image -> auto -> chunk if ( trainingType === DatasetCollectionDataProcessModeEnum.chunk && imageIndex && diff --git a/packages/service/core/dataset/collection/validateName.ts b/packages/service/core/dataset/collection/validateName.ts new file mode 100644 index 000000000000..bc06164d6dc0 --- /dev/null +++ b/packages/service/core/dataset/collection/validateName.ts @@ -0,0 +1,45 @@ +import { DatasetCollectionTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset'; + +/** + * Validate collection name when updating + * Checks for: + * 1. File extension removal (not allowed) + * 2. File extension mismatch with original + * Note: Duplicate name checking is handled separately at the folder level + */ +export const validateCollectionNameUpdate = async ({ + newName, + originalName, + collectionType +}: { + collectionId?: string; // Kept for backward compatibility, not used internally + datasetId?: string; // Kept for backward compatibility, not used internally + newName: string; + originalName: string; + collectionType: DatasetCollectionTypeEnum; +}) => { + // Only validate file type collections + if (collectionType !== DatasetCollectionTypeEnum.file) { + return; + } + + // Extract file extensions + const getFileExtension = (filename: string): string => { + const lastDotIndex = filename.lastIndexOf('.'); + return lastDotIndex > 0 ? filename.substring(lastDotIndex) : ''; + }; + + const originalExt = getFileExtension(originalName); + const newExt = getFileExtension(newName); + + // Check 1: File extension cannot be removed + if (originalExt && !newExt) { + return Promise.reject(DatasetErrEnum.collectionNameMissingExtension); + } + + // Check 2: File extension must match original + if (originalExt && newExt && originalExt !== newExt) { + return Promise.reject(DatasetErrEnum.collectionNameExtensionMismatch); + } +}; diff --git a/packages/service/core/dataset/controller.ts b/packages/service/core/dataset/controller.ts index 4e3d9a36903a..f0f4ebc4e5a4 100644 --- a/packages/service/core/dataset/controller.ts +++ b/packages/service/core/dataset/controller.ts @@ -1,6 +1,7 @@ import { type DatasetSchemaType } from '@fastgpt/global/core/dataset/type'; import { MongoDatasetCollection } from './collection/schema'; import { MongoDataset } from './schema'; +import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { delCollectionRelatedSource } from './collection/controller'; import { type ClientSession } from '../../common/mongo'; import { MongoDatasetTraining } from './training/schema'; @@ -8,8 +9,12 @@ import { MongoDatasetData } from './data/schema'; import { deleteDatasetDataVector } from '../../common/vectorDB/controller'; import { MongoDatasetDataText } from './data/dataTextSchema'; import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset'; -import { retryFn } from '@fastgpt/global/common/system/utils'; import { UserError } from '@fastgpt/global/common/error/utils'; +import { + DBDatasetVectorTableName, + DBDatasetValueVectorTableName, + DatasetVectorTableName +} from '../../common/vectorDB/constants'; import { getS3DatasetSource } from '../../common/s3/sources/dataset'; /* ============= dataset ========== */ @@ -23,34 +28,71 @@ export async function findDatasetAndAllChildren({ datasetId: string; fields?: string; }): Promise { - const find = async (id: string) => { + const rootDataset = await MongoDataset.findById(datasetId, fields).lean(); + if (!rootDataset) { + return Promise.reject(new UserError('Dataset not found')); + } + + const allDatasets: DatasetSchemaType[] = [rootDataset as DatasetSchemaType]; + let currentQueue = [datasetId]; + + // BFS:每层一次批量查询,复杂度从 O(N) 降为 O(depth) + while (currentQueue.length > 0) { const children = await MongoDataset.find( - { - teamId, - parentId: id - }, + { teamId, parentId: { $in: currentQueue } }, fields ).lean(); + allDatasets.push(...(children as DatasetSchemaType[])); + currentQueue = children.map((c) => String(c._id)); + } - let datasets = children; + return allDatasets; +} - for (const child of children) { - const grandChildrenIds = await find(child._id); - datasets = datasets.concat(grandChildrenIds); +/** + * Expand folder-type dataset IDs to their non-folder descendant IDs. + * Non-folder IDs are kept as-is. Duplicates are removed. + * BFS 批量查询:每层一次数据库查询,复杂度 O(depth)。 + */ +export async function expandFolderDatasetIds( + teamId: string, + datasetIds: string[] +): Promise { + const inputDatasets = await MongoDataset.find( + { _id: { $in: datasetIds }, teamId }, + '_id type' + ).lean(); + + const nonFolderIds: string[] = []; + let folderQueue: string[] = []; + + for (const dataset of inputDatasets) { + if (dataset.type === DatasetTypeEnum.folder) { + folderQueue.push(String(dataset._id)); + } else { + nonFolderIds.push(String(dataset._id)); } + } + + // BFS:每层对所有 folder 子节点批量查询一次 + while (folderQueue.length > 0) { + const children = await MongoDataset.find( + { parentId: { $in: folderQueue }, teamId }, + '_id type' + ).lean(); - return datasets; - }; - const [dataset, childDatasets] = await Promise.all([ - MongoDataset.findById(datasetId).lean(), - find(datasetId) - ]); + folderQueue = []; - if (!dataset) { - return Promise.reject(new UserError('Dataset not found')); + for (const child of children) { + if (child.type === DatasetTypeEnum.folder) { + folderQueue.push(String(child._id)); + } else { + nonFolderIds.push(String(child._id)); + } + } } - return [dataset, ...childDatasets]; + return [...new Set(nonFolderIds)]; } export async function getCollectionWithDataset(collectionId: string) { @@ -113,7 +155,9 @@ export async function delDatasetRelevantData({ await delCollectionRelatedSource({ collections }); // Delete vector data - await deleteDatasetDataVector({ teamId, datasetIds }); + deleteDatasetDataVector({ teamId, datasetIds, tableName: DBDatasetVectorTableName }), + deleteDatasetDataVector({ teamId, datasetIds, tableName: DBDatasetValueVectorTableName }), + deleteDatasetDataVector({ teamId, datasetIds, tableName: DatasetVectorTableName }); // delete collections await MongoDatasetCollection.deleteMany({ diff --git a/packages/service/core/dataset/data/controller.ts b/packages/service/core/dataset/data/controller.ts index 722a6205f556..9a7c4313c21b 100644 --- a/packages/service/core/dataset/data/controller.ts +++ b/packages/service/core/dataset/data/controller.ts @@ -1,9 +1,21 @@ import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils'; import { addEndpointToImageUrl } from '../../../common/file/image/utils'; -import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; +import type { + DatasetDataSchemaType, + CreateDatasetDataPropsType +} from '@fastgpt/global/core/dataset/type'; import { addDays } from 'date-fns'; import { isS3ObjectKey, jwtSignS3DownloadToken } from '../../../common/s3/utils'; import { S3Buckets } from '../../../common/s3/config/constants'; +import { MongoDatasetData } from './schema'; +import { MongoDatasetDataText } from './dataTextSchema'; +import { jiebaSplit } from '../../../common/string/jieba/index'; +import { detectLang } from 'diting-rag-ts'; +import { removeS3TTL } from '../../../common/s3/utils'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import type { ClientSession } from '../../../common/mongo'; +import { addLog } from '../../../common/system/log'; +import { pushCollectionUpdateJob } from '../collection/mq'; export const formatDatasetDataValue = ({ q, @@ -83,3 +95,230 @@ export const getFormatDatasetCiteList = (list: DatasetDataSchemaType[]) => { index: item.chunkIndex })); }; + +// ============================================================================ +// Data draft creation (pre-training) +// ============================================================================ + +/** + * Create a single draft Data record with empty indexes (no embedding/vectorization). + * Used to pre-create Data before pushing training tasks, so chunks are visible + * in the frontend while indexing is still in progress. + * + * Subsequent training stages (generateVector, generateImageIndex, etc.) + * will UPDATE this Data record rather than creating new ones. + * + * @returns The created Data's _id, used to associate with Training records via dataId. + */ +export async function createDataDraft({ + id, + teamId, + tmbId, + datasetId, + collectionId, + q, + a, + imageId, + chunkIndex = 0, + metadata, + imageDescMap, + session +}: CreateDatasetDataPropsType & { + imageDescMap?: Record; + session?: ClientSession; +}) { + if (!datasetId || !collectionId) { + return Promise.reject('datasetId, collectionId is required'); + } + if (String(teamId) === String(tmbId)) { + return Promise.reject("teamId and tmbId can't be the same"); + } + + const detectedLanguage = detectLang(q || ''); + const mergedMetadata = { ...(metadata || {}), detectedLanguage }; + + // Create Data with empty indexes (filled later by insertDataVector in generateVector) + const [{ _id }] = await MongoDatasetData.create( + [ + { + ...(id && { _id: id }), + teamId, + tmbId, + datasetId, + collectionId, + q: q || '', + a, + imageId, + imageDescMap, + chunkIndex, + indexes: [], + createTime: new Date(), + ...(Object.keys(mergedMetadata).length > 0 && { metadata: mergedMetadata }) + } + ], + { session, ordered: true } + ); + + // Create DataText with jieba分词 (may be empty for image chunks, filled later by imageParse) + const fullText = `${q || ''}\n${a || ''}`.trim(); + await MongoDatasetDataText.create( + [ + { + teamId, + datasetId, + collectionId, + dataId: _id, + fullTextToken: fullText ? await jiebaSplit({ text: fullText }) : '' + } + ], + { session, ordered: true } + ); + + // Remove image TTL + if (isS3ObjectKey(imageId, 'dataset')) { + await removeS3TTL({ key: imageId, bucketName: 'private', session }); + } + + // Trigger async collection stats update + pushCollectionUpdateJob({ + collectionId: String(collectionId), + datasetId: String(datasetId), + teamId: String(teamId) + }); + + return { _id }; +} + +/** + * Batch version of createDataDraft. Creates multiple Data drafts efficiently + * using bulk MongoDB inserts and parallel S3 TTL removal. + * + * @returns Array of { _id } in the same order as the input items. + */ +export async function createDataDrafts({ + items, + teamId, + tmbId, + datasetId, + collectionId, + session +}: { + items: (Omit & { + imageDescMap?: Record; + })[]; + teamId: string; + tmbId: string; + datasetId: string; + collectionId: string; + session?: ClientSession; +}) { + if (!datasetId || !collectionId) { + return Promise.reject('datasetId, collectionId is required'); + } + + const BATCH_SIZE = 500; + + /** + * Insert a single batch (up to BATCH_SIZE items) within the given session. + */ + const insertBatch = async ( + batch: typeof items, + batchSession: ClientSession + ): Promise<{ _id: any }[]> => { + // 1. Build Data documents for this batch + const draftDataItems = batch.map((item, i) => { + const q = item.q || ''; + const a = item.a || ''; + const detectedLanguage = detectLang(q); + const mergedMetadata = { ...(item.metadata || {}), detectedLanguage }; + return { + ...(item.id && { _id: item.id }), + teamId, + tmbId, + datasetId, + collectionId, + q, + a, + ...(item.imageId && { imageId: item.imageId }), + ...(item.imageDescMap && { imageDescMap: item.imageDescMap }), + chunkIndex: item.chunkIndex, + indexes: [], + createTime: new Date(), + ...(Object.keys(mergedMetadata).length > 0 && { metadata: mergedMetadata }) + }; + }); + + // 2. Batch insert Data records + const draftDocs = await MongoDatasetData.create(draftDataItems, { + session: batchSession, + ordered: true + }); + + // 3. Compute jieba tokens and batch insert DataText records + const textTokens = await Promise.all( + draftDocs.map((doc) => { + const fullText = `${doc.q}\n${doc.a || ''}`.trim(); + return fullText ? jiebaSplit({ text: fullText }) : ''; + }) + ); + await MongoDatasetDataText.create( + textTokens.map((token, i) => ({ + teamId, + datasetId, + collectionId, + dataId: draftDocs[i]._id, + fullTextToken: token + })), + { session: batchSession, ordered: true } + ); + + // 4. Remove S3 TTL for images in this batch (parallel) + const batchImageIds = draftDocs + .map((doc) => doc.imageId) + .filter((id): id is string => !!id && isS3ObjectKey(id, 'dataset')); + if (batchImageIds.length > 0) { + await Promise.all( + batchImageIds.map((key) => + removeS3TTL({ key, bucketName: 'private', session: batchSession }) + ) + ); + } + + return draftDocs; + }; + + const results: { _id: any }[] = []; + + if (session) { + // Caller's session: all batches share one transaction (backward compatible) + for (let offset = 0; offset < items.length; offset += BATCH_SIZE) { + const batch = items.slice(offset, offset + BATCH_SIZE); + results.push(...(await insertBatch(batch, session))); + addLog.debug('createDataDrafts batch progress (shared session)', { + done: offset + batch.length, + total: items.length + }); + } + } else { + // No session: each batch is its own independent transaction + for (let offset = 0; offset < items.length; offset += BATCH_SIZE) { + const batch = items.slice(offset, offset + BATCH_SIZE); + const batchResults = await mongoSessionRun(async (batchSession) => { + return insertBatch(batch, batchSession); + }); + results.push(...batchResults); + addLog.debug('createDataDrafts batch committed (per-batch txn)', { + done: offset + batch.length, + total: items.length + }); + } + } + + pushCollectionUpdateJob({ + collectionId: String(collectionId), + datasetId: String(datasetId), + teamId: String(teamId) + }); + + return results.map((doc) => ({ _id: doc._id })); +} diff --git a/packages/service/core/dataset/data/schema.ts b/packages/service/core/dataset/data/schema.ts index 1417564dde32..43330acd12f0 100644 --- a/packages/service/core/dataset/data/schema.ts +++ b/packages/service/core/dataset/data/schema.ts @@ -35,7 +35,8 @@ const DatasetDataSchema = new Schema({ }, q: { type: String, - required: true + default: '', + set: (v: any) => (v == null ? '' : v) }, a: { type: String @@ -51,6 +52,7 @@ const DatasetDataSchema = new Schema({ } ] }, + metadata: Object, indexes: { type: [ { @@ -70,22 +72,58 @@ const DatasetDataSchema = new Schema({ text: { type: String, required: true + }, + // 同义词转换元数据 + synonymMetadata: { + type: Object + }, + synId: { + type: Number } } ], default: [] }, + // 同义词处理状态 + synonymProcessing: { + type: String, + enum: ['standardize', 'restore'] + }, + // 需要应用的同义词文件ID数组 + synonymFileIds: { + type: [String] + }, + updateTime: { type: Date, default: () => new Date() }, + createTime: { + type: Date, + default: () => new Date() + }, + indexingCompleteTime: { + type: Date + }, chunkIndex: { type: Number, default: 0 }, rebuilding: Boolean, + // Per-phase processing timings for performance tracking + phaseTimings: { + type: [ + { + phase: { type: String }, + startTime: { type: Date }, + endTime: { type: Date } + } + ], + default: [] + }, + // Abandon fullTextToken: String, initFullText: Boolean, diff --git a/packages/service/core/dataset/database/clientManager.ts b/packages/service/core/dataset/database/clientManager.ts new file mode 100644 index 000000000000..78cd3e77c44b --- /dev/null +++ b/packages/service/core/dataset/database/clientManager.ts @@ -0,0 +1,106 @@ +import type { DatabaseConfig } from '@fastgpt/global/core/dataset/type'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../common/system/log'; +import { MysqlClient } from './model/mysql'; +import { PostgresqlClient } from './model/postgresql'; +import { MssqlClient } from './model/mssql'; +import { OracleClient } from './model/oracle'; +import type { AsyncDB } from './model/asyncDB'; +import { MongoDataset } from '../schema'; +import { i18nT } from '../../../../global/common/i18n/utils'; +import { DatabaseTypeEnum } from '@fastgpt/global/core/dataset/constants'; + +export async function createDatabaseClient(config: DatabaseConfig): Promise { + switch (config.clientType) { + case DatabaseTypeEnum.mysql: + return MysqlClient.fromConfig(config); + case DatabaseTypeEnum.postgresql: + return PostgresqlClient.fromConfig(config); + case DatabaseTypeEnum.mssql: + return MssqlClient.fromConfig(config); + case DatabaseTypeEnum.oracle: + return OracleClient.fromConfig(config); + default: + return Promise.reject(DatabaseErrEnum.notSupportType); + } +} + +export async function checkDatabaseConnection(config: DatabaseConfig): Promise { + let dbClient: AsyncDB | undefined; + try { + dbClient = await createDatabaseClient(config); + const result = await dbClient.checkConnection(); + return result; + } catch (err: any) { + return Promise.reject(err); + } finally { + if (dbClient) { + try { + await dbClient.destroy(); + } catch (destroyErr: any) { + addLog.warn(`Failed to destroy client after connection test`, destroyErr.message); + } + } + } +} + +export async function withDatabaseClient( + datasetId: string, + operation: (client: AsyncDB) => Promise +): Promise { + let dbClient: AsyncDB | undefined; + try { + const dataset = await MongoDataset.findById(datasetId); + if (!dataset?.databaseConfig) { + return Promise.reject(DatabaseErrEnum.dbConfigNotFound); + } + + dbClient = await createDatabaseClient(dataset.databaseConfig); + await dbClient.checkConnection(); + const result = await operation(dbClient); + + return result; + } catch (err) { + addLog.error(`Database operation failed`, err); + return Promise.reject(i18nT('database_client:op_unknown_database_error')); + } finally { + if (dbClient) { + try { + await dbClient.destroy(); + } catch (destroyErr: any) { + addLog.warn(`Failed to destroy database client for dataset ${datasetId}`, { + error: destroyErr.message + }); + } + } + } +} +export async function withDatabaseClientByConfig( + config: DatabaseConfig, + operation: (client: AsyncDB) => Promise +): Promise { + let dbClient: AsyncDB | undefined; + + try { + dbClient = await createDatabaseClient(config); + + await dbClient.checkConnection(); + const result = await operation(dbClient); + + return result; + } catch (err) { + addLog.error(`Database operation failed`, err); + return Promise.reject(i18nT('database_client:op_unknown_database_error')); + } finally { + if (dbClient) { + try { + await dbClient.destroy(); + } catch (destroyErr: any) { + addLog.warn(`Failed to destroy database client`, { + error: destroyErr.message + }); + } + } + } +} + diff --git a/packages/service/core/dataset/database/dative/client/dativeApiServer.ts b/packages/service/core/dataset/database/dative/client/dativeApiServer.ts new file mode 100644 index 000000000000..fd88c4a44d2c --- /dev/null +++ b/packages/service/core/dataset/database/dative/client/dativeApiServer.ts @@ -0,0 +1,76 @@ +import { request } from '../http/request'; +import type { + SqlQueryRequest, + SqlQueryResponse, + SqlGenerationRequest, + SqlGenerationResponse, + DatabaseMetadataRequest, + DatabaseMetadata, + ExcelUploadRequest, + DativeExcelUploadResponse +} from '@fastgpt/global/core/dataset/database/api'; +import { forwardMultipartStream } from '../transport/stream-forward'; +import { dativeUrl } from '../utils'; + +export async function sqlQuery(req: SqlQueryRequest): Promise { + return request({ + url: '/api/v1/data_source/sql_query', + method: 'POST', + data: req + }); +} + +export async function queryByNL(req: SqlGenerationRequest): Promise { + return request({ + url: '/api/v1/data_source/query_by_nl', + method: 'POST', + data: req + }); +} + +export async function getMetadata(req: DatabaseMetadataRequest): Promise { + return request({ + url: '/api/v1/data_source/get_metadata', + method: 'POST', + data: req + }); +} + +export async function getMetadataWithValueExamples( + req: DatabaseMetadataRequest +): Promise { + return request({ + url: '/api/v1/data_source/get_metadata_with_value_examples', + method: 'POST', + data: req + }); +} + +export async function uploadExcel(request: ExcelUploadRequest): Promise { + const { fileStream, contentType, sourceConfig, timeout = 300000 } = request; + + const endpoint = `${dativeUrl}/api/v1/data_source/excel_upload`; + + // Forward the multipart stream with injected source_config field + const response = await forwardMultipartStream({ + url: endpoint, + method: 'POST', + requestStream: fileStream, + contentType, + injectFields: [ + { + name: 'source_config', + value: sourceConfig + } + ], + timeout + }); + + // Validate Dative response + const result = response.body; + if (!result || result.msg !== 'success') { + return Promise.reject(`Excel upload failed: ${result?.msg || 'Unknown error'}`); + } + + return result; +} diff --git a/packages/service/core/dataset/database/dative/http/axios.ts b/packages/service/core/dataset/database/dative/http/axios.ts new file mode 100644 index 000000000000..50280fa575c4 --- /dev/null +++ b/packages/service/core/dataset/database/dative/http/axios.ts @@ -0,0 +1,64 @@ +import axios, { + type AxiosInstance, + type InternalAxiosRequestConfig, + type AxiosResponse +} from 'axios'; +import { addLog } from '../../../../../common/system/log'; +import { parseDativeErrorResponse, dativeUrl } from '../utils'; + +function requestStart(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { + addLog.debug('Dative request', { + url: config.url, + method: config.method, + endpoint: config.url + }); + return config; +} + +function responseSuccess(response: AxiosResponse) { + addLog.debug('Dative response success', { + url: response.config.url, + status: response.status + }); + return response.data; // 直接返回 data,简化业务层调用 +} + +function responseError(err: any) { + if (!err) { + addLog.error('Dative unknown error', { error: 'Empty error object' }); + return Promise.reject({ message: 'Unknown Dative error' }); + } + + // Network error (connection failed, timeout, etc.) + if (!err.response) { + const errorMsg = err.message || String(err); + addLog.error('Dative network error', { + error: errorMsg, + code: err.code, + url: err.config?.url + }); + return Promise.reject({ + message: `Dative service connection failed: ${errorMsg}`, + statusCode: undefined, + endpoint: err.config?.url + }); + } + const { message, code } = parseDativeErrorResponse(err.response); + return Promise.reject({ + message: message, + statusCode: code + }); +} + +export const http: AxiosInstance = axios.create({ + baseURL: dativeUrl, + timeout: 120000, // 2 minutes for SQL operations + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } +}); + +http.interceptors.request.use(requestStart, (err) => Promise.reject(err)); + +http.interceptors.response.use(responseSuccess, responseError); diff --git a/packages/service/core/dataset/database/dative/http/request.ts b/packages/service/core/dataset/database/dative/http/request.ts new file mode 100644 index 000000000000..86588716da5b --- /dev/null +++ b/packages/service/core/dataset/database/dative/http/request.ts @@ -0,0 +1,24 @@ +import { http } from './axios'; +import type { AxiosRequestConfig } from 'axios'; + +interface RequestConfig extends Omit { + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + data?: T; + params?: Record; +} + +/** + * 通用 JSON API 请求封装 + * @example + * const result = await request({ + * url: '/api/v1/data_source/sql_query', + * method: 'POST', + * data: { sql: 'SELECT * FROM users' } + * }); + */ +export async function request( + config: RequestConfig +): Promise { + return http(config); +} diff --git a/packages/service/core/dataset/database/dative/transport/stream-forward.ts b/packages/service/core/dataset/database/dative/transport/stream-forward.ts new file mode 100644 index 000000000000..9657f5c8cfb2 --- /dev/null +++ b/packages/service/core/dataset/database/dative/transport/stream-forward.ts @@ -0,0 +1,202 @@ +import { request } from 'undici'; +import { PassThrough, type Readable } from 'stream'; +import { addLog } from '../../../../../common/system/log'; +import { parseDativeErrorResponse } from '../utils'; + +export interface StreamForwardConfig { + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + headers?: Record; + timeout?: number; + query?: Record; +} + +// Multipart field injector configuration +export interface MultipartFieldInjector { + name: string; + value: string | Record; + contentType?: string; +} + +// Options for multipart stream forwarding +export interface MultipartForwardOptions extends StreamForwardConfig { + requestStream: Readable; + contentType: string; + injectFields?: MultipartFieldInjector[]; +} + +/** + * Generic response type for stream forward operations + */ +export interface StreamForwardResponse { + statusCode: number; + headers: Record; + body: T; + rawBody?: string; +} + +function extractBoundary(contentType: string): string { + const boundaryMatch = contentType.match(/boundary=["']?([^"';]+)["']?/i); + + if (!boundaryMatch || !boundaryMatch[1] || boundaryMatch[1].trim() === '') { + addLog.error('Failed to extract boundary from Content-Type', { + contentType, + expectedFormat: 'multipart/form-data; boundary=xxx' + }); + throw new Error( + 'Invalid multipart boundary in Content-Type header. ' + + 'Expected format: multipart/form-data; boundary=xxx' + ); + } + + const boundary = boundaryMatch[1].trim(); + + addLog.debug('Extracted boundary from Content-Type', { + contentType, + boundary, + boundaryLength: boundary.length + }); + + return boundary; +} + +// Create a multipart field part as a string +function createMultipartField(field: MultipartFieldInjector, boundary: string): string { + const value = typeof field.value === 'string' ? field.value : JSON.stringify(field.value); + + let part = `--${boundary}\r\n`; + part += `Content-Disposition: form-data; name="${field.name}"\r\n`; + + if (field.contentType) { + part += `Content-Type: ${field.contentType}\r\n`; + } + + part += `\r\n${value}\r\n`; + + return part; +} + +function combineMultipartStreams( + originalStream: Readable, + boundary: string, + injectFields?: MultipartFieldInjector[] +): Readable { + const combined = new PassThrough(); + try { + // Write injected fields first + if (injectFields && injectFields.length > 0) { + for (const field of injectFields) { + const fieldPart = createMultipartField(field, boundary); + combined.write(fieldPart); + + addLog.debug('Injected multipart field', { + fieldName: field.name, + fieldLength: fieldPart.length + }); + } + } + + // Pipe original stream (which should contain file parts and ending boundary) + originalStream.pipe(combined, { end: true }); + + return combined; + } catch (error) { + combined.destroy(); + throw new Error(`Failed to combine multipart streams: ${String(error)}`); + } +} + +function prepareRequestHeaders( + headers: Record, + contentType: string +): Record { + const requestHeaders: Record = { + ...headers, + 'content-type': contentType + }; + + // Remove content-length as combined stream may have different length + delete requestHeaders['content-length']; + + addLog.debug('Prepared request headers', { + contentType, + hasCustomHeaders: Object.keys(headers).length > 0 + }); + + return requestHeaders; +} + +function destroyStream(stream: Readable): void { + stream.destroy(); +} + +export async function forwardMultipartStream( + options: MultipartForwardOptions +): Promise> { + const { + url, + method = 'POST', + headers = {}, + timeout = 300000, + requestStream, + contentType, + injectFields + } = options; + + addLog.debug('Starting multipart stream forward', { + url, + method, + hasInjectedFields: Boolean(injectFields && injectFields.length > 0), + timeout + }); + + const boundary = extractBoundary(contentType); + + const finalStream = + injectFields && injectFields.length > 0 + ? combineMultipartStreams(requestStream, boundary, injectFields) + : requestStream; + + const requestHeaders = prepareRequestHeaders(headers, contentType); + + try { + const response = await request(url, { + method, + headers: requestHeaders, + body: finalStream as any, + bodyTimeout: timeout, + headersTimeout: timeout + }); + + addLog.debug('Stream forward response received', { + url, + statusCode: response.statusCode + }); + + // Parse response body + const chunks: Uint8Array[] = []; + for await (const chunk of response.body) { + chunks.push(chunk); + } + const rawBody = Buffer.concat(chunks as any).toString('utf-8'); + + if (response.statusCode !== 200) { + // Clean up request stream on error response + destroyStream(finalStream); + return Promise.reject( + parseDativeErrorResponse({ statusCode: response.statusCode, data: rawBody }) + ); + } + + return { + statusCode: response.statusCode, + headers: response.headers as Record, + body: JSON.parse(rawBody) as TResponse, + rawBody + }; + } catch (error) { + // Ensure stream cleanup on any exception + destroyStream(finalStream); + return Promise.reject(`Upload failed: ${String(error)}`); + } +} diff --git a/packages/service/core/dataset/database/dative/utils.ts b/packages/service/core/dataset/database/dative/utils.ts new file mode 100644 index 000000000000..9e509a01f30b --- /dev/null +++ b/packages/service/core/dataset/database/dative/utils.ts @@ -0,0 +1,77 @@ +import { DatabaseTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import type { + DuckDBStoreConfigType, + ExcelUploadSourceConfig +} from '@fastgpt/global/core/dataset/database/api'; + +export const dativeUrl = process.env.DATIVE_BASE_URL || ''; + +export interface DativeErrorResponse { + detail?: { + msg?: string; + error?: string; + }; + message?: string; + error?: string; +} + +export function getDuckDBStoreConfig(datasetId: string) { + return { + type: DatabaseTypeEnum.duckdb, + store: { + type: 's3', + kid: datasetId + } + } as DuckDBStoreConfigType; +} + +export function createBucketSourceConfig( + datasetId: string, + teamId: string, + tmbId: string +): ExcelUploadSourceConfig { + return { + type: 's3', + kid: datasetId, + metadata: { + teamId, + uid: tmbId + } + }; +} + +export function parseDativeErrorResponse(response: any): any { + let errorData: DativeErrorResponse = {}; + + try { + if (typeof response.data === 'string') { + errorData = JSON.parse(response.data); + } else { + errorData = response.data || {}; + } + } catch (err) { + // If JSON parsing fails, return raw data as message + return { + message: `Dative error: ${response.data || 'Unknown error'}`, + code: response.statusCode + }; + } + + const code = response.statusCode; + if (code >= 500) { + return { + message: `Dative server error: ${response.statusText || 'Internal Server Error'}`, + code: response.status + }; + } + + const msg = errorData.detail?.msg; + const error = errorData.detail?.error; + const message = + msg && error && msg !== error ? `${msg}: ${error}` : msg || error || 'Dative error'; + + return { + message, + code: code + }; +} diff --git a/packages/service/core/dataset/database/model/asyncDB.ts b/packages/service/core/dataset/database/model/asyncDB.ts new file mode 100644 index 000000000000..90161891988f --- /dev/null +++ b/packages/service/core/dataset/database/model/asyncDB.ts @@ -0,0 +1,294 @@ +import { DatabaseTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../../common/system/log'; +import type { + DatabaseConfig, + TableSchemaType, + ColumnSchemaType, + ForeignKeySchemaType +} from '@fastgpt/global/core/dataset/type'; +import { truncateText, isStringType, convertValueToString } from './utils'; +import type { TableColumn as ORMColumn, ColumnType, DataSourceOptions, Driver } from 'typeorm'; +import { DataSource } from 'typeorm'; +import pg from 'pg'; +delete (pg as any).native; + +export abstract class AsyncDB { + protected db: DataSource; + protected config: DatabaseConfig; + protected sample_value_num: number; + protected sql_result_limit: number; + protected max_string_length: number; + protected db_server_info: string; + protected table_names: Array; + + constructor( + db: DataSource, + config: DatabaseConfig, + sample_value_num: number = 3, + sql_result_limit: number = 100, + max_string_length: number = 1024 + ) { + this.db = db; + this.config = config; + this.sample_value_num = sample_value_num; + this.sql_result_limit = sql_result_limit; + this.max_string_length = max_string_length; + this.db_server_info = ''; + this.table_names = new Array(); + } + + static fromConfig(config: DatabaseConfig): AsyncDB { + throw new Error(DatabaseErrEnum.notImplemented); + } + + static from_uri(config: DatabaseConfig): DataSource { + const options: DataSourceOptions = { + type: config.clientType as any, + host: config.host, + port: config.port, + username: config.user, + password: config.password, + database: config.database, + synchronize: false, + logging: false, + poolSize: config.poolSize || 20, + // PostgreSQL specific: disable native driver to use pure JS implementation + ...(config.clientType === DatabaseTypeEnum.postgresql && { + driver: require('pg'), + nativeDriver: null, + extra: { native: false }, + ...(config.schema && { schema: config.schema }) + }), + ...(config.clientType === DatabaseTypeEnum.mssql && { + pool: { + max: config.poolSize || 20, + min: 0, + idleTimeoutMillis: 30000 + }, + ...(config.schema && { schema: config.schema }), + options: { + encrypt: config.encrypt || false, + trustServerCertificate: config.trustServerCertificate || false + } + }), + // Oracle specific - 使用 database 字段作为 serviceName + ...(config.clientType === DatabaseTypeEnum.oracle && { + serviceName: config.database, + schema: config.schema || (config.user ? config.user.toUpperCase() : ''), + extra: { + poolMax: config.poolSize || 20, + poolMin: 0, + poolIncrement: 1 + } + }) + }; + addLog.debug(`[AsyncDB.from_uri]:${JSON.stringify(options, null, 2)}`); + return new DataSource(options); + } + + async checkConnection(): Promise { + try { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + await this.db.query('SELECT 1'); + return Promise.resolve(true); + } catch (err: any) { + return Promise.reject(err); + } + } + + async destroy(): Promise { + try { + if (this.db.isInitialized) { + await this.db.destroy(); + } + // @ts-ignore + this.db = null; + } catch (err: any) { + return Promise.reject(DatabaseErrEnum.clientDestroyError); + } + } + + async dialect(): Promise { + return new Promise((resolve, reject) => { + if (!this.db.isInitialized) { + reject(DatabaseErrEnum.clientNotFound); + return; + } + resolve(this.db.options.type); + }); + } + + public driver(): Promise { + return new Promise((resolve, reject) => { + if (!this.db.isInitialized) { + reject(DatabaseErrEnum.clientNotFound); + return; + } + resolve(this.db.driver); + }); + } + + async get_db_server_info(): Promise { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const dbDriver = await this.driver(); + return `${dbDriver.options.type}-${dbDriver.version || 'unknown'}`; + } + + /*-----------------------Dynamic Introspection Methods-----------------------*/ + abstract get_all_table_names(): Promise>; + + async init_db_schema(): Promise { + this.db_server_info = await this.get_db_server_info(); + this.table_names = await this.get_all_table_names(); + } + + async get_table_columns(table_name: string): Promise> { + const queryRunner = this.db.createQueryRunner(); + try { + const table = await queryRunner.getTable(table_name); + if (!table) return Promise.reject(DatabaseErrEnum.fetchInfoError); + + return table.columns.map((col: ORMColumn) => ({ + columnName: col.name, + columnType: String(col.type), + description: col.comment ?? '', + examples: [], + forbid: true, + valueIndex: true, + isNullable: col.isNullable, + defaultValue: col.default ?? null, + isAutoIncrement: col.isGenerated, + isPrimaryKey: col.isPrimary + })); + } finally { + await queryRunner.release(); + } + } + // get protected name for sql query + protected getProtectedIdentifier(identifier: string): string { + switch (this.config.clientType) { + case DatabaseTypeEnum.mysql: + case DatabaseTypeEnum.sqlite: + return `\`${identifier}\``; + case DatabaseTypeEnum.postgresql: + return `"${identifier}"`; + case DatabaseTypeEnum.mssql: + return `[${identifier}]`; + case DatabaseTypeEnum.oracle: + return `"${identifier}"`; + default: + return identifier; + } + } + + /** + * 构建采样查询 SQL,不同数据库有不同的限制行数语法 + * 子类可以覆写此方法以使用特定数据库的语法 + */ + protected buildSampleQuery(tableName: string, columnName: string, limit: number): string { + // 默认使用 MySQL/PostgreSQL 的 LIMIT 语法 + return ` + SELECT DISTINCT ${this.getProtectedIdentifier(columnName)} + FROM ${this.getProtectedIdentifier(tableName)} + WHERE ${this.getProtectedIdentifier(columnName)} IS NOT NULL + LIMIT ${limit} + `; + } + + async get_table_info(tableName: string, getExamples: boolean = false): Promise { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + // try-finally to ensure the queryRunner is released + try { + const table = await queryRunner.getTable(tableName); + if (!table) return Promise.reject(DatabaseErrEnum.fetchInfoError); + + const tableComment = table.comment ?? ''; + + const columns: Record = {}; + + for (const col of table.columns) { + let examples: string[] = []; + let valueIndex = false; + + if (getExamples) { + const sql = this.buildSampleQuery(tableName, col.name, this.sample_value_num); + + try { + const result = await queryRunner.query(sql); + + for (const row of result) { + const value = row[col.name]; + if (value !== null && value !== undefined) { + const strValue = truncateText(convertValueToString(value), this.max_string_length); + examples.push(strValue); + } + } + + if (isStringType(col.type as ColumnType) && examples.length > 0) valueIndex = true; + } catch (error) { + addLog.warn(`Failed to get sample data for column ${col.name}:`, { error }); + examples = []; + } + } + + columns[col.name] = { + columnName: col.name, + columnType: String(col.type), + description: col.comment || '', + forbid: false, + valueIndex, + examples, + isNullable: col.isNullable, + defaultValue: col.default ?? null, + isAutoIncrement: col.isGenerated, + isPrimaryKey: col.isPrimary, + isForeignKey: table.foreignKeys.some((fk) => fk.columnNames.includes(col.name)), + relatedColumns: table.foreignKeys + ?.filter((fk) => fk.columnNames.includes(col.name)) + .map((fk) => fk.referencedColumnNames) + .flat() + }; + } + + const primaryKeys = table.primaryColumns.map((col) => col.name); + const foreignKeys: ForeignKeySchemaType[] = table.foreignKeys.flatMap((fk) => { + return fk.columnNames + .map((col, idx) => { + if (fk.referencedColumnNames[idx] && fk.referencedTableName) + return { + name: fk.name || '', + column: col, + referredSchema: + fk.referencedSchema || fk.referencedDatabase || this.config.database, + referredTable: fk.referencedTableName, + referredColumns: fk.referencedColumnNames[idx] + }; + }) + .filter(Boolean) as ForeignKeySchemaType[]; + }); + + return { + tableName, + description: tableComment, + columns, + foreignKeys, + primaryKeys, + constraints: [], + exist: true, + lastUpdated: new Date() + }; + } finally { + await queryRunner.release(); + } + } +} diff --git a/packages/service/core/dataset/database/model/mssql.ts b/packages/service/core/dataset/database/model/mssql.ts new file mode 100644 index 000000000000..ea0fdfd38a1b --- /dev/null +++ b/packages/service/core/dataset/database/model/mssql.ts @@ -0,0 +1,295 @@ +import type { + DatabaseConfig, + TableSchemaType, + ColumnSchemaType, + ForeignKeySchemaType +} from '@fastgpt/global/core/dataset/type'; +import { AsyncDB } from './asyncDB'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../../common/system/log'; +import { truncateText, convertValueToString } from './utils'; + +export class MssqlClient extends AsyncDB { + /** + * MSSQL 使用 TOP 语法而非 LIMIT + */ + protected override buildSampleQuery( + tableName: string, + columnName: string, + limit: number + ): string { + return ` + SELECT DISTINCT TOP ${limit} ${this.getProtectedIdentifier(columnName)} + FROM ${this.getProtectedIdentifier(tableName)} + WHERE ${this.getProtectedIdentifier(columnName)} IS NOT NULL + `; + } + + override async get_all_table_names(): Promise> { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + + try { + const dbName = this.db.options.database; + const schema = this.config.schema || 'dbo'; + + const tables: { TABLE_NAME: string }[] = await queryRunner.query( + `SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_CATALOG = @0 + AND TABLE_SCHEMA = @1 + AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME`, + [dbName, schema] + ); + + return tables.map((t) => t.TABLE_NAME); + } finally { + await queryRunner.release(); + } + } + + /** + * 获取表详细信息,包括列和描述 + * MSSQL 使用 sys.extended_properties 获取表和列的描述信息 + */ + override async get_table_info( + tableName: string, + getExamples: boolean = false + ): Promise { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + const schema = this.config.schema || 'dbo'; + + try { + // 1. 合并查询:表描述、列信息、列描述和主键信息(优化为单次查询) + const columnsResult: { + table_description: string | null; + column_name: string; + data_type: string; + is_nullable: boolean; + column_default: string | null; + is_identity: boolean; + column_description: string | null; + is_primary_key: boolean; + }[] = await queryRunner.query( + `SELECT + CAST(tep.value AS NVARCHAR(MAX)) AS table_description, + c.name AS column_name, + TYPE_NAME(c.user_type_id) AS data_type, + c.is_nullable, + dc.definition AS column_default, + c.is_identity, + CAST(cep.value AS NVARCHAR(MAX)) AS column_description, + CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END AS is_primary_key + FROM sys.tables t + INNER JOIN sys.columns c ON c.object_id = t.object_id + LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id + LEFT JOIN sys.extended_properties tep + ON tep.major_id = t.object_id + AND tep.minor_id = 0 + AND tep.name = 'MS_Description' + LEFT JOIN sys.extended_properties cep + ON cep.major_id = c.object_id + AND cep.minor_id = c.column_id + AND cep.name = 'MS_Description' + LEFT JOIN ( + SELECT ic.object_id, ic.column_id + FROM sys.indexes i + INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + WHERE i.is_primary_key = 1 + ) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id + WHERE t.schema_id = SCHEMA_ID(@0) + AND t.name = @1 + ORDER BY c.column_id`, + [schema, tableName] + ); + + // 检查表是否存在 + if (columnsResult.length === 0) { + addLog.error('[MSSQL] Table does not exist:', { schema, tableName }); + return Promise.reject(DatabaseErrEnum.fetchInfoError); + } + + const tableComment = columnsResult[0]?.table_description || ''; + const primaryKeys = columnsResult + .filter((col) => col.is_primary_key) + .map((col) => col.column_name); + + // 2. 获取外键信息(优化:直接 JOIN 获取列名,避免函数调用) + const foreignKeysResult: { + constraint_name: string; + column_name: string; + referenced_schema: string; + referenced_table: string; + referenced_column: string; + }[] = await queryRunner.query( + `SELECT + fk.name AS constraint_name, + pc.name AS column_name, + SCHEMA_NAME(rt.schema_id) AS referenced_schema, + rt.name AS referenced_table, + rc.name AS referenced_column + FROM sys.foreign_keys fk + INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id + INNER JOIN sys.tables t ON fk.parent_object_id = t.object_id + INNER JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id + INNER JOIN sys.columns pc ON fkc.parent_object_id = pc.object_id AND fkc.parent_column_id = pc.column_id + INNER JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id + WHERE t.schema_id = SCHEMA_ID(@0) + AND t.name = @1 + ORDER BY fk.name, fkc.constraint_column_id`, + [schema, tableName] + ); + + const foreignKeyColumns = new Set(foreignKeysResult.map((fk) => fk.column_name)); + + // 3. 构建列信息 + const columns: Record = {}; + + for (const col of columnsResult) { + let examples: string[] = []; + let valueIndex = false; + + if (getExamples) { + try { + const sql = this.buildSampleQuery(tableName, col.column_name, this.sample_value_num); + const result = await queryRunner.query(sql); + + for (const row of result) { + const value = row[col.column_name]; + if (value !== null && value !== undefined) { + const strValue = truncateText(convertValueToString(value), this.max_string_length); + examples.push(strValue); + } + } + + // MSSQL 字符串类型检查 + const stringTypes = ['varchar', 'nvarchar', 'char', 'nchar', 'text', 'ntext']; + if (stringTypes.includes(col.data_type.toLowerCase()) && examples.length > 0) { + valueIndex = true; + } + } catch (error) { + addLog.warn(`[MSSQL] Failed to get sample data for column ${col.column_name}:`, { + error + }); + examples = []; + } + } + + const fkInfo = foreignKeysResult.filter((fk) => fk.column_name === col.column_name); + + columns[col.column_name] = { + columnName: col.column_name, + columnType: col.data_type, + description: col.column_description || '', + forbid: false, + valueIndex, + examples, + isNullable: col.is_nullable, + defaultValue: col.column_default, + isAutoIncrement: col.is_identity, + isPrimaryKey: primaryKeys.includes(col.column_name), + isForeignKey: foreignKeyColumns.has(col.column_name), + relatedColumns: fkInfo.map((fk) => fk.referenced_column) + }; + } + + // 4. 构建外键列表 + const foreignKeys: ForeignKeySchemaType[] = foreignKeysResult.map((fk) => ({ + name: fk.constraint_name, + column: fk.column_name, + referredSchema: fk.referenced_schema, + referredTable: fk.referenced_table, + referredColumns: fk.referenced_column + })); + + return { + tableName, + description: tableComment, + columns, + foreignKeys, + primaryKeys, + constraints: [], + exist: true, + lastUpdated: new Date() + }; + } catch (error) { + addLog.error('[MSSQL] Failed to get table info:', { tableName, error }); + return Promise.reject(DatabaseErrEnum.fetchInfoError); + } finally { + await queryRunner.release(); + } + } + + static fromConfig(config: DatabaseConfig): MssqlClient { + const db = AsyncDB.from_uri(config); + return new MssqlClient(db, config); + } + + override async checkConnection(): Promise { + try { + await super.checkConnection(); + return true; + } catch (err: any) { + const code = err?.code; + const originalError = err?.originalError; + const errorCode = code || originalError?.code; + + // Log diagnostic information + addLog.warn('[MSSQL checkConnection]:', { + code, + originalErrorCode: originalError?.code, + message: err?.message, + originalErrorMessage: originalError?.message + }); + + switch (errorCode) { + // Tedious driver errors + case 'ELOGIN': + // Login failed - authentication error + return Promise.reject(DatabaseErrEnum.authError); + case 'EINSTLOOKUP': + // Instance lookup failed - named instance not found + return Promise.reject(DatabaseErrEnum.hostError); + case 'ESOCKET': + // Socket/network error + return Promise.reject(DatabaseErrEnum.connectionFailed); + + // Standard Node.js errors + case 'ECONNREFUSED': + // Connection refused - SQL Server not running + return Promise.reject(DatabaseErrEnum.econnRefused); + case 'ETIMEDOUT': + case 'ETIMEOUT': + // Connection timeout + return Promise.reject(DatabaseErrEnum.connectionTimeout); + case 'ENOTFOUND': + // Host not found - DNS resolution failure + return Promise.reject(DatabaseErrEnum.connectionFailed); + case 'EHOSTUNREACH': + // Host unreachable - network routing issue + return Promise.reject(DatabaseErrEnum.hostError); + case 'ERR_SOCKET_BAD_PORT': + // Invalid port number + return Promise.reject(DatabaseErrEnum.databasePortError); + + // Catch-all for unknown errors + default: + addLog.error('[MSSQL checkConnection] Unknown error:', { + code, + message: err?.message, + number: err?.number, + stack: err?.stack + }); + return Promise.reject(DatabaseErrEnum.checkError); + } + } + } +} diff --git a/packages/service/core/dataset/database/model/mysql.ts b/packages/service/core/dataset/database/model/mysql.ts new file mode 100644 index 000000000000..965daf180c2e --- /dev/null +++ b/packages/service/core/dataset/database/model/mysql.ts @@ -0,0 +1,75 @@ +import type { DatabaseConfig } from '@fastgpt/global/core/dataset/type'; +import { AsyncDB } from './asyncDB'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../../common/system/log'; + +export class MysqlClient extends AsyncDB { + override async get_all_table_names(): Promise> { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + const queryRunner = this.db.createQueryRunner(); + // try-finally to ensure the queryRunner is released + try { + // 获取当前数据库名 + const dbName = this.db.options.database; + + // 查询所有表名 + const tables: { TABLE_NAME: string }[] = await queryRunner.query( + `SELECT TABLE_NAME + FROM information_schema.tables + WHERE table_schema = ? + AND TABLE_TYPE = 'BASE TABLE'`, + [dbName] + ); + + return tables.map((t) => t.TABLE_NAME); + } finally { + await queryRunner.release(); + } + } + static fromConfig(config: DatabaseConfig): MysqlClient { + const db = AsyncDB.from_uri(config); + return new MysqlClient(db, config); + } + + override async checkConnection(): Promise { + try { + await super.checkConnection(); + return true; + } catch (err: any) { + addLog.warn('[checkConnection]:', err); + + const code = err?.code; + + switch (code) { + case 'ER_ACCESS_DENIED_ERROR': // username or password error + return Promise.reject(DatabaseErrEnum.authError); + + case 'ER_BAD_DB_ERROR': // database not found + return Promise.reject(DatabaseErrEnum.databaseNameError); + + case 'PROTOCOL_CONNECTION_LOST': // connection lost + return Promise.reject(DatabaseErrEnum.connectionLost); + + case 'ECONNREFUSED': // Error: Connection Refused + return Promise.reject(DatabaseErrEnum.econnRefused); + + case 'ETIMEDOUT': // timeout error + return Promise.reject(DatabaseErrEnum.connectionTimeout); + + case 'ENOTFOUND': // url error + return Promise.reject(DatabaseErrEnum.connectionFailed); + + case 'EHOSTUNREACH': // address error + return Promise.reject(DatabaseErrEnum.hostError); + + case 'ERR_SOCKET_BAD_PORT': // port error + return Promise.reject(DatabaseErrEnum.databasePortError); + + default: // other + return Promise.reject(DatabaseErrEnum.checkError); + } + } + } +} diff --git a/packages/service/core/dataset/database/model/oracle.ts b/packages/service/core/dataset/database/model/oracle.ts new file mode 100644 index 000000000000..8c12d8dd6588 --- /dev/null +++ b/packages/service/core/dataset/database/model/oracle.ts @@ -0,0 +1,302 @@ +import type { + DatabaseConfig, + TableSchemaType, + ColumnSchemaType, + ForeignKeySchemaType +} from '@fastgpt/global/core/dataset/type'; +import { AsyncDB } from './asyncDB'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../../common/system/log'; +import { truncateText, isStringType, convertValueToString } from './utils'; + +export class OracleClient extends AsyncDB { + /** + * 获取所有表名 + * Oracle 使用 ALL_TABLES 视图查询指定 owner 下的表 + */ + override async get_all_table_names(): Promise> { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + + try { + // 使用 schema 或默认使用用户名作为 owner + const owner = (this.config.schema || this.config.user).toUpperCase(); + + const tables: { TABLE_NAME: string }[] = await queryRunner.query( + `SELECT TABLE_NAME + FROM ALL_TABLES + WHERE OWNER = :1 + AND NESTED = 'NO' + ORDER BY TABLE_NAME`, + [owner] + ); + + return tables.map((t) => t.TABLE_NAME); + } finally { + await queryRunner.release(); + } + } + + /** + * Oracle 使用 FETCH FIRST n ROWS ONLY (12c+) 语法 + */ + protected override buildSampleQuery( + tableName: string, + columnName: string, + limit: number + ): string { + return ` + SELECT DISTINCT ${this.getProtectedIdentifier(columnName)} + FROM ${this.getProtectedIdentifier(tableName)} + WHERE ${this.getProtectedIdentifier(columnName)} IS NOT NULL + FETCH FIRST ${limit} ROWS ONLY + `; + } + + /** + * 获取表详细信息,包括列和描述 + * Oracle 需要使用特定的系统视图获取注释信息 + */ + override async get_table_info( + tableName: string, + getExamples: boolean = false + ): Promise { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + const owner = (this.config.schema || this.config.user).toUpperCase(); + + try { + // 1. 合并查询:表注释、列信息、列注释和主键信息(优化为单次查询) + const columnsResult: { + TABLE_COMMENTS: string | null; + COLUMN_NAME: string; + DATA_TYPE: string; + NULLABLE: string; + DATA_DEFAULT: string | null; + COMMENTS: string | null; + IS_PRIMARY_KEY: number; + }[] = await queryRunner.query( + `SELECT + tc.COMMENTS AS TABLE_COMMENTS, + c.COLUMN_NAME, + c.DATA_TYPE, + c.NULLABLE, + c.DATA_DEFAULT, + cc.COMMENTS, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PRIMARY_KEY + FROM ALL_TAB_COLUMNS c + LEFT JOIN ALL_COL_COMMENTS cc + ON c.OWNER = cc.OWNER + AND c.TABLE_NAME = cc.TABLE_NAME + AND c.COLUMN_NAME = cc.COLUMN_NAME + LEFT JOIN ALL_TAB_COMMENTS tc + ON c.OWNER = tc.OWNER + AND c.TABLE_NAME = tc.TABLE_NAME + LEFT JOIN ( + SELECT cons.OWNER, cons.TABLE_NAME, cols.COLUMN_NAME + FROM ALL_CONSTRAINTS cons + JOIN ALL_CONS_COLUMNS cols + ON cons.OWNER = cols.OWNER + AND cons.CONSTRAINT_NAME = cols.CONSTRAINT_NAME + WHERE cons.CONSTRAINT_TYPE = 'P' + ) pk ON c.OWNER = pk.OWNER + AND c.TABLE_NAME = pk.TABLE_NAME + AND c.COLUMN_NAME = pk.COLUMN_NAME + WHERE c.OWNER = :1 AND c.TABLE_NAME = :2 + ORDER BY c.COLUMN_ID`, + [owner, tableName] + ); + + // 检查表是否存在 + if (columnsResult.length === 0) { + addLog.error('[Oracle] Table does not exist:', { owner, tableName }); + return Promise.reject(DatabaseErrEnum.fetchInfoError); + } + + const tableComment = columnsResult[0]?.TABLE_COMMENTS || ''; + const primaryKeys = columnsResult + .filter((col) => col.IS_PRIMARY_KEY === 1) + .map((col) => col.COLUMN_NAME); + + // 2. 获取外键信息 + const foreignKeysResult: { + CONSTRAINT_NAME: string; + COLUMN_NAME: string; + R_OWNER: string; + R_TABLE_NAME: string; + R_COLUMN_NAME: string; + }[] = await queryRunner.query( + `SELECT + c.CONSTRAINT_NAME, + cc.COLUMN_NAME, + c.R_OWNER, + rc.TABLE_NAME AS R_TABLE_NAME, + rcc.COLUMN_NAME AS R_COLUMN_NAME + FROM ALL_CONSTRAINTS c + JOIN ALL_CONS_COLUMNS cc ON c.OWNER = cc.OWNER + AND c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME + JOIN ALL_CONSTRAINTS rc ON c.R_OWNER = rc.OWNER + AND c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME + JOIN ALL_CONS_COLUMNS rcc ON rc.OWNER = rcc.OWNER + AND rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME + AND cc.POSITION = rcc.POSITION + WHERE c.OWNER = :1 + AND c.TABLE_NAME = :2 + AND c.CONSTRAINT_TYPE = 'R'`, + [owner, tableName] + ); + + const foreignKeyColumns = new Set(foreignKeysResult.map((fk) => fk.COLUMN_NAME)); + + // 3. 构建列信息 + const columns: Record = {}; + + for (const col of columnsResult) { + let examples: string[] = []; + let valueIndex = false; + + if (getExamples) { + try { + const sql = this.buildSampleQuery(tableName, col.COLUMN_NAME, this.sample_value_num); + const result = await queryRunner.query(sql); + + for (const row of result) { + const value = row[col.COLUMN_NAME]; + if (value !== null && value !== undefined) { + const strValue = truncateText(convertValueToString(value), this.max_string_length); + examples.push(strValue); + } + } + + // Oracle 字符串类型检查 + const stringTypes = ['VARCHAR2', 'NVARCHAR2', 'CHAR', 'NCHAR', 'CLOB', 'NCLOB', 'LONG']; + if (stringTypes.includes(col.DATA_TYPE.toUpperCase()) && examples.length > 0) { + valueIndex = true; + } + } catch (error) { + addLog.warn(`[Oracle] Failed to get sample data for column ${col.COLUMN_NAME}:`, { + error + }); + examples = []; + } + } + + const fkInfo = foreignKeysResult.filter((fk) => fk.COLUMN_NAME === col.COLUMN_NAME); + + columns[col.COLUMN_NAME] = { + columnName: col.COLUMN_NAME, + columnType: col.DATA_TYPE, + description: col.COMMENTS || '', + forbid: false, + valueIndex, + examples, + isNullable: col.NULLABLE === 'Y', + defaultValue: col.DATA_DEFAULT, + isAutoIncrement: false, // Oracle 使用 SEQUENCE,不是自增列 + isPrimaryKey: primaryKeys.includes(col.COLUMN_NAME), + isForeignKey: foreignKeyColumns.has(col.COLUMN_NAME), + relatedColumns: fkInfo.map((fk) => fk.R_COLUMN_NAME) + }; + } + + // 4. 构建外键列表 + const foreignKeys: ForeignKeySchemaType[] = foreignKeysResult.map((fk) => ({ + name: fk.CONSTRAINT_NAME, + column: fk.COLUMN_NAME, + referredSchema: fk.R_OWNER, + referredTable: fk.R_TABLE_NAME, + referredColumns: fk.R_COLUMN_NAME + })); + + return { + tableName, + description: tableComment, + columns, + foreignKeys, + primaryKeys, + constraints: [], + exist: true, + lastUpdated: new Date() + }; + } catch (error) { + addLog.error('[Oracle] Failed to get table info:', { tableName, error }); + return Promise.reject(DatabaseErrEnum.fetchInfoError); + } finally { + await queryRunner.release(); + } + } + + /** + * 从配置创建客户端实例 + */ + static fromConfig(config: DatabaseConfig): OracleClient { + const db = AsyncDB.from_uri(config); + return new OracleClient(db, config); + } + + /** + * 连接检查,处理 Oracle 特有错误码 + * Oracle 需要使用 SELECT 1 FROM DUAL 而非 SELECT 1 + */ + override async checkConnection(): Promise { + try { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + // Oracle 使用 DUAL 虚拟表进行连接测试 + await this.db.query('SELECT 1 FROM DUAL'); + return true; + } catch (err: any) { + const code = err?.code; + const errorNum = err?.errorNum; // Oracle 特有的错误编号 + + addLog.warn('[Oracle checkConnection]:', { + code, + errorNum, + message: err?.message + }); + + // Oracle 特有错误码处理 + switch (errorNum) { + case 1017: // ORA-01017: invalid username/password + return Promise.reject(DatabaseErrEnum.authError); + case 12154: // ORA-12154: TNS:could not resolve the connect identifier + case 12541: // ORA-12541: TNS:no listener + return Promise.reject(DatabaseErrEnum.hostError); + case 12170: // ORA-12170: TNS:Connect timeout + return Promise.reject(DatabaseErrEnum.connectionTimeout); + case 12514: // ORA-12514: TNS:listener does not know of service + return Promise.reject(DatabaseErrEnum.connectionFailed); + case 1033: // ORA-01033: ORACLE initialization or shutdown in progress + return Promise.reject(DatabaseErrEnum.econnRefused); + } + + // 标准 Node.js 错误 + switch (code) { + case 'ECONNREFUSED': + return Promise.reject(DatabaseErrEnum.econnRefused); + case 'ETIMEDOUT': + case 'ETIMEOUT': + return Promise.reject(DatabaseErrEnum.connectionTimeout); + case 'ENOTFOUND': + return Promise.reject(DatabaseErrEnum.connectionFailed); + case 'ERR_SOCKET_BAD_PORT': + return Promise.reject(DatabaseErrEnum.databasePortError); + default: + addLog.error('[Oracle checkConnection] Unknown error:', { + code, + errorNum, + message: err?.message, + stack: err?.stack + }); + return Promise.reject(DatabaseErrEnum.checkError); + } + } + } +} diff --git a/packages/service/core/dataset/database/model/postgresql.ts b/packages/service/core/dataset/database/model/postgresql.ts new file mode 100644 index 000000000000..a8195f992abc --- /dev/null +++ b/packages/service/core/dataset/database/model/postgresql.ts @@ -0,0 +1,93 @@ +import type { DatabaseConfig } from '@fastgpt/global/core/dataset/type'; +import { AsyncDB } from './asyncDB'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import { addLog } from '../../../../common/system/log'; + +export class PostgresqlClient extends AsyncDB { + override async get_all_table_names(): Promise> { + if (!this.db.isInitialized) { + await this.db.initialize(); + } + + const queryRunner = this.db.createQueryRunner(); + + try { + const dbName = this.db.options.database; + + const schema = (this.config as any).schema || 'public'; + + const tables: { table_name: string }[] = await queryRunner.query( + `SELECT table_name + FROM information_schema.tables + WHERE table_catalog = $1 + AND table_schema = $2 + AND table_type = 'BASE TABLE' + ORDER BY table_name`, + [dbName, schema] + ); + + return tables.map((t) => t.table_name); + } finally { + await queryRunner.release(); + } + } + + static fromConfig(config: DatabaseConfig): PostgresqlClient { + const db = AsyncDB.from_uri(config); + return new PostgresqlClient(db, config); + } + + override async checkConnection(): Promise { + try { + await super.checkConnection(); + return true; + } catch (err: any) { + addLog.warn('[PostgreSQL checkConnection]:', err); + + // PostgreSQL SQLSTATE error codes + const code = err?.code; + + switch (code) { + // Authentication errors (SQLSTATE Class 28) + case '28000': // invalid_authorization_specification + case '28P01': // invalid_password + return Promise.reject(DatabaseErrEnum.authError); + + // Invalid database name (SQLSTATE 3D000) + case '3D000': // invalid_catalog_name + return Promise.reject(DatabaseErrEnum.databaseNameError); + + // Connection errors (SQLSTATE Class 08) + case '08006': // connection_failure + case '08003': // connection_does_not_exist + case '08001': // sqlclient_unable_to_establish_sqlconnection + return Promise.reject(DatabaseErrEnum.connectionLost); + + // Node.js system-level errors (same as MySQL) + case 'ECONNREFUSED': // Connection refused - PostgreSQL server not running + return Promise.reject(DatabaseErrEnum.econnRefused); + + case 'ETIMEDOUT': // Connection timeout - network issue or firewall + return Promise.reject(DatabaseErrEnum.connectionTimeout); + + case 'ENOTFOUND': // Host not found - DNS resolution failure + return Promise.reject(DatabaseErrEnum.connectionFailed); + + case 'EHOSTUNREACH': // Host unreachable - network routing issue + return Promise.reject(DatabaseErrEnum.hostError); + + case 'ERR_SOCKET_BAD_PORT': // Invalid port number + return Promise.reject(DatabaseErrEnum.databasePortError); + + // Catch-all for unknown errors + default: + addLog.error('[PostgreSQL checkConnection] Unknown error:', { + code, + message: err?.message, + detail: err?.detail + }); + return Promise.reject(DatabaseErrEnum.checkError); + } + } + } +} diff --git a/packages/service/core/dataset/database/model/utils.ts b/packages/service/core/dataset/database/model/utils.ts new file mode 100644 index 000000000000..278e14c48185 --- /dev/null +++ b/packages/service/core/dataset/database/model/utils.ts @@ -0,0 +1,29 @@ +import type { ColumnType } from 'typeorm'; +export class RequestValidationDiagnosisError extends Error {} + +export function isStringType(columnType: ColumnType): boolean { + const stringTypes = ['varchar', 'char', 'text', 'string', 'nvarchar', 'nchar', 'ntext']; + const typeStr = String(columnType).toLowerCase(); + return stringTypes.some((type) => typeStr.includes(type)); +} + +export function convertValueToString(value: any): string { + if (value === null || value === undefined) { + return ''; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +export function truncateText(text: string, maxLength: number = 1024): string { + return text.length <= maxLength ? text : text.substring(0, maxLength - 3) + '...'; +} diff --git a/packages/service/core/dataset/indexTransform/controller.ts b/packages/service/core/dataset/indexTransform/controller.ts new file mode 100644 index 000000000000..477376b48af6 --- /dev/null +++ b/packages/service/core/dataset/indexTransform/controller.ts @@ -0,0 +1,352 @@ +import { MongoDatasetSynonymMapping } from '../synonym/mappingSchema'; +import { MongoDatasetSynonym } from '../synonym/schema'; +import { Types } from '../../../common/mongo'; +import { buildSynonymDict, applySynonymTransform } from './utils'; +import type { + DatasetSynonymMappingSchemaType, + DatasetDataIndexItemType +} from '@fastgpt/global/core/dataset/type'; + +/** + * 获取知识库的所有同义词映射 + * + * @param params - 查询参数 + * @returns 同义词映射数组 + * + * @example + * const mappings = await getDatasetSynonymMappings({ + * teamId: 'team-123', + * datasetId: 'dataset-456' + * }); + */ +export async function getDatasetSynonymMappings({ + teamId, + datasetId +}: { + teamId: string; + datasetId: string; +}): Promise { + return await MongoDatasetSynonymMapping.find({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }) + .select('_id standardizedTerm synonymTerms') + .lean(); +} + +/** + * 根据同义词文件ID获取映射 + * + * @param params - 查询参数 + * @returns 同义词映射数组 + * + * @example + * const mappings = await getSynonymMappingsByFileId({ + * fileId: 'file-123' + * }); + */ +export async function getSynonymMappingsByFileId({ + fileId +}: { + fileId: string; +}): Promise { + return await MongoDatasetSynonymMapping.find({ + synonymFileId: new Types.ObjectId(fileId) + }) + .select('_id standardizedTerm synonymTerms') + .lean(); +} + +/** + * 检查知识库是否有同义词配置 + * + * @param params - 查询参数 + * @returns 是否有同义词 + * + * @example + * const hasSynonym = await hasDatasetSynonym({ + * teamId: 'team-123', + * datasetId: 'dataset-456' + * }); + */ +export async function hasDatasetSynonym({ + teamId, + datasetId +}: { + teamId: string; + datasetId: string; +}): Promise { + const count = await MongoDatasetSynonym.countDocuments({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }); + return count > 0; +} + +/** + * 统计知识库的同义词映射数量 + * + * @param params - 查询参数 + * @returns 映射数量 + * + * @example + * const count = await countDatasetSynonymMappings({ + * teamId: 'team-123', + * datasetId: 'dataset-456' + * }); + */ +export async function countDatasetSynonymMappings({ + teamId, + datasetId +}: { + teamId: string; + datasetId: string; +}): Promise { + return await MongoDatasetSynonymMapping.countDocuments({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }); +} + +/** + * 批量获取多个知识库的同义词转换配置 + * + * @param params - 查询参数 + * @returns { [datasetId]: config } 映射,无配置的知识库不包含在内 + * + * @example + * const configs = await getBatchDatasetSynonymConfig({ + * teamId: 'team-123', + * datasetIds: ['dataset-1', 'dataset-2'] + * }); + */ +export async function getBatchDatasetSynonymConfig({ + teamId, + datasetIds +}: { + teamId: string; + datasetIds: string[]; +}): Promise< + Record< + string, + { + synonymDict: Record; + synonymMappingMap: Record; + synonymFileId: string; + } + > +> { + const result: Record< + string, + { + synonymDict: Record; + synonymMappingMap: Record; + synonymFileId: string; + } + > = {}; + + // 1. 批量检查同义词文件 + const synonymFiles = await MongoDatasetSynonym.find({ + teamId: new Types.ObjectId(teamId), + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) } + }) + .select('_id datasetId') + .lean(); + + if (synonymFiles.length === 0) { + return result; + } + + // 2. 批量获取所有映射 + const mappings = await MongoDatasetSynonymMapping.find({ + teamId: new Types.ObjectId(teamId), + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) } + }) + .select('_id datasetId standardizedTerm synonymTerms') + .lean(); + + // 3. 按 datasetId 分组 + const mappingsByDataset: Record = {}; + for (const mapping of mappings) { + const datasetId = String(mapping.datasetId); + if (!mappingsByDataset[datasetId]) { + mappingsByDataset[datasetId] = []; + } + mappingsByDataset[datasetId].push(mapping); + } + + // 4. 为每个知识库构建配置 + for (const file of synonymFiles) { + const datasetId = String(file.datasetId); + const datasetMappings = mappingsByDataset[datasetId]; + + if (!datasetMappings || datasetMappings.length === 0) { + continue; + } + + const formattedMappings = datasetMappings.map((mapping) => ({ + _id: String(mapping._id), + standardizedTerm: mapping.standardizedTerm, + synonymTerms: mapping.synonymTerms + })); + + const { synonymDict, synonymMappingMap } = buildSynonymDict(formattedMappings); + + result[datasetId] = { + synonymDict, + synonymMappingMap, + synonymFileId: String(file._id) + }; + } + + return result; +} + +/** + * 获取知识库的同义词转换配置 + * 直接返回用于 applySynonymTransform 的字典和映射表 + * + * @param params - 查询参数 + * @returns { synonymDict, synonymMappingMap, synonymFileId } 或 null(无同义词配置) + * + * @example + * const config = await getDatasetSynonymConfig({ + * teamId: 'team-123', + * datasetId: 'dataset-456' + * }); + * + * if (config) { + * const { synonymDict, synonymMappingMap } = config; + * const { transformedText, transformations } = applySynonymTransform( + * originalText, + * synonymDict, + * synonymMappingMap + * ); + * } + */ +export async function getDatasetSynonymConfig({ + teamId, + datasetId +}: { + teamId: string; + datasetId: string; +}): Promise<{ + synonymDict: Record; + synonymMappingMap: Record; + synonymFileId: string; +} | null> { + // 1. 检查该知识库是否有同义词文件 + const synonymFile = await MongoDatasetSynonym.findOne({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }) + .select('_id') + .lean(); + + if (!synonymFile) { + return null; // 没有同义词文件 + } + + // 2. 获取所有同义词映射 + const mappings = await MongoDatasetSynonymMapping.find({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }) + .select('_id standardizedTerm synonymTerms') + .lean(); + + if (mappings.length === 0) { + return null; // 没有映射数据 + } + + // 3. 转换格式并构建字典和映射表 + const formattedMappings = mappings.map((mapping) => ({ + _id: String(mapping._id), + standardizedTerm: mapping.standardizedTerm, + synonymTerms: mapping.synonymTerms + })); + + const { synonymDict, synonymMappingMap } = buildSynonymDict(formattedMappings); + + return { + synonymDict, + synonymMappingMap, + synonymFileId: String(synonymFile._id) + }; +} + +/** + * 实时同义词标准化 (用于新增数据) + * 在训练队列中调用,对新增数据进行实时标准化 + * + * @param params - 标准化参数 + * @returns 标准化后的数据,包含原文q/a和标准化后的索引 + * + * @example + * const result = await realtimeSynonymStandardize({ + * teamId: 'team-123', + * datasetId: 'dataset-456', + * q: '如何退费?', + * a: '请联系客服办理退费', + * indexes: [{ type: 'chunk', text: '如何退费?请联系客服办理退费' }], + * isUserCustom: false + * }); + * // result.q => '如何退费?' (原文不变) + * // result.qStandardized => '如何退款?' (标准化后,用于全文检索) + * // result.indexes[0].text => '如何退款?请联系客服办理退款' (标准化后,用于向量化) + */ +export async function realtimeSynonymStandardize({ + teamId, + datasetId, + q, + a, + indexes +}: { + teamId: string; + datasetId: string; + q: string; + a?: string; + indexes: Omit[]; +}): Promise<{ + q: string; + a?: string; + qStandardized: string; + aStandardized?: string; + indexes: Omit[]; +}> { + // 1. 获取同义词配置 + const synonymConfig = await getDatasetSynonymConfig({ teamId, datasetId }); + + if (!synonymConfig) { + return { q, a, qStandardized: q, aStandardized: a, indexes }; + } + + const { synonymDict, synonymMappingMap, synonymFileId } = synonymConfig; + + // 2. 标准化 q/a (用于全文检索) + const qResult = applySynonymTransform(q, synonymDict, synonymMappingMap); + const aResult = a ? applySynonymTransform(a, synonymDict, synonymMappingMap) : null; + + // 4. 标准化 indexes - 所有索引都要走增强 + const newIndexes = indexes.map((index) => { + const indexResult = applySynonymTransform(index.text, synonymDict, synonymMappingMap); + return { + ...index, + text: indexResult.transformedText, + ...(indexResult.transformations.length > 0 && { + synonymMetadata: { + synonymFileIds: [synonymFileId], + transformations: indexResult.transformations + } + }) + }; + }); + + return { + q, // 原文不变,存储到 MongoDB + a, // 原文不变,存储到 MongoDB + qStandardized: qResult.transformedText, // 标准化后,用于全文检索 + aStandardized: aResult?.transformedText || a, // 标准化后,用于全文检索 + indexes: newIndexes + }; +} diff --git a/packages/service/core/dataset/indexTransform/index.ts b/packages/service/core/dataset/indexTransform/index.ts new file mode 100644 index 000000000000..0f8fe2bc7b8f --- /dev/null +++ b/packages/service/core/dataset/indexTransform/index.ts @@ -0,0 +1,15 @@ +/** + * Dataset Index Transform Module + * 数据集索引转换模块 + * + * 提供同义词标准化转换工具函数 + */ + +// Utils - 核心转换算法 +export { + applySynonymTransform, + buildSynonymDict, + validateTransformation, + type SynonymTransformResult, + type TransformationRecordType +} from './utils'; diff --git a/packages/service/core/dataset/indexTransform/strategies.ts b/packages/service/core/dataset/indexTransform/strategies.ts new file mode 100644 index 000000000000..dbab400a91d0 --- /dev/null +++ b/packages/service/core/dataset/indexTransform/strategies.ts @@ -0,0 +1,333 @@ +/** + * 同义词替换策略模式 + * + * 设计目标: + * 1. 支持多种替换策略,方便后续迭代优化 + * 2. 策略可配置、可切换 + * 3. 保持统一的接口和转换记录格式 + */ + +import type { TransformationRecordType, SynonymTransformResult } from './utils'; + +/** + * 同义词替换策略接口 + */ +export interface SynonymReplacementStrategy { + /** + * 策略名称 + */ + name: string; + + /** + * 策略描述 + */ + description: string; + + /** + * 执行同义词替换 + * + * @param originalText 原始文本 + * @param synonymDict 同义词字典 {标准词: [同义词数组]} + * @param synonymMappingMap 同义词映射ID映射表 {同义词: synonymMappingId} + * @returns 转换结果 {transformedText, transformations} + */ + transform( + originalText: string, + synonymDict: Record, + synonymMappingMap: Record + ): SynonymTransformResult; +} + +/** + * 匹配项接口(内部使用) + */ +export interface MatchItem { + start: number; + end: number; + alias: string; + stdName: string; + length: number; + matchedText: string; + synonymMappingId: string; +} + +/** + * 检查是否为版本号格式 (如: v1.2.3, 111.222.333) + */ +export function isVersionFormat(text: string): boolean { + return /^v?[0-9]{1,3}(\.[0-9]{0,3}){0,2}/.test(text); +} + +/** + * 智能边界替换策略(当前默认策略) + * + * 特点: + * 1. 智能边界检测(字母、数字、版本号) + * 2. 重叠匹配处理(长词优先) + * 3. 避免相邻重复替换 + * 4. 大小写不敏感 + */ +export class SmartBoundaryStrategy implements SynonymReplacementStrategy { + name = 'smart-boundary'; + description = '智能边界检测策略,支持版本号识别、重叠处理、长词优先'; + + transform( + originalText: string, + synonymDict: Record, + synonymMappingMap: Record + ): SynonymTransformResult { + if (!originalText || !synonymDict || Object.keys(synonymDict).length === 0) { + return { transformedText: originalText, transformations: [] }; + } + + // 第一步:收集所有匹配 + const allMatches: MatchItem[] = this.collectMatches( + originalText, + synonymDict, + synonymMappingMap + ); + + if (allMatches.length === 0) { + return { transformedText: originalText, transformations: [] }; + } + + // 第二步:按长度降序排序,优先处理长匹配 + allMatches.sort((a, b) => b.length - a.length); + + // 第三步:应用智能去重策略 - 处理重叠匹配 + const finalMatches = this.deduplicateOverlappingMatches(allMatches); + + // 第四步:避免相邻词的重复替换 + const filteredMatches = this.filterAdjacentDuplicates(finalMatches); + + // 第五步:构建转换记录并执行替换 + return this.applyReplacements(originalText, filteredMatches); + } + + /** + * 收集所有匹配项 + */ + private collectMatches( + originalText: string, + synonymDict: Record, + synonymMappingMap: Record + ): MatchItem[] { + const allMatches: MatchItem[] = []; + + for (const [stdName, aliasList] of Object.entries(synonymDict)) { + for (const alias of aliasList) { + // 使用正则表达式查找所有匹配(不区分大小写) + // 使用负向后顾断言 (? 0 ? text[start - 1] : ' '; + const afterChar = end < text.length ? text[end] : ' '; + const afterChars = text.substring(end, Math.min(end + 12, text.length)); + + // 检查是否跟随版本号 (如: AFv1.2.3, AF111.222.333) + const isFollowVersion = isVersionFormat(afterChars); + + // 智能边界处理(与旧逻辑保持一致): + // - 前面不能是字母(但可以是数字、空格、标点、括号等) + // - 后面不能是字母(除非是版本号格式),但可以是数字、标点等 + const validBefore = !/[a-zA-Z]/.test(beforeChar); + const validAfter = !/[a-zA-Z]/.test(afterChar) || isFollowVersion; + + return validBefore && validAfter; + } + + /** + * 去重重叠匹配 + */ + private deduplicateOverlappingMatches(matches: MatchItem[]): MatchItem[] { + const finalMatches: MatchItem[] = []; + const occupiedRanges: Array<[number, number]> = []; + + for (const matchItem of matches) { + const { start, end } = matchItem; + + // 检查是否与已选择的匹配重叠 + const hasOverlap = occupiedRanges.some(([occStart, occEnd]) => { + return !(end <= occStart || start >= occEnd); + }); + + if (!hasOverlap) { + finalMatches.push(matchItem); + occupiedRanges.push([start, end]); + } + } + + return finalMatches; + } + + /** + * 过滤相邻重复 + */ + private filterAdjacentDuplicates(matches: MatchItem[]): MatchItem[] { + // 先按位置排序 + matches.sort((a, b) => a.start - b.start); + + const filteredMatches: MatchItem[] = []; + for (let i = 0; i < matches.length; i++) { + const current = matches[i]; + let shouldSkip = false; + + // 检查是否与前一个匹配相邻且指向同一标准词 + if (i > 0) { + const prev = matches[i - 1]; + if (current.stdName === prev.stdName && current.start === prev.end) { + // 如果当前匹配比前一个短或相等,跳过 + if (current.length <= prev.length) { + shouldSkip = true; + } + } + } + + if (!shouldSkip) { + filteredMatches.push(current); + } + } + + return filteredMatches; + } + + /** + * 应用替换 + */ + private applyReplacements(originalText: string, matches: MatchItem[]): SynonymTransformResult { + const transformations: TransformationRecordType[] = []; + let transformedText = originalText; + + // 按位置从后往前排序,避免替换时位置偏移影响 + const sortedMatches = [...matches].sort((a, b) => b.start - a.start); + + // 为了正确计算 transformedStartPos/EndPos,需要按正序遍历计算偏移 + let offset = 0; + for (let i = 0; i < matches.length; i++) { + const matchItem = matches[i]; + + // 跳过无意义的替换(不记录转换信息,因为没有实际替换) + if (matchItem.matchedText === matchItem.stdName) { + continue; + } + + // 记录转换信息 + transformations.push({ + originalStartPos: matchItem.start, + originalEndPos: matchItem.end, + originalTerm: matchItem.matchedText, + transformedStartPos: matchItem.start + offset, + transformedEndPos: matchItem.start + offset + matchItem.stdName.length, + standardizedTerm: matchItem.stdName, + synonymMappingId: matchItem.synonymMappingId + }); + + // 累计偏移量 + offset += matchItem.stdName.length - matchItem.length; + } + + // 从后往前执行替换 + for (const matchItem of sortedMatches) { + // 优化:跳过无意义的替换(当匹配文本和标准词相同时) + if (matchItem.matchedText === matchItem.stdName) { + continue; + } + + const before = transformedText.substring(0, matchItem.start); + const after = transformedText.substring(matchItem.end); + transformedText = before + matchItem.stdName + after; + } + + // 注意:不做后处理去重,以保证 transformations 中的位置信息准确 + // 如果原文中有相邻的同义词(如 "信服 sangfor"),替换后会变成 "深信服 深信服" + // 这种情况很少见,且保留位置信息对于恢复原文更重要 + + return { transformedText, transformations }; + } +} + +/** + * 策略注册表 + */ +export class StrategyRegistry { + private strategies: Map = new Map(); + private defaultStrategy: string = 'smart-boundary'; + + constructor() { + // 注册内置策略 + this.register(new SmartBoundaryStrategy()); + } + + /** + * 注册策略 + */ + register(strategy: SynonymReplacementStrategy): void { + this.strategies.set(strategy.name, strategy); + } + + /** + * 获取策略 + */ + getStrategy(name?: string): SynonymReplacementStrategy { + const strategyName = name || this.defaultStrategy; + const strategy = this.strategies.get(strategyName); + + if (!strategy) { + throw new Error(`Strategy "${strategyName}" not found`); + } + + return strategy; + } + + /** + * 设置默认策略 + */ + setDefaultStrategy(name: string): void { + if (!this.strategies.has(name)) { + throw new Error(`Strategy "${name}" not found`); + } + this.defaultStrategy = name; + } + + /** + * 获取所有可用策略 + */ + getAllStrategies(): Array<{ name: string; description: string }> { + return Array.from(this.strategies.values()).map((strategy) => ({ + name: strategy.name, + description: strategy.description + })); + } +} + +// 全局策略注册表单例 +export const strategyRegistry = new StrategyRegistry(); diff --git a/packages/service/core/dataset/indexTransform/utils.ts b/packages/service/core/dataset/indexTransform/utils.ts new file mode 100644 index 000000000000..4a110c19e6c8 --- /dev/null +++ b/packages/service/core/dataset/indexTransform/utils.ts @@ -0,0 +1,183 @@ +/** + * 文本转换记录(单个转换项) + * 记录一次同义词替换的详细信息 + */ +export type TransformationRecordType = { + // 在原始文本中的位置信息 + originalStartPos: number; // 原始文本中的起始位置(字符索引) + originalEndPos: number; // 原始文本中的结束位置(字符索引) + originalTerm: string; // 原始词汇(非标准词) + + // 在转换后文本中的位置信息 + transformedStartPos: number; // 转换后文本中的起始位置(字符索引) + transformedEndPos: number; // 转换后文本中的结束位置(字符索引) + standardizedTerm: string; // 标准化词 + + // 关联信息 + synonymMappingId: string; // 关联的同义词映射记录ID(MongoSynonymMapping._id) +}; + +/** + * 同义词转换结果 + */ +export interface SynonymTransformResult { + transformedText: string; + transformations: TransformationRecordType[]; +} + +import { strategyRegistry } from './strategies'; + +/** + * 应用同义词转换,生成标准化文本和转换记录 + * + * 使用策略模式,支持多种替换策略,方便后续迭代优化 + * + * @param originalText 原始文本 + * @param synonymDict 同义词字典 {标准词: [同义词数组]} + * @param synonymMappingMap 同义词映射ID映射表 {originalTerm: synonymMappingId} + * @param strategyName 策略名称(可选,默认使用智能边界策略) + * @returns 转换结果 {transformedText, transformations} + * + * @example + * const originalText = "我爱信服,信服的产品很好用"; + * const synonymDict = { "深信服": ["信服", "sanfor"] }; + * const synonymMappingMap = { "信服": "mapping-id-1", "sanfor": "mapping-id-1" }; + * + * // 使用默认策略(智能边界) + * const result = applySynonymTransform(originalText, synonymDict, synonymMappingMap); + * + * // 指定策略 + * const result2 = applySynonymTransform(originalText, synonymDict, synonymMappingMap, 'simple-regex'); + */ +export function applySynonymTransform( + originalText: string, + synonymDict: Record, + synonymMappingMap: Record, + strategyName?: string +): SynonymTransformResult { + // 获取策略并执行转换 + const strategy = strategyRegistry.getStrategy(strategyName); + return strategy.transform(originalText, synonymDict, synonymMappingMap); +} + +/** + * 构建同义词字典和映射表 + * 从同义词映射数据构建用于转换的字典和映射表 + * + * @param synonymMappings 同义词映射数据数组 + * @returns {synonymDict, synonymMappingMap} + * + * @example + * const synonymMappings = [ + * { + * _id: "mapping-id-1", + * standardizedTerm: "深信服", + * synonymTerms: ["信服", "sanfor"] + * } + * ]; + * const { synonymDict, synonymMappingMap } = buildSynonymDict(synonymMappings); + * // synonymDict => { "深信服": ["信服", "sanfor"] } + * // synonymMappingMap => { "信服": "mapping-id-1", "sanfor": "mapping-id-1" } + */ +export function buildSynonymDict( + synonymMappings: Array<{ + _id: string; + standardizedTerm: string; + synonymTerms: string[]; + }> +): { + synonymDict: Record; + synonymMappingMap: Record; +} { + const synonymDict: Record = {}; + const synonymMappingMap: Record = {}; + + for (const mapping of synonymMappings) { + const { _id, standardizedTerm, synonymTerms } = mapping; + + // 构建字典 + if (!synonymDict[standardizedTerm]) { + synonymDict[standardizedTerm] = []; + } + synonymDict[standardizedTerm].push(...synonymTerms); + + // 构建映射表(同义词 -> mapping ID) + for (const synonym of synonymTerms) { + synonymMappingMap[synonym] = _id; + } + } + + return { synonymDict, synonymMappingMap }; +} + +/** + * 验证转换的正确性 + * 通过正向转换后再逆向恢复,验证是否能完全恢复原始文本 + * + * @param originalText 原始文本 + * @param synonymDict 同义词字典 + * @param synonymMappingMap 同义词映射表 + * @returns 是否转换正确 + */ +export function validateTransformation( + originalText: string, + synonymDict: Record, + synonymMappingMap: Record +): boolean { + const { transformedText, transformations } = applySynonymTransform( + originalText, + synonymDict, + synonymMappingMap + ); + const restoredText = restoreOriginalText(transformedText, transformations); + return restoredText === originalText; +} + +/** + * 根据转换记录恢复原始文本 + * 按transformedStartPos降序排列(从后向前恢复,避免位置偏移影响) + * + * @param transformedText 转换后的文本 + * @param transformations 转换记录数组 + * @returns 原始文本 + * + * @example + * const transformedText = "我爱深信服,深信服的产品很好用"; + * const transformations = [ + * { + * originalStartPos: 2, + * originalEndPos: 4, + * originalTerm: "信服", + * transformedStartPos: 2, + * transformedEndPos: 5, + * standardizedTerm: "深信服", + * synonymMappingId: "mapping-id-1" + * }, + * ... + * ]; + * const restored = restoreOriginalText(transformedText, transformations); + * // restored => "我爱信服,信服的产品很好用" + */ +export function restoreOriginalText( + transformedText: string, + transformations: TransformationRecordType[] +): string { + // 按transformedStartPos降序排列(从后向前恢复,避免位置偏移影响) + const sortedTransforms = [...transformations].sort( + (a, b) => b.transformedStartPos - a.transformedStartPos + ); + + let result = transformedText; + + // 从后向前依次恢复 + for (const transform of sortedTransforms) { + const { transformedStartPos, transformedEndPos, originalTerm } = transform; + + // 替换:将标准词换回原始词 + const before = result.substring(0, transformedStartPos); + const after = result.substring(transformedEndPos); + result = before + originalTerm + after; + } + + return result; +} diff --git a/packages/service/core/dataset/parseBackup.ts b/packages/service/core/dataset/parseBackup.ts new file mode 100644 index 000000000000..d3eb128bee4e --- /dev/null +++ b/packages/service/core/dataset/parseBackup.ts @@ -0,0 +1,228 @@ +import * as fs from 'fs'; +import { addLog } from '../../common/system/log'; +import { excelBufferToCSV } from '../../common/file/csv'; +import { detectAndDecodeBuffer } from '../../common/file/encoding'; +import { hashStr } from '@fastgpt/global/common/string/tools'; + +type ParseResult = { + chunks: { + q: string; + a: string; + indexes?: string[]; + imageIdList?: string[]; + metadata?: Map; + }[]; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const parseCsvFast = ( + require('csv-rex/parse') as { + default: (text: string, opts: Record) => string[][]; + } +).default; + +/** + * 将 CSV 格式的备份数据解析为 chunks + * 支持元数据列:q,a 与 indexes 之间的列作为 metadata + */ +export const parseDatasetBackup2Chunks = (rawText: string, imageIdList?: string[]): ParseResult => { + const csvArr = parseCsvFast(rawText, { + header: false, + errorOnEmptyLine: false, + errorOnExtraFields: false, + errorOnMissingFields: false, + newlineChar: rawText.includes('\r\n') ? '\r\n' : '\n' + }); + + if (csvArr.length < 2) { + return { chunks: [] }; + } + var header = csvArr[0].map((h) => h.trim()); + // 查找indexes列的起始位置 + let indexesStartIndex = -1; + for (let i = 0; i < header.length; i++) { + if (header[i] == 'indexes') { + indexesStartIndex = i; + break; + } + } + if (indexesStartIndex == 2) { + const chunks = csvArr + .slice(1) + .map((item) => ({ + q: item[0] || '', + a: item[1] || '', + indexes: item.slice(2).filter((item) => item.trim()), + imageIdList + })) + .filter((item) => item.q || item.a); + return { chunks }; + } else { + // 从q,a到indexes之间的列为元数据列 + const chunks = csvArr + .slice(1) + .map((item) => { + const q = item[0] || ''; + const a = item[1] || ''; + + const indexes = []; + const metadata = new Map(); + for (let i = 2; i < item.length; i++) { + const value = item[i]?.trim(); + if (!value) continue; + if (i >= indexesStartIndex) { + // indexes及之后的列作为indexes + indexes.push(value); + } else { + // 非q、a、indexes列作为metadata + if (!header[i]) continue; + metadata.set(header[i], value); + } + } + return { + q, + a, + indexes: indexes.length > 0 ? indexes : undefined, + imageIdList, + metadata: metadata.size > 0 ? metadata : undefined + }; + }) + .filter((item) => item.q || item.a); + return { chunks }; + } +}; + +/** + * 从 SharedArrayBuffer 解析 CSV 数据 + * Worker 内部使用:主线程通过 SharedArrayBuffer 零拷贝传入二进制数据, + * Worker 内转成字符串后调用 parseDatasetBackup2Chunks + */ +export const parseDatasetBackupFromSharedBuffer = ( + sharedBuffer: SharedArrayBuffer, + bufferSize: number, + imageIdList?: string[] +): ParseResult => { + const uint8Array = new Uint8Array(sharedBuffer, 0, bufferSize); + const rawText = Buffer.from(uint8Array).toString('utf-8'); + return parseDatasetBackup2Chunks(rawText, imageIdList); +}; + +/** + * 快速统计 CSV 备份文件的有效行数(不构建 chunk 对象) + * 用字节扫描替代 Papa.parse,避免 200k 对象分配,速度约为 Papa.parse 的 2.3 倍。 + * 主线程通过 Worker 调用,IPC 仅传回三个字段(chunkCount / hashRawText / rawTextLength), + * 彻底消除结构化克隆大数组的开销。 + * + * @internal Worker 内部使用 + */ +const countCsvValidRows = (text: string): number => { + let count = 0; + let inQuote = false; + let fieldIdx = 0; + let fieldStart = 0; + let firstLine = true; + let hasQ = false; + let hasA = false; + + const COMMA = 44; + const LF = 10; + const CR = 13; + const DQUOTE = 34; + + for (let i = 0; i <= text.length; i++) { + const c = i < text.length ? text.charCodeAt(i) : LF; // 哨兵确保最后一行被处理 + + if (c === DQUOTE) { + inQuote = !inQuote; + continue; + } + if (inQuote) continue; + + if (c === COMMA) { + if (!firstLine) { + if (fieldIdx === 0) hasQ = i > fieldStart; + else if (fieldIdx === 1) hasA = i > fieldStart; + } + fieldIdx++; + fieldStart = i + 1; + } else if (c === LF || (c === CR && i + 1 < text.length && text.charCodeAt(i + 1) === LF)) { + if (!firstLine) { + // 处理只有 q,a 两列(无逗号分隔 a)的情况 + if (fieldIdx === 1) hasA = i > fieldStart; + if (hasQ || hasA) count++; + } + firstLine = false; + fieldIdx = 0; + fieldStart = i + 1; + hasQ = false; + hasA = false; + } + } + + return count; +}; + +/** + * Worker 内部使用:从文件路径直接读取+解码,仅返回行数/哈希/长度,不构建 chunk 对象。 + * 对应生产入口:countDatasetBackupFromFileViaWorker + * + * 相比 parseDatasetBackupFromFile,消除了 Papa.parse 和 200k 对象构建, + * IPC 负载从 ~200k 对象降为 3 个标量,主线程等待时间从 ~1600ms 降至 ~220ms。 + */ +export const countDatasetBackupFromFile = ( + filePath: string, + fileExtension: string +): { chunkCount: number; hashRawText: string; rawTextLength: number } => { + const buffer = fs.readFileSync(filePath); + + let rawText: string; + if (fileExtension === 'xlsx' || fileExtension === 'xls') { + rawText = excelBufferToCSV(buffer) || ''; + if (!rawText) { + if (buffer.length > 500 * 1024) { + throw new Error('template_excel_too_much_data'); + } + throw new Error('template_excel_file_empty'); + } + } else { + rawText = detectAndDecodeBuffer(buffer).content; + } + + return { + chunkCount: countCsvValidRows(rawText), + hashRawText: hashStr(rawText), + rawTextLength: rawText.length + }; +}; + +/** + * Worker 内部使用:从文件路径直接读取+解码+解析 + * 主线程只传 filePath 和 fileExtension,不接触大字符串,不阻塞事件循环 + */ +export const parseDatasetBackupFromFile = ( + filePath: string, + fileExtension: string, + imageIdList?: string[] +): ParseResult & { hashRawText: string; rawTextLength: number } => { + const buffer = fs.readFileSync(filePath); + + let rawText: string; + if (fileExtension === 'xlsx' || fileExtension === 'xls') { + rawText = excelBufferToCSV(buffer) || ''; + // 保留原有校验:node-xlsx 对过大文件可能静默返回空 + if (!rawText) { + if (buffer.length > 500 * 1024) { + throw new Error('template_excel_too_much_data'); + } + throw new Error('template_excel_file_empty'); + } + } else { + rawText = detectAndDecodeBuffer(buffer).content; + } + + return { + ...parseDatasetBackup2Chunks(rawText, imageIdList), + hashRawText: hashStr(rawText), + rawTextLength: rawText.length + }; +}; diff --git a/packages/service/core/dataset/read.ts b/packages/service/core/dataset/read.ts index 626c81d5314d..d83898128327 100644 --- a/packages/service/core/dataset/read.ts +++ b/packages/service/core/dataset/read.ts @@ -5,16 +5,19 @@ import { import { urlsFetch } from '../../common/string/cheerio'; import { type TextSplitProps } from '@fastgpt/global/common/string/textSplitter'; import { axios } from '../../common/api/axios'; -import { readFileContentByBuffer } from '../../common/file/read/utils'; +import { readS3FileContentByBuffer } from '../../common/file/read/utils'; import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; import { getApiDatasetRequest } from './apiDataset'; -import Papa from 'papaparse'; import type { ApiDatasetServerType } from '@fastgpt/global/core/dataset/apiDataset/type'; import { text2Chunks } from '../../worker/function'; +import { parseDatasetBackup2Chunks } from './parseBackup'; +import { addLog } from '../../common/system/log'; import { retryFn } from '@fastgpt/global/common/system/utils'; import { getFileMaxSize } from '../../common/file/utils'; +import { detectFileEncoding } from '@fastgpt/global/common/file/tools'; import { UserError } from '@fastgpt/global/common/error/utils'; import { getS3DatasetSource, S3DatasetSource } from '../../common/s3/sources/dataset'; +import { getS3RawTextSource } from '../../common/s3/sources/rawText'; import { getFileS3Key, isS3ObjectKey } from '../../common/s3/utils'; import { getLogger, LogCategories } from '../../common/logger'; @@ -28,7 +31,8 @@ export const readFileRawTextByUrl = async ({ getFormatText, relatedId, datasetId, - maxFileSize = getFileMaxSize() + maxFileSize = getFileMaxSize(), + parseConfig }: { teamId: string; tmbId: string; @@ -38,6 +42,7 @@ export const readFileRawTextByUrl = async ({ relatedId: string; // externalFileId / apiFileId datasetId: string; maxFileSize?: number; + parseConfig?: Record; }) => { const extension = parseFileExtensionFromUrl(url); @@ -111,23 +116,28 @@ export const readFileRawTextByUrl = async ({ // 立即清理 chunks 数组释放内存 chunks.length = 0; + // Extract filename from URL + const filename = url.split('/').pop()?.split('?')[0] || undefined; + const { rawText } = await retryFn(() => { const { fileParsedPrefix } = getFileS3Key.dataset({ datasetId, filename: 'file' }); - return readFileContentByBuffer({ + return readS3FileContentByBuffer({ customPdfParse, getFormatText, extension, teamId, tmbId, buffer, - encoding: 'utf-8', + encoding: detectFileEncoding(buffer) || 'utf-8', imageKeyOptions: { // TODO: 链接解析出来的图片不过期,删除知识库时候也需要一起删 prefix: fileParsedPrefix - } + }, + filename, + parseConfig }); }); @@ -167,7 +177,8 @@ export const readDatasetSourceRawText = async ({ customPdfParse, getFormatText, usageId, - datasetId + datasetId, + parseConfig }: { teamId: string; tmbId: string; @@ -181,6 +192,7 @@ export const readDatasetSourceRawText = async ({ apiDatasetServer?: ApiDatasetServerType; // api dataset usageId?: string; datasetId: string; // For S3 image upload + parseConfig?: Record; }): Promise<{ title?: string; rawText: string; @@ -190,6 +202,72 @@ export const readDatasetSourceRawText = async ({ return Promise.reject('datasetId is required for S3 files'); } + // When parseConfig is provided, bypass getDatasetFileRawText (private S3 module) + // to pass extra parameters through to the custom parse service. + const hasParseConfig = parseConfig && Object.keys(parseConfig).length > 0; + logger.info('readDatasetSourceRawText path decision', { + type, + hasParseConfig, + customPdfParse, + parseConfig + }); + if (hasParseConfig) { + // 尝试读取缓存(key 包含 parseConfig 以区分不同配置的解析结果) + const cachedBuffer = await getS3RawTextSource().getRawTextBuffer({ + customPdfParse, + sourceId, + parseConfig + }); + if (cachedBuffer) { + return { + title: cachedBuffer.filename, + rawText: cachedBuffer.text + }; + } + + const { buffer, extension } = await getS3DatasetSource().getDatasetFileBuffer(sourceId); + const metadata = await getS3DatasetSource().getFileMetadata(sourceId); + const encoding = detectFileEncoding(buffer) || 'utf-8'; + const { fileParsedPrefix } = getFileS3Key.dataset({ + datasetId, + filename: 'file' + }); + + const { rawText } = await readS3FileContentByBuffer({ + customPdfParse, + getFormatText, + extension, + teamId, + tmbId, + buffer, + encoding, + imageKeyOptions: { + prefix: fileParsedPrefix + }, + filename: metadata?.filename, + usageId, + parseConfig + }); + + // 写入缓存(非关键路径,异步执行,失败不阻塞主流程) + getS3RawTextSource() + .addRawTextBuffer({ + sourceId, + sourceName: metadata?.filename || '', + text: rawText, + customPdfParse, + parseConfig + }) + .catch((err) => { + logger.warn('Failed to write rawText cache', { sourceId, error: err }); + }); + + return { + title: metadata?.filename || '', + rawText + }; + } + const { filename, rawText } = await getS3DatasetSource().getDatasetFileRawText({ teamId, tmbId, @@ -197,7 +275,8 @@ export const readDatasetSourceRawText = async ({ getFormatText, customPdfParse, usageId, - datasetId + datasetId, + parseConfig }); return { @@ -227,7 +306,8 @@ export const readDatasetSourceRawText = async ({ url: sourceId, relatedId: externalFileId, datasetId, - customPdfParse + customPdfParse, + parseConfig }); return { rawText @@ -239,7 +319,8 @@ export const readDatasetSourceRawText = async ({ teamId, tmbId, customPdfParse, - datasetId + datasetId, + parseConfig }); return { title, @@ -258,7 +339,8 @@ export const readApiServerFileContent = async ({ teamId, tmbId, customPdfParse, - datasetId + datasetId, + parseConfig }: { apiDatasetServer?: ApiDatasetServerType; apiFileId: string; @@ -266,6 +348,7 @@ export const readApiServerFileContent = async ({ tmbId: string; customPdfParse?: boolean; datasetId: string; + parseConfig?: Record; }): Promise<{ title?: string; rawText: string; @@ -275,7 +358,8 @@ export const readApiServerFileContent = async ({ tmbId, apiFileId, customPdfParse, - datasetId + datasetId, + parseConfig }); }; @@ -302,27 +386,11 @@ export const rawText2Chunks = async ({ a: string; indexes?: string[]; imageIdList?: string[]; + metadata?: Record; }[] > => { - const parseDatasetBackup2Chunks = (rawText: string) => { - const csvArr = Papa.parse(rawText).data as string[][]; - const chunks = csvArr - .slice(1) - .map((item) => ({ - q: item[0] || '', - a: item[1] || '', - indexes: item.slice(2).filter((item) => item.trim()), - imageIdList - })) - .filter((item) => item.q || item.a); - - return { - chunks - }; - }; - if (backupParse) { - return parseDatasetBackup2Chunks(rawText).chunks; + return parseDatasetBackup2Chunks(rawText, imageIdList).chunks; } // Chunk condition diff --git a/packages/service/core/dataset/schema.ts b/packages/service/core/dataset/schema.ts index 0995e01fb655..302198b2a992 100644 --- a/packages/service/core/dataset/schema.ts +++ b/packages/service/core/dataset/schema.ts @@ -2,6 +2,7 @@ import { getMongoModel, Schema } from '../../common/mongo'; import { ChunkSettingModeEnum, ChunkTriggerConfigTypeEnum, + DatabaseTypeEnum, DataChunkSplitModeEnum, DatasetCollectionDataProcessModeEnum, DatasetTypeEnum, @@ -12,6 +13,7 @@ import { TeamCollectionName, TeamMemberCollectionName } from '@fastgpt/global/support/user/team/constant'; +import { PermissionEffectScopeEnum } from '@fastgpt/global/support/permission/constant'; import type { DatasetSchemaType } from '@fastgpt/global/core/dataset/type'; import { getLogger, LogCategories } from '../../common/logger'; @@ -34,6 +36,24 @@ export const ChunkSettings = { imageIndex: Boolean, autoIndexes: Boolean, indexPrefixTitle: Boolean, + hypeIndexes: Boolean, + small2bigIndexes: Boolean, + hypeIndexPrompt: String, + imageIndexPrompt: String, + small2bigConfigType: { + type: { + maxChildChunks: Number, + chunkSize: Number, + + paragraphChunkDeep: Number, + paragraphChunkMinSize: Number, + + maxSize: Number, + overlapRatio: Number, + customReg: Array + } + }, + autoIndexesPrompt: String, chunkSettingMode: { type: String, @@ -95,17 +115,25 @@ const DatasetSchema = new Schema({ type: Date, default: () => new Date() }, - vectorModel: { + vectorModelId: { type: String, - required: true, - default: 'text-embedding-3-small' + required: function (this: any) { + return ( + this.type !== DatasetTypeEnum.structureDocument && this.type !== DatasetTypeEnum.folder + ); + } }, - agentModel: { + agentModelId: { type: String, - required: true, - default: 'gpt-4o-mini' + required: function (this: any) { + return ( + this.type !== DatasetTypeEnum.database && + this.type !== DatasetTypeEnum.structureDocument && + this.type !== DatasetTypeEnum.folder + ); + } }, - vlmModel: String, + vlmModelId: String, intro: { type: String, default: '' @@ -122,6 +150,84 @@ const DatasetSchema = new Schema({ } } }, + databaseConfig: { + type: { + clientType: { + type: String, + required: true, + enum: Object.values(DatabaseTypeEnum) + }, + version: { + type: String, + default: '5.7.44' + }, + host: { + type: String, + required: true + }, + port: { + type: Number, + default: 3306 + }, + database: { + type: String, + required: true + }, + schema: { + type: String, + validate: { + validator: function (this: any) { + return ( + this.databaseConfig?.clientType === DatabaseTypeEnum.postgresql || + this.databaseConfig?.clientType === DatabaseTypeEnum.mssql || + this.databaseConfig?.clientType === DatabaseTypeEnum.oracle + ); + } + } + }, + user: { + type: String, + required: true + }, + password: { + type: String, + required: true + }, + encrypt: { + type: Boolean, + default: true + }, + trustServerCertificate: { + type: Boolean, + validate: { + validator: function (this: any) { + return this.databaseConfig?.clientType === DatabaseTypeEnum.mssql; + } + } + }, + // Oracle specific + serviceName: { + type: String, + validate: { + validator: function (this: any) { + return this.databaseConfig?.clientType === DatabaseTypeEnum.oracle; + } + } + }, + sid: { + type: String, + validate: { + validator: function (this: any) { + return this.databaseConfig?.clientType === DatabaseTypeEnum.oracle; + } + } + }, + poolSize: { + type: Number, + default: 20 + } + } + }, chunkSettings: { type: ChunkSettings }, @@ -129,6 +235,18 @@ const DatasetSchema = new Schema({ type: Boolean, default: true }, + permissionEffectScope: { + type: String, + enum: Object.values(PermissionEffectScopeEnum), + default: PermissionEffectScopeEnum.allChildren + }, + + // 同义词文件ID数组 - 该知识库绑定的同义词文件 + // 注意:设计支持多文件,但当前版本仅支持单个文件(数组长度≤1) + synonymFiles: { + type: [Schema.Types.ObjectId], + default: [] + }, apiDatasetServer: Object, @@ -138,6 +256,30 @@ const DatasetSchema = new Schema({ default: null // null表示未删除,有值表示删除时间 }, + // 保留页眉页脚 + keep_header_footer: { + type: Boolean, + default: false + }, + + // 保留附录 + keep_appendix: { + type: Boolean, + default: true + }, + + // 文档图片识别 + image_analysis: { + type: Boolean, + default: false + }, + + // 统计图转表格 + chart_analysis: { + type: Boolean, + default: false + }, + // abandoned autoSync: Boolean, externalReadUrl: String, diff --git a/packages/service/core/dataset/search/agenticSearch.ts b/packages/service/core/dataset/search/agenticSearch.ts new file mode 100644 index 000000000000..c10d55b051d3 --- /dev/null +++ b/packages/service/core/dataset/search/agenticSearch.ts @@ -0,0 +1,445 @@ +/** + * Agentic Search 入口 + * 调用 diting-rag-ts 的 createAgenticSearch 实现多轮智能检索 + */ + +import { createAgenticSearch, createLogger, LogLevel } from 'diting-rag-ts'; +import type { AgenticSearchResult } from 'diting-rag-ts'; +import { detectLang } from 'diting-rag-ts'; +import { chunkItemsToSearchResults } from './providers/agenticAdapter'; +import { createFastGPTProviders } from './providers/fastgptProviders'; +import { + defaultSearchDatasetData, + type SearchDatasetDataResponse, + resolveCollectionFilter +} from './controller'; +import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { SearchDatasetDataProps } from './controller'; +import { getDefaultLLMModel, getDefaultRerankModel } from '../../ai/model'; +import { MongoDatasetData } from '../data/schema'; +import { MongoDatasetCollection } from '../collection/schema'; +import { Types } from '../../../common/mongo'; +import { addLog } from '../../../common/system/log'; +import type { WorkflowResponseType } from '../../../core/workflow/dispatch/type'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; +import type { ChatItemType } from '@fastgpt/global/core/chat/type'; +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; +import { i18nT } from '../../../../global/common/i18n/utils'; +import { formatAgenticLabel, getSearchingFallback } from './agenticSearchLabels'; + +// 本地定义 AgentEvent 类型(避免依赖 diting-rag-ts 新版 dist) +type AgentEvent = { + step: string; + detail?: string; + reason?: string; + playbook?: string; + timestamp: number; + extra?: Record; +}; + +export type AgenticSearchDispatchProps = SearchDatasetDataProps & { + [NodeInputKeyEnum.datasetAgenticSearchLLMModelId]?: string; + [NodeInputKeyEnum.datasetAgenticSearchRerankModelId]?: string; + agenticSearchReasoning?: boolean; + workflowStreamResponse?: WorkflowResponseType; + synonymDatasetIds?: string[]; + /** 调用方预先做过指代消解/同义词标准化的查询,优先作为 diting-rag-ts 的入口 query */ + preResolvedQuery?: string; + /** 调用方预先计算的 query 变体列表,diting-rag-ts 用来跳过内部 query_rewrite LLM 调用 */ + preComputedQueries?: string[]; + /** 调用方在 agenticSearch 之前已检索到的结果(如 SQL 结构化数据),作为 priorContext 传给 agent */ + preSearchRes?: SearchDataResponseItemType[]; + /** 调用方预先检测的用户查询语言(ISO 639-1),避免 agenticSearch 内部重复检测 */ + queryLanguage?: string; +}; + +/** + * 将预先检索到的结果格式化为 priorContext 文本,供 diting-rag-ts agent 参考 + * c.q 存的是自然语言答案(SQL 执行结果的人类可读摘要),是最有价值的内容 + */ +function formatPreSearchResAsContext(chunks: SearchDataResponseItemType[]): string { + return chunks + .map((c) => { + const lines = [`[Source: ${c.sourceName || 'Unknown Source'}]`]; + if (c.q) lines.push(c.q); + return lines.join('\n'); + }) + .join('\n\n'); +} + +/** + * 从 MongoDB 采样语言分布,用于初始化 LanguageTracker。 + * 使用 $sample 随机抽取 N 条 chunk 并聚合 detectLanguage。 + * + * @param datasetIds 要采样的知识库 ID 列表 + * @returns 语言分布 { zh: 120, en: 60 } 或 null(采样失败/空 KB) + */ +async function sampleLanguageDistribution( + datasetIds: string[] +): Promise | null> { + try { + const sampleSize = (() => { + const env = process.env.AGENTIC_LANG_SAMPLE_SIZE; + if (env !== undefined && env !== null && env !== '') { + const parsed = parseInt(String(env), 10); + if (!isNaN(parsed)) return Math.max(10, Math.min(1000, parsed)); + } + return 200; + })(); + + const results = await MongoDatasetData.aggregate([ + { + $match: { + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) } + } + }, + { $sample: { size: sampleSize } }, + { + $project: { + q: 1, + 'metadata.detectedLanguage': 1 + } + } + // Set allowDiskUse for large KBs with many chunks + ]).allowDiskUse(true); + + const stats: Record = {}; + let usedMetadata = 0; + let usedDetectLang = 0; + let fallbackOther = 0; + for (const doc of results) { + let lang: string; + if (doc.metadata?.detectedLanguage) { + lang = doc.metadata.detectedLanguage; + usedMetadata++; + } else if (typeof doc.q === 'string') { + lang = detectLang(doc.q); + usedDetectLang++; + } else { + lang = 'other'; + fallbackOther++; + } + stats[lang] = (stats[lang] || 0) + 1; + } + + addLog.info('[AgenticSearch] Language sample done', { + sampleSize: results.length, + stats, + sources: { metadata: usedMetadata, detectLang: usedDetectLang, fallbackOther } + }); + + return Object.keys(stats).length > 0 ? stats : null; + } catch (err) { + addLog.warn('[AgenticSearch] Language sample failed', { err }); + return null; + } +} + +export async function agenticSearchDispatch( + props: AgenticSearchDispatchProps +): Promise { + const { + agenticSearchLLMModelId, + agenticSearchRerankModelId, + agenticSearchReasoning, + workflowStreamResponse, + teamId, + modelId: embedModelId, + datasetIds, + reRankQuery, + histories, + limit: maxTokens, + preResolvedQuery, + preComputedQueries, + preSearchRes, + queryLanguage + } = props; + + try { + // 获取默认 LLM 模型 + const defaultLLM = getDefaultLLMModel(); + const llmModelId = agenticSearchLLMModelId || defaultLLM?.id; + const defaultRerankModel = getDefaultRerankModel(); + const rerankModelId = agenticSearchRerankModelId || defaultRerankModel?.id; + + addLog.info('[AgenticSearch] Starting', { + llmModelId, + rerankModelId, + embedModelId, + datasetIds: datasetIds?.length, + query: reRankQuery + }); + + const agenticStartTime = Date.now(); + + // 创建 Providers + const providers = createFastGPTProviders({ + llmModelId, + embedModelId, + rerankModelId, + teamId + }); + + // 创建 Logger + const logger = createLogger({ + level: LogLevel.INFO, + prefix: 'agentic-search' + }); + + // 合并 forbidCollectionIds:系统级 forbid + 权限级 forbid + const systemForbiddenCols = await MongoDatasetCollection.find( + { datasetId: { $in: datasetIds }, forbid: true, deleteTime: null }, + '_id' + ).lean(); + const forbidSet = new Set(); + systemForbiddenCols.forEach((c) => forbidSet.add(String(c._id))); + props.authForbidCollectionIds?.forEach((id) => forbidSet.add(id)); + + // 标签过滤:将白名单转黑名单追加到 forbidCollectionIds + const filterWhitelist = await resolveCollectionFilter({ + teamId, + datasetIds, + collectionFilterMatch: props.collectionFilterMatch + }); + if (filterWhitelist !== undefined) { + if (filterWhitelist.length === 0) { + // 标签过滤结果为空,直接返回空 + return { + searchRes: [], + embeddingTokens: 0, + reRankInputTokens: 0, + searchMode: DatasetSearchModeEnum.mixedRecall, + limit: 0, + similarity: 0, + usingReRank: !!rerankModelId, + usingSimilarityFilter: false + }; + } + // 查出 datasets 下所有可用 collection,不在白名单中的加入 forbid + const allCols = await MongoDatasetCollection.find( + { datasetId: { $in: datasetIds }, forbid: { $ne: true }, deleteTime: null, type: { $ne: 'folder' } }, + '_id' + ).lean(); + const whitelistSet = new Set(filterWhitelist); + allCols + .map((c) => String(c._id)) + .filter((id) => !whitelistSet.has(id)) + .forEach((id) => forbidSet.add(id)); + } + + const mergedForbidCollectionIds: string[] | undefined = + forbidSet.size > 0 ? Array.from(forbidSet) : undefined; + + // 创建 Agentic Search + const agent = createAgenticSearch({ + providers: { + llm: providers.llm, + embed: providers.embed, + vectorSearch: providers.vectorSearch, + fullTextSearch: providers.fullTextSearch, + mixedSearch: providers.mixedSearch, + reranker: providers.reranker, + logger // 注入 logger,使 nodes.ts 内部日志生效 + }, + logger, + config: { + searchMode: 'mixedRecall', + tokenBudget: maxTokens || 5000, + searchOnly: true, // FastGPT 自己有 AI 对话节点,diting-rag-ts 只负责多轮检索+chunk选择 + forbidCollectionIds: mergedForbidCollectionIds + } + }); + + // 执行检索 - 转换历史格式 + // histories 是 ChatItemType[](FastGPT 内部格式: obj/value),需转为 diting-rag-ts 需要的 role/content 格式 + const history = ((histories as ChatItemType[]) || []) + .filter((h) => h.obj === ChatRoleEnum.Human || h.obj === ChatRoleEnum.AI) + .map((h) => { + // 从 value 数组中提取 text 类型内容(跳过 reasoning、file 等类型) + const content = Array.isArray(h.value) + ? (h.value as Array<{ type: string; text?: { content?: string } }>) + .filter((v) => v.type === 'text') + .map((v) => v.text?.content || '') + .join('') + : ''; + return { + role: (h.obj === ChatRoleEnum.Human ? 'user' : 'assistant') as 'user' | 'assistant', + content + }; + }) + .filter((h) => h.content.trim().length > 0); + + // 使用调用方预检测的语言,避免重复调用 detectLang + const agentQuery = preResolvedQuery || reRankQuery; + const queryLang = queryLanguage || detectLang(agentQuery || ''); + + // 使用流式接口遍历所有事件,累积用户可读的推理文本;有 workflowStreamResponse 时同步推送 SSE + let finalResult: AgenticSearchResult | undefined; + let accumulatedReasoningText = ''; + + // 将预先检索到的结果(如 SQL 数据)格式化为 priorContext 文本,供 diting-rag-ts agent 参考 + const priorContext = preSearchRes?.length + ? formatPreSearchResAsContext(preSearchRes) + : undefined; + + // DB 采样语言分布,用于初始化 LanguageTracker(不阻塞主检索流程) + const langSample = await sampleLanguageDistribution(datasetIds); + + const stream = agent.stream({ + query: agentQuery, + datasetIds, + history, + // 若外部已有预计算变体,作为 initialQueries 传入以预填检索计划 + // diting-rag-ts 在 Blackboard Brief 中展示这些查询,引导 Agent 直接检索而非重新改写 + ...(preComputedQueries?.length ? { initialQueries: preComputedQueries } : {}), + // 若外部已有预先检索结果(如 SQL 数据),作为 priorContext 传入 + // diting-rag-ts Agent 在 Blackboard Brief 中参考这些内容,据此决定是否补充检索 + ...(priorContext ? { priorContext } : {}), + initialLanguageStats: langSample + }); + + let streamError: string | null = null; + + for await (const item of stream) { + if ('chunks' in item && 'searchCount' in item) { + finalResult = item as AgenticSearchResult; + } else { + const event = item as AgentEvent; + // 追踪 runner.ts stream() 产生的 ERROR 事件: + // runner.ts 在 catch LangGraph 异常后 yield ERROR 事件并 return, + // 生成器正常结束(不抛),需在此处显式捕获错误信息 + if (event.step === 'error') { + streamError = event.detail || 'Unknown agentic search error'; + } + const reasoningText = formatEventText(event, queryLang); + if (reasoningText) { + accumulatedReasoningText += reasoningText; + if (workflowStreamResponse && agenticSearchReasoning) { + workflowStreamResponse({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ reasoning_content: reasoningText }) + }); + } + } + } + } + + if (streamError) { + throw new Error(streamError); + } + + if (!finalResult) { + throw new Error('[AgenticSearch] No result from stream'); + } + + const result = finalResult; + + const retrievalTime = +((Date.now() - agenticStartTime) / 1000).toFixed(2); + + // 转换为 FastGPT 格式:从 providerMetadata 读取平台特有字段,重算 rrf 排名 + const searchRes = chunkItemsToSearchResults(result.chunks); + + const response: SearchDatasetDataResponse = { + searchRes, + embeddingTokens: (result as any).embeddingTokens ?? 0, + reRankInputTokens: (result as any).rerankInputTokens ?? 0, + searchMode: DatasetSearchModeEnum.mixedRecall, + limit: searchRes.length, + similarity: 0, + usingReRank: !!rerankModelId, + usingSimilarityFilter: false, + retrievalTime, + // 思考过程 + agenticSearchResult: { + // reasoningText 用于显示搜索过程,不论是否开启推理都有内容 + reasoningText: accumulatedReasoningText, + searchCount: result.searchCount || 0, + toolCallCount: result.toolCallCount || 0, + llmModelId: llmModelId, + llmInputTokens: result.llmInputTokens || 0, + llmOutputTokens: result.llmOutputTokens || 0, + playbook: result.playbook, + executionPath: result.executionPath, + confidence: result.confidence, + queryLanguage: queryLang + } + }; + + addLog.info('[AgenticSearch] Completed', { + searchCount: result.searchCount, + chunkCount: result.chunks.length + }); + + return response; + } catch (error) { + addLog.error('[AgenticSearch] Failed', { error }); + + const errorMsg = error instanceof Error ? error.message : String(error); + // diting-rag-ts agent 节点 LLM 重试全部耗尽且无 chunks: + // 底模完全不可用,不应静默降级,需将错误抛给前端展示 + if (errorMsg.includes('LLM call failed after') && errorMsg.includes('no chunks collected')) { + addLog.error('[AgenticSearch] LLM fatal error, propagating to caller'); + throw new Error(i18nT('chat:language_model_error')); + } + + // 非 LLM 致命错误(search/EMB 异常):降级使用默认混合检索 + addLog.warn('[AgenticSearch] Non-fatal error, falling back to default search'); + return defaultSearchDatasetData({ + ...props, + searchMode: DatasetSearchModeEnum.mixedRecall + }); + } +} + +/** + * 将 AgentEvent 格式化为可读的推理文本。 + * 多语言标签委托给 agenticSearchLabels.ts(eld 60 种语言全覆盖)。 + */ +function formatEventText(event: AgentEvent, lang: string): string { + // thinking / playbook_selected:直接透传 LLM 生成的内容 + if (event.step === 'thinking') { + return event.detail ? `${event.detail}\n` : ''; + } + if (event.step === 'playbook_selected') { + return (event.extra?.analysis as string) ?? ''; + } + + // generating:searchOnly 模式静默 + if (event.step === 'generating') return ''; + + // final:refuse 时返回文本,否则静默 + if (event.step === 'final') { + return event.extra?.refuse ? formatAgenticLabel('final', lang, {}) : ''; + } + + // search_done:count=0 时静默 + if (event.step === 'search_done') { + const count = event.extra?.chunkCount as number | undefined; + if (!count) return ''; + } + + // rewrite_done:queries 为空时静默 + if (event.step === 'rewrite_done') { + const queries = (event.extra?.queries as string[] | undefined) ?? []; + if (!queries.length) return ''; + } + + // searching:queries 为空时用 fallback + if (event.step === 'searching') { + const queries = (event.extra?.queries as string[] | undefined) ?? []; + if (!queries.length) { + return getSearchingFallback(lang) || ''; + } + } + + // 其他事件:委托给 agenticSearchLabels + return formatAgenticLabel(event.step, lang, { + queries: (event.extra?.queries as string[]) ?? [], + count: String(event.extra?.chunkCount ?? ''), + detail: event.detail ?? '', + analysis: (event.extra?.analysis as string) ?? '' + }); +} diff --git a/packages/service/core/dataset/search/agenticSearchLabels.ts b/packages/service/core/dataset/search/agenticSearchLabels.ts new file mode 100644 index 000000000000..25b69ffdb295 --- /dev/null +++ b/packages/service/core/dataset/search/agenticSearchLabels.ts @@ -0,0 +1,245 @@ +/** + * Agentic Search 多语言标签 + * + * eld 支持的语言均在此处有对应条目。翻译采用机器翻译 + 人工校验方式维护, + * 缺失的语言会 fallback 到中性格式(不显示任何固定语言文本)。 + * + * 新增语言:在此 Map 中追加一行即可,formatAgenticLabel 自动生效。 + */ + +// ============================================================ +// 标签定义(每个事件一条模板,{{key}} 为运行时替换变量) +// ============================================================ + +type LabelSet = { + searching: string; + searchingFallback: string; + searchDone: string; + rewriting: string; + reflecting: string; + finalNotFound: string; +}; + +// 中性 fallback:不注入任何语言特有的固定文字 +const NEUTRAL: LabelSet = { + searching: '\n{{queries}}\n', + searchingFallback: '\n...\n', + searchDone: '{{count}}\n', + rewriting: '', + reflecting: '', + finalNotFound: '' +}; + +const LABELS: Record> = { + zh: { + searching: '\n检索「{{queries}}」\n', + searchingFallback: '\n检索中...\n', + searchDone: '检索到 {{count}} 条结果\n', + rewriting: '\n现有信息不足以完整回答,扩展检索范围...\n', + reflecting: '\n评估已收集信息的完整性...\n', + finalNotFound: '知识库中未找到与此问题相关的内容。\n' + }, + en: { + searching: '\nSearching: "{{queries}}"\n', + searchingFallback: '\nSearching...\n', + searchDone: 'Retrieved {{count}} result(s)\n', + rewriting: + '\nInsufficient information to fully answer the question, broadening search scope...\n', + reflecting: '\nEvaluating completeness of gathered information...\n', + finalNotFound: 'No relevant information found in the knowledge base.\n' + }, + ja: { + searching: '\n「{{queries}}」を検索中\n', + searchingFallback: '\n検索中...\n', + searchDone: '{{count}}件の結果を取得\n', + rewriting: '\n情報が不十分なため、検索範囲を拡大しています...\n', + reflecting: '\n収集した情報の完全性を評価中...\n', + finalNotFound: 'ナレッジベースに関連コンテンツが見つかりませんでした。\n' + }, + ko: { + searching: '\n검색 중: "{{queries}}"\n', + searchingFallback: '\n검색 중...\n', + searchDone: '{{count}}개 결과 검색됨\n', + rewriting: '\n기존 정보로는 완전한 답변이 어려워 검색 범위를 확장합니다...\n', + reflecting: '\n수집된 정보의 완전성을 평가 중...\n', + finalNotFound: '지식 베이스에서 관련 정보를 찾을 수 없습니다.\n' + }, + th: { + searching: '\nกำลังค้นหา: "{{queries}}"\n', + searchingFallback: '\nกำลังค้นหา...\n', + searchDone: 'พบ {{count}} รายการ\n', + rewriting: '\nข้อมูลที่มีไม่เพียงพอที่จะตอบคำถามอย่างสมบูรณ์ กำลังขยายขอบเขตการค้นหา...\n', + reflecting: '\nกำลังประเมินความสมบูรณ์ของข้อมูลที่รวบรวม...\n', + finalNotFound: 'ไม่พบข้อมูลที่เกี่ยวข้องในฐานความรู้\n' + }, + fr: { + searching: '\nRecherche : "{{queries}}"\n', + searchingFallback: '\nRecherche...\n', + searchDone: '{{count}} résultat(s) trouvé(s)\n', + rewriting: + '\nInformations insuffisantes pour répondre complètement, élargissement de la recherche...\n', + reflecting: "\nÉvaluation de l'exhaustivité des informations collectées...\n", + finalNotFound: 'Aucune information pertinente trouvée dans la base de connaissances.\n' + }, + de: { + searching: '\nSuche: "{{queries}}"\n', + searchingFallback: '\nSuche...\n', + searchDone: '{{count}} Ergebnis(se) gefunden\n', + rewriting: + '\nUnzureichende Informationen für eine vollständige Antwort, Suche wird erweitert...\n', + reflecting: '\nBewertung der Vollständigkeit der gesammelten Informationen...\n', + finalNotFound: 'Keine relevanten Informationen in der Wissensdatenbank gefunden.\n' + }, + es: { + searching: '\nBuscando: "{{queries}}"\n', + searchingFallback: '\nBuscando...\n', + searchDone: '{{count}} resultado(s) encontrado(s)\n', + rewriting: '\nInformación insuficiente para responder completamente, ampliando búsqueda...\n', + reflecting: '\nEvaluando la integridad de la información recopilada...\n', + finalNotFound: 'No se encontró información relevante en la base de conocimiento.\n' + }, + pt: { + searching: '\nPesquisando: "{{queries}}"\n', + searchingFallback: '\nPesquisando...\n', + searchDone: '{{count}} resultado(s) encontrado(s)\n', + rewriting: + '\nInformações insuficientes para responder completamente, ampliando a pesquisa...\n', + reflecting: '\nAvaliando a completude das informações coletadas...\n', + finalNotFound: 'Nenhuma informação relevante encontrada na base de conhecimento.\n' + }, + ru: { + searching: '\nПоиск: "{{queries}}"\n', + searchingFallback: '\nПоиск...\n', + searchDone: 'Найдено: {{count}} результатов\n', + rewriting: '\nНедостаточно информации для полного ответа, расширяем область поиска...\n', + reflecting: '\nОценка полноты собранной информации...\n', + finalNotFound: 'В базе знаний не найдено релевантной информации.\n' + }, + ar: { + searching: '\nالبحث: "{{queries}}"\n', + searchingFallback: '\nجار البحث...\n', + searchDone: 'تم العثور على {{count}} نتيجة\n', + rewriting: '\nالمعلومات غير كافية للإجابة الكاملة، توسيع نطاق البحث...\n', + reflecting: '\nتقييم اكتمال المعلومات التي تم جمعها...\n', + finalNotFound: 'لم يتم العثور على معلومات ذات صلة في قاعدة المعرفة.\n' + }, + vi: { + searching: '\nĐang tìm kiếm: "{{queries}}"\n', + searchingFallback: '\nĐang tìm kiếm...\n', + searchDone: 'Đã tìm thấy {{count}} kết quả\n', + rewriting: '\nThông tin không đủ để trả lời đầy đủ, đang mở rộng phạm vi tìm kiếm...\n', + reflecting: '\nĐang đánh giá tính đầy đủ của thông tin đã thu thập...\n', + finalNotFound: 'Không tìm thấy thông tin liên quan trong cơ sở tri thức.\n' + }, + id: { + searching: '\nMencari: "{{queries}}"\n', + searchingFallback: '\nMencari...\n', + searchDone: 'Ditemukan {{count}} hasil\n', + rewriting: '\nInformasi tidak cukup untuk menjawab lengkap, memperluas pencarian...\n', + reflecting: '\nMengevaluasi kelengkapan informasi yang dikumpulkan...\n', + finalNotFound: 'Tidak ditemukan informasi relevan di basis pengetahuan.\n' + }, + tr: { + searching: '\nAranıyor: "{{queries}}"\n', + searchingFallback: '\nAranıyor...\n', + searchDone: '{{count}} sonuç bulundu\n', + rewriting: '\nTam yanıt için bilgi yetersiz, arama kapsamı genişletiliyor...\n', + reflecting: '\nToplanan bilgilerin eksiksizliği değerlendiriliyor...\n', + finalNotFound: 'Bilgi tabanında ilgili bilgi bulunamadı.\n' + }, + it: { + searching: '\nRicerca: "{{queries}}"\n', + searchingFallback: '\nRicerca...\n', + searchDone: '{{count}} risultato(i) trovato(i)\n', + rewriting: + '\nInformazioni insufficienti per rispondere completamente, ampliamento della ricerca...\n', + reflecting: '\nValutazione della completezza delle informazioni raccolte...\n', + finalNotFound: 'Nessuna informazione pertinente trovata nella knowledge base.\n' + }, + pl: { + searching: '\nSzukanie: "{{queries}}"\n', + searchingFallback: '\nSzukanie...\n', + searchDone: 'Znaleziono {{count}} wyników\n', + rewriting: + '\nNiewystarczające informacje, aby w pełni odpowiedzieć, rozszerzanie wyszukiwania...\n', + reflecting: '\nOcena kompletności zebranych informacji...\n', + finalNotFound: 'Nie znaleziono istotnych informacji w bazie wiedzy.\n' + } +}; + +/** + * 查找指定语言的 label。 + * 优先级:精确匹配 → 中性 fallback + */ +function getLabel(key: keyof LabelSet, lang: string): string { + const set = LABELS[lang]; + if (set?.[key]) return set[key]!; + // 中性 fallback:不注入任何固定语言文本 + return NEUTRAL[key]; +} + +/** + * 格式化单个 agentic search 事件为对应用户语言的文本。 + * 调用方传入 lang(eld 检测结果,如 'th', 'ko')。 + */ +export function formatAgenticLabel( + eventType: string, + lang: string, + params: Record +): string { + const template = (() => { + switch (eventType) { + case 'searching': + return getLabel('searching', lang); + case 'search_done': + return getLabel('searchDone', lang); + case 'rewriting': + return getLabel('rewriting', lang); + case 'reflecting': + return getLabel('reflecting', lang); + case 'final': + return getLabel('finalNotFound', lang); + // 以下事件不需要翻译,纯数据透传 + case 'generating': + return ''; + case 'rewrite_done': + return '{{queries_lines}}\n'; + case 'playbook_selected': + return '{{analysis}}'; + case 'reflect_done': + return '{{detail}}'; + default: + return ''; + } + })(); + + if (!template) return ''; + + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + switch (key) { + case 'queries': { + const queries = (params.queries as string[]) ?? []; + return queries.join('" , "'); + } + case 'queries_lines': { + const queries = (params.queries as string[]) ?? []; + return queries.map((q) => ` · ${q}`).join('\n'); + } + case 'count': + return String(params.count ?? ''); + case 'detail': + return String(params.detail ?? ''); + case 'analysis': + return String(params.analysis ?? ''); + default: + return ''; + } + }); +} + +/** + * searching 事件 queries 为空时的 fallback 文本 + */ +export function getSearchingFallback(lang: string): string { + return getLabel('searchingFallback', lang); +} diff --git a/packages/service/core/dataset/search/assistantReranker.ts b/packages/service/core/dataset/search/assistantReranker.ts new file mode 100644 index 000000000000..6157e7714b2b --- /dev/null +++ b/packages/service/core/dataset/search/assistantReranker.ts @@ -0,0 +1,161 @@ +/** + * Assistant专用的Reranker策略实现 + * + * 支持两种重排策略: + * 1. concat: 拼接策略 - 将多个query用\n拼接后一起rerank(默认) + * 2. maxScore: 最高分策略 - 每个query单独rerank,每个chunk取最高分 + */ + +import { addLog } from '../../../common/system/log'; +import { reRankRecall } from '../../ai/rerank'; +import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; +import { + RerankMethodEnum, + RerankStrategyEnum, + SearchScoreTypeEnum +} from '@fastgpt/global/core/dataset/constants'; + +/** + * Assistant场景的Reranker - 支持多种策略 + */ +export const assistantDatasetReRank = async ({ + rerankModel, + data, + query, + rerankMethod = RerankMethodEnum.content, + rerankStrategy = RerankStrategyEnum.concat // 默认使用拼接策略 +}: { + rerankModel?: RerankModelItemType; + data: SearchDataResponseItemType[]; + query: string; // 多个query用\n拼接的字符串 + rerankMethod?: RerankMethodEnum; + rerankStrategy?: RerankStrategyEnum; +}): Promise<{ + results: SearchDataResponseItemType[]; + inputTokens: number; +}> => { + addLog.info('Assistant Rerank Start', { + query, + rerankMethod, + rerankStrategy, + rerankModel: rerankModel?.name, + dataCount: data.length + }); + + // 准备文档数据 + const documents = data.map((item) => { + let text = ''; + switch (rerankMethod) { + case RerankMethodEnum.question: + text = item.q; + break; + case RerankMethodEnum.content: + default: + text = `${item.q}\n${item.a}`; + } + return { id: item.id, text }; + }); + + let results: { id: string; score?: number }[] = []; + let totalInputTokens = 0; + + // 根据策略执行rerank + if (rerankStrategy === RerankStrategyEnum.maxScore) { + // 策略2: 最高分策略 - 将query按换行符拆分,每个query单独rerank,取最高分 + const queries = query.split('\n').filter((q) => q.trim().length > 0); + + addLog.info('Rerank Strategy: MaxScore', { + queryCount: queries.length, + queries + }); + + // 为每个query执行rerank + const allRerankResults = await Promise.all( + queries.map(async (singleQuery) => { + const { results: queryResults, inputTokens } = await reRankRecall({ + model: rerankModel, + query: singleQuery, + documents + }); + return { results: queryResults, inputTokens }; + }) + ); + + // 汇总token消耗 + totalInputTokens = allRerankResults.reduce((sum, r) => sum + r.inputTokens, 0); + + // 为每个chunk选择最高分 + const scoreMap = new Map(); + for (const { results: queryResults } of allRerankResults) { + for (const item of queryResults) { + const currentScore = scoreMap.get(item.id) || 0; + const newScore = item.score || 0; + if (newScore > currentScore) { + scoreMap.set(item.id, newScore); + } + } + } + + // 构建最终结果,按分数排序 + results = Array.from(scoreMap.entries()) + .map(([id, score]) => ({ id, score })) + .sort((a, b) => (b.score || 0) - (a.score || 0)); + + addLog.info('Rerank MaxScore Strategy Result', { + totalQueries: queries.length, + totalInputTokens, + resultCount: results.length, + topScores: results.slice(0, 5).map((r) => ({ id: r.id, score: r.score })) + }); + } else if (rerankStrategy === RerankStrategyEnum.concat) { + // 策略1: 拼接策略 - 使用拼接后的query直接rerank + addLog.info('Rerank Strategy: Concat', { + query + }); + + const { results: rerankResults, inputTokens } = await reRankRecall({ + model: rerankModel, + query, + documents + }); + + results = rerankResults; + totalInputTokens = inputTokens; + + addLog.info('Rerank Concat Strategy Result', { + totalInputTokens, + resultCount: results.length, + topScores: results.slice(0, 5).map((r) => ({ id: r.id, score: r.score })) + }); + } + + if (results.length === 0) { + return Promise.reject('Assistant rerank error: no results'); + } + + // 将rerank分数添加到数据中 + const mergeResult = results + .map((item, index) => { + const target = data.find((dataItem) => dataItem.id === item.id); + if (!target) return null; + const score = item.score || 0; + + return { + ...target, + score: [{ type: SearchScoreTypeEnum.reRank, value: score, index }] + }; + }) + .filter(Boolean) as SearchDataResponseItemType[]; + + addLog.info('Assistant Rerank Completed', { + strategy: rerankStrategy, + inputTokens: totalInputTokens, + resultCount: mergeResult.length + }); + + return { + results: mergeResult, + inputTokens: totalInputTokens + }; +}; diff --git a/packages/service/core/dataset/search/base.ts b/packages/service/core/dataset/search/base.ts new file mode 100644 index 000000000000..5b62db8f2341 --- /dev/null +++ b/packages/service/core/dataset/search/base.ts @@ -0,0 +1,177 @@ +import { SearchScoreTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { recallFromVectorStore } from '../../../common/vectorDB/controller'; +import { getVectorsByText } from '../../ai/embedding'; +import { getEmbeddingModelById } from '../../ai/model'; +import { MongoDatasetData } from '../data/schema'; +import { MongoDatasetCollection } from '../collection/schema'; +import { readFromSecondary } from '../../../common/mongo/utils'; +import { formatDatasetDataValue } from '../data/controller'; +import { getCollectionSourceData } from '@fastgpt/global/core/dataset/collection/utils'; +import type { + DatasetCollectionSchemaType, + DatasetDataSchemaType +} from '@fastgpt/global/core/dataset/type'; +import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; + +// 数据库查询字段定义 +export const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes'; +export const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + +export const embeddingRecall = async ({ + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList = [], + filterCollectionIdList +}: { + teamId: string; + datasetIds: string[]; + queries: string[]; + modelId: string; + limit: number; + forbidCollectionIdList?: string[]; + filterCollectionIdList?: string[]; +}): Promise<{ + embeddingRecallResults: SearchDataResponseItemType[][]; + tokens: number; +}> => { + if (limit === 0) { + return { + embeddingRecallResults: [], + tokens: 0 + }; + } + + const { vectors, tokens } = await getVectorsByText({ + model: getEmbeddingModelById(modelId), + input: queries, + type: 'query' + }); + + const recallResults = await Promise.all( + vectors.map(async (vector) => { + return await recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + // Get data and collections + const collectionIdList = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.collectionId)).flat()) + ); + const indexDataIds = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.id?.trim())).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIdList }, + 'indexes.dataId': { $in: indexDataIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + item.indexes.forEach((index) => { + map.set(String(index.dataId), item); + }); + }); + + return map; + }), + MongoDatasetCollection.find( + { + _id: { $in: collectionIdList }, + deleteTime: null + }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + map.set(String(item._id), item); + }); + + return map; + }) + ]); + + const embeddingRecallResults = await Promise.all( + recallResults.map(async (item) => { + const set = new Set(); + const results: SearchDataResponseItemType[] = []; + + for (let index = 0; index < item.results.length; index++) { + const recallItem = item.results[index]; + const collection = collectionMaps.get(String(recallItem.collectionId)); + if (!collection) { + console.log('Collection is not found', recallItem); + continue; + } + + const data = dataMaps.get(String(recallItem.id)); + if (!data) { + console.log('Data is not found', recallItem); + continue; + } + + // 多个向量对应一个数据,每一路召回,保障数据只有一份 + if (set.has(data._id.toString())) { + continue; + } + set.add(data._id.toString()); + + const result: SearchDataResponseItemType = { + id: String(data._id), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score: [ + { + type: SearchScoreTypeEnum.embedding, + value: recallItem?.score || 0, + index: results.length + } + ] + }; + + results.push(result); + } + + return results; + }) + ); + + return { + embeddingRecallResults, + tokens + }; +}; diff --git a/packages/service/core/dataset/search/capabilities.ts b/packages/service/core/dataset/search/capabilities.ts new file mode 100644 index 000000000000..9bac1c2dbdb9 --- /dev/null +++ b/packages/service/core/dataset/search/capabilities.ts @@ -0,0 +1,1252 @@ +/** + * 知识库检索公共能力 + * + * 本文件提取检索系统的公共技术能力,供 Controller、Provider、Agentic 等模块复用 + * 不包含任何业务逻辑(App 级别的 correction/faq 优先),只提供纯技术能力 + */ + +import { Types } from '../../../common/mongo'; +import { recallFromVectorStore } from '../../../common/vectorDB/controller'; +import { getVectorsByText } from '../../ai/embedding'; +import { getDefaultRerankModel, getEmbeddingModelById } from '../../ai/model'; +import { reRankRecall } from '../../../core/ai/rerank'; +import { MongoDatasetData } from '../data/schema'; +import { MongoDatasetCollection } from '../collection/schema'; +import { MongoDatasetDataText } from '../data/dataTextSchema'; +import { formatDatasetDataValue } from '../data/controller'; +import { getCollectionSourceData } from '@fastgpt/global/core/dataset/collection/utils'; +import { datasetSearchResultConcat } from '@fastgpt/global/core/dataset/search/utils'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { jiebaSplit, jiebaSplitWithCustomDict } from '../../../common/string/jieba/index'; +import { readFromSecondary } from '../../../common/mongo/utils'; +import type { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { SearchScoreTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; +import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; +import type { DatasetCollectionSchemaType } from '@fastgpt/global/core/dataset/type'; +import type { DatasetDataTextSchemaType } from '@fastgpt/global/core/dataset/type'; +import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { addLog } from '../../../common/system/log'; +import { extractChunkSynonyms } from './synonym'; +import { MongoDatasetSynonymMapping } from '../synonym/mappingSchema'; +import { MILVUS_ADDRESS, PG_ADDRESS, OCEANBASE_ADDRESS } from '../../../common/vectorDB/constants'; +import { MilvusCtrl } from '../../../common/vectorDB/milvus/index'; +import { milvusVersionManager } from '../../../common/vectorDB/milvus/version'; + +/* ==================== 类型定义 ==================== */ + +export interface RecallOptions { + teamId: string; + datasetIds: string[]; + queries: string[]; + modelId: string; + limit: number; + forbidCollectionIdList?: string[]; + filterCollectionIdList?: string[]; + searchMode?: `${DatasetSearchModeEnum}`; + customWords?: string[]; + /** 预计算向量,提供时跳过 getVectorsByText 步骤,直接用于向量检索 */ + precomputedVectors?: number[][]; +} + +export interface MultiQueryRecallResult { + results: SearchDataResponseItemType[][]; + tokens: number; +} + +export interface RecallResult { + results: SearchDataResponseItemType[]; + tokens: number; +} + +export interface MixedRecallOptions extends RecallOptions { + embeddingWeight?: number; + usingReRank?: boolean; + rerankModel?: RerankModelItemType; + similarity?: number; + rerankMethod?: any; + rerankWeight?: number; +} + +export interface MixedRecallResult extends RecallResult { + usingReRank: boolean; + rerankTime?: number; + retrievalTime?: number; +} + +/* ==================== 同义词词汇表 ==================== */ + +/** + * 获取多个知识库的全部同义词词汇,作为 jieba 自定义词表 + */ +export async function getAllDatasetsSynonymWords( + teamId: string, + datasetIds: string[] +): Promise { + if (!datasetIds || datasetIds.length === 0) { + return []; + } + + try { + const allMappings = await MongoDatasetSynonymMapping.find( + { + teamId: new Types.ObjectId(teamId), + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) } + }, + 'standardizedTerm synonymTerms' + ).lean(); + + const synonymWords = new Set(); + + allMappings.forEach((mapping) => { + if (mapping.standardizedTerm) { + synonymWords.add(mapping.standardizedTerm); + } + if (mapping.synonymTerms && Array.isArray(mapping.synonymTerms)) { + mapping.synonymTerms.forEach((term) => { + if (term) synonymWords.add(term); + }); + } + }); + + const result = Array.from(synonymWords); + addLog.debug('getAllDatasetsSynonymWords', { + datasetCount: datasetIds.length, + totalSynonymWords: result.length, + words: result.slice(0, 10) + }); + + return result; + } catch (error) { + addLog.error('getAllDatasetsSynonymWords error', { + datasetIds, + error: error instanceof Error ? error.message : String(error) + }); + return []; + } +} + +/* ==================== 向量检索 ==================== */ + +/** + * 向量检索 + * 将查询文本向量化后,从向量数据库检索相似文档 + */ +export async function embeddingRecall(options: RecallOptions): Promise { + const { + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList = [], + filterCollectionIdList, + precomputedVectors + } = options; + + // 若调用方已预先计算向量(如 VectorSearchProvider),跳过 embedding 步骤 + let vectors: number[][]; + let tokens: number; + + if (precomputedVectors && precomputedVectors.length > 0) { + vectors = precomputedVectors; + tokens = 0; + } else { + const vectorModel = getEmbeddingModelById(modelId); + const result = await getVectorsByText({ + model: vectorModel, + input: queries, + type: 'query' + }); + vectors = result.vectors; + tokens = result.tokens; + } + + // 并行对每个向量执行检索 + const recallResults = await Promise.all( + vectors.map(async (vector) => { + return await recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + // 获取数据集和 collection 信息 + const collectionIdList = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.collectionId)).flat()) + ); + const indexDataIds = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.id?.trim()).flat()).flat()) + ); + + addLog.debug( + `[embeddingRecall] PG returned: indexDataIds=${indexDataIds.length}, collectionIds=${collectionIdList.length}` + ); + if (indexDataIds.length > 0) { + addLog.debug(`[embeddingRecall] sample indexDataIds: ${indexDataIds.slice(0, 3).join(', ')}`); + addLog.debug( + `[embeddingRecall] sample collectionIds: ${collectionIdList.slice(0, 3).join(', ')}` + ); + } + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIdList }, + 'indexes.dataId': { $in: indexDataIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + item.indexes.forEach((index) => { + if (indexDataIds.includes(index.dataId)) { + map.set(String(index.dataId), item); + } + }); + }); + addLog.debug( + `[embeddingRecall] MongoDB DatasetData: docs=${res.length}, indexMap=${map.size} (pgIds=${indexDataIds.length}, collIds=${collectionIdList.length})` + ); + return map; + }), + MongoDatasetCollection.find({ _id: { $in: collectionIdList }, deleteTime: null }, datasetCollectionSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + map.set(String(item._id), item); + }); + addLog.debug(`[embeddingRecall] MongoDB Collection: found=${res.length}`); + return map; + }) + ]); + + // 转换结果 + const results = recallResults + .map((item) => item.results) + .flat() + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + const data = dataMaps.get(String(item.id)); + + if (!collection || !data) return null; + + return { + id: String(data._id), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }], + metadata: data.metadata + } as SearchDataResponseItemType; + }) + .filter((item): item is SearchDataResponseItemType => item !== null); + + // 去重 + const set = new Set(); + const dedupedResults = results.filter((item) => { + if (set.has(item.id)) return false; + set.add(item.id); + return true; + }); + + return { + results: dedupedResults, + tokens + }; +} + +/** + * 向量检索(多查询版,返回 per-query 2D 结果) + * 每个 query 单独召回,保留 per-query 维度,供 controller 做第一层 RRF + * 同时提取 synonymMappings + */ +export async function embeddingRecallPerQuery( + options: RecallOptions +): Promise { + const { + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList = [], + filterCollectionIdList + } = options; + + if (limit === 0) { + return { results: [], tokens: 0 }; + } + + const vectorModel = getEmbeddingModelById(modelId); + + const { vectors, tokens } = await getVectorsByText({ + model: vectorModel, + input: queries, + type: 'query' + }); + + const recallResults = await Promise.all( + vectors.map(async (vector) => { + return await recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + const collectionIdList = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.collectionId)).flat()) + ); + const indexDataIds = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.id?.trim())).flat()) + ); + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIdList }, + 'indexes.dataId': { $in: indexDataIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + item.indexes.forEach((index) => { + if (indexDataIds.includes(index.dataId)) { + map.set(String(index.dataId), item); + } + }); + }); + return map; + }), + MongoDatasetCollection.find({ _id: { $in: collectionIdList }, deleteTime: null }, datasetCollectionSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + map.set(String(item._id), item); + }); + return map; + }) + ]); + + // 每个 query 独立转换结果,保留 2D 结构 + const results = recallResults.map((queryResult) => { + const set = new Set(); + return ( + queryResult.results + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return null; + } + const data = dataMaps.get(String(item.id)); + if (!data) { + console.log('Data is not found', item); + return null; + } + + // 提取该 chunk 的同义词映射 + const synonymMappings = extractChunkSynonyms(data, String(item.id)); + + return { + id: String(data._id), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }], + metadata: data.metadata, + synonymMappings: synonymMappings.length > 0 ? synonymMappings : undefined + } as SearchDataResponseItemType; + }) + // 同一路召回中每个 data id 只保留一份(取最高排名) + .filter((item): item is SearchDataResponseItemType => { + if (!item) return false; + if (set.has(item.id)) return false; + set.add(item.id); + return true; + }) + .map((item, index) => ({ + ...item, + score: item.score.map((s) => ({ ...s, index })) + })) + ); + }); + + return { results, tokens }; +} + +/* ==================== 全文检索 ==================== */ + +/** + * 全文检索 + * 使用全文索引(MongoDB text 或 Milvus BM25)检索文档 + */ +export async function fullTextRecall(options: RecallOptions): Promise { + const { + teamId, + datasetIds, + queries, + limit, + forbidCollectionIdList = [], + filterCollectionIdList + } = options; + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + // 并行对每个查询执行全文检索 + const recallResults = await Promise.all( + queries.map(async (query) => { + return (await MongoDatasetDataText.aggregate( + [ + { + $match: { + teamId: new Types.ObjectId(teamId), + $text: { $search: await jiebaSplit({ text: query }) }, + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) }, + ...(filterCollectionIdList + ? { + collectionId: { + $in: filterCollectionIdList + .filter((id) => !forbidCollectionIdList.includes(id)) + .map((id) => new Types.ObjectId(id)) + } + } + : forbidCollectionIdList?.length + ? { + collectionId: { + $nin: forbidCollectionIdList.map((id) => new Types.ObjectId(id)) + } + } + : {}) + } + }, + { + $sort: { + score: { $meta: 'textScore' } + } + }, + { + $limit: limit + }, + { + $project: { + _id: 1, + collectionId: 1, + dataId: 1, + score: { $meta: 'textScore' } + } + } + ], + { + ...readFromSecondary + } + )) as (DatasetDataTextSchemaType & { score: number })[]; + }) + ); + + const dataIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.dataId)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find({ _id: { $in: dataIds } }, datasetDataSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + map.set(String(item._id), item); + }); + return map; + }), + MongoDatasetCollection.find({ _id: { $in: collectionIds }, deleteTime: null }, datasetCollectionSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + map.set(String(item._id), item); + }); + return map; + }) + ]); + + // 转换结果 + const results = recallResults + .map((item) => item) + .flat() + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + const data = dataMaps.get(String(item.dataId)); + + if (!collection || !data) return null; + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [ + { + type: SearchScoreTypeEnum.fullText, + value: item.score || 0, + index + } + ] + } as SearchDataResponseItemType; + }) + .filter((item): item is SearchDataResponseItemType => item !== null); + + return { + results, + tokens: 0 // 全文检索不消耗 embedding tokens + }; +} + +/* ==================== 全文检索(多查询 / 生产版) ==================== */ + +/** + * Milvus 2.6+ BM25 全文检索(多查询,返回 2D per-query 结果) + */ +export async function fullTextRecallFromMilvus(options: { + teamId: string; + datasetIds: string[]; + queries: string[]; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; +}): Promise<{ results: SearchDataResponseItemType[][] }> { + const { teamId, datasetIds, queries, limit, forbidCollectionIdList, filterCollectionIdList } = + options; + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + const milvusCtrl = new MilvusCtrl(); + + // 并行执行多个查询的 BM25 检索 + const recallResults = await Promise.all( + queries.map(async (query) => { + return await milvusCtrl.fullTextSearch({ + teamId, + datasetIds, + query, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + const vectorIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.id)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + const [dataMap, collectionMaps] = await Promise.all([ + MongoDatasetData.find({ 'indexes.dataId': { $in: vectorIds } }, datasetDataSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + item.indexes?.forEach((idx: any) => { + if (idx.dataId) map.set(String(idx.dataId), item); + }); + }); + return map; + }), + MongoDatasetCollection.find( + { _id: { $in: collectionIds.map((id) => new Types.ObjectId(id)) }, deleteTime: null }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => map.set(String(item._id), item)); + return map; + }) + ]); + + const results = recallResults.map((queryResults) => { + return queryResults + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return null; + } + + const data = dataMap.get(String(item.id)); + if (!data) { + console.log('Data is not found', item); + return null; + } + + const synonymMappings = extractChunkSynonyms(data, String(item.id)); + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.fullText, value: item.score || 0, index }], + synonymMappings: synonymMappings.length > 0 ? synonymMappings : undefined + } as SearchDataResponseItemType; + }) + .filter((item): item is SearchDataResponseItemType => item !== null) + .map((item, index) => ({ + ...item, + score: item.score.map((s) => ({ ...s, index })) + })); + }); + + return { results }; +} + +/** + * MongoDB 全文检索(多查询,返回 2D per-query 结果) + * 支持 customWords(知识库同义词词表) + */ +export async function fullTextRecallFromMongo(options: { + teamId: string; + datasetIds: string[]; + queries: string[]; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; + customWords?: string[]; +}): Promise<{ results: SearchDataResponseItemType[][] }> { + const { + teamId, + datasetIds, + queries, + limit, + forbidCollectionIdList, + filterCollectionIdList, + customWords = [] + } = options; + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + const recallResults = await Promise.all( + queries.map(async (query) => { + const jiebaSplitResult = await jiebaSplitWithCustomDict({ text: query, customWords }); + return (await MongoDatasetDataText.aggregate( + [ + { + $match: { + teamId: new Types.ObjectId(teamId), + $text: { $search: jiebaSplitResult }, + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) }, + ...(filterCollectionIdList + ? { + collectionId: { + $in: filterCollectionIdList + .filter((id) => !forbidCollectionIdList.includes(id)) + .map((id) => new Types.ObjectId(id)) + } + } + : forbidCollectionIdList?.length + ? { + collectionId: { + $nin: forbidCollectionIdList.map((id) => new Types.ObjectId(id)) + } + } + : {}) + } + }, + { $sort: { score: { $meta: 'textScore' } } }, + { $limit: limit }, + { $project: { _id: 1, collectionId: 1, dataId: 1, score: { $meta: 'textScore' } } } + ], + { ...readFromSecondary } + )) as (DatasetDataTextSchemaType & { score: number })[]; + }) + ); + + const dataIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.dataId)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find({ _id: { $in: dataIds } }, datasetDataSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => map.set(String(item._id), item)); + return map; + }), + MongoDatasetCollection.find({ _id: { $in: collectionIds }, deleteTime: null }, datasetCollectionSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => map.set(String(item._id), item)); + return map; + }) + ]); + + const results = recallResults.map((queryResults) => { + return queryResults + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return null; + } + const data = dataMaps.get(String(item.dataId)); + if (!data) { + console.log('Data is not found', item); + return null; + } + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.fullText, value: item.score || 0, index }] + } as SearchDataResponseItemType; + }) + .filter((item): item is SearchDataResponseItemType => item !== null) + .map((item, index) => ({ + ...item, + score: item.score.map((s) => ({ ...s, index })) + })); + }); + + return { results }; +} + +/** + * 全文检索(多查询版) + * 自动路由:Milvus 2.6+ BM25 或 MongoDB 全文检索 + * 返回 2D per-query 结果,供 controller 做第一层 RRF + */ +export async function fullTextRecallPerQuery( + options: RecallOptions & { customWords?: string[] } +): Promise<{ results: SearchDataResponseItemType[][] }> { + const { + teamId, + datasetIds, + queries, + limit, + forbidCollectionIdList = [], + filterCollectionIdList, + customWords = [] + } = options; + + if (limit === 0) { + return { results: [] }; + } + + // 决策:使用 Milvus BM25 还是 MongoDB? + const useMilvusFullText = (() => { + if (!MILVUS_ADDRESS) return false; + if (PG_ADDRESS || OCEANBASE_ADDRESS) return false; + if (!milvusVersionManager.supportsFullText()) return false; + if (process.env.MILVUS_FULL_TEXT_ENABLED === 'false') return false; + return true; + })(); + + if (useMilvusFullText) { + return fullTextRecallFromMilvus({ + teamId, + datasetIds, + queries, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + } + + return fullTextRecallFromMongo({ + teamId, + datasetIds, + queries, + limit, + forbidCollectionIdList, + filterCollectionIdList, + customWords + }); +} + +/* ==================== Milvus 混合检索 ==================== */ + +/** + * Milvus 混合检索(向量 + BM25,内部做融合) + * 返回 2D per-query 结果,供 controller 做第一层 RRF + */ +export async function milvusHybridRecall(options: { + teamId: string; + datasetIds: string[]; + queries: string[]; + modelId: string; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; +}): Promise<{ results: SearchDataResponseItemType[][]; tokens: number }> { + const { + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList, + filterCollectionIdList + } = options; + + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + const vectorModel = getEmbeddingModelById(modelId); + const { vectors, tokens } = await getVectorsByText({ + model: vectorModel, + input: queries, + type: 'query' + }); + + const milvusCtrl = new MilvusCtrl(); + const recallResults = await Promise.all( + vectors.map(async (vector, index) => { + return await milvusCtrl.hybridSearch({ + teamId, + datasetIds, + vector, + query: queries[index], + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + const vectorIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.id)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + const [dataMap, collectionMaps] = await Promise.all([ + MongoDatasetData.find({ 'indexes.dataId': { $in: vectorIds } }, datasetDataSelectField, { + ...readFromSecondary + }) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + item.indexes?.forEach((idx: any) => { + if (idx.dataId) map.set(String(idx.dataId), item); + }); + }); + return map; + }), + MongoDatasetCollection.find( + { _id: { $in: collectionIds.map((id) => new Types.ObjectId(id)) }, deleteTime: null }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => map.set(String(item._id), item)); + return map; + }) + ]); + + const results = recallResults.map((queryResults) => { + return queryResults + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + const data = dataMap.get(String(item.id)); + if (!collection || !data) return null; + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.rrf, value: item.score || 0, index }] + } as SearchDataResponseItemType; + }) + .filter((item): item is SearchDataResponseItemType => item !== null); + }); + + return { results, tokens }; +} + +/* ==================== RRF 融合 ==================== */ + +/** + * RRF (Reciprocal Rank Fusion) 融合 + * 将多路检索结果按排名融合 + */ +export function rrfFusion( + embeddingResults: SearchDataResponseItemType[], + fullTextResults: SearchDataResponseItemType[], + embeddingWeight: number = 0.5 +): SearchDataResponseItemType[] { + if (embeddingResults.length === 0 && fullTextResults.length === 0) { + return []; + } + + const embWeight = embeddingWeight; + const ftWeight = 1 - embeddingWeight; + + const rrfResults = datasetSearchResultConcat([ + { weight: embWeight, list: embeddingResults }, + { weight: ftWeight, list: fullTextResults } + ]); + + return rrfResults; +} + +/* ==================== 重排 ==================== */ + +/** + * 文档重排 + * 使用 Rerank 模型对检索结果进行重排 + */ +export async function rerank( + query: string, + results: SearchDataResponseItemType[], + rerankModel: RerankModelItemType +): Promise<{ + results: SearchDataResponseItemType[]; + inputTokens: number; + rerankTime?: number; +}> { + if (results.length === 0) { + return { results: [], inputTokens: 0 }; + } + + const startTime = Date.now(); + + const documents = results.map((r) => ({ + id: r.id, + text: r.q && r.a ? `${r.q}\n${r.a}` : r.q || r.a || '' + })); + + try { + const result = await reRankRecall({ + query, + documents, + model: rerankModel + }); + + const rerankScoreMap = new Map(result.results.map((r: any) => [r.id, r.score])); + + const rerankedResults = results.map((r) => { + const rerankScore = rerankScoreMap.get(r.id); + if (rerankScore !== undefined) { + return { + ...r, + score: [ + ...r.score.filter((s) => s.type !== SearchScoreTypeEnum.reRank), + { type: SearchScoreTypeEnum.reRank as const, value: rerankScore, index: 0 } + ] + }; + } + return r; + }); + + // 按 rerank score 排序 + rerankedResults.sort((a, b) => { + const aScore = a.score.find((s) => s.type === SearchScoreTypeEnum.reRank)?.value || 0; + const bScore = b.score.find((s) => s.type === SearchScoreTypeEnum.reRank)?.value || 0; + return bScore - aScore; + }); + + const rerankTime = +((Date.now() - startTime) / 1000).toFixed(2); + + return { + results: rerankedResults, + inputTokens: result.inputTokens || 0, + rerankTime + }; + } catch (error) { + addLog.error('[rerank] Rerank failed', { error }); + return { + results, + inputTokens: 0, + rerankTime: undefined + }; + } +} + +/* ==================== 相似度过滤 ==================== */ + +/** + * 按相似度阈值过滤结果 + */ +export function filterBySimilarity( + results: SearchDataResponseItemType[], + threshold: number, + usingReRank: boolean = false +): { + filtered: SearchDataResponseItemType[]; + discarded: SearchDataResponseItemType[]; +} { + const filtered: SearchDataResponseItemType[] = []; + const discarded: SearchDataResponseItemType[] = []; + + results.forEach((item) => { + const scoreType = usingReRank ? SearchScoreTypeEnum.reRank : SearchScoreTypeEnum.embedding; + const score = item.score.find((s) => s.type === scoreType)?.value || 0; + + if (score >= threshold) { + filtered.push(item); + } else { + discarded.push(item); + } + }); + + return { filtered, discarded }; +} + +/* ==================== Token 限制 ==================== */ + +/** + * 按 token 数量限制结果 + */ +export async function filterByTokens( + results: SearchDataResponseItemType[], + maxTokens: number +): Promise { + let currentTokens = 0; + const filtered: SearchDataResponseItemType[] = []; + + for (const item of results) { + const qLength = item.q?.length || 0; + const aLength = item.a?.length || 0; + // 简单估算:中文约 1 token/字符,英文约 1.3 token/字符 + const estimatedTokens = Math.ceil((qLength + aLength) / 2); + + if (currentTokens + estimatedTokens <= maxTokens) { + filtered.push(item); + currentTokens += estimatedTokens; + } else { + break; + } + } + + return filtered; +} + +/* ==================== 去重 ==================== */ + +/** + * 基于内容 hash 去重 + */ +export function dedupeByContent( + results: SearchDataResponseItemType[] +): SearchDataResponseItemType[] { + const set = new Set(); + const deduped: SearchDataResponseItemType[] = []; + + results.forEach((item) => { + const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); + if (set.has(str)) return; + set.add(str); + deduped.push(item); + }); + + return deduped; +} + +/* ==================== 混合检索 ==================== */ + +/** + * 混合检索 + * 向量检索 + 全文检索 + RRF 融合 + 重排(可选) + */ +export async function mixedRecall(options: MixedRecallOptions): Promise { + const { + teamId, + datasetIds, + queries, + modelId, + limit, + embeddingWeight = 0.5, + usingReRank = false, + rerankModel, + similarity = 0, + rerankMethod, + rerankWeight = 0.5, + forbidCollectionIdList = [], + filterCollectionIdList + } = options; + + const startTime = Date.now(); + + // 并行执行向量检索和全文检索 + const [{ results: embResults, tokens: embTokens }, ftResult] = await Promise.all([ + embeddingRecall({ + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList, + filterCollectionIdList + }), + fullTextRecall({ + teamId, + datasetIds, + queries, + modelId, + limit, + forbidCollectionIdList, + filterCollectionIdList + }) + ]); + + // RRF 融合 + let fusedResults = rrfFusion(embResults, ftResult.results, embeddingWeight); + + // 去重 + fusedResults = dedupeByContent(fusedResults); + + // 重排(可选) + let usingReRankResult = false; + let rerankTime: number | undefined; + + if (usingReRank && rerankModel && fusedResults.length > 0) { + const rerankResult = await rerank(queries[0] || '', fusedResults, rerankModel); + fusedResults = rerankResult.results; + usingReRankResult = true; + rerankTime = rerankResult.rerankTime; + } + + // 相似度过滤 + if (similarity > 0) { + const { filtered } = filterBySimilarity(fusedResults, similarity, usingReRankResult); + fusedResults = filtered; + } + + const retrievalTime = +((Date.now() - startTime) / 1000).toFixed(2); + + return { + results: fusedResults, + tokens: embTokens, + usingReRank: usingReRankResult, + rerankTime, + retrievalTime + }; +} diff --git a/packages/service/core/dataset/search/controller.ts b/packages/service/core/dataset/search/controller.ts index 45a5eb81707e..7d9cfc671b5a 100644 --- a/packages/service/core/dataset/search/controller.ts +++ b/packages/service/core/dataset/search/controller.ts @@ -1,15 +1,24 @@ import { DatasetSearchModeEnum, DatasetSearchModeMap, - SearchScoreTypeEnum + SearchScoreTypeEnum, + RerankMethodEnum, + DatasetCollectionDataProcessModeEnum, + DatabaseTypeEnum } from '@fastgpt/global/core/dataset/constants'; -import { recallFromVectorStore } from '../../../common/vectorDB/controller'; +import { + recallFromVectorStore, + databaseEmbeddingRecall +} from '../../../common/vectorDB/controller'; import { getVectorsByText } from '../../ai/embedding'; -import { getEmbeddingModel, getDefaultRerankModel, getLLMModel } from '../../ai/model'; +import { getDefaultRerankModel, getEmbeddingModelById, getRerankModelById } from '../../ai/model'; import { MongoDatasetData } from '../data/schema'; import type { + DatabaseConfig, DatasetCollectionSchemaType, - DatasetDataSchemaType + DatasetDataSchemaType, + ColumnSchemaType, + ForeignKeySchemaType } from '@fastgpt/global/core/dataset/type'; import { type DatasetDataTextSchemaType, @@ -19,8 +28,6 @@ import { MongoDatasetCollection } from '../collection/schema'; import { reRankRecall } from '../../../core/ai/rerank'; import { countPromptTokens } from '../../../common/string/tiktoken/index'; import { datasetSearchResultConcat } from '@fastgpt/global/core/dataset/search/utils'; -import { hashStr } from '@fastgpt/global/common/string/tools'; -import { jiebaSplit } from '../../../common/string/jieba/index'; import { getCollectionSourceData } from '@fastgpt/global/core/dataset/collection/utils'; import { Types } from '../../../common/mongo'; import json5 from 'json5'; @@ -28,27 +35,60 @@ import { MongoDatasetCollectionTags } from '../tag/schema'; import { computeFilterIntersection } from './utils'; import { readFromSecondary } from '../../../common/mongo/utils'; import { MongoDatasetDataText } from '../data/dataTextSchema'; -import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import { type ChatItemMiniType, type ChatItemType } from '@fastgpt/global/core/chat/type'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { datasetSearchQueryExtension } from './utils'; +import { datasetSearchQueryExtension, getDatasetSqlResultLimit } from './utils'; import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.schema'; import { formatDatasetDataValue } from '../data/controller'; +import { + DBDatasetValueVectorTableName, + DBDatasetVectorTableName, + MILVUS_ADDRESS, + PG_ADDRESS, + OCEANBASE_ADDRESS +} from '../../../common/vectorDB/constants'; +import { MongoDataset } from '../schema'; +import { addLog } from '../../../common/system/log'; +import { i18nT } from '../../../../global/common/i18n/utils'; +import { DatabaseErrEnum } from '@fastgpt/global/common/error/code/database'; +import type { + DativeForeignKey, + DativeSourceConfigType, + DativeTable, + DativeTableColumns, + SqlGenerationRequest, + SqlGenerationResponse +} from '@fastgpt/global/core/dataset/database/api'; +import type { DatabaseEmbeddingRecallItemType } from '../../../common/vectorDB/type'; +import { queryByNL } from '../database/dative/client/dativeApiServer'; +import { searchDatasetDataForAssistant } from './customController'; import { pushTrack } from '../../../common/middle/tracks/utils'; import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils'; import { addDays } from 'date-fns'; -import { getLogger, LogCategories } from '../../../common/logger'; - -const logger = getLogger(LogCategories.MODULE.DATASET.DATA); +import { milvusVersionManager } from '../../../common/vectorDB/milvus/version'; +import { MilvusCtrl } from '../../../common/vectorDB/milvus/index'; +import { datasetDataSelectField, datasetCollectionSelectField } from './base'; +import { extractChunkSynonyms } from './synonym'; +import { jiebaSplitWithCustomDict } from '../../../common/string/jieba/index'; +// capabilities.ts 公共检索能力 +import { + getAllDatasetsSynonymWords, + embeddingRecallPerQuery, + fullTextRecallPerQuery, + milvusHybridRecall, + dedupeByContent +} from './capabilities'; export type SearchDatasetDataProps = { histories: ChatItemMiniType[]; teamId: string; uid?: string; tmbId?: string; - model: string; + modelId: string; datasetIds: string[]; reRankQuery: string; queries: string[]; + lang: string; [NodeInputKeyEnum.datasetSimilarity]?: number; // min distance [NodeInputKeyEnum.datasetMaxTokens]: number; // max Token limit @@ -56,7 +96,8 @@ export type SearchDatasetDataProps = { [NodeInputKeyEnum.datasetSearchEmbeddingWeight]?: number; [NodeInputKeyEnum.datasetSearchUsingReRank]?: boolean; - [NodeInputKeyEnum.datasetSearchRerankModel]?: RerankModelItemType; + [NodeInputKeyEnum.datasetSearchRerankModelId]?: string; + [NodeInputKeyEnum.datasetSearchRerankMethod]?: RerankMethodEnum; [NodeInputKeyEnum.datasetSearchRerankWeight]?: number; /* @@ -72,6 +113,19 @@ export type SearchDatasetDataProps = { } */ collectionFilterMatch?: string; + /** 权限过滤:用户无读权限的 collection ID,将合并到 forbidCollectionIdList */ + authForbidCollectionIds?: string[]; +}; + +export type SearchDatabaseDataProps = { + histories?: ChatItemType[]; + teamId: string; + modelId: string; + datasetIds: string[]; + queries: string[]; + [NodeInputKeyEnum.datasetMaxTokens]: number; // max Token limit + [NodeInputKeyEnum.searchColumnslLimitRatio]?: number; // default 0.3 + authForbidCollectionIds?: string[]; // 权限过滤:用户无权限的 collection ID }; export type SearchDatasetDataResponse = { @@ -83,37 +137,108 @@ export type SearchDatasetDataResponse = { similarity: number; usingReRank: boolean; usingSimilarityFilter: boolean; + isFaqResult?: boolean; // 标记是否为 FAQ 检索结果 + + retrievalTime?: number; // 检索耗时(s),统计到进入reranker之前,保留2位小数 + rerankTime?: number; // 重排耗时(s),仅当 usingReRank=true 时有值,保留2位小数 + retrievalResults?: SearchDataResponseItemType[]; // 检索结果(RRF融合后的中间结果,用于落库) + retrievalType?: 'correction' | 'faq'; // 检索类型标记,仅当 correction 或 faq 命中时存在 + rerankError?: { + // Reranker 错误信息(仅当 reranker 报错时存在) + errorMessage: Record; // 错误详细信息(结构化对象) + i18nErrorMessage: string; // 错误信息的 i18n key(用于前端根据语言动态翻译) + i18nErrorMessageData: { modelName: string }; // i18n 占位符数据(用于前端渲染 i18n 消息) + }; queryExtensionResult?: { - llmModel: string; - embeddingModel: string; + llmModelId: string; + embeddingModelId: string; inputTokens: number; outputTokens: number; embeddingTokens: number; - query: string; + query?: string; // 改为可选,当没有问题改写时为 undefined + synonymRewriteResult?: { + standardizedQuery: string; // 指代消除后标准化的查询(用于检索) + coreferenceResolved: string; // 指代消除后的查询(同义词标准化前) + }; + rewriteTime?: number; // 问题改写耗时(s),保留2位小数 }; - deepSearchResult?: { model: string; inputTokens: number; outputTokens: number }; + deepSearchResult?: { modelId: string; inputTokens: number; outputTokens: number }; + + // 校正数据结果 + correctionData?: { + correctionId: string; + correctedAnswer: string; + question: string; + similarity: number; + }; + + // 新增:仅 agentic 模式填充 + agenticSearchResult?: { + reasoningText: string; // 思考过程文本 + searchCount: number; // 实际检索轮次 + toolCallCount: number; // Tool 调用总次数 + llmModelId?: string; // 实际使用的 LLM 模型名 + llmInputTokens: number; // LLM 输入 token 总量 + llmOutputTokens: number; // LLM 输出 token 总量 + playbook?: string; // 使用的 playbook + executionPath?: string[]; // 执行路径 + confidence?: number; // 置信度 0~1 + queryLanguage?: string; // 用户查询语言(ISO 639-1 代码) + }; +}; + +export type SearchDatabaseDataResponse = { + schema: Record< + string, + { + collectionId: string; + datasetId: string; + score: number; + retrieval_columns: string[]; + } + >; + tokens: number; }; export const datasetDataReRank = async ({ rerankModel, data, - query + query, + rerankMethod = RerankMethodEnum.content // Default }: { rerankModel?: RerankModelItemType; data: SearchDataResponseItemType[]; query: string; + rerankMethod: RerankMethodEnum; }): Promise<{ results: SearchDataResponseItemType[]; inputTokens: number; }> => { + // Debug日志:打印reranker使用的query + addLog.debug('Dataset Rerank - Query Info', { + query, + rerankMethod, + rerankModel: rerankModel?.name, + dataCount: data.length + }); + const { results, inputTokens } = await reRankRecall({ model: rerankModel, query, - documents: data.map((item) => ({ - id: item.id, - text: `${item.q}\n${item.a}`.trim() - })) + documents: data.map((item) => { + let text = ''; + switch (rerankMethod) { + case RerankMethodEnum.question: + text = item.q; + break; + case RerankMethodEnum.content: + default: + text = `${item.q}\n${item.a}`; + } + addLog.debug(`RerankMethod ${rerankMethod} Item ${item.id} text: ${text}`); + return { id: item.id, text }; + }) }); if (results.length === 0) { @@ -139,6 +264,7 @@ export const datasetDataReRank = async ({ inputTokens }; }; + export const filterDatasetDataByMaxTokens = async ( data: SearchDataResponseItemType[], maxTokens: number @@ -171,6 +297,471 @@ export const filterDatasetDataByMaxTokens = async ( return filterMaxTokensResult; }; +export async function filterCollectionByNewTagFormat({ + teamId, + datasetIds, + andConditions, + orConditions +}: { + teamId: string; + datasetIds: string[]; + andConditions?: Record>[]; + orConditions?: Record>[]; +}): Promise { + if (!andConditions?.length && !orConditions?.length) return []; + + // 收集所有条件中的 tag 名称 + const allTagNames = [ + ...(andConditions || []).map((cond) => Object.keys(cond)[0]), + ...(orConditions || []).map((cond) => Object.keys(cond)[0]) + ].filter(Boolean); + + // 按 dataset 批量查询 tag 名称 → tagId + tagType + const tagDocs = await MongoDatasetCollectionTags.find( + { + teamId, + datasetId: { $in: datasetIds }, + tag: { $in: allTagNames } + }, + '_id datasetId tag tagType', + { ...readFromSecondary } + ).lean(); + + // 按 datasetId 分组:{ datasetId → Map } + const datasetTagMap = new Map>(); + tagDocs.forEach((doc) => { + const dsId = String(doc.datasetId); + if (!datasetTagMap.has(dsId)) { + datasetTagMap.set(dsId, new Map()); + } + datasetTagMap.get(dsId)!.set(doc.tag, { + id: String(doc._id), + type: (doc as any).tagType || 'string' + }); + }); + + // 应用层 value 比较 + const checkValue = ( + opObj: Record, + storedVal: string | number, + tagType: string + ): boolean => { + const op = Object.keys(opObj)[0]; + const target = opObj[op]; + + if (op === '$empty') return storedVal === '' || storedVal === null || storedVal === undefined; + if (op === '$notEmpty') + return storedVal !== '' && storedVal !== null && storedVal !== undefined; + if (target === null) return false; + + if (tagType === 'number') { + const sv = Number(storedVal); + const tv = Number(target); + if (isNaN(sv) || isNaN(tv)) return false; + switch (op) { + case '$eq': + return sv === tv; + case '$ne': + return sv !== tv; + case '$gt': + return sv > tv; + case '$lt': + return sv < tv; + case '$gte': + return sv >= tv; + case '$lte': + return sv <= tv; + } + } else if (tagType === 'datetime') { + const sv = typeof storedVal === 'number' ? storedVal : new Date(storedVal).getTime(); + const tv = new Date(target).getTime(); + if (isNaN(sv) || isNaN(tv)) return false; + switch (op) { + case '$eq': + return sv === tv; + case '$ne': + return sv !== tv; + case '$gt': + return sv > tv; + case '$lt': + return sv < tv; + case '$gte': + return sv >= tv; + case '$lte': + return sv <= tv; + } + } else { + const sv = String(storedVal); + const tv = String(target); + switch (op) { + case '$eq': + return sv === tv; + case '$ne': + return sv !== tv; + case '$contains': + return sv.toLowerCase().includes(tv.toLowerCase()); + case '$notContains': + return !sv.toLowerCase().includes(tv.toLowerCase()); + case '$startsWith': + return sv.toLowerCase().startsWith(tv.toLowerCase()); + case '$endsWith': + return sv.toLowerCase().endsWith(tv.toLowerCase()); + case '$regex': + try { + return new RegExp(tv, 'i').test(sv); + } catch { + return false; + } + } + } + return false; + }; + + // 针对每个 dataset 分别构建查询(同名 tag 在不同 dataset 中 id 不同) + const collectionIds: string[] = []; + + for (const [datasetId, tagMap] of datasetTagMap) { + const andTagIds = (andConditions || []) + .map((cond) => tagMap.get(Object.keys(cond)[0])?.id) + .filter(Boolean) as string[]; + + const orTagIds = (orConditions || []) + .map((cond) => tagMap.get(Object.keys(cond)[0])?.id) + .filter(Boolean) as string[]; + + if (andTagIds.length === 0 && orTagIds.length === 0) continue; + + // Step1: MongoDB 按 tagId 过滤(走 tags.tagId 索引) + // AND:所有 tagId 必须存在;纯 OR:任一 tagId 存在即可 + const tagIdQuery: any = + andTagIds.length > 0 + ? { 'tags.tagId': { $all: andTagIds } } + : { 'tags.tagId': { $in: orTagIds } }; + + const candidates = await MongoDatasetCollection.find( + { teamId, datasetId, deleteTime: null, ...tagIdQuery }, + '_id tags', + { ...readFromSecondary } + ).lean(); + + // Step2: 应用层按 value 条件筛选 + for (const col of candidates) { + const tagsArr = ((col.tags || []) as any[]).filter( + (t) => typeof t === 'object' && t !== null && t.tagId + ); + + // AND 条件:每个条件都要满足 + const andOk = (andConditions || []).every((cond) => { + const tagName = Object.keys(cond)[0]; + const tagInfo = tagMap.get(tagName); + if (!tagInfo) return false; + const tv = tagsArr.find((t) => t.tagId === tagInfo.id); + if (!tv) return false; + return checkValue(cond[tagName], tv.value, tagInfo.type); + }); + + if (!andOk) continue; + + // OR 条件:至少一个满足(若无 OR 条件直接通过) + if (orConditions?.length) { + const orOk = orConditions.some((cond) => { + const tagName = Object.keys(cond)[0]; + const tagInfo = tagMap.get(tagName); + if (!tagInfo) return false; + const tv = tagsArr.find((t) => t.tagId === tagInfo.id); + if (!tv) return false; + return checkValue(cond[tagName], tv.value, tagInfo.type); + }); + if (!orOk) continue; + } + + collectionIds.push(String(col._id)); + } + } + + return collectionIds; +} + +/** + * 解析 collectionFilterMatch 为白名单 collection ID 列表。 + * 整合 tag 过滤、createTime 过滤、collectionIds 过滤,取交集后展开文件夹。 + * + * @returns whitelist collection IDs,undefined 表示无过滤条件,[] 表示过滤结果为空 + */ +export async function resolveCollectionFilter({ + teamId, + datasetIds, + collectionFilterMatch +}: { + teamId: string; + datasetIds: string[]; + collectionFilterMatch?: string; +}): Promise { + const getAllCollectionIds = async ({ + parentCollectionIds + }: { + parentCollectionIds?: string[]; + }): Promise => { + if (!parentCollectionIds) return; + if (parentCollectionIds.length === 0) { + return []; + } + + const collections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + _id: { $in: parentCollectionIds }, + deleteTime: null + }, + '_id type', + { ...readFromSecondary } + ).lean(); + + const resultIds = new Set(); + collections.forEach((item) => { + if (item.type !== 'folder') { + resultIds.add(String(item._id)); + } + }); + + const folderIds = collections + .filter((item) => item.type === 'folder') + .map((item) => String(item._id)); + + if (folderIds.length) { + const childCollections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + parentId: { $in: folderIds }, + deleteTime: null + }, + '_id type', + { ...readFromSecondary } + ).lean(); + + const childIds = await getAllCollectionIds({ + parentCollectionIds: childCollections.map((item) => String(item._id)) + }); + + childIds?.forEach((id) => resultIds.add(id)); + } + + return Array.from(resultIds); + }; + + if (!collectionFilterMatch || !(global as any).feConfigs?.isPlus) return; + + let tagCollectionIdList: string[] | undefined = undefined; + let createTimeCollectionIdList: string[] | undefined = undefined; + let inputCollectionIdList: string[] | undefined = undefined; + + try { + const jsonMatch = + typeof collectionFilterMatch === 'object' + ? collectionFilterMatch + : json5.parse(collectionFilterMatch); + + const andTags = jsonMatch?.tags?.$and as (string | null | Record)[] | undefined; + const orTags = jsonMatch?.tags?.$or as (string | null | Record)[] | undefined; + + const isNewTagFormat = + (Array.isArray(andTags) && + andTags.length > 0 && + typeof andTags[0] === 'object' && + andTags[0] !== null) || + (Array.isArray(orTags) && + orTags.length > 0 && + typeof orTags[0] === 'object' && + orTags[0] !== null); + + if (isNewTagFormat) { + const andConditions = (andTags || []).filter( + (item): item is Record> => + typeof item === 'object' && item !== null + ); + const orConditions = (orTags || []).filter( + (item): item is Record> => + typeof item === 'object' && item !== null + ); + + tagCollectionIdList = await filterCollectionByNewTagFormat({ + teamId, + datasetIds, + andConditions: andConditions.length > 0 ? andConditions : undefined, + orConditions: orConditions.length > 0 ? orConditions : undefined + }); + } else if (andTags && andTags.length > 0) { + const uniqueAndTags = Array.from(new Set(andTags)); + if (uniqueAndTags.includes(null) && uniqueAndTags.some((tag) => typeof tag === 'string')) { + return []; + } + if (uniqueAndTags.every((tag) => typeof tag === 'string')) { + const matchedTags = await MongoDatasetCollectionTags.find( + { + teamId, + datasetId: { $in: datasetIds }, + tag: { $in: uniqueAndTags as string[] } + }, + '_id datasetId tag', + { ...readFromSecondary } + ).lean(); + + const datasetTagMap = new Map }>(); + + matchedTags.forEach((tag) => { + const dsId = String(tag.datasetId); + if (!datasetTagMap.has(dsId)) { + datasetTagMap.set(dsId, { tagIds: [], tagNames: new Set() }); + } + const datasetData = datasetTagMap.get(dsId)!; + datasetData.tagIds.push(String(tag._id)); + datasetData.tagNames.add(tag.tag); + }); + + const validDatasetIds = Array.from(datasetTagMap.entries()) + .filter(([_, data]) => uniqueAndTags.every((tag) => data.tagNames.has(tag as string))) + .map(([datasetId]) => datasetId); + + if (validDatasetIds.length === 0) return []; + + const collectionsPromises = validDatasetIds.map((datasetId) => { + const { tagIds } = datasetTagMap.get(datasetId)!; + return MongoDatasetCollection.find( + { + teamId, + datasetId, + tags: { $all: tagIds }, + deleteTime: null + }, + '_id', + { ...readFromSecondary } + ).lean(); + }); + + const collectionsResults = await Promise.all(collectionsPromises); + tagCollectionIdList = collectionsResults.flat().map((item) => String(item._id)); + } else if (uniqueAndTags.every((tag) => tag === null)) { + const collections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + $or: [{ tags: { $size: 0 } }, { tags: { $exists: false } }], + deleteTime: null + }, + '_id', + { ...readFromSecondary } + ).lean(); + tagCollectionIdList = collections.map((item) => String(item._id)); + } + } else if (orTags && orTags.length > 0) { + const orTagArray = await MongoDatasetCollectionTags.find( + { + teamId, + datasetId: { $in: datasetIds }, + tag: { $in: orTags.filter((tag) => tag !== null) } + }, + '_id', + { ...readFromSecondary } + ).lean(); + const orTagIds = orTagArray.map((item) => String(item._id)); + + const collections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + $or: [ + { tags: { $in: orTagIds } }, + ...(orTags.includes(null) ? [{ tags: { $size: 0 } }] : []) + ], + deleteTime: null + }, + '_id', + { ...readFromSecondary } + ).lean(); + + tagCollectionIdList = collections.map((item) => String(item._id)); + } + + // time + const getCreateTime = jsonMatch?.createTime?.$gte as string | undefined; + const lteCreateTime = jsonMatch?.createTime?.$lte as string | undefined; + if (getCreateTime || lteCreateTime) { + const collections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + deleteTime: null, + createTime: { + ...(getCreateTime && { $gte: new Date(getCreateTime) }), + ...(lteCreateTime && { $lte: new Date(lteCreateTime) }) + } + }, + '_id' + ); + createTimeCollectionIdList = collections.map((item) => String(item._id)); + } + + // collectionIds + const inputCollectionIds = jsonMatch?.collectionIds as string[] | undefined; + if (Array.isArray(inputCollectionIds) && inputCollectionIds.length > 0) { + inputCollectionIdList = await getAllCollectionIds({ + parentCollectionIds: inputCollectionIds + }); + if (inputCollectionIdList && inputCollectionIdList.length === 0) { + return []; + } + } + + const collectionIds = computeFilterIntersection([ + tagCollectionIdList, + createTimeCollectionIdList, + inputCollectionIdList + ]); + + return await getAllCollectionIds({ + parentCollectionIds: collectionIds + }); + } catch (error) { + console.error('resolveCollectionFilter error:', error); + } +} + +/** + * 生成 i18n 错误 key(用于前端展示) + */ +function generateI18nErrorKey(errorMessage: string): string { + const lowerError = errorMessage.toLowerCase(); + + if (lowerError.includes('timeout') || lowerError.includes('超时')) { + return 'common:core.dataset.error.Rerank timeout'; + } + if (lowerError.includes('network') || lowerError.includes('网络')) { + return 'common:core.dataset.error.Rerank network error'; + } + if (lowerError.includes('404') || lowerError.includes('not found')) { + return 'common:core.dataset.error.Rerank service not found'; + } + if (lowerError.includes('401') || lowerError.includes('unauthorized')) { + return 'common:core.dataset.error.Rerank unauthorized'; + } + if (lowerError.includes('403') || lowerError.includes('forbidden')) { + return 'common:core.dataset.error.Rerank forbidden'; + } + if (lowerError.includes('500') || lowerError.includes('internal server error')) { + return 'common:core.dataset.error.Rerank internal error'; + } + if (lowerError.includes('502') || lowerError.includes('bad gateway')) { + return 'common:core.dataset.error.Rerank bad gateway'; + } + if (lowerError.includes('503') || lowerError.includes('service unavailable')) { + return 'common:core.dataset.error.Rerank service unavailable'; + } + + return 'common:core.dataset.error.Rerank error'; +} + export async function searchDatasetData( props: SearchDatasetDataProps ): Promise { @@ -178,28 +769,36 @@ export async function searchDatasetData( teamId, reRankQuery, queries, - model, + modelId, similarity = 0, limit: maxTokens, searchMode = DatasetSearchModeEnum.embedding, embeddingWeight = 0.5, usingReRank = false, - rerankModel, + rerankModelId, + rerankMethod, rerankWeight = 0.5, datasetIds = [], - collectionFilterMatch + collectionFilterMatch, + authForbidCollectionIds } = props; - // Constants data - const datasetDataSelectField = - '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes'; - const datsaetCollectionSelectField = - '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; - /* init params */ searchMode = DatasetSearchModeMap[searchMode] ? searchMode : DatasetSearchModeEnum.embedding; usingReRank = usingReRank && !!getDefaultRerankModel(); + // 检索耗时计时 + const retrievalStartTime = Date.now(); + + // 重排错误信息 + let rerankError: + | { + errorMessage: Record; + i18nErrorMessage: string; + i18nErrorMessageData: { modelName: string }; + } + | undefined = undefined; + // Compatible with topk limit let set = new Set(); let usingSimilarityFilter = false; @@ -244,219 +843,10 @@ export async function searchDatasetData( 1. and 先生效 2. and 标签和 null 不能共存,否则返回空数组 */ - const filterCollectionByMetadata = async (): Promise => { - const getAllCollectionIds = async ({ - parentCollectionIds - }: { - parentCollectionIds?: string[]; - }): Promise => { - if (!parentCollectionIds) return; - if (parentCollectionIds.length === 0) { - return []; - } - - const collections = await MongoDatasetCollection.find( - { - teamId, - datasetId: { $in: datasetIds }, - _id: { $in: parentCollectionIds } - }, - '_id type', - { - ...readFromSecondary - } - ).lean(); - - const resultIds = new Set(); - collections.forEach((item) => { - if (item.type !== 'folder') { - resultIds.add(String(item._id)); - } - }); - - const folderIds = collections - .filter((item) => item.type === 'folder') - .map((item) => String(item._id)); - - // Get all child collection ids - if (folderIds.length) { - const childCollections = await MongoDatasetCollection.find( - { - teamId, - datasetId: { $in: datasetIds }, - parentId: { $in: folderIds } - }, - '_id type', - { - ...readFromSecondary - } - ).lean(); - - const childIds = await getAllCollectionIds({ - parentCollectionIds: childCollections.map((item) => String(item._id)) - }); - - childIds?.forEach((id) => resultIds.add(id)); - } - - return Array.from(resultIds); - }; - - if (!collectionFilterMatch || !global.feConfigs.isPlus) return; - let tagCollectionIdList: string[] | undefined = undefined; - let createTimeCollectionIdList: string[] | undefined = undefined; - let inputCollectionIdList: string[] | undefined = undefined; - - try { - const jsonMatch = - typeof collectionFilterMatch === 'object' - ? collectionFilterMatch - : json5.parse(collectionFilterMatch); - - const andTags = jsonMatch?.tags?.$and as (string | null)[] | undefined; - const orTags = jsonMatch?.tags?.$or as (string | null)[] | undefined; - - if (andTags && andTags.length > 0) { - const uniqueAndTags = Array.from(new Set(andTags)); - if (uniqueAndTags.includes(null) && uniqueAndTags.some((tag) => typeof tag === 'string')) { - return []; - } - if (uniqueAndTags.every((tag) => typeof tag === 'string')) { - const matchedTags = await MongoDatasetCollectionTags.find( - { - teamId, - datasetId: { $in: datasetIds }, - tag: { $in: uniqueAndTags as string[] } - }, - '_id datasetId tag', - { ...readFromSecondary } - ).lean(); - - // Group tags by dataset - const datasetTagMap = new Map }>(); - - matchedTags.forEach((tag) => { - const datasetId = String(tag.datasetId); - if (!datasetTagMap.has(datasetId)) { - datasetTagMap.set(datasetId, { - tagIds: [], - tagNames: new Set() - }); - } - - const datasetData = datasetTagMap.get(datasetId)!; - datasetData.tagIds.push(String(tag._id)); - datasetData.tagNames.add(tag.tag); - }); - - const validDatasetIds = Array.from(datasetTagMap.entries()) - .filter(([_, data]) => uniqueAndTags.every((tag) => data.tagNames.has(tag as string))) - .map(([datasetId]) => datasetId); - - if (validDatasetIds.length === 0) return []; - - const collectionsPromises = validDatasetIds.map((datasetId) => { - const { tagIds } = datasetTagMap.get(datasetId)!; - return MongoDatasetCollection.find( - { - teamId, - datasetId, - tags: { $all: tagIds } - }, - '_id', - { ...readFromSecondary } - ).lean(); - }); - - const collectionsResults = await Promise.all(collectionsPromises); - tagCollectionIdList = collectionsResults.flat().map((item) => String(item._id)); - } else if (uniqueAndTags.every((tag) => tag === null)) { - const collections = await MongoDatasetCollection.find( - { - teamId, - datasetId: { $in: datasetIds }, - $or: [{ tags: { $size: 0 } }, { tags: { $exists: false } }] - }, - '_id', - { ...readFromSecondary } - ).lean(); - tagCollectionIdList = collections.map((item) => String(item._id)); - } - } else if (orTags && orTags.length > 0) { - // Get tagId by tag string - const orTagArray = await MongoDatasetCollectionTags.find( - { - teamId, - datasetId: { $in: datasetIds }, - tag: { $in: orTags.filter((tag) => tag !== null) } - }, - '_id', - { ...readFromSecondary } - ).lean(); - const orTagIds = orTagArray.map((item) => String(item._id)); - - // Get collections by tagId - const collections = await MongoDatasetCollection.find( - { - teamId, - datasetId: { $in: datasetIds }, - $or: [ - { tags: { $in: orTagIds } }, - ...(orTags.includes(null) ? [{ tags: { $size: 0 } }] : []) - ] - }, - '_id', - { ...readFromSecondary } - ).lean(); - - tagCollectionIdList = collections.map((item) => String(item._id)); - } - - // time - const getCreateTime = jsonMatch?.createTime?.$gte as string | undefined; - const lteCreateTime = jsonMatch?.createTime?.$lte as string | undefined; - if (getCreateTime || lteCreateTime) { - const collections = await MongoDatasetCollection.find( - { - teamId, - datasetId: { $in: datasetIds }, - createTime: { - ...(getCreateTime && { $gte: new Date(getCreateTime) }), - ...(lteCreateTime && { - $lte: new Date(lteCreateTime) - }) - } - }, - '_id' - ); - createTimeCollectionIdList = collections.map((item) => String(item._id)); - } - - // collectionIds - const inputCollectionIds = jsonMatch?.collectionIds as string[] | undefined; - if (Array.isArray(inputCollectionIds) && inputCollectionIds.length > 0) { - inputCollectionIdList = await getAllCollectionIds({ - parentCollectionIds: inputCollectionIds - }); - if (inputCollectionIdList && inputCollectionIdList.length === 0) { - return []; - } - } - - // Concat tag, time and collectionIds - const collectionIds = computeFilterIntersection([ - tagCollectionIdList, - createTimeCollectionIdList, - inputCollectionIdList - ]); - - return await getAllCollectionIds({ - parentCollectionIds: collectionIds - }); - } catch (error) {} - }; - const embeddingRecall = async ({ + const filterCollectionByMetadata = () => + resolveCollectionFilter({ teamId, datasetIds, collectionFilterMatch }); + const embeddingRecallLocal = async ({ queries, limit, forbidCollectionIdList, @@ -471,14 +861,11 @@ export async function searchDatasetData( tokens: number; }> => { if (limit === 0) { - return { - embeddingRecallResults: [], - tokens: 0 - }; + return { embeddingRecallResults: [], tokens: 0 }; } const { vectors, tokens } = await getVectorsByText({ - model: getEmbeddingModel(model), + model: getEmbeddingModelById(modelId), input: queries, type: 'query' }); @@ -521,7 +908,9 @@ export async function searchDatasetData( res.forEach((item) => { item.indexes.forEach((index) => { - map.set(String(index.dataId), item); + if (indexDataIds.includes(index.dataId)) { + map.set(String(index.dataId), item); + } }); }); @@ -529,9 +918,10 @@ export async function searchDatasetData( }), MongoDatasetCollection.find( { - _id: { $in: collectionIdList } + _id: { $in: collectionIdList }, + deleteTime: null }, - datsaetCollectionSelectField, + datasetCollectionSelectField, { ...readFromSecondary } ) .lean() @@ -553,7 +943,7 @@ export async function searchDatasetData( .map((item, index) => { const collection = collectionMaps.get(String(item.collectionId)); if (!collection) { - logger.warn('Dataset collection not found during recall', { + addLog.warn('Dataset collection not found during recall', { collectionId: item.collectionId, dataId: item.id }); @@ -562,13 +952,16 @@ export async function searchDatasetData( const data = dataMaps.get(String(item.id)); if (!data) { - logger.warn('Dataset data not found during recall', { + addLog.warn('Dataset data not found during recall', { dataId: item.id, collectionId: item.collectionId }); return; } + // 提取该 chunk 的同义词映射 + const synonymMappings = extractChunkSynonyms(data, String(item.id)); + const result: SearchDataResponseItemType = { id: String(data._id), updateTime: data.updateTime, @@ -582,7 +975,9 @@ export async function searchDatasetData( datasetId: String(data.datasetId), collectionId: String(data.collectionId), ...getCollectionSourceData(collection), - score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }] + score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }], + metadata: data.metadata, + synonymMappings: synonymMappings.length > 0 ? synonymMappings : undefined }; return result; @@ -602,39 +997,232 @@ export async function searchDatasetData( }) as SearchDataResponseItemType[] ); }); + return { embeddingRecallResults, tokens }; + }; + + const fullTextRecallLocal = async ({ + queries, + limit, + filterCollectionIdList, + forbidCollectionIdList, + customWords = [] + }: { + queries: string[]; + limit: number; + filterCollectionIdList?: string[]; + forbidCollectionIdList: string[]; + customWords?: string[]; + }): Promise<{ + fullTextRecallResults: SearchDataResponseItemType[][]; + }> => { + if (limit === 0) { + return { fullTextRecallResults: [] }; + } + + // 决策:使用 Milvus BM25 还是 MongoDB? + const useMilvusFullText = (() => { + // 条件 1: 必须是 Milvus 环境(非 PG/OceanBase) + if (!MILVUS_ADDRESS) return false; + if (PG_ADDRESS || OCEANBASE_ADDRESS) return false; + + // 条件 2: Milvus 版本必须 >= 2.6 + if (!milvusVersionManager.supportsFullText()) return false; + + // 条件 3: 特性开关(可选,用于灰度),默认打开 + if (process.env.MILVUS_FULL_TEXT_ENABLED === 'false') return false; + + return true; + })(); + + if (useMilvusFullText) { + return await fullTextRecallFromMilvus({ + queries, + limit, + filterCollectionIdList, + forbidCollectionIdList, + teamId, + datasetIds + }); + } else { + return await fullTextRecallFromMongo({ + queries, + limit, + filterCollectionIdList, + forbidCollectionIdList, + customWords + }); + } + }; + + // Milvus 2.6 BM25 全文检索 + const fullTextRecallFromMilvus = async ({ + queries, + limit, + filterCollectionIdList, + forbidCollectionIdList, + teamId, + datasetIds + }: { + queries: string[]; + limit: number; + filterCollectionIdList?: string[]; + forbidCollectionIdList: string[]; + teamId: string; + datasetIds: string[]; + }): Promise<{ + fullTextRecallResults: SearchDataResponseItemType[][]; + }> => { + const milvusCtrl = new MilvusCtrl(); + + // 并行执行多个查询的 BM25 检索 + const recallResults = await Promise.all( + queries.map(async (query) => { + return await milvusCtrl.fullTextSearch({ + teamId, + datasetIds, + query, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + // 获取所有 id 和 collectionId + const vectorIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.id)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + // 通过向量 ID 查询 MongoDB 获取完整数据 + const [dataMap, collectionMaps] = await Promise.all([ + // 注意:Milvus 返回的 id 是向量 ID,需要通过它查询 MongoDatasetData + // 这里假设向量 ID 和 dataId 的对应关系存在(需要根据实际情况调整) + MongoDatasetData.find( + { + 'indexes.dataId': { $in: vectorIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + // 建立索引 dataId -> 完整数据的映射 + item.indexes?.forEach((idx: any) => { + if (idx.dataId) { + map.set(String(idx.dataId), item); + } + }); + }); + return map; + }), + MongoDatasetCollection.find( + { + _id: { $in: collectionIds.map((id) => new Types.ObjectId(id)) }, + deleteTime: null + }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + res.forEach((item) => { + map.set(String(item._id), item); + }); + return map; + }) + ]); + + // 格式化结果 + const fullTextRecallResults = recallResults.map((queryResults) => { + return queryResults + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return; + } + + const data = dataMap.get(String(item.id)); + if (!data) { + console.log('Data is not found', item); + return; + } + + // 提取该 chunk 的同义词映射 + const synonymMappings = extractChunkSynonyms(data, String(item.id)); + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [ + { + type: SearchScoreTypeEnum.fullText, + value: item.score || 0, + index + } + ], + synonymMappings: synonymMappings.length > 0 ? synonymMappings : undefined + }; + }) + .filter((item) => { + if (!item) return false; + return true; + }) + .map((item, index) => { + return { + ...item, + score: item!.score.map((scoreItem) => ({ ...scoreItem, index })) + }; + }) as SearchDataResponseItemType[]; + }); return { - embeddingRecallResults, - tokens + fullTextRecallResults }; }; - const fullTextRecall = async ({ + + // MongoDB 全文检索 + const fullTextRecallFromMongo = async ({ queries, limit, filterCollectionIdList, - forbidCollectionIdList + forbidCollectionIdList, + customWords = [] }: { queries: string[]; limit: number; filterCollectionIdList?: string[]; forbidCollectionIdList: string[]; + customWords?: string[]; }): Promise<{ fullTextRecallResults: SearchDataResponseItemType[][]; }> => { - if (limit === 0) { - return { - fullTextRecallResults: [] - }; - } - const recallResults = await Promise.all( queries.map(async (query) => { + const jiebaSplitResult = await jiebaSplitWithCustomDict({ text: query, customWords }); return (await MongoDatasetDataText.aggregate( [ { $match: { teamId: new Types.ObjectId(teamId), - $text: { $search: await jiebaSplit({ text: query }) }, + $text: { $search: jiebaSplitResult }, datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) }, ...(filterCollectionIdList ? { @@ -705,9 +1293,10 @@ export async function searchDatasetData( }), MongoDatasetCollection.find( { - _id: { $in: collectionIds } + _id: { $in: collectionIds }, + deleteTime: null }, - datsaetCollectionSelectField, + datasetCollectionSelectField, { ...readFromSecondary } ) .lean() @@ -727,7 +1316,7 @@ export async function searchDatasetData( .map((item, index) => { const collection = collectionMaps.get(String(item.collectionId)); if (!collection) { - logger.warn('Dataset collection not found during full-text recall', { + addLog.warn('Dataset collection not found during full-text recall', { collectionId: item.collectionId, dataId: item.dataId }); @@ -736,7 +1325,7 @@ export async function searchDatasetData( const data = dataMaps.get(String(item.dataId)); if (!data) { - logger.warn('Dataset data not found during full-text recall', { + addLog.warn('Dataset data not found during full-text recall', { dataId: item.dataId, collectionId: item.collectionId }); @@ -755,7 +1344,7 @@ export async function searchDatasetData( imageDescMap: data.imageDescMap }), chunkIndex: data.chunkIndex, - indexes: data.indexes, + metadata: data.metadata, ...getCollectionSourceData(collection), score: [ { @@ -777,11 +1366,9 @@ export async function searchDatasetData( }; }) as SearchDataResponseItemType[]; }); - - return { - fullTextRecallResults - }; + return { fullTextRecallResults }; }; + const multiQueryRecall = async ({ embeddingLimit, fullTextLimit @@ -794,22 +1381,62 @@ export async function searchDatasetData( filterCollectionByMetadata() ]); + // Merge permission-based forbidden collections + const mergedForbidList = authForbidCollectionIds?.length + ? [...new Set([...forbidCollectionIdList, ...authForbidCollectionIds])] + : forbidCollectionIdList; + + const useMilvusHybrid = (() => { + if (!MILVUS_ADDRESS) return false; + if (PG_ADDRESS || OCEANBASE_ADDRESS) return false; + if (!milvusVersionManager.supportsFullText()) return false; + if (process.env.MILVUS_HYBRID_SEARCH_ENABLED === 'true') return true; + return false; + })(); + + if (useMilvusHybrid && searchMode === DatasetSearchModeEnum.mixedRecall) { + const { results: hybridResults, tokens } = await milvusHybridRecall({ + teamId, + datasetIds, + queries, + modelId, + limit: Math.max(embeddingLimit, fullTextLimit), + forbidCollectionIdList: mergedForbidList, + filterCollectionIdList + }); + + const rrfHybridRecall = datasetSearchResultConcat( + hybridResults.map((list) => ({ weight: 1, list })) + ).slice(0, Math.max(embeddingLimit, fullTextLimit)); + + return { + tokens, + embeddingRecallResults: rrfHybridRecall, + fullTextRecallResults: [] + }; + } + const [{ tokens, embeddingRecallResults }, { fullTextRecallResults }] = await Promise.all([ - embeddingRecall({ + embeddingRecallLocal({ queries, limit: embeddingLimit, - forbidCollectionIdList, + forbidCollectionIdList: mergedForbidList, filterCollectionIdList }), - fullTextRecall({ - queries, - limit: fullTextLimit, - filterCollectionIdList, - forbidCollectionIdList - }) + (async () => { + // 获取知识库的全部同义词作为自定义词表 + const synonymWords = await getAllDatasetsSynonymWords(teamId, datasetIds); + return await fullTextRecallLocal({ + queries, + limit: fullTextLimit, + filterCollectionIdList, + forbidCollectionIdList: mergedForbidList, + customWords: synonymWords + }); + })() ]); - // rrf concat + // 第一层 RRF:按 query 维度融合 const rrfEmbRecall = datasetSearchResultConcat( embeddingRecallResults.map((list) => ({ weight: 1, list })) ).slice(0, embeddingLimit); @@ -828,6 +1455,17 @@ export async function searchDatasetData( // count limit const { embeddingLimit, fullTextLimit } = countRecallLimit(); + // 添加检索开始日志 + addLog.info('Non-Assistant Retrieval Start', { + queries, + searchMode, + datasetCount: datasetIds.length, + maxTokens, + usingReRank, + rerankModelId, + rerankMethod + }); + // recall const { embeddingRecallResults, @@ -838,6 +1476,9 @@ export async function searchDatasetData( fullTextLimit }); + // 重排计时 + const rerankStartTime = usingReRank ? Date.now() : undefined; + // ReRank results const { results: reRankResults, inputTokens: reRankInputTokens } = await (async () => { if (!usingReRank) { @@ -852,22 +1493,67 @@ export async function searchDatasetData( fullTextRecallResults.filter((item) => !set.has(item.id)) ); - // remove same q and a data - set = new Set(); - const filterSameDataResults = concatRecallResults.filter((item) => { - // 删除所有的标点符号与空格等,只对文本进行比较 - const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); - if (set.has(str)) return false; - set.add(str); - return true; - }); + // remove same q and a data(使用 capabilities 的 dedupeByContent) + const filterSameDataResults = dedupeByContent(concatRecallResults); + + const rerankModel = getRerankModelById(rerankModelId); try { return await datasetDataReRank({ rerankModel, query: reRankQuery, - data: filterSameDataResults + data: filterSameDataResults, + rerankMethod: rerankMethod ?? RerankMethodEnum.content }); } catch (error) { + addLog.error('Reranker raw error caught', { error, model: rerankModel?.model }); + + let errorMessage: Record = {}; + let errorTextForI18n = ''; + + if (error instanceof Error) { + errorMessage = { type: 'Error', name: error.name, message: error.message }; + errorTextForI18n = error.message; + } else if (typeof error === 'string') { + errorMessage = { type: 'string', message: error }; + errorTextForI18n = error; + } else if (error && typeof error === 'object') { + const errorObj = error as any; + errorMessage = { + type: 'object', + ...(errorObj.message && { message: errorObj.message }), + ...(errorObj.error && { error: errorObj.error }), + ...(errorObj.status && { status: errorObj.status }), + ...(errorObj.statusText && { statusText: errorObj.statusText }), + ...(errorObj.code && { code: errorObj.code }), + ...(errorObj.response && { response: errorObj.response }) + }; + if (errorObj.message) { + errorTextForI18n = errorObj.message; + } else if (errorObj.statusText) { + errorTextForI18n = `${errorObj.statusText}${errorObj.status ? ` (${errorObj.status})` : ''}`; + } else if (typeof errorObj.error === 'string') { + errorTextForI18n = errorObj.error; + } else { + errorTextForI18n = JSON.stringify(errorObj); + } + } else { + errorMessage = { type: 'unknown', value: String(error) }; + errorTextForI18n = String(error); + } + + const i18nErrorMessage = generateI18nErrorKey(errorTextForI18n); + + addLog.error('Reranker error', { + modelId: rerankModel?.id, + errorMessage, + i18nErrorKey: i18nErrorMessage + }); + + rerankError = { + errorMessage, + i18nErrorMessage, + i18nErrorMessageData: { modelName: rerankModel?.name || rerankModel?.model || 'Unknown' } + }; usingReRank = false; return { results: [], @@ -876,10 +1562,24 @@ export async function searchDatasetData( } })(); + // 计算重排耗时 + const rerankTime = + rerankStartTime !== undefined + ? +((Date.now() - rerankStartTime) / 1000).toFixed(2) + : undefined; + const rrfSearchResult = datasetSearchResultConcat([ { weight: embeddingWeight, list: embeddingRecallResults }, { weight: 1 - embeddingWeight, list: fullTextRecallResults } ]); + + // 计算检索耗时(召回阶段结束,进入 reranker 前) + const retrievalTime = +((Date.now() - retrievalStartTime) / 1000).toFixed(2); + + // 构建 retrievalResults(RRF 融合后的中间结果,用于落库) + const retrievalLimit = global.systemEnv?.assistantRetrievalLimit ?? 20; + const retrievalResults = dedupeByContent(rrfSearchResult).slice(0, retrievalLimit); + const rrfConcatResults = (() => { if (reRankResults.length === 0) return rrfSearchResult; if (rerankWeight === 1) return reRankResults; @@ -890,15 +1590,8 @@ export async function searchDatasetData( ]); })(); - // remove same q and a data - set = new Set(); - const filterSameDataResults = rrfConcatResults.filter((item) => { - // 删除所有的标点符号与空格等,只对文本进行比较 - const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); - if (set.has(str)) return false; - set.add(str); - return true; - }); + // remove same q and a data(使用 capabilities 的 dedupeByContent) + const filterSameDataResults = dedupeByContent(rrfConcatResults); // score filter const scoreFilter = (() => { @@ -942,58 +1635,720 @@ export async function searchDatasetData( limit: maxTokens, similarity, usingReRank, - usingSimilarityFilter + usingSimilarityFilter, + retrievalTime, + retrievalResults, + rerankTime, + rerankError }; } export type DefaultSearchDatasetDataProps = SearchDatasetDataProps & { [NodeInputKeyEnum.datasetSearchUsingExtensionQuery]?: boolean; - [NodeInputKeyEnum.datasetSearchExtensionModel]?: string; + [NodeInputKeyEnum.datasetSearchExtensionModelId]?: string; [NodeInputKeyEnum.datasetSearchExtensionBg]?: string; + isAssistant?: boolean; + /** + * 用于同义词检索和问题改写的知识库 ID 列表(通常为节点内所有知识库 ID)。 + * 与 SearchDatasetDataProps.datasetIds(用于向量检索的分组 ID)分开,避免名称冲突。 + * 若不传,则降级为使用 datasetIds(分组 ID)进行同义词检索。 + */ + synonymDatasetIds?: string[]; + appId?: string; // 用于校正数据检索 + faqAnswerMode?: 'quote' | 'llm-summary'; // FAQ 回答模式 + /** dispatch 层预计算的 queryExtension 结果,存在时跳过内部 LLM 调用,避免重复执行 */ + preComputedQueryExtension?: Awaited>; + lang: string; }; export const defaultSearchDatasetData = async ({ datasetSearchUsingExtensionQuery, - datasetSearchExtensionModel, + datasetSearchExtensionModelId, datasetSearchExtensionBg, + isAssistant, + synonymDatasetIds, + appId, + faqAnswerMode, + preComputedQueryExtension, + lang, ...props }: DefaultSearchDatasetDataProps): Promise => { + // 同义词检索使用全部知识库 ID(synonymDatasetIds),若未传则降级为分组 ID(props.datasetIds) + const datasetIds = synonymDatasetIds ?? props.datasetIds; const query = props.queries[0]; const histories = props.histories; - const { searchQueries, reRankQuery, aiExtensionResult } = await datasetSearchQueryExtension({ - query, - llmModel: datasetSearchUsingExtensionQuery - ? getLLMModel(datasetSearchExtensionModel).model - : undefined, - embeddingModel: props.model, - extensionBg: datasetSearchExtensionBg, - histories - }); - - const result = await searchDatasetData({ - ...props, - reRankQuery: reRankQuery, - queries: searchQueries - }); + const { searchQueries, reRankQuery, aiExtensionResult, rewriteTime, queriesForStorage } = + preComputedQueryExtension ?? + (await datasetSearchQueryExtension({ + query, + llmModelId: datasetSearchUsingExtensionQuery + ? datasetSearchExtensionModelId + : undefined, + embeddingModelId: props.modelId, + extensionBg: datasetSearchExtensionBg, + histories, + isAssistant, + teamId: props.teamId, + datasetIds, + lang + })); + + // 新增:检索开始计时(问题改写之后) + const retrievalStartTime = Date.now(); + + // 根据 isAssistant 选择调用哪个函数 + // 向量检索使用分组内的知识库 ID(props.datasetIds),同义词检索已在上方使用 datasetIds(全量 ID) + const result = isAssistant + ? await searchDatasetDataForAssistant({ + ...props, + reRankQuery: reRankQuery, + queries: searchQueries, + datasetIds: props.datasetIds, + retrievalStartTime, // 传递检索开始时间,确保 correction 和 FAQ 检索时间被计入 + lang + }) + : await searchDatasetData({ + ...props, + reRankQuery: reRankQuery, + queries: searchQueries, + datasetIds: props.datasetIds, + lang + }); return { ...result, queryExtensionResult: aiExtensionResult ? { - llmModel: aiExtensionResult.llmModel, + llmModelId: aiExtensionResult.llmModelId, + embeddingModelId: aiExtensionResult.embeddingModelId, inputTokens: aiExtensionResult.inputTokens, outputTokens: aiExtensionResult.outputTokens, - embeddingModel: aiExtensionResult.embeddingModel, embeddingTokens: aiExtensionResult.embeddingTokens, - query: searchQueries.join('\n') + query: queriesForStorage || searchQueries.join('\n'), + synonymRewriteResult: aiExtensionResult.synonymRewriteResult, + rewriteTime } : undefined }; }; export type DeepRagSearchProps = SearchDatasetDataProps & { - [NodeInputKeyEnum.datasetDeepSearchModel]?: string; + [NodeInputKeyEnum.datasetDeepSearchModelId]?: string; [NodeInputKeyEnum.datasetDeepSearchMaxTimes]?: number; [NodeInputKeyEnum.datasetDeepSearchBg]?: string; }; export const deepRagSearch = (data: DeepRagSearchProps) => global.deepRagHandler(data); + +export const SearchDatabaseData = async ( + props: SearchDatabaseDataProps +): Promise => { + let { + histories, + teamId, + modelId, + datasetIds, + queries, + limit = 50, + searchRatio = 0.3, + authForbidCollectionIds + } = props; + const desLimit = Math.floor(limit * (1 - searchRatio)); + try { + const forbidCollections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + forbid: true + }, + '_id' + ); + const vectorModel = getEmbeddingModelById(modelId); + let totalTokens = 0; + const columnDescriptionRecallResList: DatabaseEmbeddingRecallItemType[] = []; + const columnValueRecallResultList: DatabaseEmbeddingRecallItemType[] = []; + + const forbidCollectionIdList = forbidCollections.map((item: any) => String(item._id)); + // Merge permission-based forbidden collections + const mergedForbidList = authForbidCollectionIds?.length + ? [...new Set([...forbidCollectionIdList, ...authForbidCollectionIds])] + : forbidCollectionIdList; + await Promise.all( + queries.map(async (query: string) => { + const { tokens, vectors } = await getVectorsByText({ + model: vectorModel, + input: query, + type: 'query' + }); + + totalTokens += tokens; + const q_vector = vectors[0]; + + const columnDescriptionResults = await columnDescriptionRecall({ + teamId, + datasetIds, + vector: q_vector, + limit: desLimit, + forbidCollectionIdList: mergedForbidList + }); + + const columnValueResults = await columnValueRecall({ + teamId, + datasetIds, + vector: q_vector, + limit: limit - desLimit, + forbidCollectionIdList: mergedForbidList + }); + + columnDescriptionRecallResList.push(...columnDescriptionResults); + columnValueRecallResultList.push(...columnValueResults); + }) + ); + + const schema = await mergeAndGetSchema({ + columnDescriptionRecallResList, + columnValueRecallResultList, + teamId + }); + + addLog.debug(`Database embed recall completed. Found ${Object.keys(schema).length} tables.`); + + return { + schema, + tokens: totalTokens + }; + } catch (error) { + return Promise.reject(i18nT('chat:embedding_model_error')); + } +}; + +const columnDescriptionRecall = async ({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList +}: { + teamId: string; + datasetIds: string[]; + vector: number[]; + limit: number; + forbidCollectionIdList: string[]; +}): Promise => { + try { + // Use universal database embedding recall interface + const { results } = await databaseEmbeddingRecall({ + teamId, + datasetIds, + vector, + limit, + tableName: DBDatasetVectorTableName, + forbidCollectionIdList + }); + addLog.debug( + 'Column description recall results:', + results.map((r) => r.columnDesIndex) + ); + return results; + } catch (error) { + addLog.error('Column description recall error', error); + return []; + } +}; + +const columnValueRecall = async ({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList +}: { + teamId: string; + datasetIds: string[]; + vector: number[]; + limit: number; + forbidCollectionIdList: string[]; +}): Promise => { + try { + // Use universal database embedding recall interface + const { results } = await databaseEmbeddingRecall({ + teamId, + datasetIds, + vector, + limit, + tableName: DBDatasetValueVectorTableName, + forbidCollectionIdList + }); + + addLog.debug( + 'Column value recall results:', + results.map((r) => r.columnValIndex) + ); + return results; + } catch (error) { + addLog.error('Column value recall error', error); + return []; + } +}; + +// merge results and get schema +const mergeAndGetSchema = async ({ + columnDescriptionRecallResList, + columnValueRecallResultList, + teamId +}: { + columnDescriptionRecallResList: DatabaseEmbeddingRecallItemType[]; + columnValueRecallResultList: DatabaseEmbeddingRecallItemType[]; + teamId: string; +}) => { + const schema: Record< + string, + { collectionId: string; datasetId: string; score: number; retrieval_columns: string[] } + > = {}; + // Group results by collectionId directly (avoid creating intermediate array) + const resultsByCollection = new Map< + string, + Array + >(); + + // Process column description results + for (const result of columnDescriptionRecallResList) { + if (result.collectionId) { + if (!resultsByCollection.has(result.collectionId)) + resultsByCollection.set(result.collectionId, []); + resultsByCollection.get(result.collectionId)!.push({ ...result, type: 'description' }); + } + } + + // Process column value results + for (const result of columnValueRecallResultList) { + if (result.collectionId) { + if (!resultsByCollection.has(result.collectionId)) + resultsByCollection.set(result.collectionId, []); + resultsByCollection.get(result.collectionId)!.push({ ...result, type: 'value' }); + } + } + + // Batch fetch all collections with table schema + const collections = await MongoDatasetCollection.find({ + _id: { $in: Array.from(resultsByCollection.keys()) }, + teamId, + deleteTime: null + }) + .select('_id datasetId name tableSchema') + .lean(); + const collectionMap = new Map(collections.map((coll) => [String(coll._id), coll])); + // Process each collection once with all its results + for (const [collectionId, results] of resultsByCollection) { + const collection = collectionMap.get(collectionId); + if (!collection || !collection.name || !results) continue; + + const tableName = collection.name; + const primaryKeys = collection.tableSchema?.primaryKeys || []; + const foreignKyes = + collection.tableSchema?.foreignKeys.map((fk: ForeignKeySchemaType) => fk.column) || []; + + // Collect all unique columns from all results for this collection + const allRetrievedColumns = new Set([...primaryKeys, ...foreignKyes]); + + // Find the best score among all results for this collection (optimized) + let bestResult = results[0]; + for (let i = 1; i < results.length; i++) { + if (results[i].score > bestResult.score) { + bestResult = results[i]; + } + } + + schema[tableName] = { + collectionId: String(collectionId), + datasetId: String(collection.datasetId), + score: bestResult.score, + retrieval_columns: [] + }; + + // Add retrieved columns from all results in one pass + for (const result of results) { + const retrievedColumn = + result.type === 'description' ? result.columnDesIndex : result.columnValIndex; + if (retrievedColumn) { + allRetrievedColumns.add(retrievedColumn.split('')[1]); + } + } + + // Update retrieval_columns with all unique columns + schema[tableName].retrieval_columns = Array.from(allRetrievedColumns); + } + return schema; +}; + +// Generate SQL and execute query (Schema Embedding + SQL Generation mode) +export const generateAndExecuteSQL = async ({ + datasetId, + query, + schema, + teamId, + limit, + generate_sql_llm, + evaluate_sql_llm +}: { + datasetId: string; + query: string; + schema: Record< + string, + { collectionId: string; datasetId: string; score: number; retrieval_columns: string[] } + >; + teamId: string; + limit?: number; + generate_sql_llm: { model: string; api_key?: string; base_url?: string }; + evaluate_sql_llm: { model: string; api_key?: string; base_url?: string }; + externalProvider?: { + openaiAccount?: { + key: string; + baseUrl: string; + }; + }; +}): Promise => { + // Handle default limit value + const resultLimit = limit ?? getDatasetSqlResultLimit(); + + // Get dataset and database config + const dataset = await MongoDataset.findById(datasetId).lean(); + if (!dataset?.databaseConfig) { + addLog.warn('No database config found for dataset', { datasetId }); + return Promise.reject(DatabaseErrEnum.dbConfigNotFound); + } + + const dbConfig: DatabaseConfig = dataset.databaseConfig; + + // Get table schema from collections + const tableNames = Object.keys(schema); + if (tableNames.length === 0) { + addLog.warn('No tables found in schema'); + return null; + } + + // Get all table schemas from MongoDB collections + const collections = await MongoDatasetCollection.find({ + datasetId, + name: { $in: tableNames }, + teamId, + deleteTime: null + }).lean(); + + // Collections Changes during Sql Generation + if (!collections || collections.length === 0) { + addLog.warn('No collections found for tables', { tableNames }); + return Promise.reject(`${tableNames} not found or has been deleted`); + } + + // Build table schemas for Dative + const retrievedMetadata = collections.map((collection) => { + const columns: Record = {}; + if (collection.tableSchema?.columns) { + (Object.values(collection.tableSchema.columns) as ColumnSchemaType[]).forEach((col) => { + if (schema[collection.name]?.retrieval_columns.includes(col.columnName)) { + columns[col.columnName] = { + name: col.columnName, + type: col.columnType, + comment: col.description, + auto_increment: col.isAutoIncrement || false, + nullable: col.isNullable || false, + default: col.defaultValue || null, + examples: col.examples || [], + enabled: !col.forbid, + value_index: col.valueIndex || false + }; + } + }); + } + + const foreign_keys: DativeForeignKey[] = + collection.tableSchema?.foreignKeys?.map((fk: ForeignKeySchemaType) => ({ + name: fk.name, + column: fk.column, + referenced_schema: fk.referredSchema, + referenced_table: fk.referredTable, + referenced_column: fk.referredColumns + })) || []; + const table: DativeTable = { + name: collection.name, + ns_name: '', + comment: collection.tableSchema?.description || '', + columns, + primary_keys: collection.tableSchema?.primaryKeys || [], + foreign_keys: foreign_keys, + enable: !collection.forbid, + score: schema[collection.name]?.score || 0 + }; + return table; + }); + addLog.debug( + `[generateAndExecuteSQL] retrieved_metadata: ${JSON.stringify(retrievedMetadata).length}` + ); + if (retrievedMetadata.length === 0) { + addLog.warn('No valid table schemas found'); + return null; + } + + // Sort by score (highest first) for better SQL generation + retrievedMetadata.sort((a, b) => b.score - a.score); + + // Update request payload to include all table schemas + const requestPayload: SqlGenerationRequest = { + source_config: { + type: dbConfig.clientType, + host: dbConfig.host, + port: dbConfig.port || 3306, + username: dbConfig.user, + password: dbConfig.password, + db_name: dbConfig.database, + ns_name: dbConfig?.schema, + encrypt: dbConfig?.encrypt, + // Oracle specific - 使用 database 字段作为 serviceName + ...(dbConfig.clientType === DatabaseTypeEnum.oracle && { + serviceName: dbConfig.database + }) + } as DativeSourceConfigType, + generate_sql_llm, + evaluate_sql_llm, + query, + result_num_limit: resultLimit, + retrieved_metadata: { + name: dbConfig.database, // DatabaseName + comments: '', + tables: retrievedMetadata + } + }; + + try { + const result = await queryByNL(requestPayload); + return result; + } catch (error: any) { + addLog.error('Error calling Dative queryByNL', error); + return Promise.reject(DatabaseErrEnum.dativeServiceError); + } +}; + +/** + * 搜索 FAQ 数据 + * 仅检索 trainingType='template' 的 collection 数据 + * @returns 返回相似度最高且 a 不为空的 FAQ 数据(如果满足阈值要求) + */ +export async function searchFAQData({ + teamId, + datasetIds, + queryVector, + embeddingTokens, + authForbidCollectionIds, + collectionFilterMatch +}: { + teamId: string; + datasetIds: string[]; + queryVector: number[]; + embeddingTokens: number; + authForbidCollectionIds?: string[]; + collectionFilterMatch?: string; +}): Promise<(SearchDataResponseItemType & { embeddingTokens: number }) | null> { + const startTime = Date.now(); + const faqThreshold = global.systemEnv?.faqSimilarityThreshold ?? 0.95; + addLog.debug('Assistant Config - faqSimilarityThreshold', { faqThreshold }); + + addLog.debug('FAQ Search - Starting', { + teamId, + datasetIds, + embeddingTokens + }); + + try { + // 1. 获取所有 FAQ collection (trainingType='template') + const faqCollections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + trainingType: DatasetCollectionDataProcessModeEnum.template, + forbid: { $ne: true }, + deleteTime: null + }, + '_id' + ).lean(); + + if (faqCollections.length === 0) { + addLog.debug('FAQ Search - No FAQ collections found', { + teamId, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + const faqCollectionIds = faqCollections.map((c) => String(c._id)); + + // 排除权限禁止的 collection + const authForbiddenSet = new Set(authForbidCollectionIds ?? []); + const allowedFaqCollectionIds = faqCollectionIds.filter((id) => !authForbiddenSet.has(id)); + + if (allowedFaqCollectionIds.length === 0) { + addLog.debug('FAQ Search - All FAQ collections forbidden by auth', { + teamId, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + // 标签过滤:resolveCollectionFilter 整合 tag/createTime/collectionIds 三维过滤 + const filterWhitelist = await resolveCollectionFilter({ + teamId, + datasetIds, + collectionFilterMatch + }); + + // FAQ collections ∩ tag-filtered collections + const effectiveFaqCollectionIds = filterWhitelist + ? allowedFaqCollectionIds.filter((id) => filterWhitelist.includes(id)) + : allowedFaqCollectionIds; + + if (effectiveFaqCollectionIds.length === 0) { + addLog.debug('FAQ Search - No FAQ collections after tag filter', { + teamId, + tagFilteredCount: filterWhitelist?.length ?? 0, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + // 2. 全局向量检索(不传 filterCollectionIdList),取 top 100 + // 方案A:全局搜索 + MongoDB 后过滤 + // 原因:FAQ 阈值极高(0.95),真正命中的 FAQ 向量在全局排名中几乎必然位居前列; + // 不使用 filterCollectionIdList 可避免 HNSW 稀疏预过滤导致的 60s 超时问题, + // 无论 FAQ 向量在总量中占多大比例,全局搜索的耗时都是稳定可控的。 + const recallResult = await recallFromVectorStore({ + teamId, + datasetIds, + vector: queryVector, + limit: 100, + forbidCollectionIdList: authForbidCollectionIds ?? [], + ...(filterWhitelist ? { filterCollectionIdList: effectiveFaqCollectionIds } : {}) + // 不传 filterCollectionIdList,即不限制 collection(传 [] 会被向量存储当作"过滤到空集",直接返回空结果) + }); + + if (!recallResult.results || recallResult.results.length === 0) { + addLog.debug('FAQ Search - No recall results', { + teamId, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + // 3. 先过滤相似度 >= 阈值的结果 + const filteredByThreshold = recallResult.results.filter((r) => (r.score || 0) >= faqThreshold); + + if (filteredByThreshold.length === 0) { + addLog.debug('FAQ Search - No results above threshold', { + teamId, + threshold: faqThreshold, + topScore: recallResult.results[0]?.score || 0, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + // 4. 查询 MongoDB 获取完整数据 + const thresholdDataIds = new Set(filteredByThreshold.map((r) => r.id)); + const dataDocs = await MongoDatasetData.find( + { + teamId, + 'indexes.dataId': { $in: Array.from(thresholdDataIds) }, + collectionId: { $in: Array.from(effectiveFaqCollectionIds) }, // 后过滤:只保留 FAQ collection 的数据(排除权限禁止的 & 标签过滤的) + a: { $exists: true, $ne: '' } + }, + '_id datasetId collectionId updateTime q a chunkIndex indexes' + ) + .lean() + .exec(); + + if (dataDocs.length === 0) { + addLog.debug('FAQ Search - No data documents found', { + teamId, + thresholdResultsCount: filteredByThreshold.length, + duration: `${Date.now() - startTime}ms` + }); + return null; + } + + // 6. 构建 dataId -> dataDoc 的映射 + const dataIdToDocMap = new Map(); + for (const doc of dataDocs) { + for (const index of doc.indexes) { + if (thresholdDataIds.has(index.dataId)) { + dataIdToDocMap.set(index.dataId, doc); + } + } + } + + // 7. 获取最高相似度分数,处理多个相同分数的情况 + const topScore = filteredByThreshold[0].score || 0; + + // 找出所有相似度等于最高分的结果 + const topScoreResults = filteredByThreshold.filter((r) => r.score === topScore); + + // 将结果与数据文档关联,并按更新时间排序 + const matchedDocs = topScoreResults + .map((result) => { + const dataDoc = dataIdToDocMap.get(result.id); + return dataDoc ? { result, dataDoc } : null; + }) + .filter((item): item is NonNullable => item !== null) + // 按更新时间降序排序,取最新的 + .sort( + (a, b) => + new Date(b.dataDoc.updateTime).getTime() - new Date(a.dataDoc.updateTime).getTime() + ); + + if (matchedDocs.length === 0) { + return null; + } + + // 取更新时间最新的 FAQ 数据 + const { result: topResult, dataDoc } = matchedDocs[0]; + const similarity = topResult.score || 0; + + // 单独查询 collection(只查一次) + const collection = await MongoDatasetCollection.findById(dataDoc.collectionId, 'name').lean(); + + const faqData: SearchDataResponseItemType & { embeddingTokens: number } = { + id: String(dataDoc._id), + updateTime: dataDoc.updateTime, + q: dataDoc.q, + a: dataDoc.a || '', + chunkIndex: dataDoc.chunkIndex, + datasetId: String(dataDoc.datasetId), + collectionId: String(dataDoc.collectionId), + sourceName: collection?.name || 'FAQ', + sourceId: String(dataDoc._id), + score: [ + { + type: SearchScoreTypeEnum.embedding, + value: similarity, + index: 0 + } + ], + embeddingTokens: embeddingTokens + }; + + addLog.debug('FAQ Search - Match found', { + teamId, + dataId: faqData.id, + similarity, + question: dataDoc.q, + updateTime: dataDoc.updateTime, + matchedCount: matchedDocs.length, + duration: `${Date.now() - startTime}ms` + }); + + return faqData; + } catch (error) { + addLog.error('FAQ Search - Error occurred', { + error, + duration: `${Date.now() - startTime}ms` + }); + return null; + } +} diff --git a/packages/service/core/dataset/search/customController.ts b/packages/service/core/dataset/search/customController.ts new file mode 100644 index 000000000000..dc072ac16072 --- /dev/null +++ b/packages/service/core/dataset/search/customController.ts @@ -0,0 +1,930 @@ +/** + * Assistant 专属检索逻辑 + * + * 此文件包含针对 Assistant 场景优化的检索函数 + * 主要改进: + * 1. 修正检索结果与 Reranker 输入数据的一致性问题 + * 2. 解决 Reranker 分数丢失问题 + * 3. 优化的时间统计和结果限制逻辑 + */ + +import { + DatasetSearchModeEnum, + DatasetSearchModeMap, + SearchScoreTypeEnum, + RerankMethodEnum +} from '@fastgpt/global/core/dataset/constants'; +import { recallFromVectorStore } from '../../../common/vectorDB/controller'; +import { getVectorsByText } from '../../ai/embedding'; +import { getEmbeddingModelById, getDefaultRerankModel, getRerankModelById } from '../../ai/model'; +import { MongoDatasetData } from '../data/schema'; +import type { + DatasetCollectionSchemaType, + DatasetDataSchemaType +} from '@fastgpt/global/core/dataset/type'; +import { + type DatasetDataTextSchemaType, + type SearchDataResponseItemType +} from '@fastgpt/global/core/dataset/type'; +import { MongoDatasetCollection } from '../collection/schema'; +import { datasetSearchResultConcat } from '@fastgpt/global/core/dataset/search/utils'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { jiebaSplit } from '../../../common/string/jieba/index'; +import { getCollectionSourceData } from '@fastgpt/global/core/dataset/collection/utils'; +import { Types } from '../../../common/mongo'; +import json5 from 'json5'; +import { MongoDatasetCollectionTags } from '../tag/schema'; +import { readFromSecondary } from '../../../common/mongo/utils'; +import { MongoDatasetDataText } from '../data/dataTextSchema'; +import { formatDatasetDataValue } from '../data/controller'; +import { addLog } from '../../../common/system/log'; +import type { SearchDatasetDataProps, SearchDatasetDataResponse } from './controller'; +import { resolveCollectionFilter } from './controller'; + +// Import exported functions from controller +import { + datasetDataReRank as reRankFunction, + filterDatasetDataByMaxTokens as filterFunction, + filterCollectionByNewTagFormat +} from './controller'; + +// Import assistant-specific reranker +import { assistantDatasetReRank } from './assistantReranker'; + +/** + * 生成 i18n 错误 key(用于前端展示) + */ +function generateI18nErrorKey(errorMessage: string): string { + // 检查常见错误类型并返回对应的 i18n key + const lowerError = errorMessage.toLowerCase(); + + if (lowerError.includes('timeout') || lowerError.includes('超时')) { + return 'common:core.dataset.error.Rerank timeout'; + } + if (lowerError.includes('network') || lowerError.includes('网络')) { + return 'common:core.dataset.error.Rerank network error'; + } + if (lowerError.includes('404') || lowerError.includes('not found')) { + return 'common:core.dataset.error.Rerank service not found'; + } + if (lowerError.includes('401') || lowerError.includes('unauthorized')) { + return 'common:core.dataset.error.Rerank unauthorized'; + } + if (lowerError.includes('403') || lowerError.includes('forbidden')) { + return 'common:core.dataset.error.Rerank forbidden'; + } + if (lowerError.includes('500') || lowerError.includes('internal server error')) { + return 'common:core.dataset.error.Rerank internal error'; + } + if (lowerError.includes('502') || lowerError.includes('bad gateway')) { + return 'common:core.dataset.error.Rerank bad gateway'; + } + if (lowerError.includes('503') || lowerError.includes('service unavailable')) { + return 'common:core.dataset.error.Rerank service unavailable'; + } + + // 默认通用错误 + return 'common:core.dataset.error.Rerank error'; +} + +/** + * Assistant 专属知识库搜索函数 + * + * 与通用 searchDatasetData 的主要区别: + * 1. 统计时间:retrievalTime(召回阶段)、rerankTime(重排阶段) + * 2. 保存中间结果:retrievalResults(RRF 融合后的 top N) + * 3. 限制结果数量:进入 reranker 前限制、最终结果限制 + * 4. 修正检索流程:先 RRF 融合排序 → 记录 retrievalResults → 去重后送 reranker + * + * @param props 检索参数(与 searchDatasetData 相同) + * @returns 检索结果(包含 retrievalTime、rerankTime、retrievalResults) + */ +export async function searchDatasetDataForAssistant( + props: SearchDatasetDataProps & { retrievalStartTime?: number } +): Promise { + // 【步骤 1】检索开始计时 + // 如果传入了 retrievalStartTime(来自 defaultSearchDatasetData),使用传入的时间 + // 这样可以将 correction 和 FAQ 检索的耗时也计入 retrievalTime + const retrievalStartTime = props.retrievalStartTime ?? Date.now(); + + let { + teamId, + reRankQuery, + queries, + modelId, + similarity = 0, + limit: maxTokens, + searchMode = DatasetSearchModeEnum.embedding, + embeddingWeight = 0.5, + usingReRank = false, + rerankModelId, + rerankMethod, + rerankWeight = 0.5, + datasetIds = [], + collectionFilterMatch, + authForbidCollectionIds + } = props; + + // Constants data + const datasetDataSelectField = + '_id datasetId collectionId updateTime q a imageId imageDescMap chunkIndex indexes metadata'; + const datasetCollectionSelectField = + '_id name fileId rawLink apiFileId externalFileId externalFileUrl'; + + /* init params */ + searchMode = DatasetSearchModeMap[searchMode] ? searchMode : DatasetSearchModeEnum.embedding; + usingReRank = usingReRank && !!getDefaultRerankModel(); + + let set = new Set(); + let usingSimilarityFilter = false; + + /* function */ + const countRecallLimit = () => { + if (searchMode === DatasetSearchModeEnum.embedding) { + return { + embeddingLimit: 100, + fullTextLimit: 0 + }; + } + if (searchMode === DatasetSearchModeEnum.fullTextRecall) { + return { + embeddingLimit: 0, + fullTextLimit: 100 + }; + } + return { + embeddingLimit: 80, + fullTextLimit: 60 + }; + }; + + const getForbidData = async () => { + const collections = await MongoDatasetCollection.find( + { + teamId, + datasetId: { $in: datasetIds }, + forbid: true + }, + '_id' + ); + + return { + forbidCollectionIdList: collections.map((item) => String(item._id)) + }; + }; + + const filterCollectionByMetadata = () => + resolveCollectionFilter({ teamId, datasetIds, collectionFilterMatch }); + + const embeddingRecall = async ({ + queries, + limit, + forbidCollectionIdList, + filterCollectionIdList + }: { + queries: string[]; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; + }): Promise<{ + embeddingRecallResults: SearchDataResponseItemType[][]; + tokens: number; + }> => { + if (limit === 0) { + return { + embeddingRecallResults: [], + tokens: 0 + }; + } + + const { vectors, tokens } = await getVectorsByText({ + model: getEmbeddingModelById(modelId), + input: queries, + type: 'query' + }); + + const recallResults = await Promise.all( + vectors.map(async (vector) => { + return await recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + const collectionIdList = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.collectionId)).flat()) + ); + const indexDataIds = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.id?.trim())).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIdList }, + 'indexes.dataId': { $in: indexDataIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + item.indexes.forEach((index) => { + if (indexDataIds.includes(index.dataId)) { + map.set(String(index.dataId), item); + } + }); + }); + + return map; + }), + MongoDatasetCollection.find( + { + _id: { $in: collectionIdList }, + deleteTime: null + }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + map.set(String(item._id), item); + }); + + return map; + }) + ]); + + const embeddingRecallResults = recallResults.map((item) => { + const set = new Set(); + return item.results + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return; + } + + const data = dataMaps.get(String(item.id)); + if (!data) { + console.log('Data is not found', item); + return; + } + + const result: SearchDataResponseItemType = { + id: String(data._id), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }], + metadata: data.metadata + }; + + return result; + }) + .filter((item) => { + if (!item) return false; + if (set.has(item.id)) return false; + set.add(item.id); + return true; + }) + .map((item, index) => { + return { + ...item!, + score: item!.score.map((item) => ({ ...item, index })) + }; + }) as SearchDataResponseItemType[]; + }); + + return { + embeddingRecallResults, + tokens + }; + }; + + const fullTextRecall = async ({ + queries, + limit, + filterCollectionIdList, + forbidCollectionIdList + }: { + queries: string[]; + limit: number; + filterCollectionIdList?: string[]; + forbidCollectionIdList: string[]; + }): Promise<{ + fullTextRecallResults: SearchDataResponseItemType[][]; + }> => { + if (limit === 0) { + return { + fullTextRecallResults: [] + }; + } + + const recallResults = await Promise.all( + queries.map(async (query) => { + return (await MongoDatasetDataText.aggregate( + [ + { + $match: { + teamId: new Types.ObjectId(teamId), + $text: { $search: await jiebaSplit({ text: query }) }, + datasetId: { $in: datasetIds.map((id) => new Types.ObjectId(id)) }, + ...(filterCollectionIdList + ? { + collectionId: { + $in: filterCollectionIdList + .filter((id) => !forbidCollectionIdList.includes(id)) + .map((id) => new Types.ObjectId(id)) + } + } + : forbidCollectionIdList?.length + ? { + collectionId: { + $nin: forbidCollectionIdList.map((id) => new Types.ObjectId(id)) + } + } + : {}) + } + }, + { + $sort: { + score: { $meta: 'textScore' } + } + }, + { + $limit: limit + }, + { + $project: { + _id: 1, + collectionId: 1, + dataId: 1, + score: { $meta: 'textScore' } + } + } + ], + { + ...readFromSecondary + } + )) as (DatasetDataTextSchemaType & { score: number })[]; + }) + ); + + const dataIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.dataId)).flat()) + ); + const collectionIds = Array.from( + new Set(recallResults.map((item) => item.map((item) => item.collectionId)).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + _id: { $in: dataIds } + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + map.set(String(item._id), item); + }); + + return map; + }), + MongoDatasetCollection.find( + { + _id: { $in: collectionIds }, + deleteTime: null + }, + datasetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + map.set(String(item._id), item); + }); + + return map; + }) + ]); + + const fullTextRecallResults = recallResults.map((item) => { + return item + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + console.log('Collection is not found', item); + return; + } + + const data = dataMaps.get(String(item.dataId)); + if (!data) { + console.log('Data is not found', item); + return; + } + + return { + id: String(data._id), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + metadata: data.metadata, + ...getCollectionSourceData(collection), + score: [ + { + type: SearchScoreTypeEnum.fullText, + value: item.score || 0, + index + } + ] + }; + }) + .filter((item) => { + if (!item) return false; + return true; + }) + .map((item, index) => { + return { + ...item, + score: item!.score.map((item) => ({ ...item, index })) + }; + }) as SearchDataResponseItemType[]; + }); + + return { + fullTextRecallResults + }; + }; + + const multiQueryRecall = async ({ + embeddingLimit, + fullTextLimit + }: { + embeddingLimit: number; + fullTextLimit: number; + }) => { + const [{ forbidCollectionIdList }, filterCollectionIdList] = await Promise.all([ + getForbidData(), + filterCollectionByMetadata() + ]); + + // Merge permission-based forbidden collections + const mergedForbidList = authForbidCollectionIds?.length + ? [...new Set([...forbidCollectionIdList, ...authForbidCollectionIds])] + : forbidCollectionIdList; + + const [{ tokens, embeddingRecallResults }, { fullTextRecallResults }] = await Promise.all([ + embeddingRecall({ + queries, + limit: embeddingLimit, + forbidCollectionIdList: mergedForbidList, + filterCollectionIdList + }), + fullTextRecall({ + queries, + limit: fullTextLimit, + filterCollectionIdList, + forbidCollectionIdList: mergedForbidList + }) + ]); + + // rrf concat (multi-query fusion) + const rrfEmbRecall = datasetSearchResultConcat( + embeddingRecallResults.map((list) => ({ weight: 1, list })) + ).slice(0, embeddingLimit); + const rrfFTRecall = datasetSearchResultConcat( + fullTextRecallResults.map((list) => ({ weight: 1, list })) + ).slice(0, fullTextLimit); + + // 打印召回统计日志 + addLog.debug('Assistant Recall Statistics', { + embeddingIndexLimit: embeddingLimit, + fullTextIndexLimit: fullTextLimit, + embeddingChunkCount: rrfEmbRecall.length, + fullTextChunkCount: rrfFTRecall.length + }); + + return { + tokens, + embeddingRecallResults: rrfEmbRecall, + fullTextRecallResults: rrfFTRecall + }; + }; + + /* 【步骤 2】主流程:召回 */ + // 添加检索开始日志 + addLog.debug('Assistant Retrieval Start', { + queries, + searchMode, + datasetCount: datasetIds.length, + maxTokens, + usingReRank, + rerankModelId, + rerankMethod + }); + + const { embeddingLimit, fullTextLimit } = countRecallLimit(); + + const { + embeddingRecallResults, + fullTextRecallResults, + tokens: embeddingTokens + } = await multiQueryRecall({ + embeddingLimit, + fullTextLimit + }); + + // 【步骤 3】计算检索耗时(召回阶段结束) + const retrievalTime = +((Date.now() - retrievalStartTime) / 1000).toFixed(2); + + // 【步骤 4】Embedding + FullText RRF 融合(修正:先RRF,再送reranker) + const embWeight = embeddingWeight; // 向量索引的 weight 大小 + const fullTextWeight = 1 - embeddingWeight; // 全文索引的 weight 大小 + + const rrfSearchResult = datasetSearchResultConcat([ + { weight: embWeight, list: embeddingRecallResults }, + { weight: fullTextWeight, list: fullTextRecallResults } + ]); + + // 【步骤 5】先去重,再取 top N + // 去重(基于文本内容)- 这是进入 reranker 前的去重 + set = new Set(); + const dedupedRrfResults = rrfSearchResult.filter((item) => { + const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); + if (set.has(str)) return false; + set.add(str); + return true; + }); + + // 【步骤 6】构建分数映射表 + const embeddingScoreMap = new Map(); + embeddingRecallResults.forEach((item) => { + const embScore = item.score.find((s) => s.type === SearchScoreTypeEnum.embedding); + if (embScore) { + embeddingScoreMap.set(item.id, embScore.value); + } + }); + + const fullTextScoreMap = new Map(); + fullTextRecallResults.forEach((item) => { + const ftScore = item.score.find((s) => s.type === SearchScoreTypeEnum.fullText); + if (ftScore) { + fullTextScoreMap.set(item.id, ftScore.value); + } + }); + + // 【步骤 6.5】构建检索排名映射表(进入 reranker 前的排名) + const retrievalRankMap = new Map(); + dedupedRrfResults.forEach((item, index) => { + retrievalRankMap.set(item.id, index); // 排名从 0 开始,与其他 score 的 index 保持一致 + }); + + // 【步骤 7】重排开始计时 + const rerankStartTime = usingReRank ? Date.now() : undefined; + + // 【步骤 8】将完整的去重结果送进 Reranker(不截断) + let rerankError: + | { + errorMessage: Record; + i18nErrorMessage: string; + i18nErrorMessageData: { modelName: string }; + } + | undefined = undefined; + const { results: reRankResults, inputTokens: reRankInputTokens } = await (async () => { + if (!usingReRank) { + return { + results: [], + inputTokens: 0 + }; + } + + const rerankModel = getRerankModelById(rerankModelId); + try { + return await assistantDatasetReRank({ + rerankModel, + query: reRankQuery, + data: dedupedRrfResults, + rerankMethod: rerankMethod ?? RerankMethodEnum.content + // rerankStrategy 使用默认值 RerankStrategyEnum.maxScore + }); + } catch (error) { + addLog.error('Reranker raw error caught', { error, modelId: rerankModel?.id }); + + // 构建结构化的错误对象 + let errorMessage: Record = {}; + let errorTextForI18n = ''; + + if (error instanceof Error) { + errorMessage = { + type: 'Error', + name: error.name, + message: error.message + }; + errorTextForI18n = error.message; + } else if (typeof error === 'string') { + errorMessage = { + type: 'string', + message: error + }; + errorTextForI18n = error; + } else if (error && typeof error === 'object') { + const errorObj = error as any; + errorMessage = { + type: 'object', + ...(errorObj.message && { message: errorObj.message }), + ...(errorObj.error && { error: errorObj.error }), + ...(errorObj.status && { status: errorObj.status }), + ...(errorObj.statusText && { statusText: errorObj.statusText }), + ...(errorObj.code && { code: errorObj.code }), + ...(errorObj.response && { response: errorObj.response }) + }; + + // 提取用于 i18n 匹配的文本 + if (errorObj.message) { + errorTextForI18n = errorObj.message; + } else if (errorObj.statusText) { + errorTextForI18n = `${errorObj.statusText}${errorObj.status ? ` (${errorObj.status})` : ''}`; + } else if (typeof errorObj.error === 'string') { + errorTextForI18n = errorObj.error; + } else { + errorTextForI18n = JSON.stringify(errorObj); + } + } else { + errorMessage = { + type: 'unknown', + value: String(error) + }; + errorTextForI18n = String(error); + } + + // 生成 i18n 错误 key + const i18nErrorMessage = generateI18nErrorKey(errorTextForI18n); + + // 记录完整的原始错误信息用于调试 + addLog.error('Reranker error - Full error details', error); + + addLog.error('Reranker error', { + model: rerankModel?.model, + errorMessage, + i18nErrorKey: i18nErrorMessage + }); + + // 记录 reranker 错误信息 + rerankError = { + errorMessage, + i18nErrorMessage, + i18nErrorMessageData: { modelName: rerankModel?.name || rerankModel?.model || 'Unknown' } + }; + + usingReRank = false; + return { + results: [], + inputTokens: 0 + }; + } + })(); + + // 【步骤 9】RRF 融合:dedupedRrfResults + reRankResults + const rrfConcatResults = (() => { + if (reRankResults.length === 0) return dedupedRrfResults; + if (rerankWeight === 1) return reRankResults; + + const searchWeight = 1 - rerankWeight; // 搜索结果的 weight 大小 + + return datasetSearchResultConcat([ + { weight: searchWeight, list: dedupedRrfResults }, + { weight: rerankWeight, list: reRankResults } + ]); + })(); + + // 【步骤 10】去重 + set = new Set(); + const filterSameDataResults = rrfConcatResults.filter((item) => { + const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); + if (set.has(str)) return false; + set.add(str); + return true; + }); + + // 【步骤 11】相似度过滤 + const scoreFilter = (() => { + if (usingReRank) { + usingSimilarityFilter = true; + + const filtered: typeof filterSameDataResults = []; + const discarded: Array<{ id: string; collectionId: string; score: number }> = []; + + filterSameDataResults.forEach((item) => { + const reRankScore = item.score.find((item) => item.type === SearchScoreTypeEnum.reRank); + if (reRankScore && reRankScore.value < similarity) { + discarded.push({ + id: item.id, + collectionId: item.collectionId, + score: reRankScore.value + }); + } else { + filtered.push(item); + } + }); + + if (discarded.length > 0) { + addLog.debug('Similarity filter - Discarded by reRank threshold', { + threshold: similarity, + discardedCount: discarded.length, + discardedItems: discarded + }); + } + + return filtered; + } + if (searchMode === DatasetSearchModeEnum.embedding) { + usingSimilarityFilter = true; + + const filtered: typeof filterSameDataResults = []; + const discarded: Array<{ id: string; collectionId: string; score: number }> = []; + + filterSameDataResults.forEach((item) => { + const embeddingScore = item.score.find( + (item) => item.type === SearchScoreTypeEnum.embedding + ); + if (embeddingScore && embeddingScore.value < similarity) { + discarded.push({ + id: item.id, + collectionId: item.collectionId, + score: embeddingScore.value + }); + } else { + filtered.push(item); + } + }); + + if (discarded.length > 0) { + addLog.debug('Similarity filter - Discarded by embedding threshold', { + threshold: similarity, + discardedCount: discarded.length, + discardedItems: discarded + }); + } + + return filtered; + } + return filterSameDataResults; + })(); + + // 【步骤 12】Token 过滤 + const filterMaxTokensResult = await filterFunction(scoreFilter, maxTokens); + + // 【步骤 13】限制最终结果数量 + const finalResultLimit = global.systemEnv?.assistantFinalResultLimit ?? 10; + const finalResults = + filterMaxTokensResult.length > finalResultLimit + ? filterMaxTokensResult.slice(0, finalResultLimit) + : filterMaxTokensResult; + + // 【步骤 14】确保最终结果包含所有必需的分数 + // 为每个结果补充完整的分数信息(embedding、fullText、reRank、rrf) + const finalResultsWithAllScores = finalResults.map((item, finalIndex) => { + const scores: { type: SearchScoreTypeEnum; value: number; index: number }[] = []; + + // 1. 添加 embedding 分数(如果存在) + const embScore = embeddingScoreMap.get(item.id); + if (embScore !== undefined) { + const embIndex = embeddingRecallResults.findIndex((r) => r.id === item.id); + scores.push({ + type: SearchScoreTypeEnum.embedding, + value: embScore, + index: embIndex >= 0 ? embIndex : finalIndex + }); + } + + // 2. 添加 fullText 分数(如果存在) + const ftScore = fullTextScoreMap.get(item.id); + if (ftScore !== undefined) { + const ftIndex = fullTextRecallResults.findIndex((r) => r.id === item.id); + scores.push({ + type: SearchScoreTypeEnum.fullText, + value: ftScore, + index: ftIndex >= 0 ? ftIndex : finalIndex + }); + } + + // 3. 添加 reRank 分数(如果存在) + const reRankScore = item.score.find((s) => s.type === SearchScoreTypeEnum.reRank); + if (reRankScore) { + scores.push(reRankScore); + } + + // 4. 添加 RRF 分数(检索+重排融合后的分数) + const rrfScore = item.score.find((s) => s.type === SearchScoreTypeEnum.rrf); + if (rrfScore) { + scores.push({ + type: SearchScoreTypeEnum.rrf, + value: rrfScore.value, + index: finalIndex + }); + } + + return { + ...item, + score: scores, + retrievalRank: retrievalRankMap.get(item.id) // 添加检索排名 + }; + }); + + // 【步骤 15】构建 retrievalResults(用于落库,取 top retrievalLimit) + const retrievalLimit = global.systemEnv?.assistantRetrievalLimit ?? 20; + + const retrievalResults = dedupedRrfResults.slice(0, retrievalLimit).map((item, index) => { + const scores: SearchDataResponseItemType['score'] = []; + + // 添加 embedding 分数(如果存在) + const embScore = embeddingScoreMap.get(item.id); + if (embScore !== undefined) { + scores.push({ + type: SearchScoreTypeEnum.embedding, + value: embScore, + index + }); + } + + // 添加 fullText 分数(如果存在) + const ftScore = fullTextScoreMap.get(item.id); + if (ftScore !== undefined) { + scores.push({ + type: SearchScoreTypeEnum.fullText, + value: ftScore, + index + }); + } + + // 添加 RRF 分数(从 item.score 中获取) + const rrfScore = item.score.find((s) => s.type === SearchScoreTypeEnum.rrf); + if (rrfScore) { + scores.push({ + type: SearchScoreTypeEnum.rrf, + value: rrfScore.value, + index + }); + } + + return { + ...item, + score: scores + }; + }); + + // 【步骤 16】重排结束计时 + const rerankTime = + rerankStartTime !== undefined ? +((Date.now() - rerankStartTime) / 1000).toFixed(2) : undefined; + + return { + searchRes: finalResultsWithAllScores, + embeddingTokens, + reRankInputTokens, + searchMode, + limit: maxTokens, + similarity, + usingReRank, + usingSimilarityFilter, + retrievalTime, // 检索耗时(召回阶段) + rerankTime, // 重排耗时(重排阶段) + retrievalResults, // 检索结果(RRF 融合后的 top N,用于落库) + rerankError // Reranker 错误信息(如果有) + }; +} diff --git a/packages/service/core/dataset/search/providers/agenticAdapter.ts b/packages/service/core/dataset/search/providers/agenticAdapter.ts new file mode 100644 index 000000000000..eec57b7d50e3 --- /dev/null +++ b/packages/service/core/dataset/search/providers/agenticAdapter.ts @@ -0,0 +1,72 @@ +/** + * agenticAdapter.ts + * 将 diting-rag-ts 的 ChunkItem[] 转换为 FastGPT 的 SearchDataResponseItemType[] + * 所有 FastGPT 平台特有字段从 providerMetadata 中读取 + */ + +import type { ChunkItem } from 'diting-rag-ts'; +import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; + +type FastGPTChunkMeta = { + q?: string; + a?: string; + collectionId?: string; + sourceId?: string; + documentId?: string; + fileId?: string; + imageId?: string; + imageDescMap?: Record; + originalScoreArray?: Array<{ + type: 'rrf' | 'embedding' | 'fullText' | 'reRank'; + value: number; + index: number; + }>; + chunkIndex?: number; + updateTime?: Date; +}; + +/** + * 将 diting-rag-ts 最终检索结果转换为 FastGPT 格式 + * - 从 providerMetadata 读取 q/a/sourceId/imageId 等平台字段 + * - 重建 score 数组,更新 index 为最终排名,重算 rrf value + */ +export function chunkItemsToSearchResults(chunks: ChunkItem[]): SearchDataResponseItemType[] { + return chunks.map((chunk, index) => { + const meta = (chunk.providerMetadata ?? {}) as FastGPTChunkMeta; + + // 使用原始 score 数组;rrf value 按 agentic 最终位置重算 + const score = meta.originalScoreArray + ? meta.originalScoreArray.map((s) => ({ + ...s, + index, + value: s.type === 'rrf' ? 1 / (60 + index + 1) : s.value + })) + : []; + // 追加 chunk 实际的相关性分数(reranker 优先,否则向量分),前端展示用 + if (chunk.rerankScore !== undefined) { + score.push({ type: 'reRank' as const, value: chunk.rerankScore, index }); + } + if (chunk.score !== undefined) { + score.push({ type: 'embedding' as const, value: chunk.score, index }); + } + if (score.length === 0) { + score.push({ type: 'rrf' as const, value: 1 / (60 + index + 1), index }); + } + + return { + id: chunk.id, + q: meta.q ?? chunk.content, + a: meta.a ?? '', + datasetId: chunk.datasetId, + collectionId: meta.collectionId ?? chunk.collectionId ?? '', + sourceName: chunk.sourceName, + sourceId: meta.sourceId, + score, + metadata: chunk.metadata, + updateTime: meta.updateTime ?? new Date(), + chunkIndex: meta.chunkIndex ?? 0, + imageId: meta.imageId, + retrievalRank: index + } as SearchDataResponseItemType; + }); +} diff --git a/packages/service/core/dataset/search/providers/fastgptProviders.ts b/packages/service/core/dataset/search/providers/fastgptProviders.ts new file mode 100644 index 000000000000..75e2eb4f53a6 --- /dev/null +++ b/packages/service/core/dataset/search/providers/fastgptProviders.ts @@ -0,0 +1,779 @@ +/** + * FastGPT Providers for diting-rag-ts + * 封装 FastGPT 现有的 LLM/Embedding/VectorSearch/FullTextSearch/MixedSearch/Rerank 能力 + * 使用 capabilities.ts 中的公共能力,不依赖完整的 controller 逻辑 + * diting-rag-ts 负责 agentic search 的完整逻辑,包括问题分析、问题改写/拆解、检索、重排、过滤等。 + * fastgpt 只需要提供各provider能力,处理好输入输出适配即可。 + */ + +import { getVectorsByText } from '../../../ai/embedding'; +import type { EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { createChatCompletion } from '../../../ai/config'; +import { getLLMModelById, getEmbeddingModelById, getRerankModelById } from '../../../ai/model'; +import type { LLMModelItemType, RerankModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import type { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, + UnStreamResponseType +} from '@fastgpt/global/core/ai/llm/type'; +import { reRankRecall } from '../../../../core/ai/rerank'; +import { addLog } from '../../../../common/system/log'; +import { parseReasoningContent } from '../../../ai/utils'; +import { stripThinkBlocks } from 'diting-rag-ts'; + +// 导入公共能力(从 capabilities.ts) +import { + embeddingRecall, + fullTextRecall, + mixedRecall, + rerank as capabilitiesRerank +} from '../capabilities'; +import type { + LLMProvider, + EmbeddingProvider, + VectorSearchProvider, + FullTextSearchProvider, + MixedSearchProvider, + RerankProvider, + SearchResult, + ChunkResult, + RerankResult, + EmbedResult, + LLMResponse, + LLMCallOptions, + LLMMessage, + MixedSearchOptions, + FullTextSearchOptions, + VectorSearchOptions +} from 'diting-rag-ts'; + +export interface FastGPTProvidersConfig { + llmModelId: string; + embedModelId: string; + rerankModelId?: string; + teamId: string; +} + +/** + * 检测响应是否被截断(括号、引号不平衡) + */ +function detectTruncation(content: string): boolean { + let braceDepth = 0; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const c = content[i]; + if (escaped) { + escaped = false; + continue; + } + if (c === '\\') { + escaped = true; + continue; + } + if (c === '"' && !escaped) { + inString = !inString; + continue; + } + if (inString) continue; + + if (c === '{') braceDepth++; + else if (c === '}') { + braceDepth--; + if (braceDepth < 0) return true; + } + } + if (inString) return true; + if (braceDepth !== 0) return true; + return false; +} + +/** + * 创建 FastGPT LLM Provider + * + * 使用 createChatCompletion 替代直接调用 ai.chat.completions.create, + * 复用模型名解析、requestUrl、requestAuth、超时等已有逻辑。 + * + * 对齐 builtin adapter: + * - 不主动传 max_tokens,避免某些模型因 max_tokens 过小而截断 JSON 输出 + * - 增加截断检测与自动重试(max_tokens 翻倍) + */ +export function createFastGPTLLMProvider(config: FastGPTProvidersConfig): LLMProvider { + return { + async chat(messages: LLMMessage[], options?: LLMCallOptions): Promise { + const modelData = getLLMModelById(config.llmModelId); + + const body: Record = { + model: modelData.model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature, + // 仅当调用方显式传了 maxTokens 时才发送,避免部分模型兼容问题 + ...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}), + stream: false, + ...options?.extra + }; + + // 添加 tools 和 tool_choice(agentic search function calling 依赖此参数) + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + // 过滤 undefined/null + const filteredBody = Object.fromEntries( + Object.entries(body).filter(([_, v]) => v !== null && v !== undefined) + ); + + const result = await createChatCompletion({ + modelData: modelData, + body: filteredBody as unknown as ChatCompletionCreateParamsNonStreaming, + options: options?.requestOptions as any + }); + + // createChatCompletion 返回 union 类型,根据 stream:false 收窄 + if (result.isStreamResponse) { + throw new Error('Unexpected stream response for non-streaming request'); + } + + const response = result.response; + const choice = response.choices[0]; + const msg = choice?.message; + const rawContent = msg?.content || ''; + const finishReason = choice?.finish_reason; + const reasoningContent = (msg as any)?.reasoning_content || (msg as any)?.reasoning; + + // 处理 thinking 内容:API 已返回 reasoning_content 则直接用; + // 否则若模型是 reasoning 类型,从 content 中解析 标签 + let content = rawContent; + let reasoning: string | undefined = reasoningContent || undefined; + if (modelData?.reasoning && !reasoningContent && rawContent) { + const [think, answer] = parseReasoningContent(rawContent); + content = answer; + reasoning = think || undefined; + } + + // 截断检测与自动重试(对齐 builtin adapter) + const truncated = + finishReason === 'length' || (finishReason !== 'stop' && detectTruncation(content)); + + if (truncated && options?.maxTokens) { + addLog.warn('[FastGPTLLMProvider] response truncated, retrying with 2x maxTokens', { + llmModelId: config.llmModelId, + maxTokens: options.maxTokens, + finishReason, + contentPreview: content.slice(0, 200) + }); + + const retryBody = { + ...filteredBody, + max_tokens: options.maxTokens * 2 + }; + const retryResult = await createChatCompletion({ + modelData: modelData, + body: retryBody as unknown as ChatCompletionCreateParamsNonStreaming, + options: options?.requestOptions as any + }); + + if (!retryResult.isStreamResponse) { + const retryResponse = retryResult.response; + const retryChoice = retryResponse.choices[0]; + const retryMsg = retryChoice?.message; + const retryRawContent = retryMsg?.content || ''; + const retryReasoningContent = + (retryMsg as any)?.reasoning_content || (retryMsg as any)?.reasoning; + + let retryContent = retryRawContent; + let retryReasoning: string | undefined = retryReasoningContent || undefined; + if (modelData?.reasoning && !retryReasoningContent && retryRawContent) { + const [think, answer] = parseReasoningContent(retryRawContent); + retryContent = answer; + retryReasoning = think || undefined; + } + + return { + content: retryContent, + reasoning: retryReasoning, + toolCalls: (retryMsg?.tool_calls ?? msg?.tool_calls ?? []).map((tc: any) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments } + })), + usage: { + inputTokens: retryResponse.usage?.prompt_tokens ?? response.usage?.prompt_tokens, + outputTokens: + retryResponse.usage?.completion_tokens ?? response.usage?.completion_tokens + } + }; + } + } + + return { + content, + reasoning, + toolCalls: (msg?.tool_calls ?? []).map((tc: any) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function.name, arguments: tc.function.arguments } + })), + usage: { + inputTokens: response.usage?.prompt_tokens, + outputTokens: response.usage?.completion_tokens + } + }; + }, + + async *chatStream( + messages: LLMMessage[], + options?: LLMCallOptions + ): AsyncIterable { + const modelData = getLLMModelById(config.llmModelId); + + const body: Record = { + model: modelData.model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + temperature: options?.temperature, + ...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}), + stream: true, + ...options?.extra + }; + + // 添加 tools 和 tool_choice(agentic search function calling 依赖此参数) + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = options.toolChoice ?? 'auto'; + } + + const filteredBody = Object.fromEntries( + Object.entries(body).filter(([_, v]) => v !== null && v !== undefined) + ); + + const result = await createChatCompletion({ + modelData: modelData, + body: filteredBody as unknown as ChatCompletionCreateParamsStreaming, + options: options?.requestOptions as any + }); + + // createChatCompletion 返回 union 类型,根据 stream:true 收窄 + if (!result.isStreamResponse) { + throw new Error('Unexpected non-stream response for streaming request'); + } + + const response = result.response; + + // 缓冲所有 chunk(不立即 yield),截断检测后再决定输出 + type StreamChunk = { + content: string; + reasoning?: string; + toolCalls?: Array<{ + id: string; + type: 'function'; + function: { name: any; arguments: any }; + }>; + }; + const chunkBuffer: StreamChunk[] = []; + let accumulatedContent = ''; + let accumulatedReasoning = ''; + let streamFinishReason: string | undefined; + + for await (const chunk of response) { + const delta = chunk.choices[0]?.delta; + const choice = chunk.choices[0]; + if (choice?.finish_reason) streamFinishReason = choice.finish_reason; + if (!delta) continue; + + const rawContent = delta.content || ''; + const reasoningContent = (delta as any)?.reasoning_content || (delta as any)?.reasoning; + + let content = rawContent; + let reasoning: string | undefined = reasoningContent || undefined; + if (modelData?.reasoning && !reasoningContent && rawContent) { + const [think, answer] = parseReasoningContent(rawContent); + content = answer; + reasoning = think || undefined; + } + + if (content) accumulatedContent += content; + if (reasoning) accumulatedReasoning += reasoning; + + if (content || reasoning || delta.tool_calls?.length) { + chunkBuffer.push({ + content, + reasoning, + toolCalls: (delta.tool_calls ?? []).map((tc: any) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function?.name, arguments: tc.function?.arguments } + })) + }); + } + } + + // 截断检测 + const truncated = + streamFinishReason === 'length' || + (streamFinishReason !== 'stop' && detectTruncation(accumulatedContent)); + + if (truncated && options?.maxTokens) { + addLog.warn('[FastGPTLLMProvider] stream truncated, retrying with 2x maxTokens', { + llmModelId: config.llmModelId, + maxTokens: options.maxTokens, + finishReason: streamFinishReason, + contentPreview: accumulatedContent.slice(0, 200) + }); + + // 丢弃缓冲(截断内容),用 2x maxTokens 重试 + const retryBody = { + ...filteredBody, + max_tokens: options.maxTokens * 2, + stream: false + }; + const retryResult = await createChatCompletion({ + modelData: modelData, + body: retryBody as unknown as ChatCompletionCreateParamsNonStreaming, + options: options?.requestOptions as any + }); + + if (!retryResult.isStreamResponse) { + const retryResponse = retryResult.response; + const retryChoice = retryResponse.choices[0]; + const retryMsg = retryChoice?.message; + const retryRawContent = retryMsg?.content || ''; + const retryReasoningContent = + (retryMsg as any)?.reasoning_content || (retryMsg as any)?.reasoning; + + let retryContent = retryRawContent; + let retryReasoning: string | undefined = retryReasoningContent || undefined; + if (modelData?.reasoning && !retryReasoningContent && retryRawContent) { + const [think, answer] = parseReasoningContent(retryRawContent); + retryContent = answer; + retryReasoning = think || undefined; + } + + yield { + content: retryContent, + reasoning: retryReasoning, + toolCalls: (retryMsg?.tool_calls ?? []).map((tc: any) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.function?.name, arguments: tc.function?.arguments } + })), + usage: { + inputTokens: retryResponse.usage?.prompt_tokens, + outputTokens: retryResponse.usage?.completion_tokens + } + }; + } + } else { + // 未截断:yield 缓冲的 chunk + for (const c of chunkBuffer) { + yield c; + } + } + }, + + getModelInfo(): { name: string; contextWindow: number; maxOutputTokens: number } { + const model = getLLMModelById(config.llmModelId); + return { + name: model?.model || config.llmModelId, + contextWindow: (model as any)?.contextSize || 16000, + maxOutputTokens: (model as any)?.maxTokens || 8192 + }; + } + }; +} + +/** + * 创建 FastGPT Embedding Provider + */ +export function createFastGPTEmbeddingProvider(config: FastGPTProvidersConfig): EmbeddingProvider { + return { + async embed(texts: string[]): Promise { + const model = getEmbeddingModelById(config.embedModelId); + const result = await getVectorsByText({ + model: model as EmbeddingModelItemType, + input: texts + }); + + return { + vectors: result.vectors, + tokens: result.tokens + }; + }, + + getModelInfo(): { name: string; dimension: number } { + const model = getEmbeddingModelById(config.embedModelId); + return { + name: model?.model || config.embedModelId, + dimension: model?.dimensions || 1536 + }; + } + }; +} + +/** + * 创建 FastGPT Vector Search Provider + */ +export function createFastGPTVectorSearchProvider( + config: FastGPTProvidersConfig +): VectorSearchProvider { + return { + async search( + vectors: number[][], + datasetIds: string[], + options: VectorSearchOptions + ): Promise> { + const startTime = Date.now(); + + try { + // 使用 embeddingRecall 而非直接调用 recallFromVectorStore, + // 确保返回的 chunk id 是 MongoDB _id(而非向量行号), + // 避免 getQuote 等下游接口用行号查 dataset_datas 报 CastError + const { results } = await embeddingRecall({ + teamId: config.teamId, + datasetIds, + queries: [], // Provider 场景由外部传入 vectors,precomputedVectors 已提供 + modelId: config.embedModelId, + limit: options.limit || 10, + forbidCollectionIdList: options.filter?.forbidCollectionIds || [], + precomputedVectors: vectors + }); + + const chunks: ChunkResult[] = results.map((r) => ({ + id: r.id, // 已是 MongoDB _id + content: r.q && r.a ? `${r.q}\n${r.a}` : r.q || r.a || '', + score: r.score?.[0]?.value || 0, + datasetId: r.datasetId || '', + sourceName: r.sourceName || '', + collectionId: r.collectionId, + metadata: r.metadata || {}, + detectedLanguage: r.metadata?.detectedLanguage, + vectorScore: r.score?.[0]?.value || 0, + searchSource: 'vector', + providerMetadata: { + updateTime: (r as any).updateTime, + chunkIndex: (r as any).chunkIndex ?? 0, + originalScoreArray: r.score, + q: r.q, + a: r.a, + sourceId: r.sourceId + } + })); + + return { + chunks, + meta: { + searchSource: 'vector', + provider: 'fastgpt', + duration: Date.now() - startTime + } + }; + } catch (error) { + addLog.error('[FastGPTVectorSearch] Search failed', { error }); + return { + chunks: [], + error: { + code: 'VECTOR_SEARCH_ERROR', + message: (error as Error).message, + retryable: true + }, + meta: { + searchSource: 'vector', + provider: 'fastgpt', + duration: Date.now() - startTime + } + }; + } + } + }; +} + +/** + * 创建 FastGPT FullText Search Provider + * 使用 capabilities.ts 中的 fullTextRecall 实现全文检索 + */ +export function createFastGPTFullTextSearchProvider( + config: FastGPTProvidersConfig +): FullTextSearchProvider { + return { + async search( + query: string, + datasetIds: string[], + options: FullTextSearchOptions + ): Promise> { + const startTime = Date.now(); + + try { + // 使用 capabilities 中的 fullTextRecall + const result = await fullTextRecall({ + teamId: config.teamId, + modelId: config.embedModelId, + datasetIds, + queries: [query], + limit: options.limit || 10, + forbidCollectionIdList: options.filter?.forbidCollectionIds || [] + }); + + const chunks: ChunkResult[] = result.results.map((r: any) => { + const rawScore = Array.isArray(r.score) + ? r.score[0]?.value ?? 0 + : typeof r.score === 'number' + ? r.score + : 0; + return { + id: r.id || '', + content: r.q && r.a ? `${r.q}\n${r.a}` : r.q || r.a || '', + score: rawScore, + datasetId: r.datasetId || '', + sourceName: r.sourceName || '', + collectionId: r.collectionId, + metadata: r.metadata || {}, + detectedLanguage: r.metadata?.detectedLanguage, + fullTextScore: rawScore, + searchSource: 'fulltext', + providerMetadata: { + updateTime: r.updateTime, + chunkIndex: r.chunkIndex ?? 0, + originalScoreArray: Array.isArray(r.score) + ? r.score + : [{ type: 'fullText', value: rawScore, index: 0 }], + q: r.q, + a: r.a, + sourceId: r.sourceId, + documentId: r.documentId, + fileId: r.fileId, + imageId: r.imageId + } + }; + }); + + return { + chunks, + meta: { + searchSource: 'fulltext', + provider: 'fastgpt', + duration: Date.now() - startTime + } + }; + } catch (error) { + addLog.error('[FastGPTFullTextSearch] Search failed', { error }); + return { + chunks: [], + error: { + code: 'FULLTEXT_SEARCH_ERROR', + message: (error as Error).message, + retryable: true + }, + meta: { + searchSource: 'fulltext', + provider: 'fastgpt', + duration: Date.now() - startTime + } + }; + } + } + }; +} + +/** + * 创建 FastGPT Rerank Provider + */ +export function createFastGPTRerankProvider(config: FastGPTProvidersConfig): RerankProvider { + return { + async rerank(query: string, chunks: ChunkResult[]): Promise { + try { + const documents = chunks.map((c) => ({ + id: c.id, + text: c.content + })); + + let rerankModelData: RerankModelItemType | undefined; + rerankModelData = getRerankModelById(config.rerankModelId); + + if (!rerankModelData) { + // c.score is already a proper number after our provider fix; use vectorScore as best signal + return chunks.map((c) => ({ + ...c, + rerankScore: typeof c.score === 'number' ? c.score : c.vectorScore ?? 0 + })); + } + + const result = await reRankRecall({ + query, + documents, + model: rerankModelData + }); + + const rerankMap = new Map(result.results.map((r: any) => [r.id, r.score])); + return chunks.map((c) => ({ + ...c, + rerankScore: + rerankMap.get(c.id) ?? (typeof c.score === 'number' ? c.score : c.vectorScore ?? 0) + })); + } catch (error) { + addLog.error('[FastGPRerank] Failed', { error }); + return chunks.map((c) => ({ ...c, rerankScore: c.score })); + } + } + }; +} + +/** + * 创建 FastGPT Mixed Search Provider + * 使用 capabilities.ts 中的 mixedRecall 实现混合检索 + */ +export function createFastGPTMixedSearchProvider( + config: FastGPTProvidersConfig +): MixedSearchProvider { + return { + async search( + query: string, + datasetIds: string[], + options: MixedSearchOptions, + _embeddingOrVectors: { vectors?: number[][]; embeddingProvider?: EmbeddingProvider } + ): Promise> { + const startTime = Date.now(); + + try { + addLog.info( + `[MixedSearchProvider] query="${query}", datasetIds=${JSON.stringify(datasetIds)}, limit=${options.limit || 10}` + ); + + // 使用 capabilities 中的 mixedRecall + const result = await mixedRecall({ + teamId: config.teamId, + datasetIds, + queries: [query], + modelId: config.embedModelId, + limit: options.limit || 10, + embeddingWeight: options.vectorWeight ?? 0.5, + usingReRank: false, + similarity: 0, + rerankWeight: 0.5, + forbidCollectionIdList: options.filter?.forbidCollectionIds || [] + }); + + addLog.info(`[MixedSearchProvider] mixedRecall done: results=${result.results.length}`); + + // 先收集所有 chunk 的原始分数,再统一处理 + type RawChunkInfo = { + r: any; + vectorScore: number | undefined; + reRankScore: number | undefined; + rrfScore: number | undefined; + fullTextScore: number | undefined; + }; + const rawChunkInfos: RawChunkInfo[] = result.results.map((r: any) => { + const scoreArr = Array.isArray(r.score) ? r.score : []; + return { + r, + reRankScore: scoreArr.find((s: any) => s.type === 'reRank')?.value as + | number + | undefined, + vectorScore: scoreArr.find((s: any) => s.type === 'embedding')?.value as + | number + | undefined, + rrfScore: scoreArr.find((s: any) => s.type === 'rrf')?.value as number | undefined, + fullTextScore: scoreArr.find((s: any) => s.type === 'fullText')?.value as + | number + | undefined + }; + }); + + // 判断是否有语义相似度分数可用(embedding 或 reRank) + const hasSemanticScore = rawChunkInfos.some( + (item) => item.vectorScore !== undefined || item.reRankScore !== undefined + ); + + // 如果只有 RRF 分数(如 Milvus hybrid search 返回的 rrf 类型),做归一化 + const maxRrfScore = hasSemanticScore + ? 1 + : Math.max(...rawChunkInfos.map((item) => item.rrfScore ?? 0), 1e-6); + + const chunks: ChunkResult[] = rawChunkInfos.map( + ({ r, vectorScore, reRankScore, rrfScore, fullTextScore }) => { + let numericScore: number; + if (reRankScore !== undefined) { + numericScore = reRankScore; // 重排分数最优先 + } else if (vectorScore !== undefined) { + numericScore = vectorScore; // 向量余弦相似度(0-1) + } else if (rrfScore !== undefined) { + numericScore = hasSemanticScore ? rrfScore : rrfScore / maxRrfScore; // 归一化 RRF + } else { + numericScore = 0; + } + return { + id: r.id || r._id?.toString() || '', + content: r.q && r.a ? `${r.q}\n${r.a}` : r.q || r.a || '', + score: numericScore, + datasetId: r.datasetId || '', + sourceName: r.sourceName || '', + collectionId: r.collectionId, + metadata: r.metadata || {}, + detectedLanguage: r.metadata?.detectedLanguage, + searchSource: 'mixed', + vectorScore, + fullTextScore, + // 透传 FastGPT 平台所需的额外字段 + providerMetadata: { + updateTime: r.updateTime, + chunkIndex: r.chunkIndex ?? 0, + imageDescMap: r.imageDescMap, + originalScoreArray: Array.isArray(r.score) ? r.score : undefined, + q: r.q, + a: r.a, + sourceId: r.sourceId, + documentId: r.documentId, + fileId: r.fileId, + imageId: r.imageId + } + }; + } + ); + + return { + chunks, + meta: { + searchSource: 'mixed', + provider: 'fastgpt', + duration: Date.now() - startTime, + totalTokens: result.tokens, // 传递 embedding token 数 + queryAnalytics: { + originalQuery: query + } + } + }; + } catch (error) { + addLog.error('[FastGPTMixedSearch] Search failed', { error }); + return { + chunks: [], + error: { + code: 'MIXED_SEARCH_ERROR', + message: (error as Error).message, + retryable: true + }, + meta: { + searchSource: 'mixed', + provider: 'fastgpt', + duration: Date.now() - startTime + } + }; + } + } + }; +} + +/** + * 创建所有 FastGPT Providers + * 注意:字段名与 diting-rag-ts 的 AgenticSearchProviders 接口对齐 + */ +export function createFastGPTProviders(config: FastGPTProvidersConfig) { + return { + llm: createFastGPTLLMProvider(config), + embed: createFastGPTEmbeddingProvider(config), + vectorSearch: createFastGPTVectorSearchProvider(config), + fullTextSearch: createFastGPTFullTextSearchProvider(config), + mixedSearch: createFastGPTMixedSearchProvider(config), + reranker: createFastGPTRerankProvider(config) + }; +} diff --git a/packages/service/core/dataset/search/synonym.ts b/packages/service/core/dataset/search/synonym.ts new file mode 100644 index 000000000000..e344e33c8d2e --- /dev/null +++ b/packages/service/core/dataset/search/synonym.ts @@ -0,0 +1,199 @@ +import type { SynonymMappingForPrompt } from '@fastgpt/global/core/ai/type'; +import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; +import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; +import { MongoDatasetSynonymMapping } from '../synonym/mappingSchema'; +import { addLog } from '../../../common/system/log'; + +/** + * 从 chunk 的所有 default index 的 synonymMetadata.transformations 中提取同义词映射。 + * 直接读内存中已有的转换记录,无需额外数据库查询。 + * @param data 数据集数据项 + * @param chunkId chunk的ID(暂未使用,保留供日志追踪) + * @returns 同义词映射列表(仅包含该 chunk 实际发生过替换的词对) + */ +export const extractChunkSynonyms = ( + data: DatasetDataSchemaType, + chunkId: string +): SynonymMappingForPrompt[] => { + // 取所有 default 类型的 index(一个 chunk 可能有多个 default index,如 q 和 a 各一个) + const defaultIndexes = + data.indexes?.filter((idx) => idx.type === DatasetDataIndexTypeEnum.default) ?? []; + addLog.debug('extractChunkSynonyms - defaultIndexes count:', { count: defaultIndexes.length }); + + // 收集所有 default index 的 transformations + const allTransformations = defaultIndexes.flatMap( + (idx) => idx.synonymMetadata?.transformations ?? [] + ); + + if (allTransformations.length === 0) { + addLog.debug('extractChunkSynonyms - No transformations found'); + return []; + } + addLog.debug('extractChunkSynonyms - allTransformations', { allTransformations }); + + // 按 standardizedTerm 分组,将同一标准词的多个 originalTerm 合并为 synonymTerms + const termMap = new Map>(); + for (const t of allTransformations) { + if (!t.standardizedTerm || !t.originalTerm) continue; + if (!termMap.has(t.standardizedTerm)) { + termMap.set(t.standardizedTerm, new Set()); + } + termMap.get(t.standardizedTerm)!.add(t.originalTerm); + } + + const synonymMappings: SynonymMappingForPrompt[] = []; + termMap.forEach((synonymSet, standardizedTerm) => { + synonymMappings.push({ + standardizedTerm, + synonymTerms: Array.from(synonymSet), + source: 'chunk', + chunkId + }); + }); + + addLog.debug('extractChunkSynonyms - Final synonymMappings', { synonymMappings }); + return synonymMappings; +}; + +/** + * 从query中提取同义词映射 + * @param query 查询文本 + * @param datasetIds 数据集ID列表 + * @returns 同义词映射列表 + */ +export const extractQuerySynonyms = async ( + query: string, + datasetIds: string[] +): Promise => { + const synonymMappings: SynonymMappingForPrompt[] = []; + + if (!query || datasetIds.length === 0) { + return synonymMappings; + } + + try { + // 查询所有相关的同义词映射 + const mappings = await MongoDatasetSynonymMapping.find( + { + datasetId: { $in: datasetIds } + }, + 'standardizedTerm synonymTerms' + ) + .lean() + .limit(100); + + // 检查query中是否包含标准词或同义词 + mappings.forEach((mapping) => { + const allTerms = [mapping.standardizedTerm, ...(mapping.synonymTerms || [])]; + + // 检查query中是否包含任何一个词 + const isMatched = allTerms.some((term) => query.includes(term)); + + if (isMatched && mapping.synonymTerms && mapping.synonymTerms.length > 0) { + synonymMappings.push({ + standardizedTerm: mapping.standardizedTerm, + synonymTerms: mapping.synonymTerms, + source: 'query' + }); + } + }); + } catch (error) { + addLog.debug('Error extracting query synonyms', { error }); + } + + return synonymMappings; +}; + +/** + * 合并并去重同义词映射 + * @param mappings 同义词映射列表 + * @returns 合并后的同义词映射列表 + */ +export const mergeSynonymMappings = ( + mappings: SynonymMappingForPrompt[] +): SynonymMappingForPrompt[] => { + if (!mappings || mappings.length === 0) { + return []; + } + + const mergedMap = new Map< + string, + { synonymSet: Set; source: 'query' | 'chunk'; chunkId?: string } + >(); + + // 第一次遍历:收集所有同义词并记录source信息 + mappings.forEach((item) => { + const key = item.standardizedTerm; + if (!mergedMap.has(key)) { + mergedMap.set(key, { + synonymSet: new Set(), + source: item.source, + chunkId: item.chunkId + }); + } + + const entry = mergedMap.get(key)!; + item.synonymTerms.forEach((syn) => entry.synonymSet.add(syn)); + + // 如果有chunk来源,优先使用chunk来源 + if (item.source === 'chunk') { + entry.source = 'chunk'; + entry.chunkId = item.chunkId; + } + }); + + // 转换回数组格式 + const result: SynonymMappingForPrompt[] = []; + mergedMap.forEach((entry, standardizedTerm) => { + result.push({ + standardizedTerm, + synonymTerms: Array.from(entry.synonymSet), + source: entry.source, + chunkId: entry.chunkId + }); + }); + + return result; +}; + +/** + * 生成同义词映射的提示文本 + * @param synonymMappings 同义词映射列表 + * @returns 格式化的提示文本 + */ +export const buildSynonymMappingPrompt = (synonymMappings: SynonymMappingForPrompt[]): string => { + if (!synonymMappings || synonymMappings.length === 0) { + return ''; + } + + const mappingLines = synonymMappings.map((item) => { + const synonymStr = item.synonymTerms.join('、'); + // const sourceLabel = item.source === 'query' ? '[查询词]' : '[知识库]'; + // return `- "${item.standardizedTerm}" 的同义词: ${synonymStr} ${sourceLabel}`; + // return `- "${item.standardizedTerm}" 的同义词: ${synonymStr} ${sourceLabel}`; + return `- "${item.standardizedTerm}" synonyms: ${synonymStr}`; + }); + + return mappingLines.join('\n'); +}; + +/** + * 生成同义词映射的JSON格式 + * @param synonymMappings 同义词映射列表 + * @returns JSON字符串 + */ +export const buildSynonymMappingJSON = (synonymMappings: SynonymMappingForPrompt[]): string => { + if (!synonymMappings || synonymMappings.length === 0) { + return '[]'; + } + + return JSON.stringify( + synonymMappings.map((item) => ({ + standardizedTerm: item.standardizedTerm, + synonymTerms: item.synonymTerms, + source: item.source + })), + null, + 2 + ); +}; diff --git a/packages/service/core/dataset/search/utils.ts b/packages/service/core/dataset/search/utils.ts index efef1599b593..6ead1fad74e6 100644 --- a/packages/service/core/dataset/search/utils.ts +++ b/packages/service/core/dataset/search/utils.ts @@ -1,7 +1,10 @@ -import { queryExtension } from '../../ai/functions/queryExtension'; +import { queryExtension, queryExtensionForAssistant } from '../../ai/functions/queryExtension'; import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import { hashStr } from '@fastgpt/global/common/string/tools'; +import { getLLMModelById } from '../../ai/model'; +import { searchSynonymMappings } from '../synonym/controller'; import { getLogger, LogCategories } from '../../../common/logger'; +import { applySynonymTransform } from '../indexTransform/utils'; const logger = getLogger(LogCategories.MODULE.DATASET.DATA); @@ -17,19 +20,141 @@ export const computeFilterIntersection = (lists: (string[] | undefined)[]) => { }); }; +// 辅助函数:从多个知识库检索标准词映射并汇总 +export async function getSynonymMappings({ + teamId, + datasetIds, + query +}: { + teamId: string; + datasetIds: string[]; + query: string; +}): Promise<{ + synonymDict: Record; + synonymFileIds: string[]; +}> { + try { + // 对每个知识库进行全文检索,获取 top10 同义词映射 + const allMappingsPromises = datasetIds.map((datasetId) => + searchSynonymMappings({ + teamId, + datasetId, + query, + limit: 10 + }).catch((error) => { + logger.debug('Get synonym mappings error for dataset', { datasetId, error }); + return []; + }) + ); + + const allMappingsResults = await Promise.all(allMappingsPromises); + + // 汇总所有知识库的同义词映射 + const synonymDict: Record = {}; + const synonymFileIdSet = new Set(); + + for (const mappings of allMappingsResults) { + for (const mapping of mappings) { + const { standardizedTerm, synonymTerms, synonymFileId } = mapping; + if (!synonymDict[standardizedTerm]) { + synonymDict[standardizedTerm] = []; + } + // 合并同义词,去重 + const existingSet = new Set(synonymDict[standardizedTerm]); + for (const synonym of synonymTerms) { + existingSet.add(synonym); + } + synonymDict[standardizedTerm] = Array.from(existingSet); + + // 收集文件ID + if (synonymFileId) { + synonymFileIdSet.add(String(synonymFileId)); + } + } + } + + return { + synonymDict, + synonymFileIds: Array.from(synonymFileIdSet) + }; + } catch (error) { + logger.debug('Get synonym mappings error', { error }); + return { + synonymDict: {}, + synonymFileIds: [] + }; + } +} + +// 辅助函数:标准化(同义词替换) +// 复用 applySynonymTransform 算法,确保 query 和 chunk 使用相同的替换策略 +export function standardizeQuery(query: string, synonyms: Record): string { + // 构建空的 synonymMappingMap,因为 query 替换不需要记录 mappingId + const synonymMappingMap: Record = {}; + for (const aliasList of Object.values(synonyms)) { + for (const alias of aliasList) { + synonymMappingMap[alias] = ''; // query 替换不需要 mappingId + } + } + + // 使用与 chunk 相同的智能替换算法 + const { transformedText } = applySynonymTransform(query, synonyms, synonymMappingMap); + return transformedText; +} + +/** + * 数据集搜索查询扩展函数 + * + * 四个场景说明: + * ──────────────────────────────────────────────────────────────── + * 场景1: isAssistant=false, llmModelId=undefined (非assistant类型应用 + 不开启问题改写) + * 流程: 独立同义词改写 → return + * 有同义词: queries = [原始, 标准化] + * 无同义词: queries = [原始] + * + * 场景2: isAssistant=false, llmModelId=存在 (非assistant类型应用 + 开启问题改写) + * 流程: 独立同义词改写 → 问题改写 → 合并结果 + * 有同义词: queries = [原始, 标准化, 改写1, 改写2, ...] + * 无同义词: queries = [原始, 改写1, 改写2, ...] + * 注意:改写问题未进行同义词标准化 + * + * 场景3: isAssistant=true, llmModelId=undefined (assistant类型应用 + 不开启问题改写) + * 流程: 独立同义词改写 → return + * 有同义词: queries = [原始, 标准化] + * 无同义词: queries = [原始] + * + * 场景4: isAssistant=true, llmModelId=存在 (assistant类型应用 + 开启问题改写) + * 流程: 独立同义词改写 → queryExtensionForAssistant()内部: + * ① 指代消除 ② 问题改写 ③ 同义词标准化所有问题 + * queries = [标准化(指代消除), 标准化(改写1), 标准化(改写2), ...] + * 所有问题都经过指代消除 + 同义词标准化,质量最高 + * 说明: queryExtensionForAssistant内部已完整处理,外部不再做同义词改写 + * ──────────────────────────────────────────────────────────────── + */ export const datasetSearchQueryExtension = async ({ query, - llmModel, - embeddingModel, + llmModelId, + embeddingModelId, extensionBg = '', - histories = [] + histories = [], + isAssistant = false, + teamId, + datasetIds, + lang }: { query: string; - llmModel?: string; - embeddingModel?: string; + llmModelId?: string; + embeddingModelId?: string; extensionBg?: string; histories?: ChatItemMiniType[]; + isAssistant?: boolean; + teamId?: string; + datasetIds?: string[]; + lang: string; }) => { + // 统计整个问题改写流程的耗时 + const startTime = Date.now(); + const filterSamQuery = (queries: string[]) => { const set = new Set(); const filterSameQueries = queries.filter((item) => { @@ -43,7 +168,6 @@ export const datasetSearchQueryExtension = async ({ return filterSameQueries; }; - // 检查传入的 query 是否已经进行过扩展 let { queries, reRankQuery, alreadyExtension } = (() => { /* if query already extension, direct parse */ try { @@ -53,7 +177,7 @@ export const datasetSearchQueryExtension = async ({ return { queries, reRankQuery: alreadyExtension ? queries.join('\n') : query, - alreadyExtension + alreadyExtension: alreadyExtension }; } catch (error) { return { @@ -64,33 +188,213 @@ export const datasetSearchQueryExtension = async ({ } })(); - // Use LLM to generate extension queries + /** + * 【独立的同义词改写】场景1/2/3 都会执行这段逻辑 + * + * 执行条件: teamId && datasetIds && !alreadyExtension + * 处理对象: 原始query (不是改写后的问题) + * 输出: queries = [原始query, 标准化query] + * + * 说明: 这是一个通用的、与问题改写无关的同义词标准化逻辑 + */ + let synonymRewriteResult: { standardizedQuery: string; coreferenceResolved: string } | undefined = + undefined; + if (teamId && datasetIds && datasetIds.length > 0 && !alreadyExtension) { + const { synonymDict } = await getSynonymMappings({ + teamId, + datasetIds, + query + }); + + // 用标准词替换查询中的同义词 + if (Object.keys(synonymDict).length > 0) { + const standardizedQuery = standardizeQuery(query, synonymDict); + + if (standardizedQuery !== query) { + synonymRewriteResult = { standardizedQuery, coreferenceResolved: query }; + queries = [query, standardizedQuery]; // 原始在第1位,标准化在第2位 + logger.debug('Synonym rewrite applied', { + original: query, + standardized: standardizedQuery + }); + } + } + } + + /** + * 【问题改写分支】场景2/4 会执行这段逻辑 + * + * 条件判断: llmModelId && embeddingModelId && !alreadyExtension + * + * 场景2 (非assistant类型应用): 调用 queryExtension() + * 返回: extensionQueries = [改写问题1, 改写问题2, ...] + * 这些改写问题未经过同义词标准化 + * + * 场景4 (assistant类型应用): 调用 queryExtensionForAssistant() + * 内部逻辑: + * ① 指代消除 + 问题改写 + * ② 获取同义词映射 (基于指代消除后的query) + * ③ 对所有问题进行同义词标准化 + * 返回: extensionQueries = [标准化(指代消除), 标准化(改写1), ...] + * 所有问题都已标准化,质量最高 + */ const aiExtensionResult = await (async () => { - if (!llmModel || !embeddingModel || alreadyExtension) return; + if (!llmModelId || !embeddingModelId || alreadyExtension) return; - try { - const result = await queryExtension({ - chatBg: extensionBg, + // 如果是 assistant 类型且有 teamId 和 datasetIds,使用新逻辑 + if (isAssistant && teamId && datasetIds && datasetIds.length > 0) { + const result = await queryExtensionForAssistant({ query, histories, - llmModel, - embeddingModel + modelId: llmModelId, + teamId, + datasetIds, + lang }); if (result.extensionQueries?.length === 0) return; return result; - } catch (error) { - logger.error('Failed to generate extension queries', { error }); } + + const result = await queryExtension({ + chatBg: extensionBg, + query, + histories, + llmModelId, + embeddingModelId + }); + if (result.extensionQueries?.length === 0) return; + return result; })(); + /** + * 【结果合并逻辑】 + * + * 场景2 (非assistant类型应用 + 问题改写): + * if (aiExtensionResult) 成立 + * - 合并独立同义词改写和问题改写的结果 + * - 有同义词: queries = [原始, 标准化, 改写1, 改写2, ...] + * - 无同义词: queries = [原始, 改写1, 改写2, ...] + * - 继续执行,不返回 + * + * 场景4 (assistant类型应用 + 问题改写): + * if (aiExtensionResult) 成立 + * - aiExtensionResult.synonymRewriteResult 已有值 (来自queryExtensionForAssistant内部) + * - extensionQueries 已包含所有标准化问题:[标准化(指代消除), 标准化(改写1), ...] + * - 不再执行外部的独立同义词改写逻辑 + * - 继续执行,不返回 + * + * 场景1/3 (不开启问题改写): + * if (aiExtensionResult) 不成立 + * else if (synonymRewriteResult) 成立 + * - 纯同义词改写分支,直接 return + * - 有同义词: queries = [原始, 标准化] + * - 无同义词: queries = [原始] + */ + + // 用于落库的改写问题(不包含原始问题,只包含改写后的问题) + let queriesForStorage = ''; + if (aiExtensionResult) { + // 如果有问题改写结果,只保存改写后的问题(不包含原始问题) + queriesForStorage = aiExtensionResult.extensionQueries.join('\n'); queries = queries.concat(aiExtensionResult.extensionQueries); reRankQuery = queries.join('\n'); + // 如果 aiExtensionResult 有同义词结果,使用它;否则使用独立的同义词结果 + if (!aiExtensionResult.synonymRewriteResult && synonymRewriteResult) { + aiExtensionResult.synonymRewriteResult = synonymRewriteResult; + } + } else if (synonymRewriteResult) { + // 如果没有问题改写但有同义词改写,创建一个结果对象 + const dummyResult = { + llmModelId: '', + embeddingModelId: '', + inputTokens: 0, + outputTokens: 0, + embeddingTokens: 0, + extensionQueries: [] as string[], + synonymRewriteResult + }; + return { + searchQueries: queries, + reRankQuery, + aiExtensionResult: dummyResult, + rewriteTime: undefined, + // 纯同义词改写场景不落库: + // 1. 同义词替换是基于规则的映射,不是AI生成的内容,不需要记录 + // 2. standardizedQuery已在synonymRewriteResult中保存,可通过该字段访问 + // 3. queriesForStorage专门用于存储AI改写生成的问题,同义词替换不属于此类 + queriesForStorage: '' + }; } + /** + * 【assistant类型应用专用处理】仅场景4执行 + * + * 场景4 (isAssistant=true): + * queries 当前格式: [原始, 标准化(指代消除), 标准化(改写1), 标准化(改写2), ...] + * queriesForStorage 当前格式: 标准化(指代消除)\n标准化(改写1)\n标准化(改写2)\n...(不包含原始问题) + * 进行分号拼接: queries = [标准化1;标准化2;标准化3;...] + * 用于多路并行检索: 每个query作为独立的检索请求 + * + * reRankQuery 进行换行拼接,用于reranker使用 + * + * 场景1/2/3 (isAssistant=false): + * 不执行此逻辑,queries 保持数组形式 + */ + //参考客服跑验证集逻辑,不开问题优化,传入的query是原始query+问题改写+指代消除的标准化后的;拼接在一起 + + // queriesForStorage 已在上面的 if (aiExtensionResult) 分支中设置,无需再次处理 + + if (isAssistant) { + reRankQuery = queries.join('\n'); // 先计算 reranker 使用的换行符拼接 + queries = [queries.join(';')]; // 检索使用分号拼接 + } + + // 计算问题改写耗时 + const rewriteTime = +((Date.now() - startTime) / 1000).toFixed(2); + return { searchQueries: queries, reRankQuery, - aiExtensionResult + aiExtensionResult, + rewriteTime, + queriesForStorage // 用于落库的 query(只包含改写后的问题,不包含原始问题) }; }; + +// Get dataset SQL result limit from config or environment variable +export const getDatasetSqlResultLimit = (): number => { + const configLimit = global.systemEnv?.datasetSqlResultLimit; + if (configLimit !== undefined) return configLimit; + + const envLimit = process.env.DATASET_SQL_RESULT_LIMIT; + if (envLimit) { + const parsed = Number(envLimit); + if (!isNaN(parsed) && parsed > 0) return parsed; + } + + return 100; // Default +}; + +// Calculate dynamic limit based on LLM model's max context and safety factor +export const calculateDynamicLimit = ({ + generateSqlModelId, + safetyFactor = 0.6, + estimatedTokensPerItem = 512 +}: { + generateSqlModelId?: string; + safetyFactor?: number; + estimatedTokensPerItem?: number; +}): number => { + // Get the LLM model configuration + const llmModel = getLLMModelById(generateSqlModelId); + + // Calculate safe limit based on model's maxContext + const modelMaxToken = llmModel.maxContext; + const safeLimit = Math.floor((modelMaxToken * safetyFactor) / estimatedTokensPerItem); + + // Ensure minimum limit of 5 and maximum of 50 for reasonable performance + const finalLimit = Math.max(5, Math.min(safeLimit, 50)); + + return finalLimit; +}; diff --git a/packages/service/core/dataset/synonym/controller.ts b/packages/service/core/dataset/synonym/controller.ts new file mode 100644 index 000000000000..2df9a48431c2 --- /dev/null +++ b/packages/service/core/dataset/synonym/controller.ts @@ -0,0 +1,966 @@ +import Papa from 'papaparse'; +import xlsx from 'node-xlsx'; +import { MongoDatasetSynonym } from './schema'; +import { MongoDatasetSynonymMapping } from './mappingSchema'; +import { MongoDataset } from '../schema'; +import { getS3DatasetSource } from '../../../common/s3/sources/dataset'; +import { removeS3TTL } from '../../../common/s3/utils'; +import streamConsumer from 'node:stream/consumers'; +import fs from 'node:fs'; +import { i18nT } from '../../../../global/common/i18n/utils'; +import type { + DatasetSynonymSchemaType, + DatasetSynonymMappingSchemaType +} from '@fastgpt/global/core/dataset/type'; +import { Types } from '../../../common/mongo'; +import { DatasetErrEnum } from '@fastgpt/global/common/error/code/dataset'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { MongoDatasetTraining } from '../training/schema'; +import { MongoDatasetData } from '../data/schema'; +import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { createTrainingUsage } from '../../../support/wallet/usage/controller'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { getEmbeddingModelById } from '../../ai/model'; +import { jiebaSplitWithCustomDict } from '../../../common/string/jieba/index'; +import { detectAndDecodeBuffer } from '../../../common/file/encoding'; +import { addLog } from '../../../common/system/log'; +import { retryFn } from '@fastgpt/global/common/system/utils'; +import { pushCollectionUpdateJob } from '../collection/mq'; + +// 同义词词汇缓存,key 为 datasetId,value 为词汇列表、过期时间和访问时间 +interface SynonymWordsCache { + words: string[]; + expireAt: number; + lastAccessTime: number; // 用于LRU淘汰 +} +const synonymWordsCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5分钟过期 +const MAX_CACHE_SIZE = 1000; // 最大缓存项数 + +/** + * 清理过期的缓存项 + */ +function cleanExpiredCache() { + const now = Date.now(); + const expiredKeys: string[] = []; + + for (const [key, value] of synonymWordsCache.entries()) { + if (value.expireAt <= now) { + expiredKeys.push(key); + } + } + + expiredKeys.forEach((key) => synonymWordsCache.delete(key)); + + if (expiredKeys.length > 0) { + addLog.info(`cleanExpiredCache: Cleaned ${expiredKeys.length} expired cache items`); + } +} + +/** + * 确保缓存不超过最大大小限制,使用LRU策略淘汰 + */ +function ensureCacheSize() { + if (synonymWordsCache.size >= MAX_CACHE_SIZE) { + // 先尝试清理过期项 + cleanExpiredCache(); + + // 如果仍然超过限制,使用LRU策略删除最早访问的项 + if (synonymWordsCache.size >= MAX_CACHE_SIZE) { + const entries = Array.from(synonymWordsCache.entries()); + // 按最后访问时间排序,找到最早访问的项 + entries.sort((a, b) => a[1].lastAccessTime - b[1].lastAccessTime); + + // 删除最早的10%项 + const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.1)); + for (let i = 0; i < deleteCount; i++) { + synonymWordsCache.delete(entries[i][0]); + } + + addLog.info( + `ensureCacheSize: Cache full, cleaned ${deleteCount} least recently used cache items using LRU strategy` + ); + } + } +} + +/** + * CSV同义词文件解析结果 + */ +export type ParsedSynonymData = { + standardizedTerm: string; + synonymTerms: string[]; + allTerms: string; +}; + +/** + * 解析CSV同义词文件 + * CSV格式说明: + * - 第一行表头:standardizedTerm,synonymTerms,,,, + * - 第一列:标准词(standardizedTerm) + * - 第二列及之后:所有列都是同义词(synonymTerms),无论表头是否有名称 + * + * 示例: + * standardizedTerm,synonymTerms,,,, + * 退款,退费,返还,退回,钱款返回 + * 订单,订单号,单据,单号,购买单据 + * + * @param fileContent - CSV文件内容(字符串) + * @returns 解析后的同义词数据数组 + */ +export async function parseSynonymCSV(fileContent: string): Promise { + return new Promise((resolve, reject) => { + Papa.parse(fileContent, { + skipEmptyLines: true, + transform: (value) => value?.trim() || '', + complete: (results) => { + try { + const rows = results.data; + + // 验证至少有两行(表头+至少一行数据) + if (rows.length < 2) { + return reject(new Error(DatasetErrEnum.synonymFileEmpty)); + } + + // 验证表头格式 + const header = rows[0]; + if (!header || header.length < 2) { + return reject(new Error(DatasetErrEnum.synonymFileInvalidFormat)); + } + + // 第一行是表头,从第二行开始是数据 + const parsedData: ParsedSynonymData[] = []; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + + // 跳过完全空的行 + if (!row || row.length === 0 || !row[0]) { + continue; + } + + // 第一列(索引0)为标准词 + const standardizedTerm = row[0]?.trim() || ''; + if (!standardizedTerm) { + continue; + } + + // 第二列及之后的所有列(索引1开始)都是同义词,过滤空值 + const synonymTerms = row + .slice(1) + .map((term) => term?.trim()) + .filter((term) => term && term.length > 0); + + // 如果没有同义词,跳过该行 + if (synonymTerms.length === 0) { + continue; + } + + // 验证标准词不能与同义词相同 + if (synonymTerms.includes(standardizedTerm)) { + return reject(new Error(DatasetErrEnum.synonymTermDuplicate)); + } + + // 组合所有词用于全文检索 + const allTerms = [standardizedTerm, ...synonymTerms].join(' '); + + parsedData.push({ + standardizedTerm, + synonymTerms, + allTerms + }); + } + + // 验证至少有一条有效数据 + if (parsedData.length === 0) { + return reject(new Error(DatasetErrEnum.synonymFileNoValidData)); + } + + resolve(parsedData); + } catch (error) { + reject(error); + } + }, + error: (error: Error) => { + reject(new Error(DatasetErrEnum.synonymFileParseFailed)); + } + }); + }); +} + +/** + * 将Excel buffer转换为CSV字符串 + * 只读取第一个sheet + * @param buffer - Excel文件buffer + * @returns CSV字符串 + */ +export function parseExcelToCSV(buffer: Buffer): string { + // 解析Excel文件,只读取第一个sheet + const sheets = xlsx.parse(buffer, { + defval: '' + }); + + if (sheets.length === 0) { + throw new Error(DatasetErrEnum.synonymFileEmpty); + } + + // 只读取第一个sheet + const firstSheet = sheets[0]; + const rows = firstSheet.data; + + if (!rows || rows.length === 0) { + throw new Error(DatasetErrEnum.synonymFileEmpty); + } + + // 将rows转换为CSV字符串 + // 使用Papa.unparse来保证CSV格式正确(处理特殊字符、引号等) + const csvString = Papa.unparse(rows, { + quotes: false, + delimiter: ',', + newline: '\n' + }); + + return csvString; +} + +/** + * 从 S3 读取同义词文件内容 + * @param fileKey - S3 文件 key + * @returns 文件内容字符串 + */ +export async function readSynonymFileFromS3(fileKey: string): Promise { + const body = await getS3DatasetSource().getFileStream(fileKey); + if (!body) { + throw new Error(DatasetErrEnum.synonymFileNotExist); + } + const buffer = await streamConsumer.buffer(body); + const { content } = detectAndDecodeBuffer(buffer); + return content; +} + +/** + * 上传同义词文件并创建映射 + * @param params - 上传参数 + * @returns 创建的同义词文件记录 + */ +export async function uploadSynonymFile({ + teamId, + tmbId, + datasetId, + uploaderId, + filePath, + fileName, + fileSize +}: { + teamId: string; + tmbId: string; + datasetId: string; + uploaderId: string; + filePath: string; + fileName: string; + fileSize: number; +}): Promise { + let uploadedFileId: string | undefined; + + try { + // ✅ 双向互斥检查 (2/2): 同义词上传入口检查是否有任何训练任务 + // 说明: 此检查阻止同义词上传与任何训练任务(chunk/qa/auto/synonymStandardize/synonymRestore)并发 + // 注意: 这里不过滤 mode,因为需要阻止所有类型的训练任务,包括同义词自身的任务 + // 配合 pushDataListToTrainingQueue 中的检查,保证双向互斥 + // 只检查 retryCount > 0 的任务,因为 retryCount 为 0 的任务不会再被处理 + const hasTrainingTask = await MongoDatasetTraining.exists({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + retryCount: { $gt: 0 } + }); + + if (hasTrainingTask) { + throw new Error(DatasetErrEnum.datasetTrainingInProgress); + } + + // 1. 检查该知识库是否已有同义词文件 + const existingSynonym = await MongoDatasetSynonym.findOne({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }); + + // 2. 检测文件类型并确定contentType + const fileExtension = fileName.toLowerCase().split('.').pop(); + const isExcel = fileExtension === 'xlsx' || fileExtension === 'xls'; + const contentType = isExcel + ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : 'text/csv'; + + // 3. 读取本地文件并解析(验证格式) + const fileBuffer = fs.readFileSync(filePath); + let fileContent: string; + if (isExcel) { + fileContent = parseExcelToCSV(fileBuffer); + } else { + const { content } = detectAndDecodeBuffer(fileBuffer); + fileContent = content; + } + + const parsedData = await parseSynonymCSV(fileContent); + + // 4. 上传文件到 S3 + const fileId = await getS3DatasetSource().upload({ + datasetId, + filename: fileName, + stream: fs.createReadStream(filePath), + contentType + }); + uploadedFileId = fileId; + + // 5. 如果存在旧文件,删除旧文件及其映射 + if (existingSynonym) { + await deleteSynonymFile({ + synonymId: String(existingSynonym._id), + teamId, + tmbId, + datasetId + }); + } + + // 5. 获取知识库信息(用于创建billId) + const dataset = await MongoDataset.findById(datasetId).select('vectorModelId').lean(); + if (!dataset) { + throw new Error(DatasetErrEnum.unExist); + } + + // 6. 创建 usageId (用于费用追踪) + const { usageId } = await createTrainingUsage({ + teamId, + tmbId, + appName: i18nT('account_usage:synonym_standardize'), + billSource: UsageSourceEnum.training, + vectorModelId: getEmbeddingModelById(dataset.vectorModelId)?.id + }); + + // 7-9. 使用事务创建同义词记录、映射和更新知识库(保证原子性) + const result = await mongoSessionRun(async (session) => { + // 5. 创建新的同义词文件记录 + const [synonymFile] = await MongoDatasetSynonym.create( + [ + { + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + fileName, + fileId: fileId, + size: fileSize, + uploadTime: new Date(), + uploaderId: new Types.ObjectId(uploaderId) + } + ], + { session } + ); + + // 6. 批量创建同义词映射 + const mappings = parsedData.map((data) => ({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + synonymFileId: synonymFile._id, + standardizedTerm: data.standardizedTerm, + synonymTerms: data.synonymTerms, + allTerms: data.allTerms, + createdTime: new Date(), + updatedTime: new Date() + })); + + await MongoDatasetSynonymMapping.insertMany(mappings, { session }); + + // 7. 更新知识库的synonymFiles字段 + await MongoDataset.findByIdAndUpdate( + datasetId, + { + $set: { synonymFiles: [synonymFile._id] } + }, + { session } + ); + + // 8. 移出S3的TTL,防止同义词文件被自动清理 + await removeS3TTL({ key: fileId, bucketName: 'private', session }); + + return synonymFile; + }); + + // 10. 标记所有需要标准化的数据 + await MongoDatasetData.updateMany( + { + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + // 所有数据都要走增强流程 + }, + { + $set: { + synonymProcessing: 'standardize', + synonymFileIds: [String(result._id)] + }, + $unset: { + indexingCompleteTime: '' + } + } + ); + + // 11. 创建初始批次的训练任务 + /** + * 使用 retryFn 处理 WriteConflict 错误 + * + * 问题场景: + * 虽然这里是 for await 循环顺序执行,但在多 Pod/多实例部署场景下, + * 多个实例可能同时执行 uploadSynonymFile,导致多个 mongoSessionRun + * 并发竞争同一条 synonymProcessing='standardize' 的数据。 + * + * 另外,即使单实例场景,当 worker 处理速度很快时,链式处理创建的新任务 + * 也可能与这里的初始批次创建产生竞争。 + * + * 解决方案: + * 1. retryFn 重试机制:发生冲突时自动重试(最多3次) + * 2. 幂等性检查:创建任务前先检查是否已存在相同的 datasetId+dataId+mode 任务 + */ + const max = global.systemEnv?.vectorMaxProcess || 10; + const initialBatch = new Array(max * 2).fill(0); + + for await (const _ of initialBatch) { + try { + const hasNext = await retryFn( + async () => { + return await mongoSessionRun(async (session) => { + // 获取下一条需要处理的数据 + const data = await MongoDatasetData.findOneAndUpdate( + { + synonymProcessing: 'standardize', + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }, + { + $unset: { synonymProcessing: null } // 清除标记,避免重复处理 + }, + { session } + ).select({ _id: 1, collectionId: 1, synonymFileIds: 1 }); + + if (data) { + // 幂等性检查:避免重复创建任务 + const existingTask = await MongoDatasetTraining.findOne( + { + datasetId: new Types.ObjectId(datasetId), + dataId: data._id, + mode: TrainingModeEnum.synonymStandardize + }, + { _id: 1 }, + { session } + ); + + if (!existingTask) { + // 创建训练任务 + await MongoDatasetTraining.create( + [ + { + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + datasetId: new Types.ObjectId(datasetId), + collectionId: data.collectionId, + mode: TrainingModeEnum.synonymStandardize, + dataId: data._id, + dataMetadata: { + synonymFileIds: data.synonymFileIds + }, + retryCount: 50, + billId: usageId + } + ], + { session, ordered: true } + ); + } + } + + return !!data; + }); + }, + 3 // 最多重试3次 + ); + + if (!hasNext) break; + } catch (error) { + addLog.error('Failed to create synonym training task:', error); + } + } + + // 触发所有受影响的 collection 的 stats 更新,使前端状态从 ready 变为 indexing + // 直接查 MongoDatasetData,因为前面 updateMany 已标记了知识库全部数据 + const affectedCollections = await MongoDatasetData.distinct('collectionId', { + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }); + + for (const collectionId of affectedCollections) { + pushCollectionUpdateJob( + { + collectionId: String(collectionId), + datasetId, + teamId + }, + 0 + ); + } + + // 12. 清除同义词词汇缓存 + synonymWordsCache.delete(datasetId); + addLog.info(`uploadSynonymFile: Cleared cache for datasetId=${datasetId}`); + + return result.toObject(); + } catch (error) { + // 如果过程中出错且已上传文件,清理 S3 中的孤儿文件 + if (uploadedFileId) { + try { + await getS3DatasetSource().deleteDatasetFileByKey(uploadedFileId); + } catch (cleanupError) { + // 清理失败只记录,不影响原始错误的抛出 + addLog.error('Failed to cleanup uploaded file:', cleanupError); + } + } + throw error; + } +} + +/** + * 删除同义词文件及其所有映射 + * @param params - 删除参数 + */ +export async function deleteSynonymFile({ + synonymId, + teamId, + tmbId, + datasetId +}: { + synonymId: string; + teamId: string; + tmbId: string; + datasetId: string; +}): Promise { + // ✅ 双向互斥检查: 同义词删除入口检查是否有任何训练任务 + // 说明: 同 uploadSynonymFile,阻止删除操作与任何训练任务并发 + // 只检查 retryCount > 0 的任务,因为 retryCount 为 0 的任务不会再被处理 + const hasTrainingTask = await MongoDatasetTraining.exists({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + retryCount: { $gt: 0 } + }); + + if (hasTrainingTask) { + throw new Error(DatasetErrEnum.datasetTrainingInProgress); + } + + // 1. 查找同义词文件记录 + const synonymFile = await MongoDatasetSynonym.findOne({ + _id: new Types.ObjectId(synonymId), + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }); + + if (!synonymFile) { + throw new Error(DatasetErrEnum.synonymFileNotExist); + } + + // 2. 获取知识库信息(用于创建billId) + const dataset = await MongoDataset.findById(datasetId).select('vectorModelId').lean(); + if (!dataset) { + throw new Error(DatasetErrEnum.unExist); + } + + // 【改造】删除同义词文件不再触发索引恢复,注释以下代码: + // 3. 创建 usageId (用于费用追踪) + // const { usageId } = await createTrainingUsage({ + // teamId, + // tmbId, + // appName: '同义词恢复', + // billSource: UsageSourceEnum.training, + // vectorModelId: getEmbeddingModelById(dataset.vectorModel)?.id + // }); + + // 4. 标记所有需要恢复的数据 + // 注意:synonymFileIds 存储在 indexes.synonymMetadata.synonymFileIds + // await MongoDatasetData.updateMany( + // { + // teamId: new Types.ObjectId(teamId), + // datasetId: new Types.ObjectId(datasetId), + // // ✅ 查询 indexes 数组中,synonymMetadata.synonymFileIds 包含该同义词文件ID的数据 + // 'indexes.synonymMetadata.synonymFileIds': synonymId + // }, + // { + // $set: { + // synonymProcessing: 'restore', + // synonymFileIds: [synonymId] + // }, + // $unset: { + // indexingCompleteTime: '' + // } + // } + // ); + + // 5. 创建初始批次的恢复任务 + /** + * 使用 retryFn 处理 WriteConflict 错误(同 uploadSynonymFile) + * deleteSynonymFile 的恢复任务创建逻辑与标准化任务创建相同, + * 存在同样的多实例并发竞争问题。 + */ + // const max = global.systemEnv?.vectorMaxProcess || 10; + // const initialBatch = new Array(max * 2).fill(0); + + // for await (const _ of initialBatch) { + // try { + // const hasNext = await retryFn( + // async () => { + // return await mongoSessionRun(async (session) => { + // const data = await MongoDatasetData.findOneAndUpdate( + // { + // synonymProcessing: 'restore', + // teamId: new Types.ObjectId(teamId), + // datasetId: new Types.ObjectId(datasetId) + // }, + // { + // $unset: { synonymProcessing: null } + // }, + // { session } + // ).select({ _id: 1, collectionId: 1, synonymFileIds: 1 }); + + // if (data) { + // // 幂等性检查:避免重复创建任务 + // const existingTask = await MongoDatasetTraining.findOne( + // { + // datasetId: new Types.ObjectId(datasetId), + // dataId: data._id, + // mode: TrainingModeEnum.synonymRestore + // }, + // { _id: 1 }, + // { session } + // ); + + // if (!existingTask) { + // await MongoDatasetTraining.create( + // [ + // { + // teamId: new Types.ObjectId(teamId), + // tmbId: new Types.ObjectId(tmbId), + // datasetId: new Types.ObjectId(datasetId), + // collectionId: data.collectionId, + // mode: TrainingModeEnum.synonymRestore, + // dataId: data._id, + // dataMetadata: { + // synonymFileIds: data.synonymFileIds + // }, + // retryCount: 3, + // billId: usageId + // } + // ], + // { session, ordered: true } + // ); + // } + // } + + // return !!data; + // }); + // }, + // 3 // 最多重试3次 + // ); + + // if (!hasNext) break; + // } catch (error) { + // addLog.error('Failed to create synonym restore task:', error); + // } + // } + + // 6. 删除 S3 中的文件 + await getS3DatasetSource().deleteDatasetFileByKey(String(synonymFile.fileId)); + + // 7. 删除所有相关的同义词映射 + await MongoDatasetSynonymMapping.deleteMany({ + synonymFileId: synonymFile._id + }); + + // 8. 删除同义词文件元数据记录 + await MongoDatasetSynonym.deleteOne({ + _id: synonymFile._id + }); + + // 9. 更新知识库的synonymFiles字段 + await MongoDataset.findByIdAndUpdate(datasetId, { + $pull: { synonymFiles: synonymFile._id } + }); + + // 10. 清除同义词词汇缓存 + synonymWordsCache.delete(datasetId); + addLog.info(`deleteSynonymFile: Cleared cache for datasetId=${datasetId}`); +} + +/** + * 更新同义词文件 - 先删除后上传 (简化方案) + * @param params - 更新参数 + * @returns 创建的同义词文件记录 + */ +export async function updateSynonymFile({ + teamId, + tmbId, + datasetId, + oldSynonymId, + uploaderId, + filePath, + fileName, + fileSize +}: { + teamId: string; + tmbId: string; + datasetId: string; + oldSynonymId: string; + uploaderId: string; + filePath: string; + fileName: string; + fileSize: number; +}): Promise { + // ✅ 双向互斥检查: 同义词更新入口检查是否有任何训练任务 + // 说明: 同 uploadSynonymFile,阻止更新操作与任何训练任务并发 + // 只检查 retryCount > 0 的任务,因为 retryCount 为 0 的任务不会再被处理 + const hasTrainingTask = await MongoDatasetTraining.exists({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + retryCount: { $gt: 0 } + }); + + if (hasTrainingTask) { + throw new Error(DatasetErrEnum.datasetTrainingInProgress); + } + + // 1. 先调用删除流程,恢复所有数据 + await deleteSynonymFile({ + synonymId: oldSynonymId, + teamId, + tmbId, + datasetId + }); + + // 2. 等待恢复任务全部完成 (通过轮询检查) + while (true) { + const hasRestoreTask = await MongoDatasetTraining.exists({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + mode: TrainingModeEnum.synonymRestore + }); + + if (!hasRestoreTask) break; + + await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待1秒后重试 + } + + // 3. 再调用上传流程,应用新同义词 + return await uploadSynonymFile({ + teamId, + tmbId, + datasetId, + uploaderId, + filePath, + fileName, + fileSize + }); +} + +/** + * 获取知识库的同义词文件列表 + * @param params - 查询参数 + * @returns 同义词文件列表 + */ +export async function listSynonymFiles({ + teamId, + datasetId +}: { + teamId: string; + datasetId: string; +}): Promise { + const files = await MongoDatasetSynonym.find({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }) + .sort({ uploadTime: -1 }) + .lean(); + + return files.map((file) => ({ + ...file, + _id: String(file._id), + teamId: String(file.teamId), + datasetId: String(file.datasetId), + fileId: String(file.fileId), + uploaderId: String(file.uploaderId) + })); +} + +/** + * 搜索同义词映射(全文检索) + * @param params - 搜索参数 + * @returns 匹配的同义词映射列表 + */ +export async function searchSynonymMappings({ + teamId, + datasetId, + query, + limit = 10 +}: { + teamId: string; + datasetId: string; + query: string; + limit?: number; +}): Promise { + try { + // 尝试从缓存获取同义词词汇 + const now = Date.now(); + const cached = synonymWordsCache.get(datasetId); + let customWords: string[] = []; + + if (cached && cached.expireAt > now) { + // 缓存命中,更新访问时间 + customWords = cached.words; + cached.lastAccessTime = now; + addLog.info( + `searchSynonymMappings cache hit: datasetId=${datasetId}, words count=${customWords.length}` + ); + } else { + // 缓存未命中或已过期,查询数据库 + const allMappings = await MongoDatasetSynonymMapping.find( + { + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId) + }, + 'standardizedTerm synonymTerms' + ).lean(); + + // 提取所有标准词和同义词 + allMappings.forEach((mapping) => { + if (mapping.standardizedTerm) { + customWords.push(mapping.standardizedTerm); + } + if (mapping.synonymTerms && Array.isArray(mapping.synonymTerms)) { + customWords.push(...mapping.synonymTerms.filter((t) => t)); + } + }); + + // 在添加新缓存前确保不超过大小限制 + ensureCacheSize(); + + // 更新缓存 + synonymWordsCache.set(datasetId, { + words: customWords, + expireAt: now + CACHE_TTL, + lastAccessTime: now + }); + + addLog.info( + `searchSynonymMappings cache updated: datasetId=${datasetId}, words count=${customWords.length}` + ); + } + + // 如果没有同义词数据,直接返回空数组 + if (customWords.length === 0) { + addLog.info(`searchSynonymMappings: No synonym data for datasetId=${datasetId}`); + return []; + } + + // 使用自定义词典进行分词,确保同义词不会被错误切分 + const searchQuery = await jiebaSplitWithCustomDict({ + text: query, + customWords + }); + + // 如果分词结果为空,返回空数组 + if (!searchQuery || searchQuery.trim().length === 0) { + addLog.info(`searchSynonymMappings: Empty segmentation result for query=${query}`); + return []; + } + + // 打印函数入参 + addLog.info('searchSynonymMappings input params:', { + teamId, + datasetId, + originalQuery: query, + customWordsCount: customWords.length, + segmentedQuery: searchQuery, + limit + }); + + const mappings = await MongoDatasetSynonymMapping.find( + { + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + $text: { $search: searchQuery } + }, + { + score: { $meta: 'textScore' } + } + ) + .sort({ score: { $meta: 'textScore' } }) + .limit(limit) + .lean(); + + const result = mappings.map((mapping) => ({ + ...mapping, + _id: String(mapping._id), + teamId: String(mapping.teamId), + datasetId: String(mapping.datasetId), + synonymFileId: String(mapping.synonymFileId) + })); + + // 打印返回内容 + addLog.info('searchSynonymMappings result:', { + mappingsCount: mappings.length, + resultCount: result.length, + data: result + }); + + return result; + } catch (error) { + addLog.error('searchSynonymMappings error:', { + datasetId, + query, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + // 出错时返回空数组,不影响上层调用 + return []; + } +} + +/** + * 批量查询标准词的同义词映射 + * @param params - 查询参数 + * @returns 标准词到同义词的映射字典 + */ +export async function batchGetSynonymMappings({ + teamId, + datasetId, + standardizedTerms +}: { + teamId: string; + datasetId: string; + standardizedTerms: string[]; +}): Promise> { + const mappings = await MongoDatasetSynonymMapping.find({ + teamId: new Types.ObjectId(teamId), + datasetId: new Types.ObjectId(datasetId), + standardizedTerm: { $in: standardizedTerms } + }).lean(); + + // 构建结果字典 + const result: Record = {}; + + // 初始化所有查询词为null + standardizedTerms.forEach((term) => { + result[term] = null; + }); + + // 填充找到的映射 + mappings.forEach((mapping) => { + result[mapping.standardizedTerm] = { + standardizedTerm: mapping.standardizedTerm, + synonymTerms: mapping.synonymTerms + }; + }); + + return result; +} diff --git a/packages/service/core/dataset/synonym/mappingSchema.ts b/packages/service/core/dataset/synonym/mappingSchema.ts new file mode 100644 index 000000000000..04bbfbd578fc --- /dev/null +++ b/packages/service/core/dataset/synonym/mappingSchema.ts @@ -0,0 +1,104 @@ +import { connectionMongo, type Model } from '../../../common/mongo'; +import type { DatasetSynonymMappingSchemaType } from '@fastgpt/global/core/dataset/type'; +const { Schema } = connectionMongo; +import { DatasetSynonymMappingCollectionName } from '@fastgpt/global/core/dataset/constants'; +import { getMongoModel } from '../../../common/mongo/index'; +import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant'; +import { DatasetCollectionName } from '../schema'; + +/** + * 同义词映射集合 + * 用于存储标准化词到同义词的映射关系,支持全文检索和快速查询 + */ +const DatasetSynonymMappingSchema = new Schema( + { + // 团队ID - 必填 + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + // 知识库ID - 必填,关联到具体的知识库 + datasetId: { + type: Schema.Types.ObjectId, + ref: DatasetCollectionName, + required: true + }, + // 关联的同义词文件ID - 必填,用于删除文件时清理映射 + synonymFileId: { + type: Schema.Types.ObjectId, + required: true + }, + // 标准化词 - 必填,CSV第一列的标准词 + standardizedTerm: { + type: String, + required: true, + trim: true + }, + // 同义词数组 - 必填,CSV第2列及之后的所有列 + synonymTerms: { + type: [String], + required: true, + default: [] + }, + // 组合搜索字段 - 必填,用于全文检索 + // 格式: "{标准词} {同义词1} {同义词2} ..." + allTerms: { + type: String, + required: true + }, + // 创建时间 - 自动设置 + createdTime: { + type: Date, + default: () => new Date() + }, + // 更新时间 - 自动设置 + updatedTime: { + type: Date, + default: () => new Date() + } + }, + { + // 启用自动更新时间戳 + timestamps: { + createdAt: 'createdTime', + updatedAt: 'updatedTime' + } + } +); + +// 索引设置 +// 1. 文本索引 - 用于全文检索同义词(核心功能) +DatasetSynonymMappingSchema.index( + { allTerms: 'text' }, + { + weights: { + allTerms: 10 // 提高权重以优化搜索结果 + }, + name: 'allTerms_text', + default_language: 'none', // 不使用特定语言的词干提取 + textIndexVersion: 3 // 使用最新的文本索引版本 + } +); + +// 2. 标准词+知识库复合索引 - 用于精确查询某个知识库中的标准词映射(批量查询场景) +DatasetSynonymMappingSchema.index({ datasetId: 1, standardizedTerm: 1 }, { unique: false }); + +// 3. 文件ID索引 - 用于删除同义词文件时批量删除映射 +DatasetSynonymMappingSchema.index({ synonymFileId: 1 }); + +// 4. 团队+知识库+标准词三合一索引 - 用于最精确的查询(带权限校验的查询场景) +DatasetSynonymMappingSchema.index( + { teamId: 1, datasetId: 1, standardizedTerm: 1 }, + { unique: false } +); + +// 5. 创建时间索引 - 用于按时间排序和统计 +DatasetSynonymMappingSchema.index({ createdTime: -1 }); + +/** + * 导出 MongoDB 模型 + */ +export const MongoDatasetSynonymMapping: Model = getMongoModel< + DatasetSynonymMappingSchemaType +>(DatasetSynonymMappingCollectionName, DatasetSynonymMappingSchema); diff --git a/packages/service/core/dataset/synonym/schema.ts b/packages/service/core/dataset/synonym/schema.ts new file mode 100644 index 000000000000..82df39f9b8bf --- /dev/null +++ b/packages/service/core/dataset/synonym/schema.ts @@ -0,0 +1,77 @@ +import { connectionMongo, type Model } from '../../../common/mongo'; +import type { DatasetSynonymSchemaType } from '@fastgpt/global/core/dataset/type'; +const { Schema } = connectionMongo; +import { DatasetSynonymCollectionName } from '@fastgpt/global/core/dataset/constants'; +import { getMongoModel } from '../../../common/mongo/index'; +import { + TeamMemberCollectionName, + TeamCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { DatasetCollectionName } from '../schema'; + +/** + * 同义词文件元数据集合 + * 用于管理上传到知识库的同义词CSV文件 + */ +const DatasetSynonymSchema = new Schema({ + // 团队ID - 必填 + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + // 知识库ID - 必填,关联到具体的知识库 + datasetId: { + type: Schema.Types.ObjectId, + ref: DatasetCollectionName, + required: true + }, + // 文件名 - 必填 + fileName: { + type: String, + required: true + }, + // S3 文件 key - 必填,存储在 S3/MinIO 中的对象键 + fileId: { + type: String, + required: true + }, + // 文件大小(字节) - 必填 + size: { + type: Number, + required: true + }, + // 上传时间 - 自动设置 + uploadTime: { + type: Date, + default: () => new Date() + }, + // 上传者ID - 必填 + uploaderId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + } +}); + +// 索引设置 +// 1. 知识库查询索引 - 通过知识库ID查询同义词文件(最常用) +DatasetSynonymSchema.index({ datasetId: 1 }); + +// 2. 团队查询索引 - 通过团队ID查询该团队的所有同义词文件 +DatasetSynonymSchema.index({ teamId: 1 }); + +// 3. S3 key 索引 - 通过 S3 key 查询元数据 +DatasetSynonymSchema.index({ fileId: 1 }); + +// 4. 复合索引 - 团队+知识库,用于权限校验和精确查询 +DatasetSynonymSchema.index({ teamId: 1, datasetId: 1 }); + +// 5. 上传时间索引 - 用于按时间排序和清理过期文件 +DatasetSynonymSchema.index({ uploadTime: -1 }); + +/** + * 导出 MongoDB 模型 + */ +export const MongoDatasetSynonym: Model = + getMongoModel(DatasetSynonymCollectionName, DatasetSynonymSchema); diff --git a/packages/service/core/dataset/tag/schema.ts b/packages/service/core/dataset/tag/schema.ts index ecfeb056c5b2..178487a6b42f 100644 --- a/packages/service/core/dataset/tag/schema.ts +++ b/packages/service/core/dataset/tag/schema.ts @@ -21,6 +21,11 @@ const DatasetCollectionTagsSchema = new Schema({ tag: { type: String, required: true + }, + tagType: { + type: String, + default: 'string', + enum: ['string', 'number', 'datetime'] } }); diff --git a/packages/service/core/dataset/training/controller.ts b/packages/service/core/dataset/training/controller.ts index ed47d6b0fceb..6bba298f9b84 100644 --- a/packages/service/core/dataset/training/controller.ts +++ b/packages/service/core/dataset/training/controller.ts @@ -5,13 +5,13 @@ import type { } from '@fastgpt/global/openapi/core/dataset/data/api'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { type ClientSession } from '../../../common/mongo'; -import { getLLMModel, getEmbeddingModel, getVlmModel } from '../../ai/model'; +import { getLLMModelById, getEmbeddingModelById, getVlmModelById } from '../../ai/model'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { getLLMMaxChunkSize } from '../../../../global/core/dataset/training/utils'; -import { retryFn } from '@fastgpt/global/common/system/utils'; import { getLogger, LogCategories } from '../../../common/logger'; import { checkTimerLock, deleteTimerLock } from '../../../common/system/timerLock/utils'; +import { pushCollectionUpdateJob } from '../collection/mq'; const logger = getLogger(LogCategories.MODULE.DATASET.TRAINING); @@ -44,9 +44,9 @@ export const pushDataListToTrainingQueue = async ({ tmbId, datasetId, collectionId, - agentModel, - vectorModel, - vlmModel, + agentModelId, + vectorModelId, + vlmModelId, data, billId, mode = TrainingModeEnum.chunk, @@ -61,26 +61,42 @@ export const pushDataListToTrainingQueue = async ({ data: PushDataChunkType[]; mode?: TrainingModeEnum; - agentModel: string; - vectorModel: string; - vlmModel?: string; + agentModelId: string; + vectorModelId: string; + vlmModelId?: string; indexSize?: number; billId: string; session?: ClientSession; }): Promise => { - const vectorModelData = getEmbeddingModel(vectorModel); + // ✅ 双向互斥检查 (1/2): 训练入口检查是否有同义词处理任务 + // 说明: 此检查确保 chunk/qa/auto 等训练任务不会与 synonymStandardize/synonymRestore 并发 + // 配合 uploadSynonymFile/deleteSynonymFile 中的反向检查,保证同一知识库同一时刻只有一种操作 + const hasSynonymTask = await MongoDatasetTraining.exists({ + teamId: teamId, + datasetId: datasetId, + mode: { + $in: [TrainingModeEnum.synonymStandardize, TrainingModeEnum.synonymRestore] + }, + retryCount: { $gt: 0 } + }); + + if (hasSynonymTask) { + throw new Error('知识库正在进行同义词处理,请等待处理完成后再训练'); + } + + const vectorModelData = getEmbeddingModelById(vectorModelId); if (!vectorModelData) { return Promise.reject(i18nT('common:error_embedding_not_config')); } - const agentModelData = getLLMModel(agentModel); + const agentModelData = getLLMModelById(agentModelId); if (!agentModelData) { return Promise.reject(i18nT('common:error_llm_not_config')); } const { model, maxToken, weight } = await (async () => { - if (mode === TrainingModeEnum.chunk) { + if (mode === TrainingModeEnum.chunk || mode === TrainingModeEnum.small2Big) { return { maxToken: Infinity, model: vectorModelData.model, @@ -95,13 +111,25 @@ export const pushDataListToTrainingQueue = async ({ }; } if (mode === TrainingModeEnum.image || mode === TrainingModeEnum.imageParse) { - const vllmModelData = getVlmModel(vlmModel); - if (!vllmModelData) { - return Promise.reject(i18nT('common:error_vlm_not_config')); + const vlmModelData = getVlmModelById(vlmModelId); + if (!vlmModelData) { + // imageParse mode can use customPdfParse service instead of VLM + const hasCustomPdfParse = + mode === TrainingModeEnum.imageParse && + !!global.systemEnv?.customPdfParse?.url && + !!global.systemEnv?.customPdfParse?.key; + if (!hasCustomPdfParse) { + return Promise.reject(i18nT('common:error_vlm_not_config')); + } + return { + maxToken: getLLMMaxChunkSize(agentModelData), + model: agentModelData.model, + weight: 0 + }; } return { - maxToken: getLLMMaxChunkSize(vllmModelData), - model: vllmModelData.model, + maxToken: getLLMMaxChunkSize(vlmModelData), + model: vlmModelData.model, weight: 0 }; } @@ -130,97 +158,83 @@ export const pushDataListToTrainingQueue = async ({ }); // insert data to db - const batchSize = 500; // Batch insert size - const maxBatchesPerTransaction = 20; // Every session can insert at most 20 batches - - const insertDataIterative = async ( - dataToInsert: typeof data, - session: ClientSession - ): Promise => { - let insertedCount = 0; - - for (let i = 0; i < dataToInsert.length; i += batchSize) { - const batch = dataToInsert.slice(i, i + batchSize); - - if (batch.length === 0) continue; - - const result = await MongoDatasetTraining.insertMany( - batch.map((item) => ({ - teamId, - tmbId, - datasetId, - collectionId, - billId, - mode, - ...(item.q && { q: item.q }), - ...(item.a && { a: item.a }), - ...(item.imageId && { imageId: item.imageId }), - chunkIndex: item.chunkIndex ?? 0, - indexSize, - weight: weight ?? 0, - indexes: item.indexes, - retryCount: 5 - })), - { - session, - ordered: true, // 改为 true: 任何失败立即停止,事务回滚 - rawResult: true, - includeResultMetadata: false - } - ); + const batchSize = 500; - // ordered: true 模式下,成功必定等于批次大小 - insertedCount += result.insertedCount; + const insertBatch = async (batch: typeof data, batchSession: ClientSession): Promise => { + if (batch.length === 0) return 0; - logger.debug('Training data insert progress', { - insertedCount, - total: dataToInsert.length - }); - } + const result = await MongoDatasetTraining.insertMany( + batch.map((item) => ({ + teamId, + tmbId, + datasetId, + collectionId, + billId, + mode, + ...(item.q && { q: item.q }), + ...(item.a && { a: item.a }), + ...(item.imageId && { imageId: item.imageId }), + ...(item.id && { dataId: item.id }), + chunkIndex: item.chunkIndex ?? 0, + indexSize, + weight: weight ?? 0, + indexes: item.indexes, + ...(item.metadata && { dataMetadata: item.metadata }), + retryCount: 5 + })), + { + session: batchSession, + ordered: true, + rawResult: true, + includeResultMetadata: false + } + ); - return insertedCount; + return result.insertedCount; }; - // 大数据量分段事务处理 (避免事务超时) - const chunkSize = maxBatchesPerTransaction * batchSize; // 10,000 条 let start = Date.now(); + let insertedCount = 0; - if (data.length > chunkSize) { - logger.info('Large dataset detected, using chunked transactions', { - itemCount: data.length, - chunkSize - }); - - let totalInserted = 0; - - for (let i = 0; i < data.length; i += chunkSize) { - const chunk = data.slice(i, i + chunkSize); - - await retryFn(async () => { - const inserted = await mongoSessionRun(async (chunkSession) => { - return insertDataIterative(chunk, chunkSession); - }); - totalInserted += inserted; + if (session) { + // Caller's session: all batches share one transaction (backward compatible) + for (let i = 0; i < data.length; i += batchSize) { + insertedCount += await insertBatch(data.slice(i, i + batchSize), session); + logger.debug('Training batch progress (shared session)', { + insertedCount, + total: data.length }); } - - logger.info('Chunked transactions completed', { durationMs: Date.now() - start }); - - return { insertLen: totalInserted }; - } - - // 小数据量单事务处理 - if (session) { - const insertedCount = await insertDataIterative(data, session); - logger.info('Single transaction completed', { durationMs: Date.now() - start }); - return { insertLen: insertedCount }; + logger.info('Training inserts completed (shared session)', { + durationMs: Date.now() - start, + insertedCount + }); } else { - const insertedCount = await mongoSessionRun(async (session) => { - return insertDataIterative(data, session); + // No session: each batch is its own independent transaction + for (let i = 0; i < data.length; i += batchSize) { + const batchCount = await mongoSessionRun(async (batchSession) => { + return insertBatch(data.slice(i, i + batchSize), batchSession); + }); + insertedCount += batchCount; + logger.debug('Training batch committed (per-batch txn)', { + done: i + batchSize, + total: data.length, + insertedCount + }); + } + logger.info('Training inserts completed (per-batch txns)', { + durationMs: Date.now() - start, + insertedCount }); - logger.info('Single transaction completed', { durationMs: Date.now() - start }); - return { insertLen: insertedCount }; } + + pushCollectionUpdateJob({ + collectionId: String(collectionId), + datasetId: String(datasetId), + teamId: String(teamId) + }); + + return { insertLen: insertedCount }; }; export const pushDatasetToParseQueue = async ({ @@ -229,6 +243,7 @@ export const pushDatasetToParseQueue = async ({ datasetId, collectionId, billId, + useGpuQueue, session }: { teamId: string; @@ -236,6 +251,7 @@ export const pushDatasetToParseQueue = async ({ datasetId: string; collectionId: string; billId: string; + useGpuQueue?: boolean; session: ClientSession; }) => { await MongoDatasetTraining.create( @@ -246,9 +262,16 @@ export const pushDatasetToParseQueue = async ({ datasetId, collectionId, billId, - mode: TrainingModeEnum.parse + mode: TrainingModeEnum.parse, + useGpuQueue: !!useGpuQueue } ], { session, ordered: true } ); + + pushCollectionUpdateJob({ + collectionId: String(collectionId), + datasetId: String(datasetId), + teamId: String(teamId) + }); }; diff --git a/packages/service/core/dataset/training/schema.ts b/packages/service/core/dataset/training/schema.ts index cac488a3dfec..68373e5d9a2c 100644 --- a/packages/service/core/dataset/training/schema.ts +++ b/packages/service/core/dataset/training/schema.ts @@ -44,12 +44,6 @@ const TrainingDataSchema = new Schema({ enum: Object.values(TrainingModeEnum), required: true }, - - expireAt: { - // It will be deleted after 7 days - type: Date, - default: () => new Date() - }, lockTime: { type: Date, default: () => new Date('2000/1/1') @@ -82,6 +76,7 @@ const TrainingDataSchema = new Schema({ type: Schema.Types.ObjectId, ref: DatasetDataCollectionName }, + dataMetadata: Object, indexes: { type: [ { @@ -91,13 +86,20 @@ const TrainingDataSchema = new Schema({ }, text: { type: String + }, + synId: { + type: Number } } ], default: [] }, - errorMsg: String + errorMsg: String, + useGpuQueue: { + type: Boolean, + default: false + } }); TrainingDataSchema.virtual('dataset', { @@ -123,7 +125,10 @@ TrainingDataSchema.virtual('data', { TrainingDataSchema.index({ teamId: 1, datasetId: 1 }); // get training data and sort TrainingDataSchema.index({ mode: 1, retryCount: 1, lockTime: 1, weight: -1 }); -TrainingDataSchema.index({ expireAt: 1 }, { expireAfterSeconds: 7 * 24 * 60 * 60 }); // 7 days +// check collection parse/indexing completion — filters out exhausted tasks +// (retryCount <= 0) so they don't block the completion check. +// Used by markParseEnd (mode: parse) and markIndexingEnd (mode: { $ne: parse }). +TrainingDataSchema.index({ collectionId: 1, mode: 1, retryCount: 1 }); export const MongoDatasetTraining = getMongoModel( DatasetTrainingCollectionName, diff --git a/packages/service/core/dataset/training/utils.ts b/packages/service/core/dataset/training/utils.ts new file mode 100644 index 000000000000..5080d1097b75 --- /dev/null +++ b/packages/service/core/dataset/training/utils.ts @@ -0,0 +1,248 @@ +import { MongoDatasetCollection } from '../collection/schema'; +import { MongoDatasetData } from '../data/schema'; +import { MongoDatasetTraining } from './schema'; +import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { getLogger, LogCategories } from '../../../common/logger'; + +const logger = getLogger(LogCategories.MODULE.DATASET); + +// ============================================================ +// Throttle helpers — prevent redundant completion checks +// ============================================================ + +// Each call resets the timer, so the actual DB query only fires once +// after the last chunk completes (instead of once per chunk). +const THROTTLE_MS = 2000; + +const indexingEndTimers = new Map>(); +const parseEndTimers = new Map>(); + +// ============================================================ +// Collection-level timing helpers +// ============================================================ + +/** + * Set parseStartTime on the collection. + * Idempotent — only the first worker to pick up a parse task sets the timestamp. + * + * Non-critical: errors are logged but never thrown. + */ +export const markParseStart = async ({ + collectionId, + startTime +}: { + collectionId: string; + startTime?: Date | null; +}) => { + try { + const st = startTime || new Date(); + await MongoDatasetCollection.updateOne( + { _id: collectionId, parseStartTime: { $exists: false } }, + { $set: { parseStartTime: st } } + ); + } catch (err) { + logger.warn('Failed to set parseStartTime', { collectionId, error: err }); + } +}; + +/** + * Check if all parse tasks for a collection are complete, + * and if so, set parsingCompleteTime on the collection. + * + * **Throttled**: multiple calls within THROTTLE_MS are coalesced — the DB query + * runs at most once per window. The caller's Promise resolves immediately. + * + * Non-critical: errors are logged but never thrown. + */ +export const markParseEnd = async ({ + collectionId, + source +}: { + collectionId: string; + source?: string; +}) => { + const existing = parseEndTimers.get(collectionId); + if (existing) clearTimeout(existing); + + parseEndTimers.set( + collectionId, + setTimeout(async () => { + parseEndTimers.delete(collectionId); + try { + const anyRemaining = await MongoDatasetTraining.findOne( + { collectionId, mode: TrainingModeEnum.parse, retryCount: { $gt: 0 } }, + { _id: 1 } + ).lean(); + if (!anyRemaining) { + await MongoDatasetCollection.updateOne( + { _id: collectionId }, + { $set: { parsingCompleteTime: new Date() } } + ); + logger.info('Collection parsing complete', { collectionId, source }); + } + } catch (err) { + logger.warn('Failed to check collection parse completion', { + collectionId, + source, + error: err + }); + } + }, THROTTLE_MS) + ); +}; + +/** + * Set indexingStartTime on the collection. + * Idempotent — only the first worker to pick up an indexing task sets the timestamp. + * + * Non-critical: errors are logged but never thrown. + */ +export const markIndexingStart = async ({ + collectionId, + startTime +}: { + collectionId: string; + startTime?: Date | null; +}) => { + try { + const st = startTime || new Date(); + await MongoDatasetCollection.updateOne( + { _id: collectionId, indexingStartTime: { $exists: false } }, + { $set: { indexingStartTime: st } } + ); + } catch (err) { + logger.warn('Failed to set indexingStartTime', { collectionId, error: err }); + } +}; + +/** + * Check if all indexing tasks for a collection are complete, + * and if so, set indexingCompleteTime on the collection. + * + * **Throttled**: multiple calls within THROTTLE_MS are coalesced — the DB query + * runs at most once per window. The caller's Promise resolves immediately. + * + * Non-critical: errors are logged but never thrown. + */ +export const markIndexingEnd = async ({ + collectionId, + source +}: { + collectionId: string; + source?: string; +}) => { + const existing = indexingEndTimers.get(collectionId); + if (existing) clearTimeout(existing); + + indexingEndTimers.set( + collectionId, + setTimeout(async () => { + indexingEndTimers.delete(collectionId); + try { + const anyRemaining = await MongoDatasetTraining.findOne( + { collectionId, mode: { $ne: TrainingModeEnum.parse }, retryCount: { $gt: 0 } }, + { _id: 1 } + ).lean(); + if (!anyRemaining) { + await MongoDatasetCollection.updateOne( + { _id: collectionId }, + { $set: { indexingCompleteTime: new Date() } } + ); + logger.info('Collection indexing complete', { collectionId, source }); + } + } catch (err) { + logger.warn('Failed to check collection indexing completion', { + collectionId, + source, + error: err + }); + } + }, THROTTLE_MS) + ); +}; + +// ============================================================ +// Data-level phase timing helpers +// ============================================================ + +/** + * Push a completed phaseTimings entry (startTime + endTime) in a single $push. + * Use when both timestamps are known at the same call site — avoids two separate + * DB writes (start then end) that would otherwise happen back-to-back. + * + * When processing occurs between start and end (e.g. chunk, databaseSchema, small2Big), + * pass startTime through to the completion point and call this once at the end with + * both timestamps. + * + * Non-critical: errors are logged but never thrown. + */ +export const markDataTrainingPhaseTrace = async ({ + dataId, + mode, + startTime, + endTime +}: { + dataId: string; + mode: TrainingModeEnum; + startTime?: Date | null; + endTime?: Date | null; +}) => { + try { + const st = startTime || new Date(); + const et = endTime || new Date(); + await MongoDatasetData.updateOne( + { _id: dataId }, + { $push: { phaseTimings: { phase: mode, startTime: st, endTime: et } } } + ); + } catch (err) { + logger.warn('Failed to write training phase trace', { dataId, mode, error: err }); + } +}; + +/** + * Batch version of markDataTrainingPhaseTrace. + * Uses bulkWrite with ordered:false — individual failures don't block the batch. + * Chunks at BATCH_SIZE to avoid oversized write commands. + * + * Non-critical: errors are logged but never thrown. + */ +const BULK_BATCH_SIZE = 500; + +export const markDataTrainingPhaseTraceBatch = async ({ + items, + mode, + startTime, + endTime +}: { + items: { dataId: string }[]; + mode: TrainingModeEnum; + startTime?: Date | null; + endTime?: Date | null; +}) => { + if (items.length === 0) return; + + const st = startTime || new Date(); + const et = endTime || new Date(); + + for (let offset = 0; offset < items.length; offset += BULK_BATCH_SIZE) { + const batch = items.slice(offset, offset + BULK_BATCH_SIZE); + try { + await MongoDatasetData.bulkWrite( + batch.map(({ dataId }) => ({ + updateOne: { + filter: { _id: dataId }, + update: { $push: { phaseTimings: { phase: mode, startTime: st, endTime: et } } } + } + })), + { ordered: false } + ); + } catch (err) { + logger.warn('Failed to write training phase trace batch', { + mode, + batchStart: offset, + batchSize: batch.length, + error: err + }); + } + } +}; diff --git a/packages/service/core/dataset/utils.ts b/packages/service/core/dataset/utils.ts index 0cc055b434b4..5e0d0416593e 100644 --- a/packages/service/core/dataset/utils.ts +++ b/packages/service/core/dataset/utils.ts @@ -1,11 +1,17 @@ -import { authDatasetByTmbId } from '../../support/permission/dataset/auth'; +import { + authDatasetByTmbId, + getCollectionTmbPermission +} from '../../support/permission/dataset/auth'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { S3Sources } from '../../common/s3/contracts/type'; +// parseDatasetBackup2Chunks 独立文件,避免 worker 打包时带入 S3/jschardet 等无关依赖 +export { parseDatasetBackup2Chunks } from './parseBackup'; import { getS3DatasetSource, S3DatasetSource } from '../../common/s3/sources/dataset'; import { getS3ChatSource } from '../../common/s3/sources/chat'; import { jwtSignS3DownloadToken, isS3ObjectKey } from '../../common/s3/utils'; import { getLogger, LogCategories } from '../../common/logger'; import { S3Buckets } from '../../common/s3/config/constants'; +import { MongoDatasetCollection } from './collection/schema'; const logger = getLogger(LogCategories.MODULE.DATASET.FILE); @@ -37,6 +43,68 @@ export const filterDatasetsByTmbId = async ({ return datasetIds.filter((_, index) => permissions[index]); }; +/** + * Filter collections by team member's read permission. + * Checks collection-level permissions including independent permissions and parent inheritance. + * + * @param datasetIds - Dataset IDs to query collections from + * @param tmbId - Team member ID + * @param teamId - Team ID + * @returns { allPass: true } if all collections pass permission checks, + * { allPass: false, forbiddenIds: string[] } with IDs of collections the user cannot access + */ +export const filterCollectionsByTmbId = async ({ + datasetIds, + tmbId, + teamId +}: { + datasetIds: string[]; + tmbId: string; + teamId: string; +}): Promise<{ allPass: true } | { allPass: false; forbiddenIds: string[] }> => { + const collections = await MongoDatasetCollection.find( + { + datasetId: { $in: datasetIds }, + forbid: { $ne: true }, + deleteTime: null + }, + '_id tmbId datasetId parentId inheritPermission type' + ).lean(); + + if (collections.length === 0) { + return { allPass: true }; + } + + const permissions = await Promise.all( + collections.map(async (col) => { + try { + const per = await getCollectionTmbPermission({ + collection: col as any, + teamId, + tmbId + }); + return per.hasReadPer; + } catch (error) { + logger.warn('Collection access check failed', { + collectionId: col._id, + error + }); + return false; + } + }) + ); + + const forbiddenIds = collections + .filter((_, index) => !permissions[index]) + .map((col) => String(col._id)); + + if (forbiddenIds.length === 0) { + return { allPass: true }; + } + + return { allPass: false, forbiddenIds }; +}; + /** * 替换数据集引用 markdown 文本中的图片链接格式的 S3 对象键为 JWT 签名后的 URL * diff --git a/packages/service/core/evaluation/common.ts b/packages/service/core/evaluation/common.ts new file mode 100644 index 000000000000..c1993c8ab886 --- /dev/null +++ b/packages/service/core/evaluation/common.ts @@ -0,0 +1,482 @@ +import { Types } from 'mongoose'; +import type { AuthModeType } from '../../support/permission/type'; +import { authEvaluation } from '../../support/permission/evaluation/auth'; +import { authEvalDataset, authEvalMetric } from '../../support/permission/evaluation/auth'; +import { authUserPer } from '../../support/permission/user/auth'; +import { TeamEvaluationCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; +import { + ReadPermissionVal, + WritePermissionVal, + PerResourceTypeEnum +} from '@fastgpt/global/support/permission/constant'; +import type { + EvalTarget, + EvaluationWithPerType, + EvaluationItemSchemaType +} from '@fastgpt/global/core/evaluation/type'; +import { authApp } from '../../support/permission/app/auth'; +import { authDataset } from '../../support/permission/dataset/auth'; +import { MongoEvalItem } from './task/schema'; +import { MongoEvalDatasetData } from './dataset/evalDatasetDataSchema'; +import { MongoResourcePermission } from '../../support/permission/schema'; +import { getGroupsByTmbId } from '../../support/permission/memberGroup/controllers'; +import { getOrgIdSetWithParentByTmbId } from '../../support/permission/org/controllers'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; + +export const buildListQuery = ( + teamId: string, + searchKey?: string, + searchFields: string[] = ['name', 'description'] +): any => { + const filter: any = { teamId: new Types.ObjectId(teamId) }; + + if (searchKey) { + filter.$or = searchFields.map((field) => ({ + [field]: { $regex: searchKey, $options: 'i' } + })); + } + + return filter; +}; + +export const buildPaginationOptions = (page: number = 1, pageSize: number = 20) => ({ + skip: (page - 1) * pageSize, + limit: pageSize, + sort: { createTime: -1 as const } +}); + +export const getEvaluationPermissionAggregation = async ( + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + isOwner: boolean; + roleList: any[]; + myGroupMap: Map; + myOrgSet: Set; +}> => { + // Auth user permission - supports API Key and Token authentication + const { + tmbId, + teamId, + permission: teamPer + } = await authUserPer({ + ...auth, + per: ReadPermissionVal + }); + + // Get team all evaluation permissions + const [roleList, myGroupMap, myOrgSet] = await Promise.all([ + MongoResourcePermission.find({ + resourceType: PerResourceTypeEnum.evaluation, + teamId, + resourceId: { + $exists: true + } + }).lean(), + getGroupsByTmbId({ + tmbId, + teamId + }).then((item) => { + const map = new Map(); + item.forEach((item) => { + map.set(String(item._id), 1); + }); + return map; + }), + getOrgIdSetWithParentByTmbId({ + teamId, + tmbId + }) + ]); + + return { + teamId, + tmbId, + isOwner: teamPer.isOwner, + roleList, + myGroupMap, + myOrgSet + }; +}; + +// ================ Evaluation module authorization functions ================ + +export const authEvaluationTaskCreate = async ( + target: EvalTarget, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; +}> => { + const { teamId, tmbId } = await authUserPer({ + ...auth, + per: TeamEvaluationCreatePermissionVal + }); + + if (target.type == 'workflow') { + if (!target.config?.appId) { + return Promise.reject(EvaluationErrEnum.evalTargetAppIdMissing); + } + await authApp({ + ...auth, + appId: target.config.appId, + per: ReadPermissionVal + }); + } + + return { + teamId, + tmbId + }; +}; + +export const authEvaluationTaskRead = async ( + evaluationId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + teamId: string; + tmbId: string; +}> => { + const { evaluation, teamId, tmbId } = await authEvaluation({ + ...auth, + evaluationId, + per: ReadPermissionVal + }); + + return { evaluation, teamId, tmbId }; +}; + +export const authEvaluationTaskWrite = async ( + evaluationId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + teamId: string; + tmbId: string; +}> => { + const { evaluation, teamId, tmbId } = await authEvaluation({ + ...auth, + evaluationId, + per: WritePermissionVal + }); + + return { evaluation, teamId, tmbId }; +}; + +export const authEvaluationTaskExecution = async ( + evaluationId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + teamId: string; + tmbId: string; +}> => { + const { evaluation, teamId, tmbId } = await authEvaluation({ + ...auth, + evaluationId, + per: WritePermissionVal + }); + + if (evaluation.target.type == 'workflow') { + if (!evaluation.target.config?.appId) { + return Promise.reject(EvaluationErrEnum.evalTargetAppIdMissing); + } + await authApp({ + ...auth, + appId: evaluation.target.config.appId, + per: ReadPermissionVal + }); + } + + return { + evaluation, + teamId, + tmbId + }; +}; + +// ================ Evaluation item authorization functions ================ + +export const authEvaluationItemRead = async ( + evalItemId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + evaluationItem: EvaluationItemSchemaType; + teamId: string; + tmbId: string; +}> => { + const evaluationItem = await MongoEvalItem.findById(evalItemId).lean(); + if (!evaluationItem) { + throw new Error(EvaluationErrEnum.evalItemNotFound); + } + + const { teamId, tmbId, evaluation } = await authEvaluationTaskRead(evaluationItem.evalId, auth); + + return { + evaluation, + evaluationItem, + teamId, + tmbId + }; +}; + +export const authEvaluationItemWrite = async ( + evalItemId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + evaluationItem: EvaluationItemSchemaType; + teamId: string; + tmbId: string; +}> => { + const evaluationItem = await MongoEvalItem.findById(evalItemId).lean(); + if (!evaluationItem) { + throw new Error(EvaluationErrEnum.evalItemNotFound); + } + + const { teamId, tmbId, evaluation } = await authEvaluationTaskWrite(evaluationItem.evalId, auth); + + return { + evaluation, + evaluationItem, + teamId, + tmbId + }; +}; + +export const authEvaluationItemRetry = async ( + evalItemId: string, + auth: AuthModeType +): Promise<{ + evaluation: EvaluationWithPerType; + evaluationItem: EvaluationItemSchemaType; + teamId: string; + tmbId: string; +}> => { + return await authEvaluationItemWrite(evalItemId, auth); +}; + +// ================ Evaluation dataset authorization functions ================ + +export const authEvaluationDatasetCreate = async ( + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; +}> => { + const { teamId, tmbId } = await authUserPer({ + ...auth, + per: TeamEvaluationCreatePermissionVal + }); + + return { teamId, tmbId }; +}; +export const authEvaluationDatasetRead = async ( + datasetId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + datasetId: string; +}> => { + const { teamId, tmbId } = await authEvalDataset({ + ...auth, + datasetId, + per: ReadPermissionVal + }); + + return { teamId, tmbId, datasetId }; +}; + +export const authEvaluationDatasetWrite = async ( + datasetId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + datasetId: string; +}> => { + const { teamId, tmbId } = await authEvalDataset({ + ...auth, + datasetId, + per: WritePermissionVal + }); + + return { teamId, tmbId, datasetId }; +}; + +export const authEvaluationDatasetGenFromKnowledgeBase = async ( + collectionId: string, + kbDatasetIds: string[], + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; +}> => { + const { teamId, tmbId } = await authEvaluationDatasetRead(collectionId, auth); + + await Promise.all( + kbDatasetIds.map((datasetId) => + authDataset({ + ...auth, + datasetId, + per: ReadPermissionVal + }) + ) + ); + + return { + teamId, + tmbId + }; +}; + +// ================ Evaluation metric authorization functions ================ + +export const authEvaluationMetricCreate = async ( + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; +}> => { + const { teamId, tmbId } = await authUserPer({ + ...auth, + per: TeamEvaluationCreatePermissionVal + }); + + return { teamId, tmbId }; +}; + +export const authEvaluationMetricRead = async ( + metricId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + metricId: string; +}> => { + const { teamId, tmbId } = await authEvalMetric({ + ...auth, + metricId, + per: ReadPermissionVal + }); + + return { teamId, tmbId, metricId }; +}; +export const authEvaluationMetricWrite = async ( + metricId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + metricId: string; +}> => { + const { teamId, tmbId } = await authEvalMetric({ + ...auth, + metricId, + per: WritePermissionVal + }); + + return { teamId, tmbId, metricId }; +}; + +// ================ Evaluation dataset data authorization functions ================ +export const authEvaluationDatasetDataRead = async ( + collectionId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + const { teamId, tmbId } = await authEvaluationDatasetRead(collectionId, auth); + + return { teamId, tmbId, collectionId }; +}; + +export const authEvaluationDatasetDataWrite = async ( + collectionId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + const { teamId, tmbId } = await authEvaluationDatasetWrite(collectionId, auth); + + return { teamId, tmbId, collectionId }; +}; + +export const authEvaluationDatasetDataCreate = async ( + collectionId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + return await authEvaluationDatasetDataWrite(collectionId, auth); +}; + +export const authEvaluationDatasetDataDelete = async ( + collectionId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + return await authEvaluationDatasetDataWrite(collectionId, auth); +}; + +export const authEvaluationDatasetDataUpdate = async ( + collectionId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + return await authEvaluationDatasetDataWrite(collectionId, auth); +}; + +export const authEvaluationDatasetDataUpdateById = async ( + dataId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + const dataItem = await MongoEvalDatasetData.findById(dataId) + .select('evalDatasetCollectionId') + .lean(); + if (!dataItem) { + throw new Error(EvaluationErrEnum.datasetDataNotFound); + } + + const collectionId = String(dataItem.evalDatasetCollectionId); + return await authEvaluationDatasetDataUpdate(collectionId, auth); +}; + +export const authEvaluationDatasetDataReadById = async ( + dataId: string, + auth: AuthModeType +): Promise<{ + teamId: string; + tmbId: string; + collectionId: string; +}> => { + const dataItem = await MongoEvalDatasetData.findById(dataId) + .select('evalDatasetCollectionId') + .lean(); + if (!dataItem) { + throw new Error(EvaluationErrEnum.datasetDataNotFound); + } + + const collectionId = String(dataItem.evalDatasetCollectionId); + return await authEvaluationDatasetDataRead(collectionId, auth); +}; diff --git a/packages/service/core/evaluation/dataset/csvUtils.ts b/packages/service/core/evaluation/dataset/csvUtils.ts new file mode 100644 index 000000000000..0bd75179f806 --- /dev/null +++ b/packages/service/core/evaluation/dataset/csvUtils.ts @@ -0,0 +1,116 @@ +import Papa from 'papaparse'; + +// Interface for CSV row data structure +export interface CSVRow { + user_input: string; + expected_output: string; + actual_output?: string; + context?: string; + retrieval_context?: string; + metadata?: string; +} + +// Required CSV columns that must be present +export const REQUIRED_CSV_COLUMNS = ['user_input', 'expected_output'] as const; + +// Optional CSV columns that can be present +export const OPTIONAL_CSV_COLUMNS = [ + 'actual_output', + 'context', + 'retrieval_context', + 'metadata' +] as const; + +// All valid CSV columns +export const CSV_COLUMNS = [...REQUIRED_CSV_COLUMNS, ...OPTIONAL_CSV_COLUMNS] as const; + +// Enum to CSV mapping for header normalization +export const ENUM_TO_CSV_MAPPING = { + userInput: 'user_input', + expectedOutput: 'expected_output', + actualOutput: 'actual_output', + context: 'context', + retrievalContext: 'retrieval_context' +} as const; + +/** + * Normalize header names by mapping enum values to CSV column names + * @param header - The header string to normalize + * @returns The normalized header name + */ +function normalizeHeaderName(header: string): string { + // For most CSV files, headers should be used as-is + // Only apply mapping if the header matches an enum value exactly + const mappedValue = ENUM_TO_CSV_MAPPING[header as keyof typeof ENUM_TO_CSV_MAPPING]; + return mappedValue || header; +} + +/** + * Parse CSV content using Papa Parse with optimized performance settings + * @param csvContent - The raw CSV content string + * @returns Array of parsed CSV rows + * @throws Error if CSV parsing fails or required columns are missing + */ +export function parseCSVContent(csvContent: string): CSVRow[] { + if (!csvContent.trim()) { + return []; + } + + // Parse CSV with Papa Parse for optimal performance + const parseResult = Papa.parse(csvContent, { + header: true, + skipEmptyLines: true, + fastMode: false, // Disable fastMode to handle complex quoted fields + transformHeader: (header: string) => { + // Remove quotes and normalize header names + const cleanHeader = header.replace(/^"|"$/g, '').trim(); + return normalizeHeaderName(cleanHeader); + } + }); + + if (parseResult.errors.length > 0) { + const error = parseResult.errors[0]; + throw new Error(`CSV parsing error at row ${error.row + 1}: ${error.message}`); + } + + const data = parseResult.data as Record[]; + + if (data.length === 0) { + return []; + } + + // Get normalized headers from the first data row + const headers = Object.keys(data[0]); + + // Validate CSV structure + const missingColumns = REQUIRED_CSV_COLUMNS.filter((col) => !headers.includes(col)); + if (missingColumns.length > 0) { + throw new Error(`CSV file is missing required columns: ${missingColumns.join(', ')}`); + } + + // Convert to CSVRow format + const rows: CSVRow[] = data.map((rowData) => { + const row: CSVRow = { + user_input: (rowData.user_input || '').trim(), + expected_output: (rowData.expected_output || '').trim() + }; + + // Add optional fields if they exist + if ('actual_output' in rowData) { + row.actual_output = (rowData.actual_output || '').trim(); + } + if ('context' in rowData) { + row.context = (rowData.context || '').trim(); + } + if ('retrieval_context' in rowData) { + row.retrieval_context = (rowData.retrieval_context || '').trim(); + } + if ('metadata' in rowData) { + row.metadata = (rowData.metadata || '{}').trim(); + } + + return row; + }); + + return rows; +} diff --git a/packages/service/core/evaluation/dataset/dataQualityMq.ts b/packages/service/core/evaluation/dataset/dataQualityMq.ts new file mode 100644 index 000000000000..8a3ca5b10080 --- /dev/null +++ b/packages/service/core/evaluation/dataset/dataQualityMq.ts @@ -0,0 +1,191 @@ +import { getQueue, getWorker, QueueNames } from '../../../common/bullmq'; +import { type Processor } from 'bullmq'; +import { addLog } from '../../../common/system/log'; +import { MongoEvalDatasetData } from './evalDatasetDataSchema'; +import { EvalDatasetDataQualityStatusEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; +import { + createJobCleaner, + type JobCleanupResult, + type JobCleanupOptions, + checkBullMQHealth +} from '../../../common/bullmq/utils'; + +export type EvalDatasetDataQualityData = { + dataId: string; + evaluationModelId: string; +}; + +export const evalDatasetDataQualityQueue = getQueue( + QueueNames.evalDatasetDataQuality +); + +const concurrency = global.systemEnv?.evalConfig?.dataQualityConcurrency || 2; + +export const getEvalDatasetDataQualityWorker = ( + processor: Processor +) => { + const worker = getWorker( + QueueNames.evalDatasetDataQuality, + processor, + { + maxStalledCount: global.systemEnv?.evalConfig?.maxStalledCount || 3, + removeOnFail: {}, + concurrency: concurrency + } + ); + + // When a job stalls (moves from active to waiting state) + worker.on('stalled', async (jobId: string) => { + try { + const job = await evalDatasetDataQualityQueue.getJob(jobId); + if (!job) return; + + addLog.warn('Data quality job stalled', { + jobId: job.id, + dataId: job.data.dataId + }); + + await MongoEvalDatasetData.findByIdAndUpdate(job.data.dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.queueTime': new Date() + } + }); + + addLog.info('Updated stalled job status to queuing', { + jobId: job.id, + dataId: job.data.dataId + }); + } catch (error) { + addLog.error('Failed to handle stalled job', { + jobId, + error + }); + } + }); + + worker.on('failed', async (job, err) => { + try { + if (!job) { + addLog.error('Data quality job failed but job is undefined', { error: err }); + return; + } + + addLog.error('Data quality job failed', { + jobId: job.id, + dataId: job.data.dataId, + error: err + }); + + // Check if failure is due to permanent stall limit exceeded + const isStallLimitExceeded = + err && + (err.message?.includes('stalled more than allowable limit') || + err.message?.includes('job stalled more than')); + + if (isStallLimitExceeded) { + await MongoEvalDatasetData.findByIdAndUpdate(job.data.dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.error, + 'qualityMetadata.error': 'Job stalled more than allowable limit and failed permanently', + 'qualityMetadata.finishTime': new Date() + } + }); + + addLog.error('Updated permanently failed job status to error', { + jobId: job.id, + dataId: job.data.dataId, + reason: 'stall limit exceeded' + }); + } + // For other failure types, let the processor handle the status update + } catch (updateError) { + addLog.error('Failed to handle failed job', { + jobId: job?.id, + dataId: job?.data?.dataId, + error: updateError + }); + } + }); + + return worker; +}; + +export const addEvalDatasetDataQualityJob = (data: EvalDatasetDataQualityData) => { + const dataId = String(data.dataId); + + return evalDatasetDataQualityQueue.add(dataId, data, { deduplication: { id: dataId } }); +}; + +export const addEvalDatasetDataQualityBulk = (dataArray: EvalDatasetDataQualityData[]) => { + const jobs = dataArray.map((data) => ({ + name: String(data.dataId), + data, + opts: { deduplication: { id: String(data.dataId) } } + })); + + return evalDatasetDataQualityQueue.addBulk(jobs); +}; + +export const checkEvalDatasetDataQualityJobActive = async (dataId: string): Promise => { + try { + const jobId = await evalDatasetDataQualityQueue.getDeduplicationJobId(String(dataId)); + if (!jobId) return false; + + const job = await evalDatasetDataQualityQueue.getJob(jobId); + if (!job) return false; + + const jobState = await job.getState(); + return ['waiting', 'delayed', 'prioritized', 'active'].includes(jobState); + } catch (error) { + addLog.error('Failed to check eval dataset data quality job status', { dataId, error }); + return false; + } +}; + +export const checkEvalDatasetDataQualityJobInactive = async (dataId: string): Promise => { + try { + const jobId = await evalDatasetDataQualityQueue.getDeduplicationJobId(String(dataId)); + if (!jobId) return false; + + const job = await evalDatasetDataQualityQueue.getJob(jobId); + if (!job) return false; + + const jobState = await job.getState(); + return ['completed', 'failed'].includes(jobState); + } catch (error) { + addLog.error('Failed to check eval dataset data quality job inactive status', { + dataId, + error + }); + return false; + } +}; + +export const removeEvalDatasetDataQualityJobsRobust = async ( + dataIds: string[], + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + + const filterFn = (job: any) => { + return dataIds.includes(String(job.data?.dataId)); + }; + + const result = await cleaner.cleanAllJobsByFilter( + evalDatasetDataQualityQueue, + filterFn, + QueueNames.evalDatasetDataQuality + ); + + addLog.info('Evaluation DatasetData quality jobs cleanup completed', { + dataIds: dataIds.length, + result + }); + + return result; +}; + +export const checkEvalDatasetDataQualityQueueHealth = (): Promise => { + return checkBullMQHealth(evalDatasetDataQualityQueue, 'quality'); +}; diff --git a/packages/service/core/evaluation/dataset/dataQualityProcessor.ts b/packages/service/core/evaluation/dataset/dataQualityProcessor.ts new file mode 100644 index 000000000000..f4c7341a4adb --- /dev/null +++ b/packages/service/core/evaluation/dataset/dataQualityProcessor.ts @@ -0,0 +1,150 @@ +import type { Job } from 'bullmq'; +import { addLog } from '../../../common/system/log'; +import { MongoEvalDatasetData } from './evalDatasetDataSchema'; +import { getEvalDatasetDataQualityWorker, type EvalDatasetDataQualityData } from './dataQualityMq'; +import { + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; +import { createEvaluatorInstance } from '../evaluator'; +import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; +import { createEvalDatasetDataQualityUsage } from '../../../support/wallet/usage/controller'; +import { getLLMModelById } from '../../ai/model'; + +// Queue processor function +export const processEvalDatasetDataQuality = async (job: Job) => { + const { dataId, evaluationModelId } = job.data; + + addLog.info('Processing eval dataset data quality job', { dataId, evaluationModelId }); + + if (!getLLMModelById(evaluationModelId)) { + const errorMsg = `Invalid evaluation model: ${evaluationModelId}`; + addLog.error('Eval dataset data quality job failed - invalid model', { + dataId, + evaluationModelId + }); + + await MongoEvalDatasetData.findByIdAndUpdate(dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.error, + 'qualityMetadata.error': errorMsg, + 'qualityMetadata.finishTime': new Date() + } + }); + + throw new Error(errorMsg); + } + + try { + await MongoEvalDatasetData.findByIdAndUpdate(dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.evaluating, + 'qualityMetadata.startTime': new Date() + } + }); + + const datasetData = await MongoEvalDatasetData.findById(dataId); + if (!datasetData) { + throw new Error(`Dataset data not found: ${dataId}`); + } + + // Check AI points limit + await checkTeamAIPoints(datasetData.teamId); + + // Create evaluator instance and run evaluation + const metricSchema: EvalMetricSchemaType = { + _id: '', + teamId: '', + tmbId: '', + name: 'q_a_quality', + type: EvalMetricTypeEnum.Builtin, + userInputRequired: true, + actualOutputRequired: false, + expectedOutputRequired: true, + contextRequired: false, + retrievalContextRequired: false, + embeddingRequired: false, + llmRequired: true, + createTime: new Date(), + updateTime: new Date() + }; + + const evaluatorConfig = { + metric: metricSchema, + runtimeConfig: { + llmId: evaluationModelId + } + }; + + const evalCase = { + [EvalDatasetDataKeyEnum.UserInput]: datasetData.userInput, + [EvalDatasetDataKeyEnum.ActualOutput]: datasetData.actualOutput, + [EvalDatasetDataKeyEnum.ExpectedOutput]: datasetData.expectedOutput, + [EvalDatasetDataKeyEnum.Context]: datasetData.context, + [EvalDatasetDataKeyEnum.RetrievalContext]: datasetData.retrievalContext + }; + + const evaluator = await createEvaluatorInstance(evaluatorConfig, { validate: false }); + const metricResult = await evaluator.evaluate(evalCase); + + if (metricResult.status === 'success' && metricResult.data) { + // Save usage + let totalPoints = 0; + if (metricResult.usages?.length) { + const { totalPoints: calculatedPoints } = await createEvalDatasetDataQualityUsage({ + teamId: datasetData.teamId, + tmbId: datasetData.tmbId, + modelId: getLLMModelById(evaluationModelId)?.id || evaluationModelId, + usages: metricResult.usages + }); + totalPoints = calculatedPoints; + } + + const qualityResult = + metricResult.data.score >= 0.7 + ? EvalDatasetDataQualityResultEnum.highQuality + : EvalDatasetDataQualityResultEnum.needsOptimization; + + await MongoEvalDatasetData.findByIdAndUpdate(dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.completed, + 'qualityMetadata.score': metricResult.data.score, + 'qualityMetadata.reason': metricResult.data?.reason, + 'qualityMetadata.runLogs': metricResult.data?.runLogs, + 'qualityMetadata.usages': metricResult?.usages, + 'qualityMetadata.finishTime': new Date(), + 'qualityMetadata.modelId': evaluationModelId, + qualityResult: qualityResult + } + }); + + addLog.info('Eval dataset data quality job completed successfully', { + dataId, + score: metricResult.data.score, + totalPoints + }); + } else { + throw new Error(metricResult.error || 'Evaluation failed'); + } + } catch (error) { + addLog.error('Eval dataset data quality job failed', { dataId, error }); + + await MongoEvalDatasetData.findByIdAndUpdate(dataId, { + $set: { + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.error, + 'qualityMetadata.error': error instanceof Error ? error.message : 'Unknown error', + 'qualityMetadata.finishTime': new Date() + } + }); + + throw error; + } +}; + +// Initialize worker +export const initEvalDatasetDataQualityWorker = () => { + return getEvalDatasetDataQualityWorker(processEvalDatasetDataQuality); +}; diff --git a/packages/service/core/evaluation/dataset/dataSynthesizeMq.ts b/packages/service/core/evaluation/dataset/dataSynthesizeMq.ts new file mode 100644 index 000000000000..6e27fc7792bd --- /dev/null +++ b/packages/service/core/evaluation/dataset/dataSynthesizeMq.ts @@ -0,0 +1,86 @@ +import { getQueue, getWorker, QueueNames } from '../../../common/bullmq'; +import { type Processor } from 'bullmq'; +import { addLog } from '../../../common/system/log'; +import { + createJobCleaner, + type JobCleanupResult, + type JobCleanupOptions, + checkBullMQHealth +} from '../../../common/bullmq/utils'; + +export type EvalDatasetDataSynthesizeData = { + dataId: string; + intelligentGenerationModelId: string; + evalDatasetCollectionId: string; +}; + +export const evalDatasetDataSynthesizeQueue = getQueue( + QueueNames.evalDatasetDataSynthesize +); + +const concurrency = global.systemEnv?.evalConfig?.datasetDataSynthesizeConcurrency || 5; + +export const getEvalDatasetDataSynthesizeWorker = ( + processor: Processor +) => { + return getWorker(QueueNames.evalDatasetDataSynthesize, processor, { + maxStalledCount: global.systemEnv?.evalConfig?.maxStalledCount || 3, + removeOnFail: {}, + concurrency: concurrency + }); +}; + +export const addEvalDatasetDataSynthesizeBulk = (dataArray: EvalDatasetDataSynthesizeData[]) => { + const jobs = dataArray.map((data, index) => ({ + name: `synthesize-${data.dataId}-${Date.now()}-${index}`, + data, + opts: { + deduplication: { id: `synthesize-${data.dataId}-${Date.now()}-${index}` } + } + })); + + return evalDatasetDataSynthesizeQueue.addBulk(jobs); +}; + +export const checkEvalDatasetDataSynthesizeJobActive = async ( + evalDatasetCollectionId: string +): Promise => { + try { + const jobs = await evalDatasetDataSynthesizeQueue.getJobs(['waiting', 'active', 'delayed']); + return jobs.some((job) => job.data.evalDatasetCollectionId === evalDatasetCollectionId); + } catch (error) { + addLog.error('Failed to check eval dataset data synthesize job status', { + evalDatasetCollectionId: evalDatasetCollectionId, + error + }); + return false; + } +}; + +export const removeEvalDatasetDataSynthesizeJobsRobust = async ( + evalDatasetCollectionIds: string[], + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + + const filterFn = (job: any) => { + return evalDatasetCollectionIds.includes(String(job.data?.evalDatasetCollectionId)); + }; + + const result = await cleaner.cleanAllJobsByFilter( + evalDatasetDataSynthesizeQueue, + filterFn, + QueueNames.evalDatasetDataSynthesize + ); + + addLog.info('Evaluation DatasetData synthesize jobs cleanup completed', { + evalDatasetCollectionIds: evalDatasetCollectionIds.length, + result + }); + + return result; +}; + +export const checkEvalDatasetDataSynthesizeQueueHealth = (): Promise => { + return checkBullMQHealth(evalDatasetDataSynthesizeQueue, 'synthesis'); +}; diff --git a/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts b/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts new file mode 100644 index 000000000000..0059804e3678 --- /dev/null +++ b/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts @@ -0,0 +1,142 @@ +import type { Job } from 'bullmq'; +import { addLog } from '../../../common/system/log'; +import { MongoEvalDatasetCollection } from './evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from './evalDatasetDataSchema'; +import { MongoDatasetData } from '../../dataset/data/schema'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import type { EvalDatasetDataSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; +import { + type EvalDatasetDataSynthesizeData, + getEvalDatasetDataSynthesizeWorker +} from './dataSynthesizeMq'; +import { createSynthesizerInstance } from '../synthesizer'; +import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; +import { createEvalDatasetDataSynthesisUsage } from '../../../support/wallet/usage/controller'; +import { getLLMModelById } from '../../ai/model'; +import { addAuditLog } from '../../../support/user/audit/util'; +import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; + +async function processor(job: Job) { + const { dataId, intelligentGenerationModelId, evalDatasetCollectionId } = job.data; + + try { + addLog.info('Starting eval dataset data synthesis', { + dataId, + evalDatasetCollectionId, + intelligentGenerationModelId + }); + + const sourceData = await MongoDatasetData.findById(dataId); + if (!sourceData) { + throw new Error(`Source dataset data not found: ${dataId}`); + } + + const evalDatasetCollection = + await MongoEvalDatasetCollection.findById(evalDatasetCollectionId); + if (!evalDatasetCollection) { + throw new Error(`Eval dataset not found: ${evalDatasetCollectionId}`); + } + + // Check AI points limit before synthesis + await checkTeamAIPoints(evalDatasetCollection.teamId); + + const llmConfig = { + modelId: intelligentGenerationModelId + }; + + const synthesisCase = { + context: sourceData.q ? [sourceData.q] : [] + }; + + const synthesizer = createSynthesizerInstance('q_a_synthesizer', llmConfig); + const synthesisResult = await synthesizer.synthesize(synthesisCase); + + // Save usage + let totalPoints = 0; + if (synthesisResult.usages?.length) { + const { totalPoints: calculatedPoints } = await createEvalDatasetDataSynthesisUsage({ + teamId: evalDatasetCollection.teamId, + tmbId: evalDatasetCollection.tmbId, + modelId: getLLMModelById(intelligentGenerationModelId)?.id || intelligentGenerationModelId, + usages: synthesisResult.usages + }); + totalPoints = calculatedPoints; + } + + const qualityResult = + synthesisResult.data?.metadata?.score && synthesisResult.data.metadata.score >= 0.7 + ? EvalDatasetDataQualityResultEnum.highQuality + : EvalDatasetDataQualityResultEnum.needsOptimization; + + const evalData: Partial = { + teamId: evalDatasetCollection.teamId, + tmbId: evalDatasetCollection.tmbId, + evalDatasetCollectionId: evalDatasetCollectionId, + [EvalDatasetDataKeyEnum.UserInput]: synthesisResult.data?.qaPair.question, + [EvalDatasetDataKeyEnum.ExpectedOutput]: synthesisResult.data?.qaPair.answer, + [EvalDatasetDataKeyEnum.ActualOutput]: '', + [EvalDatasetDataKeyEnum.Context]: [], + [EvalDatasetDataKeyEnum.RetrievalContext]: [], + qualityMetadata: { + status: EvalDatasetDataQualityStatusEnum.completed, + score: synthesisResult.data?.metadata?.score, + reason: synthesisResult.data?.metadata?.reason, + usages: synthesisResult?.usages, + finishTime: new Date() + }, + synthesisMetadata: { + sourceDataId: sourceData._id.toString(), + sourceDatasetId: sourceData.datasetId.toString(), + sourceCollectionId: sourceData.collectionId.toString(), + intelligentGenerationModelId: intelligentGenerationModelId, + generatedAt: new Date(), + synthesizedAt: new Date() + }, + qualityResult, + createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration + }; + + const insertedRecord = await MongoEvalDatasetData.create(evalData); + + addLog.info('Completed data synthesis', { + dataId, + evalDatasetCollectionId, + insertedRecordId: insertedRecord._id, + totalPoints + }); + + (async () => { + addAuditLog({ + teamId: evalDatasetCollection.teamId, + tmbId: evalDatasetCollection.tmbId, + event: AuditEventEnum.SMART_GENERATE_EVALUATION_DATA, + params: { + collectionName: evalDatasetCollection.name + } + }); + })(); + + return { + success: true, + insertedRecordId: insertedRecord._id + }; + } catch (error) { + addLog.error('Failed to synthesize eval dataset data', { + dataId, + evalDatasetCollectionId, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + throw error; + } +} + +// Initialize worker +export const initEvalDatasetDataSynthesizeWorker = () => { + return getEvalDatasetDataSynthesizeWorker(processor); +}; diff --git a/packages/service/core/evaluation/dataset/evalDatasetCollectionSchema.ts b/packages/service/core/evaluation/dataset/evalDatasetCollectionSchema.ts new file mode 100644 index 000000000000..8edd78c14c19 --- /dev/null +++ b/packages/service/core/evaluation/dataset/evalDatasetCollectionSchema.ts @@ -0,0 +1,71 @@ +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import type { EvalDatasetCollectionSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; + +const { Schema } = connectionMongo; + +export const EvalDatasetCollectionName = 'eval_dataset_collections'; + +const EvalDatasetCollectionSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true, + index: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + description: { + type: String, + default: '', + trim: true, + maxlength: 100 + }, + createTime: { + type: Date, + default: Date.now, + immutable: true + }, + updateTime: { + type: Date, + default: Date.now + }, + metadata: { + type: Schema.Types.Mixed, + default: {} + }, + evaluationModelId: { + type: String, + trim: true, + maxlength: 100 + } +}); + +// Indexes for efficient queries +EvalDatasetCollectionSchema.index({ teamId: 1, createTime: -1 }); +EvalDatasetCollectionSchema.index({ teamId: 1, name: 1 }, { unique: true }); +EvalDatasetCollectionSchema.index({ teamId: 1, updateTime: -1 }); + +// Update the updateTime on save +EvalDatasetCollectionSchema.pre('save', function () { + if (this.isModified() && !this.isNew) { + this.updateTime = new Date(); + } +}); + +export const MongoEvalDatasetCollection = getMongoModel( + EvalDatasetCollectionName, + EvalDatasetCollectionSchema +); diff --git a/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts new file mode 100644 index 000000000000..f953fec41056 --- /dev/null +++ b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts @@ -0,0 +1,172 @@ +import type { EvalDatasetDataSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import { EvalDatasetCollectionName } from './evalDatasetCollectionSchema'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataCreateFromValues, + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum, + EvalDatasetDataQualityResultValues +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; + +const { Schema } = connectionMongo; + +export const EvalDatasetDataCollectionName = 'eval_dataset_datas'; + +const EvalDatasetDataSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true, + index: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + }, + evalDatasetCollectionId: { + type: Schema.Types.ObjectId, + ref: EvalDatasetCollectionName, + required: true, + index: true + }, + [EvalDatasetDataKeyEnum.UserInput]: { + type: String, + default: '', + trim: true + }, + [EvalDatasetDataKeyEnum.ActualOutput]: { + type: String, + default: '', + trim: true + }, + [EvalDatasetDataKeyEnum.ExpectedOutput]: { + type: String, + default: '', + trim: true + }, + [EvalDatasetDataKeyEnum.Context]: { + type: [ + { + type: String, + trim: true + } + ], + default: [], + validate: { + validator: (arr: string[]) => arr.length <= 100, + message: 'Context array cannot exceed 100 items' + } + }, + [EvalDatasetDataKeyEnum.RetrievalContext]: { + type: [ + { + type: String, + trim: true + } + ], + default: [] + }, + [EvalDatasetDataKeyEnum.RetrievalContextsFull]: { + type: [ + { + id: { type: String, required: true }, + score: [ + { + type: { type: String }, + value: Number, + index: Number + } + ] + } + ], + default: [] + }, + [EvalDatasetDataKeyEnum.ExpectedContextIds]: { + type: [String], + default: [] + }, + qualityMetadata: { + status: { + type: String, + enum: Object.values(EvalDatasetDataQualityStatusEnum), + default: EvalDatasetDataQualityStatusEnum.unevaluated, + required: true + }, + score: { + type: Number, + min: 0, + max: 1 + }, + reason: String, + modelId: String, + usages: [Schema.Types.Mixed], + runLogs: [Schema.Types.Mixed], + startTime: Date, + finishTime: Date, + queueTime: Date, + error: String + }, + synthesisMetadata: { + sourceDataId: String, + sourceDatasetId: String, + sourceCollectionId: String, + intelligentGenerationModelId: String, + synthesizedAt: Date, + generatedAt: Date + }, + qualityResult: { + type: String, + enum: EvalDatasetDataQualityResultValues + }, + createFrom: { + type: String, + enum: EvalDatasetDataCreateFromValues, + default: EvalDatasetDataCreateFromEnum.manual, + required: true, + index: true + }, + createTime: { + type: Date, + default: Date.now, + immutable: true + }, + updateTime: { + type: Date, + default: Date.now + } +}); + +// Indexes for efficient queries +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, createTime: -1 }); +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, updateTime: -1 }); + +// Quality related indexes +EvalDatasetDataSchema.index({ 'qualityMetadata.status': 1 }); +EvalDatasetDataSchema.index({ qualityResult: 1 }); +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, qualityResult: 1 }); + +// Text search index for searching within inputs and outputs +EvalDatasetDataSchema.index({ + [EvalDatasetDataKeyEnum.UserInput]: 'text', + [EvalDatasetDataKeyEnum.ExpectedOutput]: 'text', + [EvalDatasetDataKeyEnum.ActualOutput]: 'text' +}); + +// Update the updateTime on save +EvalDatasetDataSchema.pre('save', function () { + if (this.isModified() && !this.isNew) { + this.updateTime = new Date(); + } +}); + +export const MongoEvalDatasetData = getMongoModel( + EvalDatasetDataCollectionName, + EvalDatasetDataSchema +); diff --git a/packages/service/core/evaluation/dataset/utils.ts b/packages/service/core/evaluation/dataset/utils.ts new file mode 100644 index 000000000000..e4a9bfc217ed --- /dev/null +++ b/packages/service/core/evaluation/dataset/utils.ts @@ -0,0 +1,173 @@ +import type { EvalDatasetCollectionStatus } from '@fastgpt/global/core/evaluation/dataset/type'; +import { EvalDatasetCollectionStatusEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; +import { evalDatasetDataSynthesizeQueue } from './dataSynthesizeMq'; +import { getEvaluationPermissionAggregation } from '../common'; +import { replaceRegChars } from '@fastgpt/global/common/string/tools'; +import { Types } from 'mongoose'; +import type { ApiRequestProps } from '../../../type/next'; + +export async function getCollectionStatus( + collectionId: string +): Promise { + try { + const jobs = await evalDatasetDataSynthesizeQueue.getJobs([ + 'waiting', + 'active', + 'delayed', + 'failed', + 'completed' + ]); + const collectionJobs = jobs.filter((job) => job.data.evalDatasetCollectionId === collectionId); + + if (collectionJobs.length === 0) { + return EvalDatasetCollectionStatusEnum.ready; + } + + let hasActive = false; + let hasWaiting = false; + let hasFailed = false; + + for (const job of collectionJobs) { + if (await job.isActive()) { + hasActive = true; + } else if ((await job.isWaiting()) || (await job.isDelayed())) { + hasWaiting = true; + } else if (await job.isFailed()) { + hasFailed = true; + } + } + + if (hasActive) { + return EvalDatasetCollectionStatusEnum.processing; + } + if (hasWaiting) { + return EvalDatasetCollectionStatusEnum.queuing; + } + if (hasFailed) { + return EvalDatasetCollectionStatusEnum.error; + } + + return EvalDatasetCollectionStatusEnum.ready; + } catch (error) { + console.error('Error getting collection status:', error); + throw new Error( + `Failed to get collection status: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function buildCollectionAggregationPipeline(baseFields?: Record) { + return [ + { + $lookup: { + from: 'team_members', + localField: 'tmbId', + foreignField: '_id', + as: 'teamMember' + } + }, + { + $lookup: { + from: 'eval_dataset_datas', + localField: '_id', + foreignField: 'evalDatasetCollectionId', + as: 'dataItems' + } + }, + { + $addFields: { + teamMember: { $arrayElemAt: ['$teamMember', 0] }, + dataItemsCount: { $size: '$dataItems' } + } + }, + { + $project: { + _id: 1, + name: 1, + description: 1, + evaluationModelId: 1, + createTime: 1, + updateTime: 1, + creatorAvatar: '$teamMember.avatar', + creatorName: '$teamMember.name', + dataItemsCount: 1, + ...baseFields + } + } + ]; +} + +export function formatCollectionBase(collection: any) { + return { + _id: String(collection._id), + name: collection.name, + description: collection.description || '', + evaluationModelId: collection.evaluationModelId, + createTime: collection.createTime, + updateTime: collection.updateTime, + creatorAvatar: collection.creatorAvatar, + creatorName: collection.creatorName, + dataItemsCount: collection.dataItemsCount + }; +} + +export async function buildEvalDatasetCollectionFilter( + req: ApiRequestProps<{ searchKey?: string }>, + searchKey?: string +) { + // API layer permission validation: get permission aggregation info + const { teamId, tmbId, isOwner, roleList, myGroupMap, myOrgSet } = + await getEvaluationPermissionAggregation({ + req, + authApiKey: true, + authToken: true + }); + + // Calculate resource IDs accessible by user + const myRoles = roleList.filter( + (item) => + String(item.tmbId) === String(tmbId) || + myGroupMap.has(String(item.groupId)) || + myOrgSet.has(String(item.orgId)) + ); + const accessibleIds = myRoles.map((item) => String(item.resourceId)); + + // Build unified filter conditions + const baseFilter: Record = { + teamId: new Types.ObjectId(teamId) + }; + + if (searchKey && typeof searchKey === 'string' && searchKey.trim().length > 0) { + baseFilter.name = { $regex: new RegExp(`${replaceRegChars(searchKey.trim())}`, 'i') }; + } + + // Unified permission filtering logic + let finalFilter = baseFilter; + if (!isOwner) { + if (accessibleIds.length > 0) { + finalFilter = { + ...baseFilter, + $or: [ + { _id: { $in: accessibleIds.map((id) => new Types.ObjectId(id)) } }, + { tmbId: new Types.ObjectId(tmbId) } // Own datasets + ] + }; + } else { + // If no permission roles, can only access self-created datasets + finalFilter = { + ...baseFilter, + tmbId: new Types.ObjectId(tmbId) + }; + } + } + + return { + finalFilter, + teamId, + tmbId, + isOwner, + myRoles, + accessibleIds, + roleList + }; +} diff --git a/packages/service/core/evaluation/evaluator/ditingClient.ts b/packages/service/core/evaluation/evaluator/ditingClient.ts new file mode 100644 index 000000000000..f29c295e3ac1 --- /dev/null +++ b/packages/service/core/evaluation/evaluator/ditingClient.ts @@ -0,0 +1,71 @@ +import type { + HttpConfig, + EvaluationRequest, + EvaluationResponse +} from '@fastgpt/global/core/evaluation/metric/type'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; + +function loadHttpConfigFromEnv(): HttpConfig { + return { + url: process.env.DITING_BASE_URL || 'http://diting:3000', + timeout: Number(process.env.DITING_TIMEOUT) || 300000 + }; +} + +export function createDitingClient(config: HttpConfig = loadHttpConfigFromEnv()) { + function getFullUrl(path: string) { + return config.url.replace(/\/$/, '') + path; + } + + return { + async runEvaluation(request: EvaluationRequest): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + + try { + const res = await fetch(getFullUrl('/api/v1/evaluations/runs'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + if (res.status >= 500) { + throw new Error(EvaluationErrEnum.evaluatorServiceUnavailable); + } else { + throw new Error(EvaluationErrEnum.evaluatorInvalidResponse); + } + } + + const response = await res.json(); + if (!response) { + throw new Error(EvaluationErrEnum.evaluatorInvalidResponse); + } + + return response as EvaluationResponse; + } catch (err: any) { + clearTimeout(timeoutId); + + if (err.name === 'AbortError') { + throw new Error(EvaluationErrEnum.evaluatorRequestTimeout); + } + + const networkErrorCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ECONNRESET', 'ETIMEDOUT']; + if (err.code && networkErrorCodes.includes(err.code)) { + throw new Error(EvaluationErrEnum.evaluatorNetworkError); + } + + if (Object.values(EvaluationErrEnum).includes(err.message)) { + new Error(err.message); + } + + throw new Error(EvaluationErrEnum.evaluatorServiceUnavailable); + } + } + }; +} diff --git a/packages/service/core/evaluation/evaluator/index.ts b/packages/service/core/evaluation/evaluator/index.ts new file mode 100644 index 000000000000..d1db8c1a8fbb --- /dev/null +++ b/packages/service/core/evaluation/evaluator/index.ts @@ -0,0 +1,312 @@ +import type { + EvalMetricSchemaType, + EvalCase, + MetricResult, + EvaluationResponse, + MetricConfig, + EvalModelConfigType +} from '@fastgpt/global/core/evaluation/metric/type'; +import type { EvaluatorSchema } from '@fastgpt/global/core/evaluation/type'; +import { + Validatable, + type ValidationResult, + type ValidationError +} from '@fastgpt/global/core/evaluation/validate'; +import { getEvaluationModelById, getEmbeddingModelById } from '../../ai/model'; +import { getModelEndpointConfig } from '../../ai/config'; +import { createDitingClient } from './ditingClient'; +import { formatModelChars2Points } from '../../../support/wallet/usage/utils'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/model'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; + +export abstract class Evaluator extends Validatable { + protected metricConfig: MetricConfig; + protected llmConfig?: EvalModelConfigType; + protected embeddingConfig?: EvalModelConfigType; + protected evaluatorConfig?: EvaluatorSchema; + + constructor( + metricConfig: MetricConfig, + llmConfig?: EvalModelConfigType, + embeddingConfig?: EvalModelConfigType, + evaluatorConfig?: EvaluatorSchema + ) { + super(); + this.llmConfig = llmConfig; + this.embeddingConfig = embeddingConfig; + this.metricConfig = metricConfig; + this.evaluatorConfig = evaluatorConfig; + } + + abstract evaluate(evalCase: EvalCase): Promise; + + async validate(): Promise { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + try { + // Validate metric configuration requirements based on evaluatorConfig + if (this.evaluatorConfig?.metric.llmRequired && !this.llmConfig) { + errors.push({ + code: EvaluationErrEnum.evaluatorLLmConfigMissing, + message: 'LLM configuration is required for this metric', + field: 'llmConfig' + }); + } + + if (this.evaluatorConfig?.metric.embeddingRequired && !this.embeddingConfig) { + errors.push({ + code: EvaluationErrEnum.evaluatorEmbeddingConfigMissing, + message: 'Embedding configuration is required for this metric', + field: 'embeddingConfig' + }); + } + + // Validate metric configuration + if (!this.metricConfig) { + errors.push({ + code: EvaluationErrEnum.evaluatorConfigRequired, + message: 'Metric configuration is required', + field: 'metricConfig' + }); + return { isValid: false, errors, warnings }; + } + + if (!this.metricConfig.metricName) { + errors.push({ + code: EvaluationErrEnum.evalMetricNameRequired, + message: 'Metric name is required', + field: 'metricConfig.metricName' + }); + } + + if (!this.metricConfig.metricType) { + errors.push({ + code: EvaluationErrEnum.evalMetricTypeRequired, + message: 'Metric type is required', + field: 'metricConfig.metricType' + }); + } + + // Validate LLM configuration if provided + if (this.llmConfig) { + if (!this.llmConfig.modelId) { + errors.push({ + code: EvaluationErrEnum.evaluatorLLmModelNotFound, + message: 'LLM model modelId is required', + field: 'llmConfig.modelId' + }); + } else { + const llm = getEvaluationModelById(this.llmConfig.modelId); + if (!llm) { + errors.push({ + code: EvaluationErrEnum.evaluatorLLmModelNotFound, + message: `LLM model '${this.llmConfig.modelId}' not found or not accessible`, + field: 'llmConfig.modelId' + }); + } + } + } + + // Validate embedding configuration if provided + if (this.embeddingConfig) { + if (!this.embeddingConfig.modelId) { + errors.push({ + code: EvaluationErrEnum.evaluatorEmbeddingModelNotFound, + message: 'Embedding model id is required', + field: 'embeddingConfig.modelId' + }); + } else { + const embedding = getEmbeddingModelById(this.embeddingConfig.modelId); + if (!embedding) { + errors.push({ + code: EvaluationErrEnum.evaluatorEmbeddingModelNotFound, + message: `Embedding model '${this.embeddingConfig.modelId}' not found or not accessible`, + field: 'embeddingConfig.modelId' + }); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings: warnings.length > 0 ? warnings : undefined + }; + } catch (error) { + errors.push({ + code: EvaluationErrEnum.evaluatorConfigRequired, + message: `Evaluator validation failed: ${error instanceof Error ? error.message : String(error)}`, + debugInfo: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + } + }); + + return { isValid: false, errors, warnings }; + } + } +} + +export class DitingEvaluator extends Evaluator { + private client: ReturnType; + private scoreScaling: number; + + constructor( + metricConfig: MetricConfig, + llmConfig?: EvalModelConfigType, + embeddingConfig?: EvalModelConfigType, + evaluatorConfig?: EvaluatorSchema, + scoreScaling: number = 1 + ) { + super(metricConfig, llmConfig, embeddingConfig, evaluatorConfig); + this.client = createDitingClient(); + this.scoreScaling = scoreScaling; + } + + async evaluate(evalCase: EvalCase): Promise { + const response: EvaluationResponse = await this.client.runEvaluation({ + evalCase: evalCase, + metricConfig: this.metricConfig, + embeddingConfig: this.embeddingConfig, + llmConfig: this.llmConfig + }); + + // Calculate total points from usages + let totalPoints = 0; + if (response.usages && response.usages.length > 0) { + for (const usage of response.usages) { + if (usage.promptTokens || usage.completionTokens) { + const modelType = + usage.modelType === 'embed' ? ModelTypeEnum.embedding : ModelTypeEnum.llm; + const modelId = + modelType === ModelTypeEnum.embedding + ? this.embeddingConfig?.modelId + : this.llmConfig?.modelId; + + if (modelId) { + const { totalPoints: usagePoints } = formatModelChars2Points({ + modelId, + inputTokens: usage.promptTokens || 0, + outputTokens: usage.completionTokens || 0 + }); + totalPoints += usagePoints; + } + } + } + } + + // Apply score scaling if data.score exists + // scoreScaling directly multiplies the original score (e.g., 1 means no scaling, 100 means 100x amplification) + let scaledData = response.data; + if (response.data?.score !== undefined && response.data?.score !== null) { + scaledData = { + ...response.data, + score: response.data.score * this.scoreScaling + }; + } + + return { + metricName: this.metricConfig.metricName, + status: response.status, + data: scaledData, + usages: response.usages, + error: response.error, + totalPoints + }; + } +} + +export async function createEvaluatorInstance( + evaluatorConfig: EvaluatorSchema, + options: { validate?: boolean } = { validate: true } +): Promise { + const metricConfig: MetricConfig = { + metricName: evaluatorConfig.metric.name, + metricType: evaluatorConfig.metric.type, + prompt: evaluatorConfig.metric.prompt + }; + + let llmConfig: EvalModelConfigType | undefined = undefined; + let embeddingConfig: EvalModelConfigType | undefined = undefined; + + if (evaluatorConfig.runtimeConfig?.llmId) { + try { + const llm = getEvaluationModelById(evaluatorConfig.runtimeConfig.llmId); + if (!llm) { + throw new Error(`Evaluation model '${evaluatorConfig.runtimeConfig.llmId}' not found`); + } + const endpoint = getModelEndpointConfig(llm); + llmConfig = { + modelId: evaluatorConfig.runtimeConfig.llmId, + name: endpoint.name, + baseUrl: endpoint.baseUrl, + apiKey: endpoint.apiKey + }; + } catch (err) { + throw new Error(EvaluationErrEnum.evaluatorLLmModelNotFound); + } + } + + if (evaluatorConfig.runtimeConfig?.embeddingId) { + try { + const embedding = getEmbeddingModelById(evaluatorConfig.runtimeConfig.embeddingId); + const endpoint = getModelEndpointConfig(embedding); + embeddingConfig = { + modelId: evaluatorConfig.runtimeConfig.embeddingId, + name: endpoint.name, + baseUrl: endpoint.baseUrl, + apiKey: endpoint.apiKey + }; + } catch (err) { + throw new Error(EvaluationErrEnum.evaluatorEmbeddingModelNotFound); + } + } + + const scoreScaling = evaluatorConfig.scoreScaling ?? 1; + const evaluatorInstance = new DitingEvaluator( + metricConfig, + llmConfig, + embeddingConfig, + evaluatorConfig, + scoreScaling + ); + + // Validate instance if requested (default behavior) + if (options.validate) { + const validationResult = await evaluatorInstance.validate(); + if (!validationResult.isValid) { + const errorMessages = validationResult.errors + .map((err) => `${err.code}: ${err.message}`) + .join('; '); + throw new Error(`Evaluator instance validation failed: ${errorMessages}`); + } + } + + return evaluatorInstance; +} + +export async function validateEvaluatorConfig( + evaluatorConfig: EvaluatorSchema +): Promise { + try { + const evaluatorInstance = await createEvaluatorInstance(evaluatorConfig, { validate: false }); + return await evaluatorInstance.validate(); + } catch (error) { + // If we can't even create the instance, return validation error + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorInvalidConfig, + message: `Failed to create evaluator instance: ${error instanceof Error ? error.message : String(error)}`, + debugInfo: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + metricType: evaluatorConfig.metric?.type + } + } + ] + }; + } +} diff --git a/packages/service/core/evaluation/index.ts b/packages/service/core/evaluation/index.ts new file mode 100644 index 000000000000..81b35ca6ba93 --- /dev/null +++ b/packages/service/core/evaluation/index.ts @@ -0,0 +1,193 @@ +import { addLog } from '../../common/system/log'; +import { setCron } from '../../common/system/cron'; +import { initEvalDatasetDataQualityWorker } from './dataset/dataQualityProcessor'; +import { initEvalDatasetDataSynthesizeWorker } from './dataset/dataSynthesizeProcessor'; +import { initEvalTaskItemWorker } from './task/processor'; +import { initEvaluationSummaryWorker } from './summary/worker'; + +// Import all queues for cleanup +import { evaluationItemQueue } from './task/mq'; +import { evalDatasetDataQualityQueue } from './dataset/dataQualityMq'; +import { evalDatasetDataSynthesizeQueue } from './dataset/dataSynthesizeMq'; +import { getEvaluationSummaryQueue } from './summary/queue'; + +// Import MongoDB models for existence checks +import { MongoEvaluation, MongoEvalItem } from './task/schema'; +import { MongoEvalDatasetData } from './dataset/evalDatasetDataSchema'; +import { MongoEvalDatasetCollection } from './dataset/evalDatasetCollectionSchema'; + +// Initialize evaluation workers + +export const initEvaluationWorkers = () => { + addLog.info('Init Evaluation Workers...'); + + initEvalTaskItemWorker(); + + initEvalDatasetDataQualityWorker(); + initEvalDatasetDataSynthesizeWorker(); + + initEvaluationSummaryWorker(); + + // Setup periodic orphaned jobs cleanup + setupOrphanedJobsCleanup(); +}; + +/** + * Setup periodic cleanup for orphaned jobs + * Specifically handles residual issues caused by active jobs that cannot be deleted + */ +const setupOrphanedJobsCleanup = () => { + // Run cleanup every 30 minutes + setCron('*/30 * * * *', async () => { + await cleanupOrphanedJobs(); + }); + + addLog.info('[Evaluation] Orphaned jobs cleanup scheduled (every 30 minutes)'); +}; + +/** + * Comprehensive cleanup for all orphaned jobs in evaluation system + * Handles active jobs that cannot be deleted by BullMQ + */ +export const cleanupOrphanedJobs = async () => { + try { + addLog.debug('[Evaluation] Starting comprehensive orphaned jobs cleanup'); + + const summaryQueue = getEvaluationSummaryQueue(); + + // Get all jobs from all evaluation queues + const [itemJobs, dataQualityJobs, dataSynthesizeJobs, summaryJobs] = await Promise.all([ + evaluationItemQueue.getJobs(['active', 'waiting', 'delayed', 'completed', 'failed'], 0, 2000), + evalDatasetDataQualityQueue.getJobs( + ['active', 'waiting', 'delayed', 'completed', 'failed'], + 0, + 1000 + ), + evalDatasetDataSynthesizeQueue.getJobs( + ['active', 'waiting', 'delayed', 'completed', 'failed'], + 0, + 1000 + ), + summaryQueue.getJobs(['active', 'waiting', 'delayed', 'completed', 'failed'], 0, 500) + ]); + + let cleanedCount = 0; + let skippedActiveCount = 0; + + // 1. Clean orphaned eval task item jobs + for (const job of itemJobs) { + try { + const { evalId, evalItemId } = job.data; + const [evaluation, evalItem] = await Promise.all([ + MongoEvaluation.exists({ _id: evalId }), + MongoEvalItem.exists({ _id: evalItemId }) + ]); + + if (!evaluation || !evalItem) { + const result = await cleanupJob(job, 'item', { evalId, evalItemId }); + if (result.cleaned) cleanedCount++; + if (result.skippedActive) skippedActiveCount++; + } + } catch (error) { + addLog.warn('[Evaluation] Failed to cleanup item job', { jobId: job.id, error }); + } + } + + // 2. Clean orphaned data quality jobs + for (const job of dataQualityJobs) { + try { + const { dataId } = job.data; + const dataExists = await MongoEvalDatasetData.exists({ _id: dataId }); + + if (!dataExists) { + const result = await cleanupJob(job, 'dataQuality', { dataId }); + if (result.cleaned) cleanedCount++; + if (result.skippedActive) skippedActiveCount++; + } + } catch (error) { + addLog.warn('[Evaluation] Failed to cleanup data quality job', { jobId: job.id, error }); + } + } + + // 3. Clean orphaned data synthesize jobs + for (const job of dataSynthesizeJobs) { + try { + const { evalDatasetCollectionId } = job.data; + const collectionExists = await MongoEvalDatasetCollection.exists({ + _id: evalDatasetCollectionId + }); + + if (!collectionExists) { + const result = await cleanupJob(job, 'dataSynthesize', { + evalDatasetCollectionId + }); + if (result.cleaned) cleanedCount++; + if (result.skippedActive) skippedActiveCount++; + } + } catch (error) { + addLog.warn('[Evaluation] Failed to cleanup data synthesize job', { jobId: job.id, error }); + } + } + + // 4. Clean orphaned summary jobs + for (const job of summaryJobs) { + try { + const { evalId } = job.data; + const evaluation = await MongoEvaluation.exists({ _id: evalId }); + + if (!evaluation) { + const result = await cleanupJob(job, 'summary', { evalId }); + if (result.cleaned) cleanedCount++; + if (result.skippedActive) skippedActiveCount++; + } + } catch (error) { + addLog.warn('[Evaluation] Failed to cleanup summary job', { jobId: job.id, error }); + } + } + + const result = { + totalJobs: + itemJobs.length + dataQualityJobs.length + dataSynthesizeJobs.length + summaryJobs.length, + cleanedJobs: cleanedCount, + skippedActiveJobs: skippedActiveCount, + breakdown: { + itemJobs: itemJobs.length, + dataQualityJobs: dataQualityJobs.length, + dataSynthesizeJobs: dataSynthesizeJobs.length, + summaryJobs: summaryJobs.length + } + }; + + addLog.info('[Evaluation] Comprehensive orphaned jobs cleanup completed', result); + return result; + } catch (error) { + addLog.error('[Evaluation] Comprehensive orphaned jobs cleanup failed', { error }); + return null; + } +}; + +/** + * Helper function to cleanup individual job + */ +async function cleanupJob(job: any, jobType: string, context: Record) { + const jobState = await job.getState(); + + if (jobState === 'active') { + // Active job cannot be removed, log warning + addLog.warn(`[Evaluation] Found orphaned active ${jobType} job (cannot remove)`, { + jobId: job.id, + state: jobState, + ...context + }); + return { cleaned: false, skippedActive: true }; + } else { + // Non-active jobs can be safely removed + await job.remove(); + addLog.debug(`[Evaluation] Removed orphaned ${jobType} job`, { + jobId: job.id, + state: jobState, + ...context + }); + return { cleaned: true, skippedActive: false }; + } +} diff --git a/packages/service/core/evaluation/metric/provider.ts b/packages/service/core/evaluation/metric/provider.ts new file mode 100644 index 000000000000..b31370f5ea10 --- /dev/null +++ b/packages/service/core/evaluation/metric/provider.ts @@ -0,0 +1,37 @@ +import { readConfigData } from '../../../common/system/config/controller'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; + +export async function loadSystemBuiltinMetrics(): Promise { + try { + const metricContent = await readConfigData('metric.json'); + const { builtinMetrics } = JSON.parse(metricContent); + + global.builtinMetrics = (builtinMetrics || []).map((metric: any) => ({ + _id: `builtin_${metric.name}`, + teamId: '', + tmbId: '', + name: metric.name, + description: metric.description || '', + type: EvalMetricTypeEnum.Builtin, + createTime: new Date(), + updateTime: new Date(), + ...Object.fromEntries( + Object.entries(metric).filter(([key, value]) => key.endsWith('Required') && value === true) + ) + })) as EvalMetricSchemaType[]; + + console.log(`Loaded ${global.builtinMetrics.length} builtin metrics`); + } catch (error) { + console.error('Failed to load builtin metrics:', error); + global.builtinMetrics = []; + } +} + +export async function getBuiltinMetrics(): Promise { + if (!global.builtinMetrics) { + return []; + } + + return global.builtinMetrics; +} diff --git a/packages/service/core/evaluation/metric/schema.ts b/packages/service/core/evaluation/metric/schema.ts new file mode 100644 index 000000000000..9b05293a89a1 --- /dev/null +++ b/packages/service/core/evaluation/metric/schema.ts @@ -0,0 +1,107 @@ +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { EvalMetricTypeValues } from '@fastgpt/global/core/evaluation/metric/constants'; +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; + +const { Schema } = connectionMongo; + +export const EvalMetricCollectionName = 'eval_metrics'; + +const EvalMetricSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + description: { + type: String, + default: '', + trim: true, + maxlength: 100 + }, + type: { + type: String, + enum: EvalMetricTypeValues, + required: true + }, + prompt: { + type: String, + required: false + }, + + userInputRequired: { + type: Boolean, + required: false + }, + + actualOutputRequired: { + type: Boolean, + required: false + }, + + expectedOutputRequired: { + type: Boolean, + required: false + }, + contextRequired: { + type: Boolean, + required: false + }, + retrievalContextRequired: { + type: Boolean, + required: false + }, + + embeddingRequired: { + type: Boolean, + required: false + }, + llmRequired: { + type: Boolean, + required: false + }, + + createTime: { + type: Date, + required: true, + default: () => new Date() + }, + updateTime: { + type: Date, + required: true, + default: () => new Date() + } +}); + +EvalMetricSchema.index({ teamId: 1, name: 1 }, { unique: true }); +EvalMetricSchema.index({ createTime: -1 }); +EvalMetricSchema.index({ updateTime: -1 }); + +EvalMetricSchema.pre('save', function (next) { + this.updateTime = new Date(); + next(); +}); + +EvalMetricSchema.pre(['updateOne', 'updateMany', 'findOneAndUpdate'], function (next) { + this.set({ updateTime: new Date() }); + next(); +}); + +export const MongoEvalMetric = getMongoModel( + EvalMetricCollectionName, + EvalMetricSchema +); diff --git a/packages/service/core/evaluation/summary/index.ts b/packages/service/core/evaluation/summary/index.ts new file mode 100644 index 000000000000..5c3955d2a18c --- /dev/null +++ b/packages/service/core/evaluation/summary/index.ts @@ -0,0 +1,1078 @@ +import { + CaculateMethodMap, + EvaluationStatusEnum, + CalculateMethodEnum +} from '@fastgpt/global/core/evaluation/constants'; +import { MongoEvaluation, MongoEvalItem } from '../task/schema'; +import type { EvaluationSchemaType } from '@fastgpt/global/core/evaluation/type'; +import { Types } from '../../../common/mongo'; +import { addLog } from '../../../common/system/log'; +import { + SummaryStatusEnum, + PERFECT_SCORE, + MAX_TOKEN_FOR_EVALUATION_SUMMARY, + TEMPERATURE_FOR_EVALUATION_SUMMARY +} from '@fastgpt/global/core/evaluation/constants'; +import { getEvaluationSummaryTokenLimit, getEvaluationSummaryModel } from '../utils/tokenLimiter'; +import { createChatCompletion } from '../../ai/config'; +import { countGptMessagesTokens } from '../../../common/string/tiktoken'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; +import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; +import { loadRequestMessages } from '../../ai/llm/utils'; +import { + problemAnalysisTemplateZhCN, + strengthAnalysisTemplateZhCN, + goodExampleZhCN, + badExampleZhCN, + problemAnalysisTemplateZhTW, + strengthAnalysisTemplateZhTW, + goodExampleZhTW, + badExampleZhTW, + problemAnalysisTemplateEn, + strengthAnalysisTemplateEn, + goodExampleEn, + badExampleEn +} from '@fastgpt/global/core/ai/prompt/eval'; +import { LanguageType, LanguageDisplayNameMap } from './util/languageUtil'; +import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; +import { addAuditLog } from '../../../support/user/audit/util'; +import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; +import { createMergedEvaluationUsage } from '../utils/usage'; +import { formatModelChars2Points } from '../../../support/wallet/usage/utils'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import { addSummaryTaskToQueue } from './queue'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import type { ClientSession } from '../../../common/mongo'; + +export class EvaluationSummaryService { + static async getEvaluationSummary(evalId: string): Promise<{ + data: Array<{ + metricId: string; + metricName: string; + metricScore: number; + summary: string; + summaryStatus: string; + errorReason?: string; + completedItemCount: number; + overThresholdItemCount: number; + underThresholdRate: number; + threshold: number; + weight: number; + customSummary: string; + }>; + aggregateScore: number; + }> { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); + + const calculatedData = await this.calculateMetricScores(evaluation); + + const data = evaluation.evaluators.map((evaluator, index) => { + const metricId = evaluator.metric._id.toString(); + const summaryConfig = evaluation.summaryData.summaryConfigs[index]; + + const metricData = calculatedData.metricsData.find((m) => m.metricId === metricId); + const completedItemCount = metricData?.totalCount || 0; + const overThresholdItemCount = metricData?.aboveThresholdCount || 0; + const underThresholdItemCount = metricData?.belowThresholdCount || 0; + const metricScore = metricData?.metricScore || 0; + + const threshold = evaluator.thresholdValue || 0; + const underThresholdRate = + completedItemCount > 0 + ? Math.round((underThresholdItemCount / completedItemCount) * 100) + : 0; + const customSummary = `${underThresholdRate}%(${underThresholdItemCount})${summaryConfig.summary || ''}`; + + return { + metricId: metricId, + metricName: evaluator.metric.name, + metricScore: metricScore, + summary: summaryConfig.summary, + summaryStatus: summaryConfig.summaryStatus, + errorReason: summaryConfig.errorReason, + completedItemCount: completedItemCount, + overThresholdItemCount: overThresholdItemCount, + underThresholdRate: underThresholdRate, + threshold: threshold, + weight: summaryConfig.weight || 0, + customSummary: customSummary + }; + }); + + return { + data, + aggregateScore: calculatedData.aggregateScore + }; + } + + // Pure calculation of metric scores and aggregate score without database updates + static async calculateMetricScores(evaluation: EvaluationSchemaType): Promise<{ + metricsData: Array<{ + metricId: string; + metricName: string; + metricScore: number; + weight: number; + thresholdValue: number; + aboveThresholdCount: number; + belowThresholdCount: number; + totalCount: number; + }>; + aggregateScore: number; + }> { + try { + const evalId = new Types.ObjectId(evaluation._id); + + if (!evaluation.evaluators || !Array.isArray(evaluation.evaluators)) { + addLog.warn('[calculateMetricScores] Evaluation has no evaluators', { + evalId: evaluation._id + }); + return { + metricsData: [], + aggregateScore: 0 + }; + } + + if ( + !evaluation.summaryData || + !evaluation.summaryData.summaryConfigs || + !Array.isArray(evaluation.summaryData.summaryConfigs) + ) { + addLog.warn('[calculateMetricScores] Evaluation has no summary or summaryConfigs', { + evalId: evaluation._id + }); + return { + metricsData: [], + aggregateScore: 0 + }; + } + + // Calculate successful scores and total completed count per metric + const pipeline = [ + { + $match: { + evalId: evalId, + evaluatorOutputs: { $exists: true, $nin: [null, []] } + } + }, + { + $addFields: { + metricResults: { + $map: { + input: '$evaluatorOutputs', + as: 'output', + in: { + metricName: '$$output.metricName', + status: '$$output.status', + score: '$$output.data.score', + hasValidScore: { + $and: [ + { $eq: ['$$output.status', MetricResultStatusEnum.Success] }, + { $ne: ['$$output.data.score', null] } + ] + } + } + } + } + } + }, + { + $unwind: '$metricResults' + }, + { + $group: { + _id: '$metricResults.metricName', + successfulScores: { + $push: { + $cond: ['$metricResults.hasValidScore', '$metricResults.score', '$$REMOVE'] + } + }, + metricName: { $first: '$metricResults.metricName' } + } + }, + { + $addFields: { + avgScore: { + $cond: [ + { $gt: [{ $size: '$successfulScores' }, 0] }, + { $avg: '$successfulScores' }, + 0 + ] + }, + successCount: { $size: '$successfulScores' } + } + } + ]; + + const metricsStats = await MongoEvalItem.aggregate(pipeline as any); + + const processedStats = metricsStats.map((stats) => { + let medianScore = 0; + + if (stats.successfulScores && stats.successfulScores.length > 0) { + const sortedScores = [...stats.successfulScores].sort((a, b) => a - b); + const length = sortedScores.length; + + if (length % 2 === 0) { + const mid1 = sortedScores[length / 2 - 1]; + const mid2 = sortedScores[length / 2]; + medianScore = (mid1 + mid2) / 2; + } else { + medianScore = sortedScores[Math.floor(length / 2)]; + } + } + + return { + ...stats, + medianScore + }; + }); + + const metricsData: Array<{ + metricId: string; + metricName: string; + metricScore: number; + weight: number; + thresholdValue: number; + aboveThresholdCount: number; + belowThresholdCount: number; + totalCount: number; + }> = []; + + let totalWeightedScore = 0; + let totalWeight = 0; + + evaluation.evaluators.forEach((evaluator, index) => { + const metricId = evaluator.metric._id.toString(); + const metricName = evaluator.metric.name; + const stats = processedStats.find((s) => s._id === metricName); + const summaryConfig = evaluation.summaryData.summaryConfigs[index]; + + if (stats) { + let rawScore = + evaluation.summaryData.calculateType === CalculateMethodEnum.median + ? stats.medianScore + : stats.avgScore; + + if (isNaN(rawScore) || rawScore === null || rawScore === undefined) { + rawScore = 0; + } + + const metricScore = Math.round(rawScore * 100) / 100; + + const thresholdValue = evaluator.thresholdValue || 0; + const aboveThresholdCount = stats.successfulScores.filter( + (score: number) => score >= thresholdValue + ).length; + + // Calculate belowThresholdCount from successful scores that are below threshold + const belowThresholdCount = stats.successfulScores.filter( + (score: number) => score < thresholdValue + ).length; + + const weight = summaryConfig.weight; + + metricsData.push({ + metricId: metricId, + metricName: evaluator.metric.name, + metricScore, + weight, + thresholdValue: thresholdValue, + aboveThresholdCount, + belowThresholdCount, + totalCount: stats.successCount + }); + + totalWeightedScore += metricScore * weight; + totalWeight += weight; + } else { + const weight = summaryConfig.weight; + + metricsData.push({ + metricId: metricId, + metricName: evaluator.metric.name, + metricScore: 0, + weight: weight, + thresholdValue: evaluator.thresholdValue || 0, + aboveThresholdCount: 0, + belowThresholdCount: 0, + totalCount: 0 + }); + + totalWeightedScore += 0 * weight; + totalWeight += weight; + } + }); + + let aggregateScore = 0; + if (totalWeight > 0 && !isNaN(totalWeightedScore) && !isNaN(totalWeight)) { + const rawScore = totalWeightedScore / totalWeight; + aggregateScore = isNaN(rawScore) ? 0 : Math.round(rawScore * 100) / 100; + } + + return { + metricsData, + aggregateScore + }; + } catch (error) { + addLog.error('[Evaluation] Real-time calculation failed', { + evalId: evaluation._id.toString(), + error + }); + + const defaultData = evaluation.evaluators.map((evaluator, index) => { + const summaryConfig = evaluation.summaryData.summaryConfigs[index]; + return { + metricId: evaluator.metric._id.toString(), + metricName: evaluator.metric.name, + metricScore: 0, + weight: summaryConfig.weight, + thresholdValue: evaluator.thresholdValue || 0, + aboveThresholdCount: 0, + belowThresholdCount: 0, + totalCount: 0 + }; + }); + + return { + metricsData: defaultData, + aggregateScore: 0 + }; + } + } + + static async updateEvaluationSummaryConfig( + evalId: string, + calculateType: CalculateMethodEnum, + metricsConfig: Array<{ + metricId: string; + thresholdValue: number; + weight?: number; + }> + ): Promise { + addLog.info('[Evaluation] Starting configuration update', { + evalId, + metricsCount: metricsConfig.length + }); + + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); + + const evalMetricIdSet = new Set( + (evaluation.evaluators || []).map((evaluator: any) => evaluator.metric._id.toString()) + ); + for (const m of metricsConfig) { + if (!evalMetricIdSet.has(m.metricId)) { + throw new Error(EvaluationErrEnum.summaryMetricsConfigError); + } + } + + await mongoSessionRun(async (session: ClientSession) => { + const configMap = new Map(metricsConfig.map((m) => [m.metricId, m])); + + const updatedEvaluators = evaluation.evaluators.map((evaluator: any) => { + const config = configMap.get(evaluator.metric._id.toString()); + return config ? { ...evaluator, thresholdValue: config.thresholdValue } : evaluator; + }); + + const updatedSummaryConfigs = evaluation.summaryData.summaryConfigs.map( + (summaryConfig: any, index: number) => { + const metricId = evaluation.evaluators[index].metric._id.toString(); + const config = configMap.get(metricId); + + if (config) { + return { + ...summaryConfig, + ...(config.weight !== undefined && { weight: config.weight }) + }; + } + return summaryConfig; + } + ); + + await MongoEvaluation.updateOne( + { _id: evalId }, + { + $set: { + 'summaryData.calculateType': calculateType, + evaluators: updatedEvaluators, + 'summaryData.summaryConfigs': updatedSummaryConfigs + } + }, + { session } + ); + + addLog.info('[Evaluation] Configuration updated successfully', { + evalId, + evaluatorsCount: updatedEvaluators.length, + summaryConfigsCount: updatedSummaryConfigs.length + }); + }); + + addLog.info('[Evaluation] Configuration update completed successfully', { + evalId, + metricsCount: metricsConfig.length + }); + } + + static async getEvaluationSummaryConfig(evalId: string): Promise<{ + calculateType: CalculateMethodEnum; + calculateTypeName: string; + metricsConfig: Array<{ + metricId: string; + metricName: string; + metricDescription: string; + thresholdValue: number; + weight: number; + }>; + }> { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); + + const calculateType = evaluation.summaryData.calculateType; + const calculateTypeName = CaculateMethodMap[calculateType]?.name || 'Unknown'; + + const metricsConfig = evaluation.evaluators.map((evaluator, index) => { + const summaryConfig = evaluation.summaryData.summaryConfigs[index]; + return { + metricId: evaluator.metric._id.toString(), + metricName: evaluator.metric.name, + metricDescription: evaluator.metric.description || '', + thresholdValue: evaluator.thresholdValue || 0, + weight: summaryConfig.weight + }; + }); + + return { + calculateType, + calculateTypeName, + metricsConfig + }; + } + + // Generate summary reports for multiple metrics using BullMQ queue to prevent status deadlock on system crashes + static async generateSummaryReports(evalId: string, metricIds: string[]): Promise { + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + + if (!evaluation) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } + + addLog.info( + '[EvaluationSummary] Starting validation and preparation of report generation tasks', + { + evalId, + metricIds, + totalMetrics: metricIds.length + } + ); + + const validMetricIds: string[] = []; + const skippedMetrics: Array<{ + metricId: string; + metricName: string; + reason: string; + }> = []; + + metricIds.forEach((metricId) => { + const evaluatorIndex = evaluation.evaluators.findIndex( + (evaluator: any) => evaluator.metric._id.toString() === metricId + ); + + if (evaluatorIndex === -1) { + addLog.warn('[EvaluationSummary] Metric does not belong to this evaluation task', { + evalId, + metricId + }); + skippedMetrics.push({ + metricId, + metricName: 'Unknown', + reason: 'Metric does not belong to this evaluation task' + }); + return; + } + + validMetricIds.push(metricId); + }); + + if (skippedMetrics.length > 0) { + addLog.info('[EvaluationSummary] Some metrics were skipped', { + evalId, + skippedCount: skippedMetrics.length, + skippedMetrics: skippedMetrics.map((m) => ({ + metricId: m.metricId, + metricName: m.metricName, + reason: m.reason + })) + }); + } + + if (validMetricIds.length === 0) { + if (skippedMetrics.length > 0) { + addLog.info('[EvaluationSummary] All metrics were skipped, no tasks to execute', { + evalId, + totalRequested: metricIds.length, + skippedCount: skippedMetrics.length + }); + return; + } + throw new Error(EvaluationErrEnum.summaryNoValidMetricsFound); + } + + await addSummaryTaskToQueue(evalId, validMetricIds); + + addLog.info('[EvaluationSummary] Task successfully added to queue', { + evalId, + totalRequested: metricIds.length, + validMetricsCount: validMetricIds.length, + skippedCount: skippedMetrics.length + }); + } catch (error) { + addLog.error('[EvaluationSummary] Report generation task creation failed', { + evalId, + metricIds, + error + }); + throw error; + } + } + + static async generateSingleMetricSummary( + evaluation: EvaluationSchemaType, + metricId: string, + evaluatorIndex: number, + evaluator: any, + languageType: LanguageType + ): Promise { + const evalId = evaluation._id.toString(); + + addLog.info('[EvaluationSummary] Starting single metric report generation', { + evalId, + metricId, + metricName: evaluator.metric.name, + languageType + }); + + const { filteredData, totalDataCount } = await this.getFilteredEvaluationData( + evalId, + metricId, + evaluator.thresholdValue || 0, + evaluator + ); + + if (filteredData.length === 0) { + addLog.warn('[EvaluationSummary] No matching data found', { + evalId, + metricId + }); + throw new Error('No matching evaluation data found, cannot generate summary report'); + } + + // Use languageType from job data instead of detecting again + addLog.info('[EvaluationSummary] Using language type from job data', { + evalId, + metricId, + language: languageType + }); + + const modelData = getEvaluationSummaryModel(); + const tokenLimit = getEvaluationSummaryTokenLimit(modelData.id || ''); + const { truncatedData, truncatedCount } = await this.truncateDataByTokens( + filteredData, + tokenLimit, + languageType + ); + + try { + await checkTeamAIPoints(evaluation.teamId); + addLog.info('[EvaluationSummary] Balance check passed, starting LLM call', { + evalId, + metricId, + metricName: evaluator.metric.name + }); + } catch (balanceError) { + addLog.error('[EvaluationSummary] Insufficient balance, cannot generate summary report', { + evalId, + metricId, + metricName: evaluator.metric.name, + error: balanceError + }); + await this.updateSummaryResult( + evalId, + evaluatorIndex, + SummaryStatusEnum.failed, + '', + 'Insufficient balance' + ); + return; + } + + // 4. Call LLM to generate report + const summaryModel = undefined; + const { summary, usage } = await this.callLLMForSummary( + truncatedData, + languageType, + summaryModel + ); + + // undefine will use deafulatevaluation model + const llmModel = undefined; + await this.recordUsage(evaluation, evaluator, usage, llmModel); + + await this.updateSummaryResult(evalId, evaluatorIndex, SummaryStatusEnum.completed, summary); + + (async () => { + addAuditLog({ + tmbId: evaluation.tmbId, + teamId: evaluation.teamId.toString(), + event: AuditEventEnum.GENERATE_EVALUATION_SUMMARY, + params: { + evalName: evaluation.name, + metricName: evaluator.metric.name + } + }); + })(); + + addLog.info('[EvaluationSummary] Single metric report generated successfully', { + evalId, + metricId, + summaryLength: summary.length, + tokensUsed: usage?.total_tokens || 0 + }); + } + + private static async updateSummaryResult( + evalId: string, + evaluatorIndex: number, + status: SummaryStatusEnum, + summary: string, + errorReason?: string + ): Promise { + const updateData: any = { + [`summaryData.summaryConfigs.${evaluatorIndex}.summaryStatus`]: status, + [`summaryData.summaryConfigs.${evaluatorIndex}.summary`]: summary + }; + + if (errorReason) { + updateData[`summaryData.summaryConfigs.${evaluatorIndex}.errorReason`] = errorReason; + } else { + updateData[`summaryData.summaryConfigs.${evaluatorIndex}.errorReason`] = undefined; + } + + await MongoEvaluation.updateOne({ _id: evalId }, { $set: updateData }); + } + + private static async getFilteredEvaluationData( + evalId: string, + metricId: string, + thresholdValue: number, + evaluator: any + ): Promise<{ + filteredData: any[]; + totalDataCount: number; + }> { + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) { + throw new Error('Evaluation not found'); + } + + // Query evaluation items for specific metric, sorted by threshold then score + const pipeline = [ + { + $match: { + evalId: new Types.ObjectId(evalId), + evaluatorOutputs: { $exists: true, $nin: [null, []] } + } + }, + { + $addFields: { + matchingMetricResult: { + $arrayElemAt: [ + { + $filter: { + input: '$evaluatorOutputs', + as: 'output', + cond: { + $and: [ + { $eq: ['$$output.metricName', evaluator.metric.name] }, + { $eq: ['$$output.status', MetricResultStatusEnum.Success] } + ] + } + } + }, + 0 + ] + } + } + }, + { + $match: { + 'matchingMetricResult.data.score': { $exists: true, $ne: null } + } + }, + { + $addFields: { + score: '$matchingMetricResult.data.score', + isBelowThreshold: { + $lt: ['$matchingMetricResult.data.score', thresholdValue] + } + } + }, + { + $sort: { + isBelowThreshold: -1, + score: 1 + } + }, + { + $project: { + dataItem: 1, + targetOutput: 1, + evaluatorOutputs: 1, + matchingMetricResult: 1, + score: 1, + isBelowThreshold: 1 + } + } + ]; + + const results = await MongoEvalItem.aggregate(pipeline as any); + + addLog.info('[EvaluationSummary] Data query completed', { + evalId, + metricId, + totalCount: results.length, + belowThresholdCount: results.filter((item) => item.isBelowThreshold).length + }); + + return { + filteredData: results, + totalDataCount: results.length + }; + } catch (error) { + addLog.error('[EvaluationSummary] Data query failed', { + evalId, + metricId, + error + }); + throw error; + } + } + + // Truncate data to fit within token limit while selecting optimal items + private static async truncateDataByTokens( + data: any[], + tokenLimit: number, + language: LanguageType + ): Promise<{ + truncatedData: any[]; + truncatedCount: number; + }> { + if (data.length === 0) { + return { truncatedData: [], truncatedCount: 0 }; + } + + try { + const isAllPerfect = this.isAllPerfectScores(data); + const optimizedData = this.selectOptimalDataWithFlag(data, isAllPerfect); + + let selectedTemplate: string; + let selectedExample: string; + + if (language === LanguageType.English) { + selectedTemplate = isAllPerfect ? strengthAnalysisTemplateEn : problemAnalysisTemplateEn; + selectedExample = isAllPerfect ? goodExampleEn : badExampleEn; + } else if (language === LanguageType.TraditionalChinese) { + selectedTemplate = isAllPerfect + ? strengthAnalysisTemplateZhTW + : problemAnalysisTemplateZhTW; + selectedExample = isAllPerfect ? goodExampleZhTW : badExampleZhTW; + } else { + selectedTemplate = isAllPerfect + ? strengthAnalysisTemplateZhCN + : problemAnalysisTemplateZhCN; + selectedExample = isAllPerfect ? goodExampleZhCN : badExampleZhCN; + } + + const baseTemplate = selectedTemplate + .replace('{language}', LanguageDisplayNameMap[language]) + .replace('{example}', selectedExample) + .replace('{evaluation_result_for_single_metric}', ''); + + let currentTokens = await countGptMessagesTokens([ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: baseTemplate + } + ]); + + const truncatedData: any[] = []; + + for (const item of optimizedData) { + const itemContent = this.formatDataItemForPrompt(item); + const itemTokens = await countGptMessagesTokens([ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: itemContent + } + ]); + + if (currentTokens + itemTokens > tokenLimit) { + addLog.info('[EvaluationSummary] Token limit reached, stopping data addition', { + currentTokens, + itemTokens, + tokenLimit, + includedItems: truncatedData.length, + totalItems: optimizedData.length + }); + break; + } + + truncatedData.push(item); + currentTokens += itemTokens; + } + + return { + truncatedData, + truncatedCount: truncatedData.length + }; + } catch (error) { + addLog.error('[EvaluationSummary] Token calculation failed', { + error + }); + const optimizedData = this.selectOptimalData(data); + const fallbackCount = Math.ceil(optimizedData.length * 0.5); + return { + truncatedData: optimizedData.slice(0, fallbackCount), + truncatedCount: fallbackCount + }; + } + } + + private static async callLLMForSummary( + data: any[], + language: LanguageType, + llmModel?: string + ): Promise<{ + summary: string; + usage: any; + }> { + try { + const modelData = getEvaluationSummaryModel(llmModel); + const userPrompt = this.buildUserPrompt(data, language); + + const messages: ChatCompletionMessageParam[] = [ + { + role: 'user' as const, + content: userPrompt + } + ]; + + const requestMessages = await loadRequestMessages({ + messages, + useVision: false + }); + + const { response, isStreamResponse } = await createChatCompletion({ + body: { + model: modelData.model, + messages: requestMessages as any, + temperature: TEMPERATURE_FOR_EVALUATION_SUMMARY, + max_tokens: MAX_TOKEN_FOR_EVALUATION_SUMMARY, + stream: false + }, + modelData, + timeout: 30000 + }); + + if (isStreamResponse) { + throw new Error(EvaluationErrEnum.summaryStreamResponseNotSupported); + } + + const summary = response.choices[0]?.message?.content || '生成总结失败'; + const usage = response.usage; + + return { summary, usage }; + } catch (error) { + addLog.error('[EvaluationSummary] LLM call failed', { + error + }); + throw error; + } + } + + private static async recordUsage( + evaluation: EvaluationSchemaType, + evaluator: any, + usage: any, + llmModel: string | undefined + ): Promise { + if (!usage) return; + + try { + const modelData = getEvaluationSummaryModel(llmModel); + const inputTokens = usage?.prompt_tokens || 0; + const outputTokens = usage?.completion_tokens || 0; + + const { totalPoints } = formatModelChars2Points({ + modelId: modelData.id, + inputTokens, + outputTokens + }); + + await createMergedEvaluationUsage({ + evalId: evaluation._id.toString(), + teamId: evaluation.teamId.toString(), + tmbId: evaluation.tmbId.toString(), + usageId: evaluation.usageId, + totalPoints, + type: 'summary', + inputTokens, + outputTokens + }); + + addLog.info('[EvaluationSummary] Usage recorded successfully', { + evalId: evaluation._id.toString(), + metricId: evaluator.metric._id.toString(), + totalTokens: usage.total_tokens, + totalPoints + }); + } catch (error) { + addLog.error('[EvaluationSummary] Usage recording failed', { + evalId: evaluation._id.toString(), + metricId: evaluator.metric._id.toString(), + error + }); + } + } + + private static buildUserPrompt(data: any[], language: LanguageType): string { + const isAllPerfect = this.isAllPerfectScores(data); + + let selectedTemplate: string; + let selectedExample: string; + + if (language === LanguageType.English) { + selectedTemplate = isAllPerfect ? strengthAnalysisTemplateEn : problemAnalysisTemplateEn; + selectedExample = isAllPerfect ? goodExampleEn : badExampleEn; + } else if (language === LanguageType.TraditionalChinese) { + selectedTemplate = isAllPerfect ? strengthAnalysisTemplateZhTW : problemAnalysisTemplateZhTW; + selectedExample = isAllPerfect ? goodExampleZhTW : badExampleZhTW; + } else { + selectedTemplate = isAllPerfect ? strengthAnalysisTemplateZhCN : problemAnalysisTemplateZhCN; + selectedExample = isAllPerfect ? goodExampleZhCN : badExampleZhCN; + } + + const evaluationResult = data.map((item) => this.formatDataItemForPrompt(item)).join('\n\n'); + + return selectedTemplate + .replace('{language}', LanguageDisplayNameMap[language]) + .replace('{example}', selectedExample) + .replace('{evaluation_result_for_single_metric}', evaluationResult); + } + + private static formatDataItemForPrompt(item: any): string { + const reason = item.matchingMetricResult?.data?.reason || 'no content'; + + return ` +eval_reason: ${reason}`; + } + + private static isAllPerfectScores(data: any[]): boolean { + if (data.length === 0) return false; + return data.every((item) => (item.matchingMetricResult?.data?.score || 0) >= PERFECT_SCORE); + } + + private static selectOptimalData(data: any[]): any[] { + if (data.length === 0) return []; + + // Check if all scores are perfect + const isAllPerfect = this.isAllPerfectScores(data); + return this.selectOptimalDataWithFlag(data, isAllPerfect); + } + + private static selectOptimalDataWithFlag(data: any[], isAllPerfect: boolean): any[] { + if (data.length === 0) return []; + + if (isAllPerfect) { + return data; + } else { + // Prioritize non-perfect score data for problem analysis + const nonPerfectData = data.filter( + (item) => (item.matchingMetricResult?.data?.score || 0) < PERFECT_SCORE + ); + return [...nonPerfectData]; + } + } + + // Trigger summary generation when evaluation completes, only for metrics without existing summaries + static async triggerSummaryGeneration(evalId: string): Promise { + try { + const allEvalItemsStatus = await MongoEvalItem.find( + { evalId: new Types.ObjectId(evalId) }, + { status: 1 } + ).lean(); + + const allItemsAbnormal = + allEvalItemsStatus.length > 0 && + allEvalItemsStatus.every((item) => item.status === EvaluationStatusEnum.error); + + if (allItemsAbnormal) { + addLog.warn( + '[Evaluation] All evaluation items have error status, skipping summary generation for all metrics', + { + evalId, + totalItems: allEvalItemsStatus.length + } + ); + return; + } + + const itemsWithSuccessfulOutputs = await MongoEvalItem.countDocuments({ + evalId: new Types.ObjectId(evalId), + evaluatorOutputs: { + $elemMatch: { + status: MetricResultStatusEnum.Success, + 'data.score': { $exists: true, $ne: null } + } + } + }); + + if (itemsWithSuccessfulOutputs === 0) { + return; + } + + const currentEvaluation = await MongoEvaluation.findById(evalId).lean(); + + if (!currentEvaluation?.evaluators || currentEvaluation.evaluators.length === 0) { + return; + } + + const metricsNeedingSummary: string[] = []; + + currentEvaluation.evaluators.forEach((evaluator: any, index: number) => { + const metricId = evaluator.metric._id.toString(); + const summaryConfig = currentEvaluation.summaryData.summaryConfigs[index]; + + if (!summaryConfig?.summary || summaryConfig.summary.trim() === '') { + metricsNeedingSummary.push(metricId); + } + }); + + if (metricsNeedingSummary.length > 0) { + await EvaluationSummaryService.generateSummaryReports(evalId, metricsNeedingSummary); + } else { + addLog.debug( + `[Evaluation] All metrics already have summaries, skipping summary generation: ${evalId}` + ); + } + } catch (error) { + addLog.warn(`[Evaluation] Failed to trigger summary generation: ${evalId}`, { + error + }); + } + } +} diff --git a/packages/service/core/evaluation/summary/queue.ts b/packages/service/core/evaluation/summary/queue.ts new file mode 100644 index 000000000000..f53c8d58a9ca --- /dev/null +++ b/packages/service/core/evaluation/summary/queue.ts @@ -0,0 +1,138 @@ +import { getQueue } from '../../../common/bullmq'; +import { QueueNames } from '../../../common/bullmq'; +import { addLog } from '../../../common/system/log'; +import { SummaryStatusHandler } from './statusHandler'; +import { SummaryStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { + createJobCleaner, + type JobCleanupResult, + type JobCleanupOptions +} from '../../../common/bullmq/utils'; +import { type LanguageType, detectEvaluationLanguage } from './util/languageUtil'; + +export type EvaluationSummaryJobData = { + evalId: string; + metricId: string; + languageType: LanguageType; +}; + +export function getEvaluationSummaryQueue() { + return getQueue(QueueNames.evaluationSummary, { + defaultJobOptions: { + removeOnComplete: { + count: 0 + }, + removeOnFail: { + count: 0 + } + } + }); +} + +async function checkActiveSummaryJob(evalId: string, metricId: string): Promise { + try { + const queue = getEvaluationSummaryQueue(); + const activeJobs = await queue.getJobs(['active', 'waiting', 'delayed', 'prioritized']); + const existingJob = activeJobs.find( + (job) => job.data.evalId === evalId && job.data.metricId === metricId + ); + + return !!existingJob; + } catch (error) { + addLog.error('[EvaluationSummary] Failed to check active summary job', { + evalId, + metricId, + error + }); + return false; + } +} + +export async function addSummaryTaskToQueue(evalId: string, metricIds: string[]): Promise { + try { + const queue = getEvaluationSummaryQueue(); + + // Detect language once for the entire evaluation + addLog.info('[EvaluationSummary] Detecting evaluation language', { evalId }); + const { language: languageType } = await detectEvaluationLanguage(evalId); + addLog.info('[EvaluationSummary] Language detected', { evalId, languageType }); + + const addPromises = metricIds.map(async (metricId) => { + const hasActiveJob = await checkActiveSummaryJob(evalId, metricId); + if (hasActiveJob) { + addLog.warn('[EvaluationSummary] Task already in progress, skipping', { + evalId, + metricId + }); + return null; + } + + await SummaryStatusHandler.updateStatus({ + evalId, + metricId, + status: SummaryStatusEnum.pending + }); + + addLog.info('[EvaluationSummary] Adding new task to queue', { + evalId, + metricId, + languageType + }); + + return queue.add( + 'generateSummary', + { + evalId, + metricId, + languageType + }, + { + attempts: 1, + deduplication: { + id: `${evalId}_${metricId}`, + ttl: 5000 + } + } + ); + }); + + const results = await Promise.all(addPromises); + const successfullyAdded = results.filter(Boolean).length; + const skipped = metricIds.length - successfullyAdded; + + addLog.info('[EvaluationSummary] Task successfully added to queue', { + evalId, + totalRequested: metricIds.length, + validMetricsCount: successfullyAdded, + skippedCount: skipped + }); + } catch (error) { + addLog.error('[EvaluationSummary] Failed to add tasks to queue', { + evalId, + metricIds, + error + }); + throw error; + } +} + +export const removeEvaluationSummaryJobs = async ( + evalId: string, + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + const queue = getEvaluationSummaryQueue(); + + const filterFn = (job: any) => { + return String(job.data?.evalId) === String(evalId); + }; + + const result = await cleaner.cleanAllJobsByFilter(queue, filterFn, QueueNames.evaluationSummary); + + addLog.debug('Evaluation summary jobs cleanup completed', { + evalId, + result + }); + + return result; +}; diff --git a/packages/service/core/evaluation/summary/statusHandler.ts b/packages/service/core/evaluation/summary/statusHandler.ts new file mode 100644 index 000000000000..efdea92e30bb --- /dev/null +++ b/packages/service/core/evaluation/summary/statusHandler.ts @@ -0,0 +1,120 @@ +import { MongoEvaluation } from '../task/schema'; +import { SummaryStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { addLog } from '../../../common/system/log'; +import type { UpdateStatusParams } from '@fastgpt/global/core/evaluation/type'; + +export class SummaryStatusHandler { + static async updateStatus({ + evalId, + metricId, + status, + errorReason + }: UpdateStatusParams): Promise { + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) { + addLog.warn('[SummaryStatusHandler] Evaluation not found', { evalId, metricId }); + return false; + } + + const evaluatorIndex = evaluation.evaluators.findIndex( + (evaluator: any) => evaluator.metric._id.toString() === metricId + ); + + if (evaluatorIndex === -1) { + addLog.warn('[SummaryStatusHandler] Metric not found in evaluation', { evalId, metricId }); + return false; + } + + const updateObj: Record = { + [`summaryData.summaryConfigs.${evaluatorIndex}.summaryStatus`]: status + }; + + switch (status) { + case SummaryStatusEnum.generating: + updateObj[`summaryData.summaryConfigs.${evaluatorIndex}.errorReason`] = ''; + break; + + case SummaryStatusEnum.completed: + updateObj[`summaryData.summaryConfigs.${evaluatorIndex}.errorReason`] = ''; + break; + + case SummaryStatusEnum.failed: + updateObj[`summaryData.summaryConfigs.${evaluatorIndex}.errorReason`] = + errorReason || 'Unknown error'; + break; + } + + await MongoEvaluation.updateOne({ _id: evalId }, { $set: updateObj }); + + addLog.info('[SummaryStatusHandler] Status updated successfully', { + evalId, + metricId, + status, + errorReason, + evaluatorIndex + }); + + return true; + } catch (error) { + addLog.error('[SummaryStatusHandler] Failed to update status', { + evalId, + metricId, + status, + error + }); + return false; + } + } + + static async batchUpdateStatus( + evalId: string, + updates: Array<{ + metricId: string; + status: SummaryStatusEnum; + errorReason?: string; + }> + ): Promise { + const results = await Promise.allSettled( + updates.map((update) => + this.updateStatus({ + evalId, + metricId: update.metricId, + status: update.status, + errorReason: update.errorReason + }) + ) + ); + + return results.map((result) => result.status === 'fulfilled' && result.value); + } + + static async getStatus(evalId: string, metricId: string): Promise { + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) { + return null; + } + + const evaluatorIndex = evaluation.evaluators.findIndex( + (evaluator: any) => evaluator.metric._id.toString() === metricId + ); + + if (evaluatorIndex === -1) { + return null; + } + + return ( + evaluation.summaryData?.summaryConfigs?.[evaluatorIndex]?.summaryStatus || + SummaryStatusEnum.pending + ); + } catch (error) { + addLog.error('[SummaryStatusHandler] Failed to get status', { + evalId, + metricId, + error + }); + return null; + } + } +} diff --git a/packages/service/core/evaluation/summary/util/languageUtil.ts b/packages/service/core/evaluation/summary/util/languageUtil.ts new file mode 100644 index 000000000000..9e81a529095f --- /dev/null +++ b/packages/service/core/evaluation/summary/util/languageUtil.ts @@ -0,0 +1,107 @@ +import { MongoEvalItem } from '../../task/schema'; +import { Types } from '../../../../common/mongo'; +import { addLog } from '../../../../common/system/log'; +import { franc } from 'franc'; +import { tify } from 'chinese-conv'; + +export enum LanguageType { + English = 'en', + SimplifiedChinese = 'zh-CN', + TraditionalChinese = 'zh-TW' +} + +// Language type to display name mapping +export const LanguageDisplayNameMap: Record = { + [LanguageType.English]: 'English', + [LanguageType.SimplifiedChinese]: '简体中文', + [LanguageType.TraditionalChinese]: '繁體中文' +}; + +interface LanguageDetectionResult { + language: LanguageType; +} + +function detectLanguageFromText(text: string): 'en' | 'zh-cn' | 'zh-tw' { + const content = text || ''; + const lang = franc(content); + addLog.debug('[LanguageUtil] franc detected language', { lang }); + + if (lang === 'eng') { + return 'en'; + } + + // detect result is cmn or has chinese characters + if (lang === 'cmn' || /[\u4e00-\u9fff]/.test(content)) { + const hant = tify(content); + return content === hant ? 'zh-tw' : 'zh-cn'; + } + + return 'en'; +} + +/** + * Convert language code to LanguageType enum + */ +function languageCodeToType(code: 'en' | 'zh-cn' | 'zh-tw'): LanguageType { + switch (code) { + case 'en': + return LanguageType.English; + case 'zh-cn': + return LanguageType.SimplifiedChinese; + case 'zh-tw': + return LanguageType.TraditionalChinese; + default: + return LanguageType.SimplifiedChinese; + } +} + +// Detect primary language of evaluation user inputs +export async function detectEvaluationLanguage(evalId: string): Promise { + try { + addLog.info('[LanguageUtil] Starting language detection', { evalId }); + + const evalItems = await MongoEvalItem.find( + { evalId: new Types.ObjectId(evalId) }, + { 'dataItem.userInput': 1 } + ).lean(); + + if (!evalItems || evalItems.length === 0) { + addLog.warn('[LanguageUtil] No evaluation items found', { evalId }); + return { + language: LanguageType.SimplifiedChinese + }; + } + + const userInputTexts = evalItems + .map((item) => item.dataItem?.userInput || '') + .filter((input) => input.trim().length > 0); + + if (userInputTexts.length === 0) { + addLog.warn('[LanguageUtil] No user input text found', { evalId }); + return { + language: LanguageType.SimplifiedChinese + }; + } + + const combinedUserInput = userInputTexts.join('\n'); + const detectedLanguageCode = detectLanguageFromText(combinedUserInput); + const language = languageCodeToType(detectedLanguageCode); + + addLog.info('[LanguageUtil] Language detection completed', { + evalId, + language + }); + + return { + language + }; + } catch (error) { + addLog.error('[LanguageUtil] Language detection failed', { + evalId, + error + }); + return { + language: LanguageType.SimplifiedChinese + }; + } +} diff --git a/packages/service/core/evaluation/summary/util/weightCalculator.ts b/packages/service/core/evaluation/summary/util/weightCalculator.ts new file mode 100644 index 000000000000..68a6b1075c79 --- /dev/null +++ b/packages/service/core/evaluation/summary/util/weightCalculator.ts @@ -0,0 +1,90 @@ +import { SummaryStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import type { + EvaluatorSchema, + EvaluationParamsType, + EvaluationParamsWithDeafultConfigType +} from '@fastgpt/global/core/evaluation/type'; +import { addLog } from '../../../../common/system/log'; +import { CalculateMethodEnum } from '@fastgpt/global/core/evaluation/constants'; + +export function calculateMetricWeights(metricCount: number): number[] { + if (metricCount <= 0) { + return []; + } + + if (metricCount === 1) { + return [100]; + } + + const baseWeight = Math.floor(100 / metricCount); + const remainder = 100 - baseWeight * metricCount; + const weights: number[] = new Array(metricCount).fill(baseWeight); + weights[metricCount - 1] += remainder; + + return weights; +} + +function getDefaultThreshold(): number { + const threshold = global.systemEnv?.evalConfig?.caseResultThreshold || 0.8; + if (isNaN(threshold) || threshold < 0 || threshold > 1) { + addLog.warn( + `[getDefaultThreshold] Invalid caseResultThreshold value: ${threshold}. Using default: 0.8` + ); + return 0.8; + } + return threshold; +} + +// Build evaluation configuration with cleaned evaluators and separate summaryConfigs +export function buildEvalDataConfig( + evaluationParams: EvaluationParamsType +): EvaluationParamsWithDeafultConfigType { + const evaluators = evaluationParams?.evaluators || []; + + if (!evaluators || evaluators.length === 0) { + return { + ...evaluationParams, + evaluators: [], + summaryData: { + calculateType: CalculateMethodEnum.mean, + summaryConfigs: [] + } + }; + } + + const weights = calculateMetricWeights(evaluators.length); + const caseResultThreshold = getDefaultThreshold(); + + const cleanedEvaluators: EvaluatorSchema[] = evaluators.map((evaluator) => ({ + metric: evaluator.metric, + runtimeConfig: evaluator.runtimeConfig, + thresholdValue: evaluator.thresholdValue ?? caseResultThreshold + })); + + const summaryConfigs = evaluators.map((evaluator, index) => ({ + metricId: evaluator.metric._id.toString(), + metricName: evaluator.metric.name, + weight: weights[index], + summary: '', + summaryStatus: SummaryStatusEnum.pending, + errorReason: '' + })); + + addLog.debug('[buildEvalDataConfig] Processed configuration:', { + evaluators: cleanedEvaluators.map((evaluator, index) => ({ + metricId: evaluator.metric._id, + metricName: evaluator.metric.name, + thresholdValue: evaluator.thresholdValue, + summaryConfig: summaryConfigs[index] + })) + }); + + return { + ...evaluationParams, + evaluators: cleanedEvaluators, + summaryData: { + calculateType: CalculateMethodEnum.mean, + summaryConfigs + } + }; +} diff --git a/packages/service/core/evaluation/summary/worker.ts b/packages/service/core/evaluation/summary/worker.ts new file mode 100644 index 000000000000..4310536c7fdc --- /dev/null +++ b/packages/service/core/evaluation/summary/worker.ts @@ -0,0 +1,162 @@ +import { getWorker, QueueNames } from '../../../common/bullmq'; +import { addLog } from '../../../common/system/log'; +import { MongoEvaluation } from '../task/schema'; +import { SummaryStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { type EvaluationSummaryJobData, getEvaluationSummaryQueue } from './queue'; +import { EvaluationSummaryService } from './index'; +import { SummaryStatusHandler } from './statusHandler'; + +export function initEvaluationSummaryWorker() { + const worker = getWorker( + QueueNames.evaluationSummary, + async (job) => { + const { evalId, metricId, languageType } = job.data; + + addLog.info('[EvaluationSummary] Worker processing single metric task', { + jobId: job.id, + evalId, + metricId, + languageType + }); + + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) { + throw new Error(`Evaluation task not found: ${evalId}`); + } + + const evaluatorIndex = evaluation.evaluators.findIndex( + (evaluator: any) => evaluator.metric._id.toString() === metricId + ); + + if (evaluatorIndex === -1) { + throw new Error(`Metric ${metricId} does not belong to evaluation ${evalId}`); + } + + await EvaluationSummaryService.generateSingleMetricSummary( + evaluation, + metricId, + evaluatorIndex, + evaluation.evaluators[evaluatorIndex], + languageType + ); + + addLog.info('[EvaluationSummary] Worker task completed successfully', { + jobId: job.id, + evalId, + metricId + }); + } catch (error) { + addLog.error('[EvaluationSummary] Worker task failed', { + jobId: job.id, + evalId, + metricId, + error + }); + throw error; + } + }, + { + stalledInterval: 30000, + maxStalledCount: 3 + } + ); + + worker.on('active', async (job) => { + if (job?.data) { + const { evalId, metricId } = job.data; + + addLog.info('[EvaluationSummary] Task started', { + jobId: job.id, + evalId, + metricId + }); + + await SummaryStatusHandler.updateStatus({ + evalId, + metricId, + status: SummaryStatusEnum.generating + }); + } + }); + + worker.on('completed', async (job) => { + if (job?.data) { + const { evalId, metricId } = job.data; + + addLog.info('[EvaluationSummary] Task completed', { + jobId: job.id, + evalId, + metricId + }); + + await SummaryStatusHandler.updateStatus({ + evalId, + metricId, + status: SummaryStatusEnum.completed + }); + } + }); + + worker.on('stalled', async (jobId: string) => { + try { + const summaryQueue = getEvaluationSummaryQueue(); + const job = await summaryQueue.getJob(jobId); + + if (job?.data) { + const { evalId, metricId } = job.data; + + addLog.warn('[EvaluationSummary] Task job stalled, will be retried', { + jobId, + evalId, + metricId + }); + + await SummaryStatusHandler.updateStatus({ + evalId, + metricId, + status: SummaryStatusEnum.pending + }); + } else { + addLog.warn( + '[EvaluationSummary] Task job stalled, will be retried (could not get job data)', + { + jobId + } + ); + } + } catch (error) { + addLog.warn( + '[EvaluationSummary] Task job stalled, will be retried (could not get job data)', + { + jobId, + error + } + ); + } + }); + + worker.on('failed', async (job, error) => { + if (job?.data) { + const { evalId, metricId } = job.data; + + addLog.warn('[EvaluationSummary] Task failed', { + jobId: job.id, + evalId, + metricId, + error: error.message + }); + + await SummaryStatusHandler.updateStatus({ + evalId, + metricId, + status: SummaryStatusEnum.failed, + errorReason: getErrText(error) + }); + } + }); + + addLog.info('[EvaluationSummary] Worker created successfully'); + return worker; +} diff --git a/packages/service/core/evaluation/synthesizer/ditingSynthesisClient.ts b/packages/service/core/evaluation/synthesizer/ditingSynthesisClient.ts new file mode 100644 index 000000000000..b1cf4d92003e --- /dev/null +++ b/packages/service/core/evaluation/synthesizer/ditingSynthesisClient.ts @@ -0,0 +1,51 @@ +import type { + HttpConfig, + DatasetSynthesisRequest, + DatasetSynthesisResponse +} from '@fastgpt/global/core/evaluation/metric/type'; + +function loadHttpConfigFromEnv(): HttpConfig { + return { + url: process.env.DITING_BASE_URL || 'http://diting:3000', + timeout: Number(process.env.DITING_TIMEOUT) || 300000 + }; +} + +export function createDitingSynthesisClient(config: HttpConfig = loadHttpConfigFromEnv()) { + function getFullUrl(path: string) { + return config.url.replace(/\/$/, '') + path; + } + + return { + async runSynthesis(request: DatasetSynthesisRequest): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout || 30000); + + try { + const res = await fetch(getFullUrl('/api/v1/dataset-synthesis/runs'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + const errorText = await res.text().catch(() => res.statusText); + throw new Error(`HTTP ${res.status}: ${errorText}`); + } + + return (await res.json()) as DatasetSynthesisResponse; + } catch (err: any) { + clearTimeout(timeoutId); + if (err.name === 'AbortError') { + throw new Error('Synthesis request timeout'); + } + throw err; + } + } + }; +} diff --git a/packages/service/core/evaluation/synthesizer/index.ts b/packages/service/core/evaluation/synthesizer/index.ts new file mode 100644 index 000000000000..a0a41dbdf09b --- /dev/null +++ b/packages/service/core/evaluation/synthesizer/index.ts @@ -0,0 +1,107 @@ +import type { + SynthesisCase, + SynthesizerConfig, + DatasetSynthesisResponse, + EvalModelConfigType, + SynthesisResult +} from '@fastgpt/global/core/evaluation/metric/type'; +import { getLLMModelById, getEmbeddingModelById } from '../../ai/model'; +import { getModelEndpointConfig } from '../../ai/config'; +import { createDitingSynthesisClient } from './ditingSynthesisClient'; + +export abstract class Synthesizer { + protected synthesizerConfig: SynthesizerConfig; + protected llmConfig?: EvalModelConfigType; + protected embeddingConfig?: EvalModelConfigType; + + constructor( + synthesizerConfig: SynthesizerConfig, + llmConfig?: EvalModelConfigType, + embeddingConfig?: EvalModelConfigType + ) { + this.llmConfig = llmConfig; + this.embeddingConfig = embeddingConfig; + this.synthesizerConfig = synthesizerConfig; + } + + abstract synthesize(synthesisCase: SynthesisCase): Promise; +} + +export class DitingSynthesizer extends Synthesizer { + private client: ReturnType; + + constructor( + synthesizerConfig: SynthesizerConfig, + llmConfig?: EvalModelConfigType, + embeddingConfig?: EvalModelConfigType + ) { + super(synthesizerConfig, llmConfig, embeddingConfig); + this.client = createDitingSynthesisClient(); + } + + async synthesize(synthesisCase: SynthesisCase): Promise { + const response: DatasetSynthesisResponse = await this.client.runSynthesis({ + inputData: { + context: synthesisCase.context, + themes: synthesisCase.themes + }, + synthesizerConfig: this.synthesizerConfig, + llmConfig: this.llmConfig!, + embeddingConfig: this.embeddingConfig + }); + + if (response.status === 'success' && response.data) { + return { + synthesisName: this.synthesizerConfig.synthesizerName, + status: response.status, + data: response.data, + usages: response.usages, + error: response.error, + totalPoints: 0 + }; + } else { + throw new Error(response.error || 'Synthesis failed'); + } + } +} + +export function createSynthesizerInstance( + synthesizerName: string, + llmConfig?: EvalModelConfigType, + embeddingConfig?: EvalModelConfigType +): Synthesizer { + const synthesizerConfig: SynthesizerConfig = { + synthesizerName: synthesizerName + }; + + if (llmConfig?.modelId) { + try { + const llm = getLLMModelById(llmConfig.modelId); + const endpoint = getModelEndpointConfig(llm); + llmConfig = { + ...llmConfig, + name: endpoint.name, + baseUrl: endpoint.baseUrl, + apiKey: endpoint.apiKey + }; + } catch (err) { + throw new Error(`Get LLM model failed: ${(err as Error).message}`); + } + } + + if (embeddingConfig?.modelId) { + try { + const embedding = getEmbeddingModelById(embeddingConfig.modelId); + const endpoint = getModelEndpointConfig(embedding); + embeddingConfig = { + ...embeddingConfig, + baseUrl: endpoint.baseUrl, + apiKey: endpoint.apiKey + }; + } catch (err) { + throw new Error(`Get embedding model failed: ${(err as Error).message}`); + } + } + + return new DitingSynthesizer(synthesizerConfig, llmConfig, embeddingConfig); +} diff --git a/packages/service/core/evaluation/target/index.ts b/packages/service/core/evaluation/target/index.ts new file mode 100644 index 000000000000..ddc80c272f52 --- /dev/null +++ b/packages/service/core/evaluation/target/index.ts @@ -0,0 +1,375 @@ +import type { + TargetInput, + TargetOutput, + WorkflowConfig, + EvalTarget +} from '@fastgpt/global/core/evaluation/type'; +import { + Validatable, + ValidationResultUtils, + type ValidationResult, + type ValidationError +} from '@fastgpt/global/core/evaluation/validate'; +import { dispatchWorkFlow } from '../../workflow/dispatch'; +import { getAppVersionById } from '../../app/version/controller'; +import { MongoApp } from '../../app/schema'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { + getWorkflowEntryNodeIds, + storeEdges2RuntimeEdges, + storeNodes2RuntimeNodes +} from '@fastgpt/global/core/workflow/runtime/utils'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { + ChatRoleEnum, + ChatSourceEnum, + getChatSourceName +} from '@fastgpt/global/core/chat/constants'; +import type { + UserChatItemValueItemType, + UserChatItemType, + AIChatItemType +} from '@fastgpt/global/core/chat/type'; +import { WORKFLOW_MAX_RUN_TIMES } from '../../workflow/constants'; +import { getRunningUserInfoByTmbId } from '../../../support/user/team/utils'; +import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; +import { pushChatRecords as saveChat } from '../../chat/saveChat'; +import { MongoChatItem } from '../../chat/chatItemSchema'; +import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; + +// Helper function to extract retrieval context from workflow results +function extractRetrievalContext(flowResponses: ChatHistoryItemResType[]): string[] { + const retrievalContext: string[] = []; + + // Find all datasetSearchNode responses + const datasetSearchResponses = flowResponses.filter( + (response) => response.moduleType === FlowNodeTypeEnum.datasetSearchNode + ); + + // Extract quoteList from each datasetSearchNode response + for (const response of datasetSearchResponses) { + if (response.quoteList && Array.isArray(response.quoteList)) { + for (const quote of response.quoteList) { + // Extract the content from q and a fields and combine them + const content = [quote.q, quote.a].filter(Boolean).join('\n').trim(); + if (content) { + retrievalContext.push(content); + } + } + } + } + + return retrievalContext; +} + +// Evaluation target base class +export abstract class EvaluationTarget extends Validatable { + abstract execute(input: TargetInput): Promise; + // validate() method is inherited from Validatable base class +} + +// Workflow target implementation +export class WorkflowTarget extends EvaluationTarget { + private config: WorkflowConfig; + + constructor(config: WorkflowConfig) { + super(); + this.config = config; + } + + async execute(input: TargetInput): Promise { + // Get application information + const appData = await MongoApp.findById(this.config.appId); + if (!appData) { + throw new Error(EvaluationErrEnum.evalAppNotFound); + } + + // Get user information and permissions + const { nodes, edges, chatConfig } = await getAppVersionById({ + appId: String(appData._id), + versionId: this.config.versionId, + app: appData + }); + + // Construct query + const query: UserChatItemValueItemType[] = [ + { + text: { + content: input.userInput + } + } + ]; + + // Construct conversation history based on input.context + const histories: (UserChatItemType | AIChatItemType)[] = []; + + // Add context as background knowledge in conversation history + if (input.context && input.context.length > 0) { + const contextText = input.context + .filter((item) => item && item.trim()) + .map((item, index) => `${index + 1}. ${item}`) + .join('\n'); + + if (contextText) { + histories.push({ + obj: ChatRoleEnum.Human, + value: [ + { + text: { + content: `请参考以下背景知识回答问题:\n${contextText}` + } + } + ] + }); + } + } + + const chatId = getNanoid(); + + // Execute workflow + const { assistantResponses, flowUsages, flowResponses, system_memories, durationSeconds } = + await dispatchWorkFlow({ + chatId, + mode: 'chat', + usageSource: UsageSourceEnum.evaluation, + runningAppInfo: { + id: String(appData._id), + name: String(appData.name), + teamId: String(appData.teamId), + tmbId: String(appData.tmbId) + }, + runningUserInfo: await getRunningUserInfoByTmbId(appData.tmbId), + uid: String(appData.tmbId), + runtimeNodes: storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)), + runtimeEdges: storeEdges2RuntimeEdges(edges), + variables: input.targetCallParams?.variables || {}, + query, + chatConfig: { ...chatConfig, ...(this.config.chatConfig || {}) }, + histories, + stream: false, + maxRunTimes: WORKFLOW_MAX_RUN_TIMES + }); + + let response = (() => { + return assistantResponses + .map((item) => item?.text?.content) + .filter(Boolean) + .join('\n'); + })(); + + // Format response content + response = removeDatasetCiteText(response.trim(), false); + + // Construct user question object + const userQuestion: UserChatItemType = { + obj: ChatRoleEnum.Human, + value: query + }; + + // Construct AI response object + const aiResponse: AIChatItemType = { + obj: ChatRoleEnum.AI, + value: assistantResponses, + memories: system_memories, + [DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses + }; + + // Save chat record + await saveChat({ + chatId, + appId: appData._id, + teamId: appData.teamId, + tmbId: appData.tmbId, + nodes, + appChatConfig: { ...chatConfig, ...(this.config.chatConfig || {}) }, + variables: input.targetCallParams?.variables || {}, + newTitle: getChatTitleFromChatMessage(userQuestion), + source: ChatSourceEnum.evaluation, + sourceName: getChatSourceName(ChatSourceEnum.evaluation, String(appData.name)), + userContent: userQuestion, + aiContent: aiResponse, + durationSeconds + }); + + // Get the latest AI chat item dataId from the saved chat record + const latestAiChatItem = await MongoChatItem.findOne( + { + chatId, + appId: appData._id, + obj: ChatRoleEnum.AI + }, + 'dataId' + ) + .sort({ _id: -1 }) + .lean(); + + const aiChatItemDataId = latestAiChatItem?.dataId || ''; + + return { + actualOutput: response, + retrievalContext: extractRetrievalContext(flowResponses), + usage: flowUsages, + responseTime: durationSeconds, + chatId, + aiChatItemDataId + }; + } + + async validate(): Promise { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + try { + // Validate basic configuration + if (!this.config.appId) { + errors.push({ + code: EvaluationErrEnum.evalTargetAppIdMissing, + message: 'App ID is required for workflow target', + field: 'config.appId' + }); + return { isValid: false, errors, warnings }; + } + + if (!this.config.versionId) { + errors.push({ + code: EvaluationErrEnum.evalTargetVersionIdMissing, + message: 'Version ID is required for workflow target', + field: 'config.versionId' + }); + return { isValid: false, errors, warnings }; + } + + // Validate app existence and accessibility + const appData = await MongoApp.findById(this.config.appId); + if (!appData) { + errors.push({ + code: EvaluationErrEnum.evalAppNotFound, + message: `App with ID '${this.config.appId}' not found or not accessible`, + field: 'config.appId', + debugInfo: { appId: this.config.appId } + }); + return { isValid: false, errors, warnings }; + } + + // Validate that the version exists and is accessible + try { + const versionData = await getAppVersionById({ + appId: String(appData._id), + versionId: this.config.versionId, + app: appData + }); + + // If versionId was specified but the returned version doesn't match (fell back to latest), + // it means the specified version doesn't exist + if (String(versionData.versionId).trim() !== String(this.config.versionId).trim()) { + errors.push({ + code: EvaluationErrEnum.evalAppVersionNotFound, + message: `App version '${this.config.versionId}' not found for app '${appData.name}'`, + field: 'config.versionId', + debugInfo: { + appId: this.config.appId, + appName: appData.name, + requestedVersion: this.config.versionId, + availableVersion: versionData.versionId + } + }); + } + } catch (versionError) { + errors.push({ + code: EvaluationErrEnum.evalAppVersionNotFound, + message: `Failed to validate app version: ${versionError instanceof Error ? versionError.message : String(versionError)}`, + field: 'config.versionId', + debugInfo: { + appId: this.config.appId, + requestedVersion: this.config.versionId, + error: versionError instanceof Error ? versionError.message : String(versionError) + } + }); + } + + // Validate chat config if present + if (this.config.chatConfig) { + // Add basic validation for chat config structure + if (typeof this.config.chatConfig !== 'object') { + errors.push({ + code: EvaluationErrEnum.evalTargetInvalidConfig, + message: 'Chat config must be an object', + field: 'config.chatConfig', + debugInfo: { chatConfigType: typeof this.config.chatConfig } + }); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings: warnings.length > 0 ? warnings : undefined + }; + } catch (error) { + errors.push({ + code: EvaluationErrEnum.evalTargetConfigInvalid, + message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`, + debugInfo: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + } + }); + + return { isValid: false, errors, warnings }; + } + } +} + +// Target factory - creates and validates instance in one step +export async function createTargetInstance( + targetConfig: EvalTarget, + options: { validate?: boolean } = { validate: true } +): Promise { + let targetInstance: EvaluationTarget; + + switch (targetConfig.type) { + case 'workflow': + targetInstance = new WorkflowTarget(targetConfig.config as WorkflowConfig); + break; + default: + throw new Error(EvaluationErrEnum.evalUnsupportedTargetType); + } + + // Validate instance if requested (default behavior) + if (options.validate) { + const validationResult = await targetInstance.validate(); + if (!validationResult.isValid) { + throw ValidationResultUtils.toError(validationResult); + } + } + + return targetInstance; +} + +// Utility function - test the validity of target configuration +export async function validateTargetConfig(targetConfig: EvalTarget): Promise { + try { + const targetInstance = await createTargetInstance(targetConfig, { validate: false }); + return await targetInstance.validate(); + } catch (error) { + // If we can't even create the instance, return validation error + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalTargetConfigInvalid, + message: `Failed to create target instance: ${error instanceof Error ? error.message : String(error)}`, + debugInfo: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + targetType: targetConfig.type + } + } + ] + }; + } +} diff --git a/packages/service/core/evaluation/task/errors.ts b/packages/service/core/evaluation/task/errors.ts new file mode 100644 index 000000000000..a94cb4660ebe --- /dev/null +++ b/packages/service/core/evaluation/task/errors.ts @@ -0,0 +1,141 @@ +import { UnrecoverableError } from 'bullmq'; +import { addLog } from '../../../common/system/log'; +import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +/** + * Unrecoverable evaluation error that prevents BullMQ auto-retry + */ +export class EvaluationUnrecoverableError extends UnrecoverableError { + constructor( + message: string, + public readonly stage: string + ) { + super(message); + this.name = 'EvaluationUnrecoverableError'; + } +} + +/** + * Retryable evaluation error that allows BullMQ auto-retry + */ +export class EvaluationRetryableError extends Error { + constructor( + message: string, + public readonly stage: string + ) { + super(message); + this.name = 'EvaluationRetryableError'; + } +} + +/** + * Error analysis result interface + */ +export interface ErrorAnalysisResult { + isRetriable: boolean; + category?: string; + pattern?: string; +} + +/** + * Simplified error analysis function for retry logic + */ +export const analyzeError = (error: any): ErrorAnalysisResult => { + const errorStr = error?.message || error?.code || String(error); + const lowerErrorStr = errorStr.toLowerCase(); + + // Check network-related errors + const networkErrors = [ + 'NETWORK_ERROR', + 'ECONNRESET', + 'ENOTFOUND', + 'ECONNREFUSED', + 'socket hang up', + 'timeout' + ]; + if (networkErrors.some((pattern) => lowerErrorStr.includes(pattern.toLowerCase()))) { + return { isRetriable: true, category: 'network' }; + } + + // Check HTTP status codes + const httpStatusMatch = errorStr.match(/\b(4\d{2}|5\d{2})\b/); + if (httpStatusMatch) { + const statusCode = httpStatusMatch[1]; + // 429 (Too Many Requests) and 5xx errors are retryable + if (statusCode === '429' || statusCode.startsWith('5')) { + return { + isRetriable: true, + category: statusCode.startsWith('5') ? 'serverError' : 'rateLimit' + }; + } + } + + return { isRetriable: false }; +}; + +/** + * Evaluation error context interface + */ +export interface EvaluationErrorContext { + evalId?: string; + evalItemId?: string; + resourceName?: string; +} + +/** + * Create appropriate BullMQ error type for auto-retry handling + */ +export const createEvaluationError = ( + error: any, + stage: string, + context?: EvaluationErrorContext, + forceRetry?: boolean +): Error => { + const errorStr = error?.message || error?.code || String(error); + + // Use getErrText for all error types for consistent translation + const errorMessage = getErrText(error); + + // Build detailed error context + const logContext = { + stage, + error: errorStr, + originalError: error, + ...context + }; + + // Force retry for specific stages + if (forceRetry) { + addLog.warn(`[Evaluation] Force retryable error in stage ${stage}`, logContext); + return new EvaluationRetryableError(errorMessage, stage); + } + + // Unrecoverable error types + if ( + error === TeamErrEnum.aiPointsNotEnough || + error === EvaluationErrEnum.evalItemNotFound || + error === EvaluationErrEnum.evalTaskNotFound || + error == EvaluationErrEnum.evalDatasetLoadFailed || + error == EvaluationErrEnum.evalEvaluatorsConfigInvalid || + error == EvaluationErrEnum.evalTargetConfigInvalid + ) { + addLog.error(`[Evaluation] Unrecoverable error in stage ${stage}`, logContext); + return new EvaluationUnrecoverableError(errorMessage, stage); + } + + // Use existing error analysis logic to determine retry capability + const { isRetriable, category } = analyzeError(error); + + if (isRetriable) { + addLog.warn( + `[Evaluation] Retryable error in stage ${stage} (category: ${category})`, + logContext + ); + return new EvaluationRetryableError(errorMessage, stage); + } else { + addLog.error(`[Evaluation] Non-retryable error in stage ${stage}`, logContext); + return new EvaluationUnrecoverableError(errorMessage, stage); + } +}; diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts new file mode 100644 index 000000000000..d933cc8b8805 --- /dev/null +++ b/packages/service/core/evaluation/task/index.ts @@ -0,0 +1,1214 @@ +import { MongoEvaluation, MongoEvalItem } from './schema'; +import { MongoEvalDatasetData } from '../dataset/evalDatasetDataSchema'; +import type { + EvaluationSchemaType, + EvaluationItemSchemaType, + CreateEvaluationParams, + EvaluationItemDisplayType, + TargetCallParams, + EvaluationDisplayType +} from '@fastgpt/global/core/evaluation/type'; +import { Types } from 'mongoose'; +import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { + removeEvaluationItemJobs, + removeEvaluationItemJobsByItemId, + addEvaluationItemJob, + evaluationItemQueue, + checkEvaluationItemQueueHealth +} from './mq'; +import { createEvaluationUsage } from '../../../support/wallet/usage/controller'; +import { addLog } from '../../../common/system/log'; +import { buildEvalDataConfig } from '../summary/util/weightCalculator'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { type ClientSession } from '../../../common/mongo'; +import { getEvaluationTaskStatus, getEvaluationTaskStats } from './statusCalculator'; +import { EvaluationSummaryService } from '../summary'; +import { removeEvaluationSummaryJobs } from '../summary/queue'; +import { enqueueEvaluationItems } from './processor'; + +export class EvaluationTaskService { + /** + * Build evaluator failure checks for MongoDB aggregation + */ + private static buildEvaluatorFailChecks(evaluators: any[]) { + return evaluators.map((evaluator, index) => { + const threshold = evaluator.thresholdValue || 0.8; + return { + $or: [ + { + $eq: [ + { + $let: { + vars: { + evaluatorOutput: { $arrayElemAt: ['$evaluatorOutputs', index] } + }, + in: '$$evaluatorOutput.data.score' + } + }, + null + ] + }, + { + $eq: [ + { + $type: { + $let: { + vars: { + evaluatorOutput: { $arrayElemAt: ['$evaluatorOutputs', index] } + }, + in: '$$evaluatorOutput.data.score' + } + } + }, + 'missing' + ] + }, + { + $lt: [ + { + $let: { + vars: { + evaluatorOutput: { $arrayElemAt: ['$evaluatorOutputs', index] } + }, + in: '$$evaluatorOutput.data.score' + } + }, + threshold + ] + } + ] + }; + }); + } + + static async createEvaluation( + params: CreateEvaluationParams & { + teamId: string; + tmbId: string; + } + ): Promise { + const { teamId, tmbId, autoStart = true, ...evaluationParamsInput } = params; + + const evaluationParams = buildEvalDataConfig(evaluationParamsInput); + + const modelId = evaluationParams.evaluators[0]?.runtimeConfig?.llmId || ''; + + const { usageId } = await createEvaluationUsage({ + teamId, + tmbId, + appName: evaluationParams.name, + modelId + }); + + await checkEvaluationItemQueueHealth(); + const createEvaluationWithItems = async (session: ClientSession) => { + // Create evaluation in transaction + const evaluation = await MongoEvaluation.create( + [ + { + ...evaluationParams, + teamId, + tmbId, + usageId: usageId, + createTime: new Date() + } + ], + { session } + ); + + const evaluationObject = evaluation[0].toObject(); + + // Load dataset and create evaluation items + // Ensure we only operate on committed dataset documents inside the transaction + const dataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evaluationParams.evalDatasetCollectionId, + teamId + }) + .session(session) + .lean(); + + if (dataItems.length === 0) { + throw new Error(EvaluationErrEnum.evalDatasetLoadFailed); + } + + // Create evaluation items + const evalItems: Omit[] = []; + for (const dataItem of dataItems) { + const evaluationDataItem = { + _id: dataItem._id, + userInput: dataItem.userInput, + expectedOutput: dataItem.expectedOutput, + context: dataItem.context, + targetCallParams: undefined + }; + + evalItems.push({ + evalId: evaluationObject._id, + dataItem: evaluationDataItem + }); + } + + // Insert evaluation items in transaction + const insertedItems = await MongoEvalItem.insertMany(evalItems, { session }); + addLog.debug(`[Evaluation] Created ${insertedItems.length} evaluation items`); + + return { + evaluation: evaluationObject, + insertedItemCount: insertedItems.length + }; + }; + + const { evaluation, insertedItemCount } = await mongoSessionRun(createEvaluationWithItems); + + // Auto-start evaluation if enabled – this now occurs after the transaction commits + if (autoStart) { + await enqueueEvaluationItems(evaluation._id.toString()); + + addLog.debug(`[Evaluation] Task created and auto-started after commit: ${evaluation._id}`, { + insertedItemCount + }); + } else { + addLog.debug(`[Evaluation] Task created: ${evaluation._id}`, { + insertedItemCount + }); + } + + return evaluation; + } + + static async getEvaluation(evalId: string, teamId: string): Promise { + const evaluation = await MongoEvaluation.findOne({ + _id: new Types.ObjectId(evalId), + teamId: new Types.ObjectId(teamId) + }).lean(); + if (!evaluation) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } + + // Get real-time status from job queues + const status = await getEvaluationTaskStatus(evalId); + + return { + ...evaluation, + status + }; + } + + static async updateEvaluation( + evalId: string, + updates: Partial, + teamId: string + ): Promise { + const result = await MongoEvaluation.updateOne( + { _id: new Types.ObjectId(evalId), teamId: new Types.ObjectId(teamId) }, + { $set: updates } + ); + if (result.matchedCount === 0) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } + } + + static async deleteEvaluation(evalId: string, teamId: string): Promise { + const del = async (session: ClientSession) => { + // Remove tasks from queue to prevent further processing + const [itemCleanupResult, summaryCleanupResult] = await Promise.all([ + removeEvaluationItemJobs(evalId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }), + removeEvaluationSummaryJobs(evalId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }) + ]); + + addLog.debug('Queue cleanup completed for evaluation deletion', { + evalId, + itemCleanup: itemCleanupResult, + summaryCleanup: summaryCleanupResult + }); + + // Delete all evaluation items + await MongoEvalItem.deleteMany({ evalId: new Types.ObjectId(evalId) }, { session }); + + const result = await MongoEvaluation.deleteOne( + { + _id: new Types.ObjectId(evalId), + teamId: new Types.ObjectId(teamId) + }, + { session } + ); + + if (result.deletedCount === 0) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } + + addLog.debug(`[Evaluation] Evaluation task deleted including queue cleanup: ${evalId}`); + }; + + await mongoSessionRun(del); + } + + static async listEvaluations( + teamId: string, + offset: number = 0, + pageSize: number = 20, + searchKey?: string, + accessibleIds?: string[], + tmbId?: string, + isOwner: boolean = false, + appName?: string, + appId?: string + ): Promise<{ list: EvaluationDisplayType[]; total: number }> { + // Build filter and pagination + const filter: any = { teamId: new Types.ObjectId(teamId) }; + const skip = offset; + const limit = pageSize; + const sort = { createTime: -1 as const }; + + // Filter by accessible resources if not owner + let finalFilter = filter; + if (!isOwner && accessibleIds) { + finalFilter = { + ...filter, + $or: [ + { _id: { $in: accessibleIds.map((id) => new Types.ObjectId(id)) } }, + ...(tmbId ? [{ tmbId: new Types.ObjectId(tmbId) }] : []) // Own evaluations + ] + }; + } + + // Build aggregation pipeline + const aggregationPipeline = [ + { $match: finalFilter }, + { + $lookup: { + from: 'eval_dataset_collections', + localField: 'evalDatasetCollectionId', + foreignField: '_id', + as: 'evalDatasetCollection' + } + }, + { + $addFields: { + 'target.config.appObjectId': { $toObjectId: '$target.config.appId' } + } + }, + { + $lookup: { + from: 'apps', + localField: 'target.config.appObjectId', + foreignField: '_id', + as: 'app' + } + }, + { + $addFields: { + 'target.config.versionObjectId': { $toObjectId: '$target.config.versionId' } + } + }, + { + $lookup: { + from: 'app_versions', + localField: 'target.config.versionObjectId', + foreignField: '_id', + as: 'appVersion' + } + }, + { + $addFields: { + 'target.config.appName': { $arrayElemAt: ['$app.name', 0] }, + 'target.config.avatar': { $arrayElemAt: ['$app.avatar', 0] }, + 'target.config.versionName': { $arrayElemAt: ['$appVersion.versionName', 0] } + } + } + ]; + + // Add target filtering if provided + if (appName || appId) { + const targetFilter: any = {}; + + if (appName) { + targetFilter['target.config.appName'] = { $regex: appName, $options: 'i' }; + } + + if (appId) { + targetFilter['target.config.appId'] = appId; + } + + aggregationPipeline.push({ $match: targetFilter }); + } + + // Add search key filtering + if (searchKey) { + aggregationPipeline.push({ + $match: { + $or: [ + { name: { $regex: searchKey, $options: 'i' } }, + { description: { $regex: searchKey, $options: 'i' } }, + { 'target.config.versionId': { $regex: searchKey, $options: 'i' } }, + { 'target.config.versionName': { $regex: searchKey, $options: 'i' } } + ] + } + }); + } + + const [evaluations, total] = await Promise.all([ + MongoEvaluation.aggregate([ + ...aggregationPipeline, + { + $addFields: { + evalDatasetCollectionName: { $arrayElemAt: ['$evalDatasetCollection.name', 0] }, + evalDatasetCollectionId: '$evalDatasetCollectionId', + metricNames: { + $map: { + input: '$evaluators', + as: 'evaluator', + in: '$$evaluator.metric.name' + } + } + } + }, + { + $project: { + _id: 1, + name: 1, + createTime: 1, + finishTime: 1, + errorMessage: 1, + evalDatasetCollectionName: 1, + evalDatasetCollectionId: 1, + target: { + type: '$target.type', + config: { + appId: '$target.config.appId', + versionId: '$target.config.versionId', + avatar: '$target.config.avatar', + appName: '$target.config.appName', + versionName: '$target.config.versionName' + } + }, + metricNames: 1, + evaluators: 1, // Add evaluators field for real-time calculation + summaryData: 1, + aggregateScore: 1, + tmbId: 1 + } + }, + { $sort: sort }, + { $skip: skip }, + { $limit: limit } + ]), + // Get total count without pagination + MongoEvaluation.aggregate([...aggregationPipeline, { $count: 'total' }]).then( + (result) => result[0]?.total || 0 + ) + ]); + + // Get real-time status and statistics + const evaluationsWithStatus = await Promise.all( + evaluations.map(async (evaluation) => { + const [status, statistics] = await Promise.all([ + getEvaluationTaskStatus(evaluation._id.toString()), + getEvaluationTaskStats(evaluation._id.toString()) + ]); + return { + ...evaluation, + status, + statistics + }; + }) + ); + + // Return data (permissions handled in API layer) + // Calculate real-time scores for each evaluation + const evaluationsWithRealTimeScores = await Promise.all( + evaluationsWithStatus.map(async (evaluation) => { + try { + // Calculate real-time metric scores and aggregate score + const calculatedData = await EvaluationSummaryService.calculateMetricScores(evaluation); + + // Update summaryConfigs with real-time calculated values + const updatedSummaryConfigs = evaluation.summaryData.summaryConfigs.map( + (summaryConfig: any) => { + const metricData = calculatedData.metricsData.find( + (m) => m.metricId === summaryConfig.metricId + ); + return { + ...summaryConfig, + score: metricData?.metricScore || 0, + completedItemCount: metricData?.totalCount || 0, + overThresholdItemCount: metricData?.aboveThresholdCount || 0 + }; + } + ); + + return { + ...evaluation, + summaryData: { + ...evaluation.summaryData, + summaryConfigs: updatedSummaryConfigs + }, + aggregateScore: calculatedData.aggregateScore + }; + } catch (error) { + addLog.error('[listEvaluations] Failed to calculate real-time scores', { + evalId: evaluation._id, + error + }); + // Return evaluation with default score values if calculation fails + const defaultSummaryConfigs = + evaluation.summaryData?.summaryConfigs?.map((summaryConfig: any) => ({ + ...summaryConfig, + score: 0, + completedItemCount: 0, + overThresholdItemCount: 0 + })) || []; + + return { + ...evaluation, + summaryData: { + ...evaluation.summaryData, + summaryConfigs: defaultSummaryConfigs + }, + aggregateScore: 0 + }; + } + }) + ); + + // Return data with real-time scores - permissions will be handled in API layer + return { + list: evaluationsWithRealTimeScores, + total + }; + } + + static async getEvaluationDetail(evalId: string, teamId: string): Promise { + const evaluationResult = await MongoEvaluation.aggregate([ + { $match: { _id: new Types.ObjectId(evalId), teamId: new Types.ObjectId(teamId) } }, + { + $lookup: { + from: 'eval_dataset_collections', + localField: 'evalDatasetCollectionId', + foreignField: '_id', + as: 'evalDatasetCollection' + } + }, + { + $addFields: { + 'target.config.appObjectId': { $toObjectId: '$target.config.appId' } + } + }, + { + $lookup: { + from: 'apps', + localField: 'target.config.appObjectId', + foreignField: '_id', + as: 'app' + } + }, + { + $addFields: { + 'target.config.versionObjectId': { $toObjectId: '$target.config.versionId' } + } + }, + { + $lookup: { + from: 'app_versions', + localField: 'target.config.versionObjectId', + foreignField: '_id', + as: 'appVersion' + } + }, + { + $addFields: { + 'target.config.appName': { $arrayElemAt: ['$app.name', 0] }, + 'target.config.avatar': { $arrayElemAt: ['$app.avatar', 0] }, + 'target.config.versionName': { $arrayElemAt: ['$appVersion.versionName', 0] }, + evalDatasetCollectionName: { $arrayElemAt: ['$evalDatasetCollection.name', 0] }, + evalDatasetCollectionId: '$evalDatasetCollectionId' + } + }, + { + $project: { + _id: 1, + teamId: 1, + tmbId: 1, + name: 1, + description: 1, + evalDatasetCollectionId: 1, + evalDatasetCollectionName: 1, + target: { + type: '$target.type', + config: { + appId: '$target.config.appId', + versionId: '$target.config.versionId', + avatar: '$target.config.avatar', + appName: '$target.config.appName', + versionName: '$target.config.versionName' + } + }, + evaluators: 1, + summary: 1, + usageId: 1, + createTime: 1, + finishTime: 1, + errorMessage: 1 + } + } + ]); + + const evaluation = evaluationResult[0]; + if (!evaluation) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } + + const status = await getEvaluationTaskStatus(evalId); + const stats = await getEvaluationTaskStats(evalId); + + return { + ...evaluation, + status, + statistics: stats + }; + } + + static async startEvaluation(evalId: string, teamId: string): Promise { + const evaluation = await this.getEvaluation(evalId, teamId); + + // Let BullMQ handle most scenarios + const canStart = + evaluation.status === EvaluationStatusEnum.queuing || + evaluation.status === EvaluationStatusEnum.completed || + evaluation.status === EvaluationStatusEnum.error; + + if (!canStart) { + throw new Error(EvaluationErrEnum.evalInvalidStateTransition); + } + + await enqueueEvaluationItems(evalId); + + const action = + evaluation.status === EvaluationStatusEnum.error + ? 'restarted' + : evaluation.status === EvaluationStatusEnum.completed + ? 'restarted' + : 'started'; + addLog.debug(`[Evaluation] Task ${action}: ${evalId}`); + } + + static async stopEvaluation(evalId: string, teamId: string): Promise { + const evaluation = await this.getEvaluation(evalId, teamId); + + if ( + ![EvaluationStatusEnum.evaluating, EvaluationStatusEnum.queuing].includes(evaluation.status) + ) { + throw new Error(EvaluationErrEnum.evalOnlyRunningCanStop); + } + + const stopEval = async (session: ClientSession) => { + // Remove tasks from queue + const [itemCleanupResult, summaryCleanupResult] = await Promise.all([ + removeEvaluationItemJobs(evalId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }), + removeEvaluationSummaryJobs(evalId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }) + ]); + + addLog.debug('Queue cleanup completed for evaluation stop', { + evalId, + itemCleanup: itemCleanupResult, + summaryCleanup: summaryCleanupResult + }); + + // Set error state for manual stop + await MongoEvaluation.updateOne( + { _id: new Types.ObjectId(evalId) }, + { + $set: { + finishTime: new Date(), + errorMessage: EvaluationErrEnum.evalManuallyStopped + } + }, + { session } + ); + + // Mark evaluation items as manually stopped + await MongoEvalItem.updateMany( + { + evalId: new Types.ObjectId(evalId) + }, + { + $set: { + errorMessage: EvaluationErrEnum.evalManuallyStopped, + finishTime: new Date() + } + }, + { session } + ); + + addLog.debug(`[Evaluation] Task manually stopped and removed from queue: ${evalId}`); + }; + + await mongoSessionRun(stopEval); + } + + static async getEvaluationStats( + evalId: string, + teamId: string + ): Promise<{ + total: number; + completed: number; + evaluating: number; + queuing: number; + error: number; + belowThreshold: number; + }> { + const evaluation = await this.getEvaluation(evalId, teamId); // Validate access + + // Get real-time status from job queues + const basicStats = await getEvaluationTaskStats(evalId); + + // Calculate belowThreshold count using threshold checks + const evaluators = evaluation.evaluators || []; + let belowThresholdCount = 0; + + if (evaluators.length > 0) { + const evaluatorFailChecks = this.buildEvaluatorFailChecks(evaluators); + + // Count items that are below threshold checks + const belowThresholdResult = await MongoEvalItem.aggregate([ + { $match: { evalId: new Types.ObjectId(evalId) } }, + { + $addFields: { + hasFailedEvaluator: + evaluatorFailChecks.length > 0 ? { $or: evaluatorFailChecks } : false + } + }, + { + $match: { + hasFailedEvaluator: true, + finishTime: { $exists: true }, // Only completed items + errorMessage: { $exists: false } // Exclude errors + } + }, + { $count: 'belowThreshold' } + ]); + + belowThresholdCount = belowThresholdResult[0]?.belowThreshold || 0; + } + + return { + ...basicStats, + belowThreshold: belowThresholdCount + }; + } + + /** + * Evaluation Item Management + */ + + static async listEvaluationItems( + evalId: string, + teamId: string, + offset: number = 0, + pageSize: number = 20, + options: { + status?: EvaluationStatusEnum; + belowThreshold?: boolean; + userInput?: string; + expectedOutput?: string; + actualOutput?: string; + } = {} + ): Promise<{ items: EvaluationItemDisplayType[]; total: number }> { + const evaluation = await this.getEvaluation(evalId, teamId); + const { status, belowThreshold, userInput, expectedOutput, actualOutput } = options; + + // Build aggregation pipeline + const pipeline = this.buildEvaluationItemsPipeline( + evaluation, + { status, belowThreshold, userInput, expectedOutput, actualOutput }, + offset, + pageSize + ); + + try { + const [dataResult, countResult] = await Promise.all([ + MongoEvalItem.aggregate(pipeline.dataPipeline), + MongoEvalItem.aggregate(pipeline.countPipeline) + ]); + + const total = countResult[0]?.total || 0; + const items = dataResult.map((item) => ({ + ...item, + _id: String(item._id), + // Add evaluator info + evaluators: evaluation.evaluators.map((evaluator, index) => ({ + metric: evaluator.metric, + thresholdValue: evaluator.thresholdValue, + //check mongo schema need to change or not,add this for Front-end calculate aggreatescore threshold + weight: evaluation.summaryData.summaryConfigs[index]?.weight || 0 + })), + // Add summaryData + summaryData: evaluation.summaryData + })); + + return { items, total }; + } catch (error) { + console.error('Failed to list evaluation items', { + evalId, + options, + offset, + pageSize, + error + }); + throw new Error('Failed to list evaluation items'); + } + } + + /** + * Build aggregation pipeline for evaluation items listing + */ + private static buildEvaluationItemsPipeline( + evaluation: any, + filters: { + status?: EvaluationStatusEnum; + belowThreshold?: boolean; + userInput?: string; + expectedOutput?: string; + actualOutput?: string; + }, + offset: number, + pageSize: number + ) { + const { status, belowThreshold, userInput, expectedOutput, actualOutput } = filters; + + // Base match conditions - 确保 evalId 是 ObjectId 类型,aggregate 不会自动转换类型 + const matchConditions: Record = { + evalId: new Types.ObjectId(String(evaluation._id)) + }; + + // Use status for status filtering + if (status !== undefined) { + matchConditions['status'] = status; + } + + // Add text search conditions + const searchConditions: any[] = []; + if (userInput && typeof userInput === 'string' && userInput.trim().length > 0) { + searchConditions.push({ + 'dataItem.userInput': { $regex: new RegExp(userInput.trim(), 'i') } + }); + } + if (expectedOutput && typeof expectedOutput === 'string' && expectedOutput.trim().length > 0) { + searchConditions.push({ + 'dataItem.expectedOutput': { $regex: new RegExp(expectedOutput.trim(), 'i') } + }); + } + if (actualOutput && typeof actualOutput === 'string' && actualOutput.trim().length > 0) { + searchConditions.push({ + 'targetOutput.actualOutput': { $regex: new RegExp(actualOutput.trim(), 'i') } + }); + } + + if (searchConditions.length > 0) { + matchConditions.$and = searchConditions; + } + + // Build pipeline stages + const commonPipeline: any[] = [{ $match: matchConditions }]; + + // Status field is already available directly + + // Add threshold filter if specified + if (belowThreshold) { + const evaluators = evaluation.evaluators || []; + if (evaluators.length > 0) { + const evaluatorFailChecks = this.buildEvaluatorFailChecks(evaluators); + commonPipeline.push({ + $addFields: { + hasFailedEvaluator: + evaluatorFailChecks.length > 0 ? { $or: evaluatorFailChecks } : false + } + }); + commonPipeline.push({ + $match: { + hasFailedEvaluator: true, + status: EvaluationStatusEnum.completed, // Only completed items + evaluatorOutputs: { $exists: true, $ne: null, $not: { $size: 0 } } // Valid evaluator outputs + } + }); + } + } + + // Data pipeline + const dataPipeline = [ + ...commonPipeline, + { $sort: { createTime: -1 } }, + { $skip: offset }, + { $limit: pageSize }, + { + $project: { + _id: 1, + evalId: 1, + dataItem: 1, + targetOutput: 1, + evaluatorOutputs: 1, + status: 1, + createTime: 1, + updateTime: 1, + errorMessage: 1 + } + } + ]; + + // Count pipeline + const countPipeline = [...commonPipeline, { $count: 'total' }]; + + return { dataPipeline, countPipeline }; + } + + static async getEvaluationItem( + itemId: string, + teamId: string + ): Promise { + const item = await MongoEvalItem.findById(itemId).lean(); + + if (!item) { + throw new Error(EvaluationErrEnum.evalItemNotFound); + } + + await this.getEvaluation(item.evalId, teamId); + + return item; + } + + /** + * Build MongoDB update object for evaluation data items + */ + private static buildEvaluationDataItemUpdateObject(updates: { + userInput?: string; + expectedOutput?: string; + context?: string[]; + targetCallParams?: TargetCallParams; + }): any { + const updateObj: any = {}; + + if (updates.userInput !== undefined) { + updateObj['dataItem.userInput'] = updates.userInput; + } + if (updates.expectedOutput !== undefined) { + updateObj['dataItem.expectedOutput'] = updates.expectedOutput; + } + if (updates.context !== undefined) { + updateObj['dataItem.context'] = updates.context; + } + if (updates.targetCallParams !== undefined) { + updateObj['dataItem.targetCallParams'] = updates.targetCallParams; + } + + return updateObj; + } + + /** + * Update evaluation item with data item fields + */ + static async updateEvaluationItem( + itemId: string, + updates: { + userInput?: string; + expectedOutput?: string; + context?: string[]; + targetCallParams?: TargetCallParams; + }, + teamId: string + ): Promise { + const item = await this.getEvaluationItem(itemId, teamId); + const evaluation = await this.getEvaluation(item.evalId, teamId); + + // Build MongoDB update object + const updateObj = this.buildEvaluationDataItemUpdateObject(updates); + if (Object.keys(updateObj).length === 0) { + return; + } + + const result = await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(itemId) }, + { $set: updateObj } + ); + + if (result.matchedCount === 0) { + throw new Error(EvaluationErrEnum.evalItemNotFound); + } + + // Re-queue item if updated + if (result.modifiedCount > 0) { + // Get the updated item to determine the evalId + const updatedItem = await MongoEvalItem.findById(itemId, 'evalId'); + if (updatedItem) { + const cleanupResult = await removeEvaluationItemJobsByItemId(itemId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }); + + addLog.debug('Queue cleanup completed for evaluation item deletion', { + itemId, + cleanup: cleanupResult + }); + + // Reset results and re-queue + const evaluatorOutputs = evaluation.evaluators.map((evaluator) => ({ + metricName: evaluator.metric.name + })); + + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(itemId) }, + { + $set: { + targetOutput: {}, + evaluatorOutputs + } + } + ); + // Re-submit to evaluation queue + await addEvaluationItemJob({ + evalId: updatedItem.evalId.toString(), + evalItemId: itemId + }); + + addLog.debug(`[Evaluation] Item updated and re-queued for evaluation: ${itemId}`); + } + } + } + + static async deleteEvaluationItem(itemId: string, teamId: string): Promise { + await this.getEvaluationItem(itemId, teamId); + + // Remove jobs from queue before deleting + const cleanupResult = await removeEvaluationItemJobsByItemId(itemId, { + forceCleanActiveJobs: true, + retryAttempts: 3, + retryDelay: 200 + }); + + addLog.debug('Queue cleanup completed for evaluation item deletion', { + itemId, + cleanup: cleanupResult + }); + + const result = await MongoEvalItem.deleteOne({ _id: new Types.ObjectId(itemId) }); + + if (result.deletedCount === 0) { + throw new Error(EvaluationErrEnum.evalItemNotFound); + } + + addLog.debug(`[Evaluation] Evaluation item deleted including queue cleanup: ${itemId}`); + } + + static async retryEvaluationItem(itemId: string, teamId: string): Promise { + const item = await this.getEvaluationItem(itemId, teamId); + + if (item.status !== EvaluationStatusEnum.error) { + throw new Error(EvaluationErrEnum.evalItemNoErrorToRetry); + } + + const [failedJobs, pendingJobs] = await Promise.all([ + evaluationItemQueue.getJobs(['failed']), + evaluationItemQueue.getJobs(['waiting', 'delayed', 'active', 'prioritized']) + ]); + + const pendingJob = pendingJobs.find((job) => job.data?.evalItemId === itemId); + if (pendingJob) { + addLog.debug('Evaluation item retry skipped (job already pending)', { + itemId, + evalId: item.evalId, + teamId, + jobId: pendingJob.id + }); + return; + } + + const failedJob = failedJobs.find((job) => job.data?.evalItemId === itemId); + if (failedJob) { + // Retry the failed job first + await failedJob.retry(); + + // Update status to queuing after successful retry + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(itemId) }, + { + $set: { + status: EvaluationStatusEnum.queuing + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + + addLog.debug('Evaluation item retried successfully (existing failed job)', { + itemId, + evalId: item.evalId, + teamId, + jobId: failedJob.id + }); + return; + } + + // Add new job first + await addEvaluationItemJob({ + evalId: item.evalId.toString(), + evalItemId: itemId + }); + + // Update status to queuing after successful job addition + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(itemId) }, + { + $set: { + status: EvaluationStatusEnum.queuing + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + + addLog.debug('Evaluation item retried successfully (new job queued)', { + itemId, + evalId: item.evalId, + teamId + }); + } + + static async retryFailedItems(evalId: string, teamId: string): Promise { + const evaluation = await this.getEvaluation(evalId, teamId); // Validate evalId and teamId + + const itemsToProcess = await MongoEvalItem.find({ + evalId: evaluation._id, + status: EvaluationStatusEnum.error + }).lean(); + + if (itemsToProcess.length === 0) { + addLog.warn('No failed jobs found to retry for evaluation', { evalId }); + return 0; + } + + const [failedJobs, pendingJobs] = await Promise.all([ + evaluationItemQueue.getJobs(['failed']), + evaluationItemQueue.getJobs(['waiting', 'delayed', 'active', 'prioritized']) + ]); + + const failedJobMap = new Map(); + failedJobs.forEach((job) => { + const evalItemId = job.data?.evalItemId; + if (evalItemId) { + failedJobMap.set(evalItemId, job); + } + }); + + const pendingJobSet = new Set(); + pendingJobs.forEach((job) => { + const evalItemId = job.data?.evalItemId; + if (evalItemId) { + pendingJobSet.add(evalItemId); + } + }); + + let retriedJobs = 0; + let addedJobs = 0; + let skippedJobs = 0; + + // Collect successfully processed items for batch status update + const itemsToUpdate: string[] = []; + + // Process each item: retry/add job first, then collect for status update + for (const item of itemsToProcess) { + const evalItemId = item._id.toString(); + + if (pendingJobSet.has(evalItemId)) { + skippedJobs += 1; + continue; + } + + try { + const failedJob = failedJobMap.get(evalItemId); + if (failedJob) { + // Retry the failed job first + await failedJob.retry(); + retriedJobs += 1; + itemsToUpdate.push(evalItemId); + pendingJobSet.add(evalItemId); + } else { + // Add new job first + await addEvaluationItemJob({ + evalId, + evalItemId + }); + addedJobs += 1; + itemsToUpdate.push(evalItemId); + pendingJobSet.add(evalItemId); + } + } catch (error) { + addLog.error('Failed to retry evaluation item', { + evalId, + evalItemId, + error + }); + // Don't add to itemsToUpdate if job operation failed + } + } + + // Update status to queuing for all successfully processed items + if (itemsToUpdate.length > 0) { + await MongoEvalItem.updateMany( + { _id: { $in: itemsToUpdate.map((id) => new Types.ObjectId(id)) } }, + { + $set: { + status: EvaluationStatusEnum.queuing + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + } + + const totalProcessed = retriedJobs + addedJobs; + + addLog.debug('All failed evaluation items retry completed', { + evalId, + teamId, + retriedJobs, + addedJobs, + skippedJobs, + totalProcessed + }); + + return totalProcessed; + } + + static async getEvaluationItemResult( + itemId: string, + teamId: string + ): Promise { + const item = await this.getEvaluationItem(itemId, teamId); + return item; + } +} +export { MongoEvaluation }; diff --git a/packages/service/core/evaluation/task/mq.ts b/packages/service/core/evaluation/task/mq.ts new file mode 100644 index 000000000000..120d366547a4 --- /dev/null +++ b/packages/service/core/evaluation/task/mq.ts @@ -0,0 +1,311 @@ +import { addLog } from '../../../common/system/log'; +import { getQueue, getWorker, QueueNames } from '../../../common/bullmq'; +import { UnrecoverableError } from 'bullmq'; +import type { EvaluationItemJobData } from '@fastgpt/global/core/evaluation/type'; +import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { + createJobCleaner, + type JobCleanupResult, + type JobCleanupOptions, + checkBullMQHealth +} from '../../../common/bullmq/utils'; +import { getErrText } from '@fastgpt/global/common/error/utils'; + +export const evaluationItemQueue = getQueue(QueueNames.evalTaskItem, { + defaultJobOptions: { + attempts: global.systemEnv?.evalConfig?.caseMaxRetry || 3, + backoff: { + type: 'exponential', + delay: 1000 // Initial delay 1s, exponential backoff + }, + removeOnComplete: true, + removeOnFail: false + } +}); + +export const getEvaluationItemWorker = (processor: any) => { + const worker = getWorker(QueueNames.evalTaskItem, processor, { + concurrency: global.systemEnv?.evalConfig?.caseConcurrency || 10, + stalledInterval: 30000, // 30 seconds for faster recovery + maxStalledCount: global.systemEnv?.evalConfig?.maxStalledCount || 3 + }); + worker.on('stalled', async (jobId) => { + try { + const job = await evaluationItemQueue.getJob(jobId); + const evalItemId = job?.data?.evalItemId; + + addLog.warn('[Evaluation] Item job stalled, will be retried', { + jobId, + evalId: job?.data?.evalId, + evalItemId, + attemptsMade: job?.attemptsMade, + opts: job?.opts?.attempts + }); + + // Update status to queuing since stalled jobs will be retried + if (evalItemId) { + const { MongoEvalItem } = await import('./schema'); + await MongoEvalItem.updateOne( + { _id: evalItemId }, + { + $set: { + status: EvaluationStatusEnum.queuing + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + } + } catch (error) { + addLog.warn('[Evaluation] Item job stalled, will be retried (could not get job data)', { + jobId, + error + }); + } + }); + worker.on('failed', async (job, error) => { + // Handle failed items and check task completion + + try { + const evalId = job?.data?.evalId; + const evalItemId = job?.data?.evalItemId; + // Get max attempts from queue's defaultJobOptions or fallback to global config + const queueDefaultAttempts = evaluationItemQueue.opts?.defaultJobOptions?.attempts; + const maxAttempts = job?.opts?.attempts || queueDefaultAttempts || 0; + const attemptsMade = job?.attemptsMade || 0; + + // Check if error is UnrecoverableError (which prevents retries regardless of attempts) + const isUnrecoverableError = error instanceof UnrecoverableError; + + // Job will retry only if: + // 1. Not an UnrecoverableError AND + // 2. Still has attempts remaining + const willRetry = !isUnrecoverableError && attemptsMade < maxAttempts; + + addLog.error('[Evaluation] Item job failed', { + jobId: job?.id, + evalId, + evalItemId, + attemptsMade, + maxAttempts, + isUnrecoverableError, + willRetry, + error + }); + + // Update item status based on whether it will retry + if (evalItemId) { + const { MongoEvalItem } = await import('./schema'); + + if (willRetry) { + // Job will be retried, set status to queuing and clear error state + await MongoEvalItem.updateOne( + { _id: evalItemId }, + { + $set: { + status: EvaluationStatusEnum.queuing + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + } else { + // Job exhausted all retries, set final error status + await MongoEvalItem.updateOne( + { _id: evalItemId }, + { + $set: { + errorMessage: getErrText(error), + finishTime: new Date(), + status: EvaluationStatusEnum.error + } + } + ); + } + } + + // Check task completion after failure (only if no more retries) + if (evalId && !willRetry) { + addLog.debug('[Evaluation] Checking task completion after final item failure', { + jobId: job?.id, + evalId, + evalItemId, + error + }); + + // Check task completion (avoid circular dependency) + const { finishEvaluationTask } = await import('./processor'); + await finishEvaluationTask(evalId); + } + } catch (finishError) { + addLog.warn('[Evaluation] Could not retrieve job data for failed job', { + jobId: job?.id, + finishError + }); + } + }); + + worker.on('active', async (job) => { + try { + const evalId = job?.data?.evalId; + const evalItemId = job?.data?.evalItemId; + if (evalItemId) { + addLog.debug('[Evaluation] Item job started, updating status to evaluating', { + jobId: job?.id, + evalId, + evalItemId + }); + + // Update item status to evaluating + const { MongoEvalItem } = await import('./schema'); + await MongoEvalItem.updateOne( + { _id: evalItemId }, + { + $set: { + status: EvaluationStatusEnum.evaluating + }, + $unset: { + finishTime: 1, + errorMessage: 1 + } + } + ); + } + } catch (error) { + addLog.error('[Evaluation] Error in active event handler', { + jobId: job?.id, + error + }); + } + }); + + worker.on('completed', async (job) => { + try { + const evalId = job?.data?.evalId; + const evalItemId = job?.data?.evalItemId; + if (evalId && evalItemId) { + addLog.debug( + '[Evaluation] Item completed, updating metadata and checking task completion', + { + jobId: job?.id, + evalId, + evalItemId + } + ); + + // Update item status to completed + const { MongoEvalItem } = await import('./schema'); + await MongoEvalItem.updateOne( + { _id: evalItemId }, + { + $set: { + status: EvaluationStatusEnum.completed, + finishTime: new Date() + }, + $unset: { + errorMessage: 1 + } + } + ); + + // Check task completion (avoid circular dependency) + const { finishEvaluationTask } = await import('./processor'); + await finishEvaluationTask(job.data.evalId); + } + } catch (error) { + addLog.error('[Evaluation] Error in completed event handler', { + jobId: job?.id, + error + }); + } + }); +}; + +export const removeEvaluationItemJobs = async ( + evalId: string, + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + + const filterFn = (job: any) => { + return String(job.data?.evalId) === String(evalId); + }; + + const result = await cleaner.cleanAllJobsByFilter( + evaluationItemQueue, + filterFn, + QueueNames.evalTaskItem + ); + + addLog.debug('Evaluation item jobs cleanup completed', { + evalId, + result + }); + + return result; +}; + +export const removeEvaluationItemJobsByItemId = async ( + evalItemId: string, + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + + const filterFn = (job: any) => { + return String(job.data?.evalItemId) === String(evalItemId); + }; + + const result = await cleaner.cleanAllJobsByFilter( + evaluationItemQueue, + filterFn, + QueueNames.evalTaskItem + ); + + addLog.debug('Evaluation item jobs cleanup completed for specific item', { + evalItemId, + result + }); + + return result; +}; + +export const addEvaluationItemJob = (data: EvaluationItemJobData, options?: { delay?: number }) => { + const evalItemId = String(data.evalItemId); + + return evaluationItemQueue.add(evalItemId, data, { + deduplication: { + id: evalItemId, + ttl: 5000 + }, + ...options + }); +}; + +export const addEvaluationItemJobs = ( + jobs: Array<{ + data: EvaluationItemJobData; + delay?: number; + }> +) => { + const bulkJobs = jobs.map(({ data, delay }, index) => { + const evalItemId = String(data.evalItemId); + return { + name: evalItemId, + data, + opts: { + delay: delay ?? index * 100, // Small delay to avoid overwhelming system + deduplication: { id: evalItemId } + } + }; + }); + + return evaluationItemQueue.addBulk(bulkJobs); +}; + +export const checkEvaluationItemQueueHealth = (): Promise => { + return checkBullMQHealth(evaluationItemQueue, 'evaluation-item'); +}; diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts new file mode 100644 index 000000000000..b8148b54cfa3 --- /dev/null +++ b/packages/service/core/evaluation/task/processor.ts @@ -0,0 +1,348 @@ +import { addLog } from '../../../common/system/log'; +import type { Job } from '../../../common/bullmq'; +import type { EvaluationItemJobData, TargetOutput } from '@fastgpt/global/core/evaluation/type'; +import { getEvaluationItemWorker, addEvaluationItemJobs } from './mq'; +import { MongoEvaluation, MongoEvalItem } from './schema'; +import { createTargetInstance } from '../target'; +import { createEvaluatorInstance } from '../evaluator'; +import { Types } from 'mongoose'; +import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { createMergedEvaluationUsage } from '../utils/usage'; +import { EvaluationSummaryService } from '../summary'; +import { createEvaluationError } from './errors'; +import { getEvaluationTaskStats } from './statusCalculator'; +import type { MetricResult } from '@fastgpt/global/core/evaluation/metric/type'; +import { MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; + +/** + * Complete evaluation task and trigger summary generation + */ +export const finishEvaluationTask = async (evalId: string) => { + try { + // Get task statistics using the reusable function + const stats = await getEvaluationTaskStats(evalId); + + if (stats.total === 0) { + addLog.warn(`[Evaluation] Evaluation task has no evaluation item data: ${evalId}`); + return; + } + + // Check if all items are truly completed + const pendingCount = stats.evaluating + stats.queuing; + + if (pendingCount > 0) { + addLog.debug( + `[Evaluation] Task still has pending items, skipping completion: ${evalId}, total: ${stats.total}, ` + + `success: ${stats.completed}, failed: ${stats.error}, pending: ${pendingCount}` + ); + return; + } + + // Set finishTime if all items are finished (either completed or error, no pending) + // Use conditional update to prevent duplicate writes + const updateResult = await MongoEvaluation.updateOne( + { + _id: new Types.ObjectId(evalId), + finishTime: { $exists: false } // Only update if finishTime is not already set + }, + { $set: { finishTime: new Date() } } + ); + + // If no document was modified, it means another process already finished the task + if (updateResult.modifiedCount === 0) { + addLog.debug( + `[Evaluation] Task already finished by another process: ${evalId}, skipping completion` + ); + return; + } + + // Trigger summary generation for completed task + await EvaluationSummaryService.triggerSummaryGeneration(evalId); + } catch (error) { + addLog.error(`[Evaluation] Error occurred while completing task: ${evalId}`, { + error: getErrText(error) + }); + + // Save error info to database + try { + await MongoEvaluation.updateOne( + { _id: new Types.ObjectId(evalId) }, + { + $set: { + finishTime: new Date(), + errorMessage: EvaluationErrEnum.evalTaskSystemError + } + } + ); + } catch (updateError) { + addLog.warn(`[Evaluation] Failed to update task error info: ${evalId}`, { + updateError: getErrText(updateError) + }); + } + } +}; + +export const enqueueEvaluationItems = async (evalId: string) => { + // Get evaluation data + const evaluation = await MongoEvaluation.findById(evalId).lean(); + + // Skip if task doesn't exist + if (!evaluation) { + throw createEvaluationError(EvaluationErrEnum.evalTaskNotFound, 'ResourceCheck'); + } + + addLog.debug(`[Evaluation] Task ${evalId} now evaluating`); + + // Validate target and evaluators configuration + if (!evaluation.target || !evaluation.target.type || !evaluation.target.config) { + throw createEvaluationError(EvaluationErrEnum.evalTargetConfigInvalid, 'ResourceCheck'); + } + + if (!evaluation.evaluators || evaluation.evaluators.length === 0) { + throw createEvaluationError(EvaluationErrEnum.evalEvaluatorsConfigInvalid, 'ResourceCheck'); + } + + // Check if evaluation items exist + let existingItems = await MongoEvalItem.find({ evalId }).lean(); + + if (existingItems.length === 0) { + throw createEvaluationError(EvaluationErrEnum.evalItemNotFound, 'ResourceCheck'); + } + + const itemsToProcess = existingItems.filter((item) => { + return item.status === EvaluationStatusEnum.queuing; + }); + + if (itemsToProcess.length > 0) { + const jobs = itemsToProcess.map((item, index) => ({ + data: { + evalId, + evalItemId: item._id.toString() + }, + delay: index * 100 // Small delay to avoid overwhelming system + })); + + await addEvaluationItemJobs(jobs); + addLog.debug(`[Evaluation] Submitted ${jobs.length} items to queue`); + } + + addLog.debug( + `[Evaluation] Task processing completed: ${evalId}, submitted ${itemsToProcess.length} evaluation items to queue` + ); +}; + +/** + * Process evaluation item: execute target and evaluators + */ +const evaluationItemProcessor = async (job: Job) => { + const { evalId, evalItemId } = job.data; + + addLog.debug(`[Evaluation] Start processing evaluation item: ${evalItemId}`); + + // Get evaluation item + const evalItem = await MongoEvalItem.findById(evalItemId); + if (!evalItem) { + throw createEvaluationError(EvaluationErrEnum.evalItemNotFound, 'ResourceCheck'); + } + + // Get evaluation for AI points check and configuration + const evaluation = await MongoEvaluation.findById( + evalId, + 'teamId tmbId usageId target evaluators' + ); + if (!evaluation) { + throw createEvaluationError(EvaluationErrEnum.evalTaskNotFound, 'ResourceCheck'); + } + + // Check AI points availability + try { + await checkTeamAIPoints(evaluation.teamId); + } catch (error) { + throw createEvaluationError(error, 'ResourceCheck'); + } + + // Initialize outputs and check for existing results + let targetOutput: TargetOutput | undefined = undefined; + let evaluatorOutputs: MetricResult[] = []; + + // Resume from checkpoint if results exist + if (evalItem.targetOutput?.actualOutput) { + addLog.debug(`[Evaluation] Resuming targetOutput from evalItem: ${evalItemId}`); + targetOutput = evalItem.targetOutput; + } + if (evalItem.evaluatorOutputs && evalItem.evaluatorOutputs.length > 0) { + addLog.debug(`[Evaluation] Resuming evaluatorOutputs from evalItem: ${evalItemId}`); + evaluatorOutputs = evalItem.evaluatorOutputs; + } + + if (!targetOutput && !evaluatorOutputs.length) { + addLog.debug(`[Evaluation] Starting evaluation item from scratch: ${evalItemId}`); + } + + // Execute evaluation target if needed + if (!targetOutput || !targetOutput.actualOutput) { + try { + const targetInstance = await createTargetInstance(evaluation.target, { validate: false }); + targetOutput = await targetInstance.execute({ + userInput: evalItem.dataItem.userInput, + context: evalItem.dataItem.context, + targetCallParams: evalItem.dataItem.targetCallParams + }); + + // Save target output as checkpoint + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(evalItemId) }, + { + $set: { + targetOutput: targetOutput + } + } + ); + + // Record target usage + if (targetOutput.usage) { + const totalPoints = targetOutput.usage.reduce( + (sum: number, item: any) => sum + (item.totalPoints || 0), + 0 + ); + const inputTokens = targetOutput.usage.reduce( + (sum: number, item: any) => sum + (item.inputTokens || 0), + 0 + ); + const outputTokens = targetOutput.usage.reduce( + (sum: number, item: any) => sum + (item.outputTokens || 0), + 0 + ); + await createMergedEvaluationUsage({ + evalId, + teamId: evaluation.teamId, + tmbId: evaluation.tmbId, + usageId: evaluation.usageId, + totalPoints, + type: 'target', + inputTokens, + outputTokens + }); + } + + if (!targetOutput.actualOutput) { + throw new Error(EvaluationErrEnum.evalTargetExecutionError); + } + } catch (error) { + // Use BullMQ error type for retry handling + throw createEvaluationError(error, 'TargetExecute', { + evalId, + evalItemId + }); + } + } + + // Execute evaluators (only missing ones) + while (evaluatorOutputs.length < evaluation.evaluators.length) { + const evaluatorIndex = evaluatorOutputs.length; + evaluatorOutputs.push({ + metricName: evaluation.evaluators[evaluatorIndex].metric.name + }); + } + + const errors: Array<{ evaluatorName: string; error: string }> = []; + + // Process each evaluator + for (let i = 0; i < evaluation.evaluators.length; i++) { + const evaluator = evaluation.evaluators[i]; + const existingOutput = evaluatorOutputs[i]; + + // Skip if evaluator already has valid successful result + if ( + existingOutput?.data?.score !== undefined && + existingOutput?.status === MetricResultStatusEnum.Success + ) { + continue; + } + + try { + const evaluatorInstance = await createEvaluatorInstance(evaluator, { + validate: false + }); + + const evaluatorOutput = await evaluatorInstance.evaluate({ + userInput: evalItem.dataItem.userInput, + expectedOutput: evalItem.dataItem.expectedOutput, + actualOutput: targetOutput.actualOutput, + context: evalItem.dataItem.context, + retrievalContext: targetOutput.retrievalContext + }); + + await createMergedEvaluationUsage({ + evalId, + teamId: evaluation.teamId, + tmbId: evaluation.tmbId, + usageId: evaluation.usageId, + totalPoints: evaluatorOutput.totalPoints || 0, + inputTokens: + evaluatorOutput.usages?.reduce((sum, usage) => sum + (usage.promptTokens || 0), 0) || 0, + outputTokens: + evaluatorOutput.usages?.reduce((sum, usage) => sum + (usage.completionTokens || 0), 0) || + 0, + type: 'metric' + }); + + // Record error and continue + if (evaluatorOutput.status !== MetricResultStatusEnum.Success || evaluatorOutput.error) { + const errorMessage = evaluatorOutput.error || 'Evaluator execution failed'; + const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; + errors.push({ evaluatorName, error: errorMessage }); + } + + // Update evaluator output + evaluatorOutputs[i] = evaluatorOutput; + + // Save evaluator progress + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(evalItemId) }, + { $set: { evaluatorOutputs: evaluatorOutputs } } + ); + } catch (error) { + // Handle evaluator error + const errorMessage = getErrText(error) || 'Evaluator execution failed'; + const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; + errors.push({ evaluatorName, error: errorMessage }); + } + } + + // Check for evaluator errors + if (errors.length > 0) { + const errorDetails = errors.map((e) => `${e.evaluatorName}: ${e.error}`).join('; '); + addLog.error('[Evaluation] Multiple evaluator execution errors', { + errorCount: errors.length, + details: errorDetails, + errors: errors + }); + + // Use BullMQ error type + throw createEvaluationError( + EvaluationErrEnum.evalEvaluatorExecutionErrors, + 'EvaluatorExecute', + { + evalId, + evalItemId + }, + true + ); + } +}; + +/** + * Initialize evaluation item workers + */ +export const initEvalTaskItemWorker = () => { + return getEvaluationItemWorker(evaluationItemProcessor); +}; + +/** + * Export processors for testing + */ +export { evaluationItemProcessor }; diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts new file mode 100644 index 000000000000..2c8150205337 --- /dev/null +++ b/packages/service/core/evaluation/task/schema.ts @@ -0,0 +1,247 @@ +import { + TeamCollectionName, + TeamMemberCollectionName +} from '@fastgpt/global/support/user/team/constant'; +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import type { + EvaluationSchemaType, + EvaluationItemSchemaType +} from '@fastgpt/global/core/evaluation/type'; +import { UsageCollectionName } from '../../../support/wallet/usage/constants'; +import { + EvaluationStatusEnum, + EvaluationStatusValues, + SummaryStatusValues, + SummaryStatusEnum, + CaculateMethodValues, + CalculateMethodEnum +} from '@fastgpt/global/core/evaluation/constants'; +import { EvalDatasetCollectionName } from '../dataset/evalDatasetCollectionSchema'; + +const { Schema } = connectionMongo; + +export const EvaluationTargetSchema = new Schema( + { + type: { + type: String, + enum: ['workflow'], + required: true + }, + config: { + type: Schema.Types.Mixed, + required: true, + validate: { + validator: function (config: any) { + const targetType = (this as any).target?.type || (this as any).type; + if (targetType === 'workflow') { + return ( + config && + typeof config === 'object' && + config.appId != null && + config.versionId != null + ); + } + return false; + }, + message: + 'Target config must match the target type. Workflow targets require appId and versionId.' + } + } + }, + { _id: false } +); + +export const EvaluationEvaluatorSchema = new Schema( + { + metric: { + type: Schema.Types.Mixed, + required: true + }, + runtimeConfig: { + type: Schema.Types.Mixed, + required: false, + default: {} + }, + thresholdValue: { + type: Number, + required: false, + default: 0.8 + }, + scoreScaling: { + type: Number, + required: false, + default: 1, // Default no scaling + validate: { + validator: function (value: number) { + return ( + typeof value === 'number' && + !isNaN(value) && + isFinite(value) && + value > 0 && + value <= 10000 + ); // Support decimals like 0.01 for reduction + }, + message: + 'Score scaling must be a positive number greater than 0 and less than or equal to 10000' + } + } + }, + { + _id: false + } +); + +/** + * MongoDB collection names + */ +export const EvaluationCollectionName = 'evals'; +export const EvalItemCollectionName = 'eval_items'; + +export const EvaluationTaskSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: TeamMemberCollectionName, + required: true + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100 + }, + description: { + type: String, + default: '', + trim: true, + maxlength: 100 + }, + evalDatasetCollectionId: { + type: Schema.Types.ObjectId, + ref: EvalDatasetCollectionName, + required: true + }, + target: EvaluationTargetSchema, + evaluators: [EvaluationEvaluatorSchema], + summaryData: { + calculateType: { + type: String, + enum: CaculateMethodValues, + required: true, + default: CalculateMethodEnum.mean + }, + summaryConfigs: [ + { + metricId: { + type: String, + required: true + }, + metricName: { + type: String, + required: true + }, + weight: { + type: Number, + required: true + }, + summary: { + type: String, + default: '' + }, + summaryStatus: { + type: String, + enum: SummaryStatusValues, + default: SummaryStatusEnum.pending + }, + errorReason: { + type: String, + default: '' + }, + _id: false + } + ] + }, + usageId: { + type: Schema.Types.ObjectId, + ref: UsageCollectionName, + required: true + }, + createTime: { + type: Date, + required: true, + default: () => new Date() + }, + finishTime: Date, + errorMessage: String +}); + +/** + * Optimized indexes based on query patterns + */ +EvaluationTaskSchema.index({ _id: 1, teamId: 1 }); // Primary lookup +EvaluationTaskSchema.index({ teamId: 1, createTime: -1 }); // Team listing with time sort +EvaluationTaskSchema.index({ teamId: 1, tmbId: 1, createTime: -1 }); // Permission filtering +EvaluationTaskSchema.index({ teamId: 1, name: 1 }, { unique: true }); // Name uniqueness + +/** + * Evaluation item schema: atomic unit for evaluation + */ +export const EvaluationItemSchema = new Schema({ + evalId: { + type: Schema.Types.ObjectId, + ref: EvaluationCollectionName, + required: true + }, + // Data item configuration + dataItem: { + type: Object, + required: true + }, + // Execution results and outputs + targetOutput: { + type: Schema.Types.Mixed, + default: {} + }, + evaluatorOutputs: { + type: [Schema.Types.Mixed], + default: [] + }, + finishTime: Date, + errorMessage: String, + status: { + type: String, + enum: EvaluationStatusValues, + default: EvaluationStatusEnum.queuing + } +}); + +/** + * Evaluation item indexes for performance + */ +EvaluationItemSchema.index({ evalId: 1 }); // Basic queries +EvaluationItemSchema.index({ evalId: 1, createTime: -1 }); // Time-sorted listing + +// Status filtering indexes +EvaluationItemSchema.index({ evalId: 1, status: 1, createTime: -1 }); // Status with time +EvaluationItemSchema.index({ evalId: 1, status: 1 }); // Status only + +// Text search index for content filtering +EvaluationItemSchema.index({ + 'dataItem.userInput': 'text', + 'dataItem.expectedOutput': 'text', + 'targetOutput.actualOutput': 'text' +}); // Text search across inputs and outputs + +export const MongoEvaluation = getMongoModel( + EvaluationCollectionName, + EvaluationTaskSchema +); + +export const MongoEvalItem = getMongoModel( + EvalItemCollectionName, + EvaluationItemSchema +); diff --git a/packages/service/core/evaluation/task/statusCalculator.ts b/packages/service/core/evaluation/task/statusCalculator.ts new file mode 100644 index 000000000000..d1804873ba53 --- /dev/null +++ b/packages/service/core/evaluation/task/statusCalculator.ts @@ -0,0 +1,107 @@ +import { addLog } from '../../../common/system/log'; +import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { MongoEvalItem } from './schema'; +import { Types } from 'mongoose'; + +export async function getEvaluationTaskStats(evalId: string): Promise<{ + total: number; + completed: number; + evaluating: number; + queuing: number; + error: number; +}> { + try { + // Get all evaluation items from database - use same logic as finishEvaluationTask + const allEvalItems = await MongoEvalItem.find( + { evalId: new Types.ObjectId(evalId) }, + { _id: 1, status: 1 } + ).lean(); + + const totalItems = allEvalItems.length; + + if (totalItems === 0) { + return { + total: 0, + completed: 0, + evaluating: 0, + queuing: 0, + error: 0 + }; + } + + // Count status distribution - use same logic as finishEvaluationTask + let completed = 0; + let evaluating = 0; + let queuing = 0; + let error = 0; + + for (const item of allEvalItems) { + const status = item.status; + switch (status) { + case EvaluationStatusEnum.completed: + completed++; + break; + case EvaluationStatusEnum.error: + error++; + break; + case EvaluationStatusEnum.evaluating: + evaluating++; + break; + case EvaluationStatusEnum.queuing: + queuing++; + break; + } + } + + const stats = { + total: totalItems, + completed, + evaluating, + queuing, + error + }; + + return stats; + } catch (error) { + addLog.error('Error getting evaluation task stats:', { evalId, error }); + return { + total: 0, + completed: 0, + evaluating: 0, + queuing: 0, + error: 0 + }; + } +} + +export async function getEvaluationTaskStatus(evalId: string): Promise { + try { + const stats = await getEvaluationTaskStats(evalId); + + // If no items exist, task is in queuing state + if (stats.total === 0) { + return EvaluationStatusEnum.queuing; + } + + // If some items are evaluating or queuing, task is evaluating + if (stats.evaluating > 0 || stats.queuing > 0) { + return EvaluationStatusEnum.evaluating; + } + + // If any items have errors, task is in error state + if (stats.error > 0) { + return EvaluationStatusEnum.error; + } + + // If all items are completed, task is completed + if (stats.completed === stats.total) { + return EvaluationStatusEnum.completed; + } + + // This should not happen, but if it does, return error + return EvaluationStatusEnum.error; + } catch (error) { + addLog.error('Error getting evaluation task status:', { evalId, error }); + return EvaluationStatusEnum.error; + } +} diff --git a/packages/service/core/evaluation/utils/index.ts b/packages/service/core/evaluation/utils/index.ts new file mode 100644 index 000000000000..819942c5fc60 --- /dev/null +++ b/packages/service/core/evaluation/utils/index.ts @@ -0,0 +1,383 @@ +import { validateTargetConfig } from '../target'; +import { validateEvaluatorConfig } from '../evaluator'; +import type { CreateEvaluationParams } from '@fastgpt/global/core/evaluation/type'; +import type { ValidationResult } from '@fastgpt/global/core/evaluation/validate'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '@fastgpt/global/core/evaluation/constants'; +import { MongoEvalDatasetCollection } from '../dataset/evalDatasetCollectionSchema'; +import { MongoEvaluation } from '../task/schema'; +import { Types } from 'mongoose'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import { getBuiltinMetrics } from '../metric/provider'; + +export type EvaluationValidationParams = Partial; + +export interface EvaluationValidationOptions { + mode?: 'create' | 'update'; // validation mode + teamId?: string; // required for evalDatasetCollection existence validation + excludeEvaluationId?: string; // exclude current evaluation when checking name uniqueness +} + +/** + * Validate if a evalDatasetCollection exists and is accessible by the team + */ +async function validateEvalDatasetCollectionExists( + evalDatasetCollectionId: string, + teamId?: string +): Promise { + // Validate evalDatasetCollectionId format + if (!Types.ObjectId.isValid(evalDatasetCollectionId)) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'Invalid evalDatasetCollectionId format', + field: 'evalDatasetCollectionId' + } + ] + }; + } + + // Check if evalDatasetCollection exists + const filter: any = { _id: new Types.ObjectId(evalDatasetCollectionId) }; + if (teamId) { + filter.teamId = new Types.ObjectId(teamId); + } + + const datasetCollection = await MongoEvalDatasetCollection.findOne(filter).lean(); + + if (!datasetCollection) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.datasetCollectionNotFound, + message: 'evalDatasetCollection not found or access denied', + field: 'evalDatasetCollectionId' + } + ] + }; + } + + return { isValid: true, errors: [] }; +} + +/** + * Validate builtin metric name + */ +async function validateBuiltinMetricName( + metricName: string, + metricType: string +): Promise { + if (metricType !== EvalMetricTypeEnum.Builtin) { + return { isValid: true, errors: [] }; + } + + try { + const builtinMetrics = await getBuiltinMetrics(); + const validMetricNames = builtinMetrics.map((metric) => metric.name); + + if (!validMetricNames.includes(metricName)) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalMetricNameInvalid, + message: `Invalid builtin metric name '${metricName}'. Valid builtin metrics are: ${validMetricNames.join(', ')}`, + field: 'metric.name', + debugInfo: { + providedName: metricName, + validNames: validMetricNames + } + } + ] + }; + } + + return { isValid: true, errors: [] }; + } catch (err) { + // If we can't load builtin metrics, log error but allow validation to pass + // This prevents blocking evaluation creation if metric service is temporarily unavailable + console.warn('Failed to validate builtin metric name:', err); + return { isValid: true, errors: [] }; + } +} + +export async function validateEvaluationParams( + params: EvaluationValidationParams, + options?: EvaluationValidationOptions +): Promise { + const { name, description, evalDatasetCollectionId, target, evaluators } = params; + const mode = options?.mode || 'create'; + const isCreateMode = mode === 'create'; + + // For create mode, check all required fields are present + if (isCreateMode) { + if (!name || !name.trim()) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalNameRequired, + message: 'Evaluation name is required', + field: 'name' + } + ] + }; + } + + if (!evalDatasetCollectionId) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'datasetCollectionId is required', + field: 'evalDatasetCollectionId' + } + ] + }; + } + + if (!target) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalTargetRequired, + message: 'Evaluation target is required', + field: 'target' + } + ] + }; + } + + if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorsRequired, + message: 'At least one evaluator is required', + field: 'evaluators' + } + ] + }; + } + } + + // For update mode, only validate provided fields + if (name !== undefined) { + const trimmedName = typeof name === 'string' ? name.trim() : ''; + + if (!trimmedName) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalNameRequired, + message: 'Evaluation name is required', + field: 'name' + } + ] + }; + } + + if (trimmedName.length > MAX_NAME_LENGTH) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalNameTooLong, + message: 'Evaluation name is too long (max 100 characters)', + field: 'name', + debugInfo: { currentLength: trimmedName.length, maxLength: 100 } + } + ] + }; + } + + if (options?.teamId) { + const nameFilter: Record = { + teamId: new Types.ObjectId(options.teamId), + name: trimmedName + }; + + if (options.excludeEvaluationId && Types.ObjectId.isValid(options.excludeEvaluationId)) { + nameFilter._id = { $ne: new Types.ObjectId(options.excludeEvaluationId) }; + } + + const existingEvaluation = await MongoEvaluation.findOne(nameFilter).select('_id').lean(); + + if (existingEvaluation) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalNameDuplicate, + message: 'Evaluation name already exists', + field: 'name', + debugInfo: { duplicatedResourceId: existingEvaluation._id.toString() } + } + ] + }; + } + } + } + + if (description !== undefined && description && description.length > MAX_DESCRIPTION_LENGTH) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalDescriptionTooLong, + message: 'Description is too long (max 100 characters)', + field: 'description', + debugInfo: { currentLength: description.length, maxLength: 100 } + } + ] + }; + } + + if (evalDatasetCollectionId !== undefined) { + if (!evalDatasetCollectionId) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'datasetCollectionId is required', + field: 'evalDatasetCollectionId' + } + ] + }; + } + + // Validate evaldatasetcollection exists and is accessible + const datasetValidation = await validateEvalDatasetCollectionExists( + evalDatasetCollectionId, + options?.teamId + ); + if (!datasetValidation.isValid) { + return datasetValidation; + } + } + + if (target !== undefined) { + if (!target) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalTargetRequired, + message: 'Evaluation target is required', + field: 'target' + } + ] + }; + } + + // Validate target configuration using validateTargetConfig + const targetValidation = await validateTargetConfig(target); + if (!targetValidation.isValid) { + return targetValidation; // Return the detailed validation result directly + } + } + + if (evaluators !== undefined) { + if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorsRequired, + message: 'At least one evaluator is required', + field: 'evaluators' + } + ] + }; + } + + // Validate evaluators configuration using validateEvaluatorConfig + for (let i = 0; i < evaluators.length; i++) { + const evaluator = evaluators[i]; + + // Detailed validation using validateEvaluatorConfig + const evaluatorValidation = await validateEvaluatorConfig(evaluator); + if (!evaluatorValidation.isValid) { + // Prefix error messages with evaluator index for clarity + const errors = evaluatorValidation.errors.map((err) => ({ + ...err, + message: `Evaluator at index ${i}: ${err.message}`, + field: `evaluators[${i}].${err.field || 'unknown'}`, + debugInfo: { + evaluatorIndex: i, + ...err.debugInfo + } + })); + return { isValid: false, errors }; + } + + // Validate builtin metric name if type is builtin_metric + if (evaluator.metric) { + const builtinMetricValidation = await validateBuiltinMetricName( + evaluator.metric.name, + evaluator.metric.type + ); + if (!builtinMetricValidation.isValid) { + // Prefix error messages with evaluator index for clarity + const errors = builtinMetricValidation.errors.map((err) => ({ + ...err, + message: `Evaluator at index ${i}: ${err.message}`, + field: `evaluators[${i}].${err.field || 'unknown'}`, + debugInfo: { + evaluatorIndex: i, + ...err.debugInfo + } + })); + return { isValid: false, errors }; + } + } + + // Validate scoreScaling if provided + if (evaluator.scoreScaling !== undefined) { + if ( + typeof evaluator.scoreScaling !== 'number' || + isNaN(evaluator.scoreScaling) || + !isFinite(evaluator.scoreScaling) || + evaluator.scoreScaling <= 0 || + evaluator.scoreScaling > 10000 + ) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, + message: 'Evaluator scoreScaling invalid', + field: 'scoreScaling' + } + ] + }; + } + } + } + } + + return { isValid: true, errors: [] }; +} + +export async function validateEvaluationParamsForCreate( + params: EvaluationValidationParams, + teamId?: string +): Promise { + return validateEvaluationParams(params, { mode: 'create', teamId }); +} + +export async function validateEvaluationParamsForUpdate( + params: EvaluationValidationParams, + teamId?: string, + excludeEvaluationId?: string +): Promise { + return validateEvaluationParams(params, { mode: 'update', teamId, excludeEvaluationId }); +} diff --git a/packages/service/core/evaluation/utils/tokenLimiter.ts b/packages/service/core/evaluation/utils/tokenLimiter.ts new file mode 100644 index 000000000000..ae01df0755f9 --- /dev/null +++ b/packages/service/core/evaluation/utils/tokenLimiter.ts @@ -0,0 +1,27 @@ +import { getEvaluationModelById } from '../../ai/model'; +import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { addLog } from '../../../common/system/log'; +import { MAX_TOKEN_FOR_EVALUATION_SUMMARY } from '@fastgpt/global/core/evaluation/constants'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; + +export const getEvaluationSummaryModel = (llmModelId?: string): LLMModelItemType => { + let evalutionModel = getEvaluationModelById(llmModelId); + if (evalutionModel) return evalutionModel; + throw new Error(EvaluationErrEnum.summaryModelInvalid); +}; + +export const getEvaluationSummaryTokenLimit = (llmModelId: string): number => { + let modelConfig = getEvaluationSummaryModel(llmModelId); + + // Calculate token limit: model max context - reserved tokens for response + const tokenLimit = modelConfig.maxContext - MAX_TOKEN_FOR_EVALUATION_SUMMARY; + + addLog.debug('[EvaluationSummary] Calculate token limit', { + llmModelId: llmModelId || 'default', + modelName: modelConfig.name, + maxContext: modelConfig.maxContext, + tokenLimit + }); + + return tokenLimit; +}; diff --git a/packages/service/core/evaluation/utils/usage.ts b/packages/service/core/evaluation/utils/usage.ts new file mode 100644 index 000000000000..91960cb15512 --- /dev/null +++ b/packages/service/core/evaluation/utils/usage.ts @@ -0,0 +1,37 @@ +import { concatUsage } from '../../../support/wallet/usage/controller'; +import { UsageItemTypeEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { addLog } from '../../../common/system/log'; + +const typeToItemType: Record<'target' | 'metric' | 'summary', UsageItemTypeEnum> = { + target: UsageItemTypeEnum.evaluation_generateAnswer, + metric: UsageItemTypeEnum.evaluation_metricsExecute, + summary: UsageItemTypeEnum.evaluation_summaryGeneration +}; + +/** + * Create merged evaluation usage record + */ +export const createMergedEvaluationUsage = async (params: { + evalId: string; + teamId: string; + tmbId: string; + usageId: string; + totalPoints: number; + type: 'target' | 'metric' | 'summary'; + inputTokens?: number; + outputTokens?: number; +}) => { + const { evalId, teamId, usageId, totalPoints, type, inputTokens, outputTokens } = params; + + await concatUsage({ + usageId, + teamId, + totalPoints, + inputTokens: inputTokens || 0, + outputTokens: outputTokens || 0, + count: 1, + itemType: typeToItemType[type] + }); + + addLog.debug(`[Evaluation] Record usage: ${evalId}, ${type}, ${totalPoints} points`); +}; diff --git a/packages/service/core/train/common/env.ts b/packages/service/core/train/common/env.ts new file mode 100644 index 000000000000..722d493fdfba --- /dev/null +++ b/packages/service/core/train/common/env.ts @@ -0,0 +1,98 @@ +import { createEnv } from '@t3-oss/env-core'; +import z from 'zod'; +import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; + +const NumSchema = z.coerce.number(); +const IntSchema = NumSchema.int().nonnegative(); +const PositiveIntSchema = IntSchema.positive(); + +const BoolSchema = z + .string() + .transform((val) => val === 'true') + .pipe(z.boolean()); + +export const trainEnv = createEnv({ + server: { + // ===== Index Type Configuration ===== + TRAIN_INDEX_TYPE: z.enum(DatasetDataIndexTypeEnum).default(DatasetDataIndexTypeEnum.question), + + // ===== Data Sampling ===== + TRAIN_DATA_SPLIT_RATIO: NumSchema.min(0).max(1).default(0.8), + TRAIN_MIN_CHUNK_COUNT: PositiveIntSchema.default(300), + TRAIN_MAX_CHUNK_COUNT: PositiveIntSchema.default(5000), + TRAIN_MIN_EVAL_QA_COUNT: PositiveIntSchema.default(200), + + // ===== Training Task Threshold ===== + TRAIN_MIN_TASK_CHUNK_THRESHOLD: PositiveIntSchema.default(500), + + // ===== LLM Judge Configuration ===== + LLM_JUDGE_TOP_K: PositiveIntSchema.default(10), + + // ===== DiTing Service Configuration ===== + DITING_BASE_URL: z.string().default('http://diting:3000'), + DITING_TIMEOUT: PositiveIntSchema.default(1800000), + DITING_CONCURRENCY: PositiveIntSchema.default(10), + DITING_MAX_CONCURRENCY: PositiveIntSchema.default(50), + DITING_API_CONCURRENCY: PositiveIntSchema.default(10), + DITING_MOCK_ENABLE: BoolSchema.default(false), + DITING_MOCK_SYNTH_FAIL: BoolSchema.default(false), + + // ===== SFT Bridge Service Configuration ===== + SFT_BRIDGE_BASE_URL: z.string().default('http://sft-bridge:3000'), + SFT_BRIDGE_TIMEOUT: PositiveIntSchema.default(300000), + SFT_BRIDGE_POLL_INTERVAL: PositiveIntSchema.default(60000), + SFT_BRIDGE_MAX_POLLS: PositiveIntSchema.default(600), + SFT_BRIDGE_LEARNING_RATE: NumSchema.positive().default(0.0001), + SFT_BRIDGE_MOCK_ENABLE: BoolSchema.default(false), + SFT_BRIDGE_MOCK_RERANK_FAIL: BoolSchema.default(false), + SFT_BRIDGE_MOCK_EMBED_FAIL: BoolSchema.default(false), + + SFT_BRIDGE_MOCK_RERANK_ENDPOINT_BASE_URL: z + .string() + .default('http://sft-bridge:3000/Qwen3-reranker-0.6B-LORA/v1'), + SFT_BRIDGE_MOCK_RERANK_ENDPOINT_MODEL: z.string().default('Qwen3-reranker-0.6B-LORA'), + SFT_BRIDGE_MOCK_RERANK_ENDPOINT_API_KEY: z.string().default('sk-test'), + + SFT_BRIDGE_MOCK_EMBED_ENDPOINT_BASE_URL: z + .string() + .default('http://sft-bridge:3000/Qwen3-Embedding-0.6B-LoRA/v1'), + SFT_BRIDGE_MOCK_EMBED_ENDPOINT_MODEL: z.string().default('Qwen3-Embedding-0.6B-LoRA'), + SFT_BRIDGE_MOCK_EMBED_ENDPOINT_API_KEY: z.string().default('sk-test'), + + // ===== Evaluation Configuration ===== + TRAIN_EVAL_CONCURRENCY: PositiveIntSchema.default(5), + + // ===== Dataset Search Configuration ===== + TRAIN_SEARCH_SIMILARITY: NumSchema.min(0).max(1).default(0.1), + TRAIN_SEARCH_LIMIT: PositiveIntSchema.default(51200), + TRAIN_MAX_SEARCH_RUN_TIMES: PositiveIntSchema.default(50), + TRAIN_DATASET_SEARCH_CONCURRENCY: PositiveIntSchema.default(10), + + // ===== BullMQ Worker Configuration ===== + TRAIN_WORKER_STALLED_INTERVAL: PositiveIntSchema.default(30000), + TRAIN_JOB_BACKOFF_DELAY: PositiveIntSchema.default(5000), + TRAIN_TASK_CONCURRENCY: PositiveIntSchema.default(1), + TRAIN_DATA_GENERATE_CONCURRENCY: PositiveIntSchema.default(2), + TRAIN_WORKER_MAX_STALLED_COUNT: PositiveIntSchema.default(3), + + // ===== Channel Creation Configuration ===== + TRAIN_CHANNEL_CREATE_TIMEOUT: PositiveIntSchema.default(30000), + + // ===== Channel Availability Polling Configuration ===== + TRAIN_CHANNEL_AVAILABILITY_POLL_INTERVAL: PositiveIntSchema.default(10000), + TRAIN_CHANNEL_AVAILABILITY_MAX_DURATION: PositiveIntSchema.default(300000), + + // ===== AI Proxy Configuration ===== + AIPROXY_API_ENDPOINT: z.string().optional(), + AIPROXY_API_TOKEN: z.string().optional(), + + // ===== Training Data Directory ===== + TRAIN_DATA_DIR: z.string().optional() + }, + emptyStringAsUndefined: true, + runtimeEnv: process.env, + onValidationError(issues) { + const paths = issues.map((issue) => issue.path).join(', '); + throw new Error(`Invalid train environment variables. Please check: ${paths}\n`); + } +}); diff --git a/packages/service/core/train/common/errors.ts b/packages/service/core/train/common/errors.ts new file mode 100644 index 000000000000..8ccb42f18eae --- /dev/null +++ b/packages/service/core/train/common/errors.ts @@ -0,0 +1,96 @@ +import { UnrecoverableError } from 'bullmq'; +import type { GenericEnhancedErrorMessage } from '@fastgpt/global/core/train/common/error'; +import { formatTrainTaskError } from './utils'; + +/** + * Unrecoverable training task error that prevents BullMQ auto-retry + * + * Used for permanent errors that cannot be fixed by retrying: + * - Data not found (DATA_EMPTY, DATA_INVALID, DATA_FILE_NOT_FOUND) + * - Invalid configuration (MODEL_NOT_FOUND, MODEL_CONFIG_INVALID) + * - System errors (INTERNAL_ERROR, DATABASE_ERROR, FILE_SYSTEM_ERROR) + * - User cancellation (CANCELLED) + * + * These errors will cause the task to fail immediately without retrying. + */ +export class TrainTaskUnrecoverableError extends UnrecoverableError { + /** Structured error information */ + enhancedError: GenericEnhancedErrorMessage; + + constructor(enhancedError: GenericEnhancedErrorMessage) { + // Call parent constructor with formatted string message (for logging) + super(formatTrainTaskError(enhancedError)); + this.name = 'TrainTaskUnrecoverableError'; + this.enhancedError = enhancedError; + } +} + +/** + * Retryable training task error that allows BullMQ auto-retry + * + * Used for temporary errors that might succeed on retry: + * - Network errors (SERVICE_UNAVAILABLE, SERVICE_TIMEOUT, SERVICE_API_ERROR) + * - Timeout errors (TIMEOUT) + * - Temporary failures (MODEL_TRAINING_FAILED, MODEL_DEPLOYMENT_FAILED, EVAL_FAILED) + * + * These errors will be automatically retried by BullMQ (up to 3 times by default). + */ +export class TrainTaskRetriableError extends Error { + /** Structured error information */ + enhancedError: GenericEnhancedErrorMessage; + + constructor(enhancedError: GenericEnhancedErrorMessage) { + // Call parent constructor with formatted string message (for logging) + super(formatTrainTaskError(enhancedError)); + this.name = 'TrainTaskRetriableError'; + this.enhancedError = enhancedError; + } +} + +/** + * Trainset generation unrecoverable error + * + * Used for permanent errors that require user action: + * - No dataset configured (trainsetGenNoDataset) + * - Dataset empty (trainsetGenDatasetEmpty) + * - Already generating (trainsetGenAlreadyGenerating) + * - Trainset not found (trainsetGenNotFound) + * - User cancelled (trainsetGenCancelled) + * + * Note: This extends Error (NOT BullMQ.UnrecoverableError) because trainset + * generation errors are handled by the data generation worker, which has + * different retry logic than training tasks. + */ +export class TrainsetGenerationUnrecoverableError extends Error { + /** Structured error information */ + enhancedError: GenericEnhancedErrorMessage; + + constructor(enhancedError: GenericEnhancedErrorMessage) { + // Call parent constructor with formatted string message (for logging) + super(formatTrainTaskError(enhancedError)); + this.name = 'TrainsetGenerationUnrecoverableError'; + this.enhancedError = enhancedError; + } +} + +/** + * Trainset generation retriable error + * + * Used for temporary errors that might succeed on retry: + * - DiTing service failures (trainsetGenDitingFailed) + * - DiTing returned no data (trainsetGenDitingNoData) + * - Database errors (trainsetGenDatabaseError) + * + * These errors will be automatically retried by BullMQ worker. + */ +export class TrainsetGenerationRetriableError extends Error { + /** Structured error information */ + enhancedError: GenericEnhancedErrorMessage; + + constructor(enhancedError: GenericEnhancedErrorMessage) { + // Call parent constructor with formatted string message (for logging) + super(formatTrainTaskError(enhancedError)); + this.name = 'TrainsetGenerationRetriableError'; + this.enhancedError = enhancedError; + } +} diff --git a/packages/service/core/train/common/external/diting/client.ts b/packages/service/core/train/common/external/diting/client.ts new file mode 100644 index 000000000000..491a04c02aac --- /dev/null +++ b/packages/service/core/train/common/external/diting/client.ts @@ -0,0 +1,146 @@ +import axios from 'axios'; +import type { + DiTingSyntheticEvalDataRequest, + DiTingSyntheticEvalDataResponse, + DiTingLLMJudgeRequest, + DiTingLLMJudgeResponse +} from './types'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../env'; + +function getDiTingConfig() { + return { + url: trainEnv.DITING_BASE_URL, + timeout: trainEnv.DITING_TIMEOUT + }; +} + +function getDiTingEndpoint(): string { + return getDiTingConfig().url; +} + +function getDiTingTimeout(): number { + return getDiTingConfig().timeout; +} + +/** + * Synthesize single evaluation data (QA pair) + * Calls DiTing's dataset-synthesis/runs endpoint + * + * Responsibility: Only handles DiTing API call and returns raw response + * Business logic (data sampling, dataset creation, etc.) is handled by caller + * + * @param request - Contains synthesizer config, input data, and LLM config + * @returns Generated QA pair and metadata + */ +export async function synthesizeEvalData( + request: DiTingSyntheticEvalDataRequest +): Promise { + const endpoint = getDiTingEndpoint(); + const url = `${endpoint}/api/v1/dataset-synthesis/runs`; + + addLog.debug('DiTing synthesize eval data', { + url, + synthesizerName: request.synthesizerConfig.synthesizerName, + contextLength: request.inputData.context.length, + numCases: request.inputData.numCases, + llmModel: request.llm_config.name + }); + + try { + const response = await axios.post(url, request, { + timeout: request.llm_config.timeout ? request.llm_config.timeout * 1000 : getDiTingTimeout(), + headers: { + 'Content-Type': 'application/json' + } + }); + + const apiResponse = response.data; + + if ( + apiResponse.status !== 'success' || + !apiResponse.data?.qaPairs || + !Array.isArray(apiResponse.data.qaPairs) || + apiResponse.data.qaPairs.length === 0 + ) { + throw new Error('Invalid response from DiTing API'); + } + + addLog.debug('DiTing synthesize eval data completed', { + requestId: apiResponse.requestId, + question: apiResponse.data.qaPairs[0]?.question?.substring(0, 50), + qaPairsCount: apiResponse.data.qaPairs.length + }); + + return { + success: true, + requestId: apiResponse.requestId, + status: apiResponse.status, + data: apiResponse.data, + usages: apiResponse.usages + }; + } catch (error) { + addLog.error('DiTing synthesize eval data failed', { + error: error instanceof Error ? error.message : String(error) + }); + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + +/** + * Call DiTing LLM judge API to determine which chunks are truly relevant to a query + * + * @param request - Contains question, retrieval_reference_list, and llm_config + * @returns Detected relevant data IDs as determined by the LLM judge + */ +export async function callLLMJudge( + request: DiTingLLMJudgeRequest +): Promise { + const endpoint = getDiTingEndpoint(); + const url = `${endpoint}/api/v1/dataset-synthesis/detect-context`; + + addLog.debug('DiTing LLM judge request', { + url, + question: request.question?.substring(0, 50), + referenceCount: request.retrieval_reference_list.length, + llmModel: request.llm_config.name + }); + + try { + const response = await axios.post(url, request, { + timeout: getDiTingTimeout(), + headers: { + 'Content-Type': 'application/json' + } + }); + + const apiResponse = response.data; + + if (apiResponse.status !== 'success') { + throw new Error(apiResponse.error || 'Invalid response from DiTing detect-context API'); + } + + addLog.debug('DiTing LLM judge completed', { + detectedCount: apiResponse.detected_data_ids?.length ?? 0, + totalReferences: request.retrieval_reference_list.length + }); + + return { + status: apiResponse.status, + detected_data_ids: apiResponse.detected_data_ids ?? [] + }; + } catch (error) { + addLog.error('DiTing LLM judge failed', { + error: error instanceof Error ? error.message : String(error) + }); + + return { + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} diff --git a/packages/service/core/train/common/external/diting/index.ts b/packages/service/core/train/common/external/diting/index.ts new file mode 100644 index 000000000000..ad757b116bab --- /dev/null +++ b/packages/service/core/train/common/external/diting/index.ts @@ -0,0 +1,20 @@ +import { + synthesizeEvalData as synthesizeEvalDataReal, + callLLMJudge as callLLMJudgeReal +} from './client'; +import { mockSynthesizeEvalData, mockCallLLMJudge } from './mock'; + +import { trainEnv } from '../../env'; + +export const synthesizeEvalData = trainEnv.DITING_MOCK_ENABLE + ? mockSynthesizeEvalData + : synthesizeEvalDataReal; + +export const callLLMJudge = trainEnv.DITING_MOCK_ENABLE ? mockCallLLMJudge : callLLMJudgeReal; + +export type { + DiTingSyntheticEvalDataRequest, + DiTingSyntheticEvalDataResponse, + DiTingLLMJudgeRequest, + DiTingLLMJudgeResponse +} from './types'; diff --git a/packages/service/core/train/common/external/diting/mock.ts b/packages/service/core/train/common/external/diting/mock.ts new file mode 100644 index 000000000000..170911dcd194 --- /dev/null +++ b/packages/service/core/train/common/external/diting/mock.ts @@ -0,0 +1,93 @@ +import type { + DiTingSyntheticEvalDataRequest, + DiTingSyntheticEvalDataResponse, + DiTingLLMJudgeRequest, + DiTingLLMJudgeResponse +} from './types'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../env'; + +/** + * Mock implementation of single evaluation data synthesis (QA pair) + * Real implementation should call DiTing's dataset-synthesis/runs API + * + * @param request - Contains synthesizer config, input data, and LLM config + * @returns Generated QA pair and metadata + */ +export async function mockSynthesizeEvalData( + request: DiTingSyntheticEvalDataRequest +): Promise { + const numCases = request.inputData.numCases ?? 1; + + addLog.debug('[MOCK] DiTing synthesize eval data', { + synthesizerName: request.synthesizerConfig.synthesizerName, + contextLength: request.inputData.context.length, + numCases, + llmModel: request.llm_config.name + }); + + await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 200)); + + if (trainEnv.DITING_MOCK_SYNTH_FAIL) { + return { + success: false, + requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + status: 'failed', + error: 'Mock synthesis failed' + }; + } + + const contextText = request.inputData.context.join('\n'); + + // Generate multiple QA pairs when numCases > 1 (1-to-many strategy) + const qaPairs = Array.from({ length: numCases }, (_, i) => ({ + question: `Question ${i + 1} based on: ${contextText.slice(0, 50)}...`, + answer: `Answer ${i + 1} based on context: ${contextText.slice(0, 100)}...` + })); + + return { + success: true, + requestId: `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + status: 'success', + data: { + qaPairs, + metadata: { + synthesizer: request.synthesizerConfig.synthesizerName + } + }, + usages: [ + { + modelType: request.llm_config.name, + promptTokens: 100 * numCases, + completionTokens: 50 * numCases, + totalTokens: 150 * numCases + } + ] + }; +} + +/** + * Mock implementation of LLM judge relevance evaluation + * Randomly selects a subset of retrieval references as detected + */ +export async function mockCallLLMJudge( + request: DiTingLLMJudgeRequest +): Promise { + addLog.debug('[MOCK] DiTing LLM judge', { + question: request.question?.substring(0, 50), + referenceCount: request.retrieval_reference_list.length, + llmModel: request.llm_config.name + }); + + await new Promise((resolve) => setTimeout(resolve, 50 + Math.random() * 100)); + + // Randomly select 1~N references as detected + const shuffled = [...request.retrieval_reference_list].sort(() => Math.random() - 0.5); + const detectedCount = Math.max(1, Math.floor(Math.random() * shuffled.length)); + const detected_data_ids = shuffled.slice(0, detectedCount).map((c) => c.id); + + return { + status: 'success', + detected_data_ids + }; +} diff --git a/packages/service/core/train/common/external/diting/types.ts b/packages/service/core/train/common/external/diting/types.ts new file mode 100644 index 000000000000..0d8f6d18963e --- /dev/null +++ b/packages/service/core/train/common/external/diting/types.ts @@ -0,0 +1,64 @@ +/** DiTing generate single evaluation data request */ +export type DiTingSyntheticEvalDataRequest = { + synthesizerConfig: { + synthesizerName: string; + }; + inputData: { + context: string[]; + /** Number of QA pairs to generate from this context (1-to-many strategy) */ + numCases?: number; + }; + llm_config: { + name: string; + base_url?: string; + api_key?: string; + parameters?: Record; + timeout?: number; + }; +}; + +/** DiTing generate single evaluation data response */ +export type DiTingSyntheticEvalDataResponse = { + success: boolean; + requestId?: string; + status?: string; + data?: { + /** QA pairs returned by DiTing synthesizer (always an array, even for numCases=1) */ + qaPairs: Array<{ + question: string; + answer: string; + }>; + metadata?: { + synthesizer: string; + }; + }; + usages?: Array<{ + modelType: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; + }>; + error?: string; +}; + +/** LLM judge relevance evaluation request */ +export type DiTingLLMJudgeRequest = { + question: string; + retrieval_reference_list: Array<{ + id: string; + q: string; + a: string; + }>; + llm_config: { + name: string; + base_url: string; + api_key: string; + }; +}; + +/** LLM judge relevance evaluation response */ +export type DiTingLLMJudgeResponse = { + status: string; + detected_data_ids?: string[]; + error?: string; +}; diff --git a/packages/service/core/train/common/external/sftbridge/client.ts b/packages/service/core/train/common/external/sftbridge/client.ts new file mode 100644 index 000000000000..f7872db8b6fd --- /dev/null +++ b/packages/service/core/train/common/external/sftbridge/client.ts @@ -0,0 +1,272 @@ +import axios from 'axios'; +import FormData from 'form-data'; +import type { + CreateSFTTaskRequest, + CreateSFTTaskResponse, + QuerySFTTaskStatusRequest, + QuerySFTTaskStatusResponse, + DeleteSFTTaskRequest, + DeleteSFTTaskResponse +} from './types'; +import { SFTTaskStatus } from './types'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../env'; + +/** + * SFT Bridge platform real client for optimization task creation and status query + */ + +function getSFTBridgeConfig() { + return { + url: trainEnv.SFT_BRIDGE_BASE_URL, + timeout: trainEnv.SFT_BRIDGE_TIMEOUT + }; +} + +function getSFTBridgeEndpoint(): string { + return getSFTBridgeConfig().url; +} + +function getSFTBridgeTimeout(): number { + return getSFTBridgeConfig().timeout; +} + +function extractSFTBridgeErrorMessage(error: unknown): string { + if (!axios.isAxiosError(error)) { + return error instanceof Error ? error.message : String(error); + } + + const data = error.response?.data; + if (typeof data === 'string' && data.trim()) { + return data; + } + + if (data && typeof data === 'object') { + const message = 'message' in data ? data.message : undefined; + if (typeof message === 'string' && message.trim()) { + return message; + } + + const detail = 'detail' in data ? data.detail : undefined; + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + } + + return error.message; +} + +/** + * Create SFT task + * Calls SFT Bridge platform /api/v1/optimization/tasks endpoint + * + * @param request - Contains dataset file, task type, and training parameters + * @returns Task ID and creation status + */ +export async function createSFTTask(request: CreateSFTTaskRequest): Promise { + const endpoint = getSFTBridgeEndpoint(); + const url = `${endpoint}/api/v1/optimization/tasks`; + + addLog.debug('SFT Bridge create optimization task', { + url, + taskType: request.taskType, + trainMethod: request.trainMethod, + hasParameters: !!request.parameters + }); + + try { + const formData = new FormData(); + formData.append('dataset', request.datasetFile, { + filename: 'dataset.jsonl', + contentType: 'application/jsonl' + }); + formData.append('task_type', request.taskType); + + if (request.trainMethod) { + formData.append('training_method', request.trainMethod); + } + + if (request.parameters) { + formData.append('parameters', JSON.stringify(request.parameters)); + } + + if (request.evalDatasetFile) { + formData.append('eval_dataset', request.evalDatasetFile, { + filename: 'eval_dataset.jsonl', + contentType: 'application/jsonl' + }); + } + + const response = await axios.post(url, formData, { + timeout: getSFTBridgeTimeout(), + headers: { + ...formData.getHeaders() + } + }); + + const apiResponse = response.data; + + if (!apiResponse.task_id || apiResponse.status !== 'pending') { + throw new Error('Invalid response from SFT Bridge API'); + } + + addLog.debug('SFT Bridge create optimization task completed', { + taskId: apiResponse.task_id, + status: apiResponse.status + }); + + return { + task_id: apiResponse.task_id, + status: apiResponse.status, + message: apiResponse.message || 'Optimization task created successfully' + }; + } catch (error) { + addLog.error('SFT Bridge create optimization task failed', { + error: error instanceof Error ? error.message : String(error) + }); + + if (axios.isAxiosError(error)) { + const errorMessage = extractSFTBridgeErrorMessage(error); + throw new Error(`SFT Bridge API error: ${errorMessage}`); + } + + throw error; + } +} + +/** + * Query SFT Bridge task status + * Calls SFT Bridge platform /api/v1/optimization/tasks/{task_id} endpoint + * + * @param request - Contains task ID + * @returns Task status, progress, and endpoint information (if completed) + */ +export async function querySFTTaskStatus( + request: QuerySFTTaskStatusRequest +): Promise { + const endpoint = getSFTBridgeEndpoint(); + const url = `${endpoint}/api/v1/optimization/tasks/${request.taskId}`; + + addLog.debug('SFT Bridge query task status', { + url, + taskId: request.taskId + }); + + try { + const response = await axios.get(url, { + timeout: getSFTBridgeTimeout() + }); + + const apiResponse = response.data; + + if (!apiResponse.task_id || !apiResponse.status) { + throw new Error('Invalid response from SFT Bridge API'); + } + + addLog.debug('SFT Bridge query task status completed', { + taskId: apiResponse.task_id, + status: apiResponse.status, + progress: apiResponse.progress + }); + + const result: QuerySFTTaskStatusResponse = { + task_id: apiResponse.task_id, + status: apiResponse.status, + message: apiResponse.message || '' + }; + + if (apiResponse.progress !== undefined) { + result.progress = apiResponse.progress; + } + + if (apiResponse.endpoint) { + result.endpoint = { + base_url: apiResponse.endpoint.base_url, + model: apiResponse.endpoint.model, + api_key: apiResponse.endpoint.api_key + }; + } + + if (apiResponse.error) { + result.error = apiResponse.error; + } + + return result; + } catch (error) { + addLog.error('SFT Bridge query task status failed', { + taskId: request.taskId, + error: error instanceof Error ? error.message : String(error) + }); + + if (axios.isAxiosError(error) && error.response?.status === 404) { + return { + task_id: request.taskId, + status: SFTTaskStatus.failed, + message: 'Task not found', + error: 'The specified task ID does not exist' + }; + } + + if (axios.isAxiosError(error)) { + const errorMessage = extractSFTBridgeErrorMessage(error); + throw new Error(`SFT Bridge API error: ${errorMessage}`); + } + + throw error; + } +} + +/** + * Delete SFT task + * Calls SFT Bridge platform /api/v1/optimization/tasks/{task_id} endpoint + * + * @param request - Contains task ID to delete + * @returns Deletion confirmation with task_id and message + */ +export async function deleteSFTTask(request: DeleteSFTTaskRequest): Promise { + const endpoint = getSFTBridgeEndpoint(); + const url = `${endpoint}/api/v1/optimization/tasks/${encodeURIComponent(request.taskId)}`; + + addLog.debug('SFT Bridge delete task', { + url, + taskId: request.taskId + }); + + try { + const response = await axios.delete(url, { + timeout: getSFTBridgeTimeout() + }); + + const apiResponse = response.data; + + addLog.debug('SFT Bridge delete task completed', { + taskId: request.taskId, + response: apiResponse + }); + + return { + task_id: apiResponse.task_id, + message: apiResponse.message || 'Task deleted successfully' + }; + } catch (error) { + addLog.error('SFT Bridge delete task failed', { + taskId: request.taskId, + error: error instanceof Error ? error.message : String(error) + }); + + if (axios.isAxiosError(error) && error.response?.status === 404) { + // Task not found - return success with appropriate message + return { + task_id: request.taskId, + message: 'Task not found or already deleted' + }; + } + + if (axios.isAxiosError(error)) { + const errorMessage = extractSFTBridgeErrorMessage(error); + throw new Error(`SFT Bridge API error: ${errorMessage}`); + } + + throw error; + } +} diff --git a/packages/service/core/train/common/external/sftbridge/index.ts b/packages/service/core/train/common/external/sftbridge/index.ts new file mode 100644 index 000000000000..7f9ba0e9887a --- /dev/null +++ b/packages/service/core/train/common/external/sftbridge/index.ts @@ -0,0 +1,39 @@ +/** + * SFT Bridge external service entry point + * + * Selects between mock implementation or real service based on environment variable: + * - SFT_BRIDGE_MOCK_ENABLE=true: Use mock implementation + * - SFT_BRIDGE_MOCK_ENABLE=false or not set: Use real SFT Bridge service (default) + */ + +import { mockCreateSFTTask, mockQuerySFTTaskStatus, mockDeleteSFTTask } from './mock'; +import { + createSFTTask as realCreateSFTTask, + querySFTTaskStatus as realQuerySFTTaskStatus, + deleteSFTTask as realDeleteSFTTask +} from './client'; + +import { trainEnv } from '../../env'; + +export const createSFTTask = trainEnv.SFT_BRIDGE_MOCK_ENABLE + ? mockCreateSFTTask + : realCreateSFTTask; + +export const querySFTTaskStatus = trainEnv.SFT_BRIDGE_MOCK_ENABLE + ? mockQuerySFTTaskStatus + : realQuerySFTTaskStatus; + +export const deleteSFTTask = trainEnv.SFT_BRIDGE_MOCK_ENABLE + ? mockDeleteSFTTask + : realDeleteSFTTask; + +export type { + CreateSFTTaskRequest, + CreateSFTTaskResponse, + QuerySFTTaskStatusRequest, + QuerySFTTaskStatusResponse, + DeleteSFTTaskRequest, + DeleteSFTTaskResponse +} from './types'; + +export { SFTTaskStatus } from './types'; diff --git a/packages/service/core/train/common/external/sftbridge/mock.ts b/packages/service/core/train/common/external/sftbridge/mock.ts new file mode 100644 index 000000000000..f99657aa4942 --- /dev/null +++ b/packages/service/core/train/common/external/sftbridge/mock.ts @@ -0,0 +1,178 @@ +import { SFTTaskStatus } from './types'; +import type { + CreateSFTTaskRequest, + CreateSFTTaskResponse, + QuerySFTTaskStatusRequest, + QuerySFTTaskStatusResponse, + DeleteSFTTaskRequest, + DeleteSFTTaskResponse +} from './types'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../env'; + +type MockTaskInfo = { + taskId: string; + createdAt: number; + taskType: 'rerank' | 'embed'; + status: SFTTaskStatus; +}; + +const mockTasks = new Map(); + +/** + * Mock implementation of SFT task creation + * Real implementation should call SFT Bridge platform's create_optimization_task API + * + * @param request - Contains dataset file, task type, and training parameters + * @returns Task ID and creation status + */ +export async function mockCreateSFTTask( + request: CreateSFTTaskRequest +): Promise { + addLog.info('[MOCK] SFT Bridge create optimization task', { + taskType: request.taskType, + parameters: request.parameters + }); + + await new Promise((resolve) => setTimeout(resolve, 500 + Math.random() * 1000)); + + const taskId = `SFT Bridge_task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + + mockTasks.set(taskId, { + taskId, + createdAt: Date.now(), + taskType: request.taskType, + status: SFTTaskStatus.created + }); + + return { + task_id: taskId, + status: SFTTaskStatus.created, + message: 'Optimization task created successfully' + }; +} + +/** + * Mock implementation of SFT Bridge task status query + * Real implementation should call SFT Bridge platform's query_task_status API + * + * Status transition timeline (from creation time): + * - 0-3s: created + * - 3-9s: running + * - 9-12s: deploying + * - 12s+: completed + * + * @param request - Contains task ID + * @returns Task status, progress, and endpoint information (if completed) + */ +export async function mockQuerySFTTaskStatus( + request: QuerySFTTaskStatusRequest +): Promise { + const task = mockTasks.get(request.taskId); + + if (!task) { + return { + task_id: request.taskId, + status: SFTTaskStatus.failed, + message: 'Task not found', + error: 'The specified task ID does not exist' + }; + } + + const elapsedSeconds = (Date.now() - task.createdAt) / 1000; + + let currentStatus: SFTTaskStatus; + let progress: number | undefined; + let message: string; + + let shouldFail = false; + let failEnvKey = ''; + if (task.taskType === 'rerank') { + shouldFail = trainEnv.SFT_BRIDGE_MOCK_RERANK_FAIL; + failEnvKey = 'SFT_BRIDGE_MOCK_RERANK_FAIL'; + } else { + shouldFail = trainEnv.SFT_BRIDGE_MOCK_EMBED_FAIL; + failEnvKey = 'SFT_BRIDGE_MOCK_EMBED_FAIL'; + } + if (elapsedSeconds < 3) { + currentStatus = SFTTaskStatus.created; + progress = 0; + message = 'Task created, waiting to start'; + } else if (elapsedSeconds < 9) { + currentStatus = SFTTaskStatus.running; + progress = Math.min(80, Math.floor(((elapsedSeconds - 3) / 6) * 80)); + message = 'Training in progress'; + } else if (shouldFail) { + currentStatus = SFTTaskStatus.failed; + progress = undefined; + message = `Mock failure injected via ${failEnvKey}`; + } else if (elapsedSeconds < 12) { + currentStatus = SFTTaskStatus.deploying; + progress = Math.min(95, 80 + Math.floor(((elapsedSeconds - 9) / 3) * 15)); + message = 'Training completed, deploying model'; + } else { + currentStatus = SFTTaskStatus.completed; + progress = 100; + message = 'Model deployed successfully'; + } + + task.status = currentStatus; + + const response: QuerySFTTaskStatusResponse = { + task_id: request.taskId, + status: currentStatus, + progress, + message + }; + + if (currentStatus === SFTTaskStatus.failed && shouldFail) { + response.error = `Mock failure injected via ${failEnvKey}`; + } else if (currentStatus === SFTTaskStatus.completed) { + let baseUrl = null; + let model = null; + let apiKey = null; + if (task.taskType === 'rerank') { + baseUrl = trainEnv.SFT_BRIDGE_MOCK_RERANK_ENDPOINT_BASE_URL; + model = trainEnv.SFT_BRIDGE_MOCK_RERANK_ENDPOINT_MODEL; + apiKey = trainEnv.SFT_BRIDGE_MOCK_RERANK_ENDPOINT_API_KEY; + } else { + baseUrl = trainEnv.SFT_BRIDGE_MOCK_EMBED_ENDPOINT_BASE_URL; + model = trainEnv.SFT_BRIDGE_MOCK_EMBED_ENDPOINT_MODEL; + apiKey = trainEnv.SFT_BRIDGE_MOCK_EMBED_ENDPOINT_API_KEY; + } + if (baseUrl && model && apiKey) { + response.endpoint = { base_url: baseUrl, model, api_key: apiKey }; + } + } + + addLog.info('[MOCK] SFT Bridge query task status', { + taskId: request.taskId, + status: currentStatus, + progress, + elapsedSeconds: elapsedSeconds.toFixed(1) + }); + + return response; +} + +/** + * Mock implementation of SFT task deletion + * Real implementation should call SFT Bridge platform's delete_task API + * + * @param request - Contains task ID to delete + * @returns Deletion confirmation with task_id and message + */ +export async function mockDeleteSFTTask( + request: DeleteSFTTaskRequest +): Promise { + addLog.info('[MOCK] SFT Bridge delete task', { + taskId: request.taskId + }); + + await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 500)); + + return { + task_id: request.taskId, + message: `Task ${request.taskId} deleted successfully (mock)` + }; +} diff --git a/packages/service/core/train/common/external/sftbridge/types.ts b/packages/service/core/train/common/external/sftbridge/types.ts new file mode 100644 index 000000000000..b21a008ff97b --- /dev/null +++ b/packages/service/core/train/common/external/sftbridge/types.ts @@ -0,0 +1,64 @@ +import type { ReadStream } from 'fs'; +import type { EmbeddingTrainMethodEnum } from '@fastgpt/global/core/train/embedding/constants'; +import type { RerankTrainMethodEnum } from '@fastgpt/global/core/train/rerank/constants'; + +/** Create SFT task request (multipart/form-data) */ +export type CreateSFTTaskRequest = { + datasetFile: Buffer | ReadStream; + taskType: 'rerank' | 'embed'; + trainMethod?: `${EmbeddingTrainMethodEnum}` | `${RerankTrainMethodEnum}`; // Training type: lora or task_tuning + parameters?: { + learning_rate?: number; + epochs?: number; + batch_size?: number; + }; + evalDatasetFile?: Buffer | ReadStream; +}; + +/** Create SFT task response */ +export type CreateSFTTaskResponse = { + task_id: string; + status: SFTTaskStatus; + message: string; +}; + +/** Query SFT task status request */ +export type QuerySFTTaskStatusRequest = { + taskId: string; +}; + +/** SFT task status enum */ +export enum SFTTaskStatus { + created = 'created', + running = 'running', + deploying = 'deploying', + completed = 'completed', + failed = 'failed' +} + +/** Query SFT task status response */ +export type QuerySFTTaskStatusResponse = { + task_id: string; + status: SFTTaskStatus; + progress?: number; + message: string; + + endpoint?: { + base_url: string; + model: string; + api_key: string; + }; + + error?: string; +}; + +/** Delete SFT task request */ +export type DeleteSFTTaskRequest = { + taskId: string; // SFT Bridge task ID +}; + +/** Delete SFT task response */ +export type DeleteSFTTaskResponse = { + task_id: string; + message: string; +}; diff --git a/packages/service/core/train/common/metrics/rankingMetrics.ts b/packages/service/core/train/common/metrics/rankingMetrics.ts new file mode 100644 index 000000000000..109e58c26256 --- /dev/null +++ b/packages/service/core/train/common/metrics/rankingMetrics.ts @@ -0,0 +1,182 @@ +export type RankingCase = { + rankedIds: string[]; // ordered document ID list returned by the model + expectedIds: string[]; // expected matching document ID list +}; + +export type RankingMetricsResult = { + detailed_results: Record; + mrr_scores: Record; + ndcg_scores: Record; + map_scores: Record; + precision_scores: Record; + retrieval_ranks: number[][]; + total_rows: number; + expect_count: number; +}; + +const DEFAULT_K_VALUES = [5, 10, 15]; + +/** + * Compute retrieval/reranking evaluation metrics (shared by embedding and rerank) + * + * @param cases Ordered document ID list + expected hit list for each query + * @param kValues Top-k values to evaluate, defaults to [5, 10, 15] + * @param prefix Metric key prefix: 'embed' for embedding, 'rerank' for rerank + */ +export function computeRankingMetrics( + cases: RankingCase[], + kValues: number[] = DEFAULT_K_VALUES, + prefix: string = 'embed' +): RankingMetricsResult { + if (cases.length === 0) { + const detailedResults: Record = {}; + for (const k of kValues) { + detailedResults[`${prefix}_top${k}_mrr`] = 0; + detailedResults[`${prefix}_top${k}_ndcg`] = 0; + detailedResults[`${prefix}_top${k}_map`] = 0; + detailedResults[`${prefix}_top${k}_precision`] = 0; + } + detailedResults['overall_mrr'] = 0; + detailedResults['overall_ndcg'] = 0; + detailedResults['overall_map'] = 0; + detailedResults['overall_precision'] = 0; + + return { + detailed_results: detailedResults, + mrr_scores: Object.fromEntries(kValues.map((k) => [`mrr@${k}`, []])), + ndcg_scores: Object.fromEntries(kValues.map((k) => [`ndcg@${k}`, []])), + map_scores: Object.fromEntries(kValues.map((k) => [`map@${k}`, []])), + precision_scores: Object.fromEntries(kValues.map((k) => [`precision@${k}`, []])), + retrieval_ranks: [], + total_rows: 0, + expect_count: 0 + }; + } + + // Per-query metric scores for each k value + const mrrPerCase: Record = {}; + const ndcgPerCase: Record = {}; + const mapPerCase: Record = {}; + const precisionPerCase: Record = {}; + for (const k of kValues) { + mrrPerCase[k] = []; + ndcgPerCase[k] = []; + mapPerCase[k] = []; + precisionPerCase[k] = []; + } + + // retrieval_ranks per query: rank of each expectedId in the full ranked list (1-based, -1 if not found) + const retrievalRanks: number[][] = []; + + for (const c of cases) { + const expectedSet = new Set(c.expectedIds); + + // Compute rank for each expectedId + const caseRanks = c.expectedIds.map((eid) => { + const idx = c.rankedIds.indexOf(eid); + return idx === -1 ? -1 : idx + 1; // 1-based + }); + retrievalRanks.push(caseRanks); + + for (const k of kValues) { + const topK = c.rankedIds.slice(0, k); + + mrrPerCase[k].push(calcMRR(topK, expectedSet)); + ndcgPerCase[k].push(calcNDCG(topK, expectedSet)); + mapPerCase[k].push(calcAP(topK, expectedSet, c.expectedIds.length)); + precisionPerCase[k].push(calcPrecision(topK, expectedSet, c.expectedIds.length)); + } + } + + // Aggregate + const detailed_results: Record = {}; + const mrr_scores: Record = {}; + const ndcg_scores: Record = {}; + const map_scores: Record = {}; + const precision_scores: Record = {}; + + for (const k of kValues) { + detailed_results[`${prefix}_top${k}_mrr`] = mean(mrrPerCase[k]); + detailed_results[`${prefix}_top${k}_ndcg`] = mean(ndcgPerCase[k]); + detailed_results[`${prefix}_top${k}_map`] = mean(mapPerCase[k]); + detailed_results[`${prefix}_top${k}_precision`] = mean(precisionPerCase[k]); + + mrr_scores[`mrr@${k}`] = mrrPerCase[k]; + ndcg_scores[`ndcg@${k}`] = ndcgPerCase[k]; + map_scores[`map@${k}`] = mapPerCase[k]; + precision_scores[`precision@${k}`] = precisionPerCase[k]; + } + + // Overall: flatten all per-query scores across all K values (matches DiTing behavior) + // DiTing extends all column scores into a single list and averages them, + // rather than using only the max-K column. + const allMrr = kValues.flatMap((k) => mrrPerCase[k]); + const allNdcg = kValues.flatMap((k) => ndcgPerCase[k]); + const allMap = kValues.flatMap((k) => mapPerCase[k]); + const allPrecision = kValues.flatMap((k) => precisionPerCase[k]); + detailed_results['overall_mrr'] = mean(allMrr); + detailed_results['overall_ndcg'] = mean(allNdcg); + detailed_results['overall_map'] = mean(allMap); + detailed_results['overall_precision'] = mean(allPrecision); + + return { + detailed_results, + mrr_scores, + ndcg_scores, + map_scores, + precision_scores, + retrieval_ranks: retrievalRanks, + total_rows: cases.length, + expect_count: cases.filter((c) => c.expectedIds.length > 0).length + }; +} + +/** Mean Reciprocal Rank */ +function calcMRR(topK: string[], expectedSet: Set): number { + for (let i = 0; i < topK.length; i++) { + if (expectedSet.has(topK[i])) return 1 / (i + 1); + } + return 0; +} + +/** Normalized Discounted Cumulative Gain */ +function calcNDCG(topK: string[], expectedSet: Set): number { + const relevances = topK.map((id) => (expectedSet.has(id) ? 1 : 0)); + const dcg = calcDCG(relevances); + // Ideal case: all relevant docs ranked at the top + const idealRelevances = Array(Math.min(expectedSet.size, topK.length)).fill(1); + const idcg = calcDCG(idealRelevances); + return idcg === 0 ? 0 : dcg / idcg; +} + +function calcDCG(relevances: number[]): number { + return relevances.reduce((sum, rel, i) => { + return sum + (i === 0 ? rel : rel / Math.log2(i + 2)); + }, 0); +} + +/** Average Precision */ +function calcAP(topK: string[], expectedSet: Set, totalExpected: number): number { + if (totalExpected === 0) return 0; + let hitCount = 0; + let sumPrecision = 0; + for (let i = 0; i < topK.length; i++) { + if (expectedSet.has(topK[i])) { + hitCount++; + sumPrecision += hitCount / (i + 1); + } + } + return hitCount === 0 ? 0 : sumPrecision / totalExpected; +} + +/** Precision: fraction of expectedIds found in topK */ +function calcPrecision(topK: string[], expectedSet: Set, totalExpected: number): number { + if (totalExpected === 0) return 0; + const hits = topK.filter((id) => expectedSet.has(id)).length; + return hits / totalExpected; +} + +function mean(arr: number[]): number { + if (arr.length === 0) return 0; + return arr.reduce((s, v) => s + v, 0) / arr.length; +} diff --git a/packages/service/core/train/common/synthesize/buildFineTuneData.ts b/packages/service/core/train/common/synthesize/buildFineTuneData.ts new file mode 100644 index 000000000000..719164b3c1e4 --- /dev/null +++ b/packages/service/core/train/common/synthesize/buildFineTuneData.ts @@ -0,0 +1,311 @@ +import { MongoDatasetData } from '../../../dataset/data/schema'; +import { Types } from 'mongoose'; +import type { SampledDataItem } from '../utils'; +import { cleanText } from '../utils'; + +const NEGATIVE_SAMPLE_RATIO = 0.5; + +export type FineTuneSample = { + query: string; + positive: string[]; // 始终 1 元素 + negatives: string[]; + sourceId: string; + datasetId: string; +}; + +/** 对应 Python pipeline 的 buildQAText */ +export function buildQAText(q: string, a: string): string { + if (!a || a.trim() === '') return q; + return `${q}\n${a}`; +} + +// ─── Internal types ──────────────────────────────────────────────────────────── + +type ProcessedItem = { + dataId: string; + datasetId: string; + collectionId: string; +}; + +type FineTuneSampleIndex = { + sourceId: string; + datasetId: string; + negativeDataIds: string[]; +}; + +// ─── Main export ─────────────────────────────────────────────────────────────── + +/** + * Stream fine-tune training samples from sampled data IDs. + * + * Phase 1 (pure memory): Build groups from sampledItems. + * Phase 2 (pure memory): ID-placeholder negative sampling → FineTuneSampleIndex[]. + * Phase 3 (streaming DB): Batch-resolve IDs → yield FineTuneSample. + * + * Memory peak: ~38 MB for 200k items (vs. ~820 MB with the old full-doc approach). + */ +export async function* buildFineTuneDataStream(params: { + sampledItems: SampledDataItem[]; + indexType: string; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; +}): AsyncGenerator { + const { + sampledItems, + indexType, + indexMultiStrategy = 1, + negativeStrategy = 2, + minNegativeSamples = 1, + maxNegativeSamples = 5 + } = params; + + if (sampledItems.length === 0) return; + + // ── Phase 1: Build groups (pure memory, no DB) ──────────────────────────── + const allProcessed: ProcessedItem[] = []; + const groups = new Map>(); + + for (const item of sampledItems) { + const processed: ProcessedItem = { + dataId: item.dataId, + datasetId: item.datasetId, + collectionId: item.collectionId + }; + allProcessed.push(processed); + + if (!groups.has(item.datasetId)) groups.set(item.datasetId, new Map()); + const collMap = groups.get(item.datasetId)!; + if (!collMap.has(item.collectionId)) collMap.set(item.collectionId, []); + collMap.get(item.collectionId)!.push(processed); + } + + // ── Phase 2: ID-placeholder negative sampling (pure memory, no DB) ──────── + const sampleIndices: FineTuneSampleIndex[] = []; + + for (const processed of allProcessed) { + const negativeDataIds = sampleNegativeIds( + allProcessed, + groups, + processed.dataId, + processed.datasetId, + processed.collectionId, + negativeStrategy, + minNegativeSamples, + maxNegativeSamples + ); + sampleIndices.push({ + sourceId: processed.dataId, + datasetId: processed.datasetId, + negativeDataIds + }); + } + + // Release allProcessed to allow GC + allProcessed.length = 0; + + // ── Phase 3: Streaming DB resolution in batches ─────────────────────────── + const BATCH_SIZE = 500; + + for (let start = 0; start < sampleIndices.length; start += BATCH_SIZE) { + const batch = sampleIndices.slice(start, start + BATCH_SIZE); + + // Collect unique IDs needed for this batch + const idSet = new Set(); + for (const idx of batch) { + idSet.add(idx.sourceId); + for (const id of idx.negativeDataIds) idSet.add(id); + } + const uniqueIds = Array.from(idSet).map((id) => new Types.ObjectId(id)); + + // Batch fetch — only q, a, and target indexType index texts + const docs = await MongoDatasetData.aggregate([ + { $match: { _id: { $in: uniqueIds } } }, + { + $project: { + _id: 1, + q: 1, + a: 1, + datasetId: 1, + indexes: { + $filter: { + input: '$indexes', + as: 'idx', + cond: { $eq: ['$$idx.type', indexType] } + } + } + } + } + ]); + const docMap = new Map(docs.map((d: any) => [d._id.toString(), d])); + + for (const idx of batch) { + const srcDoc = docMap.get(idx.sourceId); + if (!srcDoc?.indexes?.length) continue; // no target indexType — skip + + const cleanQ = cleanText(srcDoc.q ?? ''); + const cleanA = cleanText(srcDoc.a ?? ''); + const positiveText = buildQAText(cleanQ, cleanA); + + const negatives = idx.negativeDataIds + .map((id: string) => docMap.get(id)) + .filter((d: any): d is NonNullable => d != null) + .map((d: any) => buildQAText(cleanText(d.q ?? ''), cleanText(d.a ?? ''))); + + // Select index texts based on strategy + let selectedIndexes: typeof srcDoc.indexes; + if (indexMultiStrategy === 1) { + // Strategy 1: take the longest one + const longest = srcDoc.indexes.reduce((a: any, b: any) => + (a.text?.length ?? 0) >= (b.text?.length ?? 0) ? a : b + ); + selectedIndexes = [longest]; + } else { + // Strategy 2: take all + selectedIndexes = srcDoc.indexes; + } + + for (const indexItem of selectedIndexes) { + yield { + query: cleanText(indexItem.text), + positive: [positiveText], + negatives, + sourceId: idx.sourceId, + datasetId: idx.datasetId + }; + } + } + // docMap + docs go out of scope → GC + } +} + +// ─── Negative sampling helpers ──────────────────────────────────────────────── + +function sampleNegativeIds( + allProcessed: ProcessedItem[], + groups: Map>, + selfDataId: string, + selfDatasetId: string, + selfCollectionId: string, + strategy: 1 | 2 | 3 | 4, + minCount: number, + maxCount: number +): string[] { + const totalCandidates = allProcessed.length - 1; + if (totalCandidates <= 0) return []; + + const numNeg = Math.max( + minCount, + Math.min(maxCount, Math.floor(totalCandidates * NEGATIVE_SAMPLE_RATIO)) + ); + if (numNeg <= 0) return []; + + const exclude = new Set([selfDataId]); + const sampled = new Set(); + const result: string[] = []; + + switch (strategy) { + case 1: { + const c = groups.get(selfDatasetId)?.get(selfCollectionId) ?? []; + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 2: { + const c = collectSameDatasetOtherCollections(groups, selfDatasetId, selfCollectionId); + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 3: { + const c = collectOtherDatasets(groups, selfDatasetId); + sampleFromItems(c, exclude, sampled, numNeg, result); + break; + } + case 4: { + const perSource = Math.max(1, Math.floor(numNeg / 3)); + const remainder = numNeg - perSource * 3; + // Phase 1: same dataset same collection + sampleFromItems( + groups.get(selfDatasetId)?.get(selfCollectionId) ?? [], + exclude, + sampled, + perSource, + result + ); + // Phase 2: same dataset other collections + sampleFromItems( + collectSameDatasetOtherCollections(groups, selfDatasetId, selfCollectionId), + exclude, + sampled, + perSource, + result + ); + // Phase 3: other datasets + sampleFromItems( + collectOtherDatasets(groups, selfDatasetId), + exclude, + sampled, + perSource + remainder, + result + ); + break; + } + } + + // Global fallback: if candidates were insufficient, supplement from all items + if (result.length < numNeg) { + sampleFromItems(allProcessed, exclude, sampled, numNeg - result.length, result); + } + + return result.slice(0, numNeg); +} + +/** Partial Fisher-Yates random sampling; appends dataIds to out. */ +function sampleFromItems( + candidates: ProcessedItem[], + excludeIds: Set, + sampledIds: Set, + target: number, + out: string[] +): void { + if (candidates.length === 0 || target <= 0) return; + const arr = [...candidates]; + const maxScan = Math.min(arr.length, target * 3); + const start = out.length; + for (let i = 0; i < maxScan && out.length - start < target; i++) { + const j = i + Math.floor(Math.random() * (arr.length - i)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + const id = arr[i].dataId; + if (!excludeIds.has(id) && !sampledIds.has(id)) { + out.push(id); + sampledIds.add(id); + } + } +} + +function collectSameDatasetOtherCollections( + groups: Map>, + selfDatasetId: string, + selfCollectionId: string +): ProcessedItem[] { + const collMap = groups.get(selfDatasetId); + if (!collMap) return []; + const r: ProcessedItem[] = []; + for (const [cid, items] of collMap) { + if (cid !== selfCollectionId) r.push(...items); + } + return r; +} + +function collectOtherDatasets( + groups: Map>, + selfDatasetId: string +): ProcessedItem[] { + const r: ProcessedItem[] = []; + for (const [did, collMap] of groups) { + if (did !== selfDatasetId) { + for (const items of collMap.values()) r.push(...items); + } + } + return r; +} diff --git a/packages/service/core/train/common/task-abort-signal.ts b/packages/service/core/train/common/task-abort-signal.ts new file mode 100644 index 000000000000..38ffd4a3932b --- /dev/null +++ b/packages/service/core/train/common/task-abort-signal.ts @@ -0,0 +1,38 @@ +import { getGlobalRedisConnection } from '../../../common/redis'; + +export type TrainTaskType = 'rerank' | 'embedding'; +export type TrainTaskAbortReason = 'deleted' | 'cancelled'; + +const TRAIN_TASK_ABORT_SIGNAL_TTL_SECONDS = 60 * 60; + +const getTrainTaskAbortSignalKey = (type: TrainTaskType, taskId: string) => + `train:${type}:task-abort:${taskId}`; + +export async function setTrainTaskAbortSignal({ + type, + taskId, + reason +}: { + type: TrainTaskType; + taskId: string; + reason: TrainTaskAbortReason; +}) { + await getGlobalRedisConnection().set( + getTrainTaskAbortSignalKey(type, taskId), + reason, + 'EX', + TRAIN_TASK_ABORT_SIGNAL_TTL_SECONDS + ); +} + +export async function getTrainTaskAbortSignal({ + type, + taskId +}: { + type: TrainTaskType; + taskId: string; +}): Promise { + const reason = await getGlobalRedisConnection().get(getTrainTaskAbortSignalKey(type, taskId)); + + return reason === 'deleted' || reason === 'cancelled' ? reason : null; +} diff --git a/packages/service/core/train/common/utils.ts b/packages/service/core/train/common/utils.ts new file mode 100644 index 000000000000..a7faf5152a28 --- /dev/null +++ b/packages/service/core/train/common/utils.ts @@ -0,0 +1,832 @@ +import { addLog } from '../../../common/system/log'; +import { MongoDatasetData } from '../../dataset/data/schema'; +import { Types } from 'mongoose'; +import type { GenericEnhancedErrorMessage } from '@fastgpt/global/core/train/common/error'; +import { trainEnv } from './env'; +import type { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; +import { TrainTaskUnrecoverableError } from './errors'; + +/** + * Check Promise.allSettled results and re-throw any TrainTaskUnrecoverableError. + * + * When pLimit callbacks check abort signals and throw, Promise.allSettled silently + * collects them as rejections. This must be called immediately after allSettled + * to propagate abort signals before any "not enough results" fallthrough logic. + */ +export function propagateAbortFromResults(results: PromiseSettledResult[]): void { + for (const result of results) { + if (result.status === 'rejected' && result.reason instanceof TrainTaskUnrecoverableError) { + throw result.reason; + } + } +} + +/** + * Concurrency control utility using promise limiting + * + * Limits the number of simultaneously executing promises to avoid resource exhaustion. + * + * @param concurrency - Maximum number of concurrent promises + * @returns Limit function to wrap async functions + * + * @example + * const limit = pLimit(5); + * const results = await Promise.all( + * items.map(item => limit(() => processItem(item))) + * ); + */ +export function pLimit(concurrency: number): (fn: () => Promise) => Promise { + const queue: Array<() => void> = []; + let activeCount = 0; + + const next = () => { + activeCount--; + if (queue.length > 0) { + const resolve = queue.shift()!; + resolve(); + } + }; + + const run = async (fn: () => Promise): Promise => { + return new Promise((resolve, reject) => { + const execute = async () => { + activeCount++; + try { + const result = await fn(); + resolve(result); + } catch (error) { + reject(error); + } finally { + next(); + } + }; + + if (activeCount < concurrency) { + execute(); + } else { + queue.push(execute); + } + }); + }; + + return run; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Chunk quality filter (ported from filter_chunks.py) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Quality filter configuration for sampleDataFromDataset. + * Corresponds to ChunkFilter constructor parameters in filter_chunks.py. + */ +export type ChunkFilterConfig = { + minLength?: number; // 默认 20 + maxLength?: number; // 默认 3000 + minWords?: number; // 默认 10 + maxRepetitionRatio?: number; // 默认 0.5 + minQualityScore?: number; // 默认 0.65 + enableTocFilter?: boolean; // 默认 true + enablePaginationFilter?: boolean; // 默认 true + enableMetadataFilter?: boolean; // 默认 true +}; + +const DEFAULT_FILTER_CONFIG: Required = { + minLength: 20, + maxLength: 3000, + minWords: 10, + maxRepetitionRatio: 0.5, + minQualityScore: 0.65, + enableTocFilter: true, + enablePaginationFilter: true, + enableMetadataFilter: true +}; + +/** 对应 Python pipeline 的 clean_text() */ +export function cleanText(text: string): string { + if (!text) return ''; + // HTML entity decode (&   etc.) + let t = text.replace(/&[a-zA-Z]+;/g, ' ').replace(/&#\d+;/g, ' '); + // Remove HTML tags + t = t.replace(/<[^>]+>/g, ' '); + // Normalize whitespace: multi spaces/tabs → single space, 3+ newlines → 2 newlines + t = t.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n'); + // Trim each line + t = t + .split('\n') + .map((l) => l.trim()) + .join('\n'); + return t.trim(); +} + +function isChineseDominant(text: string): boolean { + const chinese = (text.match(/[\u4e00-\u9fff]/g) || []).length; + return chinese > text.length * 0.3; +} + +function calcRepetitionRatio(text: string): number { + if (text.length === 0) return 0; + const freq = new Map(); + for (const ch of text) { + if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') { + freq.set(ch, (freq.get(ch) ?? 0) + 1); + } + } + if (freq.size === 0) return 0; + const maxCount = Math.max(...freq.values()); + return maxCount / text.length; +} + +function calcSpecialCharRatio(text: string): number { + if (text.length === 0) return 0; + const special = ( + text.match(/[^\w\s\u4e00-\u9fff,。!?、;:""''()《》\[\].,!?;:()\'"<>-]/g) || [] + ).length; + return special / text.length; +} + +const TOC_PATTERNS = [ + /^\s*目\s*录\s*$/m, + /^\s*Table\s+of\s+Contents\s*$/im, + /^\s*CONTENTS\s*$/m, + /(?:第[一二三四五六七八九十\d]+章|Chapter\s+\d+).*?\.{3,}\s*\d+/, + /^\s*\d+\.\d+.*?\.{3,}\s*\d+/m, + /^\s*[一二三四五六七八九十]\s*[、.]\s*.{1,30}\s*\.{3,}\s*\d+/m +]; + +function isToc(text: string): boolean { + for (const re of TOC_PATTERNS) { + if (re.test(text)) return true; + } + return (text.match(/\.{3,}/g) || []).length >= 3; +} + +const PAGINATION_PATTERNS = [ + /^\s*[-–—]\s*\d+\s*[-–—]\s*$/m, + /^\s*第\s*\d+\s*页\s*$/m, + /^\s*Page\s+\d+\s*$/im, + /^\s*\d+\s*\/\s*\d+\s*$/m +]; + +function isPagination(text: string): boolean { + return PAGINATION_PATTERNS.some((re) => re.test(text)); +} + +const METADATA_PATTERNS = [ + /^(?:公司|企业|机构|部门)?内部资料\s*[,,]?\s*(?:注意保密|严禁外传)?\s*$/m, + /^保密(?:等级|级别)[::]\s*(?:一般|秘密|机密|绝密)\s*$/m, + /^(?:打印|编制|审核|批准)(?:时间|日期|人)[::]/m, + /^\s*(?:版本|Version)[::]\s*[\d.]+\s*$/m +]; + +function isMetadata(text: string): boolean { + return METADATA_PATTERNS.some((re) => re.test(text)); +} + +function calcQualityScore(text: string): number { + const total = text.length; + if (total === 0) return 0; + + // Char entropy + const freq = new Map(); + for (const ch of text) freq.set(ch, (freq.get(ch) ?? 0) + 1); + let charEntropy = 0; + for (const cnt of freq.values()) { + const p = cnt / total; + charEntropy -= p * Math.log2(p); + } + + // Root TTR (word diversity) + const words = isChineseDominant(text) ? text.split('') : text.match(/\b\w+\b/gi) || []; + const totalWords = words.length; + const uniqueWords = new Set(words.map((w) => w.toLowerCase())).size; + const rootTtr = totalWords > 0 ? uniqueWords / Math.sqrt(totalWords) : 0; + + // Anti-noise metrics + const zhPunct = ',。!?;:、""\'\'()《》【】「」…—·'; + const allPunct = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + zhPunct); + let punctCount = 0; + let digitCount = 0; + for (const ch of text) { + if (allPunct.has(ch)) punctCount++; + if (ch >= '0' && ch <= '9') digitCount++; + } + const punctRatio = punctCount / total; + const digitRatio = digitCount / total; + const hasDotPattern = /\.{3,}/.test(text) ? 1 : 0; + const hasNumberPrefix = /^\s*\d+[\.)]\s+/.test(text) ? 1 : 0; + + // Normalized scores + const normEntropy = Math.min(Math.max((charEntropy - 2.0) / 3.0, 0), 1); + const normDiversity = Math.min(rootTtr / 10.0, 1); + const noisePenalty = + punctRatio * 1.5 + digitRatio * 2.0 + hasDotPattern * 0.8 + hasNumberPrefix * 0.7; + const normAntiNoise = Math.max(0, 1 - noisePenalty); + const normLength = Math.min(total / 200, 1); + + return 0.25 * normEntropy + 0.25 * normDiversity + 0.35 * normAntiNoise + 0.15 * normLength; +} + +/** Check if a doc passes all quality filters (Step 0a). Returns false if should be discarded. */ +function isValidChunk(q: string, config: Required): boolean { + if (!q.trim()) return false; + if (q.length < config.minLength) return false; + if (q.length > config.maxLength) return false; + + const wordCount = isChineseDominant(q) ? q.length : (q.match(/\S+/g) || []).length; + if (wordCount < config.minWords) return false; + + if (calcRepetitionRatio(q) > config.maxRepetitionRatio) return false; + if (config.enableTocFilter && isToc(q)) return false; + if (config.enablePaginationFilter && isPagination(q)) return false; + if (config.enableMetadataFilter && isMetadata(q)) return false; + if (calcSpecialCharRatio(q) > 0.5) return false; + if (calcQualityScore(q) < config.minQualityScore) return false; + + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dataset item types +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Lightweight sampling result from sampleDataFromDataset. + * Contains only ID fields — content is fetched on-demand by downstream consumers. + */ +export type SampledDataItem = { + dataId: string; + datasetId: string; + collectionId: string; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Hash a string to a 32-bit unsigned integer (djb2 variant) + */ +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + return hash >>> 0; +} + +/** + * Seeded pseudo-random number generator (mulberry32 algorithm) + * Returns a function that generates uniform [0, 1) values deterministically. + */ +function seededRandom(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Two-pass quota allocation. + * Returns how many items to take from each KB (in the same order as datasetIds). + * + * When sampleSize is undefined: take all available (kbCounts[i]) from each KB. + * When sampleSize=M: proportional allocation with remainder redistribution. + */ +function computeQuotas( + kbCounts: number[], + sampleSize: number | undefined, + weights: Record | undefined, + datasetIds: string[] +): number[] { + if (sampleSize === undefined) { + return [...kbCounts]; + } + + const n = kbCounts.length; + const M = sampleSize; + + const rawWeights = datasetIds.map((id) => weights?.[id] ?? 1); + const sumW = rawWeights.reduce((s, w) => s + w, 0); + const wNorm = rawWeights.map((w) => (sumW > 0 ? w / sumW : 1 / n)); + + // Round 1: proportional allocation + const samples = kbCounts.map((kc, i) => Math.min(kc, Math.floor(M * wNorm[i]))); + let remaining = M - samples.reduce((s, v) => s + v, 0); + + // Round 2: redistribute remainder to unsatisfied KBs by remaining capacity + if (remaining > 0) { + const unsatisfied = kbCounts + .map((kc, i) => ({ i, avail: kc - samples[i] })) + .filter(({ avail }) => avail > 0); + const totalCap = unsatisfied.reduce((s, { avail }) => s + avail, 0); + + if (totalCap > 0) { + for (const { i, avail } of unsatisfied) { + const extra = Math.round((remaining * avail) / totalCap); + samples[i] += Math.min(extra, avail); + } + } + + // Boundary correction: if rounding caused overshoot, reduce from largest + let total = samples.reduce((s, v) => s + v, 0); + for (let iter = 0; total > M && iter < n * 2; iter++) { + const maxIdx = samples.reduce((mi, v, i) => (v > samples[mi] ? i : mi), 0); + if (samples[maxIdx] > 0) { + samples[maxIdx]--; + total--; + } + } + } + + return samples; +} + +/** + * Apply quality filter to raw MongoDB docs, returning lightweight SampledDataItem[]. + * Only q is used for quality checks; content (a, indexes) is NOT loaded. + */ +function filterToSampledItems( + rawDocs: any[], + config: Required +): SampledDataItem[] { + const result: SampledDataItem[] = []; + + for (const doc of rawDocs) { + const rawQ: string = doc.q ?? ''; + + if (!rawQ.trim()) continue; + + const cleanedQ = cleanText(rawQ.trim()); + + if (cleanedQ.length < 10) continue; + + if (!isValidChunk(cleanedQ, config)) continue; + + result.push({ + dataId: doc._id.toString(), + datasetId: doc.datasetId.toString(), + collectionId: doc.collectionId?.toString() ?? '' + }); + } + + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main sampling function +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Sample dataset chunks for training or evaluation. + * + * Step 0: Quality filter + cleanText (full migration from filter_chunks.py). + * Step 1: Compute kb_count[i] from valid data count. + * Step 2: Two-pass quota allocation (when sampleSize specified). + * Step 3: Take data by mode (train=front 80%, eval=back 20%, random=shuffled). + * + * @param datasetIds - Dataset ID list + * @param options.datasetType - Sampling mode, default 'train': + * - 'train': deterministic shuffle, take first 80% of valid docs + * - 'eval': deterministic shuffle, take last 20% of valid docs + * - 'random': random shuffle, take ALL valid docs (no train/eval split) + * @param options.sampleSize - Total sample budget M for quota allocation. + * Optional for all modes; when omitted, takes all available docs per KB. + * Required when weights are specified. + * @param options.weights - Per-KB sampling weights (requires sampleSize) + * @param options.filterConfig - Quality filter configuration + */ +export async function sampleDataFromDataset( + datasetIds: string[], + options: { + datasetType?: 'train' | 'eval' | 'random'; + sampleSize?: number; + weights?: Record; + filterConfig?: ChunkFilterConfig; + } = {} +): Promise { + const { sampleSize, datasetType = 'train', weights, filterConfig } = options; + const config = { ...DEFAULT_FILTER_CONFIG, ...filterConfig }; + + if (weights && !sampleSize) { + throw new Error('sampleSize is required when weights are specified'); + } + + // Only fetch _id, q, datasetId, collectionId — drop a and indexes. + // check 11 (indexes not empty) is pushed to the DB query via 'indexes.0': { $exists: true }. + const selectFields = '_id q datasetId collectionId'; + + // Step 0 + 1: Quality filter + compute kb_count per KB + const kbValidDocs: SampledDataItem[][] = []; + const kbCounts: number[] = []; + + for (const datasetId of datasetIds) { + const match = { + datasetId: new Types.ObjectId(datasetId), + 'indexes.0': { $exists: true } + }; + + addLog.info('Sampling data from dataset', { datasetId, datasetType, sampleSize }); + + const rawDocs = await MongoDatasetData.find(match).select(selectFields).lean(); + + const validDocs = filterToSampledItems(rawDocs, config); + const totalValid = validDocs.length; + + addLog.info('Quality filter result', { + datasetId, + rawCount: rawDocs.length, + validCount: totalValid + }); + + if (totalValid === 0) { + addLog.warn('No valid data found in dataset after quality filter', { datasetId }); + kbValidDocs.push([]); + kbCounts.push(0); + continue; + } + + // Deterministic shuffle for train/eval (same permutation ensures disjoint sets) + if (datasetType !== 'random') { + const rng = seededRandom(hashString(datasetId)); + for (let i = totalValid - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [validDocs[i], validDocs[j]] = [validDocs[j], validDocs[i]]; + } + } else { + // random mode: shuffle with Math.random() + for (let i = totalValid - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [validDocs[i], validDocs[j]] = [validDocs[j], validDocs[i]]; + } + } + + // Step 1: kb_count[i] + const trainCount = Math.floor(totalValid * trainEnv.TRAIN_DATA_SPLIT_RATIO); + let kbCount: number; + if (datasetType === 'eval') { + kbCount = totalValid - trainCount; + } else if (datasetType === 'random') { + kbCount = totalValid; + } else { + kbCount = trainCount; + } + + kbValidDocs.push(validDocs); + kbCounts.push(kbCount); + } + + // Step 2: Quota allocation + const totalKbCount = kbCounts.reduce((s, v) => s + v, 0); + const effectiveSampleSize = + sampleSize != null + ? Math.min(sampleSize, trainEnv.TRAIN_MAX_CHUNK_COUNT) + : totalKbCount > trainEnv.TRAIN_MAX_CHUNK_COUNT + ? trainEnv.TRAIN_MAX_CHUNK_COUNT + : undefined; + + const wasExplicitlyCapped = sampleSize != null && sampleSize > trainEnv.TRAIN_MAX_CHUNK_COUNT; + const wasAutomaticallyCapped = + sampleSize == null && totalKbCount > trainEnv.TRAIN_MAX_CHUNK_COUNT; + + if (effectiveSampleSize != null && (wasExplicitlyCapped || wasAutomaticallyCapped)) { + addLog.info('Sampling capped to max chunk count', { + totalKbCount, + maxAllowed: trainEnv.TRAIN_MAX_CHUNK_COUNT, + effectiveSampleSize, + userSampleSize: sampleSize, + cappedReason: wasExplicitlyCapped ? 'explicit_sample_size' : 'auto_limit' + }); + } + + const samplesPerKb = computeQuotas(kbCounts, effectiveSampleSize, weights, datasetIds); + + // Step 3: Take data according to quota + const allSamples: SampledDataItem[] = []; + + for (let i = 0; i < datasetIds.length; i++) { + const validDocs = kbValidDocs[i]; + const quota = samplesPerKb[i]; + if (quota === 0 || validDocs.length === 0) continue; + + const totalValid = validDocs.length; + const trainCount = Math.floor(totalValid * trainEnv.TRAIN_DATA_SPLIT_RATIO); + + let selected: SampledDataItem[]; + if (datasetType === 'eval') { + selected = validDocs.slice(trainCount, trainCount + quota); + } else { + selected = validDocs.slice(0, quota); + } + + addLog.info('Dataset quota allocated', { + datasetId: datasetIds[i], + datasetType, + totalValid, + quota, + selected: selected.length + }); + + allSamples.push(...selected); + } + + addLog.info('Final sampling result', { + totalDatasets: datasetIds.length, + datasetType, + totalSamples: allSamples.length + }); + + return allSamples; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared utility functions (unchanged) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build model endpoint configuration + * + * Converts model config object to OpenAI-compliant endpoint format. + * + * @param modelConfig - Model configuration with requestUrl, requestAuth, model fields + * @returns OpenAI-compliant endpoint object + */ +export function buildModelEndpoint(modelConfig: { + requestUrl?: string; + requestAuth?: string; + model: string; +}): { + base_url?: string; + api_key?: string; + model: string; +} { + const endpoint: { + base_url?: string; + api_key?: string; + model: string; + } = { + model: modelConfig.model + }; + + if (modelConfig.requestUrl) { + endpoint.base_url = modelConfig.requestUrl; + } + + if (modelConfig.requestAuth) { + endpoint.api_key = modelConfig.requestAuth; + } + + return endpoint; +} + +/** + * Convert training stage enum value to i18n stage key + * Example: generate_trainset -> train:stage_generate_trainset + * + * @param stage - Stage value (string) + * @returns i18n key with 'train:stage_' prefix + */ +export function getTrainStageKey(stage: string): string { + return `train:stage_${stage}`; +} + +/** + * Format training task error message + * + * Formats error message into a unified, instructive string format + * + * @param error - Enhanced error message object + * @returns Formatted error message string + */ +export function formatTrainTaskError(error: GenericEnhancedErrorMessage): string { + const parts: string[] = []; + + // Add stage information + if (error.stage) { + parts.push(`[Stage: ${error.stage}]`); + } + + // Add error type + parts.push(`[Type: ${error.type}]`); + + // Add detailed error message + parts.push(error.message); + + // Add resolution suggestion + if (error.suggestion) { + parts.push(`Suggestion: ${error.suggestion}`); + } + + return parts.join(' '); +} + +/** + * Factory for creating module-specific enhanced error creators + * + * Callers inject their own i18n key generation functions to produce + * module-specific enhanced error objects. + * + * @param config - Module-specific i18n key generation functions + * @returns A createEnhancedError function bound to the module's key generators + * + * @example + * // In rerank/utils.ts: + * import { makeCreateEnhancedError } from '../../common/utils'; + * import { + * getRerankTrainErrorMessageKey, + * getRerankTrainErrorSuggestionKey + * } from '@fastgpt/global/common/error/code/train'; + * + * export const createEnhancedError = makeCreateEnhancedError< + * RerankTaskCheckpointStageEnum, + * RerankTrainErrEnum, + * RerankTrainSuggestionEnum + * >({ + * getMessageKey: getRerankTrainErrorMessageKey, + * getSuggestionKey: getRerankTrainErrorSuggestionKey + * }); + */ +export function makeCreateEnhancedError< + TStage extends string, + TErr extends string, + TSuggestion extends string = string +>(config: { + getMessageKey: (type: TErr) => string; + getSuggestionKey: (suggestion: TSuggestion) => string; +}) { + return function createEnhancedError( + stage: TStage | null, + type: TErr, + suggestion?: TSuggestion, + originalError?: string + ): GenericEnhancedErrorMessage { + return { + stage: stage ? getTrainStageKey(stage) : null, + type, + message: config.getMessageKey(type), + suggestion: suggestion ? config.getSuggestionKey(suggestion) : undefined, + originalError + }; + }; +} + +/** sampled data item enriched with q/a content */ +export type SampledDataWithContent = SampledDataItem & { q: string; a: string }; + +/** + * Fetch q/a content for sampled items from MongoDB. + * + * Items whose document cannot be found in the DB (e.g. deleted after sampling) + * are silently dropped. The caller is responsible for handling a shorter-than- + * expected result list (typically by checking against MIN_EVAL_QA_COUNT). + * + * @param items - Sampled data items to enrich + * @returns Items with q/a content, missing documents omitted + */ +export async function fetchSampledContent( + items: SampledDataItem[] +): Promise { + if (items.length === 0) return []; + const ids = items.map((i) => new Types.ObjectId(i.dataId)); + const docs = await MongoDatasetData.find({ _id: { $in: ids } }) + .select('_id q a') + .lean(); + const docMap = new Map(docs.map((d: any) => [d._id.toString(), d])); + const result = items + .filter((item) => docMap.has(item.dataId)) + .map((item) => ({ + ...item, + q: (docMap.get(item.dataId) as any).q ?? '', + a: (docMap.get(item.dataId) as any).a ?? '' + })); + if (result.length < items.length) { + const missingIds = items.filter((item) => !docMap.has(item.dataId)).map((item) => item.dataId); + addLog.warn('Some sampled documents not found in DB, skipping', { + requested: items.length, + found: result.length, + missingIds + }); + } + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dataset validation helpers (shared between embedding and rerank) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check if a dataset has at least one document with the target index type. + * Returns the index type if missing, null if found. + */ +export async function findMissingIndexType( + datasetId: string, + indexType: `${DatasetDataIndexTypeEnum}` +): Promise<`${DatasetDataIndexTypeEnum}` | null> { + const count = await MongoDatasetData.countDocuments({ + datasetId, + 'indexes.type': indexType + }); + return count === 0 ? indexType : null; +} + +/** + * Count total valid chunks across datasets for a specific index type. + */ +export async function countValidChunksForDatasets( + datasetIds: string[], + indexType: `${DatasetDataIndexTypeEnum}` +): Promise { + let total = 0; + for (const datasetId of datasetIds) { + const count = await MongoDatasetData.countDocuments({ + datasetId, + 'indexes.type': indexType + }); + total += count; + } + return total; +} + +/** + * Configuration for validateDatasetReadiness error enums. + */ +export type DatasetReadinessErrorConfig = { + noDatasetConfigured: string; + datasetNoSynthesisIndex: string; + insufficientChunks: string; +}; + +/** + * Validate dataset readiness for training. + * + * Performs two checks: + * 1. Each dataset must have at least one document with the target index type. + * 2. Total valid chunks across all datasets must meet the minimum threshold. + * + * @param datasetIds - Dataset IDs to validate + * @param indexType - Target index type to look for + * @param errorEnums - Module-specific error enum values + * @param options.minChunkThreshold - Minimum chunk count (default: trainEnv.TRAIN_MIN_TASK_CHUNK_THRESHOLD) + * @throws Rejects with appropriate error enum value on validation failure + */ +export async function validateDatasetReadiness( + datasetIds: string[], + indexType: `${DatasetDataIndexTypeEnum}`, + errorEnums: DatasetReadinessErrorConfig, + options?: { minChunkThreshold?: number } +): Promise { + if (datasetIds.length === 0) { + return Promise.reject(errorEnums.noDatasetConfigured); + } + + const minChunkThreshold = options?.minChunkThreshold ?? trainEnv.TRAIN_MIN_TASK_CHUNK_THRESHOLD; + + addLog.info('Validating dataset readiness', { + datasetCount: datasetIds.length, + datasetIds, + indexType, + minChunkThreshold + }); + + // Check 1: index type availability + const validationResults = await Promise.all( + datasetIds.map(async (datasetId) => ({ + datasetId, + missing: await findMissingIndexType(datasetId, indexType) + })) + ); + + const invalidDatasets = validationResults.filter((r) => r.missing !== null); + if (invalidDatasets.length > 0) { + addLog.error('Dataset target index validation failed', { + totalDatasets: datasetIds.length, + invalidDatasets: invalidDatasets.length, + invalidDatasetDetails: invalidDatasets.map((d) => ({ + datasetId: d.datasetId, + missingType: d.missing + })), + indexType + }); + return Promise.reject(errorEnums.datasetNoSynthesisIndex); + } + + // Check 2: total chunk count threshold + const totalChunks = await countValidChunksForDatasets(datasetIds, indexType); + if (totalChunks < minChunkThreshold) { + addLog.warn('Dataset readiness check failed: insufficient chunks', { + datasetIds, + totalChunks, + threshold: minChunkThreshold + }); + return Promise.reject(errorEnums.insufficientChunks); + } + + addLog.info('Dataset readiness validation successful', { + validatedDatasets: datasetIds.length, + indexType, + totalChunks + }); +} diff --git a/packages/service/core/train/embedding/constants.ts b/packages/service/core/train/embedding/constants.ts new file mode 100644 index 000000000000..f533c96ea288 --- /dev/null +++ b/packages/service/core/train/embedding/constants.ts @@ -0,0 +1,16 @@ +/** Embedding training module constants */ + +import * as path from 'path'; +import { trainEnv } from '../common/env'; + +// ===== Training Data File Storage ===== +/** + * Get embedding training data file directory + * Priority: TRAIN_DATA_DIR env > {cwd}/data/train/embedding (default) + * + * @returns Full path to embedding training data directory + */ +export function getEmbeddingTrainDataDir(): string { + const baseDir = trainEnv.TRAIN_DATA_DIR || path.join(process.cwd(), 'data', 'train'); + return path.join(baseDir, 'embedding'); +} diff --git a/packages/service/core/train/embedding/data/controller.ts b/packages/service/core/train/embedding/data/controller.ts new file mode 100644 index 000000000000..3b7eac68a767 --- /dev/null +++ b/packages/service/core/train/embedding/data/controller.ts @@ -0,0 +1,189 @@ +import { MongoEmbeddingTrainsetData } from './schema'; +import { EmbeddingTrainDataSourceEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { addLog } from '../../../../common/system/log'; +import type { + EmbeddingTrainsetDataSchemaType, + EmbeddingTrainsetStatistics +} from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; + +/** Create manual training data */ +export async function createManualEmbeddingTrainData(params: { + trainsetId: string; + teamId: string; + tmbId: string; + query: string; + positiveDocs: string[]; + negativeDocs: string[]; + reason?: string; +}): Promise { + const { trainsetId, teamId, tmbId, query, positiveDocs, negativeDocs, reason } = params; + + const [doc] = await MongoEmbeddingTrainsetData.create([ + { + trainsetId, + teamId, + query, + positiveDocs, + negativeDocs, + source: EmbeddingTrainDataSourceEnum.manual, + metadata: { + sourceInfo: { + manualInfo: { + creator: tmbId, + createdAt: new Date(), + reason + } + } + } + } + ]); + + addLog.info('Created manual embedding train data', { + trainsetId, + dataId: String(doc._id) + }); + + return doc.toObject() as EmbeddingTrainsetDataSchemaType; +} + +/** Update training data */ +export async function updateEmbeddingTrainData(params: { + dataId: string; + query?: string; + positiveDocs?: string[]; + negativeDocs?: string[]; +}): Promise { + const { dataId, query, positiveDocs, negativeDocs } = params; + + const updateFields: { + query?: string; + positiveDocs?: string[]; + negativeDocs?: string[]; + } = {}; + if (query) updateFields.query = query; + if (positiveDocs) updateFields.positiveDocs = positiveDocs; + if (negativeDocs) updateFields.negativeDocs = negativeDocs; + + await MongoEmbeddingTrainsetData.updateOne({ _id: dataId }, updateFields); + + addLog.info('Updated embedding train data', { dataId }); +} + +/** Delete training data */ +export async function deleteEmbeddingTrainData(dataIds: string[]): Promise { + if (dataIds.length === 0) { + return Promise.reject(CommonErrEnum.missingParams); + } + + const firstData = await MongoEmbeddingTrainsetData.findById(dataIds[0]).lean(); + if (!firstData) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTrainDataNotExist); + } + + const result = await MongoEmbeddingTrainsetData.deleteMany({ + _id: { $in: dataIds }, + trainsetId: firstData.trainsetId + }); + + addLog.info('Deleted embedding train data', { + trainsetId: String(firstData.trainsetId), + deletedCount: result.deletedCount + }); + + return result.deletedCount || 0; +} + +/** Calculate embedding trainset statistics */ +export async function calculateEmbeddingTrainsetStats( + trainsetId: string +): Promise { + const trainData = await MongoEmbeddingTrainsetData.find({ trainsetId }).lean(); + + const dataCount = trainData.length; + + let positiveCount = 0; + let negativeCount = 0; + trainData.forEach((data) => { + positiveCount += data.positiveDocs.length; + negativeCount += data.negativeDocs.length; + }); + + // Use Map to store final data structure directly + const sourceSummary = new Map(); + + trainData.forEach((data) => { + const source = data.source; + + if (source === EmbeddingTrainDataSourceEnum.dataset) { + const datasetId = data.metadata?.sourceInfo?.datasetInfo?.datasetId; + + if (datasetId) { + const key = `dataset_${datasetId}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'dataset' as const, + count: 0, + datasetInfo: { + datasetId + } + }); + } + // Type assertion needed because TypeScript can't narrow discriminated union in Map.get() + const item = sourceSummary.get(key) as Extract< + EmbeddingTrainsetStatistics['sourceSummary'][number], + { type: 'dataset' } + >; + item.count++; + } + } else if (source === EmbeddingTrainDataSourceEnum.chat_log) { + const chatId = data.metadata?.sourceInfo?.chatLogInfo?.chatId; + + if (chatId) { + const key = `chat_log_${chatId}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'chat_log' as const, + count: 0, + chatLogInfo: { + chatId + } + }); + } + const item = sourceSummary.get(key) as Extract< + EmbeddingTrainsetStatistics['sourceSummary'][number], + { type: 'chat_log' } + >; + item.count++; + } + } else if (source === EmbeddingTrainDataSourceEnum.manual) { + const creator = data.metadata?.sourceInfo?.manualInfo?.creator; + + if (creator) { + const key = `manual_${creator}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'manual' as const, + count: 0, + manualInfo: { + creator + } + }); + } + const item = sourceSummary.get(key) as Extract< + EmbeddingTrainsetStatistics['sourceSummary'][number], + { type: 'manual' } + >; + item.count++; + } + } + }); + + return { + dataCount, + positiveCount, + negativeCount, + sourceSummary: Array.from(sourceSummary.values()) + }; +} diff --git a/packages/service/core/train/embedding/data/mq.ts b/packages/service/core/train/embedding/data/mq.ts new file mode 100644 index 000000000000..10b6081a69ab --- /dev/null +++ b/packages/service/core/train/embedding/data/mq.ts @@ -0,0 +1,33 @@ +import { getQueue, QueueNames } from '../../../../common/bullmq'; +import { trainEnv } from '../../common/env'; +import type { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; + +/** Embedding train data generation config */ +export type EmbeddingTrainDataGenerateConfig = { + sampleSize?: number; + weights?: Record; + forceRegenerate?: boolean; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; +}; + +export type EmbeddingTrainDataGenerateJobData = { + trainsetId: string; + datasetIds: string[]; + generateConfig: EmbeddingTrainDataGenerateConfig; +}; + +export const embeddingTrainDataGenerateQueue = getQueue( + QueueNames.embeddingTrainDataGenerate, + { + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: trainEnv.TRAIN_JOB_BACKOFF_DELAY }, + removeOnComplete: { age: 24 * 60 * 60 }, + removeOnFail: { age: 7 * 24 * 60 * 60 } + } + } +); diff --git a/packages/service/core/train/embedding/data/processor.ts b/packages/service/core/train/embedding/data/processor.ts new file mode 100644 index 000000000000..fb155e05c0a9 --- /dev/null +++ b/packages/service/core/train/embedding/data/processor.ts @@ -0,0 +1,180 @@ +import type { Processor } from 'bullmq'; +import type { EmbeddingTrainDataGenerateJobData } from './mq'; +import { MongoEmbeddingTrainsetData } from './schema'; +import { MongoEmbeddingTrainset } from '../trainset/schema'; +import { sampleDataFromDataset, createEmbeddingEnhancedError } from '../utils'; +import { + EmbeddingTrainDataSourceEnum, + EmbeddingTrainsetStatusEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { addLog } from '../../../../common/system/log'; +import { buildFineTuneDataStream } from '../../common/synthesize/buildFineTuneData'; +import { + TrainsetGenerationUnrecoverableError, + TrainsetGenerationRetriableError +} from '../../common/errors'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { trainEnv } from '../../common/env'; + +const SAVE_BATCH_SIZE = 1000; + +/** Embedding train data generation processor */ +export const embeddingTrainDataGenerateProcessor: Processor< + EmbeddingTrainDataGenerateJobData +> = async (job) => { + const { trainsetId, datasetIds, generateConfig } = job.data; + + // 1. Check if trainset exists + const trainset = await MongoEmbeddingTrainset.findById(trainsetId); + if (!trainset) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenNotFound, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenNotFound + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 2. Check if already generating + if (trainset.status === EmbeddingTrainsetStatusEnum.generating) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenAlreadyGenerating, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenAlreadyGenerating + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 3. datasetIds is required + if (!datasetIds?.length) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenNoDataset, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenNoDataset + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + const targetDatasetIds = datasetIds; + + // Update status to generating + await MongoEmbeddingTrainset.updateOne( + { _id: trainsetId }, + { status: EmbeddingTrainsetStatusEnum.generating } + ); + + // 4. Sample data IDs from datasets (lightweight — no q/a/indexes loaded) + const sampledItems = await sampleDataFromDataset(targetDatasetIds, { + datasetType: 'train', + sampleSize: generateConfig.sampleSize, + weights: generateConfig.weights + }); + + if (sampledItems.length === 0) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenDatasetEmpty, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenDatasetEmpty + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + if (sampledItems.length < trainEnv.TRAIN_MIN_CHUNK_COUNT) { + addLog.warn('Embedding trainset generation rejected: insufficient chunks after sampling', { + trainsetId, + sampledCount: sampledItems.length, + required: trainEnv.TRAIN_MIN_CHUNK_COUNT + }); + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenInsufficientChunks, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenInsufficientChunks + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 5. forceRegenerate: delete old data before streaming new data + if (generateConfig.forceRegenerate) { + await MongoEmbeddingTrainsetData.deleteMany({ + trainsetId, + source: EmbeddingTrainDataSourceEnum.dataset + }); + } + + // 6. Stream training samples and batch-insert + let saveBatch: any[] = []; + let totalGenerated = 0; + + try { + for await (const sample of buildFineTuneDataStream({ + sampledItems, + indexType: generateConfig.indexType, + indexMultiStrategy: generateConfig.indexMultiStrategy, + negativeStrategy: generateConfig.negativeStrategy, + minNegativeSamples: generateConfig.minNegativeSamples, + maxNegativeSamples: generateConfig.maxNegativeSamples + })) { + saveBatch.push({ + trainsetId, + teamId: trainset.teamId, + query: sample.query, + positiveDocs: sample.positive, + negativeDocs: sample.negatives, + source: EmbeddingTrainDataSourceEnum.dataset, + metadata: { + sourceInfo: { + datasetInfo: { dataId: sample.sourceId, datasetId: sample.datasetId } + }, + generateConfig + }, + createTime: new Date() + }); + totalGenerated++; + + if (saveBatch.length >= SAVE_BATCH_SIZE) { + await MongoEmbeddingTrainsetData.insertMany(saveBatch); + saveBatch = []; + await new Promise((r) => setImmediate(r)); // 让 GC 有机会回收上批对象 + } + } + + if (saveBatch.length > 0) { + await MongoEmbeddingTrainsetData.insertMany(saveBatch); + } + } catch (dbError) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenDatabaseError, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenDatabaseError, + (dbError as Error).message + ); + throw new TrainsetGenerationRetriableError(error); + } + + if (totalGenerated === 0) { + const error = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingTrainsetGenDitingNoData, + EmbeddingTrainSuggestionEnum.embeddingTrainsetGenDitingNoData + ); + throw new TrainsetGenerationRetriableError(error); + } + + // 7. Update trainset status to ready + await MongoEmbeddingTrainset.updateOne( + { _id: trainsetId }, + { + status: EmbeddingTrainsetStatusEnum.ready, + updateTime: new Date() + } + ); + + addLog.info('Generated embedding train data from datasets', { + trainsetId, + datasetCount: targetDatasetIds.length, + dataCount: totalGenerated + }); +}; diff --git a/packages/service/core/train/embedding/data/schema.ts b/packages/service/core/train/embedding/data/schema.ts new file mode 100644 index 000000000000..26cf40a82e0c --- /dev/null +++ b/packages/service/core/train/embedding/data/schema.ts @@ -0,0 +1,78 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { EmbeddingTrainsetDataSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTrainDataSourceEnum } from '@fastgpt/global/core/train/embedding/constants'; + +/** Embedding trainset data schema */ +const EmbeddingTrainsetDataSchema = new connectionMongo.Schema({ + trainsetId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'embedding_trainset', + required: true + }, + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + query: { + type: String, + required: true + }, + positiveDocs: { + type: [String], + required: true + }, + negativeDocs: { + type: [String], + required: true + }, + source: { + type: String, + enum: Object.values(EmbeddingTrainDataSourceEnum), + required: true + }, + metadata: { + type: { + sourceInfo: { + datasetInfo: { + dataId: String, + datasetId: String + }, + chatLogInfo: { + chatId: String, + itemIds: [String] + }, + manualInfo: { + creator: String, + createdAt: Date, + reason: String + } + }, + generateConfig: { + sampleSize: Number, + forceRegenerate: Boolean, + minNegativeSamples: Number, + maxNegativeSamples: Number, + includeOriginalQ: Boolean + } + }, + required: true + }, + createTime: { + type: Date, + default: () => new Date() + } +}); + +// Indexes +EmbeddingTrainsetDataSchema.index({ trainsetId: 1 }); +EmbeddingTrainsetDataSchema.index({ trainsetId: 1, createTime: -1 }); +EmbeddingTrainsetDataSchema.index({ teamId: 1, createTime: -1 }); +EmbeddingTrainsetDataSchema.index({ teamId: 1 }); +EmbeddingTrainsetDataSchema.index({ source: 1 }); +EmbeddingTrainsetDataSchema.index({ trainsetId: 1, source: 1, createTime: -1 }); + +export const MongoEmbeddingTrainsetData = getMongoModel( + 'embedding_trainset_data', + EmbeddingTrainsetDataSchema +); diff --git a/packages/service/core/train/embedding/data/worker.ts b/packages/service/core/train/embedding/data/worker.ts new file mode 100644 index 000000000000..3ed4aaaf19f9 --- /dev/null +++ b/packages/service/core/train/embedding/data/worker.ts @@ -0,0 +1,159 @@ +import { getWorker, QueueNames } from '../../../../common/bullmq'; +import { addLog } from '../../../../common/system/log'; +import { type EmbeddingTrainDataGenerateJobData } from './mq'; +import { embeddingTrainDataGenerateProcessor } from './processor'; +import { trainEnv } from '../../common/env'; +import { + TrainsetGenerationUnrecoverableError, + TrainsetGenerationRetriableError +} from '../../common/errors'; +import { MongoEmbeddingTrainset } from '../trainset/schema'; +import { EmbeddingTrainsetStatusEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/embedding/error'; +import { createEmbeddingEnhancedError } from '../utils'; + +export function initEmbeddingTrainDataWorker() { + const worker = getWorker( + QueueNames.embeddingTrainDataGenerate, + embeddingTrainDataGenerateProcessor, + { + stalledInterval: trainEnv.TRAIN_WORKER_STALLED_INTERVAL, + maxStalledCount: + global.systemEnv?.trainConfig?.maxStalledCount || trainEnv.TRAIN_WORKER_MAX_STALLED_COUNT, + concurrency: + global.systemEnv?.trainConfig?.dataGenerateConcurrency || + trainEnv.TRAIN_DATA_GENERATE_CONCURRENCY + } + ); + + worker.on('active', (job) => { + if (job?.data) { + const { trainsetId, datasetIds } = job.data; + addLog.info('[EmbeddingTrainData] Generation task started', { + jobId: job.id, + trainsetId, + datasetIds + }); + } + }); + + worker.on('completed', (job) => { + if (job?.data) { + const { trainsetId } = job.data; + addLog.info('[EmbeddingTrainData] Generation task completed', { + jobId: job.id, + trainsetId + }); + } + }); + + worker.on('stalled', async (jobId: string) => { + addLog.warn('[EmbeddingTrainData] Generation task stalled, will be retried', { + jobId + }); + }); + + worker.on('failed', async (job, error) => { + if (job?.data) { + const { trainsetId } = job.data; + + // Extract structured error information + let errorMsg: EnhancedErrorMessage; + + if ( + error instanceof TrainsetGenerationUnrecoverableError || + error instanceof TrainsetGenerationRetriableError + ) { + // Use structured error from custom error classes + errorMsg = error.enhancedError; + + addLog.error('[EmbeddingTrainData] Generation task failed', { + jobId: job.id, + trainsetId, + errorType: error.name, + errorMsg, // Log complete structured error + attemptsMade: job.attemptsMade, + maxAttempts: job.opts.attempts + }); + } else { + // Unknown error, construct basic structured error + errorMsg = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingUnknownError, + EmbeddingTrainSuggestionEnum.embeddingUnknownError, + error.stack + ); + + addLog.error('[EmbeddingTrainData] Generation task failed with unknown error', { + jobId: job.id, + trainsetId, + error: error.message, + stack: error.stack, + attemptsMade: job.attemptsMade, + maxAttempts: job.opts.attempts + }); + } + + // Only persist errorMsg if this is an unrecoverable error or final retry + const isUnrecoverable = error instanceof TrainsetGenerationUnrecoverableError; + const isFinalRetry = job.attemptsMade >= (job.opts.attempts ?? 0); + + if (isUnrecoverable || isFinalRetry) { + try { + await MongoEmbeddingTrainset.updateOne( + { _id: trainsetId }, + { + status: EmbeddingTrainsetStatusEnum.error, + errorMsg, // Save structured object + updateTime: new Date() + } + ); + + addLog.info( + '[EmbeddingTrainData] Trainset status updated to error in MongoDB (unrecoverable or final retry)', + { + trainsetId, + isUnrecoverable, + isFinalRetry, + attemptsMade: job.attemptsMade + } + ); + } catch (updateError) { + addLog.error('[EmbeddingTrainData] Failed to update trainset status in MongoDB', { + trainsetId, + updateError: (updateError as Error).message + }); + } + } else { + // Retriable error with remaining attempts - reset status to pending for retry + try { + await MongoEmbeddingTrainset.updateOne( + { _id: trainsetId }, + { + status: EmbeddingTrainsetStatusEnum.pending, + updateTime: new Date() + } + ); + + addLog.info('[EmbeddingTrainData] Retriable error, status reset to pending for retry', { + trainsetId, + attemptsMade: job.attemptsMade, + remainingAttempts: (job.opts.attempts ?? 0) - job.attemptsMade + }); + } catch (updateError) { + addLog.error('[EmbeddingTrainData] Failed to reset trainset status for retry', { + trainsetId, + updateError: (updateError as Error).message + }); + } + } + } + }); + + addLog.info('[EmbeddingTrainData] Worker created successfully'); + return worker; +} diff --git a/packages/service/core/train/embedding/external/index.ts b/packages/service/core/train/embedding/external/index.ts new file mode 100644 index 000000000000..ce89e03870f7 --- /dev/null +++ b/packages/service/core/train/embedding/external/index.ts @@ -0,0 +1,50 @@ +import { synthesizeEvalData, callLLMJudge } from '../../common/external/diting'; +import type { + DiTingSyntheticEvalDataRequest, + DiTingSyntheticEvalDataResponse, + DiTingLLMJudgeRequest, + DiTingLLMJudgeResponse +} from '../../common/external/diting'; + +const EMBEDDING_SYNTHESIZER_NAME = 'eval_qa_pairs_synthesizer'; + +export type EmbeddingSyntheticEvalDataRequest = Omit< + DiTingSyntheticEvalDataRequest, + 'synthesizerConfig' +>; + +export async function synthesizeEmbeddingEvalData( + request: EmbeddingSyntheticEvalDataRequest +): Promise { + return synthesizeEvalData({ + ...request, + synthesizerConfig: { synthesizerName: EMBEDDING_SYNTHESIZER_NAME } + }); +} + +/** Call DiTing LLM judge to determine truly relevant chunks for a query */ +export type EmbeddingLLMJudgeRequest = DiTingLLMJudgeRequest; +export type EmbeddingLLMJudgeResponse = DiTingLLMJudgeResponse; + +export async function judgeRelevantChunks( + request: DiTingLLMJudgeRequest +): Promise { + return callLLMJudge(request); +} + +// SFT Bridge - re-export from common package +export { + createSFTTask, + querySFTTaskStatus, + deleteSFTTask, + SFTTaskStatus +} from '../../common/external/sftbridge'; + +export type { + CreateSFTTaskRequest, + CreateSFTTaskResponse, + QuerySFTTaskStatusRequest, + QuerySFTTaskStatusResponse, + DeleteSFTTaskRequest, + DeleteSFTTaskResponse +} from '../../common/external/sftbridge'; diff --git a/packages/service/core/train/embedding/index.ts b/packages/service/core/train/embedding/index.ts new file mode 100644 index 000000000000..5a5ba2e409b4 --- /dev/null +++ b/packages/service/core/train/embedding/index.ts @@ -0,0 +1,25 @@ +/** Initialize all Embedding training module workers */ +import { addLog } from '../../../common/system/log'; +import { initEmbeddingTrainDataWorker } from './data/worker'; +import { initEmbeddingTrainTaskWorker } from './task/worker'; + +// Re-export all public APIs from submodules +export * from './external'; +export * from './trainset/controller'; +export { MongoEmbeddingTrainset } from './trainset/schema'; +export * from './data/controller'; +export { MongoEmbeddingTrainsetData } from './data/schema'; +export { embeddingTrainDataGenerateQueue } from './data/mq'; +export { embeddingTrainDataGenerateProcessor } from './data/processor'; +export { initEmbeddingTrainDataWorker } from './data/worker'; +export * from './utils'; +export { getEmbeddingTrainDataDir } from './constants'; +export * from './task/controller'; +export { MongoEmbeddingTrainTask } from './task/schema'; +export { initEmbeddingTrainTaskWorker } from './task/worker'; + +export const initEmbeddingTrainWorkers = () => { + addLog.info('Init Embedding Train Workers...'); + initEmbeddingTrainDataWorker(); + initEmbeddingTrainTaskWorker(); +}; diff --git a/packages/service/core/train/embedding/model/controller.ts b/packages/service/core/train/embedding/model/controller.ts new file mode 100644 index 000000000000..ee6e783a5df0 --- /dev/null +++ b/packages/service/core/train/embedding/model/controller.ts @@ -0,0 +1,187 @@ +import { MongoSystemModel } from '../../../ai/config/schema'; +import { updatedReloadSystemModel } from '../../../ai/config/utils'; +import { addLog } from '../../../../common/system/log'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/model'; +import type { EmbeddingModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { getModelProvider } from '../../../app/provider/controller'; +import { + deleteTunedModelChannel, + createTunedModelChannel, + waitForChannelAvailable +} from '../task/helpers/channel'; +import { MongoEmbeddingTrainTask } from '../task/schema'; + +/** + * Create embedding model configuration + * + * Architecture: Model config contains metadata only, channel contains access credentials. + * Order: 1) Create model config, 2) Create channel, 3) Poll until channel is available. + * + * @param params.name - Display name for the tuned model + * @param params.endpoint - Tuned model endpoint (stored in channel, not model config) + * @param params.isActive - Whether to activate the model immediately + * @param params.charsPointsPrice - Inherited from base model + * @param params.defaultToken - Inherited from base model (split text default token) + * @param params.maxToken - Inherited from base model (model max token) + * @param params.weight - Inherited from base model (training weight) + * @param params.normalization - Inherited from base model + * @param params.batchSize - Inherited from base model + * @param params.defaultConfig - Inherited from base model (post request config) + * @returns Model configuration object ID + */ +export async function createEmbeddingModelConfig(params: { + name: string; + endpoint: { + base_url: string; + api_key: string; + model: string; + }; + isActive: boolean; + tmbId: string; + teamId: string; + charsPointsPrice?: number; + defaultToken?: number; + maxToken?: number; + weight?: number; + normalization?: boolean; + batchSize?: number; + defaultConfig?: Record; + instruction?: string; + taskId: string; +}): Promise { + const { name, endpoint, isActive, tmbId, teamId } = params; + const model = endpoint.model; + const channelName = `${model}-ch`; + + const modelConfig: EmbeddingModelItemType = { + id: '', + provider: getModelProvider('Sangfor AICP').id, + model, + name, + isActive: isActive ?? true, + isCustom: true, + isTuned: true, + type: ModelTypeEnum.embedding, + charsPointsPrice: params.charsPointsPrice ?? 0, + defaultToken: params.defaultToken ?? 512, + maxToken: params.maxToken ?? 512, + weight: params.weight ?? 0, + normalization: params.normalization, + batchSize: params.batchSize ?? 10, + defaultConfig: params.defaultConfig, + instruction: params.instruction + }; + + const task = await MongoEmbeddingTrainTask.findById( + params.taskId, + 'checkpoint.data.registering.tunedModelId' + ).lean(); + const existingModelId = task?.checkpoint?.data?.registering?.tunedModelId; + + const { id: _, ...configFields } = modelConfig; + + const result = existingModelId + ? await MongoSystemModel.findOneAndUpdate( + { _id: existingModelId }, + { + ...configFields, + tmbId, + teamId, + isShared: false + }, + { + new: true + } + ) + : await MongoSystemModel.create({ + ...configFields, + tmbId, + teamId, + isShared: false + }); + + if (!result) { + throw new Error('Failed to create or update model config'); + } + + const objectId = String(result._id); + + if (!existingModelId) { + await MongoEmbeddingTrainTask.updateOne( + { _id: params.taskId }, + { + 'checkpoint.data.registering.tunedModelId': objectId, + updateTime: new Date() + } + ); + } + + addLog.info('Created or updated embedding model config', { + model, + name, + objectId, + isActive + }); + + // Step 2: Create or update AI Proxy channel (idempotent) + // Channel stores access credentials (base_url and api_key) + await createTunedModelChannel({ + channelName, + endpoint, + modelConfigId: model + }); + + // Reload system models + await updatedReloadSystemModel(); + addLog.info('Reloaded system models', { model, objectId }); + + // Step 3: Poll until AI Proxy channel is available + // New channels may not be immediately accessible after creation + await waitForChannelAvailable({ model, endpoint }); + + return objectId; +} + +/** + * Delete embedding model configuration + * + * Deletes model from AI Proxy channel and FastGPT system. + * SFT Bridge resource cleanup is handled separately by deleteEmbeddingTrainTask. + * Order: 1) Delete channel first, 2) Delete model config + reload. + * + * @param modelId - Model's MongoDB _id (platform-unique model ID) + * @returns Promise that resolves when deletion is complete + * @throws {Error} When channel or model deletion fails + */ +export async function deleteEmbeddingModelConfig(modelId: string): Promise { + addLog.info('Deleting embedding model config', { modelId }); + + // Look up the model config to get the model name (needed for channel operations) + const doc = await MongoSystemModel.findById(modelId).lean(); + if (!doc) { + addLog.warn('No model config found to delete in FastGPT', { modelId }); + return; + } + const modelName = doc.model; + + // Step 1: Delete AI Proxy channel first (contains access credentials) + await deleteTunedModelChannel(modelName); + addLog.info('Deleted AI Proxy channel', { modelId, modelName }); + + // Step 2: Delete model configuration from FastGPT database + const deleteResult = await MongoSystemModel.deleteOne({ _id: modelId }); + + if (deleteResult.deletedCount === 0) { + addLog.warn('No model config found to delete in FastGPT', { modelId }); + } else { + addLog.info('Deleted embedding model config from FastGPT', { + modelId, + modelName, + deletedCount: deleteResult.deletedCount + }); + + // Reload system models after deletion + await updatedReloadSystemModel(); + addLog.info('Reloaded system models', { modelId }); + } +} diff --git a/packages/service/core/train/embedding/task/controller.ts b/packages/service/core/train/embedding/task/controller.ts new file mode 100644 index 000000000000..9b8de65ec430 --- /dev/null +++ b/packages/service/core/train/embedding/task/controller.ts @@ -0,0 +1,488 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { MongoEmbeddingTrainTask } from './schema'; +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import type { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { + EmbeddingTrainMethodEnum, + EmbeddingTrainTaskStatusEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { addLog } from '../../../../common/system/log'; +import { getEmbeddingModelById } from '../../../ai/model'; +import type { ClientSession } from '../../../../common/mongo'; +import { buildModelEndpoint } from '../utils'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { deleteEmbeddingModelConfig } from '../model/controller'; +import { getEmbeddingTrainDataDir } from '../constants'; +import { MongoEmbeddingTrainset } from '../trainset/schema'; +import { MongoEmbeddingTrainsetData } from '../data/schema'; +import { MongoEvalDatasetCollection } from '../../../evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '../../../evaluation/dataset/evalDatasetDataSchema'; +import { deleteSFTTask } from '../../common/external/sftbridge'; +import { removeEmbeddingTrainTaskJob } from './mq'; +import { setTrainTaskAbortSignal, type TrainTaskAbortReason } from '../../common/task-abort-signal'; + +/** + * Create embedding training task + * + * Key logic: + * 1. Accepts baseModelId directly + * 2. Calls getEmbeddingModelById(baseModelId) to build baseModelEndpoint + * 3. trainsetId is optional (auto mode: written by generate_trainset stage) + * 4. evalDatasetId and datasetIds are optional based on mode + * + * @param params - Task creation parameters + * @returns Task ID + * @throws {EmbeddingTrainErrEnum} When base model not found + */ +export async function createEmbeddingTrainTask(params: { + baseModelId: string; + trainsetId?: string; + evalDatasetId?: string; + datasetIds?: string[]; + newModelName?: string; + teamId: string; + tmbId: string; + name?: string; + trainMethod?: string; + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: string; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; +}): Promise { + const { + baseModelId, + trainsetId, + evalDatasetId, + datasetIds, + newModelName, + teamId, + tmbId, + name, + trainMethod, + generateConfig + } = params; + + const embeddingModel = getEmbeddingModelById(baseModelId); + if (!embeddingModel) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskModelNotFound); + } + + if (embeddingModel.isActive === false) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskBaseModelDisabled); + } + + const baseModelEndpoint = buildModelEndpoint(embeddingModel); + + addLog.info('Creating embedding train task', { + baseModelId, + baseModelEndpoint + }); + + const [doc] = await MongoEmbeddingTrainTask.create([ + { + trainsetId: trainsetId || undefined, + evalDatasetId: evalDatasetId || undefined, + datasetIds: datasetIds?.length ? datasetIds : undefined, + newModelName: newModelName || undefined, + generateConfig: generateConfig || undefined, + teamId, + tmbId, + name: name || `Embedding Training - ${new Date().toLocaleDateString()}`, + baseModelId, + baseModelEndpoint, + trainMethod: trainMethod || EmbeddingTrainMethodEnum.task_tuning, + status: EmbeddingTrainTaskStatusEnum.pending, + checkpoint: { + stage: null, + data: {}, + stageEndTime: {} + } + } + ]); + + addLog.info('Created embedding train task', { + baseModelId, + trainsetId, + taskId: String(doc._id) + }); + + return doc.toObject() as EmbeddingTrainTaskSchemaType; +} + +/** Update task status */ +export async function updateEmbeddingTaskStatus( + taskId: string, + status: `${EmbeddingTrainTaskStatusEnum}` +): Promise { + const updateData: { + status: `${EmbeddingTrainTaskStatusEnum}`; + updateTime: Date; + finishTime?: Date; + } = { + status, + updateTime: new Date() + }; + + if ( + status === EmbeddingTrainTaskStatusEnum.completed || + status === EmbeddingTrainTaskStatusEnum.cancelled || + status === EmbeddingTrainTaskStatusEnum.failed + ) { + updateData.finishTime = new Date(); + } + + await MongoEmbeddingTrainTask.updateOne({ _id: taskId }, updateData); + + addLog.info('Updated embedding task status', { taskId, status }); +} + +/** Update checkpoint stage and record completion time */ +export async function updateEmbeddingCheckpointStage( + taskId: string, + stage: `${EmbeddingTaskCheckpointStageEnum}` +): Promise { + await MongoEmbeddingTrainTask.updateOne( + { _id: taskId }, + { + 'checkpoint.stage': stage, + [`checkpoint.stageEndTime.${stage}`]: new Date(), + updateTime: new Date() + } + ); + + addLog.info('Updated embedding checkpoint stage completion', { taskId, stage }); +} + +/** + * Update checkpoint data (fine-grained update) + * + * Supports two modes for updating checkpoint data of a specific stage: + * 1. Full replacement (merge=false): Replaces entire stage data + * 2. Partial merge (merge=true): Updates only specific fields within stage + * + * @param taskId - Task ID + * @param stage - Checkpoint stage name + * @param data - Data to update + * @param merge - Whether to merge with existing data (default: false) + */ +export async function updateEmbeddingCheckpointData( + taskId: string, + stage: + | 'generate_trainset' + | 'generate_evaldataset' + | 'eval_basemodel' + | 'finetuning' + | 'registering' + | 'eval_tunedmodel' + | 'llm_judge', + data: Record, + merge: boolean = false +): Promise { + if (merge) { + const updateFields: Record = { updateTime: new Date() }; + for (const [key, value] of Object.entries(data)) { + updateFields[`checkpoint.data.${stage}.${key}`] = value; + } + await MongoEmbeddingTrainTask.updateOne({ _id: taskId }, updateFields); + } else { + await MongoEmbeddingTrainTask.updateOne( + { _id: taskId }, + { + [`checkpoint.data.${stage}`]: data, + updateTime: new Date() + } + ); + } + + addLog.info('Updated embedding checkpoint data', { + taskId, + stage, + merge, + keys: Object.keys(data) + }); +} + +/** Get embedding training task by ID */ +export async function getEmbeddingTrainTask( + taskId: string +): Promise { + return MongoEmbeddingTrainTask.findById(taskId).lean(); +} + +/** + * Delete embedding training task with unified artifact cleanup + * + * Cleanup order: + * 1. FastGPT model config + AI Proxy channel (registering stage artifact) + * 2. SFT Bridge resources (finetuning stage artifact, async non-blocking) + * 3. Eval dataset collections + data (generate_evaldataset stage artifact) + * 4. Auto-generated trainset + data (generate_trainset stage artifact, auto mode only) + * 5. Task record + * 6. Temp JSONL files (async non-blocking) + * + * @param taskId - Task ID to delete + * @param session - Optional MongoDB session for transaction + * @throws {Error} When task not found + */ +export async function deleteEmbeddingTrainTask( + taskId: string, + options?: { deleteModel?: boolean }, + session?: ClientSession +): Promise { + const task = await MongoEmbeddingTrainTask.findById(taskId, null, { session }).lean(); + if (!task) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskNotExist); + } + + // 1. Signal active worker, then remove BullMQ job before any data cleanup + await setEmbeddingTrainTaskAbortSignal(taskId, 'deleted'); + await removeEmbeddingTrainTaskJob(taskId, { forceCleanActiveJobs: true }); + + const tempFilePath = task.result?.trainDatasetFilePath; + + // 2. Clean up FastGPT model config + AI Proxy channel (registering stage artifact) + const tunedModelId = task.checkpoint?.data?.registering?.tunedModelId; + if (tunedModelId && options?.deleteModel !== false) { + addLog.info('Deleting tuned model config associated with task', { + taskId, + tunedModelId + }); + + try { + // Delete model config and channel only; SFT Bridge is handled separately below + await deleteEmbeddingModelConfig(tunedModelId); + addLog.info('Successfully deleted tuned model config', { taskId, tunedModelId }); + } catch (error) { + addLog.warn('Failed to delete tuned model config, continuing with task deletion', { + taskId, + tunedModelId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // 3. Clean up SFT Bridge resources (async non-blocking) + const sftTaskId = task.checkpoint?.data?.finetuning?.sftTaskId; + if (sftTaskId) { + setImmediate(() => { + deleteSFTTask({ taskId: sftTaskId }).catch((err) => { + addLog.warn('Failed to delete SFT task from SFT Bridge', { + taskId, + sftTaskId, + error: String(err) + }); + }); + }); + addLog.info('Triggered async SFT task deletion', { taskId, sftTaskId }); + } + + // 4. Clean up auto-generated eval dataset + data (only if this task auto-generated it) + if (task.checkpoint?.data?.generate_evaldataset?.autoGenerated) { + const evalCollections = await MongoEvalDatasetCollection.find( + { 'metadata.taskId': taskId }, + null, + { session } + ).lean(); + + if (evalCollections.length > 0) { + const collectionIds = evalCollections.map((col) => col._id); + + const deletedDataResult = await MongoEvalDatasetData.deleteMany( + { evalDatasetCollectionId: { $in: collectionIds } }, + { session } + ); + addLog.info('Deleted eval dataset data for task', { + taskId, + deletedCount: deletedDataResult.deletedCount + }); + + const deletedCollectionResult = await MongoEvalDatasetCollection.deleteMany( + { _id: { $in: collectionIds } }, + { session } + ); + addLog.info('Deleted eval dataset collections for task', { + taskId, + deletedCount: deletedCollectionResult.deletedCount + }); + } + } + + // 5. Clean up auto-generated trainset + data (only if this task auto-generated it) + const autoTrainsetId = + task.checkpoint?.data?.generate_trainset?.autoGenerated && task.trainsetId + ? String(task.trainsetId) + : undefined; + if (autoTrainsetId) { + try { + const deletedTrainData = await MongoEmbeddingTrainsetData.deleteMany( + { trainsetId: autoTrainsetId }, + { session } + ); + await MongoEmbeddingTrainset.deleteOne({ _id: autoTrainsetId }, { session }); + addLog.info('Deleted auto-generated trainset and data', { + taskId, + trainsetId: autoTrainsetId, + deletedDataCount: deletedTrainData.deletedCount + }); + } catch (error) { + addLog.warn('Failed to delete auto-generated trainset, continuing', { + taskId, + trainsetId: autoTrainsetId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // 6. Delete task record + await MongoEmbeddingTrainTask.deleteOne({ _id: taskId }, { session }); + addLog.info('Deleted embedding train task', { taskId }); + + // 7. Clean up temp files (async non-blocking) + if (tempFilePath) { + setImmediate(() => { + cleanupEmbeddingTempFiles(tempFilePath).catch((error) => { + addLog.warn('Failed to cleanup temp files after task deletion', { + taskId, + tempFilePath, + error: (error as Error).message + }); + }); + }); + } +} + +/** Cancel embedding training task */ +export async function cancelEmbeddingTrainTask(taskId: string): Promise { + const task = await MongoEmbeddingTrainTask.findById(taskId).lean(); + if (!task) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskNotExist); + } + + if ( + task.status === EmbeddingTrainTaskStatusEnum.completed || + task.status === EmbeddingTrainTaskStatusEnum.failed || + task.status === EmbeddingTrainTaskStatusEnum.cancelled + ) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskCannotCancel); + } + + // Signal active worker, then remove BullMQ job (force clean if active) + await setEmbeddingTrainTaskAbortSignal(taskId, 'cancelled'); + await removeEmbeddingTrainTaskJob(taskId, { forceCleanActiveJobs: true }); + + // Cancel SFT task if finetuning has started (async non-blocking) + const sftTaskId = task.checkpoint?.data?.finetuning?.sftTaskId; + if (sftTaskId) { + setImmediate(() => { + deleteSFTTask({ taskId: sftTaskId }).catch((err) => { + addLog.warn('Failed to cancel SFT task during task cancellation', { + taskId, + sftTaskId, + error: String(err) + }); + }); + }); + addLog.info('Triggered async SFT task cancellation', { taskId, sftTaskId }); + } + + await updateEmbeddingTaskStatus(taskId, EmbeddingTrainTaskStatusEnum.cancelled); + + addLog.info('Cancelled embedding train task', { taskId }); +} + +/** + * Clean up temporary files related to embedding training task + * + * @param filePath - Optional temp file path to delete + * @param taskId - Optional task ID to generate file pattern for cleanup + */ +export async function cleanupEmbeddingTempFiles(filePath?: string, taskId?: string): Promise { + if (!filePath && !taskId) { + addLog.warn('Both filePath and taskId are not provided for cleanup'); + return; + } + + try { + if (filePath) { + await fs.unlink(filePath); + addLog.info('Cleaned up temp file', { filePath }); + } else if (taskId) { + // Use configurable training data directory + const trainDataDir = getEmbeddingTrainDataDir(); + const files = await fs.readdir(trainDataDir); + const tempFilePattern = new RegExp(`^embedding_train_${taskId}_\\d+\\.jsonl$`); + + for (const file of files) { + if (tempFilePattern.test(file)) { + const targetPath = path.join(trainDataDir, file); + await fs.unlink(targetPath); + addLog.info('Cleaned up temp file', { taskId, filePath: targetPath }); + } + } + } + } catch (error) { + addLog.warn('Failed to cleanup temp files', { + filePath, + taskId, + error: (error as Error).message + }); + } +} + +async function setEmbeddingTrainTaskAbortSignal( + taskId: string, + reason: TrainTaskAbortReason +): Promise { + try { + await setTrainTaskAbortSignal({ type: 'embedding', taskId, reason }); + } catch (error) { + addLog.warn('Failed to set embedding train task abort signal', { + taskId, + reason, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +/** + * Traverse the training task chain upward by tunedModelId. + * Returns tasks in order from the given tunedModelId up to the root base model. + * Includes cycle detection and depth limit to prevent infinite loops. + */ +export async function resolveEmbeddingTasksByTunedModelId( + tunedModelId: string, + teamId: string +): Promise { + const result: EmbeddingTrainTaskSchemaType[] = []; + const visited = new Set(); + let currentId = tunedModelId; + const MAX_DEPTH = 100; + + while (result.length < MAX_DEPTH) { + if (visited.has(currentId)) { + addLog.warn('Cycle detected in task chain traversal', { tunedModelId, currentId, teamId }); + break; + } + visited.add(currentId); + + const task = await MongoEmbeddingTrainTask.findOne({ + teamId, + 'checkpoint.data.registering.tunedModelId': currentId + }).lean(); + + if (!task) { + break; + } + + result.push(task); + currentId = task.baseModelId; + } + + return result; +} diff --git a/packages/service/core/train/embedding/task/helpers/channel.ts b/packages/service/core/train/embedding/task/helpers/channel.ts new file mode 100644 index 000000000000..e183877cf54c --- /dev/null +++ b/packages/service/core/train/embedding/task/helpers/channel.ts @@ -0,0 +1,399 @@ +import axios from 'axios'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../../common/env'; + +/** + * Create channel for finetuned embedding model (idempotent) + * + * Creates a dedicated AI proxy channel for the finetuned embedding model. + * This function is idempotent - if a channel for this model already exists, + * it will be updated with the new configuration. + * + * @param params - Channel creation parameters + * @param params.channelName - Channel name + * @param params.endpoint - Model endpoint configuration + * @param params.modelConfigId - Model configuration ID + * @throws {Error} When environment variables missing or channel operations fail + */ +export async function createTunedModelChannel(params: { + channelName: string; + endpoint: { + base_url: string; + model: string; + api_key: string; + }; + modelConfigId: string; +}): Promise { + const { channelName, endpoint, modelConfigId } = params; + + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + throw new Error('AIPROXY_API_ENDPOINT or AIPROXY_API_TOKEN environment variable is required'); + } + + try { + // Step 1: Check if channel already exists for this model + const channelsListUrl = `${aiproxyUrl}/api/channels/all`; + const queryResponse = await axios.get(channelsListUrl, { + params: { + page: 1, + perPage: 1000 + }, + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (!queryResponse.data?.success) { + addLog.warn('AI Proxy returned non-success response', { + response: queryResponse.data + }); + } else if (queryResponse.data.data && Array.isArray(queryResponse.data.data)) { + const channels = queryResponse.data.data; + const existingChannel = channels.find( + (ch: any) => ch.models && ch.models.includes(modelConfigId) + ); + + if (existingChannel) { + // Channel exists, update it to ensure configuration is current + const updateUrl = `${aiproxyUrl}/api/channel/${existingChannel.id}`; + const updateData = { + type: 50, + name: channelName, + base_url: endpoint.base_url, + models: [modelConfigId], + model_mapping: {}, + key: endpoint.api_key, + priority: 1 + }; + + const updateResponse = await axios.put(updateUrl, updateData, { + headers: { + Authorization: `Bearer ${aiproxyToken}`, + 'Content-Type': 'application/json' + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (updateResponse.data?.success === false) { + throw new Error( + `Channel update failed: ${updateResponse.data.message || 'Unknown error'}` + ); + } + + addLog.info('Updated existing channel for embedding model', { + channelId: existingChannel.id, + modelConfigId, + channelName + }); + return; + } + } + + // Step 2: Channel doesn't exist, create it + const channelApiUrl = `${aiproxyUrl}/api/channel/`; + const requestData = { + type: 50, + name: channelName, + base_url: endpoint.base_url, + models: [modelConfigId], + model_mapping: {}, + key: endpoint.api_key, + priority: 1 + }; + + const response = await axios.post(channelApiUrl, requestData, { + headers: { + Authorization: `Bearer ${aiproxyToken}`, + 'Content-Type': 'application/json' + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (!response.data) { + throw new Error('Empty response from aiproxy service'); + } + + if (response.data.success === false) { + const errorMsg = response.data.message || 'Unknown error'; + throw new Error(`Channel creation failed: ${errorMsg}`); + } + + addLog.info('Created new channel for embedding model', { + modelConfigId, + channelName + }); + + return; + } catch (error) { + let errorMessage = 'Unknown error occurred'; + + if (axios.isAxiosError(error)) { + if (error.response) { + const errorData = error.response.data; + if (errorData && typeof errorData === 'object') { + errorMessage = errorData.message || errorData.error || 'Server error'; + } else { + errorMessage = `Server error: ${error.response.status}`; + } + } else if (error.request) { + errorMessage = 'No response from aiproxy service'; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); + } + + addLog.error('Failed to create or update tuned embedding model channel', { + channelName, + error: errorMessage, + aiproxyUrl: aiproxyUrl ? aiproxyUrl.replace(/\/$/, '') + '/***' : 'not set', + hasToken: !!aiproxyToken + }); + + throw new Error(`Failed to create or update channel: ${errorMessage}`); + } +} + +/** + * Delete channel for finetuned embedding model + * + * Deletes the dedicated AI proxy channel for the finetuned embedding model by model name. + * + * @param modelConfigId - Model configuration ID (used to identify the channel) + * @throws {Error} When environment variables missing or channel deletion fails + */ +export async function deleteTunedModelChannel(modelConfigId: string): Promise { + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + addLog.warn('AIPROXY environment variables not configured, skipping channel deletion', { + modelConfigId + }); + return; + } + + try { + // Query channels to find the one with this model + const channelsListUrl = `${aiproxyUrl}/api/channels/all`; + const queryResponse = await axios.get(channelsListUrl, { + params: { + page: 1, + perPage: 1000 + }, + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (!queryResponse.data?.success) { + addLog.warn('AI Proxy returned non-success response (delete)', { + response: queryResponse.data + }); + return; + } + + if (!queryResponse.data.data || !Array.isArray(queryResponse.data.data)) { + addLog.warn('No channel data returned from aiproxy', { modelConfigId }); + return; + } + + // Find channel(s) that contain this model + const channels = queryResponse.data.data; + const matchingChannels = channels.filter( + (ch: any) => ch.models && ch.models.includes(modelConfigId) + ); + + if (matchingChannels.length === 0) { + addLog.info('No matching channel found for embedding model', { modelConfigId }); + return; + } + + // Delete each matching channel + for (const channel of matchingChannels) { + const channelId = channel.id; + const deleteUrl = `${aiproxyUrl}/api/channel/${channelId}`; + + const deleteResponse = await axios.delete(deleteUrl, { + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (deleteResponse.data?.success === false) { + addLog.warn('Channel deletion returned non-success status', { + channelId, + modelConfigId, + message: deleteResponse.data?.message + }); + } else { + addLog.info('Successfully deleted embedding model channel', { + channelId, + modelConfigId + }); + } + } + } catch (error) { + let errorMessage = 'Unknown error occurred'; + + if (axios.isAxiosError(error)) { + if (error.response) { + const errorData = error.response.data; + if (errorData && typeof errorData === 'object') { + errorMessage = errorData.message || errorData.error || 'Server error'; + } else { + errorMessage = `Server error: ${error.response.status}`; + } + } else if (error.request) { + errorMessage = 'No response from aiproxy service'; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); + } + + addLog.error('Failed to delete tuned embedding model channel', { + modelConfigId, + error: errorMessage + }); + + throw new Error(`Failed to delete channel: ${errorMessage}`); + } +} + +/** + * Wait for AI Proxy channel to become available by polling embedding API + * + * After creating a channel, it may not be immediately available. This function + * polls the embedding API until a successful response is received or timeout is reached. + * + * @param params - Parameters for testing channel availability + * @param params.model - Model name to test + * @param params.endpoint - Model endpoint configuration + * @throws {Error} When channel does not become available within timeout period + */ +export async function waitForChannelAvailable(params: { + model: string; + endpoint: { + base_url: string; + model: string; + api_key: string; + }; +}): Promise { + const { model, endpoint } = params; + + // Skip polling in mock mode — mock endpoint may not be a real AI Proxy channel + if (trainEnv.SFT_BRIDGE_MOCK_ENABLE) { + addLog.info('Embedding channel availability check skipped (mock mode enabled)', { model }); + return; + } + + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + throw new Error('AIPROXY_API_ENDPOINT or AIPROXY_API_TOKEN environment variable is required'); + } + + const embeddingsUrl = `${aiproxyUrl}/v1/embeddings`; + const authorization = `Bearer ${aiproxyToken}`; + + const pollInterval = trainEnv.TRAIN_CHANNEL_AVAILABILITY_POLL_INTERVAL; + const maxDuration = trainEnv.TRAIN_CHANNEL_AVAILABILITY_MAX_DURATION; + const startTime = Date.now(); + + addLog.info('Starting embedding channel availability polling', { + model, + pollInterval, + maxDuration: `${maxDuration / 1000 / 60} minutes` + }); + + let pollCount = 0; + let lastError: string | undefined; + + while (Date.now() - startTime < maxDuration) { + pollCount++; + + try { + const response = await axios.post( + embeddingsUrl, + { + model: endpoint.model, + input: 'test' + }, + { + headers: { + Authorization: authorization, + 'Content-Type': 'application/json' + }, + timeout: 30000 + } + ); + + // Validate response - embeddings API should return data array + const data = response.data; + if (!data || !Array.isArray(data.data)) { + throw new Error(`Invalid embeddings response: missing data array`); + } + + addLog.info('Embedding channel is now available', { + model, + pollCount, + elapsed: `${(Date.now() - startTime) / 1000}s` + }); + return; + } catch (error) { + // Extract error message from axios error or generic error + let errorMessage: string; + if (axios.isAxiosError(error)) { + if (error.response?.data) { + // AI Proxy error response + const errorData = error.response.data; + errorMessage = + typeof errorData === 'object' + ? errorData.message || errorData.error || JSON.stringify(errorData) + : String(errorData); + } else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { + errorMessage = `Connection failed: ${error.code}`; + } else { + errorMessage = error.message; + } + } else { + errorMessage = error instanceof Error ? error.message : String(error); + } + + lastError = errorMessage; + addLog.debug('Embedding channel not yet available, will retry', { + model, + pollCount, + error: lastError + }); + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + const elapsed = `${(Date.now() - startTime) / 1000 / 60} minutes`; + addLog.error('Embedding channel availability polling timeout', { + model, + pollCount, + elapsed, + lastError + }); + + throw new Error( + `Channel for model "${model}" did not become available within ${elapsed}. Last error: ${lastError}` + ); +} diff --git a/packages/service/core/train/embedding/task/helpers/evaluate-model.ts b/packages/service/core/train/embedding/task/helpers/evaluate-model.ts new file mode 100644 index 000000000000..9e29bb1a621d --- /dev/null +++ b/packages/service/core/train/embedding/task/helpers/evaluate-model.ts @@ -0,0 +1,212 @@ +import type { EmbeddingEvalResult } from '@fastgpt/global/core/train/embedding/type'; +import type { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { getEmbeddingModelById } from '../../../../ai/model'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { computeRankingMetrics } from '../../../common/metrics/rankingMetrics'; +import { dispatchDatasetSearch } from '../../../../../core/workflow/dispatch/dataset/search'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { RerankMethodEnum, DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { trainEnv } from '../../../common/env'; +import { pLimit } from '../../../common/utils'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { Types } from 'mongoose'; + +const K_VALUES = [5, 10, 15, 20]; + +/** + * Evaluate an embedding model on the given evaluation dataset + * + * Replaces the previous DiTing-based evaluation. + * For each query in the eval dataset: + * 1. Calls dispatchDatasetSearch (embedding-only mode) + * 2. Extracts the ordered document ID list + * 3. Computes MRR, NDCG, MAP, Precision@K using computeRankingMetrics + * + * @param taskId - Training task ID (for logging) + * @param evalDatasetId - Evaluation dataset collection ID + * @param modelId - Model ID to evaluate (for config lookup) + * @param stage - Checkpoint stage enum, used for error attribution + * @param teamId - Team ID for dataset search auth + * @param tmbId - Team member ID for dataset search auth + * @param datasetIds - Dataset IDs to search against (required, must be non-empty) + */ +export async function evaluateEmbeddingModelHelper( + taskId: string, + evalDatasetId: string, + modelId: string, + stage: EmbeddingTaskCheckpointStageEnum, + teamId: string, + tmbId: string, + datasetIds: string[] +): Promise<{ + evalResult: EmbeddingEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Evaluate embedding model', { taskId, modelId, stage }); + + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evalDatasetId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + stage, + EmbeddingTrainErrEnum.embeddingEvalDatasetEmptyBeforeEval, + EmbeddingTrainSuggestionEnum.embeddingEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const modelConfig = getEmbeddingModelById(modelId); + if (!modelConfig) { + const enhancedError = createEmbeddingEnhancedError( + stage, + EmbeddingTrainErrEnum.embeddingEvalModelNotFound, + EmbeddingTrainSuggestionEnum.embeddingEvalModelNotFound, + modelId + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Run embedding search for each query and collect the ordered document ID list. + // Inject modelConfig as vectorModel so dispatchDatasetSearch uses the specified + // model instead of the dataset's default, enabling base vs tuned model comparison. + const datasets = datasetIds.map((id) => ({ + datasetId: id, + avatar: '', + name: '', + vectorModel: modelConfig + })); + + // Run embedding search for each query with bounded concurrency to avoid overwhelming the vector DB + const limit = pLimit(trainEnv.TRAIN_EVAL_CONCURRENCY); + const cases = await Promise.all( + evalDataItems.map((item) => + limit(async () => { + const abortReason = await getTrainTaskAbortSignal({ type: 'embedding', taskId }); + if (abortReason === 'deleted') { + const enhancedError = createEmbeddingEnhancedError( + stage, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createEmbeddingEnhancedError( + stage, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const query = item.userInput; + const expectedIds = item.expectedContextIds || []; + + try { + const searchResponse = await dispatchDatasetSearch({ + mode: 'test', + timezone: 'Asia/Shanghai', + externalProvider: {}, + uid: tmbId, + variables: {}, + query: [], + stream: false, + maxRunTimes: trainEnv.TRAIN_MAX_SEARCH_RUN_TIMES, + chatId: '', + checkIsStopping: () => false, + workflowDispatchDeep: 0, + runtimeNodesMap: new Map(), + usagePush: () => {}, + runningAppInfo: { + id: new Types.ObjectId().toString(), + name: 'EmbeddingTrainEvalSearch', + teamId, + tmbId + }, + runningUserInfo: { + username: '', + teamName: '', + memberName: '', + contact: '', + teamId, + tmbId + }, + histories: [], + chatConfig: {}, + node: { + nodeId: 'dataset_search', + name: 'Dataset Search', + avatar: '', + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + showStatus: true, + inputs: [], + outputs: [] + }, + runtimeNodes: [], + runtimeEdges: [], + params: { + datasets, + similarity: trainEnv.TRAIN_SEARCH_SIMILARITY, + limit: trainEnv.TRAIN_SEARCH_LIMIT, + userChatInput: query, + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: undefined, + usingReRank: false, + rerankModelId: undefined, + rerankMethod: RerankMethodEnum.question, + collectionFilterMatch: '', + datasetSearchUsingExtensionQuery: false, + datasetSearchExtensionModelId: '', + datasetSearchExtensionBg: '' + } + }); + + const nodeResponse = (searchResponse as any)[DispatchNodeResponseKeyEnum.nodeResponse]; + const retrievalResults = + nodeResponse?.retrievalResults || (searchResponse as any).data?.quoteQA || []; + const rankedIds = retrievalResults.map((r: any) => r.id as string); + + return { rankedIds, expectedIds }; + } catch (err) { + addLog.warn('Embedding eval search failed for query, treating as no results', { + taskId, + query: query?.substring(0, 50), + error: err instanceof Error ? err.message : String(err) + }); + return { rankedIds: [], expectedIds }; + } + }) + ) + ); + + const metrics = computeRankingMetrics(cases, K_VALUES, 'embed'); + + const rankingResults = evalDataItems.map((item, idx) => ({ + itemId: (item as any)._id.toString(), + rankedIds: cases[idx].rankedIds + })); + + addLog.info('Embedding model evaluated', { + taskId, + modelId, + stage, + mrr10: metrics.detailed_results.embed_top10_mrr, + ndcg10: metrics.detailed_results.embed_top10_ndcg, + precision10: metrics.detailed_results.embed_top10_precision + }); + + return { + evalResult: metrics as EmbeddingEvalResult, + rankingResults + }; +} diff --git a/packages/service/core/train/embedding/task/mq.ts b/packages/service/core/train/embedding/task/mq.ts new file mode 100644 index 000000000000..186937a7e93f --- /dev/null +++ b/packages/service/core/train/embedding/task/mq.ts @@ -0,0 +1,35 @@ +import { getQueue, QueueNames } from '../../../../common/bullmq'; +import { trainEnv } from '../../common/env'; +import { + createJobCleaner, + type JobCleanupOptions, + type JobCleanupResult +} from '../../../../common/bullmq/utils'; + +export type EmbeddingTrainTaskJobData = { + taskId: string; +}; + +export const embeddingTrainTaskQueue = getQueue( + QueueNames.embeddingTrainTask, + { + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: trainEnv.TRAIN_JOB_BACKOFF_DELAY }, + removeOnComplete: { age: 7 * 24 * 60 * 60 }, + removeOnFail: false // Keep failed jobs for troubleshooting + } + } +); + +export const removeEmbeddingTrainTaskJob = ( + taskId: string, + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + return cleaner.cleanAllJobsByFilter( + embeddingTrainTaskQueue, + (job) => String(job.data?.taskId) === String(taskId), + QueueNames.embeddingTrainTask + ); +}; diff --git a/packages/service/core/train/embedding/task/processor.ts b/packages/service/core/train/embedding/task/processor.ts new file mode 100644 index 000000000000..bdcbd245b051 --- /dev/null +++ b/packages/service/core/train/embedding/task/processor.ts @@ -0,0 +1,319 @@ +import type { Processor } from 'bullmq'; +import type { EmbeddingTrainTaskJobData } from './mq'; +import { MongoEmbeddingTrainTask } from './schema'; +import { + getEmbeddingTrainTask, + updateEmbeddingTaskStatus, + updateEmbeddingCheckpointStage, + updateEmbeddingCheckpointData +} from './controller'; +import { + EmbeddingTrainTaskStatusEnum, + EmbeddingTaskCheckpointStageEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { addLog } from '../../../../common/system/log'; + +import { runGenerateTrainsetStage } from './stages/generate-trainset'; +import { runGenerateEvalDatasetStage } from './stages/generate-evalset'; +import { runEvalBaseModelStage } from './stages/eval-basemodel'; +import { runFinetuneStage } from './stages/finetune'; +import { runRegisterStage } from './stages/register'; +import { runEvalTunedModelStage } from './stages/eval-tunedmodel'; +import { runLLMJudgeStage } from './stages/llm-judge'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { createEmbeddingEnhancedError } from '../utils'; +import { TrainTaskUnrecoverableError } from '../../common/errors'; +import { getTrainTaskAbortSignal } from '../../common/task-abort-signal'; + +/** + * Embedding training task processor + * + * Executes complete embedding model training pipeline: + * 1. generate_trainset - Generate / wait for training set (includes JSONL export) + * 2. generate_evaldataset - Generate evaluation dataset (auto: DiTing; exact: skip) + * 3. eval_basemodel - Evaluate base model to establish baseline + * 4. finetuning - Model fine-tuning via SFT Bridge (taskType='embedding') + * 5. registering - Model registration in FastGPT + * 6. eval_tunedmodel - Evaluate fine-tuned model + */ +export const embeddingTrainTaskProcessor: Processor = async (job) => { + const { taskId } = job.data; + + const task = await getEmbeddingTrainTask(taskId); + if (!task) { + const enhancedError = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingProcessorTaskNotFound, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const currentStage = task.checkpoint.stage; + + addLog.info('Embedding task execution started', { + taskId, + attemptsMade: job.attemptsMade, + checkpointStage: task.checkpoint.stage, + currentStage, + totalAttempts: job.opts.attempts + }); + + try { + if (task.status === EmbeddingTrainTaskStatusEnum.pending) { + await updateEmbeddingTaskStatus(taskId, EmbeddingTrainTaskStatusEnum.running); + } + + // Stage 1: generate_trainset + if ( + await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.generate_trainset) + ) { + const result = await runGenerateTrainsetStage(task); + await updateEmbeddingCheckpointData(taskId, 'generate_trainset', { + trainDatasetId: result.trainDatasetId, + trainDatasetFilePath: result.trainDatasetFilePath, + autoGenerated: result.autoGenerated + }); + await updateEmbeddingCheckpointStage( + taskId, + EmbeddingTaskCheckpointStageEnum.generate_trainset + ); + } + + // Stage 2: generate_evaldataset + if ( + await shouldRunStage( + taskId, + currentStage, + EmbeddingTaskCheckpointStageEnum.generate_evaldataset + ) + ) { + const taskAfterStage1 = await getEmbeddingTrainTask(taskId); + if (!taskAfterStage1) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterPrepare, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterPrepare + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runGenerateEvalDatasetStage(taskAfterStage1); + await updateEmbeddingCheckpointData(taskId, 'generate_evaldataset', { + evalDatasetId: result.evalDatasetId, + evalDatasetFilePath: result.evalDatasetFilePath, + autoGenerated: result.autoGenerated + }); + await updateEmbeddingCheckpointStage( + taskId, + EmbeddingTaskCheckpointStageEnum.generate_evaldataset + ); + } + + // Stage 3: eval_basemodel + if ( + await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.eval_basemodel) + ) { + const taskAfterStage2 = await getEmbeddingTrainTask(taskId); + if (!taskAfterStage2) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.eval_basemodel, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterEvalGen, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterEvalGen + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runEvalBaseModelStage(taskAfterStage2); + await updateEmbeddingCheckpointData(taskId, 'eval_basemodel', { + baseModelEvalResult: result.baseModelEvalResult, + rankingResults: result.rankingResults + }); + await updateEmbeddingCheckpointStage(taskId, EmbeddingTaskCheckpointStageEnum.eval_basemodel); + } + + // Stage 4: finetuning + if (await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.finetuning)) { + const taskAfterStage3 = await getEmbeddingTrainTask(taskId); + if (!taskAfterStage3) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterEval, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const finetuneResult = await runFinetuneStage(taskAfterStage3); + await updateEmbeddingCheckpointData(taskId, 'finetuning', { + sftTaskId: finetuneResult.sftTaskId, + tunedModelEndpoint: finetuneResult.tunedModelEndpoint + }); + await updateEmbeddingCheckpointStage(taskId, EmbeddingTaskCheckpointStageEnum.finetuning); + } + + // Stage 5: registering + if (await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.registering)) { + const taskAfterFinetune = await getEmbeddingTrainTask(taskId); + if (!taskAfterFinetune) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.registering, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterFinetune, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterFinetune + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const registerResult = await runRegisterStage(taskAfterFinetune); + await updateEmbeddingCheckpointData(taskId, 'registering', { + tunedModelId: registerResult.tunedModelId + }); + await updateEmbeddingCheckpointStage(taskId, EmbeddingTaskCheckpointStageEnum.registering); + } + + // Stage 6: eval_tunedmodel + if ( + await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.eval_tunedmodel) + ) { + const taskAfterRegister = await getEmbeddingTrainTask(taskId); + if (!taskAfterRegister) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterRegister, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterRegister + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runEvalTunedModelStage(taskAfterRegister); + await updateEmbeddingCheckpointData(taskId, 'eval_tunedmodel', { + tunedModelEvalResult: result.tunedModelEvalResult, + rankingResults: result.rankingResults + }); + await updateEmbeddingCheckpointStage( + taskId, + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel + ); + } + + // Stage 7: llm_judge + if (await shouldRunStage(taskId, currentStage, EmbeddingTaskCheckpointStageEnum.llm_judge)) { + const taskAfterStage6 = await getEmbeddingTrainTask(taskId); + if (!taskAfterStage6) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterEval, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const judgeResult = await runLLMJudgeStage(taskAfterStage6); + await updateEmbeddingCheckpointData(taskId, 'llm_judge', { + judgedExpectedIds: judgeResult.judgedExpectedIds, + baseModelRejudgedResult: judgeResult.baseModelRejudgedResult, + tunedModelRejudgedResult: judgeResult.tunedModelRejudgedResult + }); + await updateEmbeddingCheckpointStage(taskId, EmbeddingTaskCheckpointStageEnum.llm_judge); + } + + // Write final result + const finalTask = await getEmbeddingTrainTask(taskId); + if (!finalTask) { + const enhancedError = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingProcessorTaskLostAfterEval, + EmbeddingTrainSuggestionEnum.embeddingProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + const finalCheckpoint = finalTask.checkpoint.data || {}; + await MongoEmbeddingTrainTask.updateOne( + { _id: taskId }, + { + status: EmbeddingTrainTaskStatusEnum.completed, + updateTime: new Date(), + finishTime: new Date(), + result: { + trainDatasetId: finalCheckpoint.generate_trainset?.trainDatasetId ?? undefined, + trainDatasetFilePath: + finalCheckpoint.generate_trainset?.trainDatasetFilePath ?? undefined, + tunedModelId: finalCheckpoint.registering?.tunedModelId ?? undefined, + evalDatasetId: finalCheckpoint.generate_evaldataset?.evalDatasetId ?? undefined, + evalDatasetFilePath: + finalCheckpoint.generate_evaldataset?.evalDatasetFilePath ?? undefined, + baseModelEvalResult: finalCheckpoint.eval_basemodel?.baseModelEvalResult, + tunedModelEvalResult: finalCheckpoint.eval_tunedmodel?.tunedModelEvalResult, + baseModelRejudgedResult: finalCheckpoint.llm_judge?.baseModelRejudgedResult, + tunedModelRejudgedResult: finalCheckpoint.llm_judge?.tunedModelRejudgedResult + } + } + ); + + addLog.info('Embedding train task completed', { taskId }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isUnrecoverable = error instanceof TrainTaskUnrecoverableError; + + addLog.error('Embedding train task failed', { + taskId, + error: errorMessage, + isUnrecoverable, + errorType: error instanceof Error ? error.constructor.name : 'Unknown' + }); + + throw error; + } +}; + +/** + * Determine whether to run a stage (skip completed stages, run remaining stages) + */ +async function shouldRunStage( + taskId: string, + currentStage: `${EmbeddingTaskCheckpointStageEnum}` | null, + targetStage: `${EmbeddingTaskCheckpointStageEnum}` +): Promise { + if (currentStage === null) return true; + + const stageOrder: EmbeddingTaskCheckpointStageEnum[] = [ + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTaskCheckpointStageEnum.eval_basemodel, + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTaskCheckpointStageEnum.registering, + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel, + EmbeddingTaskCheckpointStageEnum.llm_judge + ]; + + const currentStageEnum = currentStage as EmbeddingTaskCheckpointStageEnum; + const targetStageEnum = targetStage as EmbeddingTaskCheckpointStageEnum; + + if (stageOrder.indexOf(targetStageEnum) <= stageOrder.indexOf(currentStageEnum)) { + return false; + } + + const reason = await getTrainTaskAbortSignal({ type: 'embedding', taskId }); + if (reason === 'deleted') { + throw new TrainTaskUnrecoverableError( + createEmbeddingEnhancedError( + targetStage as EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ) + ); + } + if (reason === 'cancelled') { + throw new TrainTaskUnrecoverableError( + createEmbeddingEnhancedError( + targetStage as EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ) + ); + } + return true; +} diff --git a/packages/service/core/train/embedding/task/schema.ts b/packages/service/core/train/embedding/task/schema.ts new file mode 100644 index 000000000000..f65c4c59b6b8 --- /dev/null +++ b/packages/service/core/train/embedding/task/schema.ts @@ -0,0 +1,199 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { + EmbeddingTrainTaskStatusEnum, + EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainMethodEnum +} from '@fastgpt/global/core/train/embedding/constants'; + +/** Embedding training task schema */ +const EmbeddingTrainTaskSchema = new connectionMongo.Schema({ + trainsetId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'embedding_trainset', + required: false // optional: exact mode passes at create; auto mode written by generate_trainset stage + }, + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + tmbId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team_member', + required: true + }, + name: { + type: String, + required: true + }, + baseModelId: { + type: String, + required: true + }, + baseModelEndpoint: { + type: { + base_url: { + type: String, + required: false + }, + model: { + type: String, + required: true + }, + api_key: { + type: String, + required: false + } + }, + required: true + }, + evalDatasetId: { + type: String, + required: false // exact mode: passed at create; auto mode: written by generate_evaldataset stage + }, + datasetIds: { + type: [String], + required: false // auto mode only: source for generate_trainset/generate_evaldataset + }, + newModelName: { + type: String, + required: false // optional name for the trained model + }, + generateConfig: { + type: connectionMongo.Schema.Types.Mixed, + required: false // trainset synthesis config: indexType, negativeStrategy, sampleSize, etc. + }, + trainMethod: { + type: String, + enum: Object.values(EmbeddingTrainMethodEnum), + default: EmbeddingTrainMethodEnum.lora, + required: false // defaults to lora for backward compatibility + }, + status: { + type: String, + enum: Object.values(EmbeddingTrainTaskStatusEnum), + default: EmbeddingTrainTaskStatusEnum.pending + }, + checkpoint: { + type: { + stage: { + type: String, + enum: [...Object.values(EmbeddingTaskCheckpointStageEnum), null], + default: null + }, + data: { + generate_trainset: { + trainDatasetId: String, + trainDatasetFilePath: String, + autoGenerated: Boolean + }, + generate_evaldataset: { + evalDatasetId: String, + evalDatasetFilePath: String, + autoGenerated: Boolean + }, + eval_basemodel: { + baseModelEvalResult: connectionMongo.Schema.Types.Mixed, + rankingResults: [ + { + itemId: String, + rankedIds: [String] + } + ] + }, + finetuning: { + sftTaskId: String, + tunedModelEndpoint: { + base_url: String, + model: String, + api_key: String + } + }, + registering: { + tunedModelId: String + }, + eval_tunedmodel: { + tunedModelEvalResult: connectionMongo.Schema.Types.Mixed, + rankingResults: [ + { + itemId: String, + rankedIds: [String] + } + ] + }, + llm_judge: { + judgedExpectedIds: [ + { + itemId: String, + expectedIds: [String] + } + ], + baseModelRejudgedResult: connectionMongo.Schema.Types.Mixed, + tunedModelRejudgedResult: connectionMongo.Schema.Types.Mixed + } + }, + stageEndTime: { + generate_trainset: Date, + generate_evaldataset: Date, + eval_basemodel: Date, + finetuning: Date, + registering: Date, + eval_tunedmodel: Date, + llm_judge: Date + } + }, + default: { + stage: null, + data: {}, + stageEndTime: {} + } + }, + result: { + type: { + trainDatasetId: String, + trainDatasetFilePath: String, + tunedModelId: String, + evalDatasetId: String, + evalDatasetFilePath: String, + baseModelEvalResult: connectionMongo.Schema.Types.Mixed, + tunedModelEvalResult: connectionMongo.Schema.Types.Mixed, + baseModelRejudgedResult: connectionMongo.Schema.Types.Mixed, + tunedModelRejudgedResult: connectionMongo.Schema.Types.Mixed + } + }, + errorMsg: { + type: connectionMongo.Schema.Types.Mixed + }, + jobId: { + type: String + }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + }, + finishTime: { + type: Date + } +}); + +// Indexes +EmbeddingTrainTaskSchema.index({ baseModelId: 1, status: 1, createTime: -1 }); +EmbeddingTrainTaskSchema.index({ trainsetId: 1, createTime: -1 }); +EmbeddingTrainTaskSchema.index({ teamId: 1, status: 1 }); +EmbeddingTrainTaskSchema.index({ status: 1, updateTime: 1 }); +EmbeddingTrainTaskSchema.index({ jobId: 1 }); +EmbeddingTrainTaskSchema.index({ 'checkpoint.stage': 1, status: 1 }); +EmbeddingTrainTaskSchema.index({ teamId: 1, status: 1, createTime: -1 }); +EmbeddingTrainTaskSchema.index({ 'checkpoint.data.registering.tunedModelId': 1 }); +EmbeddingTrainTaskSchema.index({ teamId: 1, 'checkpoint.data.registering.tunedModelId': 1 }); +EmbeddingTrainTaskSchema.index({ teamId: 1, baseModelId: 1 }); + +export const MongoEmbeddingTrainTask = getMongoModel( + 'embedding_train_task', + EmbeddingTrainTaskSchema +); diff --git a/packages/service/core/train/embedding/task/stages/eval-basemodel.ts b/packages/service/core/train/embedding/task/stages/eval-basemodel.ts new file mode 100644 index 000000000000..4cc11f711e3c --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/eval-basemodel.ts @@ -0,0 +1,54 @@ +import type { + EmbeddingTrainTaskSchemaType, + EmbeddingEvalResult +} from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { evaluateEmbeddingModelHelper } from '../helpers/evaluate-model'; + +/** + * Stage 3: Evaluate Base Model + * + * Evaluates the base model on the eval dataset generated in stage 2, + * establishing a performance baseline for comparison after fine-tuning. + * + * Key difference from rerank: uses evaluateEmbeddingModelHelper which + * only uses expected_dataid, without retrieval_reference_list. + * + * @param task - Embedding training task data + * @returns Base model evaluation result + */ +export async function runEvalBaseModelStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + baseModelEvalResult: EmbeddingEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Run eval base model stage (embedding)', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.eval_basemodel, + EmbeddingTrainErrEnum.embeddingEvalDatasetEmptyBeforeEval, + EmbeddingTrainSuggestionEnum.embeddingEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const { evalResult: baseModelEvalResult, rankingResults } = await evaluateEmbeddingModelHelper( + String(task._id), + evalDatasetId, + task.baseModelId, + EmbeddingTaskCheckpointStageEnum.eval_basemodel, + task.teamId, + task.tmbId, + task.datasetIds + ); + + return { baseModelEvalResult, rankingResults }; +} diff --git a/packages/service/core/train/embedding/task/stages/eval-tunedmodel.ts b/packages/service/core/train/embedding/task/stages/eval-tunedmodel.ts new file mode 100644 index 000000000000..b5c5a03841ca --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/eval-tunedmodel.ts @@ -0,0 +1,64 @@ +import type { + EmbeddingTrainTaskSchemaType, + EmbeddingEvalResult +} from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { evaluateEmbeddingModelHelper } from '../helpers/evaluate-model'; + +/** + * Stage 6: Evaluate Tuned Model + * + * Evaluates the fine-tuned embedding model on the same eval dataset used for the base model, + * to compare performance and decide whether to keep the new model. + * + * Key difference from rerank: uses evaluateEmbeddingModelHelper which + * only uses expected_dataid, without retrieval_reference_list. + * + * @param task - Embedding training task data + * @returns Tuned model evaluation result + */ +export async function runEvalTunedModelStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + tunedModelEvalResult: EmbeddingEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Run eval tuned model stage (embedding)', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel, + EmbeddingTrainErrEnum.embeddingEvalDatasetEmptyBeforeEval, + EmbeddingTrainSuggestionEnum.embeddingEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const tunedModelId = task.checkpoint.data?.registering?.tunedModelId; + if (!tunedModelId) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel, + EmbeddingTrainErrEnum.embeddingEvalModelNotFound, + EmbeddingTrainSuggestionEnum.embeddingEvalModelNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const { evalResult: tunedModelEvalResult, rankingResults } = await evaluateEmbeddingModelHelper( + String(task._id), + evalDatasetId, + tunedModelId, + EmbeddingTaskCheckpointStageEnum.eval_tunedmodel, + task.teamId, + task.tmbId, + task.datasetIds + ); + + return { tunedModelEvalResult, rankingResults }; +} diff --git a/packages/service/core/train/embedding/task/stages/finetune.ts b/packages/service/core/train/embedding/task/stages/finetune.ts new file mode 100644 index 000000000000..cfb80cfa5998 --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/finetune.ts @@ -0,0 +1,355 @@ +import * as fs from 'fs/promises'; +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { + EmbeddingTrainTaskStatusEnum, + EmbeddingTaskCheckpointStageEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { createSFTTask, querySFTTaskStatus, SFTTaskStatus } from '../../external'; +import { addLog } from '../../../../../common/system/log'; +import { getEmbeddingTrainTask, updateEmbeddingCheckpointData } from '../controller'; +import { trainEnv } from '../../../common/env'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; + +const TASK_ABORT_SIGNAL_CHECK_INTERVAL = 10 * 1000; + +const isSFTBridgeQueueFullError = (errorMsg: string) => + errorMsg.toLowerCase().includes('too many concurrent tasks'); + +/** + * Stage 4: Model Finetuning + * + * Calls SFT Bridge platform with taskType='embedding', uploads JSONL dataset. + * SFT Bridge automatically completes finetuning and deploys to inference service. + * + * Key difference from rerank: taskType is 'embedding' instead of 'rerank'. + * + * @param task - Embedding training task data + * @returns SFT task ID and tuned model endpoint + * @throws {TrainTaskUnrecoverableError} When dataset file or base model endpoint not found + */ +export async function runFinetuneStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + sftTaskId: string; + tunedModelEndpoint: { + base_url: string; + model: string; + api_key: string; + }; +}> { + addLog.info('Run finetune stage (embedding)', { taskId: String(task._id) }); + + const checkpointData = task.checkpoint.data || {}; + let sftTaskId = checkpointData.finetuning?.sftTaskId; + + if (sftTaskId) { + addLog.info('Reuse existing SFT task from checkpoint for embedding', { + taskId: String(task._id), + sftTaskId + }); + } else { + if (!task.baseModelEndpoint.model) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneModelConfigInvalid, + EmbeddingTrainSuggestionEnum.embeddingFinetuneModelConfigInvalid + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + if (!checkpointData.generate_trainset?.trainDatasetFilePath) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDataPathNotFound, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDataPathNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let datasetFile: Buffer; + try { + datasetFile = await fs.readFile(checkpointData.generate_trainset.trainDatasetFilePath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDataFileNotFound, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDataFileNotFound, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + await ensureTaskActiveBeforeSFTCreation({ taskId: String(task._id) }); + await ensureTaskNotAborted({ + taskId: String(task._id), + sftTaskId: undefined + }); + + if (!checkpointData.generate_evaldataset?.evalDatasetFilePath) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDataPathNotFound, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDataPathNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let evalDatasetFile: Buffer; + try { + evalDatasetFile = await fs.readFile(checkpointData.generate_evaldataset.evalDatasetFilePath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDataFileNotFound, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDataFileNotFound, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let createResponse: Awaited>; + try { + createResponse = await createSFTTask({ + datasetFile, + taskType: 'embed', // Key difference from rerank (SFT Bridge uses 'embed' for embedding) + trainMethod: task.trainMethod || 'lora', + parameters: { + learning_rate: trainEnv.SFT_BRIDGE_LEARNING_RATE, + epochs: 3, + batch_size: 32 + }, + evalDatasetFile + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const errorType = isSFTBridgeQueueFullError(errorMsg) + ? EmbeddingTrainErrEnum.embeddingFinetuneQueueFull + : EmbeddingTrainErrEnum.embeddingFinetuneSftBridgeCreateFailed; + const suggestionType = isSFTBridgeQueueFullError(errorMsg) + ? EmbeddingTrainSuggestionEnum.embeddingFinetuneQueueFull + : EmbeddingTrainSuggestionEnum.embeddingFinetuneSftBridgeCreateFailed; + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + errorType, + suggestionType, + errorMsg + ); + throw new TrainTaskRetriableError(enhancedError); + } + + sftTaskId = createResponse.task_id; + await updateEmbeddingCheckpointData( + String(task._id), + EmbeddingTaskCheckpointStageEnum.finetuning, + { sftTaskId }, + true + ); + + addLog.info('Created SFT task for embedding', { + taskId: String(task._id), + sftTaskId + }); + } + + let completed = false; + let endpoint: + | { + base_url: string; + model: string; + api_key: string; + } + | undefined = undefined; + + const maxPolls = trainEnv.SFT_BRIDGE_MAX_POLLS; + const pollInterval = trainEnv.SFT_BRIDGE_POLL_INTERVAL; + + addLog.info('SFT task polling configuration (embedding)', { + taskId: String(task._id), + maxPolls, + pollInterval, + maxDuration: `${(maxPolls * pollInterval) / 1000 / 60} minutes` + }); + + let pollCount = 0; + + while (!completed && pollCount < maxPolls) { + await waitForNextSFTPoll({ + taskId: String(task._id), + sftTaskId, + pollInterval + }); + + const statusResponse = await querySFTTaskStatus({ + taskId: sftTaskId + }); + + addLog.info('SFT task status (embedding)', { + taskId: String(task._id), + sftTaskId, + status: statusResponse.status, + progress: statusResponse.progress + }); + + if (statusResponse.status === SFTTaskStatus.completed) { + completed = true; + endpoint = statusResponse.endpoint; + + if (!endpoint) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDeploymentFailed, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDeploymentFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + } else if (statusResponse.status === SFTTaskStatus.failed) { + const errorMsg = statusResponse.error; + await updateEmbeddingCheckpointData( + String(task._id), + EmbeddingTaskCheckpointStageEnum.finetuning, + { sftTaskId: null }, + true + ); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneTrainingFailed, + EmbeddingTrainSuggestionEnum.embeddingFinetuneTrainingFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + pollCount++; + } + + if (!completed) { + const timeoutDuration = `${(maxPolls * pollInterval) / 1000 / 60} minutes`; + addLog.error('SFT task polling timeout (embedding)', { + taskId: String(task._id), + sftTaskId, + maxPolls, + pollInterval, + timeoutDuration + }); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneTimeout, + EmbeddingTrainSuggestionEnum.embeddingFinetuneTimeout + ); + throw new TrainTaskRetriableError(enhancedError); + } + + if (!endpoint) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneDeploymentNoEndpoint, + EmbeddingTrainSuggestionEnum.embeddingFinetuneDeploymentNoEndpoint + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('Embedding finetune stage completed (tuned model auto-deployed to serving)', { + taskId: String(task._id), + baseModelId: task.baseModelId, + baseModelEndpoint: task.baseModelEndpoint, + tunedModelEndpoint: endpoint + }); + + return { + sftTaskId, + tunedModelEndpoint: endpoint + }; +} + +async function ensureTaskActiveBeforeSFTCreation({ taskId }: { taskId: string }) { + const currentTask = await getEmbeddingTrainTask(taskId); + if (!currentTask) { + addLog.warn('SFT task creation skipped because task was deleted', { taskId }); + throwTaskDeletedError(); + } + + if (currentTask.status === EmbeddingTrainTaskStatusEnum.cancelled) { + addLog.warn('SFT task creation skipped because task was cancelled', { taskId }); + throwTaskCancelledError(); + } +} + +async function ensureTaskNotAborted({ taskId, sftTaskId }: { taskId: string; sftTaskId?: string }) { + let abortReason: Awaited>; + try { + abortReason = await getTrainTaskAbortSignal({ type: 'embedding', taskId }); + } catch (error) { + addLog.warn('Failed to check embedding train task abort signal', { + taskId, + sftTaskId, + error: error instanceof Error ? error.message : String(error) + }); + return; + } + + if (abortReason === 'deleted') { + addLog.warn('SFT task polling stopped because task was deleted', { + taskId, + sftTaskId + }); + throwTaskDeletedError(); + } + + if (abortReason === 'cancelled') { + addLog.warn('SFT task polling cancelled by user', { + taskId, + sftTaskId + }); + throwTaskCancelledError(); + } +} + +function throwTaskDeletedError(): never { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); +} + +function throwTaskCancelledError(): never { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.finetuning, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); +} + +async function waitForNextSFTPoll({ + taskId, + sftTaskId, + pollInterval +}: { + taskId: string; + sftTaskId: string; + pollInterval: number; +}) { + await ensureTaskNotAborted({ taskId, sftTaskId }); + + const checkInterval = Math.min(TASK_ABORT_SIGNAL_CHECK_INTERVAL, pollInterval); + if (checkInterval <= 0) { + return; + } + + let waited = 0; + while (waited < pollInterval) { + const waitMs = Math.min(checkInterval, pollInterval - waited); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + waited += waitMs; + + await ensureTaskNotAborted({ taskId, sftTaskId }); + } +} diff --git a/packages/service/core/train/embedding/task/stages/generate-evalset.ts b/packages/service/core/train/embedding/task/stages/generate-evalset.ts new file mode 100644 index 000000000000..27561bfba6c9 --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/generate-evalset.ts @@ -0,0 +1,549 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { MongoDatasetData } from '../../../../../core/dataset/data/schema'; +import { getEmbeddingTrainDataDir } from '../../constants'; +import { dispatchDatasetSearch } from '../../../../../core/workflow/dispatch/dataset/search'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { RerankMethodEnum, DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { Types } from 'mongoose'; +import { MongoEvalDatasetCollection } from '../../../../../core/evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import { + sampleDataFromDataset, + pLimit, + fetchSampledContent, + propagateAbortFromResults +} from '../../utils'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { synthesizeEmbeddingEvalData } from '../../external'; +import { getDefaultLLMModel } from '../../../../ai/model'; +import { getModelEndpointConfig } from '../../../../ai/config'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../../common/env'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { MongoEmbeddingTrainTask } from '../schema'; + +/** + * Generate evaluation dataset using DiTing (auto mode) + * + * Key difference from rerank: does NOT perform dataset search. + * Embedding evaluation only needs expectedContextIds (source data ID), + * not retrieval_reference_list. + * + * Responsibilities: + * 1. Sample data from task.datasetIds + * 2. Call DiTing API to generate evaluation QA pairs for each sample + * (mock can be enabled via EMBEDDING_DITING_MOCK=true in the external layer) + * 3. Create evaluation dataset collection + * 4. Batch insert evaluation data (RetrievalContextsFull = []) + * 5. Return evaluation dataset ID + * + * @param task - Embedding training task instance + * @returns Evaluation dataset collection ID + */ +async function generateEvalDatasetFromDatasets( + task: EmbeddingTrainTaskSchemaType +): Promise { + addLog.info('Run generate eval dataset (auto mode, embedding)', { taskId: String(task._id) }); + + const datasetIds = task.datasetIds; + + addLog.info('Using dataset IDs from task', { + taskId: String(task._id), + datasetIds + }); + + const sampledData = await sampleDataFromDataset(datasetIds, { + datasetType: 'eval' + }); + + if (sampledData.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalNoDataAvailable, + EmbeddingTrainSuggestionEnum.embeddingEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Fetch q/a content for DiTing API calls (sampleDataFromDataset now returns ID-only items) + const sampledDataWithContent = await fetchSampledContent(sampledData); + + if (sampledDataWithContent.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalNoDataAvailable, + EmbeddingTrainSuggestionEnum.embeddingEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Calculate numCases for 1-to-many strategy when sampled data is below minimum + const numCasesPerSample = + sampledDataWithContent.length < trainEnv.TRAIN_MIN_EVAL_QA_COUNT + ? Math.ceil(trainEnv.TRAIN_MIN_EVAL_QA_COUNT / sampledDataWithContent.length) + : 1; + + addLog.info('Sampled data from datasets', { + taskId: String(task._id), + sampleCount: sampledDataWithContent.length, + numCasesPerSample, + required: trainEnv.TRAIN_MIN_EVAL_QA_COUNT + }); + + // Resolve full endpoint config for DiTing (model + channel credentials). + const llm = getDefaultLLMModel(); + const endpointConfig = getModelEndpointConfig(llm); + const aiModelId = llm?.id || ''; + + // Controlled concurrency for DiTing API calls + const ditingConcurrency = Math.min( + Math.max(trainEnv.DITING_API_CONCURRENCY, 1), + trainEnv.DITING_MAX_CONCURRENCY + ); + addLog.info('DiTing API concurrency configuration', { + taskId: String(task._id), + concurrency: ditingConcurrency, + totalSamples: sampledDataWithContent.length + }); + + const ditingLimit = pLimit(ditingConcurrency); + const ditingResults = await Promise.allSettled( + sampledDataWithContent.map((sample) => + ditingLimit(async () => { + const abortReason = await getTrainTaskAbortSignal({ + type: 'embedding', + taskId: String(task._id) + }); + if (abortReason === 'deleted') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const context = [sample.q, sample.a].filter(Boolean); + + const response = await synthesizeEmbeddingEvalData({ + inputData: { + context, + numCases: numCasesPerSample + }, + llm_config: { + name: endpointConfig.name, + base_url: endpointConfig.baseUrl, + api_key: endpointConfig.apiKey, + timeout: trainEnv.DITING_TIMEOUT / 1000 + } + }); + + if (!response.success || !response.data) { + addLog.warn('Failed to generate eval QA pairs for sample', { + taskId: String(task._id), + sampleDataId: sample.dataId, + error: response.error + }); + return null; + } + + return { + teamId: task.teamId, + tmbId: task.tmbId, + qaPairs: response.data.qaPairs, + sourceDataId: sample.dataId, + sourceDatasetId: sample.datasetId, + synthesizedAt: new Date(), + // Embedding: expectedContextIds = [sourceDataId], no retrieval search needed + expectedContextIds: [sample.dataId] + }; + }) + ) + ); + + propagateAbortFromResults(ditingResults); + + // Flatten 1-to-many results: each DiTing call returns multiple qaPairs + const evalDataItems = ditingResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .flatMap((result) => { + const value = result.value; + if (!value || !Array.isArray(value.qaPairs)) return []; + return value.qaPairs.map((qaPair: any) => ({ + teamId: value.teamId, + tmbId: value.tmbId, + question: qaPair.question, + answer: qaPair.answer, + sourceDataId: value.sourceDataId, + sourceDatasetId: value.sourceDatasetId, + synthesizedAt: value.synthesizedAt, + expectedContextIds: value.expectedContextIds + })); + }) + .filter((value) => value !== null); + + if (evalDataItems.length < trainEnv.TRAIN_MIN_EVAL_QA_COUNT) { + addLog.warn('Generated eval QA pairs below minimum required count', { + taskId: String(task._id), + generatedCount: evalDataItems.length, + required: trainEnv.TRAIN_MIN_EVAL_QA_COUNT + }); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalDitingGenerationFailed, + EmbeddingTrainSuggestionEnum.embeddingEvalDitingGenerationFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('Generated eval QA pairs using DiTing (embedding, no retrieval search)', { + taskId: String(task._id), + generatedCount: evalDataItems.length + }); + + const evalCollectionName = `Eval Dataset - Task ${task._id}`; + + let evalCollection; + try { + [evalCollection] = await MongoEvalDatasetCollection.create([ + { + teamId: task.teamId, + tmbId: task.tmbId, + name: evalCollectionName, + description: `Embedding Model Evaluation Dataset - Training Task ${task.name}`, + metadata: { + taskId: String(task._id), + taskName: task.name, + generatedAt: new Date(), + sampleSize: evalDataItems.length + } + } + ]); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalDatabaseSaveFailed, + EmbeddingTrainSuggestionEnum.embeddingEvalDatabaseSaveFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const evalDataDocs = evalDataItems.map((item: any) => ({ + teamId: item.teamId, + tmbId: item.tmbId, + evalDatasetCollectionId: evalCollection._id, + [EvalDatasetDataKeyEnum.UserInput]: item.question, + [EvalDatasetDataKeyEnum.ExpectedOutput]: item.answer, + [EvalDatasetDataKeyEnum.Context]: [], + [EvalDatasetDataKeyEnum.RetrievalContext]: [], + [EvalDatasetDataKeyEnum.RetrievalContextsFull]: [], // embedding 不需要 retrieval 列表 + [EvalDatasetDataKeyEnum.ExpectedContextIds]: item.expectedContextIds, + createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration, + synthesisMetadata: { + sourceDataId: item.sourceDataId, + sourceDatasetId: item.sourceDatasetId, + intelligentGenerationModelId: aiModelId, + synthesizedAt: item.synthesizedAt, + generatedAt: new Date() + } + })); + + try { + await MongoEvalDatasetData.insertMany(evalDataDocs); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalDatabaseSaveFailed, + EmbeddingTrainSuggestionEnum.embeddingEvalDatabaseSaveFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Generated embedding eval dataset', { + taskId: String(task._id), + datasetId: String(evalCollection._id), + dataCount: evalDataDocs.length + }); + + return String(evalCollection._id); +} + +/** + * Generate eval dataset JSONL file from saved MongoDB eval data. + * + * For embedding, RetrievalContextsFull is typically empty, so we perform + * a fresh embedding search for each eval item to get candidate IDs. + * pos = expectedContextIds text, neg = search candidates minus pos. + */ +async function generateEvalJsonlFromDB( + evalDatasetCollectionId: string, + datasetIds: string[], + taskId: string, + tmbId: string, + teamId: string +): Promise<{ evalDatasetId: string; evalDatasetFilePath: string }> { + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingEvalNoDataAvailable, + EmbeddingTrainSuggestionEnum.embeddingEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Dataset search for each eval item to get candidate IDs + const searchLimit = pLimit(trainEnv.TRAIN_EVAL_CONCURRENCY); + const searchResults = await Promise.allSettled( + evalDataItems.map((item: any) => + searchLimit(async () => { + const abortReason = await getTrainTaskAbortSignal({ type: 'embedding', taskId }); + if (abortReason === 'deleted') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_evaldataset, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const query = (item[EvalDatasetDataKeyEnum.UserInput] as string) || ''; + const rsp = await dispatchDatasetSearch({ + mode: 'test', + timezone: 'Asia/Shanghai', + externalProvider: {}, + uid: tmbId, + variables: {}, + query: [], + stream: false, + maxRunTimes: trainEnv.TRAIN_MAX_SEARCH_RUN_TIMES, + chatId: '', + checkIsStopping: () => false, + workflowDispatchDeep: 0, + runtimeNodesMap: new Map(), + usagePush: () => {}, + runningAppInfo: { + id: new Types.ObjectId().toString(), + name: 'EvalJsonlSearch', + teamId, + tmbId + }, + runningUserInfo: { + username: '', + teamName: '', + memberName: '', + contact: '', + teamId, + tmbId + }, + histories: [], + chatConfig: {}, + node: { + nodeId: 'ds', + name: 'DS', + avatar: '', + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + showStatus: true, + inputs: [], + outputs: [] + }, + runtimeNodes: [], + runtimeEdges: [], + params: { + datasets: datasetIds.map((id) => ({ datasetId: id, avatar: '', name: '' })), + similarity: trainEnv.TRAIN_SEARCH_SIMILARITY, + limit: trainEnv.TRAIN_SEARCH_LIMIT, + userChatInput: query, + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: undefined, + usingReRank: false, + rerankModelId: undefined, + rerankMethod: RerankMethodEnum.question, + collectionFilterMatch: '', + datasetSearchUsingExtensionQuery: false, + datasetSearchExtensionModelId: '', + datasetSearchExtensionBg: '' + } + }); + const nr = (rsp as any)[DispatchNodeResponseKeyEnum.nodeResponse]; + return (nr?.retrievalResults || (rsp as any).data?.quoteQA || []).map((r: any) => r.id); + }) + ) + ); + + propagateAbortFromResults(searchResults); + + // Build pos/neg items + const allDocIds = new Set(); + const items: Array<{ query: string; posIds: string[]; negIds: string[] }> = []; + + for (let i = 0; i < evalDataItems.length; i++) { + const item = evalDataItems[i] as any; + const query = (item[EvalDatasetDataKeyEnum.UserInput] as string) || ''; + const posIds: string[] = item[EvalDatasetDataKeyEnum.ExpectedContextIds] || []; + const result = searchResults[i]; + let candidateIds: string[] = []; + if (result.status === 'fulfilled') { + candidateIds = result.value; + } else { + addLog.warn('Eval dataset search failed for item, using empty candidates', { + taskId, + index: i, + query: query.slice(0, 100), + reason: result.reason?.message || String(result.reason) + }); + } + const negIds = candidateIds.filter((id) => !posIds.includes(id)).slice(0, 20); + + posIds.forEach((id) => allDocIds.add(id)); + negIds.forEach((id) => allDocIds.add(id)); + + items.push({ query, posIds, negIds }); + } + + // Fetch doc text in one batch + let textMap = new Map(); + if (allDocIds.size > 0) { + const docs = await MongoDatasetData.find({ _id: { $in: [...allDocIds] } }, 'q a').lean(); + for (const doc of docs) { + textMap.set(String(doc._id), [doc.q, doc.a].filter(Boolean).join('\n')); + } + } + + const dataDir = getEmbeddingTrainDataDir(); + const filename = `embedding_eval_${taskId}_${Date.now()}.jsonl`; + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + const filePath = path.join(dataDir, filename); + + let dataCount = 0; + const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' }); + for (const item of items) { + writeStream.write( + JSON.stringify({ + query: item.query, + pos: item.posIds.map((id) => textMap.get(id) || ''), + neg: item.negIds.map((id) => textMap.get(id) || ''), + id: String(dataCount++) + }) + '\n' + ); + } + await new Promise((resolve, reject) => { + writeStream.end((err: Error | null | undefined) => { + if (err) reject(err); + else resolve(); + }); + }); + + addLog.info('Generated embedding eval JSONL file from DB', { + taskId, + filePath, + dataCount + }); + + return { evalDatasetId: evalDatasetCollectionId, evalDatasetFilePath: filePath }; +} + +/** + * Stage 2: Generate Evaluation Dataset + * + * - Exact mode (task.evalDatasetId is non-empty): Reads eval data from MongoDB, + * performs dataset search for candidates, generates JSONL file. + * - Auto mode (task.evalDatasetId is empty): Samples from task.datasetIds, calls DiTing to + * generate QA pairs (mock via EMBEDDING_DITING_MOCK=true), creates eval dataset collection, + * writes evalDatasetId back to task, generates JSONL file. + * + * @param task - Embedding training task data + * @returns Evaluation dataset ID and JSONL file path + */ +export async function runGenerateEvalDatasetStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + evalDatasetId: string; + evalDatasetFilePath: string; + autoGenerated: boolean; +}> { + addLog.info('Run generate eval dataset stage (embedding)', { taskId: String(task._id) }); + + // Resume from partial checkpoint + if (task.checkpoint?.data?.generate_evaldataset?.evalDatasetFilePath) { + return { + ...task.checkpoint.data.generate_evaldataset, + autoGenerated: task.checkpoint.data.generate_evaldataset.autoGenerated ?? false + }; + } + + if (task.evalDatasetId) { + // Exact mode: use the provided eval dataset + addLog.info('Exact mode: using existing eval dataset', { + taskId: String(task._id), + evalDatasetId: task.evalDatasetId + }); + const jsonlResult = await generateEvalJsonlFromDB( + task.evalDatasetId, + task.datasetIds, + String(task._id), + task.tmbId, + task.teamId + ); + return { ...jsonlResult, autoGenerated: false }; + } + + // Auto mode: generate eval dataset from datasetIds + const evalDatasetId = await generateEvalDatasetFromDatasets(task); + + // Write evalDatasetId back to task top-level field + await MongoEmbeddingTrainTask.updateOne({ _id: task._id }, { evalDatasetId }); + + // Generate JSONL from the saved eval data (with fresh search for candidates) + const jsonlResult = await generateEvalJsonlFromDB( + evalDatasetId, + task.datasetIds, + String(task._id), + task.tmbId, + task.teamId + ); + + addLog.info('Auto mode: embedding eval dataset generated and written back to task', { + taskId: String(task._id), + evalDatasetId + }); + + return { ...jsonlResult, autoGenerated: true }; +} diff --git a/packages/service/core/train/embedding/task/stages/generate-trainset.ts b/packages/service/core/train/embedding/task/stages/generate-trainset.ts new file mode 100644 index 000000000000..08f83b44d692 --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/generate-trainset.ts @@ -0,0 +1,305 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { MongoEmbeddingTrainsetData } from '../../data/schema'; +import { MongoEmbeddingTrainset } from '../../trainset/schema'; +import { addLog } from '../../../../../common/system/log'; +import { createEmbeddingEnhancedError, formatTrainTaskError } from '../../utils'; +import { getEmbeddingTrainDataDir } from '../../constants'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { + EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainsetStatusEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { calculateEmbeddingTrainsetStats } from '../../data/controller'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/embedding/error'; +import { createEmbeddingTrainset } from '../../trainset/controller'; +import { MongoEmbeddingTrainTask } from '../schema'; +import { embeddingTrainDataGenerateQueue } from '../../data/mq'; + +/** + * Poll and wait for embedding trainset to be ready + * @param trainsetId Trainset ID + * @param maxAttempts Max polling attempts (default: 360 for 60 minutes) + * @param interval Polling interval in ms (default: 10000ms = 10s) + */ +async function waitForTrainsetReady( + trainsetId: string, + taskId: string, + maxAttempts: number = 360, + interval: number = 10000 +): Promise { + let attempts = 0; + + addLog.info('Waiting for embedding trainset to be ready', { + trainsetId, + taskId, + maxAttempts, + interval + }); + + while (attempts < maxAttempts) { + const abortReason = await getTrainTaskAbortSignal({ type: 'embedding', taskId }); + if (abortReason === 'deleted') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const trainset = await MongoEmbeddingTrainset.findById(trainsetId).lean(); + + if (!trainset) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareTrainsetDeleted, + EmbeddingTrainSuggestionEnum.embeddingPrepareTrainsetDeleted + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // If ready, validate data count and return successfully + if (trainset.status === EmbeddingTrainsetStatusEnum.ready) { + const stats = await calculateEmbeddingTrainsetStats(String(trainset._id)); + if (stats.dataCount === 0) { + addLog.error('No train data available in embedding trainset', { trainsetId }); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareDataEmpty, + EmbeddingTrainSuggestionEnum.embeddingPrepareDataEmpty + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + addLog.info('Embedding trainset is ready', { trainsetId, dataCount: stats.dataCount }); + return; + } + + // If error, fail the training task immediately + if (trainset.status === EmbeddingTrainsetStatusEnum.error) { + const enhancedError = trainset.errorMsg as EnhancedErrorMessage; + + addLog.error('Embedding trainset generation failed', { + trainsetId, + errorType: enhancedError.type, + errorMessage: enhancedError.message + }); + + // Reuse trainset's enhancedError, just add the stage field + const taskError: EnhancedErrorMessage = { + ...enhancedError, + stage: EmbeddingTaskCheckpointStageEnum.generate_trainset + }; + throw new TrainTaskUnrecoverableError(taskError); + } + + // If still generating or pending, continue waiting + if ( + trainset.status === EmbeddingTrainsetStatusEnum.generating || + trainset.status === EmbeddingTrainsetStatusEnum.pending + ) { + addLog.info('Embedding trainset still generating', { + trainsetId, + status: trainset.status, + attempt: attempts + 1, + maxAttempts + }); + await new Promise((resolve) => setTimeout(resolve, interval)); + attempts++; + continue; + } + + // Unknown status, continue waiting + await new Promise((resolve) => setTimeout(resolve, interval)); + attempts++; + } + + // Timeout + addLog.error('Embedding trainset generation timeout', { trainsetId, maxAttempts }); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareTimeout, + EmbeddingTrainSuggestionEnum.embeddingPrepareTimeout + ); + throw new TrainTaskRetriableError(enhancedError); +} + +/** + * Generate embedding trainset JSONL file from trainset data + * + * Organizes training data into JSONL format for SFT Platform upload. + * Uses streaming to avoid memory overflow. + * Format: {query, pos, neg, id} — same as rerank. + * + * @param task - Embedding training task data + * @returns Train dataset ID and temporary file path + * @throws {TrainTaskUnrecoverableError} When no train data available + */ +async function generateTrainsetJsonl(task: EmbeddingTrainTaskSchemaType): Promise<{ + trainDatasetId: string; + trainDatasetFilePath: string; +}> { + // Use configurable training data directory + const trainDataDir = getEmbeddingTrainDataDir(); + + // Ensure directory exists + if (!fs.existsSync(trainDataDir)) { + fs.mkdirSync(trainDataDir, { recursive: true }); + } + + const tmpFilePath = path.join(trainDataDir, `embedding_train_${task._id}_${Date.now()}.jsonl`); + + let dataCount = 0; + + try { + const writeStream = fs.createWriteStream(tmpFilePath, { encoding: 'utf-8' }); + + // Query training data for the specific trainset + const cursor = MongoEmbeddingTrainsetData.find({ + trainsetId: task.trainsetId + }).cursor(); + + for await (const data of cursor) { + const jsonLine = JSON.stringify({ + query: data.query, + pos: data.positiveDocs, + neg: data.negativeDocs, + id: String(dataCount) + }); + + writeStream.write(jsonLine + '\n'); + dataCount++; + } + + await new Promise((resolve, reject) => { + writeStream.end((err: Error | null | undefined) => { + if (err) reject(err); + else resolve(); + }); + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareFileSystemError, + EmbeddingTrainSuggestionEnum.embeddingPrepareFileSystemError, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Double-check data count after file writing + if (dataCount === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareDataEmptyAfterWrite, + EmbeddingTrainSuggestionEnum.embeddingPrepareDataEmptyAfterWrite + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Prepared embedding train data', { + taskId: String(task._id), + dataCount, + trainsetId: String(task.trainsetId), + filePath: tmpFilePath + }); + + return { + trainDatasetId: String(task.trainsetId), + trainDatasetFilePath: tmpFilePath + }; +} + +/** + * Stage 1: Generate Training Set + * + * - Auto mode (task.trainsetId is empty): Creates a new trainset, writes trainsetId back to task, + * triggers data generation queue, then waits for trainset to be ready. + * - Exact mode (task.trainsetId is non-empty): Directly waits for trainset to be ready. + * + * Both modes then generate the JSONL file. + * + * @param task - Embedding training task data + * @returns Train dataset ID and temporary file path + */ +export async function runGenerateTrainsetStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + trainDatasetId: string; + trainDatasetFilePath: string; + autoGenerated: boolean; +}> { + addLog.info('Run generate trainset stage (embedding)', { taskId: String(task._id) }); + + let trainsetId = task.trainsetId ? String(task.trainsetId) : undefined; + let autoGenerated = false; + + if (!trainsetId) { + // Auto mode: create new trainset and trigger data generation + autoGenerated = true; + addLog.info('Auto mode: creating new embedding trainset', { taskId: String(task._id) }); + + const trainset = await createEmbeddingTrainset({ + teamId: String(task.teamId), + tmbId: String(task.tmbId), + name: `Training Set - ${task.name}`, + description: `Auto-generated for embedding training task ${task._id}` + }); + trainsetId = String(trainset._id); + + // Write trainsetId back to task top-level field + await MongoEmbeddingTrainTask.updateOne({ _id: task._id }, { trainsetId }); + + addLog.info('Auto mode: created embedding trainset, triggering data generation', { + taskId: String(task._id), + trainsetId + }); + + // generateConfig is required in auto mode; guard here to surface programming errors early + if (!task.generateConfig) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.generate_trainset, + EmbeddingTrainErrEnum.embeddingPrepareMissingGenerateConfig, + EmbeddingTrainSuggestionEnum.embeddingPrepareMissingGenerateConfig + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Trigger data generation queue + const job = await embeddingTrainDataGenerateQueue.add(`generate-trainset-${trainsetId}`, { + trainsetId, + datasetIds: task.datasetIds, + generateConfig: task.generateConfig + }); + + // Write jobId back to trainset for retry support + if (job.id) { + await MongoEmbeddingTrainset.updateOne({ _id: trainsetId }, { jobId: job.id }); + } + } else { + addLog.info('Exact mode: using existing embedding trainset', { + taskId: String(task._id), + trainsetId + }); + } + + // Wait for trainset to be ready (both modes) + await waitForTrainsetReady(trainsetId, String(task._id)); + + // Generate JSONL file from trainset data + const jsonlResult = await generateTrainsetJsonl({ ...task, trainsetId }); + return { ...jsonlResult, autoGenerated }; +} diff --git a/packages/service/core/train/embedding/task/stages/llm-judge.ts b/packages/service/core/train/embedding/task/stages/llm-judge.ts new file mode 100644 index 000000000000..e75b488575de --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/llm-judge.ts @@ -0,0 +1,312 @@ +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { MongoDatasetData } from '../../../../../core/dataset/data/schema'; +import { judgeRelevantChunks } from '../../external'; +import { computeRankingMetrics } from '../../../common/metrics/rankingMetrics'; +import type { RankingCase } from '../../../common/metrics/rankingMetrics'; +import { pLimit, propagateAbortFromResults } from '../../../common/utils'; +import { trainEnv } from '../../../common/env'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getDefaultLLMModel } from '../../../../ai/model'; + +const K_VALUES = [5, 10, 15, 20]; + +/** + * Stage 7: LLM Judge (Embedding) + * + * Uses DiTing detect-context API to determine which chunks from the combined + * baseline + tuned top-K retrieval lists truly answer each query. + * Replaces the original expectedContextIds with LLM-detected relevant IDs + * and recomputes MRR/NDCG metrics for both models. + * + * Chunk content (q/a text) is batch-fetched from MongoDatasetData using the + * merged top-K IDs, consistent with rerank's llm-judge pattern. + * + * @param task - Embedding training task (after stage 6 completion) + * @returns Judged expected IDs and re-evaluated metrics + */ +export async function runLLMJudgeStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + judgedExpectedIds: Array<{ itemId: string; expectedIds: string[] }>; + baseModelRejudgedResult: ReturnType; + tunedModelRejudgedResult: ReturnType; +}> { + addLog.info('Run LLM judge stage (embedding)', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingLLMJudgeNoEvalData, + EmbeddingTrainSuggestionEnum.embeddingLLMJudgeNoEvalData + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const baselineRanking = task.checkpoint.data?.eval_basemodel?.rankingResults; + const tunedRanking = task.checkpoint.data?.eval_tunedmodel?.rankingResults; + + if (!baselineRanking?.length || !tunedRanking?.length) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingLLMJudgeNoRankingResults, + EmbeddingTrainSuggestionEnum.embeddingLLMJudgeNoRankingResults + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Build lookup maps from eval data item _id to ranking data + const baselineMap = new Map(baselineRanking.map((r) => [r.itemId, r.rankedIds])); + const tunedMap = new Map(tunedRanking.map((r) => [r.itemId, r.rankedIds])); + + // Read eval dataset data for question text and candidate IDs + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evalDatasetId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingLLMJudgeNoEvalData, + EmbeddingTrainSuggestionEnum.embeddingLLMJudgeNoEvalData + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const topK = trainEnv.LLM_JUDGE_TOP_K; + const defaultModel = getDefaultLLMModel(); + + // Build the eval data content lookup + const evalDataContentMap = new Map( + evalDataItems.map((item: any) => [ + item._id.toString(), + { + question: item.userInput ?? '', + candidateIds: (item.retrievalContextsFull || []).map((c: any) => c.id) + } + ]) + ); + + // Collect all IDs that may need q/a lookups (baseline/tuned topK merged for each item) + const allNeededIds = new Set(); + for (const [itemId, baselineIds] of baselineMap) { + const tunedIds = tunedMap.get(itemId); + if (!tunedIds) continue; + const evalContent = evalDataContentMap.get(itemId); + if (!evalContent) continue; + + const mergedIds = Array.from( + new Set([...baselineIds.slice(0, topK), ...tunedIds.slice(0, topK)]) + ); + mergedIds.forEach((id) => allNeededIds.add(id)); + } + + // Batch fetch q/a text from original dataset data + const dataTextMap = new Map( + (await MongoDatasetData.find({ _id: { $in: [...allNeededIds] } }, 'q a').lean()).map((d) => [ + String(d._id), + d as { q: string; a: string } + ]) + ); + + // Collect items that have both baseline and tuned ranking results + const judgeItems: Array<{ + itemId: string; + question: string; + retrieval_reference_list: Array<{ id: string; q: string; a: string }>; + baselineRankedIds: string[]; + tunedRankedIds: string[]; + }> = []; + + for (const [itemId, baselineIds] of baselineMap) { + const tunedIds = tunedMap.get(itemId); + if (!tunedIds) continue; + + const evalContent = evalDataContentMap.get(itemId); + if (!evalContent) continue; + + // Merge top K IDs from baseline and tuned, dedup + const mergedIds = Array.from( + new Set([...baselineIds.slice(0, topK), ...tunedIds.slice(0, topK)]) + ); + + // Lookup chunk content from MongoDatasetData (fetched in batch above) + const retrieval_reference_list = mergedIds + .map((id) => { + const doc = dataTextMap.get(id); + return { id, q: doc?.q ?? '', a: doc?.a ?? '' }; + }) + .filter((c) => c.q.length > 0 || c.a.length > 0); + + if (retrieval_reference_list.length === 0) continue; + + judgeItems.push({ + itemId, + question: evalContent.question, + retrieval_reference_list, + baselineRankedIds: baselineIds, + tunedRankedIds: tunedIds + }); + } + + if (judgeItems.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingLLMJudgeEmptyResult, + EmbeddingTrainSuggestionEnum.embeddingLLMJudgeEmptyResult + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('LLM judge items prepared (embedding)', { + taskId: String(task._id), + totalEvalItems: evalDataItems.length, + judgeItems: judgeItems.length, + topK + }); + + // Call DiTing detect-context API with controlled concurrency + const concurrency = Math.min( + Math.max(trainEnv.DITING_API_CONCURRENCY, 1), + trainEnv.DITING_MAX_CONCURRENCY + ); + const limit = pLimit(concurrency); + + let completedCount = 0; + const totalCount = judgeItems.length; + const PROGRESS_INTERVAL = Math.max(1, Math.floor(totalCount / 10)); + + const judgeResults = await Promise.allSettled( + judgeItems.map((item) => + limit(async () => { + const abortReason = await getTrainTaskAbortSignal({ + type: 'embedding', + taskId: String(task._id) + }); + if (abortReason === 'deleted') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingTaskNotExist, + EmbeddingTrainSuggestionEnum.embeddingTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingFinetuneCancelled, + EmbeddingTrainSuggestionEnum.embeddingFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const response = await judgeRelevantChunks({ + question: item.question, + retrieval_reference_list: item.retrieval_reference_list, + llm_config: { + name: defaultModel?.model ?? '', + base_url: defaultModel?.requestUrl ?? '', + api_key: defaultModel?.requestAuth ?? '' + } + }); + + completedCount++; + if (completedCount % PROGRESS_INTERVAL === 0 || completedCount === totalCount) { + addLog.info('LLM judge progress (embedding)', { + taskId: String(task._id), + completed: completedCount, + total: totalCount + }); + } + + if (response.status !== 'success') { + addLog.warn('LLM judge failed for item (embedding)', { + taskId: String(task._id), + itemId: item.itemId, + error: response.error + }); + return null; + } + + return { + itemId: item.itemId, + expectedIds: response.detected_data_ids ?? [], + baselineRankedIds: item.baselineRankedIds, + tunedRankedIds: item.tunedRankedIds + }; + }) + ) + ); + + propagateAbortFromResults(judgeResults); + + // Collect successful results + const successfulResults = judgeResults + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value) + .filter((v) => v !== null); + + const failedCount = judgeResults.length - successfulResults.length; + if (failedCount > 0) { + addLog.warn('Some LLM judge requests failed (embedding)', { + taskId: String(task._id), + total: judgeResults.length, + failed: failedCount + }); + } + + if (successfulResults.length === 0) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.llm_judge, + EmbeddingTrainErrEnum.embeddingLLMJudgeDiTingFailed, + EmbeddingTrainSuggestionEnum.embeddingLLMJudgeDiTingFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('LLM judge completed (embedding)', { + taskId: String(task._id), + successfulResults: successfulResults.length, + failedCount + }); + + // Build judged expected IDs + const judgedExpectedIds = successfulResults.map((r) => ({ + itemId: r.itemId, + expectedIds: r.expectedIds + })); + + // Recompute baseline metrics + const baselineCases: RankingCase[] = successfulResults.map((r) => ({ + rankedIds: r.baselineRankedIds, + expectedIds: r.expectedIds + })); + const baseModelRejudgedResult = computeRankingMetrics(baselineCases, K_VALUES, 'embed'); + + // Recompute tuned metrics + const tunedCases: RankingCase[] = successfulResults.map((r) => ({ + rankedIds: r.tunedRankedIds, + expectedIds: r.expectedIds + })); + const tunedModelRejudgedResult = computeRankingMetrics(tunedCases, K_VALUES, 'embed'); + + addLog.info('LLM judge stage completed (embedding)', { + taskId: String(task._id), + judgedItemCount: successfulResults.length, + baseMrr10: baseModelRejudgedResult.detailed_results.embed_top10_mrr, + tunedMrr10: tunedModelRejudgedResult.detailed_results.embed_top10_mrr + }); + + return { + judgedExpectedIds, + baseModelRejudgedResult, + tunedModelRejudgedResult + }; +} diff --git a/packages/service/core/train/embedding/task/stages/register.ts b/packages/service/core/train/embedding/task/stages/register.ts new file mode 100644 index 000000000000..e7464459e61b --- /dev/null +++ b/packages/service/core/train/embedding/task/stages/register.ts @@ -0,0 +1,114 @@ +import type { EmbeddingTrainTaskSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { + EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainMethodEnum +} from '@fastgpt/global/core/train/embedding/constants'; +import { createEmbeddingModelConfig } from '../../model/controller'; +import { addLog } from '../../../../../common/system/log'; +import { createEmbeddingEnhancedError } from '../../utils'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { getEmbeddingModelById } from '../../../../ai/model'; + +/** + * Stage 5: Model Registration + * + * Registers the finetuned embedding model configuration in FastGPT model management system. + * + * @param task - Embedding training task data + * @returns Tuned model ID + * @throws {TrainTaskUnrecoverableError} When tuned endpoint or base model not found + */ +export async function runRegisterStage(task: EmbeddingTrainTaskSchemaType): Promise<{ + tunedModelId: string; +}> { + addLog.info('Run register stage (embedding)', { taskId: String(task._id) }); + + const checkpointData = task.checkpoint.data || {}; + if (!checkpointData.finetuning?.tunedModelEndpoint) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.registering, + EmbeddingTrainErrEnum.embeddingRegisterEndpointNotFound, + EmbeddingTrainSuggestionEnum.embeddingRegisterEndpointNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + if (!task.baseModelId) { + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.registering, + EmbeddingTrainErrEnum.embeddingRegisterBaseModelNotFound, + EmbeddingTrainSuggestionEnum.embeddingRegisterBaseModelNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const tunedEndpoint = checkpointData.finetuning.tunedModelEndpoint; + const baseModelId = task.baseModelId; + + // Use task.newModelName if provided, otherwise fall back to the model ID from SFT Bridge + const tunedModelName = task.newModelName || tunedEndpoint.model; + + // Inherit config from base model + const baseModel = getEmbeddingModelById(baseModelId); + + let tunedModelId: string; + try { + tunedModelId = await createEmbeddingModelConfig({ + name: tunedModelName, + endpoint: tunedEndpoint, + isActive: true, + tmbId: task.tmbId, + teamId: task.teamId, + charsPointsPrice: baseModel.charsPointsPrice, + defaultToken: baseModel.defaultToken, + maxToken: baseModel.maxToken, + weight: baseModel.weight, + normalization: baseModel.normalization, + batchSize: baseModel.batchSize, + defaultConfig: baseModel.defaultConfig, + instruction: + task.trainMethod === EmbeddingTrainMethodEnum.task_tuning + ? undefined + : baseModel.instruction, + taskId: String(task._id) + }); + + addLog.info('Created tuned embedding model config and channel', { + taskId: String(task._id), + tunedModelId, + tunedModelName, + endpoint: tunedEndpoint + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // Distinguish channel availability timeout from other errors + const isChannelTimeout = errorMsg.includes('did not become available'); + const enhancedError = createEmbeddingEnhancedError( + EmbeddingTaskCheckpointStageEnum.registering, + isChannelTimeout + ? EmbeddingTrainErrEnum.embeddingRegisterChannelNotAvailable + : EmbeddingTrainErrEnum.embeddingRegisterAiProxyFailed, + isChannelTimeout + ? EmbeddingTrainSuggestionEnum.embeddingRegisterChannelNotAvailable + : EmbeddingTrainSuggestionEnum.embeddingRegisterAiProxyFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Register stage completed (embedding)', { + taskId: String(task._id), + baseModelId, + tunedModelId, + tunedModelName + }); + + return { + tunedModelId + }; +} diff --git a/packages/service/core/train/embedding/task/utils.ts b/packages/service/core/train/embedding/task/utils.ts new file mode 100644 index 000000000000..d23680971ddf --- /dev/null +++ b/packages/service/core/train/embedding/task/utils.ts @@ -0,0 +1,51 @@ +/** + * Build aggregation pipeline for fetching embedding train task with creator info + * @param baseFields - Additional fields to include in projection + * @returns MongoDB aggregation pipeline stages + */ +export function buildEmbeddingTrainTaskAggregationPipeline(baseFields?: Record) { + return [ + // Lookup team member (creator) info + { + $lookup: { + from: 'team_members', + localField: 'tmbId', + foreignField: '_id', + as: 'teamMember' + } + }, + // Transform array to single object + { + $addFields: { + teamMember: { $arrayElemAt: ['$teamMember', 0] } + } + }, + // Project final fields + { + $project: { + _id: 1, + teamId: 1, + tmbId: 1, + name: 1, + baseModelId: 1, + baseModelEndpoint: 1, + trainsetId: 1, + evalDatasetId: 1, + datasetIds: 1, + newModelName: 1, + status: 1, + checkpoint: 1, + result: 1, + errorMsg: 1, + jobId: 1, + createTime: 1, + updateTime: 1, + finishTime: 1, + // Creator info from team member + creatorAvatar: '$teamMember.avatar', + creatorName: '$teamMember.name', + ...baseFields + } + } + ]; +} diff --git a/packages/service/core/train/embedding/task/worker.ts b/packages/service/core/train/embedding/task/worker.ts new file mode 100644 index 000000000000..7f27e1ab8a31 --- /dev/null +++ b/packages/service/core/train/embedding/task/worker.ts @@ -0,0 +1,116 @@ +import { getWorker, QueueNames } from '../../../../common/bullmq'; +import { addLog } from '../../../../common/system/log'; +import { type EmbeddingTrainTaskJobData } from './mq'; +import { embeddingTrainTaskProcessor } from './processor'; +import { MongoEmbeddingTrainTask } from './schema'; +import { EmbeddingTrainTaskStatusEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { trainEnv } from '../../common/env'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../common/errors'; +import { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/embedding/error'; +import { createEmbeddingEnhancedError } from '../utils'; + +export function initEmbeddingTrainTaskWorker() { + const worker = getWorker( + QueueNames.embeddingTrainTask, + embeddingTrainTaskProcessor, + { + stalledInterval: trainEnv.TRAIN_WORKER_STALLED_INTERVAL, + maxStalledCount: + global.systemEnv?.trainConfig?.maxStalledCount || trainEnv.TRAIN_WORKER_MAX_STALLED_COUNT, + concurrency: global.systemEnv?.trainConfig?.taskConcurrency || trainEnv.TRAIN_TASK_CONCURRENCY + } + ); + + worker.on('active', (job) => { + if (job?.data) { + const { taskId } = job.data; + addLog.info('[EmbeddingTrainTask] Task started', { + jobId: job.id, + taskId + }); + } + }); + + worker.on('completed', (job) => { + if (job?.data) { + const { taskId } = job.data; + addLog.info('[EmbeddingTrainTask] Task completed', { + jobId: job.id, + taskId + }); + } + }); + + worker.on('stalled', async (jobId: string) => { + addLog.warn('[EmbeddingTrainTask] Task stalled, will be retried', { + jobId + }); + }); + + worker.on('failed', async (job, error) => { + if (job?.data) { + const { taskId } = job.data; + + // Extract structured error information + let errorMsg: EnhancedErrorMessage; + + if ( + error instanceof TrainTaskUnrecoverableError || + error instanceof TrainTaskRetriableError + ) { + // Use structured error from custom error classes + errorMsg = error.enhancedError; + + addLog.error('[EmbeddingTrainTask] Task failed', { + jobId: job.id, + taskId, + errorType: error.name, + errorMsg // Log complete structured error + }); + } else { + // Unknown error, construct basic structured error + errorMsg = createEmbeddingEnhancedError( + null, + EmbeddingTrainErrEnum.embeddingUnknownError, + EmbeddingTrainSuggestionEnum.embeddingUnknownError, + error.stack + ); + + addLog.error('[EmbeddingTrainTask] Task failed with unknown error', { + jobId: job.id, + taskId, + error: error.message, + stack: error.stack + }); + } + + try { + await MongoEmbeddingTrainTask.updateOne( + { _id: taskId }, + { + status: EmbeddingTrainTaskStatusEnum.failed, + errorMsg, // Save structured object + updateTime: new Date(), + finishTime: new Date() + } + ); + + addLog.info('[EmbeddingTrainTask] Task status updated to failed in MongoDB', { + taskId + }); + } catch (updateError) { + addLog.error('[EmbeddingTrainTask] Failed to update task status in MongoDB', { + taskId, + updateError: (updateError as Error).message + }); + } + } + }); + + addLog.info('[EmbeddingTrainTask] Worker created successfully'); + return worker; +} diff --git a/packages/service/core/train/embedding/trainset/controller.ts b/packages/service/core/train/embedding/trainset/controller.ts new file mode 100644 index 000000000000..05fb741b4f99 --- /dev/null +++ b/packages/service/core/train/embedding/trainset/controller.ts @@ -0,0 +1,86 @@ +import { MongoEmbeddingTrainset } from './schema'; +import type { EmbeddingTrainsetSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTrainsetStatusEnum } from '@fastgpt/global/core/train/embedding/constants'; +import { addLog } from '../../../../common/system/log'; +import { calculateEmbeddingTrainsetStats } from '../data/controller'; +import type { ClientSession } from '../../../../common/mongo'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; + +/** + * Create embedding trainset + * + * @param params - Trainset creation parameters + * @returns Created trainset document + */ +export async function createEmbeddingTrainset(params: { + teamId: string; + tmbId: string; + name?: string; + description?: string; +}): Promise { + const { teamId, tmbId, name, description } = params; + + const [doc] = await MongoEmbeddingTrainset.create([ + { + teamId, + tmbId, + name: name || `Embedding Training Set - ${new Date().toLocaleDateString()}`, + description, + status: EmbeddingTrainsetStatusEnum.pending + } + ]); + + addLog.info('Created embedding trainset', { + teamId, + trainsetId: String(doc._id) + }); + + return doc.toObject() as EmbeddingTrainsetSchemaType; +} + +/** + * Get trainset with statistics + * + * @param trainsetId - Trainset ID + * @param teamId - Team ID for permission validation + * @returns Trainset with statistics + * @throws {Error} When trainset not found or permission denied + */ +export async function getEmbeddingTrainset( + trainsetId: string, + teamId: string +): Promise { + const trainset = await MongoEmbeddingTrainset.findOne({ + _id: trainsetId, + teamId + }).lean(); + + if (!trainset) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTrainsetNotExist); + } + + // Dynamically calculate statistics + const statistics = await calculateEmbeddingTrainsetStats(trainsetId); + + return { + ...trainset, + statistics + }; +} + +/** + * Delete trainset record + * + * Call within a MongoDB session/transaction when cascading deletions must be atomic. + * + * @param trainsetId - Trainset ID to delete + * @param session - Optional MongoDB session for transaction support + */ +export async function deleteEmbeddingTrainset( + trainsetId: string, + session?: ClientSession +): Promise { + await MongoEmbeddingTrainset.deleteOne({ _id: trainsetId }, { session }); + + addLog.info('Deleted embedding trainset', { trainsetId }); +} diff --git a/packages/service/core/train/embedding/trainset/schema.ts b/packages/service/core/train/embedding/trainset/schema.ts new file mode 100644 index 000000000000..0038c3cbc434 --- /dev/null +++ b/packages/service/core/train/embedding/trainset/schema.ts @@ -0,0 +1,52 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { EmbeddingTrainsetSchemaType } from '@fastgpt/global/core/train/embedding/type'; +import { EmbeddingTrainsetStatusEnum } from '@fastgpt/global/core/train/embedding/constants'; + +const EmbeddingTrainsetSchema = new connectionMongo.Schema({ + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + tmbId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team_member', + required: true + }, + name: { + type: String, + required: true + }, + description: { + type: String + }, + status: { + type: String, + enum: Object.values(EmbeddingTrainsetStatusEnum), + default: EmbeddingTrainsetStatusEnum.pending + }, + errorMsg: { + type: connectionMongo.Schema.Types.Mixed // Support both string (legacy) and EnhancedErrorMessage object + }, + jobId: { + type: String + }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + } +}); + +// Indexes +EmbeddingTrainsetSchema.index({ teamId: 1, updateTime: -1 }); +EmbeddingTrainsetSchema.index({ status: 1 }); +EmbeddingTrainsetSchema.index({ jobId: 1 }); + +export const MongoEmbeddingTrainset = getMongoModel( + 'embedding_trainset', + EmbeddingTrainsetSchema +); diff --git a/packages/service/core/train/embedding/utils.ts b/packages/service/core/train/embedding/utils.ts new file mode 100644 index 000000000000..0b22e9f57087 --- /dev/null +++ b/packages/service/core/train/embedding/utils.ts @@ -0,0 +1,53 @@ +import type { EmbeddingTaskCheckpointStageEnum } from '@fastgpt/global/core/train/embedding/constants'; +import type { + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { + getEmbeddingTrainErrorMessageKey, + getEmbeddingTrainErrorSuggestionKey +} from '@fastgpt/global/common/error/code/train'; +import { + pLimit, + sampleDataFromDataset, + fetchSampledContent, + buildModelEndpoint, + getTrainStageKey, + formatTrainTaskError, + makeCreateEnhancedError, + propagateAbortFromResults +} from '../common/utils'; + +// Re-export common utilities for backward compatibility +export { + pLimit, + sampleDataFromDataset, + fetchSampledContent, + buildModelEndpoint, + getTrainStageKey, + formatTrainTaskError, + propagateAbortFromResults +}; + +/** + * Create enhanced error message for embedding training tasks + * + * All i18n keys are automatically generated through conversion functions: + * - stage: getTrainStageKey(stage) -> 'train:stage_xxx' + * - message: getEmbeddingTrainErrorMessageKey(type) -> 'embedding_train:xxx' + * - suggestion: getEmbeddingTrainErrorSuggestionKey(suggestion) -> 'embedding_train:xxx_suggestion' + * + * @param stage - Current training stage (can be null for general errors) + * @param type - Error type enum + * @param suggestion - Optional suggestion enum + * @param originalError - Optional original error message for debugging + * @returns Enhanced error message object + */ +export const createEmbeddingEnhancedError = makeCreateEnhancedError< + EmbeddingTaskCheckpointStageEnum, + EmbeddingTrainErrEnum, + EmbeddingTrainSuggestionEnum +>({ + getMessageKey: getEmbeddingTrainErrorMessageKey, + getSuggestionKey: getEmbeddingTrainErrorSuggestionKey +}); diff --git a/packages/service/core/train/embedding/validation/dataset.ts b/packages/service/core/train/embedding/validation/dataset.ts new file mode 100644 index 000000000000..fbc7d5f92601 --- /dev/null +++ b/packages/service/core/train/embedding/validation/dataset.ts @@ -0,0 +1,37 @@ +import { addLog } from '../../../../common/system/log'; +import type { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { validateDatasetReadiness, type DatasetReadinessErrorConfig } from '../../common/utils'; + +const embeddingErrorConfig: DatasetReadinessErrorConfig = { + noDatasetConfigured: EmbeddingTrainErrEnum.embeddingValidationNoDatasetConfigured, + datasetNoSynthesisIndex: EmbeddingTrainErrEnum.embeddingValidationDatasetNoSynthesisIndex, + insufficientChunks: EmbeddingTrainErrEnum.embeddingTaskInsufficientChunks +}; + +/** + * Validate that all given datasets have ready indexes for the target index type + * and meet the minimum chunk threshold. + * + * Delegates to shared validateDatasetReadiness with embedding-specific error enums. + * + * @param datasetIds - Dataset IDs to validate (must be non-empty) + * @param indexType - Target index type to validate (must exist in each dataset) + * @throws {EmbeddingTrainErrEnum} When validation fails + */ +export async function validateDatasetTargetIndexes( + datasetIds: string[], + indexType: `${DatasetDataIndexTypeEnum}` +): Promise { + addLog.info('Embedding dataset validation started', { + datasetCount: datasetIds.length, + indexType + }); + + await validateDatasetReadiness(datasetIds, indexType, embeddingErrorConfig); + + addLog.info('Embedding dataset validation completed', { + datasetCount: datasetIds.length, + indexType + }); +} diff --git a/packages/service/core/train/embedding/validation/environment.ts b/packages/service/core/train/embedding/validation/environment.ts new file mode 100644 index 000000000000..4bc6c78c99aa --- /dev/null +++ b/packages/service/core/train/embedding/validation/environment.ts @@ -0,0 +1,107 @@ +import { addLog } from '../../../../common/system/log'; +import { trainEnv } from '../../common/env'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import axios from 'axios'; + +/** + * Environment validation for embedding training task creation + * + * Validates that all required external services are accessible before creating a training task. + * Throws error codes directly instead of returning validation results. + */ + +/** + * Check if SFT Bridge service is accessible + * Attempts a simple connection test to the SFT Bridge service + * + * @throws {EmbeddingTrainErrEnum.validationSftBridgeUnaccessible} If SFT Bridge is not accessible + */ +export async function validateSFTBridgeAccess(): Promise { + const sftBridgeUrl = trainEnv.SFT_BRIDGE_BASE_URL; + const timeout = trainEnv.SFT_BRIDGE_TIMEOUT; + + // Skip validation in mock mode + if (trainEnv.SFT_BRIDGE_MOCK_ENABLE) { + addLog.info('SFT Bridge validation skipped (mock mode enabled)'); + return; + } + + try { + // Try to access a health check endpoint or root endpoint with a short timeout + const healthCheckUrl = `${sftBridgeUrl}/v1/health`; + + addLog.info('Validating SFT Bridge access', { url: healthCheckUrl, timeout }); + + await axios.get(healthCheckUrl, { + timeout: Math.min(timeout, 5000), // Use max 5 seconds for validation + validateStatus: (status) => status < 500 // Accept any status below 500 (including 404) + }); + + addLog.info('SFT Bridge is accessible', { url: sftBridgeUrl }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error accessing SFT Bridge'; + + addLog.error('SFT Bridge validation failed', { + url: sftBridgeUrl, + error: errorMessage + }); + + return Promise.reject(EmbeddingTrainErrEnum.embeddingValidationSftBridgeUnaccessible); + } +} + +/** + * Check if DiTing service is accessible + * Attempts a simple connection test to the DiTing service + * + * @throws {EmbeddingTrainErrEnum.validationDitingUnaccessible} If DiTing is not accessible + */ +export async function validateDiTingAccess(): Promise { + const ditingUrl = trainEnv.DITING_BASE_URL; + const timeout = trainEnv.DITING_TIMEOUT; + + // Skip validation in mock mode + if (trainEnv.DITING_MOCK_ENABLE) { + addLog.info('DiTing validation skipped (mock mode enabled)'); + return; + } + + try { + // Try to access a health check endpoint or root endpoint with a short timeout + const healthCheckUrl = `${ditingUrl}/api/v1/healthz`; + + addLog.info('Validating DiTing access', { url: healthCheckUrl, timeout }); + + await axios.get(healthCheckUrl, { + timeout: Math.min(timeout, 5000), // Use max 5 seconds for validation + validateStatus: (status) => status < 500 // Accept any status below 500 (including 404) + }); + + addLog.info('DiTing is accessible', { url: ditingUrl }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error accessing DiTing'; + + addLog.error('DiTing validation failed', { + url: ditingUrl, + error: errorMessage + }); + + return Promise.reject(EmbeddingTrainErrEnum.embeddingValidationDitingUnaccessible); + } +} + +/** + * Validate all external services required for training + * Runs all validation checks in parallel + * + * @throws {EmbeddingTrainErrEnum} If any validation fails + */ +export async function validateTrainingEnvironment(): Promise { + addLog.info('Starting training environment validation'); + + // Run validations in parallel - will throw on first failure + await Promise.all([validateSFTBridgeAccess(), validateDiTingAccess()]); + + addLog.info('Training environment validation successful'); +} diff --git a/packages/service/core/train/embedding/validation/index.ts b/packages/service/core/train/embedding/validation/index.ts new file mode 100644 index 000000000000..55a13a6692c9 --- /dev/null +++ b/packages/service/core/train/embedding/validation/index.ts @@ -0,0 +1,12 @@ +/** + * Unified validation entry point for embedding training + * + * Exports all validation functions for easy import + */ + +export { + validateTrainingEnvironment, + validateSFTBridgeAccess, + validateDiTingAccess +} from './environment'; +export { validateDatasetTargetIndexes } from './dataset'; diff --git a/packages/service/core/train/rerank/constants.ts b/packages/service/core/train/rerank/constants.ts new file mode 100644 index 000000000000..04db4a166ae3 --- /dev/null +++ b/packages/service/core/train/rerank/constants.ts @@ -0,0 +1,16 @@ +/** Rerank training module constants */ + +import * as path from 'path'; +import { trainEnv } from '../common/env'; + +// ===== Training Data File Storage ===== +/** + * Get rerank training data file directory + * Priority: TRAIN_DATA_DIR env > {cwd}/data/train/rerank (default) + * + * @returns Full path to rerank training data directory + */ +export function getRerankTrainDataDir(): string { + const baseDir = trainEnv.TRAIN_DATA_DIR || path.join(process.cwd(), 'data', 'train'); + return path.join(baseDir, 'rerank'); +} diff --git a/packages/service/core/train/rerank/data/controller.ts b/packages/service/core/train/rerank/data/controller.ts new file mode 100644 index 000000000000..c18368d494ae --- /dev/null +++ b/packages/service/core/train/rerank/data/controller.ts @@ -0,0 +1,189 @@ +import { MongoRerankTrainsetData } from './schema'; +import { RerankTrainDataSourceEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { addLog } from '../../../../common/system/log'; +import type { + RerankTrainsetDataSchemaType, + RerankTrainsetStatistics +} from '@fastgpt/global/core/train/rerank/type'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; + +/** Create manual training data */ +export async function createManualRerankTrainData(params: { + trainsetId: string; + teamId: string; + tmbId: string; + query: string; + positiveDocs: string[]; + negativeDocs: string[]; + reason?: string; +}): Promise { + const { trainsetId, teamId, tmbId, query, positiveDocs, negativeDocs, reason } = params; + + const [doc] = await MongoRerankTrainsetData.create([ + { + trainsetId, + teamId, + query, + positiveDocs, + negativeDocs, + source: RerankTrainDataSourceEnum.manual, + metadata: { + sourceInfo: { + manualInfo: { + creator: tmbId, + createdAt: new Date(), + reason + } + } + } + } + ]); + + addLog.info('Created manual rerank train data', { + trainsetId, + dataId: String(doc._id) + }); + + return doc.toObject() as RerankTrainsetDataSchemaType; +} + +/** Update training data */ +export async function updateRerankTrainData(params: { + dataId: string; + query?: string; + positiveDocs?: string[]; + negativeDocs?: string[]; +}): Promise { + const { dataId, query, positiveDocs, negativeDocs } = params; + + const updateFields: { + query?: string; + positiveDocs?: string[]; + negativeDocs?: string[]; + } = {}; + if (query) updateFields.query = query; + if (positiveDocs) updateFields.positiveDocs = positiveDocs; + if (negativeDocs) updateFields.negativeDocs = negativeDocs; + + await MongoRerankTrainsetData.updateOne({ _id: dataId }, updateFields); + + addLog.info('Updated rerank train data', { dataId }); +} + +/** Delete training data */ +export async function deleteRerankTrainData(dataIds: string[]): Promise { + if (dataIds.length === 0) { + return Promise.reject(CommonErrEnum.missingParams); + } + + const firstData = await MongoRerankTrainsetData.findById(dataIds[0]).lean(); + if (!firstData) { + return Promise.reject(RerankTrainErrEnum.rerankTrainDataNotExist); + } + + const result = await MongoRerankTrainsetData.deleteMany({ + _id: { $in: dataIds }, + trainsetId: firstData.trainsetId + }); + + addLog.info('Deleted rerank train data', { + trainsetId: String(firstData.trainsetId), + deletedCount: result.deletedCount + }); + + return result.deletedCount || 0; +} + +/** Calculate trainset statistics */ +export async function calculateRerankTrainsetStats( + trainsetId: string +): Promise { + const trainData = await MongoRerankTrainsetData.find({ trainsetId }).lean(); + + const dataCount = trainData.length; + + let positiveCount = 0; + let negativeCount = 0; + trainData.forEach((data) => { + positiveCount += data.positiveDocs.length; + negativeCount += data.negativeDocs.length; + }); + + // Use Map to store final data structure directly + const sourceSummary = new Map(); + + trainData.forEach((data) => { + const source = data.source; + + if (source === RerankTrainDataSourceEnum.dataset) { + const datasetId = data.metadata?.sourceInfo?.datasetInfo?.datasetId; + + if (datasetId) { + const key = `dataset_${datasetId}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'dataset' as const, + count: 0, + datasetInfo: { + datasetId + } + }); + } + // Type assertion needed because TypeScript can't narrow discriminated union in Map.get() + const item = sourceSummary.get(key) as Extract< + RerankTrainsetStatistics['sourceSummary'][number], + { type: 'dataset' } + >; + item.count++; + } + } else if (source === RerankTrainDataSourceEnum.chat_log) { + const chatId = data.metadata?.sourceInfo?.chatLogInfo?.chatId; + + if (chatId) { + const key = `chat_log_${chatId}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'chat_log' as const, + count: 0, + chatLogInfo: { + chatId + } + }); + } + const item = sourceSummary.get(key) as Extract< + RerankTrainsetStatistics['sourceSummary'][number], + { type: 'chat_log' } + >; + item.count++; + } + } else if (source === RerankTrainDataSourceEnum.manual) { + const creator = data.metadata?.sourceInfo?.manualInfo?.creator; + + if (creator) { + const key = `manual_${creator}`; + if (!sourceSummary.has(key)) { + sourceSummary.set(key, { + type: 'manual' as const, + count: 0, + manualInfo: { + creator + } + }); + } + const item = sourceSummary.get(key) as Extract< + RerankTrainsetStatistics['sourceSummary'][number], + { type: 'manual' } + >; + item.count++; + } + } + }); + + return { + dataCount, + positiveCount, + negativeCount, + sourceSummary: Array.from(sourceSummary.values()) + }; +} diff --git a/packages/service/core/train/rerank/data/mq.ts b/packages/service/core/train/rerank/data/mq.ts new file mode 100644 index 000000000000..83441b1d824f --- /dev/null +++ b/packages/service/core/train/rerank/data/mq.ts @@ -0,0 +1,33 @@ +import { getQueue, QueueNames } from '../../../../common/bullmq'; +import { trainEnv } from '../../common/env'; +import type { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; + +/** Rerank train data generation config */ +export type RerankTrainDataGenerateConfig = { + sampleSize?: number; + weights?: Record; + forceRegenerate?: boolean; + indexType: `${DatasetDataIndexTypeEnum}`; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; +}; + +export type RerankTrainDataGenerateJobData = { + trainsetId: string; + datasetIds: string[]; + generateConfig: RerankTrainDataGenerateConfig; +}; + +export const rerankTrainDataGenerateQueue = getQueue( + QueueNames.rerankTrainDataGenerate, + { + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: trainEnv.TRAIN_JOB_BACKOFF_DELAY }, + removeOnComplete: { age: 24 * 60 * 60 }, + removeOnFail: { age: 7 * 24 * 60 * 60 } + } + } +); diff --git a/packages/service/core/train/rerank/data/processor.ts b/packages/service/core/train/rerank/data/processor.ts new file mode 100644 index 000000000000..574fe8b44dce --- /dev/null +++ b/packages/service/core/train/rerank/data/processor.ts @@ -0,0 +1,180 @@ +import type { Processor } from 'bullmq'; +import type { RerankTrainDataGenerateJobData } from './mq'; +import { MongoRerankTrainsetData } from './schema'; +import { MongoRerankTrainset } from '../trainset/schema'; +import { sampleDataFromDataset, createRerankEnhancedError } from '../utils'; +import { + RerankTrainDataSourceEnum, + RerankTrainsetStatusEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { addLog } from '../../../../common/system/log'; +import { buildFineTuneDataStream } from '../../common/synthesize/buildFineTuneData'; +import { + TrainsetGenerationUnrecoverableError, + TrainsetGenerationRetriableError +} from '../../common/errors'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { trainEnv } from '../../common/env'; + +const SAVE_BATCH_SIZE = 1000; + +/** Rerank train data generation processor */ +export const rerankTrainDataGenerateProcessor: Processor = async ( + job +) => { + const { trainsetId, datasetIds, generateConfig } = job.data; + + // 1. Check if trainset exists + const trainset = await MongoRerankTrainset.findById(trainsetId); + if (!trainset) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenNotFound, + RerankTrainSuggestionEnum.rerankTrainsetGenNotFound + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 2. Check if already generating + if (trainset.status === RerankTrainsetStatusEnum.generating) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenAlreadyGenerating, + RerankTrainSuggestionEnum.rerankTrainsetGenAlreadyGenerating + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 3. datasetIds is now required + if (!datasetIds?.length) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenNoDataset, + RerankTrainSuggestionEnum.rerankTrainsetGenNoDataset + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + const targetDatasetIds = datasetIds; + + // Update status to generating + await MongoRerankTrainset.updateOne( + { _id: trainsetId }, + { status: RerankTrainsetStatusEnum.generating } + ); + + // 4. Sample data IDs from datasets (lightweight — no q/a/indexes loaded) + const sampledItems = await sampleDataFromDataset(targetDatasetIds, { + datasetType: 'train', + sampleSize: generateConfig.sampleSize, + weights: generateConfig.weights + }); + + if (sampledItems.length === 0) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenDatasetEmpty, + RerankTrainSuggestionEnum.rerankTrainsetGenDatasetEmpty + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + if (sampledItems.length < trainEnv.TRAIN_MIN_CHUNK_COUNT) { + addLog.warn('Rerank trainset generation rejected: insufficient chunks after sampling', { + trainsetId, + sampledCount: sampledItems.length, + required: trainEnv.TRAIN_MIN_CHUNK_COUNT + }); + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenInsufficientChunks, + RerankTrainSuggestionEnum.rerankTrainsetGenInsufficientChunks + ); + throw new TrainsetGenerationUnrecoverableError(error); + } + + // 5. forceRegenerate: delete old data before streaming new data + if (generateConfig.forceRegenerate) { + await MongoRerankTrainsetData.deleteMany({ + trainsetId, + source: RerankTrainDataSourceEnum.dataset + }); + } + + // 6. Stream training samples and batch-insert + let saveBatch: any[] = []; + let totalGenerated = 0; + + try { + for await (const sample of buildFineTuneDataStream({ + sampledItems, + indexType: generateConfig.indexType, + indexMultiStrategy: generateConfig.indexMultiStrategy, + negativeStrategy: generateConfig.negativeStrategy, + minNegativeSamples: generateConfig.minNegativeSamples, + maxNegativeSamples: generateConfig.maxNegativeSamples + })) { + saveBatch.push({ + trainsetId, + teamId: trainset.teamId, + query: sample.query, + positiveDocs: sample.positive, + negativeDocs: sample.negatives, + source: RerankTrainDataSourceEnum.dataset, + metadata: { + sourceInfo: { + datasetInfo: { dataId: sample.sourceId, datasetId: sample.datasetId } + }, + generateConfig + }, + createTime: new Date() + }); + totalGenerated++; + + if (saveBatch.length >= SAVE_BATCH_SIZE) { + await MongoRerankTrainsetData.insertMany(saveBatch); + saveBatch = []; + await new Promise((r) => setImmediate(r)); // 让 GC 有机会回收上批对象 + } + } + + if (saveBatch.length > 0) { + await MongoRerankTrainsetData.insertMany(saveBatch); + } + } catch (dbError) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenDatabaseError, + RerankTrainSuggestionEnum.rerankTrainsetGenDatabaseError, + (dbError as Error).message + ); + throw new TrainsetGenerationRetriableError(error); + } + + if (totalGenerated === 0) { + const error = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankTrainsetGenDitingNoData, + RerankTrainSuggestionEnum.rerankTrainsetGenDitingNoData + ); + throw new TrainsetGenerationRetriableError(error); + } + + // 7. Update trainset status to ready + await MongoRerankTrainset.updateOne( + { _id: trainsetId }, + { + status: RerankTrainsetStatusEnum.ready, + updateTime: new Date() + } + ); + + addLog.info('Generated rerank train data from datasets', { + trainsetId, + datasetCount: targetDatasetIds.length, + dataCount: totalGenerated + }); +}; diff --git a/packages/service/core/train/rerank/data/schema.ts b/packages/service/core/train/rerank/data/schema.ts new file mode 100644 index 000000000000..3bef30a0c601 --- /dev/null +++ b/packages/service/core/train/rerank/data/schema.ts @@ -0,0 +1,78 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { RerankTrainsetDataSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { RerankTrainDataSourceEnum } from '@fastgpt/global/core/train/rerank/constants'; + +/** Rerank trainset data schema */ +const RerankTrainsetDataSchema = new connectionMongo.Schema({ + trainsetId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'rerank_trainset', + required: true + }, + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + query: { + type: String, + required: true + }, + positiveDocs: { + type: [String], + required: true + }, + negativeDocs: { + type: [String], + required: true + }, + source: { + type: String, + enum: Object.values(RerankTrainDataSourceEnum), + required: true + }, + metadata: { + type: { + sourceInfo: { + datasetInfo: { + dataId: String, + datasetId: String + }, + chatLogInfo: { + chatId: String, + itemIds: [String] + }, + manualInfo: { + creator: String, + createdAt: Date, + reason: String + } + }, + generateConfig: { + sampleSize: Number, + forceRegenerate: Boolean, + minNegativeSamples: Number, + maxNegativeSamples: Number, + includeOriginalQ: Boolean + } + }, + required: true + }, + createTime: { + type: Date, + default: () => new Date() + } +}); + +// Indexes +RerankTrainsetDataSchema.index({ trainsetId: 1 }); +RerankTrainsetDataSchema.index({ trainsetId: 1, createTime: -1 }); +RerankTrainsetDataSchema.index({ teamId: 1, createTime: -1 }); // Replaced appId index: query by team dimension +RerankTrainsetDataSchema.index({ teamId: 1 }); +RerankTrainsetDataSchema.index({ source: 1 }); +RerankTrainsetDataSchema.index({ trainsetId: 1, source: 1, createTime: -1 }); + +export const MongoRerankTrainsetData = getMongoModel( + 'rerank_trainset_data', + RerankTrainsetDataSchema +); diff --git a/packages/service/core/train/rerank/data/worker.ts b/packages/service/core/train/rerank/data/worker.ts new file mode 100644 index 000000000000..2e467fa198e7 --- /dev/null +++ b/packages/service/core/train/rerank/data/worker.ts @@ -0,0 +1,159 @@ +import { getWorker, QueueNames } from '../../../../common/bullmq'; +import { addLog } from '../../../../common/system/log'; +import { type RerankTrainDataGenerateJobData } from './mq'; +import { rerankTrainDataGenerateProcessor } from './processor'; +import { trainEnv } from '../../common/env'; +import { + TrainsetGenerationUnrecoverableError, + TrainsetGenerationRetriableError +} from '../../common/errors'; +import { MongoRerankTrainset } from '../trainset/schema'; +import { RerankTrainsetStatusEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/rerank/error'; +import { createRerankEnhancedError } from '../utils'; + +export function initRerankTrainDataWorker() { + const worker = getWorker( + QueueNames.rerankTrainDataGenerate, + rerankTrainDataGenerateProcessor, + { + stalledInterval: trainEnv.TRAIN_WORKER_STALLED_INTERVAL, + maxStalledCount: + global.systemEnv?.trainConfig?.maxStalledCount || trainEnv.TRAIN_WORKER_MAX_STALLED_COUNT, + concurrency: + global.systemEnv?.trainConfig?.dataGenerateConcurrency || + trainEnv.TRAIN_DATA_GENERATE_CONCURRENCY + } + ); + + worker.on('active', (job) => { + if (job?.data) { + const { trainsetId, datasetIds } = job.data; + addLog.info('[RerankTrainData] Generation task started', { + jobId: job.id, + trainsetId, + datasetIds + }); + } + }); + + worker.on('completed', (job) => { + if (job?.data) { + const { trainsetId } = job.data; + addLog.info('[RerankTrainData] Generation task completed', { + jobId: job.id, + trainsetId + }); + } + }); + + worker.on('stalled', async (jobId: string) => { + addLog.warn('[RerankTrainData] Generation task stalled, will be retried', { + jobId + }); + }); + + worker.on('failed', async (job, error) => { + if (job?.data) { + const { trainsetId } = job.data; + + // Extract structured error information + let errorMsg: EnhancedErrorMessage; + + if ( + error instanceof TrainsetGenerationUnrecoverableError || + error instanceof TrainsetGenerationRetriableError + ) { + // Use structured error from custom error classes + errorMsg = error.enhancedError; + + addLog.error('[RerankTrainData] Generation task failed', { + jobId: job.id, + trainsetId, + errorType: error.name, + errorMsg, // Log complete structured error + attemptsMade: job.attemptsMade, + maxAttempts: job.opts.attempts + }); + } else { + // Unknown error, construct basic structured error + errorMsg = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankUnknownError, + RerankTrainSuggestionEnum.rerankUnknownError, + error.stack + ); + + addLog.error('[RerankTrainData] Generation task failed with unknown error', { + jobId: job.id, + trainsetId, + error: error.message, + stack: error.stack, + attemptsMade: job.attemptsMade, + maxAttempts: job.opts.attempts + }); + } + + // Only persist errorMsg if this is an unrecoverable error or final retry + const isUnrecoverable = error instanceof TrainsetGenerationUnrecoverableError; + const isFinalRetry = job.attemptsMade >= (job.opts.attempts ?? 0); + + if (isUnrecoverable || isFinalRetry) { + try { + await MongoRerankTrainset.updateOne( + { _id: trainsetId }, + { + status: RerankTrainsetStatusEnum.error, + errorMsg, // Save structured object + updateTime: new Date() + } + ); + + addLog.info( + '[RerankTrainData] Trainset status updated to error in MongoDB (unrecoverable or final retry)', + { + trainsetId, + isUnrecoverable, + isFinalRetry, + attemptsMade: job.attemptsMade + } + ); + } catch (updateError) { + addLog.error('[RerankTrainData] Failed to update trainset status in MongoDB', { + trainsetId, + updateError: (updateError as Error).message + }); + } + } else { + // Retriable error with remaining attempts - reset status to pending for retry + try { + await MongoRerankTrainset.updateOne( + { _id: trainsetId }, + { + status: RerankTrainsetStatusEnum.pending, + updateTime: new Date() + } + ); + + addLog.info('[RerankTrainData] Retriable error, status reset to pending for retry', { + trainsetId, + attemptsMade: job.attemptsMade, + remainingAttempts: (job.opts.attempts ?? 0) - job.attemptsMade + }); + } catch (updateError) { + addLog.error('[RerankTrainData] Failed to reset trainset status for retry', { + trainsetId, + updateError: (updateError as Error).message + }); + } + } + } + }); + + addLog.info('[RerankTrainData] Worker created successfully'); + return worker; +} diff --git a/packages/service/core/train/rerank/external/index.ts b/packages/service/core/train/rerank/external/index.ts new file mode 100644 index 000000000000..0205d3d1116f --- /dev/null +++ b/packages/service/core/train/rerank/external/index.ts @@ -0,0 +1,50 @@ +import { synthesizeEvalData, callLLMJudge } from '../../common/external/diting'; +import type { + DiTingSyntheticEvalDataRequest, + DiTingSyntheticEvalDataResponse, + DiTingLLMJudgeRequest, + DiTingLLMJudgeResponse +} from '../../common/external/diting'; + +const RERANK_SYNTHESIZER_NAME = 'eval_qa_pairs_synthesizer'; + +export type RerankSyntheticEvalDataRequest = Omit< + DiTingSyntheticEvalDataRequest, + 'synthesizerConfig' +>; + +export async function synthesizeRerankEvalData( + request: RerankSyntheticEvalDataRequest +): Promise { + return synthesizeEvalData({ + ...request, + synthesizerConfig: { synthesizerName: RERANK_SYNTHESIZER_NAME } + }); +} + +/** Call DiTing LLM judge to determine truly relevant chunks for a query */ +export type RerankLLMJudgeRequest = DiTingLLMJudgeRequest; +export type RerankLLMJudgeResponse = DiTingLLMJudgeResponse; + +export async function judgeRelevantChunks( + request: DiTingLLMJudgeRequest +): Promise { + return callLLMJudge(request); +} + +// SFT Bridge - re-export from common package +export { + createSFTTask, + querySFTTaskStatus, + deleteSFTTask, + SFTTaskStatus +} from '../../common/external/sftbridge'; + +export type { + CreateSFTTaskRequest, + CreateSFTTaskResponse, + QuerySFTTaskStatusRequest, + QuerySFTTaskStatusResponse, + DeleteSFTTaskRequest, + DeleteSFTTaskResponse +} from '../../common/external/sftbridge'; diff --git a/packages/service/core/train/rerank/index.ts b/packages/service/core/train/rerank/index.ts new file mode 100644 index 000000000000..0906cd3ae7f2 --- /dev/null +++ b/packages/service/core/train/rerank/index.ts @@ -0,0 +1,17 @@ +import { addLog } from '../../../common/system/log'; +import { initRerankTrainDataWorker } from './data/worker'; +import { initRerankTrainTaskWorker } from './task/worker'; + +/** Initialize all Rerank training module workers */ +export const initRerankTrainWorkers = () => { + addLog.info('Init Rerank Train Workers...'); + + initRerankTrainDataWorker(); + initRerankTrainTaskWorker(); +}; + +// Export all public functions and types +export * from './data/controller'; +export * from './data/schema'; +export * from './task/controller'; +export * from './task/schema'; diff --git a/packages/service/core/train/rerank/model/controller.ts b/packages/service/core/train/rerank/model/controller.ts new file mode 100644 index 000000000000..d29f1e363191 --- /dev/null +++ b/packages/service/core/train/rerank/model/controller.ts @@ -0,0 +1,173 @@ +import { MongoSystemModel } from '../../../ai/config/schema'; +import { updatedReloadSystemModel } from '../../../ai/config/utils'; +import { addLog } from '../../../../common/system/log'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/model'; +import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import { getModelProvider } from '../../../app/provider/controller'; +import { + deleteTunedModelChannel, + createTunedModelChannel, + waitForChannelAvailable +} from '../task/helpers/channel'; +import { MongoRerankTrainTask } from '../task/schema'; + +/** + * Create rerank model configuration + * + * Architecture: Model config contains metadata only, channel contains access credentials. + * Order: 1) Create model config, 2) Create channel, 3) Poll until channel is available. + * + * @param params.name - Display name for the tuned model + * @param params.endpoint - Tuned model endpoint (stored in channel, not model config) + * @param params.isActive - Whether to activate the model immediately + * @param params.charsPointsPrice - Inherited from base model + * @param params.maxToken - Inherited from base model (query + single document token limit) + * @returns Model configuration object ID + */ +export async function createRerankModelConfig(params: { + name: string; + endpoint: { + base_url: string; + api_key: string; + model: string; + }; + isActive: boolean; + tmbId: string; + teamId: string; + charsPointsPrice?: number; + maxToken?: number; + instruction?: string; + taskId: string; +}): Promise { + const { name, endpoint, isActive, tmbId, teamId, charsPointsPrice, maxToken, instruction } = + params; + const model = endpoint.model; + const channelName = `${model}-ch`; + + const modelConfig: RerankModelItemType = { + id: '', + provider: getModelProvider('Sangfor AICP').id, + model, + name, + isActive: isActive ?? true, + isCustom: true, + isTuned: true, + type: ModelTypeEnum.rerank, + charsPointsPrice: charsPointsPrice ?? 0, + maxToken: maxToken ?? 3000, + instruction + }; + + const task = await MongoRerankTrainTask.findById( + params.taskId, + 'checkpoint.data.registering.tunedModelId' + ).lean(); + const existingModelId = task?.checkpoint?.data?.registering?.tunedModelId; + + const { id: _, ...configFields } = modelConfig; + + const result = existingModelId + ? await MongoSystemModel.findOneAndUpdate( + { _id: existingModelId }, + { + ...configFields, + tmbId, + teamId, + isShared: false + }, + { + new: true + } + ) + : await MongoSystemModel.create({ + ...configFields, + tmbId, + teamId, + isShared: false + }); + + if (!result) { + throw new Error('Failed to create or update model config'); + } + + const objectId = String(result._id); + + if (!existingModelId) { + await MongoRerankTrainTask.updateOne( + { _id: params.taskId }, + { + 'checkpoint.data.registering.tunedModelId': objectId, + updateTime: new Date() + } + ); + } + + addLog.info('Created or updated rerank model config', { + model, + name, + objectId, + isActive + }); + + // Step 2: Create or update AI Proxy channel (idempotent) + // Channel stores access credentials (base_url and api_key) + await createTunedModelChannel({ + channelName, + endpoint, + modelConfigId: model + }); + + // Reload system models + await updatedReloadSystemModel(); + addLog.info('Reloaded system models', { model, objectId }); + + // Step 3: Poll until AI Proxy channel is available + // New channels may not be immediately accessible after creation + await waitForChannelAvailable({ model, endpoint }); + + return objectId; +} + +/** + * Delete rerank model configuration + * + * Deletes model from AI Proxy channel and FastGPT system. + * SFT Bridge resource cleanup is handled separately by deleteRerankTrainTask. + * Order: 1) Delete channel first, 2) Delete model config + reload. + * + * @param modelId - Model's MongoDB _id (platform-unique model ID) + * @returns Promise that resolves when deletion is complete + * @throws {Error} When channel or model deletion fails + */ +export async function deleteRerankModelConfig(modelId: string): Promise { + addLog.info('Deleting rerank model config', { modelId }); + + // Look up the model config to get the model name (needed for channel operations) + const doc = await MongoSystemModel.findById(modelId).lean(); + if (!doc) { + addLog.warn('No model config found to delete in FastGPT', { modelId }); + return; + } + const modelName = doc.model; + + // Step 1: Delete AI Proxy channel first (contains access credentials) + await deleteTunedModelChannel(modelName); + addLog.info('Deleted AI Proxy channel', { modelId, modelName }); + + // Step 2: Delete model configuration from FastGPT database + const deleteResult = await MongoSystemModel.deleteOne({ _id: modelId }); + + if (deleteResult.deletedCount === 0) { + addLog.warn('No model config found to delete in FastGPT', { modelId }); + } else { + addLog.info('Deleted rerank model config from FastGPT', { + modelId, + modelName, + deletedCount: deleteResult.deletedCount + }); + + // Reload system models after deletion + await updatedReloadSystemModel(); + addLog.info('Reloaded system models', { modelId }); + } +} diff --git a/packages/service/core/train/rerank/task/controller.ts b/packages/service/core/train/rerank/task/controller.ts new file mode 100644 index 000000000000..75391492bc2c --- /dev/null +++ b/packages/service/core/train/rerank/task/controller.ts @@ -0,0 +1,495 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { MongoRerankTrainTask } from './schema'; +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import type { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { + RerankTrainMethodEnum, + RerankTrainTaskStatusEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { addLog } from '../../../../common/system/log'; +import { getRerankModelById } from '../../../ai/model'; +import type { ClientSession } from '../../../../common/mongo'; +import { MongoEvalDatasetCollection } from '../../../evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '../../../evaluation/dataset/evalDatasetDataSchema'; +import { buildModelEndpoint } from '../utils'; +import { deleteRerankModelConfig } from '../model/controller'; +import { getRerankTrainDataDir } from '../constants'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { MongoRerankTrainset } from '../trainset/schema'; +import { MongoRerankTrainsetData } from '../data/schema'; +import { deleteSFTTask } from '../external'; +import { removeRerankTrainTaskJob } from './mq'; +import { MongoSystemModel } from '../../../ai/config/schema'; +import { setTrainTaskAbortSignal, type TrainTaskAbortReason } from '../../common/task-abort-signal'; + +/** + * Create rerank training task + * + * Key logic: + * 1. Accepts baseModelId directly + * 2. Calls getRerankModelById(baseModelId) to build baseModelEndpoint + * 3. trainsetId is optional (auto mode: written by generate_trainset stage) + * 4. evalDatasetId and datasetIds are optional based on mode + * + * @param params - Task creation parameters + * @returns Task ID + * @throws {RerankTrainErrEnum} When base model not found + */ +export async function createRerankTrainTask(params: { + baseModelId: string; + trainsetId?: string; + evalDatasetId?: string; + datasetIds?: string[]; + newModelName?: string; + teamId: string; + tmbId: string; + name?: string; + trainMethod?: string; + generateConfig?: { + sampleSize?: number; + weights?: Record; + indexType: string; + indexMultiStrategy?: 1 | 2; + negativeStrategy?: 1 | 2 | 3 | 4; + minNegativeSamples?: number; + maxNegativeSamples?: number; + }; +}): Promise { + const { + baseModelId, + trainsetId, + evalDatasetId, + datasetIds, + newModelName, + teamId, + tmbId, + name, + trainMethod, + generateConfig + } = params; + + const rerankModel = getRerankModelById(baseModelId); + if (!rerankModel) { + return Promise.reject(RerankTrainErrEnum.rerankTaskModelNotFound); + } + + if (rerankModel.isActive === false) { + return Promise.reject(RerankTrainErrEnum.rerankTaskBaseModelDisabled); + } + + const baseModelEndpoint = buildModelEndpoint(rerankModel); + + addLog.info('Creating rerank train task', { + baseModelId, + baseModelEndpoint + }); + + const [doc] = await MongoRerankTrainTask.create([ + { + trainsetId: trainsetId || undefined, + evalDatasetId: evalDatasetId || undefined, + datasetIds: datasetIds?.length ? datasetIds : undefined, + newModelName: newModelName || undefined, + generateConfig: generateConfig || undefined, + teamId, + tmbId, + name: name || `Rerank Training - ${new Date().toLocaleDateString()}`, + baseModelId, + baseModelEndpoint, + trainMethod: trainMethod || RerankTrainMethodEnum.lora, + status: RerankTrainTaskStatusEnum.pending, + checkpoint: { + stage: null, + data: {}, + stageEndTime: {} + } + } + ]); + + addLog.info('Created rerank train task', { + baseModelId, + trainsetId, + taskId: String(doc._id) + }); + + return doc.toObject() as RerankTrainTaskSchemaType; +} + +/** Update task status */ +export async function updateRerankTaskStatus( + taskId: string, + status: `${RerankTrainTaskStatusEnum}` +): Promise { + const updateData: { + status: `${RerankTrainTaskStatusEnum}`; + updateTime: Date; + finishTime?: Date; + } = { + status, + updateTime: new Date() + }; + + if ( + status === RerankTrainTaskStatusEnum.completed || + status === RerankTrainTaskStatusEnum.cancelled || + status === RerankTrainTaskStatusEnum.failed + ) { + updateData.finishTime = new Date(); + } + + await MongoRerankTrainTask.updateOne({ _id: taskId }, updateData); + + addLog.info('Updated task status', { taskId, status }); +} + +/** Update checkpoint stage and record completion time */ +export async function updateRerankCheckpointStage( + taskId: string, + stage: `${RerankTaskCheckpointStageEnum}` +): Promise { + await MongoRerankTrainTask.updateOne( + { _id: taskId }, + { + 'checkpoint.stage': stage, + [`checkpoint.stageEndTime.${stage}`]: new Date(), + updateTime: new Date() + } + ); + + addLog.info('Updated checkpoint stage completion', { taskId, stage }); +} + +/** + * Update checkpoint data (fine-grained update) + * + * Supports two modes for updating checkpoint data of a specific stage: + * 1. Full replacement (merge=false): Replaces entire stage data + * 2. Partial merge (merge=true): Updates only specific fields within stage + * + * @param taskId - Task ID + * @param stage - Checkpoint stage name + * @param data - Data to update + * @param merge - Whether to merge with existing data (default: false) + */ +export async function updateRerankCheckpointData( + taskId: string, + stage: + | 'generate_trainset' + | 'generate_evaldataset' + | 'eval_basemodel' + | 'finetuning' + | 'registering' + | 'eval_tunedmodel' + | 'llm_judge', + data: Record, + merge: boolean = false +): Promise { + if (merge) { + const updateFields: Record = { updateTime: new Date() }; + for (const [key, value] of Object.entries(data)) { + updateFields[`checkpoint.data.${stage}.${key}`] = value; + } + await MongoRerankTrainTask.updateOne({ _id: taskId }, updateFields); + } else { + await MongoRerankTrainTask.updateOne( + { _id: taskId }, + { + [`checkpoint.data.${stage}`]: data, + updateTime: new Date() + } + ); + } + + addLog.info('Updated checkpoint data', { taskId, stage, merge, keys: Object.keys(data) }); +} + +/** Get rerank training task by ID */ +export async function getRerankTrainTask( + taskId: string +): Promise { + return MongoRerankTrainTask.findById(taskId).lean(); +} + +/** + * Delete rerank training task with unified artifact cleanup + * + * Cleanup order: + * 1. FastGPT model config + AI Proxy channel (registering stage artifact) + * 2. SFT Bridge resources (finetuning stage artifact, async non-blocking) + * 3. Eval dataset collections + data (generate_evaldataset stage artifact) + * 4. Auto-generated trainset + data (generate_trainset stage artifact, auto mode only) + * 5. Task record + * 6. Temp JSONL files (async non-blocking) + * + * @param taskId - Task ID to delete + * @param session - Optional MongoDB session for transaction + * @throws {Error} When task not found + */ +export async function deleteRerankTrainTask( + taskId: string, + options?: { deleteModel?: boolean }, + session?: ClientSession +): Promise { + const task = await MongoRerankTrainTask.findById(taskId, null, { session }).lean(); + if (!task) { + return Promise.reject(RerankTrainErrEnum.rerankTaskNotExist); + } + + const tempFilePath = task.result?.trainDatasetFilePath; + + // 1. Signal active worker, then remove BullMQ job before any data cleanup + await setRerankTrainTaskAbortSignal(taskId, 'deleted'); + await removeRerankTrainTaskJob(taskId, { forceCleanActiveJobs: true }); + + // 2. Clean up FastGPT model config + AI Proxy channel (registering stage artifact) + const tunedModelId = task.checkpoint?.data?.registering?.tunedModelId; + if (tunedModelId && options?.deleteModel !== false) { + addLog.info('Deleting tuned model config associated with task', { + taskId, + tunedModelId + }); + + try { + // Delete model config and channel only; SFT Bridge is handled separately below + await deleteRerankModelConfig(tunedModelId); + addLog.info('Successfully deleted tuned model config', { taskId, tunedModelId }); + } catch (error) { + addLog.warn('Failed to delete tuned model config, continuing with task deletion', { + taskId, + tunedModelId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // 3. Clean up SFT Bridge resources (async non-blocking) + const sftTaskId = task.checkpoint?.data?.finetuning?.sftTaskId; + if (sftTaskId) { + setImmediate(() => { + deleteSFTTask({ taskId: sftTaskId }).catch((err) => { + addLog.warn('Failed to delete SFT task from SFT Bridge', { + taskId, + sftTaskId, + error: String(err) + }); + }); + }); + addLog.info('Triggered async SFT task deletion', { taskId, sftTaskId }); + } + + // 4. Clean up auto-generated eval dataset + data (only if this task auto-generated it) + // autoGenerated=true is written by generate_evaldataset stage in auto mode. + // Exact mode: stage returns false, nothing is created, so nothing to delete. + // Mixed mode (trainsetId + evalDatasetId + datasetIds): exact mode skips generation → autoGenerated=false → safe. + if (task.checkpoint?.data?.generate_evaldataset?.autoGenerated) { + const evalCollections = await MongoEvalDatasetCollection.find( + { 'metadata.taskId': taskId }, + null, + { session } + ).lean(); + + if (evalCollections.length > 0) { + const collectionIds = evalCollections.map((col) => col._id); + + const deletedDataResult = await MongoEvalDatasetData.deleteMany( + { evalDatasetCollectionId: { $in: collectionIds } }, + { session } + ); + addLog.info('Deleted eval dataset data for task', { + taskId, + deletedCount: deletedDataResult.deletedCount + }); + + const deletedCollectionResult = await MongoEvalDatasetCollection.deleteMany( + { _id: { $in: collectionIds } }, + { session } + ); + addLog.info('Deleted eval dataset collections for task', { + taskId, + deletedCount: deletedCollectionResult.deletedCount + }); + } + } + + // 5. Clean up auto-generated trainset + data (only if this task auto-generated it) + // autoGenerated=true is written by generate_trainset stage in auto mode. + // Exact mode: stage returns false → safe from accidental deletion even if trainsetId is present. + const autoTrainsetId = + task.checkpoint?.data?.generate_trainset?.autoGenerated && task.trainsetId + ? String(task.trainsetId) + : undefined; + if (autoTrainsetId) { + try { + const deletedTrainData = await MongoRerankTrainsetData.deleteMany( + { trainsetId: autoTrainsetId }, + { session } + ); + await MongoRerankTrainset.deleteOne({ _id: autoTrainsetId }, { session }); + addLog.info('Deleted auto-generated trainset and data', { + taskId, + trainsetId: autoTrainsetId, + deletedDataCount: deletedTrainData.deletedCount + }); + } catch (error) { + addLog.warn('Failed to delete auto-generated trainset, continuing', { + taskId, + trainsetId: autoTrainsetId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // 6. Delete task record + await MongoRerankTrainTask.deleteOne({ _id: taskId }, { session }); + addLog.info('Deleted rerank train task', { taskId }); + + // 7. Clean up temp files (async non-blocking) + if (tempFilePath) { + setImmediate(() => { + cleanupRerankTempFiles(tempFilePath).catch((error) => { + addLog.warn('Failed to cleanup temp files after task deletion', { + taskId, + tempFilePath, + error: (error as Error).message + }); + }); + }); + } +} + +/** Cancel rerank training task */ +export async function cancelRerankTrainTask(taskId: string): Promise { + const task = await MongoRerankTrainTask.findById(taskId).lean(); + if (!task) { + return Promise.reject(RerankTrainErrEnum.rerankTaskNotExist); + } + + if ( + task.status === RerankTrainTaskStatusEnum.completed || + task.status === RerankTrainTaskStatusEnum.failed || + task.status === RerankTrainTaskStatusEnum.cancelled + ) { + return Promise.reject(RerankTrainErrEnum.rerankTaskCannotCancel); + } + + // Signal active worker, then remove BullMQ job (force clean if active) + await setRerankTrainTaskAbortSignal(taskId, 'cancelled'); + await removeRerankTrainTaskJob(taskId, { forceCleanActiveJobs: true }); + + // Cancel SFT task if finetuning has started (async non-blocking) + const sftTaskId = task.checkpoint?.data?.finetuning?.sftTaskId; + if (sftTaskId) { + setImmediate(() => { + deleteSFTTask({ taskId: sftTaskId }).catch((err) => { + addLog.warn('Failed to cancel SFT task during task cancellation', { + taskId, + sftTaskId, + error: String(err) + }); + }); + }); + addLog.info('Triggered async SFT task cancellation', { taskId, sftTaskId }); + } + + await updateRerankTaskStatus(taskId, RerankTrainTaskStatusEnum.cancelled); + + addLog.info('Cancelled rerank train task', { taskId }); +} + +/** + * Clean up temporary files related to training task + * + * @param filePath - Optional temp file path to delete + * @param taskId - Optional task ID to generate file pattern for cleanup + */ +export async function cleanupRerankTempFiles(filePath?: string, taskId?: string): Promise { + if (!filePath && !taskId) { + addLog.warn('Both filePath and taskId are not provided for cleanup'); + return; + } + + try { + if (filePath) { + await fs.unlink(filePath); + addLog.info('Cleaned up temp file', { filePath }); + } else if (taskId) { + // Use configurable training data directory + const trainDataDir = getRerankTrainDataDir(); + const files = await fs.readdir(trainDataDir); + const tempFilePattern = new RegExp(`^rerank_train_${taskId}_\\d+\\.jsonl$`); + + for (const file of files) { + if (tempFilePattern.test(file)) { + const targetPath = path.join(trainDataDir, file); + await fs.unlink(targetPath); + addLog.info('Cleaned up temp file', { taskId, filePath: targetPath }); + } + } + } + } catch (error) { + addLog.warn('Failed to cleanup temp files', { + filePath, + taskId, + error: (error as Error).message + }); + } +} + +async function setRerankTrainTaskAbortSignal( + taskId: string, + reason: TrainTaskAbortReason +): Promise { + try { + await setTrainTaskAbortSignal({ type: 'rerank', taskId, reason }); + } catch (error) { + addLog.warn('Failed to set rerank train task abort signal', { + taskId, + reason, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +/** + * Traverse the training task chain upward by tunedModelId. + * Returns tasks in order from the given tunedModelId up to the root base model. + * Includes cycle detection and depth limit to prevent infinite loops. + */ +export async function resolveRerankTasksByTunedModelId( + tunedModelId: string, + teamId: string +): Promise { + const result: RerankTrainTaskSchemaType[] = []; + const visited = new Set(); + let currentId = tunedModelId; + const MAX_DEPTH = 100; + + while (result.length < MAX_DEPTH) { + if (visited.has(currentId)) { + addLog.warn('Cycle detected in task chain traversal', { tunedModelId, currentId, teamId }); + break; + } + visited.add(currentId); + + const task = await MongoRerankTrainTask.findOne({ + teamId, + 'checkpoint.data.registering.tunedModelId': currentId + }).lean(); + + if (!task) break; + + result.push(task as unknown as RerankTrainTaskSchemaType); + currentId = task.baseModelId; + } + + if (result.length === MAX_DEPTH) { + addLog.warn('Max depth reached in task chain traversal', { + tunedModelId, + teamId, + resultCount: result.length + }); + } + + return result; +} diff --git a/packages/service/core/train/rerank/task/helpers/channel.ts b/packages/service/core/train/rerank/task/helpers/channel.ts new file mode 100644 index 000000000000..3a756f291d4c --- /dev/null +++ b/packages/service/core/train/rerank/task/helpers/channel.ts @@ -0,0 +1,406 @@ +import axios from 'axios'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../../common/env'; + +/** + * Create channel for finetuned model (idempotent) + * + * Creates a dedicated AI proxy channel for the finetuned model. + * This function is idempotent - if a channel for this model already exists, + * it will be updated with the new configuration. + * + * @param params - Channel creation parameters + * @param params.channelName - Channel name + * @param params.endpoint - Model endpoint configuration + * @param params.modelConfigId - Model configuration ID + * @throws {Error} When environment variables missing or channel operations fail + */ +export async function createTunedModelChannel(params: { + channelName: string; + endpoint: { + base_url: string; + model: string; + api_key: string; + }; + modelConfigId: string; +}): Promise { + const { channelName, endpoint, modelConfigId } = params; + + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + throw new Error('AIPROXY_API_ENDPOINT or AIPROXY_API_TOKEN environment variable is required'); + } + + try { + // Step 1: Check if channel already exists for this model + // Note: Use /api/channels/all endpoint with pagination (same as frontend) + const channelsListUrl = `${aiproxyUrl}/api/channels/all`; + const queryResponse = await axios.get(channelsListUrl, { + params: { + page: 1, + perPage: 1000 // Large enough to get all channels + }, + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + // AI Proxy response format: { success: boolean, data: ChannelInfoType[] } + if (!queryResponse.data?.success) { + addLog.warn('AI Proxy returned non-success response', { + response: queryResponse.data + }); + // Continue to create new channel + } else if (queryResponse.data.data && Array.isArray(queryResponse.data.data)) { + const channels = queryResponse.data.data; + const existingChannel = channels.find( + (ch: any) => ch.models && ch.models.includes(modelConfigId) + ); + + if (existingChannel) { + // Channel exists, update it to ensure configuration is current + const updateUrl = `${aiproxyUrl}/api/channel/${existingChannel.id}`; + const updateData = { + type: 50, + name: channelName, + base_url: endpoint.base_url, + models: [modelConfigId], + model_mapping: {}, + key: endpoint.api_key, + priority: 1 + }; + + const updateResponse = await axios.put(updateUrl, updateData, { + headers: { + Authorization: `Bearer ${aiproxyToken}`, + 'Content-Type': 'application/json' + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (updateResponse.data?.success === false) { + throw new Error( + `Channel update failed: ${updateResponse.data.message || 'Unknown error'}` + ); + } + + addLog.info('Updated existing channel for model', { + channelId: existingChannel.id, + modelConfigId, + channelName + }); + return; + } + } + + // Step 2: Channel doesn't exist, create it + const channelApiUrl = `${aiproxyUrl}/api/channel/`; + const requestData = { + type: 50, + name: channelName, + base_url: endpoint.base_url, + models: [modelConfigId], + model_mapping: {}, + key: endpoint.api_key, + priority: 1 + }; + + const response = await axios.post(channelApiUrl, requestData, { + headers: { + Authorization: `Bearer ${aiproxyToken}`, + 'Content-Type': 'application/json' + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (!response.data) { + throw new Error('Empty response from aiproxy service'); + } + + if (response.data.success === false) { + const errorMsg = response.data.message || 'Unknown error'; + throw new Error(`Channel creation failed: ${errorMsg}`); + } + + addLog.info('Created new channel for model', { + modelConfigId, + channelName + }); + + return; + } catch (error) { + let errorMessage = 'Unknown error occurred'; + + if (axios.isAxiosError(error)) { + if (error.response) { + const errorData = error.response.data; + if (errorData && typeof errorData === 'object') { + errorMessage = errorData.message || errorData.error || 'Server error'; + } else { + errorMessage = `Server error: ${error.response.status}`; + } + } else if (error.request) { + errorMessage = 'No response from aiproxy service'; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); + } + + addLog.error('Failed to create or update tuned model channel', { + channelName, + error: errorMessage, + aiproxyUrl: aiproxyUrl ? aiproxyUrl.replace(/\/$/, '') + '/***' : 'not set', + hasToken: !!aiproxyToken + }); + + throw new Error(`Failed to create or update channel: ${errorMessage}`); + } +} + +/** + * Delete channel for finetuned model + * + * Deletes the dedicated AI proxy channel for the finetuned model by model name. + * Since we don't store channel ID, we query channels by model name first, then delete. + * + * @param modelConfigId - Model configuration ID (used to identify the channel) + * @throws {Error} When environment variables missing or channel deletion fails + */ +export async function deleteTunedModelChannel(modelConfigId: string): Promise { + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + addLog.warn('AIPROXY environment variables not configured, skipping channel deletion', { + modelConfigId + }); + return; + } + + try { + // Query channels to find the one with this model + // Note: Use /api/channels/all endpoint with pagination (same as frontend) + const channelsListUrl = `${aiproxyUrl}/api/channels/all`; + const queryResponse = await axios.get(channelsListUrl, { + params: { + page: 1, + perPage: 1000 // Large enough to get all channels + }, + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + // AI Proxy response format: { success: boolean, data: ChannelInfoType[] } + if (!queryResponse.data?.success) { + addLog.warn('AI Proxy returned non-success response (delete)', { + response: queryResponse.data + }); + return; + } + + if (!queryResponse.data.data || !Array.isArray(queryResponse.data.data)) { + addLog.warn('No channel data returned from aiproxy', { modelConfigId }); + return; + } + + // Find channel(s) that contain this model + const channels = queryResponse.data.data; + const matchingChannels = channels.filter( + (ch: any) => ch.models && ch.models.includes(modelConfigId) + ); + + if (matchingChannels.length === 0) { + addLog.info('No matching channel found for model', { modelConfigId }); + return; + } + + // Delete each matching channel + for (const channel of matchingChannels) { + const channelId = channel.id; + const deleteUrl = `${aiproxyUrl}/api/channel/${channelId}`; + + const deleteResponse = await axios.delete(deleteUrl, { + headers: { + Authorization: `Bearer ${aiproxyToken}` + }, + timeout: trainEnv.TRAIN_CHANNEL_CREATE_TIMEOUT + }); + + if (deleteResponse.data?.success === false) { + addLog.warn('Channel deletion returned non-success status', { + channelId, + modelConfigId, + message: deleteResponse.data?.message + }); + } else { + addLog.info('Successfully deleted channel', { + channelId, + modelConfigId + }); + } + } + } catch (error) { + let errorMessage = 'Unknown error occurred'; + + if (axios.isAxiosError(error)) { + if (error.response) { + const errorData = error.response.data; + if (errorData && typeof errorData === 'object') { + errorMessage = errorData.message || errorData.error || 'Server error'; + } else { + errorMessage = `Server error: ${error.response.status}`; + } + } else if (error.request) { + errorMessage = 'No response from aiproxy service'; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); + } + + addLog.error('Failed to delete tuned model channel', { + modelConfigId, + error: errorMessage + }); + + throw new Error(`Failed to delete channel: ${errorMessage}`); + } +} + +/** + * Wait for AI Proxy channel to become available by polling rerank API + * + * After creating a channel, it may not be immediately available. This function + * polls the rerank API until a successful response is received or timeout is reached. + * + * @param params - Parameters for testing channel availability + * @param params.model - Model name to test + * @param params.endpoint - Model endpoint configuration + * @throws {Error} When channel does not become available within timeout period + */ +export async function waitForChannelAvailable(params: { + model: string; + endpoint: { + base_url: string; + model: string; + api_key: string; + }; +}): Promise { + const { model, endpoint } = params; + + // Skip polling in mock mode — mock endpoint may not be a real AI Proxy channel + if (trainEnv.SFT_BRIDGE_MOCK_ENABLE) { + addLog.info('Channel availability check skipped (mock mode enabled)', { model }); + return; + } + + const aiproxyUrl = trainEnv.AIPROXY_API_ENDPOINT; + const aiproxyToken = trainEnv.AIPROXY_API_TOKEN; + + if (!aiproxyUrl || !aiproxyToken) { + throw new Error('AIPROXY_API_ENDPOINT or AIPROXY_API_TOKEN environment variable is required'); + } + + const rerankUrl = `${aiproxyUrl}/v1/rerank`; + const authorization = `Bearer ${aiproxyToken}`; + + const pollInterval = trainEnv.TRAIN_CHANNEL_AVAILABILITY_POLL_INTERVAL; + const maxDuration = trainEnv.TRAIN_CHANNEL_AVAILABILITY_MAX_DURATION; + const startTime = Date.now(); + + addLog.info('Starting channel availability polling', { + model, + pollInterval, + maxDuration: `${maxDuration / 1000 / 60} minutes` + }); + + let pollCount = 0; + let lastError: string | undefined; + + while (Date.now() - startTime < maxDuration) { + pollCount++; + + try { + const response = await axios.post( + rerankUrl, + { + model: endpoint.model, + query: 'test', + documents: ['test'] + }, + { + headers: { + Authorization: authorization, + 'Content-Type': 'application/json' + }, + timeout: 30000 + } + ); + + // Validate response - rerank API should return results array + const data = response.data; + if (!data || !Array.isArray(data.results)) { + throw new Error(`Invalid rerank response: missing results array`); + } + + addLog.info('Channel is now available', { + model, + pollCount, + elapsed: `${(Date.now() - startTime) / 1000}s` + }); + return; + } catch (error) { + // Extract error message from axios error or generic error + let errorMessage: string; + if (axios.isAxiosError(error)) { + if (error.response?.data) { + // AI Proxy error response + const errorData = error.response.data; + errorMessage = + typeof errorData === 'object' + ? errorData.message || errorData.error || JSON.stringify(errorData) + : String(errorData); + } else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { + errorMessage = `Connection failed: ${error.code}`; + } else { + errorMessage = error.message; + } + } else { + errorMessage = error instanceof Error ? error.message : String(error); + } + + lastError = errorMessage; + addLog.debug('Channel not yet available, will retry', { + model, + pollCount, + error: lastError + }); + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + const elapsed = `${(Date.now() - startTime) / 1000 / 60} minutes`; + addLog.error('Channel availability polling timeout', { + model, + pollCount, + elapsed, + lastError + }); + + throw new Error( + `Channel for model "${model}" did not become available within ${elapsed}. Last error: ${lastError}` + ); +} diff --git a/packages/service/core/train/rerank/task/helpers/dataset-search.ts b/packages/service/core/train/rerank/task/helpers/dataset-search.ts new file mode 100644 index 000000000000..2fe41720d470 --- /dev/null +++ b/packages/service/core/train/rerank/task/helpers/dataset-search.ts @@ -0,0 +1,134 @@ +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import type { RetrievalContextItem } from '@fastgpt/global/core/evaluation/dataset/type'; +import { trainEnv } from '../../../common/env'; +import { dispatchDatasetSearch } from '../../../../../core/workflow/dispatch/dataset/search'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { RerankMethodEnum, DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { addLog } from '../../../../../common/system/log'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { Types } from 'mongoose'; + +/** + * Perform dataset search and retrieve search results + * + * Note: This function is exported for unit testing only. + * + * For evaluation dataset generation, rerank is NOT used - only embedding search. + * Uses default search parameters (similarity, limit, searchMode=embedding). + * + * @param task - Training task + * @param datasetIds - List of dataset IDs to search + * @param query - Query question + * @returns Array of retrieval context items + */ +export async function performDatasetSearch( + task: RerankTrainTaskSchemaType, + datasetIds: string[], + query: string +): Promise { + const datasets = datasetIds.map((id) => ({ datasetId: id, avatar: '', name: '' })); + + addLog.debug('performDatasetSearch - Starting dataset search', { + taskId: task._id, + query, + datasetCount: datasets.length + }); + + const searchResponse = await dispatchDatasetSearch({ + mode: 'test', + timezone: 'Asia/Shanghai', + externalProvider: {}, + uid: task.tmbId, + variables: {}, + query: [], + stream: false, + maxRunTimes: trainEnv.TRAIN_MAX_SEARCH_RUN_TIMES, + chatId: '', + checkIsStopping: () => false, + workflowDispatchDeep: 0, + + runningAppInfo: { + id: new Types.ObjectId().toString(), + name: 'RerankTrainEvalSearch', + teamId: task.teamId, + tmbId: task.tmbId + }, + + runningUserInfo: { + username: '', + teamName: '', + memberName: '', + contact: '', + teamId: task.teamId, + tmbId: task.tmbId + }, + + histories: [], + chatConfig: {}, + + node: { + nodeId: 'dataset_search', + name: 'Dataset Search', + avatar: '', + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + showStatus: true, + inputs: [], + outputs: [] + }, + runtimeNodes: [], + runtimeNodesMap: new Map(), + runtimeEdges: [], + usagePush: () => {}, + + params: { + datasets, + similarity: trainEnv.TRAIN_SEARCH_SIMILARITY, + limit: trainEnv.TRAIN_SEARCH_LIMIT, + userChatInput: query, + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: undefined, + usingReRank: false, + rerankModelId: undefined, + rerankMethod: RerankMethodEnum.question, + collectionFilterMatch: '', + datasetSearchUsingExtensionQuery: false, // disable for ensuring the retrieval contexts stable + datasetSearchExtensionModelId: '', + datasetSearchExtensionBg: '' + } + }); + + // Prefer retrievalResults (top 20 results from retrieval stage, used for Assistant scenarios) + // Fall back to quoteQA (final top 10 results after filtering) + const nodeResponse = searchResponse[DispatchNodeResponseKeyEnum.nodeResponse] as any; + const retrievalResults = nodeResponse?.retrievalResults || searchResponse.data?.quoteQA || []; + + addLog.debug('performDatasetSearch - Search response structure', { + taskId: task._id, + hasNodeResponse: !!nodeResponse, + hasRetrievalResults: !!nodeResponse?.retrievalResults, + retrievalResultsCount: nodeResponse?.retrievalResults?.length || 0, + hasQuoteQA: !!searchResponse.data?.quoteQA, + quoteQACount: searchResponse.data?.quoteQA?.length || 0, + usedSource: nodeResponse?.retrievalResults ? 'retrievalResults' : 'quoteQA', + finalResultCount: retrievalResults.length + }); + + if (retrievalResults.length > 0) { + addLog.debug('performDatasetSearch - First 3 retrieval results sample', { + taskId: task._id, + sample: retrievalResults.slice(0, 3).map((item: any, index: number) => ({ + index, + id: item.id, + q: item.q?.substring(0, 50) + (item.q?.length > 50 ? '...' : ''), + a: item.a?.substring(0, 50) + (item.a?.length > 50 ? '...' : ''), + scoreTypes: item.score?.map((s: any) => s.type) || [], + retrievalRank: item.retrievalRank + })) + }); + } + + return retrievalResults.map((item: any) => ({ + id: item.id, + score: item.score + })); +} diff --git a/packages/service/core/train/rerank/task/helpers/evaluate-model.ts b/packages/service/core/train/rerank/task/helpers/evaluate-model.ts new file mode 100644 index 000000000000..e1c07d4bea37 --- /dev/null +++ b/packages/service/core/train/rerank/task/helpers/evaluate-model.ts @@ -0,0 +1,170 @@ +import type { RerankEvalResult } from '@fastgpt/global/core/train/rerank/type'; +import type { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { MongoDatasetData } from '../../../../../core/dataset/data/schema'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { getRerankModelById } from '../../../../ai/model'; +import { reRankRecall } from '../../../../ai/rerank'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { computeRankingMetrics } from '../../../common/metrics/rankingMetrics'; +import { pLimit } from '../../../common/utils'; +import { trainEnv } from '../../../common/env'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; + +const K_VALUES = [5, 10, 15]; + +/** + * Evaluate a rerank model on the given evaluation dataset + * + * Replaces the previous DiTing-based evaluation. + * For each query in the eval dataset: + * 1. Reads the pre-stored retrievalContextsFull (embedding search results from eval data generation) + * 2. Calls reRankRecall with the specific rerank model to reorder the candidates + * 3. Extracts the ordered document ID list from reranked results + * 4. Computes MRR, NDCG, MAP, Precision@K using computeRankingMetrics + * + * @param taskId - Training task ID (for logging) + * @param evalDatasetId - Evaluation dataset collection ID + * @param modelId - Model ID (RerankModelItemType.model) to evaluate + * @param stage - Checkpoint stage enum, used for error attribution + */ +export async function evaluateRerankModelHelper( + taskId: string, + evalDatasetId: string, + modelId: string, + stage: RerankTaskCheckpointStageEnum +): Promise<{ + evalResult: RerankEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Evaluate rerank model', { taskId, modelId, stage }); + + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evalDatasetId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createRerankEnhancedError( + stage, + RerankTrainErrEnum.rerankEvalDatasetEmptyBeforeEval, + RerankTrainSuggestionEnum.rerankEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const modelConfig = getRerankModelById(modelId); + if (!modelConfig) { + const enhancedError = createRerankEnhancedError( + stage, + RerankTrainErrEnum.rerankEvalModelNotFound, + RerankTrainSuggestionEnum.rerankEvalModelNotFound, + modelId + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Filter out eval items that have no candidate list (cannot evaluate when retrievalContextsFull is empty) + const validItems = evalDataItems.filter((item) => (item.retrievalContextsFull || []).length > 0); + + if (validItems.length === 0) { + addLog.warn('No valid eval items with retrievalContextsFull, returning zero metrics', { + taskId, + totalItems: evalDataItems.length + }); + return { + evalResult: computeRankingMetrics([], K_VALUES, 'rerank') as RerankEvalResult, + rankingResults: [] + }; + } + + // Batch fetch q/a text from original dataset data (retrievalContextsFull now only stores id+score) + const allCandidateIds = [ + ...new Set(validItems.flatMap((item) => (item.retrievalContextsFull || []).map((c) => c.id))) + ]; + const dataTextMap = new Map( + (await MongoDatasetData.find({ _id: { $in: allCandidateIds } }, 'q a').lean()).map((d) => [ + String(d._id), + d + ]) + ); + + // Run reranker for each query with bounded concurrency to avoid overwhelming the rerank API + const limit = pLimit(trainEnv.TRAIN_EVAL_CONCURRENCY); + const cases = await Promise.all( + validItems.map((item) => + limit(async () => { + const abortReason = await getTrainTaskAbortSignal({ type: 'rerank', taskId }); + if (abortReason === 'deleted') { + const enhancedError = createRerankEnhancedError( + stage, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createRerankEnhancedError( + stage, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const query = item.userInput; + const expectedIds = item.expectedContextIds || []; + const candidates = item.retrievalContextsFull || []; + + try { + const rerankResult = await reRankRecall({ + model: modelConfig, + query, + documents: candidates.map((c) => { + const doc = dataTextMap.get(c.id); + const text = [doc?.q, doc?.a].filter(Boolean).join('\n'); + return { id: c.id, text }; + }) + }); + + const rankedIds = (rerankResult.results || []).map((r) => r.id); + return { rankedIds, expectedIds }; + } catch (err) { + addLog.warn('Rerank recall failed for query, treating as no reorder', { + taskId, + query: query?.substring(0, 50), + error: err instanceof Error ? err.message : String(err) + }); + // Fallback: preserve original embedding order + return { rankedIds: candidates.map((c) => c.id), expectedIds }; + } + }) + ) + ); + + const metrics = computeRankingMetrics(cases, K_VALUES, 'rerank'); + + const rankingResults = validItems.map((item, idx) => ({ + itemId: item._id.toString(), + rankedIds: cases[idx].rankedIds + })); + + addLog.info('Rerank model evaluated', { + taskId, + modelId, + stage, + mrr10: metrics.detailed_results.rerank_top10_mrr, + ndcg10: metrics.detailed_results.rerank_top10_ndcg, + precision10: metrics.detailed_results.rerank_top10_precision, + hasRetrievalRanks: !!metrics.retrieval_ranks + }); + + return { + evalResult: metrics as RerankEvalResult, + rankingResults + }; +} diff --git a/packages/service/core/train/rerank/task/helpers/model.ts b/packages/service/core/train/rerank/task/helpers/model.ts new file mode 100644 index 000000000000..aca9fdbead10 --- /dev/null +++ b/packages/service/core/train/rerank/task/helpers/model.ts @@ -0,0 +1,17 @@ +import { getRerankModelById } from '../../../../ai/model'; +import { addLog } from '../../../../../common/system/log'; + +/** + * Check if a model is a fine-tuned model created by training module + * @param modelConfigId Model configuration ID + * @returns true if it's a fine-tuned model, false otherwise + */ +export function isTunedModel(modelConfigId: string): boolean { + const modelConfig = getRerankModelById(modelConfigId); + if (!modelConfig) { + addLog.warn('Model config not found when checking if tuned', { modelConfigId }); + return false; + } + // Only models created by training module have isTuned flag + return (modelConfig as any).isTuned === true; +} diff --git a/packages/service/core/train/rerank/task/mq.ts b/packages/service/core/train/rerank/task/mq.ts new file mode 100644 index 000000000000..f05f0a027473 --- /dev/null +++ b/packages/service/core/train/rerank/task/mq.ts @@ -0,0 +1,32 @@ +import { getQueue, QueueNames } from '../../../../common/bullmq'; +import { trainEnv } from '../../common/env'; +import { + createJobCleaner, + type JobCleanupOptions, + type JobCleanupResult +} from '../../../../common/bullmq/utils'; + +export type RerankTrainTaskJobData = { + taskId: string; +}; + +export const rerankTrainTaskQueue = getQueue(QueueNames.rerankTrainTask, { + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: trainEnv.TRAIN_JOB_BACKOFF_DELAY }, + removeOnComplete: { age: 7 * 24 * 60 * 60 }, + removeOnFail: false // Keep failed jobs for troubleshooting + } +}); + +export const removeRerankTrainTaskJob = ( + taskId: string, + options?: JobCleanupOptions +): Promise => { + const cleaner = createJobCleaner(options); + return cleaner.cleanAllJobsByFilter( + rerankTrainTaskQueue, + (job) => String(job.data?.taskId) === String(taskId), + QueueNames.rerankTrainTask + ); +}; diff --git a/packages/service/core/train/rerank/task/processor.ts b/packages/service/core/train/rerank/task/processor.ts new file mode 100644 index 000000000000..bc53d78aaca7 --- /dev/null +++ b/packages/service/core/train/rerank/task/processor.ts @@ -0,0 +1,302 @@ +import type { Processor } from 'bullmq'; +import type { RerankTrainTaskJobData } from './mq'; +import { MongoRerankTrainTask } from './schema'; +import { + getRerankTrainTask, + updateRerankTaskStatus, + updateRerankCheckpointStage, + updateRerankCheckpointData +} from './controller'; +import { + RerankTrainTaskStatusEnum, + RerankTaskCheckpointStageEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { addLog } from '../../../../common/system/log'; + +import { runGenerateTrainsetStage } from './stages/generate-trainset'; +import { runGenerateEvalDatasetStage } from './stages/generate-evalset'; +import { runEvalBaseModelStage } from './stages/eval-basemodel'; +import { runFinetuneStage } from './stages/finetune'; +import { runRegisterStage } from './stages/register'; +import { runEvalTunedModelStage } from './stages/eval-tunedmodel'; +import { runLLMJudgeStage } from './stages/llm-judge'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { createRerankEnhancedError } from '../utils'; +import { TrainTaskUnrecoverableError } from '../../common/errors'; +import { getTrainTaskAbortSignal } from '../../common/task-abort-signal'; + +/** + * Rerank training task processor + * + * Executes complete rerank model training pipeline: + * 1. generate_trainset - Generate / wait for training set (includes JSONL export) + * 2. generate_evaldataset - Generate evaluation dataset (auto: DiTing; exact: skip) + * 3. eval_basemodel - Evaluate base model to establish baseline + * 4. finetuning - Model fine-tuning via SFT Bridge + * 5. registering - Model registration in FastGPT + * 6. eval_tunedmodel - Evaluate fine-tuned model + */ +export const rerankTrainTaskProcessor: Processor = async (job) => { + const { taskId } = job.data; + + const task = await getRerankTrainTask(taskId); + if (!task) { + const enhancedError = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankProcessorTaskNotFound, + RerankTrainSuggestionEnum.rerankProcessorTaskNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const currentStage = task.checkpoint.stage; + + addLog.info('Rerank task execution started', { + taskId, + attemptsMade: job.attemptsMade, + checkpointStage: task.checkpoint.stage, + currentStage, + totalAttempts: job.opts.attempts + }); + + try { + if (task.status === RerankTrainTaskStatusEnum.pending) { + await updateRerankTaskStatus(taskId, RerankTrainTaskStatusEnum.running); + } + + // Stage 1: generate_trainset + if ( + await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.generate_trainset) + ) { + const result = await runGenerateTrainsetStage(task); + await updateRerankCheckpointData(taskId, 'generate_trainset', { + trainDatasetId: result.trainDatasetId, + trainDatasetFilePath: result.trainDatasetFilePath, + autoGenerated: result.autoGenerated + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.generate_trainset); + } + + // Stage 2: generate_evaldataset + if ( + await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.generate_evaldataset) + ) { + const taskAfterStage1 = await getRerankTrainTask(taskId); + if (!taskAfterStage1) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankProcessorTaskLostAfterPrepare, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterPrepare + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runGenerateEvalDatasetStage(taskAfterStage1); + await updateRerankCheckpointData(taskId, 'generate_evaldataset', { + evalDatasetId: result.evalDatasetId, + evalDatasetFilePath: result.evalDatasetFilePath, + autoGenerated: result.autoGenerated + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.generate_evaldataset); + } + + // Stage 3: eval_basemodel + if (await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.eval_basemodel)) { + const taskAfterStage2 = await getRerankTrainTask(taskId); + if (!taskAfterStage2) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.eval_basemodel, + RerankTrainErrEnum.rerankProcessorTaskLostAfterEvalGen, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterEvalGen + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runEvalBaseModelStage(taskAfterStage2); + await updateRerankCheckpointData(taskId, 'eval_basemodel', { + baseModelEvalResult: result.baseModelEvalResult, + rankingResults: result.rankingResults + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.eval_basemodel); + } + + // Stage 4: finetuning + if (await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.finetuning)) { + const taskAfterStage3 = await getRerankTrainTask(taskId); + if (!taskAfterStage3) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankProcessorTaskLostAfterEval, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const finetuneResult = await runFinetuneStage(taskAfterStage3); + await updateRerankCheckpointData(taskId, 'finetuning', { + sftTaskId: finetuneResult.sftTaskId, + tunedModelEndpoint: finetuneResult.tunedModelEndpoint + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.finetuning); + } + + // Stage 5: registering + if (await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.registering)) { + const taskAfterFinetune = await getRerankTrainTask(taskId); + if (!taskAfterFinetune) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.registering, + RerankTrainErrEnum.rerankProcessorTaskLostAfterFinetune, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterFinetune + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const registerResult = await runRegisterStage(taskAfterFinetune); + await updateRerankCheckpointData(taskId, 'registering', { + tunedModelId: registerResult.tunedModelId + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.registering); + } + + // Stage 6: eval_tunedmodel + if (await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.eval_tunedmodel)) { + const taskAfterRegister = await getRerankTrainTask(taskId); + if (!taskAfterRegister) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.eval_tunedmodel, + RerankTrainErrEnum.rerankProcessorTaskLostAfterRegister, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterRegister + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const result = await runEvalTunedModelStage(taskAfterRegister); + await updateRerankCheckpointData(taskId, 'eval_tunedmodel', { + tunedModelEvalResult: result.tunedModelEvalResult, + rankingResults: result.rankingResults + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.eval_tunedmodel); + } + + // Stage 7: llm_judge + if (await shouldRunStage(taskId, currentStage, RerankTaskCheckpointStageEnum.llm_judge)) { + const taskAfterStage6 = await getRerankTrainTask(taskId); + if (!taskAfterStage6) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankProcessorTaskLostAfterEval, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const judgeResult = await runLLMJudgeStage(taskAfterStage6); + await updateRerankCheckpointData(taskId, 'llm_judge', { + judgedExpectedIds: judgeResult.judgedExpectedIds, + baseModelRejudgedResult: judgeResult.baseModelRejudgedResult, + tunedModelRejudgedResult: judgeResult.tunedModelRejudgedResult + }); + await updateRerankCheckpointStage(taskId, RerankTaskCheckpointStageEnum.llm_judge); + } + + // Write final result + const finalTask = await getRerankTrainTask(taskId); + if (!finalTask) { + const enhancedError = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankProcessorTaskLostAfterEval, + RerankTrainSuggestionEnum.rerankProcessorTaskLostAfterEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + const finalCheckpoint = finalTask.checkpoint.data || {}; + await MongoRerankTrainTask.updateOne( + { _id: taskId }, + { + status: RerankTrainTaskStatusEnum.completed, + updateTime: new Date(), + finishTime: new Date(), + result: { + trainDatasetId: finalCheckpoint.generate_trainset?.trainDatasetId ?? undefined, + trainDatasetFilePath: + finalCheckpoint.generate_trainset?.trainDatasetFilePath ?? undefined, + tunedModelId: finalCheckpoint.registering?.tunedModelId ?? undefined, + evalDatasetId: finalCheckpoint.generate_evaldataset?.evalDatasetId ?? undefined, + evalDatasetFilePath: + finalCheckpoint.generate_evaldataset?.evalDatasetFilePath ?? undefined, + baseModelEvalResult: finalCheckpoint.eval_basemodel?.baseModelEvalResult, + tunedModelEvalResult: finalCheckpoint.eval_tunedmodel?.tunedModelEvalResult, + baseModelRejudgedResult: finalCheckpoint.llm_judge?.baseModelRejudgedResult, + tunedModelRejudgedResult: finalCheckpoint.llm_judge?.tunedModelRejudgedResult + } + } + ); + + addLog.info('Rerank train task completed', { taskId }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isUnrecoverable = error instanceof TrainTaskUnrecoverableError; + + addLog.error('Rerank train task failed', { + taskId, + error: errorMessage, + isUnrecoverable, + errorType: error instanceof Error ? error.constructor.name : 'Unknown' + }); + + throw error; + } +}; + +/** + * Determine whether to run a stage (skip completed stages, run remaining stages) + */ +async function shouldRunStage( + taskId: string, + currentStage: `${RerankTaskCheckpointStageEnum}` | null, + targetStage: `${RerankTaskCheckpointStageEnum}` +): Promise { + if (currentStage === null) return true; + + const stageOrder: RerankTaskCheckpointStageEnum[] = [ + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTaskCheckpointStageEnum.eval_basemodel, + RerankTaskCheckpointStageEnum.finetuning, + RerankTaskCheckpointStageEnum.registering, + RerankTaskCheckpointStageEnum.eval_tunedmodel, + RerankTaskCheckpointStageEnum.llm_judge + ]; + + const currentStageEnum = currentStage as RerankTaskCheckpointStageEnum; + const targetStageEnum = targetStage as RerankTaskCheckpointStageEnum; + + if (stageOrder.indexOf(targetStageEnum) <= stageOrder.indexOf(currentStageEnum)) { + return false; + } + + const reason = await getTrainTaskAbortSignal({ type: 'rerank', taskId }); + if (reason === 'deleted') { + throw new TrainTaskUnrecoverableError( + createRerankEnhancedError( + targetStage as RerankTaskCheckpointStageEnum, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ) + ); + } + if (reason === 'cancelled') { + throw new TrainTaskUnrecoverableError( + createRerankEnhancedError( + targetStage as RerankTaskCheckpointStageEnum, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ) + ); + } + return true; +} diff --git a/packages/service/core/train/rerank/task/schema.ts b/packages/service/core/train/rerank/task/schema.ts new file mode 100644 index 000000000000..80adc30a075d --- /dev/null +++ b/packages/service/core/train/rerank/task/schema.ts @@ -0,0 +1,199 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { + RerankTrainTaskStatusEnum, + RerankTaskCheckpointStageEnum, + RerankTrainMethodEnum +} from '@fastgpt/global/core/train/rerank/constants'; + +/** Rerank training task schema */ +const RerankTrainTaskSchema = new connectionMongo.Schema({ + trainsetId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'rerank_trainset', + required: false // optional: exact mode passes at create; auto mode written by generate_trainset stage + }, + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + tmbId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team_member', + required: true + }, + name: { + type: String, + required: true + }, + baseModelId: { + type: String, + required: true + }, + baseModelEndpoint: { + type: { + base_url: { + type: String, + required: false + }, + model: { + type: String, + required: true + }, + api_key: { + type: String, + required: false + } + }, + required: true + }, + evalDatasetId: { + type: String, + required: false // exact mode: passed at create; auto mode: written by generate_evaldataset stage + }, + datasetIds: { + type: [String], + required: false // auto mode only: source for generate_trainset/generate_evaldataset + }, + newModelName: { + type: String, + required: false // optional name for the trained model + }, + generateConfig: { + type: connectionMongo.Schema.Types.Mixed, + required: false // trainset synthesis config: indexType, negativeStrategy, sampleSize, etc. + }, + trainMethod: { + type: String, + enum: Object.values(RerankTrainMethodEnum), + default: RerankTrainMethodEnum.lora, + required: false // defaults to lora for backward compatibility + }, + status: { + type: String, + enum: Object.values(RerankTrainTaskStatusEnum), + default: RerankTrainTaskStatusEnum.pending + }, + checkpoint: { + type: { + stage: { + type: String, + enum: [...Object.values(RerankTaskCheckpointStageEnum), null], + default: null + }, + data: { + generate_trainset: { + trainDatasetId: String, + trainDatasetFilePath: String, + autoGenerated: Boolean + }, + generate_evaldataset: { + evalDatasetId: String, + evalDatasetFilePath: String, + autoGenerated: Boolean + }, + eval_basemodel: { + baseModelEvalResult: connectionMongo.Schema.Types.Mixed, + rankingResults: [ + { + itemId: String, + rankedIds: [String] + } + ] + }, + finetuning: { + sftTaskId: String, + tunedModelEndpoint: { + base_url: String, + model: String, + api_key: String + } + }, + registering: { + tunedModelId: String + }, + eval_tunedmodel: { + tunedModelEvalResult: connectionMongo.Schema.Types.Mixed, + rankingResults: [ + { + itemId: String, + rankedIds: [String] + } + ] + }, + llm_judge: { + judgedExpectedIds: [ + { + itemId: String, + expectedIds: [String] + } + ], + baseModelRejudgedResult: connectionMongo.Schema.Types.Mixed, + tunedModelRejudgedResult: connectionMongo.Schema.Types.Mixed + } + }, + stageEndTime: { + generate_trainset: Date, + generate_evaldataset: Date, + eval_basemodel: Date, + finetuning: Date, + registering: Date, + eval_tunedmodel: Date, + llm_judge: Date + } + }, + default: { + stage: null, + data: {}, + stageEndTime: {} + } + }, + result: { + type: { + trainDatasetId: String, + trainDatasetFilePath: String, + tunedModelId: String, + evalDatasetId: String, + evalDatasetFilePath: String, + baseModelEvalResult: connectionMongo.Schema.Types.Mixed, + tunedModelEvalResult: connectionMongo.Schema.Types.Mixed, + baseModelRejudgedResult: connectionMongo.Schema.Types.Mixed, + tunedModelRejudgedResult: connectionMongo.Schema.Types.Mixed + } + }, + errorMsg: { + type: connectionMongo.Schema.Types.Mixed + }, + jobId: { + type: String + }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + }, + finishTime: { + type: Date + } +}); + +// Indexes +RerankTrainTaskSchema.index({ baseModelId: 1, status: 1, createTime: -1 }); // Replaced appId indexes +RerankTrainTaskSchema.index({ trainsetId: 1, createTime: -1 }); // Support querying tasks by trainset +RerankTrainTaskSchema.index({ teamId: 1, status: 1 }); +RerankTrainTaskSchema.index({ status: 1, updateTime: 1 }); +RerankTrainTaskSchema.index({ jobId: 1 }); +RerankTrainTaskSchema.index({ 'checkpoint.stage': 1, status: 1 }); +RerankTrainTaskSchema.index({ teamId: 1, status: 1, createTime: -1 }); +RerankTrainTaskSchema.index({ 'checkpoint.data.registering.tunedModelId': 1 }); // For chain traversal in list API +RerankTrainTaskSchema.index({ teamId: 1, 'checkpoint.data.registering.tunedModelId': 1 }); +RerankTrainTaskSchema.index({ teamId: 1, baseModelId: 1 }); + +export const MongoRerankTrainTask = getMongoModel( + 'rerank_train_task', + RerankTrainTaskSchema +); diff --git a/packages/service/core/train/rerank/task/stages/eval-basemodel.ts b/packages/service/core/train/rerank/task/stages/eval-basemodel.ts new file mode 100644 index 000000000000..89c5ecc83348 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/eval-basemodel.ts @@ -0,0 +1,48 @@ +import type { + RerankTrainTaskSchemaType, + RerankEvalResult +} from '@fastgpt/global/core/train/rerank/type'; +import { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { evaluateRerankModelHelper } from '../helpers/evaluate-model'; + +/** + * Stage 3: Evaluate Base Model + * + * Evaluates the base model on the eval dataset generated in stage 2, + * establishing a performance baseline for comparison after fine-tuning. + * + * @param task - Training task data + * @returns Base model evaluation result + */ +export async function runEvalBaseModelStage(task: RerankTrainTaskSchemaType): Promise<{ + baseModelEvalResult: RerankEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Run eval base model stage', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.eval_basemodel, + RerankTrainErrEnum.rerankEvalDatasetEmptyBeforeEval, + RerankTrainSuggestionEnum.rerankEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const { evalResult: baseModelEvalResult, rankingResults } = await evaluateRerankModelHelper( + String(task._id), + evalDatasetId, + task.baseModelId, + RerankTaskCheckpointStageEnum.eval_basemodel + ); + + return { baseModelEvalResult, rankingResults }; +} diff --git a/packages/service/core/train/rerank/task/stages/eval-tunedmodel.ts b/packages/service/core/train/rerank/task/stages/eval-tunedmodel.ts new file mode 100644 index 000000000000..f962cc918e70 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/eval-tunedmodel.ts @@ -0,0 +1,58 @@ +import type { + RerankTrainTaskSchemaType, + RerankEvalResult +} from '@fastgpt/global/core/train/rerank/type'; +import { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { evaluateRerankModelHelper } from '../helpers/evaluate-model'; + +/** + * Stage 6: Evaluate Tuned Model + * + * Evaluates the fine-tuned model on the same eval dataset used for the base model, + * to compare performance and decide whether to keep the new model. + * + * @param task - Training task data + * @returns Tuned model evaluation result + */ +export async function runEvalTunedModelStage(task: RerankTrainTaskSchemaType): Promise<{ + tunedModelEvalResult: RerankEvalResult; + rankingResults: Array<{ itemId: string; rankedIds: string[] }>; +}> { + addLog.info('Run eval tuned model stage', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.eval_tunedmodel, + RerankTrainErrEnum.rerankEvalDatasetEmptyBeforeEval, + RerankTrainSuggestionEnum.rerankEvalDatasetEmptyBeforeEval + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const tunedModelId = task.checkpoint.data?.registering?.tunedModelId; + if (!tunedModelId) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.eval_tunedmodel, + RerankTrainErrEnum.rerankEvalModelNotFound, + RerankTrainSuggestionEnum.rerankEvalModelNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const { evalResult: tunedModelEvalResult, rankingResults } = await evaluateRerankModelHelper( + String(task._id), + evalDatasetId, + tunedModelId, + RerankTaskCheckpointStageEnum.eval_tunedmodel + ); + + return { tunedModelEvalResult, rankingResults }; +} diff --git a/packages/service/core/train/rerank/task/stages/finetune.ts b/packages/service/core/train/rerank/task/stages/finetune.ts new file mode 100644 index 000000000000..04e00f09f620 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/finetune.ts @@ -0,0 +1,353 @@ +import * as fs from 'fs/promises'; +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { + RerankTrainTaskStatusEnum, + RerankTaskCheckpointStageEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { createSFTTask, querySFTTaskStatus, SFTTaskStatus } from '../../external'; +import { addLog } from '../../../../../common/system/log'; +import { getRerankTrainTask, updateRerankCheckpointData } from '../controller'; +import { trainEnv } from '../../../common/env'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; + +const TASK_ABORT_SIGNAL_CHECK_INTERVAL = 10 * 1000; + +const isSFTBridgeQueueFullError = (errorMsg: string) => + errorMsg.toLowerCase().includes('too many concurrent tasks'); + +/** + * Stage 2: Model Finetuning + * + * Calls SFT Bridge platform, uploads JSONL dataset. + * SFT Bridge automatically completes finetuning and deploys to inference service. + * + * @param task - Training tadsk data + * @returns SFT task ID and tuned model endpoint + * @throws {UnrecoverableError} When dataset file or base model endpoint not found + */ +export async function runFinetuneStage(task: RerankTrainTaskSchemaType): Promise<{ + sftTaskId: string; + tunedModelEndpoint: { + base_url: string; + model: string; + api_key: string; + }; +}> { + addLog.info('Run finetune stage', { taskId: String(task._id) }); + + const checkpointData = task.checkpoint.data || {}; + let sftTaskId = checkpointData.finetuning?.sftTaskId; + + if (sftTaskId) { + addLog.info('Reuse existing SFT task from checkpoint', { + taskId: String(task._id), + sftTaskId + }); + } else { + if (!task.baseModelEndpoint.model) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneModelConfigInvalid, + RerankTrainSuggestionEnum.rerankFinetuneModelConfigInvalid + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + if (!checkpointData.generate_trainset?.trainDatasetFilePath) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDataPathNotFound, + RerankTrainSuggestionEnum.rerankFinetuneDataPathNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let datasetFile: Buffer; + try { + datasetFile = await fs.readFile(checkpointData.generate_trainset.trainDatasetFilePath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDataFileNotFound, + RerankTrainSuggestionEnum.rerankFinetuneDataFileNotFound, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + await ensureTaskActiveBeforeSFTCreation({ taskId: String(task._id) }); + await ensureTaskNotAborted({ + taskId: String(task._id), + sftTaskId: undefined + }); + + if (!checkpointData.generate_evaldataset?.evalDatasetFilePath) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDataPathNotFound, + RerankTrainSuggestionEnum.rerankFinetuneDataPathNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let evalDatasetFile: Buffer; + try { + evalDatasetFile = await fs.readFile(checkpointData.generate_evaldataset.evalDatasetFilePath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDataFileNotFound, + RerankTrainSuggestionEnum.rerankFinetuneDataFileNotFound, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + let createResponse: Awaited>; + try { + createResponse = await createSFTTask({ + datasetFile, + taskType: 'rerank', + trainMethod: task.trainMethod || 'lora', + parameters: { + learning_rate: trainEnv.SFT_BRIDGE_LEARNING_RATE, + epochs: 3, + batch_size: 32 + }, + evalDatasetFile + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const errorType = isSFTBridgeQueueFullError(errorMsg) + ? RerankTrainErrEnum.rerankFinetuneQueueFull + : RerankTrainErrEnum.rerankFinetuneSftBridgeCreateFailed; + const suggestionType = isSFTBridgeQueueFullError(errorMsg) + ? RerankTrainSuggestionEnum.rerankFinetuneQueueFull + : RerankTrainSuggestionEnum.rerankFinetuneSftBridgeCreateFailed; + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + errorType, + suggestionType, + errorMsg + ); + throw new TrainTaskRetriableError(enhancedError); + } + + sftTaskId = createResponse.task_id; + await updateRerankCheckpointData( + String(task._id), + RerankTaskCheckpointStageEnum.finetuning, + { sftTaskId }, + true + ); + + addLog.info('Created SFT task', { + taskId: String(task._id), + sftTaskId + }); + } + + let completed = false; + let endpoint: + | { + base_url: string; + model: string; + api_key: string; + } + | undefined = undefined; + + const maxPolls = trainEnv.SFT_BRIDGE_MAX_POLLS; + const pollInterval = trainEnv.SFT_BRIDGE_POLL_INTERVAL; + + addLog.info('SFT task polling configuration', { + taskId: String(task._id), + maxPolls, + pollInterval, + maxDuration: `${(maxPolls * pollInterval) / 1000 / 60} minutes` + }); + + let pollCount = 0; + + while (!completed && pollCount < maxPolls) { + await waitForNextSFTPoll({ + taskId: String(task._id), + sftTaskId, + pollInterval + }); + + const statusResponse = await querySFTTaskStatus({ + taskId: sftTaskId + }); + + addLog.info('SFT task status', { + taskId: String(task._id), + sftTaskId, + status: statusResponse.status, + progress: statusResponse.progress + }); + + if (statusResponse.status === SFTTaskStatus.completed) { + completed = true; + endpoint = statusResponse.endpoint; + + if (!endpoint) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDeploymentFailed, + RerankTrainSuggestionEnum.rerankFinetuneDeploymentFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + } else if (statusResponse.status === SFTTaskStatus.failed) { + const errorMsg = statusResponse.error; + await updateRerankCheckpointData( + String(task._id), + RerankTaskCheckpointStageEnum.finetuning, + { sftTaskId: null }, + true + ); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneTrainingFailed, + RerankTrainSuggestionEnum.rerankFinetuneTrainingFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + pollCount++; + } + + if (!completed) { + const timeoutDuration = `${(maxPolls * pollInterval) / 1000 / 60} minutes`; + addLog.error('SFT task polling timeout', { + taskId: String(task._id), + sftTaskId, + maxPolls, + pollInterval, + timeoutDuration + }); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneTimeout, + RerankTrainSuggestionEnum.rerankFinetuneTimeout + ); + throw new TrainTaskRetriableError(enhancedError); + } + + if (!endpoint) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneDeploymentNoEndpoint, + RerankTrainSuggestionEnum.rerankFinetuneDeploymentNoEndpoint + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('Finetune stage completed (tuned model auto-deployed to serving)', { + taskId: String(task._id), + baseModelId: task.baseModelId, + baseModelEndpoint: task.baseModelEndpoint, + tunedModelEndpoint: endpoint + }); + + return { + sftTaskId, + tunedModelEndpoint: endpoint + }; +} + +async function ensureTaskActiveBeforeSFTCreation({ taskId }: { taskId: string }) { + const currentTask = await getRerankTrainTask(taskId); + if (!currentTask) { + addLog.warn('SFT task creation skipped because task was deleted', { taskId }); + throwTaskDeletedError(); + } + + if (currentTask.status === RerankTrainTaskStatusEnum.cancelled) { + addLog.warn('SFT task creation skipped because task was cancelled', { taskId }); + throwTaskCancelledError(); + } +} + +async function ensureTaskNotAborted({ taskId, sftTaskId }: { taskId: string; sftTaskId?: string }) { + let abortReason: Awaited>; + try { + abortReason = await getTrainTaskAbortSignal({ type: 'rerank', taskId }); + } catch (error) { + addLog.warn('Failed to check rerank train task abort signal', { + taskId, + sftTaskId, + error: error instanceof Error ? error.message : String(error) + }); + return; + } + + if (abortReason === 'deleted') { + addLog.warn('SFT task polling stopped because task was deleted', { + taskId, + sftTaskId + }); + throwTaskDeletedError(); + } + + if (abortReason === 'cancelled') { + addLog.warn('SFT task polling cancelled by user', { + taskId, + sftTaskId + }); + throwTaskCancelledError(); + } +} + +function throwTaskDeletedError(): never { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); +} + +function throwTaskCancelledError(): never { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.finetuning, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); +} + +async function waitForNextSFTPoll({ + taskId, + sftTaskId, + pollInterval +}: { + taskId: string; + sftTaskId: string; + pollInterval: number; +}) { + await ensureTaskNotAborted({ taskId, sftTaskId }); + + const checkInterval = Math.min(TASK_ABORT_SIGNAL_CHECK_INTERVAL, pollInterval); + if (checkInterval <= 0) { + return; + } + + let waited = 0; + while (waited < pollInterval) { + const waitMs = Math.min(checkInterval, pollInterval - waited); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + waited += waitMs; + + await ensureTaskNotAborted({ taskId, sftTaskId }); + } +} diff --git a/packages/service/core/train/rerank/task/stages/generate-evalset.ts b/packages/service/core/train/rerank/task/stages/generate-evalset.ts new file mode 100644 index 000000000000..72d33daf40a5 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/generate-evalset.ts @@ -0,0 +1,515 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { MongoDatasetData } from '../../../../../core/dataset/data/schema'; +import { getRerankTrainDataDir } from '../../constants'; +import { MongoEvalDatasetCollection } from '../../../../../core/evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import { + sampleDataFromDataset, + pLimit, + fetchSampledContent, + propagateAbortFromResults +} from '../../utils'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { synthesizeRerankEvalData } from '../../external'; +import { getDefaultLLMModel } from '../../../../ai/model'; +import { getModelEndpointConfig } from '../../../../ai/config'; +import { addLog } from '../../../../../common/system/log'; +import { trainEnv } from '../../../common/env'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { MongoRerankTrainTask } from '../schema'; +import { performDatasetSearch } from '../helpers/dataset-search'; + +/** + * Generate evaluation dataset using DiTing (auto mode) + * + * Responsibilities: + * 1. Sample data from task.datasetIds + * 2. Call DiTing API to generate evaluation QA pairs for each sample + * (mock can be enabled via DITING_MOCK_ENABLE=true in the external layer) + * 3. Perform dataset search for each QA pair (without rerank, embedding only) + * 4. Create evaluation dataset collection + * 5. Batch insert evaluation data + * 6. Return evaluation dataset ID + * + * @param task - Rerank training task instance + * @returns Evaluation dataset collection ID + */ +async function generateEvalDatasetFromDatasets(task: RerankTrainTaskSchemaType): Promise { + addLog.info('Run generate eval dataset (auto mode)', { taskId: String(task._id) }); + + const datasetIds = task.datasetIds; + + addLog.info('Using dataset IDs from task', { + taskId: String(task._id), + datasetIds + }); + + // Randomly sample from all data for evaluation + const sampledData = await sampleDataFromDataset(datasetIds, { + datasetType: 'eval' + }); + + if (sampledData.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalNoDataAvailable, + RerankTrainSuggestionEnum.rerankEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Fetch q/a content for DiTing API calls (sampleDataFromDataset now returns ID-only items) + const sampledDataWithContent = await fetchSampledContent(sampledData); + + if (sampledDataWithContent.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalNoDataAvailable, + RerankTrainSuggestionEnum.rerankEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Calculate numCases for 1-to-many strategy when sampled data is below minimum + const numCasesPerSample = + sampledDataWithContent.length < trainEnv.TRAIN_MIN_EVAL_QA_COUNT + ? Math.ceil(trainEnv.TRAIN_MIN_EVAL_QA_COUNT / sampledDataWithContent.length) + : 1; + + addLog.info('Sampled data from datasets', { + taskId: String(task._id), + sampleCount: sampledDataWithContent.length, + numCasesPerSample, + required: trainEnv.TRAIN_MIN_EVAL_QA_COUNT + }); + + // Resolve full endpoint config for DiTing (model + channel credentials). + const llm = getDefaultLLMModel(); + const endpointConfig = getModelEndpointConfig(llm); + const aiModelId = llm?.id || ''; + + // Controlled concurrency for DiTing API calls + const ditingConcurrency = Math.min( + Math.max(trainEnv.DITING_API_CONCURRENCY, 1), + trainEnv.DITING_MAX_CONCURRENCY + ); + addLog.info('DiTing API concurrency configuration', { + taskId: String(task._id), + concurrency: ditingConcurrency, + totalSamples: sampledDataWithContent.length + }); + + const ditingLimit = pLimit(ditingConcurrency); + const ditingResults = await Promise.allSettled( + sampledDataWithContent.map((sample) => + ditingLimit(async () => { + const abortReason = await getTrainTaskAbortSignal({ + type: 'rerank', + taskId: String(task._id) + }); + if (abortReason === 'deleted') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const context = [sample.q, sample.a].filter(Boolean); + + const response = await synthesizeRerankEvalData({ + inputData: { + context, + numCases: numCasesPerSample + }, + llm_config: { + name: endpointConfig.name, + base_url: endpointConfig.baseUrl, + api_key: endpointConfig.apiKey, + timeout: trainEnv.DITING_TIMEOUT / 1000 + } + }); + + if (!response.success || !response.data) { + addLog.warn('Failed to generate eval QA pairs for sample', { + taskId: String(task._id), + sampleDataId: sample.dataId, + error: response.error + }); + return null; + } + + return { + teamId: task.teamId, + tmbId: task.tmbId, + qaPairs: response.data.qaPairs, + sourceDataId: sample.dataId, + sourceDatasetId: sample.datasetId, + synthesizedAt: new Date() + }; + }) + ) + ); + + propagateAbortFromResults(ditingResults); + + // Flatten 1-to-many results: each DiTing call returns multiple qaPairs + const evalDataItems = ditingResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .flatMap((result) => { + const value = result.value; + if (!value || !Array.isArray(value.qaPairs)) return []; + return value.qaPairs.map((qaPair: any) => ({ + teamId: value.teamId, + tmbId: value.tmbId, + question: qaPair.question, + answer: qaPair.answer, + sourceDataId: value.sourceDataId, + sourceDatasetId: value.sourceDatasetId, + synthesizedAt: value.synthesizedAt + })); + }) + .filter((value) => value !== null); + + if (evalDataItems.length < trainEnv.TRAIN_MIN_EVAL_QA_COUNT) { + addLog.warn('Generated eval QA pairs below minimum required count', { + taskId: String(task._id), + generatedCount: evalDataItems.length, + required: trainEnv.TRAIN_MIN_EVAL_QA_COUNT + }); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalDitingGenerationFailed, + RerankTrainSuggestionEnum.rerankEvalDitingGenerationFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('Generated eval QA pairs using DiTing', { + taskId: String(task._id), + generatedCount: evalDataItems.length + }); + + addLog.info('Performing dataset search for eval data items', { + taskId: String(task._id), + itemCount: evalDataItems.length + }); + + // Controlled concurrency for dataset search (without rerank, embedding only) + const searchConcurrency = Math.min( + Math.max(trainEnv.TRAIN_DATASET_SEARCH_CONCURRENCY, 1), + trainEnv.DITING_MAX_CONCURRENCY + ); + addLog.info('Dataset search concurrency configuration', { + taskId: String(task._id), + concurrency: searchConcurrency, + totalItems: evalDataItems.length + }); + + const searchLimit = pLimit(searchConcurrency); + const searchResults = await Promise.allSettled( + evalDataItems.map((item) => + searchLimit(async () => { + const abortReason = await getTrainTaskAbortSignal({ + type: 'rerank', + taskId: String(task._id) + }); + if (abortReason === 'deleted') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const retrievalResults = await performDatasetSearch(task, datasetIds, item.question); + + return { + ...item, + retrievalContextsFull: retrievalResults.map((r: any) => ({ + id: r.id, + score: r.score + })), + expectedContextIds: [item.sourceDataId] + }; + }) + ) + ); + + propagateAbortFromResults(searchResults); + + const enrichedEvalDataItems = searchResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value); + + const rejectedResults = searchResults.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + if (rejectedResults.length > 0) { + addLog.warn('Some dataset searches failed during eval generation', { + taskId: String(task._id), + rejectedCount: rejectedResults.length, + firstError: String(rejectedResults[0]?.reason?.message || rejectedResults[0]?.reason), + firstStack: rejectedResults[0]?.reason?.stack?.split('\n').slice(0, 3).join('\n') + }); + } + + addLog.info('Completed dataset search for eval data items', { + taskId: String(task._id), + enrichedItemCount: enrichedEvalDataItems.length + }); + + if (enrichedEvalDataItems.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalDatasetSearchAllFailed, + RerankTrainSuggestionEnum.rerankEvalDatasetSearchAllFailed, + `All ${searchResults.length} dataset searches failed or returned empty results` + ); + throw new TrainTaskRetriableError(enhancedError); + } + + const evalCollectionName = `Eval Dataset - Task ${task._id}`; + + let evalCollection; + try { + [evalCollection] = await MongoEvalDatasetCollection.create([ + { + teamId: task.teamId, + tmbId: task.tmbId, + name: evalCollectionName, + description: `Rerank Model Evaluation Dataset - Training Task ${task.name}`, + metadata: { + taskId: String(task._id), + taskName: task.name, + generatedAt: new Date(), + sampleSize: enrichedEvalDataItems.length + } + } + ]); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalDatabaseSaveFailed, + RerankTrainSuggestionEnum.rerankEvalDatabaseSaveFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const evalDataDocs = enrichedEvalDataItems.map((item) => ({ + teamId: item.teamId, + tmbId: item.tmbId, + evalDatasetCollectionId: evalCollection._id, + [EvalDatasetDataKeyEnum.UserInput]: item.question, + [EvalDatasetDataKeyEnum.ExpectedOutput]: item.answer, + [EvalDatasetDataKeyEnum.Context]: [], + [EvalDatasetDataKeyEnum.RetrievalContext]: [], + [EvalDatasetDataKeyEnum.RetrievalContextsFull]: item.retrievalContextsFull, + [EvalDatasetDataKeyEnum.ExpectedContextIds]: item.expectedContextIds, + createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration, + synthesisMetadata: { + sourceDataId: item.sourceDataId, + sourceDatasetId: item.sourceDatasetId, + intelligentGenerationModelId: aiModelId, + synthesizedAt: item.synthesizedAt, + generatedAt: new Date() + } + })); + + try { + await MongoEvalDatasetData.insertMany(evalDataDocs); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalDatabaseSaveFailed, + RerankTrainSuggestionEnum.rerankEvalDatabaseSaveFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Generated eval dataset', { + taskId: String(task._id), + datasetId: String(evalCollection._id), + dataCount: evalDataDocs.length + }); + + return String(evalCollection._id); +} + +/** + * Generate eval dataset JSONL file from saved MongoDB eval data. + * + * Reads eval data items, fetches doc text for pos/neg doc IDs, + * and writes JSONL using streaming to avoid memory overflow. + * + * For rerank, RetrievalContextsFull already contains embedding search + * candidates so no additional search is needed. + */ +async function generateEvalJsonlFromDB( + evalDatasetCollectionId: string, + taskId: string +): Promise<{ evalDatasetId: string; evalDatasetFilePath: string }> { + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_evaldataset, + RerankTrainErrEnum.rerankEvalNoDataAvailable, + RerankTrainSuggestionEnum.rerankEvalNoDataAvailable + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Collect all doc IDs and build item list + const allDocIds = new Set(); + const items: Array<{ query: string; posIds: string[]; negIds: string[] }> = []; + + for (const item of evalDataItems) { + const query = ((item as any)[EvalDatasetDataKeyEnum.UserInput] as string) || ''; + const posIds: string[] = (item as any)[EvalDatasetDataKeyEnum.ExpectedContextIds] || []; + const retrievalFull: Array<{ id: string }> = + (item as any)[EvalDatasetDataKeyEnum.RetrievalContextsFull] || []; + + const candidateIds = retrievalFull.map((c) => String(c.id)); + const negIds = candidateIds.filter((id) => !posIds.includes(id)).slice(0, 20); + + posIds.forEach((id) => allDocIds.add(id)); + negIds.forEach((id) => allDocIds.add(id)); + + items.push({ query, posIds, negIds }); + } + + // Fetch doc text in one batch + let textMap = new Map(); + if (allDocIds.size > 0) { + const docs = await MongoDatasetData.find({ _id: { $in: [...allDocIds] } }, 'q a').lean(); + for (const doc of docs) { + textMap.set(String(doc._id), [doc.q, doc.a].filter(Boolean).join('\n')); + } + } + + const dataDir = getRerankTrainDataDir(); + const filename = `rerank_eval_${taskId}_${Date.now()}.jsonl`; + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + const filePath = path.join(dataDir, filename); + + let dataCount = 0; + const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' }); + for (const item of items) { + writeStream.write( + JSON.stringify({ + query: item.query, + pos: item.posIds.map((id) => textMap.get(id) || ''), + neg: item.negIds.map((id) => textMap.get(id) || ''), + id: String(dataCount++) + }) + '\n' + ); + } + await new Promise((resolve, reject) => { + writeStream.end((err: Error | null | undefined) => { + if (err) reject(err); + else resolve(); + }); + }); + + addLog.info('Generated rerank eval JSONL file from DB', { + taskId, + filePath, + dataCount + }); + + return { evalDatasetId: evalDatasetCollectionId, evalDatasetFilePath: filePath }; +} + +/** + * Stage 2: Generate Evaluation Dataset + * + * - Exact mode (task.evalDatasetId is non-empty): Reads eval data from MongoDB, + * generates JSONL file, returns evalDatasetId and file path. + * - Auto mode (task.evalDatasetId is empty): Samples from task.datasetIds, calls DiTing to + * generate QA pairs (mock via DITING_MOCK_ENABLE=true), creates eval dataset collection, + * writes evalDatasetId back to task, generates JSONL file. + * + * @param task - Training task data + * @returns Evaluation dataset ID and JSONL file path + */ +export async function runGenerateEvalDatasetStage(task: RerankTrainTaskSchemaType): Promise<{ + evalDatasetId: string; + evalDatasetFilePath: string; + autoGenerated: boolean; +}> { + addLog.info('Run generate eval dataset stage', { taskId: String(task._id) }); + + // Resume from partial checkpoint + if (task.checkpoint?.data?.generate_evaldataset?.evalDatasetFilePath) { + return { + ...task.checkpoint.data.generate_evaldataset, + autoGenerated: task.checkpoint.data.generate_evaldataset.autoGenerated ?? false + }; + } + + if (task.evalDatasetId) { + // Exact mode: use the provided eval dataset + addLog.info('Exact mode: using existing eval dataset', { + taskId: String(task._id), + evalDatasetId: task.evalDatasetId + }); + const jsonlResult = await generateEvalJsonlFromDB(task.evalDatasetId, String(task._id)); + return { ...jsonlResult, autoGenerated: false }; + } + + // Auto mode: generate eval dataset from datasetIds + const evalDatasetId = await generateEvalDatasetFromDatasets(task); + + // Write evalDatasetId back to task top-level field + await MongoRerankTrainTask.updateOne({ _id: task._id }, { evalDatasetId }); + + // Generate JSONL from the saved eval data + const jsonlResult = await generateEvalJsonlFromDB(evalDatasetId, String(task._id)); + + addLog.info('Auto mode: eval dataset generated and written back to task', { + taskId: String(task._id), + evalDatasetId + }); + + return { ...jsonlResult, autoGenerated: true }; +} diff --git a/packages/service/core/train/rerank/task/stages/generate-trainset.ts b/packages/service/core/train/rerank/task/stages/generate-trainset.ts new file mode 100644 index 000000000000..b59151d95912 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/generate-trainset.ts @@ -0,0 +1,304 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { MongoRerankTrainsetData } from '../../data/schema'; +import { MongoRerankTrainset } from '../../trainset/schema'; +import { addLog } from '../../../../../common/system/log'; +import { createRerankEnhancedError, formatTrainTaskError } from '../../utils'; +import { getRerankTrainDataDir } from '../../constants'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { + RerankTaskCheckpointStageEnum, + RerankTrainsetStatusEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { calculateRerankTrainsetStats } from '../../data/controller'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/rerank/error'; +import { createRerankTrainset } from '../../trainset/controller'; +import { MongoRerankTrainTask } from '../schema'; +import { rerankTrainDataGenerateQueue } from '../../data/mq'; + +/** + * Poll and wait for trainset to be ready + * @param trainsetId Trainset ID + * @param maxAttempts Max polling attempts (default: 360 for 60 minutes) + * @param interval Polling interval in ms (default: 10000ms = 10s) + */ +async function waitForTrainsetReady( + trainsetId: string, + taskId: string, + maxAttempts: number = 360, + interval: number = 10000 +): Promise { + let attempts = 0; + + addLog.info('Waiting for rerank trainset to be ready', { + trainsetId, + taskId, + maxAttempts, + interval + }); + + while (attempts < maxAttempts) { + const abortReason = await getTrainTaskAbortSignal({ type: 'rerank', taskId }); + if (abortReason === 'deleted') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const trainset = await MongoRerankTrainset.findById(trainsetId).lean(); + + if (!trainset) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareTrainsetDeleted, + RerankTrainSuggestionEnum.rerankPrepareTrainsetDeleted + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // If ready, validate data count and return successfully + if (trainset.status === RerankTrainsetStatusEnum.ready) { + const stats = await calculateRerankTrainsetStats(String(trainset._id)); + if (stats.dataCount === 0) { + addLog.error('No train data available in rerank trainset', { trainsetId }); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareDataEmpty, + RerankTrainSuggestionEnum.rerankPrepareDataEmpty + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + addLog.info('Rerank trainset is ready', { trainsetId, dataCount: stats.dataCount }); + return; + } + + // If error, fail the training task immediately + if (trainset.status === RerankTrainsetStatusEnum.error) { + const enhancedError = trainset.errorMsg as EnhancedErrorMessage; + + addLog.error('Rerank trainset generation failed', { + trainsetId, + errorType: enhancedError.type, + errorMessage: enhancedError.message + }); + + // Reuse trainset's enhancedError, just add the stage field + const taskError: EnhancedErrorMessage = { + ...enhancedError, + stage: RerankTaskCheckpointStageEnum.generate_trainset + }; + throw new TrainTaskUnrecoverableError(taskError); + } + + // If still generating or pending, continue waiting + if ( + trainset.status === RerankTrainsetStatusEnum.generating || + trainset.status === RerankTrainsetStatusEnum.pending + ) { + addLog.info('Rerank trainset still generating', { + trainsetId, + status: trainset.status, + attempt: attempts + 1, + maxAttempts + }); + await new Promise((resolve) => setTimeout(resolve, interval)); + attempts++; + continue; + } + + // Unknown status, continue waiting + await new Promise((resolve) => setTimeout(resolve, interval)); + attempts++; + } + + // Timeout + addLog.error('Rerank trainset generation timeout', { trainsetId, maxAttempts }); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareTimeout, + RerankTrainSuggestionEnum.rerankPrepareTimeout + ); + throw new TrainTaskRetriableError(enhancedError); +} + +/** + * Generate trainset JSONL file from trainset data + * + * Organizes training data into JSONL format for SFT Platform upload. + * Uses streaming to avoid memory overflow. + * + * @param task - Training task data + * @returns Train dataset ID and temporary file path + * @throws {UnrecoverableError} When no train data available + */ +async function generateTrainsetJsonl(task: RerankTrainTaskSchemaType): Promise<{ + trainDatasetId: string; + trainDatasetFilePath: string; +}> { + // Use configurable training data directory + const trainDataDir = getRerankTrainDataDir(); + + // Ensure directory exists + if (!fs.existsSync(trainDataDir)) { + fs.mkdirSync(trainDataDir, { recursive: true }); + } + + const tmpFilePath = path.join(trainDataDir, `rerank_train_${task._id}_${Date.now()}.jsonl`); + + let dataCount = 0; + + try { + const writeStream = fs.createWriteStream(tmpFilePath, { encoding: 'utf-8' }); + + // Query training data for the specific trainset + const cursor = MongoRerankTrainsetData.find({ + trainsetId: task.trainsetId + }).cursor(); + + for await (const data of cursor) { + const jsonLine = JSON.stringify({ + query: data.query, + pos: data.positiveDocs, + neg: data.negativeDocs, + id: String(dataCount) + }); + + writeStream.write(jsonLine + '\n'); + dataCount++; + } + + await new Promise((resolve, reject) => { + writeStream.end((err: Error | null | undefined) => { + if (err) reject(err); + else resolve(); + }); + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareFileSystemError, + RerankTrainSuggestionEnum.rerankPrepareFileSystemError, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Double-check data count after file writing + if (dataCount === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareDataEmptyAfterWrite, + RerankTrainSuggestionEnum.rerankPrepareDataEmptyAfterWrite + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Prepared rerank train data', { + taskId: String(task._id), + dataCount, + trainsetId: String(task.trainsetId), + filePath: tmpFilePath + }); + + return { + trainDatasetId: String(task.trainsetId), + trainDatasetFilePath: tmpFilePath + }; +} + +/** + * Stage 1: Generate Training Set + * + * - Auto mode (task.trainsetId is empty): Creates a new trainset, writes trainsetId back to task, + * triggers data generation queue, then waits for trainset to be ready. + * - Exact mode (task.trainsetId is non-empty): Directly waits for trainset to be ready. + * + * Both modes then generate the JSONL file. + * + * @param task - Training task data + * @returns Train dataset ID and temporary file path + */ +export async function runGenerateTrainsetStage(task: RerankTrainTaskSchemaType): Promise<{ + trainDatasetId: string; + trainDatasetFilePath: string; + autoGenerated: boolean; +}> { + addLog.info('Run generate trainset stage (rerank)', { taskId: String(task._id) }); + + let trainsetId = task.trainsetId ? String(task.trainsetId) : undefined; + let autoGenerated = false; + + if (!trainsetId) { + // Auto mode: create new trainset and trigger data generation + autoGenerated = true; + addLog.info('Auto mode: creating new rerank trainset', { taskId: String(task._id) }); + + const trainset = await createRerankTrainset({ + teamId: String(task.teamId), + tmbId: String(task.tmbId), + name: `Training Set - ${task.name}`, + description: `Auto-generated for training task ${task._id}` + }); + trainsetId = String(trainset._id); + + // Write trainsetId back to task top-level field + await MongoRerankTrainTask.updateOne({ _id: task._id }, { trainsetId }); + + addLog.info('Auto mode: created rerank trainset, triggering data generation', { + taskId: String(task._id), + trainsetId + }); + + // generateConfig is required in auto mode; guard here to surface programming errors early + if (!task.generateConfig) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.generate_trainset, + RerankTrainErrEnum.rerankPrepareMissingGenerateConfig, + RerankTrainSuggestionEnum.rerankPrepareMissingGenerateConfig + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Trigger data generation queue + const job = await rerankTrainDataGenerateQueue.add(`generate-trainset-${trainsetId}`, { + trainsetId, + datasetIds: task.datasetIds, + generateConfig: task.generateConfig + }); + + // Write jobId back to trainset for retry support + if (job.id) { + await MongoRerankTrainset.updateOne({ _id: trainsetId }, { jobId: job.id }); + } + } else { + addLog.info('Exact mode: using existing rerank trainset', { + taskId: String(task._id), + trainsetId + }); + } + + // Wait for trainset to be ready (both modes) + await waitForTrainsetReady(trainsetId, String(task._id)); + + // Generate JSONL file from trainset data + const jsonlResult = await generateTrainsetJsonl({ ...task, trainsetId }); + return { ...jsonlResult, autoGenerated }; +} diff --git a/packages/service/core/train/rerank/task/stages/llm-judge.ts b/packages/service/core/train/rerank/task/stages/llm-judge.ts new file mode 100644 index 000000000000..28aa03357c13 --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/llm-judge.ts @@ -0,0 +1,309 @@ +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { MongoEvalDatasetData } from '../../../../../core/evaluation/dataset/evalDatasetDataSchema'; +import { MongoDatasetData } from '../../../../../core/dataset/data/schema'; +import { judgeRelevantChunks } from '../../external'; +import { computeRankingMetrics } from '../../../common/metrics/rankingMetrics'; +import type { RankingCase } from '../../../common/metrics/rankingMetrics'; +import { pLimit, propagateAbortFromResults } from '../../../common/utils'; +import { trainEnv } from '../../../common/env'; +import { getTrainTaskAbortSignal } from '../../../common/task-abort-signal'; +import { addLog } from '../../../../../common/system/log'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../../common/errors'; +import { getDefaultLLMModel } from '../../../../ai/model'; + +const K_VALUES = [5, 10, 15]; + +/** + * Stage 7: LLM Judge + * + * Uses DiTing detect-context API to determine which chunks from the combined + * baseline + tuned top-K retrieval lists truly answer each query. + * Replaces the original expectedContextIds with LLM-detected relevant IDs + * and recomputes MRR/NDCG metrics for both models. + * + * @param task - Rerank training task (after stage 6 completion) + * @returns Judged expected IDs and re-evaluated metrics + */ +export async function runLLMJudgeStage(task: RerankTrainTaskSchemaType): Promise<{ + judgedExpectedIds: Array<{ itemId: string; expectedIds: string[] }>; + baseModelRejudgedResult: ReturnType; + tunedModelRejudgedResult: ReturnType; +}> { + addLog.info('Run LLM judge stage', { taskId: String(task._id) }); + + const evalDatasetId = task.checkpoint.data?.generate_evaldataset?.evalDatasetId; + if (!evalDatasetId) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankLLMJudgeNoEvalData, + RerankTrainSuggestionEnum.rerankLLMJudgeNoEvalData + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const baselineRanking = task.checkpoint.data?.eval_basemodel?.rankingResults; + const tunedRanking = task.checkpoint.data?.eval_tunedmodel?.rankingResults; + + if (!baselineRanking?.length || !tunedRanking?.length) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankLLMJudgeNoRankingResults, + RerankTrainSuggestionEnum.rerankLLMJudgeNoRankingResults + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + // Build lookup maps from eval data item _id to ranking data + const baselineMap = new Map(baselineRanking.map((r) => [r.itemId, r.rankedIds])); + const tunedMap = new Map(tunedRanking.map((r) => [r.itemId, r.rankedIds])); + + // Read eval dataset data + const evalDataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evalDatasetId + }).lean(); + + if (evalDataItems.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankLLMJudgeNoEvalData, + RerankTrainSuggestionEnum.rerankLLMJudgeNoEvalData + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const topK = trainEnv.LLM_JUDGE_TOP_K; + const defaultModel = getDefaultLLMModel(); + + // Build the eval data content lookup (retrievalContextsFull now only stores id+score) + const evalDataContentMap = new Map( + evalDataItems.map((item: any) => [ + item._id.toString(), + { + question: item.userInput ?? '', + candidateIds: (item.retrievalContextsFull || []).map((c: any) => c.id) + } + ]) + ); + + // Collect all IDs that may need q/a lookups (baseline/tuned topK merged for each item) + const allNeededIds = new Set(); + for (const [itemId, baselineIds] of baselineMap) { + const tunedIds = tunedMap.get(itemId); + if (!tunedIds) continue; + const evalContent = evalDataContentMap.get(itemId); + if (!evalContent) continue; + + const mergedIds = Array.from( + new Set([...baselineIds.slice(0, topK), ...tunedIds.slice(0, topK)]) + ); + mergedIds.forEach((id) => allNeededIds.add(id)); + } + + // Batch fetch q/a text from original dataset data + const dataTextMap = new Map( + (await MongoDatasetData.find({ _id: { $in: [...allNeededIds] } }, 'q a').lean()).map((d) => [ + String(d._id), + d as { q: string; a: string } + ]) + ); + + // Collect items that have both baseline and tuned ranking results + const judgeItems: Array<{ + itemId: string; + question: string; + retrieval_reference_list: Array<{ id: string; q: string; a: string }>; + baselineRankedIds: string[]; + tunedRankedIds: string[]; + }> = []; + + for (const [itemId, baselineIds] of baselineMap) { + const tunedIds = tunedMap.get(itemId); + if (!tunedIds) continue; + + const evalContent = evalDataContentMap.get(itemId); + if (!evalContent) continue; + + // Merge top K IDs from baseline and tuned, dedup + const mergedIds = Array.from( + new Set([...baselineIds.slice(0, topK), ...tunedIds.slice(0, topK)]) + ); + + // Lookup chunk content from MongoDatasetData (fetched in batch above) + const retrieval_reference_list = mergedIds + .map((id) => { + const doc = dataTextMap.get(id); + return { id, q: doc?.q ?? '', a: doc?.a ?? '' }; + }) + .filter((c) => c.q.length > 0 || c.a.length > 0); + + if (retrieval_reference_list.length === 0) continue; + + judgeItems.push({ + itemId, + question: evalContent.question, + retrieval_reference_list, + baselineRankedIds: baselineIds, + tunedRankedIds: tunedIds + }); + } + + if (judgeItems.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankLLMJudgeEmptyResult, + RerankTrainSuggestionEnum.rerankLLMJudgeEmptyResult + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('LLM judge items prepared', { + taskId: String(task._id), + totalEvalItems: evalDataItems.length, + judgeItems: judgeItems.length, + topK + }); + + // Call DiTing detect-context API with controlled concurrency + const concurrency = Math.min( + Math.max(trainEnv.DITING_API_CONCURRENCY, 1), + trainEnv.DITING_MAX_CONCURRENCY + ); + const limit = pLimit(concurrency); + + let completedCount = 0; + const totalCount = judgeItems.length; + const PROGRESS_INTERVAL = Math.max(1, Math.floor(totalCount / 10)); + + const judgeResults = await Promise.allSettled( + judgeItems.map((item) => + limit(async () => { + const abortReason = await getTrainTaskAbortSignal({ + type: 'rerank', + taskId: String(task._id) + }); + if (abortReason === 'deleted') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankTaskNotExist, + RerankTrainSuggestionEnum.rerankTaskNotExist + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + if (abortReason === 'cancelled') { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankFinetuneCancelled, + RerankTrainSuggestionEnum.rerankFinetuneCancelled + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const response = await judgeRelevantChunks({ + question: item.question, + retrieval_reference_list: item.retrieval_reference_list, + llm_config: { + name: defaultModel?.model ?? '', + base_url: defaultModel?.requestUrl ?? '', + api_key: defaultModel?.requestAuth ?? '' + } + }); + + completedCount++; + if (completedCount % PROGRESS_INTERVAL === 0 || completedCount === totalCount) { + addLog.info('LLM judge progress', { + taskId: String(task._id), + completed: completedCount, + total: totalCount + }); + } + + if (response.status !== 'success') { + addLog.warn('LLM judge failed for item', { + taskId: String(task._id), + itemId: item.itemId, + error: response.error + }); + return null; + } + + return { + itemId: item.itemId, + expectedIds: response.detected_data_ids ?? [], + baselineRankedIds: item.baselineRankedIds, + tunedRankedIds: item.tunedRankedIds + }; + }) + ) + ); + + propagateAbortFromResults(judgeResults); + + // Collect successful results + const successfulResults = judgeResults + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value) + .filter((v) => v !== null); + + const failedCount = judgeResults.length - successfulResults.length; + if (failedCount > 0) { + addLog.warn('Some LLM judge requests failed', { + taskId: String(task._id), + total: judgeResults.length, + failed: failedCount + }); + } + + if (successfulResults.length === 0) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.llm_judge, + RerankTrainErrEnum.rerankLLMJudgeDiTingFailed, + RerankTrainSuggestionEnum.rerankLLMJudgeDiTingFailed + ); + throw new TrainTaskRetriableError(enhancedError); + } + + addLog.info('LLM judge completed', { + taskId: String(task._id), + successfulResults: successfulResults.length, + failedCount + }); + + // Build judged expected IDs + const judgedExpectedIds = successfulResults.map((r) => ({ + itemId: r.itemId, + expectedIds: r.expectedIds + })); + + // Recompute baseline metrics + const baselineCases: RankingCase[] = successfulResults.map((r) => ({ + rankedIds: r.baselineRankedIds, + expectedIds: r.expectedIds + })); + const baseModelRejudgedResult = computeRankingMetrics(baselineCases, K_VALUES, 'rerank'); + + // Recompute tuned metrics + const tunedCases: RankingCase[] = successfulResults.map((r) => ({ + rankedIds: r.tunedRankedIds, + expectedIds: r.expectedIds + })); + const tunedModelRejudgedResult = computeRankingMetrics(tunedCases, K_VALUES, 'rerank'); + + addLog.info('LLM judge stage completed', { + taskId: String(task._id), + judgedItemCount: successfulResults.length, + baseMrr10: baseModelRejudgedResult.detailed_results.rerank_top10_mrr, + tunedMrr10: tunedModelRejudgedResult.detailed_results.rerank_top10_mrr + }); + + return { + judgedExpectedIds, + baseModelRejudgedResult, + tunedModelRejudgedResult + }; +} diff --git a/packages/service/core/train/rerank/task/stages/register.ts b/packages/service/core/train/rerank/task/stages/register.ts new file mode 100644 index 000000000000..e0f2f6428c2f --- /dev/null +++ b/packages/service/core/train/rerank/task/stages/register.ts @@ -0,0 +1,107 @@ +import type { RerankTrainTaskSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { + RerankTaskCheckpointStageEnum, + RerankTrainMethodEnum +} from '@fastgpt/global/core/train/rerank/constants'; +import { createRerankModelConfig } from '../../model/controller'; +import { addLog } from '../../../../../common/system/log'; +import { createRerankEnhancedError } from '../../utils'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { TrainTaskUnrecoverableError } from '../../../common/errors'; +import { getRerankModelById } from '../../../../ai/model'; + +/** + * Stage 5: Model Registration + * + * Registers the finetuned rerank model configuration in FastGPT model management system. + * + * @param task - Training task data + * @returns Tuned model ID + * @throws {UnrecoverableError} When tuned endpoint or base model not found + */ +export async function runRegisterStage(task: RerankTrainTaskSchemaType): Promise<{ + tunedModelId: string; +}> { + addLog.info('Run register stage', { taskId: String(task._id) }); + + const checkpointData = task.checkpoint.data || {}; + if (!checkpointData.finetuning?.tunedModelEndpoint) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.registering, + RerankTrainErrEnum.rerankRegisterEndpointNotFound, + RerankTrainSuggestionEnum.rerankRegisterEndpointNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + if (!task.baseModelId) { + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.registering, + RerankTrainErrEnum.rerankRegisterBaseModelNotFound, + RerankTrainSuggestionEnum.rerankRegisterBaseModelNotFound + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + const tunedEndpoint = checkpointData.finetuning.tunedModelEndpoint; + const baseModelId = task.baseModelId; + + // Use task.newModelName if provided, otherwise fall back to the model ID from SFT Bridge + const tunedModelName = task.newModelName || tunedEndpoint.model; + + // Inherit charsPointsPrice and instruction from base model + const baseModel = getRerankModelById(baseModelId); + + let tunedModelId: string; + try { + tunedModelId = await createRerankModelConfig({ + name: tunedModelName, + endpoint: tunedEndpoint, + isActive: true, + tmbId: task.tmbId, + teamId: task.teamId, + charsPointsPrice: baseModel.charsPointsPrice, + maxToken: baseModel.maxToken, + instruction: + task.trainMethod === RerankTrainMethodEnum.task_tuning ? undefined : baseModel.instruction, + taskId: String(task._id) + }); + + addLog.info('Created tuned model config and channel', { + taskId: String(task._id), + tunedModelId, + tunedModelName, + endpoint: tunedEndpoint + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // Distinguish channel availability timeout from other errors + const isChannelTimeout = errorMsg.includes('did not become available'); + const enhancedError = createRerankEnhancedError( + RerankTaskCheckpointStageEnum.registering, + isChannelTimeout + ? RerankTrainErrEnum.rerankRegisterChannelNotAvailable + : RerankTrainErrEnum.rerankRegisterAiProxyFailed, + isChannelTimeout + ? RerankTrainSuggestionEnum.rerankRegisterChannelNotAvailable + : RerankTrainSuggestionEnum.rerankRegisterAiProxyFailed, + errorMsg + ); + throw new TrainTaskUnrecoverableError(enhancedError); + } + + addLog.info('Register stage completed', { + taskId: String(task._id), + baseModelId, + tunedModelId, + tunedModelName + }); + + return { + tunedModelId + }; +} diff --git a/packages/service/core/train/rerank/task/utils.ts b/packages/service/core/train/rerank/task/utils.ts new file mode 100644 index 000000000000..1297f3b33a34 --- /dev/null +++ b/packages/service/core/train/rerank/task/utils.ts @@ -0,0 +1,51 @@ +/** + * Build aggregation pipeline for fetching train task with creator info + * @param baseFields - Additional fields to include in projection + * @returns MongoDB aggregation pipeline stages + */ +export function buildRerankTrainTaskAggregationPipeline(baseFields?: Record) { + return [ + // Lookup team member (creator) info + { + $lookup: { + from: 'team_members', + localField: 'tmbId', + foreignField: '_id', + as: 'teamMember' + } + }, + // Transform array to single object + { + $addFields: { + teamMember: { $arrayElemAt: ['$teamMember', 0] } + } + }, + // Project final fields + { + $project: { + _id: 1, + teamId: 1, + tmbId: 1, + name: 1, + baseModelId: 1, + baseModelEndpoint: 1, + trainsetId: 1, + evalDatasetId: 1, + datasetIds: 1, + newModelName: 1, + status: 1, + checkpoint: 1, + result: 1, + errorMsg: 1, + jobId: 1, + createTime: 1, + updateTime: 1, + finishTime: 1, + // Creator info from team member + creatorAvatar: '$teamMember.avatar', + creatorName: '$teamMember.name', + ...baseFields + } + } + ]; +} diff --git a/packages/service/core/train/rerank/task/worker.ts b/packages/service/core/train/rerank/task/worker.ts new file mode 100644 index 000000000000..e6e46eb35c26 --- /dev/null +++ b/packages/service/core/train/rerank/task/worker.ts @@ -0,0 +1,116 @@ +import { getWorker, QueueNames } from '../../../../common/bullmq'; +import { addLog } from '../../../../common/system/log'; +import { type RerankTrainTaskJobData } from './mq'; +import { rerankTrainTaskProcessor } from './processor'; +import { MongoRerankTrainTask } from './schema'; +import { RerankTrainTaskStatusEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { trainEnv } from '../../common/env'; +import { TrainTaskUnrecoverableError, TrainTaskRetriableError } from '../../common/errors'; +import { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import type { EnhancedErrorMessage } from '@fastgpt/global/core/train/rerank/error'; +import { createRerankEnhancedError } from '../utils'; + +export function initRerankTrainTaskWorker() { + const worker = getWorker( + QueueNames.rerankTrainTask, + rerankTrainTaskProcessor, + { + stalledInterval: trainEnv.TRAIN_WORKER_STALLED_INTERVAL, + maxStalledCount: + global.systemEnv?.trainConfig?.maxStalledCount || trainEnv.TRAIN_WORKER_MAX_STALLED_COUNT, + concurrency: global.systemEnv?.trainConfig?.taskConcurrency || trainEnv.TRAIN_TASK_CONCURRENCY + } + ); + + worker.on('active', (job) => { + if (job?.data) { + const { taskId } = job.data; + addLog.info('[RerankTrainTask] Task started', { + jobId: job.id, + taskId + }); + } + }); + + worker.on('completed', (job) => { + if (job?.data) { + const { taskId } = job.data; + addLog.info('[RerankTrainTask] Task completed', { + jobId: job.id, + taskId + }); + } + }); + + worker.on('stalled', async (jobId: string) => { + addLog.warn('[RerankTrainTask] Task stalled, will be retried', { + jobId + }); + }); + + worker.on('failed', async (job, error) => { + if (job?.data) { + const { taskId } = job.data; + + // Extract structured error information + let errorMsg: EnhancedErrorMessage; + + if ( + error instanceof TrainTaskUnrecoverableError || + error instanceof TrainTaskRetriableError + ) { + // Use structured error from custom error classes + errorMsg = error.enhancedError; + + addLog.error('[RerankTrainTask] Task failed', { + jobId: job.id, + taskId, + errorType: error.name, + errorMsg // Log complete structured error + }); + } else { + // Unknown error, construct basic structured error + errorMsg = createRerankEnhancedError( + null, + RerankTrainErrEnum.rerankUnknownError, + RerankTrainSuggestionEnum.rerankUnknownError, + error.stack + ); + + addLog.error('[RerankTrainTask] Task failed with unknown error', { + jobId: job.id, + taskId, + error: error.message, + stack: error.stack + }); + } + + try { + await MongoRerankTrainTask.updateOne( + { _id: taskId }, + { + status: RerankTrainTaskStatusEnum.failed, + errorMsg, // Save structured object + updateTime: new Date(), + finishTime: new Date() + } + ); + + addLog.info('[RerankTrainTask] Task status updated to failed in MongoDB', { + taskId + }); + } catch (updateError) { + addLog.error('[RerankTrainTask] Failed to update task status in MongoDB', { + taskId, + updateError: (updateError as Error).message + }); + } + } + }); + + addLog.info('[RerankTrainTask] Worker created successfully'); + return worker; +} diff --git a/packages/service/core/train/rerank/trainset/controller.ts b/packages/service/core/train/rerank/trainset/controller.ts new file mode 100644 index 000000000000..885df5d2823d --- /dev/null +++ b/packages/service/core/train/rerank/trainset/controller.ts @@ -0,0 +1,86 @@ +import { MongoRerankTrainset } from './schema'; +import type { RerankTrainsetSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { RerankTrainsetStatusEnum } from '@fastgpt/global/core/train/rerank/constants'; +import { addLog } from '../../../../common/system/log'; +import { calculateRerankTrainsetStats } from '../data/controller'; +import type { ClientSession } from '../../../../common/mongo'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; + +/** + * Create rerank trainset + * + * @param params - Trainset creation parameters + * @returns Trainset ID + */ +export async function createRerankTrainset(params: { + teamId: string; + tmbId: string; + name?: string; + description?: string; +}): Promise { + const { teamId, tmbId, name, description } = params; + + const [doc] = await MongoRerankTrainset.create([ + { + teamId, + tmbId, + name: name || `Rerank Training Set - ${new Date().toLocaleDateString()}`, + description, + status: RerankTrainsetStatusEnum.pending + } + ]); + + addLog.info('Created rerank trainset', { + teamId, + trainsetId: String(doc._id) + }); + + return doc.toObject() as RerankTrainsetSchemaType; +} + +/** + * Get trainset with statistics + * + * @param trainsetId - Trainset ID + * @param teamId - Team ID for permission validation + * @returns Trainset with statistics + * @throws {Error} When trainset not found or permission denied + */ +export async function getRerankTrainset( + trainsetId: string, + teamId: string +): Promise { + const trainset = await MongoRerankTrainset.findOne({ + _id: trainsetId, + teamId + }).lean(); + + if (!trainset) { + return Promise.reject(RerankTrainErrEnum.rerankTrainsetNotExist); + } + + // Dynamically calculate statistics + const statistics = await calculateRerankTrainsetStats(trainsetId); + + return { + ...trainset, + statistics + }; +} + +/** + * Delete trainset record + * + * Call within a MongoDB session/transaction when cascading deletions must be atomic. + * + * @param trainsetId - Trainset ID to delete + * @param session - Optional MongoDB session for transaction support + */ +export async function deleteRerankTrainset( + trainsetId: string, + session?: ClientSession +): Promise { + await MongoRerankTrainset.deleteOne({ _id: trainsetId }, { session }); + + addLog.info('Deleted rerank trainset', { trainsetId }); +} diff --git a/packages/service/core/train/rerank/trainset/schema.ts b/packages/service/core/train/rerank/trainset/schema.ts new file mode 100644 index 000000000000..3fecbfad4744 --- /dev/null +++ b/packages/service/core/train/rerank/trainset/schema.ts @@ -0,0 +1,52 @@ +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { RerankTrainsetSchemaType } from '@fastgpt/global/core/train/rerank/type'; +import { RerankTrainsetStatusEnum } from '@fastgpt/global/core/train/rerank/constants'; + +const RerankTrainsetSchema = new connectionMongo.Schema({ + teamId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team', + required: true + }, + tmbId: { + type: connectionMongo.Schema.Types.ObjectId, + ref: 'team_member', + required: true + }, + name: { + type: String, + required: true + }, + description: { + type: String + }, + status: { + type: String, + enum: Object.values(RerankTrainsetStatusEnum), + default: RerankTrainsetStatusEnum.pending + }, + errorMsg: { + type: connectionMongo.Schema.Types.Mixed // Support both string (legacy) and EnhancedErrorMessage object + }, + jobId: { + type: String + }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + } +}); + +// Indexes +RerankTrainsetSchema.index({ teamId: 1, updateTime: -1 }); +RerankTrainsetSchema.index({ status: 1 }); +RerankTrainsetSchema.index({ jobId: 1 }); + +export const MongoRerankTrainset = getMongoModel( + 'rerank_trainset', + RerankTrainsetSchema +); diff --git a/packages/service/core/train/rerank/utils.ts b/packages/service/core/train/rerank/utils.ts new file mode 100644 index 000000000000..180f4ca65040 --- /dev/null +++ b/packages/service/core/train/rerank/utils.ts @@ -0,0 +1,74 @@ +import { trainEnv } from '../common/env'; +import type { RerankTaskCheckpointStageEnum } from '@fastgpt/global/core/train/rerank/constants'; +import type { + RerankTrainErrEnum, + RerankTrainSuggestionEnum +} from '@fastgpt/global/common/error/code/train'; +import { + getRerankTrainErrorMessageKey, + getRerankTrainErrorSuggestionKey +} from '@fastgpt/global/common/error/code/train'; +import { + pLimit, + sampleDataFromDataset, + fetchSampledContent, + buildModelEndpoint, + getTrainStageKey, + formatTrainTaskError, + makeCreateEnhancedError, + propagateAbortFromResults +} from '../common/utils'; + +// Re-export common utilities for backward compatibility +export { + pLimit, + sampleDataFromDataset, + fetchSampledContent, + buildModelEndpoint, + getTrainStageKey, + formatTrainTaskError, + propagateAbortFromResults +}; + +// Re-export constants used by rerank-specific callers +export { trainEnv } from '../common/env'; + +/** + * Create enhanced error message for rerank training tasks + * + * All i18n keys are automatically generated through conversion functions: + * - stage: getTrainStageKey(stage) -> 'train:stage_xxx' + * - message: getRerankTrainErrorMessageKey(type) -> 'rerank_train:xxx' + * - suggestion: getRerankTrainErrorSuggestionKey(suggestion) -> 'rerank_train:xxx_suggestion' + * + * @param stage - Current training stage (can be null for general errors) + * @param type - Error type enum + * @param suggestion - Optional suggestion enum + * @param originalError - Optional original error message for debugging + * @returns Enhanced error message object + * + * @example + * // Simple error without suggestion + * const error = createRerankEnhancedError( + * RerankTaskCheckpointStageEnum.generate_trainset, + * RerankTrainErrEnum.rerankPrepareDataEmpty, + * RerankTrainSuggestionEnum.rerankPrepareDataEmpty + * ); + * + * @example + * // Error with dynamic debugging info + * const error = createRerankEnhancedError( + * RerankTaskCheckpointStageEnum.eval_tunedmodel, + * RerankTrainErrEnum.rerankEvalDatabaseSaveFailed, + * RerankTrainSuggestionEnum.rerankEvalDatabaseSaveFailed, + * errorMsg // originalError for debugging + * ); + */ +export const createRerankEnhancedError = makeCreateEnhancedError< + RerankTaskCheckpointStageEnum, + RerankTrainErrEnum, + RerankTrainSuggestionEnum +>({ + getMessageKey: getRerankTrainErrorMessageKey, + getSuggestionKey: getRerankTrainErrorSuggestionKey +}); diff --git a/packages/service/core/train/rerank/validation/dataset.ts b/packages/service/core/train/rerank/validation/dataset.ts new file mode 100644 index 000000000000..3c5eab3c1760 --- /dev/null +++ b/packages/service/core/train/rerank/validation/dataset.ts @@ -0,0 +1,37 @@ +import { addLog } from '../../../../common/system/log'; +import type { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { validateDatasetReadiness, type DatasetReadinessErrorConfig } from '../../common/utils'; + +const rerankErrorConfig: DatasetReadinessErrorConfig = { + noDatasetConfigured: RerankTrainErrEnum.rerankValidationNoDatasetConfigured, + datasetNoSynthesisIndex: RerankTrainErrEnum.rerankValidationDatasetNoSynthesisIndex, + insufficientChunks: RerankTrainErrEnum.rerankTaskInsufficientChunks +}; + +/** + * Validate that all given datasets have ready indexes for the target index type + * and meet the minimum chunk threshold. + * + * Delegates to shared validateDatasetReadiness with rerank-specific error enums. + * + * @param datasetIds - Dataset IDs to validate (must be non-empty) + * @param indexType - Target index type to validate (must exist in each dataset) + * @throws {RerankTrainErrEnum} When validation fails + */ +export async function validateDatasetTargetIndexes( + datasetIds: string[], + indexType: `${DatasetDataIndexTypeEnum}` +): Promise { + addLog.info('Rerank dataset validation started', { + datasetCount: datasetIds.length, + indexType + }); + + await validateDatasetReadiness(datasetIds, indexType, rerankErrorConfig); + + addLog.info('Rerank dataset validation completed', { + datasetCount: datasetIds.length, + indexType + }); +} diff --git a/packages/service/core/train/rerank/validation/environment.ts b/packages/service/core/train/rerank/validation/environment.ts new file mode 100644 index 000000000000..cf403cc90596 --- /dev/null +++ b/packages/service/core/train/rerank/validation/environment.ts @@ -0,0 +1,107 @@ +import { addLog } from '../../../../common/system/log'; +import { trainEnv } from '../../common/env'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import axios from 'axios'; + +/** + * Environment validation for rerank training task creation + * + * Validates that all required external services are accessible before creating a training task. + * Throws error codes directly instead of returning validation results. + */ + +/** + * Check if SFT Bridge service is accessible + * Attempts a simple connection test to the SFT Bridge service + * + * @throws {RerankTrainErrEnum.validationSftBridgeUnaccessible} If SFT Bridge is not accessible + */ +export async function validateSFTBridgeAccess(): Promise { + const sftBridgeUrl = trainEnv.SFT_BRIDGE_BASE_URL; + const timeout = trainEnv.SFT_BRIDGE_TIMEOUT; + + // Skip validation in mock mode + if (trainEnv.SFT_BRIDGE_MOCK_ENABLE) { + addLog.info('SFT Bridge validation skipped (mock mode enabled)'); + return; + } + + try { + // Try to access a health check endpoint or root endpoint with a short timeout + const healthCheckUrl = `${sftBridgeUrl}/v1/health`; + + addLog.info('Validating SFT Bridge access', { url: healthCheckUrl, timeout }); + + await axios.get(healthCheckUrl, { + timeout: Math.min(timeout, 5000), // Use max 5 seconds for validation + validateStatus: (status) => status < 500 // Accept any status below 500 (including 404) + }); + + addLog.info('SFT Bridge is accessible', { url: sftBridgeUrl }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error accessing SFT Bridge'; + + addLog.error('SFT Bridge validation failed', { + url: sftBridgeUrl, + error: errorMessage + }); + + return Promise.reject(RerankTrainErrEnum.rerankValidationSftBridgeUnaccessible); + } +} + +/** + * Check if DiTing service is accessible + * Attempts a simple connection test to the DiTing service + * + * @throws {RerankTrainErrEnum.validationDitingUnaccessible} If DiTing is not accessible + */ +export async function validateDiTingAccess(): Promise { + const ditingUrl = trainEnv.DITING_BASE_URL; + const timeout = trainEnv.DITING_TIMEOUT; + + // Skip validation in mock mode + if (trainEnv.DITING_MOCK_ENABLE) { + addLog.info('DiTing validation skipped (mock mode enabled)'); + return; + } + + try { + // Try to access a health check endpoint or root endpoint with a short timeout + const healthCheckUrl = `${ditingUrl}/api/v1/healthz`; + + addLog.info('Validating DiTing access', { url: healthCheckUrl, timeout }); + + await axios.get(healthCheckUrl, { + timeout: Math.min(timeout, 5000), // Use max 5 seconds for validation + validateStatus: (status) => status < 500 // Accept any status below 500 (including 404) + }); + + addLog.info('DiTing is accessible', { url: ditingUrl }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error accessing DiTing'; + + addLog.error('DiTing validation failed', { + url: ditingUrl, + error: errorMessage + }); + + return Promise.reject(RerankTrainErrEnum.rerankValidationDitingUnaccessible); + } +} + +/** + * Validate all external services required for training + * Runs all validation checks in parallel + * + * @throws {RerankTrainErrEnum} If any validation fails + */ +export async function validateTrainingEnvironment(): Promise { + addLog.info('Starting training environment validation'); + + // Run validations in parallel - will throw on first failure + await Promise.all([validateSFTBridgeAccess(), validateDiTingAccess()]); + + addLog.info('Training environment validation successful'); +} diff --git a/packages/service/core/train/rerank/validation/index.ts b/packages/service/core/train/rerank/validation/index.ts new file mode 100644 index 000000000000..62e557bbaaa4 --- /dev/null +++ b/packages/service/core/train/rerank/validation/index.ts @@ -0,0 +1,12 @@ +/** + * Unified validation entry point for rerank training + * + * Exports all validation functions for easy import + */ + +export { + validateTrainingEnvironment, + validateSFTBridgeAccess, + validateDiTingAccess +} from './environment'; +export { validateDatasetTargetIndexes } from './dataset'; diff --git a/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts b/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts index 49cc9d33f627..0569c2ccb36b 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts @@ -11,11 +11,11 @@ import { SandboxFetchUserFileSchema } from '@fastgpt/global/core/workflow/node/agent/skillTools'; import { - createAgentSandbox, - releaseAgentSandbox, - connectEditDebugSandbox, - disconnectEditDebugSandbox -} from '../sub/sandbox'; + SANDBOX_GET_FILE_URL_TOOL, + SANDBOX_GET_FILE_URL_TOOL_NAME, + SandboxGetFileUrlToolSchema +} from '@fastgpt/global/core/ai/sandbox/constants'; +import { createAgentSandbox, releaseAgentSandbox } from '../sub/sandbox'; import { buildSkillsContextPrompt } from '../sub/sandbox/prompt'; import { parseJsonArgs } from '../../../../../ai/utils'; import { @@ -26,6 +26,7 @@ import { dispatchSandboxSearch, dispatchSandboxFetchUserFile } from '../sub/sandbox/skill'; +import { uploadSandboxFileToS3 } from '../../../../../ai/sandbox/toolCall'; import { getLogger, LogCategories } from '../../../../../../common/logger'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { WorkflowResponseType } from '../../../type'; @@ -45,6 +46,8 @@ type SandboxToolResult = { response: string; usages: any[]; assistantResponses?: AIChatItemValueItemType[]; + /** Skill names detected when reading SKILL.md files */ + skillNames?: string[]; }; type SandboxSkillsCapabilityParams = { @@ -52,10 +55,16 @@ type SandboxSkillsCapabilityParams = { teamId: string; tmbId: string; sessionId: string; - mode: 'sessionRuntime' | 'editDebug'; workflowStreamResponse?: WorkflowResponseType; // SSE stream for lifecycle and skill events showSkillReferences: boolean; allFilesMap: Record; + // When true (chat-mode pi agent), expose `sandbox_get_file_url` so the model can + // hand sandbox files back to the user as previewable/downloadable links. + // Requires `appId`/`userId`/`chatId` to be set for S3 object keying. + exposeGetFileUrl?: boolean; + appId?: string; + userId?: string; + chatId?: string; }; /** Fetch skill metadata from MongoDB and compute sandbox paths from the ZIP for prompt construction. */ @@ -140,26 +149,45 @@ export function collectSkillReferenceResponses({ sandboxContext, workflowStreamResponse, showSkillReferences, - toolCallId + toolCallId, + fileContentMap }: { paths: string[]; sandboxContext: AgentSandboxContext; workflowStreamResponse?: WorkflowResponseType; showSkillReferences: boolean; toolCallId: string; -}): AIChatItemValueItemType[] { - if (!showSkillReferences) return []; + fileContentMap?: Record; +}): { skillItems: AIChatItemValueItemType[]; skillNames: string[] } { + if (!showSkillReferences) return { skillItems: [], skillNames: [] }; - const skillResponses: AIChatItemValueItemType[] = []; + const skillItems: AIChatItemValueItemType[] = []; + const skillNames: string[] = []; for (const filePath of paths) { if (!filePath.endsWith('/SKILL.md')) continue; - const skill = sandboxContext.deployedSkills.find( - (s) => s.skillMdPath === filePath || filePath.startsWith(s.directory + '/') - ); + // Find the most specific skill match by longest directory/prefix. + // Array.find() returns the first match, which may be a broader parent + // when skill directories nest (e.g. /skill-a/ and /skill-a/sub-plugin/). + let skill: (typeof sandboxContext.deployedSkills)[number] | undefined; + let bestMatchLength = 0; + for (const s of sandboxContext.deployedSkills) { + if (s.skillMdPath === filePath || filePath.startsWith(s.directory + '/')) { + const matchLen = s.skillMdPath === filePath ? filePath.length : s.directory.length; + if (matchLen > bestMatchLength) { + bestMatchLength = matchLen; + skill = s; + } + } + } if (!skill) continue; - // Use toolCallId from the triggering tool call for correlation + if (!skillNames.includes(skill.name)) { + skillNames.push(skill.name); + } + + // Emit skillCall with completed status and optional content + const skillContent = fileContentMap?.[filePath]; workflowStreamResponse?.({ id: toolCallId, event: SseResponseEventEnum.skillCall, @@ -169,24 +197,28 @@ export function collectSkillReferenceResponses({ skillName: skill.name, skillAvatar: skill.avatar || '', description: skill.description, - skillMdPath: filePath + skillMdPath: filePath, + status: 'completed', + content: skillContent } } }); - skillResponses.push({ + skillItems.push({ skills: [ { id: toolCallId, skillName: skill.name, skillAvatar: skill.avatar || '', description: skill.description, - skillMdPath: filePath + skillMdPath: filePath, + status: 'completed', + content: skillContent } ] }); } - return skillResponses; + return { skillItems, skillNames }; } export async function createSandboxSkillsCapability( @@ -197,71 +229,40 @@ export async function createSandboxSkillsCapability( teamId, tmbId, sessionId, - mode, workflowStreamResponse, showSkillReferences, - allFilesMap + allFilesMap, + exposeGetFileUrl, + appId, + userId, + chatId } = params; - const isEditDebug = mode === 'editDebug'; + // Only chat-mode can serve sandbox_get_file_url because the file + // upload S3 key requires appId/userId/chatId. + const canExposeGetFileUrl = !!exposeGetFileUrl && !!appId && !!userId && !!chatId; + const sessionCompletionTools = canExposeGetFileUrl + ? [...allSandboxTools, SANDBOX_GET_FILE_URL_TOOL] + : allSandboxTools; const defaults = getSandboxDefaults(); const logger = getLogger(LogCategories.MODULE.AI.AGENT); - // editDebug: keep existing immediate-connect behavior - if (isEditDebug) { - if (skillIds.length !== 1) { - throw new Error('useEditDebugSandbox only supports a single skill'); - } - const sandboxContext = await connectEditDebugSandbox({ - skillId: skillIds[0], - teamId - }); - - const systemPrompt = buildSkillsContextPrompt( - sandboxContext.deployedSkills, - sandboxContext.workDirectory - ); - - return { - id: 'sandbox-skills', - systemPrompt, - completionTools: allSandboxTools, - handleToolCall: async (toolId, args, toolCallId) => { - if (!(Object.values(SandboxToolIds) as string[]).includes(toolId)) return null; - const result = await buildEditDebugHandler( - toolId, - args, - sandboxContext, - allFilesMap, - workflowStreamResponse, - showSkillReferences, - toolCallId - ); - if (result !== null) { - // Fire-and-forget: renew sandbox expiration after successful execution - MongoSandboxInstance.updateOne( - { sandboxId: sandboxContext.providerSandboxId }, - { lastActiveAt: new Date() } - ).catch((err) => - logger.error('[Agent Sandbox] Failed to renew lastActiveAt', { error: err }) - ); - } - return result; - }, - dispose: async () => { - disconnectEditDebugSandbox(sandboxContext).catch((err) => { - logger.error('[Agent Sandbox] Disconnect failed', { error: err }); - }); - } - }; - } - // Session-runtime: preload skill metadata with sandbox paths from ZIP (no container creation) const skillsMeta = skillIds.length > 0 ? await fetchSkillsMetaForPrompt(skillIds, teamId, defaults.workDirectory) : []; - const systemPrompt = buildSkillsContextPrompt(skillsMeta, defaults.workDirectory); + const systemPrompt = canExposeGetFileUrl + ? `${buildSkillsContextPrompt(skillsMeta, defaults.workDirectory)}\n\n${buildGetFileUrlPromptSection()}` + : buildSkillsContextPrompt(skillsMeta, defaults.workDirectory); + + // Build a path→name map so callers can pre-resolve tool display names + // before SSE emission when sandbox_read_file reads SKILL.md files. + const skillPathMap: Record = {}; + for (const s of skillsMeta) { + if (s.skillMdPath) skillPathMap[s.skillMdPath] = s.name; + if (s.directory) skillPathMap[s.directory + '/'] = s.name; + } // --- Lazy-init state --- let sandboxContext: AgentSandboxContext | null = null; @@ -274,7 +275,15 @@ export async function createSandboxSkillsCapability( async function initializeSandbox(): Promise { onProgress?.({ sandboxId: sessionId, phase: 'lazyInit' }); - return createAgentSandbox({ skillIds, teamId, tmbId, sessionId, onProgress }); + return createAgentSandbox({ + skillIds, + teamId, + tmbId, + sessionId, + chatId: chatId ?? sessionId, + onProgress, + skillsMeta + }); } async function ensureSandbox(): Promise { @@ -338,8 +347,17 @@ export async function createSandboxSkillsCapability( return { id: 'sandbox-skills', systemPrompt, - completionTools: allSandboxTools, + completionTools: sessionCompletionTools, handleToolCall: async (toolId, args, toolCallId) => { + if (canExposeGetFileUrl && toolId === SANDBOX_GET_FILE_URL_TOOL_NAME) { + return executeWithRetry((ctx) => + dispatchSandboxGetFileUrl(ctx, args, { + appId: appId!, + userId: userId!, + chatId: chatId! + }) + ); + } if (!(Object.values(SandboxToolIds) as string[]).includes(toolId)) return null; return executeWithRetry(async (ctx) => { @@ -360,13 +378,14 @@ export async function createSandboxSkillsCapability( logger.error('[Agent Sandbox] Release failed', { error: err }); }); } - } + }, + skillPathMap }; } // --- Handler builders --- -async function buildEditDebugHandler( +async function buildSessionHandler( toolId: string, args: string, sandboxContext: AgentSandboxContext, @@ -374,23 +393,34 @@ async function buildEditDebugHandler( workflowStreamResponse?: WorkflowResponseType, showSkillReferences = false, toolCallId = '' -): Promise { +): Promise { const handlers: Record Promise> = { [SandboxToolIds.readFile]: async () => { const parsed = SandboxReadFileSchema.safeParse(parseJsonArgs(args)); if (!parsed.success) return { response: parsed.error.message, usages: [] }; - const assistantResponses = collectSkillReferenceResponses({ + const readResult = await dispatchSandboxReadFile(sandboxContext, parsed.data); + const fileContentMap: Record = {}; + if (readResult.files) { + for (const file of readResult.files) { + fileContentMap[file.path] = file.content; + } + } + + const { skillItems: assistantResponses, skillNames } = collectSkillReferenceResponses({ paths: parsed.data.paths, sandboxContext, workflowStreamResponse, showSkillReferences, - toolCallId + toolCallId, + fileContentMap }); return { - ...(await dispatchSandboxReadFile(sandboxContext, parsed.data)), - ...(assistantResponses.length > 0 && { assistantResponses }) + response: readResult.response, + usages: readResult.usages, + ...(assistantResponses.length > 0 && { assistantResponses }), + ...(skillNames.length > 0 && { skillNames }) }; }, [SandboxToolIds.writeFile]: async () => { @@ -421,65 +451,57 @@ async function buildEditDebugHandler( }; const handler = handlers[toolId]; - if (!handler) return null; + if (!handler) return { response: 'Unknown sandbox tool', usages: [] }; return handler(); } -async function buildSessionHandler( - toolId: string, - args: string, +async function dispatchSandboxGetFileUrl( sandboxContext: AgentSandboxContext, - allFilesMap: Record, - workflowStreamResponse?: WorkflowResponseType, - showSkillReferences = false, - toolCallId = '' + args: string, + ownership: { appId: string; userId: string; chatId: string } ): Promise { - const handlers: Record Promise> = { - [SandboxToolIds.readFile]: async () => { - const parsed = SandboxReadFileSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - - const assistantResponses = collectSkillReferenceResponses({ - paths: parsed.data.paths, - sandboxContext, - workflowStreamResponse, - showSkillReferences, - toolCallId - }); - - return { - ...(await dispatchSandboxReadFile(sandboxContext, parsed.data)), - ...(assistantResponses.length > 0 && { assistantResponses }) - }; - }, - [SandboxToolIds.writeFile]: async () => { - const parsed = SandboxWriteFileSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - return dispatchSandboxWriteFile(sandboxContext, parsed.data); - }, - [SandboxToolIds.editFile]: async () => { - const parsed = SandboxEditFileSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - return dispatchSandboxEditFile(sandboxContext, parsed.data); - }, - [SandboxToolIds.execute]: async () => { - const parsed = SandboxExecuteSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - return dispatchSandboxExecute(sandboxContext, parsed.data); - }, - [SandboxToolIds.search]: async () => { - const parsed = SandboxSearchSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - return dispatchSandboxSearch(sandboxContext, parsed.data); - }, - [SandboxToolIds.fetchUserFile]: async () => { - const parsed = SandboxFetchUserFileSchema.safeParse(parseJsonArgs(args)); - if (!parsed.success) return { response: parsed.error.message, usages: [] }; - return dispatchSandboxFetchUserFile(sandboxContext, parsed.data, allFilesMap); - } - }; + const parsed = SandboxGetFileUrlToolSchema.safeParse(parseJsonArgs(args)); + if (!parsed.success) return { response: parsed.error.message, usages: [] }; + + try { + const result = await Promise.all( + parsed.data.paths.map((filePath) => + uploadSandboxFileToS3({ + sandboxProvider: sandboxContext.sandbox, + appId: ownership.appId, + userId: ownership.userId, + chatId: ownership.chatId, + filePath + }) + ) + ); + return { response: JSON.stringify(result), usages: [] }; + } catch (err) { + return { response: `sandbox_get_file_url failed: ${(err as Error).message}`, usages: [] }; + } +} - const handler = handlers[toolId]; - if (!handler) return { response: 'Unknown sandbox tool', usages: [] }; - return handler(); +function buildGetFileUrlPromptSection(): string { + return [ + '', + 'Files produced inside the sandbox stay invisible to the user until you publish them with `sandbox_get_file_url`.', + 'WHEN to call it:', + ' - After `sandbox_write_file` creates a deliverable for the user (report, CSV, JSON, markdown, code bundle, etc.).', + ' - After `sandbox_execute` produces a new file or modifies one the user should see (charts, PDFs, archives, generated assets, downloadable logs).', + ' - When the user asks for a file, a screenshot, a chart, an export, a download, or a "可下载/可预览" artifact.', + 'WHEN NOT to call it:', + ' - For ephemeral scratch files, intermediate caches, or files used only by the next tool call.', + ' - For raw text answers that fit comfortably in chat — answer inline instead.', + 'HOW to call it:', + ' - Pass relative sandbox paths in the same form you used to write them (e.g. `["output/report.pdf", "charts/loss.png"]`).', + ' - Batch multiple files into one call when they belong to the same deliverable.', + ' - Call it AFTER the file is fully written/closed by the producing tool.', + 'AFTER the call:', + ' - The tool returns `[{ fileUrl, filename }, ...]`. The frontend automatically renders these as preview / download cards; you do NOT need to paste raw URLs in your reply.', + ' - Briefly mention the file by name (and what it contains) in natural language so the user knows what to look for.', + 'IMPORTANT:', + ' - URLs are signed and expire after 1 day; regenerate them if the user comes back later.', + ' - Never expose internal sandbox absolute paths to the user — only filenames.', + '' + ].join('\n'); } diff --git a/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts b/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts index 89a1f37d63b2..0b92a2db9666 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts @@ -5,6 +5,8 @@ export type CapabilityToolCallResult = { response: string; usages?: any[]; assistantResponses?: AIChatItemValueItemType[]; + /** Skill names loaded by this tool call (e.g. ["pptx", "pdf"]) */ + skillNames?: string[]; }; export type CapabilityToolCallHandlerType = ( @@ -28,6 +30,10 @@ export type AgentCapability = { ) => Promise; // Resource cleanup (called in finally) dispose?: () => Promise; + // Map from file path (or directory prefix) to skill display name. + // Used to pre-resolve tool display names before SSE emission when + // sandbox_read_file reads SKILL.md files. + skillPathMap?: Record; }; // Create a composite tool call handler that tries each capability in order diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index 0f54bab15ab4..2eb56a0ffe40 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -8,6 +8,9 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import { getNodeErrResponse, getHistories } from '../../utils'; +import { compressLargeContent } from '../../../../ai/llm/compress'; +import { countPromptTokens } from '../../../../../common/string/tiktoken'; +import { getLLMModelById } from '../../../../ai/model'; import type { AIChatItemValueItemType, ChatHistoryItemResType, @@ -46,6 +49,8 @@ import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type import { getLogger, LogCategories } from '../../../../../common/logger'; import { env } from '../../../../../env'; import { dispatchPiAgent } from './piAgent'; +import { resolveDatasetParams } from './resolveDatasetParams'; +import { hashStr } from '@fastgpt/global/common/string/tools'; export type DispatchAgentModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.history]?: ChatItemMiniType[]; @@ -53,18 +58,19 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.aiChatVision]?: boolean; [NodeInputKeyEnum.fileUrlList]?: string[]; - [NodeInputKeyEnum.aiModel]: string; + [NodeInputKeyEnum.aiModelId]: string; [NodeInputKeyEnum.aiSystemPrompt]: string; [NodeInputKeyEnum.selectedTools]?: SkillToolType[]; [NodeInputKeyEnum.skills]?: Array; // 兼容 string[](debugChat)和对象数组(workflow NodeAgent) - [NodeInputKeyEnum.useEditDebugSandbox]?: boolean; // 客户端显式指定使用 editDebug 沙箱 // Knowledge base search configuration [NodeInputKeyEnum.datasetParams]?: AppFormEditFormType['dataset']; // Sandbox (Computer Use) [NodeInputKeyEnum.useAgentSandbox]?: boolean; + // Deep thinking (reasoning) + [NodeInputKeyEnum.aiChatReasoning]?: boolean; }>; type Response = DispatchNodeResultType<{ @@ -102,20 +108,19 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise chatId, showSkillReferences, params: { - model, + modelId, systemPrompt, userChatInput, // 本次任务的输入 history = 6, fileUrlList: fileLinks, agent_selectedTools: selectedTools = [], skills: skillIds = [], - useEditDebugSandbox, - // Dataset search configuration - agent_datasetParams: datasetParams, // Sandbox (Computer Use) useAgentSandbox = false } } = props; + // Dataset search: resolve from composite or individual fields + const datasetParams = resolveDatasetParams(props.params); const chatHistories = getHistories(history, histories); const aiHistoryValues = chatHistories .filter((item) => item.obj === ChatRoleEnum.AI) @@ -162,9 +167,44 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise // 交互模式进来的话,这个值才是交互输入的值 const { text: queryInput, files: queryFiles } = chatValue2RuntimePrompt(query); + + // Auto-compress ultra-long user input before agent processing + let finalUserChatInput = userChatInput; + + if (finalUserChatInput) { + const modelData = getLLMModelById(modelId); + if (modelData) { + const inputTokens = await countPromptTokens(finalUserChatInput); + const threshold = Math.floor(modelData.maxContext * env.USER_INPUT_COMPRESS_THRESHOLD); + + if (inputTokens > threshold) { + const targetTokens = Math.floor(modelData.maxContext * env.USER_INPUT_COMPRESS_TARGET); + + getLogger(LogCategories.MODULE.AI.AGENT).debug( + 'User input auto-compression triggered in agent', + { inputTokens, threshold, targetTokens } + ); + + const result = await compressLargeContent({ + content: finalUserChatInput, + model: modelData, + maxTokens: targetTokens + }); + + if (result.compressed && result.compressed !== finalUserChatInput) { + finalUserChatInput = result.compressed; + + if (result.usage) { + usagePush([result.usage]); + } + } + } + } + } + const formatUserChatInput = fileInputPrompt - ? `${fileInputPrompt}\n\n${userChatInput}` - : userChatInput; + ? `${fileInputPrompt}\n\n${finalUserChatInput}` + : finalUserChatInput; const currentUserMessage = chats2GPTMessages({ messages: [ { @@ -215,22 +255,21 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise } })(); // Initialize capabilities — always create sandbox capability (lazy-init, no container yet) - // Skill capability is gated by SHOW_SKILL env: when disabled, we skip skill loading entirely - // (no MongoDB query, no sandbox init), even if existing apps still have skills configured. - if (env.SHOW_SKILL) { - const sandboxSessionId = mode === 'chat' ? chatId : `debug-${runningAppInfo.id}-${nodeId}`; - const useEditDebugSandbox_flag = !!useEditDebugSandbox; - const sandboxMode = useEditDebugSandbox_flag ? 'editDebug' : 'sessionRuntime'; + { + const sandboxSessionId = + mode === 'chat' + ? chatId + : `debug-${hashStr(`${runningAppInfo.id}-${nodeId}-${chatId}`).slice(0, 40)}`; const sandboxCap = await createSandboxSkillsCapability({ skillIds: normalizedSkillIds, teamId: runningAppInfo.teamId, tmbId: runningAppInfo.tmbId, sessionId: sandboxSessionId, - mode: sandboxMode, workflowStreamResponse, showSkillReferences: showSkillReferences === true, - allFilesMap + allFilesMap, + chatId }); capabilities.push(sandboxCap); } @@ -244,6 +283,11 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise const capabilityTools = capabilities.flatMap((c) => c.completionTools ?? []); const capabilityToolCallHandler = capabilities.length > 0 ? createCapabilityToolCallHandler(capabilities) : undefined; + // Merge skill path maps from all capabilities for pre-resolving tool display names + const skillPathMap: Record = Object.assign( + {}, + ...capabilities.map((c) => c.skillPathMap ?? {}) + ); // Get sub apps const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( @@ -345,7 +389,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise completionTools: agentCompletionTools, getSubAppInfo, systemPrompt: formatedSystemPrompt, - model, + modelId, stream, mode: 'interactive', // 初始规划模式 @@ -393,7 +437,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise completionTools: agentCompletionTools, getSubAppInfo, systemPrompt: formatedSystemPrompt, - model, + modelId, stream, mode: 'continue', // 继续规划模式 query: getContinuePlanQuery({ @@ -477,7 +521,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise steps: agentPlan.steps, // 传入所有步骤,而不仅仅是未执行的步骤 step, filesMap, - capabilityToolCallHandler + capabilityToolCallHandler, + skillPathMap }); nodeResponses.push(result.nodeResponse); @@ -494,10 +539,49 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise planId: agentPlan!.planId, stepId: step.id })); - assistantResponses.push(...assistantResponse); + + // Interleave skill items into chronological position by matching + // skill.skills[0].id ↔ tool.id, then stamp stepId. if (result.capabilityAssistantResponses?.length) { + const toolIdToIndex: Record = {}; + assistantResponse.forEach((item, idx) => { + const toolId = + (item as AIChatItemValueItemType).tool?.id || + (item as AIChatItemValueItemType).tools?.[0]?.id; + if (toolId) toolIdToIndex[toolId] = idx; + }); + + const fallbackItems: AIChatItemValueItemType[] = []; + for (const skillItem of result.capabilityAssistantResponses) { + const callId = skillItem.skills?.[0]?.id; + if (callId && toolIdToIndex[callId] !== undefined) { + assistantResponse[toolIdToIndex[callId]] = { + ...skillItem, + planId: agentPlan!.planId, + stepId: step.id + }; + } else { + fallbackItems.push({ + ...skillItem, + planId: agentPlan!.planId, + stepId: step.id + }); + } + } + assistantResponses.push(...assistantResponse, ...fallbackItems); + } else { + assistantResponses.push(...assistantResponse); + } + // Persist tool calls so they survive page refresh. + // GPTMessages2Chats already produces tools from LLM tool_calls when + // the model supports native function calling; collectedTools fills + // the gap for models that embed tool calls in text/thinking content. + const stepHasToolsFromGPT = assistantResponse.some( + (item: AIChatItemValueItemType) => (item.tools?.length ?? 0) > 0 || item.tool + ); + if (!stepHasToolsFromGPT && result.collectedTools?.length) { assistantResponses.push( - ...result.capabilityAssistantResponses.map((item) => ({ + ...result.collectedTools.map((item) => ({ ...item, stepId: step.id })) @@ -560,7 +644,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise getSubApp, completionTools: agentCompletionTools, filesMap, - capabilityToolCallHandler + capabilityToolCallHandler, + skillPathMap }); nodeResponses.push(result.nodeResponse); masterMessages = result.masterMessages; @@ -573,9 +658,39 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise }) .map((item) => item.value as AIChatItemValueItemType[]) .flat(); - assistantResponses.push(...assistantResponse); + + // Interleave skill items into chronological position by matching + // skill.skills[0].id ↔ tool.id from GPTMessages2Chats output. + // This preserves correct ordering after page refresh. if (result.capabilityAssistantResponses?.length) { - assistantResponses.push(...result.capabilityAssistantResponses); + const toolIdToIndex: Record = {}; + assistantResponse.forEach((item, idx) => { + const toolId = item.tool?.id || item.tools?.[0]?.id; + if (toolId) toolIdToIndex[toolId] = idx; + }); + + const fallbackItems: AIChatItemValueItemType[] = []; + for (const skillItem of result.capabilityAssistantResponses) { + const callId = skillItem.skills?.[0]?.id; + if (callId && toolIdToIndex[callId] !== undefined) { + assistantResponse[toolIdToIndex[callId]] = skillItem; + } else { + fallbackItems.push(skillItem); + } + } + assistantResponses.push(...assistantResponse, ...fallbackItems); + } else { + assistantResponses.push(...assistantResponse); + } + // Persist tool calls so they survive page refresh. + // GPTMessages2Chats already produces tools from LLM tool_calls when + // the model supports native function calling; collectedTools fills + // the gap for models that embed tool calls in text/thinking content. + const masterHasToolsFromGPT = assistantResponse.some( + (item: AIChatItemValueItemType) => (item.tools?.length ?? 0) > 0 || item.tool + ); + if (!masterHasToolsFromGPT && result.collectedTools?.length) { + assistantResponses.push(...result.collectedTools); } // 触发了 plan diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts index 18a5f06a2267..30cae76988ae 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts @@ -3,7 +3,11 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; import { runAgentCall } from '../../../../../ai/llm/agentCall'; -import { chats2GPTMessages, runtimePrompt2ChatsValue } from '@fastgpt/global/core/chat/adapt'; +import { + chats2GPTMessages, + GPTMessages2Chats, + runtimePrompt2ChatsValue +} from '@fastgpt/global/core/chat/adapt'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { addFilePrompt2Input, ReadFileToolSchema } from '../sub/file/utils'; import { type AgentStepItemType } from '@fastgpt/global/core/ai/agent/type'; @@ -18,7 +22,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; import { DatasetSearchToolSchema } from '../sub/dataset/utils'; import { dispatchAgentDatasetSearch } from '../sub/dataset'; import type { DispatchAgentModuleProps } from '..'; -import { getLLMModel } from '../../../../../ai/model'; +import { getLLMModelById } from '../../../../../ai/model'; import { getStepCallQuery, getStepDependon } from './dependon'; import { getOneStepResponseSummary } from './responseSummary'; import type { DispatchPlanAgentResponse } from '../sub/plan'; @@ -29,8 +33,9 @@ import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { i18nT } from '../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../global/common/i18n/utils'; import { getMasterSystemPrompt } from './prompt'; import { PlanAgentParamsSchema } from '../sub/plan/constants'; import { filterMemoryMessages } from '../../utils'; @@ -44,6 +49,7 @@ import { } from '@fastgpt/global/core/ai/sandbox/constants'; import { dispatchSandboxShell, dispatchSandboxGetFileUrl } from '../sub/sandbox'; import type { CapabilityToolCallHandlerType } from '../capability/type'; +import { resolveSkillDisplayName } from '../piAgent/toolAdapter'; type Response = { stepResponse?: { @@ -55,6 +61,8 @@ type Response = { assistantMessages: ChatCompletionMessageParam[]; masterMessages: ChatCompletionMessageParam[]; capabilityAssistantResponses?: AIChatItemValueItemType[]; + /** Tool calls collected from onToolCall for persisting to chat history */ + collectedTools?: AIChatItemValueItemType[]; nodeResponse: ChatHistoryItemResType; }; @@ -87,6 +95,8 @@ export const masterCall = async ({ // Capability tool call handler capabilityToolCallHandler?: CapabilityToolCallHandlerType; + // Map from file path prefix to skill name for pre-resolving tool display names + skillPathMap?: Record; }): Promise => { const { checkIsStopping, @@ -100,19 +110,23 @@ export const masterCall = async ({ stream, workflowStreamResponse, usagePush, + skillPathMap, params: { - model, + modelId, // Dataset search configuration agent_datasetParams: datasetParams, // Sandbox (Computer Use) useAgentSandbox = false, - aiChatVision + aiChatVision, + // Deep thinking (reasoning) + aiChatReasoning = true } } = props; const startTime = Date.now(); const childrenResponses: ChatHistoryItemResType[] = []; const capabilityAssistantResponses: AIChatItemValueItemType[] = []; + const collectedTools: AIChatItemValueItemType[] = []; const isStepCall = steps && step; const stepId = step?.id; const stepStreamResponse = (args: WorkflowResponseItemType) => { @@ -138,7 +152,7 @@ export const masterCall = async ({ nodeResponse } = await getStepDependon({ checkIsStopping, - model, + modelId, steps, step }); @@ -157,7 +171,7 @@ export const masterCall = async ({ checkIsStopping, steps, step, - model, + modelId, filesMap }); if (callQuery.usage) { @@ -216,7 +230,7 @@ export const masterCall = async ({ let planResult: DispatchPlanAgentResponse | undefined; const { - model: agentModel, + modelId: agentModelId, assistantMessages, completeMessages, inputTokens, @@ -230,7 +244,7 @@ export const masterCall = async ({ maxRunAgentTimes: 100, body: { messages: requestMessages, - model: getLLMModel(model), + modelId: modelId, stream: true, useVision: aiChatVision, tools: isStepCall @@ -244,6 +258,7 @@ export const masterCall = async ({ // childrenInteractiveParams onReasoning({ text }) { + if (!aiChatReasoning) return; stepStreamResponse?.({ event: SseResponseEventEnum.answer, data: textAdaptGptResponse({ @@ -266,19 +281,43 @@ export const masterCall = async ({ return; } - stepStreamResponse?.({ - id: call.id, - event: SseResponseEventEnum.toolCall, - data: { + // When sandbox_read_file reads SKILL.md, skip the tool display — + // completed skill references are emitted by collectSkillReferenceResponses. + const toolArgs = parseJsonArgs(call.function.arguments); + const skillDisplayName = resolveSkillDisplayName(toolArgs ?? {}, skillPathMap); + const isSkillRead = !!skillDisplayName; + + if (isSkillRead) { + // Skill display handled by skillCall events from collectSkillReferenceResponses + } else { + // Non-skill sandbox_read_file: emit normal toolCall + const toolName = subApp?.name ?? call.function.name; + + // Persist tool call to chat history (SSE also streams it for real-time display) + collectedTools.push({ tool: { id: call.id, - toolName: subApp?.name || call.function.name, + toolName, toolAvatar: subApp?.avatar || '', functionName: call.function.name, params: call.function.arguments ?? '' } - } - }); + }); + + stepStreamResponse?.({ + id: call.id, + event: SseResponseEventEnum.toolCall, + data: { + tool: { + id: call.id, + toolName, + toolAvatar: subApp?.avatar || '', + functionName: call.function.name, + params: call.function.arguments ?? '' + } + } + }); + } }, onToolParam({ tool, params }) { stepStreamResponse?.({ @@ -298,7 +337,7 @@ export const masterCall = async ({ moduleType: FlowNodeTypeEnum.emptyNode, moduleName: i18nT('chat:compress_llm_messages'), moduleLogo: 'core/app/agent/child/contextCompress', - model: modelName, + modelId: modelId, inputTokens, outputTokens, totalPoints, @@ -334,7 +373,7 @@ export const masterCall = async ({ teamId: runningUserInfo.teamId, tmbId: runningUserInfo.tmbId, customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse, - model, + modelId, userKey: externalProvider.openaiAccount }); @@ -375,15 +414,18 @@ export const masterCall = async ({ searchMode: datasetParams.searchMode, embeddingWeight: datasetParams.embeddingWeight, usingReRank: datasetParams.usingReRank ?? false, - rerankModel: datasetParams.rerankModel, + rerankModelId: datasetParams.rerankModelId, rerankWeight: datasetParams.rerankWeight || 0.5, usingExtensionQuery: datasetParams.datasetSearchUsingExtensionQuery ?? false, - extensionModel: datasetParams.datasetSearchExtensionModel, - extensionBg: datasetParams.datasetSearchExtensionBg + extensionModelId: datasetParams.datasetSearchExtensionModelId, + extensionBg: datasetParams.datasetSearchExtensionBg, + collectionFilterMatch: datasetParams.collectionFilterMatch, + authTmbId: datasetParams.authTmbId }, teamId: runningUserInfo.teamId, tmbId: runningUserInfo.tmbId, - llmModel: model + llmModelId: modelId, + lang: props.lang }); if (result.nodeResponse) { @@ -467,7 +509,7 @@ export const masterCall = async ({ completionTools, getSubAppInfo, systemPrompt, - model, + modelId, stream, mode: 'initial', ...toolArgs.data, @@ -503,6 +545,7 @@ export const masterCall = async ({ childrenResponses.push({ nodeId: callId, id: callId, + toolId, moduleType: FlowNodeTypeEnum.tool, moduleName: subInfo.name, moduleLogo: subInfo.avatar, @@ -669,6 +712,12 @@ export const masterCall = async ({ } }); + // Update collected tool item with response so it persists correctly + const collectedTool = collectedTools.find((item) => item.tool?.id === call.id); + if (collectedTool?.tool) { + collectedTool.tool.response = response; + } + return { response, assistantMessages: [], @@ -698,16 +747,38 @@ export const masterCall = async ({ // llmTotalPoints 是 runAgentCall 内每次 LLM 调用单独计价后的累计值,保证梯度计费正确 const llmUsage = { - modelName: getLLMModel(agentModel).name, + modelId: getLLMModelById(agentModelId)?.id, totalPoints: llmTotalPoints }; const childTotalPoints = childrenUsages.reduce((sum, item) => sum + item.totalPoints, 0); + + // Extract answer text from assistant messages for textOutput + const answerText = assistantMessages + .map((item) => { + if (item.role === 'assistant' && item.content) { + if (typeof item.content === 'string') { + return item.content; + } else { + return item.content + .map((content) => (content.type === 'text' ? content.text : '')) + .join('\n'); + } + } + return ''; + }) + .join('\n') + .trim(); + + // Generate history preview from complete messages + const chatCompleteMessages = GPTMessages2Chats({ messages: completeMessages }); + const historyPreview = getHistoryPreview(chatCompleteMessages, 10000, aiChatVision); + const nodeResponse: ChatHistoryItemResType = { nodeId: getNanoid(6), id: getNanoid(6), moduleType: FlowNodeTypeEnum.agent, moduleName: isStepCall ? i18nT('chat:step_call') : i18nT('chat:master_agent_call'), - model: llmUsage.modelName, + modelId: llmUsage.modelId, moduleLogo: isStepCall ? 'core/app/agent/child/stepCall' : 'core/app/type/agentFill', ...(agentError && { errorText: getErrText(agentError) }), inputTokens, @@ -716,6 +787,8 @@ export const masterCall = async ({ childrenResponses, finishReason: finish_reason, llmRequestIds: requestIds, + textOutput: answerText || undefined, + historyPreview, // Step params stepQuery: step?.title @@ -723,21 +796,6 @@ export const masterCall = async ({ // Step call if (isStepCall && !checkIsStopping()) { - const answerText = assistantMessages - .map((item) => { - if (item.role === 'assistant' && item.content) { - if (typeof item.content === 'string') { - return item.content; - } else { - return item.content - .map((content) => (content.type === 'text' ? content.text : '')) - .join('\n'); - } - } - return ''; - }) - .join('\n'); - // Get step response summary const { answerText: summary, @@ -746,7 +804,7 @@ export const masterCall = async ({ } = await getOneStepResponseSummary({ checkIsStopping, response: answerText, - model + modelId }); summaryUsage && usagePush([summaryUsage]); summaryNodeResponse && nodeResponse.childrenResponses?.push(summaryNodeResponse); @@ -762,7 +820,8 @@ export const masterCall = async ({ assistantMessages, nodeResponse, masterMessages: masterMessages.concat(assistantMessages), - capabilityAssistantResponses + capabilityAssistantResponses, + collectedTools }; } @@ -774,6 +833,7 @@ export const masterCall = async ({ assistantMessages, nodeResponse, masterMessages: filterMemoryMessages(completeMessages), - capabilityAssistantResponses + capabilityAssistantResponses, + collectedTools }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/dependon.ts b/packages/service/core/workflow/dispatch/ai/agent/master/dependon.ts index 41ea763448ad..5b438f40535e 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/dependon.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/dependon.ts @@ -1,10 +1,10 @@ -import { getLLMModel } from '../../../../../ai/model'; +import { getLLMModelById } from '../../../../../ai/model'; import type { AgentStepItemType } from '@fastgpt/global/core/ai/agent/type'; import { createLLMResponse } from '../../../../../ai/llm/request'; import { parseJsonArgs } from '../../../../../ai/utils'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { formatModelChars2Points } from '../../../../../../support/wallet/usage/utils'; -import { i18nT } from '../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../global/common/i18n/utils'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; @@ -16,12 +16,12 @@ import { getLogger, LogCategories } from '../../../../../../common/logger'; export const getStepDependon = async ({ checkIsStopping, - model, + modelId, steps, step }: { checkIsStopping: () => boolean; - model: string; + modelId?: string; steps: AgentStepItemType[]; step: AgentStepItemType; }): Promise<{ @@ -30,7 +30,7 @@ export const getStepDependon = async ({ nodeResponse?: ChatHistoryItemResType; }> => { const startTime = Date.now(); - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); const historySummary = steps .filter((item) => item.summary) .map((item) => `- ${item.id}: ${item.summary}`) @@ -81,19 +81,19 @@ export const getStepDependon = async ({ const { answerText, usage, requestId } = await createLLMResponse({ isAborted: checkIsStopping, body: { - model: modelData.model, + modelId: modelData.id, messages: [{ role: 'user', content: prompt }], stream: true } }); const { totalPoints, modelName } = formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); const formatUsage = { moduleName: i18nT('account_usage:context_pick'), - model: modelName, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens @@ -107,7 +107,7 @@ export const getStepDependon = async ({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalPoints, - model: modelName, + modelId: modelData.id, runningTime: +((Date.now() - startTime) / 1000).toFixed(2), llmRequestIds: [requestId] }; @@ -140,13 +140,13 @@ export const getStepDependon = async ({ export const getStepCallQuery = async ({ steps, step, - model, + modelId, filesMap = {}, checkIsStopping }: { steps: AgentStepItemType[]; step: AgentStepItemType; - model: string; + modelId?: string; filesMap?: Record; checkIsStopping: () => boolean; }) => { @@ -156,7 +156,7 @@ export const getStepCallQuery = async ({ */ const compressStepPrompt = async ( stepPrompt: string, - model: string, + modelId: string | undefined, currentDescription: string ): Promise<{ stepPrompt: string; @@ -164,7 +164,7 @@ export const getStepCallQuery = async ({ }> => { if (!stepPrompt) return { stepPrompt }; - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); if (!modelData) return { stepPrompt }; const tokenCount = await countPromptTokens(stepPrompt); @@ -270,7 +270,7 @@ ${stepPrompt} const { answerText, usage, finish_reason } = await createLLMResponse({ isAborted: checkIsStopping, body: { - model: modelData, + modelId: modelData.id, messages: [ { role: ChatCompletionRequestMessageRoleEnum.System, @@ -287,7 +287,7 @@ ${stepPrompt} }); const { totalPoints, modelName } = formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); @@ -296,7 +296,7 @@ ${stepPrompt} stepPrompt: finish_reason === 'close' ? stepPrompt : answerText || stepPrompt, usage: { moduleName: i18nT('account_usage:llm_compress_text'), - model: modelName, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens @@ -323,7 +323,7 @@ ${stepPrompt} // 压缩依赖的上下文 const compressResult = await compressStepPrompt( preStepPrompt, - model, + modelId, step.description || step.title ); preStepPrompt = compressResult.stepPrompt; diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/responseSummary.ts b/packages/service/core/workflow/dispatch/ai/agent/master/responseSummary.ts index 0744dfc048da..0eec04c70031 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/responseSummary.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/responseSummary.ts @@ -1,8 +1,8 @@ import { formatModelChars2Points } from '../../../../../../support/wallet/usage/utils'; import { createLLMResponse } from '../../../../../ai/llm/request'; -import { getLLMModel } from '../../../../../ai/model'; +import { getLLMModelById } from '../../../../../ai/model'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; -import { i18nT } from '../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../global/common/i18n/utils'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; @@ -10,11 +10,11 @@ import { getLogger, LogCategories } from '../../../../../../common/logger'; export const getOneStepResponseSummary = async ({ response, - model, + modelId, checkIsStopping }: { response: string; - model: string; + modelId?: string; checkIsStopping: () => boolean; }): Promise<{ answerText: string; @@ -25,12 +25,12 @@ export const getOneStepResponseSummary = async ({ const startTime = Date.now(); getLogger(LogCategories.MODULE.AI.AGENT).debug('[GetOneStepResponseSummary] start'); - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); try { const { answerText, usage, requestId, finish_reason } = await createLLMResponse({ isAborted: checkIsStopping, body: { - model: modelData.model, + modelId: modelData.id, stream: true, messages: [ { @@ -53,7 +53,7 @@ export const getOneStepResponseSummary = async ({ }); const { totalPoints, modelName } = formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); @@ -62,7 +62,7 @@ export const getOneStepResponseSummary = async ({ answerText: finish_reason === 'close' ? response : answerText, usage: { moduleName: i18nT('account_usage:step_summary'), - model: modelName, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/compaction.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/compaction.ts new file mode 100644 index 000000000000..fe22e2c2ca68 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/piAgent/compaction.ts @@ -0,0 +1,614 @@ +/** + * Context compaction for piAgent. + * + * Adapts pi-mono's coding-agent compaction strategy to FastGPT's piAgent. + * Uses the Agent's `transformContext` hook to detect large contexts and + * replace old messages with an LLM-generated structured summary. + * + * Architecture is simplified from pi-mono: + * - No session tree (flat message list) + * - No extensions + * - No branch summarization + * - No turn splitting + */ + +import type { AgentMessage, AgentState } from '@mariozechner/pi-agent-core'; +import type { AssistantMessage, Model, Usage } from '@mariozechner/pi-ai'; +import { completeSimple } from '@mariozechner/pi-ai'; +import { getLogger, LogCategories } from '../../../../../../common/logger'; + +// ============================================================================ +// Settings +// ============================================================================ + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000 +}; + +// ============================================================================ +// Compaction State (closure — persisted across transformContext calls) +// ============================================================================ + +interface CompactionState { + previousSummary?: string; + readFiles: Set; + modifiedFiles: Set; + lastCompactionIndex: number; +} + +function createCompactionState(): CompactionState { + return { + readFiles: new Set(), + modifiedFiles: new Set(), + lastCompactionIndex: -1 + }; +} + +// ============================================================================ +// Token Estimation +// ============================================================================ + +function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} + +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === 'assistant' && 'usage' in msg) { + const assistant = msg as AssistantMessage; + if (assistant.stopReason !== 'aborted' && assistant.stopReason !== 'error' && assistant.usage) { + return assistant.usage; + } + } + return undefined; +} + +/** + * Estimate token count for a message using chars/4 heuristic (conservative overestimate). + */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case 'user': { + const content = (message as { content: string | Array<{ type: string; text?: string }> }) + .content; + if (typeof content === 'string') { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) chars += block.text.length; + } + } + return Math.ceil(chars / 4); + } + case 'assistant': { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { + if (block.type === 'text') chars += block.text.length; + else if (block.type === 'thinking') chars += block.thinking.length; + else if (block.type === 'toolCall') { + chars += block.name.length + JSON.stringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case 'toolResult': { + const content = typeof message.content === 'string' ? message.content : ''; + chars = content.length; + return Math.ceil(chars / 4); + } + default: + return 0; + } +} + +/** + * Estimate total context tokens from messages. + * Uses last assistant usage for accuracy, falls back to chars/4 heuristic. + */ +export function estimateContextTokens(messages: AgentMessage[]): { + tokens: number; + lastUsageIndex: number | null; +} { + // Find last non-error assistant message with usage + let lastUsageIndex: number | null = null; + let lastUsage: Usage | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) { + lastUsage = usage; + lastUsageIndex = i; + break; + } + } + + if (!lastUsage || lastUsageIndex === null) { + let estimated = 0; + for (const m of messages) estimated += estimateTokens(m); + return { tokens: estimated, lastUsageIndex: null }; + } + + const usageTokens = calculateContextTokens(lastUsage); + let trailingTokens = 0; + for (let i = lastUsageIndex + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { tokens: usageTokens + trailingTokens, lastUsageIndex }; +} + +// ============================================================================ +// Threshold & Cut Point +// ============================================================================ + +export function shouldCompact( + contextTokens: number, + contextWindow: number, + settings: CompactionSettings +): boolean { + if (!settings.enabled) return false; + return contextTokens > contextWindow - settings.reserveTokens; +} + +/** + * Find cut point: index of first message to keep. + * Walks backwards accumulating token estimates until exceeding keepRecentTokens. + * Cuts only at user or assistant message boundaries (never in the middle of a + * tool-call → tool-result pair). + */ +function findCutPoint(messages: AgentMessage[], keepRecentTokens: number): number { + // Valid cut points: indices of user, assistant, or compaction messages + const validCutPoints: number[] = []; + for (let i = 0; i < messages.length; i++) { + const role = messages[i].role; + if (role === 'user' || role === 'assistant') { + validCutPoints.push(i); + } + } + + if (validCutPoints.length === 0) return 0; + + let accumulated = 0; + let cutIndex = validCutPoints[0]; + + for (let i = messages.length - 1; i >= 0; i--) { + accumulated += estimateTokens(messages[i]); + if (accumulated >= keepRecentTokens) { + for (const cp of validCutPoints) { + if (cp >= i) { + cutIndex = cp; + break; + } + } + break; + } + } + + // When total tokens < keepRecentTokens, accumulated never hits the threshold. + // Keep at least the first user message (current prompt); summarize the rest. + if (accumulated < keepRecentTokens && validCutPoints.length >= 2) { + // Use the second valid cut point so the first message (current prompt) is kept. + // If we only have 2 cut points and the first is index 0, keep just message 0. + cutIndex = validCutPoints[1]; + } + + // When cutting at an assistant message with tool calls, include its tool results + if (messages[cutIndex].role === 'assistant') { + let j = cutIndex + 1; + while (j < messages.length && messages[j].role === 'toolResult') { + j++; + } + return j; + } + + return cutIndex; +} + +// ============================================================================ +// File Operation Tracking +// ============================================================================ + +interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +function createFileOps(): FileOperations { + return { read: new Set(), written: new Set(), edited: new Set() }; +} + +function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== 'assistant') return; + const content = (message as unknown as Record).content; + if (!Array.isArray(content)) return; + + for (const block of content) { + const b = block as Record; + if (b.type !== 'toolCall') continue; + const args = b.arguments as Record | undefined; + if (!args) continue; + const path = typeof args.path === 'string' ? args.path : undefined; + if (!path) continue; + + switch (b.name) { + case 'read': + fileOps.read.add(path); + break; + case 'write': + fileOps.written.add(path); + break; + case 'edit': + fileOps.edited.add(path); + break; + } + } +} + +function computeFileLists(fileOps: FileOperations): { + readFiles: string[]; + modifiedFiles: string[]; +} { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const parts: string[] = []; + if (readFiles.length > 0) { + parts.push(`\n${readFiles.join('\n')}\n`); + } + if (modifiedFiles.length > 0) { + parts.push(`\n${modifiedFiles.join('\n')}\n`); + } + return parts.length > 0 ? `\n\n${parts.join('\n\n')}` : ''; +} + +// ============================================================================ +// Summarization +// ============================================================================ + +const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const INITIAL_SUMMARY_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARY_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const TOOL_RESULT_MAX_CHARS = 2000; + +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const truncated = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncated} more characters truncated]`; +} + +function serializeConversation(messages: AgentMessage[], reserveTokens: number): string { + // Max chars roughly reserveTokens * 4 (chars/4 heuristic) + const maxChars = reserveTokens * 4 * 0.8; + + let charCount = 0; + + // Walk from newest to oldest for budget prioritization + const serialized: string[] = []; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + let line = ''; + + if (msg.role === 'user') { + const content = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('') + : ''; + if (content) line = `[User]: ${content}`; + } else if (msg.role === 'assistant') { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of (msg as AssistantMessage).content) { + if (block.type === 'text') textParts.push(block.text); + else if (block.type === 'thinking') thinkingParts.push(block.thinking); + else if (block.type === 'toolCall') { + toolCalls.push(`${block.name}(${JSON.stringify(block.arguments)})`); + } + } + + if (thinkingParts.length > 0) { + line += `[Assistant thinking]${thinkingParts.join('\n')}\n`; + } + if (textParts.length > 0) { + line += `[Assistant]: ${textParts.join('\n')}\n`; + } + if (toolCalls.length > 0) { + line += `[Assistant tool calls]: ${toolCalls.join('; ')}`; + } + } else if (msg.role === 'toolResult') { + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join(''); + } + if (content) { + line = `[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`; + } + } + + if (line) { + charCount += line.length; + if (charCount > maxChars) break; + serialized.unshift(line); + } + } + + return serialized.join('\n\n'); +} + +async function generateSummary( + messagesToSummarize: AgentMessage[], + model: Model, + apiKey: string, + reserveTokens: number, + previousSummary?: string, + signal?: AbortSignal +): Promise { + const maxTokens = Math.floor(0.8 * reserveTokens); + + // Serialize conversation to text so the model doesn't try to continue it + const conversationText = serializeConversation(messagesToSummarize, reserveTokens); + + const promptType = previousSummary ? UPDATE_SUMMARY_PROMPT : INITIAL_SUMMARY_PROMPT; + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { + promptText += `\n${previousSummary}\n\n\n`; + } + promptText += promptType; + + const summarizationMessages = [ + { + role: 'user' as const, + content: [{ type: 'text' as const, text: promptText }], + timestamp: Date.now() + } + ]; + + const response = await completeSimple( + model, + { + systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, + messages: summarizationMessages + }, + { maxTokens, signal, apiKey } + ); + + if (response.stopReason === 'error') { + throw new Error(`Summarization failed: ${response.errorMessage || 'Unknown error'}`); + } + + return response.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); +} + +// ============================================================================ +// Compaction Summary Message +// ============================================================================ + +const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +const COMPACTION_SUMMARY_SUFFIX = ` +`; + +// ============================================================================ +// Main transformContext Factory +// ============================================================================ + +export interface CreateCompactionTransformOptions { + model: Model; + apiKey: string; + settings?: Partial; + /** Reference to agent state for side-effect updates */ + getAgentState: () => AgentState; +} + +/** + * Create a `transformContext` function for pi-agent-core's Agent constructor. + * + * The returned function: + * 1. Estimates context tokens from current messages + * 2. If over threshold, summarizes old messages via LLM + * 3. Replaces old messages with a compaction summary message + * 4. Returns the compacted message list for the current LLM call + * 5. Updates agent.state.messages as a side effect (for persistence) + */ +export function createCompactionTransform( + opts: CreateCompactionTransformOptions +): (messages: AgentMessage[], signal?: AbortSignal) => Promise { + const { model, apiKey, getAgentState } = opts; + const settings: CompactionSettings = { + ...DEFAULT_COMPACTION_SETTINGS, + ...opts.settings + }; + const state = createCompactionState(); + + return async function compactionTransform( + messages: AgentMessage[], + signal?: AbortSignal + ): Promise { + if (!settings.enabled) return messages; + if (messages.length < 4) return messages; // Too small to compact + + const contextWindow = model.contextWindow || 128000; + + // Check if compaction is needed + const { tokens } = estimateContextTokens(messages); + if (!shouldCompact(tokens, contextWindow, settings)) { + return messages; + } + + // Only compact messages that haven't been compacted yet + const startIndex = state.lastCompactionIndex >= 0 ? state.lastCompactionIndex : 0; + const newMessages = messages.slice(startIndex); + const oldMessages = messages.slice(0, startIndex); + + // Find cut point + const cutIndex = findCutPoint(newMessages, settings.keepRecentTokens); + const messagesToSummarize = newMessages.slice(0, cutIndex); + const messagesToKeep = newMessages.slice(cutIndex); + + // Nothing to summarize + if (messagesToSummarize.length === 0) return messages; + + // Extract file ops from messages being summarized + const fileOps = createFileOps(); + for (const msg of messagesToSummarize) { + extractFileOpsFromMessage(msg, fileOps); + } + // Merge with previous compaction's file tracking + for (const f of state.readFiles) fileOps.read.add(f); + for (const f of state.modifiedFiles) { + fileOps.edited.add(f); + } + + // Generate summary + const summaryTokens = messagesToSummarize.reduce((sum, m) => sum + estimateTokens(m), 0); + const logger = getLogger(LogCategories.MODULE.AI.AGENT); + logger.debug( + `[piAgent] Compacting: summarizing ${messagesToSummarize.length} messages (~${summaryTokens} estimated tokens)` + ); + + const summary = await generateSummary( + messagesToSummarize, + model, + apiKey, + settings.reserveTokens, + state.previousSummary, + signal + ); + + // Update compaction state + state.previousSummary = summary; + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + state.readFiles = new Set(readFiles); + state.modifiedFiles = new Set(modifiedFiles); + + // Build compaction summary message (append file lists) + const summaryWithFiles = summary + formatFileOperations(readFiles, modifiedFiles); + const summaryText = COMPACTION_SUMMARY_PREFIX + summaryWithFiles + COMPACTION_SUMMARY_SUFFIX; + const compactionMsg: AgentMessage = { + role: 'user', + content: [{ type: 'text', text: summaryText }], + timestamp: Date.now() + }; + + // Build compacted list: [...oldCompactedMsgs, compactionMsg, ...recentMessages] + const compacted = [...oldMessages, compactionMsg, ...messagesToKeep]; + state.lastCompactionIndex = compacted.length - messagesToKeep.length; + + // Side-effect: update agent state so compaction persists + try { + const agentState = getAgentState(); + agentState.messages = compacted; + } catch { + // getAgentState might fail if agent is not available; non-fatal + } + + logger.debug( + `[piAgent] Compaction complete: ${messages.length} → ${compacted.length} messages` + ); + + return compacted; + }; +} diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts index 72cfaf63925d..948ba7a0f0d2 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts @@ -10,20 +10,31 @@ import type { import type { DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { getHistories, getNodeErrResponse } from '../../../utils'; -import { parseUserSystemPrompt } from '../sub/plan/prompt'; +import { parsePiAgentSystemPrompt } from '../sub/plan/prompt'; import { formatFileInput } from '../sub/file/utils'; import { normalizeSkillIds } from '@fastgpt/global/core/app/formEdit/type'; import { systemSubInfo } from '@fastgpt/global/core/workflow/node/agent/constants'; -import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; +import { i18nT, parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { getSubapps } from '../utils'; import { createCapabilityToolCallHandler, type AgentCapability } from '../capability/type'; import { createSandboxSkillsCapability } from '../capability/sandboxSkills'; import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; import { buildPiModel, getModelApiKey } from './modelBridge'; import { buildAgentTools, type ToolDispatchContext } from './toolAdapter'; +import { createCompactionTransform } from './compaction'; import { getLogger, LogCategories } from '../../../../../../common/logger'; import { env } from '../../../../../../env'; import type { DispatchAgentModuleProps } from '..'; +import { resolveDatasetParams } from '../resolveDatasetParams'; +import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; +import { hashStr, getNanoid } from '@fastgpt/global/common/string/tools'; +import { getLLMModelById } from '../../../../../ai/model'; +import { compressLargeContent } from '../../../../../ai/llm/compress'; +import { countPromptTokens } from '../../../../../../common/string/tiktoken'; +import { formatModelChars2Points } from '../../../../../../support/wallet/usage/utils'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; +import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.answerText]: string; @@ -45,25 +56,27 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< chatId, showSkillReferences, params: { - model, + modelId, systemPrompt, userChatInput, history = 6, fileUrlList: fileLinksInput, agent_selectedTools: selectedTools = [], skills: skillIds = [], - useEditDebugSandbox, - agent_datasetParams: datasetParams, useAgentSandbox = false, - aiChatVision + aiChatVision, + aiChatReasoning = true } } = props; + // Dataset search: resolve from composite or individual fields + const datasetParams = resolveDatasetParams(props.params); const chatHistories = getHistories(history, histories); const normalizedSkillIds = normalizeSkillIds(skillIds); const assistantResponses: AIChatItemValueItemType[] = []; const nodeResponses: ChatHistoryItemResType[] = []; + const startTime = Date.now(); const capabilities: AgentCapability[] = []; try { @@ -86,24 +99,70 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< useSkill: skillIds.length > 0 }); + // Auto-compress ultra-long user input before piAgent loop + let finalUserChatInput = userChatInput; + if (finalUserChatInput) { + const modelData = getLLMModelById(modelId); + if (modelData) { + const inputTokens = await countPromptTokens(finalUserChatInput); + const threshold = Math.floor(modelData.maxContext * env.USER_INPUT_COMPRESS_THRESHOLD); + if (inputTokens > threshold) { + const targetTokens = Math.floor(modelData.maxContext * env.USER_INPUT_COMPRESS_TARGET); + getLogger(LogCategories.MODULE.AI.AGENT).debug( + 'piAgent user input auto-compression triggered', + { inputTokens, threshold, targetTokens } + ); + const result = await compressLargeContent({ + content: finalUserChatInput, + model: modelData, + maxTokens: targetTokens + }); + if (result.compressed && result.compressed !== finalUserChatInput) { + finalUserChatInput = result.compressed; + if (result.usage) { + usagePush([result.usage]); + } + // Inject metadata into _query for UI persistence (shared ref with userQuestion.value) + if (_query && Array.isArray(_query) && _query.length > 0) { + const firstValue = _query[0]; + if (firstValue?.text) { + firstValue.text.originalContent = userChatInput; + firstValue.text.content = result.compressed; + (firstValue as any).compression = { + modelId: modelData.id, + inputTokens: result.usage?.inputTokens, + outputTokens: result.usage?.outputTokens + }; + } + } + } + } + } + } + const formatUserChatInput = fileInputPrompt - ? `${fileInputPrompt}\n\n${userChatInput}` - : userChatInput; + ? `${fileInputPrompt}\n\n${finalUserChatInput}` + : finalUserChatInput; - // Initialize capabilities — sandbox skills (lazy-init, gated by SHOW_SKILL) - if (env.SHOW_SKILL) { - const sandboxSessionId = mode === 'chat' ? chatId : `debug-${runningAppInfo.id}-${nodeId}`; - const sandboxMode = useEditDebugSandbox ? 'editDebug' : 'sessionRuntime'; + // Initialize capabilities — sandbox skills (lazy-init) + { + const sandboxSessionId = + mode === 'chat' + ? chatId + : `debug-${hashStr(`${runningAppInfo.id}-${nodeId}-${chatId}`).slice(0, 40)}`; const sandboxCap = await createSandboxSkillsCapability({ skillIds: normalizedSkillIds, teamId: runningAppInfo.teamId, tmbId: runningAppInfo.tmbId, sessionId: sandboxSessionId, - mode: sandboxMode, workflowStreamResponse, showSkillReferences: showSkillReferences === true, - allFilesMap + allFilesMap, + exposeGetFileUrl: true, + appId: runningAppInfo.id, + userId: props.uid, + chatId }); capabilities.push(sandboxCap); } @@ -116,8 +175,15 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< const capabilityTools = capabilities.flatMap((c) => c.completionTools ?? []); const capabilityToolCallHandler = capabilities.length > 0 ? createCapabilityToolCallHandler(capabilities) : undefined; + // Merge skill path maps from all capabilities for pre-resolving tool display names + const skillPathMap: Record = Object.assign( + {}, + ...capabilities.map((c) => c.skillPathMap ?? {}) + ); - // Get sub apps — pi-agent-core manages reasoning, no plan tool needed + // Get sub apps — pi-agent-core manages reasoning, no plan tool needed. + // Skill capability owns the sandbox session: the model uses skill tools + // (sandbox_write_file / sandbox_execute / ...) and must NEVER see sandbox_shell. const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( { tools: selectedTools, @@ -154,16 +220,24 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< return agentSubAppsMap.get(id) || agentSubAppsMap.get(formatId); }; - const formatedSystemPrompt = parseUserSystemPrompt({ + let formatedSystemPrompt = parsePiAgentSystemPrompt({ userSystemPrompt: capabilitySystemPrompt ? `${systemPrompt || ''}\n\n${capabilitySystemPrompt}`.trim() : systemPrompt, - selectedDataset: datasetParams?.datasets + selectedDataset: datasetParams?.datasets, + lang }); + // Append sandbox system prompt when sandbox is enabled + // (skipped when skill capability is active — the skill capability owns the sandbox prompt) + if (useAgentSandbox && global.feConfigs?.show_agent_sandbox) { + formatedSystemPrompt = + `${formatedSystemPrompt}\n\n${SANDBOX_SYSTEM_PROMPT}\n\n\nWhen both sandbox_shell / sandbox_get_file_url and sandbox_execute_* tools can fulfill the same task, prefer sandbox_shell and sandbox_get_file_url — they have higher priority.\n`.trim(); + } + /* ===== Build pi-agent-core model & tools ===== */ - const piModel = buildPiModel(model, aiChatVision); - const apiKey = getModelApiKey(model); + const piModel = buildPiModel(modelId, aiChatVision); + const apiKey = getModelApiKey(modelId); const toolCtx: ToolDispatchContext = { checkIsStopping, @@ -183,7 +257,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< maxRunTimes: props.maxRunTimes, workflowDispatchDeep: props.workflowDispatchDeep, usagePush, - model, + modelId, datasetParams }; @@ -194,45 +268,271 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< getSubApp, getSubAppInfo, capabilityToolCallHandler, - nodeResponses + nodeResponses, + assistantResponses, + skillPathMap }); /* ===== Restore session messages from last AI history ===== */ const piMessagesKey = `piMessages-${nodeId}`; - const lastHistory = chatHistories[chatHistories.length - 1]; - const restoredMessages = - lastHistory?.obj === ChatRoleEnum.AI - ? (lastHistory.memories?.[piMessagesKey] as any[] | undefined) ?? [] - : []; + let restoredMessages: any[] = []; + for (let i = chatHistories.length - 1; i >= 0; i--) { + if (chatHistories[i].obj === ChatRoleEnum.AI) { + const aiItem = chatHistories[i] as { memories?: Record }; + const stored = aiItem.memories?.[piMessagesKey] as any[] | undefined; + if (stored?.length) { + restoredMessages = stored; + break; + } + } + } /* ===== Create & run Agent ===== */ const { Agent } = await import('@mariozechner/pi-agent-core'); type AgentEvent = import('@mariozechner/pi-agent-core').AgentEvent; - const agent = new Agent({ + const agent: import('@mariozechner/pi-agent-core').Agent = new Agent({ initialState: { systemPrompt: formatedSystemPrompt, model: piModel, tools: piTools, messages: restoredMessages }, - getApiKey: () => apiKey + getApiKey: () => apiKey, + transformContext: createCompactionTransform({ + model: piModel, + apiKey, + getAgentState: () => agent.state, + settings: { enabled: env.PI_AGENT_COMPACTION_ENABLED } + }), + onPayload: (params: any) => { + // Ensure tool_choice is set; some models require explicit tool_choice + if (params.tools?.length > 0 && params.tool_choice === undefined) { + params.tool_choice = 'auto'; + } + return params; + } }); // Collect text deltas to build answerText let answerText = ''; + let currentTextItem: AIChatItemValueItemType | null = null; + let currentReasoningItem: AIChatItemValueItemType | null = null; + // Separate timers for the two independent reasoning content paths: + // - xmlReasoningStartTime: set when tag opens (processTextChunk) + // - deltaReasoningStartTime: set when thinking_delta event first arrives + // This prevents timing overwrite in hybrid mode where a model emits both + // thinking_delta events and XML tags within the text stream. + let xmlReasoningStartTime: number | null = null; + let deltaReasoningStartTime: number | null = null; + + // Streaming parser for ... tags in text content + // Some models (e.g. MiniMax) emit thinking content as XML tags within the text stream + // rather than using the standard reasoning_content / thinking delta API. + let thinkTagBuffer = ''; + let inThinkTag = false; + + /** + * Process a chunk of text through the tag parser. + * Routes content inside ... as reasoning_content, + * and content outside as regular text. + */ + const processTextChunk = (chunk: string) => { + thinkTagBuffer += chunk; + + // Process the buffer character by character to detect tag boundaries + while (thinkTagBuffer.length > 0) { + if (inThinkTag) { + const closeIdx = thinkTagBuffer.indexOf(''); + if (closeIdx === -1) { + // No closing tag yet — send all as reasoning + if (thinkTagBuffer.length > 0) { + const reasoningText = thinkTagBuffer; + thinkTagBuffer = ''; + if (aiChatReasoning) { + if (!currentReasoningItem) { + currentReasoningItem = { reasoning: { content: '' } }; + assistantResponses.push(currentReasoningItem); + } + currentReasoningItem.reasoning!.content += reasoningText; + // Also accumulate in answerText for backward compatibility + answerText += reasoningText; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ reasoning_content: reasoningText }) + }); + } + } + break; + } else { + // Send content before closing tag as reasoning + const reasoningText = thinkTagBuffer.substring(0, closeIdx); + thinkTagBuffer = thinkTagBuffer.substring(closeIdx + ''.length); + inThinkTag = false; + if (aiChatReasoning && reasoningText.length > 0) { + if (!currentReasoningItem) { + currentReasoningItem = { reasoning: { content: '' } }; + assistantResponses.push(currentReasoningItem); + } + currentReasoningItem.reasoning!.content += reasoningText; + // Also accumulate in answerText for backward compatibility + answerText += reasoningText; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ reasoning_content: reasoningText }) + }); + } + // Record duration and reset reasoning item so next reasoning starts fresh + if (currentReasoningItem && xmlReasoningStartTime) { + currentReasoningItem.reasoning!.duration = + Math.round((Date.now() - xmlReasoningStartTime) / 1000) || 1; + xmlReasoningStartTime = null; + } + currentReasoningItem = null; + } + } else { + const openIdx = thinkTagBuffer.indexOf(''); + if (openIdx === -1) { + // Check for partial opening tag at the end + const partialLen = Math.min(thinkTagBuffer.length, ''.length - 1); + let partialMatch = false; + for (let i = 1; i <= partialLen; i++) { + if (''.startsWith(thinkTagBuffer.substring(thinkTagBuffer.length - i))) { + // Keep the partial match in buffer for next chunk + const safeText = thinkTagBuffer.substring(0, thinkTagBuffer.length - i); + if (safeText.length > 0) { + answerText += safeText; + if (!currentTextItem) { + currentTextItem = { text: { content: '' } }; + assistantResponses.push(currentTextItem); + } + currentTextItem.text!.content += safeText; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: safeText }) + }); + } + thinkTagBuffer = thinkTagBuffer.substring(thinkTagBuffer.length - i); + partialMatch = true; + break; + } + } + if (!partialMatch) { + // No tag at all — send all as text + if (thinkTagBuffer.length > 0) { + answerText += thinkTagBuffer; + if (!currentTextItem) { + currentTextItem = { text: { content: '' } }; + assistantResponses.push(currentTextItem); + } + currentTextItem.text!.content += thinkTagBuffer; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: thinkTagBuffer }) + }); + thinkTagBuffer = ''; + } + } + break; + } else { + // Send content before opening tag as text + const textBefore = thinkTagBuffer.substring(0, openIdx); + thinkTagBuffer = thinkTagBuffer.substring(openIdx + ''.length); + inThinkTag = true; + xmlReasoningStartTime = Date.now(); + if (textBefore.length > 0) { + answerText += textBefore; + if (!currentTextItem) { + currentTextItem = { text: { content: '' } }; + assistantResponses.push(currentTextItem); + } + currentTextItem.text!.content += textBefore; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: textBefore }) + }); + } + // Reset text item so text after starts fresh + currentTextItem = null; + } + } + } + }; agent.subscribe((event: AgentEvent) => { if (event.type === 'message_update') { const e = event.assistantMessageEvent; if (e.type === 'text_delta') { + processTextChunk(e.delta); + } else if (e.type === 'thinking_delta') { + // Send reasoning/thinking content as reasoning_content in answer events + if (!aiChatReasoning) return; + if (!currentReasoningItem) { + currentReasoningItem = { reasoning: { content: '' } }; + assistantResponses.push(currentReasoningItem); + deltaReasoningStartTime = Date.now(); + } + currentReasoningItem.reasoning!.content += e.delta; + // Also accumulate in answerText for backward compatibility with API + // consumers (e.g. step calls in master/call.ts) that read answerText + // directly rather than reconstructing it from assistantResponses. answerText += e.delta; workflowStreamResponse?.({ event: SseResponseEventEnum.answer, - data: textAdaptGptResponse({ text: e.delta }) + data: textAdaptGptResponse({ reasoning_content: e.delta }) }); } } else if (event.type === 'turn_end') { + // Reset text item tracker at turn boundary; next text_delta starts a new item. + // Tool items have already been pushed to assistantResponses by buildAgentTools + // during tool execution between turns, so they appear in the correct position. + currentTextItem = null; + // Close reasoning item with duration if one is active. + // Use whichever timer was set — the two reasoning paths are mutually + // exclusive within a single turn, so at most one timer is non-null. + if (currentReasoningItem) { + const timer = deltaReasoningStartTime ?? xmlReasoningStartTime; + if (timer) { + currentReasoningItem.reasoning!.duration = Math.round((Date.now() - timer) / 1000) || 1; + } + } + deltaReasoningStartTime = null; + xmlReasoningStartTime = null; + currentReasoningItem = null; + // Drain residual thinkTagBuffer at turn boundary. + // Two cases: + // - !inThinkTag: partial tag prefix in buffer → flush as text + // - inThinkTag: reasoning content without closing → flush as reasoning + if (thinkTagBuffer.length > 0) { + if (inThinkTag) { + // Buffer has reasoning content whose closing tag never arrived + if (aiChatReasoning) { + if (!currentReasoningItem) { + currentReasoningItem = { reasoning: { content: '' } }; + assistantResponses.push(currentReasoningItem); + } + currentReasoningItem.reasoning!.content += thinkTagBuffer; + answerText += thinkTagBuffer; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ reasoning_content: thinkTagBuffer }) + }); + } + } else { + answerText += thinkTagBuffer; + if (!currentTextItem) { + currentTextItem = { text: { content: '' } }; + assistantResponses.push(currentTextItem); + } + currentTextItem.text!.content += thinkTagBuffer; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: thinkTagBuffer }) + }); + } + } + thinkTagBuffer = ''; + inThinkTag = false; const errMsg = (event.message as any).errorMessage as string | undefined; if (errMsg) { getLogger(LogCategories.MODULE.AI.AGENT).error(`[piAgent] Turn error: ${errMsg}`); @@ -255,16 +555,109 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< clearInterval(stopPoller); getLogger(LogCategories.MODULE.AI.AGENT).debug(`[piAgent] Agent completed`); + // Record LLM usage from pi-agent-core assistant messages. + // pi-agent-core manages the LLM loop internally, so we extract token usage + // from AssistantMessage objects stored in agent.state.messages after completion. + let totalInputTokens = 0; + let totalOutputTokens = 0; + for (const msg of agent.state.messages) { + if (msg.role === 'assistant' && 'usage' in msg) { + const assistantMsg = msg as import('@mariozechner/pi-ai').AssistantMessage; + if ( + assistantMsg.stopReason !== 'error' && + assistantMsg.stopReason !== 'aborted' && + assistantMsg.usage + ) { + totalInputTokens += assistantMsg.usage.input; + totalOutputTokens += assistantMsg.usage.output; + } + } + } + + const modelData = getLLMModelById(modelId); + + if ((totalInputTokens > 0 || totalOutputTokens > 0) && usagePush) { + const totalPoints = props.externalProvider?.openaiAccount?.key + ? 0 + : formatModelChars2Points({ + modelId, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens + }).totalPoints; + + usagePush([ + { + moduleName: i18nT('account_usage:agent_call'), + modelId: modelData?.id, + totalPoints, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens + } + ]); + } + // Surface API errors that pi-agent-core stores instead of throwing if (agent.state.errorMessage) { throw new Error(agent.state.errorMessage); } - // Build assistant responses - if (answerText) { - assistantResponses.push({ text: { content: answerText } }); + // Build agent nodeResponse with textOutput and historyPreview + // Extract token usage from agent state messages, aligned with usagePush filtering. + // pi-agent-core AssistantMessage stores tokens in msg.usage.input / msg.usage.output. + let inputTokens = 0; + let outputTokens = 0; + const messageList = agent.state.messages as any[]; + for (const msg of messageList) { + if (msg.role === 'assistant' && 'usage' in msg) { + const assistantMsg = msg as import('@mariozechner/pi-ai').AssistantMessage; + if ( + assistantMsg.stopReason !== 'error' && + assistantMsg.stopReason !== 'aborted' && + assistantMsg.usage + ) { + inputTokens += assistantMsg.usage.input; + outputTokens += assistantMsg.usage.output; + } + } } + // Build history preview from agent state messages + const chatCompleteMessages = GPTMessages2Chats({ + messages: messageList.map((msg: any) => ({ + role: msg.role || 'assistant', + content: msg.content || '' + })) + }); + const historyPreview = getHistoryPreview(chatCompleteMessages, 10000, true); + + const agentNodeResponse: ChatHistoryItemResType = { + nodeId: getNanoid(6), + id: getNanoid(6), + moduleType: FlowNodeTypeEnum.agent, + moduleName: i18nT('chat:master_agent_call'), + modelId: modelId, + moduleLogo: 'core/app/type/agentFill', + inputTokens: inputTokens || undefined, + outputTokens: outputTokens || undefined, + // Master agent's own LLM points; children's totalPoints are carried in + // childrenResponses and aggregated after flattenNodeResponses in getChatDataLog. + totalPoints: modelData + ? formatModelChars2Points({ + modelId, + inputTokens, + outputTokens + }).totalPoints + : 0, + childrenResponses: nodeResponses.length > 0 ? [...nodeResponses] : undefined, + textOutput: answerText || undefined, + historyPreview, + runningTime: +((Date.now() - startTime) / 1000).toFixed(2) + }; + + // Push the agent nodeResponse as the only top-level response + nodeResponses.length = 0; + nodeResponses.push(agentNodeResponse); + return { data: { [NodeOutputKeyEnum.answerText]: answerText diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts index ef85cf779597..68538a5783fa 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/piAgent/modelBridge.ts @@ -1,4 +1,4 @@ -import { getLLMModel } from '../../../../../ai/model'; +import { getLLMModelById } from '../../../../../ai/model'; type Model = import('@mariozechner/pi-ai').Model<'openai-completions'>; @@ -8,8 +8,8 @@ const aiProxyBaseUrl = process.env.AIPROXY_API_ENDPOINT 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 buildPiModel(modelNameOrId?: string, useVision?: boolean): Model { - const cfg = getLLMModel(modelNameOrId); +export function buildPiModel(modelId?: string, useVision?: boolean): Model { + const cfg = getLLMModelById(modelId); // requestUrl is the full endpoint (e.g. https://api.deepseek.com/chat/completions). // pi-ai's openai-completions provider appends /chat/completions automatically, // so we strip it to get baseUrl. @@ -25,7 +25,12 @@ export function buildPiModel(modelNameOrId?: string, useVision?: boolean): Model baseUrl, reasoning: cfg?.reasoning ?? false, input: useVision ? ['text', 'image'] : ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: { + input: cfg?.priceTiers?.[0]?.inputPrice ?? cfg?.inputPrice ?? 0, + output: cfg?.priceTiers?.[0]?.outputPrice ?? cfg?.outputPrice ?? 0, + cacheRead: 0, + cacheWrite: 0 + }, contextWindow: cfg?.maxContext ?? 128000, maxTokens: Math.min(cfg?.maxResponse ?? 4096, (cfg?.maxContext ?? 128000) - 2048), headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, @@ -40,7 +45,7 @@ export function buildPiModel(modelNameOrId?: string, useVision?: boolean): Model }; } -export function getModelApiKey(modelNameOrId?: string): string { - const cfg = getLLMModel(modelNameOrId); +export function getModelApiKey(modelId?: string): string { + const cfg = getLLMModelById(modelId); return cfg?.requestAuth || defaultApiKey; } diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts index 0905405ca375..b96ec91b908b 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/piAgent/toolAdapter.ts @@ -1,17 +1,13 @@ import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type'; -import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType +} from '@fastgpt/global/core/chat/type'; import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; -import { - SANDBOX_TOOL_NAME, - SANDBOX_GET_FILE_URL_TOOL_NAME, - SandboxShellToolSchema, - SandboxGetFileUrlToolSchema -} from '@fastgpt/global/core/ai/sandbox/constants'; import { ReadFileToolSchema } from '../sub/file/utils'; import { DatasetSearchToolSchema } from '../sub/dataset/utils'; import { dispatchFileRead } from '../sub/file'; import { dispatchAgentDatasetSearch } from '../sub/dataset'; -import { dispatchSandboxShell, dispatchSandboxGetFileUrl } from '../sub/sandbox'; import { dispatchTool } from '../sub/tool'; import { dispatchApp, dispatchPlugin } from '../sub/app'; import { parseJsonArgs } from '../../../../../ai/utils'; @@ -23,6 +19,13 @@ import type { CapabilityToolCallHandlerType } from '../capability/type'; import type { DispatchAgentModuleProps } from '..'; import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; +import { + SANDBOX_TOOL_NAME, + SANDBOX_GET_FILE_URL_TOOL_NAME, + SandboxShellToolSchema, + SandboxGetFileUrlToolSchema +} from '@fastgpt/global/core/ai/sandbox/constants'; +import { dispatchSandboxShell, dispatchSandboxGetFileUrl } from '../sub/sandbox'; type AgentTool = import('@mariozechner/pi-agent-core').AgentTool; @@ -47,10 +50,48 @@ export type ToolDispatchContext = Pick< | 'workflowDispatchDeep' | 'usagePush' > & { - model: string; + modelId: string; datasetParams?: AppFormEditFormType['dataset']; }; +/** + * When sandbox_read_file reads SKILL.md files, resolve the display name + * to "加载 foo,bar 技能" so the frontend shows it immediately during streaming. + */ +export function resolveSkillDisplayName( + args: Record, + skillPathMap?: Record +): string | undefined { + if (!skillPathMap) return undefined; + const paths: unknown[] | undefined = args?.paths; + if (!paths?.length) return undefined; + + const skillNames: string[] = []; + for (const filePath of paths) { + // Guard against non-string and path traversal inputs from LLM-generated args + if (typeof filePath !== 'string') continue; + if (filePath.includes('..')) continue; + + if (!filePath.endsWith('/SKILL.md')) continue; + // Find the most specific (longest) matching key to avoid false positives + // when skill directories nest (e.g. /skill-a/ and /skill-a/sub-plugin/). + let bestMatchName: string | undefined; + let bestMatchLength = 0; + for (const [key, name] of Object.entries(skillPathMap)) { + if (filePath === key || filePath.startsWith(key)) { + if (key.length > bestMatchLength) { + bestMatchLength = key.length; + bestMatchName = name; + } + } + } + if (bestMatchName && !skillNames.includes(bestMatchName)) { + skillNames.push(bestMatchName); + } + } + return skillNames.length > 0 ? `加载 ${skillNames.join(',')} 技能` : undefined; +} + export async function buildAgentTools({ completionTools, ctx, @@ -58,7 +99,9 @@ export async function buildAgentTools({ getSubApp, getSubAppInfo, capabilityToolCallHandler, - nodeResponses + nodeResponses, + assistantResponses, + skillPathMap }: { completionTools: ChatCompletionTool[]; ctx: ToolDispatchContext; @@ -67,6 +110,8 @@ export async function buildAgentTools({ getSubAppInfo: GetSubAppInfoFnType; capabilityToolCallHandler?: CapabilityToolCallHandlerType; nodeResponses: ChatHistoryItemResType[]; + assistantResponses: AIChatItemValueItemType[]; + skillPathMap?: Record; }): Promise { const { Type } = await import('@mariozechner/pi-ai'); @@ -88,7 +133,7 @@ export async function buildAgentTools({ maxRunTimes, workflowDispatchDeep, usagePush, - model, + modelId, datasetParams } = ctx; @@ -124,7 +169,7 @@ export async function buildAgentTools({ teamId: runningUserInfo.teamId, tmbId: runningUserInfo.tmbId, customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse, - model, + modelId, userKey: externalProvider.openaiAccount as OpenaiAccountType | undefined }); if (result.nodeResponse) nodeResponses.push(result.nodeResponse); @@ -146,20 +191,51 @@ export async function buildAgentTools({ searchMode: datasetParams.searchMode, embeddingWeight: datasetParams.embeddingWeight, usingReRank: datasetParams.usingReRank ?? false, - rerankModel: datasetParams.rerankModel, + rerankModelId: datasetParams.rerankModelId, rerankWeight: datasetParams.rerankWeight || 0.5, usingExtensionQuery: datasetParams.datasetSearchUsingExtensionQuery ?? false, - extensionModel: datasetParams.datasetSearchExtensionModel, - extensionBg: datasetParams.datasetSearchExtensionBg + extensionModelId: datasetParams.datasetSearchExtensionModelId, + extensionBg: datasetParams.datasetSearchExtensionBg, + collectionFilterMatch: datasetParams.collectionFilterMatch, + authTmbId: datasetParams.authTmbId }, teamId: runningUserInfo.teamId, tmbId: runningUserInfo.tmbId, - llmModel: model + llmModelId: modelId, + lang: lang }); if (result.nodeResponse) nodeResponses.push(result.nodeResponse); return { response: result.response, usages: result.usages }; } + // Capability tools (e.g. sandbox skills) get first chance — + // the skills capability owns the sandbox session and must intercept tools like + // `sandbox_get_file_url` so the file lookup runs against the capability's sandbox + // (sandboxId = sessionId), not the hashed sandboxId from getSandboxClient. + const capStartTime = Date.now(); + const capResult = await capabilityToolCallHandler?.(toolId, argStr, callId); + if (capResult != null) { + const subInfo = getSubAppInfo(toolId); + nodeResponses.push({ + nodeId: callId, + id: callId, + toolId, + moduleType: FlowNodeTypeEnum.tool, + moduleName: subInfo.name, + moduleLogo: subInfo.avatar, + toolInput: parseJsonArgs(argStr), + toolRes: capResult.response, + runningTime: +((Date.now() - capStartTime) / 1000).toFixed(2) + }); + if (capResult.usages?.length) usagePush(capResult.usages); + // Persist skill assistantResponses (e.g. skill references from SKILL.md reads) + if (capResult.assistantResponses?.length) { + assistantResponses.push(...capResult.assistantResponses); + } + return { response: capResult.response, usages: capResult.usages }; + } + + // Sandbox shell if (toolId === SANDBOX_TOOL_NAME) { const toolParams = SandboxShellToolSchema.safeParse(args); if (!toolParams.success) return { response: toolParams.error.message }; @@ -171,10 +247,11 @@ export async function buildAgentTools({ chatId, lang }); - nodeResponses.push(result.nodeResponse); + if (result.nodeResponse) nodeResponses.push(result.nodeResponse); return { response: result.response, usages: result.usages }; } + // Sandbox get file url if (toolId === SANDBOX_GET_FILE_URL_TOOL_NAME) { const toolParams = SandboxGetFileUrlToolSchema.safeParse(args); if (!toolParams.success) return { response: toolParams.error.message }; @@ -185,27 +262,10 @@ export async function buildAgentTools({ chatId, lang }); - nodeResponses.push(result.nodeResponse); + if (result.nodeResponse) nodeResponses.push(result.nodeResponse); return { response: result.response, usages: result.usages }; } - // Capability tools (e.g. sandbox skills) - const capResult = await capabilityToolCallHandler?.(toolId, argStr, callId); - if (capResult != null) { - const subInfo = getSubAppInfo(toolId); - nodeResponses.push({ - nodeId: callId, - id: callId, - moduleType: FlowNodeTypeEnum.tool, - moduleName: subInfo.name, - moduleLogo: subInfo.avatar, - toolInput: parseJsonArgs(argStr), - toolRes: capResult.response - }); - if (capResult.usages?.length) usagePush(capResult.usages); - return { response: capResult.response, usages: capResult.usages }; - } - // User sub-apps const subApp = getSubApp(toolId); if (!subApp) return { response: `Can't find the tool ${toolId}` }; @@ -325,27 +385,55 @@ export async function buildAgentTools({ } }; - // Wrap execute to also emit SSE toolCall event before execution + // Wrap execute to emit SSE toolCall event and persist tool to chat history + // before execution. Pushing to assistantResponses here (during agent run) + // ensures tools are interleaved with text items in chronological order. const wrappedExecute = async ( callId: string, args: Record, signal?: AbortSignal ) => { const subAppInfo = getSubAppInfo(toolId); - workflowStreamResponse?.({ - id: callId, - event: SseResponseEventEnum.toolCall, - data: { + const skillDisplayName = resolveSkillDisplayName(args, skillPathMap); + const isSkillRead = !!skillDisplayName; + + if (isSkillRead) { + // Skill display handled by skillCall events + } else { + const toolName = subAppInfo?.name ?? toolId; + assistantResponses.push({ tool: { id: callId, - toolName: subAppInfo?.name || toolId, + toolName, toolAvatar: subAppInfo?.avatar || '', functionName: toolId, params: JSON.stringify(args) } - } - }); - return execute(callId, args, signal); + }); + workflowStreamResponse?.({ + id: callId, + event: SseResponseEventEnum.toolCall, + data: { + tool: { + id: callId, + toolName, + toolAvatar: subAppInfo?.avatar || '', + functionName: toolId, + params: JSON.stringify(args) + } + } + }); + } + const result = await execute(callId, args, signal); + // Update tool item in assistantResponses with the response so the + // output is visible alongside the input when expanding the tool block. + const toolItem = assistantResponses.find((item) => item.tool?.id === callId); + if (toolItem?.tool) { + toolItem.tool.response = Array.isArray(result?.content) + ? result.content.map((c) => c.text).join('\n') + : (result as any)?.response ?? String(result); + } + return result; }; tools.push({ diff --git a/packages/service/core/workflow/dispatch/ai/agent/resolveDatasetParams.ts b/packages/service/core/workflow/dispatch/ai/agent/resolveDatasetParams.ts new file mode 100644 index 000000000000..b00bdddc0374 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/resolveDatasetParams.ts @@ -0,0 +1,42 @@ +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; + +/** + * Resolve dataset search params from workflow node inputs. + * + * Two paths exist: + * 1. Agent application form: provides a composite agent_datasetParams object + * 2. Workflow Agent node template: provides individual dataset fields (datasets, similarity, etc.) + */ +export const resolveDatasetParams = ( + params: Record +): AppFormEditFormType['dataset'] | undefined => { + // Composite path: Agent application form provides agent_datasetParams directly + const composite = params[NodeInputKeyEnum.datasetParams] as + | AppFormEditFormType['dataset'] + | undefined; + if (composite?.datasets?.length) { + return composite; + } + // Individual field path: workflow Agent node template provides separate fields + const datasets = params[NodeInputKeyEnum.datasetSelectList]; + if (!datasets || !Array.isArray(datasets) || datasets.length === 0) { + return undefined; + } + return { + datasets, + similarity: params[NodeInputKeyEnum.datasetSimilarity], + limit: params[NodeInputKeyEnum.datasetMaxTokens], + searchMode: params[NodeInputKeyEnum.datasetSearchMode], + embeddingWeight: params[NodeInputKeyEnum.datasetSearchEmbeddingWeight], + embeddingModelId: params[NodeInputKeyEnum.datasetSearchEmbeddingModelId], + usingReRank: params[NodeInputKeyEnum.datasetSearchUsingReRank], + rerankModelId: params[NodeInputKeyEnum.datasetSearchRerankModelId], + rerankWeight: params[NodeInputKeyEnum.datasetSearchRerankWeight], + datasetSearchUsingExtensionQuery: params[NodeInputKeyEnum.datasetSearchUsingExtensionQuery], + datasetSearchExtensionModelId: params[NodeInputKeyEnum.datasetSearchExtensionModelId], + datasetSearchExtensionBg: params[NodeInputKeyEnum.datasetSearchExtensionBg], + collectionFilterMatch: params[NodeInputKeyEnum.collectionFilterMatch], + authTmbId: params[NodeInputKeyEnum.authTmbId] + } as AppFormEditFormType['dataset']; +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts index 931e439380f2..2eddac69799d 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts @@ -1,12 +1,16 @@ import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { getEmbeddingModel, getLLMModel, getRerankModel } from '../../../../../../ai/model'; +import { + getEmbeddingModelById, + getLLMModelById, + getRerankModelById +} from '../../../../../../ai/model'; import { createLLMResponse } from '../../../../../../ai/llm/request'; import { countPromptTokens } from '../../../../../../../common/string/tiktoken/index'; import { calculateCompressionThresholds } from '../../../../../../ai/llm/compress/constants'; import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils'; -import { i18nT } from '../../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../../global/common/i18n/utils'; import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; import { MongoDataset } from '../../../../../../dataset/schema'; @@ -18,12 +22,15 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getLogger, LogCategories } from '../../../../../../../common/logger'; +import { detectLang } from 'diting-rag-ts'; +import { filterDatasetsByTmbId, filterCollectionsByTmbId } from '../../../../../../dataset/utils'; type DatasetSearchParams = { teamId: string; tmbId: string; query: string; - llmModel: string; + llmModelId: string; + lang?: string; config: { datasets: SelectedDatasetType[]; similarity: number; @@ -31,12 +38,13 @@ type DatasetSearchParams = { searchMode: DatasetSearchModeEnum; embeddingWeight?: number; usingReRank: boolean; - rerankModel?: string; + rerankModelId?: string; rerankWeight?: number; usingExtensionQuery: boolean; - extensionModel?: string; + extensionModelId?: string; extensionBg?: string; collectionFilterMatch?: string; + authTmbId?: boolean; }; }; @@ -67,11 +75,11 @@ const formatDatasetSearchResponse = (searchResults: SearchDataResponseItemType[] const selectRelevantChunksByLLM = async ({ query, chunks, - model + modelId }: { query: string; chunks: SearchDataResponseItemType[]; - model: string; + modelId?: string; }): Promise< | { ids: string[]; @@ -79,7 +87,7 @@ const selectRelevantChunksByLLM = async ({ } | undefined > => { - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); const threshold = calculateCompressionThresholds(modelData.maxContext).datasetSearchSelection; const searchResponseText = chunks.map((item) => `${item.q}\n${item.a || ''}`).join('\n'); const estimatedTokens = await countPromptTokens(searchResponseText); @@ -118,7 +126,7 @@ ${chunkSummaries} try { const response = await createLLMResponse({ body: { - model, + modelId: modelData.id, messages: [{ role: 'user', content: prompt }], temperature: 0, stream: false @@ -134,7 +142,7 @@ ${chunkSummaries} // 计算 usage const { totalPoints, modelName } = formatModelChars2Points({ - model, + modelId: modelData.id, inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens }); @@ -142,7 +150,7 @@ ${chunkSummaries} const usage: ChatNodeUsageType = { totalPoints, moduleName: i18nT('account_usage:dataset_chunk_selection'), - model: modelName, + modelId: modelData.id, inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens }; @@ -161,7 +169,8 @@ export const dispatchAgentDatasetSearch = async ({ config, teamId, tmbId, - llmModel + llmModelId, + lang }: DatasetSearchParams): Promise<{ response: string; usages: ChatNodeUsageType[]; @@ -174,7 +183,12 @@ export const dispatchAgentDatasetSearch = async ({ }); try { - const datasetIds = await Promise.resolve(config.datasets.map((item) => item.datasetId)); + const datasetIds = config.authTmbId + ? await filterDatasetsByTmbId({ + datasetIds: config.datasets.map((item) => item.datasetId), + tmbId + }) + : await Promise.resolve(config.datasets.map((item) => item.datasetId)); if (datasetIds.length === 0) { return { @@ -183,30 +197,50 @@ export const dispatchAgentDatasetSearch = async ({ }; } + // Collection-level permission filtering + let authForbidCollectionIds: string[] | undefined; + if (config.authTmbId) { + const collectionFilterResult = await filterCollectionsByTmbId({ + datasetIds, + tmbId, + teamId + }); + if (!collectionFilterResult.allPass) { + if (collectionFilterResult.forbiddenIds.length === 0) { + // Safety: allPass=false but empty forbiddenIds shouldn't happen, treat as all pass + } else { + authForbidCollectionIds = collectionFilterResult.forbiddenIds; + } + } + } + // Get vector model - const vectorModel = getEmbeddingModel( - (await MongoDataset.findById(datasetIds[0], 'vectorModel').lean())?.vectorModel + const vectorModel = getEmbeddingModelById( + (await MongoDataset.findById(datasetIds[0], 'vectorModelId').lean())?.vectorModelId ); // Get Rerank Model - const rerankModelData = getRerankModel(config.rerankModel); + const rerankModelData = getRerankModelById(config.rerankModelId); const searchData: DefaultSearchDatasetDataProps = { histories: [], teamId, reRankQuery: query, queries: [query], - model: vectorModel.model, + modelId: vectorModel.id, similarity: config.similarity, limit: config.maxTokens, datasetIds, searchMode: config.searchMode, embeddingWeight: config.embeddingWeight, usingReRank: config.usingReRank, - rerankModel: rerankModelData, + rerankModelId: rerankModelData.id, rerankWeight: config.rerankWeight, datasetSearchUsingExtensionQuery: config.usingExtensionQuery, - datasetSearchExtensionModel: config.extensionModel, - datasetSearchExtensionBg: config.extensionBg + datasetSearchExtensionModelId: config.extensionModelId, + datasetSearchExtensionBg: config.extensionBg, + collectionFilterMatch: config.collectionFilterMatch, + authForbidCollectionIds, + lang: lang ?? detectLang(query) }; const { searchRes, @@ -225,7 +259,7 @@ export const dispatchAgentDatasetSearch = async ({ const pickResults = await selectRelevantChunksByLLM({ query, chunks: searchRes, - model: llmModel + modelId: llmModelId }); if (pickResults) { if (pickResults.ids.length > 0) { @@ -243,27 +277,27 @@ export const dispatchAgentDatasetSearch = async ({ // 1. Query extension if (queryExtensionResult) { const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({ - model: queryExtensionResult.llmModel, + modelId: queryExtensionResult.llmModelId, inputTokens: queryExtensionResult.inputTokens, outputTokens: queryExtensionResult.outputTokens }); usages.push({ totalPoints: llmPoints, moduleName: i18nT('common:core.module.template.Query extension'), - model: llmModelName, + modelId: queryExtensionResult.llmModelId, inputTokens: queryExtensionResult.inputTokens, outputTokens: queryExtensionResult.outputTokens }); const { totalPoints: embeddingPoints, modelName: embeddingModelName } = formatModelChars2Points({ - model: queryExtensionResult.embeddingModel, + modelId: queryExtensionResult.embeddingModelId, inputTokens: queryExtensionResult.embeddingTokens }); usages.push({ totalPoints: embeddingPoints, moduleName: `${i18nT('account_usage:ai.query_extension_embedding')}`, - model: embeddingModelName, + modelId: queryExtensionResult.embeddingModelId, inputTokens: queryExtensionResult.embeddingTokens, outputTokens: 0 }); @@ -271,26 +305,26 @@ export const dispatchAgentDatasetSearch = async ({ // 2. Search vector const { totalPoints: embeddingTotalPoints, modelName: embeddingModelName } = formatModelChars2Points({ - model: vectorModel.model, + modelId: vectorModel.id, inputTokens: embeddingTokens }); usages.push({ totalPoints: embeddingTotalPoints, moduleName: i18nT('account_usage:dataset_search'), - model: embeddingModelName, + modelId: vectorModel.id, inputTokens: embeddingTokens }); // 3. Rerank if (searchUsingReRank) { const { totalPoints: reRankTotalPoints, modelName: reRankModelName } = formatModelChars2Points({ - model: rerankModelData?.model, + modelId: rerankModelData?.id || '', inputTokens: reRankInputTokens }); usages.push({ totalPoints: reRankTotalPoints, moduleName: i18nT('account_usage:rerank'), - model: reRankModelName, + modelId: rerankModelData?.id, inputTokens: reRankInputTokens }); } @@ -305,7 +339,7 @@ export const dispatchAgentDatasetSearch = async ({ moduleName: i18nT('chat:dataset_search'), totalPoints, query, - embeddingModel: vectorModel.name, + embeddingModelId: vectorModel.id, embeddingTokens, similarity: usingSimilarityFilter ? config.similarity : undefined, limit: config.maxTokens, @@ -316,17 +350,17 @@ export const dispatchAgentDatasetSearch = async ({ : undefined, // Rerank ...(searchUsingReRank && { - rerankModel: rerankModelData?.name, + rerankModelId: rerankModelData?.id, rerankWeight: config.rerankWeight, reRankInputTokens }), searchUsingReRank, queryExtensionResult: queryExtensionResult ? { - model: queryExtensionResult.llmModel, + modelId: queryExtensionResult.llmModelId, inputTokens: queryExtensionResult.inputTokens, outputTokens: queryExtensionResult.outputTokens, - query: queryExtensionResult.query + query: queryExtensionResult.query || '' } : undefined, // Results diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/utils.ts index 56ecaee0949b..71c03b0a7284 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/utils.ts @@ -9,23 +9,6 @@ export const DatasetSearchToolSchema = z.object({ query: z.string() }); -// 工具配置类型(从 Agent 节点配置传入的预设参数) -export type DatasetSearchToolConfig = { - datasets: SelectedDatasetType[]; - similarity: number; - maxTokens: number; - searchMode: `${DatasetSearchModeEnum}`; - embeddingWeight?: number; - usingReRank: boolean; - rerankModel?: string; - rerankWeight?: number; - usingExtensionQuery: boolean; - extensionModel?: string; - extensionBg?: string; - collectionFilterMatch?: string; - model: string; -}; - // ChatCompletionTool 定义 export const datasetSearchTool: ChatCompletionTool = { type: 'function', diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts index 722f3e433cee..d23f7e218a99 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts @@ -8,14 +8,14 @@ import { } from '@fastgpt/global/common/file/tools'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { getS3RawTextSource } from '../../../../../../../common/s3/sources/rawText/index'; -import { readFileContentByBuffer } from '../../../../../../../common/file/read/utils'; -import { getLLMModel } from '../../../../../../ai/model'; +import { readS3FileContentByBuffer } from '../../../../../../../common/file/read/utils'; +import { getLLMModelById } from '../../../../../../ai/model'; import { compressLargeContent } from '../../../../../../ai/llm/compress'; import { calculateCompressionThresholds } from '../../../../../../ai/llm/compress/constants'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { i18nT } from '../../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../../global/common/i18n/utils'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import { getLogger, LogCategories } from '../../../../../../../common/logger'; import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; @@ -26,7 +26,7 @@ type FileReadParams = { teamId: string; tmbId: string; customPdfParse?: boolean; - model: string; + modelId?: string; userKey?: OpenaiAccountType; }; @@ -35,7 +35,7 @@ export const dispatchFileRead = async ({ teamId, tmbId, customPdfParse, - model, + modelId, userKey }: FileReadParams): Promise<{ response: string; @@ -99,7 +99,7 @@ export const dispatchFileRead = async ({ })(); // Read file - const { rawText } = await readFileContentByBuffer({ + const { rawText } = await readS3FileContentByBuffer({ extension, teamId, tmbId, @@ -136,7 +136,7 @@ export const dispatchFileRead = async ({ let responseText = JSON.stringify(readFilesResult); // Check if compression is needed - const llmModel = getLLMModel(model); + const llmModel = getLLMModelById(modelId); const thresholds = calculateCompressionThresholds(llmModel.maxContext); const maxTokens = thresholds.fileReadResponse.threshold; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/model/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/model/index.ts index 4660342d87e7..c0a33839068f 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/model/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/model/index.ts @@ -1,11 +1,11 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; import { createLLMResponse, type ResponseEvents } from '../../../../../../ai/llm/request'; -import { getLLMModel } from '../../../../../../ai/model'; +import { getLLMModelById } from '../../../../../../ai/model'; import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; type ModelAgentConfig = { - model: string; + modelId: string; temperature?: number; top_p?: number; stream?: boolean; @@ -24,7 +24,7 @@ type DispatchPlanAgentResponse = { }; export async function dispatchModelAgent({ - model, + modelId, temperature, top_p, stream, @@ -33,7 +33,7 @@ export async function dispatchModelAgent({ onReasoning, onStreaming }: DispatchModelAgentProps): Promise { - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); const messages: ChatCompletionMessageParam[] = [ ...(systemPrompt @@ -52,7 +52,7 @@ export async function dispatchModelAgent({ const { answerText, usage } = await createLLMResponse({ body: { - model: modelData.model, + modelId: modelData.id, temperature, messages: messages, top_p, @@ -63,7 +63,7 @@ export async function dispatchModelAgent({ }); const { totalPoints, modelName } = formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); @@ -73,7 +73,7 @@ export async function dispatchModelAgent({ usages: [ { moduleName: modelName, - model: modelData.model, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts index 2131add59b0f..0cf3190db0d4 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/index.ts @@ -10,7 +10,7 @@ import { getInitialPlanQuery, reTryPlanPrompt } from './prompt'; -import { getLLMModel } from '../../../../../../ai/model'; +import { getLLMModelById } from '../../../../../../ai/model'; import { formatModelChars2Points } from '../../../../../../../support/wallet/usage/utils'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { @@ -27,7 +27,7 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; -import { i18nT } from '../../../../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../../../../global/common/i18n/utils'; import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; import type { PlanAgentParamsType } from './constants'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; @@ -38,7 +38,7 @@ const agentLogger = getLogger(LogCategories.MODULE.AI.AGENT); type PlanAgentConfig = { systemPrompt?: string; - model: string; + modelId?: string; temperature?: number; top_p?: number; stream?: boolean; @@ -172,7 +172,7 @@ export const dispatchPlanAgent = async ({ completionTools, getSubAppInfo, systemPrompt, - model, + modelId, planId, task, description, @@ -181,7 +181,7 @@ export const dispatchPlanAgent = async ({ ...props }: DispatchPlanAgentProps): Promise => { const startTime = Date.now(); - const modelData = getLLMModel(model); + const modelData = getLLMModelById(modelId); // 移除 plan 工具 completionTools = completionTools.filter((item) => item.function.name !== SubAppIds.plan); @@ -240,7 +240,7 @@ export const dispatchPlanAgent = async ({ // console.log('userInput:', userInput, 'mode:', mode, 'interactive?.type:', interactive?.type); const requestParams = { - model: modelData.model, + modelId: modelData.id, stream: true, tools: props.mode === 'continue' ? undefined : [AIAskTool], tool_choice: 'auto' as const, @@ -274,7 +274,7 @@ export const dispatchPlanAgent = async ({ const initialPoints = userKey ? 0 : formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }).totalPoints; @@ -333,7 +333,7 @@ export const dispatchPlanAgent = async ({ const regenPoints = userKey ? 0 : formatModelChars2Points({ - model: modelData.model, + modelId: modelData.id, inputTokens: regenerateResponse.usage.inputTokens, outputTokens: regenerateResponse.usage.outputTokens }).totalPoints; @@ -380,8 +380,6 @@ export const dispatchPlanAgent = async ({ })(); // 使用累加的价格(每次调用单独计价后累加),保证梯度计费正确 - const modelName = modelData.name; - const nodeId = getNanoid(6); const nodeResponse: ChatHistoryItemResType = { nodeId: nodeId, @@ -393,7 +391,7 @@ export const dispatchPlanAgent = async ({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalPoints, - model: modelName, + modelId: modelData.id, runningTime: +((Date.now() - startTime) / 1000).toFixed(2), llmRequestIds }; @@ -412,7 +410,7 @@ export const dispatchPlanAgent = async ({ usages: [ { moduleName: i18nT('account_usage:agent_call'), - model: modelName, + modelId: modelData.id, totalPoints, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts index 7800df4c555c..5eb96064d1fa 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/plan/prompt.ts @@ -4,6 +4,7 @@ import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io' import { AIAskTool } from './ask/constants'; import type { GetSubAppInfoFnType } from '../../type'; import type { PlanAgentParamsType } from './constants'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; const getSubAppPrompt = ({ getSubAppInfo, @@ -51,6 +52,41 @@ ${selectedDataset.map((item) => `- ${item.name} (ID: ${item.datasetId})`).join(' **重要**:如果背景信息中包含工具引用(@工具名),请优先使用这些工具。当有多个同类工具可选时(如多个搜索工具),优先选择背景信息中已使用的工具,避免功能重叠。`; }; +export const parsePiAgentSystemPrompt = ({ + userSystemPrompt, + selectedDataset = [], + lang +}: { + userSystemPrompt?: string; + selectedDataset?: SelectedDatasetType[]; + lang?: localeType; +}) => { + const presetDatasetPrompt = + selectedDataset.length > 0 + ? ` +已选知识库: +${selectedDataset.map((item) => `- ${item.name} (ID: ${item.datasetId})`).join('\n')} +` + : ''; + + const langInstruction = + lang === 'zh-CN' + ? '\n\n**Important**: Please think and reason in Simplified Chinese (简体中文).' + : lang === 'zh-Hant' + ? '\n\n**Important**: Please think and reason in Traditional Chinese (繁體中文).' + : ''; + + if (!userSystemPrompt && !presetDatasetPrompt) { + return langInstruction.trim(); + } + + const list = [userSystemPrompt, presetDatasetPrompt]; + + return `${list.filter(Boolean).join('\n\n')}${langInstruction} + +**Important**: If the background information contains tool references (@ToolName), please prioritize using those tools.`; +}; + // 通用的基础部分 const getCommonPromptParts = ({ getSubAppInfo, diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts index 61385fede6d4..9e6f459d14a3 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts @@ -117,12 +117,7 @@ export const dispatchSandboxGetFileUrl = async ({ // Agent Skills re-exports export type { AgentSandboxContext } from './types'; -export { - createAgentSandbox, - releaseAgentSandbox, - connectEditDebugSandbox, - disconnectEditDebugSandbox -} from './lifecycle'; +export { createAgentSandbox, releaseAgentSandbox } from './lifecycle'; export { dispatchSandboxReadFile, dispatchSandboxWriteFile, diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts index 309cfcaae7a8..55360685d167 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts @@ -22,10 +22,8 @@ import { disconnectFromProviderSandbox, buildBaseContainerEnv } from '../../../../../../agentSkills/sandboxConfig'; -import { SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { getSandboxClient, type SandboxClient } from '../../../../../../ai/sandbox/controller'; -import { env } from '../../../../../../../env'; import type { AgentSkillSchemaType, AgentSkillsVersionSchemaType, @@ -40,9 +38,12 @@ type CreateAgentSandboxParams = { teamId: string; tmbId: string; sessionId: string; // chat 模式 = chatId,debug 模式 = 构造的 key + /** 真实 chatId,用于前端 sandbox API 查找匹配。始终为实际会话 ID,不随 mode 变化。 */ + chatId: string; entrypoint?: string; // override default entrypoint for this request image?: SandboxImageConfigType; // override default image for this request onProgress?: (status: SandboxStatusItemType) => void; // lifecycle progress callback + skillsMeta?: DeployedSkillInfo[]; // expected skill metadata from fetchSkillsMetaForPrompt }; const logger = getLogger(LogCategories.MODULE.AI.AGENT); @@ -169,32 +170,45 @@ async function deploySkillsToSandbox( // --- Exported lifecycle functions --- /** - * 创建或复用 session-runtime 沙箱。 + * 创建或复用 agent skill 沙箱。 * - * 优先查询 MongoDB 中是否有相同 sessionId 的活跃容器: - * - 有 → connect 复用,无冷启动 - * - 无 → 创建新容器,挂载会话 Volume,持久化到 MongoDB + * Uses findOneAndUpdate with upsert to atomically check-and-reserve the + * session slot, preventing two replicas from creating duplicate containers + * for the same chatId combination. */ export async function createAgentSandbox( params: CreateAgentSandboxParams ): Promise { - const { skillIds, teamId, tmbId, sessionId, entrypoint, image, onProgress } = params; + const { skillIds, teamId, tmbId, sessionId, chatId, entrypoint, image, onProgress, skillsMeta } = + params; const providerConfig = getSandboxProviderConfig(); const defaults = getSandboxDefaults(); validateSandboxConfig(providerConfig); - // Step 1: Try to reuse an existing session-runtime sandbox + // Step 1: Atomically reserve or find an existing sandbox by (chatId, appId). + // Uses unique partial index on (appId, chatId) so only one replica can claim the slot. + // The reservation is created with status=stopped so that ensureAvailable() Path 2 + // atomically acquires the semaphore and starts the container. onProgress?.({ sandboxId: sessionId, phase: 'checkExisting' }); - const existingInstance = await MongoSandboxInstance.findOne({ - chatId: sessionId, - 'metadata.sandboxType': SandboxTypeEnum.sessionRuntime - }); + const reservation = await MongoSandboxInstance.findOneAndUpdate( + { chatId, appId: teamId }, + { + $setOnInsert: { + sandboxId: sessionId, + provider: providerConfig.provider, + status: SandboxStatusEnum.stopped, + lastActiveAt: new Date(), + createdAt: new Date() + } + }, + { upsert: true, new: false } + ).lean(); - if (existingInstance) { - logger.info('[Agent Sandbox] Reusing existing session-runtime sandbox', { + if (reservation) { + logger.info('[Agent Sandbox] Reusing existing sandbox', { sessionId, - providerSandboxId: existingInstance.sandboxId + providerSandboxId: reservation.sandboxId }); onProgress?.({ sandboxId: sessionId, phase: 'connecting', isWarmStart: true }); @@ -202,31 +216,123 @@ export async function createAgentSandbox( // getSandboxClient internally calls ensureAvailable(): // - updates DB status=running, lastActiveAt // - calls provider.ensureRunning() to ensure container is running (handles stopped→running) - const client = await getSandboxClient({ sandboxId: existingInstance.sandboxId }); + const client = await getSandboxClient({ sandboxId: reservation.sandboxId }); + + // Fetch skills for current skillIds and discover what's deployed + const { skills, versionMap } = await fetchSkillsWithVersionMap(skillIds, teamId); + const deployedSkills = await discoverSkillsInSandbox(client.provider, defaults.workDirectory); + + // Sync: diff by SKILL.md paths (from ZIP-extracted metadata) vs sandbox reality + let needsRediscover = false; - const reusedSkillIds = existingInstance.metadata?.skillIds - ? existingInstance.metadata.skillIds.map(String) - : skillIds; - const { skills, versionMap } = await fetchSkillsWithVersionMap(reusedSkillIds, teamId); + if (skillsMeta && skillsMeta.length > 0) { + const expectedPathMap = new Map( + skillsMeta.filter((s) => s.skillMdPath).map((s) => [s.skillMdPath, s]) + ); + const deployedPathSet = new Set(deployedSkills.map((s) => s.skillMdPath)); + + // Skills to deploy: in expected but not deployed + const skillIdsToAdd: string[] = []; + for (const [path, meta] of expectedPathMap) { + if (!deployedPathSet.has(path)) { + skillIdsToAdd.push(meta.id); + } + } + + if (skillIdsToAdd.length > 0) { + onProgress?.({ sandboxId: sessionId, phase: 'deployingSkills' }); + const { skills: newSkills, versionMap: newVersionMap } = await fetchSkillsWithVersionMap( + skillIdsToAdd, + teamId + ); + const deployable = newSkills.filter( + (s) => newVersionMap.get(String(s._id)) && s.currentStorage + ); + if (deployable.length > 0) { + await deploySkillsToSandbox( + client.provider, + deployable, + newVersionMap, + defaults.workDirectory, + onProgress, + sessionId + ); + } + } + + // Skills to remove: deployed but not expected + const staleDeployed = deployedSkills.filter( + (s) => s.directory && !expectedPathMap.has(s.skillMdPath) + ); + for (const deployed of staleDeployed) { + try { + await client.provider.execute(`rm -rf "${deployed.directory}"`); + logger.info('[Agent Sandbox] Removed stale skill directory', { + name: deployed.name, + directory: deployed.directory + }); + } catch (err) { + logger.error('[Agent Sandbox] Failed to remove stale skill directory', { + name: deployed.name, + error: err + }); + } + } + + if (skillIdsToAdd.length > 0 || staleDeployed.length > 0) { + needsRediscover = true; + await MongoSandboxInstance.updateOne( + { sandboxId: reservation.sandboxId }, + { $set: { 'metadata.skillIds': skillIds } } + ); + } + } else if (deployedSkills.length > 0) { + // No skills configured but sandbox has stale deployed skills — clean up all + for (const deployed of deployedSkills) { + if (deployed.directory) { + try { + await client.provider.execute(`rm -rf "${deployed.directory}"`); + logger.info('[Agent Sandbox] Removed stale skill directory (empty config)', { + name: deployed.name, + directory: deployed.directory + }); + } catch (err) { + logger.error('[Agent Sandbox] Failed to remove stale skill directory', { + name: deployed.name, + error: err + }); + } + } + } + needsRediscover = true; + await MongoSandboxInstance.updateOne( + { sandboxId: reservation.sandboxId }, + { $set: { 'metadata.skillIds': [] } } + ); + } + + // Re-discover after sync (or keep original when no changes) + const finalDeployedSkills = needsRediscover + ? await discoverSkillsInSandbox(client.provider, defaults.workDirectory) + : deployedSkills; onProgress?.({ sandboxId: sessionId, phase: 'ready', isWarmStart: true }); const mergedSkills = skills.map((skill) => mergeSkillWithVersion(skill.toJSON(), versionMap.get(String(skill._id))) ); - const deployedSkills = await discoverSkillsInSandbox(client.provider, defaults.workDirectory); return { sandbox: client.provider, - providerSandboxId: existingInstance.sandboxId, + providerSandboxId: reservation.sandboxId, sessionId, skills: mergedSkills, - deployedSkills, + deployedSkills: finalDeployedSkills, workDirectory: defaults.workDirectory, isReady: true }; } // Step 2: Fetch skills and filter deployable ones (skip when no skills configured) - logger.info('[Agent Sandbox] Creating new session-runtime sandbox', { + logger.info('[Agent Sandbox] Creating new sandbox', { skillIds, teamId, sessionId @@ -254,21 +360,6 @@ export async function createAgentSandbox( } } - // Check active session-runtime sandbox count limit - const maxSessionRuntime = - global.feConfigs?.limit?.agentSandboxMaxSessionRuntime ?? env.AGENT_SANDBOX_MAX_SESSION_RUNTIME; - if (maxSessionRuntime !== undefined) { - const activeCount = await MongoSandboxInstance.countDocuments({ - status: SandboxStatusEnum.running, - 'metadata.sandboxType': SandboxTypeEnum.sessionRuntime - }); - if (activeCount >= maxSessionRuntime) { - const message = `Active session-runtime sandbox limit reached (${activeCount}/${maxSessionRuntime}). Please try again later.`; - onProgress?.({ sandboxId: sessionId, phase: 'failed', message }); - throw new Error(message); - } - } - // Step 3: Create sandbox container via getSandboxClient (handles volumes internally) let sandboxClient: SandboxClient | null = null; @@ -288,8 +379,6 @@ export async function createAgentSandbox( metadata: { teamId, tmbId, - sandboxType: SandboxTypeEnum.sessionRuntime, - skillIds: skillIds.join('-'), sessionId } } @@ -327,14 +416,13 @@ export async function createAgentSandbox( { sandboxId: sessionId }, { $set: { - appId: teamId, // session-runtime uses teamId as appId + appId: teamId, // agent sandbox uses teamId as appId userId: tmbId, - chatId: sessionId, + chatId, metadata: { - sandboxType: SandboxTypeEnum.sessionRuntime, + sessionId, teamId, tmbId, - sessionId, skillIds: hasSkills ? deployableSkills.map((s) => s._id) : [], provider: providerConfig.provider, image: sandboxInfo.image, @@ -394,74 +482,3 @@ export async function releaseAgentSandbox(ctx: AgentSandboxContext): Promise { - const { skillId, teamId } = params; - const defaults = getSandboxDefaults(); - - const instanceDoc = await MongoSandboxInstance.findOne({ - appId: skillId, - chatId: 'edit-debug', - 'metadata.sandboxType': SandboxTypeEnum.editDebug - }); - if (!instanceDoc) { - throw new Error('No active edit-debug sandbox found for this skill'); - } - - const skill = await MongoAgentSkills.findOne({ - _id: skillId, - teamId, - deleteTime: null - }); - if (!skill) { - throw new Error('Skill not found'); - } - - // getSandboxClient internally calls ensureAvailable(): - // - updates DB status=running, lastActiveAt - // - calls provider.ensureRunning() to ensure container is running (handles stopped→running) - const client = await getSandboxClient({ sandboxId: instanceDoc.sandboxId }); - - logger.info('[Agent Sandbox] Connected to edit-debug sandbox', { - skillId, - providerSandboxId: instanceDoc.sandboxId - }); - - // Dynamically discover deployed skills instead of reading from persisted metadata - const deployedSkills = await discoverSkillsInSandbox(client.provider, defaults.workDirectory); - - return { - sandbox: client.provider, - providerSandboxId: instanceDoc.sandboxId, - sessionId: String(instanceDoc._id), // editDebug sandbox uses its own _id as sessionId - skills: [skill.toJSON()], - deployedSkills, - workDirectory: defaults.workDirectory, - isReady: true - }; -} - -/** - * 只断开连接,不销毁沙箱。 - */ -export async function disconnectEditDebugSandbox(ctx: AgentSandboxContext): Promise { - try { - await disconnectFromProviderSandbox(ctx.sandbox); - logger.info('[Agent Sandbox] Disconnected from edit-debug sandbox', { - providerSandboxId: ctx.providerSandboxId - }); - } catch (error) { - logger.error('[Agent Sandbox] Failed to disconnect from edit-debug sandbox', { error }); - } -} diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts index c889ca306267..59b290197fff 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts @@ -61,11 +61,9 @@ export function buildSkillsContextPrompt( // Sandbox environment section: always present lines.push(''); lines.push(`Workspace root: ${workDirectory}`); + lines.push('You have access to sandbox_* tools: read, write, edit, execute, fetch_user_file.'); lines.push( - 'You have access to sandbox_* tools: read, write, edit, execute, search files, and fetch user-uploaded files.' - ); - lines.push( - 'If the conversation includes , use sandbox_fetch_user_file to copy files into the workspace.' + 'If the conversation includes and files are being processed in the sandbox, use sandbox_fetch_user_file to copy files into the workspace.' ); lines.push( 'Always use RELATIVE paths for target_path (e.g. "uploads/file.pdf"), never absolute paths or "..".' diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts index 2bd6c6e70b8b..076d60d02910 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts @@ -16,12 +16,14 @@ import type { SandboxFetchUserFileSchema } from '@fastgpt/global/core/workflow/node/agent/skillTools'; import axios from 'axios'; +import https from 'https'; import { serverRequestBaseUrl } from '../../../../../../../common/api/serverRequest'; import path from 'path'; type DispatchResult = { response: string; usages: []; + files?: Array<{ path: string; content: string }>; }; /** @@ -43,10 +45,12 @@ export async function dispatchSandboxReadFile( file.content instanceof Uint8Array ? new TextDecoder('utf-8').decode(file.content) : String(file.content); - return `--- ${file.path} ---\n${content}`; + return { path: file.path, content }; }); - return { response: results.join('\n\n'), usages: [] }; + const response = results.map((r) => `--- ${r.path} ---\n${r.content}`).join('\n\n'); + + return { response, usages: [], files: results }; } catch (error) { return { response: `Failed to read files: ${error instanceof Error ? error.message : String(error)}`, @@ -203,7 +207,9 @@ export async function dispatchSandboxFetchUserFile( try { const response = await axios.get(fileEntry.url, { baseURL: serverRequestBaseUrl, - responseType: 'arraybuffer' + responseType: 'arraybuffer', + timeout: 30000, + httpsAgent: new https.Agent({ rejectUnauthorized: false }) }); const buffer: ArrayBuffer = response.data; diff --git a/packages/service/core/workflow/dispatch/ai/chat.ts b/packages/service/core/workflow/dispatch/ai/chat.ts index 163533616bf0..0a9f15ce49bf 100644 --- a/packages/service/core/workflow/dispatch/ai/chat.ts +++ b/packages/service/core/workflow/dispatch/ai/chat.ts @@ -1,5 +1,6 @@ import { filterGPTMessageByMaxContext } from '../../../ai/llm/utils'; import type { ChatItemMiniType, UserChatItemFileItemType } from '@fastgpt/global/core/chat/type'; +import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { textAdaptGptResponse } from '@fastgpt/global/core/workflow/runtime/utils'; @@ -23,7 +24,7 @@ import { import type { AIChatNodeProps } from '@fastgpt/global/core/workflow/runtime/type'; import { replaceVariable } from '@fastgpt/global/common/string/tools'; import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; -import { getLLMModel } from '../../../ai/model'; +import { getLLMModelById } from '../../../ai/model'; import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import type { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; @@ -36,10 +37,20 @@ import { formatTime2YMDHM } from '@fastgpt/global/common/string/time'; import type { AiChatQuoteRoleType } from '@fastgpt/global/core/workflow/template/system/aiChat/type'; import { getFileContentFromLinks, getHistoryFileLinks } from '../tools/readFiles'; import { parseUrlToFileType } from '../../utils/context'; -import { i18nT } from '../../../../../web/i18n/utils'; +import { i18nT } from '../../../../../global/common/i18n/utils'; import { postTextCensor } from '../../../chat/postTextCensor'; import { createLLMResponse } from '../../../ai/llm/request'; +import { compressLargeContent } from '../../../ai/llm/compress'; +import { countPromptTokens } from '../../../../common/string/tiktoken'; +import { env } from '../../../../env'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import { addLog } from '../../../../common/system/log'; +import { + extractQuerySynonyms, + mergeSynonymMappings, + buildSynonymMappingPrompt +} from '../../../dataset/search/synonym'; export type ChatProps = ModuleDispatchProps< AIChatNodeProps & { @@ -76,7 +87,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise item.id) || [], + quoteQASummary: + quoteQA?.map((item, index) => ({ + index, + id: item.id, + datasetId: item.datasetId, + sourceName: item.sourceName, + q: item.q, + a: item.a + })) || [] + }); + + const [{ datasetQuoteText, synonymMappingsText }, { documentQuoteText, userFiles }] = + await Promise.all([ + filterDatasetQuote({ + quoteQA, + model: modelConstantsData, + quoteTemplate: quoteTemplate || getQuoteTemplate(version), + userChatInput + }), + getMultiInput({ + histories: chatHistories, + inputFiles, + fileLinks, + stringQuoteText, + requestOrigin, + maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20, + customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse, + usageId, + runningUserInfo + }) + ]); if (!userChatInput && !documentQuoteText && userFiles.length === 0) { return getNodeErrResponse({ error: i18nT('chat:AI_input_is_empty') }); @@ -147,6 +175,58 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise threshold) { + const targetTokens = Math.floor( + modelConstantsData.maxContext * env.USER_INPUT_COMPRESS_TARGET + ); + + addLog.debug('User input auto-compression triggered', { + inputTokens, + threshold, + targetTokens + }); + + const result = await compressLargeContent({ + content: userChatInput, + model: modelConstantsData, + maxTokens: targetTokens, + userKey: externalProvider.openaiAccount + }); + + if (result.compressed && result.compressed !== userChatInput) { + finalUserInput = result.compressed; + compressionMeta = { + originalText: userChatInput, + compressedText: result.compressed, + usage: result.usage + }; + + if (result.usage) { + props.usagePush([result.usage]); + } + + addLog.debug('User input auto-compression completed', { + originalTokens: inputTokens, + moduleName: result.usage?.moduleName + }); + } + } + } + const [{ filterMessages }] = await Promise.all([ getChatMessages({ model: modelConstantsData, @@ -154,10 +234,11 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise 0) { + const firstQueryValue = query[0]; + if (firstQueryValue?.text) { + firstQueryValue.text.originalContent = compressionMeta.originalText; + firstQueryValue.text.content = compressionMeta.compressedText; + (firstQueryValue as any).compression = { + modelId: modelConstantsData.id, + inputTokens: compressionMeta.usage?.inputTokens, + outputTokens: compressionMeta.usage?.outputTokens + }; + } + } + + // Also inject into chatCompleteMessages for downstream consumers + for (let i = chatCompleteMessages.length - 1; i >= 0; i--) { + const msg = chatCompleteMessages[i]; + if (msg.obj === ChatRoleEnum.Human) { + const firstValue = msg.value?.[0] as UserChatItemValueItemType | undefined; + if (firstValue?.text) { + firstValue.text.originalContent = compressionMeta.originalText; + firstValue.text.content = compressionMeta.compressedText; + firstValue.compression = { + modelId: modelConstantsData.id, + inputTokens: compressionMeta.usage?.inputTokens, + outputTokens: compressionMeta.usage?.outputTokens + }; + } + break; + } + } + } + if (error) { return getNodeErrResponse({ error, responseData: { totalPoints: points, - model: modelName, + modelId: modelConstantsData.id, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, query: `${userChatInput}`, @@ -260,7 +377,8 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise { + if (!item.collectionId) return true; + return filterQuoteQA.findIndex((q) => q.collectionId === item.collectionId) === index; + }); + + // 收集 chunk 来源的同义词映射(已挂在每个 item 上) + const chunkSynonymMappings = dedupedQuoteQA.flatMap((item) => item.synonymMappings ?? []); + + // 收集去重的 datasetIds,用于查询 query 来源的同义词 + const datasetIds = [...new Set(dedupedQuoteQA.map((item) => item.datasetId).filter(Boolean))]; + + // 查询 query 命中的同义词映射 + const querySynonymMappings = await extractQuerySynonyms(userChatInput, datasetIds); + + // 合并 chunk + query 两路同义词,去重 + const mergedSynonymMappings = mergeSynonymMappings([ + ...chunkSynonymMappings, + ...querySynonymMappings + ]); + + // 格式化为可读的提示文本 + const synonymMappingsText = buildSynonymMappingPrompt(mergedSynonymMappings); + function getValue({ item, index }: { item: SearchDataResponseItemType; index: number }) { + // Filter out SQL content for sql_quote items to prevent interference with LLM generation + const isSqlQuote = item.id.startsWith('sql_quote_'); + return replaceVariable(quoteTemplate, { id: item.id, q: item.q, - a: item.a || '', + a: isSqlQuote ? '' : item.a || '', updateTime: formatTime2YMDHM(item.updateTime), source: item.sourceName, sourceId: String(item.sourceId || ''), - index: index + 1 + index: index + 1, + synonymMappings: item.synonymMappings ? buildSynonymMappingPrompt(item.synonymMappings) : '' }); } - // slice filterSearch - const filterQuoteQA = await filterSearchResultsByMaxChars(quoteQA, model.quoteMaxToken); - const datasetQuoteText = - filterQuoteQA.length > 0 - ? `${filterQuoteQA.map((item, index) => getValue({ item, index }).trim()).join('\n------\n')}` + dedupedQuoteQA.length > 0 + ? `${dedupedQuoteQA.map((item, index) => getValue({ item, index }).trim()).join('\n------\n')}` : ''; + addLog.debug('AI Chat - Final Dataset Quote Text', { + quoteTextLength: datasetQuoteText.length, + quoteTextPreview: datasetQuoteText, + synonymMappingsText + }); + return { - datasetQuoteText + datasetQuoteText, + synonymMappingsText }; } @@ -404,6 +560,7 @@ async function getChatMessages({ aiChatQuoteRole, datasetQuotePrompt = '', datasetQuoteText, + synonymMappingsText = '', useDatasetQuote, version, histories = [], @@ -418,6 +575,7 @@ async function getChatMessages({ aiChatQuoteRole: AiChatQuoteRoleType; // user: replace user prompt; system: replace system prompt datasetQuotePrompt?: string; datasetQuoteText: string; + synonymMappingsText?: string; version?: string; useDatasetQuote: boolean; @@ -442,6 +600,7 @@ async function getChatMessages({ useDatasetQuote && quoteRole === 'user' ? replaceVariable(datasetQuotePromptTemplate, { quote: datasetQuoteText, + synonymMappings: synonymMappingsText, question: userChatInput }) : userChatInput; @@ -453,7 +612,8 @@ async function getChatMessages({ systemPrompt, useDatasetQuote && quoteRole === 'system' ? replaceVariable(datasetQuotePromptTemplate, { - quote: datasetQuoteText + quote: datasetQuoteText, + synonymMappings: synonymMappingsText }) : '', documentQuoteText @@ -465,6 +625,15 @@ async function getChatMessages({ .filter(Boolean) .join('\n\n===---===---===\n\n'); + // 兜底替换:确保所有占位符都被替换,即使未使用数据集引用 + const finalSystemPrompt = replaceVariable(concatenateSystemPrompt, { + synonymMappings: synonymMappingsText || '' + }); + + const finalUserInput = replaceVariable(replaceInputValue, { + synonymMappings: synonymMappingsText || '' + }); + const messages: ChatItemMiniType[] = [ ...getSystemPrompt_ChatItemType(concatenateSystemPrompt), ...histories, @@ -472,7 +641,7 @@ async function getChatMessages({ obj: ChatRoleEnum.Human, value: runtimePrompt2ChatsValue({ files: userFiles, - text: replaceInputValue + text: finalUserInput }) } ]; @@ -487,6 +656,20 @@ async function getChatMessages({ maxContext: model.maxContext - maxTokens // filter token. not response maxToken }); + // Debug日志:记录最终发送给LLM的消息 + addLog.debug('AI Chat - Final Messages to LLM', { + totalMessagesCount: filterMessages.length, + messages: filterMessages.map((msg, index) => ({ + index, + role: msg.role, + contentLength: + typeof msg.content === 'string' + ? msg.content.length + : JSON.stringify(msg.content)?.length ?? 0, + content: msg.content + })) + }); + return { filterMessages }; diff --git a/packages/service/core/workflow/dispatch/ai/classifyQuestion.ts b/packages/service/core/workflow/dispatch/ai/classifyQuestion.ts index 2141151fbabc..fc838b15259d 100644 --- a/packages/service/core/workflow/dispatch/ai/classifyQuestion.ts +++ b/packages/service/core/workflow/dispatch/ai/classifyQuestion.ts @@ -8,7 +8,7 @@ import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runti import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import { getCQSystemPrompt } from '@fastgpt/global/core/ai/prompt/agent'; import { type LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; -import { getLLMModel } from '../../../ai/model'; +import { getLLMModelById } from '../../../ai/model'; import { getHistories } from '../utils'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; @@ -19,7 +19,7 @@ import { getLogger, LogCategories } from '../../../../common/logger'; const logger = getLogger(LogCategories.MODULE.WORKFLOW.AI); type Props = ModuleDispatchProps<{ - [NodeInputKeyEnum.aiModel]: string; + [NodeInputKeyEnum.aiModelId]: string; [NodeInputKeyEnum.aiSystemPrompt]?: string; [NodeInputKeyEnum.history]?: ChatItemMiniType[] | number; [NodeInputKeyEnum.userChatInput]: string; @@ -40,14 +40,14 @@ export const dispatchClassifyQuestion = async (props: Props): Promise item.key === arg?.type) || agents[agents.length - 1]; const { totalPoints, modelName } = formatModelChars2Points({ - model: cqModel.model, + modelId: cqModel.id, inputTokens: inputTokens, outputTokens: outputTokens }); @@ -74,7 +74,7 @@ export const dispatchClassifyQuestion = async (props: Props): Promise; type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.success]: boolean; @@ -56,14 +56,14 @@ export async function dispatchContentExtract(props: Props): Promise { runningAppInfo, node: { nodeId, name }, histories, - params: { content, history = 6, model, description, extractKeys } + params: { content, history = 6, modelId, description, extractKeys } } = props; if (!content) { return getNodeErrResponse({ error: 'Input is empty' }); } - const extractModel = getLLMModel(model); + const extractModel = getLLMModelById(modelId); const chatHistories = getHistories(history, histories); const memoryKey = `${runningAppInfo.id}-${nodeId}`; @@ -123,7 +123,7 @@ export async function dispatchContentExtract(props: Props): Promise { } const { totalPoints, modelName } = formatModelChars2Points({ - model: extractModel.model, + modelId: extractModel.id, inputTokens: inputTokens, outputTokens: outputTokens }); @@ -131,7 +131,7 @@ export async function dispatchContentExtract(props: Props): Promise { { moduleName: name, totalPoints: externalProvider.openaiAccount?.key ? 0 : totalPoints, - model: modelName, + modelId: extractModel.id, inputTokens, outputTokens } @@ -148,7 +148,7 @@ export async function dispatchContentExtract(props: Props): Promise { }, [DispatchNodeResponseKeyEnum.nodeResponse]: { totalPoints: externalProvider.openaiAccount?.key ? 0 : totalPoints, - model: modelName, + modelId: extractModel.id, query: content, inputTokens, outputTokens, @@ -239,7 +239,7 @@ const toolChoice = async (props: ActionProps) => { const body = { stream: true, - model: extractModel.model, + modelId: extractModel.id, temperature: 0.01, messages: filterMessages, tools, @@ -326,7 +326,7 @@ const completions = async (props: ActionProps) => { usage: { inputTokens, outputTokens } } = await createLLMResponse({ body: { - model: extractModel.model, + modelId: extractModel.id, temperature: 0.01, messages: chats2GPTMessages({ messages, reserveId: false }), stream: true diff --git a/packages/service/core/workflow/dispatch/ai/tool/index.ts b/packages/service/core/workflow/dispatch/ai/tool/index.ts index 54a45cfc79a4..3118c109cedc 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/index.ts +++ b/packages/service/core/workflow/dispatch/ai/tool/index.ts @@ -5,7 +5,7 @@ import type { DispatchNodeResultType, RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; -import { getLLMModel } from '../../../../ai/model'; +import { getLLMModelById } from '../../../../ai/model'; import { filterToolNodeIdByEdges, getNodeErrResponse, getHistories } from '../../utils'; import { runToolCall } from './toolCall'; import { type DispatchToolModuleProps, type ToolNodeItemType } from './type'; @@ -54,7 +54,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< externalProvider, usageId, params: { - model, + modelId, systemPrompt, userChatInput, history = 6, @@ -66,7 +66,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< } = props; try { - const toolModel = getLLMModel(model); + const toolModel = getLLMModelById(modelId); const useVision = aiChatVision && toolModel.vision; const chatHistories = getHistories(history, histories); @@ -231,8 +231,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< })(); // Usage computed - // modelName 直接从 toolModel 获取;totalPoints 使用预计算值,保证梯度计费正确 - const modelName = toolModel.name; + // totalPoints 使用预计算值,保证梯度计费正确 const modelTotalPoints = toolCallTotalPoints; const toolTotalPoints = toolDispatchFlowResponses .map((item) => item.flowUsages) @@ -252,7 +251,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< toolCallInputTokens: toolCallInputTokens, toolCallOutputTokens: toolCallOutputTokens, childTotalPoints: toolTotalPoints, - model: modelName, + modelId: modelId, query: userChatInput, historyPreview: getHistoryPreview( GPTMessages2Chats({ messages: completeMessages, reserveTool: false }), @@ -291,7 +290,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< toolCallInputTokens: toolCallInputTokens, toolCallOutputTokens: toolCallOutputTokens, childTotalPoints: toolTotalPoints, - model: modelName, + modelId: modelId, query: userChatInput, historyPreview: getHistoryPreview( GPTMessages2Chats({ messages: completeMessages, reserveTool: false }), diff --git a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts index 8c568e949918..ff699b152724 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts @@ -169,7 +169,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise => { tmbId: runningAppInfo.tmbId, per: ReadPermissionVal }); - const { nodes, edges, chatConfig } = await getAppVersionById({ + const { + nodes, + edges, + chatConfig, + versionId: resolvedVersionId + } = await getAppVersionById({ appId, versionId: version, app: appData @@ -144,7 +155,8 @@ export const dispatchRunAppNode = async (props: Props): Promise => { runTimes, workflowInteractiveResponse, system_memories, - customFeedbacks + customFeedbacks, + durationSeconds } = await runWorkflow({ ...props, lastInteractive: childrenInteractive, @@ -192,6 +204,46 @@ export const dispatchRunAppNode = async (props: Props): Promise => { } ]); + // Write child app execution logs (chat, chat_items, chat_item_responses, app_chat_logs) + const childChatId = getNanoid(); + await pushChatRecords({ + chatId: childChatId, + appId: String(appData._id), + teamId: String(appData.teamId), + tmbId: String(appData.tmbId), + nodes, + appChatConfig: chatConfig, + versionId: resolvedVersionId, + variables: childrenRunVariables, + newTitle: (typeof userChatInput === 'string' ? userChatInput : '').slice(0, 20), + source: ChatSourceEnum.workflow, + sourceName: getChatSourceName( + ChatSourceEnum.workflow, + props.mode === 'test' ? runningAppInfo.name.replace(/^[^-]+-\s*/, '') : runningAppInfo.name, + props.lang + ), + parentChatId: props.chatId, + parentResponseChatItemId: props.responseChatItemId, + parentNodeId: props.node.nodeId, + parentNodeName: props.node.name, + parentAppId: runningAppInfo.id, + userContent: { + obj: ChatRoleEnum.Human, + value: runtimePrompt2ChatsValue({ + files: userInputFiles, + text: userChatInput + }) + }, + aiContent: { + obj: ChatRoleEnum.AI, + value: assistantResponses, + [DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses, + memories: system_memories, + customFeedbacks + }, + durationSeconds + }); + return { data: { [NodeOutputKeyEnum.answerText]: text, @@ -215,7 +267,8 @@ export const dispatchRunAppNode = async (props: Props): Promise => { query: userChatInput, textOutput: text, pluginDetail: appData.permission.hasWritePer ? flowResponses : undefined, - mergeSignId: props.node.nodeId + mergeSignId: props.node.nodeId, + appType: appData.type }, [DispatchNodeResponseKeyEnum.toolResponses]: text, [DispatchNodeResponseKeyEnum.customFeedbacks]: customFeedbacks diff --git a/packages/service/core/workflow/dispatch/dataset/search.ts b/packages/service/core/workflow/dispatch/dataset/search.ts index 45f3c79784eb..5610a723a312 100644 --- a/packages/service/core/workflow/dispatch/dataset/search.ts +++ b/packages/service/core/workflow/dispatch/dataset/search.ts @@ -2,21 +2,85 @@ import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runti import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; +import type { + SearchCorrectionDataProps, + SearchCorrectionDataResult +} from '@fastgpt/global/core/chat/correction/type'; +import { + SearchScoreTypeEnum, + DatasetRetrievalModeEnum +} from '@fastgpt/global/core/dataset/constants'; +import { Types } from '../../../../common/mongo'; +import { MongoChatCorrection } from '../../../chat/correction/schema'; +import { getVectorsByText } from '../../../ai/embedding'; +import { recallFromVectorStore } from '../../../../common/vectorDB/controller'; +import type { SearchDatasetDataResponse } from '../../../dataset/search/controller'; +import type { + SqlGenerationResponse, + SqlResultWithDatasetId +} from '@fastgpt/global/core/dataset/database/api'; import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; -import { getEmbeddingModel, getRerankModel } from '../../../ai/model'; -import { deepRagSearch, defaultSearchDatasetData } from '../../../dataset/search/controller'; +import { getLLMModelById, getRerankModelById, getEmbeddingModelById } from '../../../ai/model'; +import { + deepRagSearch, + defaultSearchDatasetData, + SearchDatabaseData, + generateAndExecuteSQL, + searchFAQData +} from '../../../dataset/search/controller'; +import { agenticSearchDispatch } from '../../../dataset/search/agenticSearch'; +import { + getMetadataWithValueExamples, + queryByNL +} from '../../../dataset/database/dative/client/dativeApiServer'; +import { + calculateDynamicLimit, + datasetSearchQueryExtension, + getDatasetSqlResultLimit, + getSynonymMappings, + standardizeQuery +} from '../../../dataset/search/utils'; import type { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { + DatasetSearchModeEnum, + DatasetTypeEnum, + RerankMethodEnum +} from '@fastgpt/global/core/dataset/constants'; import { type ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import { MongoDataset } from '../../../dataset/schema'; -import { i18nT } from '../../../../../web/i18n/utils'; -import { filterDatasetsByTmbId } from '../../../dataset/utils'; +import { MongoDatasetCollection } from '../../../dataset/collection/schema'; +import { i18nT } from '../../../../../global/common/i18n/utils'; +import { addLog } from '../../../../common/system/log'; +import { filterCollectionsByTmbId, filterDatasetsByTmbId } from '../../../dataset/utils'; import { getDatasetSearchToolResponsePrompt } from '@fastgpt/global/core/ai/prompt/dataset.const'; import { getNodeErrResponse } from '../utils'; -import { getLogger, LogCategories } from '../../../../common/logger'; +import { getDuckDBStoreConfig } from '../../../dataset/database/dative/utils'; +import { MongoApp } from '../../../app/schema'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { detectLang } from 'diting-rag-ts'; +import type { VariableItemType } from '@fastgpt/global/core/app/type'; +import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants'; -const logger = getLogger(LogCategories.MODULE.WORKFLOW.DATASET); +/** + * 从全局变量中获取 faqAnswerMode 配置 + * faqAnswerMode 定义在 chatConfig.variables 中,需要通过 label 找到对应的 key,再从 variables 中获取值 + */ +function getFaqAnswerMode( + variableDefinitions: VariableItemType[] | undefined, + variableValues: Record | undefined +): 'quote' | 'llm-summary' | undefined { + if (!variableDefinitions || !variableValues) return undefined; + + const faqAnswerModeVar = variableDefinitions.find((v) => v.label === 'faqAnswerMode'); + if (!faqAnswerModeVar) return undefined; + + const value = variableValues[faqAnswerModeVar.key]; + if (value === 'quote' || value === 'llm-summary') { + return value; + } + return undefined; +} type DatasetSearchProps = ModuleDispatchProps<{ [NodeInputKeyEnum.datasetSelectList]: SelectedDatasetType[]; @@ -25,22 +89,33 @@ type DatasetSearchProps = ModuleDispatchProps<{ [NodeInputKeyEnum.userChatInput]?: string; [NodeInputKeyEnum.datasetSearchMode]: DatasetSearchModeEnum; [NodeInputKeyEnum.datasetSearchEmbeddingWeight]?: number; + [NodeInputKeyEnum.datasetSearchEmbeddingModelId]?: string; [NodeInputKeyEnum.datasetSearchUsingReRank]: boolean; - [NodeInputKeyEnum.datasetSearchRerankModel]?: string; + [NodeInputKeyEnum.datasetSearchRerankModelId]?: string; + [NodeInputKeyEnum.datasetSearchRerankMethod]: RerankMethodEnum; [NodeInputKeyEnum.datasetSearchRerankWeight]?: number; [NodeInputKeyEnum.collectionFilterMatch]: string; [NodeInputKeyEnum.authTmbId]?: boolean; [NodeInputKeyEnum.datasetSearchUsingExtensionQuery]: boolean; - [NodeInputKeyEnum.datasetSearchExtensionModel]: string; + [NodeInputKeyEnum.datasetSearchExtensionModelId]: string; [NodeInputKeyEnum.datasetSearchExtensionBg]: string; [NodeInputKeyEnum.datasetDeepSearch]?: boolean; - [NodeInputKeyEnum.datasetDeepSearchModel]?: string; + [NodeInputKeyEnum.datasetDeepSearchModelId]?: string; [NodeInputKeyEnum.datasetDeepSearchMaxTimes]?: number; [NodeInputKeyEnum.datasetDeepSearchBg]?: string; + + [NodeInputKeyEnum.generateSqlModelId]?: string; + + // 检索模式(单轮/多轮) + [NodeInputKeyEnum.datasetRetrievalMode]?: `${DatasetRetrievalModeEnum}`; + // 多轮智能检索配置 + [NodeInputKeyEnum.datasetAgenticSearchLLMModelId]?: string; + [NodeInputKeyEnum.datasetAgenticSearchRerankModelId]?: string; + [NodeInputKeyEnum.datasetAgenticSearchReasoning]?: boolean; }>; export type DatasetSearchResponse = DispatchNodeResultType<{ [NodeOutputKeyEnum.datasetQuoteQA]: SearchDataResponseItemType[]; @@ -50,10 +125,14 @@ export async function dispatchDatasetSearch( props: DatasetSearchProps ): Promise { const { - runningAppInfo: { teamId }, + runningAppInfo, runningUserInfo: { tmbId }, histories, + chatConfig, + variables, node, + workflowStreamResponse, + lang, params: { datasets = [], similarity, @@ -63,21 +142,33 @@ export async function dispatchDatasetSearch( collectionFilterMatch, searchMode, embeddingWeight, + embeddingModelId, usingReRank, - rerankModel, + rerankModelId, + rerankMethod = RerankMethodEnum.content, rerankWeight, + // 检索模式(单轮/多轮) + retrievalMode = DatasetRetrievalModeEnum.standard, + // 多轮智能检索配置 + agenticSearchLLMModelId, + agenticSearchRerankModelId, + agenticSearchReasoning = true, + datasetSearchUsingExtensionQuery, - datasetSearchExtensionModel, + datasetSearchExtensionModelId, datasetSearchExtensionBg, - + generateSqlModelId, datasetDeepSearch, - datasetDeepSearchModel, + datasetDeepSearchModelId, datasetDeepSearchMaxTimes, datasetDeepSearchBg } } = props as DatasetSearchProps; + // API 请求时 lang 可能未设置,通过 detectLang 检测 queryLanguage 兜底 + const queryLanguage = detectLang(userChatInput); + if (!Array.isArray(datasets)) { return Promise.reject(i18nT('chat:dataset_quote_type error')); } @@ -86,6 +177,18 @@ export async function dispatchDatasetSearch( return getNodeErrResponse({ error: i18nT('common:core.chat.error.Select dataset empty') }); } + const { teamId, id: appId } = runningAppInfo; + + // 获取应用类型,判断是否为智能问答(assistant) + const app = await MongoApp.findById(appId).select('type').lean(); + const isAssistant = app?.type === AppTypeEnum.assistant; + + // 获取 faqAnswerMode 配置 + // const faqAnswerMode = chatConfig?.faqAnswerMode; 这样是undefined + const faqAnswerMode = getFaqAnswerMode(chatConfig?.variables, variables); + // 获取所有知识库ID(用于同义词检索) + const datasetIds = datasets.map((d) => d.datasetId); + const emptyResult: DatasetSearchResponse = { data: { quoteQA: [] @@ -115,128 +218,746 @@ export async function dispatchDatasetSearch( return emptyResult; } - // Get vector model - const vectorModel = getEmbeddingModel( - (await MongoDataset.findById(datasets[0].datasetId, 'vectorModel').lean())?.vectorModel + // Collection-level permission filtering + let authForbidCollectionIds: string[] | undefined; + if (authTmbId) { + const collectionFilterResult = await filterCollectionsByTmbId({ + datasetIds, + tmbId, + teamId + }); + if (!collectionFilterResult.allPass) { + if (collectionFilterResult.forbiddenIds.length === 0) { + // Safety: allPass=false but empty forbiddenIds shouldn't happen, treat as all pass + } else { + authForbidCollectionIds = collectionFilterResult.forbiddenIds; + } + } + } + + const useVectorModelDataset = datasets.find( + (d) => d.datasetType !== DatasetTypeEnum.structureDocument ); + // Get vector model for database type search (SearchDatabaseData uses this) + const vectorModel = useVectorModelDataset + ? getEmbeddingModelById( + (await MongoDataset.findById(useVectorModelDataset?.datasetId, 'vectorModelId').lean()) + ?.vectorModelId + ) + : undefined; // Get Rerank Model - const rerankModelData = getRerankModel(rerankModel); + const rerankModelData = getRerankModelById(rerankModelId); - // start search - const searchData = { - histories, - teamId, - reRankQuery: userChatInput, - queries: [userChatInput], - model: vectorModel.model, - similarity, - limit, - datasetIds, - searchMode, - embeddingWeight, - usingReRank, - rerankModel: rerankModelData, - rerankWeight, - collectionFilterMatch + // Use user-specified embeddingModelId for search if provided, otherwise fall back to dataset's vectorModel + const searchModel = embeddingModelId + ? getEmbeddingModelById(embeddingModelId) || vectorModel + : vectorModel; + + // Check dataset types and separate them + const datasetDetails = await Promise.all( + datasetIds.map((id) => MongoDataset.findById(id, 'type databaseConfig').lean()) + ); + + const databaseDatasetIds = datasetIds.filter( + (_, index) => + datasetDetails[index]?.type === DatasetTypeEnum.database || + datasetDetails[index]?.type === DatasetTypeEnum.structureDocument + ); + const commonDatasetIds = datasetIds.filter( + (_, index) => + datasetDetails[index]?.type !== DatasetTypeEnum.database && + datasetDetails[index]?.type !== DatasetTypeEnum.structureDocument + ); + + // Results from different search types + let commonSearchResult = null; + let totalEmbeddingTokens = 0; + // Handle different response types and merge results + let searchRes: SearchDataResponseItemType[] = []; + let embeddingTokens = 0; + let reRankInputTokens = 0; + let usingSimilarityFilter = false; + let searchUsingReRank = false; + let queryExtensionResult = undefined; + let deepSearchResult = undefined; + let sqlResult: SqlResultWithDatasetId[] = []; + let correctionData: + | { correctionId: string; correctedAnswer: string; question: string; similarity: number } + | undefined = undefined; + let faqAnswer: string | undefined = undefined; // FAQ 匹配成功时的答案 + let rerankTime: number | undefined = undefined; // 重排耗时 + let retrievalTime: number | undefined = undefined; // 检索总耗时 + let sqlRetrievalTime: number | undefined = undefined; // SQL数据库检索耗时 + let retrievalResults: SearchDataResponseItemType[] | undefined = undefined; // 检索结果 + let retrievalType: 'correction' | 'faq' | undefined = undefined; // 检索类型(仅correction/faq命中时有值) + let sqlChunks: SearchDataResponseItemType[] = []; // SQL检索结果转换的chunks + let rerankError: + | { + errorMessage: Record; + i18nErrorMessage: string; + i18nErrorMessageData: { modelName: string }; + } + | undefined = undefined; // 新增:Reranker 错误信息(仅 reranker 报错时有值) + let agenticSearchResult: SearchDatasetDataResponse['agenticSearchResult'] = undefined; // 新增:agentic 检索的过程信息(仅 agentic 路径有值) + + const convertSqlResultsToChunks = async ( + singleSQLResult: SqlGenerationResponse, + datasetId: string + ): Promise => { + const sourceName = + (await MongoDataset.findById(datasetId, 'name') + .lean() + ?.then((doc) => doc?.name)) || 'Unknown Dataset'; + const uuid = `sql_quote_${datasetId}`; + return { + id: uuid, + updateTime: new Date(), + q: singleSQLResult.answer, // Use the original query as question + a: singleSQLResult.sql, // Use the generated answer as content + chunkIndex: 0, + datasetId: datasetId, + collectionId: '', + sourceName: sourceName, + sourceId: uuid, + score: [] // Empty score array as requested + }; }; - const { - searchRes, - embeddingTokens, - reRankInputTokens, - usingSimilarityFilter, - usingReRank: searchUsingReRank, - queryExtensionResult, - deepSearchResult - } = datasetDeepSearch - ? await deepRagSearch({ - ...searchData, - datasetDeepSearchModel, - datasetDeepSearchMaxTimes, - datasetDeepSearchBg + // Database search for database datasets - search each dataset individually and generate SQL + if (databaseDatasetIds.length > 0) { + // SQL数据库检索开始计时 + const sqlRetrievalStartTime = Date.now(); + + const sqlLLM = getLLMModelById(generateSqlModelId); + // Calculate dynamic limit based on generateSqlModelId's maxContext + const dynamicLimit = calculateDynamicLimit({ + generateSqlModelId, + safetyFactor: 0.6, + estimatedTokensPerItem: 1024 // Assume each item may consume around 1000 tokens after formatting + }); + + addLog.debug('Dataset Search - Using dynamic limit for database search', { + generateSqlModelId, + calculatedLimit: dynamicLimit + }); + + // Process each database/structure dataset sequentially + await Promise.all( + databaseDatasetIds.map(async (datasetId) => { + const datasetDetail = datasetDetails.find((d) => String(d?._id) === datasetId); + const datasetType = datasetDetail?.type; + + // Check if dataset has any collections (tables/files) before proceeding + const collectionCount = await MongoDatasetCollection.countDocuments({ + teamId, + datasetId, + forbid: { $ne: true } + }); + + if (collectionCount === 0) { + addLog.warn('Dataset Search - No collections found, skipping', { + datasetId, + datasetType + }); + return; // Skip this dataset + } + + // Common SQL generation config + const key = sqlLLM.requestAuth || undefined; + const url = sqlLLM.requestUrl?.replace(/(chat\/completions.*)$/, '') || undefined; + + let singleSqlResult: SqlGenerationResponse | null = null; + + // Handle different dataset types + if (datasetType === DatasetTypeEnum.database) { + // Database type: vector search + SQL generation + const singleResult = await SearchDatabaseData({ + histories, + teamId, + queries: [userChatInput], + modelId: searchModel!.id, + limit: dynamicLimit, + datasetIds: [datasetId], + authForbidCollectionIds + }); + + if (singleResult) { + totalEmbeddingTokens += singleResult.tokens; + if (Object.keys(singleResult.schema).length > 0) { + singleSqlResult = await generateAndExecuteSQL({ + datasetId, + query: userChatInput, + schema: singleResult.schema, + teamId, + limit: getDatasetSqlResultLimit(), + generate_sql_llm: { + model: sqlLLM.model, + api_key: key, + base_url: url + }, + evaluate_sql_llm: { + model: sqlLLM.model, + api_key: key, + base_url: url + } + }); + } else { + addLog.warn('Dataset Search - No schema found, skipping SQL generation', { + datasetId + }); + } + } else { + addLog.warn('Dataset Search - Database search failed', { datasetId }); + } + } else if (datasetType === DatasetTypeEnum.structureDocument) { + // Structure document type: get metadata + SQL generation + try { + const metadata = await getMetadataWithValueExamples(getDuckDBStoreConfig(datasetId)); + + if (metadata.tables && metadata.tables.length > 0) { + singleSqlResult = await queryByNL({ + source_config: getDuckDBStoreConfig(datasetId), + generate_sql_llm: { + model: sqlLLM.model, + api_key: key, + base_url: url + }, + evaluate_sql_llm: { + model: sqlLLM.model, + api_key: key, + base_url: url + }, + query: userChatInput, + result_num_limit: getDatasetSqlResultLimit(), + retrieved_metadata: metadata + }); + } else { + addLog.warn('Dataset Search - No table metadata found, skipping SQL generation', { + datasetId + }); + } + } catch (error) { + addLog.error('Dataset Search - Structure document search failed', { + datasetId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // Collect SQL result + if (singleSqlResult) { + // Filter out empty answer or sql + if (singleSqlResult.answer) { + // Collect for billing and response data + sqlResult.push({ + ...singleSqlResult, + datasetId + } as SqlResultWithDatasetId); + // convertSqlResultsToChunks + const sqlChunk = await convertSqlResultsToChunks(singleSqlResult, datasetId); + searchRes.push(sqlChunk); + // 保存SQL检索结果到sqlChunks + sqlChunks.push(sqlChunk); + } else { + addLog.warn('Dataset Search - SQL result has empty answer or sql', { + datasetId, + datasetType, + hasAnswer: !!singleSqlResult.answer, + hasSql: !!singleSqlResult.sql + }); + } + } else { + addLog.warn('Dataset Search - SQL Generation Failed', { datasetId, datasetType }); + } }) - : await defaultSearchDatasetData({ - ...searchData, - datasetSearchUsingExtensionQuery, - datasetSearchExtensionModel, - datasetSearchExtensionBg + ); + + // SQL数据库检索结束计时 + if (sqlRetrievalStartTime !== undefined) { + sqlRetrievalTime = +((Date.now() - sqlRetrievalStartTime) / 1000).toFixed(2); + addLog.debug('Dataset Search - SQL Retrieval Time', { sqlRetrievalTime }); + } + } + if (commonDatasetIds.length > 0) { + // ===== 提前执行指代消解(仅 isAssistant + extensionQuery 场景)===== + // 目的: + // 1. 为 FAQ/Correction 提供经过指代消解 + 同义词标准化的 query,提升命中率 + // 2. 将结果下传给 defaultSearchDatasetData(standard 路径),避免重复 LLM 调用 + // 触发条件:isAssistant && 开启问题改写 && 有 LLM 模型 && 有向量模型 + let preComputedQueryExtension: + | Awaited> + | undefined = undefined; + if ( + isAssistant && + datasetSearchUsingExtensionQuery && + datasetSearchExtensionModelId && + searchModel + ) { + preComputedQueryExtension = await datasetSearchQueryExtension({ + query: userChatInput, + llmModelId: datasetSearchExtensionModelId, + embeddingModelId: searchModel.id, + extensionBg: datasetSearchExtensionBg, + histories, + isAssistant: true, + teamId, + datasetIds: commonDatasetIds, + lang: lang ?? queryLanguage + }); + } + + const searchData = { + histories, + teamId, + reRankQuery: userChatInput, + queries: [userChatInput], + modelId: searchModel!.id, + similarity, + limit, + datasetIds: commonDatasetIds, + searchMode, + embeddingWeight, + usingReRank, + rerankModelId: rerankModelData?.id, + rerankMethod, + rerankWeight, + collectionFilterMatch, + lang: lang ?? queryLanguage, + authForbidCollectionIds + }; + + // ===== App 级别:校正数据 & FAQ 优先检索 ===== + // 在路由到 agentic/standard 之前检查,确保两条路径都受到保护 + if (isAssistant && vectorModel) { + const corrFaqStartTime = Date.now(); + + // 优先使用预计算的指代消解结果(含同义词标准化); + // 降级到本地同义词标准化(extensionQuery=false 或 LLM 不可用时) + let embedInputs: string[]; + let corrFaqQueryExtensionResult: typeof preComputedQueryExtension | undefined = + preComputedQueryExtension; + if (preComputedQueryExtension?.aiExtensionResult?.synonymRewriteResult?.standardizedQuery) { + const resolved = + preComputedQueryExtension.aiExtensionResult.synonymRewriteResult.standardizedQuery; + embedInputs = resolved !== userChatInput ? [userChatInput, resolved] : [userChatInput]; + } else { + // 降级:仅做同义词标准化 + const { synonymDict } = await getSynonymMappings({ + teamId, + datasetIds: commonDatasetIds, + query: userChatInput + }); + const corrFaqQuery = standardizeQuery(userChatInput, synonymDict); + embedInputs = + corrFaqQuery !== userChatInput ? [userChatInput, corrFaqQuery] : [userChatInput]; + corrFaqQueryExtensionResult = undefined; // 降级场景无预计算结果 + } + + // 同时生成原始 query 和消解/标准化 query 的向量,两个都用于检索 + // 避免改写覆盖掉原始语义,取相似度最高的命中结果 + const { vectors: queryVectors, tokens: corrFaqTokens } = await getVectorsByText({ + model: vectorModel, + input: embedInputs, + type: 'query', + useInstruction: false }); + // === 校正数据优先检索 === + if (appId) { + // 对每个向量分别检索,取相似度最高的命中结果 + const correctionResults = await Promise.all( + queryVectors.map((vec) => + searchCorrectionData({ + appId, + queryVector: vec, + embeddingTokens: corrFaqTokens, + teamId + }) + ) + ); + const correctionResult = correctionResults + .filter(Boolean) + .sort((a, b) => (b?.similarity ?? 0) - (a?.similarity ?? 0))[0]; + + const correctionThreshold = global.systemEnv?.correctionSimilarityThreshold ?? 0.95; + addLog.debug('Assistant Config - correctionSimilarityThreshold', { + correctionThreshold + }); + + if ( + correctionResult && + correctionResult.similarity > correctionThreshold && + correctionResult.correctedAnswer + ) { + const retrievalTimeVal = +((Date.now() - corrFaqStartTime) / 1000).toFixed(2); + const correctionChunk: SearchDataResponseItemType = { + id: `correction_quote_${correctionResult.correctionId}`, + updateTime: new Date(), + q: correctionResult.question, + a: correctionResult.correctedAnswer, + chunkIndex: 0, + datasetId: appId, + collectionId: `correction_quote_${correctionResult.correctionId}`, + sourceName: 'Correction Data', + sourceId: correctionResult.correctionId, + score: [ + { + type: SearchScoreTypeEnum.embedding, + value: correctionResult.similarity, + index: 0 + } + ] + }; + commonSearchResult = { + searchRes: [correctionChunk], + embeddingTokens: corrFaqTokens, + reRankInputTokens: 0, + searchMode: DatasetSearchModeEnum.embedding, + limit, + similarity: correctionResult.similarity, + usingReRank: false, + usingSimilarityFilter: true, + retrievalTime: retrievalTimeVal, + retrievalType: 'correction' as const, + correctionData: { + correctionId: correctionResult.correctionId, + correctedAnswer: correctionResult.correctedAnswer, + question: correctionResult.question, + similarity: correctionResult.similarity + }, + queryExtensionResult: corrFaqQueryExtensionResult?.aiExtensionResult + ? { + llmModelId: corrFaqQueryExtensionResult.aiExtensionResult.llmModelId, + embeddingModelId: + corrFaqQueryExtensionResult.aiExtensionResult.embeddingModelId, + inputTokens: corrFaqQueryExtensionResult.aiExtensionResult.inputTokens, + outputTokens: corrFaqQueryExtensionResult.aiExtensionResult.outputTokens, + embeddingTokens: corrFaqQueryExtensionResult.aiExtensionResult.embeddingTokens, + query: corrFaqQueryExtensionResult.queriesForStorage || undefined, + synonymRewriteResult: + corrFaqQueryExtensionResult.aiExtensionResult.synonymRewriteResult, + rewriteTime: corrFaqQueryExtensionResult.rewriteTime + } + : undefined + }; + } + } + + // === FAQ 优先检索(仅当 correction 未命中且 faqAnswerMode === 'quote' 时)=== + if (!commonSearchResult && faqAnswerMode === 'quote') { + // 对每个向量分别检索,取得分最高的命中结果 + const faqResults = await Promise.all( + queryVectors.map((vec) => + searchFAQData({ + teamId, + datasetIds: commonDatasetIds, + queryVector: vec, + embeddingTokens: corrFaqTokens, + authForbidCollectionIds, + collectionFilterMatch + }) + ) + ); + const faqResult = faqResults + .filter(Boolean) + .sort((a, b) => (b?.score?.[0]?.value ?? 0) - (a?.score?.[0]?.value ?? 0))[0]; + + if (faqResult) { + const retrievalTimeVal = +((Date.now() - corrFaqStartTime) / 1000).toFixed(2); + addLog.debug('FAQ Search - Match found and returning', { + teamId, + dataId: faqResult.id, + similarity: faqResult.score?.[0]?.value + }); + commonSearchResult = { + searchRes: [faqResult], + embeddingTokens: corrFaqTokens, + reRankInputTokens: 0, + searchMode: DatasetSearchModeEnum.embedding, + limit, + similarity: faqResult.score?.[0]?.value || 0.95, + usingReRank: false, + usingSimilarityFilter: true, + isFaqResult: true, + retrievalTime: retrievalTimeVal, + retrievalType: 'faq' as const, + queryExtensionResult: corrFaqQueryExtensionResult?.aiExtensionResult + ? { + llmModelId: corrFaqQueryExtensionResult.aiExtensionResult.llmModelId, + embeddingModelId: + corrFaqQueryExtensionResult.aiExtensionResult.embeddingModelId, + inputTokens: corrFaqQueryExtensionResult.aiExtensionResult.inputTokens, + outputTokens: corrFaqQueryExtensionResult.aiExtensionResult.outputTokens, + embeddingTokens: corrFaqQueryExtensionResult.aiExtensionResult.embeddingTokens, + query: corrFaqQueryExtensionResult.queriesForStorage || undefined, + synonymRewriteResult: + corrFaqQueryExtensionResult.aiExtensionResult.synonymRewriteResult, + rewriteTime: corrFaqQueryExtensionResult.rewriteTime + } + : undefined + }; + } + } + } + + // 检索模式路由(correction/FAQ 未命中时才执行) + if (!commonSearchResult) { + if (retrievalMode === DatasetRetrievalModeEnum.agentic) { + // 多轮智能检索路径 + // 将 FastGPT 预计算的指代消解结果和查询变体传给 agenticSearchDispatch + // agenticSearchDispatch 内部再以通用接口透传给 diting-rag-ts,保持库的独立性 + // + // 选 coreferenceResolved 而非 standardizedQuery 作为 preResolvedQuery: + // - coreferenceResolved 已完成"代词/省略 → 实体"的展开(如"它" → "产品名称"), + // 语义最完整,适合作为 Agent 检索的主查询; + // - standardizedQuery 仅做同义词归一化,未解决指代歧义。 + const preResolvedQuery = + preComputedQueryExtension?.aiExtensionResult?.synonymRewriteResult?.coreferenceResolved; + const preComputedQueries = preComputedQueryExtension?.aiExtensionResult?.extensionQueries; + + commonSearchResult = await agenticSearchDispatch({ + ...searchData, + agenticSearchLLMModelId, + agenticSearchRerankModelId, + agenticSearchReasoning, + workflowStreamResponse, + queryLanguage, + // 预计算结果:只在有值时传入,避免空值覆盖 diting-rag-ts 内部逻辑 + ...(preResolvedQuery ? { preResolvedQuery } : {}), + ...(preComputedQueries?.length ? { preComputedQueries } : {}), + // 将 SQL 检索结果作为前置上下文传给 agent,使其能参考结构化数据后决定是否补充检索 + ...(searchRes.length > 0 ? { preSearchRes: searchRes } : {}) + }); + } else if (datasetDeepSearch) { + commonSearchResult = await deepRagSearch({ + ...searchData, + datasetDeepSearchModelId, + datasetDeepSearchMaxTimes, + datasetDeepSearchBg + }); + } else { + commonSearchResult = await defaultSearchDatasetData({ + histories, + teamId, + reRankQuery: userChatInput, + queries: [userChatInput], + modelId: searchModel!.id, + similarity, + limit, + datasetIds: commonDatasetIds, + searchMode, + embeddingWeight, + usingReRank, + rerankModelId: rerankModelData?.id, + rerankMethod, + rerankWeight, + collectionFilterMatch, + lang: lang ?? queryLanguage, + authForbidCollectionIds, + datasetSearchUsingExtensionQuery, + datasetSearchExtensionModelId, + datasetSearchExtensionBg, + isAssistant, + synonymDatasetIds: datasetIds, + appId, + faqAnswerMode, + preComputedQueryExtension + }); + } + } + } + + embeddingTokens += totalEmbeddingTokens; + + addLog.info('Dataset Search - Merging Results', { + databaseResults: sqlResult.length, + embeddingTokens, + totalEmbeddingTokens + }); + + // Handle traditional search results + if (commonSearchResult) { + const commonResult = commonSearchResult as SearchDatasetDataResponse; + // Merge traditional search results with database chunks + searchRes = [...searchRes, ...commonResult.searchRes]; + embeddingTokens += commonResult.embeddingTokens; // Accumulate embedding tokens + reRankInputTokens = commonResult.reRankInputTokens; + usingSimilarityFilter = commonResult.usingSimilarityFilter; + searchUsingReRank = commonResult.usingReRank; + queryExtensionResult = commonResult.queryExtensionResult; + deepSearchResult = commonResult.deepSearchResult; + // 提取校正数据信息 + correctionData = commonResult.correctionData; + // 提取重排耗时 + rerankTime = commonResult.rerankTime; + // 提取检索总耗时 + retrievalTime = commonResult.retrievalTime; + // 提取检索结果 + retrievalResults = commonResult.retrievalResults; + // 新增:提取检索类型(仅correction/faq命中时有值) + retrievalType = commonResult.retrievalType; + // 提取 reranker 错误信息 + rerankError = commonResult.rerankError; + // 提取 agentic 检索过程信息 + agenticSearchResult = commonResult.agenticSearchResult; + // 提取 FAQ 数据信息 + if (commonResult.isFaqResult && commonResult.searchRes.length > 0) { + faqAnswer = commonResult.searchRes[0].a; + } + } + + // 合并SQL检索结果到retrievalResults + if (sqlChunks.length > 0) { + retrievalResults = [...sqlChunks, ...(retrievalResults || [])]; + addLog.debug('Dataset Search - SQL chunks merged to retrievalResults', { + sqlChunksCount: sqlChunks.length, + totalRetrievalResults: retrievalResults.length + }); + } + // count bill results const nodeUsages: ChatNodeUsageType[] = []; - { - // 1. Search vector + // 1. Search vector — bill using searchModel + if (searchModel && embeddingTokens > 0) { const { totalPoints: embeddingTotalPoints, modelName: embeddingModelName } = formatModelChars2Points({ - model: vectorModel.model, + modelId: searchModel.id, inputTokens: embeddingTokens }); nodeUsages.push({ totalPoints: embeddingTotalPoints, moduleName: node.name, - model: embeddingModelName, + modelId: searchModel.id, inputTokens: embeddingTokens }); - // 2. Rerank - if (searchUsingReRank) { - const { totalPoints: reRankTotalPoints, modelName: reRankModelName } = - formatModelChars2Points({ - model: rerankModelData?.model, - inputTokens: reRankInputTokens - }); - nodeUsages.push({ - totalPoints: reRankTotalPoints, - moduleName: i18nT('account_usage:rerank'), - model: reRankModelName, + } + // 2. Rerank + if (searchUsingReRank) { + const { totalPoints: reRankTotalPoints, modelName: reRankModelName } = + formatModelChars2Points({ + modelId: rerankModelData?.id || '', inputTokens: reRankInputTokens }); - } - // 3. Query extension - if (queryExtensionResult) { - const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({ - model: queryExtensionResult.llmModel, - inputTokens: queryExtensionResult.inputTokens, - outputTokens: queryExtensionResult.outputTokens - }); - nodeUsages.push({ - totalPoints: llmPoints, - moduleName: i18nT('common:core.module.template.Query extension'), - model: llmModelName, - inputTokens: queryExtensionResult.inputTokens, - outputTokens: queryExtensionResult.outputTokens - }); + nodeUsages.push({ + totalPoints: reRankTotalPoints, + moduleName: i18nT('account_usage:rerank'), + modelId: rerankModelData?.id, + inputTokens: reRankInputTokens + }); + } + // 3. Query extension + if (queryExtensionResult) { + const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({ + modelId: queryExtensionResult.llmModelId, + inputTokens: queryExtensionResult.inputTokens, + outputTokens: queryExtensionResult.outputTokens + }); + nodeUsages.push({ + totalPoints: llmPoints, + moduleName: i18nT('common:core.module.template.Query extension'), + modelId: queryExtensionResult.llmModelId, + inputTokens: queryExtensionResult.inputTokens, + outputTokens: queryExtensionResult.outputTokens + }); - const { totalPoints: embeddingPoints, modelName: embeddingModelName } = - formatModelChars2Points({ - model: queryExtensionResult.embeddingModel, - inputTokens: queryExtensionResult.embeddingTokens - }); - nodeUsages.push({ - totalPoints: embeddingPoints, - moduleName: `${i18nT('account_usage:ai.query_extension_embedding')}`, - model: embeddingModelName, - inputTokens: queryExtensionResult.embeddingTokens, - outputTokens: 0 + const { totalPoints: embeddingPoints, modelName: embeddingModelName } = + formatModelChars2Points({ + modelId: queryExtensionResult.embeddingModelId, + inputTokens: queryExtensionResult.embeddingTokens }); - } - // 4. Deep search - if (deepSearchResult) { + nodeUsages.push({ + totalPoints: embeddingPoints, + moduleName: `${i18nT('account_usage:ai.query_extension_embedding')}`, + modelId: queryExtensionResult.embeddingModelId, + inputTokens: queryExtensionResult.embeddingTokens, + outputTokens: 0 + }); + } + // 4. Deep search + if (deepSearchResult) { + const { totalPoints, modelName } = formatModelChars2Points({ + modelId: deepSearchResult.modelId, + inputTokens: deepSearchResult.inputTokens, + outputTokens: deepSearchResult.outputTokens + }); + nodeUsages.push({ + totalPoints, + moduleName: i18nT('common:deep_rag_search'), + modelId: deepSearchResult.modelId, + inputTokens: deepSearchResult.inputTokens, + outputTokens: deepSearchResult.outputTokens + }); + } + // 5. Agentic search LLM + if ( + agenticSearchResult && + (agenticSearchResult.llmInputTokens || agenticSearchResult.llmOutputTokens) + ) { + const modelId = agenticSearchResult.llmModelId || agenticSearchLLMModelId || ''; + const { totalPoints, modelName } = formatModelChars2Points({ + modelId: modelId, + inputTokens: agenticSearchResult.llmInputTokens, + outputTokens: agenticSearchResult.llmOutputTokens + }); + nodeUsages.push({ + totalPoints, + moduleName: i18nT('common:agentic_search'), + modelId: modelId, + inputTokens: agenticSearchResult.llmInputTokens, + outputTokens: agenticSearchResult.llmOutputTokens + }); + } + // 6. SQL Generation (for database datasets) + if (sqlResult.length > 0) { + sqlResult.forEach((result) => { const { totalPoints, modelName } = formatModelChars2Points({ - model: deepSearchResult.model, - inputTokens: deepSearchResult.inputTokens, - outputTokens: deepSearchResult.outputTokens + modelId: generateSqlModelId!, + inputTokens: result.input_tokens, + outputTokens: result.output_tokens }); nodeUsages.push({ totalPoints, - moduleName: i18nT('common:deep_rag_search'), - model: modelName, - inputTokens: deepSearchResult.inputTokens, - outputTokens: deepSearchResult.outputTokens + moduleName: i18nT('common:database_search'), + modelId: generateSqlModelId, + inputTokens: result.input_tokens, + outputTokens: result.output_tokens }); - } + }); } const totalPoints = nodeUsages.reduce((acc, item) => acc + item.totalPoints, 0); props.usagePush(nodeUsages); + addLog.debug('Dataset Search - Final Statistics', { + totalSearchResults: searchRes.length, + totalPoints, + totalEmbeddingTokens: embeddingTokens, + totalSqlResults: sqlResult.length, + nodeUsagesCount: nodeUsages.length + }); + + // Debug日志:输出检索结果完整内容 + addLog.debug('Dataset Search - Retrieved Results Detail', { + totalResults: searchRes.length, + sqlResultsCount: sqlResult.length, + commonResultsCount: commonSearchResult + ? (commonSearchResult as SearchDatasetDataResponse).searchRes.length + : 0, + retrievalResultsCount: retrievalResults?.length, + results: searchRes.map((item, index) => ({ + index, + id: item.id, + datasetId: item.datasetId, + sourceName: item.sourceName, + q: item.q, + a: item.a, + score: + item.score?.map((s) => ({ + type: s.type, + value: s.value, + index: s.index + })) || [] + })) + }); + return { data: { quoteQA: searchRes @@ -244,7 +965,8 @@ export async function dispatchDatasetSearch( [DispatchNodeResponseKeyEnum.nodeResponse]: { totalPoints, query: userChatInput, - embeddingModel: vectorModel.name, + retrievalMode: retrievalMode as DatasetRetrievalModeEnum, + embeddingModelId: searchModel!.id, embeddingTokens, similarity: usingSimilarityFilter ? similarity : undefined, limit, @@ -253,28 +975,81 @@ export async function dispatchDatasetSearch( searchMode === DatasetSearchModeEnum.mixedRecall ? embeddingWeight : undefined, // Rerank ...(searchUsingReRank && { - rerankModel: rerankModelData?.name, + rerankModelId: rerankModelData?.id, + rerankMethod: rerankMethod, rerankWeight: rerankWeight, reRankInputTokens }), + // SQL Result (for database datasets) - use first result or create summary + sqlResult: sqlResult, searchUsingReRank, queryExtensionResult: queryExtensionResult ? { - model: queryExtensionResult.llmModel, + modelId: queryExtensionResult.llmModelId, inputTokens: queryExtensionResult.inputTokens, outputTokens: queryExtensionResult.outputTokens, - query: queryExtensionResult.query + query: queryExtensionResult.query || '', + synonymRewriteResult: queryExtensionResult.synonymRewriteResult, + rewriteTime: queryExtensionResult.rewriteTime } : undefined, deepSearchResult, // Results - quoteList: searchRes + quoteList: searchRes, + // 重排耗时 + ...(rerankTime !== undefined && { rerankTime }), + // 检索总耗时 + ...(retrievalTime !== undefined && { retrievalTime }), + // SQL数据库检索耗时 + ...(sqlRetrievalTime !== undefined && { sqlRetrievalTime }), + // 检索结果 + ...(retrievalResults && retrievalResults.length > 0 && { retrievalResults }), + // 检索类型(仅correction/faq命中时有值) + ...(retrievalType && { retrievalType }), + // 校正数据搜索结果 + ...(correctionData && { + correctSearchResult: [ + { + correctionId: correctionData.correctionId, + question: correctionData.question, + correctedAnswer: correctionData.correctedAnswer, + similarity: correctionData.similarity + } + ] + }), + // Reranker 错误信息(仅当 reranker 报错时存在) + ...(rerankError && { rerankError }), + // agentic 检索过程信息(仅 agentic 路径有值) + ...(agenticSearchResult && { agenticSearchResult }), + // 查询语言检测结果(所有检索路径均可用) + queryLanguage }, + // 将 agentic 思考过程写入 assistantResponses,使其存入 AI 消息的 value, + // 刷新页面后前端能从 value 中渲染"思考过程"块,与实时流式体验一致 + // 需要判断 agenticSearchReasoning + ...(agenticSearchReasoning && agenticSearchResult?.reasoningText + ? { + [DispatchNodeResponseKeyEnum.assistantResponses]: [ + { + type: ChatItemValueTypeEnum.reasoning, + reasoning: { content: agenticSearchResult.reasoningText } + } + ] + } + : {}), + ...((correctionData || faqAnswer) && { + [DispatchNodeResponseKeyEnum.newVariables]: { + // 设置全局变量 correct_mapping_answer + ...(correctionData && { hTRJXdb1: correctionData.correctedAnswer }), + // 设置全局变量 faqAnswer + ...(faqAnswer && { udQRlgfO: faqAnswer }) + } + }), [DispatchNodeResponseKeyEnum.toolResponses]: searchRes.length > 0 ? { prompt: getDatasetSearchToolResponsePrompt(), - cites: searchRes.map((item) => ({ + cites: searchRes.map((item: SearchDataResponseItemType) => ({ id: item.id, sourceName: item.sourceName, updateTime: item.updateTime, @@ -284,7 +1059,175 @@ export async function dispatchDatasetSearch( : 'No results' }; } catch (error) { - logger.error('Dataset search dispatch failed', { error }); + addLog.error('Dataset search dispatch failed', { error }); return getNodeErrResponse({ error }); } } + +/** + * 搜索校正数据 + * 从 chat_corrections 表中查找与用户输入相似度高的历史校正数据 + */ +export async function searchCorrectionData({ + appId, + queryVector, + embeddingTokens, + teamId +}: { + appId: string; + queryVector: number[]; + embeddingTokens: number; + teamId: string; +}): Promise { + try { + addLog.debug('Correction Search - Starting', { + appId, + embeddingTokens + }); + + // 步骤1&2:查询该应用下所有包含问题向量的校正数据,并在数据库层面过滤数组 + type CorrectionAggregateResult = { + _id: string; + chatId: string; + dataId: string; + updateTime: Date; + correctionData: { + question: string; + correctedAnswer?: string; + indexs: Array<{ type: 'q'; dataId: string }>; + }; + }; + + const corrections = await MongoChatCorrection.aggregate([ + { + $match: { + appId: new Types.ObjectId(appId), + 'correctionData.indexs': { + $elemMatch: { type: 'q' } + } + } + }, + { + $project: { + _id: 1, + chatId: 1, + dataId: 1, + updateTime: 1, + 'correctionData.question': 1, + 'correctionData.correctedAnswer': 1, + 'correctionData.indexs': { + $filter: { + input: '$correctionData.indexs', + as: 'idx', + cond: { $eq: ['$$idx.type', 'q'] } + } + } + } + } + ]); + + if (!corrections.length) { + addLog.debug('Correction Search - No corrections found', { appId }); + return null; + } + + // 提取问题向量ID(已在数据库层面过滤好,只包含 type='q' 的数据) + const questionVectorIds = corrections.flatMap((correction) => { + const indexs = correction.correctionData?.indexs || []; + return indexs.map((idx) => ({ + vectorId: idx.dataId, + correctionId: String(correction._id), + question: correction.correctionData.question, + correctedAnswer: correction.correctionData.correctedAnswer, + chatId: correction.chatId, + dataId: correction.dataId, + updateTime: correction.updateTime + })); + }); + + if (!questionVectorIds.length) { + addLog.debug('Correction Search - No question vectors found', { appId }); + return null; + } + + // 步骤3:使用传入的向量进行相似度计算 + // 从向量存储中检索与用户查询最相似的问题向量 + // 校正数据的dataset_id存储的是app_id + // 扩大搜索范围以处理多个相似度相同的情况 + const correctionSearchLimit = 10; + const { results: vectorSearchResults } = await recallFromVectorStore({ + teamId, + datasetIds: [appId], // 使用appId查询校正数据 + vector: queryVector, + limit: correctionSearchLimit, + forbidCollectionIdList: [] + }); + + // 检查是否有搜索结果 + if (vectorSearchResults.length === 0) { + addLog.debug('Correction Search - No similarity match found', { + appId, + threshold: global.systemEnv?.correctionSimilarityThreshold ?? 0.95, + totalVectors: questionVectorIds.length + }); + return null; + } + + // 获取最高相似度分数 + const topScore = vectorSearchResults[0].score; + + // 找出所有相似度等于最高分的结果 + const topScoreResults = vectorSearchResults.filter((r) => r.score === topScore); + + // 将向量结果与校正数据信息关联,并按更新时间排序 + const matchedCorrections = topScoreResults + .map((vectorResult) => { + const correctionInfo = questionVectorIds.find((item) => item.vectorId === vectorResult.id); + return correctionInfo ? { ...correctionInfo, similarity: vectorResult.score } : null; + }) + .filter((item): item is NonNullable => item !== null) + // 按更新时间降序排序,取最新的 + .sort((a, b) => new Date(b.updateTime).getTime() - new Date(a.updateTime).getTime()); + + if (matchedCorrections.length === 0) { + addLog.debug('Correction Search - No correction data found for vectors', { + appId, + topScore, + matchCount: topScoreResults.length, + threshold: global.systemEnv?.correctionSimilarityThreshold ?? 0.95, + totalVectors: questionVectorIds.length + }); + return null; + } + + // 取更新时间最新的校正数据 + const correctionInfo = matchedCorrections[0]; + + const result = { + correctionId: correctionInfo.correctionId, + correctedAnswer: correctionInfo.correctedAnswer, + question: correctionInfo.question, + similarity: correctionInfo.similarity, + chatId: correctionInfo.chatId, + dataId: correctionInfo.dataId, + embeddingTokens: embeddingTokens || 0 // 使用实际的向量生成token数量 + }; + + addLog.debug('Correction Search - Result found', { + appId, + similarity: result.similarity, + correctionId: result.correctionId, + updateTime: correctionInfo.updateTime, + matchedCount: matchedCorrections.length, + matched: true + }); + + return result; + } catch (error) { + addLog.error('Correction Search - Error', { + appId, + error: error instanceof Error ? error.message : String(error) + }); + return null; + } +} diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index 3488e1ee611b..3858d9279c34 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -5,6 +5,7 @@ import type { ChatHistoryItemResType, ToolRunResponseItemType } from '@fastgpt/global/core/chat/type'; +import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants'; import type { NodeEdgeGroups, NodeEdgeGroupsMap, @@ -32,6 +33,7 @@ import { filterWorkflowEdges, getReferenceVariableValue, replaceEditorVariable, + resolveTagFilterRefs, textAdaptGptResponse, valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils'; @@ -53,6 +55,7 @@ import { import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { callbackMap } from './constants'; import { getUserChatInfo } from '../../../support/user/team/utils'; +import { ENTRY_POINT_VARIABLE_KEY } from '@fastgpt/global/core/app/constants'; import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; import type { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { createChatUsageRecord, pushChatItemUsage } from '../../../support/wallet/usage/controller'; @@ -60,16 +63,47 @@ import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils'; import { getS3ChatSource } from '../../../common/s3/sources/chat'; import { addPreviewUrlToChatItems } from '../../chat/utils'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { validateFileUrlDomain } from '../../../common/security/fileUrlValidator'; import { classifyEdgesByDFS, findSCCs, isNodeInCycle, getEdgeType } from '../utils/tarjan'; import { observeWorkflowRun, observeWorkflowStep } from '../metrics'; import { withActiveSpan } from '../../../common/tracing'; +import { isLangfuseEnabled } from '../../../common/langfuse'; import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus'; import { runWithContext } from '../utils/context'; const logger = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH); +/* ===== Langfuse helpers ===== */ +const LANGFUSE_LLM_NODE_TYPES = new Set([ + FlowNodeTypeEnum.chatNode, + FlowNodeTypeEnum.agent, + FlowNodeTypeEnum.classifyQuestion, + FlowNodeTypeEnum.contentExtract, + FlowNodeTypeEnum.queryExtension +]); + +const LANGFUSE_EXCLUDED_INPUT_KEYS = new Set(['history', 'aiChatDatasetQuote', 'fileUrlList']); +const LANGFUSE_MAX_STR_LEN = 10000; + +function filterLangfuseNodeInput(params: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (LANGFUSE_EXCLUDED_INPUT_KEYS.has(key)) continue; + result[key] = + typeof value === 'string' && value.length > LANGFUSE_MAX_STR_LEN + ? `${value.slice(0, LANGFUSE_MAX_STR_LEN)}...[truncated]` + : value; + } + return result; +} + +function filterLangfuseNodeOutput(dispatchRes: NodeResponseType): Record { + if (!dispatchRes.data) return {}; + const data = dispatchRes.data as Record; + return Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'history')); +} + type Props = Omit< ChatDispatchProps, 'checkIsStopping' | 'workflowDispatchDeep' | 'timezone' | 'externalProvider' @@ -186,6 +220,12 @@ export async function dispatchWorkFlow({ /* Init function */ // Check point + logger.debug('[dispatchWorkFlow] Before checkTeamAIPoints', { + runningUserInfo, + runningAppInfo, + mode: data.mode, + usageSource + }); await checkTeamAIPoints(runningUserInfo.teamId); const [{ timezone, externalProvider }, newUsageId] = await Promise.all([ @@ -866,6 +906,11 @@ export class WorkflowQueue { variables: this.data.variables }); + // resolve $ref arrays embedded in collectionFilterMatch JSON string + if (input.key === NodeInputKeyEnum.collectionFilterMatch && typeof value === 'string') { + value = resolveTagFilterRefs(value, this.runtimeNodesMap, this.data.variables) ?? value; + } + // Dynamic input is stored in the dynamic key if (input.canEdit && dynamicInput && params[dynamicInput.key]) { params[dynamicInput.key][input.key] = valueTypeFormat(value, input.valueType); @@ -915,6 +960,21 @@ export class WorkflowQueue { const errorHandleId = getHandleId(node.nodeId, 'source_catch', 'right'); try { + // 特殊处理:assistant 应用选择 llmReply 且检索为空时,不传入 systemPrompt 和 quoteQA + if (node.flowNodeType === FlowNodeTypeEnum.chatNode) { + const enableFallbackReply = this.data.chatConfig?.enableFallbackReply; + const quoteQA = params[NodeInputKeyEnum.aiChatDatasetQuote]; + + // 判断条件: + // 1. enableFallbackReply 存在且为 'llmReply'(只有 assistant 应用才有此配置,且选择了大模型生成回复) + // 2. quoteQA 为空(检索结果为空) + if (enableFallbackReply === 'llmReply' && (!quoteQA || quoteQA.length === 0)) { + // 将 systemPrompt 和 quoteQA 置空,避免拼接引用提示词模板 + params[NodeInputKeyEnum.aiSystemPrompt] = ''; + params[NodeInputKeyEnum.aiChatDatasetQuote] = undefined; + } + } + const result = (await callbackMap[node.flowNodeType](dispatchData)) as NodeResponseType; if (result.error) { @@ -976,7 +1036,7 @@ export class WorkflowQueue { return {}; })(); - const nodeResponses = dispatchRes[DispatchNodeResponseKeyEnum.nodeResponses] || []; + const nodeResponses = [...(dispatchRes[DispatchNodeResponseKeyEnum.nodeResponses] || [])]; // format response data. Add modulename and module type const formatResponseData: NodeResponseCompleteType['responseData'] = (() => { if (!dispatchRes[DispatchNodeResponseKeyEnum.nodeResponse]) return undefined; @@ -1025,6 +1085,42 @@ export class WorkflowQueue { }); } + // Inject Langfuse node span attributes for root chat observations + if (stepSpan && this.isRootRuntime && mode === 'chat' && isLangfuseEnabled()) { + stepSpan.setAttribute('langfuse.trace.name', 'message'); + stepSpan.setAttribute( + 'langfuse.observation.metadata.app_id', + String(this.data.runningAppInfo.id) + ); + stepSpan.setAttribute( + 'langfuse.observation.input', + JSON.stringify(filterLangfuseNodeInput(params)) + ); + stepSpan.setAttribute( + 'langfuse.observation.output', + JSON.stringify(filterLangfuseNodeOutput(dispatchRes)) + ); + if (LANGFUSE_LLM_NODE_TYPES.has(node.flowNodeType) && formatResponseData?.modelId) { + stepSpan.setAttribute('langfuse.observation.type', 'generation'); + stepSpan.setAttribute( + 'langfuse.observation.model.name', + String(formatResponseData.modelId) + ); + const usageDetails: Record = {}; + if (formatResponseData.inputTokens != null) { + usageDetails.input = formatResponseData.inputTokens; + } + if (formatResponseData.outputTokens != null) { + usageDetails.output = formatResponseData.outputTokens; + } + if (Object.keys(usageDetails).length > 0) { + stepSpan.setAttribute( + 'langfuse.observation.usage_details', + JSON.stringify(usageDetails) + ); + } + } + } // Update new variables if (dispatchRes[DispatchNodeResponseKeyEnum.newVariables]) { this.data.variables = { @@ -1547,6 +1643,22 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise { const startTime = Date.now(); + // Inject Langfuse session/trace attributes for root chat observations + if (isRootRuntime && data.mode === 'chat' && isLangfuseEnabled()) { + workflowSpan.setAttribute('langfuse.session.id', data.chatId || ''); + workflowSpan.setAttribute('langfuse.user.id', String(data.runningUserInfo.tmbId)); + workflowSpan.setAttribute( + 'langfuse.trace.metadata.app_id', + String(data.runningAppInfo.id) + ); + workflowSpan.setAttribute( + 'langfuse.observation.metadata.app_id', + String(data.runningAppInfo.id) + ); + workflowSpan.setAttribute('langfuse.trace.metadata.appName', data.runningAppInfo.name); + workflowSpan.setAttribute('langfuse.trace.input', JSON.stringify(data.query)); + } + await rewriteRuntimeWorkFlow({ teamId: data.runningAppInfo.teamId, nodes: data.runtimeNodes, @@ -1612,6 +1724,13 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise { const result: AIChatItemValueItemType[] = []; - // 合并连续的text + // 合并连续的text和连续的reasoning for (let i = 0; i < response.length; i++) { const item = response[i]; - if (item.text) { + const lastItem = result[result.length - 1]; + if (item.type === ChatItemValueTypeEnum.text) { let text = item.text?.content || ''; - const lastItem = result[result.length - 1]; - if (lastItem && lastItem.text?.content && item.stepId === lastItem.stepId) { + if (lastItem && lastItem.type === ChatItemValueTypeEnum.text && lastItem.text?.content) { lastItem.text.content += text; continue; } + } else if (item.type === ChatItemValueTypeEnum.reasoning) { + if (lastItem && lastItem.type === ChatItemValueTypeEnum.reasoning && lastItem.reasoning) { + lastItem.reasoning.content += item.reasoning?.content || ''; + continue; + } } result.push(item); } diff --git a/packages/service/core/workflow/dispatch/interactive/formInput.ts b/packages/service/core/workflow/dispatch/interactive/formInput.ts index 2d54d7752d99..a3713bdf35c8 100644 --- a/packages/service/core/workflow/dispatch/interactive/formInput.ts +++ b/packages/service/core/workflow/dispatch/interactive/formInput.ts @@ -9,9 +9,8 @@ import type { import type { UserInputFormItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { anyValueDecrypt } from '../../../../common/secret/utils'; -import { getLogger, LogCategories } from '../../../../common/logger'; - -const logger = getLogger(LogCategories.MODULE.WORKFLOW.INTERACTIVE); +import { addLog } from '../../../../common/system/log'; +import { getReferenceVariableValue } from '@fastgpt/global/core/workflow/runtime/utils'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.description]: string; @@ -22,6 +21,37 @@ type FormInputResponse = DispatchNodeResultType<{ [key: string]: any; }>; +const formatReferenceList = (value: any): { label: string; value: string }[] => { + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') { + const text = String(item); + return { + label: text, + value: text + }; + } + + if (typeof item === 'object' && item !== null) { + const record = item as Record; + const text = record.value ?? record.label ?? record.name; + + if (text !== undefined && text !== null) { + const valueText = String(text); + return { + label: valueText, + value: valueText + }; + } + } + + return undefined; + }) + .filter((item): item is { label: string; value: string } => !!item); +}; + /* 用户输入都内容,将会以 JSON 字符串格式进入工作流,可以从 query 的 text 中获取。 */ @@ -31,10 +61,34 @@ export const dispatchFormInput = async (props: Props): Promise { + if (![FlowNodeInputTypeEnum.select, FlowNodeInputTypeEnum.multipleSelect].includes(form.type)) { + return form; + } + + if (form.listInputType !== FlowNodeInputTypeEnum.reference || !form.listReference) { + return form; + } + + const resolvedReferenceValue = getReferenceVariableValue({ + value: form.listReference, + nodesMap: new Map(runtimeNodes.map((node) => [node.nodeId, node])), + variables + }); + + return { + ...form, + list: formatReferenceList(resolvedReferenceValue), + listInputType: FlowNodeInputTypeEnum.custom as const + }; + }); + // Interactive node is not the entry node, return interactive result if (!isEntry || lastInteractive?.type !== 'userInput') { return { @@ -42,7 +96,7 @@ export const dispatchFormInput = async (props: Props): Promise { - const inputConfig = userInputForms.find((form) => form.key === key); + const inputConfig = resolvedUserInputForms.find((form) => form.key === key); if (inputConfig?.type === FlowNodeInputTypeEnum.password) { acc[key] = anyValueDecrypt(value); diff --git a/packages/service/core/workflow/dispatch/interactive/userSelect.ts b/packages/service/core/workflow/dispatch/interactive/userSelect.ts index 4039ca1a9030..e2b697c8ed69 100644 --- a/packages/service/core/workflow/dispatch/interactive/userSelect.ts +++ b/packages/service/core/workflow/dispatch/interactive/userSelect.ts @@ -8,24 +8,75 @@ import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import type { UserSelectOptionItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.description]: string; - [NodeInputKeyEnum.userSelectOptions]: UserSelectOptionItemType[]; + [NodeInputKeyEnum.userSelectOptions]: UserSelectOptionItemType[] | unknown[] | unknown; }>; type UserSelectResponse = DispatchNodeResultType<{ [NodeOutputKeyEnum.selectResult]?: string; }>; +const referenceSourceHandleKey = 'ref_default'; + +const formatReferenceOptions = (options: unknown): UserSelectOptionItemType[] => { + if (!Array.isArray(options)) return []; + + return options + .map((item, index) => { + if (typeof item === 'string') { + return { + key: `ref_${index}`, + value: item + }; + } + + if (typeof item === 'number' || typeof item === 'boolean') { + return { + key: `ref_${index}`, + value: String(item) + }; + } + + if (typeof item === 'object' && item !== null) { + const record = item as Record; + const key = record.key ?? record.id ?? `ref_${index}`; + const value = record.value ?? record.label ?? record.name; + + if (value !== undefined) { + return { + key: String(key), + value: String(value) + }; + } + } + + return undefined; + }) + .filter((item): item is UserSelectOptionItemType => !!item && !!item.value); +}; + export const dispatchUserSelect = async (props: Props): Promise => { const { histories, node, - params: { description, userSelectOptions }, + params: { description, userSelectOptions: rawOptions }, query, lastInteractive } = props; - const { nodeId, isEntry } = node; + const { nodeId, isEntry, inputs } = node; + + const optionInput = inputs.find((input) => input.key === 'userSelectOptions'); + const isReferenceMode = + optionInput?.renderTypeList?.[optionInput.selectedTypeIndex || 0] === + FlowNodeInputTypeEnum.reference; + + const userSelectOptions = isReferenceMode + ? formatReferenceOptions(rawOptions) + : Array.isArray(rawOptions) + ? (rawOptions as UserSelectOptionItemType[]) + : []; // Interactive node is not the entry node, return interactive result if (!isEntry || lastInteractive?.type !== 'userSelect') { @@ -47,9 +98,9 @@ export const dispatchUserSelect = async (props: Props): Promise - getHandleId(nodeId, 'source', item.value) - ) + [DispatchNodeResponseKeyEnum.skipHandleId]: isReferenceMode + ? [getHandleId(nodeId, 'source', referenceSourceHandleKey)] + : userSelectOptions.map((item) => getHandleId(nodeId, 'source', item.key)) }; } @@ -57,10 +108,12 @@ export const dispatchUserSelect = async (props: Props): Promise item.value !== userSelectedVal) - .map((item: any) => getHandleId(nodeId, 'source', item.key)), + [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), + [DispatchNodeResponseKeyEnum.skipHandleId]: isReferenceMode + ? [] + : userSelectOptions + .filter((item) => item.value !== userSelectedVal) + .map((item) => getHandleId(nodeId, 'source', item.key)), [DispatchNodeResponseKeyEnum.nodeResponse]: { userSelectResult: userSelectedVal }, diff --git a/packages/service/core/workflow/dispatch/tools/queryExternsion.ts b/packages/service/core/workflow/dispatch/tools/queryExternsion.ts index d8207cff1fe1..92a82e62e3a3 100644 --- a/packages/service/core/workflow/dispatch/tools/queryExternsion.ts +++ b/packages/service/core/workflow/dispatch/tools/queryExternsion.ts @@ -3,7 +3,7 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/ import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { getLLMModel, getEmbeddingModel } from '../../../../core/ai/model'; +import { getEmbeddingModelById } from '../../../../core/ai/model'; import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; import { queryExtension } from '../../../../core/ai/functions/queryExtension'; import { getHistories } from '../utils'; @@ -11,7 +11,7 @@ import { hashStr } from '@fastgpt/global/common/string/tools'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; type Props = ModuleDispatchProps<{ - [NodeInputKeyEnum.aiModel]: string; + [NodeInputKeyEnum.aiModelId]?: string; [NodeInputKeyEnum.aiSystemPrompt]?: string; [NodeInputKeyEnum.history]?: ChatItemMiniType[] | number; [NodeInputKeyEnum.userChatInput]: string; @@ -24,14 +24,13 @@ export const dispatchQueryExtension = async ({ histories, node, usagePush, - params: { model, systemPrompt, history, userChatInput } + params: { modelId, systemPrompt, history, userChatInput } }: Props): Promise => { if (!userChatInput) { return Promise.reject('Question is empty'); } - const queryExtensionModel = getLLMModel(model); - const embeddingModel = getEmbeddingModel(); + const embeddingModel = getEmbeddingModelById(); const chatHistories = getHistories(history, histories); const { @@ -39,26 +38,26 @@ export const dispatchQueryExtension = async ({ inputTokens, outputTokens, embeddingTokens, - llmModel, - embeddingModel: useEmbeddingModel + llmModelId, + embeddingModelId } = await queryExtension({ chatBg: systemPrompt, query: userChatInput, histories: chatHistories, - llmModel: queryExtensionModel.model, - embeddingModel: embeddingModel.model + llmModelId: modelId!, + embeddingModelId: embeddingModel.id! }); extensionQueries.unshift(userChatInput); const { totalPoints: llmPoints, modelName: llmModelName } = formatModelChars2Points({ - model: llmModel, + modelId: llmModelId, inputTokens, outputTokens }); const { totalPoints: embeddingPoints, modelName: embeddingModelName } = formatModelChars2Points({ - model: useEmbeddingModel, + modelId: embeddingModelId, inputTokens: embeddingTokens }); @@ -67,14 +66,14 @@ export const dispatchQueryExtension = async ({ { moduleName: node.name, totalPoints: llmPoints, - model: llmModelName, + modelId: llmModelId, inputTokens, outputTokens }, { moduleName: `${node.name} - Embedding`, totalPoints: embeddingPoints, - model: embeddingModelName, + modelId: embeddingModelId, inputTokens: embeddingTokens, outputTokens: 0 } @@ -95,7 +94,7 @@ export const dispatchQueryExtension = async ({ }, [DispatchNodeResponseKeyEnum.nodeResponse]: { totalPoints, - model: llmModelName, + modelId: llmModelId, inputTokens, outputTokens, embeddingTokens, diff --git a/packages/service/core/workflow/dispatch/tools/readFiles.ts b/packages/service/core/workflow/dispatch/tools/readFiles.ts index 2f2721dfe215..0a9362a3c0e7 100644 --- a/packages/service/core/workflow/dispatch/tools/readFiles.ts +++ b/packages/service/core/workflow/dispatch/tools/readFiles.ts @@ -11,7 +11,7 @@ import { parseContentDispositionFilename } from '@fastgpt/global/common/file/tools'; import { parseUrlToFileType } from '../../utils/context'; -import { readFileContentByBuffer } from '../../../../common/file/read/utils'; +import { readS3FileContentByBuffer } from '../../../../common/file/read/utils'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import { addDays } from 'date-fns'; @@ -24,6 +24,7 @@ import path from 'node:path'; import { S3Buckets } from '../../../../common/s3/config/constants'; import { S3Sources } from '../../../../common/s3/contracts/type'; import { getS3RawTextSource } from '../../../../common/s3/sources/rawText'; +import https from 'https'; import { getLogger, LogCategories } from '../../../../common/logger'; const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS); @@ -68,7 +69,10 @@ export const dispatchReadFiles = async (props: Props): Promise => { usageId } = props; const maxFiles = chatConfig?.fileSelectConfig?.maxFiles || 20; - const customPdfParse = chatConfig?.fileSelectConfig?.customPdfParse || false; + // 只要系统配置了自定义解析服务 url,且 App 没有显式禁用(=== false),则自动启用 + const hasCustomParseUrl = !!global.systemEnv?.customPdfParse?.url; + const customPdfParse = + chatConfig?.fileSelectConfig?.customPdfParse !== false && hasCustomParseUrl; // Get files from histories const filesFromHistories = version !== '489' ? [] : getHistoryFileLinks(histories); @@ -197,7 +201,8 @@ export const getFileContentFromLinks = async ({ // Get file buffer data const response = await axios.get(url, { baseURL: serverRequestBaseUrl, - responseType: 'arraybuffer' + responseType: 'arraybuffer', + httpsAgent: new https.Agent({ rejectUnauthorized: false }) }); const buffer = Buffer.from(response.data, 'binary'); @@ -239,7 +244,7 @@ export const getFileContentFromLinks = async ({ return detectFileEncoding(buffer); })(); - const { rawText } = await readFileContentByBuffer({ + const { rawText } = await readS3FileContentByBuffer({ extension, teamId, tmbId, @@ -247,6 +252,7 @@ export const getFileContentFromLinks = async ({ encoding, customPdfParse, getFormatText: true, + filename, imageKeyOptions: imageParsePrefix ? { prefix: imageParsePrefix, diff --git a/packages/service/core/workflow/dispatch/utils.ts b/packages/service/core/workflow/dispatch/utils.ts index 8f817b768a3a..884894a6bd89 100644 --- a/packages/service/core/workflow/dispatch/utils.ts +++ b/packages/service/core/workflow/dispatch/utils.ts @@ -17,6 +17,7 @@ import { } from '@fastgpt/global/core/workflow/runtime/type'; import type { RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; import { responseWrite } from '../../../common/response'; +import { ENTRY_POINT_VARIABLE_KEY } from '@fastgpt/global/core/app/constants'; import { type NextApiResponse } from 'next'; import { DispatchNodeResponseKeyEnum, @@ -34,6 +35,8 @@ import { MongoApp } from '../../../core/app/schema'; import { getMCPChildren } from '../../../core/app/mcp'; import { getSystemToolRunTimeNodeFromSystemToolset } from '../utils'; import type { localeType } from '@fastgpt/global/common/i18n/type'; +import { addLog } from '../../../common/system/log'; +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type'; import type { WorkflowResponseType } from './type'; import { getLogger, LogCategories } from '../../../common/logger'; @@ -100,6 +103,10 @@ export const getSystemVariables = async ({ return { ...variablesMap, + // 功能入口变量:不在 variablesConfig 中,从请求原始 variables 中直接透传 + ...(variables[ENTRY_POINT_VARIABLE_KEY] !== undefined + ? { [ENTRY_POINT_VARIABLE_KEY]: variables[ENTRY_POINT_VARIABLE_KEY] } + : {}), // System var: userId: uid, appId: String(runningAppInfo.id), @@ -285,10 +292,33 @@ export const checkQuoteQAValue = (quoteQA?: SearchDataResponseItemType[]) => { if (quoteQA.length === 0) { return []; } - if (quoteQA.some((item) => typeof item !== 'object' || !item.q)) { + + // 过滤掉无效的item(没有q字段或q为空的),但保留有效的item + const validItems = quoteQA.filter((item) => typeof item === 'object' && item.q); + + // Debug日志:记录过滤情况 + const invalidItems = quoteQA.filter((item) => typeof item !== 'object' || !item.q); + if (invalidItems.length > 0) { + addLog.debug('checkQuoteQAValue - Filtered out invalid items', { + totalItems: quoteQA.length, + validItemsCount: validItems.length, + invalidItemsCount: invalidItems.length, + invalidItems: invalidItems.map((item) => ({ + id: item?.id, + q: item?.q, + a: item?.a + })) + }); + } + + // 如果所有item都无效,返回undefined + if (validItems.length === 0) { + addLog.warn('checkQuoteQAValue - All items are invalid, returning undefined'); return undefined; } - return quoteQA; + + // 返回有效的items + return validItems; }; /* remove system variable */ diff --git a/packages/service/core/workflow/utils/index.ts b/packages/service/core/workflow/utils/index.ts index 3de7a8695db8..ae60360f8085 100644 --- a/packages/service/core/workflow/utils/index.ts +++ b/packages/service/core/workflow/utils/index.ts @@ -52,7 +52,7 @@ export async function getSystemToolRunTimeNodeFromSystemToolset({ (item) => item.toolId === child.id ); - const tool = await getSystemToolByIdAndVersionId(child.id); + const tool = await getSystemToolByIdAndVersionId('', 'en', child.id); const inputs = tool.inputs ?? []; if (toolsetInputConfig?.value) { diff --git a/packages/service/env.ts b/packages/service/env.ts index 82ef1370da1a..debcff068986 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -8,7 +8,8 @@ const BoolSchema = z const NumSchema = z.coerce.number(); const IntSchema = NumSchema.int().nonnegative(); - +const stripUrlTrailingSlash = (value?: string) => value?.replace(/\/+$/, '') || ''; +const UrlSchema = z.string().url().transform(stripUrlTrailingSlash); // 枚举 const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']); const StorageVendorSchema = z.enum(['minio', 'aws-s3', 'cos', 'oss']); @@ -28,7 +29,7 @@ export const env = createEnv({ AGENT_SANDBOX_OPENSANDBOX_RUNTIME: z.enum(['docker', 'kubernetes']).default('docker'), AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: z.string().optional(), AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: z.string().default('latest'), - AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: BoolSchema.default(true), + AGENT_SANDBOX_OPENSANDBOX_REQUEST_TIMEOUT: z.number().default(60), AGENT_SANDBOX_ENABLE_VOLUME: BoolSchema.default(false), AGENT_SANDBOX_VOLUME_MANAGER_URL: z.string().url().optional(), AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(), @@ -39,8 +40,7 @@ export const env = createEnv({ AGENT_SKILL_MAX_DOWNLOAD_SIZE: NumSchema.optional(), AGENT_SKILL_MAX_SANDBOX_SIZE: NumSchema.optional(), - AGENT_SANDBOX_MAX_EDIT_DEBUG: NumSchema.optional(), - AGENT_SANDBOX_MAX_SESSION_RUNTIME: NumSchema.optional(), + AGENT_SANDBOX_MAX_COUNT: NumSchema.default(100), // 对象存储 STORAGE_VENDOR: StorageVendorSchema.default('minio'), @@ -82,6 +82,11 @@ export const env = createEnv({ TRACING_OTEL_URL: z.url().optional(), TRACING_OTEL_SAMPLE_RATIO: NumSchema.min(0).max(1).optional(), + // Langfuse + LANGFUSE_SECRET_KEY: z.string().optional(), + LANGFUSE_PUBLIC_KEY: z.string().optional(), + LANGFUSE_BASE_URL: UrlSchema.optional(), + APP_FOLDER_MAX_AMOUNT: NumSchema.int().positive().default(1000), DATASET_FOLDER_MAX_AMOUNT: NumSchema.int().positive().default(1000), @@ -113,12 +118,17 @@ export const env = createEnv({ /** Redis 内存水位检测缓存时长(毫秒),避免每个流请求都调用 INFO MEMORY */ STREAM_RESUME_REDIS_MEMORY_CHECK_INTERVAL_MS: IntSchema.positive().default(5000), - // Beta features - // Whether the Skill feature is enabled (frontend entries + backend runtime) - SHOW_SKILL: BoolSchema.default(false), - // Agent engine selection: 'default' uses the built-in Plan+Step engine, 'pi' uses pi-agent-core - AGENT_ENGINE: z.enum(['default', 'pi']).default('default') + AGENT_ENGINE: z.enum(['default', 'pi']).default('default'), + + // piAgent context compaction (summarizes old messages when context window is near limit) + PI_AGENT_COMPACTION_ENABLED: BoolSchema.default(false), + + // User input auto-compression: compress ultra-long user input before sending to LLM + // Threshold ratio: compress when user input tokens > maxContext * this ratio (0~1) + USER_INPUT_COMPRESS_THRESHOLD: NumSchema.default(0.8), + // Target ratio: compress to maxContext * this ratio (must be < threshold) + USER_INPUT_COMPRESS_TARGET: NumSchema.default(0.6) }, emptyStringAsUndefined: true, runtimeEnv: process.env, diff --git a/packages/service/package.json b/packages/service/package.json index ae889a352d56..ed50b3a017f7 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -11,28 +11,41 @@ "@mariozechner/pi-agent-core": "^0.67.3", "@mariozechner/pi-ai": "^0.67.3", "@fastgpt-sdk/sandbox-adapter": "^0.0.36", - "@fastgpt-sdk/otel": "catalog:", - "@fastgpt-sdk/storage": "catalog:", + "@fastgpt-sdk/otel": "workspace:*", + "@fastgpt-sdk/storage": "workspace:*", "@fastgpt/global": "workspace:*", + "@langfuse/otel": "^5.0.0", + "@langfuse/tracing": "^5.0.0", "@maxmind/geoip2-node": "^6.3.4", "@modelcontextprotocol/sdk": "catalog:", "@node-rs/jieba": "2.0.1", "@opentelemetry/api": "^1.9.0", + "archiver": "^7.0.1", + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", + "@opentelemetry/resources": "^2.0.1", + "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/winston-transport": "^0.14.0", "@t3-oss/env-core": "0.13.10", "@xmldom/xmldom": "^0.8.10", - "@zilliz/milvus2-sdk-node": "2.4.10", + "@zilliz/milvus2-sdk-node": "2.6.9", "axios": "catalog:", "bullmq": "^5.52.2", "chalk": "^5.3.0", "cheerio": "1.0.0-rc.12", + "chinese-conv": "^3.2.2", + "combined-stream": "^1.0.8", "cookie": "^0.7.1", + "csv-rex": "^0.7.0", "date-fns": "catalog:", "dayjs": "catalog:", "decompress": "^4.2.1", + "diting-rag-ts": "workspace:*", "domino-ext": "^2.1.4", "encoding": "^0.1.13", "file-type": "catalog:", "form-data": "^4.0.4", + "franc": "^6.2.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "iconv-lite": "^0.6.3", @@ -49,12 +62,14 @@ "mime-types": "catalog:", "minio": "catalog:", "mongoose": "^8.10.1", + "mysql2": "^3.14.3", + "typeorm": "^0.3.24", + "reflect-metadata": "^0.2.2", "multer": "2.1.0", - "mysql2": "^3.11.3", "next": "catalog:", "nextjs-cors": "2.2.1", "node-cron": "^3.0.3", - "node-xlsx": "^0.24.0", + "node-xlsx": "file:/app/node-xlsx-0.24.0.tgz", "p-limit": "^7.2.0", "papaparse": "5.4.1", "pdfjs-dist": "4.10.38", @@ -64,6 +79,8 @@ "proxy-agent": "catalog:", "proxy-from-env": "^1.1.0", "request-ip": "^3.3.0", + "mssql": "^11.0.1", + "oracledb": "^6.3.0", "tiktoken": "1.0.17", "tunnel": "^0.0.6", "turndown": "^7.1.2", @@ -73,6 +90,8 @@ }, "devDependencies": { "@types/async-retry": "^1.4.9", + "@types/archiver": "^7.0.0", + "@types/combined-stream": "^1.0.6", "@types/cookie": "^0.5.2", "@types/decompress": "^4.2.7", "@types/jsonwebtoken": "^9.0.3", diff --git a/packages/service/support/openapi/auth.ts b/packages/service/support/openapi/auth.ts index 883f58b4a2d3..08dbd0d9691d 100644 --- a/packages/service/support/openapi/auth.ts +++ b/packages/service/support/openapi/auth.ts @@ -2,6 +2,7 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { updateApiKeyUsedTime } from './tools'; import { MongoOpenApi } from './schema'; import type { OpenApiSchema } from '@fastgpt/global/support/openapi/type'; +import { ChatSourceEnum, getChatSourceName } from '@fastgpt/global/core/chat/constants'; export type AuthOpenApiLimitProps = { openApi: OpenApiSchema }; @@ -27,7 +28,7 @@ export async function authOpenApiKey({ apikey }: { apikey: string }) { teamId: String(openApi.teamId), tmbId: String(openApi.tmbId), appId: openApi.appId || '', - sourceName: openApi.name + sourceName: getChatSourceName(ChatSourceEnum.api, openApi.name) }; } catch (error) { return Promise.reject(error); diff --git a/packages/service/support/openapi/tools.ts b/packages/service/support/openapi/tools.ts index 09c3a285e825..b93bfc8c0561 100644 --- a/packages/service/support/openapi/tools.ts +++ b/packages/service/support/openapi/tools.ts @@ -1,8 +1,63 @@ import { MongoOpenApi } from './schema'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getLogger, LogCategories } from '../../common/logger'; const logger = getLogger(LogCategories.MODULE.OPENAPI.TOOLS); + +// API Key 生成常量 +const API_KEY_MIN_LENGTH = 52; +const API_KEY_RANDOM_RANGE = 14; + +/** + * 为 Skill 导出创建专用 API Key + * 使用 UUID 作为 Key 名称,完全避免并发冲突 + * + * @param params.teamId - 团队 ID + * @param params.tmbId - 成员 ID + * @param params.appId - 应用 ID + * @returns { apiKey: string, keyName: string, expiredTime: undefined } + */ +export async function createSkillApiKey(params: { + teamId: string; + tmbId: string; + appId: string; +}): Promise<{ apiKey: string; keyName: string; expiredTime: undefined }> { + const { teamId, tmbId, appId } = params; + + // 使用 nanoid 生成唯一的 Key 名称,格式:invoke-skill-{8位随机字符} + const uniqueId = getNanoid(8); + const keyName = `invoke-skill-${uniqueId}`; + + // 生成 API Key (长度在 52-65 之间) + const nanoid = getNanoid(Math.floor(Math.random() * API_KEY_RANDOM_RANGE) + API_KEY_MIN_LENGTH); + const apiKey = `${global.systemEnv?.openapiPrefix || 'fastgpt'}-${nanoid}`; + + try { + // 创建 API Key (不设置 expiredTime,表示永不过期) + await MongoOpenApi.create({ + teamId, + tmbId, + apiKey, + appId, + name: keyName, + limit: { + maxUsagePoints: -1 // 无限制 + // 不设置 expiredTime,前端会显示为 "-" (永不过期) + } + }); + + return { apiKey, keyName, expiredTime: undefined }; + } catch (error: any) { + // 如果是唯一索引冲突(极小概率),重试一次 + if (error.code === 11000) { + // 递归重试(nanoid 冲突概率极低,一般不会进入这里) + return createSkillApiKey(params); + } + throw error; + } +} + export function updateApiKeyUsedTime(id: string) { MongoOpenApi.findByIdAndUpdate(id, { lastUsedTime: new Date() diff --git a/packages/service/support/outLink/runtime/utils.ts b/packages/service/support/outLink/runtime/utils.ts index d5edccd0f503..a48831212c44 100644 --- a/packages/service/support/outLink/runtime/utils.ts +++ b/packages/service/support/outLink/runtime/utils.ts @@ -1,4 +1,4 @@ -import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { ChatRoleEnum, getChatSourceName } from '@fastgpt/global/core/chat/constants'; import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { @@ -264,10 +264,13 @@ export async function outlinkInvokeChat({ nodes, appChatConfig: chatConfig, variables: newVariables, - newTitle: String(userQuestion || '').slice(0, 8), + newTitle: String(userQuestion || '').slice(0, 20), shareId: outLinkConfig.shareId, source: getChatSourceByPublishChannel(outLinkConfig.type), - sourceName: outLinkConfig.name, + sourceName: getChatSourceName( + getChatSourceByPublishChannel(outLinkConfig.type), + outLinkConfig.name + ), userContent: { dataId: messageId, obj: ChatRoleEnum.Human, diff --git a/packages/service/support/outLink/schema.ts b/packages/service/support/outLink/schema.ts index 27f807dd52e0..dac82cbe785e 100644 --- a/packages/service/support/outLink/schema.ts +++ b/packages/service/support/outLink/schema.ts @@ -68,6 +68,10 @@ const OutLinkSchema = new Schema({ type: Boolean, default: true }, + allowAnonymous: { + type: Boolean, + default: true + }, limit: { maxUsagePoints: { type: Number, diff --git a/packages/service/support/permission/agentSkill/auth.ts b/packages/service/support/permission/agentSkill/auth.ts index 36168825fb19..8535efe7f4ee 100644 --- a/packages/service/support/permission/agentSkill/auth.ts +++ b/packages/service/support/permission/agentSkill/auth.ts @@ -44,6 +44,10 @@ export const authSkillByTmbId = async ({ skill: AgentSkillSchemaType & { permission: SkillPermission }; }> => { const skill = await (async () => { + if (!skillId) { + return Promise.reject(SkillErrEnum.unExist); + } + const [{ teamId, permission: tmbPer }, skill] = await Promise.all([ getTmbInfoByTmbId({ tmbId }), MongoAgentSkills.findOne({ _id: skillId, deleteTime: null }).lean() diff --git a/packages/service/support/permission/app/auth.ts b/packages/service/support/permission/app/auth.ts index aa17bef9ae92..e390b73b0905 100644 --- a/packages/service/support/permission/app/auth.ts +++ b/packages/service/support/permission/app/auth.ts @@ -18,6 +18,7 @@ import { type AuthModeType, type AuthResponseType } from '../type'; import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant'; import { parseHeaderCert } from '../auth/common'; import { sumPer } from '@fastgpt/global/support/permission/utils'; +import { getAgentUserTmbIds } from '../../user/agentUser'; export const authWorkflowToolByTmbId = async ({ tmbId, @@ -51,6 +52,9 @@ export const authAppByTmbId = async ({ }> => { const { teamId, permission: tmbPer } = await getTmbInfoByTmbId({ tmbId }); + // root 用户提前获取 agent user 的 tmbId 列表,用于后续权限判断 + const agentUserTmbIds = isRoot ? await getAgentUserTmbIds() : new Set(); + const app = await (async () => { const app = await MongoApp.findOne({ _id: appId }).lean(); @@ -59,10 +63,14 @@ export const authAppByTmbId = async ({ } if (isRoot) { - return { - ...app, - permission: new AppPermission({ isOwner: true }) - }; + if (!agentUserTmbIds.has(String(app.tmbId))) { + return { + ...app, + permission: new AppPermission({ isOwner: true }) + }; + } + // 对于 agent user 创建的应用,root 不给特殊权限 + // 直接继续走正常的 permission 校验 } if (String(app.teamId) !== teamId) { @@ -84,7 +92,9 @@ export const authAppByTmbId = async ({ }; } - const isOwner = tmbPer.isOwner || String(app.tmbId) === String(tmbId); + const isOwner = + String(app.tmbId) === String(tmbId) || + (tmbPer.isOwner && !agentUserTmbIds.has(String(app.tmbId))); const isGetParentClb = app.inheritPermission && !AppFolderTypeList.includes(app.type) && !!app.parentId; diff --git a/packages/service/support/permission/controller.ts b/packages/service/support/permission/controller.ts index 5872c4ed62ef..d9eb5fa6bbbc 100644 --- a/packages/service/support/permission/controller.ts +++ b/packages/service/support/permission/controller.ts @@ -1,5 +1,5 @@ import type { ClientSession, AnyBulkWriteOperation } from '../../common/mongo'; -import type { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; import { ManageRoleVal, OwnerRoleVal } from '@fastgpt/global/support/permission/constant'; import { MongoResourcePermission } from './schema'; import type { ResourcePermissionType } from '@fastgpt/global/support/permission/type'; @@ -8,7 +8,11 @@ import { getGroupsByTmbId } from './memberGroup/controllers'; import { Permission } from '@fastgpt/global/support/permission/controller'; import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type'; import { getOrgIdSetWithParentByTmbId } from './org/controllers'; -import { getCollaboratorId, sumPer } from '@fastgpt/global/support/permission/utils'; +import { + getCollaboratorId, + sumPer, + mergeCollaboratorList +} from '@fastgpt/global/support/permission/utils'; import { type SyncChildrenPermissionResourceType } from './inheritPermission'; import { pickCollaboratorIdFields } from './utils'; import type { @@ -16,9 +20,12 @@ import type { CollaboratorItemType } from '@fastgpt/global/support/permission/collaborator'; import { MongoTeamMember } from '../../support/user/team/teamMemberSchema'; +import { MongoUser } from '../../support/user/schema'; import { MongoOrgModel } from './org/orgSchema'; import { MongoMemberGroupModel } from './memberGroup/memberGroupSchema'; import { DEFAULT_ORG_AVATAR, DEFAULT_TEAM_AVATAR } from '@fastgpt/global/common/system/constants'; +import { MongoDataset } from '../../core/dataset/schema'; +import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; /** get resource permission for a team member * If there is no permission for the team member, it will return undefined @@ -154,16 +161,26 @@ export const getClbsInfo = async ({ if (clb.groupId) groupIds.push(clb.groupId); } - const infos = ( - await Promise.all([ - MongoTeamMember.find({ _id: { $in: tmbIds }, teamId }, '_id name avatar').lean(), - MongoOrgModel.find({ _id: { $in: orgIds }, teamId }, '_id name avatar').lean(), - MongoMemberGroupModel.find({ _id: { $in: groupIds }, teamId }, '_id name avatar').lean() - ]) - ).flat(); + const [tmbInfos, orgInfos, groupInfos] = await Promise.all([ + MongoTeamMember.find({ _id: { $in: tmbIds }, teamId }, '_id name avatar userId').lean(), + MongoOrgModel.find({ _id: { $in: orgIds }, teamId }, '_id name avatar').lean(), + MongoMemberGroupModel.find({ _id: { $in: groupIds }, teamId }, '_id name avatar').lean() + ]); + + const infos = [...tmbInfos, ...orgInfos, ...groupInfos]; + + const userIds = tmbInfos.map((info) => info.userId).filter(Boolean); + const users = + userIds.length > 0 + ? await MongoUser.find({ _id: { $in: userIds } }, '_id username').lean() + : []; + const userIdToUsername = new Map(users.map((u) => [u._id.toString(), u.username ?? undefined])); + + const tmbInfoMap = new Map(tmbInfos.map((t) => [t._id, t])); return clbs.map((clb) => { const info = infos.find((info) => info._id === getCollaboratorId(clb)); + const tmbInfo = tmbInfoMap.get(getCollaboratorId(clb)); return { ...clb, @@ -173,11 +190,78 @@ export const getClbsInfo = async ({ isOwner: Boolean(ownerTmbId && clb.tmbId && ownerTmbId === clb.tmbId) }), name: info?.name ?? 'Unknown name', - avatar: info?.avatar || (clb.orgId ? DEFAULT_ORG_AVATAR : DEFAULT_TEAM_AVATAR) + avatar: info?.avatar || (clb.orgId ? DEFAULT_ORG_AVATAR : DEFAULT_TEAM_AVATAR), + username: tmbInfo?.userId ? userIdToUsername.get(tmbInfo.userId.toString()) : undefined }; }); }; +/** + * Delete all direct clbs of a resource. + * Used when moving or resuming inheritance for non-folder resources. + */ +export async function deleteResourceClbs({ + resourceType, + teamId, + resourceId, + session +}: { + resourceType: PerResourceTypeEnum; + teamId: string; + resourceId: string; + session?: ClientSession; +}) { + await MongoResourcePermission.deleteMany( + { + resourceId, + resourceType, + teamId + }, + { ...(session ? { session } : {}) } + ); +} + +/** + * Get effective collaborators for a dataset, including inherited permissions. + * If the dataset is in inherited state (inheritPermission=true and has parentId), + * merges the parent's clbs with the dataset's own clbs. + */ +export async function getDatasetEffectiveClbs({ + datasetId, + teamId, + session +}: { + datasetId: string; + teamId: string; + session?: ClientSession; +}) { + let clbs = await getResourceOwnedClbs({ + resourceId: datasetId, + resourceType: PerResourceTypeEnum.dataset, + teamId, + session + }); + + const dataset = await MongoDataset.findOne({ _id: datasetId }, 'inheritPermission parentId type') + .lean() + .session(session ?? null); + + if (dataset?.inheritPermission && dataset.parentId && dataset.type !== DatasetTypeEnum.folder) { + const parentClbs = await getResourceOwnedClbs({ + resourceId: String(dataset.parentId), + resourceType: PerResourceTypeEnum.dataset, + teamId, + session + }); + clbs = mergeCollaboratorList({ + parentClbs: parentClbs, + childClbs: clbs + }); + } + + return clbs; +} + export const createResourceDefaultCollaborators = async ({ resource, resourceType, @@ -238,3 +322,4 @@ export const createResourceDefaultCollaborators = async ({ await MongoResourcePermission.bulkWrite(ops, { session }); }; + diff --git a/packages/service/support/permission/dataset/auth.ts b/packages/service/support/permission/dataset/auth.ts index f6c309883fbe..1f617a41ef5d 100644 --- a/packages/service/support/permission/dataset/auth.ts +++ b/packages/service/support/permission/dataset/auth.ts @@ -2,11 +2,13 @@ import { type PermissionValueType } from '@fastgpt/global/support/permission/typ import { getTmbPermission } from '../controller'; import { type CollectionWithDatasetType, + type DatasetCollectionSchemaType, type DatasetDataItemType, type DatasetSchemaType } from '@fastgpt/global/core/dataset/type'; import { getTmbInfoByTmbId } from '../../user/team/controller'; import { MongoDataset } from '../../../core/dataset/schema'; +import { MongoDatasetCollection } from '../../../core/dataset/collection/schema'; import { NullPermissionVal, NullRoleVal, @@ -17,14 +19,16 @@ import { DatasetPermission } from '@fastgpt/global/support/permission/dataset/co import { getCollectionWithDataset } from '../../../core/dataset/controller'; import { MongoDatasetData } from '../../../core/dataset/data/schema'; import { type AuthModeType, type AuthResponseType } from '../type'; -import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DatasetCollectionTypeEnum, DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { type ParentIdType } from '@fastgpt/global/common/parentFolder/type'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { parseHeaderCert } from '../auth/common'; import { sumPer } from '@fastgpt/global/support/permission/utils'; import { getS3DatasetSource } from '../../../common/s3/sources/dataset'; import { isS3ObjectKey } from '../../../common/s3/utils'; +const MAX_COLLECTION_PERMISSION_DEPTH = 10; + export const authDatasetByTmbId = async ({ tmbId, datasetId, @@ -51,12 +55,19 @@ export const authDatasetByTmbId = async ({ } if (isRoot) { - return { - ...dataset, - permission: new DatasetPermission({ - isOwner: true - }) - }; + // 如果 API 知识库开启了权限同步,不走 root 绕过,继续走正常鉴权 + const isApiDatasetWithPermissionSync = + dataset.type === DatasetTypeEnum.apiDataset && + !!(dataset.apiDatasetServer as any)?.apiServer?.permissionSync; + + if (!isApiDatasetWithPermissionSync) { + return { + ...dataset, + permission: new DatasetPermission({ + isOwner: true + }) + }; + } } if (String(dataset.teamId) !== teamId) { @@ -84,6 +95,8 @@ export const authDatasetByTmbId = async ({ }) ]); + // For inherited non-folder datasets, by design they should have no direct clbs. + // We still sum myPer for backward compatibility (old data may have residual clbs). const Per = new DatasetPermission({ role: sumPer(folderPer, myPer), isOwner }); if (!Per.checkPer(per)) { @@ -96,7 +109,7 @@ export const authDatasetByTmbId = async ({ }; })(); - return { dataset }; + return { dataset: dataset as DatasetSchemaType & { permission: DatasetPermission } }; }; export const authDataset = async ({ @@ -155,23 +168,140 @@ export async function authDatasetCollection({ return Promise.reject(DatasetErrEnum.unExist); } - const { dataset } = await authDatasetByTmbId({ - tmbId, - datasetId: collection.datasetId, - per, - isRoot: isRootFromHeader + const effectiveIsRoot = isRoot || isRootFromHeader; + + if (effectiveIsRoot) { + // 如果 API 知识库开启了权限同步,不走 root 绕过,继续走正常鉴权 + const isApiDatasetWithPermissionSync = + collection.dataset.type === DatasetTypeEnum.apiDataset && + !!(collection.dataset.apiDatasetServer as any)?.apiServer?.permissionSync; + + if (!isApiDatasetWithPermissionSync) { + return { + userId, + teamId, + tmbId, + collection, + permission: new DatasetPermission({ isOwner: true }), + isRoot: effectiveIsRoot + }; + } + } + + const permission = await getCollectionTmbPermission({ + collection, + teamId, + tmbId }); + if (!permission.checkPer(per)) { + return Promise.reject(DatasetErrEnum.unAuthDatasetCollection); + } + return { userId, teamId, tmbId, collection, - permission: dataset.permission, - isRoot: isRootFromHeader + permission, + isRoot: effectiveIsRoot }; } +/** + * Get the effective permission for a collection for a given team member. + * Handles both independent permissions and inheritance from parent. + */ +export async function getCollectionTmbPermission({ + collection, + teamId, + tmbId, + depth = 0 +}: { + collection: Pick< + DatasetCollectionSchemaType, + '_id' | 'tmbId' | 'datasetId' | 'parentId' | 'inheritPermission' | 'type' + >; + teamId: string; + tmbId: string; + depth?: number; +}): Promise { + // Prevent infinite recursion + if (depth > MAX_COLLECTION_PERMISSION_DEPTH) { + return new DatasetPermission({ role: NullRoleVal }); + } + + const { permission: tmbPer } = await getTmbInfoByTmbId({ tmbId }); + + // Check if owner of this collection (team owner or collection creator) + const isOwner = tmbPer.isOwner || String(collection.tmbId) === String(tmbId); + if (isOwner) { + return new DatasetPermission({ isOwner: true }); + } + + // If collection has independent permissions (inheritPermission === false) or is a folder + // Folder collections always manage their own permissions directly + const hasIndependentPermission = + collection.inheritPermission === false || collection.type === DatasetCollectionTypeEnum.folder; + + if (hasIndependentPermission) { + // Only use own permissions from resource_permissions + const myPer = await getTmbPermission({ + teamId, + tmbId, + resourceId: collection._id, + resourceType: PerResourceTypeEnum.collection + }); + return new DatasetPermission({ role: myPer ?? NullRoleVal }); + } + + // inheritPermission = true (or undefined for backward compat): inherit from parent + // By design, inherited non-folder resources should have NO direct clbs. + // Their effective permission is purely from the parent chain. + let parentRoleVal: number = NullRoleVal; + + if (collection.parentId) { + // Parent is another collection + const parentCollection = await MongoDatasetCollection.findOne( + { _id: collection.parentId }, + '_id tmbId datasetId parentId inheritPermission type' + ).lean< + Pick< + DatasetCollectionSchemaType, + '_id' | 'tmbId' | 'datasetId' | 'parentId' | 'inheritPermission' | 'type' + > + >(); + + if (parentCollection) { + const parentPermission = await getCollectionTmbPermission({ + collection: parentCollection, + teamId, + tmbId, + depth: depth + 1 + }); + parentRoleVal = parentPermission.role; + } + } else { + // Parent is the dataset - get effective dataset permission (including folder inheritance and owner check) + const { dataset: ds } = await authDatasetByTmbId({ + tmbId, + datasetId: collection.datasetId, + per: NullPermissionVal + }); + parentRoleVal = ds.permission.role; + } + + const myPer = + (await getTmbPermission({ + teamId, + tmbId, + resourceId: collection._id, + resourceType: PerResourceTypeEnum.collection + })) ?? NullRoleVal; + + return new DatasetPermission({ role: sumPer(parentRoleVal, myPer) }); +} + /* DatasetData permission is inherited from collection. */ @@ -216,7 +346,8 @@ export async function authDatasetData({ collectionId: String(datasetData.collectionId), sourceName: result.collection.name || '', sourceId: result.collection?.fileId || result.collection?.rawLink, - isOwner: String(datasetData.tmbId) === String(result.tmbId) + isOwner: String(datasetData.tmbId) === String(result.tmbId), + metadata: datasetData.metadata // permission: result.permission }; diff --git a/packages/service/support/permission/evaluation/auth.ts b/packages/service/support/permission/evaluation/auth.ts index 36a67ed98261..5de342b4d775 100644 --- a/packages/service/support/permission/evaluation/auth.ts +++ b/packages/service/support/permission/evaluation/auth.ts @@ -1,80 +1,370 @@ -import { authAppByTmbId } from '../app/auth'; +/* Auth evaluation permission */ +import { parseHeaderCert } from '../auth/common'; +import { getTmbPermission } from '../controller'; import { - ManagePermissionVal, - ReadPermissionVal + OwnerPermissionVal, + PerResourceTypeEnum, + ReadPermissionVal, + ReadRoleVal } from '@fastgpt/global/support/permission/constant'; -import type { EvaluationSchemaType } from '@fastgpt/global/core/app/evaluation/type'; -import type { AuthModeType } from '../type'; -import { MongoEvaluation } from '../../../core/app/evaluation/evalSchema'; -import { parseHeaderCert } from '../auth/common'; -import { AppErrEnum } from '@fastgpt/global/common/error/code/app'; +import { getTmbInfoByTmbId } from '../../user/team/controller'; +import type { EvaluationWithPerType } from '@fastgpt/global/core/evaluation/type'; +import type { AuthModeType, AuthResponseType } from '../type'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { MongoEvaluation } from '../../../core/evaluation/task'; +import { EvaluationPermission } from '@fastgpt/global/support/permission/evaluation/controller'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; +import { Permission } from '@fastgpt/global/support/permission/controller'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { getS3DatasetSource } from '../../../common/s3/sources/dataset'; +import { isS3ObjectKey } from '../../../common/s3/utils'; + +// ================ Authentication and Authorization for eval task ================ +export const authEvaluationByTmbId = async ({ + tmbId, + evaluationId, + per, + isRoot +}: { + tmbId: string; + evaluationId: string; + per: PermissionValueType; + isRoot?: boolean; +}): Promise<{ evaluation: EvaluationWithPerType }> => { + const { teamId, permission: tmbPer } = await getTmbInfoByTmbId({ tmbId }); -export const authEval = async ({ - evalId, + const evaluation = await MongoEvaluation.findOne({ _id: evaluationId }).lean(); + if (!evaluation) { + return Promise.reject(EvaluationErrEnum.evalTaskNotFound); + } + + // Root用户权限特殊处理 + if (isRoot) { + return { + evaluation: { + ...evaluation, + permission: new EvaluationPermission({ isOwner: true }) + } + }; + } + + // 团队权限验证 + if (String(evaluation.teamId) !== teamId) { + return Promise.reject(EvaluationErrEnum.evalTaskNotFound); + } + + // 所有者检查 + const isOwner = tmbPer.isOwner || String(evaluation.tmbId) === String(tmbId); + + // 权限计算 + const { Per } = await (async () => { + if (isOwner) { + return { Per: new EvaluationPermission({ isOwner: true }) }; + } + + // 获取评估资源的权限 + const role = await getTmbPermission({ + teamId, + tmbId, + resourceId: evaluationId, + resourceType: PerResourceTypeEnum.evaluation + }); + + return { Per: new EvaluationPermission({ role, isOwner }) }; + })(); + + // 权限验证 + if (!Per.checkPer(per)) { + return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); + } + + return { + evaluation: { + ...evaluation, + permission: Per + } + }; +}; + +export const authEvaluation = async ({ + evaluationId, per = ReadPermissionVal, ...props }: AuthModeType & { - evalId: string; -}): Promise<{ - evaluation: EvaluationSchemaType; + evaluationId: string; + per?: PermissionValueType; +}): Promise< + AuthResponseType & { + evaluation: EvaluationWithPerType; + } +> => { + const result = await parseHeaderCert({ + ...props, + authApiKey: true // 添加API Key支持 + }); + const { tmbId } = result; + + if (!evaluationId) { + return Promise.reject(EvaluationErrEnum.evalTaskNotFound); + } + + const { evaluation } = await authEvaluationByTmbId({ + tmbId, + evaluationId, + per, + isRoot: result.isRoot + }); + + return { + ...result, + permission: evaluation.permission, + evaluation + }; +}; + +// ================ Authentication and Authorization for eval dataset ================ +export const authEvalDatasetByTmbId = async ({ + tmbId, + datasetId, + per, + isRoot +}: { tmbId: string; - teamId: string; -}> => { - const { teamId, tmbId, isRoot } = await parseHeaderCert(props); - - const evaluation = await MongoEvaluation.findOne( - { - _id: evalId, - teamId - }, - 'tmbId appId' - ).lean(); - if (!evaluation) { - return Promise.reject('Evaluation not found'); + datasetId: string; + per: PermissionValueType; + isRoot?: boolean; +}): Promise<{ dataset: any }> => { + const { MongoEvalDatasetCollection } = await import( + '../../../core/evaluation/dataset/evalDatasetCollectionSchema' + ); + const { teamId, permission: tmbPer } = await getTmbInfoByTmbId({ tmbId }); + + const dataset = await MongoEvalDatasetCollection.findOne({ _id: datasetId }).lean(); + if (!dataset) { + return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); } - if (String(evaluation.tmbId) === tmbId) { + // Root用户权限特殊处理 + if (isRoot) { return { + dataset: { + ...dataset, + permission: new EvaluationPermission({ isOwner: true }) + } + }; + } + + // 团队权限验证 + if (String(dataset.teamId) !== teamId) { + return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); + } + + // 所有者检查 + const isOwner = tmbPer.isOwner || String(dataset.tmbId) === String(tmbId); + + // 权限计算 - 使用evaluation资源类型 + const { Per } = await (async () => { + if (isOwner) { + return { Per: new EvaluationPermission({ isOwner: true }) }; + } + + // 获取evaluation资源的权限(evalDataset复用evaluation权限) + const role = await getTmbPermission({ teamId, tmbId, - evaluation + resourceId: datasetId, + resourceType: PerResourceTypeEnum.evaluation + }); + + return { Per: new EvaluationPermission({ role, isOwner }) }; + })(); + + // 权限验证 + if (!Per.checkPer(per)) { + return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); + } + + return { + dataset: { + ...dataset, + permission: Per + } + }; +}; + +export const authEvalDataset = async ({ + datasetId, + per = ReadPermissionVal, + ...props +}: AuthModeType & { + datasetId: string; + per?: PermissionValueType; +}): Promise< + AuthResponseType & { + dataset: any; + } +> => { + const result = await parseHeaderCert({ + ...props, + authApiKey: true // 添加API Key支持 + }); + const { tmbId } = result; + + if (!datasetId) { + return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); + } + + const { dataset } = await authEvalDatasetByTmbId({ + tmbId, + datasetId, + per, + isRoot: result.isRoot + }); + + return { + ...result, + permission: dataset.permission, + dataset + }; +}; + +export const authEvalDatasetCollectionFile = async ({ + fileId, + per = OwnerPermissionVal, + ...props +}: AuthModeType & { + fileId: string; +}): Promise => { + const authRes = await parseHeaderCert(props); + const { teamId } = authRes; + + if (!isS3ObjectKey(fileId, 'temp')) { + return Promise.reject(CommonErrEnum.fileNotFound); + } + + // temp key 格式: temp/{teamId}/{filename},从路径提取 teamId 校验归属 + const keyTeamId = fileId.split('/')[1]; + if (keyTeamId !== teamId) { + return Promise.reject(CommonErrEnum.unAuthFile); + } + + const exists = await getS3DatasetSource().isObjectExists(fileId); + if (!exists) { + return Promise.reject(CommonErrEnum.fileNotFound); + } + + const permission = new Permission({ role: ReadRoleVal, isOwner: true }); + + if (!permission.checkPer(per)) { + return Promise.reject(CommonErrEnum.unAuthFile); + } + + return { + ...authRes, + permission + }; +}; + +// ================ Authentication and Authorization for eval metric ================ +export const authEvalMetricByTmbId = async ({ + tmbId, + metricId, + per, + isRoot +}: { + tmbId: string; + metricId: string; + per: PermissionValueType; + isRoot?: boolean; +}): Promise<{ metric: any }> => { + const { MongoEvalMetric } = await import('../../../core/evaluation/metric/schema'); + const { teamId, permission: tmbPer } = await getTmbInfoByTmbId({ tmbId }); + + const metric = await MongoEvalMetric.findOne({ _id: metricId }).lean(); + if (!metric) { + return Promise.reject(EvaluationErrEnum.evalMetricNotFound); + } + + // Root用户权限特殊处理 + if (isRoot) { + return { + metric: { + ...metric, + permission: new EvaluationPermission({ isOwner: true }) + } }; } - try { - // App read per - if (per === ReadPermissionVal) { - await authAppByTmbId({ - tmbId, - appId: evaluation.appId, - per: ReadPermissionVal, - isRoot - }); - - return { - teamId, - tmbId, - evaluation - }; + // 团队权限验证 - 内置metric允许跨团队访问 + if (String(metric.teamId) !== teamId) { + return Promise.reject(EvaluationErrEnum.evalMetricNotFound); + } + + // 所有者检查 + const isOwner = tmbPer.isOwner || String(metric.tmbId) === String(tmbId); + + // 权限计算 - 使用evaluation资源类型 + const { Per } = await (async () => { + if (isOwner) { + return { Per: new EvaluationPermission({ isOwner: true }) }; } - // Write per - await authAppByTmbId({ + // 获取evaluation资源的权限(evalMetric复用evaluation权限) + const role = await getTmbPermission({ + teamId, tmbId, - appId: evaluation.appId, - per: ManagePermissionVal, - isRoot + resourceId: metricId, + resourceType: PerResourceTypeEnum.evaluation }); - } catch (error) { - // If app does not exist, allow operation (app was deleted, allow eval cleanup) - if (error !== AppErrEnum.unExist) { - throw error; - } + + return { Per: new EvaluationPermission({ role, isOwner }) }; + })(); + + // 权限验证 + if (!Per.checkPer(per)) { + return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); } return { - teamId, + metric: { + ...metric, + permission: Per + } + }; +}; + +export const authEvalMetric = async ({ + metricId, + per = ReadPermissionVal, + ...props +}: AuthModeType & { + metricId: string; + per?: PermissionValueType; +}): Promise< + AuthResponseType & { + metric: any; + } +> => { + const result = await parseHeaderCert({ + ...props, + authApiKey: true // 添加API Key支持 + }); + const { tmbId } = result; + + if (!metricId) { + return Promise.reject(EvaluationErrEnum.evalMetricNotFound); + } + + const { metric } = await authEvalMetricByTmbId({ tmbId, - evaluation + metricId, + per, + isRoot: result.isRoot + }); + + return { + ...result, + permission: metric.permission, + metric }; }; diff --git a/packages/service/support/permission/inheritPermission.ts b/packages/service/support/permission/inheritPermission.ts index 84362fc2bdd5..e778bcdce2a2 100644 --- a/packages/service/support/permission/inheritPermission.ts +++ b/packages/service/support/permission/inheritPermission.ts @@ -10,11 +10,7 @@ import { mongoSessionRun } from '../../common/mongo/sessionRun'; import { getResourceOwnedClbs } from './controller'; import { MongoResourcePermission } from './schema'; import type { ClientSession, Model, AnyBulkWriteOperation } from '../../common/mongo'; -import { - getCollaboratorId, - mergeCollaboratorList, - sumPer -} from '@fastgpt/global/support/permission/utils'; +import { getCollaboratorId, sumPer } from '@fastgpt/global/support/permission/utils'; import type { CollaboratorItemType } from '@fastgpt/global/support/permission/collaborator'; import { pickCollaboratorIdFields } from './utils'; import { getLogger, LogCategories } from '../../common/logger'; @@ -28,6 +24,13 @@ export type SyncChildrenPermissionResourceType = { /** * sync the permission to all children folders. + * @param resource - 当前资源对象(需包含 teamId、type 等字段) + * @param folderTypeList - 文件夹类型列表,用于判断当前资源是否为文件夹 + * @param resourceType - 资源类型枚举(如 dataset、app 等) + * @param resourceModel - Mongoose 资源模型,用于查询子资源 + * @param session - MongoDB 事务 session,在 inheritPermission 为 true 时必须提供 + * @param collaborators - 最新的协作者列表,将同步至所有子资源 + * @param additionalFilter - 额外的查询过滤条件(例如 collection 查询时传入 { datasetId }) */ export async function syncChildrenPermission({ resource, @@ -36,7 +39,8 @@ export async function syncChildrenPermission({ resourceModel, session, - collaborators: latestClbList + collaborators: latestClbList, + additionalFilter = {} }: { resource: SyncChildrenPermissionResourceType; @@ -50,6 +54,8 @@ export async function syncChildrenPermission({ session: ClientSession; collaborators: CollaboratorItemType[]; + // additional filter for resource model query (e.g. { datasetId } for collections) + additionalFilter?: Record; }) { // only folder has permission const isFolder = folderTypeList.includes(resource.type); @@ -66,7 +72,8 @@ export async function syncChildrenPermission({ inheritPermission: true, type: { $in: folderTypeList - } + }, + ...additionalFilter }, '_id parentId' ) @@ -124,32 +131,37 @@ export async function syncChildrenPermission({ const _children = parentChildrenMap.get(parentId) || []; if (_children.length === 0) continue; for (const child of _children) { - // 1. get parent's permission and what permission I have. - const parentClbs = resourceIdClbMap.get(String(child.parentId)) || []; + // 1. get what permission I have. const myClbs = resourceIdClbMap.get(child._id) || []; const myClbsIdSet = new Set(myClbs.map((clb) => getCollaboratorId(clb))); + // 2. Find child's own owner(s) + const myOwnerIds = new Set( + myClbs.filter((clb) => clb.permission === OwnerRoleVal).map((clb) => getCollaboratorId(clb)) + ); + // add or update for (const latestClb of latestClbList) { - if (latestClb.permission === OwnerRoleVal) { + const latestClbId = getCollaboratorId(latestClb); + // Skip if child already has owner permission for this collaborator + if (myOwnerIds.has(latestClbId)) { continue; } - if (!myClbsIdSet.has(getCollaboratorId(latestClb))) { + const permission = + latestClb.permission === OwnerRoleVal ? ManageRoleVal : latestClb.permission; + if (!myClbsIdSet.has(latestClbId)) { ops.push({ insertOne: { document: { resourceId: child._id, resourceType, teamId, - permission: latestClb.permission, + permission, ...pickCollaboratorIdFields(latestClb) } as ResourcePermissionType } }); } else { - const myclb = myClbs.find( - (clb) => getCollaboratorId(latestClb) === getCollaboratorId(clb) - )!; ops.push({ updateOne: { filter: { @@ -159,25 +171,20 @@ export async function syncChildrenPermission({ resourceType }, update: { - permission: sumPer(myclb.permission, latestClb.permission) + $set: { permission } } } }); } } - // delele + // delete for (const myClb of myClbs) { - const parentClb = parentClbs.find( - (clb) => getCollaboratorId(clb) === getCollaboratorId(myClb) - ); - // the new collaborators doesnt have it, and the permission is same. - // remove it - if ( - !latestClbMap.get(getCollaboratorId(myClb)) && - parentClb && - myClb.permission === parentClb.permission - ) { + // Skip child's own owner permission + if (myClb.permission === OwnerRoleVal) { + continue; + } + if (!latestClbMap.get(getCollaboratorId(myClb))) { ops.push({ deleteOne: { filter: { @@ -206,67 +213,82 @@ export async function resumeInheritPermission({ folderTypeList, resourceType, resourceModel, - session + session, + parentClbs }: { resource: SyncChildrenPermissionResourceType; folderTypeList: string[]; resourceType: PerResourceTypeEnum; resourceModel: typeof Model; session?: ClientSession; + parentClbs?: CollaboratorItemType[]; }) { const logger = getLogger(LogCategories.MODULE.PERMISSION.INHERIT); const isFolder = folderTypeList.includes(resource.type); - // Folder resource, need to sync children - const [parentClbs, oldMyClbs] = await Promise.all([ - getResourceOwnedClbs({ - resourceId: resource.parentId, - teamId: resource.teamId, - resourceType - }), - getResourceOwnedClbs({ - resourceId: resource._id, - teamId: resource.teamId, - resourceType - }) - ]); - const parentOwner = parentClbs.find((clb) => clb.permission === OwnerRoleVal); + // Non-folder: delete all own clbs and set inheritPermission: true + if (!isFolder) { + const fn = async (session: ClientSession) => { + await MongoResourcePermission.deleteMany( + { + resourceId: resource._id, + resourceType, + teamId: resource.teamId, + permission: { $ne: OwnerRoleVal } + }, + { session } + ); + await resourceModel.updateOne( + { + _id: resource._id + }, + { + inheritPermission: true + }, + { session } + ); + }; + if (session) { + return fn(session); + } else { + return mongoSessionRun(fn); + } + } - const collaborators = mergeCollaboratorList({ - parentClbs, - childClbs: oldMyClbs - }); - const parentManage = collaborators.find( - (clb) => parentOwner?.tmbId && clb.tmbId && parentOwner?.tmbId === clb.tmbId - ); - if (parentManage) parentManage.permission = ManageRoleVal; + // Folder resource, need to sync children + const _parentClbs = parentClbs + ? parentClbs + : await getResourceOwnedClbs({ + resourceId: resource.parentId, + teamId: resource.teamId, + resourceType + }); - logger.debug('Resuming inherited permissions', { - resourceId: resource._id, - resourceType, - collaboratorCount: collaborators.length + const collaborators = _parentClbs.map((clb) => { + if (clb.permission === OwnerRoleVal) { + return { ...clb, permission: ManageRoleVal }; + } + return { ...clb }; }); const fn = async (session: ClientSession) => { - if (isFolder) { - // sync self - await syncCollaborators({ - resourceType, - collaborators, - teamId: resource.teamId, - resourceId: resource._id, - session - }); - // sync children - await syncChildrenPermission({ - resource, - resourceModel, - folderTypeList, - resourceType, - session, - collaborators - }); - } + // sync self (replace with parent clbs, not merge) + await replaceResourceClbs({ + resourceType, + collaborators, + teamId: resource.teamId, + resourceId: resource._id, + session + }); + // sync children + await syncChildrenPermission({ + resource, + resourceModel, + folderTypeList, + resourceType, + session, + collaborators + }); await resourceModel.updateOne( { @@ -329,7 +351,7 @@ export async function syncCollaborators({ ...pickCollaboratorIdFields(clb) }, update: { - permission + $set: { permission } } } }); @@ -355,3 +377,106 @@ export async function syncCollaborators({ await MongoResourcePermission.bulkWrite(ops, { session }); } + +/** + * Replace resource clbs with the given collaborators using a single bulkWrite. + * Uses incremental updates: update existing, insert new, delete removed. + * Owner permission is converted to manage. Remain self owner. + */ +export async function replaceResourceClbs({ + resourceType, + teamId, + resourceId, + collaborators, + session +}: { + resourceType: PerResourceTypeEnum; + teamId: string; + resourceId: string; + collaborators: CollaboratorItemType[]; + session: ClientSession; +}) { + const clbsNow = await MongoResourcePermission.find({ + resourceType, + teamId, + resourceId + }) + .lean() + .session(session); + + // Normalize incoming collaborators: owner -> manage + const normalizedCollaborators = collaborators.map((clb) => + clb.permission === OwnerRoleVal ? { ...clb, permission: ManageRoleVal } : clb + ); + const latestClbMap = new Map(normalizedCollaborators.map((clb) => [getCollaboratorId(clb), clb])); + const nowClbMap = new Map(clbsNow.map((clb) => [getCollaboratorId(clb), clb])); + + // Find existing owners (should not be modified or deleted) + const ownerIds = new Set( + clbsNow.filter((clb) => clb.permission === OwnerRoleVal).map((clb) => getCollaboratorId(clb)) + ); + + const ops: AnyBulkWriteOperation[] = []; + + // Update existing or insert new + for (const [id, latestClb] of latestClbMap) { + // Skip if existing clb is owner + if (ownerIds.has(id)) { + continue; + } + const nowClb = nowClbMap.get(id); + if (!nowClb) { + // Insert new + ops.push({ + insertOne: { + document: { + resourceType, + teamId, + resourceId, + ...pickCollaboratorIdFields(latestClb), + permission: latestClb.permission + } as ResourcePermissionType + } + }); + } else if (nowClb.permission !== latestClb.permission) { + // Update permission if changed + ops.push({ + updateOne: { + filter: { + resourceType, + teamId, + resourceId, + ...pickCollaboratorIdFields(latestClb) + }, + update: { + $set: { permission: latestClb.permission } + } + } + }); + } + } + + // Delete removed clbs + for (const [id, nowClb] of nowClbMap) { + // Skip existing owner + if (ownerIds.has(id)) { + continue; + } + if (!latestClbMap.has(id)) { + ops.push({ + deleteOne: { + filter: { + resourceType, + teamId, + resourceId, + ...pickCollaboratorIdFields(nowClb) + } + } + }); + } + } + + if (ops.length > 0) { + await MongoResourcePermission.bulkWrite(ops, { session }); + } +} diff --git a/packages/service/support/permission/model/auth.ts b/packages/service/support/permission/model/auth.ts new file mode 100644 index 000000000000..84c777ce71ea --- /dev/null +++ b/packages/service/support/permission/model/auth.ts @@ -0,0 +1,300 @@ +import { getModelById } from '../../../core/ai/model'; +import type { SystemModelItemType } from '../../../core/ai/type'; +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { ModelPermission } from '@fastgpt/global/support/permission/model/controller'; +import { getTmbInfoByTmbId } from '../../user/team/controller'; +import { parseHeaderCert } from '../auth/common'; +import type { AuthModeType, AuthResponseType } from '../type'; +import { getModelPermission } from './controller'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import type { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { ModelErrEnum } from '@fastgpt/global/common/error/code/model'; +import { MongoApp } from '../../../core/app/schema'; +import { MongoDataset } from '../../../core/dataset/schema'; +import { extractWorkflowModelIds } from '@fastgpt/global/core/workflow/utils'; +import { authAppByTmbId } from '../app/auth'; +import { authDatasetByTmbId } from '../dataset/auth'; + +const normalizeModelIds = (modelIds?: string | Array) => { + if (!modelIds) return []; + const ids = Array.isArray(modelIds) ? modelIds : [modelIds]; + return [...new Set(ids.filter((id): id is string => typeof id === 'string' && !!id))]; +}; + +/** + * Check if the user can access a model through a resource (app or dataset) context. + * When a model is already configured in an app/dataset that the user has access to, + * the user can use that model without having direct permission on it. + */ +const checkModelAccessThroughResource = async ({ + modelId, + teamId, + tmbId, + isRoot, + resourceContext +}: { + modelId: string; + teamId: string; + tmbId: string; + isRoot?: boolean; + resourceContext: { appId: string } | { datasetId: string }; +}): Promise => { + try { + if ('appId' in resourceContext) { + // Load the app and extract its current model IDs + const app = await MongoApp.findById( + resourceContext.appId, + 'modules chatConfig tmbId teamId' + ).lean(); + if (!app) return false; + + const existingModelIds = extractWorkflowModelIds({ + modules: app.modules, + chatConfig: app.chatConfig + }); + if (!existingModelIds.includes(modelId)) return false; + + // Check if the user has read permission on the app + await authAppByTmbId({ + tmbId, + appId: resourceContext.appId, + per: ReadPermissionVal, + isRoot + }); + return true; + } + + if ('datasetId' in resourceContext) { + // Load the dataset and check its model IDs + const dataset = await MongoDataset.findById( + resourceContext.datasetId, + 'vectorModelId agentModelId vlmModelId tmbId teamId' + ).lean(); + if (!dataset) return false; + + const existingModelIds = [ + dataset.vectorModelId, + dataset.agentModelId, + dataset.vlmModelId + ].filter((id): id is string => typeof id === 'string' && !!id); + + if (!existingModelIds.includes(modelId)) return false; + + // Check if the user has read permission on the dataset + await authDatasetByTmbId({ + tmbId, + datasetId: resourceContext.datasetId, + per: ReadPermissionVal, + isRoot + }); + return true; + } + + return false; + } catch { + return false; + } +}; + +export const authModelByTmbId = async ({ + tmbId, + modelId, + per, + isRoot, + resourceContext +}: { + tmbId: string; + modelId: string; + per: PermissionValueType; + isRoot?: boolean; + resourceContext?: { appId: string } | { datasetId: string }; +}): Promise<{ + model: SystemModelItemType & { + permission: ModelPermission; + }; +}> => { + const tmb = await getTmbInfoByTmbId({ tmbId }); + const model = getModelById(modelId); + + if (!model) { + return Promise.reject(ModelErrEnum.unExist); + } + + // If resourceContext is provided, check if the model can be accessed through the resource + if (resourceContext) { + const canBypass = await checkModelAccessThroughResource({ + modelId, + teamId: tmb.teamId, + tmbId, + isRoot, + resourceContext + }); + if (canBypass) { + return { + model: { + ...model, + permission: new ModelPermission({ isOwner: false, role: ReadPermissionVal }) + } + }; + } + } + + const permission = await getModelPermission({ + model, + teamId: tmb.teamId, + tmbId, + isRoot, + teamPer: tmb.permission + }); + + if (!permission.checkPer(per)) { + return Promise.reject(ERROR_ENUM.unAuthModel); + } + + return { + model: { + ...model, + permission + } + }; +}; + +export const authModel = async ({ + modelId, + per, + resourceContext, + ...props +}: AuthModeType & { + modelId: string; + per: PermissionValueType; + resourceContext?: { appId: string } | { datasetId: string }; +}): Promise< + AuthResponseType & { + model: SystemModelItemType & { + permission: ModelPermission; + }; + } +> => { + const result = await parseHeaderCert(props); + + if (!modelId) { + return Promise.reject(ModelErrEnum.invalidModelId); + } + + const { model } = await authModelByTmbId({ + tmbId: result.tmbId, + modelId, + per, + isRoot: result.isRoot, + resourceContext + }); + + return { + ...result, + permission: model.permission, + model + }; +}; + +export const assertModelAvailable = ( + model: SystemModelItemType, + { + type, + requireVision = false + }: { + type?: `${ModelTypeEnum}`; + requireVision?: boolean; + } = {} +) => { + if (!model.isActive) { + throw new Error(ModelErrEnum.modelNotActive); + } + + if (type && model.type !== type) { + throw new Error(ModelErrEnum.modelTypeNotSupported); + } + + if (requireVision && (!('vision' in model) || model.vision !== true)) { + throw new Error(ModelErrEnum.modelTypeNotSupported); + } +}; + +export const authModels = async ({ + modelIds, + per = ReadPermissionVal, + resourceContext, + ...props +}: AuthModeType & { + modelIds?: string | Array; + per?: PermissionValueType; + resourceContext?: { appId: string } | { datasetId: string }; +}): Promise< + AuthResponseType & { + models: (SystemModelItemType & { + permission: ModelPermission; + })[]; + } +> => { + const result = await parseHeaderCert(props); + const ids = normalizeModelIds(modelIds); + + if (ids.length === 0) { + return { + ...result, + permission: new ModelPermission(), + models: [] + }; + } + + const models = ids.map((id) => getModelById(id)); + + if (models.some((model) => !model)) { + return Promise.reject(ModelErrEnum.unExist); + } + + const tmb = await getTmbInfoByTmbId({ tmbId: result.tmbId }); + const modelsWithPermission = await Promise.all( + (models as SystemModelItemType[]).map(async (model) => { + // Check if this specific model can be accessed through the resource context + if (resourceContext) { + const canBypass = await checkModelAccessThroughResource({ + modelId: model.id, + teamId: tmb.teamId, + tmbId: result.tmbId, + isRoot: result.isRoot, + resourceContext + }); + if (canBypass) { + return { + ...model, + permission: new ModelPermission({ isOwner: false, role: ReadPermissionVal }) + }; + } + } + + const permission = await getModelPermission({ + model, + teamId: tmb.teamId, + tmbId: result.tmbId, + isRoot: result.isRoot, + teamPer: tmb.permission + }); + + if (!permission.checkPer(per)) { + return Promise.reject(ERROR_ENUM.unAuthModel); + } + + return { + ...model, + permission + }; + }) + ); + + return { + ...result, + permission: modelsWithPermission[0]?.permission ?? new ModelPermission(), + models: modelsWithPermission + }; +}; diff --git a/packages/service/support/permission/model/controller.ts b/packages/service/support/permission/model/controller.ts index 1311f45cdf77..1c98a9bc607e 100644 --- a/packages/service/support/permission/model/controller.ts +++ b/packages/service/support/permission/model/controller.ts @@ -1,47 +1,183 @@ -import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { + NullRoleVal, + PerResourceTypeEnum, + ReadPermissionVal, + ReadRoleVal +} from '@fastgpt/global/support/permission/constant'; +import { ModelPermission } from '@fastgpt/global/support/permission/model/controller'; import { getGroupsByTmbId } from '../memberGroup/controllers'; -import { getOrgsByTmbId } from '../org/controllers'; +import { getOrgIdSetWithParentByTmbId } from '../org/controllers'; import { MongoResourcePermission } from '../schema'; -import { getCollaboratorId } from '@fastgpt/global/support/permission/utils'; -import { isProVersion } from '../../../common/system/constants'; +import { sumPer } from '@fastgpt/global/support/permission/utils'; +import type { SystemModelItemType } from '../../../core/ai/type'; +import { getTmbPermission } from '../controller'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; -export const getMyModels = async ({ +const getModelCollaboratorRoleMap = async ({ teamId, - tmbId, - isTeamOwner + tmbId }: { teamId: string; tmbId: string; - isTeamOwner: boolean; }) => { - if (isTeamOwner || !isProVersion()) { - return global.systemModelList.map((m) => m.model); - } - const [groups, orgs] = await Promise.all([ + const [groups, orgIdSet, rps] = await Promise.all([ getGroupsByTmbId({ teamId, tmbId }), - getOrgsByTmbId({ + getOrgIdSetWithParentByTmbId({ teamId, tmbId - }) + }), + MongoResourcePermission.find({ + teamId, + resourceType: PerResourceTypeEnum.model + }).lean() ]); - const myIdSet = new Set([tmbId, ...groups.map((g) => g._id), ...orgs.map((o) => o._id)]); + const groupIdSet = new Set(groups.map((g) => String(g._id))); + const tmbRoleMap = new Map(); + const groupAndOrgRoleMap = new Map(); + + for (const rp of rps) { + const resourceId = String(rp.resourceId); + if (rp.tmbId && String(rp.tmbId) === String(tmbId)) { + tmbRoleMap.set(resourceId, rp.permission); + continue; + } + if ( + (rp.groupId && groupIdSet.has(String(rp.groupId))) || + (rp.orgId && orgIdSet.has(String(rp.orgId))) + ) { + const roles = groupAndOrgRoleMap.get(resourceId) ?? []; + roles.push(rp.permission); + groupAndOrgRoleMap.set(resourceId, roles); + } + } + + const permissionMap = new Map(); + + groupAndOrgRoleMap.forEach((roles, resourceId) => { + permissionMap.set(resourceId, sumPer(...roles) ?? NullRoleVal); + }); + tmbRoleMap.forEach((role, resourceId) => { + permissionMap.set(resourceId, role); + }); + + return permissionMap; +}; + +const getModelPermissionFromRole = ({ + model, + teamId, + tmbId, + teamPer, + isRoot, + collaboratorRole +}: { + model: SystemModelItemType; + teamId: string; + tmbId: string; + teamPer?: { isOwner: boolean }; + isRoot?: boolean; + collaboratorRole?: PermissionValueType; +}) => { + if (isRoot) { + return new ModelPermission({ isOwner: true }); + } + + const isOwner = + String(model.tmbId) === String(tmbId) || + (teamPer?.isOwner && model.teamId && String(model.teamId) === String(teamId)); + + if (isOwner) { + return new ModelPermission({ isOwner: true }); + } + + const permission = new ModelPermission({ role: collaboratorRole ?? NullRoleVal }); - const rps = await MongoResourcePermission.find({ + if (model.isShared) { + permission.addRole(ReadRoleVal); + } + + return permission; +}; + +export const getModelPermission = async ({ + model, + teamId, + tmbId, + isRoot, + teamPer +}: { + model: SystemModelItemType; + teamId: string; + tmbId: string; + isRoot?: boolean; + teamPer?: { isOwner: boolean }; +}) => { + if ( + isRoot || + String(model.tmbId) === String(tmbId) || + (teamPer?.isOwner && model.teamId && String(model.teamId) === String(teamId)) + ) { + return getModelPermissionFromRole({ + model, + teamId, + tmbId, + isRoot, + teamPer + }); + } + + const collaboratorRole = await getTmbPermission({ + resourceType: PerResourceTypeEnum.model, teamId, - resourceType: PerResourceTypeEnum.model - }).lean(); + tmbId, + resourceId: model.id + }); - // 未配置权限的,默认是有权限 - const permissionConfiguredModelSet = new Set(rps.map((rp) => rp.resourceName)); - const unconfiguredModels = global.systemModelList.filter( - (model) => !permissionConfiguredModelSet.has(model.model) - ); + return getModelPermissionFromRole({ + model, + teamId, + tmbId, + isRoot, + teamPer, + collaboratorRole + }); +}; - const myModels = rps.filter((rp) => myIdSet.has(getCollaboratorId(rp))); +export const getModelListWithPermission = async ({ + models, + teamId, + tmbId, + teamPer, + isRoot +}: { + models: SystemModelItemType[]; + teamId: string; + tmbId: string; + teamPer: { isOwner: boolean }; + isRoot?: boolean; +}) => { + const collaboratorRoleMap = isRoot + ? new Map() + : await getModelCollaboratorRoleMap({ + teamId, + tmbId + }); - return [...unconfiguredModels.map((m) => m.model), ...myModels.map((m) => m.resourceName)]; + return models + .map((model) => ({ + ...model, + permission: getModelPermissionFromRole({ + model, + teamId, + tmbId, + teamPer, + isRoot, + collaboratorRole: model.id ? collaboratorRoleMap.get(String(model.id)) : NullRoleVal + }) + })) + .filter((model) => model.permission.checkPer(ReadPermissionVal)); }; diff --git a/packages/service/support/permission/model/reference.ts b/packages/service/support/permission/model/reference.ts new file mode 100644 index 000000000000..530b2b4eebc7 --- /dev/null +++ b/packages/service/support/permission/model/reference.ts @@ -0,0 +1,95 @@ +import { MongoApp } from '../../../core/app/schema'; +import { MongoDataset } from '../../../core/dataset/schema'; +import { MongoTeamMember } from '../../user/team/teamMemberSchema'; +import { extractWorkflowModelIds } from '@fastgpt/global/core/workflow/utils'; + +export type ModelReference = { + resourceType: 'app' | 'dataset'; + resourceId: string; + resourceName: string; + creatorTmbId: string; + creatorName: string; +}; + +/** + * Find all apps and datasets that reference a given model ID. + * Used to warn model owners before they revoke model permissions or delete the model. + */ +export async function findReferencingResources( + modelId: string, + teamId: string +): Promise { + // 1. Find apps that reference this model in their workflow nodes + const apps = await MongoApp.find( + { + teamId, + deleteTime: null, + modules: { + $elemMatch: { + inputs: { + $elemMatch: { + value: modelId + } + } + } + } + }, + 'name tmbId modules chatConfig' + ).lean(); + + // Filter apps that actually reference the model (exact match, not substring) + const filteredApps = apps.filter((app) => { + const modelIds = extractWorkflowModelIds({ + modules: app.modules, + chatConfig: app.chatConfig + }); + return modelIds.includes(modelId); + }); + + // 2. Find datasets that reference this model directly + const datasets = await MongoDataset.find( + { + teamId, + deleteTime: null, + $or: [{ vectorModelId: modelId }, { agentModelId: modelId }, { vlmModelId: modelId }] + }, + 'name tmbId' + ).lean(); + + // Combine all candidate resources + const candidates: { + resourceType: 'app' | 'dataset'; + resourceId: string; + resourceName: string; + ownerTmbId: string; + }[] = [ + ...filteredApps.map((app) => ({ + resourceType: 'app' as const, + resourceId: String(app._id), + resourceName: app.name, + ownerTmbId: String(app.tmbId) + })), + ...datasets.map((ds) => ({ + resourceType: 'dataset' as const, + resourceId: String(ds._id), + resourceName: ds.name, + ownerTmbId: String(ds.tmbId) + })) + ]; + + if (candidates.length === 0) return []; + + // Batch query: fetch all creator names in one query + const tmbIds = [...new Set(candidates.map((c) => c.ownerTmbId))]; + const members = await MongoTeamMember.find({ _id: { $in: tmbIds } }, 'name').lean(); + const nameMap = new Map(members.map((m) => [String(m._id), m.name || ''])); + + // Build results + return candidates.map((c) => ({ + resourceType: c.resourceType, + resourceId: c.resourceId, + resourceName: c.resourceName, + creatorTmbId: c.ownerTmbId, + creatorName: nameMap.get(c.ownerTmbId) || '' + })); +} diff --git a/packages/service/support/permission/teamLimit.ts b/packages/service/support/permission/teamLimit.ts index 2d6647cfb2d0..6b72bb40eff0 100644 --- a/packages/service/support/permission/teamLimit.ts +++ b/packages/service/support/permission/teamLimit.ts @@ -8,14 +8,51 @@ import { AppTypeEnum, ToolTypeList, AppFolderTypeList } from '@fastgpt/global/co import { MongoTeamMember } from '../user/team/teamMemberSchema'; import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; import { getVectorCountByTeamId } from '../../common/vectorDB/controller'; +import { MongoEvaluation } from '../../core/evaluation/task/schema'; +import { MongoEvalDatasetCollection } from '../../core/evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '../../core/evaluation/dataset/evalDatasetDataSchema'; +import { MongoEvalMetric } from '../../core/evaluation/metric/schema'; +import { addLog } from '../../common/system/log'; import { env } from '../../env'; export const checkTeamAIPoints = async (teamId: string) => { - if (!global.subPlans?.standard) return; + addLog.debug('[checkTeamAIPoints] Global configuration', { + hasSubPlans: !!global.subPlans, + hasStandardPlan: !!global.subPlans?.standard, + subPlansKeys: global.subPlans ? Object.keys(global.subPlans) : [], + standardPlanKeys: global.subPlans?.standard ? Object.keys(global.subPlans.standard) : [], + hasSystemEnv: !!global.systemEnv, + hasFeConfigs: !!global.feConfigs, + hasLicenseData: !!global.licenseData + }); + + if (!global.subPlans?.standard) { + addLog.debug('[checkTeamAIPoints] Skip check - no standard plan configured', { + teamId, + hasSubPlans: !!global.subPlans, + hasStandardPlan: !!global.subPlans?.standard + }); + return; + } + + const { totalPoints, usedPoints, surplusPoints } = await teamPoint.getTeamPoints({ teamId }); - const { totalPoints, usedPoints } = await teamPoint.getTeamPoints({ teamId }); + addLog.debug('[checkTeamAIPoints] Team points balance check', { + teamId, + totalPoints, + usedPoints, + surplusPoints, + isEnough: usedPoints < totalPoints + }); if (usedPoints >= totalPoints) { + addLog.debug('[checkTeamAIPoints] Insufficient AI points', { + teamId, + totalPoints, + usedPoints, + surplusPoints, + deficit: usedPoints - totalPoints + }); return Promise.reject(TeamErrEnum.aiPointsNotEnough); } @@ -72,7 +109,12 @@ export const checkTeamAppTypeLimit = async ({ MongoApp.countDocuments({ teamId, type: { - $in: [AppTypeEnum.chatAgent, AppTypeEnum.simple, AppTypeEnum.workflow] + $in: [ + AppTypeEnum.chatAgent, + AppTypeEnum.simple, + AppTypeEnum.workflow, + AppTypeEnum.assistant + ] } }) ]); @@ -85,7 +127,12 @@ export const checkTeamAppTypeLimit = async ({ if (global?.licenseData?.maxApps && typeof global?.licenseData?.maxApps === 'number') { const totalApps = await MongoApp.countDocuments({ type: { - $in: [AppTypeEnum.simple, AppTypeEnum.workflow] + $in: [ + AppTypeEnum.chatAgent, + AppTypeEnum.simple, + AppTypeEnum.workflow, + AppTypeEnum.assistant + ] } }); if (totalApps > global.licenseData.maxApps) { @@ -173,3 +220,103 @@ export const checkTeamDatasetSyncPermission = async (teamId: string) => { return Promise.reject(TeamErrEnum.websiteSyncNotEnough); } }; + +export const checkTeamEvaluationTaskLimit = async (teamId: string, amount = 1) => { + const [{ standardConstants }, evaluationTaskCount] = await Promise.all([ + getTeamStandPlan({ teamId }), + MongoEvaluation.countDocuments({ teamId }) + ]); + + if ( + standardConstants && + standardConstants.maxEvaluationTaskAmount !== undefined && + evaluationTaskCount + amount > standardConstants.maxEvaluationTaskAmount + ) { + return Promise.reject(TeamErrEnum.evaluationTaskAmountNotEnough); + } + + if ( + global?.licenseData?.maxEvaluationTaskAmount && + typeof global?.licenseData?.maxEvaluationTaskAmount === 'number' + ) { + const totalEvaluationTasks = await MongoEvaluation.countDocuments({}); + if (totalEvaluationTasks >= global.licenseData.maxEvaluationTaskAmount) { + return Promise.reject(SystemErrEnum.licenseEvaluationTaskAmountLimit); + } + } +}; + +export const checkTeamEvalDatasetLimit = async (teamId: string, amount = 1) => { + const [{ standardConstants }, evalDatasetCount] = await Promise.all([ + getTeamStandPlan({ teamId }), + MongoEvalDatasetCollection.countDocuments({ teamId }) + ]); + + if ( + standardConstants && + standardConstants.maxEvalDatasetAmount !== undefined && + evalDatasetCount + amount > standardConstants.maxEvalDatasetAmount + ) { + return Promise.reject(TeamErrEnum.evaluationDatasetAmountNotEnough); + } + + if ( + global?.licenseData?.maxEvalDatasetAmount && + typeof global?.licenseData?.maxEvalDatasetAmount === 'number' + ) { + const totalEvalDatasets = await MongoEvalDatasetCollection.countDocuments({}); + if (totalEvalDatasets >= global.licenseData.maxEvalDatasetAmount) { + return Promise.reject(SystemErrEnum.licenseEvalDatasetAmountLimit); + } + } +}; + +export const checkTeamEvalDatasetDataLimit = async (teamId: string, amount = 1) => { + const [{ standardConstants }, evalDatasetDataCount] = await Promise.all([ + getTeamStandPlan({ teamId }), + MongoEvalDatasetData.countDocuments({ teamId }) + ]); + + if ( + standardConstants && + standardConstants.maxEvalDatasetDataAmount !== undefined && + evalDatasetDataCount + amount > standardConstants.maxEvalDatasetDataAmount + ) { + return Promise.reject(TeamErrEnum.evaluationDatasetDataAmountNotEnough); + } + + if ( + global?.licenseData?.maxEvalDatasetDataAmount && + typeof global?.licenseData?.maxEvalDatasetDataAmount === 'number' + ) { + const totalEvalDatasetData = await MongoEvalDatasetData.countDocuments({}); + if (totalEvalDatasetData >= global.licenseData.maxEvalDatasetDataAmount) { + return Promise.reject(SystemErrEnum.licenseEvalDatasetDataAmountLimit); + } + } +}; + +export const checkTeamEvalMetricLimit = async (teamId: string, amount = 1) => { + const [{ standardConstants }, evalMetricCount] = await Promise.all([ + getTeamStandPlan({ teamId }), + MongoEvalMetric.countDocuments({ teamId }) + ]); + + if ( + standardConstants && + standardConstants.maxEvalMetricAmount !== undefined && + evalMetricCount + amount > standardConstants.maxEvalMetricAmount + ) { + return Promise.reject(TeamErrEnum.evaluationMetricAmountNotEnough); + } + + if ( + global?.licenseData?.maxEvalMetricAmount && + typeof global?.licenseData?.maxEvalMetricAmount === 'number' + ) { + const totalEvalMetrics = await MongoEvalMetric.countDocuments({}); + if (totalEvalMetrics >= global.licenseData.maxEvalMetricAmount) { + return Promise.reject(SystemErrEnum.licenseEvalMetricAmountLimit); + } + } +}; diff --git a/packages/service/support/permission/train/embedding/auth.ts b/packages/service/support/permission/train/embedding/auth.ts new file mode 100644 index 000000000000..5924f843c04b --- /dev/null +++ b/packages/service/support/permission/train/embedding/auth.ts @@ -0,0 +1,97 @@ +import type { AuthModeType } from '../../type'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { authDataset } from '../../dataset/auth'; +import { MongoEmbeddingTrainset } from '../../../../core/train/embedding/trainset/schema'; +import { MongoEmbeddingTrainTask } from '../../../../core/train/embedding/task/schema'; +import { EmbeddingTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { calculateEmbeddingTrainsetStats } from '../../../../core/train/embedding/data/controller'; +import { authCert } from '../../auth/common'; + +/** + * Embedding trainset permission authentication + * + * Since trainsets are now decoupled from Apps, authentication is done by teamId ownership. + * Returns trainset with dynamically calculated statistics. + */ +export async function authEmbeddingTrainset({ + trainsetId, + per, + ...props +}: AuthModeType & { + trainsetId: string; + per: PermissionValueType; +}) { + const trainset = await MongoEmbeddingTrainset.findById(trainsetId).lean(); + if (!trainset) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTrainsetNotExist); + } + + // Validate certificate and check team ownership + const result = await authCert(props); + if (String(result.teamId) !== String(trainset.teamId)) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTrainsetNotExist); + } + + // Dynamically calculate statistics + const statistics = await calculateEmbeddingTrainsetStats(trainsetId); + + return { + ...result, + trainset: { + ...trainset, + statistics + } + }; +} + +/** + * Authenticate permission to generate training data from datasets + */ +export async function authGenerateFromDatasets({ + datasetIds, + ...props +}: AuthModeType & { + datasetIds: string[]; +}) { + // Validate read permission for each dataset + const datasets = await Promise.all( + datasetIds.map(async (datasetId) => { + const { dataset } = await authDataset({ + ...props, + datasetId, + per: ReadPermissionVal + }); + return dataset; + }) + ); + + return { datasets }; +} + +/** + * Embedding training task permission authentication + * + * Since tasks are now decoupled from Apps, authentication is done by teamId ownership. + */ +export async function authEmbeddingTrainTask({ + taskId, + per, + ...props +}: AuthModeType & { + taskId: string; + per: PermissionValueType; +}) { + const task = await MongoEmbeddingTrainTask.findById(taskId).lean(); + if (!task) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskNotExist); + } + + // Validate certificate and check team ownership + const result = await authCert(props); + if (String(result.teamId) !== String(task.teamId)) { + return Promise.reject(EmbeddingTrainErrEnum.embeddingTaskNotExist); + } + + return { ...result, task }; +} diff --git a/packages/service/support/permission/train/rerank/auth.ts b/packages/service/support/permission/train/rerank/auth.ts new file mode 100644 index 000000000000..6062409abacf --- /dev/null +++ b/packages/service/support/permission/train/rerank/auth.ts @@ -0,0 +1,97 @@ +import type { AuthModeType } from '../../type'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { authDataset } from '../../dataset/auth'; +import { MongoRerankTrainset } from '../../../../core/train/rerank/trainset/schema'; +import { MongoRerankTrainTask } from '../../../../core/train/rerank/task/schema'; +import { RerankTrainErrEnum } from '@fastgpt/global/common/error/code/train'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { calculateRerankTrainsetStats } from '../../../../core/train/rerank/data/controller'; +import { authCert } from '../../auth/common'; + +/** + * Rerank trainset permission authentication + * + * Since trainsets are now decoupled from Apps, authentication is done by teamId ownership. + * Returns trainset with dynamically calculated statistics. + */ +export async function authRerankTrainset({ + trainsetId, + per, + ...props +}: AuthModeType & { + trainsetId: string; + per: PermissionValueType; +}) { + const trainset = await MongoRerankTrainset.findById(trainsetId).lean(); + if (!trainset) { + return Promise.reject(RerankTrainErrEnum.rerankTrainsetNotExist); + } + + // Validate certificate and check team ownership + const result = await authCert(props); + if (String(result.teamId) !== String(trainset.teamId)) { + return Promise.reject(RerankTrainErrEnum.rerankTrainsetNotExist); + } + + // Dynamically calculate statistics + const statistics = await calculateRerankTrainsetStats(trainsetId); + + return { + ...result, + trainset: { + ...trainset, + statistics + } + }; +} + +/** + * Authenticate permission to generate training data from datasets + */ +export async function authGenerateFromDatasets({ + datasetIds, + ...props +}: AuthModeType & { + datasetIds: string[]; +}) { + // Validate read permission for each dataset + const datasets = await Promise.all( + datasetIds.map(async (datasetId) => { + const { dataset } = await authDataset({ + ...props, + datasetId, + per: ReadPermissionVal + }); + return dataset; + }) + ); + + return { datasets }; +} + +/** + * Rerank training task permission authentication + * + * Since tasks are now decoupled from Apps, authentication is done by teamId ownership. + */ +export async function authRerankTrainTask({ + taskId, + per, + ...props +}: AuthModeType & { + taskId: string; + per: PermissionValueType; +}) { + const task = await MongoRerankTrainTask.findById(taskId).lean(); + if (!task) { + return Promise.reject(RerankTrainErrEnum.rerankTaskNotExist); + } + + // Validate certificate and check team ownership + const result = await authCert(props); + if (String(result.teamId) !== String(task.teamId)) { + return Promise.reject(RerankTrainErrEnum.rerankTaskNotExist); + } + + return { ...result, task }; +} diff --git a/packages/service/support/user/agentUser.ts b/packages/service/support/user/agentUser.ts new file mode 100644 index 000000000000..6b5664908163 --- /dev/null +++ b/packages/service/support/user/agentUser.ts @@ -0,0 +1,38 @@ +import { MongoUser } from './schema'; +import { MongoTeamMember } from './team/teamMemberSchema'; + +let cachedAgentUserTmbIds: Set | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 60 * 1000; // 1 minute + +/** + * 获取内置 agent user 的 tmbId 集合(带内存缓存) + * agent user 列表由 AGENT_USERS 环境变量配置,默认为 agent_user_1/2/3 + */ +export async function getAgentUserTmbIds(): Promise> { + const now = Date.now(); + if (cachedAgentUserTmbIds && now - cacheTimestamp < CACHE_TTL) { + return cachedAgentUserTmbIds; + } + + const usernames = process.env.AGENT_USERS + ? process.env.AGENT_USERS.split(',') + .map((s) => s.trim()) + .filter(Boolean) + : ['agent_user_1', 'agent_user_2', 'agent_user_3']; + + const users = await MongoUser.find({ username: { $in: usernames } }, '_id').lean(); + const userIds = users.map((u) => u._id); + + if (userIds.length === 0) { + cachedAgentUserTmbIds = new Set(); + cacheTimestamp = now; + return cachedAgentUserTmbIds; + } + + const teamMembers = await MongoTeamMember.find({ userId: { $in: userIds } }, '_id').lean(); + + cachedAgentUserTmbIds = new Set(teamMembers.map((tm) => String(tm._id))); + cacheTimestamp = now; + return cachedAgentUserTmbIds; +} diff --git a/packages/service/support/user/audit/util.ts b/packages/service/support/user/audit/util.ts index 496ed4b18c98..35324be3256d 100644 --- a/packages/service/support/user/audit/util.ts +++ b/packages/service/support/user/audit/util.ts @@ -1,7 +1,7 @@ import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; import { AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { MongoTeamAudit } from './schema'; import type { AdminAuditEventEnum, @@ -43,6 +43,7 @@ export function getI18nDatasetType(type: DatasetTypeEnum | string): string { if (type === DatasetTypeEnum.apiDataset) return i18nT('account_team:dataset.api_file'); if (type === DatasetTypeEnum.feishu) return i18nT('account_team:dataset.feishu_dataset'); if (type === DatasetTypeEnum.yuque) return i18nT('account_team:dataset.yuque_dataset'); + if (type === DatasetTypeEnum.database) return i18nT('dataset:database'); return i18nT('common:UnKnow'); } @@ -52,6 +53,15 @@ export function getI18nSkillType(type: AgentSkillTypeEnum | string): string { return i18nT('common:UnKnow'); } +export function getI18nModelType(type: string): string { + if (type === 'llm') return i18nT('common:model.type.chat'); + if (type === 'embedding') return i18nT('common:model.type.embedding'); + if (type === 'rerank') return i18nT('common:model.type.reRank'); + if (type === 'tts') return i18nT('common:model.type.tts'); + if (type === 'stt') return i18nT('common:model.type.stt'); + return i18nT('common:UnKnow'); +} + export function getI18nInformLevel(level: string): string { if (level === 'common') return i18nT('account_team:inform_level_common'); if (level === 'important') return i18nT('account_team:inform_level_important'); diff --git a/packages/service/support/user/auth/controller.ts b/packages/service/support/user/auth/controller.ts index 0609de3d612d..626cae7a848a 100644 --- a/packages/service/support/user/auth/controller.ts +++ b/packages/service/support/user/auth/controller.ts @@ -1,9 +1,9 @@ import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { MongoUserAuth } from './schema'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { UserError } from '@fastgpt/global/common/error/utils'; -import { z } from 'zod'; +import z from 'zod'; export const addAuthCode = async ({ key, diff --git a/packages/service/support/user/session.ts b/packages/service/support/user/session.ts index 106dd84e670b..3a4fbfa76c8f 100644 --- a/packages/service/support/user/session.ts +++ b/packages/service/support/user/session.ts @@ -1,5 +1,6 @@ import { retryFn } from '@fastgpt/global/common/system/utils'; import { getAllKeysByPrefix, getGlobalRedisConnection } from '../../common/redis'; +import { deleteKeys } from '../../common/redis/cluster'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getLogger, LogCategories } from '../../common/logger'; @@ -54,9 +55,10 @@ const setSession = async ({ }); }; -const delSession = (key: string) => { +const delSession = async (key: string) => { const redis = getGlobalRedisConnection(); - retryFn(() => redis.del(getSessionKey(key))); + const fullKey = getSessionKey(key); + await retryFn(() => redis.del(fullKey)); }; const getSession = async (key: string): Promise => { @@ -86,14 +88,17 @@ const getSession = async (key: string): Promise => { } }; export const delUserAllSession = async (userId: string, whiteList?: (string | undefined)[]) => { - const formatWhiteList = whiteList?.map((item) => item && getSessionKey(item)); + // Whitelist items are in format "userId:sessionId", convert to "session:userId:sessionId" to match getAllKeysByPrefix return format + const formatWhiteList = whiteList?.map((item) => item && `${redisPrefix}${item}`); const redis = getGlobalRedisConnection(); + const keys = (await getAllKeysByPrefix(`${redisPrefix}${String(userId)}`)).filter( (item) => !formatWhiteList?.includes(item) ); if (keys.length > 0) { - await redis.del(keys); + // getAllKeysByPrefix returns keys without prefix, redis.del will auto-add keyPrefix + await deleteKeys(redis, keys); } }; @@ -116,11 +121,12 @@ const delRedundantSession = async (userId: string) => { const sessionList = await Promise.all( keys.map(async (key) => { try { + // getAllKeysByPrefix returns keys without prefix, redis.hgetall will auto-add keyPrefix const data = await redis.hgetall(key); if (!data || Object.keys(data).length === 0) return null; return { - key, + key, // Store key for deletion (redis.del will auto-add keyPrefix) createdAt: parseInt(data.createdAt) }; } catch (error) { @@ -138,7 +144,7 @@ const delRedundantSession = async (userId: string) => { const delKeys = validSessions.slice(0, validSessions.length - maxSession).map((item) => item.key); if (delKeys.length > 0) { - await redis.del(delKeys); + await deleteKeys(redis, delKeys); } }; export const createUserSession = async ({ @@ -169,7 +175,7 @@ export const createUserSession = async ({ expireSeconds: 7 * 24 * 60 * 60 }); - delRedundantSession(userId); + await delRedundantSession(userId); return key; }; diff --git a/packages/service/support/user/team/delete/processor.ts b/packages/service/support/user/team/delete/processor.ts index 766c4775bf91..230b7d795ddf 100644 --- a/packages/service/support/user/team/delete/processor.ts +++ b/packages/service/support/user/team/delete/processor.ts @@ -18,8 +18,6 @@ import { MongoDiscountCoupon } from '../../../wallet/discountCoupon/schema'; import { MongoTeamAudit } from '../../audit/schema'; import { deleteTeamAllDatasets } from '../../../../core/dataset/delete/processor'; import { onDelAllApp } from './utils'; -import { MongoEvaluation } from '../../../../core/app/evaluation/evalSchema'; -import { MongoEvalItem } from '../../../../core/app/evaluation/evalItemSchema'; import { MongoTeamSub } from '../../../../support/wallet/sub/schema'; import { getLogger, LogCategories } from '../../../../common/logger'; @@ -42,14 +40,14 @@ export const teamDeleteProcessor: Processor = async (job) => // 2. 先删除知识库和应用(它们内部有自己的队列) await deleteTeamAllDatasets(teamId); await onDelAllApp(teamId); - // 删除评估 - await MongoEvaluation.deleteMany({ - teamId - }); - // 删除评估项 - await MongoEvalItem.deleteMany({ - teamId - }); + // // 删除评估 + // await MongoEvaluation.deleteMany({ + // teamId + // }); + // // 删除评估项 + // await MongoEvalItem.deleteMany({ + // teamId + // }); // 删除图片(旧的了) await MongoImage.deleteMany({ diff --git a/packages/service/support/user/team/utils.ts b/packages/service/support/user/team/utils.ts index c6745a80f534..eff0995f2534 100644 --- a/packages/service/support/user/team/utils.ts +++ b/packages/service/support/user/team/utils.ts @@ -2,6 +2,45 @@ import { MongoTeamMember } from '../../user/team/teamMemberSchema'; import { type UserModelSchema } from '@fastgpt/global/support/user/type'; import { type TeamSchema } from '@fastgpt/global/support/user/team/type'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; +import { MongoUser } from '../schema'; + +export async function getTmbIdByUsername(username: string, teamId: string) { + const user = await MongoUser.findOne({ username }, '_id'); + if (!user) return null; + const tmb = await MongoTeamMember.findOne({ userId: user._id, teamId }, '_id'); + return tmb?._id?.toString() || null; +} + +/** Batch resolve usernames to tmbIds within a team. Returns username → tmbId map. */ +export async function getTmbIdsByUsernames( + usernames: string[], + teamId: string +): Promise> { + const map = new Map(); + if (usernames.length === 0) return map; + + const users = await MongoUser.find({ username: { $in: usernames } }, '_id username').lean(); + if (users.length === 0) return map; + + const usernameToUserId = new Map(users.map((u) => [u.username, u._id])); + const userIds = users.map((u) => u._id); + + const tmbs = await MongoTeamMember.find( + { userId: { $in: userIds }, teamId }, + '_id userId' + ).lean(); + + const userIdToTmbId = new Map(tmbs.map((t) => [t.userId.toString(), t._id.toString()])); + + for (const username of usernames) { + const userId = usernameToUserId.get(username); + if (!userId) continue; + const tmbId = userIdToTmbId.get(userId.toString()); + if (tmbId) map.set(username, tmbId); + } + + return map; +} // TODO: 数据库优化 export async function getRunningUserInfoByTmbId(tmbId: string) { diff --git a/packages/service/support/wallet/sub/schema.ts b/packages/service/support/wallet/sub/schema.ts index 33b06edde52f..fb5f0dd267e8 100644 --- a/packages/service/support/wallet/sub/schema.ts +++ b/packages/service/support/wallet/sub/schema.ts @@ -56,6 +56,10 @@ const SubSchema = new Schema({ maxTeamMember: Number, maxApp: Number, maxDataset: Number, + maxEvaluationTaskAmount: Number, + maxEvalDatasetAmount: Number, + maxEvalDatasetDataAmount: Number, + maxEvalMetricAmount: Number, // custom level configurations requestsPerMinute: Number, diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index dd5b373a7e12..51a4c8aeb1fd 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -23,6 +23,7 @@ import { CacheKeyEnumTime, incrValueToCache } from '../../../common/redis/cache'; +import { addLog } from '../../../common/system/log'; import { getLogger, LogCategories } from '../../../common/logger'; const logger = getLogger(LogCategories.MODULE.WALLET.SUB); @@ -66,7 +67,13 @@ export const buildStandardPlan = ( customDomain: standard?.customDomain ?? standardConstants.customDomain, maxUploadFileSize: standard?.maxUploadFileSize ?? standardConstants.maxUploadFileSize, maxUploadFileCount: standard?.maxUploadFileCount ?? standardConstants.maxUploadFileCount, - enableSandbox: standard?.enableSandbox ?? standardConstants.enableSandbox + enableSandbox: standard?.enableSandbox ?? standardConstants.enableSandbox, + maxEvaluationTaskAmount: + standard?.maxEvaluationTaskAmount || standardConstants.maxEvaluationTaskAmount, + maxEvalDatasetAmount: standard?.maxEvalDatasetAmount || standardConstants.maxEvalDatasetAmount, + maxEvalDatasetDataAmount: + standard?.maxEvalDatasetDataAmount || standardConstants.maxEvalDatasetDataAmount, + maxEvalMetricAmount: standard?.maxEvalMetricAmount || standardConstants.maxEvalMetricAmount }); export const initTeamFreePlan = async ({ @@ -183,10 +190,11 @@ export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { ] : undefined; + const plan = standardConstants ? buildStandardPlan(standard, standardConstants) : undefined; + return { - [SubTypeEnum.standard]: standardConstants - ? buildStandardPlan(standard, standardConstants) - : undefined + [SubTypeEnum.standard]: plan, + standardConstants: plan }; }; @@ -285,19 +293,35 @@ export const teamPoint = { if (surplusCacheStr && totalCacheStr) { const totalPoints = Number(totalCacheStr); const surplusPoints = Number(surplusCacheStr); - return { + const result = { totalPoints, surplusPoints, usedPoints: totalPoints - surplusPoints }; + addLog.debug('[teamPoint.getTeamPoints] Load from Redis cache', { + teamId, + source: 'redis', + ...result + }); + return result; } + addLog.debug('[teamPoint.getTeamPoints] Redis cache miss, loading from database', { + teamId, + source: 'database' + }); const planStatus = await getTeamPlanStatus({ teamId }); - return { + const result = { totalPoints: planStatus.totalPoints, surplusPoints: planStatus.totalPoints - planStatus.usedPoints, usedPoints: planStatus.usedPoints }; + addLog.debug('[teamPoint.getTeamPoints] Loaded from database', { + teamId, + source: 'database', + ...result + }); + return result; }, incrTeamPointsCache: async ({ teamId, value }: { teamId: string; value: number }) => { const surplusCacheKey = `${CacheKeyEnum.team_point_surplus}:${teamId}`; diff --git a/packages/service/support/wallet/usage/controller.ts b/packages/service/support/wallet/usage/controller.ts index a3f5097d5a57..95098e37db73 100644 --- a/packages/service/support/wallet/usage/controller.ts +++ b/packages/service/support/wallet/usage/controller.ts @@ -7,17 +7,65 @@ import type { ConcatUsageProps, CreateUsageProps } from '@fastgpt/global/support/wallet/usage/api'; -import { i18nT } from '../../../../web/i18n/utils'; +import { i18nT } from '../../../../global/common/i18n/utils'; import { formatModelChars2Points } from './utils'; -import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { getModelById } from '../../../core/ai/model'; import { MongoUsageItem } from './usageItemSchema'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { getLogger, LogCategories } from '../../../common/logger'; +import { isProVersion } from '../../../common/system/constants'; const logger = getLogger(LogCategories.MODULE.WALLET.USAGE); export async function createUsage(data: CreateUsageProps) { try { - return await global.createUsageHandler(data); + // Aggregate inputTokens/outputTokens from list items when not provided at top level. + // Many callers only pass tokens inside list items, but the usages collection stores + // them as top-level fields (queried by the frontend usage table). + const usageData = { ...data }; + if ( + usageData.inputTokens === undefined && + usageData.outputTokens === undefined && + usageData.list?.length + ) { + usageData.inputTokens = usageData.list.reduce( + (sum, item) => sum + (item.inputTokens || 0), + 0 + ); + usageData.outputTokens = usageData.list.reduce( + (sum, item) => sum + (item.outputTokens || 0), + 0 + ); + } + + if (isProVersion()) { + return await global.createUsageHandler(usageData); + } + + // Open-source: write directly to MongoDB + const { list, ...doc } = usageData; + const [{ _id: usageId }] = await MongoUsage.create([doc], { ordered: true }); + + if (list?.length) { + await MongoUsageItem.create( + list.map((item) => ({ + teamId: usageData.teamId, + usageId, + name: item.moduleName, + amount: item.amount, + modelId: item.modelId, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + charsLength: item.charsLength, + duration: item.duration, + pages: item.pages, + count: item.count + })), + { ordered: true } + ); + } + + return String(usageId); } catch (error) { logger.error('Failed to create usage', { error }); } @@ -74,16 +122,17 @@ export const createPdfParseUsage = async ({ }); } }; + export const pushLLMTrainingUsage = async ({ teamId, - model, + modelId, inputTokens, outputTokens, usageId, type }: { teamId: string; - model: string; + modelId: string; inputTokens: number; outputTokens: number; usageId: string; @@ -91,7 +140,7 @@ export const pushLLMTrainingUsage = async ({ }) => { // Compute points const { totalPoints } = formatModelChars2Points({ - model, + modelId, inputTokens, outputTokens }); @@ -141,6 +190,7 @@ export const createChatUsageRecord = async ({ ); return String(usageId); }; + export const pushChatItemUsage = ({ teamId, usageId, @@ -156,32 +206,32 @@ export const pushChatItemUsage = ({ list: nodeUsages.map((item) => ({ moduleName: item.moduleName, amount: item.totalPoints, - model: item.model, + modelId: item.modelId, inputTokens: item.inputTokens, outputTokens: item.outputTokens })) }); }; -// Dataset training export const createTrainingUsage = async ({ teamId, tmbId, appName, billSource, - vectorModel, - agentModel, - vllmModel, + vectorModelId, + agentModelId, + vlmModelId, + rerankModelId, session }: { teamId: string; tmbId: string; appName: string; billSource: UsageSourceEnum; - - vectorModel: string; - agentModel?: string; - vllmModel?: string; + vectorModelId: string; + agentModelId?: string; + vlmModelId?: string; + rerankModelId?: string; session?: ClientSession; }) => { const create = async (session: ClientSession) => { @@ -204,18 +254,18 @@ export const createTrainingUsage = async ({ usageId: result._id, itemType: UsageItemTypeEnum.training_vector, name: i18nT('account_usage:embedding_index'), - model: vectorModel, + modelId: vectorModelId, amount: 0, inputTokens: 0 }, - ...(agentModel + ...(agentModelId ? [ { teamId, usageId: result._id, itemType: UsageItemTypeEnum.training_paragraph, name: i18nT('account_usage:llm_paragraph'), - model: agentModel, + modelId: agentModelId, amount: 0, inputTokens: 0, outputTokens: 0 @@ -225,7 +275,7 @@ export const createTrainingUsage = async ({ usageId: result._id, itemType: UsageItemTypeEnum.training_qa, name: i18nT('account_usage:qa'), - model: agentModel, + modelId: agentModelId, amount: 0, inputTokens: 0, outputTokens: 0 @@ -235,21 +285,21 @@ export const createTrainingUsage = async ({ usageId: result._id, itemType: UsageItemTypeEnum.training_autoIndex, name: i18nT('account_usage:auto_index'), - model: agentModel, + modelId: agentModelId, amount: 0, inputTokens: 0, outputTokens: 0 } ] : []), - ...(vllmModel + ...(vlmModelId ? [ { teamId, usageId: result._id, itemType: UsageItemTypeEnum.training_imageIndex, name: i18nT('account_usage:image_index'), - model: vllmModel, + modelId: vlmModelId, amount: 0, inputTokens: 0, outputTokens: 0 @@ -259,7 +309,45 @@ export const createTrainingUsage = async ({ usageId: result._id, itemType: UsageItemTypeEnum.training_imageParse, name: i18nT('account_usage:image_parse'), - model: vllmModel, + modelId: vlmModelId, + amount: 0, + inputTokens: 0, + outputTokens: 0 + } + ] + : []), + ...(vectorModelId && agentModelId + ? [ + { + teamId, + usageId: result._id, + itemType: UsageItemTypeEnum.training_hypeIndex, + name: i18nT('account_usage:hype_index'), + modelId: vectorModelId, + amount: 0, + inputTokens: 0, + outputTokens: 0 + }, + { + teamId, + usageId: result._id, + itemType: UsageItemTypeEnum.training_hypeIndex, + name: i18nT('account_usage:hype_index'), + modelId: agentModelId, + amount: 0, + inputTokens: 0, + outputTokens: 0 + } + ] + : []), + ...(rerankModelId + ? [ + { + teamId, + usageId: result._id, + itemType: UsageItemTypeEnum.training_hypeIndex, + name: i18nT('account_usage:hype_index'), + modelId: rerankModelId, amount: 0, inputTokens: 0, outputTokens: 0 @@ -279,20 +367,19 @@ export const createTrainingUsage = async ({ return mongoSessionRun(create); }; -// Evaluation export const createEvaluationUsage = async ({ teamId, tmbId, appName, - model + modelId }: { teamId: string; tmbId: string; appName: string; - model: string; + modelId: string; }) => { - const { usageId } = await mongoSessionRun(async (session) => { - const [{ _id: usageId }] = await MongoUsage.create( + return mongoSessionRun(async (session) => { + const [result] = await MongoUsage.create( [ { teamId, @@ -308,31 +395,166 @@ export const createEvaluationUsage = async ({ [ { teamId, - usageId, + usageId: result._id, itemType: UsageItemTypeEnum.evaluation_generateAnswer, name: i18nT('account_usage:generate_answer'), + modelId, amount: 0, - count: 0 + inputTokens: 0, + outputTokens: 0 }, { teamId, - usageId, - itemType: UsageItemTypeEnum.evaluation_answerAccuracy, - name: i18nT('account_usage:answer_accuracy'), + usageId: result._id, + itemType: UsageItemTypeEnum.evaluation_metricsExecute, + name: i18nT('account_usage:metrics_execute'), + modelId, amount: 0, inputTokens: 0, - outputTokens: 0, - model + outputTokens: 0 + }, + { + teamId, + usageId: result._id, + itemType: UsageItemTypeEnum.evaluation_summaryGeneration, + name: i18nT('account_usage:evaluation_summary_generation'), + modelId, + amount: 0, + inputTokens: 0, + outputTokens: 0 } ], - { - session, - ordered: true - } + { session, ordered: true } ); - return { usageId: String(usageId) }; + return { usageId: String(result._id) }; + }); +}; + +export const createEvalDatasetDataQualityUsage = async ({ + teamId, + tmbId, + modelId, + usages +}: { + teamId: string; + tmbId: string; + modelId: string; + usages: Array<{ + promptTokens?: number; + completionTokens?: number; + }>; +}) => { + let totalPoints = 0; + const usageList = usages.map((usage) => { + const { totalPoints: points } = formatModelChars2Points({ + modelId, + inputTokens: usage.promptTokens || 0, + outputTokens: usage.completionTokens || 0 + }); + totalPoints += points; + + return { + moduleName: i18nT('account_usage:evaluation_quality_assessment'), + amount: points, + modelId: modelId, + inputTokens: usage.promptTokens || 0, + outputTokens: usage.completionTokens || 0 + }; }); - return { usageId }; + await createUsage({ + teamId, + tmbId, + appName: i18nT('account_usage:evaluation_dataset_data_quality_assessment'), + totalPoints, + source: UsageSourceEnum.evaluation, + list: usageList + }); + + return { totalPoints }; +}; + +export const createEvalDatasetDataSynthesisUsage = async ({ + teamId, + tmbId, + modelId, + usages +}: { + teamId: string; + tmbId: string; + modelId: string; + usages: Array<{ + promptTokens?: number; + completionTokens?: number; + }>; +}) => { + let totalPoints = 0; + const modelData = getModelById(modelId); + const usageList = usages.map((usage) => { + const { totalPoints: points } = formatModelChars2Points({ + modelId, + inputTokens: usage.promptTokens || 0, + outputTokens: usage.completionTokens || 0 + }); + totalPoints += points; + + return { + moduleName: i18nT('account_usage:evaluation_dataset_data_qa_synthesis'), + amount: points, + modelId: modelId, + inputTokens: usage.promptTokens || 0, + outputTokens: usage.completionTokens || 0 + }; + }); + + await createUsage({ + teamId, + tmbId, + appName: i18nT('account_usage:evaluation_dataset_data_synthesis'), + totalPoints, + source: UsageSourceEnum.evaluation, + list: usageList + }); + + return { totalPoints }; +}; + +export const createEvaluationMetricDebugUsage = async ({ + teamId, + tmbId, + metricName, + totalPoints, + modelId, + inputTokens, + outputTokens +}: { + teamId: string; + tmbId: string; + metricName: string; + totalPoints: number; + modelId: string; + inputTokens: number; + outputTokens: number; +}) => { + if (totalPoints <= 0) { + return; + } + + await createUsage({ + teamId, + tmbId, + appName: i18nT('account_usage:evaluation_debug_metric'), + totalPoints, + source: UsageSourceEnum.evaluation, + list: [ + { + moduleName: `Debug: ${metricName}`, + amount: totalPoints, + modelId, + inputTokens, + outputTokens + } + ] + }); }; diff --git a/packages/service/support/wallet/usage/schema.ts b/packages/service/support/wallet/usage/schema.ts index e35e9583421d..7a12fe409b35 100644 --- a/packages/service/support/wallet/usage/schema.ts +++ b/packages/service/support/wallet/usage/schema.ts @@ -38,6 +38,14 @@ const UsageSchema = new Schema( type: Number, required: true }, + inputTokens: { + type: Number, + default: 0 + }, + outputTokens: { + type: Number, + default: 0 + }, time: { type: Date, default: () => new Date() diff --git a/packages/service/support/wallet/usage/usageItemSchema.ts b/packages/service/support/wallet/usage/usageItemSchema.ts index 591a2a32e3bf..e37284e89224 100644 --- a/packages/service/support/wallet/usage/usageItemSchema.ts +++ b/packages/service/support/wallet/usage/usageItemSchema.ts @@ -38,7 +38,7 @@ const UsageItemSchema = new Schema({ duration: Number, pages: Number, count: Number, - model: String + modelId: String }); try { diff --git a/packages/service/support/wallet/usage/utils.ts b/packages/service/support/wallet/usage/utils.ts index 54312be8ab60..1f672f67b89c 100644 --- a/packages/service/support/wallet/usage/utils.ts +++ b/packages/service/support/wallet/usage/utils.ts @@ -1,19 +1,18 @@ -import { findAIModel } from '../../../core/ai/model'; +import { getModelById } from '../../../core/ai/model'; import { calculateModelPrice } from '@fastgpt/global/core/ai/pricing'; -import type { SystemModelItemType } from '../../../core/ai/type'; export const formatModelChars2Points = ({ - model, + modelId, inputTokens = 0, outputTokens = 0, multiple = 1000 }: { - model: string | SystemModelItemType; + modelId: string; inputTokens?: number; outputTokens?: number; multiple?: number; }) => { - const modelData = findAIModel(model); + const modelData = getModelById(modelId); if (!modelData) { return { totalPoints: 0, @@ -29,6 +28,7 @@ export const formatModelChars2Points = ({ }); return { + modelId: modelData.id, modelName: modelData.name, totalPoints }; diff --git a/packages/service/test/core/agentSkills/packageEditor.test.ts b/packages/service/test/core/agentSkills/packageEditor.test.ts new file mode 100644 index 000000000000..ee34be81636a --- /dev/null +++ b/packages/service/test/core/agentSkills/packageEditor.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import JSZip from 'jszip'; +import { + validatePackagePath, + listZipDirectory, + readZipFile, + mutateZip, + zipWriteText, + zipWriteBinary, + zipDeleteRecursive, + zipRename, + zipMkdir, + withSkillEditLock +} from '@fastgpt/service/core/agentSkills/packageEditor'; + +async function buildFixtureZip(): Promise { + const zip = new JSZip(); + zip.file('skill-a/SKILL.md', '# A'); + zip.file('skill-a/scripts/run.sh', 'echo hi'); + zip.file('skill-a/scripts/lib/util.py', 'pass'); + zip.file('skill-a/assets/logo.png', Buffer.from([0x89, 0x50, 0x4e, 0x47])); + zip.folder('skill-a/empty-dir'); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +describe('packageEditor.validatePackagePath', () => { + it('rejects absolute paths', () => { + expect(() => validatePackagePath('/foo')).toThrow(); + }); + it('rejects backslash', () => { + expect(() => validatePackagePath('foo\\bar')).toThrow(); + }); + it('rejects parent traversal segment', () => { + expect(() => validatePackagePath('foo/../bar')).toThrow(); + expect(() => validatePackagePath('../foo')).toThrow(); + }); + it('allows root only when allowRoot=true', () => { + expect(() => validatePackagePath('.')).toThrow(); + expect(validatePackagePath('.', { allowRoot: true })).toBe(''); + expect(validatePackagePath('', { allowRoot: true })).toBe(''); + }); + it('strips trailing slash', () => { + expect(validatePackagePath('foo/bar/')).toBe('foo/bar'); + }); + it('accepts normal relative paths', () => { + expect(validatePackagePath('skill-a/SKILL.md')).toBe('skill-a/SKILL.md'); + }); +}); + +describe('packageEditor.listZipDirectory', () => { + it('lists root level', async () => { + const buf = await buildFixtureZip(); + const zip = await JSZip.loadAsync(buf); + const items = listZipDirectory(zip, ''); + expect(items.map((i) => i.name)).toEqual(['skill-a']); + expect(items[0].type).toBe('directory'); + }); + + it('lists files and dirs at given path, sorted dir-first', async () => { + const buf = await buildFixtureZip(); + const zip = await JSZip.loadAsync(buf); + const items = listZipDirectory(zip, 'skill-a'); + expect(items.map((i) => `${i.type}:${i.name}`)).toEqual([ + 'directory:assets', + 'directory:empty-dir', + 'directory:scripts', + 'file:SKILL.md' + ]); + }); + + it('detects implicit directories (no explicit folder entry)', async () => { + const z = new JSZip(); + z.file('a/b/c.txt', 'x'); + const items = listZipDirectory(z, 'a'); + expect(items).toEqual([{ name: 'b', path: 'a/b', type: 'directory' }]); + }); + + it('returns size for files', async () => { + const buf = await buildFixtureZip(); + const zip = await JSZip.loadAsync(buf); + const items = listZipDirectory(zip, 'skill-a'); + const skillMd = items.find((i) => i.name === 'SKILL.md'); + expect(skillMd?.size).toBe(3); + }); +}); + +describe('packageEditor.readZipFile', () => { + it('returns buffer for existing file', async () => { + const buf = await buildFixtureZip(); + const zip = await JSZip.loadAsync(buf); + const content = await readZipFile(zip, 'skill-a/SKILL.md'); + expect(content?.toString('utf-8')).toBe('# A'); + }); + it('returns null for missing file', async () => { + const buf = await buildFixtureZip(); + const zip = await JSZip.loadAsync(buf); + expect(await readZipFile(zip, 'skill-a/nope.txt')).toBeNull(); + }); +}); + +describe('packageEditor.mutateZip + zipWriteText/Binary', () => { + it('roundtrips a text write', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => { + zipWriteText(zip, 'skill-a/SKILL.md', '# updated'); + }); + const reloaded = await JSZip.loadAsync(newBuf); + expect(await reloaded.file('skill-a/SKILL.md')?.async('string')).toBe('# updated'); + }); + it('writes a new binary file', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => { + zipWriteBinary(zip, 'skill-a/assets/new.bin', Buffer.from([1, 2, 3])); + }); + const reloaded = await JSZip.loadAsync(newBuf); + const out = await reloaded.file('skill-a/assets/new.bin')?.async('nodebuffer'); + expect(out).toEqual(Buffer.from([1, 2, 3])); + }); +}); + +describe('packageEditor.zipDeleteRecursive', () => { + it('deletes single file', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => { + zipDeleteRecursive(zip, 'skill-a/SKILL.md'); + }); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.file('skill-a/SKILL.md')).toBeNull(); + expect(reloaded.file('skill-a/scripts/run.sh')).not.toBeNull(); + }); + + it('deletes whole directory subtree', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => { + zipDeleteRecursive(zip, 'skill-a/scripts'); + }); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.file('skill-a/scripts/run.sh')).toBeNull(); + expect(reloaded.file('skill-a/scripts/lib/util.py')).toBeNull(); + expect(reloaded.file('skill-a/SKILL.md')).not.toBeNull(); + }); + + it('no-op for missing path', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => { + zipDeleteRecursive(zip, 'skill-a/nope'); + }); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.file('skill-a/SKILL.md')).not.toBeNull(); + }); +}); + +describe('packageEditor.zipRename', () => { + it('renames a file (preserves content)', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => + zipRename(zip, 'skill-a/SKILL.md', 'skill-a/README.md') + ); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.file('skill-a/SKILL.md')).toBeNull(); + expect(await reloaded.file('skill-a/README.md')?.async('string')).toBe('# A'); + }); + + it('renames a directory (preserves children)', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => zipRename(zip, 'skill-a/scripts', 'skill-a/bin')); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.file('skill-a/scripts/run.sh')).toBeNull(); + expect(reloaded.file('skill-a/scripts/lib/util.py')).toBeNull(); + expect(await reloaded.file('skill-a/bin/run.sh')?.async('string')).toBe('echo hi'); + expect(await reloaded.file('skill-a/bin/lib/util.py')?.async('string')).toBe('pass'); + }); + + it('throws when source missing', async () => { + const buf = await buildFixtureZip(); + await expect( + mutateZip(buf, (zip) => zipRename(zip, 'skill-a/nope', 'skill-a/other')) + ).rejects.toThrow(/Entry not found/); + }); + + it('no-op when from === to', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => + zipRename(zip, 'skill-a/SKILL.md', 'skill-a/SKILL.md') + ); + const reloaded = await JSZip.loadAsync(newBuf); + expect(await reloaded.file('skill-a/SKILL.md')?.async('string')).toBe('# A'); + }); +}); + +describe('packageEditor.zipMkdir', () => { + it('creates an explicit folder entry', async () => { + const buf = await buildFixtureZip(); + const newBuf = await mutateZip(buf, (zip) => zipMkdir(zip, 'skill-a/new-dir')); + const reloaded = await JSZip.loadAsync(newBuf); + expect(reloaded.files['skill-a/new-dir/']?.dir).toBe(true); + }); +}); + +describe('packageEditor.withSkillEditLock', () => { + it('serializes concurrent calls for the same skillId', async () => { + const order: string[] = []; + const a = withSkillEditLock('s1', async () => { + order.push('a-start'); + await new Promise((r) => setTimeout(r, 30)); + order.push('a-end'); + return 'a'; + }); + // Give a a microtask to register first + await Promise.resolve(); + const b = withSkillEditLock('s1', async () => { + order.push('b-start'); + await new Promise((r) => setTimeout(r, 10)); + order.push('b-end'); + return 'b'; + }); + await Promise.all([a, b]); + expect(order).toEqual(['a-start', 'a-end', 'b-start', 'b-end']); + }); + + it('does not serialize across different skillIds', async () => { + const order: string[] = []; + const a = withSkillEditLock('s1', async () => { + order.push('s1-start'); + await new Promise((r) => setTimeout(r, 30)); + order.push('s1-end'); + }); + const b = withSkillEditLock('s2', async () => { + order.push('s2-start'); + await new Promise((r) => setTimeout(r, 10)); + order.push('s2-end'); + }); + await Promise.all([a, b]); + // s2 should finish before s1 + expect(order.indexOf('s2-end')).toBeLessThan(order.indexOf('s1-end')); + }); + + it('releases lock even when fn throws', async () => { + await expect( + withSkillEditLock('s3', async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + // Next call should still run + const v = await withSkillEditLock('s3', async () => 42); + expect(v).toBe(42); + }); +}); diff --git a/packages/service/test/core/agentSkills/sandboxSync.test.ts b/packages/service/test/core/agentSkills/sandboxSync.test.ts new file mode 100644 index 000000000000..47c4125e64ae --- /dev/null +++ b/packages/service/test/core/agentSkills/sandboxSync.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + validatePackagePath, + withSkillEditLock +} from '@fastgpt/service/core/agentSkills/packageEditor'; + +// sandboxSync shares withSkillEditLock with packageEditor — verify lock behavior. +// Full syncSkillSandbox tests require MongoDB and sandbox infrastructure; +// these are covered by integration tests. + +describe('sandboxSync lock integration', () => { + it('shares the same lock per skillId as packageEditor', async () => { + const order: string[] = []; + + // Simulate packageEditor holding the lock + const editorTask = withSkillEditLock('s1', async () => { + order.push('editor-start'); + await new Promise((r) => setTimeout(r, 30)); + order.push('editor-end'); + return 'editor'; + }); + + await Promise.resolve(); + // A concurrent "sync" would wait for the same lock + const syncTask = withSkillEditLock('s1', async () => { + order.push('sync-start'); + order.push('sync-end'); + return 'sync'; + }); + + await Promise.all([editorTask, syncTask]); + // sync must wait for editor + expect(order).toEqual(['editor-start', 'editor-end', 'sync-start', 'sync-end']); + }); + + it('allows concurrent sync for different skillIds', async () => { + const order: string[] = []; + const a = withSkillEditLock('s1', async () => { + order.push('s1-start'); + await new Promise((r) => setTimeout(r, 20)); + order.push('s1-end'); + }); + const b = withSkillEditLock('s2', async () => { + order.push('s2-start'); + await new Promise((r) => setTimeout(r, 5)); + order.push('s2-end'); + }); + await Promise.all([a, b]); + expect(order.indexOf('s2-end')).toBeLessThan(order.indexOf('s1-end')); + }); +}); + +describe('sandboxSync path validation', () => { + it('rejects absolute paths (defense-in-depth for sandbox sync)', () => { + expect(() => validatePackagePath('/etc/passwd')).toThrow(); + }); + + it('rejects parent traversal (prevents sandbox escape)', () => { + expect(() => validatePackagePath('skill-a/../../../root')).toThrow(); + }); +}); diff --git a/packages/service/thirdProvider/fastgptPlugin/model.ts b/packages/service/thirdProvider/fastgptPlugin/model.ts index 925503b51f17..c0310f54c63e 100644 --- a/packages/service/thirdProvider/fastgptPlugin/model.ts +++ b/packages/service/thirdProvider/fastgptPlugin/model.ts @@ -1,5 +1,14 @@ import { pluginClient } from '.'; +import type { I18nStringStrictType, AIProxyChannelsType } from '@fastgpt/global/sdk/fastgpt-plugin'; -export const loadModelProviders = async () => { +export const loadModelProviders = async (): Promise<{ + modelProviders: { provider: string; value: I18nStringStrictType; avatar: string }[]; + aiproxyChannels?: AIProxyChannelsType; +}> => { + // 如果没有配置 plugin,跳过加载 + if (!process.env.PLUGIN_BASE_URL) { + console.log('PLUGIN_BASE_URL not configured, skipping model providers load'); + return { modelProviders: [], aiproxyChannels: undefined }; + } return await pluginClient.getModelProviders(); }; diff --git a/packages/service/tsconfig.json b/packages/service/tsconfig.json index 5f31851f1d4e..2b63b544f2a3 100644 --- a/packages/service/tsconfig.json +++ b/packages/service/tsconfig.json @@ -1,7 +1,19 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "moduleResolution": "bundler" + "baseUrl": ".", + "typeRoots": [ + "../../node_modules/@types", + "./types" + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.d.ts", + "../../**/*.d.ts", + "types/**/*.d.ts" + ], + "moduleResolution": "bundler" } diff --git a/packages/service/type.ts b/packages/service/type.ts index 6d352d4283c4..521783f628bf 100644 --- a/packages/service/type.ts +++ b/packages/service/type.ts @@ -12,6 +12,7 @@ import { } from '@fastgpt/global/core/ai/model.schema'; import type { SubPlanType } from '@fastgpt/global/support/wallet/sub/type'; import type { WorkerNameEnum, WorkerPool } from './worker/utils'; +import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; import { Worker } from 'worker_threads'; declare global { @@ -22,6 +23,7 @@ declare global { var systemEnv: SystemEnvType; var subPlans: SubPlanType | undefined; var licenseData: LicenseDataType | undefined; + var builtinMetrics: EvalMetricSchemaType[]; var workerPoll: Record; } diff --git a/packages/service/type/env.ts b/packages/service/type/env.ts index 017a57b69462..cb81f0275b51 100644 --- a/packages/service/type/env.ts +++ b/packages/service/type/env.ts @@ -49,6 +49,7 @@ declare global { MILVUS_ADDRESS: string; MILVUS_TOKEN: string; + DITING_BASE_URL: string; CODE_SANDBOX_URL: string; FE_DOMAIN: string; FILE_DOMAIN: string; diff --git a/packages/service/types/external-modules.d.ts b/packages/service/types/external-modules.d.ts new file mode 100644 index 000000000000..ca71e5a080bb --- /dev/null +++ b/packages/service/types/external-modules.d.ts @@ -0,0 +1,51 @@ +// Type declarations for external packages without proper TypeScript support + +/** + * franc - Language detection library + * Detects the language of text using character n-gram frequency analysis + */ +declare module 'franc' { + /** + * Detect the language of text + * @param text - Text to analyze for language detection + * @param options - Detection options + * @returns ISO 639-3 language code (e.g., 'eng' for English, 'cmn' for Chinese) + */ + export function franc( + text: string, + options?: { + minLength?: number; + whitelist?: string[]; + blacklist?: string[]; + } + ): string; + + export default franc; +} + +/** + * chinese-conv - Chinese character conversion library + * Converts between simplified and traditional Chinese + */ +declare module 'chinese-conv' { + /** + * Convert simplified Chinese to traditional Chinese + * @param text - Simplified Chinese text to convert + * @returns Traditional Chinese text + */ + export function tify(text: string): string; + + /** + * Convert traditional Chinese to simplified Chinese + * @param text - Traditional Chinese text to convert + * @returns Simplified Chinese text + */ + export function sify(text: string): string; + + /** + * Convert JSON object with simplified Chinese to traditional Chinese + * @param obj - JSON object to convert + * @returns Converted JSON object + */ + export function tifyJson(obj: T): T; +} diff --git a/packages/service/worker/function.ts b/packages/service/worker/function.ts index 134abbc43615..b8c4796fbd53 100644 --- a/packages/service/worker/function.ts +++ b/packages/service/worker/function.ts @@ -1,18 +1,44 @@ import { - splitText2Chunks, type SplitProps, - type SplitResponse + type SplitResponse, + splitText2Chunks } from '@fastgpt/global/common/string/textSplitter'; import { runWorker, WorkerNameEnum } from './utils'; import type { ReadFileResponse } from './readFile/type'; import { isTestEnv } from '@fastgpt/global/common/system/constants'; +import { axios } from '../common/api/axios'; -export const text2Chunks = (props: SplitProps) => { - // Test env, not run worker - if (isTestEnv) { - return splitText2Chunks(props); +export const text2Chunks = async (props: SplitProps): Promise => { + const { url: pdfParseUrl, key: token, chunkTimeout } = global.systemEnv?.customPdfParse ?? {}; + const url = pdfParseUrl?.replace(/\/pdfparse$/, '/chunk'); + + if (!url) { + if (isTestEnv) { + return splitText2Chunks(props); + } + return runWorker(WorkerNameEnum.text2Chunks, props); + } + + try { + const res = await axios.post(url, { + markdown: props.text, + chunk_sizes: { text: props.chunkSize } + }, { + timeout: (chunkTimeout ?? 60) * 60 * 1000, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + } + }); + + const chunks: string[] = res.data.chunks.map((c: { text: string }) => c.text); + const chars: number = chunks.reduce((sum, c) => sum + c.length, 0); + + return { chunks, chars }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Chunk API request failed: ${message}`); } - return runWorker(WorkerNameEnum.text2Chunks, props); }; export const readRawContentFromBuffer = (props: { @@ -34,3 +60,109 @@ export const readRawContentFromBuffer = (props: { bufferSize: bufferSize }); }; + +export const runSyncFunction = (functionName: string, ...args: any[]) => { + // Test env, not run worker + if (isTestEnv) { + // 在测试环境中直接导入并运行函数 + const { parseDatasetBackup2Chunks } = require('../core/dataset/parseBackup'); + const availableFunctions: Record = { + parseDatasetBackup2Chunks + }; + + const func = availableFunctions[functionName]; + if (!func) { + throw new Error(`Function '${functionName}' is not available`); + } + return func(...args); + } + + return runWorker(WorkerNameEnum.syncFunction, { functionName, args }); +}; + +/** + * 通过 Worker + SharedArrayBuffer 解析 CSV 备份数据 + * 主线程仅做内存拷贝(Buffer → SharedArrayBuffer),Papa.parse 在 Worker 子线程执行 + * 避免 50MB+ 大字符串的结构化克隆阻塞主线程 + */ +export const parseDatasetBackupViaWorker = async ( + rawText: string, + imageIdList?: string[] +): Promise<{ + chunks: { + q: string; + a: string; + indexes?: string[]; + imageIdList?: string[]; + metadata?: Map; + }[]; +}> => { + if (isTestEnv) { + const { parseDatasetBackup2Chunks } = require('../core/dataset/parseBackup'); + return parseDatasetBackup2Chunks(rawText, imageIdList); + } + + const buffer = Buffer.from(rawText, 'utf-8'); + const bufferSize = buffer.length; + + // SharedArrayBuffer 零拷贝传递给 Worker,避免结构化克隆大字符串 + const sharedBuffer = new SharedArrayBuffer(bufferSize); + const sharedArray = new Uint8Array(sharedBuffer); + sharedArray.set(buffer); + + return runWorker(WorkerNameEnum.syncFunction, { + functionName: 'parseDatasetBackupFromSharedBuffer', + args: [sharedBuffer, bufferSize, imageIdList] + }); +}; + +/** + * 通过 Worker 直接读文件并统计 CSV 备份行数(不构建 chunk 对象) + * 主线程只传文件路径,Worker 内部读文件+解码+字节扫描计数,IPC 仅返回三个标量。 + * 用于替换 parseDatasetBackupFromFileViaWorker,彻底消除 200k 对象的 IPC 开销。 + */ +export const countDatasetBackupFromFileViaWorker = async ( + filePath: string, + fileExtension: string +): Promise<{ chunkCount: number; hashRawText: string; rawTextLength: number }> => { + if (isTestEnv) { + const { countDatasetBackupFromFile } = require('../core/dataset/parseBackup'); + return countDatasetBackupFromFile(filePath, fileExtension); + } + + return runWorker(WorkerNameEnum.syncFunction, { + functionName: 'countDatasetBackupFromFile', + args: [filePath, fileExtension] + }); +}; + +/** + * 通过 Worker 直接读文件并解析 CSV 备份数据 + * 主线程只传文件路径,Worker 内部读文件+解码+Papa.parse + * 彻底避免主线程处理大字符串/大 Buffer,不阻塞事件循环 + */ +export const parseDatasetBackupFromFileViaWorker = async ( + filePath: string, + fileExtension: string, + imageIdList?: string[] +): Promise<{ + chunks: { + q: string; + a: string; + indexes?: string[]; + imageIdList?: string[]; + metadata?: Map; + }[]; + hashRawText: string; + rawTextLength: number; +}> => { + if (isTestEnv) { + const { parseDatasetBackupFromFile } = require('../core/dataset/parseBackup'); + return parseDatasetBackupFromFile(filePath, fileExtension, imageIdList); + } + + return runWorker(WorkerNameEnum.syncFunction, { + functionName: 'parseDatasetBackupFromFile', + args: [filePath, fileExtension, imageIdList] + }); +}; diff --git a/packages/service/worker/readFile/extension/csv.ts b/packages/service/worker/readFile/extension/csv.ts index 887247c13c5a..720879ff0754 100644 --- a/packages/service/worker/readFile/extension/csv.ts +++ b/packages/service/worker/readFile/extension/csv.ts @@ -8,14 +8,38 @@ export const readCsvRawText = async (params: ReadRawTextByBuffer): Promise { + return text + .replace(/\r?\n/g, ' ') // 将换行符替换为空格 + .replace(/\|/g, '\\|') // 转义竖线字符,避免破坏表格结构 + .replace(/\s+/g, ' ') // 将多个空格合并为一个 + .trim(); // 去除首尾空格 + }; + + const formatText = `| ${header.map(formatCell).join(' | ')} | | ${header.map(() => '---').join(' | ')} | ${csvArr .slice(1) - .map((row) => `| ${row.map((item) => item.replace(/\n/g, '\\n')).join(' | ')} |`) + .map((row) => `| ${row.map(formatCell).join(' | ')} |`) .join('\n')}`; return { @@ -23,3 +47,4 @@ ${csvArr formatText }; }; + diff --git a/packages/service/worker/readFile/extension/pdf.ts b/packages/service/worker/readFile/extension/pdf.ts index 4c261e9e4015..9ea0ee5088b1 100644 --- a/packages/service/worker/readFile/extension/pdf.ts +++ b/packages/service/worker/readFile/extension/pdf.ts @@ -2,8 +2,20 @@ import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'; // @ts-ignore import('pdfjs-dist/legacy/build/pdf.worker.min.mjs'); import { type ReadRawTextByBuffer, type ReadFileResponse } from '../type'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { getLogger, LogCategories } from '../../../common/logger'; +// pdfjs-dist does not export PasswordException directly, detect by name +const isPdfPasswordError = (error: unknown): boolean => { + return ( + error instanceof Error && + (error.name === 'PasswordException' || + (error as any).code === 1 /* PasswordResponses.NEED_PASSWORD */ || + (error as any).code === 2) /* PasswordResponses.INCORRECT_PASSWORD */ + ); +}; + type TokenType = { str: string; dir: string; @@ -62,7 +74,12 @@ export const readPdfFile = async ({ buffer }: ReadRawTextByBuffer): Promise { + if (isPdfPasswordError(error)) { + return Promise.reject(new UserError(CommonErrEnum.pdfEncrypted)); + } + return Promise.reject(error); + }); const pageArr = Array.from({ length: doc.numPages }, (_, i) => i + 1); const result = ( diff --git a/packages/service/worker/readFile/extension/rawText.ts b/packages/service/worker/readFile/extension/rawText.ts index 609a11d217f9..85b31111b115 100644 --- a/packages/service/worker/readFile/extension/rawText.ts +++ b/packages/service/worker/readFile/extension/rawText.ts @@ -43,7 +43,13 @@ export const readFileRawText = async ({ } if (normalizedEncoding) { - return iconv.decode(buffer, normalizedEncoding); + // TextDecoder(V8 原生 ICU)对 GBK 比 iconv-lite 快约 30 倍,对 UTF-8 性能相当。 + // 降级到 iconv-lite 兜底 TextDecoder 不支持的编码。 + try { + return new TextDecoder(normalizedEncoding).decode(buffer); + } catch { + return iconv.decode(buffer, normalizedEncoding); + } } return buffer.toString('utf-8'); diff --git a/packages/service/worker/readFile/extension/xlsx.ts b/packages/service/worker/readFile/extension/xlsx.ts index 594986c87a0c..ceb2b7da37e7 100644 --- a/packages/service/worker/readFile/extension/xlsx.ts +++ b/packages/service/worker/readFile/extension/xlsx.ts @@ -3,6 +3,14 @@ import { type ReadRawTextByBuffer, type ReadFileResponse } from '../type'; import xlsx from 'node-xlsx'; import Papa from 'papaparse'; +// 判断行是否为空行(所有单元格都是空字符串或只包含空白字符) +const isEmptyRow = (row: any[]): boolean => { + return row.every((cell) => { + const cellStr = String(cell).trim(); + return cellStr === ''; + }); +}; + export const readXlsxRawText = async ({ buffer }: ReadRawTextByBuffer): Promise => { @@ -11,7 +19,13 @@ export const readXlsxRawText = async ({ defval: '' }); - const format2Csv = result.map(({ name, data }) => { + // 过滤掉空行 + const filteredResult = result.map(({ name, data }) => ({ + name, + data: data.filter((row) => !isEmptyRow(row)) + })); + + const format2Csv = filteredResult.map(({ name, data }) => { return { title: `#${name}`, csvText: data.map((item) => item.join(',')).join('\n') @@ -20,7 +34,7 @@ export const readXlsxRawText = async ({ const rawText = format2Csv.map((item) => item.csvText).join('\n'); - const formatText = result + const formatText = filteredResult .map(({ data }) => { const header = data[0]; if (!header) return; @@ -42,3 +56,4 @@ ${data formatText }; }; + diff --git a/packages/service/worker/readFile/index.ts b/packages/service/worker/readFile/index.ts index 807153ee12df..841b56fb70a9 100644 --- a/packages/service/worker/readFile/index.ts +++ b/packages/service/worker/readFile/index.ts @@ -26,17 +26,20 @@ parentPort?.on( return readHtmlRawText(params); case 'pdf': return readPdfFile(params); + case 'doc': case 'docx': return readDocsFile(params); + case 'ppt': case 'pptx': return readPptxRawText(params); + case 'xls': case 'xlsx': return readXlsxRawText(params); case 'csv': return readCsvRawText(params); default: return Promise.reject( - `Only support .txt, .md, .html, .pdf, .docx, pptx, .csv, .xlsx. "${params.extension}" is not supported.` + `Only support .txt, .md, .html, .pdf, .doc, .docx, .ppt, .pptx, .csv, .xls, .xlsx. "${params.extension}" is not supported.` ); } }; @@ -66,3 +69,4 @@ parentPort?.on( } } ); + diff --git a/packages/service/worker/readFile/parseOffice.ts b/packages/service/worker/readFile/parseOffice.ts index 349bb04591a2..91862153f5f4 100644 --- a/packages/service/worker/readFile/parseOffice.ts +++ b/packages/service/worker/readFile/parseOffice.ts @@ -1,8 +1,11 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; import fs from 'fs'; +import path from 'path'; import decompress from 'decompress'; import { DOMParser } from '@xmldom/xmldom'; import { clearDirFiles } from '../../common/file/utils'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { getLogger, LogCategories } from '../../common/logger'; const DEFAULTDECOMPRESSSUBLOCATION = '/tmp'; @@ -41,7 +44,7 @@ const parsePowerPoint = async ({ files.length == 0 || !files.map((file) => file.path).some((filename) => filename.match(slidesRegex)) ) { - return Promise.reject('解析 PPT 失败'); + return Promise.reject(new UserError(CommonErrEnum.pptxParseFailed)); } // Sort files by slide number to ensure correct order @@ -56,10 +59,24 @@ const parsePowerPoint = async ({ // Returning an array of all the xml contents read using fs.readFileSync const xmlContentArray = await Promise.all( sortedFiles.map(async (file) => { + // 安全的路径拼接:防止路径遍历攻击 + const safePath = path.join(decompressPath, file.path); + // 验证最终路径在目标目录内 + const normalizedSafePath = path.normalize(safePath); + const normalizedDecompressPath = path.normalize(decompressPath); + if (!normalizedSafePath.startsWith(normalizedDecompressPath + path.sep)) { + logger.error('Potential path traversal attack detected', { + decompressPath, + filePath: file.path, + resolvedPath: normalizedSafePath + }); + throw new UserError(CommonErrEnum.pptxParseFailed); + } + try { - return await fs.promises.readFile(`${decompressPath}/${file.path}`, encoding); + return await fs.promises.readFile(safePath, encoding); } catch (err) { - return await fs.promises.readFile(`${decompressPath}/${file.path}`, 'utf-8'); + return await fs.promises.readFile(safePath, 'utf-8'); } }) ); @@ -121,21 +138,31 @@ export const parseOffice = async ({ }); } - const text = await (async () => { + let text: string = ''; + try { + switch (extension) { + case 'pptx': + text = await parsePowerPoint({ filepath, decompressPath, encoding }); + break; + default: + throw new UserError(CommonErrEnum.pptxParseFailed); + } + } catch (error) { + if (error instanceof UserError) throw error; + logger.error(`Load ppt error`, { error }); + throw new UserError(CommonErrEnum.pptxParseFailed); + } finally { try { - switch (extension) { - case 'pptx': - return parsePowerPoint({ filepath, decompressPath, encoding }); - default: - return Promise.reject('只能读取 .pptx 文件'); - } + fs.unlinkSync(filepath); + } catch { + // ignore + } + try { + clearDirFiles(decompressPath); } catch (error) { logger.error('Failed to parse pptx file', { extension, error }); } - return ''; - })(); + } - fs.unlinkSync(filepath); - clearDirFiles(decompressPath); return text; }; diff --git a/packages/service/worker/syncFunction/index.ts b/packages/service/worker/syncFunction/index.ts new file mode 100644 index 000000000000..f5d8e45db22a --- /dev/null +++ b/packages/service/worker/syncFunction/index.ts @@ -0,0 +1,46 @@ +import { parentPort } from 'worker_threads'; +import { workerResponse } from '../controller'; +import { + parseDatasetBackup2Chunks, + parseDatasetBackupFromSharedBuffer, + parseDatasetBackupFromFile, + countDatasetBackupFromFile +} from '../../core/dataset/parseBackup'; + +// 这里导入所有需要在worker中运行的同步函数 +const availableFunctions: Record = { + parseDatasetBackup2Chunks, + parseDatasetBackupFromSharedBuffer, + parseDatasetBackupFromFile, + countDatasetBackupFromFile +}; + +interface SyncFunctionCall { + functionName: keyof typeof availableFunctions; + args: any[]; +} + +parentPort?.on('message', (props: SyncFunctionCall) => { + try { + const func = availableFunctions[props.functionName]; + + if (!func) { + throw new Error(`Function '${props.functionName}' is not available in syncFunction worker`); + } + + // 调用函数并传入参数 + const result = func(...props.args); + + workerResponse({ + parentPort, + status: 'success', + data: result + }); + } catch (error) { + workerResponse({ + parentPort, + status: 'error', + data: error instanceof Error ? error.message : String(error) + }); + } +}); diff --git a/packages/service/worker/utils.ts b/packages/service/worker/utils.ts index df545aa7dc62..59479ee2d192 100644 --- a/packages/service/worker/utils.ts +++ b/packages/service/worker/utils.ts @@ -7,12 +7,16 @@ export enum WorkerNameEnum { htmlStr2Md = 'htmlStr2Md', countGptMessagesTokens = 'countGptMessagesTokens', systemPluginRun = 'systemPluginRun', - text2Chunks = 'text2Chunks' + text2Chunks = 'text2Chunks', + syncFunction = 'syncFunction' } export const getSafeEnv = () => { return { MAX_HTML_TRANSFORM_CHARS: process.env.MAX_HTML_TRANSFORM_CHARS, + MAX_TEXT_PRESPLIT_MB: process.env.MAX_TEXT_PRESPLIT_MB, + LOG_LEVEL: process.env.LOG_LEVEL, + STORE_LOG_LEVEL: process.env.STORE_LOG_LEVEL, NODE_ENV: process.env.NODE_ENV, HTTP_PROXY: process.env.HTTP_PROXY, HTTPS_PROXY: process.env.HTTPS_PROXY, diff --git a/packages/templates/src/assistant/enTemplate.json b/packages/templates/src/assistant/enTemplate.json new file mode 100644 index 000000000000..8d0aab90dc30 --- /dev/null +++ b/packages/templates/src/assistant/enTemplate.json @@ -0,0 +1,1100 @@ +{ + "name": "Simple Chat Agent", + "intro": "Simple conversational agent combining knowledge base retrieval and intelligent dialogue capabilities", + "author": "FastGPT", + "avatar": "core/workflow/template/customerService", + "tags": ["customer-service"], + "type": "assistant", + "weight": 1, + "workflow": { + "nodes": [ + { + "nodeId": "userGuide", + "name": "System Config", + "intro": "Configure system parameters for the application", + "avatar": "core/workflow/template/systemConfig", + "flowNodeType": "userGuide", + "position": { + "x": -67.28076977067361, + "y": -598.1403417187121 + }, + "version": "481", + "inputs": [ + { + "key": "welcomeText", + "renderTypeList": ["hidden"], + "valueType": "string", + "label": "core.app.Welcome Text", + "value": "" + }, + { + "key": "variables", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "core.app.Chat Variable", + "value": [ + { + "key": "hTRJXdb1", + "label": "correct_mapping_answer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "", + "icon": "core/workflow/inputType/customVariable" + }, + { + "key": "udQRlgfO", + "label": "faqAnswer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "" + } + ] + }, + { + "key": "questionGuide", + "valueType": "any", + "renderTypeList": ["hidden"], + "label": "core.app.Question Guide", + "value": { + "open": true + } + }, + { + "key": "tts", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": { + "type": "web" + } + }, + { + "key": "whisper", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": { + "open": false, + "autoSend": false, + "autoTTSResponse": false + } + }, + { + "key": "scheduleTrigger", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": null + } + ], + "outputs": [] + }, + { + "nodeId": "workflowStartNodeId", + "name": "Workflow Start", + "intro": "", + "avatar": "core/workflow/template/workflowStart", + "flowNodeType": "workflowStart", + "position": { + "x": 3.719230229326385, + "y": -1352.140341718712 + }, + "version": "481", + "inputs": [ + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "required": true, + "toolDescription": "User's question", + "debugLabel": "" + } + ], + "outputs": [ + { + "id": "userChatInput", + "key": "userChatInput", + "label": "common:core.module.input.label.user question", + "type": "static", + "valueType": "string", + "description": "" + } + ] + }, + { + "nodeId": "variableUpdateNodeId", + "name": "QA Config", + "intro": "Update output values of specified nodes or update global variables", + "avatar": "core/workflow/template/variableUpdate", + "flowNodeType": "variableUpdate", + "showStatus": false, + "position": { + "x": 494.7192302293264, + "y": -1232.140341718712 + }, + "inputs": [ + { + "key": "updateList", + "valueType": "any", + "label": "", + "renderTypeList": ["hidden"], + "value": [ + { + "variable": ["VARIABLE_NODE_ID", "byG7WNk4"], + "value": ["", "Sorry, I couldn't find an answer. You can try rephrasing your question, and I'll also help report this to the relevant team."], + "valueType": "string", + "renderType": "input" + }, + { + "variable": ["VARIABLE_NODE_ID", "utjZSg8f"], + "value": ["", "quote"], + "renderType": "input", + "valueType": "string" + }, + { + "variable": ["VARIABLE_NODE_ID", "hTRJXdb1"], + "value": ["", ""], + "renderType": "input", + "valueType": "string" + }, + { + "variable": ["VARIABLE_NODE_ID", "udQRlgfO"], + "value": ["", ""], + "renderType": "input", + "valueType": "string" + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "iKBoX2vIzETU", + "name": "Knowledge Base Search", + "intro": "Search for relevant content from the knowledge base using semantic retrieval, full-text search, and database query capabilities", + "avatar": "core/workflow/template/datasetSearch", + "flowNodeType": "datasetSearchNode", + "showStatus": true, + "position": { + "x": 1060.7192302293263, + "y": -1383.640341718712 + }, + "version": "4.9.2", + "inputs": [ + { + "key": "datasets", + "renderTypeList": ["selectDataset", "reference"], + "label": "common:core.module.input.label.Select dataset", + "value": [], + "valueType": "selectDataset", + "required": true, + "valueDesc": "{\n datasetId: string;\n}[]", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "similarity", + "renderTypeList": ["selectDatasetParamsModal"], + "label": "", + "value": 0.4, + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "limit", + "renderTypeList": ["hidden"], + "label": "", + "value": 5000, + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "searchMode", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "mixedRecall", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "embeddingWeight", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "value": 0.65, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "usingReRank", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": false, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "rerankModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "", + "value": "" + }, + { + "key": "rerankMethod", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "question", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "rerankWeight", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "value": 0.4, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "datasetSearchUsingExtensionQuery", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "datasetSearchExtensionModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "", + "value": "" + }, + { + "key": "datasetSearchExtensionBg", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "authTmbId", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": false, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "toolDescription": "Content to search for", + "required": true, + "value": ["workflowStartNodeId", "userChatInput"], + "debugLabel": "" + }, + { + "key": "collectionFilterMatch", + "renderTypeList": ["textarea", "reference"], + "label": "workflow:collection_metadata_filter", + "valueType": "string", + "isPro": true, + "description": "workflow:filter_description", + "debugLabel": "", + "toolDescription": "", + "selectedTypeIndex": 1 + }, + { + "key": "generateSqlModel", + "renderTypeList": ["hidden"], + "label": "common:search_model", + "value": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "retrievalMode", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "standard", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchLLMModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchRerankModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchReasoning", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "quoteQA", + "key": "quoteQA", + "label": "common:core.module.Dataset quote.label", + "description": "Special array format, returns empty array when no search results are found.", + "type": "static", + "valueType": "datasetQuote", + "valueDesc": "{\n id: string;\n datasetId: string;\n collectionId: string;\n sourceName: string;\n sourceId?: string;\n q: string;\n a: string\n}[]" + }, + { + "id": "system_error_text", + "key": "system_error_text", + "type": "error", + "valueType": "string", + "label": "workflow:error_text", + "valueDesc": "", + "description": "" + } + ], + "catchError": false + }, + { + "nodeId": "qaFDoVSH4WcXMZPO", + "name": "Enable Fallback Reply", + "intro": "Execute different branches based on conditions.", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 1846.7192302293263, + "y": -1205.140341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["iKBoX2vIzETU", "quoteQA"], + "condition": "isEmpty", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "vprtN0xvK1dV0c6M", + "name": "Fallback Reply", + "intro": "This module can directly reply with specified content. Commonly used for guidance and prompts. Non-string content will be converted to string for output.", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 5194.719230229326, + "y": -467.1403417187121 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": ["textarea", "reference"], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": ["VARIABLE_NODE_ID", "byG7WNk4"], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "7BdojPlukIQw", + "name": "AI Chat", + "intro": "AI Large Language Model Conversation", + "avatar": "core/workflow/template/aiChat", + "flowNodeType": "chatNode", + "showStatus": true, + "position": { + "x": 5265.719230229326, + "y": -1523.140341718712 + }, + "version": "4.9.7", + "inputs": [ + { + "key": "model", + "renderTypeList": ["settingLLMModel", "reference"], + "label": "common:core.module.input.label.aiModel", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "temperature", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "maxToken", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "isResponseAnswerText", + "renderTypeList": ["hidden"], + "label": "", + "value": true, + "valueType": "boolean", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatQuoteRole", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "system", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quoteTemplate", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quotePrompt", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatVision", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatReasoning", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatTopP", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatStopSign", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatResponseFormat", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatJsonSchema", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "systemPrompt", + "renderTypeList": ["textarea", "reference"], + "max": 3000, + "valueType": "string", + "label": "common:core.ai.Prompt", + "description": "common:core.app.tip.systemPromptTip", + "placeholder": "common:core.app.tip.chatNodeSystemPromptTip", + "debugLabel": "", + "toolDescription": "", + "value": "You are an enterprise-grade AI knowledge assistant.\n\n\nThe following are user-specified priority rules. When conflicting with other rules, these rules MUST be followed first:\n\n1. Answer strictly and only based on the knowledge base content. Do not use external knowledge or make assumptions.\n2. Prior conversation history may be used only to understand user intent, not as a source of factual information.\n3. If the knowledge base content is insufficient to answer the question, clearly state that the information is not available.\n4. If the knowledge base contains conflicting information, explicitly point out the conflict without resolving it.\n5. Use the same language as the user's question.\n6. Image links must strictly be output in the format ![](url).\n" }, + { + "key": "history", + "renderTypeList": ["numberInput", "reference"], + "valueType": "chatHistory", + "label": "common:core.module.input.label.chat history", + "description": "workflow:max_dialog_rounds", + "required": true, + "min": 0, + "max": 50, + "value": 6, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quoteQA", + "renderTypeList": ["settingDatasetQuotePrompt"], + "label": "", + "debugLabel": "Knowledge Base Quote", + "valueType": "datasetQuote", + "value": ["iKBoX2vIzETU", "quoteQA"], + "description": "", + "toolDescription": "" + }, + { + "key": "fileUrlList", + "renderTypeList": ["reference", "input"], + "label": "app:workflow.user_file_input", + "debugLabel": "File Link", + "description": "app:workflow.user_file_input_desc", + "valueType": "arrayString", + "value": [["workflowStartNodeId", "userFiles"]], + "toolDescription": "" + }, + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "toolDescription": "User's question", + "required": true, + "value": ["workflowStartNodeId", "userChatInput"], + "debugLabel": "" + } + ], + "outputs": [ + { + "id": "history", + "key": "history", + "required": true, + "label": "common:core.module.output.label.New context", + "description": "Concatenate this reply with history to return as new context", + "valueType": "chatHistory", + "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]", + "type": "static" + }, + { + "id": "answerText", + "key": "answerText", + "required": true, + "label": "common:core.module.output.label.Ai response content", + "description": "Will be triggered after stream reply is completed", + "valueType": "string", + "type": "static", + "valueDesc": "" + }, + { + "id": "reasoningText", + "key": "reasoningText", + "required": false, + "label": "workflow:reasoning_text", + "valueType": "string", + "type": "static", + "invalid": false, + "valueDesc": "", + "description": "" + }, + { + "id": "system_error_text", + "key": "system_error_text", + "type": "error", + "valueType": "string", + "label": "workflow:error_text", + "valueDesc": "", + "description": "" + } + ], + "catchError": false + }, + { + "nodeId": "lb9O8Jqhq5RMomsX", + "name": "Check Correction Data Match", + "intro": "Execute different branches based on conditions.", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 4357.719230229326, + "y": -1606.640341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": [ + "hidden" + ], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "valueType": "input", + "variable": [ + "VARIABLE_NODE_ID", + "hTRJXdb1" + ], + "condition": "isNotEmpty" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "m8xjpNzwcx2yE3Ar", + "name": "Use Correction Data Reply", + "intro": "This module can directly reply with specified content. Commonly used for guidance and prompts. Non-string content will be converted to string for output.", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 5194.719230229326, + "y": -1826.140341718712 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": [ + "textarea", + "reference" + ], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": [ + "VARIABLE_NODE_ID", + "hTRJXdb1" + ], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "vjq2vUcq4Tmxiu0z", + "name": "Reply Mode Judgment", + "intro": "Execute different branches based on certain conditions.", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 2683.7192302293265, + "y": -1415.890341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "utjZSg8f"], + "condition": "equalTo", + "value": "llm-summary", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "yv0uRQ4kOeVbaQ6i", + "name": "Check FAQ Data Match", + "intro": "Execute different branches based on certain conditions.", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 3520.7192302293265, + "y": -947.1403417187121 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "udQRlgfO"], + "condition": "isNotEmpty", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "v2hbYrBvxfBKTG7s", + "name": "Use FAQ Data Reply", + "intro": "This module can directly reply with specified content. Commonly used for guidance and prompts. Non-string content will be converted to string for output.", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 4383.219230229326, + "y": -858.6403417187121 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": ["textarea", "reference"], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": ["VARIABLE_NODE_ID", "udQRlgfO"], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "ekVOtsUJMYWg4col", + "name": "Fallback Reply Switch Check", + "intro": "Execute different branches based on certain conditions.", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 4357.719230229326, + "y": -555.6403417187121 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "vKEVhtS6"], + "condition": "equalTo", + "value": "useFallbackReply", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + } + ], + "edges": [ + { + "source": "iKBoX2vIzETU", + "target": "qaFDoVSH4WcXMZPO", + "sourceHandle": "iKBoX2vIzETU-source-right", + "targetHandle": "qaFDoVSH4WcXMZPO-target-left" + }, + { + "source": "variableUpdateNodeId", + "target": "iKBoX2vIzETU", + "sourceHandle": "variableUpdateNodeId-source-right", + "targetHandle": "iKBoX2vIzETU-target-left" + }, + { + "source": "workflowStartNodeId", + "target": "variableUpdateNodeId", + "sourceHandle": "workflowStartNodeId-source-right", + "targetHandle": "variableUpdateNodeId-target-left" + }, + { + "source": "yv0uRQ4kOeVbaQ6i", + "target": "v2hbYrBvxfBKTG7s", + "sourceHandle": "yv0uRQ4kOeVbaQ6i-source-IF", + "targetHandle": "v2hbYrBvxfBKTG7s-target-left" + }, + { + "source": "vjq2vUcq4Tmxiu0z", + "target": "yv0uRQ4kOeVbaQ6i", + "sourceHandle": "vjq2vUcq4Tmxiu0z-source-ELSE", + "targetHandle": "yv0uRQ4kOeVbaQ6i-target-left" + }, + { + "source": "qaFDoVSH4WcXMZPO", + "target": "vjq2vUcq4Tmxiu0z", + "sourceHandle": "qaFDoVSH4WcXMZPO-source-ELSE", + "targetHandle": "vjq2vUcq4Tmxiu0z-target-left" + }, + { + "source": "lb9O8Jqhq5RMomsX", + "target": "m8xjpNzwcx2yE3Ar", + "sourceHandle": "lb9O8Jqhq5RMomsX-source-IF", + "targetHandle": "m8xjpNzwcx2yE3Ar-target-left" + }, + { + "source": "lb9O8Jqhq5RMomsX", + "target": "7BdojPlukIQw", + "sourceHandle": "lb9O8Jqhq5RMomsX-source-ELSE", + "targetHandle": "7BdojPlukIQw-target-left" + }, + { + "source": "vjq2vUcq4Tmxiu0z", + "target": "lb9O8Jqhq5RMomsX", + "sourceHandle": "vjq2vUcq4Tmxiu0z-source-IF", + "targetHandle": "lb9O8Jqhq5RMomsX-target-left" + }, + { + "source": "yv0uRQ4kOeVbaQ6i", + "target": "lb9O8Jqhq5RMomsX", + "sourceHandle": "yv0uRQ4kOeVbaQ6i-source-ELSE", + "targetHandle": "lb9O8Jqhq5RMomsX-target-left" + }, + { + "source": "qaFDoVSH4WcXMZPO", + "target": "ekVOtsUJMYWg4col", + "sourceHandle": "qaFDoVSH4WcXMZPO-source-IF", + "targetHandle": "ekVOtsUJMYWg4col-target-left" + }, + { + "source": "ekVOtsUJMYWg4col", + "target": "vprtN0xvK1dV0c6M", + "sourceHandle": "ekVOtsUJMYWg4col-source-IF", + "targetHandle": "vprtN0xvK1dV0c6M-target-left" + }, + { + "source": "ekVOtsUJMYWg4col", + "target": "7BdojPlukIQw", + "sourceHandle": "ekVOtsUJMYWg4col-source-ELSE", + "targetHandle": "7BdojPlukIQw-target-left" + } + ], + "chatConfig": { + "variables": [ + { + "key": "byG7WNk4", + "label": "fallbackReply", + "type": "input", + "description": "Fallback reply content", + "required": false, + "valueType": "string", + "defaultValue": "", + "maxLength": "", + "icon": "core/workflow/inputType/input" + }, + { + "key": "utjZSg8f", + "label": "faqAnswerMode", + "type": "select", + "description": "FAQ Answer Mode Config", + "required": false, + "valueType": "string", + "list": [ + { + "value": "quote", + "label": "quote" + }, + { + "label": "llm-summary", + "value": "llm-summary" + } + ], + "defaultValue": "", + "icon": "core/workflow/inputType/option" + }, + { + "key": "hTRJXdb1", + "label": "correct_mapping_answer", + "type": "custom", + "description": "Correction data matching result", + "required": false, + "valueType": "string", + "defaultValue": "", + "icon": "core/workflow/inputType/customVariable" + }, + { + "key": "udQRlgfO", + "label": "faqAnswer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "" + }, + { + "key": "vKEVhtS6", + "label": "enableFallbackReply", + "type": "select", + "description": "Enable fallback reply when knowledge base has no results", + "required": true, + "valueType": "string", + "defaultValue": "useFallbackReply", + "icon": "core/workflow/inputType/option", + "list": [ + { + "label": "useFallbackReply", + "value": "useFallbackReply" + }, + { + "label": "llmReply", + "value": "llmReply" + } + ] + } + ], + "scheduledTriggerConfig": { + "cronString": "", + "timezone": "Asia/Shanghai", + "defaultPrompt": "" + }, + "welcomeText": "Hello, how can I help you today?" + } + } +} diff --git a/packages/templates/src/assistant/template.json b/packages/templates/src/assistant/template.json new file mode 100644 index 000000000000..7f7c76c75b34 --- /dev/null +++ b/packages/templates/src/assistant/template.json @@ -0,0 +1,1093 @@ +{ + "name": "智能问答", + "intro": "专为客户服务打造的智能助手,结合知识库检索和智能对话能力", + "author": "FastGPT", + "avatar": "core/workflow/template/customerService", + "tags": ["customer-service"], + "type": "assistant", + "weight": 1, + "workflow": { + "nodes": [ + { + "nodeId": "userGuide", + "name": "系统配置", + "intro": "可以配置应用的系统参数", + "avatar": "core/workflow/template/systemConfig", + "flowNodeType": "userGuide", + "position": { + "x": -67.28076977067361, + "y": -598.1403417187121 + }, + "version": "481", + "inputs": [ + { + "key": "welcomeText", + "renderTypeList": ["hidden"], + "valueType": "string", + "label": "core.app.Welcome Text", + "value": "" + }, + { + "key": "variables", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "core.app.Chat Variable", + "value": [ + { + "key": "hTRJXdb1", + "label": "correct_mapping_answer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "", + "icon": "core/workflow/inputType/customVariable" + }, + { + "key": "udQRlgfO", + "label": "faqAnswer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "" + } + ] + }, + { + "key": "questionGuide", + "valueType": "any", + "renderTypeList": ["hidden"], + "label": "core.app.Question Guide", + "value": { + "open": true + } + }, + { + "key": "tts", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": { + "type": "web" + } + }, + { + "key": "whisper", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": { + "open": false, + "autoSend": false, + "autoTTSResponse": false + } + }, + { + "key": "scheduleTrigger", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": null + } + ], + "outputs": [] + }, + { + "nodeId": "workflowStartNodeId", + "name": "流程开始", + "intro": "", + "avatar": "core/workflow/template/workflowStart", + "flowNodeType": "workflowStart", + "position": { + "x": 3.719230229326385, + "y": -1352.140341718712 + }, + "version": "481", + "inputs": [ + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "required": true, + "toolDescription": "用户问题", + "debugLabel": "" + } + ], + "outputs": [ + { + "id": "userChatInput", + "key": "userChatInput", + "label": "common:core.module.input.label.user question", + "type": "static", + "valueType": "string", + "description": "" + } + ] + }, + { + "nodeId": "variableUpdateNodeId", + "name": "问答配置", + "intro": "可以更新指定节点的输出值或更新全局变量", + "avatar": "core/workflow/template/variableUpdate", + "flowNodeType": "variableUpdate", + "showStatus": false, + "position": { + "x": 494.7192302293264, + "y": -1232.140341718712 + }, + "inputs": [ + { + "key": "updateList", + "valueType": "any", + "label": "", + "renderTypeList": ["hidden"], + "value": [ + { + "variable": ["VARIABLE_NODE_ID", "byG7WNk4"], + "value": [ + "", + "抱歉,我没有找到答案,您可以尝试重新提问,我也会帮您反馈给相关团队。" + ], + "valueType": "string", + "renderType": "input" + }, + { + "variable": ["VARIABLE_NODE_ID", "utjZSg8f"], + "value": ["", "quote"], + "renderType": "input", + "valueType": "string" + }, + { + "variable": ["VARIABLE_NODE_ID", "hTRJXdb1"], + "value": ["", ""], + "renderType": "input", + "valueType": "string" + }, + { + "variable": ["VARIABLE_NODE_ID", "udQRlgfO"], + "value": ["", ""], + "renderType": "input", + "valueType": "string" + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "iKBoX2vIzETU", + "name": "知识库搜索", + "intro": "调用语义检索、全文检索、数据库检索能力,从知识库中查找可能与问题相关的参考内容", + "avatar": "core/workflow/template/datasetSearch", + "flowNodeType": "datasetSearchNode", + "showStatus": true, + "position": { + "x": 1060.7192302293263, + "y": -1383.640341718712 + }, + "version": "4.9.2", + "inputs": [ + { + "key": "datasets", + "renderTypeList": ["selectDataset", "reference"], + "label": "common:core.module.input.label.Select dataset", + "value": [], + "valueType": "selectDataset", + "required": true, + "valueDesc": "{\n datasetId: string;\n}[]", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "similarity", + "renderTypeList": ["selectDatasetParamsModal"], + "label": "", + "value": 0.4, + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "limit", + "renderTypeList": ["hidden"], + "label": "", + "value": 5000, + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "searchMode", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "mixedRecall", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "embeddingWeight", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "value": 0.65, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "usingReRank", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": false, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "rerankModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "", + "value": "" + }, + { + "key": "rerankMethod", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "question", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "rerankWeight", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "value": 0.4, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "datasetSearchUsingExtensionQuery", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "datasetSearchExtensionModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "", + "value": "" + }, + { + "key": "datasetSearchExtensionBg", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "authTmbId", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": false, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "toolDescription": "需要检索的内容", + "required": true, + "value": ["workflowStartNodeId", "userChatInput"], + "debugLabel": "" + }, + { + "key": "collectionFilterMatch", + "renderTypeList": ["textarea", "reference"], + "label": "workflow:collection_metadata_filter", + "valueType": "string", + "isPro": true, + "description": "workflow:filter_description", + "debugLabel": "", + "toolDescription": "", + "selectedTypeIndex": 1 + }, + { + "key": "generateSqlModel", + "renderTypeList": ["hidden"], + "label": "common:search_model", + "value": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "retrievalMode", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "standard", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchLLMModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchRerankModel", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "agenticSearchReasoning", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "quoteQA", + "key": "quoteQA", + "label": "common:core.module.Dataset quote.label", + "description": "特殊数组格式,搜索结果为空时,返回空数组。", + "type": "static", + "valueType": "datasetQuote", + "valueDesc": "{\n id: string;\n datasetId: string;\n collectionId: string;\n sourceName: string;\n sourceId?: string;\n q: string;\n a: string\n}[]" + }, + { + "id": "system_error_text", + "key": "system_error_text", + "type": "error", + "valueType": "string", + "label": "workflow:error_text", + "valueDesc": "", + "description": "" + } + ], + "catchError": false + }, + { + "nodeId": "qaFDoVSH4WcXMZPO", + "name": "是否启用兜底回复", + "intro": "根据一定的条件,执行不同的分支。", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 1846.7192302293263, + "y": -1205.140341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["iKBoX2vIzETU", "quoteQA"], + "condition": "isEmpty", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "vprtN0xvK1dV0c6M", + "name": "兜底回复", + "intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 5194.719230229326, + "y": -467.1403417187121 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": ["textarea", "reference"], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": ["VARIABLE_NODE_ID", "byG7WNk4"], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "7BdojPlukIQw", + "name": "AI 对话", + "intro": "AI 大模型对话", + "avatar": "core/workflow/template/aiChat", + "flowNodeType": "chatNode", + "showStatus": true, + "position": { + "x": 5265.719230229326, + "y": -1523.140341718712 + }, + "version": "4.9.7", + "inputs": [ + { + "key": "model", + "renderTypeList": ["settingLLMModel", "reference"], + "label": "common:core.module.input.label.aiModel", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "temperature", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "maxToken", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "isResponseAnswerText", + "renderTypeList": ["hidden"], + "label": "", + "value": true, + "valueType": "boolean", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatQuoteRole", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "value": "system", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quoteTemplate", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quotePrompt", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatVision", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatReasoning", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "boolean", + "value": true, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatTopP", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "number", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatStopSign", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatResponseFormat", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "aiChatJsonSchema", + "renderTypeList": ["hidden"], + "label": "", + "valueType": "string", + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "systemPrompt", + "renderTypeList": ["textarea", "reference"], + "max": 3000, + "valueType": "string", + "label": "common:core.ai.Prompt", + "description": "common:core.app.tip.systemPromptTip", + "placeholder": "common:core.app.tip.chatNodeSystemPromptTip", + "debugLabel": "", + "toolDescription": "", + "value": "You are an enterprise-grade AI knowledge assistant.\n\n\nThe following are user-specified priority rules. When conflicting with other rules, these rules MUST be followed first:\n\n1. Answer strictly and only based on the knowledge base content. Do not use external knowledge or make assumptions.\n2. Prior conversation history may be used only to understand user intent, not as a source of factual information.\n3. If the knowledge base content is insufficient to answer the question, clearly state that the information is not available.\n4. If the knowledge base contains conflicting information, explicitly point out the conflict without resolving it.\n5. Use the same language as the user's question.\n6. Image links must strictly be output in the format ![](url).\n" + }, + { + "key": "history", + "renderTypeList": ["numberInput", "reference"], + "valueType": "chatHistory", + "label": "common:core.module.input.label.chat history", + "description": "workflow:max_dialog_rounds", + "required": true, + "min": 0, + "max": 50, + "value": 6, + "debugLabel": "", + "toolDescription": "" + }, + { + "key": "quoteQA", + "renderTypeList": ["settingDatasetQuotePrompt"], + "label": "", + "debugLabel": "知识库引用", + "valueType": "datasetQuote", + "value": ["iKBoX2vIzETU", "quoteQA"], + "description": "", + "toolDescription": "" + }, + { + "key": "fileUrlList", + "renderTypeList": ["reference", "input"], + "label": "app:workflow.user_file_input", + "debugLabel": "文件链接", + "description": "app:workflow.user_file_input_desc", + "valueType": "arrayString", + "value": [["workflowStartNodeId", "userFiles"]], + "toolDescription": "" + }, + { + "key": "userChatInput", + "renderTypeList": ["reference", "textarea"], + "valueType": "string", + "label": "workflow:user_question", + "toolDescription": "用户问题", + "required": true, + "value": ["workflowStartNodeId", "userChatInput"], + "debugLabel": "" + } + ], + "outputs": [ + { + "id": "history", + "key": "history", + "required": true, + "label": "common:core.module.output.label.New context", + "description": "将本次回复内容拼接上历史记录,作为新的上下文返回", + "valueType": "chatHistory", + "valueDesc": "{\n obj: System | Human | AI;\n value: string;\n}[]", + "type": "static" + }, + { + "id": "answerText", + "key": "answerText", + "required": true, + "label": "common:core.module.output.label.Ai response content", + "description": "将在 stream 回复完毕后触发", + "valueType": "string", + "type": "static", + "valueDesc": "" + }, + { + "id": "reasoningText", + "key": "reasoningText", + "required": false, + "label": "workflow:reasoning_text", + "valueType": "string", + "type": "static", + "invalid": false, + "valueDesc": "", + "description": "" + }, + { + "id": "system_error_text", + "key": "system_error_text", + "type": "error", + "valueType": "string", + "label": "workflow:error_text", + "valueDesc": "", + "description": "" + } + ], + "catchError": false + }, + { + "nodeId": "lb9O8Jqhq5RMomsX", + "name": "是否命中校正数据", + "intro": "根据一定的条件,执行不同的分支。", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 4357.719230229326, + "y": -1606.640341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "valueType": "input", + "variable": ["VARIABLE_NODE_ID", "hTRJXdb1"], + "condition": "isNotEmpty" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "m8xjpNzwcx2yE3Ar", + "name": "使用校正数据回复", + "intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 5194.719230229326, + "y": -1826.140341718712 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": ["textarea", "reference"], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": ["VARIABLE_NODE_ID", "hTRJXdb1"], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "vjq2vUcq4Tmxiu0z", + "name": "回复模式判断", + "intro": "根据一定的条件,执行不同的分支。", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 2683.7192302293265, + "y": -1415.890341718712 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "utjZSg8f"], + "condition": "equalTo", + "value": "llm-summary", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "yv0uRQ4kOeVbaQ6i", + "name": "是否命中faq数据", + "intro": "根据一定的条件,执行不同的分支。", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 3520.7192302293265, + "y": -947.1403417187121 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "udQRlgfO"], + "condition": "isNotEmpty", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + }, + { + "nodeId": "v2hbYrBvxfBKTG7s", + "name": "使用faq数据回复", + "intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。", + "avatar": "core/workflow/template/reply", + "flowNodeType": "answerNode", + "position": { + "x": 4383.219230229326, + "y": -858.6403417187121 + }, + "inputs": [ + { + "key": "text", + "renderTypeList": ["textarea", "reference"], + "valueType": "any", + "required": true, + "label": "common:core.module.input.label.Response content", + "description": "common:core.module.input.description.Response content", + "placeholder": "common:core.module.input.description.Response content", + "value": ["VARIABLE_NODE_ID", "udQRlgfO"], + "selectedTypeIndex": 1, + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [] + }, + { + "nodeId": "ekVOtsUJMYWg4col", + "name": "兜底回复开关判断", + "intro": "根据一定的条件,执行不同的分支。", + "avatar": "core/workflow/template/ifelse", + "flowNodeType": "ifElseNode", + "showStatus": true, + "position": { + "x": 4357.719230229326, + "y": -555.6403417187121 + }, + "inputs": [ + { + "key": "ifElseList", + "renderTypeList": ["hidden"], + "valueType": "any", + "label": "", + "value": [ + { + "condition": "AND", + "list": [ + { + "variable": ["VARIABLE_NODE_ID", "vKEVhtS6"], + "condition": "equalTo", + "value": "useFallbackReply", + "valueType": "input" + } + ] + } + ], + "debugLabel": "", + "toolDescription": "" + } + ], + "outputs": [ + { + "id": "ifElseResult", + "key": "ifElseResult", + "label": "workflow:judgment_result", + "valueType": "string", + "type": "static", + "valueDesc": "", + "description": "" + } + ] + } + ], + "edges": [ + { + "source": "iKBoX2vIzETU", + "target": "qaFDoVSH4WcXMZPO", + "sourceHandle": "iKBoX2vIzETU-source-right", + "targetHandle": "qaFDoVSH4WcXMZPO-target-left" + }, + { + "source": "variableUpdateNodeId", + "target": "iKBoX2vIzETU", + "sourceHandle": "variableUpdateNodeId-source-right", + "targetHandle": "iKBoX2vIzETU-target-left" + }, + { + "source": "workflowStartNodeId", + "target": "variableUpdateNodeId", + "sourceHandle": "workflowStartNodeId-source-right", + "targetHandle": "variableUpdateNodeId-target-left" + }, + { + "source": "yv0uRQ4kOeVbaQ6i", + "target": "v2hbYrBvxfBKTG7s", + "sourceHandle": "yv0uRQ4kOeVbaQ6i-source-IF", + "targetHandle": "v2hbYrBvxfBKTG7s-target-left" + }, + { + "source": "vjq2vUcq4Tmxiu0z", + "target": "yv0uRQ4kOeVbaQ6i", + "sourceHandle": "vjq2vUcq4Tmxiu0z-source-ELSE", + "targetHandle": "yv0uRQ4kOeVbaQ6i-target-left" + }, + { + "source": "qaFDoVSH4WcXMZPO", + "target": "vjq2vUcq4Tmxiu0z", + "sourceHandle": "qaFDoVSH4WcXMZPO-source-ELSE", + "targetHandle": "vjq2vUcq4Tmxiu0z-target-left" + }, + { + "source": "lb9O8Jqhq5RMomsX", + "target": "m8xjpNzwcx2yE3Ar", + "sourceHandle": "lb9O8Jqhq5RMomsX-source-IF", + "targetHandle": "m8xjpNzwcx2yE3Ar-target-left" + }, + { + "source": "lb9O8Jqhq5RMomsX", + "target": "7BdojPlukIQw", + "sourceHandle": "lb9O8Jqhq5RMomsX-source-ELSE", + "targetHandle": "7BdojPlukIQw-target-left" + }, + { + "source": "vjq2vUcq4Tmxiu0z", + "target": "lb9O8Jqhq5RMomsX", + "sourceHandle": "vjq2vUcq4Tmxiu0z-source-IF", + "targetHandle": "lb9O8Jqhq5RMomsX-target-left" + }, + { + "source": "yv0uRQ4kOeVbaQ6i", + "target": "lb9O8Jqhq5RMomsX", + "sourceHandle": "yv0uRQ4kOeVbaQ6i-source-ELSE", + "targetHandle": "lb9O8Jqhq5RMomsX-target-left" + }, + { + "source": "qaFDoVSH4WcXMZPO", + "target": "ekVOtsUJMYWg4col", + "sourceHandle": "qaFDoVSH4WcXMZPO-source-IF", + "targetHandle": "ekVOtsUJMYWg4col-target-left" + }, + { + "source": "ekVOtsUJMYWg4col", + "target": "vprtN0xvK1dV0c6M", + "sourceHandle": "ekVOtsUJMYWg4col-source-IF", + "targetHandle": "vprtN0xvK1dV0c6M-target-left" + }, + { + "source": "ekVOtsUJMYWg4col", + "target": "7BdojPlukIQw", + "sourceHandle": "ekVOtsUJMYWg4col-source-ELSE", + "targetHandle": "7BdojPlukIQw-target-left" + } + ], + "chatConfig": { + "variables": [ + { + "key": "byG7WNk4", + "label": "fallbackReply", + "type": "input", + "description": "兜底回复内容", + "required": false, + "valueType": "string", + "defaultValue": "", + "maxLength": "", + "icon": "core/workflow/inputType/input" + }, + { + "key": "utjZSg8f", + "label": "faqAnswerMode", + "type": "select", + "description": "FAQ知识回答配置", + "required": false, + "valueType": "string", + "list": [ + { + "value": "quote", + "label": "quote" + }, + { + "label": "llm-summary", + "value": "llm-summary" + } + ], + "defaultValue": "", + "icon": "core/workflow/inputType/option" + }, + { + "key": "hTRJXdb1", + "label": "correct_mapping_answer", + "type": "custom", + "description": "校正数据匹配结果", + "required": false, + "valueType": "string", + "defaultValue": "", + "icon": "core/workflow/inputType/customVariable" + }, + { + "key": "udQRlgfO", + "label": "faqAnswer", + "type": "custom", + "description": "", + "required": false, + "valueType": "string", + "defaultValue": "" + }, + { + "key": "vKEVhtS6", + "label": "enableFallbackReply", + "type": "select", + "description": "是否启用兜底回复", + "required": true, + "valueType": "string", + "defaultValue": "useFallbackReply", + "icon": "core/workflow/inputType/option", + "list": [ + { + "label": "useFallbackReply", + "value": "useFallbackReply" + }, + { + "label": "llmReply", + "value": "llmReply" + } + ] + } + ], + "scheduledTriggerConfig": { + "cronString": "", + "timezone": "Asia/Shanghai", + "defaultPrompt": "" + }, + "welcomeText": "您好,请问有什么可以帮您?" + } + } +} diff --git a/packages/web/common/file/hooks/useUploadAvatar.tsx b/packages/web/common/file/hooks/useUploadAvatar.tsx index 449d19f0ef7e..3719014ea993 100644 --- a/packages/web/common/file/hooks/useUploadAvatar.tsx +++ b/packages/web/common/file/hooks/useUploadAvatar.tsx @@ -7,17 +7,19 @@ import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants'; import type { CreatePostPresignedUrlResponseType } from '@fastgpt/global/common/file/s3/type'; export const useUploadAvatar = ( - api: (params: { filename: string }) => Promise, + api: (params: { filename: string; autoExpired?: boolean }) => Promise, { onSuccess, maxW = 300, maxH = 300, - maxSize = 1024 * 500 // 500KB + maxSize = 1024 * 500, // 500KB + autoExpired }: { onSuccess?: (avatar: string) => void; maxW?: number; maxH?: number; maxSize?: number; + autoExpired?: boolean; } = {} ) => { const { toast } = useToast(); @@ -48,7 +50,7 @@ export const useUploadAvatar = ( }), file.name ); - const { url, key, headers } = await api({ filename: file.name }); + const { url, key, headers } = await api({ filename: file.name, autoExpired }); await putFileToS3({ url, diff --git a/packages/web/common/system/utils.ts b/packages/web/common/system/utils.ts index bc6bf645e5c1..cecfe8499812 100644 --- a/packages/web/common/system/utils.ts +++ b/packages/web/common/system/utils.ts @@ -1,6 +1,5 @@ -import FingerprintJS from '@fingerprintjs/fingerprintjs'; - export const getUserFingerprint = async () => { + const FingerprintJS = (await import('@fingerprintjs/fingerprintjs')).default; const fp = await FingerprintJS.load(); const result = await fp.get(); console.log(result.visitorId); diff --git a/packages/web/components/common/Avatar/index.tsx b/packages/web/components/common/Avatar/index.tsx index d886a16313da..b56d8b6ddb3b 100644 --- a/packages/web/components/common/Avatar/index.tsx +++ b/packages/web/components/common/Avatar/index.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { Box } from '@chakra-ui/react'; import type { ImageProps } from '@chakra-ui/react'; -import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; +import { LOGO_ICON, SANGFOR_LOGO_ICON } from '@fastgpt/global/common/system/constants'; import MyIcon from '../Icon'; import { iconPaths } from '../Icon/constants'; import MyImage from '../Image/MyImage'; +import { useSystem } from '../../../hooks/useSystem'; +import { getWebReqUrl } from '../../../common/system/utils'; const Avatar = ({ w = '30px', @@ -14,6 +16,9 @@ const Avatar = ({ }: Omit & { src?: string | null }) => { // @ts-ignore const isIcon = !!iconPaths[src as any]; + const isAicp = src?.toLowerCase()?.includes('aicp'); + const { systemLogo } = useSystem(); + const defaultIcon = getWebReqUrl(systemLogo || LOGO_ICON); return isIcon ? ( @@ -26,13 +31,13 @@ const Avatar = ({ ) : ( ); diff --git a/packages/web/components/common/DateRangePicker/index.tsx b/packages/web/components/common/DateRangePicker/index.tsx index 2070af291145..ae471bccdbe0 100644 --- a/packages/web/components/common/DateRangePicker/index.tsx +++ b/packages/web/components/common/DateRangePicker/index.tsx @@ -4,7 +4,7 @@ import { Box, Card, Flex, useTheme, useOutsideClick, Button } from '@chakra-ui/r import { addDays, format } from 'date-fns'; import { DayPicker } from 'react-day-picker'; import 'react-day-picker/dist/style.css'; -import { zhCN } from 'date-fns/locale/zh-CN'; +import { zhCN, zhTW, enUS } from 'date-fns/locale'; import { useTranslation } from 'next-i18next'; import MyIcon from '../Icon'; @@ -32,10 +32,27 @@ const DateRangePicker = ({ dateRange?: DateRangeType; formLabel?: string; } & BoxProps) => { - const { t } = useTranslation(); - const OutRangeRef = useRef(null); + const { t, i18n } = useTranslation(); + const OutRangeRef = useRef(null); const [range, setRange] = useState(defaultDate); const [showSelected, setShowSelected] = useState(false); + const [pickerPosition, setPickerPosition] = useState<{ left: number | string; right?: number }>({ + left: 0 + }); + + // 根据当前语言配置获取对应的 locale + const getLocale = () => { + switch (i18n.language) { + case 'zh-TW': + case 'zh-Hant': + return zhTW; + case 'en': + return enUS; + case 'zh-CN': + default: + return zhCN; + } + }; useEffect(() => { if (dateRange) { @@ -57,6 +74,28 @@ const DateRangePicker = ({ } }); + // 计算选择器位置 + useEffect(() => { + if (showSelected && OutRangeRef.current) { + const rect = OutRangeRef.current.getBoundingClientRect(); + const pickerWidth = 600; // DayPicker 大概宽度 + const viewportWidth = window.innerWidth; + const scrollbarWidth = 16; // 滚动条宽度 + + // 智能对齐:如果右侧会溢出,则右对齐 + if (rect.left + pickerWidth > viewportWidth - scrollbarWidth) { + // 右侧对齐 + setPickerPosition({ + left: 'auto', + right: 0 + }); + } else { + // 左侧对齐 + setPickerPosition({ left: 0 }); + } + } + }, [showSelected]); + return ( setShowSelected(true)} alignItems={'center'} @@ -94,7 +132,12 @@ const DateRangePicker = ({ ? { bottom: '40px' } - : {})} + : { + top: '40px' + })} + {...(pickerPosition.right !== undefined + ? { right: pickerPosition.right, left: 'auto' } + : { left: pickerPosition.left })} > { + value?: Date | null; + onChange?: (date: Date | null) => void; + placeholder?: string; + formatString?: string; + showTime?: boolean; + minuteStep?: number; + disabled?: boolean; +} const DateTimePicker = ({ + value, onChange, - popPosition = 'bottom', - defaultDate, - selectedDateTime, - disabled, - isDisabled, + placeholder, + formatString = 'yyyy-MM-dd HH:mm', + showTime = true, + minuteStep = 1, + disabled = false, ...props -}: { - onChange?: (dateTime: Date | undefined) => void; - popPosition?: 'bottom' | 'top'; - defaultDate?: Date; - selectedDateTime?: Date; - disabled?: Matcher[]; - isDisabled?: boolean; -} & Omit) => { - const containerRef = useRef(null); - const popoverRef = useRef(null); - const [selectedDate, setSelectedDate] = useState( - selectedDateTime || defaultDate - ); - const [showSelected, setShowSelected] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0 }); +}: DateTimePickerProps) => { + const { t, i18n } = useTranslation(); + const pickerRef = useRef(null); + const cardRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(value || null); + const [hours, setHours] = useState(value?.getHours() || 0); + const [minutes, setMinutes] = useState(value?.getMinutes() || 0); + + // 根据当前语言配置获取对应的 locale + const getLocale = () => { + switch (i18n.language) { + case 'zh-TW': + case 'zh-Hant': + return zhTW; + case 'en': + return enUS; + case 'zh-CN': + default: + return zhCN; + } + }; + + // 根据语言获取月份和星期名称 + const getLocalizedText = () => { + const locale = getLocale(); + + // 生成小时选项 + const hoursOptions = Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: i.toString().padStart(2, '0') + })); + + // 生成分钟选项 + const minutesOptions = Array.from({ length: Math.floor(60 / minuteStep) }, (_, i) => ({ + value: i * minuteStep, + label: (i * minuteStep).toString().padStart(2, '0') + })); + + return { + hoursOptions, + minutesOptions, + locale + }; + }; + + const { hoursOptions, minutesOptions, locale } = getLocalizedText(); + + // 格式化显示文本 + const displayValue = useMemo(() => { + if (!selectedDate) { + // 如果没有选中日期,尝试使用外部传入的值 + if (value) { + return format(value, formatString, { locale }); + } + return ''; + } + if (showTime) { + const dateWithTime = new Date(selectedDate); + dateWithTime.setHours(hours, minutes, 0, 0); + return format(dateWithTime, formatString, { locale }); + } + + return format(selectedDate, 'yyyy-MM-dd', { locale }); + }, [selectedDate, hours, minutes, formatString, showTime, locale, value]); + + // 同步外部 value 变化 useEffect(() => { - setSelectedDate(selectedDateTime); - }, [selectedDateTime]); + if (value && value !== selectedDate) { + setSelectedDate(value); + setHours(value.getHours()); + setMinutes(value.getMinutes()); + } else if (!value && selectedDate) { + setSelectedDate(null); + setHours(0); + setMinutes(0); + } + }, [value]); + // 监听 selectedDate 变化,确保时间状态正确同步 useEffect(() => { - if (!showSelected) return; - const updatePosition = () => { - const rect = containerRef.current?.getBoundingClientRect(); - const popoverRect = popoverRef.current?.getBoundingClientRect(); - if (!rect || !popoverRect) return; - - const padding = 8; - const topBottom = rect.bottom + 4; - const topTop = rect.top - 4 - popoverRect.height; - const maxTop = window.innerHeight - popoverRect.height - padding; - const maxLeft = window.innerWidth - popoverRect.width - padding; - - let top = popPosition === 'top' ? topTop : topBottom; - if ( - popPosition === 'bottom' && - topBottom + popoverRect.height > window.innerHeight - padding && - topTop >= padding - ) { - top = topTop; + if (selectedDate && hours === 0 && minutes === 0 && !value) { + // 只有在没有外部值且时间为默认值时才更新时间 + const currentTime = new Date(); + setHours(currentTime.getHours()); + setMinutes(currentTime.getMinutes()); + } + }, [selectedDate, value, hours, minutes]); + + // 处理日期选择 + const handleDateSelect = (date: Date | undefined) => { + if (date) { + setSelectedDate(date); + // 确保时间状态不为空,使用当前时间或之前选择的时间 + if (hours === 0 && minutes === 0) { + const currentHours = value?.getHours() ?? new Date().getHours(); + const currentMinutes = value?.getMinutes() ?? new Date().getMinutes(); + setHours(currentHours); + setMinutes(currentMinutes); } - if ( - popPosition === 'top' && - topTop < padding && - topBottom + popoverRect.height <= window.innerHeight - padding - ) { - top = topBottom; + } else { + setSelectedDate(null); + } + }; + + // 处理时间变化 + const handleTimeChange = (newHours: number, newMinutes: number) => { + setHours(newHours); + setMinutes(newMinutes); + }; + + // 确认选择 + const handleConfirm = () => { + if (selectedDate) { + const finalDate = new Date(selectedDate); + if (showTime) { + finalDate.setHours(hours, minutes, 0, 0); + } else { + // 如果不显示时间,设置为当天的开始 + finalDate.setHours(0, 0, 0, 0); } - top = Math.min(Math.max(top, padding), Math.max(padding, maxTop)); + onChange?.(finalDate); + } else { + onChange?.(null); + } + setIsOpen(false); + }; - let left = rect.left; - left = Math.min(Math.max(left, padding), Math.max(padding, maxLeft)); + // 清除选择 + const handleClear = () => { + setSelectedDate(null); + setHours(0); + setMinutes(0); + onChange?.(null); + setIsOpen(false); + }; - setPosition({ top, left }); - }; + const handleConfirmRef = useRef(handleConfirm); + useEffect(() => { + handleConfirmRef.current = handleConfirm; + }); - const raf = requestAnimationFrame(updatePosition); - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); - return () => { - cancelAnimationFrame(raf); - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); - }; - }, [showSelected, popPosition]); + const selectedDateRef = useRef(selectedDate); + useEffect(() => { + selectedDateRef.current = selectedDate; + }); - // 点击外部关闭 + // 点击外部关闭弹窗 useEffect(() => { - if (!showSelected) return; - const handleClick = (e: MouseEvent) => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; if ( - containerRef.current?.contains(e.target as Node) || - popoverRef.current?.contains(e.target as Node) + pickerRef.current && + !pickerRef.current.contains(target) && + cardRef.current && + !cardRef.current.contains(target) ) { - return; + selectedDateRef.current ? handleConfirmRef.current() : setIsOpen(false); } - setShowSelected(false); }; - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [showSelected]); - const formatSelected = useMemo(() => { - if (selectedDate) { - return format(selectedDate, 'y/MM/dd'); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // 获取输入框位置用于定位弹窗 + const getInputPosition = () => { + if (typeof window === 'undefined') return { top: '50%', left: '50%' }; + + const rect = pickerRef.current?.getBoundingClientRect(); + if (!rect) return { top: '50%', left: '50%' }; + + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const modalHeight = 400; // 预估弹窗高度 + const modalWidth = 320; // 预估弹窗宽度 + + let top = rect.bottom + 8; + let left = rect.left; + + // 如果下方空间不足,显示在上方 + if (rect.bottom + modalHeight > viewportHeight) { + top = rect.top - modalHeight - 8; + } + + // 如果右侧空间不足,向左调整 + if (rect.left + modalWidth > viewportWidth) { + left = viewportWidth - modalWidth - 16; } - return ''; - }, [selectedDate]); + + // 确保不超出左边界 + if (left < 16) { + left = 16; + } + + return { top: `${top}px`, left: `${left}px` }; + }; + + const modalPosition = getInputPosition(); return ( - - setShowSelected((state) => !state) - })} + + - - {formatSelected} + value={displayValue} + placeholder={placeholder || t('common:select_time')} + readOnly + cursor={disabled ? 'not-allowed' : 'pointer'} + onClick={() => !disabled && setIsOpen(!isOpen)} + disabled={disabled} + width="100%" + /> + + {!disabled && ( + + - - - {showSelected && ( - - - + )} + + {isOpen && !disabled && createPortal( + + {/* 日期选择器 */} + { - setSelectedDate(date); - onChange?.(date); - setShowSelected(false); + selected={selectedDate || undefined} + onSelect={handleDateSelect} + defaultMonth={selectedDate || new Date()} + disabled={[{ before: new Date(2000, 0, 1) }, { after: new Date(2100, 11, 31) }]} + required={false} + styles={{ + caption: { color: 'var(--chakra-colors-gray-800)' }, + head: { color: 'var(--chakra-colors-gray-600)' }, + nav: { color: 'var(--chakra-colors-gray-700)' } }} /> - - + + {/* 时间选择器 */} + {showTime && ( + + + handleTimeChange(newHours, minutes)} + width="80px" + list={hoursOptions.map((option) => ({ + label: option.label, + value: option.value + }))} + size="sm" + /> + : + handleTimeChange(hours, newMinutes)} + width="80px" + list={minutesOptions.map((option) => ({ + label: option.label, + value: option.value + }))} + size="sm" + /> + + + )} + + {/* 操作按钮 */} + + + + + , + document.body )} ); }; -export default DateTimePicker; +export default React.memo(DateTimePicker); diff --git a/packages/web/components/common/EllipsisTooltip/README.md b/packages/web/components/common/EllipsisTooltip/README.md new file mode 100644 index 000000000000..b982a42597b9 --- /dev/null +++ b/packages/web/components/common/EllipsisTooltip/README.md @@ -0,0 +1,56 @@ +# EllipsisTooltip + +文本溢出时自动显示 Tooltip,未溢出时不展示。 + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `label` | `string` | 必填 | 显示文本 | +| `forceShow` | `boolean` | `false` | 强制显示 Tooltip,忽略溢出检测 | +| `tooltipLabel` | `ReactNode` | `label` | Tooltip 内容,不传则同 label | +| `lineClamp` | `number` | `1` | 省略行数,1=单行,>1=多行截断 | +| `tooltipProps` | `Omit` | - | 透传给 MyTooltip 的额外属性 | +| `...boxProps` | `BoxProps` | - | 透传给内部 Box 的布局属性(如 `flex`、`minW`、`maxW`、`fontSize` 等) | + +## 使用说明 + +### 布局约束(重要) + +组件内部 `Box` 需要被父容器约束宽度才能触发省略。在 flex 容器中使用时,**必须通过 `boxProps` 传入 `flex`、`minW` 等属性**,不能依赖父层隐式约束: + +```tsx +// ✅ 正确:通过 boxProps 传入 flex 约束 + + +// ❌ 错误:未传 flex,Box 会撑开父容器,不产生省略 + +``` + +### 溢出检测原理 + +- **单行**(`lineClamp=1`):比较 `scrollWidth > clientWidth`,再临时去除 `-webkit-line-clamp` 约束对比高度变化 +- **多行**(`lineClamp>1`):临时移除截断样式,对比自然高度与截断高度(含 1px 浮点容差) + +检测在 `useLayoutEffect` 中执行(`label`/`lineClamp` 变化时重新计算),并在 `onMouseEnter` 时兜底复算。 + +## 示例 + +```tsx +// 单行省略 + + +// 多行省略(2 行) + + +// 在 flex 容器中使用(需传 flex/minW) + + + + +// 自定义 Tooltip 内容 +详细说明} maxW="100px" /> + +// 强制显示 Tooltip(不检测溢出) + +``` diff --git a/packages/web/components/common/EllipsisTooltip/index.tsx b/packages/web/components/common/EllipsisTooltip/index.tsx new file mode 100644 index 000000000000..d47d9de110e3 --- /dev/null +++ b/packages/web/components/common/EllipsisTooltip/index.tsx @@ -0,0 +1,103 @@ +/** + * @file 悬浮提示组件 + * TODO - 待优化成全局单例 + */ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { Box, type BoxProps, type TooltipProps } from '@chakra-ui/react'; +import MyTooltip from '../MyTooltip/index'; + +function isOverflow(el: HTMLElement): boolean { + if (!el) return false; + + // 单行水平溢出(white-space: nowrap + text-overflow: ellipsis) + if (el.scrollWidth > el.clientWidth) return true; + + // 多行 / 单行 line-clamp:临时移除截断约束,对比自然高度与截断高度 + // 直接修改原元素 inline style(优先级高于 class),避免克隆带来的继承样式丢失问题 + const clampedHeight = el.getBoundingClientRect().height; + el.style.webkitLineClamp = 'unset'; + el.style.overflow = 'visible'; + el.style.maxHeight = 'none'; + const naturalHeight = el.getBoundingClientRect().height; + // 置空 inline style,让 class 样式重新生效 + el.style.webkitLineClamp = ''; + el.style.overflow = ''; + el.style.maxHeight = ''; + + // 1px 容差防止行高计算的浮点误差 + return naturalHeight > clampedHeight + 1; +} + +type EllipsisTooltipProps = { + label: string; + /** 强制显示 tooltip,忽略溢出检测 */ + forceShow?: boolean; + /** Tooltip 展示内容,不传则默认使用 label */ + tooltipLabel?: React.ReactNode; + /** 省略行数:1=单行(默认),n>1=多行 */ + lineClamp?: number; + /** 透传给 Tooltip 的额外属性 */ + tooltipProps?: Omit; + /** 溢出状态变化时的回调 */ + onOverflowChange?: (isOverflowed: boolean) => void; +} & Omit; + +const EllipsisTooltip = ({ + label, + tooltipLabel, + lineClamp = 1, + tooltipProps, + forceShow, + onOverflowChange, + ...boxProps +}: EllipsisTooltipProps) => { + const ref = useRef(null); + const [isOverflowed, setIsOverflowed] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const updateOverflow = (el: HTMLElement) => { + const overflow = isOverflow(el); + setIsOverflowed(overflow); + onOverflowChange?.(overflow); + }; + + useLayoutEffect(() => { + if (ref.current) updateOverflow(ref.current); + }, [label, lineClamp]); + + const handleMouseEnter = () => { + if (ref.current) { + const overflow = isOverflow(ref.current); + setIsOverflowed(overflow); + onOverflowChange?.(overflow); + setIsOpen(overflow || !!forceShow); + } + }; + + const handleMouseLeave = () => { + setIsOpen(false); + }; + + return ( + + + {label} + + + ); +}; + +export default EllipsisTooltip; diff --git a/packages/web/components/common/EmptyTip/index.tsx b/packages/web/components/common/EmptyTip/index.tsx index a5a1bc729fe7..9bd653ae1e20 100644 --- a/packages/web/components/common/EmptyTip/index.tsx +++ b/packages/web/components/common/EmptyTip/index.tsx @@ -14,7 +14,7 @@ const EmptyTip = ({ text, iconSize = '48px', ...props }: Props) => { - {text || t('common:no_more_data')} + {text || t('common:no_data')} ); diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 7c9affb26567..31156a41a636 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -14,9 +14,15 @@ export const iconPaths = { closeSolid: () => import('./icons/closeSolid.svg'), code: () => import('./icons/code.svg'), codeCopilot: () => import('./icons/codeCopilot.svg'), + collectionLight: () => import('./icons/collectionLight.svg'), + optimizer: () => import('./icons/optimizer.svg'), + appEntry: () => import('./icons/app.svg'), + collectionSolid: () => import('./icons/collectionSolid.svg'), comment: () => import('./icons/comment.svg'), 'common/add2': () => import('./icons/common/add2.svg'), + 'common/addCircleLight': () => import('./icons/common/addCircleLight.svg'), 'common/addLight': () => import('./icons/common/addLight.svg'), + 'common/addUser': () => import('./icons/common/addUser.svg'), 'common/administrator': () => import('./icons/common/administrator.svg'), 'common/alipay': () => import('./icons/common/alipay.svg'), 'common/app': () => import('./icons/common/app.svg'), @@ -27,20 +33,24 @@ export const iconPaths = { 'common/backLight': () => import('./icons/common/backLight.svg'), 'common/billing': () => import('./icons/common/billing.svg'), 'common/check': () => import('./icons/common/check.svg'), + 'common/circleAlert': () => import('./icons/common/circleAlert.svg'), 'common/clearLight': () => import('./icons/common/clearLight.svg'), 'common/closeLight': () => import('./icons/common/closeLight.svg'), 'common/confirm/commonTip': () => import('./icons/common/confirm/commonTip.svg'), + 'common/confirm/info': () => import('./icons/common/confirm/info.svg'), 'common/confirm/deleteTip': () => import('./icons/common/confirm/deleteTip.svg'), + 'common/confirm/newInfo': () => import('./icons/common/confirm/newInfo.svg'), 'common/confirm/restoreTip': () => import('./icons/common/confirm/restoreTip.svg'), + 'common/confirm/rightTip': () => import('./icons/common/confirm/rightTip.svg'), 'common/courseLight': () => import('./icons/common/courseLight.svg'), 'common/customTitleLight': () => import('./icons/common/customTitleLight.svg'), + 'common/data': () => import('./icons/common/data.svg'), 'common/detail': () => import('./icons/common/detail.svg'), 'common/dingtalkFill': () => import('./icons/common/dingtalkFill.svg'), 'common/disable': () => import('./icons/common/disable.svg'), 'common/downArrowFill': () => import('./icons/common/downArrowFill.svg'), 'common/download': () => import('./icons/common/download.svg'), 'common/downloadLine': () => import('./icons/common/downloadLine.svg'), - 'common/htmlPreview': () => import('./icons/common/htmlPreview.svg'), 'common/edit': () => import('./icons/common/edit.svg'), 'common/editor/resizer': () => import('./icons/common/editor/resizer.svg'), 'common/ellipsis': () => import('./icons/common/ellipsis.svg'), @@ -55,9 +65,11 @@ export const iconPaths = { 'common/fullScreenLight': () => import('./icons/common/fullScreenLight.svg'), 'common/gitFill': () => import('./icons/common/gitFill.svg'), 'common/gitInlight': () => import('./icons/common/gitInlight.svg'), + 'common/gitLight': () => import('./icons/common/gitLight.svg'), 'common/globalLine': () => import('./icons/common/globalLine.svg'), 'common/googleFill': () => import('./icons/common/googleFill.svg'), 'common/help': () => import('./icons/common/help.svg'), + 'common/htmlPreview': () => import('./icons/common/htmlPreview.svg'), 'common/importLight': () => import('./icons/common/importLight.svg'), 'common/info': () => import('./icons/common/info.svg'), 'common/inviteLight': () => import('./icons/common/inviteLight.svg'), @@ -75,22 +87,34 @@ export const iconPaths = { 'common/linkBlue': () => import('./icons/common/linkBlue.svg'), 'common/list': () => import('./icons/common/list.svg'), 'common/loading': () => import('./icons/common/loading.svg'), + 'common/logLight': () => import('./icons/common/logLight.svg'), 'common/maximize': () => import('./icons/common/maximize.svg'), 'common/microsoft': () => import('./icons/common/microsoft.svg'), 'common/model': () => import('./icons/common/model.svg'), + 'common/monitor': () => import('./icons/common/monitor.svg'), + 'common/more': () => import('./icons/common/more.svg'), + 'common/moreFill': () => import('./icons/common/moreFill.svg'), 'common/openai': () => import('./icons/common/openai.svg'), 'common/overviewLight': () => import('./icons/common/overviewLight.svg'), 'common/paramsLight': () => import('./icons/common/paramsLight.svg'), 'common/paused': () => import('./icons/common/paused.svg'), + 'common/playFill': () => import('./icons/common/playFill.svg'), + 'common/playLight': () => import('./icons/common/playLight.svg'), + 'common/chat': () => import('./icons/common/chat.svg'), + 'common/publishFill': () => import('./icons/common/publishFill.svg'), 'common/refresh': () => import('./icons/common/refresh.svg'), 'common/refreshLight': () => import('./icons/common/refreshLight.svg'), 'common/resultLight': () => import('./icons/common/resultLight.svg'), 'common/retryLight': () => import('./icons/common/retryLight.svg'), + 'common/leftArrow2': () => import('./icons/common/leftArrow2.svg'), 'common/rightArrow': () => import('./icons/common/rightArrow.svg'), + 'common/rightArrow2': () => import('./icons/common/rightArrow2.svg'), 'common/rightArrowFill': () => import('./icons/common/rightArrowFill.svg'), 'common/rightArrowLight': () => import('./icons/common/rightArrowLight.svg'), 'common/rocket': () => import('./icons/common/rocket.svg'), + 'common/routePushLight': () => import('./icons/common/routePushLight.svg'), 'common/running': () => import('./icons/common/running.svg'), + 'common/saveFill': () => import('./icons/common/saveFill.svg'), 'common/searchLight': () => import('./icons/common/searchLight.svg'), 'common/select': () => import('./icons/common/select.svg'), 'common/selectLight': () => import('./icons/common/selectLight.svg'), @@ -98,12 +122,20 @@ export const iconPaths = { 'common/settingLight': () => import('./icons/common/settingLight.svg'), 'common/skill': () => import('./icons/common/skill.svg'), 'common/solidChevronDown': () => import('./icons/common/solidChevronDown.svg'), + 'common/solidChevronRight': () => import('./icons/common/solidChevronRight.svg'), 'common/solidChevronUp': () => import('./icons/common/solidChevronUp.svg'), + 'common/subtract': () => import('./icons/common/subtract.svg'), + 'common/table/asc': () => import('./icons/common/table/asc.svg'), + 'common/table/desc': () => import('./icons/common/table/desc.svg'), + 'common/table/filter': () => import('./icons/common/table/filter.svg'), + 'common/table/sort': () => import('./icons/common/table/sort.svg'), 'common/templateMarket': () => import('./icons/common/templateMarket.svg'), 'common/text/t': () => import('./icons/common/text/t.svg'), 'common/thirdParty': () => import('./icons/common/thirdParty.svg'), + 'common/tickFill': () => import('./icons/common/tickFill.svg'), 'common/toolkit': () => import('./icons/common/toolkit.svg'), 'common/trash': () => import('./icons/common/trash.svg'), + 'common/upRightArrowLight': () => import('./icons/common/upRightArrowLight.svg'), 'common/uploadFileFill': () => import('./icons/common/uploadFileFill.svg'), 'common/upperRight': () => import('./icons/common/upperRight.svg'), 'common/user': () => import('./icons/common/user.svg'), @@ -113,6 +145,8 @@ export const iconPaths = { 'common/voiceLight': () => import('./icons/common/voiceLight.svg'), 'common/wallet': () => import('./icons/common/wallet.svg'), 'common/warn': () => import('./icons/common/warn.svg'), + 'common/warnTriangle': () => import('./icons/common/warnTriangle.svg'), + 'common/warningFill': () => import('./icons/common/warningFill.svg'), 'common/wechat': () => import('./icons/common/wechat.svg'), 'common/wechatFill': () => import('./icons/common/wechatFill.svg'), 'common/wecom': () => import('./icons/common/wecom.svg'), @@ -124,19 +158,31 @@ export const iconPaths = { 'core/app/agent/child/plan': () => import('./icons/core/app/agent/child/plan.svg'), 'core/app/agent/child/stepCall': () => import('./icons/core/app/agent/child/stepCall.svg'), 'core/app/agent/child/stepSummary': () => import('./icons/core/app/agent/child/stepSummary.svg'), + 'core/app/agent': () => import('./icons/core/app/agent.svg'), + 'core/app/aiAgent': () => import('./icons/core/app/aiAgent.svg'), 'core/app/aiFill': () => import('./icons/core/app/aiFill.svg'), 'core/app/aiLight': () => import('./icons/core/app/aiLight.svg'), 'core/app/aiLightSmall': () => import('./icons/core/app/aiLightSmall.svg'), + 'core/app/appApiLight': () => import('./icons/core/app/appApiLight.svg'), + 'core/app/assistant/answer': () => import('./icons/core/app/assistant/answer.svg'), + 'core/app/assistant/assistantIcon': () => import('./icons/core/app/assistant/assistantIcon.svg'), + 'core/app/assistant/assistantTag': () => import('./icons/core/app/assistant/assistantTag.svg'), + 'core/app/assistant/database': () => import('./icons/core/app/assistant/database.svg'), + 'core/app/assistant/robot': () => import('./icons/core/app/assistant/robot.svg'), 'core/app/create': () => import('./icons/core/app/create.svg'), 'core/app/customFeedback': () => import('./icons/core/app/customFeedback.svg'), 'core/app/headphones': () => import('./icons/core/app/headphones.svg'), + 'core/app/importTemplateIcon': () => import('./icons/core/app/importTemplateIcon.svg'), 'core/app/inputGuides': () => import('./icons/core/app/inputGuides.svg'), 'core/app/logsLight': () => import('./icons/core/app/logsLight.svg'), 'core/app/markLight': () => import('./icons/core/app/markLight.svg'), + 'core/app/pluginFill': () => import('./icons/core/app/pluginFill.svg'), + 'core/app/pluginLight': () => import('./icons/core/app/pluginLight.svg'), 'core/app/publish/lark': () => import('./icons/core/app/publish/lark.svg'), 'core/app/publish/offiaccount': () => import('./icons/core/app/publish/offiaccount.svg'), 'core/app/publish/wechat': () => import('./icons/core/app/publish/wechat.svg'), 'core/app/publish/wecom': () => import('./icons/core/app/publish/wecom.svg'), + 'core/app/questionGuide': () => import('./icons/core/app/questionGuide.svg'), 'core/app/sandbox/css': () => import('./icons/core/app/sandbox/css.svg'), 'core/app/sandbox/default': () => import('./icons/core/app/sandbox/default.svg'), 'core/app/sandbox/docx': () => import('./icons/core/app/sandbox/docx.svg'), @@ -159,19 +205,35 @@ export const iconPaths = { 'core/app/sandbox/yml': () => import('./icons/core/app/sandbox/yml.svg'), 'core/app/sandbox/zip': () => import('./icons/core/app/sandbox/zip.svg'), 'core/app/schedulePlan': () => import('./icons/core/app/schedulePlan.svg'), + 'core/app/share': () => import('./icons/core/app/share.svg'), 'core/app/simpleBot': () => import('./icons/core/app/simpleBot.svg'), 'core/app/simpleMode/ai': () => import('./icons/core/app/simpleMode/ai.svg'), 'core/app/simpleMode/autoExec': () => import('./icons/core/app/simpleMode/autoExec.svg'), 'core/app/simpleMode/chat': () => import('./icons/core/app/simpleMode/chat.svg'), 'core/app/simpleMode/dataset': () => import('./icons/core/app/simpleMode/dataset.svg'), 'core/app/simpleMode/file': () => import('./icons/core/app/simpleMode/file.svg'), + 'core/app/simpleMode/template': () => import('./icons/core/app/simpleMode/template.svg'), 'core/app/simpleMode/tts': () => import('./icons/core/app/simpleMode/tts.svg'), 'core/app/simpleMode/variable': () => import('./icons/core/app/simpleMode/variable.svg'), 'core/app/simpleMode/whisper': () => import('./icons/core/app/simpleMode/whisper.svg'), + 'core/app/templates/TranslateRobot': () => + import('./icons/core/app/templates/TranslateRobot.svg'), + 'core/app/templates/animalLife': () => import('./icons/core/app/templates/animalLife.svg'), + 'core/app/templates/chinese': () => import('./icons/core/app/templates/chinese.svg'), + 'core/app/templates/divination': () => import('./icons/core/app/templates/divination.svg'), + 'core/app/templates/flux': () => import('./icons/core/app/templates/flux.svg'), + 'core/app/templates/githubIssue': () => import('./icons/core/app/templates/githubIssue.svg'), + 'core/app/templates/google': () => import('./icons/core/app/templates/google.svg'), + 'core/app/templates/plugin-dalle': () => import('./icons/core/app/templates/plugin-dalle.svg'), + 'core/app/templates/plugin-feishu': () => import('./icons/core/app/templates/plugin-feishu.svg'), + 'core/app/templates/stock': () => import('./icons/core/app/templates/stock.svg'), 'core/app/toolCall': () => import('./icons/core/app/toolCall.svg'), + 'core/app/ttsFill': () => import('./icons/core/app/ttsFill.svg'), + 'core/app/type/agent': () => import('./icons/core/app/type/agent.svg'), 'core/app/type/agentFill': () => import('./icons/core/app/type/agentFill.svg'), 'core/app/type/httpPlugin': () => import('./icons/core/app/type/httpPlugin.svg'), 'core/app/type/httpPluginFill': () => import('./icons/core/app/type/httpPluginFill.svg'), + 'core/app/type/jsonImport': () => import('./icons/core/app/type/jsonImport.svg'), 'core/app/type/mcpTools': () => import('./icons/core/app/type/mcpTools.svg'), 'core/app/type/mcpToolsFill': () => import('./icons/core/app/type/mcpToolsFill.svg'), 'core/app/type/plugin': () => import('./icons/core/app/type/plugin.svg'), @@ -180,25 +242,35 @@ export const iconPaths = { 'core/app/type/simpleFill': () => import('./icons/core/app/type/simpleFill.svg'), 'core/app/type/workflow': () => import('./icons/core/app/type/workflow.svg'), 'core/app/type/workflowFill': () => import('./icons/core/app/type/workflowFill.svg'), + 'core/app/variable/external': () => import('./icons/core/app/variable/external.svg'), 'core/app/variable/input': () => import('./icons/core/app/variable/input.svg'), + 'core/app/variable/select': () => import('./icons/core/app/variable/select.svg'), + 'core/app/variable/textarea': () => import('./icons/core/app/variable/textarea.svg'), 'core/chat/QGFill': () => import('./icons/core/chat/QGFill.svg'), 'core/chat/backText': () => import('./icons/core/chat/backText.svg'), + 'core/chat/cancelSpeak': () => import('./icons/core/chat/cancelSpeak.svg'), 'core/chat/chatFill': () => import('./icons/core/chat/chatFill.svg'), 'core/chat/chatLight': () => import('./icons/core/chat/chatLight.svg'), 'core/chat/chatModelTag': () => import('./icons/core/chat/chatModelTag.svg'), 'core/chat/chevronDown': () => import('./icons/core/chat/chevronDown.svg'), + 'core/chat/chevronLeft': () => import('./icons/core/chat/chevronLeft.svg'), 'core/chat/chevronRight': () => import('./icons/core/chat/chevronRight.svg'), 'core/chat/chevronSelector': () => import('./icons/core/chat/chevronSelector.svg'), 'core/chat/chevronUp': () => import('./icons/core/chat/chevronUp.svg'), + 'core/chat/export/pdf': () => import('./icons/core/chat/export/pdf.svg'), 'core/chat/feedback/badLight': () => import('./icons/core/chat/feedback/badLight.svg'), 'core/chat/feedback/goodLight': () => import('./icons/core/chat/feedback/goodLight.svg'), 'core/chat/fileSelect': () => import('./icons/core/chat/fileSelect.svg'), + 'core/chat/finishSpeak': () => import('./icons/core/chat/finishSpeak.svg'), + 'core/chat/imgSelect': () => import('./icons/core/chat/imgSelect.svg'), + 'core/chat/quoteBg': () => import('./icons/core/chat/quoteBg2.svg'), 'core/chat/quoteFill': () => import('./icons/core/chat/quoteFill.svg'), 'core/chat/quoteSign': () => import('./icons/core/chat/quoteSign.svg'), 'core/chat/recordFill': () => import('./icons/core/chat/recordFill.svg'), 'core/chat/sendFill': () => import('./icons/core/chat/sendFill.svg'), 'core/chat/sendLight': () => import('./icons/core/chat/sendLight.svg'), 'core/chat/setTopLight': () => import('./icons/core/chat/setTopLight.svg'), + 'core/chat/setting/share': () => import('./icons/core/chat/setting/share.svg'), 'core/chat/sideLine': () => import('./icons/core/chat/sideLine.svg'), 'core/chat/sidebar/expand': () => import('./icons/core/chat/sidebar/expand.svg'), 'core/chat/sidebar/fold': () => import('./icons/core/chat/sidebar/fold.svg'), @@ -206,14 +278,28 @@ export const iconPaths = { 'core/chat/sidebar/logout': () => import('./icons/core/chat/sidebar/logout.svg'), 'core/chat/sidebar/menu': () => import('./icons/core/chat/sidebar/menu.svg'), 'core/chat/sidebar/star': () => import('./icons/core/chat/sidebar/star.svg'), + 'core/chat/speaking': () => import('./icons/core/chat/speaking.svg'), 'core/chat/stopSpeech': () => import('./icons/core/chat/stopSpeech.svg'), 'core/chat/think': () => import('./icons/core/chat/think.svg'), + 'core/chat/thinking': () => import('./icons/core/chat/thinking.svg'), + 'core/chat/toolCall': () => import('./icons/core/chat/toolCall.svg'), + 'core/chat/deepThing': () => import('./icons/core/chat/deepThink.svg'), + 'core/dataset/addFaq': () => import('./icons/core/dataset/addFaq.svg'), + 'core/dataset/addFile': () => import('./icons/core/dataset/addFile.svg'), + 'core/dataset/addWebLink': () => import('./icons/core/dataset/addWebLink.svg'), + 'core/dataset/article': () => import('./icons/core/dataset/article.svg'), + 'core/dataset/commonDataset': () => import('./icons/core/dataset/commonDataset.svg'), 'core/dataset/commonDatasetColor': () => import('./icons/core/dataset/commonDatasetColor.svg'), 'core/dataset/commonDatasetOutline': () => import('./icons/core/dataset/commonDatasetOutline.svg'), + 'core/dataset/database': () => import('./icons/core/dataset/database.svg'), + 'core/dataset/databaseColor': () => import('./icons/core/dataset/databaseColor.svg'), + 'core/dataset/databaseOutline': () => import('./icons/core/dataset/databaseOutline.svg'), + 'core/dataset/datasetDb': () => import('./icons/core/dataset/datasetDb.svg'), 'core/dataset/datasetFill': () => import('./icons/core/dataset/datasetFill.svg'), 'core/dataset/datasetLight': () => import('./icons/core/dataset/datasetLight.svg'), 'core/dataset/datasetLightSmall': () => import('./icons/core/dataset/datasetLightSmall.svg'), + 'core/dataset/externalDataset': () => import('./icons/core/dataset/externalDataset.svg'), 'core/dataset/externalDatasetColor': () => import('./icons/core/dataset/externalDatasetColor.svg'), 'core/dataset/externalDatasetOutline': () => @@ -222,14 +308,20 @@ export const iconPaths = { 'core/dataset/feishuDatasetOutline': () => import('./icons/core/dataset/feishuDatasetOutline.svg'), 'core/dataset/fileCollection': () => import('./icons/core/dataset/fileCollection.svg'), + 'core/dataset/fileDb': () => import('./icons/core/dataset/fileDb.svg'), + 'core/dataset/fileDbColor': () => import('./icons/core/dataset/fileDbColor.svg'), 'core/dataset/fullTextRecall': () => import('./icons/core/dataset/fullTextRecall.svg'), 'core/dataset/imageFill': () => import('./icons/core/dataset/imageFill.svg'), + 'core/dataset/loading': () => import('./icons/core/dataset/loading.svg'), 'core/dataset/manualCollection': () => import('./icons/core/dataset/manualCollection.svg'), 'core/dataset/mixedRecall': () => import('./icons/core/dataset/mixedRecall.svg'), 'core/dataset/modeEmbedding': () => import('./icons/core/dataset/modeEmbedding.svg'), 'core/dataset/otherDataset': () => import('./icons/core/dataset/otherDataset.svg'), 'core/dataset/questionExtension': () => import('./icons/core/dataset/questionExtension.svg'), + 'core/dataset/rerank': () => import('./icons/core/dataset/rerank.svg'), 'core/dataset/searchfilter': () => import('./icons/core/dataset/searchfilter.svg'), + 'core/dataset/splitLight': () => import('./icons/core/dataset/splitLight.svg'), + 'core/dataset/tableCollection': () => import('./icons/core/dataset/tableCollection.svg'), 'core/dataset/tag': () => import('./icons/core/dataset/tag.svg'), 'core/dataset/websiteDataset': () => import('./icons/core/dataset/websiteDataset.svg'), 'core/dataset/websiteDatasetColor': () => import('./icons/core/dataset/websiteDatasetColor.svg'), @@ -239,10 +331,15 @@ export const iconPaths = { 'core/dataset/yuqueDatasetOutline': () => import('./icons/core/dataset/yuqueDatasetOutline.svg'), 'core/modules/basicNode': () => import('./icons/core/modules/basicNode.svg'), 'core/modules/fitView': () => import('./icons/core/modules/fitView.svg'), + 'core/modules/flowLight': () => import('./icons/core/modules/flowLight.svg'), + 'core/modules/previewLight': () => import('./icons/core/modules/previewLight.svg'), + 'core/modules/systemPlugin': () => import('./icons/core/modules/systemPlugin.svg'), + 'core/modules/teamPlugin': () => import('./icons/core/modules/teamPlugin.svg'), 'core/modules/variable': () => import('./icons/core/modules/variable.svg'), 'core/modules/welcomeText': () => import('./icons/core/modules/welcomeText.svg'), 'core/skill/default': () => import('./icons/core/skill/default.svg'), 'core/skill/help': () => import('./icons/core/skill/help.svg'), + 'core/skill/skill': () => import('./icons/core/skill/skill.svg'), 'core/workflow/closeEdge': () => import('./icons/core/workflow/closeEdge.svg'), 'core/workflow/debug': () => import('./icons/core/workflow/debug.svg'), 'core/workflow/debugBlue': () => import('./icons/core/workflow/debugBlue.svg'), @@ -250,9 +347,11 @@ export const iconPaths = { 'core/workflow/debugResult': () => import('./icons/core/workflow/debugResult.svg'), 'core/workflow/edgeArrow': () => import('./icons/core/workflow/edgeArrow.svg'), 'core/workflow/edgeArrowBold': () => import('./icons/core/workflow/edgeArrowBold.svg'), + 'core/workflow/grout': () => import('./icons/core/workflow/grout.svg'), 'core/workflow/inputType/array': () => import('./icons/core/workflow/inputType/array.svg'), 'core/workflow/inputType/customVariable': () => import('./icons/core/workflow/inputType/customVariable.svg'), + 'core/workflow/inputType/dataset': () => import('./icons/core/workflow/inputType/dataset.svg'), 'core/workflow/inputType/dynamic': () => import('./icons/core/workflow/inputType/dynamic.svg'), 'core/workflow/inputType/external': () => import('./icons/core/workflow/inputType/external.svg'), 'core/workflow/inputType/file': () => import('./icons/core/workflow/inputType/file.svg'), @@ -269,6 +368,7 @@ export const iconPaths = { 'core/workflow/inputType/password': () => import('./icons/core/workflow/inputType/password.svg'), 'core/workflow/inputType/reference': () => import('./icons/core/workflow/inputType/reference.svg'), + 'core/workflow/inputType/select': () => import('./icons/core/workflow/inputType/select.svg'), 'core/workflow/inputType/selectApp': () => import('./icons/core/workflow/inputType/selectApp.svg'), 'core/workflow/inputType/selectDataset': () => @@ -285,6 +385,7 @@ export const iconPaths = { 'core/workflow/present': () => import('./icons/core/workflow/present.svg'), 'core/workflow/publish': () => import('./icons/core/workflow/publish.svg'), 'core/workflow/redo': () => import('./icons/core/workflow/redo.svg'), + 'core/workflow/revertVersion': () => import('./icons/core/workflow/revertVersion.svg'), 'core/workflow/runError': () => import('./icons/core/workflow/runError.svg'), 'core/workflow/runSkip': () => import('./icons/core/workflow/runSkip.svg'), 'core/workflow/runSuccess': () => import('./icons/core/workflow/runSuccess.svg'), @@ -297,6 +398,9 @@ export const iconPaths = { 'core/workflow/template/aiChat': () => import('./icons/core/workflow/template/aiChat.svg'), 'core/workflow/template/aiChatLinear': () => import('./icons/core/workflow/template/aiChatLinear.tsx'), + 'core/workflow/template/baseChart': () => import('./icons/core/workflow/template/baseChart.svg'), + 'core/workflow/template/bing': () => import('./icons/core/workflow/template/bing.svg'), + 'core/workflow/template/bocha': () => import('./icons/core/workflow/template/bocha.svg'), 'core/workflow/template/codeRun': () => import('./icons/core/workflow/template/codeRun.svg'), 'core/workflow/template/codeRunLinear': () => import('./icons/core/workflow/template/codeRunLinear.tsx'), @@ -312,13 +416,20 @@ export const iconPaths = { import('./icons/core/workflow/template/datasetSearch.svg'), 'core/workflow/template/datasetSearchLinear': () => import('./icons/core/workflow/template/datasetSearchLinear.tsx'), + 'core/workflow/template/datasource': () => + import('./icons/core/workflow/template/datasource.svg'), + 'core/workflow/template/duckduckgo': () => + import('./icons/core/workflow/template/duckduckgo.svg'), 'core/workflow/template/extractJson': () => import('./icons/core/workflow/template/extractJson.svg'), 'core/workflow/template/extractJsonLinear': () => import('./icons/core/workflow/template/extractJsonLinear.tsx'), + 'core/workflow/template/fetchUrl': () => import('./icons/core/workflow/template/fetchUrl.svg'), 'core/workflow/template/formInput': () => import('./icons/core/workflow/template/formInput.svg'), 'core/workflow/template/formInputLinear': () => import('./icons/core/workflow/template/formInputLinear.tsx'), + 'core/workflow/template/getTime': () => import('./icons/core/workflow/template/getTime.svg'), + 'core/workflow/template/google': () => import('./icons/core/workflow/template/google.svg'), 'core/workflow/template/httpRequest': () => import('./icons/core/workflow/template/httpRequest.svg'), 'core/workflow/template/httpRequestLinear': () => @@ -361,6 +472,8 @@ export const iconPaths = { 'core/workflow/template/replyLinear': () => import('./icons/core/workflow/template/replyLinear.tsx'), 'core/workflow/template/runApp': () => import('./icons/core/workflow/template/runApp.svg'), + 'core/workflow/template/searxng': () => import('./icons/core/workflow/template/searxng.svg'), + 'core/workflow/template/sleep': () => import('./icons/core/workflow/template/sleep.svg'), 'core/workflow/template/stopTool': () => import('./icons/core/workflow/template/stopTool.svg'), 'core/workflow/template/stopToolLinear': () => import('./icons/core/workflow/template/stopToolLinear.tsx'), @@ -392,6 +505,7 @@ export const iconPaths = { import('./icons/core/workflow/template/variableUpdate.svg'), 'core/workflow/template/variableUpdateLinear': () => import('./icons/core/workflow/template/variableUpdateLinear.tsx'), + 'core/workflow/template/wiki': () => import('./icons/core/workflow/template/wiki.svg'), 'core/workflow/template/workflowStart': () => import('./icons/core/workflow/template/workflowStart.svg'), 'core/workflow/template/workflowStartLinear': () => @@ -399,6 +513,7 @@ export const iconPaths = { 'core/workflow/touchTable': () => import('./icons/core/workflow/touchTable.svg'), 'core/workflow/undo': () => import('./icons/core/workflow/undo.svg'), 'core/workflow/upload': () => import('./icons/core/workflow/upload.svg'), + 'core/workflow/versionHistories': () => import('./icons/core/workflow/versionHistories.svg'), date: () => import('./icons/date.svg'), delete: () => import('./icons/delete.svg'), drag: () => import('./icons/drag.svg'), @@ -421,9 +536,15 @@ export const iconPaths = { 'file/fill/txt': () => import('./icons/file/fill/txt.svg'), 'file/fill/video': () => import('./icons/file/fill/video.svg'), 'file/fill/xlsx': () => import('./icons/file/fill/xlsx.svg'), + 'file/html': () => import('./icons/file/html.svg'), + 'file/indexImport': () => import('./icons/file/indexImport.svg'), + 'file/manualImport': () => import('./icons/file/manualImport.svg'), 'file/markdown': () => import('./icons/file/markdown.svg'), + 'file/pdf': () => import('./icons/file/pdf.svg'), + 'file/qaImport': () => import('./icons/file/qaImport.svg'), 'file/uploadFile': () => import('./icons/file/uploadFile.svg'), fullScreen: () => import('./icons/fullScreen.svg'), + gradientLoading: () => import('./icons/gradientLoading.svg'), help: () => import('./icons/help.svg'), history: () => import('./icons/history.svg'), image: () => import('./icons/image.svg'), @@ -444,13 +565,23 @@ export const iconPaths = { 'modal/AddClb': () => import('./icons/modal/AddClb.svg'), 'modal/changePer': () => import('./icons/modal/changePer.svg'), 'modal/concat': () => import('./icons/modal/concat.svg'), + 'modal/confirmPay': () => import('./icons/modal/confirmPay.svg'), 'modal/edit': () => import('./icons/modal/edit.svg'), 'modal/manualDataset': () => import('./icons/modal/manualDataset.svg'), 'modal/selectSource': () => import('./icons/modal/selectSource.svg'), 'modal/setting': () => import('./icons/modal/setting.svg'), 'modal/teamPlans': () => import('./icons/modal/teamPlans.svg'), + 'model/aws': () => import('./icons/model/aws.svg'), + 'model/azure': () => import('./icons/model/azure.svg'), + 'model/cloudflare': () => import('./icons/model/cloudflare.svg'), + 'model/cohere': () => import('./icons/model/cohere.svg'), + 'model/coze': () => import('./icons/model/coze.svg'), 'model/huggingface': () => import('./icons/model/huggingface.svg'), + 'model/sangforaicp': () => import('./icons/model/sangforaicp.svg'), more: () => import('./icons/more.svg'), + moreLine: () => import('./icons/moreLine.svg'), + mssql: () => import('./icons/mssql.svg'), + mysql: () => import('./icons/mysql.svg'), 'navbar/chatFill': () => import('./icons/navbar/chatFill.svg'), 'navbar/chatLight': () => import('./icons/navbar/chatLight.svg'), 'navbar/dashboardFill': () => import('./icons/navbar/dashboardFill.svg'), @@ -459,12 +590,29 @@ export const iconPaths = { 'navbar/datasetLight': () => import('./icons/navbar/datasetLight.svg'), 'navbar/userFill': () => import('./icons/navbar/userFill.svg'), 'navbar/userLight': () => import('./icons/navbar/userLight.svg'), + 'navbar/chatLightNew': () => import('./icons/navbar/chatLight-new.svg'), + 'navbar/templateMarketNew': () => import('./icons/navbar/templateMarket-new.svg'), + 'navbar/appBuildNew': () => import('./icons/navbar/appBuild-new.svg'), + 'navbar/datasetLightNew': () => import('./icons/navbar/dataset-new.svg'), + 'navbar/evaluationNew': () => import('./icons/navbar/evaluation-new.svg'), + 'navbar/settingNew': () => import('./icons/navbar/setting-new.svg'), + 'navbar/bottomIcon': () => import('./icons/navbar/bottom-icon.svg'), optimizer: () => import('./icons/optimizer.svg'), + oracle: () => import('./icons/oracle.svg'), out: () => import('./icons/out.svg'), paragraph: () => import('./icons/paragraph.svg'), + pgsql: () => import('./icons/pgsql.svg'), + 'phoneTabbar/me': () => import('./icons/phoneTabbar/me.svg'), 'phoneTabbar/tool': () => import('./icons/phoneTabbar/tool.svg'), + 'phoneTabbar/toolFill': () => import('./icons/phoneTabbar/toolFill.svg'), + 'plugins/dingding': () => import('./icons/plugins/dingding.svg'), + 'plugins/doc2x': () => import('./icons/plugins/doc2x.svg'), + 'plugins/email': () => import('./icons/plugins/email.svg'), + 'plugins/qiwei': () => import('./icons/plugins/qiwei.svg'), + 'plugins/textEditor': () => import('./icons/plugins/textEditor.svg'), point: () => import('./icons/point.svg'), preview: () => import('./icons/preview.svg'), + 'price/bg': () => import('./icons/price/bg.svg'), 'price/pricearrow': () => import('./icons/price/pricearrow.svg'), 'price/right': () => import('./icons/price/right.svg'), save: () => import('./icons/save.svg'), @@ -483,22 +631,29 @@ export const iconPaths = { 'support/account/plans': () => import('./icons/support/account/plans.svg'), 'support/account/promotionLight': () => import('./icons/support/account/promotionLight.svg'), 'support/bill/extraDatasetsize': () => import('./icons/support/bill/extraDatasetsize.svg'), + 'support/bill/extraPoints': () => import('./icons/support/bill/extraPoints.svg'), 'support/bill/payRecordLight': () => import('./icons/support/bill/payRecordLight.svg'), 'support/bill/priceLight': () => import('./icons/support/bill/priceLight.svg'), 'support/bill/shoppingCart': () => import('./icons/support/bill/shoppingCart.svg'), + 'support/bill/tokens': () => import('./icons/support/bill/tokens.svg'), 'support/bill/wallet': () => import('./icons/support/bill/wallet.svg'), 'support/config/configFill': () => import('./icons/support/config/configFill.svg'), 'support/config/configLight': () => import('./icons/support/config/configLight.svg'), 'support/outlink/apikeyFill': () => import('./icons/support/outlink/apikeyFill.svg'), + 'support/outlink/iframeLight': () => import('./icons/support/outlink/iframeLight.svg'), + 'support/outlink/share': () => import('./icons/support/outlink/share.svg'), + 'support/outlink/shareLight': () => import('./icons/support/outlink/shareLight.svg'), 'support/permission/collaborator': () => import('./icons/support/permission/collaborator.svg'), 'support/permission/privateLight': () => import('./icons/support/permission/privateLight.svg'), 'support/permission/publicLight': () => import('./icons/support/permission/publicLight.svg'), 'support/team/group': () => import('./icons/support/team/group.svg'), + 'support/team/memberLight': () => import('./icons/support/team/memberLight.svg'), 'support/usage/usageRecordLight': () => import('./icons/support/usage/usageRecordLight.svg'), 'support/user/informLight': () => import('./icons/support/user/informLight.svg'), 'support/user/userFill': () => import('./icons/support/user/userFill.svg'), 'support/user/userLight': () => import('./icons/support/user/userLight.svg'), 'support/user/userLightSmall': () => import('./icons/support/user/userLightSmall.svg'), + 'support/user/usersFill': () => import('./icons/support/user/usersFill.svg'), 'support/user/usersLight': () => import('./icons/support/user/usersLight.svg'), text: () => import('./icons/text.svg'), union: () => import('./icons/union.svg'), diff --git a/packages/web/components/common/Icon/icon.html b/packages/web/components/common/Icon/icon.html new file mode 100644 index 000000000000..89b8d2d656c5 --- /dev/null +++ b/packages/web/components/common/Icon/icon.html @@ -0,0 +1,677 @@ + + + + + + + + FastGPT 图标展示 + + + + +
+
+

FastGPT 图标展示

+

点击图标查看详情,点击路径可复制到剪贴板

+
+ +
+ +
+ +
+
全部
+
通用
+
核心
+
文件
+
模型
+
支持
+
插件
+
+ +
+ +
+ +
+ +
+
+ + + +
已复制到剪贴板!
+ + + + + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/app.svg b/packages/web/components/common/Icon/icons/app.svg new file mode 100644 index 000000000000..102cac9f30f7 --- /dev/null +++ b/packages/web/components/common/Icon/icons/app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/chat.svg b/packages/web/components/common/Icon/icons/common/chat.svg new file mode 100644 index 000000000000..51f7b6cdc6d0 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/circleAlert.svg b/packages/web/components/common/Icon/icons/common/circleAlert.svg new file mode 100644 index 000000000000..74e21ff47091 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/circleAlert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/confirm/info.svg b/packages/web/components/common/Icon/icons/common/confirm/info.svg new file mode 100644 index 000000000000..869402dce0ef --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/confirm/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/confirm/newInfo.svg b/packages/web/components/common/Icon/icons/common/confirm/newInfo.svg new file mode 100644 index 000000000000..a24a0ca1d892 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/confirm/newInfo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/leftArrow2.svg b/packages/web/components/common/Icon/icons/common/leftArrow2.svg new file mode 100644 index 000000000000..3bc5f1d89621 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/leftArrow2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/rightArrow2.svg b/packages/web/components/common/Icon/icons/common/rightArrow2.svg new file mode 100644 index 000000000000..ad35bfb36c87 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/rightArrow2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/table/asc.svg b/packages/web/components/common/Icon/icons/common/table/asc.svg new file mode 100644 index 000000000000..f8b032b021c2 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/table/asc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/table/desc.svg b/packages/web/components/common/Icon/icons/common/table/desc.svg new file mode 100644 index 000000000000..3735aa1f0e3f --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/table/desc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/table/filter.svg b/packages/web/components/common/Icon/icons/common/table/filter.svg new file mode 100644 index 000000000000..f06e15788930 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/table/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/table/sort.svg b/packages/web/components/common/Icon/icons/common/table/sort.svg new file mode 100644 index 000000000000..a146a37b2cff --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/table/sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/common/user.svg b/packages/web/components/common/Icon/icons/common/user.svg index e4cb074d7d0a..0bd64e182c01 100644 --- a/packages/web/components/common/Icon/icons/common/user.svg +++ b/packages/web/components/common/Icon/icons/common/user.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/packages/web/components/common/Icon/icons/common/warnTriangle.svg b/packages/web/components/common/Icon/icons/common/warnTriangle.svg new file mode 100644 index 000000000000..204028a42d23 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/warnTriangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/assistant/answer.svg b/packages/web/components/common/Icon/icons/core/app/assistant/answer.svg new file mode 100644 index 000000000000..624874078510 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/assistant/answer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/assistant/assistantIcon.svg b/packages/web/components/common/Icon/icons/core/app/assistant/assistantIcon.svg new file mode 100644 index 000000000000..469a550cac8b --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/assistant/assistantIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/assistant/assistantTag.svg b/packages/web/components/common/Icon/icons/core/app/assistant/assistantTag.svg new file mode 100644 index 000000000000..0e4fb3d592e1 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/assistant/assistantTag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/assistant/database.svg b/packages/web/components/common/Icon/icons/core/app/assistant/database.svg new file mode 100644 index 000000000000..174ba71ade76 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/assistant/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/assistant/robot.svg b/packages/web/components/common/Icon/icons/core/app/assistant/robot.svg new file mode 100644 index 000000000000..2a470a16db64 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/assistant/robot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/importTemplateIcon.svg b/packages/web/components/common/Icon/icons/core/app/importTemplateIcon.svg new file mode 100644 index 000000000000..01d4ed0f374d --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/importTemplateIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/share.svg b/packages/web/components/common/Icon/icons/core/app/share.svg new file mode 100644 index 000000000000..b3fc817ff0dc --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/type/agentFill.svg b/packages/web/components/common/Icon/icons/core/app/type/agentFill.svg index d4c92090a2c8..67202669d2bc 100644 --- a/packages/web/components/common/Icon/icons/core/app/type/agentFill.svg +++ b/packages/web/components/common/Icon/icons/core/app/type/agentFill.svg @@ -1,45 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/type/httpPluginFill.svg b/packages/web/components/common/Icon/icons/core/app/type/httpPluginFill.svg index f8babebb2376..36e4b6c74910 100644 --- a/packages/web/components/common/Icon/icons/core/app/type/httpPluginFill.svg +++ b/packages/web/components/common/Icon/icons/core/app/type/httpPluginFill.svg @@ -1,13 +1 @@ - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/type/mcpToolsFill.svg b/packages/web/components/common/Icon/icons/core/app/type/mcpToolsFill.svg index 4e1c109f2ea8..f37af1f27731 100644 --- a/packages/web/components/common/Icon/icons/core/app/type/mcpToolsFill.svg +++ b/packages/web/components/common/Icon/icons/core/app/type/mcpToolsFill.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/type/pluginFill.svg b/packages/web/components/common/Icon/icons/core/app/type/pluginFill.svg index 36bbd4e9ab46..cda4e2cd2ff2 100644 --- a/packages/web/components/common/Icon/icons/core/app/type/pluginFill.svg +++ b/packages/web/components/common/Icon/icons/core/app/type/pluginFill.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/app/type/workflowFill.svg b/packages/web/components/common/Icon/icons/core/app/type/workflowFill.svg index f0c6cc7c30f6..0e9245c5d4d7 100644 --- a/packages/web/components/common/Icon/icons/core/app/type/workflowFill.svg +++ b/packages/web/components/common/Icon/icons/core/app/type/workflowFill.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/chat/deepThink.svg b/packages/web/components/common/Icon/icons/core/chat/deepThink.svg new file mode 100644 index 000000000000..b35350067cf8 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/deepThink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/chat/quoteBg.svg b/packages/web/components/common/Icon/icons/core/chat/quoteBg.svg new file mode 100644 index 000000000000..b4bfb3f1e716 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/quoteBg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/chat/quoteBg2.svg b/packages/web/components/common/Icon/icons/core/chat/quoteBg2.svg new file mode 100644 index 000000000000..baafb0d2dd96 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/quoteBg2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/chat/thinking.svg b/packages/web/components/common/Icon/icons/core/chat/thinking.svg new file mode 100644 index 000000000000..4caf4bdf3f14 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/thinking.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/chat/toolCall.svg b/packages/web/components/common/Icon/icons/core/chat/toolCall.svg new file mode 100644 index 000000000000..b70c4545524f --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/chat/toolCall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/addFaq.svg b/packages/web/components/common/Icon/icons/core/dataset/addFaq.svg new file mode 100644 index 000000000000..cb48afef3487 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/addFaq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/addFile.svg b/packages/web/components/common/Icon/icons/core/dataset/addFile.svg new file mode 100644 index 000000000000..102cb010d465 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/addFile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/addWebLink.svg b/packages/web/components/common/Icon/icons/core/dataset/addWebLink.svg new file mode 100644 index 000000000000..3581b2e33a67 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/addWebLink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/article.svg b/packages/web/components/common/Icon/icons/core/dataset/article.svg new file mode 100644 index 000000000000..f3771944c695 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/article.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/commonDatasetColor.svg b/packages/web/components/common/Icon/icons/core/dataset/commonDatasetColor.svg index fd9da3d258cc..2b41d1ca2fcb 100644 --- a/packages/web/components/common/Icon/icons/core/dataset/commonDatasetColor.svg +++ b/packages/web/components/common/Icon/icons/core/dataset/commonDatasetColor.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/database.svg b/packages/web/components/common/Icon/icons/core/dataset/database.svg new file mode 100644 index 000000000000..ac40dfd7de70 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/databaseColor.svg b/packages/web/components/common/Icon/icons/core/dataset/databaseColor.svg new file mode 100644 index 000000000000..e3c99879711a --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/databaseColor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/databaseOutline.svg b/packages/web/components/common/Icon/icons/core/dataset/databaseOutline.svg new file mode 100644 index 000000000000..e25231ece655 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/databaseOutline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/datasetDb.svg b/packages/web/components/common/Icon/icons/core/dataset/datasetDb.svg new file mode 100644 index 000000000000..66ad774526e3 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/datasetDb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/externalDatasetColor.svg b/packages/web/components/common/Icon/icons/core/dataset/externalDatasetColor.svg index a6f0ee8099eb..89bc0c747083 100644 --- a/packages/web/components/common/Icon/icons/core/dataset/externalDatasetColor.svg +++ b/packages/web/components/common/Icon/icons/core/dataset/externalDatasetColor.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/fileDb.svg b/packages/web/components/common/Icon/icons/core/dataset/fileDb.svg new file mode 100644 index 000000000000..fb26066f30ad --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/fileDb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/fileDbColor.svg b/packages/web/components/common/Icon/icons/core/dataset/fileDbColor.svg new file mode 100644 index 000000000000..60c438adaa83 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/fileDbColor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/loading.svg b/packages/web/components/common/Icon/icons/core/dataset/loading.svg new file mode 100644 index 000000000000..9908b974c8a9 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/dataset/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/otherDataset.svg b/packages/web/components/common/Icon/icons/core/dataset/otherDataset.svg index aa125a1ac03a..05f5f0cae71f 100644 --- a/packages/web/components/common/Icon/icons/core/dataset/otherDataset.svg +++ b/packages/web/components/common/Icon/icons/core/dataset/otherDataset.svg @@ -1,11 +1 @@ - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/dataset/websiteDatasetColor.svg b/packages/web/components/common/Icon/icons/core/dataset/websiteDatasetColor.svg index ad6d8495d10d..e96c9f0398dc 100644 --- a/packages/web/components/common/Icon/icons/core/dataset/websiteDatasetColor.svg +++ b/packages/web/components/common/Icon/icons/core/dataset/websiteDatasetColor.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/skill/skill.svg b/packages/web/components/common/Icon/icons/core/skill/skill.svg new file mode 100644 index 000000000000..27705313a984 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/skill/skill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/gradientLoading.svg b/packages/web/components/common/Icon/icons/gradientLoading.svg new file mode 100644 index 000000000000..5408cd4b75f9 --- /dev/null +++ b/packages/web/components/common/Icon/icons/gradientLoading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/model/sangforaicp.svg b/packages/web/components/common/Icon/icons/model/sangforaicp.svg new file mode 100644 index 000000000000..a8372e02538f --- /dev/null +++ b/packages/web/components/common/Icon/icons/model/sangforaicp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/mssql.svg b/packages/web/components/common/Icon/icons/mssql.svg new file mode 100644 index 000000000000..524958d67ce3 --- /dev/null +++ b/packages/web/components/common/Icon/icons/mssql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/mysql.svg b/packages/web/components/common/Icon/icons/mysql.svg new file mode 100644 index 000000000000..f4bad076d25e --- /dev/null +++ b/packages/web/components/common/Icon/icons/mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/navbar/appBuild-new.svg b/packages/web/components/common/Icon/icons/navbar/appBuild-new.svg new file mode 100644 index 000000000000..8d4b385edf63 --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/appBuild-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/bottom-icon.svg b/packages/web/components/common/Icon/icons/navbar/bottom-icon.svg new file mode 100644 index 000000000000..fa6a2f3b0b4b --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/bottom-icon.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/chatLight-new.svg b/packages/web/components/common/Icon/icons/navbar/chatLight-new.svg new file mode 100644 index 000000000000..38141e59106b --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/chatLight-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/dataset-new.svg b/packages/web/components/common/Icon/icons/navbar/dataset-new.svg new file mode 100644 index 000000000000..6487929b7216 --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/dataset-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/evaluation-new.svg b/packages/web/components/common/Icon/icons/navbar/evaluation-new.svg new file mode 100644 index 000000000000..3ebd211a0174 --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/evaluation-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/setting-new.svg b/packages/web/components/common/Icon/icons/navbar/setting-new.svg new file mode 100644 index 000000000000..db3a90d9795e --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/setting-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/navbar/templateMarket-new.svg b/packages/web/components/common/Icon/icons/navbar/templateMarket-new.svg new file mode 100644 index 000000000000..7b2bf9376a56 --- /dev/null +++ b/packages/web/components/common/Icon/icons/navbar/templateMarket-new.svg @@ -0,0 +1 @@ + diff --git a/packages/web/components/common/Icon/icons/oracle.svg b/packages/web/components/common/Icon/icons/oracle.svg new file mode 100644 index 000000000000..68e3758318ef --- /dev/null +++ b/packages/web/components/common/Icon/icons/oracle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/pgsql.svg b/packages/web/components/common/Icon/icons/pgsql.svg new file mode 100644 index 000000000000..2c102ce3de93 --- /dev/null +++ b/packages/web/components/common/Icon/icons/pgsql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/support/bill/tokens.svg b/packages/web/components/common/Icon/icons/support/bill/tokens.svg new file mode 100644 index 000000000000..4e65bbdde636 --- /dev/null +++ b/packages/web/components/common/Icon/icons/support/bill/tokens.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/InfoBanner/index.tsx b/packages/web/components/common/InfoBanner/index.tsx new file mode 100644 index 000000000000..1c67369137c4 --- /dev/null +++ b/packages/web/components/common/InfoBanner/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Box, Flex, type FlexProps } from '@chakra-ui/react'; +import MyIcon from '../Icon'; +import type { IconNameType } from '../Icon/type'; + +type BannerStatus = 'info' | 'warning' | 'error' | 'success'; + +type StatusConfig = { + bg: string; + color: string; + icon: IconNameType; +}; + +const statusConfigMap: Record = { + info: { + bg: 'blue.50', + color: 'blue.700', + icon: 'infoRounded' + }, + warning: { + bg: 'yellow.25', + color: 'yellow.700', + icon: 'common/warn' + }, + error: { + bg: 'red.25', + color: 'red.700', + icon: 'common/errorFill' + }, + success: { + bg: 'green.25', + color: 'green.700', + icon: 'checkCircle' + } +}; + +type InfoBannerProps = { + status?: BannerStatus; + children: React.ReactNode; +} & Omit; + +const InfoBanner = ({ status = 'info', children, ...props }: InfoBannerProps) => { + const { bg, color, icon } = statusConfigMap[status]; + + return ( + + + + {children} + + + ); +}; + +export default InfoBanner; diff --git a/packages/web/components/common/Input/NumberInput/index.tsx b/packages/web/components/common/Input/NumberInput/index.tsx index 44efa0bc1292..3b95c86347d9 100644 --- a/packages/web/components/common/Input/NumberInput/index.tsx +++ b/packages/web/components/common/Input/NumberInput/index.tsx @@ -37,53 +37,34 @@ const MyNumberInput = (props: Props) => { { - const numE = e.target.value === '' ? '' : Number(e.target.value); + const numE = e.target.value === '' ? NaN : Number(e.target.value); if (onBlur) { - if (numE === '') { - // @ts-ignore - onBlur(''); - } else { - onBlur(numE); - } + onBlur(numE); } if (onChange) { - if (numE === '') { - // @ts-ignore - onChange(''); - } else { - onChange(numE); - } + onChange(numE); } if (register && name) { - const event = { + register(name).onBlur({ target: { name, value: numE } - }; - register(name).onBlur(event); + }); } }} onChange={(e) => { - const numE = e === '' ? '' : e.endsWith('.') || /^\d+\.0+$/.test(e) ? e : Number(e); + const numE = e === '' ? NaN : Number(e); if (onChange) { - if (numE === '') { - // @ts-ignore - onChange(''); - } else { - // @ts-ignore - onChange(numE); - } + onChange(numE); } if (register && name) { - const event = { + register(name).onChange({ target: { name, value: numE } - }; - - register(name).onChange(event); + }); } }} > diff --git a/packages/web/components/common/InputTypeSelector/index.tsx b/packages/web/components/common/InputTypeSelector/index.tsx index 12cdfc97b973..891739498c52 100644 --- a/packages/web/components/common/InputTypeSelector/index.tsx +++ b/packages/web/components/common/InputTypeSelector/index.tsx @@ -8,11 +8,13 @@ import { useTranslation } from 'next-i18next'; const InputTypeSelector = ({ inputTypeList, selectedType, - onTypeChange + onTypeChange, + columns = 3 }: { inputTypeList: InputTypeConfigItem[][]; selectedType: string; onTypeChange: (type: string) => void; + columns?: number; }) => { const { t } = useTranslation(); return ( @@ -22,7 +24,7 @@ const InputTypeSelector = ({ diff --git a/packages/web/components/common/MyBox/FormLabel.tsx b/packages/web/components/common/MyBox/FormLabel.tsx index 1c5a0596bdc8..9f9a2ffe6ea8 100644 --- a/packages/web/components/common/MyBox/FormLabel.tsx +++ b/packages/web/components/common/MyBox/FormLabel.tsx @@ -11,15 +11,17 @@ const FormLabel = ({ }) => { return ( {required && ( - + * )} diff --git a/packages/web/components/common/MyMenu/index.tsx b/packages/web/components/common/MyMenu/index.tsx index 1ef32b271695..0c8b360c4590 100644 --- a/packages/web/components/common/MyMenu/index.tsx +++ b/packages/web/components/common/MyMenu/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Menu, MenuList, @@ -41,9 +41,11 @@ export type Props = { Button: React.ReactNode; trigger?: 'hover' | 'click'; size?: MenuSizeType; - + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; placement?: PlacementWithLogical; menuList: MenuItemData[]; + closeOnBlur?: boolean; }; const typeMapStyle: Record = { @@ -199,22 +201,45 @@ const MyMenu = ({ offset, Button, menuList, - placement = 'bottom-start' + placement = 'bottom-start', + isOpen: externalIsOpen, + onOpenChange, + closeOnBlur = true }: Props) => { const { isPc } = useSystem(); const ref = useRef(null); const closeTimer = useRef(); - const [isOpen, setIsOpen] = useState(false); + const [internalIsOpen, setInternalIsOpen] = useState(false); + + // 使用外部控制的 isOpen 或内部状态 + const isOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen; const formatTrigger = !isPc ? 'click' : trigger; + const handleSetIsOpen = useCallback( + (newIsOpen: boolean) => { + if (externalIsOpen === undefined) { + setInternalIsOpen(newIsOpen); + } + onOpenChange?.(newIsOpen); + }, + [externalIsOpen, onOpenChange] + ); + useOutsideClick({ ref: ref, handler: () => { - setIsOpen(false); + handleSetIsOpen(false); } }); + // 组件卸载时清除关闭定时器,防止内存泄漏 + useEffect(() => { + return () => { + clearTimeout(closeTimer.current); + }; + }, []); + const computeOffset = useMemo<[number, number]>(() => { if (offset) return offset; if (typeof width === 'number') return [-width / 2, 5]; @@ -224,6 +249,7 @@ const MyMenu = ({ return ( { if (formatTrigger === 'hover') { - setIsOpen(true); + handleSetIsOpen(true); } + // 鼠标重新进入触发区域时,清除待执行的关闭定时器 clearTimeout(closeTimer.current); }} onMouseLeave={() => { if (formatTrigger === 'hover') { + // 延迟关闭,给鼠标移向 MenuList(Portal 渲染)留出时间 closeTimer.current = setTimeout(() => { - setIsOpen(false); + handleSetIsOpen(false); }, 100); } }} @@ -253,7 +281,7 @@ const MyMenu = ({ onClickCapture={(e) => { e.stopPropagation(); if (formatTrigger === 'click') { - setIsOpen(!isOpen); + handleSetIsOpen(!isOpen); } }} > @@ -283,6 +311,20 @@ const MyMenu = ({ p={'6px'} border={'1px solid #fff'} boxShadow={'3'} + onMouseEnter={() => { + // MenuList 通过 Portal 渲染到 body,不在 ref Box 的 DOM 范围内。 + // 鼠标从触发按钮移向菜单列表时,会触发外层 Box 的 onMouseLeave, + // 此处清除关闭定时器,确保鼠标在菜单列表上时菜单保持打开。 + clearTimeout(closeTimer.current); + }} + onMouseLeave={() => { + if (formatTrigger === 'hover') { + // 鼠标离开菜单列表时,延迟关闭 + closeTimer.current = setTimeout(() => { + handleSetIsOpen(false); + }, 100); + } + }} > {menuList.map((item, i) => { return ( @@ -301,7 +343,7 @@ const MyMenu = ({ return; } if (child.onClick) { - setIsOpen(false); + handleSetIsOpen(false); child.onClick(); } }} diff --git a/packages/web/components/common/MyModal/EditFolderModal.tsx b/packages/web/components/common/MyModal/EditFolderModal.tsx index 6f37e94fd97b..d8230acedd98 100644 --- a/packages/web/components/common/MyModal/EditFolderModal.tsx +++ b/packages/web/components/common/MyModal/EditFolderModal.tsx @@ -1,19 +1,36 @@ -import React, { useMemo } from 'react'; -import { ModalFooter, ModalBody, Input, Button, Box, Textarea } from '@chakra-ui/react'; +import React, { useCallback, useMemo } from 'react'; +import { + ModalFooter, + ModalBody, + Input, + Button, + Box, + Textarea, + Flex, + HStack +} from '@chakra-ui/react'; import MyModal from './index'; import { useTranslation } from 'next-i18next'; import { useRequest } from '../../../hooks/useRequest'; import FormLabel from '../MyBox/FormLabel'; import { useForm } from 'react-hook-form'; +import Avatar from '../Avatar'; +import { useUploadAvatar } from '../../../common/file/hooks/useUploadAvatar'; +import MyTooltip from '../MyTooltip'; +import type { CreatePostPresignedUrlResult } from '@fastgpt/global/common/file/s3/type'; +import { FolderImgUrl } from '@fastgpt/global/common/file/image/constants'; export type EditFolderFormType = { id?: string; name?: string; intro?: string; + avatar?: string; }; + type CommitType = { name: string; intro?: string; + avatar?: string; }; const EditFolderModal = ({ @@ -22,69 +39,94 @@ const EditFolderModal = ({ onEdit, id, name, - intro + intro, + avatar: defaultAvatar, + getPresignedUrl }: EditFolderFormType & { onClose: () => void; onCreate: (data: CommitType) => any; onEdit: (data: CommitType & { id: string }) => any; + getPresignedUrl?: (params: { filename: string }) => Promise; }) => { const { t } = useTranslation(); const isEdit = !!id; - const { register, handleSubmit } = useForm({ - defaultValues: { - name, - intro - } + + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { name, intro, avatar: defaultAvatar ?? FolderImgUrl } }); + const avatar = watch('avatar'); + + const afterUploadAvatar = useCallback( + (avatarUrl: string) => setValue('avatar', avatarUrl), + [setValue] + ); + const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar( + getPresignedUrl ?? (() => Promise.reject('No presigned URL provider')), + { onSuccess: afterUploadAvatar } + ); - const typeMap = useMemo( - () => - isEdit - ? { - title: t('common:dataset.Edit Folder') - } - : { - title: t('common:dataset.Create Folder') - }, + const title = useMemo( + () => (isEdit ? t('common:dataset.Edit Folder') : t('common:dataset.Create Folder')), [isEdit, t] ); const { run: onSave, loading } = useRequest( - ({ name = '', intro }: EditFolderFormType) => { + ({ name = '', intro, avatar }: EditFolderFormType) => { if (!name) return; - - if (isEdit) return onEdit({ id, name, intro }); - return onCreate({ name, intro }); + if (isEdit) return onEdit({ id, name, intro, avatar }); + return onCreate({ name, intro, avatar }); }, { - onSuccess: (res) => { - onClose(); - } + onSuccess: () => onClose() } ); return ( - - - - {t('common:input_name')} - + + + + {t('common:app_icon_and_name')} + + + {getPresignedUrl && ( + + + + )} + + + + + + {t('common:plugin.Description')} + +