Commit 57a505f8 by Jon Committed by GitHub

Agent skill dev (#6668)

* chore: Rename service & container names for consistency in Docker configs (#6710)

* chore: Rename container names for consistency in Docker configs

* chore: Rename service names for consistency in Docker configs

chore: Update OpenSandbox versions and image repositories (#6709)

* chore: Update OpenSandbox versions and image repositories

* yml version

* images

* init yml

* port

---------

Co-authored-by: archer <545436317@qq.com>

refactor(chat): optimize sandbox status logic and decouple UI/Status hooks (#6713)

* refactor(chat): optimize sandbox status logic and decouple UI/Status hooks

* fix: useRef, rename onClose to afterClose

Update .env.template (#6720)

aiproxy默认的请求地址改成http协议

feat: comprehensive agent skill management and sandbox infrastructure optimization

- Skill System: Implemented a full skill management module including CRUD operations, folder organization, AI-driven skill generation, and versioning (switch/update).
- Sandbox Infrastructure: Introduced 'volume-manager' for PVC and Docker volume lifecycle management, replacing the MinIO sync-agent for better data persistence.
- Workflow Integration: Enhanced the Agent node to support skill selection and configuration, including new UI components and data normalization.
- Permission Management: Added granular permission controls for skills, supporting collaborators, owner transfers, and permission inheritance.
- UI/UX: Added a dedicated Skill dashboard, sandbox debug interface (terminal, logs, and iframe proxy), and comprehensive i18n support.
- Maintenance: Migrated Docker services to named volumes, optimized sandbox instance limits, and improved error handling for sandbox providers.

Co-authored-by: chanzhi82020 <chenzhi@sangfor.com.cn>
Co-authored-by: lavine77
Signed-off-by: Jon <ljp@sangfor.com.cn>

feat: hide skill

prettier

* perf: hide skill code

* fix: ts

* lock

* perf: tool code

* fix: ts

* lock

* fix: test

* fix: openapi

* lock

* fix: test

* null model

---------

Co-authored-by: archer <545436317@qq.com>
parent 5c709afe
---
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` | 鉴权函数 |
# 权限接入实施清单
> 上线前核对清单,可打印使用。
## 设计判断
- [ ] 确认资源是否有 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 权限记录正确
- [ ] 前后端展示的"最终权限"与后端实际鉴权一致
# 完整接入:继承型资源权限
> 适用于**继承型资源**:有 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
<MemberManager
permission={permission}
onGetCollaboratorList={() => 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 && (
<Tag colorScheme="blue">继承自父级</Tag>
)}
```
---
## 完成后检查
使用 [实施清单](../checklist.md) 进行最终检查。
## 深入了解
- [继承机制详解](../reference/inheritance.md)
- [协作者管理编排器](../reference/pro-collaborator.md)
- [Owner 转移机制](../reference/pro-owner-transfer.md)
# 快速入门: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)
# 参考文档索引
> 深入理解 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) |
# 鉴权函数实现
## 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
});
// 删除逻辑...
}
```
# 核心概念
## 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 } // 是否继承(可选)
});
```
# 继承机制详解
## 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
});
}
```
# 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` |
# 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 | 组织级权限 |
新资源接入时,必须同时支持这三类协作者。
# 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
}
});
```
......@@ -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
......@@ -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
......@@ -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"
......@@ -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: |
......
......@@ -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"
......@@ -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"
......@@ -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"
......@@ -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"
......@@ -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: |
......
......@@ -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"
......@@ -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"
......@@ -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"
......@@ -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
......
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
......@@ -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
......@@ -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}}
......@@ -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"
......@@ -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: |
......
......@@ -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"
......@@ -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"
......@@ -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"
......@@ -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"
......@@ -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: |
......
......@@ -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"
......@@ -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"
......@@ -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"
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}`>);
......@@ -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
};
......@@ -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 = {
......
export * from '../../openapi/core/agentSkills/api';
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;
}>;
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';
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<Buffer>(
(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<typeof AgentSkillConfigSchema>;
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<typeof AgentSkillSchema>;
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<typeof AgentSkillListItemSchema>;
export const AgentSkillDetailSchema = AgentSkillSchema.extend({
appCount: z.number().optional(),
permission: z.any().optional()
});
export type AgentSkillDetailType = z.infer<typeof AgentSkillDetailSchema>;
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<typeof AgentSkillsVersionSchema>;
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<typeof SkillPackageSchema>;
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<typeof ZipEntryInfoSchema>;
export const ExtractedSkillPackageSchema = z.object({
skillPackage: SkillPackageSchema,
zipBuffer: BufferSchema,
zipEntries: z.array(ZipEntryInfoSchema),
totalSize: z.number()
});
export type ExtractedSkillPackage = z.infer<typeof ExtractedSkillPackageSchema>;
export const SkillSandboxEndpointSchema = z.object({
host: z.string(),
port: z.number(),
protocol: SandboxProtocolSchema,
url: z.string()
});
export type SkillSandboxEndpointType = z.infer<typeof SkillSandboxEndpointSchema>;
export const SandboxImageConfigSchema = z.object({
repository: z.string(),
tag: z.string().optional()
});
export type SandboxImageConfigType = z.infer<typeof SandboxImageConfigSchema>;
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<typeof SandboxInstanceSchema>;
......@@ -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];
......
......@@ -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<typeof SelectedAgentSkillItemTypeSchema>;
/**
* 将 skills 输入值规范化为 skillId 字符串数组。
* 兼容两种格式:
* - string[]:debugChat 运行时直接传入的 skillId 数组
* - SelectedAgentSkillItemType[]:工作流 NodeAgent 存储的完整对象数组(含 name/avatar 等展示字段)
*/
export const normalizeSkillIds = (
skills: Array<string | SelectedAgentSkillItemType> | 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<typeof AppFormEditFormV1TypeSchema>;
......@@ -24,6 +24,7 @@ export const getDefaultAppForm = (): AppFormEditFormType => {
datasetSearchExtensionBg: ''
},
selectedTools: [],
selectedAgentSkills: [],
chatConfig: {}
};
};
......
......@@ -33,6 +33,52 @@ export const StepTitleItemSchema = z.object({
});
export type StepTitleItemType = z.infer<typeof StepTitleItemSchema>;
/* 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<typeof SkillModuleResponseItemSchema>;
/* --------- 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(),
......
......@@ -173,6 +173,7 @@ export enum NodeInputKeyEnum {
datasetParams = 'agent_datasetParams',
skills = 'skills',
useAgentSandbox = 'useAgentSandbox',
useEditDebugSandbox = 'useEditDebugSandbox',
// dataset
datasetSelectList = 'datasets',
......
......@@ -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
};
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
];
......@@ -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;
......@@ -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
......
......@@ -82,6 +82,7 @@ export type ChatDispatchProps = {
lastInteractive?: WorkflowInteractiveResponseType; // last interactive response
stream: boolean;
retainDatasetCite?: boolean;
showSkillReferences?: boolean;
maxRunTimes: number;
isToolCall?: boolean;
workflowStreamResponse?: WorkflowResponseType;
......
......@@ -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,
......
import { FlowNodeTypeEnum } from '../../../node/constant';
import {
datasetSelectValueDesc,
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import { FlowNodeTemplateTypeEnum } from '../../../constants';
import {
Input_Template_History,
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
]
};
......@@ -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': [
......
......@@ -73,6 +73,8 @@ export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
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<T extends OutlinkAppType = undefined> = {
name: string;
showCite?: OutLinkSchema<T>['showCite'];
showRunningStatus?: OutLinkSchema<T>['showRunningStatus'];
showSkillReferences?: OutLinkSchema<T>['showSkillReferences'];
showFullText?: OutLinkSchema<T>['showFullText'];
canDownloadSource?: OutLinkSchema<T>['canDownloadSource'];
// response when request
......@@ -122,10 +125,11 @@ export type OutLinkEditType<T extends OutlinkAppType = undefined> = {
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<typeof PlaygroundVisibilityConfigSchema>;
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;
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);
}
}
......@@ -50,7 +50,8 @@ export enum PerResourceTypeEnum {
team = 'team',
app = 'app',
dataset = 'dataset',
model = 'model'
model = 'model',
agentSkill = 'agentSkill'
}
/* new permission */
......
......@@ -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<TeamPerKeyEnum> = {
...CommonPerList,
apikeyCreate: 0b100000,
appCreate: 0b001000,
datasetCreate: 0b010000
datasetCreate: 0b010000,
skillCreate: 0b1000000
};
export const TeamRoleList: RoleListType<TeamRoleKeyEnum> = {
......@@ -60,6 +63,12 @@ export const TeamRoleList: RoleListType<TeamRoleKeyEnum> = {
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;
......@@ -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);
});
}
}
......@@ -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 = {
......
......@@ -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')
}
};
......
......@@ -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']
......
......@@ -115,12 +115,12 @@ const addCommonMiddleware = (schema: mongoose.Schema) => {
return schema;
};
export const getMongoModel = <T>(name: string, schema: mongoose.Schema) => {
export const getMongoModel = <T>(name: string, schema: mongoose.Schema): Model<T> => {
if (connectionMongo.models[name]) return connectionMongo.models[name] as Model<T>;
if (!isTestEnv) logger.debug('Loading MongoDB model', { modelName: name });
addCommonMiddleware(schema);
const model = connectionMongo.model<T>(name, schema);
const model = connectionMongo.model(name, schema) as Model<T>;
// Sync index
syncMongoIndex(model);
......@@ -128,11 +128,11 @@ export const getMongoModel = <T>(name: string, schema: mongoose.Schema) => {
return model;
};
export const getMongoLogModel = <T>(name: string, schema: mongoose.Schema) => {
export const getMongoLogModel = <T>(name: string, schema: mongoose.Schema): Model<T> => {
if (connectionLogMongo.models[name]) return connectionLogMongo.models[name] as Model<T>;
logger.debug('Loading MongoDB log model', { modelName: name });
const model = connectionLogMongo.model<T>(name, schema);
const model = connectionLogMongo.model(name, schema) as Model<T>;
// Sync index
syncMongoIndex(model);
......
......@@ -35,7 +35,7 @@ export const addS3DelJob = async (data: S3MQJobData): Promise<void> => {
return undefined;
}
if (data.prefix) {
return `${data.bucketName}:${data.prefix}`;
return `${data.bucketName}-${data.prefix}`;
}
throw new Error('Invalid s3 delete job data');
})();
......
import decompress from 'decompress';
export type ArchiveFormat = 'zip' | 'tar' | 'tar.gz';
export type ArchiveFileMap = Record<string, Buffer>;
/** 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<ArchiveFileMap> {
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<SkillMdInfo | null> {
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<string | null> {
const info = await extractSkillMdInfoFromBuffer(buffer);
return info ? info.content : null;
}
export { collectionName, MongoSandboxInstance } from '../ai/sandbox/schema';
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<AgentSkillSchemaType>(
agentSkillsCollectionName,
AgentSkillsSchema
);
/**
* 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<SkillStorageInfo> {
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<Buffer> {
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<void> {
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<boolean> {
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<SkillStorageInfo & { exists: boolean }> {
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<UploadSkillPackageParams, 'zipBuffer' | 'checksum'>
): Promise<SkillStorageInfo> {
// 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<string[]> {
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<Buffer> {
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 };
}
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<string, any>;
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<string, any> {
const result: Record<string, any> = {};
const lines = yaml.split('\n');
let currentObj = result;
const stack: { key: string; obj: Record<string, any> }[] = [];
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<string, any>;
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<string, any>;
// 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: {}
}
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment