Commit 7e333e85 by YeYuheng Committed by GitHub

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

parent 6724a1e6
import z from 'zod';
import { SelectedDatasetSchema } from '../../../workflow/type/io';
import { SelectedAgentSkillItemTypeSchema } from '../../../app/formEdit/type';
// TopAgent 参数配置
export const topAgentParamsSchema = z.object({
......@@ -8,6 +9,7 @@ export const topAgentParamsSchema = z.object({
systemPrompt: z.string().nullish(),
selectedTools: z.array(z.string()).nullish(),
selectedDatasets: z.array(z.string()).nullish(),
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).nullish(),
fileUpload: z.boolean().nullish(),
enableSandbox: z.boolean().nullish()
});
......@@ -17,6 +19,7 @@ export const TopAgentFormDataSchema = z.object({
systemPrompt: z.string().optional(),
tools: z.array(z.string()).optional().default([]),
datasets: z.array(SelectedDatasetSchema).optional().default([]),
selectedAgentSkills: z.array(SelectedAgentSkillItemTypeSchema).optional().default([]),
fileUploadEnabled: z.boolean().optional().default(false),
enableSandboxEnabled: z.boolean().optional().default(false),
executionPlan: z.any().optional()
......
......@@ -14,6 +14,14 @@ describe('topAgentParamsSchema', () => {
systemPrompt: 'You are a helpful assistant',
selectedTools: ['tool1', 'tool2'],
selectedDatasets: ['dataset1'],
selectedAgentSkills: [
{
skillId: 'skill1',
name: 'Research Skill',
description: 'Research workflow',
isDeleted: false
}
],
fileUpload: true
});
expect(result.success).toBe(true);
......@@ -34,6 +42,7 @@ describe('topAgentParamsSchema', () => {
systemPrompt: null,
selectedTools: null,
selectedDatasets: null,
selectedAgentSkills: null,
fileUpload: null
});
expect(result.success).toBe(true);
......@@ -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', () => {
const result = topAgentParamsSchema.safeParse({
selectedTools: 'not-an-array'
......@@ -66,6 +92,13 @@ describe('topAgentParamsSchema', () => {
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', () => {
const result = topAgentParamsSchema.safeParse({
fileUpload: 'not-a-boolean'
......
......@@ -8,6 +8,7 @@ export * from './types';
export * from './create';
export * from './update';
export * from './query';
export * from './list';
export * from './folder';
export * from './delete';
export * from './import';
......@@ -28,6 +28,7 @@ const buildSkillInfoFindCommand = (dir: string) => {
type GetAgentSkillInfosParams = {
workDirectory?: string;
skillDirectories?: string[];
deployedSkillVersions?: DeployedSkillVersion[];
sandbox: ISandbox;
};
......@@ -40,9 +41,13 @@ type GetAgentSkillInfosParams = {
export const getAgentSkillInfos = async ({
workDirectory,
skillDirectories,
deployedSkillVersions,
sandbox
}: GetAgentSkillInfosParams): Promise<DeployedSkillInfo[]> => {
const scanDirectories = skillDirectories?.length ? skillDirectories : [workDirectory || '.'];
const deployedVersionByTargetDir = new Map(
deployedSkillVersions?.map((version) => [normalizeSandboxDir(version.targetDir), version]) || []
);
// 并发 find 所有目录,过滤出错目录,避免级联报错
const findResults = await Promise.all(
......@@ -89,12 +94,22 @@ export const getAgentSkillInfos = async ({
return null;
}
const directory = file.path.replace(/\/skill\.md$/i, '');
const deployedVersion = findDeployedVersionByPath(directory, deployedVersionByTargetDir);
return {
id: file.path,
name: String(frontmatter.name),
description: frontmatter.description ? String(frontmatter.description) : '',
directory: file.path.replace(/\/skill\.md$/i, ''),
skillMdPath: file.path
directory,
skillMdPath: file.path,
...(deployedVersion
? {
appId: deployedVersion.skillId,
appName: deployedVersion.name,
appDescription: deployedVersion.description
}
: {})
};
})
.filter((info): info is DeployedSkillInfo => !!info);
......@@ -330,7 +345,11 @@ export const injectAgentSkillFilesToSandbox = async ({
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,
targetDir
}));
......@@ -338,6 +357,24 @@ export const injectAgentSkillFilesToSandbox = async ({
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 root = skillsRootPath === '/' ? '' : skillsRootPath.replace(/\/+$/, '');
const prefix = `${root}/`;
......
......@@ -6,9 +6,16 @@ export type DeployedSkillInfo = {
avatar?: string;
directory: string;
skillMdPath: string;
appId?: string;
appName?: string;
appDescription?: string;
};
export type DeployedSkillVersion = {
skillId?: string;
name?: string;
description?: string;
avatar?: string;
versionId: string;
targetDir: string;
};
......@@ -237,18 +237,27 @@ export function buildAgentSkillsPrompt(skillInfos: DeployedSkillInfo[] = []): st
return `## 技能
你可以使用可复用的技能。每个技能都提供针对特定任务的操作说明。当用户任务与某个技能的描述匹配时,先读取该技能的 SKILL.md 路径,然后再继续执行。不要仅凭技能描述推断完整工作流。
如果技能包含 app_name 或 app_description,它们表示平台 Skill 应用的名称和描述;name 和 description 表示该应用包内展开后的具体子 Skill。匹配任务时同时参考平台 Skill 应用信息和子 Skill 信息。如果用户、系统提示词或应用配置提到某个平台 Skill 应用名,应在该应用下选择最匹配的子 Skill。
当技能引用相对路径文件时,应以该技能的 SKILL.md 所在目录作为基准目录进行解析。
实际执行入口始终是子 Skill 的 path;平台 Skill 应用信息只用于帮助你把应用层语义对齐到具体子 Skill。
你可以通过 ${SANDBOX_READ_FILE_TOOL_NAME} 工具来读取完整的技能。
下面是可用的技能:
${skillInfos
.map(
(info) => `<skill>
<name>${escapeXml(info.name)}</name>
<description>${escapeXml(info.description)}</description>
<directory>${escapeXml(info.directory)}</directory>
<path>${escapeXml(info.skillMdPath)}</path>
</skill>`
.map((info) =>
[
'<skill>',
...(info.appId ? [`<app_id>${escapeXml(info.appId)}</app_id>`] : []),
...(info.appName ? [`<app_name>${escapeXml(info.appName)}</app_name>`] : []),
...(info.appDescription
? [`<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')}`;
}
......
......@@ -227,6 +227,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
selectedSkills,
editSkillId,
prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles
......
......@@ -160,6 +160,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
selectedSkills,
editSkillId,
prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles
......
import type { AgentInputFile } from '../../adapter/userContext';
import type { DeployedSkillInfo, DeployedSkillVersion } from '../../../../../../ai/skill/runtime';
import type { BuiltinSkillSource } from '@fastgpt/global/core/ai/skill/runtime/builtin';
import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type';
import {
getAgentSkillInfos,
getBuiltinSkillsRootPath,
......@@ -46,6 +47,7 @@ type EnsureAgentSandboxRuntimeParams = {
needSandboxRuntime: boolean;
sandboxEntrypoint?: string;
skillIds: string[];
selectedSkills?: SelectedAgentSkillItemType[];
editSkillId?: string;
prepareActions?: AgentSandboxPrepareAction[];
currentFiles: AgentInputFile[];
......@@ -72,6 +74,7 @@ export async function ensureAgentSandboxRuntime({
needSandboxRuntime,
sandboxEntrypoint,
skillIds,
selectedSkills,
editSkillId,
prepareActions = [],
currentFiles
......@@ -113,7 +116,7 @@ export async function ensureAgentSandboxRuntime({
: prepareSandbox(
context,
preparePackageMirrors(),
injectSelectedSkillFiles({ teamId, tmbId, skillIds }),
injectSelectedSkillFiles({ teamId, tmbId, skillIds, selectedSkills }),
injectCurrentInputFiles(currentFiles),
...prepareActions,
readCurrentWorkingDirectory(),
......@@ -183,22 +186,42 @@ const injectSelectedSkillFiles =
({
teamId,
tmbId,
skillIds
skillIds,
selectedSkills
}: {
teamId: string;
tmbId: string;
skillIds: string[];
selectedSkills?: SelectedAgentSkillItemType[];
}): AgentSandboxPrepareStep =>
async (context) => ({
...context,
deployedSkillVersions: await injectAgentSkillFilesToSandbox({
async (context) => {
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: context.sandbox,
teamId,
tmbId,
skillIds,
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) => {
if (context.deployedSkillVersions.length > 0) {
......@@ -220,7 +243,8 @@ const scanSelectedSkillInfos = (): AgentSandboxPrepareStep => async (context) =>
return skillDirectories.length > 0
? getAgentSkillInfos({
sandbox: context.sandbox,
skillDirectories
skillDirectories,
deployedSkillVersions: context.deployedSkillVersions
})
: Promise.resolve([]);
})()
......
......@@ -230,7 +230,7 @@ description: Zeta skill
)
};
const deployedVersions = await injectAgentSkillFilesToSandbox({
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any,
skillIds: [String(skill1._id), String(skill2._id)],
teamId,
......@@ -239,7 +239,7 @@ description: Zeta skill
});
const result = await getAgentSkillInfos({
sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir)
skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
});
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
......@@ -427,7 +427,7 @@ description: Missing skill
)
};
const deployedVersions = await injectAgentSkillFilesToSandbox({
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any,
skillIds: [String(existingSkill._id), String(missingSkill._id)],
teamId,
......@@ -436,7 +436,7 @@ description: Missing skill
});
const result = await getAgentSkillInfos({
sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir)
skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
});
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
......@@ -566,7 +566,7 @@ description: Latest current skill
])
};
const deployedVersions = await injectAgentSkillFilesToSandbox({
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
......@@ -575,7 +575,7 @@ description: Latest current skill
});
const result = await getAgentSkillInfos({
sandbox: sandbox as any,
skillDirectories: deployedVersions.map(({ targetDir }) => targetDir)
skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
});
expect(
......@@ -693,7 +693,7 @@ description: Latest current skill
readFiles: vi.fn()
};
const deployedVersions = await injectAgentSkillFilesToSandbox({
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any,
skillIds: [String(readableSkill._id), String(protectedSkill._id)],
teamId: owner.teamId,
......@@ -701,8 +701,12 @@ description: Latest current skill
workDirectory: '/workspace'
});
expect(deployedVersions).toEqual([
expect(deployedSkillVersions).toEqual([
{
skillId: String(readableSkill._id),
name: 'Readable',
description: '',
avatar: undefined,
versionId: String(readableVersionId),
targetDir: readableTargetDir
}
......@@ -787,7 +791,7 @@ description: Latest current skill
readFiles: vi.fn()
};
const deployedVersions = await injectAgentSkillFilesToSandbox({
const deployedSkillVersions = await injectAgentSkillFilesToSandbox({
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
......@@ -795,8 +799,12 @@ description: Latest current skill
workDirectory: '/workspace'
});
expect(deployedVersions).toEqual([
expect(deployedSkillVersions).toEqual([
{
skillId: String(skill._id),
name: 'CachedVersion',
description: '',
avatar: undefined,
versionId: String(currentVersionId),
targetDir: currentTargetDir
}
......@@ -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', () => {
expect(result).toContain('<directory>/workspace/Report &amp; Review</directory>');
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', () => {
......
......@@ -387,6 +387,7 @@ describe('dispatchRunAgent user context', () => {
needSandboxRuntime: true,
sandboxEntrypoint: 'pip install -r requirements.txt',
skillIds: [],
selectedSkills: [],
editSkillId: undefined,
prepareActions: undefined,
currentFiles: [
......@@ -493,6 +494,7 @@ describe('dispatchRunAgent user context', () => {
needSandboxRuntime: true,
sandboxEntrypoint: undefined,
skillIds: ['edit_skill_1'],
selectedSkills: [],
editSkillId: 'edit_skill_1',
prepareActions: undefined,
currentFiles: [
......
......@@ -479,6 +479,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true,
sandboxEntrypoint: undefined,
skillIds: [],
selectedSkills: [],
editSkillId: undefined,
prepareActions: undefined,
currentFiles: [
......@@ -544,6 +545,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true,
sandboxEntrypoint: undefined,
skillIds: ['skill_1'],
selectedSkills: [{ skillId: 'skill_1' }],
editSkillId: undefined,
prepareActions: undefined,
currentFiles: [
......@@ -621,6 +623,7 @@ describe('dispatchPiAgent user context', () => {
needSandboxRuntime: true,
sandboxEntrypoint: undefined,
skillIds: ['edit_skill_1'],
selectedSkills: [],
editSkillId: 'edit_skill_1',
prepareActions: undefined,
currentFiles: [
......
......@@ -182,7 +182,13 @@ describe('ensureAgentSandboxRuntime', () => {
);
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
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({
sandboxClient: sandboxClientMock,
......
......@@ -17,7 +17,12 @@ import type { SelectedToolItemType } from '@fastgpt/global/core/app/formEdit/typ
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;
};
......
Subproject commit 61871458aa384de0ecd4c0e50f23ff8b71b516a5
Subproject commit 23a105e56fcb7a4e7e0c161e23488290ef297a21
......@@ -30,6 +30,7 @@ export const HelperBotContext = createContext<HelperBotContextType>({
taskObject: '',
selectedTools: [],
selectedDatasets: [],
selectedAgentSkills: [],
fileUpload: false,
enableSandbox: false
},
......
......@@ -115,6 +115,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
systemPrompt: appForm.aiSettings.systemPrompt,
selectedTools: appForm.selectedTools.map((tool) => tool.id),
selectedDatasets: appForm.dataset.datasets.map((dataset) => dataset.datasetId),
selectedAgentSkills: appForm.selectedAgentSkills || [],
fileUpload: appForm.chatConfig.fileSelectConfig?.canSelectFile || false,
enableSandbox: appForm.aiSettings.useAgentSandbox || false,
modelConfig: {
......@@ -209,6 +210,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
const newForm: AppFormEditFormType = {
...prev,
selectedTools: [...newTools],
selectedAgentSkills: formData.selectedAgentSkills || [],
dataset:
formData.datasets && formData.datasets.length > 0
? {
......@@ -219,7 +221,8 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
aiSettings: {
...prev.aiSettings,
systemPrompt: formData.systemPrompt || prev.aiSettings.systemPrompt,
useAgentSandbox: enableSandboxEnabled
useAgentSandbox:
enableSandboxEnabled || (formData.selectedAgentSkills?.length || 0) > 0
},
chatConfig: {
...prev.chatConfig,
......
......@@ -66,8 +66,28 @@ const EditForm = ({
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({
selectedTools: appForm.selectedTools,
selectedAgentSkills,
onDeleteTool: (id) => {
setAppForm((state) => ({
...state,
......@@ -93,6 +113,7 @@ const EditForm = ({
}
});
},
onAddAgentSkill,
canUploadFile: !!(
appForm.chatConfig.fileSelectConfig?.canSelectFile ||
appForm.chatConfig.fileSelectConfig?.canSelectImg ||
......@@ -116,24 +137,6 @@ const EditForm = ({
} = useDisclosure();
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(
() => ({
...skillOption,
......
......@@ -83,9 +83,31 @@ export const useAgentSkillSelect = ({
const onAddAgentSkill = useCallback(
(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) => ({
...state,
selectedAgentSkills: [skill, ...(state.selectedAgentSkills || [])],
selectedAgentSkills: [
skill,
...(state.selectedAgentSkills || []).filter((item) => item.skillId !== skill.skillId)
],
aiSettings: {
...state.aiSettings,
useAgentSandbox: true
......@@ -97,8 +119,19 @@ export const useAgentSkillSelect = ({
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(
......
......@@ -5,7 +5,7 @@ import type {
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useTranslation } from 'next-i18next';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
checkNeedsUserConfiguration,
getToolConfigStatus,
......@@ -16,7 +16,10 @@ import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTemplateTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { SkillLabelItemType } from '@fastgpt/web/components/common/Textarea/PromptEditor/plugins/SkillLabelPlugin';
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 {
getAppToolTemplates,
getClientToolPreviewNode,
......@@ -33,8 +36,12 @@ import { SubAppIds, systemSubInfo } from '@fastgpt/global/core/workflow/node/age
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
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 { 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'));
type AgentSkillListItemType = ListSkillsResponse['list'][number];
const isSubApp = (flowNodeType: FlowNodeTypeEnum) => {
const subAppTypeMap: Record<string, boolean> = {
......@@ -56,16 +63,42 @@ const toSkillLabelItem = (
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 = ({
selectedTools,
selectedAgentSkills = [],
onUpdateOrAddTool,
onAddAgentSkill,
canUploadFile,
hasSelectedDataset,
useAgentSandbox
}: {
selectedTools: SelectedToolItemType[];
selectedAgentSkills?: SelectedAgentSkillItemType[];
onDeleteTool: (id: string) => void;
onUpdateOrAddTool: (tool: SelectedToolItemType) => void;
onAddAgentSkill?: (skill: SelectedAgentSkillItemType) => boolean;
canUploadFile: boolean;
hasSelectedDataset: boolean;
useAgentSandbox: boolean;
......@@ -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 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(
async (toolId: string): Promise<SkillClickResult | undefined> => {
// Check tool exists, if exists, not update/add tool
......@@ -317,6 +425,13 @@ export const useSkillManager = ({
onFolderLoad: (folderId: string) => onFolderLoadTeamApps(folderId, AppTypeList),
onClick: onAddAppOrTool
};
} else if (id === 'agentSkill') {
return {
description: t('app:space_to_expand_folder'),
list: agentSkills,
onFolderLoad: onFolderLoadAgentSkills,
onClick: onAddSkill
};
}
return undefined;
},
......@@ -339,9 +454,31 @@ export const useSkillManager = ({
icon: 'core/workflow/template/runApp',
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 ===== */
const selectedSkills = useMemoEnhance<SkillLabelItemType[]>(() => {
......@@ -413,12 +550,23 @@ export const useSkillManager = ({
});
}
return tools;
}, [selectedTools, canUploadFile, hasSelectedDataset, useAgentSandbox, i18n.language]);
return [...tools, ...selectedAgentSkills.map(toAgentSkillLabelItem)];
}, [
selectedTools,
selectedAgentSkills,
canUploadFile,
hasSelectedDataset,
useAgentSandbox,
i18n.language
]);
const [configTool, setConfigTool] = useState<SelectedToolItemType>();
const onClickSkill = useCallback(
(id: string) => {
if (selectedAgentSkills.some((skill) => skill.skillId === id)) {
return;
}
const tool = selectedTools.find((tool) => tool.pluginId === id);
if (!tool) return;
......@@ -431,7 +579,7 @@ export const useSkillManager = ({
setConfigTool(tool);
}
},
[selectedTools]
[selectedAgentSkills, selectedTools]
);
const onRemoveSkill = useCallback(() => {}, []);
......
......@@ -381,6 +381,7 @@ const DataCard = () => {
position={'absolute'}
bottom={2}
right={2}
zIndex={2}
overflow={'hidden'}
alignItems={'flex-end'}
visibility={'hidden'}
......
......@@ -11,6 +11,11 @@ import { Call } from '@test/utils/request';
import type { ListSkillsQuery, ListSkillsResponse } from '@fastgpt/global/core/ai/skill/api';
import { onCreateApp } from '@/pages/api/core/app/create';
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 {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
......@@ -62,6 +67,84 @@ describe('POST /api/core/ai/skill/list', () => {
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 () => {
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