Commit 4bdf7471 by YeYuheng Committed by GitHub

feat: integrate Skill assisted generation (#7166)

* feat: initialize blank skill workspace

* test: stabilize skill assisted generation checks

* refactor(sandbox): restructure agent sandbox runtime and preparation lifecycle

* refactor(sandbox): pass currentFiles explicitly; update lazyInit i18n

* perf(skill): prune dependency directories when scanning SKILL.md

* fix: preserve runtime skill authorization

* feat: support pro builtin skill debug preparation

* fix: pass team id to pro llm paragraph requests

---------

Co-authored-by: DigHuang <114602213+DigHuang@users.noreply.github.com>
parent 60c62b7a
...@@ -9,8 +9,6 @@ export enum SkillErrEnum { ...@@ -9,8 +9,6 @@ export enum SkillErrEnum {
invalidDescription = 'invalidDescription', invalidDescription = 'invalidDescription',
invalidCategory = 'invalidCategory', invalidCategory = 'invalidCategory',
invalidConfig = 'invalidConfig', invalidConfig = 'invalidConfig',
missingModel = 'missingModel',
requirementsTooLong = 'requirementsTooLong',
noStorage = 'noStorage', noStorage = 'noStorage',
noFieldsToUpdate = 'noFieldsToUpdate', noFieldsToUpdate = 'noFieldsToUpdate',
invalidArchiveFormat = 'invalidArchiveFormat', invalidArchiveFormat = 'invalidArchiveFormat',
...@@ -56,16 +54,6 @@ const skillErrList = [ ...@@ -56,16 +54,6 @@ const skillErrList = [
httpStatus: 400 httpStatus: 400
}, },
{ {
statusText: SkillErrEnum.missingModel,
message: i18nT('common:code_error.skill_error.missing_model'),
httpStatus: 400
},
{
statusText: SkillErrEnum.requirementsTooLong,
message: i18nT('common:code_error.skill_error.requirements_too_long'),
httpStatus: 400
},
{
statusText: SkillErrEnum.noStorage, statusText: SkillErrEnum.noStorage,
message: i18nT('common:code_error.skill_error.no_storage') message: i18nT('common:code_error.skill_error.no_storage')
}, },
......
...@@ -15,6 +15,12 @@ export const SandboxStatusEnum = { ...@@ -15,6 +15,12 @@ export const SandboxStatusEnum = {
} as const; } as const;
export type SandboxStatusType = (typeof SandboxStatusEnum)[keyof typeof SandboxStatusEnum]; export type SandboxStatusType = (typeof SandboxStatusEnum)[keyof typeof SandboxStatusEnum];
// ---- 沙盒实例类型 ----
export enum SandboxTypeEnum {
editDebug = 'edit-debug',
sessionRuntime = 'session-runtime'
}
// ---- 暂停阈值(分钟) ---- // ---- 暂停阈值(分钟) ----
export const SANDBOX_SUSPEND_MINUTES = 10; export const SANDBOX_SUSPEND_MINUTES = 10;
...@@ -35,6 +41,5 @@ export const SANDBOX_SYSTEM_PROMPT = `## 沙盒能力 ...@@ -35,6 +41,5 @@ export const SANDBOX_SYSTEM_PROMPT = `## 沙盒能力
- 使用 ${SANDBOX_WRITE_FILE_TOOL_NAME} 创建或覆盖文本文件 - 使用 ${SANDBOX_WRITE_FILE_TOOL_NAME} 创建或覆盖文本文件
- 使用 ${SANDBOX_EDIT_FILE_TOOL_NAME} 对已有文件做精确查找替换 - 使用 ${SANDBOX_EDIT_FILE_TOOL_NAME} 对已有文件做精确查找替换
- 使用 ${SANDBOX_SEARCH_TOOL_NAME} 搜索沙盒内的文件路径 - 使用 ${SANDBOX_SEARCH_TOOL_NAME} 搜索沙盒内的文件路径
- 生成的文件内容保存在当前工作区即可 - 默认将生成文件保存在当前 sandbox 工作目录;若本轮 system-reminder 指定了更具体的产物目录或禁止目录,必须优先遵守
- 若需要把沙盒中生成的文件提供给用户下载,必须先使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 获取临时访问链接 - 若需要将生成的文件链接,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 获取临时访问链接`;
- 最终回复中不得直接输出 sandbox:/、/workspace/、/home/devbox/workspace/、/home/user/ 等沙盒内部路径`;
import z from 'zod';
export const SandboxImageConfigSchema = z.object({
repository: z.string(),
tag: z.string().optional()
});
export type SandboxImageConfigType = z.infer<typeof SandboxImageConfigSchema>;
...@@ -28,8 +28,3 @@ export enum AgentSkillTypeEnum { ...@@ -28,8 +28,3 @@ export enum AgentSkillTypeEnum {
folder = 'folder', folder = 'folder',
skill = 'skill' skill = 'skill'
} }
// Sandbox types
export enum SandboxTypeEnum {
editDebug = 'edit-debug',
sessionRuntime = 'session-runtime'
}
export type BuiltinSkillSourceFile = {
relativePath: string;
content: Buffer;
};
export type BuiltinSkillSource = {
name: string;
files: BuiltinSkillSourceFile[];
};
...@@ -3,10 +3,12 @@ import { ...@@ -3,10 +3,12 @@ import {
AgentSkillSourceEnum, AgentSkillSourceEnum,
AgentSkillCategoryEnum, AgentSkillCategoryEnum,
AgentSkillTypeEnum, AgentSkillTypeEnum,
AgentSkillCreationStatusEnum, AgentSkillCreationStatusEnum
SandboxTypeEnum
} from './constants'; } from './constants';
import { SandboxStatusEnum } from '../sandbox/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '../sandbox/constants';
import { SandboxImageConfigSchema } from '../sandbox/type';
export { SandboxImageConfigSchema };
export type { SandboxImageConfigType } from '../sandbox/type';
const LooseObjectSchema = z.object({}).catchall(z.any()); const LooseObjectSchema = z.object({}).catchall(z.any());
const BufferSchema = z.custom<Buffer>( const BufferSchema = z.custom<Buffer>(
...@@ -121,12 +123,6 @@ export const ExtractedSkillPackageSchema = z.object({ ...@@ -121,12 +123,6 @@ export const ExtractedSkillPackageSchema = z.object({
}); });
export type ExtractedSkillPackage = z.infer<typeof ExtractedSkillPackageSchema>; export type ExtractedSkillPackage = z.infer<typeof ExtractedSkillPackageSchema>;
export const SandboxImageConfigSchema = z.object({
repository: z.string(),
tag: z.string().optional()
});
export type SandboxImageConfigType = z.infer<typeof SandboxImageConfigSchema>;
export const SandboxProviderStatusSchema = z.object({ export const SandboxProviderStatusSchema = z.object({
state: z.string(), state: z.string(),
message: z.string().optional(), message: z.string().optional(),
......
...@@ -63,7 +63,6 @@ export const CreateSkillBodySchema = z.object({ ...@@ -63,7 +63,6 @@ export const CreateSkillBodySchema = z.object({
parentId: NullableParentIdSchema, parentId: NullableParentIdSchema,
name: z.string().describe('技能名称'), name: z.string().describe('技能名称'),
description: z.string().optional().describe('技能描述'), description: z.string().optional().describe('技能描述'),
requirements: z.string().optional().describe('用于 AI 生成技能的需求描述'),
category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'), category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'),
avatar: z.string().optional().describe('技能头像') avatar: z.string().optional().describe('技能头像')
}); });
......
...@@ -84,7 +84,7 @@ export const SkillPath: OpenAPIPath = { ...@@ -84,7 +84,7 @@ export const SkillPath: OpenAPIPath = {
'/core/ai/skill/create': { '/core/ai/skill/create': {
post: { post: {
summary: '创建技能', summary: '创建技能',
description: '创建一个新的技能,可选使用 AI 根据 requirements 生成 SKILL.md', description: '创建一个新的技能,并初始化空白 skills 工作区',
tags: [DevApiTagsMap.aiSkill], tags: [DevApiTagsMap.aiSkill],
requestBody: { requestBody: {
content: { content: {
......
import { type FastGPTConfigFileType } from '@fastgpt/global/common/system/types'; import { type FastGPTConfigFileType } from '@fastgpt/global/common/system/types';
import { isIPv6 } from 'net'; import { isIPv6 } from 'net';
import { getLogger, LogCategories } from '../logger'; import { getLogger, LogCategories } from '../logger';
import { serviceEnv } from '../../env'; import { hasAgentSandboxConfig, serviceEnv } from '../../env';
const logger = getLogger(LogCategories.ERROR); const logger = getLogger(LogCategories.ERROR);
...@@ -20,6 +20,7 @@ export const initFastGPTConfig = (config?: FastGPTConfigFileType) => { ...@@ -20,6 +20,7 @@ export const initFastGPTConfig = (config?: FastGPTConfigFileType) => {
!!config.systemEnv.customPdfParse?.textinAppId || !!config.systemEnv.customPdfParse?.textinAppId ||
!!config.systemEnv.customPdfParse?.doc2xKey; !!config.systemEnv.customPdfParse?.doc2xKey;
config.feConfigs.customPdfParsePrice = config.systemEnv.customPdfParse?.price || 0; config.feConfigs.customPdfParsePrice = config.systemEnv.customPdfParse?.price || 0;
config.feConfigs.show_agent_sandbox = hasAgentSandboxConfig();
config.feConfigs.uploadFileMaxSize = serviceEnv.UPLOAD_FILE_MAX_SIZE; config.feConfigs.uploadFileMaxSize = serviceEnv.UPLOAD_FILE_MAX_SIZE;
config.feConfigs.uploadFileMaxAmount = serviceEnv.UPLOAD_FILE_MAX_AMOUNT; config.feConfigs.uploadFileMaxAmount = serviceEnv.UPLOAD_FILE_MAX_AMOUNT;
config.feConfigs.limit = { config.feConfigs.limit = {
......
...@@ -36,7 +36,6 @@ export const getDefaultChatTitleModel = () => global?.systemDefaultModel.chatTit ...@@ -36,7 +36,6 @@ export const getDefaultChatTitleModel = () => global?.systemDefaultModel.chatTit
export const getDefaultHelperBotModel = (): LLMModelItemType => export const getDefaultHelperBotModel = (): LLMModelItemType =>
global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel(); global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel();
export const getSkillCreationLLMModel = () => getDefaultLLMModel().model;
export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!; export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!;
export const getEmbeddingModel = (model?: string | EmbeddingModelItemType) => { export const getEmbeddingModel = (model?: string | EmbeddingModelItemType) => {
if (!model) return getDefaultEmbeddingModel(); if (!model) return getDefaultEmbeddingModel();
......
import type { SandboxStatusType } from '@fastgpt/global/core/ai/sandbox/constants'; import type { SandboxStatusType } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum, type SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import type { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { MongoSandboxInstance } from './schema'; import { MongoSandboxInstance } from './schema';
import type { SandboxInstanceSchemaType, SandboxProviderType } from '../type'; import type { SandboxInstanceSchemaType, SandboxProviderType } from '../type';
......
import { connectionMongo, getMongoModel } from '../../../../common/mongo'; import { connectionMongo, getMongoModel } from '../../../../common/mongo';
const { Schema } = connectionMongo; const { Schema } = connectionMongo;
import type { SandboxInstanceSchemaType } from '../type'; import type { SandboxInstanceSchemaType } from '../type';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxLimitSchema, SandboxProviderSchema } from '../type'; import { SandboxLimitSchema, SandboxProviderSchema } from '../type';
/** /**
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock';
import { createAgentSandboxInitializingError } from '../error';
import { buildRuntimeHash, shellQuote } from './utils';
import {
getRuntimeStateHash,
readSandboxRuntimeState,
setRuntimeStateHash,
writeSandboxRuntimeState
} from './state';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
export const SANDBOX_ENTRYPOINT_STATE_HASH_KEY = 'sandboxEntrypoint';
const MAX_LOG_OUTPUT_LENGTH = 4000;
export const MAX_ENTRYPOINT_OUTPUT_BYTES = 8 * 1024;
const SANDBOX_INIT_LEASE_TTL_MS = 3 * 60 * 1000;
const SANDBOX_INIT_LEASE_RENEW_INTERVAL_MS = SANDBOX_INIT_LEASE_TTL_MS / 6;
/**
* 保护同一个 sandbox 的运行态初始化流程。
*
* 同一个 sandbox 内并发初始化时,如果文件部署、entrypoint 和扫描交错,可能互相污染。
* 锁只存在于服务端 Redis,不写入 sandbox 文件系统。
*/
export const withAgentSandboxInitLease = async <T>({
sandboxId,
fn
}: {
sandboxId: string;
fn: () => Promise<T>;
}): Promise<T> => {
return withRedisLease({
key: `agent-sandbox:init:${sandboxId}`,
label: 'agent-sandbox-init',
ttlMs: SANDBOX_INIT_LEASE_TTL_MS,
renewIntervalMs: SANDBOX_INIT_LEASE_RENEW_INTERVAL_MS,
fn
}).catch((error) => {
if (isRedisLeaseError(error)) {
throw createAgentSandboxInitializingError();
}
throw error;
});
};
/**
* 执行 runtime sandbox entrypoint。
*
* 状态只写入 sandbox 用户 HOME,确保“是否执行过”跟具体 sandbox 实例绑定。
* 脚本失败、超时或状态读写失败都不会阻断主流程。
*/
export const runAgentSandboxEntrypoint = async ({
sandbox,
sandboxEntrypoint,
workDirectory
}: {
sandbox: ISandbox;
sandboxEntrypoint?: string;
workDirectory?: string;
}): Promise<void> => {
const script = sandboxEntrypoint?.trim();
if (!script) return;
const stateContext = await readSandboxRuntimeState({ sandbox });
const scriptHash = buildRuntimeHash(script);
if (getRuntimeStateHash(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY) === scriptHash) {
return;
}
const command = buildBashScriptCommand(script, workDirectory);
const result = await executeEntrypointCommand({
sandbox,
command,
label: 'sandbox'
});
if (!result) return;
setRuntimeStateHash(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY, scriptHash);
await writeSandboxRuntimeState(sandbox, stateContext);
};
export const executeEntrypointCommand = async ({
sandbox,
command,
label
}: {
sandbox: ISandbox;
command: string;
label: string;
}): Promise<boolean> => {
const timeoutSeconds = getEntrypointTimeoutSeconds();
const result = await sandbox
.execute(command, {
timeoutMs: timeoutSeconds * 1000,
maxOutputBytes: MAX_ENTRYPOINT_OUTPUT_BYTES
})
.catch((error) => {
logger.warn('[Agent Skills] Entrypoint execution threw', {
label,
error
});
return undefined;
});
if (!result) return false;
if (result.exitCode !== 0) {
logger.warn('[Agent Skills] Entrypoint execution failed', {
label,
exitCode: result.exitCode,
stdout: truncateOutput(result.stdout),
stderr: truncateOutput(result.stderr),
truncated: result.truncated
});
return false;
}
logger.info('[Agent Skills] Entrypoint execution succeeded', {
label,
stdout: truncateOutput(result.stdout),
stderr: truncateOutput(result.stderr),
truncated: result.truncated
});
return true;
};
export const buildLimitedOutputShellCommand = (scriptCommand: string): string =>
`/bin/bash -c ${shellQuote(
`${scriptCommand} > >(tail -c ${MAX_ENTRYPOINT_OUTPUT_BYTES}) 2> >(tail -c ${MAX_ENTRYPOINT_OUTPUT_BYTES} >&2)`
)}`;
const buildBashScriptCommand = (script: string, workDirectory?: string): string => {
const encoded = Buffer.from(script, 'utf-8').toString('base64');
const runScriptCommand = buildLimitedOutputShellCommand(
`printf %s ${shellQuote(encoded)} | base64 -d | /bin/bash`
);
return workDirectory
? `cd ${shellQuote(workDirectory)} && ${runScriptCommand}`
: runScriptCommand;
};
const getEntrypointTimeoutSeconds = (): number =>
Math.min(Math.max(serviceEnv.AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS, 1), 600);
const truncateOutput = (value: string): string =>
value.length > MAX_LOG_OUTPUT_LENGTH ? `${value.slice(0, MAX_LOG_OUTPUT_LENGTH)}...` : value;
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { SANDBOX_USER_FILES_PATH } from '@fastgpt/global/core/ai/sandbox/constants';
import { pickOutboundAxios } from '../../../../common/api/axios';
import { getSafeAgentInputFilename } from '../../../workflow/dispatch/ai/agent/adapter/fileName';
export type SandboxInputFile = {
name: string;
url: string;
};
export type SandboxCommandClient = {
exec: (command: string) => Promise<{
exitCode: number | null;
stdout: string;
}>;
};
/**
* 读取 sandbox 当前目录,仅作为 user reminder 的提示增强。
* 如果命令失败或没有输出,返回 undefined,让提示词侧完全跳过 pwd 区块。
*/
export const readSandboxPwd = async (sandboxClient: SandboxCommandClient) => {
try {
const result = await sandboxClient.exec('pwd');
if (result.exitCode === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
return;
}
};
/**
* 将本轮用户输入文件写入当前 sandbox。
*
* 路径规则和通用 toolcall 保持一致:用户文件直接写入 user_files/<文件名>。
* 这里直接消费 currentFiles,避免先构造中间 sandbox file 结构再二次遍历。
*/
export const injectInputFilesToSandbox = async (sandbox: ISandbox, files: SandboxInputFile[]) => {
const writeFileTasks: Promise<FileWriteEntry>[] = [];
const usedNames = new Map<string, number>();
for (const [index, file] of files.entries()) {
const filename = getSafeAgentInputFilename(file.name, index, usedNames);
const path = `${SANDBOX_USER_FILES_PATH}${filename}`;
writeFileTasks.push(
pickOutboundAxios(file.url)
.get<ArrayBuffer>(file.url, {
responseType: 'arraybuffer'
})
.then((response) => ({
path,
data: response.data
}))
);
}
if (writeFileTasks.length === 0) return;
await sandbox.writeFiles(await Promise.all(writeFileTasks));
};
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { getLogger, LogCategories } from '../../../../common/logger';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
/**
* 从实际 sandbox 环境解析 HOME。
*
* HOME 属于镜像/运行用户的运行时状态,不应由 provider profile 静态维护。
* 解析失败时返回 undefined,让调用方按场景决定是否降级。
*/
export const resolveSandboxHome = async (sandbox: ISandbox): Promise<string | undefined> => {
const homeResult = await sandbox
.execute('printf "%s" "$HOME"', {
timeoutMs: 5_000,
maxOutputBytes: 1024
})
.catch(() => undefined);
const homeFromEnv = homeResult?.exitCode === 0 ? homeResult.stdout.trim() : '';
if (homeFromEnv) return homeFromEnv;
const fallbackResult = await sandbox
.execute('sh -c "echo ~"', {
timeoutMs: 5_000,
maxOutputBytes: 1024
})
.catch((error) => {
logger.warn('[Sandbox] Failed to resolve HOME from shell fallback', { error });
return undefined;
});
const fallbackHome = fallbackResult?.exitCode === 0 ? fallbackResult.stdout.trim() : '';
if (fallbackHome) return fallbackHome;
logger.warn('[Sandbox] Failed to resolve HOME');
};
import { getSandboxClient, type SandboxClient } from '../service/runtime';
import { createAgentSandboxPermissionDeniedError } from '../error';
import { checkTeamSandboxPermission } from '../../../../support/permission/teamLimit';
import { getSandboxRuntimeProfile } from './profile';
export type AgentSandboxRuntimeContext = {
sandboxClient: SandboxClient;
workDirectory: string;
};
/**
* 准备 Agent 运行需要的 sandbox runtime。
*
* 该函数只负责 sandbox 维度:权限校验、实例获取和 runtime profile 解析。
* skill 注入、entrypoint 和扫描由 skill runtime 自己处理。
*/
export async function prepareAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
needSandboxRuntime
}: {
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
teamId: string;
needSandboxRuntime: boolean;
}): Promise<AgentSandboxRuntimeContext | undefined> {
if (!needSandboxRuntime) return;
try {
await checkTeamSandboxPermission(teamId);
} catch {
throw createAgentSandboxPermissionDeniedError();
}
const sandboxClient = await getSandboxClient(
sandboxId ? { sandboxId } : { appId, userId, chatId }
);
const runtimeProfile = getSandboxRuntimeProfile();
return {
sandboxClient,
workDirectory: runtimeProfile.workDirectory
};
}
import { serviceEnv } from '../../../../../env'; import { serviceEnv } from '../../../../../env';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import type { SandboxRuntimeProfile } from './types'; import type { SandboxRuntimeProfile } from './types';
import { getSandboxSkillsRootPath, mergeStringRecord, mergeUnknownRecord } from './utils'; import { getSandboxSkillsRootPath, mergeStringRecord, mergeUnknownRecord } from './utils';
import { parseImageSpec } from '@fastgpt-sdk/sandbox-adapter'; import { parseImageSpec } from '@fastgpt-sdk/sandbox-adapter';
......
import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type'; import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/sandbox/type';
import type { SandboxCreateSpec, SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter'; import type { SandboxCreateSpec, SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter';
import type { VolumeManagerResult } from '../../volume/service'; import type { VolumeManagerResult } from '../../volume/service';
......
/** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */ import { joinSandboxPath } from '../utils';
export const trimSandboxPathRight = (value: string) =>
value === '/' ? '' : value.replace(/\/+$/, '');
/** 用 sandbox 语义拼接路径,避免不同 provider 工作目录末尾斜杠导致双斜杠。 */
export const joinSandboxPath = (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`;
/** FastGPT 约定所有 skill 包都写入运行态工作目录下的 skills 子目录。 */ /** FastGPT 约定所有 skill 包都写入运行态工作目录下的 skills 子目录。 */
export const getSandboxSkillsRootPath = (workDirectory: string) => export const getSandboxSkillsRootPath = (workDirectory: string) =>
joinSandboxPath(workDirectory, 'skills'); joinSandboxPath(workDirectory, 'skills');
/** 内置 Skill 注入到 sandbox 用户主目录,不属于用户可编辑 workspace。 */
export const getSandboxBuiltinSkillsRootPath = (homeDirectory: string) =>
joinSandboxPath(joinSandboxPath(homeDirectory, '.fastgpt'), 'skills');
/** /**
* 合并环境变量时让业务场景入参覆盖已有 createConfig。 * 合并环境变量时让业务场景入参覆盖已有 createConfig。
* *
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { getLogger, LogCategories } from '../../../../common/logger';
import { resolveSandboxHome } from './home';
import { joinSandboxPath, shellQuote } from './utils';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
const RUNTIME_STATE_DIR_RELATIVE_PATH = '.fastgpt/runtime';
const RUNTIME_STATE_FILE_NAME = 'state.json';
export type SandboxRuntimeState = {
hashes?: Record<string, string>;
lists?: Record<string, string[]>;
};
export type SandboxRuntimeStateContext = {
statePath?: string;
state: SandboxRuntimeState;
};
type RuntimeStateLocation = Pick<SandboxRuntimeStateContext, 'statePath'>;
/**
* 读取 sandbox HOME 下的 FastGPT runtime 状态文件。
*
* 该文件只记录“某段 runtime 逻辑是否已经针对当前 sandbox 成功执行过”的轻量状态,
* 例如 entrypoint hash、内置文件 etag、skill version marker。读写失败时返回空状态,
* 让上层逻辑按未执行处理,避免阻断 agent 主流程。
*/
export const readSandboxRuntimeState = async ({
sandbox,
homeDirectory
}: {
sandbox: ISandbox;
homeDirectory?: string;
}): Promise<SandboxRuntimeStateContext> => {
const location = await resolveRuntimeStateLocation({ sandbox, homeDirectory });
const { statePath } = location;
if (!statePath) {
return {
state: {}
};
}
const [stateFile] = await sandbox.readFiles([statePath]).catch((error) => {
logger.warn('[Sandbox Runtime] Failed to read runtime state file', {
statePath,
error
});
return [];
});
if (!stateFile || stateFile.error) {
return {
statePath,
state: {}
};
}
try {
const content = Buffer.from(stateFile.content).toString('utf-8');
if (!content.trim()) {
return {
statePath,
state: {}
};
}
return {
statePath,
state: normalizeRuntimeState(JSON.parse(content))
};
} catch (error) {
logger.warn('[Sandbox Runtime] Failed to parse runtime state file', {
statePath,
error
});
return {
statePath,
state: {}
};
}
};
/**
* 写回 sandbox runtime 状态文件。
*
* 调用方应只在对应 runtime 动作成功后更新状态;写入失败只记录日志,
* 下次运行会按未执行重新尝试。
*/
export const writeSandboxRuntimeState = async (
sandbox: ISandbox,
{ statePath, state }: SandboxRuntimeStateContext
): Promise<void> => {
if (!statePath) return;
const normalizedState = normalizeRuntimeState(state);
const writeResult = await sandbox
.writeFiles([
{
path: statePath,
data: JSON.stringify(normalizedState, null, 2)
}
])
.catch((error) => {
logger.warn('[Sandbox Runtime] Failed to write runtime state file', {
statePath,
error
});
return [];
});
const failed = writeResult.find((item) => item.error);
if (failed) {
logger.warn('[Sandbox Runtime] Failed to write runtime state file', {
statePath,
error: failed.error
});
}
};
export const getRuntimeStateHash = (state: SandboxRuntimeState, key: string): string | undefined =>
state.hashes?.[key];
export const setRuntimeStateHash = (
state: SandboxRuntimeState,
key: string,
hash: string
): void => {
state.hashes = {
...(state.hashes ?? {}),
[key]: hash
};
};
export const getRuntimeStateList = (state: SandboxRuntimeState, key: string): string[] =>
state.lists?.[key] ?? [];
export const setRuntimeStateList = (
state: SandboxRuntimeState,
key: string,
values: string[]
): void => {
const uniqueValues = Array.from(new Set(values));
state.lists = {
...(state.lists ?? {}),
...(uniqueValues.length > 0 ? { [key]: uniqueValues } : {})
};
if (uniqueValues.length === 0) {
delete state.lists[key];
}
if (Object.keys(state.lists).length === 0) {
delete state.lists;
}
};
const resolveRuntimeStateLocation = async ({
sandbox,
homeDirectory
}: {
sandbox: ISandbox;
homeDirectory?: string;
}): Promise<RuntimeStateLocation> => {
const homeDir = homeDirectory || (await resolveSandboxHome(sandbox));
if (!homeDir) {
return {};
}
const stateDir = joinSandboxPath(homeDir, RUNTIME_STATE_DIR_RELATIVE_PATH);
const statePath = joinSandboxPath(stateDir, RUNTIME_STATE_FILE_NAME);
const prepareResult = await sandbox
.execute(`mkdir -p ${shellQuote(stateDir)}`, {
timeoutMs: 5_000,
maxOutputBytes: 1024
})
.catch((error) => {
logger.warn('[Sandbox Runtime] Failed to prepare runtime state directory', {
stateDir,
error
});
return undefined;
});
if (!prepareResult || prepareResult.exitCode !== 0) {
return {};
}
return {
statePath
};
};
const normalizeRuntimeState = (value: unknown): SandboxRuntimeState => {
if (!value || typeof value !== 'object') return {};
const raw = value as SandboxRuntimeState;
const hashes = normalizeStringRecord(raw.hashes);
const lists = normalizeStringListRecord(raw.lists);
return {
...(hashes ? { hashes } : {}),
...(lists ? { lists } : {})
};
};
const normalizeStringRecord = (value: unknown): Record<string, string> | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
const entries = Object.entries(value).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string'
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
const normalizeStringListRecord = (value: unknown): Record<string, string[]> | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
const entries = Object.entries(value).flatMap(([key, list]) => {
if (!Array.isArray(list)) return [];
const values = Array.from(new Set(list.filter((item) => typeof item === 'string')));
return values.length > 0 ? [[key, values] as const] : [];
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
import { createHash } from 'crypto';
type HashContent = string | Buffer | Uint8Array;
/** Shell 单参数安全转义,用于拼接传给 sandbox 的命令。 */
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
/** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */
export const trimSandboxPathRight = (value: string) =>
value === '/' ? '' : value.replace(/\/+$/, '');
/** 用 sandbox 语义拼接路径,避免不同 provider 工作目录末尾斜杠导致双斜杠。 */
export const joinSandboxPath = (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`;
/** 构建 runtime 状态和 manifest 统一使用的内容 hash。 */
export const buildRuntimeHash = (content: HashContent): string =>
`sha256:${createHash('sha256').update(content).digest('hex')}`;
...@@ -9,7 +9,7 @@ import { serviceEnv } from '../../../../env'; ...@@ -9,7 +9,7 @@ import { serviceEnv } from '../../../../env';
import { getSandboxAdapterConfig } from '../provider/config'; import { getSandboxAdapterConfig } from '../provider/config';
import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle'; import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle';
import { getSandboxRuntimeProfile } from '../runtime/profile'; import { getSandboxRuntimeProfile } from '../runtime/profile';
import { joinSandboxPath } from '../runtime/profile/utils'; import { joinSandboxPath, shellQuote } from '../runtime/utils';
import { import {
deleteSessionVolume, deleteSessionVolume,
getSessionVolumeConfig, getSessionVolumeConfig,
...@@ -77,8 +77,6 @@ export interface SandboxArchiveOptions { ...@@ -77,8 +77,6 @@ export interface SandboxArchiveOptions {
onProgress?: (progress: SandboxArchiveProgress) => void | Promise<void>; onProgress?: (progress: SandboxArchiveProgress) => void | Promise<void>;
} }
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
const runSandboxCommand = async ( const runSandboxCommand = async (
sandbox: ISandbox, sandbox: ISandbox,
command: string, command: string,
......
import z from 'zod'; import z from 'zod';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
// ---- 沙盒实例 DB 类型 ---- // ---- 沙盒实例 DB 类型 ----
export const SandboxProviderSchema = z.enum(['sealosdevbox', 'opensandbox', 'e2b']); export const SandboxProviderSchema = z.enum(['sealosdevbox', 'opensandbox', 'e2b']);
......
export { handleSkillDebugChat } from './handler';
export { buildDebugRuntimeNodes } from './runtime';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import type { RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
const START_NODE_ID = 'skill-debug-start';
const AGENT_NODE_ID = 'skill-debug-agent';
/**
* 构造 Skill 调试对话使用的最小 workflow。
*
* 运行态只包含 workflowStart -> agent 两个节点;agent 通过 editSkillId 进入当前
* Skill 的编辑沙盒,避免调试链路依赖真实应用配置。
*/
export function buildDebugRuntimeNodes(
skillId: string,
model: string,
systemPrompt: string
): {
runtimeNodes: RuntimeNodeItemType[];
runtimeEdges: RuntimeEdgeItemType[];
} {
const runtimeNodes: RuntimeNodeItemType[] = [
{
nodeId: START_NODE_ID,
name: 'Workflow Start',
avatar: '',
intro: '',
flowNodeType: FlowNodeTypeEnum.workflowStart,
showStatus: false,
isEntry: true,
inputs: [
{
key: NodeInputKeyEnum.userChatInput,
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
valueType: WorkflowIOValueTypeEnum.string,
label: 'User Question',
toolDescription: 'user question',
required: true,
value: ''
}
],
outputs: [
{
id: NodeOutputKeyEnum.userChatInput,
key: NodeOutputKeyEnum.userChatInput,
label: 'User Question',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.string
}
]
},
{
nodeId: AGENT_NODE_ID,
name: 'Agent',
avatar: '',
intro: '',
flowNodeType: FlowNodeTypeEnum.agent,
showStatus: true,
isEntry: false,
inputs: [
{
key: NodeInputKeyEnum.userChatInput,
renderTypeList: [FlowNodeInputTypeEnum.reference],
valueType: WorkflowIOValueTypeEnum.string,
label: 'User Question',
required: true,
value: [START_NODE_ID, NodeOutputKeyEnum.userChatInput]
},
{
key: NodeInputKeyEnum.history,
renderTypeList: [FlowNodeInputTypeEnum.numberInput],
valueType: WorkflowIOValueTypeEnum.chatHistory,
label: 'Chat History',
required: true,
min: 0,
max: 50,
value: 20
},
{
key: NodeInputKeyEnum.aiModel,
renderTypeList: [FlowNodeInputTypeEnum.selectLLMModel],
label: 'AI Model',
required: true,
valueType: WorkflowIOValueTypeEnum.string,
value: model
},
{
key: NodeInputKeyEnum.aiSystemPrompt,
renderTypeList: [FlowNodeInputTypeEnum.textarea],
valueType: WorkflowIOValueTypeEnum.string,
label: 'System Prompt',
value: systemPrompt
},
{
key: NodeInputKeyEnum.editSkillId,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.string,
label: 'Edit Skill ID',
value: skillId
}
],
outputs: [
{
id: NodeOutputKeyEnum.answerText,
key: NodeOutputKeyEnum.answerText,
label: 'Answer',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.string
}
]
}
];
const runtimeEdges: RuntimeEdgeItemType[] = [
{
source: START_NODE_ID,
sourceHandle: getHandleId(START_NODE_ID, 'source', 'right'),
target: AGENT_NODE_ID,
targetHandle: getHandleId(AGENT_NODE_ID, 'target', 'left'),
status: 'waiting'
}
];
return { runtimeNodes, runtimeEdges };
}
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSseErrorResponse } from '../../../../common/response';
import { clearCookie } from '../../../../support/permission/auth/common';
import { STREAM_RESUME_REQUEST_HEADER } from '@fastgpt/global/core/chat/constants';
import { getStreamResumeMirror } from '../../../chat/resume';
import { getWorkflowResponseWrite } from '../../../workflow/dispatch/utils';
type CreateSkillDebugStreamResponseContextParams = {
req: NextApiRequest;
res: NextApiResponse;
stream: boolean;
detail: boolean;
teamId: string;
appId: string;
chatId: string;
responseId?: string;
showNodeStatus?: boolean;
};
/**
* 创建 Skill 调试对话的 workflow 响应上下文。
*
* 该 helper 放在 service 层,供开源 API 和 Pro API 复用;它只负责 SSE writer 与
* stream resume mirror,不处理 Skill 鉴权、chat round 生命周期和 workflow 调度。
*/
export const createSkillDebugStreamResponseContext = async ({
req,
res,
stream,
detail,
teamId,
appId,
chatId,
responseId,
showNodeStatus = true
}: CreateSkillDebugStreamResponseContextParams) => {
const mirror = stream
? await getStreamResumeMirror({
resumeRequestHeaderValue: req.headers?.[STREAM_RESUME_REQUEST_HEADER],
teamId,
appId,
chatId
})
: undefined;
const responseWrite = getWorkflowResponseWrite({
res,
detail,
streamResponse: stream,
id: responseId,
showNodeStatus,
streamResumeMirror: mirror
});
return {
responseWrite,
async flushResume() {
await mirror?.flush();
await mirror?.shrinkTTLAfterComplete();
},
writeStreamError(error: unknown) {
if (!stream) return;
const { event, data, shouldClearCookie } = getSseErrorResponse(error);
if (shouldClearCookie) {
clearCookie(res);
}
responseWrite({
event,
data
});
}
};
};
export type SkillDebugStreamResponseContext = Awaited<
ReturnType<typeof createSkillDebugStreamResponseContext>
>;
...@@ -13,8 +13,7 @@ import { ...@@ -13,8 +13,7 @@ import {
updateSandboxInstanceRecordBySandboxId updateSandboxInstanceRecordBySandboxId
} from '../../sandbox/instance/repository'; } from '../../sandbox/instance/repository';
import { MongoAgentSkills } from '../model/schema'; import { MongoAgentSkills } from '../model/schema';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { UserError } from '@fastgpt/global/common/error/utils'; import { UserError } from '@fastgpt/global/common/error/utils';
import type { SaveDeploySkillResponse } from '@fastgpt/global/core/ai/skill/api'; import type { SaveDeploySkillResponse } from '@fastgpt/global/core/ai/skill/api';
......
...@@ -2,8 +2,14 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; ...@@ -2,8 +2,14 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema'; import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema'; import { MongoAgentSkillsVersion } from '../version/schema';
import { shellQuote, joinSandboxPath, parseGitignoreRules } from '../utils'; import { parseGitignoreRules } from '../utils';
import { downloadSkillPackage, DEFAULT_GITIGNORE_CONTENT, validateZipStructure } from '../package'; import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import {
downloadSkillPackage,
DEFAULT_GITIGNORE_CONTENT,
validateDeployableSkillWorkspacePackage,
validateZipStructure
} from '../package';
import { EDIT_DEBUG_SANDBOX_CHAT_ID, getEditDebugSandboxId } from './config'; import { EDIT_DEBUG_SANDBOX_CHAT_ID, getEditDebugSandboxId } from './config';
import { import {
getSandboxProviderConfig, getSandboxProviderConfig,
...@@ -12,7 +18,7 @@ import { ...@@ -12,7 +18,7 @@ import {
} from '../../sandbox/provider/config'; } from '../../sandbox/provider/config';
import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile'; import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile';
import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type'; import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { import {
connectReadySandboxByInstance, connectReadySandboxByInstance,
connectToSandbox, connectToSandbox,
...@@ -689,8 +695,9 @@ export async function createEditDebugSandbox( ...@@ -689,8 +695,9 @@ export async function createEditDebugSandbox(
export async function packageSkillInSandbox(params: { export async function packageSkillInSandbox(params: {
sandboxId: string; sandboxId: string;
workDirectory?: string; workDirectory?: string;
validationMode?: 'basicZip' | 'deployableWorkspace';
}): Promise<Buffer> { }): Promise<Buffer> {
const { sandboxId, workDirectory } = params; const { sandboxId, workDirectory, validationMode = 'deployableWorkspace' } = params;
const maxBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024; const maxBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
const providerConfig = getSandboxProviderConfig(); const providerConfig = getSandboxProviderConfig();
...@@ -780,7 +787,12 @@ export async function packageSkillInSandbox(params: { ...@@ -780,7 +787,12 @@ export async function packageSkillInSandbox(params: {
); );
} }
const validation = await validateZipStructure(zipBuffer, { const validation =
validationMode === 'deployableWorkspace'
? await validateDeployableSkillWorkspacePackage(zipBuffer, {
maxUncompressedBytes: maxBytes
})
: await validateZipStructure(zipBuffer, {
maxUncompressedBytes: maxBytes maxUncompressedBytes: maxBytes
}); });
if (!validation.valid) { if (!validation.valid) {
......
...@@ -22,7 +22,6 @@ export async function createSkill(data: CreateSkillData, session?: ClientSession ...@@ -22,7 +22,6 @@ export async function createSkill(data: CreateSkillData, session?: ClientSession
type: AgentSkillTypeEnum.skill, type: AgentSkillTypeEnum.skill,
source: AgentSkillSourceEnum.personal, source: AgentSkillSourceEnum.personal,
creationStatus: createData.creationStatus ?? AgentSkillCreationStatusEnum.ready, creationStatus: createData.creationStatus ?? AgentSkillCreationStatusEnum.ready,
creationPayload: createData.creationPayload,
updateTime: new Date() updateTime: new Date()
}); });
await skill.save({ session }); await skill.save({ session });
......
...@@ -9,10 +9,8 @@ import { Types } from '../../../../../common/mongo'; ...@@ -9,10 +9,8 @@ import { Types } from '../../../../../common/mongo';
import { mongoSessionRun } from '../../../../../common/mongo/sessionRun'; import { mongoSessionRun } from '../../../../../common/mongo/sessionRun';
import { MongoAgentSkills } from '../../model/schema'; import { MongoAgentSkills } from '../../model/schema';
import { updateCurrentVersion, updateSkillCreationFailed } from '../update'; import { updateCurrentVersion, updateSkillCreationFailed } from '../update';
import { buildSkillMd, extractSkillNameFromSkillMd } from '../../utils';
import { generateSkillMd } from './skillMdGenerator';
import { import {
createSkillPackage, createBlankSkillWorkspacePackage,
deleteSkillPackage, deleteSkillPackage,
removeSkillPackageTTL, removeSkillPackageTTL,
type SkillStorageInfo, type SkillStorageInfo,
...@@ -22,8 +20,6 @@ import { createVersion } from '../../version'; ...@@ -22,8 +20,6 @@ import { createVersion } from '../../version';
import { getLogger, LogCategories } from '../../../../../common/logger'; import { getLogger, LogCategories } from '../../../../../common/logger';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants'; import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import { createSkillGenerationUsage } from './usage';
import { getSkillCreationLLMModel } from '../../../model';
const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION);
...@@ -31,9 +27,6 @@ export type AgentSkillCreateJobData = { ...@@ -31,9 +27,6 @@ export type AgentSkillCreateJobData = {
skillId: string; skillId: string;
teamId: string; teamId: string;
tmbId: string; tmbId: string;
name: string;
description: string;
requirements?: string;
}; };
const agentSkillCreateQueue = getQueue<AgentSkillCreateJobData>(QueueNames.agentSkillCreate, { const agentSkillCreateQueue = getQueue<AgentSkillCreateJobData>(QueueNames.agentSkillCreate, {
...@@ -88,10 +81,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> { ...@@ -88,10 +81,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
{ {
_id: 1, _id: 1,
teamId: 1, teamId: 1,
tmbId: 1, tmbId: 1
name: 1,
description: 1,
creationPayload: 1
} }
).lean(); ).lean();
...@@ -108,10 +98,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> { ...@@ -108,10 +98,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
return addAgentSkillCreateJob({ return addAgentSkillCreateJob({
skillId: skill._id.toString(), skillId: skill._id.toString(),
teamId: skill.teamId.toString(), teamId: skill.teamId.toString(),
tmbId: skill.tmbId.toString(), tmbId: skill.tmbId.toString()
name: skill.name,
description: skill.description,
requirements: skill.creationPayload?.requirements
}); });
}) })
); );
...@@ -126,14 +113,14 @@ async function resumePendingSkillCreationJobs(): Promise<void> { ...@@ -126,14 +113,14 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
} }
/** /**
* 完成一个 pending skill 的初始包生成、上传和 v0 版本绑定。 * 完成一个 pending skill 的空白初始工作区上传和 v0 版本绑定。
* *
* API 先创建可见 skill 行,保证详情页拥有稳定 skillId;worker 再执行较慢的 * API 先创建可见 skill 行,保证详情页拥有稳定 skillId;worker 再执行较慢的
* SKILL.md 生成、zip 打包、对象存储上传和版本初始化。失败会写回 skill 行, * workspace zip 打包、对象存储上传和版本初始化。真正的 `skills/<name>/SKILL.md`
* 这样刷新页面或后续访问都能看到确定的终态,而不是依赖队列状态。 * 由用户或内置辅助生成 Skill 在编辑沙盒里生成,避免新建时制造一个无需求来源的同名 Skill。
*/ */
export async function completePendingSkillCreation(data: AgentSkillCreateJobData): Promise<void> { export async function completePendingSkillCreation(data: AgentSkillCreateJobData): Promise<void> {
const { skillId, teamId, tmbId, name, description } = data; const { skillId, teamId, tmbId } = data;
let uploadedStorageInfo: SkillStorageInfo | undefined; let uploadedStorageInfo: SkillStorageInfo | undefined;
const skill = await MongoAgentSkills.findOne({ const skill = await MongoAgentSkills.findOne({
...@@ -154,38 +141,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData ...@@ -154,38 +141,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData
} }
try { try {
const requirements = data.requirements ?? skill.creationPayload?.requirements; const zipBuffer = await createBlankSkillWorkspacePackage();
let skillMd: string;
if (requirements) {
// 有用户需求时走模型辅助生成;否则只创建一个最小 SKILL.md 模板。
const model = getSkillCreationLLMModel();
const [generatedSkillMd, usage] = await generateSkillMd({
teamId,
name,
description,
requirements: requirements.trim(),
model
});
skillMd = generatedSkillMd;
// 只有模型辅助生成才产生 token 用量;普通模板创建不计入模型消耗。
await createSkillGenerationUsage({
teamId,
tmbId,
model,
usage
});
} else {
skillMd = buildSkillMd({
name,
description
});
}
const packageRootName = extractSkillNameFromSkillMd(skillMd);
const zipBuffer = await createSkillPackage({ name: `skills/${packageRootName}`, skillMd });
const versionId = new Types.ObjectId().toString(); const versionId = new Types.ObjectId().toString();
const storageInfo = await uploadSkillPackage({ const storageInfo = await uploadSkillPackage({
...@@ -207,7 +163,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData ...@@ -207,7 +163,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData
versionId, versionId,
skillId, skillId,
tmbId, tmbId,
versionName: 'Initial creation', versionName: 'Initial blank workspace',
storageKey: storageInfo.key storageKey: storageInfo.key
}, },
session session
......
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants';
import { createUsage } from '../../../../../support/wallet/usage/controller';
import { formatModelChars2Points } from '../../../../../support/wallet/usage/utils';
import type { SkillMdGenerationUsage } from './skillMdGenerator';
/**
* 记录 Skill 创建阶段 AI 辅助生成 SKILL.md 的用量。
*
* 创建队列没有挂在工作流 usage 汇总里,所以这里直接创建一条独立 usage。
* 如果未来该调用支持用户自带 key,则保留 token 记录但积分为 0,保持和其它 LLM
* 计费路径一致。
*/
export async function createSkillGenerationUsage({
teamId,
tmbId,
model,
usage
}: {
teamId: string;
tmbId: string;
model: string;
usage: SkillMdGenerationUsage;
}) {
const { totalPoints, modelName } = formatModelChars2Points({
model,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens
});
const points = usage.usedUserOpenAIKey ? 0 : totalPoints;
await createUsage({
teamId,
tmbId,
appName: i18nT('common:support.wallet.usage.Assist Generate Skill'),
totalPoints: points,
source: UsageSourceEnum.assist_generate_skill,
list: [
{
moduleName: i18nT('common:support.wallet.usage.Assist Generate Skill'),
amount: points,
model: modelName,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens
}
]
});
}
...@@ -10,9 +10,6 @@ export type CreateSkillData = { ...@@ -10,9 +10,6 @@ export type CreateSkillData = {
teamId: string; teamId: string;
tmbId: string; tmbId: string;
creationStatus?: AgentSkillCreationStatusEnum; creationStatus?: AgentSkillCreationStatusEnum;
creationPayload?: {
requirements?: string;
};
}; };
// UpdateSkillData excludes markdown to ensure consistency with version management. // UpdateSkillData excludes markdown to ensure consistency with version management.
......
...@@ -59,8 +59,8 @@ export async function updateCurrentVersion( ...@@ -59,8 +59,8 @@ export async function updateCurrentVersion(
/** /**
* 将异步创建的 skill 标记为失败,并保留可见行用于删除和问题诊断。 * 将异步创建的 skill 标记为失败,并保留可见行用于删除和问题诊断。
* *
* creationPayload 可能包含用户输入的生成要求。记录终态失败后,保留短错误文本 * 记录终态失败后,保留短错误文本已足够支撑 UI 展示。
* 已足够支撑 UI 展示,同时避免继续保存不必要的生成输入。 * `$unset.creationPayload` 仅用于清理历史版本遗留的创建期临时字段。
*/ */
export async function updateSkillCreationFailed({ export async function updateSkillCreationFailed({
skillId, skillId,
......
...@@ -13,17 +13,7 @@ import { ...@@ -13,17 +13,7 @@ import {
} from '@fastgpt/global/support/user/team/constant'; } from '@fastgpt/global/support/user/team/constant';
import type { AgentSkillSchemaType } from '@fastgpt/global/core/ai/skill/type'; import type { AgentSkillSchemaType } from '@fastgpt/global/core/ai/skill/type';
/** export type MongoAgentSkillSchemaType = AgentSkillSchemaType;
* Agent Skill 主表模型类型。
*
* creationPayload 只用于 AI 辅助创建阶段保存临时生成上下文,不属于发布后的
* SKILL.md 运行时元数据,因此只在 service 层模型里补充。
*/
export type MongoAgentSkillSchemaType = AgentSkillSchemaType & {
creationPayload?: {
requirements?: string;
};
};
const { Schema } = connectionMongo; const { Schema } = connectionMongo;
...@@ -104,9 +94,6 @@ const AgentSkillsSchema = new Schema({ ...@@ -104,9 +94,6 @@ const AgentSkillsSchema = new Schema({
}, },
creationError: { creationError: {
type: String type: String
},
creationPayload: {
requirements: String
} }
}); });
......
...@@ -46,6 +46,19 @@ export type ZipValidationResult = { ...@@ -46,6 +46,19 @@ export type ZipValidationResult = {
totalUncompressedBytes?: number; totalUncompressedBytes?: number;
}; };
export type DeployableSkillWorkspaceValidationResult = {
valid: boolean;
files: string[];
error?: string;
};
type ZipSafetyValidationResult = {
valid: boolean;
files: string[];
error?: string;
totalUncompressedBytes?: number;
};
export type ExtractSkillPackageResult = { export type ExtractSkillPackageResult = {
success: boolean; success: boolean;
skillMd?: string; skillMd?: string;
...@@ -100,6 +113,21 @@ export async function createSkillPackage(params: CreateSkillPackageParams): Prom ...@@ -100,6 +113,21 @@ export async function createSkillPackage(params: CreateSkillPackageParams): Prom
} }
/** /**
* 创建新建 Skill 的空白工作区包。
*
* 初始版本只建立工作区外壳,不生成任何可执行 Skill。空目录在 ZIP 中需要显式写入,
* 否则解压后 `skills/` 不会存在。
*/
export async function createBlankSkillWorkspacePackage(): Promise<Buffer> {
const zip = new JSZip();
zip.file('.gitignore', DEFAULT_GITIGNORE_CONTENT);
zip.folder('skills');
return generateZipBuffer(zip);
}
/**
* 向 ZIP 中写入单个文件,并统一处理 Buffer、Uint8Array 和字符串内容。 * 向 ZIP 中写入单个文件,并统一处理 Buffer、Uint8Array 和字符串内容。
*/ */
function addFileToZip(zip: JSZip, path: string, content: Buffer | string | Uint8Array): void { function addFileToZip(zip: JSZip, path: string, content: Buffer | string | Uint8Array): void {
...@@ -139,98 +167,221 @@ export async function validateZipStructure( ...@@ -139,98 +167,221 @@ export async function validateZipStructure(
): Promise<ZipValidationResult> { ): Promise<ZipValidationResult> {
try { try {
const zip = await JSZip.loadAsync(zipBuffer); const zip = await JSZip.loadAsync(zipBuffer);
const files = Object.keys(zip.files); const safety = validateZipSafety(zip, options);
const files = safety.files;
if (files.length === 0) { if (!safety.valid) {
return { return {
valid: false, valid: false,
hasSkillMd: false, hasSkillMd: false,
files, files,
error: 'ZIP archive is empty' totalUncompressedBytes: safety.totalUncompressedBytes,
error: safety.error
}; };
} }
let totalUncompressedBytes = 0; // 兼容根目录直接放 SKILL.md 的历史包。
for (const file of Object.values(zip.files)) { let skillMdPath = files.find((f) => f.toUpperCase() === 'SKILL.MD');
const unsafePath = file.unsafeOriginalName ?? file.name;
if (!isSafeZipEntryPath(unsafePath)) { // 标准包会把 SKILL.md 放在子目录内。
if (!skillMdPath) {
// 优先在 skills/ 目录中寻找
skillMdPath = files.find((f) => {
const upper = f.toUpperCase();
return upper.includes('/SKILLS/') && upper.endsWith('/SKILL.MD');
});
// 兜底找任意子目录下的 SKILL.md
if (!skillMdPath) {
skillMdPath = files.find((f) => f.toUpperCase().endsWith('/SKILL.MD'));
}
}
if (!skillMdPath) {
return { return {
valid: false, valid: false,
hasSkillMd: false, hasSkillMd: false,
files, files,
error: `Unsafe ZIP entry path: ${unsafePath}` error: 'Missing required file: SKILL.md (expected at root or inside a top-level directory)'
}; };
} }
if (isZipSymlink(file)) { return {
valid: true,
hasSkillMd: true,
files,
skillMdPath,
totalUncompressedBytes: safety.totalUncompressedBytes
};
} catch (error) {
return { return {
valid: false, valid: false,
hasSkillMd: false, hasSkillMd: false,
files, files: [],
error: `ZIP symlink entries are not allowed: ${unsafePath}` error: `Invalid ZIP archive: ${error instanceof Error ? error.message : 'Unknown error'}`
}; };
} }
}
if (!file.dir) { /**
totalUncompressedBytes += getZipEntryUncompressedSize(file); * 发布/保存版本时校验可部署工作区。
if ( *
options.maxUncompressedBytes !== undefined && * 这里只校验 workspace 级最小结构,不解析 SKILL.md frontmatter。创建阶段的空白初始包
totalUncompressedBytes > options.maxUncompressedBytes * 不应调用该校验;用户主动发布时必须至少存在一个可执行 Skill 目录。
) { */
export async function validateDeployableSkillWorkspacePackage(
zipBuffer: Buffer,
options: { maxUncompressedBytes?: number } = {}
): Promise<DeployableSkillWorkspaceValidationResult> {
try {
const zip = await JSZip.loadAsync(zipBuffer);
const safety = validateZipSafety(zip, options);
const files = safety.files;
if (!safety.valid) {
return { return {
valid: false, valid: false,
hasSkillMd: false,
files, files,
totalUncompressedBytes, error: safety.error
error: 'ZIP archive uncompressed size exceeds maximum allowed size'
}; };
} }
}
const hasSkillsDirectory = files.some((path) => {
const normalized = normalizeDeployableWorkspaceEntryPath(path);
return normalized === 'skills/' || normalized.startsWith('skills/');
});
if (!hasSkillsDirectory) {
return {
valid: false,
files,
error: 'Missing required directory: skills/'
};
} }
// 兼容根目录直接放 SKILL.md 的历史包。 const firstLevelSkillDirs = new Set<string>();
let skillMdPath = files.find((f) => f.toUpperCase() === 'SKILL.MD'); const executableSkillDirs = new Set<string>();
// 标准包会把 SKILL.md 放在子目录内。 for (const path of files) {
if (!skillMdPath) { const normalized = normalizeDeployableWorkspaceEntryPath(path);
// 优先在 skills/ 目录中寻找
skillMdPath = files.find((f) => {
const upper = f.toUpperCase();
return upper.includes('/SKILLS/') && upper.endsWith('/SKILL.MD');
});
// 兜底找任意子目录下的 SKILL.md const firstLevelDirMatch = normalized.match(/^skills\/([^/]+)(?:\/|$)/);
if (!skillMdPath) { if (firstLevelDirMatch?.[1] && normalized !== `skills/${firstLevelDirMatch[1]}`) {
skillMdPath = files.find((f) => f.toUpperCase().endsWith('/SKILL.MD')); firstLevelSkillDirs.add(firstLevelDirMatch[1]);
}
const skillMdMatch = normalized.match(/^skills\/([^/]+)\/SKILL\.md$/);
if (skillMdMatch?.[1]) {
executableSkillDirs.add(skillMdMatch[1]);
} }
} }
if (!skillMdPath) { if (firstLevelSkillDirs.size === 0) {
return { return {
valid: false, valid: false,
hasSkillMd: false,
files, files,
error: 'Missing required file: SKILL.md (expected at root or inside a top-level directory)' error: 'The skills/ directory must contain at least one first-level skill folder'
}; };
} }
const missingSkillMdDirs = [...firstLevelSkillDirs].filter(
(dir) => !executableSkillDirs.has(dir)
);
if (missingSkillMdDirs.length > 0) {
return { return {
valid: true, valid: false,
hasSkillMd: true,
files, files,
skillMdPath, error: `Each first-level skill folder under skills/ must contain SKILL.md: ${missingSkillMdDirs.join(', ')}`
totalUncompressedBytes };
}
return {
valid: true,
files
}; };
} catch (error) { } catch (error) {
return { return {
valid: false, valid: false,
hasSkillMd: false,
files: [], files: [],
error: `Invalid ZIP archive: ${error instanceof Error ? error.message : 'Unknown error'}` error: `Invalid ZIP archive: ${error instanceof Error ? error.message : 'Unknown error'}`
}; };
} }
} }
function normalizeDeployableWorkspaceEntryPath(path: string): string {
return path.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
function isZipRootDirectoryEntry(path: string): boolean {
const normalized = path.replace(/\\/g, '/');
return normalized === '/' || normalized === './' || normalized === '.';
}
function normalizeZipEntryPathForSafety(path: string): string {
return path.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
function validateZipSafety(
zip: JSZip,
options: { maxUncompressedBytes?: number } = {}
): ZipSafetyValidationResult {
const files = Object.keys(zip.files);
if (files.length === 0) {
return {
valid: false,
files,
error: 'ZIP archive is empty'
};
}
let totalUncompressedBytes = 0;
for (const file of Object.values(zip.files)) {
const unsafePath = file.unsafeOriginalName ?? file.name;
if (file.dir && isZipRootDirectoryEntry(unsafePath)) {
continue;
}
const normalizedUnsafePath = normalizeZipEntryPathForSafety(unsafePath);
if (!isSafeZipEntryPath(normalizedUnsafePath)) {
return {
valid: false,
files,
error: `Unsafe ZIP entry path: ${unsafePath}`
};
}
if (isZipSymlink(file)) {
return {
valid: false,
files,
error: `ZIP symlink entries are not allowed: ${unsafePath}`
};
}
if (!file.dir) {
totalUncompressedBytes += getZipEntryUncompressedSize(file);
if (
options.maxUncompressedBytes !== undefined &&
totalUncompressedBytes > options.maxUncompressedBytes
) {
return {
valid: false,
files,
totalUncompressedBytes,
error: 'ZIP archive uncompressed size exceeds maximum allowed size'
};
}
}
}
return {
valid: true,
files,
totalUncompressedBytes
};
}
function isSafeZipEntryPath(path: string): boolean { function isSafeZipEntryPath(path: string): boolean {
if (!path || path.includes('\0')) return false; if (!path || path.includes('\0')) return false;
if (path.startsWith('/') || path.startsWith('\\')) return false; if (path.startsWith('/') || path.startsWith('\\')) return false;
......
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import type {
BuiltinSkillSource,
BuiltinSkillSourceFile
} from '@fastgpt/global/core/ai/skill/runtime/builtin';
import { getSandboxBuiltinSkillsRootPath } from '../../sandbox/runtime/profile/utils';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import {
getRuntimeStateHash,
readSandboxRuntimeState,
setRuntimeStateHash,
writeSandboxRuntimeState
} from '../../sandbox/runtime/state';
const BUILTIN_SKILL_STATE_HASH_PREFIX = 'builtinSkill:';
type BuiltinSkillSyncSource = BuiltinSkillSource & {
files: BuiltinSkillSourceFile[];
etag: string;
};
export function getBuiltinSkillsRootPath(homeDirectory: string): string {
return getSandboxBuiltinSkillsRootPath(homeDirectory);
}
/**
* 将内置 Skill 源码注入 sandbox 用户主目录。
*
* 目标路径位于 `<homeDirectory>/.fastgpt/skills/<name>`,不在用户 workspace
* 内,因此不会进入编辑器文件树、导出包或发布包。
*/
export async function syncBuiltinSkillsToSandbox({
sandbox,
homeDirectory,
sources
}: {
sandbox: ISandbox;
homeDirectory: string;
sources: BuiltinSkillSource[];
}): Promise<void> {
const syncSources = sources.map(buildBuiltinSkillSyncSource);
if (syncSources.length === 0) return;
const builtinSkillsRootPath = getBuiltinSkillsRootPath(homeDirectory);
const runtimeStateContext = await readSandboxRuntimeState({ sandbox, homeDirectory });
for (const source of syncSources) {
const targetDirectory = joinSandboxPath(builtinSkillsRootPath, source.name);
const stateKey = getBuiltinSkillStateHashKey(source.name);
if (getRuntimeStateHash(runtimeStateContext.state, stateKey) === source.etag) {
continue;
}
const prepareResult = await sandbox.execute(
`rm -rf ${shellQuote(targetDirectory)} && mkdir -p ${shellQuote(targetDirectory)}`
);
if (prepareResult.exitCode !== 0) {
throw new Error(`Failed to prepare builtin skill directory: ${prepareResult.stderr}`);
}
const writeEntries: FileWriteEntry[] = source.files.map((sourceFile) => ({
path: joinSandboxPath(targetDirectory, sourceFile.relativePath),
data: sourceFile.content
}));
const writeResults = await sandbox.writeFiles(writeEntries);
const failedWrite = writeResults.find((result) => result.error);
if (failedWrite) {
throw new Error(`Failed to write builtin skill files: ${failedWrite.error?.message}`);
}
setRuntimeStateHash(runtimeStateContext.state, stateKey, source.etag);
await writeSandboxRuntimeState(sandbox, runtimeStateContext);
}
}
function buildBuiltinSkillSyncSource(source: BuiltinSkillSource): BuiltinSkillSyncSource {
return {
...source,
etag: computeBuiltinSkillEtag(source.files)
};
}
function computeBuiltinSkillEtag(files: BuiltinSkillSourceFile[]): string {
const fileEtags = files
.map((file) => ({
relativePath: file.relativePath,
etag: buildRuntimeHash(file.content)
}))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return buildRuntimeHash(fileEtags.map((file) => `${file.relativePath}:${file.etag}\n`).join(''));
}
const getBuiltinSkillStateHashKey = (name: string) => `${BUILTIN_SKILL_STATE_HASH_PREFIX}${name}`;
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* *
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。 * 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。
*/ */
import { joinSandboxPath, shellQuote } from '../sandbox/runtime/utils';
/* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */ /* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */
...@@ -124,8 +125,8 @@ export type BuildSkillMdParams = { ...@@ -124,8 +125,8 @@ export type BuildSkillMdParams = {
/** /**
* 生成一个最小可用的 SKILL.md。 * 生成一个最小可用的 SKILL.md。
* *
* 该模板只包含 frontmatter,不生成正文说明,主要用于没有 AI 辅助生成需求的 * 该模板只包含 frontmatter,不生成正文说明。它只作为通用文本工具保留;
* 初次创建流程。后续用户可在 edit sandbox 中继续补充正文和其他文件。 * 新建 Skill 的初始版本不再调用它生成默认技能文件。
*/ */
export function buildSkillMd(params: BuildSkillMdParams): string { export function buildSkillMd(params: BuildSkillMdParams): string {
return generateFrontmatter(params.name, params.description); return generateFrontmatter(params.name, params.description);
...@@ -183,25 +184,10 @@ export function extractSkillNameFromSkillMd(content: string): string { ...@@ -183,25 +184,10 @@ export function extractSkillNameFromSkillMd(content: string): string {
return headerMatch ? getSafeSkillDirectoryName(headerMatch[1]).toLowerCase() : 'unnamed-skill'; return headerMatch ? getSafeSkillDirectoryName(headerMatch[1]).toLowerCase() : 'unnamed-skill';
} }
/* ==================== Shell 安全辅助 (原 shell.ts) ==================== */
/**
* 智能转义参数以防止 Shell 注入。
*/
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
/* ==================== 沙盒路径与命名清洗辅助 (自 runtime 移入) ==================== */ /* ==================== 沙盒路径与命名清洗辅助 (自 runtime 移入) ==================== */
export const MAX_SKILL_DIRECTORY_NAME_LENGTH = 50; export const MAX_SKILL_DIRECTORY_NAME_LENGTH = 50;
const trimSandboxPathRight = (value: string) => (value === '/' ? '' : value.replace(/\/+$/, ''));
/**
* 拼接沙盒路径。
*/
export const joinSandboxPath = (basePath: string, path: string): string =>
`${trimSandboxPathRight(basePath)}/${path}`;
/** /**
* 获取运行态 selected skill version 的 projects 根目录。 * 获取运行态 selected skill version 的 projects 根目录。
*/ */
......
...@@ -283,6 +283,22 @@ const buildAgentEnvPrompt = ({ ...@@ -283,6 +283,22 @@ const buildAgentEnvPrompt = ({
${currentTime ? `当前时间: ${currentTime}` : ''} ${currentTime ? `当前时间: ${currentTime}` : ''}
${currentWorkingDirectory ? `当前 sandbox 工作目录: ${currentWorkingDirectory}` : ''}`; ${currentWorkingDirectory ? `当前 sandbox 工作目录: ${currentWorkingDirectory}` : ''}`;
}; };
const buildSandboxFileWriteBoundaryPrompt = ({
currentWorkingDirectory
}: {
currentWorkingDirectory?: string;
}) => {
if (!currentWorkingDirectory) return '';
return `## Sandbox 文件写入边界
生成或修改文件时,必须严格区分系统目录和用户产物目录:
- 用户 Skill 产物根目录:${currentWorkingDirectory}/skills
- 如果任务需要创建或修改用户 Skill,只能写入:${currentWorkingDirectory}/skills/<skill-name>/
- 用户 Skill 主文件必须是:${currentWorkingDirectory}/skills/<skill-name>/SKILL.md
- 禁止写入:${currentWorkingDirectory}/<skill-name>/ 或 ${currentWorkingDirectory}/SKILL.md
- 禁止写入:/home/sandbox/.fastgpt/skills/、~/.fastgpt/skills/ 或任何 .fastgpt/skills/ 路径;这些路径只用于系统内置 Skill。`;
};
// 当前轮动态上下文统一包在 user message 内。它不是系统角色 prompt, // 当前轮动态上下文统一包在 user message 内。它不是系统角色 prompt,
// 但对模型来说是回答本轮问题时可用的事实提醒。 // 但对模型来说是回答本轮问题时可用的事实提醒。
export const buildAgentUserReminderInput = ({ export const buildAgentUserReminderInput = ({
...@@ -302,6 +318,7 @@ export const buildAgentUserReminderInput = ({ ...@@ -302,6 +318,7 @@ export const buildAgentUserReminderInput = ({
}) => { }) => {
const reminder = [ const reminder = [
buildAgentSkillsPrompt(skillInfos), buildAgentSkillsPrompt(skillInfos),
buildSandboxFileWriteBoundaryPrompt({ currentWorkingDirectory }),
buildAgentInputFilesPrompt(filesInfo), buildAgentInputFilesPrompt(filesInfo),
buildAgentInputDatasetsPrompt(selectedDataset), buildAgentInputDatasetsPrompt(selectedDataset),
buildAgentEnvPrompt({ currentTime, currentWorkingDirectory }) buildAgentEnvPrompt({ currentTime, currentWorkingDirectory })
......
...@@ -34,9 +34,9 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils'; ...@@ -34,9 +34,9 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { import {
agentSandboxBootstrap,
ensureAgentSandboxRuntime, ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus streamAgentSandboxInitStatus,
type AgentSandboxPrepareAction
} from './sub/sandbox'; } from './sub/sandbox';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage'; import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type'; import type { RuntimeNodeResponseSummary } from '../../type';
...@@ -78,6 +78,7 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{ ...@@ -78,6 +78,7 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.sandboxEntrypoint]?: string; [NodeInputKeyEnum.sandboxEntrypoint]?: string;
}> & { }> & {
nodeResponseWriter?: WorkflowNodeResponseWriter; nodeResponseWriter?: WorkflowNodeResponseWriter;
agentSandboxPrepareActions?: AgentSandboxPrepareAction[];
}; };
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
...@@ -138,6 +139,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -138,6 +139,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
runningAppInfo, runningAppInfo,
runningUserInfo, runningUserInfo,
workflowStreamResponse, workflowStreamResponse,
agentSandboxPrepareActions,
usagePush, usagePush,
chatId, chatId,
uid, uid,
...@@ -223,10 +225,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -223,10 +225,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
teamId: runningAppInfo.teamId, teamId: runningAppInfo.teamId,
tmbId: runningUserInfo.tmbId, tmbId: runningUserInfo.tmbId,
needSandboxRuntime: effectiveUseAgentSandbox, needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint, sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds, skillIds,
editSkillId, editSkillId,
prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles currentFiles: userContext.currentFiles
}); });
// 获取请求上下文 // 获取请求上下文
......
...@@ -15,11 +15,7 @@ import { getLogger, LogCategories } from '../../../../../../common/logger'; ...@@ -15,11 +15,7 @@ import { getLogger, LogCategories } from '../../../../../../common/logger';
import type { DispatchAgentModuleProps } from '..'; import type { DispatchAgentModuleProps } from '..';
import { parseUserSystemPrompt } from '../adapter/prompt'; import { parseUserSystemPrompt } from '../adapter/prompt';
import { useUserContext } from '../adapter/userContext'; import { useUserContext } from '../adapter/userContext';
import { import { ensureAgentSandboxRuntime, streamAgentSandboxInitStatus } from '../sub/sandbox';
agentSandboxBootstrap,
ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus
} from '../sub/sandbox';
import { getAgentDatasetParams, getSubapps, type ToolDispatchContext } from '../utils'; import { getAgentDatasetParams, getSubapps, type ToolDispatchContext } from '../utils';
import { import {
createPiAgentWorkflowRuntime, createPiAgentWorkflowRuntime,
...@@ -50,6 +46,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -50,6 +46,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
runningAppInfo, runningAppInfo,
runningUserInfo, runningUserInfo,
workflowStreamResponse, workflowStreamResponse,
agentSandboxPrepareActions,
usagePush, usagePush,
chatId, chatId,
uid, uid,
...@@ -161,10 +158,10 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -161,10 +158,10 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
teamId: runningAppInfo.teamId, teamId: runningAppInfo.teamId,
tmbId: runningUserInfo.tmbId, tmbId: runningUserInfo.tmbId,
needSandboxRuntime: effectiveUseAgentSandbox, needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint, sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds, skillIds,
editSkillId, editSkillId,
prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles currentFiles: userContext.currentFiles
}); });
......
import type { AgentSandboxBootstrap } from './runtime';
/**
* 平台侧 Agent sandbox bootstrap 回调。
*
* 回调会在 per-sandbox Redis lease 内执行,且早于用户文件注入、skill 包部署和
* sandbox entrypoint。需要配置镜像源、写平台内置文件或做其他基础环境准备时,直接把
* 这里的 undefined 替换成 async 回调;回调内部自行维护幂等、版本判断和错误处理。
*
* 示例:
* export const agentSandboxBootstrap: AgentSandboxBootstrap = async ({ sandbox, workDirectory }) => {
* await sandbox.writeFiles([{ path: '.npmrc', data: 'registry=https://registry.npmmirror.com' }]);
* await sandbox.execute(`cd ${workDirectory} && ...`);
* };
*/
export const agentSandboxBootstrap: AgentSandboxBootstrap | undefined = undefined;
export { ensureAgentSandboxRuntime } from './runtime'; export {
export { agentSandboxBootstrap } from './bootstrap'; createBuiltinSkillPrepareAction,
ensureAgentSandboxRuntime,
type AgentSandboxPrepareAction
} from './prepare';
export { dispatchSandboxTool } from './tool'; export { dispatchSandboxTool } from './tool';
export { streamAgentSandboxInitStatus } from './status'; export { streamAgentSandboxInitStatus } from './status';
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 {
getAgentSkillInfos,
getBuiltinSkillsRootPath,
injectAgentSkillFilesToSandbox,
syncBuiltinSkillsToSandbox,
runAgentSkillVersionEntrypoints
} from '../../../../../../ai/skill/runtime';
import { prepareAgentSandboxRuntime } from '../../../../../../ai/sandbox/runtime';
import type { SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
import {
injectInputFilesToSandbox,
readSandboxPwd
} from '../../../../../../ai/sandbox/runtime/files';
import {
runAgentSandboxEntrypoint,
withAgentSandboxInitLease
} from '../../../../../../ai/sandbox/runtime/entrypoint';
import { resolveSandboxHome } from '../../../../../../ai/sandbox/runtime/home';
export type AgentSandboxPrepareContext = {
sandboxClient: SandboxClient;
workDirectory: string;
currentWorkingDirectory?: string;
deployedSkillVersions: DeployedSkillVersion[];
skillInfos: DeployedSkillInfo[];
skillScanDirectories: string[];
};
export type AgentSandboxPrepareAction = (
context: AgentSandboxPrepareContext
) => Promise<AgentSandboxPrepareContext>;
type EnsureAgentSandboxRuntimeParams = {
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
teamId: string;
tmbId: string;
needSandboxRuntime: boolean;
sandboxEntrypoint?: string;
skillIds: string[];
editSkillId?: string;
prepareActions?: AgentSandboxPrepareAction[];
currentFiles: AgentInputFile[];
};
type EnsureAgentSandboxRuntimeResult = {
sandboxClient?: SandboxClient;
currentWorkingDirectory?: string;
skillInfos: DeployedSkillInfo[];
};
type AgentSandboxPrepareStep = (
context: AgentSandboxPrepareContext
) => Promise<AgentSandboxPrepareContext>;
/**
* 确保 Agent 本轮 sandbox runtime 可用。
*
* workflow 层显式编排本轮 sandbox 生命周期;runtime 层只暴露具体原子能力。
*/
export async function ensureAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
tmbId,
needSandboxRuntime,
sandboxEntrypoint,
skillIds,
editSkillId,
prepareActions = [],
currentFiles
}: EnsureAgentSandboxRuntimeParams): Promise<EnsureAgentSandboxRuntimeResult> {
const sandboxContext = await prepareAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
needSandboxRuntime
});
if (!sandboxContext) {
return {
skillInfos: []
};
}
const preparedContext = await withAgentSandboxInitLease({
sandboxId: sandboxContext.sandboxClient.getSandboxId(),
fn: () => {
const context = {
...sandboxContext,
deployedSkillVersions: [],
skillInfos: [],
skillScanDirectories: []
};
return editSkillId
? prepareSandbox(
context,
injectCurrentInputFiles(currentFiles),
...prepareActions,
readCurrentWorkingDirectory(),
scanEditDebugSkillInfos()
)
: prepareSandbox(
context,
injectSelectedSkillFiles({ teamId, tmbId, skillIds }),
injectCurrentInputFiles(currentFiles),
...prepareActions,
readCurrentWorkingDirectory(),
runSandboxEntrypoint({ sandboxEntrypoint }),
runSelectedSkillEntrypoints(),
scanSelectedSkillInfos()
);
}
});
return {
sandboxClient: sandboxContext.sandboxClient,
currentWorkingDirectory: preparedContext.currentWorkingDirectory,
skillInfos: preparedContext.skillInfos
};
}
const prepareSandbox = async (
context: AgentSandboxPrepareContext,
...steps: AgentSandboxPrepareStep[]
): Promise<AgentSandboxPrepareContext> => {
let currentContext = context;
for (const step of steps) {
currentContext = await step(currentContext);
}
return currentContext;
};
const injectCurrentInputFiles =
(currentFiles: AgentInputFile[]): AgentSandboxPrepareStep =>
async (context) => {
await injectInputFilesToSandbox(context.sandboxClient.provider, currentFiles);
return context;
};
const readCurrentWorkingDirectory = (): AgentSandboxPrepareStep => async (context) => ({
...context,
currentWorkingDirectory: await readSandboxPwd(context.sandboxClient)
});
/**
* 创建“同步内置 Skill 到当前 sandbox”的 prepare action。
*
* 调用方只提供内置 Skill 文件来源;具体同步位置、HOME 解析和后续扫描目录登记
* 都在 sandbox prepare 生命周期内完成,避免 API 层感知 sandbox 细节。
*/
export const createBuiltinSkillPrepareAction =
({
getSources,
injectToSandbox = syncBuiltinSkillsToSandbox
}: {
getSources: () => Promise<BuiltinSkillSource[]>;
injectToSandbox?: typeof syncBuiltinSkillsToSandbox;
}): AgentSandboxPrepareAction =>
async (context) => {
const sources = await getSources();
if (sources.length === 0) return context;
const homeDirectory = await resolveSandboxHome(context.sandboxClient.provider);
if (!homeDirectory) {
throw new Error('Failed to resolve sandbox HOME for builtin skill sync');
}
await injectToSandbox({
sandbox: context.sandboxClient.provider,
homeDirectory,
sources
});
const builtinSkillsRootPath = getBuiltinSkillsRootPath(homeDirectory);
return {
...context,
skillScanDirectories: [
...context.skillScanDirectories,
...sources.map((source) => `${builtinSkillsRootPath}/${source.name}`)
]
};
};
const scanEditDebugSkillInfos = (): AgentSandboxPrepareStep => async (context) => ({
...context,
skillInfos: await getAgentSkillInfos({
sandbox: context.sandboxClient.provider,
skillDirectories: [context.workDirectory, ...context.skillScanDirectories]
})
});
const injectSelectedSkillFiles =
({
teamId,
tmbId,
skillIds
}: {
teamId: string;
tmbId: string;
skillIds: string[];
}): AgentSandboxPrepareStep =>
async (context) => ({
...context,
deployedSkillVersions: await injectAgentSkillFilesToSandbox({
sandbox: context.sandboxClient.provider,
teamId,
tmbId,
skillIds,
workDirectory: context.workDirectory
})
});
const runSandboxEntrypoint =
({ sandboxEntrypoint }: { sandboxEntrypoint?: string }): AgentSandboxPrepareStep =>
async (context) => {
await runAgentSandboxEntrypoint({
sandbox: context.sandboxClient.provider,
sandboxEntrypoint,
workDirectory: context.workDirectory
});
return context;
};
const runSelectedSkillEntrypoints = (): AgentSandboxPrepareStep => async (context) => {
if (context.deployedSkillVersions.length > 0) {
await runAgentSkillVersionEntrypoints({
sandbox: context.sandboxClient.provider,
versions: context.deployedSkillVersions
});
}
return context;
};
const scanSelectedSkillInfos = (): AgentSandboxPrepareStep => async (context) => ({
...context,
skillInfos: await (() => {
const skillDirectories = [
...context.deployedSkillVersions.map(({ targetDir }) => targetDir),
...context.skillScanDirectories
];
return skillDirectories.length > 0
? getAgentSkillInfos({
sandbox: context.sandboxClient.provider,
skillDirectories
})
: Promise.resolve([]);
})()
});
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import type { AgentInputFile } from '../../adapter/userContext';
import { SANDBOX_USER_FILES_PATH } from '@fastgpt/global/core/ai/sandbox/constants';
import {
getAgentSkillInfos,
injectAgentSkillFilesToSandbox,
type DeployedSkillInfo
} from '../../../../../../ai/skill/runtime';
import {
runAgentSandboxEntrypoint,
runAgentSkillVersionEntrypoints,
withAgentSandboxInitLease
} from '../../../../../../ai/skill/runtime/entrypoint';
import { getSandboxRuntimeProfile } from '../../../../../../ai/sandbox/runtime/profile';
import { getSandboxClient, type SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
import { pickOutboundAxios } from '../../../../../../../common/api/axios';
import { checkTeamSandboxPermission } from '../../../../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../../../../../ai/sandbox/error';
import { getSafeAgentInputFilename } from '../../adapter/fileName';
export type AgentSandboxBootstrap = (context: {
sandboxClient: SandboxClient;
sandbox: ISandbox;
workDirectory: string;
}) => Promise<void>;
type EnsureAgentSandboxRuntimeParams = {
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
teamId: string;
tmbId: string;
needSandboxRuntime: boolean;
sandboxBootstrap?: AgentSandboxBootstrap;
sandboxEntrypoint?: string;
skillIds: string[];
editSkillId?: string;
currentFiles: AgentInputFile[];
};
type EnsureAgentSandboxRuntimeResult = {
sandboxClient?: SandboxClient;
currentWorkingDirectory?: string;
skillInfos: DeployedSkillInfo[];
};
type SandboxRuntimeContext = {
sandboxClient: SandboxClient;
workDirectory: string;
};
type InitRuntimeSandboxParams = SandboxRuntimeContext & {
teamId: string;
tmbId: string;
skillIds: string[];
sandboxBootstrap?: AgentSandboxBootstrap;
sandboxEntrypoint?: string;
currentFiles: AgentInputFile[];
};
type InitEditSkillSandboxParams = SandboxRuntimeContext & {
currentFiles: AgentInputFile[];
};
/**
* 读取 sandbox 当前目录,仅作为 user reminder 的提示增强。
* 如果命令失败或没有输出,返回 undefined,让提示词侧完全跳过 pwd 区块。
*/
const readSandboxPwd = async (sandboxClient: SandboxClient) => {
try {
const result = await sandboxClient.exec('pwd');
if (result.exitCode === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
return;
}
};
/**
* 将本轮用户输入文件写入当前 sandbox。
*
* 路径规则和通用 toolcall 保持一致:用户文件直接写入 user_files/<文件名>。
* 这里直接消费 currentFiles,避免先构造中间 sandbox file 结构再二次遍历。
*/
const injectInputFilesToSandbox = async (sandbox: ISandbox, files: AgentInputFile[]) => {
const writeFileTasks: Promise<FileWriteEntry>[] = [];
const usedNames = new Map<string, number>();
for (const [index, file] of files.entries()) {
const filename = getSafeAgentInputFilename(file.name, index, usedNames);
const path = `${SANDBOX_USER_FILES_PATH}${filename}`;
writeFileTasks.push(
pickOutboundAxios(file.url)
.get<ArrayBuffer>(file.url, {
responseType: 'arraybuffer'
})
.then((response) => ({
path,
data: response.data
}))
);
}
if (writeFileTasks.length === 0) return;
await sandbox.writeFiles(await Promise.all(writeFileTasks));
};
/**
* 初始化 edit-debug sandbox。
*
* 编辑调试包已解压到当前工作目录,这里只补齐用户输入文件和 SKILL.md 扫描。
*/
const initEditSkillSandbox = async ({
sandboxClient,
workDirectory,
currentFiles
}: InitEditSkillSandboxParams): Promise<Omit<EnsureAgentSandboxRuntimeResult, 'sandboxClient'>> => {
const [, currentWorkingDirectory, skillInfos] = await Promise.all([
injectInputFilesToSandbox(sandboxClient.provider, currentFiles),
readSandboxPwd(sandboxClient),
getAgentSkillInfos({
sandbox: sandboxClient.provider,
workDirectory
})
]);
return {
currentWorkingDirectory,
skillInfos
};
};
/**
* 初始化普通 Agent sandbox。
*
* 顺序固定为:平台 bootstrap 回调 -> 准备文件和 skill 包 -> sandbox entrypoint -> skill entrypoint -> 扫描 SKILL.md。
*/
const initRuntimeSandbox = async ({
sandboxClient,
workDirectory,
teamId,
tmbId,
skillIds,
sandboxBootstrap,
sandboxEntrypoint,
currentFiles
}: InitRuntimeSandboxParams): Promise<Omit<EnsureAgentSandboxRuntimeResult, 'sandboxClient'>> => {
return withAgentSandboxInitLease({
sandboxId: sandboxClient.getSandboxId(),
fn: async () => {
const sandbox = sandboxClient.provider;
await sandboxBootstrap?.({
sandboxClient,
sandbox,
workDirectory
});
const [deployedSkillVersions, , currentWorkingDirectory] = await Promise.all([
injectAgentSkillFilesToSandbox({
sandbox,
skillIds,
teamId,
tmbId,
workDirectory
}),
injectInputFilesToSandbox(sandbox, currentFiles),
readSandboxPwd(sandboxClient)
]);
const effectiveSandboxEntrypoint = sandboxEntrypoint?.trim();
if (effectiveSandboxEntrypoint) {
await runAgentSandboxEntrypoint({
sandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint,
workDirectory
});
}
if (deployedSkillVersions.length > 0) {
await runAgentSkillVersionEntrypoints({
sandbox,
versions: deployedSkillVersions
});
}
const skillInfos =
deployedSkillVersions.length > 0
? await getAgentSkillInfos({
sandbox,
skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
})
: [];
return {
currentWorkingDirectory,
skillInfos
};
}
});
};
/**
* 确保 Agent 本轮 sandbox runtime 可用。
*
* 只要显式启用 sandbox 或本轮有 skill,就在 agent-loop 前启动同一个
* appId/userId/chatId sandbox,并完成本轮文件注入、skill 包注入和 SKILL.md 扫描。
* 返回的 sandboxClient 会继续传给 sandbox tool,避免工具执行阶段重新定位实例。
*/
export async function ensureAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
tmbId,
needSandboxRuntime,
sandboxBootstrap,
sandboxEntrypoint,
skillIds,
editSkillId,
currentFiles
}: EnsureAgentSandboxRuntimeParams): Promise<EnsureAgentSandboxRuntimeResult> {
const hasEditSkill = !!editSkillId;
if (needSandboxRuntime) {
try {
await checkTeamSandboxPermission(teamId);
} catch {
throw createAgentSandboxPermissionDeniedError();
}
}
if (!needSandboxRuntime) {
return {
skillInfos: []
};
}
// 确认使用沙盒,启动沙盒实例
const sandboxClient = await getSandboxClient(
sandboxId ? { sandboxId } : { appId, userId, chatId }
);
const runtimeProfile = getSandboxRuntimeProfile();
const context = {
sandboxClient,
workDirectory: runtimeProfile.workDirectory
};
if (hasEditSkill) {
const { currentWorkingDirectory, skillInfos } = await initEditSkillSandbox({
...context,
currentFiles
});
return {
sandboxClient,
currentWorkingDirectory,
skillInfos
};
}
const { currentWorkingDirectory, skillInfos } = await initRuntimeSandbox({
...context,
teamId,
tmbId,
skillIds,
sandboxBootstrap,
sandboxEntrypoint,
currentFiles
});
return {
sandboxClient,
currentWorkingDirectory,
skillInfos
};
}
...@@ -15,7 +15,7 @@ import { getSandboxRuntimeProfile } from '../../../../../ai/sandbox/runtime/prof ...@@ -15,7 +15,7 @@ import { getSandboxRuntimeProfile } from '../../../../../ai/sandbox/runtime/prof
import { import {
runAgentSandboxEntrypoint, runAgentSandboxEntrypoint,
withAgentSandboxInitLease withAgentSandboxInitLease
} from '../../../../../ai/skill/runtime/entrypoint'; } from '../../../../../ai/sandbox/runtime/entrypoint';
import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime'; import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime';
import type { FileInputType, ToolNodeItemType } from '../type'; import type { FileInputType, ToolNodeItemType } from '../type';
import { ReadFileTooData, ReadFileToolSchema } from '../tools/file'; import { ReadFileTooData, ReadFileToolSchema } from '../tools/file';
......
...@@ -83,6 +83,7 @@ import { ...@@ -83,6 +83,7 @@ import {
type WorkflowObservedStepResult type WorkflowObservedStepResult
} from './utils/trace'; } from './utils/trace';
import { getWorkflowNodeRunParams } from './utils/runtime'; import { getWorkflowNodeRunParams } from './utils/runtime';
import type { AgentSandboxPrepareAction } from './ai/agent/sub/sandbox';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH); const logger = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH);
...@@ -102,6 +103,7 @@ type Props = Omit< ...@@ -102,6 +103,7 @@ type Props = Omit<
req?: IncomingMessage; req?: IncomingMessage;
defaultSkipNodeQueue?: WorkflowDebugResponse['skipNodeQueue']; defaultSkipNodeQueue?: WorkflowDebugResponse['skipNodeQueue'];
nodeResponseWriteConfig: WorkflowNodeResponseWriteConfig; nodeResponseWriteConfig: WorkflowNodeResponseWriteConfig;
agentSandboxPrepareActions?: AgentSandboxPrepareAction[];
}; };
type NodeResponseType = DispatchNodeResultType<{ type NodeResponseType = DispatchNodeResultType<{
[key: string]: any; [key: string]: any;
......
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/instance/schema'; import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/instance/schema';
import { import {
buildSandboxInstanceLookup,
countRunningSandboxInstancesByType, countRunningSandboxInstancesByType,
createSandboxResourcesToArchiveCursor, createSandboxResourcesToArchiveCursor,
deleteSandboxInstanceRecord, deleteSandboxInstanceRecord,
...@@ -28,8 +27,7 @@ import { ...@@ -28,8 +27,7 @@ import {
upsertRunningSandboxInstance, upsertRunningSandboxInstance,
type SandboxResourceDoc type SandboxResourceDoc
} from '@fastgpt/service/core/ai/sandbox/instance/repository'; } from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
const collectArchiveCursor = async ( const collectArchiveCursor = async (
......
import { describe, expect, it, vi } from 'vitest';
const { pickOutboundAxiosGetMock } = vi.hoisted(() => ({
pickOutboundAxiosGetMock: vi.fn()
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
pickOutboundAxios: () => ({
get: pickOutboundAxiosGetMock
})
}));
describe('sandbox runtime files', () => {
it('writes input files with safe unique filenames', async () => {
const { injectInputFilesToSandbox } = await import(
'@fastgpt/service/core/ai/sandbox/runtime/files'
);
const sandbox = {
writeFiles: vi.fn()
};
pickOutboundAxiosGetMock.mockResolvedValue({ data: new ArrayBuffer(1) });
await injectInputFilesToSandbox(sandbox as any, [
{
name: 'current.pdf',
url: 'https://files/current.pdf'
},
{
name: '../current.pdf',
url: 'https://files/unsafe-current.pdf'
},
{
name: 'folder/report.txt',
url: 'https://files/report.txt'
},
{
name: '..',
url: 'https://files/nameless'
}
]);
expect(sandbox.writeFiles).toHaveBeenCalledWith([
{
path: 'user_files/current.pdf',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/current-1.pdf',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/report.txt',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/file-3',
data: expect.any(ArrayBuffer)
}
]);
});
});
import { describe, expect, it, vi } from 'vitest';
import {
getBuiltinSkillsRootPath,
syncBuiltinSkillsToSandbox
} from '@fastgpt/service/core/ai/skill/runtime/builtin';
import { buildRuntimeHash } from '@fastgpt/service/core/ai/sandbox/runtime/utils';
describe('builtin skill runtime', () => {
it('injects builtin skill files into runtime directory instead of user workspace', async () => {
const skillCreatorSource = {
name: 'skill-creator',
files: createBuiltinSkillSourceFiles()
};
const sandbox = {
execute: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })),
readFiles: vi.fn(async (paths: string[]) =>
paths.map((path) => ({
path,
content: Buffer.from(''),
error: new Error('not found')
}))
),
writeFiles: vi.fn(async (entries: Array<{ path: string; data: Buffer | string }>) =>
entries.map((entry) => ({
path: entry.path,
bytesWritten: entry.data.length,
error: null
}))
)
};
await syncBuiltinSkillsToSandbox({
sandbox: sandbox as any,
homeDirectory: '/home/sandbox',
sources: [skillCreatorSource!]
});
expect(getBuiltinSkillsRootPath('/home/sandbox')).toBe('/home/sandbox/.fastgpt/skills');
expect(sandbox.execute).toHaveBeenNthCalledWith(
1,
"mkdir -p '/home/sandbox/.fastgpt/runtime'",
{
maxOutputBytes: 1024,
timeoutMs: 5000
}
);
expect(sandbox.execute).toHaveBeenNthCalledWith(
2,
"rm -rf '/home/sandbox/.fastgpt/skills/skill-creator' && mkdir -p '/home/sandbox/.fastgpt/skills/skill-creator'"
);
const writeEntries = sandbox.writeFiles.mock.calls[0][0];
expect(writeEntries).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/home/sandbox/.fastgpt/skills/skill-creator/SKILL.md',
data: expect.any(Buffer)
})
])
);
expect(writeEntries.every((entry) => !entry.path.includes('/workspace/'))).toBe(true);
expect(sandbox.writeFiles.mock.calls[1][0]).toEqual([
expect.objectContaining({
path: '/home/sandbox/.fastgpt/runtime/state.json',
data: expect.stringContaining('builtinSkill:skill-creator')
})
]);
});
it('skips writing builtin skill when runtime state etag is current', async () => {
const sources = [
{
name: 'skill-creator',
files: createBuiltinSkillSourceFiles()
}
];
const currentEtag = getSourceEtagForTest(sources[0].files);
const sandbox = {
execute: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })),
readFiles: vi.fn(async (paths: string[]) =>
paths.map((path) => ({
path,
content: Buffer.from(
JSON.stringify({
hashes: {
'builtinSkill:skill-creator': currentEtag
}
})
),
error: null
}))
),
writeFiles: vi.fn()
};
await syncBuiltinSkillsToSandbox({
sandbox: sandbox as any,
homeDirectory: '/home/sandbox',
sources
});
expect(sandbox.writeFiles).not.toHaveBeenCalled();
expect(sandbox.execute).toHaveBeenCalledTimes(1);
expect(sandbox.readFiles).toHaveBeenCalledWith(['/home/sandbox/.fastgpt/runtime/state.json']);
});
});
function createBuiltinSkillSourceFiles() {
return [
{
relativePath: 'SKILL.md',
content: Buffer.from(`---
name: skill-creator
description: Create FastGPT skills.
---
# Skill Creator
`)
},
{
relativePath: 'scripts/init_skill.py',
content: Buffer.from('print("init")\n')
}
];
}
function getSourceEtagForTest(files: Array<{ relativePath: string; content: Buffer }>) {
const fileEtags = files
.map((file) => ({
relativePath: file.relativePath,
etag: buildRuntimeHash(file.content)
}))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return buildRuntimeHash(fileEtags.map((file) => `${file.relativePath}:${file.etag}\n`).join(''));
}
import { afterEach, describe, expect, it } from 'vitest';
import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
import { getSkillCreationLLMModel } from '@fastgpt/service/core/ai/model';
const originalSystemDefaultModel = global.systemDefaultModel;
const buildLlmModel = (model: string, isDefault = false): LLMModelItemType => ({
type: ModelTypeEnum.llm,
model,
name: model,
avatar: model,
isActive: true,
isDefault,
isCustom: false,
provider: 'OpenAI',
functionCall: false,
toolChoice: false,
maxContext: 4096,
maxResponse: 4096,
quoteMaxToken: 2048
});
describe('skill creation model selection', () => {
afterEach(() => {
global.systemDefaultModel = originalSystemDefaultModel;
});
it('uses the system default LLM even when helper bot model is configured', () => {
const systemModel = buildLlmModel('system-default-model', true);
const helperModel = buildLlmModel('helper-env-model');
global.systemDefaultModel = {
...global.systemDefaultModel,
llm: systemModel,
helperBotLLM: helperModel
};
expect(getSkillCreationLLMModel()).toBe('system-default-model');
});
it('falls back to the system default LLM when helper bot model is missing', () => {
const systemModel = buildLlmModel('system-default-model', true);
global.systemDefaultModel = {
...global.systemDefaultModel,
llm: systemModel,
helperBotLLM: undefined
};
expect(getSkillCreationLLMModel()).toBe('system-default-model');
});
});
...@@ -26,6 +26,10 @@ vi.mock('@fastgpt/service/core/ai/skill/version/schema', () => ({ ...@@ -26,6 +26,10 @@ vi.mock('@fastgpt/service/core/ai/skill/version/schema', () => ({
vi.mock('@fastgpt/service/core/ai/skill/package', () => ({ vi.mock('@fastgpt/service/core/ai/skill/package', () => ({
downloadSkillPackage: vi.fn(), downloadSkillPackage: vi.fn(),
DEFAULT_GITIGNORE_CONTENT: '.venv/\nnode_modules/\n', DEFAULT_GITIGNORE_CONTENT: '.venv/\nnode_modules/\n',
validateDeployableSkillWorkspacePackage: vi.fn(async () => ({
valid: true,
files: []
})),
validateZipStructure: vi.fn(async () => ({ validateZipStructure: vi.fn(async () => ({
valid: true, valid: true,
hasSkillMd: true, hasSkillMd: true,
...@@ -133,7 +137,11 @@ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ ...@@ -133,7 +137,11 @@ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema'; import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema'; import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema';
import { downloadSkillPackage, validateZipStructure } from '@fastgpt/service/core/ai/skill/package'; import {
downloadSkillPackage,
validateDeployableSkillWorkspacePackage,
validateZipStructure
} from '@fastgpt/service/core/ai/skill/package';
import { import {
createEditDebugSandbox, createEditDebugSandbox,
packageSkillInSandbox packageSkillInSandbox
...@@ -212,12 +220,36 @@ describe('packageSkillInSandbox', () => { ...@@ -212,12 +220,36 @@ describe('packageSkillInSandbox', () => {
expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/package.zip']); expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/package.zip']);
expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'"); expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'");
expect(validateZipStructure).toHaveBeenCalledWith(Buffer.from(zipContent), { expect(validateDeployableSkillWorkspacePackage).toHaveBeenCalledWith(Buffer.from(zipContent), {
maxUncompressedBytes: 1024 * 1024 maxUncompressedBytes: 1024 * 1024
}); });
expect(validateZipStructure).not.toHaveBeenCalled();
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox); expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox);
}); });
it('uses basic zip validation when packaging for export', async () => {
const zipContent = new Uint8Array([1, 2, 3]);
const sandbox = createSandbox({
readFilesResult: [
{
path: '/workspace/package.zip',
content: zipContent,
error: null
}
]
});
mocks.connectToSandbox.mockResolvedValueOnce(sandbox);
await expect(
packageSkillInSandbox({ sandboxId: 'sandbox-1', validationMode: 'basicZip' })
).resolves.toEqual(Buffer.from(zipContent));
expect(validateZipStructure).toHaveBeenCalledWith(Buffer.from(zipContent), {
maxUncompressedBytes: 1024 * 1024
});
expect(validateDeployableSkillWorkspacePackage).not.toHaveBeenCalled();
});
it('throws when the final package zip exceeds the skill package limit', async () => { it('throws when the final package zip exceeds the skill package limit', async () => {
const zipContent = new Uint8Array(1024 * 1024 + 1); const zipContent = new Uint8Array(1024 * 1024 + 1);
const sandbox = createSandbox({ const sandbox = createSandbox({
...@@ -235,6 +267,7 @@ describe('packageSkillInSandbox', () => { ...@@ -235,6 +267,7 @@ describe('packageSkillInSandbox', () => {
'Skill package size' 'Skill package size'
); );
expect(validateDeployableSkillWorkspacePackage).not.toHaveBeenCalled();
expect(validateZipStructure).not.toHaveBeenCalled(); expect(validateZipStructure).not.toHaveBeenCalled();
expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'"); expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'");
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox); expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox);
......
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import { runAgentSandboxEntrypoint } from '@fastgpt/service/core/ai/sandbox/runtime/entrypoint';
runAgentSandboxEntrypoint, import { runAgentSkillVersionEntrypoints } from '@fastgpt/service/core/ai/skill/runtime/entrypoint';
runAgentSkillVersionEntrypoints
} from '@fastgpt/service/core/ai/skill/runtime/entrypoint';
import type { DeployedSkillVersion } from '@fastgpt/service/core/ai/skill/runtime'; import type { DeployedSkillVersion } from '@fastgpt/service/core/ai/skill/runtime';
type ExecuteResult = { type ExecuteResult = {
...@@ -28,7 +26,7 @@ const createSandbox = ({ ...@@ -28,7 +26,7 @@ const createSandbox = ({
if (command === 'printf "%s" "$HOME"') { if (command === 'printf "%s" "$HOME"') {
return { exitCode: 0, stdout: '/home/test', stderr: '' }; return { exitCode: 0, stdout: '/home/test', stderr: '' };
} }
if (command.startsWith("mkdir -p '/home/test/.fastgpt/agent-skill-entrypoints'")) { if (command.startsWith("mkdir -p '/home/test/.fastgpt/runtime'")) {
return { exitCode: 0, stdout: '', stderr: '' }; return { exitCode: 0, stdout: '', stderr: '' };
} }
if (command.startsWith("[ -f '/workspace/projects/version-1/entrypoint.sh' ]")) { if (command.startsWith("[ -f '/workspace/projects/version-1/entrypoint.sh' ]")) {
...@@ -95,7 +93,7 @@ describe('runtime entrypoint', () => { ...@@ -95,7 +93,7 @@ describe('runtime entrypoint', () => {
sandbox: sandbox as any, sandbox: sandbox as any,
sandboxEntrypoint: 'echo first' sandboxEntrypoint: 'echo first'
}); });
const firstHash = sandbox.getState()?.sandboxEntrypointHash; const firstHash = sandbox.getState()?.hashes?.sandboxEntrypoint;
await runAgentSandboxEntrypoint({ await runAgentSandboxEntrypoint({
sandbox: sandbox as any, sandbox: sandbox as any,
...@@ -111,8 +109,8 @@ describe('runtime entrypoint', () => { ...@@ -111,8 +109,8 @@ describe('runtime entrypoint', () => {
.filter(isSandboxEntrypointCommand); .filter(isSandboxEntrypointCommand);
expect(entrypointCommands).toHaveLength(2); expect(entrypointCommands).toHaveLength(2);
expect(sandbox.getState()?.sandboxEntrypointHash).toMatch(/^sha256:/); expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toMatch(/^sha256:/);
expect(sandbox.getState()?.sandboxEntrypointHash).not.toBe(firstHash); expect(sandbox.getState()?.hashes?.sandboxEntrypoint).not.toBe(firstHash);
}); });
it('runs sandbox entrypoint from the configured work directory', async () => { it('runs sandbox entrypoint from the configured work directory', async () => {
...@@ -138,7 +136,7 @@ describe('runtime entrypoint', () => { ...@@ -138,7 +136,7 @@ describe('runtime entrypoint', () => {
sandboxEntrypoint: 'exit 1' sandboxEntrypoint: 'exit 1'
}); });
expect(sandbox.getState()?.sandboxEntrypointHash).toBeUndefined(); expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toBeUndefined();
}); });
it('does not throw or write state when sandbox entrypoint execution throws', async () => { it('does not throw or write state when sandbox entrypoint execution throws', async () => {
...@@ -150,7 +148,7 @@ describe('runtime entrypoint', () => { ...@@ -150,7 +148,7 @@ describe('runtime entrypoint', () => {
sandboxEntrypoint: 'echo throw' sandboxEntrypoint: 'echo throw'
}) })
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(sandbox.getState()?.sandboxEntrypointHash).toBeUndefined(); expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toBeUndefined();
}); });
it('uses skill version state to skip successful skill entrypoints', async () => { it('uses skill version state to skip successful skill entrypoints', async () => {
...@@ -170,7 +168,7 @@ describe('runtime entrypoint', () => { ...@@ -170,7 +168,7 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand); .filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(1); expect(runCommands).toHaveLength(1);
expect(sandbox.getState()?.skillEntrypoints).toEqual(['version-1']); expect(sandbox.getState()?.lists?.skillEntrypoints).toEqual(['version-1']);
}); });
it('retries skill entrypoint after a failed run', async () => { it('retries skill entrypoint after a failed run', async () => {
...@@ -190,6 +188,6 @@ describe('runtime entrypoint', () => { ...@@ -190,6 +188,6 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand); .filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(2); expect(runCommands).toHaveLength(2);
expect(sandbox.getState()?.skillEntrypoints).toBeUndefined(); expect(sandbox.getState()?.lists?.skillEntrypoints).toBeUndefined();
}); });
}); });
...@@ -52,11 +52,12 @@ const makeWriteResults = (entries: Array<{ path: string; data: unknown }>) => ...@@ -52,11 +52,12 @@ const makeWriteResults = (entries: Array<{ path: string; data: unknown }>) =>
const LIST_VERSION_DIRS_COMMAND = const LIST_VERSION_DIRS_COMMAND =
"find '/workspace/projects' -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null"; "find '/workspace/projects' -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null";
const WORKSPACE_SKILL_INFO_FIND_COMMAND = `find '/workspace' \\( -name 'node_modules' -o -name '.venv' -o -name 'venv' \\) -prune -o -iname "SKILL.md" -print0 2>/dev/null`;
describe('getAgentSkillInfos', () => { describe('getAgentSkillInfos', () => {
it('scans every recursive skill.md from every selected version directory', async () => { it('scans every recursive skill.md from every selected version directory', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-scan-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const [skill1, skill2] = await MongoAgentSkills.create([ const [skill1, skill2] = await MongoAgentSkills.create([
{ {
...@@ -233,6 +234,7 @@ description: Zeta skill ...@@ -233,6 +234,7 @@ description: Zeta skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill1._id), String(skill2._id)], skillIds: [String(skill1._id), String(skill2._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
...@@ -245,8 +247,12 @@ description: Zeta skill ...@@ -245,8 +247,12 @@ description: Zeta skill
const writtenFilePaths = sandbox.writeFiles.mock.calls[0][0].map( const writtenFilePaths = sandbox.writeFiles.mock.calls[0][0].map(
(entry: { path: string }) => entry.path (entry: { path: string }) => entry.path
); );
expect(writtenFilePaths[0]).toContain(`/workspace/projects/.tmp-${String(skill1VersionId)}`); expect(writtenFilePaths).toEqual(
expect(writtenFilePaths[1]).toContain(`/workspace/projects/.tmp-${String(skill2VersionId)}`); expect.arrayContaining([
expect.stringContaining(`/workspace/projects/.tmp-${String(skill1VersionId)}`),
expect.stringContaining(`/workspace/projects/.tmp-${String(skill2VersionId)}`)
])
);
expect(writtenFilePaths.every((path: string) => path.endsWith('/package.zip'))).toBe(true); expect(writtenFilePaths.every((path: string) => path.endsWith('/package.zip'))).toBe(true);
const unzipCommands = sandbox.execute.mock.calls const unzipCommands = sandbox.execute.mock.calls
.map(([command]) => command) .map(([command]) => command)
...@@ -266,9 +272,7 @@ description: Zeta skill ...@@ -266,9 +272,7 @@ description: Zeta skill
.filter((command) => command.includes('-iname "SKILL.md"')); .filter((command) => command.includes('-iname "SKILL.md"'));
expect(findSkillCommands.some((c) => c.includes(`'${skill1TargetDir}'`))).toBe(true); expect(findSkillCommands.some((c) => c.includes(`'${skill1TargetDir}'`))).toBe(true);
expect(findSkillCommands.some((c) => c.includes(`'${skill2TargetDir}'`))).toBe(true); expect(findSkillCommands.some((c) => c.includes(`'${skill2TargetDir}'`))).toBe(true);
expect(findSkillCommands).not.toContain( expect(findSkillCommands).not.toContain(WORKSPACE_SKILL_INFO_FIND_COMMAND);
`find '/workspace' -iname "SKILL.md" -print0 2>/dev/null`
);
expect(result).toHaveLength(6); expect(result).toHaveLength(6);
expect(result.map((item) => item.name)).toEqual( expect(result.map((item) => item.name)).toEqual(
expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta']) expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'])
...@@ -286,8 +290,8 @@ description: Zeta skill ...@@ -286,8 +290,8 @@ description: Zeta skill
}); });
it('deploys every selected current version into version directories', async () => { it('deploys every selected current version into version directories', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-deploy-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const [existingSkill, missingSkill] = await MongoAgentSkills.create([ const [existingSkill, missingSkill] = await MongoAgentSkills.create([
{ {
...@@ -427,6 +431,7 @@ description: Missing skill ...@@ -427,6 +431,7 @@ description: Missing skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(existingSkill._id), String(missingSkill._id)], skillIds: [String(existingSkill._id), String(missingSkill._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
...@@ -456,8 +461,8 @@ description: Missing skill ...@@ -456,8 +461,8 @@ description: Missing skill
}); });
it('uses the version pointed to by skill.currentVersionId', async () => { it('uses the version pointed to by skill.currentVersionId', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-current-version-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({ const skill = await MongoAgentSkills.create({
name: 'MultiActive', name: 'MultiActive',
...@@ -565,6 +570,7 @@ description: Latest current skill ...@@ -565,6 +570,7 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
const result = await getAgentSkillInfos({ const result = await getAgentSkillInfos({
...@@ -713,8 +719,8 @@ description: Latest current skill ...@@ -713,8 +719,8 @@ description: Latest current skill
}); });
it('skips existing current version directories and removes unselected version directories', async () => { it('skips existing current version directories and removes unselected version directories', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-cached-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({ const skill = await MongoAgentSkills.create({
name: 'CachedVersion', name: 'CachedVersion',
...@@ -785,6 +791,7 @@ description: Latest current skill ...@@ -785,6 +791,7 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
...@@ -800,8 +807,8 @@ description: Latest current skill ...@@ -800,8 +807,8 @@ description: Latest current skill
}); });
it('throws when a skill package file fails to write', async () => { it('throws when a skill package file fails to write', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-write-fail-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({ const skill = await MongoAgentSkills.create({
name: 'Broken', name: 'Broken',
...@@ -879,6 +886,7 @@ description: Latest current skill ...@@ -879,6 +886,7 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}) })
).rejects.toThrow('Failed to write skill ZIP packages: write failed'); ).rejects.toThrow('Failed to write skill ZIP packages: write failed');
...@@ -887,8 +895,8 @@ description: Latest current skill ...@@ -887,8 +895,8 @@ description: Latest current skill
}); });
it('returns empty array when skills are invalid/deleted or missing current version', async () => { it('returns empty array when skills are invalid/deleted or missing current version', async () => {
const teamId = new Types.ObjectId().toHexString(); const user = await getUser(`runtime-skill-empty-${getNanoid(6)}`);
const tmbId = new Types.ObjectId().toHexString(); const { teamId, tmbId } = user;
const sandbox = { const sandbox = {
writeFiles: vi.fn(), writeFiles: vi.fn(),
...@@ -909,6 +917,7 @@ description: Latest current skill ...@@ -909,6 +917,7 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [new Types.ObjectId().toHexString()], skillIds: [new Types.ObjectId().toHexString()],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
expect(resultNoSkills).toEqual([]); expect(resultNoSkills).toEqual([]);
...@@ -925,12 +934,14 @@ description: Latest current skill ...@@ -925,12 +934,14 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [String(skill._id)], skillIds: [String(skill._id)],
teamId, teamId,
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
expect(resultNoVersion).toEqual([]); expect(resultNoVersion).toEqual([]);
}); });
it('cleans stale version directories when no skills are selected', async () => { it('cleans stale version directories when no skills are selected', async () => {
const tmbId = new Types.ObjectId().toHexString();
const sandbox = { const sandbox = {
writeFiles: vi.fn(), writeFiles: vi.fn(),
execute: vi.fn(async (command: string) => { execute: vi.fn(async (command: string) => {
...@@ -956,6 +967,7 @@ description: Latest current skill ...@@ -956,6 +967,7 @@ description: Latest current skill
sandbox: sandbox as any, sandbox: sandbox as any,
skillIds: [], skillIds: [],
teamId: new Types.ObjectId().toHexString(), teamId: new Types.ObjectId().toHexString(),
tmbId,
workDirectory: '/workspace' workDirectory: '/workspace'
}); });
...@@ -1027,9 +1039,7 @@ description: Write reports ...@@ -1027,9 +1039,7 @@ description: Write reports
sandbox: sandbox as any sandbox: sandbox as any
}); });
expect(sandbox.execute).toHaveBeenCalledWith( expect(sandbox.execute).toHaveBeenCalledWith(WORKSPACE_SKILL_INFO_FIND_COMMAND);
`find '/workspace' -iname "SKILL.md" -print0 2>/dev/null`
);
expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/Report/SKILL.md']); expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/Report/SKILL.md']);
expect(skillInfos).toEqual([ expect(skillInfos).toEqual([
{ {
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { import {
createBlankSkillWorkspacePackage,
createSkillPackage, createSkillPackage,
validateDeployableSkillWorkspacePackage,
validateZipStructure, validateZipStructure,
extractSkillPackage, extractSkillPackage,
standardizeSkillPackageBySkillMdName standardizeSkillPackageBySkillMdName
...@@ -112,6 +114,24 @@ ${largeMarkdown}`; ...@@ -112,6 +114,24 @@ ${largeMarkdown}`;
}); });
}); });
// ==================== createBlankSkillWorkspacePackage ====================
describe('createBlankSkillWorkspacePackage', () => {
it('should create an initial blank workspace with .gitignore and empty skills directory', async () => {
const zipBuffer = await createBlankSkillWorkspacePackage();
expect(Buffer.isBuffer(zipBuffer)).toBe(true);
expect(zipBuffer.length).toBeGreaterThan(0);
const zip = await JSZip.loadAsync(zipBuffer);
const files = Object.keys(zip.files);
expect(files).toContain('.gitignore');
expect(files).toContain('skills/');
expect(files).not.toContain('skills/SKILL.md');
expect(files.some((file) => /^skills\/[^/]+\/SKILL\.md$/i.test(file))).toBe(false);
});
});
// ==================== validateZipStructure ==================== // ==================== validateZipStructure ====================
describe('validateZipStructure', () => { describe('validateZipStructure', () => {
it('should validate zip with SKILL.md at root', async () => { it('should validate zip with SKILL.md at root', async () => {
...@@ -175,6 +195,82 @@ ${largeMarkdown}`; ...@@ -175,6 +195,82 @@ ${largeMarkdown}`;
}); });
}); });
// ==================== validateDeployableSkillWorkspacePackage ====================
describe('validateDeployableSkillWorkspacePackage', () => {
it('should reject blank initial workspace during publish validation', async () => {
const buffer = await createBlankSkillWorkspacePackage();
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(false);
expect(result.error).toContain('at least one first-level skill folder');
});
it('should accept a workspace with at least one first-level skill folder containing SKILL.md', async () => {
const zip = new JSZip();
zip.file('.gitignore', 'node_modules/\n');
zip.file(
'skills/seo-title-generator/SKILL.md',
'---\nname: seo-title-generator\ndescription: SEO title generator\n---\n'
);
zip.file('skills/seo-title-generator/examples/input.md', '# Example');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(true);
});
it('should accept workspace entries prefixed by ./ from sandbox zip command', async () => {
const zip = new JSZip();
zip.file(
'./skills/seo-title-generator/SKILL.md',
'---\nname: seo-title-generator\ndescription: SEO title generator\n---\n'
);
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(true);
});
it('should reject when any first-level skill folder misses SKILL.md', async () => {
const zip = new JSZip();
zip.folder('skills/valid-skill');
zip.file('skills/valid-skill/SKILL.md', '---\nname: valid-skill\n---\n');
zip.folder('skills/missing-entry');
zip.file('skills/missing-entry/README.md', '# Missing entry');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(false);
expect(result.error).toContain('missing-entry');
});
it('should require uppercase SKILL.md for deployable skill folders', async () => {
const zip = new JSZip();
zip.file('skills/lowercase-entry/skill.md', '---\nname: lowercase-entry\n---\n');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(false);
expect(result.error).toContain('lowercase-entry');
});
it('should reject unsafe zip entry paths before workspace structure validation', async () => {
const zip = new JSZip();
zip.file('skills/valid-skill/SKILL.md', '---\nname: valid-skill\n---\n');
zip.file('../escape.txt', 'escape');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateDeployableSkillWorkspacePackage(buffer);
expect(result.valid).toBe(false);
expect(result.error).toContain('Unsafe ZIP entry path');
});
});
// ==================== extractSkillPackage ==================== // ==================== extractSkillPackage ====================
describe('extractSkillPackage', () => { describe('extractSkillPackage', () => {
it('should extract SKILL.md from zip root', async () => { it('should extract SKILL.md from zip root', async () => {
......
...@@ -343,6 +343,19 @@ describe('buildAgentUserReminderInput', () => { ...@@ -343,6 +343,19 @@ describe('buildAgentUserReminderInput', () => {
expect(result).toContain('执行这个技能'); expect(result).toContain('执行这个技能');
}); });
it('adds sandbox file write boundary reminder when current working directory exists', () => {
const result = buildAgentUserReminderInput({
query: '帮我生成一个编写小说的 skill',
currentWorkingDirectory: '/workspace'
});
expect(result).toContain('## Sandbox 文件写入边界');
expect(result).toContain('用户 Skill 产物根目录:/workspace/skills');
expect(result).toContain('/workspace/skills/<skill-name>/SKILL.md');
expect(result).toContain('禁止写入:/workspace/<skill-name>/ 或 /workspace/SKILL.md');
expect(result).toContain('/home/sandbox/.fastgpt/skills/');
});
it('escapes XML fields in skill metadata', () => { it('escapes XML fields in skill metadata', () => {
const result = buildAgentSkillsPrompt([ const result = buildAgentSkillsPrompt([
{ {
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
const {
getSandboxClientMock,
checkTeamSandboxPermissionMock,
pickOutboundAxiosGetMock,
injectAgentSkillFilesToSandboxMock,
getAgentSkillInfosMock,
runAgentSandboxEntrypointMock,
runAgentSkillVersionEntrypointsMock,
withAgentSandboxInitLeaseMock,
sandboxWriteFilesMock,
sandboxExecMock
} = vi.hoisted(() => ({
getSandboxClientMock: vi.fn(),
checkTeamSandboxPermissionMock: vi.fn(),
pickOutboundAxiosGetMock: vi.fn(),
injectAgentSkillFilesToSandboxMock: vi.fn(),
getAgentSkillInfosMock: vi.fn(),
runAgentSandboxEntrypointMock: vi.fn(),
runAgentSkillVersionEntrypointsMock: vi.fn(),
withAgentSandboxInitLeaseMock: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn()),
sandboxWriteFilesMock: vi.fn(),
sandboxExecMock: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', () => ({
getSandboxClient: getSandboxClientMock
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/profile', () => ({
getSandboxRuntimeProfile: () => ({
workDirectory: '/workspace'
})
}));
vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
checkTeamSandboxPermission: checkTeamSandboxPermissionMock
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
pickOutboundAxios: () => ({
get: pickOutboundAxiosGetMock
})
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime', () => ({
injectAgentSkillFilesToSandbox: injectAgentSkillFilesToSandboxMock,
getAgentSkillInfos: getAgentSkillInfosMock
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
runAgentSkillVersionEntrypoints: runAgentSkillVersionEntrypointsMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
}));
describe('ensureAgentSandboxRuntime', () => {
beforeEach(() => {
vi.clearAllMocks();
checkTeamSandboxPermissionMock.mockResolvedValue(undefined);
pickOutboundAxiosGetMock.mockResolvedValue({ data: new ArrayBuffer(1) });
sandboxWriteFilesMock.mockResolvedValue(undefined);
sandboxExecMock.mockResolvedValue({
exitCode: 0,
stdout: '/workspace\n',
stderr: ''
});
getSandboxClientMock.mockResolvedValue({
provider: {
writeFiles: sandboxWriteFilesMock
},
exec: sandboxExecMock,
getSandboxId: () => 'sandbox_1'
});
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
{
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
]);
getAgentSkillInfosMock.mockResolvedValue([
{
id: '/workspace/projects/version_1/SKILL.md',
name: 'Report',
description: 'Write reports',
directory: '/workspace/projects/version_1',
skillMdPath: '/workspace/projects/version_1/SKILL.md'
}
]);
});
it('initializes runtime sandbox once and scans deployed skill directories', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/runtime');
const sandboxBootstrapMock = vi.fn(async () => undefined);
let bootstrapReadyBeforeSkillInject = false;
injectAgentSkillFilesToSandboxMock.mockImplementationOnce(async () => {
bootstrapReadyBeforeSkillInject = sandboxBootstrapMock.mock.calls.length > 0;
return [
{
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
];
});
const result = await ensureAgentSandboxRuntime({
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1',
teamId: 'team_1',
tmbId: 'tmb_1',
needSandboxRuntime: true,
sandboxBootstrap: sandboxBootstrapMock,
sandboxEntrypoint: 'pip install -r requirements.txt',
skillIds: ['skill_1'],
currentFiles: [
{
id: 'file_1',
name: 'current.pdf',
type: ChatFileTypeEnum.file,
url: 'https://files/current.pdf'
},
{
id: 'file_2',
name: '../current.pdf',
type: ChatFileTypeEnum.file,
url: 'https://files/unsafe-current.pdf'
},
{
id: 'file_3',
name: 'folder/report.txt',
type: ChatFileTypeEnum.file,
url: 'https://files/report.txt'
},
{
id: 'file_4',
name: '..',
type: ChatFileTypeEnum.file,
url: 'https://files/nameless'
}
]
});
expect(checkTeamSandboxPermissionMock).toHaveBeenCalledWith('team_1');
expect(withAgentSandboxInitLeaseMock).toHaveBeenCalledWith({
sandboxId: 'sandbox_1',
fn: expect.any(Function)
});
expect(sandboxBootstrapMock).toHaveBeenCalledWith({
sandboxClient: expect.any(Object),
sandbox: expect.any(Object),
workDirectory: '/workspace'
});
expect(bootstrapReadyBeforeSkillInject).toBe(true);
expect(injectAgentSkillFilesToSandboxMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillIds: ['skill_1'],
teamId: 'team_1',
tmbId: 'tmb_1',
workDirectory: '/workspace'
});
expect(sandboxWriteFilesMock).toHaveBeenCalledWith([
{
path: 'user_files/current.pdf',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/current-1.pdf',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/report.txt',
data: expect.any(ArrayBuffer)
},
{
path: 'user_files/file-3',
data: expect.any(ArrayBuffer)
}
]);
expect(runAgentSandboxEntrypointMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
sandboxEntrypoint: 'pip install -r requirements.txt',
workDirectory: '/workspace'
});
expect(runAgentSkillVersionEntrypointsMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
versions: [{ versionId: 'version_1', targetDir: '/workspace/projects/version_1' }]
});
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillDirectories: ['/workspace/projects/version_1']
});
expect(result.currentWorkingDirectory).toBe('/workspace');
expect(result.skillInfos).toHaveLength(1);
});
});
...@@ -29,7 +29,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => { ...@@ -29,7 +29,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => {
}; };
}); });
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock, runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
})); }));
......
...@@ -199,7 +199,7 @@ ...@@ -199,7 +199,7 @@
"sandbox_status_failed": "Sandbox creation failed", "sandbox_status_failed": "Sandbox creation failed",
"sandbox_status_failed_with_message": "Sandbox creation failed: {{message}}", "sandbox_status_failed_with_message": "Sandbox creation failed: {{message}}",
"sandbox_status_fetchSkills": "Fetching skill metadata...", "sandbox_status_fetchSkills": "Fetching skill metadata...",
"sandbox_status_lazyInit": "Starting virtual machine...", "sandbox_status_lazyInit": "Virtual machine is running...",
"sandbox_status_ready_cold": "Sandbox is ready", "sandbox_status_ready_cold": "Sandbox is ready",
"sandbox_status_ready_warm": "Sandbox is ready (warm start)", "sandbox_status_ready_warm": "Sandbox is ready (warm start)",
"sandbox_status_uploadingPackage": "Uploading skill package to sandbox...", "sandbox_status_uploadingPackage": "Uploading skill package to sandbox...",
......
...@@ -177,11 +177,9 @@ ...@@ -177,11 +177,9 @@
"code_error.skill_error.invalid_package": "Invalid skill package structure", "code_error.skill_error.invalid_package": "Invalid skill package structure",
"code_error.skill_error.invalid_skill_id": "Invalid skill ID", "code_error.skill_error.invalid_skill_id": "Invalid skill ID",
"code_error.skill_error.missing_image_repository": "image.repository is required when image is provided", "code_error.skill_error.missing_image_repository": "image.repository is required when image is provided",
"code_error.skill_error.missing_model": "Model is required when requirements is provided",
"code_error.skill_error.no_fields_to_update": "No fields to update", "code_error.skill_error.no_fields_to_update": "No fields to update",
"code_error.skill_error.no_storage": "Skill has no storage, cannot copy", "code_error.skill_error.no_storage": "Skill has no storage, cannot copy",
"code_error.skill_error.not_exist": "Skill Does Not Exist", "code_error.skill_error.not_exist": "Skill Does Not Exist",
"code_error.skill_error.requirements_too_long": "Requirements must be less than 8000 characters",
"code_error.skill_error.skill_name_too_long": "Skill name must be 50 characters or fewer", "code_error.skill_error.skill_name_too_long": "Skill name must be 50 characters or fewer",
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill", "code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.", "code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
......
...@@ -16,10 +16,6 @@ ...@@ -16,10 +16,6 @@
"skill_avatar_and_name": "Avatar & Name", "skill_avatar_and_name": "Avatar & Name",
"skill_intro_label": "App Introduction", "skill_intro_label": "App Introduction",
"skill_intro_placeholder": "Describe use cases and access paths", "skill_intro_placeholder": "Describe use cases and access paths",
"skill_requirement_label": "Skill Requirements (used to auto-generate SKILL.md)",
"skill_requirement_tooltip_title": "Example:",
"skill_requirement_tooltip_example": "## Goal\nAutomatically generate meeting minutes from meeting notes.\n\n## Process\n1. Identify the meeting topic and participants\n2. Extract key discussion points\n3. Organize clear conclusions and decisions\n4. Extract follow-up action items and note the responsible person (if any)\n\n## Requirements\n1. Output in structured format\n2. Include: meeting topic, participants, discussion points, decisions, action items\n3. Content should be concise and clear, avoiding redundancy",
"skill_requirement_default": "## Goal\n\n## Process\n\n## Requirements",
"import_skill": "Import Skill", "import_skill": "Import Skill",
"import_skill_select_file": "Select a Skill archive", "import_skill_select_file": "Select a Skill archive",
"import_skill_file_type_tip": "Supports {{ext}} formats", "import_skill_file_type_tip": "Supports {{ext}} formats",
......
...@@ -199,7 +199,7 @@ ...@@ -199,7 +199,7 @@
"sandbox_status_failed": "沙箱创建失败", "sandbox_status_failed": "沙箱创建失败",
"sandbox_status_failed_with_message": "沙箱创建失败:{{message}}", "sandbox_status_failed_with_message": "沙箱创建失败:{{message}}",
"sandbox_status_fetchSkills": "正在获取技能信息...", "sandbox_status_fetchSkills": "正在获取技能信息...",
"sandbox_status_lazyInit": "虚拟机启动中...", "sandbox_status_lazyInit": "虚拟机运行中...",
"sandbox_status_ready_cold": "沙箱环境就绪", "sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)", "sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_uploadingPackage": "正在上传技能包到沙箱...", "sandbox_status_uploadingPackage": "正在上传技能包到沙箱...",
......
...@@ -177,11 +177,9 @@ ...@@ -177,11 +177,9 @@
"code_error.skill_error.invalid_package": "无效的技能包结构", "code_error.skill_error.invalid_package": "无效的技能包结构",
"code_error.skill_error.invalid_skill_id": "无效的技能 ID", "code_error.skill_error.invalid_skill_id": "无效的技能 ID",
"code_error.skill_error.missing_image_repository": "提供 image 时必须指定 image.repository", "code_error.skill_error.missing_image_repository": "提供 image 时必须指定 image.repository",
"code_error.skill_error.missing_model": "提供需求描述时必须指定 model",
"code_error.skill_error.no_fields_to_update": "没有需要更新的字段", "code_error.skill_error.no_fields_to_update": "没有需要更新的字段",
"code_error.skill_error.no_storage": "技能没有存储,无法复制", "code_error.skill_error.no_storage": "技能没有存储,无法复制",
"code_error.skill_error.not_exist": "技能不存在", "code_error.skill_error.not_exist": "技能不存在",
"code_error.skill_error.requirements_too_long": "需求描述不能超过 8000 字符",
"code_error.skill_error.skill_name_too_long": "技能名称不能超过 50 字符", "code_error.skill_error.skill_name_too_long": "技能名称不能超过 50 字符",
"code_error.skill_error.un_auth_skill": "无权操作该技能", "code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。", "code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"search_skill": "搜索", "search_skill": "搜索",
"create_skill": "新建技能", "create_skill": "新建技能",
"create_your_first_skill": "创建你的第一个技能", "create_your_first_skill": "创建你的第一个技能",
"no_skills": "暂无技能", "no_skills": "暂无 Skill",
"copy_skill": "创建副本", "copy_skill": "创建副本",
"confirm_delete_title": "确定删除该技能吗?", "confirm_delete_title": "确定删除该技能吗?",
"confirm_delete_with_refs": "该技能当前正被<bold>{{count}}个应用</bold>引用。删除后,相关应用将<bold>无法调用此技能</bold>。建议先解除关联或备份配置。", "confirm_delete_with_refs": "该技能当前正被<bold>{{count}}个应用</bold>引用。删除后,相关应用将<bold>无法调用此技能</bold>。建议先解除关联或备份配置。",
...@@ -12,14 +12,10 @@ ...@@ -12,14 +12,10 @@
"permission_settings": "权限设置", "permission_settings": "权限设置",
"export_config": "导出配置", "export_config": "导出配置",
"unnamed_skill": "未命名", "unnamed_skill": "未命名",
"skill_name_placeholder": "请输入技能名称", "skill_name_placeholder": "请输入 Skill 名称",
"skill_avatar_and_name": "头像 & 名称", "skill_avatar_and_name": "头像 & 名称",
"skill_intro_label": "应用介绍", "skill_intro_label": "应用介绍",
"skill_intro_placeholder": "介绍使用场景及途径", "skill_intro_placeholder": "介绍使用场景及途径",
"skill_requirement_label": "技能需求(用于智能生成技能说明文件)",
"skill_requirement_tooltip_title": "示例:",
"skill_requirement_tooltip_example": "## 目标\n根据会议记录自动生成会议纪要。\n\n## 流程\n1. 识别会议主题和参与人员\n2. 提取讨论的关键要点\n3. 整理出明确的结论和决策\n4. 提取需要跟进的行动项,并标注负责人(如有)\n\n## 要求\n1. 结果以结构化格式输出\n2. 包含:会议主题、参与人、讨论要点、决策结论、行动项\n3. 内容简洁清晰,避免冗余描述",
"skill_requirement_default": "## 目标\n\n## 流程\n\n## 要求",
"import_skill": "导入技能", "import_skill": "导入技能",
"import_skill_select_file": "上传技能", "import_skill_select_file": "上传技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式", "import_skill_file_type_tip": "支持 {{ext}} 格式",
...@@ -41,43 +37,43 @@ ...@@ -41,43 +37,43 @@
"deploy_failed": "发布失败", "deploy_failed": "发布失败",
"copy_skill_confirm": "系统将为您创建一个相同配置技能,但权限不会进行复制,请确认!", "copy_skill_confirm": "系统将为您创建一个相同配置技能,但权限不会进行复制,请确认!",
"history_versions": "历史版本", "history_versions": "历史版本",
"select_skill": "选择技能", "select_skill": "选择 Skill",
"associated_skills": "关联技能", "associated_skills": "关联 Skill",
"skill_deleted": "技能已删除", "skill_deleted": "技能已删除",
"skill_deleted_click_remove_tip": "技能已删除,点击删除", "skill_deleted_click_remove_tip": "技能已删除,点击删除",
"skill_select_limit_tip": "已达到单个应用可关联技能的上限(100 个)", "skill_select_limit_tip": "已达到单个应用可关联 Skill 的上限(100 个)",
"sandbox_auto_enabled_for_skill": "技能运行依赖虚拟机环境,已为你打开虚拟机功能", "sandbox_auto_enabled_for_skill": "skill运行依赖虚拟机环境,已为你打开虚拟机功能",
"sandbox_disable_blocked_toast": "技能运行依赖虚拟机环境,当前 Agent 已配置技能,请先移除所有技能再关闭虚拟机", "sandbox_disable_blocked_toast": "Skill运行依赖虚拟机环境,当前 Agent 已配置 Skill,请先移除所有 Skill 再关闭虚拟机",
"sandbox_system_not_configured_toast": "当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。", "sandbox_system_not_configured_toast": "当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。",
"sandbox_skill_system_not_configured_toast": "技能运行依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。", "sandbox_skill_system_not_configured_toast": "skill运行依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。",
"sandbox_operation_system_not_configured_title": "未配置虚拟机", "sandbox_operation_system_not_configured_title": "未配置虚拟机",
"sandbox_operation_system_not_configured_content": "技能操作依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。", "sandbox_operation_system_not_configured_content": "skill操作依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。",
"sandbox_plan_not_supported_title": "套餐不支持功能", "sandbox_plan_not_supported_title": "套餐不支持功能",
"sandbox_skill_plan_not_supported_content": "技能运行依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。", "sandbox_skill_plan_not_supported_content": "skill运行依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_operation_plan_not_supported_content": "技能操作依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。", "sandbox_operation_plan_not_supported_content": "skill操作依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_upgrade_action": "去升级", "sandbox_upgrade_action": "去升级",
"sandbox_unavailable_tag": "不可用", "sandbox_unavailable_tag": "不可用",
"sandbox_skill_unavailable_toast": "技能运行依赖虚拟机环境,当前 Agent 已配置技能,请先移除所有技能再关闭虚拟机", "sandbox_skill_unavailable_toast": "Skill运行依赖虚拟机环境,当前 Agent 已配置 Skill,请先移除所有 Skill 再关闭虚拟机",
"sandbox_checking": "正在检查现有沙箱环境...", "sandbox_checking": "正在检查现有沙箱环境...",
"sandbox_connecting": "正在连接沙箱环境...", "sandbox_connecting": "正在连接沙箱环境...",
"sandbox_fetch_skills": "正在获取技能配置信息...", "sandbox_fetch_skills": "正在获取 Skill 配置信息...",
"sandbox_creating_container": "正在初始化云端沙箱...", "sandbox_creating_container": "正在初始化云端沙箱...",
"sandbox_deploying_skills": "正在部署技能: {{skillName}}...", "sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...",
"sandbox_downloading": "正在下载技能包...", "sandbox_downloading": "正在下载 Skill 包...",
"sandbox_uploading": "正在上传技能包到沙箱...", "sandbox_uploading": "正在上传 Skill 包到沙箱...",
"sandbox_extracting": "正在解压技能包...", "sandbox_extracting": "正在解压 Skill 包...",
"sandbox_lazy_init": "正在初始化运行环境...", "sandbox_lazy_init": "正在初始化运行环境...",
"sandbox_ready": "沙箱环境就绪", "sandbox_ready": "沙箱环境就绪",
"sandbox_ready_warm": "沙箱环境就绪(热启动)", "sandbox_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_failed": "沙箱创建失败: {{message}}", "sandbox_failed": "沙箱创建失败: {{message}}",
"sandbox_retry": "重试", "sandbox_retry": "重试",
"sandbox_error_title": "沙箱创建失败", "sandbox_error_title": "沙箱创建失败",
"no_current_version": "技能暂无可用版本,请重新创建或导入后再编辑。", "no_current_version": "Skill 暂无可用版本,请重新创建或导入后再编辑。",
"permission.des.read": "可查看 Agent 技能", "permission.des.read": "可查看 Agent Skill",
"permission.des.write": "可编辑 Agent 技能", "permission.des.write": "可编辑 Agent Skill",
"permission.des.manage": "可管理 Agent 技能和协作者", "permission.des.manage": "可管理 Agent Skill 和协作者",
"empty_state_tip": "告诉 AI 如何修改技能,\n或让 AI 运行技能查看效果", "empty_state_tip": "告诉 AI 如何修改 Skill,\n或让 AI 运行 Skill 查看效果",
"empty_state_community_prefix": "通过对话预览技能效果,", "empty_state_community_prefix": "通过对话预览 Skill 效果,",
"empty_state_community_upgrade": "升级商业版", "empty_state_community_upgrade": "升级商业版",
"empty_state_community_suffix": "可使用 AI 生成技能。" "empty_state_community_suffix": "可使用 AI 生成 Skill。"
} }
...@@ -196,7 +196,7 @@ ...@@ -196,7 +196,7 @@
"sandbox_status_failed": "沙箱創建失敗", "sandbox_status_failed": "沙箱創建失敗",
"sandbox_status_failed_with_message": "沙箱創建失敗:{{message}}", "sandbox_status_failed_with_message": "沙箱創建失敗:{{message}}",
"sandbox_status_fetchSkills": "正在獲取技能資訊...", "sandbox_status_fetchSkills": "正在獲取技能資訊...",
"sandbox_status_lazyInit": "虛擬機啟動中...", "sandbox_status_lazyInit": "虛擬機運行中...",
"sandbox_status_ready_cold": "沙箱環境就緒", "sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)", "sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...", "sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...",
......
...@@ -175,11 +175,9 @@ ...@@ -175,11 +175,9 @@
"code_error.skill_error.invalid_package": "無效的技能包結構", "code_error.skill_error.invalid_package": "無效的技能包結構",
"code_error.skill_error.invalid_skill_id": "無效的技能 ID", "code_error.skill_error.invalid_skill_id": "無效的技能 ID",
"code_error.skill_error.missing_image_repository": "提供 image 時必須指定 image.repository", "code_error.skill_error.missing_image_repository": "提供 image 時必須指定 image.repository",
"code_error.skill_error.missing_model": "提供需求描述時必須指定 model",
"code_error.skill_error.no_fields_to_update": "沒有需要更新的欄位", "code_error.skill_error.no_fields_to_update": "沒有需要更新的欄位",
"code_error.skill_error.no_storage": "技能沒有存儲,無法複製", "code_error.skill_error.no_storage": "技能沒有存儲,無法複製",
"code_error.skill_error.not_exist": "技能不存在", "code_error.skill_error.not_exist": "技能不存在",
"code_error.skill_error.requirements_too_long": "需求描述不能超過 8000 字元",
"code_error.skill_error.skill_name_too_long": "技能名稱不能超過 50 字元", "code_error.skill_error.skill_name_too_long": "技能名稱不能超過 50 字元",
"code_error.skill_error.un_auth_skill": "無權操作該技能", "code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。", "code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"search_skill": "搜尋", "search_skill": "搜尋",
"create_skill": "新建技能", "create_skill": "新建技能",
"create_your_first_skill": "創建你的第一個技能", "create_your_first_skill": "創建你的第一個技能",
"no_skills": "暫無技能", "no_skills": "暫無 Skill",
"copy_skill": "建立副本", "copy_skill": "建立副本",
"confirm_delete_title": "確定刪除該技能嗎?", "confirm_delete_title": "確定刪除該技能嗎?",
"confirm_delete_with_refs": "該技能當前正被<bold>{{count}}個應用</bold>引用。刪除後,相關應用將<bold>無法調用此技能</bold>。建議先解除關聯或備份配置。", "confirm_delete_with_refs": "該技能當前正被<bold>{{count}}個應用</bold>引用。刪除後,相關應用將<bold>無法調用此技能</bold>。建議先解除關聯或備份配置。",
...@@ -12,22 +12,18 @@ ...@@ -12,22 +12,18 @@
"permission_settings": "權限設置", "permission_settings": "權限設置",
"export_config": "導出配置", "export_config": "導出配置",
"unnamed_skill": "未命名", "unnamed_skill": "未命名",
"skill_name_placeholder": "請輸入技能名稱", "skill_name_placeholder": "請輸入 Skill 名稱",
"skill_avatar_and_name": "頭像 & 名稱", "skill_avatar_and_name": "頭像 & 名稱",
"skill_intro_label": "應用介紹", "skill_intro_label": "應用介紹",
"skill_intro_placeholder": "介紹使用場景及途徑", "skill_intro_placeholder": "介紹使用場景及途徑",
"skill_requirement_label": "技能需求(用於智能生成技能說明文件)",
"skill_requirement_tooltip_title": "示例:",
"skill_requirement_tooltip_example": "## 目標\n根據會議記錄自動生成會議紀要。\n\n## 流程\n1. 識別會議主題和參與人員\n2. 提取討論的關鍵要點\n3. 整理出明確的結論和決策\n4. 提取需要跟進的行動項,並標注負責人(如有)\n\n## 要求\n1. 結果以結構化格式輸出\n2. 包含:會議主題、參與人、討論要點、決策結論、行動項\n3. 內容簡潔清晰,避免冗余描述",
"skill_requirement_default": "## 目標\n\n## 流程\n\n## 要求",
"import_skill": "導入技能", "import_skill": "導入技能",
"import_skill_select_file": "上傳技能", "import_skill_select_file": "上傳技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式", "import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}", "import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}",
"unsupported_file_format": "不支持 {{ext}} 文件格式", "unsupported_file_format": "不支持 {{ext}} 文件格式",
"skill_info_edit": "技能資訊編輯", "skill_info_edit": "技能資訊編輯",
"move_skill": "移動技能", "move_skill": "移動 Skill",
"move_skill_hint": "移動後,所選技能/文件夾將繼承新文件夾的權限設置。", "move_skill_hint": "移動後,所選 Skill/文件夾將繼承新文件夾的權限設置。",
"delete_success": "刪除成功", "delete_success": "刪除成功",
"delete_failed": "刪除失敗", "delete_failed": "刪除失敗",
"copy_success": "複製成功", "copy_success": "複製成功",
...@@ -41,43 +37,43 @@ ...@@ -41,43 +37,43 @@
"deploy_failed": "發布失敗", "deploy_failed": "發布失敗",
"copy_skill_confirm": "系統將為您創建一個相同配置技能,但權限不會進行複製,請確認!", "copy_skill_confirm": "系統將為您創建一個相同配置技能,但權限不會進行複製,請確認!",
"history_versions": "歷史版本", "history_versions": "歷史版本",
"select_skill": "選擇技能", "select_skill": "選擇 Skill",
"associated_skills": "關聯技能", "associated_skills": "關聯 Skill",
"skill_deleted": "技能已刪除", "skill_deleted": "技能已刪除",
"skill_deleted_click_remove_tip": "技能已刪除,點擊刪除", "skill_deleted_click_remove_tip": "技能已刪除,點擊刪除",
"skill_select_limit_tip": "已達到單個應用可關聯技能的上限(100 個)", "skill_select_limit_tip": "已達到單個應用可關聯 Skill 的上限(100 個)",
"sandbox_auto_enabled_for_skill": "技能運行依賴虛擬機器環境,已為你開啟虛擬機器功能", "sandbox_auto_enabled_for_skill": "skill運行依賴虛擬機器環境,已為你開啟虛擬機器功能",
"sandbox_disable_blocked_toast": "技能運行依賴虛擬機器環境,目前 Agent 已配置技能,請先移除所有技能再關閉虛擬機器", "sandbox_disable_blocked_toast": "Skill運行依賴虛擬機器環境,目前 Agent 已配置 Skill,請先移除所有 Skill 再關閉虛擬機器",
"sandbox_system_not_configured_toast": "目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。", "sandbox_system_not_configured_toast": "目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。",
"sandbox_skill_system_not_configured_toast": "技能運行依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。", "sandbox_skill_system_not_configured_toast": "skill運行依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。",
"sandbox_operation_system_not_configured_title": "未配置虛擬機器", "sandbox_operation_system_not_configured_title": "未配置虛擬機器",
"sandbox_operation_system_not_configured_content": "技能操作依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。", "sandbox_operation_system_not_configured_content": "skill操作依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。",
"sandbox_plan_not_supported_title": "套餐不支援功能", "sandbox_plan_not_supported_title": "套餐不支援功能",
"sandbox_skill_plan_not_supported_content": "技能運行依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。", "sandbox_skill_plan_not_supported_content": "skill運行依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_operation_plan_not_supported_content": "技能操作依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。", "sandbox_operation_plan_not_supported_content": "skill操作依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_upgrade_action": "去升級", "sandbox_upgrade_action": "去升級",
"sandbox_unavailable_tag": "不可用", "sandbox_unavailable_tag": "不可用",
"sandbox_skill_unavailable_toast": "技能運行依賴虛擬機器環境,目前 Agent 已配置技能,請先移除所有技能再關閉虛擬機器", "sandbox_skill_unavailable_toast": "Skill運行依賴虛擬機器環境,目前 Agent 已配置 Skill,請先移除所有 Skill 再關閉虛擬機器",
"sandbox_checking": "正在檢查現有沙箱環境...", "sandbox_checking": "正在檢查現有沙箱環境...",
"sandbox_connecting": "正在連接沙箱環境...", "sandbox_connecting": "正在連接沙箱環境...",
"sandbox_fetch_skills": "正在獲取技能配置資訊...", "sandbox_fetch_skills": "正在獲取 Skill 配置資訊...",
"sandbox_creating_container": "正在初始化雲端沙箱...", "sandbox_creating_container": "正在初始化雲端沙箱...",
"sandbox_deploying_skills": "正在部署技能: {{skillName}}...", "sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...",
"sandbox_downloading": "正在下載技能包...", "sandbox_downloading": "正在下載 Skill 包...",
"sandbox_uploading": "正在上傳技能包到沙箱...", "sandbox_uploading": "正在上傳 Skill 包到沙箱...",
"sandbox_extracting": "正在解壓技能包...", "sandbox_extracting": "正在解壓 Skill 包...",
"sandbox_lazy_init": "正在初始化運行環境...", "sandbox_lazy_init": "正在初始化運行環境...",
"sandbox_ready": "沙箱環境就緒", "sandbox_ready": "沙箱環境就緒",
"sandbox_ready_warm": "沙箱環境就緒(熱啟動)", "sandbox_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_failed": "沙箱創建失敗: {{message}}", "sandbox_failed": "沙箱創建失敗: {{message}}",
"sandbox_retry": "重試", "sandbox_retry": "重試",
"sandbox_error_title": "沙箱創建失敗", "sandbox_error_title": "沙箱創建失敗",
"no_current_version": "技能暫無可用版本,請重新建立或匯入後再編輯。", "no_current_version": "Skill 暫無可用版本,請重新建立或匯入後再編輯。",
"permission.des.read": "可查看 Agent 技能", "permission.des.read": "可查看 Agent Skill",
"permission.des.write": "可編輯 Agent 技能", "permission.des.write": "可編輯 Agent Skill",
"permission.des.manage": "可管理 Agent 技能和協作者", "permission.des.manage": "可管理 Agent Skill 和協作者",
"empty_state_tip": "告訴 AI 如何修改技能,\n或讓 AI 運行技能查看效果", "empty_state_tip": "告訴 AI 如何修改 Skill,\n或讓 AI 運行 Skill 查看效果",
"empty_state_community_prefix": "透過對話預覽技能效果,", "empty_state_community_prefix": "透過對話預覽 Skill 效果,",
"empty_state_community_upgrade": "升級商業版", "empty_state_community_upgrade": "升級商業版",
"empty_state_community_suffix": "可使用 AI 生成技能。" "empty_state_community_suffix": "可使用 AI 生成 Skill。"
} }
Subproject commit 1d3699fa547f04ed2e30df5e6c459fd989bb7872 Subproject commit 9a950b77868f43a056f996ee78ab0099f1de12a2
...@@ -5,8 +5,6 @@ import MyModal from '@fastgpt/web/components/v2/common/MyModal'; ...@@ -5,8 +5,6 @@ import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar'; import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
...@@ -20,7 +18,6 @@ type FormType = { ...@@ -20,7 +18,6 @@ type FormType = {
avatar: string; avatar: string;
name: string; name: string;
intro?: string; intro?: string;
requirement: string;
}; };
type Props = { type Props = {
...@@ -36,13 +33,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => { ...@@ -36,13 +33,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
defaultValues: { defaultValues: {
avatar: DEFAULT_SKILL_AVATAR, avatar: DEFAULT_SKILL_AVATAR,
name: '', name: '',
intro: '', intro: ''
requirement: t('skill:skill_requirement_default')
} }
}); });
const avatar = useWatch({ control, name: 'avatar' }); const avatar = useWatch({ control, name: 'avatar' });
const requirement = useWatch({ control, name: 'requirement' });
const { Component: AvatarUploader, handleFileSelectorOpen: handleAvatarSelectorOpen } = const { Component: AvatarUploader, handleFileSelectorOpen: handleAvatarSelectorOpen } =
useUploadAvatar(getUploadAvatarPresignedUrl, { useUploadAvatar(getUploadAvatarPresignedUrl, {
...@@ -52,19 +47,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => { ...@@ -52,19 +47,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
}); });
const { runAsync: onCreate, loading: isCreating } = useRequest( const { runAsync: onCreate, loading: isCreating } = useRequest(
async ({ avatar, name, intro, requirement }: FormType) => { async ({ avatar, name, intro }: FormType) => {
const trimmedRequirement = requirement.trim();
const defaultRequirement = t('skill:skill_requirement_default').trim();
const resolvedRequirement =
trimmedRequirement && trimmedRequirement !== defaultRequirement
? trimmedRequirement
: undefined;
return postCreateSkill({ return postCreateSkill({
parentId: parentId ?? null, parentId: parentId ?? null,
name: name.trim(), name: name.trim(),
description: intro?.trim() || undefined, description: intro?.trim() || undefined,
requirements: resolvedRequirement,
avatar: avatar || undefined avatar: avatar || undefined
}); });
}, },
...@@ -140,56 +127,6 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => { ...@@ -140,56 +127,6 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
resize={'vertical'} resize={'vertical'}
/> />
</Box> </Box>
{/* Skill 需求 */}
<Box>
<Flex alignItems={'center'} mb={2}>
<FormLabel>
<Box as="span" color={'red.600'} mr={0.5}>
*
</Box>
{t('skill:skill_requirement_label')}
</FormLabel>
<MyPopover
trigger={'hover'}
placement={'right-start'}
hasArrow={false}
p={0}
w={'320px'}
Trigger={
<Box ml={1} display={'inline-flex'} alignItems={'center'} cursor={'default'}>
<MyIcon name={'help' as any} w={'16px'} color={'myGray.500'} />
</Box>
}
>
{() => (
<Box p={'12px'}>
<Box fontSize={'xs'} color={'#333'} mb={2}>
{t('skill:skill_requirement_tooltip_title')}
</Box>
<Box
fontSize={'xs'}
color={'#333'}
border={'1px solid #E8EBF0'}
borderRadius={'4px'}
p={'10px'}
whiteSpace={'pre-wrap'}
cursor={'default'}
>
{t('skill:skill_requirement_tooltip_example')}
</Box>
</Box>
)}
</MyPopover>
</Flex>
<Textarea
value={requirement}
onChange={(e) => setValue('requirement', e.target.value)}
h={'150px'}
minH={'150px'}
resize={'vertical'}
/>
</Box>
</Flex> </Flex>
</MyModal> </MyModal>
<AvatarUploader /> <AvatarUploader />
......
...@@ -10,7 +10,7 @@ import { getLogger } from '@fastgpt/service/common/logger'; ...@@ -10,7 +10,7 @@ import { getLogger } from '@fastgpt/service/common/logger';
import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import { getConfiguredSandboxProvider } from '@fastgpt/service/core/ai/sandbox/provider/config'; import { getConfiguredSandboxProvider } from '@fastgpt/service/core/ai/sandbox/provider/config';
import type { SandboxProviderType } from '@fastgpt/service/core/ai/sandbox/type'; import type { SandboxProviderType } from '@fastgpt/service/core/ai/sandbox/type';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import z from 'zod'; import z from 'zod';
......
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
type SandboxCheckExistResponse type SandboxCheckExistResponse
} from '@fastgpt/global/openapi/core/ai/sandbox/api'; } from '@fastgpt/global/openapi/core/ai/sandbox/api';
import { EDIT_DEBUG_SANDBOX_CHAT_ID } from '@fastgpt/service/core/ai/skill/edit/config'; import { EDIT_DEBUG_SANDBOX_CHAT_ID } from '@fastgpt/service/core/ai/skill/edit/config';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
async function handler(req: ApiRequestProps): Promise<SandboxCheckExistResponse> { async function handler(req: ApiRequestProps): Promise<SandboxCheckExistResponse> {
if (!global.feConfigs?.show_agent_sandbox) { if (!global.feConfigs?.show_agent_sandbox) {
......
...@@ -29,7 +29,6 @@ import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar'; ...@@ -29,7 +29,6 @@ import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar';
import type { ApiRequestProps } from '@fastgpt/service/type/next'; import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import { getSkillCreationLLMModel } from '@fastgpt/service/core/ai/model';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION); const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION);
...@@ -39,14 +38,12 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki ...@@ -39,14 +38,12 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
parentId, parentId,
name, name,
description, description,
requirements,
category = [], category = [],
avatar avatar
} = parseApiInput({ req, bodySchema: CreateSkillBodySchema }).body; } = parseApiInput({ req, bodySchema: CreateSkillBodySchema }).body;
const requestedName = name.trim(); const requestedName = name.trim();
const requestedDescription = description?.trim() || ''; const requestedDescription = description?.trim() || '';
const requestedRequirements = requirements?.trim() || undefined;
// Authenticate user: if parentId exists, verify parent folder permission // Authenticate user: if parentId exists, verify parent folder permission
const { teamId, tmbId } = parentId const { teamId, tmbId } = parentId
...@@ -74,13 +71,6 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki ...@@ -74,13 +71,6 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
if (requestedDescription.length > 500) { if (requestedDescription.length > 500) {
return Promise.reject(SkillErrEnum.invalidDescription); return Promise.reject(SkillErrEnum.invalidDescription);
} }
if (requestedRequirements && !getSkillCreationLLMModel()) {
return Promise.reject(SkillErrEnum.missingModel);
}
if (requestedRequirements && requestedRequirements.length > 8000) {
return Promise.reject(SkillErrEnum.requirementsTooLong);
}
const validCategories = Object.values(AgentSkillCategoryEnum) as string[]; const validCategories = Object.values(AgentSkillCategoryEnum) as string[];
if (category.length > 0 && category.some((c) => !validCategories.includes(c))) { if (category.length > 0 && category.some((c) => !validCategories.includes(c))) {
return Promise.reject(SkillErrEnum.invalidCategory); return Promise.reject(SkillErrEnum.invalidCategory);
...@@ -98,8 +88,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki ...@@ -98,8 +88,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
avatar, avatar,
teamId, teamId,
tmbId, tmbId,
creationStatus: AgentSkillCreationStatusEnum.creating, creationStatus: AgentSkillCreationStatusEnum.creating
creationPayload: requestedRequirements ? { requirements: requestedRequirements } : undefined
}, },
session session
); );
...@@ -123,10 +112,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki ...@@ -123,10 +112,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
const createJobData = { const createJobData = {
skillId, skillId,
teamId, teamId,
tmbId, tmbId
name: requestedName,
description: requestedDescription,
requirements: requestedRequirements
}; };
try { try {
......
...@@ -4,7 +4,7 @@ import { authSkill } from '@fastgpt/service/support/permission/skill/auth'; ...@@ -4,7 +4,7 @@ import { authSkill } from '@fastgpt/service/support/permission/skill/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { ExportSkillQuerySchema } from '@fastgpt/global/openapi/core/ai/skill/api'; import { ExportSkillQuerySchema } from '@fastgpt/global/openapi/core/ai/skill/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { AgentSkillTypeEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { AgentSkillTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { addAuditLog, getI18nSkillType } from '@fastgpt/service/support/user/audit/util'; import { addAuditLog, getI18nSkillType } from '@fastgpt/service/support/user/audit/util';
import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
...@@ -12,7 +12,7 @@ import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/nex ...@@ -12,7 +12,7 @@ import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/nex
import { findSandboxInstanceByAppChatType } from '@fastgpt/service/core/ai/sandbox/instance/repository'; import { findSandboxInstanceByAppChatType } from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { getSandboxProviderConfig } from '@fastgpt/service/core/ai/sandbox/provider/config'; import { getSandboxProviderConfig } from '@fastgpt/service/core/ai/sandbox/provider/config';
import { getSandboxRuntimeProfile } from '@fastgpt/service/core/ai/sandbox/runtime/profile'; import { getSandboxRuntimeProfile } from '@fastgpt/service/core/ai/sandbox/runtime/profile';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { import {
EDIT_DEBUG_SANDBOX_CHAT_ID, EDIT_DEBUG_SANDBOX_CHAT_ID,
packageSkillInSandbox packageSkillInSandbox
...@@ -63,7 +63,8 @@ async function handler(req: ApiRequestProps, res: ApiResponseType<any>) { ...@@ -63,7 +63,8 @@ async function handler(req: ApiRequestProps, res: ApiResponseType<any>) {
const runtimeProfile = getSandboxRuntimeProfile(providerConfig.provider); const runtimeProfile = getSandboxRuntimeProfile(providerConfig.provider);
const zipBuffer = await packageSkillInSandbox({ const zipBuffer = await packageSkillInSandbox({
sandboxId: sandboxInfo.sandboxId, sandboxId: sandboxInfo.sandboxId,
workDirectory: runtimeProfile.workDirectory workDirectory: runtimeProfile.workDirectory,
validationMode: 'basicZip'
}); });
const filename = `${skill.name}.zip`; const filename = `${skill.name}.zip`;
......
...@@ -21,7 +21,7 @@ import type { ...@@ -21,7 +21,7 @@ import type {
} from '@fastgpt/global/support/wallet/usage/api'; } from '@fastgpt/global/support/wallet/usage/api';
import { isProVersion } from '@fastgpt/service/common/system/constants'; import { isProVersion } from '@fastgpt/service/common/system/constants';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { hasAgentSandboxConfig, serviceEnv } from '@fastgpt/service/env'; import { serviceEnv } from '@fastgpt/service/env';
import { hasAIProxyApiEndpoint } from '@fastgpt/service/thirdProvider/aiproxy/config'; import { hasAIProxyApiEndpoint } from '@fastgpt/service/thirdProvider/aiproxy/config';
import { appEnv } from '@/env'; import { appEnv } from '@/env';
import { pluginTagList } from '@fastgpt/global/sdk/fastgpt-plugin'; import { pluginTagList } from '@fastgpt/global/sdk/fastgpt-plugin';
...@@ -169,7 +169,6 @@ export async function initSystemConfig() { ...@@ -169,7 +169,6 @@ export async function initSystemConfig() {
show_discount_coupon: appEnv.SHOW_DISCOUNT_COUPON, show_discount_coupon: appEnv.SHOW_DISCOUNT_COUPON,
show_dataset_enhance: licenseData?.functions?.datasetEnhance, show_dataset_enhance: licenseData?.functions?.datasetEnhance,
show_batch_eval: licenseData?.functions?.batchEval, show_batch_eval: licenseData?.functions?.batchEval,
show_agent_sandbox: hasAgentSandboxConfig(),
payFormUrl: appEnv.PAY_FORM_URL || '', payFormUrl: appEnv.PAY_FORM_URL || '',
agentSandboxFree: appEnv.AGENT_SANDBOX_FREE_TIP, agentSandboxFree: appEnv.AGENT_SANDBOX_FREE_TIP,
......
...@@ -40,11 +40,13 @@ const logger = getLogger(LogCategories.MODULE.DATASET.FILE_PARSE); ...@@ -40,11 +40,13 @@ const logger = getLogger(LogCategories.MODULE.DATASET.FILE_PARSE);
const requestLLMPargraph = async ({ const requestLLMPargraph = async ({
rawText, rawText,
model, model,
teamId,
billId, billId,
paragraphChunkAIMode paragraphChunkAIMode
}: { }: {
rawText: string; rawText: string;
model: string; model: string;
teamId: string;
billId: string; billId: string;
paragraphChunkAIMode?: ParagraphChunkAIModeEnum; paragraphChunkAIMode?: ParagraphChunkAIModeEnum;
}) => { }) => {
...@@ -85,6 +87,7 @@ const requestLLMPargraph = async ({ ...@@ -85,6 +87,7 @@ const requestLLMPargraph = async ({
{ {
rawText, rawText,
model, model,
teamId,
billId billId
}, },
{ timeout: 600000 } { timeout: 600000 }
...@@ -268,6 +271,7 @@ export const datasetParseQueue = async (): Promise<any> => { ...@@ -268,6 +271,7 @@ export const datasetParseQueue = async (): Promise<any> => {
const { resultText, totalInputTokens, totalOutputTokens } = await requestLLMPargraph({ const { resultText, totalInputTokens, totalOutputTokens } = await requestLLMPargraph({
rawText, rawText,
model: dataset.agentModel, model: dataset.agentModel,
teamId: String(data.teamId),
billId: data.billId, billId: data.billId,
paragraphChunkAIMode: collection.paragraphChunkAIMode paragraphChunkAIMode: collection.paragraphChunkAIMode
}); });
......
...@@ -152,13 +152,15 @@ export const streamSkillDebugChat = ({ ...@@ -152,13 +152,15 @@ export const streamSkillDebugChat = ({
data: SkillDebugChatBody; data: SkillDebugChatBody;
onMessage: StartChatFnProps['generatingMessage']; onMessage: StartChatFnProps['generatingMessage'];
abortCtrl: AbortController; abortCtrl: AbortController;
}): Promise<StreamResponseType> => }): Promise<StreamResponseType> => {
streamFetch({ const { feConfigs } = useSystemStore.getState();
url: '/api/core/ai/skill/debugChat', return streamFetch({
url: feConfigs?.isPlus ? '/api/proApi/core/ai/skill/debugChat' : '/api/core/ai/skill/debugChat',
data, data,
onMessage, onMessage,
abortCtrl abortCtrl
}); });
};
/** 创建 Skill 文件夹 */ /** 创建 Skill 文件夹 */
export const postCreateSkillFolder = (data: CreateSkillFolderBody) => export const postCreateSkillFolder = (data: CreateSkillFolderBody) =>
......
import { buildDebugRuntimeNodes } from '@/pages/api/core/ai/skill/debugChat'; import { buildDebugRuntimeNodes } from '@fastgpt/service/core/ai/skill/debugChat';
import * as debugChatApi from '@/pages/api/core/ai/skill/debugChat'; import * as debugChatApi from '@/pages/api/core/ai/skill/debugChat';
import { AgentSkillSourceEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants'; import { AgentSkillSourceEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { import {
FlowNodeTypeEnum, FlowNodeTypeEnum,
FlowNodeInputTypeEnum, FlowNodeInputTypeEnum,
...@@ -76,8 +77,8 @@ vi.mock('@fastgpt/service/support/user/team/utils', () => ({ ...@@ -76,8 +77,8 @@ vi.mock('@fastgpt/service/support/user/team/utils', () => ({
getRunningUserInfoByTmbId: debugChatMocks.getRunningUserInfoByTmbId getRunningUserInfoByTmbId: debugChatMocks.getRunningUserInfoByTmbId
})); }));
vi.mock('@/service/core/workflow/streamResponseContext', () => ({ vi.mock('@fastgpt/service/core/ai/skill/debugChat/streamResponseContext', () => ({
createWorkflowStreamResponseContext: debugChatMocks.createWorkflowStreamResponseContext createSkillDebugStreamResponseContext: debugChatMocks.createWorkflowStreamResponseContext
})); }));
// ── Constants mirrored from the implementation ── // ── Constants mirrored from the implementation ──
...@@ -483,7 +484,8 @@ describe('debugChat handler — parameter validation', () => { ...@@ -483,7 +484,8 @@ describe('debugChat handler — parameter validation', () => {
expect(debugChatMocks.dispatchWorkFlow).toHaveBeenCalledWith( expect(debugChatMocks.dispatchWorkFlow).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
chatId: 'prepared-debug-chat-id', chatId: 'prepared-debug-chat-id',
responseChatItemId: 'prepared-debug-response-id' responseChatItemId: 'prepared-debug-response-id',
agentSandboxPrepareActions: undefined
}) })
); );
expect(debugChatMocks.finalizeChatRound).toHaveBeenCalledWith( expect(debugChatMocks.finalizeChatRound).toHaveBeenCalledWith(
......
...@@ -147,6 +147,7 @@ describe('GET /api/core/ai/skill/export', () => { ...@@ -147,6 +147,7 @@ describe('GET /api/core/ai/skill/export', () => {
expect(mockJsonRes).not.toHaveBeenCalled(); expect(mockJsonRes).not.toHaveBeenCalled();
expect(skillExportMocks.packageSkillInSandboxMock).toHaveBeenCalledWith({ expect(skillExportMocks.packageSkillInSandboxMock).toHaveBeenCalledWith({
sandboxId: 'edit-sandbox-1', sandboxId: 'edit-sandbox-1',
validationMode: 'basicZip',
workDirectory: expect.any(String) workDirectory: expect.any(String)
}); });
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/zip'); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/zip');
......
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