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 {
invalidDescription = 'invalidDescription',
invalidCategory = 'invalidCategory',
invalidConfig = 'invalidConfig',
missingModel = 'missingModel',
requirementsTooLong = 'requirementsTooLong',
noStorage = 'noStorage',
noFieldsToUpdate = 'noFieldsToUpdate',
invalidArchiveFormat = 'invalidArchiveFormat',
......@@ -56,16 +54,6 @@ const skillErrList = [
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,
message: i18nT('common:code_error.skill_error.no_storage')
},
......
......@@ -15,6 +15,12 @@ export const SandboxStatusEnum = {
} as const;
export type SandboxStatusType = (typeof SandboxStatusEnum)[keyof typeof SandboxStatusEnum];
// ---- 沙盒实例类型 ----
export enum SandboxTypeEnum {
editDebug = 'edit-debug',
sessionRuntime = 'session-runtime'
}
// ---- 暂停阈值(分钟) ----
export const SANDBOX_SUSPEND_MINUTES = 10;
......@@ -35,6 +41,5 @@ export const SANDBOX_SYSTEM_PROMPT = `## 沙盒能力
- 使用 ${SANDBOX_WRITE_FILE_TOOL_NAME} 创建或覆盖文本文件
- 使用 ${SANDBOX_EDIT_FILE_TOOL_NAME} 对已有文件做精确查找替换
- 使用 ${SANDBOX_SEARCH_TOOL_NAME} 搜索沙盒内的文件路径
- 生成的文件内容保存在当前工作区即可
- 若需要把沙盒中生成的文件提供给用户下载,必须先使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 获取临时访问链接
- 最终回复中不得直接输出 sandbox:/、/workspace/、/home/devbox/workspace/、/home/user/ 等沙盒内部路径`;
- 默认将生成文件保存在当前 sandbox 工作目录;若本轮 system-reminder 指定了更具体的产物目录或禁止目录,必须优先遵守
- 若需要将生成的文件链接,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 获取临时访问链接`;
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 {
folder = 'folder',
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 {
AgentSkillSourceEnum,
AgentSkillCategoryEnum,
AgentSkillTypeEnum,
AgentSkillCreationStatusEnum,
SandboxTypeEnum
AgentSkillCreationStatusEnum
} 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 BufferSchema = z.custom<Buffer>(
......@@ -121,12 +123,6 @@ export const ExtractedSkillPackageSchema = z.object({
});
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({
state: z.string(),
message: z.string().optional(),
......
......@@ -63,7 +63,6 @@ export const CreateSkillBodySchema = z.object({
parentId: NullableParentIdSchema,
name: z.string().describe('技能名称'),
description: z.string().optional().describe('技能描述'),
requirements: z.string().optional().describe('用于 AI 生成技能的需求描述'),
category: z.array(AgentSkillCategorySchema).optional().describe('技能分类'),
avatar: z.string().optional().describe('技能头像')
});
......
......@@ -84,7 +84,7 @@ export const SkillPath: OpenAPIPath = {
'/core/ai/skill/create': {
post: {
summary: '创建技能',
description: '创建一个新的技能,可选使用 AI 根据 requirements 生成 SKILL.md',
description: '创建一个新的技能,并初始化空白 skills 工作区',
tags: [DevApiTagsMap.aiSkill],
requestBody: {
content: {
......
import { type FastGPTConfigFileType } from '@fastgpt/global/common/system/types';
import { isIPv6 } from 'net';
import { getLogger, LogCategories } from '../logger';
import { serviceEnv } from '../../env';
import { hasAgentSandboxConfig, serviceEnv } from '../../env';
const logger = getLogger(LogCategories.ERROR);
......@@ -20,6 +20,7 @@ export const initFastGPTConfig = (config?: FastGPTConfigFileType) => {
!!config.systemEnv.customPdfParse?.textinAppId ||
!!config.systemEnv.customPdfParse?.doc2xKey;
config.feConfigs.customPdfParsePrice = config.systemEnv.customPdfParse?.price || 0;
config.feConfigs.show_agent_sandbox = hasAgentSandboxConfig();
config.feConfigs.uploadFileMaxSize = serviceEnv.UPLOAD_FILE_MAX_SIZE;
config.feConfigs.uploadFileMaxAmount = serviceEnv.UPLOAD_FILE_MAX_AMOUNT;
config.feConfigs.limit = {
......
......@@ -36,7 +36,6 @@ export const getDefaultChatTitleModel = () => global?.systemDefaultModel.chatTit
export const getDefaultHelperBotModel = (): LLMModelItemType =>
global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel();
export const getSkillCreationLLMModel = () => getDefaultLLMModel().model;
export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!;
export const getEmbeddingModel = (model?: string | EmbeddingModelItemType) => {
if (!model) return getDefaultEmbeddingModel();
......
import type { SandboxStatusType } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import type { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum, type SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { MongoSandboxInstance } from './schema';
import type { SandboxInstanceSchemaType, SandboxProviderType } from '../type';
......
import { connectionMongo, getMongoModel } from '../../../../common/mongo';
const { Schema } = connectionMongo;
import type { SandboxInstanceSchemaType } from '../type';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
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 { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import type { SandboxRuntimeProfile } from './types';
import { getSandboxSkillsRootPath, mergeStringRecord, mergeUnknownRecord } from './utils';
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 { VolumeManagerResult } from '../../volume/service';
......
/** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */
export const trimSandboxPathRight = (value: string) =>
value === '/' ? '' : value.replace(/\/+$/, '');
/** 用 sandbox 语义拼接路径,避免不同 provider 工作目录末尾斜杠导致双斜杠。 */
export const joinSandboxPath = (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`;
import { joinSandboxPath } from '../utils';
/** FastGPT 约定所有 skill 包都写入运行态工作目录下的 skills 子目录。 */
export const getSandboxSkillsRootPath = (workDirectory: string) =>
joinSandboxPath(workDirectory, 'skills');
/** 内置 Skill 注入到 sandbox 用户主目录,不属于用户可编辑 workspace。 */
export const getSandboxBuiltinSkillsRootPath = (homeDirectory: string) =>
joinSandboxPath(joinSandboxPath(homeDirectory, '.fastgpt'), 'skills');
/**
* 合并环境变量时让业务场景入参覆盖已有 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';
import { getSandboxAdapterConfig } from '../provider/config';
import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle';
import { getSandboxRuntimeProfile } from '../runtime/profile';
import { joinSandboxPath } from '../runtime/profile/utils';
import { joinSandboxPath, shellQuote } from '../runtime/utils';
import {
deleteSessionVolume,
getSessionVolumeConfig,
......@@ -77,8 +77,6 @@ export interface SandboxArchiveOptions {
onProgress?: (progress: SandboxArchiveProgress) => void | Promise<void>;
}
const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
const runSandboxCommand = async (
sandbox: ISandbox,
command: string,
......
import z from 'zod';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
// ---- 沙盒实例 DB 类型 ----
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 {
updateSandboxInstanceRecordBySandboxId
} from '../../sandbox/instance/repository';
import { MongoAgentSkills } from '../model/schema';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { UserError } from '@fastgpt/global/common/error/utils';
import type { SaveDeploySkillResponse } from '@fastgpt/global/core/ai/skill/api';
......
......@@ -2,8 +2,14 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { shellQuote, joinSandboxPath, parseGitignoreRules } from '../utils';
import { downloadSkillPackage, DEFAULT_GITIGNORE_CONTENT, validateZipStructure } from '../package';
import { parseGitignoreRules } from '../utils';
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 {
getSandboxProviderConfig,
......@@ -12,7 +18,7 @@ import {
} from '../../sandbox/provider/config';
import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile';
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 {
connectReadySandboxByInstance,
connectToSandbox,
......@@ -689,8 +695,9 @@ export async function createEditDebugSandbox(
export async function packageSkillInSandbox(params: {
sandboxId: string;
workDirectory?: string;
validationMode?: 'basicZip' | 'deployableWorkspace';
}): Promise<Buffer> {
const { sandboxId, workDirectory } = params;
const { sandboxId, workDirectory, validationMode = 'deployableWorkspace' } = params;
const maxBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
const providerConfig = getSandboxProviderConfig();
......@@ -780,9 +787,14 @@ export async function packageSkillInSandbox(params: {
);
}
const validation = await validateZipStructure(zipBuffer, {
maxUncompressedBytes: maxBytes
});
const validation =
validationMode === 'deployableWorkspace'
? await validateDeployableSkillWorkspacePackage(zipBuffer, {
maxUncompressedBytes: maxBytes
})
: await validateZipStructure(zipBuffer, {
maxUncompressedBytes: maxBytes
});
if (!validation.valid) {
throw new Error(validation.error || 'Invalid skill package structure');
}
......
......@@ -22,7 +22,6 @@ export async function createSkill(data: CreateSkillData, session?: ClientSession
type: AgentSkillTypeEnum.skill,
source: AgentSkillSourceEnum.personal,
creationStatus: createData.creationStatus ?? AgentSkillCreationStatusEnum.ready,
creationPayload: createData.creationPayload,
updateTime: new Date()
});
await skill.save({ session });
......
......@@ -9,10 +9,8 @@ import { Types } from '../../../../../common/mongo';
import { mongoSessionRun } from '../../../../../common/mongo/sessionRun';
import { MongoAgentSkills } from '../../model/schema';
import { updateCurrentVersion, updateSkillCreationFailed } from '../update';
import { buildSkillMd, extractSkillNameFromSkillMd } from '../../utils';
import { generateSkillMd } from './skillMdGenerator';
import {
createSkillPackage,
createBlankSkillWorkspacePackage,
deleteSkillPackage,
removeSkillPackageTTL,
type SkillStorageInfo,
......@@ -22,8 +20,6 @@ import { createVersion } from '../../version';
import { getLogger, LogCategories } from '../../../../../common/logger';
import { getErrText } from '@fastgpt/global/common/error/utils';
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);
......@@ -31,9 +27,6 @@ export type AgentSkillCreateJobData = {
skillId: string;
teamId: string;
tmbId: string;
name: string;
description: string;
requirements?: string;
};
const agentSkillCreateQueue = getQueue<AgentSkillCreateJobData>(QueueNames.agentSkillCreate, {
......@@ -88,10 +81,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
{
_id: 1,
teamId: 1,
tmbId: 1,
name: 1,
description: 1,
creationPayload: 1
tmbId: 1
}
).lean();
......@@ -108,10 +98,7 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
return addAgentSkillCreateJob({
skillId: skill._id.toString(),
teamId: skill.teamId.toString(),
tmbId: skill.tmbId.toString(),
name: skill.name,
description: skill.description,
requirements: skill.creationPayload?.requirements
tmbId: skill.tmbId.toString()
});
})
);
......@@ -126,14 +113,14 @@ async function resumePendingSkillCreationJobs(): Promise<void> {
}
/**
* 完成一个 pending skill 的初始包生成、上传和 v0 版本绑定。
* 完成一个 pending skill 的空白初始工作区上传和 v0 版本绑定。
*
* API 先创建可见 skill 行,保证详情页拥有稳定 skillId;worker 再执行较慢的
* SKILL.md 生成、zip 打包、对象存储上传和版本初始化。失败会写回 skill 行,
* 这样刷新页面或后续访问都能看到确定的终态,而不是依赖队列状态。
* workspace zip 打包、对象存储上传和版本初始化。真正的 `skills/<name>/SKILL.md`
* 由用户或内置辅助生成 Skill 在编辑沙盒里生成,避免新建时制造一个无需求来源的同名 Skill。
*/
export async function completePendingSkillCreation(data: AgentSkillCreateJobData): Promise<void> {
const { skillId, teamId, tmbId, name, description } = data;
const { skillId, teamId, tmbId } = data;
let uploadedStorageInfo: SkillStorageInfo | undefined;
const skill = await MongoAgentSkills.findOne({
......@@ -154,38 +141,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData
}
try {
const requirements = data.requirements ?? skill.creationPayload?.requirements;
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 zipBuffer = await createBlankSkillWorkspacePackage();
const versionId = new Types.ObjectId().toString();
const storageInfo = await uploadSkillPackage({
......@@ -207,7 +163,7 @@ export async function completePendingSkillCreation(data: AgentSkillCreateJobData
versionId,
skillId,
tmbId,
versionName: 'Initial creation',
versionName: 'Initial blank workspace',
storageKey: storageInfo.key
},
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 = {
teamId: string;
tmbId: string;
creationStatus?: AgentSkillCreationStatusEnum;
creationPayload?: {
requirements?: string;
};
};
// UpdateSkillData excludes markdown to ensure consistency with version management.
......
......@@ -59,8 +59,8 @@ export async function updateCurrentVersion(
/**
* 将异步创建的 skill 标记为失败,并保留可见行用于删除和问题诊断。
*
* creationPayload 可能包含用户输入的生成要求。记录终态失败后,保留短错误文本
* 已足够支撑 UI 展示,同时避免继续保存不必要的生成输入。
* 记录终态失败后,保留短错误文本已足够支撑 UI 展示。
* `$unset.creationPayload` 仅用于清理历史版本遗留的创建期临时字段。
*/
export async function updateSkillCreationFailed({
skillId,
......
......@@ -13,17 +13,7 @@ import {
} from '@fastgpt/global/support/user/team/constant';
import type { AgentSkillSchemaType } from '@fastgpt/global/core/ai/skill/type';
/**
* Agent Skill 主表模型类型。
*
* creationPayload 只用于 AI 辅助创建阶段保存临时生成上下文,不属于发布后的
* SKILL.md 运行时元数据,因此只在 service 层模型里补充。
*/
export type MongoAgentSkillSchemaType = AgentSkillSchemaType & {
creationPayload?: {
requirements?: string;
};
};
export type MongoAgentSkillSchemaType = AgentSkillSchemaType;
const { Schema } = connectionMongo;
......@@ -104,9 +94,6 @@ const AgentSkillsSchema = new Schema({
},
creationError: {
type: String
},
creationPayload: {
requirements: String
}
});
......
......@@ -46,6 +46,19 @@ export type ZipValidationResult = {
totalUncompressedBytes?: number;
};
export type DeployableSkillWorkspaceValidationResult = {
valid: boolean;
files: string[];
error?: string;
};
type ZipSafetyValidationResult = {
valid: boolean;
files: string[];
error?: string;
totalUncompressedBytes?: number;
};
export type ExtractSkillPackageResult = {
success: boolean;
skillMd?: string;
......@@ -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 和字符串内容。
*/
function addFileToZip(zip: JSZip, path: string, content: Buffer | string | Uint8Array): void {
......@@ -139,55 +167,19 @@ export async function validateZipStructure(
): Promise<ZipValidationResult> {
try {
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 {
valid: false,
hasSkillMd: false,
files,
error: 'ZIP archive is empty'
totalUncompressedBytes: safety.totalUncompressedBytes,
error: safety.error
};
}
let totalUncompressedBytes = 0;
for (const file of Object.values(zip.files)) {
const unsafePath = file.unsafeOriginalName ?? file.name;
if (!isSafeZipEntryPath(unsafePath)) {
return {
valid: false,
hasSkillMd: false,
files,
error: `Unsafe ZIP entry path: ${unsafePath}`
};
}
if (isZipSymlink(file)) {
return {
valid: false,
hasSkillMd: 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,
hasSkillMd: false,
files,
totalUncompressedBytes,
error: 'ZIP archive uncompressed size exceeds maximum allowed size'
};
}
}
}
// 兼容根目录直接放 SKILL.md 的历史包。
let skillMdPath = files.find((f) => f.toUpperCase() === 'SKILL.MD');
......@@ -219,7 +211,7 @@ export async function validateZipStructure(
hasSkillMd: true,
files,
skillMdPath,
totalUncompressedBytes
totalUncompressedBytes: safety.totalUncompressedBytes
};
} catch (error) {
return {
......@@ -231,6 +223,165 @@ export async function validateZipStructure(
}
}
/**
* 发布/保存版本时校验可部署工作区。
*
* 这里只校验 workspace 级最小结构,不解析 SKILL.md frontmatter。创建阶段的空白初始包
* 不应调用该校验;用户主动发布时必须至少存在一个可执行 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 {
valid: false,
files,
error: safety.error
};
}
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/'
};
}
const firstLevelSkillDirs = new Set<string>();
const executableSkillDirs = new Set<string>();
for (const path of files) {
const normalized = normalizeDeployableWorkspaceEntryPath(path);
const firstLevelDirMatch = normalized.match(/^skills\/([^/]+)(?:\/|$)/);
if (firstLevelDirMatch?.[1] && normalized !== `skills/${firstLevelDirMatch[1]}`) {
firstLevelSkillDirs.add(firstLevelDirMatch[1]);
}
const skillMdMatch = normalized.match(/^skills\/([^/]+)\/SKILL\.md$/);
if (skillMdMatch?.[1]) {
executableSkillDirs.add(skillMdMatch[1]);
}
}
if (firstLevelSkillDirs.size === 0) {
return {
valid: false,
files,
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 {
valid: false,
files,
error: `Each first-level skill folder under skills/ must contain SKILL.md: ${missingSkillMdDirs.join(', ')}`
};
}
return {
valid: true,
files
};
} catch (error) {
return {
valid: false,
files: [],
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 {
if (!path || path.includes('\0')) 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 @@
*
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。
*/
import { joinSandboxPath, shellQuote } from '../sandbox/runtime/utils';
/* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */
......@@ -124,8 +125,8 @@ export type BuildSkillMdParams = {
/**
* 生成一个最小可用的 SKILL.md。
*
* 该模板只包含 frontmatter,不生成正文说明,主要用于没有 AI 辅助生成需求的
* 初次创建流程。后续用户可在 edit sandbox 中继续补充正文和其他文件。
* 该模板只包含 frontmatter,不生成正文说明。它只作为通用文本工具保留;
* 新建 Skill 的初始版本不再调用它生成默认技能文件。
*/
export function buildSkillMd(params: BuildSkillMdParams): string {
return generateFrontmatter(params.name, params.description);
......@@ -183,25 +184,10 @@ export function extractSkillNameFromSkillMd(content: string): string {
return headerMatch ? getSafeSkillDirectoryName(headerMatch[1]).toLowerCase() : 'unnamed-skill';
}
/* ==================== Shell 安全辅助 (原 shell.ts) ==================== */
/**
* 智能转义参数以防止 Shell 注入。
*/
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
/* ==================== 沙盒路径与命名清洗辅助 (自 runtime 移入) ==================== */
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 根目录。
*/
......
......@@ -283,6 +283,22 @@ const buildAgentEnvPrompt = ({
${currentTime ? `当前时间: ${currentTime}` : ''}
${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,
// 但对模型来说是回答本轮问题时可用的事实提醒。
export const buildAgentUserReminderInput = ({
......@@ -302,6 +318,7 @@ export const buildAgentUserReminderInput = ({
}) => {
const reminder = [
buildAgentSkillsPrompt(skillInfos),
buildSandboxFileWriteBoundaryPrompt({ currentWorkingDirectory }),
buildAgentInputFilesPrompt(filesInfo),
buildAgentInputDatasetsPrompt(selectedDataset),
buildAgentEnvPrompt({ currentTime, currentWorkingDirectory })
......
......@@ -34,9 +34,9 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import {
agentSandboxBootstrap,
ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus
streamAgentSandboxInitStatus,
type AgentSandboxPrepareAction
} from './sub/sandbox';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type';
......@@ -78,6 +78,7 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.sandboxEntrypoint]?: string;
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
agentSandboxPrepareActions?: AgentSandboxPrepareAction[];
};
type Response = DispatchNodeResultType<{
......@@ -138,6 +139,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
runningAppInfo,
runningUserInfo,
workflowStreamResponse,
agentSandboxPrepareActions,
usagePush,
chatId,
uid,
......@@ -223,10 +225,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
teamId: runningAppInfo.teamId,
tmbId: runningUserInfo.tmbId,
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
editSkillId,
prepareActions: agentSandboxPrepareActions,
currentFiles: userContext.currentFiles
});
// 获取请求上下文
......
......@@ -15,11 +15,7 @@ import { getLogger, LogCategories } from '../../../../../../common/logger';
import type { DispatchAgentModuleProps } from '..';
import { parseUserSystemPrompt } from '../adapter/prompt';
import { useUserContext } from '../adapter/userContext';
import {
agentSandboxBootstrap,
ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus
} from '../sub/sandbox';
import { ensureAgentSandboxRuntime, streamAgentSandboxInitStatus } from '../sub/sandbox';
import { getAgentDatasetParams, getSubapps, type ToolDispatchContext } from '../utils';
import {
createPiAgentWorkflowRuntime,
......@@ -50,6 +46,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
runningAppInfo,
runningUserInfo,
workflowStreamResponse,
agentSandboxPrepareActions,
usagePush,
chatId,
uid,
......@@ -161,10 +158,10 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
teamId: runningAppInfo.teamId,
tmbId: runningUserInfo.tmbId,
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
editSkillId,
prepareActions: agentSandboxPrepareActions,
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 { agentSandboxBootstrap } from './bootstrap';
export {
createBuiltinSkillPrepareAction,
ensureAgentSandboxRuntime,
type AgentSandboxPrepareAction
} from './prepare';
export { dispatchSandboxTool } from './tool';
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
import {
runAgentSandboxEntrypoint,
withAgentSandboxInitLease
} from '../../../../../ai/skill/runtime/entrypoint';
} from '../../../../../ai/sandbox/runtime/entrypoint';
import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime';
import type { FileInputType, ToolNodeItemType } from '../type';
import { ReadFileTooData, ReadFileToolSchema } from '../tools/file';
......
......@@ -83,6 +83,7 @@ import {
type WorkflowObservedStepResult
} from './utils/trace';
import { getWorkflowNodeRunParams } from './utils/runtime';
import type { AgentSandboxPrepareAction } from './ai/agent/sub/sandbox';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH);
......@@ -102,6 +103,7 @@ type Props = Omit<
req?: IncomingMessage;
defaultSkipNodeQueue?: WorkflowDebugResponse['skipNodeQueue'];
nodeResponseWriteConfig: WorkflowNodeResponseWriteConfig;
agentSandboxPrepareActions?: AgentSandboxPrepareAction[];
};
type NodeResponseType = DispatchNodeResultType<{
[key: string]: any;
......
import { beforeEach, describe, expect, it } from 'vitest';
import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/instance/schema';
import {
buildSandboxInstanceLookup,
countRunningSandboxInstancesByType,
createSandboxResourcesToArchiveCursor,
deleteSandboxInstanceRecord,
......@@ -28,8 +27,7 @@ import {
upsertRunningSandboxInstance,
type SandboxResourceDoc
} from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
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', () => ({
vi.mock('@fastgpt/service/core/ai/skill/package', () => ({
downloadSkillPackage: vi.fn(),
DEFAULT_GITIGNORE_CONTENT: '.venv/\nnode_modules/\n',
validateDeployableSkillWorkspacePackage: vi.fn(async () => ({
valid: true,
files: []
})),
validateZipStructure: vi.fn(async () => ({
valid: true,
hasSkillMd: true,
......@@ -133,7 +137,11 @@ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/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 {
createEditDebugSandbox,
packageSkillInSandbox
......@@ -212,12 +220,36 @@ describe('packageSkillInSandbox', () => {
expect(sandbox.readFiles).toHaveBeenCalledWith(['/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
});
expect(validateZipStructure).not.toHaveBeenCalled();
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 () => {
const zipContent = new Uint8Array(1024 * 1024 + 1);
const sandbox = createSandbox({
......@@ -235,6 +267,7 @@ describe('packageSkillInSandbox', () => {
'Skill package size'
);
expect(validateDeployableSkillWorkspacePackage).not.toHaveBeenCalled();
expect(validateZipStructure).not.toHaveBeenCalled();
expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'");
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox);
......
import { describe, expect, it, vi } from 'vitest';
import {
runAgentSandboxEntrypoint,
runAgentSkillVersionEntrypoints
} from '@fastgpt/service/core/ai/skill/runtime/entrypoint';
import { runAgentSandboxEntrypoint } from '@fastgpt/service/core/ai/sandbox/runtime/entrypoint';
import { runAgentSkillVersionEntrypoints } from '@fastgpt/service/core/ai/skill/runtime/entrypoint';
import type { DeployedSkillVersion } from '@fastgpt/service/core/ai/skill/runtime';
type ExecuteResult = {
......@@ -28,7 +26,7 @@ const createSandbox = ({
if (command === 'printf "%s" "$HOME"') {
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: '' };
}
if (command.startsWith("[ -f '/workspace/projects/version-1/entrypoint.sh' ]")) {
......@@ -95,7 +93,7 @@ describe('runtime entrypoint', () => {
sandbox: sandbox as any,
sandboxEntrypoint: 'echo first'
});
const firstHash = sandbox.getState()?.sandboxEntrypointHash;
const firstHash = sandbox.getState()?.hashes?.sandboxEntrypoint;
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
......@@ -111,8 +109,8 @@ describe('runtime entrypoint', () => {
.filter(isSandboxEntrypointCommand);
expect(entrypointCommands).toHaveLength(2);
expect(sandbox.getState()?.sandboxEntrypointHash).toMatch(/^sha256:/);
expect(sandbox.getState()?.sandboxEntrypointHash).not.toBe(firstHash);
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toMatch(/^sha256:/);
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).not.toBe(firstHash);
});
it('runs sandbox entrypoint from the configured work directory', async () => {
......@@ -138,7 +136,7 @@ describe('runtime entrypoint', () => {
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 () => {
......@@ -150,7 +148,7 @@ describe('runtime entrypoint', () => {
sandboxEntrypoint: 'echo throw'
})
).resolves.toBeUndefined();
expect(sandbox.getState()?.sandboxEntrypointHash).toBeUndefined();
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toBeUndefined();
});
it('uses skill version state to skip successful skill entrypoints', async () => {
......@@ -170,7 +168,7 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand);
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 () => {
......@@ -190,6 +188,6 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand);
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 }>) =>
const LIST_VERSION_DIRS_COMMAND =
"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', () => {
it('scans every recursive skill.md from every selected version directory', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-scan-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const [skill1, skill2] = await MongoAgentSkills.create([
{
......@@ -233,6 +234,7 @@ description: Zeta skill
sandbox: sandbox as any,
skillIds: [String(skill1._id), String(skill2._id)],
teamId,
tmbId,
workDirectory: '/workspace'
});
const result = await getAgentSkillInfos({
......@@ -245,8 +247,12 @@ description: Zeta skill
const writtenFilePaths = sandbox.writeFiles.mock.calls[0][0].map(
(entry: { path: string }) => entry.path
);
expect(writtenFilePaths[0]).toContain(`/workspace/projects/.tmp-${String(skill1VersionId)}`);
expect(writtenFilePaths[1]).toContain(`/workspace/projects/.tmp-${String(skill2VersionId)}`);
expect(writtenFilePaths).toEqual(
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);
const unzipCommands = sandbox.execute.mock.calls
.map(([command]) => command)
......@@ -266,9 +272,7 @@ description: Zeta skill
.filter((command) => command.includes('-iname "SKILL.md"'));
expect(findSkillCommands.some((c) => c.includes(`'${skill1TargetDir}'`))).toBe(true);
expect(findSkillCommands.some((c) => c.includes(`'${skill2TargetDir}'`))).toBe(true);
expect(findSkillCommands).not.toContain(
`find '/workspace' -iname "SKILL.md" -print0 2>/dev/null`
);
expect(findSkillCommands).not.toContain(WORKSPACE_SKILL_INFO_FIND_COMMAND);
expect(result).toHaveLength(6);
expect(result.map((item) => item.name)).toEqual(
expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'])
......@@ -286,8 +290,8 @@ description: Zeta skill
});
it('deploys every selected current version into version directories', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-deploy-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const [existingSkill, missingSkill] = await MongoAgentSkills.create([
{
......@@ -427,6 +431,7 @@ description: Missing skill
sandbox: sandbox as any,
skillIds: [String(existingSkill._id), String(missingSkill._id)],
teamId,
tmbId,
workDirectory: '/workspace'
});
const result = await getAgentSkillInfos({
......@@ -456,8 +461,8 @@ description: Missing skill
});
it('uses the version pointed to by skill.currentVersionId', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-current-version-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({
name: 'MultiActive',
......@@ -565,6 +570,7 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
tmbId,
workDirectory: '/workspace'
});
const result = await getAgentSkillInfos({
......@@ -713,8 +719,8 @@ description: Latest current skill
});
it('skips existing current version directories and removes unselected version directories', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-cached-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({
name: 'CachedVersion',
......@@ -785,6 +791,7 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
tmbId,
workDirectory: '/workspace'
});
......@@ -800,8 +807,8 @@ description: Latest current skill
});
it('throws when a skill package file fails to write', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-write-fail-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const skill = await MongoAgentSkills.create({
name: 'Broken',
......@@ -879,6 +886,7 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
tmbId,
workDirectory: '/workspace'
})
).rejects.toThrow('Failed to write skill ZIP packages: write failed');
......@@ -887,8 +895,8 @@ description: Latest current skill
});
it('returns empty array when skills are invalid/deleted or missing current version', async () => {
const teamId = new Types.ObjectId().toHexString();
const tmbId = new Types.ObjectId().toHexString();
const user = await getUser(`runtime-skill-empty-${getNanoid(6)}`);
const { teamId, tmbId } = user;
const sandbox = {
writeFiles: vi.fn(),
......@@ -909,6 +917,7 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [new Types.ObjectId().toHexString()],
teamId,
tmbId,
workDirectory: '/workspace'
});
expect(resultNoSkills).toEqual([]);
......@@ -925,12 +934,14 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [String(skill._id)],
teamId,
tmbId,
workDirectory: '/workspace'
});
expect(resultNoVersion).toEqual([]);
});
it('cleans stale version directories when no skills are selected', async () => {
const tmbId = new Types.ObjectId().toHexString();
const sandbox = {
writeFiles: vi.fn(),
execute: vi.fn(async (command: string) => {
......@@ -956,6 +967,7 @@ description: Latest current skill
sandbox: sandbox as any,
skillIds: [],
teamId: new Types.ObjectId().toHexString(),
tmbId,
workDirectory: '/workspace'
});
......@@ -1027,9 +1039,7 @@ description: Write reports
sandbox: sandbox as any
});
expect(sandbox.execute).toHaveBeenCalledWith(
`find '/workspace' -iname "SKILL.md" -print0 2>/dev/null`
);
expect(sandbox.execute).toHaveBeenCalledWith(WORKSPACE_SKILL_INFO_FIND_COMMAND);
expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/Report/SKILL.md']);
expect(skillInfos).toEqual([
{
......
import { describe, expect, it } from 'vitest';
import JSZip from 'jszip';
import {
createBlankSkillWorkspacePackage,
createSkillPackage,
validateDeployableSkillWorkspacePackage,
validateZipStructure,
extractSkillPackage,
standardizeSkillPackageBySkillMdName
......@@ -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 ====================
describe('validateZipStructure', () => {
it('should validate zip with SKILL.md at root', async () => {
......@@ -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 ====================
describe('extractSkillPackage', () => {
it('should extract SKILL.md from zip root', async () => {
......
......@@ -343,6 +343,19 @@ describe('buildAgentUserReminderInput', () => {
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', () => {
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) => {
};
});
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
}));
......
......@@ -199,7 +199,7 @@
"sandbox_status_failed": "Sandbox creation failed",
"sandbox_status_failed_with_message": "Sandbox creation failed: {{message}}",
"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_warm": "Sandbox is ready (warm start)",
"sandbox_status_uploadingPackage": "Uploading skill package to sandbox...",
......
......@@ -177,11 +177,9 @@
"code_error.skill_error.invalid_package": "Invalid skill package structure",
"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_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_storage": "Skill has no storage, cannot copy",
"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.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
......
......@@ -16,10 +16,6 @@
"skill_avatar_and_name": "Avatar & Name",
"skill_intro_label": "App Introduction",
"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_select_file": "Select a Skill archive",
"import_skill_file_type_tip": "Supports {{ext}} formats",
......
......@@ -199,7 +199,7 @@
"sandbox_status_failed": "沙箱创建失败",
"sandbox_status_failed_with_message": "沙箱创建失败:{{message}}",
"sandbox_status_fetchSkills": "正在获取技能信息...",
"sandbox_status_lazyInit": "虚拟机启动中...",
"sandbox_status_lazyInit": "虚拟机运行中...",
"sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_uploadingPackage": "正在上传技能包到沙箱...",
......
......@@ -177,11 +177,9 @@
"code_error.skill_error.invalid_package": "无效的技能包结构",
"code_error.skill_error.invalid_skill_id": "无效的技能 ID",
"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_storage": "技能没有存储,无法复制",
"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.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
......
......@@ -2,7 +2,7 @@
"search_skill": "搜索",
"create_skill": "新建技能",
"create_your_first_skill": "创建你的第一个技能",
"no_skills": "暂无技能",
"no_skills": "暂无 Skill",
"copy_skill": "创建副本",
"confirm_delete_title": "确定删除该技能吗?",
"confirm_delete_with_refs": "该技能当前正被<bold>{{count}}个应用</bold>引用。删除后,相关应用将<bold>无法调用此技能</bold>。建议先解除关联或备份配置。",
......@@ -12,14 +12,10 @@
"permission_settings": "权限设置",
"export_config": "导出配置",
"unnamed_skill": "未命名",
"skill_name_placeholder": "请输入技能名称",
"skill_name_placeholder": "请输入 Skill 名称",
"skill_avatar_and_name": "头像 & 名称",
"skill_intro_label": "应用介绍",
"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_select_file": "上传技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
......@@ -41,43 +37,43 @@
"deploy_failed": "发布失败",
"copy_skill_confirm": "系统将为您创建一个相同配置技能,但权限不会进行复制,请确认!",
"history_versions": "历史版本",
"select_skill": "选择技能",
"associated_skills": "关联技能",
"select_skill": "选择 Skill",
"associated_skills": "关联 Skill",
"skill_deleted": "技能已删除",
"skill_deleted_click_remove_tip": "技能已删除,点击删除",
"skill_select_limit_tip": "已达到单个应用可关联技能的上限(100 个)",
"sandbox_auto_enabled_for_skill": "技能运行依赖虚拟机环境,已为你打开虚拟机功能",
"sandbox_disable_blocked_toast": "技能运行依赖虚拟机环境,当前 Agent 已配置技能,请先移除所有技能再关闭虚拟机",
"skill_select_limit_tip": "已达到单个应用可关联 Skill 的上限(100 个)",
"sandbox_auto_enabled_for_skill": "skill运行依赖虚拟机环境,已为你打开虚拟机功能",
"sandbox_disable_blocked_toast": "Skill运行依赖虚拟机环境,当前 Agent 已配置 Skill,请先移除所有 Skill 再关闭虚拟机",
"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_content": "技能操作依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。",
"sandbox_operation_system_not_configured_content": "skill操作依赖虚拟机环境。当前系统未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。",
"sandbox_plan_not_supported_title": "套餐不支持功能",
"sandbox_skill_plan_not_supported_content": "技能运行依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_operation_plan_not_supported_content": "技能操作依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_skill_plan_not_supported_content": "skill运行依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_operation_plan_not_supported_content": "skill操作依赖虚拟机环境。当前套餐不支持虚拟机功能,请升级套餐后继续使用。",
"sandbox_upgrade_action": "去升级",
"sandbox_unavailable_tag": "不可用",
"sandbox_skill_unavailable_toast": "技能运行依赖虚拟机环境,当前 Agent 已配置技能,请先移除所有技能再关闭虚拟机",
"sandbox_skill_unavailable_toast": "Skill运行依赖虚拟机环境,当前 Agent 已配置 Skill,请先移除所有 Skill 再关闭虚拟机",
"sandbox_checking": "正在检查现有沙箱环境...",
"sandbox_connecting": "正在连接沙箱环境...",
"sandbox_fetch_skills": "正在获取技能配置信息...",
"sandbox_fetch_skills": "正在获取 Skill 配置信息...",
"sandbox_creating_container": "正在初始化云端沙箱...",
"sandbox_deploying_skills": "正在部署技能: {{skillName}}...",
"sandbox_downloading": "正在下载技能包...",
"sandbox_uploading": "正在上传技能包到沙箱...",
"sandbox_extracting": "正在解压技能包...",
"sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...",
"sandbox_downloading": "正在下载 Skill 包...",
"sandbox_uploading": "正在上传 Skill 包到沙箱...",
"sandbox_extracting": "正在解压 Skill 包...",
"sandbox_lazy_init": "正在初始化运行环境...",
"sandbox_ready": "沙箱环境就绪",
"sandbox_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_failed": "沙箱创建失败: {{message}}",
"sandbox_retry": "重试",
"sandbox_error_title": "沙箱创建失败",
"no_current_version": "技能暂无可用版本,请重新创建或导入后再编辑。",
"permission.des.read": "可查看 Agent 技能",
"permission.des.write": "可编辑 Agent 技能",
"permission.des.manage": "可管理 Agent 技能和协作者",
"empty_state_tip": "告诉 AI 如何修改技能,\n或让 AI 运行技能查看效果",
"empty_state_community_prefix": "通过对话预览技能效果,",
"no_current_version": "Skill 暂无可用版本,请重新创建或导入后再编辑。",
"permission.des.read": "可查看 Agent Skill",
"permission.des.write": "可编辑 Agent Skill",
"permission.des.manage": "可管理 Agent Skill 和协作者",
"empty_state_tip": "告诉 AI 如何修改 Skill,\n或让 AI 运行 Skill 查看效果",
"empty_state_community_prefix": "通过对话预览 Skill 效果,",
"empty_state_community_upgrade": "升级商业版",
"empty_state_community_suffix": "可使用 AI 生成技能。"
"empty_state_community_suffix": "可使用 AI 生成 Skill。"
}
......@@ -196,7 +196,7 @@
"sandbox_status_failed": "沙箱創建失敗",
"sandbox_status_failed_with_message": "沙箱創建失敗:{{message}}",
"sandbox_status_fetchSkills": "正在獲取技能資訊...",
"sandbox_status_lazyInit": "虛擬機啟動中...",
"sandbox_status_lazyInit": "虛擬機運行中...",
"sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...",
......
......@@ -175,11 +175,9 @@
"code_error.skill_error.invalid_package": "無效的技能包結構",
"code_error.skill_error.invalid_skill_id": "無效的技能 ID",
"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_storage": "技能沒有存儲,無法複製",
"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.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
......
......@@ -2,7 +2,7 @@
"search_skill": "搜尋",
"create_skill": "新建技能",
"create_your_first_skill": "創建你的第一個技能",
"no_skills": "暫無技能",
"no_skills": "暫無 Skill",
"copy_skill": "建立副本",
"confirm_delete_title": "確定刪除該技能嗎?",
"confirm_delete_with_refs": "該技能當前正被<bold>{{count}}個應用</bold>引用。刪除後,相關應用將<bold>無法調用此技能</bold>。建議先解除關聯或備份配置。",
......@@ -12,22 +12,18 @@
"permission_settings": "權限設置",
"export_config": "導出配置",
"unnamed_skill": "未命名",
"skill_name_placeholder": "請輸入技能名稱",
"skill_name_placeholder": "請輸入 Skill 名稱",
"skill_avatar_and_name": "頭像 & 名稱",
"skill_intro_label": "應用介紹",
"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_select_file": "上傳技能",
"import_skill_file_type_tip": "支持 {{ext}} 格式",
"import_skill_max_size_tip": "單次最多上傳 {{maxCount}} 個文件,單個文件最大 {{maxSize}}",
"unsupported_file_format": "不支持 {{ext}} 文件格式",
"skill_info_edit": "技能資訊編輯",
"move_skill": "移動技能",
"move_skill_hint": "移動後,所選技能/文件夾將繼承新文件夾的權限設置。",
"move_skill": "移動 Skill",
"move_skill_hint": "移動後,所選 Skill/文件夾將繼承新文件夾的權限設置。",
"delete_success": "刪除成功",
"delete_failed": "刪除失敗",
"copy_success": "複製成功",
......@@ -41,43 +37,43 @@
"deploy_failed": "發布失敗",
"copy_skill_confirm": "系統將為您創建一個相同配置技能,但權限不會進行複製,請確認!",
"history_versions": "歷史版本",
"select_skill": "選擇技能",
"associated_skills": "關聯技能",
"select_skill": "選擇 Skill",
"associated_skills": "關聯 Skill",
"skill_deleted": "技能已刪除",
"skill_deleted_click_remove_tip": "技能已刪除,點擊刪除",
"skill_select_limit_tip": "已達到單個應用可關聯技能的上限(100 個)",
"sandbox_auto_enabled_for_skill": "技能運行依賴虛擬機器環境,已為你開啟虛擬機器功能",
"sandbox_disable_blocked_toast": "技能運行依賴虛擬機器環境,目前 Agent 已配置技能,請先移除所有技能再關閉虛擬機器",
"skill_select_limit_tip": "已達到單個應用可關聯 Skill 的上限(100 個)",
"sandbox_auto_enabled_for_skill": "skill運行依賴虛擬機器環境,已為你開啟虛擬機器功能",
"sandbox_disable_blocked_toast": "Skill運行依賴虛擬機器環境,目前 Agent 已配置 Skill,請先移除所有 Skill 再關閉虛擬機器",
"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_content": "技能操作依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。",
"sandbox_operation_system_not_configured_content": "skill操作依賴虛擬機器環境。目前系統未配置虛擬機器,暫時無法使用相關功能,請聯絡管理員配置。",
"sandbox_plan_not_supported_title": "套餐不支援功能",
"sandbox_skill_plan_not_supported_content": "技能運行依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_operation_plan_not_supported_content": "技能操作依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_skill_plan_not_supported_content": "skill運行依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_operation_plan_not_supported_content": "skill操作依賴虛擬機器環境。目前套餐不支援虛擬機器功能,請升級套餐後繼續使用。",
"sandbox_upgrade_action": "去升級",
"sandbox_unavailable_tag": "不可用",
"sandbox_skill_unavailable_toast": "技能運行依賴虛擬機器環境,目前 Agent 已配置技能,請先移除所有技能再關閉虛擬機器",
"sandbox_skill_unavailable_toast": "Skill運行依賴虛擬機器環境,目前 Agent 已配置 Skill,請先移除所有 Skill 再關閉虛擬機器",
"sandbox_checking": "正在檢查現有沙箱環境...",
"sandbox_connecting": "正在連接沙箱環境...",
"sandbox_fetch_skills": "正在獲取技能配置資訊...",
"sandbox_fetch_skills": "正在獲取 Skill 配置資訊...",
"sandbox_creating_container": "正在初始化雲端沙箱...",
"sandbox_deploying_skills": "正在部署技能: {{skillName}}...",
"sandbox_downloading": "正在下載技能包...",
"sandbox_uploading": "正在上傳技能包到沙箱...",
"sandbox_extracting": "正在解壓技能包...",
"sandbox_deploying_skills": "正在部署 Skill: {{skillName}}...",
"sandbox_downloading": "正在下載 Skill 包...",
"sandbox_uploading": "正在上傳 Skill 包到沙箱...",
"sandbox_extracting": "正在解壓 Skill 包...",
"sandbox_lazy_init": "正在初始化運行環境...",
"sandbox_ready": "沙箱環境就緒",
"sandbox_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_failed": "沙箱創建失敗: {{message}}",
"sandbox_retry": "重試",
"sandbox_error_title": "沙箱創建失敗",
"no_current_version": "技能暫無可用版本,請重新建立或匯入後再編輯。",
"permission.des.read": "可查看 Agent 技能",
"permission.des.write": "可編輯 Agent 技能",
"permission.des.manage": "可管理 Agent 技能和協作者",
"empty_state_tip": "告訴 AI 如何修改技能,\n或讓 AI 運行技能查看效果",
"empty_state_community_prefix": "透過對話預覽技能效果,",
"no_current_version": "Skill 暫無可用版本,請重新建立或匯入後再編輯。",
"permission.des.read": "可查看 Agent Skill",
"permission.des.write": "可編輯 Agent Skill",
"permission.des.manage": "可管理 Agent Skill 和協作者",
"empty_state_tip": "告訴 AI 如何修改 Skill,\n或讓 AI 運行 Skill 查看效果",
"empty_state_community_prefix": "透過對話預覽 Skill 效果,",
"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';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import { useTranslation } from 'next-i18next';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar';
......@@ -20,7 +18,6 @@ type FormType = {
avatar: string;
name: string;
intro?: string;
requirement: string;
};
type Props = {
......@@ -36,13 +33,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
defaultValues: {
avatar: DEFAULT_SKILL_AVATAR,
name: '',
intro: '',
requirement: t('skill:skill_requirement_default')
intro: ''
}
});
const avatar = useWatch({ control, name: 'avatar' });
const requirement = useWatch({ control, name: 'requirement' });
const { Component: AvatarUploader, handleFileSelectorOpen: handleAvatarSelectorOpen } =
useUploadAvatar(getUploadAvatarPresignedUrl, {
......@@ -52,19 +47,11 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
});
const { runAsync: onCreate, loading: isCreating } = useRequest(
async ({ avatar, name, intro, requirement }: FormType) => {
const trimmedRequirement = requirement.trim();
const defaultRequirement = t('skill:skill_requirement_default').trim();
const resolvedRequirement =
trimmedRequirement && trimmedRequirement !== defaultRequirement
? trimmedRequirement
: undefined;
async ({ avatar, name, intro }: FormType) => {
return postCreateSkill({
parentId: parentId ?? null,
name: name.trim(),
description: intro?.trim() || undefined,
requirements: resolvedRequirement,
avatar: avatar || undefined
});
},
......@@ -140,56 +127,6 @@ const CreateSkillModal = ({ parentId, onClose }: Props) => {
resize={'vertical'}
/>
</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>
</MyModal>
<AvatarUploader />
......
......@@ -10,7 +10,7 @@ import { getLogger } from '@fastgpt/service/common/logger';
import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import { getConfiguredSandboxProvider } from '@fastgpt/service/core/ai/sandbox/provider/config';
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 z from 'zod';
......
......@@ -9,7 +9,7 @@ import {
type SandboxCheckExistResponse
} from '@fastgpt/global/openapi/core/ai/sandbox/api';
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> {
if (!global.feConfigs?.show_agent_sandbox) {
......
......@@ -29,7 +29,6 @@ import { getS3AvatarSource } from '@fastgpt/service/common/s3/sources/avatar';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { getSkillCreationLLMModel } from '@fastgpt/service/core/ai/model';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION);
......@@ -39,14 +38,12 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
parentId,
name,
description,
requirements,
category = [],
avatar
} = parseApiInput({ req, bodySchema: CreateSkillBodySchema }).body;
const requestedName = name.trim();
const requestedDescription = description?.trim() || '';
const requestedRequirements = requirements?.trim() || undefined;
// Authenticate user: if parentId exists, verify parent folder permission
const { teamId, tmbId } = parentId
......@@ -74,13 +71,6 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
if (requestedDescription.length > 500) {
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[];
if (category.length > 0 && category.some((c) => !validCategories.includes(c))) {
return Promise.reject(SkillErrEnum.invalidCategory);
......@@ -98,8 +88,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
avatar,
teamId,
tmbId,
creationStatus: AgentSkillCreationStatusEnum.creating,
creationPayload: requestedRequirements ? { requirements: requestedRequirements } : undefined
creationStatus: AgentSkillCreationStatusEnum.creating
},
session
);
......@@ -123,10 +112,7 @@ async function handler(req: ApiRequestProps<CreateSkillBody>): Promise<CreateSki
const createJobData = {
skillId,
teamId,
tmbId,
name: requestedName,
description: requestedDescription,
requirements: requestedRequirements
tmbId
};
try {
......
......@@ -4,7 +4,7 @@ import { authSkill } from '@fastgpt/service/support/permission/skill/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { ExportSkillQuerySchema } from '@fastgpt/global/openapi/core/ai/skill/api';
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 { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
......@@ -12,7 +12,7 @@ import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/nex
import { findSandboxInstanceByAppChatType } from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { getSandboxProviderConfig } from '@fastgpt/service/core/ai/sandbox/provider/config';
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 {
EDIT_DEBUG_SANDBOX_CHAT_ID,
packageSkillInSandbox
......@@ -63,7 +63,8 @@ async function handler(req: ApiRequestProps, res: ApiResponseType<any>) {
const runtimeProfile = getSandboxRuntimeProfile(providerConfig.provider);
const zipBuffer = await packageSkillInSandbox({
sandboxId: sandboxInfo.sandboxId,
workDirectory: runtimeProfile.workDirectory
workDirectory: runtimeProfile.workDirectory,
validationMode: 'basicZip'
});
const filename = `${skill.name}.zip`;
......
......@@ -21,7 +21,7 @@ import type {
} from '@fastgpt/global/support/wallet/usage/api';
import { isProVersion } from '@fastgpt/service/common/system/constants';
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 { appEnv } from '@/env';
import { pluginTagList } from '@fastgpt/global/sdk/fastgpt-plugin';
......@@ -169,7 +169,6 @@ export async function initSystemConfig() {
show_discount_coupon: appEnv.SHOW_DISCOUNT_COUPON,
show_dataset_enhance: licenseData?.functions?.datasetEnhance,
show_batch_eval: licenseData?.functions?.batchEval,
show_agent_sandbox: hasAgentSandboxConfig(),
payFormUrl: appEnv.PAY_FORM_URL || '',
agentSandboxFree: appEnv.AGENT_SANDBOX_FREE_TIP,
......
......@@ -40,11 +40,13 @@ const logger = getLogger(LogCategories.MODULE.DATASET.FILE_PARSE);
const requestLLMPargraph = async ({
rawText,
model,
teamId,
billId,
paragraphChunkAIMode
}: {
rawText: string;
model: string;
teamId: string;
billId: string;
paragraphChunkAIMode?: ParagraphChunkAIModeEnum;
}) => {
......@@ -85,6 +87,7 @@ const requestLLMPargraph = async ({
{
rawText,
model,
teamId,
billId
},
{ timeout: 600000 }
......@@ -268,6 +271,7 @@ export const datasetParseQueue = async (): Promise<any> => {
const { resultText, totalInputTokens, totalOutputTokens } = await requestLLMPargraph({
rawText,
model: dataset.agentModel,
teamId: String(data.teamId),
billId: data.billId,
paragraphChunkAIMode: collection.paragraphChunkAIMode
});
......
......@@ -152,13 +152,15 @@ export const streamSkillDebugChat = ({
data: SkillDebugChatBody;
onMessage: StartChatFnProps['generatingMessage'];
abortCtrl: AbortController;
}): Promise<StreamResponseType> =>
streamFetch({
url: '/api/core/ai/skill/debugChat',
}): Promise<StreamResponseType> => {
const { feConfigs } = useSystemStore.getState();
return streamFetch({
url: feConfigs?.isPlus ? '/api/proApi/core/ai/skill/debugChat' : '/api/core/ai/skill/debugChat',
data,
onMessage,
abortCtrl
});
};
/** 创建 Skill 文件夹 */
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 { 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 {
FlowNodeTypeEnum,
FlowNodeInputTypeEnum,
......@@ -76,8 +77,8 @@ vi.mock('@fastgpt/service/support/user/team/utils', () => ({
getRunningUserInfoByTmbId: debugChatMocks.getRunningUserInfoByTmbId
}));
vi.mock('@/service/core/workflow/streamResponseContext', () => ({
createWorkflowStreamResponseContext: debugChatMocks.createWorkflowStreamResponseContext
vi.mock('@fastgpt/service/core/ai/skill/debugChat/streamResponseContext', () => ({
createSkillDebugStreamResponseContext: debugChatMocks.createWorkflowStreamResponseContext
}));
// ── Constants mirrored from the implementation ──
......@@ -483,7 +484,8 @@ describe('debugChat handler — parameter validation', () => {
expect(debugChatMocks.dispatchWorkFlow).toHaveBeenCalledWith(
expect.objectContaining({
chatId: 'prepared-debug-chat-id',
responseChatItemId: 'prepared-debug-response-id'
responseChatItemId: 'prepared-debug-response-id',
agentSandboxPrepareActions: undefined
})
);
expect(debugChatMocks.finalizeChatRound).toHaveBeenCalledWith(
......
......@@ -147,6 +147,7 @@ describe('GET /api/core/ai/skill/export', () => {
expect(mockJsonRes).not.toHaveBeenCalled();
expect(skillExportMocks.packageSkillInSandboxMock).toHaveBeenCalledWith({
sandboxId: 'edit-sandbox-1',
validationMode: 'basicZip',
workDirectory: expect.any(String)
});
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