Commit 7e333e85 by YeYuheng Committed by GitHub

feat: add skill context for agent helper generation (#7187)

parent 6724a1e6
# Skill 接入辅助生成与子 Skill 元数据存储方案
## 背景
当前 ChatAgent 的辅助生成(HelperBot TopAgent)只把应用已有的系统提示词、工具、知识库、文件上传状态和虚拟机状态传入辅助生成链路,没有传入或展示 `selectedAgentSkills`。
实际代码现状:
- 前端 `projects/app/src/pageComponents/app/detail/Edit/ChatAgent/ChatTest.tsx` 构建 `topAgentMetadata` 时没有带 `appForm.selectedAgentSkills`。
- `packages/global/core/chat/helperBot/topAgent/type.ts` 的 `topAgentParamsSchema` 和 `TopAgentFormDataSchema` 没有 Skill 字段。
- `pro/admin/src/service/core/chat/HelperBot/processors/topAgent/utils.ts` 的 `generateResourceList()` 只生成工具和知识库资源列表。
- `pro/admin/src/service/core/chat/HelperBot/processors/topAgent/prompt.ts` 只描述预设工具和预设知识库,没有 Skill 资源。
- Skill 创建/发布时,Mongo 当前只保存平台 Skill 主表信息和版本包指针,没有结构化保存包内多个 `SKILL.md` 的 `name` / `description`。
因此,辅助生成无法基于当前用户可访问的 Skill 做规划,也无法把生成结果回填到应用的 Skill 关联中。
## 目标
分两个阶段完成:
1. 第一阶段:让辅助生成可以使用当前用户可访问的 Skill 应用。
2. 第二阶段:在 Skill 创建/发布时结构化保存包内子 Skill 信息,让辅助生成看到更准确的子 Skill 能力。
## 非目标
- 不把每个子 Skill 拆成独立权限资源。
- 不改变应用最终选择 Skill 的模型,应用仍然关联平台 Skill 应用。
- 不在辅助生成请求时临时下载对象存储 zip 或解包读取 `SKILL.md`。
- 不为了平台 Skill `description` 接一层 LLM 摘要。
- 不在第一阶段修改 Skill 发布/打包链路。
## 当前数据模型
### 平台 Skill 主表
`MongoAgentSkills` 当前主要保存:
```ts
{
parentId,
type,
inheritPermission,
source,
name,
description,
avatar,
teamId,
tmbId,
category,
createTime,
updateTime,
deleteTime,
currentVersionId,
creationStatus,
creationError,
creationPayload
}
```
`name` 和 `description` 是平台 Skill 应用层面的名称和描述。
### Skill 版本表
`MongoAgentSkillsVersion` 当前主要保存:
```ts
{
skillId,
tmbId,
versionName,
storageKey,
importSource,
createdAt
}
```
版本包真实内容存在对象存储,Mongo 只保存 `storageKey`。
### 应用关联
应用表单中关联 Skill 的结构是:
```ts
{
skillId: string;
name: string;
description: string;
avatar?: string;
isDeleted: boolean;
}
```
这里保存的是平台 Skill 应用信息,不保存子 Skill 信息。
## 第一阶段:Skill 接入辅助生成
### 目标
辅助生成可以:
- 读取当前用户可访问的 Skill 应用列表。
- 在资源列表中展示 Skill 应用。
- 在规划中选择 Skill。
- 将选中的 Skill 回填到 `appForm.selectedAgentSkills`。
- 如果选择了 Skill,自动保持 `aiSettings.useAgentSandbox = true`。
### 数据来源
第一阶段只使用 `MongoAgentSkills` 的平台字段:
```ts
{
skillId,
name,
description,
avatar
}
```
不读取版本包,不解析 `SKILL.md`。
### 权限查询
不要在服务端辅助生成中调用 `/core/ai/skill/list` API。
应把 `projects/app/src/pages/api/core/ai/skill/list.ts` 中“当前成员可访问 Skill”的权限查询抽成 service/helper,供以下两处复用:
- Skill 列表 API。
- `generateResourceList()`。
查询需要保留现有权限语义:
- team owner 可以访问团队内个人 Skill。
- 普通成员只看到自己有读权限的 Skill。
- 支持用户、组织、用户组权限。
- 支持文件夹继承权限。
- 过滤 `deleteTime: null`。
- 默认只返回 `AgentSkillTypeEnum.skill`,不把文件夹作为可选资源暴露给辅助生成。
- 系统 Skill 是否纳入第一阶段需要单独确认;建议第一版只接入 `source: personal`,和现有应用选择器保持一致。
建议新增 service:
```ts
type AccessibleSkillResource = {
skillId: string;
name: string;
description: string;
avatar?: string;
};
async function getAccessibleSkillResources({
teamId,
tmbId,
isRoot
}: {
teamId: string;
tmbId: string;
isRoot: boolean;
}): Promise<AccessibleSkillResource[]>;
```
### TopAgent metadata
前端 `topAgentMetadata` 增加当前应用已选 Skill:
```ts
selectedAgentSkills: appForm.selectedAgentSkills || []
```
`topAgentParamsSchema` 增加:
```ts
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).nullish()
```
这些预设 Skill 在 prompt 中作为高优先级已有配置提示,不代表固定约束。
### 资源列表
`generateResourceList()` 从:
```md
## 可用工具与知识库
### 工具
...
### 知识库
...
```
扩展为:
```md
## 可用工具、知识库与 Skill
### 工具
...
### 知识库
...
### Skill
- **skillId** [Skill]: name - description
```
没有可访问 Skill 时展示:
```md
暂未配置 Skill
```
### Prompt 约束
需要让 TopAgent 明确知道:
- Skill 是可选资源,适合表达可复用操作经验、项目规范、流程约束和领域方法。
- 选择 Skill 时返回平台 Skill `skillId`。
- 不要求用户提供 Skill ID,TopAgent 应从资源列表中自行选择。
- 如果资源列表里没有合适 Skill,不要强行选择。
- 选择 Skill 后需要启用虚拟机,因为 Agent Skill 运行依赖 sandbox。
预设信息区增加:
```md
**预设 Skill**: 搭建者已预先选择了以下 Skill ID: ...
```
### 生成结果 schema
`TopAgentFormDataSchema` 增加:
```ts
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).optional().default([])
```
辅助生成的计划资源提取从:
```ts
{ tools, knowledges }
```
扩展为:
```ts
{ tools, knowledges, skills }
```
并根据 `skills` 过滤出真实可访问 Skill,形成 `selectedAgentSkills`。
过滤逻辑必须按 `skillId` 校验当前用户仍有读权限,不能完全信任 LLM 输出。
### 前端回填
`onApply(formData)` 增加:
```ts
selectedAgentSkills: formData.selectedAgentSkills
```
并且:
```ts
aiSettings.useAgentSandbox = enableSandboxEnabled || formData.selectedAgentSkills.length > 0
```
如果当前套餐或系统配置不支持 sandbox,沿用现有 `checkAgentSkillSandboxUnavailable` 的提示和阻断逻辑。
### 前端 Skill 选择与辅助生成应用流程
第一阶段不新增一个“辅助生成专用 Skill 选择器”。手动选择 Skill 仍然沿用现有 ChatAgent 编辑区里的 `SkillSelectModal` 和 `useAgentSkillSelect()`。
需要明确两条路径:
#### 手动选择路径
用户在 ChatAgent 编辑表单中点击 Skill 选择入口:
```text
SkillSelectModal
-> onAddAgentSkill(skill)
-> appForm.selectedAgentSkills
-> useAgentSkillSelect 自动保持 sandbox 开启
```
这条路径已经存在,第一阶段只要求后续辅助生成能读取这个结果作为预设信息。
#### 辅助生成路径
用户在辅助生成面板里提出需求后:
```text
HelperBot TopAgent
-> generateResourceList() 提供可访问 Skill 资源
-> LLM 规划并返回 selectedAgentSkills
-> topAgentConfig SSE
-> HelperBot onApply(formData)
-> ChatAgent ChatTest.onApply
-> setAppForm 写入 appForm.selectedAgentSkills
```
这条路径和工具的辅助生成回填类似,但 Skill 回填不能只写 ID。前端应用表单需要完整保存:
```ts
{
skillId,
name,
description,
avatar,
isDeleted: false
}
```
因此服务端在生成 `TopAgentFormData` 时,必须把 LLM 输出的 Skill ID 重新映射成可访问 Skill 列表中的完整对象,前端不再额外请求 Skill 详情。
#### 和工具回填的区别
工具目前会通过 `loadGeneratedTools()` 根据工具 ID 补齐工具模板配置。Skill 不应复用工具加载逻辑,而应由服务端直接返回 `SelectedAgentSkillItemType[]`。
原因:
- Skill 选择不需要像工具那样加载节点模板。
- Skill 权限必须在服务端生成阶段校验,不能把无权限 ID 交给前端再处理。
- Skill 运行依赖 sandbox,回填时需要同步开启 `useAgentSandbox`。
### 运行态 Skill 应用名与子 Skill 对齐
第一阶段还需要解决一个提示词对齐问题:
- 辅助生成和调试预览阶段展示、回填的是平台 Skill 应用。
- 真正运行 Agent 时,sandbox 中可读取和执行的是该 Skill 应用包内展开后的一个或多个 `skills/**/SKILL.md` 子 Skill。
如果运行态只把子 Skill 的 `name` / `description` 提供给模型,而不提供平台 Skill 应用的 `name` / `description`,就会出现错位:
```text
系统提示词或辅助生成结果提到:数据分析助手
运行态可用技能列表只有:data-cleaning、chart-reporting
模型无法稳定判断 data-cleaning/chart-reporting 属于“数据分析助手”,可能导致提示词提到了 Skill 但运行时不调用。
```
因此第一阶段需要在运行态 skill prompt 中保留两层信息:
```xml
<skill>
<app_id>平台 Skill 应用 ID</app_id>
<app_name>平台 Skill 应用名</app_name>
<app_description>平台 Skill 应用描述</app_description>
<name>子 Skill 名</name>
<description>子 Skill 描述</description>
<directory>子 Skill 目录</directory>
<path>子 Skill 的 SKILL.md 路径</path>
</skill>
```
运行态 prompt 需要明确告诉模型:
- 匹配用户任务、系统提示词和应用配置时,应同时参考 `app_name` / `app_description` 与子 Skill 的 `name` / `description`。
- 如果用户任务或系统提示词提到了某个平台 Skill 应用名,应在该应用下选择最匹配的子 Skill。
- 执行时不能只凭平台 Skill 应用描述推断完整流程,仍必须读取最终选中的子 Skill `SKILL.md`。
- `app_name` / `app_description` 只用于对齐应用层语义和辅助生成回填结果;实际执行入口仍然是子 Skill 的 `path`。
实现上,普通运行态应在注入 Skill 包后,把 `selectedAgentSkills` 中的平台应用信息合并到已部署版本信息,再传给 `getAgentSkillInfos()`。`getAgentSkillInfos()` 扫描子 Skill 时,把匹配到的应用信息附加到每个 `DeployedSkillInfo`,最后由 `buildAgentSkillsPrompt()` 输出上述字段和匹配规则。
这个方案属于第一阶段的运行态 prompt 对齐,不要求提前解析并落库子 Skill 元数据,也不要求辅助生成资源列表展示子 Skill 详情。辅助生成资源列表展示子 Skill 详情仍放在第二阶段,依赖 `runtimeSkills` / `currentRuntimeSkills`。
### 第一阶段验收
- 辅助生成资源列表包含当前用户可访问 Skill。
- 用户要求适合某个 Skill 的场景时,生成结果能自动关联该 Skill。
- 已选 Skill 会作为预设信息进入下一轮辅助生成。
- 无权限 Skill 即使被 LLM 输出,也不会进入 `selectedAgentSkills`。
- 选择 Skill 后应用配置中自动开启 sandbox。
- 手动选择的 Skill 会进入辅助生成预设信息。
- 辅助生成选择的 Skill 会直接显示在 ChatAgent 编辑表单的 Skill 列表中,和手动选择效果一致。
- 运行态 skill prompt 包含平台 Skill 应用 `app_name` / `app_description` 和子 Skill `name` / `description`。
- 当系统提示词或用户输入提到平台 Skill 应用名时,模型能在该应用下选择匹配的子 Skill,并读取对应 `SKILL.md`。
## 第二阶段:发布时保存子 Skill 元数据
### 目标
在创建、导入、保存发布 Skill 包时,解析包内所有 `skills/**/SKILL.md` 的 frontmatter,把子 Skill 信息结构化写入 Mongo。辅助生成后续直接从 Mongo 读取子 Skill `name` / `description`,不需要临时解包。
### 子 Skill 元数据结构
建议新增公共类型:
```ts
type RuntimeSkillMetadata = {
name: string;
description: string;
path: string;
};
```
示例:
```ts
runtimeSkills: [
{
name: 'data-cleaning',
description: '清洗表格中的缺失值、异常值和格式问题',
path: 'skills/data-cleaning/SKILL.md'
},
{
name: 'chart-reporting',
description: '根据数据生成图表和分析报告',
path: 'skills/chart-reporting/SKILL.md'
}
]
```
### 存储位置
建议两层存储:
1. `MongoAgentSkillsVersion.runtimeSkills`
- 必须存。
- 表示该版本包里实际包含的子 Skill。
- 不同版本可以不同。
2. `MongoAgentSkills.currentRuntimeSkills`
- 建议存。
- 缓存当前版本的子 Skill 列表。
- 辅助生成和列表查询可以直接读主表,避免每次 join 当前版本表。
### 平台 Skill name/description
平台 Skill `name` 不被子 Skill 覆盖,仍然是 Skill 应用名。
平台 Skill `description` 可以在发布时确定性更新为子 Skill 描述拼接后的短文本,但必须控制在 500 字符内。
建议规则:
```ts
const parts = runtimeSkills.map((item) => `${item.name}: ${item.description}`);
const description = joinAndTruncate(parts, 500);
```
超长时使用确定性截断,不调用 LLM,例如:
```text
data-cleaning: 清洗表格数据;chart-reporting: 生成图表报告;...等 6 个子 Skill
```
如果用户手动维护了平台描述,是否发布时自动覆盖需要产品确认。建议第一版采用:
- 创建/导入时平台描述为空:自动写入拼接描述。
- 保存发布已有 Skill 时:只更新 `currentRuntimeSkills`,不自动覆盖用户手写 `description`。
- 如需覆盖,后续在发布弹窗中增加开关。
### 解析时机
需要覆盖所有会产生版本包的入口:
- AI 创建初始包:`completePendingSkillCreation()`。
- 导入 Skill 包。
- 复制 Skill。
- 从编辑态 sandbox 保存发布:`saveDeploySkillFromSandbox()`。
解析应在上传对象存储前完成,确保包内容和入库 metadata 来自同一份内容。
### 校验规则
发布包解析后需要校验:
- 至少存在一个 `skills/**/SKILL.md`。
- 每个 `SKILL.md` 必须有 frontmatter `name`。
- `description` 建议必填;如果为了兼容旧包允许为空,辅助生成展示时用空字符串。
- 同一个包内子 Skill `name` 不能重复。
- `path` 必须在 `skills/` 下,不能接受 `../` 等越界路径。
重复 name 不应静默覆盖,应发布失败并给出明确错误。
### 辅助生成第二阶段展示
第一阶段资源列表:
```md
- **skillId** [Skill]: 平台 Skill 名 - 平台描述
```
第二阶段资源列表升级为:
```md
- **skillId** [Skill]: 平台 Skill 名 - 平台描述
- **data-cleaning**: 清洗表格中的缺失值、异常值和格式问题
- **chart-reporting**: 根据数据生成图表和分析报告
```
模型选择时仍然只返回平台 `skillId`,不返回子 Skill path。
### Prompt 长度控制
如果某个 Skill 应用包含很多子 Skill,资源列表应限制展示长度:
- 单个 Skill 最多展示前 10 个子 Skill。
- 每个子 Skill 描述按固定字符数截断。
- 超出时追加:
```md
- ...还有 N 个子 Skill
```
这样避免辅助生成 prompt 被少数大型 Skill 包撑爆。
## 数据迁移与兼容
第二阶段上线后,旧版本记录没有 `runtimeSkills`。
兼容策略:
- 旧数据的 `runtimeSkills` 缺失时,辅助生成退回使用平台 Skill `name` / `description`。
- 不强制后台批量解包历史对象存储。
- 用户下一次保存发布后,自动写入当前版本的 `runtimeSkills` 和主表缓存。
## 测试计划
### 第一阶段测试
- `topAgentParamsSchema` 支持 `selectedAgentSkills`。
- `TopAgentFormDataSchema` 支持 `selectedAgentSkills` 默认值。
- `generateResourceList()` 能输出 Skill 分区。
- 无 Skill 时输出空提示。
- 权限过滤只返回当前用户可读 Skill。
- LLM 输出不存在或无权限 Skill ID 时被过滤。
- 前端 `onApply` 能回填 `selectedAgentSkills`。
- 回填 Skill 时自动开启 sandbox。
- 运行态 `buildAgentSkillsPrompt()` 输出 `app_id`、`app_name`、`app_description`。
- 运行态 prompt 明确要求用平台 Skill 应用信息匹配任务,再读取匹配子 Skill 的 `SKILL.md`。
### 第二阶段测试
- 单个 `SKILL.md` 解析出一个 runtime skill。
- 多个 `SKILL.md` 解析出多个 runtime skills。
- 重复 `name` 发布失败。
- 缺少 `name` 发布失败。
- 无 `SKILL.md` 发布失败。
- `description` 拼接不超过 500 字符。
- 保存发布新版本后:
- `MongoAgentSkillsVersion.runtimeSkills` 写入。
- `MongoAgentSkills.currentRuntimeSkills` 更新。
- `currentVersionId` 正确切换。
- 旧版本无 `runtimeSkills` 时辅助生成仍可使用平台描述。
## TODO
### 阶段一 TODO
- [ ] 抽取 Skill 可访问列表查询 service,复用列表 API 的权限规则。
- [ ] `generateResourceList()` 增加 Skill 分区。
- [ ] `topAgentParamsSchema` 增加 `selectedAgentSkills`。
- [ ] `TopAgentFormDataSchema` 增加 `selectedAgentSkills`。
- [ ] TopAgent prompt 增加 Skill 资源说明、预设 Skill 说明、sandbox 依赖说明。
- [ ] 扩展 `extractResourcesFromPlan()`,支持 `skill` 资源类型。
- [ ] TopAgent 生成阶段校验并回填可访问 Skill。
- [ ] 前端 `topAgentMetadata` 传入 `appForm.selectedAgentSkills`。
- [ ] 前端 `onApply` 回填 `selectedAgentSkills` 并保持 sandbox 开启。
- [ ] 明确辅助生成回填后的 Skill 展示复用现有 ChatAgent Skill 列表,不新增独立展示组件。
- [ ] 运行态 skill prompt 补充平台 Skill 应用信息与子 Skill 的匹配规则。
- [ ] 补充单元测试与必要的 pro/admin TopAgent 测试。
### 阶段二 TODO
- [ ] 定义 `RuntimeSkillMetadata` schema/type。
- [ ] `MongoAgentSkillsVersion` 增加 `runtimeSkills`。
- [ ] `MongoAgentSkills` 增加 `currentRuntimeSkills` 缓存。
- [ ] 实现从包内容解析 `skills/**/SKILL.md` frontmatter 的 service。
- [ ] 创建初始包时写入 runtime skill metadata。
- [ ] 导入包时写入 runtime skill metadata。
- [ ] 复制 Skill 时复制 runtime skill metadata。
- [ ] 保存发布 sandbox 包时写入 runtime skill metadata。
- [ ] 实现平台 description 的确定性拼接与 500 字符限制。
- [ ] 辅助生成资源列表展示子 Skill 详情,并做数量和长度截断。
- [ ] 补充发布、导入、复制、辅助生成资源列表测试。
import z from 'zod'; import z from 'zod';
import { SelectedDatasetSchema } from '../../../workflow/type/io'; import { SelectedDatasetSchema } from '../../../workflow/type/io';
import { SelectedAgentSkillItemTypeSchema } from '../../../app/formEdit/type';
// TopAgent 参数配置 // TopAgent 参数配置
export const topAgentParamsSchema = z.object({ export const topAgentParamsSchema = z.object({
...@@ -8,6 +9,7 @@ export const topAgentParamsSchema = z.object({ ...@@ -8,6 +9,7 @@ export const topAgentParamsSchema = z.object({
systemPrompt: z.string().nullish(), systemPrompt: z.string().nullish(),
selectedTools: z.array(z.string()).nullish(), selectedTools: z.array(z.string()).nullish(),
selectedDatasets: z.array(z.string()).nullish(), selectedDatasets: z.array(z.string()).nullish(),
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).nullish(),
fileUpload: z.boolean().nullish(), fileUpload: z.boolean().nullish(),
enableSandbox: z.boolean().nullish() enableSandbox: z.boolean().nullish()
}); });
...@@ -17,6 +19,7 @@ export const TopAgentFormDataSchema = z.object({ ...@@ -17,6 +19,7 @@ export const TopAgentFormDataSchema = z.object({
systemPrompt: z.string().optional(), systemPrompt: z.string().optional(),
tools: z.array(z.string()).optional().default([]), tools: z.array(z.string()).optional().default([]),
datasets: z.array(SelectedDatasetSchema).optional().default([]), datasets: z.array(SelectedDatasetSchema).optional().default([]),
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).optional().default([]),
fileUploadEnabled: z.boolean().optional().default(false), fileUploadEnabled: z.boolean().optional().default(false),
enableSandboxEnabled: z.boolean().optional().default(false), enableSandboxEnabled: z.boolean().optional().default(false),
executionPlan: z.any().optional() executionPlan: z.any().optional()
......
...@@ -14,6 +14,14 @@ describe('topAgentParamsSchema', () => { ...@@ -14,6 +14,14 @@ describe('topAgentParamsSchema', () => {
systemPrompt: 'You are a helpful assistant', systemPrompt: 'You are a helpful assistant',
selectedTools: ['tool1', 'tool2'], selectedTools: ['tool1', 'tool2'],
selectedDatasets: ['dataset1'], selectedDatasets: ['dataset1'],
selectedAgentSkills: [
{
skillId: 'skill1',
name: 'Research Skill',
description: 'Research workflow',
isDeleted: false
}
],
fileUpload: true fileUpload: true
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
...@@ -34,6 +42,7 @@ describe('topAgentParamsSchema', () => { ...@@ -34,6 +42,7 @@ describe('topAgentParamsSchema', () => {
systemPrompt: null, systemPrompt: null,
selectedTools: null, selectedTools: null,
selectedDatasets: null, selectedDatasets: null,
selectedAgentSkills: null,
fileUpload: null fileUpload: null
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
...@@ -59,6 +68,23 @@ describe('topAgentParamsSchema', () => { ...@@ -59,6 +68,23 @@ describe('topAgentParamsSchema', () => {
} }
}); });
it('should validate selectedAgentSkills as selected skill item array', () => {
const result = topAgentParamsSchema.safeParse({
selectedAgentSkills: [
{
skillId: 'skill1',
name: 'Research Skill',
description: 'Research workflow'
}
]
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.selectedAgentSkills).toHaveLength(1);
expect(result.data.selectedAgentSkills?.[0]?.isDeleted).toBe(false);
}
});
it('should reject invalid selectedTools type', () => { it('should reject invalid selectedTools type', () => {
const result = topAgentParamsSchema.safeParse({ const result = topAgentParamsSchema.safeParse({
selectedTools: 'not-an-array' selectedTools: 'not-an-array'
...@@ -66,6 +92,13 @@ describe('topAgentParamsSchema', () => { ...@@ -66,6 +92,13 @@ describe('topAgentParamsSchema', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it('should reject invalid selectedAgentSkills type', () => {
const result = topAgentParamsSchema.safeParse({
selectedAgentSkills: ['skill1']
});
expect(result.success).toBe(false);
});
it('should reject invalid fileUpload type', () => { it('should reject invalid fileUpload type', () => {
const result = topAgentParamsSchema.safeParse({ const result = topAgentParamsSchema.safeParse({
fileUpload: 'not-a-boolean' fileUpload: 'not-a-boolean'
......
...@@ -8,6 +8,7 @@ export * from './types'; ...@@ -8,6 +8,7 @@ export * from './types';
export * from './create'; export * from './create';
export * from './update'; export * from './update';
export * from './query'; export * from './query';
export * from './list';
export * from './folder'; export * from './folder';
export * from './delete'; export * from './delete';
export * from './import'; export * from './import';
import { Types } from '../../../../common/mongo';
import { MongoApp } from '../../../app/schema';
import { AppResourceRefsSkillIdsPath, buildAppSkillRefMongoQuery } from '../../../app/resourceRefs';
import { MongoAgentSkills } from '../model/schema';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { MongoResourcePermission } from '../../../../support/permission/schema';
import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils';
import { replaceRegChars } from '@fastgpt/global/common/string/tools';
import { getGroupsByTmbId } from '../../../../support/permission/memberGroup/controllers';
import { getOrgIdSetWithParentByTmbId } from '../../../../support/permission/org/controllers';
import { addSourceMember } from '../../../../support/user/utils';
import { sumPer } from '@fastgpt/global/support/permission/utils';
import type { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import { AgentSkillSourceEnum, AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import type { ListSkillsQuery } from '@fastgpt/global/core/ai/skill/api';
type TeamPermission = {
isOwner: boolean;
};
type ListReadableAgentSkillsParams = ListSkillsQuery & {
teamId: string;
tmbId: string;
teamPer: TeamPermission;
creationStatus?: AgentSkillCreationStatusEnum;
withSourceMember?: boolean;
};
const mergeMongoAndQuery = (...queries: Record<string, unknown>[]) => {
const validQueries = queries.filter((query) => Object.keys(query).length > 0);
if (validQueries.length === 0) return {};
if (validQueries.length === 1) return validQueries[0];
// 多个过滤条件都可能包含 $or,用 $and 合并避免对象展开时覆盖同名键。
return {
$and: validQueries
};
};
/**
* 查询当前成员可读的 Agent Skill 列表。
*
* 这里集中维护 Skill 列表 API 和 HelperBot TopAgent 共用的权限过滤规则:
* owner 可读团队内资源;普通成员按成员、用户组、组织授权计算,并支持目录继承权限。
*/
export const listReadableAgentSkills = async ({
teamId,
tmbId,
teamPer,
parentId,
source,
searchKey,
category,
type,
skillIds,
page,
pageSize,
withAppCount,
creationStatus,
withSourceMember = true
}: ListReadableAgentSkillsParams) => {
const selectedSkillIds = skillIds?.filter(Boolean) ?? [];
const isSkillIdsQuery = selectedSkillIds.length > 0;
const [roleList, myGroupMap, myOrgSet] = await Promise.all([
MongoResourcePermission.find({
resourceType: PerResourceTypeEnum.agentSkill,
teamId,
resourceId: {
$exists: true
}
}).lean(),
getGroupsByTmbId({
tmbId,
teamId
}).then((item) => {
const map = new Map<string, 1>();
item.forEach((item) => {
map.set(String(item._id), 1);
});
return map;
}),
getOrgIdSetWithParentByTmbId({
teamId,
tmbId
})
]);
const myRoles = roleList.filter(
(item) =>
String(item.tmbId) === String(tmbId) ||
myGroupMap.has(String(item.groupId)) ||
myOrgSet.has(String(item.orgId))
);
const myRoleResourceIds = Array.from(
new Map(myRoles.map((item) => [String(item.resourceId), item.resourceId])).values()
);
const roleCountByResourceId = new Map<string, number>();
roleList.forEach((item) => {
const resourceId = String(item.resourceId);
roleCountByResourceId.set(resourceId, (roleCountByResourceId.get(resourceId) ?? 0) + 1);
});
const myTmbRoleByResourceId = new Map<string, number>();
const myGroupOrgRoleListByResourceId = new Map<string, Parameters<typeof sumPer>>();
myRoles.forEach((item) => {
const resourceId = String(item.resourceId);
if (item.tmbId) {
myTmbRoleByResourceId.set(resourceId, item.permission);
return;
}
if (item.groupId || item.orgId) {
const permissionList = myGroupOrgRoleListByResourceId.get(resourceId) ?? [];
permissionList.push(item.permission);
myGroupOrgRoleListByResourceId.set(resourceId, permissionList);
}
});
const myGroupOrgRoleByResourceId = new Map<string, ReturnType<typeof sumPer>>();
myGroupOrgRoleListByResourceId.forEach((permissionList, resourceId) => {
myGroupOrgRoleByResourceId.set(resourceId, sumPer(...permissionList));
});
const findSkillQuery = (() => {
const sourceQuery = (() => {
if (source === 'store') return { source: AgentSkillSourceEnum.system };
if (source === 'mine') return { source: AgentSkillSourceEnum.personal };
return {};
})();
const typeQuery = {
...(category ? { category: { $in: [category] } } : {}),
...(type ? { type } : {})
};
const baseQuery = {
deleteTime: null,
...sourceQuery,
...typeQuery,
...(creationStatus ? { creationStatus } : {})
};
const searchMatch = searchKey
? {
$or: [
{ name: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } },
{ description: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } }
]
}
: {};
if (isSkillIdsQuery) {
const scopeQuery = source
? source === 'store'
? {}
: { teamId }
: {
$or: [{ teamId }, { source: AgentSkillSourceEnum.system }]
};
const idList = { _id: { $in: myRoleResourceIds } };
const readPermissionQuery =
teamPer.isOwner || source === 'store'
? {}
: {
$or: [
idList,
{ tmbId },
{
inheritPermission: true,
parentId: { $in: myRoleResourceIds }
}
]
};
return mergeMongoAndQuery(baseQuery, scopeQuery, readPermissionQuery, {
_id: { $in: selectedSkillIds },
...searchMatch
});
}
// 普通列表查询需要叠加当前目录过滤;skillIds 查询已在上方按读权限过滤,但不受目录限制。
const idList = { _id: { $in: myRoleResourceIds } };
const skillPerQuery = teamPer.isOwner
? {}
: parentId
? {
$or: [idList, parseParentIdInMongo(parentId)]
}
: { $or: [idList, { parentId: null }] };
const teamIdQuery = source === 'store' ? {} : { teamId };
if (searchKey) {
return mergeMongoAndQuery(skillPerQuery, teamIdQuery, baseQuery, searchMatch);
}
return mergeMongoAndQuery(
skillPerQuery,
teamIdQuery,
baseQuery,
parseParentIdInMongo(parentId)
);
})();
const mySkills = await MongoAgentSkills.find(findSkillQuery)
.sort({
type: -1,
updateTime: -1
})
.lean();
const formatSkills = mySkills
.map((skill) => {
const { Per, privateSkill } = (() => {
const getPer = (skillId: string) => {
const tmbRole = myTmbRoleByResourceId.get(skillId);
const groupAndOrgRole = myGroupOrgRoleByResourceId.get(skillId);
return new SkillPermission({
role: tmbRole ?? groupAndOrgRole,
isOwner: String(skill.tmbId) === String(tmbId) || teamPer.isOwner
});
};
const getClbCount = (skillId: string) => {
return roleCountByResourceId.get(skillId) ?? 0;
};
if (skill.inheritPermission && skill.parentId && skill.type !== AgentSkillTypeEnum.folder) {
return {
Per: getPer(String(skill.parentId)).addRole(getPer(String(skill._id)).role),
privateSkill: getClbCount(String(skill.parentId)) <= 1
};
}
return {
Per: getPer(String(skill._id)),
privateSkill: getClbCount(String(skill._id)) <= 1
};
})();
return {
_id: skill._id,
avatar: skill.avatar,
name: skill.name,
description: skill.description,
type: skill.type,
source: skill.source,
category: skill.category,
inheritPermission: skill.inheritPermission,
currentVersionId: skill.currentVersionId ? String(skill.currentVersionId) : undefined,
creationStatus: skill.creationStatus,
tmbId: skill.tmbId,
parentId: skill.parentId,
createTime: skill.createTime,
updateTime: skill.updateTime,
permission: Per,
private: privateSkill
};
})
.filter((skill) => skill.permission.hasReadPer);
const total = formatSkills.length;
const pagedSkills = (() => {
if (page && pageSize) {
const skip = (page - 1) * pageSize;
return formatSkills.slice(skip, skip + pageSize);
}
return formatSkills;
})();
const nonFolderSkills =
withAppCount !== false ? pagedSkills.filter((s) => s.type !== AgentSkillTypeEnum.folder) : [];
const appCountMap = new Map<string, number>();
if (nonFolderSkills.length > 0) {
const skillIdStrings = nonFolderSkills.map((skill) => String(skill._id));
const counts = await MongoApp.aggregate<{ _id: string; count: number }>([
{
$match: {
teamId: new Types.ObjectId(String(teamId)),
deleteTime: null,
...buildAppSkillRefMongoQuery(skillIdStrings)
}
},
{ $unwind: `$${AppResourceRefsSkillIdsPath}` },
{ $match: buildAppSkillRefMongoQuery(skillIdStrings) },
{
$group: {
_id: `$${AppResourceRefsSkillIdsPath}`,
count: { $sum: 1 }
}
}
]);
counts.forEach((item) => {
appCountMap.set(String(item._id), item.count);
});
}
const listWithAppCount = pagedSkills.map((skill) => ({
...skill,
appCount: appCountMap.get(skill._id.toString()) ?? 0
}));
const list = withSourceMember
? await addSourceMember({ list: listWithAppCount })
: listWithAppCount;
return { list, total };
};
...@@ -28,6 +28,7 @@ const buildSkillInfoFindCommand = (dir: string) => { ...@@ -28,6 +28,7 @@ const buildSkillInfoFindCommand = (dir: string) => {
type GetAgentSkillInfosParams = { type GetAgentSkillInfosParams = {
workDirectory?: string; workDirectory?: string;
skillDirectories?: string[]; skillDirectories?: string[];
deployedSkillVersions?: DeployedSkillVersion[];
sandbox: ISandbox; sandbox: ISandbox;
}; };
...@@ -40,9 +41,13 @@ type GetAgentSkillInfosParams = { ...@@ -40,9 +41,13 @@ type GetAgentSkillInfosParams = {
export const getAgentSkillInfos = async ({ export const getAgentSkillInfos = async ({
workDirectory, workDirectory,
skillDirectories, skillDirectories,
deployedSkillVersions,
sandbox sandbox
}: GetAgentSkillInfosParams): Promise<DeployedSkillInfo[]> => { }: GetAgentSkillInfosParams): Promise<DeployedSkillInfo[]> => {
const scanDirectories = skillDirectories?.length ? skillDirectories : [workDirectory || '.']; const scanDirectories = skillDirectories?.length ? skillDirectories : [workDirectory || '.'];
const deployedVersionByTargetDir = new Map(
deployedSkillVersions?.map((version) => [normalizeSandboxDir(version.targetDir), version]) || []
);
// 并发 find 所有目录,过滤出错目录,避免级联报错 // 并发 find 所有目录,过滤出错目录,避免级联报错
const findResults = await Promise.all( const findResults = await Promise.all(
...@@ -89,12 +94,22 @@ export const getAgentSkillInfos = async ({ ...@@ -89,12 +94,22 @@ export const getAgentSkillInfos = async ({
return null; return null;
} }
const directory = file.path.replace(/\/skill\.md$/i, '');
const deployedVersion = findDeployedVersionByPath(directory, deployedVersionByTargetDir);
return { return {
id: file.path, id: file.path,
name: String(frontmatter.name), name: String(frontmatter.name),
description: frontmatter.description ? String(frontmatter.description) : '', description: frontmatter.description ? String(frontmatter.description) : '',
directory: file.path.replace(/\/skill\.md$/i, ''), directory,
skillMdPath: file.path skillMdPath: file.path,
...(deployedVersion
? {
appId: deployedVersion.skillId,
appName: deployedVersion.name,
appDescription: deployedVersion.description
}
: {})
}; };
}) })
.filter((info): info is DeployedSkillInfo => !!info); .filter((info): info is DeployedSkillInfo => !!info);
...@@ -330,7 +345,11 @@ export const injectAgentSkillFilesToSandbox = async ({ ...@@ -330,7 +345,11 @@ export const injectAgentSkillFilesToSandbox = async ({
await cleanupStaleDirs(expectedTargetDirs); await cleanupStaleDirs(expectedTargetDirs);
return deployableSkills.map(({ versionId, targetDir }) => ({ return deployableSkills.map(({ skill, versionId, targetDir }) => ({
skillId: String(skill._id),
name: skill.name,
description: skill.description || '',
avatar: skill.avatar,
versionId, versionId,
targetDir targetDir
})); }));
...@@ -338,6 +357,24 @@ export const injectAgentSkillFilesToSandbox = async ({ ...@@ -338,6 +357,24 @@ export const injectAgentSkillFilesToSandbox = async ({
const getSafeRuntimePathSegment = (value: string): string => value.replace(/[^a-zA-Z0-9_-]/g, '-'); const getSafeRuntimePathSegment = (value: string): string => value.replace(/[^a-zA-Z0-9_-]/g, '-');
const normalizeSandboxDir = (dir: string): string => dir.replace(/\/+$/, '');
const findDeployedVersionByPath = (
path: string,
deployedVersionByTargetDir: Map<string, DeployedSkillVersion>
): DeployedSkillVersion | undefined => {
const normalizedPath = normalizeSandboxDir(path);
const sortedTargetDirs = Array.from(deployedVersionByTargetDir.keys()).sort(
(a, b) => b.length - a.length
);
return deployedVersionByTargetDir.get(
sortedTargetDirs.find((targetDir) => {
return normalizedPath === targetDir || normalizedPath.startsWith(`${targetDir}/`);
}) || ''
);
};
const isSafeDirectSkillVersionDir = (dir: string, skillsRootPath: string): boolean => { const isSafeDirectSkillVersionDir = (dir: string, skillsRootPath: string): boolean => {
const root = skillsRootPath === '/' ? '' : skillsRootPath.replace(/\/+$/, ''); const root = skillsRootPath === '/' ? '' : skillsRootPath.replace(/\/+$/, '');
const prefix = `${root}/`; const prefix = `${root}/`;
......
...@@ -6,9 +6,16 @@ export type DeployedSkillInfo = { ...@@ -6,9 +6,16 @@ export type DeployedSkillInfo = {
avatar?: string; avatar?: string;
directory: string; directory: string;
skillMdPath: string; skillMdPath: string;
appId?: string;
appName?: string;
appDescription?: string;
}; };
export type DeployedSkillVersion = { export type DeployedSkillVersion = {
skillId?: string;
name?: string;
description?: string;
avatar?: string;
versionId: string; versionId: string;
targetDir: string; targetDir: string;
}; };
...@@ -237,18 +237,27 @@ export function buildAgentSkillsPrompt(skillInfos: DeployedSkillInfo[] = []): st ...@@ -237,18 +237,27 @@ export function buildAgentSkillsPrompt(skillInfos: DeployedSkillInfo[] = []): st
return `## 技能 return `## 技能
你可以使用可复用的技能。每个技能都提供针对特定任务的操作说明。当用户任务与某个技能的描述匹配时,先读取该技能的 SKILL.md 路径,然后再继续执行。不要仅凭技能描述推断完整工作流。 你可以使用可复用的技能。每个技能都提供针对特定任务的操作说明。当用户任务与某个技能的描述匹配时,先读取该技能的 SKILL.md 路径,然后再继续执行。不要仅凭技能描述推断完整工作流。
如果技能包含 app_name 或 app_description,它们表示平台 Skill 应用的名称和描述;name 和 description 表示该应用包内展开后的具体子 Skill。匹配任务时同时参考平台 Skill 应用信息和子 Skill 信息。如果用户、系统提示词或应用配置提到某个平台 Skill 应用名,应在该应用下选择最匹配的子 Skill。
当技能引用相对路径文件时,应以该技能的 SKILL.md 所在目录作为基准目录进行解析。 当技能引用相对路径文件时,应以该技能的 SKILL.md 所在目录作为基准目录进行解析。
实际执行入口始终是子 Skill 的 path;平台 Skill 应用信息只用于帮助你把应用层语义对齐到具体子 Skill。
你可以通过 ${SANDBOX_READ_FILE_TOOL_NAME} 工具来读取完整的技能。 你可以通过 ${SANDBOX_READ_FILE_TOOL_NAME} 工具来读取完整的技能。
下面是可用的技能: 下面是可用的技能:
${skillInfos ${skillInfos
.map( .map((info) =>
(info) => `<skill> [
<name>${escapeXml(info.name)}</name> '<skill>',
<description>${escapeXml(info.description)}</description> ...(info.appId ? [`<app_id>${escapeXml(info.appId)}</app_id>`] : []),
<directory>${escapeXml(info.directory)}</directory> ...(info.appName ? [`<app_name>${escapeXml(info.appName)}</app_name>`] : []),
<path>${escapeXml(info.skillMdPath)}</path> ...(info.appDescription
</skill>` ? [`<app_description>${escapeXml(info.appDescription)}</app_description>`]
: []),
`<name>${escapeXml(info.name)}</name>`,
`<description>${escapeXml(info.description)}</description>`,
`<directory>${escapeXml(info.directory)}</directory>`,
`<path>${escapeXml(info.skillMdPath)}</path>`,
'</skill>'
].join('\n')
) )
.join('\n')}`; .join('\n')}`;
} }
......
...@@ -227,6 +227,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -227,6 +227,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
needSandboxRuntime: effectiveUseAgentSandbox, needSandboxRuntime: effectiveUseAgentSandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint, sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds, skillIds,
selectedSkills,
editSkillId, editSkillId,
prepareActions: agentSandboxPrepareActions, prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles currentFiles: userContext.currentFiles
......
...@@ -160,6 +160,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -160,6 +160,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
needSandboxRuntime: effectiveUseAgentSandbox, needSandboxRuntime: effectiveUseAgentSandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint, sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds, skillIds,
selectedSkills,
editSkillId, editSkillId,
prepareActions: agentSandboxPrepareActions, prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles currentFiles: userContext.currentFiles
......
import type { AgentInputFile } from '../../adapter/userContext'; import type { AgentInputFile } from '../../adapter/userContext';
import type { DeployedSkillInfo, DeployedSkillVersion } from '../../../../../../ai/skill/runtime'; import type { DeployedSkillInfo, DeployedSkillVersion } from '../../../../../../ai/skill/runtime';
import type { BuiltinSkillSource } from '@fastgpt/global/core/ai/skill/runtime/builtin'; import type { BuiltinSkillSource } from '@fastgpt/global/core/ai/skill/runtime/builtin';
import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type';
import { import {
getAgentSkillInfos, getAgentSkillInfos,
getBuiltinSkillsRootPath, getBuiltinSkillsRootPath,
...@@ -46,6 +47,7 @@ type EnsureAgentSandboxRuntimeParams = { ...@@ -46,6 +47,7 @@ type EnsureAgentSandboxRuntimeParams = {
needSandboxRuntime: boolean; needSandboxRuntime: boolean;
sandboxEntrypoint?: string; sandboxEntrypoint?: string;
skillIds: string[]; skillIds: string[];
selectedSkills?: SelectedAgentSkillItemType[];
editSkillId?: string; editSkillId?: string;
prepareActions?: AgentSandboxPrepareAction[]; prepareActions?: AgentSandboxPrepareAction[];
currentFiles: AgentInputFile[]; currentFiles: AgentInputFile[];
...@@ -72,6 +74,7 @@ export async function ensureAgentSandboxRuntime({ ...@@ -72,6 +74,7 @@ export async function ensureAgentSandboxRuntime({
needSandboxRuntime, needSandboxRuntime,
sandboxEntrypoint, sandboxEntrypoint,
skillIds, skillIds,
selectedSkills,
editSkillId, editSkillId,
prepareActions = [], prepareActions = [],
currentFiles currentFiles
...@@ -113,7 +116,7 @@ export async function ensureAgentSandboxRuntime({ ...@@ -113,7 +116,7 @@ export async function ensureAgentSandboxRuntime({
: prepareSandbox( : prepareSandbox(
context, context,
preparePackageMirrors(), preparePackageMirrors(),
injectSelectedSkillFiles({ teamId, tmbId, skillIds }), injectSelectedSkillFiles({ teamId, tmbId, skillIds, selectedSkills }),
injectCurrentInputFiles(currentFiles), injectCurrentInputFiles(currentFiles),
...prepareActions, ...prepareActions,
readCurrentWorkingDirectory(), readCurrentWorkingDirectory(),
...@@ -183,22 +186,42 @@ const injectSelectedSkillFiles = ...@@ -183,22 +186,42 @@ const injectSelectedSkillFiles =
({ ({
teamId, teamId,
tmbId, tmbId,
skillIds skillIds,
selectedSkills
}: { }: {
teamId: string; teamId: string;
tmbId: string; tmbId: string;
skillIds: string[]; skillIds: string[];
selectedSkills?: SelectedAgentSkillItemType[];
}): AgentSandboxPrepareStep => }): AgentSandboxPrepareStep =>
async (context) => ({ async (context) => {
...context, const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
deployedSkillVersions: await injectAgentSkillFilesToSandbox({
sandbox: context.sandbox, sandbox: context.sandbox,
teamId, teamId,
tmbId, tmbId,
skillIds, skillIds,
workDirectory: context.workDirectory workDirectory: context.workDirectory
}) });
}); const selectedSkillMap = new Map(selectedSkills?.map((skill) => [skill.skillId, skill]) || []);
return {
...context,
deployedSkillVersions: deployedSkillVersions.map((version) => {
const selectedSkill = version.skillId ? selectedSkillMap.get(version.skillId) : undefined;
return {
...version,
...(selectedSkill
? {
name: selectedSkill.name,
description: selectedSkill.description,
avatar: selectedSkill.avatar
}
: {})
};
})
};
};
const runSelectedSkillEntrypoints = (): AgentSandboxPrepareStep => async (context) => { const runSelectedSkillEntrypoints = (): AgentSandboxPrepareStep => async (context) => {
if (context.deployedSkillVersions.length > 0) { if (context.deployedSkillVersions.length > 0) {
...@@ -220,7 +243,8 @@ const scanSelectedSkillInfos = (): AgentSandboxPrepareStep => async (context) => ...@@ -220,7 +243,8 @@ const scanSelectedSkillInfos = (): AgentSandboxPrepareStep => async (context) =>
return skillDirectories.length > 0 return skillDirectories.length > 0
? getAgentSkillInfos({ ? getAgentSkillInfos({
sandbox: context.sandbox, sandbox: context.sandbox,
skillDirectories skillDirectories,
deployedSkillVersions: context.deployedSkillVersions
}) })
: Promise.resolve([]); : Promise.resolve([]);
})() })()
......
...@@ -230,7 +230,7 @@ description: Zeta skill ...@@ -230,7 +230,7 @@ description: Zeta skill
) )
}; };
const deployedVersions = await injectAgentSkillFilesToSandbox({ const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill1._id), String(skill2._id)], skillIds: [String(skill1._id), String(skill2._id)],
teamId, teamId,
...@@ -239,7 +239,7 @@ description: Zeta skill ...@@ -239,7 +239,7 @@ description: Zeta skill
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
sandbox: sandbox as any, sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir) skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
}); });
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1); expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
...@@ -427,7 +427,7 @@ description: Missing skill ...@@ -427,7 +427,7 @@ description: Missing skill
) )
}; };
const deployedVersions = await injectAgentSkillFilesToSandbox({ const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(existingSkill._id), String(missingSkill._id)], skillIds: [String(existingSkill._id), String(missingSkill._id)],
teamId, teamId,
...@@ -436,7 +436,7 @@ description: Missing skill ...@@ -436,7 +436,7 @@ description: Missing skill
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
sandbox: sandbox as any, sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir) skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
}); });
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1); expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
...@@ -566,7 +566,7 @@ description: Latest current skill ...@@ -566,7 +566,7 @@ description: Latest current skill
]) ])
}; };
const deployedVersions = await injectAgentSkillFilesToSandbox({ const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
...@@ -575,7 +575,7 @@ description: Latest current skill ...@@ -575,7 +575,7 @@ description: Latest current skill
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
sandbox: sandbox as any, sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir) skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
}); });
expect( expect(
...@@ -693,7 +693,7 @@ description: Latest current skill ...@@ -693,7 +693,7 @@ description: Latest current skill
readFiles: vi.fn() readFiles: vi.fn()
}; };
const deployedVersions = await injectAgentSkillFilesToSandbox({ const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(readableSkill._id), String(protectedSkill._id)], skillIds: [String(readableSkill._id), String(protectedSkill._id)],
teamId: owner.teamId, teamId: owner.teamId,
...@@ -701,8 +701,12 @@ description: Latest current skill ...@@ -701,8 +701,12 @@ description: Latest current skill
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
expect(deployedVersions).toEqual([ expect(deployedSkillVersions).toEqual([
{ {
skillId: String(readableSkill._id),
name: 'Readable',
description: '',
avatar: undefined,
versionId: String(readableVersionId), versionId: String(readableVersionId),
targetDir: readableTargetDir targetDir: readableTargetDir
} }
...@@ -787,7 +791,7 @@ description: Latest current skill ...@@ -787,7 +791,7 @@ description: Latest current skill
readFiles: vi.fn() readFiles: vi.fn()
}; };
const deployedVersions = await injectAgentSkillFilesToSandbox({ const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
...@@ -795,8 +799,12 @@ description: Latest current skill ...@@ -795,8 +799,12 @@ description: Latest current skill
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
expect(deployedVersions).toEqual([ expect(deployedSkillVersions).toEqual([
{ {
skillId: String(skill._id),
name: 'CachedVersion',
description: '',
avatar: undefined,
versionId: String(currentVersionId), versionId: String(currentVersionId),
targetDir: currentTargetDir targetDir: currentTargetDir
} }
...@@ -1051,4 +1059,52 @@ description: Write reports ...@@ -1051,4 +1059,52 @@ description: Write reports
} }
]); ]);
}); });
it('attaches parent skill app metadata by deployed version directory', async () => {
const sandbox = {
execute: vi.fn(async () => ({
exitCode: 0,
stdout: '/workspace/.skills/version_1/fetch-webpage/SKILL.md\0',
stderr: ''
})),
readFiles: vi.fn(async () => [
{
path: '/workspace/.skills/version_1/fetch-webpage/SKILL.md',
content: `---
name: fetch-webpage
description: Read webpages
---
# Fetch webpage`
}
])
};
const skillInfos = await getAgentSkillInfos({
skillDirectories: ['/workspace/.skills/version_1'],
deployedSkillVersions: [
{
skillId: 'skill_app_1',
name: 'Web Research App',
description: 'Contains webpage fetch and summary skills',
versionId: 'version_1',
targetDir: '/workspace/.skills/version_1'
}
],
sandbox: sandbox as any
});
expect(skillInfos).toEqual([
{
id: '/workspace/.skills/version_1/fetch-webpage/SKILL.md',
appId: 'skill_app_1',
appName: 'Web Research App',
appDescription: 'Contains webpage fetch and summary skills',
name: 'fetch-webpage',
description: 'Read webpages',
directory: '/workspace/.skills/version_1/fetch-webpage',
skillMdPath: '/workspace/.skills/version_1/fetch-webpage/SKILL.md'
}
]);
});
}); });
...@@ -372,6 +372,29 @@ describe('buildAgentUserReminderInput', () => { ...@@ -372,6 +372,29 @@ describe('buildAgentUserReminderInput', () => {
expect(result).toContain('<directory>/workspace/Report &amp; Review</directory>'); expect(result).toContain('<directory>/workspace/Report &amp; Review</directory>');
expect(result).toContain('<path>/workspace/Report &amp; Review/SKILL.md</path>'); expect(result).toContain('<path>/workspace/Report &amp; Review/SKILL.md</path>');
}); });
it('includes parent skill app metadata for injected child skills', () => {
const result = buildAgentSkillsPrompt([
{
id: 'skill_report',
appId: 'app_skill_1',
appName: 'Research <App>',
appDescription: 'Includes fetch & summarize skills',
name: 'fetch-webpage',
description: 'Read webpages',
directory: '/workspace/.skills/version_1/fetch-webpage',
skillMdPath: '/workspace/.skills/version_1/fetch-webpage/SKILL.md'
}
]);
expect(result).toContain('<app_id>app_skill_1</app_id>');
expect(result).toContain('<app_name>Research &lt;App&gt;</app_name>');
expect(result).toContain(
'<app_description>Includes fetch &amp; summarize skills</app_description>'
);
expect(result).toContain('<name>fetch-webpage</name>');
expect(result).toContain('<path>/workspace/.skills/version_1/fetch-webpage/SKILL.md</path>');
});
}); });
describe('useUserContext', () => { describe('useUserContext', () => {
......
...@@ -387,6 +387,7 @@ describe('dispatchRunAgent user context', () => { ...@@ -387,6 +387,7 @@ describe('dispatchRunAgent user context', () => {
needSandboxRuntime: true, needSandboxRuntime: true,
sandboxEntrypoint: 'pip install -r requirements.txt', sandboxEntrypoint: 'pip install -r requirements.txt',
skillIds: [], skillIds: [],
selectedSkills: [],
editSkillId: undefined, editSkillId: undefined,
prepareActions: undefined, prepareActions: undefined,
currentFiles: [ currentFiles: [
...@@ -493,6 +494,7 @@ describe('dispatchRunAgent user context', () => { ...@@ -493,6 +494,7 @@ describe('dispatchRunAgent user context', () => {
needSandboxRuntime: true, needSandboxRuntime: true,
sandboxEntrypoint: undefined, sandboxEntrypoint: undefined,
skillIds: ['edit_skill_1'], skillIds: ['edit_skill_1'],
selectedSkills: [],
editSkillId: 'edit_skill_1', editSkillId: 'edit_skill_1',
prepareActions: undefined, prepareActions: undefined,
currentFiles: [ currentFiles: [
......
...@@ -479,6 +479,7 @@ describe('dispatchPiAgent user context', () => { ...@@ -479,6 +479,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true, needSandboxRuntime: true,
sandboxEntrypoint: undefined, sandboxEntrypoint: undefined,
skillIds: [], skillIds: [],
selectedSkills: [],
editSkillId: undefined, editSkillId: undefined,
prepareActions: undefined, prepareActions: undefined,
currentFiles: [ currentFiles: [
...@@ -544,6 +545,7 @@ describe('dispatchPiAgent user context', () => { ...@@ -544,6 +545,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true, needSandboxRuntime: true,
sandboxEntrypoint: undefined, sandboxEntrypoint: undefined,
skillIds: ['skill_1'], skillIds: ['skill_1'],
selectedSkills: [{ skillId: 'skill_1' }],
editSkillId: undefined, editSkillId: undefined,
prepareActions: undefined, prepareActions: undefined,
currentFiles: [ currentFiles: [
...@@ -621,6 +623,7 @@ describe('dispatchPiAgent user context', () => { ...@@ -621,6 +623,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true, needSandboxRuntime: true,
sandboxEntrypoint: undefined, sandboxEntrypoint: undefined,
skillIds: ['edit_skill_1'], skillIds: ['edit_skill_1'],
selectedSkills: [],
editSkillId: 'edit_skill_1', editSkillId: 'edit_skill_1',
prepareActions: undefined, prepareActions: undefined,
currentFiles: [ currentFiles: [
......
...@@ -182,7 +182,13 @@ describe('ensureAgentSandboxRuntime', () => { ...@@ -182,7 +182,13 @@ describe('ensureAgentSandboxRuntime', () => {
); );
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({ expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock, sandbox: sandboxProviderMock,
skillDirectories: ['/workspace/skills/version_1', '/home/sandbox/.fastgpt/skills'] skillDirectories: ['/workspace/skills/version_1', '/home/sandbox/.fastgpt/skills'],
deployedSkillVersions: [
{
versionId: 'version_1',
targetDir: '/workspace/skills/version_1'
}
]
}); });
expect(result).toEqual({ expect(result).toEqual({
sandboxClient: sandboxClientMock, sandboxClient: sandboxClientMock,
......
...@@ -17,7 +17,12 @@ import type { SelectedToolItemType } from '@fastgpt/global/core/app/formEdit/typ ...@@ -17,7 +17,12 @@ import type { SelectedToolItemType } from '@fastgpt/global/core/app/formEdit/typ
const REGEX = new RegExp(getSkillRegexString(), 'i'); const REGEX = new RegExp(getSkillRegexString(), 'i');
export type SkillLabelItemType = SelectedToolItemType & { export type SkillLabelItemType = Partial<SelectedToolItemType> & {
id: string;
name: string;
avatar?: string;
flowNodeType: FlowNodeTypeEnum;
configStatus?: SelectedToolItemType['configStatus'];
tooltip?: string; tooltip?: string;
}; };
......
Subproject commit 61871458aa384de0ecd4c0e50f23ff8b71b516a5 Subproject commit 23a105e56fcb7a4e7e0c161e23488290ef297a21
...@@ -30,6 +30,7 @@ export const HelperBotContext = createContext<HelperBotContextType>({ ...@@ -30,6 +30,7 @@ export const HelperBotContext = createContext<HelperBotContextType>({
taskObject: '', taskObject: '',
selectedTools: [], selectedTools: [],
selectedDatasets: [], selectedDatasets: [],
selectedAgentSkills: [],
fileUpload: false, fileUpload: false,
enableSandbox: false enableSandbox: false
}, },
......
...@@ -115,6 +115,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -115,6 +115,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
systemPrompt: appForm.aiSettings.systemPrompt, systemPrompt: appForm.aiSettings.systemPrompt,
selectedTools: appForm.selectedTools.map((tool) => tool.id), selectedTools: appForm.selectedTools.map((tool) => tool.id),
selectedDatasets: appForm.dataset.datasets.map((dataset) => dataset.datasetId), selectedDatasets: appForm.dataset.datasets.map((dataset) => dataset.datasetId),
selectedAgentSkills: appForm.selectedAgentSkills || [],
fileUpload: appForm.chatConfig.fileSelectConfig?.canSelectFile || false, fileUpload: appForm.chatConfig.fileSelectConfig?.canSelectFile || false,
enableSandbox: appForm.aiSettings.useAgentSandbox || false, enableSandbox: appForm.aiSettings.useAgentSandbox || false,
modelConfig: { modelConfig: {
...@@ -209,6 +210,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -209,6 +210,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
const newForm: AppFormEditFormType = { const newForm: AppFormEditFormType = {
...prev, ...prev,
selectedTools: [...newTools], selectedTools: [...newTools],
selectedAgentSkills: formData.selectedAgentSkills || [],
dataset: dataset:
formData.datasets && formData.datasets.length > 0 formData.datasets && formData.datasets.length > 0
? { ? {
...@@ -219,7 +221,8 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -219,7 +221,8 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
aiSettings: { aiSettings: {
...prev.aiSettings, ...prev.aiSettings,
systemPrompt: formData.systemPrompt || prev.aiSettings.systemPrompt, systemPrompt: formData.systemPrompt || prev.aiSettings.systemPrompt,
useAgentSandbox: enableSandboxEnabled useAgentSandbox:
enableSandboxEnabled || (formData.selectedAgentSkills?.length || 0) > 0
}, },
chatConfig: { chatConfig: {
...prev.chatConfig, ...prev.chatConfig,
......
...@@ -66,8 +66,28 @@ const EditForm = ({ ...@@ -66,8 +66,28 @@ const EditForm = ({
const selectDatasets = useMemo(() => appForm?.dataset?.datasets, [appForm]); const selectDatasets = useMemo(() => appForm?.dataset?.datasets, [appForm]);
const {
selectedAgentSkills,
isAgentSkillSandboxUnavailable,
isOpenSkillSelect,
onCloseSkillSelect,
openSkillSelect,
onAddAgentSkill,
onRemoveAgentSkill,
onChangeAgentSandbox,
ConfirmModal,
isOpenRecharge,
onCloseRecharge
} = useAgentSkillSelect({
appForm,
showSandbox,
enableSandbox,
setAppForm
});
const { skillOption, selectedSkills, onClickSkill, onRemoveSkill, SkillModal } = useSkillManager({ const { skillOption, selectedSkills, onClickSkill, onRemoveSkill, SkillModal } = useSkillManager({
selectedTools: appForm.selectedTools, selectedTools: appForm.selectedTools,
selectedAgentSkills,
onDeleteTool: (id) => { onDeleteTool: (id) => {
setAppForm((state) => ({ setAppForm((state) => ({
...state, ...state,
...@@ -93,6 +113,7 @@ const EditForm = ({ ...@@ -93,6 +113,7 @@ const EditForm = ({
} }
}); });
}, },
onAddAgentSkill,
canUploadFile: !!( canUploadFile: !!(
appForm.chatConfig.fileSelectConfig?.canSelectFile || appForm.chatConfig.fileSelectConfig?.canSelectFile ||
appForm.chatConfig.fileSelectConfig?.canSelectImg || appForm.chatConfig.fileSelectConfig?.canSelectImg ||
...@@ -116,24 +137,6 @@ const EditForm = ({ ...@@ -116,24 +137,6 @@ const EditForm = ({
} = useDisclosure(); } = useDisclosure();
const selectedModel = getWebLLMModel(appForm.aiSettings.model); const selectedModel = getWebLLMModel(appForm.aiSettings.model);
const {
selectedAgentSkills,
isAgentSkillSandboxUnavailable,
isOpenSkillSelect,
onCloseSkillSelect,
openSkillSelect,
onAddAgentSkill,
onRemoveAgentSkill,
onChangeAgentSandbox,
ConfirmModal,
isOpenRecharge,
onCloseRecharge
} = useAgentSkillSelect({
appForm,
showSandbox,
enableSandbox,
setAppForm
});
const promptSkillOption = useMemo( const promptSkillOption = useMemo(
() => ({ () => ({
...skillOption, ...skillOption,
......
...@@ -83,9 +83,31 @@ export const useAgentSkillSelect = ({ ...@@ -83,9 +83,31 @@ export const useAgentSkillSelect = ({
const onAddAgentSkill = useCallback( const onAddAgentSkill = useCallback(
(skill: SelectedAgentSkillItemType) => { (skill: SelectedAgentSkillItemType) => {
if (!showSandbox) {
toast({
status: 'warning',
title: t('skill:sandbox_skill_system_not_configured_toast')
});
return false;
}
if (!enableSandbox) {
openConfirm({
title: t('skill:sandbox_plan_not_supported_title'),
customContent: t('skill:sandbox_skill_plan_not_supported_content'),
onConfirm: isTeamAdmin ? onOpenRecharge : undefined,
confirmText: isTeamAdmin ? t('skill:sandbox_upgrade_action') : t('common:Close'),
cancelText: t('common:Close'),
showCancel: isTeamAdmin
})();
return false;
}
setAppForm((state) => ({ setAppForm((state) => ({
...state, ...state,
selectedAgentSkills: [skill, ...(state.selectedAgentSkills || [])], selectedAgentSkills: [
skill,
...(state.selectedAgentSkills || []).filter((item) => item.skillId !== skill.skillId)
],
aiSettings: { aiSettings: {
...state.aiSettings, ...state.aiSettings,
useAgentSandbox: true useAgentSandbox: true
...@@ -97,8 +119,19 @@ export const useAgentSkillSelect = ({ ...@@ -97,8 +119,19 @@ export const useAgentSkillSelect = ({
title: t('skill:sandbox_auto_enabled_for_skill') title: t('skill:sandbox_auto_enabled_for_skill')
}); });
} }
return true;
}, },
[appForm.aiSettings.useAgentSandbox, setAppForm, t, toast] [
appForm.aiSettings.useAgentSandbox,
enableSandbox,
isTeamAdmin,
onOpenRecharge,
openConfirm,
setAppForm,
showSandbox,
t,
toast
]
); );
const onRemoveAgentSkill = useCallback( const onRemoveAgentSkill = useCallback(
......
...@@ -5,7 +5,7 @@ import type { ...@@ -5,7 +5,7 @@ import type {
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { import {
checkNeedsUserConfiguration, checkNeedsUserConfiguration,
getToolConfigStatus, getToolConfigStatus,
...@@ -16,7 +16,10 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; ...@@ -16,7 +16,10 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { SkillLabelItemType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillLabelPlugin'; import type { SkillLabelItemType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillLabelPlugin';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { SelectedToolItemType } from '@fastgpt/global/core/app/formEdit/type'; import type {
SelectedAgentSkillItemType,
SelectedToolItemType
} from '@fastgpt/global/core/app/formEdit/type';
import { import {
getAppToolTemplates, getAppToolTemplates,
getClientToolPreviewNode, getClientToolPreviewNode,
...@@ -33,8 +36,12 @@ import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/age ...@@ -33,8 +36,12 @@ import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/age
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools'; import { AGENT_SANDBOX_TOOLSET_ID } from '@fastgpt/global/core/ai/sandbox/tools';
import type { SkillClickResult } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin'; import type { SkillClickResult } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillPickerPlugin';
import { getSkillList } from '@/web/core/skill/api';
import { AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import type { ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api';
const ConfigToolModal = dynamic(() => import('../../component/ConfigToolModal')); const ConfigToolModal = dynamic(() => import('../../component/ConfigToolModal'));
type AgentSkillListItemType = ListSkillsResponse['list'][number];
const isSubApp = (flowNodeType: FlowNodeTypeEnum) => { const isSubApp = (flowNodeType: FlowNodeTypeEnum) => {
const subAppTypeMap: Record<string, boolean> = { const subAppTypeMap: Record<string, boolean> = {
...@@ -56,16 +63,42 @@ const toSkillLabelItem = ( ...@@ -56,16 +63,42 @@ const toSkillLabelItem = (
configStatus configStatus
}); });
const toAgentSkillItem = (item: AgentSkillListItemType): SkillItemType => {
const isFolder = item.type === AgentSkillTypeEnum.folder;
return {
id: item._id,
label: item.name,
icon: item.avatar || (isFolder ? 'common/folderFill' : 'core/skill/default'),
description: item.description,
isFolder,
canClick: item.type === AgentSkillTypeEnum.skill
};
};
const toAgentSkillLabelItem = (skill: SelectedAgentSkillItemType): SkillLabelItemType => ({
id: skill.skillId,
name: skill.name,
avatar: skill.avatar || 'core/skill/default',
intro: skill.description,
flowNodeType: FlowNodeTypeEnum.tool,
configStatus: skill.isDeleted ? 'invalid' : 'noConfig'
});
export const useSkillManager = ({ export const useSkillManager = ({
selectedTools, selectedTools,
selectedAgentSkills = [],
onUpdateOrAddTool, onUpdateOrAddTool,
onAddAgentSkill,
canUploadFile, canUploadFile,
hasSelectedDataset, hasSelectedDataset,
useAgentSandbox useAgentSandbox
}: { }: {
selectedTools: SelectedToolItemType[]; selectedTools: SelectedToolItemType[];
selectedAgentSkills?: SelectedAgentSkillItemType[];
onDeleteTool: (id: string) => void; onDeleteTool: (id: string) => void;
onUpdateOrAddTool: (tool: SelectedToolItemType) => void; onUpdateOrAddTool: (tool: SelectedToolItemType) => void;
onAddAgentSkill?: (skill: SelectedAgentSkillItemType) => boolean;
canUploadFile: boolean; canUploadFile: boolean;
hasSelectedDataset: boolean; hasSelectedDataset: boolean;
useAgentSandbox: boolean; useAgentSandbox: boolean;
...@@ -195,7 +228,82 @@ export const useSkillManager = ({ ...@@ -195,7 +228,82 @@ export const useSkillManager = ({
}); });
}, []); }, []);
/* ===== Agent skills ===== */
const agentSkillMapRef = useRef<Map<string, AgentSkillListItemType>>(new Map());
const cacheAgentSkillList = useCallback((list: AgentSkillListItemType[]) => {
list.forEach((item) => {
if (item.type === AgentSkillTypeEnum.skill) {
agentSkillMapRef.current.set(item._id, item);
}
});
return list.map(toAgentSkillItem);
}, []);
const { data: agentSkills = [] } = useRequest(
async () => {
if (!onAddAgentSkill) return [];
const { list } = await getSkillList({
source: 'mine',
parentId: '',
withAppCount: false
});
return cacheAgentSkillList(list);
},
{
manual: false
}
);
const onFolderLoadAgentSkills = useCallback(
async (folderId: string) => {
const { list } = await getSkillList({
source: 'mine',
parentId: folderId,
withAppCount: false
});
return cacheAgentSkillList(list);
},
[cacheAgentSkillList]
);
const lastSelectedTools = useLatest(selectedTools); const lastSelectedTools = useLatest(selectedTools);
const lastSelectedAgentSkills = useLatest(selectedAgentSkills);
const onAddSkill = useCallback(
async (skillId: string): Promise<SkillClickResult | undefined> => {
const existsSkill = lastSelectedAgentSkills.current?.find((item) => item.skillId === skillId);
if (existsSkill) {
const skill = toAgentSkillLabelItem(existsSkill);
return {
id: skill.id,
skill
};
}
const targetSkill = agentSkillMapRef.current.get(skillId);
if (!targetSkill) return;
const selectedSkill: SelectedAgentSkillItemType = {
skillId: targetSkill._id,
name: targetSkill.name,
description: targetSkill.description,
avatar: targetSkill.avatar,
isDeleted: false
};
if (!onAddAgentSkill?.(selectedSkill)) return;
const skill = toAgentSkillLabelItem(selectedSkill);
return {
id: skill.id,
skill
};
},
[lastSelectedAgentSkills, onAddAgentSkill]
);
const onAddAppOrTool = useCallback( const onAddAppOrTool = useCallback(
async (toolId: string): Promise<SkillClickResult | undefined> => { async (toolId: string): Promise<SkillClickResult | undefined> => {
// Check tool exists, if exists, not update/add tool // Check tool exists, if exists, not update/add tool
...@@ -317,6 +425,13 @@ export const useSkillManager = ({ ...@@ -317,6 +425,13 @@ export const useSkillManager = ({
onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList), onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList),
onClick: onAddAppOrTool onClick: onAddAppOrTool
}; };
} else if (id === 'agentSkill') {
return {
description: t('app:space_to_expand_folder'),
list: agentSkills,
onFolderLoad: onFolderLoadAgentSkills,
onClick: onAddSkill
};
} }
return undefined; return undefined;
}, },
...@@ -339,9 +454,31 @@ export const useSkillManager = ({ ...@@ -339,9 +454,31 @@ export const useSkillManager = ({
icon: 'core/workflow/template/runApp', icon: 'core/workflow/template/runApp',
canClick: false canClick: false
} }
] ].concat(
onAddAgentSkill
? [
{
id: 'agentSkill',
label: t('skill:associated_skills'),
icon: 'core/skill/default',
canClick: false
}
]
: []
)
}; };
}, [onAddAppOrTool, onLoadSystemTool, myTools, myAgents, onFolderLoadTeamApps, t]); }, [
onAddAppOrTool,
onAddSkill,
onAddAgentSkill,
onLoadSystemTool,
myTools,
myAgents,
agentSkills,
onFolderLoadTeamApps,
onFolderLoadAgentSkills,
t
]);
/* ===== Selected skills ===== */ /* ===== Selected skills ===== */
const selectedSkills = useMemoEnhance<SkillLabelItemType[]>(() => { const selectedSkills = useMemoEnhance<SkillLabelItemType[]>(() => {
...@@ -413,12 +550,23 @@ export const useSkillManager = ({ ...@@ -413,12 +550,23 @@ export const useSkillManager = ({
}); });
} }
return tools; return [...tools, ...selectedAgentSkills.map(toAgentSkillLabelItem)];
}, [selectedTools, canUploadFile, hasSelectedDataset, useAgentSandbox, i18n.language]); }, [
selectedTools,
selectedAgentSkills,
canUploadFile,
hasSelectedDataset,
useAgentSandbox,
i18n.language
]);
const [configTool, setConfigTool] = useState<SelectedToolItemType>(); const [configTool, setConfigTool] = useState<SelectedToolItemType>();
const onClickSkill = useCallback( const onClickSkill = useCallback(
(id: string) => { (id: string) => {
if (selectedAgentSkills.some((skill) => skill.skillId === id)) {
return;
}
const tool = selectedTools.find((tool) => tool.pluginId === id); const tool = selectedTools.find((tool) => tool.pluginId === id);
if (!tool) return; if (!tool) return;
...@@ -431,7 +579,7 @@ export const useSkillManager = ({ ...@@ -431,7 +579,7 @@ export const useSkillManager = ({
setConfigTool(tool); setConfigTool(tool);
} }
}, },
[selectedTools] [selectedAgentSkills, selectedTools]
); );
const onRemoveSkill = useCallback(() => {}, []); const onRemoveSkill = useCallback(() => {}, []);
......
...@@ -381,6 +381,7 @@ const DataCard = () => { ...@@ -381,6 +381,7 @@ const DataCard = () => {
position={'absolute'} position={'absolute'}
bottom={2} bottom={2}
right={2} right={2}
zIndex={2}
overflow={'hidden'} overflow={'hidden'}
alignItems={'flex-end'} alignItems={'flex-end'}
visibility={'hidden'} visibility={'hidden'}
......
import { NextAPI } from '@/service/middleware/entry'; import { NextAPI } from '@/service/middleware/entry';
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { Types } from '@fastgpt/service/common/mongo';
import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import {
PerResourceTypeEnum,
ReadPermissionVal
} from '@fastgpt/global/support/permission/constant';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { parseParentIdInMongo } from '@fastgpt/global/common/parentFolder/utils';
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { authSkill } from '@fastgpt/service/support/permission/skill/auth'; import { authSkill } from '@fastgpt/service/support/permission/skill/auth';
import { replaceRegChars } from '@fastgpt/global/common/string/tools';
import { getGroupsByTmbId } from '@fastgpt/service/support/permission/memberGroup/controllers';
import { getOrgIdSetWithParentByTmbId } from '@fastgpt/service/support/permission/org/controllers';
import { addSourceMember } from '@fastgpt/service/support/user/utils';
import { sumPer } from '@fastgpt/global/support/permission/utils';
import { AgentSkillTypeEnum, AgentSkillSourceEnum } from '@fastgpt/global/core/ai/skill/constants';
import { ListSkillsQuerySchema, type ListSkillsQuery } from '@fastgpt/global/core/ai/skill/api'; import { ListSkillsQuerySchema, type ListSkillsQuery } from '@fastgpt/global/core/ai/skill/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { import { listReadableAgentSkills } from '@fastgpt/service/core/ai/skill/manage';
AppResourceRefsSkillIdsPath,
buildAppSkillRefMongoQuery
} from '@fastgpt/service/core/app/resourceRefs';
export type GetSkillListBody = ListSkillsQuery; export type GetSkillListBody = ListSkillsQuery;
const mergeMongoAndQuery = (...queries: Record<string, unknown>[]) => {
const validQueries = queries.filter((query) => Object.keys(query).length > 0);
if (validQueries.length === 0) return {};
if (validQueries.length === 1) return validQueries[0];
// 多个过滤条件都可能包含 $or,用 $and 合并避免对象展开时覆盖同名键。
return {
$and: validQueries
};
};
async function handler(req: ApiRequestProps<GetSkillListBody>) { async function handler(req: ApiRequestProps<GetSkillListBody>) {
const { parentId, source, searchKey, category, type, skillIds, page, pageSize, withAppCount } = const { parentId, source, searchKey, category, type, skillIds, page, pageSize, withAppCount } =
parseApiInput({ req, bodySchema: ListSkillsQuerySchema }).body; parseApiInput({ req, bodySchema: ListSkillsQuerySchema }).body;
...@@ -66,231 +36,20 @@ async function handler(req: ApiRequestProps<GetSkillListBody>) { ...@@ -66,231 +36,20 @@ async function handler(req: ApiRequestProps<GetSkillListBody>) {
: []) : [])
]); ]);
// Get team all skill permissions return listReadableAgentSkills({
const [roleList, myGroupMap, myOrgSet] = await Promise.all([ teamId,
MongoResourcePermission.find({ tmbId,
resourceType: PerResourceTypeEnum.agentSkill, teamPer,
teamId, parentId,
resourceId: { source,
$exists: true searchKey,
} category,
}).lean(), type,
getGroupsByTmbId({ skillIds: selectedSkillIds,
tmbId, page,
teamId pageSize,
}).then((item) => { withAppCount
const map = new Map<string, 1>();
item.forEach((item) => {
map.set(String(item._id), 1);
});
return map;
}),
getOrgIdSetWithParentByTmbId({
teamId,
tmbId
})
]);
const myRoles = roleList.filter(
(item) =>
String(item.tmbId) === String(tmbId) ||
myGroupMap.has(String(item.groupId)) ||
myOrgSet.has(String(item.orgId))
);
const myRoleResourceIds = Array.from(
new Map(myRoles.map((item) => [String(item.resourceId), item.resourceId])).values()
);
const roleCountByResourceId = new Map<string, number>();
roleList.forEach((item) => {
const resourceId = String(item.resourceId);
roleCountByResourceId.set(resourceId, (roleCountByResourceId.get(resourceId) ?? 0) + 1);
}); });
const myTmbRoleByResourceId = new Map<string, ReturnType<typeof sumPer>>();
const myGroupOrgRoleListByResourceId = new Map<string, Parameters<typeof sumPer>>();
myRoles.forEach((item) => {
const resourceId = String(item.resourceId);
if (item.tmbId) {
myTmbRoleByResourceId.set(resourceId, item.permission);
return;
}
if (item.groupId || item.orgId) {
const permissionList = myGroupOrgRoleListByResourceId.get(resourceId) ?? [];
permissionList.push(item.permission);
myGroupOrgRoleListByResourceId.set(resourceId, permissionList);
}
});
const myGroupOrgRoleByResourceId = new Map<string, ReturnType<typeof sumPer>>();
myGroupOrgRoleListByResourceId.forEach((permissionList, resourceId) => {
myGroupOrgRoleByResourceId.set(resourceId, sumPer(...permissionList));
});
const findSkillQuery = (() => {
const sourceQuery = (() => {
if (source === 'store') return { source: AgentSkillSourceEnum.system };
if (source === 'mine') return { source: AgentSkillSourceEnum.personal };
return {};
})();
const typeQuery = {
...(category ? { category: { $in: [category] } } : {}),
...(type ? { type } : {})
};
const baseQuery = {
deleteTime: null,
...sourceQuery,
...typeQuery
};
const searchMatch = searchKey
? {
$or: [
{ name: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } },
{ description: { $regex: new RegExp(`${replaceRegChars(searchKey)}`, 'i') } }
]
}
: {};
if (isSkillIdsQuery) {
const scopeQuery = source
? source === 'store'
? {}
: { teamId }
: {
$or: [{ teamId }, { source: AgentSkillSourceEnum.system }]
};
return mergeMongoAndQuery(baseQuery, scopeQuery, {
_id: { $in: selectedSkillIds },
...searchMatch
});
}
// Filter skills by permission, if not owner, only get skills that I have permission to access
const idList = { _id: { $in: myRoleResourceIds } };
const skillPerQuery = teamPer.isOwner
? {}
: parentId
? {
$or: [idList, parseParentIdInMongo(parentId)]
}
: { $or: [idList, { parentId: null }] };
// Only restrict by teamId for personal (mine) skills; store (system) skills are global
const teamIdQuery = source === 'store' ? {} : { teamId };
if (searchKey) {
return mergeMongoAndQuery(skillPerQuery, teamIdQuery, baseQuery, searchMatch);
}
return mergeMongoAndQuery(
skillPerQuery,
teamIdQuery,
baseQuery,
parseParentIdInMongo(parentId)
);
})();
const mySkills = await MongoAgentSkills.find(findSkillQuery)
.sort({
type: -1, // Folders first
updateTime: -1
})
.lean();
const formatSkills = mySkills
.map((skill) => {
const { Per, privateSkill } = (() => {
const getPer = (skillId: string) => {
const tmbRole = myTmbRoleByResourceId.get(skillId);
const groupAndOrgRole = myGroupOrgRoleByResourceId.get(skillId);
return new SkillPermission({
role: tmbRole ?? groupAndOrgRole,
isOwner: String(skill.tmbId) === String(tmbId) || teamPer.isOwner
});
};
const getClbCount = (skillId: string) => {
return roleCountByResourceId.get(skillId) ?? 0;
};
// inherit
if (skill.inheritPermission && skill.parentId && skill.type !== AgentSkillTypeEnum.folder) {
return {
Per: getPer(String(skill.parentId)).addRole(getPer(String(skill._id)).role),
privateSkill: getClbCount(String(skill.parentId)) <= 1
};
}
return {
Per: getPer(String(skill._id)),
privateSkill: getClbCount(String(skill._id)) <= 1
};
})();
return {
_id: skill._id,
avatar: skill.avatar,
name: skill.name,
description: skill.description,
type: skill.type,
source: skill.source,
category: skill.category,
inheritPermission: skill.inheritPermission,
currentVersionId: skill.currentVersionId ? String(skill.currentVersionId) : undefined,
creationStatus: skill.creationStatus,
tmbId: skill.tmbId,
parentId: skill.parentId,
createTime: skill.createTime,
updateTime: skill.updateTime,
permission: Per,
private: privateSkill
};
})
.filter((skill) => skill.permission.hasReadPer);
const total = formatSkills.length;
// Apply pagination if requested
const pagedSkills = (() => {
if (page && pageSize) {
const skip = (page - 1) * pageSize;
return formatSkills.slice(skip, skip + pageSize);
}
return formatSkills;
})();
// 默认保持历史行为返回 appCount;只统计本次返回的 skill,编辑页状态校验可显式关闭。
const nonFolderSkills =
withAppCount !== false ? pagedSkills.filter((s) => s.type !== AgentSkillTypeEnum.folder) : [];
const appCountMap = new Map<string, number>();
if (nonFolderSkills.length > 0) {
const skillIdStrings = nonFolderSkills.map((skill) => String(skill._id));
const counts = await MongoApp.aggregate<{ _id: string; count: number }>([
{
$match: {
teamId: new Types.ObjectId(String(teamId)),
deleteTime: null,
...buildAppSkillRefMongoQuery(skillIdStrings)
}
},
{ $unwind: `$${AppResourceRefsSkillIdsPath}` },
{ $match: buildAppSkillRefMongoQuery(skillIdStrings) },
{
$group: {
_id: `$${AppResourceRefsSkillIdsPath}`,
count: { $sum: 1 }
}
}
]);
counts.forEach((item) => {
appCountMap.set(String(item._id), item.count);
});
}
const listWithAppCount = pagedSkills.map((skill) => ({
...skill,
appCount: appCountMap.get(skill._id.toString()) ?? 0
}));
const listWithSourceMember = await addSourceMember({ list: listWithAppCount });
return { list: listWithSourceMember, total };
} }
export default NextAPI(handler); export default NextAPI(handler);
...@@ -11,6 +11,11 @@ import { Call } from '@test/utils/request'; ...@@ -11,6 +11,11 @@ import { Call } from '@test/utils/request';
import type { ListSkillsQuery, ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api'; import type { ListSkillsQuery, ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api';
import { onCreateApp } from '@/pages/api/core/app/create'; import { onCreateApp } from '@/pages/api/core/app/create';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import {
PerResourceTypeEnum,
ReadPermissionVal
} from '@fastgpt/global/support/permission/constant';
import { import {
FlowNodeInputTypeEnum, FlowNodeInputTypeEnum,
FlowNodeTypeEnum FlowNodeTypeEnum
...@@ -62,6 +67,84 @@ describe('POST /api/core/ai/skill/list', () => { ...@@ -62,6 +67,84 @@ describe('POST /api/core/ai/skill/list', () => {
expect(res.data.list.map((item) => String(item._id))).toEqual([String(activeSkill._id)]); expect(res.data.list.map((item) => String(item._id))).toEqual([String(activeSkill._id)]);
}); });
it('按 skillIds 查询时会在查询阶段过滤当前成员无权读取的 Skill', async () => {
const owner = await getUser(`agent-skill-list-owner-${getNanoid(6)}`);
const member = await getUser(`agent-skill-list-member-${getNanoid(6)}`, owner.teamId);
const [ownedSkill, protectedSkill] = await MongoAgentSkills.create([
{
name: 'Owned Skill',
type: AgentSkillTypeEnum.skill,
source: AgentSkillSourceEnum.personal,
teamId: owner.teamId,
tmbId: member.tmbId
},
{
name: 'Protected Skill',
type: AgentSkillTypeEnum.skill,
source: AgentSkillSourceEnum.personal,
teamId: owner.teamId,
tmbId: owner.tmbId
}
]);
const res = await Call<ListSkillsQuery, Record<string, never>, ListSkillsResponse>(handler, {
auth: member,
body: {
source: 'mine',
skillIds: [String(ownedSkill._id), String(protectedSkill._id)],
parentId: null,
withAppCount: false
}
});
expect(res.code).toBe(200);
expect(res.data.list.map((item) => String(item._id))).toEqual([String(ownedSkill._id)]);
});
it('按 skillIds 查询时保留继承父目录读权限的 Skill', async () => {
const owner = await getUser(`agent-skill-list-inherit-owner-${getNanoid(6)}`);
const member = await getUser(`agent-skill-list-inherit-member-${getNanoid(6)}`, owner.teamId);
const folder = await MongoAgentSkills.create({
name: 'Shared Folder',
type: AgentSkillTypeEnum.folder,
source: AgentSkillSourceEnum.personal,
teamId: owner.teamId,
tmbId: owner.tmbId
});
const inheritedSkill = await MongoAgentSkills.create({
name: 'Inherited Skill',
type: AgentSkillTypeEnum.skill,
source: AgentSkillSourceEnum.personal,
parentId: folder._id,
inheritPermission: true,
teamId: owner.teamId,
tmbId: owner.tmbId
});
await MongoResourcePermission.create({
resourceType: PerResourceTypeEnum.agentSkill,
teamId: owner.teamId,
resourceId: folder._id,
tmbId: member.tmbId,
permission: ReadPermissionVal
});
const res = await Call<ListSkillsQuery, Record<string, never>, ListSkillsResponse>(handler, {
auth: member,
body: {
source: 'mine',
skillIds: [String(inheritedSkill._id)],
parentId: null,
withAppCount: false
}
});
expect(res.code).toBe(200);
expect(res.data.list.map((item) => String(item._id))).toEqual([String(inheritedSkill._id)]);
});
it('appCount 基于已发布版本的 resourceRefs,草稿保存不影响统计', async () => { it('appCount 基于已发布版本的 resourceRefs,草稿保存不影响统计', async () => {
const user = await getUser(`agent-skill-list-published-refs-${getNanoid(6)}`); const user = await getUser(`agent-skill-list-published-refs-${getNanoid(6)}`);
......
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