diff --git a/.claude/skills/add-permission/SKILL.md b/.claude/skills/add-permission/SKILL.md new file mode 100644 index 0000000000..9072f3a4c4 --- /dev/null +++ b/.claude/skills/add-permission/SKILL.md @@ -0,0 +1,37 @@ +--- +name: add-permission +description: 为 FastGPT 新资源接入权限管理。当用户需要为新资源(如 AgentSkill、Plugin 等)添加权限支持时触发。 +--- + +# 新资源权限接入 + +## 你的资源是哪种类型? + +``` +资源有父子/文件夹结构吗? + │ + ├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md) + │ + └─ 是 ──► 资源支持权限继承吗? + │ + ├─ 否 ──► 简单资源 ──► [快速入门](./guides/quick-start.md) + │ + └─ 是 ──► 继承型资源 ──► [完整接入](./guides/full-integration.md) +``` + +## 快速链接 + +| 我想... | 去看... | +|---------|---------| +| 5 步完成最小接入 | [快速入门](./guides/quick-start.md) | +| 接入继承型资源 | [完整接入](./guides/full-integration.md) | +| 检查遗漏项 | [实施清单](./checklist.md) | +| 理解权限系统原理 | [参考文档](./reference/README.md) | + +## 关键代码位置 + +| 文件 | 用途 | +|------|------| +| `packages/global/support/permission/constant.ts` | 添加 `PerResourceTypeEnum` | +| `packages/global/support/permission/{resource}/` | 权限常量 + Permission 类 | +| `packages/service/support/permission/{resource}/auth.ts` | 鉴权函数 | diff --git a/.claude/skills/add-permission/checklist.md b/.claude/skills/add-permission/checklist.md new file mode 100644 index 0000000000..7394219706 --- /dev/null +++ b/.claude/skills/add-permission/checklist.md @@ -0,0 +1,134 @@ +# 权限接入实施清单 + +> 上线前核对清单,可打印使用。 + +## 设计判断 + +- [ ] 确认资源是否有 owner +- [ ] 确认资源是否属于 team +- [ ] 确认资源是否需要协作者 +- [ ] 确认资源是否有 folder / parent-child 结构 +- [ ] 确认资源是否支持 `inheritPermission` +- [ ] 确认资源是否需要 owner 转移 + +--- + +## FastGPT 主仓库 + +### 权限定义 + +- [ ] `PerResourceTypeEnum` 已添加新资源类型 +- [ ] `packages/global/support/permission/{resource}/constant.ts` 已创建 + - [ ] `{Resource}RoleList` + - [ ] `{Resource}RolePerMap` + - [ ] `{Resource}PerList` + - [ ] `{Resource}DefaultRoleVal` +- [ ] `packages/global/support/permission/{resource}/controller.ts` 已创建 + - [ ] `{Resource}Permission` 类 + +### 资源 Schema + +- [ ] 包含 `teamId` 字段 +- [ ] 包含 `tmbId` 字段(owner) +- [ ] 包含 `parentId` 字段(如有层级) +- [ ] 包含 `inheritPermission` 字段(如有继承) + +### 鉴权函数 + +- [ ] `packages/service/support/permission/{resource}/auth.ts` 已创建 +- [ ] `auth{Resource}` 函数已实现 +- [ ] 如有继承,已实现父级权限合并 + +### API 权限校验 + +- [ ] 列表接口:`ReadPermissionVal` +- [ ] 详情接口:`ReadPermissionVal` +- [ ] 创建接口:`WritePermissionVal` 或 team 级创建权限 +- [ ] 更新接口:`WritePermissionVal` +- [ ] 删除接口:`OwnerPermissionVal`(不是 Manage!) +- [ ] Folder 创建接口(如有) +- [ ] 恢复继承接口(如有) + +### 继承相关(如适用) + +- [ ] 明确 folder 类型列表 +- [ ] 资源创建时复制父协作者 +- [ ] 资源移动时同步子树权限 +- [ ] `resumeInheritPermission` 逻辑 + +--- + +## fastgpt-pro + +### 协作者管理 + +- [ ] `collaborator/list` 接口 + - [ ] 返回 `clbs`(最终生效协作者) + - [ ] 返回 `parentClbs`(父级协作者) +- [ ] `collaborator/update` 接口 + - [ ] 需要 `ManagePermissionVal` + - [ ] 不能修改自己的权限 + - [ ] 非 owner 不能修改管理员权限 + - [ ] 继承冲突时自动断开继承 + +### Owner 转移(如适用) + +- [ ] `changeOwner` 接口 + - [ ] 需要 `OwnerPermissionVal` + - [ ] 更新资源表 `tmbId` + - [ ] 根资源断开继承 + - [ ] 修正权限记录 + +### 协作者类型支持 + +- [ ] 支持 `tmbId`(团队成员) +- [ ] 支持 `groupId`(成员组) +- [ ] 支持 `orgId`(组织) + +### 审计日志 + +- [ ] 更新协作者日志 +- [ ] 删除协作者日志 +- [ ] Owner 转移日志 +- [ ] 恢复继承日志(如有) +- [ ] 移动资源日志(如有) + +--- + +## 前端 + +- [ ] 协作者列表 API 调用 +- [ ] 协作者更新 API 调用 +- [ ] Owner 转移 API 调用(如有) +- [ ] 权限配置弹窗 / 协作者管理组件 +- [ ] 继承态提示 UI(如有) +- [ ] 恢复继承入口(如有) + +--- + +## 测试 + +### 单元测试 + +- [ ] Permission 类与角色映射 +- [ ] `getTmbPermission` 优先级逻辑 +- [ ] 继承型资源的父子权限合并 + +### 集成测试 + +- [ ] 主要 API 的权限边界 +- [ ] 删除是否要求 owner +- [ ] 移动与继承恢复逻辑 +- [ ] 协作者更新冲突处理 +- [ ] Owner 转移后权限记录正确性 + +--- + +## 最终检查 + +- [ ] 删除要求 owner,而不是 manage +- [ ] group / org 协作者按预期生效 +- [ ] 继承断开后生成正确的显式协作者快照 +- [ ] 移动资源后子树权限同步 +- [ ] Owner 转移后旧/新 owner 权限记录正确 +- [ ] 前后端展示的"最终权限"与后端实际鉴权一致 diff --git a/.claude/skills/add-permission/guides/full-integration.md b/.claude/skills/add-permission/guides/full-integration.md new file mode 100644 index 0000000000..9150933334 --- /dev/null +++ b/.claude/skills/add-permission/guides/full-integration.md @@ -0,0 +1,289 @@ +# 完整接入:继承型资源权限 + +> 适用于**继承型资源**:有 folder 结构、支持 `inheritPermission`、需要协作者管理和 owner 转移。 + +## 概览 + +继承型资源需要在两个仓库中实现: + +| 仓库 | 职责 | +|------|------| +| FastGPT 主仓库 | 权限定义、鉴权、继承同步 | +| fastgpt-pro | 协作者管理、owner 转移、审计日志 | + +--- + +## Part 1: FastGPT 主仓库 + +### 1.1 基础权限定义 + +与[快速入门](./quick-start.md)相同,完成 Step 1-3。 + +### 1.2 资源 Schema 字段 + +确保资源 Schema 包含以下字段: + +```typescript +const {Resource}Schema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner + parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源 + type: { type: String }, // 区分 folder 和普通资源 + inheritPermission: { type: Boolean, default: true } // 是否继承父权限 +}); +``` + +### 1.3 实现带继承的鉴权函数 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 关键:判断是否需要合并父级权限 + const isGetParentClb = + resource.inheritPermission && + resource.type !== '{resource}Folder' && // folder 不继承 + !!resource.parentId; + + // 并行获取父级权限和自身权限 + const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ + teamId, + tmbId, + resourceId: resource.parentId!, + resourceType: PerResourceTypeEnum.{resource} + }) + : NullRoleVal, + getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }) + ]); + + // 合并权限 + const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner + }); + + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +### 1.4 Folder 创建时复制父协作者 + +```typescript +// 创建 folder 时 +import { createResourceDefaultCollaborators } from '@fastgpt/service/support/permission/controller'; + +await createResourceDefaultCollaborators({ + teamId, + tmbId, + resourceId: newFolderId, + resourceType: PerResourceTypeEnum.{resource}, + parentId, + session +}); +``` + +### 1.5 移动资源时同步子树权限 + +```typescript +// 资源移动后 +import { syncChildrenPermission } from '@fastgpt/service/support/permission/inheritPermission'; + +await syncChildrenPermission({ + resource: movedResource, + folderTypeList: ['{resource}Folder'], + resourceType: PerResourceTypeEnum.{resource}, + resourceModel: Mongo{Resource}, + session, + collaborators: newParentCollaborators +}); +``` + +### 1.6 恢复继承 + +```typescript +// 恢复继承时 +import { resumeInheritPermission } from '@fastgpt/service/support/permission/inheritPermission'; + +await resumeInheritPermission({ + resource, + folderTypeList: ['{resource}Folder'], + resourceType: PerResourceTypeEnum.{resource}, + resourceModel: Mongo{Resource}, + session +}); +``` + +--- + +## Part 2: fastgpt-pro + +### 2.1 协作者列表接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts +async function handler(req) { + const { teamId, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + + const isGetParentClbs = + !!{resource}.inheritPermission && + {resource}.type !== '{resource}Folder' && + !!{resource}.parentId; + + const [parentClbs, childClbs] = await Promise.all([ + isGetParentClbs + ? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType }) + : [], + getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType }) + ]); + + const realClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs, parentClbs }) + : childClbs; + + return { + clbs: await getClbsInfo(realClbs), // 最终生效协作者 + parentClbs: await getClbsInfo(parentClbs) // 父级协作者(用于 UI 展示来源) + }; +} +``` + +### 2.2 协作者更新接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/update.ts +async function handler(req) { + const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ManagePermissionVal + }); + + // 保护规则 + const changedClbs = getChangedCollaborators({ newRealClbs: collaborators, oldRealClbs }); + + // 1. 不能修改自己的权限 + if (changedClbs.find((clb) => clb?.tmbId === tmbId)) { + return Promise.reject({Resource}ErrEnum.canNotEditSelfPermission); + } + + // 2. 非 owner 不能修改管理员级协作者 + if ( + changedClbs.some((clb) => new {Resource}Permission({ role: clb.changedRole }).hasManagePer) && + !myPer.isOwner + ) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 调用通用编排器 + await updateResourceCollaborators({ + teamId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource}, + collaborators, + folderTypeList: ['{resource}Folder'], + resource: {resource}, + resourceModel: Mongo{Resource}, + session + }); +} +``` + +### 2.3 Owner 转移接口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts +async function handler(req) { + const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal // 只有 owner 能转移 + }); + + await changeOwner({ + changeOwnerType: '{resource}', + resourceId: {resource}._id, + newOwnerId: newOwnerTmbId, + oldOwnerId: {resource}.tmbId, + teamId: {resource}.teamId + }); +} +``` + +--- + +## Part 3: 前端 + +### 3.1 协作者管理组件 + +复用现有的 `MemberManager` 组件,配置: + +```typescript + get{Resource}Collaborators({resource}Id)} + onUpdateCollaborators={(clbs) => update{Resource}Collaborators({resource}Id, clbs)} + onDelOneCollaborator={(clb) => delete{Resource}Collaborator({resource}Id, clb)} +/> +``` + +### 3.2 继承态提示 + +```typescript +{resource.inheritPermission && resource.parentId && ( + 继承自父级 +)} +``` + +--- + +## 完成后检查 + +使用 [实施清单](../checklist.md) 进行最终检查。 + +## 深入了解 + +- [继承机制详解](../reference/inheritance.md) +- [协作者管理编排器](../reference/pro-collaborator.md) +- [Owner 转移机制](../reference/pro-owner-transfer.md) diff --git a/.claude/skills/add-permission/guides/quick-start.md b/.claude/skills/add-permission/guides/quick-start.md new file mode 100644 index 0000000000..5f985a8990 --- /dev/null +++ b/.claude/skills/add-permission/guides/quick-start.md @@ -0,0 +1,192 @@ +# 快速入门:5 步完成权限接入 + +> 适用于**简单资源**:无父子结构、无继承、无 owner 转移需求。 + +## 前置条件 + +- 资源已有 `teamId` 和 `tmbId` 字段 +- 资源属于某个 team + +--- + +## Step 1: 添加资源类型枚举 + +```typescript +// packages/global/support/permission/constant.ts +export enum PerResourceTypeEnum { + // ...existing + {resource} = '{resource}' // 例如: agentSkill = 'agentSkill' +} +``` + +--- + +## Step 2: 创建权限常量文件 + +```typescript +// packages/global/support/permission/{resource}/constant.ts +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { + CommonRoleList, + CommonPerKeyEnum, + CommonRolePerMap, + CommonPerList, + NullRoleVal +} from '../constant'; + +export const {Resource}RoleList = { + [CommonPerKeyEnum.read]: { + ...CommonRoleList[CommonPerKeyEnum.read], + description: i18nT('permission:{resource}.read_desc') + }, + [CommonPerKeyEnum.write]: { + ...CommonRoleList[CommonPerKeyEnum.write], + description: i18nT('permission:{resource}.write_desc') + }, + [CommonPerKeyEnum.manage]: { + ...CommonRoleList[CommonPerKeyEnum.manage], + description: i18nT('permission:{resource}.manage_desc') + } +}; + +export const {Resource}RolePerMap = CommonRolePerMap; +export const {Resource}PerList = CommonPerList; +export const {Resource}DefaultRoleVal = NullRoleVal; +``` + +--- + +## Step 3: 创建 Permission 类 + +```typescript +// packages/global/support/permission/{resource}/controller.ts +import { Permission, PerConstructPros } from '../controller'; +import { + {Resource}RoleList, + {Resource}RolePerMap, + {Resource}PerList, + {Resource}DefaultRoleVal +} from './constant'; + +export class {Resource}Permission extends Permission { + constructor(props?: PerConstructPros) { + if (!props) { + props = { role: {Resource}DefaultRoleVal }; + } else if (!props.role) { + props.role = {Resource}DefaultRoleVal; + } + + props.roleList = {Resource}RoleList; + props.rolePerMap = {Resource}RolePerMap; + props.perList = {Resource}PerList; + super(props); + } +} +``` + +--- + +## Step 4: 实现鉴权函数 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +import { AuthModeType } from '../type'; +import { parseHeaderCert } from '../../controller'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller'; +import { getTmbPermission } from '../controller'; +import { Mongo{Resource} } from '@fastgpt/service/core/{resource}/schema'; +import { {Resource}ErrEnum } from '@fastgpt/global/common/error/code/{resource}'; + +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + // 1. 查询资源 + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + // 2. 验证 team 归属 + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 3. 判断 owner + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 4. 获取权限 + const myPer = await getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }); + + // 5. 构建权限对象并检查 + const Per = new {Resource}Permission({ role: myPer, isOwner }); + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## Step 5: 在 API 中使用 + +```typescript +// 读取操作 +const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal +}); + +// 写入操作 +const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: WritePermissionVal +}); + +// 删除操作(要求 owner) +const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal +}); +``` + +--- + +## 完成后检查 + +- [ ] `PerResourceTypeEnum` 已添加 +- [ ] 权限常量文件已创建 +- [ ] Permission 类已创建 +- [ ] 鉴权函数已实现 +- [ ] API 路由已使用鉴权函数 + +## 下一步 + +- 需要协作者管理?→ 在 fastgpt-pro 中添加 `collaborator/list` 和 `collaborator/update` 接口 +- 需要更多细节?→ [参考文档](../reference/README.md) diff --git a/.claude/skills/add-permission/reference/README.md b/.claude/skills/add-permission/reference/README.md new file mode 100644 index 0000000000..048859ee9f --- /dev/null +++ b/.claude/skills/add-permission/reference/README.md @@ -0,0 +1,27 @@ +# 参考文档索引 + +> 深入理解 FastGPT 权限系统的设计原理和实现细节。 + +## 文档结构 + +``` +reference/ +├── core-concepts.md ── 核心概念:权限值、角色、协作者 +├── permission-class.md ── Permission 类设计与使用 +├── auth-function.md ── 鉴权函数实现模式 +├── inheritance.md ── 继承机制详解 +├── pro-collaborator.md ── fastgpt-pro 协作者管理 +└── pro-owner-transfer.md ── Owner 转移机制 +``` + +## 按场景查阅 + +| 我想了解... | 去看... | +|-------------|---------| +| 权限值的位字段设计 | [核心概念](./core-concepts.md) | +| 如何扩展 Permission 类 | [Permission 类设计](./permission-class.md) | +| 鉴权函数的标准实现 | [鉴权函数实现](./auth-function.md) | +| 父子资源权限如何合并 | [继承机制](./inheritance.md) | +| 协作者更新如何处理冲突 | [协作者管理](./pro-collaborator.md) | +| Owner 转移的完整流程 | [Owner 转移](./pro-owner-transfer.md) | + diff --git a/.claude/skills/add-permission/reference/auth-function.md b/.claude/skills/add-permission/reference/auth-function.md new file mode 100644 index 0000000000..31173d7ec0 --- /dev/null +++ b/.claude/skills/add-permission/reference/auth-function.md @@ -0,0 +1,251 @@ +# 鉴权函数实现 + +## 1. 标准鉴权流程 + +``` +用户请求 (带 Token/ApiKey) + │ + ▼ +┌──────────────────────┐ +│ parseHeaderCert │ 解析认证信息 +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 查询资源 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 验证 team 归属 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 判断 isOwner │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ getTmbPermission │ 获取用户权限 +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 构建 Permission 对象 │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ checkPer 验证 │ +└──────────────────────┘ +``` + +--- + +## 2. 简单资源鉴权模板 + +```typescript +// packages/service/support/permission/{resource}/auth.ts +import { AuthModeType, parseHeaderCert } from '../type'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import { {Resource}Permission } from '@fastgpt/global/support/permission/{resource}/controller'; +import { getTmbPermission } from '../controller'; + +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + // 1. 解析认证信息 + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + // 2. 查询资源 + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + // 3. 验证 team 归属 + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 4. 判断 owner + // - team owner 视为资源 owner + // - 资源创建者是 owner + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 5. 获取用户权限 + const myPer = await getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }); + + // 6. 构建权限对象并检查 + const Per = new {Resource}Permission({ role: myPer, isOwner }); + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + // 7. 返回结果 + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## 3. 继承型资源鉴权模板 + +```typescript +export const auth{Resource} = async ({ + {resource}Id, + per, + ...props +}: AuthModeType & { + {resource}Id: string; + per: PermissionValueType; +}) => { + const result = await parseHeaderCert(props); + const { tmbId, teamId } = result; + + const resource = await Mongo{Resource}.findById({resource}Id).lean(); + if (!resource) { + return Promise.reject({Resource}ErrEnum.notExist); + } + + if (String(resource.teamId) !== teamId) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + const isOwner = result.permission.isOwner || String(resource.tmbId) === String(tmbId); + + // 关键:判断是否需要合并父级权限 + const isGetParentClb = + resource.inheritPermission && // 开启了继承 + resource.type !== '{resource}Folder' && // folder 不继承 + !!resource.parentId; // 有父资源 + + // 并行获取 + const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ + teamId, + tmbId, + resourceId: resource.parentId!, + resourceType: PerResourceTypeEnum.{resource} + }) + : NullRoleVal, + getTmbPermission({ + teamId, + tmbId, + resourceId: {resource}Id, + resourceType: PerResourceTypeEnum.{resource} + }) + ]); + + // 合并权限 + const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner + }); + + if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); + } + + return { + ...result, + permission: Per, + {resource}: resource + }; +}; +``` + +--- + +## 4. getTmbPermission 实现 + +```typescript +// packages/service/support/permission/controller.ts +export const getTmbPermission = async ({ + teamId, + tmbId, + resourceId, + resourceType +}) => { + // 1. 个人权限优先 + const tmbPer = ( + await MongoResourcePermission.findOne({ + resourceType, + teamId, + resourceId, + tmbId + }, 'permission').lean() + )?.permission; + + // 个人权限存在则直接返回(即使是 0) + if (tmbPer !== undefined) return tmbPer; + + // 2. 获取 group 和 org 权限 + const [groupPers, orgPers] = await Promise.all([ + // 查询用户所属 group 的权限 + getGroupPermissions(...), + // 查询用户所属 org 的权限 + getOrgPermissions(...) + ]); + + // 3. 合并返回 + return sumPer(...groupPers, ...orgPers); +}; +``` + +--- + +## 5. API 使用示例 + +```typescript +// 读取操作 +async function handler(req) { + const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + return { ...{resource}, permission }; +} + +// 写入操作 +async function handler(req) { + const { {resource}, permission } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: WritePermissionVal + }); + // 业务逻辑... +} + +// 删除操作(要求 owner) +async function handler(req) { + await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal + }); + // 删除逻辑... +} +``` diff --git a/.claude/skills/add-permission/reference/core-concepts.md b/.claude/skills/add-permission/reference/core-concepts.md new file mode 100644 index 0000000000..d504cf10bc --- /dev/null +++ b/.claude/skills/add-permission/reference/core-concepts.md @@ -0,0 +1,134 @@ +# 核心概念 + +## 1. 权限值 (Permission Value) - 位字段设计 + +权限使用位字段 (bitmask) 表示,支持权限组合: + +```typescript +// packages/global/support/permission/constant.ts +export const CommonPerList = { + owner: ~0 >>> 0, // 所有位为1,表示所有者 + read: 0b100, // 读权限 (4) + write: 0b010, // 写权限 (2) + manage: 0b001 // 管理权限 (1) +}; +``` + +### 权限值对照表 + +| 权限 | 值 | 二进制 | 说明 | +|------|-----|--------|------| +| NullRoleVal | 0 | 0b000 | 无角色 | +| ReadPermissionVal | 4 | 0b100 | 读权限 | +| WritePermissionVal | 2 | 0b010 | 写权限 | +| ManagePermissionVal | 1 | 0b001 | 管理权限 | +| OwnerPermissionVal | ~0>>>0 | 全1 | 所有者 | + +### 位运算示例 + +```typescript +// 检查是否有读权限 +const hasRead = (permission & ReadPermissionVal) === ReadPermissionVal; + +// 合并权限 +const merged = permission1 | permission2; + +// 添加权限 +const withWrite = permission | WritePermissionVal; +``` + +--- + +## 2. 角色值 (Role Value) - 权限映射 + +**关键区分**:数据库中 `permission` 字段存储的是**角色值**,不是展开后的权限值。 + +```typescript +// 角色 -> 权限映射 +export const CommonRolePerMap = new Map([ + [0b100, 0b100], // read 角色 -> read 权限 + [0b010, 0b110], // write 角色 -> write + read 权限 + [0b001, 0b111] // manage 角色 -> manage + write + read 权限 +]); +``` + +### 角色继承关系 + +``` +manage (0b001) ──包含──► write + read + │ +write (0b010) ──包含──► read + │ +read (0b100) +``` + +--- + +## 3. 协作者类型 + +权限可以分配给三种实体(三选一): + +```typescript +// packages/global/support/permission/collaborator.ts +type CollaboratorIdType = RequireOnlyOne<{ + tmbId: string; // 团队成员 + groupId: string; // 成员组 + orgId: string; // 组织 +}>; +``` + +### 权限优先级 + +``` +tmbId (个人权限) + │ + └─ 存在?─► 直接返回 + │ + └─ 否 ─► groupId + orgId 合并后返回 +``` + +**注意**:不是"个人 > 组 > 组织"的覆盖关系,而是: +- 个人权限存在则直接使用 +- 否则 group 和 org 权限按位合并 + +--- + +## 4. ResourcePermission Schema + +```typescript +// packages/service/support/permission/schema.ts +const ResourcePermissionSchema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + + // 协作者标识(三选一) + tmbId: { type: Schema.Types.ObjectId }, + groupId: { type: Schema.Types.ObjectId }, + orgId: { type: Schema.Types.ObjectId }, + + // 资源信息 + resourceType: { type: String, enum: Object.values(PerResourceTypeEnum), required: true }, + resourceId: { type: Schema.Types.ObjectId }, + + // 存储的是角色值 + permission: { type: Number, required: true } +}); +``` + +### 索引 + +- `resourceId + tmbId` 唯一 +- `resourceId + groupId` 唯一 +- `resourceId + orgId` 唯一 + +--- + +## 5. 资源 Schema 权限相关字段 + +```typescript +const ResourceSchema = new Schema({ + teamId: { type: Schema.Types.ObjectId, required: true }, + tmbId: { type: Schema.Types.ObjectId, required: true }, // 创建者/owner + parentId: { type: Schema.Types.ObjectId, default: null }, // 父资源(可选) + inheritPermission: { type: Boolean, default: true } // 是否继承(可选) +}); +``` diff --git a/.claude/skills/add-permission/reference/inheritance.md b/.claude/skills/add-permission/reference/inheritance.md new file mode 100644 index 0000000000..1ceeef15be --- /dev/null +++ b/.claude/skills/add-permission/reference/inheritance.md @@ -0,0 +1,295 @@ +# 继承机制详解 + +## 1. 继承模型概述 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Folder A (inheritPermission: false) │ +│ 协作者: [User1: manage, User2: write] │ +└───────────────────────┬─────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────┐ ┌───────────────────────────┐ +│ Resource B │ │ Folder C │ +│ inherit: true │ │ inherit: true │ +│ 自身协作者: [] │ │ 协作者: [User1, User2] │ +│ │ │ (从 A 复制) │ +│ 最终权限: │ └─────────────┬─────────────┘ +│ User1: manage │ │ +│ User2: write │ ▼ +│ (来自父级) │ ┌───────────────────────────┐ +└───────────────────┘ │ Resource D │ + │ inherit: true │ + │ 自身协作者: [User3: read] │ + │ │ + │ 最终权限: │ + │ User1: manage (父级) │ + │ User2: write (父级) │ + │ User3: read (自身) │ + └───────────────────────────┘ +``` + +### 关键规则 + +1. **Folder 不继承**:folder 的 `inheritPermission` 无效,它有自己的完整协作者列表 +2. **普通资源继承**:开启继承时,鉴权时合并父级权限 +3. **继承是增量合并**:不是覆盖,子资源可以有额外的显式协作者 + +--- + +## 2. 鉴权时的权限合并 + +```typescript +// packages/service/support/permission/dataset/auth.ts +const isGetParentClb = + dataset.inheritPermission && + dataset.type !== DatasetTypeEnum.folder && + !!dataset.parentId; + +const [folderPer, myPer] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ resourceId: dataset.parentId, ... }) + : NullRoleVal, + getTmbPermission({ resourceId: datasetId, ... }) +]); + +// 按位合并 +const Per = new DatasetPermission({ + role: sumPer(folderPer, myPer), + isOwner +}); +``` + +### sumPer 实现 + +```typescript +export const sumPer = (...pers: PermissionValueType[]) => { + return pers.reduce((acc, per) => acc | per, NullRoleVal); +}; +``` + +--- + +## 3. Folder 创建时复制父协作者 + +```typescript +// packages/service/support/permission/controller.ts +export const createResourceDefaultCollaborators = async ({ + teamId, + tmbId, + resourceId, + resourceType, + parentId, + session +}) => { + // 1. 获取父协作者 + const parentClbs = parentId + ? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType }) + : []; + + // 2. 构建新协作者列表 + const collaborators = [ + ...parentClbs + .filter((item) => item.tmbId !== tmbId) // 排除创建者 + .map((clb) => { + // 父 owner 降级为 manage + if (clb.permission === OwnerRoleVal) { + clb.permission = ManageRoleVal; + } + return clb; + }), + // 创建者成为 owner + { tmbId, permission: OwnerRoleVal } + ]; + + // 3. 批量插入 + await MongoResourcePermission.insertMany( + collaborators.map((clb) => ({ + teamId, + resourceType, + resourceId, + ...clb + })), + { session } + ); +}; +``` + +--- + +## 4. 子树权限同步 (syncChildrenPermission) + +当 folder 的协作者变化时,需要同步到继承它的子树。 + +```typescript +// packages/service/support/permission/inheritPermission.ts +export async function syncChildrenPermission({ + resource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: latestClbList +}) { + // 1. 只处理 folder + if (!folderTypeList.includes(resource.type)) return; + + // 2. 获取所有 inheritPermission: true 的 folder 子树 + const allFolders = await resourceModel.find({ + teamId: resource.teamId, + inheritPermission: true, + type: { $in: folderTypeList } + }); + + // 3. BFS 遍历子树 + const queue = [resource._id]; + while (queue.length) { + const parentId = queue.shift(); + const children = allFolders.filter(f => String(f.parentId) === String(parentId)); + + for (const child of children) { + // 获取子资源现有协作者 + const childClbs = await getResourceOwnedClbs({ resourceId: child._id, ... }); + + for (const latestClb of latestClbList) { + // 跳过 owner + if (latestClb.permission === OwnerRoleVal) continue; + + const myClb = childClbs.find(c => sameClb(c, latestClb)); + + if (myClb) { + // 已有则合并(增量) + await MongoResourcePermission.updateOne( + { _id: myClb._id }, + { permission: sumPer(myClb.permission, latestClb.permission) }, + { session } + ); + } else { + // 没有则新增 + await MongoResourcePermission.create([{ + ...latestClb, + resourceId: child._id, + resourceType + }], { session }); + } + } + + // 删除不再存在的纯继承协作者 + for (const childClb of childClbs) { + const inLatest = latestClbList.find(c => sameClb(c, childClb)); + if (!inLatest && childClb.permission === parentClb?.permission) { + // 是纯继承的,删除 + await MongoResourcePermission.deleteOne({ _id: childClb._id }, { session }); + } + } + + queue.push(child._id); + } + } +} +``` + +### 关键点 + +- **增量合并**:不是简单覆盖,保留子资源的显式增量 +- **只删纯继承**:只删除权限值与父级完全一致的协作者 +- **跳过 owner**:owner 不参与继承同步 + +--- + +## 5. 恢复继承 (resumeInheritPermission) + +```typescript +// packages/service/support/permission/inheritPermission.ts +export const resumeInheritPermission = async ({ + resource, + folderTypeList, + resourceType, + resourceModel, + session +}) => { + const { teamId, parentId, _id: resourceId } = resource; + + // 1. 获取父协作者 + const parentClbs = parentId + ? await getResourceOwnedClbs({ teamId, resourceId: parentId, resourceType }) + : []; + + // 2. 获取自身协作者 + const selfClbs = await getResourceOwnedClbs({ teamId, resourceId, resourceType }); + + // 3. 合并协作者 + const mergedClbs = mergeCollaboratorList({ + childClbs: selfClbs, + parentClbs: parentClbs.map((clb) => { + // 父 owner 降为 manage + if (clb.permission === OwnerRoleVal) { + return { ...clb, permission: ManageRoleVal }; + } + return clb; + }) + }); + + // 4. 删除旧协作者 + await MongoResourcePermission.deleteMany({ + resourceType, + resourceId + }, { session }); + + // 5. 插入合并后的协作者 + await MongoResourcePermission.insertMany( + mergedClbs.map(clb => ({ + teamId, + resourceType, + resourceId, + ...clb + })), + { session } + ); + + // 6. 如果是 folder,同步子树 + if (folderTypeList.includes(resource.type)) { + await syncChildrenPermission({ + resource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: mergedClbs + }); + } + + // 7. 设置继承标志 + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: true }, + { session } + ); +}; +``` + +--- + +## 6. 资源移动时的处理 + +```typescript +// 移动资源后 +if (newParentId !== oldParentId) { + // 获取新父级协作者 + const newParentClbs = await getResourceOwnedClbs({ + resourceId: newParentId, + ... + }); + + // 同步子树 + await syncChildrenPermission({ + resource: movedResource, + folderTypeList, + resourceType, + resourceModel, + session, + collaborators: newParentClbs + }); +} +``` diff --git a/.claude/skills/add-permission/reference/permission-class.md b/.claude/skills/add-permission/reference/permission-class.md new file mode 100644 index 0000000000..ff9ce3c13d --- /dev/null +++ b/.claude/skills/add-permission/reference/permission-class.md @@ -0,0 +1,143 @@ +# Permission 类设计 + +## 1. 基类结构 + +```typescript +// packages/global/support/permission/controller.ts +export class Permission { + role: PermissionValueType; + private permission: PermissionValueType; + + // 权限状态(计算属性) + isOwner: boolean; + hasManagePer: boolean; + hasWritePer: boolean; + hasReadPer: boolean; + + // 角色状态 + hasManageRole: boolean; + hasWriteRole: boolean; + hasReadRole: boolean; + + constructor({ role, isOwner, roleList, perList, rolePerMap }) { + this.role = isOwner ? OwnerRoleVal : role; + this.updatePermissions(); + } + + // 检查是否拥有指定权限 + checkPer(perm: PermissionValueType): boolean { + if (perm === OwnerPermissionVal) { + return this.permission === OwnerPermissionVal; + } + return (this.permission & perm) === perm; + } + + // 添加角色 + addRole(...roleList: RoleValueType[]) { + for (const role of roleList) { + this.role = this.role | role; + } + this.updatePermissions(); + return this; + } +} +``` + +### 关键点 + +1. **存储的是 role**:`permission` 字段存的是 role 值,通过 `rolePerMap` 展开成实际权限 +2. **isOwner 提升**:如果 `isOwner=true`,role 直接设为 `OwnerRoleVal` +3. **链式调用**:`addRole` 返回 `this`,支持链式操作 + +--- + +## 2. 创建资源特定的 Permission 类 + +```typescript +// packages/global/support/permission/{resource}/controller.ts +import { Permission, PerConstructPros } from '../controller'; +import { + {Resource}RoleList, + {Resource}RolePerMap, + {Resource}PerList, + {Resource}DefaultRoleVal +} from './constant'; + +export class {Resource}Permission extends Permission { + constructor(props?: PerConstructPros) { + // 处理空参数 + if (!props) { + props = { role: {Resource}DefaultRoleVal }; + } else if (!props.role) { + props.role = {Resource}DefaultRoleVal; + } + + // 注入资源特定的配置 + props.roleList = {Resource}RoleList; + props.rolePerMap = {Resource}RolePerMap; + props.perList = {Resource}PerList; + + super(props); + } +} +``` + +--- + +## 3. 使用示例 + +### 3.1 基本检查 + +```typescript +const per = new DatasetPermission({ role: WriteRoleVal }); + +per.hasReadPer; // true(write 包含 read) +per.hasWritePer; // true +per.hasManagePer; // false +per.isOwner; // false + +per.checkPer(ReadPermissionVal); // true +per.checkPer(ManagePermissionVal); // false +``` + +### 3.2 在鉴权中使用 + +```typescript +const Per = new {Resource}Permission({ + role: myPer, + isOwner: String(resource.tmbId) === String(tmbId) +}); + +if (!Per.checkPer(per)) { + return Promise.reject({Resource}ErrEnum.unAuth); +} + +// 返回给调用方 +return { + permission: Per, + {resource}: resource +}; +``` + +### 3.3 合并权限 + +```typescript +import { sumPer } from '@fastgpt/global/support/permission/utils'; + +// 合并父级权限和自身权限 +const Per = new {Resource}Permission({ + role: sumPer(folderPer, myPer), + isOwner +}); +``` + +--- + +## 4. 现有 Permission 类 + +| 类 | 文件 | +|----|------| +| `Permission` | `packages/global/support/permission/controller.ts` | +| `DatasetPermission` | `packages/global/support/permission/dataset/controller.ts` | +| `AppPermission` | `packages/global/support/permission/app/controller.ts` | +| `TeamPermission` | `packages/global/support/permission/user/controller.ts` | diff --git a/.claude/skills/add-permission/reference/pro-collaborator.md b/.claude/skills/add-permission/reference/pro-collaborator.md new file mode 100644 index 0000000000..da19cc1ea4 --- /dev/null +++ b/.claude/skills/add-permission/reference/pro-collaborator.md @@ -0,0 +1,298 @@ +# fastgpt-pro 协作者管理 + +> fastgpt-pro 在 FastGPT 主仓库的基础权限系统之上,提供"可运营的权限管理能力"。 + +## 1. 架构分层 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ fastgpt-pro 权限扩展层 │ +├────────────────────────────────────────────────────────────────────┤ +│ API 层 │ +│ ├── /api/core/{resource}/collaborator/list │ +│ ├── /api/core/{resource}/collaborator/update │ +│ └── /api/core/{resource}/changeOwner │ +├────────────────────────────────────────────────────────────────────┤ +│ 编排层 │ +│ ├── updateResourceCollaborators │ +│ ├── getChangedCollaborators │ +│ ├── checkRoleUpdateConflict │ +│ └── mergeCollaboratorList │ +├────────────────────────────────────────────────────────────────────┤ +│ FastGPT 主仓库基础能力 │ +│ ├── authDataset / authApp │ +│ ├── getTmbPermission │ +│ └── ResourcePermission Schema │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**一句话概括**:FastGPT 负责"判定权限",fastgpt-pro 负责"管理权限"。 + +--- + +## 2. 协作者列表接口 + +### 接口设计 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/collaborator/list.ts +type Response = { + clbs: CollaboratorItemDetailType[]; // 最终生效协作者 + parentClbs?: CollaboratorItemDetailType[]; // 父级协作者(用于展示来源) +}; +``` + +### 实现 + +```typescript +async function handler(req) { + const { teamId, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ReadPermissionVal + }); + + // 判断是否需要获取父级协作者 + const isGetParentClbs = + !!{resource}.inheritPermission && + {resource}.type !== '{resource}Folder' && + !!{resource}.parentId; + + const [parentClbs, childClbs] = await Promise.all([ + isGetParentClbs + ? getResourceOwnedClbs({ teamId, resourceId: {resource}.parentId, resourceType }) + : [], + getResourceOwnedClbs({ teamId, resourceId: {resource}Id, resourceType }) + ]); + + // 合并得到最终生效协作者 + const realClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs, parentClbs }) + : childClbs; + + return { + clbs: await getClbsInfo(realClbs), + parentClbs: await getClbsInfo(parentClbs) + }; +} +``` + +### 设计意图 + +- 不是简单返回 `MongoResourcePermission.find({ resourceId })` +- 同时返回"最终权限视图"和"继承来源视图" +- 前端可以据此展示"此权限来自父级"的 UI 提示 + +--- + +## 3. 协作者更新接口 + +### 核心流程 + +```typescript +async function handler(req) { + // 1. 鉴权(需要 manage 权限) + const { teamId, tmbId, permission: myPer, {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: ManagePermissionVal + }); + + // 2. 获取新旧协作者 + const [parentClbs, oldChildClbs] = await Promise.all([ + getResourceOwnedClbs({ resourceId: parentId }), + getResourceOwnedClbs({ resourceId: {resource}Id }) + ]); + + const oldRealClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs }) + : oldChildClbs; + + // 3. 计算变化 + const changedClbs = getChangedCollaborators({ + newRealClbs: collaborators, + oldRealClbs + }); + + // 4. 权限保护检查 + await checkPermissionProtection(changedClbs, tmbId, myPer); + + // 5. 调用编排器更新 + await updateResourceCollaborators({ + teamId, + resourceId: {resource}Id, + resourceType, + collaborators, + folderTypeList, + resource: {resource}, + resourceModel, + session + }); +} +``` + +### 权限保护规则 + +```typescript +// 1. 不能修改自己的权限 +if (changedClbs.find((clb) => clb?.tmbId === tmbId)) { + return Promise.reject(ErrEnum.canNotEditSelfPermission); +} + +// 2. 非 owner 不能修改管理员级协作者 +if ( + changedClbs.some((clb) => + new {Resource}Permission({ role: clb.changedRole }).hasManagePer + ) && + !myPer.isOwner +) { + return Promise.reject(ErrEnum.unAuth); +} +``` + +--- + +## 4. updateResourceCollaborators 编排器 + +### 核心逻辑 + +```typescript +export const updateResourceCollaborators = async ({ + teamId, + resourceId, + resourceType, + collaborators, // 用户想更新成的协作者列表 + folderTypeList, + resource, + resourceModel, + session +}) => { + // 1. 获取父级和当前协作者 + const [parentClbs, oldChildClbs] = await Promise.all([...]); + + // 2. 计算旧的最终协作者 + const oldRealClbs = isGetParentClbs + ? mergeCollaboratorList({ childClbs: oldChildClbs, parentClbs }) + : oldChildClbs; + + // 3. 计算变化的协作者 + const changedClbs = getChangedCollaborators({ + newRealClbs: collaborators, + oldRealClbs + }); + + // 4. 检测继承冲突 + const hasConflict = checkRoleUpdateConflict({ + changedClbs, + parentClbs + }); + + // 5. 如果是 folder,先同步子树 + if (folderTypeList.includes(resource.type)) { + await syncChildrenPermission({ + resource, + collaborators, + ... + }); + } + + // 6. 如果处于继承态且有冲突,自动断开继承 + if (resource.inheritPermission && hasConflict) { + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: false }, + { session } + ); + } + + // 7. 更新协作者记录 + if (folderTypeList.includes(resource.type) || hasConflict) { + // folder 或冲突:整表重建 + await MongoResourcePermission.deleteMany({ resourceId }, { session }); + await MongoResourcePermission.insertMany(collaborators, { session }); + } else { + // 普通情况:增量更新 + for (const clb of changedClbs) { + if (clb.action === 'add') { + await MongoResourcePermission.create([clb], { session }); + } else if (clb.action === 'update') { + await MongoResourcePermission.updateOne( + { resourceId, ...clbId }, + { permission: clb.permission }, + { session } + ); + } else if (clb.action === 'delete') { + await MongoResourcePermission.deleteOne({ resourceId, ...clbId }, { session }); + } + } + } +}; +``` + +--- + +## 5. 继承冲突检测 + +### checkRoleUpdateConflict + +```typescript +export const checkRoleUpdateConflict = ({ + changedClbs, + parentClbs +}) => { + for (const changed of changedClbs) { + // 找到对应的父协作者 + const parentClb = parentClbs.find(p => sameClb(p, changed)); + + if (parentClb) { + // 如果修改了来自父级的协作者权限,或删除了父级协作者 + if ( + changed.action === 'delete' || + changed.permission !== parentClb.permission + ) { + return true; // 有冲突 + } + } + } + return false; +}; +``` + +### 冲突即断继承 + +**设计价值**: +1. 用户不需要先点"取消继承"再改协作者 +2. 直接改协作者就自动完成"打断继承"状态迁移 +3. 交互从"配置底层机制"变成"编辑最终结果" + +--- + +## 6. 为什么 folder 要"整表重建" + +folder 或继承态冲突时,采用"删除全部协作者记录,再插入新列表"。 + +**原因**:这两类场景里,"当前资源的协作者记录"已经不再只是"子级自定义增量",而是要转成一份新的"显式完整权限快照"。 + +```typescript +if (folderTypeList.includes(resource.type) || hasConflict) { + // 整表重建 + await MongoResourcePermission.deleteMany({ resourceId }, { session }); + await MongoResourcePermission.insertMany(collaborators, { session }); +} +``` + +--- + +## 7. 支持三类协作者 + +fastgpt-pro 的协作者管理同时支持: + +| 类型 | 字段 | 说明 | +|------|------|------| +| 团队成员 | tmbId | 个人级权限 | +| 成员组 | groupId | 组级权限 | +| 组织 | orgId | 组织级权限 | + +新资源接入时,必须同时支持这三类协作者。 diff --git a/.claude/skills/add-permission/reference/pro-owner-transfer.md b/.claude/skills/add-permission/reference/pro-owner-transfer.md new file mode 100644 index 0000000000..c448179b11 --- /dev/null +++ b/.claude/skills/add-permission/reference/pro-owner-transfer.md @@ -0,0 +1,259 @@ +# Owner 转移机制 + +## 1. 接口入口 + +```typescript +// fastgpt-pro/projects/app/src/pages/api/core/{resource}/changeOwner.ts +async function handler(req) { + // 只有 owner 能转移 + const { {resource} } = await auth{Resource}({ + req, + authToken: true, + {resource}Id, + per: OwnerPermissionVal + }); + + await changeOwner({ + changeOwnerType: '{resource}', + resourceId: {resource}._id, + newOwnerId: newOwnerTmbId, + oldOwnerId: {resource}.tmbId, + teamId: {resource}.teamId + }); +} +``` + +--- + +## 2. 通用 changeOwner 实现 + +```typescript +// fastgpt-pro/projects/app/src/service/core/changeOwner.ts +export const changeOwner = async ({ + changeOwnerType, + resourceId, + newOwnerId, + oldOwnerId, + teamId +}) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const { resourceModel, folderTypeList, resourceType } = getResourceConfig(changeOwnerType); + + // 1. 查询资源 + const resource = await resourceModel.findById(resourceId); + + // 2. 如果是 folder,获取整个子树 + const allResources = folderTypeList.includes(resource.type) + ? await getResourceTree(resource, resourceModel, folderTypeList) + : [resource]; + + // 3. 更新资源表的 tmbId + await updateResourceOwner(allResources, newOwnerId, resourceModel, session); + + // 4. 根资源断开继承 + await resourceModel.updateOne( + { _id: resourceId }, + { inheritPermission: false }, + { session } + ); + + // 5. 修正权限记录 + await fixPermissionRecords(allResources, oldOwnerId, newOwnerId, resourceType, session); + + await session.commitTransaction(); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } +}; +``` + +--- + +## 3. 更新资源表 Owner + +```typescript +const updateResourceOwner = async ( + allResources, + newOwnerId, + resourceModel, + session +) => { + // 根资源直接改 owner + await resourceModel.updateOne( + { _id: allResources[0]._id }, + { tmbId: newOwnerId }, + { session } + ); + + // 子资源:只改仍属于旧 owner 的 + const childResources = allResources.slice(1); + const oldOwnerChildren = childResources.filter( + r => String(r.tmbId) === String(oldOwnerId) + ); + + if (oldOwnerChildren.length > 0) { + await resourceModel.updateMany( + { _id: { $in: oldOwnerChildren.map(r => r._id) } }, + { tmbId: newOwnerId }, + { session } + ); + } +}; +``` + +--- + +## 4. 权限记录修正策略 + +```typescript +const fixPermissionRecords = async ( + allResources, + oldOwnerId, + newOwnerId, + resourceType, + session +) => { + const resourceIds = allResources.map(r => r._id); + + // 查询涉及的权限记录 + const permissions = await MongoResourcePermission.find({ + resourceType, + resourceId: { $in: resourceIds }, + tmbId: { $in: [oldOwnerId, newOwnerId] } + }); + + // 按资源分组 + const perByResource = groupBy(permissions, 'resourceId'); + + for (const [resourceId, pers] of Object.entries(perByResource)) { + const oldOwnerPer = pers.find(p => String(p.tmbId) === String(oldOwnerId)); + const newOwnerPer = pers.find(p => String(p.tmbId) === String(newOwnerId)); + + if (oldOwnerPer && newOwnerPer) { + // 情况1:两者都有记录 → 合并后只保留 newOwner + await MongoResourcePermission.updateOne( + { _id: newOwnerPer._id }, + { permission: Math.max(oldOwnerPer.permission, newOwnerPer.permission) }, + { session } + ); + await MongoResourcePermission.deleteOne( + { _id: oldOwnerPer._id }, + { session } + ); + } else if (oldOwnerPer && !newOwnerPer) { + // 情况2:只有 oldOwner 有记录 → 改成 newOwner + await MongoResourcePermission.updateOne( + { _id: oldOwnerPer._id }, + { tmbId: newOwnerId }, + { session } + ); + } + // 情况3:只有 newOwner 有记录 → 保持不变 + } +}; +``` + +### 注意 + +当前使用 `Math.max(oldPer, newPer)` 合并权限。这在 bitmask 设计下有潜在风险,因为数值更大不一定代表权限更强。 + +建议后续改成更明确的合并策略: + +```typescript +// 推荐做法 +const mergedPermission = oldOwnerPer.permission | newOwnerPer.permission; +``` + +--- + +## 5. Folder 子树处理 + +```typescript +const getResourceTree = async (root, resourceModel, folderTypeList) => { + const result = [root]; + const queue = [root._id]; + + while (queue.length) { + const parentId = queue.shift(); + + const children = await resourceModel.find({ + parentId, + teamId: root.teamId + }); + + for (const child of children) { + result.push(child); + // 只有 folder 才继续递归 + if (folderTypeList.includes(child.type)) { + queue.push(child._id); + } + } + } + + return result; +}; +``` + +--- + +## 6. 完整流程图 + +``` +Owner 转移请求 + │ + ▼ +┌──────────────────────┐ +│ 验证 OwnerPermission │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 查询资源(及子树) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 更新资源表 tmbId │ +│ (根资源 + 旧owner子) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 根资源断开继承 │ +│ inheritPermission: │ +│ false │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ 修正权限记录 │ +│ oldOwner → newOwner │ +└──────────────────────┘ +``` + +--- + +## 7. 审计日志 + +Owner 转移是敏感操作,必须记录审计日志: + +```typescript +await addOperationLog({ + teamId, + tmbId, + operationType: 'changeOwner', + resourceType, + resourceId, + metadata: { + oldOwnerId, + newOwnerId, + resourceName: resource.name + } +}); +``` diff --git a/deploy/dev/docker-compose.cn.yml b/deploy/dev/docker-compose.cn.yml index e32d41554e..b5bde09796 100644 --- a/deploy/dev/docker-compose.cn.yml +++ b/deploy/dev/docker-compose.cn.yml @@ -13,6 +13,8 @@ # - fastgpt-aiproxy: 3010 # - fastgpt-aiproxy-pg: 5432 # - 使用 pgvector 作为默认的向量库 +# - 配置 opensandbox-config 的 network_mode 为 docker 网络,如 dev_fastgpt +# - 配置 opensandbox-config 的 host_ip 为宿主机 LAN IP,如 192.168.1.100 # plugin auth token x-plugin-auth-token: &x-plugin-auth-token 'token' @@ -366,6 +368,47 @@ services: interval: 5s timeout: 5s retries: 10 + opensandbox-server: + image: registry.cn-hangzhou.aliyuncs.com/fastgpt/opensandbox-server:v0.1.9 + container_name: opensandbox-server + restart: always + networks: + - fastgpt + ports: + - '8090:8090' + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: opensandbox-config + target: /etc/opensandbox/config.toml + environment: + - SANDBOX_CONFIG_PATH=/etc/opensandbox/config.toml + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8090/health'] + interval: 10s + timeout: 5s + retries: 5 + volume-manager: + image: fastgpt-volume-manager:latest + container_name: volume-manager + restart: always + networks: + - fastgpt + ports: + - 3004:3001 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - VM_RUNTIME=docker + - VM_AUTH_TOKEN=changeme + - VM_VOLUME_NAME_PREFIX=fastgpt-session + - VM_LOG_LEVEL=info + healthcheck: + test: + ['CMD', 'bun', '-e', "fetch('http://localhost:3001/health').then((res) => { if (!res.ok) throw new Error(String(res.status)); })"] + interval: 10s + timeout: 5s + retries: 5 networks: fastgpt: aiproxy: @@ -394,6 +437,12 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" \ No newline at end of file diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 518578e9e0..bfe8a20acd 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -13,6 +13,8 @@ # - fastgpt-aiproxy: 3010 # - fastgpt-aiproxy-pg: 5432 # - 使用 pgvector 作为默认的向量库 +# - 配置 opensandbox-config 的 network_mode 为 docker 网络,如 dev_fastgpt +# - 配置 opensandbox-config 的 host_ip 为宿主机 LAN IP,如 192.168.1.100 # plugin auth token x-plugin-auth-token: &x-plugin-auth-token 'token' @@ -366,6 +368,47 @@ services: interval: 5s timeout: 5s retries: 10 + opensandbox-server: + image: opensandbox/server:v0.1.9 + container_name: opensandbox-server + restart: always + networks: + - fastgpt + ports: + - '8090:8090' + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: opensandbox-config + target: /etc/opensandbox/config.toml + environment: + - SANDBOX_CONFIG_PATH=/etc/opensandbox/config.toml + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8090/health'] + interval: 10s + timeout: 5s + retries: 5 + volume-manager: + image: fastgpt-volume-manager:latest + container_name: volume-manager + restart: always + networks: + - fastgpt + ports: + - 3004:3001 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - VM_RUNTIME=docker + - VM_AUTH_TOKEN=changeme + - VM_VOLUME_NAME_PREFIX=fastgpt-session + - VM_LOG_LEVEL=info + healthcheck: + test: + ['CMD', 'bun', '-e', "fetch('http://localhost:3001/health').then((res) => { if (!res.ok) throw new Error(String(res.status)); })"] + interval: 10s + timeout: 5s + retries: 5 networks: fastgpt: aiproxy: @@ -394,6 +437,12 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" \ 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 76332e2b65..1033a79336 100644 --- a/deploy/docker/cn/docker-compose.milvus.yml +++ b/deploy/docker/cn/docker-compose.milvus.yml @@ -504,7 +504,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/cn/docker-compose.oceanbase.yml b/deploy/docker/cn/docker-compose.oceanbase.yml index 759a72b203..381a8ea70d 100644 --- a/deploy/docker/cn/docker-compose.oceanbase.yml +++ b/deploy/docker/cn/docker-compose.oceanbase.yml @@ -481,9 +481,15 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" init_sql: name: init_sql content: | diff --git a/deploy/docker/cn/docker-compose.pg.yml b/deploy/docker/cn/docker-compose.pg.yml index b761e03636..dc41c49009 100644 --- a/deploy/docker/cn/docker-compose.pg.yml +++ b/deploy/docker/cn/docker-compose.pg.yml @@ -462,7 +462,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/cn/docker-compose.seekdb.yml b/deploy/docker/cn/docker-compose.seekdb.yml index 1b112dce3f..17ae2d125f 100644 --- a/deploy/docker/cn/docker-compose.seekdb.yml +++ b/deploy/docker/cn/docker-compose.seekdb.yml @@ -468,7 +468,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/cn/docker-compose.zilliz.yml b/deploy/docker/cn/docker-compose.zilliz.yml index 81960c44b6..7f8ec89a7b 100644 --- a/deploy/docker/cn/docker-compose.zilliz.yml +++ b/deploy/docker/cn/docker-compose.zilliz.yml @@ -444,7 +444,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/global/docker-compose.milvus.yml b/deploy/docker/global/docker-compose.milvus.yml index 8219d3e3f2..51bab2fc6e 100644 --- a/deploy/docker/global/docker-compose.milvus.yml +++ b/deploy/docker/global/docker-compose.milvus.yml @@ -504,7 +504,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/global/docker-compose.oceanbase.yml b/deploy/docker/global/docker-compose.oceanbase.yml index 7b2e5e2205..381f3ffbf6 100644 --- a/deploy/docker/global/docker-compose.oceanbase.yml +++ b/deploy/docker/global/docker-compose.oceanbase.yml @@ -481,9 +481,15 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" init_sql: name: init_sql content: | diff --git a/deploy/docker/global/docker-compose.pg.yml b/deploy/docker/global/docker-compose.pg.yml index f5300bbd51..32032a7043 100644 --- a/deploy/docker/global/docker-compose.pg.yml +++ b/deploy/docker/global/docker-compose.pg.yml @@ -462,7 +462,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/global/docker-compose.seekdb.yml b/deploy/docker/global/docker-compose.seekdb.yml index e2fb7ee3a4..41f4c632e6 100644 --- a/deploy/docker/global/docker-compose.seekdb.yml +++ b/deploy/docker/global/docker-compose.seekdb.yml @@ -468,7 +468,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/docker/global/docker-compose.ziliiz.yml b/deploy/docker/global/docker-compose.ziliiz.yml index 1d436ed6a0..eb0b13baae 100644 --- a/deploy/docker/global/docker-compose.ziliiz.yml +++ b/deploy/docker/global/docker-compose.ziliiz.yml @@ -444,7 +444,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/deploy/helm/opensandbox/values.yaml b/deploy/helm/opensandbox/values.yaml index 98dec79be2..63a987a9b1 100644 --- a/deploy/helm/opensandbox/values.yaml +++ b/deploy/helm/opensandbox/values.yaml @@ -106,7 +106,7 @@ server: # Server image configuration image: repository: opensandbox/server - tag: "v0.1.0" + tag: "v0.1.9" pullPolicy: Never # Number of replicas diff --git a/deploy/k8s/volume-manager.yaml b/deploy/k8s/volume-manager.yaml new file mode 100644 index 0000000000..afb91da67f --- /dev/null +++ b/deploy/k8s/volume-manager.yaml @@ -0,0 +1,111 @@ +apiVersion: v1 +kind: Secret +metadata: + name: volume-manager-secret + namespace: opensandbox +type: Opaque +stringData: + auth-token: changeme +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: fastgpt-local +provisioner: rancher.io/local-path +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: volume-manager + namespace: opensandbox +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: volume-manager + namespace: opensandbox +rules: + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "create", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: volume-manager + namespace: opensandbox +subjects: + - kind: ServiceAccount + name: volume-manager + namespace: opensandbox +roleRef: + kind: Role + name: volume-manager + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: volume-manager + namespace: opensandbox + labels: + app: volume-manager +spec: + replicas: 1 + selector: + matchLabels: + app: volume-manager + template: + metadata: + labels: + app: volume-manager + spec: + serviceAccountName: volume-manager + containers: + - name: volume-manager + image: fastgpt-volume-manager:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3001 + env: + - name: VM_RUNTIME + value: kubernetes + - name: VM_K8S_NAMESPACE + value: opensandbox + - name: VM_K8S_PVC_STORAGE_CLASS + value: fastgpt-local + - name: VM_LOG_LEVEL + value: info + - name: VM_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: volume-manager-secret + key: auth-token + readinessProbe: + httpGet: + path: /health + port: 3001 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: volume-manager + namespace: opensandbox +spec: + selector: + app: volume-manager + ports: + - port: 3001 + targetPort: 3001 + type: ClusterIP diff --git a/deploy/templates/docker-compose.dev.yml b/deploy/templates/docker-compose.dev.yml index 4809ef37f7..e4ebadbce2 100644 --- a/deploy/templates/docker-compose.dev.yml +++ b/deploy/templates/docker-compose.dev.yml @@ -13,6 +13,8 @@ # - fastgpt-aiproxy: 3010 # - fastgpt-aiproxy-pg: 5432 # - 使用 pgvector 作为默认的向量库 +# - 配置 opensandbox-config 的 network_mode 为 docker 网络,如 dev_fastgpt +# - 配置 opensandbox-config 的 host_ip 为宿主机 LAN IP,如 192.168.1.100 # plugin auth token x-plugin-auth-token: &x-plugin-auth-token 'token' @@ -366,6 +368,47 @@ services: interval: 5s timeout: 5s retries: 10 + opensandbox-server: + image: ${{opensandbox-server.image}}:${{opensandbox-server.tag}} + container_name: opensandbox-server + restart: always + networks: + - fastgpt + ports: + - '8090:8090' + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: opensandbox-config + target: /etc/opensandbox/config.toml + environment: + - SANDBOX_CONFIG_PATH=/etc/opensandbox/config.toml + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8090/health'] + interval: 10s + timeout: 5s + retries: 5 + volume-manager: + image: fastgpt-volume-manager:latest + container_name: volume-manager + restart: always + networks: + - fastgpt + ports: + - 3004:3001 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - VM_RUNTIME=docker + - VM_AUTH_TOKEN=changeme + - VM_VOLUME_NAME_PREFIX=fastgpt-session + - VM_LOG_LEVEL=info + healthcheck: + test: + ['CMD', 'bun', '-e', "fetch('http://localhost:3001/health').then((res) => { if (!res.ok) throw new Error(String(res.status)); })"] + interval: 10s + timeout: 5s + retries: 5 networks: fastgpt: aiproxy: @@ -394,6 +437,12 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" \ No newline at end of file diff --git a/deploy/templates/docker-compose.prod.yml b/deploy/templates/docker-compose.prod.yml index 5d3eac78e1..34c54cab36 100644 --- a/deploy/templates/docker-compose.prod.yml +++ b/deploy/templates/docker-compose.prod.yml @@ -443,7 +443,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" ${{vec.extra}} diff --git a/document/public/deploy/docker/cn/docker-compose.milvus.yml b/document/public/deploy/docker/cn/docker-compose.milvus.yml index 76332e2b65..1033a79336 100644 --- a/document/public/deploy/docker/cn/docker-compose.milvus.yml +++ b/document/public/deploy/docker/cn/docker-compose.milvus.yml @@ -504,7 +504,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/cn/docker-compose.oceanbase.yml index 759a72b203..381a8ea70d 100644 --- a/document/public/deploy/docker/cn/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/cn/docker-compose.oceanbase.yml @@ -481,9 +481,15 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" init_sql: name: init_sql content: | diff --git a/document/public/deploy/docker/cn/docker-compose.pg.yml b/document/public/deploy/docker/cn/docker-compose.pg.yml index b761e03636..dc41c49009 100644 --- a/document/public/deploy/docker/cn/docker-compose.pg.yml +++ b/document/public/deploy/docker/cn/docker-compose.pg.yml @@ -462,7 +462,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/cn/docker-compose.seekdb.yml index 1b112dce3f..17ae2d125f 100644 --- a/document/public/deploy/docker/cn/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/cn/docker-compose.seekdb.yml @@ -468,7 +468,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/cn/docker-compose.zilliz.yml index 81960c44b6..7f8ec89a7b 100644 --- a/document/public/deploy/docker/cn/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/cn/docker-compose.zilliz.yml @@ -444,7 +444,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/global/docker-compose.milvus.yml b/document/public/deploy/docker/global/docker-compose.milvus.yml index 8219d3e3f2..51bab2fc6e 100644 --- a/document/public/deploy/docker/global/docker-compose.milvus.yml +++ b/document/public/deploy/docker/global/docker-compose.milvus.yml @@ -504,7 +504,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/global/docker-compose.oceanbase.yml index 7b2e5e2205..381f3ffbf6 100644 --- a/document/public/deploy/docker/global/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/global/docker-compose.oceanbase.yml @@ -481,9 +481,15 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" init_sql: name: init_sql content: | diff --git a/document/public/deploy/docker/global/docker-compose.pg.yml b/document/public/deploy/docker/global/docker-compose.pg.yml index f5300bbd51..32032a7043 100644 --- a/document/public/deploy/docker/global/docker-compose.pg.yml +++ b/document/public/deploy/docker/global/docker-compose.pg.yml @@ -462,7 +462,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/global/docker-compose.seekdb.yml b/document/public/deploy/docker/global/docker-compose.seekdb.yml index e2fb7ee3a4..41f4c632e6 100644 --- a/document/public/deploy/docker/global/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/global/docker-compose.seekdb.yml @@ -468,7 +468,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/document/public/deploy/docker/global/docker-compose.ziliiz.yml b/document/public/deploy/docker/global/docker-compose.ziliiz.yml index 1d436ed6a0..eb0b13baae 100644 --- a/document/public/deploy/docker/global/docker-compose.ziliiz.yml +++ b/document/public/deploy/docker/global/docker-compose.ziliiz.yml @@ -444,7 +444,13 @@ configs: [docker] network_mode = "bridge" - # 容器内访问宿主机服务时需要设置为宿主机 IP 或 hostname - # macOS/Windows: host.docker.internal;Linux: 宿主机 LAN IP(如 192.168.1.100) + # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP). + # It's required when server deployed with docker container under host. host_ip = "host.docker.internal" + drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] + no_new_privileges = true + pids_limit = 512 + + [ingress] + mode = "direct" diff --git a/packages/global/common/error/code/agentSkill.ts b/packages/global/common/error/code/agentSkill.ts new file mode 100644 index 0000000000..5de39a5a2f --- /dev/null +++ b/packages/global/common/error/code/agentSkill.ts @@ -0,0 +1,33 @@ +import { type ErrType } from '../errorCode'; +import { i18nT } from '../../../../web/i18n/utils'; +/* agentSkill: 509000 */ +export enum SkillErrEnum { + unExist = 'skillUnExist', + unAuthSkill = 'unAuthSkill', + canNotEditAdminPermission = 'canNotEditAdminPermission' +} +const skillErrList = [ + { + statusText: SkillErrEnum.unExist, + message: i18nT('common:code_error.skill_error.not_exist') + }, + { + statusText: SkillErrEnum.unAuthSkill, + message: i18nT('common:code_error.skill_error.un_auth_skill') + }, + { + statusText: SkillErrEnum.canNotEditAdminPermission, + message: i18nT('common:code_error.skill_error.can_not_edit_admin_permission') + } +]; +export default skillErrList.reduce((acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 509000 + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; +}, {} as ErrType<`${SkillErrEnum}`>); diff --git a/packages/global/common/error/errorCode.ts b/packages/global/common/error/errorCode.ts index 478694742d..1a8e509326 100644 --- a/packages/global/common/error/errorCode.ts +++ b/packages/global/common/error/errorCode.ts @@ -8,6 +8,7 @@ import teamErr from './code/team'; import userErr from './code/user'; import commonErr from './code/common'; import SystemErrEnum from './code/system'; +import agentSkillErr from './code/agentSkill'; import { i18nT } from '../../../web/i18n/utils'; export const ERROR_CODE: { [key: number]: string } = { @@ -108,5 +109,6 @@ export const ERROR_RESPONSE: Record< ...userErr, ...pluginErr, ...commonErr, - ...SystemErrEnum + ...SystemErrEnum, + ...agentSkillErr }; diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 3540d7275b..ce55790495 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -109,6 +109,8 @@ export type FastGPTFeConfigsType = { limit?: { exportDatasetLimitMinutes?: number; websiteSyncLimitMinuted?: number; + agentSandboxMaxEditDebug?: number; + agentSandboxMaxSessionRuntime?: number; }; uploadFileMaxAmount: number; @@ -143,6 +145,8 @@ export type FastGPTFeConfigsType = { // tmp agentSandboxFree?: boolean; + // Beta features + show_skill?: boolean; }; export type SystemEnvType = { diff --git a/packages/global/core/agentSkills/api.ts b/packages/global/core/agentSkills/api.ts new file mode 100644 index 0000000000..923ab32b06 --- /dev/null +++ b/packages/global/core/agentSkills/api.ts @@ -0,0 +1 @@ +export * from '../../openapi/core/agentSkills/api'; diff --git a/packages/global/core/agentSkills/collaborator.ts b/packages/global/core/agentSkills/collaborator.ts new file mode 100644 index 0000000000..4fd4d33da1 --- /dev/null +++ b/packages/global/core/agentSkills/collaborator.ts @@ -0,0 +1,14 @@ +import type { UpdateClbPermissionProps } from '../../support/permission/collaborator'; +import type { RequireOnlyOne } from '../../common/type/utils'; + +export type UpdateSkillCollaboratorBody = UpdateClbPermissionProps & { + skillId: string; +}; + +export type SkillCollaboratorDeleteParams = { + skillId: string; +} & RequireOnlyOne<{ + tmbId: string; + groupId: string; + orgId: string; +}>; diff --git a/packages/global/core/agentSkills/constants.ts b/packages/global/core/agentSkills/constants.ts new file mode 100644 index 0000000000..042d42f41a --- /dev/null +++ b/packages/global/core/agentSkills/constants.ts @@ -0,0 +1,115 @@ +import type { I18nStringType } from '../../common/i18n/type'; + +export enum AgentSkillSourceEnum { + system = 'system', + personal = 'personal' +} + +export enum AgentSkillCategoryEnum { + search = 'search', + tool = 'tool', + coding = 'coding', + data = 'data', + analysis = 'analysis', + communication = 'communication', + other = 'other' +} + +export const AgentSkillCategoryMap: Record< + `${AgentSkillCategoryEnum}`, + { label: I18nStringType; icon: string } +> = { + [AgentSkillCategoryEnum.search]: { + label: { + 'zh-CN': '搜索', + 'zh-Hant': '搜索', + en: 'Search' + }, + icon: 'core/agentSkill/search' + }, + [AgentSkillCategoryEnum.tool]: { + label: { + 'zh-CN': '工具', + 'zh-Hant': '工具', + en: 'Tool' + }, + icon: 'core/agentSkill/tool' + }, + [AgentSkillCategoryEnum.coding]: { + label: { + 'zh-CN': '编程', + 'zh-Hant': '編程', + en: 'Coding' + }, + icon: 'core/agentSkill/coding' + }, + [AgentSkillCategoryEnum.data]: { + label: { + 'zh-CN': '数据处理', + 'zh-Hant': '數據處理', + en: 'Data Processing' + }, + icon: 'core/agentSkill/data' + }, + [AgentSkillCategoryEnum.analysis]: { + label: { + 'zh-CN': '分析', + 'zh-Hant': '分析', + en: 'Analysis' + }, + icon: 'core/agentSkill/analysis' + }, + [AgentSkillCategoryEnum.communication]: { + label: { + 'zh-CN': '通信', + 'zh-Hant': '通訊', + en: 'Communication' + }, + icon: 'core/agentSkill/communication' + }, + [AgentSkillCategoryEnum.other]: { + label: { + 'zh-CN': '其他', + 'zh-Hant': '其他', + en: 'Other' + }, + icon: 'core/agentSkill/other' + } +}; + +export const agentSkillsCollectionName = 'agent_skills'; + +export const agentSkillsVersionCollectionName = 'agent_skills_versions'; + +export const skillSandboxCollectionName = 'skill_sandbox_info'; + +// Agent Skill types +export enum AgentSkillTypeEnum { + folder = 'folder', + skill = 'skill' +} + +export const AgentSkillFolderTypeList = [AgentSkillTypeEnum.folder]; + +// Sandbox types +export enum SandboxTypeEnum { + editDebug = 'edit-debug', + sessionRuntime = 'session-runtime' +} + +// Sandbox status states +export enum SandboxStateEnum { + pending = 'Pending', + running = 'Running', + failed = 'Failed', + succeeded = 'Succeeded', + unknown = 'Unknown' +} + +// Sandbox protocol types +export enum SandboxProtocolEnum { + http = 'http', + https = 'https' +} + +export const sandboxInstanceCollectionName = 'agent_sandbox_instances'; diff --git a/packages/global/core/agentSkills/type.ts b/packages/global/core/agentSkills/type.ts new file mode 100644 index 0000000000..6677e5065b --- /dev/null +++ b/packages/global/core/agentSkills/type.ts @@ -0,0 +1,213 @@ +import { z } from 'zod'; +import { + AgentSkillSourceEnum, + AgentSkillCategoryEnum, + AgentSkillTypeEnum, + SandboxProtocolEnum, + SandboxTypeEnum +} from './constants'; +import { SandboxStatusEnum } from '../ai/sandbox/constants'; + +const LooseObjectSchema = z.object({}).catchall(z.any()); +const BufferSchema = z.custom( + (value) => typeof Buffer !== 'undefined' && Buffer.isBuffer(value), + 'Expected Buffer' +); + +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 +] as const); + +export const AgentSkillConfigParameterSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string(), + required: z.boolean().optional(), + default: z.any().optional() +}); + +export const AgentSkillApiConfigSchema = z.object({ + url: z.string(), + method: z.enum(['GET', 'POST', 'PUT', 'DELETE']), + headers: z.record(z.string(), z.string()).optional(), + timeout: z.number().optional() +}); + +export const AgentSkillConfigSchema = z + .object({ + parameters: z.array(AgentSkillConfigParameterSchema).optional(), + api: AgentSkillApiConfigSchema.optional() + }) + .catchall(z.any()); +export type AgentSkillConfigType = z.infer; + +export const AgentSkillStorageSchema = z.object({ + bucket: z.string(), + key: z.string(), + size: z.number() +}); + +export const SkillVersionStorageSchema = AgentSkillStorageSchema.extend({ + checksum: z.string().optional() +}); + +export const AgentSkillSchema = z.object({ + _id: z.string(), + parentId: z.string().nullable().optional(), + type: AgentSkillTypeSchema, + inheritPermission: z.boolean().optional(), + source: AgentSkillSourceSchema, + name: z.string(), + description: z.string(), + author: z.string(), + category: z.array(AgentSkillCategorySchema), + config: AgentSkillConfigSchema, + avatar: z.string().optional(), + teamId: z.string(), + tmbId: z.string(), + createTime: z.date(), + updateTime: z.date(), + deleteTime: z.date().nullable().optional(), + currentVersion: z.number(), + versionCount: z.number(), + currentStorage: AgentSkillStorageSchema.optional() +}); +export type AgentSkillSchemaType = z.infer; + +export const AgentSkillListItemSchema = z.object({ + _id: z.string(), + source: AgentSkillSourceSchema, + type: AgentSkillTypeSchema, + parentId: z.string().nullable().optional(), + inheritPermission: z.boolean().optional(), + name: z.string(), + description: z.string(), + author: z.string(), + category: z.array(AgentSkillCategorySchema), + avatar: z.string().optional(), + createTime: z.date(), + updateTime: z.date(), + appCount: z.number().optional(), + sourceMember: z + .object({ + name: z.string(), + avatar: z.string().nullable().optional(), + status: z.string() + }) + .optional() +}); +export type AgentSkillListItemType = z.infer; + +export const AgentSkillDetailSchema = AgentSkillSchema.extend({ + appCount: z.number().optional(), + permission: z.any().optional() +}); +export type AgentSkillDetailType = z.infer; + +export const AgentSkillsVersionImportSourceSchema = z.object({ + originalFilename: z.string(), + importedAt: z.date() +}); + +export const AgentSkillsVersionSchema = z.object({ + _id: z.string(), + skillId: z.string(), + tmbId: z.string(), + version: z.number(), + versionName: z.string().optional(), + storage: SkillVersionStorageSchema, + importSource: AgentSkillsVersionImportSourceSchema.optional(), + isActive: z.boolean(), + isDeleted: z.boolean(), + createdAt: z.date() +}); +export type AgentSkillsVersionSchemaType = z.infer; + +export const SkillPackageSkillSchema = z.object({ + name: z.string(), + description: z.string(), + category: z.array(AgentSkillCategorySchema), + config: AgentSkillConfigSchema, + avatar: z.string().optional() +}); + +export const SkillPackageSchema = z.object({ + skill: SkillPackageSkillSchema +}); +export type SkillPackageType = z.infer; + +export const ZipEntryInfoSchema = z.object({ + name: z.string(), + size: z.number(), + isDirectory: z.boolean(), + uncompressedSize: z.number().optional(), + compressionMethod: z.number().optional() +}); +export type ZipEntryInfo = z.infer; + +export const ExtractedSkillPackageSchema = z.object({ + skillPackage: SkillPackageSchema, + zipBuffer: BufferSchema, + zipEntries: z.array(ZipEntryInfoSchema), + totalSize: z.number() +}); +export type ExtractedSkillPackage = z.infer; + +export const SkillSandboxEndpointSchema = z.object({ + host: z.string(), + port: z.number(), + protocol: SandboxProtocolSchema, + url: z.string() +}); +export type SkillSandboxEndpointType = z.infer; + +export const SandboxImageConfigSchema = z.object({ + repository: z.string(), + tag: z.string().optional() +}); +export type SandboxImageConfigType = z.infer; + +export const SandboxProviderStatusSchema = z.object({ + state: z.string(), + message: z.string().optional(), + reason: z.string().optional() +}); + +export const SandboxStorageSchema = AgentSkillStorageSchema.extend({ + uploadedAt: z.date() +}); + +export const SandboxInstanceDetailSchema = z.object({ + sandboxType: SandboxTypeSchema, + teamId: z.string(), + tmbId: z.string(), + skillId: z.string().optional(), + sessionId: z.string().optional(), + skillIds: z.array(z.string()).optional(), + provider: z.string(), + image: SandboxImageConfigSchema, + providerStatus: SandboxProviderStatusSchema, + providerCreatedAt: z.date(), + endpoint: SkillSandboxEndpointSchema.optional(), + storage: SandboxStorageSchema.optional(), + metadata: z.union([z.map(z.string(), z.any()), LooseObjectSchema]).optional() +}); + +export const SandboxInstanceSchema = z.object({ + _id: z.string(), + sandboxId: z.string(), + appId: z.string(), + userId: z.string(), + chatId: z.string(), + status: SandboxStatusSchema, + lastActiveAt: z.date(), + createdAt: z.date(), + detail: SandboxInstanceDetailSchema +}); +export type SandboxInstanceSchemaType = z.infer; diff --git a/packages/global/core/ai/sandbox/constants.ts b/packages/global/core/ai/sandbox/constants.ts index 081fa66692..e595eb907e 100644 --- a/packages/global/core/ai/sandbox/constants.ts +++ b/packages/global/core/ai/sandbox/constants.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; // ---- 沙盒状态 ---- export const SandboxStatusEnum = { running: 'running', - stoped: 'stoped' + stopped: 'stopped' } as const; export type SandboxStatusType = (typeof SandboxStatusEnum)[keyof typeof SandboxStatusEnum]; diff --git a/packages/global/core/app/formEdit/type.ts b/packages/global/core/app/formEdit/type.ts index e29587a508..beb3b37f7d 100644 --- a/packages/global/core/app/formEdit/type.ts +++ b/packages/global/core/app/formEdit/type.ts @@ -6,6 +6,25 @@ import { NodeInputKeyEnum } from '../../workflow/constants'; export type AgentSubAppItemType = {}; +/* ===== Agent Skill ===== */ +export const SelectedAgentSkillItemTypeSchema = z.object({ + skillId: z.string(), + name: z.string(), + description: z.string().default(''), + avatar: z.string().optional() +}); +export type SelectedAgentSkillItemType = z.infer; + +/** + * 将 skills 输入值规范化为 skillId 字符串数组。 + * 兼容两种格式: + * - string[]:debugChat 运行时直接传入的 skillId 数组 + * - SelectedAgentSkillItemType[]:工作流 NodeAgent 存储的完整对象数组(含 name/avatar 等展示字段) + */ +export const normalizeSkillIds = ( + skills: Array | undefined +): string[] => (skills ?? []).map((s) => (typeof s === 'string' ? s : s.skillId)).filter(Boolean); + /* ===== Tool ===== */ export const SelectedToolItemTypeSchema = FlowNodeTemplateTypeSchema.extend({ configStatus: z.enum(['noConfig', 'waitingForConfig', 'configured', 'invalid']).optional() @@ -32,6 +51,7 @@ export const AppFormEditFormV1TypeSchema = z.object({ datasets: z.array(SelectedDatasetSchema) }), selectedTools: z.array(SelectedToolItemTypeSchema), + selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).optional(), chatConfig: AppChatConfigTypeSchema }); export type AppFormEditFormType = z.infer; diff --git a/packages/global/core/app/utils.ts b/packages/global/core/app/utils.ts index 3a9b27a2a4..ae61f918da 100644 --- a/packages/global/core/app/utils.ts +++ b/packages/global/core/app/utils.ts @@ -24,6 +24,7 @@ export const getDefaultAppForm = (): AppFormEditFormType => { datasetSearchExtensionBg: '' }, selectedTools: [], + selectedAgentSkills: [], chatConfig: {} }; }; diff --git a/packages/global/core/chat/type.ts b/packages/global/core/chat/type.ts index 6388c14894..050506809d 100644 --- a/packages/global/core/chat/type.ts +++ b/packages/global/core/chat/type.ts @@ -33,6 +33,52 @@ export const StepTitleItemSchema = z.object({ }); export type StepTitleItemType = z.infer; +/* Sandbox lifecycle phase */ +export type SandboxStatusPhase = + // Lifecycle phases + | 'checkExisting' // checking for existing container in MongoDB + | '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) + | 'deployingSkills' // announcing which skill is about to be deployed + | 'downloadingPackage' // downloading skill package from MinIO + | 'uploadingPackage' // uploading package into sandbox container + | 'extractingPackage' // extracting package in sandbox + // Lazy-init phases + | 'lazyInit' // LLM first calls sandbox tool, triggers container creation + // Terminal phases + | 'ready' // sandbox is ready + | 'failed'; // initialization failed +// Note: 'expiredDetected' and 'restarting' are internal and filtered server-side + +export type SandboxStatusItemType = { + sandboxId: string; // sessionId or skillId (correlates events for same sandbox) + phase: SandboxStatusPhase; + isWarmStart?: boolean; // present on 'connecting' and 'ready' + skillName?: string; // present on 'deployingSkills', 'downloadingPackage', + // 'uploadingPackage', 'extractingPackage' in session-runtime + message?: string; // optional human-readable message + // Present on 'ready' phase for edit-debug sandboxes + endpoint?: { + host: string; + port: number; + protocol: 'http' | 'https'; + url: string; + }; + providerSandboxId?: string; // present on 'ready' for edit-debug +}; + +/* Skill module response */ +export const SkillModuleResponseItemSchema = z.object({ + id: z.string(), + skillName: z.string(), + skillAvatar: z.string(), + description: z.string(), + skillMdPath: z.string() +}); +export type SkillModuleResponseItemType = z.infer; + /* --------- chat ---------- */ export type ChatSchemaType = { _id: string; @@ -143,6 +189,7 @@ export const AIChatItemValueSchema = z.object({ }) .nullish(), tools: z.array(ToolModuleResponseItemSchema).nullish(), + skills: z.array(SkillModuleResponseItemSchema).nullish(), interactive: WorkflowInteractiveResponseTypeSchema.optional(), plan: AgentPlanSchema.nullish(), stepTitle: StepTitleItemSchema.nullish(), diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index 0bbd2d4e9c..e9ef98241b 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -173,6 +173,7 @@ export enum NodeInputKeyEnum { datasetParams = 'agent_datasetParams', skills = 'skills', useAgentSandbox = 'useAgentSandbox', + useEditDebugSandbox = 'useEditDebugSandbox', // dataset datasetSelectList = 'datasets', diff --git a/packages/global/core/workflow/node/agent/constants.ts b/packages/global/core/workflow/node/agent/constants.ts index 2e51292956..0e00a4a09e 100644 --- a/packages/global/core/workflow/node/agent/constants.ts +++ b/packages/global/core/workflow/node/agent/constants.ts @@ -6,6 +6,7 @@ import { SANDBOX_SHELL_TOOL } from '../../../ai/sandbox/constants'; import type { I18nStringType } from '../../../../common/i18n/type'; +import { skillToolsMap } from './skillTools'; export enum SubAppIds { plan = 'plan_agent', @@ -76,5 +77,6 @@ export const systemSubInfo: Record< }, avatar: 'core/workflow/template/agent', toolDescription: '调用 LLM 模型完成一些通用任务。' - } + }, + ...skillToolsMap }; diff --git a/packages/global/core/workflow/node/agent/skillTools.ts b/packages/global/core/workflow/node/agent/skillTools.ts new file mode 100644 index 0000000000..c365f21008 --- /dev/null +++ b/packages/global/core/workflow/node/agent/skillTools.ts @@ -0,0 +1,238 @@ +import z from 'zod'; +import type { ChatCompletionTool } from '../../../ai/type'; + +export enum SandboxToolIds { + readFile = 'sandbox_read_file', + writeFile = 'sandbox_write_file', + editFile = 'sandbox_edit_file', + execute = 'sandbox_execute', + search = 'sandbox_search', + fetchUserFile = 'sandbox_fetch_user_file' +} + +export const skillToolsMap = { + // Sandbox tools + [SandboxToolIds.readFile]: { + name: { + 'zh-CN': '读取文件', + 'zh-Hant': '讀取文件', + en: 'ReadFile' + }, + avatar: 'core/workflow/template/readFiles', + toolDescription: + 'Read file contents in the sandbox, supports batch reading. Used to view SKILL.md documents, config files, execution results, etc.' + }, + [SandboxToolIds.writeFile]: { + name: { + 'zh-CN': '写入文件', + 'zh-Hant': '寫入文件', + en: 'WriteFile' + }, + avatar: 'core/workflow/template/readFiles', + toolDescription: + 'Create or overwrite a file in the sandbox. Used to write input data, create config files, etc.' + }, + [SandboxToolIds.editFile]: { + name: { + 'zh-CN': '编辑文件', + 'zh-Hant': '編輯文件', + en: 'EditFile' + }, + avatar: 'core/workflow/template/readFiles', + toolDescription: + 'Edit files in the sandbox precisely by finding and replacing specified content. Supports batch editing across multiple files.' + }, + [SandboxToolIds.execute]: { + name: { + 'zh-CN': '执行命令', + 'zh-Hant': '執行命令', + en: 'Execute' + }, + avatar: 'core/workflow/template/codeRun', + toolDescription: + 'Execute a shell command in the sandbox. Used to run scripts, install dependencies, execute skills, etc.' + }, + [SandboxToolIds.search]: { + name: { + 'zh-CN': '搜索文件', + 'zh-Hant': '搜索文件', + en: 'SearchFile' + }, + avatar: 'core/workflow/template/datasetSearch', + toolDescription: + 'Search for files in the sandbox. Find matching file paths by filename pattern (glob).' + }, + [SandboxToolIds.fetchUserFile]: { + name: { + 'zh-CN': '获取用户文件', + 'zh-Hant': '獲取用戶文件', + en: 'FetchUserFile' + }, + avatar: 'core/workflow/template/readFiles', + 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.' + } +}; + +// Zod parameter schemas (runtime validation) +export const SandboxReadFileSchema = z.object({ + paths: z.array(z.string()).describe('Array of absolute file paths') +}); +export const SandboxWriteFileSchema = z.object({ + path: z.string().describe('Absolute file path'), + content: z.string().describe('File content') +}); +export const SandboxEditFileSchema = z.object({ + entries: z.array( + z.object({ + path: z.string().describe('Absolute file path'), + oldContent: z.string().describe('Original content to replace'), + newContent: z.string().describe('New content after replacement') + }) + ) +}); +export const SandboxExecuteSchema = z.object({ + command: z.string().describe('Shell command to execute'), + workingDirectory: z.string().optional().describe('Working directory (optional)'), + timeoutMs: z.number().optional().default(30000).describe('Timeout in milliseconds') +}); +export const SandboxSearchSchema = z.object({ + pattern: z.string().describe('Search pattern (filename or glob)'), + path: z.string().optional().describe('Starting path for search (optional)') +}); +export const SandboxFetchUserFileSchema = z.object({ + file_index: z.string().describe('File index from available_files (e.g. "1")'), + target_path: z + .string() + .describe( + 'Relative path from workspace root to write the file (e.g. "uploads/document.pdf"). Do NOT use absolute paths or "..".' + ) +}); + +// ChatCompletionTool definitions (exposed to LLM) +export const sandboxReadFileTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.readFile, + description: skillToolsMap[SandboxToolIds.readFile].toolDescription, + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + items: { type: 'string' }, + description: 'Array of absolute file paths' + } + }, + required: ['paths'] + } + } +}; + +export const sandboxWriteFileTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.writeFile, + description: skillToolsMap[SandboxToolIds.writeFile].toolDescription, + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'Absolute file path' }, + content: { type: 'string', description: 'File content' } + }, + required: ['path', 'content'] + } + } +}; + +export const sandboxEditFileTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.editFile, + description: skillToolsMap[SandboxToolIds.editFile].toolDescription, + parameters: { + type: 'object', + properties: { + entries: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Absolute file path' }, + oldContent: { type: 'string', description: 'Original content to replace' }, + newContent: { type: 'string', description: 'New content after replacement' } + }, + required: ['path', 'oldContent', 'newContent'] + }, + description: 'Array of edit operations' + } + }, + required: ['entries'] + } + } +}; + +export const sandboxExecuteTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.execute, + description: skillToolsMap[SandboxToolIds.execute].toolDescription, + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: 'Shell command to execute' }, + workingDirectory: { type: 'string', description: 'Working directory (optional)' }, + timeoutMs: { type: 'number', description: 'Timeout in milliseconds (default 30000)' } + }, + required: ['command'] + } + } +}; + +export const sandboxSearchTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.search, + description: skillToolsMap[SandboxToolIds.search].toolDescription, + parameters: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Search pattern (filename or glob)' }, + path: { type: 'string', description: 'Starting path for search (optional)' } + }, + required: ['pattern'] + } + } +}; + +export const sandboxFetchUserFileTool: ChatCompletionTool = { + type: 'function', + function: { + name: SandboxToolIds.fetchUserFile, + description: skillToolsMap[SandboxToolIds.fetchUserFile].toolDescription, + parameters: { + type: 'object', + properties: { + file_index: { + type: 'string', + description: 'File index from available_files (e.g. "1")' + }, + target_path: { + type: 'string', + description: + 'Relative path from workspace root (e.g. "uploads/document.pdf"). Must not start with "/" or contain "..".' + } + }, + required: ['file_index', 'target_path'] + } + } +}; + +export const allSandboxTools: ChatCompletionTool[] = [ + sandboxReadFileTool, + sandboxWriteFileTool, + sandboxEditFileTool, + sandboxExecuteTool, + sandboxSearchTool, + sandboxFetchUserFileTool +]; diff --git a/packages/global/core/workflow/node/constant.ts b/packages/global/core/workflow/node/constant.ts index a15bd6b13f..0b0e801c2a 100644 --- a/packages/global/core/workflow/node/constant.ts +++ b/packages/global/core/workflow/node/constant.ts @@ -28,6 +28,9 @@ export enum FlowNodeInputTypeEnum { // render ui hidden = 'hidden', custom = 'custom', // 自定义渲染 + selectSkill = 'selectSkill', + selectTool = 'selectTool', + fileSelect = 'fileSelect', timePointSelect = 'timePointSelect', timeRangeSelect = 'timeRangeSelect', @@ -87,6 +90,12 @@ export const FlowNodeInputMap: Record< [FlowNodeInputTypeEnum.custom]: { icon: 'core/workflow/inputType/custom' }, + [FlowNodeInputTypeEnum.selectSkill]: { + icon: 'core/workflow/inputType/selectDataset' + }, + [FlowNodeInputTypeEnum.selectTool]: { + icon: 'core/workflow/inputType/selectDataset' + }, [FlowNodeInputTypeEnum.input]: { icon: 'core/workflow/inputType/input' }, @@ -292,7 +301,8 @@ export const NodeGradients = { lafTeal: 'linear-gradient(180deg, rgba(72, 213, 186, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)', skyBlue: 'linear-gradient(180deg, rgba(137, 229, 255, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)', salmon: 'linear-gradient(180deg, rgba(255, 160, 160, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)', - gray: 'linear-gradient(180deg, rgba(136, 136, 136, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)' + gray: 'linear-gradient(180deg, rgba(136, 136, 136, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)', + emerald: 'linear-gradient(180deg, rgba(20, 168, 70, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)' }; export const NodeBorderColors = { pink: 'rgba(255, 161, 206, 0.6)', @@ -313,7 +323,8 @@ export const NodeBorderColors = { lafTeal: 'rgba(72, 213, 186, 0.6)', skyBlue: 'rgba(137, 229, 255, 0.6)', salmon: 'rgba(255, 160, 160, 0.6)', - gray: 'rgba(136, 136, 136, 0.6)' + gray: 'rgba(136, 136, 136, 0.6)', + emerald: 'rgba(20, 168, 70, 0.6)' }; export const NodeColorSchemaEnum = [ 'pink', @@ -334,5 +345,6 @@ export const NodeColorSchemaEnum = [ 'lafTeal', 'skyBlue', 'salmon', - 'gray' + 'gray', + 'emerald' ] as const; diff --git a/packages/global/core/workflow/runtime/constants.ts b/packages/global/core/workflow/runtime/constants.ts index a7af531a46..8996a22779 100644 --- a/packages/global/core/workflow/runtime/constants.ts +++ b/packages/global/core/workflow/runtime/constants.ts @@ -21,6 +21,10 @@ export enum SseResponseEventEnum { plan = 'plan', // plan response stepTitle = 'stepTitle', // step title response + // Sandbox lifecycle + sandboxStatus = 'sandboxStatus', // sandbox lifecycle phase notification + skillCall = 'skillCall', // skill invocation announce (when SKILL.md is loaded) + // Helperbot collectionForm = 'collectionForm', // collection form for HelperBot topAgentConfig = 'topAgentConfig' // form data for TopAgent diff --git a/packages/global/core/workflow/runtime/type.ts b/packages/global/core/workflow/runtime/type.ts index 47e7bb30d2..e2b218cdec 100644 --- a/packages/global/core/workflow/runtime/type.ts +++ b/packages/global/core/workflow/runtime/type.ts @@ -82,6 +82,7 @@ export type ChatDispatchProps = { lastInteractive?: WorkflowInteractiveResponseType; // last interactive response stream: boolean; retainDatasetCite?: boolean; + showSkillReferences?: boolean; maxRunTimes: number; isToolCall?: boolean; workflowStreamResponse?: WorkflowResponseType; diff --git a/packages/global/core/workflow/template/constants.ts b/packages/global/core/workflow/template/constants.ts index 470779c9a4..aea5e62b71 100644 --- a/packages/global/core/workflow/template/constants.ts +++ b/packages/global/core/workflow/template/constants.ts @@ -12,6 +12,7 @@ import { WorkflowStart } from './system/workflowStart'; import { StopToolNode } from './system/stopTool'; import { ToolCallNode } from './system/toolCall'; +import { AgentNode } from './system/agent'; import { RunAppModule } from './system/abandoned/runApp/index'; import { PluginInputModule } from './system/pluginInput'; @@ -48,6 +49,7 @@ const systemNodes: FlowNodeTemplateType[] = [ ToolCallNode, ToolParamsNode, StopToolNode, + AgentNode, ReadFilesNode, HttpNode468, AiQueryExtension, diff --git a/packages/global/core/workflow/template/system/agent/index.ts b/packages/global/core/workflow/template/system/agent/index.ts index 39d183afbd..379798143d 100644 --- a/packages/global/core/workflow/template/system/agent/index.ts +++ b/packages/global/core/workflow/template/system/agent/index.ts @@ -1,12 +1,25 @@ -import { FlowNodeTypeEnum } from '../../../node/constant'; -import { type FlowNodeTemplateType } from '../../../type/node'; -import { FlowNodeTemplateTypeEnum } from '../../../constants'; import { - Input_Template_History, + datasetSelectValueDesc, + FlowNodeInputTypeEnum, + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '../../../node/constant'; +import { type FlowNodeTemplateType } from '../../../type/node'; +import { + WorkflowIOValueTypeEnum, + NodeOutputKeyEnum, + FlowNodeTemplateTypeEnum, + NodeInputKeyEnum +} from '../../../constants'; +import { + Input_Template_SettingAiModel, Input_Template_System_Prompt, Input_Template_UserChatInput } from '../../input'; +import { chatNodeSystemPromptTip, systemPromptTip } from '../../tip'; import { i18nT } from '../../../../../../web/i18n/utils'; +import { Input_Template_File_Link } from '../../input'; +import { Output_Template_Error_Message } from '../../output'; import { DatasetSearchModeEnum } from '../../../../dataset/constants'; export const AgentNode: FlowNodeTemplateType = { @@ -15,13 +28,194 @@ export const AgentNode: FlowNodeTemplateType = { templateType: FlowNodeTemplateTypeEnum.ai, showSourceHandle: true, showTargetHandle: true, - avatar: 'core/app/type/agentFill', - name: 'Agent', - intro: '', + avatar: 'core/workflow/template/agent', + avatarLinear: 'core/workflow/template/agentLinear', + colorSchema: 'emerald', + name: i18nT('workflow:template.agent_module'), + intro: i18nT('workflow:template.agent_module_intro'), showStatus: true, - isTool: true, - version: '4.16.0', catchError: false, - inputs: [], - outputs: [] + version: '4.17.0', + inputs: [ + Input_Template_SettingAiModel, + { + key: NodeInputKeyEnum.aiChatTemperature, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.number + }, + { + key: NodeInputKeyEnum.aiChatMaxToken, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.number + }, + { + key: NodeInputKeyEnum.aiChatIsResponseText, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + value: true, + valueType: WorkflowIOValueTypeEnum.boolean + }, + { + key: NodeInputKeyEnum.aiChatVision, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.boolean, + value: true + }, + { + key: NodeInputKeyEnum.aiChatReasoning, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.boolean, + value: true + }, + { + key: NodeInputKeyEnum.aiChatTopP, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.number + }, + { + key: NodeInputKeyEnum.aiChatStopSign, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.aiChatResponseFormat, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.aiChatJsonSchema, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string + }, + + { + ...Input_Template_System_Prompt, + label: i18nT('common:core.ai.Prompt'), + description: systemPromptTip, + placeholder: chatNodeSystemPromptTip + }, + Input_Template_File_Link, + Input_Template_UserChatInput, + // Skill + { + key: NodeInputKeyEnum.skills, + renderTypeList: [FlowNodeInputTypeEnum.selectSkill, FlowNodeInputTypeEnum.reference], + label: 'Skill', + valueType: WorkflowIOValueTypeEnum.arrayObject, + valueDesc: '{\n skillId:string;\n}[]', + value: [] + }, + // Tool + { + key: NodeInputKeyEnum.selectedTools, + renderTypeList: [FlowNodeInputTypeEnum.selectTool, FlowNodeInputTypeEnum.reference], + label: i18nT('workflow:agent.tools'), + valueType: WorkflowIOValueTypeEnum.arrayObject, + valueDesc: '{\n toolId:string;\n}[]', + value: [] + }, + // Dataset + { + key: NodeInputKeyEnum.datasetSelectList, + renderTypeList: [FlowNodeInputTypeEnum.selectDataset, FlowNodeInputTypeEnum.reference], + label: i18nT('common:core.module.input.label.Select dataset'), + value: [], + valueType: WorkflowIOValueTypeEnum.selectDataset, + valueDesc: datasetSelectValueDesc + }, + { + key: NodeInputKeyEnum.datasetSimilarity, + renderTypeList: [FlowNodeInputTypeEnum.selectDatasetParamsModal], + label: '', + value: 0.4, + valueType: WorkflowIOValueTypeEnum.number + }, + { + key: NodeInputKeyEnum.datasetMaxTokens, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + value: 5000, + valueType: WorkflowIOValueTypeEnum.number + }, + { + key: NodeInputKeyEnum.datasetSearchMode, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string, + value: DatasetSearchModeEnum.embedding + }, + { + key: NodeInputKeyEnum.datasetSearchEmbeddingWeight, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.number, + value: 0.5 + }, + { + key: NodeInputKeyEnum.datasetSearchUsingReRank, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.boolean, + value: false + }, + { + key: NodeInputKeyEnum.datasetSearchRerankModel, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.datasetSearchRerankWeight, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.number, + value: 0.5 + }, + { + key: NodeInputKeyEnum.datasetSearchUsingExtensionQuery, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.boolean, + value: true + }, + { + key: NodeInputKeyEnum.datasetSearchExtensionModel, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string + }, + { + key: NodeInputKeyEnum.datasetSearchExtensionBg, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.string, + value: '' + }, + { + key: NodeInputKeyEnum.authTmbId, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.boolean, + value: false + } + ], + outputs: [ + { + id: NodeOutputKeyEnum.answerText, + key: NodeOutputKeyEnum.answerText, + label: i18nT('common:core.module.output.label.Ai response content'), + description: i18nT('common:core.module.output.description.Ai response content'), + valueType: WorkflowIOValueTypeEnum.string, + type: FlowNodeOutputTypeEnum.static + }, + Output_Template_Error_Message + ] }; diff --git a/packages/global/openapi/core/agentSkills/api.ts b/packages/global/openapi/core/agentSkills/api.ts new file mode 100644 index 0000000000..f1b55c54e7 --- /dev/null +++ b/packages/global/openapi/core/agentSkills/api.ts @@ -0,0 +1,394 @@ +import { z } from 'zod'; +import { + AgentSkillCategorySchema, + AgentSkillListItemSchema, + AgentSkillSourceSchema, + AgentSkillStorageSchema, + AgentSkillTypeSchema, + AgentSkillConfigSchema, + ExtractedSkillPackageSchema, + SandboxImageConfigSchema, + SandboxProviderStatusSchema, + SkillPackageSchema, + SkillSandboxEndpointSchema, + ZipEntryInfoSchema +} from '../../../core/agentSkills/type'; + +const IdSchema = z.string().min(1).meta({ description: '资源 ID' }); +const NullableParentIdSchema = z.string().nullable().optional().meta({ + description: '父级目录 ID' +}); +const LooseObjectSchema = z.object({}).catchall(z.any()); + +export const ListSkillsQuerySchema = z.object({ + source: z.enum(['store', 'mine']).optional().describe('技能来源: store=系统技能, mine=我的技能'), + searchKey: z.string().optional().describe('搜索关键词'), + category: AgentSkillCategorySchema.optional().describe('技能分类'), + type: AgentSkillTypeSchema.optional().describe('技能类型过滤'), + parentId: NullableParentIdSchema, + page: z.coerce.number().int().positive().optional().describe('页码'), + pageSize: z.coerce.number().int().positive().optional().describe('每页数量') +}); +export type ListSkillsQuery = z.infer; + +export const ListSkillsResponseItemSchema = AgentSkillListItemSchema.omit({ + createTime: true, + updateTime: true +}).extend({ + source: AgentSkillSourceSchema, + type: AgentSkillTypeSchema, + createTime: z.string(), + updateTime: z.string(), + permission: z.number().optional(), + sourceMember: z + .object({ + name: z.string(), + avatar: z.string().nullable().optional(), + status: z.string() + }) + .optional() +}); + +export const ListSkillsResponseSchema = z.object({ + list: z.array(ListSkillsResponseItemSchema), + total: z.number() +}); +export type ListSkillsResponse = z.infer; + +export const CreateSkillBodySchema = z.object({ + parentId: NullableParentIdSchema, + name: z.string().describe('技能名称'), + description: z.string().optional().describe('技能描述'), + requirements: z.string().optional().describe('用于 AI 生成技能的需求描述'), + model: z.string().optional().describe('生成技能时使用的模型'), + category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'), + config: AgentSkillConfigSchema.optional().describe('技能配置'), + avatar: z.string().optional().describe('技能头像') +}); +export type CreateSkillBody = z.infer; + +export const CreateSkillResponseSchema = IdSchema; +export type CreateSkillResponse = z.infer; + +export const UpdateSkillBodySchema = z.object({ + skillId: IdSchema, + name: z.string().optional(), + description: z.string().optional(), + category: z.array(AgentSkillCategorySchema).optional(), + config: AgentSkillConfigSchema.optional(), + avatar: z.string().optional(), + parentId: z + .string() + .nullable() + .optional() + .describe('移动到指定文件夹,null 表示根目录,undefined 表示不移动') +}); +export type UpdateSkillBody = z.infer; + +export const UpdateSkillResponseSchema = z.void(); +export type UpdateSkillResponse = z.infer; + +export const CopySkillBodySchema = z.object({ + skillId: IdSchema +}); +export type CopySkillBody = z.infer; + +export const CopySkillResponseSchema = z.object({ + skillId: z.string() +}); +export type CopySkillResponse = z.infer; + +export const DeleteSkillQuerySchema = z.object({ + skillId: IdSchema +}); +export type DeleteSkillQuery = z.infer; + +export const DeleteSkillResponseSchema = z.void(); +export type DeleteSkillResponse = z.infer; + +export const GetSkillDetailQuerySchema = z.object({ + skillId: IdSchema +}); +export type GetSkillDetailQuery = z.infer; + +export const GetSkillDetailResponseSchema = z.object({ + _id: z.string(), + source: AgentSkillSourceSchema, + type: AgentSkillTypeSchema.optional(), + parentId: z.string().nullable().optional(), + inheritPermission: z.boolean().optional(), + name: z.string(), + description: z.string(), + author: z.string(), + category: z.array(AgentSkillCategorySchema), + config: AgentSkillConfigSchema, + avatar: z.string().optional(), + teamId: z.string().optional(), + tmbId: z.string().optional(), + createTime: z.string(), + updateTime: z.string(), + permission: z.any().optional(), + appCount: z.number().optional() +}); +export type GetSkillDetailResponse = z.infer; + +export const ImportSkillBodySchema = z.object({ + parentId: z.string().nullable().optional().describe('导入的目标目录 ID'), + name: z.string().optional().describe('导入后的技能名称'), + description: z.string().optional().describe('导入后的技能描述'), + avatar: z.string().optional().describe('导入后的技能头像') +}); +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 +}); +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({ + state: true, + message: true + }), + createTime: z.string() +}); +export type GetSandboxInfoResponse = z.infer; + +export const DeleteSandboxBodySchema = z.object({ + sandboxId: IdSchema +}); +export type DeleteSandboxBody = z.infer; + +export const DeleteSandboxResponseSchema = z.void(); +export type DeleteSandboxResponse = z.infer; + +export const SaveDeploySkillBodySchema = z.object({ + skillId: IdSchema, + versionName: z.string().optional(), + description: z.string().optional() +}); +export type SaveDeploySkillBody = z.infer; + +export const SaveDeploySkillResponseSchema = z.object({ + skillId: z.string(), + version: z.number(), + versionName: z.string(), + storage: AgentSkillStorageSchema, + createdAt: z.string() +}); +export type SaveDeploySkillResponse = z.infer; + +export { ExtractedSkillPackageSchema, SkillPackageSchema, ZipEntryInfoSchema }; +export type { + ExtractedSkillPackage, + SkillPackageType, + ZipEntryInfo +} from '../../../core/agentSkills/type'; + +export const SkillDebugChatBodySchema = z.object({ + skillId: IdSchema, + chatId: z.string(), + responseChatItemId: z.string().optional(), + messages: z.array(LooseObjectSchema), + model: z.string().optional(), + systemPrompt: z.string().optional() +}); +export type SkillDebugChatBody = z.infer; + +export const SkillDebugSessionListQuerySchema = z.object({ + skillId: IdSchema, + pageNum: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().optional() +}); +export type SkillDebugSessionListQuery = z.infer; + +export const SkillDebugSessionListResponseSchema = z.object({ + list: z.array( + z.object({ + chatId: z.string(), + title: z.string(), + updateTime: z.string() + }) + ), + total: z.number() +}); +export type SkillDebugSessionListResponse = z.infer; + +export const SkillDebugSessionDeleteBodySchema = z.object({ + skillId: IdSchema, + chatId: z.string() +}); +export type SkillDebugSessionDeleteBody = z.infer; + +export const ListAppsBySkillIdQuerySchema = z.object({ + skillId: IdSchema +}); +export type ListAppsBySkillIdQuery = z.infer; + +export const AppsBySkillIdItemSchema = z.object({ + _id: z.string(), + name: z.string(), + avatar: z.string(), + intro: z.string(), + tmbId: z.string(), + type: z.string(), + updateTime: z.date(), + sourceMember: z.object({ + name: z.string(), + avatar: z.string().nullable().optional(), + status: z.string() + }) +}); +export type AppsBySkillIdItem = z.infer; + +export const ListAppsBySkillIdResponseSchema = z.array(AppsBySkillIdItemSchema); +export type ListAppsBySkillIdResponse = z.infer; + +export const CreateSkillFolderBodySchema = z.object({ + parentId: NullableParentIdSchema, + name: z.string(), + description: z.string().optional() +}); +export type CreateSkillFolderBody = z.infer; + +export const CreateSkillFolderResponseSchema = z.object({ + folderId: z.string() +}); +export type CreateSkillFolderResponse = z.infer; + +export const GetSkillFolderPathQuerySchema = z.object({ + sourceId: z.string().optional(), + type: z.enum(['current', 'parent']) +}); +export type GetSkillFolderPathQuery = z.infer; + +export const GetSkillFolderPathResponseSchema = z.array( + z.object({ + parentId: z.string().nullable(), + parentName: z.string() + }) +); +export type GetSkillFolderPathResponse = z.infer; + +export const ExportSkillQuerySchema = z.object({ + skillId: IdSchema +}); +export type ExportSkillQuery = z.infer; + +export const SkillDebugDeleteChatItemBodySchema = z.object({ + skillId: IdSchema, + chatId: z.string(), + contentId: z.string() +}); +export type SkillDebugDeleteChatItemBody = z.infer; + +export const SkillDebugRecordsBodySchema = z.object({ + skillId: IdSchema, + chatId: z.string(), + pageSize: z.coerce.number().int().positive().optional(), + initialId: z.string().optional(), + nextId: z.string().optional(), + prevId: z.string().optional() +}); +export type SkillDebugRecordsBody = z.infer; + +export const SkillDebugRecordsResponseSchema = z.object({ + list: z.array(z.any()), + total: z.number(), + hasMorePrev: z.boolean(), + hasMoreNext: z.boolean() +}); +export type SkillDebugRecordsResponse = z.infer; + +export const ListSkillVersionsBodySchema = z.object({ + skillId: IdSchema, + pageNum: z.number().int().positive().optional().describe('页码,从 1 开始'), + pageSize: z.number().int().positive().describe('每页数量'), + isActive: z.boolean().optional().describe('筛选是否为活跃版本') +}); +export type ListSkillVersionsBody = z.infer; + +export const SkillVersionListItemSchema = z.object({ + _id: z.string(), + skillId: z.string(), + tmbId: z.string(), + version: z.number(), + versionName: z.string().optional(), + isActive: z.boolean(), + createdAt: z.string() +}); +export type SkillVersionListItemType = z.infer; + +export const ListSkillVersionsResponseSchema = z.object({ + list: z.array(SkillVersionListItemSchema), + total: z.number() +}); +export type ListSkillVersionsResponse = z.infer; + +export const UpdateSkillVersionBodySchema = z.object({ + skillId: IdSchema, + versionId: IdSchema, + versionName: z.string().describe('版本名称') +}); +export type UpdateSkillVersionBody = z.infer; + +export const UpdateSkillVersionResponseSchema = z.void(); +export type UpdateSkillVersionResponse = z.infer; + +export const SwitchSkillVersionBodySchema = z.object({ + skillId: IdSchema, + versionId: IdSchema +}); +export type SwitchSkillVersionBody = z.infer; + +export const SwitchSkillVersionResponseSchema = z.void(); +export type SwitchSkillVersionResponse = z.infer; + +export const ImportSkillMultipartRequestSchema = { + type: 'object' as const, + properties: { + file: { + type: 'string' as const, + format: 'binary' as const, + description: '技能压缩包文件,支持 ZIP / TAR / TAR.GZ' + }, + name: { + type: 'string' as const, + description: '导入后的技能名称,可选' + }, + description: { + type: 'string' as const, + description: '导入后的技能描述,可选' + }, + avatar: { + type: 'string' as const, + description: '导入后的技能头像,可选' + } + }, + required: ['file'] as string[] +}; diff --git a/packages/global/openapi/core/agentSkills/index.ts b/packages/global/openapi/core/agentSkills/index.ts new file mode 100644 index 0000000000..b87ffc7063 --- /dev/null +++ b/packages/global/openapi/core/agentSkills/index.ts @@ -0,0 +1,450 @@ +import type { OpenAPIPath } from '../../type'; +import { TagsMap } from '../../tag'; +import { + AppsBySkillIdItemSchema, + CreateEditDebugSandboxBodySchema, + CreateEditDebugSandboxResponseSchema, + CreateSkillBodySchema, + CreateSkillFolderBodySchema, + CreateSkillFolderResponseSchema, + CreateSkillResponseSchema, + DeleteSkillQuerySchema, + ExportSkillQuerySchema, + GetSkillDetailQuerySchema, + GetSkillDetailResponseSchema, + GetSkillFolderPathQuerySchema, + GetSkillFolderPathResponseSchema, + ImportSkillMultipartRequestSchema, + ImportSkillResponseSchema, + ListAppsBySkillIdQuerySchema, + ListSkillVersionsBodySchema, + ListSkillVersionsResponseSchema, + ListSkillsQuerySchema, + ListSkillsResponseSchema, + SaveDeploySkillBodySchema, + SaveDeploySkillResponseSchema, + SkillDebugChatBodySchema, + SkillDebugRecordsBodySchema, + SkillDebugRecordsResponseSchema, + SkillDebugSessionDeleteBodySchema, + SkillDebugSessionListQuerySchema, + SkillDebugSessionListResponseSchema, + SwitchSkillVersionBodySchema, + UpdateSkillBodySchema, + UpdateSkillVersionBodySchema +} from './api'; + +export const AgentSkillsPath: OpenAPIPath = { + '/core/agentSkills/list': { + post: { + summary: '获取技能列表', + description: '分页获取当前团队可见的系统技能或个人技能', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: ListSkillsQuerySchema + } + } + }, + responses: { + 200: { + description: '成功返回技能列表', + content: { + 'application/json': { + schema: ListSkillsResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/detail': { + get: { + summary: '获取技能详情', + description: '根据 skillId 获取技能详情', + tags: [TagsMap.aiSkill], + requestParams: { + query: GetSkillDetailQuerySchema + }, + responses: { + 200: { + description: '成功返回技能详情', + content: { + 'application/json': { + schema: GetSkillDetailResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/create': { + post: { + summary: '创建技能', + description: '创建一个新的技能,可选使用 AI 根据 requirements 生成 SKILL.md', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: CreateSkillBodySchema + } + } + }, + responses: { + 200: { + description: '成功创建技能', + content: { + 'application/json': { + schema: CreateSkillResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/update': { + post: { + summary: '更新技能', + description: '更新技能名称、描述、分类和配置', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: UpdateSkillBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新技能' + } + } + } + }, + '/core/agentSkills/delete': { + delete: { + summary: '删除技能', + description: '根据 skillId 删除技能', + tags: [TagsMap.aiSkill], + requestParams: { + query: DeleteSkillQuerySchema + }, + responses: { + 200: { + description: '成功删除技能' + } + } + } + }, + '/core/agentSkills/import': { + post: { + summary: '导入技能', + description: '上传 ZIP / TAR / TAR.GZ 技能压缩包并导入为技能', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'multipart/form-data': { + schema: ImportSkillMultipartRequestSchema + } + } + }, + responses: { + 200: { + description: '成功导入技能', + content: { + 'application/json': { + schema: ImportSkillResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/export': { + get: { + summary: '导出技能', + description: '下载技能 ZIP 包', + tags: [TagsMap.aiSkill], + requestParams: { + query: ExportSkillQuerySchema + }, + responses: { + 200: { + description: '返回技能 zip 文件', + content: { + 'application/zip': { + schema: { + type: 'string', + format: 'binary' + } + } + } + } + } + } + }, + '/core/agentSkills/apps': { + get: { + summary: '查询引用技能的应用', + description: '查询使用指定 skillId 的应用列表', + tags: [TagsMap.aiSkill], + requestParams: { + query: ListAppsBySkillIdQuerySchema + }, + responses: { + 200: { + description: '成功返回引用应用列表', + content: { + 'application/json': { + schema: AppsBySkillIdItemSchema.array() + } + } + } + } + } + }, + '/core/agentSkills/folder/create': { + post: { + summary: '创建技能文件夹', + description: '在技能目录树中创建一个文件夹', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: CreateSkillFolderBodySchema + } + } + }, + responses: { + 200: { + description: '成功创建文件夹', + content: { + 'application/json': { + schema: CreateSkillFolderResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/folder/path': { + get: { + summary: '获取技能文件夹路径', + description: '根据当前 skillId 返回目录路径', + tags: [TagsMap.aiSkill], + requestParams: { + query: GetSkillFolderPathQuerySchema + }, + responses: { + 200: { + description: '成功返回目录路径', + content: { + 'application/json': { + schema: GetSkillFolderPathResponseSchema + } + } + } + } + } + }, + '/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 沙盒打包当前技能并创建新版本', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: SaveDeploySkillBodySchema + } + } + }, + responses: { + 200: { + description: '成功保存并发布技能', + content: { + 'application/json': { + schema: SaveDeploySkillResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/debugChat': { + post: { + summary: '技能调试对话', + description: '基于 edit-debug 沙盒发起技能调试对话,返回 SSE 流', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: SkillDebugChatBodySchema + } + } + }, + responses: { + 200: { + description: '返回 text/event-stream 调试事件流', + content: { + 'text/event-stream': { + schema: { + type: 'string' + } + } + } + } + } + } + }, + '/core/agentSkills/debugSession/list': { + get: { + summary: '获取技能调试会话列表', + description: '分页获取技能的调试会话', + tags: [TagsMap.aiSkill], + requestParams: { + query: SkillDebugSessionListQuerySchema + }, + responses: { + 200: { + description: '成功返回调试会话列表', + content: { + 'application/json': { + schema: SkillDebugSessionListResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/debugSession/delete': { + post: { + summary: '删除技能调试会话', + description: '软删除一个技能调试会话', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: SkillDebugSessionDeleteBodySchema + } + } + }, + responses: { + 200: { + description: '成功删除调试会话' + } + } + } + }, + '/core/agentSkills/debugSession/records': { + post: { + summary: '获取技能调试会话记录', + description: '分页获取技能调试会话的聊天记录', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: SkillDebugRecordsBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回调试记录', + content: { + 'application/json': { + schema: SkillDebugRecordsResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/version/list': { + post: { + summary: '获取技能版本列表', + description: '分页获取指定技能的版本列表,按版本号倒序排列', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: ListSkillVersionsBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回版本列表', + content: { + 'application/json': { + schema: ListSkillVersionsResponseSchema + } + } + } + } + } + }, + '/core/agentSkills/version/update': { + post: { + summary: '更新技能版本名称', + description: '更新指定技能版本的名称', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: UpdateSkillVersionBodySchema + } + } + }, + responses: { + 200: { + description: '成功更新版本名称' + } + } + } + }, + '/core/agentSkills/version/switch': { + post: { + summary: '切换技能活跃版本', + description: '将指定版本设置为活跃版本,同时取消其他版本的活跃状态', + tags: [TagsMap.aiSkill], + requestBody: { + content: { + 'application/json': { + schema: SwitchSkillVersionBodySchema + } + } + }, + responses: { + 200: { + description: '成功切换活跃版本' + } + } + } + } +}; diff --git a/packages/global/openapi/index.ts b/packages/global/openapi/index.ts index acf0fd04ff..245e162516 100644 --- a/packages/global/openapi/index.ts +++ b/packages/global/openapi/index.ts @@ -6,6 +6,7 @@ import { AppPath } from './core/app'; import { SupportPath } from './support'; import { DatasetPath } from './core/dataset'; import { AIPath } from './core/ai'; +import { AgentSkillsPath } from './core/agentSkills'; export const openAPIDocument = createDocument({ openapi: '3.1.0', @@ -20,7 +21,8 @@ export const openAPIDocument = createDocument({ ...DatasetPath, ...PluginPath, ...SupportPath, - ...AIPath + ...AIPath, + ...AgentSkillsPath }, servers: [{ url: '/api' }], 'x-tagGroups': [ diff --git a/packages/global/support/outLink/type.ts b/packages/global/support/outLink/type.ts index 58133ae5ce..693c83ec4b 100644 --- a/packages/global/support/outLink/type.ts +++ b/packages/global/support/outLink/type.ts @@ -73,6 +73,8 @@ export type OutLinkSchema = { showCite: boolean; // whether to show the running status showRunningStatus: boolean; + // whether to show skill reference logs + showSkillReferences: boolean; // whether to show the full text reader showFullText: boolean; // whether can download source @@ -108,6 +110,7 @@ export type OutLinkEditType = { name: string; showCite?: OutLinkSchema['showCite']; showRunningStatus?: OutLinkSchema['showRunningStatus']; + showSkillReferences?: OutLinkSchema['showSkillReferences']; showFullText?: OutLinkSchema['showFullText']; canDownloadSource?: OutLinkSchema['canDownloadSource']; // response when request @@ -122,10 +125,11 @@ export type OutLinkEditType = { export const PlaygroundVisibilityConfigSchema = z.object({ showRunningStatus: z.boolean(), - showCite: z.boolean(), - showFullText: z.boolean(), - canDownloadSource: z.boolean(), - showWholeResponse: z.boolean() + showSkillReferences: z.boolean().optional().default(true), + showCite: z.boolean().optional().default(true), + showFullText: z.boolean().optional().default(true), + canDownloadSource: z.boolean().optional().default(true), + showWholeResponse: z.boolean().optional().default(true) }); export type PlaygroundVisibilityConfigType = z.infer; diff --git a/packages/global/support/permission/agentSkill/constant.ts b/packages/global/support/permission/agentSkill/constant.ts new file mode 100644 index 0000000000..c546bc8bf9 --- /dev/null +++ b/packages/global/support/permission/agentSkill/constant.ts @@ -0,0 +1,31 @@ +import { i18nT } from '../../../../web/i18n/utils'; +import { + NullRoleVal, + CommonPerKeyEnum, + CommonRoleList, + CommonRolePerMap, + CommonPerList +} from '../constant'; +import type { RolePerMapType } from '../type'; +import type { RoleListType } from '../type'; + +export const SkillRoleList: RoleListType = { + [CommonPerKeyEnum.read]: { + ...CommonRoleList[CommonPerKeyEnum.read], + description: i18nT('skill:permission.des.read') + }, + [CommonPerKeyEnum.write]: { + ...CommonRoleList[CommonPerKeyEnum.write], + description: i18nT('skill:permission.des.write') + }, + [CommonPerKeyEnum.manage]: { + ...CommonRoleList[CommonPerKeyEnum.manage], + description: i18nT('skill:permission.des.manage') + } +}; + +export const SkillRolePerMap: RolePerMapType = CommonRolePerMap; + +export const SkillPerList = CommonPerList; + +export const SkillDefaultRoleVal = NullRoleVal; diff --git a/packages/global/support/permission/agentSkill/controller.ts b/packages/global/support/permission/agentSkill/controller.ts new file mode 100644 index 0000000000..05f67d74d7 --- /dev/null +++ b/packages/global/support/permission/agentSkill/controller.ts @@ -0,0 +1,16 @@ +import { type PerConstructPros, Permission } from '../controller'; +import { SkillDefaultRoleVal, SkillPerList, SkillRoleList, SkillRolePerMap } from './constant'; + +export class SkillPermission extends Permission { + constructor(props?: PerConstructPros) { + if (!props) { + props = { role: SkillDefaultRoleVal }; + } else if (!props?.role) { + props.role = SkillDefaultRoleVal; + } + props.roleList = SkillRoleList; + props.rolePerMap = SkillRolePerMap; + props.perList = SkillPerList; + super(props); + } +} diff --git a/packages/global/support/permission/constant.ts b/packages/global/support/permission/constant.ts index d8c173bb82..51837832aa 100644 --- a/packages/global/support/permission/constant.ts +++ b/packages/global/support/permission/constant.ts @@ -50,7 +50,8 @@ export enum PerResourceTypeEnum { team = 'team', app = 'app', dataset = 'dataset', - model = 'model' + model = 'model', + agentSkill = 'agentSkill' } /* new permission */ diff --git a/packages/global/support/permission/user/constant.ts b/packages/global/support/permission/user/constant.ts index 890f50d796..bea6ce1a69 100644 --- a/packages/global/support/permission/user/constant.ts +++ b/packages/global/support/permission/user/constant.ts @@ -12,20 +12,23 @@ import { sumPer } from '../utils'; export enum TeamPerKeyEnum { appCreate = 'appCreate', datasetCreate = 'datasetCreate', - apikeyCreate = 'apikeyCreate' + apikeyCreate = 'apikeyCreate', + skillCreate = 'skillCreate' } export enum TeamRoleKeyEnum { appCreate = 'appCreate', datasetCreate = 'datasetCreate', - apikeyCreate = 'apikeyCreate' + apikeyCreate = 'apikeyCreate', + skillCreate = 'skillCreate' } export const TeamPerList: PermissionListType = { ...CommonPerList, apikeyCreate: 0b100000, appCreate: 0b001000, - datasetCreate: 0b010000 + datasetCreate: 0b010000, + skillCreate: 0b1000000 }; export const TeamRoleList: RoleListType = { @@ -60,6 +63,12 @@ export const TeamRoleList: RoleListType = { description: '', name: i18nT('account_team:permission_apikeyCreate'), value: 0b100000 + }, + [TeamRoleKeyEnum.skillCreate]: { + checkBoxType: 'multiple', + description: '', + name: i18nT('account_team:permission_skillCreate'), + value: 0b1000000 } }; @@ -80,6 +89,10 @@ export const TeamRolePerMap: RolePerMapType = new Map([ [ TeamRoleList['apikeyCreate'].value, sumPer(TeamPerList.apikeyCreate, CommonPerList.read, CommonPerList.write) as PermissionValueType + ], + [ + TeamRoleList['skillCreate'].value, + sumPer(TeamPerList.skillCreate, CommonPerList.read, CommonPerList.write) as PermissionValueType ] ]); @@ -89,6 +102,7 @@ export const TeamManageRoleVal = TeamRoleList['manage'].value; export const TeamAppCreateRoleVal = TeamRoleList['appCreate'].value; export const TeamDatasetCreateRoleVal = TeamRoleList['datasetCreate'].value; export const TeamApikeyCreateRoleVal = TeamRoleList['apikeyCreate'].value; +export const TeamSkillCreateRoleVal = TeamRoleList['skillCreate'].value; export const TeamDefaultRoleVal = TeamReadRoleVal; export const TeamReadPermissionVal = TeamPerList.read; @@ -97,4 +111,5 @@ export const TeamManagePermissionVal = TeamPerList.manage; export const TeamAppCreatePermissionVal = TeamPerList.appCreate; export const TeamDatasetCreatePermissionVal = TeamPerList.datasetCreate; export const TeamApikeyCreatePermissionVal = TeamPerList.apikeyCreate; +export const TeamSkillCreatePermissionVal = TeamPerList.skillCreate; export const TeamDefaultPermissionVal = TeamReadPermissionVal; diff --git a/packages/global/support/permission/user/controller.ts b/packages/global/support/permission/user/controller.ts index b92f98072d..3579305939 100644 --- a/packages/global/support/permission/user/controller.ts +++ b/packages/global/support/permission/user/controller.ts @@ -3,6 +3,7 @@ import { TeamApikeyCreateRoleVal, TeamAppCreateRoleVal, TeamDatasetCreateRoleVal, + TeamSkillCreateRoleVal, TeamDefaultRoleVal, TeamPerList, TeamRoleList, @@ -13,9 +14,11 @@ export class TeamPermission extends Permission { hasAppCreateRole: boolean = false; hasDatasetCreateRole: boolean = false; hasApikeyCreateRole: boolean = false; + hasSkillCreateRole: boolean = false; hasAppCreatePer: boolean = false; hasDatasetCreatePer: boolean = false; hasApikeyCreatePer: boolean = false; + hasSkillCreatePer: boolean = false; constructor(props?: PerConstructPros) { if (!props) { @@ -34,9 +37,11 @@ export class TeamPermission extends Permission { this.hasAppCreateRole = this.checkRole(TeamAppCreateRoleVal); this.hasDatasetCreateRole = this.checkRole(TeamDatasetCreateRoleVal); this.hasApikeyCreateRole = this.checkRole(TeamApikeyCreateRoleVal); + this.hasSkillCreateRole = this.checkRole(TeamSkillCreateRoleVal); this.hasAppCreatePer = this.checkPer(TeamAppCreateRoleVal); this.hasDatasetCreatePer = this.checkPer(TeamDatasetCreateRoleVal); this.hasApikeyCreatePer = this.checkPer(TeamApikeyCreateRoleVal); + this.hasSkillCreatePer = this.checkPer(TeamSkillCreateRoleVal); }); } } diff --git a/packages/global/support/user/audit/constants.ts b/packages/global/support/user/audit/constants.ts index 1c05b22b82..e631186655 100644 --- a/packages/global/support/user/audit/constants.ts +++ b/packages/global/support/user/audit/constants.ts @@ -93,7 +93,20 @@ export enum AuditEventEnum { SET_INVOICE_HEADER = 'SET_INVOICE_HEADER', CREATE_API_KEY = 'CREATE_API_KEY', UPDATE_API_KEY = 'UPDATE_API_KEY', - DELETE_API_KEY = 'DELETE_API_KEY' + DELETE_API_KEY = 'DELETE_API_KEY', + //Agent Skills + CREATE_SKILL = 'CREATE_SKILL', + UPDATE_SKILL = 'UPDATE_SKILL', + DEPLOY_SKILL = 'DEPLOY_SKILL', + DELETE_SKILL = 'DELETE_SKILL', + IMPORT_SKILL = 'IMPORT_SKILL', + CREATE_SKILL_FOLDER = 'CREATE_SKILL_FOLDER', + EXPORT_SKILL = 'EXPORT_SKILL', + COPY_SKILL = 'COPY_SKILL', + MOVE_SKILL = 'MOVE_SKILL', + UPDATE_SKILL_COLLABORATOR = 'UPDATE_SKILL_COLLABORATOR', + DELETE_SKILL_COLLABORATOR = 'DELETE_SKILL_COLLABORATOR', + TRANSFER_SKILL_OWNERSHIP = 'TRANSFER_SKILL_OWNERSHIP' } export type AuditEventParamsType = { diff --git a/packages/global/support/wallet/usage/constants.ts b/packages/global/support/wallet/usage/constants.ts index 8581363d5e..fd7e15d49a 100644 --- a/packages/global/support/wallet/usage/constants.ts +++ b/packages/global/support/wallet/usage/constants.ts @@ -16,7 +16,8 @@ export enum UsageSourceEnum { mcp = 'mcp', evaluation = 'evaluation', optimize_prompt = 'optimize_prompt', - code_copilot = 'code_copilot' + code_copilot = 'code_copilot', + assist_generate_skill = 'assist_generate_skill' } export const UsageSourceMap = { @@ -67,6 +68,9 @@ export const UsageSourceMap = { }, [UsageSourceEnum.code_copilot]: { label: i18nT('common:support.wallet.usage.Code Copilot') + }, + [UsageSourceEnum.assist_generate_skill]: { + label: i18nT('common:support.wallet.usage.Assist Generate Skill') } }; diff --git a/packages/service/common/logger/categories.ts b/packages/service/common/logger/categories.ts index aa7d15d207..41111b6b30 100644 --- a/packages/service/common/logger/categories.ts +++ b/packages/service/common/logger/categories.ts @@ -78,6 +78,10 @@ export const LogCategories = { RERANK: ['ai', 'rerank'], SANDBOX: ['ai', 'sandbox'] }), + AGENT_SKILLS: Object.assign(['agent-skills'], { + CREATION: ['agent-skills', 'create-skill'], + EXPORT: ['agent-skills', 'export-skill'] + }), USER: Object.assign(['user'], { ACCOUNT: ['user', 'account'], TEAM: ['user', 'team'] diff --git a/packages/service/common/mongo/index.ts b/packages/service/common/mongo/index.ts index fd8d7c1814..954d569c4c 100644 --- a/packages/service/common/mongo/index.ts +++ b/packages/service/common/mongo/index.ts @@ -115,12 +115,12 @@ const addCommonMiddleware = (schema: mongoose.Schema) => { return schema; }; -export const getMongoModel = (name: string, schema: mongoose.Schema) => { +export const getMongoModel = (name: string, schema: mongoose.Schema): Model => { if (connectionMongo.models[name]) return connectionMongo.models[name] as Model; if (!isTestEnv) logger.debug('Loading MongoDB model', { modelName: name }); addCommonMiddleware(schema); - const model = connectionMongo.model(name, schema); + const model = connectionMongo.model(name, schema) as Model; // Sync index syncMongoIndex(model); @@ -128,11 +128,11 @@ export const getMongoModel = (name: string, schema: mongoose.Schema) => { return model; }; -export const getMongoLogModel = (name: string, schema: mongoose.Schema) => { +export const getMongoLogModel = (name: string, schema: mongoose.Schema): Model => { if (connectionLogMongo.models[name]) return connectionLogMongo.models[name] as Model; logger.debug('Loading MongoDB log model', { modelName: name }); - const model = connectionLogMongo.model(name, schema); + const model = connectionLogMongo.model(name, schema) as Model; // Sync index syncMongoIndex(model); diff --git a/packages/service/common/s3/mq.ts b/packages/service/common/s3/mq.ts index 09b93dc29e..9388587778 100644 --- a/packages/service/common/s3/mq.ts +++ b/packages/service/common/s3/mq.ts @@ -35,7 +35,7 @@ export const addS3DelJob = async (data: S3MQJobData): Promise => { return undefined; } if (data.prefix) { - return `${data.bucketName}:${data.prefix}`; + return `${data.bucketName}-${data.prefix}`; } throw new Error('Invalid s3 delete job data'); })(); diff --git a/packages/service/core/agentSkills/archiveUtils.ts b/packages/service/core/agentSkills/archiveUtils.ts new file mode 100644 index 0000000000..24d36cdc67 --- /dev/null +++ b/packages/service/core/agentSkills/archiveUtils.ts @@ -0,0 +1,112 @@ +import decompress from 'decompress'; + +export type ArchiveFormat = 'zip' | 'tar' | 'tar.gz'; +export type ArchiveFileMap = Record; + +/** Detect supported format from filename extension. Returns null if unsupported. */ +export function getSupportedArchiveFormat(filename: string): ArchiveFormat | null { + const lower = filename.toLowerCase(); + if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'tar.gz'; + if (lower.endsWith('.tar')) return 'tar'; + if (lower.endsWith('.zip')) return 'zip'; + return null; +} + +/** + * Extract archive file (zip/tar/tar.gz) to a file map. + * Path traversal entries are filtered out for security. + * Total uncompressed size is capped to prevent Zip Bomb OOM attacks. + */ +export async function extractToFileMap( + filePath: string, + maxUncompressedBytes = 200 * 1024 * 1024 +): Promise { + const files = await decompress(filePath); + const fileMap: ArchiveFileMap = {}; + let totalSize = 0; + for (const file of files) { + if (file.type === 'directory') continue; + const normalized = file.path.replace(/\\/g, '/').replace(/^\/+/, ''); + // Filter path traversal + if (!normalized || normalized.includes('../')) continue; + totalSize += file.data.length; + if (totalSize > maxUncompressedBytes) { + throw new Error( + `Uncompressed archive exceeds maximum allowed size (${maxUncompressedBytes / 1024 / 1024}MB)` + ); + } + fileMap[normalized] = file.data; + } + return fileMap; +} + +/** Find SKILL.md key in file map (case-insensitive, root or single-level subdir). */ +export function findSkillMdKey(fileMap: ArchiveFileMap): string | null { + const paths = Object.keys(fileMap); + const rootKey = paths.find((p) => !p.includes('/') && p.toLowerCase() === 'skill.md'); + if (rootKey) return rootKey; + return ( + paths.find((p) => { + const parts = p.split('/'); + return parts.length === 2 && parts[1].toLowerCase() === 'skill.md'; + }) ?? null + ); +} + +/** Get root directory prefix from SKILL.md path (e.g. 'my-skill/' or ''). */ +export function getRootPrefix(skillMdKey: string): string { + const idx = skillMdKey.lastIndexOf('/'); + return idx === -1 ? '' : skillMdKey.slice(0, idx + 1); +} + +/** Strip root prefix from all keys in file map. */ +export function stripRootPrefix(fileMap: ArchiveFileMap, rootPrefix: string): ArchiveFileMap { + if (!rootPrefix) return fileMap; + const result: ArchiveFileMap = {}; + for (const [key, value] of Object.entries(fileMap)) { + const stripped = key.startsWith(rootPrefix) ? key.slice(rootPrefix.length) : key; + if (stripped) result[stripped] = value; + } + return result; +} + +/** SKILL.md content together with its relative path inside the archive. */ +export type SkillMdInfo = { content: string; relativePath: string }; + +/** + * Extract SKILL.md content and its relative path from a ZIP buffer without writing to disk. + * Searches the same locations as findSkillMdKey: + * - root: SKILL.md + * - one level deep: {dir}/SKILL.md + * Returns null when SKILL.md is not found. + */ +export async function extractSkillMdInfoFromBuffer(buffer: Buffer): Promise { + const files = await decompress(buffer); + + const target = files.find((f) => { + const p = f.path.replace(/\\/g, '/').replace(/^\//, ''); + const parts = p.split('/'); + if (parts.length === 1 && parts[0].toLowerCase() === 'skill.md') return true; + if (parts.length === 2 && parts[1].toLowerCase() === 'skill.md') return true; + return false; + }); + + if (!target || !target.data) return null; + const content = Buffer.isBuffer(target.data) + ? target.data.toString('utf-8') + : String(target.data); + const relativePath = target.path.replace(/\\/g, '/').replace(/^\//, ''); + return { content, relativePath }; +} + +/** + * Extract SKILL.md content from a ZIP buffer without writing to disk. + * Searches the same locations as findSkillMdKey: + * - root: SKILL.md + * - one level deep: {dir}/SKILL.md + * Returns null when SKILL.md is not found. + */ +export async function extractSkillMdContentFromBuffer(buffer: Buffer): Promise { + const info = await extractSkillMdInfoFromBuffer(buffer); + return info ? info.content : null; +} diff --git a/packages/service/core/agentSkills/controller.ts b/packages/service/core/agentSkills/controller.ts new file mode 100644 index 0000000000..d32326e090 --- /dev/null +++ b/packages/service/core/agentSkills/controller.ts @@ -0,0 +1,452 @@ +import { MongoAgentSkills } from './schema'; +import { MongoAgentSkillsVersion } from './version/schema'; +import { + AgentSkillSourceEnum, + AgentSkillTypeEnum +} from '@fastgpt/global/core/agentSkills/constants'; +import type { AgentSkillSchemaType, SkillPackageType } from '@fastgpt/global/core/agentSkills/type'; +import type { ClientSession } from '../../common/mongo'; +import { uploadSkillPackage, deleteSkillAllPackages } from './storage'; +import { removeImageByPath } from '../../common/file/image/controller'; +import { createVersion } from './version/controller'; +import { mongoSessionRun } from '../../common/mongo/sessionRun'; +import { getLogger, LogCategories } from '../../common/logger'; +import { deleteSkillRelatedSandboxes } from './sandboxController'; + +const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); + +// Types for service operations +type CreateSkillData = { + parentId?: string | null; + name: string; + description: string; + author: string; + category: string[]; + config: Record; + avatar?: string; + teamId: string; + tmbId: string; +}; + +// UpdateSkillData excludes markdown to ensure consistency with version management +// markdown updates must go through version workflow to keep package.zip in sync +type UpdateSkillData = Partial< + Pick +>; + +// ==================== CRUD Operations ==================== + +/** + * Create a new skill + */ +export async function createSkill(data: CreateSkillData, session?: ClientSession): Promise { + const skill = new MongoAgentSkills({ + ...data, + parentId: data.parentId || null, + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + currentVersion: 0, + versionCount: 0, + updateTime: new Date() + }); + await skill.save({ session }); + return skill._id.toString(); +} + +/** + * Update an existing skill + * + * Note: This function does NOT update markdown field. + * To update skill content (markdown), use version management workflow: + * - Create a new version with updated markdown + * - Generate and upload new package.zip + * - Update currentVersion and currentStorage accordingly + */ +export async function updateSkill( + skillId: string, + data: UpdateSkillData, + session?: ClientSession +): Promise { + const updateData = { + ...data, + updateTime: new Date() + }; + + await MongoAgentSkills.updateOne( + { _id: skillId, deleteTime: null }, + { $set: updateData }, + { session } + ); +} + +/** + * Update currentStorage for a skill + */ +export async function updateCurrentStorage( + skillId: string, + storageInfo: { + bucket: string; + key: string; + size: number; + }, + session?: ClientSession +): Promise { + await MongoAgentSkills.updateOne( + { _id: skillId, deleteTime: null }, + { $set: { currentStorage: storageInfo, updateTime: new Date() } }, + { session } + ); +} + +/** + * Soft delete a skill or folder (only personal skills can be deleted) + * If it's a folder, recursively deletes all children + */ +export async function deleteSkill(skillId: string, session?: ClientSession): Promise { + const skill = await MongoAgentSkills.findOne({ + _id: skillId, + deleteTime: null + }); + + if (!skill) { + throw new Error('Skill not found'); + } + + if (skill.source === AgentSkillSourceEnum.system) { + throw new Error('Cannot delete system skill'); + } + + // Find all children if it's a folder + let deleteList: AgentSkillSchemaType[]; + if (skill.type === AgentSkillTypeEnum.folder) { + deleteList = await findSkillAndAllChildren({ + teamId: skill.teamId!.toString(), + skillId + }); + } else { + deleteList = [skill]; + } + + // Batch soft delete all skill records + await MongoAgentSkills.updateMany( + { _id: { $in: deleteList.map((s) => s._id) } }, + { $set: { deleteTime: new Date() } }, + { session } + ); + + // Batch soft delete all version records + await MongoAgentSkillsVersion.updateMany( + { skillId: { $in: deleteList.map((s) => s._id) } }, + { $set: { isDeleted: true } }, + { session } + ); + + // Queue MinIO file deletion after DB changes (S3 is not transactional) + for (const item of deleteList) { + if (item.teamId && item.type !== AgentSkillTypeEnum.folder) { + deleteSkillAllPackages(item.teamId.toString(), item._id); + if (item.avatar) { + removeImageByPath(item.avatar); + } + } + } + + // Async force delete all related sandbox resources (fire-and-forget) + const nonFolderIds = deleteList + .filter((s) => s.type !== AgentSkillTypeEnum.folder) + .map((s) => s._id.toString()); + if (nonFolderIds.length > 0) { + deleteSkillRelatedSandboxes(nonFolderIds).catch((err) => { + logger.error('[Skill] Failed to cleanup skill sandboxes', { + skillIds: nonFolderIds, + error: err + }); + }); + } +} + +/** + * Get skill by ID + */ +export async function getSkillById(skillId: string): Promise { + const skill = await MongoAgentSkills.findOne({ + _id: skillId, + deleteTime: null + }).lean(); + + return skill as AgentSkillSchemaType | null; +} + +// ==================== Import/Export ==================== + +/** + * Import skill from package with full workflow (transaction) + * This function expects to be called inside mongoSessionRun + */ +export async function importSkill( + packageData: SkillPackageType, + teamId: string, + tmbId: string, + userId: string, + zipBuffer: Buffer, + parentId?: string | null, + session?: ClientSession +): Promise { + const { skill } = packageData; + + // Check for duplicate name before creating + const nameExists = await checkSkillNameExists(skill.name, teamId, parentId || null); + if (nameExists) { + throw new Error('Skill with this name already exists'); + } + + // Create skill record first + const newSkill = new MongoAgentSkills({ + parentId: parentId || null, + type: AgentSkillTypeEnum.skill, + source: AgentSkillSourceEnum.personal, + name: skill.name, + description: skill.description, + author: userId, + category: skill.category, + config: skill.config || {}, + avatar: skill.avatar, + teamId, + tmbId, + currentVersion: 0, + versionCount: 1, // Will have v0 + createTime: new Date(), + updateTime: new Date() + }); + await newSkill.save({ session }); + + const newSkillId = newSkill._id.toString(); + + // Upload ZIP to MinIO + const storageInfo = await uploadSkillPackage({ + teamId, + skillId: newSkillId, + version: 0, + zipBuffer + }); + + // Update skill's currentStorage field + await updateCurrentStorage(newSkillId, storageInfo, session); + + // Create v0 version record + await createVersion( + { + skillId: newSkillId, + tmbId, + version: 0, + versionName: 'Initial import', + storage: storageInfo + }, + session + ); + + return newSkillId; +} + +// ==================== Permission Checks ==================== + +/** + * Check if user can modify/delete a skill + */ +export async function canModifySkill(skillId: string, tmbId: string): Promise { + const skill = await MongoAgentSkills.findOne({ + _id: skillId, + deleteTime: null + }); + + if (!skill) { + return false; + } + + // System skills cannot be modified + if (skill.source === AgentSkillSourceEnum.system) { + return false; + } + + // Only the creator can modify + return skill.tmbId?.toString() === tmbId; +} + +/** + * Check if skill/folder name already exists in the same parent folder + */ +export async function checkSkillNameExists( + name: string, + teamId: string, + parentId: string | null, + excludeId?: 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 ==================== + +/** + * Recursively find a skill/folder and all its children + */ +export async function findSkillAndAllChildren({ + teamId, + skillId, + fields +}: { + teamId: string; + skillId: string; + fields?: string; +}): Promise { + const find = async (id: string): Promise => { + const children = await MongoAgentSkills.find( + { + teamId, + parentId: id, + deleteTime: null + }, + fields + ).lean(); + + let skills: AgentSkillSchemaType[] = children as AgentSkillSchemaType[]; + + for (const child of children) { + const grandChildren = await find(child._id); + skills = skills.concat(grandChildren); + } + + return skills; + }; + + const [skill, childSkills] = await Promise.all([ + MongoAgentSkills.findById(skillId, fields).lean(), + find(skillId) + ]); + + if (!skill) { + throw new Error('Skill not found'); + } + + return [skill as AgentSkillSchemaType, ...childSkills]; +} + +/** + * Create a skill folder + */ +export async function createSkillFolder( + data: { + name: string; + description?: string; + parentId?: string | null; + teamId: string; + tmbId: string; + }, + session?: ClientSession +): 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, + parentId: parentId || null, + name, + description: description || '', + author: '', + category: [], + config: {}, + teamId, + tmbId, + currentVersion: 0, + versionCount: 0, + createTime: new Date(), + updateTime: new Date() + }); + + await folder.save({ session }); + return folder.toObject() as AgentSkillSchemaType; +} + +/** + * Get folder path from a skill/folder to root + */ +export async function getSkillFolderPath( + skillId: string | null, + type: 'current' | 'parent' +): Promise<{ parentId: string | null; parentName: string }[]> { + if (!skillId) { + return []; + } + + const skill = await MongoAgentSkills.findById(skillId, 'name parentId type'); + if (!skill) { + return []; + } + + const targetId = type === 'current' ? skillId : skill.parentId ?? null; + return await getParents(targetId); +} + +/** + * Recursively get parent folders + */ +async function getParents( + parentId: string | null +): Promise<{ parentId: string | null; parentName: string }[]> { + if (!parentId) { + return []; + } + + const parent = await MongoAgentSkills.findById(parentId, 'name parentId'); + if (!parent) { + return []; + } + + const paths = await getParents(parent.parentId ?? null); + paths.push({ parentId, parentName: parent.name }); + + return paths; +} + +/** + * Update parent folders' updateTime recursively (fire-and-forget) + */ +export const updateParentFoldersUpdateTime = ({ parentId }: { parentId?: string | null }) => { + mongoSessionRun(async (session) => { + const existsId = new Set(); + let currentId: string | null | undefined = parentId; + while (true) { + if (!currentId || existsId.has(currentId)) return; + + existsId.add(currentId); + + const parentSkill = await MongoAgentSkills.findById(currentId, 'parentId updateTime'); + if (!parentSkill) return; + + parentSkill.updateTime = new Date(); + await parentSkill.save({ session }); + + currentId = parentSkill.parentId ?? null; + } + }).catch((err) => { + logger.error('Failed to update parent folder updateTime', { error: err }); + }); +}; diff --git a/packages/service/core/agentSkills/sandboxConfig.ts b/packages/service/core/agentSkills/sandboxConfig.ts new file mode 100644 index 0000000000..8c77259610 --- /dev/null +++ b/packages/service/core/agentSkills/sandboxConfig.ts @@ -0,0 +1,355 @@ +/** + * Skill Sandbox Configuration + * + * Provides configuration and defaults for sandbox management. + */ + +import type { + SandboxImageConfigType, + SkillSandboxEndpointType +} from '@fastgpt/global/core/agentSkills/type'; +import { createSandbox, type ISandbox, type OpenSandboxVolume } from '@fastgpt-sdk/sandbox-adapter'; +import type { OpenSandboxConfigType, SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter'; +import type { OpenSandboxAdapter } from '@fastgpt-sdk/sandbox-adapter'; +import { env } from '../../env'; + +type SandboxRuntime = 'kubernetes' | 'docker'; + +type BaseSandboxProviderConfig = { + provider: SandboxProviderType; + baseUrl: string; + runtime: SandboxRuntime; +}; + +export type OpenSandboxProviderConfig = BaseSandboxProviderConfig & { + provider: 'opensandbox'; + apiKey?: string; + useServerProxy?: boolean; +}; + +export type SealosDevboxProviderConfig = BaseSandboxProviderConfig & { + provider: 'sealosdevbox'; + token: string; +}; + +export type SandboxProviderConfig = OpenSandboxProviderConfig | SealosDevboxProviderConfig; + +/** + * App-side sandbox create config. + * Providers may support only a subset of these fields. + */ +export type SandboxCreateConfig = OpenSandboxConfigType; + +export type SandboxDefaults = { + defaultImage: SandboxImageConfigType; + workDirectory: string; + targetPort: number; + entrypoint: string; +}; + +export type SkillSizeLimits = { + maxUploadBytes: number; // Compressed upload size limit + maxUncompressedBytes: number; // Uncompressed size after extraction (Zip Bomb guard) + maxDownloadBytes: number; // Download from MinIO/S3 + maxSandboxPackageBytes: number; // Sandbox directory size before zip +}; + +function assertNever(value: never): never { + throw new Error(`Unsupported sandbox provider: ${String(value)}`); +} + +function createUnsupportedCreateConfigError(provider: SandboxProviderType): Error { + return new Error( + `Sandbox provider "${provider}" does not support custom image/entrypoint/env/metadata through @fastgpt/sandbox. Agent skill sandboxes currently require those capabilities.` + ); +} + +function toOpenSandboxCreateConfig( + createConfig?: SandboxCreateConfig +): OpenSandboxConfigType | undefined { + return createConfig; +} + +/** + * Get sandbox provider configuration from environment variables + */ +export function getSandboxProviderConfig(): SandboxProviderConfig { + const provider = (env.AGENT_SANDBOX_PROVIDER ?? 'opensandbox') as SandboxProviderType; + const runtime = (env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME ?? 'kubernetes') as SandboxRuntime; + + switch (provider) { + case 'opensandbox': + return { + 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 + }; + + case 'sealosdevbox': + return { + provider, + baseUrl: env.AGENT_SANDBOX_SEALOS_BASEURL ?? env.AGENT_SANDBOX_OPENSANDBOX_BASEURL ?? '', + token: env.AGENT_SANDBOX_SEALOS_TOKEN ?? env.AGENT_SANDBOX_OPENSANDBOX_API_KEY ?? '', + runtime + }; + + case 'e2b': + throw new Error('Sandbox provider "e2b" is not supported'); + + default: + return assertNever(provider); + } +} + +/** + * Get sandbox default settings + */ +export function getSandboxDefaults(): SandboxDefaults { + return { + defaultImage: { + repository: env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO ?? 'fastgpt-agent-sandbox', + tag: env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG ?? 'latest' + }, + workDirectory: '/home/sandbox/workspace', + // workDirectory: env.AGENT_SANDBOX_OPENSANDBOX_WORK_DIRECTORY ?? '/home/sandbox/workspace', + targetPort: 8080, + entrypoint: '/home/sandbox/entrypoint.sh' + // entrypoint: env.AGENT_SANDBOX_OPENSANDBOX_ENTRYPOINT ?? '/home/sandbox/entrypoint.sh' + }; +} + +/** + * Get skill size limits from environment variables + */ +export function getSkillSizeLimits(): SkillSizeLimits { + return { + maxUploadBytes: env.AGENT_SKILL_MAX_UPLOAD_SIZE ?? 50 * 1024 * 1024, + maxUncompressedBytes: env.AGENT_SKILL_MAX_UNCOMPRESSED_SIZE ?? 200 * 1024 * 1024, + maxDownloadBytes: env.AGENT_SKILL_MAX_DOWNLOAD_SIZE ?? 200 * 1024 * 1024, + maxSandboxPackageBytes: env.AGENT_SKILL_MAX_SANDBOX_SIZE ?? 200 * 1024 * 1024 + }; +} + +/** + * Validate sandbox configuration + */ +export function validateSandboxConfig(config: SandboxProviderConfig): void { + if (!config.baseUrl) { + throw new Error('Sandbox provider base URL is required'); + } + + if (!['kubernetes', 'docker'].includes(config.runtime)) { + throw new Error(`Invalid runtime: ${config.runtime}`); + } + + if (config.provider === 'sealosdevbox' && !config.token) { + throw new Error('Sandbox provider token is required for sealosdevbox'); + } +} + +/** + * Build a provider-specific sandbox adapter behind the unified ISandbox interface. + * For providers that require a sandboxId at construction time, pass providerSandboxId. + */ +export function buildSandboxAdapter( + providerConfig: SandboxProviderConfig, + props: { + providerSandboxId: string; + createConfig?: SandboxCreateConfig; + } +): ISandbox { + switch (providerConfig.provider) { + case 'opensandbox': + return createSandbox( + 'opensandbox', + { + apiKey: providerConfig.apiKey, + baseUrl: providerConfig.baseUrl, + runtime: providerConfig.runtime, + useServerProxy: providerConfig.useServerProxy, + sessionId: props.providerSandboxId + }, + toOpenSandboxCreateConfig(props.createConfig) + ); + + case 'sealosdevbox': { + if (!props.providerSandboxId) { + throw new Error( + 'Sandbox provider "sealosdevbox" requires providerSandboxId when initializing the adapter' + ); + } + if (props.createConfig) { + throw createUnsupportedCreateConfigError(providerConfig.provider); + } + + const connection = { + baseUrl: providerConfig.baseUrl, + token: providerConfig.token, + sandboxId: props.providerSandboxId + }; + + return createSandbox('sealosdevbox', connection); + } + + default: + return assertNever(providerConfig); + } +} + +/** + * Connect to an existing provider sandbox and return a unified adapter instance. + * + * OpenSandbox requires an explicit SDK connect call. Other providers, like + * Sealos Devbox, identify the target sandbox during adapter construction. + */ +export async function connectToProviderSandbox( + providerConfig: SandboxProviderConfig, + providerSandboxId: string +): Promise { + const sandbox = buildSandboxAdapter(providerConfig, { providerSandboxId }); + + if (sandbox.provider === 'opensandbox') { + await (sandbox as OpenSandboxAdapter).connect(providerSandboxId); + } + + return sandbox; +} + +/** + * Release any provider-specific client resources tied to the sandbox handle. + * + * `close()` is not part of the shared ISandbox contract today, so keep the + * OpenSandbox branch here instead of leaking adapter-specific casts into + * business code. Other providers currently have no equivalent method. + */ +export async function disconnectFromProviderSandbox(sandbox: ISandbox): Promise { + if (sandbox.provider === 'opensandbox') { + await (sandbox as OpenSandboxAdapter).close(); + } +} + +/** + * Resolve the externally reachable endpoint for a sandbox service. + * + * `getEndpoint()` is an OpenSandbox-specific extension. If another provider adds + * a similar capability later, extend this function instead of branching again + * in application code. + */ +export async function getProviderSandboxEndpoint( + sandbox: ISandbox, + port: number +): Promise { + if (sandbox.provider === 'opensandbox') { + const endpoint = await (sandbox as OpenSandboxAdapter).getEndpoint(port); + return { + host: endpoint.host, + port: endpoint.port, + protocol: endpoint.protocol, + url: endpoint.url + }; + } + + 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.` + ); +} + +// ---- Volume Manager integration ---- + +export type VolumeManagerConfig = { + url: string; + token: string; + mountPath: string; +}; + +/** + * Read Volume Manager configuration from environment variables. + * Throws when any required field is missing. + */ +export function getVolumeManagerConfig(): VolumeManagerConfig { + const { + AGENT_SANDBOX_VOLUME_MANAGER_URL, + AGENT_SANDBOX_VOLUME_MANAGER_TOKEN, + AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH + } = env; + if ( + !AGENT_SANDBOX_VOLUME_MANAGER_URL || + !AGENT_SANDBOX_VOLUME_MANAGER_TOKEN || + !AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH + ) { + throw new Error( + 'Missing required Volume Manager configuration: VOLUME_MANAGER_URL, VOLUME_MANAGER_TOKEN, VOLUME_MANAGER_MOUNT_PATH must be set' + ); + } + return { + url: AGENT_SANDBOX_VOLUME_MANAGER_URL, + token: AGENT_SANDBOX_VOLUME_MANAGER_TOKEN, + mountPath: AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH + }; +} + +/** + * Call volume-manager HTTP API to idempotently create a volume for the session. + * Returns the claimName (PVC name or Docker volume name). + */ +export async function ensureSessionVolume( + sessionId: string, + vmConfig: VolumeManagerConfig +): Promise { + const res = await fetch(`${vmConfig.url}/v1/volumes/ensure`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${vmConfig.token}` + }, + body: JSON.stringify({ sessionId }) + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Volume Manager error ${res.status}: ${text}`); + } + + const data = (await res.json()) as { claimName: string }; + return data.claimName; +} + +/** + * Build the volumes entry for the sandbox create config. + * + * Both Docker and Kubernetes runtimes use the `pvc` backend: + * - Kubernetes: pvc.claimName is the K8s PVC name + * - Docker: pvc.claimName is the Docker named volume name (docker volume create) + * + * The `host` backend is for bind mounts (absolute paths) only and is not used here. + */ +export function buildVolumeConfig( + _runtime: SandboxRuntime, + sessionId: string, + claimName: string, + mountPath: string +): OpenSandboxVolume { + // Volume name must match DNS label format: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + const name = sessionId + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/^-+|-+$/g, ''); + return { name, pvc: { claimName }, mountPath, readOnly: false }; +} + +/** + * Build container env vars for the sandbox process. + */ +export function buildBaseContainerEnv( + sessionId: string, + workDirectory: string, + enableCodeServer: boolean +): Record { + return { + FASTGPT_SESSION_ID: sessionId, + FASTGPT_WORKDIR: workDirectory, + FASTGPT_ENABLE_CODE_SERVER: enableCodeServer ? 'true' : 'false' + }; +} diff --git a/packages/service/core/agentSkills/sandboxController.ts b/packages/service/core/agentSkills/sandboxController.ts new file mode 100644 index 0000000000..12063d206c --- /dev/null +++ b/packages/service/core/agentSkills/sandboxController.ts @@ -0,0 +1,589 @@ +/** + * Skill Sandbox Controller + * + * Provides core business logic for managing skill sandbox instances. + * + */ + +import type { ISandbox, OpenSandboxVolume } 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, + buildSandboxAdapter, + connectToProviderSandbox, + disconnectFromProviderSandbox, + getProviderSandboxEndpoint, + getVolumeManagerConfig, + ensureSessionVolume, + buildVolumeConfig, + buildBaseContainerEnv +} 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 } from '../ai/sandbox/controller'; +import { mongoSessionRun } from '../../common/mongo/sessionRun'; +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; +}; + +export type DeleteSandboxParams = { + sandboxId: string; + 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?.status === SandboxStatusEnum.running) { + // Reuse running sandbox - return stored endpoint directly + addLog.info('[Sandbox] Found running sandbox instance, reusing', { + instanceId: existingInstance._id, + sandboxId: existingInstance.sandboxId + }); + + const endpointInfo = existingInstance.metadata!.endpoint!; + + await MongoSandboxInstance.updateOne( + { _id: existingInstance._id }, + { lastActiveAt: new Date() } + ); + + onProgress?.({ + sandboxId: skillId, + phase: 'ready', + endpoint: endpointInfo, + providerSandboxId: existingInstance.sandboxId + }); + + return { + sandboxId: existingInstance._id.toString(), + providerSandboxId: existingInstance.sandboxId, + endpoint: endpointInfo, + status: { + state: existingInstance.status + // message: existingInstance.metadata!.providerStatus.message + } + }; + } + + if (existingInstance?.status === SandboxStatusEnum.stopped) { + // Resume stopped sandbox + addLog.info('[Sandbox] Found stopped sandbox instance, resuming', { + instanceId: existingInstance._id, + sandboxId: existingInstance.sandboxId + }); + + let resumeSandboxAdapter: ISandbox | null = null; + try { + const newAdapter = await connectToProviderSandbox(providerConfig, existingInstance.sandboxId); + resumeSandboxAdapter = newAdapter; + + onProgress?.({ sandboxId: skillId, phase: 'creatingContainer' }); + await newAdapter.start(); + await newAdapter.waitUntilReady(60000); + + const endpointInfo = await getProviderSandboxEndpoint(newAdapter, defaults.targetPort); + + await MongoSandboxInstance.updateOne( + { _id: existingInstance._id }, + { + status: SandboxStatusEnum.running, + lastActiveAt: new Date(), + 'metadata.endpoint': endpointInfo, + 'metadata.providerStatus': { state: 'Running' } + } + ); + + 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 resume stopped sandbox', { error }); + throw error; + } finally { + if (resumeSandboxAdapter) { + await disconnectFromProviderSandbox(resumeSandboxAdapter); + } + } + } + + // 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; + + try { + addLog.info('[Sandbox] Creating sandbox instance', { + image: sandboxImage + }); + + onProgress?.({ sandboxId: skillId, phase: 'creatingContainer' }); + const sessionId = new mongoose.Types.ObjectId().toHexString(); + + const createEntrypoint = defaults.entrypoint; + + let volumes: OpenSandboxVolume[] | undefined; + if (providerConfig.provider === 'opensandbox' && env.AGENT_SANDBOX_ENABLE_VOLUME) { + const vmConfig = getVolumeManagerConfig(); + const claimName = await ensureSessionVolume(sessionId, vmConfig); + volumes = [ + buildVolumeConfig(providerConfig.runtime, sessionId, claimName, vmConfig.mountPath) + ]; + } + + const newSandbox = buildSandboxAdapter(providerConfig, { + providerSandboxId: sessionId, + createConfig: { + image: sandboxImage, + entrypoint: [entrypoint ?? createEntrypoint], + env: buildBaseContainerEnv(sessionId, defaults.workDirectory, true), + volumes, + metadata: { + skillId, + teamId, + sandboxType: SandboxTypeEnum.editDebug, + sessionId + } + } + }); + sandbox = newSandbox; // keep outer ref for finally cleanup + + await newSandbox.create(); + + addLog.info('[Sandbox] Waiting for sandbox to be ready'); + await newSandbox.waitUntilReady(60000); + const sandboxInfo = await newSandbox.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 newSandbox.writeFiles([ + { + path: zipPath, + data: standardizedBuffer + } + ]); + + addLog.info('[Sandbox] Extracting package'); + onProgress?.({ sandboxId: skillId, phase: 'extractingPackage' }); + const extractResult = await newSandbox.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(newSandbox, defaults.targetPort); + + addLog.info('[Sandbox] Endpoint obtained', endpointInfo); + + // Persist to MongoDB + const newSandboxDoc = await mongoSessionRun(async (session) => { + const doc = await MongoSandboxInstance.create( + [ + { + provider: providerConfig.provider, + sandboxId: sandboxInfo.id, + appId: skillId, + userId: tmbId, + chatId: EDIT_DEBUG_CHAT_ID, + status: SandboxStatusEnum.running, + lastActiveAt: new Date(), + createdAt: new Date(), + metadata: { + sandboxType: SandboxTypeEnum.editDebug, + teamId, + tmbId, + skillId, + provider: providerConfig.provider, + image: sandboxInfo.image, + providerStatus: { + state: sandboxInfo.status.state, + message: sandboxInfo.status.message, + reason: sandboxInfo.status.reason + }, + 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()] + ]) + } + } + ], + { session } + ); + + return doc[0]; + }); + + addLog.info('[Sandbox] Sandbox info saved to database', { + sandboxId: newSandboxDoc._id + }); + + onProgress?.({ + sandboxId: skillId, + phase: 'ready', + endpoint: endpointInfo, + providerSandboxId: sandboxInfo.id + }); + + return { + sandboxId: newSandboxDoc._id.toString(), + providerSandboxId: sandboxInfo.id, + 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 + }); + + // Cleanup provider sandbox if it was created + if (sandbox) { + try { + await sandbox.delete(); + } 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 { + const { sandboxId, teamId } = params; + + const sandbox = await MongoSandboxInstance.findOne({ + _id: sandboxId, + 'metadata.teamId': teamId + }); + + if (!sandbox) { + throw new Error('Sandbox not found or access denied'); + } + + return sandbox as unknown as SandboxInstanceSchemaType; +} + +/** + * Delete sandbox + */ +export async function deleteSandbox(params: DeleteSandboxParams): Promise { + const { sandboxId, teamId } = params; + + const instanceDoc = await MongoSandboxInstance.findOne({ + _id: sandboxId, + 'metadata.teamId': teamId + }); + + if (!instanceDoc) { + throw new Error('Sandbox not found or access denied'); + } + + addLog.info('[Sandbox] Deleting sandbox', { sandboxId }); + + const client = await getSandboxClient({ sandboxId: instanceDoc.sandboxId }); + await client.delete().catch((err) => { + addLog.error('[Sandbox] Failed to delete sandbox', { + sandboxId: instanceDoc.sandboxId, + error: err + }); + }); +} + +/** + * Force delete all sandbox instances related to the given skill IDs + * Called when a skill is deleted to clean up provider resources + */ +export async function deleteSkillRelatedSandboxes(skillIds: string[]): Promise { + if (skillIds.length === 0) return; + + // Find all sandbox instances related to these skills + const instances = await MongoSandboxInstance.find({ + $or: [{ appId: { $in: skillIds } }, { 'metadata.skillId': { $in: skillIds } }] + }).lean(); + + if (instances.length === 0) return; + + addLog.info('[Sandbox] Force deleting skill-related sandboxes', { + skillIds, + count: instances.length + }); + + await Promise.allSettled( + instances.map(async (doc) => { + const client = await getSandboxClient({ sandboxId: doc.sandboxId }); + await client.delete().catch((err) => { + addLog.error('[Sandbox] Failed to delete sandbox', { + sandboxId: doc.sandboxId, + error: err + }); + }); + }) + ); +} + +/** + * Package skill directory in sandbox + * + * Creates a package.zip file containing all files in the sandbox working directory + * + * @param params - Parameters for packaging + * @param params.providerSandboxId - Provider sandbox ID + * @param params.workDirectory - Working directory + * @returns Buffer containing the package.zip file + * + * @throws Error if packaging fails, file cannot be read, or directory exceeds size limit + */ +export async function packageSkillInSandbox(params: { + providerSandboxId: string; + workDirectory?: string; +}): Promise { + const { providerSandboxId, workDirectory } = params; + const { maxSandboxPackageBytes: maxBytes } = getSkillSizeLimits(); + + const providerConfig = getSandboxProviderConfig(); + const defaults = getSandboxDefaults(); + const targetDir = workDirectory || defaults.workDirectory; + + addLog.info('[Sandbox] Packaging skill in sandbox', { + providerSandboxId, + workDirectory: targetDir + }); + + let sandbox: ISandbox | null = null; + + try { + const newSandbox = await connectToProviderSandbox(providerConfig, providerSandboxId); + sandbox = newSandbox; + + // Fast path: check directory size before expensive zip operation + // Use 'find -ls | awk' instead of 'du' for better portability: + // 'du' reports disk-block usage and its flags (-sb, --bytes) differ across GNU coreutils, + // busybox (Alpine), and BSD; 'find -ls' is POSIX and outputs per-file byte sizes in $7 + // uniformly across all those environments. + const sizeCheckCmd = `find ${targetDir} -type f ! -name 'package.zip' -ls 2>/dev/null | awk '{s+=$7} END {print s+0}'`; + addLog.info('[Sandbox] Checking directory size before packaging'); + const sizeResult = await newSandbox.execute(sizeCheckCmd); + + if (sizeResult.exitCode === 0 && sizeResult.stdout.trim()) { + const dirBytes = parseInt(sizeResult.stdout.trim(), 10); + if (!isNaN(dirBytes) && dirBytes > maxBytes) { + throw new Error( + `Skill directory size (${(dirBytes / 1024 / 1024).toFixed(2)}MB) exceeds maximum allowed size (${maxBytes / 1024 / 1024}MB)` + ); + } + addLog.info('[Sandbox] Directory size check passed', { + dirBytes, + maxBytes + }); + } + + // Zip workDirectory directly so that archive entries are {skill-name}/... + // keeping the same structure expected by validateZipStructure. + const zipCommand = `cd ${targetDir} && zip -r package.zip . -x 'package.zip'`; + + addLog.info('[Sandbox] Executing zip command'); + + const zipResult = await newSandbox.execute(zipCommand); + + if (zipResult.exitCode !== 0) { + throw new Error(`Failed to package skill directory: ${zipResult.stderr || zipResult.stdout}`); + } + + addLog.info('[Sandbox] Zip command executed successfully', { + stdout: zipResult.stdout + }); + + // Read the generated package.zip file + const zipFilePath = `${targetDir}/package.zip`; + + addLog.info('[Sandbox] Reading package.zip from sandbox', { path: zipFilePath }); + + const files = await newSandbox.readFiles([zipFilePath]); + + if (!files || files.length === 0) { + throw new Error('Package file not found in sandbox'); + } + + addLog.info('[Sandbox] Package read successfully', { + size: files[0].content.length + }); + + const content = files[0].content; + return Buffer.from(content instanceof Uint8Array ? content : Buffer.from(content, 'utf-8')); + } catch (error) { + addLog.error('[Sandbox] Failed to package skill', { + providerSandboxId, + error + }); + throw error; + } finally { + if (sandbox) { + await disconnectFromProviderSandbox(sandbox); + } + } +} diff --git a/packages/service/core/agentSkills/sandboxSchema.ts b/packages/service/core/agentSkills/sandboxSchema.ts new file mode 100644 index 0000000000..8c3c09e094 --- /dev/null +++ b/packages/service/core/agentSkills/sandboxSchema.ts @@ -0,0 +1 @@ +export { collectionName, MongoSandboxInstance } from '../ai/sandbox/schema'; diff --git a/packages/service/core/agentSkills/schema.ts b/packages/service/core/agentSkills/schema.ts new file mode 100644 index 0000000000..76b0e07754 --- /dev/null +++ b/packages/service/core/agentSkills/schema.ts @@ -0,0 +1,119 @@ +import { connectionMongo, getMongoModel } from '../../common/mongo'; +import { + agentSkillsCollectionName as agentSkillsCollectionName, + AgentSkillSourceEnum, + AgentSkillCategoryEnum, + AgentSkillTypeEnum +} from '@fastgpt/global/core/agentSkills/constants'; +import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type'; + +const { Schema } = connectionMongo; + +const AgentSkillsSchema = new Schema({ + // Folder hierarchy + parentId: { + type: Schema.Types.ObjectId, + ref: agentSkillsCollectionName, + default: null + }, + type: { + type: String, + enum: Object.values(AgentSkillTypeEnum), + default: AgentSkillTypeEnum.skill + }, + // Permission inheritance + inheritPermission: { + type: Boolean, + default: true + }, + source: { + type: String, + enum: Object.values(AgentSkillSourceEnum), + required: true + }, + name: { + type: String, + required: true + }, + description: { + type: String, + default: '' + }, + author: { + type: String, + default: '' + }, + category: { + type: [String], + enum: Object.values(AgentSkillCategoryEnum), + default: [] + }, + config: { + type: Object, + default: {} + }, + avatar: { + type: String + }, + teamId: { + type: Schema.Types.ObjectId, + ref: 'team' + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: 'team_members' + }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + }, + deleteTime: { + type: Date, + default: null + }, + // === Version Control === + currentVersion: { + type: Number, + default: 0 + }, + versionCount: { + type: Number, + default: 0 + }, + currentStorage: { + bucket: String, + key: String, + size: Number + } +}); + +// Create indexes +try { + // Text index for search + AgentSkillsSchema.index({ name: 'text', description: 'text' }); + // Compound index for list queries + AgentSkillsSchema.index({ source: 1, teamId: 1, deleteTime: 1, createTime: -1 }); + // Category index + 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); +} + +export const MongoAgentSkills = getMongoModel( + agentSkillsCollectionName, + AgentSkillsSchema +); diff --git a/packages/service/core/agentSkills/skillMdBuilder.ts b/packages/service/core/agentSkills/skillMdBuilder.ts new file mode 100644 index 0000000000..d64f142046 --- /dev/null +++ b/packages/service/core/agentSkills/skillMdBuilder.ts @@ -0,0 +1,431 @@ +/** + * SKILL.md Builder + * + * This module provides utilities for building and manipulating SKILL.md files + * following the Agent Skills specification. + */ + +import { createLLMResponse } from '../ai/llm/request'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; +import { sliceJsonStr } from '@fastgpt/global/common/string/tools'; +import json5 from 'json5'; + +export type BuildSkillMdParams = { + name: string; + description: string; +}; + +// Params for AI-assisted SKILL.md generation +export type GenerateSkillParam = { + name: string; + description: string; + requirements: string; + model: string; +}; + +/** + * Generate system prompt for SKILL.md generation + * Instructs the LLM to create a complete SKILL.md file following the Agent Skills specification + */ +const getSkillMdGeneratorSystemPrompt = () => { + return `# Role +Agent Skill Designer + +## Skills +- Deep understanding of Agent Skills specification and SKILL.md format +- Expertise in designing clear, actionable skill definitions +- Ability to create well-structured YAML frontmatter with appropriate metadata +- Proficiency in writing comprehensive skill documentation with examples +- Strong capability in analyzing requirements and translating them into skill specifications + +## Goals +- Generate a complete, valid SKILL.md file that follows the Agent Skills specification +- Create a skill definition that accurately captures the user's requirements +- Ensure the skill is practical, well-documented, and ready for deployment +- Provide clear instructions and examples for skill usage + +## Constraints +- Output must be a complete SKILL.md file with valid YAML frontmatter +- Frontmatter must include 'name' (kebab-case, 1-64 chars, lowercase/numbers/hyphens only) and 'description' fields +- The 'name' field must not start or end with hyphens, and must not contain consecutive hyphens +- The 'description' field should be concise (1-200 chars) and describe when/why the skill is triggered +- Body content must include Overview, Instructions, and Examples sections +- All content must be practical and directly usable +- Do not include any explanatory text outside the SKILL.md file +- Do not wrap the output in code blocks or markdown formatting + +## Workflow +1. Analyze the user's goal, workflow, requirements, and examples +2. Design an appropriate skill name in kebab-case format +3. Create a concise, action-oriented description +4. Structure the skill documentation with clear sections +5. Generate the complete SKILL.md with frontmatter and body +6. Ensure all content is practical and implementation-ready + +## Output Format +Output ONLY the complete SKILL.md file content, starting with the frontmatter (---) and including the full body. No additional text, explanations, or code block markers.`; +}; + +/** + * Generate user prompt for SKILL.md generation + * Assembles user input into a structured prompt for the LLM + */ +const getSkillMdGeneratorUserPrompt = (params: { + goal: string; + workflow?: string; + requirements?: string; + examples?: string; +}) => { + const { goal, workflow, requirements, examples } = params; + + let prompt = `Please generate a complete SKILL.md file based on the following skill requirements: + +## Skill Goal +${goal}`; + + if (workflow) { + prompt += ` + +## Workflow/Process +${workflow}`; + } + + if (requirements) { + prompt += ` + +## Additional Requirements +${requirements}`; + } + + if (examples) { + prompt += ` + +## Usage Examples +${examples}`; + } + + prompt += ` + +## Important Notes +- Generate the complete SKILL.md file with valid YAML frontmatter +- The 'name' field must be in kebab-case format (lowercase letters, numbers, and hyphens only) +- The 'name' must be 1-64 characters long +- The 'description' should be concise and action-oriented (1-200 characters) +- Include Overview, Instructions, and Examples sections in the body +- Output ONLY the SKILL.md content, no explanations or code blocks +- Ensure the skill is practical and ready for immediate use`; + + return prompt; +}; + +/** + * System prompt for skill guidance extraction. + * Instructs the LLM to parse free-form requirements text into structured fields. + */ +const getSkillGuidanceSystemPrompt = () => + `You are a skill design analyst. Your task is to analyze the user's skill requirements text and extract structured design information. + +Output a JSON object with the following fields: +- "goal" (required, string): A concise statement of what the skill should accomplish +- "workflow" (optional, string): Step-by-step process or workflow, use numbered list format +- "requirements" (optional, string): Specific constraints, technical requirements, or rules +- "examples" (optional, string): Concrete usage examples or sample scenarios + +Rules: +- Output ONLY valid JSON, no markdown code blocks, no explanations +- If the input already contains clear structured information, extract it faithfully +- If a field cannot be determined from the input, omit it +- Keep each field concise and focused`; + +/** + * User prompt for skill guidance extraction. + */ +const getSkillGuidanceUserPrompt = (name: string, description: string, requirements: string) => { + let prompt = `Please analyze the following skill requirements and extract structured design information: + +## Skill Name +${name}`; + + if (description) { + prompt += ` + +## Skill Description +${description}`; + } + + prompt += ` + +## Requirements Text +${requirements}`; + + return prompt; +}; + +/** + * Parse free-form skill requirements into structured guidance using LLM. + * Extracts goal, workflow, requirements, and examples from the user's input. + */ +export async function getSkillGuidance( + name: string, + description: string, + requirements: string, + model: string +): Promise<{ + guidance: { goal: string; workflow?: string; requirements?: string; examples?: string }; + usage: { inputTokens: number; outputTokens: number }; +}> { + const messages: ChatCompletionMessageParam[] = [ + { + role: 'system', + content: getSkillGuidanceSystemPrompt() + }, + { + role: 'user', + content: getSkillGuidanceUserPrompt(name, description, requirements) + } + ]; + + const { answerText, usage } = await createLLMResponse({ + body: { + model, + messages, + temperature: 0, + max_tokens: 1000, + stream: false + } + }); + + try { + const parsed = json5.parse(sliceJsonStr(answerText)); + return { + guidance: { + goal: parsed.goal || description || name, + workflow: parsed.workflow || undefined, + requirements: parsed.requirements || undefined, + examples: parsed.examples || undefined + }, + usage + }; + } catch { + // Fallback: treat the entire requirements text as the goal + return { + guidance: { + goal: description || requirements || name, + requirements + }, + usage + }; + } +} + +/** + * Generate complete SKILL.md content using LLM guidance. + * Makes two LLM calls: one to parse requirements, one to generate SKILL.md. + * Returns merged token usage from both calls. + */ +export async function generateSkillMd( + params: GenerateSkillParam +): Promise<[string, { inputTokens: number; outputTokens: number }]> { + const model = params.model; + + // Step 1: Parse requirements into structured guidance + const { guidance, usage: guidanceUsage } = await getSkillGuidance( + params.name, + params.description, + params.requirements, + model + ); + + // Build messages for LLM + const messages: ChatCompletionMessageParam[] = [ + { + role: 'system', + content: getSkillMdGeneratorSystemPrompt() + }, + { + role: 'user', + content: getSkillMdGeneratorUserPrompt({ + goal: guidance.goal.trim(), + workflow: guidance.workflow?.trim(), + requirements: guidance.requirements?.trim(), + examples: guidance.examples?.trim() + }) + } + ]; + + // Step 2: Call LLM to generate SKILL.md (non-streaming) + const { answerText, usage: generateUsage } = await createLLMResponse({ + body: { + model, + messages, + temperature: 0.1, + max_tokens: 4000, + stream: false + } + }); + + // Merge token usage from both LLM calls + const mergedUsage = { + inputTokens: guidanceUsage.inputTokens + generateUsage.inputTokens, + outputTokens: guidanceUsage.outputTokens + generateUsage.outputTokens + }; + + return [answerText, mergedUsage]; +} + +/** + * Build a complete SKILL.md content with frontmatter only + */ +export function buildSkillMd(params: BuildSkillMdParams): string { + return generateFrontmatter(params.name, params.description); +} + +/** + * Generate YAML frontmatter for SKILL.md + */ +export function generateFrontmatter(name: string, description: string): string { + const escapedName = escapeYaml(name); + const escapedDescription = escapeYaml(description); + + return `---\nname: ${escapedName}\ndescription: ${escapedDescription}\n---`; +} + +/** + * Parse frontmatter from SKILL.md content + */ +export function parseFrontmatter(content: string): { + name: string; + description: string; + body: string; +} { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/); + + if (!frontmatterMatch) { + throw new Error('Invalid SKILL.md format: missing frontmatter'); + } + + const frontmatterText = frontmatterMatch[1]; + const body = frontmatterMatch[2]; + + // Parse name + const nameMatch = frontmatterText.match(/^name:\s*(.+)$/m); + const name = nameMatch ? unescapeYaml(nameMatch[1].trim()) : ''; + + // Parse description + const descMatch = frontmatterText.match(/^description:\s*(.+)$/m); + const description = descMatch ? unescapeYaml(descMatch[1].trim()) : ''; + + return { name, description, body }; +} + +/** + * Escape a string for use in YAML + */ +export function escapeYaml(value: string): string { + if (value === '') { + return '""'; + } + + // Check if value needs quoting + const needsQuoting = + /[:#{}\[\],&*?|<>!=~`@]/.test(value) || + /^[-?]/.test(value) || + value.includes('\n') || + value.includes('"') || + /^true$|^false$|^null$|^~$/i.test(value); + + if (!needsQuoting) { + return value; + } + + // Handle multi-line strings + if (value.includes('\n')) { + // Use literal block scalar for multi-line strings + const lines = value.split('\n'); + return '|\n' + lines.map((line) => ' ' + line).join('\n'); + } + + // Escape double quotes + const escaped = value.replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +/** + * Unescape a YAML string value + */ +export function unescapeYaml(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/\\"/g, '"'); + } + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/\\'/g, "'"); + } + return value; +} + +/** + * Validate skill name according to Agent Skills spec + */ +export function validateSkillName(name: string): boolean { + // Must be 1-64 characters + if (name.length === 0 || name.length > 64) { + return false; + } + + // Must only contain lowercase letters, numbers, and hyphens + if (!/^[a-z0-9-]+$/.test(name)) { + return false; + } + + // Must not start or end with hyphen + if (name.startsWith('-') || name.endsWith('-')) { + return false; + } + + // Must not contain consecutive hyphens + if (name.includes('--')) { + return false; + } + + return true; +} + +/** + * Sanitize a skill name for use as a file/directory name + */ +export function sanitizeSkillNameForFile(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/_/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 64); +} + +/** + * Extract skill name from SKILL.md content + */ +export function extractSkillNameFromSkillMd(content: string): string { + try { + const { name } = parseFrontmatter(content); + return name; + } catch { + // Fallback: try to extract from first header + const headerMatch = content.match(/^#\s+(.+)$/m); + return headerMatch ? sanitizeSkillNameForFile(headerMatch[1]) : 'unnamed-skill'; + } +} + +/** + * Extract description from SKILL.md content + */ +export function extractDescriptionFromSkillMd(content: string): string { + try { + const { description } = parseFrontmatter(content); + return description; + } catch { + return ''; + } +} diff --git a/packages/service/core/agentSkills/storage.ts b/packages/service/core/agentSkills/storage.ts new file mode 100644 index 0000000000..4d9a982610 --- /dev/null +++ b/packages/service/core/agentSkills/storage.ts @@ -0,0 +1,291 @@ +/** + * Skill Storage Service + * + * Provides utilities for uploading, downloading, and managing skill packages + * in object storage (MinIO/S3) using @fastgpt-sdk/storage. + */ + +import { S3PrivateBucket } from '../../common/s3/buckets/private'; +import type { ClientSession } from '../../common/mongo'; +import { getSkillSizeLimits } from './sandboxConfig'; + +export type SkillStorageInfo = { + bucket: string; + key: string; + size: number; + checksum?: string; +}; + +export type UploadSkillPackageParams = { + teamId: string; + skillId: string; + version: number; + zipBuffer: Buffer; + checksum?: string; +}; + +export type DownloadSkillPackageParams = { + storageInfo: SkillStorageInfo; +}; + +export type GetSkillStorageInfoParams = { + teamId: string; + skillId: string; + version: number; +}; + +/** + * Generate storage key for skill package + */ +export function getSkillStorageKey(teamId: string, skillId: string, version: number): string { + return `agent-skills/${teamId}/${skillId}/v${version}/package.zip`; +} + +/** + * Parse storage key to extract teamId, skillId, and version + */ +export function parseSkillStorageKey( + key: string +): { teamId: string; skillId: string; version: number } | null { + const match = key.match(/^agent-skills\/([^/]+)\/([^/]+)\/v(\d+)\/package\.zip$/); + if (!match) return null; + + return { + teamId: match[1], + skillId: match[2], + version: parseInt(match[3], 10) + }; +} + +/** + * Upload skill package to MinIO/S3 storage + */ +export async function uploadSkillPackage( + params: UploadSkillPackageParams +): Promise { + const { teamId, skillId, version, zipBuffer, checksum } = params; + + // Generate storage key + const key = getSkillStorageKey(teamId, skillId, version); + + // Use S3PrivateBucket for upload + const bucket = new S3PrivateBucket(); + + await bucket.client.uploadObject({ + key, + body: zipBuffer, + contentType: 'application/zip', + metadata: { + 'x-amz-meta-team-id': teamId, + 'x-amz-meta-skill-id': skillId, + 'x-amz-meta-version': version.toString(), + ...(checksum && { 'x-amz-meta-checksum': checksum }) + } + }); + + return { + bucket: bucket.bucketName, + key, + size: zipBuffer.length, + ...(checksum && { checksum }) + }; +} + +/** + * Download skill package from MinIO/S3 storage + */ +export async function downloadSkillPackage(params: DownloadSkillPackageParams): Promise { + const { storageInfo } = params; + const { maxDownloadBytes } = getSkillSizeLimits(); + + 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}`); + } + + // 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)` + ); + } + chunks.push(buf); + } + + return Buffer.concat(chunks); +} + +/** + * Delete skill package from MinIO/S3 storage + */ +export async function deleteSkillPackage(storageInfo: SkillStorageInfo): Promise { + const bucket = new S3PrivateBucket(); + + await bucket.client.deleteObject({ + key: storageInfo.key + }); +} + +/** + * Delete all packages for a skill across all versions using prefix deletion. + * Fire-and-forget: enqueues to BullMQ and returns immediately. + * Prefix covers: agent-skills/{teamId}/{skillId}/ (all versions) + */ +export function deleteSkillAllPackages(teamId: string, skillId: string): void { + const prefix = `agent-skills/${teamId}/${skillId}/`; + const bucket = new S3PrivateBucket(); + bucket.addDeleteJob({ prefix }); +} + +/** + * Check if skill package exists in storage + */ +export async function checkSkillPackageExists(storageInfo: SkillStorageInfo): Promise { + try { + const bucket = new S3PrivateBucket(); + + const { exists } = await bucket.client.checkObjectExists({ + key: storageInfo.key + }); + + return exists ?? false; + } catch { + return false; + } +} + +/** + * Get skill storage info for a specific version + */ +export async function getSkillStorageInfo( + params: GetSkillStorageInfoParams +): Promise { + const { teamId, skillId, version } = params; + + const key = getSkillStorageKey(teamId, skillId, version); + const bucket = new S3PrivateBucket(); + + // Check if object exists and get metadata + const { exists } = await bucket.client.checkObjectExists({ key }); + + if (!exists) { + return { + bucket: bucket.bucketName, + key, + size: 0, + exists: false + }; + } + + // Get object metadata to get size + try { + const metadata = await bucket.client.getObjectMetadata({ key }); + + return { + bucket: bucket.bucketName, + key, + size: metadata.contentLength ?? 0, + exists: true + }; + } catch { + return { + bucket: bucket.bucketName, + key, + size: 0, + exists: true + }; + } +} + +/** + * Copy skill package to a new version + */ +export async function copySkillPackage( + sourceStorageInfo: SkillStorageInfo, + targetParams: Omit +): Promise { + // Download the source package + const zipBuffer = await downloadSkillPackage({ + storageInfo: sourceStorageInfo + }); + + // Upload to the new location + return uploadSkillPackage({ + ...targetParams, + zipBuffer + }); +} + +/** + * 获取会话制品列表 + */ +export async function listSessionArtifacts(sessionId: string): Promise { + const prefix = `agent-sessions/${sessionId}/`; + const bucket = new S3PrivateBucket(); + + const { keys } = await bucket.client.listObjects({ prefix }); + return keys.map((key) => key.replace(prefix, '')); +} + +/** + * 下载制品 + */ +export async function downloadSessionArtifact( + sessionId: string, + filePath: string +): Promise { + const key = `agent-sessions/${sessionId}/${filePath}`; + const bucket = new S3PrivateBucket(); + + const response = await bucket.client.downloadObject({ key }); + + if (!response.body) { + throw new Error(`Failed to download artifact: ${key}`); + } + + const chunks: Buffer[] = []; + for await (const chunk of response.body) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks); +} + +/** + * 清理单个会话的所有制品 + */ +export async function cleanSessionArtifacts(sessionId: string): Promise<{ deletedCount: number }> { + const prefix = `agent-sessions/${sessionId}/`; + const bucket = new S3PrivateBucket(); + + const { keys: failedKeys } = await bucket.client.deleteObjectsByPrefix({ prefix }); + + // deleteObjectsByPrefix 不返回实际删除数量,以 0 失败 key 数为成功标志 + return { deletedCount: failedKeys.length === 0 ? 1 : 0 }; +} + +/** + * 批量清理多个会话的制品 + */ +export async function cleanExpiredSessionArtifacts( + sessionIds: string[] +): Promise<{ deletedCount: number }> { + let totalDeleted = 0; + + for (const sessionId of sessionIds) { + const { deletedCount } = await cleanSessionArtifacts(sessionId); + totalDeleted += deletedCount; + } + + return { deletedCount: totalDeleted }; +} diff --git a/packages/service/core/agentSkills/utils.ts b/packages/service/core/agentSkills/utils.ts new file mode 100644 index 0000000000..a3df2bf817 --- /dev/null +++ b/packages/service/core/agentSkills/utils.ts @@ -0,0 +1,297 @@ +import type { SkillPackageType } from '@fastgpt/global/core/agentSkills/type'; +import { AgentSkillCategoryEnum } from '@fastgpt/global/core/agentSkills/constants'; + +/** + * Parse YAML frontmatter from markdown content + * Returns { frontmatter: object, content: string } + */ +export function parseSkillMarkdown(markdown: string): { + frontmatter: Record; + content: string; + error?: string; +} { + // Check for YAML frontmatter delimited by --- + const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; + const match = markdown.match(frontmatterRegex); + + if (!match) { + return { + frontmatter: {}, + content: markdown, + error: 'SKILL.md must contain YAML frontmatter (delimited by ---)' + }; + } + + const yamlContent = match[1]; + const bodyContent = match[2]; + + try { + const frontmatter = parseYamlFrontmatter(yamlContent); + return { + frontmatter, + content: bodyContent + }; + } catch (error: any) { + return { + frontmatter: {}, + content: markdown, + error: `Failed to parse frontmatter: ${error.message}` + }; + } +} + +/** + * Simple YAML parser for frontmatter + * Handles simple key: value and nested objects + */ +function parseYamlFrontmatter(yaml: string): Record { + const result: Record = {}; + const lines = yaml.split('\n'); + let currentObj = result; + const stack: { key: string; obj: Record }[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Check for object nesting (metadata:) + if (trimmed.endsWith(':')) { + const key = trimmed.slice(0, -1).trim(); + currentObj[key] = {}; + stack.push({ key, obj: currentObj }); + currentObj = currentObj[key] as Record; + continue; + } + + // Parse key: value + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + // Handle different value types + if (value.startsWith('"') || value.startsWith("'")) { + // String literal + currentObj[key] = value.slice(1, -1); + } else if (value === 'true') { + currentObj[key] = true; + } else if (value === 'false') { + currentObj[key] = false; + } else if (!isNaN(Number(value)) && value !== '') { + currentObj[key] = Number(value); + } else if (value === 'null') { + currentObj[key] = null; + } else if (value.startsWith('[') && value.endsWith(']')) { + // Array + const arrayContent = value.slice(1, -1).trim(); + currentObj[key] = arrayContent + ? arrayContent.split(',').map((item) => item.trim().replace(/["']/g, '')) + : []; + } else { + // // Plain string + currentObj[key] = value; + } + } + + return result; +} + +/** + * Extract skill metadata from SKILL.md frontmatter + * Returns FastGPT skill object format + */ +export function extractSkillFromMarkdown(markdown: string): { skill: any; error?: string } { + const { frontmatter, content, error } = parseSkillMarkdown(markdown); + + if (error) { + return { skill: null, error }; + } + + // Validate required fields + if (!frontmatter.name) { + return { skill: null, error: 'Frontmatter field "name" is required' }; + } + + if (!frontmatter.description) { + return { skill: null, error: 'Frontmatter field "description" is required' }; + } + + // Ensure name is always treated as a string (YAML parser may convert numeric names to number) + const skillName = String(frontmatter.name); + + // Validate name format (lowercase, numbers, hyphens only; no consecutive hyphens; no leading/trailing hyphens) + const nameRegex = /^[a-z0-9]([a-z0-9]|-(?!-))*[a-z0-9]$|^[a-z0-9]$/; + if (!nameRegex.test(skillName)) { + return { + skill: null, + error: + 'Name must contain only lowercase letters, numbers, and hyphens; cannot start/end with hyphen; no consecutive hyphens' + }; + } + + // Validate name length (max 64 per spec, but FastGPT uses 50) + if (skillName.length > 50) { + return { skill: null, error: 'Name must be less than 50 characters' }; + } + + // Truncate description if too long (max 500 characters) + const description = frontmatter.description.slice(0, 500); + + // Build FastGPT skill object + const skill: any = { + name: skillName, + description, + category: [AgentSkillCategoryEnum.other], // default + config: {} + }; + + // Map frontmatter fields to FastGPT format + if (frontmatter.license) { + skill.config.license = frontmatter.license; + } + + if (frontmatter.compatibility) { + skill.config.compatibility = frontmatter.compatibility; + } + + if (frontmatter['allowed-tools']) { + skill.config['allowed-tools'] = frontmatter['allowed-tools']; + } + + // Map metadata fields to config + if (frontmatter.metadata && typeof frontmatter.metadata === 'object') { + const metadata = frontmatter.metadata as Record; + + // category from metadata + if (metadata.category) { + if (Array.isArray(metadata.category)) { + skill.category = metadata.category; + } else if (typeof metadata.category === 'string') { + skill.category = metadata.category.split(',').map((c) => c.trim()); + } + + // Validate categories + const validCategories = Object.values(AgentSkillCategoryEnum); + const invalidCategories = skill.category.filter( + (c: string) => !validCategories.includes(c as AgentSkillCategoryEnum) + ); + + if (invalidCategories.length > 0) { + skill.category = ['other']; // fallback to default + } + } + + // Copy other metadata to config + for (const [key, value] of Object.entries(metadata)) { + if (key !== 'category') { + skill.config[key] = value; + } + } + } + + return { skill }; +} + +/** + * Validate skill package structure + */ +export function validateSkillPackage(data: any): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') { + return { valid: false, error: 'Invalid package format' }; + } + + const { skill } = data; + + // Check required fields + if (!skill || typeof skill !== 'object') { + return { valid: false, error: 'Missing skill metadata' }; + } + + if (!skill.name || typeof skill.name !== 'string' || skill.name.trim().length === 0) { + return { valid: false, error: 'Skill name is required' }; + } + + if (skill.name.length > 50) { + return { valid: false, error: 'Skill name must be less than 50 characters' }; + } + + // Validate description length + if (skill.description && skill.description.length > 500) { + return { valid: false, error: 'Description must be less than 500 characters' }; + } + + // Validate category + if (skill.category) { + if (!Array.isArray(skill.category)) { + return { valid: false, error: 'Category must be an array' }; + } + + const validCategories = Object.values(AgentSkillCategoryEnum); + const invalidCategories = skill.category.filter( + (c: string) => !validCategories.includes(c as AgentSkillCategoryEnum) + ); + + if (invalidCategories.length > 0) { + return { valid: false, error: `Invalid categories: ${invalidCategories.join(', ')}` }; + } + } + + // Validate config + if (skill.config && typeof skill.config !== 'object') { + return { valid: false, error: 'Config must be an object' }; + } + + return { valid: true }; +} + +/** + * Parse skill package from JSON string or object + */ +export function parseSkillPackage(data: string | object): { + success: boolean; + package?: SkillPackageType; + error?: string; +} { + try { + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + + const validation = validateSkillPackage(parsed); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + return { + success: true, + package: parsed as SkillPackageType + }; + } catch (error) { + return { success: false, error: 'Failed to parse skill package: ' + (error as Error).message }; + } +} + +/** + * Sanitize skill name for file system + */ +export function sanitizeSkillName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]/g, '_') // Allow Chinese characters + .replace(/_+/g, '_') + .substring(0, 50); +} + +/** + * Create default skill package template + */ +export function createSkillTemplate(name: string): SkillPackageType { + return { + skill: { + name: name || 'New Skill', + description: 'Enter a description for your skill', + category: [AgentSkillCategoryEnum.other], + config: {} + } + }; +} diff --git a/packages/service/core/agentSkills/version/controller.ts b/packages/service/core/agentSkills/version/controller.ts new file mode 100644 index 0000000000..3f2c710cf6 --- /dev/null +++ b/packages/service/core/agentSkills/version/controller.ts @@ -0,0 +1,242 @@ +/** + * Skill Version Controller + * + * Provides CRUD operations for skill version management. + */ + +import { MongoAgentSkillsVersion } from './schema'; +import type { ClientSession } from '../../../common/mongo'; +import type { AgentSkillsVersionSchemaType } from '@fastgpt/global/core/agentSkills/type'; + +export type CreateVersionData = { + skillId: string; + tmbId: string; + version: number; + versionName?: string; + storage: { + bucket: string; + key: string; + size: number; + checksum?: string; + }; + importSource?: { + originalFilename: string; + importedAt: Date; + }; +}; + +export type UpdateVersionData = Partial>; + +/** + * Create a new skill version + */ +export async function createVersion( + data: CreateVersionData, + session?: ClientSession +): Promise { + const version = new MongoAgentSkillsVersion({ + ...data, + isActive: true, + isDeleted: false, + createdAt: new Date() + }); + await version.save({ session }); + + return version._id.toString(); +} + +/** + * Get the next version number for a skill. + * Should be called inside a transaction session to avoid version number races. + */ +export async function getNextVersionNumber( + skillId: string, + session?: ClientSession +): Promise { + const lastVersion = await MongoAgentSkillsVersion.findOne( + { skillId, isDeleted: false }, + { version: 1 }, + { sort: { version: -1 }, session } + ).lean(); + + return (lastVersion?.version ?? -1) + 1; +} + +/** + * Get a specific version by skillId and version number + */ +export async function getVersionBySkillIdAndVersion( + skillId: string, + version: number +): Promise { + const versionDoc = await MongoAgentSkillsVersion.findOne({ + skillId, + version, + isDeleted: false + }).lean(); + + return versionDoc as AgentSkillsVersionSchemaType | null; +} + +/** + * Get the active version for a skill + */ +export async function getActiveVersion( + skillId: string +): Promise { + const version = await MongoAgentSkillsVersion.findOne({ + skillId, + isActive: true, + isDeleted: false + }).lean(); + + return version as AgentSkillsVersionSchemaType | null; +} + +/** + * List all versions for a skill + */ +export async function listVersions( + skillId: string, + options?: { + includeDeleted?: boolean; + sort?: 'asc' | 'desc'; + } +): Promise { + const query: Record = { skillId }; + + if (!options?.includeDeleted) { + query.isDeleted = false; + } + + const sortOrder = options?.sort === 'asc' ? 1 : -1; + + const versions = await MongoAgentSkillsVersion.find(query).sort({ version: sortOrder }).lean(); + + return versions as AgentSkillsVersionSchemaType[]; +} + +/** + * Set a version as the active version for a skill + */ +export async function setActiveVersion( + skillId: string, + version: number, + session?: ClientSession +): Promise { + // First, deactivate all versions for this skill + await MongoAgentSkillsVersion.updateMany( + { skillId, isDeleted: false }, + { $set: { isActive: false } }, + { session } + ); + + // Then, activate the specified version + const result = await MongoAgentSkillsVersion.updateOne( + { skillId, version, isDeleted: false }, + { $set: { isActive: true } }, + { session } + ); + + if (result.matchedCount === 0) { + throw new Error(`Version ${version} not found for skill ${skillId}`); + } +} + +/** + * Soft delete a version. + * Active versions cannot be deleted — deactivate or switch to another version first. + */ +export async function deleteVersion( + skillId: string, + version: number, + session?: ClientSession +): Promise { + const versionDoc = await MongoAgentSkillsVersion.findOne({ + skillId, + version, + isDeleted: false + }); + + if (!versionDoc) { + throw new Error(`Version ${version} not found for skill ${skillId}`); + } + + // Refuse to delete the currently active version to prevent data orphaning + if (versionDoc.isActive) { + throw new Error( + `Cannot delete active version ${version}. Switch to another version before deleting.` + ); + } + + await MongoAgentSkillsVersion.updateOne( + { skillId, version }, + { $set: { isDeleted: true } }, + { session } + ); +} + +/** + * Restore a deleted version. + * The restored version is set back to isDeleted=false but remains inactive (isActive=false). + * Call setActiveVersion explicitly if you want to make it the active version. + */ +export async function restoreVersion( + skillId: string, + version: number, + session?: ClientSession +): Promise { + const versionDoc = await MongoAgentSkillsVersion.findOne({ + skillId, + version, + isDeleted: true + }); + + if (!versionDoc) { + throw new Error(`Deleted version ${version} not found for skill ${skillId}`); + } + + await MongoAgentSkillsVersion.updateOne( + { skillId, version }, + { $set: { isDeleted: false } }, + { session } + ); +} + +/** + * Update version metadata + */ +export async function updateVersion( + skillId: string, + version: number, + data: Partial<{ + versionName: string; + }>, + session?: ClientSession +): Promise { + const result = await MongoAgentSkillsVersion.updateOne( + { skillId, version, isDeleted: false }, + { $set: data }, + { session } + ); + + if (result.matchedCount === 0) { + throw new Error(`Version ${version} not found for skill ${skillId}`); + } +} + +/** + * Count versions for a skill + */ +export async function countVersions( + skillId: string, + options?: { includeDeleted?: boolean } +): Promise { + const query: Record = { skillId }; + + if (!options?.includeDeleted) { + query.isDeleted = false; + } + + return MongoAgentSkillsVersion.countDocuments(query); +} diff --git a/packages/service/core/agentSkills/version/schema.ts b/packages/service/core/agentSkills/version/schema.ts new file mode 100644 index 0000000000..7b49a508cf --- /dev/null +++ b/packages/service/core/agentSkills/version/schema.ts @@ -0,0 +1,89 @@ +/** + * Skill Version Schema + * + * Defines the database schema for skill versions. + */ + +import { connectionMongo, getMongoModel } from '../../../common/mongo'; +import { + agentSkillsCollectionName, + agentSkillsVersionCollectionName +} from '@fastgpt/global/core/agentSkills/constants'; +import type { AgentSkillsVersionSchemaType } from '@fastgpt/global/core/agentSkills/type'; + +const { Schema } = connectionMongo; + +const AgentSkillsVersionSchema = new Schema({ + skillId: { + type: Schema.Types.ObjectId, + ref: agentSkillsCollectionName, + required: true + }, + tmbId: { + type: Schema.Types.ObjectId, + ref: 'team_members', + required: true + }, + version: { + type: Number, + required: true + }, + versionName: { + type: String, + default: '' + }, + // Storage information + storage: { + bucket: { + type: String, + required: true + }, + key: { + type: String, + required: true + }, + size: { + type: Number, + required: true + }, + checksum: { + type: String + } + }, + // Import source (optional) + importSource: { + originalFilename: String, + importedAt: Date + }, + isActive: { + type: Boolean, + default: false + }, + isDeleted: { + type: Boolean, + default: false + }, + createdAt: { + type: Date, + default: () => new Date() + } +}); + +// Create indexes +try { + // Index for listing versions of a skill + AgentSkillsVersionSchema.index({ skillId: 1, isDeleted: 1, version: -1 }); + + // Index for finding active version + AgentSkillsVersionSchema.index({ skillId: 1, isActive: 1 }); + + // Unique index to prevent duplicate versions + AgentSkillsVersionSchema.index({ skillId: 1, version: 1 }, { unique: true }); +} catch (error) { + console.log('SkillVersion index error:', error); +} + +export const MongoAgentSkillsVersion = getMongoModel( + agentSkillsVersionCollectionName, + AgentSkillsVersionSchema +); diff --git a/packages/service/core/agentSkills/zipBuilder.ts b/packages/service/core/agentSkills/zipBuilder.ts new file mode 100644 index 0000000000..90bda2c258 --- /dev/null +++ b/packages/service/core/agentSkills/zipBuilder.ts @@ -0,0 +1,296 @@ +/** + * ZIP Builder for Skill Packages + * + * This module provides utilities for creating and extracting ZIP archives + * for skill packages following the Agent Skills specification. + * + * ZIP structure (multi-skill): + * package.zip/ + * ├── skill-1/SKILL.md + * ├── skill-2/SKILL.md + * └── skill-3/SKILL.md + * + * Each top-level subdirectory that contains a SKILL.md is treated as one agent skill. + */ + +import JSZip from 'jszip'; + +// Re-export JSZip for test files that need direct access +export { JSZip }; + +export type CreateSkillPackageParams = { + name: string; + skillMd: string; + assets?: Record; + additionalFiles?: Record; +}; + +// Info about a single skill directory discovered in a ZIP +export type SkillDirInfo = { + dirName: string; // top-level directory name inside the ZIP + name: string; // from SKILL.md frontmatter + description: string; // from SKILL.md frontmatter + skillMdContent: string; // raw SKILL.md text +}; + +export type ZipValidationResult = { + valid: boolean; + hasSkillMd: boolean; + files: string[]; + error?: string; + skillMdPath?: string; +}; + +export type ExtractSkillPackageResult = { + success: boolean; + skillMd?: string; + assets?: Record; + error?: string; +}; + +/** + * Create a skill package ZIP archive + */ +export async function createSkillPackage(params: CreateSkillPackageParams): Promise { + const { name, skillMd, assets, additionalFiles } = params; + const zip = new JSZip(); + + // Root directory name (default to skill name) + const rootDir = name.replace(/\/+$/, '').trim(); + + // Add root directory explicitly + zip.folder(rootDir); + + // Add SKILL.md (required) + zip.file(`${rootDir}/SKILL.md`, skillMd); + + // Add assets (optional) + if (assets) { + Object.entries(assets).forEach(([path, content]) => { + addFileToZip(zip, `${rootDir}/${path}`, content); + }); + } + + // Add additional files (optional) + if (additionalFiles) { + Object.entries(additionalFiles).forEach(([path, content]) => { + addFileToZip(zip, `${rootDir}/${path}`, content); + }); + } + + // Generate ZIP buffer + return generateZipBuffer(zip); +} + +/** + * Add a file to the ZIP archive + */ +export function addFileToZip( + zip: JSZip, + path: string, + content: Buffer | string | Uint8Array +): void { + // Normalize path + const normalizedPath = path.replace(/^\/+/, ''); + + if (content instanceof Buffer) { + zip.file(normalizedPath, content); + } else if (content instanceof Uint8Array) { + zip.file(normalizedPath, Buffer.from(content)); + } else { + zip.file(normalizedPath, content); + } +} + +/** + * Generate ZIP buffer from JSZip instance + */ +export async function generateZipBuffer(zip: JSZip): Promise { + return zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { + level: 6 // Default compression level + } + }); +} + +/** + * Validate ZIP structure for skill package. + * + * Accepts both legacy single-skill ZIPs (one SKILL.md) and + * multi-skill ZIPs (multiple top-level directories each containing a SKILL.md). + */ +export async function validateZipStructure(zipBuffer: Buffer): Promise { + try { + const zip = await JSZip.loadAsync(zipBuffer); + const files = Object.keys(zip.files); + + if (files.length === 0) { + return { + valid: false, + hasSkillMd: false, + files, + error: 'ZIP archive is empty' + }; + } + + // Check for SKILL.md at root + let skillMdPath = files.find((f) => f.toUpperCase() === 'SKILL.MD'); + + // Check for SKILL.md exactly one level deep (single-skill or first skill in multi-skill ZIP) + if (!skillMdPath) { + skillMdPath = files.find( + (f) => f.toUpperCase().endsWith('/SKILL.MD') && f.split('/').length === 2 + ); + } + + if (!skillMdPath) { + return { + valid: false, + hasSkillMd: false, + files, + error: 'Missing required file: SKILL.md (expected at root or inside a top-level directory)' + }; + } + + return { + valid: true, + hasSkillMd: true, + files, + skillMdPath + }; + } catch (error) { + return { + valid: false, + hasSkillMd: false, + files: [], + error: `Invalid ZIP archive: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} + +/** + * Extract skill package from ZIP buffer + */ +export async function extractSkillPackage(zipBuffer: Buffer): Promise { + try { + // First validate the zip structure + const validation = await validateZipStructure(zipBuffer); + if (!validation.valid || !validation.skillMdPath) { + return { + success: false, + error: validation.error + }; + } + + const zip = await JSZip.loadAsync(zipBuffer); + const assets: Record = {}; + const skillMdPath = validation.skillMdPath; + + // Determine root prefix if SKILL.md is in a subfolder + const rootPrefix = skillMdPath.includes('/') + ? skillMdPath.substring(0, skillMdPath.lastIndexOf('/') + 1) + : ''; + + // Extract SKILL.md + const skillMdFile = zip.file(skillMdPath); + if (!skillMdFile) { + return { + success: false, + error: 'SKILL.md not found in ZIP archive' + }; + } + const skillMd = await skillMdFile.async('string'); + + // Extract assets (all files except SKILL.md) + const filePromises = Object.entries(zip.files) + .filter(([path, file]) => !file.dir && path !== skillMdPath) + .map(async ([path, file]) => { + const content = await file.async('nodebuffer'); + // Strip root prefix from asset path if it exists + const assetPath = + rootPrefix && path.startsWith(rootPrefix) ? path.slice(rootPrefix.length) : path; + assets[assetPath] = content; + }); + + await Promise.all(filePromises); + + return { + success: true, + skillMd, + assets + }; + } catch (error) { + return { + success: false, + error: `Failed to extract skill package: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} + +/** + * Standardize a skill package ZIP buffer to ensure it has a root folder named after the skill + */ +export async function standardizeSkillPackage( + zipBuffer: Buffer, + name: string +): Promise<{ buffer: Buffer; skillMd: string; assets: Record }> { + const extractResult = await extractSkillPackage(zipBuffer); + + if (!extractResult.success || !extractResult.skillMd) { + throw new Error(extractResult.error || 'Invalid skill package'); + } + + const { skillMd, assets = {} } = extractResult; + + const standardizedBuffer = await createSkillPackage({ + name, + skillMd, + assets + }); + + return { + buffer: standardizedBuffer, + skillMd, + assets + }; +} + +/** + * Repack a file map as a ZIP buffer, preserving the original directory structure. + * All entries in fileMap are added as-is; directory entries are not needed. + */ +export async function repackFileMapAsZip(fileMap: Record): Promise { + const zip = new JSZip(); + for (const [path, content] of Object.entries(fileMap)) { + zip.file(path, content); + } + return generateZipBuffer(zip); +} + +/** + * Get file list from ZIP buffer + */ +export async function getZipFileList(zipBuffer: Buffer): Promise { + try { + const zip = await JSZip.loadAsync(zipBuffer); + return Object.keys(zip.files).filter((path) => !zip.files[path].dir); + } catch { + return []; + } +} + +/** + * Read a specific file from ZIP buffer + */ +export async function readFileFromZip(zipBuffer: Buffer, filePath: string): Promise { + try { + const zip = await JSZip.loadAsync(zipBuffer); + const file = zip.file(filePath); + if (!file) return null; + return file.async('nodebuffer'); + } catch { + return null; + } +} diff --git a/packages/service/core/ai/sandbox/controller.ts b/packages/service/core/ai/sandbox/controller.ts index 18868ed445..9c0837b5a8 100644 --- a/packages/service/core/ai/sandbox/controller.ts +++ b/packages/service/core/ai/sandbox/controller.ts @@ -109,7 +109,6 @@ export class SandboxClient { } }), metadata: { - sessionKey: this.sandboxId, volumeEnabled: !!this.opts?.vmConfig }, createdAt: new Date() @@ -158,7 +157,7 @@ export class SandboxClient { await this.provider.stop(); await MongoSandboxInstance.updateOne( { sandboxId: this.sandboxId }, - { $set: { status: SandboxStatusEnum.stoped } } + { $set: { status: SandboxStatusEnum.stopped } } ); } } diff --git a/packages/service/core/ai/sandbox/schema.ts b/packages/service/core/ai/sandbox/schema.ts index f84099fb94..4614f3b62a 100644 --- a/packages/service/core/ai/sandbox/schema.ts +++ b/packages/service/core/ai/sandbox/schema.ts @@ -2,7 +2,7 @@ import { connectionMongo, getMongoModel } from '../../../common/mongo'; const { Schema } = connectionMongo; import type { SandboxInstanceSchemaType } from './type'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; -import { AppCollectionName } from '../../app/schema'; +import { SandboxProtocolEnum, SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; import { SandboxLimitSchema, SandboxProviderSchema } from './type'; export const collectionName = 'agent_sandbox_instances'; @@ -18,11 +18,8 @@ const SandboxInstanceSchema = new Schema({ type: String, required: true }, - // Chat 模式下会关联会话。Skill editor 不需要 appId,userId,chatId - appId: { - type: Schema.Types.ObjectId, - ref: AppCollectionName - }, + // Chat 模式和 skill sandbox 都会复用这组根字段。 + appId: String, userId: String, chatId: String, @@ -66,6 +63,19 @@ SandboxInstanceSchema.index( ); SandboxInstanceSchema.index({ status: 1, lastActiveAt: 1 }); SandboxInstanceSchema.index({ provider: 1, sandboxId: 1 }, { unique: true }); +SandboxInstanceSchema.index( + { appId: 1, chatId: 1 }, + { + unique: true, + partialFilterExpression: { + appId: { $exists: true }, + chatId: { $exists: true }, + 'metadata.sandboxType': { $exists: true } + } + } +); +SandboxInstanceSchema.index({ 'metadata.skillId': 1 }); +SandboxInstanceSchema.index({ 'metadata.sandboxType': 1, chatId: 1 }); export const MongoSandboxInstance = getMongoModel( collectionName, diff --git a/packages/service/core/ai/sandbox/type.ts b/packages/service/core/ai/sandbox/type.ts index cd39156560..e2b0e93503 100644 --- a/packages/service/core/ai/sandbox/type.ts +++ b/packages/service/core/ai/sandbox/type.ts @@ -1,9 +1,12 @@ import z from 'zod'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; +import { SandboxProtocolEnum, SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; // ---- 沙盒实例 DB 类型 ---- export const SandboxProviderSchema = z.enum(['sealosdevbox', 'opensandbox', 'e2b']); export type SandboxProviderType = z.infer; +export const SharedSandboxStatusSchema = z.enum(SandboxStatusEnum); +export type SharedSandboxStatusType = z.infer; export const SandboxLimitSchema = z.object({ cpuCount: z.number(), @@ -24,9 +27,30 @@ export const SandboxStorageSchema = z.object({ }); export type SandboxStorageType = z.infer; +export const SandboxImageSchema = z.object({ + repository: z.string(), + tag: z.string().optional() +}); + +export const SandboxEndpointSchema = z.object({ + host: z.string(), + port: z.number(), + protocol: z.enum(SandboxProtocolEnum), + url: z.string() +}); + export const SandboxMetadataSchema = z.object({ - sessionKey: z.string().optional(), - volumeEnabled: z.boolean().optional() + sandboxType: z.enum(SandboxTypeEnum), + teamId: z.string().optional(), + tmbId: z.string().optional(), + + volumeEnabled: z.boolean().optional(), + + skillId: z.string().optional(), + sessionId: z.string().optional(), + skillIds: z.array(z.string()).optional(), + image: SandboxImageSchema, + endpoint: SandboxEndpointSchema.optional() }); export type SandboxMetadataType = z.infer; @@ -36,7 +60,7 @@ export const SandboxInstanceZodSchema = z.object({ appId: z.string().nullish(), userId: z.string().nullish(), chatId: z.string().nullish(), - status: z.enum(SandboxStatusEnum), + status: SharedSandboxStatusSchema, lastActiveAt: z.date(), createdAt: z.date(), limit: SandboxLimitSchema.nullish(), diff --git a/packages/service/core/app/controller.ts b/packages/service/core/app/controller.ts index 353cd652d2..8248cf6ac4 100644 --- a/packages/service/core/app/controller.ts +++ b/packages/service/core/app/controller.ts @@ -30,7 +30,7 @@ import { MongoMcpKey } from '../../support/mcp/schema'; import { MongoAppRecord } from './record/schema'; import { mongoSessionRun } from '../../common/mongo/sessionRun'; import { getLogger, LogCategories } from '../../common/logger'; -import { deleteSandboxesByAppId } from '../ai/sandbox/controller'; +import { deleteSandboxesByAppId, deleteSandboxesByChatIds } from '../ai/sandbox/controller'; const logger = getLogger(LogCategories.MODULE.APP.FOLDER); @@ -156,7 +156,16 @@ export const deleteAppDataProcessor = async ({ // 2. 删除聊天记录和S3文件 // 删除沙盒实例 - await deleteSandboxesByAppId(appId); + { + // 对话生成的 + await deleteSandboxesByAppId(appId); + // 编辑 skill 生成的 + const appChatIds = (await MongoChat.find({ appId }, { _id: 1 }).lean()).map((c) => + String(c._id) + ); + await deleteSandboxesByChatIds({ appId, chatIds: appChatIds }); + } + await getS3ChatSource().deleteChatFilesByPrefix({ appId }); await MongoAppChatLog.deleteMany({ teamId, appId }); await MongoChatItemResponse.deleteMany({ appId }); diff --git a/packages/service/core/app/delete/index.ts b/packages/service/core/app/delete/index.ts index 04efc61c09..a78cdc999b 100644 --- a/packages/service/core/app/delete/index.ts +++ b/packages/service/core/app/delete/index.ts @@ -32,7 +32,7 @@ export const addAppDeleteJob = (data: AppDeleteJobData) => { } }); - const jobId = `${String(data.teamId)}:${String(data.appId)}`; + const jobId = `${String(data.teamId)}-${String(data.appId)}`; // Use jobId to automatically prevent duplicate deletion tasks (BullMQ feature) return appDeleteQueue.add('delete_app', data, { diff --git a/packages/service/core/dataset/delete/index.ts b/packages/service/core/dataset/delete/index.ts index aa4000a20a..7a6cbf3dac 100644 --- a/packages/service/core/dataset/delete/index.ts +++ b/packages/service/core/dataset/delete/index.ts @@ -32,7 +32,7 @@ export const addDatasetDeleteJob = (data: DatasetDeleteJobData) => { } }); - const jobId = `${String(data.teamId)}:${String(data.datasetId)}`; + const jobId = `${String(data.teamId)}-${String(data.datasetId)}`; // 使用去重机制,避免重复删除 return datasetDeleteQueue.add('delete_dataset', data, { diff --git a/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts b/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts new file mode 100644 index 0000000000..49cc9d33f6 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts @@ -0,0 +1,485 @@ +import path from 'path'; +import type { AgentCapability } from './type'; +import { + allSandboxTools, + SandboxToolIds, + SandboxReadFileSchema, + SandboxWriteFileSchema, + SandboxEditFileSchema, + SandboxExecuteSchema, + SandboxSearchSchema, + SandboxFetchUserFileSchema +} from '@fastgpt/global/core/workflow/node/agent/skillTools'; +import { + createAgentSandbox, + releaseAgentSandbox, + connectEditDebugSandbox, + disconnectEditDebugSandbox +} from '../sub/sandbox'; +import { buildSkillsContextPrompt } from '../sub/sandbox/prompt'; +import { parseJsonArgs } from '../../../../../ai/utils'; +import { + dispatchSandboxReadFile, + dispatchSandboxWriteFile, + dispatchSandboxEditFile, + dispatchSandboxExecute, + dispatchSandboxSearch, + dispatchSandboxFetchUserFile +} from '../sub/sandbox/skill'; +import { getLogger, LogCategories } from '../../../../../../common/logger'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import type { WorkflowResponseType } from '../../../type'; +import type { + AIChatItemValueItemType, + SandboxStatusItemType +} from '@fastgpt/global/core/chat/type'; +import type { AgentSandboxContext, DeployedSkillInfo } from '../sub/sandbox/types'; +import { MongoAgentSkills } from '../../../../../agentSkills/schema'; +import { MongoSandboxInstance } from '../../../../../ai/sandbox/schema'; +import { getSandboxDefaults } from '../../../../../agentSkills/sandboxConfig'; +import { downloadSkillPackage } from '../../../../../agentSkills/storage'; +import { extractSkillMdInfoFromBuffer } from '../../../../../agentSkills/archiveUtils'; +import { parseSkillMarkdown } from '../../../../../agentSkills/utils'; + +type SandboxToolResult = { + response: string; + usages: any[]; + assistantResponses?: AIChatItemValueItemType[]; +}; + +type SandboxSkillsCapabilityParams = { + skillIds: string[]; + teamId: string; + tmbId: string; + sessionId: string; + mode: 'sessionRuntime' | 'editDebug'; + workflowStreamResponse?: WorkflowResponseType; // SSE stream for lifecycle and skill events + showSkillReferences: boolean; + allFilesMap: Record; +}; + +/** Fetch skill metadata from MongoDB and compute sandbox paths from the ZIP for prompt construction. */ +async function fetchSkillsMetaForPrompt( + skillIds: string[], + teamId: string, + workDirectory: string +): Promise { + const skills = await MongoAgentSkills.find( + { _id: { $in: skillIds }, teamId, deleteTime: null }, + { name: 1, description: 1, avatar: 1, currentStorage: 1 } + ).lean(); + + const results = await Promise.allSettled( + skills.map(async (skill) => { + const fallback: DeployedSkillInfo = { + id: String(skill._id), + name: skill.name, + description: skill.description ?? '', + avatar: skill.avatar, + skillMdPath: '', + directory: '' + }; + + if (!skill.currentStorage) return fallback; + + try { + const buffer = await downloadSkillPackage({ storageInfo: skill.currentStorage }); + const info = await extractSkillMdInfoFromBuffer(buffer); + if (!info) return fallback; + + const { frontmatter } = parseSkillMarkdown(info.content); + const skillMdPath = `${workDirectory}/${info.relativePath}`; + return { + id: fallback.id, + name: frontmatter.name ? String(frontmatter.name) : fallback.name, + description: frontmatter.description + ? String(frontmatter.description) + : fallback.description, + avatar: fallback.avatar, + skillMdPath, + directory: path.dirname(skillMdPath) + }; + } catch { + return fallback; + } + }) + ); + + return results.map((r, i) => + r.status === 'fulfilled' + ? r.value + : { + id: String(skills[i]._id), + name: skills[i].name, + description: skills[i].description ?? '', + avatar: skills[i].avatar, + skillMdPath: '', + directory: '' + } + ); +} + +/** Check whether an error indicates a sandbox that no longer exists or is unreachable. */ +export function isSandboxExpiredError(err: unknown): boolean { + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + return ( + msg.includes('not found') || + msg.includes('not exist') || + msg.includes('connection') || + msg.includes('sandbox_not_found') || + msg.includes('econnrefused') || + msg.includes('econnreset') + ); + } + return false; +} + +export function collectSkillReferenceResponses({ + paths, + sandboxContext, + workflowStreamResponse, + showSkillReferences, + toolCallId +}: { + paths: string[]; + sandboxContext: AgentSandboxContext; + workflowStreamResponse?: WorkflowResponseType; + showSkillReferences: boolean; + toolCallId: string; +}): AIChatItemValueItemType[] { + if (!showSkillReferences) return []; + + const skillResponses: AIChatItemValueItemType[] = []; + for (const filePath of paths) { + if (!filePath.endsWith('/SKILL.md')) continue; + + const skill = sandboxContext.deployedSkills.find( + (s) => s.skillMdPath === filePath || filePath.startsWith(s.directory + '/') + ); + if (!skill) continue; + + // Use toolCallId from the triggering tool call for correlation + workflowStreamResponse?.({ + id: toolCallId, + event: SseResponseEventEnum.skillCall, + data: { + skill: { + id: toolCallId, + skillName: skill.name, + skillAvatar: skill.avatar || '', + description: skill.description, + skillMdPath: filePath + } + } + }); + + skillResponses.push({ + skills: [ + { + id: toolCallId, + skillName: skill.name, + skillAvatar: skill.avatar || '', + description: skill.description, + skillMdPath: filePath + } + ] + }); + } + return skillResponses; +} + +export async function createSandboxSkillsCapability( + params: SandboxSkillsCapabilityParams +): Promise { + const { + skillIds, + teamId, + tmbId, + sessionId, + mode, + workflowStreamResponse, + showSkillReferences, + allFilesMap + } = params; + const isEditDebug = mode === 'editDebug'; + 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); + + // --- Lazy-init state --- + let sandboxContext: AgentSandboxContext | null = null; + let initPromise: Promise | null = null; + + const onProgress = workflowStreamResponse + ? (status: SandboxStatusItemType) => + workflowStreamResponse({ event: SseResponseEventEnum.sandboxStatus, data: status }) + : undefined; + + async function initializeSandbox(): Promise { + onProgress?.({ sandboxId: sessionId, phase: 'lazyInit' }); + return createAgentSandbox({ skillIds, teamId, tmbId, sessionId, onProgress }); + } + + async function ensureSandbox(): Promise { + if (sandboxContext) return sandboxContext; + if (!initPromise) { + initPromise = initializeSandbox() + .then((ctx) => { + sandboxContext = ctx; + initPromise = null; + return ctx; + }) + .catch((err) => { + initPromise = null; + throw err; + }); + } + return initPromise; + } + + async function executeWithRetry( + executor: (ctx: AgentSandboxContext) => Promise + ): Promise { + let ctx: AgentSandboxContext; + try { + ctx = await ensureSandbox(); + } catch (err) { + return { + response: `Sandbox initialization failed: ${(err as Error).message}`, + usages: [] + }; + } + + let result: SandboxToolResult; + try { + result = await executor(ctx); + } catch (err) { + if (!isSandboxExpiredError(err)) throw err; + + // Silent rebuild: clear state and retry once + sandboxContext = null; + try { + ctx = await ensureSandbox(); + result = await executor(ctx); + } catch (retryErr) { + return { + response: `Sandbox operation failed: ${(retryErr as Error).message}`, + usages: [] + }; + } + } + + // Fire-and-forget: renew sandbox expiration after successful execution + MongoSandboxInstance.updateOne( + { sandboxId: ctx.providerSandboxId }, + { lastActiveAt: new Date() } + ).catch((err) => logger.error('[Agent Sandbox] Failed to renew lastActiveAt', { error: err })); + + return result; + } + + return { + id: 'sandbox-skills', + systemPrompt, + completionTools: allSandboxTools, + handleToolCall: async (toolId, args, toolCallId) => { + if (!(Object.values(SandboxToolIds) as string[]).includes(toolId)) return null; + + return executeWithRetry(async (ctx) => { + return buildSessionHandler( + toolId, + args, + ctx, + allFilesMap, + workflowStreamResponse, + showSkillReferences, + toolCallId + ); + }); + }, + dispose: async () => { + if (sandboxContext) { + releaseAgentSandbox(sandboxContext).catch((err) => { + logger.error('[Agent Sandbox] Release failed', { error: err }); + }); + } + } + }; +} + +// --- Handler builders --- + +async function buildEditDebugHandler( + toolId: string, + args: string, + sandboxContext: AgentSandboxContext, + allFilesMap: Record, + workflowStreamResponse?: WorkflowResponseType, + showSkillReferences = false, + toolCallId = '' +): 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 handler = handlers[toolId]; + if (!handler) return null; + return handler(); +} + +async function buildSessionHandler( + toolId: string, + args: string, + sandboxContext: AgentSandboxContext, + allFilesMap: Record, + workflowStreamResponse?: WorkflowResponseType, + showSkillReferences = false, + toolCallId = '' +): 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 handler = handlers[toolId]; + if (!handler) return { response: 'Unknown sandbox tool', usages: [] }; + return handler(); +} diff --git a/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts b/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts new file mode 100644 index 0000000000..a6fdcbe167 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/capability/type.ts @@ -0,0 +1,50 @@ +import type { ChatCompletionTool } from '@fastgpt/global/core/ai/type'; +import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; + +export type CapabilityToolCallResult = { + response: string; + usages?: any[]; + assistantResponses?: AIChatItemValueItemType[]; +}; + +export type CapabilityToolCallHandlerType = ( + toolId: string, + args: string, + toolCallId: string +) => Promise; + +// Capability interface: each capability contributes system prompt, tools, tool handler, and cleanup +export type AgentCapability = { + id: string; + // Appended to the user's systemPrompt + systemPrompt?: string; + // Additional tool definitions + completionTools?: ChatCompletionTool[]; + // Tool call handler: return result if recognized, null otherwise + handleToolCall?: ( + toolId: string, + args: string, + toolCallId: string + ) => Promise; + // Resource cleanup (called in finally) + dispose?: () => Promise; +}; + +// Create a composite tool call handler that tries each capability in order +export function createCapabilityToolCallHandler( + capabilities: AgentCapability[] +): CapabilityToolCallHandlerType { + return async ( + toolId: string, + args: string, + toolCallId: string + ): Promise => { + for (const cap of capabilities) { + if (cap.handleToolCall) { + const result = await cap.handleToolCall(toolId, args, toolCallId); + if (result !== null) return result; + } + } + return null; + }; +} diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index a0acdc775a..61f4a92d3c 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -1,5 +1,4 @@ -import type { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; -import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum, SseResponseEventEnum @@ -32,12 +31,20 @@ import { formatFileInput } from './sub/file/utils'; import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; import { masterCall } from './master/call'; import type { SkillToolType } from '@fastgpt/global/core/ai/skill/type'; +import { + normalizeSkillIds, + type SelectedAgentSkillItemType +} from '@fastgpt/global/core/app/formEdit/type'; import { getSubapps } from './utils'; +import type { AgentCapability } from './capability/type'; +import { createCapabilityToolCallHandler } from './capability/type'; +import { createSandboxSkillsCapability } from './capability/sandboxSkills'; import { type AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import { getContinuePlanQuery, parseUserSystemPrompt } from './sub/plan/prompt'; import type { PlanAgentParamsType } from './sub/plan/constants'; import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; import { getLogger, LogCategories } from '../../../../../common/logger'; +import { env } from '../../../../../env'; export type DispatchAgentModuleProps = ModuleDispatchProps<{ [NodeInputKeyEnum.history]?: ChatItemType[]; @@ -49,6 +56,8 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{ [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']; @@ -83,6 +92,9 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise stream, workflowStreamResponse, usagePush, + mode, + chatId, + showSkillReferences, params: { model, systemPrompt, @@ -90,6 +102,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise history = 6, fileUrlList: fileLinks, agent_selectedTools: selectedTools = [], + skills: skillIds = [], + useEditDebugSandbox, // Dataset search configuration agent_datasetParams: datasetParams, // Sandbox (Computer Use) @@ -100,6 +114,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise const aiHistoryValues = chatHistories .filter((item) => item.obj === ChatRoleEnum.AI) .flatMap((item) => item.value); + // 规范化:兼容 string[](debugChat 路径)和 SelectedAgentSkillItemType[](workflow NodeAgent 路径) + const normalizedSkillIds = normalizeSkillIds(skillIds); const historiesMessages = chats2GPTMessages({ messages: chatHistories, reserveId: false, @@ -117,6 +133,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise const assistantResponses: AIChatItemValueItemType[] = []; const nodeResponses: ChatHistoryItemResType[] = []; + const capabilities: AgentCapability[] = []; try { // Get files @@ -124,11 +141,17 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise if (!fileUrlInput || !fileUrlInput.value || fileUrlInput.value.length === 0) { fileLinks = undefined; } - const { filesMap, prompt: fileInputPrompt } = formatFileInput({ + + const { + filesMap, + allFilesMap, + prompt: fileInputPrompt + } = formatFileInput({ fileUrls: fileLinks, requestOrigin, maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20, - histories: chatHistories + histories: chatHistories, + useSkill: skillIds.length > 0 }); // 交互模式进来的话,这个值才是交互输入的值 @@ -185,6 +208,36 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise : restoredMasterMessages; } })(); + // 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 sandboxCap = await createSandboxSkillsCapability({ + skillIds: normalizedSkillIds, + teamId: runningAppInfo.teamId, + tmbId: runningAppInfo.tmbId, + sessionId: sandboxSessionId, + mode: sandboxMode, + workflowStreamResponse, + showSkillReferences: showSkillReferences === true, + allFilesMap + }); + capabilities.push(sandboxCap); + } + + // Aggregate capability contributions + const capabilitySystemPrompt = capabilities + .map((c) => c.systemPrompt) + .filter(Boolean) + .join('\n\n'); + // TODO: 看看要不要和 getSubapps 合并 + const capabilityTools = capabilities.flatMap((c) => c.completionTools ?? []); + const capabilityToolCallHandler = + capabilities.length > 0 ? createCapabilityToolCallHandler(capabilities) : undefined; // Get sub apps const { completionTools: agentCompletionTools, subAppsMap: agentSubAppsMap } = await getSubapps( @@ -195,7 +248,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise getPlanTool: true, hasDataset: datasetParams && datasetParams.datasets.length > 0, hasFiles: !!chatConfig?.fileSelectConfig?.canSelectFile, - useAgentSandbox: useAgentSandbox && !!global.feConfigs?.show_agent_sandbox + useAgentSandbox: useAgentSandbox && !!global.feConfigs?.show_agent_sandbox, + extraTools: capabilityTools } ); @@ -227,7 +281,9 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise }; const formatedSystemPrompt = parseUserSystemPrompt({ - userSystemPrompt: systemPrompt, + userSystemPrompt: capabilitySystemPrompt + ? `${systemPrompt || ''}\n\n${capabilitySystemPrompt}`.trim() + : systemPrompt, selectedDataset: datasetParams?.datasets }); @@ -414,7 +470,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise completionTools: agentCompletionTools, steps: agentPlan.steps, // 传入所有步骤,而不仅仅是未执行的步骤 step, - filesMap + filesMap, + capabilityToolCallHandler }); nodeResponses.push(result.nodeResponse); @@ -432,6 +489,14 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise stepId: step.id })); assistantResponses.push(...assistantResponse); + if (result.capabilityAssistantResponses?.length) { + assistantResponses.push( + ...result.capabilityAssistantResponses.map((item) => ({ + ...item, + stepId: step.id + })) + ); + } step.response = result.stepResponse?.rawResponse; step.summary = result.stepResponse?.summary; @@ -488,7 +553,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise getSubAppInfo, getSubApp, completionTools: agentCompletionTools, - filesMap + filesMap, + capabilityToolCallHandler }); nodeResponses.push(result.nodeResponse); masterMessages = result.masterMessages; @@ -502,6 +568,9 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise .map((item) => item.value as AIChatItemValueItemType[]) .flat(); assistantResponses.push(...assistantResponse); + if (result.capabilityAssistantResponses?.length) { + assistantResponses.push(...result.capabilityAssistantResponses); + } // 触发了 plan if (result.planResponse) { @@ -538,7 +607,15 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise } // 任务结束 + const answerText = assistantResponses + .filter((item) => item.text?.content) + .map((item) => item.text!.content) + .join(''); + return { + data: { + [NodeOutputKeyEnum.answerText]: answerText + }, [DispatchNodeResponseKeyEnum.memories]: { [masterMessagesKey]: undefined, [agentPlanKey]: undefined, @@ -549,6 +626,13 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses }; } catch (error) { + getLogger(LogCategories.MODULE.AI.AGENT).error(`[Agent Debug] dispatchRunAgent caught error`, { + error + }); return getNodeErrResponse({ error }); + } finally { + for (const cap of capabilities) { + await cap.dispose?.(); + } } }; 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 f842e26e6d..d1293d55fa 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts @@ -21,7 +21,10 @@ import { getOneStepResponseSummary } from './responseSummary'; import type { DispatchPlanAgentResponse } from '../sub/plan'; import { dispatchPlanAgent } from '../sub/plan'; import type { WorkflowResponseItemType } from '../../../type'; -import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType +} from '@fastgpt/global/core/chat/type'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { i18nT } from '../../../../../../../web/i18n/utils'; @@ -37,6 +40,7 @@ import { SandboxGetFileUrlToolSchema } from '@fastgpt/global/core/ai/sandbox/constants'; import { dispatchSandboxShell, dispatchSandboxGetFileUrl } from '../sub/sandbox'; +import type { CapabilityToolCallHandlerType } from '../capability/type'; type Response = { stepResponse?: { @@ -47,6 +51,7 @@ type Response = { completeMessages: ChatCompletionMessageParam[]; assistantMessages: ChatCompletionMessageParam[]; masterMessages: ChatCompletionMessageParam[]; + capabilityAssistantResponses?: AIChatItemValueItemType[]; nodeResponse: ChatHistoryItemResType; }; @@ -61,6 +66,7 @@ export const masterCall = async ({ filesMap, steps, step, + capabilityToolCallHandler, ...props }: DispatchAgentModuleProps & { masterMessages: ChatCompletionMessageParam[]; @@ -75,6 +81,9 @@ export const masterCall = async ({ // Step call steps?: AgentStepItemType[]; step?: AgentStepItemType; + + // Capability tool call handler + capabilityToolCallHandler?: CapabilityToolCallHandlerType; }): Promise => { const { checkIsStopping, @@ -100,6 +109,7 @@ export const masterCall = async ({ const startTime = Date.now(); const childrenResponses: ChatHistoryItemResType[] = []; + const capabilityAssistantResponses: AIChatItemValueItemType[] = []; const isStepCall = steps && step; const stepId = step?.id; const stepStreamResponse = (args: WorkflowResponseItemType) => { @@ -189,7 +199,8 @@ export const masterCall = async ({ content: getMasterSystemPrompt({ systemPrompt, hasUserTools, - useAgentSandbox: useAgentSandbox && !!global.feConfigs?.show_agent_sandbox + useAgentSandbox: useAgentSandbox && !!global.feConfigs?.show_agent_sandbox, + hasSandboxSkills: !!capabilityToolCallHandler }) }, ...masterMessages @@ -474,142 +485,167 @@ export const masterCall = async ({ } } - // User Sub App - else { - const tool = getSubApp(toolId); - if (!tool) { - return { - response: `Can't find the tool ${toolId}`, - usages: [] - }; + // TODO: 所有内置工具,合并成一个 function + // Capability tools (e.g. sandbox skills) + const capResult = await capabilityToolCallHandler?.( + toolId, + call.function.arguments ?? '', + callId + ); + if (capResult != null) { + if (capResult.assistantResponses?.length) { + capabilityAssistantResponses.push(...capResult.assistantResponses); } - const toolCallParams = parseJsonArgs(call.function.arguments); - - if (call.function.arguments && !toolCallParams) { - return { - response: 'Params is not object', - usages: [] - }; - } - - // Get params - const requestParams = { - ...tool.params, - ...toolCallParams + const subInfo = getSubAppInfo(toolId); + childrenResponses.push({ + nodeId: callId, + id: callId, + moduleType: FlowNodeTypeEnum.tool, + moduleName: subInfo.name, + moduleLogo: subInfo.avatar, + toolInput: parseJsonArgs(call.function.arguments), + toolRes: capResult.response + }); + return { + response: capResult.response, + usages: capResult.usages || [] }; - // Remove sensitive data + } - if (tool.type === 'tool') { - const { response, usages, runningTime, toolParams, result } = await dispatchTool({ - tool: { - name: tool.name, - version: tool.version, - toolConfig: tool.toolConfig - }, - params: requestParams, - runningUserInfo, - runningAppInfo, - chatId, - uid, - variables, - workflowStreamResponse: stepStreamResponse - }); + // User Sub App + const tool = getSubApp(toolId); + if (!tool) { + return { + response: `Can't find the tool ${toolId}`, + usages: [] + }; + } + const toolCallParams = parseJsonArgs(call.function.arguments); - childrenResponses.push({ - nodeId: callId, - id: callId, - runningTime, - moduleType: FlowNodeTypeEnum.tool, - moduleName: tool.name, - moduleLogo: tool.avatar, - toolInput: toolParams, - toolRes: result || response, - totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) - }); - return { - response, - usages - }; - } else if (tool.type === 'workflow') { - const { userChatInput, ...params } = requestParams; + if (call.function.arguments && !toolCallParams) { + return { + response: 'Params is not object', + usages: [] + }; + } - const { response, runningTime, usages } = await dispatchApp({ - appId: tool.id, - userChatInput: userChatInput, - customAppVariables: params, - checkIsStopping, - lang: props.lang, - requestOrigin: props.requestOrigin, - mode: props.mode, - timezone: props.timezone, - externalProvider: props.externalProvider, - runningAppInfo: props.runningAppInfo, - runningUserInfo: props.runningUserInfo, - retainDatasetCite: props.retainDatasetCite, - maxRunTimes: props.maxRunTimes, - workflowDispatchDeep: props.workflowDispatchDeep, - variables: props.variables - }); + // Get params + const requestParams = { + ...tool.params, + ...toolCallParams + }; + // Remove sensitive data - childrenResponses.push({ - nodeId: callId, - id: callId, - runningTime, - moduleType: FlowNodeTypeEnum.appModule, - moduleName: tool.name, - moduleLogo: tool.avatar, - toolInput: requestParams, - toolRes: response, - totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) - }); + if (tool.type === 'tool') { + const { response, usages, runningTime, toolParams, result } = await dispatchTool({ + tool: { + name: tool.name, + version: tool.version, + toolConfig: tool.toolConfig + }, + params: requestParams, + runningUserInfo, + runningAppInfo, + chatId, + uid, + variables, + workflowStreamResponse: stepStreamResponse + }); - return { - response, - usages, - runningTime - }; - } else if (tool.type === 'toolWorkflow') { - const { response, result, runningTime, usages } = await dispatchPlugin({ - appId: tool.id, - userChatInput: '', - customAppVariables: requestParams, - checkIsStopping, - lang: props.lang, - requestOrigin: props.requestOrigin, - mode: props.mode, - timezone: props.timezone, - externalProvider: props.externalProvider, - runningAppInfo: props.runningAppInfo, - runningUserInfo: props.runningUserInfo, - retainDatasetCite: props.retainDatasetCite, - maxRunTimes: props.maxRunTimes, - workflowDispatchDeep: props.workflowDispatchDeep, - variables: props.variables - }); + childrenResponses.push({ + nodeId: callId, + id: callId, + runningTime, + moduleType: FlowNodeTypeEnum.tool, + moduleName: tool.name, + moduleLogo: tool.avatar, + toolInput: toolParams, + toolRes: result || response, + totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) + }); + return { + response, + usages + }; + } else if (tool.type === 'workflow') { + const { userChatInput, ...params } = requestParams; - childrenResponses.push({ - nodeId: callId, - id: callId, - runningTime, - moduleType: FlowNodeTypeEnum.pluginModule, - moduleName: tool.name, - moduleLogo: tool.avatar, - toolInput: requestParams, - toolRes: result, - totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) - }); + const { response, runningTime, usages } = await dispatchApp({ + appId: tool.id, + userChatInput: userChatInput, + customAppVariables: params, + checkIsStopping, + lang: props.lang, + requestOrigin: props.requestOrigin, + mode: props.mode, + timezone: props.timezone, + externalProvider: props.externalProvider, + runningAppInfo: props.runningAppInfo, + runningUserInfo: props.runningUserInfo, + retainDatasetCite: props.retainDatasetCite, + maxRunTimes: props.maxRunTimes, + workflowDispatchDeep: props.workflowDispatchDeep, + variables: props.variables + }); - return { - response, - usages, - runningTime - }; - } else { - return { - response: 'Invalid tool type', - usages: [] - }; - } + childrenResponses.push({ + nodeId: callId, + id: callId, + runningTime, + moduleType: FlowNodeTypeEnum.appModule, + moduleName: tool.name, + moduleLogo: tool.avatar, + toolInput: requestParams, + toolRes: response, + totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) + }); + + return { + response, + usages, + runningTime + }; + } else if (tool.type === 'toolWorkflow') { + const { response, result, runningTime, usages } = await dispatchPlugin({ + appId: tool.id, + userChatInput: '', + customAppVariables: requestParams, + checkIsStopping, + lang: props.lang, + requestOrigin: props.requestOrigin, + mode: props.mode, + timezone: props.timezone, + externalProvider: props.externalProvider, + runningAppInfo: props.runningAppInfo, + runningUserInfo: props.runningUserInfo, + retainDatasetCite: props.retainDatasetCite, + maxRunTimes: props.maxRunTimes, + workflowDispatchDeep: props.workflowDispatchDeep, + variables: props.variables + }); + + childrenResponses.push({ + nodeId: callId, + id: callId, + runningTime, + moduleType: FlowNodeTypeEnum.pluginModule, + moduleName: tool.name, + moduleLogo: tool.avatar, + toolInput: requestParams, + toolRes: result, + totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0) + }); + + return { + response, + usages, + runningTime + }; + } else { + return { + response: 'Invalid tool type', + usages: [] + }; } } catch (error) { return { @@ -630,11 +666,9 @@ export const masterCall = async ({ } }); - // TODO: 推送账单 - return { response, - assistantMessages: [], // TODO + assistantMessages: [], usages, stop }; @@ -724,7 +758,8 @@ export const masterCall = async ({ completeMessages, assistantMessages, nodeResponse, - masterMessages: masterMessages.concat(assistantMessages) + masterMessages: masterMessages.concat(assistantMessages), + capabilityAssistantResponses }; } @@ -735,6 +770,7 @@ export const masterCall = async ({ completeMessages, assistantMessages, nodeResponse, - masterMessages: filterMemoryMessages(completeMessages) + masterMessages: filterMemoryMessages(completeMessages), + capabilityAssistantResponses }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/prompt.ts b/packages/service/core/workflow/dispatch/ai/agent/master/prompt.ts index 723d2f0744..a3c2b71d32 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/prompt.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/prompt.ts @@ -4,11 +4,13 @@ import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants export const getMasterSystemPrompt = ({ systemPrompt, hasUserTools, - useAgentSandbox + useAgentSandbox, + hasSandboxSkills }: { systemPrompt?: string; hasUserTools: boolean; useAgentSandbox: boolean; + hasSandboxSkills: boolean; }) => { return ` @@ -37,10 +39,15 @@ ${SANDBOX_SYSTEM_PROMPT} } -三种执行路径: +${hasSandboxSkills ? '四' : '三'}种执行路径: 1. **规划模式**:调用 ${SubAppIds.plan} 进行任务分解和规划,或者重新进入规划 2. **工具模式**:直接调用工具完成单步操作 -3. **总结模式**:基于已有信息直接输出结论 +3. **总结模式**:基于已有信息直接输出结论${ + hasSandboxSkills + ? ` +4. **技能执行模式**:当任务匹配可用技能的描述时,先用 sandbox_read_file 加载技能文档,然后通过沙箱工具执行` + : '' + } ${ @@ -63,7 +70,12 @@ ${ ${ hasUserTools ? `**有工具场景**:用户选择了至少一个可用工具(搜索、文件、数据集、自定义工具等) -- ✅ 可以根据后续决策矩阵选择任意执行路径 +- ✅ 可以根据后续决策矩阵选择任意执行路径${ + hasSandboxSkills + ? ` +- 💡 **注意**:当前有可用技能(见 ),优先检查任务是否匹配技能描述,匹配时使用技能执行模式` + : '' + } - 继续进行后续的上下文评估和任务复杂度判断` : `**无工具场景**:用户未选择任何可用工具 - 必须严格遵守 中的约束 diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts index e49a6cce49..660b76f56f 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts @@ -35,14 +35,17 @@ export const formatFileInput = ({ fileUrls = [], requestOrigin, maxFiles, - histories + histories, + useSkill }: { fileUrls?: string[]; requestOrigin?: string; maxFiles: number; histories: ChatItemType[]; + useSkill: boolean; }): { filesMap: Record; + allFilesMap: Record; prompt: string; } => { const filesFromHistories = getHistoryFileLinks(histories); @@ -50,12 +53,13 @@ export const formatFileInput = ({ if (filesFromHistories.length === 0 && fileUrls.length === 0) { return { filesMap: {}, + allFilesMap: {}, prompt: '' }; } const parseFn = (urls: string[]) => { - const parseUrlList = urls + const parseResult = urls // Remove invalid urls .filter((url) => { if (typeof url !== 'string') return false; @@ -68,8 +72,6 @@ export const formatFileInput = ({ return false; }) - // Just get the document type file - .filter((url) => parseUrlToFileType(url)?.type === 'file') .map((url) => { try { // Check is system upload file @@ -87,15 +89,13 @@ export const formatFileInput = ({ } }) .filter(Boolean) - .slice(0, maxFiles); - - const parseResult = parseUrlList - .map((url) => parseUrlToFileType(url)) - .filter((item) => item?.name && item?.type === ChatFileTypeEnum.file) as { + .slice(0, maxFiles) + .map(parseUrlToFileType) as { type: `${ChatFileTypeEnum}`; name: string; url: string; }[]; + return parseResult; }; @@ -116,37 +116,65 @@ export const formatFileInput = ({ const uniqueFiles = Array.from(uniqueFilesMap.values()); - // 只为新上传的文件(在 queryParseResult 中但不在历史中的)生成 prompt - const newFiles = queryParseResult.filter( - (queryFile) => !historyParseResult.some((histFile) => histFile.name === queryFile.name) + // Build allFilesMap: all files (documents + images) with unified sequential index + const allFilesMap = uniqueFiles.reduce( + (acc, item, index) => { + acc[`${index + 1}`] = { url: item.url, name: item.name, type: item.type }; + return acc; + }, + {} as Record ); - const promptList: { index: string; name: string }[] = []; - newFiles.forEach((item) => { + // filesMap: derived from allFilesMap, only document type files (preserving index) + const filesMap = Object.entries(allFilesMap) + .filter(([, v]) => v.type === ChatFileTypeEnum.file) + .reduce( + (acc, [k, v]) => { + acc[k] = v.url; + return acc; + }, + {} as Record + ); + + /* ===== 构建新文件的提示词 ===== */ + // 只为新上传的文件(在 queryParseResult 中但不在历史中的)生成 prompt. skill 模式,都注入提示词 + const newFiles = queryParseResult.filter( + (queryFile) => + (queryFile.type === ChatFileTypeEnum.file || useSkill) && + !historyParseResult.some((histFile) => histFile.name === queryFile.name) + ); + const promptList = newFiles.map((item) => { const index = uniqueFiles.findIndex((f) => f.name === item.name); - promptList.push({ index: `${index + 1}`, name: item.name }); + return { + index: `${index + 1}`, + name: item.name, + type: item.type === ChatFileTypeEnum.file ? 'document' : 'image' + }; }); + const prompt = - promptList.length > 0 + newFiles.length > 0 ? ` 当前对话中用户已上传以下文件: -${promptList.map((item) => `- 文件${item.index}: ${item.name}`).join('\n')} - +${promptList.map((item) => `- 文件${item.index}: ${item.name}(${item.type})`).join('\n')} **重要提示**: - 如果用户的任务涉及文件分析、解析或处理,请在规划步骤时优先考虑使用文件解析工具 - 在步骤的 description 中可以使用 @文件解析工具 来处理这些文件 + +${ + useSkill + ? `**文件访问说明**: +- 读取文本内容 → 使用 file_read 工具(仅支持 document 类型) +- 将文件写入沙箱供技能处理 → 使用 sandbox_fetch_user_file 工具(支持所有类型)` + : '' +} ` : ''; return { - filesMap: uniqueFiles.reduce( - (acc, item, index) => { - acc[index + 1] = item.url; - return acc; - }, - {} as Record - ), + filesMap, + allFilesMap, prompt }; }; 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 552689b3e6..61385fede6 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 @@ -114,3 +114,20 @@ export const dispatchSandboxGetFileUrl = async ({ }) }; }; + +// Agent Skills re-exports +export type { AgentSandboxContext } from './types'; +export { + createAgentSandbox, + releaseAgentSandbox, + connectEditDebugSandbox, + disconnectEditDebugSandbox +} from './lifecycle'; +export { + dispatchSandboxReadFile, + dispatchSandboxWriteFile, + dispatchSandboxEditFile, + dispatchSandboxExecute, + dispatchSandboxSearch +} from './skill'; +export { buildSkillsContextPrompt } from './prompt'; 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 new file mode 100644 index 0000000000..f62cb92a47 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts @@ -0,0 +1,496 @@ +/** + * Agent Sandbox Lifecycle Management + * + * Manages sandbox creation/destruction for agent skill execution. + * + * 沙箱容器生命周期: + * - createAgentSandbox:优先复用已有容器(查 MongoDB),否则创建新容器并持久化到 MongoDB + * - releaseAgentSandbox:断开 SDK 连接,不销毁容器 + */ + +import type { ISandbox, OpenSandboxVolume } from '@fastgpt-sdk/sandbox-adapter'; +import type { HydratedDocument } from 'mongoose'; +import { MongoAgentSkills } from '../../../../../../agentSkills/schema'; +import { MongoSandboxInstance } from '../../../../../../ai/sandbox/schema'; +import { MongoAgentSkillsVersion } from '../../../../../../agentSkills/version/schema'; +import { downloadSkillPackage } from '../../../../../../agentSkills/storage'; +import { parseSkillMarkdown } from '../../../../../../agentSkills/utils'; +import { + getSandboxProviderConfig, + getSandboxDefaults, + validateSandboxConfig, + buildSandboxAdapter, + connectToProviderSandbox, + disconnectFromProviderSandbox, + getVolumeManagerConfig, + ensureSessionVolume, + buildVolumeConfig, + buildBaseContainerEnv +} from '../../../../../../agentSkills/sandboxConfig'; +import { SandboxTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; +import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; +import { env } from '../../../../../../../env'; +import type { + AgentSkillSchemaType, + AgentSkillsVersionSchemaType, + SandboxImageConfigType +} from '@fastgpt/global/core/agentSkills/type'; +import type { AgentSandboxContext, DeployedSkillInfo } from './types'; +import { getLogger, LogCategories } from '../../../../../../../common/logger'; +import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type'; + +type CreateAgentSandboxParams = { + skillIds: string[]; + teamId: string; + tmbId: string; + sessionId: string; // chat 模式 = chatId,debug 模式 = 构造的 key + entrypoint?: string; // override default entrypoint for this request + image?: SandboxImageConfigType; // override default image for this request + onProgress?: (status: SandboxStatusItemType) => void; // lifecycle progress callback +}; + +const logger = getLogger(LogCategories.MODULE.AI.AGENT); + +// --- Private helpers --- + +type SkillDoc = HydratedDocument; +type VersionDoc = HydratedDocument; +/** Query skills and their active versions, returning a map keyed by skillId string. */ +async function fetchSkillsWithVersionMap( + skillIds: string[], + teamId: string +): Promise<{ skills: SkillDoc[]; versionMap: Map }> { + const skills = await MongoAgentSkills.find({ + _id: { $in: skillIds }, + teamId, + deleteTime: null + }); + const activeVersions = await MongoAgentSkillsVersion.find({ + skillId: { $in: skills.map((s) => s._id) }, + isActive: true, + isDeleted: false + }); + const versionMap = new Map(activeVersions.map((v) => [String(v.skillId), v])); + return { skills, versionMap }; +} + +/** Merge an active version snapshot into a skill POJO. Returns skill unchanged when version is absent. */ +function mergeSkillWithVersion( + skill: AgentSkillSchemaType, + version: VersionDoc | null | undefined +): AgentSkillSchemaType { + if (!version) return skill; + return { ...skill }; +} + +/** Dynamically discover all deployed skill directories in the sandbox by locating SKILL.md files. + * Example: + * $ find ${FASTGPT_WORKDIR} -name "SKILL.md" -maxdepth 5 2>/dev/null + * /home/sandbox/workspace/projects/skill-creator/SKILL.md + * /home/sandbox/workspace/projects/deep-research/SKILL.md + * /home/sandbox/workspace/projects/science/SKILL.md + * + * Runs `find` inside the sandbox to locate SKILL.md files up to maxdepth 5, + * then reads each file and parses the frontmatter for name/description. + * This replaces the pre-scan approach and works with arbitrary ZIP structures. + */ +async function discoverSkillsInSandbox( + sandbox: ISandbox, + workDirectory: string +): Promise { + // Use `find` with -maxdepth 5 to avoid deep recursion performance issues. + const findResult = await sandbox.execute( + `find "${workDirectory}" -name "SKILL.md" -maxdepth 5 2>/dev/null` + ); + if (findResult.exitCode !== 0 || !findResult.stdout.trim()) return []; + + const paths = findResult.stdout.trim().split('\n').filter(Boolean); + const files = await sandbox.readFiles(paths); + + const result: DeployedSkillInfo[] = []; + for (const file of files) { + const content = + file.content instanceof Uint8Array + ? new TextDecoder('utf-8').decode(file.content) + : String(file.content); + const { frontmatter } = parseSkillMarkdown(content); + if (!frontmatter.name) continue; + const directory = file.path.replace(/\/SKILL\.md$/i, ''); + result.push({ + id: file.path, + name: String(frontmatter.name), + description: frontmatter.description ? String(frontmatter.description) : '', + directory, + skillMdPath: file.path + }); + } + return result; +} + +/** Download and extract each skill package into the sandbox work directory. */ +async function deploySkillsToSandbox( + sandbox: ISandbox, + deployableSkills: SkillDoc[], + versionMap: Map, + workDirectory: string, + onProgress?: (status: SandboxStatusItemType) => void, + sessionId?: string +): Promise { + for (const skill of deployableSkills) { + const version = versionMap.get(String(skill._id))!; + const sandboxId = sessionId ?? skill._id.toString(); + onProgress?.({ + sandboxId, + phase: 'deployingSkills', + skillName: skill.name + }); + try { + onProgress?.({ sandboxId, phase: 'downloadingPackage', skillName: skill.name }); + const packageBuffer = await downloadSkillPackage({ storageInfo: version.storage }); + + // Write raw ZIP to sandbox and extract directly into workDirectory. + const zipPath = `${workDirectory}/package_${skill.name}.zip`; + onProgress?.({ sandboxId, phase: 'uploadingPackage', skillName: skill.name }); + await sandbox.writeFiles([{ path: zipPath, data: packageBuffer }]); + + onProgress?.({ sandboxId, phase: 'extractingPackage', skillName: skill.name }); + const extractResult = await sandbox.execute( + `cd ${workDirectory} && unzip -o package_${skill.name}.zip && rm package_${skill.name}.zip` + ); + + if (extractResult.exitCode !== 0) { + logger.error('[Agent Sandbox] Failed to extract skill package', { + skillName: skill.name, + stderr: extractResult.stderr + }); + } + } catch (error) { + logger.error('[Agent Sandbox] Failed to deploy skill', { skillName: skill.name, error }); + } + } +} + +// --- Exported lifecycle functions --- + +/** + * 创建或复用 session-runtime 沙箱。 + * + * 优先查询 MongoDB 中是否有相同 sessionId 的活跃容器: + * - 有 → connect 复用,无冷启动 + * - 无 → 创建新容器,挂载会话 Volume,持久化到 MongoDB + */ +export async function createAgentSandbox( + params: CreateAgentSandboxParams +): Promise { + const { skillIds, teamId, tmbId, sessionId, entrypoint, image, onProgress } = params; + + const providerConfig = getSandboxProviderConfig(); + const defaults = getSandboxDefaults(); + validateSandboxConfig(providerConfig); + + // Step 1: Try to reuse an existing session-runtime sandbox + onProgress?.({ sandboxId: sessionId, phase: 'checkExisting' }); + const existingInstance = await MongoSandboxInstance.findOne({ + chatId: sessionId, + 'metadata.sandboxType': SandboxTypeEnum.sessionRuntime + }); + + if (existingInstance) { + logger.info('[Agent Sandbox] Reusing existing session-runtime sandbox', { + sessionId, + providerSandboxId: existingInstance.sandboxId + }); + + onProgress?.({ sandboxId: sessionId, phase: 'connecting', isWarmStart: true }); + const sandbox = await connectToProviderSandbox(providerConfig, existingInstance.sandboxId); + + if (existingInstance.status === SandboxStatusEnum.stopped) { + logger.info('[Agent Sandbox] Resuming stopped sandbox', { + sessionId, + providerSandboxId: existingInstance.sandboxId + }); + await sandbox.start(); + await sandbox.waitUntilReady(60000); + } + + await MongoSandboxInstance.updateOne( + { _id: existingInstance._id }, + { lastActiveAt: new Date() } + ); + + const reusedSkillIds = existingInstance.metadata?.skillIds + ? existingInstance.metadata.skillIds.map(String) + : skillIds; + const { skills, versionMap } = await fetchSkillsWithVersionMap(reusedSkillIds, teamId); + + onProgress?.({ sandboxId: sessionId, phase: 'ready', isWarmStart: true }); + const mergedSkills = skills.map((skill) => + mergeSkillWithVersion(skill.toJSON(), versionMap.get(String(skill._id))) + ); + // Dynamically discover deployed skills instead of reconstructing from DB name assumptions + const deployedSkills = await discoverSkillsInSandbox(sandbox, defaults.workDirectory); + return { + sandbox, + providerSandboxId: existingInstance.sandboxId, + sessionId, + skills: mergedSkills, + deployedSkills, + 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', { + skillIds, + teamId, + sessionId + }); + + const hasSkills = skillIds.length > 0; + let deployableSkills: SkillDoc[] = []; + let versionMap = new Map(); + + if (hasSkills) { + onProgress?.({ sandboxId: sessionId, phase: 'fetchSkills', isWarmStart: false }); + const result = await fetchSkillsWithVersionMap(skillIds, teamId); + + if (result.skills.length === 0) { + throw new Error('No valid skills found'); + } + + deployableSkills = result.skills.filter( + (skill) => result.versionMap.get(String(skill._id)) && skill.currentStorage + ); + versionMap = result.versionMap; + + if (deployableSkills.length === 0) { + throw new Error('No deployable skills found (missing active versions)'); + } + } + + // 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, inject SESSION_ID + let sandbox: ISandbox | null = null; + + try { + const createEntrypoint = defaults.entrypoint; + + let volumes: OpenSandboxVolume[] | undefined; + if (providerConfig.provider === 'opensandbox' && env.AGENT_SANDBOX_ENABLE_VOLUME) { + const vmConfig = getVolumeManagerConfig(); + const claimName = await ensureSessionVolume(sessionId, vmConfig); + volumes = [ + buildVolumeConfig(providerConfig.runtime, sessionId, claimName, vmConfig.mountPath) + ]; + } + + const createConfig = { + image: image ?? defaults.defaultImage, + entrypoint: [entrypoint ?? createEntrypoint], + env: buildBaseContainerEnv(sessionId, defaults.workDirectory, false), + volumes, + metadata: { + teamId, + tmbId, + sandboxType: SandboxTypeEnum.sessionRuntime, + skillIds: skillIds.join('-'), + sessionId + } + }; + + sandbox = buildSandboxAdapter(providerConfig, { + providerSandboxId: sessionId, + createConfig + }); + + onProgress?.({ sandboxId: sessionId, phase: 'creatingContainer', isWarmStart: false }); + await sandbox.create(); + + await sandbox.waitUntilReady(60000); + + const sandboxInfo = await sandbox.getInfo(); + if (!sandboxInfo) throw new Error('Failed to get sandbox info after creation'); + + logger.info('[Agent Sandbox] Sandbox created', { + providerSandboxId: sandboxInfo.id, + sessionId + }); + + // Step 4: Deploy skill packages (only when skills are configured) + if (hasSkills) { + await deploySkillsToSandbox( + sandbox, + deployableSkills, + versionMap, + defaults.workDirectory, + onProgress, + sessionId + ); + } + const deployedSkills = hasSkills + ? await discoverSkillsInSandbox(sandbox, defaults.workDirectory) + : []; + + // Step 5: Persist to MongoDB + await MongoSandboxInstance.create({ + provider: providerConfig.provider, + sandboxId: sandboxInfo.id, + appId: teamId, // session-runtime uses teamId as appId + userId: tmbId, + chatId: sessionId, + status: SandboxStatusEnum.running, + lastActiveAt: new Date(), + createdAt: new Date(), + metadata: { + sandboxType: SandboxTypeEnum.sessionRuntime, + teamId, + tmbId, + sessionId, + skillIds: hasSkills ? deployableSkills.map((s) => s._id) : [], + provider: providerConfig.provider, + image: sandboxInfo.image, + providerStatus: { + state: sandboxInfo.status.state, + message: sandboxInfo.status.message, + reason: sandboxInfo.status.reason + }, + providerCreatedAt: sandboxInfo.createdAt + } + }); + + logger.info('[Agent Sandbox] Sandbox info saved to MongoDB', { sessionId }); + + onProgress?.({ sandboxId: sessionId, phase: 'ready', isWarmStart: false }); + return { + sandbox, + providerSandboxId: sandboxInfo.id, + sessionId, + skills: hasSkills + ? deployableSkills.map((skill) => + mergeSkillWithVersion(skill.toJSON(), versionMap.get(String(skill._id))!) + ) + : [], + deployedSkills, + workDirectory: defaults.workDirectory, + isReady: true + }; + } catch (error) { + logger.error('[Agent Sandbox] Failed to create sandbox', { error }); + + if (sandbox) { + try { + await sandbox.delete(); + } catch (cleanupError) { + logger.error('[Agent Sandbox] Cleanup failed after creation error', { cleanupError }); + } + await disconnectFromProviderSandbox(sandbox); + } + + throw error; + } +} + +/** + * 结束本次 sandbox 使用。 + * + * 只断开 SDK 连接,不销毁容器。 + * 容器保持存活,供同会话的下一次 agent 调用复用。 + */ +export async function releaseAgentSandbox(ctx: AgentSandboxContext): Promise { + try { + await disconnectFromProviderSandbox(ctx.sandbox); + logger.info('[Agent Sandbox] Released sandbox connection', { + sessionId: ctx.sessionId, + providerSandboxId: ctx.providerSandboxId + }); + } catch (error) { + logger.error('[Agent Sandbox] Failed to close sandbox connection', { error }); + } +} + +type ConnectEditDebugSandboxParams = { + skillId: string; + teamId: string; +}; + +/** + * 连接已有的 editDebug 沙箱,构建 AgentSandboxContext。 + * 用于 test 模式下,复用编辑中的沙箱进行调试。 + */ +export async function connectEditDebugSandbox( + params: ConnectEditDebugSandboxParams +): Promise { + const { skillId, teamId } = params; + const providerConfig = getSandboxProviderConfig(); + const defaults = getSandboxDefaults(); + validateSandboxConfig(providerConfig); + + 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'); + } + + const sandbox = await connectToProviderSandbox(providerConfig, instanceDoc.sandboxId); + + await MongoSandboxInstance.updateOne({ _id: instanceDoc._id }, { lastActiveAt: new Date() }); + + 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(sandbox, defaults.workDirectory); + + return { + sandbox, + 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 new file mode 100644 index 0000000000..c889ca3062 --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts @@ -0,0 +1,77 @@ +/** + * Agent Sandbox Skills Prompt + * + * Implements progressive disclosure: only inject skill metadata + * (name/description/location) into the prompt. LLM uses + * sandbox_read_file to load full SKILL.md on demand. + */ + +import type { DeployedSkillInfo } from './types'; + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Build skills context prompt for progressive disclosure. + * + * Accepts DeployedSkillInfo[] so that name/description come from SKILL.md + * frontmatter (scanned at deploy time) rather than from the database. + * LLM loads full SKILL.md via sandbox_read_file when needed. + * + * When deployedSkills is empty, only the sandbox environment section is emitted, + * allowing the agent to use sandbox tools even without any installed skills. + */ +export function buildSkillsContextPrompt( + deployedSkills: DeployedSkillInfo[], + workDirectory: string +): string { + const lines: string[] = []; + + // Skills section: only when skills are deployed + if (deployedSkills.length > 0) { + lines.push(''); + lines.push( + 'The following skills are deployed in the sandbox environment. When a task matches a skill description, use sandbox_read_file to load the SKILL.md for detailed instructions, then execute via sandbox_* tools.' + ); + lines.push(''); + lines.push(''); + for (const info of deployedSkills) { + lines.push(' '); + lines.push(` ${escapeXml(info.name)}`); + lines.push(` ${escapeXml(info.description)}`); + if (info.skillMdPath) { + lines.push(` ${escapeXml(info.skillMdPath)}`); + } + if (info.directory) { + lines.push(` ${escapeXml(info.directory)}`); + } + lines.push(' '); + } + lines.push(''); + lines.push(''); + lines.push(''); + } + + // Sandbox environment section: always present + lines.push(''); + lines.push(`Workspace root: ${workDirectory}`); + 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.' + ); + lines.push( + 'Always use RELATIVE paths for target_path (e.g. "uploads/file.pdf"), never absolute paths or "..".' + ); + lines.push(`Files are placed at: ${workDirectory}/`); + lines.push(''); + + return lines.join('\n'); +} 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 new file mode 100644 index 0000000000..2bd6c6e70b --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts @@ -0,0 +1,222 @@ +/** + * Agent Sandbox Tool Dispatch + * + * Implements the 5 sandbox tool dispatch functions that map + * LLM tool calls to ISandbox operations. + */ + +import type { AgentSandboxContext } from './types'; +import type { z } from 'zod'; +import type { + SandboxReadFileSchema, + SandboxWriteFileSchema, + SandboxEditFileSchema, + SandboxExecuteSchema, + SandboxSearchSchema, + SandboxFetchUserFileSchema +} from '@fastgpt/global/core/workflow/node/agent/skillTools'; +import axios from 'axios'; +import { serverRequestBaseUrl } from '../../../../../../../common/api/serverRequest'; +import path from 'path'; + +type DispatchResult = { + response: string; + usages: []; +}; + +/** + * Read files from sandbox + */ +export async function dispatchSandboxReadFile( + ctx: AgentSandboxContext, + params: z.infer +): Promise { + try { + const files = await ctx.sandbox.readFiles(params.paths); + + if (!files || files.length === 0) { + return { response: 'No files found', usages: [] }; + } + + const results = files.map((file: { path: string; content: Uint8Array | string }) => { + const content = + file.content instanceof Uint8Array + ? new TextDecoder('utf-8').decode(file.content) + : String(file.content); + return `--- ${file.path} ---\n${content}`; + }); + + return { response: results.join('\n\n'), usages: [] }; + } catch (error) { + return { + response: `Failed to read files: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} + +/** + * Write a file to sandbox + */ +export async function dispatchSandboxWriteFile( + ctx: AgentSandboxContext, + params: z.infer +): Promise { + try { + await ctx.sandbox.writeFiles([ + { + path: params.path, + data: params.content + } + ]); + + return { response: `File written successfully: ${params.path}`, usages: [] }; + } catch (error) { + return { + response: `Failed to write file: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} + +/** + * Edit files in sandbox using find-and-replace + */ +export async function dispatchSandboxEditFile( + ctx: AgentSandboxContext, + params: z.infer +): Promise { + try { + await ctx.sandbox.replaceContent( + params.entries.map((e) => ({ + path: e.path, + oldContent: e.oldContent, + newContent: e.newContent + })) + ); + + const editedPaths = params.entries.map((e) => e.path).join(', '); + return { response: `Files edited successfully: ${editedPaths}`, usages: [] }; + } catch (error) { + return { + response: `Failed to edit files: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} + +/** + * Execute a shell command in sandbox + */ +export async function dispatchSandboxExecute( + ctx: AgentSandboxContext, + params: z.infer +): Promise { + try { + const result = await ctx.sandbox.execute(params.command, { + workingDirectory: params.workingDirectory, + timeoutMs: params.timeoutMs + }); + + const parts: string[] = []; + parts.push(`Exit code: ${result.exitCode}`); + if (result.stdout) parts.push(`stdout:\n${result.stdout}`); + if (result.stderr) parts.push(`stderr:\n${result.stderr}`); + + return { response: parts.join('\n'), usages: [] }; + } catch (error) { + return { + response: `Failed to execute command: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} + +/** + * Search files in sandbox + */ +export async function dispatchSandboxSearch( + ctx: AgentSandboxContext, + params: z.infer +): Promise { + try { + const results = await ctx.sandbox.search(params.pattern, params.path); + + if (!results || results.length === 0) { + return { response: 'No matching files found', usages: [] }; + } + + const paths = results + .map((r: string | { path: string }) => (typeof r === 'string' ? r : r.path)) + .join('\n'); + return { response: `Matching files:\n${paths}`, usages: [] }; + } catch (error) { + return { + response: `Failed to search files: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} + +/** + * Fetch a user-uploaded file (from conversation) and write it into the sandbox filesystem + */ + +/** + * Resolve target_path to an absolute path within workDirectory. + * Strips leading slash if present, then resolves and validates no traversal. + * Returns null if the resolved path escapes workDirectory. + */ +function resolveTargetPath(targetPath: string, workDirectory: string): string | null { + // Strip leading slash if provided (LLM might still send absolute path) + const relative = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; + + // Resolve to absolute, then verify it's within workDirectory + const resolved = path.resolve(workDirectory, relative); + if (!resolved.startsWith(workDirectory + '/') && resolved !== workDirectory) { + return null; // Path traversal detected + } + return resolved; +} + +export async function dispatchSandboxFetchUserFile( + ctx: AgentSandboxContext, + params: z.infer, + allFilesMap: Record +): Promise { + const fileEntry = allFilesMap[params.file_index]; + if (!fileEntry) { + return { + response: `Failed: file index "${params.file_index}" not found in available_files`, + usages: [] + }; + } + + const resolvedPath = resolveTargetPath(params.target_path, ctx.workDirectory); + if (!resolvedPath) { + return { + response: `Failed: target_path "${params.target_path}" is invalid or attempts to escape workspace.`, + usages: [] + }; + } + + try { + const response = await axios.get(fileEntry.url, { + baseURL: serverRequestBaseUrl, + responseType: 'arraybuffer' + }); + const buffer: ArrayBuffer = response.data; + + await ctx.sandbox.writeFiles([{ path: resolvedPath, data: buffer }]); + + return { + response: `File written to sandbox: ${resolvedPath} (name: ${fileEntry.name}, size: ${buffer.byteLength} bytes)`, + usages: [] + }; + } catch (error) { + return { + response: `Failed to fetch user file: ${error instanceof Error ? error.message : String(error)}`, + usages: [] + }; + } +} diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/types.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/types.ts new file mode 100644 index 0000000000..032fe7896e --- /dev/null +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/types.ts @@ -0,0 +1,23 @@ +import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; +import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type'; + +// Info about a single skill directory discovered inside a deployed package.zip +export type DeployedSkillInfo = { + id: string; // skill id from Mongo + name: string; // from SKILL.md frontmatter + description: string; // from SKILL.md frontmatter + avatar?: string; // skill avatar + directory: string; // absolute path in sandbox, e.g. /workspace/projects/my-skill + skillMdPath: string; // absolute SKILL.md path in sandbox +}; + +// Sandbox runtime context - shared across the entire agent lifecycle +export type AgentSandboxContext = { + sandbox: ISandbox; + providerSandboxId: string; + sessionId: string; + skills: AgentSkillSchemaType[]; + deployedSkills: DeployedSkillInfo[]; + workDirectory: string; + isReady: boolean; +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/utils.ts index 1d0b18695b..33a99022af 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/utils.ts @@ -15,7 +15,8 @@ export const getSubapps = async ({ getPlanTool, hasDataset, hasFiles, - useAgentSandbox + useAgentSandbox, + extraTools }: { tmbId: string; tools: SkillToolType[]; @@ -23,7 +24,8 @@ export const getSubapps = async ({ getPlanTool?: Boolean; hasDataset?: boolean; hasFiles: boolean; - useAgentSandbox: boolean; + useAgentSandbox?: boolean; + extraTools?: ChatCompletionTool[]; }): Promise<{ completionTools: ChatCompletionTool[]; subAppsMap: Map; @@ -50,6 +52,11 @@ export const getSubapps = async ({ completionTools.push(...SANDBOX_TOOLS); } + /* Capability extra tools (e.g. sandbox skills) */ + if (extraTools && extraTools.length > 0) { + completionTools.push(...extraTools); + } + /* System tool */ const formatTools = await getAgentRuntimeTools({ tools, diff --git a/packages/service/env.ts b/packages/service/env.ts index 1dca0ddb03..526de1c59d 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -5,6 +5,7 @@ const BoolSchema = z .string() .transform((val) => val === 'true') .pipe(z.boolean()); +const NumSchema = z.coerce.number(); const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']); @@ -12,6 +13,7 @@ export const env = createEnv({ server: { // ===== Agent sandbox ===== AGENT_SANDBOX_PROVIDER: z.enum(['sealosdevbox', 'opensandbox', 'e2b']).optional(), + AGENT_SANDBOX_E2B_API_KEY: z.string().optional(), AGENT_SANDBOX_SEALOS_BASEURL: z.string().url().optional(), AGENT_SANDBOX_SEALOS_TOKEN: z.string().optional(), @@ -26,8 +28,15 @@ export const env = createEnv({ AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(), AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: z.string().default('/workspace'), - AGENT_SANDBOX_E2B_API_KEY: z.string().optional(), + AGENT_SKILL_MAX_UPLOAD_SIZE: NumSchema.optional(), + AGENT_SKILL_MAX_UNCOMPRESSED_SIZE: NumSchema.optional(), + 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(), + + // ===== Logging ===== LOG_ENABLE_CONSOLE: BoolSchema.default(true), LOG_CONSOLE_LEVEL: LogLevelSchema.default('debug'), LOG_ENABLE_OTEL: BoolSchema.default(false), @@ -46,7 +55,11 @@ export const env = createEnv({ TRACING_OTEL_SAMPLE_RATIO: z.coerce.number().min(0).max(1).optional(), APP_FOLDER_MAX_AMOUNT: z.coerce.number().int().positive().default(1000), - DATASET_FOLDER_MAX_AMOUNT: z.coerce.number().int().positive().default(1000) + DATASET_FOLDER_MAX_AMOUNT: z.coerce.number().int().positive().default(1000), + + // Beta features + // Whether the Skill feature is enabled (frontend entries + backend runtime) + SHOW_SKILL: BoolSchema.default(false) }, emptyStringAsUndefined: true, runtimeEnv: process.env, diff --git a/packages/service/package.json b/packages/service/package.json index db15d31cba..911fb59c14 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -37,8 +37,9 @@ "ioredis": "^5.6.0", "joplin-turndown-plugin-gfm": "^1.0.12", "json5": "catalog:", - "jsonpath-plus": "^10.3.0", "jsonrepair": "^3.0.0", + "jszip": "^3.10.1", + "jsonpath-plus": "^10.3.0", "jsonwebtoken": "^9.0.2", "lodash": "catalog:", "mammoth": "^1.11.0", diff --git a/packages/service/support/outLink/schema.ts b/packages/service/support/outLink/schema.ts index fb22358b62..11a02a9f43 100644 --- a/packages/service/support/outLink/schema.ts +++ b/packages/service/support/outLink/schema.ts @@ -48,6 +48,10 @@ const OutLinkSchema = new Schema({ type: Boolean, default: false }, + showSkillReferences: { + type: Boolean, + default: false + }, showCite: { type: Boolean, default: false diff --git a/packages/service/support/permission/agentSkill/auth.ts b/packages/service/support/permission/agentSkill/auth.ts new file mode 100644 index 0000000000..36168825fb --- /dev/null +++ b/packages/service/support/permission/agentSkill/auth.ts @@ -0,0 +1,150 @@ +import { MongoAgentSkills } from '../../../core/agentSkills/schema'; +import { + AgentSkillSourceEnum, + AgentSkillTypeEnum +} from '@fastgpt/global/core/agentSkills/constants'; +import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type'; +import { SkillErrEnum } from '@fastgpt/global/common/error/code/agentSkill'; +import { SkillPermission } from '@fastgpt/global/support/permission/agentSkill/controller'; +import { + NullRoleVal, + PerResourceTypeEnum, + ReadRoleVal +} from '@fastgpt/global/support/permission/constant'; +import type { PermissionValueType } from '@fastgpt/global/support/permission/type'; +import type { AuthModeType, AuthResponseType } from '../type'; +import { getTmbInfoByTmbId } from '../../user/team/controller'; +import { parseHeaderCert } from '../auth/common'; +import { getTmbPermission } from '../controller'; +import { sumPer } from '@fastgpt/global/support/permission/utils'; + +export type AuthSkillResponse = AuthResponseType & { + skill: AgentSkillSchemaType & { permission: SkillPermission }; +}; + +/** + * Verify skill access permission by tmbId (no request dependency, for internal use). + * + * Permission rules: + * - System skills: read-only for all team members; write/manage are rejected + * - Team owner or skill creator: owner-level access + * - Other team members: permission from ResourcePermission table (supports inheritance) + */ +export const authSkillByTmbId = async ({ + tmbId, + skillId, + per, + isRoot = false +}: { + tmbId: string; + skillId: string; + per: PermissionValueType; + isRoot?: boolean; +}): Promise<{ + skill: AgentSkillSchemaType & { permission: SkillPermission }; +}> => { + const skill = await (async () => { + const [{ teamId, permission: tmbPer }, skill] = await Promise.all([ + getTmbInfoByTmbId({ tmbId }), + MongoAgentSkills.findOne({ _id: skillId, deleteTime: null }).lean() + ]); + + if (!skill) { + return Promise.reject(SkillErrEnum.unExist); + } + + if (isRoot) { + return { + ...skill, + permission: new SkillPermission({ isOwner: true }) + }; + } + + if (String(skill.teamId) !== teamId) { + return Promise.reject(SkillErrEnum.unAuthSkill); + } + + // System skills are read-only for all team members + if (skill.source === AgentSkillSourceEnum.system) { + const sysPer = new SkillPermission({ role: ReadRoleVal, isOwner: false }); + if (!sysPer.checkPer(per)) { + return Promise.reject(SkillErrEnum.unAuthSkill); + } + return { + ...skill, + permission: sysPer + }; + } + + // Check if should inherit permission from parent folder + const isOwner = tmbPer.isOwner || String(skill.tmbId) === String(tmbId); + const isGetParentClb = + skill.inheritPermission !== false && + skill.type !== AgentSkillTypeEnum.folder && + !!skill.parentId; + + // Get parent folder permission and self permission in parallel + const [folderPer = NullRoleVal, myPer = NullRoleVal] = await Promise.all([ + isGetParentClb + ? getTmbPermission({ + teamId, + tmbId, + resourceId: skill.parentId!, + resourceType: PerResourceTypeEnum.agentSkill + }) + : NullRoleVal, + getTmbPermission({ + teamId, + tmbId, + resourceId: skillId, + resourceType: PerResourceTypeEnum.agentSkill + }) + ]); + + // Merge folder permission and self permission + const Per = new SkillPermission({ role: sumPer(folderPer, myPer), isOwner }); + + if (!Per.checkPer(per)) { + return Promise.reject(SkillErrEnum.unAuthSkill); + } + + return { + ...skill, + permission: Per + }; + })(); + + return { skill }; +}; + +/** + * Verify skill access permission from an HTTP request (for API route use). + */ +export const authSkill = async ({ + skillId, + per, + ...props +}: AuthModeType & { + skillId: string; + per: PermissionValueType; +}): Promise => { + const result = await parseHeaderCert(props); + const { tmbId } = result; + + if (!skillId) { + return Promise.reject(SkillErrEnum.unExist); + } + + const { skill } = await authSkillByTmbId({ + tmbId, + skillId, + per, + isRoot: result.isRoot + }); + + return { + ...result, + permission: skill.permission, + skill + }; +}; diff --git a/packages/service/support/tmpData/controller.ts b/packages/service/support/tmpData/controller.ts index 09e116da45..3a247471d3 100644 --- a/packages/service/support/tmpData/controller.ts +++ b/packages/service/support/tmpData/controller.ts @@ -43,8 +43,7 @@ export function setTmpData({ expireAt: addMilliseconds(Date.now(), TmpDataExpireTime[type]) }, { - upsert: true, - new: true + upsert: true } ); } diff --git a/packages/service/support/user/audit/util.ts b/packages/service/support/user/audit/util.ts index 7e5feeeaba..496ed4b18c 100644 --- a/packages/service/support/user/audit/util.ts +++ b/packages/service/support/user/audit/util.ts @@ -1,5 +1,6 @@ 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 { MongoTeamAudit } from './schema'; import type { @@ -45,6 +46,12 @@ export function getI18nDatasetType(type: DatasetTypeEnum | string): string { return i18nT('common:UnKnow'); } +export function getI18nSkillType(type: AgentSkillTypeEnum | string): string { + if (type === AgentSkillTypeEnum.folder) return i18nT('account_team:skill.folder'); + if (type === AgentSkillTypeEnum.skill) return i18nT('account_team:skill.skill'); + 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/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index b993d589ec..7b0457cad7 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -95,6 +95,7 @@ export const iconPaths = { 'common/selectLight': () => import('./icons/common/selectLight.svg'), 'common/setting': () => import('./icons/common/setting.svg'), 'common/settingLight': () => import('./icons/common/settingLight.svg'), + 'common/skill': () => import('./icons/common/skill.svg'), 'common/solidChevronDown': () => import('./icons/common/solidChevronDown.svg'), 'common/solidChevronUp': () => import('./icons/common/solidChevronUp.svg'), 'common/templateMarket': () => import('./icons/common/templateMarket.svg'), @@ -103,6 +104,8 @@ export const iconPaths = { 'common/toolkit': () => import('./icons/common/toolkit.svg'), 'common/trash': () => import('./icons/common/trash.svg'), 'common/uploadFileFill': () => import('./icons/common/uploadFileFill.svg'), + 'common/upperRight': () => import('./icons/common/upperRight.svg'), + 'common/user': () => import('./icons/common/user.svg'), 'common/userInfo': () => import('./icons/common/userInfo.svg'), 'common/variable': () => import('./icons/common/variable.svg'), 'common/viewLight': () => import('./icons/common/viewLight.svg'), @@ -236,6 +239,9 @@ export const iconPaths = { 'core/modules/basicNode': () => import('./icons/core/modules/basicNode.svg'), 'core/modules/fitView': () => import('./icons/core/modules/fitView.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/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'), @@ -282,6 +288,11 @@ export const iconPaths = { 'core/workflow/runSkip': () => import('./icons/core/workflow/runSkip.svg'), 'core/workflow/runSuccess': () => import('./icons/core/workflow/runSuccess.svg'), 'core/workflow/running': () => import('./icons/core/workflow/running.svg'), + 'core/workflow/template/BI': () => import('./icons/core/workflow/template/BI.svg'), + 'core/workflow/template/FileRead': () => import('./icons/core/workflow/template/FileRead.svg'), + 'core/workflow/template/agent': () => import('./icons/core/workflow/template/agent.svg'), + 'core/workflow/template/agentLinear': () => + import('./icons/core/workflow/template/agentLinear.tsx'), 'core/workflow/template/aiChat': () => import('./icons/core/workflow/template/aiChat.svg'), 'core/workflow/template/aiChatLinear': () => import('./icons/core/workflow/template/aiChatLinear.tsx'), diff --git a/packages/web/components/common/Icon/icons/common/skill.svg b/packages/web/components/common/Icon/icons/common/skill.svg new file mode 100644 index 0000000000..fad96a2e93 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/skill.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 new file mode 100644 index 0000000000..e4cb074d7d --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/skill/default.svg b/packages/web/components/common/Icon/icons/core/skill/default.svg new file mode 100644 index 0000000000..491a1ff6c2 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/skill/default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/skill/help.svg b/packages/web/components/common/Icon/icons/core/skill/help.svg new file mode 100644 index 0000000000..94d894c298 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/skill/help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/agent.svg b/packages/web/components/common/Icon/icons/core/workflow/template/agent.svg new file mode 100644 index 0000000000..a66af74941 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/agentLinear.tsx b/packages/web/components/common/Icon/icons/core/workflow/template/agentLinear.tsx new file mode 100644 index 0000000000..c54f260f95 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/agentLinear.tsx @@ -0,0 +1,39 @@ +import React, { useId } from 'react'; + +type AgentLinearProps = React.SVGProps; + +const AgentLinear: React.FC = (props) => { + const gradientId = useId(); + + return ( + + + + + + + + + + + ); +}; + +export default AgentLinear; diff --git a/packages/web/components/common/MyMenu/index.tsx b/packages/web/components/common/MyMenu/index.tsx index 6be822b446..1ef32b2716 100644 --- a/packages/web/components/common/MyMenu/index.tsx +++ b/packages/web/components/common/MyMenu/index.tsx @@ -16,6 +16,7 @@ import MyDivider from '../MyDivider'; import type { IconNameType } from '../Icon/type'; import { useSystem } from '../../../hooks/useSystem'; import Avatar from '../Avatar'; +import MyTooltip from '../MyTooltip'; export type MenuItemType = 'primary' | 'danger' | 'gray' | 'grayBg'; export type MenuSizeType = 'sm' | 'md' | 'xs' | 'mini'; @@ -30,6 +31,8 @@ export type MenuItemData = { description?: string; onClick?: () => any; menuItemStyles?: MenuItemProps; + disabled?: boolean; + disabledTip?: string; }>; }; export type Props = { @@ -286,59 +289,74 @@ const MyMenu = ({ {item.label && {item.label}} {i !== 0 && } - {item.children.map((child, index) => ( - { - e.stopPropagation(); - if (child.onClick) { - setIsOpen(false); - child.onClick(); - } - }} - alignItems={'center'} - fontSize={'sm'} - color={child.isActive ? 'primary.700' : 'myGray.600'} - whiteSpace={'pre-wrap'} - {...typeMapStyle[child.type || 'primary'].styles} - {...sizeMapStyle[size].menuItemStyle} - {...child.menuItemStyles} - > - {!!child.icon && ( - { + const menuItem = ( + { + e.stopPropagation(); + if (child.disabled) { + return; } - sx={{ - '[role="menuitem"]:hover &': { - color: 'inherit' + if (child.onClick) { + setIsOpen(false); + child.onClick(); + } + }} + alignItems={'center'} + fontSize={'sm'} + color={child.isActive ? 'primary.700' : 'myGray.600'} + whiteSpace={'pre-wrap'} + {...typeMapStyle[child.type || 'primary'].styles} + {...sizeMapStyle[size].menuItemStyle} + {...child.menuItemStyles} + > + {!!child.icon && ( + - )} - - - {child.label} - - {child.description && ( - - {child.description} - + sx={{ + '[role="menuitem"]:hover &': { + color: 'inherit' + } + }} + /> )} - - - ))} + + + {child.label} + + {child.description && !child.disabled && ( + + {child.description} + + )} + + + ); + + if (child.disabled && child.disabledTip) { + return ( + + {menuItem} + + ); + } + return menuItem; + })} ); })} diff --git a/packages/web/components/common/Textarea/CodeEditor/type.ts b/packages/web/components/common/Textarea/CodeEditor/type.ts new file mode 100644 index 0000000000..f323478984 --- /dev/null +++ b/packages/web/components/common/Textarea/CodeEditor/type.ts @@ -0,0 +1,7 @@ +import { type Monaco } from '@monaco-editor/react'; + +type CompletionItemProvider = Parameters[1]; +type ProvideCompletionItemsFn = CompletionItemProvider['provideCompletionItems']; + +export type CompletionModel = Parameters[0]; +export type CompletionPosition = Parameters[1]; diff --git a/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts index 5f6eceebd2..e597948208 100644 --- a/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts +++ b/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts @@ -1,5 +1,6 @@ import { type Monaco } from '@monaco-editor/react'; import { useCallback } from 'react'; +import { type CompletionModel, type CompletionPosition } from './type'; /** * Sandbox runtime type declarations. @@ -103,7 +104,7 @@ const useJSCompletion = () => { monaco.languages.registerCompletionItemProvider('javascript', { triggerCharacters: ['.'], - provideCompletionItems: (model, position) => { + provideCompletionItems: (model: CompletionModel, position: CompletionPosition) => { const wordInfo = model.getWordUntilPosition(position); const currentWordPrefix = wordInfo.word; diff --git a/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts index 01a9949c10..2b478d6bbf 100644 --- a/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts +++ b/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts @@ -1,5 +1,6 @@ import { type Monaco } from '@monaco-editor/react'; import { useCallback } from 'react'; +import { type CompletionModel, type CompletionPosition } from './type'; let monacoInstance: Monaco | null = null; @@ -10,7 +11,7 @@ const usePythonCompletion = () => { monaco.languages.registerCompletionItemProvider('python', { triggerCharacters: ['_'], - provideCompletionItems: (model, position) => { + provideCompletionItems: (model: CompletionModel, position: CompletionPosition) => { const wordInfo = model.getWordUntilPosition(position); const currentWordPrefix = wordInfo.word; const lineContent = model.getLineContent(position.lineNumber); diff --git a/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts index 8375fb2fd7..8fe5be04f5 100644 --- a/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts +++ b/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts @@ -1,5 +1,6 @@ import { type Monaco } from '@monaco-editor/react'; import { useCallback } from 'react'; +import { type CompletionModel, type CompletionPosition } from './type'; let monacoInstance: Monaco | null = null; @@ -10,16 +11,8 @@ const useSystemHelperCompletion = () => { const buildSuggestions = ( monaco: Monaco, - model: Parameters< - Parameters< - typeof monaco.languages.registerCompletionItemProvider - >[1]['provideCompletionItems'] - >[0], - position: Parameters< - Parameters< - typeof monaco.languages.registerCompletionItemProvider - >[1]['provideCompletionItems'] - >[1], + model: CompletionModel, + position: CompletionPosition, memberSnippet: string ) => { const lineContent = model.getLineContent(position.lineNumber); @@ -76,7 +69,7 @@ const useSystemHelperCompletion = () => { for (const lang of ['javascript', 'typescript'] as const) { monaco.languages.registerCompletionItemProvider(lang, { triggerCharacters: ['.'], - provideCompletionItems: (model, position) => + provideCompletionItems: (model: CompletionModel, position: CompletionPosition) => buildSuggestions(monaco, model, position, jsSnippet) }); } @@ -85,7 +78,7 @@ const useSystemHelperCompletion = () => { const pySnippet = 'httpRequest(${1:url}, method="${2:GET}", headers={}, timeout=${3:60})'; monaco.languages.registerCompletionItemProvider('python', { triggerCharacters: ['.'], - provideCompletionItems: (model, position) => + provideCompletionItems: (model: CompletionModel, position: CompletionPosition) => buildSuggestions(monaco, model, position, pySnippet) }); }, []); diff --git a/packages/web/components/core/workflow/NodeInputSelect.tsx b/packages/web/components/core/workflow/NodeInputSelect.tsx index 4f3fcbb1e7..f66f15633f 100644 --- a/packages/web/components/core/workflow/NodeInputSelect.tsx +++ b/packages/web/components/core/workflow/NodeInputSelect.tsx @@ -98,6 +98,16 @@ const NodeInputSelect = ({ icon: FlowNodeInputMap[FlowNodeInputTypeEnum.custom].icon, title: t('common:core.workflow.inputType.Manual input') }, + { + type: FlowNodeInputTypeEnum.selectSkill, + icon: FlowNodeInputMap[FlowNodeInputTypeEnum.selectSkill].icon, + title: t('common:core.workflow.inputType.Manual select') + }, + { + type: FlowNodeInputTypeEnum.selectTool, + icon: FlowNodeInputMap[FlowNodeInputTypeEnum.selectTool].icon, + title: t('common:core.workflow.inputType.Manual select') + }, { type: FlowNodeInputTypeEnum.fileSelect, icon: FlowNodeInputMap[FlowNodeInputTypeEnum.fileSelect].icon, diff --git a/packages/web/i18n/constants.ts b/packages/web/i18n/constants.ts index d568e47831..fecdfb8539 100644 --- a/packages/web/i18n/constants.ts +++ b/packages/web/i18n/constants.ts @@ -20,7 +20,8 @@ export const I18N_NAMESPACES = [ 'account_team', 'account_model', 'dashboard_mcp', - 'dashboard_evaluation' + 'dashboard_evaluation', + 'skill' ]; export const I18N_NAMESPACES_MAP = I18N_NAMESPACES.reduce( diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index 117a046a18..6221941d0b 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -64,6 +64,8 @@ "dataset.folder_dataset": "Folder", "dataset.website_dataset": "Website Sync", "dataset.yuque_dataset": "Yuque Knowledge Base", + "skill.folder": "Skill Folder", + "skill.skill": "Skill", "delete": "delete", "delete_api_key": "Delete the API key", "delete_app": "Delete the workbench application", @@ -198,6 +200,18 @@ "log_update_dataset": "【{{name}}】Updated [{{datasetType}}] named [{{datasetName}}]", "log_update_dataset_collaborator": "[{{name}}] Updated the collaborator named [{{datasetName}}] to: Organization: [{{orgList}}], Group: [{{groupList}}], Member [{{tmbList}}]; permissions updated to: [{{readPermission}}], [{{writePermission}}], [{{managePermission}}]", "log_update_publish_app": "【{{name}}】【{{operationName}}】【{{appType}}】 named [{{appName}}】", + "log_create_skill": "[{{name}}] Created a skill named [{{skillName}}]", + "log_create_skill_folder": "[{{name}}] Created a skill folder named [{{folderName}}]", + "log_delete_skill": "[{{name}}] Deleted the skill named [{{skillName}}]", + "log_deploy_skill": "[{{name}}] Deployed the skill named [{{skillName}}]", + "log_import_skill": "[{{name}}] Imported a skill named [{{skillName}}]", + "log_export_skill": "[{{name}}] Exported a skill named [{{skillName}}]", + "log_copy_skill": "[{{name}}] Copied a skill named [{{skillName}}]", + "log_move_skill": "[{{name}}] Moved a skill named [{{skillName}}] to [{{targetFolderName}}]", + "log_update_skill_collaborator": "[{{name}}] Updated collaborators of [{{skillType}}] [{{skillName}}] to: Organizations: [{{orgList}}], Groups: [{{groupList}}], Members: [{{tmbList}}]; Permission: [{{permission}}]", + "log_delete_skill_collaborator": "[{{name}}] Deleted collaborator [{{itemName}}]: [{{itemValueName}}] from [{{skillType}}] [{{skillName}}]", + "log_transfer_skill_ownership": "[{{name}}] Transferred ownership of skill [{{skillName}}] to [{{newOwnerName}}]", + "log_update_skill": "[{{name}}] Updated the skill named [{{skillName}}]", "log_user": "Operator", "login": "Log in", "manage_member": "Managing members", @@ -218,6 +232,8 @@ "permission_appCreate_tip": "Can create applications in the root directory (creation permissions in folders are controlled by the folder)", "permission_datasetCreate": "Create Knowledge Base", "permission_datasetCreate_Tip": "Can create knowledge bases in the root directory (creation permissions in folders are controlled by the folder)", + "permission_skillCreate": "Create Skill", + "permission_skillCreate_Tip": "Can create skills in the root directory (creation permissions in folders are controlled by the folder)", "permission_manage": "Admin", "permission_manage_tip": "Can manage members, create groups, manage all groups, and assign permissions to groups and members", "please_bind_contact": "Please bind the contact information", @@ -261,6 +277,18 @@ "update_dataset": "Update the knowledge base", "update_dataset_collaborator": "Knowledge Base Permission Changes", "update_publish_app": "Application update", + "create_skill": "Create skill", + "create_skill_folder": "Create skill folder", + "delete_skill": "Delete skill", + "deploy_skill": "Deploy skill", + "import_skill": "Import skill", + "export_skill": "Export skill", + "copy_skill": "Copy skill", + "move_skill": "Move skill", + "update_skill_collaborator": "Update skill collaborator", + "delete_skill_collaborator": "Delete skill collaborator", + "transfer_skill_ownership": "Transfer skill ownership", + "update_skill": "Update skill", "used_times_limit": "Limit", "user_name": "username", "user_team_invite_member": "Invite members", diff --git a/packages/web/i18n/en/chat.json b/packages/web/i18n/en/chat.json index b70f368a8c..d289f5e858 100644 --- a/packages/web/i18n/en/chat.json +++ b/packages/web/i18n/en/chat.json @@ -177,5 +177,19 @@ "tool_output": "Tool output", "unsupported_file_type": "Unsupported file types", "variable_invisable_in_share": "External variables are not visible in login-free links", - "view_citations": "View References" + "view_citations": "View References", + "web_site_sync": "Web Site Sync", + "sandbox_status_checkExisting": "Checking sandbox...", + "sandbox_status_connecting": "Connecting to existing sandbox...", + "sandbox_status_fetchSkills": "Loading skill metadata...", + "sandbox_status_creatingContainer": "Starting sandbox (up to 60s)...", + "sandbox_status_deployingSkills": "Deploying skill {{skillName}}...", + "sandbox_status_downloadingPackage": "Downloading {{skillName}} package...", + "sandbox_status_uploadingPackage": "Uploading {{skillName}} package...", + "sandbox_status_extractingPackage": "Extracting {{skillName}} package...", + "sandbox_status_ready_warm": "Sandbox connected", + "sandbox_status_ready_cold": "Sandbox ready", + "sandbox_status_failed": "Sandbox failed to start", + "sandbox_status_lazyInit": "Initializing sandbox...", + "skill_calling": "Calling skill: {{name}}" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index f740531662..e2cd2c0cf2 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -136,6 +136,9 @@ "code_error.app_error.invalid_owner": "Unauthorized Application Owner", "code_error.app_error.not_exist": "Application Does Not Exist", "code_error.app_error.un_auth_app": "Unauthorized to Operate This Application", + "code_error.skill_error.not_exist": "Skill Does Not Exist", + "code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill", + "code_error.skill_error.can_not_edit_admin_permission": "Can not edit admin permission", "code_error.chat_error.un_auth": "Unauthorized to Operate This Chat Record", "code_error.error_code.400": "Request Failed", "code_error.error_code.401": "No Access Permission", @@ -1061,6 +1064,12 @@ "support.wallet.usage.Audio Speech": "Voice Playback", "support.wallet.usage.Code Copilot": "Code Copilot", "support.wallet.usage.Optimize Prompt": "Prompt word optimization", + "support.wallet.usage.Assist Generate Skill": "Skill Assistance Generation", + "support.wallet.usage.Source": "Source", + "support.wallet.usage.Text Length": "Text Length", + "support.wallet.usage.Time": "Generation Time", + "support.wallet.usage.Token Length": "Token Length", + "support.wallet.usage.Total": "Total Amount", "support.wallet.usage.Total points": "Total credit", "support.wallet.usage.Whisper": "Voice Input", "sync_link": "Sync Link", diff --git a/packages/web/i18n/en/publish.json b/packages/web/i18n/en/publish.json index 354f173d5b..6a7a75bd83 100644 --- a/packages/web/i18n/en/publish.json +++ b/packages/web/i18n/en/publish.json @@ -32,6 +32,8 @@ "qpm_tips": "Maximum number of queries per minute per IP", "request_address": "Request URL", "show_node": "real-time running status", + "show_skill_reference": "Show skill references", + "show_skill_reference_tips": "View all referenced Skills and their loading process", "show_share_link_modal_title": "Get Started", "token_auth": "Token Authentication", "token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.", diff --git a/packages/web/i18n/en/skill.json b/packages/web/i18n/en/skill.json new file mode 100644 index 0000000000..b5ba0a0c90 --- /dev/null +++ b/packages/web/i18n/en/skill.json @@ -0,0 +1,72 @@ +{ + "search_skill": "Search", + "create_skill": "New Skill", + "no_skills": "No skills yet", + "copy_skill": "Create a copy", + "related_apps_count": "{{count}} linked apps", + "delete_disabled_tip": "Cannot delete: linked apps exist", + "confirm_delete_tip": "Are you sure you want to delete this Skill?", + "custom_skill": "Custom Skill", + "custom_skill_desc": "Intelligently generate skill outlines and frameworks by describing skill requirements", + "import_skill_zip": "Import zip", + "related_count": "Related Apps", + "creator_tooltip": "Creator: {{creator}}", + "update_time_tooltip": "Updated: {{updateTime}}", + "permission_settings": "Permission Settings", + "export_config": "Export Config", + "create_folder": "New Folder", + "skill_name_placeholder": "Enter skill name", + "skill_intro_label": "Introduction", + "skill_intro_placeholder": "Describe use cases and purpose", + "skill_requirement_label": "Skill Requirements (auto-generate SKILL.md)", + "skill_requirement_tooltip": "TODO", + "skill_requirement_tooltip_title": "Example:", + "skill_requirement_tooltip_example": "Goal:\nAutomatically generate meeting minutes from meeting notes.\n\nProcess:\n1. Identify the meeting topic and participants\n2. Extract key discussion points\n3. Organize clear conclusions and decisions\n4. Extract follow-up action items and note the responsible person (if any)\n\nRequirements:\n1. Output in structured format\n2. Include: meeting topic, participants, discussion points, decisions, action items\n3. Content should be concise and clear, avoiding redundancy", + "skill_requirement_default": "Goal:\nProcess:\nRequirements:", + "ai_optimize": "AI Optimize", + "import_skill": "Import Skill", + "import_skill_file_type_tip": "Supports {{ext}} formats", + "import_skill_max_size_tip": "Upload up to {{maxCount}} file at a time, max file size: {{maxSize}}", + "unsupported_file_format": "File format {{ext}} is not supported", + "skill_info_edit": "Edit Skill Info", + "move_skill": "Move Skill", + "move_skill_hint": "After moving, the selected skill/folder will inherit the permission settings of the new folder.", + "delete_success": "Deleted successfully", + "delete_failed": "Delete failed", + "copy_success": "Copied successfully", + "copy_failed": "Copy failed", + "edit_success": "Updated successfully", + "edit_failed": "Update failed", + "move_success": "Moved successfully", + "move_failed": "Move failed", + "export_success": "Exported successfully", + "export_failed": "Export failed", + "deploy_success": "Published successfully", + "deploy_failed": "Publish failed", + "copy_skill_confirm": "The system will create a skill with the same configuration for you, but the permissions will not be copied. Please confirm!", + "detail_tab_config": "Skill Config", + "detail_tab_preview": "Run Preview", + "generating": "Generating Skill...", + "history_versions": "History Versions", + "exit_tips": "Your changes have not been saved. \"Exit directly\" will not save your edits.", + "select_skill": "Select Skill", + "associated_skills": "Skills", + "skill_select_limit_tip": "Reached the limit of 100 associated Skills per app", + "sandbox_checking": "Checking existing sandbox...", + "sandbox_connecting": "Connecting to sandbox...", + "sandbox_fetch_skills": "Fetching skill configuration...", + "sandbox_creating_container": "Initializing cloud sandbox...", + "sandbox_deploying_skills": "Deploying skill: {{skillName}}...", + "sandbox_downloading": "Downloading skill package...", + "sandbox_uploading": "Uploading skill package to sandbox...", + "sandbox_extracting": "Extracting skill package...", + "sandbox_lazy_init": "Initializing runtime environment...", + "sandbox_ready": "Sandbox is ready", + "sandbox_ready_warm": "Sandbox is ready (warm start)", + "sandbox_failed": "Sandbox creation failed: {{message}}", + "sandbox_retry": "Retry", + "sandbox_error_title": "Sandbox Creation Failed", + "permission.des.read": "Can view Agent Skill", + "permission.des.write": "Can edit Agent Skill", + "permission.des.manage": "Can manage Agent Skill and collaborators" +} diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 1967ccac0c..b957381996 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -170,6 +170,12 @@ "target_fields_description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted.", "template.agent": "Agent", "template.agent_intro": "Automatically select one or more functional blocks for calling through the AI model, or call plugins.", + "template.agent_module": "Agent", + "template.agent_module_intro": "Associate required tools and Skills to enable AI-driven autonomous invocation and planning", + "agent.tools": "Tools", + "agent.prompt_skill_tip": "@ to select Skills and tools, \"/\" to select variables", + "agent.select_skill": "Select Skill", + "agent.select_tool": "Select Tool", "template.ai_chat": "AI Chat", "template.ai_chat_intro": "AI Large Model Chat", "template.dataset_search": "Dataset Search", @@ -218,5 +224,6 @@ "workflow.My edit": "My Edit", "workflow.Switch_success": "Switch Successful", "workflow.Team cloud": "Team Cloud", - "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits." + "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits.", + "params_setting": "Parameters" } diff --git a/packages/web/i18n/i18next.ts b/packages/web/i18n/i18next.ts index ff4cd1b35d..23a249959f 100644 --- a/packages/web/i18n/i18next.ts +++ b/packages/web/i18n/i18next.ts @@ -21,6 +21,7 @@ import type login from './zh-CN/login.json'; import type account_model from './zh-CN/account_model.json'; import type dashboard_mcp from './zh-CN/dashboard_mcp.json'; import type dashboard_evaluation from './zh-CN/dashboard_evaluation.json'; +import type skill from './zh-CN/skill.json'; import type { I18N_NAMESPACES } from './constants'; export interface I18nNamespaces { @@ -46,6 +47,7 @@ export interface I18nNamespaces { account_model: typeof account_model; dashboard_mcp: typeof dashboard_mcp; dashboard_evaluation: typeof dashboard_evaluation; + skill: typeof skill; } export type I18nNsType = (keyof I18nNamespaces)[]; diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index c72738dcc1..175ae2a104 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -56,6 +56,8 @@ "create_invitation_link": "创建邀请链接", "create_invoice": "开发票", "create_org": "创建部门", + "create_skill": "创建技能", + "create_skill_folder": "创建技能文件夹", "create_sub_org": "创建子部门", "dataset.api_file": "API 知识库", "dataset.common_dataset": "知识库", @@ -64,6 +66,8 @@ "dataset.folder_dataset": "文件夹", "dataset.website_dataset": "网站同步", "dataset.yuque_dataset": "语雀知识库", + "skill.folder": "技能文件夹", + "skill.skill": "技能", "delete": "删除", "delete_api_key": "删除api密钥", "delete_app": "删除工作台应用", @@ -79,6 +83,7 @@ "delete_from_team": "移出团队", "delete_group": "删除群组", "delete_org": "删除部门", + "delete_skill": "删除技能", "department": "部门", "edit_info": "编辑信息", "edit_member": "编辑用户", @@ -158,6 +163,8 @@ "log_create_group": "【{{name}}】创建了群组【{{groupName}}】", "log_create_invitation_link": "【{{name}}】创建了邀请链接【{{link}}】", "log_create_invoice": "【{{name}}】进行了开发票操作", + "log_create_skill": "【{{name}}】创建了名为【{{skillName}}】的技能", + "log_create_skill_folder": "【{{name}}】创建了名为【{{folderName}}】的技能文件夹", "log_delete_api_key": "【{{name}}】删除了名为【{{keyName}}】的api密钥", "log_delete_app": "【{{name}}】将名为【{{appName}}】的【{{appType}}】删除", "log_delete_app_collaborator": "【{{name}}】将名为【{{appName}}】的【{{appType}}】中名为【{{itemValueName}}】的【{{itemName}}】权限删除", @@ -168,6 +175,7 @@ "log_delete_dataset_collaborator": "【{{name}}】将名为【{{datasetName}}】的【{{datasetType}}】中名为【itemValueName】的【itemName】权限删除", "log_delete_department": "【{{name}}】删除了部门【{{departmentName}}】", "log_delete_evaluation": "【{{name}}】删除了名为【{{appName}}】的【{{appType}}】的评测数据", + "log_delete_skill": "【{{name}}】删除了名为【{{skillName}}】的技能", "log_delete_group": "【{{name}}】删除了群组【{{groupName}}】", "log_details": "详情", "log_export_app_chat_log": "【{{name}}】导出了名为【{{appName}}】的【{{appType}}】的聊天记录", @@ -198,6 +206,15 @@ "log_update_dataset": "【{{name}}】更新了名为【{{datasetName}}】的【{{datasetType}}】", "log_update_dataset_collaborator": "【{{name}}】将名为【{{datasetName}}】的【{{datasetType}}】的合作者更新为:组织:【{{orgList}}】,群组:【{{groupList}}】,成员【{{tmbList}}】;权限更新为:【{{readPermission}}】,【{{writePermission}}】,【{{managePermission}}】", "log_update_publish_app": "【{{name}}】【{{operationName}}】名为【{{appName}}】的【{{appType}}】", + "log_update_skill": "【{{name}}】更新了名为【{{skillName}}】的技能", + "log_deploy_skill": "【{{name}}】发布了名为【{{skillName}}】的技能", + "log_import_skill": "【{{name}}】导入了名为【{{skillName}}】的技能", + "log_export_skill": "【{{name}}】导出了名为【{{skillName}}】的技能", + "log_copy_skill": "【{{name}}】复制了名为【{{skillName}}】的技能", + "log_move_skill": "【{{name}}】将名为【{{skillName}}】的技能移动到【{{targetFolderName}}】", + "log_update_skill_collaborator": "【{{name}}】将名为【{{skillName}}】的【{{skillType}}】的协作者更新为:组织:【{{orgList}}】,群组:【{{groupList}}】,成员【{{tmbList}}】;权限更新为:【{{permission}}】", + "log_delete_skill_collaborator": "【{{name}}】删除了名为【{{skillName}}】的【{{skillType}}】的协作者【{{itemName}}】:【{{itemValueName}}】", + "log_transfer_skill_ownership": "【{{name}}】将名为【{{skillName}}】的技能所有权转移给了【{{newOwnerName}}】", "log_user": "操作人员", "login": "登录", "manage_member": "管理成员", @@ -218,6 +235,8 @@ "permission_appCreate_tip": "可以在根目录创建应用,(文件夹下的创建权限由文件夹控制)", "permission_datasetCreate": "创建知识库", "permission_datasetCreate_Tip": "可以在根目录创建知识库,(文件夹下的创建权限由文件夹控制)", + "permission_skillCreate": "创建 Skill", + "permission_skillCreate_Tip": "可以在根目录创建 Skill,(文件夹下的创建权限由文件夹控制)", "permission_manage": "管理员", "permission_manage_tip": "可以管理成员、创建群组、管理所有群组、为群组和成员分配权限", "please_bind_contact": "请绑定联系方式", @@ -261,6 +280,15 @@ "update_dataset": "更新知识库", "update_dataset_collaborator": "知识库权限更改", "update_publish_app": "应用更新", + "update_skill": "更新技能", + "deploy_skill": "发布技能", + "import_skill": "导入技能", + "export_skill": "导出技能", + "copy_skill": "复制技能", + "move_skill": "移动技能", + "update_skill_collaborator": "更新技能协作者", + "delete_skill_collaborator": "删除技能协作者", + "transfer_skill_ownership": "转移技能所有权", "used_times_limit": "有效人数", "user_name": "用户名", "user_team_invite_member": "邀请成员", diff --git a/packages/web/i18n/zh-CN/chat.json b/packages/web/i18n/zh-CN/chat.json index f8f9982ae3..b0c79d42dd 100644 --- a/packages/web/i18n/zh-CN/chat.json +++ b/packages/web/i18n/zh-CN/chat.json @@ -177,5 +177,19 @@ "tool_output": "工具输出", "unsupported_file_type": "不支持的文件类型", "variable_invisable_in_share": "外部变量在免登录链接中不可见", - "view_citations": "查看引用" + "view_citations": "查看引用", + "web_site_sync": "Web站点同步", + "sandbox_status_checkExisting": "检查沙箱状态...", + "sandbox_status_connecting": "连接已有沙箱...", + "sandbox_status_fetchSkills": "加载技能信息...", + "sandbox_status_creatingContainer": "启动沙箱(约 60 秒)...", + "sandbox_status_deployingSkills": "部署技能 {{skillName}}...", + "sandbox_status_downloadingPackage": "下载技能包 {{skillName}}...", + "sandbox_status_uploadingPackage": "上传技能包 {{skillName}}...", + "sandbox_status_extractingPackage": "解压技能包 {{skillName}}...", + "sandbox_status_ready_warm": "沙箱已连接", + "sandbox_status_ready_cold": "沙箱就绪", + "sandbox_status_failed": "沙箱启动失败", + "sandbox_status_lazyInit": "初始化沙箱...", + "skill_calling": "正在调用技能:{{name}}" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index a255c4875f..a5d9e0d0b1 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -136,6 +136,9 @@ "code_error.app_error.invalid_owner": "非法的应用所有者", "code_error.app_error.not_exist": "应用不存在", "code_error.app_error.un_auth_app": "无权操作该应用", + "code_error.skill_error.not_exist": "技能不存在", + "code_error.skill_error.un_auth_skill": "无权操作该技能", + "code_error.skill_error.can_not_edit_admin_permission": "不能编辑管理员权限", "code_error.chat_error.un_auth": "没有权限操作此对话记录", "code_error.error_code.400": "请求失败", "code_error.error_code.401": "无访问权限", @@ -1062,6 +1065,13 @@ "support.wallet.usage.Code Copilot": "代码助手", "support.wallet.usage.Optimize Prompt": "提示词优化", "support.wallet.usage.Total points": "积分总消耗", + "support.wallet.usage.Assist Generate Skill": "协助生成Skill", + "support.wallet.usage.Source": "来源", + "support.wallet.usage.Text Length": "文本长度", + "support.wallet.usage.Time": "生成时间", + "support.wallet.usage.Token Length": "token 长度", + "support.wallet.usage.Total": "总金额", + "support.wallet.usage.Usage Detail": "使用详情", "support.wallet.usage.Whisper": "语音输入", "sync_link": "同步链接", "sync_success": "同步成功", diff --git a/packages/web/i18n/zh-CN/publish.json b/packages/web/i18n/zh-CN/publish.json index 05201677c9..46d3922598 100644 --- a/packages/web/i18n/zh-CN/publish.json +++ b/packages/web/i18n/zh-CN/publish.json @@ -32,6 +32,8 @@ "qpm_tips": "每个 IP 每分钟最多提问多少次", "request_address": "请求地址", "show_node": "实时运行状态", + "show_skill_reference": "查看 Skill 引用", + "show_skill_reference_tips": "查看引用的所有 Skill 及加载过程", "show_share_link_modal_title": "开始使用", "token_auth": "身份验证", "token_auth_tips": "身份校验服务器地址", diff --git a/packages/web/i18n/zh-CN/skill.json b/packages/web/i18n/zh-CN/skill.json new file mode 100644 index 0000000000..7a2bfb5ab3 --- /dev/null +++ b/packages/web/i18n/zh-CN/skill.json @@ -0,0 +1,71 @@ +{ + "search_skill": "搜索", + "create_skill": "新建 Skill", + "no_skills": "暂无 Skill", + "copy_skill": "创建副本", + "related_apps_count": "关联应用 {{count}}", + "delete_disabled_tip": "存在关联应用,无法删除", + "confirm_delete_tip": "确认删除该 Skill?", + "custom_skill": "自定义 Skill", + "custom_skill_desc": "通过描述 Skill 需求,智能生成 Skill 大纲及框架", + "import_skill_zip": "导入压缩包", + "related_count": "关联应用", + "creator_tooltip": "创建人:{{creator}}", + "update_time_tooltip": "更新时间:{{updateTime}}", + "permission_settings": "权限设置", + "export_config": "导出配置", + "create_folder": "新建文件夹", + "skill_name_placeholder": "请输入 Skill 名称", + "skill_intro_label": "介绍", + "skill_intro_placeholder": "介绍使用场景及用途", + "skill_requirement_label": "Skill 需求(智能生成 SKILL.md)", + "skill_requirement_tooltip_title": "示例:", + "skill_requirement_tooltip_example": "目标:\n根据会议记录自动生成会议纪要。\n\n流程:\n1、识别会议主题和参与人员\n2、提取讨论的关键要点\n3、整理出明确的结论和决策\n4、提取需要跟进的行动项,并标注负责人(如有)\n\n要求:\n1、结果以结构化格式输出\n2、包含:会议主题、参与人、讨论要点、决策结论、行动项\n3、内容简洁清晰,避免冗余描述", + "skill_requirement_default": "目标:\n流程:\n要求:", + "ai_optimize": "AI 优化", + "import_skill": "导入 Skill", + "import_skill_file_type_tip": "支持 {{ext}} 格式", + "import_skill_max_size_tip": "单次最多上传 {{maxCount}} 个文件,单个文件最大 {{maxSize}}", + "unsupported_file_format": "不支持 {{ext}} 文件格式", + "skill_info_edit": "技能信息编辑", + "move_skill": "移动技能", + "move_skill_hint": "移动后,所选技能/文件夹将继承新文件夹的权限设置。", + "delete_success": "删除成功", + "delete_failed": "删除失败", + "copy_success": "复制成功", + "copy_failed": "复制失败", + "edit_success": "编辑成功", + "edit_failed": "编辑失败", + "move_success": "移动成功", + "move_failed": "移动失败", + "export_success": "导出成功", + "export_failed": "导出失败", + "deploy_success": "发布成功", + "deploy_failed": "发布失败", + "copy_skill_confirm": "系统将为您创建一个相同配置技能,但权限不会进行复制,请确认!", + "detail_tab_config": "Skill 配置", + "detail_tab_preview": "运行预览", + "generating": "Skill 生成中...", + "history_versions": "历史版本", + "exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。", + "select_skill": "选择 Skill", + "associated_skills": "关联 Skill", + "skill_select_limit_tip": "已达到单个应用可关联 Skill 的上限(100 个)", + "sandbox_checking": "正在检查现有沙箱环境...", + "sandbox_connecting": "正在连接沙箱环境...", + "sandbox_fetch_skills": "正在获取 Skill 配置信息...", + "sandbox_creating_container": "正在初始化云端沙箱...", + "sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...", + "sandbox_downloading": "正在下载 Skill 包...", + "sandbox_uploading": "正在上传 Skill 包到沙箱...", + "sandbox_extracting": "正在解压 Skill 包...", + "sandbox_lazy_init": "正在初始化运行环境...", + "sandbox_ready": "沙箱环境就绪", + "sandbox_ready_warm": "沙箱环境就绪(热启动)", + "sandbox_failed": "沙箱创建失败: {{message}}", + "sandbox_retry": "重试", + "sandbox_error_title": "沙箱创建失败", + "permission.des.read": "可查看 Agent Skill", + "permission.des.write": "可编辑 Agent Skill", + "permission.des.manage": "可管理 Agent Skill 和协作者" +} diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index 83f1ef252d..4bd04483d7 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -170,6 +170,12 @@ "target_fields_description": "由 '描述' 和 'key' 组成一个目标字段,可提取多个目标字段", "template.agent": "工具调用", "template.agent_intro": "由 AI 自主决定工具调用。", + "template.agent_module": "Agent", + "template.agent_module_intro": "关联所需工具及 Skill,可实现 AI 自主调用与规划", + "agent.tools": "工具", + "agent.prompt_skill_tip": "@选择 Skill 及工具,\"/\"选择变量", + "agent.select_skill": "选择 Skill", + "agent.select_tool": "选择工具", "template.ai_chat": "AI 对话", "template.ai_chat_intro": "AI 大模型对话", "template.dataset_search": "知识库搜索", @@ -218,5 +224,6 @@ "workflow.My edit": "我的编辑", "workflow.Switch_success": "切换成功", "workflow.Team cloud": "团队云端", - "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。" + "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。", + "params_setting": "参数设置" } diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index ec09e58ac9..bcaaeaf4b5 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -63,6 +63,8 @@ "dataset.folder_dataset": "資料夾", "dataset.website_dataset": "網站同步", "dataset.yuque_dataset": "語雀知識庫", + "skill.folder": "技能資料夾", + "skill.skill": "技能", "delete": "刪除", "delete_api_key": "刪除api密鑰", "delete_app": "刪除工作台應用", @@ -194,6 +196,18 @@ "log_update_dataset": "【{{name}}】更新了名為【{{datasetName}}】的【{{datasetType}}】", "log_update_dataset_collaborator": "【{{name}}】將名為【{{datasetName}}】的【{{datasetType}}】的合作者更新為:組織:【{{orgList}}】,群組:【{{groupList}}】,成員【{{tmbList}}】;權限更新為:【{{readPermission}}】,【{{writePermission}}】,【{{managePermission}}】", "log_update_publish_app": "【{{name}}】【{{operationName}}】名為【{{appName}}】的【{{appType}}】", + "log_create_skill": "【{{name}}】建立了名為【{{skillName}}】的技能", + "log_create_skill_folder": "【{{name}}】建立了名為【{{folderName}}】的技能資料夾", + "log_delete_skill": "【{{name}}】刪除了名為【{{skillName}}】的技能", + "log_deploy_skill": "【{{name}}】發布了名為【{{skillName}}】的技能", + "log_import_skill": "【{{name}}】匯入了名為【{{skillName}}】的技能", + "log_export_skill": "【{{name}}】匯出了名為【{{skillName}}】的技能", + "log_copy_skill": "【{{name}}】複製了名為【{{skillName}}】的技能", + "log_move_skill": "【{{name}}】將名為【{{skillName}}】的技能移動到【{{targetFolderName}}】", + "log_update_skill_collaborator": "【{{name}}】將名為【{{skillName}}】的【{{skillType}}】的協作者更新為:組織:【{{orgList}}】,群組:【{{groupList}}】,成員【{{tmbList}}】;權限更新為:【{{permission}}】", + "log_delete_skill_collaborator": "【{{name}}】刪除了名為【{{skillName}}】的【{{skillType}}】的協作者【{{itemName}}】:【{{itemValueName}}】", + "log_transfer_skill_ownership": "【{{name}}】將名為【{{skillName}}】的技能所有權轉移給了【{{newOwnerName}}】", + "log_update_skill": "【{{name}}】更新了名為【{{skillName}}】的技能", "log_user": "操作人員", "login": "登入", "manage_member": "管理成員", @@ -214,6 +228,8 @@ "permission_appCreate_tip": "可以在根目錄建立應用程式,(資料夾下的建立權限由資料夾控制)", "permission_datasetCreate": "建立知識庫", "permission_datasetCreate_Tip": "可以在根目錄建立知識庫,(資料夾下的建立權限由資料夾控制)", + "permission_skillCreate": "建立 Skill", + "permission_skillCreate_Tip": "可以在根目錄建立 Skill,(資料夾下的建立權限由資料夾控制)", "permission_manage": "管理員", "permission_manage_tip": "可以管理成員、建立群組、管理所有群組、為群組和成員分配權限", "please_bind_contact": "請綁定聯繫方式", @@ -257,6 +273,18 @@ "update_dataset": "更新知識庫", "update_dataset_collaborator": "知識庫權限更改", "update_publish_app": "應用更新", + "create_skill": "建立技能", + "create_skill_folder": "建立技能資料夾", + "delete_skill": "刪除技能", + "deploy_skill": "發布技能", + "import_skill": "匯入技能", + "export_skill": "匯出技能", + "copy_skill": "複製技能", + "move_skill": "移動技能", + "update_skill_collaborator": "更新技能協作者", + "delete_skill_collaborator": "刪除技能協作者", + "transfer_skill_ownership": "轉移技能所有權", + "update_skill": "更新技能", "used_times_limit": "有效人數", "user_name": "使用者名稱", "user_team_invite_member": "邀請成員", diff --git a/packages/web/i18n/zh-Hant/chat.json b/packages/web/i18n/zh-Hant/chat.json index c9ac6e99c4..20a3ecf9a4 100644 --- a/packages/web/i18n/zh-Hant/chat.json +++ b/packages/web/i18n/zh-Hant/chat.json @@ -173,5 +173,19 @@ "tool_output": "工具輸出", "unsupported_file_type": "不支援的檔案類型", "variable_invisable_in_share": "外部變量在免登錄鏈接中不可見", - "view_citations": "檢視引用" + "view_citations": "檢視引用", + "web_site_sync": "網站同步", + "sandbox_status_checkExisting": "檢查沙箱狀態...", + "sandbox_status_connecting": "連接已有沙箱...", + "sandbox_status_fetchSkills": "載入技能資訊...", + "sandbox_status_creatingContainer": "啟動沙箱(約 60 秒)...", + "sandbox_status_deployingSkills": "部署技能 {{skillName}}...", + "sandbox_status_downloadingPackage": "下載技能套件 {{skillName}}...", + "sandbox_status_uploadingPackage": "上傳技能套件 {{skillName}}...", + "sandbox_status_extractingPackage": "解壓縮技能套件 {{skillName}}...", + "sandbox_status_ready_warm": "沙箱已連接", + "sandbox_status_ready_cold": "沙箱就緒", + "sandbox_status_failed": "沙箱啟動失敗", + "sandbox_status_lazyInit": "初始化沙箱...", + "skill_calling": "正在呼叫技能:{{name}}" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 58a6faea02..654857531d 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -135,6 +135,9 @@ "code_error.app_error.invalid_owner": "非法的應用程式擁有者", "code_error.app_error.not_exist": "應用程式不存在", "code_error.app_error.un_auth_app": "無權操作此應用程式", + "code_error.skill_error.not_exist": "技能不存在", + "code_error.skill_error.un_auth_skill": "無權操作該技能", + "code_error.skill_error.can_not_edit_admin_permission": "不能編輯管理員權限", "code_error.chat_error.un_auth": "沒有權限操作此對話記錄", "code_error.error_code.400": "請求失敗", "code_error.error_code.401": "無存取權限", @@ -1051,6 +1054,13 @@ "support.wallet.usage.Code Copilot": "代碼助手", "support.wallet.usage.Optimize Prompt": "提示詞優化", "support.wallet.usage.Total points": "積分總消耗", + "support.wallet.usage.Assist Generate Skill": "協助生成skill", + "support.wallet.usage.Source": "來源", + "support.wallet.usage.Text Length": "文字長度", + "support.wallet.usage.Time": "產生時間", + "support.wallet.usage.Token Length": "Token 長度", + "support.wallet.usage.Total": "總金額", + "support.wallet.usage.Usage Detail": "使用詳細資訊", "support.wallet.usage.Whisper": "語音輸入", "sync_link": "同步連結", "sync_success": "同步成功", diff --git a/packages/web/i18n/zh-Hant/publish.json b/packages/web/i18n/zh-Hant/publish.json index 0488c6b2ba..e808894bf8 100644 --- a/packages/web/i18n/zh-Hant/publish.json +++ b/packages/web/i18n/zh-Hant/publish.json @@ -32,6 +32,8 @@ "qpm_tips": "每個 IP 每分鐘最高查詢次數", "request_address": "請求網址", "show_node": "即時執行狀態", + "show_skill_reference": "查看 Skill 引用", + "show_skill_reference_tips": "查看引用的所有 Skill 及載入過程", "show_share_link_modal_title": "開始使用", "token_auth": "身分驗證", "token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。", diff --git a/packages/web/i18n/zh-Hant/skill.json b/packages/web/i18n/zh-Hant/skill.json new file mode 100644 index 0000000000..4466d4252b --- /dev/null +++ b/packages/web/i18n/zh-Hant/skill.json @@ -0,0 +1,71 @@ +{ + "search_skill": "搜尋", + "create_skill": "新建 Skill", + "no_skills": "暫無 Skill", + "copy_skill": "建立副本", + "related_apps_count": "關聯應用 {{count}}", + "delete_disabled_tip": "存在關聯應用,無法刪除", + "confirm_delete_tip": "確認刪除該 Skill?", + "custom_skill": "自定義 Skill", + "custom_skill_desc": "通過描述 Skill 需求,智能生成 Skill 大綱及框架", + "import_skill_zip": "導入壓縮包", + "related_count": "關聯應用", + "creator_tooltip": "創建人:{{creator}}", + "update_time_tooltip": "更新時間:{{updateTime}}", + "permission_settings": "權限設置", + "export_config": "導出配置", + "create_folder": "新建文件夾", + "skill_name_placeholder": "請輸入 Skill 名稱", + "skill_intro_label": "介紹", + "skill_intro_placeholder": "介紹使用場景及用途", + "skill_requirement_label": "Skill 需求(智能生成 SKILL.md)", + "skill_requirement_tooltip_title": "示例:", + "skill_requirement_tooltip_example": "目標:\n根據會議記錄自動生成會議紀要。\n\n流程:\n1、識別會議主題和參與人員\n2、提取討論的關鍵要點\n3、整理出明確的結論和決策\n4、提取需要跟進的行動項,並標注負責人(如有)\n\n要求:\n1、結果以結構化格式輸出\n2、包含:會議主題、參與人、討論要點、決策結論、行動項\n3、內容簡潔清晰,避免冗余描述", + "skill_requirement_default": "目標:\n流程:\n要求:", + "ai_optimize": "AI 優化", + "import_skill": "導入 Skill", + "import_skill_file_type_tip": "支持 {{ext}} 格式", + "import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}", + "unsupported_file_format": "不支持 {{ext}} 文件格式", + "skill_info_edit": "技能資訊編輯", + "move_skill": "移動 Skill", + "move_skill_hint": "移動後,所選 Skill/文件夾將繼承新文件夾的權限設置。", + "delete_success": "刪除成功", + "delete_failed": "刪除失敗", + "copy_success": "複製成功", + "copy_failed": "複製失敗", + "edit_success": "編輯成功", + "edit_failed": "編輯失敗", + "move_success": "移動成功", + "move_failed": "移動失敗", + "export_success": "導出成功", + "export_failed": "導出失敗", + "deploy_success": "發布成功", + "deploy_failed": "發布失敗", + "copy_skill_confirm": "系統將為您創建一個相同配置技能,但權限不會進行複製,請確認!", + "detail_tab_config": "Skill 配置", + "detail_tab_preview": "執行預覽", + "generating": "Skill 生成中...", + "history_versions": "歷史版本", + "exit_tips": "您的更改尚未儲存,「直接退出」將不會儲存您的編輯記錄。", + "select_skill": "選擇 Skill", + "associated_skills": "關聯 Skill", + "skill_select_limit_tip": "已達到單個應用可關聯 Skill 的上限(100 個)", + "sandbox_checking": "正在檢查現有沙箱環境...", + "sandbox_connecting": "正在連接沙箱環境...", + "sandbox_fetch_skills": "正在獲取 Skill 配置資訊...", + "sandbox_creating_container": "正在初始化雲端沙箱...", + "sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...", + "sandbox_downloading": "正在下載 Skill 包...", + "sandbox_uploading": "正在上傳 Skill 包到沙箱...", + "sandbox_extracting": "正在解壓 Skill 包...", + "sandbox_lazy_init": "正在初始化運行環境...", + "sandbox_ready": "沙箱環境就緒", + "sandbox_ready_warm": "沙箱環境就緒(熱啟動)", + "sandbox_failed": "沙箱創建失敗: {{message}}", + "sandbox_retry": "重試", + "sandbox_error_title": "沙箱創建失敗", + "permission.des.read": "可查看 Agent Skill", + "permission.des.write": "可編輯 Agent Skill", + "permission.des.manage": "可管理 Agent Skill 和協作者" +} diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index d4084c8217..d6c3199870 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -168,8 +168,14 @@ "special_array_format": "特殊陣列格式,搜尋結果為空時,回傳空陣列。", "start_with": "開頭為", "target_fields_description": "由「描述」和「鍵值」組成一個目標欄位,可以擷取多個目標欄位", - "template.agent": "工具调用", + "template.agent": "工具呼叫", "template.agent_intro": "透過 AI 模型自動選擇一或多個功能區塊進行呼叫,也可以呼叫外掛程式。", + "template.agent_module": "Agent", + "template.agent_module_intro": "關聯所需工具及 Skill,可實現 AI 自主呼叫與規劃", + "agent.tools": "工具", + "agent.prompt_skill_tip": "@選擇 Skill 及工具,\"/\"選擇變數", + "agent.select_skill": "選擇 Skill", + "agent.select_tool": "選擇工具", "template.ai_chat": "AI 對話", "template.ai_chat_intro": "AI 大型語言模型對話", "template.dataset_search": "知識庫搜尋", @@ -218,5 +224,6 @@ "workflow.My edit": "我的編輯", "workflow.Switch_success": "切換成功", "workflow.Team cloud": "團隊雲端", - "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。" + "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。", + "params_setting": "參數設定" } diff --git a/packages/web/support/user/audit/constants.ts b/packages/web/support/user/audit/constants.ts index aa77ea0b94..4a1cd82dba 100644 --- a/packages/web/support/user/audit/constants.ts +++ b/packages/web/support/user/audit/constants.ts @@ -510,5 +510,86 @@ export const auditLogMap = { content: i18nT('account_team:log_delete_api_key'), typeLabel: i18nT('account_team:delete_api_key'), params: {} as { name?: string; keyName: string } + }, + //Agent Skills + [AuditEventEnum.CREATE_SKILL]: { + content: i18nT('account_team:log_create_skill'), + typeLabel: i18nT('account_team:create_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.UPDATE_SKILL]: { + content: i18nT('account_team:log_update_skill'), + typeLabel: i18nT('account_team:update_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.DEPLOY_SKILL]: { + content: i18nT('account_team:log_deploy_skill'), + typeLabel: i18nT('account_team:deploy_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.DELETE_SKILL]: { + content: i18nT('account_team:log_delete_skill'), + typeLabel: i18nT('account_team:delete_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.IMPORT_SKILL]: { + content: i18nT('account_team:log_import_skill'), + typeLabel: i18nT('account_team:import_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.CREATE_SKILL_FOLDER]: { + content: i18nT('account_team:log_create_skill_folder'), + typeLabel: i18nT('account_team:create_skill_folder'), + params: {} as { name?: string; folderName: string } + }, + [AuditEventEnum.EXPORT_SKILL]: { + content: i18nT('account_team:log_export_skill'), + typeLabel: i18nT('account_team:export_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.COPY_SKILL]: { + content: i18nT('account_team:log_copy_skill'), + typeLabel: i18nT('account_team:copy_skill'), + params: {} as { name?: string; skillName: string; skillType: string } + }, + [AuditEventEnum.MOVE_SKILL]: { + content: i18nT('account_team:log_move_skill'), + typeLabel: i18nT('account_team:move_skill'), + params: {} as { name?: string; skillName: string; skillType: string; targetFolderName: string } + }, + [AuditEventEnum.UPDATE_SKILL_COLLABORATOR]: { + content: i18nT('account_team:log_update_skill_collaborator'), + typeLabel: i18nT('account_team:update_skill_collaborator'), + params: {} as { + name?: string; + skillName: string; + skillType: string; + tmbList: string[]; + groupList: string[]; + orgList: string[]; + permission: string; + } + }, + [AuditEventEnum.DELETE_SKILL_COLLABORATOR]: { + content: i18nT('account_team:log_delete_skill_collaborator'), + typeLabel: i18nT('account_team:delete_skill_collaborator'), + params: {} as { + name?: string; + skillName: string; + skillType: string; + itemName: string; + itemValueName: string; + } + }, + [AuditEventEnum.TRANSFER_SKILL_OWNERSHIP]: { + content: i18nT('account_team:log_transfer_skill_ownership'), + typeLabel: i18nT('account_team:transfer_skill_ownership'), + params: {} as { + name?: string; + skillName: string; + skillType: string; + oldOwnerName: string; + newOwnerName: string; + } } } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 421d415b29..76afc10fe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lodash: specifier: 'catalog:' version: 4.17.23 @@ -701,6 +704,9 @@ importers: framer-motion: specifier: 9.1.7 version: 9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + http-proxy: + specifier: ^1.18.1 + version: 1.18.1 hyperdown: specifier: ^2.4.29 version: 2.4.29 @@ -725,6 +731,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lodash: specifier: 'catalog:' version: 4.17.23 @@ -819,6 +828,9 @@ importers: '@types/archiver': specifier: ^6.0.2 version: 6.0.4 + '@types/http-proxy': + specifier: ^1.17.15 + version: 1.17.17 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -4859,6 +4871,9 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/http-proxy@1.17.17': + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -7576,6 +7591,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + http-signature@1.2.0: resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} engines: {node: '>=0.8', npm: '>=1.3.7'} @@ -10050,6 +10069,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -16212,6 +16234,10 @@ snapshots: '@types/http-errors@2.0.4': {} + '@types/http-proxy@1.17.17': + dependencies: + '@types/node': 20.17.24 + '@types/js-cookie@3.0.6': {} '@types/js-yaml@4.0.9': {} @@ -19672,6 +19698,14 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11(debug@4.4.0) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + http-signature@1.2.0: dependencies: assert-plus: 1.0.0 @@ -22651,6 +22685,8 @@ snapshots: require-main-filename@2.0.0: {} + requires-port@1.0.0: {} + resize-observer-polyfill@1.5.1: {} resolve-alpn@1.2.1: {} diff --git a/projects/app/.env.template b/projects/app/.env.template index 88e37ad592..0dd6b834c1 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -54,6 +54,9 @@ AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH=/workspace # E2B 配置(PROVIDER=e2b 时生效) AGENT_SANDBOX_E2B_API_KEY= +# 活跃沙箱数量上限(不设置则不限制) +AGENT_SANDBOX_MAX_EDIT_DEBUG= +AGENT_SANDBOX_MAX_SESSION_RUNTIME= # 辅助生成模型(暂时只能指定一个,需保证系统中已激活该模型) HELPER_BOT_MODEL=qwen-max @@ -174,6 +177,10 @@ CONFIG_JSON_PATH= # HTML 转 Markdown 最大字符数(超过后不执行转换) MAX_HTML_TRANSFORM_CHARS= +# ==================== Beta features ==================== +# 是否展示 Skill 功能入口 +SHOW_SKILL=false + # ==================== 对话日志推送(可选) ==================== # 日志服务地址 # CHAT_LOG_URL=http://localhost:8080 diff --git a/projects/app/next.config.ts b/projects/app/next.config.ts index 3886a2e48e..41a5a35425 100644 --- a/projects/app/next.config.ts +++ b/projects/app/next.config.ts @@ -23,7 +23,7 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: '/((?!chat/share$).*)', + source: '/((?!chat\\/share$)(?!proxy\\/)(?!absproxy\\/).*)', headers: [ { key: 'X-Frame-Options', diff --git a/projects/app/package.json b/projects/app/package.json index 82b66a3d00..f1604650cd 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -4,9 +4,10 @@ "private": false, "scripts": { "dev": "NODE_OPTIONS='--max-old-space-size=8192' npm run build:workers && next dev", + "dev:skill": "NODE_OPTIONS='--max-old-space-size=8192' npm run build:workers && tsx server.ts", "dev:webpack": "NODE_OPTIONS='--max-old-space-size=8192' npm run build:workers && WEBPACK=1 next dev --webpack", "build": "npm run build:workers && next build --debug --webpack", - "start": "next start", + "start": "NODE_ENV=production tsx server.ts", "build:workers": "npx tsx scripts/build-workers.ts", "typecheck": "tsc --noEmit --pretty", "build:workers:watch": "npx tsx scripts/build-workers.ts --watch" @@ -53,6 +54,7 @@ "jsondiffpatch": "^0.7.2", "jsonwebtoken": "^9.0.2", "lodash": "catalog:", + "jszip": "^3.10.1", "mermaid": "^10.9.4", "minio": "catalog:", "nanoid": "^5.1.3", @@ -77,11 +79,13 @@ "remark-math": "^6.0.0", "request-ip": "^3.3.0", "sass": "^1.58.3", + "http-proxy": "^1.18.1", "undici": "^7.18.2", "use-context-selector": "^1.4.4", "zod": "catalog:" }, "devDependencies": { + "@types/http-proxy": "^1.17.15", "@next/bundle-analyzer": "16.1.6", "@svgr/webpack": "^6.5.1", "@types/archiver": "^6.0.2", diff --git a/projects/app/public/imgs/terminalBg.svg b/projects/app/public/imgs/terminalBg.svg new file mode 100644 index 0000000000..925433beca --- /dev/null +++ b/projects/app/public/imgs/terminalBg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/app/server.ts b/projects/app/server.ts new file mode 100644 index 0000000000..aa2f2f278c --- /dev/null +++ b/projects/app/server.ts @@ -0,0 +1,519 @@ +import type { IncomingMessage } from 'http'; +import { createServer, ServerResponse } from 'http'; +import { parse } from 'url'; +import next from 'next'; +import httpProxy from 'http-proxy'; +import { Readable } from 'stream'; +import net from 'net'; +import crypto from 'crypto'; + +const dev = process.env.NODE_ENV !== 'production'; +const port = parseInt(process.env.PORT || '3000', 10); + +// sandboxId: alphanumeric + hyphens, 8–64 chars, must start/end with alnum. +// Explicitly excludes '.', '/', '%', '..', and other path-traversal characters. +const SANDBOX_ID_RE = /[a-zA-Z0-9][a-zA-Z0-9-]{6,62}[a-zA-Z0-9]/; + +// Match /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port} +const PATH_PROXY_RE = new RegExp(`^\\/(proxy|absproxy)\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`); + +// Match /tcptunnel/{sandboxId}/{port} — WebSocket upgrade only +const TCPTUNNEL_RE = new RegExp(`^\\/tcptunnel\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`); + +// Strip subdomain prefix from a host string (may include :port). +// "port--uuid.localhost:3000" → "localhost:3000" +function deriveBaseHost(subdomainHost: string): string { + const dotIdx = subdomainHost.indexOf('.'); + return dotIdx >= 0 ? subdomainHost.substring(dotIdx + 1) : subdomainHost; +} + +async function main() { + const app = next({ dev }); + const handle = app.getRequestHandler(); + await app.prepare(); + + // Import pure utilities from sandboxProxyUtils — no service-layer deps, safe in tsx CJS mode. + // getSandboxProxyTarget is NOT imported here; auth is delegated to the proxyAuth API route. + const { parseSubdomainProxy, rewriteHtml, redeemRelayToken } = (await import( + './src/service/core/sandbox/proxyUtils' + )) as typeof import('./src/service/core/sandbox/proxyUtils'); + + const proxy = httpProxy.createProxyServer({ xfwd: true, changeOrigin: true }); + proxy.on( + 'error', + (err: Error, _req: IncomingMessage, res: ServerResponse | import('stream').Duplex) => { + if (res instanceof ServerResponse && !res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end(`Proxy error: ${err.message}`); + } + } + ); + + // absproxy: fetch upstream then rewrite HTML paths with base prefix + async function handleAbsProxy( + req: IncomingMessage, + res: ServerResponse, + target: string, + sandboxId: string, + targetPort: string + ) { + const upstreamUrl = `${target}${req.url || '/'}`; + const response = await fetch(upstreamUrl, { + method: req.method, + headers: buildProxyHeaders(req.headers), + // @ts-ignore — Node 18+ supports duplex on fetch body streams + duplex: 'half', + body: req.method !== 'GET' && req.method !== 'HEAD' ? (req as any) : undefined + }); + + const skipHeaders = new Set([ + 'content-encoding', + 'transfer-encoding', + 'x-frame-options', + 'content-security-policy' + ]); + response.headers.forEach((value, key) => { + if (!skipHeaders.has(key.toLowerCase())) res.setHeader(key, value); + }); + res.statusCode = response.status; + + const contentType = response.headers.get('content-type') || ''; + const contentLength = Number(response.headers.get('content-length') || 0); + + // Only rewrite HTML; stream large or binary responses directly + if (contentType.includes('text/html') && response.body && contentLength < 10 * 1024 * 1024) { + const html = await response.text(); + const basePath = `/absproxy/${sandboxId}/${targetPort}`; + const rewritten = rewriteHtml(html, basePath); + res.setHeader('content-length', Buffer.byteLength(rewritten)); + res.end(rewritten); + } else if (response.body) { + Readable.fromWeb(response.body as any).pipe(res); + } else { + res.end(); + } + } + + async function handleProxy( + req: IncomingMessage, + res: ServerResponse, + sandboxId: string, + portNum: number, + proxyType: string + ) { + try { + const target = await authProxyTarget(req.headers, sandboxId, portNum); + if (proxyType === 'absproxy') { + await handleAbsProxy(req, res, target, sandboxId, String(portNum)); + } else { + // Rewrite Origin so code-server's CSRF check passes (changeOrigin only rewrites Host). + const targetUrl = new URL(target); + proxy.web(req, res, { + target, + headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } + }); + } + } catch (err: any) { + const status = err.statusCode || 502; + if (!res.headersSent) { + res.writeHead(status, { 'Content-Type': 'text/plain' }); + res.end(err.message || 'Proxy error'); + } + } + } + + // Subdomain proxy handler: on auth failure (401/403) redirect to proxyAuth for cross-domain cookie hand-off. + async function handleSubdomainProxy( + req: IncomingMessage, + res: ServerResponse, + sandboxId: string, + portNum: number + ) { + // Check for relay token in query string (?__pt=). + // proxyAuth GET redirects here after storing fastgptToken server-side. + // We set the cookie from this subdomain so Chrome scopes it correctly. + const urlObj = new URL(`http://placeholder${req.url || '/'}`); + const relayToken = urlObj.searchParams.get('__pt'); + if (relayToken) { + const fastgptToken = redeemRelayToken(relayToken); + if (fastgptToken) { + urlObj.searchParams.delete('__pt'); + const cleanUrl = urlObj.pathname + (urlObj.search !== '?' ? urlObj.search : ''); + dev && + console.log( + `[proxy:subdomain] relay token redeemed, setting cookie and redirecting to ${cleanUrl}` + ); + res.setHeader( + 'Set-Cookie', + `fastgpt_token=${fastgptToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800` + ); + res.writeHead(302, { Location: cleanUrl || '/' }); + res.end(); + return; + } + console.warn(`[proxy:subdomain] relay token invalid or expired: ${relayToken}`); + } + + try { + const target = await authProxyTarget(req.headers, sandboxId, portNum); + const targetUrl = new URL(target); + proxy.web(req, res, { + target, + headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } + }); + } catch (err: any) { + const status = err.statusCode || 502; + // Auth failure — redirect to proxyAuth on the base origin for cookie hand-off + if (status === 401 || status === 403) { + const host = req.headers.host!; + const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; + const originalUrl = `${proto}://${host}${req.url || '/'}`; + const authBase = `${proto}://${deriveBaseHost(host)}`; + const authUrl = new URL(`${authBase}/api/core/sandbox/proxyAuth`); + authUrl.searchParams.set('sandboxId', sandboxId); + authUrl.searchParams.set('port', String(portNum)); + authUrl.searchParams.set('next', originalUrl); + console.warn( + `[proxy:subdomain] auth failed (${status}), redirecting to proxyAuth. next=${originalUrl}` + ); + res.writeHead(302, { Location: authUrl.toString() }); + res.end(); + return; + } + if (!res.headersSent) { + res.writeHead(status, { 'Content-Type': 'text/plain' }); + res.end(err.message || 'Proxy error'); + } + } + } + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const parsedUrl = parse(req.url || ''); + + // ① Check subdomain proxy first: {port}--{sandboxId}.{baseDomain} + const subdomain = parseSubdomainProxy(req.headers.host); + if (subdomain) { + await handleSubdomainProxy(req, res, subdomain.sandboxId, subdomain.port); + return; + } + + // ② Path-based proxy: /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port} + const match = parsedUrl.pathname?.match(PATH_PROXY_RE); + if (match) { + const [, proxyType, sandboxId, portStr] = match; + // Strip proxy prefix so upstream sees the real path + req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portStr}`, '') || '/'; + await handleProxy(req, res, sandboxId, Number(portStr), proxyType); + return; + } + + // ③ Fall through to Next.js handler + handle(req, res, parsedUrl as any); + }); + + // WebSocket upgrade handler — supports all three proxy modes + server.on('upgrade', async (req: IncomingMessage, socket, head) => { + // ① tcptunnel: raw TCP-over-WebSocket, handled before all other upgrade logic + const tunnelMatch = req.url?.match(TCPTUNNEL_RE); + if (tunnelMatch) { + const tunnelSandboxId = tunnelMatch[1]; + const tunnelPort = Number(tunnelMatch[2]); + dev && + console.log( + `[proxy:tcptunnel] upgrade sandboxId=${tunnelSandboxId} port=${tunnelPort} hasCookie=${!!req.headers.cookie}` + ); + + let target: string; + try { + target = await authProxyTarget(req.headers, tunnelSandboxId, tunnelPort); + dev && console.log(`[proxy:tcptunnel] auth ok target=${target}`); + } catch (err: any) { + const status = err.statusCode || 502; + console.error(`[proxy:tcptunnel] auth failed status=${status} message=${err.message}`); + socket.write(`HTTP/1.1 ${status} ${err.message || 'Auth error'}\r\n\r\n`); + socket.destroy(); + return; + } + + // Parse host from auth target URL + const targetUrl = new URL(target); + const containerHost = targetUrl.hostname; + const containerPort = tunnelPort; + + // Complete WebSocket handshake (RFC 6455 §4.2.2) + const wsKey = req.headers['sec-websocket-key']; + if (!wsKey) { + socket.write('HTTP/1.1 400 Missing Sec-WebSocket-Key\r\n\r\n'); + socket.destroy(); + return; + } + const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + const acceptKey = crypto + .createHash('sha1') + .update(wsKey + WS_GUID) + .digest('base64'); + + // Connect to TCP target first, then send 101 after connect + const tcpSocket = net.createConnection({ host: containerHost, port: containerPort }); + let closed = false; + + function cleanup() { + if (closed) return; + closed = true; + tcpSocket.destroy(); + socket.destroy(); + } + + tcpSocket.once('connect', () => { + dev && + console.log( + `[proxy:tcptunnel] TCP connected host=${containerHost} port=${containerPort}` + ); + + // Send 101 after TCP is ready + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${acceptKey}\r\n` + + '\r\n' + ); + + // Flush any buffered data that came in with the upgrade request + const decoder = new WsFrameDecoder(); + if (head && head.length > 0) { + for (const payload of decoder.push(head)) { + if (payload.length === 0) { + cleanup(); + return; + } + tcpSocket.write(payload); + } + } + + // Browser → TCP: decode WS frames, write raw bytes to tcpSocket + socket.on('data', (chunk: Buffer) => { + if (closed) return; + for (const payload of decoder.push(chunk)) { + if (payload.length === 0) { + cleanup(); + return; + } + tcpSocket.write(payload); + } + }); + + // TCP → Browser: wrap raw bytes in WS binary frames + tcpSocket.on('data', (chunk: Buffer) => { + if (closed) return; + socket.write(encodeWsFrame(chunk)); + }); + }); + + tcpSocket.once('error', (err) => { + console.error(`[proxy:tcptunnel] TCP error: ${err.message}`); + cleanup(); + }); + tcpSocket.once('close', cleanup); + socket.once('error', cleanup); + socket.once('close', cleanup); + return; + } + + let sandboxId: string; + let portNum: number; + let proxyType = 'proxy'; + + const subdomain = parseSubdomainProxy(req.headers.host); + if (subdomain) { + sandboxId = subdomain.sandboxId; + portNum = subdomain.port; + } else { + const match = req.url?.match(PATH_PROXY_RE); + if (!match) { + dev && console.log(`[proxy:ws] no match, destroying socket. url=${req.url}`); + socket.destroy(); + return; + } + proxyType = match[1]; + sandboxId = match[2]; + portNum = Number(match[3]); + req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portNum}`, '') || '/'; + } + + dev && + console.log( + `[proxy:ws] upgrade sandboxId=${sandboxId} port=${portNum} url=${req.url} hasCookie=${!!req.headers.cookie}` + ); + + try { + const target = await authProxyTarget(req.headers, sandboxId, portNum); + dev && console.log(`[proxy:ws] auth ok, forwarding to target=${target}`); + // Rewrite Origin to match the target host so code-server's CSRF check passes. + // changeOrigin:true only rewrites Host, not Origin. + const targetUrl = new URL(target); + proxy.ws(req, socket, head, { + target, + headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } + }); + } catch (err: any) { + const status = err.statusCode || 502; + console.error(`[proxy:ws] auth failed status=${status} message=${err.message}`); + socket.write(`HTTP/1.1 ${status} ${err.message || 'Proxy error'}\r\n\r\n`); + socket.destroy(); + } + }); + + server.listen(port, () => { + console.log(`> Ready on http://localhost:${port} [${dev ? 'dev' : 'production'}]`); + }); +} + +// Authenticate a sandbox proxy request via the internal Next.js API route. +// This avoids importing @fastgpt/service (ESM-only deps) directly in server.ts. +async function authProxyTarget( + reqHeaders: IncomingMessage['headers'], + sandboxId: string, + targetPort: number +): Promise { + dev && + console.log( + `[proxy:auth] POST proxyAuth sandboxId=${sandboxId} port=${targetPort} hasCookie=${!!reqHeaders.cookie}` + ); + const authResp = await fetch(`http://127.0.0.1:${port}/api/core/sandbox/proxyAuth`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(reqHeaders.cookie ? { cookie: reqHeaders.cookie as string } : {}), + ...(reqHeaders.authorization ? { authorization: reqHeaders.authorization as string } : {}) + }, + body: JSON.stringify({ sandboxId, targetPort }) + }); + + if (!authResp.ok) { + // NextAPI always returns HTTP 500 for errors; read the real code from JSON body + const body = await authResp.json().catch(() => ({ code: authResp.status })); + const code = body?.code || authResp.status; + const msg = body?.message || body?.error || 'Auth failed'; + console.error( + `[proxy:auth] proxyAuth failed httpStatus=${authResp.status} code=${code} message=${msg}` + ); + throw Object.assign(new Error(msg), { statusCode: code }); + } + + const { target } = await authResp.json(); + dev && console.log(`[proxy:auth] proxyAuth ok target=${target}`); + return target as string; +} + +// Build upstream request headers, dropping hop-by-hop headers +function buildProxyHeaders(headers: IncomingMessage['headers']): Record { + const hopByHop = new Set([ + 'host', + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade' + ]); + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (hopByHop.has(key.toLowerCase())) continue; + if (value) result[key] = Array.isArray(value) ? value.join(', ') : value; + } + return result; +} + +// RFC 6455 WebSocket frame decoder with buffer accumulation. +// Handles fragmented frames that arrive across multiple TCP chunks. +class WsFrameDecoder { + private buf: Buffer = Buffer.alloc(0); + + // Push a new chunk; returns list of decoded payloads. + // An empty Buffer in the list signals a close frame (opcode 0x8). + push(chunk: Buffer): Buffer[] { + this.buf = Buffer.concat([this.buf, chunk]); + const payloads: Buffer[] = []; + + while (this.buf.length >= 2) { + const b0 = this.buf[0]; + const b1 = this.buf[1]; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let payloadLen = b1 & 0x7f; + let headerLen = 2; + + if (payloadLen === 126) { + if (this.buf.length < 4) break; + payloadLen = this.buf.readUInt16BE(2); + headerLen = 4; + } else if (payloadLen === 127) { + if (this.buf.length < 10) break; + // Only handle payloads up to 2^32; high 4 bytes are expected to be 0 + payloadLen = this.buf.readUInt32BE(6); + headerLen = 10; + } + + if (masked) headerLen += 4; + const totalLen = headerLen + payloadLen; + if (this.buf.length < totalLen) break; + + const payload = Buffer.allocUnsafe(payloadLen); + if (masked) { + const maskOffset = headerLen - 4; + for (let i = 0; i < payloadLen; i++) { + payload[i] = this.buf[headerLen + i] ^ this.buf[maskOffset + (i & 3)]; + } + } else { + this.buf.copy(payload, 0, headerLen, totalLen); + } + + this.buf = this.buf.subarray(totalLen); + + if (opcode === 0x8) { + // Close frame — signal EOF + payloads.push(Buffer.alloc(0)); + break; + } + // data frame (text=0x1, binary=0x2, continuation=0x0) or ping(0x9)/pong(0xa) ignored + if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) { + payloads.push(payload); + } + } + + return payloads; + } +} + +// Encode raw bytes as a WebSocket binary frame (server→client, no masking). +function encodeWsFrame(data: Buffer): Buffer { + const len = data.length; + let header: Buffer; + + if (len <= 125) { + header = Buffer.allocUnsafe(2); + header[0] = 0x82; // FIN=1, opcode=0x2 (binary) + header[1] = len; + } else if (len <= 65535) { + header = Buffer.allocUnsafe(4); + header[0] = 0x82; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.allocUnsafe(10); + header[0] = 0x82; + header[1] = 127; + header.writeUInt32BE(0, 2); + header.writeUInt32BE(len, 6); + } + + return Buffer.concat([header, data]); +} + +main().catch((err) => { + console.error('Failed to start server:', err); + process.exit(1); +}); diff --git a/projects/app/src/components/Layout/navbar.tsx b/projects/app/src/components/Layout/navbar.tsx index 0dcf451a7d..047f2a654f 100644 --- a/projects/app/src/components/Layout/navbar.tsx +++ b/projects/app/src/components/Layout/navbar.tsx @@ -62,6 +62,8 @@ const Navbar = ({ unread }: { unread: number }) => { '/dashboard/agent', '/dashboard/create', '/app/detail', + '/dashboard/skill', + '/skill/detail', '/dashboard/tool', '/dashboard/systemTool', '/dashboard/templateMarket', diff --git a/projects/app/src/components/core/ai/SettingLLMModel/index.tsx b/projects/app/src/components/core/ai/SettingLLMModel/index.tsx index f26fa2fd09..76f967e728 100644 --- a/projects/app/src/components/core/ai/SettingLLMModel/index.tsx +++ b/projects/app/src/components/core/ai/SettingLLMModel/index.tsx @@ -34,7 +34,7 @@ const SettingLLMModel = ({ return { modelList: llmModelList, modelSet, - defaultLLMModel: getWebDefaultLLMModel(llmModelList).model + defaultLLMModel: getWebDefaultLLMModel(llmModelList)?.model }; }, [llmModelList]); diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx index bd4d5912d7..9e1ed3552f 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx @@ -201,6 +201,7 @@ const ChatItem = ({ hasPlanCheck, ...props }: Props) => { const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting); const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType); const showRunningStatus = useContextSelector(ChatItemContext, (v) => v.showRunningStatus); + const showAvatar = useContextSelector(ChatItemContext, (v) => v.showAvatar); const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId); const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId); @@ -371,7 +372,7 @@ const ChatItem = ({ hasPlanCheck, ...props }: Props) => { /> )} - + {showAvatar !== false && } {/* Workflow status */} {!!chatStatusMap && statusBoxData && isLastChild && showRunningStatus && ( diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx index d4d6871ab3..f7e9ac2a21 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx @@ -97,6 +97,8 @@ type Props = OutLinkChatAuthProps & } >; onTriggerRefresh?: () => void; + // TODO: 待优化。 自定义删除消息的实现,不传则使用默认的 delChatRecordById + onDeleteChatItem?: (contentId: string, delFile?: boolean) => Promise; }; const ChatBox = ({ @@ -108,7 +110,8 @@ const ChatBox = ({ showWorkorder, onStartChat, chatType, - onTriggerRefresh + onTriggerRefresh, + onDeleteChatItem }: Props) => { const ScrollContainerRef = useRef(null); const { t } = useTranslation(); @@ -254,6 +257,8 @@ const ChatBox = ({ plan, stepId, stepTitle, + sandboxStatus, + skill, variables, nodeResponse, durationSeconds, @@ -294,6 +299,59 @@ const ChatBox = ({ moduleName: name }; } + if (event === SseResponseEventEnum.sandboxStatus && sandboxStatus) { + const getSandboxPhaseLabel = (): string => { + const { phase, isWarmStart, skillName } = sandboxStatus; + if (phase === 'deployingSkills') { + return t('chat:sandbox_status_deployingSkills', { skillName: skillName ?? '' }); + } + if ( + phase === 'downloadingPackage' || + phase === 'uploadingPackage' || + phase === 'extractingPackage' + ) { + return t(`chat:sandbox_status_${phase}` as any, { skillName: skillName ?? '' }); + } + if (phase === 'ready') { + return t( + isWarmStart ? 'chat:sandbox_status_ready_warm' : 'chat:sandbox_status_ready_cold' + ); + } + return t(`chat:sandbox_status_${phase}` as any); + }; + return { + ...item, + status: 'loading' as const, + moduleName: getSandboxPhaseLabel() + }; + } + if (event === SseResponseEventEnum.skillCall && skill) { + // 去重检查:避免同一个 skill 在同一步骤中重复展示 + const alreadyExists = item.value.some( + (v) => + v.stepId === stepId && v.skills?.some((s) => s.skillMdPath === skill.skillMdPath) + ); + if (alreadyExists) return item; + + const skillId = skill.id || responseValueId || getNanoid(10); + const val: AIChatItemValueItemType = { + id: responseValueId, + stepId, + skills: [ + { + id: skillId, + skillName: skill.skillName, + skillAvatar: skill.skillAvatar || '', + description: skill.description, + skillMdPath: skill.skillMdPath + } + ] + }; + return { + ...item, + value: [...item.value, val] + }; + } if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) { if (reasoningText) { if (updateValue?.reasoning && updateValue.stepId === stepId) { @@ -751,6 +809,9 @@ const ChatBox = ({ // retry input const onDelMessage = useCallback( (contentId: string, delFile = true) => { + if (onDeleteChatItem) { + return onDeleteChatItem(contentId, delFile); + } return delChatRecordById({ appId, chatId, @@ -759,7 +820,7 @@ const ChatBox = ({ ...outLinkAuthData }); }, - [appId, chatId, outLinkAuthData] + [appId, chatId, outLinkAuthData, onDeleteChatItem] ); const retryInput = useMemoizedFn((dataId?: string) => { if (!dataId || !onDelMessage) return; diff --git a/projects/app/src/components/core/chat/ChatContainer/type.ts b/projects/app/src/components/core/chat/ChatContainer/type.ts index 553c1cea18..36567da8b4 100644 --- a/projects/app/src/components/core/chat/ChatContainer/type.ts +++ b/projects/app/src/components/core/chat/ChatContainer/type.ts @@ -3,7 +3,9 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; import type { ChatHistoryItemResType, StepTitleItemType, - ToolModuleResponseItemType + ToolModuleResponseItemType, + SandboxStatusItemType, + SkillModuleResponseItemType } from '@fastgpt/global/core/chat/type'; import type { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import type { @@ -32,6 +34,10 @@ export type generatingMessageProps = { plan?: AgentPlanType; stepTitle?: StepTitleItemType; + // Sandbox + sandboxStatus?: SandboxStatusItemType; + skill?: SkillModuleResponseItemType; + // HelperBot collectionForm?: UserInputInteractive; formData?: TopAgentFormDataType; diff --git a/projects/app/src/components/core/chat/components/AIResponseBox.tsx b/projects/app/src/components/core/chat/components/AIResponseBox.tsx index 224bae0f90..65ca71933a 100644 --- a/projects/app/src/components/core/chat/components/AIResponseBox.tsx +++ b/projects/app/src/components/core/chat/components/AIResponseBox.tsx @@ -13,7 +13,8 @@ import { import type { AIChatItemValueItemType, StepTitleItemType, - ToolModuleResponseItemType + ToolModuleResponseItemType, + SkillModuleResponseItemType } from '@fastgpt/global/core/chat/type'; import React, { useCallback, useMemo } from 'react'; import MyIcon from '@fastgpt/web/components/common/Icon'; @@ -203,6 +204,48 @@ ${response}`} (prevProps, nextProps) => isEqual(prevProps, nextProps) ); +const RenderSkill = React.memo( + function RenderSkill({ + showAnimation, + skill + }: { + showAnimation: boolean; + skill: SkillModuleResponseItemType; + }) { + const { t } = useSafeTranslation(); + + return ( + + + + + + {skill.skillName} + + + + + {skill.description && ( + + {skill.description} + + )} + + + + ); + }, + (prevProps, nextProps) => isEqual(prevProps, nextProps) +); + const onSendPrompt = (text: string) => eventBus.emit(EventNameEnum.sendQuestion, { text, @@ -428,8 +471,10 @@ const AIResponseBox = ({ onOpenCiteModal?: (e?: OnOpenCiteModalProps) => void; }) => { const showRunningStatus = useContextSelector(ChatItemContext, (v) => v.showRunningStatus); + const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences); const tools = value.tool ? [value.tool] : value.tools; const disableStreamingInteraction = isChatting && isLastChild; + const skills = value.skills; if ('text' in value && value.text) { return ( @@ -459,6 +504,13 @@ const AIResponseBox = ({ )); } + if (skills && showSkillReferences && showRunningStatus) { + return skills.map((skill) => ( + + + + )); + } if ('interactive' in value && value.interactive) { const interactive = extractDeepestInteractive(value.interactive); if (interactive.type === 'userSelect' || interactive.type === 'agentPlanAskUserSelect') { diff --git a/projects/app/src/pageComponents/account/team/PermissionManage/index.tsx b/projects/app/src/pageComponents/account/team/PermissionManage/index.tsx index 427b338925..4064d7bc1b 100644 --- a/projects/app/src/pageComponents/account/team/PermissionManage/index.tsx +++ b/projects/app/src/pageComponents/account/team/PermissionManage/index.tsx @@ -23,6 +23,7 @@ import { updateOneMemberPermission } from '@/web/support/user/team/api'; import { useUserStore } from '@/web/support/user/useUserStore'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import Avatar from '@fastgpt/web/components/common/Avatar'; import MemberTag from '../../../../components/support/user/team/Info/MemberTag'; @@ -36,6 +37,8 @@ import { TeamDatasetCreateRoleVal, TeamManagePermissionVal, TeamManageRoleVal, + TeamSkillCreatePermissionVal, + TeamSkillCreateRoleVal, TeamRoleList } from '@fastgpt/global/support/permission/user/constant'; import { TeamPermission } from '@fastgpt/global/support/permission/user/controller'; @@ -63,6 +66,8 @@ function PermissionManage({ }) { const { t } = useTranslation(); const { userInfo } = useUserStore(); + const { feConfigs } = useSystemStore(); + const showSkill = !!feConfigs?.show_skill; const collaboratorList = useContextSelector( CollaboratorContext, @@ -232,6 +237,14 @@ function PermissionManage({ + {showSkill && ( + + + {t('account_team:permission_skillCreate')} + + + + )} {t('account_team:permission_datasetCreate')} @@ -283,6 +296,16 @@ function PermissionManage({ clbPer={member.permission} id={member.tmbId!} /> + {showSkill && ( + + )} + {showSkill && ( + + )} + {showSkill && ( + + )} diff --git a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/EditForm.tsx b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/EditForm.tsx index 5dcfe2495d..6dffd1b362 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/EditForm.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/ChatAgent/EditForm.tsx @@ -23,6 +23,7 @@ import { TTSTypeEnum } from '@/web/core/app/constants'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import { getWebLLMModel } from '@/web/common/system/utils'; import ToolSelect from '../FormComponent/ToolSelector/ToolSelect'; +import SkillSelect from '../FormComponent/ToolSelector/SkillSelect'; import { cardStyles } from '../../constants'; import { SmallAddIcon } from '@chakra-ui/icons'; import MyIconButton, { MyDeleteIconButton } from '@fastgpt/web/components/common/Icon/button'; @@ -253,6 +254,28 @@ const EditForm = ({ + {/* skill choice */} + {feConfigs?.show_skill && ( + + { + setAppForm((state) => ({ + ...state, + selectedAgentSkills: [skill, ...(state.selectedAgentSkills || [])] + })); + }} + onRemoveSkill={(skillId) => { + setAppForm((state) => ({ + ...state, + selectedAgentSkills: + state.selectedAgentSkills?.filter((item) => item.skillId !== skillId) || [] + })); + }} + /> + + )} + {/* tool choice */} 0) { + defaultAppForm.selectedAgentSkills = skills; + } } else if (node.flowNodeType === FlowNodeTypeEnum.systemConfig) { defaultAppForm.chatConfig = getAppChatConfig({ chatConfig, @@ -249,7 +258,19 @@ export function agentForm2AppWorkflow( label: '', valueType: WorkflowIOValueTypeEnum.boolean, value: data.aiSettings.useAgentSandbox ?? false - } + }, + // Skills configuration + ...(data.selectedAgentSkills && data.selectedAgentSkills.length > 0 + ? [ + { + key: NodeInputKeyEnum.skills, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + label: '', + valueType: WorkflowIOValueTypeEnum.arrayObject, + value: data.selectedAgentSkills + } + ] + : []) ], outputs: AgentNode.outputs } diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelect.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelect.tsx new file mode 100644 index 0000000000..96837fd902 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelect.tsx @@ -0,0 +1,118 @@ +import { Box, Button, Flex, Grid, useDisclosure } from '@chakra-ui/react'; +import React from 'react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'next-i18next'; +import { SmallAddIcon } from '@chakra-ui/icons'; +import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; +import SkillSelectModal from './SkillSelectModal'; + +const SkillSelect = ({ + selectedSkills = [], + onAddSkill, + onRemoveSkill +}: { + selectedSkills?: SelectedAgentSkillItemType[]; + onAddSkill: (skill: SelectedAgentSkillItemType) => void; + onRemoveSkill: (skillId: string) => void; +}) => { + const { t } = useTranslation(); + + const { + isOpen: isOpenSkillSelect, + onOpen: onOpenSkillSelect, + onClose: onCloseSkillSelect + } = useDisclosure(); + + return ( + <> + + + + {t('skill:associated_skills')} + + + + 0 ? 2 : 0} + gridTemplateColumns={'repeat(2, minmax(0, 1fr))'} + gridGap={[2, 4]} + > + {selectedSkills.map((item) => ( + + + {item.avatar ? ( + + ) : ( + + )} + + {item.name} + + {/* Delete icon */} + + { + e.stopPropagation(); + onRemoveSkill(item.skillId); + }} + /> + + + + ))} + + + {isOpenSkillSelect && ( + + )} + + ); +}; + +export default React.memo(SkillSelect); diff --git a/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx new file mode 100644 index 0000000000..f1fa2b33d7 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal.tsx @@ -0,0 +1,259 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useTranslation } from 'next-i18next'; +import { Box, Button, Flex, Grid } from '@chakra-ui/react'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import SearchInput from '@fastgpt/web/components/common/Input/SearchInput'; +import MyAvatar from '@fastgpt/web/components/common/Avatar'; +import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type'; +import { getSkillList } from '@/web/core/skill/api'; +import { AgentSkillTypeEnum } from '@fastgpt/global/core/agentSkills/constants'; +import type { ListSkillsResponse } from '@fastgpt/global/core/agentSkills/api'; +import FolderPath from '@/components/common/folder/Path'; +import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; + +const MAX_SKILL_COUNT = 100; + +type SkillItem = ListSkillsResponse['list'][number]; +type NavItem = { id: string; name: string }; + +const SkillSelectModal = ({ + selectedSkills, + onAddSkill, + onRemoveSkill, + onClose +}: { + selectedSkills: SelectedAgentSkillItemType[]; + onAddSkill: (skill: SelectedAgentSkillItemType) => void; + onRemoveSkill: (skillId: string) => void; + onClose: () => void; +}) => { + const { t } = useTranslation(); + const [searchKey, setSearchKey] = useState(''); + // 导航栈:每个元素代表一级文件夹 {id, name},空数组表示根目录 + const [navStack, setNavStack] = useState([]); + + // 根目录传 ''(空字符串),与 context.tsx 的调用保持一致 + const parentId = navStack.length > 0 ? navStack[navStack.length - 1].id : ''; + + const { data: skillList = [], loading: isLoading } = useRequest( + () => + getSkillList({ + source: 'mine', + parentId: parentId, + searchKey: searchKey || undefined + }).then((res) => res.list), + { + manual: false, + refreshDeps: [parentId, searchKey], + throttleWait: 300 + } + ); + + const onEnterFolder = useCallback((item: SkillItem) => { + setNavStack((prev) => [...prev, { id: item._id, name: item.name }]); + setSearchKey(''); + }, []); + + // 将 navStack 转为 FolderPath 所需的 paths 格式 + const paths = useMemo( + () => navStack.map((item) => ({ parentId: item.id, parentName: item.name })), + [navStack] + ); + + // FolderPath 点击某一层级时,根据 parentId 裁剪 navStack + const onUpdateParentId = useCallback((targetParentId: ParentIdType) => { + if (!targetParentId) { + setNavStack([]); + } else { + setNavStack((prev) => { + const idx = prev.findIndex((item) => item.id === targetParentId); + return idx >= 0 ? prev.slice(0, idx + 1) : prev; + }); + } + setSearchKey(''); + }, []); + + const isAtLimit = selectedSkills.length >= MAX_SKILL_COUNT; + + return ( + + {/* Header: search */} + + + setSearchKey(e.target.value)} + placeholder={t('skill:search_skill')} + /> + + + + {/* 面包屑导航 */} + {!searchKey && navStack.length > 0 && ( + + + + )} + + + + {skillList.length > 0 ? ( + + {skillList.map((item) => ( + s.skillId === item._id)} + isAtLimit={isAtLimit} + onAdd={() => + onAddSkill({ + skillId: item._id, + name: item.name, + description: item.description, + avatar: item.avatar + }) + } + onRemove={() => onRemoveSkill(item._id)} + onOpenFolder={() => onEnterFolder(item)} + /> + ))} + + ) : ( + + )} + + + + ); +}; + +export default React.memo(SkillSelectModal); + +const SkillCard = React.memo(function SkillCard({ + item, + isSelected, + isAtLimit, + onAdd, + onRemove, + onOpenFolder +}: { + item: SkillItem; + isSelected: boolean; + isAtLimit: boolean; + onAdd: () => void; + onRemove: () => void; + onOpenFolder: () => void; +}) { + const { t } = useTranslation(); + const isFolder = item.type === AgentSkillTypeEnum.folder; + + return ( + + + {isFolder ? ( + + ) : ( + + )} + + {item.name} + + + + {item.description || t('common:no_intro')} + + + } + > + + {isFolder ? ( + + ) : ( + + )} + + + {item.name} + + + + + {isFolder ? ( + + ) : isSelected ? ( + + ) : ( + + + + )} + + + ); +}); diff --git a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ChatTest.tsx b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ChatTest.tsx index e7ea3d75cd..05e393f10c 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ChatTest.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/HTTPTools/ChatTest.tsx @@ -209,6 +209,7 @@ const Render = ({ isShowCite={true} isShowFullText={true} showRunningStatus={true} + showSkillReferences={true} showWholeResponse={true} > diff --git a/projects/app/src/pageComponents/app/detail/Edit/MCPTools/ChatTest.tsx b/projects/app/src/pageComponents/app/detail/Edit/MCPTools/ChatTest.tsx index 4ebfd6748d..d004289327 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/MCPTools/ChatTest.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/MCPTools/ChatTest.tsx @@ -187,6 +187,7 @@ const Render = ({ isShowCite={true} isShowFullText={true} showRunningStatus={true} + showSkillReferences={true} showWholeResponse={true} > diff --git a/projects/app/src/pageComponents/app/detail/Edit/SimpleApp/ChatTest.tsx b/projects/app/src/pageComponents/app/detail/Edit/SimpleApp/ChatTest.tsx index 249a047ada..a89a27e3a1 100644 --- a/projects/app/src/pageComponents/app/detail/Edit/SimpleApp/ChatTest.tsx +++ b/projects/app/src/pageComponents/app/detail/Edit/SimpleApp/ChatTest.tsx @@ -140,6 +140,7 @@ const Render = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => { isShowCite={true} isShowFullText={true} showRunningStatus={true} + showSkillReferences={true} showWholeResponse={true} > diff --git a/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx b/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx index 906fa473d0..4d9039c918 100644 --- a/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Logs/DetailLogsModal.tsx @@ -280,6 +280,7 @@ const Render = (props: Props) => { isShowCite={true} isShowFullText={true} showRunningStatus={true} + showSkillReferences={true} showWholeResponse={true} > diff --git a/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx b/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx index 5e23c31f16..f9b6d31c1a 100644 --- a/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Publish/Link/index.tsx @@ -186,6 +186,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => { canDownloadSource: item.canDownloadSource, showFullText: item.showFullText, showRunningStatus: item.showRunningStatus, + showSkillReferences: item.showSkillReferences, limit: item.limit }) }, @@ -281,6 +282,8 @@ function EditLinkModal({ defaultValues: defaultData }); + const showRunningStatus = watch('showRunningStatus'); + const showSkillReferences = watch('showSkillReferences'); const showCite = watch('showCite'); const showFullText = watch('showFullText'); const canDownloadSource = watch('canDownloadSource'); @@ -476,6 +479,24 @@ function EditLinkModal({ isChecked={canDownloadSource} /> + {feConfigs?.show_skill && ( + + + {t('publish:show_skill_reference')} + + + + + )} diff --git a/projects/app/src/pageComponents/app/detail/Publish/Playground/index.tsx b/projects/app/src/pageComponents/app/detail/Publish/Playground/index.tsx index 7e42c493ab..23b58ef910 100644 --- a/projects/app/src/pageComponents/app/detail/Publish/Playground/index.tsx +++ b/projects/app/src/pageComponents/app/detail/Publish/Playground/index.tsx @@ -13,9 +13,11 @@ import type { PlaygroundVisibilityConfigType } from '@fastgpt/global/support/out import MyIcon from '@fastgpt/web/components/common/Icon'; import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; const defaultPlaygroundVisibilityForm: PlaygroundVisibilityConfigType = { showRunningStatus: true, + showSkillReferences: false, showCite: true, showFullText: true, canDownloadSource: true, @@ -25,6 +27,7 @@ const defaultPlaygroundVisibilityForm: PlaygroundVisibilityConfigType = { const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => { const { t } = useTranslation(); const { copyData } = useCopyData(); + const { feConfigs } = useSystemStore(); const { register, watch, setValue, reset } = useForm({ defaultValues: defaultPlaygroundVisibilityForm @@ -34,6 +37,7 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => { const showFullText = watch('showFullText'); const canDownloadSource = watch('canDownloadSource'); const showRunningStatus = watch('showRunningStatus'); + const showSkillReferences = watch('showSkillReferences'); const showWholeResponse = watch('showWholeResponse'); const playgroundLink = useMemo(() => { @@ -47,6 +51,7 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => { onSuccess: (data) => { reset({ showRunningStatus: data.showRunningStatus, + showSkillReferences: data.showSkillReferences, showCite: data.showCite, showFullText: data.showFullText, canDownloadSource: data.canDownloadSource, @@ -201,6 +206,27 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => { /> + {feConfigs?.show_skill && ( + + + + {t('publish:show_skill_reference')} + + + + + + )} ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/ChatTest.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/ChatTest.tsx index 6d6cf06ac9..912bdd6522 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/ChatTest.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/ChatTest.tsx @@ -225,6 +225,7 @@ const Render = (Props: Props) => { isShowCite={true} isShowFullText={true} showRunningStatus={true} + showSkillReferences={true} showWholeResponse={true} > diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx index 3411f23d87..e262e235f0 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx @@ -217,11 +217,14 @@ const NodeTemplateList = ({ onUpdateParentId }: TemplateListProps) => { const { t, i18n } = useTranslation(); + const { feConfigs } = useSystemStore(); const { toast } = useToast(); const { computedNewNodeName } = useWorkflowUtils(); const { getNodeList, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v); const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams); + const showSkill = !!feConfigs?.show_skill; + const handleAddNode = useCallback( async ({ template, @@ -368,6 +371,9 @@ const NodeTemplateList = ({ }, {}); templates.forEach((item) => { + if (!showSkill && item.flowNodeType === FlowNodeTypeEnum.agent) { + return; + } if (item.templateType && map[item.templateType]) { map[item.templateType].list.push({ ...item, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index 0c4c7daa3b..02f354a790 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -111,7 +111,12 @@ export const useNodeTemplates = () => { return getTeamAppTemplates({ parentId, searchKey: searchVal, - type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow] + type: [ + AppTypeEnum.folder, + AppTypeEnum.simple, + AppTypeEnum.workflow, + ...(feConfigs?.show_skill ? [AppTypeEnum.chatAgent] : []) + ] }).then((res) => res.filter((app) => app.id !== appId)); } if (type === TemplateTypeEnum.systemTools) { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx index 0d94f20371..6f23836b5c 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx @@ -50,7 +50,7 @@ const nodeTypes: Record = { [FlowNodeTypeEnum.pluginModule]: NodeSimple, [FlowNodeTypeEnum.queryExtension]: NodeSimple, [FlowNodeTypeEnum.stopTool]: NodeStopTool, - [FlowNodeTypeEnum.agent]: undefined, + [FlowNodeTypeEnum.agent]: dynamic(() => import('./nodes/NodeAgent')), [FlowNodeTypeEnum.toolCall]: dynamic(() => import('./nodes/NodeToolCall')), [FlowNodeTypeEnum.tool]: NodeSimple, [FlowNodeTypeEnum.toolSet]: dynamic(() => import('./nodes/NodeToolSet')), diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx new file mode 100644 index 0000000000..cb285d4488 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/index.tsx @@ -0,0 +1,787 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, Flex, Grid, HStack, useDisclosure } from '@chakra-ui/react'; +import { type NodeProps } from 'reactflow'; +import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import { useTranslation } from 'next-i18next'; +import { useContextSelector } from 'use-context-selector'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import dynamic from 'next/dynamic'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import NodeInputSelect from '@fastgpt/web/components/core/workflow/NodeInputSelect'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import MyIconButton from '@fastgpt/web/components/common/Icon/button'; + +import NodeCard from '../render/NodeCard'; +import Container from '../../components/Container'; +import RenderInput from '../render/RenderInput'; +import RenderOutput from '../render/RenderOutput'; +import RenderToolInput from '../render/RenderToolInput'; +import IOTitle from '../../components/IOTitle'; +import InputLabel from '../render/RenderInput/Label'; +import CatchError from '../render/RenderOutput/CatchError'; + +import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; +import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext'; +import { WorkflowBufferDataContext } from '../../../context/workflowInitContext'; +import { AppContext } from '@/pageComponents/app/detail/context'; + +import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { getEditorVariables } from '../../../utils'; +import { getWebLLMModel } from '@/web/common/system/utils'; + +import { useAgentSkillManager } from './useAgentSkillManager'; +import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover'; + +import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type'; +import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import type { AppDatasetSearchParamsType } from '@fastgpt/global/core/app/type'; + +const PromptEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/PromptEditor')); +const SkillSelectModal = dynamic( + () => import('@/pageComponents/app/detail/Edit/FormComponent/ToolSelector/SkillSelectModal') +); +const ToolSelectModal = dynamic( + () => import('@/pageComponents/app/detail/Edit/FormComponent/ToolSelector/ToolSelectModal') +); +const ReferenceRender = dynamic(() => import('../render/RenderInput/templates/Reference')); +const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal')); +const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal')); + +/* ======== Helper: get current renderType of an input ======== */ +const getRenderType = (input: FlowNodeInputItemType) => + input.renderTypeList?.[input.selectedTypeIndex || 0] || FlowNodeInputTypeEnum.custom; + +/* ======== Helper: custom label row with optional type tag ======== */ +const CustomInputLabel = React.memo(function CustomInputLabel({ + nodeId, + input, + refLabel, + refTooltip +}: { + nodeId: string; + input: FlowNodeInputItemType; + refLabel?: string; + refTooltip?: string; +}) { + const { t } = useTranslation(); + const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + + const renderType = getRenderType(input); + + const onChangeRenderType = useCallback( + (e: string) => { + const index = input.renderTypeList.findIndex((item) => item === e) || 0; + onChangeNode({ + nodeId, + type: 'updateInput', + key: input.key, + value: { ...input, selectedTypeIndex: index, value: undefined } + }); + }, + [input, nodeId, onChangeNode] + ); + + return ( + + + {t(input.label as any)} + + + {/* In reference mode show a readable type tag instead of "Array" */} + {renderType === FlowNodeInputTypeEnum.reference && refLabel && ( + + + {refLabel} + + + )} + + {/* Mode switch */} + {input.renderTypeList && input.renderTypeList.length > 1 && ( + + + + )} + + ); +}); + +// TODO: 待优化,不一定需要重写,用模板渲染也可以 +const NodeAgent = ({ data, selected }: NodeProps) => { + const { nodeId, catchError, inputs, outputs } = data; + const { t } = useTranslation(); + + const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + const { splitToolInputs, splitOutput } = useContextSelector(WorkflowUtilsContext, (ctx) => ctx); + const { getNodeById, edges, systemConfigNode, llmMaxQuoteContext } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); + const { appDetail } = useContextSelector(AppContext, (v) => v); + const { feConfigs, defaultModels } = useSystemStore(); + + // Split tool/common inputs and outputs + const { isTool, commonInputs } = useMemoEnhance( + () => splitToolInputs(inputs, nodeId), + [inputs, nodeId, splitToolInputs] + ); + const { successOutputs, errorOutputs } = useMemoEnhance( + () => splitOutput(outputs), + [splitOutput, outputs] + ); + + // Skill manager (for PromptEditor @ integration) + const { + selectedTools, + skillOption, + selectedSkills, + onClickSkill, + onRemoveSkill, + onUpdateOrAddTool, + onDeleteTool, + SkillModal + } = useAgentSkillManager({ nodeId, inputs }); + + // Editor variables for PromptEditor + const editorVariables = useMemoEnhance( + () => + getEditorVariables({ + nodeId, + systemConfigNode, + getNodeById, + edges, + appDetail, + t + }), + [nodeId, systemConfigNode, getNodeById, edges, appDetail, t] + ); + const externalVariables = useMemo( + () => + feConfigs?.externalProviderWorkflowVariables?.map((item) => ({ + key: item.key, + label: item.name + })) || [], + [feConfigs?.externalProviderWorkflowVariables] + ); + const allVariables = useMemo( + () => [...(editorVariables || []), ...(externalVariables || [])], + [editorVariables, externalVariables] + ); + + // ---- Dataset params state (for Agent node inline settings button) ---- + const [datasetParamsData, setDatasetParamsData] = useState({ + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: 0.5, + limit: 3000, + similarity: 0.5, + usingReRank: true, + rerankModel: defaultModels.llm?.model, + rerankWeight: 0.6, + datasetSearchUsingExtensionQuery: true, + datasetSearchExtensionModel: defaultModels.llm?.model, + datasetSearchExtensionBg: '' + }); + const { + isOpen: isOpenDatasetParams, + onOpen: onOpenDatasetParams, + onClose: onCloseDatasetParams + } = useDisclosure(); + const { + isOpen: isOpenDatasetSelect, + onOpen: onOpenDatasetSelect, + onClose: onCloseDatasetSelect + } = useDisclosure(); + + useEffect(() => { + inputs.forEach((input) => { + if ((datasetParamsData as any)[input.key] !== undefined) { + setDatasetParamsData((state) => ({ + ...state, + [input.key]: input.value ?? (state as any)[input.key] + })); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputs]); + + // ---- Prompt ---- + const promptInput = useMemo( + () => inputs.find((i) => i.key === NodeInputKeyEnum.aiSystemPrompt), + [inputs] + ); + const skillsInput = useMemo( + () => inputs.find((i) => i.key === NodeInputKeyEnum.skills), + [inputs] + ); + const toolsInput = useMemo( + () => inputs.find((i) => i.key === NodeInputKeyEnum.selectedTools), + [inputs] + ); + + // Split commonInputs into groups + const manualKeys = useMemo( + () => + new Set([ + NodeInputKeyEnum.aiModel, + NodeInputKeyEnum.aiSystemPrompt, + NodeInputKeyEnum.skills, + NodeInputKeyEnum.selectedTools + ]), + [] + ); + const modelInputs = useMemo( + () => commonInputs.filter((i) => i.key === NodeInputKeyEnum.aiModel), + [commonInputs] + ); + // Inputs rendered before skills/tools (fileLink, userChatInput) + const chatInputKeys = useMemo( + () => new Set([NodeInputKeyEnum.fileUrlList, NodeInputKeyEnum.userChatInput]), + [] + ); + const chatInputs = useMemo( + () => commonInputs.filter((i) => chatInputKeys.has(i.key as NodeInputKeyEnum)), + [commonInputs, chatInputKeys] + ); + // Inputs rendered after skills/tools (dataset, etc.) + const datasetInputs = useMemo( + () => + commonInputs.filter( + (i) => + !manualKeys.has(i.key as NodeInputKeyEnum) && + !chatInputKeys.has(i.key as NodeInputKeyEnum) + ), + [commonInputs, manualKeys, chatInputKeys] + ); + // Separate datasetSelectList from other dataset inputs + const datasetSelectInput = useMemo( + () => datasetInputs.find((i) => i.key === NodeInputKeyEnum.datasetSelectList), + [datasetInputs] + ); + const selectedDatasets = useMemo( + () => (Array.isArray(datasetSelectInput?.value) ? datasetSelectInput!.value : []), + [datasetSelectInput] + ); + const datasetOtherInputs = useMemo( + () => + datasetInputs.filter( + (i) => + i.key !== NodeInputKeyEnum.datasetSelectList && + i.key !== NodeInputKeyEnum.datasetParams && + i.key !== NodeInputKeyEnum.datasetSimilarity + ), + [datasetInputs] + ); + + // ---- Dataset select render type (for mode switch) ---- + const datasetSelectRenderType = useMemo( + () => datasetSelectInput?.renderTypeList?.[datasetSelectInput?.selectedTypeIndex || 0], + [datasetSelectInput] + ); + const onChangeDatasetSelectRenderType = useCallback( + (e: string) => { + if (!datasetSelectInput) return; + const index = datasetSelectInput.renderTypeList.findIndex((item) => item === e) || 0; + onChangeNode({ + nodeId, + type: 'updateInput', + key: datasetSelectInput.key, + value: { ...datasetSelectInput, selectedTypeIndex: index, value: undefined } + }); + }, + [datasetSelectInput, nodeId, onChangeNode] + ); + + // ---- Prompt ---- + const onPromptChange = useCallback( + (text: string) => { + if (!promptInput) return; + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.aiSystemPrompt, + type: 'updateInput', + value: { ...promptInput, value: text } + }); + }, + [promptInput, nodeId, onChangeNode] + ); + const promptRenderType = useMemo(() => { + if (!promptInput) return FlowNodeInputTypeEnum.textarea; + return getRenderType(promptInput); + }, [promptInput]); + const PromptSkillTip = useMemo( + () => + promptRenderType === FlowNodeInputTypeEnum.textarea ? ( + + + {t('workflow:agent.prompt_skill_tip')} + + ) : undefined, + [promptRenderType, t] + ); + const OptimizerPopoverComponent = useCallback( + ({ iconButtonStyle }: { iconButtonStyle: Record }) => ( + + ), + [promptInput?.value, onPromptChange] + ); + + // ---- Skills ---- + const selectedAgentSkills: SelectedAgentSkillItemType[] = useMemo( + () => (Array.isArray(skillsInput?.value) ? skillsInput!.value : []), + [skillsInput] + ); + const skillsRenderType = useMemo( + () => (skillsInput ? getRenderType(skillsInput) : FlowNodeInputTypeEnum.selectSkill), + [skillsInput] + ); + const { + isOpen: isOpenSkillSelect, + onOpen: onOpenSkillSelect, + onClose: onCloseSkillSelect + } = useDisclosure(); + + // ---- Tools ---- + const toolsRenderType = useMemo( + () => (toolsInput ? getRenderType(toolsInput) : FlowNodeInputTypeEnum.selectTool), + [toolsInput] + ); + const { + isOpen: isOpenToolSelect, + onOpen: onOpenToolSelect, + onClose: onCloseToolSelect + } = useDisclosure(); + + // ---- Model ---- + const currentModel = useMemo(() => { + const modelValue = inputs.find((i) => i.key === NodeInputKeyEnum.aiModel)?.value; + return getWebLLMModel(modelValue); + }, [inputs]); + + return ( + + {isTool && ( + + + + )} + + + + + {/* 1. Model settings */} + {modelInputs.length > 0 && } + + {/* 2. System prompt */} + {promptInput && ( + + + + {promptRenderType === FlowNodeInputTypeEnum.textarea ? ( + + ) : ( + + )} + + + )} + + {/* 3. Chat inputs (fileLink, userChatInput) */} + {chatInputs.length > 0 && } + + {/* 4. Skills section (manual select / reference dual mode) */} + {feConfigs?.show_skill && skillsInput && ( + + + + {skillsRenderType === FlowNodeInputTypeEnum.selectSkill ? ( + <> + + + {selectedAgentSkills.map((item) => ( + + + {item.avatar ? ( + + ) : ( + + )} + + {item.name} + + + { + e.stopPropagation(); + if (!skillsInput) return; + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.skills, + type: 'updateInput', + value: { + ...skillsInput, + value: selectedAgentSkills.filter( + (s) => s.skillId !== item.skillId + ) + } + }); + }} + /> + + + + ))} + + {isOpenSkillSelect && ( + { + if (!skillsInput) return; + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.skills, + type: 'updateInput', + value: { + ...skillsInput, + value: [skill, ...selectedAgentSkills] + } + }); + }} + onRemoveSkill={(skillId: string) => { + if (!skillsInput) return; + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.skills, + type: 'updateInput', + value: { + ...skillsInput, + value: selectedAgentSkills.filter((s) => s.skillId !== skillId) + } + }); + }} + onClose={onCloseSkillSelect} + /> + )} + + ) : ( + + )} + + + )} + + {/* 5. Tools section (manual select / reference dual mode) */} + {toolsInput && ( + + + + {toolsRenderType === FlowNodeInputTypeEnum.selectTool ? ( + <> + + + {selectedTools.map((item) => ( + + + + + {item.name} + + + { + e.stopPropagation(); + onDeleteTool(item.pluginId!); + }} + /> + + + + ))} + + {isOpenToolSelect && ( + onUpdateOrAddTool({ ...tool, id: tool.pluginId! })} + onRemoveTool={(tool) => onDeleteTool(tool.id)} + onClose={onCloseToolSelect} + /> + )} + + ) : ( + + )} + + + )} + + {/* 6. Dataset inputs (datasetSelectList, datasetParams, etc.) */} + {datasetSelectInput && ( + + + {t('common:core.dataset.Dataset')} + {datasetSelectInput.renderTypeList && + datasetSelectInput.renderTypeList.length > 1 && ( + + + + )} + + + + + + + + {datasetSelectRenderType === FlowNodeInputTypeEnum.selectDataset ? ( + <> + + + {selectedDatasets.map((dataset) => ( + + + + {dataset.name} + + + ))} + + {isOpenDatasetSelect && ( + ({ + datasetId: d.datasetId, + name: d.name, + avatar: d.avatar, + vectorModel: d.vectorModel + }))} + onChange={(e) => { + if (!datasetSelectInput) return; + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.datasetSelectList, + type: 'updateInput', + value: { ...datasetSelectInput, value: e } + }); + }} + onClose={onCloseDatasetSelect} + /> + )} + + ) : ( + + )} + + + )} + {datasetOtherInputs.length > 0 && ( + + )} + + + {successOutputs.length > 0 && ( + + + + + )} + {catchError && } + + + + {isOpenDatasetParams && ( + { + setDatasetParamsData(e); + for (const key in e) { + const item = inputs.find((input) => input.key === key); + if (!item) continue; + onChangeNode({ + nodeId, + type: 'updateInput', + key, + value: { ...item, value: (e as any)[key] } + }); + } + }} + /> + )} + + ); +}; + +export default React.memo(NodeAgent); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/useAgentSkillManager.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/useAgentSkillManager.ts new file mode 100644 index 0000000000..d68a491ccb --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeAgent/useAgentSkillManager.ts @@ -0,0 +1,104 @@ +import { useCallback, useMemo } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; +import { useSkillManager } from '@/pageComponents/app/detail/Edit/ChatAgent/hooks/useSkillManager'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import type { SelectedToolItemType } from '@fastgpt/global/core/app/formEdit/type'; + +/** + * Adapts the ChatAgent's useSkillManager to work in the workflow node context. + * Reads/writes selectedTools from/to the node's input via onChangeNode. + */ +export const useAgentSkillManager = ({ + nodeId, + inputs +}: { + nodeId: string; + inputs: FlowNodeInputItemType[]; +}) => { + const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + + const toolsInput = useMemo( + () => inputs.find((i) => i.key === NodeInputKeyEnum.selectedTools), + [inputs] + ); + const selectedTools: SelectedToolItemType[] = useMemo( + () => (Array.isArray(toolsInput?.value) ? toolsInput!.value : []), + [toolsInput?.value] + ); + + const fileLinkInput = useMemo(() => inputs.find((i) => i.key === 'fileLink'), [inputs]); + const canUploadFile = !!fileLinkInput?.value; + + const datasetInput = useMemo( + () => inputs.find((i) => i.key === NodeInputKeyEnum.datasetSelectList), + [inputs] + ); + const hasSelectedDataset = Array.isArray(datasetInput?.value) && datasetInput!.value.length > 0; + + const useAgentSandbox = useMemo(() => { + const sandboxInput = inputs.find((i) => i.key === NodeInputKeyEnum.useAgentSandbox); + return !!sandboxInput?.value; + }, [inputs]); + + const onUpdateOrAddTool = useCallback( + (tool: SelectedToolItemType) => { + const exists = selectedTools.find((t) => t.pluginId === tool.pluginId); + const newTools = exists + ? selectedTools.map((t) => (t.pluginId === tool.pluginId ? tool : t)) + : [...selectedTools, tool]; + + if (toolsInput) { + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.selectedTools, + type: 'updateInput', + value: { + ...toolsInput, + value: newTools + } + }); + } + }, + [selectedTools, toolsInput, nodeId, onChangeNode] + ); + + const onDeleteTool = useCallback( + (id: string) => { + const newTools = selectedTools.filter((t) => t.pluginId !== id); + if (toolsInput) { + onChangeNode({ + nodeId, + key: NodeInputKeyEnum.selectedTools, + type: 'updateInput', + value: { + ...toolsInput, + value: newTools + } + }); + } + }, + [selectedTools, toolsInput, nodeId, onChangeNode] + ); + + const { skillOption, selectedSkills, onClickSkill, onRemoveSkill, SkillModal } = useSkillManager({ + selectedTools, + onUpdateOrAddTool, + onDeleteTool, + canUploadFile, + hasSelectedDataset, + useAgentSandbox + }); + + return { + selectedTools, + skillOption, + selectedSkills, + onClickSkill, + onRemoveSkill, + onUpdateOrAddTool, + onDeleteTool, + SkillModal + }; +}; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/index.tsx index 9f329b2d6f..e08daea6dc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/index.tsx @@ -91,7 +91,9 @@ const RenderList: Record< [FlowNodeInputTypeEnum.customVariable]: undefined, [FlowNodeInputTypeEnum.hidden]: undefined, - [FlowNodeInputTypeEnum.custom]: undefined + [FlowNodeInputTypeEnum.custom]: undefined, + [FlowNodeInputTypeEnum.selectSkill]: undefined, + [FlowNodeInputTypeEnum.selectTool]: undefined }; const hideLabelTypeList = [FlowNodeInputTypeEnum.addInputParam]; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDataset.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDataset.tsx index 5fc606c961..b8108844bc 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDataset.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDataset.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { RenderInputProps } from '../type'; -import { Box, Button, Flex, Grid, Switch, useDisclosure, useTheme } from '@chakra-ui/react'; +import { Box, Button, Flex, Grid, Switch, useDisclosure } from '@chakra-ui/react'; import { type SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { useTranslation } from 'next-i18next'; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx index 0012de3de3..f5850aa238 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx @@ -200,7 +200,7 @@ const WorkflowInitContextProvider = ({ allNodeFolded = false; } - if (flowNodeType === FlowNodeTypeEnum.agent || flowNodeType === FlowNodeTypeEnum.toolCall) { + if (flowNodeType === FlowNodeTypeEnum.toolCall) { hasToolNode = true; } }); diff --git a/projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx b/projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx index e489b66875..bbe9a2f19b 100644 --- a/projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx +++ b/projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx @@ -42,6 +42,7 @@ const AppChatWindow = () => { const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin); const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite); + const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences); const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId); const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData); const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData); @@ -108,7 +109,8 @@ const AppChatWindow = () => { responseChatItemId, appId, chatId, - retainDatasetCite: isShowCite + retainDatasetCite: isShowCite, + showSkillReferences }, abortCtrl: controller, onMessage: generatingMessage @@ -133,6 +135,7 @@ const AppChatWindow = () => { setChatBoxData, forbidLoadChat, isShowCite, + showSkillReferences, refreshRecentlyUsed ] ); diff --git a/projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx b/projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx index 34b79c4282..4f5f8ec0fc 100644 --- a/projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx +++ b/projects/app/src/pageComponents/chat/ChatWindow/HomeChatWindow.tsx @@ -78,6 +78,7 @@ const HomeChatWindow = () => { const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData); const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables); const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite); + const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences); const pane = useContextSelector(ChatPageContext, (v) => v.pane); const chatSettings = useContextSelector(ChatPageContext, (v) => v.chatSettings); @@ -212,7 +213,8 @@ const HomeChatWindow = () => { responseChatItemId, appId, chatId, - retainDatasetCite: isShowCite + retainDatasetCite: isShowCite, + showSkillReferences }, abortCtrl: controller, onMessage: generatingMessage @@ -263,6 +265,7 @@ const HomeChatWindow = () => { appName: t('chat:home.chat_app'), chatId, retainDatasetCite: isShowCite, + showSkillReferences, ...form2AppWorkflow(formData, t) }, onMessage: generatingMessage, diff --git a/projects/app/src/pageComponents/dashboard/Container.tsx b/projects/app/src/pageComponents/dashboard/Container.tsx index d188f0448e..4bb81e6b16 100644 --- a/projects/app/src/pageComponents/dashboard/Container.tsx +++ b/projects/app/src/pageComponents/dashboard/Container.tsx @@ -17,6 +17,7 @@ import { useUserStore } from '@/web/support/user/useUserStore'; export enum TabEnum { agent = 'agent', + skill = 'skill', tool = 'tool', system_tool = 'systemTool', app_templates = 'templateMarket', @@ -126,6 +127,16 @@ const DashboardContainer = ({ } ] }, + ...(feConfigs?.show_skill + ? [ + { + groupId: TabEnum.skill, + groupAvatar: 'common/skill', + groupName: 'Skill', + children: [] + } + ] + : []), { groupId: TabEnum.tool, groupAvatar: 'core/app/type/plugin', @@ -207,7 +218,15 @@ const DashboardContainer = ({ ] : []) ]; - }, [currentType, feConfigs.appTemplateCourse, feConfigs?.isPlus, t, templateList, templateTags]); + }, [ + currentType, + feConfigs.appTemplateCourse, + feConfigs?.isPlus, + feConfigs?.show_skill, + t, + templateList, + templateTags + ]); const MenuIcon = useMemo( () => ( diff --git a/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx b/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx new file mode 100644 index 0000000000..279b1beea2 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/skill/CreateSkillModal.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { Box, Button, Flex, Input, ModalBody, ModalFooter, Textarea } from '@chakra-ui/react'; +import { useForm } from 'react-hook-form'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyPopover from '@fastgpt/web/components/common/MyPopover'; +import { useTranslation } from 'next-i18next'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar'; +import { getUploadAvatarPresignedUrl } from '@/web/common/file/api'; +import { postCreateSkill } from '@/web/core/skill/api'; +import { useRouter } from 'next/router'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; + +const DEFAULT_SKILL_AVATAR = 'core/skill/default'; + +type FormType = { + avatar: string; + name: string; + intro?: string; + requirement: string; +}; + +type Props = { + parentId?: string | null; + onClose: () => void; + onSuccess?: () => void; +}; + +const CreateSkillModal = ({ parentId, onClose, onSuccess }: Props) => { + const { t } = useTranslation(); + const router = useRouter(); + const { defaultModels } = useSystemStore(); + + const { register, setValue, watch, handleSubmit } = useForm({ + defaultValues: { + avatar: DEFAULT_SKILL_AVATAR, + name: '', + intro: '', + requirement: t('skill:skill_requirement_default') + } + }); + + const avatar = watch('avatar'); + const requirement = watch('requirement'); + + const { Component: AvatarUploader, handleFileSelectorOpen: handleAvatarSelectorOpen } = + useUploadAvatar(getUploadAvatarPresignedUrl, { + onSuccess(newAvatar) { + setValue('avatar', newAvatar); + } + }); + + const { runAsync: onCreate, loading: isCreating } = useRequest( + async ({ avatar, name, intro, requirement }: FormType) => { + const trimmedRequirement = requirement.trim(); + const defaultModel = defaultModels.llm?.model; + return postCreateSkill({ + parentId: parentId ?? null, + name: name.trim(), + description: intro?.trim() || undefined, + requirements: trimmedRequirement || undefined, + model: trimmedRequirement && defaultModel ? defaultModel : undefined, + avatar: avatar || undefined + }); + }, + { + onSuccess(skillId) { + onSuccess?.(); + onClose(); + router.push(`/skill/detail?skillId=${skillId}`); + }, + successToast: t('common:create_success'), + errorToast: t('common:create_failed') + } + ); + + return ( + <> + + + {/* 图标 & 名称 */} + + + {t('common:app_icon_and_name')} + + + + + + + + + + + + {/* 介绍 */} + + {t('skill:skill_intro_label')} +